feat(editor): add event bus for editor lifecycle events

Wire nanoevents-based event bus to the editor core:

- EditorEvents type with render, graph, selection, tool, page, viewport events
- All node mutations (create/update/delete/reparent/reorder) forwarded from SceneGraph
- Selection changes routed through setSelectedIds() with diff detection
- Tool changes routed through setActiveTool() with change detection
- Page switches emit page:changed
- Viewport pan/zoom/fit/zoomTo emit viewport:changed
- Graph replacement emits graph:replaced
- Typed onEditorEvent() subscription on the Editor return object
- useEditorEvent() Vue composable with automatic scope cleanup
- Replace all direct state.selectedIds/state.activeTool mutations in core, app, and SDK
This commit is contained in:
Danila Poyarkov 2026-05-06 15:25:27 +03:00
parent dfa9505805
commit 1835903e56
28 changed files with 207 additions and 65 deletions

View file

@ -38,7 +38,7 @@ export function createClipboardActions(ctx: EditorContext) {
} }
if (newRootIds.length > 0) { if (newRootIds.length > 0) {
ctx.state.selectedIds = new Set(newRootIds) ctx.setSelectedIds(new Set(newRootIds))
ctx.undo.push({ ctx.undo.push({
label: 'Duplicate', label: 'Duplicate',
forward: () => { forward: () => {
@ -48,11 +48,11 @@ export function createClipboardActions(ctx: EditorContext) {
const parentId = snapshot.parentId ?? ctx.state.currentPageId const parentId = snapshot.parentId ?? ctx.state.currentPageId
restoreSubtree(ctx.graph, snapshot, parentId, allSnapshots) restoreSubtree(ctx.graph, snapshot, parentId, allSnapshots)
} }
ctx.state.selectedIds = new Set(newRootIds) ctx.setSelectedIds(new Set(newRootIds))
}, },
inverse: () => { inverse: () => {
for (const id of newRootIds.slice().reverse()) ctx.graph.deleteNode(id) for (const id of newRootIds.slice().reverse()) ctx.graph.deleteNode(id)
ctx.state.selectedIds = prevSelection ctx.setSelectedIds(prevSelection)
} }
}) })
} }
@ -82,7 +82,7 @@ export function createClipboardActions(ctx: EditorContext) {
const cy = cursorPos?.y ?? (-ctx.state.panY + viewH / 2) / ctx.state.zoom const cy = cursorPos?.y ?? (-ctx.state.panY + viewH / 2) / ctx.state.zoom
placementActions.centerNodesAt(created, cx, cy) placementActions.centerNodesAt(created, cx, cy)
computeAllLayouts(ctx.graph, ctx.state.currentPageId) computeAllLayouts(ctx.graph, ctx.state.currentPageId)
ctx.state.selectedIds = new Set(created) ctx.setSelectedIds(new Set(created))
const allNodes = collectSubtrees(ctx.graph, created) const allNodes = collectSubtrees(ctx.graph, created)
const pageId = ctx.state.currentPageId const pageId = ctx.state.currentPageId
@ -96,12 +96,12 @@ export function createClipboardActions(ctx: EditorContext) {
}) })
} }
computeAllLayouts(ctx.graph, pageId) computeAllLayouts(ctx.graph, pageId)
ctx.state.selectedIds = new Set(created) ctx.setSelectedIds(new Set(created))
}, },
inverse: () => { inverse: () => {
for (const id of [...created].reverse()) ctx.graph.deleteNode(id) for (const id of [...created].reverse()) ctx.graph.deleteNode(id)
computeAllLayouts(ctx.graph, pageId) computeAllLayouts(ctx.graph, pageId)
ctx.state.selectedIds = prevSelection ctx.setSelectedIds(prevSelection)
} }
}) })
void fontActions.loadFontsForNodes(created) void fontActions.loadFontsForNodes(created)
@ -137,7 +137,7 @@ export function createClipboardActions(ctx: EditorContext) {
if (cursorPos) placementActions.centerNodesAt(created, cursorPos.x, cursorPos.y) if (cursorPos) placementActions.centerNodesAt(created, cursorPos.x, cursorPos.y)
computeAllLayouts(ctx.graph, ctx.state.currentPageId) computeAllLayouts(ctx.graph, ctx.state.currentPageId)
ctx.state.selectedIds = new Set(created) ctx.setSelectedIds(new Set(created))
const allNodes = collectSubtrees(ctx.graph, created) const allNodes = collectSubtrees(ctx.graph, created)
const pageId = ctx.state.currentPageId const pageId = ctx.state.currentPageId
@ -151,12 +151,12 @@ export function createClipboardActions(ctx: EditorContext) {
}) })
} }
computeAllLayouts(ctx.graph, pageId) computeAllLayouts(ctx.graph, pageId)
ctx.state.selectedIds = new Set(created) ctx.setSelectedIds(new Set(created))
}, },
inverse: () => { inverse: () => {
for (const id of [...created].reverse()) ctx.graph.deleteNode(id) for (const id of [...created].reverse()) ctx.graph.deleteNode(id)
computeAllLayouts(ctx.graph, pageId) computeAllLayouts(ctx.graph, pageId)
ctx.state.selectedIds = prevSelection ctx.setSelectedIds(prevSelection)
} }
}) })
} }
@ -192,7 +192,7 @@ export function createClipboardActions(ctx: EditorContext) {
label: 'Delete', label: 'Delete',
forward: () => { forward: () => {
for (const { id } of entries) ctx.graph.deleteNode(id) for (const { id } of entries) ctx.graph.deleteNode(id)
ctx.state.selectedIds = new Set() ctx.setSelectedIds(new Set())
}, },
inverse: () => { inverse: () => {
for (const { id, parentId, index, subtree } of [...entries].reverse()) { for (const { id, parentId, index, subtree } of [...entries].reverse()) {
@ -200,10 +200,10 @@ export function createClipboardActions(ctx: EditorContext) {
if (rootSnap) restoreSubtree(ctx.graph, rootSnap, parentId, subtree) if (rootSnap) restoreSubtree(ctx.graph, rootSnap, parentId, subtree)
if (index >= 0) ctx.graph.reorderChild(id, parentId, index) if (index >= 0) ctx.graph.reorderChild(id, parentId, index)
} }
ctx.state.selectedIds = prevSelection ctx.setSelectedIds(prevSelection)
} }
}) })
ctx.state.selectedIds = new Set() ctx.setSelectedIds(new Set())
} }
const copyActions = createClipboardCopyActions(ctx) const copyActions = createClipboardCopyActions(ctx)

