snapshot of https://github.com/ueberdosis/tiptap.git (main, 2026-09-15; blobs > 131072B omitted)
Some checks failed
Verify / Install node dependencies (push) Failing after 2s
Verify / Build packages (push) Has been skipped
Verify / Check linting & formatting (push) Has been skipped
Verify / Run unit tests (push) Has been skipped
Verify / Run e2e tests (shard 1/4) (push) Has been skipped
Verify / Run e2e tests (shard 2/4) (push) Has been skipped
Verify / Run e2e tests (shard 3/4) (push) Has been skipped
Verify / Run e2e tests (shard 4/4) (push) Has been skipped
Publish / Resolve publish config (push) Failing after 2s
Publish / Send notification to Slack (push) Has been skipped
Publish / Send failure notification to Slack (push) Failing after 8s
Verify / Merge e2e reports (push) Has been skipped
Publish / Build (push) Has been skipped
Publish / Bump Versions OR Release to Registry (push) Has been skipped
Some checks failed
Verify / Install node dependencies (push) Failing after 2s
Verify / Build packages (push) Has been skipped
Verify / Check linting & formatting (push) Has been skipped
Verify / Run unit tests (push) Has been skipped
Verify / Run e2e tests (shard 1/4) (push) Has been skipped
Verify / Run e2e tests (shard 2/4) (push) Has been skipped
Verify / Run e2e tests (shard 3/4) (push) Has been skipped
Verify / Run e2e tests (shard 4/4) (push) Has been skipped
Publish / Resolve publish config (push) Failing after 2s
Publish / Send notification to Slack (push) Has been skipped
Publish / Send failure notification to Slack (push) Failing after 8s
Verify / Merge e2e reports (push) Has been skipped
Publish / Build (push) Has been skipped
Publish / Bump Versions OR Release to Registry (push) Has been skipped
This commit is contained in:
1055
packages/extension-table-of-contents/CHANGELOG.md
Normal file
1055
packages/extension-table-of-contents/CHANGELOG.md
Normal file
File diff suppressed because it is too large
Load Diff
18
packages/extension-table-of-contents/README.md
Normal file
18
packages/extension-table-of-contents/README.md
Normal file
@@ -0,0 +1,18 @@
|
||||
# @tiptap/extension-table-of-contents
|
||||
|
||||
[](https://www.npmjs.com/package/@tiptap/extension-table-of-contents)
|
||||
[](https://npmcharts.com/compare/tiptap?minimal=true)
|
||||
[](https://www.npmjs.com/package/@tiptap/extension-table-of-contents)
|
||||
[](https://github.com/sponsors/ueberdosis)
|
||||
|
||||
## Introduction
|
||||
|
||||
Tiptap is a headless wrapper around [ProseMirror](https://ProseMirror.net) – a toolkit for building rich text WYSIWYG editors, which is already in use at many well-known companies such as _New York Times_, _The Guardian_ or _Atlassian_.
|
||||
|
||||
## Official Documentation
|
||||
|
||||
Documentation can be found on the [Tiptap website](https://tiptap.dev).
|
||||
|
||||
## License
|
||||
|
||||
Tiptap is open sourced software licensed under the [MIT license](https://github.com/ueberdosis/tiptap/blob/main/LICENSE.md).
|
||||
55
packages/extension-table-of-contents/package.json
Normal file
55
packages/extension-table-of-contents/package.json
Normal file
@@ -0,0 +1,55 @@
|
||||
{
|
||||
"name": "@tiptap/extension-table-of-contents",
|
||||
"version": "3.30.3",
|
||||
"description": "table of contents extension for Tiptap",
|
||||
"keywords": [
|
||||
"tiptap",
|
||||
"tiptap extension"
|
||||
],
|
||||
"homepage": "https://tiptap.dev/docs/editor/extensions/functionality/table-of-contents",
|
||||
"bugs": {
|
||||
"url": "https://github.com/ueberdosis/tiptap/issues"
|
||||
},
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/ueberdosis/tiptap",
|
||||
"directory": "packages/extension-table-of-contents"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"files": [
|
||||
"src",
|
||||
"dist"
|
||||
],
|
||||
"type": "module",
|
||||
"main": "dist/index.cjs",
|
||||
"module": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": {
|
||||
"import": "./dist/index.d.ts",
|
||||
"require": "./dist/index.d.cts"
|
||||
},
|
||||
"import": "./dist/index.js",
|
||||
"require": "./dist/index.cjs"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "vp pack"
|
||||
},
|
||||
"dependencies": {
|
||||
"uuid": "^14.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tiptap/core": "workspace:^",
|
||||
"@tiptap/pm": "workspace:^"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "workspace:*",
|
||||
"@tiptap/pm": "workspace:*"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
import type { JSONContent } from '@tiptap/core'
|
||||
import Document from '@tiptap/extension-document'
|
||||
import Heading from '@tiptap/extension-heading'
|
||||
import Paragraph from '@tiptap/extension-paragraph'
|
||||
import Text from '@tiptap/extension-text'
|
||||
import { describe, expect, it } from 'vite-plus/test'
|
||||
|
||||
import { generateTocIds } from './generate-toc-ids.js'
|
||||
import { TableOfContents } from './tableOfContents.js'
|
||||
|
||||
const baseExtensions = [Document, Paragraph, Text, Heading]
|
||||
|
||||
const docWithHeadings = (...texts: string[]): JSONContent => ({
|
||||
type: 'doc',
|
||||
content: texts.map(text => ({
|
||||
type: 'heading',
|
||||
attrs: { level: 1 },
|
||||
content: [{ type: 'text', text }],
|
||||
})),
|
||||
})
|
||||
|
||||
describe('generateTocIds', () => {
|
||||
it('throws when TableOfContents extension is missing', () => {
|
||||
expect(() => generateTocIds(docWithHeadings('a'), baseExtensions)).toThrow(
|
||||
'TableOfContents extension (name: "tableOfContents") not found in the extensions array',
|
||||
)
|
||||
})
|
||||
|
||||
it('accepts a custom extension name for users who extend TableOfContents', () => {
|
||||
const CustomToc = TableOfContents.extend({ name: 'myCustomToc' })
|
||||
const result = generateTocIds(
|
||||
docWithHeadings('Intro'),
|
||||
[...baseExtensions, CustomToc],
|
||||
'myCustomToc',
|
||||
)
|
||||
const heading = result.content?.[0]
|
||||
|
||||
expect(heading?.attrs?.id).toEqual(expect.any(String))
|
||||
expect(heading?.attrs?.['data-toc-id']).toEqual(expect.any(String))
|
||||
})
|
||||
|
||||
it('reports the requested name in the not-found error when a custom name is missing', () => {
|
||||
expect(() => generateTocIds(docWithHeadings('a'), baseExtensions, 'myCustomToc')).toThrow(
|
||||
'TableOfContents extension (name: "myCustomToc") not found in the extensions array',
|
||||
)
|
||||
})
|
||||
|
||||
it('assigns id and data-toc-id to anchor nodes that have none', () => {
|
||||
const result = generateTocIds(docWithHeadings('Intro'), [...baseExtensions, TableOfContents])
|
||||
const heading = result.content?.[0]
|
||||
|
||||
expect(heading?.attrs?.id).toEqual(expect.any(String))
|
||||
expect(heading?.attrs?.['data-toc-id']).toEqual(expect.any(String))
|
||||
expect(heading?.attrs?.id).toBe(heading?.attrs?.['data-toc-id'])
|
||||
})
|
||||
|
||||
it('skips anchor nodes with empty text content', () => {
|
||||
const doc: JSONContent = {
|
||||
type: 'doc',
|
||||
content: [{ type: 'heading', attrs: { level: 1 } }],
|
||||
}
|
||||
const result = generateTocIds(doc, [...baseExtensions, TableOfContents])
|
||||
const heading = result.content?.[0]
|
||||
|
||||
expect(heading?.attrs?.id).toBeFalsy()
|
||||
expect(heading?.attrs?.['data-toc-id']).toBeFalsy()
|
||||
})
|
||||
|
||||
it('preserves unique existing data-toc-id values', () => {
|
||||
const doc: JSONContent = {
|
||||
type: 'doc',
|
||||
content: [
|
||||
{
|
||||
type: 'heading',
|
||||
attrs: { level: 1, id: 'existing', 'data-toc-id': 'existing' },
|
||||
content: [{ type: 'text', text: 'Intro' }],
|
||||
},
|
||||
],
|
||||
}
|
||||
const result = generateTocIds(doc, [...baseExtensions, TableOfContents])
|
||||
|
||||
expect(result.content?.[0].attrs?.['data-toc-id']).toBe('existing')
|
||||
expect(result.content?.[0].attrs?.id).toBe('existing')
|
||||
})
|
||||
|
||||
it('regenerates ids for duplicate data-toc-id values', () => {
|
||||
const doc: JSONContent = {
|
||||
type: 'doc',
|
||||
content: [
|
||||
{
|
||||
type: 'heading',
|
||||
attrs: { level: 1, id: 'dup', 'data-toc-id': 'dup' },
|
||||
content: [{ type: 'text', text: 'First' }],
|
||||
},
|
||||
{
|
||||
type: 'heading',
|
||||
attrs: { level: 1, id: 'dup', 'data-toc-id': 'dup' },
|
||||
content: [{ type: 'text', text: 'Second' }],
|
||||
},
|
||||
],
|
||||
}
|
||||
const result = generateTocIds(doc, [...baseExtensions, TableOfContents])
|
||||
const [first, second] = result.content ?? []
|
||||
|
||||
expect(first.attrs?.['data-toc-id']).toBe('dup')
|
||||
expect(second.attrs?.['data-toc-id']).not.toBe('dup')
|
||||
expect(second.attrs?.['data-toc-id']).toEqual(expect.any(String))
|
||||
})
|
||||
|
||||
it('does not modify non-anchor nodes', () => {
|
||||
const doc: JSONContent = {
|
||||
type: 'doc',
|
||||
content: [
|
||||
{ type: 'paragraph', content: [{ type: 'text', text: 'hello' }] },
|
||||
{
|
||||
type: 'heading',
|
||||
attrs: { level: 1 },
|
||||
content: [{ type: 'text', text: 'Intro' }],
|
||||
},
|
||||
],
|
||||
}
|
||||
const result = generateTocIds(doc, [...baseExtensions, TableOfContents])
|
||||
|
||||
expect(result.content?.[0].attrs?.id).toBeUndefined()
|
||||
expect(result.content?.[1].attrs?.id).toEqual(expect.any(String))
|
||||
})
|
||||
|
||||
it('respects a custom getId option', () => {
|
||||
const result = generateTocIds(docWithHeadings('Hello World'), [
|
||||
...baseExtensions,
|
||||
TableOfContents.configure({
|
||||
getId: textContent => textContent.toLowerCase().replace(/\s+/g, '-'),
|
||||
}),
|
||||
])
|
||||
|
||||
expect(result.content?.[0].attrs?.id).toBe('hello-world')
|
||||
expect(result.content?.[0].attrs?.['data-toc-id']).toBe('hello-world')
|
||||
})
|
||||
|
||||
it('returns an empty content array unchanged', () => {
|
||||
const doc: JSONContent = { type: 'doc', content: [] }
|
||||
const result = generateTocIds(doc, [...baseExtensions, TableOfContents])
|
||||
|
||||
expect(result).toEqual({ type: 'doc' })
|
||||
})
|
||||
})
|
||||
89
packages/extension-table-of-contents/src/generate-toc-ids.ts
Normal file
89
packages/extension-table-of-contents/src/generate-toc-ids.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import type { Extensions, JSONContent } from '@tiptap/core'
|
||||
import { getSchema } from '@tiptap/core'
|
||||
import { Node as PMNode } from '@tiptap/pm/model'
|
||||
import { EditorState } from '@tiptap/pm/state'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
|
||||
import type { TableOfContents } from './tableOfContents.js'
|
||||
|
||||
/**
|
||||
* Creates a new document with `id` and `data-toc-id` attributes added to
|
||||
* anchor-type nodes (headings by default). Does the same job as the
|
||||
* `TableOfContents` ProseMirror plugin, but without needing an `Editor`
|
||||
* instance — so it can run on the server before `renderToHTMLString`.
|
||||
*
|
||||
* The configuration from the `TableOfContents` extension (in particular
|
||||
* `anchorTypes` and `getId`) is picked up automatically. Nodes that already
|
||||
* have a unique `data-toc-id` are left untouched; duplicates and missing IDs
|
||||
* are re-generated to match the plugin's behavior.
|
||||
*
|
||||
* @throws {Error} If the `TableOfContents` extension is not in the array.
|
||||
*
|
||||
* @example
|
||||
* const doc = {
|
||||
* type: 'doc',
|
||||
* content: [
|
||||
* { type: 'heading', attrs: { level: 1 }, content: [{ type: 'text', text: 'Intro' }] },
|
||||
* ],
|
||||
* }
|
||||
* const docWithIds = generateTocIds(doc, [StarterKit, TableOfContents])
|
||||
* // → heading now has `id` and `data-toc-id` attributes
|
||||
* const html = renderToHTMLString({ content: docWithIds, extensions: [...] })
|
||||
*
|
||||
* @param doc - A Tiptap JSON document to add anchor IDs to.
|
||||
* @param extensions - The extensions to use. Must include the `TableOfContents` extension (or one extending it).
|
||||
* @param extensionName - Optional name of the `TableOfContents` extension to look up. Defaults to `'tableOfContents'`. Override this if you have extended the base extension and changed its `name`.
|
||||
* @returns The updated Tiptap JSON document.
|
||||
*/
|
||||
export function generateTocIds(
|
||||
doc: JSONContent,
|
||||
extensions: Extensions,
|
||||
extensionName = 'tableOfContents',
|
||||
): JSONContent {
|
||||
const tocExtension = extensions.find(ext => ext.name === extensionName) as
|
||||
| typeof TableOfContents
|
||||
| undefined
|
||||
|
||||
if (!tocExtension) {
|
||||
throw new Error(
|
||||
`TableOfContents extension (name: "${extensionName}") not found in the extensions array`,
|
||||
)
|
||||
}
|
||||
|
||||
const schema = getSchema([...extensions.filter(ext => ext.name !== extensionName), tocExtension])
|
||||
const anchorTypes = tocExtension.options.anchorTypes ?? ['heading']
|
||||
const getId = tocExtension.options.getId ?? (() => uuidv4())
|
||||
|
||||
const contentNode = PMNode.fromJSON(schema, doc)
|
||||
|
||||
// Mirrors the TableOfContents plugin's appendTransaction in `plugin.ts`:
|
||||
// an anchor node gets a fresh id when its `data-toc-id` is missing or
|
||||
// already seen earlier in the document. We track original ids — including
|
||||
// null — to match the plugin's behavior exactly.
|
||||
type Update = { pos: number; attrs: Record<string, unknown> }
|
||||
const updates: Update[] = []
|
||||
const existingIds: Array<string | null | undefined> = []
|
||||
|
||||
contentNode.descendants((node, pos) => {
|
||||
if (!anchorTypes.includes(node.type.name) || node.textContent.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const currentId = node.attrs['data-toc-id'] as string | null | undefined
|
||||
|
||||
if (currentId == null || existingIds.includes(currentId)) {
|
||||
const id = getId(node.textContent)
|
||||
updates.push({ pos, attrs: { ...node.attrs, id, 'data-toc-id': id } })
|
||||
}
|
||||
|
||||
existingIds.push(currentId)
|
||||
})
|
||||
|
||||
const tr = EditorState.create({ doc: contentNode }).tr
|
||||
|
||||
updates.forEach(({ pos, attrs }) => {
|
||||
tr.setNodeMarkup(pos, undefined, attrs)
|
||||
})
|
||||
|
||||
return tr.doc.toJSON()
|
||||
}
|
||||
8
packages/extension-table-of-contents/src/index.ts
Normal file
8
packages/extension-table-of-contents/src/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { TableOfContents } from './tableOfContents.js'
|
||||
|
||||
export * from './generate-toc-ids.js'
|
||||
export * from './plugin.js'
|
||||
export * from './tableOfContents.js'
|
||||
export * from './utils.js'
|
||||
|
||||
export default TableOfContents
|
||||
65
packages/extension-table-of-contents/src/plugin.ts
Normal file
65
packages/extension-table-of-contents/src/plugin.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import type { NodeType } from '@tiptap/pm/model'
|
||||
import { Plugin, PluginKey } from '@tiptap/pm/state'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
|
||||
export const TableOfContentsPlugin = ({
|
||||
getId,
|
||||
anchorTypes = ['heading'],
|
||||
}: {
|
||||
getId?: (textContent: string) => string
|
||||
anchorTypes?: Array<string | NodeType>
|
||||
}) => {
|
||||
return new Plugin({
|
||||
key: new PluginKey('tableOfContent'),
|
||||
|
||||
appendTransaction(transactions, _oldState, newState) {
|
||||
// Avoid running on the server where `window` / DOM is not available.
|
||||
if (typeof window === 'undefined') {
|
||||
return null
|
||||
}
|
||||
|
||||
// Skip updating table of contents during IME composition
|
||||
// to avoid interfering with input method editor
|
||||
if (transactions.some(tr => tr.getMeta('composition'))) {
|
||||
return
|
||||
}
|
||||
|
||||
const tr = newState.tr
|
||||
let modified = false
|
||||
|
||||
if (transactions.some(transaction => transaction.docChanged)) {
|
||||
const existingIds: string[] = []
|
||||
|
||||
newState.doc.descendants((node, pos) => {
|
||||
const nodeId = node.attrs['data-toc-id']
|
||||
|
||||
if (!anchorTypes.includes(node.type.name) || node.textContent.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
if (nodeId === null || nodeId === undefined || existingIds.includes(nodeId)) {
|
||||
let id = ''
|
||||
|
||||
if (getId) {
|
||||
id = getId(node.textContent)
|
||||
} else {
|
||||
id = uuidv4()
|
||||
}
|
||||
|
||||
tr.setNodeMarkup(pos, undefined, {
|
||||
...node.attrs,
|
||||
'data-toc-id': id,
|
||||
id,
|
||||
})
|
||||
|
||||
modified = true
|
||||
}
|
||||
|
||||
existingIds.push(nodeId)
|
||||
})
|
||||
}
|
||||
|
||||
return modified ? tr : null
|
||||
},
|
||||
})
|
||||
}
|
||||
373
packages/extension-table-of-contents/src/tableOfContents.ts
Normal file
373
packages/extension-table-of-contents/src/tableOfContents.ts
Normal file
@@ -0,0 +1,373 @@
|
||||
import type { Editor } from '@tiptap/core'
|
||||
import { Extension } from '@tiptap/core'
|
||||
import type { Node as TiptapNode } from '@tiptap/pm/model'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
|
||||
import { TableOfContentsPlugin } from './plugin.js'
|
||||
import type {
|
||||
GetTableOfContentIndexFunction,
|
||||
GetTableOfContentLevelFunction,
|
||||
TableOfContentData,
|
||||
TableOfContentDataItem,
|
||||
TableOfContentsOptions,
|
||||
TableOfContentsStorage,
|
||||
} from './types.js'
|
||||
import { getHeadlineLevel, getLinearIndexes } from './utils.js'
|
||||
|
||||
export * from './types.js'
|
||||
|
||||
const addTocActiveStatesAndGetItems = (
|
||||
content: TableOfContentDataItem[],
|
||||
options: {
|
||||
editor: Editor
|
||||
anchorTypes: Array<string> | undefined
|
||||
storage: TableOfContentsStorage
|
||||
onUpdate?: (data: TableOfContentData, isCreate?: boolean) => void | undefined
|
||||
},
|
||||
) => {
|
||||
const { editor } = options
|
||||
const headlines: Array<{ node: TiptapNode; pos: number }> = []
|
||||
const scrolledOverIds: string[] = []
|
||||
let activeId: string | null = null
|
||||
|
||||
if (editor.isDestroyed) {
|
||||
return content
|
||||
}
|
||||
|
||||
editor.state.doc.descendants((node, pos) => {
|
||||
const isValidType = options.anchorTypes?.includes(node.type.name)
|
||||
|
||||
if (!isValidType) {
|
||||
return
|
||||
}
|
||||
|
||||
headlines.push({ node, pos })
|
||||
})
|
||||
|
||||
headlines.forEach(headline => {
|
||||
const domElement = editor.view.domAtPos(headline.pos + 1).node as
|
||||
| HTMLHeadingElement
|
||||
| HTMLElement
|
||||
const scrolledOver = options.storage.scrollPosition >= domElement.offsetTop
|
||||
|
||||
if (scrolledOver) {
|
||||
activeId = headline.node.attrs['data-toc-id']
|
||||
scrolledOverIds.push(headline.node.attrs['data-toc-id'])
|
||||
}
|
||||
})
|
||||
|
||||
content = content.map(heading => ({
|
||||
...heading,
|
||||
isActive: heading.id === activeId,
|
||||
isScrolledOver: scrolledOverIds.includes(heading.id),
|
||||
}))
|
||||
|
||||
if (options.onUpdate) {
|
||||
const isInitialCreation = options.storage.content.length === 0
|
||||
|
||||
options.onUpdate(content, isInitialCreation)
|
||||
}
|
||||
|
||||
return content
|
||||
}
|
||||
|
||||
const setTocData = (options: {
|
||||
editor: Editor
|
||||
anchorTypes: Array<string> | undefined
|
||||
getIndexFn: GetTableOfContentIndexFunction
|
||||
getLevelFn: GetTableOfContentLevelFunction
|
||||
storage: TableOfContentsStorage
|
||||
onUpdate?: (data: TableOfContentData, isCreate?: boolean) => void | undefined
|
||||
}) => {
|
||||
const { editor } = options
|
||||
|
||||
if (editor.isDestroyed) {
|
||||
return
|
||||
}
|
||||
|
||||
const headlines: Array<{ node: TiptapNode; pos: number }> = []
|
||||
|
||||
let anchors: TableOfContentDataItem[] = []
|
||||
const anchorEls: Array<HTMLHeadingElement | HTMLElement> = []
|
||||
|
||||
editor.state.doc.descendants((node, pos) => {
|
||||
const isValidType = options.anchorTypes?.includes(node.type.name)
|
||||
|
||||
if (!isValidType) {
|
||||
return
|
||||
}
|
||||
|
||||
headlines.push({ node, pos })
|
||||
})
|
||||
|
||||
headlines.forEach((headline, i) => {
|
||||
if (headline.node.textContent.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const domElement = editor.view.domAtPos(headline.pos + 1).node as HTMLHeadingElement
|
||||
const scrolledOver = options.storage.scrollPosition >= domElement.offsetTop
|
||||
|
||||
anchorEls.push(domElement)
|
||||
|
||||
const originalLevel = headline.node.attrs.level
|
||||
|
||||
const prevHeadline = headlines[i - 1]
|
||||
|
||||
const level = options.getLevelFn(headline, anchors)
|
||||
const itemIndex = options.getIndexFn(headline, anchors, level)
|
||||
|
||||
if (!prevHeadline) {
|
||||
anchors = [
|
||||
...anchors,
|
||||
{
|
||||
itemIndex,
|
||||
id: headline.node.attrs['data-toc-id'],
|
||||
originalLevel,
|
||||
level,
|
||||
textContent: headline.node.textContent,
|
||||
pos: headline.pos,
|
||||
editor,
|
||||
isActive: false,
|
||||
isScrolledOver: scrolledOver,
|
||||
node: headline.node,
|
||||
dom: domElement,
|
||||
},
|
||||
]
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
anchors = [
|
||||
...anchors,
|
||||
{
|
||||
itemIndex,
|
||||
id: headline.node.attrs['data-toc-id'],
|
||||
originalLevel,
|
||||
level,
|
||||
textContent: headline.node.textContent,
|
||||
pos: headline.pos,
|
||||
editor,
|
||||
isActive: false,
|
||||
isScrolledOver: false,
|
||||
node: headline.node,
|
||||
dom: domElement,
|
||||
},
|
||||
]
|
||||
})
|
||||
|
||||
anchors = addTocActiveStatesAndGetItems(anchors, options)
|
||||
|
||||
options.storage.anchors = anchorEls
|
||||
options.storage.content = anchors
|
||||
editor.state.tr.setMeta('toc', anchors)
|
||||
editor.view.dispatch(editor.state.tr)
|
||||
}
|
||||
|
||||
export const TableOfContents = Extension.create<TableOfContentsOptions, TableOfContentsStorage>({
|
||||
name: 'tableOfContents',
|
||||
|
||||
addStorage() {
|
||||
return {
|
||||
content: [],
|
||||
anchors: [],
|
||||
scrollHandler: () => null,
|
||||
scrollPosition: 0,
|
||||
}
|
||||
},
|
||||
|
||||
addGlobalAttributes() {
|
||||
return [
|
||||
{
|
||||
types: (this.options.anchorTypes as string[]) || ['headline'],
|
||||
attributes: {
|
||||
id: {
|
||||
default: null,
|
||||
renderHTML: attributes => {
|
||||
return {
|
||||
id: attributes.id,
|
||||
}
|
||||
},
|
||||
parseHTML: element => {
|
||||
return element.id || null
|
||||
},
|
||||
},
|
||||
'data-toc-id': {
|
||||
default: null,
|
||||
renderHTML: attributes => {
|
||||
return {
|
||||
'data-toc-id': attributes['data-toc-id'],
|
||||
}
|
||||
},
|
||||
parseHTML: element => {
|
||||
return element.dataset.tocId || null
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
},
|
||||
|
||||
addOptions() {
|
||||
const defaultScrollParent = typeof window !== 'undefined' ? () => window : undefined
|
||||
|
||||
return {
|
||||
// oxlint-disable-next-line
|
||||
onUpdate: () => {},
|
||||
// oxlint-disable-next-line
|
||||
getId: _textContent => uuidv4(),
|
||||
|
||||
scrollParent: defaultScrollParent,
|
||||
|
||||
anchorTypes: ['heading'],
|
||||
}
|
||||
},
|
||||
|
||||
addCommands() {
|
||||
return {
|
||||
updateTableOfContents:
|
||||
() =>
|
||||
({ dispatch }) => {
|
||||
if (dispatch) {
|
||||
setTocData({
|
||||
editor: this.editor,
|
||||
storage: this.storage,
|
||||
onUpdate: this.options.onUpdate?.bind(this),
|
||||
getIndexFn: this.options.getIndex || getLinearIndexes,
|
||||
getLevelFn: this.options.getLevel || getHeadlineLevel,
|
||||
anchorTypes: this.options.anchorTypes,
|
||||
})
|
||||
}
|
||||
|
||||
return true
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
onTransaction({ transaction }) {
|
||||
if (transaction.docChanged && !transaction.getMeta('toc')) {
|
||||
setTocData({
|
||||
editor: this.editor,
|
||||
storage: this.storage,
|
||||
onUpdate: this.options.onUpdate?.bind(this),
|
||||
getIndexFn: this.options.getIndex || getLinearIndexes,
|
||||
getLevelFn: this.options.getLevel || getHeadlineLevel,
|
||||
anchorTypes: this.options.anchorTypes,
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
onCreate() {
|
||||
// Avoid mutating the document during server-side rendering.
|
||||
if (typeof window === 'undefined' || !this.editor.view) {
|
||||
return
|
||||
}
|
||||
|
||||
const { tr } = this.editor.state
|
||||
const existingIds: string[] = []
|
||||
|
||||
if (this.options.scrollParent && typeof this.options.scrollParent !== 'function') {
|
||||
console.warn(
|
||||
"[Tiptap Table of Contents Deprecation Notice]: The 'scrollParent' option must now be provided as a callback function that returns the 'scrollParent' element. The ability to pass this option directly will be deprecated in future releases.",
|
||||
)
|
||||
}
|
||||
|
||||
this.editor.state.doc.descendants((node, pos) => {
|
||||
const nodeId = node.attrs['data-toc-id']
|
||||
const isValidType = this.options.anchorTypes?.includes(node.type.name)
|
||||
|
||||
if (!isValidType || node.textContent.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
if (nodeId === null || nodeId === undefined || existingIds.includes(nodeId)) {
|
||||
let id = ''
|
||||
|
||||
if (this.options.getId) {
|
||||
id = this.options.getId(node.textContent)
|
||||
} else {
|
||||
id = uuidv4()
|
||||
}
|
||||
|
||||
tr.setNodeMarkup(pos, undefined, {
|
||||
...node.attrs,
|
||||
'data-toc-id': id,
|
||||
id,
|
||||
})
|
||||
}
|
||||
|
||||
existingIds.push(nodeId)
|
||||
})
|
||||
|
||||
this.editor.view.dispatch(tr)
|
||||
|
||||
setTocData({
|
||||
editor: this.editor,
|
||||
storage: this.storage,
|
||||
onUpdate: this.options.onUpdate?.bind(this),
|
||||
getIndexFn: this.options.getIndex || getLinearIndexes,
|
||||
getLevelFn: this.options.getLevel || getHeadlineLevel,
|
||||
anchorTypes: this.options.anchorTypes,
|
||||
})
|
||||
|
||||
this.storage.scrollHandler = () => {
|
||||
if (!this.options.scrollParent) {
|
||||
return
|
||||
}
|
||||
|
||||
// if scrollParent is HTML Element, use scrollTop - otherwise use scrollY
|
||||
const scrollParent =
|
||||
typeof this.options.scrollParent === 'function'
|
||||
? this.options.scrollParent()
|
||||
: this.options.scrollParent
|
||||
|
||||
const scrollPosition =
|
||||
scrollParent instanceof HTMLElement ? scrollParent.scrollTop : scrollParent.scrollY
|
||||
|
||||
this.storage.scrollPosition = scrollPosition || 0
|
||||
|
||||
const updatedItems = addTocActiveStatesAndGetItems(this.storage.content, {
|
||||
editor: this.editor,
|
||||
anchorTypes: this.options.anchorTypes,
|
||||
storage: this.storage,
|
||||
onUpdate: this.options.onUpdate?.bind(this),
|
||||
})
|
||||
|
||||
this.storage.content = updatedItems
|
||||
}
|
||||
|
||||
if (!this.options.scrollParent) {
|
||||
return
|
||||
}
|
||||
|
||||
const scrollParent =
|
||||
typeof this.options.scrollParent === 'function'
|
||||
? this.options.scrollParent()
|
||||
: this.options.scrollParent
|
||||
|
||||
if (scrollParent) {
|
||||
scrollParent.addEventListener('scroll', this.storage.scrollHandler)
|
||||
}
|
||||
},
|
||||
|
||||
onDestroy() {
|
||||
if (!this.options.scrollParent) {
|
||||
return
|
||||
}
|
||||
|
||||
const scrollParent =
|
||||
typeof this.options.scrollParent === 'function'
|
||||
? this.options.scrollParent()
|
||||
: this.options.scrollParent
|
||||
|
||||
if (scrollParent) {
|
||||
scrollParent.removeEventListener('scroll', this.storage.scrollHandler)
|
||||
}
|
||||
},
|
||||
|
||||
addProseMirrorPlugins() {
|
||||
return [
|
||||
TableOfContentsPlugin({ getId: this.options.getId, anchorTypes: this.options.anchorTypes }),
|
||||
]
|
||||
},
|
||||
})
|
||||
57
packages/extension-table-of-contents/src/types.ts
Normal file
57
packages/extension-table-of-contents/src/types.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import type { Editor } from '@tiptap/core'
|
||||
import type { Node as PMNode } from '@tiptap/pm/model'
|
||||
|
||||
export type GetTableOfContentLevelFunction = (
|
||||
headline: { node: PMNode; pos: number },
|
||||
previousItems: TableOfContentDataItem[],
|
||||
) => number
|
||||
|
||||
export type GetTableOfContentIndexFunction = (
|
||||
headline: { node: PMNode; pos: number },
|
||||
previousItems: TableOfContentDataItem[],
|
||||
currentLevel?: number,
|
||||
) => number
|
||||
|
||||
export type TableOfContentsOptions = {
|
||||
getId?: (textContent: string) => string
|
||||
onUpdate?: (data: TableOfContentData, isCreate?: boolean) => void
|
||||
getLevel?: GetTableOfContentLevelFunction
|
||||
getIndex?: GetTableOfContentIndexFunction
|
||||
scrollParent?: (() => HTMLElement | Window) | HTMLElement | Window
|
||||
anchorTypes?: Array<string>
|
||||
}
|
||||
|
||||
export type TableOfContentsStorage = {
|
||||
content: TableOfContentData
|
||||
anchors: Array<HTMLHeadingElement | HTMLElement>
|
||||
scrollHandler: () => void
|
||||
scrollPosition: number
|
||||
}
|
||||
|
||||
export type TableOfContentData = Array<TableOfContentDataItem>
|
||||
|
||||
export type TableOfContentDataItem = {
|
||||
dom: HTMLHeadingElement
|
||||
editor: Editor
|
||||
id: string
|
||||
isActive: boolean
|
||||
isScrolledOver: boolean
|
||||
itemIndex: number
|
||||
level: number
|
||||
node: PMNode
|
||||
originalLevel: number
|
||||
pos: number
|
||||
textContent: string
|
||||
}
|
||||
|
||||
declare module '@tiptap/core' {
|
||||
interface Commands<ReturnType> {
|
||||
tableOfContents: {
|
||||
updateTableOfContents: () => ReturnType
|
||||
}
|
||||
}
|
||||
|
||||
interface Storage {
|
||||
tableOfContents: TableOfContentsStorage
|
||||
}
|
||||
}
|
||||
85
packages/extension-table-of-contents/src/utils.ts
Normal file
85
packages/extension-table-of-contents/src/utils.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import type {
|
||||
GetTableOfContentIndexFunction,
|
||||
GetTableOfContentLevelFunction,
|
||||
TableOfContentDataItem,
|
||||
} from './types.js'
|
||||
|
||||
export const getLastHeadingOnLevel = (
|
||||
headings: TableOfContentDataItem[],
|
||||
level: number,
|
||||
): TableOfContentDataItem | undefined => {
|
||||
let heading = headings.filter(currentHeading => currentHeading.level === level).pop()
|
||||
|
||||
if (level === 0) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
if (!heading) {
|
||||
heading = getLastHeadingOnLevel(headings, level - 1)
|
||||
}
|
||||
|
||||
return heading
|
||||
}
|
||||
|
||||
export const getHeadlineLevel: GetTableOfContentLevelFunction = (headline, previousItems) => {
|
||||
let level = 1
|
||||
|
||||
const previousHeadline = previousItems.at(-1)
|
||||
const highestHeadlineAbove = [...previousItems]
|
||||
.reverse()
|
||||
.find(h => h.originalLevel <= headline.node.attrs.level)
|
||||
|
||||
const highestLevelAbove = highestHeadlineAbove?.level || 1
|
||||
|
||||
if (headline.node.attrs.level > (previousHeadline?.originalLevel || 1)) {
|
||||
level = (previousHeadline?.level || 1) + 1
|
||||
} else if (headline.node.attrs.level < (previousHeadline?.originalLevel || 1)) {
|
||||
level = highestLevelAbove
|
||||
} else {
|
||||
level = previousHeadline?.level || 1
|
||||
}
|
||||
|
||||
return level
|
||||
}
|
||||
|
||||
export const getLinearIndexes: GetTableOfContentIndexFunction = (_headline, previousHeadlines) => {
|
||||
const previousHeadline = previousHeadlines.at(-1)
|
||||
|
||||
if (!previousHeadline) {
|
||||
return 1
|
||||
}
|
||||
|
||||
return (previousHeadline?.itemIndex || 1) + 1
|
||||
}
|
||||
|
||||
export const getHierarchicalIndexes: GetTableOfContentIndexFunction = (
|
||||
headline,
|
||||
previousHeadlines,
|
||||
currentLevel,
|
||||
) => {
|
||||
const level = currentLevel || headline.node.attrs.level || 1
|
||||
let itemIndex = 1
|
||||
|
||||
const previousHeadlinesOnLevelOrBelow = previousHeadlines.filter(h => h.level <= level)
|
||||
|
||||
if (previousHeadlinesOnLevelOrBelow.at(-1)?.level === level) {
|
||||
itemIndex = (previousHeadlinesOnLevelOrBelow.at(-1)?.itemIndex || 1) + 1
|
||||
} else {
|
||||
itemIndex = 1
|
||||
}
|
||||
|
||||
return itemIndex
|
||||
}
|
||||
|
||||
export function debounce(func: (...args: any[]) => void, wait: number) {
|
||||
let timeout: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
return (...args: any[]) => {
|
||||
if (timeout) {
|
||||
clearTimeout(timeout)
|
||||
}
|
||||
timeout = setTimeout(() => {
|
||||
func(...args)
|
||||
}, wait)
|
||||
}
|
||||
}
|
||||
9
packages/extension-table-of-contents/vite.config.ts
Normal file
9
packages/extension-table-of-contents/vite.config.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { defineConfig } from 'vite-plus'
|
||||
import { basePackConfig } from '../../pack.config.mjs'
|
||||
|
||||
export default defineConfig({
|
||||
pack: {
|
||||
entry: ['src/index.ts'],
|
||||
...basePackConfig(),
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user