From 3e8931866b0f1c2f3d90be96c90f36cad9f71009 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ademir=20Jos=C3=A9=20Ferreira=20J=C3=BAnior?= Date: Mon, 12 Oct 2026 18:02:29 -0300 Subject: [PATCH] Update heading fold decorations only where an edit can change them --- CHANGELOG.md | 1 + oxlint.config.ts | 1 + src/features/editor/plugins/headingFold.ts | 282 ++++++++++++---- .../plugins/headingFoldDecorations.test.ts | 316 ++++++++++++++++++ src/features/editor/utils/changedRanges.ts | 25 ++ .../editor/utils/codeBlockDecorations.ts | 26 +- 6 files changed, 554 insertions(+), 97 deletions(-) create mode 100644 src/features/editor/plugins/headingFoldDecorations.test.ts create mode 100644 src/features/editor/utils/changedRanges.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index ead1edae..573c9a98 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ Leafdown uses lightweight [Keep a Changelog](https://keepachangelog.com/en/1.1.0 - Stop larger documents from freezing just after you pause typing. Leafdown converted the whole document back to Markdown 200 ms after each edit, which froze the window for about a third of a second in a 200 KB document of tables and about a second at 500 KB. It now reads the document only when something needs it, such as saving. - Keep typing responsive in documents with many code blocks. Every keystroke used to highlight all of the document's code again, so typing slowed sharply as a document's code grew; now an edit highlights only the code blocks it changes, and typing outside code highlights nothing. Line numbers for code blocks, when shown, are now updated the same way instead of being redrawn for every block on each keystroke. +- Type faster in documents with many headings. Every keystroke used to work out again which headings could fold and redraw every fold marker and folded section, which took a growing share of each keystroke as headings were added; now an edit updates only the headings and folded sections it can change. - Type without long pauses in large documents. Every keystroke used to update the block gutters of the whole document, so in several hundred kilobytes of Markdown with many headings and lists each character could take seconds to appear. The gutters now keep only the blocks in view up to date, and large documents open faster too. - Open documents with many list items faster. Reading each item's indentation searched back toward the start of the file, so opening took longer with every item and with the file's size, adding about a second to a 1 MB document and much more to larger ones. - Draw the scrollbars of wide code blocks, tables, and display math in the same thin, rounded style as the rest of Leafdown instead of the system default. Help code samples, Find in folder's skipped-file lists, the code block language and wiki link suggestion lists, and menus cut short by a small window match it too. diff --git a/oxlint.config.ts b/oxlint.config.ts index d55ba677..f4a7641d 100644 --- a/oxlint.config.ts +++ b/oxlint.config.ts @@ -95,6 +95,7 @@ export default defineConfig({ "expectControlsOutOfTabSequence", "expectMarkSource", "expectOversizedMarkdownFileToast", + "expectRebuiltAfter", "expectSaved", "expectUnchanged", ], diff --git a/src/features/editor/plugins/headingFold.ts b/src/features/editor/plugins/headingFold.ts index 3e37c7e6..5918aa53 100644 --- a/src/features/editor/plugins/headingFold.ts +++ b/src/features/editor/plugins/headingFold.ts @@ -13,6 +13,7 @@ import { $prose } from "@milkdown/kit/utils"; import { t } from "@/lib/i18n"; +import { collectChangedRanges, type ChangedRange } from "../utils/changedRanges"; import { getEditorSurfaceElementAt, isEditorSurfaceElement } from "../utils/editorSurface"; import { findScrollingAncestor } from "../utils/scrollingAncestor"; import { finalizeSourceProjection } from "./sourceProjection"; @@ -134,55 +135,184 @@ const createFoldIndicator = (view: EditorView, getPos: () => number | undefined) return indicator; }; -const createDecorations = (doc: ProseMirrorNode, folded: readonly number[]) => { - const foldedSet = new Set(folded); - const decorations: Decoration[] = []; - - doc.descendants((node, position) => { - if (!isHeading(node)) return !node.isTextblock; - if (!hasSection(doc, position, node)) return false; +// A heading's own decorations depend on the heading, its next sibling, and whether it is folded; +// the blocks a folded section hides depend on every sibling up to the section's end. +const isHeadingLayer = (spec: { leafdownFold?: string }) => spec.leafdownFold === "heading"; +const isSectionLayer = (spec: { leafdownFold?: string }) => spec.leafdownFold === "section"; - const isFolded = foldedSet.has(position); - const level = `H${String(node.attrs.level)}`; - decorations.push( - Decoration.node(position, position + node.nodeSize, { +const decorateHeading = ( + doc: ProseMirrorNode, + position: number, + node: ProseMirrorNode, + isFolded: boolean, +) => { + if (!hasSection(doc, position, node)) return []; + + const decorations = [ + Decoration.node( + position, + position + node.nodeSize, + { "data-leafdown-fold": isFolded ? "folded" : "open", - "data-leafdown-fold-marker": level, - }), - ); - if (!isFolded) return false; - + "data-leafdown-fold-marker": `H${String(node.attrs.level)}`, + }, + { leafdownFold: "heading" }, + ), + ]; + if (isFolded) { decorations.push( Decoration.widget(position + node.nodeSize - 1, createFoldIndicator, { side: 1, ignoreSelection: true, key: "leafdown-fold-indicator", stopEvent: () => true, + leafdownFold: "heading", }), ); + } + return decorations; +}; + +const decorateFoldedSections = (doc: ProseMirrorNode, folded: readonly number[]) => + folded.flatMap((position) => { const section = getHeadingSection(doc, position); - if (section) { - doc.nodesBetween(section.from, section.to, (child, childPosition) => { - if (childPosition < section.from) return true; - decorations.push( - Decoration.node(childPosition, childPosition + child.nodeSize, { - class: "leafdown-folded-block", - }), - ); - return false; - }); - } + if (!section) return []; + const decorations: Decoration[] = []; + doc.nodesBetween(section.from, section.to, (child, childPosition) => { + if (childPosition < section.from) return true; + decorations.push( + Decoration.node( + childPosition, + childPosition + child.nodeSize, + { class: "leafdown-folded-block" }, + { leafdownFold: "section" }, + ), + ); + return false; + }); + return decorations; + }); + +export const createHeadingFoldDecorations = (doc: ProseMirrorNode, folded: readonly number[]) => { + const foldedSet = new Set(folded); + const decorations: Decoration[] = []; + + doc.descendants((node, position) => { + if (!isHeading(node)) return !node.isTextblock; + decorations.push(...decorateHeading(doc, position, node, foldedSet.has(position))); return false; }); - return DecorationSet.create(doc, decorations); + return DecorationSet.create(doc, [...decorations, ...decorateFoldedSections(doc, folded)]); +}; + +// Every textblock a change touches, and every node whose own opening or closing token it touches, +// since mapping drops the decorations of blocks that move under a new parent. Each range reaches +// one position back so the block before it, whose next sibling may have changed, is included. +const collectTouchedBlocks = (doc: ProseMirrorNode, ranges: readonly ChangedRange[]) => { + const blocks = new Map(); + + for (const [from, to] of ranges) { + doc.nodesBetween(Math.max(0, from - 1), to, (node, position) => { + const end = position + node.nodeSize; + if (node.isTextblock || (position >= from && position < to) || (end > from && end <= to)) { + blocks.set(position, node); + return false; + } + return true; + }); + } + + return blocks; +}; + +const collectHeadings = ( + node: ProseMirrorNode, + position: number, + headings: Map, +) => { + if (isHeading(node)) { + headings.set(position, node); + return; + } + node.descendants((child, offset) => { + if (isHeading(child)) headings.set(position + 1 + offset, child); + return !child.isTextblock; + }); }; -const createFoldState = (doc: ProseMirrorNode, folded: readonly number[]): HeadingFoldState => ({ - folded, - decorations: createDecorations(doc, folded), - markerHover: null, -}); +// A section changes with any of its blocks, the heading that ends it, or a node that holds it +// moving under a new parent. +const touchesFoldedSection = ( + doc: ProseMirrorNode, + folded: readonly number[], + ranges: readonly ChangedRange[], + blocks: ReadonlyMap, +) => + folded.some((position) => { + const section = getHeadingSection(doc, position); + if (!section) return false; + return ( + ranges.some(([from, to]) => from <= section.to && to >= section.heading) || + [...blocks].some( + ([block, node]) => block < section.heading && section.to <= block + node.nodeSize, + ) + ); + }); + +const updateFoldDecorations = ( + transaction: Transaction, + value: HeadingFoldState, + mapped: readonly number[], + folded: readonly number[], +) => { + const { doc } = transaction; + const ranges = transaction.docChanged ? collectChangedRanges(transaction) : []; + let next = transaction.docChanged + ? value.decorations.map(transaction.mapping, doc) + : value.decorations; + + const mappedSet = new Set(mapped); + const foldedSet = new Set(folded); + const toggled = [ + ...mapped.filter((position) => !foldedSet.has(position)), + ...folded.filter((position) => !mappedSet.has(position)), + ]; + + const stale = new Set(); + const headings = new Map(); + const blocks = collectTouchedBlocks(doc, ranges); + for (const position of toggled) { + const node = doc.nodeAt(position); + if (node) blocks.set(position, node); + } + for (const [position, node] of blocks) { + const end = position + node.nodeSize; + for (const decoration of next.find(position, end, isHeadingLayer)) { + if (decoration.from >= position && decoration.to <= end) stale.add(decoration); + } + collectHeadings(node, position, headings); + } + + const added = [...headings].flatMap(([position, node]) => + decorateHeading(doc, position, node, foldedSet.has(position)), + ); + // A fold whose heading the change deleted maps to nothing, yet its hidden blocks keep their + // decorations through mapping. + if ( + toggled.length > 0 || + mapped.length < value.folded.length || + touchesFoldedSection(doc, folded, ranges, blocks) + ) { + for (const decoration of next.find(undefined, undefined, isSectionLayer)) { + stale.add(decoration); + } + added.push(...decorateFoldedSections(doc, folded)); + } + + next = next.remove([...stale]); + return next.add(doc, added); +}; const sameFolds = (left: readonly number[], right: readonly number[]) => left.length === right.length && left.every((position, index) => position === right[index]); @@ -197,9 +327,10 @@ const applyFoldTransaction = ( const { doc } = transaction; let next = value; if (transaction.docChanged || meta?.fold !== undefined || meta?.unfold || meta?.reveal) { - let folded = transaction.docChanged + const mapped = transaction.docChanged ? value.folded.flatMap((position) => mapFoldedHeading(transaction, position) ?? []) - : [...value.folded]; + : value.folded; + let folded = [...mapped]; if (meta?.fold !== undefined) folded.push(meta.fold); if (meta?.unfold) folded = folded.filter((position) => !meta.unfold!.includes(position)); @@ -213,7 +344,11 @@ const applyFoldTransaction = ( const normalized = normalizeFolds(doc, folded); if (transaction.docChanged || !sameFolds(normalized, value.folded)) { - next = createFoldState(doc, normalized); + next = { + folded: normalized, + decorations: updateFoldDecorations(transaction, value, mapped, normalized), + markerHover: null, + }; } } @@ -402,41 +537,42 @@ const findPressedMarker = (view: EditorView, event: MouseEvent) => { return isHeading(view.state.doc.nodeAt(position)) ? position : null; }; -export const createLeafdownHeadingFoldPlugin = () => - $prose( - () => - new Plugin({ - key: headingFoldPluginKey, - state: { - init: (_, state) => createFoldState(state.doc, []), - apply: applyFoldTransaction, - }, - view: trackMarkerHover, - appendTransaction: (transactions, _, state) => { - if ( - !transactions.some((transaction) => transaction.docChanged || transaction.selectionSet) - ) - return null; - if (state.selection instanceof AllSelection) return null; - const reached = getFoldedSections(state) - .filter((section) => selectionReachesSection(state.selection, section)) - .map((section) => section.heading); - return reached.length > 0 - ? state.tr.setMeta(headingFoldPluginKey, { unfold: reached }) - : null; - }, - props: { - decorations: getFoldDecorations, - handleDOMEvents: { - mousedown: (view, event) => { - if (event.button !== 0) return false; - const heading = findPressedMarker(view, event); - if (heading === null) return false; - event.preventDefault(); - toggleHeadingFold(view, heading); - return true; - }, - }, - }, +export const createHeadingFoldPlugin = () => + new Plugin({ + key: headingFoldPluginKey, + state: { + init: (_, state) => ({ + folded: [], + decorations: createHeadingFoldDecorations(state.doc, []), + markerHover: null, }), - ); + apply: applyFoldTransaction, + }, + view: trackMarkerHover, + appendTransaction: (transactions, _, state) => { + if (!transactions.some((transaction) => transaction.docChanged || transaction.selectionSet)) + return null; + if (state.selection instanceof AllSelection) return null; + const reached = getFoldedSections(state) + .filter((section) => selectionReachesSection(state.selection, section)) + .map((section) => section.heading); + return reached.length > 0 + ? state.tr.setMeta(headingFoldPluginKey, { unfold: reached }) + : null; + }, + props: { + decorations: getFoldDecorations, + handleDOMEvents: { + mousedown: (view, event) => { + if (event.button !== 0) return false; + const heading = findPressedMarker(view, event); + if (heading === null) return false; + event.preventDefault(); + toggleHeadingFold(view, heading); + return true; + }, + }, + }, + }); + +export const createLeafdownHeadingFoldPlugin = () => $prose(createHeadingFoldPlugin); diff --git a/src/features/editor/plugins/headingFoldDecorations.test.ts b/src/features/editor/plugins/headingFoldDecorations.test.ts new file mode 100644 index 00000000..ff8a3561 --- /dev/null +++ b/src/features/editor/plugins/headingFoldDecorations.test.ts @@ -0,0 +1,316 @@ +import { Fragment, Schema, Slice, type Node as ProseMirrorNode } from "@milkdown/kit/prose/model"; +import { EditorState, type Transaction } from "@milkdown/kit/prose/state"; +import { liftTarget } from "@milkdown/kit/prose/transform"; +import { Decoration, type DecorationSet } from "@milkdown/kit/prose/view"; +import { describe, expect, it, vi } from "vitest"; + +import { + createHeadingFoldDecorations, + createHeadingFoldPlugin, + getFoldedHeadings, + headingFoldPluginKey, + revealFoldedRange, +} from "./headingFold"; + +const schema = new Schema({ + nodes: { + doc: { content: "block+" }, + text: { group: "inline" }, + paragraph: { content: "inline*", group: "block" }, + heading: { content: "inline*", group: "block", attrs: { level: { default: 1 } } }, + blockquote: { content: "block+", group: "block" }, + }, +}); + +const paragraph = (text: string) => schema.nodes.paragraph.create(null, schema.text(text)); +const heading = (level: number, text: string) => + schema.nodes.heading.create({ level }, schema.text(text)); +const quote = (...content: ProseMirrorNode[]) => schema.nodes.blockquote.create(null, content); + +const createDoc = () => + schema.nodes.doc.create(null, [ + paragraph("Intro"), + heading(1, "Guide"), + paragraph("Guide body"), + heading(2, "Install"), + paragraph("Install body"), + heading(3, "Detail"), + paragraph("Detail body"), + heading(2, "Empty"), + heading(2, "Use"), + paragraph("Use body"), + quote(heading(2, "Quoted"), paragraph("Quoted body"), heading(3, "Quoted end")), + paragraph("After quote"), + heading(1, "Next"), + paragraph("Next body"), + ]); + +const positionOf = (doc: ProseMirrorNode, text: string) => { + let found = -1; + doc.descendants((node, position) => { + if (found < 0 && node.isTextblock && node.textContent === text) found = position; + return found < 0; + }); + expect(found).toBeGreaterThanOrEqual(0); + return found; +}; + +const endOf = (doc: ProseMirrorNode, text: string) => + positionOf(doc, text) + doc.nodeAt(positionOf(doc, text))!.nodeSize; + +const describeDecorations = (decorations: DecorationSet | undefined) => + (decorations?.find() ?? []) + .map((decoration) => { + const { attrs } = (decoration as unknown as { type: { attrs?: unknown } }).type; + return `${decoration.from}-${decoration.to} ${attrs ? JSON.stringify(attrs) : "widget"}`; + }) + .toSorted(); + +const foldDecorations = (state: EditorState) => headingFoldPluginKey.getState(state)?.decorations; + +const expectRebuilt = (state: EditorState) => { + expect(describeDecorations(foldDecorations(state))).toEqual( + describeDecorations(createHeadingFoldDecorations(state.doc, getFoldedHeadings(state))), + ); +}; + +const expectRebuiltAfter = (state: EditorState, transaction: Transaction) => { + const next = state.apply(transaction); + expectRebuilt(next); + return next; +}; + +// Positions of the decorations created while applying, which a full rebuild would create for +// every heading with a section. +const applyAndTrack = (state: EditorState, transaction: Transaction) => { + const node = vi.spyOn(Decoration, "node"); + const widget = vi.spyOn(Decoration, "widget"); + const next = state.apply(transaction); + const decorated = [...node.mock.calls, ...widget.mock.calls] + .map(([from]) => from) + .toSorted((left, right) => left - right); + node.mockRestore(); + widget.mockRestore(); + expectRebuilt(next); + return { next, decorated }; +}; + +const fold = (state: EditorState, text: string) => + expectRebuiltAfter( + state, + state.tr.setMeta(headingFoldPluginKey, { fold: positionOf(state.doc, text) }), + ); + +const setup = (folds: readonly string[]) => { + let state = EditorState.create({ doc: createDoc(), plugins: [createHeadingFoldPlugin()] }); + for (const text of folds) state = fold(state, text); + expect(getFoldedHeadings(state)).toHaveLength(folds.length); + return state; +}; + +const foldCases = [ + { name: "without folds", folds: [] }, + { name: "with folds", folds: ["Install", "Quoted", "Next"] }, +]; + +describe.each(foldCases)("heading fold decorations $name", ({ folds }) => { + it("decorates no heading when typing in a paragraph", () => { + let state = setup(folds); + const edits: ((doc: ProseMirrorNode) => [text: string, from: number, to: number])[] = [ + (doc) => ["!", endOf(doc, "Use body") - 1, endOf(doc, "Use body") - 1], + (doc) => ["?", positionOf(doc, "Intro") + 1, positionOf(doc, "Intro") + 1], + (doc) => ["", endOf(doc, "After quote") - 3, endOf(doc, "After quote") - 1], + ]; + + for (const edit of edits) { + const { next, decorated } = applyAndTrack(state, state.tr.insertText(...edit(state.doc))); + expect(decorated).toEqual([]); + state = next; + } + expect(getFoldedHeadings(state)).toHaveLength(folds.length); + }); + + it("decorates a heading created from a paragraph and ends the section before it", () => { + const state = setup(folds); + const next = expectRebuiltAfter( + state, + state.tr.setBlockType( + positionOf(state.doc, "Detail body") + 1, + positionOf(state.doc, "Detail body") + 1, + schema.nodes.heading, + { level: 2 }, + ), + ); + + expect(next.doc.nodeAt(positionOf(next.doc, "Detail body"))?.type.name).toBe("heading"); + }); + + it("follows a heading turned into a paragraph or removed", () => { + let state = setup(folds); + state = expectRebuiltAfter( + state, + state.tr.setBlockType( + positionOf(state.doc, "Detail") + 1, + positionOf(state.doc, "Detail") + 1, + schema.nodes.paragraph, + ), + ); + state = expectRebuiltAfter( + state, + state.tr.delete(positionOf(state.doc, "Use"), endOf(state.doc, "Use")), + ); + expectRebuiltAfter( + state, + state.tr.delete(positionOf(state.doc, "Quoted end"), endOf(state.doc, "Quoted end")), + ); + }); + + it("follows a heading's level changing", () => { + let state = setup(folds); + state = expectRebuiltAfter( + state, + state.tr.setNodeMarkup(positionOf(state.doc, "Install"), undefined, { level: 1 }), + ); + state = expectRebuiltAfter( + state, + state.tr.setNodeAttribute(positionOf(state.doc, "Empty"), "level", 3), + ); + state = expectRebuiltAfter( + state, + state.tr.setNodeAttribute(positionOf(state.doc, "Quoted end"), "level", 1), + ); + expectRebuiltAfter( + state, + state.tr.setNodeMarkup(positionOf(state.doc, "Next"), undefined, { level: 3 }), + ); + }); + + it("follows a heading gaining or losing its section", () => { + let state = setup(folds); + state = expectRebuiltAfter(state, state.tr.insert(endOf(state.doc, "Empty"), paragraph("New"))); + expect(state.doc.nodeAt(positionOf(state.doc, "New"))?.type.name).toBe("paragraph"); + state = expectRebuiltAfter( + state, + state.tr.delete(positionOf(state.doc, "New"), endOf(state.doc, "New")), + ); + state = expectRebuiltAfter( + state, + state.tr.delete(positionOf(state.doc, "Detail body"), endOf(state.doc, "Detail body")), + ); + expectRebuiltAfter( + state, + state.tr.delete(positionOf(state.doc, "Next body"), endOf(state.doc, "Next body")), + ); + }); + + it("follows a heading split in two and joined back", () => { + const state = setup(folds); + const split = expectRebuiltAfter(state, state.tr.split(positionOf(state.doc, "Use") + 2)); + const joined = expectRebuiltAfter(split, split.tr.join(endOf(split.doc, "U"))); + + expect(joined.doc.eq(state.doc)).toBe(true); + expectRebuiltAfter(joined, joined.tr.split(endOf(joined.doc, "Guide body") - 1)); + }); + + it("follows blocks wrapped in a quote and lifted out of one", () => { + let state = setup(folds); + const $from = state.doc.resolve(positionOf(state.doc, "Guide") + 1); + const $to = state.doc.resolve(endOf(state.doc, "Use body") - 1); + const range = $from.blockRange($to)!; + state = expectRebuiltAfter(state, state.tr.wrap(range, [{ type: schema.nodes.blockquote }])); + + const quoted = state.doc.resolve(positionOf(state.doc, "Quoted") + 1); + const quotedEnd = state.doc.resolve(endOf(state.doc, "Quoted end") - 1); + const lift = quoted.blockRange(quotedEnd)!; + expectRebuiltAfter(state, state.tr.lift(lift, liftTarget(lift)!)); + }); + + it("follows content inserted into and removed from a section", () => { + let state = setup(folds); + state = expectRebuiltAfter( + state, + state.tr.insert(endOf(state.doc, "Install body"), heading(1, "Inserted")), + ); + state = expectRebuiltAfter( + state, + state.tr.delete(positionOf(state.doc, "Inserted"), endOf(state.doc, "Inserted")), + ); + state = expectRebuiltAfter( + state, + state.tr.insert(positionOf(state.doc, "Quoted body"), paragraph("Quoted lead")), + ); + expectRebuiltAfter( + state, + state.tr.delete(positionOf(state.doc, "Use"), endOf(state.doc, "After quote")), + ); + }); + + it("decorates each heading a multi-step transaction changes", () => { + const state = setup(folds); + const transaction = state.tr + .insert(endOf(state.doc, "Empty"), paragraph("Gained")) + .setNodeAttribute(positionOf(state.doc, "Guide"), "level", 2); + transaction.delete( + transaction.mapping.map(positionOf(state.doc, "Next")), + transaction.mapping.map(endOf(state.doc, "Next")), + ); + transaction.insertText("!", transaction.mapping.map(endOf(state.doc, "Intro") - 1)); + + expectRebuiltAfter(state, transaction); + }); + + it("decorates every heading when the whole document is replaced", () => { + const state = setup(folds); + const replacement = createDoc(); + + expectRebuiltAfter( + state, + state.tr.replace( + 0, + state.doc.content.size, + new Slice(Fragment.from(replacement.content), 0, 0), + ), + ); + }); +}); + +describe("folding and unfolding", () => { + it("re-decorates only the toggled heading and its section", () => { + let state = setup([]); + + const folded = applyAndTrack( + state, + state.tr.setMeta(headingFoldPluginKey, { fold: positionOf(state.doc, "Install") }), + ); + state = folded.next; + expect(folded.decorated).toEqual([ + positionOf(state.doc, "Install"), + endOf(state.doc, "Install") - 1, + positionOf(state.doc, "Install body"), + positionOf(state.doc, "Detail"), + positionOf(state.doc, "Detail body"), + ]); + + state = fold(state, "Guide"); + state = expectRebuiltAfter( + state, + state.tr.setMeta(headingFoldPluginKey, { unfold: [positionOf(state.doc, "Install")] }), + ); + state = expectRebuiltAfter( + state, + revealFoldedRange(state.tr, positionOf(state.doc, "Detail body") + 1), + ); + + expect(getFoldedHeadings(state)).toEqual([]); + }); + + it("drops a fold whose section an edit removes", () => { + let state = setup(["Detail"]); + state = expectRebuiltAfter( + state, + state.tr.delete(positionOf(state.doc, "Detail body"), endOf(state.doc, "Detail body")), + ); + + expect(getFoldedHeadings(state)).toEqual([]); + }); +}); diff --git a/src/features/editor/utils/changedRanges.ts b/src/features/editor/utils/changedRanges.ts new file mode 100644 index 00000000..44e460dd --- /dev/null +++ b/src/features/editor/utils/changedRanges.ts @@ -0,0 +1,25 @@ +import type { Transaction } from "@milkdown/kit/prose/state"; +import { AttrStep } from "@milkdown/kit/prose/transform"; + +export type ChangedRange = [from: number, to: number]; + +// Ranges are in the transaction's final document. An attribute step maps no positions, so the +// node it changes is added as a range of its own. +export const collectChangedRanges = (transaction: Transaction) => { + let ranges: ChangedRange[] = []; + + transaction.mapping.maps.forEach((map, index) => { + ranges = ranges.map(([from, to]) => [map.map(from, -1), map.map(to, 1)]); + map.forEach((_oldStart, _oldEnd, newStart, newEnd) => { + ranges.push([newStart, newEnd]); + }); + + const step = transaction.steps[index]; + + if (step instanceof AttrStep) { + ranges.push([step.pos, step.pos + 1]); + } + }); + + return ranges; +}; diff --git a/src/features/editor/utils/codeBlockDecorations.ts b/src/features/editor/utils/codeBlockDecorations.ts index f189cef9..3b6b3967 100644 --- a/src/features/editor/utils/codeBlockDecorations.ts +++ b/src/features/editor/utils/codeBlockDecorations.ts @@ -1,12 +1,11 @@ import type { Node as ProseMirrorNode } from "@milkdown/kit/prose/model"; import type { Transaction } from "@milkdown/kit/prose/state"; -import { AttrStep } from "@milkdown/kit/prose/transform"; import type { Decoration } from "@milkdown/kit/prose/view"; import { DecorationSet } from "@milkdown/kit/prose/view"; -export type DecorateCodeBlock = (node: ProseMirrorNode, position: number) => Decoration[]; +import { collectChangedRanges, type ChangedRange } from "./changedRanges"; -type ChangedRange = [from: number, to: number]; +export type DecorateCodeBlock = (node: ProseMirrorNode, position: number) => Decoration[]; const isCodeBlock = (node: ProseMirrorNode) => node.type.name === "code_block"; @@ -35,27 +34,6 @@ export const createCodeBlockDecorations = (doc: ProseMirrorNode, decorate: Decor ); }; -// Ranges are in the transaction's final document. An attribute step maps no positions, so the -// node it changes is added as a range of its own. -const collectChangedRanges = (transaction: Transaction) => { - let ranges: ChangedRange[] = []; - - transaction.mapping.maps.forEach((map, index) => { - ranges = ranges.map(([from, to]) => [map.map(from, -1), map.map(to, 1)]); - map.forEach((_oldStart, _oldEnd, newStart, newEnd) => { - ranges.push([newStart, newEnd]); - }); - - const step = transaction.steps[index]; - - if (step instanceof AttrStep) { - ranges.push([step.pos, step.pos + 1]); - } - }); - - return ranges; -}; - // A node whose own opening or closing token changed may now hold blocks that were elsewhere, such // as blocks wrapped in a new quote. A block decoration fails validation under its new parent and // is dropped while mapping, so every code block inside such a node is decorated again.