View file

@ -68,7 +68,7 @@ export function createClipboardImageActions(ctx: EditorContext) {
ctx.graph.images.delete(hash) ctx.graph.images.delete(hash)
const next = new Set(ctx.state.selectedIds) const next = new Set(ctx.state.selectedIds)
next.delete(id) next.delete(id)
ctx.state.selectedIds = next ctx.setSelectedIds(next)
} }
}) })
return id return id
@ -97,7 +97,7 @@ export function createClipboardImageActions(ctx: EditorContext) {
curX += p.w + IMAGE_GAP curX += p.w + IMAGE_GAP
} }
if (ids.length) { if (ids.length) {
ctx.state.selectedIds = new Set(ids) ctx.setSelectedIds(new Set(ids))
ctx.requestRender() ctx.requestRender()
} }
} }

View file

@ -25,16 +25,16 @@ export function createComponentActions(ctx: EditorContext) {
if (node.type === 'FRAME' || node.type === 'GROUP') { if (node.type === 'FRAME' || node.type === 'GROUP') {
ctx.graph.updateNode(node.id, { type: 'COMPONENT' }) ctx.graph.updateNode(node.id, { type: 'COMPONENT' })
ctx.state.selectedIds = new Set([node.id]) ctx.setSelectedIds(new Set([node.id]))
ctx.undo.push({ ctx.undo.push({
label: 'Create component', label: 'Create component',
forward: () => { forward: () => {
ctx.graph.updateNode(node.id, { type: 'COMPONENT' }) ctx.graph.updateNode(node.id, { type: 'COMPONENT' })
ctx.state.selectedIds = new Set([node.id]) ctx.setSelectedIds(new Set([node.id]))
}, },
inverse: () => { inverse: () => {
ctx.graph.updateNode(node.id, { type: prevType }) ctx.graph.updateNode(node.id, { type: prevType })
ctx.state.selectedIds = prevSelection ctx.setSelectedIds(prevSelection)
} }
}) })
return return

View file

@ -18,7 +18,7 @@ export function createComponentFocusActions(ctx: EditorContext) {
void switchPage(current.id) void switchPage(current.id)
} }
ctx.state.selectedIds = new Set([main.id]) ctx.setSelectedIds(new Set([main.id]))
const abs = ctx.graph.getAbsolutePosition(main.id) const abs = ctx.graph.getAbsolutePosition(main.id)
const { width: viewW, height: viewH } = ctx.getViewportSize() const { width: viewW, height: viewH } = ctx.getViewportSize()

View file

@ -14,17 +14,17 @@ export function createComponentInstanceActions(ctx: EditorContext) {
if (!instance) return null if (!instance) return null
const instanceId = instance.id const instanceId = instance.id
ctx.state.selectedIds = new Set([instanceId]) ctx.setSelectedIds(new Set([instanceId]))
ctx.undo.push({ ctx.undo.push({
label: 'Create instance', label: 'Create instance',
forward: () => { forward: () => {
ctx.graph.createInstance(componentId, parentId, { ...instance }) ctx.graph.createInstance(componentId, parentId, { ...instance })
ctx.state.selectedIds = new Set([instanceId]) ctx.setSelectedIds(new Set([instanceId]))
}, },
inverse: () => { inverse: () => {
ctx.graph.deleteNode(instanceId) ctx.graph.deleteNode(instanceId)
ctx.state.selectedIds = new Set([componentId]) ctx.setSelectedIds(new Set([componentId]))
} }
}) })
return instanceId return instanceId
@ -36,7 +36,7 @@ export function createComponentInstanceActions(ctx: EditorContext) {
const prevComponentId = selectedNode.componentId const prevComponentId = selectedNode.componentId
ctx.graph.detachInstance(selectedNode.id) ctx.graph.detachInstance(selectedNode.id)
ctx.state.selectedIds = new Set([selectedNode.id]) ctx.setSelectedIds(new Set([selectedNode.id]))
ctx.undo.push({ ctx.undo.push({
label: 'Detach instance', label: 'Detach instance',

View file

@ -1,4 +1,6 @@
import type { CanvasKit } from 'canvaskit-wasm' import type { CanvasKit } from 'canvaskit-wasm'
import { createNanoEvents } from 'nanoevents'
import type { Emitter } from 'nanoevents'
import type { SkiaRenderer } from '#core/canvas/renderer' import type { SkiaRenderer } from '#core/canvas/renderer'
import { prefetchFigmaSchema } from '#core/clipboard' import { prefetchFigmaSchema } from '#core/clipboard'
@ -28,7 +30,13 @@ import { createShapeActions } from './shapes'
import { createDefaultEditorState } from './state' import { createDefaultEditorState } from './state'
import { createStructureActions } from './structure' import { createStructureActions } from './structure'
import { createTextActions } from './text' import { createTextActions } from './text'
import type { EditorContext, EditorOptions, EditorState } from './types' import type {
EditorContext,
EditorEventName,
EditorEvents,
EditorOptions,
EditorState
} from './types'
import { createUndoActions } from './undo' import { createUndoActions } from './undo'
import { createVariableActions } from './variables' import { createVariableActions } from './variables'
import { createViewportActions } from './viewport' import { createViewportActions } from './viewport'
@ -50,18 +58,56 @@ export function createEditor(options?: EditorOptions) {
let _renderer: SkiaRenderer | null = null let _renderer: SkiaRenderer | null = null
const _renderers = new Set<SkiaRenderer>() const _renderers = new Set<SkiaRenderer>()
let _textEditor: TextEditor | null = null let _textEditor: TextEditor | null = null
const events: Emitter<EditorEvents> = createNanoEvents()
void prefetchFigmaSchema() void prefetchFigmaSchema()
const state: EditorState = options?.state ?? createDefaultEditorState(_graph.getPages()[0].id) const state: EditorState = options?.state ?? createDefaultEditorState(_graph.getPages()[0].id)
function emitEditorEvent<K extends EditorEventName>(
event: K,
...args: Parameters<EditorEvents[K]>
) {
events.emit(event, ...args)
}
function onEditorEvent<K extends EditorEventName>(event: K, handler: EditorEvents[K]) {
return events.on(event, handler)
}
function requestRender() { function requestRender() {
state.renderVersion++ state.renderVersion++
state.sceneVersion++ state.sceneVersion++
emitEditorEvent('render:requested', {
renderVersion: state.renderVersion,
sceneVersion: state.sceneVersion
})
} }
function requestRepaint() { function requestRepaint() {
state.renderVersion++ state.renderVersion++
emitEditorEvent('repaint:requested', {
renderVersion: state.renderVersion,
sceneVersion: state.sceneVersion
})
}
function setSelectedIds(ids: Set<string>) {
const previous = [...state.selectedIds]
state.selectedIds = ids
const selected = [...ids]
if (
previous.length !== selected.length ||
previous.some((id, index) => id !== selected[index])
) {
emitEditorEvent('selection:changed', selected, previous)
}
}
function setActiveTool(tool: EditorState['activeTool']) {
const previous = state.activeTool
state.activeTool = tool
if (previous !== tool) emitEditorEvent('tool:changed', tool, previous)
} }
const graphReads = createGraphReadActions(() => _graph) const graphReads = createGraphReadActions(() => _graph)
@ -72,7 +118,8 @@ export function createEditor(options?: EditorOptions) {
getGraph: () => _graph, getGraph: () => _graph,
getRenderers: () => _renderers, getRenderers: () => _renderers,
scheduleComponentSync, scheduleComponentSync,
requestRender requestRender,
emitEditorEvent
}) })
if (!skipInitialGraphSetup) { if (!skipInitialGraphSetup) {
@ -96,6 +143,9 @@ export function createEditor(options?: EditorOptions) {
getTextEditor: () => _textEditor, getTextEditor: () => _textEditor,
requestRender, requestRender,
requestRepaint, requestRepaint,
emitEditorEvent,
setSelectedIds,
setActiveTool,
runLayoutForNode, runLayoutForNode,
subscribeToGraph subscribeToGraph
} }
@ -137,10 +187,15 @@ export function createEditor(options?: EditorOptions) {
function replaceGraph(newGraph: SceneGraph) { function replaceGraph(newGraph: SceneGraph) {
_graph = newGraph _graph = newGraph
subscribeToGraph() subscribeToGraph()
const previousPageId = state.currentPageId
state.currentPageId = _graph.getPages()[0]?.id ?? _graph.rootId state.currentPageId = _graph.getPages()[0]?.id ?? _graph.rootId
state.selectedIds = new Set() setSelectedIds(new Set())
state.hoveredNodeId = null state.hoveredNodeId = null
pages.clearPageViewports() pages.clearPageViewports()
emitEditorEvent('graph:replaced', _graph)
if (previousPageId !== state.currentPageId) {
emitEditorEvent('page:changed', state.currentPageId, previousPageId)
}
requestRender() requestRender()
} }
@ -163,6 +218,7 @@ export function createEditor(options?: EditorOptions) {
// Lifecycle // Lifecycle
requestRender, requestRender,
requestRepaint, requestRepaint,
onEditorEvent,
setCanvasKit, setCanvasKit,
removeCanvasRenderer, removeCanvasRenderer,
replaceGraph, replaceGraph,

View file

@ -1,11 +1,15 @@
import type { SkiaRenderer } from '#core/canvas/renderer' import type { SkiaRenderer } from '#core/canvas/renderer'
import type { SceneGraph, SceneNode } from '#core/scene-graph' import type { SceneGraph, SceneGraphEvents, SceneNode } from '#core/scene-graph'
type GraphEventOptions = { type GraphEventOptions = {
getGraph: () => SceneGraph getGraph: () => SceneGraph
getRenderers: () => Iterable<SkiaRenderer> getRenderers: () => Iterable<SkiaRenderer>
scheduleComponentSync: (nodeId: string) => void scheduleComponentSync: (nodeId: string) => void
requestRender: () => void requestRender: () => void
emitEditorEvent: <K extends keyof SceneGraphEvents>(
event: K,
...args: Parameters<SceneGraphEvents[K]>
) => void
} }
export function createGraphEventSubscription(options: GraphEventOptions) { export function createGraphEventSubscription(options: GraphEventOptions) {
@ -16,6 +20,7 @@ export function createGraphEventSubscription(options: GraphEventOptions) {
if ('vectorNetwork' in changes) renderer.invalidateVectorPath(id) if ('vectorNetwork' in changes) renderer.invalidateVectorPath(id)
renderer.invalidateNodePicture(id) renderer.invalidateNodePicture(id)
} }
options.emitEditorEvent('node:updated', id, changes)
options.scheduleComponentSync(id) options.scheduleComponentSync(id)
options.requestRender() options.requestRender()
} }
@ -29,10 +34,22 @@ export function createGraphEventSubscription(options: GraphEventOptions) {
unbindGraphEvents?.() unbindGraphEvents?.()
unbindGraphEvents = options.getGraph().onNodeEvents({ unbindGraphEvents = options.getGraph().onNodeEvents({
updated: onNodeUpdated, updated: onNodeUpdated,
created: (node) => onNodeStructureChanged(node.id), created: (node) => {
deleted: onNodeStructureChanged, options.emitEditorEvent('node:created', node)
reparented: onNodeStructureChanged, onNodeStructureChanged(node.id)
reordered: onNodeStructureChanged },
deleted: (id) => {
options.emitEditorEvent('node:deleted', id)
onNodeStructureChanged(id)
},
reparented: (nodeId, oldParentId, newParentId) => {
options.emitEditorEvent('node:reparented', nodeId, oldParentId, newParentId)
onNodeStructureChanged(nodeId)
},
reordered: (nodeId, parentId, index) => {
options.emitEditorEvent('node:reordered', nodeId, parentId, index)
onNodeStructureChanged(nodeId)
}
}) })
} }

View file

@ -27,7 +27,7 @@ export function restorePageFromSnapshot(ctx: EditorContext, snapshot: PageSnapsh
ctx.graph.clearAbsPosCache() ctx.graph.clearAbsPosCache()
computeAllLayouts(ctx.graph, pageId) computeAllLayouts(ctx.graph, pageId)
ctx.state.selectedIds = new Set() ctx.setSelectedIds(new Set())
ctx.state.hoveredNodeId = null ctx.state.hoveredNodeId = null
ctx.requestRender() ctx.requestRender()
} }

View file

@ -3,4 +3,11 @@ export type { Editor } from './create'
export { createTextActions } from './text' export { createTextActions } from './text'
export { EDITOR_TOOLS, TOOL_SHORTCUTS } from './tool-registry' export { EDITOR_TOOLS, TOOL_SHORTCUTS } from './tool-registry'
export type { EditorToolDef } from './tool-registry' export type { EditorToolDef } from './tool-registry'
export type { EditorContext, EditorOptions, EditorState, Tool } from './types' export type {
EditorContext,
EditorEventName,
EditorEvents,
EditorOptions,
EditorState,
Tool
} from './types'

View file

@ -14,9 +14,11 @@ export function createPageActions(ctx: EditorContext) {
pageViewportStore.saveCurrentPageViewport() pageViewportStore.saveCurrentPageViewport()
const previousPageId = ctx.state.currentPageId
ctx.state.currentPageId = pageId ctx.state.currentPageId = pageId
ctx.state.enteredContainerId = null ctx.state.enteredContainerId = null
ctx.state.selectedIds = new Set() ctx.setSelectedIds(new Set())
if (previousPageId !== pageId) ctx.emitEditorEvent('page:changed', pageId, previousPageId)
pageViewportStore.restorePageViewport(pageId) pageViewportStore.restorePageViewport(pageId)

View file

@ -12,19 +12,19 @@ export function createSelectionActions(ctx: EditorContext) {
if (next.has(id)) next.delete(id) if (next.has(id)) next.delete(id)
else next.add(id) else next.add(id)
} }
ctx.state.selectedIds = next ctx.setSelectedIds(next)
} else { } else {
ctx.state.selectedIds = new Set(ids) ctx.setSelectedIds(new Set(ids))
} }
} }
function clearSelection() { function clearSelection() {
ctx.state.selectedIds = new Set() ctx.setSelectedIds(new Set())
} }
function selectAll() { function selectAll() {
const children = ctx.graph.getChildren(ctx.state.currentPageId) const children = ctx.graph.getChildren(ctx.state.currentPageId)
ctx.state.selectedIds = new Set(children.map((n) => n.id)) ctx.setSelectedIds(new Set(children.map((n) => n.id)))
} }
const containerActions = createSelectionContainerActions(ctx) const containerActions = createSelectionContainerActions(ctx)

View file

@ -21,7 +21,7 @@ export function createSelectionContainerActions(ctx: EditorContext) {
} else { } else {
ctx.state.enteredContainerId = null ctx.state.enteredContainerId = null
} }
ctx.state.selectedIds = new Set(entered ? [entered] : []) ctx.setSelectedIds(new Set(entered ? [entered] : []))
} }
return { validateEnteredContainer, enterContainer, exitContainer } return { validateEnteredContainer, enterContainer, exitContainer }

