vite-plugin-specter 0.5.0 → 0.6.0

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/index.js CHANGED
@@ -33,6 +33,7 @@ function getClientScript(options) {
33
33
  // \u2500\u2500\u2500 State \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
34
34
  var fiActive = false;
35
35
  var measureMode = false;
36
+ var commentMode = false; // C: hide props/measure overlays for design review \u2014 Specs still capture them
36
37
  var pinEl = null;
37
38
  var pinHighlight = null;
38
39
  var lastHovered = null;
@@ -198,7 +199,7 @@ function getClientScript(options) {
198
199
  hoverFx(listBtn, { background: 'rgba(255,255,255,0.32)' }, { background: 'rgba(255,255,255,0.16)' });
199
200
 
200
201
  var clearBtn = document.createElement('span');
201
- clearBtn.textContent = '\u2715 Delete all annotations';
202
+ clearBtn.textContent = '\u2715 Delete all';
202
203
  clearBtn.title = 'Delete all annotations';
203
204
  Object.assign(clearBtn.style, {
204
205
  display: 'none',
@@ -230,7 +231,18 @@ function getClientScript(options) {
230
231
  chevron.addEventListener('click', function (e) { e.stopPropagation(); moveSide(); });
231
232
  hoverFx(chevron, { opacity: '1', transform: 'scale(1.2)' }, { opacity: '0.8', transform: 'scale(1)' });
232
233
 
234
+ // Sync status in the control bar (only when a bridge is configured): spinner while
235
+ // syncing, green when synced, red on error \u2014 so you always know what's staged.
236
+ var pillSync = document.createElement('span');
237
+ Object.assign(pillSync.style, {
238
+ display: 'none', width: '8px', height: '8px', borderRadius: '999px',
239
+ background: '#6B7280', flexShrink: '0', boxSizing: 'border-box',
240
+ });
241
+ pillSync.title = 'Sync status \u2014 click to re-sync';
242
+ pillSync.addEventListener('click', function (e) { e.stopPropagation(); if (BRIDGE) doSync(); });
243
+
233
244
  pill.appendChild(iconBtn);
245
+ pill.appendChild(pillSync);
234
246
  pill.appendChild(pillText);
235
247
  pill.appendChild(listBtn);
236
248
  pill.appendChild(clearBtn);
@@ -238,6 +250,30 @@ function getClientScript(options) {
238
250
  pillWrap.appendChild(pill);
239
251
  document.body.appendChild(pillWrap);
240
252
 
253
+ // Keyframes for the sync spinner (injected once).
254
+ var spinStyle = document.createElement('style');
255
+ spinStyle.textContent = '@keyframes __specterSpin{to{transform:rotate(360deg)}}';
256
+ markUI(spinStyle);
257
+ document.head.appendChild(spinStyle);
258
+
259
+ // Shared dot styling for the control-bar AND side-panel sync indicators, so they
260
+ // always match: spinner while syncing, green when synced, red on error.
261
+ function applyDotState(el, state) {
262
+ el.style.boxSizing = 'border-box';
263
+ if (state === 'syncing') {
264
+ Object.assign(el.style, { width: '10px', height: '10px', background: 'transparent', border: '2px solid rgba(255,255,255,0.35)', borderTopColor: '#fff', animation: '__specterSpin 0.6s linear infinite' });
265
+ } else if (state === 'synced') {
266
+ Object.assign(el.style, { width: '8px', height: '8px', background: GREEN, border: 'none', animation: 'none' });
267
+ } else { // offline / error
268
+ Object.assign(el.style, { width: '8px', height: '8px', background: '#F26D6D', border: 'none', animation: 'none' });
269
+ }
270
+ }
271
+ function setPillSync(state) {
272
+ if (!BRIDGE) { pillSync.style.display = 'none'; return; }
273
+ pillSync.style.display = 'inline-block';
274
+ applyDotState(pillSync, state);
275
+ }
276
+
241
277
  pillWrap.addEventListener('mouseenter', function () {
242
278
  clearMeasureOverlay();
243
279
  clearMeasureTargetHL();
@@ -267,6 +303,12 @@ function getClientScript(options) {
267
303
  }
268
304
  }
269
305
 
306
+ // The mode is shown at all times (persistent prefix), so you always know whether
307
+ // hovering shows properties, measurements, or nothing (Comment).
308
+ function modeLabel() {
309
+ return commentMode ? 'Comment' : (measureMode ? 'Measure' : 'Properties');
310
+ }
311
+
270
312
  function expandPill(text) {
271
313
  pill.style.maxWidth = '820px';
272
314
  pillText.style.display = 'inline';
@@ -274,19 +316,17 @@ function getClientScript(options) {
274
316
  listBtn.style.display = specs.length > 0 ? 'inline-flex' : 'none';
275
317
  clearBtn.style.display = specs.length > 0 ? 'inline' : 'none';
276
318
  pillExpanded = true;
277
- if (text) { pillText.textContent = text; return; }
278
- if (specs.length > 0) {
279
- pillText.textContent = specs.length + (specs.length === 1 ? ' Spec' : ' Specs') + ' \xB7 P add \xB7 L panel \xB7 Cmd+C copy';
280
- } else if (measureMode) {
281
- pillText.textContent = 'Measure \xB7 hover distances \xB7 P mark \xB7 M pin \xB7 Cmd+C copy \xB7 Option toggle';
282
- } else {
283
- pillText.textContent = 'Properties \xB7 P mark / annotate \xB7 Cmd+C copy \xB7 Option measure';
284
- }
319
+ // Just the mode (+ the action buttons when Specs exist). Shortcuts live in the
320
+ // side panel now, so the pill stays short.
321
+ pillText.textContent = text || modeLabel();
285
322
  }
286
323
 
324
+ // "Collapsed" = the compact resting state: mode label only, no hints/buttons.
325
+ // Still shows the mode so it's visible at all times.
287
326
  function collapsePill() {
288
- pill.style.maxWidth = '32px';
289
- pillText.style.display = 'none';
327
+ pillText.textContent = modeLabel();
328
+ pillText.style.display = 'inline';
329
+ pill.style.maxWidth = '220px';
290
330
  chevron.style.display = 'none';
291
331
  listBtn.style.display = 'none';
292
332
  clearBtn.style.display = 'none';
@@ -295,7 +335,7 @@ function getClientScript(options) {
295
335
 
296
336
  function flashMode() {
297
337
  if (pillExpanded && pillWrap.matches(':hover')) return;
298
- var text = measureMode ? '\u2B21 Measure mode' : '\u25C9 Properties mode';
338
+ var text = commentMode ? 'Comment mode' : (measureMode ? 'Measure mode' : 'Properties mode');
299
339
  expandPill(text);
300
340
  clearTimeout(flashTimer);
301
341
  flashTimer = setTimeout(function () {
@@ -316,11 +356,13 @@ function getClientScript(options) {
316
356
  function activate() {
317
357
  fiActive = true;
318
358
  measureMode = false;
359
+ commentMode = false;
319
360
  pillWrap.style.display = 'block';
320
361
  document.body.style.cursor = 'crosshair';
321
362
  updatePill();
322
363
  startLoop();
323
364
  reflowSpecs();
365
+ if (BRIDGE) doSync(); // resolve the control-bar sync dot (green if reachable, red if not)
324
366
  }
325
367
 
326
368
  function deactivate() {
@@ -604,24 +646,37 @@ function getClientScript(options) {
604
646
  return ['[Specter]', head, 'find: ' + resolveLocator(data.el), props.join(' \xB7 ')].join('\\n');
605
647
  }
606
648
 
649
+ // Group Specs on the SAME element so their identical properties aren't emitted
650
+ // twice \u2014 several comments on one thing share ONE property block, each with its
651
+ // own change note. Measure Specs always stand alone (each is a distinct reading).
652
+ function groupSpecs() {
653
+ var groups = [];
654
+ for (var i = 0; i < specs.length; i++) {
655
+ var s = specs[i], g = null;
656
+ if (s.kind === 'element' && s.el) {
657
+ for (var j = 0; j < groups.length; j++) { if (groups[j].kind === 'element' && groups[j].el === s.el) { g = groups[j]; break; } }
658
+ }
659
+ if (g) g.specs.push(s);
660
+ else groups.push({ kind: s.kind, el: s.el, specs: [s] });
661
+ }
662
+ return groups;
663
+ }
664
+
607
665
  // Copy every Spec \u2014 using each Spec's body snapshotted at mark time, so Specs
608
666
  // whose element is currently hidden (e.g. inside a closed modal) still copy.
667
+ // Same-element Specs collapse into one block (properties once, notes stacked).
609
668
  function buildSpecsCopyText() {
610
- var n = specs.length;
611
- return specs.map(function (spec, i) {
612
- var tag = spec.kind === 'measure' ? 'Specter Measure' : 'Specter';
669
+ var groups = groupSpecs();
670
+ var n = groups.length;
671
+ return groups.map(function (g, i) {
672
+ var tag = g.kind === 'measure' ? 'Specter Measure' : 'Specter';
613
673
  var header = n > 1 ? '[' + tag + ' ' + (i + 1) + '/' + n + ']' : '[' + tag + ']';
614
- if (spec.note) header += '\\n\u270F\uFE0F CHANGE: ' + spec.note;
615
- return spec.body.replace(/^\\[Specter[^\\]]*\\]/, function () { return header; });
674
+ var notes = g.specs.filter(function (s) { return s.note; }).map(function (s) { return '\u270F\uFE0F CHANGE: ' + s.note; });
675
+ if (notes.length) header += '\\n' + notes.join('\\n');
676
+ return g.specs[0].body.replace(/^\\[Specter[^\\]]*\\]/, function () { return header; });
616
677
  }).join('\\n\\n---\\n\\n');
617
678
  }
618
679
 
619
- function linesToHTML(str) {
620
- return str.split('\\n').map(function (l) {
621
- return '<div style="margin-bottom:4px;color:#fff">' + esc(l) + '</div>';
622
- }).join('');
623
- }
624
-
625
680
  // \u2500\u2500\u2500 Hover outline \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
626
681
  function setHoverOutline(el) {
627
682
  if (outlinedEl === el) return;
@@ -653,12 +708,14 @@ function getClientScript(options) {
653
708
  }
654
709
 
655
710
  function reRenderTooltip() {
711
+ if (commentMode) { hideTooltip(); return; }
656
712
  if (measureMode) {
657
- var text = (pinEl && pinEl !== lastHovered) ? measureBetween(pinEl, lastHovered) : measureToNeighbor(lastHovered);
658
- tooltip.innerHTML = linesToHTML(text);
659
- } else {
660
- tooltip.innerHTML = buildHumanDisplay(buildInfo(lastHovered));
713
+ // Redraw the overlay on scroll/resize; readout box stays hidden (see hover handler).
714
+ if (pinEl && pinEl !== lastHovered) measureBetween(pinEl, lastHovered); else measureToNeighbor(lastHovered);
715
+ hideTooltip();
716
+ return;
661
717
  }
718
+ tooltip.innerHTML = buildHumanDisplay(buildInfo(lastHovered));
662
719
  positionTooltip(lastMouse.x, lastMouse.y);
663
720
  }
664
721
 
@@ -814,7 +871,7 @@ function getClientScript(options) {
814
871
 
815
872
  function updateBadgeContent(spec) {
816
873
  spec.num.textContent = String(specs.indexOf(spec) + 1);
817
- if (spec.note) spec.noteSpan.textContent = '\u270F\uFE0F ' + spec.note;
874
+ if (spec.note) spec.noteSpan.textContent = spec.note;
818
875
  else spec.noteSpan.textContent = spec.kind === 'measure' ? '\u2B21 measure' : '';
819
876
  }
820
877
 
@@ -859,10 +916,6 @@ function getClientScript(options) {
859
916
  updateBadgeContent(spec);
860
917
  }
861
918
 
862
- function findElementSpec(el) {
863
- for (var i = 0; i < specs.length; i++) if (specs[i].kind === 'element' && specs[i].el === el) return specs[i];
864
- return null;
865
- }
866
919
 
867
920
  // If the element sits inside a dialog/modal/drawer/menu, produce a hint for how to
868
921
  // bring it back when it's later hidden \u2014 a declarative opener (aria-controls /
@@ -901,7 +954,9 @@ function getClientScript(options) {
901
954
  if (pinEl && lastHovered && lastHovered !== pinEl) { el = pinEl; body = buildMeasureCopyText(pinEl, lastHovered); }
902
955
  else { el = anchorEl; body = buildNeighborCopyText(anchorEl); }
903
956
  } else {
904
- if (findElementSpec(anchorEl)) return; // P over an already-Spec'd element \u2192 nothing
957
+ // Multiple Specs per element are allowed in every mode \u2014 clustering fans the badges
958
+ // out and same-element output collapses to one property block. Mode only changes
959
+ // what's shown on screen, never whether you can add an annotation.
905
960
  body = buildLLMClipboard(buildInfo(anchorEl));
906
961
  }
907
962
  clearHoverOutline();
@@ -1139,14 +1194,74 @@ function getClientScript(options) {
1139
1194
  }
1140
1195
 
1141
1196
  var panelList = document.createElement('div');
1142
- Object.assign(panelList.style, { flex: '1', overflowY: 'auto', overflowX: 'hidden' });
1197
+ // overscroll-behavior:contain stops the panel's scroll from chaining into the page.
1198
+ Object.assign(panelList.style, { flex: '1', overflowY: 'auto', overflowX: 'hidden', overscrollBehavior: 'contain' });
1199
+
1200
+ // Keyboard shortcuts reference \u2014 collapsible, collapsed by default, lives here (not
1201
+ // in the pill) so the pill stays short.
1202
+ var panelKeys = document.createElement('div');
1203
+ Object.assign(panelKeys.style, {
1204
+ flexShrink: '0', fontSize: '11px', lineHeight: '18px', color: LABEL,
1205
+ borderTop: '1px solid rgba(255,255,255,0.08)',
1206
+ });
1207
+ (function () {
1208
+ var rows = [
1209
+ ['P', 'mark / comment the hovered element'],
1210
+ ['C', 'toggle Comment mode (hide properties)'],
1211
+ ['\\u2325 Option', 'toggle Measure mode'],
1212
+ ['M', 'pin an element to measure from'],
1213
+ ['Cmd/Ctrl+C', 'copy all Specs'],
1214
+ ['L', 'toggle this panel'],
1215
+ ];
1216
+ var head = document.createElement('div');
1217
+ Object.assign(head.style, { display: 'flex', alignItems: 'center', gap: '6px', color: '#8A8D96', fontWeight: '700', letterSpacing: '0.04em', textTransform: 'uppercase', fontSize: '11px', padding: '10px 16px', cursor: 'pointer', userSelect: 'none' });
1218
+ var caret = document.createElement('span');
1219
+ caret.textContent = '\\u203A'; // \u203A
1220
+ Object.assign(caret.style, { display: 'inline-block', fontSize: '16px', lineHeight: '1', transition: 'transform 0.15s ease', transform: 'rotate(0deg)' });
1221
+ var headText = document.createElement('span');
1222
+ headText.textContent = 'Keyboard shortcuts';
1223
+ head.appendChild(caret);
1224
+ head.appendChild(headText);
1225
+ var body = document.createElement('div');
1226
+ Object.assign(body.style, { display: 'none', padding: '0 16px 12px' }); // collapsed by default
1227
+ rows.forEach(function (r) {
1228
+ var row = document.createElement('div');
1229
+ Object.assign(row.style, { display: 'flex', gap: '8px', marginBottom: '2px' });
1230
+ var k = document.createElement('span');
1231
+ k.textContent = r[0];
1232
+ Object.assign(k.style, { color: '#E0A3F5', fontWeight: '700', minWidth: '78px', flexShrink: '0' });
1233
+ var d = document.createElement('span');
1234
+ d.textContent = r[1];
1235
+ row.appendChild(k); row.appendChild(d);
1236
+ body.appendChild(row);
1237
+ });
1238
+ var open = false;
1239
+ head.addEventListener('click', function (e) {
1240
+ e.stopPropagation();
1241
+ open = !open;
1242
+ body.style.display = open ? 'block' : 'none';
1243
+ caret.style.transform = open ? 'rotate(90deg)' : 'rotate(0deg)';
1244
+ });
1245
+ panelKeys.appendChild(head);
1246
+ panelKeys.appendChild(body);
1247
+ })();
1143
1248
 
1144
1249
  panelWrap.appendChild(panelHead);
1145
1250
  panelWrap.appendChild(panelTools);
1146
1251
  panelWrap.appendChild(panelHint);
1147
1252
  panelWrap.appendChild(panelList);
1253
+ panelWrap.appendChild(panelKeys);
1148
1254
  document.body.appendChild(panelWrap);
1149
1255
 
1256
+ // Panel scroll and page scroll are mutually exclusive: wheel over the scrollable list
1257
+ // scrolls it natively (overscroll-behavior:contain stops it chaining at the bounds);
1258
+ // wheel over any non-scrolling part of the panel never scrolls the page behind it.
1259
+ panelWrap.addEventListener('wheel', function (e) {
1260
+ var canScroll = panelList.scrollHeight > panelList.clientHeight;
1261
+ if (panelList.contains(e.target) && canScroll) return; // let the list scroll natively
1262
+ e.preventDefault();
1263
+ }, { passive: false });
1264
+
1150
1265
  // \u2500\u2500 Auto-sync: mirror the browser's current Specs to the local bridge \u2500\u2500
1151
1266
  // Debounced so rapid edits/typing collapse into one POST. The bridge replaces
1152
1267
  // its snapshot for this URL, so it always reflects what's in the panel \u2014 no
@@ -1154,24 +1269,33 @@ function getClientScript(options) {
1154
1269
  var syncTimer = null;
1155
1270
  function setSyncState(s) {
1156
1271
  if (!BRIDGE) return;
1157
- if (s === 'syncing') { dot.style.background = '#F59E0B'; dotLabel.textContent = 'Syncing\u2026'; }
1158
- else if (s === 'synced') { dot.style.background = GREEN; dotLabel.textContent = specs.length ? (specs.length + (specs.length === 1 ? ' Spec synced' : ' Specs synced')) : 'Synced'; }
1159
- else { dot.style.background = '#6B7280'; dotLabel.textContent = 'Bridge offline'; }
1272
+ setPillSync(s); // control-bar dot
1273
+ applyDotState(dot, s); // side-panel dot \u2014 same spinner/green/red
1274
+ if (s === 'syncing') { dotLabel.textContent = 'Syncing\u2026'; }
1275
+ else if (s === 'synced') { dotLabel.textContent = specs.length ? (specs.length + (specs.length === 1 ? ' Spec synced' : ' Specs synced')) : 'Synced'; }
1276
+ else { dotLabel.textContent = 'Bridge offline'; }
1160
1277
  }
1161
1278
  function doSync() {
1162
1279
  if (!BRIDGE) return;
1163
1280
  setSyncState('syncing');
1281
+ // Keep the spinner up for at least 1s even on an instant localhost sync, so the
1282
+ // feedback is actually perceptible instead of flashing by.
1283
+ var started = Date.now();
1284
+ var settle = function (state) {
1285
+ var wait = Math.max(0, 1000 - (Date.now() - started));
1286
+ setTimeout(function () { setSyncState(state); }, wait);
1287
+ };
1164
1288
  var payload = {
1165
1289
  url: location.href,
1166
1290
  text: buildSpecsCopyText(),
1167
- specs: specs.map(function (s, i) {
1168
- return { num: i + 1, note: s.note || '', kind: s.kind, body: s.body };
1291
+ specs: groupSpecs().map(function (g, i) {
1292
+ return { num: i + 1, note: g.specs.map(function (s) { return s.note; }).filter(Boolean).join('\\n'), kind: g.kind, body: g.specs[0].body };
1169
1293
  }),
1170
1294
  };
1171
1295
  fetch(BRIDGE + '/specs', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) })
1172
1296
  .then(function (r) { if (!r.ok) throw 0; return r.json(); })
1173
- .then(function () { setSyncState('synced'); })
1174
- .catch(function () { setSyncState('offline'); });
1297
+ .then(function () { settle('synced'); })
1298
+ .catch(function () { settle('offline'); });
1175
1299
  }
1176
1300
  function scheduleSync() {
1177
1301
  if (!BRIDGE) return;
@@ -1315,7 +1439,7 @@ function getClientScript(options) {
1315
1439
  Object.assign(hbar.style, { display: 'flex', alignItems: 'center', gap: '7px', marginTop: '2px', flexWrap: 'wrap' });
1316
1440
  var tag = document.createElement('span');
1317
1441
  tag.textContent = 'HIDDEN';
1318
- Object.assign(tag.style, { fontSize: '9px', fontWeight: '700', letterSpacing: '0.05em', color: '#3A2A05', background: '#F59E0B', borderRadius: '4px', padding: '2px 6px', flexShrink: '0' });
1442
+ Object.assign(tag.style, { fontSize: '11px', fontWeight: '700', letterSpacing: '0.05em', color: '#3A2A05', background: '#F59E0B', borderRadius: '4px', padding: '2px 6px', flexShrink: '0' });
1319
1443
  var hint = document.createElement('span');
1320
1444
  hint.textContent = spec.locate || 'Not on the page right now';
1321
1445
  Object.assign(hint.style, { fontSize: '11px', color: '#C9CBD2', overflow: 'hidden', textOverflow: 'ellipsis' });
@@ -1579,9 +1703,9 @@ function getClientScript(options) {
1579
1703
  position: 'absolute',
1580
1704
  background: RED,
1581
1705
  color: '#fff',
1582
- fontSize: '10px',
1706
+ fontSize: '12px',
1583
1707
  fontFamily: MONO,
1584
- padding: '1px 4px',
1708
+ padding: '2px 5px',
1585
1709
  borderRadius: '3px',
1586
1710
  pointerEvents: 'none',
1587
1711
  whiteSpace: 'nowrap',
@@ -1781,12 +1905,24 @@ function getClientScript(options) {
1781
1905
 
1782
1906
  lastHovered = target;
1783
1907
 
1908
+ // Comment mode: outline only (so you know what you're commenting on), no
1909
+ // properties/measure overlay on screen. The Spec still captures everything.
1910
+ if (commentMode) {
1911
+ clearMeasureOverlay();
1912
+ clearMeasureTargetHL();
1913
+ setHoverOutline(target);
1914
+ hideTooltip();
1915
+ return;
1916
+ }
1917
+
1784
1918
  if (measureMode) {
1785
1919
  clearHoverOutline();
1786
1920
  showMeasureTargetHL(target);
1787
- var text = (pinEl && pinEl !== target) ? measureBetween(pinEl, target) : measureToNeighbor(target);
1788
- tooltip.innerHTML = linesToHTML(text);
1789
- positionTooltip(e.clientX, e.clientY);
1921
+ // Draw the on-screen measurement overlay (the px badges + lines) but keep
1922
+ // the dark readout box hidden \u2014 it occludes the very measurements it reports.
1923
+ // The full readout still copies with Cmd+C. (Properties mode keeps its box.)
1924
+ if (pinEl && pinEl !== target) measureBetween(pinEl, target); else measureToNeighbor(target);
1925
+ hideTooltip();
1790
1926
  if (pinEl) updatePinHL();
1791
1927
  } else {
1792
1928
  clearMeasureOverlay();
@@ -1868,6 +2004,21 @@ function getClientScript(options) {
1868
2004
  return;
1869
2005
  }
1870
2006
 
2007
+ // C \u2014 toggle Comment mode (outline only; props/measure hidden but still captured).
2008
+ // Plain c only \u2014 Cmd/Ctrl+C copy was handled above and returned.
2009
+ if ((e.key === 'c' || e.key === 'C') && !e.metaKey && !e.ctrlKey) {
2010
+ e.preventDefault();
2011
+ commentMode = !commentMode;
2012
+ if (commentMode && measureMode) { measureMode = false; clearPin(); }
2013
+ clearMeasureOverlay();
2014
+ clearMeasureTargetHL();
2015
+ clearHoverOutline();
2016
+ hideTooltip();
2017
+ if (lastHovered && commentMode) setHoverOutline(lastHovered);
2018
+ flashMode();
2019
+ return;
2020
+ }
2021
+
1871
2022
  // Esc only HIDES the plugin \u2014 Specs persist and return on reactivate.
1872
2023
  if (e.key === 'Escape') {
1873
2024
  if (panelOpen) { hidePanel(); return; }