@k2b/editor 0.0.0 → 0.1.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/LICENSE +661 -0
- package/README.md +32 -1
- package/package.json +116 -4
- package/src/BubbleMenu.tsx +126 -0
- package/src/Editor.tsx +32 -0
- package/src/SlashMenu.tsx +13 -0
- package/src/Toolbar.tsx +205 -0
- package/src/actions.ts +102 -0
- package/src/chart-schema.ts +274 -0
- package/src/charts.ts +710 -0
- package/src/collab/history.ts +39 -0
- package/src/collab/index.ts +32 -0
- package/src/collab/relative-cursor.ts +18 -0
- package/src/collab/remote-cursors.ts +358 -0
- package/src/editor-state.ts +199 -0
- package/src/formula.ts +1283 -0
- package/src/index.ts +16 -0
- package/src/markdown/_lib/cursor-zone-field.ts +338 -0
- package/src/markdown/_lib/inline-syntax.ts +9 -0
- package/src/markdown/_lib/link-opener.ts +50 -0
- package/src/markdown/_lib/list-item-line.ts +34 -0
- package/src/markdown/_lib/list-structure.ts +227 -0
- package/src/markdown/_lib/math-syntax.ts +3 -0
- package/src/markdown/_lib/table-cell.ts +74 -0
- package/src/markdown/basic.ts +37 -0
- package/src/markdown/block-previews.tsx +453 -0
- package/src/markdown/chart-block-messages.ts +5 -0
- package/src/markdown/chart-copy.ts +20 -0
- package/src/markdown/code-fence-snippets.ts +167 -0
- package/src/markdown/code-font.ts +54 -0
- package/src/markdown/completion-icon.ts +9 -0
- package/src/markdown/data-block-template.ts +11 -0
- package/src/markdown/data-blocks.ts +214 -0
- package/src/markdown/editor-scope.ts +92 -0
- package/src/markdown/fade-scroll-margins.browser-harness.ts +78 -0
- package/src/markdown/fade-scroll-margins.ts +25 -0
- package/src/markdown/image-syntax.ts +26 -0
- package/src/markdown/images.ts +192 -0
- package/src/markdown/index.ts +54 -0
- package/src/markdown/info-block-snippets.ts +159 -0
- package/src/markdown/info-blocks.ts +156 -0
- package/src/markdown/katex.ts +303 -0
- package/src/markdown/lib/chart-blocks.ts +511 -0
- package/src/markdown/lib/heading-anchors.ts +40 -0
- package/src/markdown/lib/ligatures.ts +107 -0
- package/src/markdown/lib/markdown-context.ts +72 -0
- package/src/markdown/lib/markdown-fences.ts +22 -0
- package/src/markdown/lib/markdown-image.fixtures.ts +20 -0
- package/src/markdown/lib/markdown-image.ts +36 -0
- package/src/markdown/lib/named-blocks.ts +403 -0
- package/src/markdown/lib/query-blocks.ts +145 -0
- package/src/markdown/ligatures.ts +223 -0
- package/src/markdown/links.ts +158 -0
- package/src/markdown/list-commands.ts +1124 -0
- package/src/markdown/lists.ts +290 -0
- package/src/markdown/mark.ts +103 -0
- package/src/markdown/markdown.ts +78 -0
- package/src/markdown/markup.ts +385 -0
- package/src/markdown/mermaid-export.ts +78 -0
- package/src/markdown/mermaid-preview.tsx +76 -0
- package/src/markdown/mermaid-render.ts +22 -0
- package/src/markdown/mermaid-viewport.tsx +78 -0
- package/src/markdown/mermaid.ts +242 -0
- package/src/markdown/messages.ts +17 -0
- package/src/markdown/named-blocks.ts +84 -0
- package/src/markdown/note-scroller.browser-harness.ts +84 -0
- package/src/markdown/note-scroller.ts +57 -0
- package/src/markdown/notebook-charts.tsx +142 -0
- package/src/markdown/pretty-table-messages.ts +33 -0
- package/src/markdown/pretty-table.ts +220 -0
- package/src/markdown/search-theme.ts +98 -0
- package/src/markdown/shared-tables.ts +286 -0
- package/src/markdown/sub-sup.ts +115 -0
- package/src/markdown/surface-press.ts +17 -0
- package/src/markdown/tab-indent.ts +104 -0
- package/src/markdown/table-columns.ts +188 -0
- package/src/markdown/table-formulas.ts +178 -0
- package/src/markdown/tables.ts +703 -0
- package/src/markdown/theme.ts +243 -0
- package/src/markdown/urls.ts +29 -0
- package/src/messages.ts +45 -0
- package/src/slash-menu.ts +220 -0
- package/src/styles.css +376 -0
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Markdown table-row helpers shared by the formula-name autocomplete
|
|
3
|
+
* (`table-formulas.ts`) and the column-name autocomplete
|
|
4
|
+
* (`table-columns.ts`).
|
|
5
|
+
*
|
|
6
|
+
* Both sources need to answer the same two questions per keystroke:
|
|
7
|
+
*
|
|
8
|
+
* 1. Is the current line a table data row? (= begins + ends with
|
|
9
|
+
* `|`, has at least one internal `|`, isn't a separator row.)
|
|
10
|
+
* 2. What's the text typed inside the current cell?
|
|
11
|
+
*
|
|
12
|
+
* Both checks are intentionally heuristic-only (no Lezer-`Table`
|
|
13
|
+
* node lookup) because the parser doesn't promote a freshly-typed
|
|
14
|
+
* row to a `Table` until the structure is complete; the heuristic
|
|
15
|
+
* works during active editing.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/** Matches the `|---|:--:|` separator row plus permissive variants. */
|
|
19
|
+
export const TABLE_SEPARATOR_RE = /^\s*\|?\s*[:\-|\s]+\|?\s*$/;
|
|
20
|
+
|
|
21
|
+
export type TableCellRange = { fromInLine: number; toInLine: number; text: string };
|
|
22
|
+
|
|
23
|
+
/** Source positions stay intact; an escaped GFM pipe belongs to its cell. */
|
|
24
|
+
export const splitTableLineCells = (lineText: string): TableCellRange[] => {
|
|
25
|
+
const separators: number[] = [];
|
|
26
|
+
let backslashes = 0;
|
|
27
|
+
for (let i = 0; i < lineText.length; i++) {
|
|
28
|
+
const char = lineText[i];
|
|
29
|
+
if (char === "|" && backslashes % 2 === 0) separators.push(i);
|
|
30
|
+
backslashes = char === "\\" ? backslashes + 1 : 0;
|
|
31
|
+
}
|
|
32
|
+
const ranges: TableCellRange[] = [];
|
|
33
|
+
let start = 0;
|
|
34
|
+
for (const separator of separators) {
|
|
35
|
+
if (separator === separators[0] && !lineText.slice(0, separator).trim()) {
|
|
36
|
+
start = separator + 1;
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
ranges.push({ fromInLine: start, toInLine: separator, text: lineText.slice(start, separator) });
|
|
40
|
+
start = separator + 1;
|
|
41
|
+
}
|
|
42
|
+
if (start < lineText.length && (lineText.slice(start).trim() || separators.length === 0)) {
|
|
43
|
+
ranges.push({ fromInLine: start, toInLine: lineText.length, text: lineText.slice(start) });
|
|
44
|
+
}
|
|
45
|
+
return ranges;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
export const tableCellText = (raw: string): string => raw.trim().replace(/\\\|/g, "|");
|
|
49
|
+
|
|
50
|
+
/** Is this line a markdown table data row?
|
|
51
|
+
* - begins + ends with `|` (after trim)
|
|
52
|
+
* - has ≥ 2 pipe characters
|
|
53
|
+
* - is NOT the `---` separator row
|
|
54
|
+
*/
|
|
55
|
+
export const isTableRow = (lineText: string): boolean => {
|
|
56
|
+
const trimmed = lineText.trim();
|
|
57
|
+
if (!trimmed.startsWith("|") || !trimmed.endsWith("|")) return false;
|
|
58
|
+
let backslashes = 0;
|
|
59
|
+
for (let i = trimmed.length - 2; i >= 0 && trimmed[i] === "\\"; i--) backslashes++;
|
|
60
|
+
if (backslashes % 2 !== 0) return false;
|
|
61
|
+
if (TABLE_SEPARATOR_RE.test(lineText)) return false;
|
|
62
|
+
return true;
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
/** Find the text between the nearest `|` BEFORE the cursor and the
|
|
66
|
+
* cursor itself. Returns `{ from, text }` with `from` relative to
|
|
67
|
+
* the line, or `null` if no `|` separator exists before the cursor
|
|
68
|
+
* (= cursor is in the line's row-prefix before any cell). */
|
|
69
|
+
export type CellTextBefore = { from: number; text: string };
|
|
70
|
+
|
|
71
|
+
export const cellTextBeforeCursor = (lineText: string, cursorCol: number): CellTextBefore | null => {
|
|
72
|
+
const cell = splitTableLineCells(lineText).find((range) => range.fromInLine <= cursorCol && cursorCol <= range.toInLine);
|
|
73
|
+
return cell ? { from: cell.fromInLine, text: lineText.slice(cell.fromInLine, cursorCol) } : null;
|
|
74
|
+
};
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { closeBrackets, closeBracketsKeymap, completionKeymap } from "@codemirror/autocomplete";
|
|
2
|
+
import { defaultKeymap } from "@codemirror/commands";
|
|
3
|
+
import { bracketMatching, defaultHighlightStyle, foldKeymap, indentOnInput, syntaxHighlighting } from "@codemirror/language";
|
|
4
|
+
import { lintKeymap } from "@codemirror/lint";
|
|
5
|
+
import { highlightSelectionMatches, searchKeymap } from "@codemirror/search";
|
|
6
|
+
import { EditorState, type Extension } from "@codemirror/state";
|
|
7
|
+
import {
|
|
8
|
+
crosshairCursor,
|
|
9
|
+
drawSelection,
|
|
10
|
+
dropCursor,
|
|
11
|
+
EditorView,
|
|
12
|
+
highlightSpecialChars,
|
|
13
|
+
keymap,
|
|
14
|
+
rectangularSelection,
|
|
15
|
+
} from "@codemirror/view";
|
|
16
|
+
import { SPECIAL_CHAR_REGEX } from "./_lib/inline-syntax";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Slightly modified version of the basic extensions provided by CodeMirror.
|
|
20
|
+
* @see https://github.com/codemirror/basic-setup/blob/main/src/codemirror.ts
|
|
21
|
+
*/
|
|
22
|
+
export const basicExtensions: () => Extension = () => [
|
|
23
|
+
highlightSpecialChars({ specialChars: SPECIAL_CHAR_REGEX }),
|
|
24
|
+
drawSelection(),
|
|
25
|
+
dropCursor(),
|
|
26
|
+
EditorView.lineWrapping,
|
|
27
|
+
EditorState.allowMultipleSelections.of(true),
|
|
28
|
+
EditorState.tabSize.of(2),
|
|
29
|
+
indentOnInput(),
|
|
30
|
+
syntaxHighlighting(defaultHighlightStyle, { fallback: true }),
|
|
31
|
+
bracketMatching(),
|
|
32
|
+
closeBrackets(),
|
|
33
|
+
rectangularSelection(),
|
|
34
|
+
crosshairCursor(),
|
|
35
|
+
highlightSelectionMatches(),
|
|
36
|
+
keymap.of([...closeBracketsKeymap, ...defaultKeymap, ...searchKeymap, ...foldKeymap, ...completionKeymap, ...lintKeymap]),
|
|
37
|
+
];
|
|
@@ -0,0 +1,453 @@
|
|
|
1
|
+
import { setDiagnostics } from "@codemirror/lint";
|
|
2
|
+
import { type EditorState, type Extension, type Range, StateEffect, StateField } from "@codemirror/state";
|
|
3
|
+
import { Decoration, type DecorationSet, EditorView, WidgetType } from "@codemirror/view";
|
|
4
|
+
import { query, timed } from "@k2b/stdlib/solid";
|
|
5
|
+
import { Button } from "@k2b/ui";
|
|
6
|
+
import { type Accessor, createEffect, createMemo, createSignal, onCleanup } from "solid-js";
|
|
7
|
+
import { render } from "solid-js/web";
|
|
8
|
+
import {
|
|
9
|
+
blockWidgetLineNavigationExtension,
|
|
10
|
+
holdsDecorations,
|
|
11
|
+
refreshMarkdownDecorationsEffect,
|
|
12
|
+
selectionIntersectsRange,
|
|
13
|
+
} from "./_lib/cursor-zone-field";
|
|
14
|
+
import {
|
|
15
|
+
chartBlockFrame,
|
|
16
|
+
type MarkdownChartOptions,
|
|
17
|
+
notebookChartFrameHtml,
|
|
18
|
+
notebookChartHeight,
|
|
19
|
+
parseNotebookChartBlocks,
|
|
20
|
+
renderNotebookChartFigure,
|
|
21
|
+
} from "./lib/chart-blocks";
|
|
22
|
+
import { extractNotebookDirectiveRanges } from "./lib/query-blocks";
|
|
23
|
+
import { enhanceNotebookCharts } from "./notebook-charts";
|
|
24
|
+
|
|
25
|
+
export type BlockPreviewResult = {
|
|
26
|
+
markdown: string;
|
|
27
|
+
blocks: Array<{ line: number; html: string }>;
|
|
28
|
+
diagnostics: Array<{ line: number; message: string }>;
|
|
29
|
+
headings: Array<{ id: string; line: number }>;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export type BlockPreviewRange = ReturnType<typeof extractNotebookDirectiveRanges>[number];
|
|
33
|
+
export type BlockPreviewMessages = Record<
|
|
34
|
+
BlockPreviewRange["type"] | "loading" | "failed" | "savedChanged" | "reload" | "headingUnavailable" | "showSource" | "retry",
|
|
35
|
+
string
|
|
36
|
+
>;
|
|
37
|
+
export type BlockPreviewPlaceholder = {
|
|
38
|
+
/** Source identity used when deciding whether the existing widget can be kept. */
|
|
39
|
+
key: string;
|
|
40
|
+
estimatedHeight: number;
|
|
41
|
+
render: (content: HTMLElement, status: string) => void;
|
|
42
|
+
};
|
|
43
|
+
export type BlockPreviewOptions = {
|
|
44
|
+
source: Accessor<string>;
|
|
45
|
+
readOnly: boolean;
|
|
46
|
+
initialMarkdown: string;
|
|
47
|
+
view: Accessor<EditorView | undefined>;
|
|
48
|
+
enabled: Accessor<boolean>;
|
|
49
|
+
locale: Accessor<string>;
|
|
50
|
+
charts: MarkdownChartOptions;
|
|
51
|
+
messages: (locale: string) => BlockPreviewMessages;
|
|
52
|
+
load: (markdown: string | undefined, pages: ReadonlyMap<number, number>, signal: AbortSignal) => Promise<BlockPreviewResult>;
|
|
53
|
+
subscribe?: (context: { invalidate: () => Promise<void>; active: () => boolean }) => () => void;
|
|
54
|
+
placeholder?: (block: BlockPreviewRange, source: string, locale: string, readOnly: boolean) => BlockPreviewPlaceholder | null;
|
|
55
|
+
enhance?: (content: HTMLElement, view: EditorView, block: BlockPreviewRange, locale: string, source: string) => () => void;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
type ChartFrame = NonNullable<ReturnType<typeof chartBlockFrame>>;
|
|
59
|
+
type PreviewSnapshot = {
|
|
60
|
+
result?: BlockPreviewResult;
|
|
61
|
+
failed: boolean;
|
|
62
|
+
locale: string;
|
|
63
|
+
retry: () => void;
|
|
64
|
+
/** Shows another page of the query that starts on this line. */
|
|
65
|
+
turnPage: (line: number, page: number) => void;
|
|
66
|
+
/** What a paged query keeps when its widget is replaced: the focused page button and the tallest height shown. */
|
|
67
|
+
pager: QueryPagerMemory;
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
type QueryPagerMemory = { focus: { line: number; index: number } | null; heights: Map<number, number> };
|
|
71
|
+
|
|
72
|
+
const previewEffect = StateEffect.define<PreviewSnapshot>();
|
|
73
|
+
|
|
74
|
+
class QueryBlockWidget extends WidgetType {
|
|
75
|
+
private dispose?: () => void;
|
|
76
|
+
private restoreCharts?: () => void;
|
|
77
|
+
private restoreMaps?: () => void;
|
|
78
|
+
private destroyed = false;
|
|
79
|
+
private content?: HTMLElement;
|
|
80
|
+
|
|
81
|
+
constructor(
|
|
82
|
+
private options: BlockPreviewOptions,
|
|
83
|
+
private block: BlockPreviewRange,
|
|
84
|
+
private snapshot: PreviewSnapshot,
|
|
85
|
+
private html: string | undefined,
|
|
86
|
+
private diagnostic?: string,
|
|
87
|
+
private staleReadOnly = false,
|
|
88
|
+
/** A chart's title, kind and drawn height from its source: the frame stands while its drawing loads or fails. */
|
|
89
|
+
private chart: ChartFrame | null = null,
|
|
90
|
+
private placeholder: BlockPreviewPlaceholder | null = null,
|
|
91
|
+
) {
|
|
92
|
+
super();
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
override eq(other: WidgetType) {
|
|
96
|
+
return (
|
|
97
|
+
other instanceof QueryBlockWidget &&
|
|
98
|
+
other.block.from === this.block.from &&
|
|
99
|
+
other.block.to === this.block.to &&
|
|
100
|
+
other.html === this.html &&
|
|
101
|
+
other.diagnostic === this.diagnostic &&
|
|
102
|
+
other.staleReadOnly === this.staleReadOnly &&
|
|
103
|
+
other.chart?.kind === this.chart?.kind &&
|
|
104
|
+
other.chart?.area === this.chart?.area &&
|
|
105
|
+
other.chart?.title === this.chart?.title &&
|
|
106
|
+
other.chart?.subtitle === this.chart?.subtitle &&
|
|
107
|
+
other.placeholder?.key === this.placeholder?.key &&
|
|
108
|
+
other.chart?.height === this.chart?.height &&
|
|
109
|
+
other.snapshot.failed === this.snapshot.failed &&
|
|
110
|
+
other.snapshot.locale === this.snapshot.locale &&
|
|
111
|
+
other.snapshot.result?.headings === this.snapshot.result?.headings
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
override toDOM(view: EditorView) {
|
|
116
|
+
const t = this.options.messages(this.snapshot.locale);
|
|
117
|
+
const wrapper = document.createElement("div");
|
|
118
|
+
wrapper.className = "cm-query-block-widget";
|
|
119
|
+
wrapper.contentEditable = "false";
|
|
120
|
+
wrapper.setAttribute("role", "group");
|
|
121
|
+
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
|
+
const content = document.createElement("div");
|
|
125
|
+
content.className = "cm-query-block-preview notebook-book-content";
|
|
126
|
+
if (this.html !== undefined && !this.snapshot.failed) {
|
|
127
|
+
content.innerHTML = this.html;
|
|
128
|
+
// A drawn chart gets the same tooltips, table and Copy data as in Book.
|
|
129
|
+
this.restoreCharts = enhanceNotebookCharts(content, this.snapshot.locale, this.options.charts);
|
|
130
|
+
const source = view.state.sliceDoc(this.block.from, this.block.to);
|
|
131
|
+
// Host enhancements mount after CodeMirror attaches the preview (for example a themed map).
|
|
132
|
+
queueMicrotask(() => {
|
|
133
|
+
if (this.destroyed || !content.isConnected) return;
|
|
134
|
+
this.restoreMaps = this.options.enhance?.(content, view, this.block, this.snapshot.locale, source);
|
|
135
|
+
});
|
|
136
|
+
} else if (this.placeholder) {
|
|
137
|
+
this.placeholder.render(content, this.snapshot.failed ? t.failed : this.staleReadOnly ? t.savedChanged : t.loading);
|
|
138
|
+
} else if (this.chart) {
|
|
139
|
+
// The frame keeps the drawn chart's height, so loading, a failure or a reload message never moves the note.
|
|
140
|
+
const status = document.createElement("p");
|
|
141
|
+
status.className = "notebook-book-chart__status text-sm text-dimmed";
|
|
142
|
+
status.setAttribute("role", "status");
|
|
143
|
+
status.textContent = this.snapshot.failed ? t.failed : this.staleReadOnly ? t.savedChanged : (this.diagnostic ?? t.loading);
|
|
144
|
+
content.innerHTML = notebookChartFrameHtml(this.chart, "");
|
|
145
|
+
content.querySelector(".notebook-book-chart__viewport")?.append(status);
|
|
146
|
+
} else {
|
|
147
|
+
content.className += " text-sm text-dimmed";
|
|
148
|
+
content.setAttribute("role", "status");
|
|
149
|
+
content.textContent = this.snapshot.failed ? t.failed : this.staleReadOnly ? t.savedChanged : (this.diagnostic ?? t.loading);
|
|
150
|
+
}
|
|
151
|
+
wrapper.append(controls, content);
|
|
152
|
+
this.content = content;
|
|
153
|
+
const memory = this.snapshot.pager;
|
|
154
|
+
// A paged list never gets shorter while it stays open, so turning to a short last page moves nothing below it.
|
|
155
|
+
const height = memory.heights.get(this.block.line);
|
|
156
|
+
if (height && content.querySelector(".notebook-query-pager")) content.style.minHeight = `${height}px`;
|
|
157
|
+
if (content.querySelector(".notebook-query-pager")) {
|
|
158
|
+
// The replaced widget records its focused button while the editor removes it, after this one is built.
|
|
159
|
+
queueMicrotask(() => {
|
|
160
|
+
const pending = memory.focus;
|
|
161
|
+
if (pending?.line !== this.block.line) return;
|
|
162
|
+
memory.focus = null;
|
|
163
|
+
// Focus returns only if it was lost with the old buttons; a reader who moved on keeps their place.
|
|
164
|
+
const active = document.activeElement;
|
|
165
|
+
if (active && active !== document.body && active.isConnected) return;
|
|
166
|
+
const buttons = content.querySelectorAll<HTMLButtonElement>("button[data-query-page]");
|
|
167
|
+
const button = buttons[pending.index]?.disabled ? buttons[1 - pending.index] : buttons[pending.index];
|
|
168
|
+
if (button?.isConnected) button.focus({ preventScroll: true });
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
const reveal = () => {
|
|
172
|
+
view.dispatch({ selection: { anchor: this.block.from }, scrollIntoView: true });
|
|
173
|
+
view.focus();
|
|
174
|
+
};
|
|
175
|
+
this.dispose = render(
|
|
176
|
+
() => (
|
|
177
|
+
<>
|
|
178
|
+
{this.staleReadOnly && (
|
|
179
|
+
<Button size="xs" variant="ghost" onClick={() => window.location.reload()}>
|
|
180
|
+
{t.reload}
|
|
181
|
+
</Button>
|
|
182
|
+
)}
|
|
183
|
+
{this.snapshot.failed && (
|
|
184
|
+
<Button size="xs" variant="ghost" onClick={this.snapshot.retry}>
|
|
185
|
+
{t.retry}
|
|
186
|
+
</Button>
|
|
187
|
+
)}
|
|
188
|
+
<Button size="xs" variant="ghost" onClick={reveal}>
|
|
189
|
+
{t.showSource}
|
|
190
|
+
</Button>
|
|
191
|
+
</>
|
|
192
|
+
),
|
|
193
|
+
controls,
|
|
194
|
+
);
|
|
195
|
+
// Interactive preview content owns pointer events; never place the editor
|
|
196
|
+
// cursor through a rendered link or control underneath the widget.
|
|
197
|
+
wrapper.addEventListener("mousedown", (event) => event.stopPropagation());
|
|
198
|
+
wrapper.addEventListener("click", (event) => {
|
|
199
|
+
const pager = event.target instanceof Element ? event.target.closest<HTMLButtonElement>("button[data-query-page]") : null;
|
|
200
|
+
if (pager) {
|
|
201
|
+
event.preventDefault();
|
|
202
|
+
event.stopPropagation();
|
|
203
|
+
const page = Number(pager.dataset.queryPage);
|
|
204
|
+
if (pager.disabled || !Number.isInteger(page) || page < 1) return;
|
|
205
|
+
// Safari does not focus a clicked button; the page that arrives returns focus to it.
|
|
206
|
+
pager.focus({ preventScroll: true });
|
|
207
|
+
this.snapshot.turnPage(this.block.line, page);
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
const anchor = event.target instanceof Element ? event.target.closest("a") : null;
|
|
211
|
+
const href = anchor?.getAttribute("href");
|
|
212
|
+
if (!href?.startsWith("#")) return;
|
|
213
|
+
event.preventDefault();
|
|
214
|
+
event.stopPropagation();
|
|
215
|
+
const heading = this.snapshot.result?.headings.find((entry) => entry.id === href.slice(1));
|
|
216
|
+
if (!heading || heading.line < 1 || heading.line > view.state.doc.lines) {
|
|
217
|
+
content.querySelector(".cm-query-heading-unavailable")?.remove();
|
|
218
|
+
const status = document.createElement("p");
|
|
219
|
+
status.className = "cm-query-heading-unavailable text-sm text-dimmed";
|
|
220
|
+
status.setAttribute("role", "status");
|
|
221
|
+
status.textContent = t.headingUnavailable;
|
|
222
|
+
content.append(status);
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
view.dispatch({ selection: { anchor: view.state.doc.line(heading.line).from }, scrollIntoView: true });
|
|
226
|
+
view.focus();
|
|
227
|
+
});
|
|
228
|
+
return wrapper;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
override destroy() {
|
|
232
|
+
this.destroyed = true;
|
|
233
|
+
const content = this.content;
|
|
234
|
+
const pagerButtons = content ? [...content.querySelectorAll<HTMLButtonElement>("button[data-query-page]")] : [];
|
|
235
|
+
if (content && pagerButtons.length > 0) {
|
|
236
|
+
const memory = this.snapshot.pager;
|
|
237
|
+
memory.heights.set(this.block.line, Math.max(memory.heights.get(this.block.line) ?? 0, content.offsetHeight));
|
|
238
|
+
const focused = pagerButtons.indexOf(document.activeElement as HTMLButtonElement);
|
|
239
|
+
if (focused >= 0) memory.focus = { line: this.block.line, index: focused };
|
|
240
|
+
}
|
|
241
|
+
this.dispose?.();
|
|
242
|
+
this.restoreCharts?.();
|
|
243
|
+
this.restoreMaps?.();
|
|
244
|
+
}
|
|
245
|
+
override ignoreEvent() {
|
|
246
|
+
return true;
|
|
247
|
+
}
|
|
248
|
+
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).
|
|
251
|
+
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);
|
|
253
|
+
// Query tables have 14px / 21px text, 16px cell padding and row dividers;
|
|
254
|
+
// controls, widget padding/border and table spacing add 65px.
|
|
255
|
+
// The truncated count paragraph inherits 16px / 1.6 text (25.6px) in
|
|
256
|
+
// `.cm-query-block-preview.notebook-book-content` and 1rem block margins
|
|
257
|
+
// from app.css. Its top margin collapses with the table's bottom margin,
|
|
258
|
+
// so it adds one line plus one 16px margin: 41.6px.
|
|
259
|
+
const rows = this.snapshot.failed ? 0 : (this.html?.match(/<tr(?:\s|>)/g)?.length ?? 0);
|
|
260
|
+
// The page buttons of a paged query add a 28px row and its 8px top margin.
|
|
261
|
+
const countHeight = this.html?.includes('<p class="notebook-book-empty">')
|
|
262
|
+
? 41.6
|
|
263
|
+
: this.html?.includes('class="notebook-query-pager"')
|
|
264
|
+
? 36
|
|
265
|
+
: 0;
|
|
266
|
+
return rows ? rows * 38 + 65 + countHeight : 120;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
type BlockState = { decorations: DecorationSet; snapshot: PreviewSnapshot; ranges: BlockPreviewRange[] };
|
|
271
|
+
|
|
272
|
+
/** A single Solid-owned canonical query serves all blocks in one editor. */
|
|
273
|
+
export function createBlockPreviews(options: BlockPreviewOptions): { extension: Extension; listener: Extension } {
|
|
274
|
+
const [markdown, setMarkdown] = createSignal(options.initialMarkdown.replace(/\r\n?/g, "\n"));
|
|
275
|
+
// The page each query shows, by its source line, while this note is open.
|
|
276
|
+
const [pages, setPages] = createSignal<ReadonlyMap<number, number>>(new Map());
|
|
277
|
+
const pager: QueryPagerMemory = { focus: null, heights: new Map() };
|
|
278
|
+
// Only the queries in the note keep a page; lines left behind by edits are dropped before the next request.
|
|
279
|
+
const currentPages = () => {
|
|
280
|
+
const lines = new Set(
|
|
281
|
+
extractNotebookDirectiveRanges(markdown())
|
|
282
|
+
.filter((block) => block.type === "query")
|
|
283
|
+
.map((block) => block.line),
|
|
284
|
+
);
|
|
285
|
+
return new Map([...pages()].filter(([line]) => lines.has(line)));
|
|
286
|
+
};
|
|
287
|
+
const [ready, setReady] = createSignal(true);
|
|
288
|
+
const hasBlocks = createMemo(() => extractNotebookDirectiveRanges(markdown()).length > 0);
|
|
289
|
+
const previews = query.create({
|
|
290
|
+
source: options.source,
|
|
291
|
+
enabled: () => options.enabled() && hasBlocks() && ready(),
|
|
292
|
+
load: (_source, { abortSignal }) => options.load(options.readOnly ? undefined : markdown(), currentPages(), abortSignal),
|
|
293
|
+
subscribe: ({ invalidate }) => options.subscribe?.({ invalidate, active: () => options.enabled() && hasBlocks() }),
|
|
294
|
+
});
|
|
295
|
+
const update = timed.debounce(() => {
|
|
296
|
+
setReady(true);
|
|
297
|
+
if (hasBlocks()) void previews.invalidate().catch(() => undefined);
|
|
298
|
+
else previews.abort();
|
|
299
|
+
}, 300);
|
|
300
|
+
const turnPage = (line: number, page: number) => {
|
|
301
|
+
setPages(() => currentPages().set(line, page));
|
|
302
|
+
void previews.invalidate().catch(() => undefined);
|
|
303
|
+
};
|
|
304
|
+
const snapshotOf = (result: BlockPreviewResult | undefined, failed: boolean): PreviewSnapshot => ({
|
|
305
|
+
result,
|
|
306
|
+
failed,
|
|
307
|
+
locale: options.locale(),
|
|
308
|
+
retry: () => void previews.refresh(),
|
|
309
|
+
turnPage,
|
|
310
|
+
pager,
|
|
311
|
+
});
|
|
312
|
+
let snapshot: PreviewSnapshot = snapshotOf(undefined, false);
|
|
313
|
+
// The last drawing of each chart, by its source: an edit elsewhere keeps it in place until the new preview arrives.
|
|
314
|
+
let drawnCharts = new Map<string, string>();
|
|
315
|
+
const build = (state: EditorState, next: PreviewSnapshot): BlockState => {
|
|
316
|
+
const document = state.doc.toString();
|
|
317
|
+
const result = next.result?.markdown === document ? next.result : undefined;
|
|
318
|
+
const ranges: Range<Decoration>[] = [];
|
|
319
|
+
const blocks = extractNotebookDirectiveRanges(document);
|
|
320
|
+
if (result)
|
|
321
|
+
drawnCharts = new Map(
|
|
322
|
+
blocks.flatMap((block) => {
|
|
323
|
+
const html = block.type === "chart" ? result.blocks.find((entry) => entry.line === block.line)?.html : undefined;
|
|
324
|
+
return html === undefined ? [] : [[document.slice(block.from, block.to), html] as const];
|
|
325
|
+
}),
|
|
326
|
+
);
|
|
327
|
+
// A placeholder reserves the height its drawing will take; the note's charts are read once, and only for one.
|
|
328
|
+
let heights: Map<number, number | undefined> | undefined;
|
|
329
|
+
const heightAt = (line: number) => {
|
|
330
|
+
heights ??= new Map(
|
|
331
|
+
parseNotebookChartBlocks(document, options.charts).blocks.map((entry) => [
|
|
332
|
+
entry.line,
|
|
333
|
+
notebookChartHeight(entry.input, next.locale),
|
|
334
|
+
]),
|
|
335
|
+
);
|
|
336
|
+
return heights.get(line);
|
|
337
|
+
};
|
|
338
|
+
for (const block of blocks) {
|
|
339
|
+
if (selectionIntersectsRange(state.selection.main, block.from, block.to)) continue;
|
|
340
|
+
const source = block.type === "chart" ? document.slice(block.from, block.to) : "";
|
|
341
|
+
const html = result?.blocks.find((entry) => entry.line === block.line)?.html ?? (source ? drawnCharts.get(source) : undefined);
|
|
342
|
+
const placeholder = options.placeholder?.(block, document.slice(block.from, block.to), next.locale, state.readOnly) ?? null;
|
|
343
|
+
const frame =
|
|
344
|
+
block.type === "chart" ? chartBlockFrame(source.split("\n").slice(1, block.closed ? -1 : undefined), options.charts) : null;
|
|
345
|
+
// A failed refresh shows the placeholder in place of a kept drawing: it keeps that drawing's height too.
|
|
346
|
+
const height = frame && (html === undefined || next.failed) ? heightAt(block.line) : undefined;
|
|
347
|
+
const chart = frame && height ? { ...frame, height } : frame;
|
|
348
|
+
const diagnostic = result?.diagnostics
|
|
349
|
+
.filter((entry) => entry.line >= block.line && entry.line <= state.doc.lineAt(block.to).number)
|
|
350
|
+
.map((entry) => entry.message)
|
|
351
|
+
.join("\n");
|
|
352
|
+
ranges.push(
|
|
353
|
+
Decoration.replace({
|
|
354
|
+
widget: new QueryBlockWidget(
|
|
355
|
+
options,
|
|
356
|
+
block,
|
|
357
|
+
{ ...next, result },
|
|
358
|
+
html,
|
|
359
|
+
diagnostic || undefined,
|
|
360
|
+
options.readOnly && !!next.result && !result,
|
|
361
|
+
chart,
|
|
362
|
+
placeholder,
|
|
363
|
+
),
|
|
364
|
+
block: true,
|
|
365
|
+
}).range(block.from, block.to),
|
|
366
|
+
);
|
|
367
|
+
}
|
|
368
|
+
return { decorations: Decoration.set(ranges, true), snapshot: next, ranges: blocks };
|
|
369
|
+
};
|
|
370
|
+
const field = StateField.define<BlockState>({
|
|
371
|
+
create: (state) => build(state, snapshot),
|
|
372
|
+
update(value, tr) {
|
|
373
|
+
const effect = tr.effects.find((effect) => effect.is(previewEffect));
|
|
374
|
+
if (effect?.is(previewEffect)) return build(tr.state, effect.value);
|
|
375
|
+
if (tr.docChanged || tr.effects.some((effect) => effect.is(refreshMarkdownDecorationsEffect))) return build(tr.state, value.snapshot);
|
|
376
|
+
if (holdsDecorations(tr)) return value;
|
|
377
|
+
if (
|
|
378
|
+
tr.selection &&
|
|
379
|
+
value.ranges.some(
|
|
380
|
+
(range) =>
|
|
381
|
+
selectionIntersectsRange(tr.startState.selection.main, range.from, range.to) !==
|
|
382
|
+
selectionIntersectsRange(tr.state.selection.main, range.from, range.to),
|
|
383
|
+
)
|
|
384
|
+
)
|
|
385
|
+
return build(tr.state, value.snapshot);
|
|
386
|
+
return value;
|
|
387
|
+
},
|
|
388
|
+
provide: (field) => [
|
|
389
|
+
EditorView.decorations.from(field, (value) => value.decorations),
|
|
390
|
+
EditorView.atomicRanges.of((view) => view.state.field(field).decorations),
|
|
391
|
+
],
|
|
392
|
+
});
|
|
393
|
+
let disposed = false;
|
|
394
|
+
let queued = false;
|
|
395
|
+
onCleanup(() => {
|
|
396
|
+
disposed = true;
|
|
397
|
+
});
|
|
398
|
+
createEffect(() => {
|
|
399
|
+
snapshot = snapshotOf(previews.data(), !!previews.error());
|
|
400
|
+
const view = options.view();
|
|
401
|
+
options.enabled();
|
|
402
|
+
markdown();
|
|
403
|
+
if (!view || queued) return;
|
|
404
|
+
queued = true;
|
|
405
|
+
queueMicrotask(() => {
|
|
406
|
+
queued = false;
|
|
407
|
+
if (disposed) return;
|
|
408
|
+
if (view.state.field(field, false)) view.dispatch({ effects: previewEffect.of(snapshot) });
|
|
409
|
+
const diagnostics = snapshot.result?.markdown === view.state.doc.toString() ? snapshot.result.diagnostics : [];
|
|
410
|
+
view.dispatch(
|
|
411
|
+
setDiagnostics(
|
|
412
|
+
view.state,
|
|
413
|
+
diagnostics.map((diagnostic) => {
|
|
414
|
+
const line = view.state.doc.line(Math.max(1, Math.min(diagnostic.line, view.state.doc.lines)));
|
|
415
|
+
return { from: line.from, to: line.to, severity: "error", message: diagnostic.message };
|
|
416
|
+
}),
|
|
417
|
+
),
|
|
418
|
+
);
|
|
419
|
+
});
|
|
420
|
+
});
|
|
421
|
+
return {
|
|
422
|
+
extension: [
|
|
423
|
+
field,
|
|
424
|
+
// The widget's box lives in app.css: the note's server preview reserves the same box for a chart.
|
|
425
|
+
blockWidgetLineNavigationExtension(field, (value) => value.decorations),
|
|
426
|
+
],
|
|
427
|
+
listener: EditorView.updateListener.of((event) => {
|
|
428
|
+
if (!event.docChanged) return;
|
|
429
|
+
setReady(false);
|
|
430
|
+
setMarkdown(event.state.doc.toString());
|
|
431
|
+
update.debouncedFn();
|
|
432
|
+
}),
|
|
433
|
+
};
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/** A local load callback: named tables and formula cells are evaluated without a server. */
|
|
437
|
+
export function localChartPreviews(
|
|
438
|
+
markdown: string,
|
|
439
|
+
locale: string,
|
|
440
|
+
charts: MarkdownChartOptions,
|
|
441
|
+
diagnosticMessage: (diagnostic: import("./lib/query-blocks").NotebookBlockDiagnostic) => string,
|
|
442
|
+
): BlockPreviewResult {
|
|
443
|
+
const parsed = parseNotebookChartBlocks(markdown, charts);
|
|
444
|
+
return {
|
|
445
|
+
markdown,
|
|
446
|
+
blocks: parsed.blocks.flatMap(({ line, input }) => {
|
|
447
|
+
const html = renderNotebookChartFigure(input, locale);
|
|
448
|
+
return html === null ? [] : [{ line, html }];
|
|
449
|
+
}),
|
|
450
|
+
diagnostics: parsed.diagnostics.map((diagnostic) => ({ line: diagnostic.line, message: diagnosticMessage(diagnostic) })),
|
|
451
|
+
headings: [],
|
|
452
|
+
};
|
|
453
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { ChartTable } from "../charts";
|
|
2
|
+
|
|
3
|
+
// A formatted number such as `-1.200,00 €`, `-$18.00` or `-5 %`: a spreadsheet reads it as a value.
|
|
4
|
+
const plainNumber = /^[-+]?\p{Sc}?\s?\d[\d.,\s]*(?:\s?(?:%|\p{Sc}))?$/u;
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Spreadsheets run a pasted cell that starts like a formula. A leading apostrophe keeps it as text, unless the cell is
|
|
8
|
+
* only a formatted number: a unit or compact suffix such as `-1 +1` would still run.
|
|
9
|
+
*/
|
|
10
|
+
const tsvCell = (value: string) => {
|
|
11
|
+
const safe = /^[=+\-@\t\r]/.test(value) && !plainNumber.test(value) ? `'${value}` : value;
|
|
12
|
+
return /[\t\r\n"]/.test(safe) ? `"${safe.replaceAll('"', '""')}"` : safe;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
/** A chart's table as tab-separated text with the shown, formatted cells, as Copy data puts it on the clipboard. */
|
|
16
|
+
export const chartTableText = (table: ChartTable): string =>
|
|
17
|
+
[
|
|
18
|
+
table.columns.map((column) => tsvCell(column.label)).join("\t"),
|
|
19
|
+
...table.rows.map((row) => table.columns.map((column) => tsvCell(row.cells[column.id] ?? "")).join("\t")),
|
|
20
|
+
].join("\n");
|