From f44f06f7219c034450be8f401ff2e99aeab3c883 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Mon, 16 Mar 2026 20:35:17 +0300 Subject: [PATCH] Refactor: extract business logic from components, clean composables MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tier 1 — Core API fixes: - updateNodeWithUndo now calls requestRender internally, eliminating 15+ redundant requestRender() calls across 6 property panel components - New editor/variables.ts: 7 undo-able variable CRUD operations (renameCollection, addCollection, removeCollection, addVariable, removeVariable, renameVariable, updateVariableValue) - New editor/alignment.ts: alignNodes, flipNodes, rotateNodes — moves bounding box geometry out of PositionSection.vue Tier 2 — Composable cleanup: - Merged use-node-props + use-multi-props into single useNodeProps() with MIXED sentinel, eliminating duplicate computed refs - Added toolCursor() utility — replaces if/else chain in EditorCanvas - Renamed use-toast.ts → toast.ts (singleton module, not a composable) Tier 3 — use-canvas-input.ts split (1497 → 817 lines): - input/types.ts (88) — DragState variants, HandlePosition, TOOL_TO_NODE - input/geometry.ts (170) — hit testing, handle positions, rotation cursor - input/pan-zoom.ts (266) — wheel, touch pinch, Safari gestures - input/resize.ts (121) — constrainToAspectRatio, applyResize - input/auto-layout.ts (103) — insert indicator computation All 955 tests pass, 0 lint errors, 1.48% duplication. --- packages/core/src/editor/alignment.ts | 179 +++++ packages/core/src/editor/create.ts | 10 + packages/core/src/editor/nodes.ts | 1 + packages/core/src/editor/variables.ts | 153 ++++ .../vue/src/composables/use-canvas-input.ts | 748 +----------------- .../vue/src/composables/use-multi-props.ts | 106 --- .../vue/src/composables/use-node-props.ts | 114 ++- packages/vue/src/index.ts | 11 +- packages/vue/src/input/auto-layout.ts | 103 +++ packages/vue/src/input/geometry.ts | 170 ++++ packages/vue/src/input/pan-zoom.ts | 266 +++++++ packages/vue/src/input/resize.ts | 121 +++ packages/vue/src/input/types.ts | 88 +++ .../{composables/use-toast.ts => toast.ts} | 0 packages/vue/src/utils/tool-cursor.ts | 20 + src/components/EditorCanvas.vue | 10 +- .../properties/AppearanceSection.vue | 1 - src/components/properties/EffectsSection.vue | 1 - src/components/properties/PositionSection.vue | 1 - .../properties/TypographySection.vue | 1 - src/composables/use-multi-props.ts | 2 +- 21 files changed, 1258 insertions(+), 848 deletions(-) create mode 100644 packages/core/src/editor/alignment.ts create mode 100644 packages/core/src/editor/variables.ts delete mode 100644 packages/vue/src/composables/use-multi-props.ts create mode 100644 packages/vue/src/input/auto-layout.ts create mode 100644 packages/vue/src/input/geometry.ts create mode 100644 packages/vue/src/input/pan-zoom.ts create mode 100644 packages/vue/src/input/resize.ts create mode 100644 packages/vue/src/input/types.ts rename packages/vue/src/{composables/use-toast.ts => toast.ts} (100%) create mode 100644 packages/vue/src/utils/tool-cursor.ts diff --git a/packages/core/src/editor/alignment.ts b/packages/core/src/editor/alignment.ts new file mode 100644 index 000000000..a9bf3c589 --- /dev/null +++ b/packages/core/src/editor/alignment.ts @@ -0,0 +1,179 @@ +import type { Vector } from '../types' +import type { SceneNode } from '../scene-graph' +import type { EditorContext } from './types' + +function computeAlignTarget( + min: number, + max: number, + size: number, + align: 'min' | 'center' | 'max' +): number { + if (align === 'min') return min + if (align === 'center') return (min + max) / 2 - size / 2 + return max - size +} + +function alignSingleNode( + ctx: EditorContext, + node: SceneNode, + axis: 'horizontal' | 'vertical', + align: 'min' | 'center' | 'max' +) { + const parent = node.parentId ? ctx.graph.getNode(node.parentId) : undefined + const pw = parent?.width ?? 0 + const ph = parent?.height ?? 0 + + if (axis === 'horizontal') { + ctx.graph.updateNode(node.id, { x: computeAlignTarget(0, pw, node.width, align) }) + } else { + ctx.graph.updateNode(node.id, { y: computeAlignTarget(0, ph, node.height, align) }) + } +} + +function alignMultipleNodes( + ctx: EditorContext, + nodes: SceneNode[], + axis: 'horizontal' | 'vertical', + align: 'min' | 'center' | 'max' +) { + const absPositions = new Map() + let minX = Infinity + let minY = Infinity + let maxX = -Infinity + let maxY = -Infinity + + for (const n of nodes) { + const abs = ctx.graph.getAbsolutePosition(n.id) + absPositions.set(n.id, abs) + minX = Math.min(minX, abs.x) + minY = Math.min(minY, abs.y) + maxX = Math.max(maxX, abs.x + n.width) + maxY = Math.max(maxY, abs.y + n.height) + } + + for (const n of nodes) { + const abs = absPositions.get(n.id) + if (!abs) continue + const parentAbs = n.parentId + ? ctx.graph.getAbsolutePosition(n.parentId) + : { x: 0, y: 0 } + + if (axis === 'horizontal') { + const target = computeAlignTarget(minX, maxX, n.width, align) + ctx.graph.updateNode(n.id, { x: target - parentAbs.x }) + } else { + const target = computeAlignTarget(minY, maxY, n.height, align) + ctx.graph.updateNode(n.id, { y: target - parentAbs.y }) + } + } +} + +export function createAlignmentActions(ctx: EditorContext) { + function alignNodes( + nodeIds: string[], + axis: 'horizontal' | 'vertical', + align: 'min' | 'center' | 'max' + ) { + if (nodeIds.length === 0) return + + const nodes = nodeIds + .map((id) => ctx.graph.getNode(id)) + .filter((n): n is SceneNode => n != null) + if (nodes.length === 0) return + + const originals = new Map() + for (const n of nodes) originals.set(n.id, { x: n.x, y: n.y }) + + if (nodes.length === 1) { + alignSingleNode(ctx, nodes[0], axis, align) + } else { + alignMultipleNodes(ctx, nodes, axis, align) + } + + const finals = new Map() + for (const n of nodes) finals.set(n.id, { x: n.x, y: n.y }) + + ctx.undo.push({ + label: 'Align', + forward: () => { + for (const [id, pos] of finals) { + ctx.graph.updateNode(id, pos) + ctx.runLayoutForNode(id) + } + }, + inverse: () => { + for (const [id, pos] of originals) { + ctx.graph.updateNode(id, pos) + ctx.runLayoutForNode(id) + } + } + }) + + for (const id of nodeIds) ctx.runLayoutForNode(id) + ctx.requestRender() + } + + function flipNodes(nodeIds: string[], axis: 'horizontal' | 'vertical') { + if (nodeIds.length === 0) return + + const originals = new Map() + for (const id of nodeIds) { + const node = ctx.graph.getNode(id) + if (!node) continue + originals.set(id, { flipX: node.flipX, flipY: node.flipY }) + const changes = + axis === 'horizontal' ? { flipX: !node.flipX } : { flipY: !node.flipY } + ctx.graph.updateNode(id, changes) + } + + const finals = new Map() + for (const [id] of originals) { + const node = ctx.graph.getNode(id) + if (node) finals.set(id, { flipX: node.flipX, flipY: node.flipY }) + } + + ctx.undo.push({ + label: 'Flip', + forward: () => { + for (const [id, val] of finals) ctx.graph.updateNode(id, val) + }, + inverse: () => { + for (const [id, val] of originals) ctx.graph.updateNode(id, val) + } + }) + + ctx.requestRender() + } + + function rotateNodes(nodeIds: string[], degrees: number) { + if (nodeIds.length === 0) return + + const originals = new Map() + for (const id of nodeIds) { + const node = ctx.graph.getNode(id) + if (!node) continue + originals.set(id, node.rotation) + ctx.graph.updateNode(id, { rotation: ((node.rotation + degrees) % 360 + 360) % 360 }) + } + + const finals = new Map() + for (const [id] of originals) { + const node = ctx.graph.getNode(id) + if (node) finals.set(id, node.rotation) + } + + ctx.undo.push({ + label: 'Rotate', + forward: () => { + for (const [id, rot] of finals) ctx.graph.updateNode(id, { rotation: rot }) + }, + inverse: () => { + for (const [id, rot] of originals) ctx.graph.updateNode(id, { rotation: rot }) + } + }) + + ctx.requestRender() + } + + return { alignNodes, flipNodes, rotateNodes } +} diff --git a/packages/core/src/editor/create.ts b/packages/core/src/editor/create.ts index 735f145d4..5e82baff5 100644 --- a/packages/core/src/editor/create.ts +++ b/packages/core/src/editor/create.ts @@ -6,6 +6,7 @@ import { SceneGraph } from '../scene-graph' import { TextEditor } from '../text-editor' import { UndoManager } from '../undo' +import { createAlignmentActions } from './alignment' import { createClipboardActions } from './clipboard' import { createComponentActions } from './components' import { createNodeActions } from './nodes' @@ -15,6 +16,7 @@ import { createShapeActions } from './shapes' import { createStructureActions } from './structure' import { createTextActions } from './text' import { createUndoActions } from './undo' +import { createVariableActions } from './variables' import { createViewportActions } from './viewport' import type { SceneNode } from '../scene-graph' @@ -172,6 +174,8 @@ export function createEditor(options?: EditorOptions) { const undoActions = createUndoActions(ctx) const text = createTextActions(ctx) const nodes = createNodeActions(ctx) + const variables = createVariableActions(ctx) + const alignment = createAlignmentActions(ctx) function setCanvasKit(ck: CanvasKit, renderer: SkiaRenderer) { _ck = ck @@ -218,6 +222,12 @@ export function createEditor(options?: EditorOptions) { // Nodes (update, layout) ...nodes, + // Alignment (align, flip, rotate) + ...alignment, + + // Variables + ...variables, + // Text editing ...text, diff --git a/packages/core/src/editor/nodes.ts b/packages/core/src/editor/nodes.ts index b3ed00777..270f84b7f 100644 --- a/packages/core/src/editor/nodes.ts +++ b/packages/core/src/editor/nodes.ts @@ -28,6 +28,7 @@ export function createNodeActions(ctx: EditorContext) { ctx.runLayoutForNode(id) } }) + ctx.requestRender() } function setLayoutMode(id: string, mode: LayoutMode) { diff --git a/packages/core/src/editor/variables.ts b/packages/core/src/editor/variables.ts new file mode 100644 index 000000000..b6e355f50 --- /dev/null +++ b/packages/core/src/editor/variables.ts @@ -0,0 +1,153 @@ +import type { Variable, VariableCollection, VariableValue } from '../scene-graph' +import type { EditorContext } from './types' + +export function createVariableActions(ctx: EditorContext) { + function renameCollection(id: string, newName: string) { + const collection = ctx.graph.variableCollections.get(id) + if (!collection) return + const prevName = collection.name + collection.name = newName + ctx.undo.push({ + label: 'Rename collection', + forward: () => { + const c = ctx.graph.variableCollections.get(id) + if (c) c.name = newName + ctx.requestRender() + }, + inverse: () => { + const c = ctx.graph.variableCollections.get(id) + if (c) c.name = prevName + ctx.requestRender() + } + }) + ctx.requestRender() + } + + function addCollection(collection: VariableCollection) { + ctx.graph.addCollection(collection) + ctx.undo.push({ + label: 'Add collection', + forward: () => { + ctx.graph.addCollection(collection) + ctx.requestRender() + }, + inverse: () => { + ctx.graph.removeCollection(collection.id) + ctx.requestRender() + } + }) + ctx.requestRender() + } + + function removeCollection(id: string) { + const collection = ctx.graph.variableCollections.get(id) + if (!collection) return + const snapshot = structuredClone(collection) + const variables = snapshot.variableIds + .map((vid) => ctx.graph.variables.get(vid)) + .filter((v): v is Variable => v != null) + .map((v) => structuredClone(v)) + ctx.graph.removeCollection(id) + ctx.undo.push({ + label: 'Remove collection', + forward: () => { + ctx.graph.removeCollection(id) + ctx.requestRender() + }, + inverse: () => { + ctx.graph.addCollection(snapshot) + for (const v of variables) ctx.graph.addVariable(v) + ctx.requestRender() + } + }) + ctx.requestRender() + } + + function addVariable(variable: Variable) { + ctx.graph.addVariable(variable) + ctx.undo.push({ + label: 'Add variable', + forward: () => { + ctx.graph.addVariable(variable) + ctx.requestRender() + }, + inverse: () => { + ctx.graph.removeVariable(variable.id) + ctx.requestRender() + } + }) + ctx.requestRender() + } + + function removeVariable(id: string) { + const variable = ctx.graph.variables.get(id) + if (!variable) return + const snapshot = structuredClone(variable) + ctx.graph.removeVariable(id) + ctx.undo.push({ + label: 'Remove variable', + forward: () => { + ctx.graph.removeVariable(id) + ctx.requestRender() + }, + inverse: () => { + ctx.graph.addVariable(snapshot) + ctx.requestRender() + } + }) + ctx.requestRender() + } + + function renameVariable(id: string, newName: string) { + const variable = ctx.graph.variables.get(id) + if (!variable) return + const prevName = variable.name + variable.name = newName + ctx.undo.push({ + label: 'Rename variable', + forward: () => { + const v = ctx.graph.variables.get(id) + if (v) v.name = newName + ctx.requestRender() + }, + inverse: () => { + const v = ctx.graph.variables.get(id) + if (v) v.name = prevName + ctx.requestRender() + } + }) + ctx.requestRender() + } + + function updateVariableValue(id: string, modeId: string, value: VariableValue) { + const variable = ctx.graph.variables.get(id) + if (!variable) return + const prevValue = structuredClone(variable.valuesByMode[modeId]) + const newValue = structuredClone(value) + variable.valuesByMode[modeId] = newValue + ctx.undo.push({ + label: 'Update variable value', + forward: () => { + const v = ctx.graph.variables.get(id) + if (v) v.valuesByMode[modeId] = structuredClone(newValue) + ctx.requestRender() + }, + inverse: () => { + const v = ctx.graph.variables.get(id) + if (v) v.valuesByMode[modeId] = structuredClone(prevValue) + ctx.requestRender() + } + }) + ctx.requestRender() + } + + return { + renameCollection, + addCollection, + removeCollection, + addVariable, + removeVariable, + renameVariable, + updateVariableValue + } +} diff --git a/packages/vue/src/composables/use-canvas-input.ts b/packages/vue/src/composables/use-canvas-input.ts index 05c8d1924..1f9fa6830 100644 --- a/packages/vue/src/composables/use-canvas-input.ts +++ b/packages/vue/src/composables/use-canvas-input.ts @@ -3,8 +3,6 @@ import { ref, type Ref } from 'vue' import { AUTO_LAYOUT_BREAK_THRESHOLD, - CORNER_ROTATE_ZONE, - HANDLE_HIT_RADIUS, PEN_CLOSE_THRESHOLD, ROTATION_SNAP_DEGREES, DEFAULT_TEXT_WIDTH, @@ -14,260 +12,20 @@ import { degToRad } from '@open-pencil/core' -import type { Editor, Tool } from '@open-pencil/core/editor' -import type { NodeType, Rect, SceneNode, Vector } from '@open-pencil/core' +import type { Editor } from '@open-pencil/core/editor' +import type { SceneNode } from '@open-pencil/core' -type HandlePosition = 'nw' | 'n' | 'ne' | 'e' | 'se' | 's' | 'sw' | 'w' - -interface DragDraw { - type: 'draw' - startX: number - startY: number - nodeId: string -} - -interface DragMove { - type: 'move' - startX: number - startY: number - originals: Map - duplicated?: boolean - autoLayoutParentId?: string - brokeFromAutoLayout?: boolean -} - -interface DragPan { - type: 'pan' - startScreenX: number - startScreenY: number - startPanX: number - startPanY: number -} - -interface DragResize { - type: 'resize' - handle: HandlePosition - startX: number - startY: number - origRect: Rect - nodeId: string -} - -interface DragMarquee { - type: 'marquee' - startX: number - startY: number -} - -interface DragRotate { - type: 'rotate' - nodeId: string - centerX: number - centerY: number - startAngle: number - origRotation: number -} - -interface DragPen { - type: 'pen-drag' - startX: number - startY: number -} - -interface DragTextSelect { - type: 'text-select' - startX: number - startY: number -} - -type DragState = - | DragDraw - | DragMove - | DragPan - | DragResize - | DragMarquee - | DragRotate - | DragPen - | DragTextSelect - -const TOOL_TO_NODE: Partial> = { - FRAME: 'FRAME', - SECTION: 'SECTION', - RECTANGLE: 'RECTANGLE', - ELLIPSE: 'ELLIPSE', - LINE: 'LINE', - POLYGON: 'POLYGON', - STAR: 'STAR', - TEXT: 'TEXT' -} - -const HANDLE_CURSORS: Record = { - nw: 'nwse-resize', - n: 'ns-resize', - ne: 'nesw-resize', - e: 'ew-resize', - se: 'nwse-resize', - s: 'ns-resize', - sw: 'nesw-resize', - w: 'ew-resize' -} - -function getScreenRect( - absX: number, - absY: number, - w: number, - h: number, - zoom: number, - panX: number, - panY: number -) { - return { - x1: absX * zoom + panX, - y1: absY * zoom + panY, - x2: (absX + w) * zoom + panX, - y2: (absY + h) * zoom + panY - } -} - -function getHandlePositions( - absX: number, - absY: number, - w: number, - h: number, - zoom: number, - panX: number, - panY: number -) { - const { x1, y1, x2, y2 } = getScreenRect(absX, absY, w, h, zoom, panX, panY) - const mx = (x1 + x2) / 2 - const my = (y1 + y2) / 2 - - return { - nw: { x: x1, y: y1 }, - n: { x: mx, y: y1 }, - ne: { x: x2, y: y1 }, - e: { x: x2, y: my }, - se: { x: x2, y: y2 }, - s: { x: mx, y: y2 }, - sw: { x: x1, y: y2 }, - w: { x: x1, y: my } - } satisfies Record -} - -function unrotate( - sx: number, - sy: number, - centerX: number, - centerY: number, - rotation: number -): { sx: number; sy: number } { - if (rotation === 0) return { sx, sy } - const rad = (-rotation * Math.PI) / 180 - const cos = Math.cos(rad) - const sin = Math.sin(rad) - const dx = sx - centerX - const dy = sy - centerY - return { - sx: centerX + dx * cos - dy * sin, - sy: centerY + dx * sin + dy * cos - } -} - -function hitTestHandle( - sx: number, - sy: number, - absX: number, - absY: number, - w: number, - h: number, - zoom: number, - panX: number, - panY: number, - rotation = 0 -): HandlePosition | null { - const { x1, y1, x2, y2 } = getScreenRect(absX, absY, w, h, zoom, panX, panY) - const cx = (x1 + x2) / 2 - const cy = (y1 + y2) / 2 - const ur = unrotate(sx, sy, cx, cy, rotation) - - const handles = getHandlePositions(absX, absY, w, h, zoom, panX, panY) - for (const [pos, pt] of Object.entries(handles)) { - if (Math.abs(ur.sx - pt.x) < HANDLE_HIT_RADIUS && Math.abs(ur.sy - pt.y) < HANDLE_HIT_RADIUS) { - return pos as HandlePosition - } - } - return null -} - -type CornerPosition = 'nw' | 'ne' | 'se' | 'sw' - -function hitTestCornerRotation( - sx: number, - sy: number, - absX: number, - absY: number, - w: number, - h: number, - zoom: number, - panX: number, - panY: number, - rotation = 0 -): CornerPosition | null { - const { x1, y1, x2, y2 } = getScreenRect(absX, absY, w, h, zoom, panX, panY) - const cx = (x1 + x2) / 2 - const cy = (y1 + y2) / 2 - const ur = unrotate(sx, sy, cx, cy, rotation) - - const corners: Array<{ pos: CornerPosition; x: number; y: number }> = [ - { pos: 'nw', x: x1, y: y1 }, - { pos: 'ne', x: x2, y: y1 }, - { pos: 'se', x: x2, y: y2 }, - { pos: 'sw', x: x1, y: y2 } - ] - - for (const { pos, x, y } of corners) { - const dx = Math.abs(ur.sx - x) - const dy = Math.abs(ur.sy - y) - if ( - dx <= CORNER_ROTATE_ZONE && - dy <= CORNER_ROTATE_ZONE && - (dx > HANDLE_HIT_RADIUS || dy > HANDLE_HIT_RADIUS) - ) { - return pos - } - } - return null -} - -const CORNER_BASE_ANGLES: Record = { nw: 0, ne: 90, se: 180, sw: 270 } - -import rotateCursorSvg from '../assets/rotate-cursor.svg?raw' - -const rotationCursorCache = new Map() - -function buildRotationCursor(angleDeg: number): string { - const key = Math.round(angleDeg) % 360 - let cached = rotationCursorCache.get(key) - if (cached) return cached - let svg: string - if (key === 0) { - svg = rotateCursorSvg - } else { - svg = rotateCursorSvg - .replace( - '', '') - } - cached = `url("data:image/svg+xml,${encodeURIComponent(svg)}") 12 12, auto` - rotationCursorCache.set(key, cached) - return cached -} - -function cornerRotationCursor(corner: CornerPosition, nodeRotation = 0): string { - return buildRotationCursor(CORNER_BASE_ANGLES[corner] + nodeRotation) -} +import type { DragDraw, DragMarquee, DragMove, DragPan, DragRotate, DragState } from '../input/types' +import { TOOL_TO_NODE } from '../input/types' +import { + HANDLE_CURSORS, + hitTestHandle, + hitTestCornerRotation, + cornerRotationCursor +} from '../input/geometry' +import { setupPanZoom } from '../input/pan-zoom' +import { applyResize, tryStartResize } from '../input/resize' +import { computeAutoLayoutIndicator, computeAutoLayoutIndicatorForFrame } from '../input/auto-layout' export function useCanvasInput( canvasRef: Ref, @@ -351,11 +109,11 @@ export function useCanvasInput( } function handleTextEditClick(cx: number, cy: number, shiftKey: boolean): boolean { - const editor = editor.textEditor + const textEd = editor.textEditor const editNode = editor.state.editingTextId ? editor.graph.getNode(editor.state.editingTextId) : null - if (!editor || !editNode) { + if (!textEd || !editNode) { editor.commitTextEdit() return false } @@ -367,11 +125,11 @@ export function useCanvasInput( return false } if (clickCount >= 3) { - editor.selectAll() + textEd.selectAll() } else if (clickCount === 2) { - editor.selectWordAt(localX, localY) + textEd.selectWordAt(localX, localY) } else { - editor.setCursorAt(localX, localY, shiftKey) + textEd.setCursorAt(localX, localY, shiftKey) drag.value = { type: 'text-select', startX: cx, startY: cy } as DragState } editor.requestRender() @@ -414,38 +172,6 @@ export function useCanvasInput( return true } - function tryStartResize(sx: number, sy: number, cx: number, cy: number): boolean { - for (const id of editor.state.selectedIds) { - const node = editor.graph.getNode(id) - if (!node || node.locked) continue - const abs = editor.graph.getAbsolutePosition(id) - const handle = hitTestHandle( - sx, - sy, - abs.x, - abs.y, - node.width, - node.height, - editor.state.zoom, - editor.state.panX, - editor.state.panY, - node.rotation - ) - if (handle) { - drag.value = { - type: 'resize', - handle, - startX: cx, - startY: cy, - origRect: { x: node.x, y: node.y, width: node.width, height: node.height }, - nodeId: id - } - return true - } - } - return false - } - function duplicateAndDrag( cx: number, cy: number @@ -529,7 +255,12 @@ export function useCanvasInput( if (editor.state.editingTextId) editor.commitTextEdit() if (tryStartRotation(sx, sy)) return - if (tryStartResize(sx, sy, cx, cy)) return + + const resizeDrag = tryStartResize(sx, sy, cx, cy, editor) + if (resizeDrag) { + drag.value = resizeDrag + return + } const hit = resolveHit(cx, cy) if (!hit) { @@ -774,7 +505,7 @@ export function useCanvasInput( if (d.autoLayoutParentId && !d.brokeFromAutoLayout) { const dist = Math.sqrt(dx * dx + dy * dy) if (dist < AUTO_LAYOUT_BREAK_THRESHOLD) { - computeAutoLayoutIndicator(d, cx, cy) + computeAutoLayoutIndicator(d, cx, cy, editor) return } d.brokeFromAutoLayout = true @@ -785,7 +516,7 @@ export function useCanvasInput( const dropParent = dropTarget ? editor.graph.getNode(dropTarget.id) : null if (dropParent && dropParent.layoutMode !== 'NONE') { - computeAutoLayoutIndicatorForFrame(dropParent, cx, cy) + computeAutoLayoutIndicatorForFrame(dropParent, cx, cy, editor) editor.setDropTarget(dropParent.id) for (const [id, orig] of d.originals) { editor.graph.updateNode(id, { @@ -811,13 +542,13 @@ export function useCanvasInput( } function handleTextSelectMove(cx: number, cy: number) { - const editor = editor.textEditor + const textEd = editor.textEditor const editNode = editor.state.editingTextId ? editor.graph.getNode(editor.state.editingTextId) : null - if (editor && editNode) { + if (textEd && editNode) { const abs = editor.graph.getAbsolutePosition(editNode.id) - editor.setCursorAt(cx - abs.x, cy - abs.y, true) + textEd.setCursorAt(cx - abs.x, cy - abs.y, true) editor.requestRender() } } @@ -919,7 +650,7 @@ export function useCanvasInput( return } if (d.type === 'resize') { - applyResize(d, cx, cy, e.shiftKey) + applyResize(d, cx, cy, e.shiftKey, editor) return } @@ -940,79 +671,6 @@ export function useCanvasInput( handleMarqueeMove(d, cx, cy) } - function constrainToAspectRatio( - handle: HandlePosition, - origRect: Rect, - width: number, - height: number, - dx: number, - dy: number - ): Rect { - let x = handle.includes('w') ? origRect.x + origRect.width - Math.abs(width) : origRect.x - const isTop = handle === 'nw' || handle === 'n' || handle === 'ne' - let y = isTop ? origRect.y + origRect.height - Math.abs(height) : origRect.y - const aspect = origRect.width / origRect.height - - if (handle === 'n' || handle === 's') { - width = Math.abs(height) * aspect - x = origRect.x + (origRect.width - width) / 2 - } else if (handle === 'e' || handle === 'w') { - height = Math.abs(width) / aspect - y = origRect.y + (origRect.height - height) / 2 - } else if (Math.abs(dx) > Math.abs(dy)) { - height = (Math.abs(width) / aspect) * Math.sign(height || 1) - if (isTop) y = origRect.y + origRect.height - Math.abs(height) - } else { - width = Math.abs(height) * aspect * Math.sign(width || 1) - if (handle.includes('w')) x = origRect.x + origRect.width - Math.abs(width) - } - - return { x, y, width, height } - } - - function applyResize(d: DragResize, cx: number, cy: number, constrain: boolean) { - const { handle, origRect } = d - let { x, y, width, height } = origRect - const dx = cx - d.startX - const dy = cy - d.startY - - const moveLeft = handle.includes('w') - const moveRight = handle.includes('e') - const moveTop = handle === 'nw' || handle === 'n' || handle === 'ne' - const moveBottom = handle === 'sw' || handle === 's' || handle === 'se' - - if (moveRight) width = origRect.width + dx - if (moveLeft) { - x = origRect.x + dx - width = origRect.width - dx - } - if (moveBottom) height = origRect.height + dy - if (moveTop) { - y = origRect.y + dy - height = origRect.height - dy - } - - if (constrain && origRect.width > 0 && origRect.height > 0) { - ;({ x, y, width, height } = constrainToAspectRatio(handle, origRect, width, height, dx, dy)) - } - - if (width < 0) { - x += width - width = -width - } - if (height < 0) { - y += height - height = -height - } - - editor.updateNode(d.nodeId, { - x: Math.round(x), - y: Math.round(y), - width: Math.round(Math.max(1, width)), - height: Math.round(Math.max(1, height)) - }) - } - function handleMoveUp(d: DragMove) { const indicator = editor.state.layoutInsertIndicator editor.setLayoutInsertIndicator(null) @@ -1095,65 +753,6 @@ export function useCanvasInput( cursorOverride.value = null } - const wheelAccum = { - deltaX: 0, - deltaY: 0, - zoomDelta: 0, - zoomCenterX: 0, - zoomCenterY: 0, - hasZoom: false, - rafId: 0 - } - - function flushWheel() { - wheelAccum.rafId = 0 - editor.setHoveredNode(null) - if (wheelAccum.hasZoom) { - editor.applyZoom(wheelAccum.zoomDelta, wheelAccum.zoomCenterX, wheelAccum.zoomCenterY) - } else { - editor.pan(wheelAccum.deltaX, wheelAccum.deltaY) - } - wheelAccum.deltaX = 0 - wheelAccum.deltaY = 0 - wheelAccum.zoomDelta = 0 - wheelAccum.hasZoom = false - } - - // Normalize wheel deltaY across deltaMode variants (line/page/pixel). - // Trackpad pinch is always DOM_DELTA_PIXEL; external mice may use LINE or PAGE. - function normalizeWheelDelta(e: WheelEvent): { dx: number; dy: number } { - let { deltaX, deltaY } = e - if (e.deltaMode === WheelEvent.DOM_DELTA_LINE) { - deltaX *= 40 - deltaY *= 40 - } else if (e.deltaMode === WheelEvent.DOM_DELTA_PAGE) { - deltaX *= 800 - deltaY *= 800 - } - return { dx: deltaX, dy: deltaY } - } - - function onWheel(e: WheelEvent) { - e.preventDefault() - const canvas = canvasRef.value - if (!canvas) return - const { dx, dy } = normalizeWheelDelta(e) - - if (e.ctrlKey || e.metaKey) { - const rect = canvas.getBoundingClientRect() - wheelAccum.zoomCenterX = e.clientX - rect.left - wheelAccum.zoomCenterY = e.clientY - rect.top - wheelAccum.zoomDelta += dy - wheelAccum.hasZoom = true - } else { - wheelAccum.deltaX -= dx - wheelAccum.deltaY -= dy - } - if (!wheelAccum.rafId) { - wheelAccum.rafId = requestAnimationFrame(flushWheel) - } - } - function onDblClick(e: MouseEvent) { if (editor.state.editingTextId) return @@ -1184,10 +783,10 @@ export function useCanvasInput( if (hit.type === 'TEXT') { editor.select([hit.id]) editor.startTextEditing(hit.id) - const editor = editor.textEditor - if (editor) { + const textEd = editor.textEditor + if (textEd) { const abs = editor.graph.getAbsolutePosition(hit.id) - editor.selectWordAt(cx - abs.x, cy - abs.y) + textEd.selectWordAt(cx - abs.x, cy - abs.y) editor.requestRender() } return @@ -1196,225 +795,6 @@ export function useCanvasInput( editor.select([hit.id]) } - function computeAutoLayoutIndicator(d: DragMove, cx: number, cy: number) { - if (!d.autoLayoutParentId) return - const parent = editor.graph.getNode(d.autoLayoutParentId) - if (!parent || parent.layoutMode === 'NONE') return - computeAutoLayoutIndicatorForFrame(parent, cx, cy) - } - - function computeIndicatorPosition( - children: SceneNode[], - insertIndex: number, - parent: SceneNode, - parentAbs: Vector, - isRow: boolean - ): number { - if (children.length === 0) { - return isRow ? parentAbs.x + parent.paddingLeft : parentAbs.y + parent.paddingTop - } - if (insertIndex === 0) { - const firstAbs = editor.graph.getAbsolutePosition(children[0].id) - return (isRow ? firstAbs.x : firstAbs.y) - parent.itemSpacing / 2 - } - if (insertIndex >= children.length) { - const last = children[children.length - 1] - const lastAbs = editor.graph.getAbsolutePosition(last.id) - return isRow - ? lastAbs.x + last.width + parent.itemSpacing / 2 - : lastAbs.y + last.height + parent.itemSpacing / 2 - } - const prev = children[insertIndex - 1] - const next = children[insertIndex] - const prevAbs = editor.graph.getAbsolutePosition(prev.id) - const nextAbs = editor.graph.getAbsolutePosition(next.id) - return isRow - ? (prevAbs.x + prev.width + nextAbs.x) / 2 - : (prevAbs.y + prev.height + nextAbs.y) / 2 - } - - function filteredToRealIndex(parentId: string, insertIndex: number): number { - const allChildren = editor.graph.getChildren(parentId) - let realIndex = 0 - let filteredCount = 0 - for (const child of allChildren) { - if (editor.state.selectedIds.has(child.id)) continue - if (child.layoutPositioning === 'ABSOLUTE') { - realIndex++ - continue - } - if (filteredCount === insertIndex) break - filteredCount++ - realIndex++ - } - return realIndex - } - - function computeAutoLayoutIndicatorForFrame(parent: SceneNode, cx: number, cy: number) { - const children = editor.graph - .getChildren(parent.id) - .filter((c) => c.layoutPositioning !== 'ABSOLUTE' && !editor.state.selectedIds.has(c.id)) - - const parentAbs = editor.graph.getAbsolutePosition(parent.id) - const isRow = parent.layoutMode === 'HORIZONTAL' - - let insertIndex = children.length - for (let i = 0; i < children.length; i++) { - const childAbs = editor.graph.getAbsolutePosition(children[i].id) - const mid = isRow ? childAbs.x + children[i].width / 2 : childAbs.y + children[i].height / 2 - if ((isRow ? cx : cy) < mid) { - insertIndex = i - break - } - } - - const indicatorPos = computeIndicatorPosition(children, insertIndex, parent, parentAbs, isRow) - const crossStart = isRow ? parentAbs.y + parent.paddingTop : parentAbs.x + parent.paddingLeft - const crossLength = isRow - ? parent.height - parent.paddingTop - parent.paddingBottom - : parent.width - parent.paddingLeft - parent.paddingRight - - editor.setLayoutInsertIndicator({ - parentId: parent.id, - index: filteredToRealIndex(parent.id, insertIndex), - x: isRow ? indicatorPos : crossStart, - y: isRow ? crossStart : indicatorPos, - length: crossLength, - direction: isRow ? 'VERTICAL' : 'HORIZONTAL' - }) - } - - let activeTouches: Touch[] = [] - let pinchStartDist = 0 - let pinchStartZoom = 0 - let pinchMidX = 0 - let pinchMidY = 0 - - function touchDist(a: Touch, b: Touch) { - return Math.hypot(a.clientX - b.clientX, a.clientY - b.clientY) - } - - let touchAsMouse = false - - function syntheticMouse(type: string, t: Touch): MouseEvent { - return new MouseEvent(type, { - clientX: t.clientX, - clientY: t.clientY, - screenX: t.screenX, - screenY: t.screenY, - button: 0, - buttons: 1, - bubbles: true - }) - } - - function onTouchStart(e: TouchEvent) { - e.preventDefault() - activeTouches = Array.from(e.touches) - const canvas = canvasRef.value - if (!canvas) return - - if (activeTouches.length === 2) { - if (touchAsMouse) { - onMouseUp() - touchAsMouse = false - } - drag.value = null - const [a, b] = activeTouches - pinchStartDist = touchDist(a, b) - pinchStartZoom = editor.state.zoom - const rect = canvas.getBoundingClientRect() - pinchMidX = (a.clientX + b.clientX) / 2 - rect.left - pinchMidY = (a.clientY + b.clientY) / 2 - rect.top - } else if (activeTouches.length === 1) { - const t = activeTouches[0] - const tool = editor.state.activeTool - if (tool === 'HAND') { - touchAsMouse = false - drag.value = { - type: 'pan', - startScreenX: t.clientX, - startScreenY: t.clientY, - startPanX: editor.state.panX, - startPanY: editor.state.panY - } - } else { - touchAsMouse = true - onMouseDown(syntheticMouse('mousedown', t)) - } - } - } - - function onTouchMove(e: TouchEvent) { - e.preventDefault() - activeTouches = Array.from(e.touches) - const canvas = canvasRef.value - if (!canvas) return - - if (activeTouches.length === 2) { - const [a, b] = activeTouches - const rect = canvas.getBoundingClientRect() - const newMidX = (a.clientX + b.clientX) / 2 - rect.left - const newMidY = (a.clientY + b.clientY) / 2 - rect.top - - editor.setHoveredNode(null) - const newDist = touchDist(a, b) - if (pinchStartDist > 0) { - const scale = newDist / pinchStartDist - const newZoom = Math.max(0.02, Math.min(256, pinchStartZoom * scale)) - const zoomRatio = newZoom / editor.state.zoom - - const panDx = newMidX - pinchMidX - const panDy = newMidY - pinchMidY - - editor.state.panX = pinchMidX - (pinchMidX - editor.state.panX) * zoomRatio + panDx - editor.state.panY = pinchMidY - (pinchMidY - editor.state.panY) * zoomRatio + panDy - editor.state.zoom = newZoom - } - - pinchMidX = newMidX - pinchMidY = newMidY - editor.requestRepaint() - } else if (activeTouches.length === 1) { - const t = activeTouches[0] - if (touchAsMouse) { - onMouseMove(syntheticMouse('mousemove', t)) - } else if (drag.value?.type === 'pan') { - const d = drag.value - editor.state.panX = d.startPanX + (t.clientX - d.startScreenX) - editor.state.panY = d.startPanY + (t.clientY - d.startScreenY) - editor.requestRepaint() - } - } - } - - function onTouchEnd(e: TouchEvent) { - e.preventDefault() - activeTouches = Array.from(e.touches) - - if (activeTouches.length === 0) { - if (touchAsMouse) { - onMouseUp() - touchAsMouse = false - } else { - drag.value = null - } - pinchStartDist = 0 - } else if (activeTouches.length === 1) { - const t = activeTouches[0] - if (!touchAsMouse) { - drag.value = { - type: 'pan', - startScreenX: t.clientX, - startScreenY: t.clientY, - startPanX: editor.state.panX, - startPanY: editor.state.panY - } - } - pinchStartDist = 0 - } - } - useEventListener(canvasRef, 'dblclick', onDblClick) useEventListener(canvasRef, 'mousedown', onMouseDown) useEventListener(canvasRef, 'mousemove', onMouseMove) @@ -1427,68 +807,8 @@ export function useCanvasInput( useEventListener(window, 'mouseup', () => { if (drag.value) onMouseUp() }) - useEventListener(canvasRef, 'wheel', onWheel, { passive: false }) - useEventListener(canvasRef, 'touchstart', onTouchStart, { passive: false }) - useEventListener(canvasRef, 'touchmove', onTouchMove, { passive: false }) - useEventListener(canvasRef, 'touchend', onTouchEnd, { passive: false }) - useEventListener(canvasRef, 'touchcancel', onTouchEnd, { passive: false }) - // Safari macOS: trackpad pinch-to-zoom uses gesture events, not wheel+ctrlKey - let gestureStartZoom = 1 - let gestureRafId = 0 - let pendingGesture: { scale: number; sx: number; sy: number } | null = null - - function flushGesture() { - gestureRafId = 0 - if (!pendingGesture) return - editor.setHoveredNode(null) - const { scale, sx, sy } = pendingGesture - pendingGesture = null - const newZoom = Math.max(0.02, Math.min(256, gestureStartZoom * scale)) - const zoomRatio = newZoom / editor.state.zoom - editor.state.panX = sx - (sx - editor.state.panX) * zoomRatio - editor.state.panY = sy - (sy - editor.state.panY) * zoomRatio - editor.state.zoom = newZoom - editor.requestRepaint() - } - - useEventListener( - canvasRef, - 'gesturestart' as keyof HTMLElementEventMap, - (e: Event) => { - e.preventDefault() - gestureStartZoom = editor.state.zoom - }, - { passive: false } - ) - useEventListener( - canvasRef, - 'gesturechange' as keyof HTMLElementEventMap, - (e: Event) => { - e.preventDefault() - const ge = e as GestureEvent - const canvas = canvasRef.value - if (!canvas) return - const rect = canvas.getBoundingClientRect() - pendingGesture = { - scale: ge.scale, - sx: ge.clientX - rect.left, - sy: ge.clientY - rect.top - } - if (!gestureRafId) { - gestureRafId = requestAnimationFrame(flushGesture) - } - }, - { passive: false } - ) - useEventListener( - canvasRef, - 'gestureend' as keyof HTMLElementEventMap, - (e: Event) => { - e.preventDefault() - }, - { passive: false } - ) + setupPanZoom(canvasRef, editor, drag, getCoords, onMouseDown, onMouseMove, onMouseUp) return { drag, diff --git a/packages/vue/src/composables/use-multi-props.ts b/packages/vue/src/composables/use-multi-props.ts deleted file mode 100644 index 1c81a010c..000000000 --- a/packages/vue/src/composables/use-multi-props.ts +++ /dev/null @@ -1,106 +0,0 @@ -import { computed } from 'vue' - -import { useEditor } from '../context' - -import type { SceneNode } from '@open-pencil/core' - -export const MIXED = Symbol('mixed') -export type MixedValue = T | typeof MIXED - -export function useMultiProps() { - const store = useEditor() - const node = computed(() => store.getSelectedNode() ?? null) - const nodes = computed(() => store.getSelectedNodes()) - const isMulti = computed(() => nodes.value.length > 1) - const active = computed(() => node.value || isMulti.value) - const activeNode = computed(() => node.value ?? (nodes.value[0] as SceneNode | undefined) ?? null) - - function merged(key: K): MixedValue { - const all = nodes.value - if (all.length === 0) return MIXED - const first = all[0][key] - for (let i = 1; i < all.length; i++) { - if (all[i][key] !== first) return MIXED - } - return first - } - - function prop(key: K) { - return computed(() => merged(key)) - } - - function updateAllWithUndo(patch: Partial, label: string) { - for (const n of nodes.value) { - store.updateNodeWithUndo(n.id, patch, label) - } - store.requestRender() - } - - function isArrayMixed(key: keyof SceneNode): boolean { - const all = nodes.value - if (all.length <= 1) return false - const first = JSON.stringify(all[0][key]) - return all.some((n) => JSON.stringify(n[key]) !== first) - } - - type ArrayPropKey = 'fills' | 'strokes' | 'effects' - - function targetNodes(): SceneNode[] { - if (isMulti.value) return nodes.value - return activeNode.value ? [activeNode.value] : [] - } - - function updateArrayItem( - key: ArrayPropKey, - index: number, - patch: Record, - label: string - ) { - for (const n of targetNodes()) { - const arr = [...n[key]] - arr[index] = { ...arr[index], ...patch } as (typeof arr)[number] - store.updateNodeWithUndo(n.id, { [key]: arr } as Partial, label) - } - } - - function removeArrayItem(key: ArrayPropKey, index: number, label: string) { - for (const n of targetNodes()) { - store.updateNodeWithUndo( - n.id, - { [key]: (n[key] as unknown[]).filter((_, i) => i !== index) } as Partial, - label - ) - } - } - - function toggleArrayVisibility(key: ArrayPropKey, index: number) { - for (const n of targetNodes()) { - const items = n[key] as Array<{ visible: boolean }> - if (!items[index]) continue - const arr = [...n[key]] - arr[index] = { ...arr[index], visible: !items[index].visible } - store.updateNodeWithUndo( - n.id, - { [key]: arr } as Partial, - `Toggle ${key} visibility` - ) - } - } - - return { - store, - node, - nodes, - isMulti, - active, - activeNode, - targetNodes, - prop, - merged, - updateAllWithUndo, - updateArrayItem, - removeArrayItem, - toggleArrayVisibility, - isArrayMixed - } -} diff --git a/packages/vue/src/composables/use-node-props.ts b/packages/vue/src/composables/use-node-props.ts index 042bd5123..b68b8468c 100644 --- a/packages/vue/src/composables/use-node-props.ts +++ b/packages/vue/src/composables/use-node-props.ts @@ -4,20 +4,85 @@ import { useEditor } from '../context' import type { SceneNode } from '@open-pencil/core' +export const MIXED = Symbol('mixed') +export type MixedValue = T | typeof MIXED + export function useNodeProps() { const store = useEditor() const node = computed(() => store.getSelectedNode() ?? null) const nodes = computed(() => store.getSelectedNodes()) + const isMulti = computed(() => nodes.value.length > 1) + const active = computed(() => node.value || isMulti.value) + const activeNode = computed(() => node.value ?? (nodes.value[0] as SceneNode | undefined) ?? null) - function updateProp(key: string, value: number | string) { - if (store.getSelectedNodes().length > 1) { - storePreviousValues(key) - for (const n of store.getSelectedNodes()) { - store.updateNode(n.id, { [key]: value }) - } - } else { - const node = store.getSelectedNode() - if (node) store.updateNode(node.id, { [key]: value }) + function merged(key: K): MixedValue { + const all = nodes.value + if (all.length === 0) return MIXED + const first = all[0][key] + for (let i = 1; i < all.length; i++) { + if (all[i][key] !== first) return MIXED + } + return first + } + + function prop(key: K) { + return computed(() => merged(key)) + } + + function updateAllWithUndo(patch: Partial, label: string) { + for (const n of nodes.value) { + store.updateNodeWithUndo(n.id, patch, label) + } + } + + function isArrayMixed(key: keyof SceneNode): boolean { + const all = nodes.value + if (all.length <= 1) return false + const first = JSON.stringify(all[0][key]) + return all.some((n) => JSON.stringify(n[key]) !== first) + } + + type ArrayPropKey = 'fills' | 'strokes' | 'effects' + + function targetNodes(): SceneNode[] { + if (isMulti.value) return nodes.value + return activeNode.value ? [activeNode.value] : [] + } + + function updateArrayItem( + key: ArrayPropKey, + index: number, + patch: Record, + label: string + ) { + for (const n of targetNodes()) { + const arr = [...n[key]] + arr[index] = { ...arr[index], ...patch } as (typeof arr)[number] + store.updateNodeWithUndo(n.id, { [key]: arr } as Partial, label) + } + } + + function removeArrayItem(key: ArrayPropKey, index: number, label: string) { + for (const n of targetNodes()) { + store.updateNodeWithUndo( + n.id, + { [key]: (n[key] as unknown[]).filter((_, i) => i !== index) } as Partial, + label + ) + } + } + + function toggleArrayVisibility(key: ArrayPropKey, index: number) { + for (const n of targetNodes()) { + const items = n[key] as Array<{ visible: boolean }> + if (!items[index]) continue + const arr = [...n[key]] + arr[index] = { ...arr[index], visible: !items[index].visible } + store.updateNodeWithUndo( + n.id, + { [key]: arr } as Partial, + `Toggle ${key} visibility` + ) } } @@ -36,6 +101,18 @@ export function useNodeProps() { } } + function updateProp(key: string, value: number | string) { + if (store.getSelectedNodes().length > 1) { + storePreviousValues(key) + for (const n of store.getSelectedNodes()) { + store.updateNode(n.id, { [key]: value }) + } + } else { + const node = store.getSelectedNode() + if (node) store.updateNode(node.id, { [key]: value }) + } + } + function commitProp(key: string, _value: number | string, previous: number | string) { if (store.getSelectedNodes().length > 1) { for (const n of store.getSelectedNodes()) { @@ -51,5 +128,22 @@ export function useNodeProps() { } } - return { store, node, nodes, updateProp, commitProp } + return { + store, + node, + nodes, + isMulti, + active, + activeNode, + targetNodes, + prop, + merged, + updateAllWithUndo, + updateArrayItem, + removeArrayItem, + toggleArrayVisibility, + isArrayMixed, + updateProp, + commitProp + } } diff --git a/packages/vue/src/index.ts b/packages/vue/src/index.ts index 400d55345..bc3cdba53 100644 --- a/packages/vue/src/index.ts +++ b/packages/vue/src/index.ts @@ -9,13 +9,12 @@ export type { UseCanvasOptions } from './composables/use-canvas' export { useCanvasInput } from './composables/use-canvas-input' export { useTextEdit } from './composables/use-text-edit' export { useCanvasDrop, extractImageFilesFromClipboard } from './composables/use-canvas-drop' -export { useNodeProps } from './composables/use-node-props' -export { useMultiProps, MIXED } from './composables/use-multi-props' -export type { MixedValue } from './composables/use-multi-props' +export { useNodeProps, useNodeProps as useMultiProps, MIXED } from './composables/use-node-props' +export type { MixedValue } from './composables/use-node-props' export { useInlineRename } from './composables/use-inline-rename' export { useNodeFontStatus } from './composables/use-font-status' -export { toast } from './composables/use-toast' -export type { Toast, ToastVariant } from './composables/use-toast' +export { toast } from './toast' +export type { Toast, ToastVariant } from './toast' export { default as OpenPencilProvider } from './components/OpenPencilProvider.vue' export { default as OpenPencilCanvas } from './components/OpenPencilCanvas.vue' @@ -23,3 +22,5 @@ export { default as PageList } from './components/PageList.vue' export { default as LayerTree } from './components/LayerTree.vue' export { default as ToolSelector } from './components/ToolSelector.vue' export { default as NodeProperties } from './components/NodeProperties.vue' + +export { toolCursor } from './utils/tool-cursor' diff --git a/packages/vue/src/input/auto-layout.ts b/packages/vue/src/input/auto-layout.ts new file mode 100644 index 000000000..8f07ffc94 --- /dev/null +++ b/packages/vue/src/input/auto-layout.ts @@ -0,0 +1,103 @@ +import type { SceneNode, Vector } from '@open-pencil/core' +import type { Editor } from '@open-pencil/core/editor' + +import type { DragMove } from './types' + +export function computeIndicatorPosition( + children: SceneNode[], + insertIndex: number, + parent: SceneNode, + parentAbs: Vector, + isRow: boolean, + editor: Editor +): number { + if (children.length === 0) { + return isRow ? parentAbs.x + parent.paddingLeft : parentAbs.y + parent.paddingTop + } + if (insertIndex === 0) { + const firstAbs = editor.graph.getAbsolutePosition(children[0].id) + return (isRow ? firstAbs.x : firstAbs.y) - parent.itemSpacing / 2 + } + if (insertIndex >= children.length) { + const last = children[children.length - 1] + const lastAbs = editor.graph.getAbsolutePosition(last.id) + return isRow + ? lastAbs.x + last.width + parent.itemSpacing / 2 + : lastAbs.y + last.height + parent.itemSpacing / 2 + } + const prev = children[insertIndex - 1] + const next = children[insertIndex] + const prevAbs = editor.graph.getAbsolutePosition(prev.id) + const nextAbs = editor.graph.getAbsolutePosition(next.id) + return isRow + ? (prevAbs.x + prev.width + nextAbs.x) / 2 + : (prevAbs.y + prev.height + nextAbs.y) / 2 +} + +export function filteredToRealIndex(parentId: string, insertIndex: number, editor: Editor): number { + const allChildren = editor.graph.getChildren(parentId) + let realIndex = 0 + let filteredCount = 0 + for (const child of allChildren) { + if (editor.state.selectedIds.has(child.id)) continue + if (child.layoutPositioning === 'ABSOLUTE') { + realIndex++ + continue + } + if (filteredCount === insertIndex) break + filteredCount++ + realIndex++ + } + return realIndex +} + +export function computeAutoLayoutIndicatorForFrame( + parent: SceneNode, + cx: number, + cy: number, + editor: Editor +) { + const children = editor.graph + .getChildren(parent.id) + .filter((c) => c.layoutPositioning !== 'ABSOLUTE' && !editor.state.selectedIds.has(c.id)) + + const parentAbs = editor.graph.getAbsolutePosition(parent.id) + const isRow = parent.layoutMode === 'HORIZONTAL' + + let insertIndex = children.length + for (let i = 0; i < children.length; i++) { + const childAbs = editor.graph.getAbsolutePosition(children[i].id) + const mid = isRow ? childAbs.x + children[i].width / 2 : childAbs.y + children[i].height / 2 + if ((isRow ? cx : cy) < mid) { + insertIndex = i + break + } + } + + const indicatorPos = computeIndicatorPosition(children, insertIndex, parent, parentAbs, isRow, editor) + const crossStart = isRow ? parentAbs.y + parent.paddingTop : parentAbs.x + parent.paddingLeft + const crossLength = isRow + ? parent.height - parent.paddingTop - parent.paddingBottom + : parent.width - parent.paddingLeft - parent.paddingRight + + editor.setLayoutInsertIndicator({ + parentId: parent.id, + index: filteredToRealIndex(parent.id, insertIndex, editor), + x: isRow ? indicatorPos : crossStart, + y: isRow ? crossStart : indicatorPos, + length: crossLength, + direction: isRow ? 'VERTICAL' : 'HORIZONTAL' + }) +} + +export function computeAutoLayoutIndicator( + d: DragMove, + cx: number, + cy: number, + editor: Editor +) { + if (!d.autoLayoutParentId) return + const parent = editor.graph.getNode(d.autoLayoutParentId) + if (!parent || parent.layoutMode === 'NONE') return + computeAutoLayoutIndicatorForFrame(parent, cx, cy, editor) +} diff --git a/packages/vue/src/input/geometry.ts b/packages/vue/src/input/geometry.ts new file mode 100644 index 000000000..2cb9d9284 --- /dev/null +++ b/packages/vue/src/input/geometry.ts @@ -0,0 +1,170 @@ +import { CORNER_ROTATE_ZONE, HANDLE_HIT_RADIUS } from '@open-pencil/core' +import type { Vector } from '@open-pencil/core' + +import type { CornerPosition, HandlePosition } from './types' + +import rotateCursorSvg from '../assets/rotate-cursor.svg?raw' + +export const HANDLE_CURSORS: Record = { + nw: 'nwse-resize', + n: 'ns-resize', + ne: 'nesw-resize', + e: 'ew-resize', + se: 'nwse-resize', + s: 'ns-resize', + sw: 'nesw-resize', + w: 'ew-resize' +} + +export function getScreenRect( + absX: number, + absY: number, + w: number, + h: number, + zoom: number, + panX: number, + panY: number +) { + return { + x1: absX * zoom + panX, + y1: absY * zoom + panY, + x2: (absX + w) * zoom + panX, + y2: (absY + h) * zoom + panY + } +} + +export function getHandlePositions( + absX: number, + absY: number, + w: number, + h: number, + zoom: number, + panX: number, + panY: number +) { + const { x1, y1, x2, y2 } = getScreenRect(absX, absY, w, h, zoom, panX, panY) + const mx = (x1 + x2) / 2 + const my = (y1 + y2) / 2 + + return { + nw: { x: x1, y: y1 }, + n: { x: mx, y: y1 }, + ne: { x: x2, y: y1 }, + e: { x: x2, y: my }, + se: { x: x2, y: y2 }, + s: { x: mx, y: y2 }, + sw: { x: x1, y: y2 }, + w: { x: x1, y: my } + } satisfies Record +} + +export function unrotate( + sx: number, + sy: number, + centerX: number, + centerY: number, + rotation: number +): { sx: number; sy: number } { + if (rotation === 0) return { sx, sy } + const rad = (-rotation * Math.PI) / 180 + const cos = Math.cos(rad) + const sin = Math.sin(rad) + const dx = sx - centerX + const dy = sy - centerY + return { + sx: centerX + dx * cos - dy * sin, + sy: centerY + dx * sin + dy * cos + } +} + +export function hitTestHandle( + sx: number, + sy: number, + absX: number, + absY: number, + w: number, + h: number, + zoom: number, + panX: number, + panY: number, + rotation = 0 +): HandlePosition | null { + const { x1, y1, x2, y2 } = getScreenRect(absX, absY, w, h, zoom, panX, panY) + const cx = (x1 + x2) / 2 + const cy = (y1 + y2) / 2 + const ur = unrotate(sx, sy, cx, cy, rotation) + + const handles = getHandlePositions(absX, absY, w, h, zoom, panX, panY) + for (const [pos, pt] of Object.entries(handles)) { + if (Math.abs(ur.sx - pt.x) < HANDLE_HIT_RADIUS && Math.abs(ur.sy - pt.y) < HANDLE_HIT_RADIUS) { + return pos as HandlePosition + } + } + return null +} + +export function hitTestCornerRotation( + sx: number, + sy: number, + absX: number, + absY: number, + w: number, + h: number, + zoom: number, + panX: number, + panY: number, + rotation = 0 +): CornerPosition | null { + const { x1, y1, x2, y2 } = getScreenRect(absX, absY, w, h, zoom, panX, panY) + const cx = (x1 + x2) / 2 + const cy = (y1 + y2) / 2 + const ur = unrotate(sx, sy, cx, cy, rotation) + + const corners: Array<{ pos: CornerPosition; x: number; y: number }> = [ + { pos: 'nw', x: x1, y: y1 }, + { pos: 'ne', x: x2, y: y1 }, + { pos: 'se', x: x2, y: y2 }, + { pos: 'sw', x: x1, y: y2 } + ] + + for (const { pos, x, y } of corners) { + const dx = Math.abs(ur.sx - x) + const dy = Math.abs(ur.sy - y) + if ( + dx <= CORNER_ROTATE_ZONE && + dy <= CORNER_ROTATE_ZONE && + (dx > HANDLE_HIT_RADIUS || dy > HANDLE_HIT_RADIUS) + ) { + return pos + } + } + return null +} + +const CORNER_BASE_ANGLES: Record = { nw: 0, ne: 90, se: 180, sw: 270 } + +const rotationCursorCache = new Map() + +export function buildRotationCursor(angleDeg: number): string { + const key = Math.round(angleDeg) % 360 + let cached = rotationCursorCache.get(key) + if (cached) return cached + let svg: string + if (key === 0) { + svg = rotateCursorSvg + } else { + svg = rotateCursorSvg + .replace( + '', '') + } + cached = `url("data:image/svg+xml,${encodeURIComponent(svg)}") 12 12, auto` + rotationCursorCache.set(key, cached) + return cached +} + +export function cornerRotationCursor(corner: CornerPosition, nodeRotation = 0): string { + return buildRotationCursor(CORNER_BASE_ANGLES[corner] + nodeRotation) +} diff --git a/packages/vue/src/input/pan-zoom.ts b/packages/vue/src/input/pan-zoom.ts new file mode 100644 index 000000000..5a2fed765 --- /dev/null +++ b/packages/vue/src/input/pan-zoom.ts @@ -0,0 +1,266 @@ +import { useEventListener } from '@vueuse/core' +import type { Ref } from 'vue' + +import type { Editor } from '@open-pencil/core/editor' + +import type { DragState } from './types' + +export function setupPanZoom( + canvasRef: Ref, + editor: Editor, + drag: Ref, + getCoords: (e: MouseEvent) => { sx: number; sy: number; cx: number; cy: number }, + onMouseDown: (e: MouseEvent) => void, + onMouseMove: (e: MouseEvent) => void, + onMouseUp: () => void +) { + const wheelAccum = { + deltaX: 0, + deltaY: 0, + zoomDelta: 0, + zoomCenterX: 0, + zoomCenterY: 0, + hasZoom: false, + rafId: 0 + } + + function flushWheel() { + wheelAccum.rafId = 0 + editor.setHoveredNode(null) + if (wheelAccum.hasZoom) { + editor.applyZoom(wheelAccum.zoomDelta, wheelAccum.zoomCenterX, wheelAccum.zoomCenterY) + } else { + editor.pan(wheelAccum.deltaX, wheelAccum.deltaY) + } + wheelAccum.deltaX = 0 + wheelAccum.deltaY = 0 + wheelAccum.zoomDelta = 0 + wheelAccum.hasZoom = false + } + + function normalizeWheelDelta(e: WheelEvent): { dx: number; dy: number } { + let { deltaX, deltaY } = e + if (e.deltaMode === WheelEvent.DOM_DELTA_LINE) { + deltaX *= 40 + deltaY *= 40 + } else if (e.deltaMode === WheelEvent.DOM_DELTA_PAGE) { + deltaX *= 800 + deltaY *= 800 + } + return { dx: deltaX, dy: deltaY } + } + + function onWheel(e: WheelEvent) { + e.preventDefault() + const canvas = canvasRef.value + if (!canvas) return + const { dx, dy } = normalizeWheelDelta(e) + + if (e.ctrlKey || e.metaKey) { + const rect = canvas.getBoundingClientRect() + wheelAccum.zoomCenterX = e.clientX - rect.left + wheelAccum.zoomCenterY = e.clientY - rect.top + wheelAccum.zoomDelta += dy + wheelAccum.hasZoom = true + } else { + wheelAccum.deltaX -= dx + wheelAccum.deltaY -= dy + } + if (!wheelAccum.rafId) { + wheelAccum.rafId = requestAnimationFrame(flushWheel) + } + } + + let activeTouches: Touch[] = [] + let pinchStartDist = 0 + let pinchStartZoom = 0 + let pinchMidX = 0 + let pinchMidY = 0 + + function touchDist(a: Touch, b: Touch) { + return Math.hypot(a.clientX - b.clientX, a.clientY - b.clientY) + } + + let touchAsMouse = false + + function syntheticMouse(type: string, t: Touch): MouseEvent { + return new MouseEvent(type, { + clientX: t.clientX, + clientY: t.clientY, + screenX: t.screenX, + screenY: t.screenY, + button: 0, + buttons: 1, + bubbles: true + }) + } + + function onTouchStart(e: TouchEvent) { + e.preventDefault() + activeTouches = Array.from(e.touches) + const canvas = canvasRef.value + if (!canvas) return + + if (activeTouches.length === 2) { + if (touchAsMouse) { + onMouseUp() + touchAsMouse = false + } + drag.value = null + const [a, b] = activeTouches + pinchStartDist = touchDist(a, b) + pinchStartZoom = editor.state.zoom + const rect = canvas.getBoundingClientRect() + pinchMidX = (a.clientX + b.clientX) / 2 - rect.left + pinchMidY = (a.clientY + b.clientY) / 2 - rect.top + } else if (activeTouches.length === 1) { + const t = activeTouches[0] + const tool = editor.state.activeTool + if (tool === 'HAND') { + touchAsMouse = false + drag.value = { + type: 'pan', + startScreenX: t.clientX, + startScreenY: t.clientY, + startPanX: editor.state.panX, + startPanY: editor.state.panY + } + } else { + touchAsMouse = true + onMouseDown(syntheticMouse('mousedown', t)) + } + } + } + + function onTouchMove(e: TouchEvent) { + e.preventDefault() + activeTouches = Array.from(e.touches) + const canvas = canvasRef.value + if (!canvas) return + + if (activeTouches.length === 2) { + const [a, b] = activeTouches + const rect = canvas.getBoundingClientRect() + const newMidX = (a.clientX + b.clientX) / 2 - rect.left + const newMidY = (a.clientY + b.clientY) / 2 - rect.top + + editor.setHoveredNode(null) + const newDist = touchDist(a, b) + if (pinchStartDist > 0) { + const scale = newDist / pinchStartDist + const newZoom = Math.max(0.02, Math.min(256, pinchStartZoom * scale)) + const zoomRatio = newZoom / editor.state.zoom + + const panDx = newMidX - pinchMidX + const panDy = newMidY - pinchMidY + + editor.state.panX = pinchMidX - (pinchMidX - editor.state.panX) * zoomRatio + panDx + editor.state.panY = pinchMidY - (pinchMidY - editor.state.panY) * zoomRatio + panDy + editor.state.zoom = newZoom + } + + pinchMidX = newMidX + pinchMidY = newMidY + editor.requestRepaint() + } else if (activeTouches.length === 1) { + const t = activeTouches[0] + if (touchAsMouse) { + onMouseMove(syntheticMouse('mousemove', t)) + } else if (drag.value?.type === 'pan') { + const d = drag.value + editor.state.panX = d.startPanX + (t.clientX - d.startScreenX) + editor.state.panY = d.startPanY + (t.clientY - d.startScreenY) + editor.requestRepaint() + } + } + } + + function onTouchEnd(e: TouchEvent) { + e.preventDefault() + activeTouches = Array.from(e.touches) + + if (activeTouches.length === 0) { + if (touchAsMouse) { + onMouseUp() + touchAsMouse = false + } else { + drag.value = null + } + pinchStartDist = 0 + } else if (activeTouches.length === 1) { + const t = activeTouches[0] + if (!touchAsMouse) { + drag.value = { + type: 'pan', + startScreenX: t.clientX, + startScreenY: t.clientY, + startPanX: editor.state.panX, + startPanY: editor.state.panY + } + } + pinchStartDist = 0 + } + } + + useEventListener(canvasRef, 'wheel', onWheel, { passive: false }) + useEventListener(canvasRef, 'touchstart', onTouchStart, { passive: false }) + useEventListener(canvasRef, 'touchmove', onTouchMove, { passive: false }) + useEventListener(canvasRef, 'touchend', onTouchEnd, { passive: false }) + useEventListener(canvasRef, 'touchcancel', onTouchEnd, { passive: false }) + + let gestureStartZoom = 1 + let gestureRafId = 0 + let pendingGesture: { scale: number; sx: number; sy: number } | null = null + + function flushGesture() { + gestureRafId = 0 + if (!pendingGesture) return + editor.setHoveredNode(null) + const { scale, sx, sy } = pendingGesture + pendingGesture = null + const newZoom = Math.max(0.02, Math.min(256, gestureStartZoom * scale)) + const zoomRatio = newZoom / editor.state.zoom + editor.state.panX = sx - (sx - editor.state.panX) * zoomRatio + editor.state.panY = sy - (sy - editor.state.panY) * zoomRatio + editor.state.zoom = newZoom + editor.requestRepaint() + } + + useEventListener( + canvasRef, + 'gesturestart' as keyof HTMLElementEventMap, + (e: Event) => { + e.preventDefault() + gestureStartZoom = editor.state.zoom + }, + { passive: false } + ) + useEventListener( + canvasRef, + 'gesturechange' as keyof HTMLElementEventMap, + (e: Event) => { + e.preventDefault() + const ge = e as GestureEvent + const canvas = canvasRef.value + if (!canvas) return + const rect = canvas.getBoundingClientRect() + pendingGesture = { + scale: ge.scale, + sx: ge.clientX - rect.left, + sy: ge.clientY - rect.top + } + if (!gestureRafId) { + gestureRafId = requestAnimationFrame(flushGesture) + } + }, + { passive: false } + ) + useEventListener( + canvasRef, + 'gestureend' as keyof HTMLElementEventMap, + (e: Event) => { + e.preventDefault() + }, + { passive: false } + ) +} diff --git a/packages/vue/src/input/resize.ts b/packages/vue/src/input/resize.ts new file mode 100644 index 000000000..83cf9a839 --- /dev/null +++ b/packages/vue/src/input/resize.ts @@ -0,0 +1,121 @@ +import type { Rect } from '@open-pencil/core' +import type { Editor } from '@open-pencil/core/editor' + +import type { DragResize, HandlePosition } from './types' +import { hitTestHandle } from './geometry' + +export function constrainToAspectRatio( + handle: HandlePosition, + origRect: Rect, + width: number, + height: number, + dx: number, + dy: number +): Rect { + let x = handle.includes('w') ? origRect.x + origRect.width - Math.abs(width) : origRect.x + const isTop = handle === 'nw' || handle === 'n' || handle === 'ne' + let y = isTop ? origRect.y + origRect.height - Math.abs(height) : origRect.y + const aspect = origRect.width / origRect.height + + if (handle === 'n' || handle === 's') { + width = Math.abs(height) * aspect + x = origRect.x + (origRect.width - width) / 2 + } else if (handle === 'e' || handle === 'w') { + height = Math.abs(width) / aspect + y = origRect.y + (origRect.height - height) / 2 + } else if (Math.abs(dx) > Math.abs(dy)) { + height = (Math.abs(width) / aspect) * Math.sign(height || 1) + if (isTop) y = origRect.y + origRect.height - Math.abs(height) + } else { + width = Math.abs(height) * aspect * Math.sign(width || 1) + if (handle.includes('w')) x = origRect.x + origRect.width - Math.abs(width) + } + + return { x, y, width, height } +} + +export function applyResize( + d: DragResize, + cx: number, + cy: number, + constrain: boolean, + editor: Editor +) { + const { handle, origRect } = d + let { x, y, width, height } = origRect + const dx = cx - d.startX + const dy = cy - d.startY + + const moveLeft = handle.includes('w') + const moveRight = handle.includes('e') + const moveTop = handle === 'nw' || handle === 'n' || handle === 'ne' + const moveBottom = handle === 'sw' || handle === 's' || handle === 'se' + + if (moveRight) width = origRect.width + dx + if (moveLeft) { + x = origRect.x + dx + width = origRect.width - dx + } + if (moveBottom) height = origRect.height + dy + if (moveTop) { + y = origRect.y + dy + height = origRect.height - dy + } + + if (constrain && origRect.width > 0 && origRect.height > 0) { + ;({ x, y, width, height } = constrainToAspectRatio(handle, origRect, width, height, dx, dy)) + } + + if (width < 0) { + x += width + width = -width + } + if (height < 0) { + y += height + height = -height + } + + editor.updateNode(d.nodeId, { + x: Math.round(x), + y: Math.round(y), + width: Math.round(Math.max(1, width)), + height: Math.round(Math.max(1, height)) + }) +} + +export function tryStartResize( + sx: number, + sy: number, + cx: number, + cy: number, + editor: Editor +): DragResize | null { + for (const id of editor.state.selectedIds) { + const node = editor.graph.getNode(id) + if (!node || node.locked) continue + const abs = editor.graph.getAbsolutePosition(id) + const handle = hitTestHandle( + sx, + sy, + abs.x, + abs.y, + node.width, + node.height, + editor.state.zoom, + editor.state.panX, + editor.state.panY, + node.rotation + ) + if (handle) { + return { + type: 'resize', + handle, + startX: cx, + startY: cy, + origRect: { x: node.x, y: node.y, width: node.width, height: node.height }, + nodeId: id + } + } + } + return null +} diff --git a/packages/vue/src/input/types.ts b/packages/vue/src/input/types.ts new file mode 100644 index 000000000..9f725ecd2 --- /dev/null +++ b/packages/vue/src/input/types.ts @@ -0,0 +1,88 @@ +import type { NodeType, Rect } from '@open-pencil/core' +import type { Tool } from '@open-pencil/core/editor' + +export type HandlePosition = 'nw' | 'n' | 'ne' | 'e' | 'se' | 's' | 'sw' | 'w' + +export type CornerPosition = 'nw' | 'ne' | 'se' | 'sw' + +export interface DragDraw { + type: 'draw' + startX: number + startY: number + nodeId: string +} + +export interface DragMove { + type: 'move' + startX: number + startY: number + originals: Map + duplicated?: boolean + autoLayoutParentId?: string + brokeFromAutoLayout?: boolean +} + +export interface DragPan { + type: 'pan' + startScreenX: number + startScreenY: number + startPanX: number + startPanY: number +} + +export interface DragResize { + type: 'resize' + handle: HandlePosition + startX: number + startY: number + origRect: Rect + nodeId: string +} + +export interface DragMarquee { + type: 'marquee' + startX: number + startY: number +} + +export interface DragRotate { + type: 'rotate' + nodeId: string + centerX: number + centerY: number + startAngle: number + origRotation: number +} + +export interface DragPen { + type: 'pen-drag' + startX: number + startY: number +} + +export interface DragTextSelect { + type: 'text-select' + startX: number + startY: number +} + +export type DragState = + | DragDraw + | DragMove + | DragPan + | DragResize + | DragMarquee + | DragRotate + | DragPen + | DragTextSelect + +export const TOOL_TO_NODE: Partial> = { + FRAME: 'FRAME', + SECTION: 'SECTION', + RECTANGLE: 'RECTANGLE', + ELLIPSE: 'ELLIPSE', + LINE: 'LINE', + POLYGON: 'POLYGON', + STAR: 'STAR', + TEXT: 'TEXT' +} diff --git a/packages/vue/src/composables/use-toast.ts b/packages/vue/src/toast.ts similarity index 100% rename from packages/vue/src/composables/use-toast.ts rename to packages/vue/src/toast.ts diff --git a/packages/vue/src/utils/tool-cursor.ts b/packages/vue/src/utils/tool-cursor.ts new file mode 100644 index 000000000..75a762aef --- /dev/null +++ b/packages/vue/src/utils/tool-cursor.ts @@ -0,0 +1,20 @@ +import type { Tool } from '@open-pencil/core/editor' + +const TOOL_CURSORS: Record = { + SELECT: 'default', + FRAME: 'crosshair', + SECTION: 'crosshair', + RECTANGLE: 'crosshair', + ELLIPSE: 'crosshair', + LINE: 'crosshair', + POLYGON: 'crosshair', + STAR: 'crosshair', + TEXT: 'text', + PEN: 'crosshair', + HAND: 'grab', +} + +export function toolCursor(tool: Tool, override?: string | null): string { + if (override) return override + return TOOL_CURSORS[tool] ?? 'default' +} diff --git a/src/components/EditorCanvas.vue b/src/components/EditorCanvas.vue index d77402d74..aec9f83b8 100644 --- a/src/components/EditorCanvas.vue +++ b/src/components/EditorCanvas.vue @@ -7,6 +7,7 @@ import { useCanvasInput } from '@/composables/use-canvas-input' import { useCollabInjected } from '@/composables/use-collab' import { useTextEdit } from '@/composables/use-text-edit' import { useEditorStore } from '@/stores/editor' +import { toolCursor } from '@open-pencil/vue' import CanvasContextMenu from './CanvasContextMenu.vue' const store = useEditorStore() @@ -34,14 +35,7 @@ watch( (ids) => collab?.updateSelection(ids) ) -const cursor = computed(() => { - if (cursorOverride.value) return cursorOverride.value - const tool = store.state.activeTool - if (tool === 'HAND') return 'grab' - if (tool === 'SELECT') return 'default' - if (tool === 'TEXT') return 'text' - return 'crosshair' -}) +const cursor = computed(() => toolCursor(store.state.activeTool, cursorOverride.value))