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:
co-authored by
Paul Nothaft
parent
fb9da72f14
commit
d6a0c4aff5
@@ -61,6 +61,7 @@ export const CMSEditor: React.FC<CMSEditorProps> = ({ content, onChange, onSave,
|
||||
const [showHelp, setShowHelp] = useState(false);
|
||||
const [wordCount, setWordCount] = useState(0);
|
||||
const [charCount, setCharCount] = useState(0);
|
||||
const toolbarRef = React.useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const editor = useEditor({
|
||||
extensions: [
|
||||
@@ -117,6 +118,34 @@ export const CMSEditor: React.FC<CMSEditorProps> = ({ 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<CMSEditorProps> = ({ content, onChange, onSave,
|
||||
|
||||
return (
|
||||
<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">
|
||||
{/* Top Toolbar */}
|
||||
<div className="border-b border-neutral-200 dark:border-neutral-700 bg-neutral-50 dark:bg-neutral-800">
|
||||
{/* 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. */}
|
||||
<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 */}
|
||||
<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">
|
||||
@@ -428,9 +467,9 @@ export const CMSEditor: React.FC<CMSEditorProps> = ({ content, onChange, onSave,
|
||||
</MenuButton>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Link Dialog */}
|
||||
{/* 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
|
||||
@@ -451,6 +490,7 @@ export const CMSEditor: React.FC<CMSEditorProps> = ({ content, onChange, onSave,
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Editor Content Area */}
|
||||
<div className="flex-1 flex overflow-hidden">
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user