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)) && (