Refactor: extract business logic from components, clean composables

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.
This commit is contained in:
Danila Poyarkov 2026-03-16 20:35:17 +03:00
parent 608be0ea27
commit f44f06f721
21 changed files with 1258 additions and 848 deletions

View file

@ -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<string, Vector>()
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<string, Vector>()
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<string, Vector>()
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<string, { flipX: boolean; flipY: boolean }>()
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<string, { flipX: boolean; flipY: boolean }>()
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<string, number>()
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<string, number>()
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 }
}

View file

@ -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,

View file

@ -28,6 +28,7 @@ export function createNodeActions(ctx: EditorContext) {
ctx.runLayoutForNode(id)
}
})
ctx.requestRender()
}
function setLayoutMode(id: string, mode: LayoutMode) {

View file

@ -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
}
}

View file

@ -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<string, { x: number; y: number; parentId: string }>
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<Record<Tool, NodeType>> = {
FRAME: 'FRAME',
SECTION: 'SECTION',
RECTANGLE: 'RECTANGLE',
ELLIPSE: 'ELLIPSE',
LINE: 'LINE',
POLYGON: 'POLYGON',
STAR: 'STAR',
TEXT: 'TEXT'
}
const HANDLE_CURSORS: Record<HandlePosition, string> = {
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<HandlePosition, Vector>
}
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<CornerPosition, number> = { nw: 0, ne: 90, se: 180, sw: 270 }
import rotateCursorSvg from '../assets/rotate-cursor.svg?raw'
const rotationCursorCache = new Map<number, string>()
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(
'<path',
`<g transform='translate(1002 2110) rotate(${key}) translate(-1002 -2110)'><path`
)
.replace('</svg>', '</g></svg>')
}
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<HTMLCanvasElement | null>,
@ -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,

View file

@ -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> = 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<K extends keyof SceneNode>(key: K): MixedValue<SceneNode[K]> {
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<K extends keyof SceneNode>(key: K) {
return computed(() => merged(key))
}
function updateAllWithUndo(patch: Partial<SceneNode>, 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<string, unknown>,
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<SceneNode>, 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<SceneNode>,
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<SceneNode>,
`Toggle ${key} visibility`
)
}
}
return {
store,
node,
nodes,
isMulti,
active,
activeNode,
targetNodes,
prop,
merged,
updateAllWithUndo,
updateArrayItem,
removeArrayItem,
toggleArrayVisibility,
isArrayMixed
}
}

View file

@ -4,20 +4,85 @@ import { useEditor } from '../context'
import type { SceneNode } from '@open-pencil/core'
export const MIXED = Symbol('mixed')
export type MixedValue<T> = 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<K extends keyof SceneNode>(key: K): MixedValue<SceneNode[K]> {
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<K extends keyof SceneNode>(key: K) {
return computed(() => merged(key))
}
function updateAllWithUndo(patch: Partial<SceneNode>, 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<string, unknown>,
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<SceneNode>, 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<SceneNode>,
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<SceneNode>,
`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
}
}

View file

@ -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'

View file

@ -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)
}

View file

@ -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<HandlePosition, string> = {
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<HandlePosition, Vector>
}
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<CornerPosition, number> = { nw: 0, ne: 90, se: 180, sw: 270 }
const rotationCursorCache = new Map<number, string>()
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(
'<path',
`<g transform='translate(1002 2110) rotate(${key}) translate(-1002 -2110)'><path`
)
.replace('</svg>', '</g></svg>')
}
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)
}

View file

@ -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<HTMLCanvasElement | null>,
editor: Editor,
drag: Ref<DragState | null>,
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 }
)
}

View file

@ -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
}

View file

@ -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<string, { x: number; y: number; parentId: string }>
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<Record<Tool, NodeType>> = {
FRAME: 'FRAME',
SECTION: 'SECTION',
RECTANGLE: 'RECTANGLE',
ELLIPSE: 'ELLIPSE',
LINE: 'LINE',
POLYGON: 'POLYGON',
STAR: 'STAR',
TEXT: 'TEXT'
}

View file

@ -0,0 +1,20 @@
import type { Tool } from '@open-pencil/core/editor'
const TOOL_CURSORS: Record<Tool, string> = {
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'
}

View file

@ -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))
</script>
<template>

View file

@ -77,7 +77,6 @@ function toggleIndependentCorners() {
)
}
}
store.requestRender()
} else {
const n = node.value
if (!n) return

View file

@ -113,7 +113,6 @@ function add() {
for (const n of nodes.value) {
store.updateNodeWithUndo(n.id, { effects: [defaultEffect()] }, 'Set effect')
}
store.requestRender()
return
}
const n = node.value

View file

@ -112,7 +112,6 @@ function flipVertical() {
for (const n of nodes.value) {
store.updateNodeWithUndo(n.id, { flipY: !n.flipY }, 'Flip vertical')
}
store.requestRender()
}
function rotate90() {

View file

@ -32,7 +32,6 @@ type TextAlign = 'LEFT' | 'CENTER' | 'RIGHT'
async function selectFamily(family: string) {
await loadFont(family, currentWeightLabel.value)
store.updateNodeWithUndo(node.value.id, { fontFamily: family }, 'Change font')
store.requestRender()
}
async function selectWeight(weight: number) {

View file

@ -1,2 +1,2 @@
export { useMultiProps, MIXED } from '@open-pencil/vue'
export { useNodeProps as useMultiProps, MIXED } from '@open-pencil/vue'
export type { MixedValue } from '@open-pencil/vue'