vite-plugin-specter 0.7.0 → 0.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/client.js +164 -78
- package/dist/extension-chrome/content.js +164 -78
- package/dist/extension-chrome/manifest.json +1 -1
- package/dist/extension-firefox/content.js +164 -78
- package/dist/extension-firefox/manifest.json +1 -1
- package/dist/index.cjs +164 -78
- package/dist/index.js +164 -78
- package/extension/content.js +164 -78
- package/package.json +1 -1
|
@@ -65,6 +65,45 @@
|
|
|
65
65
|
function markUI(el) { el.setAttribute('data-specter-ui', ''); return el; }
|
|
66
66
|
function isUI(el) { return !!(el && el.closest && el.closest('[data-specter-ui]')); }
|
|
67
67
|
|
|
68
|
+
// ─── Hydration-proof mounting ───────────────────────────────────────────────
|
|
69
|
+
// We inject at document_end, but frameworks that hydrate <body> (Next.js App
|
|
70
|
+
// Router / React 18-19, some Remix/Gatsby) then reconcile away DOM nodes they
|
|
71
|
+
// didn't render — silently deleting Specter's UI on those sites (pill/panel
|
|
72
|
+
// vanish; badges added later survive because they're created post-hydration).
|
|
73
|
+
// Track our persistent singletons and re-attach any that gets detached, so the
|
|
74
|
+
// UI survives the hydration pass and later SPA re-renders. The parent is resolved
|
|
75
|
+
// fresh on each remount so a wholesale body/head element swap is handled too.
|
|
76
|
+
var _mounted = [];
|
|
77
|
+
function mount(node, where) {
|
|
78
|
+
var parent = where === 'head' ? document.head : document.body;
|
|
79
|
+
if (parent) parent.appendChild(node);
|
|
80
|
+
_mounted.push({ node: node, where: where });
|
|
81
|
+
return node;
|
|
82
|
+
}
|
|
83
|
+
var _remountQueued = false;
|
|
84
|
+
function remountDetached() {
|
|
85
|
+
_remountQueued = false;
|
|
86
|
+
for (var i = 0; i < _mounted.length; i++) {
|
|
87
|
+
var m = _mounted[i];
|
|
88
|
+
if (m.node.isConnected) continue;
|
|
89
|
+
var p = m.where === 'head' ? document.head : document.body;
|
|
90
|
+
if (p) p.appendChild(m.node); // re-append; isConnected guard above prevents an observer loop
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
try {
|
|
94
|
+
var _defer = window.requestAnimationFrame ? function (fn) { requestAnimationFrame(fn); } : function (fn) { setTimeout(fn, 0); };
|
|
95
|
+
var _mo = new MutationObserver(function () {
|
|
96
|
+
if (_remountQueued) return;
|
|
97
|
+
_remountQueued = true;
|
|
98
|
+
_defer(remountDetached); // coalesce a burst of mutations into one restore pass
|
|
99
|
+
});
|
|
100
|
+
// childList on the roots is enough — every singleton is a direct child of
|
|
101
|
+
// body/head; watching documentElement too catches a body/head element swap.
|
|
102
|
+
_mo.observe(document.documentElement, { childList: true });
|
|
103
|
+
_mo.observe(document.body, { childList: true });
|
|
104
|
+
_mo.observe(document.head, { childList: true });
|
|
105
|
+
} catch (e) {}
|
|
106
|
+
|
|
68
107
|
// Attach a hover effect to an interactive icon/button: apply the "on" styles
|
|
69
108
|
// while hovered, restore the "off" styles on leave (keeps things clickable-feeling).
|
|
70
109
|
function hoverFx(el, on, off) {
|
|
@@ -91,7 +130,7 @@
|
|
|
91
130
|
boxShadow: '0 4px 16px rgba(0,0,0,0.3)',
|
|
92
131
|
display: 'none',
|
|
93
132
|
});
|
|
94
|
-
|
|
133
|
+
mount(tooltip);
|
|
95
134
|
|
|
96
135
|
// Measure overlay
|
|
97
136
|
var measureOverlay = document.createElement('div');
|
|
@@ -103,7 +142,7 @@
|
|
|
103
142
|
pointerEvents: 'none',
|
|
104
143
|
display: 'none',
|
|
105
144
|
});
|
|
106
|
-
|
|
145
|
+
mount(measureOverlay);
|
|
107
146
|
|
|
108
147
|
// ─── Pill ─────────────────────────────────────────────────────────────────
|
|
109
148
|
var pillWrap = document.createElement('div');
|
|
@@ -179,41 +218,34 @@
|
|
|
179
218
|
var pillText = document.createElement('span');
|
|
180
219
|
Object.assign(pillText.style, { display: 'none', color: '#f3d9fb' });
|
|
181
220
|
|
|
221
|
+
// Panel toggle — icon + label so it's self-explanatory. Label reflects state
|
|
222
|
+
// (Open/Close) and always names the shortcut.
|
|
182
223
|
var listBtn = document.createElement('span');
|
|
183
|
-
listBtn.
|
|
184
|
-
listBtn.title = 'Show Specs panel (L)';
|
|
224
|
+
listBtn.title = 'Toggle Specs panel (L)';
|
|
185
225
|
Object.assign(listBtn.style, {
|
|
186
226
|
display: 'none',
|
|
187
227
|
alignItems: 'center',
|
|
228
|
+
gap: '6px',
|
|
188
229
|
cursor: 'pointer',
|
|
189
230
|
color: '#fff',
|
|
231
|
+
fontSize: '11px',
|
|
190
232
|
flexShrink: '0',
|
|
191
|
-
padding: '4px
|
|
233
|
+
padding: '4px 10px',
|
|
192
234
|
marginLeft: '2px',
|
|
193
235
|
borderRadius: '999px',
|
|
194
236
|
background: 'rgba(255,255,255,0.16)',
|
|
195
237
|
});
|
|
238
|
+
var listIcon = document.createElement('span');
|
|
239
|
+
listIcon.innerHTML = LIST;
|
|
240
|
+
Object.assign(listIcon.style, { display: 'flex', alignItems: 'center' });
|
|
241
|
+
var listLabel = document.createElement('span');
|
|
242
|
+
listBtn.appendChild(listIcon);
|
|
243
|
+
listBtn.appendChild(listLabel);
|
|
244
|
+
function updateListBtn() { listLabel.textContent = (panelOpen ? 'Close' : 'Open') + ' sidebar [L]'; }
|
|
245
|
+
updateListBtn();
|
|
196
246
|
listBtn.addEventListener('click', function (e) { e.stopPropagation(); togglePanel(); });
|
|
197
247
|
hoverFx(listBtn, { background: 'rgba(255,255,255,0.32)' }, { background: 'rgba(255,255,255,0.16)' });
|
|
198
248
|
|
|
199
|
-
var clearBtn = document.createElement('span');
|
|
200
|
-
clearBtn.textContent = '✕ Delete all';
|
|
201
|
-
clearBtn.title = 'Delete all annotations';
|
|
202
|
-
Object.assign(clearBtn.style, {
|
|
203
|
-
display: 'none',
|
|
204
|
-
cursor: 'pointer',
|
|
205
|
-
color: '#fff',
|
|
206
|
-
fontSize: '11px',
|
|
207
|
-
fontWeight: '600',
|
|
208
|
-
flexShrink: '0',
|
|
209
|
-
padding: '2px 8px',
|
|
210
|
-
marginLeft: '2px',
|
|
211
|
-
borderRadius: '999px',
|
|
212
|
-
background: 'rgba(255,255,255,0.16)',
|
|
213
|
-
});
|
|
214
|
-
clearBtn.addEventListener('click', function (e) { e.stopPropagation(); removeAllSpecs(); });
|
|
215
|
-
hoverFx(clearBtn, { background: 'rgba(255,255,255,0.32)' }, { background: 'rgba(255,255,255,0.16)' });
|
|
216
|
-
|
|
217
249
|
var chevron = document.createElement('span');
|
|
218
250
|
chevron.textContent = '›';
|
|
219
251
|
chevron.title = 'Move to other side';
|
|
@@ -243,16 +275,15 @@
|
|
|
243
275
|
pill.appendChild(pillSync);
|
|
244
276
|
pill.appendChild(pillText);
|
|
245
277
|
pill.appendChild(listBtn);
|
|
246
|
-
pill.appendChild(clearBtn);
|
|
247
278
|
pill.appendChild(chevron);
|
|
248
279
|
pillWrap.appendChild(pill);
|
|
249
|
-
|
|
280
|
+
mount(pillWrap);
|
|
250
281
|
|
|
251
282
|
// Keyframes for the sync spinner (injected once).
|
|
252
283
|
var spinStyle = document.createElement('style');
|
|
253
284
|
spinStyle.textContent = '@keyframes __specterSpin{to{transform:rotate(360deg)}}';
|
|
254
285
|
markUI(spinStyle);
|
|
255
|
-
|
|
286
|
+
mount(spinStyle, 'head');
|
|
256
287
|
|
|
257
288
|
// Shared dot styling for the control-bar AND side-panel sync indicators, so they
|
|
258
289
|
// always match: spinner while syncing, green when synced, red on error.
|
|
@@ -304,7 +335,7 @@
|
|
|
304
335
|
// The mode is shown at all times (persistent prefix), so you always know whether
|
|
305
336
|
// hovering shows properties, measurements, or nothing (Comment).
|
|
306
337
|
function modeLabel() {
|
|
307
|
-
return commentMode ? 'Comment' : (measureMode ? 'Measure' : 'Properties');
|
|
338
|
+
return (commentMode ? 'Comment' : (measureMode ? 'Measure' : 'Properties')) + ' mode';
|
|
308
339
|
}
|
|
309
340
|
|
|
310
341
|
function expandPill(text) {
|
|
@@ -312,7 +343,7 @@
|
|
|
312
343
|
pillText.style.display = 'inline';
|
|
313
344
|
chevron.style.display = 'inline';
|
|
314
345
|
listBtn.style.display = 'inline-flex'; // always reachable — the panel is also where you Import a shared file
|
|
315
|
-
|
|
346
|
+
updateListBtn();
|
|
316
347
|
pillExpanded = true;
|
|
317
348
|
// Just the mode (+ the action buttons when Specs exist). Shortcuts live in the
|
|
318
349
|
// side panel now, so the pill stays short.
|
|
@@ -327,14 +358,12 @@
|
|
|
327
358
|
pill.style.maxWidth = '220px';
|
|
328
359
|
chevron.style.display = 'none';
|
|
329
360
|
listBtn.style.display = 'none';
|
|
330
|
-
clearBtn.style.display = 'none';
|
|
331
361
|
pillExpanded = false;
|
|
332
362
|
}
|
|
333
363
|
|
|
334
364
|
function flashMode() {
|
|
335
365
|
if (pillExpanded && pillWrap.matches(':hover')) return;
|
|
336
|
-
|
|
337
|
-
expandPill(text);
|
|
366
|
+
expandPill(modeLabel());
|
|
338
367
|
clearTimeout(flashTimer);
|
|
339
368
|
flashTimer = setTimeout(function () {
|
|
340
369
|
if (!pillWrap.matches(':hover') && specs.length === 0 && !pinEl) collapsePill();
|
|
@@ -460,10 +489,13 @@
|
|
|
460
489
|
var cur = el;
|
|
461
490
|
for (var i = 0; i < 3 && cur && cur !== document.body; i++) {
|
|
462
491
|
var part = cur.tagName.toLowerCase();
|
|
463
|
-
if (cur.id) { part += '#' + cur.id; parts.unshift(part); break; }
|
|
492
|
+
if (cur.id) { part += '#' + cssEsc(cur.id); parts.unshift(part); break; }
|
|
464
493
|
var cls = Array.prototype.slice.call(cur.classList).filter(function (c) { return c.indexOf('__specter') !== 0; });
|
|
465
494
|
if (cls.length) {
|
|
466
|
-
|
|
495
|
+
// Escape each class: Tailwind classes like lg:block / bg-[#f5f5dc] are
|
|
496
|
+
// invalid raw in a selector (the : reads as a pseudo, [# as an attr) and
|
|
497
|
+
// throw on query, so they must be CSS.escape-d first.
|
|
498
|
+
part += '.' + cls.slice(0, 2).map(cssEsc).join('.');
|
|
467
499
|
} else if (cur.parentElement) {
|
|
468
500
|
var sameTag = Array.prototype.slice.call(cur.parentElement.children).filter(function (c) { return c.tagName === cur.tagName; });
|
|
469
501
|
if (sameTag.length > 1) {
|
|
@@ -543,13 +575,13 @@
|
|
|
543
575
|
var i, v;
|
|
544
576
|
var testAttrs = ['data-testid', 'data-test-id', 'data-test', 'data-cy', 'data-qa'];
|
|
545
577
|
for (i = 0; i < testAttrs.length; i++) { v = el.getAttribute(testAttrs[i]); if (v) { var ts = '[' + testAttrs[i] + '="' + cssEsc(v) + '"]'; if (uniqueSel(ts)) return ts; } }
|
|
546
|
-
if (el.id && !isHashedId(el.id) && uniqueSel('#' + cssEsc(el.id))) return '#' + el.id;
|
|
578
|
+
if (el.id && !isHashedId(el.id) && uniqueSel('#' + cssEsc(el.id))) return '#' + cssEsc(el.id);
|
|
547
579
|
var attrs = ['aria-label', 'name', 'placeholder', 'alt', 'title'];
|
|
548
580
|
for (i = 0; i < attrs.length; i++) { v = el.getAttribute(attrs[i]); if (v && v.trim()) { v = v.trim(); if (uniqueSel('[' + attrs[i] + '="' + cssEsc(v) + '"]')) return attrs[i] + ' "' + v.slice(0, 60) + '"'; } }
|
|
549
581
|
var txt = ownText(el);
|
|
550
582
|
if (txt && uniqueTextAnchor(el, txt)) return 'text "' + txt.slice(0, 40) + (txt.length > 40 ? '…' : '') + '"';
|
|
551
583
|
var cls = ownClass(el);
|
|
552
|
-
if (cls) return '.' + cls;
|
|
584
|
+
if (cls) return '.' + cssEsc(cls);
|
|
553
585
|
return getSelector(el); // fallback: the CSS path
|
|
554
586
|
}
|
|
555
587
|
|
|
@@ -723,11 +755,20 @@
|
|
|
723
755
|
function elementPath(el) {
|
|
724
756
|
if (!el || el.nodeType !== 1) return '';
|
|
725
757
|
var parts = [], cur = el;
|
|
726
|
-
|
|
727
|
-
|
|
758
|
+
// Root the path at <body> or a stable id, and index with nth-OF-TYPE rather than
|
|
759
|
+
// nth-child: a hydrated page injects <script>/<style> siblings that shift a raw
|
|
760
|
+
// child index (the old div:nth-child(11) bug — matched a different node, or
|
|
761
|
+
// none, on the recipient). nth-of-type counts only same-tag siblings, so it
|
|
762
|
+
// survives that. Deeper cap (12) so the chain reaches a rooting anchor.
|
|
763
|
+
while (cur && cur.nodeType === 1 && parts.length < 12) {
|
|
764
|
+
if (cur === document.body) { parts.unshift('body'); break; }
|
|
765
|
+
if (cur.id && !isHashedId(cur.id)) { parts.unshift('#' + cssEsc(cur.id)); break; }
|
|
728
766
|
var seg = cur.tagName.toLowerCase();
|
|
729
767
|
var parent = cur.parentElement;
|
|
730
|
-
if (parent)
|
|
768
|
+
if (parent) {
|
|
769
|
+
var same = Array.prototype.filter.call(parent.children, function (c) { return c.tagName === cur.tagName; });
|
|
770
|
+
if (same.length > 1) seg += ':nth-of-type(' + (Array.prototype.indexOf.call(same, cur) + 1) + ')';
|
|
771
|
+
}
|
|
731
772
|
parts.unshift(seg);
|
|
732
773
|
cur = cur.parentElement;
|
|
733
774
|
}
|
|
@@ -803,6 +844,11 @@
|
|
|
803
844
|
var vis = [];
|
|
804
845
|
for (var i = 0; i < specs.length; i++) {
|
|
805
846
|
var s = specs[i];
|
|
847
|
+
// Badges created at import-time can be wiped by a framework hydrating <body>
|
|
848
|
+
// (same cause as the singleton mount guard). Re-attach a detached wrap so the
|
|
849
|
+
// pin reappears; a removed spec is already out of the specs array, so this
|
|
850
|
+
// never resurrects a deleted badge.
|
|
851
|
+
if (s.wrap && !s.wrap.isConnected && document.body) document.body.appendChild(s.wrap);
|
|
806
852
|
if (!fiActive) { s.wrap.style.display = 'none'; s._liveVis = false; continue; }
|
|
807
853
|
if (s.missing) { s.wrap.style.display = 'none'; s._liveVis = false; continue; } // shared comment whose target is gone/changed
|
|
808
854
|
if (!s.el || !s.el.isConnected) { var f = safeQuery(s.path); if (f) s.el = f; }
|
|
@@ -1002,6 +1048,16 @@
|
|
|
1002
1048
|
saveSpecs();
|
|
1003
1049
|
}
|
|
1004
1050
|
|
|
1051
|
+
// Drop every imported (shared) spec, leaving the user's own local ones. Used by
|
|
1052
|
+
// importComments so a received share replaces the prior set instead of stacking.
|
|
1053
|
+
function removeSharedSpecs() {
|
|
1054
|
+
if (highlightSpec && highlightSpec.shared) highlightSpec = null;
|
|
1055
|
+
if (panelEditSpec && panelEditSpec.shared) panelEditSpec = null;
|
|
1056
|
+
for (var i = specs.length - 1; i >= 0; i--) {
|
|
1057
|
+
if (specs[i].shared) { if (specs[i].wrap) specs[i].wrap.remove(); specs.splice(i, 1); }
|
|
1058
|
+
}
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1005
1061
|
// ─── Reload insurance (localStorage, per-URL, zero network) ──────────────────
|
|
1006
1062
|
// Persist only the serializable parts of each Spec; the live element ref and
|
|
1007
1063
|
// DOM nodes are rebuilt on restore (re-anchored best-effort via the CSS path).
|
|
@@ -1033,11 +1089,13 @@
|
|
|
1033
1089
|
// element is gone OR its signature changed, the comment shows as MISSING (panel
|
|
1034
1090
|
// only, greyed, with a reason) instead of being drawn on a guessed spot.
|
|
1035
1091
|
|
|
1036
|
-
// Normalized element signature
|
|
1037
|
-
//
|
|
1038
|
-
//
|
|
1039
|
-
//
|
|
1040
|
-
//
|
|
1092
|
+
// Normalized element signature — a STRUCTURAL identity used to disambiguate which
|
|
1093
|
+
// element a shared comment belongs to (and to scan for it when selectors fail).
|
|
1094
|
+
// Deliberately NO raw text: page-load-dynamic text (a clock, a random greeting, a
|
|
1095
|
+
// price) was false-tripping this and blocking re-anchor on otherwise-identical
|
|
1096
|
+
// pages. tag + sorted own classes + key attrs + child count identifies the node
|
|
1097
|
+
// without that fragility. (Trade-off: pure text edits under a stable node no
|
|
1098
|
+
// longer read as "changed" — placement is favoured over change-detection.)
|
|
1041
1099
|
function normSig(el) {
|
|
1042
1100
|
if (!el || el.nodeType !== 1) return '';
|
|
1043
1101
|
var cls = Array.prototype.slice.call(el.classList)
|
|
@@ -1045,8 +1103,7 @@
|
|
|
1045
1103
|
var attrs = ['type', 'role', 'name', 'href', 'aria-label'].map(function (a) {
|
|
1046
1104
|
var v = el.getAttribute(a); return v ? a + '=' + v.trim() : '';
|
|
1047
1105
|
}).filter(Boolean).join('|');
|
|
1048
|
-
|
|
1049
|
-
return el.tagName.toLowerCase() + '#' + cls + '#' + attrs + '#' + txt;
|
|
1106
|
+
return el.tagName.toLowerCase() + '#' + cls + '#' + attrs + '#' + el.childElementCount;
|
|
1050
1107
|
}
|
|
1051
1108
|
// djb2 xor → short base36 hash. Zero-dep; collisions don't matter (a match just
|
|
1052
1109
|
// means "unchanged enough", and the re-find already narrowed us to one element).
|
|
@@ -1058,51 +1115,73 @@
|
|
|
1058
1115
|
return (h >>> 0).toString(36);
|
|
1059
1116
|
}
|
|
1060
1117
|
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
//
|
|
1064
|
-
|
|
1065
|
-
|
|
1118
|
+
function queryAll(sel) { try { return sel ? Array.prototype.slice.call(document.querySelectorAll(sel)) : []; } catch (e) { return []; } }
|
|
1119
|
+
|
|
1120
|
+
// Every element whose OWN text matches (trunc = the stored anchor was cut to 40
|
|
1121
|
+
// chars). Returns ALL matches — the fingerprint picks among them in reFindShared,
|
|
1122
|
+
// so a repeated label (e.g. two "Read more") no longer forces a MISSING. Skips UI.
|
|
1123
|
+
function findAllByOwnText(val, trunc) {
|
|
1124
|
+
var all = document.body ? document.body.getElementsByTagName('*') : [], out = [];
|
|
1066
1125
|
for (var i = 0; i < all.length; i++) {
|
|
1067
1126
|
var el = all[i];
|
|
1068
1127
|
if (el.closest && el.closest('[data-specter-ui]')) continue;
|
|
1069
1128
|
var t = ownText(el);
|
|
1070
1129
|
if (!t) continue;
|
|
1071
|
-
if (trunc ? (t.slice(0, 40) === val) : (t === val))
|
|
1130
|
+
if (trunc ? (t.slice(0, 40) === val) : (t === val)) out.push(el);
|
|
1072
1131
|
}
|
|
1073
|
-
return
|
|
1132
|
+
return out;
|
|
1074
1133
|
}
|
|
1075
1134
|
|
|
1076
|
-
//
|
|
1077
|
-
// 'attr "value"' rebuilds the attribute selector; 'text "value"'
|
|
1078
|
-
//
|
|
1079
|
-
//
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
if (!find) return null;
|
|
1135
|
+
// Candidate elements for a resolveLocator() anchor: #id/.class/[attr]/CSS-path via
|
|
1136
|
+
// querySelectorAll; 'attr "value"' rebuilds the attribute selector; 'text "value"'
|
|
1137
|
+
// scans own-text. Returns ALL matches (may be >1) — reFindShared narrows by
|
|
1138
|
+
// fingerprint rather than rejecting anything ambiguous up front.
|
|
1139
|
+
function resolveFindCandidates(find) {
|
|
1140
|
+
if (!find) return [];
|
|
1083
1141
|
var c = find.charAt(0);
|
|
1084
|
-
if (c === '#' || c === '.' || c === '[') return
|
|
1142
|
+
if (c === '#' || c === '.' || c === '[') return queryAll(find);
|
|
1085
1143
|
var q = find.indexOf(' "');
|
|
1086
1144
|
if (q > 0 && find.charAt(find.length - 1) === '"') {
|
|
1087
1145
|
var attr = find.slice(0, q), val = find.slice(q + 2, -1), trunc = false;
|
|
1088
1146
|
if (val.charAt(val.length - 1) === '…') { trunc = true; val = val.slice(0, -1); }
|
|
1089
|
-
if (attr === 'text') return
|
|
1090
|
-
if (val.indexOf('"') < 0)
|
|
1091
|
-
return
|
|
1147
|
+
if (attr === 'text') return findAllByOwnText(val, trunc);
|
|
1148
|
+
if (val.indexOf('"') < 0) return queryAll('[' + attr + '="' + val + '"]');
|
|
1149
|
+
return [];
|
|
1092
1150
|
}
|
|
1093
|
-
return
|
|
1151
|
+
return queryAll(find); // a bare CSS path
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
// Re-find a shared comment's element. The fingerprint is the DISAMBIGUATOR, not
|
|
1155
|
+
// just a change-detector: gather every candidate from the find anchor AND the
|
|
1156
|
+
// nth-child path, then return the one whose signature matches — so a locator
|
|
1157
|
+
// shared by siblings (e.g. div:nth-child(1)) still resolves on the same page
|
|
1158
|
+
// instead of failing as ambiguous. With no fp match: hand back a lone candidate
|
|
1159
|
+
// (so importComments can say "changed"), else null ("couldn't find").
|
|
1160
|
+
// Whole-DOM scan for the one element whose structural signature matches — the
|
|
1161
|
+
// last-resort re-finder when both the find anchor and the nth-of-type path fail
|
|
1162
|
+
// (e.g. selectors that don't round-trip, or a shifted structure). >1 match → bail
|
|
1163
|
+
// (ambiguous, don't guess). Skips Specter's own UI.
|
|
1164
|
+
function findByFingerprint(fp) {
|
|
1165
|
+
if (!fp) return null;
|
|
1166
|
+
var all = document.body ? document.body.getElementsByTagName('*') : [], hit = null;
|
|
1167
|
+
for (var i = 0; i < all.length; i++) {
|
|
1168
|
+
var el = all[i];
|
|
1169
|
+
if (el.closest && el.closest('[data-specter-ui]')) continue;
|
|
1170
|
+
if (fingerprint(el) === fp) { if (hit) return null; hit = el; }
|
|
1171
|
+
}
|
|
1172
|
+
return hit;
|
|
1094
1173
|
}
|
|
1095
1174
|
|
|
1096
|
-
// Re-find a shared comment's element, using the fingerprint to DISAMBIGUATE (not
|
|
1097
|
-
// only to detect change): prefer whichever candidate — the find anchor or the
|
|
1098
|
-
// nth-child path — actually matches the stored signature. Falls back to a
|
|
1099
|
-
// best-effort element so importComments can still tell "changed" from "not found".
|
|
1100
1175
|
function reFindShared(item) {
|
|
1101
|
-
var
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
if (
|
|
1105
|
-
|
|
1176
|
+
var cands = resolveFindCandidates(item.find).concat(queryAll(item.path));
|
|
1177
|
+
var uniq = [], i;
|
|
1178
|
+
for (i = 0; i < cands.length; i++) if (cands[i] && uniq.indexOf(cands[i]) < 0) uniq.push(cands[i]);
|
|
1179
|
+
if (item.fp) {
|
|
1180
|
+
for (i = 0; i < uniq.length; i++) if (fingerprint(uniq[i]) === item.fp) return uniq[i]; // fp picks the right candidate
|
|
1181
|
+
var scan = findByFingerprint(item.fp); // selectors failed/ambiguous → find it by signature
|
|
1182
|
+
if (scan) return scan;
|
|
1183
|
+
}
|
|
1184
|
+
return uniq.length === 1 ? uniq[0] : null; // last resort: a lone unambiguous candidate
|
|
1106
1185
|
}
|
|
1107
1186
|
|
|
1108
1187
|
// URL gate: two people must be on the SAME page for a shared comment to place.
|
|
@@ -1133,12 +1212,19 @@
|
|
|
1133
1212
|
console.warn('[Specter] These comments are for ' + pageKey(srcUrl) + ' — not this page (' + pageKey() + '). Not imported.');
|
|
1134
1213
|
return 0;
|
|
1135
1214
|
}
|
|
1215
|
+
// A received share REPLACES the previously-imported set: re-opening the same
|
|
1216
|
+
// link (or a refreshed one) gives exactly that set, never a stacked-up pile of
|
|
1217
|
+
// duplicates across rounds. Your OWN local specs (shared:false) are untouched.
|
|
1218
|
+
removeSharedSpecs();
|
|
1136
1219
|
var added = 0;
|
|
1137
1220
|
comments.forEach(function (item) {
|
|
1138
1221
|
if (!item) return;
|
|
1222
|
+
// reFindShared already used the fingerprint to pick/scan the right element, so
|
|
1223
|
+
// trust its result: if it located a node, PLACE the comment. Only a genuine
|
|
1224
|
+
// no-match is MISSING — we no longer hide a found element just because its
|
|
1225
|
+
// signature drifted (that over-fired on dynamic pages and buried real pins).
|
|
1139
1226
|
var el = reFindShared(item), missing = false, reason = '';
|
|
1140
1227
|
if (!el) { missing = true; reason = 'Couldn’t find this element on the page'; }
|
|
1141
|
-
else if (item.fp && fingerprint(el) !== item.fp) { missing = true; reason = 'This element changed — can’t place the comment'; }
|
|
1142
1228
|
// body stays empty on purpose: comments-only, never the shared element's props.
|
|
1143
1229
|
// A MISSING spec keeps el=null so it's never drawn or re-anchored via its path.
|
|
1144
1230
|
var spec = { el: missing ? null : el, path: item.path || '', note: item.note || '', body: '', kind: item.kind || 'element', locate: '', shared: true, fp: item.fp || '', missing: missing, missReason: reason };
|
|
@@ -1517,7 +1603,7 @@
|
|
|
1517
1603
|
Object.assign(body.style, { display: 'none', padding: '0 16px 12px' }); // collapsed by default
|
|
1518
1604
|
rows.forEach(function (r) {
|
|
1519
1605
|
var row = document.createElement('div');
|
|
1520
|
-
Object.assign(row.style, { display: 'flex', gap: '8px', marginBottom: '
|
|
1606
|
+
Object.assign(row.style, { display: 'flex', gap: '8px', marginBottom: '8px' });
|
|
1521
1607
|
var k = document.createElement('span');
|
|
1522
1608
|
k.textContent = r[0];
|
|
1523
1609
|
Object.assign(k.style, { color: '#E0A3F5', fontWeight: '700', minWidth: '78px', flexShrink: '0' });
|
|
@@ -1542,7 +1628,7 @@
|
|
|
1542
1628
|
panelWrap.appendChild(panelHint);
|
|
1543
1629
|
panelWrap.appendChild(panelList);
|
|
1544
1630
|
panelWrap.appendChild(panelKeys);
|
|
1545
|
-
|
|
1631
|
+
mount(panelWrap);
|
|
1546
1632
|
|
|
1547
1633
|
// Panel scroll and page scroll are mutually exclusive: wheel over the scrollable list
|
|
1548
1634
|
// scrolls it natively (overscroll-behavior:contain stops it chaining at the bounds);
|
|
@@ -1801,8 +1887,8 @@
|
|
|
1801
1887
|
panelVisSig = liveVisSig(); // record what this render reflects, so reflow only re-renders on a real flip
|
|
1802
1888
|
}
|
|
1803
1889
|
|
|
1804
|
-
function showPanel() { panelOpen = true; renderPanel(); panelWrap.style.transform = 'translateX(0)'; if (BRIDGE) doSync(); }
|
|
1805
|
-
function hidePanel() { panelOpen = false; panelEditSpec = null; panelWrap.style.transform = 'translateX(100%)'; }
|
|
1890
|
+
function showPanel() { panelOpen = true; renderPanel(); panelWrap.style.transform = 'translateX(0)'; updateListBtn(); if (BRIDGE) doSync(); }
|
|
1891
|
+
function hidePanel() { panelOpen = false; panelEditSpec = null; panelWrap.style.transform = 'translateX(100%)'; updateListBtn(); }
|
|
1806
1892
|
function togglePanel() { if (panelOpen) hidePanel(); else if (fiActive) showPanel(); }
|
|
1807
1893
|
|
|
1808
1894
|
// ─── Spec editor (annotation box: add / edit / delete) ───────────────────────
|