openpencil/packages/core/src/editor/create.ts
Danila Poyarkov f892cb583c feat(editor): add ruler guide authoring foundation
- Preserve page and frame guide GUIDs through .fig conversion
- Add undoable guide add, move, transfer, and remove actions
- Start page/frame guide previews from CanvasKit rulers
- Render warm guide previews with frame-scoped solid lines and dotted continuation
2026-08-19 18:08:48 +03:00

302 lines
8.8 KiB
TypeScript

import type { CanvasKit } from 'canvaskit-wasm'
import { createNanoEvents } from 'nanoevents'
import type { Emitter } from 'nanoevents'
import { SceneGraph } from '@open-pencil/scene-graph'
import { UndoManager } from '@open-pencil/scene-graph/undo'
import type { SkiaRenderer } from '#core/canvas/renderer'
import { prefetchFigmaSchema } from '#core/clipboard'
import { IS_BROWSER } from '#core/constants'
import { setTextMeasurer } from '#core/layout'
import { TextEditor } from '#core/text/editor'
import { fontManager } from '#core/text/fonts'
import { fontResolver } from '#core/text/resolver'
import { createAlignmentActions } from './alignment'
import { createClipboardBridge } from './bridges/clipboard'
import { createComponentBridge } from './bridges/components'
import { createStructureBridge } from './bridges/structure'
import { createUndoBridge } from './bridges/undo'
import { createClipboardActions } from './clipboard'
import { createColorSpaceActions } from './color-space'
import { createComponentSyncScheduler } from './component-sync'
import { createComponentActions } from './components'
import { createGraphEventSubscription } from './graph-events'
import { createGraphReadActions } from './graph-reads'
import { createGuideActions } from './guides'
import { createLayoutRunner } from './layout-runner'
import { createNodeActions } from './nodes'
import { createPageActions } from './pages'
import { createSelectionActions } from './selection'
import { createShapeActions } from './shapes'
import { createDefaultEditorState } from './state'
import { createStructureActions } from './structure'
import { createTextActions } from './text'
import type {
EditorContext,
EditorEventName,
EditorEvents,
EditorOptions,
EditorState
} from './types'
import { createUndoActions } from './undo'
import { createVariableActions } from './variables'
import { createVectorizeActions } from './vectorize'
import { createViewportActions } from './viewport'
export { createDefaultEditorState } from './state'
export function createEditor(options?: EditorOptions) {
let _graph = options?.graph ?? new SceneGraph()
const skipInitialGraphSetup = options?.skipInitialGraphSetup ?? false
const undo = new UndoManager()
const _loadFont = options?.loadFont ?? fontManager.loadFont.bind(fontManager)
const _getViewportSize =
options?.getViewportSize ??
(() => {
if (IS_BROWSER) return { width: window.innerWidth, height: window.innerHeight }
return { width: 800, height: 600 }
})
let _ck: CanvasKit | null = null
let _renderer: SkiaRenderer | null = null
const _renderers = new Set<SkiaRenderer>()
let _textEditor: TextEditor | null = null
const events: Emitter<EditorEvents> = createNanoEvents()
const stopFontResolutionEvents = fontResolver.subscribe((event, snapshot) => {
events.emit('font:resolution-changed', event, snapshot)
})
void prefetchFigmaSchema()
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() {
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<string>) {
const previous = [...state.selectedIds]
state.selectedIds = ids
if (ids.size === 0) state.measurementMode = 'off'
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 (tool !== 'SELECT') state.measurementMode = 'off'
if (previous !== tool) emitEditorEvent('tool:changed', tool, previous)
}
const graphReads = createGraphReadActions(() => _graph)
const { runLayoutForNode } = createLayoutRunner(() => _graph)
const { scheduleComponentSync } = createComponentSyncScheduler(() => _graph, requestRender)
const { subscribeToGraph } = createGraphEventSubscription({
getGraph: () => _graph,
getRenderers: () => _renderers,
scheduleComponentSync,
requestRender,
emitEditorEvent
})
if (!skipInitialGraphSetup) {
subscribeToGraph()
}
// Build the shared context
const ctx: EditorContext = {
get graph() {
return _graph
},
set graph(g) {
_graph = g
},
undo,
state,
loadFont: _loadFont,
resolveFigmaClipboardImages: options?.resolveFigmaClipboardImages ?? null,
getViewportSize: _getViewportSize,
getCk: () => _ck,
getRenderer: () => _renderer,
getTextEditor: () => _textEditor,
requestRender,
requestRepaint,
emitEditorEvent,
setSelectedIds,
setActiveTool,
runLayoutForNode,
subscribeToGraph
}
// Assemble domain modules
const viewport = createViewportActions(ctx)
const selection = createSelectionActions(ctx)
const pages = createPageActions(ctx)
const guides = createGuideActions(ctx)
const shapes = createShapeActions(ctx)
const structure = createStructureActions(ctx)
const components = createComponentActions(ctx)
const clipboard = createClipboardActions(ctx)
const colorSpace = createColorSpaceActions(ctx)
const undoActions = createUndoActions(ctx)
const text = createTextActions(ctx)
const nodes = createNodeActions(ctx)
const variables = createVariableActions(ctx)
const vectorize = createVectorizeActions(ctx)
const alignment = createAlignmentActions(ctx)
const clipboardBridge = createClipboardBridge(clipboard, selection)
const componentBridge = createComponentBridge(components, selection, structure, pages)
const structureBridge = createStructureBridge(structure, selection)
const undoBridge = createUndoBridge(undoActions, selection)
function setCanvasKit(ck: CanvasKit, renderer: SkiaRenderer) {
_ck = ck
_renderer = renderer
_renderers.add(renderer)
_textEditor ??= new TextEditor(ck)
setTextMeasurer(
typeof renderer.measureTextNode === 'function'
? (node, maxWidth) => renderer.measureTextNode(node, maxWidth)
: null
)
}
function removeCanvasRenderer(renderer: SkiaRenderer) {
_renderers.delete(renderer)
if (_renderer === renderer) {
_renderer = _renderers.values().next().value ?? null
}
}
function replaceGraph(newGraph: SceneGraph) {
_graph = newGraph
subscribeToGraph()
const previousPageId = state.currentPageId
state.currentPageId = _graph.getPages()[0]?.id ?? _graph.rootId
setSelectedIds(new Set())
state.hoveredNodeId = null
state.measurementMode = 'off'
state.snapGuides = []
state.guidePreview = null
state.layoutInsertIndicator = null
state.dropTargetId = null
pages.clearPageViewports()
emitEditorEvent('graph:replaced', _graph)
if (previousPageId !== state.currentPageId) {
emitEditorEvent('page:changed', state.currentPageId, previousPageId)
}
requestRender()
}
return {
get graph() {
return _graph
},
get renderer() {
return _renderer
},
get canvasRenderers() {
return [..._renderers]
},
get textEditor() {
return _textEditor
},
undo,
state,
// Graph reads
...graphReads,
// Lifecycle
requestRender,
requestRepaint,
onEditorEvent,
setCanvasKit,
removeCanvasRenderer,
replaceGraph,
subscribeToGraph,
dispose: stopFontResolutionEvents,
// Selection
...selection,
// Pages
...pages,
// Canvas and frame guides
...guides,
// Shapes & tools
...shapes,
// Structure (group, reorder, reparent, z-order)
...structure,
// Nodes (update, layout)
...nodes,
// Alignment (align, flip, rotate)
...alignment,
// Bitmap-to-vector replacement
...vectorize,
// Variables
...variables,
// Text editing
...text,
// Viewport
...viewport,
// Undo — bridge functions that need cross-module refs
...undoBridge,
setDocumentColorSpace: colorSpace.setDocumentColorSpace,
// Clipboard — bridge functions that need selectedNodes
...clipboardBridge,
// Components — bridge functions
...componentBridge,
// Structure — bridge functions that need selectedNodes
...structureBridge
}
}
export type Editor = ReturnType<typeof createEditor>