@typbase/codemirror 0.2.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/LICENSE +661 -0
- package/dist/index.d.mts +346 -0
- package/dist/index.mjs +2110 -0
- package/package.json +48 -0
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,2110 @@
|
|
|
1
|
+
import { acceptCompletion, autocompletion, closeBracketsKeymap, completionKeymap, snippet, startCompletion } from "@codemirror/autocomplete";
|
|
2
|
+
import { indentService } from "@codemirror/language";
|
|
3
|
+
import { Decoration, EditorView, ViewPlugin, WidgetType, hoverTooltip, keymap, showTooltip } from "@codemirror/view";
|
|
4
|
+
import { ChangeSet, EditorSelection, EditorState, Facet, Prec, RangeSetBuilder, StateEffect, StateField, countColumn, findColumn } from "@codemirror/state";
|
|
5
|
+
import { copyLineDown, copyLineUp, indentLess, indentMore, insertBlankLine, moveLineDown, moveLineUp, redo, redoSelection, simplifySelection, standardKeymap, toggleBlockComment, toggleLineComment, undo, undoSelection } from "@codemirror/commands";
|
|
6
|
+
import { linter, nextDiagnostic, setDiagnostics } from "@codemirror/lint";
|
|
7
|
+
import { closeSearchPanel, findNext, findPrevious, gotoLine, openSearchPanel, selectNextOccurrence, selectSelectionMatches } from "@codemirror/search";
|
|
8
|
+
import { LRUCache } from "lru-cache";
|
|
9
|
+
//#region src/highlight.ts
|
|
10
|
+
const typstSyntaxHighlighting = (fileId, typstState) => StateField.define({
|
|
11
|
+
create(state) {
|
|
12
|
+
return buildDecorations$2(state, fileId, typstState);
|
|
13
|
+
},
|
|
14
|
+
update(decoration, transaction) {
|
|
15
|
+
if (transaction.docChanged) return buildDecorations$2(transaction.state, fileId, typstState);
|
|
16
|
+
return decoration;
|
|
17
|
+
},
|
|
18
|
+
provide(field) {
|
|
19
|
+
return EditorView.decorations.from(field);
|
|
20
|
+
}
|
|
21
|
+
});
|
|
22
|
+
function buildDecorations$2(state, fileId, typstState) {
|
|
23
|
+
const builder = new RangeSetBuilder();
|
|
24
|
+
let tokens;
|
|
25
|
+
try {
|
|
26
|
+
tokens = typstState.highlight(fileId, state.doc.toString());
|
|
27
|
+
} catch (error) {
|
|
28
|
+
console.error("[typst] highlight panicked:", error);
|
|
29
|
+
return Decoration.none;
|
|
30
|
+
}
|
|
31
|
+
for (const token of tokens) builder.add(token.range.start, token.range.end, Decoration.mark({ class: token.tag }));
|
|
32
|
+
return builder.finish();
|
|
33
|
+
}
|
|
34
|
+
const parseBackticks = (str, into) => {
|
|
35
|
+
const result = str.split("`").map((sub, i) => {
|
|
36
|
+
if (i % 2) {
|
|
37
|
+
const code = document.createElement("code");
|
|
38
|
+
code.textContent = sub;
|
|
39
|
+
return code;
|
|
40
|
+
}
|
|
41
|
+
return document.createTextNode(sub);
|
|
42
|
+
});
|
|
43
|
+
into.append(...result);
|
|
44
|
+
};
|
|
45
|
+
//#endregion
|
|
46
|
+
//#region src/hover.ts
|
|
47
|
+
const typstHoverTooltip = (fileId, typstState) => hoverTooltip((_, pos, side) => {
|
|
48
|
+
let tooltip;
|
|
49
|
+
try {
|
|
50
|
+
tooltip = typstState.hover(fileId, pos, side);
|
|
51
|
+
} catch (error) {
|
|
52
|
+
console.error("[typst] hover panicked:", error);
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
if (tooltip) return {
|
|
56
|
+
pos,
|
|
57
|
+
create() {
|
|
58
|
+
const div = document.createElement("div");
|
|
59
|
+
if (tooltip.startsWith("<code>")) div.innerHTML = "<pre>" + tooltip.replace(/span data-tag=(\w+)/g, (_match, tag) => `span class="${"typ-" + tag}"`) + "</pre>";
|
|
60
|
+
else parseBackticks(tooltip, div);
|
|
61
|
+
return { dom: div };
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
return null;
|
|
65
|
+
});
|
|
66
|
+
//#endregion
|
|
67
|
+
//#region src/commands.ts
|
|
68
|
+
function toggleStrong(view) {
|
|
69
|
+
toggleAroundSelection(view, ["*"], ["*"]);
|
|
70
|
+
return true;
|
|
71
|
+
}
|
|
72
|
+
function toggleEmph(view) {
|
|
73
|
+
toggleAroundSelection(view, ["_"], ["_"]);
|
|
74
|
+
return true;
|
|
75
|
+
}
|
|
76
|
+
function toggleUnderline(view) {
|
|
77
|
+
toggleAroundSelection(view, ["#underline["], ["]"]);
|
|
78
|
+
return true;
|
|
79
|
+
}
|
|
80
|
+
function toggleStrike(view) {
|
|
81
|
+
toggleAroundSelection(view, ["#strike["], ["]"]);
|
|
82
|
+
return true;
|
|
83
|
+
}
|
|
84
|
+
function toggleMath(view) {
|
|
85
|
+
toggleAroundSelection(view, ["$", "$ "], ["$", " $"]);
|
|
86
|
+
return true;
|
|
87
|
+
}
|
|
88
|
+
function insertTabLike(view) {
|
|
89
|
+
return view.state.selection.ranges.some((range) => !range.empty) ? indentMore(view) : (view.dispatch(view.state.changeByRange(({ from: pos }) => {
|
|
90
|
+
const line = view.state.doc.lineAt(pos);
|
|
91
|
+
const spaces = countColumn(line.text, 2, pos - line.from) % 2 == 0 ? " " : " ";
|
|
92
|
+
return {
|
|
93
|
+
changes: {
|
|
94
|
+
from: pos,
|
|
95
|
+
insert: spaces
|
|
96
|
+
},
|
|
97
|
+
range: EditorSelection.cursor(pos + spaces.length)
|
|
98
|
+
};
|
|
99
|
+
})), true);
|
|
100
|
+
}
|
|
101
|
+
function toggleCode(view) {
|
|
102
|
+
return toggleAroundSelection(view, ["`", {
|
|
103
|
+
regex: /(`{3,}([a-z0-9][a-z0-9-]*)?(\s*)*)/i,
|
|
104
|
+
replacement: "```\n"
|
|
105
|
+
}], ["`", {
|
|
106
|
+
regex: /((\s*)*`{3,})/,
|
|
107
|
+
replacement: "\n```"
|
|
108
|
+
}], true) === 0 && view.state.selection.ranges.every((range) => range.empty) && startCompletion(view), true;
|
|
109
|
+
}
|
|
110
|
+
function insertFontFamily(n, font) {
|
|
111
|
+
return wrapOrInsert(n, `#text(font: "${font}")[`, "]", `#set text(font: "${font}")\n`), true;
|
|
112
|
+
}
|
|
113
|
+
function cycleHeading(view) {
|
|
114
|
+
if (view.state.facet(EditorState.readOnly)) return true;
|
|
115
|
+
const { state } = view;
|
|
116
|
+
return view.dispatch(state.changeByRange((r) => {
|
|
117
|
+
let o = r.from;
|
|
118
|
+
let c = r.to;
|
|
119
|
+
const u = state.doc.lineAt(o), d = u.from;
|
|
120
|
+
if (r.empty) {
|
|
121
|
+
const f = state.doc.sliceString(u.from, o).match(/^\s*/);
|
|
122
|
+
if (f) o = u.from + f[0].length;
|
|
123
|
+
else {
|
|
124
|
+
o = u.from;
|
|
125
|
+
c = u.to;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
const m = state.sliceDoc(d, c).match(/^(\s*)(=+)/);
|
|
129
|
+
if (m) {
|
|
130
|
+
if (!r.empty) {
|
|
131
|
+
const g = state.doc.sliceString(u.from, o).match(/^\s*/);
|
|
132
|
+
if (g) o = u.from + g[0].length;
|
|
133
|
+
else o = u.from;
|
|
134
|
+
}
|
|
135
|
+
const f = m[2].length;
|
|
136
|
+
return f < 6 ? {
|
|
137
|
+
range: EditorSelection.range(r.from + 1, r.to + 1),
|
|
138
|
+
changes: [{
|
|
139
|
+
from: o + f,
|
|
140
|
+
insert: "="
|
|
141
|
+
}]
|
|
142
|
+
} : {
|
|
143
|
+
range: EditorSelection.range(r.from - f - 1, r.to - f - 1),
|
|
144
|
+
changes: [{
|
|
145
|
+
from: o,
|
|
146
|
+
to: o + f + 1,
|
|
147
|
+
insert: ""
|
|
148
|
+
}]
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
const f = state.doc.lineAt(o);
|
|
152
|
+
let g;
|
|
153
|
+
const v = f.from === 0 ? void 0 : state.doc.lineAt(f.from - 1), y = v === void 0 || state.sliceDoc(v.from, v.to).trim().length === 0;
|
|
154
|
+
if (f.from === o || state.sliceDoc(f.from, o).trim().length === 0) g = y ? 0 : 1;
|
|
155
|
+
else {
|
|
156
|
+
g = 2;
|
|
157
|
+
if (!y) g++;
|
|
158
|
+
}
|
|
159
|
+
const E = f.text.match(/^\s*/)?.[0] ?? "", N = state.sliceDoc(o, c).split("\n"), _ = N.length > 1 && !N[0].startsWith("#[") && !N[N.length - 1].endsWith("]"), T = (g > 0 ? "\n".repeat(g) + E : "") + "= ", M = E + " ";
|
|
160
|
+
let O = N.map((U, J) => J === 0 ? _ ? "#[\n" + E + " " + U : U : U.trim() != "]" && !U.startsWith(M) ? M + U : U).join("\n");
|
|
161
|
+
if (_) O += "\n" + E + "]";
|
|
162
|
+
let W = T + O;
|
|
163
|
+
const P = state.doc.length === c ? void 0 : state.doc.lineAt(c + 1);
|
|
164
|
+
if (!P || P.from >= c) {
|
|
165
|
+
if (P && state.sliceDoc(P.from, P.to).match(/^\s*$/) === null) W += "\n";
|
|
166
|
+
}
|
|
167
|
+
return {
|
|
168
|
+
range: r.empty ? EditorSelection.cursor(o + T.length + O.length) : EditorSelection.range(o + T.length, o + T.length + O.length),
|
|
169
|
+
changes: [{
|
|
170
|
+
from: o,
|
|
171
|
+
to: c,
|
|
172
|
+
insert: W
|
|
173
|
+
}]
|
|
174
|
+
};
|
|
175
|
+
})), view.focus(), true;
|
|
176
|
+
}
|
|
177
|
+
function insertAtCursor(n, e) {
|
|
178
|
+
const r = n.state.selection.main.head;
|
|
179
|
+
n.dispatch({
|
|
180
|
+
selection: EditorSelection.cursor(r + e.length),
|
|
181
|
+
changes: ChangeSet.of({
|
|
182
|
+
from: r,
|
|
183
|
+
insert: e
|
|
184
|
+
}, n.state.doc.length)
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
function wrapOrInsert(view, prefix, suffix, insert) {
|
|
188
|
+
if (view.state.facet(EditorState.readOnly)) return;
|
|
189
|
+
const c = view.state;
|
|
190
|
+
if (c.selection.main.empty) view.dispatch(c.changeByRange((u) => ({
|
|
191
|
+
range: EditorSelection.cursor(u.from + insert.length),
|
|
192
|
+
changes: [{
|
|
193
|
+
from: u.from,
|
|
194
|
+
insert
|
|
195
|
+
}]
|
|
196
|
+
})));
|
|
197
|
+
else toggleAroundSelection(view, [prefix], [suffix]);
|
|
198
|
+
}
|
|
199
|
+
function toggleAroundSelection(view, prefixes, suffixes, preferNextLevel) {
|
|
200
|
+
if (view.state.facet(EditorState.readOnly)) return null;
|
|
201
|
+
const { state } = view, { doc } = state;
|
|
202
|
+
let matchIndex = null;
|
|
203
|
+
return view.dispatch(state.changeByRange((range) => {
|
|
204
|
+
let selRange = range;
|
|
205
|
+
if (range.empty) {
|
|
206
|
+
const line = doc.lineAt(range.from), wordMatches = line.text.matchAll(/[\p{L},.]+/gu);
|
|
207
|
+
for (const match of wordMatches) {
|
|
208
|
+
const wordStart = line.from + (match.index ?? 0), wordEnd = wordStart + match[0].length;
|
|
209
|
+
if (wordStart <= range.from && range.from <= wordEnd) selRange = EditorSelection.range(wordStart, wordEnd);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
const matchResults = [];
|
|
213
|
+
for (let i = 0; i < prefixes.length; i++) {
|
|
214
|
+
const prefix = prefixes[i], suffix = suffixes[i];
|
|
215
|
+
let isMatch, beforeText = "", afterText = "";
|
|
216
|
+
const CONTEXT_SIZE = 50;
|
|
217
|
+
let beforeOffset = 0, afterOffset = 0;
|
|
218
|
+
if (typeof prefix === "string") {
|
|
219
|
+
beforeText = state.sliceDoc(Math.max(selRange.from - prefix.length, 0), selRange.from);
|
|
220
|
+
isMatch = beforeText === prefix;
|
|
221
|
+
} else {
|
|
222
|
+
const beforeContext = state.sliceDoc(Math.max(selRange.from - CONTEXT_SIZE, 0), selRange.from);
|
|
223
|
+
const prefixMatch = beforeContext.match(prefix.regex);
|
|
224
|
+
if (prefixMatch) {
|
|
225
|
+
beforeText = prefixMatch[1];
|
|
226
|
+
beforeOffset = beforeContext.length - (beforeText.length + beforeContext.indexOf(beforeText));
|
|
227
|
+
isMatch = true;
|
|
228
|
+
} else isMatch = false;
|
|
229
|
+
}
|
|
230
|
+
if (typeof suffix === "string") {
|
|
231
|
+
afterText = state.sliceDoc(selRange.to, Math.min(selRange.to + suffix.length, doc.length));
|
|
232
|
+
isMatch = isMatch && afterText === suffix;
|
|
233
|
+
} else {
|
|
234
|
+
const afterContext = state.sliceDoc(selRange.to, Math.min(selRange.to + CONTEXT_SIZE, doc.length));
|
|
235
|
+
const suffixMatch = afterContext.match(suffix.regex);
|
|
236
|
+
if (suffixMatch) {
|
|
237
|
+
afterText = suffixMatch[1];
|
|
238
|
+
afterOffset = afterContext.indexOf(afterText);
|
|
239
|
+
isMatch = isMatch && true;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
if (isMatch && (beforeOffset > 0 || afterOffset > 0)) selRange = EditorSelection.range(selRange.from - beforeOffset, selRange.to + afterOffset);
|
|
243
|
+
matchResults.push({
|
|
244
|
+
before: beforeText,
|
|
245
|
+
after: afterText,
|
|
246
|
+
match: isMatch
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
for (let i = 0; i < matchResults.length; i++) {
|
|
250
|
+
if (matchResults[i].match && preferNextLevel && matchResults.slice(i + 1).some((result) => result.match)) continue;
|
|
251
|
+
const { before, after, match } = matchResults[i];
|
|
252
|
+
if (match) {
|
|
253
|
+
matchIndex = i;
|
|
254
|
+
if (i + 1 < prefixes.length) {
|
|
255
|
+
const nextPrefix = prefixes[i + 1], nextSuffix = suffixes[i + 1], newPrefix = typeof nextPrefix === "string" ? nextPrefix : nextPrefix.replacement, newSuffix = typeof nextSuffix === "string" ? nextSuffix : nextSuffix.replacement, prefixOffset = newPrefix.length - before.length;
|
|
256
|
+
return {
|
|
257
|
+
range: preferNextLevel && range.empty && i === 0 ? EditorSelection.cursor(selRange.from + prefixOffset - 1) : EditorSelection.range(selRange.from + prefixOffset, selRange.to + prefixOffset),
|
|
258
|
+
changes: [{
|
|
259
|
+
from: selRange.from - before.length,
|
|
260
|
+
to: selRange.from,
|
|
261
|
+
insert: newPrefix
|
|
262
|
+
}, {
|
|
263
|
+
from: selRange.to,
|
|
264
|
+
to: selRange.to + before.length,
|
|
265
|
+
insert: newSuffix
|
|
266
|
+
}]
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
return {
|
|
270
|
+
range: EditorSelection.range(selRange.from - before.length, selRange.to - before.length),
|
|
271
|
+
changes: [{
|
|
272
|
+
from: selRange.from - before.length,
|
|
273
|
+
to: selRange.from,
|
|
274
|
+
insert: ""
|
|
275
|
+
}, {
|
|
276
|
+
from: selRange.to,
|
|
277
|
+
to: selRange.to + after.length,
|
|
278
|
+
insert: ""
|
|
279
|
+
}]
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
const firstPrefix = prefixes[0], firstSuffix = suffixes[0], prefixText = typeof firstPrefix === "string" ? firstPrefix : firstPrefix.replacement, suffixText = typeof firstSuffix === "string" ? firstSuffix : firstSuffix.replacement;
|
|
284
|
+
return {
|
|
285
|
+
range: EditorSelection.range(selRange.from + prefixText.length, selRange.to + prefixText.length),
|
|
286
|
+
changes: [{
|
|
287
|
+
from: selRange.from,
|
|
288
|
+
insert: prefixText
|
|
289
|
+
}, {
|
|
290
|
+
from: selRange.to,
|
|
291
|
+
insert: suffixText
|
|
292
|
+
}]
|
|
293
|
+
};
|
|
294
|
+
})), state.selection.ranges.length > 1 && (matchIndex = null), view.focus(), matchIndex;
|
|
295
|
+
}
|
|
296
|
+
function toggleList(n) {
|
|
297
|
+
toggleListLike(n, "- ");
|
|
298
|
+
return true;
|
|
299
|
+
}
|
|
300
|
+
function toggleEnum(n) {
|
|
301
|
+
toggleListLike(n, "+ ");
|
|
302
|
+
return true;
|
|
303
|
+
}
|
|
304
|
+
function toggleListLike(view, prefix) {
|
|
305
|
+
if (view.state.facet(EditorState.readOnly)) return;
|
|
306
|
+
const { doc } = view.state;
|
|
307
|
+
view.dispatch(view.state.changeByRange((range) => {
|
|
308
|
+
const changes = [];
|
|
309
|
+
let fromOffset = 0, toOffset = 0;
|
|
310
|
+
const calculateOffset = (pos, change) => change.from <= pos ? change.insert.length - (Math.min(change.to, pos) - change.from) : 0;
|
|
311
|
+
const addChange = (change) => {
|
|
312
|
+
changes.push(change);
|
|
313
|
+
fromOffset += calculateOffset(range.from, change);
|
|
314
|
+
toOffset += calculateOffset(range.to, change);
|
|
315
|
+
};
|
|
316
|
+
const getLineStart = (lineNum) => {
|
|
317
|
+
const line = doc.line(lineNum);
|
|
318
|
+
const indentMatch = line.text.match(/^\s*/);
|
|
319
|
+
return line.from + (indentMatch?.[0].length ?? 0);
|
|
320
|
+
};
|
|
321
|
+
const startLine = doc.lineAt(range.from), endLine = doc.lineAt(range.to), listMarkers = [];
|
|
322
|
+
let currentLineNum = startLine.number, hasListMarkers = false;
|
|
323
|
+
const lineIter = doc.iterLines(startLine.number, endLine.number + 1);
|
|
324
|
+
while (lineIter.next() && !lineIter.done) {
|
|
325
|
+
const listMatch = lineIter.value.trimStart().match(/^(-|\+|\d*\.)\s?/);
|
|
326
|
+
if (listMatch !== null) {
|
|
327
|
+
listMarkers.push([currentLineNum, listMatch[0].length]);
|
|
328
|
+
hasListMarkers = true;
|
|
329
|
+
}
|
|
330
|
+
currentLineNum++;
|
|
331
|
+
}
|
|
332
|
+
if (hasListMarkers) for (const [lineNum, markerLength] of listMarkers) {
|
|
333
|
+
const lineStart = getLineStart(lineNum);
|
|
334
|
+
addChange({
|
|
335
|
+
from: lineStart,
|
|
336
|
+
to: lineStart + markerLength,
|
|
337
|
+
insert: ""
|
|
338
|
+
});
|
|
339
|
+
}
|
|
340
|
+
else for (let lineNum = startLine.number; lineNum <= endLine.number; lineNum++) {
|
|
341
|
+
const lineStart = getLineStart(lineNum);
|
|
342
|
+
addChange({
|
|
343
|
+
from: lineStart,
|
|
344
|
+
to: lineStart,
|
|
345
|
+
insert: prefix
|
|
346
|
+
});
|
|
347
|
+
}
|
|
348
|
+
return {
|
|
349
|
+
range: EditorSelection.range(range.from + fromOffset, range.to + toOffset),
|
|
350
|
+
changes
|
|
351
|
+
};
|
|
352
|
+
}));
|
|
353
|
+
view.focus();
|
|
354
|
+
}
|
|
355
|
+
function cursorAddVertical(view, offset) {
|
|
356
|
+
const { state } = view, newCursors = [];
|
|
357
|
+
for (const range of state.selection.ranges) {
|
|
358
|
+
const currentLine = state.doc.lineAt(range.from), currentColumn = countColumn(currentLine.text, state.tabSize, range.from - currentLine.from), targetLineNum = currentLine.number + offset;
|
|
359
|
+
if (targetLineNum >= 1 && targetLineNum <= state.doc.lines) {
|
|
360
|
+
const targetLine = state.doc.line(currentLine.number + offset), targetPos = targetLine.from + findColumn(targetLine.text, currentColumn, state.tabSize);
|
|
361
|
+
newCursors.push(EditorSelection.cursor(targetPos));
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
if (offset > 0) newCursors.reverse();
|
|
365
|
+
newCursors.push(...state.selection.ranges);
|
|
366
|
+
view.dispatch({
|
|
367
|
+
selection: EditorSelection.create(newCursors),
|
|
368
|
+
scrollIntoView: true,
|
|
369
|
+
userEvent: "select"
|
|
370
|
+
});
|
|
371
|
+
return true;
|
|
372
|
+
}
|
|
373
|
+
//#endregion
|
|
374
|
+
//#region src/keymap.ts
|
|
375
|
+
const typstKeymap = keymap.of([
|
|
376
|
+
...completionKeymap,
|
|
377
|
+
...closeBracketsKeymap,
|
|
378
|
+
{
|
|
379
|
+
key: "Tab",
|
|
380
|
+
run: acceptCompletion
|
|
381
|
+
},
|
|
382
|
+
{
|
|
383
|
+
key: "Mod-z",
|
|
384
|
+
run: undo,
|
|
385
|
+
preventDefault: true
|
|
386
|
+
},
|
|
387
|
+
{
|
|
388
|
+
key: "Mod-y",
|
|
389
|
+
mac: "Mod-Shift-z",
|
|
390
|
+
run: redo,
|
|
391
|
+
preventDefault: true
|
|
392
|
+
},
|
|
393
|
+
{
|
|
394
|
+
key: "Mod-Shift-z",
|
|
395
|
+
run: redo,
|
|
396
|
+
preventDefault: true
|
|
397
|
+
},
|
|
398
|
+
{
|
|
399
|
+
key: "Mod-u",
|
|
400
|
+
run: undoSelection,
|
|
401
|
+
preventDefault: true
|
|
402
|
+
},
|
|
403
|
+
{
|
|
404
|
+
key: "Alt-u",
|
|
405
|
+
mac: "Mod-Shift-u",
|
|
406
|
+
run: redoSelection,
|
|
407
|
+
preventDefault: true
|
|
408
|
+
},
|
|
409
|
+
{
|
|
410
|
+
key: "Mod-/",
|
|
411
|
+
run: toggleLineComment
|
|
412
|
+
},
|
|
413
|
+
{
|
|
414
|
+
mac: "Cmd-Alt-/",
|
|
415
|
+
win: "Shift-Alt-a",
|
|
416
|
+
linux: "Ctrl+Shift+a",
|
|
417
|
+
run: toggleBlockComment
|
|
418
|
+
},
|
|
419
|
+
{
|
|
420
|
+
key: "Mod-#",
|
|
421
|
+
run: toggleLineComment
|
|
422
|
+
},
|
|
423
|
+
{
|
|
424
|
+
key: "Mod-Shift-7",
|
|
425
|
+
run: toggleLineComment
|
|
426
|
+
},
|
|
427
|
+
{
|
|
428
|
+
key: "Mod-Shift-/",
|
|
429
|
+
run: toggleLineComment
|
|
430
|
+
},
|
|
431
|
+
{
|
|
432
|
+
key: "Shift-Alt-a",
|
|
433
|
+
run: toggleBlockComment
|
|
434
|
+
},
|
|
435
|
+
{
|
|
436
|
+
key: "Mod-b",
|
|
437
|
+
run: toggleStrong
|
|
438
|
+
},
|
|
439
|
+
{
|
|
440
|
+
key: "Mod-Shift-f",
|
|
441
|
+
run: toggleStrong
|
|
442
|
+
},
|
|
443
|
+
{
|
|
444
|
+
key: "Mod-i",
|
|
445
|
+
run: toggleEmph
|
|
446
|
+
},
|
|
447
|
+
{
|
|
448
|
+
key: "Mod-Shift-K",
|
|
449
|
+
run: toggleEmph
|
|
450
|
+
},
|
|
451
|
+
{
|
|
452
|
+
key: "Mod-u",
|
|
453
|
+
run: toggleUnderline
|
|
454
|
+
},
|
|
455
|
+
{
|
|
456
|
+
key: "Alt-ArrowUp",
|
|
457
|
+
run: moveLineUp
|
|
458
|
+
},
|
|
459
|
+
{
|
|
460
|
+
key: "Shift-Alt-ArrowUp",
|
|
461
|
+
run: copyLineUp
|
|
462
|
+
},
|
|
463
|
+
{
|
|
464
|
+
key: "Alt-ArrowDown",
|
|
465
|
+
run: moveLineDown
|
|
466
|
+
},
|
|
467
|
+
{
|
|
468
|
+
key: "Shift-Alt-ArrowDown",
|
|
469
|
+
run: copyLineDown
|
|
470
|
+
},
|
|
471
|
+
{
|
|
472
|
+
key: "Tab",
|
|
473
|
+
run: insertTabLike,
|
|
474
|
+
shift: indentLess
|
|
475
|
+
},
|
|
476
|
+
{
|
|
477
|
+
key: "Mod-Enter",
|
|
478
|
+
run: insertBlankLine
|
|
479
|
+
},
|
|
480
|
+
{
|
|
481
|
+
key: "F8",
|
|
482
|
+
run: nextDiagnostic
|
|
483
|
+
},
|
|
484
|
+
{
|
|
485
|
+
key: "Mod-g",
|
|
486
|
+
run: gotoLine,
|
|
487
|
+
scope: "editor goto-panel search-panel"
|
|
488
|
+
},
|
|
489
|
+
{
|
|
490
|
+
key: "Escape",
|
|
491
|
+
run: simplifySelection
|
|
492
|
+
},
|
|
493
|
+
{
|
|
494
|
+
key: "Mod-Alt-ArrowUp",
|
|
495
|
+
run: (n) => cursorAddVertical(n, -1)
|
|
496
|
+
},
|
|
497
|
+
{
|
|
498
|
+
key: "Mod-Alt-ArrowDown",
|
|
499
|
+
run: (n) => cursorAddVertical(n, 1)
|
|
500
|
+
},
|
|
501
|
+
{
|
|
502
|
+
key: "Mod-f",
|
|
503
|
+
run: openSearchPanel,
|
|
504
|
+
scope: "editor search-panel goto-panel"
|
|
505
|
+
},
|
|
506
|
+
{
|
|
507
|
+
key: "F3",
|
|
508
|
+
run: findNext,
|
|
509
|
+
shift: findPrevious,
|
|
510
|
+
scope: "editor search-panel",
|
|
511
|
+
preventDefault: true
|
|
512
|
+
},
|
|
513
|
+
{
|
|
514
|
+
key: "Escape",
|
|
515
|
+
run: closeSearchPanel,
|
|
516
|
+
scope: "editor search-panel"
|
|
517
|
+
},
|
|
518
|
+
{
|
|
519
|
+
key: "Mod-Shift-l",
|
|
520
|
+
run: selectSelectionMatches
|
|
521
|
+
},
|
|
522
|
+
{
|
|
523
|
+
key: "Mod-d",
|
|
524
|
+
run: selectNextOccurrence,
|
|
525
|
+
preventDefault: true
|
|
526
|
+
},
|
|
527
|
+
...standardKeymap
|
|
528
|
+
]);
|
|
529
|
+
//#endregion
|
|
530
|
+
//#region src/language.ts
|
|
531
|
+
async function autocomplete(context, fileId, typstState) {
|
|
532
|
+
const { pos, explicit } = context;
|
|
533
|
+
let result;
|
|
534
|
+
try {
|
|
535
|
+
result = typstState.autocomplete(fileId, pos, explicit);
|
|
536
|
+
} catch (error) {
|
|
537
|
+
console.error("[typst] autocomplete panicked:", error);
|
|
538
|
+
return null;
|
|
539
|
+
}
|
|
540
|
+
if (!result) return null;
|
|
541
|
+
const { offset, completions } = result;
|
|
542
|
+
return {
|
|
543
|
+
from: offset,
|
|
544
|
+
options: completions.map((completion) => {
|
|
545
|
+
const complete = {
|
|
546
|
+
type: typeof completion.type === "object" ? "symbol" : completion.type,
|
|
547
|
+
apply: completion.apply,
|
|
548
|
+
label: completion.label,
|
|
549
|
+
info: completion.detail
|
|
550
|
+
};
|
|
551
|
+
if (completion.apply?.includes("$")) {
|
|
552
|
+
const tt = completion.apply.match(/\$\{\w*\}/)?.at(0) == "${}" && (completion.type !== "syntax" || completion.label != "linebreak"), applySnippet = snippet(completion.apply);
|
|
553
|
+
complete.apply = (applyView, applyCompletion, from, to) => {
|
|
554
|
+
applySnippet(applyView, applyCompletion, from, to);
|
|
555
|
+
if (tt) startCompletion(applyView);
|
|
556
|
+
};
|
|
557
|
+
}
|
|
558
|
+
return complete;
|
|
559
|
+
})
|
|
560
|
+
};
|
|
561
|
+
}
|
|
562
|
+
const typstLanguageData = EditorState.languageData.of(() => [{
|
|
563
|
+
closeBrackets: {
|
|
564
|
+
brackets: [
|
|
565
|
+
"(",
|
|
566
|
+
"[",
|
|
567
|
+
"{",
|
|
568
|
+
"\"",
|
|
569
|
+
"`",
|
|
570
|
+
"$"
|
|
571
|
+
],
|
|
572
|
+
before: ")]}\"`$,;"
|
|
573
|
+
},
|
|
574
|
+
commentTokens: {
|
|
575
|
+
line: "//",
|
|
576
|
+
block: {
|
|
577
|
+
open: "/*",
|
|
578
|
+
close: "*/"
|
|
579
|
+
}
|
|
580
|
+
},
|
|
581
|
+
wordChars: "-_",
|
|
582
|
+
indentOnInput: /^\s*[[\]{}()$]$/
|
|
583
|
+
}]);
|
|
584
|
+
//#endregion
|
|
585
|
+
//#region src/notebook.ts
|
|
586
|
+
/** The options of the notebook extension active in this state, if any. */
|
|
587
|
+
const notebookOptionsFacet = Facet.define({ combine: (values) => values.at(-1) });
|
|
588
|
+
function notebookOptions(state) {
|
|
589
|
+
return state.facet(notebookOptionsFacet);
|
|
590
|
+
}
|
|
591
|
+
/** Re-renders notebooks from cached frames without recompiling. */
|
|
592
|
+
const notebookRefreshEffect = StateEffect.define();
|
|
593
|
+
/** Asks the plugin to recompile and report the result through `onRun`. */
|
|
594
|
+
const notebookRunEffect = StateEffect.define();
|
|
595
|
+
/** First position that belongs to the cell (marker line, or content). */
|
|
596
|
+
function cellStart(cell) {
|
|
597
|
+
return cell.marker_end > cell.marker_start ? cell.marker_start : cell.content_start;
|
|
598
|
+
}
|
|
599
|
+
function hasMarker(cell) {
|
|
600
|
+
return cell.marker_end > cell.marker_start;
|
|
601
|
+
}
|
|
602
|
+
/** Index of the cell containing `pos`; gaps belong to the cell above. */
|
|
603
|
+
function cellIndexAt(cells, pos) {
|
|
604
|
+
let index = cells.length ? 0 : -1;
|
|
605
|
+
for (let i = 0; i < cells.length; i++) if (pos >= cellStart(cells[i])) index = i;
|
|
606
|
+
else break;
|
|
607
|
+
return index;
|
|
608
|
+
}
|
|
609
|
+
function cellsOf(state) {
|
|
610
|
+
const options = notebookOptions(state);
|
|
611
|
+
return options ? options.cells(state.doc.toString()) : [];
|
|
612
|
+
}
|
|
613
|
+
/** Position just past a cell's block, including the gap before the next one. */
|
|
614
|
+
function blockEnd(cells, index, text) {
|
|
615
|
+
const next = cells[index + 1];
|
|
616
|
+
return next ? cellStart(next) : text.length;
|
|
617
|
+
}
|
|
618
|
+
/** The marker line for a cell type. Markup is the unlabeled default. */
|
|
619
|
+
function markerText(type) {
|
|
620
|
+
return type === "code" ? "// %% [code]" : "// %%";
|
|
621
|
+
}
|
|
622
|
+
function insertCellText(text, cells, index, where, type, content = "") {
|
|
623
|
+
const marker = markerText(type);
|
|
624
|
+
if (where === "end" || cells.length === 0) {
|
|
625
|
+
const from = text.length;
|
|
626
|
+
const insert = `${from === 0 || text.endsWith("\n\n") ? "" : text.endsWith("\n") ? "\n" : "\n\n"}${marker}\n${content ? `${content}\n` : ""}`;
|
|
627
|
+
return {
|
|
628
|
+
changes: {
|
|
629
|
+
from,
|
|
630
|
+
insert
|
|
631
|
+
},
|
|
632
|
+
anchor: from + insert.length
|
|
633
|
+
};
|
|
634
|
+
}
|
|
635
|
+
const cell = cells[index];
|
|
636
|
+
const from = where === "above" ? cellStart(cell) : blockEnd(cells, index, text);
|
|
637
|
+
return {
|
|
638
|
+
changes: {
|
|
639
|
+
from,
|
|
640
|
+
insert: `${marker}\n${content ? `${content}\n` : ""}\n`
|
|
641
|
+
},
|
|
642
|
+
anchor: from + marker.length + 1 + content.length
|
|
643
|
+
};
|
|
644
|
+
}
|
|
645
|
+
function duplicateCellText(text, cells, index) {
|
|
646
|
+
const cell = cells[index];
|
|
647
|
+
return insertCellText(text, cells, index, "below", cell.kind, text.slice(cell.content_start, cell.content_end));
|
|
648
|
+
}
|
|
649
|
+
function deleteCellText(text, cells, index) {
|
|
650
|
+
const cell = cells[index];
|
|
651
|
+
if (cells.length <= 1) return {
|
|
652
|
+
changes: {
|
|
653
|
+
from: 0,
|
|
654
|
+
to: text.length,
|
|
655
|
+
insert: ""
|
|
656
|
+
},
|
|
657
|
+
anchor: 0
|
|
658
|
+
};
|
|
659
|
+
const from = cellStart(cell);
|
|
660
|
+
return {
|
|
661
|
+
changes: {
|
|
662
|
+
from,
|
|
663
|
+
to: blockEnd(cells, index, text),
|
|
664
|
+
insert: ""
|
|
665
|
+
},
|
|
666
|
+
anchor: from
|
|
667
|
+
};
|
|
668
|
+
}
|
|
669
|
+
function moveCellText(text, cells, index, delta) {
|
|
670
|
+
const target = index + delta;
|
|
671
|
+
if (target < 0 || target >= cells.length) return null;
|
|
672
|
+
const aStart = cellStart(cells[index]);
|
|
673
|
+
const aEnd = blockEnd(cells, index, text);
|
|
674
|
+
const bStart = cellStart(cells[target]);
|
|
675
|
+
const bEnd = blockEnd(cells, target, text);
|
|
676
|
+
const from = Math.min(aStart, bStart);
|
|
677
|
+
const to = Math.max(aEnd, bEnd);
|
|
678
|
+
const a = text.slice(aStart, aEnd).trimEnd();
|
|
679
|
+
const b = text.slice(bStart, bEnd).trimEnd();
|
|
680
|
+
return {
|
|
681
|
+
changes: {
|
|
682
|
+
from,
|
|
683
|
+
to,
|
|
684
|
+
insert: `${delta < 0 ? `${a}\n\n${b}` : `${b}\n\n${a}`}${to < text.length ? "\n\n" : "\n"}`
|
|
685
|
+
},
|
|
686
|
+
anchor: delta < 0 ? bStart : bStart + b.length + 2
|
|
687
|
+
};
|
|
688
|
+
}
|
|
689
|
+
function splitCellText(text, cells, index, pos) {
|
|
690
|
+
const cell = cells[index];
|
|
691
|
+
if (pos < cell.content_start || pos > cell.content_end) return null;
|
|
692
|
+
return {
|
|
693
|
+
changes: {
|
|
694
|
+
from: pos,
|
|
695
|
+
insert: `\n\n${markerText(cell.kind)}\n\n`
|
|
696
|
+
},
|
|
697
|
+
anchor: pos
|
|
698
|
+
};
|
|
699
|
+
}
|
|
700
|
+
function mergeCellText(text, cells, index) {
|
|
701
|
+
const cell = cells[index];
|
|
702
|
+
if (index === 0 || !hasMarker(cell)) return null;
|
|
703
|
+
return {
|
|
704
|
+
changes: {
|
|
705
|
+
from: cell.marker_start,
|
|
706
|
+
to: cell.content_start,
|
|
707
|
+
insert: ""
|
|
708
|
+
},
|
|
709
|
+
anchor: cell.marker_start
|
|
710
|
+
};
|
|
711
|
+
}
|
|
712
|
+
function setCellTypeText(text, cells, index, type) {
|
|
713
|
+
const cell = cells[index];
|
|
714
|
+
const marker = markerText(type);
|
|
715
|
+
if (hasMarker(cell)) return {
|
|
716
|
+
changes: {
|
|
717
|
+
from: cell.marker_start,
|
|
718
|
+
to: cell.marker_end,
|
|
719
|
+
insert: marker
|
|
720
|
+
},
|
|
721
|
+
anchor: cell.content_start + (marker.length - (cell.marker_end - cell.marker_start))
|
|
722
|
+
};
|
|
723
|
+
return {
|
|
724
|
+
changes: {
|
|
725
|
+
from: cellStart(cell),
|
|
726
|
+
insert: `${marker}\n`
|
|
727
|
+
},
|
|
728
|
+
anchor: cell.content_start + marker.length + 1
|
|
729
|
+
};
|
|
730
|
+
}
|
|
731
|
+
/** Matches a whole cell marker line, indentation and newline included. */
|
|
732
|
+
const MARKER_LINE = /^[ \t]*\/\/[ \t]*%%(?:[ \t]*\[(?:code|markup|text)\])?[ \t]*\r?\n?/gm;
|
|
733
|
+
/**
|
|
734
|
+
* Removes `// %%` cell markers. Notebooks are comments-plus-content, so a
|
|
735
|
+
* cleaned file is the same document for anyone who does not know the
|
|
736
|
+
* convention.
|
|
737
|
+
*/
|
|
738
|
+
function stripCellMarkers(text) {
|
|
739
|
+
return text.replace(MARKER_LINE, "");
|
|
740
|
+
}
|
|
741
|
+
function dispatchEdit(view, edit) {
|
|
742
|
+
view.dispatch({
|
|
743
|
+
changes: edit.changes,
|
|
744
|
+
selection: { anchor: edit.anchor },
|
|
745
|
+
scrollIntoView: true
|
|
746
|
+
});
|
|
747
|
+
return true;
|
|
748
|
+
}
|
|
749
|
+
/** Compiles and reports through `onRun`; does not move the cursor. */
|
|
750
|
+
function runCell(view, index) {
|
|
751
|
+
if (!notebookOptions(view.state) || index < 0) return false;
|
|
752
|
+
view.dispatch({ effects: notebookRunEffect.of({ index }) });
|
|
753
|
+
return true;
|
|
754
|
+
}
|
|
755
|
+
function runAllCells(view) {
|
|
756
|
+
if (!notebookOptions(view.state)) return false;
|
|
757
|
+
view.dispatch({ effects: notebookRunEffect.of({ index: "all" }) });
|
|
758
|
+
return true;
|
|
759
|
+
}
|
|
760
|
+
function focusCell(view, index, edge = "start") {
|
|
761
|
+
const cell = cellsOf(view.state)[index];
|
|
762
|
+
if (!cell) return false;
|
|
763
|
+
view.dispatch({
|
|
764
|
+
selection: { anchor: edge === "start" ? cell.content_start : cell.content_end },
|
|
765
|
+
scrollIntoView: true
|
|
766
|
+
});
|
|
767
|
+
view.focus();
|
|
768
|
+
return true;
|
|
769
|
+
}
|
|
770
|
+
function currentCell(view) {
|
|
771
|
+
return cellIndexAt(cellsOf(view.state), view.state.selection.main.head);
|
|
772
|
+
}
|
|
773
|
+
function insertCell(view, where) {
|
|
774
|
+
return insertCellAt(view, currentCell(view), where);
|
|
775
|
+
}
|
|
776
|
+
function insertCellAt(view, index, where) {
|
|
777
|
+
const cells = cellsOf(view.state);
|
|
778
|
+
if (index < 0 || !cells[index]) return false;
|
|
779
|
+
const type = cells[index]?.kind ?? "markup";
|
|
780
|
+
return dispatchEdit(view, insertCellText(view.state.doc.toString(), cells, index, where, type));
|
|
781
|
+
}
|
|
782
|
+
function duplicateCell(view) {
|
|
783
|
+
return duplicateCellAt(view, currentCell(view));
|
|
784
|
+
}
|
|
785
|
+
function duplicateCellAt(view, index) {
|
|
786
|
+
const cells = cellsOf(view.state);
|
|
787
|
+
if (index < 0 || !cells[index]) return false;
|
|
788
|
+
return dispatchEdit(view, duplicateCellText(view.state.doc.toString(), cells, index));
|
|
789
|
+
}
|
|
790
|
+
function deleteCell(view) {
|
|
791
|
+
return deleteCellAt(view, currentCell(view));
|
|
792
|
+
}
|
|
793
|
+
function deleteCellAt(view, index) {
|
|
794
|
+
const cells = cellsOf(view.state);
|
|
795
|
+
if (index < 0 || !cells[index]) return false;
|
|
796
|
+
return dispatchEdit(view, deleteCellText(view.state.doc.toString(), cells, index));
|
|
797
|
+
}
|
|
798
|
+
function moveCell(view, delta) {
|
|
799
|
+
return moveCellAt(view, currentCell(view), delta);
|
|
800
|
+
}
|
|
801
|
+
function moveCellAt(view, index, delta) {
|
|
802
|
+
const cells = cellsOf(view.state);
|
|
803
|
+
if (index < 0 || !cells[index]) return false;
|
|
804
|
+
const edit = moveCellText(view.state.doc.toString(), cells, index, delta);
|
|
805
|
+
if (!edit) return false;
|
|
806
|
+
return dispatchEdit(view, edit);
|
|
807
|
+
}
|
|
808
|
+
function splitCell(view) {
|
|
809
|
+
const cells = cellsOf(view.state);
|
|
810
|
+
const index = currentCell(view);
|
|
811
|
+
if (index < 0) return false;
|
|
812
|
+
const edit = splitCellText(view.state.doc.toString(), cells, index, view.state.selection.main.head);
|
|
813
|
+
if (!edit) return false;
|
|
814
|
+
return dispatchEdit(view, edit);
|
|
815
|
+
}
|
|
816
|
+
function mergeCell(view) {
|
|
817
|
+
const cells = cellsOf(view.state);
|
|
818
|
+
const index = currentCell(view);
|
|
819
|
+
if (index < 0) return false;
|
|
820
|
+
const edit = mergeCellText(view.state.doc.toString(), cells, index);
|
|
821
|
+
if (!edit) return false;
|
|
822
|
+
return dispatchEdit(view, edit);
|
|
823
|
+
}
|
|
824
|
+
function toggleCellType(view, index) {
|
|
825
|
+
const cells = cellsOf(view.state);
|
|
826
|
+
const cell = cells[index];
|
|
827
|
+
if (!cell) return false;
|
|
828
|
+
const next = cell.kind === "code" ? "markup" : "code";
|
|
829
|
+
return dispatchEdit(view, setCellTypeText(view.state.doc.toString(), cells, index, next));
|
|
830
|
+
}
|
|
831
|
+
function setCellType(view, index, type) {
|
|
832
|
+
const cells = cellsOf(view.state);
|
|
833
|
+
if (!cells[index]) return false;
|
|
834
|
+
return dispatchEdit(view, setCellTypeText(view.state.doc.toString(), cells, index, type));
|
|
835
|
+
}
|
|
836
|
+
function runAndAdvance(view) {
|
|
837
|
+
const cells = cellsOf(view.state);
|
|
838
|
+
const index = currentCell(view);
|
|
839
|
+
if (index < 0) return false;
|
|
840
|
+
runCell(view, index);
|
|
841
|
+
if (cells[index + 1]) focusCell(view, index + 1);
|
|
842
|
+
else dispatchEdit(view, insertCellText(view.state.doc.toString(), cells, index, "below", cells[index]?.kind ?? "markup"));
|
|
843
|
+
return true;
|
|
844
|
+
}
|
|
845
|
+
function runAndInsert(view) {
|
|
846
|
+
const cells = cellsOf(view.state);
|
|
847
|
+
const index = currentCell(view);
|
|
848
|
+
if (index < 0) return false;
|
|
849
|
+
runCell(view, index);
|
|
850
|
+
return dispatchEdit(view, insertCellText(view.state.doc.toString(), cells, index, "below", cells[index]?.kind ?? "markup"));
|
|
851
|
+
}
|
|
852
|
+
function runCurrent(view) {
|
|
853
|
+
const index = currentCell(view);
|
|
854
|
+
return index < 0 ? false : runCell(view, index);
|
|
855
|
+
}
|
|
856
|
+
/** Arrow keys at a cell's first or last line move between cells. */
|
|
857
|
+
function boundaryMove(view, direction) {
|
|
858
|
+
const { state } = view;
|
|
859
|
+
const range = state.selection.main;
|
|
860
|
+
if (!range.empty) return false;
|
|
861
|
+
const cells = cellsOf(state);
|
|
862
|
+
const index = cellIndexAt(cells, range.head);
|
|
863
|
+
const cell = cells[index];
|
|
864
|
+
if (!cell) return false;
|
|
865
|
+
if (direction === -1) {
|
|
866
|
+
const first = state.doc.lineAt(cell.content_start);
|
|
867
|
+
if (range.head < first.from || range.head > first.to || index === 0) return false;
|
|
868
|
+
return focusCell(view, index - 1, "end");
|
|
869
|
+
}
|
|
870
|
+
const last = state.doc.lineAt(cell.content_end);
|
|
871
|
+
if (range.head < last.from || range.head > last.to || index >= cells.length - 1) return false;
|
|
872
|
+
return focusCell(view, index + 1);
|
|
873
|
+
}
|
|
874
|
+
/**
|
|
875
|
+
* Notebook keymap. Mounted with high precedence so Alt-ArrowUp/Down move
|
|
876
|
+
* cells instead of lines and Escape reaches command mode.
|
|
877
|
+
*/
|
|
878
|
+
const notebookKeymap = Prec.high(keymap.of([
|
|
879
|
+
{
|
|
880
|
+
key: "Shift-Enter",
|
|
881
|
+
run: runAndAdvance
|
|
882
|
+
},
|
|
883
|
+
{
|
|
884
|
+
key: "Mod-Enter",
|
|
885
|
+
run: runCurrent
|
|
886
|
+
},
|
|
887
|
+
{
|
|
888
|
+
key: "Alt-Enter",
|
|
889
|
+
run: runAndInsert
|
|
890
|
+
},
|
|
891
|
+
{
|
|
892
|
+
key: "Alt-ArrowUp",
|
|
893
|
+
run: (view) => moveCell(view, -1)
|
|
894
|
+
},
|
|
895
|
+
{
|
|
896
|
+
key: "Alt-ArrowDown",
|
|
897
|
+
run: (view) => moveCell(view, 1)
|
|
898
|
+
},
|
|
899
|
+
{
|
|
900
|
+
key: "Escape",
|
|
901
|
+
run: (view) => {
|
|
902
|
+
notebookOptions(view.state)?.onCommandMode?.();
|
|
903
|
+
return true;
|
|
904
|
+
}
|
|
905
|
+
},
|
|
906
|
+
{
|
|
907
|
+
key: "ArrowUp",
|
|
908
|
+
run: (view) => boundaryMove(view, -1)
|
|
909
|
+
},
|
|
910
|
+
{
|
|
911
|
+
key: "ArrowDown",
|
|
912
|
+
run: (view) => boundaryMove(view, 1)
|
|
913
|
+
}
|
|
914
|
+
]));
|
|
915
|
+
//#endregion
|
|
916
|
+
//#region src/diagnostics.ts
|
|
917
|
+
/**
|
|
918
|
+
* Latest Typst diagnostics per page path. The WYSIWYG plugin caches the
|
|
919
|
+
* diagnostics of every compile here, so the linter source can reuse them
|
|
920
|
+
* instead of compiling the same text a second time.
|
|
921
|
+
*/
|
|
922
|
+
const diagnosticsCache = /* @__PURE__ */ new Map();
|
|
923
|
+
function rememberDiagnostics(path, text, diagnostics) {
|
|
924
|
+
diagnosticsCache.set(path, {
|
|
925
|
+
text,
|
|
926
|
+
diagnostics
|
|
927
|
+
});
|
|
928
|
+
}
|
|
929
|
+
function cachedDiagnostics(path, text) {
|
|
930
|
+
const entry = diagnosticsCache.get(path);
|
|
931
|
+
return entry && entry.text === text ? entry.diagnostics : void 0;
|
|
932
|
+
}
|
|
933
|
+
/** Converts engine diagnostics into CodeMirror lint diagnostics. */
|
|
934
|
+
function toLintDiagnostics(typstDiagnostics) {
|
|
935
|
+
return typstDiagnostics.map((diagnostic) => ({
|
|
936
|
+
from: diagnostic.range.start,
|
|
937
|
+
to: diagnostic.range.end,
|
|
938
|
+
severity: diagnostic.severity,
|
|
939
|
+
message: diagnostic.message,
|
|
940
|
+
renderMessage() {
|
|
941
|
+
const fragment = document.createDocumentFragment();
|
|
942
|
+
const paragraph = document.createElement("p");
|
|
943
|
+
parseBackticks(diagnostic.message, paragraph);
|
|
944
|
+
fragment.append(paragraph);
|
|
945
|
+
if (diagnostic.hints.length) {
|
|
946
|
+
const list = document.createElement("ul");
|
|
947
|
+
list.className = "typst-hints";
|
|
948
|
+
for (const hint of diagnostic.hints) {
|
|
949
|
+
const item = document.createElement("li");
|
|
950
|
+
parseBackticks(hint, item);
|
|
951
|
+
list.append(item);
|
|
952
|
+
}
|
|
953
|
+
fragment.append(list);
|
|
954
|
+
}
|
|
955
|
+
return fragment;
|
|
956
|
+
}
|
|
957
|
+
}));
|
|
958
|
+
}
|
|
959
|
+
/**
|
|
960
|
+
* Typst diagnostics as a CodeMirror linter source.
|
|
961
|
+
*
|
|
962
|
+
* This runs in every editor mode and combines with other sources (the
|
|
963
|
+
* spellcheck linter), so neither one replaces the other. The WYSIWYG plugin's
|
|
964
|
+
* compile feeds the cache; split and source mode fall back to a compile here
|
|
965
|
+
* so they get diagnostics too.
|
|
966
|
+
*/
|
|
967
|
+
const typstLinter = (fileId, spaceId, path, prelude, typstState, options = {}) => linter(async (view) => {
|
|
968
|
+
const text = view.state.doc.toString();
|
|
969
|
+
const cached = cachedDiagnostics(path, text);
|
|
970
|
+
if (cached) return toLintDiagnostics(cached);
|
|
971
|
+
let result;
|
|
972
|
+
try {
|
|
973
|
+
result = typstState.compilePaged(fileId, text, prelude.value);
|
|
974
|
+
} catch (error) {
|
|
975
|
+
console.error("[typst] diagnostics compile panicked:", error);
|
|
976
|
+
options.onPanic?.(fileId);
|
|
977
|
+
return [];
|
|
978
|
+
}
|
|
979
|
+
if (result.requests.length > 0 && options.onRequests) {
|
|
980
|
+
if (await options.onRequests(result.requests, spaceId)) result = typstState.compilePaged(fileId, text, prelude.value);
|
|
981
|
+
}
|
|
982
|
+
rememberDiagnostics(path, text, result.diagnostics);
|
|
983
|
+
options.onCompile?.();
|
|
984
|
+
return toLintDiagnostics(result.diagnostics);
|
|
985
|
+
}, { delay: 400 });
|
|
986
|
+
//#endregion
|
|
987
|
+
//#region src/frames.ts
|
|
988
|
+
const containerCache = new LRUCache({ max: 128 });
|
|
989
|
+
const openInNewTab = (url) => {
|
|
990
|
+
window.open(url, "_blank", "noopener,noreferrer");
|
|
991
|
+
};
|
|
992
|
+
/**
|
|
993
|
+
* The compiled size of an SVG frame, from the markup's viewBox. The viewBox
|
|
994
|
+
* is the page width the engine laid out at (pt), and the app renders one
|
|
995
|
+
* Typst point per CSS pixel, so the numbers map to px directly.
|
|
996
|
+
*
|
|
997
|
+
* `frame.render.width` is the chunk's bounding-box width, which does not
|
|
998
|
+
* change with the pane for fixed-size blocks (images, boxes). The viewBox
|
|
999
|
+
* width is the correct resize key and the size to pin: a render laid out
|
|
1000
|
+
* for a wider pane must keep its size, not be stretched by CSS while a
|
|
1001
|
+
* resize is in flight.
|
|
1002
|
+
*/
|
|
1003
|
+
function frameSize(frame) {
|
|
1004
|
+
const match = frame.render.svg.match(/viewBox="0 0 ([\d.]+) ([\d.]+)"/);
|
|
1005
|
+
return {
|
|
1006
|
+
width: match ? Number(match[1]) : frame.render.width,
|
|
1007
|
+
height: match ? Number(match[2]) : frame.render.height
|
|
1008
|
+
};
|
|
1009
|
+
}
|
|
1010
|
+
/**
|
|
1011
|
+
* Pins a container's SVG to the compiled frame size (via CSS variables). The
|
|
1012
|
+
* container keeps max-width: 100% so it never exceeds the line. An explicit
|
|
1013
|
+
* container width would make CodeMirror's flex layout grow the content width
|
|
1014
|
+
* forever (resize -> wider widget -> wider content). The pinned SVG overflows
|
|
1015
|
+
* the clamped container while the pane is narrower than the compiled width;
|
|
1016
|
+
* the scroller clips it, so the render stays its size instead of scaling.
|
|
1017
|
+
*/
|
|
1018
|
+
function syncFrameContainer(container, frame) {
|
|
1019
|
+
const size = frameSize(frame);
|
|
1020
|
+
const sizeKey = `${Math.round(size.width)}x${Math.round(size.height)}`;
|
|
1021
|
+
if (container.dataset.renderSize === sizeKey) return;
|
|
1022
|
+
container.dataset.renderSize = sizeKey;
|
|
1023
|
+
container.style.setProperty("--render-w", size.width + "px");
|
|
1024
|
+
container.style.setProperty("--render-h", size.height + "px");
|
|
1025
|
+
container.style.height = size.height + "px";
|
|
1026
|
+
container.setHTMLUnsafe(frame.render.svg);
|
|
1027
|
+
}
|
|
1028
|
+
/** Fresh frame container for widgets that own their DOM (notebook outputs). */
|
|
1029
|
+
function createFrameContainer(frame) {
|
|
1030
|
+
const container = document.createElement("div");
|
|
1031
|
+
container.dataset.hash = frame.render.hash.toString();
|
|
1032
|
+
container.classList.add("typst-render");
|
|
1033
|
+
syncFrameContainer(container, frame);
|
|
1034
|
+
return container;
|
|
1035
|
+
}
|
|
1036
|
+
/**
|
|
1037
|
+
* Click handling shared by inline widgets and notebook outputs: typbase://
|
|
1038
|
+
* links are left for the host's anchor router, external links confirm first
|
|
1039
|
+
* and go through `openExternal`, anything else maps back into the compiled
|
|
1040
|
+
* document and moves the cursor. Alt-click edits the link instead of
|
|
1041
|
+
* following it.
|
|
1042
|
+
*/
|
|
1043
|
+
function attachFrameInteractions(container, view, frame, fileId, typstState, openExternal = openInNewTab) {
|
|
1044
|
+
const handle = (event) => {
|
|
1045
|
+
event.preventDefault();
|
|
1046
|
+
const anchor = event.target?.closest?.("a[href]");
|
|
1047
|
+
if (anchor && !event.altKey) {
|
|
1048
|
+
if (event.type !== "click") return;
|
|
1049
|
+
const href = anchor.getAttribute("href") ?? "";
|
|
1050
|
+
if (href.startsWith("typbase://")) return;
|
|
1051
|
+
if (window.confirm(`Open external link?\n\n${href}\n\nIt opens in a new tab.`)) openExternal(href);
|
|
1052
|
+
return;
|
|
1053
|
+
}
|
|
1054
|
+
const { top, left } = container.getBoundingClientRect();
|
|
1055
|
+
const x = event.clientX - left;
|
|
1056
|
+
const y = event.clientY - top + frame.render.yOffset;
|
|
1057
|
+
const jump = (() => {
|
|
1058
|
+
try {
|
|
1059
|
+
return typstState.jumpPaged(fileId, x, y);
|
|
1060
|
+
} catch (error) {
|
|
1061
|
+
console.error("[typst] jump panicked:", error);
|
|
1062
|
+
return;
|
|
1063
|
+
}
|
|
1064
|
+
})();
|
|
1065
|
+
const position = jump ? jump.position : frame.range.end;
|
|
1066
|
+
view.focus();
|
|
1067
|
+
view.dispatch({ selection: { anchor: position } });
|
|
1068
|
+
};
|
|
1069
|
+
container.addEventListener("click", handle);
|
|
1070
|
+
container.addEventListener("mousedown", handle);
|
|
1071
|
+
}
|
|
1072
|
+
/**
|
|
1073
|
+
* True when the selection does not touch `start..end`, so the frame can render
|
|
1074
|
+
* as a widget instead of showing source. Shared by the inline WYSIWYG pass and
|
|
1075
|
+
* the notebook markup cells.
|
|
1076
|
+
*/
|
|
1077
|
+
function frameIsInactive(view, state, start, end) {
|
|
1078
|
+
return !view.hasFocus || state.selection.ranges.every((range) => (range.from < start || range.from > end) && (range.to < start || range.to > end) && (start < range.from || start > range.to) && (end < range.from || end > range.to));
|
|
1079
|
+
}
|
|
1080
|
+
/**
|
|
1081
|
+
* Line decorations that stand in for a frame while the cursor is inside it:
|
|
1082
|
+
* the source stays visible with the frame's height reserved, so the pane does
|
|
1083
|
+
* not jump between render and source. Matches the inline WYSIWYG behavior.
|
|
1084
|
+
*
|
|
1085
|
+
* `lineHeights` carries measured source heights from the remeasure pass; when
|
|
1086
|
+
* it is absent the view still reports the widget's height for those lines.
|
|
1087
|
+
*/
|
|
1088
|
+
function frameActiveDecorations(view, state, frame, lineHeights) {
|
|
1089
|
+
const decorations = [];
|
|
1090
|
+
let lineHeight = 0;
|
|
1091
|
+
const { number: startLine } = state.doc.lineAt(frame.range.start);
|
|
1092
|
+
const { number: endLine } = state.doc.lineAt(frame.range.end);
|
|
1093
|
+
for (let currentLine = startLine; currentLine <= endLine; currentLine++) {
|
|
1094
|
+
const line = state.doc.line(currentLine);
|
|
1095
|
+
let style = "";
|
|
1096
|
+
if (currentLine == startLine) style += "border-top-left-radius:0.25rem;border-top-right-radius:0.25rem;";
|
|
1097
|
+
if (currentLine == endLine) style += `border-bottom-left-radius:0.25rem;border-bottom-right-radius:0.25rem;min-height:${Math.max(0, frame.render.height - lineHeight)}px`;
|
|
1098
|
+
else lineHeight += lineHeights?.get(line.from) ?? view.lineBlockAt(line.from).height;
|
|
1099
|
+
decorations.push(Decoration.line({
|
|
1100
|
+
class: "cm-activeLine",
|
|
1101
|
+
attributes: { style }
|
|
1102
|
+
}).range(line.from));
|
|
1103
|
+
}
|
|
1104
|
+
return decorations;
|
|
1105
|
+
}
|
|
1106
|
+
var TypstWidget = class extends WidgetType {
|
|
1107
|
+
view;
|
|
1108
|
+
frame;
|
|
1109
|
+
locked;
|
|
1110
|
+
fileId;
|
|
1111
|
+
typstState;
|
|
1112
|
+
openExternal;
|
|
1113
|
+
container;
|
|
1114
|
+
size;
|
|
1115
|
+
constructor(view, frame, locked, fileId, typstState, openExternal) {
|
|
1116
|
+
super();
|
|
1117
|
+
this.view = view;
|
|
1118
|
+
this.frame = frame;
|
|
1119
|
+
this.locked = locked;
|
|
1120
|
+
this.fileId = fileId;
|
|
1121
|
+
this.typstState = typstState;
|
|
1122
|
+
this.openExternal = openExternal;
|
|
1123
|
+
this.size = frameSize(frame);
|
|
1124
|
+
const cached = containerCache.get(frame.render.hash);
|
|
1125
|
+
if (cached?.isConnected) {
|
|
1126
|
+
this.container = cached;
|
|
1127
|
+
syncFrameContainer(cached, frame);
|
|
1128
|
+
} else {
|
|
1129
|
+
const container = document.createElement("div");
|
|
1130
|
+
container.dataset.hash = frame.render.hash.toString();
|
|
1131
|
+
container.classList.add("typst-render");
|
|
1132
|
+
if (!locked) attachFrameInteractions(container, view, frame, fileId, typstState, this.openExternal);
|
|
1133
|
+
containerCache.set(frame.render.hash, container);
|
|
1134
|
+
this.container = container;
|
|
1135
|
+
syncFrameContainer(container, frame);
|
|
1136
|
+
}
|
|
1137
|
+
}
|
|
1138
|
+
eq(other) {
|
|
1139
|
+
return other.frame.render.hash === this.frame.render.hash && other.size.width === this.size.width && other.size.height === this.size.height;
|
|
1140
|
+
}
|
|
1141
|
+
toDOM() {
|
|
1142
|
+
return this.container;
|
|
1143
|
+
}
|
|
1144
|
+
get estimatedHeight() {
|
|
1145
|
+
return this.frame.render.height;
|
|
1146
|
+
}
|
|
1147
|
+
};
|
|
1148
|
+
//#endregion
|
|
1149
|
+
//#region src/notebook-widgets.ts
|
|
1150
|
+
function iconSpan(name) {
|
|
1151
|
+
const span = document.createElement("span");
|
|
1152
|
+
span.className = "ms-icon material-symbols-rounded";
|
|
1153
|
+
span.textContent = name;
|
|
1154
|
+
span.setAttribute("aria-hidden", "true");
|
|
1155
|
+
return span;
|
|
1156
|
+
}
|
|
1157
|
+
/** Fallback labels for consumers that enable cells without i18n strings. */
|
|
1158
|
+
const FALLBACK_LABELS = {
|
|
1159
|
+
run: "Run cell",
|
|
1160
|
+
code: "Code",
|
|
1161
|
+
markup: "Text",
|
|
1162
|
+
moveUp: "Move cell up",
|
|
1163
|
+
moveDown: "Move cell down",
|
|
1164
|
+
duplicate: "Duplicate cell",
|
|
1165
|
+
remove: "Delete cell",
|
|
1166
|
+
clearOutput: "Clear output",
|
|
1167
|
+
toggleSource: "Collapse cell",
|
|
1168
|
+
noOutput: "No output",
|
|
1169
|
+
error: "Cell error"
|
|
1170
|
+
};
|
|
1171
|
+
/** Buttons must not hand the mousedown to CodeMirror's selection handling. */
|
|
1172
|
+
function actionButton(icon, label, onClick, className = "") {
|
|
1173
|
+
const button = document.createElement("button");
|
|
1174
|
+
button.type = "button";
|
|
1175
|
+
button.className = `tb-cell-btn ${className}`.trim();
|
|
1176
|
+
button.title = label;
|
|
1177
|
+
button.setAttribute("aria-label", label);
|
|
1178
|
+
button.append(iconSpan(icon));
|
|
1179
|
+
button.addEventListener("mousedown", (event) => event.preventDefault());
|
|
1180
|
+
button.addEventListener("click", (event) => {
|
|
1181
|
+
event.preventDefault();
|
|
1182
|
+
event.stopPropagation();
|
|
1183
|
+
onClick(event);
|
|
1184
|
+
});
|
|
1185
|
+
return button;
|
|
1186
|
+
}
|
|
1187
|
+
var NotebookHeaderWidget = class extends WidgetType {
|
|
1188
|
+
view;
|
|
1189
|
+
args;
|
|
1190
|
+
constructor(view, args) {
|
|
1191
|
+
super();
|
|
1192
|
+
this.view = view;
|
|
1193
|
+
this.args = args;
|
|
1194
|
+
}
|
|
1195
|
+
eq(other) {
|
|
1196
|
+
const a = this.args;
|
|
1197
|
+
const b = other.args;
|
|
1198
|
+
return a.index === b.index && a.kind === b.kind && a.counter === b.counter && a.count === b.count && a.collapsed === b.collapsed && a.labels === b.labels;
|
|
1199
|
+
}
|
|
1200
|
+
toDOM() {
|
|
1201
|
+
const { index, kind, labels } = this.args;
|
|
1202
|
+
const header = document.createElement("div");
|
|
1203
|
+
header.className = "tb-cell-header";
|
|
1204
|
+
header.dataset.kind = kind;
|
|
1205
|
+
header.dataset.cell = String(index);
|
|
1206
|
+
const run = actionButton("play_arrow", labels.run, () => runCell(this.view, index));
|
|
1207
|
+
run.classList.add("tb-cell-btn--run");
|
|
1208
|
+
header.append(run);
|
|
1209
|
+
if (this.args.counter) {
|
|
1210
|
+
const chip = document.createElement("span");
|
|
1211
|
+
chip.className = "tb-cell-counter";
|
|
1212
|
+
chip.textContent = this.args.count === void 0 ? "[ ]" : `[${this.args.count}]`;
|
|
1213
|
+
header.append(chip);
|
|
1214
|
+
}
|
|
1215
|
+
header.append(actionButton(kind === "code" ? "code" : "notes", kind === "code" ? labels.code : labels.markup, () => toggleCellType(this.view, index), "tb-cell-btn--type"));
|
|
1216
|
+
const spacer = document.createElement("span");
|
|
1217
|
+
spacer.className = "tb-cell-spacer";
|
|
1218
|
+
header.append(spacer);
|
|
1219
|
+
const actions = document.createElement("span");
|
|
1220
|
+
actions.className = "tb-cell-actions";
|
|
1221
|
+
actions.append(actionButton("arrow_upward", labels.moveUp, () => moveCellAt(this.view, index, -1)), actionButton("arrow_downward", labels.moveDown, () => moveCellAt(this.view, index, 1)), actionButton("content_copy", labels.duplicate, () => duplicateCellAt(this.view, index)), actionButton(this.args.collapsed ? "unfold_more" : "unfold_less", labels.toggleSource, () => {
|
|
1222
|
+
notebookOptions(this.view.state)?.onToggleCollapse?.(index);
|
|
1223
|
+
this.view.dispatch({ effects: notebookRefreshEffect.of(null) });
|
|
1224
|
+
}), actionButton("delete_sweep", labels.clearOutput, () => {
|
|
1225
|
+
notebookOptions(this.view.state)?.onClearOutput?.(index);
|
|
1226
|
+
this.view.dispatch({ effects: notebookRefreshEffect.of(null) });
|
|
1227
|
+
}), actionButton("delete", labels.remove, () => deleteCellAt(this.view, index)));
|
|
1228
|
+
header.append(actions);
|
|
1229
|
+
header.addEventListener("mousedown", (event) => {
|
|
1230
|
+
if (event.target?.closest("button")) return;
|
|
1231
|
+
event.preventDefault();
|
|
1232
|
+
focusCell(this.view, index);
|
|
1233
|
+
});
|
|
1234
|
+
return header;
|
|
1235
|
+
}
|
|
1236
|
+
get estimatedHeight() {
|
|
1237
|
+
return 24;
|
|
1238
|
+
}
|
|
1239
|
+
};
|
|
1240
|
+
var NotebookOutputWidget = class extends WidgetType {
|
|
1241
|
+
view;
|
|
1242
|
+
fileId;
|
|
1243
|
+
typstState;
|
|
1244
|
+
args;
|
|
1245
|
+
openExternal;
|
|
1246
|
+
constructor(view, fileId, typstState, args, openExternal) {
|
|
1247
|
+
super();
|
|
1248
|
+
this.view = view;
|
|
1249
|
+
this.fileId = fileId;
|
|
1250
|
+
this.typstState = typstState;
|
|
1251
|
+
this.args = args;
|
|
1252
|
+
this.openExternal = openExternal;
|
|
1253
|
+
}
|
|
1254
|
+
/** Hash of everything the DOM depends on; DOM identity is expensive. */
|
|
1255
|
+
get key() {
|
|
1256
|
+
const frames = this.args.frames.map((frame) => {
|
|
1257
|
+
const size = frameSize(frame);
|
|
1258
|
+
return `${frame.render.hash}:${Math.round(size.width)}x${Math.round(size.height)}`;
|
|
1259
|
+
}).join(",");
|
|
1260
|
+
const diagnostics = this.args.diagnostics.map((diagnostic) => diagnostic.message).join("|");
|
|
1261
|
+
return `${this.args.index}:${this.args.cleared}:${this.args.hasRun}:${frames}:${diagnostics}`;
|
|
1262
|
+
}
|
|
1263
|
+
eq(other) {
|
|
1264
|
+
return this.key === other.key;
|
|
1265
|
+
}
|
|
1266
|
+
toDOM() {
|
|
1267
|
+
const root = document.createElement("div");
|
|
1268
|
+
const { cleared, labels } = this.args;
|
|
1269
|
+
root.className = "tb-cell-output";
|
|
1270
|
+
if (cleared) {
|
|
1271
|
+
root.classList.add("tb-cell-output--cleared");
|
|
1272
|
+
return root;
|
|
1273
|
+
}
|
|
1274
|
+
if (!(this.args.frames.length > 0 || this.args.diagnostics.length > 0 || this.args.hasRun)) {
|
|
1275
|
+
root.classList.add("tb-cell-output--empty");
|
|
1276
|
+
return root;
|
|
1277
|
+
}
|
|
1278
|
+
const body = document.createElement("div");
|
|
1279
|
+
body.className = "tb-cell-output-body";
|
|
1280
|
+
for (const diagnostic of this.args.diagnostics) {
|
|
1281
|
+
const item = document.createElement("div");
|
|
1282
|
+
item.className = `tb-cell-diagnostic tb-cell-diagnostic--${diagnostic.severity}`;
|
|
1283
|
+
item.textContent = diagnostic.message;
|
|
1284
|
+
if (diagnostic.hints.length) {
|
|
1285
|
+
const hints = document.createElement("ul");
|
|
1286
|
+
for (const hint of diagnostic.hints) {
|
|
1287
|
+
const entry = document.createElement("li");
|
|
1288
|
+
entry.textContent = hint;
|
|
1289
|
+
hints.append(entry);
|
|
1290
|
+
}
|
|
1291
|
+
item.append(hints);
|
|
1292
|
+
}
|
|
1293
|
+
body.append(item);
|
|
1294
|
+
}
|
|
1295
|
+
for (const frame of this.args.frames) {
|
|
1296
|
+
const holder = document.createElement("div");
|
|
1297
|
+
holder.className = "tb-cell-frame";
|
|
1298
|
+
const container = createFrameContainer(frame);
|
|
1299
|
+
attachFrameInteractions(container, this.view, frame, this.fileId, this.typstState, this.openExternal);
|
|
1300
|
+
holder.append(container);
|
|
1301
|
+
body.append(holder);
|
|
1302
|
+
}
|
|
1303
|
+
if (!this.args.frames.length && !this.args.diagnostics.length && this.args.hasRun) {
|
|
1304
|
+
const empty = document.createElement("div");
|
|
1305
|
+
empty.className = "tb-cell-empty";
|
|
1306
|
+
empty.textContent = labels.noOutput;
|
|
1307
|
+
body.append(empty);
|
|
1308
|
+
}
|
|
1309
|
+
root.append(body);
|
|
1310
|
+
return root;
|
|
1311
|
+
}
|
|
1312
|
+
get estimatedHeight() {
|
|
1313
|
+
let height = 0;
|
|
1314
|
+
for (const frame of this.args.frames) height += frameSize(frame).height;
|
|
1315
|
+
return height + 16;
|
|
1316
|
+
}
|
|
1317
|
+
};
|
|
1318
|
+
function decorateNotebook(args) {
|
|
1319
|
+
const { view, state, cells, frames, diagnostics, fileId, typstState, locked, onExternalLink, options } = args;
|
|
1320
|
+
const decorations = [];
|
|
1321
|
+
const byCell = cells.map(() => []);
|
|
1322
|
+
for (const frame of frames) {
|
|
1323
|
+
const index = cellIndexAt(cells, frame.range.start);
|
|
1324
|
+
if (index >= 0) byCell[index].push(frame);
|
|
1325
|
+
}
|
|
1326
|
+
const counter = options.counters?.() ?? false;
|
|
1327
|
+
cells.forEach((cell, index) => {
|
|
1328
|
+
const cellState = options.state?.(index) ?? {};
|
|
1329
|
+
const headerPos = state.doc.lineAt(cellStart(cell)).from;
|
|
1330
|
+
decorations.push(Decoration.widget({
|
|
1331
|
+
widget: new NotebookHeaderWidget(view, {
|
|
1332
|
+
index,
|
|
1333
|
+
kind: cell.kind,
|
|
1334
|
+
counter,
|
|
1335
|
+
count: cellState.count,
|
|
1336
|
+
collapsed: cellState.collapsed ?? false,
|
|
1337
|
+
labels: options.labels ?? FALLBACK_LABELS
|
|
1338
|
+
}),
|
|
1339
|
+
side: -1
|
|
1340
|
+
}).range(headerPos));
|
|
1341
|
+
if (hasMarker(cell)) decorations.push(Decoration.line({ class: "tb-cell-marker" }).range(state.doc.lineAt(cell.marker_start).from));
|
|
1342
|
+
if (cellState.collapsed && cell.content_end > cell.content_start) decorations.push(Decoration.replace({}).range(cell.content_start, cell.content_end));
|
|
1343
|
+
if (cell.kind === "markup") {
|
|
1344
|
+
if (cellState.collapsed) return;
|
|
1345
|
+
for (const frame of byCell[index] ?? []) {
|
|
1346
|
+
const { start, end } = frame.range;
|
|
1347
|
+
if (!frame.render || end <= start) continue;
|
|
1348
|
+
if (frameIsInactive(view, state, start, end)) decorations.push(Decoration.replace({ widget: new TypstWidget(view, frame, locked, fileId, typstState, onExternalLink) }).range(start, end));
|
|
1349
|
+
else decorations.push(...frameActiveDecorations(view, state, frame));
|
|
1350
|
+
}
|
|
1351
|
+
return;
|
|
1352
|
+
}
|
|
1353
|
+
const outputPos = cell.content_end > cell.content_start ? state.doc.lineAt(cell.content_end).to : cell.content_start;
|
|
1354
|
+
const cellDiagnostics = diagnostics.filter((diagnostic) => diagnostic.range.start >= cell.content_start && diagnostic.range.start <= cell.content_end);
|
|
1355
|
+
decorations.push(Decoration.widget({
|
|
1356
|
+
widget: new NotebookOutputWidget(view, fileId, typstState, {
|
|
1357
|
+
index,
|
|
1358
|
+
frames: byCell[index] ?? [],
|
|
1359
|
+
diagnostics: cellDiagnostics,
|
|
1360
|
+
cleared: cellState.cleared ?? false,
|
|
1361
|
+
hasRun: cellState.count !== void 0,
|
|
1362
|
+
labels: options.labels ?? FALLBACK_LABELS
|
|
1363
|
+
}, onExternalLink),
|
|
1364
|
+
block: true,
|
|
1365
|
+
side: 1
|
|
1366
|
+
}).range(outputPos));
|
|
1367
|
+
});
|
|
1368
|
+
return decorations;
|
|
1369
|
+
}
|
|
1370
|
+
//#endregion
|
|
1371
|
+
//#region src/widgets.ts
|
|
1372
|
+
const compileCache = new LRUCache({ max: 8 });
|
|
1373
|
+
const updateFlagStore = /* @__PURE__ */ new Set();
|
|
1374
|
+
/**
|
|
1375
|
+
* Dispatch this to recompile even when the document did not change. Used when
|
|
1376
|
+
* app data changed (fonts installed, workspace data, query results).
|
|
1377
|
+
*/
|
|
1378
|
+
const typstRecompileEffect = StateEffect.define({});
|
|
1379
|
+
/** Compile cache keys include the wasm revision so new fonts re-render. */
|
|
1380
|
+
function compileCacheKey(path, typstState, revision) {
|
|
1381
|
+
return `${path}:${typstState.revision()}:${revision?.() ?? ""}`;
|
|
1382
|
+
}
|
|
1383
|
+
const SCROLLBAR_ALLOWANCE = 12;
|
|
1384
|
+
function editorRenderWidth(scrollDOM, contentDOM) {
|
|
1385
|
+
const pane = scrollDOM.getBoundingClientRect();
|
|
1386
|
+
const gutters = scrollDOM.querySelector(".cm-gutters");
|
|
1387
|
+
const style = getComputedStyle(contentDOM);
|
|
1388
|
+
return Math.max(0, pane.width - (gutters?.getBoundingClientRect().width ?? 0) - parseFloat(style.paddingLeft) - parseFloat(style.paddingRight) - SCROLLBAR_ALLOWANCE);
|
|
1389
|
+
}
|
|
1390
|
+
const tooltipsStateEffect = StateEffect.define();
|
|
1391
|
+
const tooltipsStateField = StateField.define({
|
|
1392
|
+
create() {
|
|
1393
|
+
return [];
|
|
1394
|
+
},
|
|
1395
|
+
update(frames, transaction) {
|
|
1396
|
+
const effect = transaction.effects.find((e) => e.is(tooltipsStateEffect));
|
|
1397
|
+
return effect ? effect.value : frames;
|
|
1398
|
+
}
|
|
1399
|
+
});
|
|
1400
|
+
/**
|
|
1401
|
+
* Builds the decoration set for a compile result: rendered blocks become
|
|
1402
|
+
* replace widgets, the block the cursor is in shows its source and stretches
|
|
1403
|
+
* its last line to the render's height. Returns the frames that show source,
|
|
1404
|
+
* for the remeasure pass.
|
|
1405
|
+
*/
|
|
1406
|
+
function buildDecorations$1({ state, view, frames, locked, fileId, typstState, openExternal, lineHeights }) {
|
|
1407
|
+
const decorations = [];
|
|
1408
|
+
const active = [];
|
|
1409
|
+
for (const frame of frames) {
|
|
1410
|
+
const { start, end } = frame.range;
|
|
1411
|
+
if (!frame.render) continue;
|
|
1412
|
+
if (frameIsInactive(view, state, start, end)) {
|
|
1413
|
+
const widget = new TypstWidget(view, frame, locked, fileId, typstState, openExternal);
|
|
1414
|
+
decorations.push(Decoration.replace({ widget }).range(start, end));
|
|
1415
|
+
continue;
|
|
1416
|
+
}
|
|
1417
|
+
active.push(frame);
|
|
1418
|
+
decorations.push(...frameActiveDecorations(view, state, frame, lineHeights));
|
|
1419
|
+
}
|
|
1420
|
+
return {
|
|
1421
|
+
decorations: Decoration.set(decorations, true),
|
|
1422
|
+
active
|
|
1423
|
+
};
|
|
1424
|
+
}
|
|
1425
|
+
/**
|
|
1426
|
+
* Source line heights for the blocks whose widgets were just removed. Runs
|
|
1427
|
+
* inside the view's measure pass, after the source lines are in the DOM, so
|
|
1428
|
+
* the heights are the text's and not the widget's.
|
|
1429
|
+
*/
|
|
1430
|
+
function measureLineHeights(view, frames) {
|
|
1431
|
+
const heights = /* @__PURE__ */ new Map();
|
|
1432
|
+
const { state } = view;
|
|
1433
|
+
for (const frame of frames) {
|
|
1434
|
+
const { number: startLine } = state.doc.lineAt(frame.range.start);
|
|
1435
|
+
const { number: endLine } = state.doc.lineAt(frame.range.end);
|
|
1436
|
+
for (let currentLine = startLine; currentLine <= endLine; currentLine++) {
|
|
1437
|
+
const line = state.doc.line(currentLine);
|
|
1438
|
+
if (!heights.has(line.from)) heights.set(line.from, view.lineBlockAt(line.from).height);
|
|
1439
|
+
}
|
|
1440
|
+
}
|
|
1441
|
+
return heights;
|
|
1442
|
+
}
|
|
1443
|
+
function decorate({ fileId, spaceId, path, prelude, locked, update, updateInWidget: _updateInWidget, widthChanged, forced, typstState, revision, onRequests, onPanic, onCompile, onExternalLink, notebook }) {
|
|
1444
|
+
const text = update.state.doc.toString();
|
|
1445
|
+
const isFlaggedForUpdate = updateFlagStore.has(path);
|
|
1446
|
+
const cacheKey = compileCacheKey(path, typstState, revision);
|
|
1447
|
+
let frames;
|
|
1448
|
+
let tooltips;
|
|
1449
|
+
let diagnostics;
|
|
1450
|
+
if (forced || update.docChanged || widthChanged || !compileCache.has(cacheKey)) {
|
|
1451
|
+
if (isFlaggedForUpdate) updateFlagStore.delete(path);
|
|
1452
|
+
else updateFlagStore.add(path);
|
|
1453
|
+
let compileResult;
|
|
1454
|
+
try {
|
|
1455
|
+
compileResult = typstState.compilePaged(fileId, text, prelude);
|
|
1456
|
+
} catch (error) {
|
|
1457
|
+
console.error("[typst] compile panicked:", error);
|
|
1458
|
+
onPanic?.(fileId);
|
|
1459
|
+
return {
|
|
1460
|
+
decorations: Decoration.none,
|
|
1461
|
+
tooltips: [],
|
|
1462
|
+
frames: [],
|
|
1463
|
+
active: []
|
|
1464
|
+
};
|
|
1465
|
+
}
|
|
1466
|
+
dispatchDiagnostics(compileResult.diagnostics, update.state, update.view);
|
|
1467
|
+
rememberDiagnostics(path, text, compileResult.diagnostics);
|
|
1468
|
+
onCompile?.();
|
|
1469
|
+
if (compileResult.requests.length > 0 && onRequests) Promise.resolve(onRequests(compileResult.requests, spaceId)).then((wasUpdated) => {
|
|
1470
|
+
if (wasUpdated) {
|
|
1471
|
+
const doc = update.view.state.doc.toString();
|
|
1472
|
+
update.view.dispatch({
|
|
1473
|
+
changes: {
|
|
1474
|
+
from: 0,
|
|
1475
|
+
to: doc.length,
|
|
1476
|
+
insert: doc
|
|
1477
|
+
},
|
|
1478
|
+
effects: typstRecompileEffect.of(null)
|
|
1479
|
+
});
|
|
1480
|
+
}
|
|
1481
|
+
});
|
|
1482
|
+
({frames, tooltips} = compileResult);
|
|
1483
|
+
diagnostics = compileResult.diagnostics;
|
|
1484
|
+
compileCache.set(cacheKey, {
|
|
1485
|
+
frames,
|
|
1486
|
+
tooltips,
|
|
1487
|
+
diagnostics
|
|
1488
|
+
});
|
|
1489
|
+
notebook?.onCompile?.({
|
|
1490
|
+
text,
|
|
1491
|
+
frames,
|
|
1492
|
+
diagnostics
|
|
1493
|
+
});
|
|
1494
|
+
} else {
|
|
1495
|
+
const cached = compileCache.get(cacheKey);
|
|
1496
|
+
({frames, tooltips, diagnostics} = cached);
|
|
1497
|
+
}
|
|
1498
|
+
const { view, state } = update;
|
|
1499
|
+
if (notebook) {
|
|
1500
|
+
let cells;
|
|
1501
|
+
try {
|
|
1502
|
+
cells = notebook.cells(text);
|
|
1503
|
+
} catch (error) {
|
|
1504
|
+
console.error("[typst] cell extraction panicked:", error);
|
|
1505
|
+
onPanic?.(fileId);
|
|
1506
|
+
return {
|
|
1507
|
+
decorations: Decoration.none,
|
|
1508
|
+
tooltips: [],
|
|
1509
|
+
frames: [],
|
|
1510
|
+
active: []
|
|
1511
|
+
};
|
|
1512
|
+
}
|
|
1513
|
+
notebook.onCells?.(cells);
|
|
1514
|
+
notebook.onActiveCell?.(cells.length ? cellIndexAt(cells, state.selection.main.head) : null);
|
|
1515
|
+
return {
|
|
1516
|
+
decorations: Decoration.set(decorateNotebook({
|
|
1517
|
+
view,
|
|
1518
|
+
state,
|
|
1519
|
+
cells,
|
|
1520
|
+
frames,
|
|
1521
|
+
diagnostics,
|
|
1522
|
+
fileId,
|
|
1523
|
+
typstState,
|
|
1524
|
+
locked,
|
|
1525
|
+
onExternalLink,
|
|
1526
|
+
options: notebook
|
|
1527
|
+
}), true),
|
|
1528
|
+
tooltips,
|
|
1529
|
+
frames,
|
|
1530
|
+
active: []
|
|
1531
|
+
};
|
|
1532
|
+
}
|
|
1533
|
+
const built = buildDecorations$1({
|
|
1534
|
+
state,
|
|
1535
|
+
view,
|
|
1536
|
+
frames,
|
|
1537
|
+
locked,
|
|
1538
|
+
fileId,
|
|
1539
|
+
typstState,
|
|
1540
|
+
openExternal: onExternalLink
|
|
1541
|
+
});
|
|
1542
|
+
return {
|
|
1543
|
+
decorations: built.decorations,
|
|
1544
|
+
tooltips,
|
|
1545
|
+
frames,
|
|
1546
|
+
active: built.active
|
|
1547
|
+
};
|
|
1548
|
+
}
|
|
1549
|
+
const typstStateEffect = StateEffect.define({});
|
|
1550
|
+
const typstStateField = StateField.define({
|
|
1551
|
+
create() {
|
|
1552
|
+
return Decoration.none;
|
|
1553
|
+
},
|
|
1554
|
+
update(decorations, transaction) {
|
|
1555
|
+
const effect = transaction.effects.find((e) => e.is(typstStateEffect));
|
|
1556
|
+
if (effect) return effect.value.decorations;
|
|
1557
|
+
return decorations.map(transaction.changes);
|
|
1558
|
+
},
|
|
1559
|
+
provide: (field) => [EditorView.decorations.from(field)]
|
|
1560
|
+
});
|
|
1561
|
+
const typstViewPlugin = (fileId, spaceId, path, text, prelude, locked, typstState, options = {}) => ViewPlugin.define((_view) => {
|
|
1562
|
+
let firstUpdate = true;
|
|
1563
|
+
let resizeTimer;
|
|
1564
|
+
const remeasureKey = {};
|
|
1565
|
+
/**
|
|
1566
|
+
* A pass that shows a block's source runs while the block's widget is still
|
|
1567
|
+
* mounted, so the view reports the widget's height for those lines and the
|
|
1568
|
+
* min-height that should keep the render's height comes out as zero. Once
|
|
1569
|
+
* the source lines are in the DOM and measured, rebuild the active
|
|
1570
|
+
* decorations with the real text heights. The measure pass runs before
|
|
1571
|
+
* paint, so the rebuild is not visible.
|
|
1572
|
+
*/
|
|
1573
|
+
const remeasureActive = (update, result) => {
|
|
1574
|
+
if (result.active.length === 0) return;
|
|
1575
|
+
const { view, state } = update;
|
|
1576
|
+
const focused = view.hasFocus;
|
|
1577
|
+
const stale = () => view.state.doc !== state.doc || !view.state.selection.eq(state.selection) || view.hasFocus !== focused;
|
|
1578
|
+
view.requestMeasure({
|
|
1579
|
+
key: remeasureKey,
|
|
1580
|
+
read: (readView) => stale() ? null : measureLineHeights(readView, result.active),
|
|
1581
|
+
write: (heights) => {
|
|
1582
|
+
if (!heights) return;
|
|
1583
|
+
queueMicrotask(() => {
|
|
1584
|
+
if (stale()) return;
|
|
1585
|
+
const rebuilt = buildDecorations$1({
|
|
1586
|
+
state: view.state,
|
|
1587
|
+
view,
|
|
1588
|
+
frames: result.frames,
|
|
1589
|
+
locked,
|
|
1590
|
+
fileId,
|
|
1591
|
+
typstState,
|
|
1592
|
+
openExternal: options.onExternalLink,
|
|
1593
|
+
lineHeights: heights
|
|
1594
|
+
});
|
|
1595
|
+
view.dispatch({ effects: typstStateEffect.of({ decorations: rebuilt.decorations }) });
|
|
1596
|
+
});
|
|
1597
|
+
}
|
|
1598
|
+
});
|
|
1599
|
+
};
|
|
1600
|
+
return {
|
|
1601
|
+
update(update) {
|
|
1602
|
+
let widthChanged = false;
|
|
1603
|
+
const effects = update.transactions.flatMap((transaction) => transaction.effects);
|
|
1604
|
+
const run = effects.find((effect) => effect.is(notebookRunEffect));
|
|
1605
|
+
const refresh = effects.some((effect) => effect.is(notebookRefreshEffect));
|
|
1606
|
+
const forced = firstUpdate || update.transactions.some((transaction) => transaction.effects.some((effect) => effect.is(typstRecompileEffect)));
|
|
1607
|
+
firstUpdate = false;
|
|
1608
|
+
if (run) options.notebook?.onRun?.(run.value.index);
|
|
1609
|
+
if (update.geometryChanged) {
|
|
1610
|
+
const { scrollDOM, contentDOM } = update.view;
|
|
1611
|
+
try {
|
|
1612
|
+
widthChanged = typstState.resize(fileId, editorRenderWidth(scrollDOM, contentDOM), locked ? scrollDOM.clientHeight : void 0);
|
|
1613
|
+
} catch (error) {
|
|
1614
|
+
console.error("[typst] resize panicked:", error);
|
|
1615
|
+
options.onPanic?.(fileId);
|
|
1616
|
+
return;
|
|
1617
|
+
}
|
|
1618
|
+
}
|
|
1619
|
+
if (widthChanged) {
|
|
1620
|
+
clearTimeout(resizeTimer);
|
|
1621
|
+
resizeTimer = window.setTimeout(() => {
|
|
1622
|
+
update.view.dispatch({ effects: typstRecompileEffect.of(null) });
|
|
1623
|
+
}, 150);
|
|
1624
|
+
}
|
|
1625
|
+
if (update.docChanged || update.selectionSet || update.focusChanged || forced || run || refresh) {
|
|
1626
|
+
queueMicrotask(() => {
|
|
1627
|
+
let result;
|
|
1628
|
+
try {
|
|
1629
|
+
result = decorate({
|
|
1630
|
+
fileId,
|
|
1631
|
+
spaceId,
|
|
1632
|
+
path,
|
|
1633
|
+
prelude: prelude.value,
|
|
1634
|
+
locked,
|
|
1635
|
+
update,
|
|
1636
|
+
updateInWidget: false,
|
|
1637
|
+
widthChanged,
|
|
1638
|
+
forced: forced || Boolean(run),
|
|
1639
|
+
typstState,
|
|
1640
|
+
revision: options.revision,
|
|
1641
|
+
onRequests: options.onRequests,
|
|
1642
|
+
onPanic: options.onPanic,
|
|
1643
|
+
onCompile: options.onCompile,
|
|
1644
|
+
onExternalLink: options.onExternalLink,
|
|
1645
|
+
notebook: options.notebook
|
|
1646
|
+
});
|
|
1647
|
+
} catch (error) {
|
|
1648
|
+
console.error("[typst] decorate panicked:", error);
|
|
1649
|
+
options.onPanic?.(fileId);
|
|
1650
|
+
return;
|
|
1651
|
+
}
|
|
1652
|
+
if (result) {
|
|
1653
|
+
const stateEffects = [typstStateEffect.of({ decorations: result.decorations }), tooltipsStateEffect.of(result.tooltips)];
|
|
1654
|
+
update.view.dispatch({ effects: stateEffects });
|
|
1655
|
+
remeasureActive(update, result);
|
|
1656
|
+
}
|
|
1657
|
+
});
|
|
1658
|
+
if (update.docChanged) text.value = update.state.doc.toString();
|
|
1659
|
+
}
|
|
1660
|
+
},
|
|
1661
|
+
destroy() {
|
|
1662
|
+
clearTimeout(resizeTimer);
|
|
1663
|
+
}
|
|
1664
|
+
};
|
|
1665
|
+
});
|
|
1666
|
+
function dispatchDiagnostics(typstDiagnostics, state, view) {
|
|
1667
|
+
view.dispatch(setDiagnostics(state, toLintDiagnostics(typstDiagnostics)));
|
|
1668
|
+
}
|
|
1669
|
+
//#endregion
|
|
1670
|
+
//#region src/tooltip.ts
|
|
1671
|
+
const tooltipStateEffect = StateEffect.define();
|
|
1672
|
+
const tooltipStateField = StateField.define({
|
|
1673
|
+
create() {
|
|
1674
|
+
return null;
|
|
1675
|
+
},
|
|
1676
|
+
update(tooltip, transaction) {
|
|
1677
|
+
const effect = transaction.effects.find((e) => e.is(tooltipStateEffect));
|
|
1678
|
+
if (effect) return effect.value;
|
|
1679
|
+
return tooltip;
|
|
1680
|
+
},
|
|
1681
|
+
provide: (field) => showTooltip.from(field)
|
|
1682
|
+
});
|
|
1683
|
+
const tooltipViewPlugin = () => ViewPlugin.define((view) => ({ update(update) {
|
|
1684
|
+
if (!update.selectionSet && !update.docChanged && !update.focusChanged) return;
|
|
1685
|
+
queueMicrotask(() => {
|
|
1686
|
+
if (!view.hasFocus) {
|
|
1687
|
+
view.dispatch({ effects: tooltipStateEffect.of(null) });
|
|
1688
|
+
return;
|
|
1689
|
+
}
|
|
1690
|
+
const state = view.state;
|
|
1691
|
+
const pos = state.selection.main.from;
|
|
1692
|
+
const tooltips = state.field(tooltipsStateField);
|
|
1693
|
+
let tooltip = null;
|
|
1694
|
+
for (const { render, range } of tooltips) {
|
|
1695
|
+
const { start, end } = range;
|
|
1696
|
+
if (pos >= start && pos <= end && render) {
|
|
1697
|
+
const container = document.createElement("div");
|
|
1698
|
+
container.classList.add("typst-popup-render");
|
|
1699
|
+
const svg = document.createElement("div");
|
|
1700
|
+
svg.style.width = render.width + "px";
|
|
1701
|
+
svg.style.height = render.height + "px";
|
|
1702
|
+
svg.style.transform = `translateX(-${render.xOffset}px)`;
|
|
1703
|
+
svg.setHTMLUnsafe(render.svg);
|
|
1704
|
+
container.append(svg);
|
|
1705
|
+
tooltip = {
|
|
1706
|
+
pos: start + 1,
|
|
1707
|
+
end: end - 1,
|
|
1708
|
+
create(tooltipView) {
|
|
1709
|
+
return {
|
|
1710
|
+
dom: container,
|
|
1711
|
+
getCoords(_pos) {
|
|
1712
|
+
const startCoords = tooltipView.coordsAtPos(start);
|
|
1713
|
+
const endCoords = tooltipView.coordsAtPos(end);
|
|
1714
|
+
return {
|
|
1715
|
+
left: startCoords.left,
|
|
1716
|
+
right: startCoords.right,
|
|
1717
|
+
top: endCoords.top,
|
|
1718
|
+
bottom: endCoords.bottom
|
|
1719
|
+
};
|
|
1720
|
+
}
|
|
1721
|
+
};
|
|
1722
|
+
}
|
|
1723
|
+
};
|
|
1724
|
+
break;
|
|
1725
|
+
}
|
|
1726
|
+
}
|
|
1727
|
+
view.dispatch({ effects: tooltipStateEffect.of(tooltip) });
|
|
1728
|
+
});
|
|
1729
|
+
} }));
|
|
1730
|
+
//#endregion
|
|
1731
|
+
//#region src/plugin.ts
|
|
1732
|
+
const typstPlugin = (fileId, spaceId, path, text, prelude, locked, typstState, options = {}) => {
|
|
1733
|
+
const extensions = [
|
|
1734
|
+
typstStateField,
|
|
1735
|
+
tooltipsStateField,
|
|
1736
|
+
typstViewPlugin(fileId, spaceId, path, text, prelude, locked, typstState, options),
|
|
1737
|
+
tooltipViewPlugin(),
|
|
1738
|
+
tooltipStateField,
|
|
1739
|
+
autocompletion({ override: [(context) => autocomplete(context, fileId, typstState)] }),
|
|
1740
|
+
typstKeymap,
|
|
1741
|
+
typstLanguageData,
|
|
1742
|
+
typstSyntaxHighlighting(fileId, typstState),
|
|
1743
|
+
typstHoverTooltip(fileId, typstState),
|
|
1744
|
+
addSpaceBeforeClosingBracket,
|
|
1745
|
+
indentService.of((ctx, pos) => {
|
|
1746
|
+
const last = Math.max(0, pos - 1);
|
|
1747
|
+
const prev = ctx.lineAt(last).text;
|
|
1748
|
+
if (prev.endsWith("$") && prev !== "$") return 0;
|
|
1749
|
+
const indent = /[{[($]\s*$/.test(prev);
|
|
1750
|
+
return ctx.lineIndent(last) + (indent ? ctx.unit : 0);
|
|
1751
|
+
})
|
|
1752
|
+
];
|
|
1753
|
+
if (options.notebook) extensions.push(notebookOptionsFacet.of(options.notebook), notebookKeymap);
|
|
1754
|
+
return extensions;
|
|
1755
|
+
};
|
|
1756
|
+
const addSpaceBeforeClosingBracket = EditorView.inputHandler.of((view, from, to, text) => {
|
|
1757
|
+
if (text === " ") {
|
|
1758
|
+
const { state } = view;
|
|
1759
|
+
const pos = from;
|
|
1760
|
+
const bracketPairs = {
|
|
1761
|
+
"(": ")",
|
|
1762
|
+
"[": "]",
|
|
1763
|
+
"{": "}",
|
|
1764
|
+
$: "$"
|
|
1765
|
+
};
|
|
1766
|
+
const before = state.doc.sliceString(pos - 1, pos);
|
|
1767
|
+
const after = state.doc.sliceString(pos, pos + 1);
|
|
1768
|
+
if (bracketPairs[before] && after === bracketPairs[before]) view.dispatch({
|
|
1769
|
+
changes: {
|
|
1770
|
+
from: pos,
|
|
1771
|
+
to: pos,
|
|
1772
|
+
insert: " "
|
|
1773
|
+
},
|
|
1774
|
+
selection: { anchor: pos + 1 }
|
|
1775
|
+
});
|
|
1776
|
+
}
|
|
1777
|
+
return false;
|
|
1778
|
+
});
|
|
1779
|
+
//#endregion
|
|
1780
|
+
//#region src/static-highlight.ts
|
|
1781
|
+
const KEYWORDS = /* @__PURE__ */ new Set([
|
|
1782
|
+
"and",
|
|
1783
|
+
"as",
|
|
1784
|
+
"auto",
|
|
1785
|
+
"break",
|
|
1786
|
+
"context",
|
|
1787
|
+
"continue",
|
|
1788
|
+
"else",
|
|
1789
|
+
"false",
|
|
1790
|
+
"for",
|
|
1791
|
+
"if",
|
|
1792
|
+
"import",
|
|
1793
|
+
"in",
|
|
1794
|
+
"include",
|
|
1795
|
+
"let",
|
|
1796
|
+
"none",
|
|
1797
|
+
"not",
|
|
1798
|
+
"or",
|
|
1799
|
+
"return",
|
|
1800
|
+
"set",
|
|
1801
|
+
"show",
|
|
1802
|
+
"true",
|
|
1803
|
+
"while"
|
|
1804
|
+
]);
|
|
1805
|
+
const IDENT = /^[A-Za-z_][\w-]*/;
|
|
1806
|
+
/** Math identifiers cannot start with `_`, which is the attach operator. */
|
|
1807
|
+
const MATH_IDENT = /^[A-Za-z][A-Za-z0-9-]*/;
|
|
1808
|
+
function classForIdent(ident, rest) {
|
|
1809
|
+
if (KEYWORDS.has(ident)) return "typ-key";
|
|
1810
|
+
if (/^\s*[([{]/.test(rest)) return "typ-func";
|
|
1811
|
+
return "typ-pol";
|
|
1812
|
+
}
|
|
1813
|
+
function runLength(text, from) {
|
|
1814
|
+
let end = from;
|
|
1815
|
+
while (end < text.length && text[end] === "`") end += 1;
|
|
1816
|
+
return end - from;
|
|
1817
|
+
}
|
|
1818
|
+
/** Start of the first backtick run of at least `fence` at or after `from`. */
|
|
1819
|
+
function findFence(text, fence, from) {
|
|
1820
|
+
let index = text.indexOf("`", from);
|
|
1821
|
+
while (index >= 0) {
|
|
1822
|
+
const run = runLength(text, index);
|
|
1823
|
+
if (run >= fence) return index;
|
|
1824
|
+
index = text.indexOf("`", index + run);
|
|
1825
|
+
}
|
|
1826
|
+
return -1;
|
|
1827
|
+
}
|
|
1828
|
+
const typstStaticHighlighting = StateField.define({
|
|
1829
|
+
create(state) {
|
|
1830
|
+
return buildDecorations(state.doc);
|
|
1831
|
+
},
|
|
1832
|
+
update(decorations, transaction) {
|
|
1833
|
+
if (transaction.docChanged) return buildDecorations(transaction.state.doc);
|
|
1834
|
+
return decorations;
|
|
1835
|
+
},
|
|
1836
|
+
provide: (field) => EditorView.decorations.from(field)
|
|
1837
|
+
});
|
|
1838
|
+
function buildDecorations(doc) {
|
|
1839
|
+
const builder = new RangeSetBuilder();
|
|
1840
|
+
const state = {
|
|
1841
|
+
mode: "markup",
|
|
1842
|
+
mathReturn: "markup",
|
|
1843
|
+
fence: 0,
|
|
1844
|
+
depth: 0
|
|
1845
|
+
};
|
|
1846
|
+
const add = (from, to, cls) => {
|
|
1847
|
+
if (to > from) builder.add(from, to, Decoration.mark({ class: cls }));
|
|
1848
|
+
};
|
|
1849
|
+
for (let lineNo = 1; lineNo <= doc.lines; lineNo++) {
|
|
1850
|
+
const line = doc.line(lineNo);
|
|
1851
|
+
scanLine(line.text, state, (from, to, cls) => add(line.from + from, line.from + to, cls));
|
|
1852
|
+
}
|
|
1853
|
+
return builder.finish();
|
|
1854
|
+
}
|
|
1855
|
+
function scanLine(line, state, emit) {
|
|
1856
|
+
let i = 0;
|
|
1857
|
+
if (state.mode === "comment") {
|
|
1858
|
+
const close = line.indexOf("*/");
|
|
1859
|
+
if (close < 0) {
|
|
1860
|
+
emit(0, line.length, "typ-comment");
|
|
1861
|
+
return;
|
|
1862
|
+
}
|
|
1863
|
+
emit(0, close + 2, "typ-comment");
|
|
1864
|
+
state.mode = "markup";
|
|
1865
|
+
i = close + 2;
|
|
1866
|
+
} else if (state.mode === "raw") {
|
|
1867
|
+
const close = findFence(line, state.fence, 0);
|
|
1868
|
+
if (close < 0) {
|
|
1869
|
+
emit(0, line.length, "typ-raw");
|
|
1870
|
+
return;
|
|
1871
|
+
}
|
|
1872
|
+
const run = runLength(line, close);
|
|
1873
|
+
emit(0, close + run, "typ-raw");
|
|
1874
|
+
state.mode = "markup";
|
|
1875
|
+
state.fence = 0;
|
|
1876
|
+
i = close + run;
|
|
1877
|
+
} else if (state.mode === "code" && state.depth === 0) state.mode = "markup";
|
|
1878
|
+
if (state.mode === "markup" && i === 0) i = markupLineStart(line, emit);
|
|
1879
|
+
while (i < line.length) if (state.mode === "markup") i = markupToken(line, i, state, emit);
|
|
1880
|
+
else if (state.mode === "code") i = codeToken(line, i, state, emit);
|
|
1881
|
+
else i = mathToken(line, i, state, emit);
|
|
1882
|
+
if (state.mode === "code" && state.depth === 0) state.mode = "markup";
|
|
1883
|
+
}
|
|
1884
|
+
/** Headings, list markers, and term text, which only count at line start. */
|
|
1885
|
+
function markupLineStart(line, emit) {
|
|
1886
|
+
const indent = /^\s*/.exec(line)?.[0].length ?? 0;
|
|
1887
|
+
const rest = line.slice(indent);
|
|
1888
|
+
const heading = /^(=+)(\s|$)/.exec(rest);
|
|
1889
|
+
if (heading) {
|
|
1890
|
+
const level = Math.min(heading[1].length, 6);
|
|
1891
|
+
emit(indent, line.length, `typ-heading typ-heading-level-${level}`);
|
|
1892
|
+
return line.length;
|
|
1893
|
+
}
|
|
1894
|
+
const marker = /^([-+/]|\d+\.)(\s|$)/.exec(rest);
|
|
1895
|
+
if (marker) {
|
|
1896
|
+
const end = indent + marker[1].length;
|
|
1897
|
+
emit(indent, end, "typ-marker");
|
|
1898
|
+
if (marker[1] === "/") {
|
|
1899
|
+
const colon = line.indexOf(":", end + 1);
|
|
1900
|
+
if (colon > 0) {
|
|
1901
|
+
emit(end + 1, colon, "typ-term");
|
|
1902
|
+
return colon + 1;
|
|
1903
|
+
}
|
|
1904
|
+
}
|
|
1905
|
+
return end;
|
|
1906
|
+
}
|
|
1907
|
+
return indent;
|
|
1908
|
+
}
|
|
1909
|
+
function markupToken(line, i, state, emit) {
|
|
1910
|
+
const rest = line.slice(i);
|
|
1911
|
+
const ch = line[i];
|
|
1912
|
+
if (rest.startsWith("//")) {
|
|
1913
|
+
emit(i, line.length, "typ-comment");
|
|
1914
|
+
return line.length;
|
|
1915
|
+
}
|
|
1916
|
+
if (rest.startsWith("/*")) {
|
|
1917
|
+
const close = line.indexOf("*/", i + 2);
|
|
1918
|
+
if (close < 0) {
|
|
1919
|
+
emit(i, line.length, "typ-comment");
|
|
1920
|
+
state.mode = "comment";
|
|
1921
|
+
return line.length;
|
|
1922
|
+
}
|
|
1923
|
+
emit(i, close + 2, "typ-comment");
|
|
1924
|
+
return close + 2;
|
|
1925
|
+
}
|
|
1926
|
+
if (ch === "`") {
|
|
1927
|
+
const run = runLength(line, i);
|
|
1928
|
+
const close = findFence(line, run, i + run);
|
|
1929
|
+
if (close < 0) {
|
|
1930
|
+
emit(i + run, line.length, "typ-raw");
|
|
1931
|
+
if (run >= 3) {
|
|
1932
|
+
state.mode = "raw";
|
|
1933
|
+
state.fence = run;
|
|
1934
|
+
}
|
|
1935
|
+
return line.length;
|
|
1936
|
+
}
|
|
1937
|
+
emit(i + run, close, "typ-raw");
|
|
1938
|
+
return close + runLength(line, close);
|
|
1939
|
+
}
|
|
1940
|
+
if (ch === "$") {
|
|
1941
|
+
emit(i, i + 1, "typ-math-delim");
|
|
1942
|
+
state.mode = "math";
|
|
1943
|
+
state.mathReturn = "markup";
|
|
1944
|
+
return i + 1;
|
|
1945
|
+
}
|
|
1946
|
+
if (ch === "#") {
|
|
1947
|
+
const ident = IDENT.exec(line.slice(i + 1));
|
|
1948
|
+
if (ident) {
|
|
1949
|
+
const end = i + 1 + ident[0].length;
|
|
1950
|
+
emit(i, end, classForIdent(ident[0], line.slice(end)));
|
|
1951
|
+
if (KEYWORDS.has(ident[0])) {
|
|
1952
|
+
state.mode = "code";
|
|
1953
|
+
state.depth = 0;
|
|
1954
|
+
}
|
|
1955
|
+
return end;
|
|
1956
|
+
}
|
|
1957
|
+
emit(i, i + 1, "typ-punct");
|
|
1958
|
+
return i + 1;
|
|
1959
|
+
}
|
|
1960
|
+
if (ch === "<") {
|
|
1961
|
+
const label = /^<[A-Za-z_][\w:.-]*>/.exec(rest);
|
|
1962
|
+
if (label) {
|
|
1963
|
+
emit(i, i + label[0].length, "typ-label");
|
|
1964
|
+
return i + label[0].length;
|
|
1965
|
+
}
|
|
1966
|
+
}
|
|
1967
|
+
if (ch === "@") {
|
|
1968
|
+
const ref = /^@[\w:.-]+/.exec(rest);
|
|
1969
|
+
if (ref) {
|
|
1970
|
+
emit(i, i + ref[0].length, "typ-ref");
|
|
1971
|
+
return i + ref[0].length;
|
|
1972
|
+
}
|
|
1973
|
+
}
|
|
1974
|
+
if (ch === "\\") {
|
|
1975
|
+
emit(i, Math.min(i + 2, line.length), "typ-escape");
|
|
1976
|
+
return i + 2;
|
|
1977
|
+
}
|
|
1978
|
+
if (ch === "*" || ch === "_") {
|
|
1979
|
+
const before = i === 0 ? "" : line[i - 1];
|
|
1980
|
+
const close = line.indexOf(ch, i + 1);
|
|
1981
|
+
const after = close < 0 ? "" : line[close + 1] ?? "";
|
|
1982
|
+
if (close > i + 1 && !/\w/.test(before) && !/\s/.test(line[i + 1]) && !/\s/.test(line[close - 1]) && !/\w/.test(after)) {
|
|
1983
|
+
emit(i, close + 1, ch === "*" ? "typ-strong" : "typ-emph");
|
|
1984
|
+
return close + 1;
|
|
1985
|
+
}
|
|
1986
|
+
}
|
|
1987
|
+
const link = /^(https?:\/\/|www\.)\S+/.exec(rest);
|
|
1988
|
+
if (link) {
|
|
1989
|
+
emit(i, i + link[0].length, "typ-link");
|
|
1990
|
+
return i + link[0].length;
|
|
1991
|
+
}
|
|
1992
|
+
return i + 1;
|
|
1993
|
+
}
|
|
1994
|
+
function codeToken(line, i, state, emit) {
|
|
1995
|
+
const rest = line.slice(i);
|
|
1996
|
+
const ch = line[i];
|
|
1997
|
+
if (rest.startsWith("//")) {
|
|
1998
|
+
emit(i, line.length, "typ-comment");
|
|
1999
|
+
return line.length;
|
|
2000
|
+
}
|
|
2001
|
+
if (rest.startsWith("/*")) {
|
|
2002
|
+
const close = line.indexOf("*/", i + 2);
|
|
2003
|
+
if (close < 0) {
|
|
2004
|
+
emit(i, line.length, "typ-comment");
|
|
2005
|
+
state.mode = "comment";
|
|
2006
|
+
return line.length;
|
|
2007
|
+
}
|
|
2008
|
+
emit(i, close + 2, "typ-comment");
|
|
2009
|
+
return close + 2;
|
|
2010
|
+
}
|
|
2011
|
+
if (ch === "\"") {
|
|
2012
|
+
const end = stringEnd(line, i);
|
|
2013
|
+
emit(i, end, "typ-str");
|
|
2014
|
+
return end;
|
|
2015
|
+
}
|
|
2016
|
+
if (ch === "$") {
|
|
2017
|
+
emit(i, i + 1, "typ-math-delim");
|
|
2018
|
+
state.mode = "math";
|
|
2019
|
+
state.mathReturn = "code";
|
|
2020
|
+
return i + 1;
|
|
2021
|
+
}
|
|
2022
|
+
if (/[0-9]/.test(ch)) {
|
|
2023
|
+
const number = /^[0-9][\w.]*/.exec(rest);
|
|
2024
|
+
emit(i, i + number[0].length, "typ-num");
|
|
2025
|
+
return i + number[0].length;
|
|
2026
|
+
}
|
|
2027
|
+
if (/[A-Za-z_]/.test(ch)) {
|
|
2028
|
+
const ident = IDENT.exec(rest);
|
|
2029
|
+
emit(i, i + ident[0].length, classForIdent(ident[0], line.slice(i + ident[0].length)));
|
|
2030
|
+
return i + ident[0].length;
|
|
2031
|
+
}
|
|
2032
|
+
if ("([{".includes(ch)) {
|
|
2033
|
+
state.depth += 1;
|
|
2034
|
+
emit(i, i + 1, "typ-punct");
|
|
2035
|
+
return i + 1;
|
|
2036
|
+
}
|
|
2037
|
+
if (")]}".includes(ch)) {
|
|
2038
|
+
state.depth = Math.max(0, state.depth - 1);
|
|
2039
|
+
emit(i, i + 1, "typ-punct");
|
|
2040
|
+
return i + 1;
|
|
2041
|
+
}
|
|
2042
|
+
if (".,;:".includes(ch)) {
|
|
2043
|
+
emit(i, i + 1, "typ-punct");
|
|
2044
|
+
return i + 1;
|
|
2045
|
+
}
|
|
2046
|
+
const operator = /^(==|!=|<=|>=|=>|\.\.\.|\.\.|[+\-*/=!<>&|])/.exec(rest);
|
|
2047
|
+
if (operator) {
|
|
2048
|
+
emit(i, i + operator[0].length, "typ-op");
|
|
2049
|
+
return i + operator[0].length;
|
|
2050
|
+
}
|
|
2051
|
+
return i + 1;
|
|
2052
|
+
}
|
|
2053
|
+
function mathToken(line, i, state, emit) {
|
|
2054
|
+
const rest = line.slice(i);
|
|
2055
|
+
const ch = line[i];
|
|
2056
|
+
if (ch === "$") {
|
|
2057
|
+
emit(i, i + 1, "typ-math-delim");
|
|
2058
|
+
state.mode = state.mathReturn;
|
|
2059
|
+
return i + 1;
|
|
2060
|
+
}
|
|
2061
|
+
if (ch === "\"") {
|
|
2062
|
+
const end = stringEnd(line, i);
|
|
2063
|
+
emit(i, end, "typ-str");
|
|
2064
|
+
return end;
|
|
2065
|
+
}
|
|
2066
|
+
if (/[0-9]/.test(ch)) {
|
|
2067
|
+
const number = /^[0-9][\w.]*/.exec(rest);
|
|
2068
|
+
emit(i, i + number[0].length, "typ-num");
|
|
2069
|
+
return i + number[0].length;
|
|
2070
|
+
}
|
|
2071
|
+
if (/[A-Za-z_]/.test(ch)) {
|
|
2072
|
+
const ident = MATH_IDENT.exec(rest);
|
|
2073
|
+
if (ident) {
|
|
2074
|
+
emit(i, i + ident[0].length, classForIdent(ident[0], line.slice(i + ident[0].length)));
|
|
2075
|
+
return i + ident[0].length;
|
|
2076
|
+
}
|
|
2077
|
+
}
|
|
2078
|
+
if (ch === "(" || ch === ")") {
|
|
2079
|
+
emit(i, i + 1, "typ-math-group");
|
|
2080
|
+
return i + 1;
|
|
2081
|
+
}
|
|
2082
|
+
if ("[]{}.,;:".includes(ch)) {
|
|
2083
|
+
emit(i, i + 1, "typ-punct");
|
|
2084
|
+
return i + 1;
|
|
2085
|
+
}
|
|
2086
|
+
if ("^_&/".includes(ch)) {
|
|
2087
|
+
emit(i, i + 1, "typ-math-op");
|
|
2088
|
+
return i + 1;
|
|
2089
|
+
}
|
|
2090
|
+
if ("+-*=<>.!|".includes(ch)) {
|
|
2091
|
+
emit(i, i + 1, "typ-op");
|
|
2092
|
+
return i + 1;
|
|
2093
|
+
}
|
|
2094
|
+
return i + 1;
|
|
2095
|
+
}
|
|
2096
|
+
/** End of a string literal starting at `from`, or the line end. */
|
|
2097
|
+
function stringEnd(line, from) {
|
|
2098
|
+
let index = from + 1;
|
|
2099
|
+
while (index < line.length) {
|
|
2100
|
+
if (line[index] === "\\") {
|
|
2101
|
+
index += 2;
|
|
2102
|
+
continue;
|
|
2103
|
+
}
|
|
2104
|
+
if (line[index] === "\"") return index + 1;
|
|
2105
|
+
index += 1;
|
|
2106
|
+
}
|
|
2107
|
+
return line.length;
|
|
2108
|
+
}
|
|
2109
|
+
//#endregion
|
|
2110
|
+
export { TypstWidget, attachFrameInteractions, autocomplete, cellIndexAt, cellStart, cellsOf, compileCache, createFrameContainer, cursorAddVertical, cycleHeading, deleteCell, deleteCellAt, deleteCellText, duplicateCell, duplicateCellAt, duplicateCellText, focusCell, frameActiveDecorations, frameIsInactive, frameSize, hasMarker, insertAtCursor, insertCell, insertCellAt, insertCellText, insertFontFamily, insertTabLike, markerText, mergeCell, mergeCellText, moveCell, moveCellAt, moveCellText, notebookKeymap, notebookOptions, notebookOptionsFacet, notebookRefreshEffect, notebookRunEffect, parseBackticks, rememberDiagnostics, runAllCells, runCell, setCellType, setCellTypeText, splitCell, splitCellText, stripCellMarkers, syncFrameContainer, toLintDiagnostics, toggleAroundSelection, toggleCellType, toggleCode, toggleEmph, toggleEnum, toggleList, toggleListLike, toggleMath, toggleStrike, toggleStrong, toggleUnderline, tooltipStateField, tooltipViewPlugin, tooltipsStateEffect, tooltipsStateField, typstHoverTooltip, typstKeymap, typstLanguageData, typstLinter, typstPlugin, typstRecompileEffect, typstStateField, typstStaticHighlighting, typstSyntaxHighlighting, typstViewPlugin, wrapOrInsert };
|