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() + } + }) + } +})