From 5d5073be6ca2aff3fa205b8be01c486948864745 Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Thu, 29 Oct 2026 19:13:25 -0700 Subject: [PATCH] fix(mobile): preserve blocks and images when formatting inline code (#35110) * fix(mobile): preserve blocks and images when formatting inline code * test(mobile): drop collapsed-selection inline code case --------- Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com> --- .../editor-inline-code-blocks.test.ts | 121 ++++++++++++++++++ .../rich-markdown/editor-selection.ts | 78 +++++++++-- 2 files changed, 190 insertions(+), 9 deletions(-) create mode 100644 mobile/src/components/rich-markdown/editor-inline-code-blocks.test.ts diff --git a/mobile/src/components/rich-markdown/editor-inline-code-blocks.test.ts b/mobile/src/components/rich-markdown/editor-inline-code-blocks.test.ts new file mode 100644 index 00000000000..e8f0770232c --- /dev/null +++ b/mobile/src/components/rich-markdown/editor-inline-code-blocks.test.ts @@ -0,0 +1,121 @@ +// @vitest-environment happy-dom +import { afterEach, expect, it } from 'vitest' +import { createRichMarkdownEditorDocument } from './create-rich-markdown-editor-document' +import { RICH_MARKDOWN_EDITOR_MARKUP } from './document-markup' +import { quietRichMarkdownEditorHost } from './rich-markdown-editor-host.test-support' +import type { MobileRichMarkdownEditorMessage } from '../mobile-rich-markdown-editor-contract' + +const stopDocuments: (() => void)[] = [] + +afterEach(() => { + stopDocuments.splice(0).forEach((stop) => stop()) + window.getSelection()?.removeAllRanges() + document.body.innerHTML = '' +}) + +function mount(markdown: string) { + document.body.innerHTML = RICH_MARKDOWN_EDITOR_MARKUP + const messages: MobileRichMarkdownEditorMessage[] = [] + const editorDocument = createRichMarkdownEditorDocument({ + ...quietRichMarkdownEditorHost(), + postToHost: (message) => messages.push(message) + }) + stopDocuments.push(editorDocument.stop) + editorDocument.send.setMarkdown(markdown, 7) + const surface = document.getElementById('editor')! + return { surface, messages, send: editorDocument.send } +} + +function select(range: Range) { + const selection = window.getSelection()! + selection.removeAllRanges() + selection.addRange(range) +} + +it.each([ + ['first\n\nsecond\n\nthird', '`first`\n\n`second`\n\n`third`', ['first', 'second', 'third']], + ['# Heading\n\n> Quoted', '# `Heading`\n\n> `Quoted`', ['Heading', 'Quoted']], + ['- first\n- second', '- `first`\n- `second`', ['first', 'second']], + ['- [ ] first\n- [x] second', '- [ ] `first`\n- [x] `second`', ['first', 'second']], + [ + 'left ![pixel](https://example.com/pixel.png) right', + '`left `![pixel](https://example.com/pixel.png)` right`', + ['left ', ' right'] + ], + ['left\nright', '`left`\n`right`', ['left', 'right']] +])('keeps blocks and embedded content when formatting %j', async (markdown, saved, codes) => { + const editor = mount(markdown) + const range = document.createRange() + range.selectNodeContents(editor.surface) + select(range) + await editor.send.runCommand('inlineCode') + expect( + editor.surface.querySelector( + 'code p,code h1,code blockquote,code ul,code li,code img,code br,code input' + ) + ).toBeNull() + expect( + Array.from(editor.surface.querySelectorAll('code')).map((code) => code.textContent) + ).toEqual(codes) + expect(editor.messages.filter((message) => message.type === 'change')).toEqual([ + { type: 'change', generation: 7, markdown: saved } + ]) + editor.send.setMarkdown(saved, 8) + expect( + Array.from(editor.surface.querySelectorAll('code')).map((code) => code.textContent) + ).toEqual(codes) + expect(editor.send.currentMarkdown()).toBe(saved) +}) + +it('keeps unselected paragraph prefixes and suffixes', async () => { + const editor = mount('Prefix first\n\nMiddle text\n\nlast suffix') + const paragraphs = editor.surface.querySelectorAll('p') + const range = document.createRange() + range.setStart(paragraphs[0].firstChild!, 7) + range.setEnd(paragraphs[2].firstChild!, 4) + select(range) + await editor.send.runCommand('inlineCode') + expect(editor.surface.querySelectorAll('p')).toHaveLength(3) + expect( + Array.from(editor.surface.querySelectorAll('p')).map((paragraph) => paragraph.innerHTML) + ).toEqual(['Prefix first', 'Middle text', 'last suffix']) + expect(editor.send.currentMarkdown()).toBe('Prefix `first`\n\n`Middle text`\n\n`last` suffix') + expect(window.getSelection()?.toString().replace(/\n/g, '')).toBe('firstMiddle textlast') +}) + +it('keeps an image nested inside inline formatting', async () => { + const editor = mount('**left ![pixel](https://example.com/pixel.png) right**') + const image = editor.surface.querySelector('img')! + const range = document.createRange() + range.selectNodeContents(editor.surface) + select(range) + await editor.send.runCommand('inlineCode') + expect(image.isConnected).toBe(true) + expect(image.closest('code')).toBeNull() + expect( + Array.from(editor.surface.querySelectorAll('code')).map((code) => code.textContent) + ).toEqual(['left ', ' right']) + expect(editor.send.currentMarkdown()).toContain('![pixel](https://example.com/pixel.png)') +}) + +it('leaves existing code blocks intact when selecting surrounding paragraphs', async () => { + const editor = mount('Before\n\n```ts\nconst n = 1\n```\n\nAfter') + const original = editor.surface.querySelector('pre')!.outerHTML + const range = document.createRange() + range.selectNodeContents(editor.surface) + select(range) + await editor.send.runCommand('inlineCode') + expect(editor.surface.querySelector('pre')!.outerHTML).toBe(original) + expect(editor.send.currentMarkdown()).toBe('`Before`\n\n```ts\nconst n = 1\n```\n\n`After`') +}) + +it('formats selected spaces within a paragraph', async () => { + const editor = mount('placeholder') + const paragraph = editor.surface.querySelector('p')! + paragraph.textContent = ' ' + const range = document.createRange() + range.selectNodeContents(paragraph) + select(range) + await editor.send.runCommand('inlineCode') + expect(editor.send.currentMarkdown()).toBe('` `') +}) diff --git a/mobile/src/components/rich-markdown/editor-selection.ts b/mobile/src/components/rich-markdown/editor-selection.ts index af473f9033e..82ce5cb2bf7 100644 --- a/mobile/src/components/rich-markdown/editor-selection.ts +++ b/mobile/src/components/rich-markdown/editor-selection.ts @@ -92,7 +92,55 @@ export function restoreRememberedSelection(scope: RichMarkdownEditorScope) { collapseToEnd(scope) } -// Wrap selected format lacking execCommand verb; extract/reinsert if surroundContents crosses element boundary. Emit nothing because runCommand alone reports change. +const BLOCK_CONTENT = 'p,div,h1,h2,h3,h4,h5,h6,blockquote,pre,ul,ol,li,table,thead,tbody,tr,th,td' +const INLINE_BOUNDARY = `${BLOCK_CONTENT},br,img,input,hr,[contenteditable="false"]` + +function selectedInlineRuns(parent: ParentNode, selected: Range): Range[] { + const ranges: Range[] = [] + const hasBlocks = Array.from(parent.children).some((child) => child.matches(BLOCK_CONTENT)) + let run: Node[] = [] + const flush = () => { + const first = run[0] + const last = run.at(-1) + if (first && last) { + const range = selected.cloneRange() + range.setStartBefore(first) + range.setEndAfter(last) + if (selected.comparePoint(range.startContainer, range.startOffset) < 0) { + range.setStart(selected.startContainer, selected.startOffset) + } + if (selected.comparePoint(range.endContainer, range.endOffset) > 0) { + range.setEnd(selected.endContainer, selected.endOffset) + } + const text = range.toString() + if (!range.collapsed && text && (!hasBlocks || text.trim())) { + ranges.push(range) + } + } + run = [] + } + for (const child of Array.from(parent.childNodes)) { + if (!selected.intersectsNode(child)) { + flush() + continue + } + if ( + child instanceof Element && + (child.matches(INLINE_BOUNDARY) || child.querySelector(INLINE_BOUNDARY)) + ) { + flush() + if (!child.matches('br,img,input,hr,[contenteditable="false"],pre')) { + ranges.push(...selectedInlineRuns(child, selected)) + } + } else { + run.push(child) + } + } + flush() + return ranges +} + +// Keep inline code inside each selected block and outside embedded controls. export function wrapSelection(scope: RichMarkdownEditorScope, tagName: string) { restoreSelectionOrEnd(scope) const selection = scope.getSelection() @@ -103,13 +151,25 @@ export function wrapSelection(scope: RichMarkdownEditorScope, tagName: string) { if (range.collapsed) { return } - const wrapper = scope.getDocument().createElement(tagName) - try { - range.surroundContents(wrapper) - } catch { - wrapper.appendChild(range.extractContents()) - range.insertNode(wrapper) + const ranges = selectedInlineRuns(editorElement(scope), range) + const wrappers: HTMLElement[] = [] + for (let index = ranges.length - 1; index >= 0; index -= 1) { + const selected = ranges[index] + const wrapper = scope.getDocument().createElement(tagName) + try { + selected.surroundContents(wrapper) + } catch { + wrapper.appendChild(selected.extractContents()) + selected.insertNode(wrapper) + } + wrappers.unshift(wrapper) + } + const first = wrappers[0] + const last = wrappers.at(-1) + if (first && last) { + const formatted = scope.getDocument().createRange() + formatted.setStart(first, 0) + formatted.setEnd(last, last.childNodes.length) + applySelectionRange(scope, formatted) } - selection.removeAllRanges() - selection.selectAllChildren(wrapper) }