@huaqiu/dsh-tool-kicad-schematic-gen 0.6.3 → 0.6.4
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/lib/client.js +721 -8
- package/lib/index.js +45 -7
- package/package.json +6 -3
- package/src/client/composer-chip.ts +328 -0
- package/src/client/index.ts +56 -4
- package/src/client/sidebar-entry.ts +230 -0
- package/src/index.ts +30 -5
- package/src/schematic/preview.ts +42 -2
package/lib/client.js
CHANGED
|
@@ -84,6 +84,190 @@
|
|
|
84
84
|
}
|
|
85
85
|
//#endregion
|
|
86
86
|
//#region ../dsh-sidebar-entry/lib/index.mjs
|
|
87
|
+
/** Find the sidebar shell root element, or undefined while not yet mounted. */
|
|
88
|
+
function sidebarRoot() {
|
|
89
|
+
const column = document.querySelector("[data-pane=\"sidebar\"], [class*=\"sidebarCol\"]");
|
|
90
|
+
if (column === null) return void 0;
|
|
91
|
+
return column.querySelector("[class*=\"logoRow\"]")?.parentElement ?? column.firstElementChild;
|
|
92
|
+
}
|
|
93
|
+
/** The New Session button: nested in the logo row on current shells, a direct child on legacy shells. */
|
|
94
|
+
function newSessionButton(root) {
|
|
95
|
+
const nested = root.querySelector("button[class*=\"newSession\"]");
|
|
96
|
+
if (nested !== null) return nested;
|
|
97
|
+
for (const child of root.children) if (child.tagName === "BUTTON") return child;
|
|
98
|
+
}
|
|
99
|
+
/** Build the entry row (a detached button; insert once the shell is up). */
|
|
100
|
+
function createEntry(options) {
|
|
101
|
+
const entry = document.createElement("button");
|
|
102
|
+
entry.type = "button";
|
|
103
|
+
entry.setAttribute(options.rowAttribute, "");
|
|
104
|
+
if (options.plugin !== void 0) {
|
|
105
|
+
entry.setAttribute("data-dsh-plugin", options.plugin);
|
|
106
|
+
entry.setAttribute("data-dsh-part", "sidebar-entry");
|
|
107
|
+
}
|
|
108
|
+
entry.className = options.css["entry"] ?? "";
|
|
109
|
+
const labelSpan = document.createElement("span");
|
|
110
|
+
labelSpan.className = options.css["entryLabel"] ?? "";
|
|
111
|
+
const iconSpan = document.createElement("span");
|
|
112
|
+
iconSpan.className = options.css["entryIcon"] ?? "";
|
|
113
|
+
iconSpan.innerHTML = options.icon;
|
|
114
|
+
entry.append(iconSpan, labelSpan);
|
|
115
|
+
let badgeSpan = null;
|
|
116
|
+
const applyBadge = () => {
|
|
117
|
+
if (options.badge === void 0) return;
|
|
118
|
+
const count = options.badge.count();
|
|
119
|
+
if (count <= 0) {
|
|
120
|
+
badgeSpan?.remove();
|
|
121
|
+
badgeSpan = null;
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
if (badgeSpan === null) {
|
|
125
|
+
badgeSpan = document.createElement("span");
|
|
126
|
+
badgeSpan.className = options.css["entryBadge"] ?? "";
|
|
127
|
+
entry.append(badgeSpan);
|
|
128
|
+
}
|
|
129
|
+
badgeSpan.textContent = String(count);
|
|
130
|
+
entry.setAttribute("data-has-badge", "");
|
|
131
|
+
};
|
|
132
|
+
const applyLabel = () => {
|
|
133
|
+
entry.setAttribute("aria-label", options.label());
|
|
134
|
+
if (options.tooltip !== void 0) entry.setAttribute("title", options.tooltip());
|
|
135
|
+
labelSpan.textContent = options.label();
|
|
136
|
+
};
|
|
137
|
+
applyLabel();
|
|
138
|
+
applyBadge();
|
|
139
|
+
entry.addEventListener("click", options.onToggle);
|
|
140
|
+
return {
|
|
141
|
+
entry,
|
|
142
|
+
applyLabel,
|
|
143
|
+
applyBadge
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* Re-insert the entry after the New Session row, in the family's intended
|
|
148
|
+
* order.
|
|
149
|
+
*
|
|
150
|
+
* The `familySelectors` array order is the single source of truth for visual
|
|
151
|
+
* order, so the rendered order is stable regardless of which plugin mounts
|
|
152
|
+
* first or of observer callback order — a re-render can never swap two rows.
|
|
153
|
+
* (Earlier builds used the `position: 'before' | 'after'` convention, which
|
|
154
|
+
* clustered rows by mount order and let a late-mounting `before` row steal the
|
|
155
|
+
* lead. The array order supersedes it; `position` remains only as a fallback
|
|
156
|
+
* for a row whose selector is absent from the family list.)
|
|
157
|
+
*/
|
|
158
|
+
function placeEntry(root, entry, options) {
|
|
159
|
+
const button = newSessionButton(root);
|
|
160
|
+
if (button === void 0) return false;
|
|
161
|
+
if (entry.parentElement !== root) {
|
|
162
|
+
const row = button.closest("[class*=\"logoRow\"]");
|
|
163
|
+
const base = row !== null && row.parentElement === root ? row : button;
|
|
164
|
+
const order = options.familySelectors;
|
|
165
|
+
const present = Array.from(root.children).filter((el) => el instanceof HTMLElement && el.matches(order.join(", "))).map((el) => ({
|
|
166
|
+
el,
|
|
167
|
+
idx: order.findIndex((sel) => el.matches(sel))
|
|
168
|
+
}));
|
|
169
|
+
const selfIndex = order.indexOf(options.rowSelector);
|
|
170
|
+
let anchor;
|
|
171
|
+
if (selfIndex === -1) anchor = options.position === "before" ? present.length > 0 ? present[0].el : base.nextElementSibling : present.length > 0 ? present[present.length - 1].el.nextElementSibling : base.nextElementSibling;
|
|
172
|
+
else {
|
|
173
|
+
const later = present.find((member) => member.idx > selfIndex);
|
|
174
|
+
if (later !== void 0) anchor = later.el;
|
|
175
|
+
else {
|
|
176
|
+
const earlier = [...present].reverse().find((member) => member.idx < selfIndex);
|
|
177
|
+
anchor = earlier !== void 0 ? earlier.el.nextElementSibling : base.nextElementSibling;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
root.insertBefore(entry, anchor ?? null);
|
|
181
|
+
}
|
|
182
|
+
return true;
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* Mount the sidebar entry, waiting for the shell to render and self-healing
|
|
186
|
+
* on later React re-renders.
|
|
187
|
+
* @param options - the row's attribute/icon/copy/action/ordering configuration.
|
|
188
|
+
* @returns disposer removing the entry and its observers.
|
|
189
|
+
*/
|
|
190
|
+
function mountSidebarEntry(options) {
|
|
191
|
+
if (typeof document !== "undefined" && document.querySelector(options.rowSelector) !== null) return () => {};
|
|
192
|
+
const { entry, applyLabel, applyBadge } = createEntry(options);
|
|
193
|
+
let root;
|
|
194
|
+
let placed = false;
|
|
195
|
+
let unsubscribeRefresh;
|
|
196
|
+
if (options.refresh !== void 0) try {
|
|
197
|
+
unsubscribeRefresh = options.refresh.subscribe(applyLabel);
|
|
198
|
+
} catch {}
|
|
199
|
+
let unsubscribeBadge;
|
|
200
|
+
if (options.badge !== void 0) try {
|
|
201
|
+
unsubscribeBadge = options.badge.subscribe(applyBadge);
|
|
202
|
+
} catch {}
|
|
203
|
+
const tryPlace = () => {
|
|
204
|
+
if (root !== void 0 && !root.isConnected) {
|
|
205
|
+
rootObserver.disconnect();
|
|
206
|
+
root = void 0;
|
|
207
|
+
placed = false;
|
|
208
|
+
}
|
|
209
|
+
if (placed) {
|
|
210
|
+
if (document.body.contains(entry)) return;
|
|
211
|
+
rootObserver.disconnect();
|
|
212
|
+
root = void 0;
|
|
213
|
+
placed = false;
|
|
214
|
+
}
|
|
215
|
+
root ??= sidebarRoot();
|
|
216
|
+
if (root === void 0) return;
|
|
217
|
+
placed = placeEntry(root, entry, options);
|
|
218
|
+
if (placed) rootObserver.observe(root, {
|
|
219
|
+
childList: true,
|
|
220
|
+
subtree: true
|
|
221
|
+
});
|
|
222
|
+
};
|
|
223
|
+
const waitObserver = new MutationObserver(() => {
|
|
224
|
+
tryPlace();
|
|
225
|
+
});
|
|
226
|
+
waitObserver.observe(document.body, {
|
|
227
|
+
childList: true,
|
|
228
|
+
subtree: true
|
|
229
|
+
});
|
|
230
|
+
const rootObserver = new MutationObserver(() => {
|
|
231
|
+
if (root === void 0 || !root.isConnected) {
|
|
232
|
+
placed = false;
|
|
233
|
+
tryPlace();
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
if (!root.contains(entry)) placed = placeEntry(root, entry, options);
|
|
237
|
+
});
|
|
238
|
+
options.active === void 0 || (() => {
|
|
239
|
+
const syncActive = () => {
|
|
240
|
+
if (options.active.isOpen()) entry.dataset.active = "true";
|
|
241
|
+
else delete entry.dataset.active;
|
|
242
|
+
};
|
|
243
|
+
const unsubscribe = options.active.subscribe(syncActive);
|
|
244
|
+
syncActive();
|
|
245
|
+
return unsubscribe;
|
|
246
|
+
})();
|
|
247
|
+
tryPlace();
|
|
248
|
+
return () => {
|
|
249
|
+
waitObserver.disconnect();
|
|
250
|
+
rootObserver.disconnect();
|
|
251
|
+
unsubscribeRefresh?.();
|
|
252
|
+
unsubscribeBadge?.();
|
|
253
|
+
entry.remove();
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
/**
|
|
257
|
+
* Every row selector in the Huaqiu sidebar family, in intended visual order.
|
|
258
|
+
*
|
|
259
|
+
* Family ordering is only stable when every participating plugin passes the
|
|
260
|
+
* SAME list to {@link mountSidebarEntry} — the core positions a row relative to
|
|
261
|
+
* the family block, never relative to transient shell geometry. Adding a new
|
|
262
|
+
* family row means adding its selector here (and to no plugin-local list).
|
|
263
|
+
*/
|
|
264
|
+
const HUAQIU_SIDEBAR_ENTRY_FAMILY = [
|
|
265
|
+
"[data-hq-plugin-update-entry]",
|
|
266
|
+
"[data-hqcg-footprint-entry]",
|
|
267
|
+
"[data-hqcg-symbol-entry]",
|
|
268
|
+
"[data-hqcg-schematic-entry]",
|
|
269
|
+
"[data-hq-dummy-entry]"
|
|
270
|
+
];
|
|
87
271
|
/**
|
|
88
272
|
* Read the host UI language from `<html lang>`.
|
|
89
273
|
*
|
|
@@ -100,6 +284,44 @@
|
|
|
100
284
|
const primary = tag.toLowerCase().split("-")[0];
|
|
101
285
|
return primary === "zh" || primary === "en" ? primary : void 0;
|
|
102
286
|
}
|
|
287
|
+
/**
|
|
288
|
+
* Watch `<html lang>` and report every resolved change.
|
|
289
|
+
*
|
|
290
|
+
* Idempotent-safe: repeated calls share one observer and each caller gets its
|
|
291
|
+
* own disposer; the observer disconnects when the last listener leaves.
|
|
292
|
+
* @param listener - called with the newly resolved language.
|
|
293
|
+
* @returns disposer removing this listener.
|
|
294
|
+
*/
|
|
295
|
+
/** Shared observer/listener set backing {@link observeHostLocale}. */
|
|
296
|
+
const localeListeners = /* @__PURE__ */ new Set();
|
|
297
|
+
let localeObserver = null;
|
|
298
|
+
function observeHostLocale(listener) {
|
|
299
|
+
if (typeof document === "undefined" || typeof MutationObserver === "undefined" || document.documentElement === null) return () => {};
|
|
300
|
+
let current = detectHostLocale();
|
|
301
|
+
const listeners = localeListeners;
|
|
302
|
+
listeners.add(listener);
|
|
303
|
+
if (localeObserver === null) {
|
|
304
|
+
localeObserver = new MutationObserver(() => {
|
|
305
|
+
const next = detectHostLocale();
|
|
306
|
+
if (next === current) return;
|
|
307
|
+
current = next;
|
|
308
|
+
for (const fn of [...listeners]) try {
|
|
309
|
+
fn(next);
|
|
310
|
+
} catch {}
|
|
311
|
+
});
|
|
312
|
+
localeObserver.observe(document.documentElement, {
|
|
313
|
+
attributes: true,
|
|
314
|
+
attributeFilter: ["lang"]
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
return () => {
|
|
318
|
+
listeners.delete(listener);
|
|
319
|
+
if (listeners.size === 0 && localeObserver !== null) {
|
|
320
|
+
localeObserver.disconnect();
|
|
321
|
+
localeObserver = null;
|
|
322
|
+
}
|
|
323
|
+
};
|
|
324
|
+
}
|
|
103
325
|
//#endregion
|
|
104
326
|
//#region src/client/schematic-ref.ts
|
|
105
327
|
/**
|
|
@@ -145,11 +367,11 @@
|
|
|
145
367
|
* Every line is `console.debug` and every helper is wrapped, so a host without
|
|
146
368
|
* a console (or one that rejects the payload) can never break the plugin.
|
|
147
369
|
*/
|
|
148
|
-
const TRACE = "[schematic-gen:ref]";
|
|
370
|
+
const TRACE$1 = "[schematic-gen:ref]";
|
|
149
371
|
/** Emit one trace line. Never throws. */
|
|
150
|
-
function trace(event, payload) {
|
|
372
|
+
function trace$1(event, payload) {
|
|
151
373
|
try {
|
|
152
|
-
console.debug(TRACE, event, payload ?? "");
|
|
374
|
+
console.debug(TRACE$1, event, payload ?? "");
|
|
153
375
|
} catch {}
|
|
154
376
|
}
|
|
155
377
|
/**
|
|
@@ -162,7 +384,7 @@
|
|
|
162
384
|
*/
|
|
163
385
|
function schematicGenLabel(lang = detectHostLocale()) {
|
|
164
386
|
const label = lang === "en" ? "Generate schematic" : "生成原理图";
|
|
165
|
-
trace("label", {
|
|
387
|
+
trace$1("label", {
|
|
166
388
|
hostLang: lang ?? null,
|
|
167
389
|
htmlLang: readHtmlLang(),
|
|
168
390
|
label
|
|
@@ -212,7 +434,7 @@
|
|
|
212
434
|
label: schematicGenLabel(lang),
|
|
213
435
|
clipboardText: SCHEMATIC_GEN_CLIPBOARD
|
|
214
436
|
};
|
|
215
|
-
trace("insert", insert);
|
|
437
|
+
trace$1("insert", insert);
|
|
216
438
|
return insert;
|
|
217
439
|
}
|
|
218
440
|
/**
|
|
@@ -239,12 +461,12 @@
|
|
|
239
461
|
name: schematicGenLabel(lang),
|
|
240
462
|
description: schematicGenDescription(lang)
|
|
241
463
|
}];
|
|
242
|
-
trace("candidates", rows);
|
|
464
|
+
trace$1("candidates", rows);
|
|
243
465
|
return Promise.resolve(rows);
|
|
244
466
|
},
|
|
245
467
|
onPick: () => {
|
|
246
468
|
const insert = schematicGenInsert(getLang());
|
|
247
|
-
trace("onPick", insert);
|
|
469
|
+
trace$1("onPick", insert);
|
|
248
470
|
return { insert };
|
|
249
471
|
},
|
|
250
472
|
codec: {
|
|
@@ -273,6 +495,449 @@
|
|
|
273
495
|
}
|
|
274
496
|
}
|
|
275
497
|
//#endregion
|
|
498
|
+
//#region src/client/sidebar-entry.ts
|
|
499
|
+
/**
|
|
500
|
+
* `@huaqiu/dsh-tool-kicad-schematic-gen` — the `Schematic Gen` sidebar toolbutton.
|
|
501
|
+
*
|
|
502
|
+
* Migrated from the retired `@huaqiu/dsh-tool-schematic-gen` plugin: the
|
|
503
|
+
* migration deleted the whole old package and this row went with it, which is
|
|
504
|
+
* why the sidebar showed only Plugin Updates / Footprint Gen / Symbol Gen while
|
|
505
|
+
* the plugin itself stayed installed and registered. The row, its ordering, its
|
|
506
|
+
* icon and its localization are restored verbatim.
|
|
507
|
+
*
|
|
508
|
+
* Mounts one row through the shared family helper
|
|
509
|
+
* (`@huaqiu/dsh-sidebar-entry`), in the same block and with the same look as
|
|
510
|
+
* the component-gen rows (封装生成 / Symbol 生成). Unlike those, clicking this
|
|
511
|
+
* row does NOT open a workspace: it inserts a `Generate schematic` reference
|
|
512
|
+
* chip into the current session's composer.
|
|
513
|
+
*
|
|
514
|
+
* The row is plain DOM; the chip it inserts is real DSH composer state.
|
|
515
|
+
*
|
|
516
|
+
* @module
|
|
517
|
+
*/
|
|
518
|
+
/** Stable data attribute identifying the injected row. */
|
|
519
|
+
const SCHEMATIC_ENTRY_SELECTOR = "[data-hqcg-schematic-entry]";
|
|
520
|
+
/** Full attribute name (the helper's idempotency key). */
|
|
521
|
+
const SCHEMATIC_ENTRY_ATTRIBUTE = "data-hqcg-schematic-entry";
|
|
522
|
+
/** L2 semantic plugin id: rows carry data-dsh-plugin + data-dsh-part. */
|
|
523
|
+
const PLUGIN_ID = "huaqiu-schematic-gen";
|
|
524
|
+
/** Row class names (defined by {@link ENTRY_CSS_TEXT}). */
|
|
525
|
+
const ENTRY_CSS = {
|
|
526
|
+
entry: "hqcg-schematic-sidebar-entry",
|
|
527
|
+
entryIcon: "hqcg-schematic-sidebar-entry__icon",
|
|
528
|
+
entryLabel: "hqcg-schematic-sidebar-entry__label"
|
|
529
|
+
};
|
|
530
|
+
/** Localized row copy: zh is the fallback, matching the HIT card. */
|
|
531
|
+
const COPY = {
|
|
532
|
+
zh: {
|
|
533
|
+
label: "原理图生成",
|
|
534
|
+
tooltip: "在输入框插入「生成原理图」引用"
|
|
535
|
+
},
|
|
536
|
+
en: {
|
|
537
|
+
label: "Schematic Gen",
|
|
538
|
+
tooltip: "Insert a \"Generate schematic\" reference into the composer"
|
|
539
|
+
}
|
|
540
|
+
};
|
|
541
|
+
/**
|
|
542
|
+
* Resolve the row copy for one language.
|
|
543
|
+
* @param lang - resolved host language; undefined falls back to zh.
|
|
544
|
+
* @returns the label/tooltip pair.
|
|
545
|
+
*/
|
|
546
|
+
function schematicEntryCopy(lang) {
|
|
547
|
+
return lang === "en" ? COPY.en : COPY.zh;
|
|
548
|
+
}
|
|
549
|
+
/**
|
|
550
|
+
* Inline schematic glyph — cropped to the artwork's ink, `currentColor` so it
|
|
551
|
+
* inherits the sidebar text/theme colour, 16×16 like the shell's nav glyphs
|
|
552
|
+
* (18×18 on the collapsed rail).
|
|
553
|
+
*/
|
|
554
|
+
const SCHEMATIC_ICON = `
|
|
555
|
+
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
556
|
+
<!-- Schematic Sheet Frame -->
|
|
557
|
+
<rect x="3" y="3" width="18" height="18" rx="2" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
|
558
|
+
|
|
559
|
+
<!-- Circuit Wires & Connection Nodes -->
|
|
560
|
+
<path d="M7 8H10M14 8H17" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
|
561
|
+
<path d="M10 16H14" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
|
562
|
+
<path d="M10 8V16" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
|
563
|
+
|
|
564
|
+
<!-- Component Symbol (Resistor / Block representation) -->
|
|
565
|
+
<path d="M10 10L11 11L12 9L13 11L14 10" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
|
566
|
+
|
|
567
|
+
<!-- Junction Nodes / Pins -->
|
|
568
|
+
<circle cx="7" cy="8" r="1.5" fill="currentColor"/>
|
|
569
|
+
<circle cx="17" cy="8" r="1.5" fill="currentColor"/>
|
|
570
|
+
<circle cx="10" cy="16" r="1.5" fill="currentColor"/>
|
|
571
|
+
<circle cx="14" cy="16" r="1.5" fill="currentColor"/>
|
|
572
|
+
</svg>
|
|
573
|
+
`;
|
|
574
|
+
/**
|
|
575
|
+
* Row styles — matched to the shipped `sidebar.panellist` rows and to the
|
|
576
|
+
* component-gen rows, so the family block sits flush with the shell's own
|
|
577
|
+
* entries.
|
|
578
|
+
*/
|
|
579
|
+
const ENTRY_CSS_TEXT = `
|
|
580
|
+
.hqcg-schematic-sidebar-entry {
|
|
581
|
+
box-sizing: border-box;
|
|
582
|
+
display: flex;
|
|
583
|
+
align-items: center;
|
|
584
|
+
gap: 8px;
|
|
585
|
+
width: 100%;
|
|
586
|
+
margin: 0 2px;
|
|
587
|
+
min-height: 36px;
|
|
588
|
+
padding: 7px 8px;
|
|
589
|
+
background: transparent;
|
|
590
|
+
border: none;
|
|
591
|
+
border-radius: 12px;
|
|
592
|
+
color: var(--dsw-alias-label-primary);
|
|
593
|
+
cursor: pointer;
|
|
594
|
+
font: inherit;
|
|
595
|
+
line-height: 22px;
|
|
596
|
+
text-align: left;
|
|
597
|
+
white-space: nowrap;
|
|
598
|
+
}
|
|
599
|
+
.hqcg-schematic-sidebar-entry:hover {
|
|
600
|
+
background: var(--dsw-alias-interactive-bg-hover);
|
|
601
|
+
}
|
|
602
|
+
.hqcg-schematic-sidebar-entry[data-active] {
|
|
603
|
+
background: var(--dsw-alias-interactive-bg-hover);
|
|
604
|
+
color: var(--dsw-alias-label-primary);
|
|
605
|
+
}
|
|
606
|
+
.hqcg-schematic-sidebar-entry__icon {
|
|
607
|
+
display: inline-flex;
|
|
608
|
+
align-items: center;
|
|
609
|
+
justify-content: center;
|
|
610
|
+
flex: none;
|
|
611
|
+
}
|
|
612
|
+
.hqcg-schematic-sidebar-entry__icon svg {
|
|
613
|
+
display: block;
|
|
614
|
+
width: 16px;
|
|
615
|
+
height: 16px;
|
|
616
|
+
}
|
|
617
|
+
.hqcg-schematic-sidebar-entry__label {
|
|
618
|
+
min-width: 0;
|
|
619
|
+
overflow: hidden;
|
|
620
|
+
text-overflow: ellipsis;
|
|
621
|
+
}
|
|
622
|
+
/* Collapsed rail: icon-only, centered — the same 36px square the shell uses. */
|
|
623
|
+
[data-dsh-frame][data-sidebar-collapsed] .hqcg-schematic-sidebar-entry,
|
|
624
|
+
[data-sidebar-collapsed] .hqcg-schematic-sidebar-entry {
|
|
625
|
+
justify-content: center;
|
|
626
|
+
width: 36px;
|
|
627
|
+
height: 36px;
|
|
628
|
+
margin: 0;
|
|
629
|
+
padding: 0;
|
|
630
|
+
}
|
|
631
|
+
[data-dsh-frame][data-sidebar-collapsed] .hqcg-schematic-sidebar-entry__icon svg,
|
|
632
|
+
[data-sidebar-collapsed] .hqcg-schematic-sidebar-entry__icon svg {
|
|
633
|
+
width: 18px;
|
|
634
|
+
height: 18px;
|
|
635
|
+
}
|
|
636
|
+
[data-dsh-frame][data-sidebar-collapsed] .hqcg-schematic-sidebar-entry__label,
|
|
637
|
+
[data-sidebar-collapsed] .hqcg-schematic-sidebar-entry__label {
|
|
638
|
+
display: none;
|
|
639
|
+
}
|
|
640
|
+
`;
|
|
641
|
+
/** Style tag id used for idempotent style injection. */
|
|
642
|
+
const STYLE_TAG_ID = "data-plugin-css=\"huaqiu-schematic-gen-sidebar\"";
|
|
643
|
+
/** Inject the row stylesheet once (idempotent). */
|
|
644
|
+
function injectSchematicEntryStyles() {
|
|
645
|
+
if (typeof document === "undefined") return;
|
|
646
|
+
if (document.querySelector(`style[${STYLE_TAG_ID}]`) !== null) return;
|
|
647
|
+
const tag = document.createElement("style");
|
|
648
|
+
tag.dataset.pluginCss = "huaqiu-schematic-gen-sidebar";
|
|
649
|
+
tag.textContent = ENTRY_CSS_TEXT;
|
|
650
|
+
document.head.appendChild(tag);
|
|
651
|
+
}
|
|
652
|
+
/**
|
|
653
|
+
* Mount the `Schematic Gen` sidebar row.
|
|
654
|
+
*
|
|
655
|
+
* Localization follows the host UI language through `<html lang>`
|
|
656
|
+
* (`dsh-client-locale` rewrites it on every language change and HQ Edge mirrors
|
|
657
|
+
* it), so the row re-labels itself without a `locale` service dependency.
|
|
658
|
+
*
|
|
659
|
+
* @param options - click action and family-block position.
|
|
660
|
+
* @returns disposer removing the row, its observers and its locale listener.
|
|
661
|
+
*/
|
|
662
|
+
function mountSchematicGenSidebarEntry(options) {
|
|
663
|
+
if (typeof document === "undefined") return () => {};
|
|
664
|
+
injectSchematicEntryStyles();
|
|
665
|
+
const listeners = /* @__PURE__ */ new Set();
|
|
666
|
+
const announce = () => {
|
|
667
|
+
for (const fn of [...listeners]) try {
|
|
668
|
+
fn();
|
|
669
|
+
} catch {}
|
|
670
|
+
};
|
|
671
|
+
let lang = detectHostLocale();
|
|
672
|
+
const stopLocale = observeHostLocale((next) => {
|
|
673
|
+
lang = next;
|
|
674
|
+
announce();
|
|
675
|
+
});
|
|
676
|
+
const dispose = mountSidebarEntry({
|
|
677
|
+
rowAttribute: SCHEMATIC_ENTRY_ATTRIBUTE,
|
|
678
|
+
rowSelector: SCHEMATIC_ENTRY_SELECTOR,
|
|
679
|
+
plugin: PLUGIN_ID,
|
|
680
|
+
icon: SCHEMATIC_ICON,
|
|
681
|
+
css: ENTRY_CSS,
|
|
682
|
+
label: () => schematicEntryCopy(lang).label,
|
|
683
|
+
tooltip: () => schematicEntryCopy(lang).tooltip,
|
|
684
|
+
refresh: { subscribe: (fn) => {
|
|
685
|
+
listeners.add(fn);
|
|
686
|
+
return () => {
|
|
687
|
+
listeners.delete(fn);
|
|
688
|
+
};
|
|
689
|
+
} },
|
|
690
|
+
onToggle: options.onToggle,
|
|
691
|
+
position: options.position ?? "after",
|
|
692
|
+
familySelectors: HUAQIU_SIDEBAR_ENTRY_FAMILY
|
|
693
|
+
});
|
|
694
|
+
return () => {
|
|
695
|
+
dispose();
|
|
696
|
+
stopLocale();
|
|
697
|
+
listeners.clear();
|
|
698
|
+
};
|
|
699
|
+
}
|
|
700
|
+
//#endregion
|
|
701
|
+
//#region src/client/composer-chip.ts
|
|
702
|
+
/**
|
|
703
|
+
* `@huaqiu/dsh-tool-kicad-schematic-gen` — programmatic composer chip insertion.
|
|
704
|
+
*
|
|
705
|
+
* Migrated from the retired `@huaqiu/dsh-tool-schematic-gen` plugin, whose
|
|
706
|
+
* sidebar row was lost with the package. The row is restored together with the
|
|
707
|
+
* path that makes it do something: inserting the `@ Generate schematic` chip
|
|
708
|
+
* into the live composer.
|
|
709
|
+
*
|
|
710
|
+
* The public path is `ctx.conversation.input.for(actx).insertReference(ref,
|
|
711
|
+
* span)` — a verb on DSH's frozen `SessionInput` face, reached through two
|
|
712
|
+
* public services (`conversation`, `sessions`). No DOM forgery, no new public
|
|
713
|
+
* API, no DSH source change.
|
|
714
|
+
*
|
|
715
|
+
* Two coordinate systems are in play and mixing them silently misplaces the
|
|
716
|
+
* chip:
|
|
717
|
+
*
|
|
718
|
+
* - `InputState.draft` and `Occurrence.offset|length` are CLIPBOARD
|
|
719
|
+
* coordinates — a chip occupies its whole `clipboardText`.
|
|
720
|
+
* - `TokenSpan` is DETECT coordinates — a chip is exactly one `U+FFFC`.
|
|
721
|
+
*
|
|
722
|
+
* `insertReference` is span-CAS'd (`span.draftRev !== this.rev → false`) and
|
|
723
|
+
* refuses busy phases, so every call reads a fresh snapshot and retries once.
|
|
724
|
+
*
|
|
725
|
+
* @module
|
|
726
|
+
*/
|
|
727
|
+
/** Browser-console tag for the insertion path (see schematic-ref.ts). */
|
|
728
|
+
const TRACE = "[schematic-gen:chip]";
|
|
729
|
+
/** Emit one trace line. Never throws. */
|
|
730
|
+
function trace(event, payload) {
|
|
731
|
+
try {
|
|
732
|
+
console.debug(TRACE, event, payload ?? "");
|
|
733
|
+
} catch {}
|
|
734
|
+
}
|
|
735
|
+
/**
|
|
736
|
+
* The Session whose composer the user is looking at.
|
|
737
|
+
*
|
|
738
|
+
* `SessionListState` has no `current` field — reading one silently yields
|
|
739
|
+
* undefined and the insertion aborts before touching the draft. The main view
|
|
740
|
+
* is whichever row `retainedBy.mainView` counts, so that is the primary
|
|
741
|
+
* answer; host list order and then any known row are the fallbacks.
|
|
742
|
+
*
|
|
743
|
+
* @param sessions - `ctx.get('sessions')`, or undefined.
|
|
744
|
+
* @returns the Session id, or undefined when the host has none yet.
|
|
745
|
+
*/
|
|
746
|
+
function currentSessionId(sessions) {
|
|
747
|
+
let list;
|
|
748
|
+
try {
|
|
749
|
+
list = sessions?.list?.getSnapshot();
|
|
750
|
+
} catch {
|
|
751
|
+
list = void 0;
|
|
752
|
+
}
|
|
753
|
+
if (list === void 0 || list === null) return void 0;
|
|
754
|
+
const ids = Array.isArray(list.ids) ? list.ids : [];
|
|
755
|
+
for (const id of ids) if ((list.byId?.[id]?.retainedBy?.mainView ?? 0) > 0) return id;
|
|
756
|
+
if (ids.length > 0) return ids[0];
|
|
757
|
+
return Object.keys(list.byId ?? {})[0];
|
|
758
|
+
}
|
|
759
|
+
/** How many Sessions the host knows about — the only context a `no-session` trace needs. */
|
|
760
|
+
function countSessions(sessions) {
|
|
761
|
+
try {
|
|
762
|
+
const list = sessions?.list?.getSnapshot();
|
|
763
|
+
const ids = Array.isArray(list?.ids) ? list?.ids.length ?? 0 : 0;
|
|
764
|
+
return ids > 0 ? ids : Object.keys(list?.byId ?? {}).length;
|
|
765
|
+
} catch {
|
|
766
|
+
return 0;
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
/**
|
|
770
|
+
* Resolve one Session's scope context — the value `conversation.input.for()`
|
|
771
|
+
* needs.
|
|
772
|
+
*
|
|
773
|
+
* `binding(id).ctx` is the ONLY guard-safe path: DSH's dynamic-plugin facade
|
|
774
|
+
* rejects every service call whose result is a cordis Context
|
|
775
|
+
* (`sessions.scope()` → "service \"sessions\" returned a cordis Context, which
|
|
776
|
+
* the dynamic facade does not expose"), and that rejection THROWS. `binding()`
|
|
777
|
+
* returns a plain object, so its `ctx` field is reachable without going back
|
|
778
|
+
* through the facade. `scope()` stays as a last resort for a host whose
|
|
779
|
+
* binding carries no ctx.
|
|
780
|
+
*
|
|
781
|
+
* @param sessions - `ctx.get('sessions')`, or undefined.
|
|
782
|
+
* @param sessionId - target Session.
|
|
783
|
+
* @returns the scope context, or undefined.
|
|
784
|
+
*/
|
|
785
|
+
function resolveSessionScope(sessions, sessionId) {
|
|
786
|
+
let binding;
|
|
787
|
+
try {
|
|
788
|
+
binding = sessions?.binding?.(sessionId);
|
|
789
|
+
} catch {
|
|
790
|
+
binding = void 0;
|
|
791
|
+
}
|
|
792
|
+
const ctx = binding?.ctx;
|
|
793
|
+
if (ctx !== void 0 && ctx !== null) return ctx;
|
|
794
|
+
try {
|
|
795
|
+
return sessions?.scope?.(sessionId);
|
|
796
|
+
} catch {
|
|
797
|
+
return;
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
/**
|
|
801
|
+
* Translate a clipboard-projected draft length into DETECT coordinates.
|
|
802
|
+
*
|
|
803
|
+
* Every existing chip shrinks from `length` clipboard characters to one detect
|
|
804
|
+
* character, so the detect length is the clipboard length minus the sum of
|
|
805
|
+
* `length - 1` over all occurrences. A draft with no chips is unchanged.
|
|
806
|
+
*
|
|
807
|
+
* @param state - the fresh input snapshot.
|
|
808
|
+
* @returns the detect-coordinate length of the whole draft.
|
|
809
|
+
*/
|
|
810
|
+
function detectLengthOf(state) {
|
|
811
|
+
const draft = typeof state.draft === "string" ? state.draft : "";
|
|
812
|
+
const occurrences = Array.isArray(state.occurrences) ? state.occurrences : [];
|
|
813
|
+
let shrink = 0;
|
|
814
|
+
for (const occurrence of occurrences) {
|
|
815
|
+
const length = typeof occurrence.length === "number" ? occurrence.length : 0;
|
|
816
|
+
if (length > 1) shrink += length - 1;
|
|
817
|
+
}
|
|
818
|
+
return Math.max(0, draft.length - shrink);
|
|
819
|
+
}
|
|
820
|
+
/** Whether the draft already carries our chip. */
|
|
821
|
+
function hasSchematicChip(state) {
|
|
822
|
+
return (Array.isArray(state.occurrences) ? state.occurrences : []).some((o) => o.source === "schematic-gen" && o.ref === "generate");
|
|
823
|
+
}
|
|
824
|
+
/** Phases in which `insertReference` refuses to edit. */
|
|
825
|
+
const BUSY_PHASES = ["adjudicating", "submitting"];
|
|
826
|
+
/**
|
|
827
|
+
* Insert the `Generate schematic` chip at the end of the current draft.
|
|
828
|
+
*
|
|
829
|
+
* Never throws and never partially edits: every failure leaves the draft
|
|
830
|
+
* exactly as it was.
|
|
831
|
+
*
|
|
832
|
+
* @param deps - lazy service accessors (resolved per call, never captured).
|
|
833
|
+
* @param insert - the reference to insert (defaults to the schematic-gen one,
|
|
834
|
+
* whose label is resolved against the host language at call
|
|
835
|
+
* time — so the chip matches the UI language, not a frozen
|
|
836
|
+
* English literal).
|
|
837
|
+
* @returns whether a chip landed, and why not when it did not.
|
|
838
|
+
*/
|
|
839
|
+
function insertSchematicChip(deps, insert = schematicGenInsert()) {
|
|
840
|
+
const sessions = deps.getSessions();
|
|
841
|
+
const conversation = deps.getConversation();
|
|
842
|
+
if (sessions === void 0 || conversation === void 0 || conversation.input === void 0) {
|
|
843
|
+
trace("unavailable", {
|
|
844
|
+
hasSessions: sessions !== void 0,
|
|
845
|
+
hasConversation: conversation !== void 0
|
|
846
|
+
});
|
|
847
|
+
return {
|
|
848
|
+
ok: false,
|
|
849
|
+
reason: "unavailable"
|
|
850
|
+
};
|
|
851
|
+
}
|
|
852
|
+
const sessionId = currentSessionId(sessions);
|
|
853
|
+
if (sessionId === void 0 || sessionId === null || sessionId === "") {
|
|
854
|
+
trace("no-session", { known: countSessions(sessions) });
|
|
855
|
+
return {
|
|
856
|
+
ok: false,
|
|
857
|
+
reason: "no-session"
|
|
858
|
+
};
|
|
859
|
+
}
|
|
860
|
+
const actx = resolveSessionScope(sessions, sessionId);
|
|
861
|
+
if (actx === void 0 || actx === null) {
|
|
862
|
+
trace("no-scope", { sessionId });
|
|
863
|
+
return {
|
|
864
|
+
ok: false,
|
|
865
|
+
reason: "no-session"
|
|
866
|
+
};
|
|
867
|
+
}
|
|
868
|
+
let input;
|
|
869
|
+
try {
|
|
870
|
+
input = conversation.input.for(actx);
|
|
871
|
+
} catch (err) {
|
|
872
|
+
trace("for() threw", err instanceof Error ? err.message : err);
|
|
873
|
+
input = void 0;
|
|
874
|
+
}
|
|
875
|
+
if (input === void 0 || input === null || typeof input.insertReference !== "function") {
|
|
876
|
+
trace("unavailable", { hasInput: input !== void 0 && input !== null });
|
|
877
|
+
return {
|
|
878
|
+
ok: false,
|
|
879
|
+
reason: "unavailable"
|
|
880
|
+
};
|
|
881
|
+
}
|
|
882
|
+
trace("inserting", {
|
|
883
|
+
label: insert.label,
|
|
884
|
+
clipboardText: insert.clipboardText
|
|
885
|
+
});
|
|
886
|
+
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
887
|
+
const state = input.state?.getSnapshot() ?? {};
|
|
888
|
+
if (hasSchematicChip(state)) {
|
|
889
|
+
trace("already-present");
|
|
890
|
+
return {
|
|
891
|
+
ok: false,
|
|
892
|
+
reason: "already-present"
|
|
893
|
+
};
|
|
894
|
+
}
|
|
895
|
+
const phase = state.phase ?? "plain";
|
|
896
|
+
if (BUSY_PHASES.includes(phase)) {
|
|
897
|
+
trace("busy", { phase });
|
|
898
|
+
return {
|
|
899
|
+
ok: false,
|
|
900
|
+
reason: "busy"
|
|
901
|
+
};
|
|
902
|
+
}
|
|
903
|
+
const draftRev = typeof state.draftRev === "number" ? state.draftRev : 0;
|
|
904
|
+
const end = detectLengthOf(state);
|
|
905
|
+
let applied = false;
|
|
906
|
+
try {
|
|
907
|
+
applied = input.insertReference(insert, {
|
|
908
|
+
start: end,
|
|
909
|
+
end,
|
|
910
|
+
draftRev
|
|
911
|
+
});
|
|
912
|
+
} catch (err) {
|
|
913
|
+
trace("insertReference threw", err instanceof Error ? err.message : err);
|
|
914
|
+
applied = false;
|
|
915
|
+
}
|
|
916
|
+
if (applied) {
|
|
917
|
+
trace("inserted", {
|
|
918
|
+
label: insert.label,
|
|
919
|
+
span: {
|
|
920
|
+
start: end,
|
|
921
|
+
end,
|
|
922
|
+
draftRev
|
|
923
|
+
}
|
|
924
|
+
});
|
|
925
|
+
try {
|
|
926
|
+
input.focus?.();
|
|
927
|
+
} catch (err) {
|
|
928
|
+
trace("focus threw", err instanceof Error ? err.message : err);
|
|
929
|
+
}
|
|
930
|
+
return { ok: true };
|
|
931
|
+
}
|
|
932
|
+
trace("cas-miss", { attempt });
|
|
933
|
+
}
|
|
934
|
+
trace("conflict");
|
|
935
|
+
return {
|
|
936
|
+
ok: false,
|
|
937
|
+
reason: "conflict"
|
|
938
|
+
};
|
|
939
|
+
}
|
|
940
|
+
//#endregion
|
|
276
941
|
//#region src/client/index.ts
|
|
277
942
|
/** Harness compacts finished tool calls; keep only our delivery card visible. */
|
|
278
943
|
function keepDeliveryVisible() {
|
|
@@ -465,7 +1130,32 @@
|
|
|
465
1130
|
apply: (ctx) => {
|
|
466
1131
|
getHost = () => ctx.get?.("hqEdge");
|
|
467
1132
|
const stopVisibility = keepDeliveryVisible();
|
|
468
|
-
const
|
|
1133
|
+
const triggers = ctx.get?.("inputTriggers");
|
|
1134
|
+
try {
|
|
1135
|
+
console.debug("[schematic-gen:client] apply", {
|
|
1136
|
+
inputTriggers: triggers !== void 0 && triggers !== null,
|
|
1137
|
+
slots: typeof ctx.slots?.inject === "function"
|
|
1138
|
+
});
|
|
1139
|
+
} catch {}
|
|
1140
|
+
const stopRefs = registerSchematicGenSource(triggers);
|
|
1141
|
+
const chipDeps = {
|
|
1142
|
+
getConversation: () => ctx.get?.("conversation"),
|
|
1143
|
+
getSessions: () => ctx.get?.("sessions")
|
|
1144
|
+
};
|
|
1145
|
+
const stopEntry = mountSchematicGenSidebarEntry({
|
|
1146
|
+
onToggle: () => {
|
|
1147
|
+
const result = insertSchematicChip(chipDeps);
|
|
1148
|
+
try {
|
|
1149
|
+
console.debug("[schematic-gen:chip] click", result);
|
|
1150
|
+
} catch {}
|
|
1151
|
+
if (result.ok || result.reason === "already-present") return;
|
|
1152
|
+
notifyComposer(ctx, result.reason);
|
|
1153
|
+
},
|
|
1154
|
+
position: "after"
|
|
1155
|
+
});
|
|
1156
|
+
try {
|
|
1157
|
+
console.debug("[schematic-gen:client] sidebar row", { rows: document.querySelectorAll("[data-hqcg-schematic-entry]").length });
|
|
1158
|
+
} catch {}
|
|
469
1159
|
const dispose = ctx.slots.inject("tool.call.toolview", () => ctx.slots.register({
|
|
470
1160
|
name: "tool.call.toolview",
|
|
471
1161
|
key: "render_kicad_module_preview"
|
|
@@ -473,10 +1163,33 @@
|
|
|
473
1163
|
ctx.effect?.(() => () => {
|
|
474
1164
|
stopVisibility();
|
|
475
1165
|
stopRefs();
|
|
1166
|
+
stopEntry();
|
|
476
1167
|
if (typeof dispose === "function") dispose();
|
|
477
1168
|
});
|
|
478
1169
|
}
|
|
479
1170
|
};
|
|
1171
|
+
/** Surface why a chip insertion did not happen, through the composer's own notice channel. Best-effort by design. */
|
|
1172
|
+
function notifyComposer(context, reason) {
|
|
1173
|
+
try {
|
|
1174
|
+
const sessions = context.get?.("sessions");
|
|
1175
|
+
const conversation = context.get?.("conversation");
|
|
1176
|
+
if (sessions === void 0 || conversation?.input === void 0) return;
|
|
1177
|
+
const sessionId = currentSessionId(sessions);
|
|
1178
|
+
if (!sessionId) return;
|
|
1179
|
+
const actx = resolveSessionScope(sessions, sessionId);
|
|
1180
|
+
if (actx === void 0 || actx === null) return;
|
|
1181
|
+
conversation.input.for(actx)?.notify?.("info", noticeFor(reason));
|
|
1182
|
+
} catch {}
|
|
1183
|
+
}
|
|
1184
|
+
/** Human-readable notice text for one insertion failure reason. */
|
|
1185
|
+
function noticeFor(reason) {
|
|
1186
|
+
switch (reason) {
|
|
1187
|
+
case "no-session": return "Open a conversation first, then click Schematic Gen again.";
|
|
1188
|
+
case "busy": return "The composer is busy — try again once the current message settles.";
|
|
1189
|
+
case "conflict": return "The composer changed while inserting — click Schematic Gen again.";
|
|
1190
|
+
default: return "Schematic Gen is unavailable in this session.";
|
|
1191
|
+
}
|
|
1192
|
+
}
|
|
480
1193
|
}
|
|
481
1194
|
});
|
|
482
1195
|
//#endregion
|