feat(cms): keep the editor toolbar in reach on long pages (#1335)

On the CMS page the editor has no bounded height, so a long document
scrolls the whole admin content area and the toolbar scrolled away with
it. Editing a 16-section privacy policy meant scrolling back to the top
for every heading or list.

The top toolbar block (mode/save row and formatting row) is now
`sticky top-0` from the md breakpoint up, pinned to the admin page's
scroller. The rounded wrapper clips with `overflow-clip` instead of
`overflow-hidden`, because hidden turns the wrapper into a scroll
container and the toolbar would pin to that instead of to the page. Not
below md: there the formatting row wraps to several lines and a
permanently stuck block would eat most of a phone's editing area.

Two things follow from pinning. The link-entry row moves inside the
sticky block: rendered below it, the URL field sat at the toolbar's
original document position, under the pinned toolbar. And ProseMirror's
selection scrolling gets a top threshold and margin sized from the
block's rendered height (ResizeObserver, re-applied through
editor.setOptions), because the formatting row wraps to two rows at
common desktop widths and the link row comes and goes; a constant would
leave the caret behind the toolbar half the time. Below md the offsets
are zero again.

Verified in Chromium against the CMS page with an 18-section document:
scrolled to the last sections, the toolbar stays at the top of the
content area; on main it is gone. A source-level test pins the sticky
block, the wrapper's clip, the link row's placement and the measured
offsets, since jsdom does not lay out.

Relates to issue 1289

