@k2b/editor 0.3.0 → 0.4.0
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/package.json +2 -2
- package/src/Toolbar.tsx +68 -26
- package/src/collab/remote-cursors.ts +8 -3
- package/src/dictation.ts +13 -0
- package/src/index.ts +1 -0
- package/src/markdown/block-previews.tsx +2 -2
- package/src/markdown/named-blocks.ts +4 -0
- package/src/styles.css +24 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@k2b/editor",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Configurable Markdown editor for SolidJS: editor, toolbar, bubble menu, and slash menu on one shared state.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"sideEffects": [
|
|
@@ -69,7 +69,7 @@
|
|
|
69
69
|
"@codemirror/state": "6.7.1",
|
|
70
70
|
"@codemirror/view": "6.43.7",
|
|
71
71
|
"@k2b/stdlib": "0.30.0",
|
|
72
|
-
"@k2b/ui": "0.
|
|
72
|
+
"@k2b/ui": "0.41.0",
|
|
73
73
|
"@lezer/common": "1.5.3",
|
|
74
74
|
"@lezer/highlight": "1.2.4",
|
|
75
75
|
"@lezer/markdown": "1.7.2",
|
package/src/Toolbar.tsx
CHANGED
|
@@ -1,7 +1,21 @@
|
|
|
1
1
|
import { EditorView } from "@codemirror/view";
|
|
2
|
-
import {
|
|
3
|
-
|
|
2
|
+
import {
|
|
3
|
+
createDictation,
|
|
4
|
+
type DictationAlternative,
|
|
5
|
+
DictationBar,
|
|
6
|
+
DictationButton,
|
|
7
|
+
type DictationSource,
|
|
8
|
+
Dropdown,
|
|
9
|
+
type DropdownPosition,
|
|
10
|
+
dictationBarShown,
|
|
11
|
+
IconButton,
|
|
12
|
+
Tooltip,
|
|
13
|
+
Toolbar as UiToolbar,
|
|
14
|
+
useLocale,
|
|
15
|
+
} from "@k2b/ui";
|
|
16
|
+
import { createEffect, createMemo, createSignal, For, type JSX, on, onCleanup, onMount, Show } from "solid-js";
|
|
4
17
|
import { ariaShortcut, shortcutLabel } from "./actions";
|
|
18
|
+
import { dictationTarget } from "./dictation";
|
|
5
19
|
import type { EditorAction, EditorHandle } from "./editor-state";
|
|
6
20
|
import { editorMessages } from "./messages";
|
|
7
21
|
|
|
@@ -42,6 +56,12 @@ export type ToolbarProps = {
|
|
|
42
56
|
* and give up the same height in the page above it, so the caret line stays visible.
|
|
43
57
|
*/
|
|
44
58
|
liftAboveKeyboard?: boolean;
|
|
59
|
+
/**
|
|
60
|
+
* Live dictation as the first control after the action row, so it never scrolls away. While it runs, the dictation
|
|
61
|
+
* bar stands in for the action row in the same line; on a phone it fills the line. Without a source there is no
|
|
62
|
+
* microphone.
|
|
63
|
+
*/
|
|
64
|
+
dictation?: { source: DictationSource; alternative?: DictationAlternative };
|
|
45
65
|
};
|
|
46
66
|
|
|
47
67
|
const isMenu = (item: ToolbarItem): item is ToolbarMenu => "kind" in item && item.kind === "menu";
|
|
@@ -114,6 +134,22 @@ export function Toolbar(props: ToolbarProps) {
|
|
|
114
134
|
const [coarse, setCoarse] = createSignal(false);
|
|
115
135
|
const [lift, setLift] = createSignal(0);
|
|
116
136
|
let root!: HTMLDivElement;
|
|
137
|
+
const dictation = createDictation({
|
|
138
|
+
source: {
|
|
139
|
+
start: (options) => props.dictation!.source.start(options),
|
|
140
|
+
devices: () => props.dictation?.source.devices() ?? Promise.resolve([]),
|
|
141
|
+
},
|
|
142
|
+
get alternative() {
|
|
143
|
+
return props.dictation?.alternative;
|
|
144
|
+
},
|
|
145
|
+
target: dictationTarget(props.editor),
|
|
146
|
+
disabled: () => !props.dictation || !props.editor.state() || Boolean(props.editor.state()?.readOnly),
|
|
147
|
+
});
|
|
148
|
+
// A note that turns read-only, or a host that drops the source, ends dictation; the words so far stay.
|
|
149
|
+
// A memo compares the value, so ordinary edits never end it.
|
|
150
|
+
const dictationScope = createMemo(() => `${Boolean(props.dictation)}:${Boolean(props.editor.state()?.readOnly)}`);
|
|
151
|
+
createEffect(on(dictationScope, () => dictation.end(), { defer: true }));
|
|
152
|
+
const dictating = () => Boolean(props.dictation) && dictationBarShown(dictation);
|
|
117
153
|
|
|
118
154
|
onMount(() => {
|
|
119
155
|
const pointer = window.matchMedia("(pointer: coarse)");
|
|
@@ -173,34 +209,40 @@ export function Toolbar(props: ToolbarProps) {
|
|
|
173
209
|
class={props.class ?? "k2b-editor-toolbar flex min-w-0 items-center gap-2 text-dimmed"}
|
|
174
210
|
// The stylesheet sizes every control's icon here, the host's start and end controls included.
|
|
175
211
|
data-k2b-editor-toolbar=""
|
|
212
|
+
data-dictating={dictating() ? "" : undefined}
|
|
176
213
|
style={{ "margin-bottom": lift() ? `${lift()}px` : undefined }}
|
|
177
214
|
on:mousedown={(event) => event.preventDefault()}
|
|
178
215
|
>
|
|
179
216
|
{props.start}
|
|
180
|
-
<
|
|
181
|
-
<
|
|
182
|
-
{
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
<
|
|
186
|
-
{
|
|
187
|
-
|
|
188
|
-
item
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
<
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
217
|
+
<Show when={!dictating()} fallback={<DictationBar dictation={dictation} />}>
|
|
218
|
+
<UiToolbar label={props.label ?? t().formatting} class={props.toolsClass ?? "no-scrollbar min-w-0 flex-1 overflow-x-auto"}>
|
|
219
|
+
<For each={props.groups}>
|
|
220
|
+
{(group, index) => (
|
|
221
|
+
<>
|
|
222
|
+
<UiToolbar.Group label={group.label} class={group.class ?? "shrink-0"}>
|
|
223
|
+
<For each={group.items}>
|
|
224
|
+
{(item) =>
|
|
225
|
+
isCustom(item) ? (
|
|
226
|
+
item.render()
|
|
227
|
+
) : isMenu(item) ? (
|
|
228
|
+
<ToolbarMenuButton editor={props.editor} menu={item} coarse={coarse()} />
|
|
229
|
+
) : (
|
|
230
|
+
<ToolbarButton editor={props.editor} action={item} />
|
|
231
|
+
)
|
|
232
|
+
}
|
|
233
|
+
</For>
|
|
234
|
+
</UiToolbar.Group>
|
|
235
|
+
<Show when={index() < props.groups.length - 1}>
|
|
236
|
+
<UiToolbar.Separator class={group.separatorClass} />
|
|
237
|
+
</Show>
|
|
238
|
+
</>
|
|
239
|
+
)}
|
|
240
|
+
</For>
|
|
241
|
+
</UiToolbar>
|
|
242
|
+
</Show>
|
|
243
|
+
<Show when={props.dictation && !dictating()}>
|
|
244
|
+
<DictationButton dictation={dictation} size="xs" class="text-dimmed" />
|
|
245
|
+
</Show>
|
|
204
246
|
{props.end}
|
|
205
247
|
</div>
|
|
206
248
|
);
|
|
@@ -339,12 +339,17 @@ export const remoteCursors = (ytext: Y.Text, awareness: Awareness): Extension =>
|
|
|
339
339
|
const base = view.scrollDOM.getBoundingClientRect();
|
|
340
340
|
const edge = view.contentDOM.getBoundingClientRect();
|
|
341
341
|
const padding = parseFloat(getComputedStyle(view.contentDOM).paddingLeft);
|
|
342
|
+
// A rendered block hides the source the other person edits, so no line of it shows the caret's place: the
|
|
343
|
+
// caret runs along the whole block, which a line-high caret at its top would misstate.
|
|
344
|
+
// The block as drawn, which a widget may offset from the line the editor gives it.
|
|
345
|
+
const box = view.coordsAtPos(block.from, 1);
|
|
346
|
+
if (!box) return [];
|
|
342
347
|
const rect = new RectangleMarker(
|
|
343
|
-
"cm-remoteCaret",
|
|
348
|
+
"cm-remoteCaret cm-remoteCaret-block",
|
|
344
349
|
edge.left + padding - base.left + view.scrollDOM.scrollLeft,
|
|
345
|
-
|
|
350
|
+
box.top - base.top + view.scrollDOM.scrollTop,
|
|
346
351
|
null,
|
|
347
|
-
|
|
352
|
+
box.bottom - box.top,
|
|
348
353
|
);
|
|
349
354
|
return [new RemoteCaretMarker(rect, color, name, idle)];
|
|
350
355
|
}
|
package/src/dictation.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { DictationTarget } from "@k2b/ui";
|
|
2
|
+
import { codeMirrorDictationTarget, dictationExtension } from "@k2b/ui/text-editor";
|
|
3
|
+
import type { EditorHandle } from "./editor-state";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* A dictation target for the shared editor. Words go into the document at once, as typed text does, so a shared
|
|
7
|
+
* document shows them to everyone and they last as long as the host saves typed text. The mark follows local and
|
|
8
|
+
* remote edits; Discard removes exactly the dictated ranges in one step, including what the person typed inside them.
|
|
9
|
+
*/
|
|
10
|
+
export function dictationTarget(editor: EditorHandle): DictationTarget {
|
|
11
|
+
editor.use(dictationExtension());
|
|
12
|
+
return codeMirrorDictationTarget(editor.view);
|
|
13
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
export { ariaShortcut, isApple, markdownActions, shortcutLabel } from "./actions";
|
|
2
2
|
export { BubbleMenu, type BubbleMenuProps } from "./BubbleMenu";
|
|
3
|
+
export { dictationTarget } from "./dictation";
|
|
3
4
|
export { Editor, type EditorProps } from "./Editor";
|
|
4
5
|
export { asUndoStep, createEditor, type EditorAction, type EditorHandle } from "./editor-state";
|
|
5
6
|
export { editorMessages } from "./messages";
|
|
@@ -251,7 +251,7 @@ class QueryBlockWidget extends WidgetType {
|
|
|
251
251
|
if (this.placeholder) return this.placeholder.estimatedHeight;
|
|
252
252
|
if (this.chart) return 66 + (this.chart.subtitle ? 44 : 28) + 8 + (this.chart.kind === "stat" ? 160 : 288);
|
|
253
253
|
// Query tables have 14px / 21px text, 16px cell padding and row dividers;
|
|
254
|
-
// controls, widget padding/border and table spacing add 65px.
|
|
254
|
+
// controls, widget padding/border and table spacing add 65px, the space around the frame 16px.
|
|
255
255
|
// The truncated count paragraph inherits 16px / 1.6 text (25.6px) in
|
|
256
256
|
// `.cm-query-block-preview.notebook-book-content` and 1rem block margins
|
|
257
257
|
// from app.css. Its top margin collapses with the table's bottom margin,
|
|
@@ -263,7 +263,7 @@ class QueryBlockWidget extends WidgetType {
|
|
|
263
263
|
: this.html?.includes('class="notebook-query-pager"')
|
|
264
264
|
? 36
|
|
265
265
|
: 0;
|
|
266
|
-
return rows ? rows * 38 +
|
|
266
|
+
return rows ? rows * 38 + 81 + countHeight : 136;
|
|
267
267
|
}
|
|
268
268
|
}
|
|
269
269
|
|
|
@@ -82,8 +82,12 @@ export const namedBlocksExtension = (): Extension => {
|
|
|
82
82
|
stateField,
|
|
83
83
|
blockWidgetLineNavigationExtension(stateField, (value) => value.atomicDecorations),
|
|
84
84
|
EditorView.theme({
|
|
85
|
+
// A block of the editor, measured without margins: an inline label would sit in a taller line box than the
|
|
86
|
+
// editor measures, and every line after it would drift from where the editor places it.
|
|
85
87
|
".cm-named-block-handle": {
|
|
88
|
+
display: "flex",
|
|
86
89
|
margin: "0 !important",
|
|
90
|
+
padding: "0.35rem 0 0.15rem",
|
|
87
91
|
},
|
|
88
92
|
}),
|
|
89
93
|
];
|
package/src/styles.css
CHANGED
|
@@ -9,6 +9,19 @@
|
|
|
9
9
|
}
|
|
10
10
|
}
|
|
11
11
|
|
|
12
|
+
/* The dictation bar's controls match the toolbar's own, so the row keeps its height when the bar stands in. */
|
|
13
|
+
[data-k2b-editor-toolbar] .k2b-dictation-bar {
|
|
14
|
+
--k2b-dictation-control: 1.75rem;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/* While dictating, the bar stands in for the action row. On a phone it fills the whole toolbar line; the host's start
|
|
18
|
+
and end controls come back when dictation ended. */
|
|
19
|
+
@media (max-width: 47.999rem) {
|
|
20
|
+
[data-k2b-editor-toolbar][data-dictating] > :not(.k2b-dictation-bar) {
|
|
21
|
+
display: none;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
12
25
|
/* ── Slash-command autocomplete palette ─────────────────────────────────
|
|
13
26
|
The CodeMirror autocomplete tooltip is portaled outside the editor
|
|
14
27
|
surface AND its theme is injected as a runtime `<style>` after this file
|
|
@@ -269,13 +282,21 @@
|
|
|
269
282
|
}
|
|
270
283
|
}
|
|
271
284
|
|
|
272
|
-
/* A rendered block in the editor, and the box the note's server preview reserves for a chart in its place.
|
|
285
|
+
/* A rendered block in the editor, and the box the note's server preview reserves for a chart in its place. The editor
|
|
286
|
+
measures blocks without margins, so the space above and below the frame is the block's own padding and the frame is
|
|
287
|
+
drawn inside it; a margin would move every line after the block away from where the editor places it. */
|
|
273
288
|
.cm-query-block-widget {
|
|
289
|
+
position: relative;
|
|
274
290
|
display: block;
|
|
275
|
-
|
|
276
|
-
|
|
291
|
+
padding: calc(1rem + 1px) calc(0.5rem + 1px);
|
|
292
|
+
}
|
|
293
|
+
.cm-query-block-widget::before {
|
|
294
|
+
content: "";
|
|
295
|
+
position: absolute;
|
|
296
|
+
inset: 0.5rem 0;
|
|
277
297
|
border: 1px solid var(--ui-border);
|
|
278
298
|
border-radius: var(--k2b-radius-control);
|
|
299
|
+
pointer-events: none;
|
|
279
300
|
}
|
|
280
301
|
.cm-query-block-preview.notebook-book-content {
|
|
281
302
|
max-width: none;
|