@djangocfg/ui-tools 2.1.417 → 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 (35) hide show
  1. package/dist/audio-player/index.cjs +1 -2
  2. package/dist/audio-player/index.cjs.map +1 -1
  3. package/dist/audio-player/index.d.cts +3 -11
  4. package/dist/audio-player/index.d.ts +3 -11
  5. package/dist/audio-player/index.mjs +1 -2
  6. package/dist/audio-player/index.mjs.map +1 -1
  7. package/dist/tree/index.cjs +0 -3
  8. package/dist/tree/index.cjs.map +1 -1
  9. package/dist/tree/index.mjs +0 -3
  10. package/dist/tree/index.mjs.map +1 -1
  11. package/package.json +30 -14
  12. package/src/tools/data/Tree/components/TreeRow.tsx +0 -11
  13. package/src/tools/forms/CodeEditor/components/Editor.tsx +19 -0
  14. package/src/tools/forms/CodeEditor/types/index.ts +7 -0
  15. package/src/tools/forms/MarkdownEditor/MarkdownEditor.tsx +40 -0
  16. package/src/tools/forms/MarkdownEditor/styles.css +174 -21
  17. package/src/tools/forms/NotionEditor/CustomKeymap.ts +48 -0
  18. package/src/tools/forms/NotionEditor/LinkDialog.tsx +133 -0
  19. package/src/tools/forms/NotionEditor/NotionEditor.tsx +304 -0
  20. package/src/tools/forms/NotionEditor/SlashExtension.ts +32 -0
  21. package/src/tools/forms/NotionEditor/SlashList.tsx +136 -0
  22. package/src/tools/forms/NotionEditor/TaskItemView.tsx +41 -0
  23. package/src/tools/forms/NotionEditor/createSlashSuggestion.ts +121 -0
  24. package/src/tools/forms/NotionEditor/extensions.ts +105 -0
  25. package/src/tools/forms/NotionEditor/index.ts +1 -0
  26. package/src/tools/forms/NotionEditor/lazy.tsx +44 -0
  27. package/src/tools/forms/NotionEditor/slashItems.ts +159 -0
  28. package/src/tools/forms/NotionEditor/styles.css +478 -0
  29. package/src/tools/forms/NotionEditor/types.ts +28 -0
  30. package/src/tools/media/AudioPlayer/PlayerShell.tsx +3 -11
  31. package/src/tools/media/AudioPlayer/types.ts +4 -11
  32. package/src/tools/media/ImageViewer/components/ImageViewer.tsx +8 -0
  33. package/src/tools/media/ImageViewer/types.ts +4 -0
  34. package/src/tools/media/VideoPlayer/VideoPlayer.tsx +20 -1
  35. package/src/tools/media/VideoPlayer/types.ts +4 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@djangocfg/ui-tools",
3
- "version": "2.1.417",
3
+ "version": "2.1.418",
4
4
  "description": "Heavy React tools with lazy loading - for Electron, Vite, CRA, Next.js apps",
5
5
  "keywords": [
6
6
  "ui-tools",
@@ -116,6 +116,11 @@
116
116
  "import": "./src/tools/forms/MarkdownEditor/lazy.tsx",
117
117
  "require": "./src/tools/forms/MarkdownEditor/lazy.tsx"
118
118
  },
119
+ "./notion-editor": {
120
+ "types": "./src/tools/forms/NotionEditor/lazy.tsx",
121
+ "import": "./src/tools/forms/NotionEditor/lazy.tsx",
122
+ "require": "./src/tools/forms/NotionEditor/lazy.tsx"
123
+ },
119
124
  "./markdown-message": {
120
125
  "types": "./src/tools/dev/MarkdownMessage/index.ts",
121
126
  "import": "./src/tools/dev/MarkdownMessage/index.ts",
@@ -259,8 +264,8 @@
259
264
  "test:watch": "vitest"
260
265
  },
