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