@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.
Files changed (57) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +40 -0
  3. package/package.json +121 -0
  4. package/src/blocks/BlockError.tsx +46 -0
  5. package/src/blocks/index.ts +34 -0
  6. package/src/blocks/schemas.ts +143 -0
  7. package/src/blocks/types.ts +76 -0
  8. package/src/chips/README.md +67 -0
  9. package/src/chips/UrlChip.tsx +105 -0
  10. package/src/chips/index.ts +20 -0
  11. package/src/index.ts +12 -0
  12. package/src/link-preview/LinkPreviewCard.tsx +178 -0
  13. package/src/link-preview/index.ts +12 -0
  14. package/src/link-preview/types.ts +44 -0
  15. package/src/markdown/MarkdownEditor.tsx +700 -0
  16. package/src/markdown/MentionList.tsx +106 -0
  17. package/src/markdown/README.md +269 -0
  18. package/src/markdown/chip/ChipNode.ts +526 -0
  19. package/src/markdown/chip/syncChips.ts +80 -0
  20. package/src/markdown/createMentionSuggestion.ts +131 -0
  21. package/src/markdown/filePath/FilePathChip.tsx +68 -0
  22. package/src/markdown/filePath/detect.ts +289 -0
  23. package/src/markdown/filePath/index.ts +15 -0
  24. package/src/markdown/index.ts +29 -0
  25. package/src/markdown/lazy.tsx +88 -0
  26. package/src/markdown/markdown-editor.stories.tsx +500 -0
  27. package/src/markdown/mentionPresets.ts +49 -0
  28. package/src/markdown/slash/SlashCommandNode.ts +162 -0
  29. package/src/markdown/slash/index.ts +4 -0
  30. package/src/markdown/slash/syncSlashNode.ts +97 -0
  31. package/src/markdown/slash/types.ts +14 -0
  32. package/src/markdown/styles.css +522 -0
  33. package/src/markdown/submitOnEnter.ts +83 -0
  34. package/src/markdown/types.ts +50 -0
  35. package/src/markdown/url/detect.ts +227 -0
  36. package/src/notion/BookmarkNode.ts +161 -0
  37. package/src/notion/BookmarkView.tsx +45 -0
  38. package/src/notion/CustomKeymap.ts +48 -0
  39. package/src/notion/LinkDialog.tsx +133 -0
  40. package/src/notion/MapEditLayer.tsx +199 -0
  41. package/src/notion/MapNode.ts +220 -0
  42. package/src/notion/MapView.tsx +103 -0
  43. package/src/notion/NotionEditor.tsx +309 -0
  44. package/src/notion/README.md +158 -0
  45. package/src/notion/SlashExtension.ts +32 -0
  46. package/src/notion/SlashList.tsx +136 -0
  47. package/src/notion/TaskItemView.tsx +41 -0
  48. package/src/notion/createSlashSuggestion.ts +121 -0
  49. package/src/notion/extensions.ts +120 -0
  50. package/src/notion/index.ts +1 -0
  51. package/src/notion/lazy.tsx +44 -0
  52. package/src/notion/notion-editor.stories.tsx +293 -0
  53. package/src/notion/slashItems.ts +178 -0
  54. package/src/notion/styles.css +503 -0
  55. package/src/notion/types.ts +37 -0
  56. package/src/tiptap/index.ts +10 -0
  57. package/src/tiptap/useNodeAttrs.ts +128 -0
@@ -0,0 +1,178 @@
1
+ import {
2
+ CheckSquare,
3
+ Code,
4
+ Heading1,
5
+ Heading2,
6
+ Heading3,
7
+ List,
8
+ ListOrdered,
9
+ Map as MapIcon,
10
+ Minus,
11
+ Quote,
12
+ Table as TableIcon,
13
+ Text,
14
+ type LucideIcon,
15
+ } from 'lucide-react';
16
+ import type { Editor, Range } from '@tiptap/react';
17
+ import { DEFAULT_MAP_PAYLOAD } from './MapNode';
18
+
19
+ export interface SlashItem {
20
+ title: string;
21
+ description: string;
22
+ icon: LucideIcon;
23
+ /** Markdown shorthand hint shown right-aligned in the menu — teaches
24
+ * the user that they can also type this directly (e.g. `#`, `- [ ]`). */
25
+ hint?: string;
26
+ /** Extra search aliases (besides `title`). */
27
+ aliases?: string[];
28
+ command: (ctx: { editor: Editor; range: Range }) => void;
29
+ }
30
+
31
+ /**
32
+ * Canonical Notion-style slash menu. Each command runs `deleteRange(range)`
33
+ * first so the typed `/verb` text is removed, then applies the block
34
+ * transformation. `focus()` keeps the caret in the editor after the
35
+ * popover closes.
36
+ */
37
+ export const slashItems: SlashItem[] = [
38
+ {
39
+ title: 'Text',
40
+ description: 'Plain paragraph.',
41
+ icon: Text,
42
+ aliases: ['p', 'paragraph'],
43
+ command: ({ editor, range }) => {
44
+ editor.chain().focus().deleteRange(range).setNode('paragraph').run();
45
+ },
46
+ },
47
+ {
48
+ title: 'Heading 1',
49
+ description: 'Big section heading.',
50
+ icon: Heading1,
51
+ hint: '#',
52
+ aliases: ['h1', 'title'],
53
+ command: ({ editor, range }) => {
54
+ editor.chain().focus().deleteRange(range).setNode('heading', { level: 1 }).run();
55
+ },
56
+ },
57
+ {
58
+ title: 'Heading 2',
59
+ description: 'Medium section heading.',
60
+ icon: Heading2,
61
+ hint: '##',
62
+ aliases: ['h2', 'subtitle'],
63
+ command: ({ editor, range }) => {
64
+ editor.chain().focus().deleteRange(range).setNode('heading', { level: 2 }).run();
65
+ },
66
+ },
67
+ {
68
+ title: 'Heading 3',
69
+ description: 'Small section heading.',
70
+ icon: Heading3,
71
+ hint: '###',
72
+ aliases: ['h3'],
73
+ command: ({ editor, range }) => {
74
+ editor.chain().focus().deleteRange(range).setNode('heading', { level: 3 }).run();
75
+ },
76
+ },
77
+ {
78
+ title: 'Bullet list',
79
+ description: 'Simple unordered list.',
80
+ icon: List,
81
+ hint: '- ',
82
+ aliases: ['ul', 'unordered'],
83
+ command: ({ editor, range }) => {
84
+ editor.chain().focus().deleteRange(range).toggleBulletList().run();
85
+ },
86
+ },
87
+ {
88
+ title: 'Numbered list',
89
+ description: 'List with auto-numbering.',
90
+ icon: ListOrdered,
91
+ hint: '1. ',
92
+ aliases: ['ol', 'ordered', 'numbered'],
93
+ command: ({ editor, range }) => {
94
+ editor.chain().focus().deleteRange(range).toggleOrderedList().run();
95
+ },
96
+ },
97
+ {
98
+ title: 'To-do list',
99
+ description: 'Tasks with checkboxes.',
100
+ icon: CheckSquare,
101
+ hint: '- [ ]',
102
+ aliases: ['todo', 'task', 'checkbox', 'check'],
103
+ command: ({ editor, range }) => {
104
+ editor.chain().focus().deleteRange(range).toggleTaskList().run();
105
+ },
106
+ },
107
+ {
108
+ title: 'Quote',
109
+ description: 'Capture a quote.',
110
+ icon: Quote,
111
+ hint: '> ',
112
+ aliases: ['blockquote'],
113
+ command: ({ editor, range }) => {
114
+ editor.chain().focus().deleteRange(range).toggleBlockquote().run();
115
+ },
116
+ },
117
+ {
118
+ title: 'Code block',
119
+ description: 'Syntax-highlighted code.',
120
+ icon: Code,
121
+ hint: '```',
122
+ aliases: ['codeblock', 'pre'],
123
+ command: ({ editor, range }) => {
124
+ editor.chain().focus().deleteRange(range).toggleCodeBlock().run();
125
+ },
126
+ },
127
+ {
128
+ title: 'Divider',
129
+ description: 'Horizontal rule.',
130
+ icon: Minus,
131
+ hint: '---',
132
+ aliases: ['hr', 'rule', 'separator'],
133
+ command: ({ editor, range }) => {
134
+ editor.chain().focus().deleteRange(range).setHorizontalRule().run();
135
+ },
136
+ },
137
+ {
138
+ title: 'Table',
139
+ description: '3×3 starter table.',
140
+ icon: TableIcon,
141
+ aliases: ['grid'],
142
+ command: ({ editor, range }) => {
143
+ editor
144
+ .chain()
145
+ .focus()
146
+ .deleteRange(range)
147
+ .insertTable({ rows: 3, cols: 3, withHeaderRow: true })
148
+ .run();
149
+ },
150
+ },
151
+ {
152
+ title: 'Map',
153
+ description: 'Interactive map block.',
154
+ icon: MapIcon,
155
+ aliases: ['map', 'location', 'geo', 'pin'],
156
+ command: ({ editor, range }) => {
157
+ // Drop the typed `/map`, then insert a map block seeded with a sensible
158
+ // default payload so the user sees a live map immediately. Attrs are
159
+ // plain JSON and round-trip through markdown (see MapNode).
160
+ editor
161
+ .chain()
162
+ .focus()
163
+ .deleteRange(range)
164
+ .setMapBlock({ ...DEFAULT_MAP_PAYLOAD })
165
+ .run();
166
+ },
167
+ },
168
+ ];
169
+
170
+ /** Case-insensitive filter over title + aliases. */
171
+ export function filterSlashItems(query: string): SlashItem[] {
172
+ const q = query.trim().toLowerCase();
173
+ if (!q) return slashItems;
174
+ return slashItems.filter((item) => {
175
+ if (item.title.toLowerCase().includes(q)) return true;
176
+ return item.aliases?.some((a) => a.toLowerCase().includes(q)) ?? false;
177
+ });
178
+ }
@@ -0,0 +1,503 @@
1
+ /* ─────────────────────────────────────────────────────────────────────
2
+ * NotionEditor — Notion-style WYSIWYG. Reuses the typography decisions
3
+ * from MarkdownEditor's `styles.css` (font, heading scale, list spacing,
4
+ * code styling) but adds:
5
+ * - drag-handle gutter + grabber styling
6
+ * - bubble-menu floating toolbar
7
+ * - slash-menu popover
8
+ * - table cell selection ring
9
+ * - code-block lowlight token colours (Prism-compatible)
10
+ * ───────────────────────────────────────────────────────────────────── */
11
+
12
+ .notion-editor {
13
+ position: relative;
14
+ width: 100%;
15
+ }
16
+
17
+ .notion-editor .notion-editor-content {
18
+ outline: none;
19
+ }
20
+
21
+ .notion-editor .tiptap,
22
+ .notion-editor .ProseMirror {
23
+ color: var(--color-foreground, var(--foreground));
24
+ font-family:
25
+ -apple-system, BlinkMacSystemFont, "SF Pro Text", "Segoe UI", Roboto,
26
+ "Helvetica Neue", Arial, sans-serif;
27
+ font-size: 16px;
28
+ line-height: 1.65;
29
+ -webkit-font-smoothing: antialiased;
30
+ -moz-osx-font-smoothing: grayscale;
31
+ text-rendering: optimizeLegibility;
32
+ /* Left padding leaves room for the drag-handle gutter so it never
33
+ * overlaps with text. */
34
+ padding-left: 32px;
35
+ padding-right: 8px;
36
+ }
37
+
38
+ /* Empty placeholder. Tiptap's Placeholder extension stamps
39
+ * `is-editor-empty` on the doc when nothing has been typed, plus
40
+ * `is-empty` + `data-placeholder` on the active empty node. We match
41
+ * BOTH conditions so the ghost prompt shows on a brand-new editor and
42
+ * on whichever empty block the caret is parked in — but never on
43
+ * unrelated empty blocks (that would look like literal text content). */
44
+ .notion-editor .tiptap .is-editor-empty:first-child::before,
45
+ .notion-editor .tiptap .ProseMirror-focused > .is-empty::before {
46
+ content: attr(data-placeholder);
47
+ float: left;
48
+ height: 0;
49
+ pointer-events: none;
50
+ opacity: 0.35;
51
+ font-weight: 400;
52
+ }
53
+
54
+ /* Links */
55
+ .notion-editor .tiptap a {
56
+ color: var(--color-primary, var(--primary));
57
+ text-decoration: underline;
58
+ text-underline-offset: 2px;
59
+ }
60
+
61
+ /* …but block-card NodeViews (bookmark/map) own their styling — the card is
62
+ itself an <a>, and its inner title/description must NOT inherit the editor
63
+ link underline (it read as if the whole card text were a link). */
64
+ .notion-editor .tiptap .notion-bookmark a,
65
+ .notion-editor .tiptap a.notion-bookmark {
66
+ color: inherit;
67
+ text-decoration: none;
68
+ }
69
+
70
+ /* Headings */
71
+ .notion-editor .tiptap h1 {
72
+ font-size: 2em;
73
+ font-weight: 700;
74
+ line-height: 1.2;
75
+ margin: 1em 0 0.3em;
76
+ letter-spacing: -0.02em;
77
+ }
78
+ .notion-editor .tiptap h2 {
79
+ font-size: 1.5em;
80
+ font-weight: 600;
81
+ line-height: 1.3;
82
+ margin: 0.9em 0 0.25em;
83
+ letter-spacing: -0.01em;
84
+ }
85
+ .notion-editor .tiptap h3 {
86
+ font-size: 1.25em;
87
+ font-weight: 600;
88
+ line-height: 1.35;
89
+ margin: 0.8em 0 0.2em;
90
+ }
91
+ .notion-editor .tiptap h4 {
92
+ font-size: 1.1em;
93
+ font-weight: 600;
94
+ line-height: 1.4;
95
+ margin: 0.7em 0 0.2em;
96
+ }
97
+ .notion-editor .tiptap > :is(h1, h2, h3, h4):first-child {
98
+ margin-top: 0;
99
+ }
100
+
101
+ .notion-editor .tiptap p {
102
+ margin: 0.25em 0;
103
+ }
104
+
105
+ /* Lists */
106
+ .notion-editor .tiptap ul,
107
+ .notion-editor .tiptap ol {
108
+ padding-left: 1.5em;
109
+ margin: 0.35em 0;
110
+ }
111
+ .notion-editor .tiptap ul {
112
+ list-style: disc;
113
+ }
114
+ .notion-editor .tiptap ol {
115
+ list-style: decimal;
116
+ }
117
+ .notion-editor .tiptap ul ul {
118
+ list-style: circle;
119
+ }
120
+ .notion-editor .tiptap ul ul ul {
121
+ list-style: square;
122
+ }
123
+ .notion-editor .tiptap li {
124
+ margin: 0.15em 0;
125
+ }
126
+ .notion-editor .tiptap li > p {
127
+ margin: 0;
128
+ }
129
+
130
+ /* Task list — backed by a React NodeView that mounts our ui-core
131
+ * <Checkbox>. Selectors target the wrapper class set by `TaskItemView`.
132
+ *
133
+ * Alignment math (the part that always goes wrong):
134
+ * editor font-size: 16px
135
+ * line-height: 1.65 → line box ≈ 26.4px
136
+ * cap-height of text: ≈ 11.5px, sits ≈ 7.5px from line top
137
+ * checkbox box: 16px square (ui-core default)
138
+ * ⇒ to centre the checkbox on the cap-height row, top = (26.4 − 16) / 2 ≈ 5px
139
+ *
140
+ * Using `align-items: flex-start` + a fixed `margin-top` keeps multi-line
141
+ * task items working: the checkbox stays parked at the top of the first
142
+ * line and the text wraps freely below. `align-items: center` would
143
+ * recentre on multi-line items and drift the checkbox into the middle. */
144
+ .notion-editor .tiptap ul[data-type="taskList"] {
145
+ list-style: none;
146
+ padding-left: 0;
147
+ margin-left: 0;
148
+ }
149
+ .notion-editor .tiptap .notion-task-item {
150
+ display: flex;
151
+ /* `align-items: baseline` lets the checkbox sit on the *text baseline*
152
+ * of the first line — independent of checkbox size, font-size, or
153
+ * line-height. Works correctly across themes / presets that scale the
154
+ * Checkbox token (macOS vs default). For multi-line items the checkbox
155
+ * still anchors to the first line (because baseline is per-line) which
156
+ * is what Notion does. Earlier `align-items: flex-start + margin-top:Xpx`
157
+ * was brittle: it assumed a fixed checkbox size + line-height. */
158
+ align-items: baseline;
159
+ gap: 0.5em;
160
+ padding: 2px 0;
161
+ }
162
+ .notion-editor .tiptap .notion-task-checkbox {
163
+ flex-shrink: 0;
164
+ /* `translateY(2px)` nudges the optical centre of the checkbox box onto
165
+ * the cap-height line. Without this the checkbox sits visually a bit
166
+ * high because its bounding box centres on the baseline, not cap-height.
167
+ * Tiny constant — independent of checkbox / font size. */
168
+ transform: translateY(2px);
169
+ }
170
+ .notion-editor .tiptap .notion-task-text {
171
+ flex: 1;
172
+ min-width: 0;
173
+ }
174
+ .notion-editor .tiptap .notion-task-item[data-checked] .notion-task-text {
175
+ opacity: 0.55;
176
+ text-decoration: line-through;
177
+ }
178
+
179
+ /* ── Bookmark (link-preview) + Map block nodes ────────────────────────────
180
+ * Block-level NodeViews rendering shared cards (LinkPreviewCard / the map).
181
+ * The card / map owns its own frame (radius, border, shadow); here we only
182
+ * own the block's vertical rhythm so it breathes like a paragraph and the
183
+ * ProseMirror-selectednode ring reads cleanly on the atom. */
184
+ .notion-editor .tiptap .notion-bookmark,
185
+ .notion-editor .tiptap .notion-map {
186
+ margin: 0.75em 0;
187
+ }
188
+ .notion-editor .tiptap .notion-bookmark.ProseMirror-selectednode,
189
+ .notion-editor .tiptap .notion-map.ProseMirror-selectednode {
190
+ outline: 2px solid var(--ring);
191
+ outline-offset: 2px;
192
+ border-radius: var(--radius, 0.5rem);
193
+ }
194
+
195
+ /* Blockquote */
196
+ .notion-editor .tiptap blockquote {
197
+ border-left: 3px solid var(--color-border, var(--border));
198
+ padding: 0.2em 0 0.2em 1em;
199
+ margin: 0.6em 0;
200
+ color: color-mix(in oklab, var(--color-foreground, var(--foreground)) 80%, transparent);
201
+ }
202
+ .notion-editor .tiptap blockquote > :first-child { margin-top: 0; }
203
+ .notion-editor .tiptap blockquote > :last-child { margin-bottom: 0; }
204
+
205
+ /* Divider */
206
+ .notion-editor .tiptap hr {
207
+ border: none;
208
+ border-top: 1px solid var(--color-border, var(--border));
209
+ margin: 1.5em 0;
210
+ }
211
+
212
+ /* Inline code */
213
+ .notion-editor .tiptap code {
214
+ background: color-mix(in oklab, var(--color-muted, var(--muted)) 70%, transparent);
215
+ color: var(--color-foreground, var(--foreground));
216
+ padding: 0.15em 0.35em;
217
+ border-radius: 4px;
218
+ font-size: 0.875em;
219
+ font-family: "SF Mono", ui-monospace, "JetBrains Mono", "Fira Code", Menlo, Consolas, monospace;
220
+ border: 1px solid color-mix(in oklab, var(--color-border, var(--border)) 60%, transparent);
221
+ }
222
+
223
+ /* Code block (lowlight) — always dark, regardless of host theme.
224
+ * Rationale: syntax highlighting palettes are calibrated for dark
225
+ * backgrounds; a "light-on-light" code block on a light app theme looks
226
+ * washed out and reads worse than the same block on dark. Notion / Linear
227
+ * / GitHub all force-dark code blocks on light themes too. We hardcode
228
+ * a One Dark-flavour surface and let the token colours below sit on it. */
229
+ .notion-editor .tiptap pre {
230
+ background: #1e2127;
231
+ color: #d7dae0;
232
+ padding: 0.9em 1em;
233
+ border-radius: 6px;
234
+ margin: 0.75em 0;
235
+ font-family: "SF Mono", ui-monospace, "JetBrains Mono", "Fira Code", Menlo, Consolas, monospace;
236
+ font-size: 0.875em;
237
+ line-height: 1.55;
238
+ overflow-x: auto;
239
+ border: 1px solid rgba(255, 255, 255, 0.06);
240
+ }
241
+ .notion-editor .tiptap pre code {
242
+ background: transparent;
243
+ border: none;
244
+ padding: 0;
245
+ border-radius: 0;
246
+ font-size: inherit;
247
+ color: inherit;
248
+ }
249
+
250
+ /* Lowlight token colours — One Dark Pro palette. `@tiptap/extension-code-
251
+ * block-lowlight` emits `.hljs-*` class names; we colour against the fixed
252
+ * dark surface above so the palette stays readable on every host theme. */
253
+ .notion-editor .hljs-comment,
254
+ .notion-editor .hljs-quote { color: #5c6370; font-style: italic; }
255
+ .notion-editor .hljs-keyword,
256
+ .notion-editor .hljs-selector-tag,
257
+ .notion-editor .hljs-built_in { color: #c678dd; }
258
+ .notion-editor .hljs-string,
259
+ .notion-editor .hljs-attr { color: #98c379; }
260
+ .notion-editor .hljs-number,
261
+ .notion-editor .hljs-literal { color: #d19a66; }
262
+ .notion-editor .hljs-function .hljs-title,
263
+ .notion-editor .hljs-title.function_ { color: #61afef; }
264
+ .notion-editor .hljs-class .hljs-title,
265
+ .notion-editor .hljs-type { color: #e5c07b; }
266
+ .notion-editor .hljs-tag,
267
+ .notion-editor .hljs-name { color: #e06c75; }
268
+ .notion-editor .hljs-attribute { color: #d19a66; }
269
+ .notion-editor .hljs-variable,
270
+ .notion-editor .hljs-template-variable { color: #e06c75; }
271
+ .notion-editor .hljs-symbol,
272
+ .notion-editor .hljs-bullet,
273
+ .notion-editor .hljs-link { color: #56b6c2; }
274
+ .notion-editor .hljs-meta { color: #61afef; }
275
+ .notion-editor .hljs-deletion { background: rgba(224, 108, 117, 0.2); }
276
+ .notion-editor .hljs-addition { background: rgba(152, 195, 121, 0.2); }
277
+
278
+ /* Tables */
279
+ .notion-editor .tiptap table {
280
+ border-collapse: collapse;
281
+ width: 100%;
282
+ margin: 0.75em 0;
283
+ font-size: 0.95em;
284
+ table-layout: fixed;
285
+ }
286
+ .notion-editor .tiptap th,
287
+ .notion-editor .tiptap td {
288
+ border: 1px solid var(--color-border, var(--border));
289
+ padding: 0.45em 0.75em;
290
+ vertical-align: top;
291
+ text-align: left;
292
+ min-width: 4ch;
293
+ position: relative;
294
+ }
295
+ .notion-editor .tiptap th {
296
+ background: color-mix(in oklab, var(--color-muted, var(--muted)) 60%, transparent);
297
+ font-weight: 600;
298
+ }
299
+ .notion-editor .tiptap .selectedCell::after {
300
+ /* ProseMirror table cell selection ring. */
301
+ background: color-mix(in oklab, var(--color-primary, var(--primary)) 20%, transparent);
302
+ content: "";
303
+ position: absolute;
304
+ inset: 0;
305
+ pointer-events: none;
306
+ z-index: 2;
307
+ }
308
+
309
+ /* Images */
310
+ .notion-editor .tiptap img {
311
+ max-width: 100%;
312
+ height: auto;
313
+ border-radius: 6px;
314
+ margin: 0.5em 0;
315
+ }
316
+
317
+ /* Marks */
318
+ .notion-editor .tiptap strong { font-weight: 700; }
319
+ .notion-editor .tiptap em { font-style: italic; }
320
+ .notion-editor .tiptap s { text-decoration: line-through; }
321
+ .notion-editor .tiptap mark {
322
+ background: color-mix(in oklab, var(--color-primary, var(--primary)) 25%, transparent);
323
+ color: inherit;
324
+ padding: 0 0.15em;
325
+ border-radius: 2px;
326
+ }
327
+
328
+ /* Selection */
329
+ .notion-editor .tiptap ::selection {
330
+ background: color-mix(in oklab, var(--color-primary, var(--primary)) 25%, transparent);
331
+ }
332
+
333
+ /* ─────────────────────────────────────────────────────────────────────
334
+ * Global drag handle (tiptap-extension-global-drag-handle)
335
+ *
336
+ * The extension mints a single `.drag-handle` element and reparents it
337
+ * to the hovered block on `mouseover`. We mask a fixed 2×3 dot grid
338
+ * (Notion's ⠿ grip) — earlier attempts used `background-repeat: round`
339
+ * which tiled the dot pattern and produced a 3×5 grid that looked like
340
+ * a literal halftone block. A `mask-image` with `mask-repeat: no-repeat`
341
+ * pins exactly six dots regardless of the handle's box size.
342
+ * ───────────────────────────────────────────────────────────────────── */
343
+ .drag-handle {
344
+ position: fixed;
345
+ opacity: 0;
346
+ transition: opacity 120ms ease-out;
347
+ height: 1.4rem;
348
+ width: 1.1rem;
349
+ display: flex;
350
+ align-items: center;
351
+ justify-content: center;
352
+ border-radius: 4px;
353
+ cursor: grab;
354
+ background-color: transparent;
355
+
356
+ /* Painted as a `mask-image` of `lucide-react`'s `GripVertical` icon
357
+ * (six stroked circles at x∈{9,15}, y∈{5,12,19}, r=1, stroke-width=2,
358
+ * viewBox 24×24). Using lucide ensures the grabber matches every other
359
+ * icon in the app at the pixel level; we just can't render it as a
360
+ * <GripVertical/> component because `tiptap-extension-global-drag-handle`
361
+ * mints the DOM node imperatively. `mask` + `background-color` keeps
362
+ * the colour theme-driven. */
363
+ -webkit-mask-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'><circle cx='9' cy='12' r='1'/><circle cx='9' cy='5' r='1'/><circle cx='9' cy='19' r='1'/><circle cx='15' cy='12' r='1'/><circle cx='15' cy='5' r='1'/><circle cx='15' cy='19' r='1'/></svg>");
364
+ mask-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'><circle cx='9' cy='12' r='1'/><circle cx='9' cy='5' r='1'/><circle cx='9' cy='19' r='1'/><circle cx='15' cy='12' r='1'/><circle cx='15' cy='5' r='1'/><circle cx='15' cy='19' r='1'/></svg>");
365
+ -webkit-mask-repeat: no-repeat;
366
+ mask-repeat: no-repeat;
367
+ -webkit-mask-position: center;
368
+ mask-position: center;
369
+ -webkit-mask-size: 18px 18px;
370
+ mask-size: 18px 18px;
371
+ background-color: color-mix(in oklab, var(--color-foreground, var(--foreground)) 45%, transparent);
372
+ }
373
+ .drag-handle:hover {
374
+ background-color: var(--color-foreground, var(--foreground));
375
+ }
376
+ .drag-handle:active {
377
+ cursor: grabbing;
378
+ }
379
+ .drag-handle.hide {
380
+ opacity: 0 !important;
381
+ pointer-events: none;
382
+ }
383
+ /* Reveal the handle whenever the editor surface is hovered. */
384
+ .notion-editor:hover .drag-handle {
385
+ opacity: 1;
386
+ }
387
+
388
+ /* ─────────────────────────────────────────────────────────────────────
389
+ * Bubble menu — floating selection toolbar
390
+ * ───────────────────────────────────────────────────────────────────── */
391
+ .notion-bubble-menu {
392
+ display: flex;
393
+ gap: 2px;
394
+ padding: 4px;
395
+ background: var(--color-popover, var(--popover));
396
+ color: var(--color-popover-foreground, var(--popover-foreground));
397
+ border: 1px solid var(--color-border, var(--border));
398
+ border-radius: 8px;
399
+ box-shadow: 0 8px 30px rgba(0, 0, 0, 0.35);
400
+ z-index: 50;
401
+ }
402
+ .notion-bubble-btn {
403
+ width: 28px;
404
+ height: 28px;
405
+ display: inline-flex;
406
+ align-items: center;
407
+ justify-content: center;
408
+ background: transparent;
409
+ border: none;
410
+ border-radius: 4px;
411
+ cursor: pointer;
412
+ color: var(--color-foreground, var(--foreground));
413
+ opacity: 0.7;
414
+ transition: opacity 120ms, background 120ms;
415
+ }
416
+ .notion-bubble-btn:hover {
417
+ opacity: 1;
418
+ background: var(--color-muted, var(--muted));
419
+ }
420
+ .notion-bubble-btn--active {
421
+ opacity: 1;
422
+ background: var(--color-muted, var(--muted));
423
+ }
424
+
425
+ /* ─────────────────────────────────────────────────────────────────────
426
+ * Slash menu popover
427
+ * ───────────────────────────────────────────────────────────────────── */
428
+ .notion-slash-list {
429
+ background: var(--color-popover, var(--popover));
430
+ color: var(--color-popover-foreground, var(--popover-foreground));
431
+ border: 1px solid var(--color-border, var(--border));
432
+ border-radius: 10px;
433
+ box-shadow: 0 12px 36px rgba(0, 0, 0, 0.5);
434
+ padding: 4px;
435
+ width: 280px;
436
+ max-height: 320px;
437
+ overflow-y: auto;
438
+ display: flex;
439
+ flex-direction: column;
440
+ gap: 1px;
441
+ }
442
+ .notion-slash-item {
443
+ display: flex;
444
+ align-items: center;
445
+ gap: 10px;
446
+ width: 100%;
447
+ padding: 6px 8px;
448
+ border: none;
449
+ background: transparent;
450
+ border-radius: 6px;
451
+ cursor: pointer;
452
+ text-align: left;
453
+ color: inherit;
454
+ font-size: 13px;
455
+ transition: background 80ms;
456
+ }
457
+ .notion-slash-item--active,
458
+ .notion-slash-item:hover {
459
+ background: var(--color-muted, var(--muted));
460
+ }
461
+ .notion-slash-empty {
462
+ padding: 10px 12px;
463
+ color: color-mix(in oklab, var(--color-foreground, var(--foreground)) 55%, transparent);
464
+ font-size: 12px;
465
+ text-align: center;
466
+ }
467
+ .notion-slash-icon {
468
+ width: 32px;
469
+ height: 32px;
470
+ display: inline-flex;
471
+ align-items: center;
472
+ justify-content: center;
473
+ flex-shrink: 0;
474
+ border: 1px solid var(--color-border, var(--border));
475
+ border-radius: 6px;
476
+ background: color-mix(in oklab, var(--color-muted, var(--muted)) 40%, transparent);
477
+ }
478
+ .notion-slash-meta {
479
+ display: flex;
480
+ flex-direction: column;
481
+ min-width: 0;
482
+ flex: 1;
483
+ }
484
+ .notion-slash-hint {
485
+ flex-shrink: 0;
486
+ padding: 1px 6px;
487
+ border: 1px solid var(--color-border, var(--border));
488
+ border-radius: 4px;
489
+ font-size: 10.5px;
490
+ font-family: "SF Mono", ui-monospace, "JetBrains Mono", Menlo, Consolas, monospace;
491
+ color: color-mix(in oklab, var(--color-foreground, var(--foreground)) 55%, transparent);
492
+ background: color-mix(in oklab, var(--color-muted, var(--muted)) 30%, transparent);
493
+ }
494
+ .notion-slash-title {
495
+ font-weight: 500;
496
+ font-size: 13px;
497
+ line-height: 1.3;
498
+ }
499
+ .notion-slash-desc {
500
+ font-size: 11.5px;
501
+ opacity: 0.6;
502
+ line-height: 1.3;
503
+ }