pi-ele-picker 0.4.0 → 0.5.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
@@ -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 **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
+ - 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.
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,16 @@
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;
155
158
  var shots = [];
156
159
  // Set when a pick commits on pointerdown; swallows the follow-up click so the
157
160
  // page (links, buttons) never reacts to the click that finished the pick.
158
161
  var swallowClick = false;
162
+ // Set when the user closes the panel themselves, so a reload does not pop it
163
+ // back open on top of whatever they moved on to.
164
+ var panelDismissed = false;
159
165
 
160
166
  document.documentElement.appendChild(host);
161
167
 
@@ -487,17 +493,23 @@
487
493
 
488
494
  /** Re-show the panel for the element already captured — no re-pick, no overwrite. */
489
495
  function openPanel() {
490
- if (!target) return false;
496
+ var el = resolveTarget();
497
+ if (!el) return false;
491
498
  panel.classList.add('open');
492
- showHighlight(target);
499
+ showHighlight(el);
493
500
  positionPanel();
494
501
  return true;
495
502
  }
496
503
 
497
504
  function select(el) {
498
505
  target = el;
499
- metaEl.innerHTML = '<code>' + escapeHtml(cssPath(el)) + '</code>' + vueLine(el);
506
+ targetSelector = cssPath(el);
507
+ // A new pick re-arms the draft: the user is working again, so a reload must
508
+ // bring the panel back even if they closed it earlier.
509
+ panelDismissed = false;
510
+ metaEl.innerHTML = '<code>' + escapeHtml(targetSelector) + '</code>' + vueLine(el);
500
511
  openPanel();
512
+ saveDraft();
501
513
  taEl.focus();
502
514
  }
503
515
 
@@ -593,6 +605,7 @@
593
605
  function addShot(dataUrl) {
594
606
  shots.push(dataUrl);
595
607
  renderShots();
608
+ saveShots();
596
609
  }
597
610
 
598
611
  function renderShots() {
@@ -609,6 +622,7 @@
609
622
  x.addEventListener('click', function () {
610
623
  shots.splice(i, 1);
611
624
  renderShots();
625
+ saveShots();
612
626
  });
613
627
  d1.appendChild(img);
614
628
  d1.appendChild(x);
@@ -627,6 +641,224 @@
627
641
  });
628
642
  }
629
643
 
644
+ /* --- draft persistence ---------------------------------------------------
645
+ *
646
+ * Vite reloads the page on every save, and the composer is by far the most
647
+ * expensive thing in this tool to lose: a couple of hundred words of
648
+ * description plus a few pasted screenshots. So the draft is mirrored into
649
+ * `sessionStorage` and re-attached on the next boot.
650
+ *
651
+ * `sessionStorage`, not `localStorage`: a draft belongs to the tab that
652
+ * produced it and must not follow the developer into a fresh window.
653
+ *
654
+ * Two keys on purpose. The text changes on every keystroke while the shots
655
+ * change rarely; re-serialising megabytes of base64 per keypress would both
656
+ * stutter the panel and hit the ~5MB quota needlessly. The text key stays
657
+ * tiny so writing it aggressively is cheap, and the shots key is rewritten
658
+ * only when the shot set actually changes.
659
+ *
660
+ * Everything here is best-effort: a storage failure must never break the
661
+ * editor, only lose the draft.
662
+ */
663
+ var DRAFT_PREFIX = 'pi-ele-picker:draft:v1';
664
+ var SHOTS_PREFIX = 'pi-ele-picker:shots:v1';
665
+ var SAVE_DEBOUNCE = 250;
666
+ // A draft left over from a tab that stayed open for days is more confusing than
667
+ // helpful: it describes an element that has almost certainly moved on.
668
+ var DRAFT_TTL = 24 * 60 * 60 * 1000;
669
+ var saveTimer = null;
670
+ // True while an edit has not reached storage yet, so a page that is going
671
+ // away flushes exactly once and a delivered draft is not resurrected.
672
+ var draftDirty = false;
673
+ var shotsDropped = false;
674
+
675
+ function draftKey() {
676
+ return DRAFT_PREFIX + ':' + location.pathname;
677
+ }
678
+
679
+ function shotsKey() {
680
+ return SHOTS_PREFIX + ':' + location.pathname;
681
+ }
682
+
683
+ /** Never throws: returns false when storage is full or unavailable. */
684
+ function writeStorage(key, value) {
685
+ try {
686
+ sessionStorage.setItem(key, value);
687
+ return true;
688
+ } catch (e) {
689
+ return false;
690
+ }
691
+ }
692
+
693
+ function removeStorage(key) {
694
+ try {
695
+ sessionStorage.removeItem(key);
696
+ } catch (e) {
697
+ /* storage unavailable — nothing to clear */
698
+ }
699
+ }
700
+
701
+ function readStorage(key) {
702
+ try {
703
+ return sessionStorage.getItem(key);
704
+ } catch (e) {
705
+ return null;
706
+ }
707
+ }
708
+
709
+ function saveShots() {
710
+ if (!shots.length) {
711
+ removeStorage(shotsKey());
712
+ shotsDropped = false;
713
+ return;
714
+ }
715
+ if (writeStorage(shotsKey(), JSON.stringify(shots))) {
716
+ shotsDropped = false;
717
+ return;
718
+ }
719
+ // The images do not fit. Keep the words and say so once, so a reload does not
720
+ // silently eat the screenshots that were just pasted.
721
+ removeStorage(shotsKey());
722
+ if (!shotsDropped) {
723
+ shotsDropped = true;
724
+ setStatus('截图过大,刷新后不会保留(描述仍会保存)', 'err');
725
+ }
726
+ }
727
+
728
+ function saveDraft() {
729
+ var rec = {
730
+ selector: targetSelector,
731
+ text: taEl.value,
732
+ dismissed: panelDismissed,
733
+ savedAt: Date.now(),
734
+ };
735
+ if (!rec.text && !rec.selector) {
736
+ // Nothing worth restoring — do not resurrect an empty panel on reload.
737
+ clearDraft();
738
+ return;
739
+ }
740
+ // Stay dirty when the write fails, so a page that is going away retries.
741
+ if (writeStorage(draftKey(), JSON.stringify(rec))) draftDirty = false;
742
+ }
743
+
744
+ /** Debounced text write: the keystroke path must stay cheap. */
745
+ function scheduleSave() {
746
+ draftDirty = true;
747
+ if (saveTimer) clearTimeout(saveTimer);
748
+ saveTimer = setTimeout(function () {
749
+ saveTimer = null;
750
+ saveDraft();
751
+ }, SAVE_DEBOUNCE);
752
+ }
753
+
754
+ /** Write any pending edit right now — the page is being torn down. */
755
+ function flushDraft() {
756
+ if (!draftDirty && !saveTimer) return;
757
+ if (saveTimer) clearTimeout(saveTimer);
758
+ saveTimer = null;
759
+ saveDraft();
760
+ }
761
+
762
+ /** Forget the draft: it has been delivered, or it is stale garbage. */
763
+ function clearDraft() {
764
+ if (saveTimer) clearTimeout(saveTimer);
765
+ saveTimer = null;
766
+ draftDirty = false;
767
+ shotsDropped = false;
768
+ removeStorage(draftKey());
769
+ removeStorage(shotsKey());
770
+ }
771
+
772
+ /**
773
+ * The live element the panel acts on.
774
+ *
775
+ * `target` is a DOM reference captured at pick time, so an HMR update — or any
776
+ * re-render that swaps the node — leaves it detached. Re-resolving from the
777
+ * remembered selector keeps the panel pointed at the right element without
778
+ * asking the user to pick again, and returns null when the element is really
779
+ * gone, which is what send() and openPanel() need to know.
780
+ */
781
+ function resolveTarget() {
782
+ if (target && target.isConnected) return target;
783
+ if (targetSelector) {
784
+ try {
785
+ var el = document.querySelector(targetSelector);
786
+ if (el) {
787
+ target = el;
788
+ return el;
789
+ }
790
+ } catch (e) {
791
+ /* not valid CSS (Tailwind-style class names) — cannot re-resolve */
792
+ }
793
+ }
794
+ return null;
795
+ }
796
+
797
+ /**
798
+ * Re-attach the draft written before this page load.
799
+ *
800
+ * The element is re-resolved from its selector rather than trusted blindly:
801
+ * an HMR update replaces DOM nodes, so a captured `target` reference is dead by
802
+ * the time the page comes back. If the selector no longer resolves, the text
803
+ * and screenshots are still restored into the composer, so the next pick
804
+ * inherits them instead of the user retyping everything.
805
+ */
806
+ function restoreDraft() {
807
+ var raw = readStorage(draftKey());
808
+ if (!raw) return;
809
+ var rec;
810
+ try {
811
+ rec = JSON.parse(raw);
812
+ } catch (e) {
813
+ clearDraft();
814
+ return;
815
+ }
816
+ if (!rec || typeof rec !== 'object') {
817
+ clearDraft();
818
+ return;
819
+ }
820
+ if (typeof rec.savedAt === 'number' && Date.now() - rec.savedAt > DRAFT_TTL) {
821
+ clearDraft();
822
+ return;
823
+ }
824
+
825
+ taEl.value = typeof rec.text === 'string' ? rec.text : '';
826
+
827
+ var rawShots = readStorage(shotsKey());
828
+ if (rawShots) {
829
+ try {
830
+ var arr = JSON.parse(rawShots);
831
+ if (arr instanceof Array) {
832
+ shots = arr.filter(function (d) {
833
+ return typeof d === 'string' && d.indexOf('data:image/') === 0;
834
+ });
835
+ renderShots();
836
+ }
837
+ } catch (e) {
838
+ removeStorage(shotsKey());
839
+ }
840
+ }
841
+
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
847
+ }
848
+
849
+ if (rec.dismissed) return;
850
+ if (target) {
851
+ openPanel();
852
+ return;
853
+ }
854
+ 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.
857
+ panel.classList.add('open');
858
+ metaEl.textContent = '上次选中的元素已不在页面中,请重新点击选择';
859
+ positionPanel();
860
+ }
861
+
630
862
  function loadConfig(force) {
631
863
  if (!force && cfg.port) return Promise.resolve(cfg);
632
864
  return fetch(CFG_URL, { cache: 'no-store' })
@@ -687,6 +919,12 @@
687
919
  }
