diff --git a/view/AGENTS.md b/view/AGENTS.md index 1d9db72..83c31a9 100644 --- a/view/AGENTS.md +++ b/view/AGENTS.md @@ -6,3 +6,4 @@ - Event handlers, toolbar buttons, menus, and effects must request state changes by dispatching commands. - Do not implement business rules, document mutation, renderer backend logic, or editor workflow ownership in components. - Effects are for subscriptions and UI integration only. +- Keep canvas components small. Extract renderer lifecycle, resize observation, input wiring, cursor logic, and other DOM integration into focused hooks/files under `view/canvas/`. diff --git a/view/CanvasViewport.tsx b/view/CanvasViewport.tsx index 884ef2c..2bcff44 100644 --- a/view/CanvasViewport.tsx +++ b/view/CanvasViewport.tsx @@ -1,14 +1,10 @@ -import { useEffect, useRef, useState } from "react"; +import { useMemo, useRef } from "react"; import type { AppStore } from "@editor/store"; import type { GlobalKeybindConsumer, GlobalPointerConsumer, GlobalWheelConsumer } from "@input/index"; -import { - createViewportPanInputController, - handleViewportWheel, - keybindEventFromKeyboardEvent, - pointerInputEventFromPointerEvent, - wheelInputEventFromWheelEvent, -} from "@input/index"; -import { createRenderer } from "@renderer/index"; +import { canvasCursorClass } from "./canvas/cursor"; +import { useCanvasInput } from "./canvas/useCanvasInput"; +import { useCanvasRenderer } from "./canvas/useCanvasRenderer"; +import { useCanvasResize } from "./canvas/useCanvasResize"; import { useAppState } from "./useAppState"; const ignoreGlobalKeybind: GlobalKeybindConsumer = () => false; @@ -30,123 +26,15 @@ export function CanvasViewport({ }: CanvasViewportProps) { const canvasRef = useRef(null); const state = useAppState(store); - const [isPanning, setIsPanning] = useState(false); + const inputOptions = useMemo( + () => ({ globalKeybindConsumer, globalPointerConsumer, globalWheelConsumer }), + [globalKeybindConsumer, globalPointerConsumer, globalWheelConsumer], + ); - useEffect(() => { - const canvas = canvasRef.current; - if (!canvas) return; - - const renderer = createRenderer(canvas); - renderer.render(store.getState()); - - const unsubscribe = store.subscribe((state) => { - renderer.render(state); - }); - - return () => { - unsubscribe(); - renderer.dispose(); - }; - }, [store]); - - useEffect(() => { - const canvas = canvasRef.current; - if (!canvas) return; - - const resizeObserver = new ResizeObserver(([entry]) => { - if (!entry) return; - - const width = Math.floor(entry.contentRect.width); - const height = Math.floor(entry.contentRect.height); - store.dispatch("viewport.setSize", { w: width, h: height }); - }); - - resizeObserver.observe(canvas); - return () => resizeObserver.disconnect(); - }, [store]); - - useEffect(() => { - const canvas = canvasRef.current; - if (!canvas) return; - - const panHandler = createViewportPanInputController({ - globalKeyConsumer: globalKeybindConsumer, - globalPointerConsumer, - dispatch: store.dispatch, - getCurrentZoom: () => store.getState().editor.viewport.zoom, - isPanMode: () => store.getState().editor.tools.interactionMode.type === "temporary-pan", - }); - - const handleKeyDown = (event: KeyboardEvent) => { - const consumed = panHandler.keyDown(keybindEventFromKeyboardEvent(event)); - if (consumed) event.preventDefault(); - }; - - const handleKeyUp = (event: KeyboardEvent) => { - const consumed = panHandler.keyUp(keybindEventFromKeyboardEvent(event)); - if (consumed) event.preventDefault(); - }; - - const handlePointerDown = (event: PointerEvent) => { - const consumed = panHandler.pointerDown(pointerInputEventFromPointerEvent(event)); - if (!consumed) return; - - canvas.setPointerCapture(event.pointerId); - setIsPanning(true); - event.preventDefault(); - }; - - const handlePointerMove = (event: PointerEvent) => { - const consumed = panHandler.pointerMove(pointerInputEventFromPointerEvent(event)); - if (consumed) event.preventDefault(); - }; - - const handlePointerUp = (event: PointerEvent) => { - const consumed = panHandler.pointerUp(pointerInputEventFromPointerEvent(event)); - if (!consumed) return; - - setIsPanning(false); - event.preventDefault(); - }; - - window.addEventListener("keydown", handleKeyDown); - window.addEventListener("keyup", handleKeyUp); - canvas.addEventListener("pointerdown", handlePointerDown); - canvas.addEventListener("pointermove", handlePointerMove); - canvas.addEventListener("pointerup", handlePointerUp); - canvas.addEventListener("pointercancel", handlePointerUp); - - return () => { - window.removeEventListener("keydown", handleKeyDown); - window.removeEventListener("keyup", handleKeyUp); - canvas.removeEventListener("pointerdown", handlePointerDown); - canvas.removeEventListener("pointermove", handlePointerMove); - canvas.removeEventListener("pointerup", handlePointerUp); - canvas.removeEventListener("pointercancel", handlePointerUp); - }; - }, [globalKeybindConsumer, globalPointerConsumer, store]); - - useEffect(() => { - const canvas = canvasRef.current; - if (!canvas) return; - - const handleWheel = (event: WheelEvent) => { - const consumed = handleViewportWheel({ - event: wheelInputEventFromWheelEvent(event), - globalConsumer: globalWheelConsumer, - dispatch: store.dispatch, - currentZoom: store.getState().editor.viewport.zoom, - }); - - if (consumed) event.preventDefault(); - }; - - canvas.addEventListener("wheel", handleWheel, { passive: false }); - return () => canvas.removeEventListener("wheel", handleWheel); - }, [globalWheelConsumer, store]); - - const isPanMode = state.editor.tools.interactionMode.type === "temporary-pan"; - const cursorClass = isPanning ? "cursor-grabbing" : isPanMode ? "cursor-grab" : "cursor-default"; + useCanvasRenderer(canvasRef, store); + useCanvasResize(canvasRef, store.dispatch); + const input = useCanvasInput(canvasRef, store, inputOptions); + const cursorClass = canvasCursorClass(state.editor.tools.interactionMode, input); return ; } diff --git a/view/canvas/cursor.ts b/view/canvas/cursor.ts new file mode 100644 index 0000000..311d92a --- /dev/null +++ b/view/canvas/cursor.ts @@ -0,0 +1,8 @@ +import type { InteractionMode } from "@editor/tools"; +import type { CanvasInputState } from "./useCanvasInput"; + +export function canvasCursorClass(interactionMode: InteractionMode, input: CanvasInputState) { + if (input.isPanning) return "cursor-grabbing"; + if (interactionMode.type === "temporary-pan") return "cursor-grab"; + return "cursor-default"; +} diff --git a/view/canvas/useCanvasInput.ts b/view/canvas/useCanvasInput.ts new file mode 100644 index 0000000..80b03d5 --- /dev/null +++ b/view/canvas/useCanvasInput.ts @@ -0,0 +1,104 @@ +import { useEffect, useState, type RefObject } from "react"; +import type { AppStore } from "@editor/store"; +import type { GlobalKeybindConsumer, GlobalPointerConsumer, GlobalWheelConsumer } from "@input/index"; +import { + createViewportPanInputController, + handleViewportWheel, + keybindEventFromKeyboardEvent, + pointerInputEventFromPointerEvent, + wheelInputEventFromWheelEvent, +} from "@input/index"; + +export type CanvasInputOptions = { + globalKeybindConsumer: GlobalKeybindConsumer; + globalPointerConsumer: GlobalPointerConsumer; + globalWheelConsumer: GlobalWheelConsumer; +}; + +export type CanvasInputState = { + isPanning: boolean; +}; + +export function useCanvasInput( + canvasRef: RefObject, + store: AppStore, + options: CanvasInputOptions, +): CanvasInputState { + const [isPanning, setIsPanning] = useState(false); + + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) return; + + const panHandler = createViewportPanInputController({ + globalKeyConsumer: options.globalKeybindConsumer, + globalPointerConsumer: options.globalPointerConsumer, + dispatch: store.dispatch, + getCurrentZoom: () => store.getState().editor.viewport.zoom, + isPanMode: () => store.getState().editor.tools.interactionMode.type === "temporary-pan", + }); + + const handleKeyDown = (event: KeyboardEvent) => { + const consumed = panHandler.keyDown(keybindEventFromKeyboardEvent(event)); + if (consumed) event.preventDefault(); + }; + + const handleKeyUp = (event: KeyboardEvent) => { + const consumed = panHandler.keyUp(keybindEventFromKeyboardEvent(event)); + if (consumed) event.preventDefault(); + }; + + const handlePointerDown = (event: PointerEvent) => { + const consumed = panHandler.pointerDown(pointerInputEventFromPointerEvent(event)); + if (!consumed) return; + + canvas.setPointerCapture(event.pointerId); + setIsPanning(true); + event.preventDefault(); + }; + + const handlePointerMove = (event: PointerEvent) => { + const consumed = panHandler.pointerMove(pointerInputEventFromPointerEvent(event)); + if (consumed) event.preventDefault(); + }; + + const handlePointerUp = (event: PointerEvent) => { + const consumed = panHandler.pointerUp(pointerInputEventFromPointerEvent(event)); + if (!consumed) return; + + setIsPanning(false); + event.preventDefault(); + }; + + const handleWheel = (event: WheelEvent) => { + const consumed = handleViewportWheel({ + event: wheelInputEventFromWheelEvent(event), + globalConsumer: options.globalWheelConsumer, + dispatch: store.dispatch, + currentZoom: store.getState().editor.viewport.zoom, + }); + + if (consumed) event.preventDefault(); + }; + + window.addEventListener("keydown", handleKeyDown); + window.addEventListener("keyup", handleKeyUp); + canvas.addEventListener("pointerdown", handlePointerDown); + canvas.addEventListener("pointermove", handlePointerMove); + canvas.addEventListener("pointerup", handlePointerUp); + canvas.addEventListener("pointercancel", handlePointerUp); + canvas.addEventListener("wheel", handleWheel, { passive: false }); + + return () => { + window.removeEventListener("keydown", handleKeyDown); + window.removeEventListener("keyup", handleKeyUp); + canvas.removeEventListener("pointerdown", handlePointerDown); + canvas.removeEventListener("pointermove", handlePointerMove); + canvas.removeEventListener("pointerup", handlePointerUp); + canvas.removeEventListener("pointercancel", handlePointerUp); + canvas.removeEventListener("wheel", handleWheel); + }; + }, [canvasRef, options, store]); + + return { isPanning }; +} diff --git a/view/canvas/useCanvasRenderer.ts b/view/canvas/useCanvasRenderer.ts new file mode 100644 index 0000000..ec3597c --- /dev/null +++ b/view/canvas/useCanvasRenderer.ts @@ -0,0 +1,22 @@ +import { useEffect, type RefObject } from "react"; +import type { AppStore } from "@editor/store"; +import { createRenderer } from "@renderer/index"; + +export function useCanvasRenderer(canvasRef: RefObject, store: AppStore) { + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) return; + + const renderer = createRenderer(canvas); + renderer.render(store.getState()); + + const unsubscribe = store.subscribe((state) => { + renderer.render(state); + }); + + return () => { + unsubscribe(); + renderer.dispose(); + }; + }, [canvasRef, store]); +} diff --git a/view/canvas/useCanvasResize.ts b/view/canvas/useCanvasResize.ts new file mode 100644 index 0000000..45a2746 --- /dev/null +++ b/view/canvas/useCanvasResize.ts @@ -0,0 +1,20 @@ +import { useEffect, type RefObject } from "react"; +import type { Dispatch } from "@commands/dispatcher"; + +export function useCanvasResize(canvasRef: RefObject, dispatch: Dispatch) { + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) return; + + const resizeObserver = new ResizeObserver(([entry]) => { + if (!entry) return; + + const width = Math.floor(entry.contentRect.width); + const height = Math.floor(entry.contentRect.height); + dispatch("viewport.setSize", { w: width, h: height }); + }); + + resizeObserver.observe(canvas); + return () => resizeObserver.disconnect(); + }, [canvasRef, dispatch]); +}