pi-ele-picker 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/README.md CHANGED
@@ -110,10 +110,10 @@ The launcher starts in the **bottom-right** corner and stays out of the way:
110
110
  Vite reloads the page every time you save a file, so the composer is mirrored into `sessionStorage` and re-attached on the next load:
111
111
 
112
112
  - The description and any attached screenshots come back exactly as they were.
113
+ - The captured element is **snapshotted at pick time and persisted with the draft**, so the element data survives the node being unmounted, an SPA route change, or the reload itself.
113
114
  - The captured element is **re-resolved from its selector**, so after an HMR update you land straight back in the panel on the same element — no re-pick.
114
115
  - Closing the panel with **✕** also suppresses the automatic reopen; the draft stays reachable through the launcher's hover.
115
- - If the element is genuinely gone (unmounted, renamed), the words are still restored and the panel says so — picking a new element attaches the existing text to it.
116
- - Sending is refused while the panel points at an element that has left the page, so a stale pick can never ship a description of nothing. A replaced node under the same selector (the usual HMR case) is picked up automatically instead.
116
+ - **Sending never depends on the element still being on the page.** Pick a button, go copy something from another route, then send — the description goes out with the element as it was when you picked it. A live read is used instead while you are still on the pick-time page, so an HMR swap of the same node is described in its current state; after a route change the snapshot wins, so a lookalike behind the same selector cannot be mistaken for your pick. When a snapshot was used, the status line says so.
117
117
  - The draft is per tab and per path (`sessionStorage`, not `localStorage`), and one older than 24h is discarded. A successful send clears it.
118
118
  - Screenshots that exceed the ~5MB `sessionStorage` quota are dropped from the draft with a notice rather than silently lost; the description is always kept.
119
119
 
package/client/picker.js CHANGED
@@ -155,6 +155,12 @@
155
155
  // The selector `target` was captured through. Kept so the draft can be
156
156
  // re-attached after a reload replaces the DOM node `target` points at.
157
157
  var targetSelector = null;
158
+ // Everything about the target captured at pick time. Sending must not depend on
159
+ // the node still being on the page — see elementPayload().
160
+ var targetSnapshot = null;
161
+ // The URL the target was picked on. A selector only identifies an element within
162
+ // one document: after a route change the same selector may hit a lookalike.
163
+ var targetUrl = null;
158
164
  var shots = [];
159
165
  // Set when a pick commits on pointerdown; swallows the follow-up click so the
160
166
  // page (links, buttons) never reacts to the click that finished the pick.
@@ -491,12 +497,20 @@
491
497
  }
492
498
  }
493
499
 
494
- /** Re-show the panel for the element already captured — no re-pick, no overwrite. */
500
+ /**
501
+ * Re-show the panel for the element already captured — no re-pick, no overwrite.
502
+ *
503
+ * A snapshot is enough to reopen: the developer may have navigated away from the
504
+ * page that element lives on, and losing access to their own draft over that
505
+ * would be the wrong trade. The highlight is simply skipped when there is
506
+ * nothing on screen to outline.
507
+ */
495
508
  function openPanel() {
496
509
  var el = resolveTarget();
497
- if (!el) return false;
510
+ if (!el && !targetSnapshot) return false;
498
511
  panel.classList.add('open');
499
- showHighlight(el);
512
+ if (el) showHighlight(el);
513
+ else hideHighlight();
500
514
  positionPanel();
501
515
  return true;
502
516
  }
@@ -504,6 +518,8 @@
504
518
  function select(el) {
505
519
  target = el;
506
520
  targetSelector = cssPath(el);
521
+ targetSnapshot = describe(el);
522
+ targetUrl = location.href;
507
523
  // A new pick re-arms the draft: the user is working again, so a reload must
508
524
  // bring the panel back even if they closed it earlier.
509
525
  panelDismissed = false;
@@ -513,6 +529,14 @@
513
529
  taEl.focus();
514
530
  }