688
920
 
689
921
  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');
926
+ return;
927
+ }
690
928
  sendBtn.disabled = true;
691
929
  sendNowBtn.disabled = true;
692
930
  setStatus(submit ? '直接发送中…' : '写入中…');
@@ -720,6 +958,7 @@
720
958
  taEl.value = '';
721
959
  shots = [];
722
960
  renderShots();
961
+ clearDraft();
723
962
  } else {
724
963
  setStatus('失败: ' + ((out.body && out.body.error) || out.error || 'HTTP ' + out.status), 'err');
725
964
  }
@@ -777,6 +1016,10 @@
777
1016
  panel.classList.remove('open');
778
1017
  hideHighlight();
779
1018
  setStatus('');
1019
+ // Remember the dismissal so a reload does not pop the panel back open, while
1020
+ // the draft stays available through the launcher's hover-to-reopen.
1021
+ panelDismissed = true;
1022
+ saveDraft();
780
1023
  });
781
1024
  attachBtn.addEventListener('click', function () {
782
1025
  fileEl.click();
@@ -791,6 +1034,8 @@
791
1034
  sendNowBtn.addEventListener('click', function () {
792
1035
  send(true);
793
1036
  });
1037
+ taEl.addEventListener('input', scheduleSave);
1038
+ window.addEventListener('pagehide', flushDraft);
794
1039
  taEl.addEventListener('paste', function (e) {
795
1040
  var items = e.clipboardData && e.clipboardData.items;
796
1041
  if (!items) return;
@@ -944,4 +1189,8 @@
944
1189
  var override = readStatusOverride();
945
1190
  setStatusEnabled(override !== null ? override : cfg && cfg.statusDefault === true, false);
946
1191
  });
1192
+
1193
+ // Last: the draft restore depends on state initialised above it (launcher
1194
+ // position, panel offset) and on the draft storage keys being assigned.
1195
+ restoreDraft();
947
1196
  })();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-ele-picker",
3
- "version": "0.4.0",
3
+ "version": "0.5.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",