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.cjs
CHANGED
|
@@ -97,6 +97,45 @@ function getClientScript(options) {
|
|
|
97
97
|
function markUI(el) { el.setAttribute('data-specter-ui', ''); return el; }
|
|
98
98
|
function isUI(el) { return !!(el && el.closest && el.closest('[data-specter-ui]')); }
|
|
99
99
|
|
|
100
|
+
// \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
|
|
101
|
+
// We inject at document_end, but frameworks that hydrate <body> (Next.js App
|
|
102
|
+
// Router / React 18-19, some Remix/Gatsby) then reconcile away DOM nodes they
|
|
103
|
+
// didn't render \u2014 silently deleting Specter's UI on those sites (pill/panel
|
|
104
|
+
// vanish; badges added later survive because they're created post-hydration).
|
|
105
|
+
// Track our persistent singletons and re-attach any that gets detached, so the
|
|
106
|
+
// UI survives the hydration pass and later SPA re-renders. The parent is resolved
|
|
107
|
+
// fresh on each remount so a wholesale body/head element swap is handled too.
|
|
108
|
+
var _mounted = [];
|
|
109
|
+
function mount(node, where) {
|
|
110
|
+
var parent = where === 'head' ? document.head : document.body;
|
|
111
|
+
if (parent) parent.appendChild(node);
|
|
112
|
+
_mounted.push({ node: node, where: where });
|
|
113
|
+
return node;
|
|
114
|
+
}
|
|
115
|
+
var _remountQueued = false;
|
|
116
|
+
function remountDetached() {
|
|
117
|
+
_remountQueued = false;
|
|
118
|
+
for (var i = 0; i < _mounted.length; i++) {
|
|
119
|
+
var m = _mounted[i];
|
|
120
|
+
if (m.node.isConnected) continue;
|
|
121
|
+
var p = m.where === 'head' ? document.head : document.body;
|
|
122
|
+
if (p) p.appendChild(m.node); // re-append; isConnected guard above prevents an observer loop
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
try {
|
|
126
|
+
var _defer = window.requestAnimationFrame ? function (fn) { requestAnimationFrame(fn); } : function (fn) { setTimeout(fn, 0); };
|
|
127
|
+
var _mo = new MutationObserver(function () {
|
|
128
|
+
if (_remountQueued) return;
|
|
129
|
+
_remountQueued = true;
|
|
130
|
+
_defer(remountDetached); // coalesce a burst of mutations into one restore pass
|
|
131
|
+
});
|
|
132
|
+
// childList on the roots is enough \u2014 every singleton is a direct child of
|
|
133
|
+
// body/head; watching documentElement too catches a body/head element swap.
|
|
134
|
+
_mo.observe(document.documentElement, { childList: true });
|
|
135
|
+
_mo.observe(document.body, { childList: true });
|
|
136
|
+
_mo.observe(document.head, { childList: true });
|
|
137
|
+
} catch (e) {}
|
|
138
|
+
|
|
100
139
|
// Attach a hover effect to an interactive icon/button: apply the "on" styles
|
|
101
140
|
// while hovered, restore the "off" styles on leave (keeps things clickable-feeling).
|
|
102
141
|
function hoverFx(el, on, off) {
|
|
@@ -123,7 +162,7 @@ function getClientScript(options) {
|
|
|
123
162
|
boxShadow: '0 4px 16px rgba(0,0,0,0.3)',
|
|
124
163
|
display: 'none',
|
|
125
164
|
});
|
|
126
|
-
|
|
165
|
+
mount(tooltip);
|
|
127
166
|
|
|
128
167
|
// Measure overlay
|
|
129
168
|
var measureOverlay = document.createElement('div');
|
|
@@ -135,7 +174,7 @@ function getClientScript(options) {
|
|
|
135
174
|
pointerEvents: 'none',
|
|
136
175
|
display: 'none',
|
|
137
176
|
});
|
|
138
|
-
|
|
177
|
+
mount(measureOverlay);
|
|
139
178
|
|
|
140
179
|
// \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
|
|
141
180
|
var pillWrap = document.createElement('div');
|
|
@@ -211,41 +250,34 @@ function getClientScript(options) {
|
|
|
211
250
|
var pillText = document.createElement('span');
|
|
212
251
|
Object.assign(pillText.style, { display: 'none', color: '#f3d9fb' });
|
|
213
252
|
|
|
253
|
+
// Panel toggle \u2014 icon + label so it's self-explanatory. Label reflects state
|
|
254
|
+
// (Open/Close) and always names the shortcut.
|
|
214
255
|
var listBtn = document.createElement('span');
|
|
215
|
-
listBtn.
|
|
216
|
-
listBtn.title = 'Show Specs panel (L)';
|
|
256
|
+
listBtn.title = 'Toggle Specs panel (L)';
|
|
217
257
|
Object.assign(listBtn.style, {
|
|
218
258
|
display: 'none',
|
|
219
259
|
alignItems: 'center',
|
|
260
|
+
gap: '6px',
|
|
220
261
|
cursor: 'pointer',
|
|
221
262
|
color: '#fff',
|
|
263
|
+
fontSize: '11px',
|
|
222
264
|
flexShrink: '0',
|
|
223
|
-
padding: '4px
|
|
265
|
+
padding: '4px 10px',
|
|
224
266
|
marginLeft: '2px',
|
|
225
267
|
borderRadius: '999px',
|
|
226
268
|
background: 'rgba(255,255,255,0.16)',
|
|
227
269
|
});
|
|
270
|
+
var listIcon = document.createElement('span');
|
|
271
|
+
listIcon.innerHTML = LIST;
|
|
272
|
+
Object.assign(listIcon.style, { display: 'flex', alignItems: 'center' });
|
|
273
|
+
var listLabel = document.createElement('span');
|
|
274
|
+
listBtn.appendChild(listIcon);
|
|
275
|
+
listBtn.appendChild(listLabel);
|
|
276
|
+
function updateListBtn() { listLabel.textContent = (panelOpen ? 'Close' : 'Open') + ' sidebar [L]'; }
|
|
277
|
+
updateListBtn();
|
|
228
278
|
listBtn.addEventListener('click', function (e) { e.stopPropagation(); togglePanel(); });
|
|
229
279
|
hoverFx(listBtn, { background: 'rgba(255,255,255,0.32)' }, { background: 'rgba(255,255,255,0.16)' });
|
|
230
280
|
|
|
231
|
-
var clearBtn = document.createElement('span');
|
|
232
|
-
clearBtn.textContent = '\u2715 Delete all';
|
|
233
|
-
clearBtn.title = 'Delete all annotations';
|
|
234
|
-
Object.assign(clearBtn.style, {
|
|
235
|
-
display: 'none',
|
|
236
|
-
cursor: 'pointer',
|
|
237
|
-
color: '#fff',
|
|
238
|
-
fontSize: '11px',
|
|
239
|
-
fontWeight: '600',
|
|
240
|
-
flexShrink: '0',
|
|
241
|
-
padding: '2px 8px',
|
|
242
|
-
marginLeft: '2px',
|
|
243
|
-
borderRadius: '999px',
|
|
244
|
-
background: 'rgba(255,255,255,0.16)',
|
|
245
|
-
});
|
|
246
|
-
clearBtn.addEventListener('click', function (e) { e.stopPropagation(); removeAllSpecs(); });
|
|
247
|
-
hoverFx(clearBtn, { background: 'rgba(255,255,255,0.32)' }, { background: 'rgba(255,255,255,0.16)' });
|
|
248
|
-
|
|
249
281
|
var chevron = document.createElement('span');
|
|
250
282
|
chevron.textContent = '\u203A';
|
|
251
283
|
chevron.title = 'Move to other side';
|
|
@@ -275,16 +307,15 @@ function getClientScript(options) {
|
|
|
275
307
|
pill.appendChild(pillSync);
|
|
276
308
|
pill.appendChild(pillText);
|
|
277
309
|
pill.appendChild(listBtn);
|
|
278
|
-
pill.appendChild(clearBtn);
|
|
279
310
|
pill.appendChild(chevron);
|
|
280
311
|
pillWrap.appendChild(pill);
|
|
281
|
-
|
|
312
|
+
mount(pillWrap);
|
|
282
313
|
|
|
283
314
|
// Keyframes for the sync spinner (injected once).
|
|
284
315
|
var spinStyle = document.createElement('style');
|
|
285
316
|
spinStyle.textContent = '@keyframes __specterSpin{to{transform:rotate(360deg)}}';
|
|
286
317
|
markUI(spinStyle);
|
|
287
|
-
|
|
318
|
+
mount(spinStyle, 'head');
|
|
288
319
|
|
|
289
320
|
// Shared dot styling for the control-bar AND side-panel sync indicators, so they
|
|
290
321
|
// always match: spinner while syncing, green when synced, red on error.
|
|
@@ -336,7 +367,7 @@ function getClientScript(options) {
|
|
|
336
367
|
// The mode is shown at all times (persistent prefix), so you always know whether
|
|
337
368
|
// hovering shows properties, measurements, or nothing (Comment).
|
|
338
369
|
function modeLabel() {
|
|
339
|
-
return commentMode ? 'Comment' : (measureMode ? 'Measure' : 'Properties');
|
|
370
|
+
return (commentMode ? 'Comment' : (measureMode ? 'Measure' : 'Properties')) + ' mode';
|
|
340
371
|
}
|
|
341
372
|
|
|
342
373
|
function expandPill(text) {
|
|
@@ -344,7 +375,7 @@ function getClientScript(options) {
|
|
|
344
375
|
pillText.style.display = 'inline';
|
|
345
376
|
chevron.style.display = 'inline';
|
|
346
377
|
listBtn.style.display = 'inline-flex'; // always reachable \u2014 the panel is also where you Import a shared file
|
|
347
|
-
|
|
378
|
+
updateListBtn();
|
|
348
379
|
pillExpanded = true;
|
|
349
380
|
// Just the mode (+ the action buttons when Specs exist). Shortcuts live in the
|
|
350
381
|
// side panel now, so the pill stays short.
|
|
@@ -359,14 +390,12 @@ function getClientScript(options) {
|
|
|
359
390
|
pill.style.maxWidth = '220px';
|
|
360
391
|
chevron.style.display = 'none';
|
|
361
392
|
listBtn.style.display = 'none';
|
|
362
|
-
clearBtn.style.display = 'none';
|
|
363
393
|
pillExpanded = false;
|
|
364
394
|
}
|
|
365
395
|
|
|
366
396
|
function flashMode() {
|
|
367
397
|
if (pillExpanded && pillWrap.matches(':hover')) return;
|
|
368
|
-
|
|
369
|
-
expandPill(text);
|
|
398
|
+
expandPill(modeLabel());
|
|
370
399
|
clearTimeout(flashTimer);
|
|
371
400
|
flashTimer = setTimeout(function () {
|
|
372
401
|
if (!pillWrap.matches(':hover') && specs.length === 0 && !pinEl) collapsePill();
|
|
@@ -492,10 +521,13 @@ function getClientScript(options) {
|
|
|
492
521
|
var cur = el;
|
|
493
522
|
for (var i = 0; i < 3 && cur && cur !== document.body; i++) {
|
|
494
523
|
var part = cur.tagName.toLowerCase();
|
|
495
|
-
if (cur.id) { part += '#' + cur.id; parts.unshift(part); break; }
|
|
524
|
+
if (cur.id) { part += '#' + cssEsc(cur.id); parts.unshift(part); break; }
|
|
496
525
|
var cls = Array.prototype.slice.call(cur.classList).filter(function (c) { return c.indexOf('__specter') !== 0; });
|
|
497
526
|
if (cls.length) {
|
|
498
|
-
|
|
527
|
+
// Escape each class: Tailwind classes like lg:block / bg-[#f5f5dc] are
|
|
528
|
+
// invalid raw in a selector (the : reads as a pseudo, [# as an attr) and
|
|
529
|
+
// throw on query, so they must be CSS.escape-d first.
|
|
530
|
+
part += '.' + cls.slice(0, 2).map(cssEsc).join('.');
|
|
499
531
|
} else if (cur.parentElement) {
|
|
500
532
|
var sameTag = Array.prototype.slice.call(cur.parentElement.children).filter(function (c) { return c.tagName === cur.tagName; });
|
|
501
533
|
if (sameTag.length > 1) {
|
|
@@ -575,13 +607,13 @@ function getClientScript(options) {
|
|
|
575
607
|
var i, v;
|
|
576
608
|
var testAttrs = ['data-testid', 'data-test-id', 'data-test', 'data-cy', 'data-qa'];
|
|
577
609
|
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; } }
|
|
578
|
-
if (el.id && !isHashedId(el.id) && uniqueSel('#' + cssEsc(el.id))) return '#' + el.id;
|
|
610
|
+
if (el.id && !isHashedId(el.id) && uniqueSel('#' + cssEsc(el.id))) return '#' + cssEsc(el.id);
|
|
579
611
|
var attrs = ['aria-label', 'name', 'placeholder', 'alt', 'title'];
|
|
580
612
|
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) + '"'; } }
|
|
581
613
|
var txt = ownText(el);
|
|
582
614
|
if (txt && uniqueTextAnchor(el, txt)) return 'text "' + txt.slice(0, 40) + (txt.length > 40 ? '\u2026' : '') + '"';
|
|
583
615
|
var cls = ownClass(el);
|
|
584
|
-
if (cls) return '.' + cls;
|
|
616
|
+
if (cls) return '.' + cssEsc(cls);
|
|
585
617
|
return getSelector(el); // fallback: the CSS path
|
|
586
618
|
}
|
|
587
619
|
|
|
@@ -755,11 +787,20 @@ function getClientScript(options) {
|
|
|
755
787
|
function elementPath(el) {
|
|
756
788
|
if (!el || el.nodeType !== 1) return '';
|
|
757
789
|
var parts = [], cur = el;
|
|
758
|
-
|
|
759
|
-
|
|
790
|
+
// Root the path at <body> or a stable id, and index with nth-OF-TYPE rather than
|
|
791
|
+
// nth-child: a hydrated page injects <script>/<style> siblings that shift a raw
|
|
792
|
+
// child index (the old div:nth-child(11) bug \u2014 matched a different node, or
|
|
793
|
+
// none, on the recipient). nth-of-type counts only same-tag siblings, so it
|
|
794
|
+
// survives that. Deeper cap (12) so the chain reaches a rooting anchor.
|
|
795
|
+
while (cur && cur.nodeType === 1 && parts.length < 12) {
|
|
796
|
+
if (cur === document.body) { parts.unshift('body'); break; }
|
|
797
|
+
if (cur.id && !isHashedId(cur.id)) { parts.unshift('#' + cssEsc(cur.id)); break; }
|
|
760
798
|
var seg = cur.tagName.toLowerCase();
|
|
761
799
|
var parent = cur.parentElement;
|
|
762
|
-
if (parent)
|
|
800
|
+
if (parent) {
|
|
801
|
+
var same = Array.prototype.filter.call(parent.children, function (c) { return c.tagName === cur.tagName; });
|
|
802
|
+
if (same.length > 1) seg += ':nth-of-type(' + (Array.prototype.indexOf.call(same, cur) + 1) + ')';
|
|
803
|
+
}
|
|
763
804
|
parts.unshift(seg);
|
|
764
805
|
cur = cur.parentElement;
|
|
765
806
|
}
|
|
@@ -835,6 +876,11 @@ function getClientScript(options) {
|
|
|
835
876
|
var vis = [];
|
|
836
877
|
for (var i = 0; i < specs.length; i++) {
|
|
837
878
|
var s = specs[i];
|
|
879
|
+
// Badges created at import-time can be wiped by a framework hydrating <body>
|
|
880
|
+
// (same cause as the singleton mount guard). Re-attach a detached wrap so the
|
|
881
|
+
// pin reappears; a removed spec is already out of the specs array, so this
|
|
882
|
+
// never resurrects a deleted badge.
|
|
883
|
+
if (s.wrap && !s.wrap.isConnected && document.body) document.body.appendChild(s.wrap);
|
|
838
884
|
if (!fiActive) { s.wrap.style.display = 'none'; s._liveVis = false; continue; }
|
|
839
885
|
if (s.missing) { s.wrap.style.display = 'none'; s._liveVis = false; continue; } // shared comment whose target is gone/changed
|
|
840
886
|
if (!s.el || !s.el.isConnected) { var f = safeQuery(s.path); if (f) s.el = f; }
|
|
@@ -1034,6 +1080,16 @@ function getClientScript(options) {
|
|
|
1034
1080
|
saveSpecs();
|
|
1035
1081
|
}
|
|
1036
1082
|
|
|
1083
|
+
// Drop every imported (shared) spec, leaving the user's own local ones. Used by
|
|
1084
|
+
// importComments so a received share replaces the prior set instead of stacking.
|
|
1085
|
+
function removeSharedSpecs() {
|
|
1086
|
+
if (highlightSpec && highlightSpec.shared) highlightSpec = null;
|
|
1087
|
+
if (panelEditSpec && panelEditSpec.shared) panelEditSpec = null;
|
|
1088
|
+
for (var i = specs.length - 1; i >= 0; i--) {
|
|
1089
|
+
if (specs[i].shared) { if (specs[i].wrap) specs[i].wrap.remove(); specs.splice(i, 1); }
|
|
1090
|
+
}
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1037
1093
|
// \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
|
|
1038
1094
|
// Persist only the serializable parts of each Spec; the live element ref and
|
|
1039
1095
|
// DOM nodes are rebuilt on restore (re-anchored best-effort via the CSS path).
|
|
@@ -1065,11 +1121,13 @@ function getClientScript(options) {
|
|
|
1065
1121
|
// element is gone OR its signature changed, the comment shows as MISSING (panel
|
|
1066
1122
|
// only, greyed, with a reason) instead of being drawn on a guessed spot.
|
|
1067
1123
|
|
|
1068
|
-
// Normalized element signature
|
|
1069
|
-
//
|
|
1070
|
-
//
|
|
1071
|
-
//
|
|
1072
|
-
//
|
|
1124
|
+
// Normalized element signature \u2014 a STRUCTURAL identity used to disambiguate which
|
|
1125
|
+
// element a shared comment belongs to (and to scan for it when selectors fail).
|
|
1126
|
+
// Deliberately NO raw text: page-load-dynamic text (a clock, a random greeting, a
|
|
1127
|
+
// price) was false-tripping this and blocking re-anchor on otherwise-identical
|
|
1128
|
+
// pages. tag + sorted own classes + key attrs + child count identifies the node
|
|
1129
|
+
// without that fragility. (Trade-off: pure text edits under a stable node no
|
|
1130
|
+
// longer read as "changed" \u2014 placement is favoured over change-detection.)
|
|
1073
1131
|
function normSig(el) {
|
|
1074
1132
|
if (!el || el.nodeType !== 1) return '';
|
|
1075
1133
|
var cls = Array.prototype.slice.call(el.classList)
|
|
@@ -1077,8 +1135,7 @@ function getClientScript(options) {
|
|
|
1077
1135
|
var attrs = ['type', 'role', 'name', 'href', 'aria-label'].map(function (a) {
|
|
1078
1136
|
var v = el.getAttribute(a); return v ? a + '=' + v.trim() : '';
|
|
1079
1137
|
}).filter(Boolean).join('|');
|
|
1080
|
-
|
|
1081
|
-
return el.tagName.toLowerCase() + '#' + cls + '#' + attrs + '#' + txt;
|
|
1138
|
+
return el.tagName.toLowerCase() + '#' + cls + '#' + attrs + '#' + el.childElementCount;
|
|
1082
1139
|
}
|
|
1083
1140
|
// djb2 xor \u2192 short base36 hash. Zero-dep; collisions don't matter (a match just
|
|
1084
1141
|
// means "unchanged enough", and the re-find already narrowed us to one element).
|
|
@@ -1090,51 +1147,73 @@ function getClientScript(options) {
|
|
|
1090
1147
|
return (h >>> 0).toString(36);
|
|
1091
1148
|
}
|
|
1092
1149
|
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
//
|
|
1096
|
-
|
|
1097
|
-
|
|
1150
|
+
function queryAll(sel) { try { return sel ? Array.prototype.slice.call(document.querySelectorAll(sel)) : []; } catch (e) { return []; } }
|
|
1151
|
+
|
|
1152
|
+
// Every element whose OWN text matches (trunc = the stored anchor was cut to 40
|
|
1153
|
+
// chars). Returns ALL matches \u2014 the fingerprint picks among them in reFindShared,
|
|
1154
|
+
// so a repeated label (e.g. two "Read more") no longer forces a MISSING. Skips UI.
|
|
1155
|
+
function findAllByOwnText(val, trunc) {
|
|
1156
|
+
var all = document.body ? document.body.getElementsByTagName('*') : [], out = [];
|
|
1098
1157
|
for (var i = 0; i < all.length; i++) {
|
|
1099
1158
|
var el = all[i];
|
|
1100
1159
|
if (el.closest && el.closest('[data-specter-ui]')) continue;
|
|
1101
1160
|
var t = ownText(el);
|
|
1102
1161
|
if (!t) continue;
|
|
1103
|
-
if (trunc ? (t.slice(0, 40) === val) : (t === val))
|
|
1162
|
+
if (trunc ? (t.slice(0, 40) === val) : (t === val)) out.push(el);
|
|
1104
1163
|
}
|
|
1105
|
-
return
|
|
1164
|
+
return out;
|
|
1106
1165
|
}
|
|
1107
1166
|
|
|
1108
|
-
//
|
|
1109
|
-
// 'attr "value"' rebuilds the attribute selector; 'text "value"'
|
|
1110
|
-
//
|
|
1111
|
-
//
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
if (!find) return null;
|
|
1167
|
+
// Candidate elements for a resolveLocator() anchor: #id/.class/[attr]/CSS-path via
|
|
1168
|
+
// querySelectorAll; 'attr "value"' rebuilds the attribute selector; 'text "value"'
|
|
1169
|
+
// scans own-text. Returns ALL matches (may be >1) \u2014 reFindShared narrows by
|
|
1170
|
+
// fingerprint rather than rejecting anything ambiguous up front.
|
|
1171
|
+
function resolveFindCandidates(find) {
|
|
1172
|
+
if (!find) return [];
|
|
1115
1173
|
var c = find.charAt(0);
|
|
1116
|
-
if (c === '#' || c === '.' || c === '[') return
|
|
1174
|
+
if (c === '#' || c === '.' || c === '[') return queryAll(find);
|
|
1117
1175
|
var q = find.indexOf(' "');
|
|
1118
1176
|
if (q > 0 && find.charAt(find.length - 1) === '"') {
|
|
1119
1177
|
var attr = find.slice(0, q), val = find.slice(q + 2, -1), trunc = false;
|
|
1120
1178
|
if (val.charAt(val.length - 1) === '\u2026') { trunc = true; val = val.slice(0, -1); }
|
|
1121
|
-
if (attr === 'text') return
|
|
1122
|
-
if (val.indexOf('"') < 0)
|
|
1123
|
-
return
|
|
1179
|
+
if (attr === 'text') return findAllByOwnText(val, trunc);
|
|
1180
|
+
if (val.indexOf('"') < 0) return queryAll('[' + attr + '="' + val + '"]');
|
|
1181
|
+
return [];
|
|
1124
1182
|
}
|
|
1125
|
-
return
|
|
1183
|
+
return queryAll(find); // a bare CSS path
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
// Re-find a shared comment's element. The fingerprint is the DISAMBIGUATOR, not
|
|
1187
|
+
// just a change-detector: gather every candidate from the find anchor AND the
|
|
1188
|
+
// nth-child path, then return the one whose signature matches \u2014 so a locator
|
|
1189
|
+
// shared by siblings (e.g. div:nth-child(1)) still resolves on the same page
|
|
1190
|
+
// instead of failing as ambiguous. With no fp match: hand back a lone candidate
|
|
1191
|
+
// (so importComments can say "changed"), else null ("couldn't find").
|
|
1192
|
+
// Whole-DOM scan for the one element whose structural signature matches \u2014 the
|
|
1193
|
+
// last-resort re-finder when both the find anchor and the nth-of-type path fail
|
|
1194
|
+
// (e.g. selectors that don't round-trip, or a shifted structure). >1 match \u2192 bail
|
|
1195
|
+
// (ambiguous, don't guess). Skips Specter's own UI.
|
|
1196
|
+
function findByFingerprint(fp) {
|
|
1197
|
+
if (!fp) return null;
|
|
1198
|
+
var all = document.body ? document.body.getElementsByTagName('*') : [], hit = null;
|
|
1199
|
+
for (var i = 0; i < all.length; i++) {
|
|
1200
|
+
var el = all[i];
|
|
1201
|
+
if (el.closest && el.closest('[data-specter-ui]')) continue;
|
|
1202
|
+
if (fingerprint(el) === fp) { if (hit) return null; hit = el; }
|
|
1203
|
+
}
|
|
1204
|
+
return hit;
|
|
1126
1205
|
}
|
|
1127
1206
|
|
|
1128
|
-
// Re-find a shared comment's element, using the fingerprint to DISAMBIGUATE (not
|
|
1129
|
-
// only to detect change): prefer whichever candidate \u2014 the find anchor or the
|
|
1130
|
-
// nth-child path \u2014 actually matches the stored signature. Falls back to a
|
|
1131
|
-
// best-effort element so importComments can still tell "changed" from "not found".
|
|
1132
1207
|
function reFindShared(item) {
|
|
1133
|
-
var
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
if (
|
|
1137
|
-
|
|
1208
|
+
var cands = resolveFindCandidates(item.find).concat(queryAll(item.path));
|
|
1209
|
+
var uniq = [], i;
|
|
1210
|
+
for (i = 0; i < cands.length; i++) if (cands[i] && uniq.indexOf(cands[i]) < 0) uniq.push(cands[i]);
|
|
1211
|
+
if (item.fp) {
|
|
1212
|
+
for (i = 0; i < uniq.length; i++) if (fingerprint(uniq[i]) === item.fp) return uniq[i]; // fp picks the right candidate
|
|
1213
|
+
var scan = findByFingerprint(item.fp); // selectors failed/ambiguous \u2192 find it by signature
|
|
1214
|
+
if (scan) return scan;
|
|
1215
|
+
}
|
|
1216
|
+
return uniq.length === 1 ? uniq[0] : null; // last resort: a lone unambiguous candidate
|
|
1138
1217
|
}
|
|
1139
1218
|
|
|
1140
1219
|
// URL gate: two people must be on the SAME page for a shared comment to place.
|
|
@@ -1165,12 +1244,19 @@ function getClientScript(options) {
|
|
|
1165
1244
|
console.warn('[Specter] These comments are for ' + pageKey(srcUrl) + ' \u2014 not this page (' + pageKey() + '). Not imported.');
|
|
1166
1245
|
return 0;
|
|
1167
1246
|
}
|
|
1247
|
+
// A received share REPLACES the previously-imported set: re-opening the same
|
|
1248
|
+
// link (or a refreshed one) gives exactly that set, never a stacked-up pile of
|
|
1249
|
+
// duplicates across rounds. Your OWN local specs (shared:false) are untouched.
|
|
1250
|
+
removeSharedSpecs();
|
|
1168
1251
|
var added = 0;
|
|
1169
1252
|
comments.forEach(function (item) {
|
|
1170
1253
|
if (!item) return;
|
|
1254
|
+
// reFindShared already used the fingerprint to pick/scan the right element, so
|
|
1255
|
+
// trust its result: if it located a node, PLACE the comment. Only a genuine
|
|
1256
|
+
// no-match is MISSING \u2014 we no longer hide a found element just because its
|
|
1257
|
+
// signature drifted (that over-fired on dynamic pages and buried real pins).
|
|
1171
1258
|
var el = reFindShared(item), missing = false, reason = '';
|
|
1172
1259
|
if (!el) { missing = true; reason = 'Couldn\u2019t find this element on the page'; }
|
|
1173
|
-
else if (item.fp && fingerprint(el) !== item.fp) { missing = true; reason = 'This element changed \u2014 can\u2019t place the comment'; }
|
|
1174
1260
|
// body stays empty on purpose: comments-only, never the shared element's props.
|
|
1175
1261
|
// A MISSING spec keeps el=null so it's never drawn or re-anchored via its path.
|
|
1176
1262
|
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 };
|
|
@@ -1549,7 +1635,7 @@ function getClientScript(options) {
|
|
|
1549
1635
|
Object.assign(body.style, { display: 'none', padding: '0 16px 12px' }); // collapsed by default
|
|
1550
1636
|
rows.forEach(function (r) {
|
|
1551
1637
|
var row = document.createElement('div');
|
|
1552
|
-
Object.assign(row.style, { display: 'flex', gap: '8px', marginBottom: '
|
|
1638
|
+
Object.assign(row.style, { display: 'flex', gap: '8px', marginBottom: '8px' });
|
|
1553
1639
|
var k = document.createElement('span');
|
|
1554
1640
|
k.textContent = r[0];
|
|
1555
1641
|
Object.assign(k.style, { color: '#E0A3F5', fontWeight: '700', minWidth: '78px', flexShrink: '0' });
|
|
@@ -1574,7 +1660,7 @@ function getClientScript(options) {
|
|
|
1574
1660
|
panelWrap.appendChild(panelHint);
|
|
1575
1661
|
panelWrap.appendChild(panelList);
|
|
1576
1662
|
panelWrap.appendChild(panelKeys);
|
|
1577
|
-
|
|
1663
|
+
mount(panelWrap);
|
|
1578
1664
|
|
|
1579
1665
|
// Panel scroll and page scroll are mutually exclusive: wheel over the scrollable list
|
|
1580
1666
|
// scrolls it natively (overscroll-behavior:contain stops it chaining at the bounds);
|
|
@@ -1833,8 +1919,8 @@ function getClientScript(options) {
|
|
|
1833
1919
|
panelVisSig = liveVisSig(); // record what this render reflects, so reflow only re-renders on a real flip
|
|
1834
1920
|
}
|
|
1835
1921
|
|
|
1836
|
-
function showPanel() { panelOpen = true; renderPanel(); panelWrap.style.transform = 'translateX(0)'; if (BRIDGE) doSync(); }
|
|
1837
|
-
function hidePanel() { panelOpen = false; panelEditSpec = null; panelWrap.style.transform = 'translateX(100%)'; }
|
|
1922
|
+
function showPanel() { panelOpen = true; renderPanel(); panelWrap.style.transform = 'translateX(0)'; updateListBtn(); if (BRIDGE) doSync(); }
|
|
1923
|
+
function hidePanel() { panelOpen = false; panelEditSpec = null; panelWrap.style.transform = 'translateX(100%)'; updateListBtn(); }
|
|
1838
1924
|
function togglePanel() { if (panelOpen) hidePanel(); else if (fiActive) showPanel(); }
|
|
1839
1925
|
|
|
1840
1926
|
// \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
|