openpencil/packages/core/src/editor/create.ts
Danila Poyarkov 4faccd047d
feat(canvas): select auto layout frames by their gaps and dot their children, as in Figma (#943)
* fix: select top-level auto layout frames by their empty area

Checked against Figma desktop 126 with real pointer input. The gaps and
padding of a top-level frame with auto layout (any direction or grid,
filled or not, on the page or in a section) select, highlight, and drag
the frame, while clicks on its layers still select those layers. A plain
top-level frame's empty area stays background, as #900 recorded; its
oracle scenarios only used frames without auto layout.

Auto layout frames are hit by their bounds even without a fill, and a
press on such a frame's empty area no longer starts a marquee.

* feat(canvas): outline auto layout children with dotted lines, as in Figma

Hovering a horizontal or vertical auto layout frame outlines its visible
direct children, and a single selected layer dots the border of its auto
layout parent, in the container's outline color. Grid and plain frames,
grandchildren, and multi-selections show none. Line weight and dash were
measured from Figma 126 screenshots: 1 px with 1.75 px dashes and 1.25 px
gaps in screen space.

The editor gains a transforming flag that canvas input sets while layers
move, resize, or rotate; the outlines and the padding and gap markers of a
selected auto layout frame hide while it is set, as Figma's do.

The children outline a selected auto layout frame drew under the pointer
now uses the same stroke, and the dash effect is freed after each draw.

* fix(canvas): select unfilled frames by their empty area only on a click

Live Figma 126, with real pointer input on a top-level card holding an
unfilled auto layout row and an unfilled plain frame, with and without
auto layout on the card: a click on either frame's empty area selects
that frame, while a ⌘-click looks through it to the card.

Only top-level auto layout frames now own their empty area in hit
testing, so a deep hit no longer stops at a nested unfilled auto layout
frame. A click that ends on an open container selects the frame child
under the point by its bounds, filled or not, which plain frames did not
get before; boards that open by themselves keep their empty area as
background.

* fix(canvas): select an empty unfilled frame on the page by its bounds

Live Figma 126: a click inside an empty frame without a fill selects it,
on the page and in a section. The section case already did after the
previous commit; on the page the click found nothing, since an unfilled
frame without children is not hit. A click that hits nothing now looks
for a frame under the point among the scope's children, which skips
boards that hold layers, as their empty area stays background.

* refactor(scene-graph): read the containers the selection opens in their own helper

* fix(canvas): open a selected container, so repeated clicks reach deeper layers

In Figma, clicking again at the same point inside a selected frame,
component, or instance selects the layer under the pointer, so repeated
clicks walk down to a text in a nested component such as shadcn's
calendar. The click hit test opened only the ancestors of the selection,
so a selected nested container stopped the walk at itself and only a
double-click went deeper.

A selected container with layers now opens like its ancestors; groups
and booleans stay closed, as in Figma. Pressing inside the selection
still drags it, and its empty area keeps it selected.
2026-10-07 11:29:52 +00:00

405 lines
13 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 { getPageColor, setDefaultPageBackground } from '#core/figma-api/page-backgrounds'
import { releaseFigPopulationWorker } from '#core/kiwi/fig/population/client'
import { releaseOriginalFigArchive } from '#core/kiwi/fig/session/original-archive'
import { installTextMeasurer } from '#core/layout'
import { createLayoutRunner } from '#core/layout/mutations'
import { emitNavigationTrace } from '#core/profiler'
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 { createLintFixBridge } from './bridges/lint'
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 { createDesignIssueActions } from './issues'
import { createNodeActions } from './nodes'
import { createPageActions } from './pages'
import { createPlayActions } from './play/actions'
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({ onChange: () => emitEditorEvent('history:changed') })
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 uninstallTextMeasurer: (() => void) | null = null
const interactiveEdits = new Set<symbol>()
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++
emitNavigationTrace('render:requested', {
kind: 'render',
renderVersion: state.renderVersion,
sceneVersion: state.sceneVersion
})
emitEditorEvent('render:requested', {
renderVersion: state.renderVersion,
sceneVersion: state.sceneVersion
})
}
function requestRepaint() {
state.renderVersion++
emitNavigationTrace('render:requested', {
kind: 'repaint',
renderVersion: state.renderVersion,
sceneVersion: state.sceneVersion
})
emitEditorEvent('repaint:requested', {
renderVersion: state.renderVersion,
sceneVersion: state.sceneVersion
})
}
/** Track an actual live edit independently of history batching. Release is idempotent. */
function beginInteractiveEdit() {
const token = Symbol('interactive-edit')
interactiveEdits.add(token)
requestRepaint()
return () => {
if (interactiveEdits.delete(token)) requestRepaint()
}
}
function setNavigationPhase(phase: EditorState['navigation']['phase'], inputAt = 0) {
const previous = { ...state.navigation }
const active = phase === 'pan' || phase === 'zoom' || phase === 'momentum'
const wasActive =
previous.phase === 'pan' || previous.phase === 'zoom' || previous.phase === 'momentum'
state.navigation = {
phase,
generation: active && !wasActive ? previous.generation + 1 : previous.generation,
lastInputAt: inputAt || previous.lastInputAt
}
if (
state.navigation.phase !== previous.phase ||
state.navigation.generation !== previous.generation ||
state.navigation.lastInputAt !== previous.lastInputAt
) {
emitNavigationTrace('navigation:phase', {
phase: state.navigation.phase,
previousPhase: previous.phase,
generation: state.navigation.generation,
lastInputAt: state.navigation.lastInputAt
})
emitEditorEvent('navigation:changed', state.navigation, previous)
}
}
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, runMutationWithLayout } = createLayoutRunner(() => _graph)
const { scheduleComponentSync } = createComponentSyncScheduler(() => _graph, requestRender)
const { subscribeToGraph, unsubscribeFromGraph } = createGraphEventSubscription({
getGraph: () => _graph,
getRenderers: () => _renderers,
scheduleComponentSync,
requestRender,
emitEditorEvent
})
if (!skipInitialGraphSetup) {
subscribeToGraph()
// A new document's first page takes Figma's background for the interface theme the app gives.
const firstPage = _graph.getPages()[0]
if (options?.state?.theme) {
setDefaultPageBackground(_graph, firstPage, options.state.theme)
options.state.pageColor = getPageColor(firstPage)
}
}
// 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,
beginInteractiveEdit,
onEditorEvent,
emitEditorEvent,
setSelectedIds,
setActiveTool,
setNavigationPhase,
runLayoutForNode,
runMutationWithLayout,
subscribeToGraph
}
// Assemble domain modules
const viewport = createViewportActions(ctx)
const selection = createSelectionActions(ctx)
const pages = createPageActions(ctx)
const guides = createGuideActions(ctx)
const designIssues = createDesignIssueActions(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 preview = createPlayActions(ctx)
const clipboardBridge = createClipboardBridge(clipboard, selection)
const componentBridge = createComponentBridge(components, selection, structure, pages)
const structureBridge = createStructureBridge(structure, selection)
const undoBridge = createUndoBridge(undoActions, selection)
const lintFixBridge = createLintFixBridge(ctx, nodes, structure, clipboard)
function setCanvasKit(ck: CanvasKit, renderer: SkiaRenderer) {
_ck = ck
_renderer = renderer
_renderers.add(renderer)
_textEditor ??= new TextEditor(ck)
uninstallTextMeasurer?.()
uninstallTextMeasurer =
typeof renderer.measureTextNode === 'function'
? installTextMeasurer((node, maxWidth) => renderer.measureTextNode(node, maxWidth))
: null
}
function releaseTextMeasurer() {
uninstallTextMeasurer?.()
uninstallTextMeasurer = null
}
function removeCanvasRenderer(renderer: SkiaRenderer) {
_renderers.delete(renderer)
if (_renderer === renderer) {
_renderer = _renderers.values().next().value ?? null
}
if (_renderers.size === 0) releaseTextMeasurer()
}
function replaceGraph(newGraph: SceneGraph) {
nodes.cancelNodePreviews()
undo.discardBatches()
_graph = newGraph
subscribeToGraph()
const previousPageId = state.currentPageId
state.currentPageId = _graph.getPages()[0]?.id ?? _graph.rootId
setSelectedIds(new Set())
state.hoveredNodeId = null
state.transforming = false
state.measurementMode = 'off'
state.snapGuides = []
state.guides = { preview: null, hovered: null, selected: null, redline: null }
state.layoutInsertIndicator = null
state.dropTargetId = null
pages.clearPageViewports()
for (const renderer of _renderers) renderer.tiledScene.invalidateStructure()
emitEditorEvent('graph:replaced', _graph)
if (previousPageId !== state.currentPageId) {
emitEditorEvent('page:changed', state.currentPageId, previousPageId)
}
requestRender()
}
function dispose() {
releaseTextMeasurer()
nodes.cancelNodePreviews()
interactiveEdits.clear()
stopFontResolutionEvents()
unsubscribeFromGraph()
}
function releaseGraphResources() {
releaseFigPopulationWorker(_graph)
releaseOriginalFigArchive(_graph)
}
return {
get graph() {
return _graph
},
get renderer() {
return _renderer
},
get canvasRenderers() {
return [..._renderers]
},
get textEditor() {
return _textEditor
},
undo,
state,
// Graph reads
runLayoutForNode,
runMutationWithLayout,
...graphReads,
// Lifecycle
beginInteractiveEdit,
isInteractiveEditing: () => interactiveEdits.size > 0,
requestRender,
requestRepaint,
onEditorEvent,
setCanvasKit,
setNavigationPhase,
removeCanvasRenderer,
replaceGraph,
subscribeToGraph,
dispose,
releaseGraphResources,
// Selection
...selection,
// Pages
...pages,
// Canvas and frame guides
...guides,
...designIssues,
// Shapes & tools
...shapes,
// Structure (group, reorder, reparent, z-order)
...structure,
// Nodes (update, layout)
...nodes,
// Alignment (align, flip, rotate)
...alignment,
// Preview: instances with a behaviour respond to the pointer on this canvas
...preview,
// 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,
// Lint fixes, which touch nodes, structure and deletion in one undo step
...lintFixBridge
}
}
export type Editor = ReturnType<typeof createEditor>