261
266
  "peerDependencies": {
262
- "@djangocfg/i18n": "^2.1.417",
263
- "@djangocfg/ui-core": "^2.1.417",
267
+ "@djangocfg/i18n": "^2.1.418",
268
+ "@djangocfg/ui-core": "^2.1.418",
264
269
  "consola": "^3.4.2",
265
270
  "lodash-es": "^4.18.1",
266
271
  "lucide-react": "^0.545.0",
@@ -279,14 +284,25 @@
279
284
  "@rjsf/utils": "^6.1.2",
280
285
  "@rjsf/validator-ajv8": "^6.1.2",
281
286
  "@rpldy/uploady": "^1.8.5",
282
- "@tiptap/core": "^3.20.1",
283
- "@tiptap/extension-mention": "^3.20.1",
284
- "@tiptap/extension-placeholder": "^3.20.1",
285
- "@tiptap/markdown": "^3.20.1",
286
- "@tiptap/pm": "^3.20.1",
287
- "@tiptap/react": "^3.20.1",
288
- "@tiptap/starter-kit": "^3.20.1",
289
- "@tiptap/suggestion": "^3.20.1",
287
+ "@tiptap/core": "^3.23.0",
288
+ "@tiptap/extension-bubble-menu": "^3.23.0",
289
+ "@tiptap/extension-code-block-lowlight": "^3.23.0",
290
+ "@tiptap/extension-highlight": "^3.23.0",
291
+ "@tiptap/extension-mention": "^3.23.0",
292
+ "@tiptap/extension-placeholder": "^3.23.0",
293
+ "@tiptap/extension-table": "^3.23.0",
294
+ "@tiptap/extension-table-cell": "^3.23.0",
295
+ "@tiptap/extension-table-header": "^3.23.0",
296
+ "@tiptap/extension-table-row": "^3.23.0",
297
+ "@tiptap/extension-task-item": "^3.23.0",
298
+ "@tiptap/extension-task-list": "^3.23.0",
299
+ "@tiptap/markdown": "^3.23.0",
300
+ "@tiptap/pm": "^3.23.0",
301
+ "@tiptap/react": "^3.23.0",
302
+ "@tiptap/starter-kit": "^3.23.0",
303
+ "@tiptap/suggestion": "^3.23.0",
304
+ "lowlight": "^3.3.0",
305
+ "tiptap-extension-global-drag-handle": "^0.1.18",
290
306
  "@wavesurfer/react": "^1.0.12",
291
307
  "@radix-ui/react-slot": "^1.2.4",
292
308
  "@radix-ui/react-direction": "^1.1.1",
@@ -324,9 +340,9 @@
324
340
  "@maplibre/maplibre-gl-geocoder": "^1.7.0"
325
341
  },
326
342
  "devDependencies": {
327
- "@djangocfg/i18n": "^2.1.417",
328
- "@djangocfg/typescript-config": "^2.1.417",
329
- "@djangocfg/ui-core": "^2.1.417",
343
+ "@djangocfg/i18n": "^2.1.418",
344
+ "@djangocfg/typescript-config": "^2.1.418",
345
+ "@djangocfg/ui-core": "^2.1.418",
330
346
  "@types/lodash-es": "^4.17.12",
331
347
  "@types/mapbox__mapbox-gl-draw": "^1.4.8",
332
348
  "@types/node": "^25.2.3",
@@ -180,17 +180,6 @@ function TreeRowRaw<T>({ row, className }: TreeRowProps<T>) {
180
180
  }}
181
181
  {...(dnd.active ? draggable.listeners : {})}
182
182
  {...(dnd.active ? draggable.attributes : {})}
183
- // Block the browser's native "click moves focus into the
184
- // focusable target" behaviour. Tree owns its own focus model
185
- // (arrow keys drive `setFocus`, click only selects + activates),
186
- // and stealing focus on every click stops downstream auto-focus
187
- // patterns from working — e.g. clicking a file in a tree-plus-
188
- // preview layout would let the row grab focus after the preview
189
- // viewer tries to take it. The synthetic `onClick` below still
190
- // fires; only the focus side-effect of mousedown is suppressed.
191
- onMouseDown={(e) => {
192
- if (e.button === 0) e.preventDefault();
193
- }}
194
183
  onClick={handleClick}
195
184
  onDoubleClick={handleDoubleClick}
196
185
  onContextMenu={handleContextMenu}
