@djangocfg/ui-tools 2.1.416 → 2.1.418

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.
Files changed (66) hide show
  1. package/dist/audio-player/index.cjs +2098 -0
  2. package/dist/audio-player/index.cjs.map +1 -0
  3. package/dist/audio-player/index.css +65 -0
  4. package/dist/audio-player/index.css.map +1 -0
  5. package/dist/audio-player/index.d.cts +166 -0
  6. package/dist/audio-player/index.d.ts +166 -0
  7. package/dist/audio-player/index.mjs +2075 -0
  8. package/dist/audio-player/index.mjs.map +1 -0
  9. package/dist/composer-registry/index.cjs +45 -0
  10. package/dist/composer-registry/index.cjs.map +1 -0
  11. package/dist/composer-registry/index.d.cts +73 -0
  12. package/dist/composer-registry/index.d.ts +73 -0
  13. package/dist/composer-registry/index.mjs +39 -0
  14. package/dist/composer-registry/index.mjs.map +1 -0
  15. package/dist/tree/index.cjs +82 -63
  16. package/dist/tree/index.cjs.map +1 -1
  17. package/dist/tree/index.d.cts +15 -1
  18. package/dist/tree/index.d.ts +15 -1
  19. package/dist/tree/index.mjs +83 -64
  20. package/dist/tree/index.mjs.map +1 -1
  21. package/package.json +38 -17
  22. package/src/tools/chat/composer/Composer.tsx +8 -8
  23. package/src/tools/chat/context/ChatProvider.tsx +13 -78
  24. package/src/tools/chat/hooks/useAutoFocusOnStreamEnd.ts +12 -15
  25. package/src/tools/chat/hooks/useFocusOnEmptyClick.ts +4 -5
  26. package/src/tools/chat/launcher/header/ChatHeader.tsx +14 -19
  27. package/src/tools/chat/launcher/header/ChatHeaderActionButton.tsx +8 -12
  28. package/src/tools/data/Tree/TreeRoot.tsx +33 -109
  29. package/src/tools/data/Tree/context/TreeContext.tsx +22 -3
  30. package/src/tools/data/Tree/context/menu/index.ts +1 -0
  31. package/src/tools/data/Tree/context/menu/render.tsx +75 -0
  32. package/src/tools/data/Tree/context/menu/use-resolved-menu.ts +16 -2
  33. package/src/tools/data/Tree/index.tsx +1 -0
  34. package/src/tools/data/Tree/types/index.ts +1 -1
  35. package/src/tools/data/Tree/types/root-props.ts +16 -0
  36. package/src/tools/dev/OpenapiViewer/components/DocsLayout/EndpointDoc/Header/MetaActions.tsx +6 -9
  37. package/src/tools/dev/OpenapiViewer/components/DocsLayout/index.tsx +2 -4
  38. package/src/tools/forms/CodeEditor/components/Editor.tsx +19 -0
  39. package/src/tools/forms/CodeEditor/types/index.ts +7 -0
  40. package/src/tools/forms/MarkdownEditor/MarkdownEditor.tsx +40 -0
  41. package/src/tools/forms/MarkdownEditor/styles.css +174 -21
  42. package/src/tools/forms/NotionEditor/CustomKeymap.ts +48 -0
  43. package/src/tools/forms/NotionEditor/LinkDialog.tsx +133 -0
  44. package/src/tools/forms/NotionEditor/NotionEditor.tsx +304 -0
  45. package/src/tools/forms/NotionEditor/SlashExtension.ts +32 -0
  46. package/src/tools/forms/NotionEditor/SlashList.tsx +136 -0
  47. package/src/tools/forms/NotionEditor/TaskItemView.tsx +41 -0
  48. package/src/tools/forms/NotionEditor/createSlashSuggestion.ts +121 -0
  49. package/src/tools/forms/NotionEditor/extensions.ts +105 -0
  50. package/src/tools/forms/NotionEditor/index.ts +1 -0
  51. package/src/tools/forms/NotionEditor/lazy.tsx +44 -0
  52. package/src/tools/forms/NotionEditor/slashItems.ts +159 -0
  53. package/src/tools/forms/NotionEditor/styles.css +478 -0
  54. package/src/tools/forms/NotionEditor/types.ts +28 -0
  55. package/src/tools/input/SpeechRecognition/widgets/VoiceComposerSlot.tsx +11 -12
  56. package/src/tools/integration/ComposerRegistry/index.ts +105 -0
  57. package/src/tools/media/AudioPlayer/Player.tsx +2 -0
  58. package/src/tools/media/AudioPlayer/PlayerShell.tsx +29 -22
  59. package/src/tools/media/AudioPlayer/lazy.tsx +30 -42
  60. package/src/tools/media/AudioPlayer/parts/Controls/IconButton.tsx +10 -11
  61. package/src/tools/media/AudioPlayer/parts/Controls/VolumeControl.tsx +52 -115
  62. package/src/tools/media/AudioPlayer/types.ts +8 -0
  63. package/src/tools/media/ImageViewer/components/ImageViewer.tsx +8 -0
  64. package/src/tools/media/ImageViewer/types.ts +4 -0
  65. package/src/tools/media/VideoPlayer/VideoPlayer.tsx +20 -1
  66. package/src/tools/media/VideoPlayer/types.ts +4 -0
