mirror of
https://github.com/stablyai/orca.git
synced 2026-09-23 00:02:29 +00:00
* fix(editor): make markdown images inline so a paragraph stays schema-valid Image was registered as a block node while paragraph is content:'inline*', but the markdown pipeline nests an inline image as a paragraph child. Schema.nodeFromJSON does not validate content, so the editor built a schema-invalid document that rendered fine and threw on the first step that reassembled the paragraph - i.e. on the user's next keystroke. Report 0e46c048 (1.4.198, macOS): RangeError "Invalid content for node paragraph" from checkContent via Node.replace, tearing down the editor.rich-markdown boundary. Register Image as inline and override paragraph's parseMarkdown so a lone image is not hoisted out of its paragraph. Also fixes the same crash class reachable through details/summary. Markdown output is byte-identical. * fix(editor): keep a fenced code block intact when an image is inserted into it Making the image node inline meant it could no longer be fitted into codeBlock (content:'text*', marks:''), so inserting one with the cursor inside a fence made ProseMirror close the block at the insertion point: the remaining code escaped as plain prose and the language attribute was lost, and autosave wrote that markdown to the user's file. The pre-fix block image split the fence into two intact blocks instead. Resolve the insert content against the target position: when an inline image cannot be fitted where the caret sits, wrap it in a paragraph so ProseMirror splits the block and both halves keep their ``` fencing and language. Prose insertion is unchanged. Every production insert path now shares that resolution - the toolbar picker, the slash command and the clipboard-screenshot paste through insertRichMarkdownImageFromPath, plus the GitHub/GitLab composer's image-URL insert - each with a regression test. Also guard the unchecked cast of Paragraph.config.parseMarkdown: a Tiptap upgrade that drops the field would otherwise turn every paragraph parse into a TypeError and take the whole editor down, instead of degrading to parseInline. Four of the new round-trip cases asserted only on getMarkdown(), which walks the document without running NodeType.checkContent and so emits byte-identical output from a schema-invalid document - they passed on the pre-fix code. roundTripMarkdown now runs doc.check(), the list-item and table-cell case performs a real edit, and the standalone-image case types beside the image. All twelve cases now fail on the merge-base. Adds an Electron e2e spec driving the real renderer: a paragraph image and a toggle-summary image each survive a keystroke, and Bold over a selection spanning the image keeps it. --------- Co-authored-by: m4air <m4air@m4airs-MacBook-Air.local> Co-authored-by: Neil <neil@stably.ai>
136 lines
5.1 KiB
TypeScript
136 lines
5.1 KiB
TypeScript
import { test, expect } from './helpers/orca-app'
|
|
import {
|
|
cleanupMarkdownFixture,
|
|
createMarkdownFixture,
|
|
getActiveWorktreeContext,
|
|
openMarkdownFixture,
|
|
waitForRichMarkdownEditor
|
|
} from './helpers/markdown-editor-fixture'
|
|
import {
|
|
collectRichMarkdownPageErrors,
|
|
expectNoRichMarkdownSchemaCrash,
|
|
INLINE_IMAGE_DETAILS_MARKDOWN,
|
|
INLINE_IMAGE_FIXTURE_DIRECTORY,
|
|
INLINE_IMAGE_PARAGRAPH_MARKDOWN,
|
|
writeInlineImageAsset
|
|
} from './helpers/markdown-inline-image'
|
|
import { waitForActiveWorktree, waitForSessionReady } from './helpers/store'
|
|
|
|
// A markdown image nested in a paragraph or a toggle summary used to parse into a
|
|
// schema-invalid document that only threw on the first edit reassembling it.
|
|
test.describe('Rich markdown inline image regression', () => {
|
|
test.beforeEach(async ({ orcaPage }) => {
|
|
await waitForSessionReady(orcaPage)
|
|
await waitForActiveWorktree(orcaPage)
|
|
})
|
|
|
|
test('an inline image inside a paragraph survives a keystroke', async ({
|
|
orcaPage
|
|
}, testInfo) => {
|
|
const context = await getActiveWorktreeContext(orcaPage)
|
|
const pageErrors = collectRichMarkdownPageErrors(orcaPage)
|
|
let filePath: string | null = null
|
|
|
|
try {
|
|
writeInlineImageAsset(context.rootPath)
|
|
filePath = await createMarkdownFixture(
|
|
context,
|
|
INLINE_IMAGE_FIXTURE_DIRECTORY,
|
|
'paragraph-inline-image',
|
|
testInfo.workerIndex,
|
|
INLINE_IMAGE_PARAGRAPH_MARKDOWN
|
|
)
|
|
await openMarkdownFixture(orcaPage, context, filePath)
|
|
const editor = await waitForRichMarkdownEditor(orcaPage)
|
|
|
|
const paragraph = editor.locator('p').filter({ hasText: 'more text' }).first()
|
|
await expect(paragraph).toBeVisible({ timeout: 15_000 })
|
|
await expect(editor.locator('img')).toHaveCount(1, { timeout: 15_000 })
|
|
|
|
// Typing at the paragraph start reassembles the whole paragraph, which is
|
|
// the frame the reported RangeError bottomed out in.
|
|
await paragraph.click({ position: { x: 12, y: 8 } })
|
|
await orcaPage.keyboard.press('Home')
|
|
await orcaPage.keyboard.type('X')
|
|
|
|
await expect(editor.locator('p').filter({ hasText: 'XSome text' })).toHaveCount(1)
|
|
await expect(editor.locator('img')).toHaveCount(1)
|
|
await expectNoRichMarkdownSchemaCrash(orcaPage, pageErrors)
|
|
} finally {
|
|
await cleanupMarkdownFixture(filePath)
|
|
}
|
|
})
|
|
|
|
test('an inline image inside a toggle summary survives a keystroke', async ({
|
|
orcaPage
|
|
}, testInfo) => {
|
|
const context = await getActiveWorktreeContext(orcaPage)
|
|
const pageErrors = collectRichMarkdownPageErrors(orcaPage)
|
|
let filePath: string | null = null
|
|
|
|
try {
|
|
writeInlineImageAsset(context.rootPath)
|
|
filePath = await createMarkdownFixture(
|
|
context,
|
|
INLINE_IMAGE_FIXTURE_DIRECTORY,
|
|
'details-inline-image',
|
|
testInfo.workerIndex,
|
|
INLINE_IMAGE_DETAILS_MARKDOWN
|
|
)
|
|
await openMarkdownFixture(orcaPage, context, filePath)
|
|
const editor = await waitForRichMarkdownEditor(orcaPage)
|
|
|
|
const summary = editor.locator('summary').first()
|
|
await expect(summary).toBeVisible({ timeout: 15_000 })
|
|
await expect(editor.locator('summary img')).toHaveCount(1, { timeout: 15_000 })
|
|
|
|
await summary.click()
|
|
await orcaPage.keyboard.press('End')
|
|
await orcaPage.keyboard.type('X')
|
|
|
|
await expect(editor.locator('summary').filter({ hasText: 'labelX' })).toHaveCount(1)
|
|
await expect(editor.locator('summary img')).toHaveCount(1)
|
|
await expectNoRichMarkdownSchemaCrash(orcaPage, pageErrors)
|
|
} finally {
|
|
await cleanupMarkdownFixture(filePath)
|
|
}
|
|
})
|
|
|
|
test('a formatting command over an inline image keeps the image', async ({
|
|
orcaPage
|
|
}, testInfo) => {
|
|
const context = await getActiveWorktreeContext(orcaPage)
|
|
const pageErrors = collectRichMarkdownPageErrors(orcaPage)
|
|
let filePath: string | null = null
|
|
|
|
try {
|
|
writeInlineImageAsset(context.rootPath)
|
|
filePath = await createMarkdownFixture(
|
|
context,
|
|
INLINE_IMAGE_FIXTURE_DIRECTORY,
|
|
'paragraph-inline-image-bold',
|
|
testInfo.workerIndex,
|
|
INLINE_IMAGE_PARAGRAPH_MARKDOWN
|
|
)
|
|
await openMarkdownFixture(orcaPage, context, filePath)
|
|
const editor = await waitForRichMarkdownEditor(orcaPage)
|
|
|
|
const paragraph = editor.locator('p').filter({ hasText: 'more text' }).first()
|
|
await expect(paragraph).toBeVisible({ timeout: 15_000 })
|
|
await expect(editor.locator('img')).toHaveCount(1, { timeout: 15_000 })
|
|
|
|
// toggleBold runs tr.addMark across the selection, which reassembles every
|
|
// paragraph it spans — and silently dropped the image before the fix.
|
|
await paragraph.click({ position: { x: 12, y: 8 } })
|
|
await orcaPage.keyboard.press('ControlOrMeta+a')
|
|
await orcaPage.getByRole('button', { name: 'Bold', exact: true }).first().click()
|
|
|
|
await expect(editor.locator('strong').first()).toBeVisible()
|
|
await expect(editor.locator('img')).toHaveCount(1)
|
|
await expectNoRichMarkdownSchemaCrash(orcaPage, pageErrors)
|
|
} finally {
|
|
await cleanupMarkdownFixture(filePath)
|
|
}
|
|
})
|
|
})
|