From 5a9e15e2c5978a1663e7c4facbb9de3665289307 Mon Sep 17 00:00:00 2001 From: Vadim Melnicuk Date: Sun, 1 Mar 2026 17:49:28 +0000 Subject: [PATCH] feat: implement collapsible details blocks with summary widget and styling --- webview/src/helpers/headingCollapse.ts | 302 ++++++++++++++++++------- webview/src/helpers/markdownSyntax.ts | 113 +++++++++ webview/src/liveMode.ts | 120 +++++++++- webview/src/styles.css | 32 +++ 4 files changed, 480 insertions(+), 87 deletions(-) diff --git a/webview/src/helpers/headingCollapse.ts b/webview/src/helpers/headingCollapse.ts index e66dd79..1e987ae 100644 --- a/webview/src/helpers/headingCollapse.ts +++ b/webview/src/helpers/headingCollapse.ts @@ -1,11 +1,26 @@ import { RangeSetBuilder, StateEffect, StateField, Transaction, EditorState } from '@codemirror/state'; import { EditorView, GutterMarker, gutter } from '@codemirror/view'; import { createElement, ChevronDown } from 'lucide'; -import { extractHeadingSections, HeadingSection } from './markdownSyntax'; +import { extractDetailsBlocks, extractHeadingSections, HeadingSection, DetailsBlockInfo } from './markdownSyntax'; const toggleHeadingCollapseEffect = StateEffect.define(); const expandHeadingCollapseEffect = StateEffect.define(); -const emptyCollapsedHeadings: readonly number[] = Object.freeze([]); +const emptyCollapseOverrides = Object.freeze(new Map()); + +interface CollapsibleSection { + kind: 'heading' | 'details'; + anchor: number; + lineFrom: number; + collapseFrom: number; + collapseTo: number; + defaultCollapsed: boolean; + headingSection: HeadingSection | null; + detailsBlock: DetailsBlockInfo | null; +} + +export interface DetailsBlockState extends DetailsBlockInfo { + collapsed: boolean; +} function isHeadingSectionCollapsible(state: EditorState, section: HeadingSection): boolean { if (!section || section.collapseTo <= section.collapseFrom) { @@ -14,13 +29,52 @@ function isHeadingSectionCollapsible(state: EditorState, section: HeadingSection return state.doc.sliceString(section.collapseFrom, section.collapseTo).trim().length > 0; } +function createHeadingCollapsibleSection(section: HeadingSection): CollapsibleSection { + return { + kind: 'heading', + anchor: section.lineFrom, + lineFrom: section.lineFrom, + collapseFrom: section.collapseFrom, + collapseTo: section.collapseTo, + defaultCollapsed: false, + headingSection: section, + detailsBlock: null + }; +} + +function createDetailsCollapsibleSection(detailsBlock: DetailsBlockInfo): CollapsibleSection { + return { + kind: 'details', + anchor: detailsBlock.anchorFrom, + lineFrom: detailsBlock.lineFrom, + collapseFrom: detailsBlock.bodyFrom, + collapseTo: detailsBlock.bodyTo, + defaultCollapsed: detailsBlock.defaultCollapsed, + headingSection: null, + detailsBlock + }; +} + function getCollapsibleHeadingSections(state: EditorState): HeadingSection[] { return extractHeadingSections(state).filter((section) => isHeadingSectionCollapsible(state, section)); } -function getCollapsibleHeadingSectionMap(state: EditorState): Map { - const sections = getCollapsibleHeadingSections(state); - return new Map(sections.map((section) => [section.lineFrom, section])); +function getCollapsibleSections(state: EditorState): CollapsibleSection[] { + const sections = [ + ...getCollapsibleHeadingSections(state).map(createHeadingCollapsibleSection), + ...extractDetailsBlocks(state).map(createDetailsCollapsibleSection) + ]; + + sections.sort((a, b) => a.lineFrom - b.lineFrom || a.anchor - b.anchor); + return sections; +} + +function getCollapsibleSectionLineMap(state: EditorState): Map { + return new Map(getCollapsibleSections(state).map((section) => [section.lineFrom, section])); +} + +function getCollapsibleSectionAnchorMap(state: EditorState): Map { + return new Map(getCollapsibleSections(state).map((section) => [section.anchor, section])); } function hasHeadingCollapseEffect(transaction: any): boolean { @@ -35,127 +89,216 @@ function hasToggleHeadingCollapseEffect(transaction: any): boolean { return transaction.effects.some((effect: any) => effect.is(toggleHeadingCollapseEffect)); } -function mapCollapsedHeadingAnchors(anchors: readonly number[], transaction: any): number[] { - if (!anchors.length || !transaction.docChanged) { - return anchors.slice(); +function mapCollapseOverrides( + overrides: ReadonlyMap, + transaction: Transaction +): Map { + if (!overrides.size || !transaction.docChanged) { + return new Map(overrides); } - return anchors.map((lineFrom) => transaction.changes.mapPos(lineFrom, 1)); + + const mapped = new Map(); + for (const [anchor, collapsed] of overrides) { + mapped.set(transaction.changes.mapPos(anchor, 1), collapsed); + } + return mapped; } -function normalizeCollapsedHeadingAnchors(state: EditorState, anchors: number[]): readonly number[] { - if (!anchors.length) { - return emptyCollapsedHeadings; +function normalizeCollapseOverrides( + state: EditorState, + overrides: Map +): ReadonlyMap { + if (!overrides.size) { + return emptyCollapseOverrides; } - const validLineStarts = new Set(getCollapsibleHeadingSections(state).map((section) => section.lineFrom)); - const normalized: number[] = []; + const sections = getCollapsibleSectionAnchorMap(state); + const normalizedEntries: Array<[number, boolean]> = []; const seen = new Set(); - for (const lineFrom of anchors) { - if (!validLineStarts.has(lineFrom) || seen.has(lineFrom)) { + for (const [anchor, collapsed] of overrides) { + if (seen.has(anchor)) { continue; } - seen.add(lineFrom); - normalized.push(lineFrom); + seen.add(anchor); + + const section = sections.get(anchor); + if (!section || collapsed === section.defaultCollapsed) { + continue; + } + + normalizedEntries.push([anchor, collapsed]); } - normalized.sort((a, b) => a - b); - return normalized.length ? normalized : emptyCollapsedHeadings; + if (!normalizedEntries.length) { + return emptyCollapseOverrides; + } + + normalizedEntries.sort((a, b) => a[0] - b[0]); + return new Map(normalizedEntries); } -function arraysEqual(a: readonly number[], b: readonly number[]): boolean { +function collapseOverridesEqual( + a: ReadonlyMap, + b: ReadonlyMap +): boolean { if (a === b) { return true; } - if (a.length !== b.length) { + if (a.size !== b.size) { return false; } - for (let index = 0; index < a.length; index += 1) { - if (a[index] !== b[index]) { + + const aEntries = a.entries(); + const bEntries = b.entries(); + while (true) { + const nextA = aEntries.next(); + const nextB = bEntries.next(); + if (nextA.done || nextB.done) { + return nextA.done === nextB.done; + } + if (nextA.value[0] !== nextB.value[0] || nextA.value[1] !== nextB.value[1]) { return false; } } - return true; } function sortedNumbersFromSet(values: Set): readonly number[] { if (!values.size) { - return emptyCollapsedHeadings; + return []; } return Array.from(values).sort((a, b) => a - b); } -function toggleSetNumber(values: Set, value: number): void { - if (values.has(value)) { - values.delete(value); - return; - } - values.add(value); +function getEffectiveCollapsedState( + overrides: ReadonlyMap, + section: CollapsibleSection +): boolean { + return overrides.get(section.anchor) ?? section.defaultCollapsed; } -const headingCollapseStateField = StateField.define({ - create(): readonly number[] { - return emptyCollapsedHeadings; +function setCollapseOverride( + overrides: Map, + sections: Map, + anchor: number, + collapsed: boolean +): void { + const section = sections.get(anchor); + if (!section) { + return; + } + + if (collapsed === section.defaultCollapsed) { + overrides.delete(anchor); + return; + } + + overrides.set(anchor, collapsed); +} + +function toggleCollapseOverride( + overrides: Map, + sections: Map, + anchor: number +): void { + const section = sections.get(anchor); + if (!section) { + return; + } + + const nextCollapsed = !getEffectiveCollapsedState(overrides, section); + setCollapseOverride(overrides, sections, anchor, nextCollapsed); +} + +const headingCollapseStateField = StateField.define>({ + create(): ReadonlyMap { + return emptyCollapseOverrides; }, - update(collapsedHeadings: readonly number[], transaction: any): readonly number[] { + update( + collapsedHeadings: ReadonlyMap, + transaction: Transaction + ): ReadonlyMap { const hasEffectChange = hasHeadingCollapseEffect(transaction); if (!transaction.docChanged && !hasEffectChange) { return collapsedHeadings; } - let next = mapCollapsedHeadingAnchors(collapsedHeadings, transaction); + let next = mapCollapseOverrides(collapsedHeadings, transaction); + const sections = getCollapsibleSectionAnchorMap(transaction.state); if (hasEffectChange) { - const nextSet = new Set(next); for (const effect of transaction.effects) { if (effect.is(toggleHeadingCollapseEffect)) { - toggleSetNumber(nextSet, effect.value); + toggleCollapseOverride(next, sections, effect.value); continue; } if (effect.is(expandHeadingCollapseEffect)) { - for (const lineFrom of effect.value) { - nextSet.delete(lineFrom); + for (const anchor of effect.value) { + setCollapseOverride(next, sections, anchor, false); } } } - next = Array.from(nextSet); } - const normalized = normalizeCollapsedHeadingAnchors(transaction.state, next); - return arraysEqual(normalized, collapsedHeadings) ? collapsedHeadings : normalized; + const normalized = normalizeCollapseOverrides(transaction.state, next); + return collapseOverridesEqual(normalized, collapsedHeadings) ? collapsedHeadings : normalized; } }); const headingCollapseSharedExtension = Object.freeze([headingCollapseStateField]); -function getCollapsedHeadingAnchors(state: EditorState): readonly number[] { - return state.field(headingCollapseStateField, false) ?? emptyCollapsedHeadings; +function getCollapseOverrides(state: EditorState): ReadonlyMap { + return state.field(headingCollapseStateField, false) ?? emptyCollapseOverrides; } -function isHeadingCollapsed(state: EditorState, lineFrom: number): boolean { - return getCollapsedHeadingAnchors(state).includes(lineFrom); +function isSectionCollapsed(state: EditorState, section: CollapsibleSection): boolean { + return getEffectiveCollapsedState(getCollapseOverrides(state), section); +} + +function isSectionCollapsedByAnchor(state: EditorState, anchor: number, defaultCollapsed: boolean): boolean { + return getCollapseOverrides(state).get(anchor) ?? defaultCollapsed; +} + +function getCollapsedSections(state: EditorState): CollapsibleSection[] { + return getCollapsibleSections(state).filter((section) => isSectionCollapsed(state, section)); } export function getCollapsedHeadingSections(state: EditorState): HeadingSection[] { - const collapsedLineStarts = getCollapsedHeadingAnchors(state); - if (!collapsedLineStarts.length) { - return []; + return getCollapsedSections(state) + .filter((section) => section.kind === 'heading' && section.headingSection) + .map((section) => section.headingSection as HeadingSection); +} + +export function getDetailsBlocks(state: EditorState): DetailsBlockState[] { + return extractDetailsBlocks(state).map((detailsBlock) => ({ + ...detailsBlock, + collapsed: isSectionCollapsedByAnchor(state, detailsBlock.anchorFrom, detailsBlock.defaultCollapsed) + })); +} + +export function toggleCollapsibleSection(view: EditorView, anchor: number): boolean { + const section = getCollapsibleSectionAnchorMap(view.state).get(anchor); + if (!section) { + return false; } - const sectionMap = getCollapsibleHeadingSectionMap(state); - const sections: HeadingSection[] = []; - for (const lineFrom of collapsedLineStarts) { - const section = sectionMap.get(lineFrom); - if (section) { - sections.push(section); - } + const isCollapsed = isSectionCollapsed(view.state, section); + const transactionSpec: any = { + effects: toggleHeadingCollapseEffect.of(section.anchor), + annotations: Transaction.addToHistory.of(false) + }; + if (!isCollapsed && section.kind === 'heading') { + transactionSpec.selection = { anchor: section.lineFrom }; } - return sections.length ? sections : []; + + view.dispatch(transactionSpec); + view.focus(); + return true; } function collectCollapsedHeadingAnchorsForSelection(state: EditorState): readonly number[] { - const collapsedSections = getCollapsedHeadingSections(state); + const collapsedSections = getCollapsedSections(state); if (!collapsedSections.length) { - return emptyCollapsedHeadings; + return []; } const matches = new Set(); @@ -165,12 +308,12 @@ function collectCollapsedHeadingAnchorsForSelection(state: EditorState): readonl for (const section of collapsedSections) { if (selectionRange.empty) { if (from > section.collapseFrom && from < section.collapseTo) { - matches.add(section.lineFrom); + matches.add(section.anchor); } continue; } if (from < section.collapseTo && to > section.collapseFrom) { - matches.add(section.lineFrom); + matches.add(section.anchor); } } } @@ -230,12 +373,11 @@ const headingFoldGutterSpacerMarker = new HeadingFoldGutterSpacerMarker(); function buildHeadingFoldGutterMarkers(state: EditorState): any { const builder = new RangeSetBuilder(); - const collapsedAnchors = new Set(getCollapsedHeadingAnchors(state)); - for (const section of getCollapsibleHeadingSections(state)) { + for (const section of getCollapsibleSections(state)) { builder.add( section.lineFrom, section.lineFrom, - collapsedAnchors.has(section.lineFrom) ? collapsedHeadingFoldMarker : expandedHeadingFoldMarker + isSectionCollapsed(state, section) ? collapsedHeadingFoldMarker : expandedHeadingFoldMarker ); } return builder.finish(); @@ -269,24 +411,12 @@ const liveHeadingFoldGutterExtension = gutter({ return false; } - const section = getCollapsibleHeadingSectionMap(view.state).get(line.from); + const section = getCollapsibleSectionLineMap(view.state).get(line.from); event.preventDefault(); event.stopPropagation(); - if (!section) { - return true; + if (section) { + toggleCollapsibleSection(view, section.anchor); } - - const isCollapsed = isHeadingCollapsed(view.state, section.lineFrom); - const transactionSpec: any = { - effects: toggleHeadingCollapseEffect.of(section.lineFrom), - annotations: Transaction.addToHistory.of(false) - }; - if (!isCollapsed) { - transactionSpec.selection = { anchor: section.lineFrom }; - } - - view.dispatch(transactionSpec); - view.focus(); return true; } } @@ -297,18 +427,18 @@ const liveHeadingAutoExpandSelectionExtension = EditorView.updateListener.of((up return; } - const collapsedAnchors = getCollapsedHeadingAnchors(update.state); - if (!collapsedAnchors.length) { + const collapsedSections = getCollapsedSections(update.state); + if (!collapsedSections.length) { return; } - const expandLineStarts = collectCollapsedHeadingAnchorsForSelection(update.state); - if (!expandLineStarts.length) { + const expandAnchors = collectCollapsedHeadingAnchorsForSelection(update.state); + if (!expandAnchors.length) { return; } update.view.dispatch({ - effects: expandHeadingCollapseEffect.of(expandLineStarts as number[]), + effects: expandHeadingCollapseEffect.of(expandAnchors as number[]), annotations: Transaction.addToHistory.of(false) }); }); diff --git a/webview/src/helpers/markdownSyntax.ts b/webview/src/helpers/markdownSyntax.ts index 66ca7d3..99eebd9 100644 --- a/webview/src/helpers/markdownSyntax.ts +++ b/webview/src/helpers/markdownSyntax.ts @@ -32,6 +32,28 @@ export interface HeadingSection extends HeadingInfo { collapseTo: number; } +export interface DetailsBlockInfo { + kind: 'details'; + anchorFrom: number; + anchorTo: number; + summaryFrom: number; + summaryTo: number; + lineFrom: number; + lineTo: number; + sectionFrom: number; + sectionTo: number; + bodyFrom: number; + bodyTo: number; + closingFrom: number; + closingTo: number; + summaryText: string; + defaultCollapsed: boolean; +} + +const detailsOpenTagPattern = /]*>/i; +const detailsCloseTagPattern = /<\/details\s*>/i; +const summaryTagPattern = /]*>([\s\S]*?)<\/summary\s*>/i; + export function extractHeadings(state: EditorState): HeadingInfo[] { const headings: HeadingInfo[] = []; const tree = resolvedSyntaxTree(state); @@ -56,6 +78,97 @@ export function extractHeadings(state: EditorState): HeadingInfo[] { return headings; } +function hasDetailsOpenAttribute(openTag: string): boolean { + const attributeText = openTag + .replace(/^$/, ''); + return /(?:^|[\s/])open(?=[\s=/>]|$)/i.test(attributeText); +} + +function normalizeSummaryText(rawText: string | undefined): string { + const text = String(rawText ?? '') + .replace(/<[^>]+>/g, ' ') + .replace(/\s+/g, ' ') + .trim(); + return text || 'Details'; +} + +export function extractDetailsBlocks(state: EditorState): DetailsBlockInfo[] { + const detailsBlocks: DetailsBlockInfo[] = []; + const pendingBlocks: Array<{ + anchorFrom: number; + anchorTo: number; + summaryFrom: number; + summaryTo: number; + lineFrom: number; + lineTo: number; + summaryText: string; + defaultCollapsed: boolean; + }> = []; + const tree = resolvedSyntaxTree(state); + + tree.iterate({ + enter(node: any) { + if (node.name !== 'HTMLBlock') { + return; + } + + const rawText = state.doc.sliceString(node.from, node.to); + const openTagMatch = rawText.match(detailsOpenTagPattern); + if (openTagMatch) { + const openingLine = state.doc.lineAt(node.from); + const summaryMatch = rawText.match(summaryTagPattern); + const summaryFrom = typeof summaryMatch?.index === 'number' + ? node.from + summaryMatch.index + : node.from; + const summaryTo = typeof summaryMatch?.index === 'number' + ? summaryFrom + summaryMatch[0].length + : openingLine.to; + pendingBlocks.push({ + anchorFrom: node.from, + anchorTo: node.to, + summaryFrom, + summaryTo, + lineFrom: openingLine.from, + lineTo: openingLine.to, + summaryText: normalizeSummaryText(summaryMatch?.[1]), + defaultCollapsed: !hasDetailsOpenAttribute(openTagMatch[0]) + }); + } + + if (!detailsCloseTagPattern.test(rawText)) { + return; + } + + const openBlock = pendingBlocks.pop(); + if (!openBlock) { + return; + } + + detailsBlocks.push({ + kind: 'details', + anchorFrom: openBlock.anchorFrom, + anchorTo: openBlock.anchorTo, + summaryFrom: openBlock.summaryFrom, + summaryTo: openBlock.summaryTo, + lineFrom: openBlock.lineFrom, + lineTo: openBlock.lineTo, + sectionFrom: openBlock.anchorFrom, + sectionTo: node.to, + bodyFrom: openBlock.anchorTo, + bodyTo: node.from, + closingFrom: node.from, + closingTo: node.to, + summaryText: openBlock.summaryText, + defaultCollapsed: openBlock.defaultCollapsed + }); + } + }); + + detailsBlocks.sort((a, b) => a.anchorFrom - b.anchorFrom); + return detailsBlocks; +} + export function extractHeadingSections(state: EditorState): HeadingSection[] { const headings: HeadingSection[] = []; const tree = resolvedSyntaxTree(state); diff --git a/webview/src/liveMode.ts b/webview/src/liveMode.ts index 53d1ca8..7dfb099 100644 --- a/webview/src/liveMode.ts +++ b/webview/src/liveMode.ts @@ -21,7 +21,9 @@ import { headingLevelFromName, resolvedSyntaxTree } from './helpers/markdownSynt import { headingCollapseLiveExtensions, headingCollapseSharedExtensions, - getCollapsedHeadingSections + getCollapsedHeadingSections, + getDetailsBlocks, + toggleCollapsibleSection } from './helpers/headingCollapse'; import { addListMarkerDecoration, @@ -78,6 +80,7 @@ const lineStyleDecos = { h4: Decoration.line({ class: 'meo-md-h4' }), h5: Decoration.line({ class: 'meo-md-h5' }), h6: Decoration.line({ class: 'meo-md-h6' }), + detailsSummary: Decoration.line({ class: 'meo-md-details-summary-line' }), quote: Decoration.line({ class: 'meo-md-quote' }), mergeIncomingHeader: Decoration.line({ class: 'meo-merge-line meo-merge-incoming-header' }), codeBlock: Decoration.line({ class: 'meo-md-code-block' }), @@ -401,6 +404,61 @@ function frontmatterArrayPillsWidget(itemLabels) { return widget; } +class DetailsSummaryWidget extends WidgetType { + anchor: number; + lineFrom: number; + summaryText: string; + collapsed: boolean; + + constructor(anchor: number, lineFrom: number, summaryText: string, collapsed: boolean) { + super(); + this.anchor = anchor; + this.lineFrom = lineFrom; + this.summaryText = summaryText; + this.collapsed = collapsed; + } + + eq(other: WidgetType): boolean { + return ( + other instanceof DetailsSummaryWidget && + other.anchor === this.anchor && + other.lineFrom === this.lineFrom && + other.summaryText === this.summaryText && + other.collapsed === this.collapsed + ); + } + + toDOM(view: EditorView): HTMLElement { + const button = document.createElement('button'); + button.type = 'button'; + button.className = 'meo-md-details-summary'; + button.title = this.collapsed ? 'Expand details' : 'Collapse details'; + button.setAttribute('aria-label', this.collapsed ? 'Expand details' : 'Collapse details'); + + const label = document.createElement('span'); + label.className = 'meo-md-details-summary-label'; + label.textContent = this.summaryText; + button.appendChild(label); + + button.addEventListener('pointerdown', (event) => { + event.preventDefault(); + event.stopPropagation(); + }); + + button.addEventListener('click', (event) => { + event.preventDefault(); + event.stopPropagation(); + toggleCollapsibleSection(view, this.anchor); + }); + + return button; + } + + ignoreEvent(): boolean { + return true; + } +} + function addMarkdownLinkDecorations(builder, state, node, activeLines) { const urlNode = findChildNode(node, 'URL'); if (!urlNode) { @@ -584,6 +642,64 @@ function addLineClass(builder, state, from, to, deco) { } } +function rangeTouchesActiveLine(state: EditorState, from: number, to: number, activeLines: Set): boolean { + if (to <= from) { + return false; + } + + const startLine = state.doc.lineAt(from).number; + const endLine = state.doc.lineAt(Math.max(from, to - 1)).number; + for (let lineNo = startLine; lineNo <= endLine; lineNo += 1) { + if (activeLines.has(lineNo)) { + return true; + } + } + return false; +} + +function addDetailsBlockDecorations(builder, state, detailsBlocks, activeLines) { + for (const detailsBlock of detailsBlocks) { + const openingActive = rangeTouchesActiveLine(state, detailsBlock.anchorFrom, detailsBlock.anchorTo, activeLines); + const closingActive = rangeTouchesActiveLine(state, detailsBlock.closingFrom, detailsBlock.closingTo, activeLines); + const editingBoundary = openingActive || closingActive; + + if (!editingBoundary) { + addLineClass(builder, state, detailsBlock.lineFrom, detailsBlock.lineTo, lineStyleDecos.detailsSummary); + + if (detailsBlock.summaryFrom > detailsBlock.anchorFrom) { + builder.push( + collapsedHeadingBodyDeco.range(detailsBlock.anchorFrom, detailsBlock.summaryFrom) + ); + } + + builder.push( + Decoration.replace({ + widget: new DetailsSummaryWidget( + detailsBlock.anchorFrom, + detailsBlock.lineFrom, + detailsBlock.summaryText, + detailsBlock.collapsed + ) + }).range(detailsBlock.summaryFrom, detailsBlock.summaryTo) + ); + + if (detailsBlock.anchorTo > detailsBlock.summaryTo) { + builder.push( + collapsedHeadingBodyDeco.range(detailsBlock.summaryTo, detailsBlock.anchorTo) + ); + } + } + + if (!editingBoundary) { + builder.push(collapsedHeadingBodyDeco.range(detailsBlock.closingFrom, detailsBlock.closingTo)); + } + + if (detailsBlock.collapsed && detailsBlock.bodyTo > detailsBlock.bodyFrom) { + builder.push(collapsedHeadingBodyDeco.range(detailsBlock.bodyFrom, detailsBlock.bodyTo)); + } + } +} + function addAtxHeadingPrefixMarkers(builder, state, from, activeLines) { const line = state.doc.lineAt(from); const text = state.doc.sliceString(line.from, line.to); @@ -664,6 +780,7 @@ function buildDecorations(state) { const indentSelectedLines = collectIndentSelectedLines(state); const tree = resolvedSyntaxTree(state); const collapsedHeadingSections = getCollapsedHeadingSections(state); + const detailsBlocks = getDetailsBlocks(state); const strikeRanges = collectStrikethroughRanges(tree); const codeBlockLines = collectCodeBlockLines(state, tree); const parsedTableRanges = []; @@ -868,6 +985,7 @@ function buildDecorations(state) { addSingleTildeStrikeDecorations(ranges, state, activeLines, strikeRanges, codeBlockLines); addListLineDecorations(ranges, state, indentSelectedLines, frontmatter, codeBlockLines); addEmojiDecorations(ranges, state, codeBlockLines); + addDetailsBlockDecorations(ranges, state, detailsBlocks, activeLines); for (const section of collapsedHeadingSections) { addLineClass(ranges, state, section.lineFrom, section.lineTo, collapsedHeadingLineDeco); addRange(ranges, section.collapseFrom, section.collapseTo, collapsedHeadingBodyDeco); diff --git a/webview/src/styles.css b/webview/src/styles.css index d3075ae..68195f2 100644 --- a/webview/src/styles.css +++ b/webview/src/styles.css @@ -666,6 +666,38 @@ body { transform: rotate(-90deg); } +.cm-editor .meo-md-details-summary { + display: inline-flex; + align-items: center; + gap: 0.35rem; + padding: 1px 0; + border: 0; + background: transparent; + color: inherit; + font: inherit; + font-weight: 600; + cursor: pointer; + border-radius: 3px; +} + +.cm-editor .cm-line.meo-md-details-summary-line { + box-shadow: inset 0 -1px 0 color-mix( + in srgb, + var(--meo-color-base03, var(--vscode-editorLineNumber-foreground)) 70%, + transparent + ); +} + +.cm-editor .meo-md-details-summary:hover, +.cm-editor .meo-md-details-summary:focus-visible { + color: var(--vscode-editor-foreground); + outline: none; +} + +.cm-editor .meo-md-details-summary-label { + display: inline; +} + .cm-editor .cm-gutterElement.cm-activeLineGutter { background: transparent !important; color: var(--vscode-editor-foreground);