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)
|
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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
Reference in new issue
Block a user