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  right',
+ '`left `` 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  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('')
+})
+
+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)
}