@k2b/editor 0.4.0 → 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.4.0",
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.41.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/index.ts CHANGED
@@ -15,3 +15,4 @@ export {
15
15
  type ToolbarMenu,
16
16
  type ToolbarProps,
17
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
@@ -47,6 +47,9 @@
47
47
  font-family: inherit !important;
48
48
  font-size: 0.75rem !important;
49
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;
50
53
  }
51
54
  .dark .cm-tooltip.cm-tooltip-autocomplete {
52
55
  background-color: rgb(24 24 27) !important;
@@ -56,6 +59,9 @@
56
59
  .cm-tooltip-autocomplete > ul {
57
60
  font-family: inherit !important;
58
61
  max-height: 28rem;
62
+ min-height: 0;
63
+ flex: 1 1 auto;
64
+ overflow-y: auto;
59
65
  padding: 0 !important;
60
66
  margin: 0;
61
67
  list-style: none;
@@ -298,6 +304,19 @@
298
304
  border-radius: var(--k2b-radius-control);
299
305
  pointer-events: none;
300
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
+ }
301
320
  .cm-query-block-preview.notebook-book-content {
302
321
  max-width: none;
303
322
  margin: 0;
@@ -317,6 +336,10 @@
317
336
  min-width: 0;
318
337
  margin: 1.25rem 0;
319
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
+ }
320
343
  .notebook-book-chart__header {
321
344
  display: flex;
322
345
  flex-direction: column;
@@ -330,6 +353,13 @@
330
353
  width: 3.75rem;
331
354
  height: 1.75rem;
332
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
+ }
333
363
  /* The table takes the drawing's place inside the same fixed frame and scrolls there. */
334
364
  .notebook-book-chart[data-view="table"] .notebook-book-chart__viewport > :not(.notebook-book-chart__table) {
335
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
+ });