@@ -46,6 +46,8 @@ export const Editor = forwardRef<EditorRef, EditorProps>(function Editor(
46
46
  autoHeight = false,
47
47
  minHeight = 100,
48
48
  maxHeight = 600,
49
+ autoFocus = false,
50
+ onSave,
49
51
  },
50
52
  ref
51
53
  ) {
@@ -67,9 +69,11 @@ export const Editor = forwardRef<EditorRef, EditorProps>(function Editor(
67
69
  // without going stale when the parent passes new function identities.
68
70
  const onChangeRef = useRef(onChange);
69
71
  const onMountRef = useRef(onMount);
72
+ const onSaveRef = useRef(onSave);
70
73
  useEffect(() => {
71
74
  onChangeRef.current = onChange;
72
75
  onMountRef.current = onMount;
76
+ onSaveRef.current = onSave;
73
77
  });
74
78
 
75
79
  // Expose editor methods via ref
@@ -126,6 +130,21 @@ export const Editor = forwardRef<EditorRef, EditorProps>(function Editor(
126
130
  updateContentHeight(editor);
127
131
  }
128
132
 
133
+ // Cmd/Ctrl+S → save. Registered as a Monaco command so it wins over
134
+ // the browser's "save page" default whenever the editor has focus.
135
+ // Read through the ref so swapping handlers does not need to rebuild.
136
+ editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS, () => {
137
+ onSaveRef.current?.(editor.getValue());
138
+ });
139
+
140
+ // autoFocus on first mount — Monaco refuses focus during layout
141
+ // measurement otherwise.
142
+ if (autoFocus) {
143
+ // queueMicrotask: defer past Monaco's own post-create layout so the
144
+ // focus call lands on a fully laid-out editor.
145
+ queueMicrotask(() => editorRef.current?.focus());
146
+ }
147
+
129
148
  // Call onMount callback
130
149
  onMountRef.current?.(editor);
131
150
 
@@ -61,6 +61,13 @@ export interface EditorProps {
61
61
  minHeight?: number;
62
62
  /** Max height in px when autoHeight is enabled (default: 600) */
63
63
  maxHeight?: number;
64
+ /** Focus the editor once Monaco mounts. Pair with `key={path}` upstream
65
+ * for per-file focus reset. */
66
+ autoFocus?: boolean;
67
+ /** Bound to Cmd/Ctrl+S inside the editor via Monaco's command palette.
68
+ * Receives the current value. The browser default is suppressed by
69
+ * Monaco when a command is registered for that chord. */
70
+ onSave?: (value: string) => void;
64
71
  }
65
72
 
66
73
  export interface DiffEditorProps {
@@ -7,6 +7,7 @@ import Mention from '@tiptap/extension-mention';
7
7
  import { Markdown } from '@tiptap/markdown';
8
8
  import type { AnyExtension } from '@tiptap/core';
9
9
  import { forwardRef, useEffect, useImperativeHandle, useMemo, useRef } from 'react';
10
+ import { useHotkey } from '@djangocfg/ui-core/hooks';
10
11
  import {
11
12
  Bold, Italic, Strikethrough, Heading1, Heading2, Heading3,
12
13
  List, ListOrdered, Quote, Minus, Code, type LucideIcon,
@@ -125,6 +126,17 @@ export interface MarkdownEditorProps {
125
126
  * empty draft".
126
127
  */
127
128
  onSubmit?: () => boolean | void;
129
+ /**
130
+ * Focus the editor on mount. Pair with `key={file}` upstream when the
131
+ * host wants a fresh focus per file change (inspector / editor tab).
132
+ */
133
+ autoFocus?: boolean;
134
+ /**
135
+ * Called when the user presses Cmd/Ctrl+S inside the editor. Receives
136
+ * the current markdown. The browser's "save page" default is suppressed
137
+ * only when this handler is supplied — otherwise Cmd+S falls through.
138
+ */
139
+ onSave?: (markdown: string) => void;
128
140
  }
129
141
 
130
142
  /**
@@ -159,6 +171,8 @@ export const MarkdownEditor = forwardRef<MarkdownEditorHandle, MarkdownEditorPro
159
171
  slashCommands,
160
172
  onMentionIdsChange,
161
173
  onSubmit,
174
+ autoFocus = false,
175
+ onSave,
162
176
  },
163
177
  ref,
164
178
  ) {
@@ -380,6 +394,32 @@ export const MarkdownEditor = forwardRef<MarkdownEditorHandle, MarkdownEditorPro
380
394
  editor?.setEditable(!disabled);
381
395
  }, [editor, disabled]);
382
396
 
397
+ // Declarative autoFocus — runs once the editor instance exists. Hosts
398
+ // that want per-file focus reset should pair this with `key={path}`.
399
+ useEffect(() => {
400
+ if (!autoFocus || !editor) return;
401
+ editor.commands.focus('end');
402
+ }, [autoFocus, editor]);
403
+
404
+ // Cmd/Ctrl+S → save. Uses the shared `useHotkey` (inInput=true by
405
+ // default for modifier combos). Guarded to fire only when focus is
406
+ // inside this editor's ProseMirror DOM, so multiple sibling editors
407
+ // don't all fire on one chord.
408
+ const onSaveRef = useRef(onSave);
409
+ onSaveRef.current = onSave;
410
+ useHotkey(
411
+ 'mod+s',
412
+ () => {
413
+ const h = onSaveRef.current;
414
+ if (!h || !editor) return;
415
+ const dom = editor.view.dom;
416
+ const active = document.activeElement;
417
+ if (!active || !dom.contains(active)) return;
418
+ h(getMarkdown(editor));
419
+ },
420
+ { enabled: !!editor && !!onSave },
421
+ );
422
+
383
423
  // Imperative API for hosts that drive the editor without owning a
384
424
  // TipTap ref directly — chat composer registration, voice slot,
385
425
  // focus-on-stream-end.
@@ -2,49 +2,88 @@
2
2
  outline: none;
3
3
  }
4
4
 
5
- /* Inherit semantic foreground so the editor renders correctly in both
6
- light + dark themes (and under any active preset). Without this the
7
- browser falls back to UA black on a token-driven background. */
5
+ /* ─────────────────────────────────────────────────────────────────────
6
+ * Typography Notion-inspired: comfy line-height, generous heading
7
+ * scale, restrained spacing. Calibrated so the chat composer (compact,
8
+ * single-paragraph) and the document-preview editor (long-form, with
9
+ * many headings) both feel right without a variant prop.
10
+ *
11
+ * Inherit semantic foreground so the editor renders correctly in both
12
+ * light + dark themes (and under any active preset).
13
+ * ───────────────────────────────────────────────────────────────────── */
8
14
  .markdown-editor .tiptap,
9
15
  .markdown-editor .ProseMirror {
10
16
  color: var(--color-foreground, var(--foreground));
17
+ font-family:
18
+ -apple-system, BlinkMacSystemFont, "SF Pro Text", "Segoe UI", Roboto,
19
+ "Helvetica Neue", Arial, sans-serif;
20
+ font-size: 15px;
21
+ line-height: 1.6;
22
+ -webkit-font-smoothing: antialiased;
23
+ -moz-osx-font-smoothing: grayscale;
24
+ text-rendering: optimizeLegibility;
11
25
  }
12
26
 
13
27
  .markdown-editor .tiptap a,
14
28
  .markdown-editor .ProseMirror a {
15
29
  color: var(--color-primary, var(--primary));
16
30
  text-decoration: underline;
31
+ text-underline-offset: 2px;
32
+ text-decoration-thickness: 1px;
17
33
  }
18
34
 
35
+ .markdown-editor .tiptap a:hover,
36
+ .markdown-editor .ProseMirror a:hover {
37
+ text-decoration-thickness: 2px;
38
+ }
39
+
40
+ /* Headings — Notion scale. Top-padding > bottom so a heading sits
41
+ * visually grouped with the paragraph below it. */
19
42
  .markdown-editor .tiptap h1 {
20
- font-size: 1.5em;
43
+ font-size: 1.875em;
21
44
  font-weight: 700;
22
- margin: 0.5em 0 0.25em;
23
- line-height: 1.3;
45
+ line-height: 1.25;
46
+ margin: 1em 0 0.25em;
47
+ letter-spacing: -0.015em;
24
48
  }
25
49
 
26
50
  .markdown-editor .tiptap h2 {
27
- font-size: 1.25em;
51
+ font-size: 1.5em;
28
52
  font-weight: 600;
29
- margin: 0.5em 0 0.25em;
30
53
  line-height: 1.3;
54
+ margin: 0.9em 0 0.25em;
55
+ letter-spacing: -0.01em;
31
56
  }
32
57
 
33
58
  .markdown-editor .tiptap h3 {
59
+ font-size: 1.25em;
60
+ font-weight: 600;
61
+ line-height: 1.35;
62
+ margin: 0.8em 0 0.2em;
63
+ }
64
+
65
+ .markdown-editor .tiptap h4 {
34
66
  font-size: 1.1em;
35
67
  font-weight: 600;
36
- margin: 0.4em 0 0.2em;
37
- line-height: 1.3;
68
+ line-height: 1.4;
69
+ margin: 0.7em 0 0.2em;
70
+ }
71
+
72
+ /* First heading at the very top of the document has no leading air —
73
+ * Notion convention. */
74
+ .markdown-editor .tiptap > :is(h1, h2, h3, h4):first-child {
75
+ margin-top: 0;
38
76
  }
39
77
 
40
78
  .markdown-editor .tiptap p {
41
79
  margin: 0.25em 0;
42
80
  }
43
81
 
82
+ /* Lists — Notion uses tighter row spacing than browser default. */
44
83
  .markdown-editor .tiptap ul,
45
84
  .markdown-editor .tiptap ol {
46
85
  padding-left: 1.5em;
47
- margin: 0.25em 0;
86
+ margin: 0.35em 0;
48
87
  }
49
88
 
50
89
  .markdown-editor .tiptap ul {
@@ -56,29 +95,138 @@
56
95
  }
57
96
 
58
97
  .markdown-editor .tiptap li {
59
- margin: 0.1em 0;
98
+ margin: 0.15em 0;
99
+ }
100
+
101
+ .markdown-editor .tiptap li > p {
102
+ margin: 0;
103
+ }
104
+
105
+ /* Nested lists get a subtler marker. */
106
+ .markdown-editor .tiptap ul ul {
107
+ list-style: circle;
108
+ }
109
+ .markdown-editor .tiptap ul ul ul {
110
+ list-style: square;
111
+ }
112
+
113
+ /* Task list (GFM `- [ ] item`). StarterKit ships TaskList extension. */
114
+ .markdown-editor .tiptap ul[data-type="taskList"] {
115
+ list-style: none;
116
+ padding-left: 0.25em;
117
+ }
118
+
119
+ .markdown-editor .tiptap ul[data-type="taskList"] li {
120
+ display: flex;
121
+ align-items: flex-start;
122
+ gap: 0.5em;
123
+ }
124
+
125
+ .markdown-editor .tiptap ul[data-type="taskList"] li > label {
126
+ flex-shrink: 0;
127
+ margin-top: 0.3em;
128
+ user-select: none;
129
+ }
130
+
131
+ .markdown-editor .tiptap ul[data-type="taskList"] li > div {
132
+ flex: 1;
133
+ min-width: 0;
134
+ }
135
+
136
+ .markdown-editor .tiptap ul[data-type="taskList"] li[data-checked="true"] > div {
137
+ opacity: 0.55;
138
+ text-decoration: line-through;
60
139
  }
61
140
 
141
+ /* Blockquote — Notion uses a chunky left bar without italic. */
62
142
  .markdown-editor .tiptap blockquote {
63
143
  border-left: 3px solid var(--color-border, var(--border));
64
- padding-left: 1em;
65
- margin: 0.5em 0;
66
- opacity: 0.8;
144
+ padding: 0.2em 0 0.2em 1em;
145
+ margin: 0.6em 0;
146
+ color: color-mix(in oklab, var(--color-foreground, var(--foreground)) 80%, transparent);
147
+ }
148
+
149
+ .markdown-editor .tiptap blockquote > :first-child {
150
+ margin-top: 0;
151
+ }
152
+ .markdown-editor .tiptap blockquote > :last-child {
153
+ margin-bottom: 0;
67
154
  }
68
155
 
156
+ /* Divider — full-width thin rule with breathing room. */
69
157
  .markdown-editor .tiptap hr {
70
158
  border: none;
71
159
  border-top: 1px solid var(--color-border, var(--border));
72
- margin: 0.75em 0;
160
+ margin: 1.5em 0;
73
161
  }
74
162
 
163
+ /* Inline code — monospace pill, restrained tint. */
75
164
  .markdown-editor .tiptap code {
165
+ background: color-mix(in oklab, var(--color-muted, var(--muted)) 70%, transparent);
166
+ color: var(--color-foreground, var(--foreground));
167
+ padding: 0.15em 0.35em;
168
+ border-radius: 4px;
169
+ font-size: 0.875em;
170
+ font-family:
171
+ "SF Mono", ui-monospace, "JetBrains Mono", "Fira Code", Menlo, Consolas,
172
+ monospace;
173
+ border: 1px solid color-mix(in oklab, var(--color-border, var(--border)) 60%, transparent);
174
+ }
175
+
176
+ /* Code block — darker surface so it stands apart from inline code. */
177
+ .markdown-editor .tiptap pre {
76
178
  background: var(--color-muted, var(--muted));
77
- color: var(--color-muted-foreground, var(--muted-foreground));
78
- padding: 0.15em 0.3em;
79
- border-radius: 0.25em;
80
- font-size: 0.9em;
81
- font-family: monospace;
179
+ color: var(--color-foreground, var(--foreground));
180
+ padding: 0.9em 1em;
181
+ border-radius: 6px;
182
+ margin: 0.75em 0;
183
+ font-family:
184
+ "SF Mono", ui-monospace, "JetBrains Mono", "Fira Code", Menlo, Consolas,
185
+ monospace;
186
+ font-size: 0.875em;
187
+ line-height: 1.55;
188
+ overflow-x: auto;
189
+ border: 1px solid color-mix(in oklab, var(--color-border, var(--border)) 50%, transparent);
190
+ }
191
+
192
+ .markdown-editor .tiptap pre code {
193
+ background: transparent;
194
+ border: none;
195
+ padding: 0;
196
+ border-radius: 0;
197
+ font-size: inherit;
198
+ color: inherit;
199
+ }
200
+
201
+ /* Tables — borderless, subtle row separators (Notion-flavour). */
202
+ .markdown-editor .tiptap table {
203
+ border-collapse: collapse;
204
+ width: 100%;
205
+ margin: 0.75em 0;
206
+ font-size: 0.95em;
207
+ table-layout: fixed;
208
+ }
209
+
210
+ .markdown-editor .tiptap th,
211
+ .markdown-editor .tiptap td {
212
+ border: 1px solid var(--color-border, var(--border));
213
+ padding: 0.45em 0.75em;
214
+ vertical-align: top;
215
+ text-align: left;
216
+ min-width: 4ch;
217
+ }
218
+
219
+ .markdown-editor .tiptap th {
220
+ background: color-mix(in oklab, var(--color-muted, var(--muted)) 60%, transparent);
221
+ font-weight: 600;
222
+ }
223
+
224
+ /* Images — rounded, never overflow. */
225
+ .markdown-editor .tiptap img {
226
+ max-width: 100%;
227
+ height: auto;
228
+ border-radius: 6px;
229
+ margin: 0.5em 0;
82
230
  }
83
231
 
84
232
  .markdown-editor .tiptap strong {
@@ -93,6 +241,11 @@
93
241
  text-decoration: line-through;
94
242
  }
95
243
 
244
+ /* Selection highlight — semantic ring colour, not browser-blue. */
245
+ .markdown-editor .tiptap ::selection {
246
+ background: color-mix(in oklab, var(--color-primary, var(--primary)) 25%, transparent);
247
+ }
248
+
96
249
  .markdown-editor .tiptap p.is-editor-empty:first-child::before {
97
250
  content: attr(data-placeholder);
98
251
  float: left;
@@ -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
+ }