From f2b25b776f3fdff85f9eec97f3db8fc9acbc2d8a Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Thu, 27 Aug 2026 16:30:16 -0700 Subject: [PATCH] Optimize nested toggle validation by sharing fence ranges across sibling When validating nested details elements, computing fence ranges once and reusing across siblings eliminates redundant body rescans. Export MarkdownFenceRanges type and add precomputedFenceRanges parameter to matchDetailsHtmlBlock. --- .../editor/details-markdown-html.test.ts | 16 ++++++++++++++ .../editor/details-markdown-html.ts | 21 ++++++++++++++----- 2 files changed, 32 insertions(+), 5 deletions(-) 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, '