mirror of
https://github.com/stablyai/orca.git
synced 2026-11-02 01:57:57 +00:00
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>
This commit is contained in:
@@ -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 <code>first</code>', '<code>Middle text</code>', '<code>last</code> 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('` `')
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user