Delete src/types.ts (pure re-export of Color, Rect, etc.) — 9 files now import directly from @open-pencil/core. Remove dead AI_PROVIDERS re-export from use-chat.ts and dead font function re-exports from engine/fonts.ts.
1298 lines
37 KiB
TypeScript
1298 lines
37 KiB
TypeScript
import { useEventListener } from '@vueuse/core'
|
|
import { ref, type Ref } from 'vue'
|
|
|
|
import {
|
|
AUTO_LAYOUT_BREAK_THRESHOLD,
|
|
HANDLE_HIT_RADIUS,
|
|
ROTATION_HIT_RADIUS,
|
|
PEN_CLOSE_THRESHOLD,
|
|
ROTATION_SNAP_DEGREES,
|
|
ROTATION_HIT_OFFSET,
|
|
DEFAULT_TEXT_WIDTH,
|
|
DEFAULT_TEXT_HEIGHT
|
|
} from '@/constants'
|
|
import { computeSelectionBounds, computeSnap } from '@open-pencil/core'
|
|
|
|
import type { EditorStore, Tool } from '@/stores/editor'
|
|
import type { NodeType, Rect, 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 }>
|
|
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, { x: number; y: number }>
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
function hitTestRotationHandle(
|
|
sx: number,
|
|
sy: number,
|
|
absX: number,
|
|
absY: number,
|
|
w: number,
|
|
h: number,
|
|
zoom: number,
|
|
panX: number,
|
|
panY: number,
|
|
rotation = 0
|
|
): boolean {
|
|
const { x1, x2, y1, 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 mx = (x1 + x2) / 2
|
|
const rotY = y1 - ROTATION_HIT_OFFSET
|
|
return Math.abs(ur.sx - mx) < ROTATION_HIT_RADIUS && Math.abs(ur.sy - rotY) < ROTATION_HIT_RADIUS
|
|
}
|
|
|
|
export function useCanvasInput(
|
|
canvasRef: Ref<HTMLCanvasElement | null>,
|
|
store: EditorStore,
|
|
hitTestSectionTitle: (cx: number, cy: number) => import('@open-pencil/core').SceneNode | null,
|
|
hitTestComponentLabel: (cx: number, cy: number) => import('@open-pencil/core').SceneNode | null,
|
|
onCursorMove?: (cx: number, cy: number) => void
|
|
) {
|
|
const drag = ref<DragState | null>(null)
|
|
const cursorOverride = ref<string | null>(null)
|
|
let lastClickTime = 0
|
|
let lastClickX = 0
|
|
let lastClickY = 0
|
|
let clickCount = 0
|
|
const MULTI_CLICK_DELAY = 500
|
|
const MULTI_CLICK_RADIUS = 5
|
|
|
|
function getCoords(e: MouseEvent) {
|
|
const canvas = canvasRef.value
|
|
if (!canvas) return { sx: 0, sy: 0, cx: 0, cy: 0 }
|
|
const rect = canvas.getBoundingClientRect()
|
|
const sx = e.clientX - rect.left
|
|
const sy = e.clientY - rect.top
|
|
const { x: cx, y: cy } = store.screenToCanvas(sx, sy)
|
|
return { sx, sy, cx, cy }
|
|
}
|
|
|
|
function onMouseDown(e: MouseEvent) {
|
|
store.setHoveredNode(null)
|
|
const { sx, sy, cx, cy } = getCoords(e)
|
|
|
|
const now = performance.now()
|
|
if (
|
|
now - lastClickTime < MULTI_CLICK_DELAY &&
|
|
Math.abs(sx - lastClickX) < MULTI_CLICK_RADIUS &&
|
|
Math.abs(sy - lastClickY) < MULTI_CLICK_RADIUS
|
|
) {
|
|
clickCount++
|
|
} else {
|
|
clickCount = 1
|
|
}
|
|
lastClickTime = now
|
|
lastClickX = sx
|
|
lastClickY = sy
|
|
const tool = store.state.activeTool
|
|
|
|
if (e.button === 1 || tool === 'HAND') {
|
|
drag.value = {
|
|
type: 'pan',
|
|
startScreenX: e.clientX,
|
|
startScreenY: e.clientY,
|
|
startPanX: store.state.panX,
|
|
startPanY: store.state.panY
|
|
}
|
|
return
|
|
}
|
|
|
|
if (tool === 'SELECT' && e.altKey && !store.state.selectedIds.size) {
|
|
drag.value = {
|
|
type: 'pan',
|
|
startScreenX: e.clientX,
|
|
startScreenY: e.clientY,
|
|
startPanX: store.state.panX,
|
|
startPanY: store.state.panY
|
|
}
|
|
return
|
|
}
|
|
|
|
if (tool === 'SELECT') {
|
|
if (store.state.editingTextId) {
|
|
const editor = store.textEditor
|
|
const editNode = store.graph.getNode(store.state.editingTextId)
|
|
if (editor && editNode) {
|
|
const abs = store.graph.getAbsolutePosition(editNode.id)
|
|
const localX = cx - abs.x
|
|
const localY = cy - abs.y
|
|
if (localX >= 0 && localY >= 0 && localX <= editNode.width && localY <= editNode.height) {
|
|
if (clickCount >= 3) {
|
|
editor.selectAll()
|
|
} else if (clickCount === 2) {
|
|
editor.selectWordAt(localX, localY)
|
|
} else {
|
|
editor.setCursorAt(localX, localY, e.shiftKey)
|
|
drag.value = { type: 'text-select', startX: cx, startY: cy } as DragState
|
|
}
|
|
store.requestRender()
|
|
return
|
|
}
|
|
}
|
|
store.commitTextEdit()
|
|
}
|
|
|
|
// Check rotation handle (single selection only)
|
|
if (store.state.selectedIds.size === 1) {
|
|
const id = [...store.state.selectedIds][0]
|
|
const node = store.graph.getNode(id)
|
|
if (node) {
|
|
const abs = store.graph.getAbsolutePosition(id)
|
|
if (
|
|
hitTestRotationHandle(
|
|
sx,
|
|
sy,
|
|
abs.x,
|
|
abs.y,
|
|
node.width,
|
|
node.height,
|
|
store.state.zoom,
|
|
store.state.panX,
|
|
store.state.panY,
|
|
node.rotation
|
|
)
|
|
) {
|
|
const screenCx = (abs.x + node.width / 2) * store.state.zoom + store.state.panX
|
|
const screenCy = (abs.y + node.height / 2) * store.state.zoom + store.state.panY
|
|
const startAngle = Math.atan2(sy - screenCy, sx - screenCx) * (180 / Math.PI)
|
|
drag.value = {
|
|
type: 'rotate',
|
|
nodeId: id,
|
|
centerX: screenCx,
|
|
centerY: screenCy,
|
|
startAngle,
|
|
origRotation: node.rotation
|
|
}
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
// Check resize handles
|
|
for (const id of store.state.selectedIds) {
|
|
const node = store.graph.getNode(id)
|
|
if (!node) continue
|
|
const abs = store.graph.getAbsolutePosition(id)
|
|
const handle = hitTestHandle(
|
|
sx,
|
|
sy,
|
|
abs.x,
|
|
abs.y,
|
|
node.width,
|
|
node.height,
|
|
store.state.zoom,
|
|
store.state.panX,
|
|
store.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
|
|
}
|
|
}
|
|
|
|
// Hit test nodes (labels first, then body)
|
|
const hit =
|
|
hitTestSectionTitle(cx, cy) ??
|
|
hitTestComponentLabel(cx, cy) ??
|
|
store.graph.hitTest(cx, cy, store.state.currentPageId)
|
|
if (hit) {
|
|
if (!store.state.selectedIds.has(hit.id) && !e.shiftKey) {
|
|
store.select([hit.id])
|
|
} else if (e.shiftKey) {
|
|
store.select([hit.id], true)
|
|
}
|
|
|
|
const originals = new Map<string, { x: number; y: number }>()
|
|
for (const id of store.state.selectedIds) {
|
|
const n = store.graph.getNode(id)
|
|
if (n) originals.set(id, { x: n.x, y: n.y })
|
|
}
|
|
|
|
// Alt+drag → duplicate
|
|
if (e.altKey && store.state.selectedIds.size > 0) {
|
|
const newIds: string[] = []
|
|
const newOriginals = new Map<string, { x: number; y: number }>()
|
|
for (const id of store.state.selectedIds) {
|
|
const src = store.graph.getNode(id)
|
|
if (!src) continue
|
|
const newId = store.createShape(src.type, src.x, src.y, src.width, src.height)
|
|
store.graph.updateNode(newId, {
|
|
name: src.name + ' copy',
|
|
fills: [...src.fills],
|
|
strokes: [...src.strokes],
|
|
effects: [...src.effects],
|
|
cornerRadius: src.cornerRadius,
|
|
opacity: src.opacity,
|
|
rotation: src.rotation
|
|
})
|
|
newIds.push(newId)
|
|
newOriginals.set(newId, { x: src.x, y: src.y })
|
|
}
|
|
store.select(newIds)
|
|
drag.value = {
|
|
type: 'move',
|
|
startX: cx,
|
|
startY: cy,
|
|
originals: newOriginals,
|
|
duplicated: true
|
|
}
|
|
store.requestRender()
|
|
return
|
|
}
|
|
|
|
// Detect if we're inside an auto-layout frame
|
|
let autoLayoutParentId: string | undefined
|
|
if (store.state.selectedIds.size === 1) {
|
|
const selectedId = [...store.state.selectedIds][0]
|
|
const selectedNode = store.graph.getNode(selectedId)
|
|
if (selectedNode?.parentId) {
|
|
const parent = store.graph.getNode(selectedNode.parentId)
|
|
if (
|
|
parent &&
|
|
parent.layoutMode !== 'NONE' &&
|
|
selectedNode.layoutPositioning !== 'ABSOLUTE'
|
|
) {
|
|
autoLayoutParentId = parent.id
|
|
}
|
|
}
|
|
}
|
|
|
|
drag.value = { type: 'move', startX: cx, startY: cy, originals, autoLayoutParentId }
|
|
} else {
|
|
store.clearSelection()
|
|
drag.value = { type: 'marquee', startX: cx, startY: cy }
|
|
}
|
|
return
|
|
}
|
|
|
|
// Pen tool: click to add vertices
|
|
if (tool === 'PEN') {
|
|
store.penAddVertex(cx, cy)
|
|
drag.value = { type: 'pen-drag', startX: cx, startY: cy } as DragState
|
|
return
|
|
}
|
|
|
|
// Text tool: click to create text node
|
|
if (tool === 'TEXT') {
|
|
const nodeId = store.createShape('TEXT', cx, cy, DEFAULT_TEXT_WIDTH, DEFAULT_TEXT_HEIGHT)
|
|
store.graph.updateNode(nodeId, { text: '' })
|
|
store.select([nodeId])
|
|
store.startTextEditing(nodeId)
|
|
store.setTool('SELECT')
|
|
store.requestRender()
|
|
return
|
|
}
|
|
|
|
// Shape creation
|
|
const nodeType = TOOL_TO_NODE[tool]
|
|
if (!nodeType) return
|
|
|
|
const nodeId = store.createShape(nodeType, cx, cy, 0, 0)
|
|
store.select([nodeId])
|
|
|
|
drag.value = { type: 'draw', startX: cx, startY: cy, nodeId }
|
|
}
|
|
|
|
function onMouseMove(e: MouseEvent) {
|
|
if (onCursorMove) {
|
|
const { cx, cy } = getCoords(e)
|
|
onCursorMove(cx, cy)
|
|
}
|
|
|
|
// Pen tool: track cursor for preview line
|
|
if (store.state.activeTool === 'PEN' && store.state.penState && !drag.value) {
|
|
const { cx, cy } = getCoords(e)
|
|
store.state.penCursorX = cx
|
|
store.state.penCursorY = cy
|
|
|
|
// Check proximity to first vertex for closing
|
|
const first = store.state.penState.vertices[0]
|
|
if (store.state.penState.vertices.length > 2 && first) {
|
|
const dist = Math.hypot(cx - first.x, cy - first.y)
|
|
store.penSetClosingToFirst(dist < PEN_CLOSE_THRESHOLD)
|
|
}
|
|
store.requestRepaint()
|
|
}
|
|
|
|
// Cursor + hover highlight
|
|
if (!drag.value && store.state.activeTool === 'SELECT') {
|
|
const { sx, sy, cx, cy } = getCoords(e)
|
|
let cursor: string | null = null
|
|
|
|
// Rotation handle cursor
|
|
if (store.state.selectedIds.size === 1) {
|
|
const id = [...store.state.selectedIds][0]
|
|
const node = store.graph.getNode(id)
|
|
if (node) {
|
|
const abs = store.graph.getAbsolutePosition(id)
|
|
if (
|
|
hitTestRotationHandle(
|
|
sx,
|
|
sy,
|
|
abs.x,
|
|
abs.y,
|
|
node.width,
|
|
node.height,
|
|
store.state.zoom,
|
|
store.state.panX,
|
|
store.state.panY,
|
|
node.rotation
|
|
)
|
|
) {
|
|
cursor = 'grab'
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!cursor) {
|
|
for (const id of store.state.selectedIds) {
|
|
const node = store.graph.getNode(id)
|
|
if (!node) continue
|
|
const abs = store.graph.getAbsolutePosition(id)
|
|
const handle = hitTestHandle(
|
|
sx,
|
|
sy,
|
|
abs.x,
|
|
abs.y,
|
|
node.width,
|
|
node.height,
|
|
store.state.zoom,
|
|
store.state.panX,
|
|
store.state.panY,
|
|
node.rotation
|
|
)
|
|
if (handle) {
|
|
cursor = HANDLE_CURSORS[handle]
|
|
break
|
|
}
|
|
}
|
|
}
|
|
cursorOverride.value = cursor
|
|
|
|
const hit =
|
|
hitTestSectionTitle(cx, cy) ??
|
|
hitTestComponentLabel(cx, cy) ??
|
|
store.graph.hitTest(cx, cy, store.state.currentPageId)
|
|
store.setHoveredNode(hit && !store.state.selectedIds.has(hit.id) ? hit.id : null)
|
|
}
|
|
|
|
if (!drag.value) return
|
|
const d = drag.value
|
|
|
|
if (d.type === 'pan') {
|
|
const dx = e.clientX - d.startScreenX
|
|
const dy = e.clientY - d.startScreenY
|
|
store.state.panX = d.startPanX + dx
|
|
store.state.panY = d.startPanY + dy
|
|
store.requestRepaint()
|
|
return
|
|
}
|
|
|
|
const { cx, cy, sx, sy } = getCoords(e)
|
|
|
|
if (d.type === 'rotate') {
|
|
const currentAngle = Math.atan2(sy - d.centerY, sx - d.centerX) * (180 / Math.PI)
|
|
let rotation = d.origRotation + (currentAngle - d.startAngle)
|
|
|
|
// Shift → snap to 15° increments
|
|
if (e.shiftKey) {
|
|
rotation = Math.round(rotation / ROTATION_SNAP_DEGREES) * ROTATION_SNAP_DEGREES
|
|
}
|
|
|
|
// Normalize to -180..180
|
|
rotation = ((((rotation + 180) % 360) + 360) % 360) - 180
|
|
|
|
store.setRotationPreview({ nodeId: d.nodeId, angle: rotation })
|
|
return
|
|
}
|
|
|
|
if (d.type === 'move') {
|
|
let dx = cx - d.startX
|
|
let dy = cy - d.startY
|
|
|
|
// Auto-layout: dead zone before breaking out
|
|
if (d.autoLayoutParentId && !d.brokeFromAutoLayout) {
|
|
const dist = Math.sqrt(dx * dx + dy * dy)
|
|
if (dist < AUTO_LAYOUT_BREAK_THRESHOLD) {
|
|
// Still in dead zone — compute insertion indicator within parent
|
|
computeAutoLayoutIndicator(d, cx, cy)
|
|
return
|
|
}
|
|
d.brokeFromAutoLayout = true
|
|
store.setLayoutInsertIndicator(null)
|
|
}
|
|
|
|
// Check if we're hovering over an auto-layout frame
|
|
let dropTarget = store.graph.hitTestFrame(
|
|
cx,
|
|
cy,
|
|
store.state.selectedIds,
|
|
store.state.currentPageId
|
|
)
|
|
// Sections can't be dropped into frames or groups
|
|
const movingSection = [...store.state.selectedIds].some(
|
|
(id) => store.graph.getNode(id)?.type === 'SECTION'
|
|
)
|
|
if (
|
|
movingSection &&
|
|
dropTarget &&
|
|
dropTarget.type !== 'SECTION' &&
|
|
dropTarget.type !== 'CANVAS'
|
|
) {
|
|
dropTarget = null
|
|
}
|
|
const dropParent = dropTarget ? store.graph.getNode(dropTarget.id) : null
|
|
|
|
if (dropParent && dropParent.layoutMode !== 'NONE') {
|
|
computeAutoLayoutIndicatorForFrame(dropParent, cx, cy)
|
|
store.setDropTarget(dropParent.id)
|
|
|
|
// Still move the nodes freely so the user sees them floating
|
|
for (const [id, orig] of d.originals) {
|
|
store.graph.updateNode(id, {
|
|
x: Math.round(orig.x + dx),
|
|
y: Math.round(orig.y + dy)
|
|
})
|
|
}
|
|
store.requestRender()
|
|
return
|
|
}
|
|
|
|
store.setLayoutInsertIndicator(null)
|
|
|
|
// Compute snap using absolute positions
|
|
const selectedNodes: SceneNode[] = []
|
|
for (const [id, orig] of d.originals) {
|
|
const n = store.graph.getNode(id)
|
|
if (n) {
|
|
const abs = store.graph.getAbsolutePosition(id)
|
|
const parentAbs = n.parentId
|
|
? store.graph.getAbsolutePosition(n.parentId)
|
|
: { x: 0, y: 0 }
|
|
selectedNodes.push({
|
|
...n,
|
|
x: abs.x - parentAbs.x - n.x + orig.x + dx,
|
|
y: abs.y - parentAbs.y - n.y + orig.y + dy
|
|
})
|
|
}
|
|
}
|
|
|
|
const bounds = computeSelectionBounds(selectedNodes)
|
|
if (bounds) {
|
|
// Snap against siblings in absolute coordinates
|
|
const firstId = [...d.originals.keys()][0]
|
|
const firstNode = store.graph.getNode(firstId)
|
|
const parentId = firstNode?.parentId ?? store.state.currentPageId
|
|
const siblings = store.graph.getChildren(parentId)
|
|
const parentAbs = !store.isTopLevel(parentId)
|
|
? store.graph.getAbsolutePosition(parentId)
|
|
: { x: 0, y: 0 }
|
|
const absTargets = siblings.map((n) => ({
|
|
...n,
|
|
x: n.x + parentAbs.x,
|
|
y: n.y + parentAbs.y
|
|
}))
|
|
// Convert moving bounds to absolute coords too
|
|
const absBounds = {
|
|
x: bounds.x + parentAbs.x,
|
|
y: bounds.y + parentAbs.y,
|
|
width: bounds.width,
|
|
height: bounds.height
|
|
}
|
|
const snap = computeSnap(store.state.selectedIds, absBounds, absTargets)
|
|
dx += snap.dx
|
|
dy += snap.dy
|
|
store.setSnapGuides(snap.guides)
|
|
}
|
|
|
|
for (const [id, orig] of d.originals) {
|
|
store.updateNode(id, { x: Math.round(orig.x + dx), y: Math.round(orig.y + dy) })
|
|
}
|
|
|
|
store.setDropTarget(dropTarget?.id ?? null)
|
|
return
|
|
}
|
|
|
|
if (d.type === 'text-select') {
|
|
const editor = store.textEditor
|
|
const editNode = store.state.editingTextId
|
|
? store.graph.getNode(store.state.editingTextId)
|
|
: null
|
|
if (editor && editNode) {
|
|
const abs = store.graph.getAbsolutePosition(editNode.id)
|
|
editor.setCursorAt(cx - abs.x, cy - abs.y, true)
|
|
store.requestRender()
|
|
}
|
|
return
|
|
}
|
|
|
|
if (d.type === 'resize') {
|
|
applyResize(d, cx, cy, e.shiftKey)
|
|
return
|
|
}
|
|
|
|
if (d.type === 'pen-drag') {
|
|
const tx = cx - d.startX
|
|
const ty = cy - d.startY
|
|
if (Math.hypot(tx, ty) > 2) {
|
|
store.penSetDragTangent(tx, ty)
|
|
}
|
|
return
|
|
}
|
|
|
|
if (d.type === 'draw') {
|
|
let w = cx - d.startX
|
|
let h = cy - d.startY
|
|
|
|
if (e.shiftKey) {
|
|
const size = Math.max(Math.abs(w), Math.abs(h))
|
|
w = Math.sign(w) * size
|
|
h = Math.sign(h) * size
|
|
}
|
|
|
|
store.updateNode(d.nodeId, {
|
|
x: w < 0 ? d.startX + w : d.startX,
|
|
y: h < 0 ? d.startY + h : d.startY,
|
|
width: Math.abs(w),
|
|
height: Math.abs(h)
|
|
})
|
|
return
|
|
}
|
|
|
|
if (d.type === 'marquee') {
|
|
const minX = Math.min(d.startX, cx)
|
|
const minY = Math.min(d.startY, cy)
|
|
const maxX = Math.max(d.startX, cx)
|
|
const maxY = Math.max(d.startY, cy)
|
|
|
|
const hits: string[] = []
|
|
for (const node of store.graph.getChildren(store.state.currentPageId)) {
|
|
if (
|
|
node.x + node.width > minX &&
|
|
node.x < maxX &&
|
|
node.y + node.height > minY &&
|
|
node.y < maxY
|
|
) {
|
|
hits.push(node.id)
|
|
}
|
|
}
|
|
store.select(hits)
|
|
store.setMarquee({ x: minX, y: minY, width: maxX - minX, height: maxY - minY })
|
|
}
|
|
}
|
|
|
|
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) {
|
|
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 (moveTop) y = origRect.y + origRect.height - Math.abs(height)
|
|
} else {
|
|
width = Math.abs(height) * aspect * Math.sign(width || 1)
|
|
if (moveLeft) x = origRect.x + origRect.width - Math.abs(width)
|
|
}
|
|
}
|
|
}
|
|
|
|
if (width < 0) {
|
|
x = x + width
|
|
width = -width
|
|
}
|
|
if (height < 0) {
|
|
y = y + height
|
|
height = -height
|
|
}
|
|
|
|
store.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 onMouseUp() {
|
|
if (!drag.value) return
|
|
const d = drag.value
|
|
|
|
if (d.type === 'move') {
|
|
const indicator = store.state.layoutInsertIndicator
|
|
store.setLayoutInsertIndicator(null)
|
|
store.setSnapGuides([])
|
|
|
|
if (indicator) {
|
|
// Drop into auto-layout at the indicated position
|
|
for (const id of store.state.selectedIds) {
|
|
store.reorderInAutoLayout(id, indicator.parentId, indicator.index)
|
|
}
|
|
store.setDropTarget(null)
|
|
} else {
|
|
// Check if the user actually dragged
|
|
const moved = [...d.originals].some(([id, orig]) => {
|
|
const node = store.graph.getNode(id)
|
|
return node && (node.x !== orig.x || node.y !== orig.y)
|
|
})
|
|
|
|
if (moved) {
|
|
store.commitMove(d.originals)
|
|
|
|
// Reparent into frame if dropped on one
|
|
const dropId = store.state.dropTargetId
|
|
if (dropId) {
|
|
store.reparentNodes([...store.state.selectedIds], dropId)
|
|
} else {
|
|
// Reparent to grandparent if dragged outside parent bounds
|
|
for (const id of store.state.selectedIds) {
|
|
const node = store.graph.getNode(id)
|
|
if (!node?.parentId || store.isTopLevel(node.parentId)) continue
|
|
const parent = store.graph.getNode(node.parentId)
|
|
if (!parent || (parent.type !== 'FRAME' && parent.type !== 'SECTION')) continue
|
|
const outsideX = node.x + node.width < 0 || node.x > parent.width
|
|
const outsideY = node.y + node.height < 0 || node.y > parent.height
|
|
if (outsideX || outsideY) {
|
|
const grandparentId = parent.parentId ?? store.state.currentPageId
|
|
store.graph.reparentNode(id, grandparentId)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
store.setDropTarget(null)
|
|
}
|
|
}
|
|
|
|
if (d.type === 'text-select') {
|
|
drag.value = null
|
|
return
|
|
}
|
|
|
|
if (d.type === 'resize') {
|
|
store.commitResize(d.nodeId, d.origRect)
|
|
}
|
|
|
|
if (d.type === 'pen-drag') {
|
|
drag.value = null
|
|
return
|
|
}
|
|
|
|
if (d.type === 'rotate') {
|
|
const preview = store.state.rotationPreview
|
|
if (preview) {
|
|
store.updateNode(d.nodeId, { rotation: preview.angle })
|
|
store.commitRotation(d.nodeId, d.origRotation)
|
|
}
|
|
store.setRotationPreview(null)
|
|
}
|
|
|
|
if (d.type === 'draw') {
|
|
const node = store.graph.getNode(d.nodeId)
|
|
if (node && node.width < 2 && node.height < 2) {
|
|
store.updateNode(d.nodeId, { width: 100, height: 100 })
|
|
}
|
|
if (node?.type === 'SECTION') {
|
|
store.adoptNodesIntoSection(node.id)
|
|
}
|
|
store.setTool('SELECT')
|
|
}
|
|
|
|
if (d.type === 'marquee') {
|
|
store.setMarquee(null)
|
|
}
|
|
|
|
drag.value = null
|
|
cursorOverride.value = null
|
|
}
|
|
|
|
let wheelAccum = {
|
|
deltaX: 0,
|
|
deltaY: 0,
|
|
zoomDelta: 0,
|
|
zoomCenterX: 0,
|
|
zoomCenterY: 0,
|
|
hasZoom: false,
|
|
rafId: 0
|
|
}
|
|
|
|
function flushWheel() {
|
|
wheelAccum.rafId = 0
|
|
store.setHoveredNode(null)
|
|
if (wheelAccum.hasZoom) {
|
|
store.applyZoom(wheelAccum.zoomDelta, wheelAccum.zoomCenterX, wheelAccum.zoomCenterY)
|
|
} else {
|
|
store.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 (store.state.editingTextId) return
|
|
|
|
const { cx, cy } = getCoords(e)
|
|
const hit =
|
|
hitTestSectionTitle(cx, cy) ??
|
|
hitTestComponentLabel(cx, cy) ??
|
|
store.graph.hitTestDeep(cx, cy, store.state.currentPageId)
|
|
if (!hit) return
|
|
|
|
if (hit.type === 'TEXT') {
|
|
store.select([hit.id])
|
|
store.startTextEditing(hit.id)
|
|
const editor = store.textEditor
|
|
if (editor) {
|
|
const abs = store.graph.getAbsolutePosition(hit.id)
|
|
editor.selectWordAt(cx - abs.x, cy - abs.y)
|
|
store.requestRender()
|
|
}
|
|
return
|
|
}
|
|
|
|
store.select([hit.id])
|
|
}
|
|
|
|
function computeAutoLayoutIndicator(d: DragMove, cx: number, cy: number) {
|
|
if (!d.autoLayoutParentId) return
|
|
const parent = store.graph.getNode(d.autoLayoutParentId)
|
|
if (!parent || parent.layoutMode === 'NONE') return
|
|
computeAutoLayoutIndicatorForFrame(parent, cx, cy)
|
|
}
|
|
|
|
function computeAutoLayoutIndicatorForFrame(parent: SceneNode, cx: number, cy: number) {
|
|
const children = store.graph
|
|
.getChildren(parent.id)
|
|
.filter((c) => c.layoutPositioning !== 'ABSOLUTE' && !store.state.selectedIds.has(c.id))
|
|
|
|
const parentAbs = store.graph.getAbsolutePosition(parent.id)
|
|
const isRow = parent.layoutMode === 'HORIZONTAL'
|
|
|
|
let insertIndex = children.length
|
|
|
|
for (let i = 0; i < children.length; i++) {
|
|
const child = children[i]
|
|
const childAbs = store.graph.getAbsolutePosition(child.id)
|
|
const mid = isRow ? childAbs.x + child.width / 2 : childAbs.y + child.height / 2
|
|
const cursor = isRow ? cx : cy
|
|
|
|
if (cursor < mid) {
|
|
insertIndex = i
|
|
break
|
|
}
|
|
}
|
|
|
|
// Compute indicator position
|
|
let indicatorPos: number
|
|
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
|
|
|
|
if (children.length === 0) {
|
|
indicatorPos = isRow ? parentAbs.x + parent.paddingLeft : parentAbs.y + parent.paddingTop
|
|
} else if (insertIndex === 0) {
|
|
const first = children[0]
|
|
const firstAbs = store.graph.getAbsolutePosition(first.id)
|
|
indicatorPos = isRow
|
|
? firstAbs.x - parent.itemSpacing / 2
|
|
: firstAbs.y - parent.itemSpacing / 2
|
|
} else if (insertIndex >= children.length) {
|
|
const last = children[children.length - 1]
|
|
const lastAbs = store.graph.getAbsolutePosition(last.id)
|
|
indicatorPos = isRow
|
|
? lastAbs.x + last.width + parent.itemSpacing / 2
|
|
: lastAbs.y + last.height + parent.itemSpacing / 2
|
|
} else {
|
|
const prev = children[insertIndex - 1]
|
|
const next = children[insertIndex]
|
|
const prevAbs = store.graph.getAbsolutePosition(prev.id)
|
|
const nextAbs = store.graph.getAbsolutePosition(next.id)
|
|
indicatorPos = isRow
|
|
? (prevAbs.x + prev.width + nextAbs.x) / 2
|
|
: (prevAbs.y + prev.height + nextAbs.y) / 2
|
|
}
|
|
|
|
// Account for the dragged node being in the children list
|
|
// (we filtered it out, so insertIndex is relative to the filtered list)
|
|
// Convert to actual childIds index
|
|
const allChildren = store.graph.getChildren(parent.id)
|
|
let realIndex = 0
|
|
let filteredCount = 0
|
|
for (let i = 0; i < allChildren.length; i++) {
|
|
if (store.state.selectedIds.has(allChildren[i].id)) continue
|
|
if (allChildren[i].layoutPositioning === 'ABSOLUTE') {
|
|
realIndex++
|
|
continue
|
|
}
|
|
if (filteredCount === insertIndex) break
|
|
filteredCount++
|
|
realIndex++
|
|
}
|
|
|
|
store.setLayoutInsertIndicator({
|
|
parentId: parent.id,
|
|
index: realIndex,
|
|
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 = store.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 = store.state.activeTool
|
|
if (tool === 'HAND') {
|
|
touchAsMouse = false
|
|
drag.value = {
|
|
type: 'pan',
|
|
startScreenX: t.clientX,
|
|
startScreenY: t.clientY,
|
|
startPanX: store.state.panX,
|
|
startPanY: store.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
|
|
|
|
store.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 / store.state.zoom
|
|
|
|
const panDx = newMidX - pinchMidX
|
|
const panDy = newMidY - pinchMidY
|
|
|
|
store.state.panX = pinchMidX - (pinchMidX - store.state.panX) * zoomRatio + panDx
|
|
store.state.panY = pinchMidY - (pinchMidY - store.state.panY) * zoomRatio + panDy
|
|
store.state.zoom = newZoom
|
|
}
|
|
|
|
pinchMidX = newMidX
|
|
pinchMidY = newMidY
|
|
store.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
|
|
store.state.panX = d.startPanX + (t.clientX - d.startScreenX)
|
|
store.state.panY = d.startPanY + (t.clientY - d.startScreenY)
|
|
store.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: store.state.panX,
|
|
startPanY: store.state.panY
|
|
}
|
|
}
|
|
pinchStartDist = 0
|
|
}
|
|
}
|
|
|
|
useEventListener(canvasRef, 'dblclick', onDblClick)
|
|
useEventListener(canvasRef, 'mousedown', onMouseDown)
|
|
useEventListener(canvasRef, 'mousemove', onMouseMove)
|
|
useEventListener(canvasRef, 'mouseup', onMouseUp)
|
|
useEventListener(canvasRef, 'mouseleave', () => {
|
|
onMouseUp()
|
|
store.setHoveredNode(null)
|
|
})
|
|
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
|
|
store.setHoveredNode(null)
|
|
const { scale, sx, sy } = pendingGesture
|
|
pendingGesture = null
|
|
const newZoom = Math.max(0.02, Math.min(256, gestureStartZoom * scale))
|
|
const zoomRatio = newZoom / store.state.zoom
|
|
store.state.panX = sx - (sx - store.state.panX) * zoomRatio
|
|
store.state.panY = sy - (sy - store.state.panY) * zoomRatio
|
|
store.state.zoom = newZoom
|
|
store.requestRepaint()
|
|
}
|
|
|
|
useEventListener(
|
|
canvasRef,
|
|
'gesturestart' as keyof HTMLElementEventMap,
|
|
(e: Event) => {
|
|
e.preventDefault()
|
|
gestureStartZoom = store.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.width / 2) - rect.left,
|
|
sy: (ge.clientY ?? rect.height / 2) - rect.top
|
|
}
|
|
if (!gestureRafId) {
|
|
gestureRafId = requestAnimationFrame(flushGesture)
|
|
}
|
|
},
|
|
{ passive: false }
|
|
)
|
|
useEventListener(
|
|
canvasRef,
|
|
'gestureend' as keyof HTMLElementEventMap,
|
|
(e: Event) => {
|
|
e.preventDefault()
|
|
},
|
|
{ passive: false }
|
|
)
|
|
|
|
return {
|
|
drag,
|
|
cursorOverride
|
|
}
|
|
}
|