editor: resizable rail, Esc closes panel, dev tab remembered

Rail gets a drag handle on its edge (240-640px, width persisted like
the rest of the panel state). Escape now closes the panel as the last
step of its chain (dialog > inline > selection > panel). The dev panel
remembers its tab across reloads. Panel toggle button title notes the
Esc shortcut. Harness covers resize/persist, Esc-close and dev tab.

🤖 Generated with Codebuff
Co-Authored-By: Codebuff <noreply@codebuff.com>
This commit is contained in:
mats committed 2026-11-07 12:40:46 +01:00
1 parent bb875a0956
commit fc0eb2c6be
8 files changed
+111 -13

No files matched your search

+1
View File
@@ -408,6 +408,7 @@ export class Controller {
if (dialogs.length) this.closeDialog(dialogs.reduce((top, x) => (x.z > top.z ? x : top)).key) if (dialogs.length) this.closeDialog(dialogs.reduce((top, x) => (x.z > top.z ? x : top)).key)
else if (this.inlineSession) this.stopInline() else if (this.inlineSession) this.stopInline()
else if (this.store.get().selected) this.select(null) else if (this.store.get().selected) this.select(null)
else if (this.store.get().rail) this.setRail(false)
else return else return
e.preventDefault() e.preventDefault()
return return
+13 -4
View File
@@ -1,12 +1,21 @@
// Remembering small UI preferences across reloads (rail open, section // Remembering small UI preferences across reloads (rail open/width, section
// collapsed). Editors reload the page constantly to preview; losing the // collapsed, dev tab). Editors reload the page constantly to preview; losing
// panel state every time is the papercut this removes. All access is // the panel state every time is the papercut this removes. All access is
// guarded: private mode, sandboxed iframes and some embeds throw. // guarded: private mode, sandboxed iframes and some embeds throw.
export function readPersist<T>(key: string, fallback: T): T { export function readPersist<T>(key: string, fallback: T): T {
try { try {
const raw = localStorage.getItem(key) const raw = localStorage.getItem(key)
if (raw === null) return fallback if (raw === null) return fallback
return (typeof fallback === 'boolean' ? raw === 'true' : JSON.parse(raw)) as T if (typeof fallback === 'boolean') return (raw === 'true') as T
if (typeof fallback === 'number') {
const n = Number(raw)
return (Number.isFinite(n) ? n : fallback) as T
}
try {
return JSON.parse(raw) as T
} catch {
return (typeof fallback === 'string' ? raw : fallback) as T
}
} catch { } catch {
return fallback return fallback
} }
+3 -2
View File
@@ -2,7 +2,7 @@ import { useEffect, useState } from 'react'
import type { CmsNode, JSONSchema } from '../api' import type { CmsNode, JSONSchema } from '../api'
import { blockEditors, fieldEditors, inlineEditors } from '../registry' import { blockEditors, fieldEditors, inlineEditors } from '../registry'
import { Icon } from './Icon' import { Icon } from './Icon'
import { useCtrl, useEditorState, useRegistryVersion } from './hooks' import { useCtrl, useEditorState, usePersistentState, useRegistryVersion } from './hooks'
type Tab = 'requests' | 'selection' | 'registry' type Tab = 'requests' | 'selection' | 'registry'
@@ -10,7 +10,8 @@ type Tab = 'requests' | 'selection' | 'registry'
export function DevPanel() { export function DevPanel() {
const ctrl = useCtrl() const ctrl = useCtrl()
const open = useEditorState((s) => s.dev) const open = useEditorState((s) => s.dev)
const [tab, setTab] = useState<Tab>('requests') const [saved, setTab] = usePersistentState<Tab>('cms-onpage.devtab', 'requests')
const tab: Tab = saved === 'selection' || saved === 'registry' ? saved : 'requests'
if (!open) return null if (!open) return null
return ( return (
<aside className="cms-dev" aria-label="Developer panel"> <aside className="cms-dev" aria-label="Developer panel">
+1 -1
View File
@@ -51,7 +51,7 @@ export function Dock() {
<Icon name="redo" /> <Icon name="redo" />
</button> </button>
<span className="cms-dock__sep" /> <span className="cms-dock__sep" />
<button className={`cms-ico${s.rail ? ' is-on' : ''}`} onClick={() => panel()} title="Page panel: outline, blocks and nodes" aria-label="Panel"> <button className={`cms-ico${s.rail ? ' is-on' : ''}`} onClick={() => panel()} title="Page panel: outline, blocks and nodes (Esc closes)" aria-label="Panel">
<Icon name="outline" /> <Icon name="outline" />
</button> </button>
{aiSupported() && ( {aiSupported() && (
+31 -2
View File
@@ -17,9 +17,13 @@ import type { Block, Controller } from '../controller'
* - Blocks are the new types that fit this page (drag onto the page or the outline). * - Blocks are the new types that fit this page (drag onto the page or the outline).
* - Nodes finds *existing* nodes (type filter + text) to link into the page. * - Nodes finds *existing* nodes (type filter + text) to link into the page.
*/ */
const RAIL_W_MIN = 240
const RAIL_W_MAX = 640
export function Rail() { export function Rail() {
const ctrl = useCtrl() const ctrl = useCtrl()
const open = useEditorState((s) => s.rail) const open = useEditorState((s) => s.rail)
const [width, setWidth] = usePersistentState<number>('cms-onpage.railwidth', 320)
useEffect(() => { useEffect(() => {
if (!open) return if (!open) return
// Types carry every schema (titles, groups, slots): one request seeds them all. // Types carry every schema (titles, groups, slots): one request seeds them all.
@@ -33,16 +37,41 @@ export function Rail() {
void ctrl.fillHandles() void ctrl.fillHandles()
}, [open, ctrl]) }, [open, ctrl])
if (!open) return null if (!open) return null
const w = Math.max(RAIL_W_MIN, Math.min(width, window.innerWidth - 24))
const startResize = (e: React.PointerEvent) => {
if (e.button !== 0) return
e.preventDefault()
const doc = e.currentTarget.ownerDocument
const rail = e.currentTarget.parentElement!
const move = (m: PointerEvent) => {
const left = rail.getBoundingClientRect().left
setWidth(Math.round(Math.max(RAIL_W_MIN, Math.min(RAIL_W_MAX, m.clientX - left))))
}
const stop = () => {
doc.removeEventListener('pointermove', move, true)
doc.removeEventListener('pointerup', stop, true)
}
doc.addEventListener('pointermove', move, true)
doc.addEventListener('pointerup', stop, true)
}
return ( return (
<aside className="cms-rail" aria-label="Page panel"> <aside className="cms-rail" aria-label="Page panel" style={{ width: w }}>
<header> <header>
<span className="cms-rail__title"> <span className="cms-rail__title">
<Icon name="outline" size={16} /> Page <Icon name="outline" size={16} /> Page
</span> </span>
<button className="cms-ico" onClick={() => ctrl.setRail(false)} aria-label="Close panel"> <button className="cms-ico" onClick={() => ctrl.setRail(false)} aria-label="Close panel" title="Close panel (Esc)">
<Icon name="close" /> <Icon name="close" />
</button> </button>
</header> </header>
<div
className="cms-rail__resize"
role="separator"
aria-orientation="vertical"
aria-label="Resize panel"
title="Drag to resize"
onPointerDown={startResize}
/>
<div className="cms-rail__body"> <div className="cms-rail__body">
<OutlineTree /> <OutlineTree />
<Palette /> <Palette />
+4 -4
View File
@@ -11,12 +11,12 @@ export function useEditorState<S>(select: (s: EditorState) => S): S {
return useStore(useCtrl().store, select) return useStore(useCtrl().store, select)
} }
/** Boolean UI state that survives reloads (rail sections, panel toggles). */ /** UI state that survives reloads (rail sections, widths, panel toggles). */
export function usePersistentState(key: string, initial: boolean): [boolean, (update: boolean | ((prev: boolean) => boolean)) => void] { export function usePersistentState<T>(key: string, initial: T): [T, (update: T | ((prev: T) => T)) => void] {
const [value, setValue] = useState(() => readPersist(key, initial)) const [value, setValue] = useState(() => readPersist(key, initial))
const set = (update: boolean | ((prev: boolean) => boolean)) => const set = (update: T | ((prev: T) => T)) =>
setValue((prev) => { setValue((prev) => {
const next = typeof update === 'function' ? update(prev) : update const next = typeof update === 'function' ? (update as (prev: T) => T)(prev) : update
writePersist(key, next) writePersist(key, next)
return next return next
}) })
+3
View File
@@ -228,6 +228,9 @@ input.cms-map__key { padding-top: 6px; }
/* left panel: outline + blocks + nodes, one scroll (no tabs) */ /* left panel: outline + blocks + nodes, one scroll (no tabs) */
.cms-rail { pointer-events: auto; position: fixed; z-index: 2147483001; left: 12px; top: 12px; bottom: 76px; width: min(320px, calc(100vw - 24px)); display: flex; flex-direction: column; overflow: hidden; background: var(--cms-panel); border: 1px solid var(--cms-line); border-radius: 12px; box-shadow: 0 10px 40px rgba(0,0,0,.5); } .cms-rail { pointer-events: auto; position: fixed; z-index: 2147483001; left: 12px; top: 12px; bottom: 76px; width: min(320px, calc(100vw - 24px)); display: flex; flex-direction: column; overflow: hidden; background: var(--cms-panel); border: 1px solid var(--cms-line); border-radius: 12px; box-shadow: 0 10px 40px rgba(0,0,0,.5); }
.cms-rail > header { display: flex; align-items: center; justify-content: space-between; padding: 6px 6px 6px 12px; border-bottom: 1px solid var(--cms-line); font-weight: 600; } .cms-rail > header { display: flex; align-items: center; justify-content: space-between; padding: 6px 6px 6px 12px; border-bottom: 1px solid var(--cms-line); font-weight: 600; }
.cms-rail__resize { position: absolute; top: 0; bottom: 0; right: -5px; width: 12px; cursor: ew-resize; touch-action: none; z-index: 2; }
.cms-rail__resize::after { content: ''; position: absolute; top: 10px; bottom: 10px; right: 5px; width: 2px; border-radius: 2px; background: transparent; }
.cms-rail__resize:hover::after { background: var(--cms-accent); }
.cms-rail__title { display: inline-flex; align-items: center; gap: 8px; } .cms-rail__title { display: inline-flex; align-items: center; gap: 8px; }
.cms-rail__body { overflow-y: auto; flex: 1; min-height: 0; padding-bottom: 12px; } .cms-rail__body { overflow-y: auto; flex: 1; min-height: 0; padding-bottom: 12px; }
.cms-rail__body > section + section { border-top: 1px solid var(--cms-line); margin-top: 8px; padding-top: 4px; } .cms-rail__body > section + section { border-top: 1px solid var(--cms-line); margin-top: 8px; padding-top: 4px; }
+55
View File
@@ -59,6 +59,61 @@ describe('rail sections', () => {
expect(qa('.cms-outline').length).toBeGreaterThan(0) expect(qa('.cms-outline').length).toBeGreaterThan(0)
}) })
it('dragging the rail edge resizes it and remembers the width', async () => {
h = await setup()
await h.settle()
await h.press('Panel')
await h.settle()
const grip = q('.cms-rail__resize')
const down = new Event('pointerdown', { bubbles: true, cancelable: true }) as Event & { clientX: number; button: number }
down.clientX = 332
down.button = 0
await act(async () => void grip.dispatchEvent(down))
const move = new Event('pointermove', { bubbles: true, cancelable: true }) as Event & { clientX: number }
move.clientX = 420
await act(async () => void document.dispatchEvent(move))
const up = new Event('pointerup', { bubbles: true, cancelable: true })
await act(async () => void document.dispatchEvent(up))
await h.settle()
// jsdom reports rect.left as 0, so width == clientX, clamped to [240, 640]
expect((q('.cms-rail') as HTMLElement).style.width).toBe('420px')
expect(localStorage.getItem('cms-onpage.railwidth')).toBe('420')
h.cleanup()
h = await setup({ keepStorage: true })
await h.settle()
await h.settle()
expect((q('.cms-rail') as HTMLElement).style.width).toBe('420px')
})
it('Escape closes the rail when nothing else is open', async () => {
h = await setup()
await h.settle()
await h.press('Panel')
await h.settle()
expect(q('.cms-rail')).not.toBeNull()
await act(async () => void document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true, composed: true })))
await h.settle()
expect(h.shadow.querySelector('.cms-rail')).toBeNull()
expect(localStorage.getItem('cms-onpage.rail')).toBe('false')
})
it('dev panel remembers its tab', async () => {
h = await setup()
await h.settle()
await act(async () => void h.editor.ctrl.store.set({ dev: true }))
await h.settle()
const registry = qa('.cms-dev header button').find((b) => b.textContent === 'registry')!
await act(async () => void registry.click())
await h.settle()
expect(localStorage.getItem('cms-onpage.devtab')).toBe('registry')
h.cleanup()
h = await setup({ keepStorage: true })
await h.settle()
await act(async () => void h.editor.ctrl.store.set({ dev: true }))
await h.settle()
expect(q('.cms-dev__body').textContent).toContain('blockEditors')
})
it('assistant sits beside the dev panel instead of stacking under it', async () => { it('assistant sits beside the dev panel instead of stacking under it', async () => {
;(navigator as any).gpu = {} // aiSupported() checks for a WebGPU API ;(navigator as any).gpu = {} // aiSupported() checks for a WebGPU API
h = await setup() h = await setup()