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:
1 parent
bb875a0956
commit
fc0eb2c6be
8 files changed
+111
-13
No files matched your search
@@ -408,6 +408,7 @@ export class Controller {
|
||||
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.store.get().selected) this.select(null)
|
||||
else if (this.store.get().rail) this.setRail(false)
|
||||
else return
|
||||
e.preventDefault()
|
||||
return
|
||||
|
||||
+13
-4
@@ -1,12 +1,21 @@
|
||||
// Remembering small UI preferences across reloads (rail open, section
|
||||
// collapsed). Editors reload the page constantly to preview; losing the
|
||||
// panel state every time is the papercut this removes. All access is
|
||||
// Remembering small UI preferences across reloads (rail open/width, section
|
||||
// collapsed, dev tab). Editors reload the page constantly to preview; losing
|
||||
// the panel state every time is the papercut this removes. All access is
|
||||
// guarded: private mode, sandboxed iframes and some embeds throw.
|
||||
export function readPersist<T>(key: string, fallback: T): T {
|
||||
try {
|
||||
const raw = localStorage.getItem(key)
|
||||
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 {
|
||||
return fallback
|
||||
}
|
||||
|
||||
+3
-2
@@ -2,7 +2,7 @@ import { useEffect, useState } from 'react'
|
||||
import type { CmsNode, JSONSchema } from '../api'
|
||||
import { blockEditors, fieldEditors, inlineEditors } from '../registry'
|
||||
import { Icon } from './Icon'
|
||||
import { useCtrl, useEditorState, useRegistryVersion } from './hooks'
|
||||
import { useCtrl, useEditorState, usePersistentState, useRegistryVersion } from './hooks'
|
||||
|
||||
type Tab = 'requests' | 'selection' | 'registry'
|
||||
|
||||
@@ -10,7 +10,8 @@ type Tab = 'requests' | 'selection' | 'registry'
|
||||
export function DevPanel() {
|
||||
const ctrl = useCtrl()
|
||||
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
|
||||
return (
|
||||
<aside className="cms-dev" aria-label="Developer panel">
|
||||
|
||||
+1
-1
@@ -51,7 +51,7 @@ export function Dock() {
|
||||
<Icon name="redo" />
|
||||
</button>
|
||||
<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" />
|
||||
</button>
|
||||
{aiSupported() && (
|
||||
|
||||
+31
-2
@@ -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).
|
||||
* - 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() {
|
||||
const ctrl = useCtrl()
|
||||
const open = useEditorState((s) => s.rail)
|
||||
const [width, setWidth] = usePersistentState<number>('cms-onpage.railwidth', 320)
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
// Types carry every schema (titles, groups, slots): one request seeds them all.
|
||||
@@ -33,16 +37,41 @@ export function Rail() {
|
||||
void ctrl.fillHandles()
|
||||
}, [open, ctrl])
|
||||
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 (
|
||||
<aside className="cms-rail" aria-label="Page panel">
|
||||
<aside className="cms-rail" aria-label="Page panel" style={{ width: w }}>
|
||||
<header>
|
||||
<span className="cms-rail__title">
|
||||
<Icon name="outline" size={16} /> Page
|
||||
</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" />
|
||||
</button>
|
||||
</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">
|
||||
<OutlineTree />
|
||||
<Palette />
|
||||
|
||||
+4
-4
@@ -11,12 +11,12 @@ export function useEditorState<S>(select: (s: EditorState) => S): S {
|
||||
return useStore(useCtrl().store, select)
|
||||
}
|
||||
|
||||
/** Boolean UI state that survives reloads (rail sections, panel toggles). */
|
||||
export function usePersistentState(key: string, initial: boolean): [boolean, (update: boolean | ((prev: boolean) => boolean)) => void] {
|
||||
/** UI state that survives reloads (rail sections, widths, panel toggles). */
|
||||
export function usePersistentState<T>(key: string, initial: T): [T, (update: T | ((prev: T) => T)) => void] {
|
||||
const [value, setValue] = useState(() => readPersist(key, initial))
|
||||
const set = (update: boolean | ((prev: boolean) => boolean)) =>
|
||||
const set = (update: T | ((prev: T) => T)) =>
|
||||
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)
|
||||
return next
|
||||
})
|
||||
|
||||
@@ -228,6 +228,9 @@ input.cms-map__key { padding-top: 6px; }
|
||||
/* 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 > 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__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; }
|
||||
|
||||
@@ -59,6 +59,61 @@ describe('rail sections', () => {
|
||||
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 () => {
|
||||
;(navigator as any).gpu = {} // aiSupported() checks for a WebGPU API
|
||||
h = await setup()
|
||||
|
||||
Reference in new issue
Block a user