@k2b/editor 0.3.1 → 0.5.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@k2b/editor",
3
- "version": "0.3.1",
3
+ "version": "0.5.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": [
@@ -21,6 +21,7 @@
21
21
  "./formula": "./src/formula.ts",
22
22
  "./charts": "./src/charts.ts",
23
23
  "./markdown/pretty-table": "./src/markdown/pretty-table.ts",
24
+ "./markdown/shared-tables": "./src/markdown/shared-tables.ts",
24
25
  "./markdown/pretty-table-messages": "./src/markdown/pretty-table-messages.ts",
25
26
  "./markdown/chart-copy": "./src/markdown/chart-copy.ts",
26
27
  "./markdown/notebook-charts": "./src/markdown/notebook-charts.tsx"
@@ -69,7 +70,7 @@
69
70
  "@codemirror/state": "6.7.1",
70
71
  "@codemirror/view": "6.43.7",
71
72
  "@k2b/stdlib": "0.30.0",
72
- "@k2b/ui": "0.40.0",
73
+ "@k2b/ui": "0.42.0",
73
74
  "@lezer/common": "1.5.3",
74
75
  "@lezer/highlight": "1.2.4",
75
76
  "@lezer/markdown": "1.7.2",
package/src/Toolbar.tsx CHANGED
@@ -1,7 +1,21 @@
1
1
  import { EditorView } from "@codemirror/view";
2
- import { Dropdown, type DropdownPosition, IconButton, Tooltip, Toolbar as UiToolbar, useLocale } from "@k2b/ui";
3
- import { createSignal, For, type JSX, onCleanup, onMount, Show } from "solid-js";
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
- <UiToolbar label={props.label ?? t().formatting} class={props.toolsClass ?? "no-scrollbar min-w-0 flex-1 overflow-x-auto"}>
181
- <For each={props.groups}>
182
- {(group, index) => (
183
- <>
184
- <UiToolbar.Group label={group.label} class={group.class ?? "shrink-0"}>
185
- <For each={group.items}>
186
- {(item) =>
187
- isCustom(item) ? (
188
- item.render()
189
- ) : isMenu(item) ? (
190
- <ToolbarMenuButton editor={props.editor} menu={item} coarse={coarse()} />
191
- ) : (
192
- <ToolbarButton editor={props.editor} action={item} />
193
- )
194
- }
195
- </For>
196
- </UiToolbar.Group>
197
- <Show when={index() < props.groups.length - 1}>
198
- <UiToolbar.Separator class={group.separatorClass} />
199
- </Show>
200
- </>
201
- )}
202
- </For>
203
- </UiToolbar>
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
  );
@@ -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";
@@ -14,3 +15,4 @@ export {
14
15
  type ToolbarMenu,
15
16
  type ToolbarProps,
16
17
  } from "./Toolbar";
18
+ export { caretIntoTableCell, cellAt, gridCell, tableAround, tableCellCompletions, tablesOf } from "./table-cells";
@@ -2,7 +2,7 @@ import { setDiagnostics } from "@codemirror/lint";
2
2
  import { type EditorState, type Extension, type Range, StateEffect, StateField } from "@codemirror/state";
3
3
  import { Decoration, type DecorationSet, EditorView, WidgetType } from "@codemirror/view";
4
4
  import { query, timed } from "@k2b/stdlib/solid";
5
- import { Button } from "@k2b/ui";
5
+ import { Button, IconButton } from "@k2b/ui";
6
6
  import { type Accessor, createEffect, createMemo, createSignal, onCleanup } from "solid-js";
7
7
  import { render } from "solid-js/web";
