openpencil/src/stores/editor.ts
Danila Poyarkov 94c4635907 Add rotation, multi-select bbox, snap guides, text tool, color picker
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
2026-02-27 22:15:35 +03:00

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
}