@@ -0,0 +1,48 @@
1
+ import { Extension } from '@tiptap/core';
2
+ import { TextSelection } from '@tiptap/pm/state';
3
+
4
+ /**
5
+ * Notion-style smart Cmd/Ctrl+A:
6
+ * 1st press → select the current text block
7
+ * 2nd press → select the whole document
8
+ *
9
+ * Default editor behaviour selects everything on first press, which is
10
+ * jarring in long documents where the user almost always wants "select
11
+ * paragraph" first. Ported from Novel (`extensions/custom-keymap.ts`).
12
+ */
13
+ export const CustomKeymap = Extension.create({
14
+ name: 'notion-custom-keymap',
15
+
16
+ addKeyboardShortcuts() {
17
+ return {
18
+ 'Mod-a': ({ editor }) => {
19
+ const { selection, doc } = editor.state;
20
+ const { $from, $to, from, to } = selection;
21
+
22
+ // Resolve the boundaries of the current text block. `start`/`end`
23
+ // on $from/$to give the inclusive content range of the parent
24
+ // textblock (a paragraph, heading, code block, list item body).
25
+ const blockStart = $from.start();
26
+ const blockEnd = $to.end();
27
+
28
+ const isWholeBlockSelected = from === blockStart && to === blockEnd;
29
+ // If the block is already fully selected → escalate to whole doc.
30
+ if (isWholeBlockSelected) {
31
+ editor
32
+ .chain()
33
+ .setTextSelection({ from: 0, to: doc.content.size })
34
+ .run();
35
+ return true;
36
+ }
37
+
38
+ editor
39
+ .chain()
40
+ .setTextSelection(
41
+ TextSelection.create(editor.state.doc, blockStart, blockEnd),
42
+ )
43
+ .run();
44
+ return true;
45
+ },
46
+ };
47
+ },
48
+ });
@@ -0,0 +1,133 @@
1
+ 'use client';
2
+
3
+ /**
4
+ * Cmd/Ctrl+K link prompt. Modeless dialog with a single URL input and
5
+ * Save/Remove/Cancel actions. Mounted as a side-channel above the editor
6
+ * so it survives selection collapse — without this, focusing the input
7
+ * would unset the selection and TipTap couldn't apply the mark.
8
+ *
9
+ * We snapshot `from`/`to` of the selection on open and re-apply it
10
+ * before running the link command. ProseMirror tolerates this because
11
+ * the dialog is rendered in a portal (Radix's `DialogPortal`) and the
12
+ * editor's view does not lose its `state` — only its DOM focus.
13
+ */
14
+
15
+ import { useEffect, useRef, useState } from 'react';
16
+ import {
17
+ Button,
18
+ Dialog,
19
+ DialogContent,
20
+ DialogFooter,
21
+ DialogHeader,
22
+ DialogTitle,
23
+ Input,
24
+ } from '@djangocfg/ui-core';
25
+ import type { Editor } from '@tiptap/react';
26
+
27
+ /** Reject `javascript:`, `data:`, `vbscript:` schemes. Plain anchors,
28
+ * http(s), mailto, tel, and relative paths pass. Same allow-list TipTap
29
+ * uses internally when `validate` is omitted, made explicit here. */
30
+ function isSafeHref(value: string): boolean {
31
+ const trimmed = value.trim();
32
+ const colon = trimmed.indexOf(':');
33
+ if (colon === -1) return true; // relative or hash link
34
+ const scheme = trimmed.slice(0, colon).toLowerCase();
35
+ return ['http', 'https', 'mailto', 'tel', 'sms', 'ftp'].includes(scheme);
36
+ }
37
+
38
+ interface LinkDialogProps {
39
+ editor: Editor;
40
+ open: boolean;
41
+ onOpenChange: (open: boolean) => void;
42
+ }
43
+
44
+ export function LinkDialog({ editor, open, onOpenChange }: LinkDialogProps) {
45
+ const [href, setHref] = useState('');
46
+ const inputRef = useRef<HTMLInputElement | null>(null);
47
+ // Remember the selection range when the dialog opened — focusing the
48
+ // input collapses selection in the editor; we re-apply the snapshot
49
+ // before running the link command.
50
+ const rangeRef = useRef<{ from: number; to: number } | null>(null);
51
+
52
+ useEffect(() => {
53
+ if (!open) return;
54
+ const { from, to } = editor.state.selection;
55
+ rangeRef.current = { from, to };
56
+ const existing = editor.getAttributes('link').href as string | undefined;
57
+ setHref(existing ?? '');
58
+ // Focus is handled by Radix's `onOpenAutoFocus` below — that fires
59
+ // after the dialog DOM is in the document and avoids racing the
60
+ // built-in focus trap.
61
+ }, [open, editor]);
62
+
63
+ const applyLink = (value: string) => {
64
+ const range = rangeRef.current;
65
+ if (range) {
66
+ editor.chain().focus().setTextSelection(range).run();
67
+ }
68
+ const trimmed = value.trim();
69
+ if (!trimmed) {
70
+ editor.chain().focus().unsetLink().run();
71
+ } else if (isSafeHref(trimmed)) {
72
+ editor.chain().focus().extendMarkRange('link').setLink({ href: trimmed }).run();
73
+ }
74
+ onOpenChange(false);
75
+ };
76
+
77
+ const removeLink = () => {
78
+ const range = rangeRef.current;
79
+ if (range) {
80
+ editor.chain().focus().setTextSelection(range).run();
81
+ }
82
+ editor.chain().focus().unsetLink().run();
83
+ onOpenChange(false);
84
+ };
85
+
86
+ return (
87
+ <Dialog open={open} onOpenChange={onOpenChange}>
88
+ <DialogContent
89
+ className="sm:max-w-md"
90
+ // Override Radix's default auto-focus (first focusable). We want
91
+ // the URL input focused + selected — combining `preventDefault`
92
+ // here with a direct `focus()` call sequences cleanly with the
93
+ // dialog mount; the earlier queueMicrotask version raced Radix.
94
+ onOpenAutoFocus={(e) => {
95
+ e.preventDefault();
96
+ inputRef.current?.focus();
97
+ inputRef.current?.select();
98
+ }}
99
+ >
100
+ <DialogHeader>
101
+ <DialogTitle>Link</DialogTitle>
102
+ </DialogHeader>
103
+ <form
104
+ onSubmit={(e) => {
105
+ e.preventDefault();
106
+ applyLink(href);
107
+ }}
108
+ >
109
+ <Input
110
+ ref={inputRef}
111
+ type="url"
112
+ placeholder="https://example.com"
113
+ value={href}
114
+ onChange={(e) => setHref(e.target.value)}
115
+ autoComplete="off"
116
+ spellCheck={false}
117
+ />
118
+ <DialogFooter className="mt-4 gap-2 sm:gap-2">
119
+ {editor.isActive('link') ? (
120
+ <Button type="button" variant="outline" onClick={removeLink}>
121
+ Remove
122
+ </Button>
123
+ ) : null}
124
+ <Button type="button" variant="ghost" onClick={() => onOpenChange(false)}>
125
+ Cancel
126
+ </Button>
127
+ <Button type="submit">Save</Button>
128
+ </DialogFooter>
129
+ </form>
130
+ </DialogContent>
131
+ </Dialog>
132
+ );
133
+ }
@@ -0,0 +1,304 @@
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
+ },
70
+ ref,
71
+ ) {
72
+ const isExternalUpdate = useRef(false);
73
+ const [linkDialogOpen, setLinkDialogOpen] = useState(false);
74
+
75
+ // Build extensions once. Placeholder is captured by closure on first
76
+ // render — same constraint as MarkdownEditor; mentions / slash items
77
+ // don't change at runtime here.
78
+ const extensions = useMemo(() => notionExtensions({ placeholder }), [placeholder]);
79
+
80
+ const editor = useEditor({
81
+ immediatelyRender: false,
82
+ editable: !disabled,
83
+ extensions,
84
+ content: value,
85
+ contentType: 'markdown',
86
+ onUpdate: ({ editor }) => {
87
+ if (isExternalUpdate.current) return;
88
+ onChange(getMarkdown(editor));
89
+ },
90
+ editorProps: {
91
+ attributes: {
92
+ class: 'notion-editor-content focus:outline-none',
93
+ style: `min-height: ${minHeight}px`,
94
+ },
95
+ },
96
+ });
97
+
98
+ // Sync external value → editor without looping back into onChange.
99
+ useEffect(() => {
100
+ if (!editor) return;
101
+ const current = getMarkdown(editor);
102
+ if (current === value) return;
103
+ isExternalUpdate.current = true;
104
+ editor.commands.setContent(value, {
105
+ contentType: 'markdown',
106
+ emitUpdate: false,
107
+ });
108
+ isExternalUpdate.current = false;
109
+ }, [value, editor]);
110
+
111
+ useEffect(() => {
112
+ editor?.setEditable(!disabled);
113
+ }, [editor, disabled]);
114
+
115
+ // Declarative autoFocus.
116
+ useEffect(() => {
117
+ if (!autoFocus || !editor) return;
118
+ editor.commands.focus('end');
119
+ }, [autoFocus, editor]);
120
+
121
+ // Cmd/Ctrl+S → save, scoped to the editor DOM via guard.
122
+ const onSaveRef = useRef(onSave);
123
+ onSaveRef.current = onSave;
124
+ useHotkey(
125
+ 'mod+s',
126
+ () => {
127
+ const h = onSaveRef.current;
128
+ if (!h || !editor) return;
129
+ const dom = editor.view.dom;
130
+ const active = document.activeElement;
131
+ if (!active || !dom.contains(active)) return;
132
+ h(getMarkdown(editor));
133
+ },
134
+ { enabled: !!editor && !!onSave },
135
+ );
136
+
137
+ // Cmd/Ctrl+K → link prompt. Same focus-scope guard so we don't
138
+ // collide with global command palettes higher up the tree.
139
+ useHotkey(
140
+ 'mod+k',
141
+ () => {
142
+ if (!editor) return;
143
+ const dom = editor.view.dom;
144
+ const active = document.activeElement;
145
+ if (!active || !dom.contains(active)) return;
146
+ setLinkDialogOpen(true);
147
+ },
148
+ { enabled: !!editor },
149
+ );
150
+
151
+ useImperativeHandle(
152
+ ref,
153
+ (): NotionEditorHandle => ({
154
+ focus: () => {
155
+ editor?.commands.focus();
156
+ },
157
+ moveCursorToEnd: () => {
158
+ editor?.commands.focus('end');
159
+ },
160
+ getEditor: () => editor ?? null,
161
+ }),
162
+ [editor],
163
+ );
164
+
165
+ return (
166
+ <div className={`notion-editor ${disabled ? 'opacity-60' : ''} ${className}`.trim()}>
167
+ {editor ? (
168
+ <>
169
+ <BubbleSelectionToolbar
170
+ editor={editor}
171
+ onOpenLink={() => setLinkDialogOpen(true)}
172
+ />
173
+ <LinkDialog
174
+ editor={editor}
175
+ open={linkDialogOpen}
176
+ onOpenChange={setLinkDialogOpen}
177
+ />
178
+ </>
179
+ ) : null}
180
+ <EditorContent editor={editor} />
181
+ </div>
182
+ );
183
+ },
184
+ );
185
+
186
+ interface BubbleItem {
187
+ icon: LucideIcon;
188
+ title: string;
189
+ /** Keyboard shortcut to display in the tooltip. macOS-style (⌘ B). */
190
+ shortcut: readonly string[];
191
+ isActive: () => boolean;
192
+ run: () => void;
193
+ }
194
+
195
+ /**
196
+ * Floating selection toolbar. `@tiptap/extension-bubble-menu` anchors
197
+ * itself to the current selection; it auto-hides when the selection
198
+ * collapses, so we only need to declare the buttons. Tooltips show the
199
+ * keyboard chord — Tiptap installs the shortcuts itself (StarterKit).
200
+ */
201
+ function BubbleSelectionToolbar({
202
+ editor,
203
+ onOpenLink,
204
+ }: {
205
+ editor: Editor;
206
+ onOpenLink: () => void;
207
+ }) {
208
+ const items: BubbleItem[] = [
209
+ {
210
+ icon: Bold,
211
+ title: 'Bold',
212
+ shortcut: ['⌘', 'B'],
213
+ isActive: () => editor.isActive('bold'),
214
+ run: () => editor.chain().focus().toggleBold().run(),
215
+ },
216
+ {
217
+ icon: Italic,
218
+ title: 'Italic',
219
+ shortcut: ['⌘', 'I'],
220
+ isActive: () => editor.isActive('italic'),
221
+ run: () => editor.chain().focus().toggleItalic().run(),
222
+ },
223
+ {
224
+ icon: UnderlineIcon,
225
+ title: 'Underline',
226
+ shortcut: ['⌘', 'U'],
227
+ isActive: () => editor.isActive('underline'),
228
+ run: () => editor.chain().focus().toggleUnderline().run(),
229
+ },
230
+ {
231
+ icon: Strikethrough,
232
+ title: 'Strike',
233
+ shortcut: ['⌘', '⇧', 'X'],
234
+ isActive: () => editor.isActive('strike'),
235
+ run: () => editor.chain().focus().toggleStrike().run(),
236
+ },
237
+ {
238
+ icon: CodeIcon,
239
+ title: 'Inline code',
240
+ shortcut: ['⌘', 'E'],
241
+ isActive: () => editor.isActive('code'),
242
+ run: () => editor.chain().focus().toggleCode().run(),
243
+ },
244
+ {
245
+ icon: Highlighter,
246
+ title: 'Highlight',
247
+ shortcut: ['⌘', '⇧', 'H'],
248
+ isActive: () => editor.isActive('highlight'),
249
+ run: () => editor.chain().focus().toggleHighlight().run(),
250
+ },
251
+ {
252
+ icon: LinkIcon,
253
+ title: 'Link',
254
+ shortcut: ['⌘', 'K'],
255
+ isActive: () => editor.isActive('link'),
256
+ run: onOpenLink,
257
+ },
258
+ ];
259
+
260
+ return (
261
+ <BubbleMenu
262
+ editor={editor}
263
+ className="notion-bubble-menu"
264
+ // Hide inside code blocks (Bold/Italic don't apply there) and when
265
+ // the selection is empty/whitespace-only. Default behaviour shows
266
+ // the menu on any non-empty range, which is too eager.
267
+ shouldShow={({ editor, from, to }) => {
268
+ if (from === to) return false;
269
+ if (editor.isActive('codeBlock')) return false;
270
+ const text = editor.state.doc.textBetween(from, to, ' ').trim();
271
+ return text.length > 0;
272
+ }}
273
+ >
274
+ {items.map((item) => {
275
+ const Icon = item.icon;
276
+ const active = item.isActive();
277
+ return (
278
+ <Tooltip key={item.title}>
279
+ <TooltipTrigger asChild>
280
+ <button
281
+ type="button"
282
+ aria-label={item.title}
283
+ aria-pressed={active}
284
+ onMouseDown={(e) => e.preventDefault()}
285
+ onClick={item.run}
286
+ className={`notion-bubble-btn${active ? ' notion-bubble-btn--active' : ''}`}
287
+ >
288
+ <Icon style={{ width: 14, height: 14 }} aria-hidden />
289
+ </button>
290
+ </TooltipTrigger>
291
+ <TooltipContent side="top" sideOffset={6} className="flex items-center gap-1.5">
292
+ <span>{item.title}</span>
293
+ <span className="flex items-center gap-0.5">
294
+ {item.shortcut.map((k) => (
295
+ <Kbd key={k}>{k}</Kbd>
296
+ ))}
297
+ </span>
298
+ </TooltipContent>
299
+ </Tooltip>
300
+ );
301
+ })}
302
+ </BubbleMenu>
303
+ );
304
+ }
@@ -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
+ }