@k2b/editor 0.1.1 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -2
- package/src/Toolbar.tsx +2 -0
- package/src/markdown/data-blocks.ts +7 -2
- package/src/markdown/lib/named-blocks.ts +2 -2
- package/src/markdown/named-blocks.ts +11 -5
- package/src/markdown/note-scroller.browser-harness.ts +85 -8
- package/src/markdown/note-scroller.ts +92 -1
- package/src/markdown/pretty-table.ts +1 -1
- package/src/markdown/shared-tables.ts +76 -41
- package/src/styles.css +14 -12
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@k2b/editor",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Configurable Markdown editor for SolidJS: editor, toolbar, bubble menu, and slash menu on one shared state.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"sideEffects": [
|
|
@@ -69,7 +69,7 @@
|
|
|
69
69
|
"@codemirror/state": "6.7.1",
|
|
70
70
|
"@codemirror/view": "6.43.7",
|
|
71
71
|
"@k2b/stdlib": "0.30.0",
|
|
72
|
-
"@k2b/ui": "0.
|
|
72
|
+
"@k2b/ui": "0.39.0",
|
|
73
73
|
"@lezer/common": "1.5.3",
|
|
74
74
|
"@lezer/highlight": "1.2.4",
|
|
75
75
|
"@lezer/markdown": "1.7.2",
|
package/src/Toolbar.tsx
CHANGED
|
@@ -171,6 +171,8 @@ export function Toolbar(props: ToolbarProps) {
|
|
|
171
171
|
<div
|
|
172
172
|
ref={root}
|
|
173
173
|
class={props.class ?? "k2b-editor-toolbar flex min-w-0 items-center gap-2 text-dimmed"}
|
|
174
|
+
// The stylesheet sizes every control's icon here, the host's start and end controls included.
|
|
175
|
+
data-k2b-editor-toolbar=""
|
|
174
176
|
style={{ "margin-bottom": lift() ? `${lift()}px` : undefined }}
|
|
175
177
|
on:mousedown={(event) => event.preventDefault()}
|
|
176
178
|
>
|
|
@@ -2,6 +2,7 @@ import { type EditorState, type Extension, Prec, type Range, RangeSet, StateFiel
|
|
|
2
2
|
import { Decoration, type DecorationSet, EditorView, keymap, WidgetType } from "@codemirror/view";
|
|
3
3
|
import { holdsDecorations, refreshMarkdownDecorationsEffect, selectionIntersectsRange } from "./_lib/cursor-zone-field";
|
|
4
4
|
import { type DataBlock, extractDataBlocks, namedBlockBody, renderDataBlockHtml } from "./lib/named-blocks";
|
|
5
|
+
import { blockAnchorsDrawnElsewhere } from "./named-blocks";
|
|
5
6
|
|
|
6
7
|
type DataBlockRange = {
|
|
7
8
|
from: number;
|
|
@@ -52,8 +53,10 @@ class DataBlockWidget extends WidgetType {
|
|
|
52
53
|
|
|
53
54
|
override get estimatedHeight() {
|
|
54
55
|
const rows = Math.max(1, this.body.split("\n").filter((line) => line.trim().length > 0).length);
|
|
55
|
-
// 12px / 16px cells + 16px padding + divider; the
|
|
56
|
-
|
|
56
|
+
// 12px / 16px cells + 16px padding + divider per row; the anchor label (18px line), its margins and the outer
|
|
57
|
+
// padding take just under 44px. Chromium and WebKit round the rows differently (one and two rows measured
|
|
58
|
+
// 75.98 / 109.19px and 109.89px), so the base keeps the estimate at most 1px above both.
|
|
59
|
+
return 43.95 + rows * 33;
|
|
57
60
|
}
|
|
58
61
|
|
|
59
62
|
override ignoreEvent() {
|
|
@@ -76,6 +79,8 @@ export const dataBlocksExtension = (): Extension => {
|
|
|
76
79
|
},
|
|
77
80
|
provide(field) {
|
|
78
81
|
return [
|
|
82
|
+
// The data block widget draws its anchor label together with the block.
|
|
83
|
+
blockAnchorsDrawnElsewhere.of("data"),
|
|
79
84
|
EditorView.decorations.from(field, (value) => value.decorations),
|
|
80
85
|
EditorView.atomicRanges.of((view) => view.state.field(field).blockWidgetDecorations),
|
|
81
86
|
];
|
|
@@ -354,7 +354,7 @@ const humanizeKey = (key: string): string =>
|
|
|
354
354
|
export const renderDataBlockHtml = (name: string | null, src: string): string => {
|
|
355
355
|
const entries = parseNamedDataBlock(src);
|
|
356
356
|
const handle = name
|
|
357
|
-
? `<div class="md-block-handle" data-block-name="${escapeHtml(name)}"
|
|
357
|
+
? `<div class="md-block-handle" data-block-name="${escapeHtml(name)}">${escapeHtml(name)}</div>`
|
|
358
358
|
: `<div class="md-data-handle-row"><div class="md-block-handle">data</div></div>`;
|
|
359
359
|
const rows =
|
|
360
360
|
entries.length > 0
|
|
@@ -392,7 +392,7 @@ export const renderNamedBlockHandlesMarkdown = (md: string | null | undefined):
|
|
|
392
392
|
}
|
|
393
393
|
for (const block of extractNamedBlocks(md)) {
|
|
394
394
|
if (block.type === "data") continue;
|
|
395
|
-
const handle = `<div class="md-block-handle" data-block-name="${escapeHtml(block.name)}"
|
|
395
|
+
const handle = `<div class="md-block-handle" data-block-name="${escapeHtml(block.name)}">${escapeHtml(block.name)}</div>`;
|
|
396
396
|
replacements.push({ from: block.handleStart, to: block.handleEnd, html: handle });
|
|
397
397
|
}
|
|
398
398
|
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type Extension, type Range, RangeSet } from "@codemirror/state";
|
|
1
|
+
import { type Extension, Facet, type Range, RangeSet } from "@codemirror/state";
|
|
2
2
|
import { Decoration, EditorView, WidgetType } from "@codemirror/view";
|
|
3
3
|
import {
|
|
4
4
|
blockWidgetLineNavigationExtension,
|
|
@@ -8,6 +8,13 @@ import {
|
|
|
8
8
|
} from "./_lib/cursor-zone-field";
|
|
9
9
|
import { extractNamedBlocks } from "./lib/named-blocks";
|
|
10
10
|
|
|
11
|
+
/**
|
|
12
|
+
* Block types whose anchor line another extension draws together with the block, such as data blocks. The anchor
|
|
13
|
+
* label leaves those lines alone; every other named block (tables, lists, …) gets the label.
|
|
14
|
+
*/
|
|
15
|
+
export const blockAnchorsDrawnElsewhere = Facet.define<string, ReadonlySet<string>>({ combine: (types) => new Set(types) });
|
|
16
|
+
|
|
17
|
+
/** The anchor line `@name` above a block, shown as a quiet label until the caret enters the line. */
|
|
11
18
|
class NamedBlockHandleWidget extends WidgetType {
|
|
12
19
|
constructor(
|
|
13
20
|
private name: string,
|
|
@@ -19,8 +26,9 @@ class NamedBlockHandleWidget extends WidgetType {
|
|
|
19
26
|
override toDOM(view: EditorView) {
|
|
20
27
|
const el = document.createElement("div");
|
|
21
28
|
el.className = "cm-named-block-handle md-block-handle";
|
|
29
|
+
el.dataset.blockName = this.name;
|
|
22
30
|
el.setAttribute("contenteditable", "false");
|
|
23
|
-
el.textContent =
|
|
31
|
+
el.textContent = this.name;
|
|
24
32
|
el.onmousedown = (event) => {
|
|
25
33
|
event.preventDefault();
|
|
26
34
|
event.stopPropagation();
|
|
@@ -52,9 +60,7 @@ export const namedBlocksExtension = (): Extension => {
|
|
|
52
60
|
const nextLine = state.doc.lineAt(Math.min(block.handleEnd + 1, state.doc.length));
|
|
53
61
|
const sourceVisibleRange = { from: prevLine.from, to: nextLine.to };
|
|
54
62
|
ranges.push(sourceVisibleRange);
|
|
55
|
-
|
|
56
|
-
// with the preview so the handle and body collapse as one stable block.
|
|
57
|
-
if (block.type === "table" || block.type === "data") continue;
|
|
63
|
+
if (state.facet(blockAnchorsDrawnElsewhere).has(block.type)) continue;
|
|
58
64
|
if (selectionIntersectsRange(cursor, sourceVisibleRange.from, sourceVisibleRange.to)) continue;
|
|
59
65
|
const handleDecoration = Decoration.replace({
|
|
60
66
|
widget: new NamedBlockHandleWidget(block.name, block.handleStart),
|
|
@@ -37,19 +37,33 @@ const iconField = StateField.define<DecorationSet>({
|
|
|
37
37
|
provide: (field) => EditorView.decorations.from(field),
|
|
38
38
|
});
|
|
39
39
|
|
|
40
|
-
const
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
].join("\n\n"),
|
|
40
|
+
const words = "Every paragraph wraps at its own width so a narrower column moves some line breaks on almost every frame".split(" ");
|
|
41
|
+
// Paragraphs of different lengths, as in a real note: a column that changes width rewraps one of them on nearly every frame.
|
|
42
|
+
const prose = Array.from({ length: 90 }, (_, index) =>
|
|
43
|
+
[`Paragraph ${index + 1}`, ...Array.from({ length: 30 + ((index * 37) % 50) }, (_, word) => words[(index + word) % words.length])].join(
|
|
44
|
+
" ",
|
|
45
|
+
),
|
|
47
46
|
).join("\n\n");
|
|
48
47
|
|
|
48
|
+
const wraps = new URLSearchParams(location.search).has("prose");
|
|
49
|
+
const doc = wraps
|
|
50
|
+
? prose
|
|
51
|
+
: Array.from({ length: 6 }, (_, block) =>
|
|
52
|
+
[
|
|
53
|
+
...Array.from({ length: 35 }, (_, line) => `Paragraph ${block + 1}.${line + 1}`),
|
|
54
|
+
":::icon",
|
|
55
|
+
`Anchor ${block + 1}`,
|
|
56
|
+
...Array.from({ length: 35 }, (_, line) => `Following ${block + 1}.${line + 1}`),
|
|
57
|
+
].join("\n\n"),
|
|
58
|
+
).join("\n\n");
|
|
59
|
+
|
|
49
60
|
const port = document.getElementById("port")!;
|
|
50
61
|
const view = new EditorView({
|
|
51
62
|
parent: port,
|
|
52
|
-
state: EditorState.create({
|
|
63
|
+
state: EditorState.create({
|
|
64
|
+
doc,
|
|
65
|
+
extensions: [iconField, noteScroller, wraps ? EditorView.lineWrapping : [], EditorView.theme({ ".cm-line": { lineHeight: "20px" } })],
|
|
66
|
+
}),
|
|
53
67
|
});
|
|
54
68
|
|
|
55
69
|
const frame = () => new Promise((resolve) => requestAnimationFrame(resolve));
|
|
@@ -68,6 +82,69 @@ const harness = {
|
|
|
68
82
|
for (let index = 0; index < 4; index++) await frame();
|
|
69
83
|
return { written, scrollTop: port.scrollTop };
|
|
70
84
|
},
|
|
85
|
+
/**
|
|
86
|
+
* Scrolls the page so the given paragraph starts at its top edge, then sets the column width once per frame. Reports,
|
|
87
|
+
* for every painted frame, how far the reading point at the top edge moved: the line there and how far into it the
|
|
88
|
+
* edge fell before the resize.
|
|
89
|
+
*
|
|
90
|
+
* `before` happens first: `edit` inserts paragraphs at the start of the note, as another person would, without
|
|
91
|
+
* scrolling; `column` narrows the column inside the same scroller, as "Full width" does, and the reader then
|
|
92
|
+
* scrolls on to the given paragraph.
|
|
93
|
+
*/
|
|
94
|
+
resizeColumn: async (text: string, widths: number[], focused: boolean, before?: "edit" | "column") => {
|
|
95
|
+
if (focused) view.focus();
|
|
96
|
+
else view.contentDOM.blur();
|
|
97
|
+
// The drawn line itself: asking the editor for coordinates would make it measure first.
|
|
98
|
+
const offset = (target = text) => {
|
|
99
|
+
const { node } = view.domAtPos(view.state.doc.toString().indexOf(target));
|
|
100
|
+
const line = (node instanceof Element ? node : node.parentElement)!.closest(".cm-line")!;
|
|
101
|
+
return Math.round(line.getBoundingClientRect().top - port.getBoundingClientRect().top);
|
|
102
|
+
};
|
|
103
|
+
// Scrolling draws the lines with their real heights, so the line reaches the edge in a few steps.
|
|
104
|
+
const reach = async (target: string) => {
|
|
105
|
+
const from = view.state.doc.toString().indexOf(target);
|
|
106
|
+
port.scrollTop += view.documentTop + view.lineBlockAt(from).top - port.getBoundingClientRect().top;
|
|
107
|
+
for (let step = 0; step < 3; step++) {
|
|
108
|
+
for (let index = 0; index < 4; index++) await frame();
|
|
109
|
+
port.scrollTop += offset(target);
|
|
110
|
+
}
|
|
111
|
+
for (let index = 0; index < 4; index++) await frame();
|
|
112
|
+
};
|
|
113
|
+
if (before === "column") {
|
|
114
|
+
await reach("Paragraph 20 ");
|
|
115
|
+
view.contentDOM.style.maxWidth = "640px";
|
|
116
|
+
for (let index = 0; index < 4; index++) await frame();
|
|
117
|
+
}
|
|
118
|
+
await reach(text);
|
|
119
|
+
if (before === "edit") {
|
|
120
|
+
view.dispatch({ changes: { from: 0, insert: `${words.join(" ")}\n\n`.repeat(3) } });
|
|
121
|
+
for (let index = 0; index < 4; index++) await frame();
|
|
122
|
+
}
|
|
123
|
+
// The reading point: the line at the top edge and how far into it the edge falls.
|
|
124
|
+
const portTop = port.getBoundingClientRect().top;
|
|
125
|
+
const content = view.contentDOM.getBoundingClientRect();
|
|
126
|
+
const hit = document.elementFromPoint(content.left + 2, portTop + 1)!.closest(".cm-line")!;
|
|
127
|
+
const pos = view.posAtDOM(hit);
|
|
128
|
+
const lineAt = () => {
|
|
129
|
+
const { node } = view.domAtPos(pos);
|
|
130
|
+
return (node instanceof Element ? node : node.parentElement)!.closest(".cm-line")!.getBoundingClientRect();
|
|
131
|
+
};
|
|
132
|
+
const share = (portTop - lineAt().top) / lineAt().height;
|
|
133
|
+
const point = () => {
|
|
134
|
+
const line = lineAt();
|
|
135
|
+
return Math.round(line.top + share * line.height - port.getBoundingClientRect().top);
|
|
136
|
+
};
|
|
137
|
+
const start = point();
|
|
138
|
+
const moved: number[] = [];
|
|
139
|
+
for (const width of widths) {
|
|
140
|
+
port.style.width = `${width}px`;
|
|
141
|
+
// Callbacks run in the order they were requested: this one reads what the last frame painted, before the editor
|
|
142
|
+
// measures again.
|
|
143
|
+
await frame();
|
|
144
|
+
moved.push(point() - start);
|
|
145
|
+
}
|
|
146
|
+
return moved;
|
|
147
|
+
},
|
|
71
148
|
/** Deletes the start of the note while the focused editor sits at the bottom, as a collaborator's edit arrives. */
|
|
72
149
|
deleteAboveAtBottom: async (length: number) => {
|
|
73
150
|
view.focus();
|
|
@@ -53,5 +53,96 @@ const lockHeightWhileRendering = ViewPlugin.fromClass(
|
|
|
53
53
|
},
|
|
54
54
|
);
|
|
55
55
|
|
|
56
|
+
/** The nearest ancestor that scrolls vertically: the page's scroll area. */
|
|
57
|
+
const scrollParent = (element: HTMLElement) => {
|
|
58
|
+
for (let parent = element.parentElement; parent; parent = parent.parentElement)
|
|
59
|
+
if (/^(auto|scroll|overlay)$/.test(getComputedStyle(parent).overflowY)) return parent;
|
|
60
|
+
return null;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The line block at the scroller's top edge and how far into it the edge falls, in its heights, with the column and
|
|
65
|
+
* scroller widths it was read at.
|
|
66
|
+
*/
|
|
67
|
+
type ReadingAnchor = { width: number; port: number } & ({ from: number; within: number } | { from: null });
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* A sidebar, panel or window resize changes the column width on every frame and rewraps lines above the page's top
|
|
71
|
+
* edge. CodeMirror keeps the text in place in a page scroller only while it has focus, which the drag took away, and
|
|
72
|
+
* only from the frame after the browser painted the rewrap. Either way the note jumps on every rewrap. This measures
|
|
73
|
+
* the editor in the frame the scroller resizes, before the paint, and keeps the line at the top edge where it was.
|
|
74
|
+
*/
|
|
75
|
+
const holdReadingPosition = ViewPlugin.fromClass(
|
|
76
|
+
class {
|
|
77
|
+
private scroller: HTMLElement | null = null;
|
|
78
|
+
private anchor: ReadingAnchor | null = null;
|
|
79
|
+
private readonly resize = new ResizeObserver(() => this.hold());
|
|
80
|
+
private readonly capture = { key: this, read: () => this.readAnchor() };
|
|
81
|
+
private readonly onScroll = () => this.view.requestMeasure(this.capture);
|
|
82
|
+
|
|
83
|
+
constructor(private readonly view: EditorView) {}
|
|
84
|
+
|
|
85
|
+
update({ view, docChanged, heightChanged, changes }: ViewUpdate) {
|
|
86
|
+
if (this.anchor?.from != null && docChanged) this.anchor.from = changes.mapPos(this.anchor.from);
|
|
87
|
+
// An edit or a block that changed its height moves the text without a scroll: read the place again.
|
|
88
|
+
if (this.scroller && (docChanged || heightChanged)) view.requestMeasure(this.capture);
|
|
89
|
+
if (this.scroller || !view.dom.isConnected) return;
|
|
90
|
+
this.scroller = scrollParent(view.dom);
|
|
91
|
+
if (!this.scroller) return;
|
|
92
|
+
this.resize.observe(this.scroller);
|
|
93
|
+
this.scroller.addEventListener("scroll", this.onScroll, { passive: true });
|
|
94
|
+
view.requestMeasure(this.capture);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
destroy() {
|
|
98
|
+
this.resize.disconnect();
|
|
99
|
+
this.scroller?.removeEventListener("scroll", this.onScroll);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Records where the reader is. A resized scroller keeps the old place for its resize callback; a column that
|
|
104
|
+
* changed inside an unchanged scroller, such as for "Full width", is not this plugin's to hold.
|
|
105
|
+
*/
|
|
106
|
+
private readAnchor() {
|
|
107
|
+
const width = this.view.contentDOM.getBoundingClientRect().width;
|
|
108
|
+
const port = this.scroller!.clientWidth;
|
|
109
|
+
const { anchor } = this;
|
|
110
|
+
if (!anchor || Math.abs(anchor.width - width) < 0.5 || anchor.port === port) this.anchor = this.measureAnchor(width);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
private measureAnchor(width: number): ReadingAnchor {
|
|
114
|
+
const port = this.scroller!.clientWidth;
|
|
115
|
+
const edge = this.scroller!.getBoundingClientRect().top - this.view.documentTop;
|
|
116
|
+
// Nothing above the top edge can move while the note starts below it.
|
|
117
|
+
if (edge <= 0) return { width, port, from: null };
|
|
118
|
+
const block = this.view.lineBlockAtHeight(edge);
|
|
119
|
+
return { width, port, from: block.from, within: (edge - block.top) / Math.max(1, block.height) };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
private hold() {
|
|
123
|
+
const { view, scroller, anchor } = this;
|
|
124
|
+
if (!scroller || !view.dom.isConnected) return;
|
|
125
|
+
const width = view.contentDOM.getBoundingClientRect().width;
|
|
126
|
+
if (anchor?.from != null && Math.abs(anchor.width - width) >= 0.5) {
|
|
127
|
+
// Reading the layout runs the requested measure now. A focused editor corrects part of the rewrap itself;
|
|
128
|
+
// this adds only the rest. A correction onto lines it had not drawn lands on estimated heights; the next pass
|
|
129
|
+
// draws them, and three passes settle.
|
|
130
|
+
for (let pass = 0; pass < 3; pass++) {
|
|
131
|
+
view.requestMeasure();
|
|
132
|
+
// Any layout read runs the requested measure first; the block is then at its new place.
|
|
133
|
+
view.lineBlockAtHeight(0);
|
|
134
|
+
const block = view.lineBlockAt(Math.min(anchor.from, view.state.doc.length));
|
|
135
|
+
const edge = scroller.getBoundingClientRect().top - view.documentTop;
|
|
136
|
+
// WebKit drops a fraction of a pixel from the scroll offset.
|
|
137
|
+
const delta = Math.round(block.top + anchor.within * block.height - edge);
|
|
138
|
+
if (delta === 0) break;
|
|
139
|
+
scroller.scrollTop += delta;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
this.anchor = this.measureAnchor(width);
|
|
143
|
+
}
|
|
144
|
+
},
|
|
145
|
+
);
|
|
146
|
+
|
|
56
147
|
/** The page scrolls the note; CodeMirror only renders it. */
|
|
57
|
-
export const noteScroller = [noteScrollerTheme, lockHeightWhileRendering];
|
|
148
|
+
export const noteScroller = [noteScrollerTheme, lockHeightWhileRendering, holdReadingPosition];
|
|
@@ -205,7 +205,7 @@ export const createPrettyTablePresentation = (data: PrettyTableData, options: Pr
|
|
|
205
205
|
export const renderPrettyTableHtml = (data: PrettyTableData, options: PrettyTableOptions = {}): string => {
|
|
206
206
|
const presentation = createPrettyTablePresentation(data, options);
|
|
207
207
|
const caption = data.caption
|
|
208
|
-
? `<div class="md-block-handle" data-block-name="${escapeHtml(data.caption)}"
|
|
208
|
+
? `<div class="md-block-handle" data-block-name="${escapeHtml(data.caption)}">${escapeHtml(data.caption)}</div>`
|
|
209
209
|
: "";
|
|
210
210
|
const headerHtml = data.headers.map((_, i) => `<th scope="col">${presentation.headerHtml(i)}</th>`).join("");
|
|
211
211
|
// An all-empty GFM header is the approved header-off representation.
|
|
@@ -7,9 +7,14 @@ import type { TableHost } from "@k2b/ui/text-editor";
|
|
|
7
7
|
import { displayCell, findTables, type Table } from "@k2b/ui/text-editor/model";
|
|
8
8
|
import { isFormula } from "../formula";
|
|
9
9
|
import { refreshMarkdownDecorationsEffect } from "./_lib/cursor-zone-field";
|
|
10
|
-
import { extractNamedBlocks } from "./lib/named-blocks";
|
|
11
10
|
import { applyLigatures } from "./ligatures";
|
|
12
|
-
import {
|
|
11
|
+
import {
|
|
12
|
+
createPrettyTablePresentation,
|
|
13
|
+
formatFormulaError,
|
|
14
|
+
formatFormulaValue,
|
|
15
|
+
type PrettyTableData,
|
|
16
|
+
type PrettyTableOptions,
|
|
17
|
+
} from "./pretty-table";
|
|
13
18
|
import { formulaPreviewWidget } from "./tables";
|
|
14
19
|
|
|
15
20
|
export type TablePresentationOptions = Omit<PrettyTableOptions, "locale"> & {
|
|
@@ -33,30 +38,6 @@ const presentationData = (table: Table): PrettyTableData => {
|
|
|
33
38
|
return { headers: values(table.lines[0]), rows: table.lines.slice(2).map(values), align: table.align };
|
|
34
39
|
};
|
|
35
40
|
|
|
36
|
-
const captionDecorations = (state: EditorState): DecorationSet => {
|
|
37
|
-
const source = state.doc.toString();
|
|
38
|
-
const starts = new Set(findTables(source).map((table) => table.from));
|
|
39
|
-
return Decoration.set(
|
|
40
|
-
extractNamedBlocks(source)
|
|
41
|
-
.filter((block) => block.type === "table" && starts.has(block.blockStart))
|
|
42
|
-
.map((block) =>
|
|
43
|
-
// Keep the actual handle editable. namedBlocksExtension skips table handles;
|
|
44
|
-
// tableGrid starts on the header, so neither extension renders a second caption.
|
|
45
|
-
Decoration.mark({ class: "md-block-handle cm-notebook-table-caption", attributes: { "data-block-name": block.name } }).range(
|
|
46
|
-
block.handleStart,
|
|
47
|
-
block.handleEnd,
|
|
48
|
-
),
|
|
49
|
-
),
|
|
50
|
-
true,
|
|
51
|
-
);
|
|
52
|
-
};
|
|
53
|
-
|
|
54
|
-
const tableCaptions = StateField.define<DecorationSet>({
|
|
55
|
-
create: captionDecorations,
|
|
56
|
-
update: (value, tr) => (tr.docChanged ? captionDecorations(tr.state) : value),
|
|
57
|
-
provide: (field) => EditorView.decorations.from(field),
|
|
58
|
-
});
|
|
59
|
-
|
|
60
41
|
const presentationTheme = EditorView.theme({
|
|
61
42
|
".k2b-ste-table .cm-notebook-formula-editor": {
|
|
62
43
|
position: "absolute",
|
|
@@ -69,23 +50,40 @@ const presentationTheme = EditorView.theme({
|
|
|
69
50
|
zIndex: "5",
|
|
70
51
|
background: "var(--k2b-surface)",
|
|
71
52
|
},
|
|
53
|
+
// One flat chip right below the edited cell, above the page so the table's scroll area never cuts it.
|
|
72
54
|
".cm-notebook-active-result": {
|
|
73
|
-
position: "
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
55
|
+
position: "fixed",
|
|
56
|
+
inset: "auto",
|
|
57
|
+
margin: "0",
|
|
58
|
+
display: "flex",
|
|
59
|
+
alignItems: "baseline",
|
|
60
|
+
gap: "6px",
|
|
61
|
+
width: "max-content",
|
|
62
|
+
height: "auto",
|
|
63
|
+
maxWidth: "min(20rem, calc(100vw - 16px))",
|
|
64
|
+
overflow: "visible",
|
|
65
|
+
padding: "4px 8px",
|
|
66
|
+
border: "0",
|
|
81
67
|
borderRadius: "var(--k2b-radius-control)",
|
|
82
|
-
boxShadow: "
|
|
83
|
-
|
|
68
|
+
boxShadow: "none",
|
|
69
|
+
background: "var(--k2b-surface-muted)",
|
|
70
|
+
color: "var(--k2b-text-secondary)",
|
|
71
|
+
font: "12.5px/1.4 var(--k2b-font-sans)",
|
|
72
|
+
whiteSpace: "normal",
|
|
73
|
+
overflowWrap: "anywhere",
|
|
84
74
|
},
|
|
75
|
+
".cm-notebook-active-result[data-error]": { background: "var(--k2b-danger-surface)", color: "var(--k2b-danger-text)" },
|
|
76
|
+
".cm-notebook-active-result > .ti": { flex: "none", alignSelf: "center" },
|
|
85
77
|
|
|
86
78
|
".k2b-ste-table .k2b-ste-cell > .md-table-cell": { padding: "0", minHeight: "0" },
|
|
79
|
+
".k2b-ste-table .k2b-ste-cell > .md-table-cell.md-formula-error": {
|
|
80
|
+
display: "inline-block",
|
|
81
|
+
padding: "0 6px",
|
|
82
|
+
borderRadius: "var(--k2b-radius-control)",
|
|
83
|
+
fontFamily: "var(--k2b-font-mono)",
|
|
84
|
+
fontSize: "12.5px",
|
|
85
|
+
},
|
|
87
86
|
".k2b-ste-table tr:has([data-notebook-total]) > td": { borderTop: "1px solid var(--ui-border-strong)", fontWeight: "600" },
|
|
88
|
-
".cm-notebook-table-caption": { margin: "0" },
|
|
89
87
|
".cm-notebook-formula-copy": {
|
|
90
88
|
display: "inline-flex",
|
|
91
89
|
alignItems: "center",
|
|
@@ -143,19 +141,30 @@ const copyButton = (element: HTMLElement, value: string, locale: string): HTMLBu
|
|
|
143
141
|
return button;
|
|
144
142
|
};
|
|
145
143
|
|
|
146
|
-
/** The active formula stays over its reserved cell, with
|
|
147
|
-
*
|
|
144
|
+
/** The active formula stays over its reserved cell, with its result or error
|
|
145
|
+
* right below it, described to screen readers. Reuse the same evaluation as
|
|
146
|
+
* the inactive grid and exports. */
|
|
147
|
+
let resultIds = 0;
|
|
148
148
|
const activeFormulaPreview = (options: TablePresentationOptions): Extension =>
|
|
149
149
|
ViewPlugin.define((view) => {
|
|
150
150
|
let cell: HTMLElement | null = null;
|
|
151
151
|
let result: HTMLElement | null = null;
|
|
152
152
|
let alive = true;
|
|
153
|
+
const place = () => {
|
|
154
|
+
const box = cell?.parentElement?.getBoundingClientRect();
|
|
155
|
+
if (!box || !result) return;
|
|
156
|
+
result.style.left = `${Math.max(8, Math.min(box.left, innerWidth - result.offsetWidth - 8))}px`;
|
|
157
|
+
result.style.top = `${box.bottom + 4}px`;
|
|
158
|
+
};
|
|
153
159
|
const clear = () => {
|
|
154
160
|
cell?.classList.remove("cm-notebook-formula-editor");
|
|
161
|
+
cell?.removeAttribute("aria-describedby");
|
|
155
162
|
if (cell?.parentElement) cell.parentElement.style.removeProperty("height");
|
|
156
163
|
result?.remove();
|
|
157
164
|
result = null;
|
|
158
165
|
cell = null;
|
|
166
|
+
removeEventListener("scroll", place, true);
|
|
167
|
+
removeEventListener("resize", place);
|
|
159
168
|
};
|
|
160
169
|
const paint = () =>
|
|
161
170
|
queueMicrotask(() => {
|
|
@@ -181,15 +190,41 @@ const activeFormulaPreview = (options: TablePresentationOptions): Extension =>
|
|
|
181
190
|
parent.style.height = `${parent.getBoundingClientRect().height}px`;
|
|
182
191
|
result = parent.ownerDocument.createElement("div");
|
|
183
192
|
result.className = "cm-notebook-active-result";
|
|
193
|
+
result.id = `cm-notebook-active-result-${++resultIds}`;
|
|
194
|
+
result.popover = "manual";
|
|
184
195
|
result.setAttribute("role", "status");
|
|
185
196
|
result.contentEditable = "false";
|
|
186
197
|
parent.append(result);
|
|
198
|
+
result.showPopover();
|
|
187
199
|
target.classList.add("cm-notebook-formula-editor");
|
|
200
|
+
target.setAttribute("aria-describedby", result.id);
|
|
188
201
|
cell = target;
|
|
202
|
+
addEventListener("scroll", place, true);
|
|
203
|
+
addEventListener("resize", place);
|
|
189
204
|
}
|
|
190
205
|
const locale = options.locale?.() ?? (target.ownerDocument.documentElement.lang || "en");
|
|
191
206
|
const presentation = createPrettyTablePresentation(presentationData(table), { ...options, locale });
|
|
192
|
-
|
|
207
|
+
const evaluated = presentation.rows[row - 1]?.cells[column]?.result;
|
|
208
|
+
if (!evaluated) {
|
|
209
|
+
clear();
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
// An error says what is wrong and what helps, on one wrapping line; a valid result shows quietly.
|
|
213
|
+
const error = evaluated.kind === "error";
|
|
214
|
+
result.toggleAttribute("data-error", error);
|
|
215
|
+
result.replaceChildren();
|
|
216
|
+
if (error) {
|
|
217
|
+
const icon = result.ownerDocument.createElement("i");
|
|
218
|
+
icon.className = "ti ti-alert-triangle";
|
|
219
|
+
icon.setAttribute("aria-hidden", "true");
|
|
220
|
+
result.append(icon);
|
|
221
|
+
}
|
|
222
|
+
const message = result.ownerDocument.createElement("span");
|
|
223
|
+
message.textContent = error
|
|
224
|
+
? formatFormulaError(evaluated, locale).replace(/\n→ /, " · ")
|
|
225
|
+
: formatFormulaValue(evaluated.value, locale);
|
|
226
|
+
result.append(message);
|
|
227
|
+
place();
|
|
193
228
|
});
|
|
194
229
|
view.contentDOM.addEventListener("focusin", paint);
|
|
195
230
|
view.contentDOM.addEventListener("focusout", paint);
|
|
@@ -216,7 +251,7 @@ export const createNotebookTablePresentation = (
|
|
|
216
251
|
} => {
|
|
217
252
|
const snapshots = new WeakMap<Table, { locale: string; presentation: ReturnType<typeof createPrettyTablePresentation> }>();
|
|
218
253
|
return {
|
|
219
|
-
extension: [
|
|
254
|
+
extension: [presentationTheme, activeFormulaPreview(options)],
|
|
220
255
|
renderCell(element, _raw, table, row, column) {
|
|
221
256
|
const locale = options.locale?.() ?? (element.ownerDocument.documentElement.lang || "en");
|
|
222
257
|
let snapshot = snapshots.get(table);
|
package/src/styles.css
CHANGED
|
@@ -1,6 +1,14 @@
|
|
|
1
1
|
/* Generate utility classes for the source package in the consuming Tailwind build. */
|
|
2
2
|
@source "./**/*.{ts,tsx}";
|
|
3
3
|
|
|
4
|
+
/* One icon size for every control of an editor toolbar, the host's start and end controls included, so an icon a
|
|
5
|
+
host adds matches the formatting icons without its own size class. A utility on one icon still wins. */
|
|
6
|
+
@layer components {
|
|
7
|
+
[data-k2b-editor-toolbar] .k2b-button > .k2b-button__label > .ti:first-child {
|
|
8
|
+
font-size: 0.875rem;
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
|
|
4
12
|
/* ── Slash-command autocomplete palette ─────────────────────────────────
|
|
5
13
|
The CodeMirror autocomplete tooltip is portaled outside the editor
|
|
6
14
|
surface AND its theme is injected as a runtime `<style>` after this file
|
|
@@ -142,25 +150,19 @@
|
|
|
142
150
|
display: none !important;
|
|
143
151
|
}
|
|
144
152
|
|
|
153
|
+
/* The anchor of a named block (`@name` in the source) reads as a quiet label above the block, in the label type of
|
|
154
|
+
form fields. The caret in its line shows the source again. */
|
|
145
155
|
.md-block-handle {
|
|
146
156
|
display: inline-flex;
|
|
147
157
|
align-items: center;
|
|
148
158
|
width: fit-content;
|
|
149
159
|
max-width: 100%;
|
|
150
160
|
margin: 0.35rem 0 0.15rem;
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
background: rgb(239 246 255);
|
|
154
|
-
color: rgb(37 99 235);
|
|
155
|
-
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
|
|
156
|
-
font-size: 0.6875rem;
|
|
161
|
+
color: var(--k2b-text-muted);
|
|
162
|
+
font-size: 0.8125rem;
|
|
157
163
|
font-weight: 600;
|
|
158
|
-
line-height: 1.
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
.dark .md-block-handle {
|
|
162
|
-
background: rgb(30 58 138 / 0.25);
|
|
163
|
-
color: rgb(147 197 253);
|
|
164
|
+
line-height: 1.125rem;
|
|
165
|
+
overflow-wrap: anywhere;
|
|
164
166
|
}
|
|
165
167
|
|
|
166
168
|
.md-data-handle-row {
|