diff --git a/src/index.ts b/src/index.ts index 429ed1b0..51100a95 100644 --- a/src/index.ts +++ b/src/index.ts @@ -21,6 +21,7 @@ export * from './plugins/math'; export * from './plugins/mention'; export * from './plugins/slash'; export * from './plugins/table'; +export * from './plugins/toc'; export * from './plugins/toolbar'; export * from './plugins/upload'; export * from './plugins/virtual-block'; diff --git a/src/locale/index.ts b/src/locale/index.ts index e3d989a4..3713ab57 100644 --- a/src/locale/index.ts +++ b/src/locale/index.ts @@ -59,4 +59,12 @@ export default { insertRowAbove: 'Insert {{count}} row(s) above', insertRowBelow: 'Insert {{count}} row(s) below', }, + toc: { + ariaLabel: 'Table of contents', + empty: 'No headings', + expand: 'Expand table of contents', + pin: 'Pin table of contents', + title: 'TOC', + unpin: 'Unpin table of contents', + }, }; diff --git a/src/plugins/toc/__tests__/service.test.ts b/src/plugins/toc/__tests__/service.test.ts new file mode 100644 index 00000000..3a1e2278 --- /dev/null +++ b/src/plugins/toc/__tests__/service.test.ts @@ -0,0 +1,93 @@ +import type { LexicalEditor } from 'lexical'; +import { describe, expect, it, vi } from 'vitest'; + +import Editor from '@/editor-kernel'; +import { CommonPlugin } from '@/plugins/common'; +import { MarkdownPlugin } from '@/plugins/markdown'; + +import { TocPlugin } from '../plugin'; +import { ITocService, TocService } from '../service'; + +const nextTick = () => new Promise((resolve) => setTimeout(resolve, 0)); + +describe('TocService', () => { + it('notifies subscribers when active heading changes', () => { + const service = new TocService(); + const listener = vi.fn(); + const unsubscribe = service.subscribe(listener); + + service.setActiveKey('heading-a'); + service.setActiveKey('heading-a'); + service.setActiveKey('heading-b'); + unsubscribe(); + service.setActiveKey('heading-c'); + + expect(listener).toHaveBeenCalledTimes(2); + expect(service.getActiveKey()).toBe('heading-c'); + }); + + it('scrolls the viewport to a heading through the bound lexical editor', () => { + const scrollTo = vi.spyOn(window, 'scrollTo').mockImplementation(() => {}); + const service = new TocService(); + const editor = { + getElementByKey: (key: string) => + key === 'heading-a' + ? { + getBoundingClientRect: () => ({ top: 120 }), + scrollIntoView: vi.fn(), + } + : null, + } as unknown as LexicalEditor; + + service.bindEditor(editor); + + expect(service.jumpTo('missing')).toBe(false); + expect(service.jumpTo('heading-a', { behavior: 'auto', offsetTop: 16 })).toBe(true); + expect(scrollTo).toHaveBeenCalledWith({ behavior: 'auto', top: 104 }); + expect(service.getActiveKey()).toBe('heading-a'); + }); + + it('refreshes toc items when document headings change', async () => { + const kernel = Editor.createEditor().registerPlugins([CommonPlugin, MarkdownPlugin, TocPlugin]); + kernel.initNodeEditor(); + + const service = kernel.requireService(ITocService); + expect(service).not.toBeNull(); + + kernel.setDocument('markdown', '# Intro\n\n## Details\n\nBody'); + await nextTick(); + + expect(service?.getFlatItems().map((item) => item.title)).toEqual(['Intro', 'Details']); + + const json = kernel.getDocument('json') as any; + json.root.children[0].children[0].text = 'Updated intro'; + json.root.children.splice(1, 1); + json.root.children.push({ + children: [ + { + detail: 0, + format: 0, + mode: 'normal', + style: '', + text: 'New section', + type: 'text', + version: 1, + }, + ], + direction: 'ltr', + format: '', + indent: 0, + tag: 'h2', + type: 'heading', + version: 1, + }); + + kernel.setDocument('json', json); + await nextTick(); + + expect(service?.getFlatItems().map((item) => item.title)).toEqual([ + 'Updated intro', + 'New section', + ]); + }); +}); diff --git a/src/plugins/toc/__tests__/utils.test.ts b/src/plugins/toc/__tests__/utils.test.ts new file mode 100644 index 00000000..b6bd6557 --- /dev/null +++ b/src/plugins/toc/__tests__/utils.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, it } from 'vitest'; + +import { buildTocTree, flattenTocTree } from '../utils'; + +describe('buildTocTree', () => { + it('nests headings by depth while preserving document order', () => { + const tree = buildTocTree([ + { depth: 1, key: 'a', tag: 'h1', title: 'Intro' }, + { depth: 2, key: 'b', tag: 'h2', title: 'Background' }, + { depth: 3, key: 'c', tag: 'h3', title: 'Details' }, + { depth: 2, key: 'd', tag: 'h2', title: 'Result' }, + { depth: 1, key: 'e', tag: 'h1', title: 'Summary' }, + ]); + + expect(tree).toEqual([ + { + children: [ + { + children: [ + { + children: [], + depth: 3, + key: 'c', + tag: 'h3', + title: 'Details', + }, + ], + depth: 2, + key: 'b', + tag: 'h2', + title: 'Background', + }, + { + children: [], + depth: 2, + key: 'd', + tag: 'h2', + title: 'Result', + }, + ], + depth: 1, + key: 'a', + tag: 'h1', + title: 'Intro', + }, + { + children: [], + depth: 1, + key: 'e', + tag: 'h1', + title: 'Summary', + }, + ]); + }); + + it('keeps skipped-depth headings under the nearest shallower heading', () => { + const tree = buildTocTree([ + { depth: 2, key: 'a', tag: 'h2', title: 'Start' }, + { depth: 4, key: 'b', tag: 'h4', title: 'Deep' }, + { depth: 3, key: 'c', tag: 'h3', title: 'Middle' }, + ]); + + expect(tree).toEqual([ + { + children: [ + { + children: [], + depth: 4, + key: 'b', + tag: 'h4', + title: 'Deep', + }, + { + children: [], + depth: 3, + key: 'c', + tag: 'h3', + title: 'Middle', + }, + ], + depth: 2, + key: 'a', + tag: 'h2', + title: 'Start', + }, + ]); + }); + + it('flattens a toc tree in render order', () => { + const tree = buildTocTree([ + { depth: 1, key: 'a', tag: 'h1', title: 'Intro' }, + { depth: 2, key: 'b', tag: 'h2', title: 'Background' }, + { depth: 1, key: 'c', tag: 'h1', title: 'Summary' }, + ]); + + expect(flattenTocTree(tree).map((item) => item.key)).toEqual(['a', 'b', 'c']); + }); +}); diff --git a/src/plugins/toc/index.ts b/src/plugins/toc/index.ts new file mode 100644 index 00000000..5b1ba305 --- /dev/null +++ b/src/plugins/toc/index.ts @@ -0,0 +1,5 @@ +export * from './plugin'; +export * from './react'; +export * from './service'; +export * from './types'; +export * from './utils'; diff --git a/src/plugins/toc/plugin/index.ts b/src/plugins/toc/plugin/index.ts new file mode 100644 index 00000000..f327274d --- /dev/null +++ b/src/plugins/toc/plugin/index.ts @@ -0,0 +1,89 @@ +import { $isHeadingNode } from '@lexical/rich-text'; +import { $getNodeByKey, type EditorState, type LexicalEditor, type LexicalNode } from 'lexical'; + +import { KernelPlugin } from '@/editor-kernel/plugin'; +import type { IEditorKernel, IEditorPlugin, IEditorPluginConstructor } from '@/types'; + +import { ITocService, TocPluginOptions, TocService } from '../service'; + +function $hasHeadingAncestor(node: LexicalNode | null) { + let current: LexicalNode | null = node; + + while (current) { + if ($isHeadingNode(current)) { + return true; + } + + current = current.getParent(); + } + + return false; +} + +function hasHeadingUpdate( + editorState: EditorState, + prevEditorState: EditorState, + dirtyElements: Map, + dirtyLeaves: Set, +) { + const dirtyKeys = new Set([...dirtyElements.keys(), ...dirtyLeaves]); + if (dirtyKeys.size === 0) return false; + if (dirtyElements.get('root') === true) return true; + + let hasHeading = false; + const findHeading = () => { + for (const key of dirtyKeys) { + if ($hasHeadingAncestor($getNodeByKey(key))) { + hasHeading = true; + return; + } + } + }; + + editorState.read(findHeading); + if (hasHeading) return true; + + prevEditorState.read(findHeading); + return hasHeading; +} + +export const TocPlugin: IEditorPluginConstructor = class + extends KernelPlugin + implements IEditorPlugin +{ + static pluginName = 'TocPlugin'; + + public service: TocService; + + constructor( + protected kernel: IEditorKernel, + public config: TocPluginOptions = {}, + ) { + super(); + + this.service = new TocService(); + this.service.setDepthRange(config); + kernel.registerServiceHotReload(ITocService, this.service); + } + + onInit(editor: LexicalEditor): void { + this.service.bindEditor(editor); + this.service.refresh(); + + const refreshOnDocumentChange = this.service.refresh.bind(this.service); + + this.kernel.on('documentChange', refreshOnDocumentChange); + this.register(() => this.kernel.off('documentChange', refreshOnDocumentChange)); + this.register( + editor.registerUpdateListener( + ({ dirtyElements, dirtyLeaves, editorState, prevEditorState }) => { + if (!hasHeadingUpdate(editorState, prevEditorState, dirtyElements, dirtyLeaves)) { + return; + } + + this.service.refresh(); + }, + ), + ); + } +}; diff --git a/src/plugins/toc/react/ReactTocPlugin.tsx b/src/plugins/toc/react/ReactTocPlugin.tsx new file mode 100644 index 00000000..c01530f9 --- /dev/null +++ b/src/plugins/toc/react/ReactTocPlugin.tsx @@ -0,0 +1,26 @@ +'use client'; + +import type { FC } from 'react'; + +import { useLexicalComposerContext } from '@/editor-kernel/react/react-context'; + +import TocView from './TocView'; +import type { ReactTocPluginProps } from './type'; + +const TocViewWithContext: FC> = (props) => { + const [editor] = useLexicalComposerContext(); + + return ; +}; + +const ReactTocPlugin: FC = ({ editor, ...props }) => { + if (editor) { + return ; + } + + return ; +}; + +ReactTocPlugin.displayName = 'ReactTocPlugin'; + +export default ReactTocPlugin; diff --git a/src/plugins/toc/react/TableOfContents.tsx b/src/plugins/toc/react/TableOfContents.tsx new file mode 100644 index 00000000..54dfeea4 --- /dev/null +++ b/src/plugins/toc/react/TableOfContents.tsx @@ -0,0 +1,141 @@ +'use client'; + +import { cx } from 'antd-style'; +import { EyeIcon, EyeOffIcon } from 'lucide-react'; +import type { FC, ReactNode } from 'react'; + +import { styles } from './style'; +import type { TocItem } from './type'; + +interface TableOfContentsProps { + activeKey: null | string; + collapsed: boolean; + emptyText: ReactNode; + expandLabel: string; + items: TocItem[]; + jumpTo: (_key: string) => void; + pinLabel: string; + pinned: boolean; + setExpandedByHover: (expanded: boolean) => void; + setPinned: (pinned: boolean) => void; + showHeader?: boolean; + title: ReactNode; + unpinLabel: string; +} + +const TocList: FC<{ + activeKey: null | string; + items: TocItem[]; + jumpTo: (_key: string) => void; +}> = ({ activeKey, items, jumpTo }) => { + return ( +
    + {items.map((item, index) => { + const active = activeKey === item.key; + const prefix = `${index + 1}.`; + + return ( +
  1. + + {item.children.length > 0 ? ( + + ) : null} +
  2. + ); + })} +
+ ); +}; + +function flattenItems(items: TocItem[]): TocItem[] { + const result: TocItem[] = []; + + for (const item of items) { + result.push(item, ...flattenItems(item.children)); + } + + return result; +} + +const TableOfContents: FC = ({ + activeKey, + collapsed, + emptyText, + expandLabel, + items, + jumpTo, + pinLabel, + pinned, + setExpandedByHover, + setPinned, + showHeader = true, + title, + unpinLabel, +}) => { + if (collapsed) { + const railItems = flattenItems(items).slice(0, 10); + + return ( + + ); + } + + return ( + <> + {showHeader ? ( +
+ {title} + + + +
+ ) : null} + {items.length > 0 ? ( + + ) : ( +
{emptyText}
+ )} + + ); +}; + +export default TableOfContents; diff --git a/src/plugins/toc/react/TocView.tsx b/src/plugins/toc/react/TocView.tsx new file mode 100644 index 00000000..8c4a4256 --- /dev/null +++ b/src/plugins/toc/react/TocView.tsx @@ -0,0 +1,197 @@ +'use client'; + +import { cx } from 'antd-style'; +import { + type CSSProperties, + type FC, + useCallback, + useInsertionEffect, + useLayoutEffect, + useRef, + useState, +} from 'react'; + +import type { IEditor } from '@/types'; + +import { TocPlugin } from '../plugin'; +import TableOfContents from './TableOfContents'; +import { resolveTocScrollContainer } from './getNearestScrollContainer'; +import { useActiveHeading } from './hooks/useActiveHeading'; +import { usePinnedTocReserve } from './hooks/usePinnedTocReserve'; +import { useTocAnchor } from './hooks/useTocAnchor'; +import { useTocItems } from './hooks/useTocItems'; +import { styles } from './style'; +import type { ReactTocPluginProps } from './type'; + +type TocViewProps = ReactTocPluginProps & { editor: IEditor }; + +function getFixedStyle( + pinned: boolean, + tocAnchor: ReturnType, +): CSSProperties | undefined { + const stuck = Boolean(pinned && tocAnchor?.stuck); + + return stuck && tocAnchor + ? { + insetBlockStart: tocAnchor.top, + insetInlineStart: tocAnchor.left, + width: tocAnchor.width, + } + : undefined; +} + +function getSlotStyle( + style: CSSProperties | undefined, + tocAnchor: ReturnType, +): CSSProperties | undefined { + return tocAnchor + ? { + ...style, + insetBlockStart: tocAnchor.slotTop, + } + : style; +} + +function getBottomedStyle( + pinned: boolean, + tocAnchor: ReturnType, +): CSSProperties | undefined { + return pinned && tocAnchor?.bottomed + ? { + insetBlockStart: tocAnchor.bottomTop, + width: tocAnchor.width, + } + : undefined; +} + +const TocView: FC = ({ + behavior = 'smooth', + className, + defaultPinned = false, + emptyText, + editor, + getScrollContainer, + locale, + maxDepth = 6, + minDepth = 1, + offsetTop = 8, + onItemsChange, + onPinnedChange, + render, + reserveGap = 24, + reserveOnPinned = true, + showHeader, + style, + title, +}) => { + const slotRef = useRef(null); + const [expandedByHover, setExpandedByHover] = useState(false); + const [, setLocaleVersion] = useState(0); + const [pinned, setPinnedState] = useState(defaultPinned); + const collapsed = !pinned && !expandedByHover; + const { activeKey, items, service } = useTocItems({ + editor, + maxDepth, + minDepth, + onItemsChange, + }); + const tocAnchor = useTocAnchor({ editor, offsetTop, pinned, slotRef }); + + const setPinned = useCallback( + (nextPinned: boolean) => { + setPinnedState(nextPinned); + setExpandedByHover(nextPinned); + onPinnedChange?.(nextPinned); + }, + [onPinnedChange], + ); + + useInsertionEffect(() => { + editor.registerPlugin(TocPlugin, { maxDepth, minDepth }); + }, [editor, maxDepth, minDepth]); + + useLayoutEffect(() => { + if (locale) { + editor.registerLocale(locale); + setLocaleVersion((version) => version + 1); + } + }, [editor, locale]); + + usePinnedTocReserve({ editor, pinned, reserveGap, reserveOnPinned, tocAnchor }); + useActiveHeading({ editor, getScrollContainer, offsetTop, service }); + + const jumpTo = useCallback( + (key: string) => { + const scrollContainer = resolveTocScrollContainer(editor, getScrollContainer); + service?.jumpTo(key, { behavior, container: scrollContainer, offsetTop }); + }, + [behavior, editor, getScrollContainer, offsetTop, service], + ); + + const content = render ? ( + render({ activeKey, collapsed, items, jumpTo, pinned, setPinned }) + ) : ( + + ); + const fixedStyle = getFixedStyle(pinned, tocAnchor); + const bottomedStyle = getBottomedStyle(pinned, tocAnchor); + const slotStyle = getSlotStyle(style, tocAnchor); + const stuck = Boolean(pinned && tocAnchor?.stuck); + const bottomed = Boolean(pinned && tocAnchor?.bottomed); + + return ( +
setExpandedByHover(true)} + onMouseLeave={() => { + if (!pinned) { + setExpandedByHover(false); + } + }} + onPointerEnter={() => setExpandedByHover(true)} + onPointerLeave={() => { + if (!pinned) { + setExpandedByHover(false); + } + }} + ref={slotRef} + style={slotStyle} + > + +
+ ); +}; + +TocView.displayName = 'TocView'; + +export default TocView; diff --git a/src/plugins/toc/react/getNearestScrollContainer.ts b/src/plugins/toc/react/getNearestScrollContainer.ts new file mode 100644 index 00000000..65fbc840 --- /dev/null +++ b/src/plugins/toc/react/getNearestScrollContainer.ts @@ -0,0 +1,34 @@ +import type { IEditor } from '@/types'; + +export type GetTocScrollContainer = (editor: IEditor) => HTMLElement | Window | null; + +export function getNearestScrollContainer(element: HTMLElement | null): HTMLElement | Window { + if (!element || typeof window === 'undefined') { + return window; + } + + let current = element.parentElement; + while (current) { + if (current === document.body || current === document.documentElement) { + return window; + } + + const style = window.getComputedStyle(current); + const overflow = `${style.overflow} ${style.overflowY} ${style.overflowX}`; + + if (/(auto|scroll|overlay)/.test(overflow) && current.scrollHeight > current.clientHeight) { + return current; + } + + current = current.parentElement; + } + + return window; +} + +export function resolveTocScrollContainer( + editor: IEditor, + getScrollContainer?: GetTocScrollContainer, +): HTMLElement | Window { + return getScrollContainer?.(editor) ?? getNearestScrollContainer(editor.getRootElement()); +} diff --git a/src/plugins/toc/react/hooks/useActiveHeading.ts b/src/plugins/toc/react/hooks/useActiveHeading.ts new file mode 100644 index 00000000..fa84e86d --- /dev/null +++ b/src/plugins/toc/react/hooks/useActiveHeading.ts @@ -0,0 +1,86 @@ +import { useEffect } from 'react'; + +import type { IEditor } from '@/types'; + +import type { ITocService } from '../../service'; +import { + type GetTocScrollContainer, + resolveTocScrollContainer, +} from '../getNearestScrollContainer'; + +interface UseActiveHeadingOptions { + editor: IEditor; + getScrollContainer?: GetTocScrollContainer; + offsetTop: number; + service: ITocService | null; +} + +export function useActiveHeading({ + editor, + getScrollContainer, + offsetTop, + service, +}: UseActiveHeadingOptions) { + useEffect(() => { + const lexicalEditor = editor.getLexicalEditor(); + if (!lexicalEditor || !service) { + service?.setActiveKey(null); + return; + } + + const scrollContainer = resolveTocScrollContainer(editor, getScrollContainer); + let frameId: null | number = null; + + const measure = () => { + const flatItems = service.getFlatItems(); + if (flatItems.length === 0) { + service.setActiveKey(null); + return; + } + + let nextActiveKey = flatItems[0]?.key ?? null; + const containerTop = + scrollContainer instanceof Window ? 0 : scrollContainer.getBoundingClientRect().top; + + for (const item of flatItems) { + const element = lexicalEditor.getElementByKey(item.key); + if (!element) continue; + + const top = element.getBoundingClientRect().top - containerTop; + if (top <= offsetTop + 1) { + nextActiveKey = item.key; + } else { + break; + } + } + + service.setActiveKey(nextActiveKey); + }; + + const scheduleMeasure = () => { + if (frameId !== null) return; + + frameId = window.requestAnimationFrame(() => { + frameId = null; + measure(); + }); + }; + + measure(); + const scrollOptions = { passive: true } as const; + const documentScrollOptions = { capture: true, passive: true } as const; + scrollContainer.addEventListener('scroll', scheduleMeasure, scrollOptions); + document.addEventListener('scroll', scheduleMeasure, documentScrollOptions); + window.addEventListener('resize', scheduleMeasure); + + return () => { + if (frameId !== null) { + window.cancelAnimationFrame(frameId); + } + + scrollContainer.removeEventListener('scroll', scheduleMeasure, false); + document.removeEventListener('scroll', scheduleMeasure, true); + window.removeEventListener('resize', scheduleMeasure); + }; + }, [editor, getScrollContainer, offsetTop, service]); +} diff --git a/src/plugins/toc/react/hooks/usePinnedTocReserve.ts b/src/plugins/toc/react/hooks/usePinnedTocReserve.ts new file mode 100644 index 00000000..dd8688f8 --- /dev/null +++ b/src/plugins/toc/react/hooks/usePinnedTocReserve.ts @@ -0,0 +1,37 @@ +import { useLayoutEffect } from 'react'; + +import type { IEditor } from '@/types'; + +import type { TocAnchor } from './useTocAnchor'; + +interface UsePinnedTocReserveOptions { + editor: IEditor; + pinned: boolean; + reserveGap: number; + reserveOnPinned: boolean; + tocAnchor: TocAnchor | null; +} + +export function usePinnedTocReserve({ + editor, + pinned, + reserveGap, + reserveOnPinned, + tocAnchor, +}: UsePinnedTocReserveOptions) { + useLayoutEffect(() => { + const rootElement = editor.getRootElement(); + if (!rootElement || !reserveOnPinned || !pinned || !tocAnchor) return; + + const previousPaddingInlineEnd = rootElement.style.paddingInlineEnd; + const previousBoxSizing = rootElement.style.boxSizing; + + rootElement.style.boxSizing = 'border-box'; + rootElement.style.paddingInlineEnd = `${tocAnchor.width + reserveGap}px`; + + return () => { + rootElement.style.paddingInlineEnd = previousPaddingInlineEnd; + rootElement.style.boxSizing = previousBoxSizing; + }; + }, [editor, pinned, reserveGap, reserveOnPinned, tocAnchor]); +} diff --git a/src/plugins/toc/react/hooks/useTocAnchor.ts b/src/plugins/toc/react/hooks/useTocAnchor.ts new file mode 100644 index 00000000..dbe7e84e --- /dev/null +++ b/src/plugins/toc/react/hooks/useTocAnchor.ts @@ -0,0 +1,94 @@ +import { type RefObject, useLayoutEffect, useState } from 'react'; + +import type { IEditor } from '@/types'; + +export interface TocAnchor { + bottomTop: number; + bottomed: boolean; + left: number; + slotTop: number; + stuck: boolean; + top: number; + width: number; +} + +interface UseTocAnchorOptions { + editor: IEditor; + offsetTop: number; + pinned: boolean; + slotRef: RefObject; +} + +export function useTocAnchor({ + editor, + offsetTop, + pinned, + slotRef, +}: UseTocAnchorOptions): TocAnchor | null { + const [tocAnchor, setTocAnchor] = useState(null); + + useLayoutEffect(() => { + let frameId: null | number = null; + + const measure = () => { + const slot = slotRef.current; + const rect = slot?.getBoundingClientRect(); + const rootElement = editor.getRootElement(); + const rootRect = rootElement?.getBoundingClientRect(); + if (!slot || !rect || !rootRect) return; + + const tocRect = (slot.firstElementChild as HTMLElement | null)?.getBoundingClientRect(); + const tocHeight = tocRect?.height ?? 0; + const offsetParentRect = (slot.offsetParent as HTMLElement | null)?.getBoundingClientRect(); + const slotTop = rootRect.top - (offsetParentRect?.top ?? 0); + const top = Math.max(rootRect.top, offsetTop); + const bottomed = + pinned && rootRect.top <= offsetTop && rootRect.bottom <= offsetTop + tocHeight; + const bottomTop = Math.max(0, rootRect.height - tocHeight); + const stuck = rootRect.top <= offsetTop && !bottomed; + + setTocAnchor((current) => { + if ( + current && + current.bottomed === bottomed && + current.bottomTop === bottomTop && + current.left === rect.left && + current.slotTop === slotTop && + current.stuck === stuck && + current.top === top && + current.width === rect.width + ) { + return current; + } + + return { bottomTop, bottomed, left: rect.left, slotTop, stuck, top, width: rect.width }; + }); + }; + + const scheduleMeasure = () => { + if (frameId !== null) return; + + frameId = window.requestAnimationFrame(() => { + frameId = null; + measure(); + }); + }; + + measure(); + editor.on('initialized', scheduleMeasure); + document.addEventListener('scroll', scheduleMeasure, { capture: true, passive: true }); + window.addEventListener('resize', scheduleMeasure); + + return () => { + if (frameId !== null) { + window.cancelAnimationFrame(frameId); + } + + editor.off('initialized', scheduleMeasure); + document.removeEventListener('scroll', scheduleMeasure, true); + window.removeEventListener('resize', scheduleMeasure); + }; + }, [editor, offsetTop, pinned, slotRef]); + + return tocAnchor; +} diff --git a/src/plugins/toc/react/hooks/useTocItems.ts b/src/plugins/toc/react/hooks/useTocItems.ts new file mode 100644 index 00000000..239cb307 --- /dev/null +++ b/src/plugins/toc/react/hooks/useTocItems.ts @@ -0,0 +1,60 @@ +import { useEffect, useRef, useState } from 'react'; + +import type { IEditor } from '@/types'; + +import { ITocService, type ITocService as TocService } from '../../service'; +import type { TocItem } from '../../types'; + +interface UseTocItemsOptions { + editor: IEditor; + maxDepth: number; + minDepth: number; + onItemsChange?: (items: TocItem[]) => void; +} + +export function useTocItems({ editor, maxDepth, minDepth, onItemsChange }: UseTocItemsOptions) { + const onItemsChangeRef = useRef(onItemsChange); + const [service, setService] = useState(() => + editor.requireService(ITocService), + ); + const [activeKey, setActiveKey] = useState(null); + const [items, setItems] = useState([]); + + onItemsChangeRef.current = onItemsChange; + + useEffect(() => { + let unsubscribeService: (() => void) | undefined; + + const attachService = () => { + const tocService = editor.requireService(ITocService); + if (!tocService) return; + + unsubscribeService?.(); + unsubscribeService = undefined; + + tocService.setDepthRange({ maxDepth, minDepth }); + tocService.refresh(); + setService(tocService); + + const sync = () => { + const nextItems = tocService.getItems(); + setItems(nextItems); + setActiveKey(tocService.getActiveKey()); + onItemsChangeRef.current?.(nextItems); + }; + + sync(); + unsubscribeService = tocService.subscribe(sync); + }; + + attachService(); + editor.on('initialized', attachService); + + return () => { + editor.off('initialized', attachService); + unsubscribeService?.(); + }; + }, [editor, maxDepth, minDepth]); + + return { activeKey, items, service }; +} diff --git a/src/plugins/toc/react/index.ts b/src/plugins/toc/react/index.ts new file mode 100644 index 00000000..0daf19dc --- /dev/null +++ b/src/plugins/toc/react/index.ts @@ -0,0 +1,3 @@ +export { default as ReactTocPlugin } from './ReactTocPlugin'; +export { default as TableOfContents } from './TableOfContents'; +export type * from './type'; diff --git a/src/plugins/toc/react/style.ts b/src/plugins/toc/react/style.ts new file mode 100644 index 00000000..871d311b --- /dev/null +++ b/src/plugins/toc/react/style.ts @@ -0,0 +1,286 @@ +import { createStaticStyles } from 'antd-style'; + +export const styles = createStaticStyles(({ css, cssVar }) => { + const root = css` + width: 280px; + padding-block: 8px; + padding-inline: 12px; + + color: ${cssVar.colorTextSecondary}; + + transition: + width 160ms ease, + padding 160ms ease; + `; + + const slot = css` + width: 0; + min-width: 0; + `; + + const slotFloating = css` + pointer-events: none; + + position: absolute; + z-index: 20; + inset-block-start: 0; + inset-inline-end: 0; + + height: 100%; + `; + + const slotPinned = css` + pointer-events: auto; + + position: absolute; + z-index: 20; + inset-inline-end: 0; + + width: 280px; + height: 100%; + `; + + const rootFloating = css` + pointer-events: auto; + + position: absolute; + z-index: 20; + inset-block-start: 0; + inset-inline-end: 0; + + overflow: auto; + + max-height: 640px; + + background: ${cssVar.colorBgContainer}; + `; + + const rootPinned = css` + position: relative; + overflow: auto; + max-height: calc(100vh - 32px); + border-inline-start: 1px solid ${cssVar.colorBorderSecondary}; + `; + + const rootPinnedFixed = css` + position: fixed; + z-index: 20; + inset-block-start: 16px; + + overflow: auto; + + max-height: calc(100vh - 32px); + border-inline-start: 1px solid ${cssVar.colorBorderSecondary}; + `; + + const rootPinnedBottomed = css` + position: absolute; + z-index: 20; + inset-inline-start: 0; + + overflow: auto; + + max-height: calc(100vh - 32px); + border-inline-start: 1px solid ${cssVar.colorBorderSecondary}; + `; + + const rootCollapsed = css` + overflow: visible; + width: 24px; + padding: 0; + background: transparent; + `; + + const header = css` + display: flex; + gap: 10px; + align-items: center; + + height: 32px; + padding-inline: 8px; + + font-weight: 600; + color: ${cssVar.colorText}; + `; + + const headerActions = css` + display: flex; + gap: 8px; + align-items: center; + + margin-inline-start: auto; + + color: ${cssVar.colorTextTertiary}; + `; + + const icon = css` + display: block; + width: 16px; + height: 16px; + `; + + const iconButton = css` + cursor: pointer; + + display: inline-flex; + align-items: center; + justify-content: center; + + width: 24px; + height: 24px; + padding: 0; + border: 0; + border-radius: ${cssVar.borderRadiusSM}px; + + color: inherit; + + background: transparent; + + &:hover { + color: ${cssVar.colorText}; + background: ${cssVar.colorFillTertiary}; + } + `; + + const list = css` + display: flex; + flex-direction: column; + gap: 2px; + + margin: 0; + padding: 0; + + list-style: none; + `; + + const children = css` + margin: 0; + padding: 0; + list-style: none; + `; + + const item = css` + margin: 0; + padding: 0; + `; + + const button = css` + cursor: pointer; + + display: flex; + align-items: center; + + width: 100%; + min-width: 0; + height: 32px; + padding-block: 0; + padding-inline: 8px; + border: 0; + border-radius: ${cssVar.borderRadiusSM}px; + + font: inherit; + color: inherit; + text-align: start; + + background: transparent; + + &:hover { + color: ${cssVar.colorText}; + background: ${cssVar.colorFillTertiary}; + } + `; + + const buttonActive = css` + color: ${cssVar.colorText}; + background: ${cssVar.colorFillSecondary}; + `; + + const marker = css` + flex: none; + + width: 2px; + height: 18px; + margin-inline-end: 10px; + border-radius: 2px; + + background: transparent; + `; + + const markerActive = css` + background: ${cssVar.colorPrimary}; + `; + + const text = css` + overflow: hidden; + flex: 1; + + min-width: 0; + + text-overflow: ellipsis; + white-space: nowrap; + `; + + const empty = css` + padding-block: 12px; + padding-inline: 8px; + color: ${cssVar.colorTextTertiary}; + `; + + const rail = css` + cursor: pointer; + + display: flex; + flex-direction: column; + gap: 24px; + align-items: flex-end; + + width: 24px; + height: 100%; + min-height: 360px; + padding-block: 56px; + padding-inline: 0; + border: 0; + border-inline-end: 1px solid ${cssVar.colorBorderSecondary}; + + background: transparent; + `; + + const railBar = css` + display: block; + max-width: 30px; + height: 2px; + background: ${cssVar.colorTextTertiary}; + `; + + const railBarActive = css` + background: ${cssVar.colorPrimary}; + `; + + return { + button, + buttonActive, + children, + empty, + header, + headerActions, + icon, + iconButton, + item, + list, + marker, + markerActive, + rail, + railBar, + railBarActive, + root, + rootCollapsed, + rootFloating, + rootPinned, + rootPinnedBottomed, + rootPinnedFixed, + slot, + slotFloating, + slotPinned, + text, + }; +}); diff --git a/src/plugins/toc/react/type.ts b/src/plugins/toc/react/type.ts new file mode 100644 index 00000000..32c80e69 --- /dev/null +++ b/src/plugins/toc/react/type.ts @@ -0,0 +1,53 @@ +import type { CSSProperties, ReactNode } from 'react'; + +import type { IEditor, ILocaleKeys } from '@/types'; + +import type { TocItem, TocScrollBehavior } from '../types'; + +export type { TocHeadingTag, TocItem } from '../types'; + +export interface TocRenderContext { + activeKey: null | string; + collapsed: boolean; + items: TocItem[]; + jumpTo: (key: string) => void; + pinned: boolean; + setPinned: (pinned: boolean) => void; +} + +export interface ReactTocPluginProps { + /** + * Scroll behavior used when clicking a toc item. + * @default 'smooth' + */ + behavior?: TocScrollBehavior; + className?: string; + defaultPinned?: boolean; + editor?: IEditor; + emptyText?: ReactNode; + getScrollContainer?: (editor: IEditor) => HTMLElement | Window | null; + locale?: Partial>; + maxDepth?: number; + minDepth?: number; + /** + * Top offset used for fixed TOC placement and heading jump alignment. + * @default 8 + */ + offsetTop?: number; + onItemsChange?: (items: TocItem[]) => void; + onPinnedChange?: (pinned: boolean) => void; + render?: (context: TocRenderContext) => ReactNode; + /** + * Extra spacing between editor content and the pinned TOC. + * @default 24 + */ + reserveGap?: number; + /** + * Whether to reserve editor right-side space while the TOC is pinned. + * @default true + */ + reserveOnPinned?: boolean; + showHeader?: boolean; + style?: CSSProperties; + title?: ReactNode; +} diff --git a/src/plugins/toc/service/i-toc-service.ts b/src/plugins/toc/service/i-toc-service.ts new file mode 100644 index 00000000..d7bb7b3b --- /dev/null +++ b/src/plugins/toc/service/i-toc-service.ts @@ -0,0 +1,192 @@ +/* eslint-disable no-redeclare */ +/* eslint-disable @typescript-eslint/no-redeclare */ +import { $isHeadingNode } from '@lexical/rich-text'; +import EventEmitter from 'eventemitter3'; +import { + $getRoot, + $isElementNode, + type ElementNode, + type LexicalEditor, + type LexicalNode, +} from 'lexical'; + +import { genServiceId } from '@/editor-kernel'; +import type { IServiceID } from '@/types'; + +import type { TocHeadingTag, TocItem, TocJumpOptions, TocPluginOptions } from '../types'; +import { buildTocTree, flattenTocTree } from '../utils'; + +export interface ITocService { + getActiveKey(): null | string; + getFlatItems(): TocItem[]; + getItems(): TocItem[]; + jumpTo(key: string, options?: TocJumpOptions): boolean; + refresh(): TocItem[]; + setActiveKey(key: null | string): void; + setDepthRange(options: TocPluginOptions): void; + subscribe(listener: () => void): () => void; +} + +export type { TocHeadingTag, TocItem, TocJumpOptions, TocPluginOptions } from '../types'; + +export const ITocService: IServiceID = genServiceId('TocService'); + +type TocServiceEvents = { + change: () => void; +}; + +function walkHeadings( + node: LexicalNode, + headings: Omit[], + minDepth: number, + maxDepth: number, +) { + if ($isHeadingNode(node)) { + const tag = node.getTag() as TocHeadingTag; + const depth = Number(tag.slice(1)); + const title = node.getTextContent().trim(); + + if (title && depth >= minDepth && depth <= maxDepth) { + headings.push({ + depth, + key: node.getKey(), + tag, + title, + }); + } + } + + if ($isElementNode(node)) { + for (const child of (node as ElementNode).getChildren()) { + walkHeadings(child, headings, minDepth, maxDepth); + } + } +} + +function isSameTocItems(a: TocItem[], b: TocItem[]): boolean { + const flatA = flattenTocTree(a); + const flatB = flattenTocTree(b); + + if (flatA.length !== flatB.length) return false; + + return flatA.every((item, index) => { + const next = flatB[index]; + return ( + item.key === next.key && + item.depth === next.depth && + item.tag === next.tag && + item.title === next.title + ); + }); +} + +export class TocService extends EventEmitter implements ITocService { + private activeKey: null | string = null; + private editor: LexicalEditor | null = null; + private items: TocItem[] = []; + private maxDepth = 6; + private minDepth = 1; + + bindEditor(editor: LexicalEditor): void { + this.editor = editor; + } + + getActiveKey(): null | string { + return this.activeKey; + } + + getFlatItems(): TocItem[] { + return flattenTocTree(this.items); + } + + getItems(): TocItem[] { + return this.items; + } + + jumpTo(key: string, options?: TocJumpOptions): boolean { + const element = this.editor?.getElementByKey(key); + if (!element) return false; + + const behavior = options?.behavior ?? 'smooth'; + const offsetTop = options?.offsetTop ?? 0; + const scrollContainer = options?.container; + + if (typeof window === 'undefined') { + element.scrollIntoView({ + behavior, + block: options?.block ?? 'start', + }); + this.setActiveKey(key); + + return true; + } + + if (!scrollContainer || scrollContainer instanceof Window) { + const top = element.getBoundingClientRect().top + window.scrollY - offsetTop; + window.scrollTo({ behavior, top }); + } else { + const containerTop = scrollContainer.getBoundingClientRect().top; + const top = + scrollContainer.scrollTop + element.getBoundingClientRect().top - containerTop - offsetTop; + scrollContainer.scrollTo({ behavior, top }); + } + + this.setActiveKey(key); + + return true; + } + + refresh(): TocItem[] { + if (!this.editor) { + this.updateItems([]); + return []; + } + + const items = this.editor.getEditorState().read(() => { + const headings: Omit[] = []; + walkHeadings($getRoot(), headings, this.minDepth, this.maxDepth); + return buildTocTree(headings); + }); + + this.updateItems(items); + + return items; + } + + setActiveKey(key: null | string): void { + if (this.activeKey === key) return; + + this.activeKey = key; + this.emit('change'); + } + + setDepthRange(options: TocPluginOptions): void { + const minDepth = options.minDepth ?? 1; + const maxDepth = options.maxDepth ?? 6; + + if (this.minDepth === minDepth && this.maxDepth === maxDepth) return; + + this.minDepth = minDepth; + this.maxDepth = maxDepth; + this.refresh(); + } + + subscribe(listener: () => void): () => void { + this.on('change', listener); + + return () => { + this.off('change', listener); + }; + } + + private updateItems(items: TocItem[]): void { + if (isSameTocItems(this.items, items)) return; + + this.items = items; + const flatItems = this.getFlatItems(); + if (this.activeKey && !flatItems.some((item) => item.key === this.activeKey)) { + this.activeKey = flatItems[0]?.key ?? null; + } + this.emit('change'); + } +} diff --git a/src/plugins/toc/service/index.ts b/src/plugins/toc/service/index.ts new file mode 100644 index 00000000..db0e3ecc --- /dev/null +++ b/src/plugins/toc/service/index.ts @@ -0,0 +1 @@ +export * from './i-toc-service'; diff --git a/src/plugins/toc/types.ts b/src/plugins/toc/types.ts new file mode 100644 index 00000000..b1a41feb --- /dev/null +++ b/src/plugins/toc/types.ts @@ -0,0 +1,25 @@ +export type TocHeadingTag = 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6'; + +export interface TocItem { + children: TocItem[]; + depth: number; + key: string; + tag: TocHeadingTag; + title: string; +} + +export interface TocPluginOptions { + maxDepth?: number; + minDepth?: number; +} + +export type TocScrollBehavior = 'auto' | 'instant' | 'smooth'; + +export type TocScrollLogicalPosition = 'center' | 'end' | 'nearest' | 'start'; + +export interface TocJumpOptions { + behavior?: TocScrollBehavior; + block?: TocScrollLogicalPosition; + container?: HTMLElement | Window | null; + offsetTop?: number; +} diff --git a/src/plugins/toc/utils/index.ts b/src/plugins/toc/utils/index.ts new file mode 100644 index 00000000..c902be92 --- /dev/null +++ b/src/plugins/toc/utils/index.ts @@ -0,0 +1,39 @@ +import type { TocItem } from '../types'; + +export function buildTocTree(items: Omit[]): TocItem[] { + const roots: TocItem[] = []; + const stack: TocItem[] = []; + + for (const item of items) { + const current: TocItem = { ...item, children: [] }; + + while (stack.length > 0 && stack.at(-1)!.depth >= current.depth) { + stack.pop(); + } + + const parent = stack.at(-1); + if (parent) { + parent.children.push(current); + } else { + roots.push(current); + } + + stack.push(current); + } + + return roots; +} + +export function flattenTocTree(items: TocItem[]): TocItem[] { + const result: TocItem[] = []; + const walk = (tocItems: TocItem[]) => { + for (const item of tocItems) { + result.push(item); + walk(item.children); + } + }; + + walk(items); + + return result; +} diff --git a/src/react/Editor/demos/data.json b/src/react/Editor/demos/data.json index a7899b80..1397dba6 100644 --- a/src/react/Editor/demos/data.json +++ b/src/react/Editor/demos/data.json @@ -38,6 +38,201 @@ "type": "quote", "version": 1 }, + { + "children": [ + { + "detail": 0, + "format": 0, + "mode": "normal", + "style": "", + "text": "Core Workflow", + "type": "text", + "version": 1 + } + ], + "direction": "ltr", + "format": "", + "indent": 0, + "type": "heading", + "version": 1, + "tag": "h2" + }, + { + "children": [ + { + "detail": 0, + "format": 0, + "mode": "normal", + "style": "", + "text": "Use this section to verify that the table of contents reads headings from editor state and keeps the rendered outline in sync.", + "type": "text", + "version": 1 + } + ], + "direction": "ltr", + "format": "", + "indent": 0, + "type": "paragraph", + "version": 1, + "textFormat": 0, + "textStyle": "" + }, + { + "children": [ + { + "detail": 0, + "format": 0, + "mode": "normal", + "style": "", + "text": "Collect headings on change", + "type": "text", + "version": 1 + } + ], + "direction": "ltr", + "format": "", + "indent": 0, + "type": "heading", + "version": 1, + "tag": "h3" + }, + { + "children": [ + { + "detail": 0, + "format": 0, + "mode": "normal", + "style": "", + "text": "Every editor update triggers a lightweight scan of heading nodes so title edits, inserted headings, and removed headings are reflected in the TOC.", + "type": "text", + "version": 1 + } + ], + "direction": "ltr", + "format": "", + "indent": 0, + "type": "paragraph", + "version": 1, + "textFormat": 0, + "textStyle": "" + }, + { + "children": [ + { + "detail": 0, + "format": 0, + "mode": "normal", + "style": "", + "text": "Nested tree conversion", + "type": "text", + "version": 1 + } + ], + "direction": "ltr", + "format": "", + "indent": 0, + "type": "heading", + "version": 1, + "tag": "h4" + }, + { + "children": [ + { + "detail": 0, + "format": 0, + "mode": "normal", + "style": "", + "text": "Skipped heading levels are attached to the nearest shallower heading, which keeps the outline predictable for real documents.", + "type": "text", + "version": 1 + } + ], + "direction": "ltr", + "format": "", + "indent": 0, + "type": "paragraph", + "version": 1, + "textFormat": 0, + "textStyle": "" + }, + { + "children": [ + { + "detail": 0, + "format": 0, + "mode": "normal", + "style": "", + "text": "Navigation Behavior", + "type": "text", + "version": 1 + } + ], + "direction": "ltr", + "format": "", + "indent": 0, + "type": "heading", + "version": 1, + "tag": "h2" + }, + { + "children": [ + { + "detail": 0, + "format": 0, + "mode": "normal", + "style": "", + "text": "Clicking an outline item scrolls the page to the matching heading. Scrolling the document updates the active item from the current viewport position.", + "type": "text", + "version": 1 + } + ], + "direction": "ltr", + "format": "", + "indent": 0, + "type": "paragraph", + "version": 1, + "textFormat": 0, + "textStyle": "" + }, + { + "children": [ + { + "detail": 0, + "format": 0, + "mode": "normal", + "style": "", + "text": "Active heading tracking", + "type": "text", + "version": 1 + } + ], + "direction": "ltr", + "format": "", + "indent": 0, + "type": "heading", + "version": 1, + "tag": "h3" + }, + { + "children": [ + { + "detail": 0, + "format": 0, + "mode": "normal", + "style": "", + "text": "The active marker follows the heading closest to the top of the scroll container.", + "type": "text", + "version": 1 + } + ], + "direction": "ltr", + "format": "", + "indent": 0, + "type": "paragraph", + "version": 1, + "textFormat": 0, + "textStyle": "" + }, { "children": [ { diff --git a/src/react/Editor/demos/index.tsx b/src/react/Editor/demos/index.tsx index b2dfd82b..13f71379 100644 --- a/src/react/Editor/demos/index.tsx +++ b/src/react/Editor/demos/index.tsx @@ -21,6 +21,7 @@ import { ReactLiteXmlPlugin, ReactMathPlugin, ReactTablePlugin, + ReactTocPlugin, ReactToolbarPlugin, ReactVirtualBlockPlugin, type SlashOptions, @@ -288,6 +289,10 @@ const Demo: FC> = (props ReactTablePlugin, ReactMathPlugin, ReactCodePlugin, + Editor.withProps(ReactTocPlugin, { + offsetTop: 88, + reserveGap: 24, + }), Editor.withProps(ReactToolbarPlugin, { children: , }),