@autono/pinbox-toolbar 0.1.0 → 0.8.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/dist/index-BAoi0bRT.d.ts +2633 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/plugins/vite.js +11 -7
- package/dist/react.d.ts +1 -1
- package/dist/react.js +1 -1
- package/dist/{src-BoLg81_j.js → src-D1qn1iet.js} +430 -33
- package/dist/svelte.d.ts +1 -1
- package/dist/svelte.js +1 -1
- package/dist/toolbar.iife.js +430 -33
- package/dist/vue.d.ts +1 -1
- package/dist/vue.js +1 -1
- package/package.json +2 -2
- package/dist/index-ZR3JBQu-.d.ts +0 -206
package/dist/toolbar.iife.js
CHANGED
|
@@ -2,13 +2,20 @@ var Pinbox = (function(exports) {
|
|
|
2
2
|
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
3
3
|
//#region src/targeting/dom.ts
|
|
4
4
|
/**
|
|
5
|
-
* Deepest element under (clientX, clientY), or null when
|
|
6
|
-
* chrome
|
|
5
|
+
* Deepest element under (clientX, clientY) that the caller does not ignore, or null when there is
|
|
6
|
+
* nothing there but page chrome (html/body).
|
|
7
|
+
*
|
|
8
|
+
* Looks THROUGH our own overlay rather than giving up at it. The single-element form could not:
|
|
9
|
+
* the drag-aim grip sits exactly on the point being aimed at, so it is always the topmost thing
|
|
10
|
+
* under the crosshair, and every probe came back "nothing" the moment touch aiming existed.
|
|
7
11
|
*/
|
|
8
12
|
function hitTest(doc, x, y, ignore) {
|
|
9
|
-
const
|
|
10
|
-
|
|
11
|
-
|
|
13
|
+
const stack = doc.elementsFromPoint?.(x, y) ?? [doc.elementFromPoint(x, y)];
|
|
14
|
+
for (const el of stack) {
|
|
15
|
+
if (!el || el === doc.body || el === doc.documentElement) return null;
|
|
16
|
+
if (!ignore(el)) return el;
|
|
17
|
+
}
|
|
18
|
+
return null;
|
|
12
19
|
}
|
|
13
20
|
/** CLASS-or-TAG display name with a sibling index when needed (prototype nodeName). */
|
|
14
21
|
function nodeName(el) {
|
|
@@ -148,6 +155,44 @@ var Pinbox = (function(exports) {
|
|
|
148
155
|
}
|
|
149
156
|
return false;
|
|
150
157
|
}
|
|
158
|
+
/** Beyond this an element is not a thing you pinned, it is a region. Too many to rewrite as a set. */
|
|
159
|
+
const MAX_RUNS = 40;
|
|
160
|
+
const MAX_RUN_LENGTH = 200;
|
|
161
|
+
/** Text that is not content: a script body or a stylesheet is not something to rewrite. */
|
|
162
|
+
const NON_CONTENT = /* @__PURE__ */ new Set([
|
|
163
|
+
"SCRIPT",
|
|
164
|
+
"STYLE",
|
|
165
|
+
"NOSCRIPT",
|
|
166
|
+
"TEMPLATE"
|
|
167
|
+
]);
|
|
168
|
+
/**
|
|
169
|
+
* The element's text, split the way the browser stores it: one entry per run of characters.
|
|
170
|
+
*
|
|
171
|
+
* This walks TEXT NODES, not elements, and that distinction is the whole point — it makes no
|
|
172
|
+
* assumption about how a site is built. A heading is one run. A nav bar is one per link. A
|
|
173
|
+
* paragraph with a bold word in the middle is three, in reading order, including the halves either
|
|
174
|
+
* side of the bold. An earlier version keyed off "elements with no element children", which
|
|
175
|
+
* quietly lost the "Hello " in `<p>Hello <b>world</b></p>` — text a person can obviously see and
|
|
176
|
+
* would obviously expect to be able to change.
|
|
177
|
+
*
|
|
178
|
+
* `nearbyText` runs them all together, which is fine to read and useless to edit: it cannot tell
|
|
179
|
+
* an agent that "work approach people contact" is four separate places. This can.
|
|
180
|
+
*/
|
|
181
|
+
function textRuns(el) {
|
|
182
|
+
const runs = [];
|
|
183
|
+
const walk = (node) => {
|
|
184
|
+
if (node.nodeType === 3) {
|
|
185
|
+
const text = (node.nodeValue ?? "").trim();
|
|
186
|
+
if (text.length > 0) runs.push(text.slice(0, MAX_RUN_LENGTH));
|
|
187
|
+
return runs.length <= MAX_RUNS;
|
|
188
|
+
}
|
|
189
|
+
if (node.nodeType !== 1 || NON_CONTENT.has(node.tagName)) return true;
|
|
190
|
+
for (const child of node.childNodes) if (!walk(child)) return false;
|
|
191
|
+
return true;
|
|
192
|
+
};
|
|
193
|
+
if (!walk(el) || runs.length === 0) return void 0;
|
|
194
|
+
return runs;
|
|
195
|
+
}
|
|
151
196
|
function buildContext(win, el) {
|
|
152
197
|
const context = {};
|
|
153
198
|
if (el.classList.length > 0) context.classes = [...el.classList];
|
|
@@ -159,6 +204,8 @@ var Pinbox = (function(exports) {
|
|
|
159
204
|
if (nearby !== void 0) context.nearbyText = nearby;
|
|
160
205
|
const selected = selectedText(win, el);
|
|
161
206
|
if (selected !== void 0) context.selectedText = selected;
|
|
207
|
+
const runs = textRuns(el);
|
|
208
|
+
if (runs !== void 0) context.textRuns = runs;
|
|
162
209
|
return Object.keys(context).length > 0 ? context : void 0;
|
|
163
210
|
}
|
|
164
211
|
/** Fills PinInput.target/env from a chosen element (shapes come from the pin schema). */
|
|
@@ -178,6 +225,14 @@ var Pinbox = (function(exports) {
|
|
|
178
225
|
fixed: isFixed(win, el)
|
|
179
226
|
};
|
|
180
227
|
if (opts?.anchor !== void 0) target.anchor = opts.anchor;
|
|
228
|
+
if (opts?.at !== void 0 && r.width > 0 && r.height > 0) {
|
|
229
|
+
const fx = (opts.at.x - (r.left + win.scrollX)) / r.width;
|
|
230
|
+
const fy = (opts.at.y - (r.top + win.scrollY)) / r.height;
|
|
231
|
+
if (fx >= 0 && fx <= 1 && fy >= 0 && fy <= 1) target.spot = {
|
|
232
|
+
x: fx,
|
|
233
|
+
y: fy
|
|
234
|
+
};
|
|
235
|
+
}
|
|
181
236
|
const context = buildContext(win, el);
|
|
182
237
|
if (context !== void 0) target.context = context;
|
|
183
238
|
return {
|
|
@@ -942,6 +997,118 @@ var Pinbox = (function(exports) {
|
|
|
942
997
|
}
|
|
943
998
|
};
|
|
944
999
|
//#endregion
|
|
1000
|
+
//#region src/ui/aim.ts
|
|
1001
|
+
/**
|
|
1002
|
+
* True when aiming has to be done by dragging rather than by pointing.
|
|
1003
|
+
*
|
|
1004
|
+
* Two independent reasons, either of which is sufficient. A coarse pointer means there is no
|
|
1005
|
+
* hover to follow at all — the mouse crosshair cannot work, whatever the screen size. The width
|
|
1006
|
+
* check is the design's own rule (720px) and catches the case a media query cannot: a device that
|
|
1007
|
+
* reports a fine pointer but is being used at phone width.
|
|
1008
|
+
*/
|
|
1009
|
+
function needsDragAim(win) {
|
|
1010
|
+
return win.matchMedia?.("(pointer: coarse)").matches === true || win.innerWidth < 720;
|
|
1011
|
+
}
|
|
1012
|
+
/**
|
|
1013
|
+
* Where the reticle starts.
|
|
1014
|
+
*
|
|
1015
|
+
* Slightly above centre: the confirm bar owns the bottom of the screen, and a reticle that opens
|
|
1016
|
+
* underneath your own thumb is one you have to move before you can even see it.
|
|
1017
|
+
*/
|
|
1018
|
+
function startPoint(win) {
|
|
1019
|
+
return {
|
|
1020
|
+
x: win.innerWidth / 2,
|
|
1021
|
+
y: win.innerHeight * .42
|
|
1022
|
+
};
|
|
1023
|
+
}
|
|
1024
|
+
const MARKUP = "<div class=\"h\"></div><div class=\"v\"></div><div class=\"grip\" aria-label=\"Pin position — arrow keys to aim\" tabindex=\"0\"><i></i></div><div class=\"bar\"><span class=\"lab\" role=\"status\" aria-live=\"polite\"></span><button type=\"button\" class=\"cancel\" data-aim=\"cancel\">CANCEL</button><button type=\"button\" class=\"ok\" data-aim=\"confirm\">PIN IT HERE</button></div>";
|
|
1025
|
+
function createAim(doc, handlers) {
|
|
1026
|
+
const win = doc.defaultView;
|
|
1027
|
+
const root = doc.createElement("div");
|
|
1028
|
+
root.className = "pb-aim";
|
|
1029
|
+
root.innerHTML = MARKUP;
|
|
1030
|
+
const h = root.querySelector(".h");
|
|
1031
|
+
const v = root.querySelector(".v");
|
|
1032
|
+
const grip = root.querySelector(".grip");
|
|
1033
|
+
const label = root.querySelector(".lab");
|
|
1034
|
+
const point = {
|
|
1035
|
+
x: 0,
|
|
1036
|
+
y: 0
|
|
1037
|
+
};
|
|
1038
|
+
/** Grab offset, so the reticle does not jump to your fingertip when you take hold of it. */
|
|
1039
|
+
let grab = null;
|
|
1040
|
+
function put(x, y) {
|
|
1041
|
+
point.x = Math.max(0, Math.min(win.innerWidth, x));
|
|
1042
|
+
point.y = Math.max(0, Math.min(win.innerHeight, y));
|
|
1043
|
+
h.style.top = `${point.y}px`;
|
|
1044
|
+
v.style.left = `${point.x}px`;
|
|
1045
|
+
grip.style.left = `${point.x}px`;
|
|
1046
|
+
grip.style.top = `${point.y}px`;
|
|
1047
|
+
}
|
|
1048
|
+
const onPointerMove = (e) => {
|
|
1049
|
+
if (grab === null) return;
|
|
1050
|
+
e.preventDefault();
|
|
1051
|
+
put(e.clientX + grab.dx, e.clientY + grab.dy);
|
|
1052
|
+
handlers.onAim(point.x, point.y);
|
|
1053
|
+
};
|
|
1054
|
+
const onPointerUp = () => {
|
|
1055
|
+
grab = null;
|
|
1056
|
+
};
|
|
1057
|
+
grip.addEventListener("pointerdown", (e) => {
|
|
1058
|
+
e.preventDefault();
|
|
1059
|
+
e.stopPropagation();
|
|
1060
|
+
grab = {
|
|
1061
|
+
dx: point.x - e.clientX,
|
|
1062
|
+
dy: point.y - e.clientY
|
|
1063
|
+
};
|
|
1064
|
+
grip.setPointerCapture?.(e.pointerId);
|
|
1065
|
+
});
|
|
1066
|
+
grip.addEventListener("keydown", (e) => {
|
|
1067
|
+
const step = e.shiftKey ? 20 : 2;
|
|
1068
|
+
const delta = {
|
|
1069
|
+
ArrowLeft: [-step, 0],
|
|
1070
|
+
ArrowRight: [step, 0],
|
|
1071
|
+
ArrowUp: [0, -step],
|
|
1072
|
+
ArrowDown: [0, step]
|
|
1073
|
+
}[e.key];
|
|
1074
|
+
if (!delta) return;
|
|
1075
|
+
e.preventDefault();
|
|
1076
|
+
put(point.x + delta[0], point.y + delta[1]);
|
|
1077
|
+
handlers.onAim(point.x, point.y);
|
|
1078
|
+
});
|
|
1079
|
+
root.addEventListener("click", (e) => {
|
|
1080
|
+
const action = e.target.closest?.("[data-aim]")?.getAttribute("data-aim");
|
|
1081
|
+
if (!action) return;
|
|
1082
|
+
e.preventDefault();
|
|
1083
|
+
e.stopPropagation();
|
|
1084
|
+
if (action === "confirm") handlers.onConfirm();
|
|
1085
|
+
else handlers.onCancel();
|
|
1086
|
+
});
|
|
1087
|
+
win.addEventListener("pointermove", onPointerMove, { passive: false });
|
|
1088
|
+
win.addEventListener("pointerup", onPointerUp);
|
|
1089
|
+
win.addEventListener("pointercancel", onPointerUp);
|
|
1090
|
+
return {
|
|
1091
|
+
root,
|
|
1092
|
+
point,
|
|
1093
|
+
show(x, y) {
|
|
1094
|
+
put(x, y);
|
|
1095
|
+
root.classList.add("on");
|
|
1096
|
+
},
|
|
1097
|
+
hide() {
|
|
1098
|
+
grab = null;
|
|
1099
|
+
root.classList.remove("on");
|
|
1100
|
+
},
|
|
1101
|
+
setLabel(text) {
|
|
1102
|
+
label.textContent = text;
|
|
1103
|
+
},
|
|
1104
|
+
destroy() {
|
|
1105
|
+
win.removeEventListener("pointermove", onPointerMove);
|
|
1106
|
+
win.removeEventListener("pointerup", onPointerUp);
|
|
1107
|
+
win.removeEventListener("pointercancel", onPointerUp);
|
|
1108
|
+
}
|
|
1109
|
+
};
|
|
1110
|
+
}
|
|
1111
|
+
//#endregion
|
|
945
1112
|
//#region src/ui/bar.ts
|
|
946
1113
|
const PIN_ICON = "<svg width=\"14\" height=\"14\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.4\"><rect x=\"3\" y=\"1.5\" width=\"10\" height=\"6.5\" rx=\"1\"/><path d=\"M8 8v6.5\"/></svg>";
|
|
947
1114
|
const INBOX_ICON = "<svg width=\"14\" height=\"14\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.4\"><path d=\"M1.8 8.5h3.4l1 2h3.6l1-2h3.4\"/><path d=\"M2.6 3.2h10.8l1.2 5.3v4a1 1 0 01-1 1H2.4a1 1 0 01-1-1v-4z\"/></svg>";
|
|
@@ -1050,6 +1217,31 @@ var Pinbox = (function(exports) {
|
|
|
1050
1217
|
const via = origin ? `<span class="via-tag"><span>${esc(origin)}</span></span>` : "";
|
|
1051
1218
|
return `<div class="pb-msg you"><div class="pb-av${mirror ? " via" : ""}">${esc(initials)}</div><div class="col"><div class="line"><span class="who">${esc(who)}</span><span class="tm">${esc(timeOf(m.at))}</span>${via}</div><div class="txt">${esc(m.text)}</div>${attachmentsHtml(m)}</div></div>`;
|
|
1052
1219
|
}
|
|
1220
|
+
/** The agent has the message and has not answered yet. Its own node, so patching never rebuilds. */
|
|
1221
|
+
const TYPING_HTML = "<div class=\"pb-typing\"><div class=\"pb-av agent\">AI</div><div class=\"dots\"><i></i><i></i><i></i></div><div class=\"lbl\">THINKING</div></div>";
|
|
1222
|
+
/**
|
|
1223
|
+
* Show or hide the "working on it" row.
|
|
1224
|
+
*
|
|
1225
|
+
* Without it the card sits silent from the moment you comment until the answer lands, which reads
|
|
1226
|
+
* as nothing happening — the single most common report on the demo.
|
|
1227
|
+
*/
|
|
1228
|
+
function patchTyping(threadEl, pending) {
|
|
1229
|
+
const existing = threadEl.querySelector("[data-iid=\"pb-typing\"]");
|
|
1230
|
+
if (!pending) {
|
|
1231
|
+
existing?.remove();
|
|
1232
|
+
return;
|
|
1233
|
+
}
|
|
1234
|
+
if (existing) {
|
|
1235
|
+
threadEl.appendChild(existing);
|
|
1236
|
+
return;
|
|
1237
|
+
}
|
|
1238
|
+
const node = threadEl.ownerDocument.createElement("div");
|
|
1239
|
+
node.className = "pb-msg-w";
|
|
1240
|
+
node.setAttribute("data-iid", "pb-typing");
|
|
1241
|
+
node.innerHTML = TYPING_HTML;
|
|
1242
|
+
threadEl.appendChild(node);
|
|
1243
|
+
threadEl.scrollTop = threadEl.scrollHeight;
|
|
1244
|
+
}
|
|
1053
1245
|
/** Keyed thread patching: appends/patches [data-iid] nodes only, never rebuilds. */
|
|
1054
1246
|
function patchThread(threadEl, messages) {
|
|
1055
1247
|
let appended = false;
|
|
@@ -1215,6 +1407,22 @@ var Pinbox = (function(exports) {
|
|
|
1215
1407
|
};
|
|
1216
1408
|
}
|
|
1217
1409
|
/** Render the thread card for a state snapshot: the active pin, or the draft. */
|
|
1410
|
+
/**
|
|
1411
|
+
* The pin's own text, as the first message in its thread.
|
|
1412
|
+
*
|
|
1413
|
+
* A pin stores what you wrote on the pin itself, not in the thread — so a card that renders only
|
|
1414
|
+
* `thread` shows an empty box the moment you hit Comment, and your words look lost. They are not
|
|
1415
|
+
* lost; they were never drawn.
|
|
1416
|
+
*/
|
|
1417
|
+
function pinAsMessage(pin) {
|
|
1418
|
+
return {
|
|
1419
|
+
id: `pin:${pin.id}`,
|
|
1420
|
+
pinId: pin.id,
|
|
1421
|
+
role: "human",
|
|
1422
|
+
text: pin.text,
|
|
1423
|
+
at: pin.createdAt
|
|
1424
|
+
};
|
|
1425
|
+
}
|
|
1218
1426
|
function renderCard(root, state, actions) {
|
|
1219
1427
|
const card = ensureShell(root);
|
|
1220
1428
|
const ctx = ctxByCard.get(card);
|
|
@@ -1228,7 +1436,7 @@ var Pinbox = (function(exports) {
|
|
|
1228
1436
|
if (ctx.pid !== view.pid) {
|
|
1229
1437
|
ctx.pid = view.pid;
|
|
1230
1438
|
ctx.parts = {};
|
|
1231
|
-
buildSkeleton(card, ctx, view.pid === "draft", view.thread.length > 0);
|
|
1439
|
+
buildSkeleton(card, ctx, view.pid === "draft", view.pin !== null || view.thread.length > 0);
|
|
1232
1440
|
}
|
|
1233
1441
|
card.hidden = false;
|
|
1234
1442
|
const queued = view.pin !== null && state.queuedIds.has(view.pin.id);
|
|
@@ -1237,9 +1445,13 @@ var Pinbox = (function(exports) {
|
|
|
1237
1445
|
setPart(card, ctx, "hd", hdHtml(view.n, view.label, statusLabel, resolvable));
|
|
1238
1446
|
setPart(card, ctx, "link", linkHtml(view.pin));
|
|
1239
1447
|
setPart(card, ctx, "verify", verifyHtml(view.status));
|
|
1240
|
-
|
|
1448
|
+
const messages = view.pin === null ? view.thread : [pinAsMessage(view.pin), ...view.thread];
|
|
1449
|
+
setPart(card, ctx, "row", rowHtml(messages.length > 0));
|
|
1241
1450
|
const threadEl = card.querySelector("[data-ref=\"thread\"]");
|
|
1242
|
-
if (threadEl)
|
|
1451
|
+
if (threadEl) {
|
|
1452
|
+
patchThread(threadEl, messages);
|
|
1453
|
+
patchTyping(threadEl, !queued && view.pin?.status === "open" && view.status === "waiting");
|
|
1454
|
+
}
|
|
1243
1455
|
position(card, view.at);
|
|
1244
1456
|
}
|
|
1245
1457
|
//#endregion
|
|
@@ -1339,11 +1551,19 @@ var Pinbox = (function(exports) {
|
|
|
1339
1551
|
//#region src/ui/pins.ts
|
|
1340
1552
|
/** The prototype's `_h` innerHTML memo, kept off the DOM node. */
|
|
1341
1553
|
const chipMemo = /* @__PURE__ */ new WeakMap();
|
|
1342
|
-
/**
|
|
1343
|
-
|
|
1554
|
+
/**
|
|
1555
|
+
* Where the needle lands: the point inside the element that was actually clicked, when the pin
|
|
1556
|
+
* recorded one, else the centre of its box.
|
|
1557
|
+
*
|
|
1558
|
+
* `spot` is a fraction of the element, so the pin still tracks the element when it moves or
|
|
1559
|
+
* resizes — it just stops sliding to the middle of a wide block the moment you commit it.
|
|
1560
|
+
*/
|
|
1561
|
+
function pinPoint(r, spot) {
|
|
1562
|
+
const fx = spot?.x ?? .5;
|
|
1563
|
+
const fy = spot?.y ?? .5;
|
|
1344
1564
|
return {
|
|
1345
|
-
x: r.x + r.width
|
|
1346
|
-
y: r.y + r.height
|
|
1565
|
+
x: r.x + r.width * fx,
|
|
1566
|
+
y: r.y + r.height * fy
|
|
1347
1567
|
};
|
|
1348
1568
|
}
|
|
1349
1569
|
/** Chip contents (prototype chipBtnInner, lines 546–550): number + linked-channel tag,
|
|
@@ -1385,21 +1605,28 @@ var Pinbox = (function(exports) {
|
|
|
1385
1605
|
const placed = [];
|
|
1386
1606
|
visible.forEach((pin, i) => {
|
|
1387
1607
|
const rect = pin.target?.rect;
|
|
1388
|
-
if (rect
|
|
1608
|
+
if (rect === void 0) return;
|
|
1609
|
+
const spot = pin.target?.spot;
|
|
1610
|
+
placed.push(spot === void 0 ? {
|
|
1389
1611
|
pin,
|
|
1390
1612
|
n: i + 1,
|
|
1391
1613
|
rect
|
|
1614
|
+
} : {
|
|
1615
|
+
pin,
|
|
1616
|
+
n: i + 1,
|
|
1617
|
+
rect,
|
|
1618
|
+
spot
|
|
1392
1619
|
});
|
|
1393
1620
|
});
|
|
1394
1621
|
const keys = new Set(placed.map((entry) => entry.pin.id));
|
|
1395
1622
|
if (state.draft) keys.add("draft");
|
|
1396
1623
|
for (const node of [...layer.children]) if (!keys.has(node.getAttribute("data-pin") ?? "")) node.remove();
|
|
1397
|
-
for (const { pin, n, rect } of placed) {
|
|
1624
|
+
for (const { pin, n, rect, spot } of placed) {
|
|
1398
1625
|
const node = ensureNode(layer, pin.id, false);
|
|
1399
1626
|
const hot = pin.id === state.activePinId;
|
|
1400
1627
|
const queued = state.queuedIds.has(pin.id);
|
|
1401
1628
|
node.classList.toggle("queued", queued);
|
|
1402
|
-
patchNode(node, pinPoint(rect), hot, chipInner(n, pin, queued));
|
|
1629
|
+
patchNode(node, pinPoint(rect, spot), hot, chipInner(n, pin, queued));
|
|
1403
1630
|
}
|
|
1404
1631
|
if (state.draft) patchNode(ensureNode(layer, "draft", true), state.draft.placedAt, true, chipInner(visible.length + 1, null));
|
|
1405
1632
|
}
|
|
@@ -1540,6 +1767,26 @@ button { font: inherit; color: inherit; background: none; border: 0; cursor: poi
|
|
|
1540
1767
|
.pb-reticle .box { position: absolute; width: 15px; height: 15px; margin: -8px 0 0 -8px; border: 1px solid var(--pb-amber); border-radius: 2px; }
|
|
1541
1768
|
.pb-reticle .ro { position: absolute; margin: 14px 0 0 14px; padding: 3px 6px; background: var(--pb-amber); color: var(--pb-amber-ink); font-family: var(--pb-font-mono); font-size: 9.5px; letter-spacing: .12em; border-radius: 2px; white-space: nowrap; }
|
|
1542
1769
|
|
|
1770
|
+
/* Drag-to-aim, for touch. The layer never takes pointer events — only the grip and the bar do —
|
|
1771
|
+
so what is under the crosshair can still be probed, and the page underneath is still visible. */
|
|
1772
|
+
/* Above the command bar (90), below the shortcuts modal (120). The confirm bar sits at the very
|
|
1773
|
+
bottom of the screen, where the command bar already is — under it, CONFIRM was unclickable. */
|
|
1774
|
+
.pb-aim { position: fixed; inset: 0; z-index: 100; display: none; pointer-events: none; }
|
|
1775
|
+
.pb-aim.on { display: block; animation: pb-fade 160ms ease-out both; }
|
|
1776
|
+
.pb-aim .h { position: absolute; left: 0; right: 0; height: 1px; background: color-mix(in srgb, var(--pb-amber) 30%, transparent); }
|
|
1777
|
+
.pb-aim .v { position: absolute; top: 0; bottom: 0; width: 1px; background: color-mix(in srgb, var(--pb-amber) 30%, transparent); }
|
|
1778
|
+
/* 72px: a finger-sized target, per the design. Smaller and you cannot hold it accurately;
|
|
1779
|
+
touch-action:none is what stops the page scrolling instead of the reticle moving. */
|
|
1780
|
+
.pb-aim .grip { position: absolute; width: 72px; height: 72px; margin: -36px 0 0 -36px; border-radius: 999px; border: 1px solid var(--pb-amber); background: var(--pb-amber-soft); backdrop-filter: blur(2px); -webkit-backdrop-filter: blur(2px); display: flex; align-items: center; justify-content: center; pointer-events: auto; touch-action: none; cursor: grab; }
|
|
1781
|
+
.pb-aim .grip:active { cursor: grabbing; }
|
|
1782
|
+
.pb-aim .grip i { width: 10px; height: 10px; border-radius: 999px; background: var(--pb-amber); box-shadow: 0 0 0 3px var(--pb-canvas); }
|
|
1783
|
+
.pb-aim .bar { position: absolute; left: 12px; right: 12px; bottom: 12px; display: flex; align-items: center; gap: 8px; padding: 7px; background: var(--pb-bar); backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px); border: 1px solid var(--pb-line-2); border-radius: 4px; box-shadow: var(--pb-shadow); pointer-events: auto; }
|
|
1784
|
+
.pb-aim .bar .lab { flex: 1; min-width: 0; padding-left: 8px; font-family: var(--pb-font-mono); font-size: 10px; letter-spacing: .14em; color: var(--pb-fg3); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
1785
|
+
/* 48px tall: the minimum a thumb hits reliably. */
|
|
1786
|
+
.pb-aim .bar button { height: 48px; border-radius: 2px; font-family: var(--pb-font-mono); font-size: 11px; letter-spacing: .16em; cursor: pointer; }
|
|
1787
|
+
.pb-aim .bar .cancel { flex: none; padding: 0 18px; border: 1px solid var(--pb-line-2); background: transparent; color: var(--pb-fg2); }
|
|
1788
|
+
.pb-aim .bar .ok { flex: none; padding: 0 20px; border: none; background: var(--pb-amber); color: var(--pb-amber-ink); }
|
|
1789
|
+
|
|
1543
1790
|
.pb-pin { position: absolute; }
|
|
1544
1791
|
.pb-pin.resolving { animation: pb-resolve 380ms var(--pb-ease) forwards; }
|
|
1545
1792
|
.pb-pin .ring { position: absolute; left: 0; top: 0; width: 26px; height: 26px; border: 1px solid var(--pb-amber); border-radius: 999px; animation: pb-ring 900ms var(--pb-ease) forwards; pointer-events: none; }
|
|
@@ -1575,6 +1822,12 @@ button { font: inherit; color: inherit; background: none; border: 0; cursor: poi
|
|
|
1575
1822
|
.pb-msg-w { animation: pb-in 260ms var(--pb-ease) both; }
|
|
1576
1823
|
.pb-msg { padding: 13px 14px; display: flex; gap: 10px; }
|
|
1577
1824
|
.pb-msg.you { border-bottom: 1px solid var(--pb-line); }
|
|
1825
|
+
.pb-typing { padding: 13px 14px; display: flex; gap: 10px; align-items: center; }
|
|
1826
|
+
.pb-typing .dots { display: flex; gap: 4px; }
|
|
1827
|
+
.pb-typing .dots i { width: 4px; height: 4px; border-radius: 999px; background: var(--pb-amber); animation: pb-pulse 1.1s var(--pb-ease) infinite; }
|
|
1828
|
+
.pb-typing .dots i:nth-child(2) { animation-delay: .18s; }
|
|
1829
|
+
.pb-typing .dots i:nth-child(3) { animation-delay: .36s; }
|
|
1830
|
+
.pb-typing .lbl { font-family: var(--pb-font-mono); font-size: 9px; letter-spacing: .14em; color: var(--pb-fg3); }
|
|
1578
1831
|
.pb-msg .steps { display: flex; flex-direction: column; gap: 7px; }
|
|
1579
1832
|
.pb-av { flex: none; width: 22px; height: 22px; border-radius: 999px; display: flex; align-items: center; justify-content: center; font-family: var(--pb-font-mono); font-size: 9px; letter-spacing: .04em; background: var(--pb-invert-bg); color: var(--pb-invert-fg); border: 1px solid var(--pb-invert-bg); }
|
|
1580
1833
|
.pb-av.via { background: transparent; color: var(--pb-info); border-color: var(--pb-info); }
|
|
@@ -1672,17 +1925,31 @@ button { font: inherit; color: inherit; background: none; border: 0; cursor: poi
|
|
|
1672
1925
|
}
|
|
1673
1926
|
var PinboxToolbarElement = class extends BaseElement {
|
|
1674
1927
|
static tagName = "pinbox-toolbar";
|
|
1928
|
+
/** Watched so a config that arrives after insertion can still start the transport. */
|
|
1929
|
+
static observedAttributes = ["hub", "token"];
|
|
1675
1930
|
store = createStore();
|
|
1676
1931
|
/** Card → transport seam (wired by #startTransport once a config exists). */
|
|
1677
1932
|
actions = {};
|
|
1678
1933
|
#config = null;
|
|
1679
1934
|
#transport = null;
|
|
1935
|
+
/**
|
|
1936
|
+
* Connected-lifetime counter, bumped on disconnect. #startTransport can park at
|
|
1937
|
+
* an await (getToken, or `await undefined` on the token-less path); a
|
|
1938
|
+
* continuation that crossed a disconnect must not install a transport into a
|
|
1939
|
+
* later lifetime, where it would shadow or duplicate that lifetime's own start.
|
|
1940
|
+
*/
|
|
1941
|
+
#lifetime = 0;
|
|
1942
|
+
/** One deferred start per tick, however many attribute callbacks land in it. */
|
|
1943
|
+
#startQueued = false;
|
|
1680
1944
|
#token = "";
|
|
1681
1945
|
#built = false;
|
|
1682
1946
|
#bar = null;
|
|
1683
1947
|
#reticle = null;
|
|
1684
1948
|
#pinsLayer = null;
|
|
1685
1949
|
#drawer = null;
|
|
1950
|
+
#aim = null;
|
|
1951
|
+
/** Pending viewport-refresh frame, 0 when none is queued. */
|
|
1952
|
+
#viewportFrame = 0;
|
|
1686
1953
|
#modal = null;
|
|
1687
1954
|
#helpOpen = false;
|
|
1688
1955
|
#pageStyle = null;
|
|
@@ -1698,6 +1965,7 @@ button { font: inherit; color: inherit; background: none; border: 0; cursor: poi
|
|
|
1698
1965
|
/** Programmatic path (Pinbox.init). The snippet path reads hub/token attributes. */
|
|
1699
1966
|
configure(config) {
|
|
1700
1967
|
this.#config = config;
|
|
1968
|
+
if (this.isConnected) this.#queueStart();
|
|
1701
1969
|
}
|
|
1702
1970
|
get config() {
|
|
1703
1971
|
if (this.#config) return this.#config;
|
|
@@ -1722,25 +1990,77 @@ button { font: inherit; color: inherit; background: none; border: 0; cursor: poi
|
|
|
1722
1990
|
style.textContent = PAGE_CSS;
|
|
1723
1991
|
document.head.appendChild(style);
|
|
1724
1992
|
this.#pageStyle = style;
|
|
1993
|
+
if (this.#aim === null) this.#mountAim();
|
|
1725
1994
|
document.addEventListener("mousemove", this.#onMouseMove);
|
|
1995
|
+
window.addEventListener("scroll", this.#onViewportChange, { passive: true });
|
|
1996
|
+
window.addEventListener("resize", this.#onViewportChange);
|
|
1726
1997
|
document.addEventListener("click", this.#onClickCapture, true);
|
|
1727
1998
|
document.addEventListener("keydown", this.#onKeyDown);
|
|
1728
1999
|
this.#unsubscribe = this.store.subscribe((s) => this.#render(s));
|
|
1729
2000
|
this.#render(this.store.get());
|
|
1730
|
-
this.#
|
|
2001
|
+
this.#queueStart();
|
|
2002
|
+
}
|
|
2003
|
+
/**
|
|
2004
|
+
* Late-config rescue: an element inserted BEFORE its hub/token attributes were
|
|
2005
|
+
* set connected configless and #startTransport bailed. Starting here the moment
|
|
2006
|
+
* a config first exists keeps such an element from staying silently dead.
|
|
2007
|
+
* Config is still read once — a running transport is never reconfigured.
|
|
2008
|
+
*/
|
|
2009
|
+
attributeChangedCallback() {
|
|
2010
|
+
if (this.isConnected && this.config !== null) this.#queueStart();
|
|
2011
|
+
}
|
|
2012
|
+
/**
|
|
2013
|
+
* Start at the END of the current tick, not synchronously: a config assembled
|
|
2014
|
+
* attribute-by-attribute on a connected element (append → set hub → set token)
|
|
2015
|
+
* must be read whole. A synchronous start at the first fragment would connect
|
|
2016
|
+
* token-less and, per the read-once rule, drop the token forever.
|
|
2017
|
+
*/
|
|
2018
|
+
#queueStart() {
|
|
2019
|
+
if (this.#startQueued) return;
|
|
2020
|
+
this.#startQueued = true;
|
|
2021
|
+
queueMicrotask(() => {
|
|
2022
|
+
this.#startQueued = false;
|
|
2023
|
+
this.#startTransport();
|
|
2024
|
+
});
|
|
1731
2025
|
}
|
|
1732
2026
|
disconnectedCallback() {
|
|
2027
|
+
this.#lifetime += 1;
|
|
1733
2028
|
this.#transport?.close();
|
|
1734
2029
|
this.#transport = null;
|
|
1735
2030
|
document.removeEventListener("mousemove", this.#onMouseMove);
|
|
2031
|
+
window.removeEventListener("scroll", this.#onViewportChange);
|
|
2032
|
+
window.removeEventListener("resize", this.#onViewportChange);
|
|
1736
2033
|
document.removeEventListener("click", this.#onClickCapture, true);
|
|
1737
2034
|
document.removeEventListener("keydown", this.#onKeyDown);
|
|
2035
|
+
if (this.#viewportFrame !== 0) cancelAnimationFrame(this.#viewportFrame);
|
|
2036
|
+
this.#viewportFrame = 0;
|
|
2037
|
+
this.#aim?.destroy();
|
|
2038
|
+
this.#aim = null;
|
|
1738
2039
|
this.#unsubscribe?.();
|
|
1739
2040
|
this.#unsubscribe = null;
|
|
1740
2041
|
this.#pageStyle?.remove();
|
|
1741
2042
|
this.#pageStyle = null;
|
|
1742
2043
|
document.body.classList.remove(PAGE_PLACING_CLASS);
|
|
1743
2044
|
}
|
|
2045
|
+
/**
|
|
2046
|
+
* Create the aim controller and put its layer in the shadow root.
|
|
2047
|
+
*
|
|
2048
|
+
* Separate from `#build` because the two have different lifetimes: `#build` runs once, but
|
|
2049
|
+
* `disconnectedCallback` tears this controller's window listeners down. A re-parented element
|
|
2050
|
+
* would otherwise come back with no controller and its markup still in place — a grip that
|
|
2051
|
+
* renders and does nothing, with no error to explain it.
|
|
2052
|
+
*/
|
|
2053
|
+
#mountAim() {
|
|
2054
|
+
const shadow = this.shadowRoot;
|
|
2055
|
+
if (!shadow) return;
|
|
2056
|
+
shadow.querySelector(".pb-aim")?.remove();
|
|
2057
|
+
this.#aim = createAim(document, {
|
|
2058
|
+
onAim: (x, y) => this.#probe(x, y),
|
|
2059
|
+
onConfirm: () => this.#confirmAim(),
|
|
2060
|
+
onCancel: () => this.#dismiss()
|
|
2061
|
+
});
|
|
2062
|
+
shadow.appendChild(this.#aim.root);
|
|
2063
|
+
}
|
|
1744
2064
|
#build() {
|
|
1745
2065
|
const shadow = this.attachShadow({ mode: "open" });
|
|
1746
2066
|
try {
|
|
@@ -1777,6 +2097,7 @@ button { font: inherit; color: inherit; background: none; border: 0; cursor: poi
|
|
|
1777
2097
|
onClose: () => this.store.update({ inboxOpen: false })
|
|
1778
2098
|
});
|
|
1779
2099
|
shadow.appendChild(this.#drawer.root);
|
|
2100
|
+
this.#mountAim();
|
|
1780
2101
|
this.#modal = createShortcutsModal(document, () => this.#setHelp(false));
|
|
1781
2102
|
shadow.appendChild(this.#modal.root);
|
|
1782
2103
|
this.#pinsLayer.addEventListener("click", (e) => this.#onChipClick(e));
|
|
@@ -1787,10 +2108,13 @@ button { font: inherit; color: inherit; background: none; border: 0; cursor: poi
|
|
|
1787
2108
|
* still renders read-only threads and queued drafts.
|
|
1788
2109
|
*/
|
|
1789
2110
|
async #startTransport() {
|
|
1790
|
-
if (this.#transport !== null) return;
|
|
2111
|
+
if (this.#transport !== null || !this.isConnected) return;
|
|
1791
2112
|
const cfg = this.config;
|
|
1792
2113
|
if (cfg === null) return;
|
|
1793
|
-
|
|
2114
|
+
const lifetime = this.#lifetime;
|
|
2115
|
+
const token = cfg.token ?? await cfg.getToken?.().catch(() => void 0) ?? "";
|
|
2116
|
+
if (this.#lifetime !== lifetime || this.#transport !== null || !this.isConnected) return;
|
|
2117
|
+
this.#token = token;
|
|
1794
2118
|
const transport = new HubTransport({
|
|
1795
2119
|
endpoint: cfg.endpoint,
|
|
1796
2120
|
token: this.#token,
|
|
@@ -1806,7 +2130,10 @@ button { font: inherit; color: inherit; background: none; border: 0; cursor: poi
|
|
|
1806
2130
|
if (queued.length > 0) this.store.update({ queuedIds: new Set(queued.map((p) => p.id)) });
|
|
1807
2131
|
this.actions.send = (pinId, text) => void this.#send(transport, pinId, text);
|
|
1808
2132
|
this.actions.resolve = (pinId) => void transport.resolve(pinId).then((pin) => upsertPin(this.store, pin)).catch(() => {});
|
|
1809
|
-
this.actions.verify = (pinId, outcome) => void transport.verify(pinId, outcome).then((pin) =>
|
|
2133
|
+
this.actions.verify = (pinId, outcome) => void transport.verify(pinId, outcome).then((pin) => {
|
|
2134
|
+
upsertPin(this.store, pin);
|
|
2135
|
+
if (outcome === "accepted") this.#dismiss();
|
|
2136
|
+
}).catch(() => {});
|
|
1810
2137
|
transport.connect();
|
|
1811
2138
|
}
|
|
1812
2139
|
/** draft ⇒ compose PinInput (+ best-effort screenshot) and createPin; else thread reply. */
|
|
@@ -1832,6 +2159,7 @@ button { font: inherit; color: inherit; background: none; border: 0; cursor: poi
|
|
|
1832
2159
|
async #screenshot(selector) {
|
|
1833
2160
|
const cfg = this.config;
|
|
1834
2161
|
if (cfg === null) return null;
|
|
2162
|
+
if (cfg.screenshots === false) return null;
|
|
1835
2163
|
try {
|
|
1836
2164
|
const el = document.querySelector(selector);
|
|
1837
2165
|
if (el === null) return null;
|
|
@@ -1915,28 +2243,71 @@ button { font: inherit; color: inherit; background: none; border: 0; cursor: poi
|
|
|
1915
2243
|
});
|
|
1916
2244
|
if (this.store.get().draft) this.store.discardDraft();
|
|
1917
2245
|
}
|
|
2246
|
+
/**
|
|
2247
|
+
* Work out what sits under a viewport point and highlight it.
|
|
2248
|
+
*
|
|
2249
|
+
* Shared by both ways of aiming — following a mouse, and dragging the reticle — so the two can
|
|
2250
|
+
* never disagree about what is under the crosshair.
|
|
2251
|
+
*/
|
|
2252
|
+
#probe(clientX, clientY) {
|
|
2253
|
+
const el = hitTest(document, clientX, clientY, (hit) => hit === this);
|
|
2254
|
+
this.#hover = el;
|
|
2255
|
+
if (el) this.#reticle?.snap(el.getBoundingClientRect(), targetLabel(el), {
|
|
2256
|
+
x: window.scrollX,
|
|
2257
|
+
y: window.scrollY
|
|
2258
|
+
});
|
|
2259
|
+
else this.#reticle?.release();
|
|
2260
|
+
this.#aim?.setLabel(el ? targetLabel(el) : "NOTHING UNDER THE PIN");
|
|
2261
|
+
}
|
|
2262
|
+
/**
|
|
2263
|
+
* Keep the drag-aim reticle honest while the viewport moves under it.
|
|
2264
|
+
*
|
|
2265
|
+
* Scrolling changes what is beneath a fixed reticle, and resizing (a phone rotating, a window
|
|
2266
|
+
* dragged narrow) can both strand it off-screen and flip which way of aiming applies.
|
|
2267
|
+
*/
|
|
2268
|
+
#onViewportChange = () => {
|
|
2269
|
+
if (this.store.get().mode !== "placing" || this.#viewportFrame !== 0) return;
|
|
2270
|
+
this.#viewportFrame = requestAnimationFrame(() => {
|
|
2271
|
+
this.#viewportFrame = 0;
|
|
2272
|
+
if (this.store.get().mode !== "placing") return;
|
|
2273
|
+
this.#syncAim(true);
|
|
2274
|
+
const aim = this.#aim;
|
|
2275
|
+
if (aim?.root.classList.contains("on") === true) this.#probe(aim.point.x, aim.point.y);
|
|
2276
|
+
});
|
|
2277
|
+
};
|
|
1918
2278
|
#onMouseMove = (e) => {
|
|
1919
2279
|
if (this.store.get().mode !== "placing" || !this.#reticle) return;
|
|
1920
2280
|
this.#reticle.move(e);
|
|
1921
|
-
|
|
1922
|
-
if (el) {
|
|
1923
|
-
this.#hover = el;
|
|
1924
|
-
this.#reticle.snap(el.getBoundingClientRect(), targetLabel(el), {
|
|
1925
|
-
x: window.scrollX,
|
|
1926
|
-
y: window.scrollY
|
|
1927
|
-
});
|
|
1928
|
-
} else {
|
|
1929
|
-
this.#hover = null;
|
|
1930
|
-
this.#reticle.release();
|
|
1931
|
-
}
|
|
2281
|
+
this.#probe(e.clientX, e.clientY);
|
|
1932
2282
|
};
|
|
2283
|
+
/** Commit the pin the drag-aim reticle is sitting on. */
|
|
2284
|
+
#confirmAim() {
|
|
2285
|
+
const aim = this.#aim;
|
|
2286
|
+
if (!aim) return;
|
|
2287
|
+
this.#probe(aim.point.x, aim.point.y);
|
|
2288
|
+
const el = this.#hover ?? document.body;
|
|
2289
|
+
this.store.place({
|
|
2290
|
+
target: captureTarget(el, { at: {
|
|
2291
|
+
x: aim.point.x + window.scrollX,
|
|
2292
|
+
y: aim.point.y + window.scrollY
|
|
2293
|
+
} }),
|
|
2294
|
+
placedAt: {
|
|
2295
|
+
x: aim.point.x + window.scrollX,
|
|
2296
|
+
y: aim.point.y + window.scrollY
|
|
2297
|
+
}
|
|
2298
|
+
});
|
|
2299
|
+
this.#reticle?.release();
|
|
2300
|
+
}
|
|
1933
2301
|
/** Placement click: capture the hovered target (or body) into a client-only draft. */
|
|
1934
2302
|
#placeDraft(e) {
|
|
1935
2303
|
e.preventDefault();
|
|
1936
2304
|
e.stopPropagation();
|
|
1937
2305
|
const el = this.#hover ?? document.body;
|
|
1938
2306
|
this.store.place({
|
|
1939
|
-
target: captureTarget(el
|
|
2307
|
+
target: captureTarget(el, { at: {
|
|
2308
|
+
x: e.pageX,
|
|
2309
|
+
y: e.pageY
|
|
2310
|
+
} }),
|
|
1940
2311
|
placedAt: {
|
|
1941
2312
|
x: e.pageX,
|
|
1942
2313
|
y: e.pageY
|
|
@@ -1947,8 +2318,12 @@ button { font: inherit; color: inherit; background: none; border: 0; cursor: poi
|
|
|
1947
2318
|
#onClickCapture = (e) => {
|
|
1948
2319
|
if (e.composedPath().includes(this)) return;
|
|
1949
2320
|
const state = this.store.get();
|
|
1950
|
-
if (state.mode === "placing")
|
|
1951
|
-
|
|
2321
|
+
if (state.mode === "placing") {
|
|
2322
|
+
if (!needsDragAim(window)) this.#placeDraft(e);
|
|
2323
|
+
return;
|
|
2324
|
+
}
|
|
2325
|
+
if (state.inboxOpen) this.store.update({ inboxOpen: false });
|
|
2326
|
+
if (state.activePinId || state.draft) this.#dismiss();
|
|
1952
2327
|
};
|
|
1953
2328
|
/** Prototype keyboard map (v2-command-bar.html lines 701–712). */
|
|
1954
2329
|
#shortcuts = {
|
|
@@ -1964,11 +2339,33 @@ button { font: inherit; color: inherit; background: none; border: 0; cursor: poi
|
|
|
1964
2339
|
if (isTextEntry(e.composedPath()[0])) return;
|
|
1965
2340
|
this.#shortcuts[e.key === "?" ? "?" : e.key.toLowerCase()]?.();
|
|
1966
2341
|
};
|
|
2342
|
+
/**
|
|
2343
|
+
* Bring the drag-aim reticle up with placing mode, seeded mid-screen and already showing what it
|
|
2344
|
+
* is over — so the first thing you see is a live target, not an empty crosshair waiting for a
|
|
2345
|
+
* mouse that is never coming.
|
|
2346
|
+
*/
|
|
2347
|
+
#syncAim(placing) {
|
|
2348
|
+
const aim = this.#aim;
|
|
2349
|
+
if (!aim) return;
|
|
2350
|
+
if (!placing || !needsDragAim(window)) {
|
|
2351
|
+
aim.hide();
|
|
2352
|
+
return;
|
|
2353
|
+
}
|
|
2354
|
+
if (aim.root.classList.contains("on")) {
|
|
2355
|
+
if (aim.point.x <= window.innerWidth && aim.point.y <= window.innerHeight) return;
|
|
2356
|
+
aim.show(Math.min(aim.point.x, window.innerWidth), Math.min(aim.point.y, window.innerHeight));
|
|
2357
|
+
return;
|
|
2358
|
+
}
|
|
2359
|
+
const { x, y } = startPoint(window);
|
|
2360
|
+
aim.show(x, y);
|
|
2361
|
+
this.#probe(x, y);
|
|
2362
|
+
}
|
|
1967
2363
|
#render(state) {
|
|
1968
2364
|
const placing = state.mode === "placing";
|
|
1969
2365
|
this.toggleAttribute("data-placing", placing);
|
|
1970
2366
|
document.body.classList.toggle(PAGE_PLACING_CLASS, placing);
|
|
1971
2367
|
if (!placing) this.#reticle?.release();
|
|
2368
|
+
this.#syncAim(placing);
|
|
1972
2369
|
if (this.#pinsLayer) renderPins(this.#pinsLayer, state);
|
|
1973
2370
|
if (this.shadowRoot) renderCard(this.shadowRoot, state, this.#cardActions);
|
|
1974
2371
|
this.#drawer?.update(state);
|