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
|
||||
|
||||
- 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.
|
||||
|
||||
- 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)
|
||||
- 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)
|
||||
|
|
|
|||
|
|
@ -164,6 +164,17 @@
|
|||
{
|
||||
"type": "separator"
|
||||
},
|
||||
{
|
||||
"id": "view-split-right",
|
||||
"label": "Split Right"
|
||||
},
|
||||
{
|
||||
"id": "view-split-down",
|
||||
"label": "Split Down"
|
||||
},
|
||||
{
|
||||
"type": "separator"
|
||||
},
|
||||
{
|
||||
"id": "view-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)
|
||||
|
||||
useEventListener(canvasRef, 'dragover', (e: DragEvent) => {
|
||||
|
|
@ -52,6 +56,7 @@ export function useCanvasDrop(canvasRef: Ref<HTMLCanvasElement | null>, editor:
|
|||
|
||||
useEventListener(canvasRef, 'dragenter', (e: DragEvent) => {
|
||||
if (!hasComponentData(e) && !hasFileData(e)) return
|
||||
onActivate?.()
|
||||
e.preventDefault()
|
||||
isDraggingOver.value = true
|
||||
})
|
||||
|
|
@ -61,6 +66,7 @@ export function useCanvasDrop(canvasRef: Ref<HTMLCanvasElement | null>, editor:
|
|||
})
|
||||
|
||||
useEventListener(canvasRef, 'drop', (e: DragEvent) => {
|
||||
onActivate?.()
|
||||
e.preventDefault()
|
||||
isDraggingOver.value = false
|
||||
|
||||
|
|
|
|||
|
|
@ -88,12 +88,16 @@ export function createCanvasRenderLoop(
|
|||
scheduler.schedule(renderFrame)
|
||||
}
|
||||
|
||||
const unsubscribe = [
|
||||
editor.onEditorEvent('render:requested', scheduleFrame),
|
||||
editor.onEditorEvent('viewport:changed', scheduleFrame)
|
||||
]
|
||||
const scheduleDirtyFrame = () => {
|
||||
dirty = true
|
||||
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)) {
|
||||
unsubscribe.push(editor.onEditorEvent('selection:changed', scheduleFrame))
|
||||
|
|
|
|||
|
|
@ -26,17 +26,20 @@ export function useTextEditingSession({
|
|||
textareaRef,
|
||||
resetBlink,
|
||||
stopBlink,
|
||||
resetComposition
|
||||
resetComposition,
|
||||
isEnabled
|
||||
}: {
|
||||
store: Editor
|
||||
textareaRef: ShallowRef<HTMLTextAreaElement | null>
|
||||
resetBlink: () => void
|
||||
stopBlink: () => void
|
||||
resetComposition: () => void
|
||||
isEnabled?: () => boolean
|
||||
}) {
|
||||
watch(
|
||||
() => store.state.editingTextId,
|
||||
(id, _, onCleanup) => {
|
||||
() => [store.state.editingTextId, isEnabled?.() ?? true] as const,
|
||||
([id, enabled], _, onCleanup) => {
|
||||
if (!enabled) return
|
||||
if (id) {
|
||||
const el = createHiddenTextArea()
|
||||
textareaRef.value = el
|
||||
|
|
|
|||
|
|
@ -16,7 +16,11 @@ import { focusTextAreaOnCanvasPointerDown, useTextEditingSession } from './texta
|
|||
* blinking, keyboard editing behavior, text formatting shortcuts, and syncing
|
||||
* 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 { resetBlink, stopBlink } = createCaretBlink(store)
|
||||
const {
|
||||
|
|
@ -77,5 +81,12 @@ export function useTextEdit(canvasRef: Ref<HTMLCanvasElement | null>, store: Edi
|
|||
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,
|
||||
hitTestComponentLabel: (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 cursorOverride = ref<string | null>(null)
|
||||
|
|
@ -96,6 +98,7 @@ export function useCanvasInput(
|
|||
}
|
||||
|
||||
function updateModifier(code: string, held: boolean) {
|
||||
if (!isEnabled()) return
|
||||
if (code === 'AltLeft' || code === 'AltRight') altHeld = held
|
||||
if (code === 'MetaLeft' || code === 'MetaRight') metaHeld = held
|
||||
if (code === 'ControlLeft' || code === 'ControlRight') controlHeld = held
|
||||
|
|
@ -200,6 +203,8 @@ export function useCanvasInput(
|
|||
}
|
||||
|
||||
function onMouseDown(e: MouseEvent) {
|
||||
onActivate?.()
|
||||
if (!isEnabled()) return
|
||||
editor.setMeasurementMode('off')
|
||||
const paddingEdit = autoLayoutPaddingEdit.value
|
||||
if (paddingEdit) {
|
||||
|
|
@ -228,6 +233,7 @@ export function useCanvasInput(
|
|||
}
|
||||
|
||||
function onMouseMove(e: MouseEvent) {
|
||||
if (!isEnabled()) return
|
||||
pointerInside.value = true
|
||||
const coords = getCoords(e)
|
||||
lastPointer.value = { cx: coords.cx, cy: coords.cy }
|
||||
|
|
@ -310,6 +316,7 @@ export function useCanvasInput(
|
|||
}
|
||||
|
||||
function onMouseUp() {
|
||||
if (!isEnabled()) return
|
||||
if (!drag.value) return
|
||||
const d = drag.value
|
||||
|
||||
|
|
@ -355,6 +362,7 @@ export function useCanvasInput(
|
|||
useEventListener(window, 'blur', resetMeasurementModifiers)
|
||||
useEventListener(canvasRef, 'mouseleave', () => {
|
||||
pointerInside.value = false
|
||||
if (!isEnabled()) return
|
||||
editor.setMeasurementMode('off')
|
||||
if (!drag.value) {
|
||||
editor.setHoveredNode(null)
|
||||
|
|
@ -365,6 +373,7 @@ export function useCanvasInput(
|
|||
})
|
||||
|
||||
const stopToolListener = editor.onEditorEvent('tool:changed', () => {
|
||||
if (!isEnabled()) return
|
||||
editor.setMeasurementMode('off')
|
||||
})
|
||||
onScopeDispose(stopToolListener)
|
||||
|
|
@ -376,6 +385,13 @@ export function useCanvasInput(
|
|||
autoLayoutPaddingEdit,
|
||||
updateAutoLayoutPaddingEdit,
|
||||
commitAutoLayoutPaddingEdit,
|
||||
cancelAutoLayoutPaddingEdit
|
||||
cancelAutoLayoutPaddingEdit,
|
||||
cleanupInteractions() {
|
||||
cancelAutoLayoutPaddingEdit()
|
||||
drag.value = null
|
||||
cursorOverride.value = null
|
||||
pointerInside.value = false
|
||||
resetMeasurementModifiers()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -58,5 +58,8 @@
|
|||
"multiplayerCursors": "Multiplayer-Cursor",
|
||||
"renameSelection": "Auswahl umbenennen…",
|
||||
"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",
|
||||
"renameSelection": "Renombrar selección…",
|
||||
"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",
|
||||
"renameSelection": "Renommer la sélection…",
|
||||
"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",
|
||||
"renameSelection": "Rinomina selezione…",
|
||||
"rulers": "Righelli",
|
||||
"settings": "Impostazioni…"
|
||||
"settings": "Impostazioni…",
|
||||
"splitRight": "Dividi a destra",
|
||||
"splitDown": "Dividi in basso",
|
||||
"closeView": "Chiudi vista"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -58,5 +58,8 @@
|
|||
"multiplayerCursors": "マルチプレイヤーカーソル",
|
||||
"renameSelection": "選択範囲の名前を変更…",
|
||||
"rulers": "ルーラー",
|
||||
"settings": "設定…"
|
||||
"settings": "設定…",
|
||||
"splitRight": "右に分割",
|
||||
"splitDown": "下に分割",
|
||||
"closeView": "ビューを閉じる"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -58,5 +58,8 @@
|
|||
"multiplayerCursors": "Kursory wielu użytkowników",
|
||||
"renameSelection": "Zmień nazwę zaznaczenia…",
|
||||
"rulers": "Linijki",
|
||||
"settings": "Ustawienia…"
|
||||
"settings": "Ustawienia…",
|
||||
"splitRight": "Podziel w prawo",
|
||||
"splitDown": "Podziel w dół",
|
||||
"closeView": "Zamknij widok"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -58,5 +58,8 @@
|
|||
"multiplayerCursors": "Курсоры участников",
|
||||
"renameSelection": "Переименовать выделение…",
|
||||
"rulers": "Линейки",
|
||||
"settings": "Настройки…"
|
||||
"settings": "Настройки…",
|
||||
"splitRight": "Разделить вправо",
|
||||
"splitDown": "Разделить вниз",
|
||||
"closeView": "Закрыть вид"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -58,5 +58,8 @@
|
|||
"multiplayerCursors": "多人光标",
|
||||
"renameSelection": "重命名所选内容…",
|
||||
"rulers": "标尺",
|
||||
"settings": "设置…"
|
||||
"settings": "设置…",
|
||||
"splitRight": "向右拆分",
|
||||
"splitDown": "向下拆分",
|
||||
"closeView": "关闭视图"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -66,7 +66,10 @@ export const menuMessageDefaults = {
|
|||
arrangeAlignMiddle: 'Align middle',
|
||||
arrangeAlignBottom: 'Align bottom',
|
||||
zoomIn: 'Zoom in',
|
||||
zoomOut: 'Zoom out'
|
||||
zoomOut: 'Zoom out',
|
||||
splitRight: 'Split right',
|
||||
splitDown: 'Split down',
|
||||
closeView: 'Close view'
|
||||
} as const
|
||||
|
||||
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 { bindClipboardNotifications } from '@/app/editor/clipboard/notifications'
|
||||
import { loadFont } from '@/app/editor/fonts'
|
||||
import { createCanvasPaneRegistry } from '@/app/editor/panes/registry'
|
||||
import {
|
||||
createEditorComputedRefs,
|
||||
createEditorStoreModules,
|
||||
|
|
@ -54,12 +55,24 @@ export function createEditorStore(initialGraph?: SceneGraph) {
|
|||
// ─── Public API ───────────────────────────────────────────────
|
||||
// Spread all core Editor methods, then override getters and add app-specific.
|
||||
|
||||
const panes = createCanvasPaneRegistry(state)
|
||||
|
||||
const store = {
|
||||
...editor,
|
||||
state,
|
||||
panes,
|
||||
selectedNodes,
|
||||
selectedNode,
|
||||
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
|
||||
...modules
|
||||
|
|
|
|||
|
|
@ -68,6 +68,8 @@ export function useAppMenu() {
|
|||
'theme-auto': 'themeAuto',
|
||||
'zoom-in': 'zoomIn',
|
||||
'zoom-out': 'zoomOut',
|
||||
'view-split-right': 'splitRight',
|
||||
'view-split-down': 'splitDown',
|
||||
'text.bold': 'bold',
|
||||
'text.italic': 'italic',
|
||||
'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 {
|
||||
const key = translatedMenuItemLabels[entry.id]
|
||||
return key ? menu.value[key] : entry.label
|
||||
|
|
@ -201,6 +213,7 @@ export function useAppMenu() {
|
|||
label: menuLabel(entry),
|
||||
shortcut: appMenuShortcutLabel(entry.id),
|
||||
action: itemAction(entry),
|
||||
disabled: disabled(entry),
|
||||
checked: checked(entry),
|
||||
onCheckedChange: onCheckedChange(entry),
|
||||
sub: entry.sub?.map(buildEntry).filter((item): item is MenuEntry => item !== null)
|
||||
|
|
|
|||
|
|
@ -52,6 +52,8 @@ export function createSharedEditorMenuActions(
|
|||
return {
|
||||
'zoom-in': () => 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': () => {
|
||||
store.state.showRulers = !store.state.showRulers
|
||||
store.requestRepaint()
|
||||
|
|
|
|||
|
|
@ -119,6 +119,9 @@ export const APP_MENU_SCHEMA = [
|
|||
{ id: 'zoom-in', label: 'Zoom In', shortcut: 'MOD+=' },
|
||||
{ id: 'zoom-out', label: 'Zoom Out', shortcut: 'MOD+-' },
|
||||
{ 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-multiplayer-cursors', label: 'Multiplayer Cursors', checkbox: true },
|
||||
{ type: 'separator' },
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
<script setup lang="ts">
|
||||
import { computed, ref, type Component } from 'vue'
|
||||
import { computed, onUnmounted, ref, watch, type Component } from 'vue'
|
||||
import {
|
||||
AUTO_LAYOUT_PADDING_EDITOR_OFFSET_X,
|
||||
AUTO_LAYOUT_PADDING_EDITOR_OFFSET_Y
|
||||
|
|
@ -32,23 +32,46 @@ import IconLucidePanelTop from '~icons/lucide/panel-top'
|
|||
import CanvasMenu from './canvas/CanvasMenu.vue'
|
||||
import NumberField from './inputs/NumberField.vue'
|
||||
|
||||
const { paneId } = defineProps<{
|
||||
paneId?: string
|
||||
}>()
|
||||
|
||||
const store = useEditorStore()
|
||||
const collab = useCollabInjected()
|
||||
const sceneCanvasRef = 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 { selectAtContextPoint } = createCanvasContextSelection(canvasRef, store)
|
||||
|
||||
useCanvas(sceneCanvasRef, store, {
|
||||
layer: 'scene',
|
||||
showRulers: false
|
||||
showRulers: false,
|
||||
getRenderState,
|
||||
onViewportResize
|
||||
})
|
||||
const { hitTestSectionTitle, hitTestComponentLabel, hitTestFrameTitle } = useCanvas(
|
||||
canvasRef,
|
||||
store,
|
||||
{
|
||||
layer: 'overlays'
|
||||
layer: 'overlays',
|
||||
getRenderState,
|
||||
onViewportResize
|
||||
}
|
||||
)
|
||||
const {
|
||||
|
|
@ -56,18 +79,26 @@ const {
|
|||
autoLayoutPaddingEdit,
|
||||
updateAutoLayoutPaddingEdit,
|
||||
commitAutoLayoutPaddingEdit,
|
||||
cancelAutoLayoutPaddingEdit
|
||||
cancelAutoLayoutPaddingEdit,
|
||||
cleanupInteractions
|
||||
} = useCanvasInput(
|
||||
canvasRef,
|
||||
store,
|
||||
hitTestSectionTitle,
|
||||
hitTestComponentLabel,
|
||||
hitTestFrameTitle,
|
||||
updateCursor
|
||||
updatePaneCursor,
|
||||
activatePane,
|
||||
() => isActivePane.value
|
||||
)
|
||||
|
||||
useTextEdit(canvasRef, store)
|
||||
const { isDraggingOver } = useCanvasDrop(canvasRef, store)
|
||||
watch(isActivePane, (active) => {
|
||||
if (!active) cleanupInteractions()
|
||||
})
|
||||
onUnmounted(cleanupInteractions)
|
||||
|
||||
useTextEdit(canvasRef, store, { isEnabled: () => isActivePane.value })
|
||||
const { isDraggingOver } = useCanvasDrop(canvasRef, store, activatePane)
|
||||
|
||||
const paddingSideIcons = {
|
||||
top: IconLucidePanelTop,
|
||||
|
|
@ -103,16 +134,24 @@ const cursor = computed(() => toolCursor(store.state.activeTool, cursorOverride.
|
|||
<ContextMenuTrigger as-child @contextmenu="selectAtContextPoint">
|
||||
<div
|
||||
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"
|
||||
@pointerdown.capture="activatePane"
|
||||
@focusin.capture="activatePane"
|
||||
@wheel.capture="activatePane"
|
||||
@dragenter.capture="activatePane"
|
||||
>
|
||||
<canvas
|
||||
ref="sceneCanvasRef"
|
||||
:data-pane-id="paneId"
|
||||
data-test-id="scene-canvas-element"
|
||||
aria-hidden="true"
|
||||
class="pointer-events-none absolute inset-0 size-full outline-none"
|
||||
/>
|
||||
<canvas
|
||||
ref="canvasRef"
|
||||
:data-pane-id="paneId"
|
||||
data-test-id="canvas-element"
|
||||
tabindex="-1"
|
||||
:style="{ cursor }"
|
||||
|
|
|
|||
|
|
@ -1,11 +1,13 @@
|
|||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { tv } from 'tailwind-variants'
|
||||
import { SplitterGroup, SplitterPanel, SplitterResizeHandle } from 'reka-ui'
|
||||
|
||||
import { useI18n } from '@open-pencil/vue'
|
||||
|
||||
import AppMenu from '@/components/Shell/AppMenu.vue'
|
||||
import SegmentedControl from '@/components/ui/SegmentedControl.vue'
|
||||
import splitterTheme from '@/theme/splitter'
|
||||
import AssetsPanel from './assets-panel/AssetsPanel.vue'
|
||||
import LayerTree from './LayerTree/LayerTree.vue'
|
||||
import PagesPanel from './PagesPanel.vue'
|
||||
|
|
@ -23,6 +25,7 @@ const panelOptions = computed(() => [
|
|||
{ value: 'assets', label: panels.value.assets }
|
||||
])
|
||||
const panelTabsUI = { root: 'w-full' }
|
||||
const splitterStyles = tv(splitterTheme)({ direction: 'vertical' })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
|
@ -66,10 +69,8 @@ const panelTabsUI = { root: 'w-full' }
|
|||
>
|
||||
<PagesPanel />
|
||||
</SplitterPanel>
|
||||
<SplitterResizeHandle class="group relative z-10 -my-1 h-2 cursor-row-resize">
|
||||
<div
|
||||
class="pointer-events-none absolute inset-x-0 top-1/2 h-px -translate-y-1/2 bg-border"
|
||||
/>
|
||||
<SplitterResizeHandle :class="splitterStyles.handle()">
|
||||
<div :class="splitterStyles.divider()" />
|
||||
</SplitterResizeHandle>
|
||||
<SplitterPanel :default-size="70" :min-size="20" class="flex flex-col overflow-hidden">
|
||||
<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">
|
||||
import { onMounted, onUnmounted, provide, ref } from 'vue'
|
||||
import { tv } from 'tailwind-variants'
|
||||
import { useEventListener, useUrlSearchParams } from '@vueuse/core'
|
||||
import { useRoute } from 'vue-router'
|
||||
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 EditorCanvas from '@/components/EditorCanvas.vue'
|
||||
import CanvasSplitRoot from '@/components/canvas/CanvasSplitRoot.vue'
|
||||
import FontStatusBanner from '@/components/font-status/FontStatusBanner.vue'
|
||||
import LayersPanel from '@/components/LayersPanel.vue'
|
||||
import MobileDrawer from '@/components/MobileDrawer.vue'
|
||||
|
|
@ -30,6 +32,7 @@ import SafariBanner from '@/components/SafariBanner.vue'
|
|||
import TabBar from '@/components/TabBar.vue'
|
||||
import Tip from '@/components/ui/Tip.vue'
|
||||
import Toolbar from '@/components/Toolbar/Toolbar.vue'
|
||||
import splitterTheme from '@/theme/splitter'
|
||||
|
||||
const route = useRoute()
|
||||
const params = useUrlSearchParams('history')
|
||||
|
|
@ -65,6 +68,7 @@ const automationCleanup = ref<(() => void) | null>(null)
|
|||
const mcpCleanup = ref<(() => void) | null>(null)
|
||||
const fileAssociationCleanup = ref<(() => void) | null>(null)
|
||||
const initialEditorLayout = loadEditorLayout()
|
||||
const horizontalSplitterStyles = tv(splitterTheme)({ direction: 'horizontal' })
|
||||
|
||||
type PendingOpenFile = {
|
||||
path: string
|
||||
|
|
@ -135,18 +139,18 @@ onUnmounted(() => {
|
|||
</SplitterPanel>
|
||||
<SplitterResizeHandle
|
||||
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>
|
||||
<SplitterPanel id="canvas" :default-size="initialEditorLayout[1]" :min-size="30" class="flex">
|
||||
<div class="relative flex min-w-0 flex-1">
|
||||
<EditorCanvas />
|
||||
<CanvasSplitRoot />
|
||||
<Toolbar />
|
||||
</div>
|
||||
</SplitterPanel>
|
||||
<SplitterResizeHandle class="group relative z-10 -mx-1 w-2 cursor-col-resize">
|
||||
<div class="pointer-events-none absolute inset-y-0 left-1/2 w-px -translate-x-1/2" />
|
||||
<SplitterResizeHandle :class="horizontalSplitterStyles.handle()">
|
||||
<div :class="horizontalSplitterStyles.divider()" />
|
||||
</SplitterResizeHandle>
|
||||
<SplitterPanel
|
||||
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', () => {
|
||||
const scheduler = createFrameScheduler()
|
||||
try {
|
||||
|
|
@ -211,12 +242,12 @@ describe('canvas render loop', () => {
|
|||
|
||||
emit('repaint:requested')
|
||||
scheduler.flush()
|
||||
expect(renders).toBe(1)
|
||||
expect(renders).toBe(2)
|
||||
|
||||
viewState.renderVersion++
|
||||
emit('repaint:requested')
|
||||
scheduler.flush()
|
||||
expect(renders).toBe(2)
|
||||
expect(renders).toBe(3)
|
||||
} finally {
|
||||
scheduler.restore()
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue