pi-ele-picker 0.4.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.
Files changed (3) hide show
  1. package/README.md +24 -0
  2. package/client/picker.js +322 -10
  3. package/package.json +4 -2
package/README.md CHANGED
@@ -105,6 +105,18 @@ The launcher starts in the **bottom-right** corner and stays out of the way:
105
105
  - **写入pi输入框** — pre-fills the current pi input box with the composed prompt; you edit and press Enter.
106
106
  - **直接发送** — submits the composed prompt as a real user turn immediately (queued as a follow-up if pi is mid-turn).
107
107
 
108
+ ### Drafts survive reloads
109
+
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
+
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.
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.
115
+ - Closing the panel with **✕** also suppresses the automatic reopen; the draft stays reachable through the launcher's hover.
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
+ - The draft is per tab and per path (`sessionStorage`, not `localStorage`), and one older than 24h is discarded. A successful send clears it.
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
+
108
120
  ### Session status
109
121
 
110
122
  Status polling is **opt-in and off by default**: each cycle opens a loopback request, so it is disabled unless you want it. There are two levels:
@@ -142,6 +154,18 @@ The same payload is available from the bridge directly:
142
154
  - If pi restarts while a page is open (new random port + token), the next send re-discovers the bridge and retries once automatically — no page refresh needed. The status poll does the same: a stale port flips to `offline` for one cycle, then rediscovers.
143
155
  - The bridge never auto-submits on its own: **写入pi输入框** only fills the editor; **直接发送** is an explicit user action in the page.
144
156
 
157
+ ## Development
158
+
159
+ ```bash
160
+ npm install
161
+ npm test # end-to-end suite; starts its own Vite dev server
162
+ npm run typecheck
163
+ ```
164
+
165
+ The suite drives the real browser client inside [`test/fixture`](test/fixture), a throwaway Vite app: it picks elements, types descriptions, attaches screenshots and reloads, then asserts the draft survived. It runs against `src/` and `client/` directly — no build step is needed first, and a broken client fails the tests rather than the published package.
166
+
167
+ Local runs drive your installed Chrome. To use Playwright's pinned Chromium instead, run `npx playwright install chromium` once and set `PW_BUNDLED=1`; CI always uses the pinned build. `npx playwright test --ui` and `--debug` work as usual, and a failing run keeps a trace in `test-results/`.
168
+
145
169
  ## Releasing
146
170
 
147
171
  Publishing is token-free: `.github/workflows/release.yml` publishes with **Trusted Publishing (OIDC)**, so `npm publish` authenticates through a short-lived OIDC token minted by GitHub Actions and provenance is attached automatically.
package/client/picker.js CHANGED
@@ -152,10 +152,22 @@
152
152
  // The captured element the panel acts on. Stays put across close/reopen so
153
153
  // hovering the launcher can bring the panel back without overwriting it.
154
154
  var target = null;
155
+ // The selector `target` was captured through. Kept so the draft can be
156
+ // re-attached after a reload replaces the DOM node `target` points at.
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;
155
164
  var shots = [];
156
165
  // Set when a pick commits on pointerdown; swallows the follow-up click so the
157
166
  // page (links, buttons) never reacts to the click that finished the pick.
158
167
  var swallowClick = false;
168
+ // Set when the user closes the panel themselves, so a reload does not pop it
169
+ // back open on top of whatever they moved on to.
170
+ var panelDismissed = false;
159
171
 
160
172
  document.documentElement.appendChild(host);
161
173
 
@@ -485,22 +497,46 @@
485
497
  }
486
498
  }
487
499
 
488
- /** 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
+ */
489
508
  function openPanel() {
490
- if (!target) return false;
509
+ var el = resolveTarget();
510
+ if (!el && !targetSnapshot) return false;
491
511
  panel.classList.add('open');
492
- showHighlight(target);
512
+ if (el) showHighlight(el);
513
+ else hideHighlight();
493
514
  positionPanel();
494
515
  return true;
495
516
  }
496
517
 
497
518
  function select(el) {
498
519
  target = el;
499
- metaEl.innerHTML = '<code>' + escapeHtml(cssPath(el)) + '</code>' + vueLine(el);
520
+ targetSelector = cssPath(el);
521
+ targetSnapshot = describe(el);
522
+ targetUrl = location.href;
523
+ // A new pick re-arms the draft: the user is working again, so a reload must
524
+ // bring the panel back even if they closed it earlier.
525
+ panelDismissed = false;
526
+ metaEl.innerHTML = '<code>' + escapeHtml(targetSelector) + '</code>' + vueLine(el);
500
527
  openPanel();
528
+ saveDraft();
501
529
  taEl.focus();
502
530
  }
503
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
+
504
540
  function vueLine(el) {
505
541
  var v = vueInfo(el);
506
542
  if (v.componentFile) return '<br>组件: <code>' + escapeHtml((v.componentName ? v.componentName + ' ' : '') + v.componentFile) + '</code>';
@@ -593,6 +629,7 @@
593
629
  function addShot(dataUrl) {
594
630
  shots.push(dataUrl);
595
631
  renderShots();
632
+ saveShots();
596
633
  }
597
634
 
598
635
  function renderShots() {
@@ -609,6 +646,7 @@
609
646
  x.addEventListener('click', function () {
610
647
  shots.splice(i, 1);
611
648
  renderShots();
649
+ saveShots();
612
650
  });
613
651
  d1.appendChild(img);
614
652
  d1.appendChild(x);
@@ -627,6 +665,234 @@
627
665
  });
628
666
  }
629
667
 
668
+ /* --- draft persistence ---------------------------------------------------
669
+ *
670
+ * Vite reloads the page on every save, and the composer is by far the most
671
+ * expensive thing in this tool to lose: a couple of hundred words of
672
+ * description plus a few pasted screenshots. So the draft is mirrored into
673
+ * `sessionStorage` and re-attached on the next boot.
674
+ *
675
+ * `sessionStorage`, not `localStorage`: a draft belongs to the tab that
676
+ * produced it and must not follow the developer into a fresh window.
677
+ *
678
+ * Two keys on purpose. The text changes on every keystroke while the shots
679
+ * change rarely; re-serialising megabytes of base64 per keypress would both
680
+ * stutter the panel and hit the ~5MB quota needlessly. The text key stays
681
+ * tiny so writing it aggressively is cheap, and the shots key is rewritten
682
+ * only when the shot set actually changes.
683
+ *
684
+ * Everything here is best-effort: a storage failure must never break the
685
+ * editor, only lose the draft.
686
+ */
687
+ var DRAFT_PREFIX = 'pi-ele-picker:draft:v1';
688
+ var SHOTS_PREFIX = 'pi-ele-picker:shots:v1';
689
+ var SAVE_DEBOUNCE = 250;
690
+ // A draft left over from a tab that stayed open for days is more confusing than
691
+ // helpful: it describes an element that has almost certainly moved on.
692
+ var DRAFT_TTL = 24 * 60 * 60 * 1000;
693
+ var saveTimer = null;
694
+ // True while an edit has not reached storage yet, so a page that is going
695
+ // away flushes exactly once and a delivered draft is not resurrected.
696
+ var draftDirty = false;
697
+ var shotsDropped = false;
698
+
699
+ function draftKey() {
700
+ return DRAFT_PREFIX + ':' + location.pathname;
701
+ }
702
+
703
+ function shotsKey() {
704
+ return SHOTS_PREFIX + ':' + location.pathname;
705
+ }
706
+
707
+ /** Never throws: returns false when storage is full or unavailable. */
708
+ function writeStorage(key, value) {
709
+ try {
710
+ sessionStorage.setItem(key, value);
711
+ return true;
712
+ } catch (e) {
713
+ return false;
714
+ }
715
+ }
716
+
717
+ function removeStorage(key) {
718
+ try {
719
+ sessionStorage.removeItem(key);
720
+ } catch (e) {
721
+ /* storage unavailable — nothing to clear */
722
+ }
723
+ }
724
+
725
+ function readStorage(key) {
726
+ try {
727
+ return sessionStorage.getItem(key);
728
+ } catch (e) {
729
+ return null;
730
+ }
731
+ }
732
+
733
+ function saveShots() {
734
+ if (!shots.length) {
735
+ removeStorage(shotsKey());
736
+ shotsDropped = false;
737
+ return;
738
+ }
739
+ if (writeStorage(shotsKey(), JSON.stringify(shots))) {
740
+ shotsDropped = false;
741
+ return;
742
+ }
743
+ // The images do not fit. Keep the words and say so once, so a reload does not
744
+ // silently eat the screenshots that were just pasted.
745
+ removeStorage(shotsKey());
746
+ if (!shotsDropped) {
747
+ shotsDropped = true;
748
+ setStatus('截图过大,刷新后不会保留(描述仍会保存)', 'err');
749
+ }
750
+ }
751
+
752
+ function saveDraft() {
753
+ var rec = {
754
+ selector: targetSelector,
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,
761
+ dismissed: panelDismissed,
762
+ savedAt: Date.now(),
763
+ };
764
+ if (!rec.text && !rec.selector && !rec.element) {
765
+ // Nothing worth restoring — do not resurrect an empty panel on reload.
766
+ clearDraft();
767
+ return;
768
+ }
769
+ // Stay dirty when the write fails, so a page that is going away retries.
770
+ if (writeStorage(draftKey(), JSON.stringify(rec))) draftDirty = false;
771
+ }
772
+
773
+ /** Debounced text write: the keystroke path must stay cheap. */
774
+ function scheduleSave() {
775
+ draftDirty = true;
776
+ if (saveTimer) clearTimeout(saveTimer);
777
+ saveTimer = setTimeout(function () {
778
+ saveTimer = null;
779
+ saveDraft();
780
+ }, SAVE_DEBOUNCE);
781
+ }
782
+
783
+ /** Write any pending edit right now — the page is being torn down. */
784
+ function flushDraft() {
785
+ if (!draftDirty && !saveTimer) return;
786
+ if (saveTimer) clearTimeout(saveTimer);
787
+ saveTimer = null;
788
+ saveDraft();
789
+ }
790
+
791
+ /** Forget the draft: it has been delivered, or it is stale garbage. */
792
+ function clearDraft() {
793
+ if (saveTimer) clearTimeout(saveTimer);
794
+ saveTimer = null;
795
+ draftDirty = false;
796
+ shotsDropped = false;
797
+ removeStorage(draftKey());
798
+ removeStorage(shotsKey());
799
+ }
800
+
801
+ /**
802
+ * The live element the panel acts on.
803
+ *
804
+ * `target` is a DOM reference captured at pick time, so an HMR update — or any
805
+ * re-render that swaps the node — leaves it detached. Re-resolving from the
806
+ * remembered selector keeps the panel pointed at the right element without
807
+ * asking the user to pick again, and returns null when the element is really
808
+ * gone, which is what send() and openPanel() need to know.
809
+ */
810
+ function resolveTarget() {
811
+ if (target && target.isConnected) return target;
812
+ if (targetSelector) {
813
+ try {
814
+ var el = document.querySelector(targetSelector);
815
+ if (el) {
816
+ target = el;
817
+ return el;
818
+ }
819
+ } catch (e) {
820
+ /* not valid CSS (Tailwind-style class names) — cannot re-resolve */
821
+ }
822
+ }
823
+ return null;
824
+ }
825
+
826
+ /**
827
+ * Re-attach the draft written before this page load.
828
+ *
829
+ * The element is re-resolved from its selector rather than trusted blindly:
830
+ * an HMR update replaces DOM nodes, so a captured `target` reference is dead by
831
+ * the time the page comes back. If the selector no longer resolves, the text
832
+ * and screenshots are still restored into the composer, so the next pick
833
+ * inherits them instead of the user retyping everything.
834
+ */
835
+ function restoreDraft() {
836
+ var raw = readStorage(draftKey());
837
+ if (!raw) return;
838
+ var rec;
839
+ try {
840
+ rec = JSON.parse(raw);
841
+ } catch (e) {
842
+ clearDraft();
843
+ return;
844
+ }
845
+ if (!rec || typeof rec !== 'object') {
846
+ clearDraft();
847
+ return;
848
+ }
849
+ if (typeof rec.savedAt === 'number' && Date.now() - rec.savedAt > DRAFT_TTL) {
850
+ clearDraft();
851
+ return;
852
+ }
853
+
854
+ taEl.value = typeof rec.text === 'string' ? rec.text : '';
855
+
856
+ var rawShots = readStorage(shotsKey());
857
+ if (rawShots) {
858
+ try {
859
+ var arr = JSON.parse(rawShots);
860
+ if (arr instanceof Array) {
861
+ shots = arr.filter(function (d) {
862
+ return typeof d === 'string' && d.indexOf('data:image/') === 0;
863
+ });
864
+ renderShots();
865
+ }
866
+ } catch (e) {
867
+ removeStorage(shotsKey());
868
+ }
869
+ }
870
+
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
+ '上次选中的元素已不在页面中,发送时会带上抓取时的快照';
883
+ }
884
+
885
+ if (rec.dismissed) return;
886
+ if (openPanel()) return;
887
+ if (!taEl.value && !shots.length) return;
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.
891
+ panel.classList.add('open');
892
+ metaEl.textContent = '上次选中的元素已不在页面中,请重新点击选择';
893
+ positionPanel();
894
+ }
895
+
630
896
  function loadConfig(force) {
631
897
  if (!force && cfg.port) return Promise.resolve(cfg);
632
898
  return fetch(CFG_URL, { cache: 'no-store' })
@@ -643,14 +909,16 @@
643
909
  }
644
910
 
645
911
  /** POST the payload to the bridge. Never rejects; status 0 means the fetch itself failed. */
646
- function deliver(c, submit) {
912
+ function deliver(c, submit, element) {
647
913
  var payload = {
648
914
  text: taEl.value,
649
915
  images: shots.map(function (d, i) {
650
916
  return { name: 'shot-' + i + '.png', dataUrl: d };
651
917
  }),
652
- element: describe(target),
653
- 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,
654
922
  title: document.title,
655
923
  submit: !!submit,
656
924
  };
@@ -687,6 +955,32 @@
687
955
  }
688
956
 
689
957
  function send(submit) {
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');
976
+ return;
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
+
690
984
  sendBtn.disabled = true;
691
985
  sendNowBtn.disabled = true;
692
986
  setStatus(submit ? '直接发送中…' : '写入中…');
@@ -694,7 +988,7 @@
694
988
  function attempt(forceFresh) {
695
989
  return loadConfig(forceFresh).then(function (c) {
696
990
  if (!c || !c.port) return { status: 0, body: null, noBridge: true };
697
- return deliver(c, submit);
991
+ return deliver(c, submit, element);
698
992
  });
699
993
  }
700
994
 
@@ -712,14 +1006,22 @@
712
1006
  return;
713
1007
  }
714
1008
  if (out.body && out.body.ok) {
1009
+ // Say which element went out, so a stale pick is visible rather than silent.
715
1010
  if (out.body.submitted) {
716
- setStatus(out.body.queued ? '已发送 ✓(agent 忙碌,已排队为后续任务)' : '已直接发送给 pi 并开始执行 ✓', 'ok');
1011
+ setStatus(
1012
+ (out.body.queued ? '已发送 ✓(agent 忙碌,已排队为后续任务)' : '已直接发送给 pi 并开始执行 ✓') + note,
1013
+ 'ok'
1014
+ );
717
1015
  } else {
718
- setStatus(out.body.inserted ? '已写入 pi 输入框 ✓(去终端按回车发送)' : '已发送给 pi ✓', 'ok');
1016
+ setStatus(
1017
+ (out.body.inserted ? '已写入 pi 输入框 ✓(去终端按回车发送)' : '已发送给 pi ✓') + note,
1018
+ 'ok'
1019
+ );
719
1020
  }
720
1021
  taEl.value = '';
721
1022
  shots = [];
722
1023
  renderShots();
1024
+ clearDraft();
723
1025
  } else {
724
1026
  setStatus('失败: ' + ((out.body && out.body.error) || out.error || 'HTTP ' + out.status), 'err');
725
1027
  }
@@ -777,6 +1079,10 @@
777
1079
  panel.classList.remove('open');
778
1080
  hideHighlight();
779
1081
  setStatus('');
1082
+ // Remember the dismissal so a reload does not pop the panel back open, while
1083
+ // the draft stays available through the launcher's hover-to-reopen.
1084
+ panelDismissed = true;
1085
+ saveDraft();
780
1086
  });
