@cowwo/dsh-icon-custom 0.9.0 → 0.9.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/client/client.js +227 -46
- package/package.json +1 -1
package/client/client.js
CHANGED
|
@@ -426,6 +426,9 @@ window.__ModuleLoader__.load({
|
|
|
426
426
|
function notifyBadge() {
|
|
427
427
|
const snapshot = badgeSnapshot();
|
|
428
428
|
badgeListeners.forEach((fn) => { try { fn(snapshot); } catch {} });
|
|
429
|
+
// The header badge is a DOM node, not a listener, so it is reconciled
|
|
430
|
+
// here as well as from the mutation watchdog. Idempotent either way.
|
|
431
|
+
paintHeaderBadge();
|
|
429
432
|
}
|
|
430
433
|
/** Manual test input. */
|
|
431
434
|
function emitBadge(value) {
|
|
@@ -1012,48 +1015,218 @@ window.__ModuleLoader__.load({
|
|
|
1012
1015
|
}
|
|
1013
1016
|
|
|
1014
1017
|
/**
|
|
1015
|
-
* The
|
|
1016
|
-
*
|
|
1017
|
-
*
|
|
1018
|
-
*
|
|
1018
|
+
* The unread badge, injected into the sidebar's section header.
|
|
1019
|
+
*
|
|
1020
|
+
* There is no slot to register into: `sidebar.workspaces` is a SINGLE slot
|
|
1021
|
+
* owned by ui-workspace that covers the whole browsing region *including* its
|
|
1022
|
+
* header, and shadowing it would mean reimplementing the shipped browser. So
|
|
1023
|
+
* the badge is injected into the DOM — the same technique, the same contract,
|
|
1024
|
+
* and the same failure posture as the workspace-row dots: decorative, never
|
|
1025
|
+
* throws, and after repeated failures it gives up on its own with one warning.
|
|
1026
|
+
*
|
|
1027
|
+
* Placement is measured, not inherited, because the shipped CSS forbids the
|
|
1028
|
+
* obvious approach: `.sectionHeader` is 36px tall with `overflow:hidden`, the
|
|
1029
|
+
* label inside it is a 20px line box that is ALSO `overflow:hidden`, so a badge
|
|
1030
|
+
* parented to the label and raised clear of the glyph is clipped to a sliver by
|
|
1031
|
+
* the label's own overflow. The badge is therefore a SIBLING of the label,
|
|
1032
|
+
* absolutely positioned over the label's top-right corner from its measured
|
|
1033
|
+
* rect, and clamped so it never leaves the header's 36px box.
|
|
1034
|
+
*
|
|
1035
|
+
* The count is shown for EVERY value including zero: a grey 0 keeps the
|
|
1036
|
+
* position from jumping, and doubles as the only way back to the panel once
|
|
1037
|
+
* the old sidebar-foot button is gone.
|
|
1019
1038
|
*/
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1039
|
+
const HEAD_BADGE_ATTR = "data-icon-custom-unreadbadge";
|
|
1040
|
+
const HEAD_BADGE_STYLE_ID = "dsh-icon-custom-unreadbadge-css";
|
|
1041
|
+
const HEAD_BADGE_WIRED = "data-icon-custom-unreadbadge-wired";
|
|
1042
|
+
const HEAD_BADGE_MAX_FAILURES = 40;
|
|
1043
|
+
/** How many ancestors to walk up looking for the header row. */
|
|
1044
|
+
const HEAD_BADGE_MAX_STEPS = 6;
|
|
1045
|
+
/** Raise above the label's top edge; the header only leaves 8px of headroom. */
|
|
1046
|
+
const HEAD_BADGE_RAISE = 7;
|
|
1047
|
+
/** Overhang past the label's right edge. */
|
|
1048
|
+
const HEAD_BADGE_OVERHANG = 9;
|
|
1049
|
+
let headerBadgeHost = null;
|
|
1050
|
+
let headerBadgeHostPosition = "";
|
|
1051
|
+
let headerBadgeObserved = null;
|
|
1052
|
+
let headerBadgeObserver = null;
|
|
1053
|
+
let headerBadgeFailures = 0;
|
|
1054
|
+
let headerBadgeGivenUp = false;
|
|
1055
|
+
/** Locale binder, installed by `apply()` so the injected node can label itself. */
|
|
1056
|
+
let badgeT = null;
|
|
1057
|
+
|
|
1058
|
+
/**
|
|
1059
|
+
* Rules for the injected badge. A stylesheet rather than inline styles because
|
|
1060
|
+
* hover and the theme-following ring cannot be expressed inline. Colours come
|
|
1061
|
+
* from the official tokens wherever one exists:
|
|
1062
|
+
* * the red is the one this plugin has always drawn;
|
|
1063
|
+
* * the "nothing pending" grey is the official idle-state colour, so it
|
|
1064
|
+
* follows light/dark without naming either;
|
|
1065
|
+
* * the ring is the sidebar's own fill — that is what makes a badge sitting
|
|
1066
|
+
* on top of a glyph read as a badge instead of a smudge.
|
|
1067
|
+
* The hit area grows sideways and downward only: there are just 8px above the
|
|
1068
|
+
* label, so anything reaching further up is clipped by the header's overflow.
|
|
1069
|
+
*/
|
|
1070
|
+
const HEAD_BADGE_CSS = [
|
|
1071
|
+
`[${HEAD_BADGE_ATTR}]{position:absolute;display:none;align-items:center;justify-content:center;min-width:15px;height:15px;padding:0 3.5px;box-sizing:border-box;border:0;border-radius:999px;background:#e5484d;color:#fff;font:inherit;font-size:9.5px;font-weight:700;line-height:1;font-variant-numeric:tabular-nums;letter-spacing:-.02em;cursor:pointer;z-index:3;box-shadow:0 0 0 1.5px var(--dsw-specific-sidebar-fill,#fff)}`,
|
|
1072
|
+
`[${HEAD_BADGE_ATTR}]::after{content:"";position:absolute;inset:0 -5px -5px -5px;border-radius:999px}`,
|
|
1073
|
+
`[${HEAD_BADGE_ATTR}]:hover{filter:brightness(1.12)}`,
|
|
1074
|
+
`[${HEAD_BADGE_ATTR}][data-zero="1"]{background:var(--dsw-alias-state-idle-primary,#b6bcc4)}`
|
|
1075
|
+
].join("");
|
|
1076
|
+
|
|
1077
|
+
/** Install the badge rules once per page. */
|
|
1078
|
+
function ensureHeaderBadgeStyle() {
|
|
1079
|
+
try {
|
|
1080
|
+
if (document.getElementById(HEAD_BADGE_STYLE_ID) !== null) return;
|
|
1081
|
+
const style = document.createElement("style");
|
|
1082
|
+
style.id = HEAD_BADGE_STYLE_ID;
|
|
1083
|
+
style.textContent = HEAD_BADGE_CSS;
|
|
1084
|
+
document.head.appendChild(style);
|
|
1085
|
+
} catch { /* styling is cosmetic; never let it reach a render tree */ }
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1088
|
+
/**
|
|
1089
|
+
* The section-header row and the label inside it.
|
|
1090
|
+
*
|
|
1091
|
+
* The title TEXT is deliberately not matched: it flips between 工作区 and 会话
|
|
1092
|
+
* with the group-by mode and is localized, so any string table would be both
|
|
1093
|
+
* incomplete and wrong half the time. Structure is stable instead —
|
|
1094
|
+
* `sidebar.workspaces` is a slot root (the official renderer stamps
|
|
1095
|
+
* `data-slot` on those), the header row's first button is the search control,
|
|
1096
|
+
* and the label is the row's first text-bearing child that owns no button.
|
|
1097
|
+
* @param container - the browsing region root.
|
|
1098
|
+
* @returns `{ row, label }`, or null when this build's DOM is not recognised.
|
|
1099
|
+
*/
|
|
1100
|
+
function sectionHeaderParts(container) {
|
|
1101
|
+
// Our own badge is a button too; never let it stand in for the search
|
|
1102
|
+
// control, or the row anchor would resolve to the wrong element.
|
|
1103
|
+
let firstButton = null;
|
|
1104
|
+
for (const candidate of container.querySelectorAll("button")) {
|
|
1105
|
+
if (candidate.getAttribute(HEAD_BADGE_ATTR) !== null) continue;
|
|
1106
|
+
firstButton = candidate;
|
|
1107
|
+
break;
|
|
1108
|
+
}
|
|
1109
|
+
if (firstButton === null) return null;
|
|
1110
|
+
let row = firstButton.parentElement;
|
|
1111
|
+
for (let step = 0; step < HEAD_BADGE_MAX_STEPS && row !== null && row !== container; step++) {
|
|
1112
|
+
for (const child of row.children) {
|
|
1113
|
+
if (child === firstButton || child.contains(firstButton)) continue;
|
|
1114
|
+
if (child.tagName === "BUTTON") continue;
|
|
1115
|
+
if (child.querySelector("button") !== null) continue;
|
|
1116
|
+
if ((child.textContent ?? "").trim() !== "") return { row, label: child };
|
|
1043
1117
|
}
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1118
|
+
row = row.parentElement;
|
|
1119
|
+
}
|
|
1120
|
+
return null;
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
/** Warn once, then give up for this page — the row dots' exact posture. */
|
|
1124
|
+
function noteHeaderBadgeFailure(why) {
|
|
1125
|
+
headerBadgeFailures++;
|
|
1126
|
+
if (headerBadgeFailures === 1) {
|
|
1127
|
+
try { console.warn("dsh-icon-custom: 工作区标题红点定位失败,已降级(不影响其他功能):", why); } catch {}
|
|
1128
|
+
return;
|
|
1129
|
+
}
|
|
1130
|
+
if (headerBadgeFailures < HEAD_BADGE_MAX_FAILURES || headerBadgeGivenUp) return;
|
|
1131
|
+
headerBadgeGivenUp = true;
|
|
1132
|
+
try { console.warn("dsh-icon-custom: 工作区标题红点连续失败,本次会话内已自动关闭。"); } catch {}
|
|
1055
1133
|
}
|
|
1056
1134
|
|
|
1135
|
+
/**
|
|
1136
|
+
* Reconcile the header badge with the current count and the label's position.
|
|
1137
|
+
*
|
|
1138
|
+
* Driven by the same mutation watchdog as the row dots, so it must be
|
|
1139
|
+
* idempotent: it writes only what actually differs, or the observer that
|
|
1140
|
+
* called it would re-arm on its own output. Geometry is read from the live
|
|
1141
|
+
* rects, so the badge follows a sidebar resize, a locale change and the
|
|
1142
|
+
* label's own collapse animation without any hardcoded offsets.
|
|
1143
|
+
*/
|
|
1144
|
+
function paintHeaderBadge() {
|
|
1145
|
+
if (headerBadgeGivenUp) return;
|
|
1146
|
+
const container = workspaceDotContainer();
|
|
1147
|
+
if (container === null) return; // start-up race, not a failure
|
|
1148
|
+
const parts = sectionHeaderParts(container);
|
|
1149
|
+
if (parts === null) { noteHeaderBadgeFailure("找不到分区标题"); return; }
|
|
1150
|
+
const row = parts.row;
|
|
1151
|
+
const label = parts.label;
|
|
1152
|
+
ensureHeaderBadgeStyle();
|
|
1153
|
+
if (headerBadgeHost !== row) {
|
|
1154
|
+
if (headerBadgeHost !== null) { try { headerBadgeHost.style.position = headerBadgeHostPosition; } catch {} }
|
|
1155
|
+
headerBadgeHost = row;
|
|
1156
|
+
headerBadgeHostPosition = row.style.position;
|
|
1157
|
+
try { if (window.getComputedStyle(row).position === "static") row.style.position = "relative"; } catch {}
|
|
1158
|
+
}
|
|
1159
|
+
let node = row.querySelector(`[${HEAD_BADGE_ATTR}]`);
|
|
1160
|
+
if (node === null) {
|
|
1161
|
+
node = document.createElement("button");
|
|
1162
|
+
node.type = "button";
|
|
1163
|
+
node.setAttribute(HEAD_BADGE_ATTR, "1");
|
|
1164
|
+
// Appended AFTER the shipped controls, so it can never be mistaken for
|
|
1165
|
+
// the header's first button.
|
|
1166
|
+
row.appendChild(node);
|
|
1167
|
+
}
|
|
1168
|
+
if (node.getAttribute(HEAD_BADGE_WIRED) !== "1") {
|
|
1169
|
+
node.setAttribute(HEAD_BADGE_WIRED, "1");
|
|
1170
|
+
node.addEventListener("click", (event) => {
|
|
1171
|
+
// The row belongs to the sidebar; keep the click to ourselves.
|
|
1172
|
+
event.preventDefault();
|
|
1173
|
+
event.stopPropagation();
|
|
1174
|
+
const rect = node.getBoundingClientRect();
|
|
1175
|
+
toggleUnreadPanel({ left: rect.left, top: rect.top });
|
|
1176
|
+
});
|
|
1177
|
+
}
|
|
1178
|
+
const count = effectiveCount();
|
|
1179
|
+
const text = badgeLabel(count);
|
|
1180
|
+
const zero = count > 0 ? "0" : "1";
|
|
1181
|
+
if (node.getAttribute("data-zero") !== zero) node.setAttribute("data-zero", zero);
|
|
1182
|
+
if (typeof badgeT === "function") {
|
|
1183
|
+
const spoken = count > 0 ? badgeT("unreadPanelCount").replace("{n}", String(count)) : badgeT("unreadPanelNone");
|
|
1184
|
+
if (node.getAttribute("aria-label") !== spoken) {
|
|
1185
|
+
node.setAttribute("aria-label", spoken);
|
|
1186
|
+
node.setAttribute("title", spoken);
|
|
1187
|
+
}
|
|
1188
|
+
}
|
|
1189
|
+
if (node.textContent !== text) node.textContent = text;
|
|
1190
|
+
// The label collapses to nothing while the search box is open; the badge
|
|
1191
|
+
// has no meaning then, and the label's own visibility cannot hide it
|
|
1192
|
+
// because it is a sibling.
|
|
1193
|
+
const rowRect = row.getBoundingClientRect();
|
|
1194
|
+
const labelRect = label.getBoundingClientRect();
|
|
1195
|
+
if (labelRect.width < 8) {
|
|
1196
|
+
if (node.style.display !== "none") node.style.display = "none";
|
|
1197
|
+
} else {
|
|
1198
|
+
const width = typeof node.offsetWidth === "number" && node.offsetWidth > 0 ? node.offsetWidth : 15;
|
|
1199
|
+
const left = Math.round(labelRect.right - rowRect.left - width + HEAD_BADGE_OVERHANG);
|
|
1200
|
+
// Clamped: the header clips its overflow, and only ~8px sit above the label.
|
|
1201
|
+
const top = Math.max(0, Math.round(labelRect.top - rowRect.top - HEAD_BADGE_RAISE));
|
|
1202
|
+
if (node.style.left !== `${left}px`) node.style.left = `${left}px`;
|
|
1203
|
+
if (node.style.top !== `${top}px`) node.style.top = `${top}px`;
|
|
1204
|
+
if (node.style.display !== "flex") node.style.display = "flex";
|
|
1205
|
+
}
|
|
1206
|
+
// The label animates its own width; follow it instead of waiting for the
|
|
1207
|
+
// next mutation, which a CSS transition never produces.
|
|
1208
|
+
if (typeof window.ResizeObserver === "function") {
|
|
1209
|
+
if (headerBadgeObserver === null) headerBadgeObserver = new window.ResizeObserver(() => { paintHeaderBadge(); });
|
|
1210
|
+
if (headerBadgeObserved !== label) {
|
|
1211
|
+
try { headerBadgeObserver.disconnect(); headerBadgeObserver.observe(label); headerBadgeObserved = label; } catch {}
|
|
1212
|
+
}
|
|
1213
|
+
}
|
|
1214
|
+
}
|
|
1215
|
+
|
|
1216
|
+
/** Take the badge back out and restore the row's own positioning. */
|
|
1217
|
+
function clearHeaderBadge() {
|
|
1218
|
+
try { document.querySelectorAll(`[${HEAD_BADGE_ATTR}]`).forEach((node) => node.remove()); } catch {}
|
|
1219
|
+
if (headerBadgeObserver !== null) {
|
|
1220
|
+
try { headerBadgeObserver.disconnect(); } catch {}
|
|
1221
|
+
headerBadgeObserver = null;
|
|
1222
|
+
headerBadgeObserved = null;
|
|
1223
|
+
}
|
|
1224
|
+
if (headerBadgeHost !== null) {
|
|
1225
|
+
try { headerBadgeHost.style.position = headerBadgeHostPosition; } catch {}
|
|
1226
|
+
}
|
|
1227
|
+
headerBadgeHost = null;
|
|
1228
|
+
headerBadgeHostPosition = "";
|
|
1229
|
+
}
|
|
1057
1230
|
/**
|
|
1058
1231
|
* The list behind that number, in the frame-wide overlay layer so it escapes
|
|
1059
1232
|
* the sidebar's clipping and scroll container. Clicking a row opens that
|
|
@@ -1577,9 +1750,11 @@ window.__ModuleLoader__.load({
|
|
|
1577
1750
|
inject: () => ({ t })
|
|
1578
1751
|
}, (props) => React.createElement(FaviconSection, { ...props, t, rpc })));
|
|
1579
1752
|
|
|
1580
|
-
//
|
|
1581
|
-
//
|
|
1582
|
-
//
|
|
1753
|
+
// The badge itself is NOT a slot occupant: it is injected into the
|
|
1754
|
+
// sidebar's section header by `paintHeaderBadge()` (see above), because
|
|
1755
|
+
// `sidebar.workspaces` is a single slot covering the whole region. Only
|
|
1756
|
+
// the list it opens still rides a slot — the frame-wide overlay, which
|
|
1757
|
+
// escapes the sidebar's clipping.
|
|
1583
1758
|
//
|
|
1584
1759
|
// 0.2.x has no `ctx.sessions.open(id)` (the ClientSessions service exposes
|
|
1585
1760
|
// retain/using/retainInfo/refreshProjections/search/fork/scope/binding — no
|
|
@@ -1590,10 +1765,9 @@ window.__ModuleLoader__.load({
|
|
|
1590
1765
|
const openSession = (id) => {
|
|
1591
1766
|
try { ctx.get("uiWorkspace")?.openSession(id); } catch { /* navigation is best effort */ }
|
|
1592
1767
|
};
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
));
|
|
1768
|
+
// The injected node labels itself for screen readers, so it needs the
|
|
1769
|
+
// locale binder that only exists inside `apply()`.
|
|
1770
|
+
badgeT = t;
|
|
1597
1771
|
ctx.slots.inject("shell.overlay", () => ctx.slots.register(
|
|
1598
1772
|
{ name: "shell.overlay", id: "icon-custom-unread-popup", order: 30 },
|
|
1599
1773
|
() => React.createElement(UnreadPopup, { t, onOpen: openSession })
|
|
@@ -1656,7 +1830,13 @@ window.__ModuleLoader__.load({
|
|
|
1656
1830
|
const schedule = () => {
|
|
1657
1831
|
if (queued) return;
|
|
1658
1832
|
queued = true;
|
|
1659
|
-
window.setTimeout(() => {
|
|
1833
|
+
window.setTimeout(() => {
|
|
1834
|
+
queued = false;
|
|
1835
|
+
paintWorkspaceDots();
|
|
1836
|
+
// Same region, same watchdog: the section-header badge is
|
|
1837
|
+
// re-injected whenever the shipped UI re-renders over it.
|
|
1838
|
+
paintHeaderBadge();
|
|
1839
|
+
}, 200);
|
|
1660
1840
|
};
|
|
1661
1841
|
const attach = () => {
|
|
1662
1842
|
const container = workspaceDotContainer();
|
|
@@ -1683,6 +1863,7 @@ window.__ModuleLoader__.load({
|
|
|
1683
1863
|
offDots();
|
|
1684
1864
|
offConfig();
|
|
1685
1865
|
clearWorkspaceDots();
|
|
1866
|
+
clearHeaderBadge();
|
|
1686
1867
|
};
|
|
1687
1868
|
}, "dsh-icon-custom: workspace dots");
|
|
1688
1869
|
// The platform favicon.svg repaints itself for dark mode through
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cowwo/dsh-icon-custom",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.1",
|
|
4
4
|
"description": "自定义浏览器标签页图标:在设置页上传 SVG/PNG/ICO,立即生效并持久保存,自动适配安卓/iOS/平板 PWA 图标。· Custom browser tab favicon: upload SVG/PNG/ICO from the settings page, applies instantly and persists, auto-sizes PWA icons for Android/iOS/tablets.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|