diff --git a/README.md b/README.md index bd49b34..90582b2 100644 --- a/README.md +++ b/README.md @@ -21,15 +21,22 @@ ```tsx // App.jsx import React from "react"; -import { Group, Rect, Text } from "react-tela"; +import { Group, Rect, Text, useDimensions } from "react-tela"; + +function Contents() { + const dims = useDimensions(); + return <> + + + Hello world! + + ; +} export function App() { return ( - - - Hello world! - + ); } diff --git a/biome.json b/biome.json index 59493f8..ac7221f 100644 --- a/biome.json +++ b/biome.json @@ -1,6 +1,6 @@ { "linter": { - "enabled": true + "enabled": false }, "javascript": { "formatter": { diff --git a/src/canvas.ts b/src/canvas.ts index 5c79ed7..160b260 100644 --- a/src/canvas.ts +++ b/src/canvas.ts @@ -9,10 +9,7 @@ export class Canvas extends Entity { constructor(opts: CanvasProps, root: Root) { super(opts); - this.subcanvas = new root.Canvas( - this.calculatedWidth, - this.calculatedHeight, - ); + this.subcanvas = new root.Canvas(this.width, this.height); } getContext(...args: Parameters) { @@ -21,12 +18,6 @@ export class Canvas extends Entity { render(): void { super.render(); - this.root.ctx.drawImage( - this.subcanvas, - 0, - 0, - this.calculatedWidth, - this.calculatedHeight, - ); + this.root.ctx.drawImage(this.subcanvas, 0, 0, this.width, this.height); } } diff --git a/src/entity.ts b/src/entity.ts index f06a392..95bdc2c 100644 --- a/src/entity.ts +++ b/src/entity.ts @@ -1,25 +1,24 @@ import { TelaEventTarget } from './event-target.js'; -import { parsePercent } from './util.js'; import type { Root } from './root.js'; -import type { PercentageString, TelaMouseEvent } from './types.js'; +import type { TelaMouseEvent } from './types.js'; export type EntityProps = { /** * The x (horizontal) coordinate of the entity from the top-left corner of the context. */ - x?: number | PercentageString; + x?: number; /** * The y (vertical) coordinate of the entity from the top-left corner of the context. */ - y?: number | PercentageString; + y?: number; /** - * The height of the entity in pixels. + * The width of the entity in pixels. */ - width?: number | PercentageString; + width?: number; /** * The height of the entity in pixels. */ - height?: number | PercentageString; + height?: number; /** * The alpha transparency value of the entity. The value `0` is fully transparent. The value `1` is fully opaque. * @@ -62,10 +61,10 @@ export type EntityProps = { }; export class Entity extends TelaEventTarget { - x: number | PercentageString; - y: number | PercentageString; - width: number | PercentageString; - height: number | PercentageString; + x: number; + y: number; + width: number; + height: number; alpha: number; rotate: number; scaleX?: number; @@ -123,41 +122,19 @@ export class Entity extends TelaEventTarget { } get calculatedX() { - let { x } = this; - if (typeof x !== 'number') { - x = this.root.ctx.canvas.width * parsePercent(x); - } - return x + this.calculatedWidth / 2; + return this.x + this.width / 2; } get calculatedY() { - let { y } = this; - if (typeof y !== 'number') { - y = this.root.ctx.canvas.height * parsePercent(y); - } - return y + this.calculatedHeight / 2; - } - - get calculatedWidth() { - if (typeof this.width === 'number') { - return this.width; - } - return this.root.ctx.canvas.width * parsePercent(this.width); - } - - get calculatedHeight() { - if (typeof this.height === 'number') { - return this.height; - } - return this.root.ctx.canvas.height * parsePercent(this.height); + return this.y + this.height / 2; } get offsetX() { - return -this.calculatedWidth / 2; + return -this.width / 2; } get offsetY() { - return -this.calculatedHeight / 2; + return -this.height / 2; } get matrix() { @@ -191,7 +168,7 @@ export class Entity extends TelaEventTarget { get path() { const p = new this.root.Path2D(); - p.rect(0, 0, this.calculatedWidth, this.calculatedHeight); + p.rect(0, 0, this.width, this.height); return p; } diff --git a/src/group.ts b/src/group.ts index 303ab35..b9a923f 100644 --- a/src/group.ts +++ b/src/group.ts @@ -3,10 +3,7 @@ import { Entity, EntityProps } from './entity.js'; import { proxyEvents } from './events.js'; import type { ICanvasRenderingContext2D } from './types.js'; -export interface GroupProps extends Omit { - width: number; - height: number; -} +export interface GroupProps extends EntityProps {} export class Group extends Entity { subroot: Root; @@ -24,8 +21,8 @@ export class Group extends Entity { this.subroot.ctx.canvas, 0, 0, - this.calculatedWidth, - this.calculatedHeight, + this.width, + this.height, ); } } diff --git a/src/hooks/use-layout.ts b/src/hooks/use-layout.ts new file mode 100644 index 0000000..eb11794 --- /dev/null +++ b/src/hooks/use-layout.ts @@ -0,0 +1,19 @@ +import { createContext, useContext } from 'react'; + +export interface Layout { + x: number; + y: number; + width: number; + height: number; +} + +export const LayoutContext = createContext({ + x: 0, + y: 0, + width: 0, + height: 0, +}); + +export function useLayout() { + return useContext(LayoutContext); +} diff --git a/src/image.ts b/src/image.ts index f17d48f..a77486e 100644 --- a/src/image.ts +++ b/src/image.ts @@ -1,15 +1,13 @@ import { Entity, type EntityProps } from './entity.js'; import type { Root } from './root.js'; -import type { IImage, PercentageString } from './types.js'; +import type { IImage } from './types.js'; -export interface ImageProps extends Omit { +export interface ImageProps extends EntityProps { src: string; sx?: number; sy?: number; sw?: number; sh?: number; - width?: number | PercentageString; - height?: number | PercentageString; } export class Image extends Entity { @@ -70,8 +68,8 @@ export class Image extends Entity { this.sh ?? img.naturalHeight, 0, 0, - this.calculatedWidth, - this.calculatedHeight, + this.width, + this.height, ); } } diff --git a/src/index.tsx b/src/index.tsx index aaa08e1..be24b04 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -18,13 +18,24 @@ import { Path as _Path, type PathProps } from './path.js'; import { Image as _Image, type ImageProps } from './image.js'; import { Text as _Text, type TextProps as _TextProps } from './text.js'; import { ICanvas } from './types.js'; +import { LayoutContext, useLayout } from './hooks/use-layout.js'; +import { EntityProps } from './entity.js'; type MaybeArray = T | T[]; -const factory = (type: string) => { - const c = forwardRef((props, ref) => - createElement(type, { ...props, ref }), - ); +function useAdjustedLayout(props: any) { + let { x, y, width, height } = useLayout(); + x += props.x ?? 0; + y += props.y ?? 0; + width += props.width ?? 0; + height += props.height ?? 0; + return { x, y, width, height }; +} + +const factory = (type: string) => { + const c = forwardRef((props, ref) => { + return createElement(type, { ...props, ...useAdjustedLayout(props), ref }); + }); c.displayName = type; return c; }; @@ -42,7 +53,6 @@ export type { _Canvas as CanvasRef }; export const Arc = factory<_Arc, ArcProps>('Arc'); export const Canvas = factory<_Canvas, CanvasProps>('Canvas'); -//export const Group = factory<_Group, GroupProps>('Group'); export const Image = factory<_Image, ImageProps>('Image'); export const Path = factory<_Path, PathProps>('Path'); export const Rect = factory<_Rect, RectProps>('Rect'); @@ -67,35 +77,40 @@ export const Group = forwardRef<_Group, GroupProps>((props, ref) => { const root = useParent(); const rootRef = useRef(); let canvas: ICanvas; + const layout = useAdjustedLayout(props); if (rootRef.current) { canvas = rootRef.current.ctx.canvas; } else { - canvas = new root.Canvas(props.width || 300, props.height || 150); + canvas = new root.Canvas(layout.width || 300, layout.height || 150); const ctx = canvas.getContext('2d'); if (!ctx) { throw new Error('Could not get "2d" canvas context'); } rootRef.current = new GroupRoot(ctx, root); } - if (props.width > 0 && props.width !== canvas.width) { - canvas.width = props.width; + if (layout.width > 0 && layout.width !== canvas.width) { + canvas.width = layout.width; } - if (props.height > 0 && props.height !== canvas.height) { - canvas.height = props.height; + if (layout.height > 0 && layout.height !== canvas.height) { + canvas.height = layout.height; } //console.log({ props }) return ( - {createElement('Group', { - ...props, - root: rootRef.current, - ref, - })} + + {createElement('Group', { + ...props, + ...layout, + root: rootRef.current, + ref, + })} + ); }); Group.displayName = 'Group'; export { useParent } from './hooks/use-parent.js'; +export { useLayout, LayoutContext, type Layout } from './hooks/use-layout.js'; export { useDimensions } from './hooks/use-dimensions.js'; export { useTextMetrics } from './hooks/use-text-metrics.js'; diff --git a/src/round-rect.ts b/src/round-rect.ts index b6b05a1..c5e7675 100644 --- a/src/round-rect.ts +++ b/src/round-rect.ts @@ -14,7 +14,7 @@ export class RoundRect extends Shape { get path() { const p = new this.root.Path2D(); - p.roundRect(0, 0, this.calculatedWidth, this.calculatedHeight, this.radii); + p.roundRect(0, 0, this.width, this.height, this.radii); return p; } } diff --git a/src/test.tsx b/src/test.tsx index 43bb6fc..696c4b4 100644 --- a/src/test.tsx +++ b/src/test.tsx @@ -1,3 +1,4 @@ +// @ts-nocheck import React, { useEffect, useRef, @@ -64,6 +65,7 @@ import { useTextMetrics, TextProps, useDimensions, + LayoutContext, } from './index.js'; const canvas = document.getElementById('c') as HTMLCanvasElement; @@ -758,12 +760,13 @@ function CenteredText({ children, ...props }: TextProps) { function Page1() { // biome-ignore lint/suspicious/noExplicitAny: const data = useLoaderData() as any; + const dims = useDimensions(); return ( <> - + - + hello world @@ -794,6 +797,18 @@ function Page1() { ); } +function Page3() { + return ( + + + + ); +} + +function RedRect() { + return ; +} + function ErrorBoundary() { // biome-ignore lint/suspicious/noExplicitAny: const error = useAsyncError() as any; @@ -820,7 +835,7 @@ function Async() { const data = useAsyncValue(); console.log(data); return ( - + Loaded! ); @@ -835,6 +850,7 @@ const routes: RouteObject[] = [ { path: '/test', //element: , + //element: , //element: , element: , //element: , diff --git a/src/text.ts b/src/text.ts index b9dc60e..b23a833 100644 --- a/src/text.ts +++ b/src/text.ts @@ -49,7 +49,7 @@ export class Text extends Entity { } render(): void { - let { + const { value, fontFamily = 'sans-serif', fontWeight = '', diff --git a/src/types.ts b/src/types.ts index d71255f..2622515 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,5 +1,3 @@ -export type PercentageString = `${string}%`; - export interface ICanvasRenderingContext2D { globalAlpha: number; canvas: ICanvas; diff --git a/src/util.ts b/src/util.ts index 9e42615..c91affc 100644 --- a/src/util.ts +++ b/src/util.ts @@ -1,10 +1,6 @@ import { Root } from './root.js'; import type { Entity } from './entity.js'; -import type { PercentageString, Point } from './types.js'; - -export function parsePercent(str: PercentageString) { - return parseFloat(str.slice(0, -1)) / 100; -} +import type { Point } from './types.js'; const MouseEvent = globalThis.MouseEvent || class MouseEvent extends Event {}; diff --git a/test/__image_snapshots__/group-test-tsx-test-group-test-tsx-should-render-group-1-snap.png b/test/__image_snapshots__/group-test-tsx-test-group-test-tsx-should-render-group-1-snap.png new file mode 100644 index 0000000..2aee8c8 Binary files /dev/null and b/test/__image_snapshots__/group-test-tsx-test-group-test-tsx-should-render-group-1-snap.png differ diff --git a/test/__image_snapshots__/group-test-tsx-test-group-test-tsx-should-render-group-with-parent-layout-context-1-snap.png b/test/__image_snapshots__/group-test-tsx-test-group-test-tsx-should-render-group-with-parent-layout-context-1-snap.png new file mode 100644 index 0000000..1d2f32a Binary files /dev/null and b/test/__image_snapshots__/group-test-tsx-test-group-test-tsx-should-render-group-with-parent-layout-context-1-snap.png differ diff --git a/test/__image_snapshots__/path-test-tsx-test-path-test-tsx-should-render-path-1-snap.png b/test/__image_snapshots__/path-test-tsx-test-path-test-tsx-should-render-path-1-snap.png new file mode 100644 index 0000000..1bc97e7 Binary files /dev/null and b/test/__image_snapshots__/path-test-tsx-test-path-test-tsx-should-render-path-1-snap.png differ diff --git a/test/__image_snapshots__/rect-test-tsx-test-rect-test-tsx-should-render-rect-with-layout-context-1-snap.png b/test/__image_snapshots__/rect-test-tsx-test-rect-test-tsx-should-render-rect-with-layout-context-1-snap.png new file mode 100644 index 0000000..c33a1c3 Binary files /dev/null and b/test/__image_snapshots__/rect-test-tsx-test-rect-test-tsx-should-render-rect-with-layout-context-1-snap.png differ diff --git a/test/group.test.tsx b/test/group.test.tsx new file mode 100644 index 0000000..5373f27 --- /dev/null +++ b/test/group.test.tsx @@ -0,0 +1,79 @@ +import React from 'react'; +import { join } from 'path'; +import { test, expect } from 'vitest'; +import config, { Canvas, GlobalFonts } from '@napi-rs/canvas'; +import { Group, Rect, Text, useDimensions, LayoutContext } from '../src'; +import { render } from '../src/render'; + +GlobalFonts.registerFromPath( + join(__dirname, 'Geist-Regular.otf'), + 'Geist Sans', +); + +test('should render ', async () => { + const canvas = new Canvas(300, 100); + let dims: { width: number; height: number }; + function Inner() { + dims = useDimensions(); + return ( + <> + + + Hello world! + + + ); + } + await render( + + + , + canvas, + config, + ); + expect(dims!).toEqual({ + width: 200, + height: 50, + }); + expect(canvas.toBuffer('image/png')).toMatchImageSnapshot(); +}); + +test('should render with parent layout context', async () => { + const canvas = new Canvas(300, 100); + let dims: { width: number; height: number }; + function Inner() { + dims = useDimensions(); + return ( + <> + + + Hello world! + + + ); + } + await render( + + + + + , + canvas, + config, + ); + expect(dims!).toEqual({ + width: 200, + height: 50, + }); + expect(canvas.toBuffer('image/png')).toMatchImageSnapshot(); +}); diff --git a/test/path.test.tsx b/test/path.test.tsx new file mode 100644 index 0000000..20ea2d1 --- /dev/null +++ b/test/path.test.tsx @@ -0,0 +1,39 @@ +import React from 'react'; +import { test, expect } from 'vitest'; +import config, { Canvas } from '@napi-rs/canvas'; +import { Path } from '../src'; +import { render } from '../src/render'; + +test('should render ', async () => { + const canvas = new Canvas(800, 800); + await render( + { + // setStroke3('black'); + //}} + //onMouseLeave={() => { + // setStroke3(undefined); + //}} + />, + canvas, + config, + ); + expect(canvas.toBuffer('image/png')).toMatchImageSnapshot(); +}); diff --git a/test/rect.test.tsx b/test/rect.test.tsx index 67efe09..dd18100 100644 --- a/test/rect.test.tsx +++ b/test/rect.test.tsx @@ -1,7 +1,7 @@ import React, { useState } from 'react'; import { test, expect } from 'vitest'; import config, { Canvas } from '@napi-rs/canvas'; -import { Rect } from '../src'; +import { LayoutContext, Rect } from '../src'; import { render } from '../src/render'; import { enableEvents, dispatchEvent } from './helpers/event'; @@ -15,6 +15,21 @@ test('should render ', async () => { expect(canvas.toBuffer('image/png')).toMatchImageSnapshot(); }); +test('should render with layout context', async () => { + const canvas = new Canvas(150, 100); + function BlueRect() { + return ; + } + await render( + + + , + canvas, + config, + ); + expect(canvas.toBuffer('image/png')).toMatchImageSnapshot(); +}); + test('should receive "click" event', async () => { const canvas = new Canvas(150, 100); enableEvents(canvas);