View file

@ -70,7 +70,7 @@ export function createShapeActions(ctx: EditorContext) {
ctx.graph.deleteNode(id) ctx.graph.deleteNode(id)
const next = new Set(ctx.state.selectedIds) const next = new Set(ctx.state.selectedIds)
next.delete(id) next.delete(id)
ctx.state.selectedIds = next ctx.setSelectedIds(next)
} }
}) })
return id return id
@ -79,7 +79,7 @@ export function createShapeActions(ctx: EditorContext) {
const penActions = createPenActions(ctx, createShape) const penActions = createPenActions(ctx, createShape)
function setTool(tool: typeof ctx.state.activeTool) { function setTool(tool: typeof ctx.state.activeTool) {
ctx.state.activeTool = tool ctx.setActiveTool(tool)
} }
return { return {

View file

@ -205,12 +205,12 @@ export function createPenActions(ctx: EditorContext, createShape: CreateShape) {
fills, fills,
strokes strokes
}) })
ctx.state.selectedIds = new Set([nodeId]) ctx.setSelectedIds(new Set([nodeId]))
ctx.state.penState = null ctx.state.penState = null
ctx.state.penCursorX = null ctx.state.penCursorX = null
ctx.state.penCursorY = null ctx.state.penCursorY = null
ctx.state.activeTool = 'SELECT' ctx.setActiveTool('SELECT')
ctx.requestRender() ctx.requestRender()
} }
@ -218,7 +218,7 @@ export function createPenActions(ctx: EditorContext, createShape: CreateShape) {
ctx.state.penState = null ctx.state.penState = null
ctx.state.penCursorX = null ctx.state.penCursorX = null
ctx.state.penCursorY = null ctx.state.penCursorY = null
ctx.state.activeTool = 'SELECT' ctx.setActiveTool('SELECT')
ctx.requestRender() ctx.requestRender()
} }

