Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
124 changes: 124 additions & 0 deletions src/examples/collapsible-sections.stories.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div style={{ marginBottom: 8, display: 'flex', gap: 8 }}>
<button
onClick={() => {
toggleAll(true)
}}
>
Collapse All
</button>
<button
onClick={() => {
toggleAll(false)
}}
>
Expand All
</button>
</div>
)
}

const collapsibleSectionsWithControlsPlugin = realmPlugin({
init(realm) {
realm.pub(addTopAreaChild$, CollapseControls)
}
})

export function CollapsibleSections() {
return (
<MDXEditor
markdown={testMarkdown}
plugins={[
headingsPlugin(),
listsPlugin(),
quotePlugin(),
linkPlugin(),
thematicBreakPlugin(),
markdownShortcutPlugin(),
toolbarPlugin({
toolbarContents: () => (
<DiffSourceToggleWrapper>
<UndoRedo />
</DiffSourceToggleWrapper>
)
}),
diffSourcePlugin(),
collapsibleSectionsPlugin(),
collapsibleSectionsWithControlsPlugin()
]}
onChange={console.log}
/>
)
}
2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
109 changes: 109 additions & 0 deletions src/plugins/collapsible-sections/CollapsibleSectionsComponent.tsx
Original file line number Diff line number Diff line change
@@ -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
}
Loading