@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.
- package/dist/audio-player/index.cjs +1 -2
- package/dist/audio-player/index.cjs.map +1 -1
- package/dist/audio-player/index.d.cts +3 -11
- package/dist/audio-player/index.d.ts +3 -11
- package/dist/audio-player/index.mjs +1 -2
- package/dist/audio-player/index.mjs.map +1 -1
- package/dist/tree/index.cjs +0 -3
- package/dist/tree/index.cjs.map +1 -1
- package/dist/tree/index.mjs +0 -3
- package/dist/tree/index.mjs.map +1 -1
- package/package.json +30 -14
- package/src/tools/data/Tree/components/TreeRow.tsx +0 -11
- package/src/tools/forms/CodeEditor/components/Editor.tsx +19 -0
- package/src/tools/forms/CodeEditor/types/index.ts +7 -0
- package/src/tools/forms/MarkdownEditor/MarkdownEditor.tsx +40 -0
- package/src/tools/forms/MarkdownEditor/styles.css +174 -21
- package/src/tools/forms/NotionEditor/CustomKeymap.ts +48 -0
- package/src/tools/forms/NotionEditor/LinkDialog.tsx +133 -0
- package/src/tools/forms/NotionEditor/NotionEditor.tsx +304 -0
- package/src/tools/forms/NotionEditor/SlashExtension.ts +32 -0
- package/src/tools/forms/NotionEditor/SlashList.tsx +136 -0
- package/src/tools/forms/NotionEditor/TaskItemView.tsx +41 -0
- package/src/tools/forms/NotionEditor/createSlashSuggestion.ts +121 -0
- package/src/tools/forms/NotionEditor/extensions.ts +105 -0
- package/src/tools/forms/NotionEditor/index.ts +1 -0
- package/src/tools/forms/NotionEditor/lazy.tsx +44 -0
- package/src/tools/forms/NotionEditor/slashItems.ts +159 -0
- package/src/tools/forms/NotionEditor/styles.css +478 -0
- package/src/tools/forms/NotionEditor/types.ts +28 -0
- package/src/tools/media/AudioPlayer/PlayerShell.tsx +3 -11
- package/src/tools/media/AudioPlayer/types.ts +4 -11
- package/src/tools/media/ImageViewer/components/ImageViewer.tsx +8 -0
- package/src/tools/media/ImageViewer/types.ts +4 -0
- package/src/tools/media/VideoPlayer/VideoPlayer.tsx +20 -1
- 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.
|
|
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.
|
|
263
|
-
"@djangocfg/ui-core": "^2.1.
|
|
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.
|
|
283
|
-
"@tiptap/extension-
|
|
284
|
-
"@tiptap/extension-
|
|
285
|
-
"@tiptap/
|
|
286
|
-
"@tiptap/
|
|
287
|
-
"@tiptap/
|
|
288
|
-
"@tiptap/
|
|
289
|
-
"@tiptap/
|
|
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.
|
|
328
|
-
"@djangocfg/typescript-config": "^2.1.
|
|
329
|
-
"@djangocfg/ui-core": "^2.1.
|
|
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
|
-
/*
|
|
6
|
-
|
|
7
|
-
|
|
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.
|
|
43
|
+
font-size: 1.875em;
|
|
21
44
|
font-weight: 700;
|
|
22
|
-
|
|
23
|
-
|
|
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.
|
|
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
|
-
|
|
37
|
-
|
|
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.
|
|
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.
|
|
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
|
|
65
|
-
margin: 0.
|
|
66
|
-
|
|
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:
|
|
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-
|
|
78
|
-
padding: 0.
|
|
79
|
-
border-radius:
|
|
80
|
-
|
|
81
|
-
font-family:
|
|
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
|
+
}
|