From e3a9a82d4a66419826ac488f381f0f165b85880e Mon Sep 17 00:00:00 2001 From: hung6895 Date: Thu, 19 Mar 2026 15:40:37 +0700 Subject: [PATCH] feat: add collapsible sections plugin Add support for collapsing/expanding document sections by heading level. Click the left side of any heading to toggle collapse. All content under the heading (until the next heading of same or higher level) is hidden. Includes Collapse All / Expand All signals for programmatic control. Co-Authored-By: Claude Opus 4.6 --- src/examples/collapsible-sections.stories.tsx | 124 +++++++++++ src/index.ts | 2 + .../CollapsibleSectionsComponent.tsx | 109 ++++++++++ src/plugins/collapsible-sections/index.ts | 200 ++++++++++++++++++ 4 files changed, 435 insertions(+) create mode 100644 src/examples/collapsible-sections.stories.tsx create mode 100644 src/plugins/collapsible-sections/CollapsibleSectionsComponent.tsx create mode 100644 src/plugins/collapsible-sections/index.ts diff --git a/src/examples/collapsible-sections.stories.tsx b/src/examples/collapsible-sections.stories.tsx new file mode 100644 index 00000000..670eb70e --- /dev/null +++ b/src/examples/collapsible-sections.stories.tsx @@ -0,0 +1,124 @@ +import React from 'react' +import { usePublisher } from '@mdxeditor/gurx' +import { + MDXEditor, + collapsibleSectionsPlugin, + headingsPlugin, + listsPlugin, + quotePlugin, + linkPlugin, + thematicBreakPlugin, + toolbarPlugin, + diffSourcePlugin, + markdownShortcutPlugin, + toggleAllSections$, + addTopAreaChild$, + UndoRedo, + DiffSourceToggleWrapper, + realmPlugin +} from '../index' + +const testMarkdown = `# Chapter 1 + +This is the introduction to chapter 1. Click the **left side** of any heading to collapse/expand that section. + +## Section 1.1 + +Content under section 1.1. This should collapse when "Chapter 1" is collapsed. + +### Subsection 1.1.1 + +Deep nested content under section 1.1. + +### Subsection 1.1.2 + +More deep content. These subsections should also hide when Section 1.1 or Chapter 1 is collapsed. + +## Section 1.2 + +Content under section 1.2. This should remain visible when Section 1.1 is collapsed, but hidden when Chapter 1 is collapsed. + +# Chapter 2 + +This is chapter 2. It should remain visible when Chapter 1 is collapsed. + +## Section 2.1 + +Content under section 2.1. + +### Deep Section + +Some deeply nested content here. + +#### Even Deeper + +This should all hide when Section 2.1 or Chapter 2 is collapsed. + +* A list item +* Another list item +* Yet another + +## Section 2.2 + +Final section content. + +# Appendix + +Some appendix content. +` + +function CollapseControls() { + const toggleAll = usePublisher(toggleAllSections$) + + return ( +
+ + +
+ ) +} + +const collapsibleSectionsWithControlsPlugin = realmPlugin({ + init(realm) { + realm.pub(addTopAreaChild$, CollapseControls) + } +}) + +export function CollapsibleSections() { + return ( + ( + + + + ) + }), + diffSourcePlugin(), + collapsibleSectionsPlugin(), + collapsibleSectionsWithControlsPlugin() + ]} + onChange={console.log} + /> + ) +} diff --git a/src/index.ts b/src/index.ts index d777fd8b..6c0dcc7c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -79,6 +79,8 @@ export * from './plugins/markdown-shortcut' export * from './plugins/search' +export * from './plugins/collapsible-sections' + // Toolbar components export * from './plugins/toolbar/components/BlockTypeSelect' export * from './plugins/toolbar/components/BoldItalicUnderlineToggles' diff --git a/src/plugins/collapsible-sections/CollapsibleSectionsComponent.tsx b/src/plugins/collapsible-sections/CollapsibleSectionsComponent.tsx new file mode 100644 index 00000000..c5708f60 --- /dev/null +++ b/src/plugins/collapsible-sections/CollapsibleSectionsComponent.tsx @@ -0,0 +1,109 @@ +import React from 'react' +import { useCellValue, usePublisher } from '@mdxeditor/gurx' +import { rootEditor$ } from '../core' +import { toggleHeadingCollapse$ } from './index' + +const INJECTED_STYLE_ID = 'mdxeditor-collapsible-sections-style' + +const COLLAPSIBLE_CSS = ` +[data-collapsed-by] { + display: none; +} +[data-collapsible-heading] { + position: relative; + padding-left: 24px; + cursor: default; +} +[data-collapsible-heading]::before { + content: ''; + position: absolute; + left: 2px; + top: 50%; + transform: translateY(-50%); + width: 0; + height: 0; + border-style: solid; + border-width: 4px 0 4px 6px; + border-color: transparent transparent transparent currentColor; + opacity: 0; + transition: opacity 0.15s ease; + pointer-events: none; +} +[data-collapsible-heading]:hover::before { + opacity: 0.5; +} +[data-heading-collapsed]::before { + opacity: 1 !important; +} +[data-heading-collapsed] { + opacity: 0.7; +} +` + +function injectStyles() { + if (typeof document === 'undefined') { + return + } + if (document.getElementById(INJECTED_STYLE_ID)) { + return + } + const styleEl = document.createElement('style') + styleEl.id = INJECTED_STYLE_ID + styleEl.textContent = COLLAPSIBLE_CSS + document.head.appendChild(styleEl) +} + +export function CollapsibleSectionsComponent() { + const rootEditor = useCellValue(rootEditor$) + const toggleHeadingCollapse = usePublisher(toggleHeadingCollapse$) + + React.useEffect(() => { + injectStyles() + return () => { + document.getElementById(INJECTED_STYLE_ID)?.remove() + } + }, []) + + React.useEffect(() => { + if (!rootEditor) { + return + } + + const rootElement = rootEditor.getRootElement() + if (!rootElement) { + return + } + + const handleClick = (event: MouseEvent) => { + const target = event.target as HTMLElement + const heading = target.closest('[data-heading-key]') as HTMLElement | null + if (!heading) { + return + } + + // Only respond to clicks in the left 24px toggle zone + const headingRect = heading.getBoundingClientRect() + const clickX = event.clientX - headingRect.left + if (clickX >= 24) { + return + } + + // Intercept the click so Lexical doesn't process it as a text selection + event.stopPropagation() + + const nodeKey = heading.getAttribute('data-heading-key') + if (nodeKey) { + toggleHeadingCollapse(nodeKey) + } + } + + // Use capture phase to intercept before Lexical handles it + rootElement.addEventListener('click', handleClick, true) + + return () => { + rootElement.removeEventListener('click', handleClick, true) + } + }, [rootEditor, toggleHeadingCollapse]) + + return null +} diff --git a/src/plugins/collapsible-sections/index.ts b/src/plugins/collapsible-sections/index.ts new file mode 100644 index 00000000..7ea24dc3 --- /dev/null +++ b/src/plugins/collapsible-sections/index.ts @@ -0,0 +1,200 @@ +import { $isHeadingNode } from '@lexical/rich-text' +import { Cell, Signal } from '@mdxeditor/gurx' +import { $getRoot, LexicalEditor } from 'lexical' +import { realmPlugin } from '../../RealmWithPlugins' +import { addActivePlugin$, addComposerChild$, createRootEditorSubscription$, rootEditor$ } from '../core' +import { CollapsibleSectionsComponent } from './CollapsibleSectionsComponent' + +function headingTagToLevel(tag: string): number { + return parseInt(tag.replace('h', ''), 10) +} + +interface HeadingInfo { + key: string + level: number + element: HTMLElement | null +} + +/** + * Collects heading information and their DOM elements from the Lexical tree. + * Only considers top-level headings (direct children of root). + */ +function collectHeadingsWithElements(editor: LexicalEditor): HeadingInfo[] { + const headings: HeadingInfo[] = [] + editor.getEditorState().read(() => { + const root = $getRoot() + root.getChildren().forEach((child) => { + if ($isHeadingNode(child)) { + headings.push({ + key: child.getKey(), + level: headingTagToLevel(child.getTag()), + element: editor.getElementByKey(child.getKey()) + }) + } + }) + }) + return headings +} + +/** + * Removes all collapsible section attributes from the DOM. + */ +function clearCollapseAttributes(rootElement: HTMLElement) { + rootElement.querySelectorAll('[data-collapsed-by]').forEach((el) => { + el.removeAttribute('data-collapsed-by') + }) + + rootElement.querySelectorAll('[data-collapsible-heading]').forEach((el) => { + el.removeAttribute('data-collapsible-heading') + el.removeAttribute('data-heading-collapsed') + el.removeAttribute('data-heading-key') + }) +} + +/** + * Updates the DOM to reflect the current collapse state. + * Headings are processed outermost-first so that outer collapses take precedence + * over inner ones. An inner collapse never overwrites an outer collapse's attribute. + */ +function updateCollapseUI(editor: LexicalEditor, collapsedKeys: Set) { + const rootElement = editor.getRootElement() + if (!rootElement) { + return + } + + requestAnimationFrame(() => { + clearCollapseAttributes(rootElement) + + const headings = collectHeadingsWithElements(editor) + + for (let i = 0; i < headings.length; i++) { + const heading = headings[i] + if (!heading.element) { + continue + } + + heading.element.setAttribute('data-collapsible-heading', 'true') + heading.element.setAttribute('data-heading-key', heading.key) + + const isCollapsed = collapsedKeys.has(heading.key) + if (isCollapsed) { + heading.element.setAttribute('data-heading-collapsed', 'true') + + // Find the boundary: the next heading of same or higher level, or end of document + let boundaryElement: Element | null = null + for (let j = i + 1; j < headings.length; j++) { + if (headings[j].level <= heading.level) { + boundaryElement = headings[j].element + break + } + } + + // Hide all DOM siblings between this heading and the boundary. + // Don't overwrite attributes set by an outer (earlier) collapse — + // outermost collapsed heading wins, which is correct since we iterate + // in document order (outer headings come first). + let el: Element | null = heading.element.nextElementSibling + while (el && el !== boundaryElement) { + if (!el.hasAttribute('data-collapsed-by')) { + el.setAttribute('data-collapsed-by', heading.key) + } + el = el.nextElementSibling + } + } + } + }) +} + +/** + * Holds the set of currently collapsed heading node keys. + * @group Collapsible Sections + */ +export const collapsedHeadingKeys$ = Cell>(new Set()) + +/** + * A signal to toggle a specific heading's collapse state. + * Publish a heading node key to toggle it. + * @group Collapsible Sections + */ +export const toggleHeadingCollapse$ = Signal((r) => { + r.sub(toggleHeadingCollapse$, (nodeKey) => { + const collapsedKeys = new Set(r.getValue(collapsedHeadingKeys$)) + if (collapsedKeys.has(nodeKey)) { + collapsedKeys.delete(nodeKey) + } else { + collapsedKeys.add(nodeKey) + } + const rootEditor = r.getValue(rootEditor$) + if (rootEditor) { + r.pub(collapsedHeadingKeys$, collapsedKeys) + updateCollapseUI(rootEditor, collapsedKeys) + } + }) +}) + +/** + * A signal to collapse or expand all sections. + * Publish `true` to collapse all, `false` to expand all. + * @group Collapsible Sections + */ +export const toggleAllSections$ = Signal((r) => { + r.sub(toggleAllSections$, (collapse) => { + const rootEditor = r.getValue(rootEditor$) + if (!rootEditor) { + return + } + + if (collapse) { + const allKeys = collectHeadingsWithElements(rootEditor).map((h) => h.key) + r.pub(collapsedHeadingKeys$, new Set(allKeys)) + updateCollapseUI(rootEditor, new Set(allKeys)) + } else { + r.pub(collapsedHeadingKeys$, new Set()) + updateCollapseUI(rootEditor, new Set()) + } + }) +}) + +/** + * A plugin that adds support for collapsing/expanding document sections by heading level. + * Click on the left side of a heading to toggle collapse. All content under the heading + * (until the next heading of same or higher level) will be hidden. + * + * This is a UI-only feature. Collapse state is transient and not persisted to markdown. + * + * @group Collapsible Sections + */ +export const collapsibleSectionsPlugin = realmPlugin({ + init(realm) { + realm.pubIn({ + [addActivePlugin$]: 'collapsibleSections' + }) + + realm.pub(addComposerChild$, CollapsibleSectionsComponent) + + // Update collapse UI when the document structure changes. + // Always run to keep heading attributes in sync, even when nothing is collapsed. + realm.pub(createRootEditorSubscription$, (editor) => { + // Initial setup: apply heading attributes once the root element is ready + let initialUpdateDone = false + const removeRootListener = editor.registerRootListener((rootElement) => { + if (rootElement && !initialUpdateDone) { + initialUpdateDone = true + updateCollapseUI(editor, realm.getValue(collapsedHeadingKeys$)) + } + }) + + const removeUpdateListener = editor.registerUpdateListener(({ dirtyElements }) => { + if (dirtyElements.size === 0) { + return + } + updateCollapseUI(editor, realm.getValue(collapsedHeadingKeys$)) + }) + + return () => { + removeRootListener() + removeUpdateListener() + } + }) + } +})