mirror of
https://github.com/vadimmelnicuk/meo.git
synced 2026-05-03 20:50:45 +00:00
feat: implement collapsible details blocks with summary widget and styling
This commit is contained in:
@@ -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<number>();
|
||||
const expandHeadingCollapseEffect = StateEffect.define<number[]>();
|
||||
const emptyCollapsedHeadings: readonly number[] = Object.freeze([]);
|
||||
const emptyCollapseOverrides = Object.freeze(new Map<number, boolean>());
|
||||
|
||||
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<number, HeadingSection> {
|
||||
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<number, CollapsibleSection> {
|
||||
return new Map(getCollapsibleSections(state).map((section) => [section.lineFrom, section]));
|
||||
}
|
||||
|
||||
function getCollapsibleSectionAnchorMap(state: EditorState): Map<number, CollapsibleSection> {
|
||||
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<number, boolean>,
|
||||
transaction: Transaction
|
||||
): Map<number, boolean> {
|
||||
if (!overrides.size || !transaction.docChanged) {
|
||||
return new Map(overrides);
|
||||
}
|
||||
return anchors.map((lineFrom) => transaction.changes.mapPos(lineFrom, 1));
|
||||
|
||||
const mapped = new Map<number, boolean>();
|
||||
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<number, boolean>
|
||||
): ReadonlyMap<number, boolean> {
|
||||
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<number>();
|
||||
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<number, boolean>,
|
||||
b: ReadonlyMap<number, boolean>
|
||||
): 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<number>): readonly number[] {
|
||||
if (!values.size) {
|
||||
return emptyCollapsedHeadings;
|
||||
return [];
|
||||
}
|
||||
return Array.from(values).sort((a, b) => a - b);
|
||||
}
|
||||
|
||||
function toggleSetNumber(values: Set<number>, value: number): void {
|
||||
if (values.has(value)) {
|
||||
values.delete(value);
|
||||
return;
|
||||
}
|
||||
values.add(value);
|
||||
function getEffectiveCollapsedState(
|
||||
overrides: ReadonlyMap<number, boolean>,
|
||||
section: CollapsibleSection
|
||||
): boolean {
|
||||
return overrides.get(section.anchor) ?? section.defaultCollapsed;
|
||||
}
|
||||
|
||||
const headingCollapseStateField = StateField.define<readonly number[]>({
|
||||
create(): readonly number[] {
|
||||
return emptyCollapsedHeadings;
|
||||
function setCollapseOverride(
|
||||
overrides: Map<number, boolean>,
|
||||
sections: Map<number, CollapsibleSection>,
|
||||
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<number, boolean>,
|
||||
sections: Map<number, CollapsibleSection>,
|
||||
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<ReadonlyMap<number, boolean>>({
|
||||
create(): ReadonlyMap<number, boolean> {
|
||||
return emptyCollapseOverrides;
|
||||
},
|
||||
update(collapsedHeadings: readonly number[], transaction: any): readonly number[] {
|
||||
update(
|
||||
collapsedHeadings: ReadonlyMap<number, boolean>,
|
||||
transaction: Transaction
|
||||
): ReadonlyMap<number, boolean> {
|
||||
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<number, boolean> {
|
||||
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<number>();
|
||||
@@ -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<any>();
|
||||
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)
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 = /<details\b[^>]*>/i;
|
||||
const detailsCloseTagPattern = /<\/details\s*>/i;
|
||||
const summaryTagPattern = /<summary\b[^>]*>([\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(/^<details\b/i, '')
|
||||
.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);
|
||||
|
||||
+119
-1
@@ -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<number>): 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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user