Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/reimplement_responsive_composer_layout.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
default: patch
---

Reimplement responsive composer layout
17 changes: 11 additions & 6 deletions src/app/components/editor/Editor.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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: <button type="button">Send</button> });
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 () => {
Expand All @@ -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: <button type="button">Send</button> });

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', () => {
Expand Down
112 changes: 107 additions & 5 deletions src/app/components/editor/Editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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;
Expand Down Expand Up @@ -81,13 +86,102 @@ export const CustomEditor = forwardRef<HTMLDivElement, CustomEditorProps>(
const [shortcutOverrides] = useSetting(settingsAtom, 'shortcutOverrides');
const [alwaysInlineEditor] = useSetting(settingsAtom, 'alwaysInlineEditor');
const rootRef = useRef<HTMLDivElement | null>(null);
const rowRef = useRef<HTMLDivElement | null>(null);
const beforeRef = useRef<HTMLDivElement | null>(null);
const afterRef = useRef<HTMLDivElement | null>(null);
const editableRef = useRef<HTMLDivElement | null>(null);
const measurerRef = useRef<HTMLDivElement | null>(null);
const latestTextRef = useRef(editor.getText());
const focusScrollTimerRef = useRef<number>();
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) => {
Expand Down Expand Up @@ -126,21 +220,25 @@ export const CustomEditor = forwardRef<HTMLDivElement, CustomEditorProps>(
);
const handleDocumentChange = useCallback(
(document: EditorDocument) => {
latestTextRef.current = editor.getText();
setMeasurementVersion((version) => version + 1);
onChange?.(document);
},
[onChange]
[editor, onChange]
);

return (
<div ref={setRootRef} className={`${css.Editor} ${className ?? ''}`}>
{top}
<Box
ref={rowRef}
className={`${css.EditorRow} ${layoutIsMultiline ? css.EditorRowMultiline : ''} ${showResponsiveAfterInFooter ? css.EditorRowMultilineWithResponsiveAfter : ''}`}
alignItems="Start"
style={{ display: after ? 'grid' : 'flex' }}
>
{before && (
<Box
ref={beforeRef}
className={`${css.EditorOptions} ${layoutIsMultiline ? css.EditorOptionsMultiline : ''}`}
alignItems="Center"
gap="100"
Expand All @@ -158,6 +256,9 @@ export const CustomEditor = forwardRef<HTMLDivElement, CustomEditorProps>(
hideTrack
>
<ProseMirrorEditable
onHostChange={(element) => {
editableRef.current = element;
}}
controller={editor}
editableName={editableName}
editorClassName={css.EditorTextarea}
Expand Down Expand Up @@ -189,6 +290,7 @@ export const CustomEditor = forwardRef<HTMLDivElement, CustomEditorProps>(
</Scroll>
{(after || (responsiveAfter && !showResponsiveAfterInFooter)) && (
<Box
ref={afterRef}
className={`${css.EditorOptions} ${layoutIsMultiline ? `${css.EditorOptionsMultiline} ${css.EditorOptionsAfterMultiline}` : ''}`}
alignItems="Center"
gap="100"
Expand Down
Loading