@k2b/editor 0.2.0 → 0.3.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@k2b/editor",
3
- "version": "0.2.0",
3
+ "version": "0.3.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": [
@@ -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.38.1",
72
+ "@k2b/ui": "0.40.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
  >
@@ -339,12 +339,17 @@ export const remoteCursors = (ytext: Y.Text, awareness: Awareness): Extension =>
339
339
  const base = view.scrollDOM.getBoundingClientRect();
340
340
  const edge = view.contentDOM.getBoundingClientRect();
341
341
  const padding = parseFloat(getComputedStyle(view.contentDOM).paddingLeft);
342
+ // A rendered block hides the source the other person edits, so no line of it shows the caret's place: the
343
+ // caret runs along the whole block, which a line-high caret at its top would misstate.
344
+ // The block as drawn, which a widget may offset from the line the editor gives it.
345
+ const box = view.coordsAtPos(block.from, 1);
346
+ if (!box) return [];
342
347
  const rect = new RectangleMarker(
343
- "cm-remoteCaret",
348
+ "cm-remoteCaret cm-remoteCaret-block",
344
349
  edge.left + padding - base.left + view.scrollDOM.scrollLeft,
345
- view.documentTop + block.top - base.top + view.scrollDOM.scrollTop,
350
+ box.top - base.top + view.scrollDOM.scrollTop,
346
351
  null,
347
- view.defaultLineHeight,
352
+ box.bottom - box.top,
348
353
  );
349
354
  return [new RemoteCaretMarker(rect, color, name, idle)];
350
355
  }
@@ -251,7 +251,7 @@ class QueryBlockWidget extends WidgetType {
251
251
  if (this.placeholder) return this.placeholder.estimatedHeight;
252
252
  if (this.chart) return 66 + (this.chart.subtitle ? 44 : 28) + 8 + (this.chart.kind === "stat" ? 160 : 288);
253
253
  // Query tables have 14px / 21px text, 16px cell padding and row dividers;
254
- // controls, widget padding/border and table spacing add 65px.
254
+ // controls, widget padding/border and table spacing add 65px, the space around the frame 16px.
255
255
  // The truncated count paragraph inherits 16px / 1.6 text (25.6px) in
256
256
  // `.cm-query-block-preview.notebook-book-content` and 1rem block margins
257
257
  // from app.css. Its top margin collapses with the table's bottom margin,
@@ -263,7 +263,7 @@ class QueryBlockWidget extends WidgetType {
263
263
  : this.html?.includes('class="notebook-query-pager"')
264
264
  ? 36
265
265
  : 0;
266
- return rows ? rows * 38 + 65 + countHeight : 120;
266
+ return rows ? rows * 38 + 81 + countHeight : 136;
267
267
  }
268
268
  }
269
269
 
@@ -82,8 +82,12 @@ export const namedBlocksExtension = (): Extension => {
82
82
  stateField,
83
83
  blockWidgetLineNavigationExtension(stateField, (value) => value.atomicDecorations),
84
84
  EditorView.theme({
85
+ // A block of the editor, measured without margins: an inline label would sit in a taller line box than the
86
+ // editor measures, and every line after it would drift from where the editor places it.
85
87
  ".cm-named-block-handle": {
88
+ display: "flex",
86
89
  margin: "0 !important",
90
+ padding: "0.35rem 0 0.15rem",
87
91
  },
88
92
  }),
89
93
  ];
@@ -37,19 +37,33 @@ const iconField = StateField.define<DecorationSet>({
37
37
  provide: (field) => EditorView.decorations.from(field),
38
38
  });
39
39
 
