diff --git a/CHANGELOG.md b/CHANGELOG.md index 8a547227f..6fb4e4e7f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/desktop/generated/menu.json b/desktop/generated/menu.json index db0f54726..85f7b4fbf 100644 --- a/desktop/generated/menu.json +++ b/desktop/generated/menu.json @@ -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", diff --git a/packages/vue/src/canvas/drop/use.ts b/packages/vue/src/canvas/drop/use.ts index 0f2e3a7f9..5065af1f5 100644 --- a/packages/vue/src/canvas/drop/use.ts +++ b/packages/vue/src/canvas/drop/use.ts @@ -40,7 +40,11 @@ function componentDropPlacement(componentId: string, cx: number, cy: number, edi } } -export function useCanvasDrop(canvasRef: Ref, editor: Editor) { +export function useCanvasDrop( + canvasRef: Ref, + editor: Editor, + onActivate?: () => void +) { const isDraggingOver = ref(false) useEventListener(canvasRef, 'dragover', (e: DragEvent) => { @@ -52,6 +56,7 @@ export function useCanvasDrop(canvasRef: Ref, 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, editor: }) useEventListener(canvasRef, 'drop', (e: DragEvent) => { + onActivate?.() e.preventDefault() isDraggingOver.value = false diff --git a/packages/vue/src/canvas/surface/render-loop.ts b/packages/vue/src/canvas/surface/render-loop.ts index 01672325b..457d85bed 100644 --- a/packages/vue/src/canvas/surface/render-loop.ts +++ b/packages/vue/src/canvas/surface/render-loop.ts @@ -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)) diff --git a/packages/vue/src/canvas/text-edit/textarea.ts b/packages/vue/src/canvas/text-edit/textarea.ts index b97f641fe..110970d2d 100644 --- a/packages/vue/src/canvas/text-edit/textarea.ts +++ b/packages/vue/src/canvas/text-edit/textarea.ts @@ -26,17 +26,20 @@ export function useTextEditingSession({ textareaRef, resetBlink, stopBlink, - resetComposition + resetComposition, + isEnabled }: { store: Editor textareaRef: ShallowRef 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 diff --git a/packages/vue/src/canvas/text-edit/use.ts b/packages/vue/src/canvas/text-edit/use.ts index c5b25ed00..a5a7ec3c9 100644 --- a/packages/vue/src/canvas/text-edit/use.ts +++ b/packages/vue/src/canvas/text-edit/use.ts @@ -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, store: Editor) { +export function useTextEdit( + canvasRef: Ref, + store: Editor, + options?: { isEnabled?: () => boolean } +) { const textareaRef = shallowRef(null) const { resetBlink, stopBlink } = createCaretBlink(store) const { @@ -77,5 +81,12 @@ export function useTextEdit(canvasRef: Ref, store: Edi focusTextAreaOnCanvasPointerDown(textareaRef, store) ) - useTextEditingSession({ store, textareaRef, resetBlink, stopBlink, resetComposition }) + useTextEditingSession({ + store, + textareaRef, + resetBlink, + stopBlink, + resetComposition, + isEnabled: options?.isEnabled + }) } diff --git a/packages/vue/src/canvas/useCanvasInput.ts b/packages/vue/src/canvas/useCanvasInput.ts index 23fe0f75b..12e4146d6 100644 --- a/packages/vue/src/canvas/useCanvasInput.ts +++ b/packages/vue/src/canvas/useCanvasInput.ts @@ -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(null) const cursorOverride = ref(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() + } } } diff --git a/packages/vue/src/i18n/locales/de/menu.json b/packages/vue/src/i18n/locales/de/menu.json index 407588820..432829a31 100644 --- a/packages/vue/src/i18n/locales/de/menu.json +++ b/packages/vue/src/i18n/locales/de/menu.json @@ -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" } diff --git a/packages/vue/src/i18n/locales/es/menu.json b/packages/vue/src/i18n/locales/es/menu.json index 65cf1e7f3..1f283145c 100644 --- a/packages/vue/src/i18n/locales/es/menu.json +++ b/packages/vue/src/i18n/locales/es/menu.json @@ -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" } diff --git a/packages/vue/src/i18n/locales/fr/menu.json b/packages/vue/src/i18n/locales/fr/menu.json index 6f40df1df..9e9a764a0 100644 --- a/packages/vue/src/i18n/locales/fr/menu.json +++ b/packages/vue/src/i18n/locales/fr/menu.json @@ -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" } diff --git a/packages/vue/src/i18n/locales/it/menu.json b/packages/vue/src/i18n/locales/it/menu.json index 38b0798d2..c51128460 100644 --- a/packages/vue/src/i18n/locales/it/menu.json +++ b/packages/vue/src/i18n/locales/it/menu.json @@ -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" } diff --git a/packages/vue/src/i18n/locales/ja/menu.json b/packages/vue/src/i18n/locales/ja/menu.json index b61a9cee6..1254e423a 100644 --- a/packages/vue/src/i18n/locales/ja/menu.json +++ b/packages/vue/src/i18n/locales/ja/menu.json @@ -58,5 +58,8 @@ "multiplayerCursors": "マルチプレイヤーカーソル", "renameSelection": "選択範囲の名前を変更…", "rulers": "ルーラー", - "settings": "設定…" + "settings": "設定…", + "splitRight": "右に分割", + "splitDown": "下に分割", + "closeView": "ビューを閉じる" } diff --git a/packages/vue/src/i18n/locales/pl/menu.json b/packages/vue/src/i18n/locales/pl/menu.json index 1661c6ce7..30a612e84 100644 --- a/packages/vue/src/i18n/locales/pl/menu.json +++ b/packages/vue/src/i18n/locales/pl/menu.json @@ -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" } diff --git a/packages/vue/src/i18n/locales/ru/menu.json b/packages/vue/src/i18n/locales/ru/menu.json index bf6091bcb..d24a4078e 100644 --- a/packages/vue/src/i18n/locales/ru/menu.json +++ b/packages/vue/src/i18n/locales/ru/menu.json @@ -58,5 +58,8 @@ "multiplayerCursors": "Курсоры участников", "renameSelection": "Переименовать выделение…", "rulers": "Линейки", - "settings": "Настройки…" + "settings": "Настройки…", + "splitRight": "Разделить вправо", + "splitDown": "Разделить вниз", + "closeView": "Закрыть вид" } diff --git a/packages/vue/src/i18n/locales/zh-cn/menu.json b/packages/vue/src/i18n/locales/zh-cn/menu.json index 34dbf687e..502b9b87b 100644 --- a/packages/vue/src/i18n/locales/zh-cn/menu.json +++ b/packages/vue/src/i18n/locales/zh-cn/menu.json @@ -58,5 +58,8 @@ "multiplayerCursors": "多人光标", "renameSelection": "重命名所选内容…", "rulers": "标尺", - "settings": "设置…" + "settings": "设置…", + "splitRight": "向右拆分", + "splitDown": "向下拆分", + "closeView": "关闭视图" } diff --git a/packages/vue/src/i18n/messages/menu.ts b/packages/vue/src/i18n/messages/menu.ts index 7c7b731fd..c4387d651 100644 --- a/packages/vue/src/i18n/messages/menu.ts +++ b/packages/vue/src/i18n/messages/menu.ts @@ -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) diff --git a/src/app/editor/panes/registry.ts b/src/app/editor/panes/registry.ts new file mode 100644 index 000000000..795b42c84 --- /dev/null +++ b/src/app/editor/panes/registry.ts @@ -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({ 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 diff --git a/src/app/editor/panes/split-tree.ts b/src/app/editor/panes/split-tree.ts new file mode 100644 index 000000000..09f1f3320 --- /dev/null +++ b/src/app/editor/panes/split-tree.ts @@ -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)) + } +} diff --git a/src/app/editor/panes/state.ts b/src/app/editor/panes/state.ts new file mode 100644 index 000000000..bd21f92da --- /dev/null +++ b/src/app/editor/panes/state.ts @@ -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 = {} +): 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(), + 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 + }) +} diff --git a/src/app/editor/session/create.ts b/src/app/editor/session/create.ts index 1fc675dca..802ffbded 100644 --- a/src/app/editor/session/create.ts +++ b/src/app/editor/session/create.ts @@ -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 diff --git a/src/app/shell/menu/app-menu.ts b/src/app/shell/menu/app-menu.ts index 8f9c8970a..a9a20c727 100644 --- a/src/app/shell/menu/app-menu.ts +++ b/src/app/shell/menu/app-menu.ts @@ -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) diff --git a/src/app/shell/menu/editor-actions.ts b/src/app/shell/menu/editor-actions.ts index 8d9ab0526..5dd132aec 100644 --- a/src/app/shell/menu/editor-actions.ts +++ b/src/app/shell/menu/editor-actions.ts @@ -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() diff --git a/src/app/shell/menu/schema.ts b/src/app/shell/menu/schema.ts index d993f9156..2eb06c0b7 100644 --- a/src/app/shell/menu/schema.ts +++ b/src/app/shell/menu/schema.ts @@ -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' }, diff --git a/src/components/EditorCanvas.vue b/src/components/EditorCanvas.vue index 3a539d8ea..f70f98cfb 100644 --- a/src/components/EditorCanvas.vue +++ b/src/components/EditorCanvas.vue @@ -1,5 +1,5 @@