781
1087
  attachBtn.addEventListener('click', function () {
782
1088
  fileEl.click();
@@ -791,6 +1097,8 @@
791
1097
  sendNowBtn.addEventListener('click', function () {
792
1098
  send(true);
793
1099
  });
1100
+ taEl.addEventListener('input', scheduleSave);
1101
+ window.addEventListener('pagehide', flushDraft);
794
1102
  taEl.addEventListener('paste', function (e) {
795
1103
  var items = e.clipboardData && e.clipboardData.items;
796
1104
  if (!items) return;
@@ -944,4 +1252,8 @@
944
1252
  var override = readStatusOverride();
945
1253
  setStatusEnabled(override !== null ? override : cfg && cfg.statusDefault === true, false);
946
1254
  });
1255
+
1256
+ // Last: the draft restore depends on state initialised above it (launcher
1257
+ // position, panel offset) and on the draft storage keys being assigned.
1258
+ restoreDraft();
947
1259
  })();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-ele-picker",
3
- "version": "0.4.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",
@@ -54,7 +54,8 @@
54
54
  "scripts": {
55
55
  "build": "tsup",
56
56
  "dev": "tsup --watch",
57
- "typecheck": "tsc --noEmit",
57
+ "typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.test.json",
58
+ "test": "playwright test",
58
59
  "prepublishOnly": "npm run build"
59
60
  },
60
61
  "peerDependencies": {
@@ -70,6 +71,7 @@
70
71
  }
71
72
  },
72
73
  "devDependencies": {
74
+ "@playwright/test": "^1.64.1",
73
75
  "@types/node": "^20.11.0",
74
76
  "tsup": "^8.0.0",
75
77
  "typescript": "^5.4.0",