8
8
  import {
@@ -71,6 +71,59 @@ type QueryPagerMemory = { focus: { line: number; index: number } | null; heights
71
71
 
72
72
  const previewEffect = StateEffect.define<PreviewSnapshot>();
73
73
 
74
+ /** The one switch between a block's preview and its source, in the preview's icon group and on the source's first line. */
75
+ function SourceToggle(props: { label: string; pressed: boolean; onClick: () => void }) {
76
+ return (
77
+ <IconButton label={props.label} size="sm" variant="ghost" aria-pressed={props.pressed} onClick={props.onClick}>
78
+ <i class="ti ti-code" aria-hidden="true" />
79
+ </IconButton>
80
+ );
81
+ }
82
+
83
+ /** Rests on the revealed block's first line, without taking room from its text; pressing it brings the preview back. */
84
+ class SourceToggleWidget extends WidgetType {
85
+ private dispose?: () => void;
86
+
87
+ constructor(
88
+ private label: string,
89
+ /** Where the cursor goes to leave the block. */
90
+ private exit: number,
91
+ ) {
92
+ super();
93
+ }
94
+
95
+ override eq(other: WidgetType) {
96
+ return other instanceof SourceToggleWidget && other.label === this.label && other.exit === this.exit;
97
+ }
98
+
99
+ override toDOM(view: EditorView) {
100
+ const host = document.createElement("span");
101
+ host.className = "cm-block-source-toggle";
102
+ this.dispose = render(
103
+ () => (
104
+ <SourceToggle
105
+ label={this.label}
106
+ pressed
107
+ onClick={() => {
108
+ view.dispatch({ selection: { anchor: this.exit } });
109
+ view.focus();
110
+ }}
111
+ />
112
+ ),
113
+ host,
114
+ );
115
+ host.addEventListener("mousedown", (event) => event.stopPropagation());
116
+ return host;
117
+ }
118
+
119
+ override destroy() {
120
+ this.dispose?.();
121
+ }
122
+ override ignoreEvent() {
123
+ return true;
124
+ }
125
+ }
126
+
74
127
  class QueryBlockWidget extends WidgetType {
75
128
  private dispose?: () => void;
76
129
  private restoreCharts?: () => void;
@@ -119,8 +172,6 @@ class QueryBlockWidget extends WidgetType {
119
172
  wrapper.contentEditable = "false";
120
173
  wrapper.setAttribute("role", "group");
121
174
  wrapper.setAttribute("aria-label", t[this.block.type]);
122
- const controls = document.createElement("div");
123
- controls.className = "flex justify-end gap-1 mb-1";
124
175
  const content = document.createElement("div");
125
176
  content.className = "cm-query-block-preview notebook-book-content";
126
177
  if (this.html !== undefined && !this.snapshot.failed) {
@@ -148,7 +199,14 @@ class QueryBlockWidget extends WidgetType {
148
199
  content.setAttribute("role", "status");
149
200
  content.textContent = this.snapshot.failed ? t.failed : this.staleReadOnly ? t.savedChanged : (this.diagnostic ?? t.loading);
150
201
  }
151
- wrapper.append(controls, content);
202
+ // A chart's title row already holds its icon group; any other block gets a row of its own above the preview.
203
+ const chartActions = content.querySelector<HTMLElement>(".notebook-book-chart__actions");
204
+ const controls = chartActions ?? document.createElement("div");
205
+ if (chartActions) wrapper.append(content);
206
+ else {
207
+ controls.className = "flex justify-end gap-1 mb-1";
208
+ wrapper.append(controls, content);
209
+ }
152
210
  this.content = content;
153
211
  const memory = this.snapshot.pager;
154
212
  // A paged list never gets shorter while it stays open, so turning to a short last page moves nothing below it.
@@ -185,9 +243,7 @@ class QueryBlockWidget extends WidgetType {
185
243
  {t.retry}
186
244
  </Button>
187
245
  )}
188
- <Button size="xs" variant="ghost" onClick={reveal}>
189
- {t.showSource}
190
- </Button>
246
+ <SourceToggle label={t.showSource} pressed={false} onClick={reveal} />
191
247
  </>
192
248
  ),
193
249
  controls,
@@ -246,12 +302,12 @@ class QueryBlockWidget extends WidgetType {
246
302
  return true;
247
303
  }
248
304
  override get estimatedHeight() {
249
- // Widget margins, border, padding and controls (66px), the title row beside the chart's 28px controls and the
250
- // kind's fixed frame (app.css).
305
+ // Widget margins, border and padding (34px), the title row beside the chart's 28px controls and the kind's fixed
306
+ // frame (app.css).
251
307
  if (this.placeholder) return this.placeholder.estimatedHeight;
252
- if (this.chart) return 66 + (this.chart.subtitle ? 44 : 28) + 8 + (this.chart.kind === "stat" ? 160 : 288);
308
+ if (this.chart) return 34 + (this.chart.subtitle ? 44 : 28) + 8 + (this.chart.kind === "stat" ? 160 : 288);
253
309
  // Query tables have 14px / 21px text, 16px cell padding and row dividers;
254
- // controls, widget padding/border and table spacing add 65px, the space around the frame 16px.
310
+ // the 28px source switch, widget padding/border and table spacing add 69px, the space around the frame 16px.
255
311
  // The truncated count paragraph inherits 16px / 1.6 text (25.6px) in
256
312
  // `.cm-query-block-preview.notebook-book-content` and 1rem block margins
257
313
  // from app.css. Its top margin collapses with the table's bottom margin,
@@ -263,11 +319,19 @@ class QueryBlockWidget extends WidgetType {
263
319
  : this.html?.includes('class="notebook-query-pager"')
264
320
  ? 36
265
321
  : 0;
266
- return rows ? rows * 38 + 81 + countHeight : 136;
322
+ return rows ? rows * 38 + 85 + countHeight : 140;
267
323
  }
268
324
  }
269
325
 
270
- type BlockState = { decorations: DecorationSet; snapshot: PreviewSnapshot; ranges: BlockPreviewRange[] };
326
+ type BlockState = { decorations: DecorationSet; sourceToggles: DecorationSet; snapshot: PreviewSnapshot; ranges: BlockPreviewRange[] };
327
+
328
+ const sourceToggleLine = Decoration.line({ class: "cm-block-source-line" });
329
+
330
+ /** The nearest place outside every block; a note that is one block alone has none, and its source stays open. */
331
+ const blockExit = (block: BlockPreviewRange, blocks: BlockPreviewRange[], length: number) =>
332
+ [block.to + 1, block.from - 1].find(
333
+ (position) => position >= 0 && position <= length && !blocks.some((other) => position >= other.from && position <= other.to),
334
+ );
271
335
 
272
336
  /** A single Solid-owned canonical query serves all blocks in one editor. */
273
337
  export function createBlockPreviews(options: BlockPreviewOptions): { extension: Extension; listener: Extension } {
@@ -335,8 +399,20 @@ export function createBlockPreviews(options: BlockPreviewOptions): { extension:
335
399
  );
336
400
  return heights.get(line);
337
401
  };
402
+ const sourceToggles: Range<Decoration>[] = [];
338
403
  for (const block of blocks) {
339
- if (selectionIntersectsRange(state.selection.main, block.from, block.to)) continue;
404
+ if (selectionIntersectsRange(state.selection.main, block.from, block.to)) {
405
+ const exit = blockExit(block, blocks, state.doc.length);
406
+ if (exit !== undefined) {
407
+ const line = state.doc.lineAt(block.from);
408
+ const label = options.messages(next.locale).showSource;
409
+ sourceToggles.push(
410
+ sourceToggleLine.range(line.from),
411
+ Decoration.widget({ widget: new SourceToggleWidget(label, exit), side: 1 }).range(line.to),
412
+ );
413
+ }
414
+ continue;
415
+ }
340
416
  const source = block.type === "chart" ? document.slice(block.from, block.to) : "";
341
417
  const html = result?.blocks.find((entry) => entry.line === block.line)?.html ?? (source ? drawnCharts.get(source) : undefined);
342
418
  const placeholder = options.placeholder?.(block, document.slice(block.from, block.to), next.locale, state.readOnly) ?? null;
@@ -365,7 +441,12 @@ export function createBlockPreviews(options: BlockPreviewOptions): { extension:
365
441
  }).range(block.from, block.to),
366
442
  );
367
443
  }
368
- return { decorations: Decoration.set(ranges, true), snapshot: next, ranges: blocks };
444
+ return {
445
+ decorations: Decoration.set(ranges, true),
446
+ sourceToggles: Decoration.set(sourceToggles, true),
447
+ snapshot: next,
448
+ ranges: blocks,
449
+ };
369
450
  };