View file

@ -60,7 +60,7 @@ export function createStructureActions(ctx: EditorContext) {
for (const id of ids) { for (const id of ids) {
ctx.graph.reparentNode(id, pageId) ctx.graph.reparentNode(id, pageId)
} }
ctx.state.selectedIds = new Set() ctx.setSelectedIds(new Set())
} }
function renameNode(id: string, name: string) { function renameNode(id: string, name: string) {

View file

@ -52,7 +52,7 @@ export function wrapInAutoLayout(
computeLayout(ctx.graph, frameId) computeLayout(ctx.graph, frameId)
ctx.runLayoutForNode(frameId) ctx.runLayoutForNode(frameId)
ctx.state.selectedIds = new Set([frameId]) ctx.setSelectedIds(new Set([frameId]))
ctx.undo.push({ ctx.undo.push({
label: 'Wrap in auto layout', label: 'Wrap in auto layout',
@ -61,7 +61,7 @@ export function wrapInAutoLayout(
for (const n of origPositions) ctx.graph.reparentNode(n.id, f.id) for (const n of origPositions) ctx.graph.reparentNode(n.id, f.id)
computeLayout(ctx.graph, f.id) computeLayout(ctx.graph, f.id)
ctx.runLayoutForNode(f.id) ctx.runLayoutForNode(f.id)
ctx.state.selectedIds = new Set([f.id]) ctx.setSelectedIds(new Set([f.id]))
}, },
inverse: () => { inverse: () => {
for (const orig of origPositions) { for (const orig of origPositions) {
@ -69,7 +69,7 @@ export function wrapInAutoLayout(
ctx.graph.updateNode(orig.id, { x: orig.x, y: orig.y }) ctx.graph.updateNode(orig.id, { x: orig.x, y: orig.y })
} }
ctx.graph.deleteNode(frameId) ctx.graph.deleteNode(frameId)
ctx.state.selectedIds = prevSelection ctx.setSelectedIds(prevSelection)
} }
}) })
} }

View file

@ -70,7 +70,7 @@ export function wrapSelectionInContainer(
ctx.graph.reparentNode(n.id, containerId) ctx.graph.reparentNode(n.id, containerId)
} }
ctx.state.selectedIds = new Set([containerId]) ctx.setSelectedIds(new Set([containerId]))
ctx.undo.push({ ctx.undo.push({
label: `Create ${containerType.toLowerCase().replace('_', ' ')}`, label: `Create ${containerType.toLowerCase().replace('_', ' ')}`,
@ -82,7 +82,7 @@ export function wrapSelectionInContainer(
}) })
ctx.graph.insertChildAt(c.id, parentId, firstIndex) ctx.graph.insertChildAt(c.id, parentId, firstIndex)
for (const n of origPositions) ctx.graph.reparentNode(n.id, c.id) for (const n of origPositions) ctx.graph.reparentNode(n.id, c.id)
ctx.state.selectedIds = new Set([c.id]) ctx.setSelectedIds(new Set([c.id]))
}, },
inverse: () => { inverse: () => {
for (const orig of origPositions) { for (const orig of origPositions) {
@ -90,7 +90,7 @@ export function wrapSelectionInContainer(
ctx.graph.updateNode(orig.id, { x: orig.x, y: orig.y }) ctx.graph.updateNode(orig.id, { x: orig.x, y: orig.y })
} }
ctx.graph.deleteNode(containerId) ctx.graph.deleteNode(containerId)
ctx.state.selectedIds = prevSelection ctx.setSelectedIds(prevSelection)
} }
}) })