40
- const doc = Array.from({ length: 6 }, (_, block) =>
41
- [
42
- ...Array.from({ length: 35 }, (_, line) => `Paragraph ${block + 1}.${line + 1}`),
43
- ":::icon",
44
- `Anchor ${block + 1}`,
45
- ...Array.from({ length: 35 }, (_, line) => `Following ${block + 1}.${line + 1}`),
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({ doc, extensions: [iconField, noteScroller, EditorView.theme({ ".cm-line": { lineHeight: "20px" } })] }),
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];
@@ -8,7 +8,13 @@ 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
10
  import { applyLigatures } from "./ligatures";
11
- import { createPrettyTablePresentation, formatFormulaValue, type PrettyTableData, type PrettyTableOptions } from "./pretty-table";
11
+ import {
12
+ createPrettyTablePresentation,
13
+ formatFormulaError,
14
+ formatFormulaValue,
15
+ type PrettyTableData,
16
+ type PrettyTableOptions,
17
+ } from "./pretty-table";
12
18
  import { formulaPreviewWidget } from "./tables";
13
19
 
14
20
  export type TablePresentationOptions = Omit<PrettyTableOptions, "locale"> & {
@@ -44,21 +50,39 @@ const presentationTheme = EditorView.theme({
44
50
  zIndex: "5",
45
51
  background: "var(--k2b-surface)",
46
52
  },
53
+ // One flat chip right below the edited cell, above the page so the table's scroll area never cuts it.
47
54
  ".cm-notebook-active-result": {
48
- position: "absolute",
49
- bottom: "100%",
50
- right: "0",
51
- zIndex: "6",
52
- padding: "2px 6px",
53
- maxWidth: "18rem",
54
- background: "var(--k2b-surface)",
55
- border: "1px solid var(--k2b-border)",
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",
56
67
  borderRadius: "var(--k2b-radius-control)",
57
- boxShadow: "var(--k2b-shadow-float)",
58
- fontSize: "12px",
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",
59
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" },
60
77
 
61
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
+ },
62
86
  ".k2b-ste-table tr:has([data-notebook-total]) > td": { borderTop: "1px solid var(--ui-border-strong)", fontWeight: "600" },
63
87
  ".cm-notebook-formula-copy": {
64
88
  display: "inline-flex",
@@ -117,19 +141,30 @@ const copyButton = (element: HTMLElement, value: string, locale: string): HTMLBu
117
141
  return button;
118
142
  };
119
143
 
120
- /** The active formula stays over its reserved cell, with the evaluated result
121
- * beside it. Reuse the same presentation as the inactive grid and exports. */
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;
122
148
  const activeFormulaPreview = (options: TablePresentationOptions): Extension =>
123
149
  ViewPlugin.define((view) => {
124
150
  let cell: HTMLElement | null = null;
125
151
  let result: HTMLElement | null = null;
126
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
+ };
127
159
  const clear = () => {
128
160
  cell?.classList.remove("cm-notebook-formula-editor");
161
+ cell?.removeAttribute("aria-describedby");
129
162
  if (cell?.parentElement) cell.parentElement.style.removeProperty("height");
130
163
  result?.remove();
131
164
  result = null;
132
165
  cell = null;
166
+ removeEventListener("scroll", place, true);
167
+ removeEventListener("resize", place);
133
168
  };
134
169
  const paint = () =>
135
170
  queueMicrotask(() => {
@@ -155,15 +190,41 @@ const activeFormulaPreview = (options: TablePresentationOptions): Extension =>
155
190
  parent.style.height = `${parent.getBoundingClientRect().height}px`;
156
191
  result = parent.ownerDocument.createElement("div");
157
192
  result.className = "cm-notebook-active-result";
193
+ result.id = `cm-notebook-active-result-${++resultIds}`;
194
+ result.popover = "manual";
158
195
  result.setAttribute("role", "status");
159
196
  result.contentEditable = "false";
160
197
  parent.append(result);
198
+ result.showPopover();
161
199
  target.classList.add("cm-notebook-formula-editor");
200
+ target.setAttribute("aria-describedby", result.id);
162
201
  cell = target;
202
+ addEventListener("scroll", place, true);
203
+ addEventListener("resize", place);
163
204
  }
164
205
  const locale = options.locale?.() ?? (target.ownerDocument.documentElement.lang || "en");
165
206
  const presentation = createPrettyTablePresentation(presentationData(table), { ...options, locale });
166
- result.innerHTML = presentation.cellHtml(row - 1, column);
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();
167
228
  });
168
229
  view.contentDOM.addEventListener("focusin", paint);
169
230
  view.contentDOM.addEventListener("focusout", paint);
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
@@ -261,13 +269,21 @@
261
269
  }
262
270
  }
263
271
 
264
- /* A rendered block in the editor, and the box the note's server preview reserves for a chart in its place. */
272
+ /* A rendered block in the editor, and the box the note's server preview reserves for a chart in its place. The editor
273
+ measures blocks without margins, so the space above and below the frame is the block's own padding and the frame is
274
+ drawn inside it; a margin would move every line after the block away from where the editor places it. */
265
275
  .cm-query-block-widget {
276
+ position: relative;
266
277
  display: block;
267
- margin: 0.5rem 0;
268
- padding: 0.5rem;
278
+ padding: calc(1rem + 1px) calc(0.5rem + 1px);
279
+ }
280
+ .cm-query-block-widget::before {
281
+ content: "";
282
+ position: absolute;
283
+ inset: 0.5rem 0;
269
284
  border: 1px solid var(--ui-border);
270
285
  border-radius: var(--k2b-radius-control);
286
+ pointer-events: none;
271
287
  }
272
288
  .cm-query-block-preview.notebook-book-content {
273
289
  max-width: none;