From d6a0c4aff52bab268799e9f7c2c5b62f2410c958 Mon Sep 17 00:00:00 2001 From: Paul Nothaft <53005142+the-luap@users.noreply.github.com> Date: Mon, 7 Sep 2026 20:41:29 +0200 Subject: [PATCH] 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 --- frontend/src/components/admin/CMSEditor.tsx | 90 +++++++++++++------ .../__tests__/cmsEditorStickyToolbar.test.ts | 55 ++++++++++++ 2 files changed, 120 insertions(+), 25 deletions(-) create mode 100644 frontend/src/components/admin/__tests__/cmsEditorStickyToolbar.test.ts diff --git a/frontend/src/components/admin/CMSEditor.tsx b/frontend/src/components/admin/CMSEditor.tsx index e99dd87c..e6dc5fe0 100644 --- a/frontend/src/components/admin/CMSEditor.tsx +++ b/frontend/src/components/admin/CMSEditor.tsx @@ -61,6 +61,7 @@ export const CMSEditor: React.FC = ({ content, onChange, onSave, const [showHelp, setShowHelp] = useState(false); const [wordCount, setWordCount] = useState(0); const [charCount, setCharCount] = useState(0); + const toolbarRef = React.useRef(null); const editor = useEditor({ extensions: [ @@ -117,6 +118,34 @@ export const CMSEditor: React.FC = ({ content, onChange, onSave, }, []); // 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(() => { if (editor && content !== editor.getHTML()) { editor.commands.setContent(content); @@ -187,9 +216,19 @@ export const CMSEditor: React.FC = ({ content, onChange, onSave, return (
-
- {/* Top Toolbar */} -
+ {/* overflow-clip, not overflow-hidden: both clip the rounded corners, but + hidden makes this box a scroll container, and the sticky toolbar + below would pin to it instead of to the admin page's scroller. */} +
+ {/* 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. */} +
{/* View Mode Controls */}
@@ -428,30 +467,31 @@ export const CMSEditor: React.FC = ({ content, onChange, onSave,
)} + {/* 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 && ( +
+ 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 + /> + + +
+ )}
- {/* Link Dialog */} - {showLinkDialog && ( -
- 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 - /> - - -
- )} - {/* Editor Content Area */}
{/* Editor — prose-invert in dark mode flips the prose typography diff --git a/frontend/src/components/admin/__tests__/cmsEditorStickyToolbar.test.ts b/frontend/src/components/admin/__tests__/cmsEditorStickyToolbar.test.ts new file mode 100644 index 00000000..6ad8aa15 --- /dev/null +++ b/frontend/src/components/admin/__tests__/cmsEditorStickyToolbar.test.ts @@ -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'); + }); +});