View file

@ -26,7 +26,7 @@ export function ungroupSelected(ctx: EditorContext, selectedNode: SceneNode | un
} }
ctx.graph.deleteNode(node.id) ctx.graph.deleteNode(node.id)
ctx.state.selectedIds = new Set(childIds) ctx.setSelectedIds(new Set(childIds))
ctx.undo.push({ ctx.undo.push({
label: 'Ungroup', label: 'Ungroup',
@ -36,7 +36,7 @@ export function ungroupSelected(ctx: EditorContext, selectedNode: SceneNode | un
ctx.graph.insertChildAt(childIds[i], parentId, groupIndex + i) ctx.graph.insertChildAt(childIds[i], parentId, groupIndex + i)
} }
ctx.graph.deleteNode(groupId) ctx.graph.deleteNode(groupId)
ctx.state.selectedIds = new Set(childIds) ctx.setSelectedIds(new Set(childIds))
}, },
inverse: () => { inverse: () => {
const g = ctx.graph.createNode('GROUP', parentId, { const g = ctx.graph.createNode('GROUP', parentId, {
@ -49,7 +49,7 @@ export function ungroupSelected(ctx: EditorContext, selectedNode: SceneNode | un
ctx.graph.reparentNode(orig.id, g.id) ctx.graph.reparentNode(orig.id, g.id)
ctx.graph.updateNode(orig.id, { x: orig.x, y: orig.y }) ctx.graph.updateNode(orig.id, { x: orig.x, y: orig.y })
} }
ctx.state.selectedIds = prevSelection ctx.setSelectedIds(prevSelection)
} }
}) })
} }

