diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml new file mode 100644 index 0000000..fe96d8a --- /dev/null +++ b/.github/workflows/main.yml @@ -0,0 +1,19 @@ +name: CI + +on: + push: + branches: [main] + workflow_dispatch: + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v3 + + - name: deploy pages + uses: JamesIves/github-pages-deploy-action@v4.4.1 + with: + branch: gh-pages + folder: dev/dist diff --git a/dev/pages/Smileys.tsx b/dev/pages/Smileys.tsx index 6b4232c..ae4b5af 100644 --- a/dev/pages/Smileys.tsx +++ b/dev/pages/Smileys.tsx @@ -55,7 +55,15 @@ const Smiley = (props: { counter: number }) => { { })` const clock = createClock() - clock.start() + clock.start(1000 / 30) + + const [amount, setAmount] = createSignal(300) + const [shouldUseClock, setShouldUseClock] = createSignal(false) return ( <> +
+
+ + setAmount(+e.currentTarget.value)} + step={10} + /> +
+
+ + setShouldUseClock(e.currentTarget.checked)} + /> +
+
- + {() => } diff --git a/dev/pages/rectangles.tsx b/dev/pages/rectangles.tsx index 73fc62f..c2e8735 100644 --- a/dev/pages/rectangles.tsx +++ b/dev/pages/rectangles.tsx @@ -38,29 +38,29 @@ const App: Component = () => { draggable > ({ - position: { - x: Math.random() * (window.innerWidth + 200) - 100, - y: Math.random() * (window.innerHeight + 200) - 100, + each={new Array(600).fill('').map(v => ({ + transform: { + position: { + x: Math.random() * (window.innerWidth + 200) - 100, + y: Math.random() * (window.innerHeight + 200) - 100, + }, + skew: { + y: Math.random() * 90, + }, }, - fill: { - r: Math.random() * 215, - g: Math.random() * 215, - b: Math.random() * 215, + style: { + fill: { + r: Math.random() * 215, + g: Math.random() * 215, + b: Math.random() * 215, + }, + stroke: false, + dimensions: { width: 100, height: 100 }, + composite: 'hard-light' as const, }, - skewY: Math.random() * 90, }))} > - {data => ( - - )} + {data => } diff --git a/package.json b/package.json index 4ef23f0..3bd9413 100644 --- a/package.json +++ b/package.json @@ -72,6 +72,7 @@ }, "scripts": { "dev": "vite serve dev", + "dev:build": "vite build dev", "build": "tsup", "test": "concurrently pnpm:test:*", "test:client": "vitest", diff --git a/src/components/Canvas.tsx b/src/components/Canvas.tsx index 72c8d1b..6961ef9 100644 --- a/src/components/Canvas.tsx +++ b/src/components/Canvas.tsx @@ -14,11 +14,15 @@ import { untrack, } from 'solid-js' import { createStore } from 'solid-js/store' -import { InternalContext } from 'src/context/InternalContext' +import { + InternalContext, + InternalContextType, +} from 'src/context/InternalContext' import { UserContext } from 'src/context/UserContext' import { CanvasToken, parser } from 'src/parser' import { + CanvasFlags, CanvasMouseEvent, CanvasMouseEventTypes, Color, @@ -32,6 +36,7 @@ import { createMouseEventHandler } from 'src/utils/createMouseEventHandler' import forEachReversed from 'src/utils/forEachReversed' import { resolveColor } from 'src/utils/resolveColor' import withContext from 'src/utils/withContext' +import { should } from 'vitest' /** * All `solid-canvas`-components have to be inside a `Canvas` @@ -107,6 +112,74 @@ export const Canvas: Component<{ const matrix = createMatrix(() => props) + const flags: Record = { + shouldHitTest: true, + hasInteractiveTokens: false, + } + + const [interactiveTokens, setInteractiveTokens] = createSignal( + [], + ) + + createEffect(() => { + if (interactiveTokens().length > 0) { + setFlag('hasInteractiveTokens', true) + } else { + setFlag('hasInteractiveTokens', false) + } + }) + + const registerInteractiveToken = (token: CanvasToken, add = true) => { + if (add) { + setInteractiveTokens(tokens => [...tokens, token]) + } else { + if (interactiveTokens().includes(token)) { + setInteractiveTokens(tokens => tokens.filter(t => t !== token)) + } + } + } + + const setFlag = (key: CanvasFlags, value: boolean) => { + flags[key] = value + } + + const context: InternalContextType = { + ctx, + setFlag: setFlag, + get flags() { + return flags + }, + get debug() { + return !!props.debug + }, + get matrix() { + return matrix() + }, + registerInteractiveToken, + get interactiveTokens() { + return interactiveTokens() + }, + addEventListener: ( + type: CanvasMouseEvent['type'], + callback: (event: CanvasMouseEvent) => void, + ) => { + setEventListeners(type, listeners => [...listeners, callback]) + }, + removeEventListener: ( + type: CanvasMouseEvent['type'], + callback: (event: CanvasMouseEvent) => void, + ) => { + setEventListeners(type, listeners => { + const index = listeners.indexOf(callback) + const result = [ + ...listeners.slice(0, index), + ...listeners.slice(index + 1), + ] + return result + }) + }, + } + const tokens = resolveTokens( parser, withContext( @@ -114,34 +187,7 @@ export const Canvas: Component<{ [ { context: InternalContext, - value: { - ctx, - get debug() { - return !!props.debug - }, - get matrix() { - return matrix() - }, - addEventListener: ( - type: CanvasMouseEvent['type'], - callback: (event: CanvasMouseEvent) => void, - ) => { - setEventListeners(type, listeners => [...listeners, callback]) - }, - removeEventListener: ( - type: CanvasMouseEvent['type'], - callback: (event: CanvasMouseEvent) => void, - ) => { - setEventListeners(type, listeners => { - const index = listeners.indexOf(callback) - const result = [ - ...listeners.slice(0, index), - ...listeners.slice(index + 1), - ] - return result - }) - }, - }, + value: context, }, { context: UserContext, @@ -201,10 +247,12 @@ export const Canvas: Component<{ ctx.restore() forEachReversed(tokens(), token => { - if (props.debug && 'debug' in token.data) token.data.debug(ctx) if ('render' in token.data) token.data.render(ctx) }) - + ctx.resetTransform() + forEachReversed(tokens(), token => { + if (props.debug && 'debug' in token.data) token.data.debug(ctx) + }) if (props.fill) { ctx.save() ctx.globalCompositeOperation = 'destination-over' @@ -232,7 +280,7 @@ export const Canvas: Component<{ } } - const scheduled = createScheduled(fn => throttle(fn, 1000 / 120)) + const scheduled = createScheduled(fn => throttle(fn)) createEffect(() => { if (!!props.clock || props.clock === 0) return @@ -264,8 +312,8 @@ export const Canvas: Component<{ const mouseMoveHandler = createMouseEventHandler( 'onMouseMove', - tokens, - ctx, + interactiveTokens, + context, eventListeners, event => { props.onMouseMove?.(event) @@ -274,8 +322,8 @@ export const Canvas: Component<{ const mouseDownHandler = createMouseEventHandler( 'onMouseDown', - tokens, - ctx, + interactiveTokens, + context, eventListeners, event => { if (props.draggable) { @@ -293,8 +341,8 @@ export const Canvas: Component<{ const mouseUpHandler = createMouseEventHandler( 'onMouseUp', - tokens, - ctx, + interactiveTokens, + context, eventListeners, event => { props.onMouseUp?.(event) diff --git a/src/components/Object2D/Group.tsx b/src/components/Object2D/Group.tsx index fd0c8be..82947ab 100644 --- a/src/components/Object2D/Group.tsx +++ b/src/components/Object2D/Group.tsx @@ -1,13 +1,12 @@ import { createToken } from '@solid-primitives/jsx-tokenizer' import { Accessor, mergeProps, splitProps } from 'solid-js' import { JSX } from 'solid-js/jsx-runtime' +import { useInternalContext } from 'src/context/InternalContext' import { RegisterControllerEvents } from 'src/controllers/controllers' import { CanvasToken, parser } from 'src/parser' import { Color, Object2DProps, ResolvedShape2DProps, Vector } from 'src/types' import { createParenthood } from 'src/utils/createParenthood' -import { createUpdatedContext } from 'src/utils/createUpdatedContext' import { SingleOrArray } from 'src/utils/typehelpers' -import { T } from 'vitest/dist/types-c800444e' export type GroupProps = { children: SingleOrArray @@ -37,17 +36,35 @@ const Group = createToken(parser, (props: Object2DProps) => { // until then we will only allow controllers for types extending `Shape2DProps` // const controlled = createControlledProps(mergedProps) - const context = createUpdatedContext(() => mergedProps) - const parenthood = createParenthood(props, context) + const context = useInternalContext() + const parenthood = createParenthood(props, context!) return { type: 'Object2D', id: 'Group', - render: ctx => parenthood.render(ctx), - debug: () => {}, + render: ctx => { + ctx.translate( + props.transform?.position?.x ?? 0, + props.transform?.position?.y ?? 0, + ) + ctx.rotate(props.transform?.rotation ?? 0) + parenthood.render(ctx) + ctx.translate( + (props.transform?.position?.x ?? 0) * -1, + (props.transform?.position?.y ?? 0) * -1, + ) + ctx.rotate((props.transform?.rotation ?? 0) * -1) + }, + debug: ctx => { + parenthood.debug(ctx) + }, hitTest: event => { - parenthood.hitTest(event) - if (!event.propagation) return false - return true + if (!event.propagation) return + + const hit = parenthood.hitTest(event) + if (hit) { + props[event.type]?.(event) + } + return hit }, paths: () => [], tokens: [], diff --git a/src/components/Object2D/Shape2D/Path2D/Line.tsx b/src/components/Object2D/Shape2D/Path2D/Line.tsx index fd7b4a1..bc769cc 100644 --- a/src/components/Object2D/Shape2D/Path2D/Line.tsx +++ b/src/components/Object2D/Shape2D/Path2D/Line.tsx @@ -6,7 +6,7 @@ import { createPath2D } from '../../../../utils/createPath2D' type LineProps = { points: Vector[] - style: { + style?: { close?: boolean } } @@ -18,7 +18,9 @@ type LineProps = { const Line = createToken( parser, (props: Shape2DProps & LineProps) => { - let path2D: Path2D, point: Vector | undefined, index: number + let path2D: Path2D + let point: Vector | undefined + let index: number return createPath2D({ id: 'Line', props, diff --git a/src/context/InternalContext.ts b/src/context/InternalContext.ts index 68a770a..17e5f13 100644 --- a/src/context/InternalContext.ts +++ b/src/context/InternalContext.ts @@ -1,8 +1,12 @@ import { createContext, useContext } from 'solid-js' import { CanvasToken } from 'src/parser' -import { CanvasMouseEvent } from '../types' +import { CanvasFlags, CanvasMouseEvent } from '../types' export type InternalContextType = { + registerInteractiveToken: (token: CanvasToken, add?: boolean) => void + interactiveTokens: CanvasToken[] + setFlag: (key: CanvasFlags, value: boolean) => void + flags: Record ctx: CanvasRenderingContext2D matrix: DOMMatrix debug: boolean diff --git a/src/controllers/ClickStyle.ts b/src/controllers/ClickStyle.ts index 3d08578..a84ca8f 100644 --- a/src/controllers/ClickStyle.ts +++ b/src/controllers/ClickStyle.ts @@ -8,20 +8,22 @@ type HoverOptions = { stroke?: ExtendedColor fill?: ExtendedColor } -const ClickStyle = createController((props, events, options) => { - const [selected, setSelected] = createSignal(false) +const ClickStyle = createController( + (props, events, token, options) => { + const [selected, setSelected] = createSignal(false) - events.onMouseDown(() => setSelected(true)) - events.onMouseUp(() => setSelected(false)) + events.onMouseDown(() => setSelected(true)) + events.onMouseUp(() => setSelected(false)) - return mergeGetters(props(), { - get stroke() { - return selected() ? options.stroke : props().style.stroke - }, - get fill() { - return selected() ? options.fill : props().style.fill - }, - }) -}) + return mergeGetters(props(), { + get stroke() { + return selected() ? options.stroke : props().style.stroke + }, + get fill() { + return selected() ? options.fill : props().style.fill + }, + }) + }, +) export { ClickStyle } diff --git a/src/controllers/Drag.ts b/src/controllers/Drag.ts index fe7a0ab..777ff54 100644 --- a/src/controllers/Drag.ts +++ b/src/controllers/Drag.ts @@ -1,6 +1,6 @@ -import { Accessor, createEffect, createSignal, onCleanup } from 'solid-js' +import { createEffect, createSignal, onCleanup } from 'solid-js' import { useInternalContext } from 'src/context/InternalContext' -import { CanvasMouseEvent, Vector, Shape2DProps } from 'src/types' +import { CanvasMouseEvent, Vector } from 'src/types' import { createController } from './createController' type DragOptions = { @@ -9,7 +9,7 @@ type DragOptions = { onDragMove?: (position: Vector, event: CanvasMouseEvent) => void } -const Drag = createController((props, events, options) => { +const Drag = createController((props, events, token, options) => { const [dragPosition, setDragPosition] = createSignal({ x: 0, y: 0 }) const [selected, setSelected] = createSignal(false) const internalContext = useInternalContext() @@ -30,6 +30,7 @@ const Drag = createController((props, events, options) => { options.onDragMove?.(dragPosition(), event) } const handleMouseUp = (event: CanvasMouseEvent) => { + internalContext?.setFlag('shouldHitTest', true) setSelected(false) } @@ -54,6 +55,7 @@ const Drag = createController((props, events, options) => { ? true : options.active ) { + internalContext?.setFlag('shouldHitTest', false) setSelected(true) event.propagation = false } @@ -61,6 +63,13 @@ const Drag = createController((props, events, options) => { events.onMouseDown(dragEventHandler) let position = { x: 0, y: 0 } + /* const position = createMemo(() => ({ + x: (props().transform.position?.x || 0) + dragPosition().x, + y: (props().transform.position?.y || 0) + dragPosition().y + })) */ + + createEffect(() => {}) + return { transform: { get position() { @@ -72,6 +81,9 @@ const Drag = createController((props, events, options) => { } }, }, + style: { + pointerEvents: true, + }, } }) diff --git a/src/controllers/Hover.ts b/src/controllers/Hover.ts index 0ba739b..c5205a6 100644 --- a/src/controllers/Hover.ts +++ b/src/controllers/Hover.ts @@ -1,15 +1,6 @@ -import { - createEffect, - createMemo, - createSignal, - indexArray, - mergeProps, - onCleanup, - untrack, -} from 'solid-js' -import { RGB, Shape2DStyle, Transforms } from 'src/types' +import { createEffect, createSignal, mergeProps } from 'solid-js' +import { Shape2DStyle, Transforms } from 'src/types' import { createController } from './createController' -import { deepMergeGetters } from 'src/utils/mergeGetters' type HoverOptions = { active?: boolean @@ -17,36 +8,34 @@ type HoverOptions = { transform: Transforms | undefined } -const Hover = createController((props, events, options) => { - const [isHovered, setIsHovered] = createSignal(false) +const Hover = createController( + (props, events, token, options) => { + const [isHovered, setIsHovered] = createSignal(false) - createEffect(() => { - if (!options.style) return - events.onMouseEnter(() => { - setIsHovered(true) - // tweens().forEach(t => t[1]('forward')) + createEffect(() => { + if (!options.style) return + events.onMouseEnter(() => { + setIsHovered(true) + }) + events.onMouseLeave(() => { + setIsHovered(false) + }) }) - events.onMouseLeave(() => { - setIsHovered(false) - // tweens().forEach(t => t[1]('backward')) - }) - }) - const styles = createMemo(() => mergeProps(props().style, options.style)) - const transforms = createMemo(() => - options.transform - ? mergeProps(props().transform, options.transform) - : props().transform, - ) + const mergedStyle = mergeProps(props().style, options.style) + const mergedTransform = mergeProps(props().transform, options.transform) - return { - get style() { - return options.style && isHovered() ? styles() : props().style - }, - get transform() { - return options.transform && isHovered() ? transforms() : props().transform - }, - } -}) + return { + get style() { + return options.style && isHovered() ? mergedStyle : props().style + }, + get transform() { + return options.transform && isHovered() + ? mergedTransform + : props().transform + }, + } + }, +) export { Hover } diff --git a/src/controllers/createController.ts b/src/controllers/createController.ts index 79881cc..a6576de 100644 --- a/src/controllers/createController.ts +++ b/src/controllers/createController.ts @@ -2,18 +2,21 @@ import { Accessor, createMemo } from 'solid-js' import { ResolvedShape2DProps, Shape2DProps } from 'src/types' import { deepMergeGetters } from 'src/utils/mergeGetters' import { RegisterControllerEvents } from './controllers' +import { CanvasToken } from 'src/parser' const createController = < ControllerOptions extends Record, AdditionalProperties = {}, + Token = CanvasToken, >( callback: ( props: Accessor< ResolvedShape2DProps & AdditionalProperties >, events: RegisterControllerEvents, + token: Accessor, options: ControllerOptions, - ) => Partial, + ) => Partial | undefined, ) => { function Controller( options?: ControllerOptions, @@ -23,6 +26,7 @@ const createController = < ResolvedShape2DProps & AdditionalProperties >, events: RegisterControllerEvents, + token: Accessor, options: ControllerOptions, ): Accessor & AdditionalProperties> function Controller( @@ -30,6 +34,7 @@ const createController = < | Accessor> | ControllerOptions, events?: RegisterControllerEvents, + token?: Accessor, options?: ControllerOptions, ) { if (!events) { @@ -38,7 +43,8 @@ const createController = < ResolvedShape2DProps & AdditionalProperties >, events: RegisterControllerEvents, - ) => Controller(props, events, propsOrOptions as ControllerOptions) + token: Accessor, + ) => Controller(props, events, token, propsOrOptions as ControllerOptions) } // NOTE: `result` needs to be defined outside `mergeProps` for unknown reasons @@ -48,6 +54,7 @@ const createController = < ResolvedShape2DProps & AdditionalProperties >, events, + token!, options!, ) @@ -67,7 +74,7 @@ const createController = < )(), result, ), - ) as Accessor & AdditionalProperties> + ) as Accessor> } return Controller } diff --git a/src/defaultProps.ts b/src/defaultProps.ts index c244bce..b044529 100644 --- a/src/defaultProps.ts +++ b/src/defaultProps.ts @@ -9,7 +9,7 @@ const defaultShape2DProps: ResolvedShape2DProps = { lineJoin: 'round', miterLimit: 10, lineWidth: 2, - pointerEvents: true, + pointerEvents: false, opacity: 1, cursor: 'default', }, @@ -26,16 +26,17 @@ const defaultShape2DProps: ResolvedShape2DProps = { const defaultBoundsProps: ResolvedShape2DProps = { style: { stroke: 'grey', - fill: 'transparent', + // fill: 'black', + fill: false, lineDash: [], lineCap: 'butt', lineJoin: 'round', miterLimit: 10, - lineWidth: 0.5, + lineWidth: 2, opacity: 1, - composite: 'destination-over', + composite: 'source-over', cursor: undefined, - pointerEvents: true, + pointerEvents: false, }, transform: { position: { x: 0, y: 0 }, diff --git a/src/parser.ts b/src/parser.ts index a6ef2db..b98b496 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -1,10 +1,15 @@ import { createTokenizer, TokenElement } from '@solid-primitives/jsx-tokenizer' import { Accessor } from 'solid-js' -import { CanvasMouseEvent } from './types' +import { CanvasMouseEvent, Dimensions, Vector } from './types' export type Shape2DToken = { type: 'Shape2D' id: string + bounds: Accessor<{ + path: Path2D + dimensions: Dimensions + position: Vector + }> render: (ctx: CanvasRenderingContext2D) => void debug: (ctx: CanvasRenderingContext2D) => void hitTest: (event: CanvasMouseEvent) => boolean diff --git a/src/types.ts b/src/types.ts index db6a9b6..80fd7c0 100644 --- a/src/types.ts +++ b/src/types.ts @@ -2,9 +2,10 @@ import { Accessor, JSX } from 'solid-js' import { RegisterControllerEvents } from './controllers/controllers' import { CanvasToken } from './parser' import { RequiredPartially, SingleOrArray } from './utils/typehelpers' -import { InternalContextType } from './context/InternalContext' -export type Object2DProps = { +export type CanvasFlags = 'shouldHitTest' | 'hasInteractiveTokens' + +export type Object2DProps = CanvasMouseEvents & { transform?: Transforms style?: { composite?: Composite @@ -17,7 +18,7 @@ export type Object2DProps = { // controllers?: ((props: Object2DProps, events: ControllerEvents) => any)[] } -export type Shape2DProps = Shape2DEvents & { +export type Shape2DProps = CanvasMouseEvents & { transform?: Transforms & { '&:hover'?: Transforms } style?: Shape2DStyle & { '&:hover'?: Shape2DStyle } @@ -38,6 +39,7 @@ export type Shape2DProps = Shape2DEvents & { controllers?: (( props: Accessor>, events: RegisterControllerEvents, + token: Accessor, ) => T | Shape2DProps)[] } @@ -57,27 +59,27 @@ export type ResolvedShape2DProps = Shape2DProps & { transform: Required } -type Shape2DEvents = { +type CanvasMouseEvents = { /** * Set onMouseDown-eventhandler. */ - onMouseDown?: SingleOrArray<(event: CanvasMouseEvent) => void> + onMouseDown?: (event: CanvasMouseEvent) => void /** * Set onMouseUp-eventhandler. */ - onMouseUp?: SingleOrArray<(event: CanvasMouseEvent) => void> + onMouseUp?: (event: CanvasMouseEvent) => void /** * Set onMouseMove-eventhandler. */ - onMouseMove?: SingleOrArray<(event: CanvasMouseEvent) => void> + onMouseMove?: (event: CanvasMouseEvent) => void /** * Set onMouseEnter-eventhandler. */ - onMouseEnter?: SingleOrArray<(event: CanvasMouseEvent) => void> + onMouseEnter?: (event: CanvasMouseEvent) => void /** * Set onMouseLeave-eventhandler. */ - onMouseLeave?: SingleOrArray<(event: CanvasMouseEvent) => void> + onMouseLeave?: (event: CanvasMouseEvent) => void } export interface Transforms { diff --git a/src/utils/createBounds.ts b/src/utils/createBounds.ts index 7d5e217..4d43709 100644 --- a/src/utils/createBounds.ts +++ b/src/utils/createBounds.ts @@ -1,9 +1,8 @@ import { Accessor, createMemo } from 'solid-js' -import { InternalContextType } from 'src/context/InternalContext' -const createBounds = ( +export const createBounds = ( points: Accessor<{ x: number; y: number }[]>, - context: InternalContextType, + matrix: Accessor, ) => { let dimensions: { width: number; height: number } let position: { x: number; y: number } @@ -19,6 +18,7 @@ const createBounds = ( max: -Infinity, }, } + return createMemo(() => { bounds = { x: { @@ -32,11 +32,11 @@ const createBounds = ( } points().forEach(({ x, y }) => { - point = new DOMPoint(x, y).matrixTransform(context.matrix) - if (point.x < bounds.x.min) bounds.x.min = x - if (point.x > bounds.x.max) bounds.x.max = x - if (point.y < bounds.y.min) bounds.y.min = y - if (point.y > bounds.y.max) bounds.y.max = y + point = new DOMPoint(x, y).matrixTransform(matrix()) + if (point.x < bounds.x.min) bounds.x.min = point.x + if (point.x > bounds.x.max) bounds.x.max = point.x + if (point.y < bounds.y.min) bounds.y.min = point.y + if (point.y > bounds.y.max) bounds.y.max = point.y }) dimensions = { @@ -50,7 +50,6 @@ const createBounds = ( path = new Path2D() path.rect(position.x, position.y, dimensions.width, dimensions.height) - return { path, position, @@ -58,5 +57,3 @@ const createBounds = ( } }) } - -export { createBounds } diff --git a/src/utils/createControlledProps.ts b/src/utils/createControlledProps.ts index 41b22d4..d7fb98a 100644 --- a/src/utils/createControlledProps.ts +++ b/src/utils/createControlledProps.ts @@ -1,13 +1,9 @@ import { createLazyMemo } from '@solid-primitives/memo' -import { Accessor, mapArray } from 'solid-js' +import { Accessor, createMemo, mapArray } from 'solid-js' import { ControllerEvents } from 'src/controllers/controllers' import { ResolvedShape2DProps, Shape2DProps } from 'src/types' import { DeepRequired } from './typehelpers' -import withContext from './withContext' -import { - InternalContext, - InternalContextType, -} from 'src/context/InternalContext' +import { CanvasToken } from 'src/parser' const createControlledProps = < T extends Record, @@ -15,6 +11,7 @@ const createControlledProps = < >( props: U, defaultControllers: Accessor>[] = [], + token: Accessor, ) => { const events: { [K in keyof ControllerEvents]: ControllerEvents[K][] @@ -67,15 +64,22 @@ const createControlledProps = < onRender: callback => events.onRender.push(callback), onHitTest: callback => events.onHitTest.push(callback), }, + token, ) }, ), ) + const output = createMemo( + () => + (controllers()[controllers().length - 1]?.() ?? + props) as ResolvedShape2DProps & DeepRequired, + ) + return { get props() { - return (controllers()[controllers().length - 1]?.() ?? - props) as ResolvedShape2DProps & DeepRequired + // return props + return output() }, emit, } diff --git a/src/utils/createMouseEventHandler.ts b/src/utils/createMouseEventHandler.ts index c2617f1..c46752c 100644 --- a/src/utils/createMouseEventHandler.ts +++ b/src/utils/createMouseEventHandler.ts @@ -3,29 +3,29 @@ import { Accessor } from 'solid-js' import { InternalContextType } from 'src/context/InternalContext' import { CanvasToken } from 'src/parser' import { - Vector, + CanvasFlags, CanvasMouseEvent, - CanvasMouseEventListener, CanvasMouseEventTypes, + Vector, } from 'src/types' -import forEachReversed from './forEachReversed' +import { throttle } from './throttle' const createMouseEventHandler = ( type: 'onMouseDown' | 'onMouseMove' | 'onMouseUp', - tokens: Accessor[]>, - ctx: CanvasRenderingContext2D, + tokens: Accessor, + context: InternalContextType, eventListeners: Record< CanvasMouseEventTypes, ((event: CanvasMouseEvent) => void)[] >, - final?: (event: CanvasMouseEvent) => void, + final: (event: CanvasMouseEvent) => void, ) => { let position: Vector let delta: Vector let event: CanvasMouseEvent let lastCursorPosition: Vector - return (e: MouseEvent) => { + const func = throttle((e: MouseEvent) => { position = { x: e.clientX, y: e.clientY } delta = lastCursorPosition ? { @@ -37,7 +37,7 @@ const createMouseEventHandler = ( // NOTE: `event` gets mutated by `token.hitTest` event = { - ctx, + ctx: context.ctx, position, delta, propagation: true, @@ -45,23 +45,25 @@ const createMouseEventHandler = ( type, cursor: 'move', } + if (context.flags.shouldHitTest && context.flags.hasInteractiveTokens) { + tokens().forEach(data => { + if (!event.propagation) return + if ('hitTest' in data) { + data.hitTest(event) + } + }) + } - tokens().forEach(({ data }) => { - // forEachReversed(tokens(), ({ data }) => { - if (!event.propagation) return - if ('hitTest' in data) { - data.hitTest(event) - } - }) - - if (event.propagation && final) final(event) + final?.(event) // setCursorStyle(event.cursor) eventListeners[type].forEach(listener => listener(event)) return event - } + }) + + return func } export { createMouseEventHandler } diff --git a/src/utils/createParenthood.ts b/src/utils/createParenthood.ts index 445c315..db6daea 100644 --- a/src/utils/createParenthood.ts +++ b/src/utils/createParenthood.ts @@ -67,6 +67,9 @@ function createParenthood( forEachReversed(tokens(), ({ data }) => { if ('render' in data) data.render?.(ctx) }) + } + + const debug = (ctx: CanvasRenderingContext2D) => { forEachReversed(tokens(), ({ data }) => { if ('debug' in data && context.debug) data.debug(ctx) }) @@ -85,19 +88,10 @@ function createParenthood( } } }) - /* forEachReversed(tokens(), token => { - if (!event.propagation) return - if ('hitTest' in token.data) { - hitTestHit = token.data.hitTest(event) - if (hitTestHit) { - hitTestResult.push(token) - } - } - }) */ return hitTestHit } - return { render, hitTest } + return { render, hitTest, debug } } export { createParenthood } diff --git a/src/utils/createPath2D.ts b/src/utils/createPath2D.ts index 8e62b7a..b29e503 100644 --- a/src/utils/createPath2D.ts +++ b/src/utils/createPath2D.ts @@ -1,5 +1,11 @@ -import { createMemo, createSignal, createUniqueId, mergeProps } from 'solid-js' -import { defaultShape2DProps } from 'src/defaultProps' +import { + createEffect, + createMemo, + createSignal, + createUniqueId, + mergeProps, +} from 'solid-js' +import { defaultBoundsProps, defaultShape2DProps } from 'src/defaultProps' import { Shape2DToken } from 'src/parser' import { CanvasMouseEventListener, @@ -15,8 +21,11 @@ import { deepMergeGetters } from './mergeGetters' import { DeepRequired, RequireOptionals, SingleOrArray } from './typehelpers' import { Hover } from 'src/controllers/Hover' import { isPointInShape2D } from './isPointInShape2D' +import { useInternalContext } from 'src/context/InternalContext' +import { createTransformedCallback } from './transformedCallback' +import { createBounds } from './createBounds' -const createPath2D = (arg: { +const createPath2D = (arg: { id: string props: Shape2DProps & T defaultStyle: RequireOptionals @@ -35,104 +44,108 @@ const createPath2D = (arg: { }, }) - const controlled = createControlledProps(props, [ - /* Hover({ - style: props.style?.['&:hover'], - transform: props.transform?.['&:hover'], - }), */ - ]) + const controlled = createControlledProps( + props, + [ + Hover({ + style: props.style?.['&:hover'], + transform: props.transform?.['&:hover'], + }), + ], + () => token, + ) - const context = createUpdatedContext(() => controlled.props) + const context = useInternalContext() - const parenthood = createParenthood(arg.props, context) + const parenthood = createParenthood(arg.props, context!) const path = createMemo(() => arg.path(controlled.props)) - // const bounds = createBounds(() => arg.bounds(props), context) - const [hover, setHover] = createSignal(false) + const [matrix, setMatrix] = createSignal(new DOMMatrix()) + const bounds = createBounds(() => arg.bounds(controlled.props), matrix) + + const transformedCallback = createTransformedCallback() + const token: Shape2DToken = { type: 'Shape2D', id: arg.id, path, + bounds, render: ctx => { - renderPath(context, controlled.props, path()) - parenthood.render(ctx) - controlled.emit.onRender(ctx) + transformedCallback(ctx, controlled.props, () => { + setMatrix(ctx.getTransform()) + renderPath(context!, controlled.props, path()) + parenthood.render(ctx) + controlled.emit.onRender(ctx) + return 0 + }) }, debug: ctx => { - // renderPath(context, defaultBoundsProps, bounds().path) + parenthood.debug(ctx) + renderPath(context!, defaultBoundsProps, bounds().path) }, hitTest: event => { - parenthood.hitTest(event) - if (!event.propagation) return false - controlled.emit.onHitTest(event) if (!event.propagation) return false - if (controlled.props.style.pointerEvents === false) return false - - context.ctx.save() - context.ctx.setTransform(context.matrix) - context.ctx.lineWidth = controlled.props.style.lineWidth - ? controlled.props.style.lineWidth < 20 - ? 20 - : controlled.props.style.lineWidth - : 20 - - const hit = isPointInShape2D(event, props, path()) - - context.ctx.resetTransform() - - if (hit) { - event.target.push(token) - let controlledListeners = controlled.props[ - event.type - ] as SingleOrArray - - if (controlledListeners) { - if (Array.isArray(controlledListeners)) - controlledListeners.forEach(l => l(event)) - else controlledListeners(event) - } - - /* let propListeners = props[ - event.type - ] as SingleOrArray - - if (propListeners) { - if (Array.isArray(propListeners)) propListeners.forEach(l => l(event)) - else propListeners(event) - } */ - - if (controlled.props.cursor) - event.cursor = controlled.props.style.cursor + parenthood.hitTest(event) - if (event.type === 'onMouseMove') { - if (event.target.length === 1) { - if (!hover()) { - setHover(true) - controlled.emit.onMouseEnter(event) - } - } else { - if (hover()) { - setHover(false) - controlled.emit.onMouseLeave(event) + event.ctx.setTransform(matrix()) + let hit = false + if (controlled.props.style.pointerEvents) { + controlled.emit.onHitTest(event) + event.ctx.lineWidth = controlled.props.style.lineWidth + ? controlled.props.style.lineWidth < 20 + ? 20 + : controlled.props.style.lineWidth + : 20 + + hit = isPointInShape2D(event, props, path()) + + if (hit) { + event.propagation = false + event.target.push(token) + + controlled.props[event.type]?.(event) + + if (controlled.props.cursor) + event.cursor = controlled.props.style.cursor + + if (event.type === 'onMouseMove') { + if (event.target.length === 1) { + if (!hover()) { + setHover(true) + controlled.emit.onMouseEnter(event) + } + } else { + if (hover()) { + setHover(false) + controlled.emit.onMouseLeave(event) + } } } - } - controlled.emit[event.type](event) - } else { - if (hover() && event.type === 'onMouseMove') { - setHover(false) - controlled.emit.onMouseLeave(event) + controlled.emit[event.type](event) + } else { + if (hover() && event.type === 'onMouseMove') { + setHover(false) + controlled.emit.onMouseLeave(event) + } } } - context.ctx.restore() + event.ctx.resetTransform() return hit }, } + + createEffect(() => + context?.registerInteractiveToken( + token, + controlled.props.style.pointerEvents, + ), + ) + return token } diff --git a/src/utils/createShape2D.ts b/src/utils/createShape2D.ts index 0afe9b0..bce37b1 100644 --- a/src/utils/createShape2D.ts +++ b/src/utils/createShape2D.ts @@ -14,6 +14,7 @@ import { createUpdatedContext } from './createUpdatedContext' import { deepMergeGetters, mergeGetters } from './mergeGetters' import { mergeShape2DProps } from './mergeShape2DProps' import withContext from './withContext' +import { createTransformedCallback } from './transformedCallback' const createShape2D = < T, @@ -35,10 +36,10 @@ const createShape2D = < defaultValues: U }) => { const controlled = createControlledProps( - // TODO: fix any deepMergeGetters(arg.defaultValues, arg.props), + [], + () => token, ) - // const matrix = createMatrix(() => arg.props) const context = createUpdatedContext(() => controlled.props) const parenthood = createParenthood(arg.props, context) @@ -78,32 +79,53 @@ const createShape2D = < context, ) as Accessor> + let matrix: DOMMatrix + + const transformedCallback = createTransformedCallback() + const token: Object2DToken = { type: 'Object2D', id: arg.id, hitTest: event => { + if (!event.propagation) return false parenthood.hitTest(event) if (!event.propagation) return false - let hit = path().data.hitTest(event) + if (!arg.props.style?.pointerEvents) return false + event.ctx.setTransform(matrix) + let hit = path().data.hitTest(event) if (hit) { controlled.emit[event.type](event) + arg.props[event.type]?.(event) } controlled.emit.onHitTest(event) - if (!event.propagation) return false + event.ctx.resetTransform() return hit }, - debug: event => path().data.debug(event), + debug: event => { + path().data.debug(event) + }, render: ctx => { if (!arg.dimensions) return - path().data.render(ctx) - // TODO: fix any - arg.render(controlled.props as any, context, context.matrix) - parenthood.render(ctx) - controlled.emit.onRender(ctx) + transformedCallback(ctx, arg.props, () => { + path().data.render(ctx) + // TODO: fix any + arg.render(controlled.props as any, context, context.matrix) + parenthood.render(ctx) + controlled.emit.onRender(ctx) + if (arg.props.style?.pointerEvents && context.flags.shouldHitTest) { + matrix = ctx.getTransform() + } + }) }, } + createEffect(() => + context?.registerInteractiveToken( + token, + controlled.props.style.pointerEvents, + ), + ) return token } export { createShape2D } diff --git a/src/utils/createUpdatedContext.ts b/src/utils/createUpdatedContext.ts index 62b0377..927283a 100644 --- a/src/utils/createUpdatedContext.ts +++ b/src/utils/createUpdatedContext.ts @@ -8,12 +8,13 @@ const createUpdatedContext = ( props: Accessor<{ transform?: Partial }>, ) => { const internalContext = useInternalContext() - const matrix = createMatrix(props, internalContext) + /* const matrix = createMatrix(props, internalContext) return deepMergeGetters(internalContext, { get matrix() { return matrix() }, - }) + }) */ + return internalContext! } export { createUpdatedContext } diff --git a/src/utils/mergeGetters.ts b/src/utils/mergeGetters.ts index d363cca..d3959d6 100644 --- a/src/utils/mergeGetters.ts +++ b/src/utils/mergeGetters.ts @@ -9,6 +9,8 @@ function mergeGetters(a: A, b: B) { return result as A & Omit } +// a bit unorthodox maybe, but since deepMergeGetters can run a lot, I believe it's worth it. +let prop, props1, props2, descriptor1, descriptor2 function deepMergeGetters< T extends { [key: string]: any }, U extends { [key: string]: any }, @@ -21,25 +23,25 @@ function deepMergeGetters< } const result = {} as T & U + props1 = Object.getOwnPropertyNames(obj1) + props2 = Object.getOwnPropertyNames(obj2) - const props1 = Object.getOwnPropertyNames(obj1) - const props2 = Object.getOwnPropertyNames(obj2) - let prop: keyof T & keyof U - for (prop of [...props1, ...props2]) { - const descriptor1 = Object.getOwnPropertyDescriptor(obj1, prop) - const descriptor2 = Object.getOwnPropertyDescriptor(obj2, prop) + for (prop of [...props1, ...props2] as (keyof T & U)[]) { + descriptor1 = Object.getOwnPropertyDescriptor(obj1, prop) + descriptor2 = Object.getOwnPropertyDescriptor(obj2, prop) if (descriptor2 && descriptor2.get) { Object.defineProperty(result, prop, descriptor2) - } else if (typeof obj2[prop] === 'object') { - result[prop] = deepMergeGetters(obj1[prop], obj2[prop]) - } else if (obj2[prop]) { - result[prop] = obj2[prop] + } else if (typeof obj2[prop as string] === 'object') { + result[prop] = deepMergeGetters(obj1[prop], obj2[prop as string]) + } else if (obj2[prop as string]) { + result[prop] = obj2[prop as string] } else if (descriptor1 && descriptor1.get) { Object.defineProperty(result, prop, descriptor1) } else if (typeof obj1[prop] === 'object') { - result[prop] = deepMergeGetters(obj1[prop], obj2[prop]) + result[prop] = deepMergeGetters(obj1[prop], obj2[prop as string]) } else { - result[prop] = obj2[prop] !== undefined ? obj2[prop] : obj1[prop] + result[prop] = + obj2[prop as string] !== undefined ? obj2[prop as string] : obj1[prop] } } diff --git a/src/utils/renderPath.ts b/src/utils/renderPath.ts index ba7c2b3..37eced3 100644 --- a/src/utils/renderPath.ts +++ b/src/utils/renderPath.ts @@ -2,47 +2,42 @@ import { InternalContextType } from 'src/context/InternalContext' import { ResolvedShape2DProps } from 'src/types' import { resolveColor, resolveExtendedColor } from './resolveColor' +let style export default ( context: InternalContextType, props: ResolvedShape2DProps, path: Path2D, ) => { + style = props.style context.ctx.save() // NOTE: it would be more performant if we would compose commands from the styles with mapArray // and forEach execute them, instead of doing checks on each renderPath. - if (props.style.shadow) { - context.ctx.shadowBlur = props.style.shadow.blur ?? 0 - context.ctx.shadowOffsetX = props.style.shadow.offset?.x ?? 0 - context.ctx.shadowOffsetY = props.style.shadow.offset?.y ?? 0 + if (style.shadow) { + context.ctx.shadowBlur = style.shadow.blur ?? 0 + context.ctx.shadowOffsetX = style.shadow.offset?.x ?? 0 + context.ctx.shadowOffsetY = style.shadow.offset?.y ?? 0 context.ctx.shadowColor = - resolveColor(props.style.shadow.color ?? 'black') ?? 'black' + resolveColor(style.shadow.color ?? 'black') ?? 'black' } - // if (props.style.composite) - context.ctx.globalCompositeOperation = props.style.composite ?? 'source-over' - if (props.style.opacity) context.ctx.globalAlpha = props.style.opacity + if (style.composite) context.ctx.globalCompositeOperation = style.composite + if (style.opacity) context.ctx.globalAlpha = style.opacity - context.ctx.setTransform(context.matrix) - if (props.style.fill) { - context.ctx.fillStyle = - resolveExtendedColor(props.style.fill) ?? 'transparent' + if (style.fill) { + context.ctx.fillStyle = resolveExtendedColor(style.fill) ?? 'transparent' context.ctx.fill(path) } - if (props.style.stroke && props.style.stroke !== 'transparent') { - if (props.style.lineWidth) context.ctx.lineWidth = props.style.lineWidth - if (props.style.miterLimit) context.ctx.miterLimit = props.style.miterLimit - if (props.style.lineJoin) - context.ctx.lineJoin = props.style.lineJoin ?? 'bevel' - if (context.ctx.lineCap) - context.ctx.lineCap = props.style.lineCap ?? 'round' - // if (props.style.lineDash) context.ctx.setLineDash(props.style.lineDash) + if (style.stroke && style.stroke !== 'transparent') { + if (style.lineWidth) context.ctx.lineWidth = style.lineWidth + if (style.miterLimit) context.ctx.miterLimit = style.miterLimit + if (style.lineJoin) context.ctx.lineJoin = style.lineJoin ?? 'bevel' + if (context.ctx.lineCap) context.ctx.lineCap = style.lineCap ?? 'round' + if (style.lineDash) context.ctx.setLineDash(style.lineDash) - context.ctx.strokeStyle = - resolveExtendedColor(props.style.stroke) ?? 'black' + context.ctx.strokeStyle = resolveExtendedColor(style.stroke) ?? 'black' context.ctx.stroke(path) } - context.ctx.resetTransform() context.ctx.restore() } diff --git a/src/utils/throttle.ts b/src/utils/throttle.ts new file mode 100644 index 0000000..3d61564 --- /dev/null +++ b/src/utils/throttle.ts @@ -0,0 +1,14 @@ +let amount = 60 +export const throttle = (callback: (...args: any[]) => void) => { + let lastTime: number = performance.now() + return (...args: any[]) => { + if (performance.now() - lastTime < 1000 / amount) { + lastTime = performance.now() + queueMicrotask(() => callback(...args)) + return + } else { + lastTime = performance.now() + return callback(...args) + } + } +} diff --git a/src/utils/transformedCallback.ts b/src/utils/transformedCallback.ts new file mode 100644 index 0000000..d4151cf --- /dev/null +++ b/src/utils/transformedCallback.ts @@ -0,0 +1,60 @@ +import { Transforms, Vector } from 'src/types' + +const transferMatrix = (matrix1: DOMMatrix, matrix2: DOMMatrix) => { + matrix1.a = matrix2.a + matrix1.b = matrix2.b + matrix1.c = matrix2.c + matrix1.d = matrix2.d + matrix1.e = matrix2.e + matrix1.f = matrix2.f +} + +const createTransformedCallback = () => { + let matrix = new DOMMatrix() + let matrix2 = new DOMMatrix() + let result: unknown | undefined + let position: Vector | undefined + let rotation: number | undefined + let transform: Partial | undefined + let left: number + let top: number + + return ( + ctx: CanvasRenderingContext2D, + props: { transform?: Transforms }, + callback: () => T, + ) => { + transform = props.transform + position = props.transform?.position + left = position?.x ?? 0 + top = position?.y ?? 0 + rotation = transform?.rotation ?? 0 + + if (!transform) { + return callback() + } else if (transform.skew) { + transferMatrix(matrix2, matrix) + + matrix.translateSelf(left, top) + matrix.rotateSelf(rotation ?? 0) + matrix.skewXSelf(transform.skew?.x ?? 0) + matrix.skewYSelf(transform.skew?.y ?? 0) + + ctx.setTransform(matrix) + result = callback() + ctx.setTransform(matrix2) + + transferMatrix(matrix, matrix2) + + return result + } else { + ctx.translate(left, top) + result = callback() + ctx.translate(left * -1, top * -1) + ctx.rotate(rotation * -1) + return result + } + } +} + +export { createTransformedCallback }