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 +24 -0
- package/client/picker.js +252 -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 **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
|
-
|
|
496
|
+
var el = resolveTarget();
|
|
497
|
+
if (!el) return false;
|
|
491
498
|
panel.classList.add('open');
|
|
492
|
-
showHighlight(
|
|
499
|
+
showHighlight(el);
|
|
493
500
|
positionPanel();
|
|
494
501
|
return true;
|
|
495
502
|
}
|
|
496
503
|
|
|
497
504
|
function select(el) {
|
|
498
505
|
target = el;
|
|
499
|
-
|
|
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.
|
|
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",
|