Repair outline rows and add split panels for drag & drop
Outline chevron buttons were matched by a generic `.cms-outline li
button { width: 100% }` rule, so each expand/collapse arrow took a full
row and the tree looked stacked and broken. Scope row styling to
`.cms-orow__main`, fix the chevron to 22px, add rotating chevrons to the
section headers, and turn the blocks palette from centered narrow cards
into left-aligned rows.
Add a split toggle to the panel header: outline stays visible in a left
column while Blocks/Nodes switch as tabs in a right column, so a drag
source and its outline drop gaps are on screen together. Choice and
library tab persist per profile; stacked mode is unchanged.
🤖 Generated with Codebuff
Co-Authored-By: Codebuff <noreply@codebuff.com>
This commit is contained in:
1 parent
fc0eb2c6be
commit
b7849c0359
3 files changed
+140
-44
No files matched your search
+72
-21
@@ -9,21 +9,29 @@ import { useCtrl, useEditorState, usePersistentState } from './hooks'
|
|||||||
import type { Block, Controller } from '../controller'
|
import type { Block, Controller } from '../controller'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Left panel: outline + blocks + nodes in one place (no tabs).
|
* Left panel: outline + blocks + nodes.
|
||||||
*
|
*
|
||||||
* - Outline is a tree with its slots (e.g. a pdp's `info-section` /
|
* - Outline is a tree with its slots (e.g. a pdp's `info-section` /
|
||||||
* `3d-accessories` connections): drag any row by its grip to reorder, drop
|
* `3d-accessories` connections): drag any row by its grip to reorder, drop
|
||||||
* palette blocks and searched nodes onto the gaps between rows or into slots.
|
* palette blocks and searched nodes onto the gaps between rows or into slots.
|
||||||
* - 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.
|
||||||
|
*
|
||||||
|
* Stacked (default) all sections share one scroll. Split mode puts the outline
|
||||||
|
* in a left column and Blocks/Nodes as switchable tabs in a right column, so a
|
||||||
|
* drag source and its outline drop gaps are visible at the same time.
|
||||||
*/
|
*/
|
||||||
const RAIL_W_MIN = 240
|
const RAIL_W_MIN = 240
|
||||||
const RAIL_W_MAX = 640
|
const RAIL_W_MAX = 640
|
||||||
|
|
||||||
|
type LibTab = 'blocks' | 'nodes'
|
||||||
|
|
||||||
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)
|
const [width, setWidth] = usePersistentState<number>('cms-onpage.railwidth', 320)
|
||||||
|
const [split, setSplit] = usePersistentState<boolean>('cms-onpage.railsplit', false)
|
||||||
|
const [lib, setLib] = usePersistentState<LibTab>('cms-onpage.raillib', 'blocks')
|
||||||
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.
|
||||||
@@ -37,7 +45,8 @@ 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 single = Math.max(RAIL_W_MIN, Math.min(width, window.innerWidth - 24))
|
||||||
|
const w = split ? Math.min(single * 2 + 12, window.innerWidth - 24) : single
|
||||||
const startResize = (e: React.PointerEvent) => {
|
const startResize = (e: React.PointerEvent) => {
|
||||||
if (e.button !== 0) return
|
if (e.button !== 0) return
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
@@ -55,14 +64,25 @@ export function Rail() {
|
|||||||
doc.addEventListener('pointerup', stop, true)
|
doc.addEventListener('pointerup', stop, true)
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<aside className="cms-rail" aria-label="Page panel" style={{ width: w }}>
|
<aside className={`cms-rail${split ? ' is-split' : ''}`} 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" title="Close panel (Esc)">
|
<span className="cms-rail__headbtns">
|
||||||
<Icon name="close" />
|
<button
|
||||||
</button>
|
className={`cms-ico${split ? ' is-on' : ''}`}
|
||||||
|
onClick={() => setSplit((s) => !s)}
|
||||||
|
aria-label={split ? 'Stack panels' : 'Split panels side by side'}
|
||||||
|
title={split ? 'Stack panels (one column)' : 'Split panels side by side (outline + blocks/nodes)'}
|
||||||
|
aria-pressed={split}
|
||||||
|
>
|
||||||
|
<Icon name="flow-row" />
|
||||||
|
</button>
|
||||||
|
<button className="cms-ico" onClick={() => ctrl.setRail(false)} aria-label="Close panel" title="Close panel (Esc)">
|
||||||
|
<Icon name="close" />
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
</header>
|
</header>
|
||||||
<div
|
<div
|
||||||
className="cms-rail__resize"
|
className="cms-rail__resize"
|
||||||
@@ -72,12 +92,32 @@ export function Rail() {
|
|||||||
title="Drag to resize"
|
title="Drag to resize"
|
||||||
onPointerDown={startResize}
|
onPointerDown={startResize}
|
||||||
/>
|
/>
|
||||||
<div className="cms-rail__body">
|
{split ? (
|
||||||
<OutlineTree />
|
<div className="cms-rail__body is-split">
|
||||||
<Palette />
|
<div className="cms-rail__col" aria-label="Outline column">
|
||||||
<NodeSearch />
|
<OutlineTree forceOpen />
|
||||||
<ExperimentsPanel />
|
<ExperimentsPanel />
|
||||||
</div>
|
</div>
|
||||||
|
<div className="cms-rail__col" aria-label="Library column">
|
||||||
|
<div className="cms-rail__tabs" role="tablist" aria-label="Library">
|
||||||
|
<button role="tab" aria-selected={lib === 'blocks'} className={`cms-pill${lib === 'blocks' ? ' is-on' : ''}`} onClick={() => setLib('blocks')} aria-label="Show blocks panel">
|
||||||
|
Blocks
|
||||||
|
</button>
|
||||||
|
<button role="tab" aria-selected={lib === 'nodes'} className={`cms-pill${lib === 'nodes' ? ' is-on' : ''}`} onClick={() => setLib('nodes')} aria-label="Show nodes panel">
|
||||||
|
Nodes
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{lib === 'blocks' ? <Palette bare /> : <NodeSearch bare />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="cms-rail__body">
|
||||||
|
<OutlineTree />
|
||||||
|
<Palette />
|
||||||
|
<NodeSearch />
|
||||||
|
<ExperimentsPanel />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</aside>
|
</aside>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -86,7 +126,7 @@ export function Rail() {
|
|||||||
|
|
||||||
type Gap = { parentId: string; handle?: string; index: number }
|
type Gap = { parentId: string; handle?: string; index: number }
|
||||||
|
|
||||||
function OutlineTree() {
|
function OutlineTree({ forceOpen = false }: { forceOpen?: boolean } = {}) {
|
||||||
const ctrl = useCtrl()
|
const ctrl = useCtrl()
|
||||||
const blocks = useEditorState((s) => s.blocks)
|
const blocks = useEditorState((s) => s.blocks)
|
||||||
const selected = useEditorState((s) => s.selected)
|
const selected = useEditorState((s) => s.selected)
|
||||||
@@ -138,15 +178,17 @@ function OutlineTree() {
|
|||||||
|
|
||||||
const roots = useMemo(() => blocks.filter((b) => !b.parentId), [blocks])
|
const roots = useMemo(() => blocks.filter((b) => !b.parentId), [blocks])
|
||||||
const [open, setOpen] = usePersistentState('cms-onpage.sec.outline', true)
|
const [open, setOpen] = usePersistentState('cms-onpage.sec.outline', true)
|
||||||
|
const shown = open || forceOpen
|
||||||
if (!blocks.length) return null
|
if (!blocks.length) return null
|
||||||
return (
|
return (
|
||||||
<section aria-label="Outline">
|
<section aria-label="Outline">
|
||||||
<h4 className="cms-rail__sec">
|
<h4 className="cms-rail__sec">
|
||||||
<button className="cms-sectoggle" aria-expanded={open} aria-label="Outline" onClick={() => setOpen((o) => !o)}>
|
<button className="cms-sectoggle" aria-expanded={shown} aria-label="Outline" onClick={() => setOpen((o) => !o)}>
|
||||||
|
<Icon name="chevron" size={14} className="cms-chev" />
|
||||||
Outline ({blocks.length})
|
Outline ({blocks.length})
|
||||||
</button>
|
</button>
|
||||||
</h4>
|
</h4>
|
||||||
{open && (
|
{shown && (
|
||||||
<>
|
<>
|
||||||
<ul className="cms-outline cms-outline--tree">
|
<ul className="cms-outline cms-outline--tree">
|
||||||
{roots.map((b) => (
|
{roots.map((b) => (
|
||||||
@@ -315,8 +357,8 @@ function SlotGroup({ parentId, level, slot, over, setOver, dragging, collapsed,
|
|||||||
key = ctrl.openDialog({ id: `insert:${parentId}:${slot.name}`, title: `Add to ${slot.name}`, size: 'sm', content: createElement(InsertPicker, { parentId, handle: slot.name, ctrl, close: () => ctrl.closeDialog(key) }) })
|
key = ctrl.openDialog({ id: `insert:${parentId}:${slot.name}`, title: `Add to ${slot.name}`, size: 'sm', content: createElement(InsertPicker, { parentId, handle: slot.name, ctrl, close: () => ctrl.closeDialog(key) }) })
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<li className="cms-oslot" style={{ marginLeft: level * 14 }}>
|
<li className="cms-oslot">
|
||||||
<div className="cms-oslot__head" title={slot.def.description ?? `Slot: ${slot.name}`}>
|
<div className="cms-oslot__head" style={{ paddingLeft: 4 + level * 14 }} title={slot.def.description ?? `Slot: ${slot.name}`}>
|
||||||
<Icon name="shape" size={13} />
|
<Icon name="shape" size={13} />
|
||||||
<span>{slot.name}</span>
|
<span>{slot.name}</span>
|
||||||
<span className="cms-muted">{slot.def.to.length ? slot.def.to.join(', ') : 'any'}</span>
|
<span className="cms-muted">{slot.def.to.length ? slot.def.to.join(', ') : 'any'}</span>
|
||||||
@@ -341,7 +383,7 @@ const DRAG_THRESHOLD = 5
|
|||||||
|
|
||||||
type Item = { key: string; label: string; sub?: string; type?: string; blueprint?: string; group: string; description?: string }
|
type Item = { key: string; label: string; sub?: string; type?: string; blueprint?: string; group: string; description?: string }
|
||||||
|
|
||||||
function Palette() {
|
function Palette({ bare = false }: { bare?: boolean } = {}) {
|
||||||
const ctrl = useCtrl()
|
const ctrl = useCtrl()
|
||||||
const [types, setTypes] = useState<TypeInfo[] | null>(null)
|
const [types, setTypes] = useState<TypeInfo[] | null>(null)
|
||||||
const [blueprints, setBlueprints] = useState<import('../api').Blueprint[]>([])
|
const [blueprints, setBlueprints] = useState<import('../api').Blueprint[]>([])
|
||||||
@@ -428,14 +470,18 @@ function Palette() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const [open, setOpen] = usePersistentState('cms-onpage.sec.blocks', true)
|
const [open, setOpen] = usePersistentState('cms-onpage.sec.blocks', true)
|
||||||
|
const shownBlocks = open || bare
|
||||||
return (
|
return (
|
||||||
<section aria-label="Blocks">
|
<section aria-label="Blocks">
|
||||||
|
{!bare && (
|
||||||
<h4 className="cms-rail__sec">
|
<h4 className="cms-rail__sec">
|
||||||
<button className="cms-sectoggle" aria-expanded={open} aria-label="Blocks" onClick={() => setOpen((o) => !o)}>
|
<button className="cms-sectoggle" aria-expanded={open} aria-label="Blocks" onClick={() => setOpen((o) => !o)}>
|
||||||
|
<Icon name="chevron" size={14} className="cms-chev" />
|
||||||
Blocks
|
Blocks
|
||||||
</button>
|
</button>
|
||||||
</h4>
|
</h4>
|
||||||
{open && (
|
)}
|
||||||
|
{shownBlocks && (
|
||||||
<>
|
<>
|
||||||
<div className="cms-pad">
|
<div className="cms-pad">
|
||||||
<input className="cms-input" placeholder="Search blocks…" aria-label="Search blocks" value={q} onChange={(e) => setQ(e.target.value)} />
|
<input className="cms-input" placeholder="Search blocks…" aria-label="Search blocks" value={q} onChange={(e) => setQ(e.target.value)} />
|
||||||
@@ -489,7 +535,7 @@ function nodeTitle(ctrl: Controller, n: CmsNode): string | undefined {
|
|||||||
return fb || undefined
|
return fb || undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
function NodeSearch() {
|
function NodeSearch({ bare = false }: { bare?: boolean } = {}) {
|
||||||
const ctrl = useCtrl()
|
const ctrl = useCtrl()
|
||||||
const [typeNames, setTypeNames] = useState<string[]>([])
|
const [typeNames, setTypeNames] = useState<string[]>([])
|
||||||
const [type, setType] = useState('')
|
const [type, setType] = useState('')
|
||||||
@@ -563,14 +609,18 @@ function NodeSearch() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const [open, setOpen] = usePersistentState('cms-onpage.sec.nodes', true)
|
const [open, setOpen] = usePersistentState('cms-onpage.sec.nodes', true)
|
||||||
|
const shownNodes = open || bare
|
||||||
return (
|
return (
|
||||||
<section aria-label="Nodes">
|
<section aria-label="Nodes">
|
||||||
|
{!bare && (
|
||||||
<h4 className="cms-rail__sec">
|
<h4 className="cms-rail__sec">
|
||||||
<button className="cms-sectoggle" aria-expanded={open} aria-label="Nodes" onClick={() => setOpen((o) => !o)}>
|
<button className="cms-sectoggle" aria-expanded={open} aria-label="Nodes" onClick={() => setOpen((o) => !o)}>
|
||||||
|
<Icon name="chevron" size={14} className="cms-chev" />
|
||||||
Nodes
|
Nodes
|
||||||
</button>
|
</button>
|
||||||
</h4>
|
</h4>
|
||||||
{open && (
|
)}
|
||||||
|
{shownNodes && (
|
||||||
<>
|
<>
|
||||||
<div className="cms-pad cms-nodefind">
|
<div className="cms-pad cms-nodefind">
|
||||||
<input className="cms-input" placeholder="Search existing nodes…" aria-label="Search nodes" value={q} onChange={(e) => setQ(e.target.value)} />
|
<input className="cms-input" placeholder="Search existing nodes…" aria-label="Search nodes" value={q} onChange={(e) => setQ(e.target.value)} />
|
||||||
@@ -683,6 +733,7 @@ function ExperimentsPanel() {
|
|||||||
<section aria-label="Experiments panel">
|
<section aria-label="Experiments panel">
|
||||||
<h4 className="cms-rail__sec">
|
<h4 className="cms-rail__sec">
|
||||||
<button className="cms-sectoggle" aria-expanded={open} aria-label="Experiments" onClick={() => setOpen((o) => !o)}>
|
<button className="cms-sectoggle" aria-expanded={open} aria-label="Experiments" onClick={() => setOpen((o) => !o)}>
|
||||||
|
<Icon name="chevron" size={14} className="cms-chev" />
|
||||||
Experiments{rules ? ` (${rules.length})` : ''}
|
Experiments{rules ? ` (${rules.length})` : ''}
|
||||||
</button>
|
</button>
|
||||||
</h4>
|
</h4>
|
||||||
|
|||||||
+35
-23
@@ -61,11 +61,10 @@ button:disabled { opacity: .35; cursor: default; }
|
|||||||
.cms-tile:hover { border-color: #d2a24c; }
|
.cms-tile:hover { border-color: #d2a24c; }
|
||||||
|
|
||||||
.cms-dev header { display: flex; align-items: center; justify-content: space-between; padding: 6px 8px 6px 12px; border-bottom: 1px solid #2a2d3a; }
|
.cms-dev header { display: flex; align-items: center; justify-content: space-between; padding: 6px 8px 6px 12px; border-bottom: 1px solid #2a2d3a; }
|
||||||
.cms-outline li button { display: flex; gap: 8px; width: 100%; background: none; border: 0; border-radius: 5px; padding: 3px 8px; text-align: left; overflow: hidden; white-space: nowrap; }
|
.cms-outline { list-style: none; margin: 0; padding: 4px 6px; }
|
||||||
.cms-outline li button:hover, .cms-outline li button.is-sel { background: #242836; }
|
.cms-outline li { min-width: 0; }
|
||||||
.cms-outline li button.is-sel { color: #d2a24c; }
|
.cms-outline__type { font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex-shrink: 0; max-width: 55%; }
|
||||||
.cms-outline__type { font-weight: 600; }
|
.cms-outline__text { color: #8b8fa0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1; min-width: 0; }
|
||||||
.cms-outline__text { color: #8b8fa0; overflow: hidden; text-overflow: ellipsis; }
|
|
||||||
|
|
||||||
.cms-dev { right: 12px; top: 12px; bottom: 70px; width: min(420px, calc(100vw - 24px)); display: flex; flex-direction: column; overflow: hidden; }
|
.cms-dev { right: 12px; top: 12px; bottom: 70px; width: min(420px, calc(100vw - 24px)); display: flex; flex-direction: column; overflow: hidden; }
|
||||||
.cms-dev header { justify-content: flex-start; gap: 4px; }
|
.cms-dev header { justify-content: flex-start; gap: 4px; }
|
||||||
@@ -232,24 +231,38 @@ input.cms-map__key { padding-top: 6px; }
|
|||||||
.cms-rail__resize::after { content: ''; position: absolute; top: 10px; bottom: 10px; right: 5px; width: 2px; border-radius: 2px; background: transparent; }
|
.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__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__headbtns { display: inline-flex; align-items: center; gap: 2px; margin-left: auto; }
|
||||||
|
.cms-rail__tabs { display: flex; gap: 4px; padding: 10px 10px 2px; position: sticky; top: 0; z-index: 1; background: var(--cms-panel); }
|
||||||
|
.cms-rail__tabs .cms-pill { flex: 1; justify-content: center; }
|
||||||
.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.is-split { display: grid; grid-template-columns: 1fr 1fr; overflow: hidden; padding-bottom: 0; }
|
||||||
|
.cms-rail__col { overflow-y: auto; min-width: 0; min-height: 0; padding-bottom: 12px; }
|
||||||
|
.cms-rail__col + .cms-rail__col { border-left: 1px solid var(--cms-line); }
|
||||||
.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; }
|
||||||
.cms-rail__sec { margin: 10px 12px 4px; font-size: 11px; letter-spacing: .08em; text-transform: uppercase; color: #8b8fa0; }
|
.cms-rail__sec { margin: 10px 12px 4px; font-size: 11px; letter-spacing: .08em; text-transform: uppercase; color: #8b8fa0; }
|
||||||
.cms-rail .cms-tabs { border: 0; } .cms-rail .cms-tabs button { display: inline-flex; align-items: center; gap: 6px; }
|
.cms-rail .cms-tabs { border: 0; } .cms-rail .cms-tabs button { display: inline-flex; align-items: center; gap: 6px; }
|
||||||
.cms-orow { display: flex; align-items: center; gap: 2px; border-radius: 6px; padding-right: 4px; }
|
.cms-orow { display: flex; align-items: center; gap: 0; border-radius: 6px; padding-right: 4px; min-height: 28px; }
|
||||||
.cms-orow:hover { background: #242836; }
|
.cms-orow:hover { background: #242836; }
|
||||||
.cms-orow.is-sel { background: #242836; color: var(--cms-accent); }
|
.cms-orow.is-sel { background: #242836; }
|
||||||
.cms-orow__main { display: flex; align-items: center; gap: 8px; flex: 1; min-width: 0; background: none; border: 0; border-radius: 6px; padding: 4px 6px 4px 2px; text-align: left; overflow: hidden; white-space: nowrap; }
|
.cms-orow.is-sel .cms-orow__main { color: var(--cms-accent); }
|
||||||
.cms-orow__grip { cursor: grab; color: #5b6075; display: inline-flex; padding: 4px 2px; touch-action: none; user-select: none; }
|
.cms-orow__main { display: flex; align-items: center; gap: 7px; flex: 1; min-width: 0; background: none; border: 0; border-radius: 6px; padding: 4px 6px 4px 2px; text-align: left; overflow: hidden; white-space: nowrap; cursor: pointer; }
|
||||||
|
.cms-orow__main > svg { flex-shrink: 0; color: #9aa0b5; }
|
||||||
|
.cms-orow.is-sel .cms-orow__main > svg { color: var(--cms-accent); }
|
||||||
|
.cms-orow__grip { cursor: grab; color: #5b6075; display: inline-flex; align-items: center; padding: 4px 3px; touch-action: none; user-select: none; flex-shrink: 0; }
|
||||||
.cms-orow__grip:active { cursor: grabbing; }
|
.cms-orow__grip:active { cursor: grabbing; }
|
||||||
.cms-orow__chev { flex-shrink: 0; }
|
.cms-orow__grip:hover { color: #c9ccda; }
|
||||||
.cms-orow__spacer { width: 28px; flex-shrink: 0; }
|
.cms-orow__chev { flex-shrink: 0; min-width: 0; width: 22px; height: 22px; padding: 0; }
|
||||||
.cms-outline--nested { padding: 0; overflow: visible; }
|
.cms-orow__spacer { width: 22px; flex-shrink: 0; }
|
||||||
|
.cms-outline--nested { margin: 0; padding: 0 0 0 2px; overflow: visible; list-style: none; }
|
||||||
|
.cms-outline--tree { padding: 4px 6px; }
|
||||||
.cms-ogap { height: 6px; border-radius: 3px; margin: 1px 6px; transition: background .1s; }
|
.cms-ogap { height: 6px; border-radius: 3px; margin: 1px 6px; transition: background .1s; }
|
||||||
.cms-ogap.is-over { background: rgba(94,224,160,.7); height: 8px; box-shadow: 0 0 0 2px rgba(94,224,160,.25); }
|
.cms-ogap.is-over { background: rgba(94,224,160,.7); height: 8px; box-shadow: 0 0 0 2px rgba(94,224,160,.25); }
|
||||||
.cms-ogap.is-refused { background: rgba(163,58,74,.35); }
|
.cms-ogap.is-refused { background: rgba(163,58,74,.35); }
|
||||||
.cms-oslot { list-style: none; }
|
.cms-oslot { list-style: none; min-width: 0; }
|
||||||
.cms-oslot__head { display: flex; align-items: center; gap: 6px; padding: 3px 6px 3px 4px; color: #8b8fa0; font-size: 12px; }
|
.cms-oslot__head { display: flex; align-items: center; gap: 6px; padding: 6px 6px 2px; color: #8b8fa0; font-size: 11px; letter-spacing: .04em; }
|
||||||
|
.cms-oslot__head > svg { flex-shrink: 0; }
|
||||||
|
.cms-oslot__head > span:first-of-type { font-weight: 600; text-transform: uppercase; font-size: 10.5px; flex-shrink: 0; }
|
||||||
|
.cms-oslot__head .cms-ico { margin-left: auto; min-width: 0; width: 22px; height: 22px; padding: 0; }
|
||||||
.cms-oslot__head .cms-muted { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1; }
|
.cms-oslot__head .cms-muted { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1; }
|
||||||
.cms-oslot-tag { font-style: normal; font-size: 10px; padding: 0 5px; border-radius: 99px; background: #2b3350; color: #9fb2ff; }
|
.cms-oslot-tag { font-style: normal; font-size: 10px; padding: 0 5px; border-radius: 99px; background: #2b3350; color: #9fb2ff; }
|
||||||
.cms-nodefind { display: flex; flex-direction: column; gap: 6px; }
|
.cms-nodefind { display: flex; flex-direction: column; gap: 6px; }
|
||||||
@@ -258,8 +271,11 @@ input.cms-map__key { padding-top: 6px; }
|
|||||||
.cms-noderow:active { cursor: grabbing; }
|
.cms-noderow:active { cursor: grabbing; }
|
||||||
.cms-noderow__main { flex: 1; min-width: 0; display: flex; flex-direction: column; overflow: hidden; white-space: nowrap; }
|
.cms-noderow__main { flex: 1; min-width: 0; display: flex; flex-direction: column; overflow: hidden; white-space: nowrap; }
|
||||||
.cms-pad { padding: 10px 12px 4px; }
|
.cms-pad { padding: 10px 12px 4px; }
|
||||||
.cms-sectoggle { background: none; border: 0; padding: 0; font: inherit; letter-spacing: inherit; text-transform: inherit; color: inherit; cursor: pointer; display: inline-flex; align-items: center; gap: 6px; }
|
.cms-sectoggle { background: none; border: 0; padding: 2px 0; font: inherit; letter-spacing: inherit; text-transform: inherit; color: inherit; cursor: pointer; display: inline-flex; align-items: center; gap: 6px; width: 100%; text-align: left; }
|
||||||
.cms-sectoggle:hover { color: var(--cms-accent); }
|
.cms-sectoggle:hover { color: var(--cms-accent); }
|
||||||
|
.cms-sectoggle .cms-chev { flex-shrink: 0; transition: transform .12s; }
|
||||||
|
.cms-sectoggle[aria-expanded="false"] .cms-chev { transform: rotate(0deg); }
|
||||||
|
.cms-sectoggle[aria-expanded="true"] .cms-chev { transform: rotate(90deg); }
|
||||||
|
|
||||||
/* experiments QA panel */
|
/* experiments QA panel */
|
||||||
.cms-explist { list-style: none; margin: 8px 0; padding: 0; display: flex; flex-direction: column; gap: 10px; }
|
.cms-explist { list-style: none; margin: 8px 0; padding: 0; display: flex; flex-direction: column; gap: 10px; }
|
||||||
@@ -273,18 +289,14 @@ input.cms-map__key { padding-top: 6px; }
|
|||||||
.cms-exp__actions .cms-input { max-width: 150px; }
|
.cms-exp__actions .cms-input { max-width: 150px; }
|
||||||
.cms-exp__actions .cms-ico.is-on { border-color: #a33a4a; color: #ff8a8a; }
|
.cms-exp__actions .cms-ico.is-on { border-color: #a33a4a; color: #ff8a8a; }
|
||||||
.cms-hint { font-size: 11.5px; margin-top: 6px; }
|
.cms-hint { font-size: 11.5px; margin-top: 6px; }
|
||||||
.cms-outline { list-style: none; margin: 0; padding: 4px; overflow: auto; }
|
|
||||||
.cms-outline li button { display: flex; align-items: center; gap: 8px; width: 100%; background: none; border: 0; border-radius: 6px; padding: 4px 8px; text-align: left; overflow: hidden; white-space: nowrap; }
|
|
||||||
.cms-outline li button:hover, .cms-outline li button.is-sel { background: #242836; }
|
|
||||||
.cms-outline li button.is-sel { color: var(--cms-accent); }
|
|
||||||
.cms-outline__type { font-weight: 600; } .cms-outline__text { color: #8b8fa0; overflow: hidden; text-overflow: ellipsis; }
|
|
||||||
.cms-palette { overflow: auto; padding-bottom: 12px; }
|
.cms-palette { overflow: auto; padding-bottom: 12px; }
|
||||||
.cms-palette h4 { margin: 10px 12px 6px; font-size: 11px; letter-spacing: .08em; text-transform: uppercase; color: #8b8fa0; }
|
.cms-palette h4 { margin: 10px 12px 6px; font-size: 11px; letter-spacing: .08em; text-transform: uppercase; color: #8b8fa0; }
|
||||||
.cms-palette__grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 6px; padding: 0 10px; }
|
.cms-palette__grid { display: flex; flex-direction: column; gap: 4px; padding: 0 10px; }
|
||||||
.cms-block { display: flex; flex-direction: column; align-items: center; gap: 6px; padding: 12px 6px 8px; background: #1a1d26; border: 1px solid var(--cms-line); border-radius: 10px; cursor: grab; touch-action: none; user-select: none; text-align: center; }
|
.cms-block { display: flex; flex-direction: row; align-items: center; gap: 10px; padding: 7px 10px; background: #1a1d26; border: 1px solid var(--cms-line); border-radius: 8px; cursor: grab; touch-action: none; user-select: none; text-align: left; width: 100%; }
|
||||||
.cms-block svg { color: var(--cms-accent); } .cms-block span { font-size: 12px; overflow: hidden; text-overflow: ellipsis; max-width: 100%; }
|
.cms-block svg { color: var(--cms-accent); flex-shrink: 0; } .cms-block span { font-size: 12.5px; font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1; min-width: 0; }
|
||||||
.cms-block:hover { border-color: #4a5a9a; background: #20243a; }
|
.cms-block:hover { border-color: #4a5a9a; background: #20243a; }
|
||||||
.cms-block:active { cursor: grabbing; }
|
.cms-block:active { cursor: grabbing; }
|
||||||
|
.cms-block small { margin-left: auto; flex-shrink: 0; }
|
||||||
.cms-dev header .cms-ico { margin-left: auto; }
|
.cms-dev header .cms-ico { margin-left: auto; }
|
||||||
|
|
||||||
/* sort snapping: faint markers at every candidate position, the active one animates between them */
|
/* sort snapping: faint markers at every candidate position, the active one animates between them */
|
||||||
|
|||||||
@@ -114,6 +114,39 @@ describe('rail sections', () => {
|
|||||||
expect(q('.cms-dev__body').textContent).toContain('blockEditors')
|
expect(q('.cms-dev__body').textContent).toContain('blockEditors')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('split mode shows the outline beside blocks/nodes tabs for drag & drop', async () => {
|
||||||
|
h = await setup()
|
||||||
|
await h.settle()
|
||||||
|
await h.press('Panel')
|
||||||
|
await h.settle()
|
||||||
|
// stacked by default: single column, no tabs
|
||||||
|
expect(h.shadow.querySelector('.cms-rail.is-split')).toBeNull()
|
||||||
|
await act(async () => void q('button[aria-label="Split panels side by side"]').click())
|
||||||
|
await h.settle()
|
||||||
|
expect(q('.cms-rail.is-split')).not.toBeNull()
|
||||||
|
// outline and blocks visible at the same time: a drag source can reach a gap
|
||||||
|
expect(qa('.cms-outline').length).toBeGreaterThan(0)
|
||||||
|
expect(q('[aria-label="Search blocks"]')).not.toBeNull()
|
||||||
|
// switch the library tab to nodes: blocks hide, nodes show, outline stays
|
||||||
|
await act(async () => void q('button[aria-label="Show nodes panel"]').click())
|
||||||
|
await h.settle()
|
||||||
|
expect(h.shadow.querySelector('[aria-label="Search blocks"]')).toBeNull()
|
||||||
|
expect(q('[aria-label="Search nodes"]')).not.toBeNull()
|
||||||
|
expect(qa('.cms-outline').length).toBeGreaterThan(0)
|
||||||
|
// starting a palette drag reveals outline gaps while both columns stay visible
|
||||||
|
h.editor.ctrl.beginPaletteDrag({ type: 'text', label: 'text' })
|
||||||
|
await h.settle()
|
||||||
|
expect(qa('[data-ogap]').length).toBeGreaterThan(0)
|
||||||
|
expect(q('[aria-label="Search nodes"]')).not.toBeNull()
|
||||||
|
await act(async () => void h.editor.ctrl.cancelDrag())
|
||||||
|
await h.settle()
|
||||||
|
// stacking collapses back to one column and remembers the choice
|
||||||
|
await act(async () => void q('button[aria-label="Stack panels"]').click())
|
||||||
|
await h.settle()
|
||||||
|
expect(h.shadow.querySelector('.cms-rail.is-split')).toBeNull()
|
||||||
|
expect(localStorage.getItem('cms-onpage.railsplit')).toBe('false')
|
||||||
|
})
|
||||||
|
|
||||||
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