@cowwo/dsh-icon-custom 0.8.2 → 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 +307 -87
- package/lib/index.js +22 -4
- 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) {
|
|
@@ -482,16 +485,66 @@ window.__ModuleLoader__.load({
|
|
|
482
485
|
if (iconState.active !== true) return "image/svg+xml";
|
|
483
486
|
return iconState.png === true ? "image/png" : (iconState.mime || "image/svg+xml");
|
|
484
487
|
}
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
488
|
+
/**
|
|
489
|
+
* Every `<link rel="icon">` the document carries, creating one if the page
|
|
490
|
+
* has none.
|
|
491
|
+
*
|
|
492
|
+
* DSH 0.2.x ships TWO of them — a dark and a light one, each behind a
|
|
493
|
+
* `media` query. Touching only `querySelector`'s first match left the light
|
|
494
|
+
* one pointing at the shipped favicon, and the browser honours the LAST
|
|
495
|
+
* suitable candidate, so a custom icon silently failed to apply in light
|
|
496
|
+
* mode (the tab kept the platform whale and the feature looked dead).
|
|
497
|
+
* Every write below therefore goes to all of them.
|
|
498
|
+
*/
|
|
499
|
+
function iconLinks() {
|
|
500
|
+
const links = Array.from(document.querySelectorAll('link[rel="icon"]'));
|
|
501
|
+
if (links.length > 0) return links;
|
|
502
|
+
const link = document.createElement("link");
|
|
503
|
+
link.rel = "icon";
|
|
504
|
+
link.type = "image/svg+xml";
|
|
505
|
+
link.href = "/favicon.svg";
|
|
506
|
+
document.head.appendChild(link);
|
|
507
|
+
return [link];
|
|
508
|
+
}
|
|
509
|
+
/** Authored href/type, remembered before the first write so a reset can put the shipped pair back. */
|
|
510
|
+
const ICON_ORIGINALS = new WeakMap();
|
|
511
|
+
function rememberIcon(link) {
|
|
512
|
+
if (ICON_ORIGINALS.has(link)) return;
|
|
513
|
+
ICON_ORIGINALS.set(link, { href: link.getAttribute("href"), type: link.getAttribute("type") });
|
|
514
|
+
}
|
|
515
|
+
/**
|
|
516
|
+
* Point every icon link at one image, so no scheme-specific sibling is left stale.
|
|
517
|
+
* Writes only on change: the head MutationObserver feeds `reconcileFavicon`,
|
|
518
|
+
* so a redundant write would re-arm the watchdog on every tick.
|
|
519
|
+
*/
|
|
520
|
+
function setIconEverywhere(href, type) {
|
|
521
|
+
for (const link of iconLinks()) {
|
|
522
|
+
rememberIcon(link);
|
|
523
|
+
if (link.getAttribute("href") !== href) link.setAttribute("href", href);
|
|
524
|
+
if (typeof type === "string" && type !== "") {
|
|
525
|
+
if (link.getAttribute("type") !== type) link.setAttribute("type", type);
|
|
526
|
+
} else if (link.getAttribute("type") !== null) link.removeAttribute("type");
|
|
493
527
|
}
|
|
494
|
-
|
|
528
|
+
}
|
|
529
|
+
/**
|
|
530
|
+
* Put the authored hrefs back; a link this plugin created falls back to the
|
|
531
|
+
* platform default. Idempotent for the same watchdog reason as above, and a
|
|
532
|
+
* no-op for any link the plugin never touched — which is what keeps the
|
|
533
|
+
* documented "zero side effects while no custom icon is set" true.
|
|
534
|
+
*/
|
|
535
|
+
function restoreShippedIcons() {
|
|
536
|
+
for (const link of iconLinks()) {
|
|
537
|
+
const original = ICON_ORIGINALS.get(link);
|
|
538
|
+
if (original === undefined) continue; // never touched → leave the shipped tag alone
|
|
539
|
+
const href = original.href === null ? "/favicon.svg" : original.href;
|
|
540
|
+
if (link.getAttribute("href") !== href) link.setAttribute("href", href);
|
|
541
|
+
if (original.type === null) {
|
|
542
|
+
if (link.getAttribute("type") !== null) link.removeAttribute("type");
|
|
543
|
+
} else if (link.getAttribute("type") !== original.type) link.setAttribute("type", original.type);
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
function iconLink() {
|
|
547
|
+
return iconLinks()[0];
|
|
495
548
|
}
|
|
496
549
|
function roundRect(c2d, x, y, w, h) {
|
|
497
550
|
const r = Math.min(h / 2, w / 2);
|
|
@@ -535,15 +588,14 @@ window.__ModuleLoader__.load({
|
|
|
535
588
|
});
|
|
536
589
|
}
|
|
537
590
|
async function applyBadgeToFavicon(count, size) {
|
|
538
|
-
const link = iconLink();
|
|
539
591
|
const base = desiredIconHref();
|
|
540
592
|
if (count <= 0) {
|
|
541
|
-
//
|
|
542
|
-
//
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
593
|
+
// No badge to draw. With a custom icon active the page must carry it on
|
|
594
|
+
// EVERY link (a stale scheme-specific sibling outranks the one we set);
|
|
595
|
+
// with none, the shipped dark/light pair goes back untouched — the
|
|
596
|
+
// plugin promises zero side effects while it has no icon of its own.
|
|
597
|
+
if (iconState.active === true) setIconEverywhere(base, desiredIconType());
|
|
598
|
+
else restoreShippedIcons();
|
|
547
599
|
faviconBase = null;
|
|
548
600
|
return;
|
|
549
601
|
}
|
|
@@ -566,8 +618,7 @@ window.__ModuleLoader__.load({
|
|
|
566
618
|
const dh = ih * scale;
|
|
567
619
|
c2d.drawImage(img, (FAVICON_SIZE - dw) / 2, (FAVICON_SIZE - dh) / 2, dw, dh);
|
|
568
620
|
drawFaviconBadge(c2d, FAVICON_SIZE, count, badgeScale(size));
|
|
569
|
-
|
|
570
|
-
link.setAttribute("type", "image/png");
|
|
621
|
+
setIconEverywhere(canvas.toDataURL("image/png"), "image/png");
|
|
571
622
|
} catch (error) {
|
|
572
623
|
// Never silent: a failure here is invisible in the UI (the tab simply
|
|
573
624
|
// keeps its previous icon), which once cost a whole debugging session.
|
|
@@ -588,13 +639,16 @@ window.__ModuleLoader__.load({
|
|
|
588
639
|
* composite was replaced and has to be rebuilt from the new base.
|
|
589
640
|
*/
|
|
590
641
|
function reconcileFavicon() {
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
const
|
|
642
|
+
// Inspect EVERY icon link: 0.2.x ships two (dark/light), and a sibling
|
|
643
|
+
// left stale by anything outside the plugin must be repaired too — that
|
|
644
|
+
// stale sibling is exactly what used to outrank the icon we set.
|
|
645
|
+
const hrefs = iconLinks().map((candidate) => candidate.getAttribute("href"));
|
|
646
|
+
const ours = hrefs.length > 0 && hrefs.every((value) => typeof value === "string" && value.slice(0, 5) === "data:");
|
|
595
647
|
if (effectiveCount() <= 0) {
|
|
596
|
-
//
|
|
597
|
-
|
|
648
|
+
// Carries the custom icon, or puts the shipped pair back. Both writers
|
|
649
|
+
// only touch a link that actually differs, so this stays a no-op once
|
|
650
|
+
// the page is correct and cannot re-arm the head observer.
|
|
651
|
+
applyBadgeToFavicon(0);
|
|
598
652
|
return;
|
|
599
653
|
}
|
|
600
654
|
if (ours || faviconComposing) return; // already showing, or a build is running
|
|
@@ -961,48 +1015,218 @@ window.__ModuleLoader__.load({
|
|
|
961
1015
|
}
|
|
962
1016
|
|
|
963
1017
|
/**
|
|
964
|
-
* The
|
|
965
|
-
*
|
|
966
|
-
*
|
|
967
|
-
*
|
|
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.
|
|
1038
|
+
*/
|
|
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.
|
|
968
1099
|
*/
|
|
969
|
-
function
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
const
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
style: {
|
|
987
|
-
position: "relative", display: "inline-flex", alignItems: "center", justifyContent: "center", gap: "6px",
|
|
988
|
-
height: "32px", width: wide ? "auto" : "32px", padding: wide ? "0 10px" : "0",
|
|
989
|
-
border: "1px solid var(--dsw-alias-border-l2)", borderRadius: "8px", background: "transparent",
|
|
990
|
-
color: "var(--dsw-alias-label-primary)", cursor: "pointer", font: "inherit", fontSize: "12px",
|
|
991
|
-
opacity: count > 0 ? 1 : 0.55
|
|
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 };
|
|
992
1117
|
}
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
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 {}
|
|
1004
1133
|
}
|
|
1005
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
|
+
}
|
|
1006
1230
|
/**
|
|
1007
1231
|
* The list behind that number, in the frame-wide overlay layer so it escapes
|
|
1008
1232
|
* the sidebar's clipping and scroll container. Clicking a row opens that
|
|
@@ -1169,30 +1393,18 @@ window.__ModuleLoader__.load({
|
|
|
1169
1393
|
// visible in this very tab; other tabs pick it up on their next render.
|
|
1170
1394
|
const applyLive = React.useCallback((s) => {
|
|
1171
1395
|
if (!s || s.active !== true) return;
|
|
1172
|
-
const link = document.querySelector('link[rel="icon"]');
|
|
1173
1396
|
const href = s.png === true ? `/icon-custom-192.png?v=${s.rev}` : `/icon-custom.svg?v=${s.rev}`;
|
|
1174
1397
|
const type = s.png === true ? "image/png" : (s.mime || "image/svg+xml");
|
|
1175
|
-
|
|
1176
|
-
link.setAttribute("href", href);
|
|
1177
|
-
link.setAttribute("type", type);
|
|
1178
|
-
} else {
|
|
1179
|
-
const el = document.createElement("link");
|
|
1180
|
-
el.rel = "icon";
|
|
1181
|
-
el.type = type;
|
|
1182
|
-
el.href = href;
|
|
1183
|
-
document.head.appendChild(el);
|
|
1184
|
-
}
|
|
1398
|
+
setIconEverywhere(href, type);
|
|
1185
1399
|
// The base icon just changed: rebuild the composite from it.
|
|
1186
1400
|
refreshFaviconBase();
|
|
1187
1401
|
}, []);
|
|
1188
1402
|
|
|
1189
|
-
// When cleared,
|
|
1403
|
+
// When cleared, put the shipped dark/light pair back. Restoring the
|
|
1404
|
+
// authored hrefs (rather than forcing one href on every link) keeps the
|
|
1405
|
+
// platform's scheme-aware icons working after a reset.
|
|
1190
1406
|
const applyDefault = React.useCallback(() => {
|
|
1191
|
-
|
|
1192
|
-
if (link) {
|
|
1193
|
-
link.setAttribute("href", "/favicon.svg");
|
|
1194
|
-
link.setAttribute("type", "image/svg+xml");
|
|
1195
|
-
}
|
|
1407
|
+
restoreShippedIcons();
|
|
1196
1408
|
refreshFaviconBase();
|
|
1197
1409
|
}, []);
|
|
1198
1410
|
|
|
@@ -1538,9 +1750,11 @@ window.__ModuleLoader__.load({
|
|
|
1538
1750
|
inject: () => ({ t })
|
|
1539
1751
|
}, (props) => React.createElement(FaviconSection, { ...props, t, rpc })));
|
|
1540
1752
|
|
|
1541
|
-
//
|
|
1542
|
-
//
|
|
1543
|
-
//
|
|
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.
|
|
1544
1758
|
//
|
|
1545
1759
|
// 0.2.x has no `ctx.sessions.open(id)` (the ClientSessions service exposes
|
|
1546
1760
|
// retain/using/retainInfo/refreshProjections/search/fork/scope/binding — no
|
|
@@ -1551,10 +1765,9 @@ window.__ModuleLoader__.load({
|
|
|
1551
1765
|
const openSession = (id) => {
|
|
1552
1766
|
try { ctx.get("uiWorkspace")?.openSession(id); } catch { /* navigation is best effort */ }
|
|
1553
1767
|
};
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
));
|
|
1768
|
+
// The injected node labels itself for screen readers, so it needs the
|
|
1769
|
+
// locale binder that only exists inside `apply()`.
|
|
1770
|
+
badgeT = t;
|
|
1558
1771
|
ctx.slots.inject("shell.overlay", () => ctx.slots.register(
|
|
1559
1772
|
{ name: "shell.overlay", id: "icon-custom-unread-popup", order: 30 },
|
|
1560
1773
|
() => React.createElement(UnreadPopup, { t, onOpen: openSession })
|
|
@@ -1617,7 +1830,13 @@ window.__ModuleLoader__.load({
|
|
|
1617
1830
|
const schedule = () => {
|
|
1618
1831
|
if (queued) return;
|
|
1619
1832
|
queued = true;
|
|
1620
|
-
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);
|
|
1621
1840
|
};
|
|
1622
1841
|
const attach = () => {
|
|
1623
1842
|
const container = workspaceDotContainer();
|
|
@@ -1644,6 +1863,7 @@ window.__ModuleLoader__.load({
|
|
|
1644
1863
|
offDots();
|
|
1645
1864
|
offConfig();
|
|
1646
1865
|
clearWorkspaceDots();
|
|
1866
|
+
clearHeaderBadge();
|
|
1647
1867
|
};
|
|
1648
1868
|
}, "dsh-icon-custom: workspace dots");
|
|
1649
1869
|
// The platform favicon.svg repaints itself for dark mode through
|
package/lib/index.js
CHANGED
|
@@ -302,6 +302,20 @@ async function deriveSizes(source) {
|
|
|
302
302
|
return out
|
|
303
303
|
}
|
|
304
304
|
|
|
305
|
+
/**
|
|
306
|
+
* Insert markup immediately after the document's opening `<head …>` tag.
|
|
307
|
+
*
|
|
308
|
+
* Tolerates attributes on `<head>` (a bare `/<head>/` test silently missed those,
|
|
309
|
+
* which would drop the icon tag entirely). Returns the input unchanged when the
|
|
310
|
+
* document has no head to anchor to.
|
|
311
|
+
* @param html - full document source.
|
|
312
|
+
* @param markup - tag(s) to inject.
|
|
313
|
+
* @returns the rewritten document.
|
|
314
|
+
*/
|
|
315
|
+
function insertAfterHead(html, markup) {
|
|
316
|
+
return html.replace(/<head[^>]*>/i, (open) => `${open}${markup}`)
|
|
317
|
+
}
|
|
318
|
+
|
|
305
319
|
/**
|
|
306
320
|
* 图标替换服务:保存/读取用户上传的 favicon,并通过 webServer 提供
|
|
307
321
|
* `/icon-custom.svg` 路由与 index 头部改写。通过 typert
|
|
@@ -718,12 +732,16 @@ export class FaviconCustomService extends TypertRemoteService {
|
|
|
718
732
|
: `/icon-custom.svg?v=${c.rev}`
|
|
719
733
|
const favType = c.pngBaseline !== null ? 'image/png' : c.mime
|
|
720
734
|
const tag = `<link rel="icon" type="${favType}" href="${favHref}" />`
|
|
721
|
-
|
|
722
|
-
|
|
735
|
+
// DSH 0.2.x ships TWO `<link rel="icon">` tags — a dark and a light one, each
|
|
736
|
+
// behind a `media` query. Replacing only the first left the light one still
|
|
737
|
+
// serving the platform favicon, and the browser honours the LAST suitable
|
|
738
|
+
// candidate, so a custom icon silently failed to apply in light mode (the tab
|
|
739
|
+
// kept the whale) while looking fine in dark mode. Drop every icon link and
|
|
740
|
+
// insert one scheme-agnostic tag, which wins under either `prefers-color-scheme`.
|
|
741
|
+
out = insertAfterHead(out.replace(/<link\s+rel="icon"[^>]*\/?>/gi, ''), tag)
|
|
723
742
|
if (c.pwa === true && c.pngBaseline !== null) {
|
|
724
743
|
const tag2 = `<link rel="apple-touch-icon" href="/icon-custom-apple-180.png?v=${c.rev}" />`
|
|
725
|
-
|
|
726
|
-
out = re2.test(out) ? out.replace(re2, tag2) : out.replace(/<head>/i, `<head>${tag2}`)
|
|
744
|
+
out = insertAfterHead(out.replace(/<link\s+rel="apple-touch-icon"[^>]*\/?>/gi, ''), tag2)
|
|
727
745
|
}
|
|
728
746
|
return out
|
|
729
747
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cowwo/dsh-icon-custom",
|
|
3
|
-
"version": "0.
|
|
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",
|