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:
parent
6193b6da7e
commit
7a02ee2c62
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -58,5 +58,8 @@
|
||||||
"multiplayerCursors": "マルチプレイヤーカーソル",
|
"multiplayerCursors": "マルチプレイヤーカーソル",
|
||||||
"renameSelection": "選択範囲の名前を変更…",
|
"renameSelection": "選択範囲の名前を変更…",
|
||||||
"rulers": "ルーラー",
|
"rulers": "ルーラー",
|
||||||
"settings": "設定…"
|
"settings": "設定…",
|
||||||
|
"splitRight": "右に分割",
|
||||||
|
"splitDown": "下に分割",
|
||||||
|
"closeView": "ビューを閉じる"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -58,5 +58,8 @@
|
||||||
"multiplayerCursors": "Курсоры участников",
|
"multiplayerCursors": "Курсоры участников",
|
||||||
"renameSelection": "Переименовать выделение…",
|
"renameSelection": "Переименовать выделение…",
|
||||||
"rulers": "Линейки",
|
"rulers": "Линейки",
|
||||||
"settings": "Настройки…"
|
"settings": "Настройки…",
|
||||||
|
"splitRight": "Разделить вправо",
|
||||||
|
"splitDown": "Разделить вниз",
|
||||||
|
"closeView": "Закрыть вид"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -58,5 +58,8 @@
|
||||||
"multiplayerCursors": "多人光标",
|
"multiplayerCursors": "多人光标",
|
||||||
"renameSelection": "重命名所选内容…",
|
"renameSelection": "重命名所选内容…",
|
||||||
"rulers": "标尺",
|
"rulers": "标尺",
|
||||||
"settings": "设置…"
|
"settings": "设置…",
|
||||||
|
"splitRight": "向右拆分",
|
||||||
|
"splitDown": "向下拆分",
|
||||||
|
"closeView": "关闭视图"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
125
src/app/editor/panes/registry.ts
Normal file
125
src/app/editor/panes/registry.ts
Normal 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>
|
||||||
96
src/app/editor/panes/split-tree.ts
Normal file
96
src/app/editor/panes/split-tree.ts
Normal 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))
|
||||||
|
}
|
||||||
|
}
|
||||||
49
src/app/editor/panes/state.ts
Normal file
49
src/app/editor/panes/state.ts
Normal 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
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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' },
|
||||||
|
|
|
||||||
|
|
@ -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 }"
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
90
src/components/canvas/CanvasPaneHeader.vue
Normal file
90
src/components/canvas/CanvasPaneHeader.vue
Normal 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>
|
||||||
64
src/components/canvas/CanvasSplitNode.vue
Normal file
64
src/components/canvas/CanvasSplitNode.vue
Normal 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>
|
||||||
15
src/components/canvas/CanvasSplitRoot.vue
Normal file
15
src/components/canvas/CanvasSplitRoot.vue
Normal 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>
|
||||||
23
src/theme/canvas-pane-header.ts
Normal file
23
src/theme/canvas-pane-header.ts
Normal 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
23
src/theme/splitter.ts
Normal 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
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
54
tests/e2e/canvas/split-views.spec.ts
Normal file
54
tests/e2e/canvas/split-views.spec.ts
Normal 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)
|
||||||
|
})
|
||||||
|
})
|
||||||
66
tests/engine/app/editor/panes.test.ts
Normal file
66
tests/engine/app/editor/panes.test.ts
Normal 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()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -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()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue