@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,478 @@
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
+ /* Headings */
62
+ .notion-editor .tiptap h1 {
63
+ font-size: 2em;
64
+ font-weight: 700;
65
+ line-height: 1.2;
66
+ margin: 1em 0 0.3em;
67
+ letter-spacing: -0.02em;
68
+ }
69
+ .notion-editor .tiptap h2 {
70
+ font-size: 1.5em;
71
+ font-weight: 600;
72
+ line-height: 1.3;
73
+ margin: 0.9em 0 0.25em;
74
+ letter-spacing: -0.01em;
75
+ }
76
+ .notion-editor .tiptap h3 {
77
+ font-size: 1.25em;
78
+ font-weight: 600;
79
+ line-height: 1.35;
80
+ margin: 0.8em 0 0.2em;
81
+ }
82
+ .notion-editor .tiptap h4 {
83
+ font-size: 1.1em;
84
+ font-weight: 600;
85
+ line-height: 1.4;
86
+ margin: 0.7em 0 0.2em;
87
+ }
88
+ .notion-editor .tiptap > :is(h1, h2, h3, h4):first-child {
89
+ margin-top: 0;
90
+ }
91
+
92
+ .notion-editor .tiptap p {
93
+ margin: 0.25em 0;
94
+ }
95
+
96
+ /* Lists */
97
+ .notion-editor .tiptap ul,
98
+ .notion-editor .tiptap ol {
99
+ padding-left: 1.5em;
100
+ margin: 0.35em 0;
101
+ }
102
+ .notion-editor .tiptap ul {
103
+ list-style: disc;
104
+ }
105
+ .notion-editor .tiptap ol {
106
+ list-style: decimal;
107
+ }
108
+ .notion-editor .tiptap ul ul {
109
+ list-style: circle;
110
+ }
111
+ .notion-editor .tiptap ul ul ul {
112
+ list-style: square;
113
+ }
114
+ .notion-editor .tiptap li {
115
+ margin: 0.15em 0;
116
+ }
117
+ .notion-editor .tiptap li > p {
118
+ margin: 0;
119
+ }
120
+
121
+ /* Task list — backed by a React NodeView that mounts our ui-core
122
+ * <Checkbox>. Selectors target the wrapper class set by `TaskItemView`.
123
+ *
124
+ * Alignment math (the part that always goes wrong):
125
+ * editor font-size: 16px
126
+ * line-height: 1.65 → line box ≈ 26.4px
127
+ * cap-height of text: ≈ 11.5px, sits ≈ 7.5px from line top
128
+ * checkbox box: 16px square (ui-core default)
129
+ * ⇒ to centre the checkbox on the cap-height row, top = (26.4 − 16) / 2 ≈ 5px
130
+ *
131
+ * Using `align-items: flex-start` + a fixed `margin-top` keeps multi-line
132
+ * task items working: the checkbox stays parked at the top of the first
133
+ * line and the text wraps freely below. `align-items: center` would
134
+ * recentre on multi-line items and drift the checkbox into the middle. */
135
+ .notion-editor .tiptap ul[data-type="taskList"] {
136
+ list-style: none;
137
+ padding-left: 0;
138
+ margin-left: 0;
139
+ }
140
+ .notion-editor .tiptap .notion-task-item {
141
+ display: flex;
142
+ /* `align-items: baseline` lets the checkbox sit on the *text baseline*
143
+ * of the first line — independent of checkbox size, font-size, or
144
+ * line-height. Works correctly across themes / presets that scale the
145
+ * Checkbox token (macOS vs default). For multi-line items the checkbox
146
+ * still anchors to the first line (because baseline is per-line) which
147
+ * is what Notion does. Earlier `align-items: flex-start + margin-top:Xpx`
148
+ * was brittle: it assumed a fixed checkbox size + line-height. */
149
+ align-items: baseline;
150
+ gap: 0.5em;
151
+ padding: 2px 0;
152
+ }
153
+ .notion-editor .tiptap .notion-task-checkbox {
154
+ flex-shrink: 0;
155
+ /* `translateY(2px)` nudges the optical centre of the checkbox box onto
156
+ * the cap-height line. Without this the checkbox sits visually a bit
157
+ * high because its bounding box centres on the baseline, not cap-height.
158
+ * Tiny constant — independent of checkbox / font size. */
159
+ transform: translateY(2px);
160
+ }
161
+ .notion-editor .tiptap .notion-task-text {
162
+ flex: 1;
163
+ min-width: 0;
164
+ }
165
+ .notion-editor .tiptap .notion-task-item[data-checked] .notion-task-text {
166
+ opacity: 0.55;
167
+ text-decoration: line-through;
168
+ }
169
+
170
+ /* Blockquote */
171
+ .notion-editor .tiptap blockquote {
172
+ border-left: 3px solid var(--color-border, var(--border));
173
+ padding: 0.2em 0 0.2em 1em;
174
+ margin: 0.6em 0;
175
+ color: color-mix(in oklab, var(--color-foreground, var(--foreground)) 80%, transparent);
176
+ }
177
+ .notion-editor .tiptap blockquote > :first-child { margin-top: 0; }
178
+ .notion-editor .tiptap blockquote > :last-child { margin-bottom: 0; }
179
+
180
+ /* Divider */
181
+ .notion-editor .tiptap hr {
182
+ border: none;
183
+ border-top: 1px solid var(--color-border, var(--border));
184
+ margin: 1.5em 0;
185
+ }
186
+
187
+ /* Inline code */
188
+ .notion-editor .tiptap code {
189
+ background: color-mix(in oklab, var(--color-muted, var(--muted)) 70%, transparent);
190
+ color: var(--color-foreground, var(--foreground));
191
+ padding: 0.15em 0.35em;
192
+ border-radius: 4px;
193
+ font-size: 0.875em;
194
+ font-family: "SF Mono", ui-monospace, "JetBrains Mono", "Fira Code", Menlo, Consolas, monospace;
195
+ border: 1px solid color-mix(in oklab, var(--color-border, var(--border)) 60%, transparent);
196
+ }
197
+
198
+ /* Code block (lowlight) — always dark, regardless of host theme.
199
+ * Rationale: syntax highlighting palettes are calibrated for dark
200
+ * backgrounds; a "light-on-light" code block on a light app theme looks
201
+ * washed out and reads worse than the same block on dark. Notion / Linear
202
+ * / GitHub all force-dark code blocks on light themes too. We hardcode
203
+ * a One Dark-flavour surface and let the token colours below sit on it. */
204
+ .notion-editor .tiptap pre {
205
+ background: #1e2127;
206
+ color: #d7dae0;
207
+ padding: 0.9em 1em;
208
+ border-radius: 6px;
209
+ margin: 0.75em 0;
210
+ font-family: "SF Mono", ui-monospace, "JetBrains Mono", "Fira Code", Menlo, Consolas, monospace;
211
+ font-size: 0.875em;
212
+ line-height: 1.55;
213
+ overflow-x: auto;
214
+ border: 1px solid rgba(255, 255, 255, 0.06);
215
+ }
216
+ .notion-editor .tiptap pre code {
217
+ background: transparent;
218
+ border: none;
219
+ padding: 0;
220
+ border-radius: 0;
221
+ font-size: inherit;
222
+ color: inherit;
223
+ }
224
+
225
+ /* Lowlight token colours — One Dark Pro palette. `@tiptap/extension-code-
226
+ * block-lowlight` emits `.hljs-*` class names; we colour against the fixed
227
+ * dark surface above so the palette stays readable on every host theme. */
228
+ .notion-editor .hljs-comment,
229
+ .notion-editor .hljs-quote { color: #5c6370; font-style: italic; }
230
+ .notion-editor .hljs-keyword,
231
+ .notion-editor .hljs-selector-tag,
232
+ .notion-editor .hljs-built_in { color: #c678dd; }
233
+ .notion-editor .hljs-string,
234
+ .notion-editor .hljs-attr { color: #98c379; }
235
+ .notion-editor .hljs-number,
236
+ .notion-editor .hljs-literal { color: #d19a66; }
237
+ .notion-editor .hljs-function .hljs-title,
238
+ .notion-editor .hljs-title.function_ { color: #61afef; }
239
+ .notion-editor .hljs-class .hljs-title,
240
+ .notion-editor .hljs-type { color: #e5c07b; }
241
+ .notion-editor .hljs-tag,
242
+ .notion-editor .hljs-name { color: #e06c75; }
243
+ .notion-editor .hljs-attribute { color: #d19a66; }
244
+ .notion-editor .hljs-variable,
245
+ .notion-editor .hljs-template-variable { color: #e06c75; }
246
+ .notion-editor .hljs-symbol,
247
+ .notion-editor .hljs-bullet,
248
+ .notion-editor .hljs-link { color: #56b6c2; }
249
+ .notion-editor .hljs-meta { color: #61afef; }
250
+ .notion-editor .hljs-deletion { background: rgba(224, 108, 117, 0.2); }
251
+ .notion-editor .hljs-addition { background: rgba(152, 195, 121, 0.2); }
252
+
253
+ /* Tables */
254
+ .notion-editor .tiptap table {
255
+ border-collapse: collapse;
256
+ width: 100%;
257
+ margin: 0.75em 0;
258
+ font-size: 0.95em;
259
+ table-layout: fixed;
260
+ }
261
+ .notion-editor .tiptap th,
262
+ .notion-editor .tiptap td {
263
+ border: 1px solid var(--color-border, var(--border));
264
+ padding: 0.45em 0.75em;
265
+ vertical-align: top;
266
+ text-align: left;
267
+ min-width: 4ch;
268
+ position: relative;
269
+ }
270
+ .notion-editor .tiptap th {
271
+ background: color-mix(in oklab, var(--color-muted, var(--muted)) 60%, transparent);
272
+ font-weight: 600;
273
+ }
274
+ .notion-editor .tiptap .selectedCell::after {
275
+ /* ProseMirror table cell selection ring. */
276
+ background: color-mix(in oklab, var(--color-primary, var(--primary)) 20%, transparent);
277
+ content: "";
278
+ position: absolute;
279
+ inset: 0;
280
+ pointer-events: none;
281
+ z-index: 2;
282
+ }
283
+
284
+ /* Images */
285
+ .notion-editor .tiptap img {
286
+ max-width: 100%;
287
+ height: auto;
288
+ border-radius: 6px;
289
+ margin: 0.5em 0;
290
+ }
291
+
292
+ /* Marks */
293
+ .notion-editor .tiptap strong { font-weight: 700; }
294
+ .notion-editor .tiptap em { font-style: italic; }
295
+ .notion-editor .tiptap s { text-decoration: line-through; }
296
+ .notion-editor .tiptap mark {
297
+ background: color-mix(in oklab, var(--color-primary, var(--primary)) 25%, transparent);
298
+ color: inherit;
299
+ padding: 0 0.15em;
300
+ border-radius: 2px;
301
+ }
302
+
303
+ /* Selection */
304
+ .notion-editor .tiptap ::selection {
305
+ background: color-mix(in oklab, var(--color-primary, var(--primary)) 25%, transparent);
306
+ }
307
+
308
+ /* ─────────────────────────────────────────────────────────────────────
309
+ * Global drag handle (tiptap-extension-global-drag-handle)
310
+ *
311
+ * The extension mints a single `.drag-handle` element and reparents it
312
+ * to the hovered block on `mouseover`. We mask a fixed 2×3 dot grid
313
+ * (Notion's ⠿ grip) — earlier attempts used `background-repeat: round`
314
+ * which tiled the dot pattern and produced a 3×5 grid that looked like
315
+ * a literal halftone block. A `mask-image` with `mask-repeat: no-repeat`
316
+ * pins exactly six dots regardless of the handle's box size.
317
+ * ───────────────────────────────────────────────────────────────────── */
318
+ .drag-handle {
319
+ position: fixed;
320
+ opacity: 0;
321
+ transition: opacity 120ms ease-out;
322
+ height: 1.4rem;
323
+ width: 1.1rem;
324
+ display: flex;
325
+ align-items: center;
326
+ justify-content: center;
327
+ border-radius: 4px;
328
+ cursor: grab;
329
+ background-color: transparent;
330
+
331
+ /* Painted as a `mask-image` of `lucide-react`'s `GripVertical` icon
332
+ * (six stroked circles at x∈{9,15}, y∈{5,12,19}, r=1, stroke-width=2,
333
+ * viewBox 24×24). Using lucide ensures the grabber matches every other
334
+ * icon in the app at the pixel level; we just can't render it as a
335
+ * <GripVertical/> component because `tiptap-extension-global-drag-handle`
336
+ * mints the DOM node imperatively. `mask` + `background-color` keeps
337
+ * the colour theme-driven. */
338
+ -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>");
339
+ 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>");
340
+ -webkit-mask-repeat: no-repeat;
341
+ mask-repeat: no-repeat;
342
+ -webkit-mask-position: center;
343
+ mask-position: center;
344
+ -webkit-mask-size: 18px 18px;
345
+ mask-size: 18px 18px;
346
+ background-color: color-mix(in oklab, var(--color-foreground, var(--foreground)) 45%, transparent);
347
+ }
348
+ .drag-handle:hover {
349
+ background-color: var(--color-foreground, var(--foreground));
350
+ }
351
+ .drag-handle:active {
352
+ cursor: grabbing;
353
+ }
354
+ .drag-handle.hide {
355
+ opacity: 0 !important;
356
+ pointer-events: none;
357
+ }
358
+ /* Reveal the handle whenever the editor surface is hovered. */
359
+ .notion-editor:hover .drag-handle {
360
+ opacity: 1;
361
+ }
362
+
363
+ /* ─────────────────────────────────────────────────────────────────────
364
+ * Bubble menu — floating selection toolbar
365
+ * ───────────────────────────────────────────────────────────────────── */
366
+ .notion-bubble-menu {
367
+ display: flex;
368
+ gap: 2px;
369
+ padding: 4px;
370
+ background: var(--color-popover, var(--popover));
371
+ color: var(--color-popover-foreground, var(--popover-foreground));
372
+ border: 1px solid var(--color-border, var(--border));
373
+ border-radius: 8px;
374
+ box-shadow: 0 8px 30px rgba(0, 0, 0, 0.35);
375
+ z-index: 50;
376
+ }
377
+ .notion-bubble-btn {
378
+ width: 28px;
379
+ height: 28px;
380
+ display: inline-flex;
381
+ align-items: center;
382
+ justify-content: center;
383
+ background: transparent;
384
+ border: none;
385
+ border-radius: 4px;
386
+ cursor: pointer;
387
+ color: var(--color-foreground, var(--foreground));
388
+ opacity: 0.7;
389
+ transition: opacity 120ms, background 120ms;
390
+ }
391
+ .notion-bubble-btn:hover {
392
+ opacity: 1;
393
+ background: var(--color-muted, var(--muted));
394
+ }
395
+ .notion-bubble-btn--active {
396
+ opacity: 1;
397
+ background: var(--color-muted, var(--muted));
398
+ }
399
+
400
+ /* ─────────────────────────────────────────────────────────────────────
401
+ * Slash menu popover
402
+ * ───────────────────────────────────────────────────────────────────── */
403
+ .notion-slash-list {
404
+ background: var(--color-popover, var(--popover));
405
+ color: var(--color-popover-foreground, var(--popover-foreground));
406
+ border: 1px solid var(--color-border, var(--border));
407
+ border-radius: 10px;
408
+ box-shadow: 0 12px 36px rgba(0, 0, 0, 0.5);
409
+ padding: 4px;
410
+ width: 280px;
411
+ max-height: 320px;
412
+ overflow-y: auto;
413
+ display: flex;
414
+ flex-direction: column;
415
+ gap: 1px;
416
+ }
417
+ .notion-slash-item {
418
+ display: flex;
419
+ align-items: center;
420
+ gap: 10px;
421
+ width: 100%;
422
+ padding: 6px 8px;
423
+ border: none;
424
+ background: transparent;
425
+ border-radius: 6px;
426
+ cursor: pointer;
427
+ text-align: left;
428
+ color: inherit;
429
+ font-size: 13px;
430
+ transition: background 80ms;
431
+ }
432
+ .notion-slash-item--active,
433
+ .notion-slash-item:hover {
434
+ background: var(--color-muted, var(--muted));
435
+ }
436
+ .notion-slash-empty {
437
+ padding: 10px 12px;
438
+ color: color-mix(in oklab, var(--color-foreground, var(--foreground)) 55%, transparent);
439
+ font-size: 12px;
440
+ text-align: center;
441
+ }
442
+ .notion-slash-icon {
443
+ width: 32px;
444
+ height: 32px;
445
+ display: inline-flex;
446
+ align-items: center;
447
+ justify-content: center;
448
+ flex-shrink: 0;
449
+ border: 1px solid var(--color-border, var(--border));
450
+ border-radius: 6px;
451
+ background: color-mix(in oklab, var(--color-muted, var(--muted)) 40%, transparent);
452
+ }
453
+ .notion-slash-meta {
454
+ display: flex;
455
+ flex-direction: column;
456
+ min-width: 0;
457
+ flex: 1;
458
+ }
459
+ .notion-slash-hint {
460
+ flex-shrink: 0;
461
+ padding: 1px 6px;
462
+ border: 1px solid var(--color-border, var(--border));
463
+ border-radius: 4px;
464
+ font-size: 10.5px;
465
+ font-family: "SF Mono", ui-monospace, "JetBrains Mono", Menlo, Consolas, monospace;
466
+ color: color-mix(in oklab, var(--color-foreground, var(--foreground)) 55%, transparent);
467
+ background: color-mix(in oklab, var(--color-muted, var(--muted)) 30%, transparent);
468
+ }
469
+ .notion-slash-title {
470
+ font-weight: 500;
471
+ font-size: 13px;
472
+ line-height: 1.3;
473
+ }
474
+ .notion-slash-desc {
475
+ font-size: 11.5px;
476
+ opacity: 0.6;
477
+ line-height: 1.3;
478
+ }
@@ -0,0 +1,28 @@
1
+ import type { Editor } from '@tiptap/react';
2
+
3
+ export interface NotionEditorProps {
4
+ value: string;
5
+ onChange: (markdown: string) => void;
6
+ /** Markdown placeholder shown when document is empty. */
7
+ placeholder?: string;
8
+ /** Disable editing — turns the editor into a read-only renderer. */
9
+ disabled?: boolean;
10
+ /** Focus the editor on mount. Pair with `key={path}` upstream when the
11
+ * host wants a fresh focus per source change. */
12
+ autoFocus?: boolean;
13
+ /** Called when the user presses Cmd/Ctrl+S inside the editor. Receives
14
+ * the current markdown. The browser default is suppressed. */
15
+ onSave?: (markdown: string) => void;
16
+ /** Extra class name on the outer wrapper. */
17
+ className?: string;
18
+ /** Minimum height of the editor surface in px. Default 320. */
19
+ minHeight?: number;
20
+ }
21
+
22
+ /** Imperative handle exposed via `ref`. Compatible with `ComposerHandle`
23
+ * shape used by the chat suite (focus / moveCursorToEnd). */
24
+ export interface NotionEditorHandle {
25
+ focus: () => void;
26
+ moveCursorToEnd: () => void;
27
+ getEditor: () => Editor | null;
28
+ }
@@ -6,8 +6,7 @@ import { AlertCircle, Loader2, Mic } from 'lucide-react';
6
6
 
7
7
  import { useCountdownFromSeconds, useNotificationSounds } from '@djangocfg/ui-core/hooks';
8
8
  import { cn } from '@djangocfg/ui-core/lib';
9
-
10
- import { useChatContextOptional } from '../../../chat/context';
9
+ import { useActiveComposer } from '@djangocfg/ui-tools/composer-registry';
11
10
  import { useSpeechRecognition } from '../hooks/useSpeechRecognition';
12
11
  import { useVoiceSupport } from '../hooks/useVoiceSupport';
13
12
  import { getSpeechLogger } from '../core/logger';
@@ -105,24 +104,24 @@ export function VoiceComposerSlot({
105
104
  }: VoiceComposerSlotProps): React.ReactElement | null {
106
105
  const support = useVoiceSupport(engine);
107
106
 
108
- // Read the composer handle from chat context — works transparently
109
- // for the built-in `<Composer>` (registers itself) and for TipTap
110
- // hosts that call `useRegisterComposer({ getValue, setValue, focus,
111
- // moveCursorToEnd })`. Falls back to a no-op when mounted outside of
112
- // a chat.
113
- const chatCtx = useChatContextOptional();
114
- const composerHandleRef = useRef(chatCtx?.composer ?? null);
115
- composerHandleRef.current = chatCtx?.composer ?? null;
107
+ // Read the active composer handle from the cross-tool registry
108
+ // (`@djangocfg/ui-tools/composer-registry`). The built-in
109
+ // `<Composer>` (and TipTap hosts via `useRegisterComposer`) publish
110
+ // their handle to this registry on mount. Falls back to a no-op
111
+ // when nothing is registered (no composer in the tree).
112
+ const activeComposer = useActiveComposer();
113
+ const composerHandleRef = useRef(activeComposer);
114
+ composerHandleRef.current = activeComposer;
116
115
 
117
116
  useEffect(() => {
118
117
  log.slot.debug('mount', {
119
118
  supported: support.supported,
120
119
  reason: support.reason,
121
- hasComposerHandle: !!chatCtx?.composer,
120
+ hasComposerHandle: !!activeComposer,
122
121
  hasExplicitValue: value !== undefined,
123
122
  hasOnChange: !!onChange,
124
123
  });
125
- }, [support.supported, support.reason, chatCtx?.composer, value, onChange]);
124
+ }, [support.supported, support.reason, activeComposer, value, onChange]);
126
125
 
127
126
  // Resolve value/onChange: prop wins; otherwise pull from the
128
127
  // registered composer handle. The slot can therefore be dropped into
@@ -0,0 +1,105 @@
1
+ 'use client';
2
+
3
+ import { useCallback, useSyncExternalStore } from 'react';
4
+
5
+ /**
6
+ * Minimal imperative handle every text-editor surface implements so
7
+ * an external tool (voice dictation, command palette, AI suggestion)
8
+ * can read/write its text content without traversing React.
9
+ *
10
+ * Methods are optional so a host can register a partial handle
11
+ * (e.g. only `getValue` + `setValue`), and the caller checks before use.
12
+ */
13
+ export interface ComposerHandle {
14
+ /** Move keyboard focus into the composer's editable surface. */
15
+ focus: () => void;
16
+ /** Move the caret to the very end of the input. */
17
+ moveCursorToEnd?: () => void;
18
+ /** Read the current draft text. Voice dictation anchors partial
19
+ * transcripts onto the user's already-typed prefix via this. */
20
+ getValue?: () => string;
21
+ /** Replace the current draft text. Voice dictation pushes interim
22
+ * and final transcripts through this without owning a controlled
23
+ * binding. */
24
+ setValue?: (value: string) => void;
25
+ }
26
+
27
+ /**
28
+ * `@djangocfg/ui-tools/composer-registry`
29
+ *
30
+ * Cross-tool bridge: the currently-active text composer's handle.
31
+ *
32
+ * Producer side (`@djangocfg/ui-tools/chat` and TipTap hosts):
33
+ * register their composer's imperative handle via `attachComposer`.
34
+ *
35
+ * Consumer side (`@djangocfg/ui-tools/speech-recognition`):
36
+ * reads the active handle via `useActiveComposer`/`getActiveComposer`
37
+ * and pipes voice transcripts into it.
38
+ *
39
+ * Why this lives in its own subpath (not inside `chat`)
40
+ * ----------------------------------------------------
41
+ * `chat` and `speech-recognition` are sibling subpath exports. If the
42
+ * registry lived inside `chat`, then `speech-recognition` would have
43
+ * to reach into it via a cross-tool relative import — and under Vite
44
+ * dev's dependency optimizer that file ends up loaded TWICE (once via
45
+ * the `./chat` URL, once via the `./speech-recognition` relative-up
46
+ * URL), giving the producer and the consumer two separate `let active`
47
+ * slots. The active handle registered by chat would be invisible to
48
+ * speech-recognition (and vice versa).
49
+ *
50
+ * Putting the registry in its own dedicated subpath (a single tool
51
+ * that NEITHER chat nor speech-recognition cross-import — they both
52
+ * import this one as their dependency) means Vite resolves it from a
53
+ * single URL across the whole graph. One module instance, one shared
54
+ * `active` slot.
55
+ *
56
+ * Semantics: one active composer per realm. The most recent
57
+ * `registerComposer(handle)` wins; `registerComposer(null)` clears it.
58
+ */
59
+
60
+ type Listener = (handle: ComposerHandle | null) => void;
61
+
62
+ let active: ComposerHandle | null = null;
63
+ const listeners = new Set<Listener>();
64
+
65
+ /** Set or replace the active composer handle. Pass `null` to clear. */
66
+ export function registerComposer(handle: ComposerHandle | null): void {
67
+ active = handle;
68
+ for (const fn of listeners) fn(active);
69
+ }
70
+
71
+ /**
72
+ * Convenience for components: register on mount, unregister on
73
+ * unmount. Returns a cleanup function suitable for `useEffect`.
74
+ */
75
+ export function attachComposer(handle: ComposerHandle): () => void {
76
+ registerComposer(handle);
77
+ return () => {
78
+ if (active === handle) registerComposer(null);
79
+ };
80
+ }
81
+
82
+ /** Read the current active handle (no subscription). */
83
+ export function getActiveComposer(): ComposerHandle | null {
84
+ return active;
85
+ }
86
+
87
+ /** Subscribe to handle changes; returns an unsubscribe fn. */
88
+ export function subscribeComposer(listener: Listener): () => void {
89
+ listeners.add(listener);
90
+ return () => {
91
+ listeners.delete(listener);
92
+ };
93
+ }
94
+
95
+ /**
96
+ * React hook: re-renders the caller whenever the active composer
97
+ * changes. Built on `useSyncExternalStore` so concurrent rendering,
98
+ * SSR, and dev-mode strict-effects all behave correctly.
99
+ */
100
+ export function useActiveComposer(): ComposerHandle | null {
101
+ const subscribe = useCallback((onChange: () => void) => {
102
+ return subscribeComposer(onChange);
103
+ }, []);
104
+ return useSyncExternalStore(subscribe, getActiveComposer, () => null);
105
+ }
@@ -34,6 +34,7 @@ export const Player = forwardRef<PlayerHandle, PlayerProps>(function Player(prop
34
34
  ariaLabel,
35
35
  enableKeyboardShortcuts,
36
36
  seekStartsPlayback,
37
+ autoFocus,
37
38
  } = props;
38
39
 
39
40
  // onTimeUpdate is intentionally not wired in the provider — we expose it via
@@ -71,6 +72,7 @@ export const Player = forwardRef<PlayerHandle, PlayerProps>(function Player(prop
71
72
  enableKeyboardShortcuts={enableKeyboardShortcuts}
72
73
  ariaLabel={ariaLabel}
73
74
  seekStartsPlayback={seekStartsPlayback}
75
+ autoFocus={autoFocus}
74
76
  handleRef={ref}
75
77
  />
76
78
  </PlayerProvider>