@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
|
@@ -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
|
-
//
|
|
103
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
56
|
-
*
|
|
57
|
-
*
|
|
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
|
}
|