@djangocfg/widget-editor 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +40 -0
- package/package.json +121 -0
- package/src/blocks/BlockError.tsx +46 -0
- package/src/blocks/index.ts +34 -0
- package/src/blocks/schemas.ts +143 -0
- package/src/blocks/types.ts +76 -0
- package/src/chips/README.md +67 -0
- package/src/chips/UrlChip.tsx +105 -0
- package/src/chips/index.ts +20 -0
- package/src/index.ts +12 -0
- package/src/link-preview/LinkPreviewCard.tsx +178 -0
- package/src/link-preview/index.ts +12 -0
- package/src/link-preview/types.ts +44 -0
- package/src/markdown/MarkdownEditor.tsx +700 -0
- package/src/markdown/MentionList.tsx +106 -0
- package/src/markdown/README.md +269 -0
- package/src/markdown/chip/ChipNode.ts +526 -0
- package/src/markdown/chip/syncChips.ts +80 -0
- package/src/markdown/createMentionSuggestion.ts +131 -0
- package/src/markdown/filePath/FilePathChip.tsx +68 -0
- package/src/markdown/filePath/detect.ts +289 -0
- package/src/markdown/filePath/index.ts +15 -0
- package/src/markdown/index.ts +29 -0
- package/src/markdown/lazy.tsx +88 -0
- package/src/markdown/markdown-editor.stories.tsx +500 -0
- package/src/markdown/mentionPresets.ts +49 -0
- package/src/markdown/slash/SlashCommandNode.ts +162 -0
- package/src/markdown/slash/index.ts +4 -0
- package/src/markdown/slash/syncSlashNode.ts +97 -0
- package/src/markdown/slash/types.ts +14 -0
- package/src/markdown/styles.css +522 -0
- package/src/markdown/submitOnEnter.ts +83 -0
- package/src/markdown/types.ts +50 -0
- package/src/markdown/url/detect.ts +227 -0
- package/src/notion/BookmarkNode.ts +161 -0
- package/src/notion/BookmarkView.tsx +45 -0
- package/src/notion/CustomKeymap.ts +48 -0
- package/src/notion/LinkDialog.tsx +133 -0
- package/src/notion/MapEditLayer.tsx +199 -0
- package/src/notion/MapNode.ts +220 -0
- package/src/notion/MapView.tsx +103 -0
- package/src/notion/NotionEditor.tsx +309 -0
- package/src/notion/README.md +158 -0
- package/src/notion/SlashExtension.ts +32 -0
- package/src/notion/SlashList.tsx +136 -0
- package/src/notion/TaskItemView.tsx +41 -0
- package/src/notion/createSlashSuggestion.ts +121 -0
- package/src/notion/extensions.ts +120 -0
- package/src/notion/index.ts +1 -0
- package/src/notion/lazy.tsx +44 -0
- package/src/notion/notion-editor.stories.tsx +293 -0
- package/src/notion/slashItems.ts +178 -0
- package/src/notion/styles.css +503 -0
- package/src/notion/types.ts +37 -0
- package/src/tiptap/index.ts +10 -0
- package/src/tiptap/useNodeAttrs.ts +128 -0
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* NotionEditor — Notion-style WYSIWYG built on TipTap v3.
|
|
5
|
+
*
|
|
6
|
+
* Composition (vs `MarkdownEditor`):
|
|
7
|
+
* - StarterKit (with H1-H4) + Placeholder + Markdown serialiser
|
|
8
|
+
* - TaskList + TaskItem (GFM `- [ ]`)
|
|
9
|
+
* - Table (+ Row/Header/Cell)
|
|
10
|
+
* - CodeBlockLowlight (syntax highlight via lowlight's `common` pack)
|
|
11
|
+
* - Highlight (`==text==`)
|
|
12
|
+
* - GlobalDragHandle (Notion-style block grabbers)
|
|
13
|
+
* - SlashExtension (own `/` menu — see SlashList.tsx)
|
|
14
|
+
* - BubbleMenu (floating selection toolbar)
|
|
15
|
+
*
|
|
16
|
+
* Why a separate component instead of a variant on MarkdownEditor:
|
|
17
|
+
* - Different baseline (no mentions, no slash-as-chip extension that
|
|
18
|
+
* the chat composer depends on).
|
|
19
|
+
* - ~30KB more deps (tables, lowlight, drag-handle) — would punish
|
|
20
|
+
* every chat composer mount if added to the shared editor.
|
|
21
|
+
* - Lazy chunk boundary stays clean — Skills / chat composer keep
|
|
22
|
+
* their slim TipTap; document-preview pulls the heavy stack.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { useEditor, EditorContent, type Editor } from '@tiptap/react';
|
|
26
|
+
import { BubbleMenu } from '@tiptap/react/menus';
|
|
27
|
+
import { forwardRef, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react';
|
|
28
|
+
import {
|
|
29
|
+
Bold,
|
|
30
|
+
Italic,
|
|
31
|
+
Strikethrough,
|
|
32
|
+
Code as CodeIcon,
|
|
33
|
+
Highlighter,
|
|
34
|
+
Link as LinkIcon,
|
|
35
|
+
Underline as UnderlineIcon,
|
|
36
|
+
type LucideIcon,
|
|
37
|
+
} from 'lucide-react';
|
|
38
|
+
import { Kbd, Tooltip, TooltipContent, TooltipTrigger } from '@djangocfg/ui-core';
|
|
39
|
+
import { useHotkey } from '@djangocfg/ui-core/hooks';
|
|
40
|
+
import { notionExtensions } from './extensions';
|
|
41
|
+
import { LinkDialog } from './LinkDialog';
|
|
42
|
+
import type { NotionEditorHandle, NotionEditorProps } from './types';
|
|
43
|
+
import './styles.css';
|
|
44
|
+
|
|
45
|
+
// Same markdown helper as MarkdownEditor — TipTap v3 augments Editor with
|
|
46
|
+
// `getMarkdown()` when @tiptap/markdown is registered.
|
|
47
|
+
interface MarkdownManager {
|
|
48
|
+
serialize: (json: Record<string, unknown>) => string;
|
|
49
|
+
}
|
|
50
|
+
function getMarkdown(editor: Editor): string {
|
|
51
|
+
const withMd = editor as Editor & { getMarkdown?: () => string };
|
|
52
|
+
if (typeof withMd.getMarkdown === 'function') return withMd.getMarkdown();
|
|
53
|
+
const storage = editor.storage.markdown as { manager?: MarkdownManager } | undefined;
|
|
54
|
+
if (!storage?.manager) return editor.getText();
|
|
55
|
+
return storage.manager.serialize(editor.getJSON());
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export const NotionEditor = forwardRef<NotionEditorHandle, NotionEditorProps>(
|
|
59
|
+
function NotionEditor(
|
|
60
|
+
{
|
|
61
|
+
value,
|
|
62
|
+
onChange,
|
|
63
|
+
placeholder = "Type '/' for commands…",
|
|
64
|
+
disabled = false,
|
|
65
|
+
autoFocus = false,
|
|
66
|
+
onSave,
|
|
67
|
+
className = '',
|
|
68
|
+
minHeight = 320,
|
|
69
|
+
resolveLinkPreview,
|
|
70
|
+
},
|
|
71
|
+
ref,
|
|
72
|
+
) {
|
|
73
|
+
const isExternalUpdate = useRef(false);
|
|
74
|
+
const [linkDialogOpen, setLinkDialogOpen] = useState(false);
|
|
75
|
+
|
|
76
|
+
// Build extensions once. Placeholder is captured by closure on first
|
|
77
|
+
// render — same constraint as MarkdownEditor; mentions / slash items
|
|
78
|
+
// don't change at runtime here. The link-preview resolver is likewise
|
|
79
|
+
// captured on first build; pass a stable reference (it's a host method).
|
|
80
|
+
const extensions = useMemo(
|
|
81
|
+
() => notionExtensions({ placeholder, resolveLinkPreview }),
|
|
82
|
+
[placeholder, resolveLinkPreview],
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
const editor = useEditor({
|
|
86
|
+
immediatelyRender: false,
|
|
87
|
+
editable: !disabled,
|
|
88
|
+
extensions,
|
|
89
|
+
content: value,
|
|
90
|
+
contentType: 'markdown',
|
|
91
|
+
onUpdate: ({ editor }) => {
|
|
92
|
+
if (isExternalUpdate.current) return;
|
|
93
|
+
onChange(getMarkdown(editor));
|
|
94
|
+
},
|
|
95
|
+
editorProps: {
|
|
96
|
+
attributes: {
|
|
97
|
+
class: 'notion-editor-content focus:outline-none',
|
|
98
|
+
style: `min-height: ${minHeight}px`,
|
|
99
|
+
},
|
|
100
|
+
},
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
// Sync external value → editor without looping back into onChange.
|
|
104
|
+
useEffect(() => {
|
|
105
|
+
if (!editor) return;
|
|
106
|
+
const current = getMarkdown(editor);
|
|
107
|
+
if (current === value) return;
|
|
108
|
+
isExternalUpdate.current = true;
|
|
109
|
+
editor.commands.setContent(value, {
|
|
110
|
+
contentType: 'markdown',
|
|
111
|
+
emitUpdate: false,
|
|
112
|
+
});
|
|
113
|
+
isExternalUpdate.current = false;
|
|
114
|
+
}, [value, editor]);
|
|
115
|
+
|
|
116
|
+
useEffect(() => {
|
|
117
|
+
editor?.setEditable(!disabled);
|
|
118
|
+
}, [editor, disabled]);
|
|
119
|
+
|
|
120
|
+
// Declarative autoFocus.
|
|
121
|
+
useEffect(() => {
|
|
122
|
+
if (!autoFocus || !editor) return;
|
|
123
|
+
editor.commands.focus('end');
|
|
124
|
+
}, [autoFocus, editor]);
|
|
125
|
+
|
|
126
|
+
// Cmd/Ctrl+S → save, scoped to the editor DOM via guard.
|
|
127
|
+
const onSaveRef = useRef(onSave);
|
|
128
|
+
onSaveRef.current = onSave;
|
|
129
|
+
useHotkey(
|
|
130
|
+
'mod+s',
|
|
131
|
+
() => {
|
|
132
|
+
const h = onSaveRef.current;
|
|
133
|
+
if (!h || !editor) return;
|
|
134
|
+
const dom = editor.view.dom;
|
|
135
|
+
const active = document.activeElement;
|
|
136
|
+
if (!active || !dom.contains(active)) return;
|
|
137
|
+
h(getMarkdown(editor));
|
|
138
|
+
},
|
|
139
|
+
{ enabled: !!editor && !!onSave },
|
|
140
|
+
);
|
|
141
|
+
|
|
142
|
+
// Cmd/Ctrl+K → link prompt. Same focus-scope guard so we don't
|
|
143
|
+
// collide with global command palettes higher up the tree.
|
|
144
|
+
useHotkey(
|
|
145
|
+
'mod+k',
|
|
146
|
+
() => {
|
|
147
|
+
if (!editor) return;
|
|
148
|
+
const dom = editor.view.dom;
|
|
149
|
+
const active = document.activeElement;
|
|
150
|
+
if (!active || !dom.contains(active)) return;
|
|
151
|
+
setLinkDialogOpen(true);
|
|
152
|
+
},
|
|
153
|
+
{ enabled: !!editor },
|
|
154
|
+
);
|
|
155
|
+
|
|
156
|
+
useImperativeHandle(
|
|
157
|
+
ref,
|
|
158
|
+
(): NotionEditorHandle => ({
|
|
159
|
+
focus: () => {
|
|
160
|
+
editor?.commands.focus();
|
|
161
|
+
},
|
|
162
|
+
moveCursorToEnd: () => {
|
|
163
|
+
editor?.commands.focus('end');
|
|
164
|
+
},
|
|
165
|
+
getEditor: () => editor ?? null,
|
|
166
|
+
}),
|
|
167
|
+
[editor],
|
|
168
|
+
);
|
|
169
|
+
|
|
170
|
+
return (
|
|
171
|
+
<div className={`notion-editor ${disabled ? 'opacity-60' : ''} ${className}`.trim()}>
|
|
172
|
+
{editor ? (
|
|
173
|
+
<>
|
|
174
|
+
<BubbleSelectionToolbar
|
|
175
|
+
editor={editor}
|
|
176
|
+
onOpenLink={() => setLinkDialogOpen(true)}
|
|
177
|
+
/>
|
|
178
|
+
<LinkDialog
|
|
179
|
+
editor={editor}
|
|
180
|
+
open={linkDialogOpen}
|
|
181
|
+
onOpenChange={setLinkDialogOpen}
|
|
182
|
+
/>
|
|
183
|
+
</>
|
|
184
|
+
) : null}
|
|
185
|
+
<EditorContent editor={editor} />
|
|
186
|
+
</div>
|
|
187
|
+
);
|
|
188
|
+
},
|
|
189
|
+
);
|
|
190
|
+
|
|
191
|
+
interface BubbleItem {
|
|
192
|
+
icon: LucideIcon;
|
|
193
|
+
title: string;
|
|
194
|
+
/** Keyboard shortcut to display in the tooltip. macOS-style (⌘ B). */
|
|
195
|
+
shortcut: readonly string[];
|
|
196
|
+
isActive: () => boolean;
|
|
197
|
+
run: () => void;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Floating selection toolbar. `@tiptap/extension-bubble-menu` anchors
|
|
202
|
+
* itself to the current selection; it auto-hides when the selection
|
|
203
|
+
* collapses, so we only need to declare the buttons. Tooltips show the
|
|
204
|
+
* keyboard chord — Tiptap installs the shortcuts itself (StarterKit).
|
|
205
|
+
*/
|
|
206
|
+
function BubbleSelectionToolbar({
|
|
207
|
+
editor,
|
|
208
|
+
onOpenLink,
|
|
209
|
+
}: {
|
|
210
|
+
editor: Editor;
|
|
211
|
+
onOpenLink: () => void;
|
|
212
|
+
}) {
|
|
213
|
+
const items: BubbleItem[] = [
|
|
214
|
+
{
|
|
215
|
+
icon: Bold,
|
|
216
|
+
title: 'Bold',
|
|
217
|
+
shortcut: ['⌘', 'B'],
|
|
218
|
+
isActive: () => editor.isActive('bold'),
|
|
219
|
+
run: () => editor.chain().focus().toggleBold().run(),
|
|
220
|
+
},
|
|
221
|
+
{
|
|
222
|
+
icon: Italic,
|
|
223
|
+
title: 'Italic',
|
|
224
|
+
shortcut: ['⌘', 'I'],
|
|
225
|
+
isActive: () => editor.isActive('italic'),
|
|
226
|
+
run: () => editor.chain().focus().toggleItalic().run(),
|
|
227
|
+
},
|
|
228
|
+
{
|
|
229
|
+
icon: UnderlineIcon,
|
|
230
|
+
title: 'Underline',
|
|
231
|
+
shortcut: ['⌘', 'U'],
|
|
232
|
+
isActive: () => editor.isActive('underline'),
|
|
233
|
+
run: () => editor.chain().focus().toggleUnderline().run(),
|
|
234
|
+
},
|
|
235
|
+
{
|
|
236
|
+
icon: Strikethrough,
|
|
237
|
+
title: 'Strike',
|
|
238
|
+
shortcut: ['⌘', '⇧', 'X'],
|
|
239
|
+
isActive: () => editor.isActive('strike'),
|
|
240
|
+
run: () => editor.chain().focus().toggleStrike().run(),
|
|
241
|
+
},
|
|
242
|
+
{
|
|
243
|
+
icon: CodeIcon,
|
|
244
|
+
title: 'Inline code',
|
|
245
|
+
shortcut: ['⌘', 'E'],
|
|
246
|
+
isActive: () => editor.isActive('code'),
|
|
247
|
+
run: () => editor.chain().focus().toggleCode().run(),
|
|
248
|
+
},
|
|
249
|
+
{
|
|
250
|
+
icon: Highlighter,
|
|
251
|
+
title: 'Highlight',
|
|
252
|
+
shortcut: ['⌘', '⇧', 'H'],
|
|
253
|
+
isActive: () => editor.isActive('highlight'),
|
|
254
|
+
run: () => editor.chain().focus().toggleHighlight().run(),
|
|
255
|
+
},
|
|
256
|
+
{
|
|
257
|
+
icon: LinkIcon,
|
|
258
|
+
title: 'Link',
|
|
259
|
+
shortcut: ['⌘', 'K'],
|
|
260
|
+
isActive: () => editor.isActive('link'),
|
|
261
|
+
run: onOpenLink,
|
|
262
|
+
},
|
|
263
|
+
];
|
|
264
|
+
|
|
265
|
+
return (
|
|
266
|
+
<BubbleMenu
|
|
267
|
+
editor={editor}
|
|
268
|
+
className="notion-bubble-menu"
|
|
269
|
+
// Hide inside code blocks (Bold/Italic don't apply there) and when
|
|
270
|
+
// the selection is empty/whitespace-only. Default behaviour shows
|
|
271
|
+
// the menu on any non-empty range, which is too eager.
|
|
272
|
+
shouldShow={({ editor, from, to }) => {
|
|
273
|
+
if (from === to) return false;
|
|
274
|
+
if (editor.isActive('codeBlock')) return false;
|
|
275
|
+
const text = editor.state.doc.textBetween(from, to, ' ').trim();
|
|
276
|
+
return text.length > 0;
|
|
277
|
+
}}
|
|
278
|
+
>
|
|
279
|
+
{items.map((item) => {
|
|
280
|
+
const Icon = item.icon;
|
|
281
|
+
const active = item.isActive();
|
|
282
|
+
return (
|
|
283
|
+
<Tooltip key={item.title}>
|
|
284
|
+
<TooltipTrigger asChild>
|
|
285
|
+
<button
|
|
286
|
+
type="button"
|
|
287
|
+
aria-label={item.title}
|
|
288
|
+
aria-pressed={active}
|
|
289
|
+
onMouseDown={(e) => e.preventDefault()}
|
|
290
|
+
onClick={item.run}
|
|
291
|
+
className={`notion-bubble-btn${active ? ' notion-bubble-btn--active' : ''}`}
|
|
292
|
+
>
|
|
293
|
+
<Icon style={{ width: 14, height: 14 }} aria-hidden />
|
|
294
|
+
</button>
|
|
295
|
+
</TooltipTrigger>
|
|
296
|
+
<TooltipContent side="top" sideOffset={6} className="flex items-center gap-1.5">
|
|
297
|
+
<span>{item.title}</span>
|
|
298
|
+
<span className="flex items-center gap-0.5">
|
|
299
|
+
{item.shortcut.map((k) => (
|
|
300
|
+
<Kbd key={k}>{k}</Kbd>
|
|
301
|
+
))}
|
|
302
|
+
</span>
|
|
303
|
+
</TooltipContent>
|
|
304
|
+
</Tooltip>
|
|
305
|
+
);
|
|
306
|
+
})}
|
|
307
|
+
</BubbleMenu>
|
|
308
|
+
);
|
|
309
|
+
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
# NotionEditor
|
|
2
|
+
|
|
3
|
+
Notion-style WYSIWYG markdown editor built on **TipTap v3**.
|
|
4
|
+
|
|
5
|
+
Lazy-loaded sibling of [`MarkdownEditor`](../MarkdownEditor/) — same
|
|
6
|
+
markdown round-trip, but with a heavier extension stack, the floating
|
|
7
|
+
menus (slash / bubble / drag handle) users expect from a "document"
|
|
8
|
+
editor, **and serializable block nodes** (a link-preview *bookmark*
|
|
9
|
+
card and an interactive *map* block) that round-trip through markdown.
|
|
10
|
+
|
|
11
|
+
```tsx
|
|
12
|
+
import { NotionEditor } from '@djangocfg/ui-tools/notion-editor';
|
|
13
|
+
|
|
14
|
+
<NotionEditor
|
|
15
|
+
value={markdown}
|
|
16
|
+
onChange={setMarkdown}
|
|
17
|
+
onSave={(md) => writeFile(md)}
|
|
18
|
+
autoFocus
|
|
19
|
+
placeholder="Type '/' for commands…"
|
|
20
|
+
resolveLinkPreview={unfurl} // optional — powers bookmark cards
|
|
21
|
+
/>
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
> **Bundle.** The full stack (lowlight common pack + tables + drag
|
|
25
|
+
> handle, plus the lazy MapLibre chunk when a map block mounts) lands
|
|
26
|
+
> around ~350 KB minified. `@djangocfg/ui-tools/notion-editor` is the
|
|
27
|
+
> **lazy** entry (`lazy.tsx`) — pages that never render the editor pay
|
|
28
|
+
> nothing. All of it is `'use client'`.
|
|
29
|
+
|
|
30
|
+
## When to use this vs `MarkdownEditor`
|
|
31
|
+
|
|
32
|
+
| Use case | Pick |
|
|
33
|
+
| --------------------------------------- | ---------------- |
|
|
34
|
+
| Chat composer, single-paragraph input | `MarkdownEditor` |
|
|
35
|
+
| Note / doc surface, multi-paragraph | `NotionEditor` |
|
|
36
|
+
| `.md` file viewer with full editing | `NotionEditor` |
|
|
37
|
+
| Slim mention dropdown, no tables/tasks | `MarkdownEditor` |
|
|
38
|
+
| Slash menu, drag handle, tables, hljs | `NotionEditor` |
|
|
39
|
+
| Bookmark cards / embedded map blocks | `NotionEditor` |
|
|
40
|
+
|
|
41
|
+
The two coexist intentionally — `NotionEditor` is heavier; don't mount
|
|
42
|
+
it inside a chat composer just to get features the user will never use.
|
|
43
|
+
|
|
44
|
+
## Props
|
|
45
|
+
|
|
46
|
+
`NotionEditorProps` (see `types.ts`):
|
|
47
|
+
|
|
48
|
+
| Prop | Type | Default | Notes |
|
|
49
|
+
| -------------------- | -------------------------- | -------------------- | ----- |
|
|
50
|
+
| `value` | `string` | — | Markdown source (required). |
|
|
51
|
+
| `onChange` | `(md: string) => void` | — | Fired on every keystroke with freshly-serialised markdown (required). |
|
|
52
|
+
| `placeholder` | `string` | `"Type '/' for commands…"` | Ghost text on an empty paragraph. |
|
|
53
|
+
| `disabled` | `boolean` | `false` | Read-only renderer (also makes map blocks static). |
|
|
54
|
+
| `autoFocus` | `boolean` | `false` | Focus on mount. Pair with `key={path}` upstream for fresh focus per file. |
|
|
55
|
+
| `onSave` | `(md: string) => void` | — | Bound to Cmd/Ctrl+S, scoped to the editor DOM. |
|
|
56
|
+
| `className` | `string` | `''` | Extra class on the outer wrapper. |
|
|
57
|
+
| `minHeight` | `number` | `320` | Min height of the editor surface (px). |
|
|
58
|
+
| `resolveLinkPreview` | `ResolveLinkPreview` | `undefined` | **Host URL-unfurl resolver** threaded into the bookmark node. `undefined` → cards render a hostname/favicon fallback. See [nodes.md](./@docs/nodes.md#bookmark-block). |
|
|
59
|
+
|
|
60
|
+
`resolveLinkPreview` is captured on first build (memoised on
|
|
61
|
+
`[placeholder, resolveLinkPreview]`) — pass a **stable reference** (a
|
|
62
|
+
host method), not a new closure per render.
|
|
63
|
+
|
|
64
|
+
### Imperative API
|
|
65
|
+
|
|
66
|
+
```tsx
|
|
67
|
+
const ref = useRef<NotionEditorHandle>(null);
|
|
68
|
+
|
|
69
|
+
ref.current?.focus(); // focus the editor surface
|
|
70
|
+
ref.current?.moveCursorToEnd(); // focus + caret at end
|
|
71
|
+
ref.current?.getEditor(); // raw TipTap Editor (escape hatch)
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
`NotionEditorHandle` is structurally compatible with `ComposerHandle`
|
|
75
|
+
from `@djangocfg/ui-tools/composer-registry` — the chat suite can
|
|
76
|
+
register a NotionEditor as a composer if you ever want to swap them in.
|
|
77
|
+
|
|
78
|
+
## Features at a glance
|
|
79
|
+
|
|
80
|
+
- **Slash menu (`/`)** — 12 commands: Text, H1-H3, Bullet/Numbered/Todo
|
|
81
|
+
lists, Quote, Code block, Divider, Table, and **Map**. Filtering by
|
|
82
|
+
title + aliases; each item shows its markdown shorthand as a hint.
|
|
83
|
+
See [slash.md](./@docs/slash.md).
|
|
84
|
+
- **Block nodes** — a **bookmark** (link-preview) card from pasting a
|
|
85
|
+
bare URL on an empty line, and an interactive **map** block from
|
|
86
|
+
`/map`. Both round-trip through markdown. See [nodes.md](./@docs/nodes.md).
|
|
87
|
+
- **Interactive map editing** — drag a pin, click-to-add, remove,
|
|
88
|
+
switch basemap; every edit writes back into the document via
|
|
89
|
+
`useNodeAttrs`. See [interactivity.md](./@docs/interactivity.md).
|
|
90
|
+
- **Safe block rendering** — each block parses its attrs through a Zod
|
|
91
|
+
schema; a malformed payload renders `<BlockError>` instead of
|
|
92
|
+
crashing the editor. See [interactivity.md](./@docs/interactivity.md#safe-rendering).
|
|
93
|
+
- **Bubble menu** — floating selection toolbar (Bold, Italic, Underline,
|
|
94
|
+
Strike, Code, Highlight, Link) with shortcut tooltips. Auto-hides in
|
|
95
|
+
code blocks and on empty selections.
|
|
96
|
+
- **Cmd+K link prompt**, **drag handle**, **smart Cmd+A**,
|
|
97
|
+
**heading-aware placeholder**, **forced-dark code blocks**,
|
|
98
|
+
**task list** (ui-core `<Checkbox>`), **Cmd+S save hook**,
|
|
99
|
+
**markdown input rules**. See [editor.md](./@docs/editor.md).
|
|
100
|
+
|
|
101
|
+
## Docs
|
|
102
|
+
|
|
103
|
+
| Doc | Covers |
|
|
104
|
+
|---|---|
|
|
105
|
+
| [nodes.md](./@docs/nodes.md) | The **bookmark** + **map** block nodes — triggers, payload shapes, the `resolveLinkPreview` injection, and markdown serialisation / round-trip. |
|
|
106
|
+
| [interactivity.md](./@docs/interactivity.md) | The `useNodeAttrs` write-back seam (how map edits persist) and the Zod-validator / `<BlockError>` safety net. |
|
|
107
|
+
| [slash.md](./@docs/slash.md) | The `/` slash menu — the command list, filtering, and how to add a command (incl. `/map`). |
|
|
108
|
+
| [editor.md](./@docs/editor.md) | Base editor config — the TipTap extension stack, keymap, task items, drag handle, link dialog, markdown round-trip table, and known limitations. |
|
|
109
|
+
| [stories.md](./@docs/stories.md) | The Storybook stories list. |
|
|
110
|
+
|
|
111
|
+
## Files
|
|
112
|
+
|
|
113
|
+
| File | Purpose |
|
|
114
|
+
| ----------------------------- | ----------------------------------------------------------- |
|
|
115
|
+
| `lazy.tsx` | Subpath entry — `React.lazy` + `forwardRef` for the handle |
|
|
116
|
+
| `NotionEditor.tsx` | Main component + `BubbleSelectionToolbar` |
|
|
117
|
+
| `extensions.ts` | Assembled TipTap stack (factory `notionExtensions()`) |
|
|
118
|
+
| `types.ts` | `NotionEditorProps`, `NotionEditorHandle` |
|
|
119
|
+
| `BookmarkNode.ts` · `BookmarkView.tsx` | Bookmark (link-preview) block node + NodeView |
|
|
120
|
+
| `MapNode.ts` · `MapView.tsx` · `MapEditLayer.tsx` | Map block node, NodeView, editable layer |
|
|
121
|
+
| `SlashExtension.ts` | TipTap extension wrapping `@tiptap/suggestion` |
|
|
122
|
+
| `createSlashSuggestion.ts` | Suggestion config — floating-ui popup mount/update/teardown |
|
|
123
|
+
| `SlashList.tsx` | Popover content (listbox + keyboard nav) |
|
|
124
|
+
| `slashItems.ts` | Command list + `filterSlashItems` helper |
|
|
125
|
+
| `CustomKeymap.ts` | Smart Cmd+A extension |
|
|
126
|
+
| `TaskItemView.tsx` | React NodeView for `taskItem` (mounts ui-core Checkbox) |
|
|
127
|
+
| `LinkDialog.tsx` | Cmd+K link prompt (ui-core Dialog) |
|
|
128
|
+
| `styles.css` | Typography + slash/bubble menu + drag handle + lowlight |
|
|
129
|
+
|
|
130
|
+
Shared seams used by the block nodes live under `src/common/` (outside
|
|
131
|
+
this folder): `common/link-preview` (the `LinkPreviewCard` +
|
|
132
|
+
`ResolveLinkPreview` contract), `common/blocks` (payload types, Zod
|
|
133
|
+
schemas, `<BlockError>`), and `common/tiptap` (the `useNodeAttrs`
|
|
134
|
+
write-back hook).
|
|
135
|
+
|
|
136
|
+
## Saving — dirty / pristine handling
|
|
137
|
+
|
|
138
|
+
The editor calls `onChange` on every keystroke with the freshly
|
|
139
|
+
serialised markdown; the host owns state and detects "dirty" however it
|
|
140
|
+
likes. Two gotchas:
|
|
141
|
+
|
|
142
|
+
1. **TipTap re-serialises markdown on mount** (trailing newline, CRLF →
|
|
143
|
+
LF). The first `onChange` after `setContent(value)` is byte-different
|
|
144
|
+
from `value` even though nobody typed. Normalise both sides before a
|
|
145
|
+
dirty comparison:
|
|
146
|
+
|
|
147
|
+
```ts
|
|
148
|
+
const norm = (s: string) => s.replace(/\r\n/g, '\n').trimEnd();
|
|
149
|
+
const isDirty = norm(draft) !== norm(saved);
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
2. **Cmd+S only fires when focus is inside the editor.** Add a
|
|
153
|
+
window-level `useHotkey('mod+s', save)` in the host as a fallback —
|
|
154
|
+
both paths call the same save fn.
|
|
155
|
+
|
|
156
|
+
See `cmdop/.../document-preview/viewers/text-viewer.tsx` for the
|
|
157
|
+
canonical wiring. Use `key={path}` on the wrapper if you remount per
|
|
158
|
+
file — that's what makes `autoFocus` fire on each file open.
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { Extension } from '@tiptap/core';
|
|
2
|
+
import Suggestion, { type SuggestionOptions } from '@tiptap/suggestion';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Tiptap extension that wires `@tiptap/suggestion` for the slash menu.
|
|
6
|
+
* The actual suggestion config (items / render / floating-ui popup) is
|
|
7
|
+
* passed in via options — keeps this file UI-agnostic and lets the
|
|
8
|
+
* popover live in `createSlashSuggestion.ts`.
|
|
9
|
+
*/
|
|
10
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
11
|
+
export interface SlashExtensionOptions {
|
|
12
|
+
suggestion: Omit<SuggestionOptions<any>, 'editor'>;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export const SlashExtension = Extension.create<SlashExtensionOptions>({
|
|
16
|
+
name: 'notion-slash-command',
|
|
17
|
+
|
|
18
|
+
addOptions() {
|
|
19
|
+
return {
|
|
20
|
+
suggestion: { char: '/' } as Omit<SuggestionOptions<unknown>, 'editor'>,
|
|
21
|
+
};
|
|
22
|
+
},
|
|
23
|
+
|
|
24
|
+
addProseMirrorPlugins() {
|
|
25
|
+
return [
|
|
26
|
+
Suggestion({
|
|
27
|
+
editor: this.editor,
|
|
28
|
+
...this.options.suggestion,
|
|
29
|
+
}),
|
|
30
|
+
];
|
|
31
|
+
},
|
|
32
|
+
});
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
forwardRef,
|
|
5
|
+
useCallback,
|
|
6
|
+
useEffect,
|
|
7
|
+
useImperativeHandle,
|
|
8
|
+
useRef,
|
|
9
|
+
useState,
|
|
10
|
+
} from 'react';
|
|
11
|
+
import type { SlashItem } from './slashItems';
|
|
12
|
+
|
|
13
|
+
export interface SlashListRef {
|
|
14
|
+
/** Forwarded from `@tiptap/suggestion.render().onKeyDown`, which fires
|
|
15
|
+
* the underlying native `KeyboardEvent` — typing this as React's
|
|
16
|
+
* synthetic event would be a lie. */
|
|
17
|
+
onKeyDown: (event: KeyboardEvent) => boolean;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface SlashListProps {
|
|
21
|
+
items: SlashItem[];
|
|
22
|
+
command: (item: SlashItem) => void;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const LISTBOX_ID = 'notion-slash-menu';
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Slash-command popover content. Mirrors the MentionList pattern —
|
|
29
|
+
* Arrow keys / Enter / Tab keyboard navigation, mouse hover preview,
|
|
30
|
+
* auto-scroll active item into view.
|
|
31
|
+
*/
|
|
32
|
+
export const SlashList = forwardRef<SlashListRef, SlashListProps>(
|
|
33
|
+
({ items, command }, ref) => {
|
|
34
|
+
const [selectedIndex, setSelectedIndex] = useState(0);
|
|
35
|
+
const itemRefs = useRef<(HTMLButtonElement | null)[]>([]);
|
|
36
|
+
|
|
37
|
+
useEffect(() => setSelectedIndex(0), [items]);
|
|
38
|
+
|
|
39
|
+
useEffect(() => {
|
|
40
|
+
itemRefs.current[selectedIndex]?.scrollIntoView({ block: 'nearest' });
|
|
41
|
+
}, [selectedIndex]);
|
|
42
|
+
|
|
43
|
+
const select = useCallback(
|
|
44
|
+
(index: number) => {
|
|
45
|
+
const item = items[index];
|
|
46
|
+
if (item) command(item);
|
|
47
|
+
},
|
|
48
|
+
[items, command],
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
useImperativeHandle(ref, () => ({
|
|
52
|
+
onKeyDown: (event: KeyboardEvent) => {
|
|
53
|
+
if (items.length === 0) return false;
|
|
54
|
+
if (event.key === 'ArrowUp') {
|
|
55
|
+
setSelectedIndex((i) => (i + items.length - 1) % items.length);
|
|
56
|
+
return true;
|
|
57
|
+
}
|
|
58
|
+
if (event.key === 'ArrowDown') {
|
|
59
|
+
setSelectedIndex((i) => (i + 1) % items.length);
|
|
60
|
+
return true;
|
|
61
|
+
}
|
|
62
|
+
if (event.key === 'Enter' || event.key === 'Tab') {
|
|
63
|
+
select(selectedIndex);
|
|
64
|
+
return true;
|
|
65
|
+
}
|
|
66
|
+
return false;
|
|
67
|
+
},
|
|
68
|
+
}));
|
|
69
|
+
|
|
70
|
+
// Empty state — render a "no matches" pill instead of vanishing so
|
|
71
|
+
// the user gets feedback that the popover is alive (not a bug).
|
|
72
|
+
if (items.length === 0) {
|
|
73
|
+
return (
|
|
74
|
+
<div
|
|
75
|
+
id={LISTBOX_ID}
|
|
76
|
+
role="listbox"
|
|
77
|
+
aria-label="Slash commands"
|
|
78
|
+
className="notion-slash-list"
|
|
79
|
+
>
|
|
80
|
+
<div className="notion-slash-empty">No matching blocks</div>
|
|
81
|
+
</div>
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const activeId = `${LISTBOX_ID}-${selectedIndex}`;
|
|
86
|
+
|
|
87
|
+
return (
|
|
88
|
+
<div
|
|
89
|
+
id={LISTBOX_ID}
|
|
90
|
+
role="listbox"
|
|
91
|
+
aria-label="Slash commands"
|
|
92
|
+
aria-activedescendant={activeId}
|
|
93
|
+
className="notion-slash-list"
|
|
94
|
+
>
|
|
95
|
+
{items.map((item, i) => {
|
|
96
|
+
const Icon = item.icon;
|
|
97
|
+
const isActive = i === selectedIndex;
|
|
98
|
+
return (
|
|
99
|
+
<button
|
|
100
|
+
key={item.title}
|
|
101
|
+
id={`${LISTBOX_ID}-${i}`}
|
|
102
|
+
ref={(el) => {
|
|
103
|
+
itemRefs.current[i] = el;
|
|
104
|
+
}}
|
|
105
|
+
type="button"
|
|
106
|
+
role="option"
|
|
107
|
+
aria-selected={isActive}
|
|
108
|
+
onMouseEnter={() => setSelectedIndex(i)}
|
|
109
|
+
onClick={() => select(i)}
|
|
110
|
+
// Prevent the click from blurring the editor — without this,
|
|
111
|
+
// ProseMirror loses its selection before the slash command
|
|
112
|
+
// can resolve the range.
|
|
113
|
+
onMouseDown={(e) => e.preventDefault()}
|
|
114
|
+
className={`notion-slash-item${isActive ? ' notion-slash-item--active' : ''}`}
|
|
115
|
+
>
|
|
116
|
+
<span className="notion-slash-icon">
|
|
117
|
+
<Icon style={{ width: 16, height: 16 }} aria-hidden />
|
|
118
|
+
</span>
|
|
119
|
+
<span className="notion-slash-meta">
|
|
120
|
+
<span className="notion-slash-title">{item.title}</span>
|
|
121
|
+
<span className="notion-slash-desc">{item.description}</span>
|
|
122
|
+
</span>
|
|
123
|
+
{item.hint ? (
|
|
124
|
+
<span className="notion-slash-hint" aria-hidden>
|
|
125
|
+
{item.hint}
|
|
126
|
+
</span>
|
|
127
|
+
) : null}
|
|
128
|
+
</button>
|
|
129
|
+
);
|
|
130
|
+
})}
|
|
131
|
+
</div>
|
|
132
|
+
);
|
|
133
|
+
},
|
|
134
|
+
);
|
|
135
|
+
|
|
136
|
+
SlashList.displayName = 'SlashList';
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* React NodeView for `taskItem`. The default Tiptap renderer mints a
|
|
5
|
+
* raw `<input type="checkbox">`; we swap it for our ui-core `<Checkbox>`
|
|
6
|
+
* so task lists pick up the macOS / app token styling (radius, ring,
|
|
7
|
+
* focus state) and stay theme-aware.
|
|
8
|
+
*
|
|
9
|
+
* Wired via `TaskItem.extend({ addNodeView: () => ReactNodeViewRenderer(TaskItemView) })`
|
|
10
|
+
* — TaskItem.attributes already expose `checked` and `editor.commands`
|
|
11
|
+
* persists the change back into ProseMirror state. We only render the UI.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { NodeViewContent, NodeViewWrapper, type NodeViewProps } from '@tiptap/react';
|
|
15
|
+
import { Checkbox } from '@djangocfg/ui-core';
|
|
16
|
+
|
|
17
|
+
export function TaskItemView({ node, updateAttributes, editor }: NodeViewProps) {
|
|
18
|
+
const checked = node.attrs.checked === true;
|
|
19
|
+
const isEditable = editor.isEditable;
|
|
20
|
+
|
|
21
|
+
return (
|
|
22
|
+
<NodeViewWrapper
|
|
23
|
+
as="li"
|
|
24
|
+
data-type="taskList"
|
|
25
|
+
data-checked={checked || undefined}
|
|
26
|
+
className="notion-task-item"
|
|
27
|
+
>
|
|
28
|
+
<Checkbox
|
|
29
|
+
checked={checked}
|
|
30
|
+
disabled={!isEditable}
|
|
31
|
+
onCheckedChange={(value) => updateAttributes({ checked: value === true })}
|
|
32
|
+
// Stop ProseMirror from intercepting the click — without this
|
|
33
|
+
// the editor steals focus mid-toggle and the state flickers.
|
|
34
|
+
onClick={(event) => event.stopPropagation()}
|
|
35
|
+
aria-label={checked ? 'Mark as not done' : 'Mark as done'}
|
|
36
|
+
className="notion-task-checkbox"
|
|
37
|
+
/>
|
|
38
|
+
<NodeViewContent as="div" className="notion-task-text" />
|
|
39
|
+
</NodeViewWrapper>
|
|
40
|
+
);
|
|
41
|
+
}
|