diff --git a/src/renderer/src/components/editor/details-markdown-html.test.ts b/src/renderer/src/components/editor/details-markdown-html.test.ts index 47e404318b8..85f8f332cfe 100644 --- a/src/renderer/src/components/editor/details-markdown-html.test.ts +++ b/src/renderer/src/components/editor/details-markdown-html.test.ts @@ -91,6 +91,22 @@ describe('details markdown html', () => { expect(isEditableHtml(nestedToggles(16))).toBe(true) }) + it('bounds each of many sibling nested toggles independently', () => { + const siblings = (count: number, extra = ''): string => + Array.from( + { length: count }, + (_, index) => + `
\nsibling ${index}\n\nBody\n\n
` + ).join('\n\n') + const wrap = (body: string): string => + `
\nOuter\n\n${body}\n\n
` + + expect(isEditableHtml(wrap(siblings(40)))).toBe(true) + // A single non-editable sibling must still reject, so sharing fence ranges + // cannot make later siblings inherit an earlier sibling's boundaries. + expect(isEditableHtml(wrap(`${siblings(20)}\n\n${siblings(1, ' id="x"')}`))).toBe(false) + }) + it('rejects a toggle whose nested toggle is not itself editable', () => { const block: DetailsHtmlBlock = { raw: '', diff --git a/src/renderer/src/components/editor/details-markdown-html.ts b/src/renderer/src/components/editor/details-markdown-html.ts index 248e2303637..109005460dc 100644 --- a/src/renderer/src/components/editor/details-markdown-html.ts +++ b/src/renderer/src/components/editor/details-markdown-html.ts @@ -36,6 +36,10 @@ export type DetailsHtmlBlock = { inner: string } +// Fence ranges depend only on the scanned string, so callers scanning one body +// repeatedly compute them once and share them across sibling matches. +export type MarkdownFenceRanges = readonly (readonly [number, number])[] + export type DetailsSummaryHtml = { attributes: string content: string @@ -87,7 +91,7 @@ export function renderDetailsAttributes(attrs: Record | undefin return attributes.join(' ') } -function markdownFenceRanges(content: string): [number, number][] { +function markdownFenceRanges(content: string): MarkdownFenceRanges { const ranges: [number, number][] = [] let offset = 0 let openFence: { marker: '`' | '~'; length: number; start: number } | null = null @@ -129,11 +133,15 @@ function markdownFenceRanges(content: string): [number, number][] { return ranges } -function isInsideRange(index: number, ranges: [number, number][]): boolean { +function isInsideRange(index: number, ranges: MarkdownFenceRanges): boolean { return ranges.some(([start, end]) => index >= start && index < end) } -export function matchDetailsHtmlBlock(content: string, start: number): DetailsHtmlBlock | null { +export function matchDetailsHtmlBlock( + content: string, + start: number, + precomputedFenceRanges?: MarkdownFenceRanges +): DetailsHtmlBlock | null { const openingMatch = content.slice(start).match(/^]*>/i) if (!openingMatch) { return null @@ -141,7 +149,7 @@ export function matchDetailsHtmlBlock(content: string, start: number): DetailsHt const detailsTagPattern = /<\/?details\b[^>]*>/gi detailsTagPattern.lastIndex = start - const fenceRanges = markdownFenceRanges(content) + const fenceRanges = precomputedFenceRanges ?? markdownFenceRanges(content) let depth = 0 @@ -270,6 +278,8 @@ const MAX_DETAILS_NESTING_LEVELS = 16 function stripEditableNestedDetails(bodyHtml: string, nestingLevel: number): string | null { let result = '' let index = 0 + // Why: without sharing this, N sibling toggles rescan the whole body N times. + let fenceRanges: MarkdownFenceRanges | null = null for (;;) { const nestedStart = indexOfAsciiIgnoreCase(bodyHtml, '