diff --git a/.changeset/fix-android-pill-recompose.md b/.changeset/fix-android-pill-recompose.md new file mode 100644 index 000000000..42a646bd9 --- /dev/null +++ b/.changeset/fix-android-pill-recompose.md @@ -0,0 +1,5 @@ +--- +default: patch +--- + +Fix the composer duplicating a mention or command label when erasing it on Android diff --git a/src/app/components/editor/Editor.css.ts b/src/app/components/editor/Editor.css.ts index c3845b046..f0c4ef92e 100644 --- a/src/app/components/editor/Editor.css.ts +++ b/src/app/components/editor/Editor.css.ts @@ -121,3 +121,13 @@ export const EditorToolbarBase = style({ export const EditorToolbar = style({ padding: config.space.S100, }); + +// Gboard recomposes the text node before the caret even inside a +// contenteditable=false pill, and Chrome appends each update after it. +export const AtomLabel = style({ + selectors: { + '&::before': { + content: 'attr(data-label)', + }, + }, +}); diff --git a/src/app/components/editor/prosemirrorNodeViews.test.tsx b/src/app/components/editor/prosemirrorNodeViews.test.tsx index 4374c5839..8563dadf9 100644 --- a/src/app/components/editor/prosemirrorNodeViews.test.tsx +++ b/src/app/components/editor/prosemirrorNodeViews.test.tsx @@ -39,7 +39,9 @@ describe('atom node views', () => { expect(pill).toHaveAttribute('contenteditable', 'false'); expect(pill.className).not.toBe(''); - expect(pill).toHaveTextContent('bob'); + expect(pill).toHaveAttribute('data-label', '@bob'); + // Gboard recomposes any text node before the caret, so the label must not be one. + expect(pill.textContent).toBe(''); }); it('renders an mxc emoticon as an image via the render context', () => { @@ -72,7 +74,10 @@ describe('atom node views', () => { ); expect(container.querySelector('img[alt="party"]')).toBeNull(); - expect(container.querySelector('.ProseMirror')).toHaveTextContent('🎉'); + expect(container.querySelector('.ProseMirror [data-label]')).toHaveAttribute( + 'data-label', + '🎉' + ); }); it('drops the command’s active styling once text precedes it', () => { @@ -88,7 +93,7 @@ describe('atom node views', () => { act(() => controller.setDocument(paragraph({ text: 'hi ' }, command))); const pill = container.querySelector('.ProseMirror > p > span') as HTMLElement; - expect(pill).toHaveTextContent('/shrug'); + expect(pill).toHaveAttribute('data-label', '/shrug'); expect(pill.className).not.toBe(activeClass); }); @@ -118,7 +123,8 @@ describe('atom node views', () => { const pill = container.querySelector('.ProseMirror > p > span')!; expect(pill).toHaveAttribute('contenteditable', 'false'); - expect(pill).toHaveTextContent('/shrug'); + expect(pill).toHaveAttribute('data-label', '/shrug'); + expect(pill.textContent).toBe(''); }); }); diff --git a/src/app/components/editor/prosemirrorNodeViews.ts b/src/app/components/editor/prosemirrorNodeViews.ts index e55079cb1..7afbaccae 100644 --- a/src/app/components/editor/prosemirrorNodeViews.ts +++ b/src/app/components/editor/prosemirrorNodeViews.ts @@ -3,6 +3,7 @@ import { Plugin } from 'prosemirror-state'; import type { Decoration, NodeView, NodeViewConstructor } from 'prosemirror-view'; import { Decoration as NodeDecoration, DecorationSet } from 'prosemirror-view'; import * as css from '$styles/CustomHtml.css'; +import { AtomLabel } from './Editor.css'; import { BlockType } from './types'; import type { MentionToken } from './model'; import { formatMentionElementDisplayName } from './utils'; @@ -31,6 +32,13 @@ const mentionTokenOf = (node: ProseMirrorNode): MentionToken => ({ children: [{ text: '' }], }); +/** Text lives in the attribute and a ::before pseudo-element, never the DOM. */ +const setAtomLabel = (dom: HTMLElement, className: string, label: string): void => { + dom.className = `${className} ${AtomLabel}`; + dom.setAttribute('data-label', label); + dom.setAttribute('aria-label', label); +}; + abstract class AtomNodeView implements NodeView { dom: HTMLElement; @@ -70,8 +78,11 @@ class MentionNodeView extends AtomNodeView { protected render(): void { const token = mentionTokenOf(this.node); - this.dom.className = css.Mention({ highlight: token.highlight, focus: this.selected }); - this.dom.textContent = this.context().mentionDisplayName(token); + setAtomLabel( + this.dom, + css.Mention({ highlight: token.highlight, focus: this.selected }), + this.context().mentionDisplayName(token) + ); } } @@ -98,8 +109,11 @@ class CommandNodeView extends AtomNodeView { } protected render(): void { - this.dom.className = css.Command({ focus: this.selected, active: this.active }); - this.dom.textContent = `/${this.node.attrs.command as string}`; + setAtomLabel( + this.dom, + css.Command({ focus: this.selected, active: this.active }), + `/${this.node.attrs.command as string}` + ); } } @@ -166,7 +180,7 @@ class EmoticonNodeView extends AtomNodeView { img.alt = shortcode; inner.append(img); } else { - inner.textContent = key.startsWith('mxc://') ? `:${shortcode}:` : key; + setAtomLabel(inner, inner.className, key.startsWith('mxc://') ? `:${shortcode}:` : key); } this.dom.replaceChildren(inner);