@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.
- package/dist/audio-player/index.cjs +2098 -0
- package/dist/audio-player/index.cjs.map +1 -0
- package/dist/audio-player/index.css +65 -0
- package/dist/audio-player/index.css.map +1 -0
- package/dist/audio-player/index.d.cts +166 -0
- package/dist/audio-player/index.d.ts +166 -0
- package/dist/audio-player/index.mjs +2075 -0
- package/dist/audio-player/index.mjs.map +1 -0
- package/dist/composer-registry/index.cjs +45 -0
- package/dist/composer-registry/index.cjs.map +1 -0
- package/dist/composer-registry/index.d.cts +73 -0
- package/dist/composer-registry/index.d.ts +73 -0
- package/dist/composer-registry/index.mjs +39 -0
- package/dist/composer-registry/index.mjs.map +1 -0
- package/dist/tree/index.cjs +82 -63
- package/dist/tree/index.cjs.map +1 -1
- package/dist/tree/index.d.cts +15 -1
- package/dist/tree/index.d.ts +15 -1
- package/dist/tree/index.mjs +83 -64
- package/dist/tree/index.mjs.map +1 -1
- package/package.json +38 -17
- package/src/tools/chat/composer/Composer.tsx +8 -8
- package/src/tools/chat/context/ChatProvider.tsx +13 -78
- package/src/tools/chat/hooks/useAutoFocusOnStreamEnd.ts +12 -15
- package/src/tools/chat/hooks/useFocusOnEmptyClick.ts +4 -5
- package/src/tools/chat/launcher/header/ChatHeader.tsx +14 -19
- package/src/tools/chat/launcher/header/ChatHeaderActionButton.tsx +8 -12
- package/src/tools/data/Tree/TreeRoot.tsx +33 -109
- package/src/tools/data/Tree/context/TreeContext.tsx +22 -3
- package/src/tools/data/Tree/context/menu/index.ts +1 -0
- package/src/tools/data/Tree/context/menu/render.tsx +75 -0
- package/src/tools/data/Tree/context/menu/use-resolved-menu.ts +16 -2
- package/src/tools/data/Tree/index.tsx +1 -0
- package/src/tools/data/Tree/types/index.ts +1 -1
- package/src/tools/data/Tree/types/root-props.ts +16 -0
- package/src/tools/dev/OpenapiViewer/components/DocsLayout/EndpointDoc/Header/MetaActions.tsx +6 -9
- package/src/tools/dev/OpenapiViewer/components/DocsLayout/index.tsx +2 -4
- 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/input/SpeechRecognition/widgets/VoiceComposerSlot.tsx +11 -12
- package/src/tools/integration/ComposerRegistry/index.ts +105 -0
- package/src/tools/media/AudioPlayer/Player.tsx +2 -0
- package/src/tools/media/AudioPlayer/PlayerShell.tsx +29 -22
- package/src/tools/media/AudioPlayer/lazy.tsx +30 -42
- package/src/tools/media/AudioPlayer/parts/Controls/IconButton.tsx +10 -11
- package/src/tools/media/AudioPlayer/parts/Controls/VolumeControl.tsx +52 -115
- package/src/tools/media/AudioPlayer/types.ts +8 -0
- 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
|
+
}
|
|
@@ -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
|
|
109
|
-
//
|
|
110
|
-
// hosts
|
|
111
|
-
//
|
|
112
|
-
//
|
|
113
|
-
const
|
|
114
|
-
const composerHandleRef = useRef(
|
|
115
|
-
composerHandleRef.current =
|
|
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: !!
|
|
120
|
+
hasComposerHandle: !!activeComposer,
|
|
122
121
|
hasExplicitValue: value !== undefined,
|
|
123
122
|
hasOnChange: !!onChange,
|
|
124
123
|
});
|
|
125
|
-
}, [support.supported, support.reason,
|
|
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>
|