Rotation: - Rotation handle above selection (line + circle) - Drag to rotate, Shift snaps to 15° increments - Live preview during drag, applied on release - Rotated nodes render correctly with canvas.rotate() Multi-select bounding box: - Individual outlines per node + unified group bbox - Group resize handles on the unified bounds - Handles rotated AABB for rotated nodes Snap guides: - Magenta guide lines when moving nodes near edges/centers - Snaps to left/right/top/bottom/center of other nodes - 5px threshold, guides clear on mouse up Text tool: - T shortcut or toolbar to activate - Click to place text node (200×24, default 'Text') - CanvasKit drawText with default font (Inter to come) Color picker (Reka UI Popover): - HSV saturation/brightness area with pointer drag - Hue slider (rainbow gradient) - Alpha slider - Hex input + alpha percentage - Live color updates to fill/stroke Properties panel: - Add/remove fills and strokes with +/× buttons - Toggle fill visibility - Stroke weight editing - Multi-select shows layer count + shared opacity - ColorPicker integrated for fill and stroke colors Frame clipping: - Frame children now clip to parent bounds
321 lines
7.8 KiB
TypeScript
321 lines
7.8 KiB
TypeScript
import { reactive, shallowRef, computed } from 'vue'
|
|
|
|
import { SceneGraph } from '../engine/scene-graph'
|
|
import { UndoManager } from '../engine/undo'
|
|
|
|
import type { SceneNode, NodeType, Fill } from '../engine/scene-graph'
|
|
import type { SnapGuide } from '../engine/snap'
|
|
|
|
export type Tool = 'SELECT' | 'FRAME' | 'RECTANGLE' | 'ELLIPSE' | 'LINE' | 'TEXT' | 'PEN' | 'HAND'
|
|
|
|
export interface ToolDef {
|
|
key: Tool
|
|
label: string
|
|
icon: string
|
|
shortcut: string
|
|
flyout?: Tool[]
|
|
}
|
|
|
|
export const TOOLS: ToolDef[] = [
|
|
{ key: 'SELECT', label: 'Move', icon: '↖', shortcut: 'V' },
|
|
{ key: 'FRAME', label: 'Frame', icon: '#', shortcut: 'F' },
|
|
{
|
|
key: 'RECTANGLE',
|
|
label: 'Rectangle',
|
|
icon: '□',
|
|
shortcut: 'R',
|
|
flyout: ['RECTANGLE', 'ELLIPSE', 'LINE']
|
|
},
|
|
{ key: 'PEN', label: 'Pen', icon: '✒', shortcut: 'P' },
|
|
{ key: 'TEXT', label: 'Text', icon: 'T', shortcut: 'T' },
|
|
{ key: 'HAND', label: 'Hand', icon: '✋', shortcut: 'H' }
|
|
]
|
|
|
|
export const TOOL_SHORTCUTS: Record<string, Tool> = {
|
|
v: 'SELECT',
|
|
f: 'FRAME',
|
|
r: 'RECTANGLE',
|
|
o: 'ELLIPSE',
|
|
l: 'LINE',
|
|
t: 'TEXT',
|
|
p: 'PEN',
|
|
h: 'HAND'
|
|
}
|
|
|
|
const DEFAULT_FILLS: Record<string, Fill> = {
|
|
FRAME: { type: 'SOLID', color: { r: 1, g: 1, b: 1, a: 1 }, opacity: 1, visible: true },
|
|
RECTANGLE: {
|
|
type: 'SOLID',
|
|
color: { r: 0.83, g: 0.83, b: 0.83, a: 1 },
|
|
opacity: 1,
|
|
visible: true
|
|
},
|
|
ELLIPSE: {
|
|
type: 'SOLID',
|
|
color: { r: 0.83, g: 0.83, b: 0.83, a: 1 },
|
|
opacity: 1,
|
|
visible: true
|
|
},
|
|
LINE: { type: 'SOLID', color: { r: 0, g: 0, b: 0, a: 1 }, opacity: 1, visible: true },
|
|
TEXT: { type: 'SOLID', color: { r: 0, g: 0, b: 0, a: 1 }, opacity: 1, visible: true }
|
|
}
|
|
|
|
export function createEditorStore() {
|
|
const graph = new SceneGraph()
|
|
const undo = new UndoManager()
|
|
|
|
const state = reactive({
|
|
activeTool: 'SELECT' as Tool,
|
|
selectedIds: new Set<string>(),
|
|
marquee: null as { x: number; y: number; width: number; height: number } | null,
|
|
snapGuides: [] as SnapGuide[],
|
|
rotationPreview: null as { nodeId: string; angle: number } | null,
|
|
panX: 0,
|
|
panY: 0,
|
|
zoom: 1,
|
|
renderVersion: 0
|
|
})
|
|
|
|
const selectedNodes = computed(() => {
|
|
const nodes: SceneNode[] = []
|
|
for (const id of state.selectedIds) {
|
|
const n = graph.getNode(id)
|
|
if (n) nodes.push(n)
|
|
}
|
|
return nodes
|
|
})
|
|
|
|
const selectedNode = computed(() =>
|
|
selectedNodes.value.length === 1 ? selectedNodes.value[0] : undefined
|
|
)
|
|
|
|
const layerNodes = computed(() => {
|
|
void state.renderVersion
|
|
return graph.getChildren(graph.rootId)
|
|
})
|
|
|
|
function requestRender() {
|
|
state.renderVersion++
|
|
}
|
|
|
|
function setTool(tool: Tool) {
|
|
state.activeTool = tool
|
|
}
|
|
|
|
function select(ids: string[], additive = false) {
|
|
if (additive) {
|
|
const next = new Set(state.selectedIds)
|
|
for (const id of ids) {
|
|
if (next.has(id)) next.delete(id)
|
|
else next.add(id)
|
|
}
|
|
state.selectedIds = next
|
|
} else {
|
|
state.selectedIds = new Set(ids)
|
|
}
|
|
}
|
|
|
|
function clearSelection() {
|
|
state.selectedIds = new Set()
|
|
}
|
|
|
|
function setMarquee(rect: { x: number; y: number; width: number; height: number } | null) {
|
|
state.marquee = rect
|
|
requestRender()
|
|
}
|
|
|
|
function setSnapGuides(guides: SnapGuide[]) {
|
|
state.snapGuides = guides
|
|
requestRender()
|
|
}
|
|
|
|
function setRotationPreview(preview: { nodeId: string; angle: number } | null) {
|
|
state.rotationPreview = preview
|
|
requestRender()
|
|
}
|
|
|
|
function updateNode(id: string, changes: Partial<SceneNode>) {
|
|
graph.updateNode(id, changes)
|
|
requestRender()
|
|
}
|
|
|
|
function createShape(type: NodeType, x: number, y: number, w: number, h: number): string {
|
|
const fill = DEFAULT_FILLS[type] ?? DEFAULT_FILLS.RECTANGLE
|
|
const node = graph.createNode(type, graph.rootId, {
|
|
x,
|
|
y,
|
|
width: w,
|
|
height: h,
|
|
fills: [{ ...fill }]
|
|
})
|
|
requestRender()
|
|
return node.id
|
|
}
|
|
|
|
function selectAll() {
|
|
const children = graph.getChildren(graph.rootId)
|
|
state.selectedIds = new Set(children.map((n) => n.id))
|
|
}
|
|
|
|
function duplicateSelected() {
|
|
const newIds: string[] = []
|
|
for (const id of state.selectedIds) {
|
|
const src = graph.getNode(id)
|
|
if (!src) continue
|
|
const node = graph.createNode(src.type, graph.rootId, {
|
|
...src,
|
|
name: src.name + ' copy',
|
|
x: src.x + 20,
|
|
y: src.y + 20
|
|
})
|
|
newIds.push(node.id)
|
|
}
|
|
if (newIds.length > 0) {
|
|
state.selectedIds = new Set(newIds)
|
|
requestRender()
|
|
}
|
|
}
|
|
|
|
function deleteSelected() {
|
|
undo.beginBatch('Delete')
|
|
for (const id of state.selectedIds) {
|
|
const node = graph.getNode(id)
|
|
if (!node) continue
|
|
const snapshot = { ...node }
|
|
const parentId = node.parentId ?? graph.rootId
|
|
undo.apply({
|
|
label: 'Delete',
|
|
forward: () => graph.deleteNode(id),
|
|
inverse: () => {
|
|
graph.createNode(snapshot.type, parentId, snapshot)
|
|
}
|
|
})
|
|
}
|
|
undo.commitBatch()
|
|
clearSelection()
|
|
requestRender()
|
|
}
|
|
|
|
function commitMove(originals: Map<string, { x: number; y: number }>) {
|
|
const finals = new Map<string, { x: number; y: number }>()
|
|
for (const [id] of originals) {
|
|
const n = graph.getNode(id)
|
|
if (n) finals.set(id, { x: n.x, y: n.y })
|
|
}
|
|
undo.apply({
|
|
label: 'Move',
|
|
forward: () => {
|
|
for (const [id, pos] of finals) graph.updateNode(id, pos)
|
|
},
|
|
inverse: () => {
|
|
for (const [id, pos] of originals) graph.updateNode(id, pos)
|
|
}
|
|
})
|
|
}
|
|
|
|
function undoAction() {
|
|
undo.undo()
|
|
requestRender()
|
|
}
|
|
|
|
function redoAction() {
|
|
undo.redo()
|
|
requestRender()
|
|
}
|
|
|
|
function screenToCanvas(sx: number, sy: number) {
|
|
return {
|
|
x: (sx - state.panX) / state.zoom,
|
|
y: (sy - state.panY) / state.zoom
|
|
}
|
|
}
|
|
|
|
function applyZoom(delta: number, centerX: number, centerY: number) {
|
|
const factor = delta < 0 ? 1.1 : 0.9
|
|
const newZoom = Math.max(0.02, Math.min(256, state.zoom * factor))
|
|
state.panX = centerX - (centerX - state.panX) * (newZoom / state.zoom)
|
|
state.panY = centerY - (centerY - state.panY) * (newZoom / state.zoom)
|
|
state.zoom = newZoom
|
|
requestRender()
|
|
}
|
|
|
|
function pan(dx: number, dy: number) {
|
|
state.panX += dx
|
|
state.panY += dy
|
|
requestRender()
|
|
}
|
|
|
|
function zoomToFit() {
|
|
const nodes = graph.getChildren(graph.rootId)
|
|
if (nodes.length === 0) return
|
|
|
|
let minX = Infinity
|
|
let minY = Infinity
|
|
let maxX = -Infinity
|
|
let maxY = -Infinity
|
|
for (const n of nodes) {
|
|
minX = Math.min(minX, n.x)
|
|
minY = Math.min(minY, n.y)
|
|
maxX = Math.max(maxX, n.x + n.width)
|
|
maxY = Math.max(maxY, n.y + n.height)
|
|
}
|
|
|
|
const padding = 80
|
|
const w = maxX - minX + padding * 2
|
|
const h = maxY - minY + padding * 2
|
|
|
|
// Will be set by canvas composable
|
|
const viewW = 800
|
|
const viewH = 600
|
|
const zoom = Math.min(viewW / w, viewH / h, 1)
|
|
|
|
state.zoom = zoom
|
|
state.panX = (viewW - w * zoom) / 2 - minX * zoom + padding * zoom
|
|
state.panY = (viewH - h * zoom) / 2 - minY * zoom + padding * zoom
|
|
requestRender()
|
|
}
|
|
|
|
return {
|
|
graph,
|
|
undo,
|
|
state,
|
|
selectedNodes,
|
|
selectedNode,
|
|
layerNodes,
|
|
requestRender,
|
|
setTool,
|
|
select,
|
|
clearSelection,
|
|
selectAll,
|
|
setMarquee,
|
|
setSnapGuides,
|
|
setRotationPreview,
|
|
updateNode,
|
|
createShape,
|
|
duplicateSelected,
|
|
deleteSelected,
|
|
commitMove,
|
|
undoAction,
|
|
redoAction,
|
|
screenToCanvas,
|
|
applyZoom,
|
|
pan,
|
|
zoomToFit
|
|
}
|
|
}
|
|
|
|
export type EditorStore = ReturnType<typeof createEditorStore>
|
|
|
|
const storeRef = shallowRef<EditorStore>()
|
|
|
|
export function provideEditorStore(): EditorStore {
|
|
const store = createEditorStore()
|
|
storeRef.value = store
|
|
return store
|
|
}
|
|
|
|
export function useEditorStore(): EditorStore {
|
|
if (!storeRef.value) throw new Error('Editor store not provided')
|
|
return storeRef.value
|
|
}
|