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
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
8 changes: 8 additions & 0 deletions src/locale/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
},
};
93 changes: 93 additions & 0 deletions src/plugins/toc/__tests__/service.test.ts
Original file line number Diff line number Diff line change
@@ -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',
]);
});
});
98 changes: 98 additions & 0 deletions src/plugins/toc/__tests__/utils.test.ts
Original file line number Diff line number Diff line change
@@ -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']);
});
});
5 changes: 5 additions & 0 deletions src/plugins/toc/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export * from './plugin';
export * from './react';
export * from './service';
export * from './types';
export * from './utils';
89 changes: 89 additions & 0 deletions src/plugins/toc/plugin/index.ts
Original file line number Diff line number Diff line change
@@ -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<string, boolean>,
dirtyLeaves: Set<string>,
) {
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<TocPluginOptions> = class
extends KernelPlugin
implements IEditorPlugin<TocPluginOptions>
{
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();
},
),
);
}
};
26 changes: 26 additions & 0 deletions src/plugins/toc/react/ReactTocPlugin.tsx
Original file line number Diff line number Diff line change
@@ -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<Omit<ReactTocPluginProps, 'editor'>> = (props) => {
const [editor] = useLexicalComposerContext();

return <TocView {...props} editor={editor} />;
};

const ReactTocPlugin: FC<ReactTocPluginProps> = ({ editor, ...props }) => {
if (editor) {
return <TocView {...props} editor={editor} />;
}

return <TocViewWithContext {...props} />;
};

ReactTocPlugin.displayName = 'ReactTocPlugin';

export default ReactTocPlugin;
Loading
Loading