@codecanvas-ide/engine 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 +21 -0
- package/README.md +41 -2
- package/dist/codecanvas-engine.d.ts +1 -0
- package/dist/codecanvas-engine.js +693 -0
- package/dist/index.js +3630 -0
- package/dist/index.js.map +7 -0
- package/dist/types/arrange.d.ts +19 -0
- package/dist/types/canvas.d.ts +257 -0
- package/dist/types/conflicts.d.ts +37 -0
- package/dist/types/diff.d.ts +28 -0
- package/dist/types/edits.d.ts +38 -0
- package/dist/types/html.d.ts +1 -0
- package/dist/types/index.d.ts +20 -0
- package/dist/types/layout.d.ts +75 -0
- package/dist/types/layoutRules.d.ts +48 -0
- package/dist/types/parse.d.ts +72 -0
- package/dist/types/project.d.ts +118 -0
- package/dist/types/projectServer.d.ts +19 -0
- package/dist/types/projectWorker.d.ts +14 -0
- package/dist/types/remoteProject.d.ts +31 -0
- package/dist/types/styles.d.ts +3 -0
- package/dist/types/types.d.ts +89 -0
- package/package.json +52 -4
package/dist/index.js
ADDED
|
@@ -0,0 +1,3630 @@
|
|
|
1
|
+
// src/parse.ts
|
|
2
|
+
import ts from "typescript";
|
|
3
|
+
var PREVIEW_LINES = 6;
|
|
4
|
+
var CONTAINER_PREVIEW_LINES = 3;
|
|
5
|
+
var PREVIEW_WIDTH = 64;
|
|
6
|
+
var internals = /* @__PURE__ */ new WeakMap();
|
|
7
|
+
function parseFile(path, text) {
|
|
8
|
+
const m = parseStructure(path, text);
|
|
9
|
+
m.usages = computeUsages(m);
|
|
10
|
+
return m;
|
|
11
|
+
}
|
|
12
|
+
function parseStructure(path, text, prev) {
|
|
13
|
+
const kind = /\.[jt]sx$/.test(path) ? ts.ScriptKind.TSX : ts.ScriptKind.TS;
|
|
14
|
+
const old = prev && prev.path === path ? internals.get(prev) : void 0;
|
|
15
|
+
let sf;
|
|
16
|
+
if (old && !old.reused) {
|
|
17
|
+
old.reused = true;
|
|
18
|
+
sf = ts.updateSourceFile(old.sf, text, changeRange(prev.text, text));
|
|
19
|
+
} else {
|
|
20
|
+
sf = ts.createSourceFile(path, text, ts.ScriptTarget.Latest, true, kind);
|
|
21
|
+
}
|
|
22
|
+
const b = new Builder(sf, text);
|
|
23
|
+
b.collectStatements(sf.statements, null, 0);
|
|
24
|
+
const syntax = sf.parseDiagnostics ?? [];
|
|
25
|
+
const model = {
|
|
26
|
+
path,
|
|
27
|
+
text,
|
|
28
|
+
roots: b.roots,
|
|
29
|
+
blocks: b.byId,
|
|
30
|
+
usages: [],
|
|
31
|
+
syntaxErrors: syntax.length,
|
|
32
|
+
firstErrorLine: -1,
|
|
33
|
+
diagnostics: [],
|
|
34
|
+
indentUnit: detectIndentUnit(text)
|
|
35
|
+
};
|
|
36
|
+
model.diagnostics = syntax.map((d) => toCodeDiagnostic(model, sf, d, "syntax"));
|
|
37
|
+
model.firstErrorLine = model.diagnostics[0]?.line ?? -1;
|
|
38
|
+
internals.set(model, { sf, builder: b, reused: false });
|
|
39
|
+
return model;
|
|
40
|
+
}
|
|
41
|
+
function blockAt(model, offset) {
|
|
42
|
+
let found = null;
|
|
43
|
+
let candidates = model.roots;
|
|
44
|
+
for (; ; ) {
|
|
45
|
+
const containing = candidates.find((b) => offset >= b.start && offset <= b.end);
|
|
46
|
+
if (!containing) return found;
|
|
47
|
+
found = containing;
|
|
48
|
+
candidates = containing.children;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
function toCodeDiagnostic(model, sf, d, source) {
|
|
52
|
+
const start = d.start ?? 0;
|
|
53
|
+
return {
|
|
54
|
+
start,
|
|
55
|
+
length: d.length ?? 0,
|
|
56
|
+
line: sf.getLineAndCharacterOfPosition(Math.min(start, sf.text.length)).line + 1,
|
|
57
|
+
message: ts.flattenDiagnosticMessageText(d.messageText, "\n"),
|
|
58
|
+
source,
|
|
59
|
+
severity: d.category === ts.DiagnosticCategory.Error ? "error" : "warning",
|
|
60
|
+
blockId: blockAt(model, start)?.id ?? null
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
function builderOf(model) {
|
|
64
|
+
return internals.get(model)?.builder;
|
|
65
|
+
}
|
|
66
|
+
function computeUsages(model) {
|
|
67
|
+
const st = internals.get(model);
|
|
68
|
+
if (!st) return [];
|
|
69
|
+
if (st.reused) {
|
|
70
|
+
return parseFile(model.path, model.text).usages;
|
|
71
|
+
}
|
|
72
|
+
return st.builder.collectUsages(st.sf, createProgram(st.sf).getTypeChecker());
|
|
73
|
+
}
|
|
74
|
+
function changeRange(a, b) {
|
|
75
|
+
let s = 0;
|
|
76
|
+
const n = Math.min(a.length, b.length);
|
|
77
|
+
while (s < n && a.charCodeAt(s) === b.charCodeAt(s)) s++;
|
|
78
|
+
let e = 0;
|
|
79
|
+
while (e < n - s && a.charCodeAt(a.length - 1 - e) === b.charCodeAt(b.length - 1 - e)) e++;
|
|
80
|
+
return ts.createTextChangeRange(ts.createTextSpan(s, a.length - e - s), b.length - e - s);
|
|
81
|
+
}
|
|
82
|
+
function createProgram(sf) {
|
|
83
|
+
const options = {
|
|
84
|
+
noLib: true,
|
|
85
|
+
noResolve: true,
|
|
86
|
+
allowJs: true,
|
|
87
|
+
target: ts.ScriptTarget.Latest,
|
|
88
|
+
jsx: ts.JsxEmit.Preserve,
|
|
89
|
+
types: []
|
|
90
|
+
};
|
|
91
|
+
const host = {
|
|
92
|
+
getSourceFile: (name) => name === sf.fileName ? sf : void 0,
|
|
93
|
+
getDefaultLibFileName: () => "lib.d.ts",
|
|
94
|
+
writeFile: () => {
|
|
95
|
+
},
|
|
96
|
+
getCurrentDirectory: () => "/",
|
|
97
|
+
getCanonicalFileName: (f) => f,
|
|
98
|
+
useCaseSensitiveFileNames: () => true,
|
|
99
|
+
getNewLine: () => "\n",
|
|
100
|
+
fileExists: (f) => f === sf.fileName,
|
|
101
|
+
readFile: () => void 0,
|
|
102
|
+
getDirectories: () => []
|
|
103
|
+
};
|
|
104
|
+
return ts.createProgram({ rootNames: [sf.fileName], options, host });
|
|
105
|
+
}
|
|
106
|
+
function detectIndentUnit(text) {
|
|
107
|
+
const m = /\n(\t+|[ ]{2,8})\S/.exec(text);
|
|
108
|
+
if (!m) return " ";
|
|
109
|
+
return m[1].startsWith(" ") ? " " : m[1].length >= 4 && m[1].length % 4 === 0 ? " " : " ";
|
|
110
|
+
}
|
|
111
|
+
var Builder = class {
|
|
112
|
+
constructor(sf, text) {
|
|
113
|
+
this.sf = sf;
|
|
114
|
+
this.text = text;
|
|
115
|
+
}
|
|
116
|
+
sf;
|
|
117
|
+
text;
|
|
118
|
+
roots = [];
|
|
119
|
+
byId = /* @__PURE__ */ new Map();
|
|
120
|
+
/**
|
|
121
|
+
* Declaration node → block id, keyed by node position (see `nodeKey`) so a syntax tree parsed
|
|
122
|
+
* elsewhere from the same text (the project's language service) maps to the same blocks.
|
|
123
|
+
*/
|
|
124
|
+
declToBlock = /* @__PURE__ */ new Map();
|
|
125
|
+
/** Range node → block, to find which block contains a usage. Keyed like `declToBlock`. */
|
|
126
|
+
rangeToBlock = /* @__PURE__ */ new Map();
|
|
127
|
+
/** Every name a usage could resolve to. Other identifiers skip the (slow) checker. */
|
|
128
|
+
names = /* @__PURE__ */ new Set();
|
|
129
|
+
// ---------- structure ----------
|
|
130
|
+
collectStatements(stmts, parent, depth, declarationsOnly = false) {
|
|
131
|
+
let group = [];
|
|
132
|
+
const flushGroup = () => {
|
|
133
|
+
if (!group.length) return;
|
|
134
|
+
const nodes = group;
|
|
135
|
+
group = [];
|
|
136
|
+
const first = nodes[0];
|
|
137
|
+
const firstLine = this.text.slice(first.getStart(this.sf), first.end).split("\n")[0].trim();
|
|
138
|
+
this.add(
|
|
139
|
+
{
|
|
140
|
+
kind: "statement",
|
|
141
|
+
key: "stmt:" + firstLine.slice(0, 40),
|
|
142
|
+
name: truncate(firstLine, 48),
|
|
143
|
+
detail: nodes.length > 1 ? `+${nodes.length - 1} more` : "",
|
|
144
|
+
tags: [],
|
|
145
|
+
rangeNodes: nodes,
|
|
146
|
+
declNodes: [],
|
|
147
|
+
previewRange: [nodes[0].getStart(this.sf), nodes[nodes.length - 1].end],
|
|
148
|
+
insert: null,
|
|
149
|
+
insertAt: -1
|
|
150
|
+
},
|
|
151
|
+
parent,
|
|
152
|
+
depth
|
|
153
|
+
);
|
|
154
|
+
};
|
|
155
|
+
for (let i = 0; i < stmts.length; i++) {
|
|
156
|
+
const s = stmts[i];
|
|
157
|
+
if (ts.isFunctionDeclaration(s)) {
|
|
158
|
+
flushGroup();
|
|
159
|
+
const overloads = [s];
|
|
160
|
+
while (!overloads[overloads.length - 1].body) {
|
|
161
|
+
const next = stmts[i + 1];
|
|
162
|
+
if (next && ts.isFunctionDeclaration(next) && next.name?.text === s.name?.text) {
|
|
163
|
+
overloads.push(next);
|
|
164
|
+
i++;
|
|
165
|
+
} else break;
|
|
166
|
+
}
|
|
167
|
+
this.addFunctionLike(overloads, s.name?.text ?? "default", parent, depth);
|
|
168
|
+
} else if (ts.isClassDeclaration(s)) {
|
|
169
|
+
flushGroup();
|
|
170
|
+
this.addClass(s, s, s.name?.text ?? "default", parent, depth);
|
|
171
|
+
} else if (ts.isVariableStatement(s)) {
|
|
172
|
+
flushGroup();
|
|
173
|
+
this.addVariableStatement(s, parent, depth, declarationsOnly);
|
|
174
|
+
} else if (declarationsOnly) {
|
|
175
|
+
continue;
|
|
176
|
+
} else if (ts.isImportDeclaration(s) || ts.isImportEqualsDeclaration(s)) {
|
|
177
|
+
flushGroup();
|
|
178
|
+
this.addImport(s, parent, depth);
|
|
179
|
+
} else if (ts.isInterfaceDeclaration(s) || ts.isTypeAliasDeclaration(s) || ts.isEnumDeclaration(s)) {
|
|
180
|
+
flushGroup();
|
|
181
|
+
const keyword = ts.isInterfaceDeclaration(s) ? "interface" : ts.isEnumDeclaration(s) ? "enum" : "type";
|
|
182
|
+
const typeParams = "typeParameters" in s && s.typeParameters ? `<${s.typeParameters.map((t) => t.getText()).join(", ")}>` : "";
|
|
183
|
+
const bodyStart = ts.isTypeAliasDeclaration(s) ? s.type.getStart(this.sf) : this.text.indexOf("{", s.name.end);
|
|
184
|
+
this.add(
|
|
185
|
+
{
|
|
186
|
+
kind: "type",
|
|
187
|
+
key: s.name.text,
|
|
188
|
+
name: s.name.text,
|
|
189
|
+
detail: typeParams,
|
|
190
|
+
tags: [...modifierTags(s), keyword],
|
|
191
|
+
rangeNodes: [s],
|
|
192
|
+
declNodes: [s],
|
|
193
|
+
nameNode: s.name,
|
|
194
|
+
previewRange: bodyStart >= 0 ? [bodyStart, s.end] : null,
|
|
195
|
+
insert: null,
|
|
196
|
+
insertAt: -1
|
|
197
|
+
},
|
|
198
|
+
parent,
|
|
199
|
+
depth
|
|
200
|
+
);
|
|
201
|
+
} else if (ts.isModuleDeclaration(s) && s.body && ts.isModuleBlock(s.body)) {
|
|
202
|
+
flushGroup();
|
|
203
|
+
const body = s.body;
|
|
204
|
+
this.add(
|
|
205
|
+
{
|
|
206
|
+
kind: "namespace",
|
|
207
|
+
key: s.name.getText(),
|
|
208
|
+
name: s.name.getText(),
|
|
209
|
+
detail: "",
|
|
210
|
+
tags: [...modifierTags(s), "namespace"],
|
|
211
|
+
rangeNodes: [s],
|
|
212
|
+
declNodes: [s],
|
|
213
|
+
nameNode: s.name,
|
|
214
|
+
previewRange: [body.getStart(this.sf) + 1, body.end - 1],
|
|
215
|
+
insert: "body",
|
|
216
|
+
insertAt: body.end - 1,
|
|
217
|
+
children: (blk) => this.collectStatements(body.statements, blk, depth + 1)
|
|
218
|
+
},
|
|
219
|
+
parent,
|
|
220
|
+
depth
|
|
221
|
+
);
|
|
222
|
+
} else {
|
|
223
|
+
group.push(s);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
flushGroup();
|
|
227
|
+
}
|
|
228
|
+
addImport(s, parent, depth) {
|
|
229
|
+
let spec;
|
|
230
|
+
let names = [];
|
|
231
|
+
if (ts.isImportDeclaration(s)) {
|
|
232
|
+
spec = s.moduleSpecifier.text;
|
|
233
|
+
const clause = s.importClause;
|
|
234
|
+
if (clause?.name) names.push(clause.name.text);
|
|
235
|
+
const nb = clause?.namedBindings;
|
|
236
|
+
if (nb && ts.isNamespaceImport(nb)) names.push("* as " + nb.name.text);
|
|
237
|
+
if (nb && ts.isNamedImports(nb)) names.push(...nb.elements.map((e) => e.name.text));
|
|
238
|
+
if (!clause) names = ["(side effect)"];
|
|
239
|
+
} else {
|
|
240
|
+
spec = s.name.text;
|
|
241
|
+
names = [s.moduleReference.getText()];
|
|
242
|
+
this.names.add(s.name.text);
|
|
243
|
+
}
|
|
244
|
+
for (const n of names) this.names.add(n.replace("* as ", ""));
|
|
245
|
+
this.add(
|
|
246
|
+
{
|
|
247
|
+
kind: "import",
|
|
248
|
+
key: "import:" + spec,
|
|
249
|
+
name: spec,
|
|
250
|
+
detail: "",
|
|
251
|
+
tags: ts.isImportDeclaration(s) && s.importClause?.isTypeOnly ? ["type"] : [],
|
|
252
|
+
rangeNodes: [s],
|
|
253
|
+
declNodes: [s],
|
|
254
|
+
previewRange: null,
|
|
255
|
+
insert: null,
|
|
256
|
+
insertAt: -1
|
|
257
|
+
},
|
|
258
|
+
parent,
|
|
259
|
+
depth,
|
|
260
|
+
wrapNames(names)
|
|
261
|
+
);
|
|
262
|
+
}
|
|
263
|
+
addFunctionLike(nodes, name, parent, depth, opts = {}) {
|
|
264
|
+
const impl = nodes[nodes.length - 1];
|
|
265
|
+
const sig = nodes.find((n) => !!n.body) ?? impl;
|
|
266
|
+
const body = impl.body;
|
|
267
|
+
let previewRange = null;
|
|
268
|
+
let insert = null;
|
|
269
|
+
let insertAt = -1;
|
|
270
|
+
if (body && ts.isBlock(body)) {
|
|
271
|
+
previewRange = [body.getStart(this.sf) + 1, body.end - 1];
|
|
272
|
+
insert = "body";
|
|
273
|
+
const last = body.statements[body.statements.length - 1];
|
|
274
|
+
insertAt = last && ts.isReturnStatement(last) ? this.lineStartOf(last.getStart(this.sf)) : body.end - 1;
|
|
275
|
+
} else if (body) {
|
|
276
|
+
previewRange = [body.getStart(this.sf), body.end];
|
|
277
|
+
}
|
|
278
|
+
const typeParams = sig.typeParameters?.length ? `<${sig.typeParameters.map((t) => t.getText()).join(", ")}>` : "";
|
|
279
|
+
const ret = sig.type ? ": " + sig.type.getText() : "";
|
|
280
|
+
const detail = squash(`${typeParams}(${sig.parameters.map((p) => p.getText()).join(", ")})${ret}`);
|
|
281
|
+
const tags = [...opts.extraTags ?? [], ...modifierTags(nodes[0])];
|
|
282
|
+
if (nodes.length > 1) tags.push(`${nodes.length - 1} overload${nodes.length > 2 ? "s" : ""}`);
|
|
283
|
+
if (impl.asteriskToken) tags.push("generator");
|
|
284
|
+
this.add(
|
|
285
|
+
{
|
|
286
|
+
kind: "function",
|
|
287
|
+
key: opts.key ?? name,
|
|
288
|
+
name,
|
|
289
|
+
detail,
|
|
290
|
+
tags,
|
|
291
|
+
rangeNodes: opts.rangeNodes ?? nodes,
|
|
292
|
+
declNodes: opts.declNodes ?? nodes,
|
|
293
|
+
nameNode: impl.name,
|
|
294
|
+
previewRange,
|
|
295
|
+
insert,
|
|
296
|
+
insertAt,
|
|
297
|
+
children: body && ts.isBlock(body) ? (blk) => this.collectStatements(body.statements, blk, depth + 1, true) : void 0
|
|
298
|
+
},
|
|
299
|
+
parent,
|
|
300
|
+
depth
|
|
301
|
+
);
|
|
302
|
+
}
|
|
303
|
+
addClass(cls, rangeNode, name, parent, depth, declNodes = [cls], extraTags = []) {
|
|
304
|
+
const heritage = (cls.heritageClauses ?? []).map((h) => h.getText()).join(" ");
|
|
305
|
+
const openBrace = this.text.indexOf("{", (cls.heritageClauses?.at(-1) ?? cls.typeParameters?.at(-1) ?? cls.name ?? cls).end);
|
|
306
|
+
const typeParams = cls.typeParameters?.length ? `<${cls.typeParameters.map((t) => t.getText()).join(", ")}>` : "";
|
|
307
|
+
this.add(
|
|
308
|
+
{
|
|
309
|
+
kind: "class",
|
|
310
|
+
key: name,
|
|
311
|
+
name,
|
|
312
|
+
detail: squash(typeParams + (heritage ? " " + heritage : "")),
|
|
313
|
+
tags: [...extraTags, ...modifierTags(rangeNode === cls ? cls : rangeNode)],
|
|
314
|
+
rangeNodes: [rangeNode],
|
|
315
|
+
declNodes,
|
|
316
|
+
nameNode: cls.name,
|
|
317
|
+
previewRange: openBrace >= 0 ? [openBrace + 1, cls.end - 1] : null,
|
|
318
|
+
insert: "class",
|
|
319
|
+
insertAt: cls.end - 1,
|
|
320
|
+
children: (blk) => this.collectMembers(cls.members, blk, depth + 1)
|
|
321
|
+
},
|
|
322
|
+
parent,
|
|
323
|
+
depth
|
|
324
|
+
);
|
|
325
|
+
}
|
|
326
|
+
collectMembers(members, parent, depth) {
|
|
327
|
+
for (let i = 0; i < members.length; i++) {
|
|
328
|
+
const m = members[i];
|
|
329
|
+
if (ts.isMethodDeclaration(m) || ts.isConstructorDeclaration(m)) {
|
|
330
|
+
const name = ts.isConstructorDeclaration(m) ? "constructor" : m.name.getText();
|
|
331
|
+
const overloads = [m];
|
|
332
|
+
while (!overloads[overloads.length - 1].body) {
|
|
333
|
+
const next = members[i + 1];
|
|
334
|
+
const nextName = next && (ts.isConstructorDeclaration(next) ? "constructor" : ts.isMethodDeclaration(next) ? next.name.getText() : null);
|
|
335
|
+
if (nextName === name) {
|
|
336
|
+
overloads.push(next);
|
|
337
|
+
i++;
|
|
338
|
+
} else break;
|
|
339
|
+
}
|
|
340
|
+
this.addFunctionLike(overloads, name, parent, depth);
|
|
341
|
+
} else if (ts.isGetAccessor(m) || ts.isSetAccessor(m)) {
|
|
342
|
+
const name = m.name.getText();
|
|
343
|
+
const word = ts.isGetAccessor(m) ? "get" : "set";
|
|
344
|
+
this.addFunctionLike([m], name, parent, depth, { key: `${word} ${name}`, extraTags: [word] });
|
|
345
|
+
} else if (ts.isPropertyDeclaration(m)) {
|
|
346
|
+
const name = m.name.getText();
|
|
347
|
+
const init = m.initializer && unwrap(m.initializer);
|
|
348
|
+
if (init && (ts.isArrowFunction(init) || ts.isFunctionExpression(init))) {
|
|
349
|
+
this.addFunctionLike([init], name, parent, depth, { rangeNodes: [m], declNodes: [m], extraTags: modifierTags(m) });
|
|
350
|
+
} else {
|
|
351
|
+
this.addVariable(m, m, name, m.type ? ": " + m.type.getText() : "", modifierTags(m), parent, depth, m.initializer);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
addVariableStatement(s, parent, depth, functionsOnly) {
|
|
357
|
+
const decls = s.declarationList.declarations;
|
|
358
|
+
const keyword = s.declarationList.flags & ts.NodeFlags.Const ? "const" : s.declarationList.flags & ts.NodeFlags.Let ? "let" : "var";
|
|
359
|
+
for (const d of decls) {
|
|
360
|
+
const rangeNode = decls.length === 1 ? s : d;
|
|
361
|
+
const name = d.name.getText();
|
|
362
|
+
const tags = [...modifierTags(s), keyword];
|
|
363
|
+
const init = d.initializer && unwrap(d.initializer);
|
|
364
|
+
if (init && (ts.isArrowFunction(init) || ts.isFunctionExpression(init))) {
|
|
365
|
+
this.addFunctionLike([init], name, parent, depth, { rangeNodes: [rangeNode], declNodes: [d], extraTags: tags });
|
|
366
|
+
} else if (init && ts.isClassExpression(init)) {
|
|
367
|
+
this.addClass(init, rangeNode, name, parent, depth, [d, init], tags);
|
|
368
|
+
} else if (!functionsOnly) {
|
|
369
|
+
this.addVariable(d, rangeNode, name, d.type ? ": " + d.type.getText() : "", tags, parent, depth, d.initializer);
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
addVariable(decl, rangeNode, name, type, tags, parent, depth, initializer) {
|
|
374
|
+
const init = initializer && unwrap(initializer);
|
|
375
|
+
const objectChildren = init && ts.isObjectLiteralExpression(init) ? init.properties.filter(
|
|
376
|
+
(p) => ts.isMethodDeclaration(p) || ts.isPropertyAssignment(p) && (ts.isArrowFunction(unwrap(p.initializer)) || ts.isFunctionExpression(unwrap(p.initializer)))
|
|
377
|
+
) : [];
|
|
378
|
+
this.add(
|
|
379
|
+
{
|
|
380
|
+
kind: "variable",
|
|
381
|
+
key: name,
|
|
382
|
+
name,
|
|
383
|
+
detail: squash(type),
|
|
384
|
+
tags,
|
|
385
|
+
rangeNodes: [rangeNode],
|
|
386
|
+
declNodes: [decl],
|
|
387
|
+
nameNode: decl.name,
|
|
388
|
+
previewRange: initializer ? [initializer.getStart(this.sf), initializer.end] : null,
|
|
389
|
+
insert: null,
|
|
390
|
+
insertAt: -1,
|
|
391
|
+
children: objectChildren.length ? (blk) => {
|
|
392
|
+
for (const p of objectChildren) {
|
|
393
|
+
const pname = p.name.getText();
|
|
394
|
+
if (ts.isMethodDeclaration(p)) this.addFunctionLike([p], pname, blk, depth + 1);
|
|
395
|
+
else {
|
|
396
|
+
const fn = unwrap(p.initializer);
|
|
397
|
+
this.addFunctionLike([fn], pname, blk, depth + 1, { rangeNodes: [p], declNodes: [p] });
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
} : void 0
|
|
401
|
+
},
|
|
402
|
+
parent,
|
|
403
|
+
depth
|
|
404
|
+
);
|
|
405
|
+
}
|
|
406
|
+
add(p, parent, depth, presetPreview) {
|
|
407
|
+
const first = p.rangeNodes[0];
|
|
408
|
+
const last = p.rangeNodes[p.rangeNodes.length - 1];
|
|
409
|
+
const start = this.startWithComments(first);
|
|
410
|
+
const siblings = parent ? parent.children : this.roots;
|
|
411
|
+
let key = p.key;
|
|
412
|
+
if (siblings.some((s) => s.key === key)) {
|
|
413
|
+
let n = 2;
|
|
414
|
+
while (siblings.some((s) => s.key === `${p.key}#${n}`)) n++;
|
|
415
|
+
key = `${p.key}#${n}`;
|
|
416
|
+
}
|
|
417
|
+
const id = parent ? `${parent.id}/${key}` : key;
|
|
418
|
+
const block = {
|
|
419
|
+
id,
|
|
420
|
+
key,
|
|
421
|
+
kind: p.kind,
|
|
422
|
+
name: p.name,
|
|
423
|
+
detail: p.detail,
|
|
424
|
+
tags: p.tags,
|
|
425
|
+
start,
|
|
426
|
+
end: last.end,
|
|
427
|
+
nameStart: p.nameNode ? p.nameNode.getStart(this.sf) : -1,
|
|
428
|
+
preview: [],
|
|
429
|
+
previewMore: 0,
|
|
430
|
+
children: [],
|
|
431
|
+
parentId: parent?.id ?? null,
|
|
432
|
+
depth,
|
|
433
|
+
insert: p.insert,
|
|
434
|
+
insertAt: p.insertAt,
|
|
435
|
+
indent: this.indentAt(first.getStart(this.sf))
|
|
436
|
+
};
|
|
437
|
+
siblings.push(block);
|
|
438
|
+
this.byId.set(id, block);
|
|
439
|
+
this.names.add(p.name);
|
|
440
|
+
for (const n of p.rangeNodes) this.rangeToBlock.set(nodeKey(n), block);
|
|
441
|
+
for (const n of p.declNodes) this.declToBlock.set(nodeKey(n), id);
|
|
442
|
+
p.children?.(block);
|
|
443
|
+
if (presetPreview) {
|
|
444
|
+
block.preview = presetPreview;
|
|
445
|
+
} else if (p.previewRange) {
|
|
446
|
+
const max = block.children.length ? CONTAINER_PREVIEW_LINES : PREVIEW_LINES;
|
|
447
|
+
const { lines, more } = makePreview(this.text, p.previewRange, block.children, max);
|
|
448
|
+
block.preview = lines;
|
|
449
|
+
block.previewMore = more;
|
|
450
|
+
}
|
|
451
|
+
return block;
|
|
452
|
+
}
|
|
453
|
+
/** Include doc comments directly above the node (no blank line in between). */
|
|
454
|
+
startWithComments(node) {
|
|
455
|
+
let start = node.getStart(this.sf);
|
|
456
|
+
const comments = ts.getLeadingCommentRanges(this.text, node.pos) ?? [];
|
|
457
|
+
for (let i = comments.length - 1; i >= 0; i--) {
|
|
458
|
+
const gap = this.text.slice(comments[i].end, start);
|
|
459
|
+
if (/\S/.test(gap) || (gap.match(/\n/g)?.length ?? 0) > 1) break;
|
|
460
|
+
start = comments[i].pos;
|
|
461
|
+
}
|
|
462
|
+
return start;
|
|
463
|
+
}
|
|
464
|
+
lineStartOf(pos) {
|
|
465
|
+
return this.text.lastIndexOf("\n", pos - 1) + 1;
|
|
466
|
+
}
|
|
467
|
+
indentAt(pos) {
|
|
468
|
+
const ls = this.lineStartOf(pos);
|
|
469
|
+
return /^[ \t]*/.exec(this.text.slice(ls, pos))[0];
|
|
470
|
+
}
|
|
471
|
+
// ---------- usages ----------
|
|
472
|
+
blockIdForDeclaration(declaration) {
|
|
473
|
+
return this.declToBlock.get(nodeKey(declaration));
|
|
474
|
+
}
|
|
475
|
+
/**
|
|
476
|
+
* Link every name used inside a block to the block that declares it. `sf` is a tree of the same
|
|
477
|
+
* text (this builder's own, or the language service's when resolving across files).
|
|
478
|
+
*/
|
|
479
|
+
collectUsages(sf, checker, otherFiles) {
|
|
480
|
+
const byPair = /* @__PURE__ */ new Map();
|
|
481
|
+
const visit = (node, current) => {
|
|
482
|
+
const blk = this.rangeToBlock.get(nodeKey(node));
|
|
483
|
+
if (blk) current = blk;
|
|
484
|
+
if (ts.isIdentifier(node) && current) this.recordUsage(node, current, checker, byPair, otherFiles);
|
|
485
|
+
ts.forEachChild(node, (c) => visit(c, current));
|
|
486
|
+
};
|
|
487
|
+
visit(sf, null);
|
|
488
|
+
return [...byPair.values()];
|
|
489
|
+
}
|
|
490
|
+
recordUsage(id, from, checker, out, otherFiles) {
|
|
491
|
+
const isKnownName = this.names.has(id.text) || !!otherFiles?.projectNames.has(id.text);
|
|
492
|
+
if (!isKnownName) return;
|
|
493
|
+
const parent = id.parent;
|
|
494
|
+
const isDeclarationName = !!parent && parent.name === id && !ts.isPropertyAccessExpression(parent) && !ts.isShorthandPropertyAssignment(parent);
|
|
495
|
+
if (isDeclarationName) return;
|
|
496
|
+
let sym;
|
|
497
|
+
try {
|
|
498
|
+
sym = parent && ts.isShorthandPropertyAssignment(parent) ? checker.getShorthandAssignmentValueSymbol(parent) : checker.getSymbolAtLocation(id);
|
|
499
|
+
} catch {
|
|
500
|
+
return;
|
|
501
|
+
}
|
|
502
|
+
if (!sym?.declarations) return;
|
|
503
|
+
const kind = usageKind(id);
|
|
504
|
+
let to;
|
|
505
|
+
let importBlockId;
|
|
506
|
+
for (const d of sym.declarations) {
|
|
507
|
+
to = this.declToBlock.get(nodeKey(d));
|
|
508
|
+
if (!to) {
|
|
509
|
+
let n = d;
|
|
510
|
+
while (n && !ts.isImportDeclaration(n) && !ts.isImportEqualsDeclaration(n) && !ts.isSourceFile(n)) n = n.parent;
|
|
511
|
+
if (n && !ts.isSourceFile(n)) importBlockId = to = this.declToBlock.get(nodeKey(n));
|
|
512
|
+
}
|
|
513
|
+
if (to) break;
|
|
514
|
+
}
|
|
515
|
+
if (otherFiles) {
|
|
516
|
+
const isImportedName = !!importBlockId && (sym.flags & ts.SymbolFlags.Alias) !== 0;
|
|
517
|
+
const target = isImportedName ? safeAliasedSymbol(checker, sym) : to ? void 0 : sym;
|
|
518
|
+
const declaredElsewhere = target?.declarations?.find((d) => d.getSourceFile() !== id.getSourceFile());
|
|
519
|
+
const external = declaredElsewhere && otherFiles.blockInOtherFile(declaredElsewhere);
|
|
520
|
+
if (external) addUsage(out, { from: from.id, to: external.id, toPath: external.path, via: importBlockId, kind, count: 1 });
|
|
521
|
+
}
|
|
522
|
+
if (!to || to === from.id) return;
|
|
523
|
+
if (from.id.startsWith(to + "/") || to.startsWith(from.id + "/")) return;
|
|
524
|
+
addUsage(out, { from: from.id, to, kind, count: 1 });
|
|
525
|
+
}
|
|
526
|
+
};
|
|
527
|
+
function addUsage(out, usage) {
|
|
528
|
+
const pairKey = `${usage.from}>${usage.toPath ?? ""}:${usage.to}`;
|
|
529
|
+
const existing = out.get(pairKey);
|
|
530
|
+
if (!existing) return void out.set(pairKey, usage);
|
|
531
|
+
existing.count++;
|
|
532
|
+
if (usage.kind === "call") existing.kind = "call";
|
|
533
|
+
}
|
|
534
|
+
function safeAliasedSymbol(checker, symbol) {
|
|
535
|
+
try {
|
|
536
|
+
return checker.getAliasedSymbol(symbol);
|
|
537
|
+
} catch {
|
|
538
|
+
return void 0;
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
function nodeKey(node) {
|
|
542
|
+
return `${node.kind}:${node.pos}:${node.end}`;
|
|
543
|
+
}
|
|
544
|
+
function usageKind(id) {
|
|
545
|
+
let n = id;
|
|
546
|
+
if (ts.isPropertyAccessExpression(n.parent) && n.parent.name === n) n = n.parent;
|
|
547
|
+
const p = n.parent;
|
|
548
|
+
if ((ts.isCallExpression(p) || ts.isNewExpression(p)) && p.expression === n) return "call";
|
|
549
|
+
if (ts.isTaggedTemplateExpression(p) && p.tag === n) return "call";
|
|
550
|
+
if (ts.isJsxOpeningLikeElement(p) && p.tagName === n) return "call";
|
|
551
|
+
if (ts.isBinaryExpression(p) && p.left === n && p.operatorToken.kind >= ts.SyntaxKind.FirstAssignment && p.operatorToken.kind <= ts.SyntaxKind.LastAssignment) return "write";
|
|
552
|
+
if ((ts.isPrefixUnaryExpression(p) || ts.isPostfixUnaryExpression(p)) && (p.operator === ts.SyntaxKind.PlusPlusToken || p.operator === ts.SyntaxKind.MinusMinusToken)) return "write";
|
|
553
|
+
if (ts.isTypeReferenceNode(p) || ts.isExpressionWithTypeArguments(p) || ts.isTypeQueryNode(p) || ts.isQualifiedName(p)) return "type";
|
|
554
|
+
return "read";
|
|
555
|
+
}
|
|
556
|
+
function unwrap(e) {
|
|
557
|
+
while (ts.isParenthesizedExpression(e) || ts.isAsExpression(e) || ts.isSatisfiesExpression(e) || ts.isTypeAssertionExpression(e)) e = e.expression;
|
|
558
|
+
return e;
|
|
559
|
+
}
|
|
560
|
+
function modifierTags(n) {
|
|
561
|
+
const mods = ts.canHaveModifiers(n) ? ts.getModifiers(n) ?? [] : [];
|
|
562
|
+
return mods.map((m) => m.getText());
|
|
563
|
+
}
|
|
564
|
+
function squash(s) {
|
|
565
|
+
return s.replace(/\s+/g, " ").replace(/\(\s+/g, "(").replace(/\s+\)/g, ")").replace(/,\s*\)/g, ")").trim();
|
|
566
|
+
}
|
|
567
|
+
function truncate(s, n) {
|
|
568
|
+
return s.length > n ? s.slice(0, n - 1) + "\u2026" : s;
|
|
569
|
+
}
|
|
570
|
+
function wrapNames(names) {
|
|
571
|
+
const lines = [];
|
|
572
|
+
let cur = "";
|
|
573
|
+
for (const n of names) {
|
|
574
|
+
const piece = cur ? cur + ", " + n : n;
|
|
575
|
+
if (piece.length > 40 && cur) {
|
|
576
|
+
lines.push(cur + ",");
|
|
577
|
+
cur = n;
|
|
578
|
+
} else cur = piece;
|
|
579
|
+
}
|
|
580
|
+
if (cur) lines.push(cur);
|
|
581
|
+
return lines.slice(0, PREVIEW_LINES);
|
|
582
|
+
}
|
|
583
|
+
function codeWithoutChildren(text, range, children) {
|
|
584
|
+
let [a, b] = range;
|
|
585
|
+
let src = "";
|
|
586
|
+
const kids = children.filter((c) => c.start >= a && c.end <= b).sort((x, y) => x.start - y.start);
|
|
587
|
+
for (const k of kids) {
|
|
588
|
+
src += text.slice(a, k.start);
|
|
589
|
+
a = k.end;
|
|
590
|
+
}
|
|
591
|
+
return src + text.slice(a, b);
|
|
592
|
+
}
|
|
593
|
+
function makePreview(text, range, children, max) {
|
|
594
|
+
const src = codeWithoutChildren(text, range, children);
|
|
595
|
+
const kids = children.filter((c) => c.start >= range[0] && c.end <= range[1]);
|
|
596
|
+
let lines = src.split("\n").map((l) => l.replace(/\s+$/, ""));
|
|
597
|
+
if (kids.length) lines = lines.filter((l) => !/^[\s{}[\](),;]*$/.test(l) || !l.trim());
|
|
598
|
+
lines = lines.filter((l, i) => l.trim() || i > 0 && lines[i - 1].trim());
|
|
599
|
+
while (lines.length && !lines[0].trim()) lines.shift();
|
|
600
|
+
while (lines.length && !lines[lines.length - 1].trim()) lines.pop();
|
|
601
|
+
const indents = lines.slice(lines.length > 1 && !/^\s/.test(lines[0]) ? 1 : 0).filter((l) => l.trim()).map((l) => /^[ \t]*/.exec(l)[0].length);
|
|
602
|
+
const minIndent = indents.length ? Math.min(...indents) : 0;
|
|
603
|
+
lines = lines.map((l) => (/^\s/.test(l) ? l.slice(minIndent) : l).replace(/\t/g, " "));
|
|
604
|
+
const more = Math.max(0, lines.length - max);
|
|
605
|
+
return { lines: lines.slice(0, max).map((l) => truncate(l, PREVIEW_WIDTH)), more };
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
// src/project.ts
|
|
609
|
+
import ts2 from "typescript";
|
|
610
|
+
var LIB_DIR = "/__lib__/";
|
|
611
|
+
var DEFAULT_LIB = ["lib.es2022.d.ts", "lib.dom.d.ts"];
|
|
612
|
+
var Project = class {
|
|
613
|
+
constructor(files = {}, projectOptions = {}) {
|
|
614
|
+
this.projectOptions = projectOptions;
|
|
615
|
+
this.configure(projectOptions);
|
|
616
|
+
for (const [path, text] of Object.entries(files)) this.setFile(path, text);
|
|
617
|
+
}
|
|
618
|
+
projectOptions;
|
|
619
|
+
files = /* @__PURE__ */ new Map();
|
|
620
|
+
projectVersion = 0;
|
|
621
|
+
libFiles = {};
|
|
622
|
+
hasLib = false;
|
|
623
|
+
options = {};
|
|
624
|
+
service;
|
|
625
|
+
projectNamesCache = null;
|
|
626
|
+
/** Options from the folder's tsconfig.json, between the defaults and `ProjectOptions.compilerOptions`. */
|
|
627
|
+
configFileOptions = {};
|
|
628
|
+
/** Changes when files are added or removed, or the settings change: where imports point may change then. */
|
|
629
|
+
fileSetVersion = 0;
|
|
630
|
+
/** Load the standard library later, e.g. once it has been downloaded. Type errors show from then on. */
|
|
631
|
+
setLibFiles(libFiles) {
|
|
632
|
+
this.projectOptions = { ...this.projectOptions, libFiles };
|
|
633
|
+
this.reconfigure();
|
|
634
|
+
}
|
|
635
|
+
/** Use the folder's tsconfig.json (or none, with null). */
|
|
636
|
+
setConfigFile(config) {
|
|
637
|
+
this.configFileOptions = config ? compilerOptionsOf(config) : {};
|
|
638
|
+
this.reconfigure();
|
|
639
|
+
}
|
|
640
|
+
reconfigure() {
|
|
641
|
+
this.service.dispose();
|
|
642
|
+
this.configure(this.projectOptions);
|
|
643
|
+
this.projectVersion++;
|
|
644
|
+
this.fileSetVersion++;
|
|
645
|
+
}
|
|
646
|
+
configure(options) {
|
|
647
|
+
this.libFiles = options.libFiles ?? {};
|
|
648
|
+
const libEntries = (options.lib ?? DEFAULT_LIB).filter((name) => name in this.libFiles);
|
|
649
|
+
this.hasLib = libEntries.length > 0;
|
|
650
|
+
this.options = {
|
|
651
|
+
target: ts2.ScriptTarget.ES2022,
|
|
652
|
+
module: ts2.ModuleKind.ESNext,
|
|
653
|
+
moduleResolution: ts2.ModuleResolutionKind.Bundler,
|
|
654
|
+
strict: true,
|
|
655
|
+
allowJs: true,
|
|
656
|
+
jsx: ts2.JsxEmit.Preserve,
|
|
657
|
+
skipLibCheck: true,
|
|
658
|
+
allowImportingTsExtensions: true,
|
|
659
|
+
...this.configFileOptions,
|
|
660
|
+
...options.compilerOptions,
|
|
661
|
+
// The project loads no @types packages, its standard library is the one given here, and it never emits.
|
|
662
|
+
types: [],
|
|
663
|
+
noEmit: true,
|
|
664
|
+
...this.hasLib ? { lib: libEntries, noLib: false } : { noLib: true }
|
|
665
|
+
};
|
|
666
|
+
this.service = ts2.createLanguageService(this.createHost(), ts2.createDocumentRegistry(true, "/"));
|
|
667
|
+
}
|
|
668
|
+
// ---------------------------------------------------------------- files (fast path)
|
|
669
|
+
/** Add or update a file. Parses only this file, incrementally from its previous version. */
|
|
670
|
+
setFile(path, text) {
|
|
671
|
+
const existing = this.files.get(path);
|
|
672
|
+
if (existing && existing.text === text) return existing.model;
|
|
673
|
+
const model = parseStructure(path, text, existing?.model);
|
|
674
|
+
this.files.set(path, { text, version: (existing?.version ?? 0) + 1, model });
|
|
675
|
+
this.projectVersion++;
|
|
676
|
+
if (!existing) this.fileSetVersion++;
|
|
677
|
+
return model;
|
|
678
|
+
}
|
|
679
|
+
removeFile(path) {
|
|
680
|
+
if (!this.files.delete(path)) return;
|
|
681
|
+
this.projectVersion++;
|
|
682
|
+
this.fileSetVersion++;
|
|
683
|
+
}
|
|
684
|
+
hasFile(path) {
|
|
685
|
+
return this.files.has(path);
|
|
686
|
+
}
|
|
687
|
+
paths() {
|
|
688
|
+
return [...this.files.keys()];
|
|
689
|
+
}
|
|
690
|
+
getModel(path) {
|
|
691
|
+
return this.files.get(path)?.model;
|
|
692
|
+
}
|
|
693
|
+
getText(path) {
|
|
694
|
+
return this.files.get(path)?.text;
|
|
695
|
+
}
|
|
696
|
+
// ---------------------------------------------------------------- resolving (when typing pauses)
|
|
697
|
+
resolve(path, { diagnostics = true } = {}) {
|
|
698
|
+
return Promise.resolve({ usages: this.usages(path), incoming: this.usagesInto(path), diagnostics: diagnostics ? this.diagnostics(path) : [] });
|
|
699
|
+
}
|
|
700
|
+
/** Usage links from this file: within the file, and to blocks in other files (`toPath`). */
|
|
701
|
+
usages(path) {
|
|
702
|
+
const file = this.files.get(path);
|
|
703
|
+
if (!file) return [];
|
|
704
|
+
if (file.usages && file.usagesProjectVersion === this.projectVersion) return file.usages;
|
|
705
|
+
const program = this.service.getProgram();
|
|
706
|
+
const sf = program?.getSourceFile(toFileName(path));
|
|
707
|
+
const builder = builderOf(file.model);
|
|
708
|
+
if (!program || !sf || !builder || sf.text !== file.text) return [];
|
|
709
|
+
file.usages = builder.collectUsages(sf, program.getTypeChecker(), this.otherFileResolver());
|
|
710
|
+
file.usagesProjectVersion = this.projectVersion;
|
|
711
|
+
return file.usages;
|
|
712
|
+
}
|
|
713
|
+
/** Usages in other files that point at blocks in this file. */
|
|
714
|
+
usagesInto(path) {
|
|
715
|
+
const incoming = [];
|
|
716
|
+
for (const otherPath of this.importersOf(path)) {
|
|
717
|
+
for (const usage of this.usages(otherPath)) {
|
|
718
|
+
if (usage.toPath === path) incoming.push({ ...usage, fromPath: otherPath });
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
return incoming;
|
|
722
|
+
}
|
|
723
|
+
/** Syntax errors, plus type errors when the standard library is loaded and the syntax is valid. */
|
|
724
|
+
diagnostics(path) {
|
|
725
|
+
const file = this.files.get(path);
|
|
726
|
+
if (!file) return [];
|
|
727
|
+
const hasSyntaxErrors = file.model.diagnostics.length > 0;
|
|
728
|
+
if (hasSyntaxErrors || !this.hasLib) return file.model.diagnostics;
|
|
729
|
+
const sf = this.service.getProgram()?.getSourceFile(toFileName(path));
|
|
730
|
+
if (!sf || sf.text !== file.text) return [];
|
|
731
|
+
return this.service.getSemanticDiagnostics(toFileName(path)).map((d) => toCodeDiagnostic(file.model, sf, d, "type"));
|
|
732
|
+
}
|
|
733
|
+
/**
|
|
734
|
+
* Text edits that rename a block and every usage of it, in every file of the project.
|
|
735
|
+
* Usages in other files are renamed too, including imports (no `as` aliases are added).
|
|
736
|
+
*/
|
|
737
|
+
renameBlock(path, blockId, newName) {
|
|
738
|
+
const file = this.files.get(path);
|
|
739
|
+
const block = file?.model.blocks.get(blockId);
|
|
740
|
+
if (!file || !block) return { ok: false, error: "Block not found" };
|
|
741
|
+
const isRenamable2 = block.nameStart >= 0 && block.kind !== "import" && block.kind !== "statement" && block.name !== "constructor";
|
|
742
|
+
if (!isRenamable2) return { ok: false, error: `${block.name} can't be renamed` };
|
|
743
|
+
if (newName === block.name) return { ok: true, edits: {} };
|
|
744
|
+
if (!isValidIdentifier(newName)) return { ok: false, error: `"${newName}" is not a valid name` };
|
|
745
|
+
if (namesVisibleFrom(file.model, block).has(newName)) return { ok: false, error: `${newName} already exists here` };
|
|
746
|
+
if (file.model.diagnostics.length) return { ok: false, error: `Fix the syntax error on line ${file.model.firstErrorLine} first` };
|
|
747
|
+
const fileName2 = toFileName(path);
|
|
748
|
+
const info = this.service.getRenameInfo(fileName2, block.nameStart, { allowRenameOfImportPath: false });
|
|
749
|
+
if (!info.canRename) return { ok: false, error: info.localizedErrorMessage };
|
|
750
|
+
const locations = this.service.findRenameLocations(fileName2, block.nameStart, false, false, { providePrefixAndSuffixTextForRename: true }) ?? [];
|
|
751
|
+
const edits = {};
|
|
752
|
+
for (const location of locations) {
|
|
753
|
+
const locationPath = fromFileName(location.fileName);
|
|
754
|
+
if (!locationPath) continue;
|
|
755
|
+
const text = this.withoutImportAlias(location, newName);
|
|
756
|
+
(edits[locationPath] ??= []).push({ start: location.textSpan.start, end: location.textSpan.start + location.textSpan.length, text });
|
|
757
|
+
}
|
|
758
|
+
return { ok: true, edits };
|
|
759
|
+
}
|
|
760
|
+
// ---------------------------------------------------------------- internals
|
|
761
|
+
/**
|
|
762
|
+
* TypeScript keeps local names stable when renaming an export (`import { newName as old }`).
|
|
763
|
+
* Here a rename should update every usage, so import/export specifiers get the plain new name.
|
|
764
|
+
*/
|
|
765
|
+
withoutImportAlias(location, newName) {
|
|
766
|
+
const prefix = location.prefixText ?? "";
|
|
767
|
+
const suffix = location.suffixText ?? "";
|
|
768
|
+
const isAliasInSpecifier = /\bas\s*$/.test(prefix) || /^\s*as\b/.test(suffix);
|
|
769
|
+
if (isAliasInSpecifier) return newName;
|
|
770
|
+
return prefix + newName + suffix;
|
|
771
|
+
}
|
|
772
|
+
otherFileResolver() {
|
|
773
|
+
return {
|
|
774
|
+
projectNames: this.projectNames(),
|
|
775
|
+
blockInOtherFile: (declaration) => {
|
|
776
|
+
const sf = declaration.getSourceFile();
|
|
777
|
+
const path = fromFileName(sf.fileName);
|
|
778
|
+
const file = path ? this.files.get(path) : void 0;
|
|
779
|
+
if (!path || !file || file.text !== sf.text) return null;
|
|
780
|
+
const id = builderOf(file.model)?.blockIdForDeclaration(declaration);
|
|
781
|
+
return id ? { path, id } : null;
|
|
782
|
+
}
|
|
783
|
+
};
|
|
784
|
+
}
|
|
785
|
+
projectNames() {
|
|
786
|
+
if (this.projectNamesCache?.version === this.projectVersion) return this.projectNamesCache.names;
|
|
787
|
+
const names = /* @__PURE__ */ new Set();
|
|
788
|
+
for (const file of this.files.values()) for (const name of builderOf(file.model)?.names ?? []) names.add(name);
|
|
789
|
+
this.projectNamesCache = { version: this.projectVersion, names };
|
|
790
|
+
return names;
|
|
791
|
+
}
|
|
792
|
+
/** Files whose imports resolve to `path`. */
|
|
793
|
+
importersOf(path) {
|
|
794
|
+
const importers = [];
|
|
795
|
+
for (const [otherPath, file] of this.files) {
|
|
796
|
+
if (otherPath !== path && this.importedPaths(otherPath, file).has(path)) importers.push(otherPath);
|
|
797
|
+
}
|
|
798
|
+
return importers;
|
|
799
|
+
}
|
|
800
|
+
importedPaths(path, file) {
|
|
801
|
+
if (file.importedPaths?.fileSetVersion === this.fileSetVersion) return file.importedPaths.paths;
|
|
802
|
+
const resolved = /* @__PURE__ */ new Set();
|
|
803
|
+
const host = this.moduleResolutionHost();
|
|
804
|
+
for (const block of file.model.roots) {
|
|
805
|
+
if (block.kind !== "import") continue;
|
|
806
|
+
const specifier = block.name;
|
|
807
|
+
const result = ts2.resolveModuleName(specifier, toFileName(path), this.options, host).resolvedModule;
|
|
808
|
+
const target = result && fromFileName(result.resolvedFileName);
|
|
809
|
+
if (target) resolved.add(target);
|
|
810
|
+
}
|
|
811
|
+
file.importedPaths = { paths: resolved, fileSetVersion: this.fileSetVersion };
|
|
812
|
+
return resolved;
|
|
813
|
+
}
|
|
814
|
+
moduleResolutionHost() {
|
|
815
|
+
return {
|
|
816
|
+
fileExists: (name) => this.fileExists(name),
|
|
817
|
+
readFile: (name) => this.readFile(name),
|
|
818
|
+
directoryExists: (dir) => this.directoryExists(dir)
|
|
819
|
+
};
|
|
820
|
+
}
|
|
821
|
+
fileExists(name) {
|
|
822
|
+
if (name.startsWith(LIB_DIR)) return name.slice(LIB_DIR.length) in this.libFiles;
|
|
823
|
+
const path = fromFileName(name);
|
|
824
|
+
return !!path && this.files.has(path);
|
|
825
|
+
}
|
|
826
|
+
readFile(name) {
|
|
827
|
+
if (name.startsWith(LIB_DIR)) return this.libFiles[name.slice(LIB_DIR.length)];
|
|
828
|
+
const path = fromFileName(name);
|
|
829
|
+
return path ? this.files.get(path)?.text : void 0;
|
|
830
|
+
}
|
|
831
|
+
directoryExists(dir) {
|
|
832
|
+
const prefix = dir.replace(/^\//, "").replace(/\/?$/, "/");
|
|
833
|
+
if (prefix === "/") return true;
|
|
834
|
+
for (const path of this.files.keys()) if (path.startsWith(prefix)) return true;
|
|
835
|
+
return false;
|
|
836
|
+
}
|
|
837
|
+
createHost() {
|
|
838
|
+
return {
|
|
839
|
+
getCompilationSettings: () => this.options,
|
|
840
|
+
getProjectVersion: () => String(this.projectVersion),
|
|
841
|
+
getScriptFileNames: () => [...this.files.keys()].map(toFileName),
|
|
842
|
+
getScriptVersion: (name) => {
|
|
843
|
+
if (name.startsWith(LIB_DIR)) return "0";
|
|
844
|
+
const path = fromFileName(name);
|
|
845
|
+
return String((path && this.files.get(path)?.version) ?? 0);
|
|
846
|
+
},
|
|
847
|
+
getScriptSnapshot: (name) => {
|
|
848
|
+
if (name.startsWith(LIB_DIR)) {
|
|
849
|
+
const text = this.libFiles[name.slice(LIB_DIR.length)];
|
|
850
|
+
return text === void 0 ? void 0 : ts2.ScriptSnapshot.fromString(text);
|
|
851
|
+
}
|
|
852
|
+
const path = fromFileName(name);
|
|
853
|
+
const file = path ? this.files.get(path) : void 0;
|
|
854
|
+
if (!file) return void 0;
|
|
855
|
+
file.snapshot ??= new TextSnapshot(file.text);
|
|
856
|
+
return file.snapshot;
|
|
857
|
+
},
|
|
858
|
+
getCurrentDirectory: () => "/",
|
|
859
|
+
getDefaultLibFileName: () => LIB_DIR + "lib.d.ts",
|
|
860
|
+
fileExists: (name) => this.fileExists(name),
|
|
861
|
+
readFile: (name) => this.readFile(name),
|
|
862
|
+
directoryExists: (dir) => this.directoryExists(dir),
|
|
863
|
+
getDirectories: () => [],
|
|
864
|
+
useCaseSensitiveFileNames: () => true
|
|
865
|
+
};
|
|
866
|
+
}
|
|
867
|
+
};
|
|
868
|
+
var TextSnapshot = class _TextSnapshot {
|
|
869
|
+
constructor(text) {
|
|
870
|
+
this.text = text;
|
|
871
|
+
}
|
|
872
|
+
text;
|
|
873
|
+
getText(start, end) {
|
|
874
|
+
return this.text.slice(start, end);
|
|
875
|
+
}
|
|
876
|
+
getLength() {
|
|
877
|
+
return this.text.length;
|
|
878
|
+
}
|
|
879
|
+
getChangeRange(old) {
|
|
880
|
+
if (!(old instanceof _TextSnapshot)) return void 0;
|
|
881
|
+
return changedRegion(old.text, this.text);
|
|
882
|
+
}
|
|
883
|
+
};
|
|
884
|
+
function changedRegion(before, after) {
|
|
885
|
+
let start = 0;
|
|
886
|
+
const shorter = Math.min(before.length, after.length);
|
|
887
|
+
while (start < shorter && before.charCodeAt(start) === after.charCodeAt(start)) start++;
|
|
888
|
+
let fromEnd = 0;
|
|
889
|
+
while (fromEnd < shorter - start && before.charCodeAt(before.length - 1 - fromEnd) === after.charCodeAt(after.length - 1 - fromEnd)) fromEnd++;
|
|
890
|
+
return ts2.createTextChangeRange(ts2.createTextSpan(start, before.length - fromEnd - start), after.length - fromEnd - start);
|
|
891
|
+
}
|
|
892
|
+
function toFileName(path) {
|
|
893
|
+
return "/" + path;
|
|
894
|
+
}
|
|
895
|
+
function fromFileName(name) {
|
|
896
|
+
if (name.startsWith(LIB_DIR) || !name.startsWith("/")) return null;
|
|
897
|
+
return name.slice(1);
|
|
898
|
+
}
|
|
899
|
+
function namesVisibleFrom(model, block) {
|
|
900
|
+
const names = /* @__PURE__ */ new Set();
|
|
901
|
+
const siblings = block.parentId ? model.blocks.get(block.parentId).children : model.roots;
|
|
902
|
+
for (const sibling of siblings) if (sibling !== block) names.add(sibling.name);
|
|
903
|
+
let parent = block.parentId ? model.blocks.get(block.parentId) : void 0;
|
|
904
|
+
const isClassMember = parent?.insert === "class";
|
|
905
|
+
if (isClassMember) return names;
|
|
906
|
+
while (parent) {
|
|
907
|
+
const parentSiblings = parent.parentId ? model.blocks.get(parent.parentId).children : model.roots;
|
|
908
|
+
for (const scopeBlock of [...parent.children, ...parentSiblings]) if (scopeBlock !== block) names.add(scopeBlock.name);
|
|
909
|
+
parent = parent.parentId ? model.blocks.get(parent.parentId) : void 0;
|
|
910
|
+
}
|
|
911
|
+
return names;
|
|
912
|
+
}
|
|
913
|
+
function isValidIdentifier(name) {
|
|
914
|
+
const scanner = ts2.createScanner(ts2.ScriptTarget.Latest, true, ts2.LanguageVariant.Standard, name);
|
|
915
|
+
const token = scanner.scan();
|
|
916
|
+
const isWholeText = scanner.getTokenEnd() === name.length && name.length > 0;
|
|
917
|
+
const isNameToken = token === ts2.SyntaxKind.Identifier || token > ts2.SyntaxKind.LastReservedWord && token <= ts2.SyntaxKind.LastKeyword;
|
|
918
|
+
return isWholeText && isNameToken;
|
|
919
|
+
}
|
|
920
|
+
function compilerOptionsOf(config) {
|
|
921
|
+
const configFileName = toFileName(config.path);
|
|
922
|
+
const json = ts2.parseConfigFileTextToJson(configFileName, config.text);
|
|
923
|
+
if (json.error || !json.config) return {};
|
|
924
|
+
const readConfig = (name) => name === configFileName ? config.text : config.extendedFiles?.[fromFileName(name) ?? ""];
|
|
925
|
+
const host = {
|
|
926
|
+
useCaseSensitiveFileNames: true,
|
|
927
|
+
readDirectory: () => [],
|
|
928
|
+
fileExists: (name) => readConfig(name) !== void 0,
|
|
929
|
+
readFile: readConfig
|
|
930
|
+
};
|
|
931
|
+
const configFolder = configFileName.slice(0, configFileName.lastIndexOf("/") + 1);
|
|
932
|
+
return ts2.parseJsonConfigFileContent(json.config, host, configFolder, void 0, configFileName).options;
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
// src/remoteProject.ts
|
|
936
|
+
var RemoteProject = class {
|
|
937
|
+
constructor(callServer) {
|
|
938
|
+
this.callServer = callServer;
|
|
939
|
+
}
|
|
940
|
+
callServer;
|
|
941
|
+
files = /* @__PURE__ */ new Map();
|
|
942
|
+
/** Load many files at once. They're parsed in the worker; here only when a canvas needs them. */
|
|
943
|
+
setFiles(files) {
|
|
944
|
+
for (const [path, text] of Object.entries(files)) this.files.set(path, { text });
|
|
945
|
+
void this.callServer("setFiles", [files]);
|
|
946
|
+
}
|
|
947
|
+
setFile(path, text) {
|
|
948
|
+
this.setFileText(path, text);
|
|
949
|
+
return this.getModel(path);
|
|
950
|
+
}
|
|
951
|
+
/** Update a file no canvas shows (e.g. typed in a text editor): no parse on this thread. */
|
|
952
|
+
setFileText(path, text) {
|
|
953
|
+
const existing = this.files.get(path);
|
|
954
|
+
if (existing?.text === text) return;
|
|
955
|
+
this.files.set(path, { text, model: existing?.model });
|
|
956
|
+
void this.callServer("setFile", [path, text]);
|
|
957
|
+
}
|
|
958
|
+
removeFile(path) {
|
|
959
|
+
if (!this.files.delete(path)) return;
|
|
960
|
+
void this.callServer("removeFile", [path]);
|
|
961
|
+
}
|
|
962
|
+
setLibFiles(libFiles) {
|
|
963
|
+
void this.callServer("setLibFiles", [libFiles]);
|
|
964
|
+
}
|
|
965
|
+
/** The folder's tsconfig.json (or none, with null). */
|
|
966
|
+
setConfigFile(config) {
|
|
967
|
+
void this.callServer("setConfigFile", [config]);
|
|
968
|
+
}
|
|
969
|
+
getModel(path) {
|
|
970
|
+
const file = this.files.get(path);
|
|
971
|
+
if (!file) return void 0;
|
|
972
|
+
const isModelOfCurrentText = file.model?.text === file.text;
|
|
973
|
+
if (!isModelOfCurrentText) file.model = parseStructure(path, file.text, file.model);
|
|
974
|
+
return file.model;
|
|
975
|
+
}
|
|
976
|
+
getText(path) {
|
|
977
|
+
return this.files.get(path)?.text;
|
|
978
|
+
}
|
|
979
|
+
paths() {
|
|
980
|
+
return [...this.files.keys()];
|
|
981
|
+
}
|
|
982
|
+
resolve(path, options) {
|
|
983
|
+
return this.callServer("resolve", [path, options]);
|
|
984
|
+
}
|
|
985
|
+
renameBlock(path, blockId, newName) {
|
|
986
|
+
return this.callServer("renameBlock", [path, blockId, newName]);
|
|
987
|
+
}
|
|
988
|
+
};
|
|
989
|
+
|
|
990
|
+
// src/projectServer.ts
|
|
991
|
+
var SERVER_METHODS = ["setLibFiles", "setConfigFile", "setFiles", "setFile", "removeFile", "resolve", "renameBlock"];
|
|
992
|
+
var ProjectServer = class {
|
|
993
|
+
project = new Project();
|
|
994
|
+
setLibFiles(libFiles) {
|
|
995
|
+
this.project.setLibFiles(libFiles);
|
|
996
|
+
}
|
|
997
|
+
setConfigFile(config) {
|
|
998
|
+
this.project.setConfigFile(config);
|
|
999
|
+
}
|
|
1000
|
+
setFiles(files) {
|
|
1001
|
+
for (const [path, text] of Object.entries(files)) this.project.setFile(path, text);
|
|
1002
|
+
}
|
|
1003
|
+
setFile(path, text) {
|
|
1004
|
+
this.project.setFile(path, text);
|
|
1005
|
+
}
|
|
1006
|
+
removeFile(path) {
|
|
1007
|
+
this.project.removeFile(path);
|
|
1008
|
+
}
|
|
1009
|
+
resolve(path, options) {
|
|
1010
|
+
return this.project.resolve(path, options);
|
|
1011
|
+
}
|
|
1012
|
+
renameBlock(path, blockId, newName) {
|
|
1013
|
+
return this.project.renameBlock(path, blockId, newName);
|
|
1014
|
+
}
|
|
1015
|
+
};
|
|
1016
|
+
function callProjectServer(server, method, args) {
|
|
1017
|
+
const isServerMethod = SERVER_METHODS.includes(method);
|
|
1018
|
+
if (!isServerMethod) throw new Error(`Unknown project method: ${method}`);
|
|
1019
|
+
return server[method](...args);
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
// src/projectWorker.ts
|
|
1023
|
+
function serveProject(port) {
|
|
1024
|
+
const server = new ProjectServer();
|
|
1025
|
+
port.addEventListener("message", async (event) => {
|
|
1026
|
+
const { id, method, args } = event.data;
|
|
1027
|
+
try {
|
|
1028
|
+
port.postMessage({ id, result: await callProjectServer(server, method, args) });
|
|
1029
|
+
} catch (error) {
|
|
1030
|
+
port.postMessage({ id, error: error instanceof Error ? error.message : String(error) });
|
|
1031
|
+
}
|
|
1032
|
+
});
|
|
1033
|
+
port.start?.();
|
|
1034
|
+
}
|
|
1035
|
+
function connectProjectWorker(port) {
|
|
1036
|
+
let nextId = 0;
|
|
1037
|
+
const waiting = /* @__PURE__ */ new Map();
|
|
1038
|
+
port.addEventListener("message", (event) => {
|
|
1039
|
+
const reply = event.data;
|
|
1040
|
+
const caller = waiting.get(reply.id);
|
|
1041
|
+
waiting.delete(reply.id);
|
|
1042
|
+
if ("error" in reply) caller?.reject(new Error(reply.error));
|
|
1043
|
+
else caller?.resolve(reply.result);
|
|
1044
|
+
});
|
|
1045
|
+
port.start?.();
|
|
1046
|
+
return new RemoteProject((method, args) => {
|
|
1047
|
+
const id = nextId++;
|
|
1048
|
+
port.postMessage({ id, method, args });
|
|
1049
|
+
return new Promise((resolve, reject) => waiting.set(id, { resolve, reject }));
|
|
1050
|
+
});
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
// src/edits.ts
|
|
1054
|
+
import ts3 from "typescript";
|
|
1055
|
+
function applyEdit(text, e) {
|
|
1056
|
+
return text.slice(0, e.start) + e.text + text.slice(e.end);
|
|
1057
|
+
}
|
|
1058
|
+
function applyEdits(text, edits) {
|
|
1059
|
+
const lastFirst = [...edits].sort((a, b) => b.start - a.start);
|
|
1060
|
+
return lastFirst.reduce(applyEdit, text);
|
|
1061
|
+
}
|
|
1062
|
+
function replaceBlock(model, id, code) {
|
|
1063
|
+
const b = must(model, id);
|
|
1064
|
+
return { start: b.start, end: b.end, text: code };
|
|
1065
|
+
}
|
|
1066
|
+
function deleteBlock(model, id) {
|
|
1067
|
+
const b = must(model, id);
|
|
1068
|
+
const t = model.text;
|
|
1069
|
+
let start = t.lastIndexOf("\n", b.start - 1) + 1;
|
|
1070
|
+
if (/\S/.test(t.slice(start, b.start))) start = b.start;
|
|
1071
|
+
let end = b.end;
|
|
1072
|
+
const trail = /^[ \t]*;?[ \t]*(\r?\n)?/.exec(t.slice(end));
|
|
1073
|
+
end += trail[0].length;
|
|
1074
|
+
const before = /(\r?\n[ \t]*)*$/.exec(t.slice(0, start))[0];
|
|
1075
|
+
const after = /^([ \t]*\r?\n)*/.exec(t.slice(end))[0];
|
|
1076
|
+
const blankBefore = (before.match(/\n/g) ?? []).length;
|
|
1077
|
+
const blankAfter = (after.match(/\n/g) ?? []).length;
|
|
1078
|
+
if (blankBefore >= 2 && blankAfter >= 1) end += after.length;
|
|
1079
|
+
else if (start === 0 && blankAfter) end += after.length;
|
|
1080
|
+
return { start, end, text: "" };
|
|
1081
|
+
}
|
|
1082
|
+
function uniqueName(model, parentId, base) {
|
|
1083
|
+
const taken = /* @__PURE__ */ new Set();
|
|
1084
|
+
const sibs = parentId ? must(model, parentId).children : model.roots;
|
|
1085
|
+
for (const s of sibs) taken.add(s.name);
|
|
1086
|
+
for (const r of model.roots) taken.add(r.name);
|
|
1087
|
+
if (!taken.has(base)) return base;
|
|
1088
|
+
let n = 2;
|
|
1089
|
+
while (taken.has(base + n)) n++;
|
|
1090
|
+
return base + n;
|
|
1091
|
+
}
|
|
1092
|
+
function containerFor(model, id) {
|
|
1093
|
+
let b = id ? model.blocks.get(id) ?? null : null;
|
|
1094
|
+
while (b && !b.insert) b = b.parentId ? model.blocks.get(b.parentId) ?? null : null;
|
|
1095
|
+
return b;
|
|
1096
|
+
}
|
|
1097
|
+
function newFunction(model, parentId, base = "newFunction") {
|
|
1098
|
+
const parent = containerFor(model, parentId);
|
|
1099
|
+
const name = uniqueName(model, parent?.id ?? null, base);
|
|
1100
|
+
const u = model.indentUnit;
|
|
1101
|
+
const code = parent?.insert === "class" ? `${name}() {
|
|
1102
|
+
${u}
|
|
1103
|
+
}` : `function ${name}() {
|
|
1104
|
+
${u}
|
|
1105
|
+
}`;
|
|
1106
|
+
const nameAt = code.indexOf(name);
|
|
1107
|
+
return insertCode(model, parent, code, name, [nameAt, nameAt + name.length]);
|
|
1108
|
+
}
|
|
1109
|
+
function copyBlock(model, id) {
|
|
1110
|
+
const b = must(model, id);
|
|
1111
|
+
const parent = b.parentId ? model.blocks.get(b.parentId) : null;
|
|
1112
|
+
return {
|
|
1113
|
+
code: dedent(model.text.slice(b.start, b.end), b.indent),
|
|
1114
|
+
name: b.name,
|
|
1115
|
+
kind: b.kind,
|
|
1116
|
+
fromClass: parent?.insert === "class"
|
|
1117
|
+
};
|
|
1118
|
+
}
|
|
1119
|
+
function pasteBlock(model, parentId, clip) {
|
|
1120
|
+
const parent = containerFor(model, parentId);
|
|
1121
|
+
const toClass = parent?.insert === "class";
|
|
1122
|
+
let code = clip.code;
|
|
1123
|
+
if (toClass && !clip.fromClass) code = toMember(code);
|
|
1124
|
+
if (!toClass && clip.fromClass) code = toTopLevel(code);
|
|
1125
|
+
if (!clip.name) {
|
|
1126
|
+
const r = insertCode(model, parent, code, "", [0, 0]);
|
|
1127
|
+
return { ...r, id: "" };
|
|
1128
|
+
}
|
|
1129
|
+
const name = uniqueName(model, parent?.id ?? null, clip.name);
|
|
1130
|
+
if (name !== clip.name) code = renameDeclaration(code, toClass, name);
|
|
1131
|
+
return insertCode(model, parent, code, name, [0, 0]);
|
|
1132
|
+
}
|
|
1133
|
+
function insertCode(model, parent, code, name, select) {
|
|
1134
|
+
const t = model.text;
|
|
1135
|
+
const key = name;
|
|
1136
|
+
const id = parent ? `${parent.id}/${key}` : key;
|
|
1137
|
+
if (!parent) {
|
|
1138
|
+
const end = t.trimEnd().length;
|
|
1139
|
+
const sep = end === 0 ? "" : "\n\n";
|
|
1140
|
+
return { edit: { start: end, end: t.length, text: sep + code + "\n" }, id, select };
|
|
1141
|
+
}
|
|
1142
|
+
const indent = parent.indent + model.indentUnit;
|
|
1143
|
+
const body = reindent(code, indent);
|
|
1144
|
+
const at = parent.insertAt;
|
|
1145
|
+
const lineStart = t.lastIndexOf("\n", at - 1) + 1;
|
|
1146
|
+
if (!/\S/.test(t.slice(lineStart, at))) {
|
|
1147
|
+
const prevLineEnd = lineStart - 1;
|
|
1148
|
+
const prevLineStart = t.lastIndexOf("\n", prevLineEnd - 1) + 1;
|
|
1149
|
+
const prevLine = t.slice(prevLineStart, prevLineEnd);
|
|
1150
|
+
const opener = /\{\s*$/.test(prevLine);
|
|
1151
|
+
const lead = opener || !/\S/.test(prevLine) ? "" : "\n";
|
|
1152
|
+
const trail = t[at] === "}" ? "" : "\n";
|
|
1153
|
+
return { edit: { start: lineStart, end: lineStart, text: lead + indent + body + "\n" + trail }, id, select };
|
|
1154
|
+
}
|
|
1155
|
+
return { edit: { start: at, end: at, text: "\n" + indent + body + "\n" + parent.indent }, id, select };
|
|
1156
|
+
}
|
|
1157
|
+
function reindent(code, indent) {
|
|
1158
|
+
return code.split("\n").map((l, i) => i === 0 || l === "" ? l : indent + l).join("\n");
|
|
1159
|
+
}
|
|
1160
|
+
function dedent(code, indent) {
|
|
1161
|
+
if (!indent) return code;
|
|
1162
|
+
return code.split("\n").map((l, i) => i > 0 && l.startsWith(indent) ? l.slice(indent.length) : l).join("\n");
|
|
1163
|
+
}
|
|
1164
|
+
function toMember(code) {
|
|
1165
|
+
const fn = /^((?:\/\*[\s\S]*?\*\/|\/\/[^\n]*|\s)*)(?:export\s+)?(?:default\s+)?(async\s+)?function\s*(\*?)\s*/.exec(code);
|
|
1166
|
+
if (fn) return fn[1] + (fn[2] ?? "") + fn[3] + code.slice(fn[0].length);
|
|
1167
|
+
const v = /^((?:\/\*[\s\S]*?\*\/|\/\/[^\n]*|\s)*)(?:export\s+)?(?:const|let|var)\s+/.exec(code);
|
|
1168
|
+
if (v) return v[1] + code.slice(v[0].length);
|
|
1169
|
+
return code;
|
|
1170
|
+
}
|
|
1171
|
+
function toTopLevel(code) {
|
|
1172
|
+
const m = /^((?:\/\*[\s\S]*?\*\/|\/\/[^\n]*|\s)*)((?:(?:public|private|protected|static|readonly|override|abstract)\s+)*)(async\s+)?(\*?)\s*([A-Za-z_$][\w$]*)\s*(<|\()/.exec(code);
|
|
1173
|
+
if (m) return `${m[1]}${m[3] ?? ""}function${m[4] ? "*" : ""} ${m[5]}${m[6]}` + code.slice(m[0].length);
|
|
1174
|
+
const p = /^((?:\/\*[\s\S]*?\*\/|\/\/[^\n]*|\s)*)((?:(?:public|private|protected|static|readonly|override)\s+)*)([A-Za-z_$][\w$]*)(\s*[:=])/.exec(code);
|
|
1175
|
+
if (p) return `${p[1]}const ${p[3]}${p[4]}` + code.slice(p[0].length);
|
|
1176
|
+
return code;
|
|
1177
|
+
}
|
|
1178
|
+
function renameDeclaration(code, inClass, name) {
|
|
1179
|
+
const prefix = inClass ? "class __cc__ {\n" : "";
|
|
1180
|
+
const src = prefix + code + (inClass ? "\n}" : "");
|
|
1181
|
+
const sf = ts3.createSourceFile("snippet.ts", src, ts3.ScriptTarget.Latest, true);
|
|
1182
|
+
let target;
|
|
1183
|
+
const first = sf.statements[0];
|
|
1184
|
+
if (inClass && first && ts3.isClassDeclaration(first)) target = first.members[0]?.name;
|
|
1185
|
+
else if (first && (ts3.isFunctionDeclaration(first) || ts3.isClassDeclaration(first) || ts3.isInterfaceDeclaration(first) || ts3.isTypeAliasDeclaration(first) || ts3.isEnumDeclaration(first)))
|
|
1186
|
+
target = first.name;
|
|
1187
|
+
else if (first && ts3.isVariableStatement(first)) target = first.declarationList.declarations[0]?.name;
|
|
1188
|
+
if (!target) return code;
|
|
1189
|
+
const s = target.getStart(sf) - prefix.length;
|
|
1190
|
+
return code.slice(0, s) + name + code.slice(target.end - prefix.length);
|
|
1191
|
+
}
|
|
1192
|
+
function must(model, id) {
|
|
1193
|
+
const b = model.blocks.get(id);
|
|
1194
|
+
if (!b) throw new Error(`No block ${id}`);
|
|
1195
|
+
return b;
|
|
1196
|
+
}
|
|
1197
|
+
function clipFromText(code) {
|
|
1198
|
+
const text = code.replace(/\s+$/, "");
|
|
1199
|
+
const sf = ts3.createSourceFile("clip.ts", text, ts3.ScriptTarget.Latest, true);
|
|
1200
|
+
const first = sf.statements[0];
|
|
1201
|
+
const diags = sf.parseDiagnostics ?? [];
|
|
1202
|
+
if (first && !diags.length) {
|
|
1203
|
+
let name;
|
|
1204
|
+
let kind = "statement";
|
|
1205
|
+
if (ts3.isFunctionDeclaration(first)) name = first.name?.text, kind = "function";
|
|
1206
|
+
else if (ts3.isClassDeclaration(first)) name = first.name?.text, kind = "class";
|
|
1207
|
+
else if (ts3.isVariableStatement(first)) name = first.declarationList.declarations[0]?.name.getText(), kind = "variable";
|
|
1208
|
+
else if (ts3.isInterfaceDeclaration(first) || ts3.isTypeAliasDeclaration(first) || ts3.isEnumDeclaration(first)) name = first.name.text, kind = "type";
|
|
1209
|
+
if (name) return { code: text, name, kind, fromClass: false };
|
|
1210
|
+
}
|
|
1211
|
+
const wrapped = ts3.createSourceFile("clip.ts", `class __cc__ {
|
|
1212
|
+
${text}
|
|
1213
|
+
}`, ts3.ScriptTarget.Latest, true);
|
|
1214
|
+
const cls = wrapped.statements[0];
|
|
1215
|
+
const wd = wrapped.parseDiagnostics ?? [];
|
|
1216
|
+
if (cls && ts3.isClassDeclaration(cls) && !wd.length && cls.members[0]?.name) {
|
|
1217
|
+
const m = cls.members[0];
|
|
1218
|
+
return { code: text, name: m.name.getText(wrapped), kind: ts3.isPropertyDeclaration(m) ? "variable" : "function", fromClass: true };
|
|
1219
|
+
}
|
|
1220
|
+
return { code: text, name: "", kind: "statement", fromClass: false };
|
|
1221
|
+
}
|
|
1222
|
+
|
|
1223
|
+
// src/diff.ts
|
|
1224
|
+
function diffModels(base, current) {
|
|
1225
|
+
const changes = /* @__PURE__ */ new Map();
|
|
1226
|
+
for (const [id, block] of current.blocks) {
|
|
1227
|
+
const baseBlock = base.blocks.get(id);
|
|
1228
|
+
if (!baseBlock) changes.set(id, "added");
|
|
1229
|
+
else if (sameCode(fullCode(base, baseBlock), fullCode(current, block))) changes.set(id, "unchanged");
|
|
1230
|
+
else if (sameCode(ownCode(base, baseBlock), ownCode(current, block))) changes.set(id, "containsChanges");
|
|
1231
|
+
else changes.set(id, "changed");
|
|
1232
|
+
}
|
|
1233
|
+
const removed = [...base.blocks.values()].filter((block) => {
|
|
1234
|
+
const isGone = !current.blocks.has(block.id);
|
|
1235
|
+
const isParentGone = !!block.parentId && !current.blocks.has(block.parentId);
|
|
1236
|
+
return isGone && !isParentGone;
|
|
1237
|
+
});
|
|
1238
|
+
return { base, changes, removed };
|
|
1239
|
+
}
|
|
1240
|
+
function diffOwnCode(base, baseBlock, current, block) {
|
|
1241
|
+
const before = baseBlock ? codeLines(ownCode(base, baseBlock)) : [];
|
|
1242
|
+
return diffLines(before, codeLines(ownCode(current, block)));
|
|
1243
|
+
}
|
|
1244
|
+
function diffLines(before, after) {
|
|
1245
|
+
let prefix = 0;
|
|
1246
|
+
while (prefix < before.length && prefix < after.length && before[prefix] === after[prefix]) prefix++;
|
|
1247
|
+
let suffix = 0;
|
|
1248
|
+
while (suffix < before.length - prefix && suffix < after.length - prefix && before[before.length - 1 - suffix] === after[after.length - 1 - suffix]) suffix++;
|
|
1249
|
+
const oldMiddle = before.slice(prefix, before.length - suffix);
|
|
1250
|
+
const newMiddle = after.slice(prefix, after.length - suffix);
|
|
1251
|
+
const common = Array.from({ length: oldMiddle.length + 1 }, () => new Uint32Array(newMiddle.length + 1));
|
|
1252
|
+
for (let i2 = oldMiddle.length - 1; i2 >= 0; i2--) {
|
|
1253
|
+
for (let j2 = newMiddle.length - 1; j2 >= 0; j2--) {
|
|
1254
|
+
common[i2][j2] = oldMiddle[i2] === newMiddle[j2] ? common[i2 + 1][j2 + 1] + 1 : Math.max(common[i2 + 1][j2], common[i2][j2 + 1]);
|
|
1255
|
+
}
|
|
1256
|
+
}
|
|
1257
|
+
const middle = [];
|
|
1258
|
+
let i = 0;
|
|
1259
|
+
let j = 0;
|
|
1260
|
+
while (i < oldMiddle.length || j < newMiddle.length) {
|
|
1261
|
+
if (i < oldMiddle.length && j < newMiddle.length && oldMiddle[i] === newMiddle[j]) {
|
|
1262
|
+
middle.push({ kind: "same", text: newMiddle[j] });
|
|
1263
|
+
i++;
|
|
1264
|
+
j++;
|
|
1265
|
+
} else if (i < oldMiddle.length && (j === newMiddle.length || common[i + 1][j] >= common[i][j + 1])) {
|
|
1266
|
+
middle.push({ kind: "removed", text: oldMiddle[i++] });
|
|
1267
|
+
} else {
|
|
1268
|
+
middle.push({ kind: "added", text: newMiddle[j++] });
|
|
1269
|
+
}
|
|
1270
|
+
}
|
|
1271
|
+
const same = (text) => ({ kind: "same", text });
|
|
1272
|
+
return [...before.slice(0, prefix).map(same), ...middle, ...after.slice(after.length - suffix).map(same)];
|
|
1273
|
+
}
|
|
1274
|
+
function diffPreview(lines, max) {
|
|
1275
|
+
const isNearChange = (index) => [index - 1, index, index + 1].some((near) => lines[near] && lines[near].kind !== "same");
|
|
1276
|
+
const changesWithContext = lines.filter((_, index) => isNearChange(index));
|
|
1277
|
+
const shown = changesWithContext.length <= max ? changesWithContext : lines.filter((line) => line.kind !== "same");
|
|
1278
|
+
return { lines: shown.slice(0, max), more: Math.max(0, shown.length - max) };
|
|
1279
|
+
}
|
|
1280
|
+
function sameCode(before, after) {
|
|
1281
|
+
return codeLines(before).join("\n") === codeLines(after).join("\n");
|
|
1282
|
+
}
|
|
1283
|
+
function fullCode(model, block) {
|
|
1284
|
+
return model.text.slice(block.start, block.end);
|
|
1285
|
+
}
|
|
1286
|
+
function ownCode(model, block) {
|
|
1287
|
+
return codeWithoutChildren(model.text, [block.start, block.end], block.children);
|
|
1288
|
+
}
|
|
1289
|
+
function codeLines(code) {
|
|
1290
|
+
const lines = code.split("\n").map((line) => line.replace(/\s+$/, "").replace(/\t/g, " ")).filter((line) => line);
|
|
1291
|
+
const indents = lines.slice(1).filter((line) => line).map((line) => /^ */.exec(line)[0].length);
|
|
1292
|
+
const commonIndent = indents.length ? Math.min(...indents) : 0;
|
|
1293
|
+
return lines.map((line, index) => index === 0 ? line.trimStart() : line.slice(Math.min(commonIndent, /^ */.exec(line)[0].length)));
|
|
1294
|
+
}
|
|
1295
|
+
|
|
1296
|
+
// src/conflicts.ts
|
|
1297
|
+
var CURRENT_MARKER = /^<{7}(?: (.*))?\r?$/;
|
|
1298
|
+
var BASE_MARKER = /^\|{7}(?: .*)?\r?$/;
|
|
1299
|
+
var SEPARATOR = /^={7}\r?$/;
|
|
1300
|
+
var INCOMING_MARKER = /^>{7}(?: (.*))?\r?$/;
|
|
1301
|
+
function findConflicts(text) {
|
|
1302
|
+
const hunks = [];
|
|
1303
|
+
let open = null;
|
|
1304
|
+
let lineStart = 0;
|
|
1305
|
+
while (lineStart < text.length) {
|
|
1306
|
+
const newline = text.indexOf("\n", lineStart);
|
|
1307
|
+
const lineEnd = newline === -1 ? text.length : newline;
|
|
1308
|
+
const nextLineStart = newline === -1 ? text.length : newline + 1;
|
|
1309
|
+
const line = text.slice(lineStart, lineEnd);
|
|
1310
|
+
const currentMarker = CURRENT_MARKER.exec(line);
|
|
1311
|
+
if (currentMarker) {
|
|
1312
|
+
open = { start: lineStart, currentLabel: currentMarker[1] ?? "", currentStart: nextLineStart };
|
|
1313
|
+
} else if (open && BASE_MARKER.test(line) && open.currentEnd === void 0) {
|
|
1314
|
+
open.currentEnd = lineStart;
|
|
1315
|
+
open.baseStart = nextLineStart;
|
|
1316
|
+
} else if (open && SEPARATOR.test(line) && open.incomingStart === void 0) {
|
|
1317
|
+
if (open.baseStart !== void 0) open.baseEnd = lineStart;
|
|
1318
|
+
else open.currentEnd = lineStart;
|
|
1319
|
+
open.incomingStart = nextLineStart;
|
|
1320
|
+
} else if (open && open.incomingStart !== void 0) {
|
|
1321
|
+
const incomingMarker = INCOMING_MARKER.exec(line);
|
|
1322
|
+
if (incomingMarker) {
|
|
1323
|
+
hunks.push({
|
|
1324
|
+
start: open.start,
|
|
1325
|
+
end: nextLineStart,
|
|
1326
|
+
current: { start: open.currentStart, end: open.currentEnd },
|
|
1327
|
+
incoming: { start: open.incomingStart, end: lineStart },
|
|
1328
|
+
base: open.baseStart !== void 0 ? { start: open.baseStart, end: open.baseEnd } : void 0,
|
|
1329
|
+
currentLabel: open.currentLabel,
|
|
1330
|
+
incomingLabel: incomingMarker[1] ?? ""
|
|
1331
|
+
});
|
|
1332
|
+
open = null;
|
|
1333
|
+
}
|
|
1334
|
+
}
|
|
1335
|
+
lineStart = nextLineStart;
|
|
1336
|
+
}
|
|
1337
|
+
return hunks;
|
|
1338
|
+
}
|
|
1339
|
+
function conflictSideText(text, hunks, side) {
|
|
1340
|
+
const pieces = [];
|
|
1341
|
+
const hunkRanges = [];
|
|
1342
|
+
let sideText = "";
|
|
1343
|
+
let fileOffset = 0;
|
|
1344
|
+
const copy = (start, end) => {
|
|
1345
|
+
pieces.push([sideText.length, start, end - start]);
|
|
1346
|
+
sideText += text.slice(start, end);
|
|
1347
|
+
};
|
|
1348
|
+
for (const hunk of hunks) {
|
|
1349
|
+
copy(fileOffset, hunk.start);
|
|
1350
|
+
const code = hunk[side];
|
|
1351
|
+
const rangeStart = sideText.length;
|
|
1352
|
+
copy(code.start, code.end);
|
|
1353
|
+
hunkRanges.push({ start: rangeStart, end: sideText.length });
|
|
1354
|
+
fileOffset = hunk.end;
|
|
1355
|
+
}
|
|
1356
|
+
copy(fileOffset, text.length);
|
|
1357
|
+
return {
|
|
1358
|
+
text: sideText,
|
|
1359
|
+
hunkRanges,
|
|
1360
|
+
toFileOffset(offset) {
|
|
1361
|
+
const piece = pieces.findLast(([sideStart]) => sideStart <= offset) ?? pieces[0];
|
|
1362
|
+
return piece[1] + Math.min(offset - piece[0], piece[2]);
|
|
1363
|
+
}
|
|
1364
|
+
};
|
|
1365
|
+
}
|
|
1366
|
+
function resolveConflict(text, hunk, choice) {
|
|
1367
|
+
const code = (side) => text.slice(side.start, side.end);
|
|
1368
|
+
const resolved = choice === "both" ? code(hunk.current) + code(hunk.incoming) : code(hunk[choice]);
|
|
1369
|
+
return { start: hunk.start, end: hunk.end, text: resolved };
|
|
1370
|
+
}
|
|
1371
|
+
|
|
1372
|
+
// src/layoutRules.ts
|
|
1373
|
+
var BAND_ORDER = ["data", "types", "main", "bottom"];
|
|
1374
|
+
var ZONE_RULES = [
|
|
1375
|
+
{ zone: "left", description: "Imports on the left", matches: (block) => block.kind === "import" },
|
|
1376
|
+
{ zone: "data", description: "Constants and other data at the top", matches: (block) => block.kind === "variable" },
|
|
1377
|
+
{ zone: "types", description: "Then interfaces, types and enums", matches: (block) => block.kind === "type" },
|
|
1378
|
+
{ zone: "bottom", description: "Code that runs when the file loads, at the bottom", matches: (block) => block.kind === "statement" },
|
|
1379
|
+
{ zone: "main", description: "Functions, classes and the rest in between", matches: () => true }
|
|
1380
|
+
];
|
|
1381
|
+
var STACK_RULES = [
|
|
1382
|
+
{
|
|
1383
|
+
description: "A getter above its setter: getTotal / setTotal, and get total / set total",
|
|
1384
|
+
pairs(blocks) {
|
|
1385
|
+
const pairs = [];
|
|
1386
|
+
for (const getter of blocks) {
|
|
1387
|
+
const accessed = accessedName(getter, "get");
|
|
1388
|
+
const setter = accessed && blocks.find((block) => block !== getter && accessedName(block, "set") === accessed);
|
|
1389
|
+
if (setter) pairs.push([getter, setter]);
|
|
1390
|
+
}
|
|
1391
|
+
return pairs;
|
|
1392
|
+
}
|
|
1393
|
+
}
|
|
1394
|
+
];
|
|
1395
|
+
var RELATEDNESS_RULES = [
|
|
1396
|
+
{
|
|
1397
|
+
description: "One calls or uses the other",
|
|
1398
|
+
score: (a, b, factsOf) => factsOf(a).uses.has(b.id) || factsOf(b).uses.has(a.id) ? 3 : 0
|
|
1399
|
+
},
|
|
1400
|
+
{
|
|
1401
|
+
description: "They use the same things",
|
|
1402
|
+
score: (a, b, factsOf) => [...factsOf(a).uses].filter((id) => factsOf(b).uses.has(id)).length
|
|
1403
|
+
},
|
|
1404
|
+
{
|
|
1405
|
+
description: "Their names start the same way (formatPrice, formatDate)",
|
|
1406
|
+
score: (a, b) => firstWord(a.name) && firstWord(a.name) === firstWord(b.name) ? 1 : 0
|
|
1407
|
+
}
|
|
1408
|
+
];
|
|
1409
|
+
var NEWSPAPER_DEPTH = 3;
|
|
1410
|
+
var isFunctionLike = (block) => block.kind === "function";
|
|
1411
|
+
function accessedName(block, accessor) {
|
|
1412
|
+
if (block.kind !== "function") return void 0;
|
|
1413
|
+
if (block.tags.includes(accessor)) return block.name;
|
|
1414
|
+
const match = new RegExp(`^${accessor}([A-Z_$][\\w$]*)$`).exec(block.name);
|
|
1415
|
+
return match ? match[1].charAt(0).toLowerCase() + match[1].slice(1) : void 0;
|
|
1416
|
+
}
|
|
1417
|
+
function firstWord(name) {
|
|
1418
|
+
const match = /^([a-z]+)[A-Z0-9_]/.exec(name);
|
|
1419
|
+
const isAccessorWord = match?.[1] === "get" || match?.[1] === "set";
|
|
1420
|
+
return match && !isAccessorWord ? match[1] : void 0;
|
|
1421
|
+
}
|
|
1422
|
+
|
|
1423
|
+
// src/arrange.ts
|
|
1424
|
+
function arrangeSiblings(blocks, sizeOf, text, gap, isFile) {
|
|
1425
|
+
const factsById = siblingFacts(blocks, text);
|
|
1426
|
+
const factsOf = (block) => factsById.get(block.id);
|
|
1427
|
+
const units = stackedUnits(blocks);
|
|
1428
|
+
const zoneUnits = (zone) => units.filter((unit) => unit.zone === zone);
|
|
1429
|
+
const bands = BAND_ORDER.map((zone) => {
|
|
1430
|
+
const inZone = zoneUnits(zone);
|
|
1431
|
+
return zone === "main" ? orderByRelatedness(newspaperTrees(inZone, factsOf), factsOf) : inZone;
|
|
1432
|
+
});
|
|
1433
|
+
const positions = /* @__PURE__ */ new Map();
|
|
1434
|
+
let left = 0;
|
|
1435
|
+
const leftColumn = zoneUnits("left");
|
|
1436
|
+
if (leftColumn.length) {
|
|
1437
|
+
let y2 = 0;
|
|
1438
|
+
for (const unit of leftColumn) {
|
|
1439
|
+
place(unit.arrangement, 0, y2, sizeOf, gap, positions);
|
|
1440
|
+
y2 += measure(unit.arrangement, sizeOf, gap).h + 12;
|
|
1441
|
+
}
|
|
1442
|
+
left = Math.max(...leftColumn.map((unit) => measure(unit.arrangement, sizeOf, gap).w)) + gap * 2;
|
|
1443
|
+
}
|
|
1444
|
+
const bandUnits = bands.flat();
|
|
1445
|
+
const area = bandUnits.reduce((sum, unit) => {
|
|
1446
|
+
const size = measure(unit.arrangement, sizeOf, gap);
|
|
1447
|
+
return sum + (size.w + gap) * (size.h + gap);
|
|
1448
|
+
}, 0);
|
|
1449
|
+
const widest = Math.max(0, ...bandUnits.map((unit) => measure(unit.arrangement, sizeOf, gap).w));
|
|
1450
|
+
const rowWidth = Math.max(widest, Math.sqrt(area * (isFile ? 1.7 : 2.4)));
|
|
1451
|
+
let y = 0;
|
|
1452
|
+
for (const band of bands.filter((band2) => band2.length)) {
|
|
1453
|
+
let x = 0;
|
|
1454
|
+
let rowHeight = 0;
|
|
1455
|
+
for (const unit of band) {
|
|
1456
|
+
const size = measure(unit.arrangement, sizeOf, gap);
|
|
1457
|
+
if (x > 0 && x + size.w > rowWidth) {
|
|
1458
|
+
x = 0;
|
|
1459
|
+
y += rowHeight + gap;
|
|
1460
|
+
rowHeight = 0;
|
|
1461
|
+
}
|
|
1462
|
+
place(unit.arrangement, left + x, y, sizeOf, gap, positions);
|
|
1463
|
+
x += size.w + gap;
|
|
1464
|
+
rowHeight = Math.max(rowHeight, size.h);
|
|
1465
|
+
}
|
|
1466
|
+
y += rowHeight + gap;
|
|
1467
|
+
}
|
|
1468
|
+
return positions;
|
|
1469
|
+
}
|
|
1470
|
+
function stackedUnits(blocks) {
|
|
1471
|
+
const zoneOf = (block) => ZONE_RULES.find((rule) => rule.matches(block)).zone;
|
|
1472
|
+
const unitOf = /* @__PURE__ */ new Map();
|
|
1473
|
+
const units = blocks.map((block, order) => {
|
|
1474
|
+
const unit = { members: [block], arrangement: { block: block.id }, zone: zoneOf(block), order };
|
|
1475
|
+
unitOf.set(block, unit);
|
|
1476
|
+
return unit;
|
|
1477
|
+
});
|
|
1478
|
+
for (const rule of STACK_RULES) {
|
|
1479
|
+
for (const [top, bottom] of rule.pairs(blocks)) {
|
|
1480
|
+
const topUnit = unitOf.get(top);
|
|
1481
|
+
const bottomUnit = unitOf.get(bottom);
|
|
1482
|
+
const canStack = topUnit !== bottomUnit && topUnit.members.length === 1 && bottomUnit.members.length === 1 && topUnit.zone === bottomUnit.zone;
|
|
1483
|
+
if (!canStack) continue;
|
|
1484
|
+
topUnit.members.push(bottom);
|
|
1485
|
+
topUnit.arrangement = { column: [{ block: top.id }, { block: bottom.id }] };
|
|
1486
|
+
topUnit.order = Math.min(topUnit.order, bottomUnit.order);
|
|
1487
|
+
unitOf.set(bottom, topUnit);
|
|
1488
|
+
units.splice(units.indexOf(bottomUnit), 1);
|
|
1489
|
+
}
|
|
1490
|
+
}
|
|
1491
|
+
return units;
|
|
1492
|
+
}
|
|
1493
|
+
function newspaperTrees(units, factsOf) {
|
|
1494
|
+
const unitOfBlock = /* @__PURE__ */ new Map();
|
|
1495
|
+
for (const unit of units) for (const member of unit.members) unitOfBlock.set(member.id, unit);
|
|
1496
|
+
const isFunctionUnit = (unit) => unit.members.some(isFunctionLike);
|
|
1497
|
+
const calledUnits = (unit) => {
|
|
1498
|
+
if (!isFunctionUnit(unit)) return [];
|
|
1499
|
+
const called = [];
|
|
1500
|
+
for (const member of unit.members) {
|
|
1501
|
+
for (const id of factsOf(member).calls) {
|
|
1502
|
+
const calledUnit = unitOfBlock.get(id);
|
|
1503
|
+
if (calledUnit && calledUnit !== unit && isFunctionUnit(calledUnit) && !called.includes(calledUnit)) called.push(calledUnit);
|
|
1504
|
+
}
|
|
1505
|
+
}
|
|
1506
|
+
return called;
|
|
1507
|
+
};
|
|
1508
|
+
const isCalledBySibling = new Set(units.flatMap(calledUnits));
|
|
1509
|
+
const claimed = /* @__PURE__ */ new Set();
|
|
1510
|
+
const tree = (unit, depth) => {
|
|
1511
|
+
if (depth >= NEWSPAPER_DEPTH) return unit.arrangement;
|
|
1512
|
+
const callees = calledUnits(unit).filter((callee) => !claimed.has(callee));
|
|
1513
|
+
for (const callee of callees) claimed.add(callee);
|
|
1514
|
+
if (!callees.length) return unit.arrangement;
|
|
1515
|
+
return { row: [unit.arrangement, { column: callees.map((callee) => tree(callee, depth + 1)) }] };
|
|
1516
|
+
};
|
|
1517
|
+
const trees = [];
|
|
1518
|
+
const starts = [...units.filter((unit) => !isCalledBySibling.has(unit)), ...units];
|
|
1519
|
+
for (const unit of starts) {
|
|
1520
|
+
if (claimed.has(unit)) continue;
|
|
1521
|
+
claimed.add(unit);
|
|
1522
|
+
const arrangement = tree(unit, 0);
|
|
1523
|
+
const members = [...units].filter((other) => other === unit || claimed.has(other) && contains(arrangement, other));
|
|
1524
|
+
trees.push({ ...unit, arrangement, members: members.flatMap((member) => member.members) });
|
|
1525
|
+
}
|
|
1526
|
+
return trees;
|
|
1527
|
+
}
|
|
1528
|
+
function orderByRelatedness(units, factsOf) {
|
|
1529
|
+
const relatedness = (a, b) => a.members.reduce((sum, x) => sum + b.members.reduce((inner, y) => inner + RELATEDNESS_RULES.reduce((total, rule) => total + rule.score(x, y, factsOf), 0), 0), 0);
|
|
1530
|
+
const left = [...units].sort((a, b) => a.order - b.order);
|
|
1531
|
+
const ordered = [];
|
|
1532
|
+
while (left.length) {
|
|
1533
|
+
const last = ordered[ordered.length - 1];
|
|
1534
|
+
let next = left[0];
|
|
1535
|
+
if (last) {
|
|
1536
|
+
let best = 0;
|
|
1537
|
+
for (const candidate of left) {
|
|
1538
|
+
const score = relatedness(last, candidate);
|
|
1539
|
+
if (score > best) {
|
|
1540
|
+
best = score;
|
|
1541
|
+
next = candidate;
|
|
1542
|
+
}
|
|
1543
|
+
}
|
|
1544
|
+
}
|
|
1545
|
+
left.splice(left.indexOf(next), 1);
|
|
1546
|
+
ordered.push(next);
|
|
1547
|
+
}
|
|
1548
|
+
return ordered;
|
|
1549
|
+
}
|
|
1550
|
+
function siblingFacts(blocks, text) {
|
|
1551
|
+
const idsByName = /* @__PURE__ */ new Map();
|
|
1552
|
+
for (const block of blocks) idsByName.set(block.name, [...idsByName.get(block.name) ?? [], block.id]);
|
|
1553
|
+
const facts = /* @__PURE__ */ new Map();
|
|
1554
|
+
for (const block of blocks) {
|
|
1555
|
+
const code = codeWithoutChildren(text, [block.start, block.end], block.children);
|
|
1556
|
+
const calls = [];
|
|
1557
|
+
const uses = /* @__PURE__ */ new Set();
|
|
1558
|
+
for (const match of code.matchAll(/[A-Za-z_$][\w$]*/g)) {
|
|
1559
|
+
const ids = idsByName.get(match[0])?.filter((id) => id !== block.id);
|
|
1560
|
+
if (!ids?.length) continue;
|
|
1561
|
+
const isCall = /^\s*\(/.test(code.slice(match.index + match[0].length, match.index + match[0].length + 40));
|
|
1562
|
+
for (const id of ids) {
|
|
1563
|
+
uses.add(id);
|
|
1564
|
+
if (isCall && !calls.includes(id)) calls.push(id);
|
|
1565
|
+
}
|
|
1566
|
+
}
|
|
1567
|
+
facts.set(block.id, { calls, uses });
|
|
1568
|
+
}
|
|
1569
|
+
return facts;
|
|
1570
|
+
}
|
|
1571
|
+
var measured = /* @__PURE__ */ new WeakMap();
|
|
1572
|
+
function measure(arrangement, sizeOf, gap) {
|
|
1573
|
+
if ("block" in arrangement) return sizeOf(arrangement.block);
|
|
1574
|
+
const known = measured.get(arrangement);
|
|
1575
|
+
if (known) return known;
|
|
1576
|
+
const size = measureGroup(arrangement, sizeOf, gap);
|
|
1577
|
+
measured.set(arrangement, size);
|
|
1578
|
+
return size;
|
|
1579
|
+
}
|
|
1580
|
+
function measureGroup(arrangement, sizeOf, gap) {
|
|
1581
|
+
const sizes = ("row" in arrangement ? arrangement.row : arrangement.column).map((item) => measure(item, sizeOf, gap));
|
|
1582
|
+
const gaps = gap * (sizes.length - 1);
|
|
1583
|
+
return "row" in arrangement ? { w: sizes.reduce((sum, size) => sum + size.w, 0) + gaps, h: Math.max(...sizes.map((size) => size.h)) } : { w: Math.max(...sizes.map((size) => size.w)), h: sizes.reduce((sum, size) => sum + size.h, 0) + gaps };
|
|
1584
|
+
}
|
|
1585
|
+
function place(arrangement, x, y, sizeOf, gap, positions) {
|
|
1586
|
+
if ("block" in arrangement) {
|
|
1587
|
+
positions.set(arrangement.block, { x: Math.round(x), y: Math.round(y) });
|
|
1588
|
+
return;
|
|
1589
|
+
}
|
|
1590
|
+
const isRow = "row" in arrangement;
|
|
1591
|
+
let offset = 0;
|
|
1592
|
+
for (const item of isRow ? arrangement.row : arrangement.column) {
|
|
1593
|
+
place(item, isRow ? x + offset : x, isRow ? y : y + offset, sizeOf, gap, positions);
|
|
1594
|
+
const size = measure(item, sizeOf, gap);
|
|
1595
|
+
offset += (isRow ? size.w : size.h) + gap;
|
|
1596
|
+
}
|
|
1597
|
+
}
|
|
1598
|
+
function contains(arrangement, unit) {
|
|
1599
|
+
if ("block" in arrangement) return unit.members.some((member) => member.id === arrangement.block);
|
|
1600
|
+
return ("row" in arrangement ? arrangement.row : arrangement.column).some((item) => contains(item, unit));
|
|
1601
|
+
}
|
|
1602
|
+
|
|
1603
|
+
// src/layout.ts
|
|
1604
|
+
var M = {
|
|
1605
|
+
headerH: 32,
|
|
1606
|
+
headerCharW: 7.9,
|
|
1607
|
+
// 13px monospace
|
|
1608
|
+
previewLineH: 15,
|
|
1609
|
+
previewCharW: 6.7,
|
|
1610
|
+
// 11px monospace
|
|
1611
|
+
previewPad: 10,
|
|
1612
|
+
pad: 14,
|
|
1613
|
+
// inner padding of containers
|
|
1614
|
+
gap: 28,
|
|
1615
|
+
// spacing between sibling blocks
|
|
1616
|
+
minW: 180,
|
|
1617
|
+
maxW: 460,
|
|
1618
|
+
miniH: 46,
|
|
1619
|
+
// collapsed container: faint mini-map of children
|
|
1620
|
+
fileHeader: 44,
|
|
1621
|
+
filePad: 48
|
|
1622
|
+
};
|
|
1623
|
+
function emptyLayout() {
|
|
1624
|
+
return { version: 1, blocks: {} };
|
|
1625
|
+
}
|
|
1626
|
+
function computeGeometry(model, layout, hints = {}, opts = {}) {
|
|
1627
|
+
const resolve = opts.resolveOverlaps ?? true;
|
|
1628
|
+
const geo = /* @__PURE__ */ new Map();
|
|
1629
|
+
const assigned = {};
|
|
1630
|
+
const collapsed = new Set(layout.collapsed ?? []);
|
|
1631
|
+
const pos = (id) => layout.blocks[id] ?? assigned[id];
|
|
1632
|
+
const measure2 = (b, hidden) => {
|
|
1633
|
+
const isCollapsed = collapsed.has(b.id) && b.children.length > 0;
|
|
1634
|
+
const kids = b.children.map((c) => measure2(c, hidden || isCollapsed));
|
|
1635
|
+
placeSiblings(kids, false);
|
|
1636
|
+
const head = headerChars(b);
|
|
1637
|
+
const previewChars = Math.max(0, ...b.preview.map((l) => l.length));
|
|
1638
|
+
const leafW = clamp(Math.ceil(Math.max(head * M.headerCharW, previewChars * M.previewCharW) + 28), M.minW, M.maxW);
|
|
1639
|
+
const lines = b.preview.length + (b.previewMore ? 1 : 0);
|
|
1640
|
+
const previewH = lines ? lines * M.previewLineH + M.previewPad : 4;
|
|
1641
|
+
let w = leafW;
|
|
1642
|
+
let h = M.headerH + previewH;
|
|
1643
|
+
let contentTop = h;
|
|
1644
|
+
if (kids.length && isCollapsed) {
|
|
1645
|
+
h += M.miniH;
|
|
1646
|
+
} else if (kids.length) {
|
|
1647
|
+
contentTop = h + 6;
|
|
1648
|
+
const right = Math.max(...kids.map((k) => k.x + k.w));
|
|
1649
|
+
const bottom = Math.max(...kids.map((k) => k.y + k.h));
|
|
1650
|
+
w = Math.max(leafW, right + M.pad * 2);
|
|
1651
|
+
h = contentTop + bottom + M.pad;
|
|
1652
|
+
}
|
|
1653
|
+
const g = { id: b.id, block: b, x: 0, y: 0, w, h, ax: 0, ay: 0, contentTop, collapsed: isCollapsed, hidden };
|
|
1654
|
+
geo.set(b.id, g);
|
|
1655
|
+
return g;
|
|
1656
|
+
};
|
|
1657
|
+
const placeSiblings = (sibs, isFile) => {
|
|
1658
|
+
if (!sibs.length) return;
|
|
1659
|
+
const isNeverLaidOut = sibs.every((g) => !layout.blocks[g.id] && !hints[g.id]);
|
|
1660
|
+
if (isNeverLaidOut) {
|
|
1661
|
+
const sizeOf = (id) => geo.get(id);
|
|
1662
|
+
const arranged = arrangeSiblings(sibs.map((g) => g.block), sizeOf, model.text, M.gap, isFile);
|
|
1663
|
+
for (const g of sibs) assigned[g.id] = arranged.get(g.id);
|
|
1664
|
+
}
|
|
1665
|
+
const placed = [];
|
|
1666
|
+
const unplaced = [];
|
|
1667
|
+
for (const g of sibs) {
|
|
1668
|
+
const p = pos(g.id);
|
|
1669
|
+
if (p) {
|
|
1670
|
+
g.x = p.x;
|
|
1671
|
+
g.y = p.y;
|
|
1672
|
+
placed.push(g);
|
|
1673
|
+
} else unplaced.push(g);
|
|
1674
|
+
}
|
|
1675
|
+
for (const g of unplaced) {
|
|
1676
|
+
const idx = sibs.indexOf(g);
|
|
1677
|
+
const prev = sibs.slice(0, idx).reverse().find((s) => placed.includes(s));
|
|
1678
|
+
const hint = hints[g.id];
|
|
1679
|
+
const p = hint ? freeSpotNear(hint, g, placed, !isFile) : findFreeSpot(g, placed, prev, !isFile);
|
|
1680
|
+
g.x = p.x;
|
|
1681
|
+
g.y = p.y;
|
|
1682
|
+
assigned[g.id] = p;
|
|
1683
|
+
placed.push(g);
|
|
1684
|
+
}
|
|
1685
|
+
if (resolve) pushApart(sibs, assigned);
|
|
1686
|
+
};
|
|
1687
|
+
const roots = model.roots.map((r) => measure2(r, false));
|
|
1688
|
+
placeSiblings(roots, true);
|
|
1689
|
+
const order = [];
|
|
1690
|
+
const place2 = (g, ox, oy) => {
|
|
1691
|
+
g.ax = ox + g.x;
|
|
1692
|
+
g.ay = oy + g.y;
|
|
1693
|
+
order.push(g);
|
|
1694
|
+
for (const c of g.block.children) place2(geo.get(c.id), g.ax + M.pad, g.ay + g.contentTop);
|
|
1695
|
+
};
|
|
1696
|
+
for (const r of roots) place2(r, 0, 0);
|
|
1697
|
+
let minX = 0, minY = 0, maxX = 640, maxY = 360;
|
|
1698
|
+
if (roots.length) {
|
|
1699
|
+
minX = Math.min(...roots.map((g) => g.x));
|
|
1700
|
+
minY = Math.min(...roots.map((g) => g.y));
|
|
1701
|
+
maxX = Math.max(...roots.map((g) => g.x + g.w));
|
|
1702
|
+
maxY = Math.max(...roots.map((g) => g.y + g.h));
|
|
1703
|
+
}
|
|
1704
|
+
const frame = {
|
|
1705
|
+
x: minX - M.filePad,
|
|
1706
|
+
y: minY - M.filePad - M.fileHeader,
|
|
1707
|
+
w: Math.max(maxX - minX, 480) + M.filePad * 2,
|
|
1708
|
+
h: Math.max(maxY - minY, 240) + M.filePad * 2 + M.fileHeader
|
|
1709
|
+
};
|
|
1710
|
+
return { geo, order, frame, assigned };
|
|
1711
|
+
}
|
|
1712
|
+
function pushApart(sibs, assigned) {
|
|
1713
|
+
for (let iter = 0; iter < 100; iter++) {
|
|
1714
|
+
let moved = false;
|
|
1715
|
+
const sorted = [...sibs].sort((a, b) => a.y - b.y || a.x - b.x);
|
|
1716
|
+
for (let i = 0; i < sorted.length; i++) {
|
|
1717
|
+
for (let j = i + 1; j < sorted.length; j++) {
|
|
1718
|
+
const a = sorted[i];
|
|
1719
|
+
const b = sorted[j];
|
|
1720
|
+
if (!overlaps(a, b, 4)) continue;
|
|
1721
|
+
const down = a.y + a.h + M.gap - b.y;
|
|
1722
|
+
const right = b.x > a.x ? a.x + a.w + M.gap - b.x : Infinity;
|
|
1723
|
+
if (right < down) b.x += right;
|
|
1724
|
+
else b.y += down;
|
|
1725
|
+
assigned[b.id] = { x: Math.round(b.x), y: Math.round(b.y) };
|
|
1726
|
+
moved = true;
|
|
1727
|
+
}
|
|
1728
|
+
}
|
|
1729
|
+
if (!moved) break;
|
|
1730
|
+
}
|
|
1731
|
+
}
|
|
1732
|
+
function headerChars(b) {
|
|
1733
|
+
const tags = b.tags.filter((t) => t !== "export").join(" ");
|
|
1734
|
+
return 4 + b.name.length + Math.min(b.detail.length, 40) + (tags ? tags.length + 2 : 0) + 3;
|
|
1735
|
+
}
|
|
1736
|
+
function overlaps(a, b, gap = M.gap / 2) {
|
|
1737
|
+
return a.x < b.x + b.w + gap && a.x + a.w + gap > b.x && a.y < b.y + b.h + gap && a.y + a.h + gap > b.y;
|
|
1738
|
+
}
|
|
1739
|
+
function fits(r, placed, clampPositive) {
|
|
1740
|
+
if (clampPositive && (r.x < 0 || r.y < 0)) return false;
|
|
1741
|
+
return !placed.some((p) => overlaps(r, p));
|
|
1742
|
+
}
|
|
1743
|
+
function findFreeSpot(g, placed, prev, clampPositive) {
|
|
1744
|
+
if (!placed.length) return { x: 0, y: 0 };
|
|
1745
|
+
const cands = [];
|
|
1746
|
+
if (prev) cands.push({ x: prev.x, y: prev.y + prev.h + M.gap }, { x: prev.x + prev.w + M.gap, y: prev.y });
|
|
1747
|
+
const origin = prev ?? placed[0];
|
|
1748
|
+
const others = [...placed].sort((a, b) => dist(a, origin) - dist(b, origin));
|
|
1749
|
+
for (const p of others) cands.push({ x: p.x, y: p.y + p.h + M.gap }, { x: p.x + p.w + M.gap, y: p.y });
|
|
1750
|
+
for (const c of cands) if (fits({ ...c, w: g.w, h: g.h }, placed, clampPositive)) return c;
|
|
1751
|
+
const bottom = Math.max(...placed.map((p) => p.y + p.h));
|
|
1752
|
+
return { x: Math.min(...placed.map((p) => p.x)), y: bottom + M.gap };
|
|
1753
|
+
}
|
|
1754
|
+
function freeSpotNear(at, g, placed, clampPositive) {
|
|
1755
|
+
const start = clampPositive ? { x: Math.max(0, at.x), y: Math.max(0, at.y) } : at;
|
|
1756
|
+
const others = placed.filter((p) => p.id === void 0 || p.id !== g.id);
|
|
1757
|
+
if (fits({ ...start, w: g.w, h: g.h }, others, clampPositive)) return round(start);
|
|
1758
|
+
const cands = [];
|
|
1759
|
+
for (const p of others) {
|
|
1760
|
+
cands.push(
|
|
1761
|
+
{ x: p.x + p.w + M.gap, y: start.y },
|
|
1762
|
+
{ x: start.x, y: p.y + p.h + M.gap },
|
|
1763
|
+
{ x: p.x - g.w - M.gap, y: start.y },
|
|
1764
|
+
{ x: start.x, y: p.y - g.h - M.gap },
|
|
1765
|
+
{ x: p.x + p.w + M.gap, y: p.y },
|
|
1766
|
+
{ x: p.x, y: p.y + p.h + M.gap }
|
|
1767
|
+
);
|
|
1768
|
+
}
|
|
1769
|
+
cands.sort((a, b) => dist(a, start) - dist(b, start));
|
|
1770
|
+
for (const c of cands) if (fits({ ...c, w: g.w, h: g.h }, others, clampPositive)) return round(c);
|
|
1771
|
+
const bottom = Math.max(...others.map((p) => p.y + p.h));
|
|
1772
|
+
return round({ x: start.x, y: bottom + M.gap });
|
|
1773
|
+
}
|
|
1774
|
+
function dist(a, b) {
|
|
1775
|
+
return Math.hypot(a.x - b.x, a.y - b.y);
|
|
1776
|
+
}
|
|
1777
|
+
function clamp(v, lo, hi) {
|
|
1778
|
+
return Math.min(hi, Math.max(lo, v));
|
|
1779
|
+
}
|
|
1780
|
+
function round(p) {
|
|
1781
|
+
return { x: Math.round(p.x), y: Math.round(p.y) };
|
|
1782
|
+
}
|
|
1783
|
+
function carryRenames(prev, next, layout) {
|
|
1784
|
+
if (!prev) return false;
|
|
1785
|
+
const removedByParent = /* @__PURE__ */ new Map();
|
|
1786
|
+
const addedByParent = /* @__PURE__ */ new Map();
|
|
1787
|
+
for (const [id, b] of prev.blocks) if (!next.blocks.has(id)) push(removedByParent, b.parentId, b);
|
|
1788
|
+
for (const [id, b] of next.blocks) if (!prev.blocks.has(id)) push(addedByParent, b.parentId, b);
|
|
1789
|
+
let changed = false;
|
|
1790
|
+
for (const [parent, added] of addedByParent) {
|
|
1791
|
+
const removed = removedByParent.get(parent);
|
|
1792
|
+
if (added.length !== 1 || removed?.length !== 1) continue;
|
|
1793
|
+
const from = removed[0].id;
|
|
1794
|
+
const to = added[0].id;
|
|
1795
|
+
for (const key of Object.keys(layout.blocks)) {
|
|
1796
|
+
if (key === from || key.startsWith(from + "/")) {
|
|
1797
|
+
const nk = to + key.slice(from.length);
|
|
1798
|
+
if (!layout.blocks[nk]) layout.blocks[nk] = layout.blocks[key];
|
|
1799
|
+
changed = true;
|
|
1800
|
+
}
|
|
1801
|
+
}
|
|
1802
|
+
if (layout.collapsed?.includes(from)) layout.collapsed.push(to);
|
|
1803
|
+
}
|
|
1804
|
+
return changed;
|
|
1805
|
+
}
|
|
1806
|
+
function push(m, k, v) {
|
|
1807
|
+
const a = m.get(k);
|
|
1808
|
+
if (a) a.push(v);
|
|
1809
|
+
else m.set(k, [v]);
|
|
1810
|
+
}
|
|
1811
|
+
function serializeLayout(layout, model) {
|
|
1812
|
+
const blocks = {};
|
|
1813
|
+
for (const k of Object.keys(layout.blocks).sort()) {
|
|
1814
|
+
if (model && !model.blocks.has(k)) continue;
|
|
1815
|
+
const p = layout.blocks[k];
|
|
1816
|
+
blocks[k] = { x: Math.round(p.x), y: Math.round(p.y) };
|
|
1817
|
+
}
|
|
1818
|
+
const out = { version: 1, blocks };
|
|
1819
|
+
const collapsed = (layout.collapsed ?? []).filter((c) => !model || model.blocks.has(c)).sort();
|
|
1820
|
+
if (collapsed.length) out.collapsed = [...new Set(collapsed)];
|
|
1821
|
+
return JSON.stringify(out, null, 2) + "\n";
|
|
1822
|
+
}
|
|
1823
|
+
|
|
1824
|
+
// src/html.ts
|
|
1825
|
+
var policy;
|
|
1826
|
+
function trustedHtmlPolicy() {
|
|
1827
|
+
if (policy !== void 0) return policy;
|
|
1828
|
+
const factory = globalThis.trustedTypes;
|
|
1829
|
+
try {
|
|
1830
|
+
policy = factory ? factory.createPolicy("codecanvas", { createHTML: (html) => html }) : null;
|
|
1831
|
+
} catch {
|
|
1832
|
+
policy = null;
|
|
1833
|
+
}
|
|
1834
|
+
return policy;
|
|
1835
|
+
}
|
|
1836
|
+
function setHTML(element, html) {
|
|
1837
|
+
const htmlPolicy = trustedHtmlPolicy();
|
|
1838
|
+
element.innerHTML = htmlPolicy ? htmlPolicy.createHTML(html) : html;
|
|
1839
|
+
}
|
|
1840
|
+
|
|
1841
|
+
// src/styles.ts
|
|
1842
|
+
var CSS = (
|
|
1843
|
+
/* css */
|
|
1844
|
+
`
|
|
1845
|
+
.cc-root {
|
|
1846
|
+
--cc-bg: #15171c;
|
|
1847
|
+
--cc-dot: rgba(255,255,255,.07);
|
|
1848
|
+
--cc-frame-bg: rgba(255,255,255,.025);
|
|
1849
|
+
--cc-frame-border: rgba(255,255,255,.10);
|
|
1850
|
+
--cc-text: #e8eaee;
|
|
1851
|
+
--cc-muted: #9aa1ad;
|
|
1852
|
+
--cc-faint: #c4cad4;
|
|
1853
|
+
--cc-panel: #1f2229;
|
|
1854
|
+
--cc-panel-border: rgba(255,255,255,.12);
|
|
1855
|
+
--cc-accent: #7aa2ff;
|
|
1856
|
+
--cc-danger: #ff6b6b;
|
|
1857
|
+
--cc-shadow: 0 10px 30px rgba(0,0,0,.45);
|
|
1858
|
+
|
|
1859
|
+
--k-import: #6b7280; --k-import-head: #3b3f46; --k-import-bg: #25272c;
|
|
1860
|
+
--k-class: #34b27a; --k-class-head: #1d6b4a; --k-class-bg: rgba(52,178,122,.08);
|
|
1861
|
+
--k-function: #ec5fa5; --k-function-head: #8f2f62; --k-function-bg: rgba(236,95,165,.09);
|
|
1862
|
+
--k-variable: #4b8ef7; --k-variable-head: #23508f; --k-variable-bg: rgba(75,142,247,.09);
|
|
1863
|
+
--k-type: #d4a92a; --k-type-head: #6e5714; --k-type-bg: rgba(212,169,42,.08);
|
|
1864
|
+
--k-namespace: #9b8cf8; --k-namespace-head: #4d4291; --k-namespace-bg: rgba(155,140,248,.08);
|
|
1865
|
+
--k-statement: #7c8799; --k-statement-head: #39414f; --k-statement-bg: rgba(124,135,153,.08);
|
|
1866
|
+
|
|
1867
|
+
position: relative; overflow: hidden; width: 100%; height: 100%;
|
|
1868
|
+
background-color: var(--cc-bg);
|
|
1869
|
+
background-image: radial-gradient(var(--cc-dot) 1.2px, transparent 1.3px);
|
|
1870
|
+
color: var(--cc-text);
|
|
1871
|
+
font: 13px/1.3 ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
|
|
1872
|
+
touch-action: none; user-select: none; -webkit-user-select: none; outline: none;
|
|
1873
|
+
-webkit-tap-highlight-color: transparent;
|
|
1874
|
+
}
|
|
1875
|
+
.cc-root[data-theme="light"] {
|
|
1876
|
+
--cc-bg: #f6f7f9; --cc-dot: rgba(0,0,0,.09);
|
|
1877
|
+
--cc-frame-bg: rgba(255,255,255,.7); --cc-frame-border: rgba(0,0,0,.12);
|
|
1878
|
+
--cc-text: #1d2129; --cc-muted: #5d6573; --cc-faint: #39404c;
|
|
1879
|
+
--cc-panel: #ffffff; --cc-panel-border: rgba(0,0,0,.12); --cc-accent: #3366e6;
|
|
1880
|
+
--cc-shadow: 0 10px 30px rgba(20,30,50,.18);
|
|
1881
|
+
--k-import: #6b7280; --k-import-head: #4b5058; --k-import-bg: #eceef1;
|
|
1882
|
+
--k-class: #1f9963; --k-class-head: #1f9963; --k-class-bg: rgba(31,153,99,.07);
|
|
1883
|
+
--k-function: #d63e8c; --k-function-head: #d63e8c; --k-function-bg: rgba(214,62,140,.07);
|
|
1884
|
+
--k-variable: #2f6fe0; --k-variable-head: #2f6fe0; --k-variable-bg: rgba(47,111,224,.07);
|
|
1885
|
+
--k-type: #b58a0c; --k-type-head: #a87f08; --k-type-bg: rgba(181,138,12,.08);
|
|
1886
|
+
--k-namespace: #6f5ce6; --k-namespace-head: #6f5ce6; --k-namespace-bg: rgba(111,92,230,.07);
|
|
1887
|
+
--k-statement: #64708a; --k-statement-head: #64708a; --k-statement-bg: rgba(100,112,138,.07);
|
|
1888
|
+
}
|
|
1889
|
+
.cc-world { position: absolute; left: 0; top: 0; transform-origin: 0 0; }
|
|
1890
|
+
.cc-root.cc-moving .cc-world { will-change: transform; }
|
|
1891
|
+
.cc-frame { position: absolute; border: 1px solid var(--cc-frame-border); background: var(--cc-frame-bg); border-radius: 18px; }
|
|
1892
|
+
.cc-frame-title { position: absolute; left: 20px; top: 12px; font: 600 15px/20px var(--cc-font, "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, Consolas, monospace); color: var(--cc-muted); white-space: nowrap; }
|
|
1893
|
+
.cc-empty { position: absolute; font-size: 15px; color: var(--cc-muted); white-space: nowrap; }
|
|
1894
|
+
|
|
1895
|
+
.cc-block {
|
|
1896
|
+
position: absolute; left: 0; top: 0; box-sizing: border-box; border-radius: 10px;
|
|
1897
|
+
border: 1.5px solid var(--k); background: var(--kbg); overflow: hidden; contain: layout style;
|
|
1898
|
+
cursor: default;
|
|
1899
|
+
}
|
|
1900
|
+
.cc-block.k-import { --k: var(--k-import); --kh: var(--k-import-head); --kbg: var(--k-import-bg); }
|
|
1901
|
+
.cc-block.k-class { --k: var(--k-class); --kh: var(--k-class-head); --kbg: var(--k-class-bg); }
|
|
1902
|
+
.cc-block.k-function { --k: var(--k-function); --kh: var(--k-function-head); --kbg: var(--k-function-bg); }
|
|
1903
|
+
.cc-block.k-variable { --k: var(--k-variable); --kh: var(--k-variable-head); --kbg: var(--k-variable-bg); }
|
|
1904
|
+
.cc-block.k-type { --k: var(--k-type); --kh: var(--k-type-head); --kbg: var(--k-type-bg); }
|
|
1905
|
+
.cc-block.k-namespace { --k: var(--k-namespace); --kh: var(--k-namespace-head); --kbg: var(--k-namespace-bg); }
|
|
1906
|
+
.cc-block.k-statement { --k: var(--k-statement); --kh: var(--k-statement-head); --kbg: var(--k-statement-bg); }
|
|
1907
|
+
.cc-block.k-import, .cc-root:not([data-theme="light"]) .cc-block.k-import { border-color: #555a63; }
|
|
1908
|
+
|
|
1909
|
+
.cc-head {
|
|
1910
|
+
display: flex; align-items: center; gap: 7px; height: 32px; padding: 0 10px; box-sizing: border-box;
|
|
1911
|
+
background: var(--kh); color: #fff; white-space: nowrap; overflow: hidden;
|
|
1912
|
+
font: 13px/32px var(--cc-font, "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, Consolas, monospace);
|
|
1913
|
+
}
|
|
1914
|
+
.cc-kind { font-size: 10px; text-transform: uppercase; letter-spacing: .06em; opacity: .75; flex: none; }
|
|
1915
|
+
.cc-name { font-weight: 700; flex: none; max-width: 70%; overflow: hidden; text-overflow: ellipsis; }
|
|
1916
|
+
.cc-detail { opacity: .8; overflow: hidden; text-overflow: ellipsis; min-width: 0; flex: 1 1 auto; }
|
|
1917
|
+
.cc-tags { font-size: 10px; opacity: .7; flex: none; }
|
|
1918
|
+
.cc-exp { width: 6px; height: 6px; border-radius: 50%; background: #fff; opacity: .8; flex: none; }
|
|
1919
|
+
.cc-fold {
|
|
1920
|
+
flex: none; margin-left: auto; width: 22px; height: 22px; border: 0; border-radius: 6px; padding: 0;
|
|
1921
|
+
background: rgba(255,255,255,.14); color: #fff; font-size: 12px; line-height: 22px; cursor: pointer;
|
|
1922
|
+
}
|
|
1923
|
+
.cc-fold:hover { background: rgba(255,255,255,.28); }
|
|
1924
|
+
.cc-prev {
|
|
1925
|
+
margin: 0; padding: 5px 12px 0; white-space: pre; overflow: hidden; color: var(--cc-faint); opacity: .5;
|
|
1926
|
+
font: 11px/15px var(--cc-font, "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, Consolas, monospace);
|
|
1927
|
+
}
|
|
1928
|
+
.cc-more { opacity: .7; font-style: italic; }
|
|
1929
|
+
.cc-mini { position: relative; margin: 6px 12px 0; height: 34px; opacity: .45; }
|
|
1930
|
+
.cc-mini > i { position: absolute; border-radius: 2px; border: 1px solid var(--cc-faint); background: rgba(255,255,255,.06); }
|
|
1931
|
+
.cc-mini > i.k-class { border-color: var(--k-class); } .cc-mini > i.k-function { border-color: var(--k-function); }
|
|
1932
|
+
.cc-mini > i.k-variable { border-color: var(--k-variable); } .cc-mini > i.k-type { border-color: var(--k-type); }
|
|
1933
|
+
|
|
1934
|
+
.cc-err {
|
|
1935
|
+
flex: none; min-width: 18px; height: 18px; padding: 0 5px; box-sizing: border-box; border-radius: 9px;
|
|
1936
|
+
background: var(--cc-danger); color: #fff; font: 700 11px/18px ui-sans-serif, system-ui, sans-serif; text-align: center;
|
|
1937
|
+
}
|
|
1938
|
+
.cc-block.cc-has-error { border-color: var(--cc-danger); }
|
|
1939
|
+
.cc-root.lod-2 .cc-err { min-width: 34px; height: 34px; border-radius: 17px; font-size: 22px; line-height: 34px; }
|
|
1940
|
+
.cc-rename {
|
|
1941
|
+
font: inherit; font-weight: 700; color: #fff; background: rgba(0,0,0,.35); border: 1px solid var(--cc-accent);
|
|
1942
|
+
border-radius: 4px; padding: 0 4px; height: 22px; outline: none; user-select: text; -webkit-user-select: text; max-width: 100%;
|
|
1943
|
+
}
|
|
1944
|
+
.cc-rename.invalid { border-color: var(--cc-danger); }
|
|
1945
|
+
.cc-name:has(.cc-rename) { max-width: none; overflow: visible; }
|
|
1946
|
+
|
|
1947
|
+
/* merge conflicts */
|
|
1948
|
+
.cc-block.cc-conflict { border-color: #f0883e; box-shadow: 0 0 0 2px rgba(240, 136, 62, .35); }
|
|
1949
|
+
.cc-conflict-chip {
|
|
1950
|
+
flex: none; min-width: 18px; height: 18px; border-radius: 9px; background: #f0883e; color: #1b1300;
|
|
1951
|
+
font: 900 13px/18px ui-sans-serif, system-ui, sans-serif; text-align: center;
|
|
1952
|
+
}
|
|
1953
|
+
.cc-root.lod-2 .cc-conflict-chip { min-width: 34px; height: 34px; border-radius: 17px; font-size: 24px; line-height: 34px; }
|
|
1954
|
+
.cc-block.cc-conflict-incoming {
|
|
1955
|
+
--k: #f0883e; --kh: #8a4a17; --kbg: rgba(240, 136, 62, .08);
|
|
1956
|
+
border-style: dashed; pointer-events: none; opacity: .9;
|
|
1957
|
+
}
|
|
1958
|
+
.cc-diff-chip.incoming { background: #f0883e; }
|
|
1959
|
+
.cc-banner.cc-banner-conflicts { border-color: #f0883e; }
|
|
1960
|
+
|
|
1961
|
+
/* changes against a base version (setDiffBase) */
|
|
1962
|
+
.cc-block.cc-diff-unchanged { filter: saturate(.3) brightness(.92); }
|
|
1963
|
+
.cc-diff-chip {
|
|
1964
|
+
flex: none; min-width: 18px; height: 18px; padding: 0 4px; box-sizing: border-box; border-radius: 5px;
|
|
1965
|
+
font: 800 13px/18px ui-sans-serif, system-ui, sans-serif; text-align: center; color: #fff;
|
|
1966
|
+
}
|
|
1967
|
+
.cc-diff-chip.added { background: #2ea043; }
|
|
1968
|
+
.cc-diff-chip.changed { background: #d29922; }
|
|
1969
|
+
.cc-diff-chip.removed { background: var(--cc-danger); }
|
|
1970
|
+
.cc-root.lod-2 .cc-diff-chip { min-width: 34px; height: 34px; border-radius: 8px; font-size: 24px; line-height: 34px; }
|
|
1971
|
+
.cc-block.cc-diff-removed {
|
|
1972
|
+
--k: var(--cc-danger); --kh: #9c2f2f; --kbg: rgba(255, 107, 107, .10);
|
|
1973
|
+
border-style: dashed; pointer-events: none; opacity: .9;
|
|
1974
|
+
}
|
|
1975
|
+
.cc-root[data-theme="light"] .cc-block.cc-diff-removed { --kh: #d64545; --kbg: rgba(214, 69, 69, .08); }
|
|
1976
|
+
.cc-prev.cc-diff-prev { opacity: .95; }
|
|
1977
|
+
.cc-diff-prev > span { display: block; margin: 0 -12px; padding: 0 12px; }
|
|
1978
|
+
.cc-diff-prev .cc-line-same { opacity: .5; }
|
|
1979
|
+
.cc-diff-prev .cc-line-added { background: rgba(46, 160, 67, .30); }
|
|
1980
|
+
.cc-diff-prev .cc-line-removed { background: rgba(248, 81, 73, .26); text-decoration: line-through; text-decoration-color: rgba(248, 81, 73, .7); }
|
|
1981
|
+
.cc-diff-summary { padding: 0 8px; font-size: 12px; color: var(--cc-muted); white-space: nowrap; }
|
|
1982
|
+
|
|
1983
|
+
.cc-block.cc-sel { box-shadow: 0 0 0 2px var(--cc-bg), 0 0 0 4px var(--k); }
|
|
1984
|
+
.cc-block.cc-editing { box-shadow: 0 0 0 2px var(--cc-bg), 0 0 0 4px var(--cc-accent); }
|
|
1985
|
+
.cc-block.cc-dragging { box-shadow: var(--cc-shadow); opacity: .92; z-index: 9999 !important; }
|
|
1986
|
+
.cc-root.has-hl .cc-block.cc-linked { box-shadow: 0 0 0 2px var(--k); }
|
|
1987
|
+
|
|
1988
|
+
.cc-edges { position: absolute; left: 0; top: 0; overflow: visible; pointer-events: none; }
|
|
1989
|
+
.cc-edge { fill: none; stroke-width: 1.5; opacity: .22; }
|
|
1990
|
+
.cc-edge.u-type { stroke-dasharray: 4 4; }
|
|
1991
|
+
.cc-root.has-hl .cc-edge { opacity: .07; }
|
|
1992
|
+
.cc-root.has-hl .cc-edge.hl { opacity: .95; stroke-width: 2.5; }
|
|
1993
|
+
|
|
1994
|
+
/* level of detail when zoomed out */
|
|
1995
|
+
.cc-root.lod-1 .cc-tags { visibility: hidden; }
|
|
1996
|
+
.cc-root.lod-2 .cc-prev, .cc-root.lod-2 .cc-detail, .cc-root.lod-2 .cc-mini { visibility: hidden; }
|
|
1997
|
+
.cc-root.lod-2 .cc-head { font-size: 26px; }
|
|
1998
|
+
.cc-root.lod-2 .cc-kind, .cc-root.lod-2 .cc-fold { display: none; }
|
|
1999
|
+
.cc-root.lod-2 .cc-frame-title { font-size: 40px; line-height: 44px; top: -8px; }
|
|
2000
|
+
|
|
2001
|
+
/* overlays (screen space) */
|
|
2002
|
+
.cc-banner {
|
|
2003
|
+
position: absolute; left: 50%; top: 12px; transform: translateX(-50%); z-index: 20; display: flex; gap: 10px; align-items: center;
|
|
2004
|
+
padding: 8px 12px; border-radius: 10px; background: var(--cc-panel); border: 1px solid var(--cc-danger); box-shadow: var(--cc-shadow);
|
|
2005
|
+
font-size: 13px; max-width: calc(100% - 32px);
|
|
2006
|
+
}
|
|
2007
|
+
.cc-banner[hidden], .cc-bar[hidden], .cc-refs[hidden] { display: none; }
|
|
2008
|
+
.cc-toast {
|
|
2009
|
+
position: absolute; left: 50%; bottom: 76px; transform: translateX(-50%); z-index: 30; padding: 8px 14px; border-radius: 10px;
|
|
2010
|
+
background: var(--cc-panel); border: 1px solid var(--cc-panel-border); box-shadow: var(--cc-shadow); font-size: 13px;
|
|
2011
|
+
transition: opacity .2s; pointer-events: none; max-width: calc(100% - 32px);
|
|
2012
|
+
}
|
|
2013
|
+
.cc-bar {
|
|
2014
|
+
position: absolute; left: 50%; bottom: 16px; transform: translateX(-50%); z-index: 20; display: flex; align-items: center; gap: 4px;
|
|
2015
|
+
padding: 5px; border-radius: 12px; background: var(--cc-panel); border: 1px solid var(--cc-panel-border); box-shadow: var(--cc-shadow);
|
|
2016
|
+
width: max-content; max-width: calc(100% - 24px); box-sizing: border-box; flex-wrap: wrap; justify-content: center;
|
|
2017
|
+
}
|
|
2018
|
+
.cc-bar .cc-chip { padding: 0 10px 0 6px; font: 600 12px/30px var(--cc-font, "JetBrains Mono", ui-monospace, Menlo, monospace); white-space: nowrap; display: flex; align-items: center; gap: 6px; max-width: 220px; overflow: hidden; text-overflow: ellipsis; }
|
|
2019
|
+
.cc-bar .cc-chip i { width: 10px; height: 10px; border-radius: 3px; background: var(--k); flex: none; }
|
|
2020
|
+
.cc-btn {
|
|
2021
|
+
border: 0; background: transparent; color: var(--cc-text); font: 500 13px/30px inherit; font-family: inherit; padding: 0 10px; border-radius: 8px;
|
|
2022
|
+
cursor: pointer; white-space: nowrap; min-height: 32px;
|
|
2023
|
+
}
|
|
2024
|
+
.cc-btn:hover { background: rgba(127,127,127,.16); }
|
|
2025
|
+
.cc-btn.danger { color: var(--cc-danger); }
|
|
2026
|
+
.cc-btn.primary { background: var(--cc-accent); color: #fff; }
|
|
2027
|
+
.cc-sep { width: 1px; height: 20px; background: var(--cc-panel-border); margin: 0 2px; flex: none; }
|
|
2028
|
+
.cc-refs {
|
|
2029
|
+
position: absolute; left: 50%; bottom: 64px; transform: translateX(-50%); z-index: 21; width: min(360px, calc(100% - 24px)); max-height: 45%;
|
|
2030
|
+
overflow: auto; border-radius: 12px; background: var(--cc-panel); border: 1px solid var(--cc-panel-border); box-shadow: var(--cc-shadow); padding: 6px;
|
|
2031
|
+
}
|
|
2032
|
+
.cc-refs h4 { margin: 8px 8px 4px; font-size: 11px; text-transform: uppercase; letter-spacing: .06em; color: var(--cc-muted); }
|
|
2033
|
+
.cc-refs button {
|
|
2034
|
+
display: flex; width: 100%; align-items: center; gap: 8px; border: 0; background: transparent; color: var(--cc-text); text-align: left;
|
|
2035
|
+
padding: 6px 8px; border-radius: 8px; cursor: pointer; font: 12px/18px var(--cc-font, "JetBrains Mono", ui-monospace, Menlo, monospace);
|
|
2036
|
+
}
|
|
2037
|
+
.cc-refs button:hover { background: rgba(127,127,127,.16); }
|
|
2038
|
+
.cc-refs button i { width: 10px; height: 10px; border-radius: 3px; background: var(--k); flex: none; }
|
|
2039
|
+
.cc-refs button span:last-child { margin-left: auto; color: var(--cc-muted); }
|
|
2040
|
+
.cc-refs button:disabled { cursor: default; opacity: .7; }
|
|
2041
|
+
.cc-refs button:disabled:hover { background: transparent; }
|
|
2042
|
+
.cc-refs .cc-file { color: var(--cc-muted); }
|
|
2043
|
+
.cc-refs .cc-error-row { --k: var(--cc-danger); align-items: flex-start; }
|
|
2044
|
+
.cc-refs .cc-error-row i { border-radius: 50%; margin-top: 4px; }
|
|
2045
|
+
.cc-refs .cc-error-row span:nth-child(2) { white-space: pre-wrap; font-family: ui-sans-serif, system-ui, sans-serif; }
|
|
2046
|
+
.cc-refs .cc-error-row span:last-child { white-space: nowrap; }
|
|
2047
|
+
.cc-refs .none { padding: 4px 8px 8px; color: var(--cc-muted); font-size: 12px; }
|
|
2048
|
+
|
|
2049
|
+
.cc-editor {
|
|
2050
|
+
position: absolute; z-index: 25; display: flex; flex-direction: column; border-radius: 12px; overflow: hidden;
|
|
2051
|
+
background: var(--cc-panel); border: 1px solid var(--cc-panel-border); box-shadow: var(--cc-shadow);
|
|
2052
|
+
}
|
|
2053
|
+
.cc-editor-head { display: flex; align-items: center; gap: 8px; padding: 4px 6px 4px 12px; border-bottom: 1px solid var(--cc-panel-border); }
|
|
2054
|
+
.cc-editor-head .cc-chip { flex: 1; min-width: 0; font: 600 12px/30px var(--cc-font, "JetBrains Mono", ui-monospace, Menlo, monospace); display: flex; align-items: center; gap: 6px; overflow: hidden; white-space: nowrap; }
|
|
2055
|
+
.cc-editor-head .cc-chip i { width: 10px; height: 10px; border-radius: 3px; background: var(--k); flex: none; }
|
|
2056
|
+
.cc-editor-body { flex: 1; min-height: 0; overflow: auto; user-select: text; -webkit-user-select: text; touch-action: auto; }
|
|
2057
|
+
.cc-editor-hint { font-size: 11px; color: var(--cc-muted); white-space: nowrap; }
|
|
2058
|
+
@media (max-width: 640px) { .cc-editor-hint { display: none; } }
|
|
2059
|
+
`
|
|
2060
|
+
);
|
|
2061
|
+
var injected = false;
|
|
2062
|
+
function injectStyles(doc = document) {
|
|
2063
|
+
if (injected) return;
|
|
2064
|
+
injected = true;
|
|
2065
|
+
const s = doc.createElement("style");
|
|
2066
|
+
s.dataset.codecanvas = "";
|
|
2067
|
+
s.textContent = CSS;
|
|
2068
|
+
doc.head.appendChild(s);
|
|
2069
|
+
}
|
|
2070
|
+
|
|
2071
|
+
// src/canvas.ts
|
|
2072
|
+
var KIND_LABEL = {
|
|
2073
|
+
import: "import",
|
|
2074
|
+
class: "class",
|
|
2075
|
+
function: "fn",
|
|
2076
|
+
variable: "var",
|
|
2077
|
+
type: "type",
|
|
2078
|
+
namespace: "ns",
|
|
2079
|
+
statement: "code"
|
|
2080
|
+
};
|
|
2081
|
+
var DIFF_CHIPS = {
|
|
2082
|
+
added: '<span class="cc-diff-chip added" title="Added">+</span>',
|
|
2083
|
+
changed: '<span class="cc-diff-chip changed" title="Changed">~</span>',
|
|
2084
|
+
removed: '<span class="cc-diff-chip removed" title="Removed">\u2212</span>',
|
|
2085
|
+
incoming: '<span class="cc-diff-chip incoming" title="Incoming">+</span>',
|
|
2086
|
+
containsChanges: "",
|
|
2087
|
+
unchanged: ""
|
|
2088
|
+
};
|
|
2089
|
+
var LABEL_TAGS = /* @__PURE__ */ new Set(["const", "let", "var", "interface", "type", "enum", "namespace", "get", "set"]);
|
|
2090
|
+
var MIN_SCALE = 0.04;
|
|
2091
|
+
var MAX_SCALE = 4;
|
|
2092
|
+
var CodeCanvas = class {
|
|
2093
|
+
constructor(container, opts) {
|
|
2094
|
+
this.opts = opts;
|
|
2095
|
+
this.project = opts.project ?? new Project();
|
|
2096
|
+
injectStyles(container.ownerDocument);
|
|
2097
|
+
const root = this.root = el("div", "cc-root");
|
|
2098
|
+
root.tabIndex = 0;
|
|
2099
|
+
root.dataset.theme = opts.theme ?? "dark";
|
|
2100
|
+
this.world = el("div", "cc-world");
|
|
2101
|
+
this.frameEl = el("div", "cc-frame");
|
|
2102
|
+
this.blocksEl = el("div", "cc-blocks");
|
|
2103
|
+
this.svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
|
|
2104
|
+
this.svg.classList.add("cc-edges");
|
|
2105
|
+
setHTML(this.svg, `<defs>${["import", "class", "function", "variable", "type", "namespace", "statement"].map(
|
|
2106
|
+
(k) => `<marker id="cc-arrow-${k}" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M0 0L10 5L0 10z" fill="var(--k-${k})"/></marker>`
|
|
2107
|
+
).join("")}</defs>`);
|
|
2108
|
+
this.world.append(this.frameEl, this.blocksEl, this.svg);
|
|
2109
|
+
this.banner = el("div", "cc-banner");
|
|
2110
|
+
this.banner.hidden = true;
|
|
2111
|
+
this.toastEl = el("div", "cc-toast");
|
|
2112
|
+
this.toastEl.style.opacity = "0";
|
|
2113
|
+
this.bar = el("div", "cc-bar");
|
|
2114
|
+
this.refsEl = el("div", "cc-refs");
|
|
2115
|
+
this.refsEl.hidden = true;
|
|
2116
|
+
root.append(this.world, this.banner, this.refsEl, this.bar, this.toastEl);
|
|
2117
|
+
container.appendChild(root);
|
|
2118
|
+
this.bindEvents();
|
|
2119
|
+
this.ro = new ResizeObserver(() => this.fittedToView ? this.fit(false) : this.queueCamera());
|
|
2120
|
+
this.ro.observe(root);
|
|
2121
|
+
this.barObserver = new ResizeObserver(([entry]) => {
|
|
2122
|
+
const barTop = entry.borderBoxSize?.[0]?.blockSize ?? this.bar.offsetHeight;
|
|
2123
|
+
this.refsEl.style.bottom = barTop + 28 + "px";
|
|
2124
|
+
this.toastEl.style.bottom = barTop + 40 + "px";
|
|
2125
|
+
});
|
|
2126
|
+
this.barObserver.observe(this.bar);
|
|
2127
|
+
this.renderBar();
|
|
2128
|
+
}
|
|
2129
|
+
opts;
|
|
2130
|
+
root;
|
|
2131
|
+
world;
|
|
2132
|
+
frameEl;
|
|
2133
|
+
blocksEl;
|
|
2134
|
+
svg;
|
|
2135
|
+
bar;
|
|
2136
|
+
refsEl;
|
|
2137
|
+
banner;
|
|
2138
|
+
toastEl;
|
|
2139
|
+
path = "";
|
|
2140
|
+
text = "";
|
|
2141
|
+
/** Latest parse. */
|
|
2142
|
+
model = null;
|
|
2143
|
+
/** What's on screen: the latest parse without syntax errors. */
|
|
2144
|
+
view = null;
|
|
2145
|
+
layout = emptyLayout();
|
|
2146
|
+
geom = null;
|
|
2147
|
+
hints = {};
|
|
2148
|
+
cam = { x: 0, y: 0, s: 1 };
|
|
2149
|
+
camQueued = false;
|
|
2150
|
+
movingTimer = 0;
|
|
2151
|
+
anim = 0;
|
|
2152
|
+
lod = -1;
|
|
2153
|
+
/** The camera still shows the whole file as `fit` left it: resizing the view fits it again. */
|
|
2154
|
+
fittedToView = false;
|
|
2155
|
+
els = /* @__PURE__ */ new Map();
|
|
2156
|
+
edges = [];
|
|
2157
|
+
selected = null;
|
|
2158
|
+
hovered = null;
|
|
2159
|
+
refsOpen = false;
|
|
2160
|
+
project;
|
|
2161
|
+
/** Errors on screen: type errors from the last resolve, or syntax errors mapped onto the last good view. */
|
|
2162
|
+
diagnostics = [];
|
|
2163
|
+
incomingUsages = [];
|
|
2164
|
+
/** The version changes are shown against (`setDiffBase`), and its geometry, computed once. */
|
|
2165
|
+
diffBase = null;
|
|
2166
|
+
diffCache = null;
|
|
2167
|
+
/** Merge conflicts in the text. The canvas then shows its current side, marking the blocks they touch. */
|
|
2168
|
+
conflicts = null;
|
|
2169
|
+
incomingBlockEls = [];
|
|
2170
|
+
removedBlockEls = [];
|
|
2171
|
+
/** Errors from the host (`setDiagnostics`), shown instead of the project's type errors. */
|
|
2172
|
+
hostDiagnostics = null;
|
|
2173
|
+
renaming = null;
|
|
2174
|
+
/** The layout before the last Arrange, until the next layout change: Undo arrange puts it back. */
|
|
2175
|
+
layoutBeforeArrange = null;
|
|
2176
|
+
/** Where the camera was before the block editor moved it out of the way, and where it moved it to. */
|
|
2177
|
+
cameraBeforeEditor = null;
|
|
2178
|
+
editing = null;
|
|
2179
|
+
clip = null;
|
|
2180
|
+
pointers = /* @__PURE__ */ new Map();
|
|
2181
|
+
gesture = null;
|
|
2182
|
+
lastTap = { t: 0, x: 0, y: 0, id: "" };
|
|
2183
|
+
lastPointer = null;
|
|
2184
|
+
toastTimer = 0;
|
|
2185
|
+
ro;
|
|
2186
|
+
barObserver;
|
|
2187
|
+
cleanup = [];
|
|
2188
|
+
// ---------------------------------------------------------------- public API
|
|
2189
|
+
/** Show a file. Fits it to the view. */
|
|
2190
|
+
openFile(path, text, layout) {
|
|
2191
|
+
this.closeEditor();
|
|
2192
|
+
this.path = path;
|
|
2193
|
+
this.text = text;
|
|
2194
|
+
this.layout = layout ? structuredClone(layout) : emptyLayout();
|
|
2195
|
+
this.model = this.view = null;
|
|
2196
|
+
this.selected = this.hovered = null;
|
|
2197
|
+
this.refsOpen = false;
|
|
2198
|
+
this.renaming = null;
|
|
2199
|
+
this.diagnostics = [];
|
|
2200
|
+
this.hostDiagnostics = null;
|
|
2201
|
+
this.diffBase = null;
|
|
2202
|
+
this.diffCache = null;
|
|
2203
|
+
this.conflicts = null;
|
|
2204
|
+
this.layoutBeforeArrange = null;
|
|
2205
|
+
this.incomingUsages = [];
|
|
2206
|
+
this.hints = {};
|
|
2207
|
+
for (const { el: el2 } of this.els.values()) el2.remove();
|
|
2208
|
+
this.els.clear();
|
|
2209
|
+
this.parseNow();
|
|
2210
|
+
this.fit(false);
|
|
2211
|
+
this.renderBar();
|
|
2212
|
+
}
|
|
2213
|
+
/** The text changed outside the canvas (e.g. the text editor). */
|
|
2214
|
+
setText(text) {
|
|
2215
|
+
if (text === this.text) return;
|
|
2216
|
+
const isEditedByOpenEditor = !!this.editing?.handle.editsFile;
|
|
2217
|
+
if (isEditedByOpenEditor) this.followEditedRange(this.text, text);
|
|
2218
|
+
else if (this.editing) this.closeEditor();
|
|
2219
|
+
this.cancelRename();
|
|
2220
|
+
this.text = text;
|
|
2221
|
+
this.parseNow();
|
|
2222
|
+
}
|
|
2223
|
+
/**
|
|
2224
|
+
* Show the changes since a base version of the file (e.g. the last commit): unchanged blocks are
|
|
2225
|
+
* desaturated, added and changed ones get a chip, removed ones show in red where they were.
|
|
2226
|
+
* `layout` is the base version's saved layout, for placing removed blocks. Null hides the changes.
|
|
2227
|
+
*/
|
|
2228
|
+
setDiffBase(base) {
|
|
2229
|
+
this.diffBase = base ? { model: parseStructure(this.path, base.text), layout: base.layout ?? null } : null;
|
|
2230
|
+
this.diffCache = null;
|
|
2231
|
+
this.render();
|
|
2232
|
+
this.renderBar();
|
|
2233
|
+
}
|
|
2234
|
+
get isShowingChanges() {
|
|
2235
|
+
return !!this.diffBase;
|
|
2236
|
+
}
|
|
2237
|
+
conflictsOf(hunks, current, currentModel) {
|
|
2238
|
+
const incoming = conflictSideText(this.text, hunks, "incoming");
|
|
2239
|
+
const incomingModel = parseStructure(this.path, incoming.text);
|
|
2240
|
+
const hunksByBlock = /* @__PURE__ */ new Map();
|
|
2241
|
+
const markTouched = (id, hunkIndex) => {
|
|
2242
|
+
const list = hunksByBlock.get(id) ?? [];
|
|
2243
|
+
if (!list.includes(hunkIndex)) list.push(hunkIndex);
|
|
2244
|
+
hunksByBlock.set(id, list);
|
|
2245
|
+
};
|
|
2246
|
+
const incomingOnly = [];
|
|
2247
|
+
hunks.forEach((_, hunkIndex) => {
|
|
2248
|
+
for (const block of innermostBlocksTouching(currentModel, current.hunkRanges[hunkIndex])) markTouched(block.id, hunkIndex);
|
|
2249
|
+
for (const block of innermostBlocksTouching(incomingModel, incoming.hunkRanges[hunkIndex])) {
|
|
2250
|
+
let outermostNew = currentModel.blocks.has(block.id) ? void 0 : block;
|
|
2251
|
+
while (outermostNew?.parentId && !currentModel.blocks.has(outermostNew.parentId)) outermostNew = incomingModel.blocks.get(outermostNew.parentId);
|
|
2252
|
+
if (!outermostNew) markTouched(block.id, hunkIndex);
|
|
2253
|
+
else if (!incomingOnly.includes(outermostNew)) incomingOnly.push(outermostNew);
|
|
2254
|
+
}
|
|
2255
|
+
});
|
|
2256
|
+
return { hunks, current, incomingModel, hunksByBlock, incomingOnly };
|
|
2257
|
+
}
|
|
2258
|
+
/** Resolve the conflicts that touch a block: their sections become the chosen side. */
|
|
2259
|
+
keepConflictSide(id, choice) {
|
|
2260
|
+
const conflicts = this.conflicts;
|
|
2261
|
+
const hunkIndexes = conflicts?.hunksByBlock.get(id);
|
|
2262
|
+
if (!conflicts || !hunkIndexes) return;
|
|
2263
|
+
const edits = hunkIndexes.map((index) => resolveConflict(this.text, conflicts.hunks[index], choice));
|
|
2264
|
+
this.commit(applyEdits(this.text, edits));
|
|
2265
|
+
const remaining = this.conflicts?.hunks.length ?? 0;
|
|
2266
|
+
this.toast(remaining ? `Resolved. ${plural(remaining, "conflict")} left` : "All conflicts in this file are resolved");
|
|
2267
|
+
}
|
|
2268
|
+
/** While changes are shown: the block's code in the base version, '' if it's new. */
|
|
2269
|
+
baseCodeOf(block) {
|
|
2270
|
+
const diff = this.currentDiff();
|
|
2271
|
+
if (!diff || !block) return void 0;
|
|
2272
|
+
const baseBlock = diff.base.blocks.get(block.id);
|
|
2273
|
+
return baseBlock ? diff.base.text.slice(baseBlock.start, baseBlock.end) : "";
|
|
2274
|
+
}
|
|
2275
|
+
/** The changes of what's on screen, while a diff base is set. */
|
|
2276
|
+
currentDiff() {
|
|
2277
|
+
if (!this.diffBase || !this.view) return null;
|
|
2278
|
+
if (this.diffCache?.view !== this.view) this.diffCache = { view: this.view, diff: diffModels(this.diffBase.model, this.view) };
|
|
2279
|
+
return this.diffCache.diff;
|
|
2280
|
+
}
|
|
2281
|
+
/**
|
|
2282
|
+
* Errors reported by the host, e.g. VS Code's Problems. While set, the canvas shows these instead of
|
|
2283
|
+
* resolving type errors itself. Pass null to go back to the project's own.
|
|
2284
|
+
*/
|
|
2285
|
+
setDiagnostics(diagnostics) {
|
|
2286
|
+
this.hostDiagnostics = diagnostics;
|
|
2287
|
+
if (!this.view || this.stale || this.conflicts) return;
|
|
2288
|
+
this.diagnostics = this.hostDiagnosticsOnBlocks(this.view);
|
|
2289
|
+
this.updateErrorMarkers();
|
|
2290
|
+
this.renderBar();
|
|
2291
|
+
}
|
|
2292
|
+
setLayout(layout) {
|
|
2293
|
+
this.layout = structuredClone(layout);
|
|
2294
|
+
this.relayout();
|
|
2295
|
+
}
|
|
2296
|
+
getLayout() {
|
|
2297
|
+
return structuredClone(this.layout);
|
|
2298
|
+
}
|
|
2299
|
+
getModel() {
|
|
2300
|
+
return this.model;
|
|
2301
|
+
}
|
|
2302
|
+
/** Re-resolve usages and errors, e.g. after other files of the project changed. */
|
|
2303
|
+
refreshProject() {
|
|
2304
|
+
if (this.view && !this.stale) this.scheduleUsages(this.view, true);
|
|
2305
|
+
}
|
|
2306
|
+
getDiagnostics() {
|
|
2307
|
+
return [...this.diagnostics];
|
|
2308
|
+
}
|
|
2309
|
+
setTheme(theme) {
|
|
2310
|
+
this.root.dataset.theme = theme;
|
|
2311
|
+
}
|
|
2312
|
+
get scale() {
|
|
2313
|
+
return this.cam.s;
|
|
2314
|
+
}
|
|
2315
|
+
/** Lay the whole file out again by the layout rules (layoutRules.ts), forgetting saved positions. */
|
|
2316
|
+
arrange() {
|
|
2317
|
+
if (!this.view) return;
|
|
2318
|
+
const before = structuredClone(this.layout);
|
|
2319
|
+
this.layout = { ...emptyLayout(), collapsed: this.layout.collapsed };
|
|
2320
|
+
this.hints = {};
|
|
2321
|
+
this.relayout();
|
|
2322
|
+
this.emitLayout("user");
|
|
2323
|
+
this.layoutBeforeArrange = before;
|
|
2324
|
+
this.fit();
|
|
2325
|
+
this.renderBar();
|
|
2326
|
+
}
|
|
2327
|
+
undoArrange() {
|
|
2328
|
+
const before = this.layoutBeforeArrange;
|
|
2329
|
+
if (!before) return;
|
|
2330
|
+
this.layout = before;
|
|
2331
|
+
this.relayout();
|
|
2332
|
+
this.emitLayout("user");
|
|
2333
|
+
this.fit();
|
|
2334
|
+
this.renderBar();
|
|
2335
|
+
}
|
|
2336
|
+
/** Fit the whole file in the view. */
|
|
2337
|
+
fit(animate = true) {
|
|
2338
|
+
if (!this.geom) return;
|
|
2339
|
+
const f = this.geom.frame;
|
|
2340
|
+
const { width: vw, height: vh } = this.root.getBoundingClientRect();
|
|
2341
|
+
if (!vw || !vh) {
|
|
2342
|
+
requestAnimationFrame(() => this.root.isConnected && this.fit(animate));
|
|
2343
|
+
return;
|
|
2344
|
+
}
|
|
2345
|
+
const pad = 24;
|
|
2346
|
+
const bottom = 64;
|
|
2347
|
+
const s = clamp2(Math.min((vw - pad * 2) / f.w, (vh - pad - bottom) / f.h, 1.1), MIN_SCALE, MAX_SCALE);
|
|
2348
|
+
const to = { s, x: (vw - f.w * s) / 2 - f.x * s, y: pad + (vh - pad - bottom - f.h * s) / 2 - f.y * s };
|
|
2349
|
+
this.moveCamera(to, animate);
|
|
2350
|
+
this.fittedToView = true;
|
|
2351
|
+
}
|
|
2352
|
+
zoomBy(factor) {
|
|
2353
|
+
const r = this.root.getBoundingClientRect();
|
|
2354
|
+
this.zoomAt({ x: r.width / 2, y: r.height / 2 }, factor, true);
|
|
2355
|
+
}
|
|
2356
|
+
/** Center a block and select it. */
|
|
2357
|
+
reveal(id) {
|
|
2358
|
+
const g = this.geom?.geo.get(id);
|
|
2359
|
+
if (!g) return;
|
|
2360
|
+
this.select(id);
|
|
2361
|
+
const { width: vw, height: vh } = this.root.getBoundingClientRect();
|
|
2362
|
+
const s = clamp2(Math.min(Math.max(this.cam.s, 0.85), (vw - 80) / g.w, (vh - 160) / g.h), MIN_SCALE, 1.4);
|
|
2363
|
+
this.moveCamera({ s, x: vw / 2 - (g.ax + g.w / 2) * s, y: (vh - 60) / 2 - (g.ay + g.h / 2) * s }, true);
|
|
2364
|
+
}
|
|
2365
|
+
select(id) {
|
|
2366
|
+
if (id === this.selected) return;
|
|
2367
|
+
this.selected = id;
|
|
2368
|
+
if (!id) this.refsOpen = false;
|
|
2369
|
+
this.updateHighlight();
|
|
2370
|
+
this.renderBar();
|
|
2371
|
+
this.opts.onSelect?.(id && this.view?.blocks.get(id) || null);
|
|
2372
|
+
}
|
|
2373
|
+
destroy() {
|
|
2374
|
+
this.closeEditor();
|
|
2375
|
+
this.ro.disconnect();
|
|
2376
|
+
this.barObserver.disconnect();
|
|
2377
|
+
for (const c of this.cleanup) c();
|
|
2378
|
+
this.root.remove();
|
|
2379
|
+
}
|
|
2380
|
+
// ---------------------------------------------------------------- model
|
|
2381
|
+
usageTimer = 0;
|
|
2382
|
+
parseNow() {
|
|
2383
|
+
const hunks = this.text.includes("<<<<<<<") ? findConflicts(this.text) : [];
|
|
2384
|
+
const currentSide = hunks.length ? conflictSideText(this.text, hunks, "current") : null;
|
|
2385
|
+
const m = currentSide ? parseStructure(this.path, currentSide.text, this.model) : this.project.setFile(this.path, this.text);
|
|
2386
|
+
this.model = m;
|
|
2387
|
+
this.conflicts = currentSide ? this.conflictsOf(hunks, currentSide, m) : null;
|
|
2388
|
+
const stale = m.syntaxErrors > 0 && this.view !== null;
|
|
2389
|
+
if (this.conflicts) this.diagnostics = [];
|
|
2390
|
+
else if (stale) this.diagnostics = this.syntaxDiagnosticsOnView(m);
|
|
2391
|
+
else if (m.syntaxErrors) this.diagnostics = m.diagnostics;
|
|
2392
|
+
else if (this.hostDiagnostics) this.diagnostics = this.hostDiagnosticsOnBlocks(m);
|
|
2393
|
+
else this.diagnostics = this.diagnostics.filter((d) => !d.blockId || m.blocks.has(d.blockId));
|
|
2394
|
+
if (!stale) {
|
|
2395
|
+
if (this.view) m.usages = this.view.usages.filter((u) => m.blocks.has(u.from) && m.blocks.has(u.to));
|
|
2396
|
+
if (carryRenames(this.view, m, this.layout)) this.emitLayout("automatic");
|
|
2397
|
+
this.view = m;
|
|
2398
|
+
if (this.selected && !m.blocks.has(this.selected)) this.selected = null;
|
|
2399
|
+
if (this.editing) {
|
|
2400
|
+
const b = [...m.blocks.values()].find((x) => x.start === this.editing.start);
|
|
2401
|
+
this.editing.id = b?.id ?? null;
|
|
2402
|
+
if (b && this.selected !== b.id) this.selected = b.id;
|
|
2403
|
+
}
|
|
2404
|
+
this.relayout();
|
|
2405
|
+
if (!this.conflicts) this.scheduleUsages(m);
|
|
2406
|
+
}
|
|
2407
|
+
const isShowingConflicts = !!this.conflicts && !stale;
|
|
2408
|
+
this.banner.hidden = !stale && !isShowingConflicts;
|
|
2409
|
+
this.banner.classList.toggle("cc-banner-conflicts", isShowingConflicts);
|
|
2410
|
+
if (isShowingConflicts) {
|
|
2411
|
+
setHTML(this.banner, `<span>${plural(this.conflicts.hunks.length, "merge conflict")} in this file. Select a block marked ! to keep a side, or edit it.</span>`);
|
|
2412
|
+
} else if (stale) {
|
|
2413
|
+
this.updateErrorMarkers();
|
|
2414
|
+
setHTML(this.banner, `<span>Syntax error on line ${m.firstErrorLine}. The diagram is paused until it's fixed.</span>`);
|
|
2415
|
+
if (!this.editing) {
|
|
2416
|
+
const b = button("Edit file", () => this.openRangeEditor(0, this.text.length, null), "primary");
|
|
2417
|
+
this.banner.appendChild(b);
|
|
2418
|
+
}
|
|
2419
|
+
}
|
|
2420
|
+
this.renderBar();
|
|
2421
|
+
}
|
|
2422
|
+
/** Usage links and type errors go through the project's language service: only once typing pauses. */
|
|
2423
|
+
scheduleUsages(m, soon = false) {
|
|
2424
|
+
clearTimeout(this.usageTimer);
|
|
2425
|
+
const run = async () => {
|
|
2426
|
+
if (this.view !== m) return;
|
|
2427
|
+
const resolution = await this.project.resolve(this.path, { diagnostics: !this.hostDiagnostics });
|
|
2428
|
+
if (this.view !== m) return;
|
|
2429
|
+
m.usages = resolution.usages;
|
|
2430
|
+
this.incomingUsages = resolution.incoming;
|
|
2431
|
+
this.diagnostics = this.hostDiagnostics ? this.hostDiagnosticsOnBlocks(m) : resolution.diagnostics;
|
|
2432
|
+
if (!this.geom) return;
|
|
2433
|
+
this.renderEdges();
|
|
2434
|
+
this.updateHighlight();
|
|
2435
|
+
this.updateErrorMarkers();
|
|
2436
|
+
this.renderBar();
|
|
2437
|
+
};
|
|
2438
|
+
const pauseMs = soon ? 0 : m.text.length < 2e4 ? 30 : 180;
|
|
2439
|
+
this.usageTimer = window.setTimeout(() => void run(), pauseMs);
|
|
2440
|
+
}
|
|
2441
|
+
hostDiagnosticsOnBlocks(model) {
|
|
2442
|
+
return (this.hostDiagnostics ?? []).map((d) => ({ ...d, blockId: blockAt(model, d.start)?.id ?? null }));
|
|
2443
|
+
}
|
|
2444
|
+
/** The text changed while an editor on the file is open: keep tracking the part of the file it shows. */
|
|
2445
|
+
followEditedRange(before, after) {
|
|
2446
|
+
const editing = this.editing;
|
|
2447
|
+
const change = changedRegion2(before, after);
|
|
2448
|
+
const lengthChange = after.length - before.length;
|
|
2449
|
+
const isBeforeEditedRange = change.end <= editing.start && change.start < editing.start;
|
|
2450
|
+
const isInsideEditedRange = change.start >= editing.start && change.start <= editing.start + editing.len;
|
|
2451
|
+
if (isBeforeEditedRange) editing.start += lengthChange;
|
|
2452
|
+
else if (isInsideEditedRange) editing.len = Math.max(0, editing.len + lengthChange);
|
|
2453
|
+
}
|
|
2454
|
+
/** While the syntax is broken the last good view stays on screen: put its errors on the closest block it still has. */
|
|
2455
|
+
syntaxDiagnosticsOnView(model) {
|
|
2456
|
+
const closestShownBlock = (id) => {
|
|
2457
|
+
let current = id;
|
|
2458
|
+
while (current && !this.view?.blocks.has(current)) current = model.blocks.get(current)?.parentId ?? null;
|
|
2459
|
+
return current;
|
|
2460
|
+
};
|
|
2461
|
+
return model.diagnostics.map((d) => ({ ...d, blockId: closestShownBlock(d.blockId) }));
|
|
2462
|
+
}
|
|
2463
|
+
get stale() {
|
|
2464
|
+
return !!this.model && this.model !== this.view;
|
|
2465
|
+
}
|
|
2466
|
+
/** Apply a text edit made on the canvas. */
|
|
2467
|
+
commit(text, otherFiles) {
|
|
2468
|
+
this.text = text;
|
|
2469
|
+
this.opts.onTextChange(text, otherFiles);
|
|
2470
|
+
this.parseNow();
|
|
2471
|
+
}
|
|
2472
|
+
relayout() {
|
|
2473
|
+
if (!this.view) return;
|
|
2474
|
+
const g = computeGeometry(this.view, this.layout, this.hints);
|
|
2475
|
+
this.geom = g;
|
|
2476
|
+
const assigned = Object.keys(g.assigned);
|
|
2477
|
+
if (assigned.length) {
|
|
2478
|
+
const isPlacedByUser = assigned.some((id) => id in this.hints);
|
|
2479
|
+
for (const id of assigned) {
|
|
2480
|
+
this.layout.blocks[id] = g.assigned[id];
|
|
2481
|
+
delete this.hints[id];
|
|
2482
|
+
}
|
|
2483
|
+
this.emitLayout(isPlacedByUser ? "user" : "automatic");
|
|
2484
|
+
}
|
|
2485
|
+
this.render();
|
|
2486
|
+
}
|
|
2487
|
+
emitLayout(reason) {
|
|
2488
|
+
if (reason === "user") this.layoutBeforeArrange = null;
|
|
2489
|
+
this.opts.onLayoutChange(structuredClone(this.layout), reason);
|
|
2490
|
+
}
|
|
2491
|
+
// ---------------------------------------------------------------- rendering
|
|
2492
|
+
render() {
|
|
2493
|
+
const g = this.geom;
|
|
2494
|
+
const view = this.view;
|
|
2495
|
+
if (!g || !view) return;
|
|
2496
|
+
const f = g.frame;
|
|
2497
|
+
Object.assign(this.frameEl.style, { transform: `translate(${f.x}px,${f.y}px)`, width: f.w + "px", height: f.h + "px" });
|
|
2498
|
+
setHTML(this.frameEl, `<div class="cc-frame-title">${esc(this.path)}</div>${view.roots.length ? "" : `<div class="cc-empty" style="left:${M.filePad}px;top:${M.filePad + M.fileHeader}px">Empty file. Double-click (or double-tap) to create a function.</div>`}`);
|
|
2499
|
+
const seen = /* @__PURE__ */ new Set();
|
|
2500
|
+
const diff = this.currentDiff();
|
|
2501
|
+
for (const geo of g.order) {
|
|
2502
|
+
const id = geo.id;
|
|
2503
|
+
seen.add(id);
|
|
2504
|
+
let entry = this.els.get(id);
|
|
2505
|
+
if (geo.hidden) {
|
|
2506
|
+
if (entry) entry.el.style.display = "none";
|
|
2507
|
+
continue;
|
|
2508
|
+
}
|
|
2509
|
+
if (!entry) {
|
|
2510
|
+
const e = el("div", "cc-block");
|
|
2511
|
+
e.dataset.id = id;
|
|
2512
|
+
this.blocksEl.appendChild(e);
|
|
2513
|
+
entry = { el: e, sig: "", box: "" };
|
|
2514
|
+
this.els.set(id, entry);
|
|
2515
|
+
}
|
|
2516
|
+
const b = geo.block;
|
|
2517
|
+
const mini = geo.collapsed ? this.miniMap(geo) : "";
|
|
2518
|
+
const change = diff?.changes.get(id);
|
|
2519
|
+
const changedLines = change === "changed" ? this.changedLinesPreview(diff, b) : "";
|
|
2520
|
+
const isConflicted = !!this.conflicts?.hunksByBlock.has(id);
|
|
2521
|
+
const sig = `${b.kind}|${b.name}|${b.detail}|${b.tags.join()}|${b.preview.join("\n")}|${b.previewMore}|${geo.collapsed}|${b.children.length}|${mini}|${change ?? ""}|${changedLines}|${isConflicted}`;
|
|
2522
|
+
if (sig !== entry.sig) {
|
|
2523
|
+
entry.sig = sig;
|
|
2524
|
+
entry.el.className = `cc-block k-${b.kind}${change ? ` cc-diff-${change}` : ""}${isConflicted ? " cc-conflict" : ""}`;
|
|
2525
|
+
setHTML(entry.el, this.blockHTML(b, geo, mini, change, changedLines, isConflicted));
|
|
2526
|
+
}
|
|
2527
|
+
const box = `${geo.ax},${geo.ay},${geo.w},${geo.h},${b.depth}`;
|
|
2528
|
+
if (box !== entry.box) {
|
|
2529
|
+
entry.box = box;
|
|
2530
|
+
const s = entry.el.style;
|
|
2531
|
+
s.transform = `translate(${geo.ax}px,${geo.ay}px)`;
|
|
2532
|
+
s.width = geo.w + "px";
|
|
2533
|
+
s.height = geo.h + "px";
|
|
2534
|
+
s.zIndex = String(b.depth + 1);
|
|
2535
|
+
s.display = "";
|
|
2536
|
+
}
|
|
2537
|
+
}
|
|
2538
|
+
for (const [id, entry] of this.els) {
|
|
2539
|
+
if (!seen.has(id)) {
|
|
2540
|
+
entry.el.remove();
|
|
2541
|
+
this.els.delete(id);
|
|
2542
|
+
}
|
|
2543
|
+
}
|
|
2544
|
+
this.renderRemovedBlocks(diff);
|
|
2545
|
+
this.renderIncomingBlocks();
|
|
2546
|
+
this.renderEdges();
|
|
2547
|
+
this.updateHighlight();
|
|
2548
|
+
this.updateErrorMarkers();
|
|
2549
|
+
}
|
|
2550
|
+
/** Blocks only the incoming side of a conflict has, drawn dashed where they would go. */
|
|
2551
|
+
renderIncomingBlocks() {
|
|
2552
|
+
for (const incomingEl of this.incomingBlockEls) incomingEl.remove();
|
|
2553
|
+
this.incomingBlockEls = [];
|
|
2554
|
+
const conflicts = this.conflicts;
|
|
2555
|
+
const geometry = this.geom;
|
|
2556
|
+
if (!conflicts?.incomingOnly.length || !geometry) return;
|
|
2557
|
+
conflicts.geometry ??= computeGeometry(conflicts.incomingModel, this.layout, {}, { resolveOverlaps: false });
|
|
2558
|
+
for (const block of conflicts.incomingOnly) {
|
|
2559
|
+
const incomingGeo = conflicts.geometry.geo.get(block.id);
|
|
2560
|
+
const parentGeo = block.parentId ? geometry.geo.get(block.parentId) : void 0;
|
|
2561
|
+
if (!incomingGeo || parentGeo && (parentGeo.hidden || parentGeo.collapsed)) continue;
|
|
2562
|
+
const x = parentGeo ? parentGeo.ax + M.pad + incomingGeo.x : incomingGeo.ax;
|
|
2563
|
+
const y = parentGeo ? parentGeo.ay + parentGeo.contentTop + incomingGeo.y : incomingGeo.ay;
|
|
2564
|
+
const incomingEl = el("div", `cc-block k-${block.kind} cc-conflict-incoming`);
|
|
2565
|
+
setHTML(incomingEl, this.blockHTML(block, { ...incomingGeo, collapsed: true }, "", "incoming"));
|
|
2566
|
+
Object.assign(incomingEl.style, { transform: `translate(${x}px,${y}px)`, width: incomingGeo.w + "px", height: incomingGeo.h + "px", zIndex: String(block.depth + 1) });
|
|
2567
|
+
incomingEl.title = `${block.name}: only in the incoming side of a merge conflict`;
|
|
2568
|
+
this.blocksEl.appendChild(incomingEl);
|
|
2569
|
+
this.incomingBlockEls.push(incomingEl);
|
|
2570
|
+
}
|
|
2571
|
+
}
|
|
2572
|
+
/** A changed block's preview: its changed lines with a line of context, as many as fit. */
|
|
2573
|
+
changedLinesPreview(diff, block) {
|
|
2574
|
+
const lines = diffOwnCode(diff.base, diff.base.blocks.get(block.id), this.view, block);
|
|
2575
|
+
const fitting = Math.max(1, block.preview.length);
|
|
2576
|
+
const preview = diffPreview(lines, fitting);
|
|
2577
|
+
const marker = { same: " ", added: "+", removed: "\u2212" };
|
|
2578
|
+
const sharedIndent = Math.min(...preview.lines.map((line) => /^ */.exec(line.text)[0].length));
|
|
2579
|
+
const rows = preview.lines.map((line) => `<span class="cc-line-${line.kind}">${marker[line.kind]} ${esc(line.text.slice(sharedIndent))}</span>`);
|
|
2580
|
+
const more = preview.more ? `<span class="cc-more">\u2026 ${preview.more} more changed line${preview.more > 1 ? "s" : ""}</span>` : "";
|
|
2581
|
+
return rows.join("") + more;
|
|
2582
|
+
}
|
|
2583
|
+
/** Removed blocks stay visible, in red, where they were in the base version. They can't be selected. */
|
|
2584
|
+
renderRemovedBlocks(diff) {
|
|
2585
|
+
for (const removedEl of this.removedBlockEls) removedEl.remove();
|
|
2586
|
+
this.removedBlockEls = [];
|
|
2587
|
+
const geometry = this.geom;
|
|
2588
|
+
if (!diff?.removed.length || !this.diffBase || !geometry) return;
|
|
2589
|
+
this.diffBase.geometry ??= computeGeometry(diff.base, this.diffBase.layout ?? this.layout, {}, { resolveOverlaps: false });
|
|
2590
|
+
for (const block of diff.removed) {
|
|
2591
|
+
const baseGeo = this.diffBase.geometry.geo.get(block.id);
|
|
2592
|
+
const parentGeo = block.parentId ? geometry.geo.get(block.parentId) : void 0;
|
|
2593
|
+
const isInsideHiddenParent = !!parentGeo && (parentGeo.hidden || parentGeo.collapsed);
|
|
2594
|
+
if (!baseGeo || isInsideHiddenParent) continue;
|
|
2595
|
+
const x = parentGeo ? parentGeo.ax + M.pad + baseGeo.x : baseGeo.ax;
|
|
2596
|
+
const y = parentGeo ? parentGeo.ay + parentGeo.contentTop + baseGeo.y : baseGeo.ay;
|
|
2597
|
+
const removedEl = el("div", `cc-block k-${block.kind} cc-diff-removed`);
|
|
2598
|
+
setHTML(removedEl, this.blockHTML(block, { ...baseGeo, collapsed: true }, "", "removed"));
|
|
2599
|
+
Object.assign(removedEl.style, { transform: `translate(${x}px,${y}px)`, width: baseGeo.w + "px", height: baseGeo.h + "px", zIndex: String(block.depth + 1) });
|
|
2600
|
+
removedEl.title = `${block.name}: removed`;
|
|
2601
|
+
this.blocksEl.appendChild(removedEl);
|
|
2602
|
+
this.removedBlockEls.push(removedEl);
|
|
2603
|
+
}
|
|
2604
|
+
}
|
|
2605
|
+
/** Diagnostics grouped by the block that shows them: the innermost visible block containing each error. */
|
|
2606
|
+
diagnosticsByShownBlock() {
|
|
2607
|
+
const byBlock = /* @__PURE__ */ new Map();
|
|
2608
|
+
const geometry = this.geom;
|
|
2609
|
+
if (!geometry) return byBlock;
|
|
2610
|
+
for (const d of this.diagnostics) {
|
|
2611
|
+
let geo = d.blockId ? geometry.geo.get(d.blockId) : void 0;
|
|
2612
|
+
while (geo?.hidden && geo.block.parentId) geo = geometry.geo.get(geo.block.parentId);
|
|
2613
|
+
if (!geo) continue;
|
|
2614
|
+
const list = byBlock.get(geo.id);
|
|
2615
|
+
if (list) list.push(d);
|
|
2616
|
+
else byBlock.set(geo.id, [d]);
|
|
2617
|
+
}
|
|
2618
|
+
return byBlock;
|
|
2619
|
+
}
|
|
2620
|
+
updateErrorMarkers() {
|
|
2621
|
+
const byBlock = this.diagnosticsByShownBlock();
|
|
2622
|
+
for (const [id, { el: blockEl }] of this.els) {
|
|
2623
|
+
const list = byBlock.get(id);
|
|
2624
|
+
const head = blockEl.querySelector(".cc-head");
|
|
2625
|
+
let badge = head?.querySelector(".cc-err") ?? null;
|
|
2626
|
+
blockEl.classList.toggle("cc-has-error", !!list);
|
|
2627
|
+
if (!list) {
|
|
2628
|
+
badge?.remove();
|
|
2629
|
+
continue;
|
|
2630
|
+
}
|
|
2631
|
+
if (!badge && head) {
|
|
2632
|
+
badge = el("span", "cc-err");
|
|
2633
|
+
head.insertBefore(badge, head.querySelector(".cc-fold"));
|
|
2634
|
+
}
|
|
2635
|
+
if (!badge) continue;
|
|
2636
|
+
badge.textContent = String(list.length);
|
|
2637
|
+
badge.title = list.map((d) => `Line ${d.line}: ${d.message}`).join("\n");
|
|
2638
|
+
}
|
|
2639
|
+
}
|
|
2640
|
+
blockHTML(b, geo, mini, change, changedLines = "", isConflicted = false) {
|
|
2641
|
+
const label = blockLabel(b);
|
|
2642
|
+
const tags = b.tags.filter((t) => t !== label && t !== "export" && (b.kind === "import" || !LABEL_TAGS.has(t))).join(" ");
|
|
2643
|
+
const exp = b.tags.includes("export") ? '<span class="cc-exp" title="exported"></span>' : "";
|
|
2644
|
+
const isRemoved = change === "removed" || change === "incoming";
|
|
2645
|
+
const fold = b.children.length && !isRemoved ? `<button class="cc-fold" title="${geo.collapsed ? "Expand" : "Collapse"}">${geo.collapsed ? "\u25B8" : "\u25BE"}</button>` : "";
|
|
2646
|
+
const prev = changedLines ? `<pre class="cc-prev cc-diff-prev">${changedLines}</pre>` : b.preview.length ? `<pre class="cc-prev">${esc(b.preview.join("\n"))}${b.previewMore ? `
|
|
2647
|
+
<span class="cc-more">\u2026 ${b.previewMore} more line${b.previewMore > 1 ? "s" : ""}</span>` : ""}</pre>` : "";
|
|
2648
|
+
const diffChip = (change ? DIFF_CHIPS[change] : "") + (isConflicted ? '<span class="cc-conflict-chip" title="Merge conflict">!</span>' : "");
|
|
2649
|
+
return `<div class="cc-head"><span class="cc-kind">${esc(label)}</span>${exp}<span class="cc-name">${esc(b.name)}</span>${diffChip}<span class="cc-detail">${esc(
|
|
2650
|
+
b.detail
|
|
2651
|
+
)}</span>${tags ? `<span class="cc-tags">${esc(tags)}</span>` : ""}${fold}</div>${prev}${mini ? `<div class="cc-mini">${mini}</div>` : ""}`;
|
|
2652
|
+
}
|
|
2653
|
+
/** Faint miniature of a collapsed block's nested blocks. */
|
|
2654
|
+
miniMap(geo) {
|
|
2655
|
+
const kids = geo.block.children.map((c) => this.geom.geo.get(c.id));
|
|
2656
|
+
const right = Math.max(...kids.map((k) => k.x + k.w));
|
|
2657
|
+
const bottom = Math.max(...kids.map((k) => k.y + k.h));
|
|
2658
|
+
const bw = geo.w - 24;
|
|
2659
|
+
const bh = 34;
|
|
2660
|
+
const s = Math.min(bw / right, bh / bottom);
|
|
2661
|
+
return kids.map(
|
|
2662
|
+
(k) => `<i class="k-${k.block.kind}" style="left:${(k.x * s).toFixed(1)}px;top:${(k.y * s).toFixed(1)}px;width:${Math.max(2, k.w * s).toFixed(1)}px;height:${Math.max(
|
|
2663
|
+
2,
|
|
2664
|
+
k.h * s
|
|
2665
|
+
).toFixed(1)}px"></i>`
|
|
2666
|
+
).join("");
|
|
2667
|
+
}
|
|
2668
|
+
renderEdges() {
|
|
2669
|
+
const g = this.geom;
|
|
2670
|
+
const view = this.view;
|
|
2671
|
+
const visible = (id) => {
|
|
2672
|
+
let geo = g.geo.get(id);
|
|
2673
|
+
while (geo?.hidden && geo.block.parentId) geo = g.geo.get(geo.block.parentId);
|
|
2674
|
+
return geo;
|
|
2675
|
+
};
|
|
2676
|
+
const want = /* @__PURE__ */ new Map();
|
|
2677
|
+
for (const u of view.usages) {
|
|
2678
|
+
if (u.toPath) continue;
|
|
2679
|
+
const a = visible(u.from);
|
|
2680
|
+
const b = visible(u.to);
|
|
2681
|
+
if (!a || !b || a === b) continue;
|
|
2682
|
+
if (a.id.startsWith(b.id + "/") || b.id.startsWith(a.id + "/")) continue;
|
|
2683
|
+
const key = `${a.id}>${b.id}`;
|
|
2684
|
+
if (!want.has(key)) want.set(key, { from: a, to: b, kind: b.block.kind, usage: u.kind, uf: u.from, ut: u.to });
|
|
2685
|
+
}
|
|
2686
|
+
const old = new Map(this.edges.map((e) => [e.key, e]));
|
|
2687
|
+
const next = [];
|
|
2688
|
+
for (const [key, w] of want) {
|
|
2689
|
+
let e = old.get(key);
|
|
2690
|
+
if (e) old.delete(key);
|
|
2691
|
+
else {
|
|
2692
|
+
const p = document.createElementNS("http://www.w3.org/2000/svg", "path");
|
|
2693
|
+
this.svg.appendChild(p);
|
|
2694
|
+
e = { key, from: w.from.id, to: w.to.id, el: p };
|
|
2695
|
+
}
|
|
2696
|
+
e.el.setAttribute("class", `cc-edge u-${w.usage}`);
|
|
2697
|
+
e.el.setAttribute("stroke", `var(--k-${w.kind})`);
|
|
2698
|
+
e.el.setAttribute("marker-end", `url(#cc-arrow-${w.kind})`);
|
|
2699
|
+
e.el.setAttribute("d", edgePath(w.from, w.to));
|
|
2700
|
+
next.push(e);
|
|
2701
|
+
}
|
|
2702
|
+
for (const e of old.values()) e.el.remove();
|
|
2703
|
+
this.edges = next;
|
|
2704
|
+
}
|
|
2705
|
+
updateHighlight() {
|
|
2706
|
+
const h = this.selected ?? this.hovered;
|
|
2707
|
+
const inH = (id) => !!h && (id === h || id.startsWith(h + "/"));
|
|
2708
|
+
const linked = /* @__PURE__ */ new Set();
|
|
2709
|
+
for (const e of this.edges) {
|
|
2710
|
+
const on = inH(e.from) || inH(e.to);
|
|
2711
|
+
e.el.classList.toggle("hl", on);
|
|
2712
|
+
if (on) {
|
|
2713
|
+
linked.add(e.from);
|
|
2714
|
+
linked.add(e.to);
|
|
2715
|
+
}
|
|
2716
|
+
}
|
|
2717
|
+
this.root.classList.toggle("has-hl", !!h);
|
|
2718
|
+
for (const [id, { el: e }] of this.els) {
|
|
2719
|
+
e.classList.toggle("cc-sel", id === this.selected);
|
|
2720
|
+
e.classList.toggle("cc-linked", linked.has(id) && !inH(id));
|
|
2721
|
+
e.classList.toggle("cc-editing", !!this.editing && id === this.editing.id);
|
|
2722
|
+
}
|
|
2723
|
+
if (this.refsOpen) this.renderRefs();
|
|
2724
|
+
}
|
|
2725
|
+
// ---------------------------------------------------------------- camera
|
|
2726
|
+
queueCamera() {
|
|
2727
|
+
if (this.camQueued) return;
|
|
2728
|
+
this.camQueued = true;
|
|
2729
|
+
requestAnimationFrame(() => {
|
|
2730
|
+
this.camQueued = false;
|
|
2731
|
+
this.applyCamera();
|
|
2732
|
+
});
|
|
2733
|
+
}
|
|
2734
|
+
applyCamera() {
|
|
2735
|
+
const { x, y, s } = this.cam;
|
|
2736
|
+
this.world.style.transform = `translate(${x}px,${y}px) scale(${s})`;
|
|
2737
|
+
const grid = 24 * s;
|
|
2738
|
+
this.root.style.backgroundSize = s < 0.25 ? "0 0" : `${grid}px ${grid}px`;
|
|
2739
|
+
this.root.style.backgroundPosition = `${x}px ${y}px`;
|
|
2740
|
+
const lod = s < 0.3 ? 2 : s < 0.55 ? 1 : 0;
|
|
2741
|
+
if (lod !== this.lod) {
|
|
2742
|
+
this.lod = lod;
|
|
2743
|
+
this.root.classList.toggle("lod-1", lod >= 1);
|
|
2744
|
+
this.root.classList.toggle("lod-2", lod >= 2);
|
|
2745
|
+
}
|
|
2746
|
+
this.root.classList.add("cc-moving");
|
|
2747
|
+
clearTimeout(this.movingTimer);
|
|
2748
|
+
this.movingTimer = window.setTimeout(() => this.root.classList.remove("cc-moving"), 160);
|
|
2749
|
+
this.opts.onCameraChange?.(s);
|
|
2750
|
+
}
|
|
2751
|
+
setCamera(c) {
|
|
2752
|
+
this.fittedToView = false;
|
|
2753
|
+
cancelAnimationFrame(this.anim);
|
|
2754
|
+
this.cam = { x: c.x, y: c.y, s: clamp2(c.s, MIN_SCALE, MAX_SCALE) };
|
|
2755
|
+
this.queueCamera();
|
|
2756
|
+
}
|
|
2757
|
+
moveCamera(to, animate) {
|
|
2758
|
+
this.fittedToView = false;
|
|
2759
|
+
cancelAnimationFrame(this.anim);
|
|
2760
|
+
if (!animate || matchMedia("(prefers-reduced-motion: reduce)").matches) {
|
|
2761
|
+
this.setCamera(to);
|
|
2762
|
+
return;
|
|
2763
|
+
}
|
|
2764
|
+
const from = { ...this.cam };
|
|
2765
|
+
const t0 = performance.now();
|
|
2766
|
+
const dur = 280;
|
|
2767
|
+
const step = (now) => {
|
|
2768
|
+
const k = Math.min(1, (now - t0) / dur);
|
|
2769
|
+
const e = 1 - Math.pow(1 - k, 3);
|
|
2770
|
+
const s = Math.exp(Math.log(from.s) + (Math.log(to.s) - Math.log(from.s)) * e);
|
|
2771
|
+
this.cam = { s, x: from.x + (to.x - from.x) * e, y: from.y + (to.y - from.y) * e };
|
|
2772
|
+
this.applyCamera();
|
|
2773
|
+
if (k < 1) this.anim = requestAnimationFrame(step);
|
|
2774
|
+
};
|
|
2775
|
+
this.anim = requestAnimationFrame(step);
|
|
2776
|
+
}
|
|
2777
|
+
zoomAt(p, factor, animate = false) {
|
|
2778
|
+
const s = clamp2(this.cam.s * factor, MIN_SCALE, MAX_SCALE);
|
|
2779
|
+
const wx = (p.x - this.cam.x) / this.cam.s;
|
|
2780
|
+
const wy = (p.y - this.cam.y) / this.cam.s;
|
|
2781
|
+
this.moveCamera({ s, x: p.x - wx * s, y: p.y - wy * s }, animate);
|
|
2782
|
+
}
|
|
2783
|
+
toWorld(clientX, clientY) {
|
|
2784
|
+
const r = this.root.getBoundingClientRect();
|
|
2785
|
+
return { x: (clientX - r.left - this.cam.x) / this.cam.s, y: (clientY - r.top - this.cam.y) / this.cam.s };
|
|
2786
|
+
}
|
|
2787
|
+
local(e) {
|
|
2788
|
+
const r = this.root.getBoundingClientRect();
|
|
2789
|
+
return { x: e.clientX - r.left, y: e.clientY - r.top };
|
|
2790
|
+
}
|
|
2791
|
+
// ---------------------------------------------------------------- hit testing
|
|
2792
|
+
/** What's under a world point: a block (header or leaf), or empty space inside a container/the file. */
|
|
2793
|
+
hitTest(p) {
|
|
2794
|
+
const g = this.geom;
|
|
2795
|
+
if (!g) return { block: null, container: null };
|
|
2796
|
+
let hit = null;
|
|
2797
|
+
for (let i = g.order.length - 1; i >= 0; i--) {
|
|
2798
|
+
const geo = g.order[i];
|
|
2799
|
+
if (geo.hidden) continue;
|
|
2800
|
+
if (p.x >= geo.ax && p.x <= geo.ax + geo.w && p.y >= geo.ay && p.y <= geo.ay + geo.h) {
|
|
2801
|
+
if (!hit || geo.block.depth > hit.block.depth) hit = geo;
|
|
2802
|
+
}
|
|
2803
|
+
}
|
|
2804
|
+
if (!hit) return { block: null, container: null };
|
|
2805
|
+
const hasKids = hit.block.children.length > 0 && !hit.collapsed;
|
|
2806
|
+
if (hasKids && p.y > hit.ay + hit.contentTop) return { block: null, container: hit.id };
|
|
2807
|
+
return { block: hit.id, container: hit.block.parentId };
|
|
2808
|
+
}
|
|
2809
|
+
// ---------------------------------------------------------------- events
|
|
2810
|
+
bindEvents() {
|
|
2811
|
+
const r = this.root;
|
|
2812
|
+
const on = (t, type, fn, o) => {
|
|
2813
|
+
t.addEventListener(type, fn, o);
|
|
2814
|
+
this.cleanup.push(() => t.removeEventListener(type, fn, o));
|
|
2815
|
+
};
|
|
2816
|
+
on(r, "pointerdown", (e) => this.onDown(e));
|
|
2817
|
+
on(r, "pointermove", (e) => this.onMove(e));
|
|
2818
|
+
on(r, "pointerup", (e) => this.onUp(e));
|
|
2819
|
+
on(r, "pointercancel", (e) => this.onUp(e, true));
|
|
2820
|
+
on(r, "wheel", (e) => this.onWheel(e), { passive: false });
|
|
2821
|
+
on(r, "keydown", (e) => this.onKey(e));
|
|
2822
|
+
on(r, "pointerover", (e) => {
|
|
2823
|
+
if (e.pointerType !== "mouse" || this.gesture) return;
|
|
2824
|
+
const id = e.target.closest?.(".cc-block")?.getAttribute("data-id") ?? null;
|
|
2825
|
+
if (id !== this.hovered) {
|
|
2826
|
+
this.hovered = id;
|
|
2827
|
+
this.updateHighlight();
|
|
2828
|
+
}
|
|
2829
|
+
});
|
|
2830
|
+
on(r, "pointerleave", () => {
|
|
2831
|
+
if (this.hovered) {
|
|
2832
|
+
this.hovered = null;
|
|
2833
|
+
this.updateHighlight();
|
|
2834
|
+
}
|
|
2835
|
+
});
|
|
2836
|
+
on(r, "gesturestart", (e) => e.preventDefault());
|
|
2837
|
+
const doc = r.ownerDocument;
|
|
2838
|
+
on(doc, "copy", (e) => this.onClipboard(e, "copy"));
|
|
2839
|
+
on(doc, "cut", (e) => this.onClipboard(e, "cut"));
|
|
2840
|
+
on(doc, "paste", (e) => this.onClipboard(e, "paste"));
|
|
2841
|
+
}
|
|
2842
|
+
isOverlay(t) {
|
|
2843
|
+
return !!t?.closest?.(".cc-editor, .cc-bar, .cc-refs, .cc-banner, .cc-rename");
|
|
2844
|
+
}
|
|
2845
|
+
onDown(e) {
|
|
2846
|
+
if (this.isOverlay(e.target)) return;
|
|
2847
|
+
if (e.pointerType === "mouse" && e.button !== 0 && e.button !== 1) return;
|
|
2848
|
+
this.root.focus({ preventScroll: true });
|
|
2849
|
+
this.root.setPointerCapture(e.pointerId);
|
|
2850
|
+
this.pointers.set(e.pointerId, this.local(e));
|
|
2851
|
+
if (this.pointers.size === 2) {
|
|
2852
|
+
if (this.gesture?.t === "drag") this.endDrag(false);
|
|
2853
|
+
const [a, b] = [...this.pointers.values()];
|
|
2854
|
+
this.gesture = { t: "pinch", d0: Math.hypot(a.x - b.x, a.y - b.y), m0: mid(a, b), cam: { ...this.cam } };
|
|
2855
|
+
return;
|
|
2856
|
+
}
|
|
2857
|
+
if (this.pointers.size > 2) return;
|
|
2858
|
+
const p = this.local(e);
|
|
2859
|
+
if (e.button === 1) {
|
|
2860
|
+
this.gesture = { t: "pan", id: e.pointerId, sx: p.x, sy: p.y, cam: { ...this.cam } };
|
|
2861
|
+
return;
|
|
2862
|
+
}
|
|
2863
|
+
this.gesture = { t: "pending", id: e.pointerId, sx: p.x, sy: p.y, touch: e.pointerType !== "mouse", target: e.target };
|
|
2864
|
+
}
|
|
2865
|
+
onMove(e) {
|
|
2866
|
+
const p = this.local(e);
|
|
2867
|
+
if (e.pointerType === "mouse") this.lastPointer = this.toWorld(e.clientX, e.clientY);
|
|
2868
|
+
if (!this.pointers.has(e.pointerId)) return;
|
|
2869
|
+
this.pointers.set(e.pointerId, p);
|
|
2870
|
+
const g = this.gesture;
|
|
2871
|
+
if (!g) return;
|
|
2872
|
+
if (g.t === "pinch") {
|
|
2873
|
+
if (this.pointers.size < 2) return;
|
|
2874
|
+
const [a, b] = [...this.pointers.values()];
|
|
2875
|
+
const d = Math.hypot(a.x - b.x, a.y - b.y);
|
|
2876
|
+
const m = mid(a, b);
|
|
2877
|
+
const s = clamp2(g.cam.s * (d / g.d0), MIN_SCALE, MAX_SCALE);
|
|
2878
|
+
const wx = (g.m0.x - g.cam.x) / g.cam.s;
|
|
2879
|
+
const wy = (g.m0.y - g.cam.y) / g.cam.s;
|
|
2880
|
+
this.setCamera({ s, x: m.x - wx * s, y: m.y - wy * s });
|
|
2881
|
+
return;
|
|
2882
|
+
}
|
|
2883
|
+
if (g.id !== e.pointerId) return;
|
|
2884
|
+
const dx = p.x - g.sx;
|
|
2885
|
+
const dy = p.y - g.sy;
|
|
2886
|
+
if (g.t === "pending") {
|
|
2887
|
+
if (Math.hypot(dx, dy) < (g.touch ? 8 : 4)) return;
|
|
2888
|
+
const w = this.toWorld(e.clientX - dx, e.clientY - dy);
|
|
2889
|
+
const hit = g.target?.closest(".cc-fold") ? { block: null } : this.hitTest(w);
|
|
2890
|
+
const geo = hit.block ? this.geom.geo.get(hit.block) : null;
|
|
2891
|
+
if (geo && !this.stale) {
|
|
2892
|
+
this.gesture = { t: "drag", id: g.id, sx: g.sx, sy: g.sy, block: geo.id, orig: { x: geo.x, y: geo.y }, nested: !!geo.block.parentId };
|
|
2893
|
+
this.els.get(geo.id)?.el.classList.add("cc-dragging");
|
|
2894
|
+
this.hovered = geo.id;
|
|
2895
|
+
} else {
|
|
2896
|
+
this.gesture = { t: "pan", id: g.id, sx: g.sx, sy: g.sy, cam: { ...this.cam } };
|
|
2897
|
+
}
|
|
2898
|
+
return this.onMove(e);
|
|
2899
|
+
}
|
|
2900
|
+
if (g.t === "pan") {
|
|
2901
|
+
this.setCamera({ ...g.cam, x: g.cam.x + dx, y: g.cam.y + dy });
|
|
2902
|
+
} else if (g.t === "drag") {
|
|
2903
|
+
let x = g.orig.x + dx / this.cam.s;
|
|
2904
|
+
let y = g.orig.y + dy / this.cam.s;
|
|
2905
|
+
if (g.nested) x = Math.max(0, x), y = Math.max(0, y);
|
|
2906
|
+
this.layout.blocks[g.block] = { x: Math.round(x), y: Math.round(y) };
|
|
2907
|
+
if (!this.camQueued) this.relayoutSoon();
|
|
2908
|
+
}
|
|
2909
|
+
}
|
|
2910
|
+
relayoutQueued = false;
|
|
2911
|
+
relayoutSoon() {
|
|
2912
|
+
if (this.relayoutQueued) return;
|
|
2913
|
+
this.relayoutQueued = true;
|
|
2914
|
+
requestAnimationFrame(() => {
|
|
2915
|
+
this.relayoutQueued = false;
|
|
2916
|
+
if (!this.view) return;
|
|
2917
|
+
this.geom = computeGeometry(this.view, this.layout, this.hints, { resolveOverlaps: false });
|
|
2918
|
+
this.render();
|
|
2919
|
+
});
|
|
2920
|
+
}
|
|
2921
|
+
onUp(e, cancelled = false) {
|
|
2922
|
+
if (!this.pointers.has(e.pointerId)) return;
|
|
2923
|
+
this.pointers.delete(e.pointerId);
|
|
2924
|
+
const g = this.gesture;
|
|
2925
|
+
if (!g) return;
|
|
2926
|
+
if (g.t === "pinch") {
|
|
2927
|
+
if (this.pointers.size === 0) this.gesture = null;
|
|
2928
|
+
else {
|
|
2929
|
+
const [id, p] = [...this.pointers.entries()][0];
|
|
2930
|
+
this.gesture = { t: "pan", id, sx: p.x, sy: p.y, cam: { ...this.cam } };
|
|
2931
|
+
}
|
|
2932
|
+
return;
|
|
2933
|
+
}
|
|
2934
|
+
if (g.id !== e.pointerId) return;
|
|
2935
|
+
if (g.t === "drag") return this.endDrag(!cancelled);
|
|
2936
|
+
this.gesture = null;
|
|
2937
|
+
if (g.t !== "pending" || cancelled) return;
|
|
2938
|
+
this.onTap(e, g.target);
|
|
2939
|
+
}
|
|
2940
|
+
endDrag(commit) {
|
|
2941
|
+
const g = this.gesture?.t === "drag" ? this.gesture : null;
|
|
2942
|
+
this.gesture = null;
|
|
2943
|
+
if (!g) return;
|
|
2944
|
+
this.els.get(g.block)?.el.classList.remove("cc-dragging");
|
|
2945
|
+
if (!commit) {
|
|
2946
|
+
this.layout.blocks[g.block] = g.orig;
|
|
2947
|
+
this.relayout();
|
|
2948
|
+
return;
|
|
2949
|
+
}
|
|
2950
|
+
const geom = computeGeometry(this.view, this.layout, this.hints, { resolveOverlaps: false });
|
|
2951
|
+
const geo = geom.geo.get(g.block);
|
|
2952
|
+
if (geo) {
|
|
2953
|
+
const parent = geo.block.parentId;
|
|
2954
|
+
const sibs = [...geom.geo.values()].filter((s) => s.block.parentId === parent && s.id !== g.block);
|
|
2955
|
+
this.layout.blocks[g.block] = freeSpotNear({ x: geo.x, y: geo.y }, geo, sibs, !!parent);
|
|
2956
|
+
}
|
|
2957
|
+
this.relayout();
|
|
2958
|
+
this.emitLayout("user");
|
|
2959
|
+
}
|
|
2960
|
+
onTap(e, target) {
|
|
2961
|
+
const fold = target?.closest(".cc-fold");
|
|
2962
|
+
const w = this.toWorld(e.clientX, e.clientY);
|
|
2963
|
+
const hit = this.hitTest(w);
|
|
2964
|
+
const now = performance.now();
|
|
2965
|
+
const p = this.local(e);
|
|
2966
|
+
const dbl = now - this.lastTap.t < 350 && Math.hypot(p.x - this.lastTap.x, p.y - this.lastTap.y) < 24 && this.lastTap.id === (hit.block ?? "@" + hit.container);
|
|
2967
|
+
this.lastTap = { t: dbl ? 0 : now, x: p.x, y: p.y, id: hit.block ?? "@" + hit.container };
|
|
2968
|
+
if (fold && hit.block) return this.toggleCollapse(hit.block);
|
|
2969
|
+
if (hit.block) {
|
|
2970
|
+
if (this.selected === hit.block && !this.editing) return this.openEditor(hit.block);
|
|
2971
|
+
if (this.editing && this.editing.id !== hit.block) this.closeEditor();
|
|
2972
|
+
this.select(hit.block);
|
|
2973
|
+
return;
|
|
2974
|
+
}
|
|
2975
|
+
if (dbl) return this.createFunctionAt(w, hit.container);
|
|
2976
|
+
if (this.editing) this.closeEditor();
|
|
2977
|
+
this.select(hit.container);
|
|
2978
|
+
}
|
|
2979
|
+
onWheel(e) {
|
|
2980
|
+
if (this.isOverlay(e.target)) return;
|
|
2981
|
+
e.preventDefault();
|
|
2982
|
+
const unit = e.deltaMode === 1 ? 16 : e.deltaMode === 2 ? 400 : 1;
|
|
2983
|
+
const p = this.local(e);
|
|
2984
|
+
if (e.ctrlKey || e.metaKey) {
|
|
2985
|
+
this.zoomAt(p, Math.exp(-e.deltaY * unit * 25e-4));
|
|
2986
|
+
} else {
|
|
2987
|
+
const dx = (e.shiftKey && !e.deltaX ? e.deltaY : e.deltaX) * unit;
|
|
2988
|
+
const dy = (e.shiftKey && !e.deltaX ? 0 : e.deltaY) * unit;
|
|
2989
|
+
this.setCamera({ ...this.cam, x: this.cam.x - dx, y: this.cam.y - dy });
|
|
2990
|
+
}
|
|
2991
|
+
}
|
|
2992
|
+
onKey(e) {
|
|
2993
|
+
const isHandledByEditor = e.defaultPrevented;
|
|
2994
|
+
if (isHandledByEditor || this.isOverlay(e.target) && e.target !== this.root) return;
|
|
2995
|
+
const mod = e.metaKey || e.ctrlKey;
|
|
2996
|
+
const k = e.key;
|
|
2997
|
+
if (mod && k.toLowerCase() === "z") {
|
|
2998
|
+
e.preventDefault();
|
|
2999
|
+
return e.shiftKey ? this.opts.onRedo?.() : this.opts.onUndo?.();
|
|
3000
|
+
}
|
|
3001
|
+
if (mod && k.toLowerCase() === "y") {
|
|
3002
|
+
e.preventDefault();
|
|
3003
|
+
return this.opts.onRedo?.();
|
|
3004
|
+
}
|
|
3005
|
+
if (mod && k.toLowerCase() === "d" && this.selected) {
|
|
3006
|
+
e.preventDefault();
|
|
3007
|
+
return this.duplicate(this.selected);
|
|
3008
|
+
}
|
|
3009
|
+
if (mod) return;
|
|
3010
|
+
if (k === "Escape") return this.editing ? this.closeEditor() : this.select(null);
|
|
3011
|
+
if (k === "Enter" && this.selected) {
|
|
3012
|
+
e.preventDefault();
|
|
3013
|
+
return this.openEditor(this.selected);
|
|
3014
|
+
}
|
|
3015
|
+
if (k === "F2" && this.selected) {
|
|
3016
|
+
e.preventDefault();
|
|
3017
|
+
return this.startRename(this.selected);
|
|
3018
|
+
}
|
|
3019
|
+
if ((k === "Delete" || k === "Backspace") && this.selected) {
|
|
3020
|
+
e.preventDefault();
|
|
3021
|
+
return this.deleteBlock(this.selected);
|
|
3022
|
+
}
|
|
3023
|
+
if (k === "0" || k === "!") return this.fit();
|
|
3024
|
+
if (k === "=" || k === "+") return this.zoomBy(1.25);
|
|
3025
|
+
if (k === "-" || k === "_") return this.zoomBy(0.8);
|
|
3026
|
+
if (k === "n" || k === "N") return this.createFunctionAtCenter();
|
|
3027
|
+
if (k === "r" && this.selected) {
|
|
3028
|
+
this.refsOpen = !this.refsOpen;
|
|
3029
|
+
return this.renderBar();
|
|
3030
|
+
}
|
|
3031
|
+
}
|
|
3032
|
+
onClipboard(e, type) {
|
|
3033
|
+
const active = this.root.ownerDocument.activeElement;
|
|
3034
|
+
if (active !== this.root) return;
|
|
3035
|
+
if (type === "paste") {
|
|
3036
|
+
const custom = e.clipboardData?.getData("application/x-codecanvas");
|
|
3037
|
+
const textData = e.clipboardData?.getData("text/plain");
|
|
3038
|
+
let clip2 = null;
|
|
3039
|
+
if (custom) {
|
|
3040
|
+
try {
|
|
3041
|
+
clip2 = JSON.parse(custom);
|
|
3042
|
+
} catch {
|
|
3043
|
+
}
|
|
3044
|
+
}
|
|
3045
|
+
if (!clip2 && textData) clip2 = clipFromText(textData);
|
|
3046
|
+
if (clip2) {
|
|
3047
|
+
e.preventDefault();
|
|
3048
|
+
this.paste(clip2);
|
|
3049
|
+
}
|
|
3050
|
+
return;
|
|
3051
|
+
}
|
|
3052
|
+
if (!this.selected || !this.view || this.stale) return;
|
|
3053
|
+
const clip = copyBlock(this.view, this.selected);
|
|
3054
|
+
this.clip = clip;
|
|
3055
|
+
e.clipboardData?.setData("text/plain", clip.code);
|
|
3056
|
+
e.clipboardData?.setData("application/x-codecanvas", JSON.stringify(clip));
|
|
3057
|
+
e.preventDefault();
|
|
3058
|
+
if (type === "cut") this.deleteBlock(this.selected);
|
|
3059
|
+
else this.toast(`Copied ${clip.name || "block"}`);
|
|
3060
|
+
this.renderBar();
|
|
3061
|
+
}
|
|
3062
|
+
// ---------------------------------------------------------------- actions
|
|
3063
|
+
guard() {
|
|
3064
|
+
if (this.stale || !this.view) {
|
|
3065
|
+
this.toast(`Fix the syntax error on line ${this.model?.firstErrorLine} first`);
|
|
3066
|
+
return false;
|
|
3067
|
+
}
|
|
3068
|
+
return true;
|
|
3069
|
+
}
|
|
3070
|
+
/** Edits that add, remove or rename blocks work on the text's structure, which conflicts break. */
|
|
3071
|
+
guardStructureEdit() {
|
|
3072
|
+
if (!this.guard()) return false;
|
|
3073
|
+
if (this.conflicts) {
|
|
3074
|
+
this.toast("Resolve the merge conflicts in this file first");
|
|
3075
|
+
return false;
|
|
3076
|
+
}
|
|
3077
|
+
return true;
|
|
3078
|
+
}
|
|
3079
|
+
toggleCollapse(id) {
|
|
3080
|
+
const set = new Set(this.layout.collapsed ?? []);
|
|
3081
|
+
if (set.has(id)) set.delete(id);
|
|
3082
|
+
else set.add(id);
|
|
3083
|
+
this.layout.collapsed = [...set];
|
|
3084
|
+
this.relayout();
|
|
3085
|
+
this.emitLayout("user");
|
|
3086
|
+
}
|
|
3087
|
+
createFunctionAt(w, containerId) {
|
|
3088
|
+
if (!this.guardStructureEdit()) return;
|
|
3089
|
+
const view = this.view;
|
|
3090
|
+
const container = containerFor(view, containerId);
|
|
3091
|
+
const r = newFunction(view, container?.id ?? null);
|
|
3092
|
+
this.hints[r.id] = this.relativeTo(container?.id ?? null, { x: w.x - 24, y: w.y - 16 });
|
|
3093
|
+
this.applyInsert(r);
|
|
3094
|
+
}
|
|
3095
|
+
createFunctionAtCenter() {
|
|
3096
|
+
const rect = this.root.getBoundingClientRect();
|
|
3097
|
+
const w = this.toWorld(rect.left + rect.width / 2, rect.top + rect.height / 2);
|
|
3098
|
+
const target = this.selected ? containerFor(this.view, this.selected)?.id ?? null : this.hitTest(w).container;
|
|
3099
|
+
if (target) {
|
|
3100
|
+
const g = this.geom.geo.get(target);
|
|
3101
|
+
return this.createFunctionAt({ x: g.ax + M.pad + 24, y: g.ay + g.h }, target);
|
|
3102
|
+
}
|
|
3103
|
+
this.createFunctionAt(w, null);
|
|
3104
|
+
}
|
|
3105
|
+
/** Convert a world point into coordinates relative to a container's content origin. */
|
|
3106
|
+
relativeTo(containerId, w) {
|
|
3107
|
+
if (!containerId) return { x: Math.round(w.x), y: Math.round(w.y) };
|
|
3108
|
+
const g = this.geom.geo.get(containerId);
|
|
3109
|
+
return { x: Math.max(0, Math.round(w.x - g.ax - M.pad)), y: Math.max(0, Math.round(w.y - g.ay - g.contentTop)) };
|
|
3110
|
+
}
|
|
3111
|
+
applyInsert(r, edit = true) {
|
|
3112
|
+
this.commit(applyEdit(this.text, r.edit));
|
|
3113
|
+
if (r.id && this.view?.blocks.has(r.id)) {
|
|
3114
|
+
this.select(r.id);
|
|
3115
|
+
if (edit) this.openEditor(r.id, r.select);
|
|
3116
|
+
}
|
|
3117
|
+
}
|
|
3118
|
+
deleteBlock(id) {
|
|
3119
|
+
if (!this.guardStructureEdit()) return;
|
|
3120
|
+
const b = this.view.blocks.get(id);
|
|
3121
|
+
if (!b) return;
|
|
3122
|
+
if (this.editing) this.closeEditor();
|
|
3123
|
+
this.select(b.parentId);
|
|
3124
|
+
this.commit(applyEdit(this.text, deleteBlock(this.view, id)));
|
|
3125
|
+
this.toast(`Deleted ${b.name}. Undo with ${isMac() ? "\u2318" : "Ctrl"}+Z`);
|
|
3126
|
+
}
|
|
3127
|
+
duplicate(id) {
|
|
3128
|
+
if (!this.guardStructureEdit()) return;
|
|
3129
|
+
const view = this.view;
|
|
3130
|
+
const b = view.blocks.get(id);
|
|
3131
|
+
if (!b || b.kind === "import" || b.kind === "statement") return;
|
|
3132
|
+
const clip = copyBlock(view, id);
|
|
3133
|
+
const r = pasteBlock(view, b.parentId, clip);
|
|
3134
|
+
const geo = this.geom.geo.get(id);
|
|
3135
|
+
if (r.id) this.hints[r.id] = { x: geo.x + 24, y: geo.y + geo.h + M.gap };
|
|
3136
|
+
this.applyInsert(r, false);
|
|
3137
|
+
}
|
|
3138
|
+
copy(id) {
|
|
3139
|
+
if (!this.view || this.stale) return;
|
|
3140
|
+
this.clip = copyBlock(this.view, id);
|
|
3141
|
+
navigator.clipboard?.writeText(this.clip.code).catch(() => {
|
|
3142
|
+
});
|
|
3143
|
+
this.toast(`Copied ${this.clip.name}`);
|
|
3144
|
+
this.renderBar();
|
|
3145
|
+
}
|
|
3146
|
+
paste(clip = this.clip) {
|
|
3147
|
+
if (!clip || !this.guardStructureEdit()) return;
|
|
3148
|
+
const view = this.view;
|
|
3149
|
+
let target = null;
|
|
3150
|
+
let at = null;
|
|
3151
|
+
if (this.lastPointer) {
|
|
3152
|
+
const hit = this.hitTest(this.lastPointer);
|
|
3153
|
+
target = hit.block ? containerFor(view, hit.block)?.id ?? null : hit.container;
|
|
3154
|
+
at = this.lastPointer;
|
|
3155
|
+
} else if (this.selected) {
|
|
3156
|
+
target = containerFor(view, this.selected)?.id ?? null;
|
|
3157
|
+
}
|
|
3158
|
+
const r = pasteBlock(view, target, clip);
|
|
3159
|
+
if (r.id && at) this.hints[r.id] = this.relativeTo(target, { x: at.x - 24, y: at.y - 16 });
|
|
3160
|
+
this.applyInsert(r, false);
|
|
3161
|
+
}
|
|
3162
|
+
// ---------------------------------------------------------------- rename
|
|
3163
|
+
/** Show a name field in the block's header. Enter renames the block and its usages in every file. */
|
|
3164
|
+
startRename(id) {
|
|
3165
|
+
if (!this.guardStructureEdit()) return;
|
|
3166
|
+
const block = this.view.blocks.get(id);
|
|
3167
|
+
if (!block) return;
|
|
3168
|
+
if (!isRenamable(block)) return this.toast(`${block.name} can't be renamed`);
|
|
3169
|
+
if (this.editing) this.closeEditor();
|
|
3170
|
+
this.cancelRename();
|
|
3171
|
+
this.select(id);
|
|
3172
|
+
if (this.cam.s < 0.6) this.reveal(id);
|
|
3173
|
+
const nameEl = this.els.get(id)?.el.querySelector(".cc-name");
|
|
3174
|
+
if (!nameEl) return;
|
|
3175
|
+
const input = document.createElement("input");
|
|
3176
|
+
input.className = "cc-rename";
|
|
3177
|
+
input.value = block.name;
|
|
3178
|
+
input.spellcheck = false;
|
|
3179
|
+
input.setAttribute("aria-label", `New name for ${block.name}`);
|
|
3180
|
+
const fitWidth = () => input.style.width = `${Math.max(input.value.length, 4) + 2}ch`;
|
|
3181
|
+
fitWidth();
|
|
3182
|
+
input.addEventListener("input", () => {
|
|
3183
|
+
fitWidth();
|
|
3184
|
+
input.classList.remove("invalid");
|
|
3185
|
+
});
|
|
3186
|
+
input.addEventListener("keydown", (e) => {
|
|
3187
|
+
e.stopPropagation();
|
|
3188
|
+
if (e.key === "Enter") {
|
|
3189
|
+
e.preventDefault();
|
|
3190
|
+
this.commitRename();
|
|
3191
|
+
} else if (e.key === "Escape") {
|
|
3192
|
+
e.preventDefault();
|
|
3193
|
+
this.cancelRename();
|
|
3194
|
+
this.root.focus({ preventScroll: true });
|
|
3195
|
+
}
|
|
3196
|
+
});
|
|
3197
|
+
input.addEventListener("blur", () => this.cancelRename());
|
|
3198
|
+
nameEl.replaceChildren(input);
|
|
3199
|
+
this.renaming = { id, input };
|
|
3200
|
+
input.focus({ preventScroll: true });
|
|
3201
|
+
input.select();
|
|
3202
|
+
}
|
|
3203
|
+
cancelRename() {
|
|
3204
|
+
const renaming = this.renaming;
|
|
3205
|
+
if (!renaming) return;
|
|
3206
|
+
this.renaming = null;
|
|
3207
|
+
const name = this.view?.blocks.get(renaming.id)?.name ?? "";
|
|
3208
|
+
renaming.input.parentElement?.replaceChildren(document.createTextNode(name));
|
|
3209
|
+
}
|
|
3210
|
+
async commitRename() {
|
|
3211
|
+
const renaming = this.renaming;
|
|
3212
|
+
const block = renaming && this.view?.blocks.get(renaming.id);
|
|
3213
|
+
if (!renaming || !block) return this.cancelRename();
|
|
3214
|
+
if (renaming.isApplying) return;
|
|
3215
|
+
const newName = renaming.input.value.trim();
|
|
3216
|
+
if (newName === block.name) {
|
|
3217
|
+
this.cancelRename();
|
|
3218
|
+
return this.root.focus({ preventScroll: true });
|
|
3219
|
+
}
|
|
3220
|
+
renaming.isApplying = true;
|
|
3221
|
+
const textAtRequest = this.text;
|
|
3222
|
+
const result = await this.project.renameBlock(this.path, block.id, newName);
|
|
3223
|
+
renaming.isApplying = false;
|
|
3224
|
+
if (this.renaming !== renaming) return;
|
|
3225
|
+
if (this.text !== textAtRequest) {
|
|
3226
|
+
renaming.input.classList.add("invalid");
|
|
3227
|
+
return this.toast("The file changed. Press Enter to rename again");
|
|
3228
|
+
}
|
|
3229
|
+
if (!result.ok) {
|
|
3230
|
+
renaming.input.classList.add("invalid");
|
|
3231
|
+
return this.toast(result.error);
|
|
3232
|
+
}
|
|
3233
|
+
this.renaming = null;
|
|
3234
|
+
const ownEdits = result.edits[this.path] ?? [];
|
|
3235
|
+
const shiftBeforeBlock = ownEdits.filter((e) => e.end <= block.start).reduce((sum, e) => sum + e.text.length - (e.end - e.start), 0);
|
|
3236
|
+
const otherPaths = Object.keys(result.edits).filter((path) => path !== this.path);
|
|
3237
|
+
const otherFiles = otherPaths.map((path) => {
|
|
3238
|
+
const previousModel = this.project.getModel(path);
|
|
3239
|
+
const before = this.project.getText(path) ?? "";
|
|
3240
|
+
const after = applyEdits(before, result.edits[path]);
|
|
3241
|
+
this.project.setFile(path, after);
|
|
3242
|
+
return { path, before, after, previousModel };
|
|
3243
|
+
});
|
|
3244
|
+
this.commit(applyEdits(this.text, ownEdits), otherFiles.length ? otherFiles : void 0);
|
|
3245
|
+
const renamed = [...this.view?.blocks.values() ?? []].find((b) => b.start === block.start + shiftBeforeBlock && b.name === newName);
|
|
3246
|
+
if (renamed) this.select(renamed.id);
|
|
3247
|
+
this.root.focus({ preventScroll: true });
|
|
3248
|
+
this.toast(otherPaths.length ? `Renamed to ${newName} in ${otherPaths.length + 1} files` : `Renamed to ${newName}`);
|
|
3249
|
+
}
|
|
3250
|
+
// ---------------------------------------------------------------- inline editor
|
|
3251
|
+
openEditor(id, selection) {
|
|
3252
|
+
if (!this.guard()) return;
|
|
3253
|
+
const b = this.view.blocks.get(id);
|
|
3254
|
+
if (!b) return;
|
|
3255
|
+
if (this.conflicts) {
|
|
3256
|
+
const touching = (this.conflicts.hunksByBlock.get(id) ?? []).map((index) => this.conflicts.hunks[index]);
|
|
3257
|
+
const start = Math.min(this.conflicts.current.toFileOffset(b.start), ...touching.map((hunk) => hunk.start));
|
|
3258
|
+
const end = Math.max(this.conflicts.current.toFileOffset(b.end), ...touching.map((hunk) => hunk.end));
|
|
3259
|
+
return this.openRangeEditor(start, end, b);
|
|
3260
|
+
}
|
|
3261
|
+
this.openRangeEditor(b.start, b.end, b, selection);
|
|
3262
|
+
}
|
|
3263
|
+
openRangeEditor(start, end, b, selection) {
|
|
3264
|
+
this.closeEditor({ restoreCamera: false });
|
|
3265
|
+
this.refsOpen = false;
|
|
3266
|
+
const panel = el("div", "cc-editor");
|
|
3267
|
+
const head = el("div", "cc-editor-head");
|
|
3268
|
+
const chip = el("div", "cc-chip");
|
|
3269
|
+
chip.classList.add(`k-${b?.kind ?? "statement"}`);
|
|
3270
|
+
chip.style.setProperty("--k", `var(--k-${b?.kind ?? "statement"})`);
|
|
3271
|
+
setHTML(chip, `<i></i><span>${esc(b ? b.name : this.path)}</span>`);
|
|
3272
|
+
const hint = el("span", "cc-editor-hint");
|
|
3273
|
+
hint.textContent = "Esc to close";
|
|
3274
|
+
head.append(chip, hint, button("Done", () => this.closeEditor(), "primary"));
|
|
3275
|
+
const body = el("div", "cc-editor-body");
|
|
3276
|
+
panel.append(head, body);
|
|
3277
|
+
this.root.appendChild(panel);
|
|
3278
|
+
const code = this.text.slice(start, end);
|
|
3279
|
+
const lines = code.split("\n").length;
|
|
3280
|
+
const { width: vw, height: vh } = this.root.getBoundingClientRect();
|
|
3281
|
+
const mobile = vw < 700;
|
|
3282
|
+
if (mobile) {
|
|
3283
|
+
Object.assign(panel.style, { left: "0", right: "0", bottom: "0", height: Math.round(vh * 0.52) + "px", borderRadius: "14px 14px 0 0" });
|
|
3284
|
+
} else {
|
|
3285
|
+
const w = Math.min(640, vw - 32);
|
|
3286
|
+
const h = Math.min(vh - 32, Math.max(160, lines * 19 + 70));
|
|
3287
|
+
Object.assign(panel.style, { right: "16px", top: "16px", width: w + "px", height: h + "px" });
|
|
3288
|
+
}
|
|
3289
|
+
if (b) {
|
|
3290
|
+
const g = this.geom?.geo.get(b.id);
|
|
3291
|
+
if (g) {
|
|
3292
|
+
const s = this.cam.s;
|
|
3293
|
+
const sx = g.ax * s + this.cam.x;
|
|
3294
|
+
const sy = g.ay * s + this.cam.y;
|
|
3295
|
+
let dx = 0, dy = 0;
|
|
3296
|
+
if (mobile) {
|
|
3297
|
+
const avail = vh * 0.48;
|
|
3298
|
+
if (sy < 12 || sy > avail - 60) dy = 24 - sy;
|
|
3299
|
+
if (sx < 12 || sx + Math.min(g.w * s, vw) > vw) dx = 16 - sx;
|
|
3300
|
+
} else {
|
|
3301
|
+
const right = vw - Math.min(640, vw - 32) - 32;
|
|
3302
|
+
if (sx + g.w * s > right) dx = Math.max(right - (sx + g.w * s), 24 - sx);
|
|
3303
|
+
if (sx + dx < 12) dx = 24 - sx;
|
|
3304
|
+
if (sy < 12 || sy > vh - 80) dy = 40 - sy;
|
|
3305
|
+
}
|
|
3306
|
+
if (dx || dy) {
|
|
3307
|
+
const shifted = { ...this.cam, x: this.cam.x + dx, y: this.cam.y + dy };
|
|
3308
|
+
this.cameraBeforeEditor = { before: this.cameraBeforeEditor?.before ?? { ...this.cam }, shifted };
|
|
3309
|
+
this.moveCamera(shifted, true);
|
|
3310
|
+
}
|
|
3311
|
+
}
|
|
3312
|
+
}
|
|
3313
|
+
const handle = this.opts.createEditor({
|
|
3314
|
+
parent: body,
|
|
3315
|
+
text: code,
|
|
3316
|
+
start,
|
|
3317
|
+
end,
|
|
3318
|
+
selection,
|
|
3319
|
+
diffBase: this.conflicts ? void 0 : this.baseCodeOf(b),
|
|
3320
|
+
onChange: (t) => this.onEditorChange(t),
|
|
3321
|
+
onClose: () => {
|
|
3322
|
+
this.closeEditor();
|
|
3323
|
+
this.root.focus({ preventScroll: true });
|
|
3324
|
+
}
|
|
3325
|
+
});
|
|
3326
|
+
this.editing = { start, len: code.length, id: b?.id ?? null, handle, el: panel };
|
|
3327
|
+
if (b) this.selected = b.id;
|
|
3328
|
+
this.banner.querySelector("button")?.remove();
|
|
3329
|
+
this.updateHighlight();
|
|
3330
|
+
this.renderBar();
|
|
3331
|
+
handle.focus();
|
|
3332
|
+
}
|
|
3333
|
+
onEditorChange(code) {
|
|
3334
|
+
const ed = this.editing;
|
|
3335
|
+
if (!ed) return;
|
|
3336
|
+
const text = this.text.slice(0, ed.start) + code + this.text.slice(ed.start + ed.len);
|
|
3337
|
+
ed.len = code.length;
|
|
3338
|
+
this.commit(text);
|
|
3339
|
+
}
|
|
3340
|
+
closeEditor({ restoreCamera = true } = {}) {
|
|
3341
|
+
const ed = this.editing;
|
|
3342
|
+
if (!ed) return;
|
|
3343
|
+
this.editing = null;
|
|
3344
|
+
const cameraBeforeEditor = this.cameraBeforeEditor;
|
|
3345
|
+
if (restoreCamera) {
|
|
3346
|
+
this.cameraBeforeEditor = null;
|
|
3347
|
+
const isStillWhereEditorPutIt = !!cameraBeforeEditor && Math.abs(this.cam.x - cameraBeforeEditor.shifted.x) < 1 && Math.abs(this.cam.y - cameraBeforeEditor.shifted.y) < 1 && this.cam.s === cameraBeforeEditor.shifted.s;
|
|
3348
|
+
if (isStillWhereEditorPutIt) this.moveCamera(cameraBeforeEditor.before, true);
|
|
3349
|
+
}
|
|
3350
|
+
ed.handle.destroy();
|
|
3351
|
+
ed.el.remove();
|
|
3352
|
+
if (this.stale) this.parseNow();
|
|
3353
|
+
this.updateHighlight();
|
|
3354
|
+
this.renderBar();
|
|
3355
|
+
}
|
|
3356
|
+
// ---------------------------------------------------------------- toolbar & references
|
|
3357
|
+
renderBar() {
|
|
3358
|
+
const bar = this.bar;
|
|
3359
|
+
bar.replaceChildren();
|
|
3360
|
+
bar.hidden = !!this.editing;
|
|
3361
|
+
const b = this.selected ? this.view?.blocks.get(this.selected) : null;
|
|
3362
|
+
if (b) {
|
|
3363
|
+
const chip = el("div", "cc-chip");
|
|
3364
|
+
chip.style.setProperty("--k", `var(--k-${b.kind})`);
|
|
3365
|
+
setHTML(chip, `<i></i>${esc(b.name)}`);
|
|
3366
|
+
const refs = this.refsFor(b.id);
|
|
3367
|
+
const errors = this.diagnosticsInside(b.id);
|
|
3368
|
+
if (this.conflicts) {
|
|
3369
|
+
const isConflicted = this.conflicts.hunksByBlock.has(b.id);
|
|
3370
|
+
const firstHunk = isConflicted ? this.conflicts.hunks[this.conflicts.hunksByBlock.get(b.id)[0]] : void 0;
|
|
3371
|
+
const keep = (label, choice, side) => {
|
|
3372
|
+
const keepButton = button(label, () => this.keepConflictSide(b.id, choice), choice === "current" ? "primary" : void 0);
|
|
3373
|
+
if (side) keepButton.title = side;
|
|
3374
|
+
return keepButton;
|
|
3375
|
+
};
|
|
3376
|
+
bar.append(
|
|
3377
|
+
chip,
|
|
3378
|
+
el("div", "cc-sep"),
|
|
3379
|
+
...firstHunk ? [keep("Keep current", "current", firstHunk.currentLabel), keep("Keep incoming", "incoming", firstHunk.incomingLabel), keep("Keep both", "both"), el("div", "cc-sep")] : [],
|
|
3380
|
+
button("Edit", () => this.openEditor(b.id)),
|
|
3381
|
+
button("Copy", () => this.copy(b.id))
|
|
3382
|
+
);
|
|
3383
|
+
return this.renderRefs();
|
|
3384
|
+
}
|
|
3385
|
+
bar.append(
|
|
3386
|
+
chip,
|
|
3387
|
+
el("div", "cc-sep"),
|
|
3388
|
+
...errors.length ? [button(plural(errors.length, "error"), () => this.toggleRefs(), "danger")] : [],
|
|
3389
|
+
button("Edit", () => this.openEditor(b.id)),
|
|
3390
|
+
...isRenamable(b) ? [button("Rename", () => this.startRename(b.id))] : [],
|
|
3391
|
+
button(`Refs ${refs.incoming.length + refs.outgoing.length}`, () => this.toggleRefs()),
|
|
3392
|
+
button("Copy", () => this.copy(b.id)),
|
|
3393
|
+
...b.kind !== "import" && b.kind !== "statement" ? [button("Duplicate", () => this.duplicate(b.id))] : [],
|
|
3394
|
+
...b.insert ? [button("+ Inside", () => this.createFunctionAtCenter())] : [],
|
|
3395
|
+
button("Delete", () => this.deleteBlock(b.id), "danger")
|
|
3396
|
+
);
|
|
3397
|
+
} else {
|
|
3398
|
+
const firstErrorBlock = this.diagnostics.find((d) => d.blockId)?.blockId;
|
|
3399
|
+
if (firstErrorBlock) bar.append(button(plural(this.diagnostics.length, "error"), () => this.showErrorsOf(firstErrorBlock), "danger"));
|
|
3400
|
+
const diff = this.currentDiff();
|
|
3401
|
+
if (diff) bar.append(changesSummary(diff), el("div", "cc-sep"));
|
|
3402
|
+
bar.append(button("+ Function", () => this.createFunctionAtCenter()));
|
|
3403
|
+
if (this.clip) bar.append(button("Paste", () => (this.lastPointer = null, this.paste())));
|
|
3404
|
+
bar.append(
|
|
3405
|
+
this.layoutBeforeArrange ? button("Undo arrange", () => this.undoArrange()) : button("Arrange", () => this.arrange()),
|
|
3406
|
+
el("div", "cc-sep"),
|
|
3407
|
+
button("Fit", () => this.fit()),
|
|
3408
|
+
button("\u2212", () => this.zoomBy(0.8)),
|
|
3409
|
+
button("+", () => this.zoomBy(1.25))
|
|
3410
|
+
);
|
|
3411
|
+
}
|
|
3412
|
+
this.renderRefs();
|
|
3413
|
+
}
|
|
3414
|
+
toggleRefs() {
|
|
3415
|
+
this.refsOpen = !this.refsOpen;
|
|
3416
|
+
this.renderBar();
|
|
3417
|
+
}
|
|
3418
|
+
showErrorsOf(id) {
|
|
3419
|
+
this.reveal(id);
|
|
3420
|
+
this.refsOpen = true;
|
|
3421
|
+
this.renderBar();
|
|
3422
|
+
}
|
|
3423
|
+
diagnosticsInside(id) {
|
|
3424
|
+
return this.diagnostics.filter((d) => d.blockId === id || !!d.blockId?.startsWith(id + "/"));
|
|
3425
|
+
}
|
|
3426
|
+
refsFor(id) {
|
|
3427
|
+
const usages = this.view?.usages ?? [];
|
|
3428
|
+
const inside = (x) => x === id || x.startsWith(id + "/");
|
|
3429
|
+
const importsReachedElsewhere = new Set(usages.filter((u) => u.toPath && u.via).map((u) => `${u.from}>${u.via}`));
|
|
3430
|
+
const isImportLinkWithTargets = (u) => !u.toPath && importsReachedElsewhere.has(`${u.from}>${u.to}`);
|
|
3431
|
+
return {
|
|
3432
|
+
incoming: [...usages.filter((u) => !u.toPath && inside(u.to) && !inside(u.from)), ...this.incomingUsages.filter((u) => inside(u.to))],
|
|
3433
|
+
outgoing: usages.filter((u) => inside(u.from) && (u.toPath || !inside(u.to)) && !isImportLinkWithTargets(u))
|
|
3434
|
+
};
|
|
3435
|
+
}
|
|
3436
|
+
renderRefs() {
|
|
3437
|
+
const panel = this.refsEl;
|
|
3438
|
+
const selected = this.selected ? this.view?.blocks.get(this.selected) : null;
|
|
3439
|
+
panel.hidden = !this.refsOpen || !selected || !!this.editing;
|
|
3440
|
+
if (panel.hidden || !selected) return;
|
|
3441
|
+
const { incoming, outgoing } = this.refsFor(selected.id);
|
|
3442
|
+
const errors = this.diagnosticsInside(selected.id);
|
|
3443
|
+
const heading = (text) => Object.assign(el("h4"), { textContent: text });
|
|
3444
|
+
const note = (text) => Object.assign(el("div", "none"), { textContent: text });
|
|
3445
|
+
const row = (path, blockId, usage) => {
|
|
3446
|
+
const isOtherFile = !!path && path !== this.path;
|
|
3447
|
+
const model = isOtherFile ? this.project.getModel(path) : this.view;
|
|
3448
|
+
const block = model?.blocks.get(blockId);
|
|
3449
|
+
const btn = keepsCanvasFocus(document.createElement("button"));
|
|
3450
|
+
btn.style.setProperty("--k", `var(--k-${block?.kind ?? "statement"})`);
|
|
3451
|
+
const name = block && model ? displayName(model, block) : blockId;
|
|
3452
|
+
const label = isOtherFile ? `<span class="cc-file">${esc(fileName(path))} \u203A</span> ${esc(name)}` : esc(name);
|
|
3453
|
+
setHTML(btn, `<i></i><span>${label}</span><span>${usage.kind}${usage.count > 1 ? " \xD7" + usage.count : ""}</span>`);
|
|
3454
|
+
btn.onclick = () => isOtherFile ? this.opts.onOpenBlock?.(path, blockId) : this.reveal(blockId);
|
|
3455
|
+
btn.disabled = isOtherFile && !this.opts.onOpenBlock;
|
|
3456
|
+
return btn;
|
|
3457
|
+
};
|
|
3458
|
+
panel.replaceChildren();
|
|
3459
|
+
if (errors.length) {
|
|
3460
|
+
panel.append(heading(`Errors (${errors.length})`));
|
|
3461
|
+
for (const d of errors) {
|
|
3462
|
+
const btn = keepsCanvasFocus(document.createElement("button"));
|
|
3463
|
+
btn.className = "cc-error-row";
|
|
3464
|
+
setHTML(btn, `<i></i><span>${esc(d.message)}</span><span>line ${d.line}</span>`);
|
|
3465
|
+
btn.onclick = () => this.openEditorAtDiagnostic(d);
|
|
3466
|
+
panel.append(btn);
|
|
3467
|
+
}
|
|
3468
|
+
}
|
|
3469
|
+
panel.append(heading(`Used by (${incoming.length})`));
|
|
3470
|
+
if (incoming.length) for (const u of incoming) panel.append(row("fromPath" in u ? u.fromPath : void 0, u.from, u));
|
|
3471
|
+
else panel.append(note("Not used anywhere"));
|
|
3472
|
+
panel.append(heading(`Uses (${outgoing.length})`));
|
|
3473
|
+
if (outgoing.length) for (const u of outgoing) panel.append(row(u.toPath, u.to, u));
|
|
3474
|
+
else panel.append(note("Uses nothing from this project"));
|
|
3475
|
+
}
|
|
3476
|
+
openEditorAtDiagnostic(d) {
|
|
3477
|
+
if (this.stale) return this.openRangeEditor(0, this.text.length, null, [d.start, d.start + d.length]);
|
|
3478
|
+
const block = d.blockId ? this.view?.blocks.get(d.blockId) : void 0;
|
|
3479
|
+
if (!block) return;
|
|
3480
|
+
const offset = d.start - block.start;
|
|
3481
|
+
this.openEditor(block.id, [offset, offset + d.length]);
|
|
3482
|
+
}
|
|
3483
|
+
toast(msg) {
|
|
3484
|
+
this.toastEl.textContent = msg;
|
|
3485
|
+
this.toastEl.style.opacity = "1";
|
|
3486
|
+
clearTimeout(this.toastTimer);
|
|
3487
|
+
this.toastTimer = window.setTimeout(() => this.toastEl.style.opacity = "0", 2200);
|
|
3488
|
+
}
|
|
3489
|
+
};
|
|
3490
|
+
function blockLabel(b) {
|
|
3491
|
+
if (b.kind === "function") return b.tags.find((t) => t === "get" || t === "set") ?? "fn";
|
|
3492
|
+
if (b.kind === "variable" || b.kind === "type") return b.tags.find((t) => LABEL_TAGS.has(t)) ?? KIND_LABEL[b.kind];
|
|
3493
|
+
return KIND_LABEL[b.kind];
|
|
3494
|
+
}
|
|
3495
|
+
function innermostBlocksTouching(model, range) {
|
|
3496
|
+
const touches = (block) => range.start === range.end ? block.start < range.start && range.start < block.end : block.start < range.end && range.start < block.end;
|
|
3497
|
+
const found = [];
|
|
3498
|
+
const visit = (blocks) => {
|
|
3499
|
+
for (const block of blocks.filter(touches)) {
|
|
3500
|
+
const touchingChildren = block.children.filter(touches);
|
|
3501
|
+
if (touchingChildren.length) visit(block.children);
|
|
3502
|
+
else found.push(block);
|
|
3503
|
+
}
|
|
3504
|
+
};
|
|
3505
|
+
visit(model.roots);
|
|
3506
|
+
return found;
|
|
3507
|
+
}
|
|
3508
|
+
function changedRegion2(before, after) {
|
|
3509
|
+
let start = 0;
|
|
3510
|
+
const shorter = Math.min(before.length, after.length);
|
|
3511
|
+
while (start < shorter && before.charCodeAt(start) === after.charCodeAt(start)) start++;
|
|
3512
|
+
let fromEnd = 0;
|
|
3513
|
+
while (fromEnd < shorter - start && before.charCodeAt(before.length - 1 - fromEnd) === after.charCodeAt(after.length - 1 - fromEnd)) fromEnd++;
|
|
3514
|
+
return { start, end: before.length - fromEnd };
|
|
3515
|
+
}
|
|
3516
|
+
function isRenamable(b) {
|
|
3517
|
+
return b.nameStart >= 0 && b.kind !== "import" && b.kind !== "statement" && b.name !== "constructor";
|
|
3518
|
+
}
|
|
3519
|
+
function displayName(model, b) {
|
|
3520
|
+
if (b.kind === "import") return "import " + b.name;
|
|
3521
|
+
const names = [b.name];
|
|
3522
|
+
let parent = b.parentId ? model.blocks.get(b.parentId) : null;
|
|
3523
|
+
while (parent) {
|
|
3524
|
+
names.unshift(parent.name);
|
|
3525
|
+
parent = parent.parentId ? model.blocks.get(parent.parentId) : null;
|
|
3526
|
+
}
|
|
3527
|
+
return names.join(".");
|
|
3528
|
+
}
|
|
3529
|
+
function fileName(path) {
|
|
3530
|
+
return path.slice(path.lastIndexOf("/") + 1);
|
|
3531
|
+
}
|
|
3532
|
+
function changesSummary(diff) {
|
|
3533
|
+
const changes = [...diff.changes.values()];
|
|
3534
|
+
const counts = [
|
|
3535
|
+
[changes.filter((change) => change === "changed").length, "changed"],
|
|
3536
|
+
[changes.filter((change) => change === "added").length, "added"],
|
|
3537
|
+
[diff.removed.length, "removed"]
|
|
3538
|
+
];
|
|
3539
|
+
const parts = counts.filter(([count]) => count > 0).map(([count, word]) => `${count} ${word}`);
|
|
3540
|
+
return Object.assign(el("span", "cc-diff-summary"), { textContent: parts.length ? parts.join(" \xB7 ") : "No changes" });
|
|
3541
|
+
}
|
|
3542
|
+
function plural(count, word) {
|
|
3543
|
+
return `${count} ${word}${count === 1 ? "" : "s"}`;
|
|
3544
|
+
}
|
|
3545
|
+
function edgePath(a, b) {
|
|
3546
|
+
const ay = a.ay + Math.min(a.h / 2, 16);
|
|
3547
|
+
const by = b.ay + Math.min(b.h / 2, 16);
|
|
3548
|
+
if (b.ax >= a.ax + a.w + 8) return curveH(a.ax + a.w, ay, b.ax, by);
|
|
3549
|
+
if (b.ax + b.w <= a.ax - 8) return curveH(a.ax, ay, b.ax + b.w, by);
|
|
3550
|
+
const ax = a.ax + a.w / 2;
|
|
3551
|
+
const bx = b.ax + b.w / 2;
|
|
3552
|
+
if (b.ay > a.ay) return curveV(ax, a.ay + a.h, bx, b.ay);
|
|
3553
|
+
return curveV(ax, a.ay, bx, b.ay + b.h);
|
|
3554
|
+
}
|
|
3555
|
+
function curveH(x1, y1, x2, y2) {
|
|
3556
|
+
const c = Math.max(30, Math.abs(x2 - x1) * 0.5) * Math.sign(x2 - x1 || 1);
|
|
3557
|
+
return `M${x1} ${y1}C${x1 + c} ${y1} ${x2 - c} ${y2} ${x2} ${y2}`;
|
|
3558
|
+
}
|
|
3559
|
+
function curveV(x1, y1, x2, y2) {
|
|
3560
|
+
const c = Math.max(30, Math.abs(y2 - y1) * 0.5) * Math.sign(y2 - y1 || 1);
|
|
3561
|
+
return `M${x1} ${y1}C${x1} ${y1 + c} ${x2} ${y2 - c} ${x2} ${y2}`;
|
|
3562
|
+
}
|
|
3563
|
+
function el(tag, cls) {
|
|
3564
|
+
const e = document.createElement(tag);
|
|
3565
|
+
if (cls) e.className = cls;
|
|
3566
|
+
return e;
|
|
3567
|
+
}
|
|
3568
|
+
function button(label, onClick, variant) {
|
|
3569
|
+
const b = keepsCanvasFocus(document.createElement("button"));
|
|
3570
|
+
b.className = "cc-btn" + (variant ? " " + variant : "");
|
|
3571
|
+
b.textContent = label;
|
|
3572
|
+
b.addEventListener("click", (e) => {
|
|
3573
|
+
e.stopPropagation();
|
|
3574
|
+
onClick();
|
|
3575
|
+
});
|
|
3576
|
+
return b;
|
|
3577
|
+
}
|
|
3578
|
+
function keepsCanvasFocus(element) {
|
|
3579
|
+
element.addEventListener("pointerdown", (e) => e.preventDefault());
|
|
3580
|
+
return element;
|
|
3581
|
+
}
|
|
3582
|
+
function esc(s) {
|
|
3583
|
+
return s.replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[c]);
|
|
3584
|
+
}
|
|
3585
|
+
function clamp2(v, lo, hi) {
|
|
3586
|
+
return Math.min(hi, Math.max(lo, v));
|
|
3587
|
+
}
|
|
3588
|
+
function mid(a, b) {
|
|
3589
|
+
return { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 };
|
|
3590
|
+
}
|
|
3591
|
+
function isMac() {
|
|
3592
|
+
return /Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent);
|
|
3593
|
+
}
|
|
3594
|
+
export {
|
|
3595
|
+
CodeCanvas,
|
|
3596
|
+
M,
|
|
3597
|
+
Project,
|
|
3598
|
+
ProjectServer,
|
|
3599
|
+
RemoteProject,
|
|
3600
|
+
applyEdit,
|
|
3601
|
+
applyEdits,
|
|
3602
|
+
blockAt,
|
|
3603
|
+
callProjectServer,
|
|
3604
|
+
carryRenames,
|
|
3605
|
+
clipFromText,
|
|
3606
|
+
computeGeometry,
|
|
3607
|
+
conflictSideText,
|
|
3608
|
+
connectProjectWorker,
|
|
3609
|
+
containerFor,
|
|
3610
|
+
copyBlock,
|
|
3611
|
+
deleteBlock,
|
|
3612
|
+
detectIndentUnit,
|
|
3613
|
+
diffLines,
|
|
3614
|
+
diffModels,
|
|
3615
|
+
diffOwnCode,
|
|
3616
|
+
diffPreview,
|
|
3617
|
+
emptyLayout,
|
|
3618
|
+
findConflicts,
|
|
3619
|
+
freeSpotNear,
|
|
3620
|
+
injectStyles,
|
|
3621
|
+
newFunction,
|
|
3622
|
+
parseFile,
|
|
3623
|
+
pasteBlock,
|
|
3624
|
+
replaceBlock,
|
|
3625
|
+
resolveConflict,
|
|
3626
|
+
serializeLayout,
|
|
3627
|
+
serveProject,
|
|
3628
|
+
uniqueName
|
|
3629
|
+
};
|
|
3630
|
+
//# sourceMappingURL=index.js.map
|