Co-authored-by: Paul Nothaft <[email protected]>
This commit is contained in:
Paul Nothaft
2026-09-07 20:41:29 +02:00
committed by GitHub
co-authored by Paul Nothaft
parent fb9da72f14
commit d6a0c4aff5
2 changed files with 120 additions and 25 deletions
+65 -25
View File
@@ -61,6 +61,7 @@ export const CMSEditor: React.FC<CMSEditorProps> = ({ content, onChange, onSave,
const [showHelp, setShowHelp] = useState(false); const [showHelp, setShowHelp] = useState(false);
const [wordCount, setWordCount] = useState(0); const [wordCount, setWordCount] = useState(0);
const [charCount, setCharCount] = useState(0); const [charCount, setCharCount] = useState(0);
const toolbarRef = React.useRef<HTMLDivElement | null>(null);
const editor = useEditor({ const editor = useEditor({
extensions: [ extensions: [
@@ -117,6 +118,34 @@ export const CMSEditor: React.FC<CMSEditorProps> = ({ content, onChange, onSave,
}, []); }, []);
// Update editor content when prop changes // Update editor content when prop changes
// With the toolbar pinned (#1289), ProseMirror's default scroll margin
// would treat a caret directly under it as visible, so arrowing upward
// through a long document could edit text behind the toolbar. The block's
// height is not a constant: the formatting row wraps to two rows at common
// desktop widths, and the link-entry row comes and goes. Measure it and
// hand ProseMirror the offsets through setOptions, which re-applies
// editorProps to the live view. Below md the toolbar is not sticky, so the
// offsets go back to zero rather than over-scrolling by a whole toolbar.
React.useEffect(() => {
const block = toolbarRef.current;
if (!editor || !block) return;
const apply = () => {
const pinned = typeof window.matchMedia === 'function' && window.matchMedia('(min-width: 768px)').matches;
const height = pinned ? Math.ceil(block.getBoundingClientRect().height) : 0;
editor.setOptions({
editorProps: {
scrollThreshold: { top: height + 8, right: 0, bottom: 0, left: 0 },
scrollMargin: { top: height + 16, right: 0, bottom: 0, left: 0 },
},
});
};
apply();
if (typeof ResizeObserver === 'undefined') return;
const observer = new ResizeObserver(apply);
observer.observe(block);
return () => observer.disconnect();
}, [editor, viewMode, showLinkDialog]);
React.useEffect(() => { React.useEffect(() => {
if (editor && content !== editor.getHTML()) { if (editor && content !== editor.getHTML()) {
editor.commands.setContent(content); editor.commands.setContent(content);
@@ -187,9 +216,19 @@ export const CMSEditor: React.FC<CMSEditorProps> = ({ content, onChange, onSave,
return ( return (
<div className={`relative ${isFullscreen ? 'fixed inset-0 z-50 bg-white dark:bg-neutral-900' : ''}`}> <div className={`relative ${isFullscreen ? 'fixed inset-0 z-50 bg-white dark:bg-neutral-900' : ''}`}>
<div className="border border-neutral-300 dark:border-neutral-700 rounded-lg overflow-hidden h-full flex flex-col bg-white dark:bg-neutral-900"> {/* overflow-clip, not overflow-hidden: both clip the rounded corners, but
{/* Top Toolbar */} hidden makes this box a scroll container, and the sticky toolbar
<div className="border-b border-neutral-200 dark:border-neutral-700 bg-neutral-50 dark:bg-neutral-800"> below would pin to it instead of to the admin page's scroller. */}
<div className="border border-neutral-300 dark:border-neutral-700 rounded-lg overflow-clip h-full flex flex-col bg-white dark:bg-neutral-900">
{/* Top Toolbar — sticky from md up (#1289). The editor pane has no
bounded height on the CMS page, so a long document scrolls the
whole admin content area and the toolbar used to leave with it;
editing a 16-section privacy policy meant scrolling back up for
every heading. Sticking it to the page's scroller keeps both rows
(mode/save and formatting) in reach. Not below md: there the
formatting row wraps to several lines and would permanently eat
most of a phone's editing area. */}
<div ref={toolbarRef} className="border-b border-neutral-200 dark:border-neutral-700 bg-neutral-50 dark:bg-neutral-800 md:sticky md:top-0 md:z-10">
{/* View Mode Controls */} {/* View Mode Controls */}
<div className="flex items-center justify-between p-2 border-b border-neutral-200 dark:border-neutral-700"> <div className="flex items-center justify-between p-2 border-b border-neutral-200 dark:border-neutral-700">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -428,30 +467,31 @@ export const CMSEditor: React.FC<CMSEditorProps> = ({ content, onChange, onSave,
</MenuButton> </MenuButton>
</div> </div>
)} )}
{/* Link entry row — inside the sticky block on purpose (#1289):
rendered below it, the URL field ended up at the toolbar's
original document position, under the pinned toolbar. */}
{showLinkDialog && (
<div className="p-3 bg-accent-dark/15 border-b border-accent-dark/30 flex items-center gap-2">
<input
type="url"
value={linkUrl}
onChange={(e) => setLinkUrl(e.target.value)}
onKeyPress={(e) => e.key === 'Enter' && addLink()}
placeholder={t('cms.editor.linkUrlPlaceholder', 'Enter URL...')}
className="flex-1 px-3 py-1 border border-accent-dark/30 bg-white dark:bg-neutral-900 text-neutral-900 dark:text-neutral-100 rounded-md focus:ring-2 focus:ring-primary-500"
autoFocus
/>
<Button size="sm" onClick={addLink}>{t('cms.editor.addLink', 'Add Link')}</Button>
<Button size="sm" variant="outline" onClick={() => {
setShowLinkDialog(false);
setLinkUrl('');
}}>
{t('common.cancel', 'Cancel')}
</Button>
</div>
)}
</div> </div>
{/* Link Dialog */}
{showLinkDialog && (
<div className="p-3 bg-accent-dark/15 border-b border-accent-dark/30 flex items-center gap-2">
<input
type="url"
value={linkUrl}
onChange={(e) => setLinkUrl(e.target.value)}
onKeyPress={(e) => e.key === 'Enter' && addLink()}
placeholder={t('cms.editor.linkUrlPlaceholder', 'Enter URL...')}
className="flex-1 px-3 py-1 border border-accent-dark/30 bg-white dark:bg-neutral-900 text-neutral-900 dark:text-neutral-100 rounded-md focus:ring-2 focus:ring-primary-500"
autoFocus
/>
<Button size="sm" onClick={addLink}>{t('cms.editor.addLink', 'Add Link')}</Button>
<Button size="sm" variant="outline" onClick={() => {
setShowLinkDialog(false);
setLinkUrl('');
}}>
{t('common.cancel', 'Cancel')}
</Button>
</div>
)}
{/* Editor Content Area */} {/* Editor Content Area */}
<div className="flex-1 flex overflow-hidden"> <div className="flex-1 flex overflow-hidden">
{/* Editor — prose-invert in dark mode flips the prose typography {/* Editor — prose-invert in dark mode flips the prose typography
@@ -0,0 +1,55 @@
/**
* Sticky editor toolbar (#1289).
*
* On the CMS page the editor has no bounded height, so a long document
* scrolls the whole admin content area and the toolbar scrolled away with
* it — editing a 16-section privacy policy meant scrolling back up for every
* heading. Two things make the toolbar stick to the page's scroller, and
* both are easy to lose in a refactor: the toolbar block is `sticky` (from
* md up, so a phone's wrapped toolbar does not eat the editing area), and
* the rounded wrapper clips with `overflow-clip` rather than
* `overflow-hidden`, because hidden turns the wrapper into a scroll
* container and the toolbar would pin to that instead of to the page.
*
* jsdom does not lay out, so this pins the source.
*/
import fs from 'fs';
import path from 'path';
import { describe, it, expect } from 'vitest';
const source = fs.readFileSync(path.resolve(__dirname, '../CMSEditor.tsx'), 'utf8');
describe('CMS editor toolbar stays in reach on long pages', () => {
it('sticks the toolbar block to the page scroller from md up', () => {
expect(source).toMatch(/className="[^"]*\bmd:sticky md:top-0 md:z-10\b[^"]*"/);
});
it('keeps the link-entry row inside the sticky block', () => {
const sticky = source.indexOf('md:sticky md:top-0 md:z-10');
const linkRow = source.indexOf('{showLinkDialog && (');
const contentArea = source.indexOf('{/* Editor Content Area */}');
expect(sticky).toBeGreaterThan(-1);
expect(linkRow).toBeGreaterThan(sticky);
expect(linkRow).toBeLessThan(contentArea);
// The block closes after the link row, not before it.
const between = source.slice(linkRow, contentArea);
expect((between.match(/^ {8}<\/div>$/m) || []).length).toBe(1);
});
it('scrolls the selection clear of the pinned toolbar, sized from the rendered block', () => {
// The formatting row wraps at common desktop widths and the link row
// comes and goes, so a constant offset would be wrong half the time.
expect(source).toMatch(/ref=\{toolbarRef\}[^>]*md:sticky/);
expect(source).toMatch(/new ResizeObserver\(apply\)/);
expect(source).toMatch(/scrollMargin: \{ top: height \+ \d+/);
expect(source).toMatch(/scrollThreshold: \{ top: height \+ \d+/);
expect(source).toMatch(/matchMedia\('\(min-width: 768px\)'\)/);
});
it('clips the rounded wrapper without creating a scroll container', () => {
const wrapper = source.match(/className="([^"]*\brounded-lg\b[^"]*\bh-full flex flex-col\b[^"]*)"/);
expect(wrapper).not.toBeNull();
expect(wrapper![1]).toContain('overflow-clip');
expect(wrapper![1]).not.toContain('overflow-hidden');
});
});