@autono/pinbox-toolbar 0.15.0 → 0.16.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-BVd-ZD4Y.d.ts → index-DENhglDF.d.ts} +48 -10
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/react.d.ts +1 -1
- package/dist/react.js +3 -7
- package/dist/{src-GX3kPe0X.js → src-z-YC_RT4.js} +1991 -1081
- package/dist/svelte.d.ts +1 -1
- package/dist/svelte.js +3 -3
- package/dist/toolbar.iife.js +1991 -1081
- package/dist/vue.d.ts +1 -1
- package/dist/vue.js +2 -2
- package/package.json +2 -2
package/dist/toolbar.iife.js
CHANGED
|
@@ -24,254 +24,170 @@ var Pinbox = (function(exports) {
|
|
|
24
24
|
} };
|
|
25
25
|
}
|
|
26
26
|
//#endregion
|
|
27
|
-
//#region src/
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
* nothing there but page chrome (html/body).
|
|
31
|
-
*
|
|
32
|
-
* Looks THROUGH our own overlay rather than giving up at it. The single-element form could not:
|
|
33
|
-
* the drag-aim grip sits exactly on the point being aimed at, so it is always the topmost thing
|
|
34
|
-
* under the crosshair, and every probe came back "nothing" the moment touch aiming existed.
|
|
35
|
-
*/
|
|
36
|
-
function hitTest(doc, x, y, ignore) {
|
|
37
|
-
const stack = doc.elementsFromPoint?.(x, y) ?? [doc.elementFromPoint(x, y)];
|
|
38
|
-
for (const el of stack) {
|
|
39
|
-
if (!el || el === doc.body || el === doc.documentElement) return null;
|
|
40
|
-
if (!ignore(el)) return el;
|
|
41
|
-
}
|
|
42
|
-
return null;
|
|
27
|
+
//#region src/capture-mode.ts
|
|
28
|
+
function captureKey(prefix) {
|
|
29
|
+
return `${prefix}:capture`;
|
|
43
30
|
}
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
const sibs = [...parent.children].filter((c) => c.classList[0] === key && c.tagName === el.tagName);
|
|
51
|
-
if (sibs.length > 1) name += ` ${sibs.indexOf(el) + 1}`;
|
|
52
|
-
}
|
|
53
|
-
return name;
|
|
54
|
-
}
|
|
55
|
-
/**
|
|
56
|
-
* Human label for a target: an explicit data-pb-el annotation wins; otherwise a
|
|
57
|
-
* CLASS/TAG ancestry chain of at most 3 parts joined with ›, terminating early
|
|
58
|
-
* at the first annotated ancestor.
|
|
59
|
-
*/
|
|
60
|
-
function targetLabel(el) {
|
|
61
|
-
const own = el.getAttribute("data-pb-el");
|
|
62
|
-
if (own) return own;
|
|
63
|
-
const parts = [nodeName(el)];
|
|
64
|
-
const body = el.ownerDocument.body;
|
|
65
|
-
let node = el.parentElement;
|
|
66
|
-
while (node && node !== body && parts.length < 3) {
|
|
67
|
-
const anchor = node.getAttribute("data-pb-el");
|
|
68
|
-
if (anchor) {
|
|
69
|
-
parts.unshift(anchor);
|
|
70
|
-
break;
|
|
71
|
-
}
|
|
72
|
-
if (node.classList[0]) parts.unshift(nodeName(node));
|
|
73
|
-
node = node.parentElement;
|
|
74
|
-
}
|
|
75
|
-
return parts.join(" › ");
|
|
31
|
+
function loadCaptureMode(storage, key, fallback) {
|
|
32
|
+
try {
|
|
33
|
+
const raw = storage?.getItem(key);
|
|
34
|
+
if (raw === "dom" || raw === "tab") return raw;
|
|
35
|
+
} catch {}
|
|
36
|
+
return fallback;
|
|
76
37
|
}
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
"data-pb-el",
|
|
82
|
-
"data-testid"
|
|
83
|
-
];
|
|
84
|
-
function attrSegment(el, doc) {
|
|
85
|
-
for (const attr of STABLE_DATA_ATTRS) {
|
|
86
|
-
const value = el.getAttribute(attr);
|
|
87
|
-
if (value === null || value.includes("\"") || value.includes("\\")) continue;
|
|
88
|
-
const selector = `${el.tagName.toLowerCase()}[${attr}="${value}"]`;
|
|
89
|
-
if (doc.querySelectorAll(selector).length === 1) return selector;
|
|
90
|
-
}
|
|
91
|
-
return null;
|
|
38
|
+
function saveCaptureMode(storage, key, mode) {
|
|
39
|
+
try {
|
|
40
|
+
storage?.setItem(key, mode);
|
|
41
|
+
} catch {}
|
|
92
42
|
}
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
43
|
+
//#endregion
|
|
44
|
+
//#region src/ui/actions.ts
|
|
45
|
+
const PIN_GLYPH = "<rect x=\"3\" y=\"1.5\" width=\"10\" height=\"6.5\" rx=\"1\"/><path d=\"M8 8v6.5\"/>";
|
|
46
|
+
const INBOX_GLYPH = "<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\"/>";
|
|
47
|
+
const THEME_GLYPH = "<path d=\"M8 2a4 4 0 0 0 6 6 6 6 0 1 1-6-6z\"/>";
|
|
48
|
+
const COPY_GLYPH = "<rect x=\"5.5\" y=\"5.5\" width=\"8\" height=\"8\" rx=\"1\"/><path d=\"M10.5 3.5v-1a1 1 0 00-1-1h-6a1 1 0 00-1 1v6a1 1 0 001 1h1\"/>";
|
|
49
|
+
const EYE_GLYPH = "<path d=\"M1.6 8S4 3.8 8 3.8 14.4 8 14.4 8 12 12.2 8 12.2 1.6 8 1.6 8z\"/><circle cx=\"8\" cy=\"8\" r=\"1.8\"/>";
|
|
50
|
+
const EYE_OFF_GLYPH = "<path d=\"M2.3 2.3l11.4 11.4\"/><path d=\"M4.9 4.9C2.7 6.2 1.6 8 1.6 8s2.4 4.2 6.4 4.2c1.2 0 2.3-.3 3.1-.8M6.7 4c.4-.1.9-.2 1.3-.2 4 0 6.4 4.2 6.4 4.2s-.8 1.4-2.2 2.5\"/>";
|
|
51
|
+
const MIN_GLYPH = "<path d=\"M6.5 2.5v4h-4\"/><path d=\"M9.5 13.5v-4h4\"/>";
|
|
52
|
+
const CAMERA_GLYPH = "<path d=\"M2 5.5h2.6l1.2-1.8h4.4L11.4 5.5H14v7.5H2z\"/><circle cx=\"8\" cy=\"9\" r=\"2.3\"/>";
|
|
53
|
+
const EXPAND_GLYPH = "<path d=\"M9.5 6.5v-4h4\"/><path d=\"M6.5 9.5v4h-4\"/>";
|
|
54
|
+
/** Frame a glyph as an inline SVG at `size` px. */
|
|
55
|
+
function icon(glyph, size) {
|
|
56
|
+
return `<svg width="${size}" height="${size}" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4">${glyph}</svg>`;
|
|
99
57
|
}
|
|
100
|
-
/**
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
58
|
+
/** Ordered as the bar and fan lay them out. */
|
|
59
|
+
const ACTIONS = [
|
|
60
|
+
{
|
|
61
|
+
id: "pin",
|
|
62
|
+
label: "Drop a pin",
|
|
63
|
+
key: "p",
|
|
64
|
+
glyph: PIN_GLYPH,
|
|
65
|
+
bar: { text: "PIN" },
|
|
66
|
+
fan: {}
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
id: "inbox",
|
|
70
|
+
label: "Open inbox",
|
|
71
|
+
key: "i",
|
|
72
|
+
glyph: INBOX_GLYPH,
|
|
73
|
+
bar: { count: true },
|
|
74
|
+
fan: {}
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
id: "copy",
|
|
78
|
+
label: "Copy open pins",
|
|
79
|
+
key: "c",
|
|
80
|
+
glyph: COPY_GLYPH,
|
|
81
|
+
bar: {},
|
|
82
|
+
fan: {}
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
id: "theme",
|
|
86
|
+
label: "Toggle theme",
|
|
87
|
+
key: "d",
|
|
88
|
+
glyph: THEME_GLYPH,
|
|
89
|
+
bar: {},
|
|
90
|
+
fan: {}
|
|
91
|
+
},
|
|
92
|
+
{
|
|
93
|
+
id: "hide",
|
|
94
|
+
label: "Hide / show pins",
|
|
95
|
+
key: "h",
|
|
96
|
+
glyph: EYE_OFF_GLYPH,
|
|
97
|
+
bar: {},
|
|
98
|
+
fan: { label: "Hide pins" }
|
|
99
|
+
},
|
|
100
|
+
{
|
|
101
|
+
id: "capture",
|
|
102
|
+
label: "Tab capture for screenshots (Chrome asks once)",
|
|
103
|
+
key: "s",
|
|
104
|
+
glyph: CAMERA_GLYPH,
|
|
105
|
+
bar: {}
|
|
106
|
+
},
|
|
107
|
+
{
|
|
108
|
+
id: "help",
|
|
109
|
+
label: "Shortcuts",
|
|
110
|
+
key: "?",
|
|
111
|
+
bar: {},
|
|
112
|
+
help: false
|
|
113
|
+
},
|
|
114
|
+
{
|
|
115
|
+
id: "minimize",
|
|
116
|
+
label: "Minimize toolbar",
|
|
117
|
+
key: "m",
|
|
118
|
+
glyph: MIN_GLYPH,
|
|
119
|
+
bar: { ariaLabel: "Minimize toolbar" },
|
|
120
|
+
fan: {
|
|
121
|
+
act: "expand",
|
|
122
|
+
label: "Expand"
|
|
118
123
|
}
|
|
119
|
-
|
|
120
|
-
|
|
124
|
+
},
|
|
125
|
+
{
|
|
126
|
+
id: "resolve",
|
|
127
|
+
label: "Mark pin resolved",
|
|
128
|
+
key: "r"
|
|
129
|
+
},
|
|
130
|
+
{
|
|
131
|
+
id: "escape",
|
|
132
|
+
label: "Cancel",
|
|
133
|
+
key: "escape",
|
|
134
|
+
keyLabel: "ESC"
|
|
121
135
|
}
|
|
122
|
-
return segments.join(" > ");
|
|
123
|
-
}
|
|
124
|
-
//#endregion
|
|
125
|
-
//#region src/capture.ts
|
|
126
|
-
/** Curated computed-style subset — enough to reconstruct layout intent, tiny on the wire. */
|
|
127
|
-
const STYLE_KEYS = [
|
|
128
|
-
"display",
|
|
129
|
-
"position",
|
|
130
|
-
"font-size",
|
|
131
|
-
"color",
|
|
132
|
-
"background-color",
|
|
133
|
-
"margin",
|
|
134
|
-
"padding",
|
|
135
|
-
"overflow"
|
|
136
136
|
];
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
let cs;
|
|
140
|
-
try {
|
|
141
|
-
cs = win.getComputedStyle(el);
|
|
142
|
-
} catch {
|
|
143
|
-
return;
|
|
144
|
-
}
|
|
145
|
-
const out = {};
|
|
146
|
-
for (const key of STYLE_KEYS) {
|
|
147
|
-
const value = cs.getPropertyValue(key);
|
|
148
|
-
if (value !== "") out[key] = value;
|
|
149
|
-
}
|
|
150
|
-
return Object.keys(out).length > 0 ? out : void 0;
|
|
151
|
-
}
|
|
152
|
-
function ariaMap(el) {
|
|
153
|
-
const out = {};
|
|
154
|
-
for (const name of el.getAttributeNames()) if (name.startsWith("aria-")) out[name] = el.getAttribute(name) ?? "";
|
|
155
|
-
return Object.keys(out).length > 0 ? out : void 0;
|
|
156
|
-
}
|
|
157
|
-
function nearbyText(el) {
|
|
158
|
-
const text = (el.textContent ?? "").replace(/\s+/g, " ").trim();
|
|
159
|
-
return text === "" ? void 0 : text.slice(0, NEARBY_TEXT_MAX);
|
|
160
|
-
}
|
|
161
|
-
/** The user's selection, only when it intersects the captured element. */
|
|
162
|
-
function selectedText(win, el) {
|
|
163
|
-
try {
|
|
164
|
-
const sel = win.getSelection?.();
|
|
165
|
-
if (!sel || sel.isCollapsed || sel.rangeCount === 0) return void 0;
|
|
166
|
-
if (!sel.getRangeAt(0).intersectsNode(el)) return void 0;
|
|
167
|
-
const text = sel.toString().trim();
|
|
168
|
-
return text === "" ? void 0 : text;
|
|
169
|
-
} catch {
|
|
170
|
-
return;
|
|
171
|
-
}
|
|
137
|
+
function keyLabelOf(a) {
|
|
138
|
+
return a.keyLabel ?? (a.key ?? "").toUpperCase();
|
|
172
139
|
}
|
|
173
|
-
/**
|
|
174
|
-
function
|
|
175
|
-
|
|
176
|
-
if (win.getComputedStyle(node).position === "fixed") return true;
|
|
177
|
-
} catch {
|
|
178
|
-
return false;
|
|
179
|
-
}
|
|
180
|
-
return false;
|
|
140
|
+
/** "Drop a pin (P)" — the bar's hover title. */
|
|
141
|
+
function titleOf(a) {
|
|
142
|
+
return a.key === void 0 ? a.label : `${a.label} (${keyLabelOf(a)})`;
|
|
181
143
|
}
|
|
182
|
-
/**
|
|
183
|
-
const
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
"
|
|
190
|
-
"
|
|
144
|
+
/** Lower-cased `KeyboardEvent.key` → action. `?` keeps its case: it only exists shifted. */
|
|
145
|
+
const ACTION_BY_KEY = new Map(ACTIONS.filter((a) => a.key !== void 0).map((a) => [a.key, a.id]));
|
|
146
|
+
//#endregion
|
|
147
|
+
//#region src/keys.ts
|
|
148
|
+
/** Hosts mark a subtree the toolbar must ignore keys from (custom editors, games). */
|
|
149
|
+
const IGNORE_KEYS_ATTR = "data-pinbox-ignore-keys";
|
|
150
|
+
const TEXT_TAGS = /* @__PURE__ */ new Set([
|
|
151
|
+
"TEXTAREA",
|
|
152
|
+
"INPUT",
|
|
153
|
+
"SELECT"
|
|
191
154
|
]);
|
|
155
|
+
const TEXT_ROLE_SELECTOR = [
|
|
156
|
+
"textbox",
|
|
157
|
+
"combobox",
|
|
158
|
+
"searchbox",
|
|
159
|
+
"spinbutton"
|
|
160
|
+
].map((r) => `[role="${r}"]`).join(",");
|
|
192
161
|
/**
|
|
193
|
-
*
|
|
194
|
-
*
|
|
195
|
-
*
|
|
196
|
-
* assumption about how a site is built. A heading is one run. A nav bar is one per link. A
|
|
197
|
-
* paragraph with a bold word in the middle is three, in reading order, including the halves either
|
|
198
|
-
* side of the bold. An earlier version keyed off "elements with no element children", which
|
|
199
|
-
* quietly lost the "Hello " in `<p>Hello <b>world</b></p>` — text a person can obviously see and
|
|
200
|
-
* would obviously expect to be able to change.
|
|
201
|
-
*
|
|
202
|
-
* `nearbyText` runs them all together, which is fine to read and useless to edit: it cannot tell
|
|
203
|
-
* an agent that "work approach people contact" is four separate places. This can.
|
|
162
|
+
* Is the key going into a text control? `INPUT`/`TEXTAREA`/contentEditable, plus
|
|
163
|
+
* the things the old check missed: `<select>`, ARIA text roles on custom editors,
|
|
164
|
+
* and any ancestor flagged with `data-pinbox-ignore-keys`.
|
|
204
165
|
*/
|
|
205
|
-
function
|
|
206
|
-
const
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
}
|
|
213
|
-
if (node.nodeType !== 1 || NON_CONTENT.has(node.tagName)) return true;
|
|
214
|
-
for (const child of node.childNodes) if (!walk(child)) return false;
|
|
215
|
-
return true;
|
|
216
|
-
};
|
|
217
|
-
if (!walk(el) || runs.length === 0) return void 0;
|
|
218
|
-
return runs;
|
|
219
|
-
}
|
|
220
|
-
function buildContext(win, el) {
|
|
221
|
-
const context = {};
|
|
222
|
-
if (el.classList.length > 0) context.classes = [...el.classList];
|
|
223
|
-
const styles = styleSubset(win, el);
|
|
224
|
-
if (styles !== void 0) context.styles = styles;
|
|
225
|
-
const aria = ariaMap(el);
|
|
226
|
-
if (aria !== void 0) context.aria = aria;
|
|
227
|
-
const nearby = nearbyText(el);
|
|
228
|
-
if (nearby !== void 0) context.nearbyText = nearby;
|
|
229
|
-
const selected = selectedText(win, el);
|
|
230
|
-
if (selected !== void 0) context.selectedText = selected;
|
|
231
|
-
const runs = textRuns(el);
|
|
232
|
-
if (runs !== void 0) context.textRuns = runs;
|
|
233
|
-
return Object.keys(context).length > 0 ? context : void 0;
|
|
166
|
+
function isTextEntry(target) {
|
|
167
|
+
const el = target;
|
|
168
|
+
if (!el || typeof el.tagName !== "string") return false;
|
|
169
|
+
if (TEXT_TAGS.has(el.tagName)) return true;
|
|
170
|
+
if (el.isContentEditable === true) return true;
|
|
171
|
+
if (el.closest?.(TEXT_ROLE_SELECTOR) != null) return true;
|
|
172
|
+
return el.closest?.(`[${IGNORE_KEYS_ATTR}]`) != null;
|
|
234
173
|
}
|
|
235
|
-
/**
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
if (opts?.at !== void 0 && r.width > 0 && r.height > 0) {
|
|
253
|
-
const fx = (opts.at.x - (r.left + win.scrollX)) / r.width;
|
|
254
|
-
const fy = (opts.at.y - (r.top + win.scrollY)) / r.height;
|
|
255
|
-
if (fx >= 0 && fx <= 1 && fy >= 0 && fy <= 1) target.spot = {
|
|
256
|
-
x: fx,
|
|
257
|
-
y: fy
|
|
258
|
-
};
|
|
259
|
-
}
|
|
260
|
-
const context = buildContext(win, el);
|
|
261
|
-
if (context !== void 0) target.context = context;
|
|
262
|
-
return {
|
|
263
|
-
target,
|
|
264
|
-
env: {
|
|
265
|
-
viewport: {
|
|
266
|
-
w: win.innerWidth,
|
|
267
|
-
h: win.innerHeight,
|
|
268
|
-
dpr: win.devicePixelRatio
|
|
269
|
-
},
|
|
270
|
-
browser: win.navigator.userAgent,
|
|
271
|
-
os: win.navigator.platform || "unknown",
|
|
272
|
-
colorScheme: win.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"
|
|
273
|
-
}
|
|
274
|
-
};
|
|
174
|
+
/**
|
|
175
|
+
* Which action, if any, a keydown asks for.
|
|
176
|
+
*
|
|
177
|
+
* Modifier chords are never ours (⌘P is print, ⌃I is italics, ⌥ types
|
|
178
|
+
* characters); Shift is allowed because `?` needs it. Repeats and IME
|
|
179
|
+
* composition are ignored so a held key acts once and a composing keyboard is
|
|
180
|
+
* left alone.
|
|
181
|
+
*/
|
|
182
|
+
function shortcutFor(e, mode = "all") {
|
|
183
|
+
if (mode === "off") return null;
|
|
184
|
+
if (e.metaKey || e.ctrlKey || e.altKey || e.repeat || e.isComposing) return null;
|
|
185
|
+
if (isTextEntry(e.composedPath()[0] ?? e.target)) return null;
|
|
186
|
+
const key = e.key === "?" ? "?" : e.key.toLowerCase();
|
|
187
|
+
const id = ACTION_BY_KEY.get(key);
|
|
188
|
+
if (id === void 0) return null;
|
|
189
|
+
if (mode === "escape-only" && id !== "escape") return null;
|
|
190
|
+
return id;
|
|
275
191
|
}
|
|
276
192
|
//#endregion
|
|
277
193
|
//#region src/markdown.ts
|
|
@@ -319,18 +235,122 @@ var Pinbox = (function(exports) {
|
|
|
319
235
|
return `${block(pin, thread)}\n`;
|
|
320
236
|
}
|
|
321
237
|
//#endregion
|
|
322
|
-
//#region src/motion/
|
|
323
|
-
/**
|
|
324
|
-
const
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
238
|
+
//#region src/motion/drag.ts
|
|
239
|
+
/** Movement that begins a drag… */
|
|
240
|
+
const DRAG_START = 8;
|
|
241
|
+
/** …but a release under this much TOTAL travel is still a tap. */
|
|
242
|
+
const TAP_MAX = 12;
|
|
243
|
+
/**
|
|
244
|
+
* Attach the drag rules to `el`. Move/up listeners live on `el` itself with pointer capture
|
|
245
|
+
* (a capture failure on a synthetic pointer is tolerated); `touch-action: none` on the element
|
|
246
|
+
* is the caller's job.
|
|
247
|
+
*/
|
|
248
|
+
function attachDrag(el, on) {
|
|
249
|
+
let hold = null;
|
|
250
|
+
function onPointerDown(e) {
|
|
251
|
+
if (e.button !== 0) return;
|
|
252
|
+
const origin = on.origin();
|
|
253
|
+
if (origin === null) return;
|
|
254
|
+
try {
|
|
255
|
+
el.setPointerCapture(e.pointerId);
|
|
256
|
+
} catch {}
|
|
257
|
+
hold = {
|
|
258
|
+
px: e.clientX,
|
|
259
|
+
py: e.clientY,
|
|
260
|
+
origin,
|
|
261
|
+
lx: e.clientX,
|
|
262
|
+
ly: e.clientY,
|
|
263
|
+
started: false
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
function onPointerMove(e) {
|
|
267
|
+
if (hold === null) return;
|
|
268
|
+
hold.lx = e.clientX;
|
|
269
|
+
hold.ly = e.clientY;
|
|
270
|
+
const dx = e.clientX - hold.px;
|
|
271
|
+
const dy = e.clientY - hold.py;
|
|
272
|
+
if (!hold.started) {
|
|
273
|
+
if (!on.canStart()) {
|
|
274
|
+
hold = null;
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
if (Math.hypot(dx, dy) < DRAG_START) return;
|
|
278
|
+
hold.started = true;
|
|
279
|
+
on.onStart(hold.origin);
|
|
280
|
+
}
|
|
281
|
+
on.onMove({
|
|
282
|
+
x: hold.origin.x + dx,
|
|
283
|
+
y: hold.origin.y + dy
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
function onPointerUp() {
|
|
287
|
+
if (hold === null) return;
|
|
288
|
+
const h = hold;
|
|
289
|
+
hold = null;
|
|
290
|
+
const total = Math.hypot(h.lx - h.px, h.ly - h.py);
|
|
291
|
+
on.onEnd({
|
|
292
|
+
dragged: h.started && total >= TAP_MAX,
|
|
293
|
+
started: h.started,
|
|
294
|
+
origin: h.origin,
|
|
295
|
+
target: {
|
|
296
|
+
x: h.origin.x + (h.lx - h.px),
|
|
297
|
+
y: h.origin.y + (h.ly - h.py)
|
|
298
|
+
}
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
el.addEventListener("pointerdown", onPointerDown);
|
|
302
|
+
el.addEventListener("pointermove", onPointerMove);
|
|
303
|
+
el.addEventListener("pointerup", onPointerUp);
|
|
304
|
+
el.addEventListener("pointercancel", onPointerUp);
|
|
305
|
+
return {
|
|
306
|
+
cancel() {
|
|
307
|
+
hold = null;
|
|
308
|
+
},
|
|
309
|
+
destroy() {
|
|
310
|
+
hold = null;
|
|
311
|
+
el.removeEventListener("pointerdown", onPointerDown);
|
|
312
|
+
el.removeEventListener("pointermove", onPointerMove);
|
|
313
|
+
el.removeEventListener("pointerup", onPointerUp);
|
|
314
|
+
el.removeEventListener("pointercancel", onPointerUp);
|
|
315
|
+
}
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
/** Keep a top-left corner of a `size`-square (or w×h box) `margin` px inside the viewport. */
|
|
319
|
+
function clampToViewport(p, win, box, margin) {
|
|
320
|
+
const clamp = (v, lo, hi) => Math.min(Math.max(v, lo), hi);
|
|
321
|
+
return {
|
|
322
|
+
x: clamp(p.x, margin, Math.max(margin, win.innerWidth - margin - box.w)),
|
|
323
|
+
y: clamp(p.y, margin, Math.max(margin, win.innerHeight - margin - box.h))
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
/** A persisted `{x,y}` (mirror convention: reads never throw upward); null when absent or malformed. */
|
|
327
|
+
function readPoint(storage, key) {
|
|
328
|
+
try {
|
|
329
|
+
const raw = storage?.getItem(key);
|
|
330
|
+
if (raw == null) return null;
|
|
331
|
+
const parsed = JSON.parse(raw);
|
|
332
|
+
if (typeof parsed.x !== "number" || typeof parsed.y !== "number") return null;
|
|
333
|
+
return {
|
|
334
|
+
x: parsed.x,
|
|
335
|
+
y: parsed.y
|
|
336
|
+
};
|
|
337
|
+
} catch {
|
|
338
|
+
return null;
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
//#endregion
|
|
342
|
+
//#region src/motion/spring.ts
|
|
343
|
+
/** Bar ⇄ puck morphs and the post-drag settle. */
|
|
344
|
+
const MORPH_SPRING = {
|
|
345
|
+
k: 300,
|
|
346
|
+
c: 30
|
|
347
|
+
};
|
|
348
|
+
/** The morph surface chasing the pointer mid-drag. */
|
|
349
|
+
const FOLLOW_SPRING = {
|
|
350
|
+
k: 600,
|
|
351
|
+
c: 38
|
|
352
|
+
};
|
|
353
|
+
/** Integration substep — small enough that MORPH/FOLLOW stay stable at any dt. */
|
|
334
354
|
const SUBSTEP = 1 / 240;
|
|
335
355
|
/** A property this close to target, moving this slowly, counts as settled. */
|
|
336
356
|
const SETTLE = .5;
|
|
@@ -384,11 +404,7 @@ var Pinbox = (function(exports) {
|
|
|
384
404
|
//#endregion
|
|
385
405
|
//#region src/minimize.ts
|
|
386
406
|
const PUCK = 48;
|
|
387
|
-
const MARGIN = 16;
|
|
388
|
-
/** Movement that begins a drag… */
|
|
389
|
-
const DRAG_START = 8;
|
|
390
|
-
/** …but a release under this much TOTAL travel is still a tap. */
|
|
391
|
-
const TAP_MAX = 12;
|
|
407
|
+
const MARGIN$1 = 16;
|
|
392
408
|
/** Spring hold while the surface swap crossfades (ms). */
|
|
393
409
|
const HOLD_MINIMIZE = 90;
|
|
394
410
|
const HOLD_RESTORE = 70;
|
|
@@ -413,7 +429,6 @@ var Pinbox = (function(exports) {
|
|
|
413
429
|
let mode = "bar";
|
|
414
430
|
let puckPos = null;
|
|
415
431
|
let dock = loadDock();
|
|
416
|
-
let pdown = null;
|
|
417
432
|
let keyboardToggle = false;
|
|
418
433
|
let raf = 0;
|
|
419
434
|
let last = 0;
|
|
@@ -423,18 +438,7 @@ var Pinbox = (function(exports) {
|
|
|
423
438
|
let fanOpen = false;
|
|
424
439
|
let fanTimer = 0;
|
|
425
440
|
function loadDock() {
|
|
426
|
-
|
|
427
|
-
const raw = host.storage?.getItem(`${host.storagePrefix}:dock`);
|
|
428
|
-
if (raw == null) return null;
|
|
429
|
-
const parsed = JSON.parse(raw);
|
|
430
|
-
if (typeof parsed.x !== "number" || typeof parsed.y !== "number") return null;
|
|
431
|
-
return {
|
|
432
|
-
x: parsed.x,
|
|
433
|
-
y: parsed.y
|
|
434
|
-
};
|
|
435
|
-
} catch {
|
|
436
|
-
return null;
|
|
437
|
-
}
|
|
441
|
+
return readPoint(host.storage, `${host.storagePrefix}:dock`);
|
|
438
442
|
}
|
|
439
443
|
function persist() {
|
|
440
444
|
try {
|
|
@@ -450,12 +454,11 @@ var Pinbox = (function(exports) {
|
|
|
450
454
|
return host.initialMinimized;
|
|
451
455
|
}
|
|
452
456
|
}
|
|
453
|
-
const clamp = (v, lo, hi) => Math.min(Math.max(v, lo), hi);
|
|
454
457
|
function clampPos(p) {
|
|
455
|
-
return {
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
};
|
|
458
|
+
return clampToViewport(p, win, {
|
|
459
|
+
w: PUCK,
|
|
460
|
+
h: PUCK
|
|
461
|
+
}, MARGIN$1);
|
|
459
462
|
}
|
|
460
463
|
function defaultDock() {
|
|
461
464
|
const r = bar.getBoundingClientRect();
|
|
@@ -532,7 +535,7 @@ var Pinbox = (function(exports) {
|
|
|
532
535
|
}
|
|
533
536
|
function minimize(keyboard = false) {
|
|
534
537
|
if (mode !== "bar") return;
|
|
535
|
-
|
|
538
|
+
drag.cancel();
|
|
536
539
|
keyboardToggle = keyboard;
|
|
537
540
|
const r = bar.getBoundingClientRect();
|
|
538
541
|
const d = clampPos(dock ?? defaultDock());
|
|
@@ -570,7 +573,7 @@ var Pinbox = (function(exports) {
|
|
|
570
573
|
function restore(keyboard = false) {
|
|
571
574
|
if (mode !== "puck" || puckPos === null) return;
|
|
572
575
|
closeFan();
|
|
573
|
-
|
|
576
|
+
drag.cancel();
|
|
574
577
|
keyboardToggle = keyboard;
|
|
575
578
|
dock = { ...puckPos };
|
|
576
579
|
ui.puck.classList.add("pb-ghost");
|
|
@@ -652,42 +655,17 @@ var Pinbox = (function(exports) {
|
|
|
652
655
|
if (path.includes(ui.fan) || path.includes(ui.puck)) return;
|
|
653
656
|
closeFan();
|
|
654
657
|
}
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
} catch {}
|
|
660
|
-
if (puckPos === null) return;
|
|
661
|
-
pdown = {
|
|
662
|
-
px: e.clientX,
|
|
663
|
-
py: e.clientY,
|
|
664
|
-
ox: puckPos.x,
|
|
665
|
-
oy: puckPos.y,
|
|
666
|
-
lx: e.clientX,
|
|
667
|
-
ly: e.clientY,
|
|
668
|
-
moved: false
|
|
669
|
-
};
|
|
670
|
-
}
|
|
671
|
-
function onPointerMove(e) {
|
|
672
|
-
if (pdown === null) return;
|
|
673
|
-
pdown.lx = e.clientX;
|
|
674
|
-
pdown.ly = e.clientY;
|
|
675
|
-
const dx = e.clientX - pdown.px;
|
|
676
|
-
const dy = e.clientY - pdown.py;
|
|
677
|
-
if (!pdown.moved) {
|
|
678
|
-
if (mode !== "puck") {
|
|
679
|
-
pdown = null;
|
|
680
|
-
return;
|
|
681
|
-
}
|
|
682
|
-
if (Math.hypot(dx, dy) < DRAG_START) return;
|
|
683
|
-
pdown.moved = true;
|
|
658
|
+
const drag = attachDrag(ui.puck, {
|
|
659
|
+
origin: () => mode === "puck" ? puckPos : null,
|
|
660
|
+
canStart: () => mode === "puck",
|
|
661
|
+
onStart(origin) {
|
|
684
662
|
closeFan();
|
|
685
663
|
mode = "drag";
|
|
686
664
|
if (!host.reduced) {
|
|
687
665
|
ui.puck.classList.add("pb-ghost");
|
|
688
666
|
main.snap({
|
|
689
|
-
x:
|
|
690
|
-
y:
|
|
667
|
+
x: origin.x,
|
|
668
|
+
y: origin.y,
|
|
691
669
|
w: PUCK,
|
|
692
670
|
h: PUCK,
|
|
693
671
|
r: PUCK / 2
|
|
@@ -695,140 +673,706 @@ var Pinbox = (function(exports) {
|
|
|
695
673
|
render();
|
|
696
674
|
showMorph();
|
|
697
675
|
}
|
|
698
|
-
}
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
676
|
+
},
|
|
677
|
+
onMove(next) {
|
|
678
|
+
if (host.reduced) {
|
|
679
|
+
const p = clampPos(next);
|
|
680
|
+
placePuck(p.x, p.y);
|
|
681
|
+
} else {
|
|
682
|
+
main.to({
|
|
683
|
+
...next,
|
|
684
|
+
w: PUCK,
|
|
685
|
+
h: PUCK,
|
|
686
|
+
r: PUCK / 2
|
|
687
|
+
}, FOLLOW_SPRING);
|
|
688
|
+
ensureLoop();
|
|
689
|
+
}
|
|
690
|
+
},
|
|
691
|
+
onEnd({ dragged, started, origin }) {
|
|
692
|
+
if (!dragged) {
|
|
693
|
+
if (started && mode === "drag") {
|
|
694
|
+
main.snap({
|
|
695
|
+
x: origin.x,
|
|
696
|
+
y: origin.y,
|
|
697
|
+
w: PUCK,
|
|
698
|
+
h: PUCK,
|
|
699
|
+
r: PUCK / 2
|
|
700
|
+
});
|
|
701
|
+
placePuck(origin.x, origin.y);
|
|
702
|
+
ui.puck.classList.remove("pb-ghost");
|
|
703
|
+
hideMorph();
|
|
704
|
+
mode = "puck";
|
|
705
|
+
}
|
|
706
|
+
if (!closeFan()) openFan();
|
|
707
|
+
return;
|
|
708
|
+
}
|
|
709
|
+
if (host.reduced) {
|
|
710
|
+
if (puckPos) {
|
|
711
|
+
const p = clampPos(puckPos);
|
|
712
|
+
placePuck(p.x, p.y);
|
|
713
|
+
dock = { ...p };
|
|
714
|
+
}
|
|
715
|
+
mode = "puck";
|
|
716
|
+
persist();
|
|
717
|
+
return;
|
|
718
|
+
}
|
|
719
|
+
const p = clampPos({
|
|
720
|
+
x: main.tgt.x,
|
|
721
|
+
y: main.tgt.y
|
|
722
|
+
});
|
|
707
723
|
main.to({
|
|
708
|
-
...
|
|
724
|
+
...p,
|
|
709
725
|
w: PUCK,
|
|
710
726
|
h: PUCK,
|
|
711
727
|
r: PUCK / 2
|
|
712
|
-
},
|
|
728
|
+
}, MORPH_SPRING);
|
|
729
|
+
mode = "settle";
|
|
713
730
|
ensureLoop();
|
|
714
731
|
}
|
|
732
|
+
});
|
|
733
|
+
/** Keyboard/AT activation is a synthesized click (detail 0) with no pointer events. */
|
|
734
|
+
function onClick(e) {
|
|
735
|
+
if (e.detail !== 0) return;
|
|
736
|
+
if (!closeFan()) openFan();
|
|
715
737
|
}
|
|
716
|
-
function
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
738
|
+
function onResize() {
|
|
739
|
+
closeFan();
|
|
740
|
+
if (mode === "puck" && puckPos !== null) {
|
|
741
|
+
const p = clampPos(puckPos);
|
|
742
|
+
placePuck(p.x, p.y);
|
|
743
|
+
}
|
|
744
|
+
if (dock !== null) dock = clampPos(dock);
|
|
745
|
+
}
|
|
746
|
+
ui.puck.addEventListener("click", onClick);
|
|
747
|
+
ui.fan.addEventListener("click", onFanClick);
|
|
748
|
+
win.document.addEventListener("pointerdown", onDocPointerDown);
|
|
749
|
+
win.addEventListener("resize", onResize);
|
|
750
|
+
return {
|
|
751
|
+
minimized: () => mode !== "bar",
|
|
752
|
+
mode: () => mode,
|
|
753
|
+
minimize,
|
|
754
|
+
restore,
|
|
755
|
+
closeFan,
|
|
756
|
+
applyInitial() {
|
|
757
|
+
if (!loadMinimized()) return;
|
|
758
|
+
const apply = () => {
|
|
759
|
+
if (mode !== "bar") return;
|
|
760
|
+
const d = clampPos(dock ?? defaultDock());
|
|
761
|
+
bar.classList.add("pb-ghost");
|
|
762
|
+
placePuck(d.x, d.y);
|
|
736
763
|
ui.puck.classList.remove("pb-ghost");
|
|
737
|
-
hideMorph();
|
|
738
764
|
mode = "puck";
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
765
|
+
host.onSettled(true, false);
|
|
766
|
+
};
|
|
767
|
+
if (host.reduced) apply();
|
|
768
|
+
else win.requestAnimationFrame(apply);
|
|
769
|
+
},
|
|
770
|
+
destroy() {
|
|
771
|
+
drag.destroy();
|
|
772
|
+
ui.puck.removeEventListener("click", onClick);
|
|
773
|
+
ui.fan.removeEventListener("click", onFanClick);
|
|
774
|
+
win.document.removeEventListener("pointerdown", onDocPointerDown);
|
|
775
|
+
win.removeEventListener("resize", onResize);
|
|
776
|
+
if (raf !== 0) win.cancelAnimationFrame(raf);
|
|
777
|
+
raf = 0;
|
|
778
|
+
win.clearTimeout(holdTimer);
|
|
779
|
+
win.clearTimeout(fadeTimer);
|
|
780
|
+
win.clearTimeout(hideTimer);
|
|
781
|
+
win.clearTimeout(fanTimer);
|
|
782
|
+
ui.fan.hidden = true;
|
|
783
|
+
ui.fan.classList.remove("on");
|
|
784
|
+
fanOpen = false;
|
|
742
785
|
}
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
786
|
+
};
|
|
787
|
+
}
|
|
788
|
+
//#endregion
|
|
789
|
+
//#region src/targeting/dom.ts
|
|
790
|
+
/**
|
|
791
|
+
* Deepest element under (clientX, clientY) that the caller does not ignore, or null when there is
|
|
792
|
+
* nothing there but page chrome (html/body).
|
|
793
|
+
*
|
|
794
|
+
* Looks THROUGH our own overlay rather than giving up at it. The single-element form could not:
|
|
795
|
+
* the drag-aim grip sits exactly on the point being aimed at, so it is always the topmost thing
|
|
796
|
+
* under the crosshair, and every probe came back "nothing" the moment touch aiming existed.
|
|
797
|
+
*/
|
|
798
|
+
function hitTest(doc, x, y, ignore) {
|
|
799
|
+
const stack = doc.elementsFromPoint?.(x, y) ?? [doc.elementFromPoint(x, y)];
|
|
800
|
+
for (const el of stack) {
|
|
801
|
+
if (!el || el === doc.body || el === doc.documentElement) return null;
|
|
802
|
+
if (!ignore(el)) return el;
|
|
803
|
+
}
|
|
804
|
+
return null;
|
|
805
|
+
}
|
|
806
|
+
/** CLASS-or-TAG display name with a sibling index when needed (prototype nodeName). */
|
|
807
|
+
function nodeName(el) {
|
|
808
|
+
const key = el.classList[0];
|
|
809
|
+
let name = (key ?? el.tagName).toUpperCase();
|
|
810
|
+
const parent = el.parentElement;
|
|
811
|
+
if (parent) {
|
|
812
|
+
const sibs = [...parent.children].filter((c) => c.classList[0] === key && c.tagName === el.tagName);
|
|
813
|
+
if (sibs.length > 1) name += ` ${sibs.indexOf(el) + 1}`;
|
|
814
|
+
}
|
|
815
|
+
return name;
|
|
816
|
+
}
|
|
817
|
+
/**
|
|
818
|
+
* Human label for a target: an explicit data-pb-el annotation wins; otherwise a
|
|
819
|
+
* CLASS/TAG ancestry chain of at most 3 parts joined with ›, terminating early
|
|
820
|
+
* at the first annotated ancestor.
|
|
821
|
+
*/
|
|
822
|
+
function targetLabel(el) {
|
|
823
|
+
const own = el.getAttribute("data-pb-el");
|
|
824
|
+
if (own) return own;
|
|
825
|
+
const parts = [nodeName(el)];
|
|
826
|
+
const body = el.ownerDocument.body;
|
|
827
|
+
let node = el.parentElement;
|
|
828
|
+
while (node && node !== body && parts.length < 3) {
|
|
829
|
+
const anchor = node.getAttribute("data-pb-el");
|
|
830
|
+
if (anchor) {
|
|
831
|
+
parts.unshift(anchor);
|
|
832
|
+
break;
|
|
752
833
|
}
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
y: main.tgt.y
|
|
756
|
-
});
|
|
757
|
-
main.to({
|
|
758
|
-
...p,
|
|
759
|
-
w: PUCK,
|
|
760
|
-
h: PUCK,
|
|
761
|
-
r: PUCK / 2
|
|
762
|
-
}, MORPH_SPRING);
|
|
763
|
-
mode = "settle";
|
|
764
|
-
ensureLoop();
|
|
834
|
+
if (node.classList[0]) parts.unshift(nodeName(node));
|
|
835
|
+
node = node.parentElement;
|
|
765
836
|
}
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
837
|
+
return parts.join(" › ");
|
|
838
|
+
}
|
|
839
|
+
const SAFE_ID = /^[A-Za-z][\w-]*$/;
|
|
840
|
+
/** Data attributes trusted as stable hooks, in priority order. */
|
|
841
|
+
const STABLE_DATA_ATTRS = [
|
|
842
|
+
"data-pb-anchor",
|
|
843
|
+
"data-pb-el",
|
|
844
|
+
"data-testid"
|
|
845
|
+
];
|
|
846
|
+
function attrSegment(el, doc) {
|
|
847
|
+
for (const attr of STABLE_DATA_ATTRS) {
|
|
848
|
+
const value = el.getAttribute(attr);
|
|
849
|
+
if (value === null || value.includes("\"") || value.includes("\\")) continue;
|
|
850
|
+
const selector = `${el.tagName.toLowerCase()}[${attr}="${value}"]`;
|
|
851
|
+
if (doc.querySelectorAll(selector).length === 1) return selector;
|
|
852
|
+
}
|
|
853
|
+
return null;
|
|
854
|
+
}
|
|
855
|
+
function nthSegment(el) {
|
|
856
|
+
const tag = el.tagName.toLowerCase();
|
|
857
|
+
const parent = el.parentElement;
|
|
858
|
+
if (!parent) return tag;
|
|
859
|
+
const sameTag = [...parent.children].filter((c) => c.tagName === el.tagName);
|
|
860
|
+
return sameTag.length > 1 ? `${tag}:nth-of-type(${sameTag.indexOf(el) + 1})` : tag;
|
|
861
|
+
}
|
|
862
|
+
/**
|
|
863
|
+
* Stable CSS path for an element: ids > stable data attributes > an
|
|
864
|
+
* nth-of-type chain. Guaranteed round-trip: querySelector(buildSelector(el)) === el.
|
|
865
|
+
*/
|
|
866
|
+
function buildSelector(el) {
|
|
867
|
+
const doc = el.ownerDocument;
|
|
868
|
+
const segments = [];
|
|
869
|
+
let node = el;
|
|
870
|
+
while (node && node !== doc.documentElement) {
|
|
871
|
+
const id = node.getAttribute("id");
|
|
872
|
+
if (id && SAFE_ID.test(id) && doc.querySelectorAll(`#${id}`).length === 1) {
|
|
873
|
+
segments.unshift(`#${id}`);
|
|
874
|
+
return segments.join(" > ");
|
|
875
|
+
}
|
|
876
|
+
const byAttr = attrSegment(node, doc);
|
|
877
|
+
if (byAttr) {
|
|
878
|
+
segments.unshift(byAttr);
|
|
879
|
+
return segments.join(" > ");
|
|
880
|
+
}
|
|
881
|
+
segments.unshift(nthSegment(node));
|
|
882
|
+
node = node.parentElement;
|
|
883
|
+
}
|
|
884
|
+
return segments.join(" > ");
|
|
885
|
+
}
|
|
886
|
+
//#endregion
|
|
887
|
+
//#region src/capture.ts
|
|
888
|
+
/** Curated computed-style subset — enough to reconstruct layout intent, tiny on the wire. */
|
|
889
|
+
const STYLE_KEYS = [
|
|
890
|
+
"display",
|
|
891
|
+
"position",
|
|
892
|
+
"font-size",
|
|
893
|
+
"color",
|
|
894
|
+
"background-color",
|
|
895
|
+
"margin",
|
|
896
|
+
"padding",
|
|
897
|
+
"overflow"
|
|
898
|
+
];
|
|
899
|
+
const NEARBY_TEXT_MAX = 160;
|
|
900
|
+
function styleSubset(win, el) {
|
|
901
|
+
let cs;
|
|
902
|
+
try {
|
|
903
|
+
cs = win.getComputedStyle(el);
|
|
904
|
+
} catch {
|
|
905
|
+
return;
|
|
906
|
+
}
|
|
907
|
+
const out = {};
|
|
908
|
+
for (const key of STYLE_KEYS) {
|
|
909
|
+
const value = cs.getPropertyValue(key);
|
|
910
|
+
if (value !== "") out[key] = value;
|
|
911
|
+
}
|
|
912
|
+
return Object.keys(out).length > 0 ? out : void 0;
|
|
913
|
+
}
|
|
914
|
+
function ariaMap(el) {
|
|
915
|
+
const out = {};
|
|
916
|
+
for (const name of el.getAttributeNames()) if (name.startsWith("aria-")) out[name] = el.getAttribute(name) ?? "";
|
|
917
|
+
return Object.keys(out).length > 0 ? out : void 0;
|
|
918
|
+
}
|
|
919
|
+
function nearbyText(el) {
|
|
920
|
+
const text = (el.textContent ?? "").replace(/\s+/g, " ").trim();
|
|
921
|
+
return text === "" ? void 0 : text.slice(0, NEARBY_TEXT_MAX);
|
|
922
|
+
}
|
|
923
|
+
/** The user's selection, only when it intersects the captured element. */
|
|
924
|
+
function selectedText(win, el) {
|
|
925
|
+
try {
|
|
926
|
+
const sel = win.getSelection?.();
|
|
927
|
+
if (!sel || sel.isCollapsed || sel.rangeCount === 0) return void 0;
|
|
928
|
+
if (!sel.getRangeAt(0).intersectsNode(el)) return void 0;
|
|
929
|
+
const text = sel.toString().trim();
|
|
930
|
+
return text === "" ? void 0 : text;
|
|
931
|
+
} catch {
|
|
932
|
+
return;
|
|
933
|
+
}
|
|
934
|
+
}
|
|
935
|
+
/** `fixed` detected via ancestry: any ancestor with computed position: fixed. */
|
|
936
|
+
function isFixed(win, el) {
|
|
937
|
+
for (let node = el; node !== null; node = node.parentElement) try {
|
|
938
|
+
if (win.getComputedStyle(node).position === "fixed") return true;
|
|
939
|
+
} catch {
|
|
940
|
+
return false;
|
|
941
|
+
}
|
|
942
|
+
return false;
|
|
943
|
+
}
|
|
944
|
+
/** Beyond this an element is not a thing you pinned, it is a region. Too many to rewrite as a set. */
|
|
945
|
+
const MAX_RUNS = 40;
|
|
946
|
+
const MAX_RUN_LENGTH = 200;
|
|
947
|
+
/** Text that is not content: a script body or a stylesheet is not something to rewrite. */
|
|
948
|
+
const NON_CONTENT = /* @__PURE__ */ new Set([
|
|
949
|
+
"SCRIPT",
|
|
950
|
+
"STYLE",
|
|
951
|
+
"NOSCRIPT",
|
|
952
|
+
"TEMPLATE"
|
|
953
|
+
]);
|
|
954
|
+
/**
|
|
955
|
+
* The element's text, split the way the browser stores it: one entry per run of characters.
|
|
956
|
+
*
|
|
957
|
+
* This walks TEXT NODES, not elements, and that distinction is the whole point — it makes no
|
|
958
|
+
* assumption about how a site is built. A heading is one run. A nav bar is one per link. A
|
|
959
|
+
* paragraph with a bold word in the middle is three, in reading order, including the halves either
|
|
960
|
+
* side of the bold. An earlier version keyed off "elements with no element children", which
|
|
961
|
+
* quietly lost the "Hello " in `<p>Hello <b>world</b></p>` — text a person can obviously see and
|
|
962
|
+
* would obviously expect to be able to change.
|
|
963
|
+
*
|
|
964
|
+
* `nearbyText` runs them all together, which is fine to read and useless to edit: it cannot tell
|
|
965
|
+
* an agent that "work approach people contact" is four separate places. This can.
|
|
966
|
+
*/
|
|
967
|
+
function textRuns(el) {
|
|
968
|
+
const runs = [];
|
|
969
|
+
const walk = (node) => {
|
|
970
|
+
if (node.nodeType === 3) {
|
|
971
|
+
const text = (node.nodeValue ?? "").trim();
|
|
972
|
+
if (text.length > 0) runs.push(text.slice(0, MAX_RUN_LENGTH));
|
|
973
|
+
return runs.length <= MAX_RUNS;
|
|
974
|
+
}
|
|
975
|
+
if (node.nodeType !== 1 || NON_CONTENT.has(node.tagName)) return true;
|
|
976
|
+
for (const child of node.childNodes) if (!walk(child)) return false;
|
|
977
|
+
return true;
|
|
978
|
+
};
|
|
979
|
+
if (!walk(el) || runs.length === 0) return void 0;
|
|
980
|
+
return runs;
|
|
981
|
+
}
|
|
982
|
+
function buildContext(win, el) {
|
|
983
|
+
const context = {};
|
|
984
|
+
if (el.classList.length > 0) context.classes = [...el.classList];
|
|
985
|
+
const styles = styleSubset(win, el);
|
|
986
|
+
if (styles !== void 0) context.styles = styles;
|
|
987
|
+
const aria = ariaMap(el);
|
|
988
|
+
if (aria !== void 0) context.aria = aria;
|
|
989
|
+
const nearby = nearbyText(el);
|
|
990
|
+
if (nearby !== void 0) context.nearbyText = nearby;
|
|
991
|
+
const selected = selectedText(win, el);
|
|
992
|
+
if (selected !== void 0) context.selectedText = selected;
|
|
993
|
+
const runs = textRuns(el);
|
|
994
|
+
if (runs !== void 0) context.textRuns = runs;
|
|
995
|
+
return Object.keys(context).length > 0 ? context : void 0;
|
|
996
|
+
}
|
|
997
|
+
/** Fills PinInput.target/env from a chosen element (shapes come from the pin schema). */
|
|
998
|
+
function captureTarget(el, opts) {
|
|
999
|
+
const win = el.ownerDocument.defaultView;
|
|
1000
|
+
const r = el.getBoundingClientRect();
|
|
1001
|
+
const target = {
|
|
1002
|
+
url: win.location.href,
|
|
1003
|
+
selector: buildSelector(el),
|
|
1004
|
+
tag: el.tagName.toLowerCase(),
|
|
1005
|
+
rect: {
|
|
1006
|
+
x: r.left + win.scrollX,
|
|
1007
|
+
y: r.top + win.scrollY,
|
|
1008
|
+
width: r.width,
|
|
1009
|
+
height: r.height
|
|
1010
|
+
},
|
|
1011
|
+
fixed: isFixed(win, el)
|
|
1012
|
+
};
|
|
1013
|
+
if (opts?.anchor !== void 0) target.anchor = opts.anchor;
|
|
1014
|
+
if (opts?.at !== void 0 && r.width > 0 && r.height > 0) {
|
|
1015
|
+
const fx = (opts.at.x - (r.left + win.scrollX)) / r.width;
|
|
1016
|
+
const fy = (opts.at.y - (r.top + win.scrollY)) / r.height;
|
|
1017
|
+
if (fx >= 0 && fx <= 1 && fy >= 0 && fy <= 1) target.spot = {
|
|
1018
|
+
x: fx,
|
|
1019
|
+
y: fy
|
|
1020
|
+
};
|
|
1021
|
+
}
|
|
1022
|
+
const context = buildContext(win, el);
|
|
1023
|
+
if (context !== void 0) target.context = context;
|
|
1024
|
+
return {
|
|
1025
|
+
target,
|
|
1026
|
+
env: {
|
|
1027
|
+
viewport: {
|
|
1028
|
+
w: win.innerWidth,
|
|
1029
|
+
h: win.innerHeight,
|
|
1030
|
+
dpr: win.devicePixelRatio
|
|
1031
|
+
},
|
|
1032
|
+
browser: win.navigator.userAgent,
|
|
1033
|
+
os: win.navigator.platform || "unknown",
|
|
1034
|
+
colorScheme: win.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"
|
|
1035
|
+
}
|
|
1036
|
+
};
|
|
1037
|
+
}
|
|
1038
|
+
//#endregion
|
|
1039
|
+
//#region src/ui/aim.ts
|
|
1040
|
+
/**
|
|
1041
|
+
* True when aiming has to be done by dragging rather than by pointing.
|
|
1042
|
+
*
|
|
1043
|
+
* Two independent reasons, either of which is sufficient. A coarse pointer means there is no
|
|
1044
|
+
* hover to follow at all — the mouse crosshair cannot work, whatever the screen size. The width
|
|
1045
|
+
* check is the design's own rule (720px) and catches the case a media query cannot: a device that
|
|
1046
|
+
* reports a fine pointer but is being used at phone width.
|
|
1047
|
+
*/
|
|
1048
|
+
function needsDragAim(win) {
|
|
1049
|
+
return win.matchMedia?.("(pointer: coarse)").matches === true || win.innerWidth < 720;
|
|
1050
|
+
}
|
|
1051
|
+
/**
|
|
1052
|
+
* Where the reticle starts.
|
|
1053
|
+
*
|
|
1054
|
+
* Slightly above centre: the confirm bar owns the bottom of the screen, and a reticle that opens
|
|
1055
|
+
* underneath your own thumb is one you have to move before you can even see it.
|
|
1056
|
+
*/
|
|
1057
|
+
function startPoint(win) {
|
|
1058
|
+
return {
|
|
1059
|
+
x: win.innerWidth / 2,
|
|
1060
|
+
y: win.innerHeight * .42
|
|
1061
|
+
};
|
|
1062
|
+
}
|
|
1063
|
+
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>";
|
|
1064
|
+
function createAim(doc, handlers) {
|
|
1065
|
+
const win = doc.defaultView;
|
|
1066
|
+
const root = doc.createElement("div");
|
|
1067
|
+
root.className = "pb-aim";
|
|
1068
|
+
root.innerHTML = MARKUP;
|
|
1069
|
+
const h = root.querySelector(".h");
|
|
1070
|
+
const v = root.querySelector(".v");
|
|
1071
|
+
const grip = root.querySelector(".grip");
|
|
1072
|
+
const label = root.querySelector(".lab");
|
|
1073
|
+
const point = {
|
|
1074
|
+
x: 0,
|
|
1075
|
+
y: 0
|
|
1076
|
+
};
|
|
1077
|
+
/** Grab offset, so the reticle does not jump to your fingertip when you take hold of it. */
|
|
1078
|
+
let grab = null;
|
|
1079
|
+
function put(x, y) {
|
|
1080
|
+
point.x = Math.max(0, Math.min(win.innerWidth, x));
|
|
1081
|
+
point.y = Math.max(0, Math.min(win.innerHeight, y));
|
|
1082
|
+
h.style.top = `${point.y}px`;
|
|
1083
|
+
v.style.left = `${point.x}px`;
|
|
1084
|
+
grip.style.left = `${point.x}px`;
|
|
1085
|
+
grip.style.top = `${point.y}px`;
|
|
1086
|
+
}
|
|
1087
|
+
const onPointerMove = (e) => {
|
|
1088
|
+
if (grab === null) return;
|
|
1089
|
+
e.preventDefault();
|
|
1090
|
+
put(e.clientX + grab.dx, e.clientY + grab.dy);
|
|
1091
|
+
handlers.onAim(point.x, point.y);
|
|
1092
|
+
};
|
|
1093
|
+
const onPointerUp = () => {
|
|
1094
|
+
grab = null;
|
|
1095
|
+
};
|
|
1096
|
+
grip.addEventListener("pointerdown", (e) => {
|
|
1097
|
+
e.preventDefault();
|
|
1098
|
+
e.stopPropagation();
|
|
1099
|
+
grab = {
|
|
1100
|
+
dx: point.x - e.clientX,
|
|
1101
|
+
dy: point.y - e.clientY
|
|
1102
|
+
};
|
|
1103
|
+
grip.setPointerCapture?.(e.pointerId);
|
|
1104
|
+
});
|
|
1105
|
+
grip.addEventListener("keydown", (e) => {
|
|
1106
|
+
const step = e.shiftKey ? 20 : 2;
|
|
1107
|
+
const delta = {
|
|
1108
|
+
ArrowLeft: [-step, 0],
|
|
1109
|
+
ArrowRight: [step, 0],
|
|
1110
|
+
ArrowUp: [0, -step],
|
|
1111
|
+
ArrowDown: [0, step]
|
|
1112
|
+
}[e.key];
|
|
1113
|
+
if (!delta) return;
|
|
1114
|
+
e.preventDefault();
|
|
1115
|
+
put(point.x + delta[0], point.y + delta[1]);
|
|
1116
|
+
handlers.onAim(point.x, point.y);
|
|
1117
|
+
});
|
|
1118
|
+
root.addEventListener("click", (e) => {
|
|
1119
|
+
const action = e.target.closest?.("[data-aim]")?.getAttribute("data-aim");
|
|
1120
|
+
if (!action) return;
|
|
1121
|
+
e.preventDefault();
|
|
1122
|
+
e.stopPropagation();
|
|
1123
|
+
if (action === "confirm") handlers.onConfirm();
|
|
1124
|
+
else handlers.onCancel();
|
|
1125
|
+
});
|
|
1126
|
+
win.addEventListener("pointermove", onPointerMove, { passive: false });
|
|
1127
|
+
win.addEventListener("pointerup", onPointerUp);
|
|
1128
|
+
win.addEventListener("pointercancel", onPointerUp);
|
|
1129
|
+
return {
|
|
1130
|
+
root,
|
|
1131
|
+
point,
|
|
1132
|
+
show(x, y) {
|
|
1133
|
+
put(x, y);
|
|
1134
|
+
root.classList.add("on");
|
|
1135
|
+
},
|
|
1136
|
+
hide() {
|
|
1137
|
+
grab = null;
|
|
1138
|
+
root.classList.remove("on");
|
|
1139
|
+
},
|
|
1140
|
+
setLabel(text) {
|
|
1141
|
+
label.textContent = text;
|
|
1142
|
+
},
|
|
1143
|
+
destroy() {
|
|
1144
|
+
win.removeEventListener("pointermove", onPointerMove);
|
|
1145
|
+
win.removeEventListener("pointerup", onPointerUp);
|
|
1146
|
+
win.removeEventListener("pointercancel", onPointerUp);
|
|
1147
|
+
}
|
|
1148
|
+
};
|
|
1149
|
+
}
|
|
1150
|
+
//#endregion
|
|
1151
|
+
//#region src/ui/multimarks.ts
|
|
1152
|
+
const MARK_CLASS = "pb-multi-mark";
|
|
1153
|
+
/** Replace the mark set to mirror `targets`; entries with no rect draw nothing. */
|
|
1154
|
+
function renderMultiMarks(layer, targets, scroll = {
|
|
1155
|
+
x: 0,
|
|
1156
|
+
y: 0
|
|
1157
|
+
}) {
|
|
1158
|
+
for (const node of [...layer.querySelectorAll(`.${MARK_CLASS}`)]) node.remove();
|
|
1159
|
+
targets.forEach((target, i) => {
|
|
1160
|
+
const rect = target.rect;
|
|
1161
|
+
if (rect === void 0) return;
|
|
1162
|
+
const mark = layer.ownerDocument.createElement("div");
|
|
1163
|
+
mark.className = MARK_CLASS;
|
|
1164
|
+
mark.style.left = `${rect.x - scroll.x}px`;
|
|
1165
|
+
mark.style.top = `${rect.y - scroll.y}px`;
|
|
1166
|
+
mark.style.width = `${rect.width}px`;
|
|
1167
|
+
mark.style.height = `${rect.height}px`;
|
|
1168
|
+
mark.innerHTML = `<span>${i + 1}</span>`;
|
|
1169
|
+
layer.appendChild(mark);
|
|
1170
|
+
});
|
|
1171
|
+
}
|
|
1172
|
+
//#endregion
|
|
1173
|
+
//#region src/ui/reticle.ts
|
|
1174
|
+
function createReticle(doc) {
|
|
1175
|
+
const crosshair = doc.createElement("div");
|
|
1176
|
+
crosshair.className = "pb-reticle";
|
|
1177
|
+
crosshair.innerHTML = "<div class=\"h\"></div><div class=\"v\"></div><div class=\"box\"></div><div class=\"ro\"></div>";
|
|
1178
|
+
const h = crosshair.querySelector(".h");
|
|
1179
|
+
const v = crosshair.querySelector(".v");
|
|
1180
|
+
const box = crosshair.querySelector(".box");
|
|
1181
|
+
const readout = crosshair.querySelector(".ro");
|
|
1182
|
+
const outline = doc.createElement("div");
|
|
1183
|
+
outline.className = "pb-outline";
|
|
1184
|
+
outline.innerHTML = "<span class=\"lab\"></span>";
|
|
1185
|
+
const lab = outline.querySelector(".lab");
|
|
1186
|
+
function setOutlineRect(rect) {
|
|
1187
|
+
outline.style.left = `${rect.left - 5}px`;
|
|
1188
|
+
outline.style.top = `${rect.top - 5}px`;
|
|
1189
|
+
outline.style.width = `${rect.width + 10}px`;
|
|
1190
|
+
outline.style.height = `${rect.height + 10}px`;
|
|
1191
|
+
}
|
|
1192
|
+
return {
|
|
1193
|
+
crosshair,
|
|
1194
|
+
outline,
|
|
1195
|
+
move(pos) {
|
|
1196
|
+
h.style.top = `${pos.clientY}px`;
|
|
1197
|
+
v.style.left = `${pos.clientX}px`;
|
|
1198
|
+
box.style.left = `${pos.clientX}px`;
|
|
1199
|
+
box.style.top = `${pos.clientY}px`;
|
|
1200
|
+
readout.style.left = `${pos.clientX}px`;
|
|
1201
|
+
readout.style.top = `${pos.clientY}px`;
|
|
1202
|
+
readout.textContent = `${Math.round(pos.pageX)} × ${Math.round(pos.pageY)}`;
|
|
1203
|
+
},
|
|
1204
|
+
snap(rect, label) {
|
|
1205
|
+
if (!outline.classList.contains("on")) {
|
|
1206
|
+
outline.style.transition = "none";
|
|
1207
|
+
setOutlineRect(rect);
|
|
1208
|
+
outline.offsetWidth;
|
|
1209
|
+
outline.style.transition = "";
|
|
1210
|
+
} else setOutlineRect(rect);
|
|
1211
|
+
lab.textContent = label;
|
|
1212
|
+
outline.classList.add("on");
|
|
1213
|
+
},
|
|
1214
|
+
release() {
|
|
1215
|
+
outline.classList.remove("on");
|
|
1216
|
+
}
|
|
1217
|
+
};
|
|
1218
|
+
}
|
|
1219
|
+
//#endregion
|
|
1220
|
+
//#region src/placement.ts
|
|
1221
|
+
function createPlacement(deps) {
|
|
1222
|
+
const { win, host, store, pinsLayer } = deps;
|
|
1223
|
+
const doc = host.ownerDocument;
|
|
1224
|
+
const reticle = createReticle(doc);
|
|
1225
|
+
let aim = null;
|
|
1226
|
+
let hover = null;
|
|
1227
|
+
/** Shift+click accumulation while placing — extra loci for ONE pending pin. */
|
|
1228
|
+
let extraTargets = [];
|
|
1229
|
+
/** Pending viewport-refresh frame, 0 when none is queued. */
|
|
1230
|
+
let viewportFrame = 0;
|
|
1231
|
+
const placing = () => store.get().mode === "placing";
|
|
1232
|
+
/**
|
|
1233
|
+
* Work out what sits under a viewport point and highlight it. Shared by both ways of aiming —
|
|
1234
|
+
* following a mouse, and dragging the reticle — so the two can never disagree.
|
|
1235
|
+
*/
|
|
1236
|
+
function probe(clientX, clientY) {
|
|
1237
|
+
const el = hitTest(doc, clientX, clientY, (hit) => hit === host);
|
|
1238
|
+
hover = el;
|
|
1239
|
+
if (el) reticle.snap(el.getBoundingClientRect(), targetLabel(el));
|
|
1240
|
+
else reticle.release();
|
|
1241
|
+
aim?.setLabel(el ? targetLabel(el) : "NOTHING UNDER THE PIN");
|
|
1242
|
+
}
|
|
1243
|
+
/**
|
|
1244
|
+
* Bring the drag-aim reticle up with placing mode, seeded mid-screen and already showing what
|
|
1245
|
+
* it is over — the first thing you see is a live target, not an empty crosshair.
|
|
1246
|
+
*/
|
|
1247
|
+
function syncAim(on) {
|
|
1248
|
+
if (!aim) return;
|
|
1249
|
+
if (!on || !needsDragAim(win)) {
|
|
1250
|
+
aim.hide();
|
|
1251
|
+
return;
|
|
1252
|
+
}
|
|
1253
|
+
if (aim.root.classList.contains("on")) {
|
|
1254
|
+
if (aim.point.x <= win.innerWidth && aim.point.y <= win.innerHeight) return;
|
|
1255
|
+
aim.show(Math.min(aim.point.x, win.innerWidth), Math.min(aim.point.y, win.innerHeight));
|
|
1256
|
+
return;
|
|
1257
|
+
}
|
|
1258
|
+
const { x, y } = startPoint(win);
|
|
1259
|
+
aim.show(x, y);
|
|
1260
|
+
probe(x, y);
|
|
1261
|
+
}
|
|
1262
|
+
/** Commit the pin the drag-aim reticle is sitting on. */
|
|
1263
|
+
function confirmAim() {
|
|
1264
|
+
if (!aim) return;
|
|
1265
|
+
probe(aim.point.x, aim.point.y);
|
|
1266
|
+
const el = hover ?? doc.body;
|
|
1267
|
+
const at = {
|
|
1268
|
+
x: aim.point.x + win.scrollX,
|
|
1269
|
+
y: aim.point.y + win.scrollY
|
|
1270
|
+
};
|
|
1271
|
+
store.place({
|
|
1272
|
+
target: captureTarget(el, { at }),
|
|
1273
|
+
placedAt: at
|
|
1274
|
+
});
|
|
1275
|
+
reticle.release();
|
|
1276
|
+
}
|
|
1277
|
+
function drawMarks() {
|
|
1278
|
+
renderMultiMarks(pinsLayer, extraTargets, {
|
|
1279
|
+
x: win.scrollX,
|
|
1280
|
+
y: win.scrollY
|
|
1281
|
+
});
|
|
1282
|
+
}
|
|
1283
|
+
function clearExtraTargets() {
|
|
1284
|
+
if (extraTargets.length === 0) return;
|
|
1285
|
+
extraTargets = [];
|
|
1286
|
+
drawMarks();
|
|
1287
|
+
}
|
|
1288
|
+
/** Placement click: capture the hovered target (or body) into a client-only draft. */
|
|
1289
|
+
function placeDraft(e) {
|
|
1290
|
+
e.preventDefault();
|
|
1291
|
+
e.stopPropagation();
|
|
1292
|
+
const capture = captureTarget(hover ?? doc.body, { at: {
|
|
1293
|
+
x: e.pageX,
|
|
1294
|
+
y: e.pageY
|
|
1295
|
+
} });
|
|
1296
|
+
if (extraTargets.length > 0) capture.target.targets = extraTargets;
|
|
1297
|
+
clearExtraTargets();
|
|
1298
|
+
store.place({
|
|
1299
|
+
target: capture,
|
|
1300
|
+
placedAt: {
|
|
1301
|
+
x: e.pageX,
|
|
1302
|
+
y: e.pageY
|
|
1303
|
+
}
|
|
1304
|
+
});
|
|
1305
|
+
reticle.release();
|
|
770
1306
|
}
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
1307
|
+
/** Shift+click while placing: capture WITHOUT committing; a numbered dashed outline is the receipt. */
|
|
1308
|
+
function accumulateTarget(e) {
|
|
1309
|
+
e.preventDefault();
|
|
1310
|
+
e.stopPropagation();
|
|
1311
|
+
extraTargets = [...extraTargets, captureTarget(hover ?? doc.body).target];
|
|
1312
|
+
drawMarks();
|
|
1313
|
+
}
|
|
1314
|
+
/**
|
|
1315
|
+
* Keep the drag-aim reticle honest while the viewport moves under it — one probe per frame,
|
|
1316
|
+
* not per event: momentum scrolling on a phone dispatches faster than frames.
|
|
1317
|
+
*/
|
|
1318
|
+
const onViewportChange = () => {
|
|
1319
|
+
if (!placing() || viewportFrame !== 0) return;
|
|
1320
|
+
viewportFrame = win.requestAnimationFrame(() => {
|
|
1321
|
+
viewportFrame = 0;
|
|
1322
|
+
if (!placing()) return;
|
|
1323
|
+
syncAim(true);
|
|
1324
|
+
if (aim?.root.classList.contains("on") === true) probe(aim.point.x, aim.point.y);
|
|
1325
|
+
});
|
|
1326
|
+
};
|
|
1327
|
+
const onMouseMove = (e) => {
|
|
1328
|
+
if (!placing()) return;
|
|
1329
|
+
reticle.move(e);
|
|
1330
|
+
probe(e.clientX, e.clientY);
|
|
1331
|
+
};
|
|
1332
|
+
function mountAim(shadow) {
|
|
1333
|
+
shadow.querySelector(".pb-aim")?.remove();
|
|
1334
|
+
aim = createAim(doc, {
|
|
1335
|
+
onAim: probe,
|
|
1336
|
+
onConfirm: confirmAim,
|
|
1337
|
+
onCancel: deps.onCancel
|
|
1338
|
+
});
|
|
1339
|
+
shadow.appendChild(aim.root);
|
|
778
1340
|
}
|
|
779
|
-
ui.puck.addEventListener("pointerdown", onPointerDown);
|
|
780
|
-
ui.puck.addEventListener("pointermove", onPointerMove);
|
|
781
|
-
ui.puck.addEventListener("pointerup", onPointerUp);
|
|
782
|
-
ui.puck.addEventListener("pointercancel", onPointerUp);
|
|
783
|
-
ui.puck.addEventListener("click", onClick);
|
|
784
|
-
ui.fan.addEventListener("click", onFanClick);
|
|
785
|
-
win.document.addEventListener("pointerdown", onDocPointerDown);
|
|
786
|
-
win.addEventListener("resize", onResize);
|
|
787
1341
|
return {
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
const apply = () => {
|
|
796
|
-
if (mode !== "bar") return;
|
|
797
|
-
const d = clampPos(dock ?? defaultDock());
|
|
798
|
-
bar.classList.add("pb-ghost");
|
|
799
|
-
placePuck(d.x, d.y);
|
|
800
|
-
ui.puck.classList.remove("pb-ghost");
|
|
801
|
-
mode = "puck";
|
|
802
|
-
host.onSettled(true, false);
|
|
803
|
-
};
|
|
804
|
-
if (host.reduced) apply();
|
|
805
|
-
else win.requestAnimationFrame(apply);
|
|
1342
|
+
outline: reticle.outline,
|
|
1343
|
+
crosshair: reticle.crosshair,
|
|
1344
|
+
connect(shadow) {
|
|
1345
|
+
if (aim === null) mountAim(shadow);
|
|
1346
|
+
doc.addEventListener("mousemove", onMouseMove);
|
|
1347
|
+
win.addEventListener("scroll", onViewportChange, { passive: true });
|
|
1348
|
+
win.addEventListener("resize", onViewportChange);
|
|
806
1349
|
},
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
1350
|
+
disconnect() {
|
|
1351
|
+
doc.removeEventListener("mousemove", onMouseMove);
|
|
1352
|
+
win.removeEventListener("scroll", onViewportChange);
|
|
1353
|
+
win.removeEventListener("resize", onViewportChange);
|
|
1354
|
+
if (viewportFrame !== 0) win.cancelAnimationFrame(viewportFrame);
|
|
1355
|
+
viewportFrame = 0;
|
|
1356
|
+
aim?.destroy();
|
|
1357
|
+
aim = null;
|
|
1358
|
+
},
|
|
1359
|
+
render(on) {
|
|
1360
|
+
if (!on) clearExtraTargets();
|
|
1361
|
+
if (!on) reticle.release();
|
|
1362
|
+
if (on && extraTargets.length > 0) drawMarks();
|
|
1363
|
+
syncAim(on);
|
|
1364
|
+
},
|
|
1365
|
+
handleClick(e) {
|
|
1366
|
+
if (needsDragAim(win)) return;
|
|
1367
|
+
if (e.shiftKey) accumulateTarget(e);
|
|
1368
|
+
else placeDraft(e);
|
|
825
1369
|
}
|
|
826
1370
|
};
|
|
827
1371
|
}
|
|
828
1372
|
//#endregion
|
|
829
1373
|
//#region src/screenshot.ts
|
|
830
|
-
const WEBP_QUALITY = .7;
|
|
831
|
-
const PLACEHOLDER_MAX = 32;
|
|
1374
|
+
const WEBP_QUALITY$1 = .7;
|
|
1375
|
+
const PLACEHOLDER_MAX$1 = 32;
|
|
832
1376
|
/** Element rect clamped to the viewport (CSS px); null when nothing is visible. */
|
|
833
1377
|
function visibleCropRect(el) {
|
|
834
1378
|
const win = el.ownerDocument.defaultView;
|
|
@@ -909,12 +1453,12 @@ var Pinbox = (function(exports) {
|
|
|
909
1453
|
const image = {
|
|
910
1454
|
blob: await canvas.convertToBlob({
|
|
911
1455
|
type: "image/webp",
|
|
912
|
-
quality: WEBP_QUALITY
|
|
1456
|
+
quality: WEBP_QUALITY$1
|
|
913
1457
|
}),
|
|
914
1458
|
width: bmp.width,
|
|
915
1459
|
height: bmp.height
|
|
916
1460
|
};
|
|
917
|
-
const scale = PLACEHOLDER_MAX / Math.max(bmp.width, bmp.height);
|
|
1461
|
+
const scale = PLACEHOLDER_MAX$1 / Math.max(bmp.width, bmp.height);
|
|
918
1462
|
const tw = Math.max(1, Math.round(bmp.width * Math.min(scale, 1)));
|
|
919
1463
|
const th = Math.max(1, Math.round(bmp.height * Math.min(scale, 1)));
|
|
920
1464
|
const thumb = new OffscreenCanvas(tw, th);
|
|
@@ -973,7 +1517,137 @@ var Pinbox = (function(exports) {
|
|
|
973
1517
|
return body.data.attachment;
|
|
974
1518
|
}
|
|
975
1519
|
//#endregion
|
|
976
|
-
//#region src/
|
|
1520
|
+
//#region src/screenshot-dom.ts
|
|
1521
|
+
const MAX_NODES = 1500;
|
|
1522
|
+
const MAX_EDGE = 1600;
|
|
1523
|
+
const WEBP_QUALITY = .7;
|
|
1524
|
+
const PLACEHOLDER_MAX = 32;
|
|
1525
|
+
/** Elements SVG-as-image cannot render (external resources) — replaced by a same-size box. */
|
|
1526
|
+
const REPLACED = /* @__PURE__ */ new Set([
|
|
1527
|
+
"IMG",
|
|
1528
|
+
"VIDEO",
|
|
1529
|
+
"CANVAS",
|
|
1530
|
+
"IFRAME",
|
|
1531
|
+
"OBJECT",
|
|
1532
|
+
"EMBED",
|
|
1533
|
+
"PICTURE"
|
|
1534
|
+
]);
|
|
1535
|
+
function realmOf(el) {
|
|
1536
|
+
return el.ownerDocument.defaultView;
|
|
1537
|
+
}
|
|
1538
|
+
/** Copy every computed property onto the clone so it renders without the page's stylesheets. */
|
|
1539
|
+
function inlineStyles(win, source, clone) {
|
|
1540
|
+
const cs = win.getComputedStyle(source);
|
|
1541
|
+
const style = clone.style;
|
|
1542
|
+
if (style === void 0) return;
|
|
1543
|
+
let css = "";
|
|
1544
|
+
for (let i = 0; i < cs.length; i += 1) {
|
|
1545
|
+
const prop = cs[i];
|
|
1546
|
+
if (prop === void 0) continue;
|
|
1547
|
+
css += `${prop}:${cs.getPropertyValue(prop)};`;
|
|
1548
|
+
}
|
|
1549
|
+
style.cssText = css;
|
|
1550
|
+
}
|
|
1551
|
+
/**
|
|
1552
|
+
* A same-size neutral box in place of an element the snapshot cannot draw. Keeps the layout
|
|
1553
|
+
* honest (the agent sees where the image sits) without a tainted or empty canvas.
|
|
1554
|
+
*/
|
|
1555
|
+
function placeholderFor(doc, source) {
|
|
1556
|
+
const r = source.getBoundingClientRect();
|
|
1557
|
+
const box = doc.createElement("span");
|
|
1558
|
+
box.style.cssText = `display:inline-block;width:${r.width}px;height:${r.height}px;background:rgba(127,127,127,.25);border-radius:4px;vertical-align:top;`;
|
|
1559
|
+
return box;
|
|
1560
|
+
}
|
|
1561
|
+
/**
|
|
1562
|
+
* Deep-clone `root` with computed styles inlined and un-renderable elements replaced.
|
|
1563
|
+
* Returns null when the subtree is too large to snapshot responsively.
|
|
1564
|
+
*/
|
|
1565
|
+
function cloneForSnapshot(root) {
|
|
1566
|
+
const doc = root.ownerDocument;
|
|
1567
|
+
const win = doc.defaultView;
|
|
1568
|
+
if (win === null) return null;
|
|
1569
|
+
const sources = [root, ...root.querySelectorAll("*")];
|
|
1570
|
+
if (sources.length > MAX_NODES) return null;
|
|
1571
|
+
const clone = root.cloneNode(true);
|
|
1572
|
+
const clones = [clone, ...clone.querySelectorAll("*")];
|
|
1573
|
+
for (let i = 0; i < sources.length; i += 1) {
|
|
1574
|
+
const source = sources[i];
|
|
1575
|
+
const target = clones[i];
|
|
1576
|
+
if (target === void 0) break;
|
|
1577
|
+
const keepDataImage = source.tagName === "IMG" && (source.getAttribute("src") ?? "").startsWith("data:");
|
|
1578
|
+
if (REPLACED.has(source.tagName) && !keepDataImage) {
|
|
1579
|
+
target.replaceWith(placeholderFor(doc, source));
|
|
1580
|
+
continue;
|
|
1581
|
+
}
|
|
1582
|
+
inlineStyles(win, source, target);
|
|
1583
|
+
}
|
|
1584
|
+
for (const script of clone.querySelectorAll("script")) script.remove();
|
|
1585
|
+
return clone;
|
|
1586
|
+
}
|
|
1587
|
+
/** The SVG document that renders `clone` at the element's size. */
|
|
1588
|
+
function svgFor(clone, width, height) {
|
|
1589
|
+
return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}"><foreignObject width="100%" height="100%"><div xmlns="http://www.w3.org/1999/xhtml">${new ((realmOf(clone)?.XMLSerializer) ?? XMLSerializer)().serializeToString(clone)}</div></foreignObject></svg>`;
|
|
1590
|
+
}
|
|
1591
|
+
function loadImage(ImageCtor, url) {
|
|
1592
|
+
return new Promise((resolve, reject) => {
|
|
1593
|
+
const img = new ImageCtor();
|
|
1594
|
+
img.onload = () => resolve(img);
|
|
1595
|
+
img.onerror = () => reject(/* @__PURE__ */ new Error("snapshot image failed to load"));
|
|
1596
|
+
img.src = url;
|
|
1597
|
+
});
|
|
1598
|
+
}
|
|
1599
|
+
function toBlob(canvas, type, quality) {
|
|
1600
|
+
return new Promise((resolve, reject) => {
|
|
1601
|
+
canvas.toBlob((b) => b === null ? reject(/* @__PURE__ */ new Error("toBlob")) : resolve(b), type, quality);
|
|
1602
|
+
});
|
|
1603
|
+
}
|
|
1604
|
+
async function rasterize(win, img, width, height) {
|
|
1605
|
+
const scale = Math.min(win.devicePixelRatio || 1, 2, MAX_EDGE / Math.max(width, height));
|
|
1606
|
+
const canvas = win.document.createElement("canvas");
|
|
1607
|
+
canvas.width = Math.max(1, Math.round(width * scale));
|
|
1608
|
+
canvas.height = Math.max(1, Math.round(height * scale));
|
|
1609
|
+
const ctx = canvas.getContext("2d");
|
|
1610
|
+
if (ctx === null) throw new Error("no 2d context");
|
|
1611
|
+
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
|
|
1612
|
+
const image = {
|
|
1613
|
+
blob: await toBlob(canvas, "image/webp", WEBP_QUALITY),
|
|
1614
|
+
width: canvas.width,
|
|
1615
|
+
height: canvas.height
|
|
1616
|
+
};
|
|
1617
|
+
const t = PLACEHOLDER_MAX / Math.max(canvas.width, canvas.height);
|
|
1618
|
+
const thumb = win.document.createElement("canvas");
|
|
1619
|
+
thumb.width = Math.max(1, Math.round(canvas.width * Math.min(t, 1)));
|
|
1620
|
+
thumb.height = Math.max(1, Math.round(canvas.height * Math.min(t, 1)));
|
|
1621
|
+
thumb.getContext("2d")?.drawImage(canvas, 0, 0, thumb.width, thumb.height);
|
|
1622
|
+
image.placeholder = `data:image/webp;base64,${toBase64(await (await toBlob(thumb, "image/webp", .5)).arrayBuffer())}`;
|
|
1623
|
+
return image;
|
|
1624
|
+
}
|
|
1625
|
+
/**
|
|
1626
|
+
* Snapshot `el` without any permission prompt. Resolves null whenever the environment cannot
|
|
1627
|
+
* (no canvas/Image, zero-size element, subtree too large, a tainted canvas) — never throws.
|
|
1628
|
+
*/
|
|
1629
|
+
async function captureElementDom(el) {
|
|
1630
|
+
const win = realmOf(el);
|
|
1631
|
+
if (win === null || typeof win.Image !== "function" || typeof win.XMLSerializer !== "function") return null;
|
|
1632
|
+
const ImageCtor = win.Image;
|
|
1633
|
+
const r = el.getBoundingClientRect();
|
|
1634
|
+
const width = Math.ceil(r.width);
|
|
1635
|
+
const height = Math.ceil(r.height);
|
|
1636
|
+
if (width < 1 || height < 1) return null;
|
|
1637
|
+
const clone = cloneForSnapshot(el);
|
|
1638
|
+
if (clone === null) return null;
|
|
1639
|
+
const svg = svgFor(clone, width, height);
|
|
1640
|
+
const url = URL.createObjectURL(new Blob([svg], { type: "image/svg+xml;charset=utf-8" }));
|
|
1641
|
+
try {
|
|
1642
|
+
return await rasterize(win, await loadImage(ImageCtor, url), width, height);
|
|
1643
|
+
} catch {
|
|
1644
|
+
return null;
|
|
1645
|
+
} finally {
|
|
1646
|
+
URL.revokeObjectURL(url);
|
|
1647
|
+
}
|
|
1648
|
+
}
|
|
1649
|
+
/** A session seen more recently than this counts as an agent that is listening. */
|
|
1650
|
+
const AGENT_LIVE_MS = 9e5;
|
|
977
1651
|
function initialState() {
|
|
978
1652
|
return {
|
|
979
1653
|
pins: [],
|
|
@@ -985,7 +1659,10 @@ var Pinbox = (function(exports) {
|
|
|
985
1659
|
connection: "connecting",
|
|
986
1660
|
queuedIds: /* @__PURE__ */ new Set(),
|
|
987
1661
|
minimized: false,
|
|
988
|
-
pinsHidden: false
|
|
1662
|
+
pinsHidden: false,
|
|
1663
|
+
clock: 0,
|
|
1664
|
+
agentLive: null,
|
|
1665
|
+
captureMode: "dom"
|
|
989
1666
|
};
|
|
990
1667
|
}
|
|
991
1668
|
function createStore() {
|
|
@@ -1061,20 +1738,60 @@ var Pinbox = (function(exports) {
|
|
|
1061
1738
|
if (typeof pin?.id !== "string") return;
|
|
1062
1739
|
upsertPin(store, pin);
|
|
1063
1740
|
}
|
|
1741
|
+
/** Unknown clock, unknown agent — the pre-clock derivation. */
|
|
1742
|
+
const NO_VIEW = {
|
|
1743
|
+
clock: 0,
|
|
1744
|
+
agentLive: null
|
|
1745
|
+
};
|
|
1746
|
+
/** When the human last spoke on this pin: the last message if it is theirs, else the pin itself. */
|
|
1747
|
+
function lastHumanAt(pin, thread) {
|
|
1748
|
+
const last = thread[thread.length - 1];
|
|
1749
|
+
return Date.parse(last === void 0 ? pin.createdAt : last.at);
|
|
1750
|
+
}
|
|
1751
|
+
/**
|
|
1752
|
+
* How long the agent has owed a reply, as a state the card can draw.
|
|
1753
|
+
*
|
|
1754
|
+
* Dogfood: a THINKING row spun for two weeks on a pin nobody ever answered, because the row
|
|
1755
|
+
* meant only "the last word is yours". Now that means thinking for 90 s, then a quiet WAITING
|
|
1756
|
+
* FOR AGENT, then NO RESPONSE after ten minutes — or after those 90 s when the hub reports no
|
|
1757
|
+
* agent listening at all. "none" for anything not owed a reply. A 0 clock never goes stale.
|
|
1758
|
+
*/
|
|
1759
|
+
function pendingKind(pin, thread, view = NO_VIEW) {
|
|
1760
|
+
if (pin.status !== "open" || pin.kind === "comment") return "none";
|
|
1761
|
+
const last = thread[thread.length - 1];
|
|
1762
|
+
if (last !== void 0 && last.role !== "human") return "none";
|
|
1763
|
+
if (view.clock === 0) return "thinking";
|
|
1764
|
+
const age = view.clock - lastHumanAt(pin, thread);
|
|
1765
|
+
if (age < 9e4) return "thinking";
|
|
1766
|
+
if (view.agentLive === false || age >= 6e5) return "stale";
|
|
1767
|
+
return "waiting";
|
|
1768
|
+
}
|
|
1064
1769
|
/**
|
|
1065
1770
|
* Wire-status → UI-status mapping:
|
|
1066
1771
|
* resolved + no verification ⇒ "verify" (accept/reopen prompt);
|
|
1067
1772
|
* resolved + verification ⇒ "resolved";
|
|
1773
|
+
* open comment pin ⇒ "note" (nobody owes a reply — never "waiting");
|
|
1774
|
+
* open + reply owed too long (pendingKind "stale") ⇒ "stale";
|
|
1068
1775
|
* open + empty thread or last message human ⇒ "waiting";
|
|
1069
1776
|
* open + last message agent|mirror ⇒ "replied".
|
|
1070
1777
|
* The prototype's WORKING/APPLIED chips need an event vocabulary the hub does not emit — excluded here.
|
|
1071
1778
|
*/
|
|
1072
|
-
function deriveUiStatus(pin, thread) {
|
|
1779
|
+
function deriveUiStatus(pin, thread, view = NO_VIEW) {
|
|
1073
1780
|
if (pin.status === "resolved") return pin.verification ? "resolved" : "verify";
|
|
1074
|
-
|
|
1075
|
-
|
|
1781
|
+
if (pin.kind === "comment") return "note";
|
|
1782
|
+
const pending = pendingKind(pin, thread, view);
|
|
1783
|
+
if (pending === "stale") return "stale";
|
|
1784
|
+
if (pending !== "none") return "waiting";
|
|
1076
1785
|
return "replied";
|
|
1077
1786
|
}
|
|
1787
|
+
/** Is anyone listening? A session not ended and seen within AGENT_LIVE_MS. */
|
|
1788
|
+
function agentIsLive(sessions, now) {
|
|
1789
|
+
return sessions.some((s) => s.endedAt === void 0 && now - Date.parse(s.lastSeenAt) < AGENT_LIVE_MS);
|
|
1790
|
+
}
|
|
1791
|
+
/** Open pins that ask something of someone — comment pins are remarks, so the badges skip them. */
|
|
1792
|
+
function openTaskCount(pins) {
|
|
1793
|
+
return pins.filter((p) => p.status !== "resolved" && p.kind !== "comment").length;
|
|
1794
|
+
}
|
|
1078
1795
|
//#endregion
|
|
1079
1796
|
//#region src/transport/mirror.ts
|
|
1080
1797
|
function randomBase36(length) {
|
|
@@ -1232,6 +1949,10 @@ var Pinbox = (function(exports) {
|
|
|
1232
1949
|
listPins() {
|
|
1233
1950
|
return this.#request("GET", "/pins");
|
|
1234
1951
|
}
|
|
1952
|
+
/** Agent sessions the hub knows, most recently seen first (routes-sessions.ts). */
|
|
1953
|
+
listSessions() {
|
|
1954
|
+
return this.#request("GET", "/sessions");
|
|
1955
|
+
}
|
|
1235
1956
|
createPin(input) {
|
|
1236
1957
|
return this.#request("POST", "/pins", input);
|
|
1237
1958
|
}
|
|
@@ -1310,6 +2031,8 @@ var Pinbox = (function(exports) {
|
|
|
1310
2031
|
#timer = null;
|
|
1311
2032
|
/** One flush at a time — reconnect and the live write path both drain the outbox. */
|
|
1312
2033
|
#flushing = false;
|
|
2034
|
+
/** Bumped per `GET /sessions`; only the newest request's snapshot reaches `onSessions`. */
|
|
2035
|
+
#sessionsGen = 0;
|
|
1313
2036
|
constructor(opts) {
|
|
1314
2037
|
this.#opts = opts;
|
|
1315
2038
|
const storage = opts.storage ?? globalThis.localStorage ?? memoryStorage();
|
|
@@ -1515,6 +2238,7 @@ var Pinbox = (function(exports) {
|
|
|
1515
2238
|
}
|
|
1516
2239
|
/** Refresh listPins (hub wins on status) → flush the outbox (client wins on new pins) → persist the fresh mirror. */
|
|
1517
2240
|
async #reconcile() {
|
|
2241
|
+
this.#refreshSessions();
|
|
1518
2242
|
try {
|
|
1519
2243
|
const pins = await this.#snapshotPins();
|
|
1520
2244
|
if (pins !== null) this.#opts.onPins?.(pins);
|
|
@@ -1541,6 +2265,18 @@ var Pinbox = (function(exports) {
|
|
|
1541
2265
|
}
|
|
1542
2266
|
return null;
|
|
1543
2267
|
}
|
|
2268
|
+
/** Best-effort: a hub without the route (or unreachable) simply leaves liveness unknown.
|
|
2269
|
+
* Reconnects can overlap; a slow older GET must not overwrite a newer snapshot, so only
|
|
2270
|
+
* the latest request applies. */
|
|
2271
|
+
async #refreshSessions() {
|
|
2272
|
+
if (this.#opts.onSessions === void 0) return;
|
|
2273
|
+
this.#sessionsGen += 1;
|
|
2274
|
+
const gen = this.#sessionsGen;
|
|
2275
|
+
try {
|
|
2276
|
+
const sessions = await this.#rest.listSessions();
|
|
2277
|
+
if (gen === this.#sessionsGen) this.#opts.onSessions(sessions);
|
|
2278
|
+
} catch {}
|
|
2279
|
+
}
|
|
1544
2280
|
async #flushOutbox() {
|
|
1545
2281
|
if (this.#flushing) return [];
|
|
1546
2282
|
this.#flushing = true;
|
|
@@ -1563,155 +2299,132 @@ var Pinbox = (function(exports) {
|
|
|
1563
2299
|
}
|
|
1564
2300
|
};
|
|
1565
2301
|
//#endregion
|
|
1566
|
-
//#region src/ui/aim.ts
|
|
1567
|
-
/**
|
|
1568
|
-
* True when aiming has to be done by dragging rather than by pointing.
|
|
1569
|
-
*
|
|
1570
|
-
* Two independent reasons, either of which is sufficient. A coarse pointer means there is no
|
|
1571
|
-
* hover to follow at all — the mouse crosshair cannot work, whatever the screen size. The width
|
|
1572
|
-
* check is the design's own rule (720px) and catches the case a media query cannot: a device that
|
|
1573
|
-
* reports a fine pointer but is being used at phone width.
|
|
1574
|
-
*/
|
|
1575
|
-
function needsDragAim(win) {
|
|
1576
|
-
return win.matchMedia?.("(pointer: coarse)").matches === true || win.innerWidth < 720;
|
|
1577
|
-
}
|
|
1578
|
-
/**
|
|
1579
|
-
* Where the reticle starts.
|
|
1580
|
-
*
|
|
1581
|
-
* Slightly above centre: the confirm bar owns the bottom of the screen, and a reticle that opens
|
|
1582
|
-
* underneath your own thumb is one you have to move before you can even see it.
|
|
1583
|
-
*/
|
|
1584
|
-
function startPoint(win) {
|
|
1585
|
-
return {
|
|
1586
|
-
x: win.innerWidth / 2,
|
|
1587
|
-
y: win.innerHeight * .42
|
|
1588
|
-
};
|
|
1589
|
-
}
|
|
1590
|
-
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>";
|
|
1591
|
-
function createAim(doc, handlers) {
|
|
1592
|
-
const win = doc.defaultView;
|
|
1593
|
-
const root = doc.createElement("div");
|
|
1594
|
-
root.className = "pb-aim";
|
|
1595
|
-
root.innerHTML = MARKUP;
|
|
1596
|
-
const h = root.querySelector(".h");
|
|
1597
|
-
const v = root.querySelector(".v");
|
|
1598
|
-
const grip = root.querySelector(".grip");
|
|
1599
|
-
const label = root.querySelector(".lab");
|
|
1600
|
-
const point = {
|
|
1601
|
-
x: 0,
|
|
1602
|
-
y: 0
|
|
1603
|
-
};
|
|
1604
|
-
/** Grab offset, so the reticle does not jump to your fingertip when you take hold of it. */
|
|
1605
|
-
let grab = null;
|
|
1606
|
-
function put(x, y) {
|
|
1607
|
-
point.x = Math.max(0, Math.min(win.innerWidth, x));
|
|
1608
|
-
point.y = Math.max(0, Math.min(win.innerHeight, y));
|
|
1609
|
-
h.style.top = `${point.y}px`;
|
|
1610
|
-
v.style.left = `${point.x}px`;
|
|
1611
|
-
grip.style.left = `${point.x}px`;
|
|
1612
|
-
grip.style.top = `${point.y}px`;
|
|
1613
|
-
}
|
|
1614
|
-
const onPointerMove = (e) => {
|
|
1615
|
-
if (grab === null) return;
|
|
1616
|
-
e.preventDefault();
|
|
1617
|
-
put(e.clientX + grab.dx, e.clientY + grab.dy);
|
|
1618
|
-
handlers.onAim(point.x, point.y);
|
|
1619
|
-
};
|
|
1620
|
-
const onPointerUp = () => {
|
|
1621
|
-
grab = null;
|
|
1622
|
-
};
|
|
1623
|
-
grip.addEventListener("pointerdown", (e) => {
|
|
1624
|
-
e.preventDefault();
|
|
1625
|
-
e.stopPropagation();
|
|
1626
|
-
grab = {
|
|
1627
|
-
dx: point.x - e.clientX,
|
|
1628
|
-
dy: point.y - e.clientY
|
|
1629
|
-
};
|
|
1630
|
-
grip.setPointerCapture?.(e.pointerId);
|
|
1631
|
-
});
|
|
1632
|
-
grip.addEventListener("keydown", (e) => {
|
|
1633
|
-
const step = e.shiftKey ? 20 : 2;
|
|
1634
|
-
const delta = {
|
|
1635
|
-
ArrowLeft: [-step, 0],
|
|
1636
|
-
ArrowRight: [step, 0],
|
|
1637
|
-
ArrowUp: [0, -step],
|
|
1638
|
-
ArrowDown: [0, step]
|
|
1639
|
-
}[e.key];
|
|
1640
|
-
if (!delta) return;
|
|
1641
|
-
e.preventDefault();
|
|
1642
|
-
put(point.x + delta[0], point.y + delta[1]);
|
|
1643
|
-
handlers.onAim(point.x, point.y);
|
|
1644
|
-
});
|
|
1645
|
-
root.addEventListener("click", (e) => {
|
|
1646
|
-
const action = e.target.closest?.("[data-aim]")?.getAttribute("data-aim");
|
|
1647
|
-
if (!action) return;
|
|
1648
|
-
e.preventDefault();
|
|
1649
|
-
e.stopPropagation();
|
|
1650
|
-
if (action === "confirm") handlers.onConfirm();
|
|
1651
|
-
else handlers.onCancel();
|
|
1652
|
-
});
|
|
1653
|
-
win.addEventListener("pointermove", onPointerMove, { passive: false });
|
|
1654
|
-
win.addEventListener("pointerup", onPointerUp);
|
|
1655
|
-
win.addEventListener("pointercancel", onPointerUp);
|
|
1656
|
-
return {
|
|
1657
|
-
root,
|
|
1658
|
-
point,
|
|
1659
|
-
show(x, y) {
|
|
1660
|
-
put(x, y);
|
|
1661
|
-
root.classList.add("on");
|
|
1662
|
-
},
|
|
1663
|
-
hide() {
|
|
1664
|
-
grab = null;
|
|
1665
|
-
root.classList.remove("on");
|
|
1666
|
-
},
|
|
1667
|
-
setLabel(text) {
|
|
1668
|
-
label.textContent = text;
|
|
1669
|
-
},
|
|
1670
|
-
destroy() {
|
|
1671
|
-
win.removeEventListener("pointermove", onPointerMove);
|
|
1672
|
-
win.removeEventListener("pointerup", onPointerUp);
|
|
1673
|
-
win.removeEventListener("pointercancel", onPointerUp);
|
|
1674
|
-
}
|
|
1675
|
-
};
|
|
1676
|
-
}
|
|
1677
|
-
//#endregion
|
|
1678
2302
|
//#region src/ui/bar.ts
|
|
1679
|
-
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>";
|
|
1680
|
-
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>";
|
|
1681
|
-
const THEME_ICON = "<svg width=\"14\" height=\"14\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.4\"><path d=\"M8 2a4 4 0 0 0 6 6 6 6 0 1 1-6-6z\"/></svg>";
|
|
1682
|
-
const COPY_ICON$1 = "<svg width=\"14\" height=\"14\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.4\"><rect x=\"5.5\" y=\"5.5\" width=\"8\" height=\"8\" rx=\"1\"/><path d=\"M10.5 3.5v-1a1 1 0 00-1-1h-6a1 1 0 00-1 1v6a1 1 0 001 1h1\"/></svg>";
|
|
1683
2303
|
const IDENT_ICON = "<svg width=\"15\" height=\"15\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"var(--pb-amber)\" stroke-width=\"1.4\"><rect x=\"2.5\" y=\"1.5\" width=\"11\" height=\"7\" rx=\"1\"/><path d=\"M8 8.5v6\"/><circle cx=\"8\" cy=\"14.6\" r=\".9\" fill=\"var(--pb-amber)\" stroke=\"none\"/></svg>";
|
|
1684
|
-
const
|
|
2304
|
+
const GRIP_ICON = "<svg width=\"6\" height=\"14\" viewBox=\"0 0 6 14\" fill=\"currentColor\"><circle cx=\"1.5\" cy=\"2\" r=\"1.1\"/><circle cx=\"4.5\" cy=\"2\" r=\"1.1\"/><circle cx=\"1.5\" cy=\"7\" r=\"1.1\"/><circle cx=\"4.5\" cy=\"7\" r=\"1.1\"/><circle cx=\"1.5\" cy=\"12\" r=\"1.1\"/><circle cx=\"4.5\" cy=\"12\" r=\"1.1\"/></svg>";
|
|
1685
2305
|
const CONNECTION_LABEL = {
|
|
1686
2306
|
connecting: "PINBOX",
|
|
1687
2307
|
live: "PINBOX",
|
|
1688
2308
|
offline: "PINBOX · OFFLINE",
|
|
1689
2309
|
incompatible: "PINBOX · UPDATE NEEDED"
|
|
1690
2310
|
};
|
|
2311
|
+
/** `data-ref` per action: the element focuses "min" after a keyboard restore. */
|
|
2312
|
+
function refOf(a) {
|
|
2313
|
+
return a.id === "minimize" ? "min" : a.id;
|
|
2314
|
+
}
|
|
2315
|
+
function buttonHtml(a) {
|
|
2316
|
+
const bar = a.bar;
|
|
2317
|
+
const glyph = a.glyph === void 0 ? "" : icon(a.glyph, 14);
|
|
2318
|
+
const body = bar.count ? `${glyph}<span data-ref="count">0</span>` : `${glyph}${bar.text ?? ""}`;
|
|
2319
|
+
const square = bar.text === void 0 && !bar.count;
|
|
2320
|
+
const aria = bar.ariaLabel === void 0 ? "" : ` aria-label="${bar.ariaLabel}"`;
|
|
2321
|
+
return `<button type="button" class="pb-tb${square ? " sq" : ""}" data-ref="${refOf(a)}" title="${titleOf(a)}"${aria}>${a.glyph === void 0 ? a.key ?? "" : body}</button>`;
|
|
2322
|
+
}
|
|
1691
2323
|
function createBar(doc, on) {
|
|
1692
2324
|
const root = doc.createElement("div");
|
|
1693
2325
|
root.className = "pb-bar";
|
|
1694
|
-
|
|
2326
|
+
const wide = ACTIONS.filter((a) => a.bar !== void 0 && (a.bar.text !== void 0 || a.bar.count));
|
|
2327
|
+
const squares = ACTIONS.filter((a) => a.bar !== void 0 && !wide.includes(a));
|
|
2328
|
+
root.innerHTML = `<div class="armed-ring"></div><div class="grip" data-ref="grip" title="Drag to move · double-click to reset">${GRIP_ICON}</div><div class="ident">${IDENT_ICON}<span class="bl" data-ref="label">PINBOX</span></div><div class="div"></div>` + wide.map(buttonHtml).join("") + "<div class=\"div\" style=\"margin:0 3px\"></div>" + squares.map(buttonHtml).join("");
|
|
1695
2329
|
const ref = (name) => root.querySelector(`[data-ref="${name}"]`);
|
|
1696
2330
|
const label = ref("label");
|
|
1697
2331
|
const pinBtn = ref("pin");
|
|
1698
2332
|
const inboxBtn = ref("inbox");
|
|
2333
|
+
const hideBtn = ref("hide");
|
|
2334
|
+
const captureBtn = ref("capture");
|
|
1699
2335
|
const count = ref("count");
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
|
|
2336
|
+
for (const a of [...wide, ...squares]) ref(refOf(a)).addEventListener("click", (e) => on.onAction(a.id, e.detail === 0));
|
|
2337
|
+
/** Last-rendered hide state; the glyph is swapped only on change. */
|
|
2338
|
+
let hideShown = null;
|
|
2339
|
+
return {
|
|
2340
|
+
root,
|
|
2341
|
+
grip: ref("grip"),
|
|
2342
|
+
update(state) {
|
|
2343
|
+
const text = state.mode === "placing" ? "CLICK TO PIN" : CONNECTION_LABEL[state.connection];
|
|
2344
|
+
if (label.textContent !== text) label.textContent = text;
|
|
2345
|
+
pinBtn.classList.toggle("hot", state.mode === "placing");
|
|
2346
|
+
inboxBtn.classList.toggle("lit", state.inboxOpen);
|
|
2347
|
+
const open = String(openTaskCount(state.pins));
|
|
2348
|
+
if (count.textContent !== open) count.textContent = open;
|
|
2349
|
+
captureBtn.classList.toggle("lit", state.captureMode === "tab");
|
|
2350
|
+
captureBtn.title = state.captureMode === "tab" ? "Tab capture on — real pixels, Chrome asks once per page load (S)" : "Screenshots: DOM snapshot, no prompt — press for tab capture (S)";
|
|
2351
|
+
if (hideShown !== state.pinsHidden) {
|
|
2352
|
+
hideShown = state.pinsHidden;
|
|
2353
|
+
hideBtn.innerHTML = icon(state.pinsHidden ? EYE_GLYPH : EYE_OFF_GLYPH, 14);
|
|
2354
|
+
hideBtn.classList.toggle("lit", state.pinsHidden);
|
|
2355
|
+
hideBtn.title = state.pinsHidden ? "Show pins (H)" : "Hide pins (H)";
|
|
2356
|
+
}
|
|
2357
|
+
}
|
|
2358
|
+
};
|
|
2359
|
+
}
|
|
2360
|
+
//#endregion
|
|
2361
|
+
//#region src/ui/bar-drag.ts
|
|
2362
|
+
const MARGIN = 16;
|
|
2363
|
+
function createBarDrag(host) {
|
|
2364
|
+
const { win, bar, grip } = host;
|
|
2365
|
+
const key = `${host.storagePrefix}:bar`;
|
|
2366
|
+
let pos = readPoint(host.storage, key);
|
|
2367
|
+
/** Where the bar was when a press began, so a tap-sized wobble snaps back. */
|
|
2368
|
+
let before = null;
|
|
2369
|
+
function persist() {
|
|
2370
|
+
try {
|
|
2371
|
+
if (pos === null) host.storage?.removeItem(key);
|
|
2372
|
+
else host.storage?.setItem(key, JSON.stringify(pos));
|
|
2373
|
+
} catch {}
|
|
2374
|
+
}
|
|
2375
|
+
function box() {
|
|
2376
|
+
const r = bar.getBoundingClientRect();
|
|
2377
|
+
return {
|
|
2378
|
+
w: r.width,
|
|
2379
|
+
h: r.height
|
|
2380
|
+
};
|
|
2381
|
+
}
|
|
2382
|
+
function apply(p) {
|
|
2383
|
+
pos = p;
|
|
2384
|
+
bar.classList.toggle("free", p !== null);
|
|
2385
|
+
bar.style.left = p === null ? "" : `${p.x}px`;
|
|
2386
|
+
bar.style.top = p === null ? "" : `${p.y}px`;
|
|
2387
|
+
}
|
|
2388
|
+
const drag = attachDrag(grip, {
|
|
2389
|
+
origin: () => {
|
|
2390
|
+
if (bar.classList.contains("pb-ghost")) return null;
|
|
2391
|
+
const r = bar.getBoundingClientRect();
|
|
2392
|
+
return {
|
|
2393
|
+
x: r.left,
|
|
2394
|
+
y: r.top
|
|
2395
|
+
};
|
|
2396
|
+
},
|
|
2397
|
+
canStart: () => !bar.classList.contains("pb-ghost"),
|
|
2398
|
+
onStart() {
|
|
2399
|
+
before = pos;
|
|
2400
|
+
bar.classList.add("dragging");
|
|
2401
|
+
},
|
|
2402
|
+
onMove(next) {
|
|
2403
|
+
apply(clampToViewport(next, win, box(), MARGIN));
|
|
2404
|
+
},
|
|
2405
|
+
onEnd({ dragged, started }) {
|
|
2406
|
+
bar.classList.remove("dragging");
|
|
2407
|
+
if (dragged) persist();
|
|
2408
|
+
else if (started) apply(before);
|
|
2409
|
+
}
|
|
2410
|
+
});
|
|
2411
|
+
function reset() {
|
|
2412
|
+
apply(null);
|
|
2413
|
+
persist();
|
|
2414
|
+
}
|
|
2415
|
+
function onResize() {
|
|
2416
|
+
if (pos !== null) apply(clampToViewport(pos, win, box(), MARGIN));
|
|
2417
|
+
}
|
|
2418
|
+
grip.addEventListener("dblclick", reset);
|
|
2419
|
+
win.addEventListener("resize", onResize);
|
|
2420
|
+
if (pos !== null) apply(clampToViewport(pos, win, box(), MARGIN));
|
|
1706
2421
|
return {
|
|
1707
|
-
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
const open = String(state.pins.filter((p) => p.status !== "resolved").length);
|
|
1714
|
-
if (count.textContent !== open) count.textContent = open;
|
|
2422
|
+
position: () => pos,
|
|
2423
|
+
reset,
|
|
2424
|
+
destroy() {
|
|
2425
|
+
drag.destroy();
|
|
2426
|
+
grip.removeEventListener("dblclick", reset);
|
|
2427
|
+
win.removeEventListener("resize", onResize);
|
|
1715
2428
|
}
|
|
1716
2429
|
};
|
|
1717
2430
|
}
|
|
@@ -1745,6 +2458,168 @@ var Pinbox = (function(exports) {
|
|
|
1745
2458
|
return String(n).padStart(2, "0");
|
|
1746
2459
|
}
|
|
1747
2460
|
//#endregion
|
|
2461
|
+
//#region src/ui/card-messages.ts
|
|
2462
|
+
/** The prototype's `_h` innerHTML memo, kept off the DOM node. */
|
|
2463
|
+
const nodeMemo = /* @__PURE__ */ new WeakMap();
|
|
2464
|
+
function timeOf(at) {
|
|
2465
|
+
const d = new Date(at);
|
|
2466
|
+
if (Number.isNaN(d.getTime())) return "";
|
|
2467
|
+
return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
|
|
2468
|
+
}
|
|
2469
|
+
function isImage(att) {
|
|
2470
|
+
if (att.contentType?.startsWith("image/")) return true;
|
|
2471
|
+
return /\.(png|webp|jpe?g|gif)$/i.test(att.url ?? att.path ?? "");
|
|
2472
|
+
}
|
|
2473
|
+
function fileName(att) {
|
|
2474
|
+
const source = att.url ?? att.path ?? att.id;
|
|
2475
|
+
return source.split("/").pop() ?? source;
|
|
2476
|
+
}
|
|
2477
|
+
/** Thumbnail when the attachment is an image; the error listener degrades it to a chip. */
|
|
2478
|
+
function attachmentsHtml(m) {
|
|
2479
|
+
if (!m.attachments?.length) return "";
|
|
2480
|
+
return `<div class="atts">${m.attachments.map((att) => isImage(att) ? `<span class="pb-att"><img src="${esc(safeUrl(att.url ?? att.path ?? ""))}" alt="${esc(fileName(att))}" loading="lazy"></span>` : `<span class="pb-att-chip">${esc(fileName(att))}</span>`).join("")}</div>`;
|
|
2481
|
+
}
|
|
2482
|
+
/** "claude:lark-mac-agent" → "Claude · lark-mac-agent"; other shapes verbatim. */
|
|
2483
|
+
function agentName(origin) {
|
|
2484
|
+
const idx = origin.indexOf(":");
|
|
2485
|
+
if (idx <= 0) return origin;
|
|
2486
|
+
const agent = origin.slice(0, idx);
|
|
2487
|
+
return `${agent.charAt(0).toUpperCase()}${agent.slice(1)} · ${origin.slice(idx + 1)}`;
|
|
2488
|
+
}
|
|
2489
|
+
function messageHtml(m) {
|
|
2490
|
+
if (m.role === "agent") return `<div class="pb-msg"><div class="pb-av agent">AI</div><div class="col"><div class="line"><span class="who">${esc(m.origin === void 0 ? "Agent" : agentName(m.origin))}</span><span class="tm">${esc(timeOf(m.at))}</span></div><div class="txt">${esc(m.text)}</div>${attachmentsHtml(m)}</div></div>`;
|
|
2491
|
+
const mirror = m.role === "mirror";
|
|
2492
|
+
const origin = mirror ? m.origin ?? "mirror" : null;
|
|
2493
|
+
const who = origin ? origin.split(":")[1] ?? origin : "You";
|
|
2494
|
+
const initials = who.slice(0, 2).toUpperCase();
|
|
2495
|
+
const via = origin ? `<span class="via-tag"><span>${esc(origin)}</span></span>` : "";
|
|
2496
|
+
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>`;
|
|
2497
|
+
}
|
|
2498
|
+
/** The agent has the message and has not answered yet. Its own node, so patching never rebuilds. */
|
|
2499
|
+
const PENDING_HTML = {
|
|
2500
|
+
thinking: "<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>",
|
|
2501
|
+
waiting: "<div class=\"pb-typing quiet\"><div class=\"pb-av agent\">AI</div><div class=\"lbl\">WAITING FOR AGENT</div></div>"
|
|
2502
|
+
};
|
|
2503
|
+
/**
|
|
2504
|
+
* Show, restyle or hide the "working on it" row.
|
|
2505
|
+
*
|
|
2506
|
+
* Without it the card sits silent from the moment you comment until the answer lands, which reads
|
|
2507
|
+
* as nothing happening — the single most common report on the demo. With it forever, a pin nobody
|
|
2508
|
+
* answers "thinks" for two weeks (dogfood) — so the row has stages, and "stale"/"none" remove it
|
|
2509
|
+
* (the stale footer takes over, card-parts.ts).
|
|
2510
|
+
*/
|
|
2511
|
+
function patchPending(threadEl, kind) {
|
|
2512
|
+
const existing = threadEl.querySelector("[data-iid=\"pb-typing\"]");
|
|
2513
|
+
if (kind === "none" || kind === "stale") {
|
|
2514
|
+
existing?.remove();
|
|
2515
|
+
return;
|
|
2516
|
+
}
|
|
2517
|
+
const html = PENDING_HTML[kind];
|
|
2518
|
+
if (existing) {
|
|
2519
|
+
if (nodeMemo.get(existing) !== html) {
|
|
2520
|
+
existing.innerHTML = html;
|
|
2521
|
+
nodeMemo.set(existing, html);
|
|
2522
|
+
}
|
|
2523
|
+
threadEl.appendChild(existing);
|
|
2524
|
+
return;
|
|
2525
|
+
}
|
|
2526
|
+
const node = threadEl.ownerDocument.createElement("div");
|
|
2527
|
+
node.className = "pb-msg-w";
|
|
2528
|
+
node.setAttribute("data-iid", "pb-typing");
|
|
2529
|
+
node.innerHTML = html;
|
|
2530
|
+
nodeMemo.set(node, html);
|
|
2531
|
+
threadEl.appendChild(node);
|
|
2532
|
+
threadEl.scrollTop = threadEl.scrollHeight;
|
|
2533
|
+
}
|
|
2534
|
+
/** Keyed thread patching: appends/patches [data-iid] nodes only, never rebuilds. */
|
|
2535
|
+
function patchThread(threadEl, messages) {
|
|
2536
|
+
let appended = false;
|
|
2537
|
+
for (const m of messages) {
|
|
2538
|
+
let node = [...threadEl.children].find((c) => c.getAttribute("data-iid") === m.id) ?? null;
|
|
2539
|
+
const html = messageHtml(m);
|
|
2540
|
+
if (!node) {
|
|
2541
|
+
node = threadEl.ownerDocument.createElement("div");
|
|
2542
|
+
node.className = "pb-msg-w";
|
|
2543
|
+
node.setAttribute("data-iid", m.id);
|
|
2544
|
+
node.innerHTML = html;
|
|
2545
|
+
nodeMemo.set(node, html);
|
|
2546
|
+
threadEl.appendChild(node);
|
|
2547
|
+
appended = true;
|
|
2548
|
+
} else if (nodeMemo.get(node) !== html) {
|
|
2549
|
+
node.innerHTML = html;
|
|
2550
|
+
nodeMemo.set(node, html);
|
|
2551
|
+
}
|
|
2552
|
+
}
|
|
2553
|
+
if (appended) threadEl.scrollTop = threadEl.scrollHeight;
|
|
2554
|
+
}
|
|
2555
|
+
//#endregion
|
|
2556
|
+
//#region src/ui/card-parts.ts
|
|
2557
|
+
const STATUS_LABEL = {
|
|
2558
|
+
open: "OPEN",
|
|
2559
|
+
waiting: "OPEN",
|
|
2560
|
+
replied: "REPLIED",
|
|
2561
|
+
resolved: "RESOLVED",
|
|
2562
|
+
verify: "VERIFY",
|
|
2563
|
+
note: "NOTE",
|
|
2564
|
+
stale: "NO RESPONSE"
|
|
2565
|
+
};
|
|
2566
|
+
const CHECK_ICON$1 = "<svg width=\"13\" height=\"13\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\"><path d=\"M3 8.5l3.2 3.2L13 4.8\"/></svg>";
|
|
2567
|
+
const X_ICON$1 = "<svg width=\"13\" height=\"13\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\"><path d=\"M4 4l8 8M12 4l-8 8\"/></svg>";
|
|
2568
|
+
const COPY_ICON = "<svg width=\"13\" height=\"13\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.4\"><rect x=\"5.5\" y=\"5.5\" width=\"8\" height=\"8\" rx=\"1\"/><path d=\"M10.5 3.5v-1a1 1 0 00-1-1h-6a1 1 0 00-1 1v6a1 1 0 001 1h1\"/></svg>";
|
|
2569
|
+
function hdHtml(n, targetLabel, status, resolvable, copyable) {
|
|
2570
|
+
return `<div class="meta"><span class="num">${pinNumber(n)}</span><span>${esc(targetLabel)}</span><span class="st">${esc(status)}</span></div><div style="display:flex;gap:2px">` + (copyable ? `<button type="button" class="pb-ico" data-action="copy" title="Copy this pin">${COPY_ICON}</button>` : "") + (resolvable ? `<button type="button" class="pb-ico ok" data-action="resolve" title="Resolve (R)">${CHECK_ICON$1}</button>` : "") + `<button type="button" class="pb-ico" data-action="close" title="Close (Esc)">${X_ICON$1}</button></div>`;
|
|
2571
|
+
}
|
|
2572
|
+
/** One name per locus: selector first, else anchor, else tag. */
|
|
2573
|
+
function locusName(t) {
|
|
2574
|
+
return t.selector ?? t.anchor ?? t.tag?.toUpperCase();
|
|
2575
|
+
}
|
|
2576
|
+
/** The extra loci of a multi-target pin — the anchor leads, extras follow. */
|
|
2577
|
+
function lociHtml(pin) {
|
|
2578
|
+
const target = pin?.target;
|
|
2579
|
+
const extras = target?.targets;
|
|
2580
|
+
if (target === void 0 || extras === void 0 || extras.length === 0) return "";
|
|
2581
|
+
const names = [target, ...extras].map((t) => esc(locusName(t) ?? "?"));
|
|
2582
|
+
return `<div class="pb-loci">${names.length} targets: ${names.join(" · ")}</div>`;
|
|
2583
|
+
}
|
|
2584
|
+
/** Link badge: pin.links[0] read-only — no picker, no unlink yet. */
|
|
2585
|
+
function linkHtml(pin) {
|
|
2586
|
+
const link = pin?.links?.[0];
|
|
2587
|
+
if (!link) return "";
|
|
2588
|
+
return `<div class="pb-linkbar"><span class="ch">${esc(link.connector)}</span><span class="mt">${esc(link.ref)}</span><span class="sp"></span><a class="pb-open" href="${esc(safeUrl(link.url))}" target="_blank" rel="noreferrer">OPEN</a></div>`;
|
|
2589
|
+
}
|
|
2590
|
+
/**
|
|
2591
|
+
* Who resolved the pin and what they said. The schema has carried `resolution.note` and
|
|
2592
|
+
* `.commit` since v1 (agents write them); the card never showed either, so a resolved pin
|
|
2593
|
+
* read as bare "RESOLVED" with the why one CLI call away.
|
|
2594
|
+
*/
|
|
2595
|
+
function resolutionHtml(pin) {
|
|
2596
|
+
const r = pin?.resolution;
|
|
2597
|
+
if (pin === null || r === void 0 || pin.status !== "resolved") return "";
|
|
2598
|
+
return `<div class="pb-resnote">Resolved by ${r.by === "agent" ? "agent" : "you"}${r.note === void 0 ? "" : ` · ${esc(r.note)}`}${r.commit === void 0 ? "" : ` <span class="hh">${esc(r.commit.slice(0, 7))}</span>`}</div>`;
|
|
2599
|
+
}
|
|
2600
|
+
/**
|
|
2601
|
+
* The pin has waited too long. Nudge re-posts your last message so a watcher re-triggers;
|
|
2602
|
+
* Resolve clears it — the two things you can actually do about a silent agent.
|
|
2603
|
+
*/
|
|
2604
|
+
function staleHtml(status) {
|
|
2605
|
+
if (status !== "stale") return "";
|
|
2606
|
+
return "<div class=\"pb-stale\"><span class=\"msg\">No agent has answered. It may not be running.</span><button type=\"button\" class=\"pb-bt-ghost\" data-action=\"nudge\" title=\"Re-send your last message\">Nudge</button><button type=\"button\" class=\"pb-bt-ok\" data-action=\"resolve\">Resolve</button></div>";
|
|
2607
|
+
}
|
|
2608
|
+
function verifyHtml(status) {
|
|
2609
|
+
if (status === "verify") return "<div class=\"pb-verify\"><button type=\"button\" class=\"pb-bt-ok\" data-action=\"verify-accept\">Looks good</button><button type=\"button\" class=\"pb-bt-ghost\" data-action=\"verify-reopen\">Reopen</button></div>";
|
|
2610
|
+
if (status === "resolved") return "<div class=\"pb-verify\"><button type=\"button\" class=\"pb-bt-ghost\" data-action=\"verify-reopen\">Unresolve</button></div>";
|
|
2611
|
+
return "";
|
|
2612
|
+
}
|
|
2613
|
+
/**
|
|
2614
|
+
* The composer row. A draft chooses what it is before it exists: "Ask agent" (the default —
|
|
2615
|
+
* a `note` pin an agent picks up) or "Note" (a `comment` pin, a remark for people that never
|
|
2616
|
+
* wakes an agent). A control on the draft, not a global toggle: a mode you set once and forget
|
|
2617
|
+
* makes the next pin silently the wrong kind.
|
|
2618
|
+
*/
|
|
2619
|
+
function rowHtml(hasThread, isDraft, kind) {
|
|
2620
|
+
return `${isDraft ? `<div class="pb-seg" role="radiogroup" aria-label="Pin kind"><button type="button" role="radio" aria-checked="${kind === "note"}" class="${kind === "note" ? "on" : ""}" data-kind="note" title="An agent picks this up">Ask agent</button><button type="button" role="radio" aria-checked="${kind === "comment"}" class="${kind === "comment" ? "on" : ""}" data-kind="comment" title="A remark for people; no agent acts on it">Note</button></div>` : ""}<div class="pb-kbd">⌘ ↵</div><button type="button" class="pb-bt-solid" data-action="send">${hasThread ? "Reply" : kind === "comment" ? "Leave note" : "Comment"}</button>`;
|
|
2621
|
+
}
|
|
2622
|
+
//#endregion
|
|
1748
2623
|
//#region src/ui/pins.ts
|
|
1749
2624
|
/** The prototype's `_h` innerHTML memo, kept off the DOM node. */
|
|
1750
2625
|
const chipMemo = /* @__PURE__ */ new WeakMap();
|
|
@@ -1766,39 +2641,89 @@ var Pinbox = (function(exports) {
|
|
|
1766
2641
|
return true;
|
|
1767
2642
|
}
|
|
1768
2643
|
}
|
|
2644
|
+
/** A stored (document-space) rect or point, in today's viewport. */
|
|
2645
|
+
function toViewport(win, p) {
|
|
2646
|
+
return {
|
|
2647
|
+
...p,
|
|
2648
|
+
x: p.x - win.scrollX,
|
|
2649
|
+
y: p.y - win.scrollY
|
|
2650
|
+
};
|
|
2651
|
+
}
|
|
1769
2652
|
/**
|
|
1770
|
-
* Where the pin's anchor is NOW (dogfood #26: markers lingered over
|
|
1771
|
-
* views after SPA tab switches, because placement trusted the stored rect
|
|
1772
|
-
*
|
|
1773
|
-
*
|
|
1774
|
-
* - it resolves
|
|
2653
|
+
* Where the pin's anchor is NOW, in VIEWPORT space (dogfood #26: markers lingered over
|
|
2654
|
+
* unrelated views after SPA tab switches, because placement trusted the stored rect forever;
|
|
2655
|
+
* dogfood v4: pins drifted on scroll, because the layer was document-space and only
|
|
2656
|
+
* re-rendered on DOM mutation). Re-resolve the captured selector on every render:
|
|
2657
|
+
* - it resolves with layout → the LIVE client rect, which is right inside inner scroll
|
|
2658
|
+
* containers and on sticky/fixed anchors alike;
|
|
2659
|
+
* - it resolves without layout (test DOMs, display:none) → stored rect minus scroll;
|
|
1775
2660
|
* - it does not resolve → no marker; the drawer stays the see-everything list.
|
|
1776
2661
|
* A pin with no selector (terminal-adjacent) keeps its stored rect, as before.
|
|
1777
2662
|
*/
|
|
1778
|
-
function anchorRect(
|
|
1779
|
-
|
|
2663
|
+
function anchorRect(doc, pin) {
|
|
2664
|
+
return targetRect(doc, pin.target);
|
|
2665
|
+
}
|
|
2666
|
+
/** The same resolution for any captured target — a pin's, or the draft's before it commits. */
|
|
2667
|
+
function targetRect(doc, target) {
|
|
2668
|
+
const stored = target?.rect;
|
|
1780
2669
|
if (stored === void 0) return null;
|
|
1781
|
-
const doc = layer.ownerDocument;
|
|
1782
2670
|
const win = doc.defaultView;
|
|
1783
2671
|
if (win === null) return stored;
|
|
1784
|
-
if (!sameView(win,
|
|
1785
|
-
const selector =
|
|
1786
|
-
if (selector === void 0) return stored;
|
|
2672
|
+
if (!sameView(win, target?.url)) return null;
|
|
2673
|
+
const selector = target?.selector;
|
|
2674
|
+
if (selector === void 0) return toViewport(win, stored);
|
|
1787
2675
|
let el;
|
|
1788
2676
|
try {
|
|
1789
2677
|
el = doc.querySelector(selector);
|
|
1790
2678
|
} catch {
|
|
1791
|
-
return stored;
|
|
2679
|
+
return toViewport(win, stored);
|
|
1792
2680
|
}
|
|
1793
2681
|
if (el === null) return null;
|
|
1794
2682
|
const r = el.getBoundingClientRect();
|
|
1795
|
-
if (r.width <= 0 && r.height <= 0) return stored;
|
|
1796
|
-
return {
|
|
1797
|
-
x: r.left
|
|
1798
|
-
y: r.top
|
|
2683
|
+
if (r.width <= 0 && r.height <= 0) return toViewport(win, stored);
|
|
2684
|
+
return clipToScrollAncestors(win, el, {
|
|
2685
|
+
x: r.left,
|
|
2686
|
+
y: r.top,
|
|
1799
2687
|
width: r.width,
|
|
1800
2688
|
height: r.height
|
|
1801
|
-
};
|
|
2689
|
+
});
|
|
2690
|
+
}
|
|
2691
|
+
const CLIPPING = /* @__PURE__ */ new Set([
|
|
2692
|
+
"auto",
|
|
2693
|
+
"scroll",
|
|
2694
|
+
"hidden",
|
|
2695
|
+
"clip"
|
|
2696
|
+
]);
|
|
2697
|
+
/**
|
|
2698
|
+
* The part of `rect` an ancestor scroll container actually shows. A row scrolled out of its
|
|
2699
|
+
* pane has a live client rect above or below the pane; drawing a marker there floats it over
|
|
2700
|
+
* unrelated content (dogfood: the draft on Record 12 sat on the heading once the pane scrolled).
|
|
2701
|
+
* Null when nothing of the element is visible; the drawer still lists the pin.
|
|
2702
|
+
*/
|
|
2703
|
+
function clipToScrollAncestors(win, el, rect) {
|
|
2704
|
+
let out = rect;
|
|
2705
|
+
for (let p = el.parentElement; p !== null && p !== win.document.body; p = p.parentElement) {
|
|
2706
|
+
const cs = win.getComputedStyle(p);
|
|
2707
|
+
if (![
|
|
2708
|
+
cs.overflow,
|
|
2709
|
+
cs.overflowX,
|
|
2710
|
+
cs.overflowY
|
|
2711
|
+
].some((v) => CLIPPING.has(v))) continue;
|
|
2712
|
+
const c = p.getBoundingClientRect();
|
|
2713
|
+
if (c.width <= 0 && c.height <= 0) continue;
|
|
2714
|
+
const x1 = Math.max(out.x, c.left);
|
|
2715
|
+
const y1 = Math.max(out.y, c.top);
|
|
2716
|
+
const x2 = Math.min(out.x + out.width, c.right);
|
|
2717
|
+
const y2 = Math.min(out.y + out.height, c.bottom);
|
|
2718
|
+
if (x2 <= x1 || y2 <= y1) return null;
|
|
2719
|
+
out = {
|
|
2720
|
+
x: x1,
|
|
2721
|
+
y: y1,
|
|
2722
|
+
width: x2 - x1,
|
|
2723
|
+
height: y2 - y1
|
|
2724
|
+
};
|
|
2725
|
+
}
|
|
2726
|
+
return out;
|
|
1802
2727
|
}
|
|
1803
2728
|
/**
|
|
1804
2729
|
* Where the needle lands: the point inside the element that was actually clicked, when the pin
|
|
@@ -1815,13 +2740,25 @@ var Pinbox = (function(exports) {
|
|
|
1815
2740
|
y: r.y + r.height * fy
|
|
1816
2741
|
};
|
|
1817
2742
|
}
|
|
2743
|
+
/**
|
|
2744
|
+
* Where the draft marker sits: its captured element's LIVE rect at the clicked spot, exactly as a
|
|
2745
|
+
* committed pin would (a draft on a sticky header must ride the header while you type). Null when
|
|
2746
|
+
* the element is scrolled out of its container or gone — no marker, and the card docks
|
|
2747
|
+
* (card.ts) instead of floating over whatever now occupies the stale point.
|
|
2748
|
+
*/
|
|
2749
|
+
function draftPoint(doc, draft) {
|
|
2750
|
+
const target = draft.target.target;
|
|
2751
|
+
const live = targetRect(doc, target);
|
|
2752
|
+
return live === null ? null : pinPoint(live, target.spot);
|
|
2753
|
+
}
|
|
1818
2754
|
/** Chip contents (prototype chipBtnInner, lines 546–550): number + linked-channel tag,
|
|
1819
2755
|
* plus the queued badge while the pin waits in the outbox for the reconnect flush. */
|
|
1820
|
-
function chipInner(n, pin, queued = false) {
|
|
2756
|
+
function chipInner(n, pin, queued = false, stale = false) {
|
|
1821
2757
|
const link = pin?.links?.[0];
|
|
1822
2758
|
const badge = link ? `<span class="lk"><span>${esc(link.connector)}</span></span>` : "";
|
|
1823
|
-
const qd = queued ? "<span class=\"qd\">QUEUED</span>" : "";
|
|
1824
|
-
|
|
2759
|
+
const qd = queued ? "<span class=\"qd\">QUEUED</span>" : stale ? "<span class=\"qd\">NO REPLY</span>" : "";
|
|
2760
|
+
const nt = pin?.kind === "comment" ? "<span class=\"nt\" title=\"Note — no agent acts on this\">N</span>" : "";
|
|
2761
|
+
return `<span>${pinNumber(n)}</span>${nt}${badge}${qd}`;
|
|
1825
2762
|
}
|
|
1826
2763
|
function ensureNode(layer, key, fresh) {
|
|
1827
2764
|
let node = layer.querySelector(`[data-pin="${key}"]`);
|
|
@@ -1855,128 +2792,43 @@ var Pinbox = (function(exports) {
|
|
|
1855
2792
|
const visible = state.pins.filter((p) => p.status !== "resolved" || p.id === state.activePinId);
|
|
1856
2793
|
const placed = [];
|
|
1857
2794
|
visible.forEach((pin, i) => {
|
|
1858
|
-
const rect = anchorRect(layer, pin);
|
|
2795
|
+
const rect = anchorRect(layer.ownerDocument, pin);
|
|
1859
2796
|
if (rect === null) return;
|
|
1860
2797
|
const spot = pin.target?.spot;
|
|
1861
|
-
const n = pin.n ?? i + 1;
|
|
1862
|
-
placed.push(spot === void 0 ? {
|
|
1863
|
-
pin,
|
|
1864
|
-
n,
|
|
1865
|
-
rect
|
|
1866
|
-
} : {
|
|
1867
|
-
pin,
|
|
1868
|
-
n,
|
|
1869
|
-
rect,
|
|
1870
|
-
spot
|
|
1871
|
-
});
|
|
1872
|
-
});
|
|
1873
|
-
const keys = new Set(placed.map((entry) => entry.pin.id));
|
|
1874
|
-
if (state.draft) keys.add("draft");
|
|
1875
|
-
for (const node of [...layer.children]) if (!keys.has(node.getAttribute("data-pin") ?? "")) node.remove();
|
|
1876
|
-
for (const { pin, n, rect, spot } of placed) {
|
|
1877
|
-
const node = ensureNode(layer, pin.id, false);
|
|
1878
|
-
const hot = pin.id === state.activePinId;
|
|
1879
|
-
const queued = state.queuedIds.has(pin.id);
|
|
1880
|
-
node.classList.toggle("queued", queued);
|
|
1881
|
-
|
|
1882
|
-
|
|
1883
|
-
|
|
1884
|
-
|
|
1885
|
-
|
|
1886
|
-
|
|
1887
|
-
|
|
1888
|
-
|
|
1889
|
-
waiting: "OPEN",
|
|
1890
|
-
replied: "REPLIED",
|
|
1891
|
-
resolved: "RESOLVED",
|
|
1892
|
-
verify: "VERIFY"
|
|
1893
|
-
};
|
|
1894
|
-
const CHECK_ICON = "<svg width=\"13\" height=\"13\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\"><path d=\"M3 8.5l3.2 3.2L13 4.8\"/></svg>";
|
|
1895
|
-
const X_ICON$1 = "<svg width=\"13\" height=\"13\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\"><path d=\"M4 4l8 8M12 4l-8 8\"/></svg>";
|
|
1896
|
-
const COPY_ICON = "<svg width=\"13\" height=\"13\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.4\"><rect x=\"5.5\" y=\"5.5\" width=\"8\" height=\"8\" rx=\"1\"/><path d=\"M10.5 3.5v-1a1 1 0 00-1-1h-6a1 1 0 00-1 1v6a1 1 0 001 1h1\"/></svg>";
|
|
1897
|
-
const ctxByCard = /* @__PURE__ */ new WeakMap();
|
|
1898
|
-
/** The prototype's `_h` innerHTML memo, kept off the DOM node. */
|
|
1899
|
-
const nodeMemo = /* @__PURE__ */ new WeakMap();
|
|
1900
|
-
function timeOf(at) {
|
|
1901
|
-
const d = new Date(at);
|
|
1902
|
-
if (Number.isNaN(d.getTime())) return "";
|
|
1903
|
-
return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
|
|
1904
|
-
}
|
|
1905
|
-
function isImage(att) {
|
|
1906
|
-
if (att.contentType?.startsWith("image/")) return true;
|
|
1907
|
-
return /\.(png|webp|jpe?g|gif)$/i.test(att.url ?? att.path ?? "");
|
|
1908
|
-
}
|
|
1909
|
-
function fileName(att) {
|
|
1910
|
-
const source = att.url ?? att.path ?? att.id;
|
|
1911
|
-
return source.split("/").pop() ?? source;
|
|
1912
|
-
}
|
|
1913
|
-
/** Thumbnail when the attachment is an image; the error listener degrades it to a chip. */
|
|
1914
|
-
function attachmentsHtml(m) {
|
|
1915
|
-
if (!m.attachments?.length) return "";
|
|
1916
|
-
return `<div class="atts">${m.attachments.map((att) => isImage(att) ? `<span class="pb-att"><img src="${esc(safeUrl(att.url ?? att.path ?? ""))}" alt="${esc(fileName(att))}" loading="lazy"></span>` : `<span class="pb-att-chip">${esc(fileName(att))}</span>`).join("")}</div>`;
|
|
1917
|
-
}
|
|
1918
|
-
/** "claude:lark-mac-agent" → "Claude · lark-mac-agent"; other shapes verbatim. */
|
|
1919
|
-
function agentName(origin) {
|
|
1920
|
-
const idx = origin.indexOf(":");
|
|
1921
|
-
if (idx <= 0) return origin;
|
|
1922
|
-
const agent = origin.slice(0, idx);
|
|
1923
|
-
return `${agent.charAt(0).toUpperCase()}${agent.slice(1)} · ${origin.slice(idx + 1)}`;
|
|
1924
|
-
}
|
|
1925
|
-
function messageHtml(m) {
|
|
1926
|
-
if (m.role === "agent") return `<div class="pb-msg"><div class="pb-av agent">AI</div><div class="col"><div class="line"><span class="who">${esc(m.origin === void 0 ? "Agent" : agentName(m.origin))}</span><span class="tm">${esc(timeOf(m.at))}</span></div><div class="txt">${esc(m.text)}</div>${attachmentsHtml(m)}</div></div>`;
|
|
1927
|
-
const mirror = m.role === "mirror";
|
|
1928
|
-
const origin = mirror ? m.origin ?? "mirror" : null;
|
|
1929
|
-
const who = origin ? origin.split(":")[1] ?? origin : "You";
|
|
1930
|
-
const initials = who.slice(0, 2).toUpperCase();
|
|
1931
|
-
const via = origin ? `<span class="via-tag"><span>${esc(origin)}</span></span>` : "";
|
|
1932
|
-
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>`;
|
|
1933
|
-
}
|
|
1934
|
-
/** The agent has the message and has not answered yet. Its own node, so patching never rebuilds. */
|
|
1935
|
-
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>";
|
|
1936
|
-
/**
|
|
1937
|
-
* Show or hide the "working on it" row.
|
|
1938
|
-
*
|
|
1939
|
-
* Without it the card sits silent from the moment you comment until the answer lands, which reads
|
|
1940
|
-
* as nothing happening — the single most common report on the demo.
|
|
1941
|
-
*/
|
|
1942
|
-
function patchTyping(threadEl, pending) {
|
|
1943
|
-
const existing = threadEl.querySelector("[data-iid=\"pb-typing\"]");
|
|
1944
|
-
if (!pending) {
|
|
1945
|
-
existing?.remove();
|
|
1946
|
-
return;
|
|
1947
|
-
}
|
|
1948
|
-
if (existing) {
|
|
1949
|
-
threadEl.appendChild(existing);
|
|
1950
|
-
return;
|
|
1951
|
-
}
|
|
1952
|
-
const node = threadEl.ownerDocument.createElement("div");
|
|
1953
|
-
node.className = "pb-msg-w";
|
|
1954
|
-
node.setAttribute("data-iid", "pb-typing");
|
|
1955
|
-
node.innerHTML = TYPING_HTML;
|
|
1956
|
-
threadEl.appendChild(node);
|
|
1957
|
-
threadEl.scrollTop = threadEl.scrollHeight;
|
|
1958
|
-
}
|
|
1959
|
-
/** Keyed thread patching: appends/patches [data-iid] nodes only, never rebuilds. */
|
|
1960
|
-
function patchThread(threadEl, messages) {
|
|
1961
|
-
let appended = false;
|
|
1962
|
-
for (const m of messages) {
|
|
1963
|
-
let node = threadEl.querySelector(`[data-iid="${m.id}"]`);
|
|
1964
|
-
const html = messageHtml(m);
|
|
1965
|
-
if (!node) {
|
|
1966
|
-
node = threadEl.ownerDocument.createElement("div");
|
|
1967
|
-
node.className = "pb-msg-w";
|
|
1968
|
-
node.setAttribute("data-iid", m.id);
|
|
1969
|
-
node.innerHTML = html;
|
|
1970
|
-
nodeMemo.set(node, html);
|
|
1971
|
-
threadEl.appendChild(node);
|
|
1972
|
-
appended = true;
|
|
1973
|
-
} else if (nodeMemo.get(node) !== html) {
|
|
1974
|
-
node.innerHTML = html;
|
|
1975
|
-
nodeMemo.set(node, html);
|
|
1976
|
-
}
|
|
1977
|
-
}
|
|
1978
|
-
if (appended) threadEl.scrollTop = threadEl.scrollHeight;
|
|
2798
|
+
const n = pin.n ?? i + 1;
|
|
2799
|
+
placed.push(spot === void 0 ? {
|
|
2800
|
+
pin,
|
|
2801
|
+
n,
|
|
2802
|
+
rect
|
|
2803
|
+
} : {
|
|
2804
|
+
pin,
|
|
2805
|
+
n,
|
|
2806
|
+
rect,
|
|
2807
|
+
spot
|
|
2808
|
+
});
|
|
2809
|
+
});
|
|
2810
|
+
const keys = new Set(placed.map((entry) => entry.pin.id));
|
|
2811
|
+
if (state.draft) keys.add("draft");
|
|
2812
|
+
for (const node of [...layer.children]) if (!keys.has(node.getAttribute("data-pin") ?? "")) node.remove();
|
|
2813
|
+
for (const { pin, n, rect, spot } of placed) {
|
|
2814
|
+
const node = ensureNode(layer, pin.id, false);
|
|
2815
|
+
const hot = pin.id === state.activePinId;
|
|
2816
|
+
const queued = state.queuedIds.has(pin.id);
|
|
2817
|
+
node.classList.toggle("queued", queued);
|
|
2818
|
+
node.classList.toggle("note", pin.kind === "comment");
|
|
2819
|
+
const stale = deriveUiStatus(pin, state.threads.get(pin.id) ?? [], state) === "stale";
|
|
2820
|
+
node.classList.toggle("stale", stale);
|
|
2821
|
+
patchNode(node, pinPoint(rect, spot), hot, chipInner(n, pin, queued, stale));
|
|
2822
|
+
}
|
|
2823
|
+
const draftAt = state.draft ? draftPoint(layer.ownerDocument, state.draft) : null;
|
|
2824
|
+
if (draftAt === null) layer.querySelector("[data-pin=\"draft\"]")?.remove();
|
|
2825
|
+
if (state.draft && draftAt !== null) patchNode(ensureNode(layer, "draft", true), draftAt, true, chipInner(nextOrdinal(state.pins), null));
|
|
1979
2826
|
}
|
|
2827
|
+
//#endregion
|
|
2828
|
+
//#region src/ui/card.ts
|
|
2829
|
+
const ctxByCard = /* @__PURE__ */ new WeakMap();
|
|
2830
|
+
/** .pb-card width (styles.ts). */
|
|
2831
|
+
const CARD_W = 344;
|
|
1980
2832
|
function ensureShell(root) {
|
|
1981
2833
|
let card = root.querySelector(".pb-card");
|
|
1982
2834
|
if (!card) {
|
|
@@ -1989,7 +2841,8 @@ var Pinbox = (function(exports) {
|
|
|
1989
2841
|
const ctx = {
|
|
1990
2842
|
pid: null,
|
|
1991
2843
|
parts: {},
|
|
1992
|
-
actions: null
|
|
2844
|
+
actions: null,
|
|
2845
|
+
draftKind: "note"
|
|
1993
2846
|
};
|
|
1994
2847
|
ctxByCard.set(card, ctx);
|
|
1995
2848
|
card.addEventListener("click", (e) => onCardClick(card, ctx, e));
|
|
@@ -2000,29 +2853,55 @@ var Pinbox = (function(exports) {
|
|
|
2000
2853
|
const ta = card.querySelector("textarea");
|
|
2001
2854
|
const text = ta?.value.trim();
|
|
2002
2855
|
if (!ta || !text || !ctx.pid) return;
|
|
2003
|
-
ctx.actions.send(ctx.pid === "draft" ? "draft" : ctx.pid, text);
|
|
2856
|
+
ctx.actions.send(ctx.pid === "draft" ? "draft" : ctx.pid, text, ctx.draftKind);
|
|
2004
2857
|
ta.value = "";
|
|
2005
2858
|
}
|
|
2859
|
+
/** The draft's kind control: pick, redraw the row, keep typing. */
|
|
2860
|
+
function onKindPick(card, ctx, kind) {
|
|
2861
|
+
ctx.draftKind = kind;
|
|
2862
|
+
setPart(card, ctx, "row", rowHtml(false, true, kind));
|
|
2863
|
+
card.querySelector("textarea")?.focus();
|
|
2864
|
+
}
|
|
2865
|
+
/** A moment of green on the copy button is the whole receipt — there is no toast layer,
|
|
2866
|
+
* and the header part-memo never resets a class toggle. */
|
|
2867
|
+
function flashCopied(card, from) {
|
|
2868
|
+
const btn = from.closest?.("[data-action=\"copy\"]");
|
|
2869
|
+
if (!btn) return;
|
|
2870
|
+
btn.classList.add("ok");
|
|
2871
|
+
card.ownerDocument.defaultView?.setTimeout(() => btn.classList.remove("ok"), 900);
|
|
2872
|
+
}
|
|
2873
|
+
/** Actions that need a committed pin (a draft has no id to act on). */
|
|
2874
|
+
const PIN_ACTIONS = {
|
|
2875
|
+
resolve: (_card, ctx, pid) => ctx.actions.resolve(pid),
|
|
2876
|
+
nudge: (_card, ctx, pid) => ctx.actions.nudge(pid),
|
|
2877
|
+
copy: (card, ctx, pid, from) => {
|
|
2878
|
+
ctx.actions.copy(pid);
|
|
2879
|
+
flashCopied(card, from);
|
|
2880
|
+
},
|
|
2881
|
+
"verify-accept": (_card, ctx, pid) => ctx.actions.verify(pid, "accepted"),
|
|
2882
|
+
"verify-reopen": (card, ctx, pid) => {
|
|
2883
|
+
ctx.actions.verify(pid, "reopened");
|
|
2884
|
+
card.querySelector("textarea")?.focus();
|
|
2885
|
+
}
|
|
2886
|
+
};
|
|
2006
2887
|
function onCardClick(card, ctx, e) {
|
|
2007
|
-
const
|
|
2888
|
+
const from = e.target;
|
|
2889
|
+
const kind = from.closest?.("[data-kind]")?.getAttribute("data-kind");
|
|
2890
|
+
if (kind === "note" || kind === "comment") {
|
|
2891
|
+
onKindPick(card, ctx, kind);
|
|
2892
|
+
return;
|
|
2893
|
+
}
|
|
2894
|
+
const action = from.closest?.("[data-action]")?.getAttribute("data-action");
|
|
2008
2895
|
if (!action || !ctx.pid) return;
|
|
2009
|
-
if (action === "send")
|
|
2010
|
-
|
|
2011
|
-
|
|
2012
|
-
if (action === "resolve") ctx.actions.resolve(ctx.pid);
|
|
2013
|
-
else if (action === "copy") {
|
|
2014
|
-
ctx.actions.copy(ctx.pid);
|
|
2015
|
-
const btn = e.target.closest?.("[data-action=\"copy\"]");
|
|
2016
|
-
if (btn) {
|
|
2017
|
-
btn.classList.add("ok");
|
|
2018
|
-
card.ownerDocument.defaultView?.setTimeout(() => btn.classList.remove("ok"), 900);
|
|
2019
|
-
}
|
|
2020
|
-
} else if (action === "verify-accept") ctx.actions.verify(ctx.pid, "accepted");
|
|
2021
|
-
else if (action === "verify-reopen") {
|
|
2022
|
-
ctx.actions.verify(ctx.pid, "reopened");
|
|
2023
|
-
card.querySelector("textarea")?.focus();
|
|
2024
|
-
}
|
|
2896
|
+
if (action === "send") {
|
|
2897
|
+
submit(card, ctx);
|
|
2898
|
+
return;
|
|
2025
2899
|
}
|
|
2900
|
+
if (action === "close") {
|
|
2901
|
+
ctx.actions.close();
|
|
2902
|
+
return;
|
|
2903
|
+
}
|
|
2904
|
+
if (ctx.pid !== "draft") PIN_ACTIONS[action]?.(card, ctx, ctx.pid, from);
|
|
2026
2905
|
}
|
|
2027
2906
|
function buildSkeleton(card, ctx, isDraft, hasThread) {
|
|
2028
2907
|
card.innerHTML = "<div class=\"in\"><div class=\"pb-hd\" data-ref=\"hd\"></div><div data-ref=\"link\"></div><div data-ref=\"loci\"></div><div class=\"pb-thread\" data-ref=\"thread\"></div><div data-ref=\"verify\"></div><div class=\"pb-composer\"><textarea rows=\"2\"></textarea><div class=\"row\" data-ref=\"row\"></div></div></div>";
|
|
@@ -2041,52 +2920,23 @@ var Pinbox = (function(exports) {
|
|
|
2041
2920
|
}, true);
|
|
2042
2921
|
if (isDraft) ta.focus();
|
|
2043
2922
|
}
|
|
2044
|
-
function hdHtml(n, targetLabel, status, resolvable, copyable) {
|
|
2045
|
-
return `<div class="meta"><span class="num">${pinNumber(n)}</span><span>${esc(targetLabel)}</span><span class="st">${esc(status)}</span></div><div style="display:flex;gap:2px">` + (copyable ? `<button type="button" class="pb-ico" data-action="copy" title="Copy this pin">${COPY_ICON}</button>` : "") + (resolvable ? `<button type="button" class="pb-ico ok" data-action="resolve" title="Resolve (R)">${CHECK_ICON}</button>` : "") + `<button type="button" class="pb-ico" data-action="close" title="Close (Esc)">${X_ICON$1}</button></div>`;
|
|
2046
|
-
}
|
|
2047
|
-
/** One name per locus: selector first, else anchor, else tag. */
|
|
2048
|
-
function locusName(t) {
|
|
2049
|
-
return t.selector ?? t.anchor ?? t.tag?.toUpperCase();
|
|
2050
|
-
}
|
|
2051
|
-
/** The extra loci of a multi-target pin — the anchor leads, extras follow. */
|
|
2052
|
-
function lociHtml(pin) {
|
|
2053
|
-
const target = pin?.target;
|
|
2054
|
-
const extras = target?.targets;
|
|
2055
|
-
if (target === void 0 || extras === void 0 || extras.length === 0) return "";
|
|
2056
|
-
const names = [target, ...extras].map((t) => esc(locusName(t) ?? "?"));
|
|
2057
|
-
return `<div class="pb-loci">${names.length} targets: ${names.join(" · ")}</div>`;
|
|
2058
|
-
}
|
|
2059
|
-
/** Link badge: pin.links[0] read-only — no picker, no unlink yet. */
|
|
2060
|
-
function linkHtml(pin) {
|
|
2061
|
-
const link = pin?.links?.[0];
|
|
2062
|
-
if (!link) return "";
|
|
2063
|
-
return `<div class="pb-linkbar"><span class="ch">${esc(link.connector)}</span><span class="mt">${esc(link.ref)}</span><span class="sp"></span><a class="pb-open" href="${esc(safeUrl(link.url))}" target="_blank" rel="noreferrer">OPEN</a></div>`;
|
|
2064
|
-
}
|
|
2065
|
-
function verifyHtml(status) {
|
|
2066
|
-
if (status === "verify") return "<div class=\"pb-verify\"><button type=\"button\" class=\"pb-bt-ok\" data-action=\"verify-accept\">Looks good</button><button type=\"button\" class=\"pb-bt-ghost\" data-action=\"verify-reopen\">Reopen</button></div>";
|
|
2067
|
-
if (status === "resolved") return "<div class=\"pb-verify\"><button type=\"button\" class=\"pb-bt-ghost\" data-action=\"verify-reopen\">Unresolve</button></div>";
|
|
2068
|
-
return "";
|
|
2069
|
-
}
|
|
2070
|
-
function rowHtml(hasThread) {
|
|
2071
|
-
return `<div class="pb-kbd">⌘ ↵</div><button type="button" class="pb-bt-solid" data-action="send">${hasThread ? "Reply" : "Comment"}</button>`;
|
|
2072
|
-
}
|
|
2073
2923
|
/**
|
|
2074
|
-
* Viewport-aware placement, ported
|
|
2075
|
-
* the rendered card, flip left when it would overflow right, clamp between
|
|
2076
|
-
*
|
|
2924
|
+
* Viewport-aware placement, ported from the prototype (lines 660–668) into viewport space:
|
|
2925
|
+
* measure the rendered card, flip left when it would overflow right, clamp between the margin
|
|
2926
|
+
* and the command-bar clearance — never off-screen.
|
|
2077
2927
|
*/
|
|
2078
2928
|
function position(card, at) {
|
|
2079
2929
|
const win = card.ownerDocument.defaultView;
|
|
2080
2930
|
if (!win) return;
|
|
2081
|
-
const W =
|
|
2931
|
+
const W = CARD_W;
|
|
2082
2932
|
const m = 12;
|
|
2083
2933
|
const barClear = 84;
|
|
2084
2934
|
let left = at.x + 22;
|
|
2085
|
-
if (left + W > win.
|
|
2086
|
-
left = Math.max(
|
|
2935
|
+
if (left + W > win.innerWidth - m) left = at.x - W - 22;
|
|
2936
|
+
left = Math.max(m, left);
|
|
2087
2937
|
const h = card.querySelector(".in")?.offsetHeight ?? 0;
|
|
2088
|
-
const minTop =
|
|
2089
|
-
const maxTop = win.
|
|
2938
|
+
const minTop = m;
|
|
2939
|
+
const maxTop = win.innerHeight - h - barClear;
|
|
2090
2940
|
card.style.left = `${left}px`;
|
|
2091
2941
|
card.style.top = `${Math.max(minTop, Math.min(at.y - 60, maxTop))}px`;
|
|
2092
2942
|
}
|
|
@@ -2108,15 +2958,35 @@ var Pinbox = (function(exports) {
|
|
|
2108
2958
|
if (pin.n !== void 0) return pin.n;
|
|
2109
2959
|
return state.pins.filter((p) => p.status !== "resolved" || p.id === state.activePinId).indexOf(pin) + 1;
|
|
2110
2960
|
}
|
|
2111
|
-
|
|
2112
|
-
|
|
2113
|
-
|
|
2961
|
+
/**
|
|
2962
|
+
* Where the card tethers, in viewport space. The LIVE anchor when the pin's element is on this
|
|
2963
|
+
* view; otherwise (other URL, selector gone, a terminal `pinbox pin`) the middle of the viewport
|
|
2964
|
+
* — dogfood: the stored rect of a pin whose view had moved on put the card off-screen, so a pin
|
|
2965
|
+
* listed in the drawer could be opened but never seen, let alone resolved.
|
|
2966
|
+
*/
|
|
2967
|
+
function anchorOf(root, pin, draft) {
|
|
2968
|
+
const doc = root.ownerDocument;
|
|
2969
|
+
const win = doc.defaultView;
|
|
2970
|
+
let live = null;
|
|
2971
|
+
if (pin !== null) {
|
|
2972
|
+
const r = anchorRect(doc, pin);
|
|
2973
|
+
live = r === null ? null : {
|
|
2974
|
+
x: r.x + r.width / 2,
|
|
2975
|
+
y: r.y + r.height / 2
|
|
2976
|
+
};
|
|
2977
|
+
} else if (draft !== null) live = draftPoint(doc, draft);
|
|
2978
|
+
else return {
|
|
2979
|
+
x: 0,
|
|
2980
|
+
y: 0
|
|
2981
|
+
};
|
|
2982
|
+
if (live !== null) return live;
|
|
2983
|
+
if (win === null) return {
|
|
2114
2984
|
x: 0,
|
|
2115
2985
|
y: 0
|
|
2116
2986
|
};
|
|
2117
2987
|
return {
|
|
2118
|
-
x:
|
|
2119
|
-
y:
|
|
2988
|
+
x: win.innerWidth / 2 - CARD_W / 2 - 22,
|
|
2989
|
+
y: win.innerHeight / 3 + 60
|
|
2120
2990
|
};
|
|
2121
2991
|
}
|
|
2122
2992
|
/**
|
|
@@ -2126,7 +2996,7 @@ var Pinbox = (function(exports) {
|
|
|
2126
2996
|
function labelOf(target) {
|
|
2127
2997
|
return target?.anchor ?? target?.tag?.toUpperCase() ?? "PIN";
|
|
2128
2998
|
}
|
|
2129
|
-
function viewOf(state) {
|
|
2999
|
+
function viewOf(root, state) {
|
|
2130
3000
|
const pin = activePin(state);
|
|
2131
3001
|
const pid = pin?.id ?? (state.draft ? "draft" : null);
|
|
2132
3002
|
if (!pid) return null;
|
|
@@ -2136,9 +3006,9 @@ var Pinbox = (function(exports) {
|
|
|
2136
3006
|
pin,
|
|
2137
3007
|
thread,
|
|
2138
3008
|
n: ordinalOf(state, pin),
|
|
2139
|
-
status: pin ? deriveUiStatus(pin, thread) : null,
|
|
3009
|
+
status: pin ? deriveUiStatus(pin, thread, state) : null,
|
|
2140
3010
|
label: labelOf(pin?.target ?? state.draft?.target.target),
|
|
2141
|
-
at: anchorOf(pin, state.draft)
|
|
3011
|
+
at: anchorOf(root, pin, state.draft)
|
|
2142
3012
|
};
|
|
2143
3013
|
}
|
|
2144
3014
|
/** Render the thread card for a state snapshot: the active pin, or the draft. */
|
|
@@ -2162,7 +3032,7 @@ var Pinbox = (function(exports) {
|
|
|
2162
3032
|
const card = ensureShell(root);
|
|
2163
3033
|
const ctx = ctxByCard.get(card);
|
|
2164
3034
|
ctx.actions = actions;
|
|
2165
|
-
const view = viewOf(state);
|
|
3035
|
+
const view = viewOf(root, state);
|
|
2166
3036
|
if (!view) {
|
|
2167
3037
|
card.hidden = true;
|
|
2168
3038
|
ctx.pid = null;
|
|
@@ -2171,6 +3041,7 @@ var Pinbox = (function(exports) {
|
|
|
2171
3041
|
if (ctx.pid !== view.pid) {
|
|
2172
3042
|
ctx.pid = view.pid;
|
|
2173
3043
|
ctx.parts = {};
|
|
3044
|
+
ctx.draftKind = "note";
|
|
2174
3045
|
buildSkeleton(card, ctx, view.pid === "draft", view.pin !== null || view.thread.length > 0);
|
|
2175
3046
|
}
|
|
2176
3047
|
card.hidden = false;
|
|
@@ -2180,32 +3051,40 @@ var Pinbox = (function(exports) {
|
|
|
2180
3051
|
setPart(card, ctx, "hd", hdHtml(view.n, view.label, statusLabel, resolvable, view.pin !== null));
|
|
2181
3052
|
setPart(card, ctx, "link", linkHtml(view.pin));
|
|
2182
3053
|
setPart(card, ctx, "loci", lociHtml(view.pin));
|
|
2183
|
-
setPart(card, ctx, "verify", verifyHtml(view.status));
|
|
3054
|
+
setPart(card, ctx, "verify", resolutionHtml(view.pin) + staleHtml(view.status) + verifyHtml(view.status));
|
|
2184
3055
|
const messages = view.pin === null ? view.thread : [pinAsMessage(view.pin), ...view.thread];
|
|
2185
|
-
setPart(card, ctx, "row", rowHtml(messages.length > 0));
|
|
3056
|
+
setPart(card, ctx, "row", rowHtml(messages.length > 0, view.pid === "draft", ctx.draftKind));
|
|
2186
3057
|
const threadEl = card.querySelector("[data-ref=\"thread\"]");
|
|
2187
3058
|
if (threadEl) {
|
|
2188
3059
|
patchThread(threadEl, messages);
|
|
2189
|
-
|
|
3060
|
+
patchPending(threadEl, queued || view.pin === null ? "none" : pendingKind(view.pin, view.thread, state));
|
|
2190
3061
|
}
|
|
2191
3062
|
position(card, view.at);
|
|
2192
3063
|
}
|
|
2193
3064
|
//#endregion
|
|
2194
3065
|
//#region src/ui/drawer.ts
|
|
2195
3066
|
const X_ICON = "<svg width=\"13\" height=\"13\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\"><path d=\"M4 4l8 8M12 4l-8 8\"/></svg>";
|
|
3067
|
+
const CHECK_ICON = "<svg width=\"13\" height=\"13\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\"><path d=\"M3 8.5l3.2 3.2L13 4.8\"/></svg>";
|
|
3068
|
+
const UNDO_ICON = "<svg width=\"13\" height=\"13\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\"><path d=\"M3.5 7.5h6a3 3 0 010 6H6\"/><path d=\"M6 4.5l-3 3 3 3\"/></svg>";
|
|
3069
|
+
/** How long a first click on Resolve-group waits for its confirming second click. */
|
|
3070
|
+
const CONFIRM_MS = 3e3;
|
|
2196
3071
|
const STATUS_TEXT = {
|
|
2197
3072
|
open: "OPEN",
|
|
2198
3073
|
waiting: "OPEN",
|
|
2199
3074
|
replied: "REPLIED",
|
|
2200
3075
|
resolved: "RESOLVED",
|
|
2201
|
-
verify: "VERIFY"
|
|
3076
|
+
verify: "VERIFY",
|
|
3077
|
+
note: "NOTE",
|
|
3078
|
+
stale: "NO RESPONSE"
|
|
2202
3079
|
};
|
|
2203
3080
|
const STATUS_DOT = {
|
|
2204
3081
|
open: "var(--pb-fg4)",
|
|
2205
3082
|
waiting: "var(--pb-fg4)",
|
|
2206
3083
|
replied: "var(--pb-info)",
|
|
2207
3084
|
resolved: "var(--pb-ok)",
|
|
2208
|
-
verify: "var(--pb-amber)"
|
|
3085
|
+
verify: "var(--pb-amber)",
|
|
3086
|
+
note: "var(--pb-fg3)",
|
|
3087
|
+
stale: "var(--pb-amber)"
|
|
2209
3088
|
};
|
|
2210
3089
|
/**
|
|
2211
3090
|
* The place a row names. A browser pin has a selector; a terminal `pinbox pin` has a
|
|
@@ -2214,44 +3093,138 @@ var Pinbox = (function(exports) {
|
|
|
2214
3093
|
function locusOf(pin) {
|
|
2215
3094
|
return pin.target?.selector ?? pin.target?.source?.file ?? "";
|
|
2216
3095
|
}
|
|
2217
|
-
|
|
2218
|
-
|
|
3096
|
+
/** "github#58" — the same label `pinbox link` prints. */
|
|
3097
|
+
function linkKey(pin) {
|
|
2219
3098
|
const link = pin.links?.[0];
|
|
2220
|
-
return
|
|
3099
|
+
return link === void 0 ? null : `${link.connector}#${link.ref}`;
|
|
3100
|
+
}
|
|
3101
|
+
function itemHtml(pin, n, active, thread, queued, view) {
|
|
3102
|
+
const status = deriveUiStatus(pin, thread, view);
|
|
3103
|
+
const link = pin.links?.[0];
|
|
3104
|
+
const act = pin.status === "resolved" ? `<button type="button" class="pb-ico" data-act="unresolve" title="Unresolve">${UNDO_ICON}</button>` : queued ? "" : `<button type="button" class="pb-ico ok" data-act="resolve" title="Resolve">${CHECK_ICON}</button>`;
|
|
3105
|
+
return `<div class="pb-item${active ? " on" : ""}" data-item="${esc(pin.id)}"><button type="button" class="pb-item-main" data-open="${esc(pin.id)}"><span class="nn">${pinNumber(n)}</span><span class="cc"><span class="tt">${esc(pin.text)}</span><span class="mm"><span class="sdot" style="background:${queued ? "var(--pb-amber)" : STATUS_DOT[status]}"></span><span>${queued ? "QUEUED" : STATUS_TEXT[status]}</span>` + (link ? `<span class="lk">${esc(link.connector)}</span>` : "") + `<span>${esc(locusOf(pin))}</span></span></span></button><span class="acts">${act}</span></div>`;
|
|
3106
|
+
}
|
|
3107
|
+
function groupHtml(key, pins, confirming) {
|
|
3108
|
+
const link = pins[0]?.links?.[0];
|
|
3109
|
+
const open = link ? `<a class="pb-open" href="${esc(safeUrl(link.url))}" target="_blank" rel="noreferrer">OPEN</a>` : "";
|
|
3110
|
+
const label = confirming ? "CONFIRM?" : `RESOLVE ${pins.length}`;
|
|
3111
|
+
return `<div class="pb-group"><span class="gk">${esc(link?.connector ?? "")}</span><span class="gr">#${esc(link?.ref ?? "")}</span><span class="sp"></span>${open}<button type="button" class="pb-gres${confirming ? " confirm" : ""}" data-group-resolve="${esc(key)}" title="Resolve every pin linked to ${esc(key)}">${label}</button></div>`;
|
|
3112
|
+
}
|
|
3113
|
+
/** Open pins split into the ungrouped list and one group per tracker item, in first-seen order. */
|
|
3114
|
+
function groupByLink(open) {
|
|
3115
|
+
const loose = [];
|
|
3116
|
+
const groups = /* @__PURE__ */ new Map();
|
|
3117
|
+
for (const pin of open) {
|
|
3118
|
+
const key = linkKey(pin);
|
|
3119
|
+
if (key === null) loose.push(pin);
|
|
3120
|
+
else groups.set(key, [...groups.get(key) ?? [], pin]);
|
|
3121
|
+
}
|
|
3122
|
+
return {
|
|
3123
|
+
loose,
|
|
3124
|
+
groups
|
|
3125
|
+
};
|
|
2221
3126
|
}
|
|
2222
3127
|
function createDrawer(doc, on) {
|
|
2223
3128
|
const root = doc.createElement("div");
|
|
2224
3129
|
root.className = "pb-drawer";
|
|
2225
3130
|
root.hidden = true;
|
|
2226
|
-
root.innerHTML = `<div class="dh"><span>INBOX</span><button type="button" class="pb-ico" data-ref="close" title="Close">${X_ICON}</button></div><div class="pb-tabs"><button type="button" class="pb-tab on" data-tab="open">OPEN · 0</button><button type="button" class="pb-tab" data-tab="resolved">RESOLVED · 0</button></div><div class="pb-items" data-ref="items"></div>`;
|
|
3131
|
+
root.innerHTML = `<div class="dh"><span>INBOX</span><button type="button" class="pb-ico" data-ref="close" title="Close">${X_ICON}</button></div><div class="pb-tabs"><button type="button" class="pb-tab on" data-tab="open">OPEN · 0</button><button type="button" class="pb-tab" data-tab="notes">NOTES · 0</button><button type="button" class="pb-tab" data-tab="resolved">RESOLVED · 0</button></div><div class="pb-items" data-ref="items"></div><div class="pb-dfoot" data-ref="foot" hidden></div>`;
|
|
2227
3132
|
let tab = "open";
|
|
2228
3133
|
let last = null;
|
|
2229
3134
|
let itemsMemo = "";
|
|
3135
|
+
/** Group key awaiting its confirming click, if any. */
|
|
3136
|
+
let confirming = null;
|
|
3137
|
+
let confirmTimer = 0;
|
|
2230
3138
|
const items = root.querySelector("[data-ref=\"items\"]");
|
|
3139
|
+
const foot = root.querySelector("[data-ref=\"foot\"]");
|
|
3140
|
+
let footMemo = "";
|
|
2231
3141
|
const tabButtons = [...root.querySelectorAll("[data-tab]")];
|
|
3142
|
+
const win = doc.defaultView;
|
|
2232
3143
|
root.querySelector("[data-ref=\"close\"]")?.addEventListener("click", on.onClose);
|
|
2233
3144
|
for (const btn of tabButtons) btn.addEventListener("click", () => {
|
|
2234
3145
|
tab = btn.getAttribute("data-tab");
|
|
2235
3146
|
if (last) render(last);
|
|
2236
3147
|
});
|
|
3148
|
+
function setConfirming(key) {
|
|
3149
|
+
confirming = key;
|
|
3150
|
+
win?.clearTimeout(confirmTimer);
|
|
3151
|
+
if (key !== null) confirmTimer = win?.setTimeout(() => setConfirming(null), CONFIRM_MS) ?? 0;
|
|
3152
|
+
if (last) render(last);
|
|
3153
|
+
}
|
|
3154
|
+
function resolveGroup(key) {
|
|
3155
|
+
if (confirming !== key) {
|
|
3156
|
+
setConfirming(key);
|
|
3157
|
+
return;
|
|
3158
|
+
}
|
|
3159
|
+
setConfirming(null);
|
|
3160
|
+
const pins = last?.pins.filter((p) => p.status === "open" && linkKey(p) === key) ?? [];
|
|
3161
|
+
for (const pin of pins) on.onResolve(pin.id, `shipped in ${key}`);
|
|
3162
|
+
}
|
|
3163
|
+
/** Every open pin the agent never answered (dogfood #6–10): one click clears the backlog. */
|
|
3164
|
+
function stalePins(state) {
|
|
3165
|
+
return state.pins.filter((p) => p.status === "open" && !state.queuedIds.has(p.id) && deriveUiStatus(p, state.threads.get(p.id) ?? [], state) === "stale");
|
|
3166
|
+
}
|
|
3167
|
+
function resolveStale() {
|
|
3168
|
+
if (confirming !== "stale") {
|
|
3169
|
+
setConfirming("stale");
|
|
3170
|
+
return;
|
|
3171
|
+
}
|
|
3172
|
+
setConfirming(null);
|
|
3173
|
+
for (const pin of last ? stalePins(last) : []) on.onResolve(pin.id, "no agent response");
|
|
3174
|
+
}
|
|
3175
|
+
foot.addEventListener("click", (e) => {
|
|
3176
|
+
if (e.target.closest?.("[data-bulk]")) resolveStale();
|
|
3177
|
+
});
|
|
2237
3178
|
items.addEventListener("click", (e) => {
|
|
2238
|
-
const
|
|
3179
|
+
const target = e.target;
|
|
3180
|
+
const act = target.closest?.("[data-act]");
|
|
3181
|
+
if (act) {
|
|
3182
|
+
const id = act.closest("[data-item]")?.getAttribute("data-item");
|
|
3183
|
+
if (!id) return;
|
|
3184
|
+
if (act.getAttribute("data-act") === "resolve") on.onResolve(id);
|
|
3185
|
+
else on.onUnresolve(id);
|
|
3186
|
+
return;
|
|
3187
|
+
}
|
|
3188
|
+
const group = target.closest?.("[data-group-resolve]")?.getAttribute("data-group-resolve");
|
|
3189
|
+
if (group) {
|
|
3190
|
+
resolveGroup(group);
|
|
3191
|
+
return;
|
|
3192
|
+
}
|
|
3193
|
+
const id = target.closest?.("[data-open]")?.getAttribute("data-open");
|
|
2239
3194
|
if (id) on.onActivate(id);
|
|
2240
3195
|
});
|
|
3196
|
+
const rowOf = (state) => (p) => itemHtml(p, p.n ?? state.pins.indexOf(p) + 1, p.id === state.activePinId, state.threads.get(p.id) ?? [], state.queuedIds.has(p.id), state);
|
|
3197
|
+
function openHtml(state, open) {
|
|
3198
|
+
const row = rowOf(state);
|
|
3199
|
+
const { loose, groups } = groupByLink(open);
|
|
3200
|
+
let html = loose.map(row).join("");
|
|
3201
|
+
for (const [key, pins] of groups) html += groupHtml(key, pins, confirming === key) + pins.map(row).join("");
|
|
3202
|
+
return html;
|
|
3203
|
+
}
|
|
3204
|
+
/** The bulk footer: present on the OPEN tab exactly while some pin has had no response. */
|
|
3205
|
+
function renderFoot(state) {
|
|
3206
|
+
const stale = tab === "open" ? stalePins(state) : [];
|
|
3207
|
+
const html = stale.length === 0 ? "" : `<button type="button" class="pb-gres${confirming === "stale" ? " confirm" : ""}" data-bulk="stale">${confirming === "stale" ? "CONFIRM?" : `RESOLVE ${stale.length} WITH NO RESPONSE`}</button>`;
|
|
3208
|
+
if (footMemo === html) return;
|
|
3209
|
+
foot.innerHTML = html;
|
|
3210
|
+
foot.hidden = html === "";
|
|
3211
|
+
footMemo = html;
|
|
3212
|
+
}
|
|
2241
3213
|
function render(state) {
|
|
2242
|
-
const open = state.pins.filter((p) => p.status === "open");
|
|
2243
|
-
const
|
|
2244
|
-
|
|
2245
|
-
|
|
2246
|
-
|
|
2247
|
-
|
|
2248
|
-
|
|
2249
|
-
|
|
2250
|
-
|
|
2251
|
-
|
|
3214
|
+
const open = state.pins.filter((p) => p.status === "open" && p.kind !== "comment");
|
|
3215
|
+
const counts = {
|
|
3216
|
+
open,
|
|
3217
|
+
notes: state.pins.filter((p) => p.status === "open" && p.kind === "comment"),
|
|
3218
|
+
resolved: state.pins.filter((p) => p.status === "resolved")
|
|
3219
|
+
};
|
|
3220
|
+
for (const btn of tabButtons) {
|
|
3221
|
+
const key = btn.getAttribute("data-tab");
|
|
3222
|
+
btn.textContent = `${key.toUpperCase()} · ${counts[key].length}`;
|
|
3223
|
+
btn.classList.toggle("on", tab === key);
|
|
2252
3224
|
}
|
|
2253
|
-
|
|
2254
|
-
const
|
|
3225
|
+
renderFoot(state);
|
|
3226
|
+
const list = counts[tab];
|
|
3227
|
+
const html = list.length ? tab === "open" ? openHtml(state, open) : list.map(rowOf(state)).join("") : "<div class=\"pb-empty\">Nothing here yet.</div>";
|
|
2255
3228
|
if (itemsMemo !== html) {
|
|
2256
3229
|
items.innerHTML = html;
|
|
2257
3230
|
itemsMemo = html;
|
|
@@ -2284,36 +3257,17 @@ var Pinbox = (function(exports) {
|
|
|
2284
3257
|
};
|
|
2285
3258
|
}
|
|
2286
3259
|
//#endregion
|
|
2287
|
-
//#region src/ui/multimarks.ts
|
|
2288
|
-
const MARK_CLASS = "pb-multi-mark";
|
|
2289
|
-
/** Replace the mark set to mirror `targets`; entries with no rect draw nothing. */
|
|
2290
|
-
function renderMultiMarks(layer, targets) {
|
|
2291
|
-
for (const node of [...layer.querySelectorAll(`.${MARK_CLASS}`)]) node.remove();
|
|
2292
|
-
targets.forEach((target, i) => {
|
|
2293
|
-
const rect = target.rect;
|
|
2294
|
-
if (rect === void 0) return;
|
|
2295
|
-
const mark = layer.ownerDocument.createElement("div");
|
|
2296
|
-
mark.className = MARK_CLASS;
|
|
2297
|
-
mark.style.left = `${rect.x}px`;
|
|
2298
|
-
mark.style.top = `${rect.y}px`;
|
|
2299
|
-
mark.style.width = `${rect.width}px`;
|
|
2300
|
-
mark.style.height = `${rect.height}px`;
|
|
2301
|
-
mark.innerHTML = `<span>${i + 1}</span>`;
|
|
2302
|
-
layer.appendChild(mark);
|
|
2303
|
-
});
|
|
2304
|
-
}
|
|
2305
|
-
//#endregion
|
|
2306
3260
|
//#region src/ui/puck.ts
|
|
2307
3261
|
/** The bar's ident mark, sized up for the 48px puck face. */
|
|
2308
3262
|
const PUCK_ICON = "<svg width=\"17\" height=\"17\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.4\"><rect x=\"2.5\" y=\"1.5\" width=\"11\" height=\"7\" rx=\"1\"/><path d=\"M8 8.5v6\"/><circle cx=\"8\" cy=\"14.6\" r=\".9\" fill=\"currentColor\" stroke=\"none\"/></svg>";
|
|
2309
|
-
|
|
2310
|
-
const
|
|
2311
|
-
|
|
2312
|
-
|
|
2313
|
-
|
|
2314
|
-
|
|
2315
|
-
|
|
2316
|
-
return `<button type="button" class="pb-fan-item" data-act="${act}" style="--i:${index}" aria-label="${label}">${icon}${
|
|
3263
|
+
/** Everything in the action table that asked for a fan item, in table order. */
|
|
3264
|
+
const FAN_ACTIONS = ACTIONS.filter((a) => a.fan !== void 0);
|
|
3265
|
+
function fanItem(a, index) {
|
|
3266
|
+
const act = a.fan?.act ?? a.id;
|
|
3267
|
+
const label = a.fan?.label ?? a.label;
|
|
3268
|
+
const glyph = act === "expand" ? EXPAND_GLYPH : a.glyph ?? "";
|
|
3269
|
+
const badge = a.id === "inbox" ? "<span class=\"badge\" data-ref=\"count\" hidden>0</span>" : "";
|
|
3270
|
+
return `<button type="button" class="pb-fan-item" data-act="${act}" style="--i:${index}" aria-label="${label}">${icon(glyph, 15)}${badge}<span class="fl">${label.toUpperCase()}<i>${keyLabelOf(a)}</i></span></button>`;
|
|
2317
3271
|
}
|
|
2318
3272
|
function createMinimizeUi(doc) {
|
|
2319
3273
|
const puck = doc.createElement("button");
|
|
@@ -2326,7 +3280,7 @@ var Pinbox = (function(exports) {
|
|
|
2326
3280
|
fan.className = "pb-fan";
|
|
2327
3281
|
fan.hidden = true;
|
|
2328
3282
|
fan.setAttribute("role", "menu");
|
|
2329
|
-
fan.innerHTML =
|
|
3283
|
+
fan.innerHTML = FAN_ACTIONS.map(fanItem).join("");
|
|
2330
3284
|
const morphWrap = doc.createElement("div");
|
|
2331
3285
|
morphWrap.className = "pb-morph-wrap";
|
|
2332
3286
|
morphWrap.hidden = true;
|
|
@@ -2352,7 +3306,7 @@ var Pinbox = (function(exports) {
|
|
|
2352
3306
|
surface,
|
|
2353
3307
|
carrier,
|
|
2354
3308
|
update(state) {
|
|
2355
|
-
const open = String(state.pins
|
|
3309
|
+
const open = String(openTaskCount(state.pins));
|
|
2356
3310
|
for (const badge of badges) {
|
|
2357
3311
|
if (badge.textContent !== open) badge.textContent = open;
|
|
2358
3312
|
badge.hidden = open === "0";
|
|
@@ -2363,7 +3317,7 @@ var Pinbox = (function(exports) {
|
|
|
2363
3317
|
if (hideShown !== state.pinsHidden) {
|
|
2364
3318
|
hideShown = state.pinsHidden;
|
|
2365
3319
|
const label = state.pinsHidden ? "Show pins" : "Hide pins";
|
|
2366
|
-
hideItem.innerHTML =
|
|
3320
|
+
hideItem.innerHTML = icon(state.pinsHidden ? EYE_GLYPH : EYE_OFF_GLYPH, 15) + `<span class="fl">${label.toUpperCase()}<i>H</i></span>`;
|
|
2367
3321
|
hideItem.setAttribute("aria-label", label);
|
|
2368
3322
|
hideItem.classList.toggle("lit", state.pinsHidden);
|
|
2369
3323
|
}
|
|
@@ -2371,70 +3325,19 @@ var Pinbox = (function(exports) {
|
|
|
2371
3325
|
};
|
|
2372
3326
|
}
|
|
2373
3327
|
//#endregion
|
|
2374
|
-
//#region src/ui/reticle.ts
|
|
2375
|
-
function createReticle(doc) {
|
|
2376
|
-
const crosshair = doc.createElement("div");
|
|
2377
|
-
crosshair.className = "pb-reticle";
|
|
2378
|
-
crosshair.innerHTML = "<div class=\"h\"></div><div class=\"v\"></div><div class=\"box\"></div><div class=\"ro\"></div>";
|
|
2379
|
-
const h = crosshair.querySelector(".h");
|
|
2380
|
-
const v = crosshair.querySelector(".v");
|
|
2381
|
-
const box = crosshair.querySelector(".box");
|
|
2382
|
-
const readout = crosshair.querySelector(".ro");
|
|
2383
|
-
const outline = doc.createElement("div");
|
|
2384
|
-
outline.className = "pb-outline";
|
|
2385
|
-
outline.innerHTML = "<span class=\"lab\"></span>";
|
|
2386
|
-
const lab = outline.querySelector(".lab");
|
|
2387
|
-
function setOutlineRect(rect, scroll) {
|
|
2388
|
-
outline.style.left = `${rect.left + scroll.x - 5}px`;
|
|
2389
|
-
outline.style.top = `${rect.top + scroll.y - 5}px`;
|
|
2390
|
-
outline.style.width = `${rect.width + 10}px`;
|
|
2391
|
-
outline.style.height = `${rect.height + 10}px`;
|
|
2392
|
-
}
|
|
2393
|
-
return {
|
|
2394
|
-
crosshair,
|
|
2395
|
-
outline,
|
|
2396
|
-
move(pos) {
|
|
2397
|
-
h.style.top = `${pos.clientY}px`;
|
|
2398
|
-
v.style.left = `${pos.clientX}px`;
|
|
2399
|
-
box.style.left = `${pos.clientX}px`;
|
|
2400
|
-
box.style.top = `${pos.clientY}px`;
|
|
2401
|
-
readout.style.left = `${pos.clientX}px`;
|
|
2402
|
-
readout.style.top = `${pos.clientY}px`;
|
|
2403
|
-
readout.textContent = `${Math.round(pos.pageX)} × ${Math.round(pos.pageY)}`;
|
|
2404
|
-
},
|
|
2405
|
-
snap(rect, label, scroll) {
|
|
2406
|
-
if (!outline.classList.contains("on")) {
|
|
2407
|
-
outline.style.transition = "none";
|
|
2408
|
-
setOutlineRect(rect, scroll);
|
|
2409
|
-
outline.offsetWidth;
|
|
2410
|
-
outline.style.transition = "";
|
|
2411
|
-
} else setOutlineRect(rect, scroll);
|
|
2412
|
-
lab.textContent = label;
|
|
2413
|
-
outline.classList.add("on");
|
|
2414
|
-
},
|
|
2415
|
-
release() {
|
|
2416
|
-
outline.classList.remove("on");
|
|
2417
|
-
}
|
|
2418
|
-
};
|
|
2419
|
-
}
|
|
2420
|
-
//#endregion
|
|
2421
3328
|
//#region src/ui/shortcuts.ts
|
|
3329
|
+
/** The action table's keyed entries, plus the composer chord the table cannot know about. */
|
|
2422
3330
|
const ROWS = [
|
|
2423
|
-
|
|
2424
|
-
["Open inbox", "I"],
|
|
2425
|
-
["Toggle theme", "D"],
|
|
3331
|
+
...ACTIONS.filter((a) => a.key !== void 0 && a.help !== false).map((a) => [a.label, keyLabelOf(a)]),
|
|
2426
3332
|
["Send comment", "⌘ ↵"],
|
|
2427
|
-
["
|
|
2428
|
-
["
|
|
2429
|
-
["Hide / show pins", "H"],
|
|
2430
|
-
["Minimize toolbar", "M"],
|
|
2431
|
-
["Cancel", "ESC"]
|
|
3333
|
+
["Move toolbar", "DRAG ⋮"],
|
|
3334
|
+
["Reset toolbar position", "2× GRIP"]
|
|
2432
3335
|
];
|
|
2433
3336
|
function createShortcutsModal(doc, onClose) {
|
|
2434
3337
|
const root = doc.createElement("div");
|
|
2435
3338
|
root.className = "pb-modal";
|
|
2436
3339
|
root.hidden = true;
|
|
2437
|
-
root.innerHTML = `<div class="mx"><div class="mh">SHORTCUTS</div><div style="padding:8px 20px 18px">${ROWS.map(([what, key]) => `<div class="mr"><span class="mw">${esc(what)}</span><span class="mk">${esc(key)}</span></div>`).join("")}</div></div>`;
|
|
3340
|
+
root.innerHTML = `<div class="mx"><div class="mh">SHORTCUTS</div><div style="padding:8px 20px 18px">${ROWS.map(([what, key]) => `<div class="mr"><span class="mw">${esc(what)}</span><span class="mk">${esc(key)}</span></div>`).join("")}</div><div class="mf">Shortcuts need the page focused. Inside an embedded frame, click the page first.</div></div>`;
|
|
2438
3341
|
root.addEventListener("click", onClose);
|
|
2439
3342
|
return {
|
|
2440
3343
|
root,
|
|
@@ -2494,8 +3397,10 @@ button { font: inherit; color: inherit; background: none; border: 0; cursor: poi
|
|
|
2494
3397
|
@keyframes pb-pulse { 0%,100% { opacity:.3 } 50% { opacity:1 } }
|
|
2495
3398
|
@keyframes pb-resolve { to { opacity:0; transform:translateY(-10px) } }
|
|
2496
3399
|
|
|
2497
|
-
/* overlay layer
|
|
2498
|
-
|
|
3400
|
+
/* overlay layer in VIEWPORT space: fixed, re-laid out on every scroll/resize frame (element.ts).
|
|
3401
|
+
Document-space it drifted — inner scroll containers, sticky anchors and any transformed
|
|
3402
|
+
ancestor of the host all broke the "page scrolls on window from the origin" assumption. */
|
|
3403
|
+
.pb-overlay { position: fixed; inset: 0; pointer-events: none; }
|
|
2499
3404
|
|
|
2500
3405
|
.pb-outline { position: absolute; z-index: 30; pointer-events: none; border: 1px solid var(--pb-amber); border-radius: 2px; background: var(--pb-amber-soft); opacity: 0;
|
|
2501
3406
|
transition: left 220ms var(--pb-ease), top 220ms var(--pb-ease), width 220ms var(--pb-ease), height 220ms var(--pb-ease), opacity 150ms linear; }
|
|
@@ -2529,7 +3434,7 @@ button { font: inherit; color: inherit; background: none; border: 0; cursor: poi
|
|
|
2529
3434
|
.pb-aim .bar .cancel { flex: none; padding: 0 18px; border: 1px solid var(--pb-line-2); background: transparent; color: var(--pb-fg2); }
|
|
2530
3435
|
.pb-aim .bar .ok { flex: none; padding: 0 20px; border: none; background: var(--pb-amber); color: var(--pb-amber-ink); }
|
|
2531
3436
|
|
|
2532
|
-
.pb-pin { position: absolute; }
|
|
3437
|
+
.pb-pin { position: absolute; pointer-events: auto; }
|
|
2533
3438
|
.pb-pin.resolving { animation: pb-resolve 380ms var(--pb-ease) forwards; }
|
|
2534
3439
|
.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; }
|
|
2535
3440
|
.pb-pin .dot { position: absolute; left: -3px; top: -3px; width: 7px; height: 7px; border-radius: 999px; background: var(--pb-amber); box-shadow: 0 0 0 2px var(--pb-canvas); }
|
|
@@ -2541,11 +3446,19 @@ button { font: inherit; color: inherit; background: none; border: 0; cursor: poi
|
|
|
2541
3446
|
.pb-chipBtn .lk { display: flex; align-items: center; gap: 5px; padding-left: 6px; margin-left: 1px; border-left: 1px solid var(--pb-line-2); font-size: 9.5px; letter-spacing: .02em; opacity: .85; }
|
|
2542
3447
|
.pb-pin.hot .pb-chipBtn .lk { border-left-color: color-mix(in srgb, var(--pb-amber-ink) 28%, transparent); }
|
|
2543
3448
|
.pb-pin.queued .pb-chipBtn { border-style: dashed; }
|
|
3449
|
+
.pb-pin.stale:not(.hot) .pb-chipBtn { border-color: var(--pb-amber); }
|
|
3450
|
+
.pb-pin.stale:not(.hot) .pb-chipBtn .qd { color: var(--pb-amber); }
|
|
3451
|
+
/* comment pins: muted, an N glyph; hot state stays amber so the active one is unmistakable */
|
|
3452
|
+
.pb-pin.note .dot { background: var(--pb-fg3); }
|
|
3453
|
+
.pb-pin.note .needle { background: linear-gradient(to top, var(--pb-fg3), color-mix(in srgb, var(--pb-fg3) 35%, transparent)); }
|
|
3454
|
+
.pb-pin.note:not(.hot) .pb-chipBtn { color: var(--pb-fg2); border-color: var(--pb-line); }
|
|
3455
|
+
.pb-chipBtn .nt { display: flex; align-items: center; justify-content: center; width: 14px; height: 14px; border-radius: 2px; background: var(--pb-fg3); color: var(--pb-canvas); font-size: 8.5px; letter-spacing: 0; }
|
|
3456
|
+
.pb-pin.hot .pb-chipBtn .nt { background: var(--pb-amber-ink); color: var(--pb-amber); }
|
|
2544
3457
|
.pb-chipBtn .qd { padding-left: 6px; margin-left: 1px; border-left: 1px solid var(--pb-line-2); font-size: 9px; letter-spacing: .12em; color: var(--pb-amber); }
|
|
2545
3458
|
.pb-pin.hot .pb-chipBtn .qd { color: var(--pb-amber-ink); border-left-color: color-mix(in srgb, var(--pb-amber-ink) 28%, transparent); }
|
|
2546
3459
|
|
|
2547
3460
|
/* thread card (ui/card.ts) — prototype lines 121–190 */
|
|
2548
|
-
.pb-card { position: absolute; z-index: 80; width: 344px; }
|
|
3461
|
+
.pb-card { position: absolute; z-index: 80; width: 344px; pointer-events: auto; }
|
|
2549
3462
|
.pb-card .in { animation: pb-in 260ms var(--pb-ease) both; background: var(--pb-elev); border: 1px solid var(--pb-line-2); border-radius: 4px; box-shadow: var(--pb-shadow); overflow: hidden; }
|
|
2550
3463
|
.pb-hd { display: flex; align-items: center; justify-content: space-between; padding: 10px 12px; border-bottom: 1px solid var(--pb-line); background: var(--pb-surface); }
|
|
2551
3464
|
.pb-hd .meta { display: flex; align-items: center; gap: 9px; font-family: var(--pb-font-mono); font-size: 10px; letter-spacing: .18em; color: var(--pb-fg3); }
|
|
@@ -2594,6 +3507,17 @@ button { font: inherit; color: inherit; background: none; border: 0; cursor: poi
|
|
|
2594
3507
|
.pb-change .ft { display: flex; align-items: center; gap: 8px; padding: 9px 10px; background: var(--pb-surface); border-top: 1px solid var(--pb-line); }
|
|
2595
3508
|
.pb-change .applied { font-family: var(--pb-font-mono); font-size: 9.5px; letter-spacing: .16em; color: var(--pb-ok); display: flex; align-items: center; gap: 8px; }
|
|
2596
3509
|
.pb-change .applied .hh { color: var(--pb-fg4); }
|
|
3510
|
+
.pb-stale { display: flex; align-items: center; gap: 8px; padding: 9px 12px; background: color-mix(in srgb, var(--pb-amber) 8%, var(--pb-surface)); border-top: 1px solid var(--pb-line); font-size: 11.5px; color: var(--pb-fg2); }
|
|
3511
|
+
.pb-stale .msg { flex: 1; min-width: 0; }
|
|
3512
|
+
.pb-typing.quiet .lbl { color: var(--pb-fg3); }
|
|
3513
|
+
.pb-dfoot { padding: 10px 16px; border-top: 1px solid var(--pb-line); display: flex; justify-content: flex-end; }
|
|
3514
|
+
.pb-resnote { padding: 8px 12px; font-size: 11.5px; line-height: 1.45; color: var(--pb-fg2); border-top: 1px solid var(--pb-line); }
|
|
3515
|
+
.pb-resnote .hh { font-family: var(--pb-font-mono); font-size: 10px; color: var(--pb-fg3); }
|
|
3516
|
+
.pb-seg { display: flex; margin-right: auto; border: 1px solid var(--pb-line-2); border-radius: 2px; overflow: hidden; }
|
|
3517
|
+
.pb-seg button { height: 24px; padding: 0 9px; font-size: 10.5px; color: var(--pb-fg3); transition: background 120ms linear, color 120ms linear; }
|
|
3518
|
+
.pb-seg button + button { border-left: 1px solid var(--pb-line-2); }
|
|
3519
|
+
.pb-seg button:hover { color: var(--pb-fg1); }
|
|
3520
|
+
.pb-seg button.on { background: var(--pb-hover); color: var(--pb-fg1); }
|
|
2597
3521
|
.pb-verify { display: flex; align-items: center; gap: 8px; padding: 9px 12px; background: var(--pb-surface); border-top: 1px solid var(--pb-line); }
|
|
2598
3522
|
.pb-bt-solid { height: 26px; padding: 0 13px; border-radius: 2px; background: var(--pb-invert-bg); color: var(--pb-invert-fg); font-size: 11.5px; }
|
|
2599
3523
|
.pb-bt-solid:hover { opacity: .9; }
|
|
@@ -2609,6 +3533,11 @@ button { font: inherit; color: inherit; background: none; border: 0; cursor: poi
|
|
|
2609
3533
|
|
|
2610
3534
|
/* command bar */
|
|
2611
3535
|
.pb-bar { position: fixed; left: 50%; bottom: 26px; transform: translateX(-50%); z-index: 90; display: flex; align-items: center; height: 46px; padding: 0 6px; gap: 3px; background: var(--pb-bar); backdrop-filter: blur(16px); -webkit-backdrop-filter: blur(16px); border: 1px solid var(--pb-line-2); border-radius: 4px; box-shadow: var(--pb-shadow); }
|
|
3536
|
+
/* Dragged once: explicit left/top (inline) replace the bottom-centre default. */
|
|
3537
|
+
.pb-bar.free { bottom: auto; transform: none; }
|
|
3538
|
+
.pb-bar .grip { display: flex; align-items: center; justify-content: center; width: 14px; height: 32px; margin-left: 2px; color: var(--pb-fg4); cursor: grab; touch-action: none; border-radius: 2px; }
|
|
3539
|
+
.pb-bar .grip:hover { color: var(--pb-fg2); background: var(--pb-hover); }
|
|
3540
|
+
.pb-bar.dragging .grip { cursor: grabbing; }
|
|
2612
3541
|
.pb-bar .armed-ring { position: absolute; inset: -1px; border: 1px solid var(--pb-amber); border-radius: 4px; box-shadow: 0 0 32px var(--pb-amber-soft); pointer-events: none; animation: pb-fade 200ms ease-out both; display: none; }
|
|
2613
3542
|
:host([data-placing]) .pb-bar .armed-ring { display: block; }
|
|
2614
3543
|
.pb-bar .ident { display: flex; align-items: center; gap: 9px; padding: 0 12px 0 10px; min-width: 150px; }
|
|
@@ -2628,8 +3557,18 @@ button { font: inherit; color: inherit; background: none; border: 0; cursor: poi
|
|
|
2628
3557
|
.pb-tab { padding: 0 0 10px; white-space: nowrap; font-family: var(--pb-font-mono); font-size: 10px; letter-spacing: .18em; color: var(--pb-fg3); border-bottom: 1px solid transparent; }
|
|
2629
3558
|
.pb-tab.on { color: var(--pb-fg1); border-bottom-color: var(--pb-amber); }
|
|
2630
3559
|
.pb-items { flex: 1; overflow: auto; }
|
|
2631
|
-
.pb-item {
|
|
3560
|
+
.pb-item { display: flex; align-items: flex-start; border-bottom: 1px solid var(--pb-line); }
|
|
2632
3561
|
.pb-item:hover { background: var(--pb-hover); }
|
|
3562
|
+
.pb-item-main { flex: 1; min-width: 0; text-align: left; display: flex; gap: 11px; padding: 14px 0 14px 16px; }
|
|
3563
|
+
/* Row actions show on hover/focus; always on touch, where there is no hover. */
|
|
3564
|
+
.pb-item .acts { display: flex; flex-direction: column; gap: 2px; padding: 12px 10px 0 6px; opacity: 0; transition: opacity 120ms linear; }
|
|
3565
|
+
.pb-item:hover .acts, .pb-item:focus-within .acts { opacity: 1; }
|
|
3566
|
+
@media (hover: none) { .pb-item .acts { opacity: 1; } }
|
|
3567
|
+
.pb-group { display: flex; align-items: center; gap: 8px; padding: 8px 16px; background: color-mix(in srgb, var(--pb-info) 7%, var(--pb-surface)); border-bottom: 1px solid var(--pb-line); font-family: var(--pb-font-mono); font-size: 9.5px; letter-spacing: .14em; color: var(--pb-fg2); }
|
|
3568
|
+
.pb-group .gr { color: var(--pb-fg1); }
|
|
3569
|
+
.pb-group .sp { flex: 1; }
|
|
3570
|
+
.pb-gres { height: 22px; padding: 0 8px; border: 1px solid var(--pb-line-2); border-radius: 2px; font-family: var(--pb-font-mono); font-size: 9px; letter-spacing: .14em; color: var(--pb-fg1); transition: color 120ms linear, border-color 120ms linear; }
|
|
3571
|
+
.pb-gres:hover, .pb-gres.confirm { border-color: var(--pb-ok); color: var(--pb-ok); }
|
|
2633
3572
|
.pb-item .nn { flex: none; display: flex; align-items: center; justify-content: center; min-width: 24px; height: 20px; border-radius: 2px; border: 1px solid var(--pb-line-2); color: var(--pb-fg2); font-family: var(--pb-font-mono); font-size: 10px; letter-spacing: .06em; }
|
|
2634
3573
|
.pb-item.on .nn { background: var(--pb-amber); color: var(--pb-amber-ink); border-color: var(--pb-amber); }
|
|
2635
3574
|
.pb-item .cc { display: flex; flex-direction: column; gap: 5px; min-width: 0; }
|
|
@@ -2687,6 +3626,7 @@ button { font: inherit; color: inherit; background: none; border: 0; cursor: poi
|
|
|
2687
3626
|
.pb-modal .mh { padding: 18px 20px 14px; border-bottom: 1px solid var(--pb-line); font-family: var(--pb-font-mono); font-size: 10px; letter-spacing: .24em; }
|
|
2688
3627
|
.pb-modal .mr { display: flex; align-items: center; justify-content: space-between; padding: 9px 0; border-bottom: 1px solid var(--pb-line); }
|
|
2689
3628
|
.pb-modal .mw { font-size: 13px; letter-spacing: -.005em; color: var(--pb-fg2); }
|
|
3629
|
+
.pb-modal .mf { padding: 0 20px 16px; font-size: 10.5px; color: var(--pb-fg2); }
|
|
2690
3630
|
.pb-modal .mk { display: flex; align-items: center; justify-content: center; min-width: 26px; height: 22px; padding: 0 7px; border: 1px solid var(--pb-line-2); border-radius: 2px; background: var(--pb-sunken); font-family: var(--pb-font-mono); font-size: 10.5px; }
|
|
2691
3631
|
|
|
2692
3632
|
[hidden] { display: none !important; }
|
|
@@ -2701,13 +3641,9 @@ button { font: inherit; color: inherit; background: none; border: 0; cursor: poi
|
|
|
2701
3641
|
const PAGE_CSS = `body.${PAGE_PLACING_CLASS}, body.${PAGE_PLACING_CLASS} * { cursor: none !important; }`;
|
|
2702
3642
|
//#endregion
|
|
2703
3643
|
//#region src/element.ts
|
|
3644
|
+
/** How long a pin commit waits for its picture before shipping without one. */
|
|
3645
|
+
const CAPTURE_TIMEOUT_MS = 4e3;
|
|
2704
3646
|
const BaseElement = globalThis.HTMLElement ?? class {};
|
|
2705
|
-
/** Keystrokes are ignored while a text control has focus (prototype line 702–703). */
|
|
2706
|
-
function isTextEntry(target) {
|
|
2707
|
-
const el = target;
|
|
2708
|
-
const tag = el?.tagName ?? "";
|
|
2709
|
-
return tag === "TEXTAREA" || tag === "INPUT" || el?.isContentEditable === true;
|
|
2710
|
-
}
|
|
2711
3647
|
var PinboxToolbarElement = class extends BaseElement {
|
|
2712
3648
|
static tagName = "pinbox-toolbar";
|
|
2713
3649
|
/** Watched so a config that arrives after insertion can still start the transport. */
|
|
@@ -2729,28 +3665,31 @@ button { font: inherit; color: inherit; background: none; border: 0; cursor: poi
|
|
|
2729
3665
|
#token = "";
|
|
2730
3666
|
#built = false;
|
|
2731
3667
|
#bar = null;
|
|
2732
|
-
#reticle = null;
|
|
2733
3668
|
#pinsLayer = null;
|
|
2734
3669
|
#drawer = null;
|
|
2735
|
-
|
|
2736
|
-
|
|
2737
|
-
#viewportFrame = 0;
|
|
3670
|
+
/** Reticle, drag-aim and multi-target capture — see placement.ts. */
|
|
3671
|
+
#placement = null;
|
|
2738
3672
|
#modal = null;
|
|
2739
3673
|
#minUi = null;
|
|
2740
3674
|
#min = null;
|
|
3675
|
+
/** The bar's grip drag; same lifetime as the minimize controller. */
|
|
3676
|
+
#barDrag = null;
|
|
2741
3677
|
/** SPA view watcher: DOM/history changes re-run the anchor-gated render. */
|
|
2742
3678
|
#anchors = null;
|
|
2743
3679
|
#helpOpen = false;
|
|
3680
|
+
/** The 30 s wall-clock tick that ages pending pins into WAITING / NO RESPONSE. 0 when idle. */
|
|
3681
|
+
#clockTimer = 0;
|
|
3682
|
+
/** Pending viewport re-layout frame, 0 when none is queued. */
|
|
3683
|
+
#layoutFrame = 0;
|
|
3684
|
+
#resizeObserver = null;
|
|
2744
3685
|
#pageStyle = null;
|
|
2745
3686
|
#unsubscribe = null;
|
|
2746
|
-
#hover = null;
|
|
2747
|
-
/** Shift+click accumulation while placing — extra loci for ONE pending pin. */
|
|
2748
|
-
#extraTargets = [];
|
|
2749
3687
|
/** Card → element: send/verify/resolve forward to the transport seam; close dismisses. */
|
|
2750
3688
|
#cardActions = {
|
|
2751
|
-
send: (pinId, text) => this.actions.send?.(pinId, text),
|
|
3689
|
+
send: (pinId, text, kind) => this.actions.send?.(pinId, text, kind),
|
|
2752
3690
|
verify: (pinId, outcome) => this.actions.verify?.(pinId, outcome),
|
|
2753
3691
|
resolve: (pinId) => this.actions.resolve?.(pinId),
|
|
3692
|
+
nudge: (pinId) => this.#nudge(pinId),
|
|
2754
3693
|
copy: (pinId) => this.#copyPin(pinId),
|
|
2755
3694
|
close: () => this.#dismiss()
|
|
2756
3695
|
};
|
|
@@ -2774,6 +3713,32 @@ button { font: inherit; color: inherit; background: none; border: 0; cursor: poi
|
|
|
2774
3713
|
this.#built = true;
|
|
2775
3714
|
this.#build();
|
|
2776
3715
|
}
|
|
3716
|
+
this.#applyPageDefaults();
|
|
3717
|
+
if (this.shadowRoot) this.#placement?.connect(this.shadowRoot);
|
|
3718
|
+
if (this.#min === null) this.#mountMinimize();
|
|
3719
|
+
if (this.#barDrag === null) this.#mountBarDrag();
|
|
3720
|
+
this.#anchors = watchAnchors(window, () => this.#render(this.store.get()));
|
|
3721
|
+
this.#listen();
|
|
3722
|
+
this.#unsubscribe = this.store.subscribe((s) => this.#render(s));
|
|
3723
|
+
this.#render(this.store.get());
|
|
3724
|
+
this.#startClock();
|
|
3725
|
+
this.store.update({ captureMode: this.#loadCaptureMode() });
|
|
3726
|
+
this.#queueStart();
|
|
3727
|
+
}
|
|
3728
|
+
/** Persisted per endpoint beside the puck dock; PinboxConfig.capture is the first-run default. */
|
|
3729
|
+
#loadCaptureMode() {
|
|
3730
|
+
const key = captureKey(`pinbox:${this.config?.endpoint ?? ""}`);
|
|
3731
|
+
return loadCaptureMode(globalThis.localStorage, key, this.config?.capture ?? "dom");
|
|
3732
|
+
}
|
|
3733
|
+
#toggleCapture() {
|
|
3734
|
+
const next = this.store.get().captureMode === "tab" ? "dom" : "tab";
|
|
3735
|
+
this.store.update({ captureMode: next });
|
|
3736
|
+
if (next === "dom") releaseCapture();
|
|
3737
|
+
saveCaptureMode(globalThis.localStorage, captureKey(`pinbox:${this.config?.endpoint ?? ""}`), next);
|
|
3738
|
+
return true;
|
|
3739
|
+
}
|
|
3740
|
+
/** Theme from the OS when the host set none; the page-level CSS (placing cursor) into <head>. */
|
|
3741
|
+
#applyPageDefaults() {
|
|
2777
3742
|
if (!this.hasAttribute("data-pb")) {
|
|
2778
3743
|
const dark = window.matchMedia("(prefers-color-scheme: dark)").matches;
|
|
2779
3744
|
this.setAttribute("data-pb", dark ? "dark" : "light");
|
|
@@ -2782,17 +3747,37 @@ button { font: inherit; color: inherit; background: none; border: 0; cursor: poi
|
|
|
2782
3747
|
style.textContent = PAGE_CSS;
|
|
2783
3748
|
document.head.appendChild(style);
|
|
2784
3749
|
this.#pageStyle = style;
|
|
2785
|
-
|
|
2786
|
-
|
|
2787
|
-
this.#
|
|
2788
|
-
|
|
2789
|
-
|
|
2790
|
-
|
|
3750
|
+
}
|
|
3751
|
+
#mountBarDrag() {
|
|
3752
|
+
if (this.#bar === null) return;
|
|
3753
|
+
this.#barDrag = createBarDrag({
|
|
3754
|
+
win: window,
|
|
3755
|
+
bar: this.#bar.root,
|
|
3756
|
+
grip: this.#bar.grip,
|
|
3757
|
+
storage: globalThis.localStorage ?? null,
|
|
3758
|
+
storagePrefix: `pinbox:${this.config?.endpoint ?? ""}`
|
|
3759
|
+
});
|
|
3760
|
+
}
|
|
3761
|
+
/** The page-level listeners of one connected lifetime; disconnectedCallback removes them. */
|
|
3762
|
+
#listen() {
|
|
2791
3763
|
document.addEventListener("click", this.#onClickCapture, true);
|
|
2792
|
-
|
|
2793
|
-
|
|
2794
|
-
|
|
2795
|
-
|
|
3764
|
+
window.addEventListener("keydown", this.#onKeyDown, true);
|
|
3765
|
+
document.addEventListener("scroll", this.#onLayoutChange, {
|
|
3766
|
+
capture: true,
|
|
3767
|
+
passive: true
|
|
3768
|
+
});
|
|
3769
|
+
window.addEventListener("resize", this.#onLayoutChange);
|
|
3770
|
+
const RO = globalThis.ResizeObserver;
|
|
3771
|
+
if (typeof RO === "function") {
|
|
3772
|
+
this.#resizeObserver = new RO(this.#onLayoutChange);
|
|
3773
|
+
this.#resizeObserver.observe(document.body);
|
|
3774
|
+
}
|
|
3775
|
+
}
|
|
3776
|
+
/** Age is state (state.ts `clock`), so a tick is a render and stale pins flip without a hub event. */
|
|
3777
|
+
#startClock() {
|
|
3778
|
+
const tick = () => this.store.update({ clock: Date.now() });
|
|
3779
|
+
tick();
|
|
3780
|
+
this.#clockTimer = window.setInterval(tick, 3e4);
|
|
2796
3781
|
}
|
|
2797
3782
|
/**
|
|
2798
3783
|
* Late-config rescue: an element inserted BEFORE its hub/token attributes were
|
|
@@ -2821,45 +3806,30 @@ button { font: inherit; color: inherit; background: none; border: 0; cursor: poi
|
|
|
2821
3806
|
this.#lifetime += 1;
|
|
2822
3807
|
this.#transport?.close();
|
|
2823
3808
|
this.#transport = null;
|
|
2824
|
-
document.removeEventListener("mousemove", this.#onMouseMove);
|
|
2825
|
-
window.removeEventListener("scroll", this.#onViewportChange);
|
|
2826
|
-
window.removeEventListener("resize", this.#onViewportChange);
|
|
2827
3809
|
document.removeEventListener("click", this.#onClickCapture, true);
|
|
2828
|
-
|
|
2829
|
-
|
|
2830
|
-
this.#
|
|
2831
|
-
this.#
|
|
2832
|
-
this.#
|
|
3810
|
+
window.removeEventListener("keydown", this.#onKeyDown, true);
|
|
3811
|
+
document.removeEventListener("scroll", this.#onLayoutChange, { capture: true });
|
|
3812
|
+
window.removeEventListener("resize", this.#onLayoutChange);
|
|
3813
|
+
this.#resizeObserver?.disconnect();
|
|
3814
|
+
this.#resizeObserver = null;
|
|
3815
|
+
if (this.#layoutFrame !== 0) cancelAnimationFrame(this.#layoutFrame);
|
|
3816
|
+
this.#layoutFrame = 0;
|
|
3817
|
+
this.#placement?.disconnect();
|
|
2833
3818
|
this.#min?.destroy();
|
|
2834
3819
|
this.#min = null;
|
|
3820
|
+
this.#barDrag?.destroy();
|
|
3821
|
+
this.#barDrag = null;
|
|
2835
3822
|
releaseCapture();
|
|
2836
3823
|
this.#anchors?.destroy();
|
|
2837
3824
|
this.#anchors = null;
|
|
2838
3825
|
this.#unsubscribe?.();
|
|
2839
3826
|
this.#unsubscribe = null;
|
|
3827
|
+
window.clearInterval(this.#clockTimer);
|
|
3828
|
+
this.#clockTimer = 0;
|
|
2840
3829
|
this.#pageStyle?.remove();
|
|
2841
3830
|
this.#pageStyle = null;
|
|
2842
3831
|
document.body.classList.remove(PAGE_PLACING_CLASS);
|
|
2843
3832
|
}
|
|
2844
|
-
/**
|
|
2845
|
-
* Create the aim controller and put its layer in the shadow root.
|
|
2846
|
-
*
|
|
2847
|
-
* Separate from `#build` because the two have different lifetimes: `#build` runs once, but
|
|
2848
|
-
* `disconnectedCallback` tears this controller's window listeners down. A re-parented element
|
|
2849
|
-
* would otherwise come back with no controller and its markup still in place — a grip that
|
|
2850
|
-
* renders and does nothing, with no error to explain it.
|
|
2851
|
-
*/
|
|
2852
|
-
#mountAim() {
|
|
2853
|
-
const shadow = this.shadowRoot;
|
|
2854
|
-
if (!shadow) return;
|
|
2855
|
-
shadow.querySelector(".pb-aim")?.remove();
|
|
2856
|
-
this.#aim = createAim(document, {
|
|
2857
|
-
onAim: (x, y) => this.#probe(x, y),
|
|
2858
|
-
onConfirm: () => this.#confirmAim(),
|
|
2859
|
-
onCancel: () => this.#dismiss()
|
|
2860
|
-
});
|
|
2861
|
-
shadow.appendChild(this.#aim.root);
|
|
2862
|
-
}
|
|
2863
3833
|
#build() {
|
|
2864
3834
|
const shadow = this.attachShadow({ mode: "open" });
|
|
2865
3835
|
try {
|
|
@@ -2875,22 +3845,21 @@ button { font: inherit; color: inherit; background: none; border: 0; cursor: poi
|
|
|
2875
3845
|
overlay.className = "pb-overlay";
|
|
2876
3846
|
this.#pinsLayer = document.createElement("div");
|
|
2877
3847
|
overlay.appendChild(this.#pinsLayer);
|
|
2878
|
-
this.#
|
|
2879
|
-
|
|
3848
|
+
this.#placement = createPlacement({
|
|
3849
|
+
win: window,
|
|
3850
|
+
host: this,
|
|
3851
|
+
store: this.store,
|
|
3852
|
+
pinsLayer: this.#pinsLayer,
|
|
3853
|
+
onCancel: () => this.#dismiss()
|
|
3854
|
+
});
|
|
3855
|
+
overlay.appendChild(this.#placement.outline);
|
|
2880
3856
|
const card = document.createElement("div");
|
|
2881
3857
|
card.className = "pb-card";
|
|
2882
3858
|
card.hidden = true;
|
|
2883
3859
|
overlay.appendChild(card);
|
|
2884
3860
|
shadow.appendChild(overlay);
|
|
2885
|
-
shadow.appendChild(this.#
|
|
2886
|
-
this.#bar = createBar(document, {
|
|
2887
|
-
onPin: () => this.#togglePlacing(),
|
|
2888
|
-
onInbox: () => this.store.update({ inboxOpen: !this.store.get().inboxOpen }),
|
|
2889
|
-
onTheme: () => this.#toggleTheme(),
|
|
2890
|
-
onHelp: () => this.#toggleHelp(),
|
|
2891
|
-
onCopy: () => this.#copyOpenPins(),
|
|
2892
|
-
onMinimize: (keyboard) => this.minimize(keyboard)
|
|
2893
|
-
});
|
|
3861
|
+
shadow.appendChild(this.#placement.crosshair);
|
|
3862
|
+
this.#bar = createBar(document, { onAction: (id, keyboard) => this.#runAction(id, keyboard) });
|
|
2894
3863
|
shadow.appendChild(this.#bar.root);
|
|
2895
3864
|
this.#minUi = createMinimizeUi(document);
|
|
2896
3865
|
shadow.appendChild(this.#minUi.morphWrap);
|
|
@@ -2898,10 +3867,11 @@ button { font: inherit; color: inherit; background: none; border: 0; cursor: poi
|
|
|
2898
3867
|
shadow.appendChild(this.#minUi.fan);
|
|
2899
3868
|
this.#drawer = createDrawer(document, {
|
|
2900
3869
|
onActivate: (pinId) => this.#activateFromInbox(pinId),
|
|
2901
|
-
onClose: () => this.store.update({ inboxOpen: false })
|
|
3870
|
+
onClose: () => this.store.update({ inboxOpen: false }),
|
|
3871
|
+
onResolve: (pinId, note) => this.actions.resolve?.(pinId, note),
|
|
3872
|
+
onUnresolve: (pinId) => this.actions.verify?.(pinId, "reopened")
|
|
2902
3873
|
});
|
|
2903
3874
|
shadow.appendChild(this.#drawer.root);
|
|
2904
|
-
this.#mountAim();
|
|
2905
3875
|
this.#modal = createShortcutsModal(document, () => this.#setHelp(false));
|
|
2906
3876
|
shadow.appendChild(this.#modal.root);
|
|
2907
3877
|
this.#pinsLayer.addEventListener("click", (e) => this.#onChipClick(e));
|
|
@@ -2921,12 +3891,7 @@ button { font: inherit; color: inherit; background: none; border: 0; cursor: poi
|
|
|
2921
3891
|
reduced: window.matchMedia("(prefers-reduced-motion: reduce)").matches,
|
|
2922
3892
|
initialMinimized: this.config?.minimized === true,
|
|
2923
3893
|
onSettled: (minimized, keyboard) => this.#onMinimizeSettled(minimized, keyboard),
|
|
2924
|
-
onFanAction: (action) =>
|
|
2925
|
-
if (action === "pin") this.#togglePlacing();
|
|
2926
|
-
else if (action === "inbox") this.#toggleInbox();
|
|
2927
|
-
else if (action === "hide") this.#togglePinsHidden();
|
|
2928
|
-
else this.#toggleTheme();
|
|
2929
|
-
}
|
|
3894
|
+
onFanAction: (action) => this.#runAction(action, false)
|
|
2930
3895
|
});
|
|
2931
3896
|
this.#min.applyInitial();
|
|
2932
3897
|
}
|
|
@@ -2972,6 +3937,7 @@ button { font: inherit; color: inherit; background: none; border: 0; cursor: poi
|
|
|
2972
3937
|
onEvent: (e) => applyHubEvent(this.store, e),
|
|
2973
3938
|
onConnection: (connection) => this.store.update({ connection }),
|
|
2974
3939
|
onPins: (pins) => this.store.update({ pins }),
|
|
3940
|
+
onSessions: (sessions) => this.store.update({ agentLive: agentIsLive(sessions, Date.now()) }),
|
|
2975
3941
|
onOutbox: (ids) => this.store.update({ queuedIds: new Set(ids) })
|
|
2976
3942
|
});
|
|
2977
3943
|
this.#transport = transport;
|
|
@@ -2979,8 +3945,8 @@ button { font: inherit; color: inherit; background: none; border: 0; cursor: poi
|
|
|
2979
3945
|
const seed = [...transport.mirrorPins(), ...queued];
|
|
2980
3946
|
if (seed.length > 0 && this.store.get().pins.length === 0) this.store.update({ pins: seed });
|
|
2981
3947
|
if (queued.length > 0) this.store.update({ queuedIds: new Set(queued.map((p) => p.id)) });
|
|
2982
|
-
this.actions.send = (pinId, text) => void this.#send(transport, pinId, text);
|
|
2983
|
-
this.actions.resolve = (pinId) => void transport.resolve(pinId).then((pin) => upsertPin(this.store, pin)).catch(() => {});
|
|
3948
|
+
this.actions.send = (pinId, text, kind) => void this.#send(transport, pinId, text, kind);
|
|
3949
|
+
this.actions.resolve = (pinId, note) => void transport.resolve(pinId, note).then((pin) => upsertPin(this.store, pin)).catch(() => {});
|
|
2984
3950
|
this.actions.verify = (pinId, outcome) => void transport.verify(pinId, outcome).then((pin) => {
|
|
2985
3951
|
upsertPin(this.store, pin);
|
|
2986
3952
|
if (outcome === "accepted") this.#dismiss();
|
|
@@ -2988,14 +3954,14 @@ button { font: inherit; color: inherit; background: none; border: 0; cursor: poi
|
|
|
2988
3954
|
transport.connect();
|
|
2989
3955
|
}
|
|
2990
3956
|
/** draft ⇒ compose PinInput (+ best-effort screenshot) and createPin; else thread reply. */
|
|
2991
|
-
async #send(transport, pinId, text) {
|
|
3957
|
+
async #send(transport, pinId, text, kind) {
|
|
2992
3958
|
try {
|
|
2993
3959
|
if (pinId === "draft") {
|
|
2994
3960
|
const draft = this.store.get().draft;
|
|
2995
3961
|
if (draft === null) return;
|
|
2996
3962
|
const input = {
|
|
2997
3963
|
text,
|
|
2998
|
-
kind
|
|
3964
|
+
kind,
|
|
2999
3965
|
target: draft.target.target,
|
|
3000
3966
|
env: draft.target.env,
|
|
3001
3967
|
author: { userId: transport.consumerId }
|
|
@@ -3006,7 +3972,11 @@ button { font: inherit; color: inherit; background: none; border: 0; cursor: poi
|
|
|
3006
3972
|
} else appendThreadMessage(this.store, await transport.reply(pinId, text));
|
|
3007
3973
|
} catch {}
|
|
3008
3974
|
}
|
|
3009
|
-
/**
|
|
3975
|
+
/**
|
|
3976
|
+
* Best-effort element screenshot: draft submit → capture → uploadAttachment. The capture is
|
|
3977
|
+
* bounded — a slow rasterize or a prompt left hanging must not hold the pin hostage, so past
|
|
3978
|
+
* CAPTURE_TIMEOUT_MS the pin ships without pixels (structured capture still carries it).
|
|
3979
|
+
*/
|
|
3010
3980
|
async #screenshot(selector) {
|
|
3011
3981
|
const cfg = this.config;
|
|
3012
3982
|
if (cfg === null) return null;
|
|
@@ -3014,7 +3984,9 @@ button { font: inherit; color: inherit; background: none; border: 0; cursor: poi
|
|
|
3014
3984
|
try {
|
|
3015
3985
|
const el = document.querySelector(selector);
|
|
3016
3986
|
if (el === null) return null;
|
|
3017
|
-
const
|
|
3987
|
+
const capture = this.store.get().captureMode === "tab" ? captureElement(el) : captureElementDom(el);
|
|
3988
|
+
const timeout = new Promise((resolve) => window.setTimeout(() => resolve(null), CAPTURE_TIMEOUT_MS));
|
|
3989
|
+
const img = await Promise.race([capture, timeout]);
|
|
3018
3990
|
if (img === null) return null;
|
|
3019
3991
|
return await uploadAttachment(cfg.endpoint, this.#token, img);
|
|
3020
3992
|
} catch {
|
|
@@ -3036,21 +4008,33 @@ button { font: inherit; color: inherit; background: none; border: 0; cursor: poi
|
|
|
3036
4008
|
const pin = this.store.get().pins.find((p) => p.id === pinId);
|
|
3037
4009
|
this.#ensureThread(pinId);
|
|
3038
4010
|
this.store.update({ activePinId: pinId });
|
|
3039
|
-
const rect = pin
|
|
4011
|
+
const rect = pin === void 0 ? null : anchorRect(document, pin);
|
|
3040
4012
|
if (rect) {
|
|
3041
|
-
const y = rect.y + rect.height / 2;
|
|
4013
|
+
const y = window.scrollY + rect.y + rect.height / 2;
|
|
3042
4014
|
window.scrollTo({
|
|
3043
4015
|
top: Math.max(0, y - window.innerHeight / 2),
|
|
3044
4016
|
behavior: "smooth"
|
|
3045
4017
|
});
|
|
3046
4018
|
}
|
|
3047
4019
|
}
|
|
4020
|
+
/**
|
|
4021
|
+
* Nudge a stale pin: re-post the last human message as a new thread message. A watcher that
|
|
4022
|
+
* missed the original (crashed, restarted, registered late) gets a fresh event to wake on.
|
|
4023
|
+
*/
|
|
4024
|
+
#nudge(pinId) {
|
|
4025
|
+
const state = this.store.get();
|
|
4026
|
+
const pin = state.pins.find((p) => p.id === pinId);
|
|
4027
|
+
if (pin === void 0) return;
|
|
4028
|
+
const last = [...state.threads.get(pinId) ?? []].reverse().find((m) => m.role === "human");
|
|
4029
|
+
this.actions.send?.(pinId, last?.text ?? pin.text, "note");
|
|
4030
|
+
}
|
|
3048
4031
|
/** The markdown offline fallback: copy every open pin's block to the clipboard. */
|
|
3049
4032
|
#copyOpenPins() {
|
|
3050
4033
|
const state = this.store.get();
|
|
3051
4034
|
try {
|
|
3052
4035
|
navigator.clipboard.writeText(pinsToMarkdown(state.pins, state.threads));
|
|
3053
4036
|
} catch {}
|
|
4037
|
+
return true;
|
|
3054
4038
|
}
|
|
3055
4039
|
/** The card's copy: exactly the pin you are looking at, thread included. */
|
|
3056
4040
|
#copyPin(pinId) {
|
|
@@ -3063,6 +4047,7 @@ button { font: inherit; color: inherit; background: none; border: 0; cursor: poi
|
|
|
3063
4047
|
}
|
|
3064
4048
|
#togglePinsHidden() {
|
|
3065
4049
|
this.store.update({ pinsHidden: !this.store.get().pinsHidden });
|
|
4050
|
+
return true;
|
|
3066
4051
|
}
|
|
3067
4052
|
#setHelp(open) {
|
|
3068
4053
|
this.#helpOpen = open;
|
|
@@ -3070,6 +4055,7 @@ button { font: inherit; color: inherit; background: none; border: 0; cursor: poi
|
|
|
3070
4055
|
}
|
|
3071
4056
|
#toggleHelp() {
|
|
3072
4057
|
this.#setHelp(!this.#helpOpen);
|
|
4058
|
+
return true;
|
|
3073
4059
|
}
|
|
3074
4060
|
/** Chip click toggles the pin active (prototype data-open delegation, line 675). */
|
|
3075
4061
|
#onChipClick(e) {
|
|
@@ -3089,175 +4075,99 @@ button { font: inherit; color: inherit; background: none; border: 0; cursor: poi
|
|
|
3089
4075
|
activePinId: null,
|
|
3090
4076
|
pinsHidden: false
|
|
3091
4077
|
});
|
|
4078
|
+
return true;
|
|
3092
4079
|
}
|
|
3093
4080
|
#toggleInbox() {
|
|
3094
4081
|
this.store.update({ inboxOpen: !this.store.get().inboxOpen });
|
|
3095
|
-
|
|
3096
|
-
#resolveActive() {
|
|
3097
|
-
const active = this.store.get().activePinId;
|
|
3098
|
-
if (active) this.actions.resolve?.(active);
|
|
4082
|
+
return true;
|
|
3099
4083
|
}
|
|
3100
4084
|
#toggleTheme() {
|
|
3101
4085
|
const next = this.getAttribute("data-pb") === "dark" ? "light" : "dark";
|
|
3102
4086
|
this.setAttribute("data-pb", next);
|
|
4087
|
+
return true;
|
|
3103
4088
|
}
|
|
3104
4089
|
/** esc / click-away: leave placing, discard the draft (client-only), deactivate. */
|
|
3105
4090
|
#dismiss() {
|
|
3106
4091
|
this.#setHelp(false);
|
|
3107
|
-
this.#clearExtraTargets();
|
|
3108
4092
|
this.store.update({
|
|
3109
4093
|
mode: "idle",
|
|
3110
4094
|
activePinId: null
|
|
3111
4095
|
});
|
|
3112
4096
|
if (this.store.get().draft) this.store.discardDraft();
|
|
3113
4097
|
}
|
|
3114
|
-
/**
|
|
3115
|
-
* Work out what sits under a viewport point and highlight it.
|
|
3116
|
-
*
|
|
3117
|
-
* Shared by both ways of aiming — following a mouse, and dragging the reticle — so the two can
|
|
3118
|
-
* never disagree about what is under the crosshair.
|
|
3119
|
-
*/
|
|
3120
|
-
#probe(clientX, clientY) {
|
|
3121
|
-
const el = hitTest(document, clientX, clientY, (hit) => hit === this);
|
|
3122
|
-
this.#hover = el;
|
|
3123
|
-
if (el) this.#reticle?.snap(el.getBoundingClientRect(), targetLabel(el), {
|
|
3124
|
-
x: window.scrollX,
|
|
3125
|
-
y: window.scrollY
|
|
3126
|
-
});
|
|
3127
|
-
else this.#reticle?.release();
|
|
3128
|
-
this.#aim?.setLabel(el ? targetLabel(el) : "NOTHING UNDER THE PIN");
|
|
3129
|
-
}
|
|
3130
|
-
/**
|
|
3131
|
-
* Keep the drag-aim reticle honest while the viewport moves under it.
|
|
3132
|
-
*
|
|
3133
|
-
* Scrolling changes what is beneath a fixed reticle, and resizing (a phone rotating, a window
|
|
3134
|
-
* dragged narrow) can both strand it off-screen and flip which way of aiming applies.
|
|
3135
|
-
*/
|
|
3136
|
-
#onViewportChange = () => {
|
|
3137
|
-
if (this.store.get().mode !== "placing" || this.#viewportFrame !== 0) return;
|
|
3138
|
-
this.#viewportFrame = requestAnimationFrame(() => {
|
|
3139
|
-
this.#viewportFrame = 0;
|
|
3140
|
-
if (this.store.get().mode !== "placing") return;
|
|
3141
|
-
this.#syncAim(true);
|
|
3142
|
-
const aim = this.#aim;
|
|
3143
|
-
if (aim?.root.classList.contains("on") === true) this.#probe(aim.point.x, aim.point.y);
|
|
3144
|
-
});
|
|
3145
|
-
};
|
|
3146
|
-
#onMouseMove = (e) => {
|
|
3147
|
-
if (this.store.get().mode !== "placing" || !this.#reticle) return;
|
|
3148
|
-
this.#reticle.move(e);
|
|
3149
|
-
this.#probe(e.clientX, e.clientY);
|
|
3150
|
-
};
|
|
3151
|
-
/** Commit the pin the drag-aim reticle is sitting on. */
|
|
3152
|
-
#confirmAim() {
|
|
3153
|
-
const aim = this.#aim;
|
|
3154
|
-
if (!aim) return;
|
|
3155
|
-
this.#probe(aim.point.x, aim.point.y);
|
|
3156
|
-
const el = this.#hover ?? document.body;
|
|
3157
|
-
this.store.place({
|
|
3158
|
-
target: captureTarget(el, { at: {
|
|
3159
|
-
x: aim.point.x + window.scrollX,
|
|
3160
|
-
y: aim.point.y + window.scrollY
|
|
3161
|
-
} }),
|
|
3162
|
-
placedAt: {
|
|
3163
|
-
x: aim.point.x + window.scrollX,
|
|
3164
|
-
y: aim.point.y + window.scrollY
|
|
3165
|
-
}
|
|
3166
|
-
});
|
|
3167
|
-
this.#reticle?.release();
|
|
3168
|
-
}
|
|
3169
|
-
/** Placement click: capture the hovered target (or body) into a client-only draft. */
|
|
3170
|
-
#placeDraft(e) {
|
|
3171
|
-
e.preventDefault();
|
|
3172
|
-
e.stopPropagation();
|
|
3173
|
-
const capture = captureTarget(this.#hover ?? document.body, { at: {
|
|
3174
|
-
x: e.pageX,
|
|
3175
|
-
y: e.pageY
|
|
3176
|
-
} });
|
|
3177
|
-
if (this.#extraTargets.length > 0) capture.target.targets = this.#extraTargets;
|
|
3178
|
-
this.#clearExtraTargets();
|
|
3179
|
-
this.store.place({
|
|
3180
|
-
target: capture,
|
|
3181
|
-
placedAt: {
|
|
3182
|
-
x: e.pageX,
|
|
3183
|
-
y: e.pageY
|
|
3184
|
-
}
|
|
3185
|
-
});
|
|
3186
|
-
this.#reticle?.release();
|
|
3187
|
-
}
|
|
3188
|
-
/** Shift+click while placing: capture WITHOUT committing; a numbered dashed
|
|
3189
|
-
* outline is the receipt. Plain click still commits (with these attached). */
|
|
3190
|
-
#accumulateTarget(e) {
|
|
3191
|
-
e.preventDefault();
|
|
3192
|
-
e.stopPropagation();
|
|
3193
|
-
const el = this.#hover ?? document.body;
|
|
3194
|
-
this.#extraTargets = [...this.#extraTargets, captureTarget(el).target];
|
|
3195
|
-
if (this.#pinsLayer) renderMultiMarks(this.#pinsLayer, this.#extraTargets);
|
|
3196
|
-
}
|
|
3197
|
-
#clearExtraTargets() {
|
|
3198
|
-
if (this.#extraTargets.length === 0) return;
|
|
3199
|
-
this.#extraTargets = [];
|
|
3200
|
-
if (this.#pinsLayer) renderMultiMarks(this.#pinsLayer, []);
|
|
3201
|
-
}
|
|
3202
4098
|
#onClickCapture = (e) => {
|
|
3203
4099
|
if (e.composedPath().includes(this)) return;
|
|
3204
4100
|
const state = this.store.get();
|
|
3205
4101
|
if (state.mode === "placing") {
|
|
3206
|
-
|
|
3207
|
-
if (e.shiftKey) this.#accumulateTarget(e);
|
|
3208
|
-
else this.#placeDraft(e);
|
|
4102
|
+
this.#placement?.handleClick(e);
|
|
3209
4103
|
return;
|
|
3210
4104
|
}
|
|
3211
4105
|
if (state.inboxOpen) this.store.update({ inboxOpen: false });
|
|
3212
4106
|
if (state.activePinId || state.draft) this.#dismiss();
|
|
3213
4107
|
};
|
|
3214
|
-
/**
|
|
3215
|
-
|
|
4108
|
+
/**
|
|
4109
|
+
* One handler per action, for every surface: bar buttons, fan items and keys all name an
|
|
4110
|
+
* action from the table (ui/actions.ts). A handler returns false when nothing was there to act
|
|
4111
|
+
* on, so the key path can let the host have the keystroke (an Esc with nothing open is the
|
|
4112
|
+
* page's Esc; R with no active pin is the page's R).
|
|
4113
|
+
*/
|
|
4114
|
+
#handlers = {
|
|
3216
4115
|
escape: () => {
|
|
3217
|
-
if (this.#min?.closeFan() === true) return;
|
|
4116
|
+
if (this.#min?.closeFan() === true) return true;
|
|
4117
|
+
const s = this.store.get();
|
|
4118
|
+
const open = s.mode === "placing" || s.activePinId !== null || s.draft !== null || this.#helpOpen;
|
|
3218
4119
|
this.#dismiss();
|
|
4120
|
+
return open;
|
|
4121
|
+
},
|
|
4122
|
+
pin: () => this.#togglePlacing(),
|
|
4123
|
+
inbox: () => this.#toggleInbox(),
|
|
4124
|
+
theme: () => this.#toggleTheme(),
|
|
4125
|
+
copy: () => this.#copyOpenPins(),
|
|
4126
|
+
hide: () => this.#togglePinsHidden(),
|
|
4127
|
+
capture: () => this.#toggleCapture(),
|
|
4128
|
+
help: () => this.#toggleHelp(),
|
|
4129
|
+
resolve: () => {
|
|
4130
|
+
const active = this.store.get().activePinId;
|
|
4131
|
+
if (active === null) return false;
|
|
4132
|
+
this.actions.resolve?.(active);
|
|
4133
|
+
return true;
|
|
3219
4134
|
},
|
|
3220
|
-
|
|
3221
|
-
|
|
3222
|
-
|
|
3223
|
-
|
|
3224
|
-
|
|
3225
|
-
h: () => this.#togglePinsHidden(),
|
|
3226
|
-
m: () => this.#min?.minimized() === true ? this.restore(true) : this.minimize(true),
|
|
3227
|
-
"?": () => this.#toggleHelp()
|
|
4135
|
+
minimize: (keyboard) => {
|
|
4136
|
+
if (this.#min?.minimized() === true) this.restore(keyboard);
|
|
4137
|
+
else this.minimize(keyboard);
|
|
4138
|
+
return true;
|
|
4139
|
+
}
|
|
3228
4140
|
};
|
|
4141
|
+
#runAction(id, keyboard) {
|
|
4142
|
+
return this.#handlers[id](keyboard);
|
|
4143
|
+
}
|
|
3229
4144
|
#onKeyDown = (e) => {
|
|
3230
|
-
|
|
3231
|
-
|
|
4145
|
+
const id = shortcutFor(e, this.config?.shortcuts ?? "all");
|
|
4146
|
+
if (id === null) return;
|
|
4147
|
+
if (!this.#runAction(id, true)) return;
|
|
4148
|
+
e.preventDefault();
|
|
4149
|
+
e.stopPropagation();
|
|
3232
4150
|
};
|
|
3233
4151
|
/**
|
|
3234
|
-
*
|
|
3235
|
-
*
|
|
3236
|
-
*
|
|
4152
|
+
* The viewport moved under the overlay (scroll, resize, layout shift): re-place what is
|
|
4153
|
+
* anchored to the page — pins, the card, multi-target marks — once per frame. Not a store
|
|
4154
|
+
* update: nothing about the pins changed, only where their anchors are on screen.
|
|
3237
4155
|
*/
|
|
3238
|
-
#
|
|
3239
|
-
|
|
3240
|
-
|
|
3241
|
-
|
|
3242
|
-
|
|
3243
|
-
|
|
3244
|
-
|
|
3245
|
-
|
|
3246
|
-
|
|
3247
|
-
|
|
3248
|
-
return;
|
|
3249
|
-
}
|
|
3250
|
-
const { x, y } = startPoint(window);
|
|
3251
|
-
aim.show(x, y);
|
|
3252
|
-
this.#probe(x, y);
|
|
3253
|
-
}
|
|
4156
|
+
#onLayoutChange = () => {
|
|
4157
|
+
if (this.#layoutFrame !== 0) return;
|
|
4158
|
+
this.#layoutFrame = requestAnimationFrame(() => {
|
|
4159
|
+
this.#layoutFrame = 0;
|
|
4160
|
+
const state = this.store.get();
|
|
4161
|
+
if (this.#pinsLayer) renderPins(this.#pinsLayer, state);
|
|
4162
|
+
if (this.shadowRoot) renderCard(this.shadowRoot, state, this.#cardActions);
|
|
4163
|
+
this.#placement?.render(state.mode === "placing");
|
|
4164
|
+
});
|
|
4165
|
+
};
|
|
3254
4166
|
#render(state) {
|
|
3255
4167
|
const placing = state.mode === "placing";
|
|
3256
4168
|
this.toggleAttribute("data-placing", placing);
|
|
3257
4169
|
document.body.classList.toggle(PAGE_PLACING_CLASS, placing);
|
|
3258
|
-
|
|
3259
|
-
if (!placing) this.#reticle?.release();
|
|
3260
|
-
this.#syncAim(placing);
|
|
4170
|
+
this.#placement?.render(placing);
|
|
3261
4171
|
if (this.#pinsLayer) renderPins(this.#pinsLayer, state);
|
|
3262
4172
|
if (this.shadowRoot) renderCard(this.shadowRoot, state, this.#cardActions);
|
|
3263
4173
|
this.#drawer?.update(state);
|