515
531
 
532
+ /**
533
+ * The element description handed to the bridge is chosen in send(), because the
534
+ * choice between a live read and the snapshot has to be reported to the user.
535
+ * Describing a detached node is never an option: `getComputedStyle()` and
536
+ * `getBoundingClientRect()` return nothing useful once it leaves the document, so
537
+ * the fallback has to be the captured value, not a fresh read.
538
+ */
539
+
516
540
  function vueLine(el) {
517
541
  var v = vueInfo(el);
518
542
  if (v.componentFile) return '<br>组件: <code>' + escapeHtml((v.componentName ? v.componentName + ' ' : '') + v.componentFile) + '</code>';
@@ -729,10 +753,15 @@
729
753
  var rec = {
730
754
  selector: targetSelector,
731
755
  text: taEl.value,
756
+ // The element as it was when it was picked. Persisted because the reload
757
+ // that restores the draft is often the same one that removed the node —
758
+ // without this the send below could only go out with no element at all.
759
+ element: targetSnapshot,
760
+ url: targetUrl,
732
761
  dismissed: panelDismissed,
733
762
  savedAt: Date.now(),
734
763
  };
735
- if (!rec.text && !rec.selector) {
764
+ if (!rec.text && !rec.selector && !rec.element) {
736
765
  // Nothing worth restoring — do not resurrect an empty panel on reload.
737
766
  clearDraft();
738
767
  return;
@@ -839,21 +868,26 @@
839
868
  }
840
869
  }
841
870
 
842
- if (rec.selector) {
843
- targetSelector = String(rec.selector);
844
- var el = resolveTarget();
845
- if (el) metaEl.innerHTML = '<code>' + escapeHtml(targetSelector) + '</code>' + vueLine(el);
846
- else targetSelector = null; // keep the words, drop the dead reference
871
+ if (rec.selector) targetSelector = String(rec.selector);
872
+ if (rec.url) targetUrl = String(rec.url);
873
+ if (rec.element && typeof rec.element === 'object') targetSnapshot = rec.element;
874
+
875
+ var el = targetSelector ? resolveTarget() : null;
876
+ if (el) {
877
+ metaEl.innerHTML = '<code>' + escapeHtml(targetSelector) + '</code>' + vueLine(el);
878
+ } else if (targetSnapshot) {
879
+ // Still sendable: the snapshot carries everything the bridge needs.
880
+ metaEl.innerHTML =
881
+ '<code>' + escapeHtml(targetSelector || '') + '</code><br>' +
882
+ '上次选中的元素已不在页面中,发送时会带上抓取时的快照';
847
883
  }
848
884
 
849
885
  if (rec.dismissed) return;
850
- if (target) {
851
- openPanel();
852
- return;
853
- }
886
+ if (openPanel()) return;
854
887
  if (!taEl.value && !shots.length) return;
855
- // The element is gone but the words survive: show the composer so the draft is
856
- // visibly still there, and let the next pick attach it to a new element.
888
+ // No element at all (not even a snapshot) but there are words: show the
889
+ // composer so the draft is visibly still there, and let the next pick attach
890
+ // it to a new element.
857
891
  panel.classList.add('open');
858
892
  metaEl.textContent = '上次选中的元素已不在页面中,请重新点击选择';
859
893
  positionPanel();
@@ -875,14 +909,16 @@
875
909
  }
876
910
 
877
911
  /** POST the payload to the bridge. Never rejects; status 0 means the fetch itself failed. */
878
- function deliver(c, submit) {
912
+ function deliver(c, submit, element) {
879
913
  var payload = {
880
914
  text: taEl.value,
881
915
  images: shots.map(function (d, i) {
882
916
  return { name: 'shot-' + i + '.png', dataUrl: d };
883
917
  }),
884
- element: describe(target),
885
- url: location.href,
918
+ element: element,
919
+ // The page the element lives on, which is not necessarily the page that is
920
+ // open right now.
921
+ url: targetUrl || location.href,
886
922
  title: document.title,
887
923
  submit: !!submit,
888
924
  };
@@ -919,12 +955,32 @@
919
955
  }
920
956
 
921
957
  function send(submit) {
922
- // The picked node can be detached by an HMR update while the panel stays open
923
- // on it; describing a dead node would ship a description of nothing.
924
- if (!resolveTarget()) {
925
- setStatus('目标元素已不在页面中,请重新点击选择元素', 'err');
958
+ // Deliberately NOT gated on the element still being on the page. Picking an
959
+ // element and then wandering off to another route to copy something is a
960
+ // normal way to work, and blocking the send there throws away the words the
961
+ // developer typed.
962
+ //
963
+ // Two sources for the element, and the wrong one silently corrupts the payload:
964
+ // the snapshot captured at pick time (a plain value, so it survives an unmount,
965
+ // an SPA route change or a reload), and a live read of whatever the selector
966
+ // resolves to now. A live read wins only while we are still on the pick-time
967
+ // URL — an HMR swap of the same element is the case that needs it, while a
968
+ // route change can put an unrelated lookalike behind the same selector.
969
+ var live = resolveTarget();
970
+ var samePage = targetUrl === null || location.href === targetUrl;
971
+ var element = live && samePage ? describe(live) : targetSnapshot;
972
+ if (element) targetSnapshot = element;
973
+
974
+ if (!element && !taEl.value.trim()) {
975
+ setStatus('请先点击页面元素,或写下要改什么', 'err');
926
976
  return;
927
977
  }
978
+
979
+ // Only ever affects the status line, never whether the send happens.
980
+ var note = '';
981
+ if (element && !live) note = '(元素已不在页面中,已附抓取时的快照)';
982
+ else if (element && !samePage) note = '(页面已切换,已附抓取时的元素快照)';
983
+
928
984
  sendBtn.disabled = true;
929
985
  sendNowBtn.disabled = true;
930
986
  setStatus(submit ? '直接发送中…' : '写入中…');
@@ -932,7 +988,7 @@
932
988
  function attempt(forceFresh) {
933
989
  return loadConfig(forceFresh).then(function (c) {
934
990
  if (!c || !c.port) return { status: 0, body: null, noBridge: true };
935
- return deliver(c, submit);
991
+ return deliver(c, submit, element);
936
992
  });
937
993
  }
938
994
 
@@ -950,10 +1006,17 @@
950
1006
  return;
951
1007
  }
952
1008
  if (out.body && out.body.ok) {
1009
+ // Say which element went out, so a stale pick is visible rather than silent.
953
1010
  if (out.body.submitted) {
954
- setStatus(out.body.queued ? '已发送 ✓(agent 忙碌,已排队为后续任务)' : '已直接发送给 pi 并开始执行 ✓', 'ok');
1011
+ setStatus(
1012
+ (out.body.queued ? '已发送 ✓(agent 忙碌,已排队为后续任务)' : '已直接发送给 pi 并开始执行 ✓') + note,
1013
+ 'ok'
1014
+ );
955
1015
  } else {
956
- setStatus(out.body.inserted ? '已写入 pi 输入框 ✓(去终端按回车发送)' : '已发送给 pi ✓', 'ok');
1016
+ setStatus(
1017
+ (out.body.inserted ? '已写入 pi 输入框 ✓(去终端按回车发送)' : '已发送给 pi ✓') + note,
1018
+ 'ok'
1019
+ );
957
1020
  }
958
1021
  taEl.value = '';
959
1022
  shots = [];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-ele-picker",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "Pick an element in your running page, describe the change, and send it to a pi session. Ships a Vite dev plugin (browser picker + discovery endpoint) and the matching pi bridge extension.",
5
5
  "type": "module",
6
6
  "license": "MIT",