diff --git a/packages/core/src/editor/clipboard.ts b/packages/core/src/editor/clipboard.ts index 626596b6d..918302e83 100644 --- a/packages/core/src/editor/clipboard.ts +++ b/packages/core/src/editor/clipboard.ts @@ -38,7 +38,7 @@ export function createClipboardActions(ctx: EditorContext) { } if (newRootIds.length > 0) { - ctx.state.selectedIds = new Set(newRootIds) + ctx.setSelectedIds(new Set(newRootIds)) ctx.undo.push({ label: 'Duplicate', forward: () => { @@ -48,11 +48,11 @@ export function createClipboardActions(ctx: EditorContext) { const parentId = snapshot.parentId ?? ctx.state.currentPageId restoreSubtree(ctx.graph, snapshot, parentId, allSnapshots) } - ctx.state.selectedIds = new Set(newRootIds) + ctx.setSelectedIds(new Set(newRootIds)) }, inverse: () => { 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 placementActions.centerNodesAt(created, cx, cy) computeAllLayouts(ctx.graph, ctx.state.currentPageId) - ctx.state.selectedIds = new Set(created) + ctx.setSelectedIds(new Set(created)) const allNodes = collectSubtrees(ctx.graph, created) const pageId = ctx.state.currentPageId @@ -96,12 +96,12 @@ export function createClipboardActions(ctx: EditorContext) { }) } computeAllLayouts(ctx.graph, pageId) - ctx.state.selectedIds = new Set(created) + ctx.setSelectedIds(new Set(created)) }, inverse: () => { for (const id of [...created].reverse()) ctx.graph.deleteNode(id) computeAllLayouts(ctx.graph, pageId) - ctx.state.selectedIds = prevSelection + ctx.setSelectedIds(prevSelection) } }) void fontActions.loadFontsForNodes(created) @@ -137,7 +137,7 @@ export function createClipboardActions(ctx: EditorContext) { if (cursorPos) placementActions.centerNodesAt(created, cursorPos.x, cursorPos.y) computeAllLayouts(ctx.graph, ctx.state.currentPageId) - ctx.state.selectedIds = new Set(created) + ctx.setSelectedIds(new Set(created)) const allNodes = collectSubtrees(ctx.graph, created) const pageId = ctx.state.currentPageId @@ -151,12 +151,12 @@ export function createClipboardActions(ctx: EditorContext) { }) } computeAllLayouts(ctx.graph, pageId) - ctx.state.selectedIds = new Set(created) + ctx.setSelectedIds(new Set(created)) }, inverse: () => { for (const id of [...created].reverse()) ctx.graph.deleteNode(id) computeAllLayouts(ctx.graph, pageId) - ctx.state.selectedIds = prevSelection + ctx.setSelectedIds(prevSelection) } }) } @@ -192,7 +192,7 @@ export function createClipboardActions(ctx: EditorContext) { label: 'Delete', forward: () => { for (const { id } of entries) ctx.graph.deleteNode(id) - ctx.state.selectedIds = new Set() + ctx.setSelectedIds(new Set()) }, inverse: () => { 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 (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) diff --git a/packages/core/src/editor/clipboard/images.ts b/packages/core/src/editor/clipboard/images.ts index 2e8ca915e..3df081999 100644 --- a/packages/core/src/editor/clipboard/images.ts +++ b/packages/core/src/editor/clipboard/images.ts @@ -68,7 +68,7 @@ export function createClipboardImageActions(ctx: EditorContext) { ctx.graph.images.delete(hash) const next = new Set(ctx.state.selectedIds) next.delete(id) - ctx.state.selectedIds = next + ctx.setSelectedIds(next) } }) return id @@ -97,7 +97,7 @@ export function createClipboardImageActions(ctx: EditorContext) { curX += p.w + IMAGE_GAP } if (ids.length) { - ctx.state.selectedIds = new Set(ids) + ctx.setSelectedIds(new Set(ids)) ctx.requestRender() } } diff --git a/packages/core/src/editor/components.ts b/packages/core/src/editor/components.ts index e038149ae..3af0eb73b 100644 --- a/packages/core/src/editor/components.ts +++ b/packages/core/src/editor/components.ts @@ -25,16 +25,16 @@ export function createComponentActions(ctx: EditorContext) { if (node.type === 'FRAME' || node.type === 'GROUP') { ctx.graph.updateNode(node.id, { type: 'COMPONENT' }) - ctx.state.selectedIds = new Set([node.id]) + ctx.setSelectedIds(new Set([node.id])) ctx.undo.push({ label: 'Create component', forward: () => { ctx.graph.updateNode(node.id, { type: 'COMPONENT' }) - ctx.state.selectedIds = new Set([node.id]) + ctx.setSelectedIds(new Set([node.id])) }, inverse: () => { ctx.graph.updateNode(node.id, { type: prevType }) - ctx.state.selectedIds = prevSelection + ctx.setSelectedIds(prevSelection) } }) return diff --git a/packages/core/src/editor/components/focus.ts b/packages/core/src/editor/components/focus.ts index 8629f5e18..977b1f957 100644 --- a/packages/core/src/editor/components/focus.ts +++ b/packages/core/src/editor/components/focus.ts @@ -18,7 +18,7 @@ export function createComponentFocusActions(ctx: EditorContext) { 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 { width: viewW, height: viewH } = ctx.getViewportSize() diff --git a/packages/core/src/editor/components/instances.ts b/packages/core/src/editor/components/instances.ts index 332ed0801..a38b5e28b 100644 --- a/packages/core/src/editor/components/instances.ts +++ b/packages/core/src/editor/components/instances.ts @@ -14,17 +14,17 @@ export function createComponentInstanceActions(ctx: EditorContext) { if (!instance) return null const instanceId = instance.id - ctx.state.selectedIds = new Set([instanceId]) + ctx.setSelectedIds(new Set([instanceId])) ctx.undo.push({ label: 'Create instance', forward: () => { ctx.graph.createInstance(componentId, parentId, { ...instance }) - ctx.state.selectedIds = new Set([instanceId]) + ctx.setSelectedIds(new Set([instanceId])) }, inverse: () => { ctx.graph.deleteNode(instanceId) - ctx.state.selectedIds = new Set([componentId]) + ctx.setSelectedIds(new Set([componentId])) } }) return instanceId @@ -36,7 +36,7 @@ export function createComponentInstanceActions(ctx: EditorContext) { const prevComponentId = selectedNode.componentId ctx.graph.detachInstance(selectedNode.id) - ctx.state.selectedIds = new Set([selectedNode.id]) + ctx.setSelectedIds(new Set([selectedNode.id])) ctx.undo.push({ label: 'Detach instance', diff --git a/packages/core/src/editor/create.ts b/packages/core/src/editor/create.ts index 256ed9090..22fd8a28e 100644 --- a/packages/core/src/editor/create.ts +++ b/packages/core/src/editor/create.ts @@ -1,4 +1,6 @@ import type { CanvasKit } from 'canvaskit-wasm' +import { createNanoEvents } from 'nanoevents' +import type { Emitter } from 'nanoevents' import type { SkiaRenderer } from '#core/canvas/renderer' import { prefetchFigmaSchema } from '#core/clipboard' @@ -28,7 +30,13 @@ import { createShapeActions } from './shapes' import { createDefaultEditorState } from './state' import { createStructureActions } from './structure' 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 { createVariableActions } from './variables' import { createViewportActions } from './viewport' @@ -50,18 +58,56 @@ export function createEditor(options?: EditorOptions) { let _renderer: SkiaRenderer | null = null const _renderers = new Set() let _textEditor: TextEditor | null = null + const events: Emitter = createNanoEvents() void prefetchFigmaSchema() const state: EditorState = options?.state ?? createDefaultEditorState(_graph.getPages()[0].id) + function emitEditorEvent( + event: K, + ...args: Parameters + ) { + events.emit(event, ...args) + } + + function onEditorEvent(event: K, handler: EditorEvents[K]) { + return events.on(event, handler) + } + function requestRender() { state.renderVersion++ state.sceneVersion++ + emitEditorEvent('render:requested', { + renderVersion: state.renderVersion, + sceneVersion: state.sceneVersion + }) } function requestRepaint() { state.renderVersion++ + emitEditorEvent('repaint:requested', { + renderVersion: state.renderVersion, + sceneVersion: state.sceneVersion + }) + } + + function setSelectedIds(ids: Set) { + 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) @@ -72,7 +118,8 @@ export function createEditor(options?: EditorOptions) { getGraph: () => _graph, getRenderers: () => _renderers, scheduleComponentSync, - requestRender + requestRender, + emitEditorEvent }) if (!skipInitialGraphSetup) { @@ -96,6 +143,9 @@ export function createEditor(options?: EditorOptions) { getTextEditor: () => _textEditor, requestRender, requestRepaint, + emitEditorEvent, + setSelectedIds, + setActiveTool, runLayoutForNode, subscribeToGraph } @@ -137,10 +187,15 @@ export function createEditor(options?: EditorOptions) { function replaceGraph(newGraph: SceneGraph) { _graph = newGraph subscribeToGraph() + const previousPageId = state.currentPageId state.currentPageId = _graph.getPages()[0]?.id ?? _graph.rootId - state.selectedIds = new Set() + setSelectedIds(new Set()) state.hoveredNodeId = null pages.clearPageViewports() + emitEditorEvent('graph:replaced', _graph) + if (previousPageId !== state.currentPageId) { + emitEditorEvent('page:changed', state.currentPageId, previousPageId) + } requestRender() } @@ -163,6 +218,7 @@ export function createEditor(options?: EditorOptions) { // Lifecycle requestRender, requestRepaint, + onEditorEvent, setCanvasKit, removeCanvasRenderer, replaceGraph, diff --git a/packages/core/src/editor/graph-events.ts b/packages/core/src/editor/graph-events.ts index de01c4c95..21b8bd949 100644 --- a/packages/core/src/editor/graph-events.ts +++ b/packages/core/src/editor/graph-events.ts @@ -1,11 +1,15 @@ 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 = { getGraph: () => SceneGraph getRenderers: () => Iterable scheduleComponentSync: (nodeId: string) => void requestRender: () => void + emitEditorEvent: ( + event: K, + ...args: Parameters + ) => void } export function createGraphEventSubscription(options: GraphEventOptions) { @@ -16,6 +20,7 @@ export function createGraphEventSubscription(options: GraphEventOptions) { if ('vectorNetwork' in changes) renderer.invalidateVectorPath(id) renderer.invalidateNodePicture(id) } + options.emitEditorEvent('node:updated', id, changes) options.scheduleComponentSync(id) options.requestRender() } @@ -29,10 +34,22 @@ export function createGraphEventSubscription(options: GraphEventOptions) { unbindGraphEvents?.() unbindGraphEvents = options.getGraph().onNodeEvents({ updated: onNodeUpdated, - created: (node) => onNodeStructureChanged(node.id), - deleted: onNodeStructureChanged, - reparented: onNodeStructureChanged, - reordered: onNodeStructureChanged + created: (node) => { + options.emitEditorEvent('node:created', node) + onNodeStructureChanged(node.id) + }, + 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) + } }) } diff --git a/packages/core/src/editor/history/snapshot.ts b/packages/core/src/editor/history/snapshot.ts index 053909e01..0cbf940a5 100644 --- a/packages/core/src/editor/history/snapshot.ts +++ b/packages/core/src/editor/history/snapshot.ts @@ -27,7 +27,7 @@ export function restorePageFromSnapshot(ctx: EditorContext, snapshot: PageSnapsh ctx.graph.clearAbsPosCache() computeAllLayouts(ctx.graph, pageId) - ctx.state.selectedIds = new Set() + ctx.setSelectedIds(new Set()) ctx.state.hoveredNodeId = null ctx.requestRender() } diff --git a/packages/core/src/editor/index.ts b/packages/core/src/editor/index.ts index a309beb3b..6f266d20c 100644 --- a/packages/core/src/editor/index.ts +++ b/packages/core/src/editor/index.ts @@ -3,4 +3,11 @@ export type { Editor } from './create' export { createTextActions } from './text' export { EDITOR_TOOLS, TOOL_SHORTCUTS } 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' diff --git a/packages/core/src/editor/pages.ts b/packages/core/src/editor/pages.ts index 035d832d4..d0858ed1f 100644 --- a/packages/core/src/editor/pages.ts +++ b/packages/core/src/editor/pages.ts @@ -14,9 +14,11 @@ export function createPageActions(ctx: EditorContext) { pageViewportStore.saveCurrentPageViewport() + const previousPageId = ctx.state.currentPageId ctx.state.currentPageId = pageId 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) diff --git a/packages/core/src/editor/selection.ts b/packages/core/src/editor/selection.ts index da5339374..7cf356e7f 100644 --- a/packages/core/src/editor/selection.ts +++ b/packages/core/src/editor/selection.ts @@ -12,19 +12,19 @@ export function createSelectionActions(ctx: EditorContext) { if (next.has(id)) next.delete(id) else next.add(id) } - ctx.state.selectedIds = next + ctx.setSelectedIds(next) } else { - ctx.state.selectedIds = new Set(ids) + ctx.setSelectedIds(new Set(ids)) } } function clearSelection() { - ctx.state.selectedIds = new Set() + ctx.setSelectedIds(new Set()) } function selectAll() { 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) diff --git a/packages/core/src/editor/selection/container.ts b/packages/core/src/editor/selection/container.ts index 4537d0bb6..1dceafd64 100644 --- a/packages/core/src/editor/selection/container.ts +++ b/packages/core/src/editor/selection/container.ts @@ -21,7 +21,7 @@ export function createSelectionContainerActions(ctx: EditorContext) { } else { ctx.state.enteredContainerId = null } - ctx.state.selectedIds = new Set(entered ? [entered] : []) + ctx.setSelectedIds(new Set(entered ? [entered] : [])) } return { validateEnteredContainer, enterContainer, exitContainer } diff --git a/packages/core/src/editor/shapes.ts b/packages/core/src/editor/shapes.ts index 493416803..7153b1039 100644 --- a/packages/core/src/editor/shapes.ts +++ b/packages/core/src/editor/shapes.ts @@ -70,7 +70,7 @@ export function createShapeActions(ctx: EditorContext) { ctx.graph.deleteNode(id) const next = new Set(ctx.state.selectedIds) next.delete(id) - ctx.state.selectedIds = next + ctx.setSelectedIds(next) } }) return id @@ -79,7 +79,7 @@ export function createShapeActions(ctx: EditorContext) { const penActions = createPenActions(ctx, createShape) function setTool(tool: typeof ctx.state.activeTool) { - ctx.state.activeTool = tool + ctx.setActiveTool(tool) } return { diff --git a/packages/core/src/editor/shapes/pen.ts b/packages/core/src/editor/shapes/pen.ts index 8a32e8f7b..6e97b006d 100644 --- a/packages/core/src/editor/shapes/pen.ts +++ b/packages/core/src/editor/shapes/pen.ts @@ -205,12 +205,12 @@ export function createPenActions(ctx: EditorContext, createShape: CreateShape) { fills, strokes }) - ctx.state.selectedIds = new Set([nodeId]) + ctx.setSelectedIds(new Set([nodeId])) ctx.state.penState = null ctx.state.penCursorX = null ctx.state.penCursorY = null - ctx.state.activeTool = 'SELECT' + ctx.setActiveTool('SELECT') ctx.requestRender() } @@ -218,7 +218,7 @@ export function createPenActions(ctx: EditorContext, createShape: CreateShape) { ctx.state.penState = null ctx.state.penCursorX = null ctx.state.penCursorY = null - ctx.state.activeTool = 'SELECT' + ctx.setActiveTool('SELECT') ctx.requestRender() } diff --git a/packages/core/src/editor/structure.ts b/packages/core/src/editor/structure.ts index 7010e61ae..ccd74058e 100644 --- a/packages/core/src/editor/structure.ts +++ b/packages/core/src/editor/structure.ts @@ -60,7 +60,7 @@ export function createStructureActions(ctx: EditorContext) { for (const id of ids) { ctx.graph.reparentNode(id, pageId) } - ctx.state.selectedIds = new Set() + ctx.setSelectedIds(new Set()) } function renameNode(id: string, name: string) { diff --git a/packages/core/src/editor/structure/auto-layout-wrap.ts b/packages/core/src/editor/structure/auto-layout-wrap.ts index 4b5bd80b3..7efe4f338 100644 --- a/packages/core/src/editor/structure/auto-layout-wrap.ts +++ b/packages/core/src/editor/structure/auto-layout-wrap.ts @@ -52,7 +52,7 @@ export function wrapInAutoLayout( computeLayout(ctx.graph, frameId) ctx.runLayoutForNode(frameId) - ctx.state.selectedIds = new Set([frameId]) + ctx.setSelectedIds(new Set([frameId])) ctx.undo.push({ label: 'Wrap in auto layout', @@ -61,7 +61,7 @@ export function wrapInAutoLayout( for (const n of origPositions) ctx.graph.reparentNode(n.id, f.id) computeLayout(ctx.graph, f.id) ctx.runLayoutForNode(f.id) - ctx.state.selectedIds = new Set([f.id]) + ctx.setSelectedIds(new Set([f.id])) }, inverse: () => { 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.deleteNode(frameId) - ctx.state.selectedIds = prevSelection + ctx.setSelectedIds(prevSelection) } }) } diff --git a/packages/core/src/editor/structure/container-wrap.ts b/packages/core/src/editor/structure/container-wrap.ts index dc048f587..1391aee25 100644 --- a/packages/core/src/editor/structure/container-wrap.ts +++ b/packages/core/src/editor/structure/container-wrap.ts @@ -70,7 +70,7 @@ export function wrapSelectionInContainer( ctx.graph.reparentNode(n.id, containerId) } - ctx.state.selectedIds = new Set([containerId]) + ctx.setSelectedIds(new Set([containerId])) ctx.undo.push({ label: `Create ${containerType.toLowerCase().replace('_', ' ')}`, @@ -82,7 +82,7 @@ export function wrapSelectionInContainer( }) ctx.graph.insertChildAt(c.id, parentId, firstIndex) 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: () => { 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.deleteNode(containerId) - ctx.state.selectedIds = prevSelection + ctx.setSelectedIds(prevSelection) } }) diff --git a/packages/core/src/editor/structure/group.ts b/packages/core/src/editor/structure/group.ts index b2507f105..3561e4350 100644 --- a/packages/core/src/editor/structure/group.ts +++ b/packages/core/src/editor/structure/group.ts @@ -26,7 +26,7 @@ export function ungroupSelected(ctx: EditorContext, selectedNode: SceneNode | un } ctx.graph.deleteNode(node.id) - ctx.state.selectedIds = new Set(childIds) + ctx.setSelectedIds(new Set(childIds)) ctx.undo.push({ label: 'Ungroup', @@ -36,7 +36,7 @@ export function ungroupSelected(ctx: EditorContext, selectedNode: SceneNode | un ctx.graph.insertChildAt(childIds[i], parentId, groupIndex + i) } ctx.graph.deleteNode(groupId) - ctx.state.selectedIds = new Set(childIds) + ctx.setSelectedIds(new Set(childIds)) }, inverse: () => { 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.updateNode(orig.id, { x: orig.x, y: orig.y }) } - ctx.state.selectedIds = prevSelection + ctx.setSelectedIds(prevSelection) } }) } diff --git a/packages/core/src/editor/types.ts b/packages/core/src/editor/types.ts index 2af89c913..070e95891 100644 --- a/packages/core/src/editor/types.ts +++ b/packages/core/src/editor/types.ts @@ -1,7 +1,7 @@ import type { CanvasKit } from 'canvaskit-wasm' 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 { UndoManager } from '#core/scene-graph/undo' import type { TextEditor } from '#core/text/editor' @@ -67,6 +67,26 @@ export interface EditorState { 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) => 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 { graph?: SceneGraph state?: EditorState @@ -87,6 +107,12 @@ export interface EditorContext { getTextEditor: () => TextEditor | null requestRender: () => void requestRepaint: () => void + emitEditorEvent: ( + event: K, + ...args: Parameters + ) => void + setSelectedIds: (ids: Set) => void + setActiveTool: (tool: Tool) => void runLayoutForNode: (id: string) => void subscribeToGraph: () => void } diff --git a/packages/core/src/editor/undo.ts b/packages/core/src/editor/undo.ts index de795544e..4021dd79a 100644 --- a/packages/core/src/editor/undo.ts +++ b/packages/core/src/editor/undo.ts @@ -66,11 +66,11 @@ export function createUndoActions(ctx: EditorContext) { ) ctx.runLayoutForNode(id) } - ctx.state.selectedIds = new Set(nextSelection) + ctx.setSelectedIds(new Set(nextSelection)) }, inverse: () => { for (const id of rootIds.toReversed()) ctx.graph.deleteNode(id) - ctx.state.selectedIds = new Set(previousSelection) + ctx.setSelectedIds(new Set(previousSelection)) } }) } diff --git a/packages/core/src/editor/viewport.ts b/packages/core/src/editor/viewport.ts index 16d8d6a5a..444de0059 100644 --- a/packages/core/src/editor/viewport.ts +++ b/packages/core/src/editor/viewport.ts @@ -4,6 +4,17 @@ import { computeBounds, computeAbsoluteBounds } from '#core/geometry' import type { EditorContext } from './types' export function createViewportActions(ctx: EditorContext) { + function currentViewport() { + return { panX: ctx.state.panX, panY: ctx.state.panY, zoom: ctx.state.zoom } + } + + function emitViewportChanged(previous: ReturnType) { + 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) { return { 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) { + const previous = currentViewport() const newZoom = Math.max(0.02, Math.min(256, level)) 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.zoom = newZoom ctx.requestRepaint() + emitViewportChanged(previous) } function applyZoom(delta: number, centerX: number, centerY: number) { @@ -28,12 +41,15 @@ export function createViewportActions(ctx: EditorContext) { } function pan(dx: number, dy: number) { + const previous = currentViewport() ctx.state.panX += dx ctx.state.panY += dy ctx.requestRepaint() + emitViewportChanged(previous) } function zoomToBounds(minX: number, minY: number, maxX: number, maxY: number) { + const previous = currentViewport() const padding = 80 const w = maxX - minX + 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.panY = (viewH - h * zoom) / 2 - minY * zoom + padding * zoom ctx.requestRepaint() + emitViewportChanged(previous) } function zoomToFit() { @@ -60,10 +77,12 @@ export function createViewportActions(ctx: EditorContext) { const centerX = (-ctx.state.panX + viewW / 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.panX = viewW / 2 - centerX ctx.state.panY = viewH / 2 - centerY ctx.requestRepaint() + emitViewportChanged(previous) } function zoomTo100() { diff --git a/packages/vue/src/editor/events/use.ts b/packages/vue/src/editor/events/use.ts new file mode 100644 index 000000000..5a8c89490 --- /dev/null +++ b/packages/vue/src/editor/events/use.ts @@ -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(event: K, handler: EditorEvents[K]) { + const editor = useEditor() + const stop = editor.onEditorEvent(event, handler) + onScopeDispose(stop) + return stop +} diff --git a/packages/vue/src/index.ts b/packages/vue/src/index.ts index 224120cc4..f5dc7cd83 100644 --- a/packages/vue/src/index.ts +++ b/packages/vue/src/index.ts @@ -2,6 +2,8 @@ export type { Editor, EditorState, EditorOptions, + EditorEvents, + EditorEventName, Tool, EditorToolDef } 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 { useSceneComputed } from '#vue/internal/scene-computed/use' export { useSelectionState } from '#vue/editor/selection-state/use' +export { useEditorEvent } from '#vue/editor/events/use' export { useSelectionCapabilities } from '#vue/editor/selection-capabilities/use' /** Command and menu composition helpers. */ diff --git a/src/app/document/io/imported-document.ts b/src/app/document/io/imported-document.ts index 42ef5cec7..98a4f0356 100644 --- a/src/app/document/io/imported-document.ts +++ b/src/app/document/io/imported-document.ts @@ -4,7 +4,7 @@ import type { SceneGraph, SceneNode } from '@open-pencil/core/scene-graph' export async function applyImportedDocument(editor: Editor, imported: SceneGraph) { editor.replaceGraph(imported) editor.undo.clear() - editor.state.selectedIds = new Set() + editor.clearSelection() const firstPage = editor.graph.getPages()[0] as SceneNode | undefined const pageId = firstPage?.id ?? editor.graph.rootId await editor.switchPage(pageId) diff --git a/src/app/document/io/reload-state.ts b/src/app/document/io/reload-state.ts index b02b043a4..f759ad41a 100644 --- a/src/app/document/io/reload-state.ts +++ b/src/app/document/io/reload-state.ts @@ -23,7 +23,7 @@ export function restoreReloadState( state: EditorState, snapshot: ReloadStateSnapshot ) { - state.selectedIds = new Set() + editor.clearSelection() if (editor.graph.getNode(snapshot.pageId)) { state.currentPageId = snapshot.pageId } else { diff --git a/src/app/editor/pen/create.ts b/src/app/editor/pen/create.ts index 3eabe6fb1..509b2fcd5 100644 --- a/src/app/editor/pen/create.ts +++ b/src/app/editor/pen/create.ts @@ -15,7 +15,7 @@ export function createPenActions(editor: Editor, graph: SceneGraph, state: PenSt if (state.penState && tool !== 'PEN' && tool !== 'HAND') { editor.penCommit(false) } - state.activeTool = tool + editor.setTool(tool) } function penResumeOnPath(nodeId: string) { @@ -29,8 +29,8 @@ export function createPenActions(editor: Editor, graph: SceneGraph, state: PenSt ) graph.deleteNode(nodeId) - state.selectedIds = new Set() - state.activeTool = 'PEN' + editor.clearSelection() + editor.setTool('PEN') editor.requestRender() } @@ -49,8 +49,8 @@ export function createPenActions(editor: Editor, graph: SceneGraph, state: PenSt state.penState = createResumedPenState(node, orderedVertices, orderedSegments) graph.deleteNode(nodeId) - state.selectedIds = new Set() - state.activeTool = 'PEN' + editor.clearSelection() + editor.setTool('PEN') editor.requestRender() } diff --git a/src/app/editor/vector-edit/lifecycle.ts b/src/app/editor/vector-edit/lifecycle.ts index 549008203..286387ce2 100644 --- a/src/app/editor/vector-edit/lifecycle.ts +++ b/src/app/editor/vector-edit/lifecycle.ts @@ -71,7 +71,7 @@ export function createVectorEditLifecycle( hoveredHandleInfo: null } - state.selectedIds = new Set([nodeId]) + editor.select([nodeId]) editor.requestRender() } diff --git a/src/app/tabs/index.ts b/src/app/tabs/index.ts index 9debbb5ed..42c8cda11 100644 --- a/src/app/tabs/index.ts +++ b/src/app/tabs/index.ts @@ -116,7 +116,7 @@ export async function openFileInNewTab( store.replaceGraph(imported) store.undo.clear() store.setDocumentSource(file.name, sourceFormat, handle, path) - store.state.selectedIds = new Set() + store.clearSelection() const pageId = store.graph.getPages()[0]?.id ?? store.graph.rootId await store.switchPage(pageId) await store.fitCurrentPageToViewport()