@k2b/editor 0.4.0 → 0.5.1
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 +3 -2
- package/src/index.ts +1 -0
- package/src/markdown/block-previews.tsx +97 -15
- package/src/markdown/lists.ts +25 -1
- package/src/markdown/notebook-charts.tsx +1 -0
- package/src/markdown/shared-tables.ts +7 -0
- package/src/markdown/table-columns.ts +4 -1
- package/src/markdown/table-formulas.ts +103 -3
- package/src/markdown/task-marker.browser-harness.ts +9 -0
- package/src/slash-menu.ts +5 -1
- package/src/styles.css +30 -0
- package/src/table-cells.ts +160 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@k2b/editor",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.1",
|
|
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.
|
|
73
|
+
"@k2b/ui": "0.43.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
|
@@ -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
|
-
|
|
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
|
-
<
|
|
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
|
|
250
|
-
//
|
|
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
|
|
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
|
-
//
|
|
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 +
|
|
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))
|
|
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 {
|
|
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
|
});
|
package/src/markdown/lists.ts
CHANGED
|
@@ -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
|
-
|
|
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:
|
|
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)\/(\
|
|
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
|
+
});
|