View file

@ -1,7 +1,7 @@
import type { CanvasKit } from 'canvaskit-wasm' import type { CanvasKit } from 'canvaskit-wasm'
import type { RulerTheme, SkiaRenderer } from '#core/canvas/renderer' import type { RulerTheme, SkiaRenderer } from '#core/canvas/renderer'
import type { SceneGraph, VectorSegment, VectorVertex } from '#core/scene-graph' import type { SceneGraph, SceneNode, VectorSegment, VectorVertex } from '#core/scene-graph'
import type { SnapGuide } from '#core/scene-graph/snap' import type { SnapGuide } from '#core/scene-graph/snap'
import type { UndoManager } from '#core/scene-graph/undo' import type { UndoManager } from '#core/scene-graph/undo'
import type { TextEditor } from '#core/text/editor' import type { TextEditor } from '#core/text/editor'
@ -67,6 +67,26 @@ export interface EditorState {
enteredContainerId: string | null enteredContainerId: string | null
} }
export interface EditorEvents {
'render:requested': (versions: { renderVersion: number; sceneVersion: number }) => void
'repaint:requested': (versions: { renderVersion: number; sceneVersion: number }) => void
'graph:replaced': (graph: SceneGraph) => void
'node:created': (node: SceneNode) => void
'node:updated': (id: string, changes: Partial<SceneNode>) => void
'node:deleted': (id: string) => void
'node:reparented': (nodeId: string, oldParentId: string | null, newParentId: string) => void
'node:reordered': (nodeId: string, parentId: string, index: number) => void
'selection:changed': (selectedIds: string[], previousIds: string[]) => void
'tool:changed': (tool: Tool, previousTool: Tool) => void
'page:changed': (pageId: string, previousPageId: string) => void
'viewport:changed': (
viewport: { panX: number; panY: number; zoom: number },
previous: { panX: number; panY: number; zoom: number }
) => void
}
export type EditorEventName = keyof EditorEvents
export interface EditorOptions { export interface EditorOptions {
graph?: SceneGraph graph?: SceneGraph
state?: EditorState state?: EditorState
@ -87,6 +107,12 @@ export interface EditorContext {
getTextEditor: () => TextEditor | null getTextEditor: () => TextEditor | null
requestRender: () => void requestRender: () => void
requestRepaint: () => void requestRepaint: () => void
emitEditorEvent: <K extends EditorEventName>(
event: K,
...args: Parameters<EditorEvents[K]>
) => void
setSelectedIds: (ids: Set<string>) => void
setActiveTool: (tool: Tool) => void
runLayoutForNode: (id: string) => void runLayoutForNode: (id: string) => void
subscribeToGraph: () => void subscribeToGraph: () => void
} }

View file

@ -66,11 +66,11 @@ export function createUndoActions(ctx: EditorContext) {
) )
ctx.runLayoutForNode(id) ctx.runLayoutForNode(id)
} }
ctx.state.selectedIds = new Set(nextSelection) ctx.setSelectedIds(new Set(nextSelection))
}, },
inverse: () => { inverse: () => {
for (const id of rootIds.toReversed()) ctx.graph.deleteNode(id) for (const id of rootIds.toReversed()) ctx.graph.deleteNode(id)
ctx.state.selectedIds = new Set(previousSelection) ctx.setSelectedIds(new Set(previousSelection))
} }
}) })
} }

