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:
parent
f781c48eaa
commit
29ed07bcc4
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
11
packages/core/src/editor/state/index.ts
Normal file
11
packages/core/src/editor/state/index.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
12
packages/core/src/editor/state/shared.ts
Normal file
12
packages/core/src/editor/state/shared.ts
Normal 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
|
||||
}
|
||||
}
|
||||
54
packages/core/src/editor/state/view.ts
Normal file
54
packages/core/src/editor/state/view.ts
Normal 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)
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
35
tests/engine/editor/view-state.test.ts
Normal file
35
tests/engine/editor/view-state.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Reference in a new issue