diff --git a/.changeset/reimplement_responsive_composer_layout.md b/.changeset/reimplement_responsive_composer_layout.md new file mode 100644 index 000000000..096a84ec8 --- /dev/null +++ b/.changeset/reimplement_responsive_composer_layout.md @@ -0,0 +1,5 @@ +--- +default: patch +--- + +Reimplement responsive composer layout diff --git a/src/app/components/editor/Editor.test.tsx b/src/app/components/editor/Editor.test.tsx index efc78859e..1ac31ee7e 100644 --- a/src/app/components/editor/Editor.test.tsx +++ b/src/app/components/editor/Editor.test.tsx @@ -92,13 +92,18 @@ describe('CustomEditor layout', () => { expect(row(container)).not.toHaveClass(css.EditorRowMultiline); }); - it('keeps buttons inline however long the text is', async () => { + it('moves buttons below text when a single line wraps', async () => { const { container, editor } = renderEditor({ after: }); + Object.defineProperty(row(container), 'clientWidth', { configurable: true, value: 100 }); + const measurer = container.querySelector('[data-editor-measurer]')!; + Object.defineProperty(measurer, 'scrollHeight', { + configurable: true, + get: () => (measurer.textContent === 'M' ? 20 : 40), + }); act(() => editor.insertText('text long enough to wrap several times over in the composer')); - await waitFor(() => expect(editor.isEmpty()).toBe(false)); - expect(row(container)).not.toHaveClass(css.EditorRowMultiline); + await waitFor(() => expect(row(container)).toHaveClass(css.EditorRowMultiline)); }); it('keeps buttons inline across many paragraphs', async () => { @@ -109,15 +114,15 @@ describe('CustomEditor layout', () => { act(() => editor.insertText('two')); await waitFor(() => expect(editor.getText()).toBe('one\ntwo')); - expect(row(container)).not.toHaveClass(css.EditorRowMultiline); + expect(row(container)).toHaveClass(css.EditorRowMultiline); }); - it('never installs a hidden measurer', () => { + it('installs a hidden measurer for text layout', () => { const { container, editor } = renderEditor({ after: }); act(() => editor.insertText('some text')); - expect(container.querySelector('[data-editor-measurer]')).toBeNull(); + expect(container.querySelector('[data-editor-measurer]')).toBeInTheDocument(); }); it('stacks the layout and moves responsive content into the footer when forced', () => { diff --git a/src/app/components/editor/Editor.tsx b/src/app/components/editor/Editor.tsx index 24eea2e71..3b3b83d71 100644 --- a/src/app/components/editor/Editor.tsx +++ b/src/app/components/editor/Editor.tsx @@ -4,7 +4,7 @@ import type { MutableRefObject, ReactNode, } from 'react'; -import { forwardRef, useCallback, useEffect, useRef, useState } from 'react'; +import { forwardRef, useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'; import { Box, Scroll } from 'folds'; import { iosApp, isMobileOrTablet } from '$utils/platform'; import { readClipboardText } from '$utils/dom'; @@ -28,6 +28,11 @@ export const useEditor = (): ProseMirrorEditorController => { }; const log = createLogger('Editor'); +const MULTILINE_HEIGHT_EPSILON = 1; +const TRAILING_SPACE_SENTINEL = '\u200B'; + +const normalizeMeasurementText = (text: string): string => + /[ \t]+$/.test(text) ? `${text}${TRAILING_SPACE_SENTINEL}` : text; type CustomEditorProps = { after?: ReactNode; @@ -81,13 +86,102 @@ export const CustomEditor = forwardRef( const [shortcutOverrides] = useSetting(settingsAtom, 'shortcutOverrides'); const [alwaysInlineEditor] = useSetting(settingsAtom, 'alwaysInlineEditor'); const rootRef = useRef(null); + const rowRef = useRef(null); + const beforeRef = useRef(null); + const afterRef = useRef(null); + const editableRef = useRef(null); + const measurerRef = useRef(null); + const latestTextRef = useRef(editor.getText()); const focusScrollTimerRef = useRef(); + const [isMultiline, setIsMultiline] = useState(false); + const [measurementVersion, setMeasurementVersion] = useState(0); - // Buttons stay inline however tall the composer grows; only the audio - // recorder stacks, because its controls need a row of their own. - const layoutIsMultiline = !alwaysInlineEditor && forceMultilineLayout; + const hasBefore = Boolean(before); + const hasAfter = Boolean(after); + const layoutIsMultiline = !alwaysInlineEditor && (isMultiline || forceMultilineLayout); const showResponsiveAfterInFooter = Boolean(responsiveAfter) && layoutIsMultiline; + const updateMultilineLayout = useCallback(() => { + const text = latestTextRef.current; + const row = rowRef.current; + const measurer = measurerRef.current; + const editable = editableRef.current; + if (!row || !measurer || !editable) return; + + let nextMultiline = text.includes('\n'); + if (!nextMultiline && text.length > 0) { + const computedStyle = getComputedStyle(editable); + const beforeWidth = beforeRef.current?.offsetWidth ?? 0; + const afterWidth = afterRef.current?.offsetWidth ?? 0; + const width = Math.max(0, row.clientWidth - beforeWidth - afterWidth); + if (width > 0) { + Object.assign(measurer.style, { + font: computedStyle.font, + lineHeight: computedStyle.lineHeight, + letterSpacing: computedStyle.letterSpacing, + fontKerning: computedStyle.fontKerning, + fontFeatureSettings: computedStyle.fontFeatureSettings, + fontVariationSettings: computedStyle.fontVariationSettings, + textTransform: computedStyle.textTransform, + textIndent: computedStyle.textIndent, + tabSize: computedStyle.tabSize, + width: 'max-content', + }); + measurer.textContent = 'M'; + const singleLineHeight = measurer.scrollHeight; + measurer.style.width = `${width}px`; + measurer.textContent = normalizeMeasurementText(text); + nextMultiline = measurer.scrollHeight > singleLineHeight + MULTILINE_HEIGHT_EPSILON; + } + } + setIsMultiline(nextMultiline); + }, []); + + useEffect(() => { + const root = rootRef.current; + if (!root) return undefined; + const measurerHost = document.createElement('div'); + const measurer = document.createElement('div'); + measurer.dataset.editorMeasurer = editableName ?? ''; + Object.assign(measurerHost.style, { + position: 'absolute', + width: '0', + height: '0', + overflow: 'hidden', + pointerEvents: 'none', + visibility: 'hidden', + }); + Object.assign(measurer.style, { + padding: '0', + border: '0', + margin: '0', + whiteSpace: 'pre-wrap', + overflowWrap: 'break-word', + wordBreak: 'break-word', + boxSizing: 'border-box', + }); + measurerHost.appendChild(measurer); + root.appendChild(measurerHost); + measurerRef.current = measurer; + return () => { + measurerRef.current = null; + measurerHost.remove(); + }; + }, [editableName]); + + useLayoutEffect(() => { + updateMultilineLayout(); + }, [measurementVersion, updateMultilineLayout]); + + useEffect(() => { + if (typeof ResizeObserver === 'undefined') return undefined; + const observer = new ResizeObserver(updateMultilineLayout); + [rowRef.current, beforeRef.current, afterRef.current].forEach((element) => { + if (element) observer.observe(element); + }); + return () => observer.disconnect(); + }, [updateMultilineLayout, hasBefore, hasAfter]); + useEffect(() => () => window.clearTimeout(focusScrollTimerRef.current), []); const handleKeyDown: KeyboardEventHandler = useCallback( (event) => { @@ -126,21 +220,25 @@ export const CustomEditor = forwardRef( ); const handleDocumentChange = useCallback( (document: EditorDocument) => { + latestTextRef.current = editor.getText(); + setMeasurementVersion((version) => version + 1); onChange?.(document); }, - [onChange] + [editor, onChange] ); return (
{top} {before && ( ( hideTrack > { + editableRef.current = element; + }} controller={editor} editableName={editableName} editorClassName={css.EditorTextarea} @@ -189,6 +290,7 @@ export const CustomEditor = forwardRef( {(after || (responsiveAfter && !showResponsiveAfterInFooter)) && (