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, '