- 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
302 lines
8.8 KiB
TypeScript
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>
|