@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
@@ -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
+ }
@@ -99,19 +99,11 @@ export function PlayerShell({
99
99
  container.setAttribute('tabindex', '0');
100
100
  }, [container]);
101
101
 
102
- // `autoFocus` opts the player into pulling keyboard focus on mount
103
- // (and whenever the container ref is established). Once focused,
104
- // the hotkey scope (Space=play/pause, ←→=seek, ↑↓=volume, M=mute)
105
- // is immediately live.
106
- //
107
- // Deferred via a 0-timeout so the tree row's native focus event
108
- // (fired by the click that triggered the mount) lands first; we
109
- // then steal focus here. rAF was racy — the row's focus event can
110
- // fire AFTER the next animation frame.
102
+ // Declarative autoFocus: focus the container once the DOM node is ready.
103
+ // Parents that want a *fresh* focus per source remount us via `key={src}`.
111
104
  useEffect(() => {
112
105
  if (!autoFocus || !container) return;
113
- const id = setTimeout(() => container.focus(), 0);
114
- return () => clearTimeout(id);
106
+ container.focus({ preventScroll: true });
115
107
  }, [autoFocus, container]);
116
108
 
117
109
  return (
@@ -51,17 +51,10 @@ export type PlayerProps = {
51
51
 
52
52
  ariaLabel?: string;
53
53
  enableKeyboardShortcuts?: boolean;
54
- /**
55
- * Move keyboard focus into the player container on mount. Activates
56
- * the hotkey scope (Space=play/pause, ←→=seek, ↑↓=volume, M=mute)
57
- * without the user having to click the player first.
58
- *
59
- * Useful when the player mounts as the result of an explicit user
60
- * action — e.g. a file picker selecting an audio file — so keyboard
61
- * control is immediately live.
62
- *
63
- * @default false
64
- */
54
+
55
+ /** Focus the player container on mount so its keyboard scope is active
56
+ * immediately. Pair with `key={src}` upstream when the parent wants a
57
+ * fresh focus on every source change (file-browser inspector pattern). */
65
58
  autoFocus?: boolean;
66
59
 
67
60
  // When the user clicks on the waveform while paused, also start playback.
@@ -38,6 +38,7 @@ export function ImageViewer({
38
38
  images,
39
39
  initialIndex = 0,
40
40
  inDialog = false,
41
+ autoFocus = false,
41
42
  }: ImageViewerProps) {
42
43
  const t = useAppT();
43
44
 
@@ -134,6 +135,13 @@ export function ImageViewer({
134
135
  return true;
135
136
  }, []);
136
137
 
138
+ // Declarative autoFocus: focus the container once on mount. Pair with
139
+ // `key={src}` upstream for per-source focus reset.
140
+ useEffect(() => {
141
+ if (!autoFocus) return;
142
+ containerRef.current?.focus({ preventScroll: true });
143
+ }, [autoFocus]);
144
+
137
145
  // Keyboard: zoom / rotate / pan (only when container focused)
138
146
  useEffect(() => {
139
147
  const handleKeyDown = (e: KeyboardEvent) => {
@@ -43,6 +43,10 @@ export interface ImageViewerProps {
43
43
  initialIndex?: number;
44
44
  /** Hide expand button when already in dialog */
45
45
  inDialog?: boolean;
46
+ /** Focus the viewer container on mount so its keyboard scope is active
47
+ * immediately (zoom/rotate/gallery hotkeys). Pair with `key={src}`
48
+ * upstream when the parent wants a fresh focus per source change. */
49
+ autoFocus?: boolean;
46
50
  }
47
51
 
48
52
  export interface ImageToolbarProps {
@@ -12,7 +12,7 @@
12
12
  * iframe sources where the embed renders its own UI).
13
13
  */
14
14
 
15
- import { useMemo, type CSSProperties } from 'react';
15
+ import { useEffect, useMemo, useRef, type CSSProperties } from 'react';
16
16
  import { MediaController } from 'media-chrome/react';
17
17
  import { cn } from '@djangocfg/ui-core/lib';
18
18
  import './styles/video-player.css';
@@ -42,6 +42,7 @@ export function VideoPlayer({
42
42
  aspectRatio = 16 / 9,
43
43
  className,
44
44
  children,
45
+ autoFocus = false,
45
46
  ...settings
46
47
  }: VideoPlayerProps) {
47
48
  const normalized = useMemo(
@@ -54,13 +55,31 @@ export function VideoPlayer({
54
55
  // control bar to avoid a non-functional UI.
55
56
  const showControls = controls && !isIframe;
56
57
 
58
+ // MediaController is a custom element; without `tabindex` it cannot take
59
+ // focus, so its built-in keyboard shortcuts (space/arrows/f) never fire.
60
+ // We type the ref through the element interface (HTMLElement methods are
61
+ // all we use) — media-chrome's full MediaController type pulls private
62
+ // fields we don't need to see.
63
+ const controllerRef = useRef<HTMLElement | null>(null);
64
+ useEffect(() => {
65
+ if (!autoFocus) return;
66
+ const el = controllerRef.current;
67
+ if (!el) return;
68
+ if (!el.hasAttribute('tabindex')) el.setAttribute('tabindex', '0');
69
+ el.focus({ preventScroll: true });
70
+ }, [autoFocus]);
71
+
57
72
  return (
58
73
  <MediaController
74
+ ref={(el) => {
75
+ controllerRef.current = el as unknown as HTMLElement | null;
76
+ }}
59
77
  // Fade controls + scrim after 2.5s of inactivity while playing;
60
78
  // they reappear on mousemove / pause / focus (media-chrome built-in).
61
79
  autohide="2.5"
62
80
  className={cn(
63
81
  'video-player relative block w-full overflow-hidden rounded-lg bg-black',
82
+ 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50',
64
83
  className,
65
84
  )}
66
85
  style={aspectRatioStyle(aspectRatio)}
@@ -79,4 +79,8 @@ export interface VideoPlayerProps extends VideoPlayerSettings {
79
79
  readonly className?: string;
80
80
  /** Custom children replace the default control bar entirely. */
81
81
  readonly children?: ReactNode;
82
+ /** Focus the player container on mount so media-chrome keyboard shortcuts
83
+ * (space=play/pause, f=fullscreen, arrows=seek/volume) are active
84
+ * immediately. Pair with `key={src}` upstream for per-source focus reset. */
85
+ readonly autoFocus?: boolean;
82
86
  }