@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/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