View file

@ -4,6 +4,17 @@ import { computeBounds, computeAbsoluteBounds } from '#core/geometry'
import type { EditorContext } from './types' import type { EditorContext } from './types'
export function createViewportActions(ctx: EditorContext) { export function createViewportActions(ctx: EditorContext) {
function currentViewport() {
return { panX: ctx.state.panX, panY: ctx.state.panY, zoom: ctx.state.zoom }
}
function emitViewportChanged(previous: ReturnType<typeof currentViewport>) {
const next = currentViewport()
if (next.panX !== previous.panX || next.panY !== previous.panY || next.zoom !== previous.zoom) {
ctx.emitEditorEvent('viewport:changed', next, previous)
}
}
function screenToCanvas(sx: number, sy: number) { function screenToCanvas(sx: number, sy: number) {
return { return {
x: (sx - ctx.state.panX) / ctx.state.zoom, x: (sx - ctx.state.panX) / ctx.state.zoom,
@ -12,11 +23,13 @@ export function createViewportActions(ctx: EditorContext) {
} }
function setZoomAroundPoint(level: number, centerX: number, centerY: number) { function setZoomAroundPoint(level: number, centerX: number, centerY: number) {
const previous = currentViewport()
const newZoom = Math.max(0.02, Math.min(256, level)) const newZoom = Math.max(0.02, Math.min(256, level))
ctx.state.panX = centerX - (centerX - ctx.state.panX) * (newZoom / ctx.state.zoom) ctx.state.panX = centerX - (centerX - ctx.state.panX) * (newZoom / ctx.state.zoom)
ctx.state.panY = centerY - (centerY - ctx.state.panY) * (newZoom / ctx.state.zoom) ctx.state.panY = centerY - (centerY - ctx.state.panY) * (newZoom / ctx.state.zoom)
ctx.state.zoom = newZoom ctx.state.zoom = newZoom
ctx.requestRepaint() ctx.requestRepaint()
emitViewportChanged(previous)
} }
function applyZoom(delta: number, centerX: number, centerY: number) { function applyZoom(delta: number, centerX: number, centerY: number) {
@ -28,12 +41,15 @@ export function createViewportActions(ctx: EditorContext) {
} }
function pan(dx: number, dy: number) { function pan(dx: number, dy: number) {
const previous = currentViewport()
ctx.state.panX += dx ctx.state.panX += dx
ctx.state.panY += dy ctx.state.panY += dy
ctx.requestRepaint() ctx.requestRepaint()
emitViewportChanged(previous)
} }
function zoomToBounds(minX: number, minY: number, maxX: number, maxY: number) { function zoomToBounds(minX: number, minY: number, maxX: number, maxY: number) {
const previous = currentViewport()
const padding = 80 const padding = 80
const w = maxX - minX + padding * 2 const w = maxX - minX + padding * 2
const h = maxY - minY + padding * 2 const h = maxY - minY + padding * 2
@ -45,6 +61,7 @@ export function createViewportActions(ctx: EditorContext) {
ctx.state.panX = (viewW - w * zoom) / 2 - minX * zoom + padding * zoom ctx.state.panX = (viewW - w * zoom) / 2 - minX * zoom + padding * zoom
ctx.state.panY = (viewH - h * zoom) / 2 - minY * zoom + padding * zoom ctx.state.panY = (viewH - h * zoom) / 2 - minY * zoom + padding * zoom
ctx.requestRepaint() ctx.requestRepaint()
emitViewportChanged(previous)
} }
function zoomToFit() { function zoomToFit() {
@ -60,10 +77,12 @@ export function createViewportActions(ctx: EditorContext) {
const centerX = (-ctx.state.panX + viewW / 2) / ctx.state.zoom const centerX = (-ctx.state.panX + viewW / 2) / ctx.state.zoom
const centerY = (-ctx.state.panY + viewH / 2) / ctx.state.zoom const centerY = (-ctx.state.panY + viewH / 2) / ctx.state.zoom
const previous = currentViewport()
ctx.state.zoom = Math.max(0.02, Math.min(256, level)) ctx.state.zoom = Math.max(0.02, Math.min(256, level))
ctx.state.panX = viewW / 2 - centerX ctx.state.panX = viewW / 2 - centerX
ctx.state.panY = viewH / 2 - centerY ctx.state.panY = viewH / 2 - centerY
ctx.requestRepaint() ctx.requestRepaint()
emitViewportChanged(previous)
} }
function zoomTo100() { function zoomTo100() {

View file

@ -0,0 +1,12 @@
import { onScopeDispose } from 'vue'
import type { EditorEventName, EditorEvents } from '@open-pencil/core/editor'
import { useEditor } from '#vue/editor/context'
export function useEditorEvent<K extends EditorEventName>(event: K, handler: EditorEvents[K]) {
const editor = useEditor()
const stop = editor.onEditorEvent(event, handler)
onScopeDispose(stop)
return stop
}

View file

@ -2,6 +2,8 @@ export type {
Editor, Editor,
EditorState, EditorState,
EditorOptions, EditorOptions,
EditorEvents,
EditorEventName,
Tool, Tool,
EditorToolDef EditorToolDef
} from '@open-pencil/core/editor' } from '@open-pencil/core/editor'
@ -27,6 +29,7 @@ export { useNodeProps, MIXED } from '#vue/controls/node-props/use'
export type { MixedValue } from '#vue/controls/node-props/use' export type { MixedValue } from '#vue/controls/node-props/use'
export { useSceneComputed } from '#vue/internal/scene-computed/use' export { useSceneComputed } from '#vue/internal/scene-computed/use'
export { useSelectionState } from '#vue/editor/selection-state/use' export { useSelectionState } from '#vue/editor/selection-state/use'
export { useEditorEvent } from '#vue/editor/events/use'
export { useSelectionCapabilities } from '#vue/editor/selection-capabilities/use' export { useSelectionCapabilities } from '#vue/editor/selection-capabilities/use'
/** Command and menu composition helpers. */ /** Command and menu composition helpers. */

View file

@ -4,7 +4,7 @@ import type { SceneGraph, SceneNode } from '@open-pencil/core/scene-graph'
export async function applyImportedDocument(editor: Editor, imported: SceneGraph) { export async function applyImportedDocument(editor: Editor, imported: SceneGraph) {
editor.replaceGraph(imported) editor.replaceGraph(imported)
editor.undo.clear() editor.undo.clear()
editor.state.selectedIds = new Set() editor.clearSelection()
const firstPage = editor.graph.getPages()[0] as SceneNode | undefined const firstPage = editor.graph.getPages()[0] as SceneNode | undefined
const pageId = firstPage?.id ?? editor.graph.rootId const pageId = firstPage?.id ?? editor.graph.rootId
await editor.switchPage(pageId) await editor.switchPage(pageId)

View file

@ -23,7 +23,7 @@ export function restoreReloadState(
state: EditorState, state: EditorState,
snapshot: ReloadStateSnapshot snapshot: ReloadStateSnapshot
) { ) {
state.selectedIds = new Set() editor.clearSelection()
if (editor.graph.getNode(snapshot.pageId)) { if (editor.graph.getNode(snapshot.pageId)) {
state.currentPageId = snapshot.pageId state.currentPageId = snapshot.pageId
} else { } else {

View file

@ -15,7 +15,7 @@ export function createPenActions(editor: Editor, graph: SceneGraph, state: PenSt
if (state.penState && tool !== 'PEN' && tool !== 'HAND') { if (state.penState && tool !== 'PEN' && tool !== 'HAND') {
editor.penCommit(false) editor.penCommit(false)
} }
state.activeTool = tool editor.setTool(tool)
} }
function penResumeOnPath(nodeId: string) { function penResumeOnPath(nodeId: string) {
@ -29,8 +29,8 @@ export function createPenActions(editor: Editor, graph: SceneGraph, state: PenSt
) )
graph.deleteNode(nodeId) graph.deleteNode(nodeId)
state.selectedIds = new Set() editor.clearSelection()
state.activeTool = 'PEN' editor.setTool('PEN')
editor.requestRender() editor.requestRender()
} }
@ -49,8 +49,8 @@ export function createPenActions(editor: Editor, graph: SceneGraph, state: PenSt
state.penState = createResumedPenState(node, orderedVertices, orderedSegments) state.penState = createResumedPenState(node, orderedVertices, orderedSegments)
graph.deleteNode(nodeId) graph.deleteNode(nodeId)
state.selectedIds = new Set() editor.clearSelection()
state.activeTool = 'PEN' editor.setTool('PEN')
editor.requestRender() editor.requestRender()
} }

View file

@ -71,7 +71,7 @@ export function createVectorEditLifecycle(
hoveredHandleInfo: null hoveredHandleInfo: null
} }
state.selectedIds = new Set([nodeId]) editor.select([nodeId])
editor.requestRender() editor.requestRender()
} }

View file

@ -116,7 +116,7 @@ export async function openFileInNewTab(
store.replaceGraph(imported) store.replaceGraph(imported)
store.undo.clear() store.undo.clear()
store.setDocumentSource(file.name, sourceFormat, handle, path) store.setDocumentSource(file.name, sourceFormat, handle, path)
store.state.selectedIds = new Set() store.clearSelection()
const pageId = store.graph.getPages()[0]?.id ?? store.graph.rootId const pageId = store.graph.getPages()[0]?.id ?? store.graph.rootId
await store.switchPage(pageId) await store.switchPage(pageId)
await store.fitCurrentPageToViewport() await store.fitCurrentPageToViewport()