Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions docs/features/editor-preferences.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. 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

`confirmBeforeDelete` runs through a single shared `<ConfirmDeleteProvider/>` mounted in `AdminCanvasLayout`. Components call `useConfirmDelete()` and pass a `commit` callback:
Expand Down
2 changes: 1 addition & 1 deletion docs/features/spotlight.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. 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.

Expand Down
2 changes: 1 addition & 1 deletion src/__tests__/settings/settingsModal.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -483,7 +483,7 @@ describe('SettingsModal — PreferencesSection toggles', () => {
openModal('preferences')
render(<SettingsModal />)
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', () => {
Expand Down
5 changes: 3 additions & 2 deletions src/__tests__/settings/settingsSections.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -56,20 +56,21 @@ 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()
expect(screen.getByRole('switch', { name: /show html tag/i })).toBeDefined()
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()
Expand Down
27 changes: 24 additions & 3 deletions src/__tests__/spotlight/layersCommands.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' }, locked: true }),
]),
})

Expand Down Expand Up @@ -88,10 +90,29 @@ 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('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 () => {
Expand Down
10 changes: 9 additions & 1 deletion src/admin/pages/site/preferences/catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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 ────────────────────────────────────────────────────
{
Expand Down
47 changes: 46 additions & 1 deletion src/admin/spotlight/__tests__/shortcutDispatch.test.ts
Original file line number Diff line number Diff line change
@@ -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<KeyboardEvent> = {}) {
return {
key,
Expand Down Expand Up @@ -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'],
Expand Down
103 changes: 68 additions & 35 deletions src/admin/spotlight/commands/layers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> } }) =>
(ctx.editor?.selectedNodeIds.length ?? 0) > 0
Expand All @@ -29,6 +29,71 @@ 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<Awaited<ReturnType<typeof getActiveLayerTree>>['page']>,
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
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<void> {
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)
Comment thread
MelmacAlpha marked this conversation as resolved.
} catch (err) {
console.error(`[spotlight] moveNode ${direction} failed:`, err)
}
}

export function getLayersCommands(): Command[] {
return [
// ── Duplicate layer ──────────────────────────────────────────────────────
Expand Down Expand Up @@ -293,23 +358,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 ────────────────────────────────────────────────────────────
Expand All @@ -323,23 +372,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 ────────────────────────────────────────────────────────
Expand Down
25 changes: 25 additions & 0 deletions src/admin/spotlight/keybindings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ──────────────────────────────────────────────────────────
Expand Down Expand Up @@ -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'
/**
Expand Down Expand Up @@ -204,6 +207,28 @@ export const KEYBINDINGS: ReadonlyArray<KeybindingDefinition> = [
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' },
Expand Down
1 change: 1 addition & 0 deletions src/admin/spotlight/shortcutDispatch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down