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:
parent
608be0ea27
commit
f44f06f721
179
packages/core/src/editor/alignment.ts
Normal file
179
packages/core/src/editor/alignment.ts
Normal 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 }
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ export function createNodeActions(ctx: EditorContext) {
|
|||
ctx.runLayoutForNode(id)
|
||||
}
|
||||
})
|
||||
ctx.requestRender()
|
||||
}
|
||||
|
||||
function setLayoutMode(id: string, mode: LayoutMode) {
|
||||
|
|
|
|||
153
packages/core/src/editor/variables.ts
Normal file
153
packages/core/src/editor/variables.ts
Normal 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
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
103
packages/vue/src/input/auto-layout.ts
Normal file
103
packages/vue/src/input/auto-layout.ts
Normal 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)
|
||||
}
|
||||
170
packages/vue/src/input/geometry.ts
Normal file
170
packages/vue/src/input/geometry.ts
Normal 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)
|
||||
}
|
||||
266
packages/vue/src/input/pan-zoom.ts
Normal file
266
packages/vue/src/input/pan-zoom.ts
Normal 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 }
|
||||
)
|
||||
}
|
||||
121
packages/vue/src/input/resize.ts
Normal file
121
packages/vue/src/input/resize.ts
Normal 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
|
||||
}
|
||||
88
packages/vue/src/input/types.ts
Normal file
88
packages/vue/src/input/types.ts
Normal 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'
|
||||
}
|
||||
20
packages/vue/src/utils/tool-cursor.ts
Normal file
20
packages/vue/src/utils/tool-cursor.ts
Normal 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'
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -77,7 +77,6 @@ function toggleIndependentCorners() {
|
|||
)
|
||||
}
|
||||
}
|
||||
store.requestRender()
|
||||
} else {
|
||||
const n = node.value
|
||||
if (!n) return
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
Loading…
Reference in a new issue