@k2b/editor 0.0.0-stage → 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 +31 -2
- package/package.json +118 -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,220 @@
|
|
|
1
|
+
import { dates } from "@k2b/stdlib";
|
|
2
|
+
import { markdownLinkReference, renderMarkdownLink } from "@k2b/ui";
|
|
3
|
+
import {
|
|
4
|
+
type EvalError,
|
|
5
|
+
type EvalResult,
|
|
6
|
+
type EvalValue,
|
|
7
|
+
evaluateFormula,
|
|
8
|
+
evaluateTableCell,
|
|
9
|
+
formatValue,
|
|
10
|
+
isFormula,
|
|
11
|
+
isTotalRow,
|
|
12
|
+
type ProgressValue,
|
|
13
|
+
parseProgressValue,
|
|
14
|
+
} from "../formula";
|
|
15
|
+
import { parseNoteLink } from "./lib/heading-anchors";
|
|
16
|
+
import { prettyTableMessages } from "./pretty-table-messages";
|
|
17
|
+
|
|
18
|
+
export type PrettyTableAlign = "left" | "right" | "center" | null;
|
|
19
|
+
|
|
20
|
+
export type PrettyTableData = {
|
|
21
|
+
caption?: string;
|
|
22
|
+
headers: string[];
|
|
23
|
+
rows: string[][];
|
|
24
|
+
align?: PrettyTableAlign[];
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/** The table and its charts consume the same values, including formula failures. */
|
|
28
|
+
export const evaluatePrettyTable = (data: PrettyTableData) =>
|
|
29
|
+
data.rows.map((row, currentRow) => ({
|
|
30
|
+
isTotalRow: isTotalRow(row),
|
|
31
|
+
cells: data.headers.map((_, currentCol) => {
|
|
32
|
+
const raw = row[currentCol] ?? "";
|
|
33
|
+
const ctx = { headers: data.headers, rows: data.rows, currentRow, currentCol };
|
|
34
|
+
return { raw, result: isFormula(raw) ? evaluateFormula(raw, ctx) : evaluateTableCell(currentRow, currentCol, ctx) };
|
|
35
|
+
}),
|
|
36
|
+
}));
|
|
37
|
+
|
|
38
|
+
const TAG_RE = /(^|\s)#([a-zA-Z][\w-]*(?:\/[\w-]+)*)/g;
|
|
39
|
+
const NOTE_LINK_RE = /\[([^\]]+)\]\((note:\/\/[0-9a-zA-Z]{6}(?:#[^)\s]*)?)\)/g;
|
|
40
|
+
// Web and mail links, and relative links to Cloud, which are references as in Book.
|
|
41
|
+
const MARKDOWN_LINK_RE = /\[([^\]\n]+)\]\((https?:\/\/[^)\s]+|mailto:[^)\s]+|tel:[^)\s]+|(?![a-zA-Z][a-zA-Z\d+.-]*:|\/\/)[^)\s]+)\)/g;
|
|
42
|
+
const INLINE_CODE_RE = /`([^`\n]+)`/g;
|
|
43
|
+
const ISO_DATE_TIME_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,9})?(?:Z|[+-]\d{2}:\d{2})$/;
|
|
44
|
+
export const escapeHtml = (s: string): string =>
|
|
45
|
+
s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
46
|
+
|
|
47
|
+
const alignClass = (align: PrettyTableAlign): string => {
|
|
48
|
+
if (align === "right") return " md-align-right";
|
|
49
|
+
if (align === "center") return " md-align-center";
|
|
50
|
+
return "";
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const renderIsoDateTime = (raw: string, locale?: string): string | null => {
|
|
54
|
+
const value = raw.trim();
|
|
55
|
+
if (!ISO_DATE_TIME_RE.test(value)) return null;
|
|
56
|
+
const date = new Date(value);
|
|
57
|
+
if (Number.isNaN(date.getTime())) return null;
|
|
58
|
+
return `<time datetime="${escapeHtml(value)}" title="${escapeHtml(value)}">${escapeHtml(dates.formatDateTime(date, { locale }))}</time>`;
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
const stashHtml = (html: string, placeholders: string[]): string => {
|
|
62
|
+
placeholders.push(html);
|
|
63
|
+
return `\u0000${placeholders.length - 1}\u0000`;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const restoreHtml = (html: string, placeholders: string[]): string =>
|
|
67
|
+
html.replace(/\u0000(\d+)\u0000/g, (_match, index: string) => placeholders[Number(index)] ?? "");
|
|
68
|
+
|
|
69
|
+
const renderInlineFormatting = (html: string): string =>
|
|
70
|
+
html
|
|
71
|
+
.replace(/\*\*([^*\n]+)\*\*/g, "<strong>$1</strong>")
|
|
72
|
+
.replace(/__([^_\n]+)__/g, "<strong>$1</strong>")
|
|
73
|
+
.replace(/~~([^~\n]+)~~/g, "<s>$1</s>")
|
|
74
|
+
.replace(/(^|[\s(])\*([^*\n]+)\*/g, "$1<em>$2</em>")
|
|
75
|
+
.replace(/(^|[\s(])_([^_\n]+)_/g, "$1<em>$2</em>");
|
|
76
|
+
|
|
77
|
+
const renderInlineMarkdown = (raw: string, options: PrettyTableOptions, locale?: string): string => {
|
|
78
|
+
const formattedDateTime = renderIsoDateTime(raw, locale);
|
|
79
|
+
if (formattedDateTime) return formattedDateTime;
|
|
80
|
+
|
|
81
|
+
const placeholders: string[] = [];
|
|
82
|
+
const withProtectedLinks = raw
|
|
83
|
+
.replace(NOTE_LINK_RE, (match, label: string, link: string) => {
|
|
84
|
+
const note = parseNoteLink(link);
|
|
85
|
+
if (!note) return match;
|
|
86
|
+
const href = options.resolveUrl?.("note", link) ?? link;
|
|
87
|
+
const reference = { kind: note.anchor ? ("heading" as const) : ("note" as const) };
|
|
88
|
+
return stashHtml(renderMarkdownLink({ href, html: escapeHtml(label), text: label, reference, locale }), placeholders);
|
|
89
|
+
})
|
|
90
|
+
.replace(MARKDOWN_LINK_RE, (_match, label: string, href: string) => {
|
|
91
|
+
const reference = markdownLinkReference(href);
|
|
92
|
+
const target = reference ? undefined : "_blank";
|
|
93
|
+
return stashHtml(renderMarkdownLink({ href, html: escapeHtml(label), text: label, target, reference, locale }), placeholders);
|
|
94
|
+
})
|
|
95
|
+
.replace(INLINE_CODE_RE, (_match, code: string) => stashHtml(`<code>${escapeHtml(code)}</code>`, placeholders));
|
|
96
|
+
|
|
97
|
+
const escaped = escapeHtml(withProtectedLinks);
|
|
98
|
+
const formatted = renderInlineFormatting(escaped);
|
|
99
|
+
const withTags = formatted.replace(TAG_RE, (_match, prefix: string, tag: string) => {
|
|
100
|
+
const href = options.resolveUrl?.("tag", tag.toLowerCase()) ?? "#";
|
|
101
|
+
const title = prettyTableMessages.resolve(locale ? [locale] : []).t.showNotesWithTag({ tag: tag.toLowerCase() });
|
|
102
|
+
return `${prefix}<a href="${escapeHtml(href)}" class="cm-tag-pill inline-flex items-center px-1.5 py-0.5 rounded bg-emerald-50 dark:bg-emerald-950/40 text-emerald-700 dark:text-emerald-300 hover:bg-emerald-100 dark:hover:bg-emerald-900/50 no-underline align-baseline font-medium" title="${escapeHtml(title)}">#${escapeHtml(tag)}</a>`;
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
return restoreHtml(withTags, placeholders);
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
const localizeProgressLabel = (label: string, locale: string): string => {
|
|
109
|
+
const values = label.match(/^(-?\d+(?:\.\d+)?)\/(-?\d+(?:\.\d+)?)$/);
|
|
110
|
+
if (!values) return label;
|
|
111
|
+
return `${new Intl.NumberFormat(locale, { maximumFractionDigits: 6, useGrouping: false }).format(Number(values[1]))}/${new Intl.NumberFormat(locale, { maximumFractionDigits: 6, useGrouping: false }).format(Number(values[2]))}`;
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
const renderProgressCell = (progress: ProgressValue, alignCls: string, title: string, locale: string): string => {
|
|
115
|
+
const pct = Math.round(progress.ratio * 100);
|
|
116
|
+
return `<span class="md-table-cell md-table-progress${alignCls}" title="${escapeHtml(title)}"><span class="md-table-progress-track" aria-hidden="true"><span class="md-table-progress-fill" style="width:${pct}%"></span></span><span>${escapeHtml(localizeProgressLabel(progress.label, locale))}</span></span>`;
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
export const formatFormulaValue = (value: EvalValue, locale?: string): string => {
|
|
120
|
+
if (typeof value !== "number" || !Number.isFinite(value)) return formatValue(value);
|
|
121
|
+
return new Intl.NumberFormat(locale, { maximumFractionDigits: 6, useGrouping: false }).format(value);
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
const errorSubject = (error: EvalError): string | undefined => {
|
|
125
|
+
const quoted = error.message.match(/["“]([^"”]+)["”]/)?.[1];
|
|
126
|
+
if (quoted) return quoted;
|
|
127
|
+
return error.message.match(/^([A-Z][A-Z0-9_]*)\s*:/)?.[1];
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
export const formatFormulaError = (error: EvalError, locale?: string): string => {
|
|
131
|
+
const t = prettyTableMessages.resolve(locale ? [locale] : []).t;
|
|
132
|
+
const name = errorSubject(error) ?? t.formulaName;
|
|
133
|
+
const message = (() => {
|
|
134
|
+
switch (error.code) {
|
|
135
|
+
case "UNKNOWN_FUNCTION":
|
|
136
|
+
return t.unknownFunction({ name });
|
|
137
|
+
case "UNKNOWN_COLUMN":
|
|
138
|
+
return t.unknownColumn({ name });
|
|
139
|
+
case "WRONG_ARG_COUNT":
|
|
140
|
+
return t.wrongArgumentCount({ name });
|
|
141
|
+
case "NON_NUMERIC":
|
|
142
|
+
return t.nonNumeric({ name });
|
|
143
|
+
case "DIV_BY_ZERO":
|
|
144
|
+
return t.divisionByZero({ name });
|
|
145
|
+
case "TYPE_ERROR":
|
|
146
|
+
return t.typeError({ name });
|
|
147
|
+
case "CIRCULAR_REF":
|
|
148
|
+
return t.circularReference({ name });
|
|
149
|
+
case "PARSE_ERROR":
|
|
150
|
+
return t.parseError;
|
|
151
|
+
}
|
|
152
|
+
})();
|
|
153
|
+
return error.suggestion ? `${message}\n→ ${t.suggestion({ value: error.suggestion })}` : message;
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
const renderBodyCellContent = (
|
|
157
|
+
cell: string,
|
|
158
|
+
alignCls: string,
|
|
159
|
+
result: EvalResult,
|
|
160
|
+
options: PrettyTableOptions,
|
|
161
|
+
locale?: string,
|
|
162
|
+
renderInline?: (raw: string) => string,
|
|
163
|
+
): string => {
|
|
164
|
+
if (!isFormula(cell) || (renderInline && /^==[^=\n]+==$/.test(cell))) {
|
|
165
|
+
const content = renderInline ? (renderIsoDateTime(cell, locale) ?? renderInline(cell)) : renderInlineMarkdown(cell, options, locale);
|
|
166
|
+
return `<span class="md-table-cell${alignCls}">${content}</span>`;
|
|
167
|
+
}
|
|
168
|
+
if (result.kind === "ok") {
|
|
169
|
+
const progress = parseProgressValue(result.value);
|
|
170
|
+
if (progress) return renderProgressCell(progress, alignCls, cell, locale ?? "en");
|
|
171
|
+
return `<span class="md-table-cell md-formula-ok${alignCls}" title="${escapeHtml(cell)}"><i class="ti ti-math-function"></i>${escapeHtml(formatFormulaValue(result.value, locale))}</span>`;
|
|
172
|
+
}
|
|
173
|
+
const tooltip = formatFormulaError(result, locale);
|
|
174
|
+
return `<span class="md-table-cell md-formula-error${alignCls}" title="${escapeHtml(tooltip)}">⚠ ${escapeHtml(cell)}</span>`;
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
export type PrettyTableOptions = {
|
|
178
|
+
locale?: string;
|
|
179
|
+
renderInline?: (raw: string) => string;
|
|
180
|
+
/** Note references and lower-case tags belong to the host's routing. */
|
|
181
|
+
resolveUrl?: (kind: "note" | "tag", ref: string) => string;
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
/** One evaluated snapshot shared by full-table and editable-grid presentation.
|
|
185
|
+
* Row indexes refer to body rows; all returned HTML is view-only. */
|
|
186
|
+
export const createPrettyTablePresentation = (data: PrettyTableData, options: PrettyTableOptions = {}) => {
|
|
187
|
+
const locale = options.locale ?? (typeof document === "undefined" ? "en" : document.documentElement.lang || "en");
|
|
188
|
+
const align = data.align ?? [];
|
|
189
|
+
const rows = evaluatePrettyTable(data);
|
|
190
|
+
return {
|
|
191
|
+
rows,
|
|
192
|
+
headerHtml: (column: number): string => {
|
|
193
|
+
const raw = data.headers[column] ?? "";
|
|
194
|
+
return `<span class="md-table-cell${alignClass(align[column] ?? null)}">${options.renderInline?.(raw) ?? escapeHtml(raw)}</span>`;
|
|
195
|
+
},
|
|
196
|
+
cellHtml: (row: number, column: number): string => {
|
|
197
|
+
const cell = rows[row]?.cells[column];
|
|
198
|
+
return cell
|
|
199
|
+
? renderBodyCellContent(cell.raw, alignClass(align[column] ?? null), cell.result, options, locale, options.renderInline)
|
|
200
|
+
: "";
|
|
201
|
+
},
|
|
202
|
+
};
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
export const renderPrettyTableHtml = (data: PrettyTableData, options: PrettyTableOptions = {}): string => {
|
|
206
|
+
const presentation = createPrettyTablePresentation(data, options);
|
|
207
|
+
const caption = data.caption
|
|
208
|
+
? `<div class="md-block-handle" data-block-name="${escapeHtml(data.caption)}">@${escapeHtml(data.caption)}</div>`
|
|
209
|
+
: "";
|
|
210
|
+
const headerHtml = data.headers.map((_, i) => `<th scope="col">${presentation.headerHtml(i)}</th>`).join("");
|
|
211
|
+
// An all-empty GFM header is the approved header-off representation.
|
|
212
|
+
const head = data.headers.some((cell) => cell.trim()) ? `<thead><tr>${headerHtml}</tr></thead>` : "";
|
|
213
|
+
const bodyHtml = presentation.rows
|
|
214
|
+
.map((row, rowIndex) => {
|
|
215
|
+
const cells = data.headers.map((_, colIdx) => `<td>${presentation.cellHtml(rowIndex, colIdx)}</td>`).join("");
|
|
216
|
+
return row.isTotalRow ? `<tr class="md-table-total-row">${cells}</tr>` : `<tr>${cells}</tr>`;
|
|
217
|
+
})
|
|
218
|
+
.join("");
|
|
219
|
+
return `${caption}<div class="md-table-wrap"><table class="md-table">${head}<tbody>${bodyHtml}</tbody></table></div>`;
|
|
220
|
+
};
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { EditorView } from "@codemirror/view";
|
|
2
|
+
|
|
3
|
+
export const searchTheme = () =>
|
|
4
|
+
EditorView.theme({
|
|
5
|
+
".cm-panels": {
|
|
6
|
+
backgroundColor: "transparent !important",
|
|
7
|
+
},
|
|
8
|
+
".cm-panel.cm-search": {
|
|
9
|
+
margin: "0 4px",
|
|
10
|
+
padding: "0",
|
|
11
|
+
"& > *": {
|
|
12
|
+
margin: "0 4px 4px 0 !important",
|
|
13
|
+
},
|
|
14
|
+
},
|
|
15
|
+
".cm-panels-bottom": {
|
|
16
|
+
borderTop: "none !important",
|
|
17
|
+
},
|
|
18
|
+
".dark & .cm-textfield": {
|
|
19
|
+
background: "var(--color-gray-800)",
|
|
20
|
+
},
|
|
21
|
+
".cm-textfield": {
|
|
22
|
+
borderRadius: "var(--radius-sm)",
|
|
23
|
+
padding: "2px 4px",
|
|
24
|
+
fontSize: "var(--text-xs)",
|
|
25
|
+
color: "var(--color-gray-500)",
|
|
26
|
+
boxShadow: "var(--shadow-sm)",
|
|
27
|
+
border: 0,
|
|
28
|
+
transition: "all 0.2s ease",
|
|
29
|
+
"&:hover": {
|
|
30
|
+
boxShadow: "var(--shadow-md)",
|
|
31
|
+
},
|
|
32
|
+
"&:focus": {
|
|
33
|
+
outline: "none",
|
|
34
|
+
boxShadow: "var(--shadow-md)",
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
".dark & .cm-button": {
|
|
38
|
+
background: "var(--color-gray-800)",
|
|
39
|
+
},
|
|
40
|
+
".cm-button": {
|
|
41
|
+
backgroundImage: "none",
|
|
42
|
+
borderRadius: "var(--radius-sm)",
|
|
43
|
+
boxShadow: "var(--shadow-sm)",
|
|
44
|
+
transition: "all 0.2s ease",
|
|
45
|
+
border: "none",
|
|
46
|
+
padding: "2px 4px",
|
|
47
|
+
fontSize: "var(--text-xs)",
|
|
48
|
+
color: "var(--color-gray-500)",
|
|
49
|
+
cursor: "pointer",
|
|
50
|
+
"&:hover": {
|
|
51
|
+
transform: "translateY(-1px)",
|
|
52
|
+
boxShadow: "var(--shadow-md)",
|
|
53
|
+
},
|
|
54
|
+
"&:focus": {
|
|
55
|
+
outline: "none",
|
|
56
|
+
boxShadow: "0 0 0 2px rgb(59 130 246 / 0.35)",
|
|
57
|
+
},
|
|
58
|
+
},
|
|
59
|
+
".dark & .cm-search button[name='close']": {
|
|
60
|
+
"&:hover": {
|
|
61
|
+
backgroundColor: "var(--color-gray-800)",
|
|
62
|
+
color: "var(--color-gray-500)",
|
|
63
|
+
},
|
|
64
|
+
},
|
|
65
|
+
".cm-search button[name='close']": {
|
|
66
|
+
backgroundColor: "transparent",
|
|
67
|
+
border: "none",
|
|
68
|
+
borderRadius: "var(--radius-sm)",
|
|
69
|
+
width: "18px",
|
|
70
|
+
height: "18px",
|
|
71
|
+
fontSize: "var(--text-xs)",
|
|
72
|
+
display: "flex",
|
|
73
|
+
alignItems: "center",
|
|
74
|
+
justifyContent: "center",
|
|
75
|
+
color: "var(--color-gray-500)",
|
|
76
|
+
fontWeight: "bold",
|
|
77
|
+
cursor: "pointer",
|
|
78
|
+
transition: "all 0.2s ease",
|
|
79
|
+
fontFamily: "var(--font-mono)",
|
|
80
|
+
"&:hover": {
|
|
81
|
+
backgroundColor: "var(--color-gray-100)",
|
|
82
|
+
color: "var(--color-gray-700)",
|
|
83
|
+
},
|
|
84
|
+
},
|
|
85
|
+
".cm-search label": {
|
|
86
|
+
display: "inline-flex",
|
|
87
|
+
alignItems: "center",
|
|
88
|
+
gap: "4px",
|
|
89
|
+
fontSize: "13px",
|
|
90
|
+
color: "var(--color-gray-500)",
|
|
91
|
+
cursor: "pointer",
|
|
92
|
+
userSelect: "none",
|
|
93
|
+
marginRight: "12px",
|
|
94
|
+
"&:hover": {
|
|
95
|
+
color: "var(--color-gray-700)",
|
|
96
|
+
},
|
|
97
|
+
},
|
|
98
|
+
});
|
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
/** Notebook presentation only. The public grid owns editing, focus, history and source writes. */
|
|
2
|
+
import { type EditorState, type Extension, type Range, StateField } from "@codemirror/state";
|
|
3
|
+
import { Decoration, type DecorationSet, EditorView, ViewPlugin } from "@codemirror/view";
|
|
4
|
+
import { i18n } from "@k2b/stdlib";
|
|
5
|
+
import { clipboard } from "@k2b/stdlib/browser";
|
|
6
|
+
import type { TableHost } from "@k2b/ui/text-editor";
|
|
7
|
+
import { displayCell, findTables, type Table } from "@k2b/ui/text-editor/model";
|
|
8
|
+
import { isFormula } from "../formula";
|
|
9
|
+
import { refreshMarkdownDecorationsEffect } from "./_lib/cursor-zone-field";
|
|
10
|
+
import { extractNamedBlocks } from "./lib/named-blocks";
|
|
11
|
+
import { applyLigatures } from "./ligatures";
|
|
12
|
+
import { createPrettyTablePresentation, formatFormulaValue, type PrettyTableData, type PrettyTableOptions } from "./pretty-table";
|
|
13
|
+
import { formulaPreviewWidget } from "./tables";
|
|
14
|
+
|
|
15
|
+
export type TablePresentationOptions = Omit<PrettyTableOptions, "locale"> & {
|
|
16
|
+
/** Pass the inherited UI locale accessor, not a separate locale signal. */
|
|
17
|
+
locale?: () => string;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const copyMessages = i18n.define({
|
|
21
|
+
baseLocale: "en",
|
|
22
|
+
messages: {
|
|
23
|
+
en: { copy: "Copy result", copied: "Copied", failed: "Could not copy. Try again." },
|
|
24
|
+
de: { copy: "Ergebnis kopieren", copied: "Kopiert", failed: "Kopieren fehlgeschlagen. Erneut versuchen." },
|
|
25
|
+
},
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
/** Padding belongs exclusively to the evaluated view. Original header indexes and source stay intact. */
|
|
29
|
+
const presentationData = (table: Table): PrettyTableData => {
|
|
30
|
+
const width = table.lines.reduce((width, line) => Math.max(width, line.cells.length), table.columns);
|
|
31
|
+
const values = (line: Table["lines"][number] | undefined) =>
|
|
32
|
+
Array.from({ length: width }, (_, column) => displayCell(line?.cells[column]?.text ?? ""));
|
|
33
|
+
return { headers: values(table.lines[0]), rows: table.lines.slice(2).map(values), align: table.align };
|
|
34
|
+
};
|
|
35
|
+
|
|
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
|
+
const presentationTheme = EditorView.theme({
|
|
61
|
+
".k2b-ste-table .cm-notebook-formula-editor": {
|
|
62
|
+
position: "absolute",
|
|
63
|
+
top: "0",
|
|
64
|
+
left: "0",
|
|
65
|
+
width: "100%",
|
|
66
|
+
minWidth: "100%",
|
|
67
|
+
maxWidth: "100%",
|
|
68
|
+
minHeight: "100%",
|
|
69
|
+
zIndex: "5",
|
|
70
|
+
background: "var(--k2b-surface)",
|
|
71
|
+
},
|
|
72
|
+
".cm-notebook-active-result": {
|
|
73
|
+
position: "absolute",
|
|
74
|
+
bottom: "100%",
|
|
75
|
+
right: "0",
|
|
76
|
+
zIndex: "6",
|
|
77
|
+
padding: "2px 6px",
|
|
78
|
+
maxWidth: "18rem",
|
|
79
|
+
background: "var(--k2b-surface)",
|
|
80
|
+
border: "1px solid var(--k2b-border)",
|
|
81
|
+
borderRadius: "var(--k2b-radius-control)",
|
|
82
|
+
boxShadow: "var(--k2b-shadow-float)",
|
|
83
|
+
fontSize: "12px",
|
|
84
|
+
},
|
|
85
|
+
|
|
86
|
+
".k2b-ste-table .k2b-ste-cell > .md-table-cell": { padding: "0", minHeight: "0" },
|
|
87
|
+
".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
|
+
".cm-notebook-formula-copy": {
|
|
90
|
+
display: "inline-flex",
|
|
91
|
+
alignItems: "center",
|
|
92
|
+
padding: "0 0.25rem",
|
|
93
|
+
border: "0",
|
|
94
|
+
borderRadius: "var(--k2b-radius-control)",
|
|
95
|
+
background: "transparent",
|
|
96
|
+
color: "var(--k2b-text-muted)",
|
|
97
|
+
font: "inherit",
|
|
98
|
+
cursor: "pointer",
|
|
99
|
+
},
|
|
100
|
+
".cm-notebook-formula-copy:hover": { background: "var(--ui-data-row-hover)" },
|
|
101
|
+
".cm-notebook-formula-copy:focus-visible": { outline: "2px solid var(--k2b-action)", outlineOffset: "2px" },
|
|
102
|
+
".cm-notebook-formula-copy[data-failed]": { color: "var(--k2b-danger-text)" },
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
const copyButton = (element: HTMLElement, value: string, locale: string): HTMLButtonElement => {
|
|
106
|
+
const t = copyMessages.resolve([locale]).t;
|
|
107
|
+
const button = element.ownerDocument.createElement("button");
|
|
108
|
+
button.type = "button";
|
|
109
|
+
button.contentEditable = "false";
|
|
110
|
+
button.className = "cm-notebook-formula-copy";
|
|
111
|
+
button.setAttribute("aria-label", t.copy);
|
|
112
|
+
button.setAttribute("aria-live", "polite");
|
|
113
|
+
button.title = t.copy;
|
|
114
|
+
button.innerHTML = '<i class="ti ti-copy" aria-hidden="true"></i>';
|
|
115
|
+
// A copy press must not focus the surrounding editable cell (which would reveal source).
|
|
116
|
+
button.onmousedown = (event) => {
|
|
117
|
+
event.preventDefault();
|
|
118
|
+
event.stopPropagation();
|
|
119
|
+
};
|
|
120
|
+
button.onkeydown = (event) => event.stopPropagation();
|
|
121
|
+
button.onclick = (event) => {
|
|
122
|
+
event.preventDefault();
|
|
123
|
+
event.stopPropagation();
|
|
124
|
+
button.disabled = true;
|
|
125
|
+
void clipboard
|
|
126
|
+
.copy(value)
|
|
127
|
+
.then(
|
|
128
|
+
() => {
|
|
129
|
+
button.textContent = t.copied;
|
|
130
|
+
button.setAttribute("aria-label", t.copied);
|
|
131
|
+
delete button.dataset.failed;
|
|
132
|
+
},
|
|
133
|
+
() => {
|
|
134
|
+
button.textContent = t.failed;
|
|
135
|
+
button.setAttribute("aria-label", t.failed);
|
|
136
|
+
button.dataset.failed = "";
|
|
137
|
+
},
|
|
138
|
+
)
|
|
139
|
+
.finally(() => {
|
|
140
|
+
button.disabled = false;
|
|
141
|
+
});
|
|
142
|
+
};
|
|
143
|
+
return button;
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
/** The active formula stays over its reserved cell, with the evaluated result
|
|
147
|
+
* beside it. Reuse the same presentation as the inactive grid and exports. */
|
|
148
|
+
const activeFormulaPreview = (options: TablePresentationOptions): Extension =>
|
|
149
|
+
ViewPlugin.define((view) => {
|
|
150
|
+
let cell: HTMLElement | null = null;
|
|
151
|
+
let result: HTMLElement | null = null;
|
|
152
|
+
let alive = true;
|
|
153
|
+
const clear = () => {
|
|
154
|
+
cell?.classList.remove("cm-notebook-formula-editor");
|
|
155
|
+
if (cell?.parentElement) cell.parentElement.style.removeProperty("height");
|
|
156
|
+
result?.remove();
|
|
157
|
+
result = null;
|
|
158
|
+
cell = null;
|
|
159
|
+
};
|
|
160
|
+
const paint = () =>
|
|
161
|
+
queueMicrotask(() => {
|
|
162
|
+
if (!alive) return;
|
|
163
|
+
const focused = view.dom.ownerDocument.activeElement;
|
|
164
|
+
const target = focused instanceof HTMLElement && focused.matches(".k2b-ste-cell") && view.dom.contains(focused) ? focused : null;
|
|
165
|
+
if (target !== cell) clear();
|
|
166
|
+
if (!target || !isFormula(target.textContent ?? "")) {
|
|
167
|
+
clear();
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
const wrap = target.closest<HTMLElement>(".k2b-ste-table");
|
|
171
|
+
const table = wrap && findTables(view.state.doc.toString()).find((table) => table.from === view.posAtDOM(wrap));
|
|
172
|
+
const row = Number(target.dataset.r),
|
|
173
|
+
column = Number(target.dataset.c);
|
|
174
|
+
if (!table || row === 0) {
|
|
175
|
+
clear();
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
if (!result) {
|
|
179
|
+
const parent = target.parentElement;
|
|
180
|
+
if (!parent) return;
|
|
181
|
+
parent.style.height = `${parent.getBoundingClientRect().height}px`;
|
|
182
|
+
result = parent.ownerDocument.createElement("div");
|
|
183
|
+
result.className = "cm-notebook-active-result";
|
|
184
|
+
result.setAttribute("role", "status");
|
|
185
|
+
result.contentEditable = "false";
|
|
186
|
+
parent.append(result);
|
|
187
|
+
target.classList.add("cm-notebook-formula-editor");
|
|
188
|
+
cell = target;
|
|
189
|
+
}
|
|
190
|
+
const locale = options.locale?.() ?? (target.ownerDocument.documentElement.lang || "en");
|
|
191
|
+
const presentation = createPrettyTablePresentation(presentationData(table), { ...options, locale });
|
|
192
|
+
result.innerHTML = presentation.cellHtml(row - 1, column);
|
|
193
|
+
});
|
|
194
|
+
view.contentDOM.addEventListener("focusin", paint);
|
|
195
|
+
view.contentDOM.addEventListener("focusout", paint);
|
|
196
|
+
return {
|
|
197
|
+
update(update) {
|
|
198
|
+
if (update.docChanged) paint();
|
|
199
|
+
},
|
|
200
|
+
destroy() {
|
|
201
|
+
alive = false;
|
|
202
|
+
view.contentDOM.removeEventListener("focusin", paint);
|
|
203
|
+
view.contentDOM.removeEventListener("focusout", paint);
|
|
204
|
+
clear();
|
|
205
|
+
},
|
|
206
|
+
};
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
/** Spread renderCell into the parent's existing TableHost; add extension only in grid mode.
|
|
210
|
+
* This factory does not install tableGrid, the old tablesExtension, or any key handlers. */
|
|
211
|
+
export const createNotebookTablePresentation = (
|
|
212
|
+
options: TablePresentationOptions,
|
|
213
|
+
): {
|
|
214
|
+
renderCell: NonNullable<TableHost["renderCell"]>;
|
|
215
|
+
extension: Extension;
|
|
216
|
+
} => {
|
|
217
|
+
const snapshots = new WeakMap<Table, { locale: string; presentation: ReturnType<typeof createPrettyTablePresentation> }>();
|
|
218
|
+
return {
|
|
219
|
+
extension: [tableCaptions, presentationTheme, activeFormulaPreview(options)],
|
|
220
|
+
renderCell(element, _raw, table, row, column) {
|
|
221
|
+
const locale = options.locale?.() ?? (element.ownerDocument.documentElement.lang || "en");
|
|
222
|
+
let snapshot = snapshots.get(table);
|
|
223
|
+
if (!snapshot || snapshot.locale !== locale) {
|
|
224
|
+
snapshot = {
|
|
225
|
+
locale,
|
|
226
|
+
presentation: createPrettyTablePresentation(presentationData(table), { ...options, locale }),
|
|
227
|
+
};
|
|
228
|
+
snapshots.set(table, snapshot);
|
|
229
|
+
}
|
|
230
|
+
const presentation = snapshot.presentation;
|
|
231
|
+
element.innerHTML = row === 0 ? presentation.headerHtml(column) : presentation.cellHtml(row - 1, column);
|
|
232
|
+
// renderCell is also called before initial attachment; use a cell attribute
|
|
233
|
+
// rather than relying on a parent row being present.
|
|
234
|
+
const total = row > 0 && presentation.rows[row - 1]?.isTotalRow === true;
|
|
235
|
+
element.toggleAttribute("data-notebook-total", total);
|
|
236
|
+
const cell = row > 0 ? presentation.rows[row - 1]?.cells[column] : undefined;
|
|
237
|
+
if (cell && isFormula(cell.raw) && cell.result.kind === "ok") {
|
|
238
|
+
element.querySelector(".md-table-cell")?.append(copyButton(element, formatFormulaValue(cell.result.value, locale), locale));
|
|
239
|
+
}
|
|
240
|
+
// Links remain usable without focusing the cell and replacing the anchor
|
|
241
|
+
// with raw source before the browser can complete its click.
|
|
242
|
+
for (const link of element.querySelectorAll<HTMLAnchorElement>("a")) {
|
|
243
|
+
link.contentEditable = "false";
|
|
244
|
+
link.onmousedown = (event) => {
|
|
245
|
+
event.preventDefault();
|
|
246
|
+
event.stopPropagation();
|
|
247
|
+
};
|
|
248
|
+
link.onclick = (event) => event.stopPropagation();
|
|
249
|
+
link.onkeydown = (event) => event.stopPropagation();
|
|
250
|
+
}
|
|
251
|
+
applyLigatures(element);
|
|
252
|
+
},
|
|
253
|
+
};
|
|
254
|
+
};
|
|
255
|
+
|
|
256
|
+
/** Source result chips only: no block replacement, atomic ranges or arrow navigation.
|
|
257
|
+
* Install in Markdown mode. A host with mixed source/grid areas can restrict the
|
|
258
|
+
* source-visible tables with sourceVisible; never decorate a replaced grid range. */
|
|
259
|
+
export const notebookSourceFormulasExtension = (
|
|
260
|
+
options: { locale?: () => string; sourceVisible?: (state: EditorState, table: Table) => boolean } = {},
|
|
261
|
+
): Extension => {
|
|
262
|
+
const decorate = (state: EditorState): DecorationSet => {
|
|
263
|
+
const ranges: Range<Decoration>[] = [];
|
|
264
|
+
const locale = options.locale?.() ?? (typeof document === "undefined" ? "en" : document.documentElement.lang || "en");
|
|
265
|
+
for (const table of findTables(state.doc.toString())) {
|
|
266
|
+
if (options.sourceVisible && !options.sourceVisible(state, table)) continue;
|
|
267
|
+
const presentation = createPrettyTablePresentation(presentationData(table), { locale });
|
|
268
|
+
table.lines.slice(2).forEach((line, row) => {
|
|
269
|
+
line.cells.forEach((cell, column) => {
|
|
270
|
+
const value = presentation.rows[row]?.cells[column];
|
|
271
|
+
if (!value || !isFormula(value.raw)) return;
|
|
272
|
+
ranges.push(Decoration.widget({ widget: formulaPreviewWidget(value.result, locale), side: 1 }).range(cell.to));
|
|
273
|
+
});
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
return Decoration.set(ranges, true);
|
|
277
|
+
};
|
|
278
|
+
return StateField.define<DecorationSet>({
|
|
279
|
+
create: decorate,
|
|
280
|
+
update: (value, tr) =>
|
|
281
|
+
tr.docChanged || (options.sourceVisible && tr.selection) || tr.effects.some((effect) => effect.is(refreshMarkdownDecorationsEffect))
|
|
282
|
+
? decorate(tr.state)
|
|
283
|
+
: value,
|
|
284
|
+
provide: (field) => EditorView.decorations.from(field),
|
|
285
|
+
});
|
|
286
|
+
};
|