feat(editor): model split canvas panes (#519)

* 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

* feat(editor): model split canvas panes

- Add pure recursive split-tree operations with validated sizes

- Add explicit pane registry and independent view-state cloning

- Cap visible panes and cover close and split behavior

* 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

* feat(editor): model split canvas panes

- Add pure recursive split-tree operations with validated sizes

- Add explicit pane registry and independent view-state cloning

- Cap visible panes and cover close and split behavior

* feat(editor): add split canvas views

- Render recursive pane layouts with Reka UI splitters and pane-local headers
- Route canvas input, selection, viewport state, and close actions to the active pane
- Repaint every canvas during shared document previews and cover split lifecycle in tests

* fix(editor): clean up inactive pane interactions

- Cancel drag, padding preview, and text editing state when pane focus changes
- Remove duplicate changelog entries introduced while updating master
This commit is contained in:
Danila Poyarkov 2026-08-15 10:31:52 +03:00 committed by GitHub
parent 6193b6da7e
commit 7a02ee2c62
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
34 changed files with 830 additions and 44 deletions

View file

@ -4,13 +4,10 @@
### Added ### Added
- Add tested pane-registry and recursive split-tree models for independently viewed same-document canvases, capped at four visible panes.
- Add explicit shared/view editor-state ownership and canvas render-state hooks as a foundation for independent same-document canvas panes. - 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) - Show Figma-style temporary distance measurements between selected and Option/Alt-hovered layers. (#491)
- Add a reproducible Dev Container for web, package, CLI, and non-browser test development.
- Add local crash recovery for unsaved and pathless documents, including MCP-created documents. (#487)
- Add isolated visual inspection that sends bounded selection renders to the configured Vision model and returns text findings without retaining image data in Design chat history. (#232, #471)
- Add image attachments to AI chat with bounded analysis, immediate transcript thumbnails, hover previews, and click-to-view images. (#232)
- Add a single CodeMirror editor for live Design JSX and HTML/CSS canvas previews, with Tailwind JSX viewing, completion, diagnostics, line numbers, bounded execution, and session-level undo. (#130) - Add a single CodeMirror editor for live Design JSX and HTML/CSS canvas previews, with Tailwind JSX viewing, completion, diagnostics, line numbers, bounded execution, and session-level undo. (#130)
- Allow supported AI model profiles to set a provider-specific reasoning effort. (#454) - Allow supported AI model profiles to set a provider-specific reasoning effort. (#454)
- Show unavailable or substituted document fonts with affected-layer selection and retry actions, and expose font fidelity through the Figma API and MCP tooling. (#503) - Show unavailable or substituted document fonts with affected-layer selection and retry actions, and expose font fidelity through the Figma API and MCP tooling. (#503)

View file

@ -164,6 +164,17 @@
{ {
"type": "separator" "type": "separator"
}, },
{
"id": "view-split-right",
"label": "Split Right"
},
{
"id": "view-split-down",
"label": "Split Down"
},
{
"type": "separator"
},
{ {
"id": "view-rulers", "id": "view-rulers",
"label": "Rulers", "label": "Rulers",

View file

@ -40,7 +40,11 @@ function componentDropPlacement(componentId: string, cx: number, cy: number, edi
} }
} }
export function useCanvasDrop(canvasRef: Ref<HTMLCanvasElement | null>, editor: Editor) { export function useCanvasDrop(
canvasRef: Ref<HTMLCanvasElement | null>,
editor: Editor,
onActivate?: () => void
) {
const isDraggingOver = ref(false) const isDraggingOver = ref(false)
useEventListener(canvasRef, 'dragover', (e: DragEvent) => { useEventListener(canvasRef, 'dragover', (e: DragEvent) => {
@ -52,6 +56,7 @@ export function useCanvasDrop(canvasRef: Ref<HTMLCanvasElement | null>, editor:
useEventListener(canvasRef, 'dragenter', (e: DragEvent) => { useEventListener(canvasRef, 'dragenter', (e: DragEvent) => {
if (!hasComponentData(e) && !hasFileData(e)) return if (!hasComponentData(e) && !hasFileData(e)) return
onActivate?.()
e.preventDefault() e.preventDefault()
isDraggingOver.value = true isDraggingOver.value = true
}) })
@ -61,6 +66,7 @@ export function useCanvasDrop(canvasRef: Ref<HTMLCanvasElement | null>, editor:
}) })
useEventListener(canvasRef, 'drop', (e: DragEvent) => { useEventListener(canvasRef, 'drop', (e: DragEvent) => {
onActivate?.()
e.preventDefault() e.preventDefault()
isDraggingOver.value = false isDraggingOver.value = false

View file

@ -88,12 +88,16 @@ export function createCanvasRenderLoop(
scheduler.schedule(renderFrame) scheduler.schedule(renderFrame)
} }
const unsubscribe = [ const scheduleDirtyFrame = () => {
editor.onEditorEvent('render:requested', scheduleFrame), dirty = true
editor.onEditorEvent('viewport:changed', scheduleFrame) scheduleFrame()
] }
unsubscribe.push(editor.onEditorEvent('repaint:requested', scheduleFrame)) const unsubscribe = [
editor.onEditorEvent('render:requested', scheduleDirtyFrame),
editor.onEditorEvent('viewport:changed', scheduleFrame),
editor.onEditorEvent('repaint:requested', scheduleDirtyFrame)
]
if (shouldScheduleForSelection(options.layer)) { if (shouldScheduleForSelection(options.layer)) {
unsubscribe.push(editor.onEditorEvent('selection:changed', scheduleFrame)) unsubscribe.push(editor.onEditorEvent('selection:changed', scheduleFrame))

View file

@ -26,17 +26,20 @@ export function useTextEditingSession({
textareaRef, textareaRef,
resetBlink, resetBlink,
stopBlink, stopBlink,
resetComposition resetComposition,
isEnabled
}: { }: {
store: Editor store: Editor
textareaRef: ShallowRef<HTMLTextAreaElement | null> textareaRef: ShallowRef<HTMLTextAreaElement | null>
resetBlink: () => void resetBlink: () => void
stopBlink: () => void stopBlink: () => void
resetComposition: () => void resetComposition: () => void
isEnabled?: () => boolean
}) { }) {
watch( watch(
() => store.state.editingTextId, () => [store.state.editingTextId, isEnabled?.() ?? true] as const,
(id, _, onCleanup) => { ([id, enabled], _, onCleanup) => {
if (!enabled) return
if (id) { if (id) {
const el = createHiddenTextArea() const el = createHiddenTextArea()
textareaRef.value = el textareaRef.value = el

View file

@ -16,7 +16,11 @@ import { focusTextAreaOnCanvasPointerDown, useTextEditingSession } from './texta
* blinking, keyboard editing behavior, text formatting shortcuts, and syncing * blinking, keyboard editing behavior, text formatting shortcuts, and syncing
* text/style-run updates back into the scene graph. * text/style-run updates back into the scene graph.
*/ */
export function useTextEdit(canvasRef: Ref<HTMLCanvasElement | null>, store: Editor) { export function useTextEdit(
canvasRef: Ref<HTMLCanvasElement | null>,
store: Editor,
options?: { isEnabled?: () => boolean }
) {
const textareaRef = shallowRef<HTMLTextAreaElement | null>(null) const textareaRef = shallowRef<HTMLTextAreaElement | null>(null)
const { resetBlink, stopBlink } = createCaretBlink(store) const { resetBlink, stopBlink } = createCaretBlink(store)
const { const {
@ -77,5 +81,12 @@ export function useTextEdit(canvasRef: Ref<HTMLCanvasElement | null>, store: Edi
focusTextAreaOnCanvasPointerDown(textareaRef, store) focusTextAreaOnCanvasPointerDown(textareaRef, store)
) )
useTextEditingSession({ store, textareaRef, resetBlink, stopBlink, resetComposition }) useTextEditingSession({
store,
textareaRef,
resetBlink,
stopBlink,
resetComposition,
isEnabled: options?.isEnabled
})
} }

View file

@ -38,7 +38,9 @@ export function useCanvasInput(
hitTestSectionTitle: (cx: number, cy: number) => SceneNode | null, hitTestSectionTitle: (cx: number, cy: number) => SceneNode | null,
hitTestComponentLabel: (cx: number, cy: number) => SceneNode | null, hitTestComponentLabel: (cx: number, cy: number) => SceneNode | null,
hitTestFrameTitle: (cx: number, cy: number) => SceneNode | null, hitTestFrameTitle: (cx: number, cy: number) => SceneNode | null,
onCursorMove?: (cx: number, cy: number) => void onCursorMove?: (cx: number, cy: number) => void,
onActivate?: () => void,
isEnabled: () => boolean = () => true
) { ) {
const drag = ref<DragState | null>(null) const drag = ref<DragState | null>(null)
const cursorOverride = ref<string | null>(null) const cursorOverride = ref<string | null>(null)
@ -96,6 +98,7 @@ export function useCanvasInput(
} }
function updateModifier(code: string, held: boolean) { function updateModifier(code: string, held: boolean) {
if (!isEnabled()) return
if (code === 'AltLeft' || code === 'AltRight') altHeld = held if (code === 'AltLeft' || code === 'AltRight') altHeld = held
if (code === 'MetaLeft' || code === 'MetaRight') metaHeld = held if (code === 'MetaLeft' || code === 'MetaRight') metaHeld = held
if (code === 'ControlLeft' || code === 'ControlRight') controlHeld = held if (code === 'ControlLeft' || code === 'ControlRight') controlHeld = held
@ -200,6 +203,8 @@ export function useCanvasInput(
} }
function onMouseDown(e: MouseEvent) { function onMouseDown(e: MouseEvent) {
onActivate?.()
if (!isEnabled()) return
editor.setMeasurementMode('off') editor.setMeasurementMode('off')
const paddingEdit = autoLayoutPaddingEdit.value const paddingEdit = autoLayoutPaddingEdit.value
if (paddingEdit) { if (paddingEdit) {
@ -228,6 +233,7 @@ export function useCanvasInput(
} }
function onMouseMove(e: MouseEvent) { function onMouseMove(e: MouseEvent) {
if (!isEnabled()) return
pointerInside.value = true pointerInside.value = true
const coords = getCoords(e) const coords = getCoords(e)
lastPointer.value = { cx: coords.cx, cy: coords.cy } lastPointer.value = { cx: coords.cx, cy: coords.cy }
@ -310,6 +316,7 @@ export function useCanvasInput(
} }
function onMouseUp() { function onMouseUp() {
if (!isEnabled()) return
if (!drag.value) return if (!drag.value) return
const d = drag.value const d = drag.value
@ -355,6 +362,7 @@ export function useCanvasInput(
useEventListener(window, 'blur', resetMeasurementModifiers) useEventListener(window, 'blur', resetMeasurementModifiers)
useEventListener(canvasRef, 'mouseleave', () => { useEventListener(canvasRef, 'mouseleave', () => {
pointerInside.value = false pointerInside.value = false
if (!isEnabled()) return
editor.setMeasurementMode('off') editor.setMeasurementMode('off')
if (!drag.value) { if (!drag.value) {
editor.setHoveredNode(null) editor.setHoveredNode(null)
@ -365,6 +373,7 @@ export function useCanvasInput(
}) })
const stopToolListener = editor.onEditorEvent('tool:changed', () => { const stopToolListener = editor.onEditorEvent('tool:changed', () => {
if (!isEnabled()) return
editor.setMeasurementMode('off') editor.setMeasurementMode('off')
}) })
onScopeDispose(stopToolListener) onScopeDispose(stopToolListener)
@ -376,6 +385,13 @@ export function useCanvasInput(
autoLayoutPaddingEdit, autoLayoutPaddingEdit,
updateAutoLayoutPaddingEdit, updateAutoLayoutPaddingEdit,
commitAutoLayoutPaddingEdit, commitAutoLayoutPaddingEdit,
cancelAutoLayoutPaddingEdit cancelAutoLayoutPaddingEdit,
cleanupInteractions() {
cancelAutoLayoutPaddingEdit()
drag.value = null
cursorOverride.value = null
pointerInside.value = false
resetMeasurementModifiers()
}
} }
} }

View file

@ -58,5 +58,8 @@
"multiplayerCursors": "Multiplayer-Cursor", "multiplayerCursors": "Multiplayer-Cursor",
"renameSelection": "Auswahl umbenennen…", "renameSelection": "Auswahl umbenennen…",
"rulers": "Lineale", "rulers": "Lineale",
"settings": "Einstellungen…" "settings": "Einstellungen…",
"splitRight": "Nach rechts teilen",
"splitDown": "Nach unten teilen",
"closeView": "Ansicht schließen"
} }

View file

@ -58,5 +58,8 @@
"multiplayerCursors": "Cursores multijugador", "multiplayerCursors": "Cursores multijugador",
"renameSelection": "Renombrar selección…", "renameSelection": "Renombrar selección…",
"rulers": "Reglas", "rulers": "Reglas",
"settings": "Ajustes…" "settings": "Ajustes…",
"splitRight": "Dividir a la derecha",
"splitDown": "Dividir hacia abajo",
"closeView": "Cerrar vista"
} }

View file

@ -58,5 +58,8 @@
"multiplayerCursors": "Curseurs multijoueurs", "multiplayerCursors": "Curseurs multijoueurs",
"renameSelection": "Renommer la sélection…", "renameSelection": "Renommer la sélection…",
"rulers": "Règles", "rulers": "Règles",
"settings": "Paramètres…" "settings": "Paramètres…",
"splitRight": "Diviser à droite",
"splitDown": "Diviser vers le bas",
"closeView": "Fermer la vue"
} }

View file

@ -58,5 +58,8 @@
"multiplayerCursors": "Cursori multigiocatore", "multiplayerCursors": "Cursori multigiocatore",
"renameSelection": "Rinomina selezione…", "renameSelection": "Rinomina selezione…",
"rulers": "Righelli", "rulers": "Righelli",
"settings": "Impostazioni…" "settings": "Impostazioni…",
"splitRight": "Dividi a destra",
"splitDown": "Dividi in basso",
"closeView": "Chiudi vista"
} }

View file

@ -58,5 +58,8 @@
"multiplayerCursors": "マルチプレイヤーカーソル", "multiplayerCursors": "マルチプレイヤーカーソル",
"renameSelection": "選択範囲の名前を変更…", "renameSelection": "選択範囲の名前を変更…",
"rulers": "ルーラー", "rulers": "ルーラー",
"settings": "設定…" "settings": "設定…",
"splitRight": "右に分割",
"splitDown": "下に分割",
"closeView": "ビューを閉じる"
} }

View file

@ -58,5 +58,8 @@
"multiplayerCursors": "Kursory wielu użytkowników", "multiplayerCursors": "Kursory wielu użytkowników",
"renameSelection": "Zmień nazwę zaznaczenia…", "renameSelection": "Zmień nazwę zaznaczenia…",
"rulers": "Linijki", "rulers": "Linijki",
"settings": "Ustawienia…" "settings": "Ustawienia…",
"splitRight": "Podziel w prawo",
"splitDown": "Podziel w dół",
"closeView": "Zamknij widok"
} }

View file

@ -58,5 +58,8 @@
"multiplayerCursors": "Курсоры участников", "multiplayerCursors": "Курсоры участников",
"renameSelection": "Переименовать выделение…", "renameSelection": "Переименовать выделение…",
"rulers": "Линейки", "rulers": "Линейки",
"settings": "Настройки…" "settings": "Настройки…",
"splitRight": "Разделить вправо",
"splitDown": "Разделить вниз",
"closeView": "Закрыть вид"
} }

View file

@ -58,5 +58,8 @@
"multiplayerCursors": "多人光标", "multiplayerCursors": "多人光标",
"renameSelection": "重命名所选内容…", "renameSelection": "重命名所选内容…",
"rulers": "标尺", "rulers": "标尺",
"settings": "设置…" "settings": "设置…",
"splitRight": "向右拆分",
"splitDown": "向下拆分",
"closeView": "关闭视图"
} }

View file

@ -66,7 +66,10 @@ export const menuMessageDefaults = {
arrangeAlignMiddle: 'Align middle', arrangeAlignMiddle: 'Align middle',
arrangeAlignBottom: 'Align bottom', arrangeAlignBottom: 'Align bottom',
zoomIn: 'Zoom in', zoomIn: 'Zoom in',
zoomOut: 'Zoom out' zoomOut: 'Zoom out',
splitRight: 'Split right',
splitDown: 'Split down',
closeView: 'Close view'
} as const } as const
export const menuMessages = i18n('menu', menuMessageDefaults) export const menuMessages = i18n('menu', menuMessageDefaults)

View file

@ -0,0 +1,125 @@
import { computed, ref, shallowRef, triggerRef } from 'vue'
import type { EditorState } from '@open-pencil/core/editor'
import { copyEditorViewState, pickEditorViewState } from '@open-pencil/core/editor'
import {
closePaneNode,
containsPane,
leafPaneIds,
MAX_VISIBLE_CANVAS_PANES,
paneCount,
splitPaneNode,
updateSplitSizes
} from './split-tree'
import type { CanvasSplitNode, SplitDirection } from './split-tree'
import { cloneCanvasPaneState, createCanvasPaneState } from './state'
import type { CanvasPaneState } from './state'
export function createCanvasPaneRegistry(state: EditorState) {
let nextPaneIndex = 1
let nextSplitIndex = 1
const initialPane = createCanvasPaneState(`pane-${nextPaneIndex++}`, state)
const panes = shallowRef(new Map([[initialPane.id, initialPane]]))
const activePaneId = ref(initialPane.id)
const splitTree = ref<CanvasSplitNode>({ type: 'pane', paneId: initialPane.id })
const visiblePaneCount = computed(() => paneCount(splitTree.value))
function getPane(paneId: string): CanvasPaneState | undefined {
return panes.value.get(paneId)
}
function syncPaneFromState(pane: CanvasPaneState): void {
Object.assign(pane, copyEditorViewState(pickEditorViewState(state)))
}
function syncStateFromPane(pane: CanvasPaneState): void {
Object.assign(state, copyEditorViewState(pane))
}
function getPaneRenderState(paneId: string): EditorState {
const pane = getPane(paneId)
if (!pane || paneId === activePaneId.value) return state
return { ...state, ...pane } satisfies EditorState
}
function getActivePane(): CanvasPaneState {
return getPane(activePaneId.value) ?? initialPane
}
function setActivePane(paneId: string): boolean {
if (paneId === activePaneId.value) return true
const pane = getPane(paneId)
if (!containsPane(splitTree.value, paneId) || !pane) return false
const current = getPane(activePaneId.value)
if (current) syncPaneFromState(current)
syncStateFromPane(pane)
activePaneId.value = paneId
state.renderVersion++
return true
}
function splitPane(paneId: string, direction: SplitDirection) {
const source = getPane(paneId)
if (!source || visiblePaneCount.value >= MAX_VISIBLE_CANVAS_PANES) return null
if (paneId === activePaneId.value) syncPaneFromState(source)
const pane = cloneCanvasPaneState(`pane-${nextPaneIndex++}`, source)
splitTree.value = splitPaneNode(
splitTree.value,
paneId,
pane.id,
`split-${nextSplitIndex++}`,
direction
)
panes.value.set(pane.id, pane)
setActivePane(pane.id)
triggerRef(panes)
return pane
}
function closePane(paneId: string): boolean {
if (visiblePaneCount.value <= 1 || !getPane(paneId)) return false
const nextTree = closePaneNode(splitTree.value, paneId)
if (!nextTree) return false
panes.value.delete(paneId)
splitTree.value = nextTree
if (activePaneId.value === paneId) {
const nextPaneId = leafPaneIds(nextTree)[0] ?? initialPane.id
const nextPane = getPane(nextPaneId)
if (nextPane) syncStateFromPane(nextPane)
activePaneId.value = nextPaneId
state.renderVersion++
}
triggerRef(panes)
return true
}
function resizePane(paneId: string, width: number, height: number): void {
const pane = getPane(paneId)
if (!pane) return
pane.viewportWidth = width
pane.viewportHeight = height
}
function setSplitSizes(splitId: string, sizes: number[]): void {
splitTree.value = updateSplitSizes(splitTree.value, splitId, sizes)
}
return {
panes,
activePaneId,
splitTree,
visiblePaneCount,
getPane,
getPaneRenderState,
getActivePane,
setActivePane,
splitPane,
closePane,
resizePane,
setSplitSizes,
maxVisiblePanes: MAX_VISIBLE_CANVAS_PANES
}
}
export type CanvasPaneRegistry = ReturnType<typeof createCanvasPaneRegistry>

View file

@ -0,0 +1,96 @@
export const MAX_VISIBLE_CANVAS_PANES = 4
export type SplitDirection = 'horizontal' | 'vertical'
export type CanvasSplitNode =
| { type: 'pane'; paneId: string }
| {
type: 'split'
id: string
direction: SplitDirection
children: CanvasSplitNode[]
sizes: number[]
}
export function paneCount(node: CanvasSplitNode): number {
return node.type === 'pane'
? 1
: node.children.reduce((count, child) => count + paneCount(child), 0)
}
export function leafPaneIds(node: CanvasSplitNode): string[] {
return node.type === 'pane' ? [node.paneId] : node.children.flatMap(leafPaneIds)
}
export function containsPane(node: CanvasSplitNode, paneId: string): boolean {
return node.type === 'pane'
? node.paneId === paneId
: node.children.some((child) => containsPane(child, paneId))
}
export function normalizeSplitSizes(length: number, sizes?: number[]): number[] {
if (length <= 0) return []
if (
!sizes ||
sizes.length !== length ||
!sizes.every((size) => Number.isFinite(size) && size > 0)
) {
return Array.from({ length }, () => 100 / length)
}
const total = sizes.reduce((sum, size) => sum + size, 0)
return sizes.map((size) => (size / total) * 100)
}
export function splitPaneNode(
node: CanvasSplitNode,
paneId: string,
newPaneId: string,
splitId: string,
direction: SplitDirection
): CanvasSplitNode {
if (node.type === 'pane') {
return node.paneId === paneId
? {
type: 'split',
id: splitId,
direction,
children: [node, { type: 'pane', paneId: newPaneId }],
sizes: [50, 50]
}
: node
}
return {
...node,
children: node.children.map((child) =>
splitPaneNode(child, paneId, newPaneId, splitId, direction)
),
sizes: normalizeSplitSizes(node.children.length, node.sizes)
}
}
export function closePaneNode(node: CanvasSplitNode, paneId: string): CanvasSplitNode | null {
if (node.type === 'pane') return node.paneId === paneId ? null : node
const children = node.children
.map((child) => closePaneNode(child, paneId))
.filter((child): child is CanvasSplitNode => child !== null)
if (children.length === 0) return null
if (children.length === 1) return children[0]
return { ...node, children, sizes: normalizeSplitSizes(children.length) }
}
export function updateSplitSizes(
node: CanvasSplitNode,
splitId: string,
sizes: number[]
): CanvasSplitNode {
if (node.type === 'pane') return node
if (node.id === splitId) {
if (sizes.length !== node.children.length) return node
if (!sizes.every((size) => Number.isFinite(size) && size > 0)) return node
return { ...node, sizes: normalizeSplitSizes(node.children.length, sizes) }
}
return {
...node,
children: node.children.map((child) => updateSplitSizes(child, splitId, sizes))
}
}

View file

@ -0,0 +1,49 @@
import { shallowReactive } from 'vue'
import type { EditorState, EditorViewState } from '@open-pencil/core/editor'
import { copyEditorViewState, pickEditorViewState } from '@open-pencil/core/editor'
export interface CanvasPaneState extends EditorViewState {
id: string
viewportWidth: number
viewportHeight: number
}
export function createCanvasPaneState(
id: string,
state: EditorState,
overrides: Partial<EditorViewState> = {}
): CanvasPaneState {
const view = copyEditorViewState({ ...pickEditorViewState(state), ...overrides })
return shallowReactive({
...view,
id,
viewportWidth: 0,
viewportHeight: 0
})
}
export function cloneCanvasPaneState(id: string, source: CanvasPaneState): CanvasPaneState {
return shallowReactive({
...copyEditorViewState(source),
id,
selectedIds: new Set<string>(),
hoveredNodeId: null,
measurementMode: 'off',
editingTextId: null,
marquee: null,
snapGuides: [],
rotationPreview: null,
dropTargetId: null,
layoutInsertIndicator: null,
autoLayoutHover: null,
penState: null,
penCursorX: null,
penCursorY: null,
nodeEditState: null,
cursorCanvasX: null,
cursorCanvasY: null,
viewportWidth: source.viewportWidth,
viewportHeight: source.viewportHeight
})
}

View file

@ -12,6 +12,7 @@ import {
import { resolveFigmaClipboardImages } from '@/app/editor/clipboard/figma-images' import { resolveFigmaClipboardImages } from '@/app/editor/clipboard/figma-images'
import { bindClipboardNotifications } from '@/app/editor/clipboard/notifications' import { bindClipboardNotifications } from '@/app/editor/clipboard/notifications'
import { loadFont } from '@/app/editor/fonts' import { loadFont } from '@/app/editor/fonts'
import { createCanvasPaneRegistry } from '@/app/editor/panes/registry'
import { import {
createEditorComputedRefs, createEditorComputedRefs,
createEditorStoreModules, createEditorStoreModules,
@ -54,12 +55,24 @@ export function createEditorStore(initialGraph?: SceneGraph) {
// ─── Public API ─────────────────────────────────────────────── // ─── Public API ───────────────────────────────────────────────
// Spread all core Editor methods, then override getters and add app-specific. // Spread all core Editor methods, then override getters and add app-specific.
const panes = createCanvasPaneRegistry(state)
const store = { const store = {
...editor, ...editor,
state, state,
panes,
selectedNodes, selectedNodes,
selectedNode, selectedNode,
layerTree, layerTree,
splitTree: panes.splitTree,
activePaneId: panes.activePaneId,
visiblePaneCount: panes.visiblePaneCount,
getPaneRenderState: panes.getPaneRenderState,
setActivePane: panes.setActivePane,
splitPane: panes.splitPane,
closePane: panes.closePane,
resizePane: panes.resizePane,
setSplitSizes: panes.setSplitSizes,
// App-specific overrides and additions // App-specific overrides and additions
...modules ...modules

View file

@ -68,6 +68,8 @@ export function useAppMenu() {
'theme-auto': 'themeAuto', 'theme-auto': 'themeAuto',
'zoom-in': 'zoomIn', 'zoom-in': 'zoomIn',
'zoom-out': 'zoomOut', 'zoom-out': 'zoomOut',
'view-split-right': 'splitRight',
'view-split-down': 'splitDown',
'text.bold': 'bold', 'text.bold': 'bold',
'text.italic': 'italic', 'text.italic': 'italic',
'text.underline': 'underline', 'text.underline': 'underline',
@ -166,6 +168,16 @@ export function useAppMenu() {
} }
} }
function disabled(item: AppMenuActionItem): boolean | undefined {
switch (item.id) {
case 'view-split-right':
case 'view-split-down':
return store.visiblePaneCount.value >= store.panes.maxVisiblePanes
default:
return undefined
}
}
function menuLabel(entry: AppMenuActionItem): string { function menuLabel(entry: AppMenuActionItem): string {
const key = translatedMenuItemLabels[entry.id] const key = translatedMenuItemLabels[entry.id]
return key ? menu.value[key] : entry.label return key ? menu.value[key] : entry.label
@ -201,6 +213,7 @@ export function useAppMenu() {
label: menuLabel(entry), label: menuLabel(entry),
shortcut: appMenuShortcutLabel(entry.id), shortcut: appMenuShortcutLabel(entry.id),
action: itemAction(entry), action: itemAction(entry),
disabled: disabled(entry),
checked: checked(entry), checked: checked(entry),
onCheckedChange: onCheckedChange(entry), onCheckedChange: onCheckedChange(entry),
sub: entry.sub?.map(buildEntry).filter((item): item is MenuEntry => item !== null) sub: entry.sub?.map(buildEntry).filter((item): item is MenuEntry => item !== null)

View file

@ -52,6 +52,8 @@ export function createSharedEditorMenuActions(
return { return {
'zoom-in': () => store.applyZoom(-100, window.innerWidth / 2, window.innerHeight / 2), 'zoom-in': () => store.applyZoom(-100, window.innerWidth / 2, window.innerHeight / 2),
'zoom-out': () => store.applyZoom(100, window.innerWidth / 2, window.innerHeight / 2), 'zoom-out': () => store.applyZoom(100, window.innerWidth / 2, window.innerHeight / 2),
'view-split-right': () => store.splitPane(store.activePaneId.value, 'horizontal'),
'view-split-down': () => store.splitPane(store.activePaneId.value, 'vertical'),
'view-rulers': () => { 'view-rulers': () => {
store.state.showRulers = !store.state.showRulers store.state.showRulers = !store.state.showRulers
store.requestRepaint() store.requestRepaint()

View file

@ -119,6 +119,9 @@ export const APP_MENU_SCHEMA = [
{ id: 'zoom-in', label: 'Zoom In', shortcut: 'MOD+=' }, { id: 'zoom-in', label: 'Zoom In', shortcut: 'MOD+=' },
{ id: 'zoom-out', label: 'Zoom Out', shortcut: 'MOD+-' }, { id: 'zoom-out', label: 'Zoom Out', shortcut: 'MOD+-' },
{ type: 'separator' }, { type: 'separator' },
{ id: 'view-split-right', label: 'Split Right' },
{ id: 'view-split-down', label: 'Split Down' },
{ type: 'separator' },
{ id: 'view-rulers', label: 'Rulers', checkbox: true }, { id: 'view-rulers', label: 'Rulers', checkbox: true },
{ id: 'view-multiplayer-cursors', label: 'Multiplayer Cursors', checkbox: true }, { id: 'view-multiplayer-cursors', label: 'Multiplayer Cursors', checkbox: true },
{ type: 'separator' }, { type: 'separator' },

View file

@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref, type Component } from 'vue' import { computed, onUnmounted, ref, watch, type Component } from 'vue'
import { import {
AUTO_LAYOUT_PADDING_EDITOR_OFFSET_X, AUTO_LAYOUT_PADDING_EDITOR_OFFSET_X,
AUTO_LAYOUT_PADDING_EDITOR_OFFSET_Y AUTO_LAYOUT_PADDING_EDITOR_OFFSET_Y
@ -32,23 +32,46 @@ import IconLucidePanelTop from '~icons/lucide/panel-top'
import CanvasMenu from './canvas/CanvasMenu.vue' import CanvasMenu from './canvas/CanvasMenu.vue'
import NumberField from './inputs/NumberField.vue' import NumberField from './inputs/NumberField.vue'
const { paneId } = defineProps<{
paneId?: string
}>()
const store = useEditorStore() const store = useEditorStore()
const collab = useCollabInjected() const collab = useCollabInjected()
const sceneCanvasRef = ref<HTMLCanvasElement | null>(null) const sceneCanvasRef = ref<HTMLCanvasElement | null>(null)
const canvasRef = ref<HTMLCanvasElement | null>(null) const canvasRef = ref<HTMLCanvasElement | null>(null)
const isActivePane = computed(() => !paneId || store.activePaneId.value === paneId)
function activatePane() {
if (paneId) store.setActivePane(paneId)
}
function updatePaneCursor(cx: number, cy: number) {
if (isActivePane.value) updateCursor(cx, cy)
}
const getRenderState = paneId ? () => store.getPaneRenderState(paneId) : undefined
const onViewportResize = paneId
? (width: number, height: number) => store.resizePane(paneId, width, height)
: undefined
const { updateCursor } = useCanvasCollaborationAwareness(store, collab) const { updateCursor } = useCanvasCollaborationAwareness(store, collab)
const { selectAtContextPoint } = createCanvasContextSelection(canvasRef, store) const { selectAtContextPoint } = createCanvasContextSelection(canvasRef, store)
useCanvas(sceneCanvasRef, store, { useCanvas(sceneCanvasRef, store, {
layer: 'scene', layer: 'scene',
showRulers: false showRulers: false,
getRenderState,
onViewportResize
}) })
const { hitTestSectionTitle, hitTestComponentLabel, hitTestFrameTitle } = useCanvas( const { hitTestSectionTitle, hitTestComponentLabel, hitTestFrameTitle } = useCanvas(
canvasRef, canvasRef,
store, store,
{ {
layer: 'overlays' layer: 'overlays',
getRenderState,
onViewportResize
} }
) )
const { const {
@ -56,18 +79,26 @@ const {
autoLayoutPaddingEdit, autoLayoutPaddingEdit,
updateAutoLayoutPaddingEdit, updateAutoLayoutPaddingEdit,
commitAutoLayoutPaddingEdit, commitAutoLayoutPaddingEdit,
cancelAutoLayoutPaddingEdit cancelAutoLayoutPaddingEdit,
cleanupInteractions
} = useCanvasInput( } = useCanvasInput(
canvasRef, canvasRef,
store, store,
hitTestSectionTitle, hitTestSectionTitle,
hitTestComponentLabel, hitTestComponentLabel,
hitTestFrameTitle, hitTestFrameTitle,
updateCursor updatePaneCursor,
activatePane,
() => isActivePane.value
) )
useTextEdit(canvasRef, store) watch(isActivePane, (active) => {
const { isDraggingOver } = useCanvasDrop(canvasRef, store) if (!active) cleanupInteractions()
})
onUnmounted(cleanupInteractions)
useTextEdit(canvasRef, store, { isEnabled: () => isActivePane.value })
const { isDraggingOver } = useCanvasDrop(canvasRef, store, activatePane)
const paddingSideIcons = { const paddingSideIcons = {
top: IconLucidePanelTop, top: IconLucidePanelTop,
@ -103,16 +134,24 @@ const cursor = computed(() => toolCursor(store.state.activeTool, cursorOverride.
<ContextMenuTrigger as-child @contextmenu="selectAtContextPoint"> <ContextMenuTrigger as-child @contextmenu="selectAtContextPoint">
<div <div
data-test-id="canvas-area" data-test-id="canvas-area"
:data-pane-id="paneId"
:data-active-pane="isActivePane ? 'true' : 'false'"
class="canvas-area relative min-h-0 min-w-0 flex-1 overflow-hidden" class="canvas-area relative min-h-0 min-w-0 flex-1 overflow-hidden"
@pointerdown.capture="activatePane"
@focusin.capture="activatePane"
@wheel.capture="activatePane"
@dragenter.capture="activatePane"
> >
<canvas <canvas
ref="sceneCanvasRef" ref="sceneCanvasRef"
:data-pane-id="paneId"
data-test-id="scene-canvas-element" data-test-id="scene-canvas-element"
aria-hidden="true" aria-hidden="true"
class="pointer-events-none absolute inset-0 size-full outline-none" class="pointer-events-none absolute inset-0 size-full outline-none"
/> />
<canvas <canvas
ref="canvasRef" ref="canvasRef"
:data-pane-id="paneId"
data-test-id="canvas-element" data-test-id="canvas-element"
tabindex="-1" tabindex="-1"
:style="{ cursor }" :style="{ cursor }"

View file

@ -1,11 +1,13 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
import { tv } from 'tailwind-variants'
import { SplitterGroup, SplitterPanel, SplitterResizeHandle } from 'reka-ui' import { SplitterGroup, SplitterPanel, SplitterResizeHandle } from 'reka-ui'
import { useI18n } from '@open-pencil/vue' import { useI18n } from '@open-pencil/vue'
import AppMenu from '@/components/Shell/AppMenu.vue' import AppMenu from '@/components/Shell/AppMenu.vue'
import SegmentedControl from '@/components/ui/SegmentedControl.vue' import SegmentedControl from '@/components/ui/SegmentedControl.vue'
import splitterTheme from '@/theme/splitter'
import AssetsPanel from './assets-panel/AssetsPanel.vue' import AssetsPanel from './assets-panel/AssetsPanel.vue'
import LayerTree from './LayerTree/LayerTree.vue' import LayerTree from './LayerTree/LayerTree.vue'
import PagesPanel from './PagesPanel.vue' import PagesPanel from './PagesPanel.vue'
@ -23,6 +25,7 @@ const panelOptions = computed(() => [
{ value: 'assets', label: panels.value.assets } { value: 'assets', label: panels.value.assets }
]) ])
const panelTabsUI = { root: 'w-full' } const panelTabsUI = { root: 'w-full' }
const splitterStyles = tv(splitterTheme)({ direction: 'vertical' })
</script> </script>
<template> <template>
@ -66,10 +69,8 @@ const panelTabsUI = { root: 'w-full' }
> >
<PagesPanel /> <PagesPanel />
</SplitterPanel> </SplitterPanel>
<SplitterResizeHandle class="group relative z-10 -my-1 h-2 cursor-row-resize"> <SplitterResizeHandle :class="splitterStyles.handle()">
<div <div :class="splitterStyles.divider()" />
class="pointer-events-none absolute inset-x-0 top-1/2 h-px -translate-y-1/2 bg-border"
/>
</SplitterResizeHandle> </SplitterResizeHandle>
<SplitterPanel :default-size="70" :min-size="20" class="flex flex-col overflow-hidden"> <SplitterPanel :default-size="70" :min-size="20" class="flex flex-col overflow-hidden">
<header <header

View file

@ -0,0 +1,90 @@
<script setup lang="ts">
import { computed } from 'vue'
import { tv } from 'tailwind-variants'
import {
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuPortal,
DropdownMenuRoot,
DropdownMenuTrigger
} from 'reka-ui'
import { useI18n } from '@open-pencil/vue'
import { useEditorStore } from '@/app/editor/active-store'
import type { SplitDirection } from '@/app/editor/panes/split-tree'
import IconButton from '@/components/ui/IconButton.vue'
import { menuItem, useMenuUI } from '@/components/ui/menu'
import canvasPaneHeaderTheme from '@/theme/canvas-pane-header'
const { paneId } = defineProps<{
paneId: string
}>()
const store = useEditorStore()
const { menu: menuText } = useI18n()
const pane = computed(() => store.panes.getPane(paneId))
const isActive = computed(() => store.activePaneId.value === paneId)
const pageName = computed(() => {
const pageId = pane.value?.currentPageId
return pageId ? (store.graph.getNode(pageId)?.name ?? menuText.value.view) : menuText.value.view
})
const zoom = computed(() => Math.round((pane.value?.zoom ?? 1) * 100))
const canSplit = computed(() => store.visiblePaneCount.value < store.panes.maxVisiblePanes)
const canClose = computed(() => store.visiblePaneCount.value > 1)
const menuCls = useMenuUI({ content: 'min-w-40' })
const itemCls = menuItem({ justify: 'start' })
const headerStyles = tv(canvasPaneHeaderTheme)
const headerCls = computed(() => headerStyles({ active: isActive.value }))
function activatePane() {
store.setActivePane(paneId)
}
function split(direction: SplitDirection) {
activatePane()
store.splitPane(paneId, direction)
}
</script>
<template>
<div
data-slot="canvas-pane-header"
:data-active="isActive ? 'true' : 'false'"
:class="headerCls.root()"
@pointerdown="activatePane"
>
<span :class="headerCls.title()">{{ pageName }}</span>
<span :class="headerCls.zoom()">{{ zoom }}%</span>
<div :class="headerCls.actions()">
<DropdownMenuRoot>
<DropdownMenuTrigger as-child>
<IconButton :label="`${menuText.splitRight} / ${menuText.splitDown}`" size="xs">
<icon-lucide-panels-top-left :class="headerCls.icon()" />
</IconButton>
</DropdownMenuTrigger>
<DropdownMenuPortal>
<DropdownMenuContent side="bottom" align="end" :side-offset="3" :class="menuCls.content">
<DropdownMenuItem :disabled="!canSplit" :class="itemCls" @select="split('horizontal')">
<icon-lucide-columns-2 :class="menuCls.icon" />
<span>{{ menuText.splitRight }}</span>
</DropdownMenuItem>
<DropdownMenuItem :disabled="!canSplit" :class="itemCls" @select="split('vertical')">
<icon-lucide-rows-2 :class="menuCls.icon" />
<span>{{ menuText.splitDown }}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenuPortal>
</DropdownMenuRoot>
<IconButton
:label="menuText.closeView"
side="bottom"
size="xs"
:disabled="!canClose"
@click="store.closePane(paneId)"
>
<icon-lucide-x :class="headerCls.icon()" />
</IconButton>
</div>
</div>
</template>

View file

@ -0,0 +1,64 @@
<script setup lang="ts">
import { computed } from 'vue'
import { tv } from 'tailwind-variants'
import { SplitterGroup, SplitterPanel, SplitterResizeHandle } from 'reka-ui'
import { useEditorStore } from '@/app/editor/active-store'
import type { CanvasSplitNode } from '@/app/editor/panes/split-tree'
import CanvasPaneHeader from '@/components/canvas/CanvasPaneHeader.vue'
import EditorCanvas from '@/components/EditorCanvas.vue'
import splitterTheme from '@/theme/splitter'
const { node } = defineProps<{
node: CanvasSplitNode
}>()
const store = useEditorStore()
const direction = computed(() => (node.type === 'split' ? node.direction : 'horizontal'))
const splitterStyles = tv(splitterTheme)
function setLayout(sizes: number[]) {
if (node.type === 'split') store.setSplitSizes(node.id, sizes)
}
</script>
<template>
<div
v-if="node.type === 'pane'"
:data-pane-id="node.paneId"
class="relative flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden"
>
<CanvasPaneHeader v-if="store.visiblePaneCount.value > 1" :pane-id="node.paneId" />
<EditorCanvas :pane-id="node.paneId" />
</div>
<SplitterGroup
v-else
:id="node.id"
:direction="direction"
class="flex min-h-0 min-w-0 flex-1 overflow-hidden"
@layout="setLayout"
>
<template
v-for="(child, index) in node.children"
:key="child.type === 'pane' ? child.paneId : child.id"
>
<SplitterPanel
:id="`${node.id}-panel-${index}`"
:default-size="node.sizes[index]"
:min-size="12"
class="flex min-h-0 min-w-0 overflow-hidden"
>
<CanvasSplitNode :node="child" />
</SplitterPanel>
<SplitterResizeHandle
v-if="index < node.children.length - 1"
:id="`${node.id}-handle-${index}`"
:data-split-id="node.id"
:class="splitterStyles({ direction }).handle()"
>
<div :class="splitterStyles({ direction }).divider()" />
</SplitterResizeHandle>
</template>
</SplitterGroup>
</template>

View file

@ -0,0 +1,15 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useEditorStore } from '@/app/editor/active-store'
import CanvasSplitNode from '@/components/canvas/CanvasSplitNode.vue'
const store = useEditorStore()
const splitTree = computed(() => store.splitTree.value)
</script>
<template>
<div data-slot="canvas-split-root" class="relative flex min-h-0 min-w-0 flex-1 overflow-hidden">
<CanvasSplitNode :node="splitTree" />
</div>
</template>

View file

@ -0,0 +1,23 @@
const canvasPaneHeaderTheme = {
slots: {
root: 'relative flex h-7 shrink-0 items-center gap-1 border-b border-border bg-panel px-1.5 text-muted after:absolute after:inset-x-0 after:bottom-0 after:h-px after:bg-transparent',
title: 'min-w-0 flex-1 truncate px-1 text-[11px]',
zoom: 'shrink-0 px-1 text-[11px] tabular-nums text-muted',
actions: 'flex shrink-0 items-center gap-0.5',
icon: 'size-3.5'
},
variants: {
active: {
true: {
root: 'text-surface after:bg-accent'
},
false: {}
}
},
defaultVariants: {
active: false
}
}
export type CanvasPaneHeaderTheme = typeof canvasPaneHeaderTheme
export default canvasPaneHeaderTheme

23
src/theme/splitter.ts Normal file
View file

@ -0,0 +1,23 @@
const splitterTheme = {
slots: {
handle:
'group relative z-10 shrink-0 touch-none outline-none focus-visible:ring-1 focus-visible:ring-panel-focus',
divider:
'pointer-events-none absolute bg-border transition-colors group-data-[state=drag]:bg-accent group-data-[state=hover]:bg-accent group-focus-visible:bg-accent'
},
variants: {
direction: {
horizontal: {
handle: '-mx-1 w-2 cursor-col-resize',
divider: 'inset-y-0 left-1/2 w-px -translate-x-1/2'
},
vertical: {
handle: '-my-1 h-2 cursor-row-resize',
divider: 'inset-x-0 top-1/2 h-px -translate-y-1/2'
}
}
}
}
export type SplitterTheme = typeof splitterTheme
export default splitterTheme

View file

@ -1,5 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, onUnmounted, provide, ref } from 'vue' import { onMounted, onUnmounted, provide, ref } from 'vue'
import { tv } from 'tailwind-variants'
import { useEventListener, useUrlSearchParams } from '@vueuse/core' import { useEventListener, useUrlSearchParams } from '@vueuse/core'
import { useRoute } from 'vue-router' import { useRoute } from 'vue-router'
import { useHead } from '@unhead/vue' import { useHead } from '@unhead/vue'
@ -20,6 +21,7 @@ import { createTab, activeTab, getActiveStore, tabCount } from '@/app/tabs'
import CollabPanel from '@/components/CollabPanel/CollabPanel.vue' import CollabPanel from '@/components/CollabPanel/CollabPanel.vue'
import EditorCanvas from '@/components/EditorCanvas.vue' import EditorCanvas from '@/components/EditorCanvas.vue'
import CanvasSplitRoot from '@/components/canvas/CanvasSplitRoot.vue'
import FontStatusBanner from '@/components/font-status/FontStatusBanner.vue' import FontStatusBanner from '@/components/font-status/FontStatusBanner.vue'
import LayersPanel from '@/components/LayersPanel.vue' import LayersPanel from '@/components/LayersPanel.vue'
import MobileDrawer from '@/components/MobileDrawer.vue' import MobileDrawer from '@/components/MobileDrawer.vue'
@ -30,6 +32,7 @@ import SafariBanner from '@/components/SafariBanner.vue'
import TabBar from '@/components/TabBar.vue' import TabBar from '@/components/TabBar.vue'
import Tip from '@/components/ui/Tip.vue' import Tip from '@/components/ui/Tip.vue'
import Toolbar from '@/components/Toolbar/Toolbar.vue' import Toolbar from '@/components/Toolbar/Toolbar.vue'
import splitterTheme from '@/theme/splitter'
const route = useRoute() const route = useRoute()
const params = useUrlSearchParams('history') const params = useUrlSearchParams('history')
@ -65,6 +68,7 @@ const automationCleanup = ref<(() => void) | null>(null)
const mcpCleanup = ref<(() => void) | null>(null) const mcpCleanup = ref<(() => void) | null>(null)
const fileAssociationCleanup = ref<(() => void) | null>(null) const fileAssociationCleanup = ref<(() => void) | null>(null)
const initialEditorLayout = loadEditorLayout() const initialEditorLayout = loadEditorLayout()
const horizontalSplitterStyles = tv(splitterTheme)({ direction: 'horizontal' })
type PendingOpenFile = { type PendingOpenFile = {
path: string path: string
@ -135,18 +139,18 @@ onUnmounted(() => {
</SplitterPanel> </SplitterPanel>
<SplitterResizeHandle <SplitterResizeHandle
data-test-id="left-splitter-handle" data-test-id="left-splitter-handle"
class="group relative z-10 -mx-1 w-2 cursor-col-resize" :class="horizontalSplitterStyles.handle()"
> >
<div class="pointer-events-none absolute inset-y-0 left-1/2 w-px -translate-x-1/2" /> <div :class="horizontalSplitterStyles.divider()" />
</SplitterResizeHandle> </SplitterResizeHandle>
<SplitterPanel id="canvas" :default-size="initialEditorLayout[1]" :min-size="30" class="flex"> <SplitterPanel id="canvas" :default-size="initialEditorLayout[1]" :min-size="30" class="flex">
<div class="relative flex min-w-0 flex-1"> <div class="relative flex min-w-0 flex-1">
<EditorCanvas /> <CanvasSplitRoot />
<Toolbar /> <Toolbar />
</div> </div>
</SplitterPanel> </SplitterPanel>
<SplitterResizeHandle class="group relative z-10 -mx-1 w-2 cursor-col-resize"> <SplitterResizeHandle :class="horizontalSplitterStyles.handle()">
<div class="pointer-events-none absolute inset-y-0 left-1/2 w-px -translate-x-1/2" /> <div :class="horizontalSplitterStyles.divider()" />
</SplitterResizeHandle> </SplitterResizeHandle>
<SplitterPanel <SplitterPanel
id="properties" id="properties"

View file

@ -0,0 +1,54 @@
import { expect, test } from '@playwright/test'
import { CanvasHelper } from '#tests/helpers/canvas'
test.describe('split canvas views', () => {
test('splits, resizes, activates, and closes independent canvas views', async ({ page }) => {
await page.goto('/')
const canvas = new CanvasHelper(page)
await canvas.waitForInit()
await expect(page.locator('[data-slot="canvas-pane-header"]')).toHaveCount(0)
await canvas.drawRect(140, 180, 180, 120)
await page.getByRole('menuitem', { name: 'View', exact: true }).click()
await page.getByRole('menuitem', { name: 'Split right' }).click()
const headers = page.locator('[data-slot="canvas-pane-header"]')
await expect(headers).toHaveCount(2)
await expect(headers.nth(1)).toHaveAttribute('data-active', 'true')
await expect(headers.nth(1)).toContainText('Page 1')
await expect(headers.nth(1)).toContainText('100%')
const panes = page.locator('[data-active-pane]')
await expect(panes).toHaveCount(2)
await expect(panes.nth(1)).toHaveAttribute('data-active-pane', 'true')
await expect(page.locator('canvas[data-ready="1"]')).toHaveCount(4)
const firstBox = await panes.nth(0).boundingBox()
const secondBox = await panes.nth(1).boundingBox()
expect(firstBox?.width).toBeGreaterThan(300)
expect(secondBox?.width).toBeGreaterThan(300)
await panes.nth(0).click({ position: { x: 100, y: 100 } })
await expect(panes.nth(0)).toHaveAttribute('data-active-pane', 'true')
await expect(headers.nth(0)).toHaveAttribute('data-active', 'true')
const handle = page.locator('[data-split-id]').first()
const handleBox = await handle.boundingBox()
if (!handleBox) throw new Error('Expected split handle')
await page.mouse.move(handleBox.x, handleBox.y + handleBox.height / 2)
await page.mouse.down()
await page.mouse.move(handleBox.x + 100, handleBox.y + handleBox.height / 2)
await page.mouse.up()
const resizedFirstBox = await panes.nth(0).boundingBox()
const resizedSecondBox = await panes.nth(1).boundingBox()
expect(resizedFirstBox?.width).toBeGreaterThan(firstBox?.width ?? 0)
expect(resizedSecondBox?.width).toBeLessThan(secondBox?.width ?? Number.POSITIVE_INFINITY)
await headers.nth(0).getByRole('button', { name: 'Close view' }).click()
await expect(panes).toHaveCount(1)
await expect(headers).toHaveCount(0)
await expect(page.locator('canvas[data-ready="1"]')).toHaveCount(2)
})
})

View file

@ -0,0 +1,66 @@
import { describe, expect, test } from 'bun:test'
import { createDefaultEditorState } from '@open-pencil/core/editor'
import { createCanvasPaneRegistry } from '@/app/editor/panes/registry'
import {
closePaneNode,
leafPaneIds,
MAX_VISIBLE_CANVAS_PANES,
normalizeSplitSizes,
paneCount,
splitPaneNode,
updateSplitSizes
} from '@/app/editor/panes/split-tree'
import type { CanvasSplitNode } from '@/app/editor/panes/split-tree'
describe('canvas split tree', () => {
test('splits panes and collapses one-child parents', () => {
const initial: CanvasSplitNode = { type: 'pane', paneId: 'a' }
const split = splitPaneNode(initial, 'a', 'b', 'split-1', 'horizontal')
expect(paneCount(split)).toBe(2)
expect(leafPaneIds(split)).toEqual(['a', 'b'])
expect(closePaneNode(split, 'a')).toEqual({ type: 'pane', paneId: 'b' })
})
test('normalizes valid sizes and rejects invalid updates', () => {
const split = splitPaneNode({ type: 'pane', paneId: 'a' }, 'a', 'b', 'split-1', 'vertical')
expect(normalizeSplitSizes(2, [1, 3])).toEqual([25, 75])
expect(updateSplitSizes(split, 'split-1', [30, 70])).toMatchObject({ sizes: [30, 70] })
expect(updateSplitSizes(split, 'split-1', [100])).toEqual(split)
})
})
describe('canvas pane registry', () => {
test('clones view state without cloning selection or transient interaction state', () => {
const state = createDefaultEditorState('page')
state.selectedIds = new Set(['selected'])
state.hoveredNodeId = 'hovered'
const registry = createCanvasPaneRegistry(state)
const first = registry.getActivePane()
const second = registry.splitPane(first.id, 'horizontal')
expect(second?.currentPageId).toBe('page')
expect(second?.selectedIds.size).toBe(0)
expect(second?.hoveredNodeId).toBeNull()
expect(registry.visiblePaneCount.value).toBe(2)
expect(state.selectedIds.size).toBe(0)
state.panX = 120
expect(registry.setActivePane(first.id)).toBe(true)
expect(second?.panX).toBe(120)
expect(state.selectedIds).toEqual(new Set(['selected']))
expect(state.panX).toBe(0)
})
test('refuses the last close and enforces the pane cap', () => {
const registry = createCanvasPaneRegistry(createDefaultEditorState('page'))
expect(registry.closePane(registry.activePaneId.value)).toBe(false)
while (registry.visiblePaneCount.value < MAX_VISIBLE_CANVAS_PANES) {
expect(registry.splitPane(registry.activePaneId.value, 'horizontal')).not.toBeNull()
}
expect(registry.splitPane(registry.activePaneId.value, 'vertical')).toBeNull()
})
})

View file

@ -185,6 +185,37 @@ describe('canvas render loop', () => {
} }
}) })
test('repaint events dirty every canvas view even when its local version is unchanged', () => {
const scheduler = createFrameScheduler()
try {
const { editor, emit } = createEditor()
const firstView = { ...editor.state, selectedIds: new Set<string>() }
const secondView = { ...editor.state, selectedIds: new Set<string>() }
let firstRenders = 0
let secondRenders = 0
const firstLoop = createCanvasRenderLoop(editor, () => firstRenders++, {
layer: 'scene',
getRenderState: () => firstView
})
const secondLoop = createCanvasRenderLoop(editor, () => secondRenders++, {
layer: 'scene',
getRenderState: () => secondView
})
emit('repaint:requested')
scheduler.flush()
firstLoop.markRendered()
secondLoop.markRendered()
expect([firstRenders, secondRenders]).toEqual([1, 1])
emit('repaint:requested')
scheduler.flush()
expect([firstRenders, secondRenders]).toEqual([2, 2])
} finally {
scheduler.restore()
}
})
test('reads versions and selection from the supplied canvas view state', () => { test('reads versions and selection from the supplied canvas view state', () => {
const scheduler = createFrameScheduler() const scheduler = createFrameScheduler()
try { try {
@ -211,12 +242,12 @@ describe('canvas render loop', () => {
emit('repaint:requested') emit('repaint:requested')
scheduler.flush() scheduler.flush()
expect(renders).toBe(1) expect(renders).toBe(2)
viewState.renderVersion++ viewState.renderVersion++
emit('repaint:requested') emit('repaint:requested')
scheduler.flush() scheduler.flush()
expect(renders).toBe(2) expect(renders).toBe(3)
} finally { } finally {
scheduler.restore() scheduler.restore()
} }