import type { AnyExtension } from '@tiptap/core';
import { ReactNodeViewRenderer } from '@tiptap/react';
import StarterKit from '@tiptap/starter-kit';
import Placeholder from '@tiptap/extension-placeholder';
import Highlight from '@tiptap/extension-highlight';
import TaskList from '@tiptap/extension-task-list';
import TaskItem from '@tiptap/extension-task-item';
import { Table } from '@tiptap/extension-table';
import { TableRow } from '@tiptap/extension-table-row';
import { TableHeader } from '@tiptap/extension-table-header';
import { TableCell } from '@tiptap/extension-table-cell';
import CodeBlockLowlight from '@tiptap/extension-code-block-lowlight';
import { Markdown } from '@tiptap/markdown';
import { common, createLowlight } from 'lowlight';
import GlobalDragHandle from 'tiptap-extension-global-drag-handle';
import { SlashExtension } from './SlashExtension';
import { createSlashSuggestion } from './createSlashSuggestion';
import { TaskItemView } from './TaskItemView';
import { CustomKeymap } from './CustomKeymap';
import { BookmarkNode } from './BookmarkNode';
import { MapNode } from './MapNode';
import type { ResolveLinkPreview } from '../../../common/link-preview';
// Lowlight bundle: "common" languages cover ts/js/json/py/go/rust/etc.
// The full language pack pulls in ~300KB more — opt-in case by case.
const lowlight = createLowlight(common);
export interface BuildExtensionsOptions {
placeholder: string;
/**
* Host resolver for the `bookmark` (link-preview) node. Given a URL it
* returns unfurl metadata. Threaded into `BookmarkNode.options` and read by
* its NodeView. Default `undefined` → the card renders its own
* hostname/favicon fallback (same seam as chat's `config.linkPreview`).
*/
resolveLinkPreview?: ResolveLinkPreview;
}
/**
* Assemble the Notion-flavour TipTap extension stack.
*
* Why a single factory rather than inline `useEditor({ extensions: [...] })`:
* - `useEditor` captures extensions once on first render. If we built
* them inline, every parent re-render would create new instances and
* React's deps array would force a teardown loop (or, with stable
* deps, we'd be hiding the problem).
* - All consumers want the same baseline. Splitting per-extension wires
* to a builder keeps the assembly readable.
*
* StarterKit overrides:
* - `codeBlock: false` — replaced by `CodeBlockLowlight` for syntax
* highlighting via lowlight's `common` language pack.
*
* Markdown serialisation:
* - `@tiptap/markdown` already handles starter-kit nodes + tables.
* - Task lists serialise as GFM `- [x]` / `- [ ]` via the same package.
* - `Highlight` round-trips as `==text==` (tiptap-markdown default).
*/
export function notionExtensions(opts: BuildExtensionsOptions): AnyExtension[] {
return [
StarterKit.configure({
heading: { levels: [1, 2, 3, 4] },
// Disable starter-kit's plain CodeBlock — we replace it with the
// lowlight variant below for syntax highlighting.
codeBlock: false,
}),
Placeholder.configure({
// Per-node placeholder: empty H1 → "Heading 1", H2 → "Heading 2",
// empty paragraph → the supplied placeholder ("Type / for…").
// Matches Notion: an empty heading is labelled with its level so the
// user sees structure before content; an empty body block shows the
// command hint. The function form is supported by tiptap-extensions
// Placeholder; the `node` arg is the block at the caret.
placeholder: ({ node }) => {
if (node.type.name === 'heading') {
const level = node.attrs.level as number | undefined;
return level ? `Heading ${level}` : 'Heading';
}
return opts.placeholder;
},
// includeChildren=true so placeholders also reach inside list items
// and quotes (otherwise an empty bullet line shows no hint).
includeChildren: true,
}),
Markdown,
Highlight.configure({ multicolor: false }),
TaskList,
// React NodeView swaps Tiptap's default ``
// for our ui-core — picks up macOS / token styling and
// tracks the editor theme automatically. See TaskItemView.tsx.
TaskItem.extend({
addNodeView() {
return ReactNodeViewRenderer(TaskItemView);
},
}).configure({ nested: true }),
Table.configure({ resizable: false }),
TableRow,
TableHeader,
TableCell,
CodeBlockLowlight.configure({ lowlight }),
// Drag handle for blocks. Renders a small grabber to the left of the
// hovered block; clicking + dragging reorders the document. Reads CSS
// from our styles.css (`.drag-handle`).
GlobalDragHandle.configure({
dragHandleWidth: 18,
// `scrollTreshold` (sic) is the option name the upstream library
// ships — typo and all. Spelling it correctly is silently ignored.
scrollTreshold: 100,
}),
// Bookmark (link-preview) block — paste a bare URL on an empty line and
// it becomes a card. The host resolver is threaded through options.
BookmarkNode.configure({ resolveLinkPreview: opts.resolveLinkPreview }),
// Map block — inserted via the `/map` slash command (see slashItems).
MapNode,
SlashExtension.configure({
suggestion: createSlashSuggestion(),
}),
CustomKeymap,
];
}