@huaqiu/dsh-sidebar-entry 0.0.0-stage → 0.5.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +47 -2
- package/lib/index.d.mts +98 -0
- package/lib/index.mjs +191 -0
- package/package.json +26 -3
- package/src/index.ts +311 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 深圳华秋智联股份有限公司
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,48 @@
|
|
|
1
|
-
#
|
|
1
|
+
# `@huaqiu/dsh-sidebar-entry`
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
One shared sidebar toolbutton row for the Huaqiu DSH plugin family.
|
|
4
|
+
|
|
5
|
+
DSH's sidebar shell exposes no slot a third-party plugin can register into at
|
|
6
|
+
the position between **New Session** and the workspace browser, so this package
|
|
7
|
+
injects a plain-DOM row there and keeps it there:
|
|
8
|
+
|
|
9
|
+
- **DOM-level idempotency** — a second `mountSidebarEntry` for the same row
|
|
10
|
+
attribute is a no-op, so HMR / duplicated `apply()` never double-mount.
|
|
11
|
+
- **Self-healing** — a `MutationObserver` re-inserts the row in the same frame
|
|
12
|
+
whenever a React re-render displaces it (no flicker), and re-queries from
|
|
13
|
+
scratch when the shell tears the whole pane down.
|
|
14
|
+
- **Family ordering** — every participating plugin passes the same
|
|
15
|
+
`familySelectors` list, so rows keep a stable relative order regardless of
|
|
16
|
+
observer callback order.
|
|
17
|
+
- **Clean disposal** — observers, subscriptions and the row itself are removed.
|
|
18
|
+
|
|
19
|
+
## Usage
|
|
20
|
+
|
|
21
|
+
```ts
|
|
22
|
+
import { mountSidebarEntry, observeHostLocale, detectHostLocale } from '@huaqiu/dsh-sidebar-entry'
|
|
23
|
+
|
|
24
|
+
let lang = detectHostLocale()
|
|
25
|
+
const stopLocale = observeHostLocale((next) => { lang = next; /* announce */ })
|
|
26
|
+
|
|
27
|
+
const dispose = mountSidebarEntry({
|
|
28
|
+
rowAttribute: 'data-hqcg-schematic-entry',
|
|
29
|
+
rowSelector: '[data-hqcg-schematic-entry]',
|
|
30
|
+
plugin: 'huaqiu-schematic-gen',
|
|
31
|
+
icon: '<svg …/>',
|
|
32
|
+
css: { entry: '…', entryIcon: '…', entryLabel: '…' },
|
|
33
|
+
label: () => (lang === 'en' ? 'Schematic Gen' : '原理图生成'),
|
|
34
|
+
tooltip: () => (lang === 'en' ? 'Insert a schematic-generation reference' : '插入原理图生成引用'),
|
|
35
|
+
refresh: { subscribe: (fn) => { /* … */ return () => {} } },
|
|
36
|
+
onToggle: () => { /* your action */ },
|
|
37
|
+
position: 'after',
|
|
38
|
+
familySelectors: ['[data-hqcg-footprint-entry]', '[data-hqcg-symbol-entry]'],
|
|
39
|
+
})
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## Notes
|
|
43
|
+
|
|
44
|
+
- The row renders `data-dsh-plugin="<plugin>"` and `data-dsh-part="sidebar-entry"`
|
|
45
|
+
when `plugin` is set (L2 semantic attributes).
|
|
46
|
+
- Do **not** swap this for a `sidebar.panellist` entry unless you also register
|
|
47
|
+
a matching `main` central panel: `SidebarRoot`'s `PanelRow` calls
|
|
48
|
+
`layout.selectPanel(id)`, which throws when no main panel uses that id.
|
package/lib/index.d.mts
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
//#region src/index.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* `@huaqiu/dsh-sidebar-entry` — one shared sidebar entry row for the Huaqiu
|
|
4
|
+
* DSH plugin family.
|
|
5
|
+
*
|
|
6
|
+
* Adapted from dsh-web's `shared/client/sidebar-entry-core.ts` (used by
|
|
7
|
+
* dsh-task-board / dsh-skill-explorer / dsh-ssh). It lives in its own package
|
|
8
|
+
* so every Huaqiu plugin that needs a sidebar toolbutton shares ONE copy of
|
|
9
|
+
* the injection policy instead of forking it.
|
|
10
|
+
*
|
|
11
|
+
* dsh's sidebar shell exposes no slot an external plugin can register into at
|
|
12
|
+
* this position (between the New Session button and the workspace browser), so
|
|
13
|
+
* the entry row is injected into the live DOM. The injection self-heals: a
|
|
14
|
+
* MutationObserver watches the sidebar root and re-inserts the row whenever a
|
|
15
|
+
* React re-render displaces it (re-insertion happens in the same frame, before
|
|
16
|
+
* paint, so no flicker).
|
|
17
|
+
*
|
|
18
|
+
* The row is plain DOM (no React tree) so it can never disturb the shell's
|
|
19
|
+
* reconciliation. What the row DOES on click is entirely the caller's: open a
|
|
20
|
+
* `shell.overlay` workspace (component-gen) or act on the DSH composer
|
|
21
|
+
* (schematic-gen).
|
|
22
|
+
*
|
|
23
|
+
* @module @huaqiu/dsh-sidebar-entry
|
|
24
|
+
*/
|
|
25
|
+
/** Per-package configuration for one sidebar entry row. */
|
|
26
|
+
interface SidebarEntryOptions {
|
|
27
|
+
/** Full attribute name identifying the injected row (idempotency key), e.g. 'data-hqcg-symbol-entry'. */
|
|
28
|
+
rowAttribute: string;
|
|
29
|
+
/** CSS selector matching the injected row, e.g. '[data-hqcg-symbol-entry]'. */
|
|
30
|
+
rowSelector: string;
|
|
31
|
+
/**
|
|
32
|
+
* L2 semantic-attribute plugin id (issue #506). When set, the row also
|
|
33
|
+
* outputs data-dsh-plugin="<id>" and data-dsh-part="sidebar-entry"; unset
|
|
34
|
+
* leaves the row without semantic attributes.
|
|
35
|
+
*/
|
|
36
|
+
plugin?: string;
|
|
37
|
+
/** Inline icon markup (matches the shell's 16px nav-icon look). */
|
|
38
|
+
icon: string;
|
|
39
|
+
/** CSS module class names for the row and its two spans (entry / entryIcon / entryLabel). */
|
|
40
|
+
css: Record<string, string>;
|
|
41
|
+
/** Localized row label (aria-label + visible text). */
|
|
42
|
+
label(): string;
|
|
43
|
+
/** Optional localized tooltip (title attribute). */
|
|
44
|
+
tooltip?(): string;
|
|
45
|
+
/**
|
|
46
|
+
* Optional locale-change subscription: re-applies label / aria-label /
|
|
47
|
+
* tooltip whenever the active locale changes.
|
|
48
|
+
*/
|
|
49
|
+
refresh?: {
|
|
50
|
+
subscribe(listener: () => void): () => void;
|
|
51
|
+
};
|
|
52
|
+
/** Click action (open/toggle the owning panel). */
|
|
53
|
+
onToggle(): void;
|
|
54
|
+
/** Family-block position: 'before' inserts ahead of sibling plugin rows, 'after' behind them. */
|
|
55
|
+
position: 'before' | 'after';
|
|
56
|
+
/**
|
|
57
|
+
* Selectors of the sibling plugin entry rows this package orders against
|
|
58
|
+
* (its own row included). Each package passes the same list so the rendered
|
|
59
|
+
* order stays stable across re-renders.
|
|
60
|
+
*/
|
|
61
|
+
familySelectors: readonly string[];
|
|
62
|
+
/** Optional active-state bridge; highlights the row while the panel is open. */
|
|
63
|
+
active?: {
|
|
64
|
+
subscribe(listener: () => void): () => void;
|
|
65
|
+
isOpen(): boolean;
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Mount the sidebar entry, waiting for the shell to render and self-healing
|
|
70
|
+
* on later React re-renders.
|
|
71
|
+
* @param options - the row's attribute/icon/copy/action/ordering configuration.
|
|
72
|
+
* @returns disposer removing the entry and its observers.
|
|
73
|
+
*/
|
|
74
|
+
declare function mountSidebarEntry(options: SidebarEntryOptions): () => void;
|
|
75
|
+
/**
|
|
76
|
+
* Every row selector in the Huaqiu sidebar family, in intended visual order.
|
|
77
|
+
*
|
|
78
|
+
* Family ordering is only stable when every participating plugin passes the
|
|
79
|
+
* SAME list to {@link mountSidebarEntry} — the core positions a row relative to
|
|
80
|
+
* the family block, never relative to transient shell geometry. Adding a new
|
|
81
|
+
* family row means adding its selector here (and to no plugin-local list).
|
|
82
|
+
*/
|
|
83
|
+
declare const HUAQIU_SIDEBAR_ENTRY_FAMILY: readonly string[];
|
|
84
|
+
/** Host UI languages this family localizes into. */
|
|
85
|
+
type HostLocale = 'zh' | 'en';
|
|
86
|
+
/**
|
|
87
|
+
* Read the host UI language from `<html lang>`.
|
|
88
|
+
*
|
|
89
|
+
* `dsh-client-locale` rewrites the attribute on every language change (dsh
|
|
90
|
+
* web) and HQ Edge mirrors it, so this is the one source every family plugin
|
|
91
|
+
* can read without importing a `locale` service.
|
|
92
|
+
* @returns 'zh' / 'en', or undefined when the tag is absent or unsupported
|
|
93
|
+
* (callers fall back to their own default).
|
|
94
|
+
*/
|
|
95
|
+
declare function detectHostLocale(): HostLocale | undefined;
|
|
96
|
+
declare function observeHostLocale(listener: (lang: HostLocale | undefined) => void): () => void;
|
|
97
|
+
//#endregion
|
|
98
|
+
export { HUAQIU_SIDEBAR_ENTRY_FAMILY, HostLocale, SidebarEntryOptions, detectHostLocale, mountSidebarEntry, observeHostLocale };
|
package/lib/index.mjs
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
//#region src/index.ts
|
|
2
|
+
/** Find the sidebar shell root element, or undefined while not yet mounted. */
|
|
3
|
+
function sidebarRoot() {
|
|
4
|
+
const column = document.querySelector("[data-pane=\"sidebar\"], [class*=\"sidebarCol\"]");
|
|
5
|
+
if (column === null) return void 0;
|
|
6
|
+
return column.querySelector("[class*=\"logoRow\"]")?.parentElement ?? column.firstElementChild;
|
|
7
|
+
}
|
|
8
|
+
/** The New Session button: nested in the logo row on current shells, a direct child on legacy shells. */
|
|
9
|
+
function newSessionButton(root) {
|
|
10
|
+
const nested = root.querySelector("button[class*=\"newSession\"]");
|
|
11
|
+
if (nested !== null) return nested;
|
|
12
|
+
for (const child of root.children) if (child.tagName === "BUTTON") return child;
|
|
13
|
+
}
|
|
14
|
+
/** Build the entry row (a detached button; insert once the shell is up). */
|
|
15
|
+
function createEntry(options) {
|
|
16
|
+
const entry = document.createElement("button");
|
|
17
|
+
entry.type = "button";
|
|
18
|
+
entry.setAttribute(options.rowAttribute, "");
|
|
19
|
+
if (options.plugin !== void 0) {
|
|
20
|
+
entry.setAttribute("data-dsh-plugin", options.plugin);
|
|
21
|
+
entry.setAttribute("data-dsh-part", "sidebar-entry");
|
|
22
|
+
}
|
|
23
|
+
entry.className = options.css["entry"] ?? "";
|
|
24
|
+
const labelSpan = document.createElement("span");
|
|
25
|
+
labelSpan.className = options.css["entryLabel"] ?? "";
|
|
26
|
+
const iconSpan = document.createElement("span");
|
|
27
|
+
iconSpan.className = options.css["entryIcon"] ?? "";
|
|
28
|
+
iconSpan.innerHTML = options.icon;
|
|
29
|
+
entry.append(iconSpan, labelSpan);
|
|
30
|
+
const applyLabel = () => {
|
|
31
|
+
entry.setAttribute("aria-label", options.label());
|
|
32
|
+
if (options.tooltip !== void 0) entry.setAttribute("title", options.tooltip());
|
|
33
|
+
labelSpan.textContent = options.label();
|
|
34
|
+
};
|
|
35
|
+
applyLabel();
|
|
36
|
+
entry.addEventListener("click", options.onToggle);
|
|
37
|
+
return {
|
|
38
|
+
entry,
|
|
39
|
+
applyLabel
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
/** Re-insert the entry after the New Session row (before the browser region). */
|
|
43
|
+
function placeEntry(root, entry, options) {
|
|
44
|
+
const button = newSessionButton(root);
|
|
45
|
+
if (button === void 0) return false;
|
|
46
|
+
if (entry.parentElement !== root) {
|
|
47
|
+
const row = button.closest("[class*=\"logoRow\"]");
|
|
48
|
+
const base = row !== null && row.parentElement === root ? row : button;
|
|
49
|
+
const family = Array.from(root.children).filter((el) => el instanceof HTMLElement && el.matches(options.familySelectors.join(", ")));
|
|
50
|
+
const anchor = options.position === "before" ? family.length > 0 ? family[0] : base.nextElementSibling : family.length > 0 ? family[family.length - 1].nextElementSibling : base.nextElementSibling;
|
|
51
|
+
root.insertBefore(entry, anchor ?? null);
|
|
52
|
+
}
|
|
53
|
+
return true;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Mount the sidebar entry, waiting for the shell to render and self-healing
|
|
57
|
+
* on later React re-renders.
|
|
58
|
+
* @param options - the row's attribute/icon/copy/action/ordering configuration.
|
|
59
|
+
* @returns disposer removing the entry and its observers.
|
|
60
|
+
*/
|
|
61
|
+
function mountSidebarEntry(options) {
|
|
62
|
+
if (typeof document !== "undefined" && document.querySelector(options.rowSelector) !== null) return () => {};
|
|
63
|
+
const { entry, applyLabel } = createEntry(options);
|
|
64
|
+
let root;
|
|
65
|
+
let placed = false;
|
|
66
|
+
let unsubscribeRefresh;
|
|
67
|
+
if (options.refresh !== void 0) try {
|
|
68
|
+
unsubscribeRefresh = options.refresh.subscribe(applyLabel);
|
|
69
|
+
} catch {}
|
|
70
|
+
const tryPlace = () => {
|
|
71
|
+
if (root !== void 0 && !root.isConnected) {
|
|
72
|
+
rootObserver.disconnect();
|
|
73
|
+
root = void 0;
|
|
74
|
+
placed = false;
|
|
75
|
+
}
|
|
76
|
+
if (placed) {
|
|
77
|
+
if (document.body.contains(entry)) return;
|
|
78
|
+
rootObserver.disconnect();
|
|
79
|
+
root = void 0;
|
|
80
|
+
placed = false;
|
|
81
|
+
}
|
|
82
|
+
root ??= sidebarRoot();
|
|
83
|
+
if (root === void 0) return;
|
|
84
|
+
placed = placeEntry(root, entry, options);
|
|
85
|
+
if (placed) rootObserver.observe(root, {
|
|
86
|
+
childList: true,
|
|
87
|
+
subtree: true
|
|
88
|
+
});
|
|
89
|
+
};
|
|
90
|
+
const waitObserver = new MutationObserver(() => {
|
|
91
|
+
tryPlace();
|
|
92
|
+
});
|
|
93
|
+
waitObserver.observe(document.body, {
|
|
94
|
+
childList: true,
|
|
95
|
+
subtree: true
|
|
96
|
+
});
|
|
97
|
+
const rootObserver = new MutationObserver(() => {
|
|
98
|
+
if (root === void 0 || !root.isConnected) {
|
|
99
|
+
placed = false;
|
|
100
|
+
tryPlace();
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
if (!root.contains(entry)) placed = placeEntry(root, entry, options);
|
|
104
|
+
});
|
|
105
|
+
const unsubscribeActive = options.active === void 0 ? void 0 : (() => {
|
|
106
|
+
const syncActive = () => {
|
|
107
|
+
if (options.active.isOpen()) entry.dataset.active = "true";
|
|
108
|
+
else delete entry.dataset.active;
|
|
109
|
+
};
|
|
110
|
+
const unsubscribe = options.active.subscribe(syncActive);
|
|
111
|
+
syncActive();
|
|
112
|
+
return unsubscribe;
|
|
113
|
+
})();
|
|
114
|
+
tryPlace();
|
|
115
|
+
return () => {
|
|
116
|
+
waitObserver.disconnect();
|
|
117
|
+
rootObserver.disconnect();
|
|
118
|
+
unsubscribeRefresh?.();
|
|
119
|
+
unsubscribeActive?.();
|
|
120
|
+
entry.remove();
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Every row selector in the Huaqiu sidebar family, in intended visual order.
|
|
125
|
+
*
|
|
126
|
+
* Family ordering is only stable when every participating plugin passes the
|
|
127
|
+
* SAME list to {@link mountSidebarEntry} — the core positions a row relative to
|
|
128
|
+
* the family block, never relative to transient shell geometry. Adding a new
|
|
129
|
+
* family row means adding its selector here (and to no plugin-local list).
|
|
130
|
+
*/
|
|
131
|
+
const HUAQIU_SIDEBAR_ENTRY_FAMILY = [
|
|
132
|
+
"[data-hqcg-footprint-entry]",
|
|
133
|
+
"[data-hqcg-symbol-entry]",
|
|
134
|
+
"[data-hqcg-schematic-entry]"
|
|
135
|
+
];
|
|
136
|
+
/**
|
|
137
|
+
* Read the host UI language from `<html lang>`.
|
|
138
|
+
*
|
|
139
|
+
* `dsh-client-locale` rewrites the attribute on every language change (dsh
|
|
140
|
+
* web) and HQ Edge mirrors it, so this is the one source every family plugin
|
|
141
|
+
* can read without importing a `locale` service.
|
|
142
|
+
* @returns 'zh' / 'en', or undefined when the tag is absent or unsupported
|
|
143
|
+
* (callers fall back to their own default).
|
|
144
|
+
*/
|
|
145
|
+
function detectHostLocale() {
|
|
146
|
+
if (typeof document === "undefined") return void 0;
|
|
147
|
+
const tag = document.documentElement?.getAttribute("lang");
|
|
148
|
+
if (tag === null || tag === void 0 || tag === "") return void 0;
|
|
149
|
+
const primary = tag.toLowerCase().split("-")[0];
|
|
150
|
+
return primary === "zh" || primary === "en" ? primary : void 0;
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* Watch `<html lang>` and report every resolved change.
|
|
154
|
+
*
|
|
155
|
+
* Idempotent-safe: repeated calls share one observer and each caller gets its
|
|
156
|
+
* own disposer; the observer disconnects when the last listener leaves.
|
|
157
|
+
* @param listener - called with the newly resolved language.
|
|
158
|
+
* @returns disposer removing this listener.
|
|
159
|
+
*/
|
|
160
|
+
/** Shared observer/listener set backing {@link observeHostLocale}. */
|
|
161
|
+
const localeListeners = /* @__PURE__ */ new Set();
|
|
162
|
+
let localeObserver = null;
|
|
163
|
+
function observeHostLocale(listener) {
|
|
164
|
+
if (typeof document === "undefined" || typeof MutationObserver === "undefined" || document.documentElement === null) return () => {};
|
|
165
|
+
let current = detectHostLocale();
|
|
166
|
+
const listeners = localeListeners;
|
|
167
|
+
listeners.add(listener);
|
|
168
|
+
if (localeObserver === null) {
|
|
169
|
+
localeObserver = new MutationObserver(() => {
|
|
170
|
+
const next = detectHostLocale();
|
|
171
|
+
if (next === current) return;
|
|
172
|
+
current = next;
|
|
173
|
+
for (const fn of [...listeners]) try {
|
|
174
|
+
fn(next);
|
|
175
|
+
} catch {}
|
|
176
|
+
});
|
|
177
|
+
localeObserver.observe(document.documentElement, {
|
|
178
|
+
attributes: true,
|
|
179
|
+
attributeFilter: ["lang"]
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
return () => {
|
|
183
|
+
listeners.delete(listener);
|
|
184
|
+
if (listeners.size === 0 && localeObserver !== null) {
|
|
185
|
+
localeObserver.disconnect();
|
|
186
|
+
localeObserver = null;
|
|
187
|
+
}
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
//#endregion
|
|
191
|
+
export { HUAQIU_SIDEBAR_ENTRY_FAMILY, detectHostLocale, mountSidebarEntry, observeHostLocale };
|
package/package.json
CHANGED
|
@@ -1,6 +1,29 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@huaqiu/dsh-sidebar-entry",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.5.7",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"main": "./lib/index.mjs",
|
|
6
|
+
"types": "./lib/index.d.mts",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": {
|
|
9
|
+
"types": "./lib/index.d.mts",
|
|
10
|
+
"default": "./lib/index.mjs"
|
|
11
|
+
},
|
|
12
|
+
"./package.json": "./package.json"
|
|
13
|
+
},
|
|
14
|
+
"files": [
|
|
15
|
+
"lib",
|
|
16
|
+
"src"
|
|
17
|
+
],
|
|
18
|
+
"publishConfig": {
|
|
19
|
+
"access": "public"
|
|
20
|
+
},
|
|
21
|
+
"devDependencies": {
|
|
22
|
+
"jsdom": "^26.0.0"
|
|
23
|
+
},
|
|
24
|
+
"scripts": {
|
|
25
|
+
"typecheck": "tsc --noEmit",
|
|
26
|
+
"build": "tsdown",
|
|
27
|
+
"test": "vitest run"
|
|
28
|
+
}
|
|
6
29
|
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@huaqiu/dsh-sidebar-entry` — one shared sidebar entry row for the Huaqiu
|
|
3
|
+
* DSH plugin family.
|
|
4
|
+
*
|
|
5
|
+
* Adapted from dsh-web's `shared/client/sidebar-entry-core.ts` (used by
|
|
6
|
+
* dsh-task-board / dsh-skill-explorer / dsh-ssh). It lives in its own package
|
|
7
|
+
* so every Huaqiu plugin that needs a sidebar toolbutton shares ONE copy of
|
|
8
|
+
* the injection policy instead of forking it.
|
|
9
|
+
*
|
|
10
|
+
* dsh's sidebar shell exposes no slot an external plugin can register into at
|
|
11
|
+
* this position (between the New Session button and the workspace browser), so
|
|
12
|
+
* the entry row is injected into the live DOM. The injection self-heals: a
|
|
13
|
+
* MutationObserver watches the sidebar root and re-inserts the row whenever a
|
|
14
|
+
* React re-render displaces it (re-insertion happens in the same frame, before
|
|
15
|
+
* paint, so no flicker).
|
|
16
|
+
*
|
|
17
|
+
* The row is plain DOM (no React tree) so it can never disturb the shell's
|
|
18
|
+
* reconciliation. What the row DOES on click is entirely the caller's: open a
|
|
19
|
+
* `shell.overlay` workspace (component-gen) or act on the DSH composer
|
|
20
|
+
* (schematic-gen).
|
|
21
|
+
*
|
|
22
|
+
* @module @huaqiu/dsh-sidebar-entry
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
/** Per-package configuration for one sidebar entry row. */
|
|
26
|
+
export interface SidebarEntryOptions {
|
|
27
|
+
/** Full attribute name identifying the injected row (idempotency key), e.g. 'data-hqcg-symbol-entry'. */
|
|
28
|
+
rowAttribute: string
|
|
29
|
+
/** CSS selector matching the injected row, e.g. '[data-hqcg-symbol-entry]'. */
|
|
30
|
+
rowSelector: string
|
|
31
|
+
/**
|
|
32
|
+
* L2 semantic-attribute plugin id (issue #506). When set, the row also
|
|
33
|
+
* outputs data-dsh-plugin="<id>" and data-dsh-part="sidebar-entry"; unset
|
|
34
|
+
* leaves the row without semantic attributes.
|
|
35
|
+
*/
|
|
36
|
+
plugin?: string
|
|
37
|
+
/** Inline icon markup (matches the shell's 16px nav-icon look). */
|
|
38
|
+
icon: string
|
|
39
|
+
/** CSS module class names for the row and its two spans (entry / entryIcon / entryLabel). */
|
|
40
|
+
css: Record<string, string>
|
|
41
|
+
/** Localized row label (aria-label + visible text). */
|
|
42
|
+
label(): string
|
|
43
|
+
/** Optional localized tooltip (title attribute). */
|
|
44
|
+
tooltip?(): string
|
|
45
|
+
/**
|
|
46
|
+
* Optional locale-change subscription: re-applies label / aria-label /
|
|
47
|
+
* tooltip whenever the active locale changes.
|
|
48
|
+
*/
|
|
49
|
+
refresh?: { subscribe(listener: () => void): () => void }
|
|
50
|
+
/** Click action (open/toggle the owning panel). */
|
|
51
|
+
onToggle(): void
|
|
52
|
+
/** Family-block position: 'before' inserts ahead of sibling plugin rows, 'after' behind them. */
|
|
53
|
+
position: 'before' | 'after'
|
|
54
|
+
/**
|
|
55
|
+
* Selectors of the sibling plugin entry rows this package orders against
|
|
56
|
+
* (its own row included). Each package passes the same list so the rendered
|
|
57
|
+
* order stays stable across re-renders.
|
|
58
|
+
*/
|
|
59
|
+
familySelectors: readonly string[]
|
|
60
|
+
/** Optional active-state bridge; highlights the row while the panel is open. */
|
|
61
|
+
active?: {
|
|
62
|
+
subscribe(listener: () => void): () => void
|
|
63
|
+
isOpen(): boolean
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Find the sidebar shell root element, or undefined while not yet mounted. */
|
|
68
|
+
function sidebarRoot(): HTMLElement | undefined {
|
|
69
|
+
const column = document.querySelector<HTMLElement>('[data-pane="sidebar"], [class*="sidebarCol"]')
|
|
70
|
+
if (column === null) return undefined
|
|
71
|
+
// Current shells wrap the sidebar UI: column > wrapper > root(logoRow owner).
|
|
72
|
+
// Prefer the element that owns the logo row — the real sidebar UI root —
|
|
73
|
+
// and fall back to the column's first child for legacy shells.
|
|
74
|
+
const logoOwner = column.querySelector<HTMLElement>('[class*="logoRow"]')?.parentElement
|
|
75
|
+
return logoOwner ?? (column.firstElementChild as HTMLElement | undefined)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** The New Session button: nested in the logo row on current shells, a direct child on legacy shells. */
|
|
79
|
+
function newSessionButton(root: HTMLElement): HTMLButtonElement | undefined {
|
|
80
|
+
const nested = root.querySelector<HTMLButtonElement>('button[class*="newSession"]')
|
|
81
|
+
if (nested !== null) return nested
|
|
82
|
+
for (const child of root.children) {
|
|
83
|
+
if (child.tagName === 'BUTTON') return child as HTMLButtonElement
|
|
84
|
+
}
|
|
85
|
+
return undefined
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Build the entry row (a detached button; insert once the shell is up). */
|
|
89
|
+
function createEntry(options: SidebarEntryOptions): { entry: HTMLButtonElement; applyLabel: () => void } {
|
|
90
|
+
const entry = document.createElement('button')
|
|
91
|
+
entry.type = 'button'
|
|
92
|
+
entry.setAttribute(options.rowAttribute, '')
|
|
93
|
+
if (options.plugin !== undefined) {
|
|
94
|
+
entry.setAttribute('data-dsh-plugin', options.plugin)
|
|
95
|
+
entry.setAttribute('data-dsh-part', 'sidebar-entry')
|
|
96
|
+
}
|
|
97
|
+
entry.className = options.css['entry'] ?? ''
|
|
98
|
+
const labelSpan = document.createElement('span')
|
|
99
|
+
labelSpan.className = options.css['entryLabel'] ?? ''
|
|
100
|
+
const iconSpan = document.createElement('span')
|
|
101
|
+
iconSpan.className = options.css['entryIcon'] ?? ''
|
|
102
|
+
iconSpan.innerHTML = options.icon
|
|
103
|
+
entry.append(iconSpan, labelSpan)
|
|
104
|
+
const applyLabel = (): void => {
|
|
105
|
+
entry.setAttribute('aria-label', options.label())
|
|
106
|
+
if (options.tooltip !== undefined) entry.setAttribute('title', options.tooltip())
|
|
107
|
+
labelSpan.textContent = options.label()
|
|
108
|
+
}
|
|
109
|
+
applyLabel()
|
|
110
|
+
entry.addEventListener('click', options.onToggle)
|
|
111
|
+
return { entry, applyLabel }
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Re-insert the entry after the New Session row (before the browser region). */
|
|
115
|
+
function placeEntry(root: HTMLElement, entry: HTMLButtonElement, options: SidebarEntryOptions): boolean {
|
|
116
|
+
const button = newSessionButton(root)
|
|
117
|
+
if (button === undefined) return false
|
|
118
|
+
if (entry.parentElement !== root) {
|
|
119
|
+
// Position relative to the family block (entries injected by sibling
|
|
120
|
+
// plugins), never relative to transient logoRow geometry: every family
|
|
121
|
+
// plugin that self-heals during a re-render then lands in the same
|
|
122
|
+
// relative order, so the entries cannot swap positions regardless of
|
|
123
|
+
// observer callback order or of shell wrapper changes. There is no
|
|
124
|
+
// append-to-end fallback: appending at the end would randomly reorder
|
|
125
|
+
// the block after a shell re-render.
|
|
126
|
+
const row = button.closest('[class*="logoRow"]')
|
|
127
|
+
const base = (row !== null && row.parentElement === root) ? row : button
|
|
128
|
+
const family = Array.from(root.children).filter(
|
|
129
|
+
(el): el is HTMLElement => el instanceof HTMLElement && el.matches(options.familySelectors.join(', ')),
|
|
130
|
+
)
|
|
131
|
+
const anchor = options.position === 'before'
|
|
132
|
+
? (family.length > 0 ? family[0] : base.nextElementSibling)
|
|
133
|
+
: (family.length > 0 ? family[family.length - 1]!.nextElementSibling : base.nextElementSibling)
|
|
134
|
+
root.insertBefore(entry, anchor ?? null)
|
|
135
|
+
}
|
|
136
|
+
return true
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Mount the sidebar entry, waiting for the shell to render and self-healing
|
|
141
|
+
* on later React re-renders.
|
|
142
|
+
* @param options - the row's attribute/icon/copy/action/ordering configuration.
|
|
143
|
+
* @returns disposer removing the entry and its observers.
|
|
144
|
+
*/
|
|
145
|
+
export function mountSidebarEntry(options: SidebarEntryOptions): () => void {
|
|
146
|
+
// DOM-level idempotency: whatever path mounted an entry row before this
|
|
147
|
+
// call (a duplicated apply, an HMR re-injection, a stale module still
|
|
148
|
+
// alive), never mount a second one. The existing row keeps working; a full
|
|
149
|
+
// page reload is the ultimate reset.
|
|
150
|
+
if (typeof document !== 'undefined' && document.querySelector(options.rowSelector) !== null) {
|
|
151
|
+
return () => {}
|
|
152
|
+
}
|
|
153
|
+
const { entry, applyLabel } = createEntry(options)
|
|
154
|
+
let root: HTMLElement | undefined
|
|
155
|
+
let placed = false
|
|
156
|
+
let unsubscribeRefresh: (() => void) | undefined
|
|
157
|
+
if (options.refresh !== undefined) {
|
|
158
|
+
try {
|
|
159
|
+
unsubscribeRefresh = options.refresh.subscribe(applyLabel)
|
|
160
|
+
} catch {
|
|
161
|
+
// A throwing subscription must not break the mount; the label stays at
|
|
162
|
+
// its initial value and the next reload resolves it again.
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const tryPlace = (): void => {
|
|
167
|
+
if (root !== undefined && !root.isConnected) {
|
|
168
|
+
// The shell rebuilt the sidebar pane (whole-tree teardown); the root
|
|
169
|
+
// observer is gone with the old tree, so detach it and re-query from
|
|
170
|
+
// scratch. The new pane is later noticed by the body-level watcher.
|
|
171
|
+
rootObserver.disconnect()
|
|
172
|
+
root = undefined
|
|
173
|
+
placed = false
|
|
174
|
+
}
|
|
175
|
+
if (placed) {
|
|
176
|
+
// Cheap short-circuit: entry still lives in a mountable subtree.
|
|
177
|
+
if (document.body.contains(entry)) return
|
|
178
|
+
// Entry was torn down together with the old tree; reset and re-place.
|
|
179
|
+
rootObserver.disconnect()
|
|
180
|
+
root = undefined
|
|
181
|
+
placed = false
|
|
182
|
+
}
|
|
183
|
+
root ??= sidebarRoot()
|
|
184
|
+
if (root === undefined) return
|
|
185
|
+
placed = placeEntry(root, entry, options)
|
|
186
|
+
if (placed) {
|
|
187
|
+
rootObserver.observe(root, { childList: true, subtree: true })
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Body-level watcher retained as the "whole rebuild" fallback: when the shell
|
|
192
|
+
// tears down the whole sidebar pane, the root observer is gone with it and
|
|
193
|
+
// only this body observation can notice the new pane mounting. It is no
|
|
194
|
+
// longer disconnected after placement; the placed-and-still-mounted case
|
|
195
|
+
// short-circuits through the cheap document.body.contains(entry) check, so
|
|
196
|
+
// unrelated app mutations (e.g. chat streaming) cost one contains check
|
|
197
|
+
// instead of churning the full re-query.
|
|
198
|
+
const waitObserver = new MutationObserver(() => { tryPlace() })
|
|
199
|
+
waitObserver.observe(document.body, { childList: true, subtree: true })
|
|
200
|
+
|
|
201
|
+
// Self-heal: if a React re-render displaces the row, re-insert it in the
|
|
202
|
+
// same frame (microtask before paint -> no visible flicker).
|
|
203
|
+
const rootObserver = new MutationObserver(() => {
|
|
204
|
+
if (root === undefined || !root.isConnected) {
|
|
205
|
+
placed = false
|
|
206
|
+
tryPlace()
|
|
207
|
+
return
|
|
208
|
+
}
|
|
209
|
+
if (!root.contains(entry)) {
|
|
210
|
+
placed = placeEntry(root, entry, options)
|
|
211
|
+
}
|
|
212
|
+
})
|
|
213
|
+
|
|
214
|
+
// Reflect the panel's open state on the row (active highlight). Note: assigning
|
|
215
|
+
// undefined to dataset.active materializes data-active="undefined" and keeps the
|
|
216
|
+
// row permanently highlighted — delete the attribute instead.
|
|
217
|
+
const unsubscribeActive = options.active === undefined ? undefined : (() => {
|
|
218
|
+
const syncActive = (): void => {
|
|
219
|
+
if (options.active!.isOpen()) entry.dataset.active = 'true'
|
|
220
|
+
else delete entry.dataset.active
|
|
221
|
+
}
|
|
222
|
+
const unsubscribe = options.active.subscribe(syncActive)
|
|
223
|
+
syncActive()
|
|
224
|
+
return unsubscribe
|
|
225
|
+
})()
|
|
226
|
+
|
|
227
|
+
tryPlace()
|
|
228
|
+
|
|
229
|
+
return () => {
|
|
230
|
+
waitObserver.disconnect()
|
|
231
|
+
rootObserver.disconnect()
|
|
232
|
+
unsubscribeRefresh?.()
|
|
233
|
+
unsubscribeActive?.()
|
|
234
|
+
entry.remove()
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Every row selector in the Huaqiu sidebar family, in intended visual order.
|
|
240
|
+
*
|
|
241
|
+
* Family ordering is only stable when every participating plugin passes the
|
|
242
|
+
* SAME list to {@link mountSidebarEntry} — the core positions a row relative to
|
|
243
|
+
* the family block, never relative to transient shell geometry. Adding a new
|
|
244
|
+
* family row means adding its selector here (and to no plugin-local list).
|
|
245
|
+
*/
|
|
246
|
+
export const HUAQIU_SIDEBAR_ENTRY_FAMILY: readonly string[] = [
|
|
247
|
+
'[data-hqcg-footprint-entry]',
|
|
248
|
+
'[data-hqcg-symbol-entry]',
|
|
249
|
+
'[data-hqcg-schematic-entry]',
|
|
250
|
+
]
|
|
251
|
+
|
|
252
|
+
// ---- host locale detection (shared by every family sidebar row) ----
|
|
253
|
+
|
|
254
|
+
/** Host UI languages this family localizes into. */
|
|
255
|
+
export type HostLocale = 'zh' | 'en'
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Read the host UI language from `<html lang>`.
|
|
259
|
+
*
|
|
260
|
+
* `dsh-client-locale` rewrites the attribute on every language change (dsh
|
|
261
|
+
* web) and HQ Edge mirrors it, so this is the one source every family plugin
|
|
262
|
+
* can read without importing a `locale` service.
|
|
263
|
+
* @returns 'zh' / 'en', or undefined when the tag is absent or unsupported
|
|
264
|
+
* (callers fall back to their own default).
|
|
265
|
+
*/
|
|
266
|
+
export function detectHostLocale(): HostLocale | undefined {
|
|
267
|
+
if (typeof document === 'undefined') return undefined
|
|
268
|
+
const tag = document.documentElement?.getAttribute('lang')
|
|
269
|
+
if (tag === null || tag === undefined || tag === '') return undefined
|
|
270
|
+
const primary = tag.toLowerCase().split('-')[0]
|
|
271
|
+
return primary === 'zh' || primary === 'en' ? primary : undefined
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Watch `<html lang>` and report every resolved change.
|
|
276
|
+
*
|
|
277
|
+
* Idempotent-safe: repeated calls share one observer and each caller gets its
|
|
278
|
+
* own disposer; the observer disconnects when the last listener leaves.
|
|
279
|
+
* @param listener - called with the newly resolved language.
|
|
280
|
+
* @returns disposer removing this listener.
|
|
281
|
+
*/
|
|
282
|
+
/** Shared observer/listener set backing {@link observeHostLocale}. */
|
|
283
|
+
const localeListeners = new Set<(lang: HostLocale | undefined) => void>()
|
|
284
|
+
let localeObserver: MutationObserver | null = null
|
|
285
|
+
|
|
286
|
+
export function observeHostLocale(listener: (lang: HostLocale | undefined) => void): () => void {
|
|
287
|
+
if (typeof document === 'undefined' || typeof MutationObserver === 'undefined' || document.documentElement === null) {
|
|
288
|
+
return () => {}
|
|
289
|
+
}
|
|
290
|
+
let current = detectHostLocale()
|
|
291
|
+
const listeners = localeListeners
|
|
292
|
+
listeners.add(listener)
|
|
293
|
+
if (localeObserver === null) {
|
|
294
|
+
localeObserver = new MutationObserver(() => {
|
|
295
|
+
const next = detectHostLocale()
|
|
296
|
+
if (next === current) return
|
|
297
|
+
current = next
|
|
298
|
+
for (const fn of [...listeners]) {
|
|
299
|
+
try { fn(next) } catch { /* one bad subscriber must not strand the rest */ }
|
|
300
|
+
}
|
|
301
|
+
})
|
|
302
|
+
localeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['lang'] })
|
|
303
|
+
}
|
|
304
|
+
return () => {
|
|
305
|
+
listeners.delete(listener)
|
|
306
|
+
if (listeners.size === 0 && localeObserver !== null) {
|
|
307
|
+
localeObserver.disconnect()
|
|
308
|
+
localeObserver = null
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
}
|