From 29ed07bcc44b3eff0ee8f95ba2b96b39950fdfa3 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Fri, 14 Aug 2026 21:24:01 +0300 Subject: [PATCH] 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 --- CHANGELOG.md | 2 + packages/core/src/editor/index.ts | 8 +++ packages/core/src/editor/state.ts | 32 --------- packages/core/src/editor/state/index.ts | 11 +++ packages/core/src/editor/state/shared.ts | 12 ++++ packages/core/src/editor/state/view.ts | 54 +++++++++++++++ packages/core/src/editor/types.ts | 29 ++++---- packages/vue/src/canvas/surface/gl-surface.ts | 21 ++++-- packages/vue/src/canvas/surface/lifecycle.ts | 13 ++-- .../vue/src/canvas/surface/render-loop.ts | 40 ++++++----- packages/vue/src/canvas/surface/types.ts | 13 ++++ tests/engine/editor/view-state.test.ts | 35 ++++++++++ tests/engine/vue/canvas/render-loop.test.ts | 67 +++++++++++++++++++ 13 files changed, 267 insertions(+), 70 deletions(-) delete mode 100644 packages/core/src/editor/state.ts create mode 100644 packages/core/src/editor/state/index.ts create mode 100644 packages/core/src/editor/state/shared.ts create mode 100644 packages/core/src/editor/state/view.ts create mode 100644 tests/engine/editor/view-state.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 30003dcbf..bdfb82899 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/packages/core/src/editor/index.ts b/packages/core/src/editor/index.ts index 9bcdae145..a7b9ceda1 100644 --- a/packages/core/src/editor/index.ts +++ b/packages/core/src/editor/index.ts @@ -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' diff --git a/packages/core/src/editor/state.ts b/packages/core/src/editor/state.ts deleted file mode 100644 index cb1457db6..000000000 --- a/packages/core/src/editor/state.ts +++ /dev/null @@ -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(), - 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 - } -} diff --git a/packages/core/src/editor/state/index.ts b/packages/core/src/editor/state/index.ts new file mode 100644 index 000000000..10f3decde --- /dev/null +++ b/packages/core/src/editor/state/index.ts @@ -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) + } +} diff --git a/packages/core/src/editor/state/shared.ts b/packages/core/src/editor/state/shared.ts new file mode 100644 index 000000000..48f00e0cc --- /dev/null +++ b/packages/core/src/editor/state/shared.ts @@ -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 + } +} diff --git a/packages/core/src/editor/state/view.ts b/packages/core/src/editor/state/view.ts new file mode 100644 index 000000000..34c043f7d --- /dev/null +++ b/packages/core/src/editor/state/view.ts @@ -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(), + 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) { + Reflect.set(view, key, state[key]) + } + return copyEditorViewState(view) +} diff --git a/packages/core/src/editor/types.ts b/packages/core/src/editor/types.ts index d3d78d0d7..96eaa3b82 100644 --- a/packages/core/src/editor/types.ts +++ b/packages/core/src/editor/types.ts @@ -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 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 diff --git a/packages/vue/src/canvas/surface/gl-surface.ts b/packages/vue/src/canvas/surface/gl-surface.ts index 904542f69..7db0d30da 100644 --- a/packages/vue/src/canvas/surface/gl-surface.ts +++ b/packages/vue/src/canvas/surface/gl-surface.ts @@ -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 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) } } diff --git a/packages/vue/src/canvas/surface/lifecycle.ts b/packages/vue/src/canvas/surface/lifecycle.ts index efe3c615e..c27b3232c 100644 --- a/packages/vue/src/canvas/surface/lifecycle.ts +++ b/packages/vue/src/canvas/surface/lifecycle.ts @@ -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 } } diff --git a/packages/vue/src/canvas/surface/render-loop.ts b/packages/vue/src/canvas/surface/render-loop.ts index 0b0b1e994..01672325b 100644 --- a/packages/vue/src/canvas/surface/render-loop.ts +++ b/packages/vue/src/canvas/surface/render-loop.ts @@ -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 | 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() + } } } diff --git a/packages/vue/src/canvas/surface/types.ts b/packages/vue/src/canvas/surface/types.ts index bd724e83a..8577543f0 100644 --- a/packages/vue/src/canvas/surface/types.ts +++ b/packages/vue/src/canvas/surface/types.ts @@ -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 } diff --git a/tests/engine/editor/view-state.test.ts b/tests/engine/editor/view-state.test.ts new file mode 100644 index 000000000..a573f940f --- /dev/null +++ b/tests/engine/editor/view-state.test.ts @@ -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) + }) +}) diff --git a/tests/engine/vue/canvas/render-loop.test.ts b/tests/engine/vue/canvas/render-loop.test.ts index 91e37723d..853889301 100644 --- a/tests/engine/vue/canvas/render-loop.test.ts +++ b/tests/engine/vue/canvas/render-loop.test.ts @@ -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 {