370
451
  const field = StateField.define<BlockState>({
371
452
  create: (state) => build(state, snapshot),
@@ -387,6 +468,7 @@ export function createBlockPreviews(options: BlockPreviewOptions): { extension:
387
468
  },
388
469
  provide: (field) => [
389
470
  EditorView.decorations.from(field, (value) => value.decorations),
471
+ EditorView.decorations.from(field, (value) => value.sourceToggles),
390
472
  EditorView.atomicRanges.of((view) => view.state.field(field).decorations),
391
473
  ],
392
474
  });
@@ -274,7 +274,31 @@ const listTheme = EditorView.theme({
274
274
  ".custom-list-dot": { boxSizing: "border-box", width: "0.375rem", height: "0.375rem", borderRadius: "50%", background: "currentColor" },
275
275
  ".custom-list-dot-1": { width: "0.4rem", height: "0.4rem", background: "none", border: "1.5px solid currentColor" },
276
276
  ".custom-list-dot-2": { width: "0.35rem", height: "0.35rem", borderRadius: "1px" },
277
- ".custom-list-task-marker": { width: "1rem", height: "1rem", margin: "0", accentColor: "var(--k2b-action-solid)" },
277
+ // Not the native box: it ignores border-radius, so it could not match the @k2b/ui checkbox.
278
+ ".custom-list-task-marker": {
279
+ appearance: "none",
280
+ boxSizing: "border-box",
281
+ width: "1rem",
282
+ height: "1rem",
283
+ margin: "0",
284
+ border: "1px solid var(--k2b-border-strong)",
285
+ borderRadius: "0.25rem",
286
+ background: "var(--k2b-surface)",
287
+ boxShadow: "none",
288
+ cursor: "pointer",
289
+ },
290
+ ".custom-list-task-marker::before": { display: "none" },
291
+ ".custom-list-task-marker:checked": {
292
+ borderColor: "var(--k2b-action-solid)",
293
+ backgroundColor: "var(--k2b-action-solid)",
294
+ // Two gradient strokes, not an image: a host's img-src policy may refuse a data: URL, and an input has no reliable pseudo-element in every engine.
295
+ backgroundImage:
296
+ "linear-gradient(45deg, transparent 27%, #fff 29.5%, #fff 49.2%, transparent 51.7%), linear-gradient(135deg, transparent 41.1%, #fff 43.6%, #fff 60.6%, transparent 63.1%)",
297
+ backgroundPosition: "23.08% 0, 68.97% 0",
298
+ backgroundRepeat: "no-repeat",
299
+ backgroundSize: "22% 100%, 42% 100%",
300
+ forcedColorAdjust: "none",
301
+ },
278
302
  ".custom-list-task-checked": {
279
303
  color: "var(--k2b-placeholder-color)",
280
304
  textDecoration: "line-through",
@@ -83,6 +83,7 @@ function ChartControls(props: { figure: HTMLElement; viewport: HTMLElement; draw
83
83
  text={chartTableText(props.drawn().table)}
84
84
  label={t().copyData}
85
85
  iconOnly
86
+ class="k2b-icon-button"
86
87
  size="sm"
87
88
  variant="ghost"
88
89
  onCopyError={() => toast.error(t().copyFailed)}
@@ -1,4 +1,5 @@
1
1
  /** Notebook presentation only. The public grid owns editing, focus, history and source writes. */
2
+ import { completionStatus } from "@codemirror/autocomplete";
2
3
  import { type EditorState, type Extension, type Range, StateField } from "@codemirror/state";
3
4
  import { Decoration, type DecorationSet, EditorView, ViewPlugin } from "@codemirror/view";
4
5
  import { i18n } from "@k2b/stdlib";
@@ -224,13 +225,19 @@ const activeFormulaPreview = (options: TablePresentationOptions): Extension =>
224
225
  ? formatFormulaError(evaluated, locale).replace(/\n→ /, " · ")
225
226
  : formatFormulaValue(evaluated.value, locale);
226
227
  result.append(message);
228
+ yieldToCompletions();
227
229
  place();
228
230
  });
231
+ // The result sits in the top layer, right where the suggestions open; while they show, they are what matters.
232
+ const yieldToCompletions = () => {
233
+ if (result) result.style.visibility = completionStatus(view.state) ? "hidden" : "";
234
+ };
229
235
  view.contentDOM.addEventListener("focusin", paint);
230
236
  view.contentDOM.addEventListener("focusout", paint);
231
237
  return {
232
238
  update(update) {
233
239
  if (update.docChanged) paint();
240
+ yieldToCompletions();
234
241
  },
235
242
  destroy() {
236
243
  alive = false;
@@ -164,6 +164,9 @@ export const tableColumnCompletionSource = (context: CompletionContext): Complet
164
164
  const charBefore = partialFrom > 0 ? context.state.sliceDoc(partialFrom - 1, partialFrom) : "";
165
165
  const userTypedBacktick = charBefore === "`";
166
166
 
167
+ const columnWord = (typeof document === "undefined" ? "en" : document.documentElement.lang || "en").startsWith("de")
168
+ ? "Spalte"
169
+ : "column";
167
170
  const options: Completion[] = columns.map((col) => {
168
171
  const c: Completion = {
169
172
  // `label` is the raw column name — used both for the popup
@@ -172,7 +175,7 @@ export const tableColumnCompletionSource = (context: CompletionContext): Complet
172
175
  // text is the backtick-quoted form.
173
176
  label: col,
174
177
  type: "property",
175
- detail: "column",
178
+ detail: columnWord,
176
179
  apply: buildColumnApply(col, userTypedBacktick),
177
180
  };
178
181
  withIcon(c, "ti-columns-3");
@@ -33,6 +33,7 @@
33
33
  */
34
34
  import { type Completion, type CompletionContext, type CompletionResult, snippetCompletion } from "@codemirror/autocomplete";
35
35
  import { cellTextBeforeCursor, isTableRow, withIcon } from "@k2b/editor/markdown";
36
+ import { i18n } from "@k2b/stdlib";
36
37
 
37
38
  type Formula = {
38
39
  /** Function name as it appears in the formula source. */
@@ -46,8 +47,7 @@ type Formula = {
46
47
  icon: string;
47
48
  };
48
49
 
49
- /** Registry — keep in sync with `FUNCTIONS` in
50
- * `packages/cloud/src/shared/markdown/formula.ts`. */
50
+ /** Registry — keep in sync with `FUNCTIONS` in `../formula.ts`. */
51
51
  const FORMULAS: Formula[] = [
52
52
  // Math ─────────────────────────────────────────────────────────
53
53
  { name: "ROUND", detail: "(number, digits)", args: "${1:value}, ${2:2}", icon: "ti-math-function" },
@@ -104,6 +104,97 @@ const FORMULAS: Formula[] = [
104
104
  },
105
105
  ];
106
106
 
107
+ /** Signatures of the functions in the note's language; the function names stay as the formula language spells them. */
108
+ const formulaHelp = i18n.define({
109
+ baseLocale: "en",
110
+ messages: {
111
+ en: {
112
+ ROUND: "(number, digits)",
113
+ ABS: "(number)",
114
+ SQRT: "(number) — square root",
115
+ POW: "(base, exp) — power",
116
+ MOD: "(a, b) — remainder",
117
+ SUM: "(column) — sum of column",
118
+ AVG: "(column) — average of column",
119
+ MEAN: "(column) — alias for AVG",
120
+ MIN: "(column) — minimum of column",
121
+ MAX: "(column) — maximum of column",
122
+ COUNT: "(column) — non-empty count",
123
+ MEDIAN: "(column) — median of column",
124
+ UNIQUE: "(column) — distinct non-empty count",
125
+ STDEV: "(column) — sample standard deviation",
126
+ COUNTIF: "(column, value) — count matching cells",
127
+ SUMIF: "(sumCol, condCol, value) — conditional sum",
128
+ PERCENT: "(part, total) — rounded percent",
129
+ PROGRESS: "(ratio) or (done, total) — progress bar",
130
+ ROWSUM: "() — sum of other cells in row",
131
+ ROWAVG: "() — average of other cells in row",
132
+ ROWMEAN: "() — alias for ROWAVG",
133
+ IF: "(cond, then, else)",
134
+ IFEMPTY: "(value, fallback)",
135
+ IFERROR: "(value, fallback)",
136
+ AND: "(a, b, ...) — all truthy",
137
+ OR: "(a, b, ...) — any truthy",
138
+ NOT: "(a) — invert truthiness",
139
+ CONTAINS: "(haystack, needle) — substring match",
140
+ CONCAT: "(...parts) — join strings",
141
+ UPPER: "(text) — uppercase",
142
+ LOWER: "(text) — lowercase",
143
+ TRIM: "(text) — strip whitespace",
144
+ LEFT: "(text, n) — first n chars",
145
+ RIGHT: "(text, n) — last n chars",
146
+ LEN: "(text) — character count",
147
+ SUBSTRING: "(text, start, length)",
148
+ REPLACE: "(text, search, replacement)",
149
+ NOW: "() — current YYYY-MM-DD HH:MM:SS",
150
+ TODAY: "() — current YYYY-MM-DD",
151
+ DATEDIFF: "(d1, d2, unit?) — date diff (d/h/m/s/ms)",
152
+ },
153
+ de: {
154
+ ROUND: "(Zahl, Stellen)",
155
+ ABS: "(Zahl)",
156
+ SQRT: "(Zahl) — Quadratwurzel",
157
+ POW: "(Basis, Exponent) — Potenz",
158
+ MOD: "(a, b) — Rest",
159
+ SUM: "(Spalte) — Summe der Spalte",
160
+ AVG: "(Spalte) — Mittelwert der Spalte",
161
+ MEAN: "(Spalte) — wie AVG",
162
+ MIN: "(Spalte) — kleinster Wert der Spalte",
163
+ MAX: "(Spalte) — größter Wert der Spalte",
164
+ COUNT: "(Spalte) — nicht leere Zellen",
165
+ MEDIAN: "(Spalte) — Median der Spalte",
166
+ UNIQUE: "(Spalte) — verschiedene Werte",
167
+ STDEV: "(Spalte) — Standardabweichung",
168
+ COUNTIF: "(Spalte, Wert) — passende Zellen zählen",
169
+ SUMIF: "(Summenspalte, Bedingungsspalte, Wert) — bedingte Summe",
170
+ PERCENT: "(Teil, Gesamt) — gerundete Prozent",
171
+ PROGRESS: "(Anteil) oder (erledigt, gesamt) — Fortschrittsbalken",
172
+ ROWSUM: "() — Summe der übrigen Zellen der Zeile",
173
+ ROWAVG: "() — Mittelwert der übrigen Zellen der Zeile",
174
+ ROWMEAN: "() — wie ROWAVG",
175
+ IF: "(Bedingung, dann, sonst)",
176
+ IFEMPTY: "(Wert, Ersatz)",
177
+ IFERROR: "(Wert, Ersatz)",
178
+ AND: "(a, b, ...) — alle wahr",
179
+ OR: "(a, b, ...) — mindestens eins wahr",
180
+ NOT: "(a) — Wahrheitswert umkehren",
181
+ CONTAINS: "(Text, Suchtext) — enthält den Text",
182
+ CONCAT: "(...Teile) — Texte verbinden",
183
+ UPPER: "(Text) — Großbuchstaben",
184
+ LOWER: "(Text) — Kleinbuchstaben",
185
+ TRIM: "(Text) — Leerraum entfernen",
186
+ LEFT: "(Text, n) — erste n Zeichen",
187
+ RIGHT: "(Text, n) — letzte n Zeichen",
188
+ LEN: "(Text) — Anzahl der Zeichen",
189
+ SUBSTRING: "(Text, Start, Länge)",
190
+ REPLACE: "(Text, Suche, Ersatz)",
191
+ NOW: "() — aktuell JJJJ-MM-TT HH:MM:SS",
192
+ TODAY: "() — aktuell JJJJ-MM-TT",
193
+ DATEDIFF: "(d1, d2, Einheit?) — Datumsabstand (d/h/m/s/ms)",
194
+ },
195
+ },
196
+ });
197
+
107
198
  /** Snippet template. Does NOT include the leading `=` because the
108
199
  * `from` position in the CompletionResult is anchored AFTER the
109
200
  * `=` the user already typed — CM's prefix-filter compares the
@@ -113,11 +204,15 @@ const FORMULAS: Formula[] = [
113
204
  * `${0}` parks the final cursor outside the parens. */
114
205
  const buildSnippet = (f: Formula): string => (f.args.length === 0 ? `${f.name}()` : `${f.name}(${f.args})\${0}`);
115
206
 
207
+ /** Ranked first among equal matches, so `=SU` offers SUM before SUBSTRING. */
208
+ const COMMON = new Set(["SUM", "AVG", "MIN", "MAX", "COUNT"]);
209
+
116
210
  const COMPLETIONS: Completion[] = FORMULAS.map((f) => {
117
211
  const c = snippetCompletion(buildSnippet(f), {
118
212
  label: f.name,
119
213
  type: "function",
120
214
  detail: f.detail,
215
+ boost: COMMON.has(f.name) ? 1 : 0,
121
216
  });
122
217
  withIcon(c, f.icon);
123
218
  return c;
@@ -165,6 +260,8 @@ export const tableFormulaCompletionSource = (context: CompletionContext): Comple
165
260
  // picking a function).
166
261
  if (!/^\s*=\w*$/.test(cell.text)) return null;
167
262
 
263
+ const help = formulaHelp.resolve(typeof document === "undefined" ? ["en"] : [document.documentElement.lang || "en"]).t;
264
+
168
265
  // Anchor `from` AFTER the `=` so CM's prefix-filter sees just the
169
266
  // function-name fragment (e.g. "SU" → "SUM"). Anchoring at the `=`
170
267
  // would force the labels to start with `=`, which they don't, so
@@ -172,7 +269,10 @@ export const tableFormulaCompletionSource = (context: CompletionContext): Comple
172
269
  return {
173
270
  from: word.from + 1,
174
271
  to: context.pos,
175
- options: COMPLETIONS,
272
+ options: COMPLETIONS.map((completion) => ({
273
+ ...completion,
274
+ detail: help[completion.label as keyof typeof help],
275
+ })),
176
276
  validFor: /^\w*$/,
177
277
  };
178
278
  };
@@ -0,0 +1,9 @@
1
+ import { EditorState } from "@codemirror/state";
2
+ import { EditorView } from "@codemirror/view";
3
+ import { listsExtension } from "./lists";
4
+ import { markdownExtension } from "./markdown";
5
+
6
+ new EditorView({
7
+ parent: document.getElementById("root")!,
8
+ state: EditorState.create({ doc: "# Checklist\n\n- [x] Done\n- [ ] Open", extensions: [markdownExtension(), listsExtension()] }),
9
+ });
package/src/slash-menu.ts CHANGED
@@ -10,6 +10,7 @@ import type { Extension } from "@codemirror/state";
10
10
  import { type EditorView, ViewPlugin } from "@codemirror/view";
11
11
  import { fuzzy } from "@k2b/stdlib";
12
12
  import type { EditorAction } from "./editor-state";
13
+ import { caretIntoTableCell, tableCellCompletions } from "./table-cells";
13
14
 
14
15
  /** One entry of the slash menu: an editor action reached by typing `/` and its name. */
15
16
  export type SlashAction = Omit<EditorAction, "run"> & {
@@ -40,7 +41,7 @@ export type SlashMenuOptions = {
40
41
  };
41
42
 
42
43
  /** `/name` after the line start or a space; group 1 is the space, group 2 the typed name and suffix. */
43
- const trigger = /(^|\s)\/(\w*)$/;
44
+ const trigger = /(^|\s)\/([\p{L}\p{N}_]*)$/u;
44
45
  /** stdlib's fuzzy scores run up to about 100; below this, accidental subsequence matches crowd the list. */
45
46
  const minimumScore = 25;
46
47
 
@@ -206,6 +207,9 @@ const safe =
206
207
  */
207
208
  export const slashMenu = (options: SlashMenuOptions): Extension => [
208
209
  pointerTiming,
210
+ // The popup also serves the table grid's own cells, which write to the same document.
211
+ caretIntoTableCell,
212
+ tableCellCompletions,
209
213
  autocompletion({
210
214
  override: [
211
215
  safe("slash", slashCompletionSource(options.actions)),
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
@@ -34,6 +47,9 @@
34
47
  font-family: inherit !important;
35
48
  font-size: 0.75rem !important;
36
49
  overflow: hidden;
50
+ /* CodeMirror shortens the popup when the screen has less room than the list; the list then scrolls inside it. */
51
+ display: flex;
52
+ flex-direction: column;
37
53
  }
38
54
  .dark .cm-tooltip.cm-tooltip-autocomplete {
39
55
  background-color: rgb(24 24 27) !important;
@@ -43,6 +59,9 @@
43
59
  .cm-tooltip-autocomplete > ul {
44
60
  font-family: inherit !important;
45
61
  max-height: 28rem;
62
+ min-height: 0;
63
+ flex: 1 1 auto;
64
+ overflow-y: auto;
46
65
  padding: 0 !important;
47
66
  margin: 0;
48
67
  list-style: none;
@@ -285,6 +304,19 @@
285
304
  border-radius: var(--k2b-radius-control);
286
305
  pointer-events: none;
287
306
  }
307
+ /* The way back from a block's source rests on its first line and takes no room from the text. */
308
+ .cm-block-source-line {
309
+ position: relative;
310
+ }
311
+ .cm-block-source-toggle {
312
+ position: absolute;
313
+ top: 0;
314
+ right: 0;
315
+ }
316
+ .cm-block-source-toggle .k2b-button[aria-pressed="true"] {
317
+ color: var(--k2b-action);
318
+ background: var(--k2b-selected);
319
+ }
288
320
  .cm-query-block-preview.notebook-book-content {
289
321
  max-width: none;
290
322
  margin: 0;
@@ -304,6 +336,10 @@
304
336
  min-width: 0;
305
337
  margin: 1.25rem 0;
306
338
  }
339
+ /* In the editor the block's own padding is the space around the chart; a margin would add to it unmeasured. */
340
+ .cm-query-block-preview.notebook-book-content .notebook-book-chart {
341
+ margin-block: 0;
342
+ }
307
343
  .notebook-book-chart__header {
308
344
  display: flex;
309
345
  flex-direction: column;
@@ -317,6 +353,13 @@
317
353
  width: 3.75rem;
318
354
  height: 1.75rem;
319
355
  }
356
+ /* In the editor the source switch joins them at the right end, so it stays put while the other two arrive. A failed
357
+ chart's Retry or Reload stands before it and takes the room it needs. */
358
+ .cm-query-block-widget .notebook-book-chart__actions {
359
+ justify-content: flex-end;
360
+ width: auto;
361
+ min-width: 5.75rem;
362
+ }
320
363
  /* The table takes the drawing's place inside the same fixed frame and scrolls there. */
321
364
  .notebook-book-chart[data-view="table"] .notebook-book-chart__viewport > :not(.notebook-book-chart__table) {
322
365
  display: none;
@@ -0,0 +1,160 @@
1
+ /**
2
+ * The shared table grid replaces a table's source with its own editable cells. These extensions keep the editor's
3
+ * caret and its one completion popup working there; `slashMenu` installs both.
4
+ */
5
+ import {
6
+ acceptCompletion,
7
+ closeCompletion,
8
+ completionStatus,
9
+ hasNextSnippetField,
10
+ hasPrevSnippetField,
11
+ moveCompletionSelection,
12
+ nextSnippetField,
13
+ prevSnippetField,
14
+ startCompletion,
15
+ } from "@codemirror/autocomplete";
16
+ import type { EditorState, Extension } from "@codemirror/state";
17
+ import { type EditorView, getTooltip, repositionTooltips, showTooltip, ViewPlugin, type ViewUpdate } from "@codemirror/view";
18
+ import { tableHost } from "@k2b/ui/text-editor";
19
+ import { displayCell, findTables, gridLine, rowCount, type Table } from "@k2b/ui/text-editor/model";
20
+
21
+ const tables = new WeakMap<EditorState, Table[]>();
22
+ /** The tables of this document state, parsed once per state. */
23
+ export const tablesOf = (state: EditorState) => {
24
+ let found = tables.get(state);
25
+ if (!found) {
26
+ found = findTables(state.doc.toString());
27
+ tables.set(state, found);
28
+ }
29
+ return found;
30
+ };
31
+
32
+ /** The table whose source contains `pos`, including its edges. */
33
+ export const tableAround = (state: EditorState, pos: number): Table | null =>
34
+ tablesOf(state).find((table) => pos >= table.from && pos <= table.to) ?? null;
35
+
36
+ /** Row and column of the cell whose source contains `pos`, as the grid addresses it. */
37
+ export const cellAt = (table: Table, pos: number): { row: number; column: number; from: number; to: number } | null => {
38
+ for (let row = 0; row < rowCount(table); row++) {
39
+ const line = gridLine(table, row);
40
+ if (!line || pos < line.from || pos > line.to) continue;
41
+ const before = line.pipes.filter((pipe) => pipe < pos).length;
42
+ const column = Math.max(0, Math.min(line.cells.length - 1, line.leadingPipe ? before - 1 : before));
43
+ const cell = line.cells[column];
44
+ return cell ? { row, column, from: cell.from, to: cell.to } : null;
45
+ }
46
+ return null;
47
+ };
48
+
49
+ /** The rendered grid cell of a table's row and column. */
50
+ export const gridCell = (view: EditorView, table: Table, row: number, column: number): HTMLElement | null => {
51
+ for (const wrap of view.contentDOM.querySelectorAll<HTMLElement>(".k2b-ste-table")) {
52
+ if (view.posAtDOM(wrap) !== table.from) continue;
53
+ return wrap.querySelector<HTMLElement>(`.k2b-ste-cell[data-r="${row}"][data-c="${column}"]`);
54
+ }
55
+ return null;
56
+ };
57
+
58
+ const selectText = (element: HTMLElement, anchor: number, head: number) => {
59
+ const node = element.firstChild;
60
+ const selection = element.ownerDocument.getSelection();
61
+ if (!selection) return;
62
+ const length = node?.textContent?.length ?? 0;
63
+ const range = element.ownerDocument.createRange();
64
+ if (node?.nodeType === Node.TEXT_NODE) {
65
+ range.setStart(node, Math.min(Math.min(anchor, head), length));
66
+ range.setEnd(node, Math.min(Math.max(anchor, head), length));
67
+ } else range.selectNodeContents(element);
68
+ selection.removeAllRanges();
69
+ selection.addRange(range);
70
+ };
71
+
72
+ /**
73
+ * A command that places the prose caret inside a table, such as inserting a
74
+ * table, slash commands or undo, continues in that cell of the grid instead of
75
+ * at a hidden source position. Remote mapping never moves focus.
76
+ */
77
+ export const caretIntoTableCell = ViewPlugin.fromClass(
78
+ class {
79
+ update(update: ViewUpdate) {
80
+ const explicit = update.transactions.some((tr) => tr.selection !== undefined);
81
+ if (!(explicit || update.focusChanged) || !update.view.hasFocus) return;
82
+ const { anchor, head } = update.state.selection.main;
83
+ const table = tableAround(update.state, head);
84
+ if (!table || head <= table.from || head >= table.to) return;
85
+ const view = update.view;
86
+ queueMicrotask(() => {
87
+ if (!view.hasFocus || view.state.selection.main.head !== head) return;
88
+ const hit = cellAt(table, head);
89
+ const element = hit && gridCell(view, table, hit.row, hit.column);
90
+ if (!hit || !element) return;
91
+ const offset = (pos: number) => displayCell(view.state.sliceDoc(hit.from, Math.min(Math.max(pos, hit.from), hit.to))).length;
92
+ element.focus();
93
+ selectText(element, offset(anchor), offset(head));
94
+ });
95
+ }
96
+ },
97
+ );
98
+
99
+ /**
100
+ * Grid cells write to the same CodeMirror state as prose. Reuse its existing
101
+ * completion sources and snippet fields, including formula/column/name sources;
102
+ * only DOM keyboard routing and the tooltip anchor differ for a replaced table.
103
+ */
104
+ export const tableCellCompletions: Extension = ViewPlugin.define((view) => {
105
+ let alive = true;
106
+ const currentCell = () => {
107
+ const focused = view.dom.ownerDocument.activeElement;
108
+ return focused instanceof HTMLElement && focused.matches(".k2b-ste-cell") && view.contentDOM.contains(focused) ? focused : null;
109
+ };
110
+ const keydown = (event: KeyboardEvent) => {
111
+ if (event.isComposing || view.state.readOnly || !currentCell()) return;
112
+ if (event.key === "Tab" && view.state.facet(tableHost)?.tabMovesFocus?.()) return;
113
+ let handled = false;
114
+ const open = completionStatus(view.state) === "active";
115
+ if ((event.ctrlKey || event.metaKey) && event.code === "Space") handled = startCompletion(view);
116
+ else if (event.key === "Escape" && open) handled = closeCompletion(view);
117
+ else if (open && (event.key === "ArrowDown" || event.key === "ArrowUp"))
118
+ handled = moveCompletionSelection(event.key === "ArrowDown")(view);
119
+ else if (open && (event.key === "Enter" || (event.key === "Tab" && !event.shiftKey))) handled = acceptCompletion(view);
120
+ else if (event.key === "Tab") {
121
+ if (event.shiftKey && hasPrevSnippetField(view.state)) handled = prevSnippetField(view);
122
+ else if (!event.shiftKey && hasNextSnippetField(view.state)) handled = nextSnippetField(view);
123
+ }
124
+ if (handled) {
125
+ event.preventDefault();
126
+ event.stopPropagation();
127
+ }
128
+ };
129
+ const position = () =>
130
+ queueMicrotask(() => {
131
+ if (!alive) return;
132
+ const cell = currentCell();
133
+ if (!cell) return;
134
+ for (const tooltip of view.state.facet(showTooltip)) {
135
+ const mounted = tooltip && getTooltip(view, tooltip);
136
+ if (!mounted?.dom.classList.contains("cm-tooltip-autocomplete")) continue;
137
+ mounted.getCoords = () => {
138
+ const selection = cell.ownerDocument.getSelection();
139
+ const caret = selection?.rangeCount ? selection.getRangeAt(0).getBoundingClientRect() : null;
140
+ return caret?.height ? caret : cell.getBoundingClientRect();
141
+ };
142
+ }
143
+ // CodeMirror owns the option list and its IDs; expose the same active option
144
+ // on the actual input so screen readers follow keyboard completion there.
145
+ for (const name of ["aria-autocomplete", "aria-controls", "aria-activedescendant", "aria-expanded"]) {
146
+ const value = view.contentDOM.getAttribute(name);
147
+ if (value) cell.setAttribute(name, value);
148
+ else cell.removeAttribute(name);
149
+ }
150
+ repositionTooltips(view);
151
+ });
152
+ view.contentDOM.addEventListener("keydown", keydown, true);
153
+ return {
154
+ update: position,
155
+ destroy() {
156
+ alive = false;
157
+ view.contentDOM.removeEventListener("keydown", keydown, true);
158
+ },
159
+ };
160
+ });