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
package/dist/index.js
CHANGED
|
@@ -70,6 +70,45 @@ function getClientScript(options) {
|
|
|
70
70
|
function markUI(el) { el.setAttribute('data-specter-ui', ''); return el; }
|
|
71
71
|
function isUI(el) { return !!(el && el.closest && el.closest('[data-specter-ui]')); }
|
|
72
72
|
|
|
73
|
+
// \u2500\u2500\u2500 Hydration-proof mounting \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
74
|
+
// We inject at document_end, but frameworks that hydrate <body> (Next.js App
|
|
75
|
+
// Router / React 18-19, some Remix/Gatsby) then reconcile away DOM nodes they
|
|
76
|
+
// didn't render \u2014 silently deleting Specter's UI on those sites (pill/panel
|
|
77
|
+
// vanish; badges added later survive because they're created post-hydration).
|
|
78
|
+
// Track our persistent singletons and re-attach any that gets detached, so the
|
|
79
|
+
// UI survives the hydration pass and later SPA re-renders. The parent is resolved
|
|
80
|
+
// fresh on each remount so a wholesale body/head element swap is handled too.
|
|
81
|
+
var _mounted = [];
|
|
82
|
+
function mount(node, where) {
|
|
83
|
+
var parent = where === 'head' ? document.head : document.body;
|
|
84
|
+
if (parent) parent.appendChild(node);
|
|
85
|
+
_mounted.push({ node: node, where: where });
|
|
86
|
+
return node;
|
|
87
|
+
}
|
|
88
|
+
var _remountQueued = false;
|
|
89
|
+
function remountDetached() {
|
|
90
|
+
_remountQueued = false;
|
|
91
|
+
for (var i = 0; i < _mounted.length; i++) {
|
|
92
|
+
var m = _mounted[i];
|
|
93
|
+
if (m.node.isConnected) continue;
|
|
94
|
+
var p = m.where === 'head' ? document.head : document.body;
|
|
95
|
+
if (p) p.appendChild(m.node); // re-append; isConnected guard above prevents an observer loop
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
try {
|
|
99
|
+
var _defer = window.requestAnimationFrame ? function (fn) { requestAnimationFrame(fn); } : function (fn) { setTimeout(fn, 0); };
|
|
100
|
+
var _mo = new MutationObserver(function () {
|
|
101
|
+
if (_remountQueued) return;
|
|
102
|
+
_remountQueued = true;
|
|
103
|
+
_defer(remountDetached); // coalesce a burst of mutations into one restore pass
|
|
104
|
+
});
|
|
105
|
+
// childList on the roots is enough \u2014 every singleton is a direct child of
|
|
106
|
+
// body/head; watching documentElement too catches a body/head element swap.
|
|
107
|
+
_mo.observe(document.documentElement, { childList: true });
|
|
108
|
+
_mo.observe(document.body, { childList: true });
|
|
109
|
+
_mo.observe(document.head, { childList: true });
|
|
110
|
+
} catch (e) {}
|
|
111
|
+
|
|
73
112
|
// Attach a hover effect to an interactive icon/button: apply the "on" styles
|
|
74
113
|
// while hovered, restore the "off" styles on leave (keeps things clickable-feeling).
|
|
75
114
|
function hoverFx(el, on, off) {
|
|
@@ -96,7 +135,7 @@ function getClientScript(options) {
|
|
|
96
135
|
boxShadow: '0 4px 16px rgba(0,0,0,0.3)',
|
|
97
136
|
display: 'none',
|
|
98
137
|
});
|
|
99
|
-
|
|
138
|
+
mount(tooltip);
|
|
100
139
|
|
|
101
140
|
// Measure overlay
|
|
102
141
|
var measureOverlay = document.createElement('div');
|
|
@@ -108,7 +147,7 @@ function getClientScript(options) {
|
|
|
108
147
|
pointerEvents: 'none',
|
|
109
148
|
display: 'none',
|
|
110
149
|
});
|
|
111
|
-
|
|
150
|
+
mount(measureOverlay);
|
|
112
151
|
|
|
113
152
|
// \u2500\u2500\u2500 Pill \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
114
153
|
var pillWrap = document.createElement('div');
|
|
@@ -184,41 +223,34 @@ function getClientScript(options) {
|
|
|
184
223
|
var pillText = document.createElement('span');
|
|
185
224
|
Object.assign(pillText.style, { display: 'none', color: '#f3d9fb' });
|
|
186
225
|
|
|
226
|
+
// Panel toggle \u2014 icon + label so it's self-explanatory. Label reflects state
|
|
227
|
+
// (Open/Close) and always names the shortcut.
|
|
187
228
|
var listBtn = document.createElement('span');
|
|
188
|
-
listBtn.
|
|
189
|
-
listBtn.title = 'Show Specs panel (L)';
|
|
229
|
+
listBtn.title = 'Toggle Specs panel (L)';
|
|
190
230
|
Object.assign(listBtn.style, {
|
|
191
231
|
display: 'none',
|
|
192
232
|
alignItems: 'center',
|
|
233
|
+
gap: '6px',
|
|
193
234
|
cursor: 'pointer',
|
|
194
235
|
color: '#fff',
|
|
236
|
+
fontSize: '11px',
|
|
195
237
|
flexShrink: '0',
|
|
196
|
-
padding: '4px
|
|
238
|
+
padding: '4px 10px',
|
|
197
239
|
marginLeft: '2px',
|
|
198
240
|
borderRadius: '999px',
|
|
199
241
|
background: 'rgba(255,255,255,0.16)',
|
|
200
242
|
});
|
|
243
|
+
var listIcon = document.createElement('span');
|
|
244
|
+
listIcon.innerHTML = LIST;
|
|
245
|
+
Object.assign(listIcon.style, { display: 'flex', alignItems: 'center' });
|
|
246
|
+
var listLabel = document.createElement('span');
|
|
247
|
+
listBtn.appendChild(listIcon);
|
|
248
|
+
listBtn.appendChild(listLabel);
|
|
249
|
+
function updateListBtn() { listLabel.textContent = (panelOpen ? 'Close' : 'Open') + ' sidebar [L]'; }
|
|
250
|
+
updateListBtn();
|
|
201
251
|
listBtn.addEventListener('click', function (e) { e.stopPropagation(); togglePanel(); });
|
|
202
252
|
hoverFx(listBtn, { background: 'rgba(255,255,255,0.32)' }, { background: 'rgba(255,255,255,0.16)' });
|
|
203
253
|
|
|
204
|
-
var clearBtn = document.createElement('span');
|
|
205
|
-
clearBtn.textContent = '\u2715 Delete all';
|
|
206
|
-
clearBtn.title = 'Delete all annotations';
|
|
207
|
-
Object.assign(clearBtn.style, {
|
|
208
|
-
display: 'none',
|
|
209
|
-
cursor: 'pointer',
|
|
210
|
-
color: '#fff',
|
|
211
|
-
fontSize: '11px',
|
|
212
|
-
fontWeight: '600',
|
|
213
|
-
flexShrink: '0',
|
|
214
|
-
padding: '2px 8px',
|
|
215
|
-
marginLeft: '2px',
|
|
216
|
-
borderRadius: '999px',
|
|
217
|
-
background: 'rgba(255,255,255,0.16)',
|
|
218
|
-
});
|
|
219
|
-
clearBtn.addEventListener('click', function (e) { e.stopPropagation(); removeAllSpecs(); });
|
|
220
|
-
hoverFx(clearBtn, { background: 'rgba(255,255,255,0.32)' }, { background: 'rgba(255,255,255,0.16)' });
|
|
221
|
-
|
|
222
254
|
var chevron = document.createElement('span');
|
|
223
255
|
chevron.textContent = '\u203A';
|
|
224
256
|
chevron.title = 'Move to other side';
|
|
@@ -248,16 +280,15 @@ function getClientScript(options) {
|
|
|
248
280
|
pill.appendChild(pillSync);
|
|
249
281
|
pill.appendChild(pillText);
|
|
250
282
|
pill.appendChild(listBtn);
|
|
251
|
-
pill.appendChild(clearBtn);
|
|
252
283
|
pill.appendChild(chevron);
|
|
253
284
|
pillWrap.appendChild(pill);
|
|
254
|
-
|
|
285
|
+
mount(pillWrap);
|
|
255
286
|
|
|
256
287
|
// Keyframes for the sync spinner (injected once).
|
|
257
288
|
var spinStyle = document.createElement('style');
|
|
258
289
|
spinStyle.textContent = '@keyframes __specterSpin{to{transform:rotate(360deg)}}';
|
|
259
290
|
markUI(spinStyle);
|
|
260
|
-
|
|
291
|
+
mount(spinStyle, 'head');
|
|
261
292
|
|
|
262
293
|
// Shared dot styling for the control-bar AND side-panel sync indicators, so they
|
|
263
294
|
// always match: spinner while syncing, green when synced, red on error.
|
|
@@ -309,7 +340,7 @@ function getClientScript(options) {
|
|
|
309
340
|
// The mode is shown at all times (persistent prefix), so you always know whether
|
|
310
341
|
// hovering shows properties, measurements, or nothing (Comment).
|
|
311
342
|
function modeLabel() {
|
|
312
|
-
return commentMode ? 'Comment' : (measureMode ? 'Measure' : 'Properties');
|
|
343
|
+
return (commentMode ? 'Comment' : (measureMode ? 'Measure' : 'Properties')) + ' mode';
|
|
313
344
|
}
|
|
314
345
|
|
|
315
346
|
function expandPill(text) {
|
|
@@ -317,7 +348,7 @@ function getClientScript(options) {
|
|
|
317
348
|
pillText.style.display = 'inline';
|
|
318
349
|
chevron.style.display = 'inline';
|
|
319
350
|
listBtn.style.display = 'inline-flex'; // always reachable \u2014 the panel is also where you Import a shared file
|
|
320
|
-
|
|
351
|
+
updateListBtn();
|
|
321
352
|
pillExpanded = true;
|
|
322
353
|
// Just the mode (+ the action buttons when Specs exist). Shortcuts live in the
|
|
323
354
|
// side panel now, so the pill stays short.
|
|
@@ -332,14 +363,12 @@ function getClientScript(options) {
|
|
|
332
363
|
pill.style.maxWidth = '220px';
|
|
333
364
|
chevron.style.display = 'none';
|
|
334
365
|
listBtn.style.display = 'none';
|
|
335
|
-
clearBtn.style.display = 'none';
|
|
336
366
|
pillExpanded = false;
|
|
337
367
|
}
|
|
338
368
|
|
|
339
369
|
function flashMode() {
|
|
340
370
|
if (pillExpanded && pillWrap.matches(':hover')) return;
|
|
341
|
-
|
|
342
|
-
expandPill(text);
|
|
371
|
+
expandPill(modeLabel());
|
|
343
372
|
clearTimeout(flashTimer);
|
|
344
373
|
flashTimer = setTimeout(function () {
|
|
345
374
|
if (!pillWrap.matches(':hover') && specs.length === 0 && !pinEl) collapsePill();
|
|
@@ -465,10 +494,13 @@ function getClientScript(options) {
|
|
|
465
494
|
var cur = el;
|
|
466
495
|
for (var i = 0; i < 3 && cur && cur !== document.body; i++) {
|
|
467
496
|
var part = cur.tagName.toLowerCase();
|
|
468
|
-
if (cur.id) { part += '#' + cur.id; parts.unshift(part); break; }
|
|
497
|
+
if (cur.id) { part += '#' + cssEsc(cur.id); parts.unshift(part); break; }
|
|
469
498
|
var cls = Array.prototype.slice.call(cur.classList).filter(function (c) { return c.indexOf('__specter') !== 0; });
|
|
470
499
|
if (cls.length) {
|
|
471
|
-
|
|
500
|
+
// Escape each class: Tailwind classes like lg:block / bg-[#f5f5dc] are
|
|
501
|
+
// invalid raw in a selector (the : reads as a pseudo, [# as an attr) and
|
|
502
|
+
// throw on query, so they must be CSS.escape-d first.
|
|
503
|
+
part += '.' + cls.slice(0, 2).map(cssEsc).join('.');
|
|
472
504
|
} else if (cur.parentElement) {
|
|
473
505
|
var sameTag = Array.prototype.slice.call(cur.parentElement.children).filter(function (c) { return c.tagName === cur.tagName; });
|
|
474
506
|
if (sameTag.length > 1) {
|
|
@@ -548,13 +580,13 @@ function getClientScript(options) {
|
|
|
548
580
|
var i, v;
|
|
549
581
|
var testAttrs = ['data-testid', 'data-test-id', 'data-test', 'data-cy', 'data-qa'];
|
|
550
582
|
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; } }
|
|
551
|
-
if (el.id && !isHashedId(el.id) && uniqueSel('#' + cssEsc(el.id))) return '#' + el.id;
|
|
583
|
+
if (el.id && !isHashedId(el.id) && uniqueSel('#' + cssEsc(el.id))) return '#' + cssEsc(el.id);
|
|
552
584
|
var attrs = ['aria-label', 'name', 'placeholder', 'alt', 'title'];
|
|
553
585
|
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) + '"'; } }
|
|
554
586
|
var txt = ownText(el);
|
|
555
587
|
if (txt && uniqueTextAnchor(el, txt)) return 'text "' + txt.slice(0, 40) + (txt.length > 40 ? '\u2026' : '') + '"';
|
|
556
588
|
var cls = ownClass(el);
|
|
557
|
-
if (cls) return '.' + cls;
|
|
589
|
+
if (cls) return '.' + cssEsc(cls);
|
|
558
590
|
return getSelector(el); // fallback: the CSS path
|
|
559
591
|
}
|
|
560
592
|
|
|
@@ -728,11 +760,20 @@ function getClientScript(options) {
|
|
|
728
760
|
function elementPath(el) {
|
|
729
761
|
if (!el || el.nodeType !== 1) return '';
|
|
730
762
|
var parts = [], cur = el;
|
|
731
|
-
|
|
732
|
-
|
|
763
|
+
// Root the path at <body> or a stable id, and index with nth-OF-TYPE rather than
|
|
764
|
+
// nth-child: a hydrated page injects <script>/<style> siblings that shift a raw
|
|
765
|
+
// child index (the old div:nth-child(11) bug \u2014 matched a different node, or
|
|
766
|
+
// none, on the recipient). nth-of-type counts only same-tag siblings, so it
|
|
767
|
+
// survives that. Deeper cap (12) so the chain reaches a rooting anchor.
|
|
768
|
+
while (cur && cur.nodeType === 1 && parts.length < 12) {
|
|
769
|
+
if (cur === document.body) { parts.unshift('body'); break; }
|
|
770
|
+
if (cur.id && !isHashedId(cur.id)) { parts.unshift('#' + cssEsc(cur.id)); break; }
|
|
733
771
|
var seg = cur.tagName.toLowerCase();
|
|
734
772
|
var parent = cur.parentElement;
|
|
735
|
-
if (parent)
|
|
773
|
+
if (parent) {
|
|
774
|
+
var same = Array.prototype.filter.call(parent.children, function (c) { return c.tagName === cur.tagName; });
|
|
775
|
+
if (same.length > 1) seg += ':nth-of-type(' + (Array.prototype.indexOf.call(same, cur) + 1) + ')';
|
|
776
|
+
}
|
|
736
777
|
parts.unshift(seg);
|
|
737
778
|
cur = cur.parentElement;
|
|
738
779
|
}
|
|
@@ -808,6 +849,11 @@ function getClientScript(options) {
|
|
|
808
849
|
var vis = [];
|
|
809
850
|
for (var i = 0; i < specs.length; i++) {
|
|
810
851
|
var s = specs[i];
|
|
852
|
+
// Badges created at import-time can be wiped by a framework hydrating <body>
|
|
853
|
+
// (same cause as the singleton mount guard). Re-attach a detached wrap so the
|
|
854
|
+
// pin reappears; a removed spec is already out of the specs array, so this
|
|
855
|
+
// never resurrects a deleted badge.
|
|
856
|
+
if (s.wrap && !s.wrap.isConnected && document.body) document.body.appendChild(s.wrap);
|
|
811
857
|
if (!fiActive) { s.wrap.style.display = 'none'; s._liveVis = false; continue; }
|
|
812
858
|
if (s.missing) { s.wrap.style.display = 'none'; s._liveVis = false; continue; } // shared comment whose target is gone/changed
|
|
813
859
|
if (!s.el || !s.el.isConnected) { var f = safeQuery(s.path); if (f) s.el = f; }
|
|
@@ -1007,6 +1053,16 @@ function getClientScript(options) {
|
|
|
1007
1053
|
saveSpecs();
|
|
1008
1054
|
}
|
|
1009
1055
|
|
|
1056
|
+
// Drop every imported (shared) spec, leaving the user's own local ones. Used by
|
|
1057
|
+
// importComments so a received share replaces the prior set instead of stacking.
|
|
1058
|
+
function removeSharedSpecs() {
|
|
1059
|
+
if (highlightSpec && highlightSpec.shared) highlightSpec = null;
|
|
1060
|
+
if (panelEditSpec && panelEditSpec.shared) panelEditSpec = null;
|
|
1061
|
+
for (var i = specs.length - 1; i >= 0; i--) {
|
|
1062
|
+
if (specs[i].shared) { if (specs[i].wrap) specs[i].wrap.remove(); specs.splice(i, 1); }
|
|
1063
|
+
}
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1010
1066
|
// \u2500\u2500\u2500 Reload insurance (localStorage, per-URL, zero network) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
1011
1067
|
// Persist only the serializable parts of each Spec; the live element ref and
|
|
1012
1068
|
// DOM nodes are rebuilt on restore (re-anchored best-effort via the CSS path).
|
|
@@ -1038,11 +1094,13 @@ function getClientScript(options) {
|
|
|
1038
1094
|
// element is gone OR its signature changed, the comment shows as MISSING (panel
|
|
1039
1095
|
// only, greyed, with a reason) instead of being drawn on a guessed spot.
|
|
1040
1096
|
|
|
1041
|
-
// Normalized element signature
|
|
1042
|
-
//
|
|
1043
|
-
//
|
|
1044
|
-
//
|
|
1045
|
-
//
|
|
1097
|
+
// Normalized element signature \u2014 a STRUCTURAL identity used to disambiguate which
|
|
1098
|
+
// element a shared comment belongs to (and to scan for it when selectors fail).
|
|
1099
|
+
// Deliberately NO raw text: page-load-dynamic text (a clock, a random greeting, a
|
|
1100
|
+
// price) was false-tripping this and blocking re-anchor on otherwise-identical
|
|
1101
|
+
// pages. tag + sorted own classes + key attrs + child count identifies the node
|
|
1102
|
+
// without that fragility. (Trade-off: pure text edits under a stable node no
|
|
1103
|
+
// longer read as "changed" \u2014 placement is favoured over change-detection.)
|
|
1046
1104
|
function normSig(el) {
|
|
1047
1105
|
if (!el || el.nodeType !== 1) return '';
|
|
1048
1106
|
var cls = Array.prototype.slice.call(el.classList)
|
|
@@ -1050,8 +1108,7 @@ function getClientScript(options) {
|
|
|
1050
1108
|
var attrs = ['type', 'role', 'name', 'href', 'aria-label'].map(function (a) {
|
|
1051
1109
|
var v = el.getAttribute(a); return v ? a + '=' + v.trim() : '';
|
|
1052
1110
|
}).filter(Boolean).join('|');
|
|
1053
|
-
|
|
1054
|
-
return el.tagName.toLowerCase() + '#' + cls + '#' + attrs + '#' + txt;
|
|
1111
|
+
return el.tagName.toLowerCase() + '#' + cls + '#' + attrs + '#' + el.childElementCount;
|
|
1055
1112
|
}
|
|
1056
1113
|
// djb2 xor \u2192 short base36 hash. Zero-dep; collisions don't matter (a match just
|
|
1057
1114
|
// means "unchanged enough", and the re-find already narrowed us to one element).
|
|
@@ -1063,51 +1120,73 @@ function getClientScript(options) {
|
|
|
1063
1120
|
return (h >>> 0).toString(36);
|
|
1064
1121
|
}
|
|
1065
1122
|
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
//
|
|
1069
|
-
|
|
1070
|
-
|
|
1123
|
+
function queryAll(sel) { try { return sel ? Array.prototype.slice.call(document.querySelectorAll(sel)) : []; } catch (e) { return []; } }
|
|
1124
|
+
|
|
1125
|
+
// Every element whose OWN text matches (trunc = the stored anchor was cut to 40
|
|
1126
|
+
// chars). Returns ALL matches \u2014 the fingerprint picks among them in reFindShared,
|
|
1127
|
+
// so a repeated label (e.g. two "Read more") no longer forces a MISSING. Skips UI.
|
|
1128
|
+
function findAllByOwnText(val, trunc) {
|
|
1129
|
+
var all = document.body ? document.body.getElementsByTagName('*') : [], out = [];
|
|
1071
1130
|
for (var i = 0; i < all.length; i++) {
|
|
1072
1131
|
var el = all[i];
|
|
1073
1132
|
if (el.closest && el.closest('[data-specter-ui]')) continue;
|
|
1074
1133
|
var t = ownText(el);
|
|
1075
1134
|
if (!t) continue;
|
|
1076
|
-
if (trunc ? (t.slice(0, 40) === val) : (t === val))
|
|
1135
|
+
if (trunc ? (t.slice(0, 40) === val) : (t === val)) out.push(el);
|
|
1077
1136
|
}
|
|
1078
|
-
return
|
|
1137
|
+
return out;
|
|
1079
1138
|
}
|
|
1080
1139
|
|
|
1081
|
-
//
|
|
1082
|
-
// 'attr "value"' rebuilds the attribute selector; 'text "value"'
|
|
1083
|
-
//
|
|
1084
|
-
//
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
if (!find) return null;
|
|
1140
|
+
// Candidate elements for a resolveLocator() anchor: #id/.class/[attr]/CSS-path via
|
|
1141
|
+
// querySelectorAll; 'attr "value"' rebuilds the attribute selector; 'text "value"'
|
|
1142
|
+
// scans own-text. Returns ALL matches (may be >1) \u2014 reFindShared narrows by
|
|
1143
|
+
// fingerprint rather than rejecting anything ambiguous up front.
|
|
1144
|
+
function resolveFindCandidates(find) {
|
|
1145
|
+
if (!find) return [];
|
|
1088
1146
|
var c = find.charAt(0);
|
|
1089
|
-
if (c === '#' || c === '.' || c === '[') return
|
|
1147
|
+
if (c === '#' || c === '.' || c === '[') return queryAll(find);
|
|
1090
1148
|
var q = find.indexOf(' "');
|
|
1091
1149
|
if (q > 0 && find.charAt(find.length - 1) === '"') {
|
|
1092
1150
|
var attr = find.slice(0, q), val = find.slice(q + 2, -1), trunc = false;
|
|
1093
1151
|
if (val.charAt(val.length - 1) === '\u2026') { trunc = true; val = val.slice(0, -1); }
|
|
1094
|
-
if (attr === 'text') return
|
|
1095
|
-
if (val.indexOf('"') < 0)
|
|
1096
|
-
return
|
|
1152
|
+
if (attr === 'text') return findAllByOwnText(val, trunc);
|
|
1153
|
+
if (val.indexOf('"') < 0) return queryAll('[' + attr + '="' + val + '"]');
|
|
1154
|
+
return [];
|
|
1097
1155
|
}
|
|
1098
|
-
return
|
|
1156
|
+
return queryAll(find); // a bare CSS path
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
// Re-find a shared comment's element. The fingerprint is the DISAMBIGUATOR, not
|
|
1160
|
+
// just a change-detector: gather every candidate from the find anchor AND the
|
|
1161
|
+
// nth-child path, then return the one whose signature matches \u2014 so a locator
|
|
1162
|
+
// shared by siblings (e.g. div:nth-child(1)) still resolves on the same page
|
|
1163
|
+
// instead of failing as ambiguous. With no fp match: hand back a lone candidate
|
|
1164
|
+
// (so importComments can say "changed"), else null ("couldn't find").
|
|
1165
|
+
// Whole-DOM scan for the one element whose structural signature matches \u2014 the
|
|
1166
|
+
// last-resort re-finder when both the find anchor and the nth-of-type path fail
|
|
1167
|
+
// (e.g. selectors that don't round-trip, or a shifted structure). >1 match \u2192 bail
|
|
1168
|
+
// (ambiguous, don't guess). Skips Specter's own UI.
|
|
1169
|
+
function findByFingerprint(fp) {
|
|
1170
|
+
if (!fp) return null;
|
|
1171
|
+
var all = document.body ? document.body.getElementsByTagName('*') : [], hit = null;
|
|
1172
|
+
for (var i = 0; i < all.length; i++) {
|
|
1173
|
+
var el = all[i];
|
|
1174
|
+
if (el.closest && el.closest('[data-specter-ui]')) continue;
|
|
1175
|
+
if (fingerprint(el) === fp) { if (hit) return null; hit = el; }
|
|
1176
|
+
}
|
|
1177
|
+
return hit;
|
|
1099
1178
|
}
|
|
1100
1179
|
|
|
1101
|
-
// Re-find a shared comment's element, using the fingerprint to DISAMBIGUATE (not
|
|
1102
|
-
// only to detect change): prefer whichever candidate \u2014 the find anchor or the
|
|
1103
|
-
// nth-child path \u2014 actually matches the stored signature. Falls back to a
|
|
1104
|
-
// best-effort element so importComments can still tell "changed" from "not found".
|
|
1105
1180
|
function reFindShared(item) {
|
|
1106
|
-
var
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
if (
|
|
1110
|
-
|
|
1181
|
+
var cands = resolveFindCandidates(item.find).concat(queryAll(item.path));
|
|
1182
|
+
var uniq = [], i;
|
|
1183
|
+
for (i = 0; i < cands.length; i++) if (cands[i] && uniq.indexOf(cands[i]) < 0) uniq.push(cands[i]);
|
|
1184
|
+
if (item.fp) {
|
|
1185
|
+
for (i = 0; i < uniq.length; i++) if (fingerprint(uniq[i]) === item.fp) return uniq[i]; // fp picks the right candidate
|
|
1186
|
+
var scan = findByFingerprint(item.fp); // selectors failed/ambiguous \u2192 find it by signature
|
|
1187
|
+
if (scan) return scan;
|
|
1188
|
+
}
|
|
1189
|
+
return uniq.length === 1 ? uniq[0] : null; // last resort: a lone unambiguous candidate
|
|
1111
1190
|
}
|
|
1112
1191
|
|
|
1113
1192
|
// URL gate: two people must be on the SAME page for a shared comment to place.
|
|
@@ -1138,12 +1217,19 @@ function getClientScript(options) {
|
|
|
1138
1217
|
console.warn('[Specter] These comments are for ' + pageKey(srcUrl) + ' \u2014 not this page (' + pageKey() + '). Not imported.');
|
|
1139
1218
|
return 0;
|
|
1140
1219
|
}
|
|
1220
|
+
// A received share REPLACES the previously-imported set: re-opening the same
|
|
1221
|
+
// link (or a refreshed one) gives exactly that set, never a stacked-up pile of
|
|
1222
|
+
// duplicates across rounds. Your OWN local specs (shared:false) are untouched.
|
|
1223
|
+
removeSharedSpecs();
|
|
1141
1224
|
var added = 0;
|
|
1142
1225
|
comments.forEach(function (item) {
|
|
1143
1226
|
if (!item) return;
|
|
1227
|
+
// reFindShared already used the fingerprint to pick/scan the right element, so
|
|
1228
|
+
// trust its result: if it located a node, PLACE the comment. Only a genuine
|
|
1229
|
+
// no-match is MISSING \u2014 we no longer hide a found element just because its
|
|
1230
|
+
// signature drifted (that over-fired on dynamic pages and buried real pins).
|
|
1144
1231
|
var el = reFindShared(item), missing = false, reason = '';
|
|
1145
1232
|
if (!el) { missing = true; reason = 'Couldn\u2019t find this element on the page'; }
|
|
1146
|
-
else if (item.fp && fingerprint(el) !== item.fp) { missing = true; reason = 'This element changed \u2014 can\u2019t place the comment'; }
|
|
1147
1233
|
// body stays empty on purpose: comments-only, never the shared element's props.
|
|
1148
1234
|
// A MISSING spec keeps el=null so it's never drawn or re-anchored via its path.
|
|
1149
1235
|
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 };
|
|
@@ -1522,7 +1608,7 @@ function getClientScript(options) {
|
|
|
1522
1608
|
Object.assign(body.style, { display: 'none', padding: '0 16px 12px' }); // collapsed by default
|
|
1523
1609
|
rows.forEach(function (r) {
|
|
1524
1610
|
var row = document.createElement('div');
|
|
1525
|
-
Object.assign(row.style, { display: 'flex', gap: '8px', marginBottom: '
|
|
1611
|
+
Object.assign(row.style, { display: 'flex', gap: '8px', marginBottom: '8px' });
|
|
1526
1612
|
var k = document.createElement('span');
|
|
1527
1613
|
k.textContent = r[0];
|
|
1528
1614
|
Object.assign(k.style, { color: '#E0A3F5', fontWeight: '700', minWidth: '78px', flexShrink: '0' });
|
|
@@ -1547,7 +1633,7 @@ function getClientScript(options) {
|
|
|
1547
1633
|
panelWrap.appendChild(panelHint);
|
|
1548
1634
|
panelWrap.appendChild(panelList);
|
|
1549
1635
|
panelWrap.appendChild(panelKeys);
|
|
1550
|
-
|
|
1636
|
+
mount(panelWrap);
|
|
1551
1637
|
|
|
1552
1638
|
// Panel scroll and page scroll are mutually exclusive: wheel over the scrollable list
|
|
1553
1639
|
// scrolls it natively (overscroll-behavior:contain stops it chaining at the bounds);
|
|
@@ -1806,8 +1892,8 @@ function getClientScript(options) {
|
|
|
1806
1892
|
panelVisSig = liveVisSig(); // record what this render reflects, so reflow only re-renders on a real flip
|
|
1807
1893
|
}
|
|
1808
1894
|
|
|
1809
|
-
function showPanel() { panelOpen = true; renderPanel(); panelWrap.style.transform = 'translateX(0)'; if (BRIDGE) doSync(); }
|
|
1810
|
-
function hidePanel() { panelOpen = false; panelEditSpec = null; panelWrap.style.transform = 'translateX(100%)'; }
|
|
1895
|
+
function showPanel() { panelOpen = true; renderPanel(); panelWrap.style.transform = 'translateX(0)'; updateListBtn(); if (BRIDGE) doSync(); }
|
|
1896
|
+
function hidePanel() { panelOpen = false; panelEditSpec = null; panelWrap.style.transform = 'translateX(100%)'; updateListBtn(); }
|
|
1811
1897
|
function togglePanel() { if (panelOpen) hidePanel(); else if (fiActive) showPanel(); }
|
|
1812
1898
|
|
|
1813
1899
|
// \u2500\u2500\u2500 Spec editor (annotation box: add / edit / delete) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|