From 587462e3724fa06f6a7186e52f87f9e4359c0738 Mon Sep 17 00:00:00 2001 From: MelmacAlpha Date: Thu, 10 Sep 2026 21:15:00 +0200 Subject: [PATCH 1/3] feat(editor): add optional arrow-key layer reordering --- docs/features/editor-preferences.md | 5 ++ docs/features/spotlight.md | 2 +- src/__tests__/settings/settingsModal.test.tsx | 2 +- .../settings/settingsSections.test.tsx | 5 +- src/admin/pages/site/preferences/catalog.ts | 10 +++- .../__tests__/shortcutDispatch.test.ts | 47 ++++++++++++++++++- src/admin/spotlight/keybindings.ts | 25 ++++++++++ src/admin/spotlight/shortcutDispatch.ts | 1 + 8 files changed, 91 insertions(+), 6 deletions(-) diff --git a/docs/features/editor-preferences.md b/docs/features/editor-preferences.md index cec9814b5..1b63a999e 100644 --- a/docs/features/editor-preferences.md +++ b/docs/features/editor-preferences.md @@ -274,10 +274,15 @@ The Settings → Preferences screen renders this list automatically from the cat | Layers panel | `layersShowClasses` | boolean | `true` | `TreeNode.tsx` | | Layers panel | `layersAutoExpandSelected` | boolean | `true` | `DomPanel.tsx` selection effect | | Layers panel | `layersSmoothScroll` | boolean | `true` | `DomPanel.tsx` scroll handler | +| Layers panel | `layersArrowKeyReorder` | boolean | `false` | `keybindings.ts` / `shortcutDispatch.ts` | | Properties panel | `propertiesSmoothScroll` | boolean | `true` | `StyleSurface.tsx` + `PropertiesPanel.tsx` | | Properties panel | `propertiesSectionsExpanded`| boolean | `true` | `StyleSectionsEditor.tsx`, `StyleSurface.tsx`, `CustomPropertiesSection.tsx` | | Command palette | `spotlightTelemetryEnabled` | boolean | `false` | command palette usage tracking | +### Arrow-key layer reordering + +`layersArrowKeyReorder` is an opt-in editor preference. Enable it under `Settings → Preferences → Layers panel` to use plain `↑` and `↓` on the canvas or Layers tree to move the selected layer one position within its current parent. The existing `Move layer up` and `Move layer down` commands in Spotlight remain available through `⌘K` / `Ctrl+K` regardless of this preference. Modifier keys, editable fields, inline text editing, and the Alt/Option inspect ladder are excluded. + ### Confirm-before-delete flow `confirmBeforeDelete` runs through a single shared `` mounted in `AdminCanvasLayout`. Components call `useConfirmDelete()` and pass a `commit` callback: diff --git a/docs/features/spotlight.md b/docs/features/spotlight.md index 2693287d0..0bc2c939a 100644 --- a/docs/features/spotlight.md +++ b/docs/features/spotlight.md @@ -302,7 +302,7 @@ Used by destructive commands: delete user, sign out all devices, revoke session, | ? | Show all keybindings | | Custom command shortcuts | Per-command entry in `keybindings.ts` | -Selected-layer shortcuts are command shortcuts too. `⌘C` / `Ctrl+C`, `⌘X` / `Ctrl+X`, `⌘V` / `Ctrl+V`, and `⌘D` / `Ctrl+D` run when focus is on the canvas or the Layers tree. `⌘⌫` / `Ctrl+Backspace` deletes the selected layer from either surface through the normal delete confirmation flow; plain Delete / Backspace remains accepted by the canvas handler for selected canvas nodes. +Selected-layer shortcuts are command shortcuts too. `⌘C` / `Ctrl+C`, `⌘X` / `Ctrl+X`, `⌘V` / `Ctrl+V`, and `⌘D` / `Ctrl+D` run when focus is on the canvas or the Layers tree. `⌘⌫` / `Ctrl+Backspace` deletes the selected layer from either surface through the normal delete confirmation flow; plain Delete / Backspace remains accepted by the canvas handler for selected canvas nodes. When the `Move layers with Arrow keys` editor preference is enabled, plain `↑` / `↓` moves the selected layer one sibling position within its current parent. The Arrow-key binding is disabled by default and ignores modifiers, editable fields, inline text editing, and the Alt/Option inspect ladder. The keybindings registry is **the single source of truth** for shortcuts — gated by `keybindings-registry-single-source.test.ts`. Register each command shortcut in `keybindings.ts`; component-owned handlers may consume those registered bindings when a surface needs local selection or confirmation behavior, but they must not hard-code a second shortcut definition. diff --git a/src/__tests__/settings/settingsModal.test.tsx b/src/__tests__/settings/settingsModal.test.tsx index 8c4edd2d9..63f81674f 100644 --- a/src/__tests__/settings/settingsModal.test.tsx +++ b/src/__tests__/settings/settingsModal.test.tsx @@ -483,7 +483,7 @@ describe('SettingsModal — PreferencesSection toggles', () => { openModal('preferences') render() const switches = screen.getAllByRole('switch') - expect(switches.length).toBe(11) + expect(switches.length).toBe(12) }) it('Hover-preview toggle has aria-checked="true" by default', () => { diff --git a/src/__tests__/settings/settingsSections.test.tsx b/src/__tests__/settings/settingsSections.test.tsx index 4e6b027a4..f29ebe048 100644 --- a/src/__tests__/settings/settingsSections.test.tsx +++ b/src/__tests__/settings/settingsSections.test.tsx @@ -56,13 +56,13 @@ describe('PreferencesSection — catalog-driven rendering', () => { // Boolean preferences currently declared in `admin/pages/site/preferences/catalog.ts`: // hoverPreview, confirmBeforeDelete, // layersShowIcon, layersShowTag, layersShowClasses, - // layersAutoExpandSelected, layersSmoothScroll, + // layersAutoExpandSelected, layersSmoothScroll, layersArrowKeyReorder, // dimInactiveBreakpoints, propertiesSmoothScroll, // propertiesSectionsExpanded, // spotlightTelemetryEnabled ← Phase 6: opt-in command-usage telemetry // Adding/removing a boolean preference is one catalog edit and this // assertion updates with it. - expect(screen.getAllByRole('switch')).toHaveLength(11) + expect(screen.getAllByRole('switch')).toHaveLength(12) expect(screen.getByRole('switch', { name: /preview suggestions on hover/i })).toBeDefined() expect(screen.getByRole('switch', { name: /confirm before deleting/i })).toBeDefined() expect(screen.getByRole('switch', { name: /show module icon/i })).toBeDefined() @@ -70,6 +70,7 @@ describe('PreferencesSection — catalog-driven rendering', () => { expect(screen.getByRole('switch', { name: /show class names/i })).toBeDefined() expect(screen.getByRole('switch', { name: /auto-expand on selection/i })).toBeDefined() expect(screen.getByRole('switch', { name: /smooth scroll to selected/i })).toBeDefined() + expect(screen.getByRole('switch', { name: /move layers with arrow keys/i })).toBeDefined() expect(screen.getByRole('switch', { name: /dim inactive viewports/i })).toBeDefined() expect(screen.getByRole('switch', { name: /smooth scroll on tab change/i })).toBeDefined() expect(screen.getByRole('switch', { name: /expand style sections by default/i })).toBeDefined() diff --git a/src/admin/pages/site/preferences/catalog.ts b/src/admin/pages/site/preferences/catalog.ts index d0dd88682..445abdef1 100644 --- a/src/admin/pages/site/preferences/catalog.ts +++ b/src/admin/pages/site/preferences/catalog.ts @@ -47,7 +47,7 @@ const PREFERENCE_CATEGORIES: ReadonlyArray<{ { id: 'layers', label: 'Layers panel', - description: 'Control what is shown next to each row in the DOM tree.', + description: 'Control layer-row display and keyboard behavior.', }, { id: 'properties', @@ -247,6 +247,14 @@ export const PREFERENCE_CATALOG = [ description: 'Animate scrolling when the tree jumps to a newly selected layer. Turn off for instant snapping.', default: true, }, + { + id: 'layersArrowKeyReorder', + type: 'boolean', + category: 'layers', + label: 'Move layers with Arrow keys', + description: 'Use ArrowUp and ArrowDown to move the selected layer within its current parent. Off by default so the keys remain available for navigation.', + default: false, + }, // ── Properties panel ──────────────────────────────────────────────────── { diff --git a/src/admin/spotlight/__tests__/shortcutDispatch.test.ts b/src/admin/spotlight/__tests__/shortcutDispatch.test.ts index b0e875e78..fcb2c2c24 100644 --- a/src/admin/spotlight/__tests__/shortcutDispatch.test.ts +++ b/src/admin/spotlight/__tests__/shortcutDispatch.test.ts @@ -1,11 +1,19 @@ -import { describe, expect, it } from 'bun:test' +import { afterEach, describe, expect, it } from 'bun:test' import { readFileSync } from 'fs' import { getKeybindingForCommand } from '../keybindings' import { findMatchingShortcutCommand } from '../shortcutDispatch' import type { CommandContext } from '../types' +import { + EDITOR_PREFS_KEY, + setEditorPreference, +} from '@site/preferences/editorPreferences' const SPOTLIGHT_ROOT = new URL('../SpotlightRoot.tsx', import.meta.url) +afterEach(() => { + localStorage.removeItem(EDITOR_PREFS_KEY) +}) + function eventLike(key: string, overrides: Partial = {}) { return { key, @@ -134,6 +142,43 @@ describe('command shortcut dispatch', () => { expect(binding?.match(eventLike('Backspace', { ctrlKey: true }))).toBe(true) }) + it('registers optional ArrowUp/ArrowDown shortcuts for moving layers', () => { + const moveUp = getKeybindingForCommand('layers.moveUp') + const moveDown = getKeybindingForCommand('layers.moveDown') + + expect(moveUp?.shortcut).toEqual({ mac: '↑', win: '↑' }) + expect(moveDown?.shortcut).toEqual({ mac: '↓', win: '↓' }) + expect(moveUp?.match(eventLike('ArrowUp'))).toBe(true) + expect(moveDown?.match(eventLike('ArrowDown'))).toBe(true) + expect(moveUp?.match(eventLike('ArrowUp', { altKey: true }))).toBe(false) + expect(moveDown?.match(eventLike('ArrowDown', { shiftKey: true }))).toBe(false) + }) + + it('dispatches layer movement only when the Arrow-key preference is enabled', () => { + const ctx = context(['site.read', 'site.structure.edit'], { + selectedNodeIds: ['node-1'], + activePageId: 'page-1', + activeDocument: { kind: 'page', pageId: 'page-1' }, + canUndo: false, + canRedo: false, + activeBreakpointId: 'desktop', + activeInlineEdit: false, + }) + const event = () => + eventLike('ArrowUp', { target: canvasTarget() as EventTarget }) as KeyboardEvent + + expect(findMatchingShortcutCommand(event(), ctx)).toBeNull() + + setEditorPreference('layersArrowKeyReorder', true) + expect(findMatchingShortcutCommand(event(), ctx)?.id).toBe('layers.moveUp') + expect( + findMatchingShortcutCommand( + eventLike('ArrowDown', { target: layerTreeTarget() as EventTarget }) as KeyboardEvent, + ctx, + )?.id, + ).toBe('layers.moveDown') + }) + it('matches browser-uppercase canvas clipboard shortcut keys', () => { const ctx = context(['site.read', 'site.structure.edit'], { selectedNodeIds: ['node-1'], diff --git a/src/admin/spotlight/keybindings.ts b/src/admin/spotlight/keybindings.ts index c941056c3..c374054e5 100644 --- a/src/admin/spotlight/keybindings.ts +++ b/src/admin/spotlight/keybindings.ts @@ -28,6 +28,7 @@ * 4. Re-run the architecture test: bun test src/__tests__/architecture/keybindings-registry-single-source.test.ts */ +import { readEditorPreferenceBool } from '@site/preferences/editorPreferences' import type { CommandId, CommandShortcut } from './types' // ─── Key event shape ────────────────────────────────────────────────────────── @@ -62,6 +63,8 @@ export interface KeybindingDefinition { ariaKeyshortcuts?: string /** Predicate that returns true when the event matches this binding. */ match: (e: KeyEventLike) => boolean + /** Optional local preference gate for shortcuts that are opt-in. */ + enabled?: () => boolean /** Activation scope — handlers gate firing based on this. */ scope: 'global' | 'editor' | 'canvas' | 'panels' /** @@ -204,6 +207,28 @@ export const KEYBINDINGS: ReadonlyArray = [ ignoreInEditableField: true, }, + { + commandId: 'layers.moveUp', + shortcut: { mac: '↑', win: '↑' }, + ariaKeyshortcuts: 'ArrowUp', + match: (e) => + !e.metaKey && !e.ctrlKey && !e.shiftKey && !e.altKey && e.key === 'ArrowUp', + enabled: () => readEditorPreferenceBool('layersArrowKeyReorder'), + scope: 'canvas', + ignoreInEditableField: true, + }, + + { + commandId: 'layers.moveDown', + shortcut: { mac: '↓', win: '↓' }, + ariaKeyshortcuts: 'ArrowDown', + match: (e) => + !e.metaKey && !e.ctrlKey && !e.shiftKey && !e.altKey && e.key === 'ArrowDown', + enabled: () => readEditorPreferenceBool('layersArrowKeyReorder'), + scope: 'canvas', + ignoreInEditableField: true, + }, + { commandId: 'layers.delete', shortcut: { mac: '⌘⌫', win: 'Ctrl+Backspace' }, diff --git a/src/admin/spotlight/shortcutDispatch.ts b/src/admin/spotlight/shortcutDispatch.ts index 5e593290a..137711b39 100644 --- a/src/admin/spotlight/shortcutDispatch.ts +++ b/src/admin/spotlight/shortcutDispatch.ts @@ -62,6 +62,7 @@ export function findMatchingShortcutCommand( for (const binding of KEYBINDINGS) { if (COMPONENT_OWNED_SHORTCUTS.has(binding.commandId)) continue + if (binding.enabled && !binding.enabled()) continue if (binding.scope === 'canvas' && context.editor?.activeInlineEdit) continue if (binding.scope === 'canvas' && !isLayerShortcutSurface(event)) continue if (shouldIgnoreEditableTarget(binding.commandId) && isEditableShortcutTarget(event.target)) continue From 9dc2eebb532c1ba84b1711bd23f8884f2579c537 Mon Sep 17 00:00:00 2001 From: MelmacAlpha Date: Thu, 10 Sep 2026 23:14:17 +0200 Subject: [PATCH 2/3] fix(editor): move multi-selected layers together --- docs/features/editor-preferences.md | 2 +- docs/features/spotlight.md | 2 +- .../spotlight/layersCommands.test.ts | 22 +++- src/admin/spotlight/commands/layers.ts | 101 ++++++++++++------ 4 files changed, 87 insertions(+), 40 deletions(-) diff --git a/docs/features/editor-preferences.md b/docs/features/editor-preferences.md index 1b63a999e..d2168c7b2 100644 --- a/docs/features/editor-preferences.md +++ b/docs/features/editor-preferences.md @@ -281,7 +281,7 @@ The Settings → Preferences screen renders this list automatically from the cat ### Arrow-key layer reordering -`layersArrowKeyReorder` is an opt-in editor preference. Enable it under `Settings → Preferences → Layers panel` to use plain `↑` and `↓` on the canvas or Layers tree to move the selected layer one position within its current parent. The existing `Move layer up` and `Move layer down` commands in Spotlight remain available through `⌘K` / `Ctrl+K` regardless of this preference. Modifier keys, editable fields, inline text editing, and the Alt/Option inspect ladder are excluded. +`layersArrowKeyReorder` is an opt-in editor preference. Enable it under `Settings → Preferences → Layers panel` to use plain `↑` and `↓` on the canvas or Layers tree to move the selected layer one position within its current parent. A multi-selection of sibling layers moves as one ordered block and is recorded as one undo step; selections spanning different parents are left unchanged. The existing `Move layer up` and `Move layer down` commands in Spotlight remain available through `⌘K` / `Ctrl+K` regardless of this preference. Modifier keys, editable fields, inline text editing, and the Alt/Option inspect ladder are excluded. ### Confirm-before-delete flow diff --git a/docs/features/spotlight.md b/docs/features/spotlight.md index 0bc2c939a..1a7608689 100644 --- a/docs/features/spotlight.md +++ b/docs/features/spotlight.md @@ -302,7 +302,7 @@ Used by destructive commands: delete user, sign out all devices, revoke session, | ? | Show all keybindings | | Custom command shortcuts | Per-command entry in `keybindings.ts` | -Selected-layer shortcuts are command shortcuts too. `⌘C` / `Ctrl+C`, `⌘X` / `Ctrl+X`, `⌘V` / `Ctrl+V`, and `⌘D` / `Ctrl+D` run when focus is on the canvas or the Layers tree. `⌘⌫` / `Ctrl+Backspace` deletes the selected layer from either surface through the normal delete confirmation flow; plain Delete / Backspace remains accepted by the canvas handler for selected canvas nodes. When the `Move layers with Arrow keys` editor preference is enabled, plain `↑` / `↓` moves the selected layer one sibling position within its current parent. The Arrow-key binding is disabled by default and ignores modifiers, editable fields, inline text editing, and the Alt/Option inspect ladder. +Selected-layer shortcuts are command shortcuts too. `⌘C` / `Ctrl+C`, `⌘X` / `Ctrl+X`, `⌘V` / `Ctrl+V`, and `⌘D` / `Ctrl+D` run when focus is on the canvas or the Layers tree. `⌘⌫` / `Ctrl+Backspace` deletes the selected layer from either surface through the normal delete confirmation flow; plain Delete / Backspace remains accepted by the canvas handler for selected canvas nodes. When the `Move layers with Arrow keys` editor preference is enabled, plain `↑` / `↓` moves the selected layer one sibling position within its current parent. Multi-selected siblings move as one ordered block in a single undo step; selections spanning different parents are left unchanged. The Arrow-key binding is disabled by default and ignores modifiers, editable fields, inline text editing, and the Alt/Option inspect ladder. The keybindings registry is **the single source of truth** for shortcuts — gated by `keybindings-registry-single-source.test.ts`. Register each command shortcut in `keybindings.ts`; component-owned handlers may consume those registered bindings when a surface needs local selection or confirmation behavior, but they must not hard-code a second shortcut definition. diff --git a/src/__tests__/spotlight/layersCommands.test.ts b/src/__tests__/spotlight/layersCommands.test.ts index ac8414d2f..cf85dacfa 100644 --- a/src/__tests__/spotlight/layersCommands.test.ts +++ b/src/__tests__/spotlight/layersCommands.test.ts @@ -26,9 +26,11 @@ function loadVisualComponentCanvas(): void { id: 'vc-card', name: 'Card', tree: makeVCTree('vc-root', [ - makeVCNode({ id: 'vc-root', moduleId: 'base.body', children: ['vc-a', 'vc-b'] }), + makeVCNode({ id: 'vc-root', moduleId: 'base.body', children: ['vc-a', 'vc-b', 'vc-c', 'vc-d'] }), makeVCNode({ id: 'vc-a', moduleId: 'base.text', props: { text: 'A' } }), makeVCNode({ id: 'vc-b', moduleId: 'base.text', props: { text: 'B' } }), + makeVCNode({ id: 'vc-c', moduleId: 'base.text', props: { text: 'C' } }), + makeVCNode({ id: 'vc-d', moduleId: 'base.text', props: { text: 'D' } }), ]), }) @@ -88,10 +90,24 @@ beforeEach(() => { describe('Spotlight layer commands', () => { it('moves selected Visual Component nodes within the active canvas tree', async () => { await runLayerCommand('layers.moveUp', ['vc-b']) - expect(vcChildren()).toEqual(['vc-b', 'vc-a']) + expect(vcChildren()).toEqual(['vc-b', 'vc-a', 'vc-c', 'vc-d']) await runLayerCommand('layers.moveDown', ['vc-b']) - expect(vcChildren()).toEqual(['vc-a', 'vc-b']) + expect(vcChildren()).toEqual(['vc-a', 'vc-b', 'vc-c', 'vc-d']) + }) + + it('moves a multi-selection as one sibling block in one undo step', async () => { + await runLayerCommand('layers.moveUp', ['vc-c', 'vc-b']) + expect(vcChildren()).toEqual(['vc-b', 'vc-c', 'vc-a', 'vc-d']) + + useEditorStore.getState().undo() + expect(vcChildren()).toEqual(['vc-a', 'vc-b', 'vc-c', 'vc-d']) + + await runLayerCommand('layers.moveDown', ['vc-a', 'vc-b']) + expect(vcChildren()).toEqual(['vc-c', 'vc-a', 'vc-b', 'vc-d']) + + useEditorStore.getState().undo() + expect(vcChildren()).toEqual(['vc-a', 'vc-b', 'vc-c', 'vc-d']) }) it('navigates parent and child selection inside the active Visual Component tree', async () => { diff --git a/src/admin/spotlight/commands/layers.ts b/src/admin/spotlight/commands/layers.ts index c0be0e832..4e039cc97 100644 --- a/src/admin/spotlight/commands/layers.ts +++ b/src/admin/spotlight/commands/layers.ts @@ -18,7 +18,7 @@ */ import { getParent } from '@core/page-tree' -import type { Command } from '../types' +import type { Command, CommandRunContext } from '../types' const hasSelection = (ctx: { editor?: { selectedNodeIds: ReadonlyArray } }) => (ctx.editor?.selectedNodeIds.length ?? 0) > 0 @@ -29,6 +29,69 @@ async function getActiveLayerTree() { return { store, page: selectActiveCanvasPage(store) } } +type LayerMoveDirection = 'up' | 'down' + +/** + * Resolve a one-step sibling move for the current selection. + * + * Multi-selection is treated as one ordered block. Nested selections collapse + * to their selected ancestors, and selections spanning different parents are + * left untouched because one sibling move cannot represent that operation. + */ +function resolveSelectedSiblingMove( + page: NonNullable>['page']>, + selectedNodeIds: readonly string[], + direction: LayerMoveDirection, +): { nodeIds: string[]; parentId: string; newIndex: number } | null { + const selected = new Set(selectedNodeIds) + const topLevelIds = selectedNodeIds.filter((id) => { + if (!page.nodes[id] || id === page.rootNodeId) return false + let ancestor = getParent(page, id) + while (ancestor) { + if (selected.has(ancestor.id)) return false + ancestor = getParent(page, ancestor.id) + } + return true + }) + if (topLevelIds.length === 0) return null + + const parents = topLevelIds.map((id) => getParent(page, id)) + if (parents.some((parent) => !parent)) return null + const parentId = parents[0]!.id + if (parents.some((parent) => parent!.id !== parentId)) return null + + const topLevelSet = new Set(topLevelIds) + const nodeIds = parents[0]!.children.filter((id) => topLevelSet.has(id)) + if (nodeIds.length === 0) return null + + const firstIndex = parents[0]!.children.indexOf(nodeIds[0]!) + const lastIndex = parents[0]!.children.indexOf(nodeIds[nodeIds.length - 1]!) + if (direction === 'up' && firstIndex <= 0) return null + if (direction === 'down' && lastIndex >= parents[0]!.children.length - 1) return null + + return { + nodeIds, + parentId, + newIndex: direction === 'up' ? firstIndex - 1 : firstIndex + 1, + } +} + +async function moveSelectedLayers(ctx: CommandRunContext, direction: LayerMoveDirection): Promise { + ctx.closeSpotlight() + const selectedNodeIds = ctx.editor?.selectedNodeIds ?? [] + if (selectedNodeIds.length === 0) return + + try { + const { store, page } = await getActiveLayerTree() + if (!page) return + const target = resolveSelectedSiblingMove(page, selectedNodeIds, direction) + if (!target) return + store.moveNodes(target.nodeIds, target.parentId, target.newIndex) + } catch (err) { + console.error(`[spotlight] moveNode ${direction} failed:`, err) + } +} + export function getLayersCommands(): Command[] { return [ // ── Duplicate layer ────────────────────────────────────────────────────── @@ -293,23 +356,7 @@ export function getLayersCommands(): Command[] { workspaces: ['site'], capability: 'site.structure.edit', when: hasSelection, - run: async (ctx) => { - ctx.closeSpotlight() - const nodeId = ctx.editor?.selectedNodeIds[ctx.editor.selectedNodeIds.length - 1] - if (!nodeId) return - try { - const { store, page } = await getActiveLayerTree() - if (!page) return - const parent = getParent(page, nodeId) - if (!parent) return - const siblings = parent.children ?? [] - const idx = siblings.indexOf(nodeId) - if (idx <= 0) return - store.moveNode(nodeId, parent.id, idx - 1) - } catch (err) { - console.error('[spotlight] moveNode up failed:', err) - } - }, + run: (ctx) => moveSelectedLayers(ctx, 'up'), }, // ── Move down ──────────────────────────────────────────────────────────── @@ -323,23 +370,7 @@ export function getLayersCommands(): Command[] { workspaces: ['site'], capability: 'site.structure.edit', when: hasSelection, - run: async (ctx) => { - ctx.closeSpotlight() - const nodeId = ctx.editor?.selectedNodeIds[ctx.editor.selectedNodeIds.length - 1] - if (!nodeId) return - try { - const { store, page } = await getActiveLayerTree() - if (!page) return - const parent = getParent(page, nodeId) - if (!parent) return - const siblings = parent.children ?? [] - const idx = siblings.indexOf(nodeId) - if (idx < 0 || idx >= siblings.length - 1) return - store.moveNode(nodeId, parent.id, idx + 1) - } catch (err) { - console.error('[spotlight] moveNode down failed:', err) - } - }, + run: (ctx) => moveSelectedLayers(ctx, 'down'), }, // ── Select parent ──────────────────────────────────────────────────────── From f04ea275ac6f0ecebf30f1bdcfcba56f0aec9076 Mon Sep 17 00:00:00 2001 From: MelmacAlpha Date: Fri, 11 Sep 2026 14:31:13 +0200 Subject: [PATCH 3/3] fix(editor): reject locked layers in arrow reorder --- docs/features/editor-preferences.md | 2 +- docs/features/spotlight.md | 2 +- src/__tests__/spotlight/layersCommands.test.ts | 7 ++++++- src/admin/spotlight/commands/layers.ts | 2 ++ 4 files changed, 10 insertions(+), 3 deletions(-) diff --git a/docs/features/editor-preferences.md b/docs/features/editor-preferences.md index d2168c7b2..73707e70a 100644 --- a/docs/features/editor-preferences.md +++ b/docs/features/editor-preferences.md @@ -281,7 +281,7 @@ The Settings → Preferences screen renders this list automatically from the cat ### Arrow-key layer reordering -`layersArrowKeyReorder` is an opt-in editor preference. Enable it under `Settings → Preferences → Layers panel` to use plain `↑` and `↓` on the canvas or Layers tree to move the selected layer one position within its current parent. A multi-selection of sibling layers moves as one ordered block and is recorded as one undo step; selections spanning different parents are left unchanged. The existing `Move layer up` and `Move layer down` commands in Spotlight remain available through `⌘K` / `Ctrl+K` regardless of this preference. Modifier keys, editable fields, inline text editing, and the Alt/Option inspect ladder are excluded. +`layersArrowKeyReorder` is an opt-in editor preference. Enable it under `Settings → Preferences → Layers panel` to use plain `↑` and `↓` on the canvas or Layers tree to move the selected layer one position within its current parent. A multi-selection of sibling layers moves as one ordered block and is recorded as one undo step; selections spanning different parents or containing a locked layer are left unchanged. The existing `Move layer up` and `Move layer down` commands in Spotlight remain available through `⌘K` / `Ctrl+K` regardless of this preference. Modifier keys, editable fields, inline text editing, and the Alt/Option inspect ladder are excluded. ### Confirm-before-delete flow diff --git a/docs/features/spotlight.md b/docs/features/spotlight.md index 1a7608689..88e0d7389 100644 --- a/docs/features/spotlight.md +++ b/docs/features/spotlight.md @@ -302,7 +302,7 @@ Used by destructive commands: delete user, sign out all devices, revoke session, | ? | Show all keybindings | | Custom command shortcuts | Per-command entry in `keybindings.ts` | -Selected-layer shortcuts are command shortcuts too. `⌘C` / `Ctrl+C`, `⌘X` / `Ctrl+X`, `⌘V` / `Ctrl+V`, and `⌘D` / `Ctrl+D` run when focus is on the canvas or the Layers tree. `⌘⌫` / `Ctrl+Backspace` deletes the selected layer from either surface through the normal delete confirmation flow; plain Delete / Backspace remains accepted by the canvas handler for selected canvas nodes. When the `Move layers with Arrow keys` editor preference is enabled, plain `↑` / `↓` moves the selected layer one sibling position within its current parent. Multi-selected siblings move as one ordered block in a single undo step; selections spanning different parents are left unchanged. The Arrow-key binding is disabled by default and ignores modifiers, editable fields, inline text editing, and the Alt/Option inspect ladder. +Selected-layer shortcuts are command shortcuts too. `⌘C` / `Ctrl+C`, `⌘X` / `Ctrl+X`, `⌘V` / `Ctrl+V`, and `⌘D` / `Ctrl+D` run when focus is on the canvas or the Layers tree. `⌘⌫` / `Ctrl+Backspace` deletes the selected layer from either surface through the normal delete confirmation flow; plain Delete / Backspace remains accepted by the canvas handler for selected canvas nodes. When the `Move layers with Arrow keys` editor preference is enabled, plain `↑` / `↓` moves the selected layer one sibling position within its current parent. Multi-selected siblings move as one ordered block in a single undo step; selections spanning different parents or containing a locked layer are left unchanged. The Arrow-key binding is disabled by default and ignores modifiers, editable fields, inline text editing, and the Alt/Option inspect ladder. The keybindings registry is **the single source of truth** for shortcuts — gated by `keybindings-registry-single-source.test.ts`. Register each command shortcut in `keybindings.ts`; component-owned handlers may consume those registered bindings when a surface needs local selection or confirmation behavior, but they must not hard-code a second shortcut definition. diff --git a/src/__tests__/spotlight/layersCommands.test.ts b/src/__tests__/spotlight/layersCommands.test.ts index cf85dacfa..645909ca3 100644 --- a/src/__tests__/spotlight/layersCommands.test.ts +++ b/src/__tests__/spotlight/layersCommands.test.ts @@ -30,7 +30,7 @@ function loadVisualComponentCanvas(): void { makeVCNode({ id: 'vc-a', moduleId: 'base.text', props: { text: 'A' } }), makeVCNode({ id: 'vc-b', moduleId: 'base.text', props: { text: 'B' } }), makeVCNode({ id: 'vc-c', moduleId: 'base.text', props: { text: 'C' } }), - makeVCNode({ id: 'vc-d', moduleId: 'base.text', props: { text: 'D' } }), + makeVCNode({ id: 'vc-d', moduleId: 'base.text', props: { text: 'D' }, locked: true }), ]), }) @@ -110,6 +110,11 @@ describe('Spotlight layer commands', () => { expect(vcChildren()).toEqual(['vc-a', 'vc-b', 'vc-c', 'vc-d']) }) + it('does not move a selection that contains a locked layer', async () => { + await runLayerCommand('layers.moveUp', ['vc-b', 'vc-d']) + expect(vcChildren()).toEqual(['vc-a', 'vc-b', 'vc-c', 'vc-d']) + }) + it('navigates parent and child selection inside the active Visual Component tree', async () => { await runLayerCommand('layers.selectParent', ['vc-a']) expect(useEditorStore.getState().selectedNodeId).toBe('vc-root') diff --git a/src/admin/spotlight/commands/layers.ts b/src/admin/spotlight/commands/layers.ts index 4e039cc97..fe6caa0f8 100644 --- a/src/admin/spotlight/commands/layers.ts +++ b/src/admin/spotlight/commands/layers.ts @@ -43,6 +43,8 @@ function resolveSelectedSiblingMove( selectedNodeIds: readonly string[], direction: LayerMoveDirection, ): { nodeIds: string[]; parentId: string; newIndex: number } | null { + if (selectedNodeIds.some((id) => page.nodes[id]?.locked)) return null + const selected = new Set(selectedNodeIds) const topLevelIds = selectedNodeIds.filter((id) => { if (!page.nodes[id] || id === page.rootNodeId) return false