pi-ele-picker 0.3.2 → 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 +26 -0
- package/client/picker.js +300 -7
- package/package.json +4 -2
package/README.md
CHANGED
|
@@ -94,6 +94,8 @@ The launcher starts in the **bottom-right** corner and stays out of the way:
|
|
|
94
94
|
- **Draggable** — grab it and drop it anywhere; the position is remembered per browser (`localStorage`).
|
|
95
95
|
- **Fades when idle** (opacity `0.5`) and returns to full opacity on hover.
|
|
96
96
|
- **Shows the live pi session status** as a coloured dot **when enabled** — green while pi is running, amber while follow-ups are queued, grey while awaiting input, red when the bridge is gone (see [Session status](#session-status)).
|
|
97
|
+
- **Hovering the launcher reopens the panel** for the element you captured last — no need to pick it again, and the captured element is only replaced when you commit a new pick. Closing the panel with **✕** keeps it available this way.
|
|
98
|
+
- Clicking the launcher starts a **new pick**: the panel steps out of the way, and pressing **Esc** keeps the previously captured element intact.
|
|
97
99
|
- The panel always opens **next to the launcher**, flipping sides and clamping to stay on screen.
|
|
98
100
|
- **The panel is draggable too** — grab its title bar (or any empty spot) to move it; the offset from the launcher is remembered per browser (`localStorage`), and the panel still follows the launcher while clamping itself to stay on screen.
|
|
99
101
|
|
|
@@ -103,6 +105,18 @@ The launcher starts in the **bottom-right** corner and stays out of the way:
|
|
|
103
105
|
- **写入pi输入框** — pre-fills the current pi input box with the composed prompt; you edit and press Enter.
|
|
104
106
|
- **直接发送** — submits the composed prompt as a real user turn immediately (queued as a follow-up if pi is mid-turn).
|
|
105
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
|
+
|
|
106
120
|
### Session status
|
|
107
121
|
|
|
108
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:
|
|
@@ -140,6 +154,18 @@ The same payload is available from the bridge directly:
|
|
|
140
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.
|
|
141
155
|
- The bridge never auto-submits on its own: **写入pi输入框** only fills the editor; **直接发送** is an explicit user action in the page.
|
|
142
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
|
+
|
|
143
169
|
## Releasing
|
|
144
170
|
|
|
145
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
|
@@ -119,7 +119,7 @@
|
|
|
119
119
|
triggerDot.className = 'dot st-off';
|
|
120
120
|
trigger.appendChild(triggerDot);
|
|
121
121
|
trigger.appendChild(document.createTextNode('Pi Picker'));
|
|
122
|
-
trigger.title = '拖拽可移动 · 面板标题栏也可拖拽 · 点击开始选择元素';
|
|
122
|
+
trigger.title = '拖拽可移动 · 面板标题栏也可拖拽 · 悬浮可再次打开上次元素 · 点击开始选择元素';
|
|
123
123
|
root.appendChild(trigger);
|
|
124
124
|
|
|
125
125
|
var panel = document.createElement('div');
|
|
@@ -149,11 +149,19 @@
|
|
|
149
149
|
var fileEl = panel.querySelector('.file');
|
|
150
150
|
|
|
151
151
|
var picking = false;
|
|
152
|
+
// The captured element the panel acts on. Stays put across close/reopen so
|
|
153
|
+
// hovering the launcher can bring the panel back without overwriting it.
|
|
152
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;
|
|
153
158
|
var shots = [];
|
|
154
159
|
// Set when a pick commits on pointerdown; swallows the follow-up click so the
|
|
155
160
|
// page (links, buttons) never reacts to the click that finished the pick.
|
|
156
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;
|
|
157
165
|
|
|
158
166
|
document.documentElement.appendChild(host);
|
|
159
167
|
|
|
@@ -281,6 +289,7 @@
|
|
|
281
289
|
|
|
282
290
|
trigger.addEventListener('pointerdown', function (e) {
|
|
283
291
|
if (e.pointerType === 'mouse' && e.button !== 0) return;
|
|
292
|
+
clearHoverOpen();
|
|
284
293
|
var r = trigger.getBoundingClientRect();
|
|
285
294
|
drag = { id: e.pointerId, sx: e.clientX, sy: e.clientY, ox: r.left, oy: r.top, moved: false };
|
|
286
295
|
try {
|
|
@@ -419,7 +428,8 @@
|
|
|
419
428
|
function onMove(e) {
|
|
420
429
|
var el = elFromEvent(e);
|
|
421
430
|
if (!el) return;
|
|
422
|
-
target
|
|
431
|
+
// Highlight only: `target` is replaced solely by a committed pick, so Esc
|
|
432
|
+
// or pausing on another element never clobbers the panel's element.
|
|
423
433
|
showHighlight(el);
|
|
424
434
|
}
|
|
425
435
|
|
|
@@ -464,6 +474,12 @@
|
|
|
464
474
|
hint.style.display = on ? 'block' : 'none';
|
|
465
475
|
trigger.style.display = on ? 'none' : 'flex';
|
|
466
476
|
if (on) {
|
|
477
|
+
// Picking means "choose another element": hide the panel so it never
|
|
478
|
+
// covers the page. It reopens when the new pick commits, and the previous
|
|
479
|
+
// element only gets replaced at that point.
|
|
480
|
+
clearHoverOpen();
|
|
481
|
+
panel.classList.remove('open');
|
|
482
|
+
hideHighlight();
|
|
467
483
|
window.addEventListener('mousemove', onMove, true);
|
|
468
484
|
window.addEventListener('pointerdown', onPointerDown, true);
|
|
469
485
|
window.addEventListener('keydown', onKey, true);
|
|
@@ -475,12 +491,25 @@
|
|
|
475
491
|
}
|
|
476
492
|
}
|
|
477
493
|
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
494
|
+
/** Re-show the panel for the element already captured — no re-pick, no overwrite. */
|
|
495
|
+
function openPanel() {
|
|
496
|
+
var el = resolveTarget();
|
|
497
|
+
if (!el) return false;
|
|
482
498
|
panel.classList.add('open');
|
|
499
|
+
showHighlight(el);
|
|
483
500
|
positionPanel();
|
|
501
|
+
return true;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
function select(el) {
|
|
505
|
+
target = 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);
|
|
511
|
+
openPanel();
|
|
512
|
+
saveDraft();
|
|
484
513
|
taEl.focus();
|
|
485
514
|
}
|
|
486
515
|
|
|
@@ -576,6 +605,7 @@
|
|
|
576
605
|
function addShot(dataUrl) {
|
|
577
606
|
shots.push(dataUrl);
|
|
578
607
|
renderShots();
|
|
608
|
+
saveShots();
|
|
579
609
|
}
|
|
580
610
|
|
|
581
611
|
function renderShots() {
|
|
@@ -592,6 +622,7 @@
|
|
|
592
622
|
x.addEventListener('click', function () {
|
|
593
623
|
shots.splice(i, 1);
|
|
594
624
|
renderShots();
|
|
625
|
+
saveShots();
|
|
595
626
|
});
|
|
596
627
|
d1.appendChild(img);
|
|
597
628
|
d1.appendChild(x);
|
|
@@ -610,6 +641,224 @@
|
|
|
610
641
|
});
|
|
611
642
|
}
|
|
612
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
|
+
|
|
613
862
|
function loadConfig(force) {
|
|
614
863
|
if (!force && cfg.port) return Promise.resolve(cfg);
|
|
615
864
|
return fetch(CFG_URL, { cache: 'no-store' })
|
|
@@ -670,6 +919,12 @@
|
|
|
670
919
|
}
|
|
671
920
|
|
|
672
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
|
+
}
|
|
673
928
|
sendBtn.disabled = true;
|
|
674
929
|
sendNowBtn.disabled = true;
|
|
675
930
|
setStatus(submit ? '直接发送中…' : '写入中…');
|
|
@@ -703,6 +958,7 @@
|
|
|
703
958
|
taEl.value = '';
|
|
704
959
|
shots = [];
|
|
705
960
|
renderShots();
|
|
961
|
+
clearDraft();
|
|
706
962
|
} else {
|
|
707
963
|
setStatus('失败: ' + ((out.body && out.body.error) || out.error || 'HTTP ' + out.status), 'err');
|
|
708
964
|
}
|
|
@@ -722,6 +978,33 @@
|
|
|
722
978
|
window.addEventListener('pointerdown', function () { swallowClick = false; }, true);
|
|
723
979
|
window.addEventListener('click', onClick, true);
|
|
724
980
|
|
|
981
|
+
/* --- hover reopens the last captured element --- */
|
|
982
|
+
var HOVER_OPEN_DELAY = 200;
|
|
983
|
+
var hoverOpenTimer = null;
|
|
984
|
+
|
|
985
|
+
function clearHoverOpen() {
|
|
986
|
+
if (hoverOpenTimer) {
|
|
987
|
+
clearTimeout(hoverOpenTimer);
|
|
988
|
+
hoverOpenTimer = null;
|
|
989
|
+
}
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
trigger.addEventListener('pointerenter', function (e) {
|
|
993
|
+
// Mouse only: touch/pen enter is part of a tap, which must keep its click.
|
|
994
|
+
if (e.pointerType !== 'mouse') return;
|
|
995
|
+
if (picking || drag) return;
|
|
996
|
+
clearHoverOpen();
|
|
997
|
+
// A short dwell keeps a mouse merely passing over the launcher from popping
|
|
998
|
+
// the panel open, while still feeling immediate when you aim at it.
|
|
999
|
+
hoverOpenTimer = setTimeout(function () {
|
|
1000
|
+
hoverOpenTimer = null;
|
|
1001
|
+
if (picking || drag) return;
|
|
1002
|
+
openPanel();
|
|
1003
|
+
}, HOVER_OPEN_DELAY);
|
|
1004
|
+
});
|
|
1005
|
+
|
|
1006
|
+
trigger.addEventListener('pointerleave', clearHoverOpen);
|
|
1007
|
+
|
|
725
1008
|
trigger.addEventListener('click', function () {
|
|
726
1009
|
if (suppressClick) {
|
|
727
1010
|
suppressClick = false;
|
|
@@ -733,6 +1016,10 @@
|
|
|
733
1016
|
panel.classList.remove('open');
|
|
734
1017
|
hideHighlight();
|
|
735
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();
|
|
736
1023
|
});
|
|
737
1024
|
attachBtn.addEventListener('click', function () {
|
|
738
1025
|
fileEl.click();
|
|
@@ -747,6 +1034,8 @@
|
|
|
747
1034
|
sendNowBtn.addEventListener('click', function () {
|
|
748
1035
|
send(true);
|
|
749
1036
|
});
|
|
1037
|
+
taEl.addEventListener('input', scheduleSave);
|
|
1038
|
+
window.addEventListener('pagehide', flushDraft);
|
|
750
1039
|
taEl.addEventListener('paste', function (e) {
|
|
751
1040
|
var items = e.clipboardData && e.clipboardData.items;
|
|
752
1041
|
if (!items) return;
|
|
@@ -802,7 +1091,7 @@
|
|
|
802
1091
|
if (info && info.sessionId) detail.push('会话 ' + String(info.sessionId).slice(0, 8));
|
|
803
1092
|
var line = STATUS_LABEL[st] + (detail.length ? ' · ' + detail.join(' · ') : '');
|
|
804
1093
|
sessionText.textContent = line;
|
|
805
|
-
var title = line + ' · 拖拽可移动 · 面板标题栏也可拖拽 · 点击开始选择元素';
|
|
1094
|
+
var title = line + ' · 拖拽可移动 · 面板标题栏也可拖拽 · 悬浮可再次打开上次元素 · 点击开始选择元素';
|
|
806
1095
|
if (title !== statusTitle) {
|
|
807
1096
|
trigger.title = title;
|
|
808
1097
|
statusTitle = title;
|
|
@@ -900,4 +1189,8 @@
|
|
|
900
1189
|
var override = readStatusOverride();
|
|
901
1190
|
setStatusEnabled(override !== null ? override : cfg && cfg.statusDefault === true, false);
|
|
902
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();
|
|
903
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",
|