refactor(editor): separate canvas view state (#518)

* refactor(editor): separate canvas view state

- Classify shared and view-local editor state explicitly

- Let canvas surfaces render supplied view state and report their viewport

- Preserve the existing one-canvas behavior by default

* fix(canvas): preserve loading render state

* refactor(editor): group state ownership modules

- Move shared and view state into the editor state namespace

- Model the partition with explicit interfaces and default factories

- Derive runtime view keys from the default view object
This commit is contained in:
Danila Poyarkov 2026-08-14 21:24:01 +03:00 committed by GitHub
parent f781c48eaa
commit 29ed07bcc4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 267 additions and 70 deletions

View file

@ -4,6 +4,8 @@
### Added
- Add explicit shared/view editor-state ownership and canvas render-state hooks as a foundation for independent same-document canvas panes.
- Show Figma-style temporary distance measurements between selected and Option/Alt-hovered layers. (#491)
- Add reusable remote MCP connections for ACP agents, with Streamable HTTP endpoints and bearer tokens stored in the configured credential backend.
- Author multidimensional component variants in the Design panel, including property/value renaming, sparse-combination diagnostics, variant duplication, and exact instance transitions. (#239)

View file

@ -4,6 +4,12 @@ export {
ReadOnlyLibraryDefinitionError
} from './capabilities'
export type { NodeEditCapability } from './capabilities'
export { createDefaultEditorSharedState } from './state/shared'
export {
copyEditorViewState,
createDefaultEditorViewState,
pickEditorViewState
} from './state/view'
export { createDefaultEditorState, createEditor } from './create'
export type { Editor } from './create'
export { reapplyInstanceComponentProperties } from './components/properties'
@ -20,6 +26,8 @@ export type {
EditorEvents,
EditorOptions,
EditorState,
EditorSharedState,
EditorViewState,
FigmaClipboardImageResolver,
Tool
} from './types'

View file

@ -1,32 +0,0 @@
import { CANVAS_BG_COLOR } from '#core/constants'
import type { EditorState } from '#core/editor/types'
export function createDefaultEditorState(pageId: string): EditorState {
return {
activeTool: 'SELECT',
currentPageId: pageId,
selectedIds: new Set<string>(),
marquee: null,
snapGuides: [],
rotationPreview: null,
dropTargetId: null,
layoutInsertIndicator: null,
hoveredNodeId: null,
measurementMode: 'off',
editingTextId: null,
penState: null,
penCursorX: null,
penCursorY: null,
remoteCursors: [],
autoLayoutHover: null,
documentName: 'Untitled',
panX: 0,
pageColor: { ...CANVAS_BG_COLOR },
panY: 0,
zoom: 1,
renderVersion: 0,
sceneVersion: 0,
loading: false,
enteredContainerId: null
}
}

View file

@ -0,0 +1,11 @@
import type { EditorState } from '#core/editor/types'
import { createDefaultEditorSharedState } from './shared'
import { createDefaultEditorViewState } from './view'
export function createDefaultEditorState(pageId: string): EditorState {
return {
...createDefaultEditorSharedState(),
...createDefaultEditorViewState(pageId)
}
}

View file

@ -0,0 +1,12 @@
import type { EditorSharedState } from '#core/editor/types'
export function createDefaultEditorSharedState(): EditorSharedState {
return {
activeTool: 'SELECT',
remoteCursors: [],
documentName: 'Untitled',
rulerTheme: undefined,
sceneVersion: 0,
loading: false
}
}

View file

@ -0,0 +1,54 @@
import { CANVAS_BG_COLOR } from '#core/constants'
import type { EditorState, EditorViewState } from '#core/editor/types'
export function createDefaultEditorViewState(pageId: string): EditorViewState {
return {
currentPageId: pageId,
selectedIds: new Set<string>(),
marquee: null,
snapGuides: [],
rotationPreview: null,
dropTargetId: null,
layoutInsertIndicator: null,
hoveredNodeId: null,
measurementMode: 'off',
editingTextId: null,
penState: null,
penCursorX: null,
penCursorY: null,
autoLayoutHover: null,
panX: 0,
pageColor: { ...CANVAS_BG_COLOR },
panY: 0,
zoom: 1,
renderVersion: 0,
enteredContainerId: null,
nodeEditState: null,
cursorCanvasX: null,
cursorCanvasY: null
}
}
export function copyEditorViewState(source: EditorViewState): EditorViewState {
return {
...source,
selectedIds: new Set(source.selectedIds),
marquee: structuredClone(source.marquee),
snapGuides: structuredClone(source.snapGuides),
rotationPreview: structuredClone(source.rotationPreview),
layoutInsertIndicator: structuredClone(source.layoutInsertIndicator),
penState: structuredClone(source.penState),
autoLayoutHover: structuredClone(source.autoLayoutHover),
pageColor: { ...source.pageColor },
nodeEditState: structuredClone(source.nodeEditState)
}
}
export function pickEditorViewState(state: EditorState): EditorViewState {
const defaults = createDefaultEditorViewState(state.currentPageId)
const view = {} as EditorViewState
for (const key of Object.keys(defaults) as Array<keyof EditorViewState>) {
Reflect.set(view, key, state[key])
}
return copyEditorViewState(view)
}

View file

@ -29,8 +29,22 @@ export type Tool =
| 'PEN'
| 'HAND'
export interface EditorState {
export interface EditorSharedState {
activeTool: Tool
remoteCursors: Array<{
name: string
color: Color
x: number
y: number
selection?: string[]
}>
documentName: string
rulerTheme?: RulerTheme
sceneVersion: number
loading: boolean
}
export interface EditorViewState {
currentPageId: string
selectedIds: Set<string>
marquee: Rect | null
@ -61,34 +75,25 @@ export interface EditorState {
} | null
penCursorX: number | null
penCursorY: number | null
remoteCursors: Array<{
name: string
color: Color
x: number
y: number
selection?: string[]
}>
autoLayoutHover: {
nodeId: string
kind: 'frame' | 'children' | 'spacing' | 'spacing-value' | 'padding' | 'padding-value'
index?: number
side?: 'top' | 'right' | 'bottom' | 'left'
} | null
documentName: string
panX: number
pageColor: Color
rulerTheme?: RulerTheme
panY: number
zoom: number
renderVersion: number
sceneVersion: number
loading: boolean
enteredContainerId: string | null
nodeEditState?: RenderOverlays['nodeEditState'] | null
cursorCanvasX?: number | null
cursorCanvasY?: number | null
}
export interface EditorState extends EditorSharedState, EditorViewState {}
export interface ClipboardImageResolution {
total: number
missing: number

View file

@ -1,5 +1,6 @@
import type { CanvasKit, Surface } from 'canvaskit-wasm'
import { IS_BROWSER } from '@open-pencil/core/constants'
import type { Editor } from '@open-pencil/core/editor'
import type { UseCanvasOptions } from '#vue/canvas/surface/types'
@ -8,12 +9,20 @@ type GLContext = ReturnType<CanvasKit['MakeGrContext']>
export type CanvasGLContext = GLContext
export function sizeCanvas(canvas: HTMLCanvasElement, editor: Editor) {
const dpr = window.devicePixelRatio || 1
canvas.width = canvas.clientWidth * dpr
canvas.height = canvas.clientHeight * dpr
if ('setViewportSize' in editor && typeof editor.setViewportSize === 'function') {
editor.setViewportSize(canvas.clientWidth, canvas.clientHeight)
export function sizeCanvas(
canvas: HTMLCanvasElement,
editor: Editor,
onViewportResize?: (width: number, height: number) => void
) {
const dpr = IS_BROWSER ? window.devicePixelRatio || 1 : 1
const width = canvas.clientWidth
const height = canvas.clientHeight
canvas.width = width * dpr
canvas.height = height * dpr
if (onViewportResize) {
onViewportResize(width, height)
} else if ('setViewportSize' in editor && typeof editor.setViewportSize === 'function') {
editor.setViewportSize(width, height)
}
}

View file

@ -53,7 +53,7 @@ export function createCanvasSurfaceManager({
state.glContext?.delete()
state.glContext = null
sizeCanvas(canvas, editor)
sizeCanvas(canvas, editor, options?.onViewportResize)
const result = makeGLSurface(ck, canvas, editor, options, state.glContext)
state.glContext = result.glContext
@ -82,7 +82,7 @@ export function createCanvasSurfaceManager({
function renderNow() {
if (!state.renderer || isDestroyed()) return
state.renderer.renderFromEditorState(
editor.state,
options?.getRenderState?.() ?? editor.state,
editor.graph,
editor.textEditor,
canvasRef.value?.clientWidth ?? 0,
@ -98,7 +98,10 @@ export function createCanvasSurfaceManager({
}
}
const renderLoop = createCanvasRenderLoop(editor, renderNow, { layer: options?.layer })
const renderLoop = createCanvasRenderLoop(editor, renderNow, {
layer: options?.layer,
getRenderState: options?.getRenderState
})
function resizeCanvas(canvas: HTMLCanvasElement) {
const ck = getCanvasKit()
@ -107,7 +110,7 @@ export function createCanvasSurfaceManager({
return
}
sizeCanvas(canvas, editor)
sizeCanvas(canvas, editor, options?.onViewportResize)
const result = makeGLSurface(ck, canvas, editor, options, state.glContext)
state.glContext = result.glContext
@ -134,7 +137,7 @@ export function createCanvasSurfaceManager({
resizeCanvas,
renderNow,
destroy,
markDirty: renderLoop.markDirty,
markDirty: () => renderLoop.markDirty(),
getRenderer: () => state.renderer
}
}

View file

@ -1,9 +1,10 @@
import type { Editor } from '@open-pencil/core/editor'
import type { Editor, EditorState } from '@open-pencil/core/editor'
import type { CanvasRenderLayer } from './types'
type RenderLoopOptions = {
layer?: CanvasRenderLayer
getRenderState?: () => EditorState
}
type EditorRenderScheduler = {
@ -55,48 +56,54 @@ export function createCanvasRenderLoop(
renderNow: () => void,
options: RenderLoopOptions = {}
) {
const getRenderState = options.getRenderState ?? (() => editor.state)
const scheduler = getRenderScheduler(editor)
let dirty = true
let frameScheduled = false
let lastRenderVersion = -1
let lastSceneVersion = -1
let lastSelectedIds: Set<string> | null = null
function renderFrame() {
frameScheduled = false
if (editor.state.loading) {
scheduleRender()
const state = getRenderState()
if (state.loading) {
dirty = true
scheduleFrame()
return
}
const versionChanged = editor.state.renderVersion !== lastRenderVersion
const selectionChanged = editor.state.selectedIds !== lastSelectedIds
if (dirty || versionChanged || selectionChanged) {
const versionChanged = state.renderVersion !== lastRenderVersion
const sceneChanged = state.sceneVersion !== lastSceneVersion
const selectionChanged = state.selectedIds !== lastSelectedIds
if (dirty || versionChanged || sceneChanged || selectionChanged) {
dirty = false
renderNow()
}
}
const scheduleRender = () => {
dirty = true
const scheduleFrame = () => {
if (frameScheduled) return
frameScheduled = true
scheduler.schedule(renderFrame)
}
const unsubscribe = [
editor.onEditorEvent('render:requested', scheduleRender),
editor.onEditorEvent('viewport:changed', scheduleRender)
editor.onEditorEvent('render:requested', scheduleFrame),
editor.onEditorEvent('viewport:changed', scheduleFrame)
]
unsubscribe.push(editor.onEditorEvent('repaint:requested', scheduleRender))
unsubscribe.push(editor.onEditorEvent('repaint:requested', scheduleFrame))
if (shouldScheduleForSelection(options.layer)) {
unsubscribe.push(editor.onEditorEvent('selection:changed', scheduleRender))
unsubscribe.push(editor.onEditorEvent('selection:changed', scheduleFrame))
}
function markRendered() {
lastRenderVersion = editor.state.renderVersion
lastSelectedIds = editor.state.selectedIds
const state = getRenderState()
lastRenderVersion = state.renderVersion
lastSceneVersion = state.sceneVersion
lastSelectedIds = state.selectedIds
}
function pause() {
@ -110,6 +117,9 @@ export function createCanvasRenderLoop(
return {
pause,
markRendered,
markDirty: scheduleRender
markDirty() {
dirty = true
scheduleFrame()
}
}
}

View file

@ -1,3 +1,5 @@
import type { EditorState } from '@open-pencil/core/editor'
/**
* Options for {@link useCanvas}.
*/
@ -25,4 +27,15 @@ export interface UseCanvasOptions {
* Called once the rendering surface is ready.
*/
onReady?: () => void
/**
* Supplies the view state rendered by this canvas. Defaults to `editor.state`.
*
* Multiple canvas surfaces can use independent view state while sharing one
* document graph, history, and editor event bus.
*/
getRenderState?: () => EditorState
/**
* Receives this canvas surface's CSS viewport size after creation and resize.
*/
onViewportResize?: (width: number, height: number) => void
}

View file

@ -0,0 +1,35 @@
import { describe, expect, test } from 'bun:test'
import {
copyEditorViewState,
createDefaultEditorState,
createDefaultEditorViewState,
pickEditorViewState
} from '@open-pencil/core/editor'
describe('editor state ownership', () => {
test('composes editor state from shared and view defaults', () => {
const state = createDefaultEditorState('page')
const view = createDefaultEditorViewState('page')
expect(pickEditorViewState(state)).toEqual(view)
expect(state.activeTool).toBe('SELECT')
expect(state.documentName).toBe('Untitled')
})
test('copies mutable view state for an independent canvas surface', () => {
const state = createDefaultEditorState('page')
state.selectedIds = new Set(['selected'])
state.snapGuides = [{ axis: 'x', position: 10, from: 0, to: 20 }]
const source = pickEditorViewState(state)
const copy = copyEditorViewState(source)
copy.selectedIds.add('pane-only')
copy.snapGuides.length = 0
copy.pageColor.r = 0.5
expect(source.selectedIds).toEqual(new Set(['selected']))
expect(source.snapGuides).toHaveLength(1)
expect(source.pageColor.r).not.toBe(0.5)
})
})

View file

@ -185,6 +185,73 @@ describe('canvas render loop', () => {
}
})
test('reads versions and selection from the supplied canvas view state', () => {
const scheduler = createFrameScheduler()
try {
const { editor, emit } = createEditor()
const viewState = {
...editor.state,
renderVersion: 4,
sceneVersion: 2,
selectedIds: new Set(['pane-node'])
}
let renders = 0
const loop = createCanvasRenderLoop(
editor,
() => {
renders++
},
{ getRenderState: () => viewState }
)
emit('repaint:requested')
scheduler.flush()
loop.markRendered()
expect(renders).toBe(1)
emit('repaint:requested')
scheduler.flush()
expect(renders).toBe(1)
viewState.renderVersion++
emit('repaint:requested')
scheduler.flush()
expect(renders).toBe(2)
} finally {
scheduler.restore()
}
})
test('renders after supplied view state finishes loading without a version change', () => {
const scheduler = createFrameScheduler()
try {
const { editor, emit } = createEditor()
const viewState = { ...editor.state, loading: false }
let renders = 0
const loop = createCanvasRenderLoop(
editor,
() => {
renders++
},
{ getRenderState: () => viewState }
)
emit('repaint:requested')
scheduler.flush()
loop.markRendered()
viewState.loading = true
emit('repaint:requested')
scheduler.flush()
expect(renders).toBe(1)
viewState.loading = false
scheduler.flush()
expect(renders).toBe(2)
} finally {
scheduler.restore()
}
})
test('cancels pending renders when paused', () => {
const scheduler = createFrameScheduler()
try {