@mica-js/schematic-editor 0.0.0-stage → 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +147 -2
- package/dist/index.d.ts +354 -0
- package/dist/index.js +3293 -0
- package/dist/index.js.map +1 -0
- package/package.json +32 -3
package/dist/index.js
ADDED
|
@@ -0,0 +1,3293 @@
|
|
|
1
|
+
import { DEFAULT_TEXT_CONTENT, GROUND_DEVICE, LABEL_DEVICE, POWER_DEVICE, SchematicEditor, SchematicError, TEXT_DEVICE, builtinSymbolKind, createDesignatorFactory, defaultElementIdFactory, deriveSchematicTopology, designatorOf, hasDesignator, hitTestSchematic, isTextDevice, joinJunctionAt, mergeWiresAtJunction, namesNet, netNameOf, parseDeviceReference, pinSide, pinStubEnd, pinsBounds, planJunctionRelease, planSegmentDrag, pointOnPolyline, previewSymbolDrag, projectOnSegment, propertyValueProblem, referenceAnchor, referencePosition, rotatePoint, samePoint, serializeSchematic, splitWireAt, symbolBounds, symbolPinPosition, symbolRole, textDeviceContent } from "@mica-js/core/schematic";
|
|
2
|
+
//#region src/controller/inspector.ts
|
|
3
|
+
/** Builds the property-panel payload for the current selection. */
|
|
4
|
+
function buildInspector(document, selection, context) {
|
|
5
|
+
if (selection.length !== 1) return;
|
|
6
|
+
const [item] = selection;
|
|
7
|
+
if (item === void 0) return;
|
|
8
|
+
if (item.kind === "wire") {
|
|
9
|
+
const wire = document.sheet.wires.find((candidate) => candidate.id === item.id);
|
|
10
|
+
if (wire === void 0) return;
|
|
11
|
+
const net = context.topology().nets.find((candidate) => candidate.wireIds.includes(wire.id));
|
|
12
|
+
return {
|
|
13
|
+
kind: "wire",
|
|
14
|
+
id: wire.id,
|
|
15
|
+
netName: net?.name ?? null,
|
|
16
|
+
points: wire.points
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
if (item.kind !== "symbol" && item.kind !== "reference") return;
|
|
20
|
+
const symbolId = item.kind === "symbol" ? item.id : item.symbolId;
|
|
21
|
+
const symbol = document.sheet.symbols.find((candidate) => candidate.id === symbolId);
|
|
22
|
+
if (symbol === void 0) return;
|
|
23
|
+
return symbolInspector(symbol, context.devices);
|
|
24
|
+
}
|
|
25
|
+
function symbolInspector(symbol, devices) {
|
|
26
|
+
const reference = parseDeviceReference(symbol.device);
|
|
27
|
+
const role = symbolRole(symbol.device);
|
|
28
|
+
const designator = designatorOf(symbol);
|
|
29
|
+
const net = netNameOf(symbol);
|
|
30
|
+
return {
|
|
31
|
+
kind: "symbol",
|
|
32
|
+
id: symbol.id,
|
|
33
|
+
role,
|
|
34
|
+
device: symbol.device,
|
|
35
|
+
name: (reference === void 0 ? void 0 : devices?.getDeviceName(reference)) ?? builtinName(symbol),
|
|
36
|
+
builtin: role !== "component",
|
|
37
|
+
...designator === void 0 ? {} : { designator },
|
|
38
|
+
...net === void 0 ? {} : { net },
|
|
39
|
+
x: symbol.x,
|
|
40
|
+
y: symbol.y,
|
|
41
|
+
rotation: symbol.rotation,
|
|
42
|
+
properties: propertyFields(symbol, devices)
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
function propertyFields(symbol, devices) {
|
|
46
|
+
const overrides = symbol.properties ?? {};
|
|
47
|
+
if (isTextDevice(symbol.device)) {
|
|
48
|
+
const override = overrides["text"];
|
|
49
|
+
return [toField(TEXT_DESCRIPTOR, typeof override === "string" ? override : void 0)];
|
|
50
|
+
}
|
|
51
|
+
if (namesNet(symbolRole(symbol.device))) return [];
|
|
52
|
+
const fields = [];
|
|
53
|
+
for (const descriptor of devicePropertyDescriptors(symbol, devices).values()) {
|
|
54
|
+
const override = overrides[descriptor.key];
|
|
55
|
+
const valid = override !== void 0 && propertyValueProblem(descriptor, override) === void 0;
|
|
56
|
+
fields.push(toField(descriptor, valid ? override : void 0));
|
|
57
|
+
}
|
|
58
|
+
return fields;
|
|
59
|
+
}
|
|
60
|
+
/** Moves the declared default to `defaultValue` so hosts can tell it apart from the instance value. */
|
|
61
|
+
function toField(descriptor, value) {
|
|
62
|
+
const { default: defaultValue, ...rest } = descriptor;
|
|
63
|
+
return {
|
|
64
|
+
...rest,
|
|
65
|
+
...defaultValue === void 0 ? {} : { defaultValue },
|
|
66
|
+
...value === void 0 ? {} : { value }
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
function devicePropertyDescriptors(symbol, devices) {
|
|
70
|
+
const reference = parseDeviceReference(symbol.device);
|
|
71
|
+
if (reference === void 0) return /* @__PURE__ */ new Map();
|
|
72
|
+
return devices?.getDevicePropertyDescriptors?.(reference) ?? /* @__PURE__ */ new Map();
|
|
73
|
+
}
|
|
74
|
+
function builtinName(symbol) {
|
|
75
|
+
if (isTextDevice(symbol.device)) return textDeviceContent(symbol);
|
|
76
|
+
return netNameOf(symbol);
|
|
77
|
+
}
|
|
78
|
+
/** `builtin:text` carries its content in one string property, described like any other (#7). */
|
|
79
|
+
var TEXT_DESCRIPTOR = {
|
|
80
|
+
key: "text",
|
|
81
|
+
type: "string",
|
|
82
|
+
title: "文本",
|
|
83
|
+
default: DEFAULT_TEXT_CONTENT
|
|
84
|
+
};
|
|
85
|
+
//#endregion
|
|
86
|
+
//#region src/controller/selection.ts
|
|
87
|
+
function sameSelectionItem(left, right) {
|
|
88
|
+
if (left.kind !== right.kind) return false;
|
|
89
|
+
if (left.kind === "vertex" && right.kind === "vertex") return left.wireId === right.wireId && left.index === right.index;
|
|
90
|
+
if (left.kind === "reference" && right.kind === "reference") return left.symbolId === right.symbolId;
|
|
91
|
+
return "id" in left && "id" in right ? left.id === right.id : false;
|
|
92
|
+
}
|
|
93
|
+
function sameSelection(left, right) {
|
|
94
|
+
return left.length === right.length && left.every((item, index) => {
|
|
95
|
+
const other = right[index];
|
|
96
|
+
return other !== void 0 && sameSelectionItem(item, other);
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
/** Drops entries whose target disappeared from the document. */
|
|
100
|
+
function pruneSelection(selection, document) {
|
|
101
|
+
if (selection.length === 0) return selection;
|
|
102
|
+
const symbols = new Set(document.sheet.symbols.map((symbol) => symbol.id));
|
|
103
|
+
const wires = new Map(document.sheet.wires.map((wire) => [wire.id, wire]));
|
|
104
|
+
const junctions = new Set(document.sheet.junctions.map((junction) => junction.id));
|
|
105
|
+
const kept = selection.filter((item) => {
|
|
106
|
+
switch (item.kind) {
|
|
107
|
+
case "symbol": return symbols.has(item.id);
|
|
108
|
+
case "reference": return symbols.has(item.symbolId);
|
|
109
|
+
case "wire": return wires.has(item.id);
|
|
110
|
+
case "junction": return junctions.has(item.id);
|
|
111
|
+
case "vertex": {
|
|
112
|
+
const wire = wires.get(item.wireId);
|
|
113
|
+
return wire !== void 0 && item.index < wire.points.length;
|
|
114
|
+
}
|
|
115
|
+
default: return false;
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
return kept.length === selection.length ? selection : kept;
|
|
119
|
+
}
|
|
120
|
+
//#endregion
|
|
121
|
+
//#region src/controller/types.ts
|
|
122
|
+
var DEFAULT_DISPLAY_OPTIONS = {
|
|
123
|
+
grid: true,
|
|
124
|
+
snap: true,
|
|
125
|
+
labels: true,
|
|
126
|
+
gridSize: 10,
|
|
127
|
+
gridSubdivisions: 2
|
|
128
|
+
};
|
|
129
|
+
//#endregion
|
|
130
|
+
//#region src/controller/session.ts
|
|
131
|
+
var MAX_MERGE_PASSES = 64;
|
|
132
|
+
/**
|
|
133
|
+
* Editing session: the document editor, the session-level state (selection, tool, display,
|
|
134
|
+
* errors), the caches derived from them and the mutation plumbing.
|
|
135
|
+
* Actions in `symbols.ts` / `wires.ts` operate on it; the controller is only a façade.
|
|
136
|
+
*/
|
|
137
|
+
var EditorSession = class {
|
|
138
|
+
editor;
|
|
139
|
+
devices;
|
|
140
|
+
tolerance;
|
|
141
|
+
createId;
|
|
142
|
+
createDesignator;
|
|
143
|
+
selection = [];
|
|
144
|
+
tool = "select";
|
|
145
|
+
display;
|
|
146
|
+
placingDevice;
|
|
147
|
+
lastError;
|
|
148
|
+
#listeners = /* @__PURE__ */ new Set();
|
|
149
|
+
#topology;
|
|
150
|
+
#stateCache;
|
|
151
|
+
#renderCache;
|
|
152
|
+
constructor(options) {
|
|
153
|
+
this.editor = new SchematicEditor(options.document, {
|
|
154
|
+
...deriveOptions(options.devices, options.tolerance),
|
|
155
|
+
...options.historyLimit === void 0 ? {} : { historyLimit: options.historyLimit }
|
|
156
|
+
});
|
|
157
|
+
this.devices = options.devices;
|
|
158
|
+
this.tolerance = options.tolerance;
|
|
159
|
+
this.createId = options.createId ?? defaultElementIdFactory;
|
|
160
|
+
this.createDesignator = options.createDesignator ?? createDesignatorFactory(options.devices);
|
|
161
|
+
this.display = {
|
|
162
|
+
...DEFAULT_DISPLAY_OPTIONS,
|
|
163
|
+
...options.display
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
get document() {
|
|
167
|
+
return this.editor.document;
|
|
168
|
+
}
|
|
169
|
+
/** Editing options for core calls (`exactOptionalPropertyTypes` friendly). */
|
|
170
|
+
get options() {
|
|
171
|
+
return deriveOptions(this.devices, this.tolerance);
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* Full editor state. Cached until the next change so hosts can compare references, and so a
|
|
175
|
+
* repaint does not rebuild the inspector payload.
|
|
176
|
+
*/
|
|
177
|
+
state() {
|
|
178
|
+
if (this.#stateCache !== void 0) return this.#stateCache;
|
|
179
|
+
this.#stateCache = {
|
|
180
|
+
...this.renderState(),
|
|
181
|
+
placingDevice: this.placingDevice,
|
|
182
|
+
canUndo: this.editor.canUndo,
|
|
183
|
+
canRedo: this.editor.canRedo,
|
|
184
|
+
inspector: this.inspect(),
|
|
185
|
+
lastError: this.lastError
|
|
186
|
+
};
|
|
187
|
+
return this.#stateCache;
|
|
188
|
+
}
|
|
189
|
+
/** Everything the canvas draws from; never builds the inspector. */
|
|
190
|
+
renderState() {
|
|
191
|
+
if (this.#renderCache !== void 0) return this.#renderCache;
|
|
192
|
+
this.#renderCache = {
|
|
193
|
+
document: this.editor.document,
|
|
194
|
+
selection: this.selection,
|
|
195
|
+
tool: this.tool,
|
|
196
|
+
display: this.display
|
|
197
|
+
};
|
|
198
|
+
return this.#renderCache;
|
|
199
|
+
}
|
|
200
|
+
subscribe(listener) {
|
|
201
|
+
this.#listeners.add(listener);
|
|
202
|
+
return () => {
|
|
203
|
+
this.#listeners.delete(listener);
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
emit() {
|
|
207
|
+
this.#stateCache = void 0;
|
|
208
|
+
this.#renderCache = void 0;
|
|
209
|
+
const state = this.state();
|
|
210
|
+
for (const listener of [...this.#listeners]) listener(state);
|
|
211
|
+
}
|
|
212
|
+
/**
|
|
213
|
+
* Runs `run` as one undoable edit, then reconciles junctions. Failures are recorded in
|
|
214
|
+
* `lastError` and rethrown; the document stays untouched.
|
|
215
|
+
*/
|
|
216
|
+
mutate(run, options = {}) {
|
|
217
|
+
try {
|
|
218
|
+
this.editor.transaction(() => {
|
|
219
|
+
run();
|
|
220
|
+
if (options.reconcile !== false) this.mergeDegenerateJunctions();
|
|
221
|
+
});
|
|
222
|
+
} catch (error) {
|
|
223
|
+
this.lastError = toSchematicError(error);
|
|
224
|
+
this.emit();
|
|
225
|
+
throw error;
|
|
226
|
+
}
|
|
227
|
+
this.lastError = void 0;
|
|
228
|
+
this.pruneSelection();
|
|
229
|
+
this.emit();
|
|
230
|
+
}
|
|
231
|
+
afterHistoryChange() {
|
|
232
|
+
this.lastError = void 0;
|
|
233
|
+
this.pruneSelection();
|
|
234
|
+
this.emit();
|
|
235
|
+
}
|
|
236
|
+
/** Records a rejected edit in `state.lastError`, notifies listeners, then rethrows. */
|
|
237
|
+
reject(error) {
|
|
238
|
+
this.lastError = error;
|
|
239
|
+
this.emit();
|
|
240
|
+
throw error;
|
|
241
|
+
}
|
|
242
|
+
undo() {
|
|
243
|
+
if (!this.editor.canUndo) return;
|
|
244
|
+
this.editor.undo();
|
|
245
|
+
this.afterHistoryChange();
|
|
246
|
+
}
|
|
247
|
+
redo() {
|
|
248
|
+
if (!this.editor.canRedo) return;
|
|
249
|
+
this.editor.redo();
|
|
250
|
+
this.afterHistoryChange();
|
|
251
|
+
}
|
|
252
|
+
/** Swaps the edited document and clears history, selection and errors. */
|
|
253
|
+
reset(document) {
|
|
254
|
+
this.editor = new SchematicEditor(document, deriveOptions(this.devices, this.tolerance));
|
|
255
|
+
this.selection = [];
|
|
256
|
+
this.lastError = void 0;
|
|
257
|
+
this.#topology = void 0;
|
|
258
|
+
this.placingDevice = void 0;
|
|
259
|
+
this.emit();
|
|
260
|
+
}
|
|
261
|
+
inspect() {
|
|
262
|
+
return buildInspector(this.editor.document, this.selection, {
|
|
263
|
+
devices: this.devices,
|
|
264
|
+
topology: () => this.topology()
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
topology() {
|
|
268
|
+
const document = this.editor.document;
|
|
269
|
+
if (this.#topology?.document !== document) this.#topology = {
|
|
270
|
+
document,
|
|
271
|
+
value: deriveSchematicTopology(document, this.options)
|
|
272
|
+
};
|
|
273
|
+
return this.#topology.value;
|
|
274
|
+
}
|
|
275
|
+
hitTest(point, options = {}) {
|
|
276
|
+
return hitTestSchematic(this.editor.document, point, {
|
|
277
|
+
...this.options,
|
|
278
|
+
...options
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
pruneSelection() {
|
|
282
|
+
const kept = pruneSelection(this.selection, this.editor.document);
|
|
283
|
+
if (kept !== this.selection) this.selection = kept;
|
|
284
|
+
}
|
|
285
|
+
findSymbol(id) {
|
|
286
|
+
return this.editor.document.sheet.symbols.find((symbol) => symbol.id === id);
|
|
287
|
+
}
|
|
288
|
+
findWire(id) {
|
|
289
|
+
return this.editor.document.sheet.wires.find((wire) => wire.id === id);
|
|
290
|
+
}
|
|
291
|
+
/** @throws {SchematicError} when the symbol is unknown. */
|
|
292
|
+
requireSymbol(symbolId) {
|
|
293
|
+
const symbol = this.findSymbol(symbolId);
|
|
294
|
+
if (symbol === void 0) this.reject(new SchematicError([{
|
|
295
|
+
severity: "error",
|
|
296
|
+
code: "edit/unknown-entity",
|
|
297
|
+
message: `Unknown symbol "${symbolId}".`,
|
|
298
|
+
entity: {
|
|
299
|
+
kind: "symbol",
|
|
300
|
+
id: symbolId
|
|
301
|
+
}
|
|
302
|
+
}]));
|
|
303
|
+
return symbol;
|
|
304
|
+
}
|
|
305
|
+
/** Device outline in symbol-local coordinates, used as the reference-designator default. */
|
|
306
|
+
outlineOf(symbol) {
|
|
307
|
+
const separator = symbol.device.indexOf(":");
|
|
308
|
+
if (separator <= 0) return;
|
|
309
|
+
return this.devices?.getDeviceSymbol({
|
|
310
|
+
raw: symbol.device,
|
|
311
|
+
packageId: symbol.device.slice(0, separator),
|
|
312
|
+
deviceId: symbol.device.slice(separator + 1)
|
|
313
|
+
})?.bounds;
|
|
314
|
+
}
|
|
315
|
+
/** A junction that only splices two collinear wires collapses back into a single wire. */
|
|
316
|
+
mergeDegenerateJunctions() {
|
|
317
|
+
for (let pass = 0; pass < MAX_MERGE_PASSES; pass += 1) {
|
|
318
|
+
const junctions = this.editor.document.sheet.junctions;
|
|
319
|
+
let merged = false;
|
|
320
|
+
for (const junction of junctions) {
|
|
321
|
+
const result = mergeWiresAtJunction(this.editor.document, junction.id, {
|
|
322
|
+
...deriveOptions(void 0, this.tolerance),
|
|
323
|
+
createId: this.createId
|
|
324
|
+
});
|
|
325
|
+
if (result === void 0) continue;
|
|
326
|
+
this.editor.replace(result.document);
|
|
327
|
+
merged = true;
|
|
328
|
+
break;
|
|
329
|
+
}
|
|
330
|
+
if (!merged) return;
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
};
|
|
334
|
+
/** Builds provider options without `undefined` values (`exactOptionalPropertyTypes`). */
|
|
335
|
+
function deriveOptions(devices, tolerance) {
|
|
336
|
+
const options = {};
|
|
337
|
+
if (devices !== void 0) options.devices = devices;
|
|
338
|
+
if (tolerance !== void 0) options.tolerance = tolerance;
|
|
339
|
+
return options;
|
|
340
|
+
}
|
|
341
|
+
function toSchematicError(error) {
|
|
342
|
+
if (error instanceof SchematicError) return error;
|
|
343
|
+
const diagnostics = [{
|
|
344
|
+
severity: "error",
|
|
345
|
+
code: "edit/invalid-command",
|
|
346
|
+
message: error instanceof Error ? error.message : String(error)
|
|
347
|
+
}];
|
|
348
|
+
return new SchematicError(diagnostics);
|
|
349
|
+
}
|
|
350
|
+
//#endregion
|
|
351
|
+
//#region src/controller/wires.ts
|
|
352
|
+
/**
|
|
353
|
+
* Appends a wire. Endpoints that land inside another wire split it and create a junction, and
|
|
354
|
+
* endpoints that land on an existing junction join it, so the new wire is electrically connected.
|
|
355
|
+
*/
|
|
356
|
+
function addWire(session, points) {
|
|
357
|
+
if (points.length < 2) session.reject(new SchematicError([{
|
|
358
|
+
severity: "error",
|
|
359
|
+
code: "wire/invalid-points",
|
|
360
|
+
message: "A wire needs two points."
|
|
361
|
+
}]));
|
|
362
|
+
session.mutate(() => {
|
|
363
|
+
const id = session.createId("wire", session.document);
|
|
364
|
+
session.editor.dispatch({
|
|
365
|
+
type: "add-wire",
|
|
366
|
+
id,
|
|
367
|
+
points
|
|
368
|
+
});
|
|
369
|
+
for (const point of [points[0], points[points.length - 1]]) {
|
|
370
|
+
if (point === void 0) continue;
|
|
371
|
+
const host = hitTestWire(session, point, id);
|
|
372
|
+
if (host !== void 0) {
|
|
373
|
+
splitInto(session, id, host.wireId, point);
|
|
374
|
+
continue;
|
|
375
|
+
}
|
|
376
|
+
const join = joinJunctionAt(session.document, id, point);
|
|
377
|
+
if (join !== void 0) session.editor.dispatch({
|
|
378
|
+
type: "update-junction",
|
|
379
|
+
id: join.id,
|
|
380
|
+
wires: join.wires
|
|
381
|
+
});
|
|
382
|
+
}
|
|
383
|
+
}, { reconcile: false });
|
|
384
|
+
}
|
|
385
|
+
/** Splits `wireId` at `point` and creates a junction there. */
|
|
386
|
+
function connectWireAt(session, wireId, point, otherWireId) {
|
|
387
|
+
let junctionId = "";
|
|
388
|
+
session.mutate(() => {
|
|
389
|
+
junctionId = splitInto(session, otherWireId, wireId, point, { strict: true });
|
|
390
|
+
}, { reconcile: false });
|
|
391
|
+
return junctionId;
|
|
392
|
+
}
|
|
393
|
+
/**
|
|
394
|
+
* Slides one wire segment by `delta` along its normal. The whole run of segments parallel to it
|
|
395
|
+
* moves along; junctions left behind on the old geometry let go of this wire.
|
|
396
|
+
*/
|
|
397
|
+
function moveWireSegment(session, wireId, segment, delta) {
|
|
398
|
+
const points = previewWireSegment(session, wireId, segment, delta);
|
|
399
|
+
if (points === void 0) return;
|
|
400
|
+
session.mutate(() => {
|
|
401
|
+
for (const release of planJunctionRelease(session.document, wireId, points)) session.editor.dispatch(release.wires === void 0 ? {
|
|
402
|
+
type: "remove-junction",
|
|
403
|
+
id: release.id
|
|
404
|
+
} : {
|
|
405
|
+
type: "update-junction",
|
|
406
|
+
id: release.id,
|
|
407
|
+
wires: release.wires
|
|
408
|
+
});
|
|
409
|
+
session.editor.dispatch({
|
|
410
|
+
type: "set-wire-points",
|
|
411
|
+
id: wireId,
|
|
412
|
+
points
|
|
413
|
+
});
|
|
414
|
+
});
|
|
415
|
+
}
|
|
416
|
+
/** Wire points a segment drag would produce; the renderer previews them before the commit. */
|
|
417
|
+
function previewWireSegment(session, wireId, segment, delta) {
|
|
418
|
+
return planSegmentDrag(session.document, wireId, segment, delta, session.options);
|
|
419
|
+
}
|
|
420
|
+
/** Removes a wire while keeping its junctions for the merge pass to collapse. */
|
|
421
|
+
function removeWire(session, wireId) {
|
|
422
|
+
const attached = session.document.sheet.junctions.filter((junction) => junction.wires.includes(wireId));
|
|
423
|
+
for (const junction of attached) {
|
|
424
|
+
const remaining = junction.wires.filter((id) => id !== wireId);
|
|
425
|
+
if (remaining.length >= 2) session.editor.dispatch({
|
|
426
|
+
type: "update-junction",
|
|
427
|
+
id: junction.id,
|
|
428
|
+
wires: remaining
|
|
429
|
+
});
|
|
430
|
+
else session.editor.dispatch({
|
|
431
|
+
type: "remove-junction",
|
|
432
|
+
id: junction.id
|
|
433
|
+
});
|
|
434
|
+
}
|
|
435
|
+
session.editor.dispatch({
|
|
436
|
+
type: "remove-wire",
|
|
437
|
+
id: wireId
|
|
438
|
+
});
|
|
439
|
+
}
|
|
440
|
+
/** Wire whose interior (not its ends) passes through `point`, excluding `excludeWireId`. */
|
|
441
|
+
function hitTestWire(session, point, excludeWireId) {
|
|
442
|
+
for (const wire of session.document.sheet.wires) {
|
|
443
|
+
if (wire.id === excludeWireId || !pointOnPolyline(wire.points, point, session.tolerance ?? 0)) continue;
|
|
444
|
+
const [first, last] = [wire.points[0], wire.points[wire.points.length - 1]];
|
|
445
|
+
if (first === void 0 || last === void 0) continue;
|
|
446
|
+
if (samePoint(first, point) || samePoint(last, point)) continue;
|
|
447
|
+
return { wireId: wire.id };
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
/** Splits `hostWireId` at `point` and joins `newWireId` to the junction it creates. */
|
|
451
|
+
function splitInto(session, newWireId, hostWireId, point, options = {}) {
|
|
452
|
+
const document = session.document;
|
|
453
|
+
if (!options.strict) {
|
|
454
|
+
const wire = session.findWire(hostWireId);
|
|
455
|
+
const [first, last] = [wire?.points[0], wire?.points[wire.points.length - 1]];
|
|
456
|
+
if (wire === void 0 || first === void 0 || last === void 0 || samePoint(first, point) || samePoint(last, point)) return "";
|
|
457
|
+
}
|
|
458
|
+
const split = splitWireAt(document, hostWireId, point, {
|
|
459
|
+
...session.tolerance === void 0 ? {} : { tolerance: session.tolerance },
|
|
460
|
+
createId: session.createId
|
|
461
|
+
});
|
|
462
|
+
const junctionId = session.createId("junction", split.document);
|
|
463
|
+
const wires = [split.firstWireId, split.secondWireId];
|
|
464
|
+
if (newWireId !== void 0 && newWireId.length > 0 && !wires.includes(newWireId)) wires.push(newWireId);
|
|
465
|
+
const junctions = [...split.document.sheet.junctions, {
|
|
466
|
+
id: junctionId,
|
|
467
|
+
x: point.x,
|
|
468
|
+
y: point.y,
|
|
469
|
+
wires
|
|
470
|
+
}];
|
|
471
|
+
session.editor.replace({
|
|
472
|
+
...split.document,
|
|
473
|
+
sheet: {
|
|
474
|
+
...split.document.sheet,
|
|
475
|
+
junctions
|
|
476
|
+
}
|
|
477
|
+
});
|
|
478
|
+
return junctionId;
|
|
479
|
+
}
|
|
480
|
+
//#endregion
|
|
481
|
+
//#region src/controller/symbols.ts
|
|
482
|
+
/** Arms placement of `device`: the next canvas click inserts it and disarms again. */
|
|
483
|
+
function placeDevice(session, device) {
|
|
484
|
+
if (parseDeviceReference(device) === void 0) session.reject(new SchematicError([{
|
|
485
|
+
severity: "error",
|
|
486
|
+
code: "device/invalid-reference",
|
|
487
|
+
message: `Invalid device reference ${JSON.stringify(device)}.`
|
|
488
|
+
}]));
|
|
489
|
+
session.placingDevice = device;
|
|
490
|
+
session.tool = "select";
|
|
491
|
+
session.emit();
|
|
492
|
+
}
|
|
493
|
+
function cancelPlacement(session) {
|
|
494
|
+
if (session.placingDevice === void 0) return;
|
|
495
|
+
session.placingDevice = void 0;
|
|
496
|
+
session.emit();
|
|
497
|
+
}
|
|
498
|
+
/** Reference designator the next placed component gets, per the host's policy. */
|
|
499
|
+
function nextDesignator(session, device) {
|
|
500
|
+
return session.createDesignator(device, session.document);
|
|
501
|
+
}
|
|
502
|
+
function moveSelectionBy(session, dx, dy) {
|
|
503
|
+
if (session.selection.length === 0 || dx === 0 && dy === 0) return;
|
|
504
|
+
session.mutate(() => {
|
|
505
|
+
for (const item of session.selection) switch (item.kind) {
|
|
506
|
+
case "symbol": {
|
|
507
|
+
const symbol = session.findSymbol(item.id);
|
|
508
|
+
if (symbol !== void 0) repositionSymbol(session, symbol, {
|
|
509
|
+
...symbol,
|
|
510
|
+
x: symbol.x + dx,
|
|
511
|
+
y: symbol.y + dy
|
|
512
|
+
});
|
|
513
|
+
break;
|
|
514
|
+
}
|
|
515
|
+
case "vertex": {
|
|
516
|
+
const wire = session.findWire(item.wireId);
|
|
517
|
+
const target = wire?.points[item.index];
|
|
518
|
+
if (wire !== void 0 && target !== void 0) session.editor.dispatch({
|
|
519
|
+
type: "set-wire-points",
|
|
520
|
+
id: wire.id,
|
|
521
|
+
points: wire.points.map((point, index) => index === item.index ? {
|
|
522
|
+
x: point.x + dx,
|
|
523
|
+
y: point.y + dy
|
|
524
|
+
} : point)
|
|
525
|
+
});
|
|
526
|
+
break;
|
|
527
|
+
}
|
|
528
|
+
case "reference": moveReferenceBy(session, item.symbolId, dx, dy);
|
|
529
|
+
}
|
|
530
|
+
});
|
|
531
|
+
}
|
|
532
|
+
function rotateSelection(session, degrees) {
|
|
533
|
+
if (session.selection.length === 0 || degrees === 0) return;
|
|
534
|
+
session.mutate(() => {
|
|
535
|
+
for (const item of session.selection) {
|
|
536
|
+
if (item.kind !== "symbol") continue;
|
|
537
|
+
const symbol = session.findSymbol(item.id);
|
|
538
|
+
if (symbol === void 0) continue;
|
|
539
|
+
repositionSymbol(session, symbol, {
|
|
540
|
+
...symbol,
|
|
541
|
+
rotation: normalizeRotation(symbol.rotation + degrees)
|
|
542
|
+
});
|
|
543
|
+
}
|
|
544
|
+
});
|
|
545
|
+
}
|
|
546
|
+
/**
|
|
547
|
+
* Replaces a symbol's placement and drags everything anchored to its pins: wire ends keep
|
|
548
|
+
* orthogonal routing, junctions and attached builtin anchors follow.
|
|
549
|
+
*/
|
|
550
|
+
function repositionSymbol(session, symbol, next) {
|
|
551
|
+
const document = session.document;
|
|
552
|
+
const preview = previewSymbolDrag(document, session.devices, symbol.id, next);
|
|
553
|
+
if (preview === void 0) return;
|
|
554
|
+
const symbols = document.sheet.symbols.map((item) => {
|
|
555
|
+
if (item.id === symbol.id) return next;
|
|
556
|
+
const target = preview.symbols.get(item.id);
|
|
557
|
+
return target === void 0 ? item : {
|
|
558
|
+
...item,
|
|
559
|
+
x: target.x,
|
|
560
|
+
y: target.y
|
|
561
|
+
};
|
|
562
|
+
});
|
|
563
|
+
const wires = document.sheet.wires.map((wire) => {
|
|
564
|
+
const points = preview.wires.get(wire.id);
|
|
565
|
+
return points === void 0 ? wire : {
|
|
566
|
+
...wire,
|
|
567
|
+
points
|
|
568
|
+
};
|
|
569
|
+
});
|
|
570
|
+
const junctions = document.sheet.junctions.map((junction) => {
|
|
571
|
+
const target = preview.junctions.get(junction.id);
|
|
572
|
+
return target === void 0 ? junction : {
|
|
573
|
+
...junction,
|
|
574
|
+
x: target.x,
|
|
575
|
+
y: target.y
|
|
576
|
+
};
|
|
577
|
+
});
|
|
578
|
+
session.editor.replace({
|
|
579
|
+
...document,
|
|
580
|
+
sheet: {
|
|
581
|
+
...document.sheet,
|
|
582
|
+
symbols,
|
|
583
|
+
wires,
|
|
584
|
+
junctions
|
|
585
|
+
}
|
|
586
|
+
});
|
|
587
|
+
}
|
|
588
|
+
function deleteSelection(session) {
|
|
589
|
+
if (session.selection.length === 0) return;
|
|
590
|
+
session.mutate(() => {
|
|
591
|
+
for (const item of session.selection) switch (item.kind) {
|
|
592
|
+
case "symbol":
|
|
593
|
+
session.editor.dispatch({
|
|
594
|
+
type: "remove-symbol",
|
|
595
|
+
id: item.id
|
|
596
|
+
});
|
|
597
|
+
break;
|
|
598
|
+
case "wire":
|
|
599
|
+
removeWire(session, item.id);
|
|
600
|
+
break;
|
|
601
|
+
case "junction":
|
|
602
|
+
session.editor.dispatch({
|
|
603
|
+
type: "remove-junction",
|
|
604
|
+
id: item.id
|
|
605
|
+
});
|
|
606
|
+
break;
|
|
607
|
+
case "vertex": {
|
|
608
|
+
const wire = session.findWire(item.wireId);
|
|
609
|
+
if (wire === void 0) break;
|
|
610
|
+
const points = wire.points.filter((_, index) => index !== item.index);
|
|
611
|
+
if (points.length < 2) removeWire(session, wire.id);
|
|
612
|
+
else session.editor.dispatch({
|
|
613
|
+
type: "set-wire-points",
|
|
614
|
+
id: wire.id,
|
|
615
|
+
points
|
|
616
|
+
});
|
|
617
|
+
break;
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
session.selection = [];
|
|
621
|
+
});
|
|
622
|
+
}
|
|
623
|
+
/** Moves a reference designator by a world-space delta; the offset is stored locally. */
|
|
624
|
+
function moveReferenceBy(session, symbolId, dx, dy) {
|
|
625
|
+
const symbol = session.findSymbol(symbolId);
|
|
626
|
+
if (symbol === void 0 || dx === 0 && dy === 0) return;
|
|
627
|
+
session.mutate(() => {
|
|
628
|
+
const anchor = referenceAnchor(session.outlineOf(symbol), symbol.reference);
|
|
629
|
+
const local = rotatePoint({
|
|
630
|
+
x: dx,
|
|
631
|
+
y: dy
|
|
632
|
+
}, -symbol.rotation);
|
|
633
|
+
session.editor.dispatch({
|
|
634
|
+
type: "update-symbol",
|
|
635
|
+
id: symbol.id,
|
|
636
|
+
reference: {
|
|
637
|
+
x: anchor.x + local.x,
|
|
638
|
+
y: anchor.y + local.y
|
|
639
|
+
}
|
|
640
|
+
});
|
|
641
|
+
});
|
|
642
|
+
}
|
|
643
|
+
function setSymbolProperty(session, symbolId, key, value) {
|
|
644
|
+
session.mutate(() => {
|
|
645
|
+
session.editor.dispatch({
|
|
646
|
+
type: "set-symbol-property",
|
|
647
|
+
id: symbolId,
|
|
648
|
+
key,
|
|
649
|
+
value
|
|
650
|
+
});
|
|
651
|
+
});
|
|
652
|
+
}
|
|
653
|
+
function removeSymbolProperty(session, symbolId, key) {
|
|
654
|
+
session.mutate(() => {
|
|
655
|
+
session.editor.dispatch({
|
|
656
|
+
type: "remove-symbol-property",
|
|
657
|
+
id: symbolId,
|
|
658
|
+
key
|
|
659
|
+
});
|
|
660
|
+
});
|
|
661
|
+
}
|
|
662
|
+
/**
|
|
663
|
+
* Sets a component's reference designator. The symbol id stays untouched, so selections and
|
|
664
|
+
* Stable ids keep references pointing at the same element.
|
|
665
|
+
*/
|
|
666
|
+
function renameDesignator(session, symbolId, nextDesignator) {
|
|
667
|
+
const trimmed = nextDesignator.trim();
|
|
668
|
+
const symbol = session.requireSymbol(symbolId);
|
|
669
|
+
const role = symbolRole(symbol.device);
|
|
670
|
+
if (!hasDesignator(role)) session.reject(new SchematicError([{
|
|
671
|
+
severity: "error",
|
|
672
|
+
code: "symbol/unsupported-field",
|
|
673
|
+
message: `Symbols of role "${role}" carry no reference designator.`,
|
|
674
|
+
entity: {
|
|
675
|
+
kind: "symbol",
|
|
676
|
+
id: symbolId
|
|
677
|
+
}
|
|
678
|
+
}]));
|
|
679
|
+
if (trimmed.length === 0) session.reject(new SchematicError([{
|
|
680
|
+
severity: "error",
|
|
681
|
+
code: "schema/invalid-value",
|
|
682
|
+
message: "Reference designator must not be empty."
|
|
683
|
+
}]));
|
|
684
|
+
if (trimmed === designatorOf(symbol)) return;
|
|
685
|
+
const duplicate = session.document.sheet.symbols.find((candidate) => candidate.id !== symbolId && designatorOf(candidate) === trimmed);
|
|
686
|
+
if (duplicate !== void 0) session.reject(new SchematicError([{
|
|
687
|
+
severity: "error",
|
|
688
|
+
code: "symbol/designator-duplicate",
|
|
689
|
+
message: `Reference designator ${JSON.stringify(trimmed)} is already used.`,
|
|
690
|
+
entity: {
|
|
691
|
+
kind: "symbol",
|
|
692
|
+
id: duplicate.id
|
|
693
|
+
}
|
|
694
|
+
}]));
|
|
695
|
+
session.mutate(() => {
|
|
696
|
+
session.editor.dispatch({
|
|
697
|
+
type: "update-symbol",
|
|
698
|
+
id: symbolId,
|
|
699
|
+
designator: trimmed
|
|
700
|
+
});
|
|
701
|
+
});
|
|
702
|
+
}
|
|
703
|
+
/**
|
|
704
|
+
* Sets the net name of a label / power / ground symbol. Same-named ports are one net, so
|
|
705
|
+
* duplicates are allowed.
|
|
706
|
+
*/
|
|
707
|
+
function renameNet(session, symbolId, nextNet) {
|
|
708
|
+
const trimmed = nextNet.trim();
|
|
709
|
+
const symbol = session.requireSymbol(symbolId);
|
|
710
|
+
const role = symbolRole(symbol.device);
|
|
711
|
+
if (!namesNet(role)) session.reject(new SchematicError([{
|
|
712
|
+
severity: "error",
|
|
713
|
+
code: "symbol/unsupported-field",
|
|
714
|
+
message: `Symbols of role "${role}" name no net.`,
|
|
715
|
+
entity: {
|
|
716
|
+
kind: "symbol",
|
|
717
|
+
id: symbolId
|
|
718
|
+
}
|
|
719
|
+
}]));
|
|
720
|
+
if (trimmed.length === 0) session.reject(new SchematicError([{
|
|
721
|
+
severity: "error",
|
|
722
|
+
code: "schema/invalid-value",
|
|
723
|
+
message: "Net name must not be empty."
|
|
724
|
+
}]));
|
|
725
|
+
if (trimmed === netNameOf(symbol)) return;
|
|
726
|
+
session.mutate(() => {
|
|
727
|
+
session.editor.dispatch({
|
|
728
|
+
type: "update-symbol",
|
|
729
|
+
id: symbolId,
|
|
730
|
+
net: trimmed
|
|
731
|
+
});
|
|
732
|
+
});
|
|
733
|
+
}
|
|
734
|
+
function normalizeRotation(degrees) {
|
|
735
|
+
return (degrees % 360 + 360) % 360;
|
|
736
|
+
}
|
|
737
|
+
//#endregion
|
|
738
|
+
//#region src/controller/controller.ts
|
|
739
|
+
/**
|
|
740
|
+
* Editing state without any DOM dependency: selection, tool, undo history and the derived data
|
|
741
|
+
* the UI needs. It is a façade over {@link EditorSession} (state + mutation plumbing) and the
|
|
742
|
+
* action modules `symbols.ts` / `wires.ts`; every mutation flows through `applySchematicCommand`.
|
|
743
|
+
*/
|
|
744
|
+
var SchematicEditorController = class {
|
|
745
|
+
#session;
|
|
746
|
+
constructor(options) {
|
|
747
|
+
this.#session = new EditorSession(options);
|
|
748
|
+
}
|
|
749
|
+
get state() {
|
|
750
|
+
return this.#session.state();
|
|
751
|
+
}
|
|
752
|
+
/** Everything the canvas draws from; never builds the inspector. */
|
|
753
|
+
get renderState() {
|
|
754
|
+
return this.#session.renderState();
|
|
755
|
+
}
|
|
756
|
+
get document() {
|
|
757
|
+
return this.#session.document;
|
|
758
|
+
}
|
|
759
|
+
get selection() {
|
|
760
|
+
return this.#session.selection;
|
|
761
|
+
}
|
|
762
|
+
get tool() {
|
|
763
|
+
return this.#session.tool;
|
|
764
|
+
}
|
|
765
|
+
get display() {
|
|
766
|
+
return this.#session.display;
|
|
767
|
+
}
|
|
768
|
+
get canUndo() {
|
|
769
|
+
return this.#session.editor.canUndo;
|
|
770
|
+
}
|
|
771
|
+
get canRedo() {
|
|
772
|
+
return this.#session.editor.canRedo;
|
|
773
|
+
}
|
|
774
|
+
/** Device armed for placement by the host's device list; `undefined` when idle. */
|
|
775
|
+
get placingDevice() {
|
|
776
|
+
return this.#session.placingDevice;
|
|
777
|
+
}
|
|
778
|
+
setTool(tool) {
|
|
779
|
+
if (this.#session.tool === tool && this.#session.placingDevice === void 0) return;
|
|
780
|
+
this.#session.tool = tool;
|
|
781
|
+
this.#session.placingDevice = void 0;
|
|
782
|
+
this.#session.emit();
|
|
783
|
+
}
|
|
784
|
+
setSelection(selection) {
|
|
785
|
+
this.#session.selection = selection;
|
|
786
|
+
this.#session.pruneSelection();
|
|
787
|
+
this.#session.emit();
|
|
788
|
+
}
|
|
789
|
+
setDisplayOptions(patch) {
|
|
790
|
+
this.#session.display = {
|
|
791
|
+
...this.#session.display,
|
|
792
|
+
...patch
|
|
793
|
+
};
|
|
794
|
+
this.#session.emit();
|
|
795
|
+
}
|
|
796
|
+
/** Swaps the edited document and clears history, selection and errors. */
|
|
797
|
+
reset(document) {
|
|
798
|
+
this.#session.reset(document);
|
|
799
|
+
}
|
|
800
|
+
/** @throws {SchematicError} leaving document, history and selection untouched. */
|
|
801
|
+
dispatch(command) {
|
|
802
|
+
this.#session.mutate(() => {
|
|
803
|
+
this.#session.editor.dispatch(command);
|
|
804
|
+
});
|
|
805
|
+
}
|
|
806
|
+
/**
|
|
807
|
+
* Swaps the whole document as one undoable edit, so a host can reload a file without losing
|
|
808
|
+
* the history it already has.
|
|
809
|
+
*/
|
|
810
|
+
replace(document) {
|
|
811
|
+
this.#session.mutate(() => {
|
|
812
|
+
this.#session.editor.replace(document);
|
|
813
|
+
});
|
|
814
|
+
}
|
|
815
|
+
/** Runs several edits as one history step; a failure rolls all of them back. */
|
|
816
|
+
transaction(run) {
|
|
817
|
+
this.#session.mutate(run);
|
|
818
|
+
}
|
|
819
|
+
undo() {
|
|
820
|
+
this.#session.undo();
|
|
821
|
+
}
|
|
822
|
+
redo() {
|
|
823
|
+
this.#session.redo();
|
|
824
|
+
}
|
|
825
|
+
/** Arms placement of `device`: the next canvas click inserts it and disarms again. */
|
|
826
|
+
placeDevice(device) {
|
|
827
|
+
placeDevice(this.#session, device);
|
|
828
|
+
}
|
|
829
|
+
cancelPlacement() {
|
|
830
|
+
cancelPlacement(this.#session);
|
|
831
|
+
}
|
|
832
|
+
/** Reference designator the next placed component gets, per the host's policy. */
|
|
833
|
+
nextDesignator(device) {
|
|
834
|
+
return nextDesignator(this.#session, device);
|
|
835
|
+
}
|
|
836
|
+
moveSelectionBy(dx, dy) {
|
|
837
|
+
moveSelectionBy(this.#session, dx, dy);
|
|
838
|
+
}
|
|
839
|
+
rotateSelection(degrees) {
|
|
840
|
+
rotateSelection(this.#session, degrees);
|
|
841
|
+
}
|
|
842
|
+
deleteSelection() {
|
|
843
|
+
deleteSelection(this.#session);
|
|
844
|
+
}
|
|
845
|
+
moveReferenceBy(symbolId, dx, dy) {
|
|
846
|
+
moveReferenceBy(this.#session, symbolId, dx, dy);
|
|
847
|
+
}
|
|
848
|
+
setSymbolProperty(symbolId, key, value) {
|
|
849
|
+
setSymbolProperty(this.#session, symbolId, key, value);
|
|
850
|
+
}
|
|
851
|
+
removeSymbolProperty(symbolId, key) {
|
|
852
|
+
removeSymbolProperty(this.#session, symbolId, key);
|
|
853
|
+
}
|
|
854
|
+
renameDesignator(symbolId, nextDesignator) {
|
|
855
|
+
renameDesignator(this.#session, symbolId, nextDesignator);
|
|
856
|
+
}
|
|
857
|
+
renameNet(symbolId, nextNet) {
|
|
858
|
+
renameNet(this.#session, symbolId, nextNet);
|
|
859
|
+
}
|
|
860
|
+
addWire(points) {
|
|
861
|
+
addWire(this.#session, points);
|
|
862
|
+
}
|
|
863
|
+
connectWireAt(wireId, point, otherWireId) {
|
|
864
|
+
return connectWireAt(this.#session, wireId, point, otherWireId);
|
|
865
|
+
}
|
|
866
|
+
moveWireSegment(wireId, segment, delta) {
|
|
867
|
+
moveWireSegment(this.#session, wireId, segment, delta);
|
|
868
|
+
}
|
|
869
|
+
previewWireSegment(wireId, segment, delta) {
|
|
870
|
+
return previewWireSegment(this.#session, wireId, segment, delta);
|
|
871
|
+
}
|
|
872
|
+
inspect() {
|
|
873
|
+
return this.#session.inspect();
|
|
874
|
+
}
|
|
875
|
+
topology() {
|
|
876
|
+
return this.#session.topology();
|
|
877
|
+
}
|
|
878
|
+
hitTest(point, options = {}) {
|
|
879
|
+
return this.#session.hitTest(point, options);
|
|
880
|
+
}
|
|
881
|
+
subscribe(listener) {
|
|
882
|
+
return this.#session.subscribe(listener);
|
|
883
|
+
}
|
|
884
|
+
};
|
|
885
|
+
//#endregion
|
|
886
|
+
//#region src/render/theme.ts
|
|
887
|
+
/**
|
|
888
|
+
* Default dark theme: neutral greys on near-black, matching the reference palette
|
|
889
|
+
* (background `#0a0a0a`, foreground `#fafafa`, muted `#a1a1a1`, ring `#737373`).
|
|
890
|
+
*/
|
|
891
|
+
var DARK_SCHEMATIC_THEME = {
|
|
892
|
+
background: "#0a0a0a",
|
|
893
|
+
grid: "rgba(250, 250, 250, 0.06)",
|
|
894
|
+
gridMajor: "rgba(250, 250, 250, 0.12)",
|
|
895
|
+
wire: "#a1a1a1",
|
|
896
|
+
wireSelected: "#fafafa",
|
|
897
|
+
junction: "#e5e5e5",
|
|
898
|
+
junctionSelected: "#fafafa",
|
|
899
|
+
pendingWire: "#fafafa",
|
|
900
|
+
symbolFill: "#171717",
|
|
901
|
+
symbolStroke: "#737373",
|
|
902
|
+
/** Device name in the symbol centre: quiet until the symbol is hovered or selected. */
|
|
903
|
+
symbolText: "#a1a1a1",
|
|
904
|
+
symbolPlaceholder: "rgba(250, 250, 250, 0.28)",
|
|
905
|
+
pin: "#737373",
|
|
906
|
+
pinText: "#a1a1a1",
|
|
907
|
+
pinOpen: "#e5e5e5",
|
|
908
|
+
labelAnchor: "#737373",
|
|
909
|
+
labelText: "#a1a1a1",
|
|
910
|
+
selection: "#fafafa",
|
|
911
|
+
hover: "#a1a1a1",
|
|
912
|
+
uiBackground: "#0a0a0a",
|
|
913
|
+
uiSurface: "#171717",
|
|
914
|
+
uiBorder: "rgba(250, 250, 250, 0.1)",
|
|
915
|
+
uiText: "#fafafa",
|
|
916
|
+
uiMutedText: "#a1a1a1",
|
|
917
|
+
uiAccent: "#e5e5e5",
|
|
918
|
+
uiAccentText: "#171717",
|
|
919
|
+
focusRing: "#737373",
|
|
920
|
+
fontFamily: "ui-sans-serif, system-ui, -apple-system, \"Segoe UI\", sans-serif",
|
|
921
|
+
fontSize: 9,
|
|
922
|
+
pinFontSize: 7,
|
|
923
|
+
symbolFontSize: 9,
|
|
924
|
+
gridSize: 10
|
|
925
|
+
};
|
|
926
|
+
var LIGHT_SCHEMATIC_THEME = {
|
|
927
|
+
background: "#ffffff",
|
|
928
|
+
grid: "rgba(10, 10, 10, 0.05)",
|
|
929
|
+
gridMajor: "rgba(10, 10, 10, 0.1)",
|
|
930
|
+
wire: "#525252",
|
|
931
|
+
wireSelected: "#0a0a0a",
|
|
932
|
+
junction: "#262626",
|
|
933
|
+
junctionSelected: "#0a0a0a",
|
|
934
|
+
pendingWire: "#0a0a0a",
|
|
935
|
+
symbolFill: "#fafafa",
|
|
936
|
+
symbolStroke: "#a1a1a1",
|
|
937
|
+
/** Device name in the symbol centre: quiet until the symbol is hovered or selected. */
|
|
938
|
+
symbolText: "#525252",
|
|
939
|
+
symbolPlaceholder: "rgba(10, 10, 10, 0.3)",
|
|
940
|
+
pin: "#a1a1a1",
|
|
941
|
+
pinText: "#525252",
|
|
942
|
+
pinOpen: "#262626",
|
|
943
|
+
labelAnchor: "#a1a1a1",
|
|
944
|
+
labelText: "#525252",
|
|
945
|
+
selection: "#0a0a0a",
|
|
946
|
+
hover: "#525252",
|
|
947
|
+
uiBackground: "#ffffff",
|
|
948
|
+
uiSurface: "#f5f5f5",
|
|
949
|
+
uiBorder: "rgba(10, 10, 10, 0.1)",
|
|
950
|
+
uiText: "#0a0a0a",
|
|
951
|
+
uiMutedText: "#525252",
|
|
952
|
+
uiAccent: "#171717",
|
|
953
|
+
uiAccentText: "#fafafa",
|
|
954
|
+
focusRing: "#a1a1a1",
|
|
955
|
+
fontFamily: DARK_SCHEMATIC_THEME.fontFamily,
|
|
956
|
+
fontSize: DARK_SCHEMATIC_THEME.fontSize,
|
|
957
|
+
pinFontSize: DARK_SCHEMATIC_THEME.pinFontSize,
|
|
958
|
+
symbolFontSize: DARK_SCHEMATIC_THEME.symbolFontSize,
|
|
959
|
+
gridSize: DARK_SCHEMATIC_THEME.gridSize
|
|
960
|
+
};
|
|
961
|
+
/** Merges overrides onto the dark baseline, so partial themes stay readable. */
|
|
962
|
+
function resolveSchematicTheme(overrides = {}) {
|
|
963
|
+
const resolved = { ...DARK_SCHEMATIC_THEME };
|
|
964
|
+
for (const key of Object.keys(overrides)) {
|
|
965
|
+
const value = overrides[key];
|
|
966
|
+
if (value !== void 0) Object.assign(resolved, { [key]: value });
|
|
967
|
+
}
|
|
968
|
+
return resolved;
|
|
969
|
+
}
|
|
970
|
+
/** Maps the theme onto `--mica-schematic-*` CSS variables for light-DOM chrome. */
|
|
971
|
+
function themeToCssVariables(theme) {
|
|
972
|
+
const variables = {};
|
|
973
|
+
for (const [key, value] of Object.entries(theme)) variables[`--mica-schematic-${kebabCase(key)}`] = String(value);
|
|
974
|
+
return variables;
|
|
975
|
+
}
|
|
976
|
+
function kebabCase(value) {
|
|
977
|
+
return value.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`);
|
|
978
|
+
}
|
|
979
|
+
//#endregion
|
|
980
|
+
//#region src/render/viewport.ts
|
|
981
|
+
var DEFAULT_VIEWPORT_LIMITS = {
|
|
982
|
+
minScale: .25,
|
|
983
|
+
maxScale: 4
|
|
984
|
+
};
|
|
985
|
+
/** Merges partial overrides onto the defaults; rejects non-positive and inverted ranges. */
|
|
986
|
+
function resolveViewportLimits(limits) {
|
|
987
|
+
const minScale = limits?.minScale;
|
|
988
|
+
const maxScale = limits?.maxScale;
|
|
989
|
+
const resolved = {
|
|
990
|
+
minScale: minScale !== void 0 && Number.isFinite(minScale) && minScale > 0 ? minScale : DEFAULT_VIEWPORT_LIMITS.minScale,
|
|
991
|
+
maxScale: maxScale !== void 0 && Number.isFinite(maxScale) && maxScale > 0 ? maxScale : DEFAULT_VIEWPORT_LIMITS.maxScale
|
|
992
|
+
};
|
|
993
|
+
if (resolved.minScale > resolved.maxScale) return {
|
|
994
|
+
minScale: resolved.maxScale,
|
|
995
|
+
maxScale: resolved.minScale
|
|
996
|
+
};
|
|
997
|
+
return resolved;
|
|
998
|
+
}
|
|
999
|
+
var IDENTITY_VIEWPORT = {
|
|
1000
|
+
scale: 1,
|
|
1001
|
+
offsetX: 0,
|
|
1002
|
+
offsetY: 0
|
|
1003
|
+
};
|
|
1004
|
+
function screenToPage(viewport, point) {
|
|
1005
|
+
return {
|
|
1006
|
+
x: (point.x - viewport.offsetX) / viewport.scale,
|
|
1007
|
+
y: (point.y - viewport.offsetY) / viewport.scale
|
|
1008
|
+
};
|
|
1009
|
+
}
|
|
1010
|
+
function pageToScreen(viewport, point) {
|
|
1011
|
+
return {
|
|
1012
|
+
x: point.x * viewport.scale + viewport.offsetX,
|
|
1013
|
+
y: point.y * viewport.scale + viewport.offsetY
|
|
1014
|
+
};
|
|
1015
|
+
}
|
|
1016
|
+
function clampScale(scale, limits = DEFAULT_VIEWPORT_LIMITS) {
|
|
1017
|
+
return Math.min(limits.maxScale, Math.max(limits.minScale, scale));
|
|
1018
|
+
}
|
|
1019
|
+
/**
|
|
1020
|
+
* Clamps a viewport to `limits` while keeping the page point at the centre of `size` in place,
|
|
1021
|
+
* so restoring a saved view can never jump the content off screen.
|
|
1022
|
+
*/
|
|
1023
|
+
function clampViewport(viewport, size, limits = DEFAULT_VIEWPORT_LIMITS) {
|
|
1024
|
+
const scale = clampScale(Number.isFinite(viewport.scale) && viewport.scale > 0 ? viewport.scale : limits.minScale, limits);
|
|
1025
|
+
if (scale === viewport.scale) return viewport;
|
|
1026
|
+
const center = {
|
|
1027
|
+
x: size.width / 2,
|
|
1028
|
+
y: size.height / 2
|
|
1029
|
+
};
|
|
1030
|
+
const anchor = screenToPage(viewport, center);
|
|
1031
|
+
return {
|
|
1032
|
+
scale,
|
|
1033
|
+
offsetX: center.x - anchor.x * scale,
|
|
1034
|
+
offsetY: center.y - anchor.y * scale
|
|
1035
|
+
};
|
|
1036
|
+
}
|
|
1037
|
+
/** Zooms around a screen-space point so the page point under the cursor stays fixed. */ function zoomAt(viewport, screenPoint, factor, limits = DEFAULT_VIEWPORT_LIMITS) {
|
|
1038
|
+
const scale = clampScale(viewport.scale * factor, limits);
|
|
1039
|
+
if (scale === viewport.scale) return viewport;
|
|
1040
|
+
const anchor = screenToPage(viewport, screenPoint);
|
|
1041
|
+
return {
|
|
1042
|
+
scale,
|
|
1043
|
+
offsetX: screenPoint.x - anchor.x * scale,
|
|
1044
|
+
offsetY: screenPoint.y - anchor.y * scale
|
|
1045
|
+
};
|
|
1046
|
+
}
|
|
1047
|
+
function panBy(viewport, dx, dy) {
|
|
1048
|
+
return {
|
|
1049
|
+
...viewport,
|
|
1050
|
+
offsetX: viewport.offsetX + dx,
|
|
1051
|
+
offsetY: viewport.offsetY + dy
|
|
1052
|
+
};
|
|
1053
|
+
}
|
|
1054
|
+
/** Fits `bounds` into `size` with padding, centering the content. */
|
|
1055
|
+
function fitBounds(bounds, size, padding = 32, limits = DEFAULT_VIEWPORT_LIMITS) {
|
|
1056
|
+
const availableWidth = Math.max(1, size.width - padding * 2);
|
|
1057
|
+
const availableHeight = Math.max(1, size.height - padding * 2);
|
|
1058
|
+
const width = Math.max(bounds.width, 1e-6);
|
|
1059
|
+
const height = Math.max(bounds.height, 1e-6);
|
|
1060
|
+
const scale = clampScale(Math.min(availableWidth / width, availableHeight / height), limits);
|
|
1061
|
+
const centerX = bounds.x + bounds.width / 2;
|
|
1062
|
+
const centerY = bounds.y + bounds.height / 2;
|
|
1063
|
+
return {
|
|
1064
|
+
scale,
|
|
1065
|
+
offsetX: size.width / 2 - centerX * scale,
|
|
1066
|
+
offsetY: size.height / 2 - centerY * scale
|
|
1067
|
+
};
|
|
1068
|
+
}
|
|
1069
|
+
//#endregion
|
|
1070
|
+
//#region src/render/canvas-renderer.ts
|
|
1071
|
+
var MAJOR_GRID_INTERVAL = 5;
|
|
1072
|
+
/** Sub-grid lines are hidden once they get closer together than this (CSS pixels). */
|
|
1073
|
+
var MIN_GRID_PIXELS = 4;
|
|
1074
|
+
/** Emphasised lines are stretched to stay at least this far apart (CSS pixels). */
|
|
1075
|
+
var MIN_MAJOR_GRID_PIXELS = 20;
|
|
1076
|
+
/** Draws a {@link SchematicScene} onto a canvas, honoring device pixel ratio. */
|
|
1077
|
+
var CanvasSchematicRenderer = class {
|
|
1078
|
+
#canvas;
|
|
1079
|
+
#context;
|
|
1080
|
+
#theme;
|
|
1081
|
+
#width = 0;
|
|
1082
|
+
#height = 0;
|
|
1083
|
+
#pixelRatio = 1;
|
|
1084
|
+
constructor(canvas, options = {}) {
|
|
1085
|
+
this.#canvas = canvas;
|
|
1086
|
+
this.#context = canvas.getContext("2d");
|
|
1087
|
+
this.#theme = resolveSchematicTheme(options.theme);
|
|
1088
|
+
}
|
|
1089
|
+
get theme() {
|
|
1090
|
+
return this.#theme;
|
|
1091
|
+
}
|
|
1092
|
+
setTheme(theme) {
|
|
1093
|
+
this.#theme = theme;
|
|
1094
|
+
}
|
|
1095
|
+
resize(width, height, pixelRatio = 1) {
|
|
1096
|
+
this.#width = width;
|
|
1097
|
+
this.#height = height;
|
|
1098
|
+
this.#pixelRatio = pixelRatio;
|
|
1099
|
+
this.#canvas.width = Math.max(1, Math.round(width * pixelRatio));
|
|
1100
|
+
this.#canvas.height = Math.max(1, Math.round(height * pixelRatio));
|
|
1101
|
+
}
|
|
1102
|
+
render(scene, viewport) {
|
|
1103
|
+
const context = this.#context;
|
|
1104
|
+
if (context === null) return;
|
|
1105
|
+
context.setTransform(this.#pixelRatio, 0, 0, this.#pixelRatio, 0, 0);
|
|
1106
|
+
context.clearRect(0, 0, this.#width, this.#height);
|
|
1107
|
+
context.fillStyle = scene.background;
|
|
1108
|
+
context.fillRect(0, 0, this.#width, this.#height);
|
|
1109
|
+
if (scene.grid !== void 0) this.#drawGrid(context, scene, viewport);
|
|
1110
|
+
for (const item of scene.items) this.#drawItem(context, item, viewport);
|
|
1111
|
+
}
|
|
1112
|
+
destroy() {
|
|
1113
|
+
this.#context = null;
|
|
1114
|
+
}
|
|
1115
|
+
#drawGrid(context, scene, viewport) {
|
|
1116
|
+
const grid = scene.grid;
|
|
1117
|
+
if (grid === void 0) return;
|
|
1118
|
+
const cell = grid.size * viewport.scale;
|
|
1119
|
+
if (!Number.isFinite(cell) || cell <= 0) return;
|
|
1120
|
+
let subdivisions = grid.subdivisions;
|
|
1121
|
+
while (subdivisions > 1 && cell / subdivisions < MIN_GRID_PIXELS) subdivisions = Math.max(1, Math.round(subdivisions / 2));
|
|
1122
|
+
let step = cell / subdivisions;
|
|
1123
|
+
if (step < MIN_GRID_PIXELS) step = cell;
|
|
1124
|
+
let majorSpacing = cell * MAJOR_GRID_INTERVAL;
|
|
1125
|
+
while (majorSpacing < MIN_MAJOR_GRID_PIXELS) majorSpacing *= 2;
|
|
1126
|
+
const majorOnly = step < MIN_GRID_PIXELS;
|
|
1127
|
+
if (majorOnly) step = majorSpacing;
|
|
1128
|
+
const origin = pageToScreen(viewport, {
|
|
1129
|
+
x: 0,
|
|
1130
|
+
y: 0
|
|
1131
|
+
});
|
|
1132
|
+
const startX = origin.x - Math.ceil(origin.x / step) * step;
|
|
1133
|
+
const startY = origin.y - Math.ceil(origin.y / step) * step;
|
|
1134
|
+
context.lineWidth = 1;
|
|
1135
|
+
for (let x = startX; x <= this.#width; x += step) {
|
|
1136
|
+
if (x < 0) continue;
|
|
1137
|
+
context.strokeStyle = majorOnly || isMajorGridLine(x - origin.x, majorSpacing) ? grid.major : grid.minor;
|
|
1138
|
+
strokeLine(context, {
|
|
1139
|
+
x: Math.round(x) + .5,
|
|
1140
|
+
y: 0
|
|
1141
|
+
}, {
|
|
1142
|
+
x: Math.round(x) + .5,
|
|
1143
|
+
y: this.#height
|
|
1144
|
+
});
|
|
1145
|
+
}
|
|
1146
|
+
for (let y = startY; y <= this.#height; y += step) {
|
|
1147
|
+
if (y < 0) continue;
|
|
1148
|
+
context.strokeStyle = majorOnly || isMajorGridLine(y - origin.y, majorSpacing) ? grid.major : grid.minor;
|
|
1149
|
+
strokeLine(context, {
|
|
1150
|
+
x: 0,
|
|
1151
|
+
y: Math.round(y) + .5
|
|
1152
|
+
}, {
|
|
1153
|
+
x: this.#width,
|
|
1154
|
+
y: Math.round(y) + .5
|
|
1155
|
+
});
|
|
1156
|
+
}
|
|
1157
|
+
}
|
|
1158
|
+
#drawItem(context, item, viewport) {
|
|
1159
|
+
switch (item.kind) {
|
|
1160
|
+
case "line":
|
|
1161
|
+
context.strokeStyle = item.color;
|
|
1162
|
+
context.lineWidth = item.width;
|
|
1163
|
+
context.setLineDash(item.dash === void 0 ? [] : [...item.dash]);
|
|
1164
|
+
strokeLine(context, pageToScreen(viewport, item.from), pageToScreen(viewport, item.to));
|
|
1165
|
+
context.setLineDash([]);
|
|
1166
|
+
break;
|
|
1167
|
+
case "polyline":
|
|
1168
|
+
context.strokeStyle = item.color;
|
|
1169
|
+
context.lineWidth = item.width;
|
|
1170
|
+
context.lineJoin = "round";
|
|
1171
|
+
context.lineCap = "round";
|
|
1172
|
+
context.setLineDash(item.dash === void 0 ? [] : [...item.dash]);
|
|
1173
|
+
context.beginPath();
|
|
1174
|
+
item.points.forEach((point, index) => {
|
|
1175
|
+
const screen = pageToScreen(viewport, point);
|
|
1176
|
+
if (index === 0) context.moveTo(screen.x, screen.y);
|
|
1177
|
+
else context.lineTo(screen.x, screen.y);
|
|
1178
|
+
});
|
|
1179
|
+
context.stroke();
|
|
1180
|
+
context.setLineDash([]);
|
|
1181
|
+
break;
|
|
1182
|
+
case "rect": {
|
|
1183
|
+
const topLeft = pageToScreen(viewport, {
|
|
1184
|
+
x: item.bounds.x,
|
|
1185
|
+
y: item.bounds.y
|
|
1186
|
+
});
|
|
1187
|
+
const width = item.bounds.width * viewport.scale;
|
|
1188
|
+
const height = item.bounds.height * viewport.scale;
|
|
1189
|
+
context.setLineDash(item.dash === void 0 ? [] : [...item.dash]);
|
|
1190
|
+
if (item.fill !== void 0) {
|
|
1191
|
+
context.fillStyle = item.fill;
|
|
1192
|
+
context.fillRect(topLeft.x, topLeft.y, width, height);
|
|
1193
|
+
}
|
|
1194
|
+
if (item.stroke !== void 0) {
|
|
1195
|
+
context.strokeStyle = item.stroke;
|
|
1196
|
+
context.lineWidth = item.width;
|
|
1197
|
+
context.strokeRect(topLeft.x, topLeft.y, width, height);
|
|
1198
|
+
}
|
|
1199
|
+
context.setLineDash([]);
|
|
1200
|
+
break;
|
|
1201
|
+
}
|
|
1202
|
+
case "circle": {
|
|
1203
|
+
const center = pageToScreen(viewport, item.center);
|
|
1204
|
+
context.beginPath();
|
|
1205
|
+
context.arc(center.x, center.y, Math.max(.5, item.radius * viewport.scale), 0, Math.PI * 2);
|
|
1206
|
+
if (item.fill !== void 0) {
|
|
1207
|
+
context.fillStyle = item.fill;
|
|
1208
|
+
context.fill();
|
|
1209
|
+
}
|
|
1210
|
+
if (item.stroke !== void 0) {
|
|
1211
|
+
context.strokeStyle = item.stroke;
|
|
1212
|
+
context.lineWidth = item.width;
|
|
1213
|
+
context.stroke();
|
|
1214
|
+
}
|
|
1215
|
+
break;
|
|
1216
|
+
}
|
|
1217
|
+
case "text": {
|
|
1218
|
+
const position = pageToScreen(viewport, item.position);
|
|
1219
|
+
context.fillStyle = item.color;
|
|
1220
|
+
context.font = scaleFont(item.font, viewport.scale);
|
|
1221
|
+
context.textAlign = item.align;
|
|
1222
|
+
context.textBaseline = item.baseline;
|
|
1223
|
+
if (item.rotation === void 0 || item.rotation === 0) {
|
|
1224
|
+
context.fillText(item.text, position.x, position.y);
|
|
1225
|
+
break;
|
|
1226
|
+
}
|
|
1227
|
+
context.save();
|
|
1228
|
+
context.translate(position.x, position.y);
|
|
1229
|
+
context.rotate(item.rotation * Math.PI / 180);
|
|
1230
|
+
context.fillText(item.text, 0, 0);
|
|
1231
|
+
context.restore();
|
|
1232
|
+
break;
|
|
1233
|
+
}
|
|
1234
|
+
}
|
|
1235
|
+
}
|
|
1236
|
+
};
|
|
1237
|
+
function strokeLine(context, from, to) {
|
|
1238
|
+
context.beginPath();
|
|
1239
|
+
context.moveTo(from.x, from.y);
|
|
1240
|
+
context.lineTo(to.x, to.y);
|
|
1241
|
+
context.stroke();
|
|
1242
|
+
}
|
|
1243
|
+
function isMajorGridLine(offset, spacing) {
|
|
1244
|
+
if (!Number.isFinite(spacing) || spacing <= 0) return false;
|
|
1245
|
+
const remainder = Math.abs(offset % spacing);
|
|
1246
|
+
return remainder < .5 || spacing - remainder < .5;
|
|
1247
|
+
}
|
|
1248
|
+
function scaleFont(font, scale) {
|
|
1249
|
+
const match = /^(\d+(?:\.\d+)?)px(.*)$/.exec(font);
|
|
1250
|
+
if (match === null) return font;
|
|
1251
|
+
return `${(Number(match[1]) * Math.max(scale, .01)).toFixed(1)}px${match[2]}`;
|
|
1252
|
+
}
|
|
1253
|
+
//#endregion
|
|
1254
|
+
//#region src/render/scene.ts
|
|
1255
|
+
var WIRE_WIDTH = 1.5;
|
|
1256
|
+
var SELECTED_WIDTH = 2.5;
|
|
1257
|
+
var JUNCTION_RADIUS = 3;
|
|
1258
|
+
var VERTEX_SIZE = 5;
|
|
1259
|
+
/** Visible grid lines per snap cell; anything unset or invalid falls back to the snap grid. */
|
|
1260
|
+
function gridSubdivisions(value) {
|
|
1261
|
+
return value !== void 0 && Number.isFinite(value) && value >= 1 ? Math.round(value) : 1;
|
|
1262
|
+
}
|
|
1263
|
+
var ALIGN_MIRROR = {
|
|
1264
|
+
left: "right",
|
|
1265
|
+
center: "center",
|
|
1266
|
+
right: "left"
|
|
1267
|
+
};
|
|
1268
|
+
var BASELINE_MIRROR = {
|
|
1269
|
+
top: "bottom",
|
|
1270
|
+
middle: "middle",
|
|
1271
|
+
bottom: "top"
|
|
1272
|
+
};
|
|
1273
|
+
/**
|
|
1274
|
+
* Text turns with the symbol, except at half turns where it would read upside down: those flip
|
|
1275
|
+
* back and mirror the anchor alignment so the glyphs stay on the same side of the anchor.
|
|
1276
|
+
*/
|
|
1277
|
+
function uprightText(rotation, align, baseline) {
|
|
1278
|
+
const normalized = (rotation % 360 + 540) % 360 - 180;
|
|
1279
|
+
if (Math.abs(normalized) === 180) return {
|
|
1280
|
+
align: ALIGN_MIRROR[align],
|
|
1281
|
+
baseline: BASELINE_MIRROR[baseline],
|
|
1282
|
+
rotation: 0
|
|
1283
|
+
};
|
|
1284
|
+
return {
|
|
1285
|
+
align,
|
|
1286
|
+
baseline,
|
|
1287
|
+
rotation: normalized
|
|
1288
|
+
};
|
|
1289
|
+
}
|
|
1290
|
+
var NO_PREVIEW = {
|
|
1291
|
+
wires: /* @__PURE__ */ new Map(),
|
|
1292
|
+
junctions: /* @__PURE__ */ new Map(),
|
|
1293
|
+
symbols: /* @__PURE__ */ new Map()
|
|
1294
|
+
};
|
|
1295
|
+
/** Live preview of the dragged symbols: wires, junctions and anchors follow their pins. */
|
|
1296
|
+
function previewDrag(document, devices, selection) {
|
|
1297
|
+
const offset = selection.offset;
|
|
1298
|
+
if (offset === void 0 || selection.symbols === void 0) return NO_PREVIEW;
|
|
1299
|
+
const wires = /* @__PURE__ */ new Map();
|
|
1300
|
+
const junctions = /* @__PURE__ */ new Map();
|
|
1301
|
+
const symbols = /* @__PURE__ */ new Map();
|
|
1302
|
+
for (const symbol of document.sheet.symbols) {
|
|
1303
|
+
if (!selection.symbols.has(symbol.id)) continue;
|
|
1304
|
+
const placed = {
|
|
1305
|
+
x: symbol.x,
|
|
1306
|
+
y: symbol.y,
|
|
1307
|
+
rotation: symbol.rotation
|
|
1308
|
+
};
|
|
1309
|
+
const preview = previewSymbolDrag(document, devices, symbol.id, {
|
|
1310
|
+
...placed,
|
|
1311
|
+
x: placed.x + offset.x,
|
|
1312
|
+
y: placed.y + offset.y
|
|
1313
|
+
});
|
|
1314
|
+
if (preview === void 0) continue;
|
|
1315
|
+
for (const [id, points] of preview.wires) wires.set(id, points);
|
|
1316
|
+
for (const [id, point] of preview.junctions) junctions.set(id, point);
|
|
1317
|
+
for (const [id, point] of preview.symbols) symbols.set(id, point);
|
|
1318
|
+
symbols.set(symbol.id, {
|
|
1319
|
+
x: placed.x + offset.x,
|
|
1320
|
+
y: placed.y + offset.y
|
|
1321
|
+
});
|
|
1322
|
+
}
|
|
1323
|
+
return {
|
|
1324
|
+
wires,
|
|
1325
|
+
junctions,
|
|
1326
|
+
symbols
|
|
1327
|
+
};
|
|
1328
|
+
}
|
|
1329
|
+
/** Builds a flat, drawable scene. Every color comes from `theme`. */
|
|
1330
|
+
function buildSchematicScene(input) {
|
|
1331
|
+
const { document, devices, theme } = input;
|
|
1332
|
+
const selection = input.selection ?? {};
|
|
1333
|
+
const tool = input.tool ?? "select";
|
|
1334
|
+
const display = input.display ?? {
|
|
1335
|
+
grid: true,
|
|
1336
|
+
labels: true
|
|
1337
|
+
};
|
|
1338
|
+
const font = `${theme.fontSize}px ${theme.fontFamily}`;
|
|
1339
|
+
const pinFont = `${theme.pinFontSize}px ${theme.fontFamily}`;
|
|
1340
|
+
const symbolFont = `${theme.symbolFontSize}px ${theme.fontFamily}`;
|
|
1341
|
+
const items = [];
|
|
1342
|
+
const drag = previewDrag(document, devices, selection);
|
|
1343
|
+
for (const wire of document.sheet.wires) {
|
|
1344
|
+
const selected = selection.wires?.has(wire.id) ?? false;
|
|
1345
|
+
const hovered = input.hover?.kind === "wire" && input.hover.id === wire.id;
|
|
1346
|
+
const preview = input.wirePreview;
|
|
1347
|
+
const points = preview !== void 0 && preview.wireId === wire.id ? preview.points : drag.wires.get(wire.id) ?? wire.points;
|
|
1348
|
+
items.push({
|
|
1349
|
+
kind: "polyline",
|
|
1350
|
+
points,
|
|
1351
|
+
color: selected ? theme.wireSelected : hovered ? theme.hover : theme.wire,
|
|
1352
|
+
width: selected ? SELECTED_WIDTH : WIRE_WIDTH
|
|
1353
|
+
});
|
|
1354
|
+
}
|
|
1355
|
+
for (const junction of document.sheet.junctions) {
|
|
1356
|
+
const selected = selection.junctions?.has(junction.id) ?? false;
|
|
1357
|
+
const hovered = input.hover?.kind === "junction" && input.hover.id === junction.id;
|
|
1358
|
+
const center = drag.junctions.get(junction.id) ?? {
|
|
1359
|
+
x: junction.x,
|
|
1360
|
+
y: junction.y
|
|
1361
|
+
};
|
|
1362
|
+
items.push({
|
|
1363
|
+
kind: "circle",
|
|
1364
|
+
center,
|
|
1365
|
+
radius: JUNCTION_RADIUS,
|
|
1366
|
+
fill: selected ? theme.junctionSelected : hovered ? theme.hover : theme.junction,
|
|
1367
|
+
width: 0
|
|
1368
|
+
});
|
|
1369
|
+
}
|
|
1370
|
+
for (const symbol of document.sheet.symbols) {
|
|
1371
|
+
const selected = selection.symbols?.has(symbol.id) ?? false;
|
|
1372
|
+
const hovered = input.hover?.kind === "symbol" && input.hover.id === symbol.id || input.hover?.kind === "reference" && input.hover.symbolId === symbol.id;
|
|
1373
|
+
const placed = {
|
|
1374
|
+
x: symbol.x,
|
|
1375
|
+
y: symbol.y,
|
|
1376
|
+
rotation: symbol.rotation
|
|
1377
|
+
};
|
|
1378
|
+
const dragged = drag.symbols.get(symbol.id);
|
|
1379
|
+
drawSymbol(items, {
|
|
1380
|
+
symbol,
|
|
1381
|
+
placement: dragged === void 0 ? placed : {
|
|
1382
|
+
...placed,
|
|
1383
|
+
x: dragged.x,
|
|
1384
|
+
y: dragged.y
|
|
1385
|
+
},
|
|
1386
|
+
theme,
|
|
1387
|
+
devices,
|
|
1388
|
+
labels: display.labels,
|
|
1389
|
+
fonts: {
|
|
1390
|
+
font,
|
|
1391
|
+
pinFont,
|
|
1392
|
+
symbolFont
|
|
1393
|
+
},
|
|
1394
|
+
selected,
|
|
1395
|
+
hovered,
|
|
1396
|
+
...input.referencePreview?.symbolId === symbol.id ? { referenceOverride: input.referencePreview.position } : {}
|
|
1397
|
+
});
|
|
1398
|
+
}
|
|
1399
|
+
for (const vertex of selection.vertices ?? []) {
|
|
1400
|
+
const point = document.sheet.wires.find((candidate) => candidate.id === vertex.wireId)?.points[vertex.index];
|
|
1401
|
+
if (point === void 0) continue;
|
|
1402
|
+
const center = point;
|
|
1403
|
+
items.push({
|
|
1404
|
+
kind: "rect",
|
|
1405
|
+
bounds: {
|
|
1406
|
+
x: center.x - VERTEX_SIZE / 2,
|
|
1407
|
+
y: center.y - VERTEX_SIZE / 2,
|
|
1408
|
+
width: VERTEX_SIZE,
|
|
1409
|
+
height: VERTEX_SIZE
|
|
1410
|
+
},
|
|
1411
|
+
fill: theme.selection,
|
|
1412
|
+
width: 0
|
|
1413
|
+
});
|
|
1414
|
+
}
|
|
1415
|
+
if (input.marquee !== void 0) items.push({
|
|
1416
|
+
kind: "rect",
|
|
1417
|
+
bounds: input.marquee,
|
|
1418
|
+
stroke: theme.selection,
|
|
1419
|
+
width: 1,
|
|
1420
|
+
dash: [4, 3]
|
|
1421
|
+
});
|
|
1422
|
+
if (input.placementPreview !== void 0) {
|
|
1423
|
+
const { device, position, rotation, wire } = input.placementPreview;
|
|
1424
|
+
if (wire !== void 0) items.push({
|
|
1425
|
+
kind: "polyline",
|
|
1426
|
+
points: wire,
|
|
1427
|
+
color: theme.hover,
|
|
1428
|
+
width: WIRE_WIDTH
|
|
1429
|
+
});
|
|
1430
|
+
drawSymbol(items, {
|
|
1431
|
+
symbol: previewSymbol(device, position),
|
|
1432
|
+
placement: {
|
|
1433
|
+
...position,
|
|
1434
|
+
rotation
|
|
1435
|
+
},
|
|
1436
|
+
theme,
|
|
1437
|
+
devices,
|
|
1438
|
+
labels: display.labels,
|
|
1439
|
+
fonts: {
|
|
1440
|
+
font,
|
|
1441
|
+
pinFont,
|
|
1442
|
+
symbolFont
|
|
1443
|
+
},
|
|
1444
|
+
selected: false,
|
|
1445
|
+
hovered: false,
|
|
1446
|
+
ghost: true
|
|
1447
|
+
});
|
|
1448
|
+
}
|
|
1449
|
+
if (tool === "wire" && input.pendingWire !== void 0 && input.pendingWire.length > 0) {
|
|
1450
|
+
items.push({
|
|
1451
|
+
kind: "polyline",
|
|
1452
|
+
points: input.pendingWire,
|
|
1453
|
+
color: theme.pendingWire,
|
|
1454
|
+
width: WIRE_WIDTH,
|
|
1455
|
+
dash: [5, 4]
|
|
1456
|
+
});
|
|
1457
|
+
const last = input.pendingWire[input.pendingWire.length - 1];
|
|
1458
|
+
if (last !== void 0) items.push({
|
|
1459
|
+
kind: "circle",
|
|
1460
|
+
center: last,
|
|
1461
|
+
radius: 2.5,
|
|
1462
|
+
fill: theme.pendingWire,
|
|
1463
|
+
width: 0
|
|
1464
|
+
});
|
|
1465
|
+
}
|
|
1466
|
+
return {
|
|
1467
|
+
background: theme.background,
|
|
1468
|
+
grid: display.grid === false ? void 0 : {
|
|
1469
|
+
size: display.gridSize ?? theme.gridSize,
|
|
1470
|
+
subdivisions: gridSubdivisions(display.gridSubdivisions),
|
|
1471
|
+
minor: theme.grid,
|
|
1472
|
+
major: theme.gridMajor
|
|
1473
|
+
},
|
|
1474
|
+
items
|
|
1475
|
+
};
|
|
1476
|
+
}
|
|
1477
|
+
/**
|
|
1478
|
+
* Draws one symbol: the single code path shared by placed symbols and the placement preview,
|
|
1479
|
+
* so what follows the cursor is exactly what will be inserted.
|
|
1480
|
+
*/
|
|
1481
|
+
function drawSymbol(items, options) {
|
|
1482
|
+
const { symbol, placement, theme, devices, fonts, selected, hovered } = options;
|
|
1483
|
+
const ghost = options.ghost ?? false;
|
|
1484
|
+
const highlight = selected ? theme.selection : hovered || ghost ? theme.hover : void 0;
|
|
1485
|
+
const stroke = highlight ?? theme.symbolStroke;
|
|
1486
|
+
const annotation = highlight ?? theme.labelText;
|
|
1487
|
+
if (isTextDevice(symbol.device)) {
|
|
1488
|
+
items.push({
|
|
1489
|
+
kind: "text",
|
|
1490
|
+
position: placement,
|
|
1491
|
+
text: textDeviceContent(symbol),
|
|
1492
|
+
color: annotation,
|
|
1493
|
+
...uprightText(placement.rotation, "left", "middle"),
|
|
1494
|
+
font: fonts.symbolFont
|
|
1495
|
+
});
|
|
1496
|
+
return;
|
|
1497
|
+
}
|
|
1498
|
+
const builtin = builtinSymbolKind(symbol.device);
|
|
1499
|
+
if (builtin !== void 0) {
|
|
1500
|
+
drawBuiltin(items, {
|
|
1501
|
+
kind: builtin,
|
|
1502
|
+
placement,
|
|
1503
|
+
text: netNameOf(symbol) ?? symbol.id,
|
|
1504
|
+
color: annotation,
|
|
1505
|
+
anchorColor: highlight ?? theme.labelAnchor,
|
|
1506
|
+
font: builtin === "label" ? fonts.font : fonts.pinFont
|
|
1507
|
+
});
|
|
1508
|
+
return;
|
|
1509
|
+
}
|
|
1510
|
+
const reference = parseDeviceReference(symbol.device);
|
|
1511
|
+
const definition = reference === void 0 ? void 0 : devices?.getDeviceSymbol(reference);
|
|
1512
|
+
if (definition === void 0) {
|
|
1513
|
+
const half = 12;
|
|
1514
|
+
items.push({
|
|
1515
|
+
kind: "rect",
|
|
1516
|
+
bounds: {
|
|
1517
|
+
x: placement.x - half,
|
|
1518
|
+
y: placement.y - half,
|
|
1519
|
+
width: 24,
|
|
1520
|
+
height: 24
|
|
1521
|
+
},
|
|
1522
|
+
stroke: theme.symbolPlaceholder,
|
|
1523
|
+
width: 1,
|
|
1524
|
+
dash: [4, 3]
|
|
1525
|
+
});
|
|
1526
|
+
items.push({
|
|
1527
|
+
kind: "text",
|
|
1528
|
+
position: {
|
|
1529
|
+
x: placement.x,
|
|
1530
|
+
y: placement.y
|
|
1531
|
+
},
|
|
1532
|
+
text: (reference === void 0 ? void 0 : devices?.getDeviceName(reference)) ?? symbol.id,
|
|
1533
|
+
color: theme.symbolPlaceholder,
|
|
1534
|
+
align: "center",
|
|
1535
|
+
baseline: "middle",
|
|
1536
|
+
font: fonts.symbolFont
|
|
1537
|
+
});
|
|
1538
|
+
return;
|
|
1539
|
+
}
|
|
1540
|
+
const localBody = definition.bounds ?? pinsBounds(definition.pins) ?? {
|
|
1541
|
+
x: -12,
|
|
1542
|
+
y: -12,
|
|
1543
|
+
width: 24,
|
|
1544
|
+
height: 24
|
|
1545
|
+
};
|
|
1546
|
+
const body = symbolBounds(placement, localBody, {}) ?? fallbackBounds(placement);
|
|
1547
|
+
items.push({
|
|
1548
|
+
kind: "rect",
|
|
1549
|
+
bounds: body,
|
|
1550
|
+
fill: theme.symbolFill,
|
|
1551
|
+
stroke,
|
|
1552
|
+
width: 1.5
|
|
1553
|
+
});
|
|
1554
|
+
items.push({
|
|
1555
|
+
kind: "text",
|
|
1556
|
+
position: options.referenceOverride ?? referencePosition(placement, localBody, symbol.reference),
|
|
1557
|
+
text: designatorOf(symbol) ?? symbol.id,
|
|
1558
|
+
color: options.referenceOverride === void 0 ? annotation : theme.selection,
|
|
1559
|
+
...uprightText(placement.rotation, "left", "bottom"),
|
|
1560
|
+
font: fonts.pinFont
|
|
1561
|
+
});
|
|
1562
|
+
items.push({
|
|
1563
|
+
kind: "text",
|
|
1564
|
+
position: {
|
|
1565
|
+
x: body.x + body.width / 2,
|
|
1566
|
+
y: body.y + body.height / 2
|
|
1567
|
+
},
|
|
1568
|
+
text: definition.name,
|
|
1569
|
+
color: selected || hovered ? theme.selection : theme.symbolText,
|
|
1570
|
+
...uprightText(placement.rotation, "center", "middle"),
|
|
1571
|
+
font: fonts.symbolFont
|
|
1572
|
+
});
|
|
1573
|
+
for (const [pinId, anchor] of Object.entries(definition.pins)) {
|
|
1574
|
+
const side = pinSide(anchor, localBody) ?? "left";
|
|
1575
|
+
const pinPosition = symbolPinPosition(placement, anchor);
|
|
1576
|
+
const edge = symbolPinPosition(placement, pinStubEnd(localBody, anchor, side));
|
|
1577
|
+
items.push({
|
|
1578
|
+
kind: "line",
|
|
1579
|
+
from: edge,
|
|
1580
|
+
to: pinPosition,
|
|
1581
|
+
color: highlight ?? theme.pin,
|
|
1582
|
+
width: 1.2
|
|
1583
|
+
});
|
|
1584
|
+
if (!options.labels) continue;
|
|
1585
|
+
const label = pinLabel(localBody, side, anchor);
|
|
1586
|
+
items.push({
|
|
1587
|
+
kind: "text",
|
|
1588
|
+
position: symbolPinPosition(placement, label.position),
|
|
1589
|
+
text: pinId,
|
|
1590
|
+
color: highlight ?? theme.pinText,
|
|
1591
|
+
...uprightText(placement.rotation + label.rotation, label.align, label.baseline),
|
|
1592
|
+
font: fonts.pinFont
|
|
1593
|
+
});
|
|
1594
|
+
}
|
|
1595
|
+
}
|
|
1596
|
+
/** Synthetic symbol used to preview the current placement tool. */
|
|
1597
|
+
function previewSymbol(device, position) {
|
|
1598
|
+
const base = {
|
|
1599
|
+
id: "N",
|
|
1600
|
+
device,
|
|
1601
|
+
x: position.x,
|
|
1602
|
+
y: position.y,
|
|
1603
|
+
rotation: 0
|
|
1604
|
+
};
|
|
1605
|
+
if (isTextDevice(device)) return {
|
|
1606
|
+
...base,
|
|
1607
|
+
properties: { text: DEFAULT_TEXT_CONTENT }
|
|
1608
|
+
};
|
|
1609
|
+
return base;
|
|
1610
|
+
}
|
|
1611
|
+
function fallbackBounds(placement) {
|
|
1612
|
+
return {
|
|
1613
|
+
x: placement.x - 12,
|
|
1614
|
+
y: placement.y - 12,
|
|
1615
|
+
width: 24,
|
|
1616
|
+
height: 24
|
|
1617
|
+
};
|
|
1618
|
+
}
|
|
1619
|
+
function pinLabel(body, side, anchor) {
|
|
1620
|
+
const centerX = body.x + body.width / 2;
|
|
1621
|
+
const centerY = body.y + body.height / 2;
|
|
1622
|
+
const clampX = Math.min(Math.max(anchor.x, body.x), body.x + body.width);
|
|
1623
|
+
const clampY = Math.min(Math.max(anchor.y, body.y), body.y + body.height);
|
|
1624
|
+
switch (side) {
|
|
1625
|
+
case "left": return {
|
|
1626
|
+
position: {
|
|
1627
|
+
x: body.x + 5,
|
|
1628
|
+
y: clampY
|
|
1629
|
+
},
|
|
1630
|
+
align: "left",
|
|
1631
|
+
baseline: "middle",
|
|
1632
|
+
rotation: 0
|
|
1633
|
+
};
|
|
1634
|
+
case "right": return {
|
|
1635
|
+
position: {
|
|
1636
|
+
x: body.x + body.width - 5,
|
|
1637
|
+
y: clampY
|
|
1638
|
+
},
|
|
1639
|
+
align: "right",
|
|
1640
|
+
baseline: "middle",
|
|
1641
|
+
rotation: 0
|
|
1642
|
+
};
|
|
1643
|
+
case "top": return {
|
|
1644
|
+
position: {
|
|
1645
|
+
x: clampX,
|
|
1646
|
+
y: body.y + 10
|
|
1647
|
+
},
|
|
1648
|
+
align: "center",
|
|
1649
|
+
baseline: "middle",
|
|
1650
|
+
rotation: -90
|
|
1651
|
+
};
|
|
1652
|
+
case "bottom": return {
|
|
1653
|
+
position: {
|
|
1654
|
+
x: clampX,
|
|
1655
|
+
y: body.y + body.height - 10
|
|
1656
|
+
},
|
|
1657
|
+
align: "center",
|
|
1658
|
+
baseline: "middle",
|
|
1659
|
+
rotation: -90
|
|
1660
|
+
};
|
|
1661
|
+
default: return {
|
|
1662
|
+
position: {
|
|
1663
|
+
x: centerX,
|
|
1664
|
+
y: centerY
|
|
1665
|
+
},
|
|
1666
|
+
align: "center",
|
|
1667
|
+
baseline: "middle",
|
|
1668
|
+
rotation: 0
|
|
1669
|
+
};
|
|
1670
|
+
}
|
|
1671
|
+
}
|
|
1672
|
+
function drawBuiltin(items, input) {
|
|
1673
|
+
const { placement, color, anchorColor, font } = input;
|
|
1674
|
+
const at = (x, y) => symbolPinPosition(placement, {
|
|
1675
|
+
x,
|
|
1676
|
+
y
|
|
1677
|
+
});
|
|
1678
|
+
const push = (x, y) => {
|
|
1679
|
+
const rotated = rotatePoint({
|
|
1680
|
+
x,
|
|
1681
|
+
y
|
|
1682
|
+
}, placement.rotation);
|
|
1683
|
+
items.push({
|
|
1684
|
+
kind: "text",
|
|
1685
|
+
position: {
|
|
1686
|
+
x: placement.x + rotated.x,
|
|
1687
|
+
y: placement.y + rotated.y
|
|
1688
|
+
},
|
|
1689
|
+
text: input.text,
|
|
1690
|
+
color,
|
|
1691
|
+
...outwardLabel(rotated),
|
|
1692
|
+
font
|
|
1693
|
+
});
|
|
1694
|
+
};
|
|
1695
|
+
if (input.kind === "label") {
|
|
1696
|
+
items.push({
|
|
1697
|
+
kind: "line",
|
|
1698
|
+
from: at(-3, 0),
|
|
1699
|
+
to: at(3, 0),
|
|
1700
|
+
color: anchorColor,
|
|
1701
|
+
width: 1.2
|
|
1702
|
+
});
|
|
1703
|
+
items.push({
|
|
1704
|
+
kind: "line",
|
|
1705
|
+
from: at(0, -3),
|
|
1706
|
+
to: at(0, 3),
|
|
1707
|
+
color: anchorColor,
|
|
1708
|
+
width: 1.2
|
|
1709
|
+
});
|
|
1710
|
+
const layout = LABEL_TEXT_LAYOUTS[quarterTurns(placement.rotation)];
|
|
1711
|
+
items.push({
|
|
1712
|
+
kind: "text",
|
|
1713
|
+
position: {
|
|
1714
|
+
x: placement.x + layout.offset.x,
|
|
1715
|
+
y: placement.y + layout.offset.y
|
|
1716
|
+
},
|
|
1717
|
+
text: input.text,
|
|
1718
|
+
color,
|
|
1719
|
+
align: layout.align,
|
|
1720
|
+
baseline: layout.baseline,
|
|
1721
|
+
font
|
|
1722
|
+
});
|
|
1723
|
+
return;
|
|
1724
|
+
}
|
|
1725
|
+
if (input.kind === "power") {
|
|
1726
|
+
items.push({
|
|
1727
|
+
kind: "line",
|
|
1728
|
+
from: at(0, 0),
|
|
1729
|
+
to: at(0, -10),
|
|
1730
|
+
color,
|
|
1731
|
+
width: 1.5
|
|
1732
|
+
});
|
|
1733
|
+
items.push({
|
|
1734
|
+
kind: "line",
|
|
1735
|
+
from: at(-6, -10),
|
|
1736
|
+
to: at(6, -10),
|
|
1737
|
+
color,
|
|
1738
|
+
width: 1.5
|
|
1739
|
+
});
|
|
1740
|
+
push(0, -13);
|
|
1741
|
+
return;
|
|
1742
|
+
}
|
|
1743
|
+
if (input.kind === "ground") {
|
|
1744
|
+
items.push({
|
|
1745
|
+
kind: "line",
|
|
1746
|
+
from: at(0, 0),
|
|
1747
|
+
to: at(0, 6),
|
|
1748
|
+
color,
|
|
1749
|
+
width: 1.5
|
|
1750
|
+
});
|
|
1751
|
+
for (const [offset, width] of [
|
|
1752
|
+
[6, 12],
|
|
1753
|
+
[9, 8],
|
|
1754
|
+
[12, 4]
|
|
1755
|
+
]) items.push({
|
|
1756
|
+
kind: "line",
|
|
1757
|
+
from: at(-width / 2, offset),
|
|
1758
|
+
to: at(width / 2, offset),
|
|
1759
|
+
color,
|
|
1760
|
+
width: 1.5
|
|
1761
|
+
});
|
|
1762
|
+
push(0, 16);
|
|
1763
|
+
}
|
|
1764
|
+
}
|
|
1765
|
+
/** Rotation snapped to the 90° steps builtin glyphs support. */
|
|
1766
|
+
function quarterTurns(rotation) {
|
|
1767
|
+
const normalized = ((rotation % 360 + 360) % 360 + 45) % 360;
|
|
1768
|
+
return Math.floor(normalized / 90) * 90;
|
|
1769
|
+
}
|
|
1770
|
+
/** Clearance from the anchor cross, so the name starts just past its arms. */
|
|
1771
|
+
var LABEL_TEXT_INSET = 4;
|
|
1772
|
+
/**
|
|
1773
|
+
* Where a label's name goes for each rotation: right above the wire for horizontal labels and
|
|
1774
|
+
* just past its tip for vertical ones, clear of the anchor cross.
|
|
1775
|
+
*/
|
|
1776
|
+
var LABEL_TEXT_LAYOUTS = {
|
|
1777
|
+
0: {
|
|
1778
|
+
offset: {
|
|
1779
|
+
x: LABEL_TEXT_INSET,
|
|
1780
|
+
y: -1
|
|
1781
|
+
},
|
|
1782
|
+
align: "left",
|
|
1783
|
+
baseline: "bottom"
|
|
1784
|
+
},
|
|
1785
|
+
90: {
|
|
1786
|
+
offset: {
|
|
1787
|
+
x: LABEL_TEXT_INSET,
|
|
1788
|
+
y: 0
|
|
1789
|
+
},
|
|
1790
|
+
align: "left",
|
|
1791
|
+
baseline: "middle"
|
|
1792
|
+
},
|
|
1793
|
+
180: {
|
|
1794
|
+
offset: {
|
|
1795
|
+
x: -4,
|
|
1796
|
+
y: -1
|
|
1797
|
+
},
|
|
1798
|
+
align: "right",
|
|
1799
|
+
baseline: "bottom"
|
|
1800
|
+
},
|
|
1801
|
+
270: {
|
|
1802
|
+
offset: {
|
|
1803
|
+
x: LABEL_TEXT_INSET,
|
|
1804
|
+
y: 0
|
|
1805
|
+
},
|
|
1806
|
+
align: "left",
|
|
1807
|
+
baseline: "middle"
|
|
1808
|
+
}
|
|
1809
|
+
};
|
|
1810
|
+
/** Horizontal text starting at `offset` (already rotated) and growing away from the origin. */
|
|
1811
|
+
function outwardLabel(offset) {
|
|
1812
|
+
if (Math.abs(offset.x) >= Math.abs(offset.y)) return {
|
|
1813
|
+
align: offset.x >= 0 ? "left" : "right",
|
|
1814
|
+
baseline: "middle",
|
|
1815
|
+
rotation: 0
|
|
1816
|
+
};
|
|
1817
|
+
return {
|
|
1818
|
+
align: "center",
|
|
1819
|
+
baseline: offset.y >= 0 ? "top" : "bottom",
|
|
1820
|
+
rotation: 0
|
|
1821
|
+
};
|
|
1822
|
+
}
|
|
1823
|
+
//#endregion
|
|
1824
|
+
//#region src/editor/snapping/nodes.ts
|
|
1825
|
+
/** World position of a pin / label / power / ground anchor under `point`. */
|
|
1826
|
+
function connectionNodeAt(context, point) {
|
|
1827
|
+
const hit = context.controller.hitTest(point, {
|
|
1828
|
+
tolerance: context.tolerance * 2,
|
|
1829
|
+
includeSymbols: false
|
|
1830
|
+
});
|
|
1831
|
+
if (hit?.kind === "pin") return pinPosition(context, hit);
|
|
1832
|
+
const anchor = anchorSymbolAt(context, point);
|
|
1833
|
+
if (anchor !== void 0) return {
|
|
1834
|
+
x: anchor.x,
|
|
1835
|
+
y: anchor.y
|
|
1836
|
+
};
|
|
1837
|
+
return wireEndpointAt(context, point);
|
|
1838
|
+
}
|
|
1839
|
+
/** Endpoint of an existing wire under `point`; used to close a connection. */
|
|
1840
|
+
function wireEndpointAt(context, point) {
|
|
1841
|
+
for (const wire of context.controller.document.sheet.wires) for (const endpoint of [wire.points[0], wire.points[wire.points.length - 1]]) if (endpoint !== void 0 && Math.abs(endpoint.x - point.x) <= context.tolerance * 2 && Math.abs(endpoint.y - point.y) <= context.tolerance * 2) return {
|
|
1842
|
+
x: endpoint.x,
|
|
1843
|
+
y: endpoint.y
|
|
1844
|
+
};
|
|
1845
|
+
}
|
|
1846
|
+
/** Builtin connection symbol (label / power / ground) anchored under `point`. */
|
|
1847
|
+
function anchorSymbolAt(context, point) {
|
|
1848
|
+
for (const symbol of context.controller.document.sheet.symbols) {
|
|
1849
|
+
const device = symbol.device;
|
|
1850
|
+
if (device !== LABEL_DEVICE && device !== POWER_DEVICE && device !== GROUND_DEVICE) continue;
|
|
1851
|
+
if (Math.abs(symbol.x - point.x) <= context.tolerance && Math.abs(symbol.y - point.y) <= context.tolerance) return symbol;
|
|
1852
|
+
}
|
|
1853
|
+
}
|
|
1854
|
+
function pinPosition(context, hit) {
|
|
1855
|
+
const symbol = context.controller.document.sheet.symbols.find((item) => item.id === hit.symbolId);
|
|
1856
|
+
if (symbol === void 0) return;
|
|
1857
|
+
const reference = parseDeviceReference(symbol.device);
|
|
1858
|
+
const anchor = reference === void 0 ? void 0 : context.devices?.getDeviceSymbol(reference)?.pins[hit.pinId];
|
|
1859
|
+
return anchor === void 0 ? void 0 : symbolPinPosition(symbol, anchor);
|
|
1860
|
+
}
|
|
1861
|
+
//#endregion
|
|
1862
|
+
//#region src/editor/snapping/drag.ts
|
|
1863
|
+
function snapToGrid(point, display) {
|
|
1864
|
+
if (!display.snap || !(display.gridSize > 0)) return point;
|
|
1865
|
+
const size = display.gridSize;
|
|
1866
|
+
return {
|
|
1867
|
+
x: Math.round(point.x / size) * size,
|
|
1868
|
+
y: Math.round(point.y / size) * size
|
|
1869
|
+
};
|
|
1870
|
+
}
|
|
1871
|
+
/**
|
|
1872
|
+
* Offset for dragging something anchored at `anchor` from `from` to `to`: the result is snapped,
|
|
1873
|
+
* not the raw pointer delta, so an off-grid symbol lands on the grid.
|
|
1874
|
+
*
|
|
1875
|
+
* A drag that stays within half a grid cell on one axis does not move that axis at all, so an
|
|
1876
|
+
* anchor that started off-grid keeps its column/row instead of jittering sideways.
|
|
1877
|
+
*/
|
|
1878
|
+
function snapDragOffset(anchor, from, to, display) {
|
|
1879
|
+
const dx = to.x - from.x;
|
|
1880
|
+
const dy = to.y - from.y;
|
|
1881
|
+
if (!display.snap || !(display.gridSize > 0)) return {
|
|
1882
|
+
x: dx,
|
|
1883
|
+
y: dy
|
|
1884
|
+
};
|
|
1885
|
+
const target = snapToGrid({
|
|
1886
|
+
x: anchor.x + dx,
|
|
1887
|
+
y: anchor.y + dy
|
|
1888
|
+
}, display);
|
|
1889
|
+
const lock = display.gridSize / 2;
|
|
1890
|
+
return {
|
|
1891
|
+
x: (Math.abs(dx) < lock ? anchor.x : target.x) - anchor.x,
|
|
1892
|
+
y: (Math.abs(dy) < lock ? anchor.y : target.y) - anchor.y
|
|
1893
|
+
};
|
|
1894
|
+
}
|
|
1895
|
+
//#endregion
|
|
1896
|
+
//#region src/editor/snapping/wire.ts
|
|
1897
|
+
/**
|
|
1898
|
+
* Waypoints needed to reach `to` from `from` with horizontal/vertical segments only.
|
|
1899
|
+
* Returns `[to]` when already aligned, otherwise an elbow plus `to` (long axis first).
|
|
1900
|
+
*/
|
|
1901
|
+
function orthogonalWaypoints(from, to) {
|
|
1902
|
+
if (from.x === to.x || from.y === to.y) return [to];
|
|
1903
|
+
return [Math.abs(to.x - from.x) >= Math.abs(to.y - from.y) ? {
|
|
1904
|
+
x: to.x,
|
|
1905
|
+
y: from.y
|
|
1906
|
+
} : {
|
|
1907
|
+
x: from.x,
|
|
1908
|
+
y: to.y
|
|
1909
|
+
}, to];
|
|
1910
|
+
}
|
|
1911
|
+
/**
|
|
1912
|
+
* Waypoints for one new wire leg. When the target lands on an existing wire the last leg runs
|
|
1913
|
+
* perpendicular to it, so the branch taps into a T instead of doubling back along the host.
|
|
1914
|
+
*/
|
|
1915
|
+
function wireWaypoints(from, to, host) {
|
|
1916
|
+
if (host === void 0 || from.x === to.x || from.y === to.y) return orthogonalWaypoints(from, to);
|
|
1917
|
+
return host === "vertical" ? [{
|
|
1918
|
+
x: from.x,
|
|
1919
|
+
y: to.y
|
|
1920
|
+
}, to] : [{
|
|
1921
|
+
x: to.x,
|
|
1922
|
+
y: from.y
|
|
1923
|
+
}, to];
|
|
1924
|
+
}
|
|
1925
|
+
/** Snaps to a nearby connection node, then to a nearby wire, otherwise to the grid. */
|
|
1926
|
+
function snapForWire(context, point) {
|
|
1927
|
+
return connectionNodeAt(context, point) ?? wireBodyPointAt(context, point) ?? snapToGrid(point, context.controller.display);
|
|
1928
|
+
}
|
|
1929
|
+
/**
|
|
1930
|
+
* Closest point on an existing wire's run when `point` is within the snap radius. Lets the wire
|
|
1931
|
+
* tool land exactly on another wire, which is what turns the run into a T junction.
|
|
1932
|
+
*/
|
|
1933
|
+
function wireBodyPointAt(context, point) {
|
|
1934
|
+
const radius = context.tolerance * 2;
|
|
1935
|
+
let best;
|
|
1936
|
+
for (const wire of context.controller.document.sheet.wires) for (let index = 1; index < wire.points.length; index += 1) {
|
|
1937
|
+
const a = wire.points[index - 1];
|
|
1938
|
+
const b = wire.points[index];
|
|
1939
|
+
if (a === void 0 || b === void 0) continue;
|
|
1940
|
+
const projected = projectOnSegment(point, a, b);
|
|
1941
|
+
const distance = Math.hypot(projected.x - point.x, projected.y - point.y);
|
|
1942
|
+
if (distance > radius || best !== void 0 && distance >= best.distance) continue;
|
|
1943
|
+
best = {
|
|
1944
|
+
point: {
|
|
1945
|
+
x: projected.x,
|
|
1946
|
+
y: projected.y
|
|
1947
|
+
},
|
|
1948
|
+
distance
|
|
1949
|
+
};
|
|
1950
|
+
}
|
|
1951
|
+
return best?.point;
|
|
1952
|
+
}
|
|
1953
|
+
//#endregion
|
|
1954
|
+
//#region src/editor/snapping/label.ts
|
|
1955
|
+
function isPlacementTool(tool) {
|
|
1956
|
+
return tool === "power" || tool === "ground" || tool === "label" || tool === "text";
|
|
1957
|
+
}
|
|
1958
|
+
function toolDevice(tool) {
|
|
1959
|
+
switch (tool) {
|
|
1960
|
+
case "power": return POWER_DEVICE;
|
|
1961
|
+
case "ground": return GROUND_DEVICE;
|
|
1962
|
+
case "label": return LABEL_DEVICE;
|
|
1963
|
+
default: return TEXT_DEVICE;
|
|
1964
|
+
}
|
|
1965
|
+
}
|
|
1966
|
+
/** Smallest unused `N<n>` net name for a freshly placed net label. */
|
|
1967
|
+
function nextNetName(document) {
|
|
1968
|
+
const used = new Set(document.sheet.symbols.map((symbol) => netNameOf(symbol)).filter((name) => name !== void 0));
|
|
1969
|
+
for (let index = 1; index < Number.MAX_SAFE_INTEGER; index += 1) {
|
|
1970
|
+
const candidate = `N${index}`;
|
|
1971
|
+
if (!used.has(candidate)) return candidate;
|
|
1972
|
+
}
|
|
1973
|
+
return "N";
|
|
1974
|
+
}
|
|
1975
|
+
/**
|
|
1976
|
+
* Lead used when a label is placed directly on a pin: labels hang off a short wire in the pin's
|
|
1977
|
+
* outward direction instead of sitting on the pin itself.
|
|
1978
|
+
*/
|
|
1979
|
+
function labelStubAt(context, point) {
|
|
1980
|
+
const hit = context.controller.hitTest(point, {
|
|
1981
|
+
tolerance: context.tolerance * 2,
|
|
1982
|
+
includeSymbols: false
|
|
1983
|
+
});
|
|
1984
|
+
if (hit?.kind !== "pin") return;
|
|
1985
|
+
const pin = pinPosition(context, hit);
|
|
1986
|
+
const direction = pinOutward(context, hit);
|
|
1987
|
+
if (pin === void 0 || direction === void 0) return;
|
|
1988
|
+
const dx = Math.round(direction.x);
|
|
1989
|
+
const dy = Math.round(direction.y);
|
|
1990
|
+
const anchor = {
|
|
1991
|
+
x: pin.x + dx * 20,
|
|
1992
|
+
y: pin.y + dy * 20
|
|
1993
|
+
};
|
|
1994
|
+
return {
|
|
1995
|
+
pin,
|
|
1996
|
+
anchor,
|
|
1997
|
+
rotation: dx > 0 ? 0 : dx < 0 ? 180 : dy > 0 ? 90 : 270,
|
|
1998
|
+
...stubWire(context, pin, anchor)
|
|
1999
|
+
};
|
|
2000
|
+
}
|
|
2001
|
+
/** The lead only has to be drawn where no wire already runs; labels bind to any wire they touch. */
|
|
2002
|
+
function stubWire(context, pin, anchor) {
|
|
2003
|
+
return context.controller.hitTest(anchor, {
|
|
2004
|
+
includeSymbols: false,
|
|
2005
|
+
tolerance: context.tolerance
|
|
2006
|
+
})?.kind === "wire" ? {} : { wire: [pin, anchor] };
|
|
2007
|
+
}
|
|
2008
|
+
/** Unit vector, in world space, pointing away from the symbol body at a pin. */
|
|
2009
|
+
function pinOutward(context, hit) {
|
|
2010
|
+
const symbol = context.controller.document.sheet.symbols.find((item) => item.id === hit.symbolId);
|
|
2011
|
+
const reference = symbol === void 0 ? void 0 : parseDeviceReference(symbol.device);
|
|
2012
|
+
const definition = reference === void 0 ? void 0 : context.devices?.getDeviceSymbol(reference);
|
|
2013
|
+
const anchor = definition?.pins[hit.pinId];
|
|
2014
|
+
if (symbol === void 0 || definition === void 0 || anchor === void 0) return;
|
|
2015
|
+
const side = pinSide(anchor, definition.bounds ?? pinsBounds(definition.pins));
|
|
2016
|
+
const local = side === "left" ? {
|
|
2017
|
+
x: -1,
|
|
2018
|
+
y: 0
|
|
2019
|
+
} : side === "right" ? {
|
|
2020
|
+
x: 1,
|
|
2021
|
+
y: 0
|
|
2022
|
+
} : side === "top" ? {
|
|
2023
|
+
x: 0,
|
|
2024
|
+
y: -1
|
|
2025
|
+
} : side === "bottom" ? {
|
|
2026
|
+
x: 0,
|
|
2027
|
+
y: 1
|
|
2028
|
+
} : void 0;
|
|
2029
|
+
return local === void 0 ? void 0 : rotatePoint(local, symbol.rotation);
|
|
2030
|
+
}
|
|
2031
|
+
/** Extra properties written when a placement inserts a builtin symbol. */
|
|
2032
|
+
function placementProperties(device) {
|
|
2033
|
+
return isTextDevice(device) ? { text: DEFAULT_TEXT_CONTENT } : void 0;
|
|
2034
|
+
}
|
|
2035
|
+
//#endregion
|
|
2036
|
+
//#region src/editor/interactions/placement.ts
|
|
2037
|
+
/** Device the next click inserts: the host's armed device wins over the toolbar tool. */
|
|
2038
|
+
function placementDevice(context) {
|
|
2039
|
+
const armed = context.deps.controller.placingDevice;
|
|
2040
|
+
if (armed !== void 0) return armed;
|
|
2041
|
+
const tool = context.deps.controller.tool;
|
|
2042
|
+
return isPlacementTool(tool) ? toolDevice(tool) : void 0;
|
|
2043
|
+
}
|
|
2044
|
+
/** Lead a label placement adds when the pointer is on a pin. */
|
|
2045
|
+
function labelStub(context, device, point) {
|
|
2046
|
+
return device === LABEL_DEVICE ? labelStubAt(context.pointer.snapContext(), point) : void 0;
|
|
2047
|
+
}
|
|
2048
|
+
/** Ghost rotation: the pin lead picks a readable angle unless the user rotated it. */
|
|
2049
|
+
function placeRotation(context, stub) {
|
|
2050
|
+
return context.state.placementRotation === 0 && stub !== void 0 ? stub.rotation : context.state.placementRotation;
|
|
2051
|
+
}
|
|
2052
|
+
/** Ghost shown while a placement tool or armed device follows the pointer. */
|
|
2053
|
+
function placementPreview(context) {
|
|
2054
|
+
const device = placementDevice(context);
|
|
2055
|
+
const cursor = context.state.cursorPoint;
|
|
2056
|
+
if (cursor === void 0 || device === void 0) return;
|
|
2057
|
+
const stub = labelStub(context, device, cursor);
|
|
2058
|
+
return {
|
|
2059
|
+
device,
|
|
2060
|
+
position: stub?.anchor ?? cursor,
|
|
2061
|
+
rotation: placeRotation(context, stub),
|
|
2062
|
+
...stub?.wire === void 0 ? {} : { wire: stub.wire }
|
|
2063
|
+
};
|
|
2064
|
+
}
|
|
2065
|
+
/** Rotates the ghost while a placement tool is active. */
|
|
2066
|
+
function rotatePlacement(context, degrees) {
|
|
2067
|
+
if (placementDevice(context) === void 0) return false;
|
|
2068
|
+
context.state.placementRotation = ((context.state.placementRotation + degrees) % 360 + 360) % 360;
|
|
2069
|
+
context.deps.onChange();
|
|
2070
|
+
return true;
|
|
2071
|
+
}
|
|
2072
|
+
/** Places the armed device at `page`. */
|
|
2073
|
+
function place(context, page) {
|
|
2074
|
+
const controller = context.deps.controller;
|
|
2075
|
+
const device = placementDevice(context);
|
|
2076
|
+
if (device === void 0 || context.deps.readOnly) return;
|
|
2077
|
+
const role = symbolRole(device);
|
|
2078
|
+
const snapped = role === "component" ? snapToGrid(page, controller.display) : context.pointer.snapWire(page);
|
|
2079
|
+
const stub = labelStub(context, device, snapped);
|
|
2080
|
+
const point = stub?.anchor ?? snapped;
|
|
2081
|
+
const properties = placementProperties(device);
|
|
2082
|
+
const designator = role === "component" ? controller.nextDesignator(device) : void 0;
|
|
2083
|
+
context.deps.run(() => controller.transaction(() => {
|
|
2084
|
+
if (stub?.wire !== void 0) controller.addWire(stub.wire);
|
|
2085
|
+
controller.dispatch({
|
|
2086
|
+
type: "add-symbol",
|
|
2087
|
+
...device === LABEL_DEVICE ? { net: nextNetName(controller.document) } : {},
|
|
2088
|
+
...designator === void 0 ? {} : { designator },
|
|
2089
|
+
device,
|
|
2090
|
+
x: point.x,
|
|
2091
|
+
y: point.y,
|
|
2092
|
+
rotation: placeRotation(context, stub),
|
|
2093
|
+
...properties === void 0 ? {} : { properties }
|
|
2094
|
+
});
|
|
2095
|
+
}));
|
|
2096
|
+
controller.setTool("select");
|
|
2097
|
+
context.state.cursorPoint = void 0;
|
|
2098
|
+
context.state.placementRotation = 0;
|
|
2099
|
+
}
|
|
2100
|
+
/** Snapping applied to the placement ghost so it matches where the click will land. */
|
|
2101
|
+
function snapPlacementPoint(context, page, device) {
|
|
2102
|
+
return symbolRole(device) === "component" ? snapToGrid(page, context.deps.controller.display) : context.pointer.snapWire(page);
|
|
2103
|
+
}
|
|
2104
|
+
//#endregion
|
|
2105
|
+
//#region src/editor/selection-box.ts
|
|
2106
|
+
function rectOf(from, to) {
|
|
2107
|
+
return {
|
|
2108
|
+
x: Math.min(from.x, to.x),
|
|
2109
|
+
y: Math.min(from.y, to.y),
|
|
2110
|
+
width: Math.abs(to.x - from.x),
|
|
2111
|
+
height: Math.abs(to.y - from.y)
|
|
2112
|
+
};
|
|
2113
|
+
}
|
|
2114
|
+
function pointInRect(point, rect) {
|
|
2115
|
+
return point.x >= rect.x && point.x <= rect.x + rect.width && point.y >= rect.y && point.y <= rect.y + rect.height;
|
|
2116
|
+
}
|
|
2117
|
+
function rectsIntersect(left, right) {
|
|
2118
|
+
return left.x + left.width >= right.x && left.x <= right.x + right.width && left.y + left.height >= right.y && left.y <= right.y + right.height;
|
|
2119
|
+
}
|
|
2120
|
+
/** Everything touched by the rubber-band rectangle. */
|
|
2121
|
+
function marqueeSelection(document, rect, devices) {
|
|
2122
|
+
if (rect.width === 0 && rect.height === 0) return [];
|
|
2123
|
+
const result = [];
|
|
2124
|
+
for (const symbol of document.sheet.symbols) if (rectsIntersect(symbolRect(symbol, devices), rect)) result.push({
|
|
2125
|
+
kind: "symbol",
|
|
2126
|
+
id: symbol.id
|
|
2127
|
+
});
|
|
2128
|
+
for (const wire of document.sheet.wires) if (wire.points.some((point) => pointInRect(point, rect))) result.push({
|
|
2129
|
+
kind: "wire",
|
|
2130
|
+
id: wire.id
|
|
2131
|
+
});
|
|
2132
|
+
for (const junction of document.sheet.junctions) if (pointInRect({
|
|
2133
|
+
x: junction.x,
|
|
2134
|
+
y: junction.y
|
|
2135
|
+
}, rect)) result.push({
|
|
2136
|
+
kind: "junction",
|
|
2137
|
+
id: junction.id
|
|
2138
|
+
});
|
|
2139
|
+
return result;
|
|
2140
|
+
}
|
|
2141
|
+
function symbolRect(symbol, devices) {
|
|
2142
|
+
const reference = parseDeviceReference(symbol.device);
|
|
2143
|
+
const definition = reference === void 0 ? void 0 : devices?.getDeviceSymbol(reference);
|
|
2144
|
+
return (definition === void 0 ? void 0 : symbolBounds(symbol, definition.bounds, definition.pins)) ?? {
|
|
2145
|
+
x: symbol.x,
|
|
2146
|
+
y: symbol.y,
|
|
2147
|
+
width: 0,
|
|
2148
|
+
height: 0
|
|
2149
|
+
};
|
|
2150
|
+
}
|
|
2151
|
+
//#endregion
|
|
2152
|
+
//#region src/editor/interactions/selection.ts
|
|
2153
|
+
/** Selection under the pointer: vertices win over wire bodies, then pins/anchors/symbols. */
|
|
2154
|
+
function selectionAt(context, page) {
|
|
2155
|
+
const vertex = vertexAt(context, page);
|
|
2156
|
+
if (vertex !== void 0) return vertex;
|
|
2157
|
+
const hit = context.deps.controller.hitTest(page, { tolerance: context.pointer.tolerance() });
|
|
2158
|
+
return hit === void 0 ? void 0 : selectionFromHit(hit);
|
|
2159
|
+
}
|
|
2160
|
+
function vertexAt(context, page) {
|
|
2161
|
+
const tolerance = context.pointer.tolerance();
|
|
2162
|
+
for (const wire of context.deps.controller.document.sheet.wires) for (let index = 0; index < wire.points.length; index += 1) {
|
|
2163
|
+
const point = wire.points[index];
|
|
2164
|
+
if (point === void 0) continue;
|
|
2165
|
+
if (Math.abs(point.x - page.x) <= tolerance && Math.abs(point.y - page.y) <= tolerance) return {
|
|
2166
|
+
kind: "vertex",
|
|
2167
|
+
wireId: wire.id,
|
|
2168
|
+
index
|
|
2169
|
+
};
|
|
2170
|
+
}
|
|
2171
|
+
}
|
|
2172
|
+
/** True when the pointer is over a builtin annotation (label / power / ground / text). */
|
|
2173
|
+
function annotationSymbolAt(context, page) {
|
|
2174
|
+
const hit = context.deps.controller.hitTest(page, { tolerance: context.pointer.tolerance() });
|
|
2175
|
+
if (hit?.kind !== "symbol") return false;
|
|
2176
|
+
const symbol = context.deps.controller.document.sheet.symbols.find((item) => item.id === hit.id);
|
|
2177
|
+
return symbol !== void 0 && symbolRole(symbol.device) !== "component";
|
|
2178
|
+
}
|
|
2179
|
+
/** Applies a click selection, honouring additive (shift) clicks. */
|
|
2180
|
+
function selectItem(context, selection, additive) {
|
|
2181
|
+
const controller = context.deps.controller;
|
|
2182
|
+
if (selection === void 0) {
|
|
2183
|
+
controller.setSelection(additive ? controller.selection : []);
|
|
2184
|
+
return;
|
|
2185
|
+
}
|
|
2186
|
+
const current = controller.selection;
|
|
2187
|
+
const exists = current.some((item) => sameSelectionItem(item, selection));
|
|
2188
|
+
if (!additive) {
|
|
2189
|
+
controller.setSelection([selection]);
|
|
2190
|
+
return;
|
|
2191
|
+
}
|
|
2192
|
+
controller.setSelection(exists ? current.filter((item) => !sameSelectionItem(item, selection)) : [...current, selection]);
|
|
2193
|
+
}
|
|
2194
|
+
/** Commits a rubber-band selection. */
|
|
2195
|
+
function finishMarquee(context, origin, current) {
|
|
2196
|
+
context.deps.controller.setSelection(marqueeSelection(context.deps.controller.document, rectOf(origin, current), context.deps.devices));
|
|
2197
|
+
}
|
|
2198
|
+
function selectionFromHit(hit) {
|
|
2199
|
+
switch (hit.kind) {
|
|
2200
|
+
case "symbol": return {
|
|
2201
|
+
kind: "symbol",
|
|
2202
|
+
id: hit.id
|
|
2203
|
+
};
|
|
2204
|
+
case "reference": return {
|
|
2205
|
+
kind: "reference",
|
|
2206
|
+
symbolId: hit.symbolId
|
|
2207
|
+
};
|
|
2208
|
+
case "wire": return {
|
|
2209
|
+
kind: "wire",
|
|
2210
|
+
id: hit.id
|
|
2211
|
+
};
|
|
2212
|
+
case "junction": return {
|
|
2213
|
+
kind: "junction",
|
|
2214
|
+
id: hit.id
|
|
2215
|
+
};
|
|
2216
|
+
case "pin": return {
|
|
2217
|
+
kind: "symbol",
|
|
2218
|
+
id: hit.symbolId
|
|
2219
|
+
};
|
|
2220
|
+
default: return;
|
|
2221
|
+
}
|
|
2222
|
+
}
|
|
2223
|
+
function sameHit(left, right) {
|
|
2224
|
+
if (left === void 0 || right === void 0) return left === right;
|
|
2225
|
+
return JSON.stringify(left) === JSON.stringify(right);
|
|
2226
|
+
}
|
|
2227
|
+
//#endregion
|
|
2228
|
+
//#region src/editor/interactions/wire.ts
|
|
2229
|
+
/** Adds the next waypoint of the wire being drawn, closing the run on a connection. */
|
|
2230
|
+
function addWirePoint(context, page) {
|
|
2231
|
+
const state = context.state;
|
|
2232
|
+
const point = context.pointer.snapWire(page);
|
|
2233
|
+
const previous = state.pendingWire[state.pendingWire.length - 1];
|
|
2234
|
+
state.pendingWire = [...state.pendingWire, ...previous === void 0 ? [point] : wireWaypoints(previous, point, context.pointer.hostAxisAt(point))];
|
|
2235
|
+
state.cursorPoint = point;
|
|
2236
|
+
const node = connectionNodeAt(context.pointer.snapContext(), point);
|
|
2237
|
+
const host = context.deps.controller.hitTest(point, {
|
|
2238
|
+
includeSymbols: false,
|
|
2239
|
+
tolerance: context.pointer.tolerance()
|
|
2240
|
+
});
|
|
2241
|
+
if ((node !== void 0 || host?.kind === "wire") && state.pendingWire.length >= 2) {
|
|
2242
|
+
finishWire(context);
|
|
2243
|
+
context.deps.controller.setTool("select");
|
|
2244
|
+
return;
|
|
2245
|
+
}
|
|
2246
|
+
context.deps.onChange();
|
|
2247
|
+
}
|
|
2248
|
+
/** Commits the pending wire as one edit; nothing is written in read-only mode. */
|
|
2249
|
+
function finishWire(context) {
|
|
2250
|
+
const points = context.state.pendingWire;
|
|
2251
|
+
context.state.pendingWire = [];
|
|
2252
|
+
context.state.cursorPoint = void 0;
|
|
2253
|
+
context.state.hover = void 0;
|
|
2254
|
+
if (points.length < 2 || context.deps.readOnly) {
|
|
2255
|
+
context.deps.onChange();
|
|
2256
|
+
return;
|
|
2257
|
+
}
|
|
2258
|
+
context.deps.run(() => context.deps.controller.addWire(points));
|
|
2259
|
+
}
|
|
2260
|
+
/** Pending wire plus the live cursor leg, for the dashed preview. */
|
|
2261
|
+
function pendingWireWithCursor(context) {
|
|
2262
|
+
const state = context.state;
|
|
2263
|
+
const cursor = state.cursorPoint;
|
|
2264
|
+
if (cursor === void 0) return state.pendingWire;
|
|
2265
|
+
const previous = state.pendingWire[state.pendingWire.length - 1];
|
|
2266
|
+
if (previous === void 0) return [cursor];
|
|
2267
|
+
return [...state.pendingWire, ...wireWaypoints(previous, cursor, context.pointer.hostAxisAt(cursor))];
|
|
2268
|
+
}
|
|
2269
|
+
/** Clicking a pin or wire end in select mode starts a wire from there. */
|
|
2270
|
+
function startWireAtNode(context, page) {
|
|
2271
|
+
const controller = context.deps.controller;
|
|
2272
|
+
if (controller.tool !== "select" || context.deps.readOnly) return false;
|
|
2273
|
+
if (annotationSymbolAt(context, page)) return false;
|
|
2274
|
+
const node = connectionNodeAt(context.pointer.snapContext(), page);
|
|
2275
|
+
if (node === void 0) return false;
|
|
2276
|
+
controller.setTool("wire");
|
|
2277
|
+
context.state.pendingWire = [node];
|
|
2278
|
+
context.state.cursorPoint = node;
|
|
2279
|
+
context.deps.onChange();
|
|
2280
|
+
return true;
|
|
2281
|
+
}
|
|
2282
|
+
//#endregion
|
|
2283
|
+
//#region src/editor/interactions/keyboard.ts
|
|
2284
|
+
/**
|
|
2285
|
+
* Cancels whatever interaction is pending: placement, the wire being drawn, a drag or the active
|
|
2286
|
+
* tool, and clears the selection. Returns whether there was anything to cancel.
|
|
2287
|
+
*/
|
|
2288
|
+
function cancelInteraction(context) {
|
|
2289
|
+
const controller = context.deps.controller;
|
|
2290
|
+
if (!(controller.placingDevice !== void 0 || controller.tool !== "select" || controller.selection.length > 0 || context.state.pendingWire.length > 0 || context.state.drag !== void 0)) return false;
|
|
2291
|
+
if (controller.placingDevice !== void 0) controller.cancelPlacement();
|
|
2292
|
+
context.state.reset();
|
|
2293
|
+
controller.setSelection([]);
|
|
2294
|
+
controller.setTool("select");
|
|
2295
|
+
context.deps.onChange();
|
|
2296
|
+
return true;
|
|
2297
|
+
}
|
|
2298
|
+
/** Keyboard shortcuts; the canvas keeps focus, so the root element owns the listener. */
|
|
2299
|
+
function handleKeyDown(context, event) {
|
|
2300
|
+
const controller = context.deps.controller;
|
|
2301
|
+
const readOnly = context.deps.readOnly;
|
|
2302
|
+
if (event.key === " ") {
|
|
2303
|
+
event.preventDefault();
|
|
2304
|
+
if (rotatePlacement(context, 90)) return;
|
|
2305
|
+
if (!readOnly) context.deps.run(() => controller.rotateSelection(90));
|
|
2306
|
+
return;
|
|
2307
|
+
}
|
|
2308
|
+
if (event.key === "Escape") {
|
|
2309
|
+
cancelInteraction(context);
|
|
2310
|
+
return;
|
|
2311
|
+
}
|
|
2312
|
+
if (event.key === "Enter") {
|
|
2313
|
+
if (controller.tool === "wire") finishWire(context);
|
|
2314
|
+
return;
|
|
2315
|
+
}
|
|
2316
|
+
if (event.key === "Backspace" && controller.tool === "wire" && context.state.pendingWire.length > 0) {
|
|
2317
|
+
event.preventDefault();
|
|
2318
|
+
context.state.pendingWire = context.state.pendingWire.slice(0, -1);
|
|
2319
|
+
context.deps.onChange();
|
|
2320
|
+
return;
|
|
2321
|
+
}
|
|
2322
|
+
if (event.key === "Delete" || event.key === "Backspace") {
|
|
2323
|
+
event.preventDefault();
|
|
2324
|
+
if (!readOnly) context.deps.run(() => controller.deleteSelection());
|
|
2325
|
+
return;
|
|
2326
|
+
}
|
|
2327
|
+
if (event.key === "r" || event.key === "R") {
|
|
2328
|
+
if (!readOnly) context.deps.run(() => controller.rotateSelection(90));
|
|
2329
|
+
return;
|
|
2330
|
+
}
|
|
2331
|
+
if (event.key === "f" || event.key === "F") {
|
|
2332
|
+
context.deps.onFit();
|
|
2333
|
+
return;
|
|
2334
|
+
}
|
|
2335
|
+
if ((event.ctrlKey || event.metaKey) && (event.key === "z" || event.key === "Z")) {
|
|
2336
|
+
event.preventDefault();
|
|
2337
|
+
if (readOnly) return;
|
|
2338
|
+
if (event.shiftKey) controller.redo();
|
|
2339
|
+
else controller.undo();
|
|
2340
|
+
return;
|
|
2341
|
+
}
|
|
2342
|
+
if ((event.ctrlKey || event.metaKey) && (event.key === "y" || event.key === "Y")) {
|
|
2343
|
+
event.preventDefault();
|
|
2344
|
+
if (readOnly) return;
|
|
2345
|
+
controller.redo();
|
|
2346
|
+
return;
|
|
2347
|
+
}
|
|
2348
|
+
if (readOnly) return;
|
|
2349
|
+
const step = controller.display.gridSize > 0 ? controller.display.gridSize : 1;
|
|
2350
|
+
const distance = event.shiftKey ? step * 10 : step;
|
|
2351
|
+
const delta = {
|
|
2352
|
+
ArrowLeft: {
|
|
2353
|
+
x: -distance,
|
|
2354
|
+
y: 0
|
|
2355
|
+
},
|
|
2356
|
+
ArrowRight: {
|
|
2357
|
+
x: distance,
|
|
2358
|
+
y: 0
|
|
2359
|
+
},
|
|
2360
|
+
ArrowUp: {
|
|
2361
|
+
x: 0,
|
|
2362
|
+
y: -distance
|
|
2363
|
+
},
|
|
2364
|
+
ArrowDown: {
|
|
2365
|
+
x: 0,
|
|
2366
|
+
y: distance
|
|
2367
|
+
}
|
|
2368
|
+
}[event.key];
|
|
2369
|
+
if (delta !== void 0) {
|
|
2370
|
+
event.preventDefault();
|
|
2371
|
+
context.deps.run(() => controller.moveSelectionBy(delta.x, delta.y));
|
|
2372
|
+
}
|
|
2373
|
+
}
|
|
2374
|
+
//#endregion
|
|
2375
|
+
//#region src/editor/interactions/move.ts
|
|
2376
|
+
/** Starts dragging a symbol; the grid is applied where it lands, not to the pointer delta. */
|
|
2377
|
+
function beginMoveDrag(context, page, symbolId) {
|
|
2378
|
+
const symbol = context.deps.controller.document.sheet.symbols.find((item) => item.id === symbolId);
|
|
2379
|
+
context.state.drag = {
|
|
2380
|
+
mode: "move",
|
|
2381
|
+
origin: page,
|
|
2382
|
+
anchor: symbol === void 0 ? page : {
|
|
2383
|
+
x: symbol.x,
|
|
2384
|
+
y: symbol.y
|
|
2385
|
+
},
|
|
2386
|
+
offset: {
|
|
2387
|
+
x: 0,
|
|
2388
|
+
y: 0
|
|
2389
|
+
}
|
|
2390
|
+
};
|
|
2391
|
+
return true;
|
|
2392
|
+
}
|
|
2393
|
+
function beginReferenceDrag(context, page, symbolId) {
|
|
2394
|
+
const symbol = context.deps.controller.document.sheet.symbols.find((item) => item.id === symbolId);
|
|
2395
|
+
if (symbol === void 0) return false;
|
|
2396
|
+
const start = referenceWorldPosition(context, symbol);
|
|
2397
|
+
context.state.drag = {
|
|
2398
|
+
mode: "reference",
|
|
2399
|
+
symbolId: symbol.id,
|
|
2400
|
+
start,
|
|
2401
|
+
origin: page,
|
|
2402
|
+
position: start
|
|
2403
|
+
};
|
|
2404
|
+
return true;
|
|
2405
|
+
}
|
|
2406
|
+
/** Updates an in-flight move drag; returns `true` when this drag is the active one. */
|
|
2407
|
+
function updateMoveDrag(context, page) {
|
|
2408
|
+
const drag = context.state.drag;
|
|
2409
|
+
if (drag?.mode !== "move") return false;
|
|
2410
|
+
const next = snapDragOffset(drag.anchor, drag.origin, page, context.deps.controller.display);
|
|
2411
|
+
if (next.x !== drag.offset.x || next.y !== drag.offset.y) {
|
|
2412
|
+
context.state.drag = {
|
|
2413
|
+
...drag,
|
|
2414
|
+
offset: next
|
|
2415
|
+
};
|
|
2416
|
+
context.deps.onChange();
|
|
2417
|
+
}
|
|
2418
|
+
return true;
|
|
2419
|
+
}
|
|
2420
|
+
function updateReferenceDrag(context, page) {
|
|
2421
|
+
const drag = context.state.drag;
|
|
2422
|
+
if (drag?.mode !== "reference") return false;
|
|
2423
|
+
context.state.drag = {
|
|
2424
|
+
...drag,
|
|
2425
|
+
position: {
|
|
2426
|
+
x: drag.start.x + (page.x - drag.origin.x),
|
|
2427
|
+
y: drag.start.y + (page.y - drag.origin.y)
|
|
2428
|
+
}
|
|
2429
|
+
};
|
|
2430
|
+
context.deps.onChange();
|
|
2431
|
+
return true;
|
|
2432
|
+
}
|
|
2433
|
+
/** Commits a finished move drag; returns whether an edit was recorded. */
|
|
2434
|
+
function finishMoveDrag(context, drag) {
|
|
2435
|
+
if (drag.offset.x === 0 && drag.offset.y === 0) return false;
|
|
2436
|
+
const { offset } = drag;
|
|
2437
|
+
context.deps.run(() => context.deps.controller.moveSelectionBy(offset.x, offset.y));
|
|
2438
|
+
return true;
|
|
2439
|
+
}
|
|
2440
|
+
function finishReferenceDrag(context, drag) {
|
|
2441
|
+
const dx = drag.position.x - drag.start.x;
|
|
2442
|
+
const dy = drag.position.y - drag.start.y;
|
|
2443
|
+
if (dx === 0 && dy === 0) return false;
|
|
2444
|
+
context.deps.run(() => context.deps.controller.moveReferenceBy(drag.symbolId, dx, dy));
|
|
2445
|
+
return true;
|
|
2446
|
+
}
|
|
2447
|
+
/** World position of the reference designator of `symbol`, used as the drag anchor. */
|
|
2448
|
+
function referenceWorldPosition(context, symbol) {
|
|
2449
|
+
const reference = parseDeviceReference(symbol.device);
|
|
2450
|
+
const definition = reference === void 0 ? void 0 : context.deps.devices?.getDeviceSymbol(reference);
|
|
2451
|
+
return referencePosition(symbol, definition?.bounds, symbol.reference);
|
|
2452
|
+
}
|
|
2453
|
+
//#endregion
|
|
2454
|
+
//#region src/editor/interactions/pointer.ts
|
|
2455
|
+
/** Screen-space geometry of the canvas: event → page coordinates, hit tolerance and snapping. */
|
|
2456
|
+
var Pointer = class {
|
|
2457
|
+
#deps;
|
|
2458
|
+
constructor(deps) {
|
|
2459
|
+
this.#deps = deps;
|
|
2460
|
+
}
|
|
2461
|
+
viewport() {
|
|
2462
|
+
return this.#deps.viewport.state;
|
|
2463
|
+
}
|
|
2464
|
+
screen(event) {
|
|
2465
|
+
const rect = this.#deps.canvas.getBoundingClientRect();
|
|
2466
|
+
return {
|
|
2467
|
+
x: event.clientX - rect.left,
|
|
2468
|
+
y: event.clientY - rect.top
|
|
2469
|
+
};
|
|
2470
|
+
}
|
|
2471
|
+
page(event) {
|
|
2472
|
+
return screenToPage(this.viewport(), this.screen(event));
|
|
2473
|
+
}
|
|
2474
|
+
/** Hit tolerance in page units, so it stays constant on screen while zooming. */
|
|
2475
|
+
tolerance() {
|
|
2476
|
+
return 6 / this.viewport().scale;
|
|
2477
|
+
}
|
|
2478
|
+
snapContext() {
|
|
2479
|
+
return {
|
|
2480
|
+
controller: this.#deps.controller,
|
|
2481
|
+
devices: this.#deps.devices,
|
|
2482
|
+
tolerance: this.tolerance()
|
|
2483
|
+
};
|
|
2484
|
+
}
|
|
2485
|
+
snapWire(point) {
|
|
2486
|
+
return snapForWire(this.snapContext(), point);
|
|
2487
|
+
}
|
|
2488
|
+
/** Orientation of the existing wire under `point`, so a branch can approach it perpendicularly. */
|
|
2489
|
+
hostAxisAt(point) {
|
|
2490
|
+
const hit = this.#deps.controller.hitTest(point, {
|
|
2491
|
+
includeSymbols: false,
|
|
2492
|
+
tolerance: this.tolerance()
|
|
2493
|
+
});
|
|
2494
|
+
if (hit?.kind !== "wire") return;
|
|
2495
|
+
const wire = this.#deps.controller.document.sheet.wires.find((item) => item.id === hit.id);
|
|
2496
|
+
const a = wire?.points[hit.segment];
|
|
2497
|
+
const b = wire?.points[hit.segment + 1];
|
|
2498
|
+
if (a === void 0 || b === void 0) return;
|
|
2499
|
+
return a.y === b.y ? "horizontal" : a.x === b.x ? "vertical" : void 0;
|
|
2500
|
+
}
|
|
2501
|
+
};
|
|
2502
|
+
//#endregion
|
|
2503
|
+
//#region src/editor/interactions/segment.ts
|
|
2504
|
+
/** Starts dragging a wire segment; the moved run is snapped so it lands on grid lines. */
|
|
2505
|
+
function beginSegmentDrag(context, page, wireId) {
|
|
2506
|
+
const hit = context.deps.controller.hitTest(page, {
|
|
2507
|
+
includeSymbols: false,
|
|
2508
|
+
tolerance: context.pointer.tolerance()
|
|
2509
|
+
});
|
|
2510
|
+
if (hit?.kind !== "wire" || hit.id !== wireId) return false;
|
|
2511
|
+
context.state.drag = {
|
|
2512
|
+
mode: "segment",
|
|
2513
|
+
wireId: hit.id,
|
|
2514
|
+
segment: hit.segment,
|
|
2515
|
+
origin: page,
|
|
2516
|
+
delta: {
|
|
2517
|
+
x: 0,
|
|
2518
|
+
y: 0
|
|
2519
|
+
}
|
|
2520
|
+
};
|
|
2521
|
+
return true;
|
|
2522
|
+
}
|
|
2523
|
+
/** Updates an in-flight segment drag; returns `true` when this drag is the active one. */
|
|
2524
|
+
function updateSegmentDrag(context, page) {
|
|
2525
|
+
const drag = context.state.drag;
|
|
2526
|
+
if (drag?.mode !== "segment") return false;
|
|
2527
|
+
const wire = context.deps.controller.document.sheet.wires.find((item) => item.id === drag.wireId);
|
|
2528
|
+
const start = wire?.points[drag.segment];
|
|
2529
|
+
const end = wire?.points[drag.segment + 1];
|
|
2530
|
+
if (wire === void 0 || start === void 0 || end === void 0) return true;
|
|
2531
|
+
const horizontal = start.y === end.y;
|
|
2532
|
+
const target = snapToGrid(horizontal ? {
|
|
2533
|
+
x: start.x,
|
|
2534
|
+
y: start.y + (page.y - drag.origin.y)
|
|
2535
|
+
} : {
|
|
2536
|
+
x: start.x + (page.x - drag.origin.x),
|
|
2537
|
+
y: start.y
|
|
2538
|
+
}, context.deps.controller.display);
|
|
2539
|
+
const candidate = horizontal ? {
|
|
2540
|
+
x: 0,
|
|
2541
|
+
y: target.y - start.y
|
|
2542
|
+
} : {
|
|
2543
|
+
x: target.x - start.x,
|
|
2544
|
+
y: 0
|
|
2545
|
+
};
|
|
2546
|
+
if (candidate.x !== drag.delta.x || candidate.y !== drag.delta.y) {
|
|
2547
|
+
context.state.drag = {
|
|
2548
|
+
...drag,
|
|
2549
|
+
delta: candidate
|
|
2550
|
+
};
|
|
2551
|
+
context.deps.onChange();
|
|
2552
|
+
}
|
|
2553
|
+
return true;
|
|
2554
|
+
}
|
|
2555
|
+
/** Commits a finished segment drag; returns whether an edit was recorded. */
|
|
2556
|
+
function finishSegmentDrag(context, drag) {
|
|
2557
|
+
if (drag.delta.x === 0 && drag.delta.y === 0) return false;
|
|
2558
|
+
context.deps.run(() => context.deps.controller.moveWireSegment(drag.wireId, drag.segment, drag.delta));
|
|
2559
|
+
return true;
|
|
2560
|
+
}
|
|
2561
|
+
/** Wire preview for the renderer, plus the orientation helper used by the wire tool. */
|
|
2562
|
+
function wirePreview(context) {
|
|
2563
|
+
const drag = context.state.drag;
|
|
2564
|
+
if (drag?.mode !== "segment") return;
|
|
2565
|
+
const points = context.deps.controller.previewWireSegment(drag.wireId, drag.segment, drag.delta);
|
|
2566
|
+
return points === void 0 ? void 0 : {
|
|
2567
|
+
wireId: drag.wireId,
|
|
2568
|
+
points
|
|
2569
|
+
};
|
|
2570
|
+
}
|
|
2571
|
+
//#endregion
|
|
2572
|
+
//#region src/editor/interactions/state.ts
|
|
2573
|
+
/**
|
|
2574
|
+
* Transient interaction state owned by the canvas. Nothing here belongs in the document, so the
|
|
2575
|
+
* controller never sees hovers, in-progress wires or previews.
|
|
2576
|
+
*/
|
|
2577
|
+
var InteractionState = class {
|
|
2578
|
+
hover;
|
|
2579
|
+
drag;
|
|
2580
|
+
pendingWire = [];
|
|
2581
|
+
cursorPoint;
|
|
2582
|
+
placementRotation = 0;
|
|
2583
|
+
get marquee() {
|
|
2584
|
+
return this.drag?.mode === "marquee" ? rectOf(this.drag.origin, this.drag.current) : void 0;
|
|
2585
|
+
}
|
|
2586
|
+
/** Live offset of the symbols being dragged. */
|
|
2587
|
+
get dragOffset() {
|
|
2588
|
+
if (this.drag?.mode !== "move") return;
|
|
2589
|
+
const { offset } = this.drag;
|
|
2590
|
+
return offset.x === 0 && offset.y === 0 ? void 0 : offset;
|
|
2591
|
+
}
|
|
2592
|
+
/** Reference designator being dragged, in world coordinates. */
|
|
2593
|
+
get referencePreview() {
|
|
2594
|
+
return this.drag?.mode === "reference" ? {
|
|
2595
|
+
symbolId: this.drag.symbolId,
|
|
2596
|
+
position: this.drag.position
|
|
2597
|
+
} : void 0;
|
|
2598
|
+
}
|
|
2599
|
+
/** Drops the interaction in progress; called when the tool changes or an edit is cancelled. */
|
|
2600
|
+
reset() {
|
|
2601
|
+
this.pendingWire = [];
|
|
2602
|
+
this.cursorPoint = void 0;
|
|
2603
|
+
this.placementRotation = 0;
|
|
2604
|
+
this.drag = void 0;
|
|
2605
|
+
}
|
|
2606
|
+
};
|
|
2607
|
+
//#endregion
|
|
2608
|
+
//#region src/editor/interactions/input.ts
|
|
2609
|
+
/**
|
|
2610
|
+
* Pointer and keyboard state machine: panning, rubber-band selection, wire drawing, placement
|
|
2611
|
+
* previews and shortcuts. It owns the transient interaction state and delegates to the modules
|
|
2612
|
+
* under `interactions/`, which never touch the DOM directly.
|
|
2613
|
+
*/
|
|
2614
|
+
var EditorInput = class {
|
|
2615
|
+
#context;
|
|
2616
|
+
#detached = false;
|
|
2617
|
+
constructor(deps) {
|
|
2618
|
+
this.#context = {
|
|
2619
|
+
deps,
|
|
2620
|
+
state: new InteractionState(),
|
|
2621
|
+
pointer: new Pointer({
|
|
2622
|
+
controller: deps.controller,
|
|
2623
|
+
devices: deps.devices,
|
|
2624
|
+
viewport: deps.viewport,
|
|
2625
|
+
canvas: deps.canvas
|
|
2626
|
+
})
|
|
2627
|
+
};
|
|
2628
|
+
}
|
|
2629
|
+
get hover() {
|
|
2630
|
+
return this.#context.state.hover;
|
|
2631
|
+
}
|
|
2632
|
+
get pendingWire() {
|
|
2633
|
+
return this.#context.state.pendingWire;
|
|
2634
|
+
}
|
|
2635
|
+
get cursorPoint() {
|
|
2636
|
+
return this.#context.state.cursorPoint;
|
|
2637
|
+
}
|
|
2638
|
+
get marquee() {
|
|
2639
|
+
return this.#context.state.marquee;
|
|
2640
|
+
}
|
|
2641
|
+
/** Live offset of the symbols being dragged. */
|
|
2642
|
+
get dragOffset() {
|
|
2643
|
+
return this.#context.state.dragOffset;
|
|
2644
|
+
}
|
|
2645
|
+
/** Wire being reshaped by a segment drag, so the renderer can show it before the commit. */
|
|
2646
|
+
get wirePreview() {
|
|
2647
|
+
return wirePreview(this.#context);
|
|
2648
|
+
}
|
|
2649
|
+
/** Reference designator being dragged, in world coordinates. */
|
|
2650
|
+
get referencePreview() {
|
|
2651
|
+
return this.#context.state.referencePreview;
|
|
2652
|
+
}
|
|
2653
|
+
/** Placement ghost for the current tool or armed device, if the pointer is inside the canvas. */
|
|
2654
|
+
get placementPreview() {
|
|
2655
|
+
return placementPreview(this.#context);
|
|
2656
|
+
}
|
|
2657
|
+
get pendingWireWithCursor() {
|
|
2658
|
+
return pendingWireWithCursor(this.#context);
|
|
2659
|
+
}
|
|
2660
|
+
/** Rotates the ghost while a placement tool is active. */
|
|
2661
|
+
rotatePlacement(degrees) {
|
|
2662
|
+
return rotatePlacement(this.#context, degrees);
|
|
2663
|
+
}
|
|
2664
|
+
/** Drops the in-progress wire; called when the tool changes. */
|
|
2665
|
+
resetTransient() {
|
|
2666
|
+
this.#context.state.reset();
|
|
2667
|
+
}
|
|
2668
|
+
attach() {
|
|
2669
|
+
const { canvas, root } = this.#context.deps;
|
|
2670
|
+
canvas.addEventListener("pointerdown", this.#onPointerDown);
|
|
2671
|
+
canvas.addEventListener("pointermove", this.#onPointerMove);
|
|
2672
|
+
canvas.addEventListener("pointerup", this.#onPointerUp);
|
|
2673
|
+
canvas.addEventListener("pointercancel", this.#onPointerUp);
|
|
2674
|
+
canvas.addEventListener("pointerleave", this.#onPointerLeave);
|
|
2675
|
+
canvas.addEventListener("wheel", this.#onWheel, { passive: false });
|
|
2676
|
+
canvas.addEventListener("dblclick", this.#onDoubleClick);
|
|
2677
|
+
canvas.addEventListener("contextmenu", this.#onContextMenu);
|
|
2678
|
+
root.addEventListener("keydown", this.#onKeyDown);
|
|
2679
|
+
root.addEventListener("keyup", this.#onKeyUp);
|
|
2680
|
+
root.ownerDocument.addEventListener("keydown", this.#onDocumentKeyDown);
|
|
2681
|
+
}
|
|
2682
|
+
detach() {
|
|
2683
|
+
if (this.#detached) return;
|
|
2684
|
+
this.#detached = true;
|
|
2685
|
+
const { canvas, root } = this.#context.deps;
|
|
2686
|
+
canvas.removeEventListener("pointerdown", this.#onPointerDown);
|
|
2687
|
+
canvas.removeEventListener("pointermove", this.#onPointerMove);
|
|
2688
|
+
canvas.removeEventListener("pointerup", this.#onPointerUp);
|
|
2689
|
+
canvas.removeEventListener("pointercancel", this.#onPointerUp);
|
|
2690
|
+
canvas.removeEventListener("pointerleave", this.#onPointerLeave);
|
|
2691
|
+
canvas.removeEventListener("wheel", this.#onWheel);
|
|
2692
|
+
canvas.removeEventListener("dblclick", this.#onDoubleClick);
|
|
2693
|
+
canvas.removeEventListener("contextmenu", this.#onContextMenu);
|
|
2694
|
+
root.removeEventListener("keydown", this.#onKeyDown);
|
|
2695
|
+
root.removeEventListener("keyup", this.#onKeyUp);
|
|
2696
|
+
root.ownerDocument.removeEventListener("keydown", this.#onDocumentKeyDown);
|
|
2697
|
+
}
|
|
2698
|
+
#onPointerDown = (event) => {
|
|
2699
|
+
const { deps, state, pointer } = this.#context;
|
|
2700
|
+
deps.root.focus();
|
|
2701
|
+
const screen = pointer.screen(event);
|
|
2702
|
+
const page = pointer.page(event);
|
|
2703
|
+
if (event.button === 1) {
|
|
2704
|
+
event.preventDefault();
|
|
2705
|
+
state.drag = {
|
|
2706
|
+
mode: "pan",
|
|
2707
|
+
lastX: screen.x,
|
|
2708
|
+
lastY: screen.y
|
|
2709
|
+
};
|
|
2710
|
+
deps.canvas.setPointerCapture(event.pointerId);
|
|
2711
|
+
return;
|
|
2712
|
+
}
|
|
2713
|
+
if (event.button !== 0) return;
|
|
2714
|
+
if (deps.controller.tool === "wire" && !deps.readOnly) {
|
|
2715
|
+
addWirePoint(this.#context, page);
|
|
2716
|
+
return;
|
|
2717
|
+
}
|
|
2718
|
+
if (!deps.readOnly && placementDevice(this.#context) !== void 0) {
|
|
2719
|
+
place(this.#context, page);
|
|
2720
|
+
return;
|
|
2721
|
+
}
|
|
2722
|
+
if (startWireAtNode(this.#context, page)) return;
|
|
2723
|
+
const selection = selectionAt(this.#context, page);
|
|
2724
|
+
if (selection === void 0) {
|
|
2725
|
+
selectItem(this.#context, void 0, event.shiftKey);
|
|
2726
|
+
state.drag = {
|
|
2727
|
+
mode: "marquee",
|
|
2728
|
+
origin: page,
|
|
2729
|
+
current: page
|
|
2730
|
+
};
|
|
2731
|
+
deps.canvas.setPointerCapture(event.pointerId);
|
|
2732
|
+
deps.onChange();
|
|
2733
|
+
return;
|
|
2734
|
+
}
|
|
2735
|
+
selectItem(this.#context, selection, event.shiftKey);
|
|
2736
|
+
if (!deps.readOnly) {
|
|
2737
|
+
if (beginDragFor(this.#context, page, selection)) deps.canvas.setPointerCapture(event.pointerId);
|
|
2738
|
+
}
|
|
2739
|
+
deps.onChange();
|
|
2740
|
+
};
|
|
2741
|
+
#onPointerMove = (event) => {
|
|
2742
|
+
const { deps, state, pointer } = this.#context;
|
|
2743
|
+
const screen = pointer.screen(event);
|
|
2744
|
+
const page = pointer.page(event);
|
|
2745
|
+
if (state.drag?.mode === "pan") {
|
|
2746
|
+
deps.viewport.pan(screen.x - state.drag.lastX, screen.y - state.drag.lastY);
|
|
2747
|
+
state.drag = {
|
|
2748
|
+
mode: "pan",
|
|
2749
|
+
lastX: screen.x,
|
|
2750
|
+
lastY: screen.y
|
|
2751
|
+
};
|
|
2752
|
+
deps.onChange();
|
|
2753
|
+
return;
|
|
2754
|
+
}
|
|
2755
|
+
if (state.drag?.mode === "marquee") {
|
|
2756
|
+
state.drag = {
|
|
2757
|
+
mode: "marquee",
|
|
2758
|
+
origin: state.drag.origin,
|
|
2759
|
+
current: page
|
|
2760
|
+
};
|
|
2761
|
+
deps.onChange();
|
|
2762
|
+
return;
|
|
2763
|
+
}
|
|
2764
|
+
if (updateMoveDrag(this.#context, page)) return;
|
|
2765
|
+
if (updateReferenceDrag(this.#context, page)) return;
|
|
2766
|
+
if (updateSegmentDrag(this.#context, page)) return;
|
|
2767
|
+
const device = placementDevice(this.#context);
|
|
2768
|
+
if (deps.controller.tool === "wire") {
|
|
2769
|
+
state.cursorPoint = pointer.snapWire(page);
|
|
2770
|
+
deps.onChange();
|
|
2771
|
+
return;
|
|
2772
|
+
}
|
|
2773
|
+
if (device !== void 0) {
|
|
2774
|
+
state.cursorPoint = snapPlacementPoint(this.#context, page, device);
|
|
2775
|
+
deps.onChange();
|
|
2776
|
+
return;
|
|
2777
|
+
}
|
|
2778
|
+
const next = deps.controller.hitTest(page, { tolerance: pointer.tolerance() });
|
|
2779
|
+
if (!sameHit(next, state.hover)) {
|
|
2780
|
+
state.hover = next;
|
|
2781
|
+
deps.onChange();
|
|
2782
|
+
}
|
|
2783
|
+
};
|
|
2784
|
+
#onPointerUp = (event) => {
|
|
2785
|
+
const { deps, state } = this.#context;
|
|
2786
|
+
const drag = state.drag;
|
|
2787
|
+
state.drag = void 0;
|
|
2788
|
+
if (deps.canvas.hasPointerCapture(event.pointerId)) deps.canvas.releasePointerCapture(event.pointerId);
|
|
2789
|
+
if (drag?.mode === "marquee") {
|
|
2790
|
+
finishMarquee(this.#context, drag.origin, drag.current);
|
|
2791
|
+
return;
|
|
2792
|
+
}
|
|
2793
|
+
if (drag?.mode === "move") {
|
|
2794
|
+
if (!finishMoveDrag(this.#context, drag)) deps.onChange();
|
|
2795
|
+
return;
|
|
2796
|
+
}
|
|
2797
|
+
if (drag?.mode === "reference") {
|
|
2798
|
+
if (!finishReferenceDrag(this.#context, drag)) deps.onChange();
|
|
2799
|
+
return;
|
|
2800
|
+
}
|
|
2801
|
+
if (drag?.mode === "segment") {
|
|
2802
|
+
if (!finishSegmentDrag(this.#context, drag)) deps.onChange();
|
|
2803
|
+
return;
|
|
2804
|
+
}
|
|
2805
|
+
if (drag !== void 0) deps.onChange();
|
|
2806
|
+
};
|
|
2807
|
+
#onPointerLeave = () => {
|
|
2808
|
+
const { deps, state } = this.#context;
|
|
2809
|
+
if (state.hover !== void 0 && state.drag === void 0) {
|
|
2810
|
+
state.hover = void 0;
|
|
2811
|
+
deps.onChange();
|
|
2812
|
+
}
|
|
2813
|
+
};
|
|
2814
|
+
#onWheel = (event) => {
|
|
2815
|
+
const { deps, pointer } = this.#context;
|
|
2816
|
+
event.preventDefault();
|
|
2817
|
+
deps.viewport.zoomAt(pointer.screen(event), event.deltaY < 0 ? 1.1 : 1 / 1.1);
|
|
2818
|
+
deps.onChange();
|
|
2819
|
+
};
|
|
2820
|
+
#onDoubleClick = (event) => {
|
|
2821
|
+
const { deps, pointer } = this.#context;
|
|
2822
|
+
if (deps.controller.tool === "wire") {
|
|
2823
|
+
finishWire(this.#context);
|
|
2824
|
+
return;
|
|
2825
|
+
}
|
|
2826
|
+
const page = pointer.page(event);
|
|
2827
|
+
const hit = deps.controller.hitTest(page, { tolerance: pointer.tolerance() });
|
|
2828
|
+
const selection = hit === void 0 ? void 0 : selectionFromHit(hit);
|
|
2829
|
+
if (selection !== void 0) deps.controller.setSelection([selection]);
|
|
2830
|
+
};
|
|
2831
|
+
#onContextMenu = (event) => {
|
|
2832
|
+
event.preventDefault();
|
|
2833
|
+
};
|
|
2834
|
+
#onKeyDown = (event) => {
|
|
2835
|
+
handleKeyDown(this.#context, event);
|
|
2836
|
+
};
|
|
2837
|
+
#onKeyUp = () => {};
|
|
2838
|
+
/**
|
|
2839
|
+
* Esc fallback for hosts that arm placement from their own UI: the event may never reach the
|
|
2840
|
+
* editor root, so listen on the document while an interaction is pending.
|
|
2841
|
+
*/
|
|
2842
|
+
#onDocumentKeyDown = (event) => {
|
|
2843
|
+
const { deps } = this.#context;
|
|
2844
|
+
if (event.key !== "Escape" || event.defaultPrevented) return;
|
|
2845
|
+
if (deps.root.contains(event.target)) return;
|
|
2846
|
+
if (cancelInteraction(this.#context)) event.preventDefault();
|
|
2847
|
+
};
|
|
2848
|
+
};
|
|
2849
|
+
function beginDragFor(context, page, selection) {
|
|
2850
|
+
switch (selection.kind) {
|
|
2851
|
+
case "symbol": return beginMoveDrag(context, page, selection.id);
|
|
2852
|
+
case "reference": return beginReferenceDrag(context, page, selection.symbolId);
|
|
2853
|
+
case "wire": return beginSegmentDrag(context, page, selection.id);
|
|
2854
|
+
default: return false;
|
|
2855
|
+
}
|
|
2856
|
+
}
|
|
2857
|
+
//#endregion
|
|
2858
|
+
//#region src/editor/scene-input.ts
|
|
2859
|
+
/** Translates controller selections into the flat sets the scene builder expects. */
|
|
2860
|
+
function toSceneHighlight(selection) {
|
|
2861
|
+
const symbols = /* @__PURE__ */ new Set();
|
|
2862
|
+
const wires = /* @__PURE__ */ new Set();
|
|
2863
|
+
const junctions = /* @__PURE__ */ new Set();
|
|
2864
|
+
const vertices = [];
|
|
2865
|
+
for (const item of selection) switch (item.kind) {
|
|
2866
|
+
case "symbol":
|
|
2867
|
+
symbols.add(item.id);
|
|
2868
|
+
break;
|
|
2869
|
+
case "wire":
|
|
2870
|
+
wires.add(item.id);
|
|
2871
|
+
break;
|
|
2872
|
+
case "junction":
|
|
2873
|
+
junctions.add(item.id);
|
|
2874
|
+
break;
|
|
2875
|
+
case "vertex":
|
|
2876
|
+
wires.add(item.wireId);
|
|
2877
|
+
vertices.push({
|
|
2878
|
+
wireId: item.wireId,
|
|
2879
|
+
index: item.index
|
|
2880
|
+
});
|
|
2881
|
+
}
|
|
2882
|
+
return {
|
|
2883
|
+
symbols,
|
|
2884
|
+
wires,
|
|
2885
|
+
junctions,
|
|
2886
|
+
vertices
|
|
2887
|
+
};
|
|
2888
|
+
}
|
|
2889
|
+
//#endregion
|
|
2890
|
+
//#region src/styles/index.ts
|
|
2891
|
+
/** Editor chrome styles, injected as a single `<style>` element inside the editor root. */
|
|
2892
|
+
var EDITOR_STYLES = ".mica-schematic {\n position: relative;\n width: 100%;\n height: 100%;\n min-height: 0;\n overflow: hidden;\n background: var(--mica-schematic-background);\n color: var(--mica-schematic-ui-text);\n font: 12px/1.45 var(--mica-schematic-font-family);\n outline: none;\n}\n\n.mica-schematic__canvas-host {\n position: absolute;\n inset: 0;\n overflow: hidden;\n}\n\n.mica-schematic__canvas {\n display: block;\n width: 100%;\n height: 100%;\n touch-action: none;\n}\n\n/* Floating tool strip; the editor owns it so the canvas stays usable without a host toolbar. */\n.mica-schematic__toolbar {\n position: absolute;\n top: 8px;\n right: 8px;\n z-index: 1;\n display: flex;\n gap: 4px;\n align-items: center;\n padding: 3px;\n border: 1px solid var(--mica-schematic-ui-border);\n border-radius: 8px;\n background: color-mix(in srgb, var(--mica-schematic-ui-surface) 88%, transparent);\n box-shadow: 0 4px 12px rgb(0 0 0 / 18%);\n backdrop-filter: blur(6px);\n}\n\n.mica-schematic--no-toolbar .mica-schematic__toolbar {\n display: none;\n}\n\n.mica-schematic__tool {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n width: 26px;\n height: 24px;\n padding: 0;\n border: 1px solid transparent;\n border-radius: 6px;\n background: transparent;\n color: var(--mica-schematic-ui-muted-text);\n font: inherit;\n cursor: pointer;\n}\n\n.mica-schematic__tool:hover:not(:disabled) {\n color: var(--mica-schematic-ui-text);\n background: var(--mica-schematic-ui-border);\n}\n\n.mica-schematic__tool[aria-pressed='true'] {\n background: var(--mica-schematic-ui-accent);\n color: var(--mica-schematic-ui-accent-text);\n}\n\n.mica-schematic__tool:focus-visible {\n outline: 2px solid var(--mica-schematic-focus-ring);\n outline-offset: 1px;\n}\n\n.mica-schematic__tool:disabled {\n cursor: default;\n opacity: 0.5;\n}\n";
|
|
2893
|
+
//#endregion
|
|
2894
|
+
//#region src/editor/icons.ts
|
|
2895
|
+
/** Toolbar entries, in display order. */
|
|
2896
|
+
var TOOL_ICONS = [
|
|
2897
|
+
{
|
|
2898
|
+
tool: "select",
|
|
2899
|
+
label: "选择",
|
|
2900
|
+
segments: ["M4 2.5 L4 12.5 L6.6 10.1 L8.4 13.5 L10 12.7 L8.2 9.4 L11.8 9.4 Z"],
|
|
2901
|
+
dots: []
|
|
2902
|
+
},
|
|
2903
|
+
{
|
|
2904
|
+
tool: "wire",
|
|
2905
|
+
label: "导线",
|
|
2906
|
+
segments: ["M3.5 12.5 L12.5 3.5"],
|
|
2907
|
+
dots: [[3.5, 12.5], [12.5, 3.5]]
|
|
2908
|
+
},
|
|
2909
|
+
{
|
|
2910
|
+
tool: "power",
|
|
2911
|
+
label: "电源",
|
|
2912
|
+
segments: ["M8 14 V6", "M4 6 H12"],
|
|
2913
|
+
dots: [[8, 14]]
|
|
2914
|
+
},
|
|
2915
|
+
{
|
|
2916
|
+
tool: "ground",
|
|
2917
|
+
label: "地",
|
|
2918
|
+
segments: [
|
|
2919
|
+
"M8 3 V8",
|
|
2920
|
+
"M3.5 8 H12.5",
|
|
2921
|
+
"M5.5 11 H10.5",
|
|
2922
|
+
"M7 14 H9"
|
|
2923
|
+
],
|
|
2924
|
+
dots: [[8, 3]]
|
|
2925
|
+
},
|
|
2926
|
+
{
|
|
2927
|
+
tool: "label",
|
|
2928
|
+
label: "网络标签",
|
|
2929
|
+
segments: ["M8 4 V12", "M4.5 8 L8 4 L11.5 8"],
|
|
2930
|
+
dots: [[8, 12]]
|
|
2931
|
+
},
|
|
2932
|
+
{
|
|
2933
|
+
tool: "text",
|
|
2934
|
+
label: "文本",
|
|
2935
|
+
segments: ["M4 4 H12", "M8 4 V12"],
|
|
2936
|
+
dots: []
|
|
2937
|
+
}
|
|
2938
|
+
];
|
|
2939
|
+
var SVG_NAMESPACE = "http://www.w3.org/2000/svg";
|
|
2940
|
+
function createToolIcon(doc, icon) {
|
|
2941
|
+
const svg = doc.createElementNS(SVG_NAMESPACE, "svg");
|
|
2942
|
+
svg.setAttribute("viewBox", "0 0 16 16");
|
|
2943
|
+
svg.setAttribute("width", "16");
|
|
2944
|
+
svg.setAttribute("height", "16");
|
|
2945
|
+
svg.setAttribute("fill", "none");
|
|
2946
|
+
svg.setAttribute("stroke", "currentColor");
|
|
2947
|
+
svg.setAttribute("stroke-width", "1.4");
|
|
2948
|
+
svg.setAttribute("stroke-linecap", "round");
|
|
2949
|
+
svg.setAttribute("stroke-linejoin", "round");
|
|
2950
|
+
svg.setAttribute("aria-hidden", "true");
|
|
2951
|
+
for (const d of icon.segments) {
|
|
2952
|
+
const path = doc.createElementNS(SVG_NAMESPACE, "path");
|
|
2953
|
+
path.setAttribute("d", d);
|
|
2954
|
+
svg.append(path);
|
|
2955
|
+
}
|
|
2956
|
+
for (const [cx, cy] of icon.dots) {
|
|
2957
|
+
const circle = doc.createElementNS(SVG_NAMESPACE, "circle");
|
|
2958
|
+
circle.setAttribute("cx", String(cx));
|
|
2959
|
+
circle.setAttribute("cy", String(cy));
|
|
2960
|
+
circle.setAttribute("r", "1.5");
|
|
2961
|
+
circle.setAttribute("fill", "currentColor");
|
|
2962
|
+
circle.setAttribute("stroke", "none");
|
|
2963
|
+
svg.append(circle);
|
|
2964
|
+
}
|
|
2965
|
+
return svg;
|
|
2966
|
+
}
|
|
2967
|
+
//#endregion
|
|
2968
|
+
//#region src/editor/shell.ts
|
|
2969
|
+
/** Builds the canvas surface and the floating tool strip; it owns no behaviour beyond wiring. */
|
|
2970
|
+
function createShell(options) {
|
|
2971
|
+
const doc = options.document;
|
|
2972
|
+
const root = doc.createElement("div");
|
|
2973
|
+
root.className = "mica-schematic";
|
|
2974
|
+
root.tabIndex = 0;
|
|
2975
|
+
const style = doc.createElement("style");
|
|
2976
|
+
style.textContent = EDITOR_STYLES;
|
|
2977
|
+
const canvasHost = doc.createElement("div");
|
|
2978
|
+
canvasHost.className = "mica-schematic__canvas-host";
|
|
2979
|
+
const canvas = doc.createElement("canvas");
|
|
2980
|
+
canvas.className = "mica-schematic__canvas";
|
|
2981
|
+
canvasHost.append(canvas);
|
|
2982
|
+
root.append(style, canvasHost);
|
|
2983
|
+
const toolButtons = /* @__PURE__ */ new Map();
|
|
2984
|
+
if (options.toolbar) {
|
|
2985
|
+
const toolbar = doc.createElement("div");
|
|
2986
|
+
toolbar.className = "mica-schematic__toolbar";
|
|
2987
|
+
for (const icon of TOOL_ICONS) {
|
|
2988
|
+
const button = doc.createElement("button");
|
|
2989
|
+
button.type = "button";
|
|
2990
|
+
button.className = "mica-schematic__tool";
|
|
2991
|
+
button.title = icon.label;
|
|
2992
|
+
button.setAttribute("aria-label", icon.label);
|
|
2993
|
+
button.disabled = options.readOnly;
|
|
2994
|
+
button.append(createToolIcon(doc, icon));
|
|
2995
|
+
button.addEventListener("click", () => options.onToolClick(icon.tool));
|
|
2996
|
+
toolButtons.set(icon.tool, button);
|
|
2997
|
+
toolbar.append(button);
|
|
2998
|
+
}
|
|
2999
|
+
root.append(toolbar);
|
|
3000
|
+
} else root.classList.add("mica-schematic--no-toolbar");
|
|
3001
|
+
return {
|
|
3002
|
+
root,
|
|
3003
|
+
canvasHost,
|
|
3004
|
+
canvas,
|
|
3005
|
+
toolButtons,
|
|
3006
|
+
setToolActive(tool) {
|
|
3007
|
+
for (const [candidate, button] of toolButtons) button.setAttribute("aria-pressed", String(candidate === tool));
|
|
3008
|
+
}
|
|
3009
|
+
};
|
|
3010
|
+
}
|
|
3011
|
+
//#endregion
|
|
3012
|
+
//#region src/editor/viewport-state.ts
|
|
3013
|
+
/**
|
|
3014
|
+
* Owns the viewport and the "auto fit until the user takes over" rule.
|
|
3015
|
+
* An empty canvas keeps refitting while the container resizes.
|
|
3016
|
+
*/
|
|
3017
|
+
var EditorViewport = class {
|
|
3018
|
+
#state = IDENTITY_VIEWPORT;
|
|
3019
|
+
#autoFit = true;
|
|
3020
|
+
#limits;
|
|
3021
|
+
constructor(limits = DEFAULT_VIEWPORT_LIMITS) {
|
|
3022
|
+
this.#limits = resolveViewportLimits(limits);
|
|
3023
|
+
}
|
|
3024
|
+
get state() {
|
|
3025
|
+
return this.#state;
|
|
3026
|
+
}
|
|
3027
|
+
get limits() {
|
|
3028
|
+
return this.#limits;
|
|
3029
|
+
}
|
|
3030
|
+
get autoFit() {
|
|
3031
|
+
return this.#autoFit;
|
|
3032
|
+
}
|
|
3033
|
+
/** Fits `bounds` into the given viewport size and re-enables auto fit. */
|
|
3034
|
+
fit(bounds, size) {
|
|
3035
|
+
if (size.width === 0 || size.height === 0) return;
|
|
3036
|
+
this.#state = fitBounds(bounds ?? {
|
|
3037
|
+
x: -50,
|
|
3038
|
+
y: -50,
|
|
3039
|
+
width: 100,
|
|
3040
|
+
height: 100
|
|
3041
|
+
}, size, 48, this.#limits);
|
|
3042
|
+
this.#autoFit = true;
|
|
3043
|
+
}
|
|
3044
|
+
pan(dx, dy) {
|
|
3045
|
+
this.#autoFit = false;
|
|
3046
|
+
this.#state = panBy(this.#state, dx, dy);
|
|
3047
|
+
}
|
|
3048
|
+
zoomAt(screenPoint, factor) {
|
|
3049
|
+
this.#autoFit = false;
|
|
3050
|
+
this.#state = zoomAt(this.#state, screenPoint, factor, this.#limits);
|
|
3051
|
+
}
|
|
3052
|
+
zoomTo(scale, center) {
|
|
3053
|
+
if (scale <= 0 || !Number.isFinite(scale)) return;
|
|
3054
|
+
this.#autoFit = false;
|
|
3055
|
+
this.#state = zoomAt(this.#state, center, scale / this.#state.scale, this.#limits);
|
|
3056
|
+
}
|
|
3057
|
+
/** Restores a saved viewport (already clamped by the caller); turns auto fit off. */
|
|
3058
|
+
setState(state) {
|
|
3059
|
+
this.#autoFit = false;
|
|
3060
|
+
this.#state = state;
|
|
3061
|
+
}
|
|
3062
|
+
/** Turns off auto fit when the user interacts with the view. */
|
|
3063
|
+
takeOver() {
|
|
3064
|
+
this.#autoFit = false;
|
|
3065
|
+
}
|
|
3066
|
+
};
|
|
3067
|
+
//#endregion
|
|
3068
|
+
//#region src/editor/mount.ts
|
|
3069
|
+
/** Mounts a canvas schematic editor inside `options.container`. */
|
|
3070
|
+
function mountSchematicEditor(options) {
|
|
3071
|
+
const doc = options.container.ownerDocument;
|
|
3072
|
+
const readOnly = options.readOnly ?? false;
|
|
3073
|
+
const controller = new SchematicEditorController({
|
|
3074
|
+
document: options.schematic,
|
|
3075
|
+
...options.devices === void 0 ? {} : { devices: options.devices },
|
|
3076
|
+
...options.display === void 0 ? {} : { display: options.display },
|
|
3077
|
+
...options.tolerance === void 0 ? {} : { tolerance: options.tolerance },
|
|
3078
|
+
...options.historyLimit === void 0 ? {} : { historyLimit: options.historyLimit },
|
|
3079
|
+
...options.createDesignator === void 0 ? {} : { createDesignator: options.createDesignator }
|
|
3080
|
+
});
|
|
3081
|
+
const shell = createShell({
|
|
3082
|
+
document: doc,
|
|
3083
|
+
readOnly,
|
|
3084
|
+
toolbar: options.toolbar !== false,
|
|
3085
|
+
onToolClick: (tool) => {
|
|
3086
|
+
controller.setTool(controller.tool === tool ? "select" : tool);
|
|
3087
|
+
shell.root.focus();
|
|
3088
|
+
}
|
|
3089
|
+
});
|
|
3090
|
+
const viewport = new EditorViewport(resolveViewportLimits(options.zoomLimits));
|
|
3091
|
+
const renderer = new CanvasSchematicRenderer(shell.canvas, { theme: resolveSchematicTheme(options.theme) });
|
|
3092
|
+
let theme = resolveSchematicTheme(options.theme);
|
|
3093
|
+
let disposed = false;
|
|
3094
|
+
let lastSelection = [];
|
|
3095
|
+
let lastTool = controller.tool;
|
|
3096
|
+
let lastPlacing = controller.placingDevice;
|
|
3097
|
+
let lastError = controller.state.lastError;
|
|
3098
|
+
let lastViewport = viewport.state;
|
|
3099
|
+
const input = new EditorInput({
|
|
3100
|
+
controller,
|
|
3101
|
+
devices: options.devices,
|
|
3102
|
+
viewport,
|
|
3103
|
+
root: shell.root,
|
|
3104
|
+
canvas: shell.canvas,
|
|
3105
|
+
readOnly,
|
|
3106
|
+
onChange: () => {
|
|
3107
|
+
render();
|
|
3108
|
+
},
|
|
3109
|
+
onFit: () => {
|
|
3110
|
+
fitToContent();
|
|
3111
|
+
},
|
|
3112
|
+
run: (edit) => {
|
|
3113
|
+
run(edit);
|
|
3114
|
+
}
|
|
3115
|
+
});
|
|
3116
|
+
function run(edit) {
|
|
3117
|
+
try {
|
|
3118
|
+
edit();
|
|
3119
|
+
} catch {}
|
|
3120
|
+
}
|
|
3121
|
+
function applyTheme() {
|
|
3122
|
+
for (const [name, value] of Object.entries(themeToCssVariables(theme))) shell.root.style.setProperty(name, value);
|
|
3123
|
+
renderer.setTheme(theme);
|
|
3124
|
+
}
|
|
3125
|
+
function render(state = controller.renderState) {
|
|
3126
|
+
if (disposed) return;
|
|
3127
|
+
const scene = buildSchematicScene({
|
|
3128
|
+
document: state.document,
|
|
3129
|
+
...options.devices === void 0 ? {} : { devices: options.devices },
|
|
3130
|
+
theme,
|
|
3131
|
+
selection: {
|
|
3132
|
+
...toSceneHighlight(state.selection),
|
|
3133
|
+
...input.dragOffset === void 0 ? {} : { offset: input.dragOffset }
|
|
3134
|
+
},
|
|
3135
|
+
...input.hover === void 0 ? {} : { hover: input.hover },
|
|
3136
|
+
tool: state.tool,
|
|
3137
|
+
display: {
|
|
3138
|
+
grid: state.display.grid,
|
|
3139
|
+
labels: state.display.labels,
|
|
3140
|
+
gridSize: state.display.gridSize,
|
|
3141
|
+
gridSubdivisions: state.display.gridSubdivisions
|
|
3142
|
+
},
|
|
3143
|
+
...input.pendingWire.length === 0 ? {} : { pendingWire: input.pendingWireWithCursor },
|
|
3144
|
+
...input.marquee === void 0 ? {} : { marquee: input.marquee },
|
|
3145
|
+
...input.placementPreview === void 0 ? {} : { placementPreview: input.placementPreview },
|
|
3146
|
+
...input.referencePreview === void 0 ? {} : { referencePreview: input.referencePreview },
|
|
3147
|
+
...input.wirePreview === void 0 ? {} : { wirePreview: input.wirePreview }
|
|
3148
|
+
});
|
|
3149
|
+
renderer.render(scene, viewport.state);
|
|
3150
|
+
if (!sameViewport(viewport.state, lastViewport)) {
|
|
3151
|
+
lastViewport = viewport.state;
|
|
3152
|
+
options.onViewportChange?.(viewport.state);
|
|
3153
|
+
}
|
|
3154
|
+
}
|
|
3155
|
+
function sync(state) {
|
|
3156
|
+
shell.setToolActive(state.tool);
|
|
3157
|
+
shell.canvas.style.cursor = state.tool === "select" && state.placingDevice === void 0 ? "default" : "crosshair";
|
|
3158
|
+
if (state.lastError !== lastError) {
|
|
3159
|
+
lastError = state.lastError;
|
|
3160
|
+
options.onError?.(state.lastError);
|
|
3161
|
+
}
|
|
3162
|
+
if (state.tool !== lastTool || state.placingDevice !== lastPlacing) {
|
|
3163
|
+
lastTool = state.tool;
|
|
3164
|
+
lastPlacing = state.placingDevice;
|
|
3165
|
+
input.resetTransient();
|
|
3166
|
+
options.onToolChange?.(state.tool);
|
|
3167
|
+
}
|
|
3168
|
+
if (!sameSelection(state.selection, lastSelection)) {
|
|
3169
|
+
lastSelection = state.selection;
|
|
3170
|
+
options.onSelectionChange?.(state.selection);
|
|
3171
|
+
}
|
|
3172
|
+
options.onChange?.(state);
|
|
3173
|
+
render(state);
|
|
3174
|
+
}
|
|
3175
|
+
function resize() {
|
|
3176
|
+
const width = shell.canvasHost.clientWidth;
|
|
3177
|
+
const height = shell.canvasHost.clientHeight;
|
|
3178
|
+
if (width === 0 || height === 0) return;
|
|
3179
|
+
renderer.resize(width, height, Math.max(1, window.devicePixelRatio || 1));
|
|
3180
|
+
if (viewport.autoFit) {
|
|
3181
|
+
fitToContent();
|
|
3182
|
+
return;
|
|
3183
|
+
}
|
|
3184
|
+
render();
|
|
3185
|
+
}
|
|
3186
|
+
function fitToContent() {
|
|
3187
|
+
viewport.fit(controller.topology().bounds, {
|
|
3188
|
+
width: shell.canvasHost.clientWidth,
|
|
3189
|
+
height: shell.canvasHost.clientHeight
|
|
3190
|
+
});
|
|
3191
|
+
render();
|
|
3192
|
+
}
|
|
3193
|
+
const unsubscribe = controller.subscribe(sync);
|
|
3194
|
+
applyTheme();
|
|
3195
|
+
input.attach();
|
|
3196
|
+
sync(controller.state);
|
|
3197
|
+
const resizeObserver = new ResizeObserver(() => resize());
|
|
3198
|
+
resizeObserver.observe(shell.canvasHost);
|
|
3199
|
+
options.container.append(shell.root);
|
|
3200
|
+
resize();
|
|
3201
|
+
return {
|
|
3202
|
+
controller,
|
|
3203
|
+
root: shell.root,
|
|
3204
|
+
canvas: shell.canvas,
|
|
3205
|
+
setSchematic(document, setOptions = {}) {
|
|
3206
|
+
if (setOptions.keepHistory === true) {
|
|
3207
|
+
run(() => controller.replace(document));
|
|
3208
|
+
return;
|
|
3209
|
+
}
|
|
3210
|
+
controller.reset(document);
|
|
3211
|
+
fitToContent();
|
|
3212
|
+
},
|
|
3213
|
+
setTheme(next) {
|
|
3214
|
+
theme = resolveSchematicTheme(next);
|
|
3215
|
+
applyTheme();
|
|
3216
|
+
render();
|
|
3217
|
+
},
|
|
3218
|
+
getTheme() {
|
|
3219
|
+
return theme;
|
|
3220
|
+
},
|
|
3221
|
+
setDisplayOptions(patch) {
|
|
3222
|
+
controller.setDisplayOptions(patch);
|
|
3223
|
+
},
|
|
3224
|
+
setTool(tool) {
|
|
3225
|
+
controller.setTool(tool);
|
|
3226
|
+
},
|
|
3227
|
+
placeDevice(device) {
|
|
3228
|
+
if (readOnly) return;
|
|
3229
|
+
controller.placeDevice(device);
|
|
3230
|
+
shell.root.focus();
|
|
3231
|
+
},
|
|
3232
|
+
cancelPlacement() {
|
|
3233
|
+
controller.cancelPlacement();
|
|
3234
|
+
},
|
|
3235
|
+
fitToContent,
|
|
3236
|
+
zoomTo(scale) {
|
|
3237
|
+
viewport.zoomTo(scale, {
|
|
3238
|
+
x: shell.canvasHost.clientWidth / 2,
|
|
3239
|
+
y: shell.canvasHost.clientHeight / 2
|
|
3240
|
+
});
|
|
3241
|
+
render();
|
|
3242
|
+
},
|
|
3243
|
+
getViewport() {
|
|
3244
|
+
return viewport.state;
|
|
3245
|
+
},
|
|
3246
|
+
setViewport(next) {
|
|
3247
|
+
viewport.setState(clampViewport(next, {
|
|
3248
|
+
width: shell.canvasHost.clientWidth,
|
|
3249
|
+
height: shell.canvasHost.clientHeight
|
|
3250
|
+
}, viewport.limits));
|
|
3251
|
+
render();
|
|
3252
|
+
},
|
|
3253
|
+
toJSON() {
|
|
3254
|
+
return serializeSchematic(controller.document);
|
|
3255
|
+
},
|
|
3256
|
+
focus() {
|
|
3257
|
+
shell.root.focus();
|
|
3258
|
+
},
|
|
3259
|
+
toPage(clientPoint) {
|
|
3260
|
+
const rect = shell.canvas.getBoundingClientRect();
|
|
3261
|
+
return screenToPage(viewport.state, {
|
|
3262
|
+
x: clientPoint.x - rect.left,
|
|
3263
|
+
y: clientPoint.y - rect.top
|
|
3264
|
+
});
|
|
3265
|
+
},
|
|
3266
|
+
toScreen(pagePoint) {
|
|
3267
|
+
return pageToScreen(viewport.state, pagePoint);
|
|
3268
|
+
},
|
|
3269
|
+
resize,
|
|
3270
|
+
invalidate: render,
|
|
3271
|
+
undo() {
|
|
3272
|
+
controller.undo();
|
|
3273
|
+
},
|
|
3274
|
+
redo() {
|
|
3275
|
+
controller.redo();
|
|
3276
|
+
},
|
|
3277
|
+
dispose() {
|
|
3278
|
+
disposed = true;
|
|
3279
|
+
unsubscribe();
|
|
3280
|
+
resizeObserver.disconnect();
|
|
3281
|
+
input.detach();
|
|
3282
|
+
renderer.destroy();
|
|
3283
|
+
shell.root.remove();
|
|
3284
|
+
}
|
|
3285
|
+
};
|
|
3286
|
+
}
|
|
3287
|
+
function sameViewport(left, right) {
|
|
3288
|
+
return right !== void 0 && left.scale === right.scale && left.offsetX === right.offsetX && left.offsetY === right.offsetY;
|
|
3289
|
+
}
|
|
3290
|
+
//#endregion
|
|
3291
|
+
export { DARK_SCHEMATIC_THEME, DEFAULT_DISPLAY_OPTIONS, DEFAULT_VIEWPORT_LIMITS, LIGHT_SCHEMATIC_THEME, SchematicEditorController, mountSchematicEditor, resolveSchematicTheme, resolveViewportLimits, themeToCssVariables };
|
|
3292
|
+
|
|
3293
|
+
//# sourceMappingURL=index.js.map
|