@autono/pinbox-toolbar 0.19.0 → 0.20.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.
@@ -69,6 +69,8 @@ interface ToolbarState {
69
69
  * Persisted per endpoint; the bar's camera button flips it.
70
70
  */
71
71
  captureMode: "dom" | "tab";
72
+ /** Optional model camera action label; absent preserves the 2D capture toggle. */
73
+ captureLabel?: string | undefined;
72
74
  }
73
75
  interface Store {
74
76
  get(): ToolbarState;
package/dist/index.d.ts CHANGED
@@ -1,2 +1,2 @@
1
- import { _ as applyHubEvent, a as HubEvent, b as upsertPin, c as WebSocketLike, d as PinboxToolbarElement, f as Draft, g as appendThreadMessage, h as UiStatus, i as ConnectionState, l as HubError, m as ToolbarState, n as PinboxConfig, o as HubTransport, p as Store, r as defineToolbarElement, s as TransportOptions, t as Pinbox, u as StorageLike, v as createStore, x as CaptureResult, y as deriveUiStatus } from "./index-DENhglDF.js";
1
+ import { _ as applyHubEvent, a as HubEvent, b as upsertPin, c as WebSocketLike, d as PinboxToolbarElement, f as Draft, g as appendThreadMessage, h as UiStatus, i as ConnectionState, l as HubError, m as ToolbarState, n as PinboxConfig, o as HubTransport, p as Store, r as defineToolbarElement, s as TransportOptions, t as Pinbox, u as StorageLike, v as createStore, x as CaptureResult, y as deriveUiStatus } from "./index-xNXeJ2mR.js";
2
2
  export { type CaptureResult, type ConnectionState, type Draft, HubError, type HubEvent, HubTransport, Pinbox, PinboxConfig, PinboxToolbarElement, type StorageLike, type Store, type ToolbarState, type TransportOptions, type UiStatus, type WebSocketLike, appendThreadMessage, applyHubEvent, createStore, defineToolbarElement, deriveUiStatus, upsertPin };
package/dist/index.js CHANGED
@@ -1,2 +1,2 @@
1
- import { a as HubError, c as createStore, i as HubTransport, l as deriveUiStatus, n as defineToolbarElement, o as appendThreadMessage, r as PinboxToolbarElement, s as applyHubEvent, t as Pinbox, u as upsertPin } from "./src-BsWnyGwB.js";
1
+ import { a as HubError, c as createStore, i as HubTransport, l as deriveUiStatus, n as defineToolbarElement, o as appendThreadMessage, r as PinboxToolbarElement, s as applyHubEvent, t as Pinbox, u as upsertPin } from "./src-DnAkx29T.js";
2
2
  export { HubError, HubTransport, Pinbox, PinboxToolbarElement, appendThreadMessage, applyHubEvent, createStore, defineToolbarElement, deriveUiStatus, upsertPin };
@@ -0,0 +1,350 @@
1
+ //#region src/capture-mode.ts
2
+ function captureKey(prefix) {
3
+ return `${prefix}:capture`;
4
+ }
5
+ function loadCaptureMode(storage, key, fallback) {
6
+ try {
7
+ const raw = storage?.getItem(key);
8
+ if (raw === "dom" || raw === "tab") return raw;
9
+ } catch {}
10
+ return fallback;
11
+ }
12
+ function saveCaptureMode(storage, key, mode) {
13
+ try {
14
+ storage?.setItem(key, mode);
15
+ } catch {}
16
+ }
17
+ //#endregion
18
+ //#region src/model-capture.ts
19
+ const handlers = /* @__PURE__ */ new WeakMap();
20
+ /** The first viewer owns the camera until another registered surface is entered. */
21
+ function registerModelCapture(owner, run, error) {
22
+ const entry = {
23
+ run,
24
+ error,
25
+ busy: false
26
+ };
27
+ const entries = handlers.get(owner) ?? [];
28
+ entries.push(entry);
29
+ handlers.set(owner, entries);
30
+ owner.store.update({ captureLabel: "Capture model screenshot (S)" });
31
+ return {
32
+ activate() {
33
+ const index = entries.indexOf(entry);
34
+ if (index < 0) return;
35
+ entries.splice(index, 1);
36
+ entries.unshift(entry);
37
+ },
38
+ destroy() {
39
+ const index = entries.indexOf(entry);
40
+ if (index < 0) return;
41
+ entries.splice(index, 1);
42
+ if (!entries.length) {
43
+ handlers.delete(owner);
44
+ owner.store.update({ captureLabel: void 0 });
45
+ }
46
+ }
47
+ };
48
+ }
49
+ function runModelCapture(owner) {
50
+ const entries = handlers.get(owner);
51
+ const entry = entries?.[0];
52
+ if (!entries || !entry) return false;
53
+ if (entries.some((candidate) => candidate.busy)) return true;
54
+ entry.busy = true;
55
+ try {
56
+ Promise.resolve(entry.run()).catch(entry.error).finally(() => {
57
+ entry.busy = false;
58
+ });
59
+ } catch (error) {
60
+ entry.busy = false;
61
+ entry.error(error);
62
+ }
63
+ return true;
64
+ }
65
+ /** Shared by the camera button, puck and S shortcut; preserve the 2D fallback. */
66
+ function toggleToolbarCapture(owner, endpoint, release) {
67
+ if (runModelCapture(owner)) return true;
68
+ const next = owner.store.get().captureMode === "tab" ? "dom" : "tab";
69
+ owner.store.update({ captureMode: next });
70
+ if (next === "dom") release();
71
+ saveCaptureMode(globalThis.localStorage, captureKey(`pinbox:${endpoint}`), next);
72
+ return true;
73
+ }
74
+ //#endregion
75
+ //#region src/targeting/dom.ts
76
+ /**
77
+ * Deepest element under (clientX, clientY) that the caller does not ignore, or null when there is
78
+ * nothing there but page chrome (html/body).
79
+ *
80
+ * Looks THROUGH our own overlay rather than giving up at it. The single-element form could not:
81
+ * the drag-aim grip sits exactly on the point being aimed at, so it is always the topmost thing
82
+ * under the crosshair, and every probe came back "nothing" the moment touch aiming existed.
83
+ */
84
+ function hitTest(doc, x, y, ignore) {
85
+ const stack = doc.elementsFromPoint?.(x, y) ?? [doc.elementFromPoint(x, y)];
86
+ for (const el of stack) {
87
+ if (!el || el === doc.body || el === doc.documentElement) return null;
88
+ if (!ignore(el)) return el;
89
+ }
90
+ return null;
91
+ }
92
+ /** CLASS-or-TAG display name with a sibling index when needed (prototype nodeName). */
93
+ function nodeName(el) {
94
+ const key = el.classList[0];
95
+ let name = (key ?? el.tagName).toUpperCase();
96
+ const parent = el.parentElement;
97
+ if (parent) {
98
+ const sibs = [...parent.children].filter((c) => c.classList[0] === key && c.tagName === el.tagName);
99
+ if (sibs.length > 1) name += ` ${sibs.indexOf(el) + 1}`;
100
+ }
101
+ return name;
102
+ }
103
+ /**
104
+ * Human label for a target: an explicit data-pb-el annotation wins; otherwise a
105
+ * CLASS/TAG ancestry chain of at most 3 parts joined with ›, terminating early
106
+ * at the first annotated ancestor.
107
+ */
108
+ function targetLabel(el) {
109
+ const own = el.getAttribute("data-pb-el");
110
+ if (own) return own;
111
+ const parts = [nodeName(el)];
112
+ const body = el.ownerDocument.body;
113
+ let node = el.parentElement;
114
+ while (node && node !== body && parts.length < 3) {
115
+ const anchor = node.getAttribute("data-pb-el");
116
+ if (anchor) {
117
+ parts.unshift(anchor);
118
+ break;
119
+ }
120
+ if (node.classList[0]) parts.unshift(nodeName(node));
121
+ node = node.parentElement;
122
+ }
123
+ return parts.join(" › ");
124
+ }
125
+ const SAFE_ID = /^[A-Za-z][\w-]*$/;
126
+ /** Data attributes trusted as stable hooks, in priority order. */
127
+ const STABLE_DATA_ATTRS = [
128
+ "data-pb-anchor",
129
+ "data-pb-el",
130
+ "data-testid"
131
+ ];
132
+ function attrSegment(el, doc) {
133
+ for (const attr of STABLE_DATA_ATTRS) {
134
+ const value = el.getAttribute(attr);
135
+ if (value === null || value.includes("\"") || value.includes("\\")) continue;
136
+ const selector = `${el.tagName.toLowerCase()}[${attr}="${value}"]`;
137
+ if (doc.querySelectorAll(selector).length === 1) return selector;
138
+ }
139
+ return null;
140
+ }
141
+ function nthSegment(el) {
142
+ const tag = el.tagName.toLowerCase();
143
+ const parent = el.parentElement;
144
+ if (!parent) return tag;
145
+ const sameTag = [...parent.children].filter((c) => c.tagName === el.tagName);
146
+ return sameTag.length > 1 ? `${tag}:nth-of-type(${sameTag.indexOf(el) + 1})` : tag;
147
+ }
148
+ /**
149
+ * Stable CSS path for an element: ids > stable data attributes > an
150
+ * nth-of-type chain. Guaranteed round-trip: querySelector(buildSelector(el)) === el.
151
+ */
152
+ function buildSelector(el) {
153
+ const doc = el.ownerDocument;
154
+ const segments = [];
155
+ let node = el;
156
+ while (node && node !== doc.documentElement) {
157
+ const id = node.getAttribute("id");
158
+ if (id && SAFE_ID.test(id) && doc.querySelectorAll(`#${id}`).length === 1) {
159
+ segments.unshift(`#${id}`);
160
+ return segments.join(" > ");
161
+ }
162
+ const byAttr = attrSegment(node, doc);
163
+ if (byAttr) {
164
+ segments.unshift(byAttr);
165
+ return segments.join(" > ");
166
+ }
167
+ segments.unshift(nthSegment(node));
168
+ node = node.parentElement;
169
+ }
170
+ return segments.join(" > ");
171
+ }
172
+ //#endregion
173
+ //#region src/capture.ts
174
+ /** Curated computed-style subset — enough to reconstruct layout intent, tiny on the wire. */
175
+ const STYLE_KEYS = [
176
+ "display",
177
+ "position",
178
+ "font-size",
179
+ "color",
180
+ "background-color",
181
+ "margin",
182
+ "padding",
183
+ "overflow"
184
+ ];
185
+ const NEARBY_TEXT_MAX = 160;
186
+ function styleSubset(win, el) {
187
+ let cs;
188
+ try {
189
+ cs = win.getComputedStyle(el);
190
+ } catch {
191
+ return;
192
+ }
193
+ const out = {};
194
+ for (const key of STYLE_KEYS) {
195
+ const value = cs.getPropertyValue(key);
196
+ if (value !== "") out[key] = value;
197
+ }
198
+ return Object.keys(out).length > 0 ? out : void 0;
199
+ }
200
+ function ariaMap(el) {
201
+ const out = {};
202
+ for (const name of el.getAttributeNames()) if (name.startsWith("aria-")) out[name] = el.getAttribute(name) ?? "";
203
+ return Object.keys(out).length > 0 ? out : void 0;
204
+ }
205
+ function nearbyText(el) {
206
+ const text = (el.textContent ?? "").replace(/\s+/g, " ").trim();
207
+ return text === "" ? void 0 : text.slice(0, NEARBY_TEXT_MAX);
208
+ }
209
+ /** The user's selection, only when it intersects the captured element. */
210
+ function selectedText(win, el) {
211
+ try {
212
+ const sel = win.getSelection?.();
213
+ if (!sel || sel.isCollapsed || sel.rangeCount === 0) return void 0;
214
+ if (!sel.getRangeAt(0).intersectsNode(el)) return void 0;
215
+ const text = sel.toString().trim();
216
+ return text === "" ? void 0 : text;
217
+ } catch {
218
+ return;
219
+ }
220
+ }
221
+ /** `fixed` detected via ancestry: any ancestor with computed position: fixed. */
222
+ function isFixed(win, el) {
223
+ for (let node = el; node !== null; node = node.parentElement) try {
224
+ if (win.getComputedStyle(node).position === "fixed") return true;
225
+ } catch {
226
+ return false;
227
+ }
228
+ return false;
229
+ }
230
+ /** Beyond this an element is not a thing you pinned, it is a region. Too many to rewrite as a set. */
231
+ const MAX_RUNS = 40;
232
+ const MAX_RUN_LENGTH = 200;
233
+ /** Text that is not content: a script body or a stylesheet is not something to rewrite. */
234
+ const NON_CONTENT = /* @__PURE__ */ new Set([
235
+ "SCRIPT",
236
+ "STYLE",
237
+ "NOSCRIPT",
238
+ "TEMPLATE"
239
+ ]);
240
+ /**
241
+ * The element's text, split the way the browser stores it: one entry per run of characters.
242
+ *
243
+ * This walks TEXT NODES, not elements, and that distinction is the whole point — it makes no
244
+ * assumption about how a site is built. A heading is one run. A nav bar is one per link. A
245
+ * paragraph with a bold word in the middle is three, in reading order, including the halves either
246
+ * side of the bold. An earlier version keyed off "elements with no element children", which
247
+ * quietly lost the "Hello " in `<p>Hello <b>world</b></p>` — text a person can obviously see and
248
+ * would obviously expect to be able to change.
249
+ *
250
+ * `nearbyText` runs them all together, which is fine to read and useless to edit: it cannot tell
251
+ * an agent that "work approach people contact" is four separate places. This can.
252
+ */
253
+ function textRuns(el) {
254
+ const runs = [];
255
+ const walk = (node) => {
256
+ if (node.nodeType === 3) {
257
+ const text = (node.nodeValue ?? "").trim();
258
+ if (text.length > 0) runs.push(text.slice(0, MAX_RUN_LENGTH));
259
+ return runs.length <= MAX_RUNS;
260
+ }
261
+ if (node.nodeType !== 1 || NON_CONTENT.has(node.tagName)) return true;
262
+ for (const child of node.childNodes) if (!walk(child)) return false;
263
+ return true;
264
+ };
265
+ if (!walk(el) || runs.length === 0) return void 0;
266
+ return runs;
267
+ }
268
+ function buildContext(win, el) {
269
+ const context = {};
270
+ if (el.classList.length > 0) context.classes = [...el.classList];
271
+ const styles = styleSubset(win, el);
272
+ if (styles !== void 0) context.styles = styles;
273
+ const aria = ariaMap(el);
274
+ if (aria !== void 0) context.aria = aria;
275
+ const nearby = nearbyText(el);
276
+ if (nearby !== void 0) context.nearbyText = nearby;
277
+ const selected = selectedText(win, el);
278
+ if (selected !== void 0) context.selectedText = selected;
279
+ const runs = textRuns(el);
280
+ if (runs !== void 0) context.textRuns = runs;
281
+ return Object.keys(context).length > 0 ? context : void 0;
282
+ }
283
+ /** Fills PinInput.target/env from a chosen element (shapes come from the pin schema). */
284
+ function captureTarget(el, opts) {
285
+ const win = el.ownerDocument.defaultView;
286
+ const r = el.getBoundingClientRect();
287
+ const target = {
288
+ url: win.location.href,
289
+ selector: buildSelector(el),
290
+ tag: el.tagName.toLowerCase(),
291
+ rect: {
292
+ x: r.left + win.scrollX,
293
+ y: r.top + win.scrollY,
294
+ width: r.width,
295
+ height: r.height
296
+ },
297
+ fixed: isFixed(win, el)
298
+ };
299
+ if (opts?.anchor !== void 0) target.anchor = opts.anchor;
300
+ if (opts?.at !== void 0 && r.width > 0 && r.height > 0) {
301
+ const fx = (opts.at.x - (r.left + win.scrollX)) / r.width;
302
+ const fy = (opts.at.y - (r.top + win.scrollY)) / r.height;
303
+ if (fx >= 0 && fx <= 1 && fy >= 0 && fy <= 1) target.spot = {
304
+ x: fx,
305
+ y: fy
306
+ };
307
+ }
308
+ const context = buildContext(win, el);
309
+ if (context !== void 0) target.context = context;
310
+ return {
311
+ target,
312
+ env: {
313
+ viewport: {
314
+ w: win.innerWidth,
315
+ h: win.innerHeight,
316
+ dpr: win.devicePixelRatio
317
+ },
318
+ browser: win.navigator.userAgent,
319
+ os: win.navigator.platform || "unknown",
320
+ colorScheme: win.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"
321
+ }
322
+ };
323
+ }
324
+ //#endregion
325
+ //#region src/model-target.ts
326
+ const projectors = /* @__PURE__ */ new WeakMap();
327
+ function registerModelProjection(doc, project) {
328
+ const set = projectors.get(doc) ?? /* @__PURE__ */ new Set();
329
+ const entry = (anchor) => project(anchor);
330
+ set.add(entry);
331
+ projectors.set(doc, set);
332
+ return () => {
333
+ set.delete(entry);
334
+ if (!set.size) projectors.delete(doc);
335
+ };
336
+ }
337
+ function projectModelTarget(doc, anchor) {
338
+ for (const project of projectors.get(doc) ?? []) {
339
+ const p = project(anchor);
340
+ if (p && Number.isFinite(p.x) && Number.isFinite(p.y)) return {
341
+ x: p.x,
342
+ y: p.y,
343
+ width: 0,
344
+ height: 0
345
+ };
346
+ }
347
+ return null;
348
+ }
349
+ //#endregion
350
+ export { targetLabel as a, captureKey as c, hitTest as i, loadCaptureMode as l, registerModelProjection as n, registerModelCapture as o, captureTarget as r, toggleToolbarCapture as s, projectModelTarget as t };
@@ -0,0 +1,52 @@
1
+ import { d as PinboxToolbarElement } from "./index-xNXeJ2mR.js";
2
+ import { ModelAnchor, ModelAnchor as ModelAnchor$1, Pin } from "@autono/pinbox-core/schema";
3
+ //#region src/model-target.d.ts
4
+ type ModelProjection = (anchor: ModelAnchor$1) => {
5
+ x: number;
6
+ y: number;
7
+ } | null;
8
+ //#endregion
9
+ //#region src/model-ui.d.ts
10
+ type ModelViewerAdapter = {
11
+ /** Canvas or element containing the model. DOM pins outside it keep working. */
12
+ surface: HTMLElement;
13
+ /** Raycast a viewport point; return a part-local anchor, or null for empty space. */
14
+ pick: (clientX: number, clientY: number) => ModelAnchor$1 | null;
15
+ /** Project to viewport CSS pixels. Null for a stale, hidden or occluded anchor. */
16
+ project: ModelProjection;
17
+ /** Called each update in pin mode, or null when the hover clears. */
18
+ onHover?: (anchor: ModelAnchor$1 | null) => void;
19
+ /** Filter activation for multiple viewers; may accept older revisions for loading. */
20
+ acceptsAnchor?: (anchor: ModelAnchor$1) => boolean;
21
+ /** Animate/load a pin view. Stop stale asynchronous work when signal is aborted. */
22
+ onActivate?: (anchor: ModelAnchor$1, pin: Pin, context: {
23
+ signal: AbortSignal;
24
+ }) => void | Promise<void>;
25
+ /** Camera button and S action. Host owns capture, preview, save and chat/storage UI. */
26
+ onCapture?: () => void | Promise<void>;
27
+ onError?: (error: unknown) => void;
28
+ };
29
+ /** Connect the real Pinbox toolbar, draft card and needle markers to a 3D viewer.
30
+ * Call update() after rendering a frame. Destroy before removing the viewer. */
31
+ declare function attachModelViewer(toolbar: PinboxToolbarElement, adapter: ModelViewerAdapter): {
32
+ openPin: (id: string) => Promise<void>;
33
+ update(): void;
34
+ destroy(): void;
35
+ };
36
+ //#endregion
37
+ //#region src/model.d.ts
38
+ type ModelScene = {
39
+ modelId: string;
40
+ revision: string;
41
+ /** Column-major 4x4 object-to-world matrix (e.g. Three.js matrixWorld.elements). */
42
+ partMatrix: (partId: string) => readonly number[] | undefined;
43
+ };
44
+ type AnchorResolution = {
45
+ status: "visible";
46
+ worldPosition: [number, number, number];
47
+ } | {
48
+ status: "stale" | "missing";
49
+ };
50
+ declare function resolveModelAnchor(anchor: ModelAnchor, scene: ModelScene): AnchorResolution;
51
+ //#endregion
52
+ export { AnchorResolution, type ModelAnchor, ModelScene, type ModelViewerAdapter, attachModelViewer, resolveModelAnchor };
package/dist/model.js ADDED
@@ -0,0 +1,182 @@
1
+ import { n as registerModelProjection, o as registerModelCapture, r as captureTarget } from "./model-target-BykKL8H5.js";
2
+ //#region src/model-hooks.ts
3
+ /** Renderer callbacks stay separate from placement and marker projection. */
4
+ function attachModelHooks(toolbar, adapter) {
5
+ const surface = adapter.surface, win = surface.ownerDocument.defaultView;
6
+ if (!win) throw new Error("The model surface must belong to a browser document");
7
+ const error = adapter.onError ?? ((cause) => console.error("Pinbox model hook failed", cause));
8
+ const capture = adapter.onCapture ? registerModelCapture(toolbar, adapter.onCapture, error) : void 0;
9
+ let point, hovering = false, disposed = false;
10
+ let active = null, controller;
11
+ let activation = Promise.resolve();
12
+ const clear = () => {
13
+ point = void 0;
14
+ if (hovering) {
15
+ hovering = false;
16
+ adapter.onHover?.(null);
17
+ }
18
+ };
19
+ const move = (event) => {
20
+ if (!event.composedPath().includes(surface)) {
21
+ clear();
22
+ return;
23
+ }
24
+ point = {
25
+ x: event.clientX,
26
+ y: event.clientY
27
+ };
28
+ capture?.activate();
29
+ };
30
+ const focus = () => capture?.activate();
31
+ const activate = (pin) => {
32
+ controller?.abort();
33
+ controller = new AbortController();
34
+ const signal = controller.signal, anchor = pin.target?.model;
35
+ if (!anchor || !adapter.onActivate || adapter.acceptsAnchor && !adapter.acceptsAnchor(anchor)) return Promise.resolve();
36
+ try {
37
+ return Promise.resolve(adapter.onActivate(anchor, pin, { signal })).catch((cause) => {
38
+ if (!signal.aborted) error(cause);
39
+ });
40
+ } catch (cause) {
41
+ error(cause);
42
+ return Promise.resolve();
43
+ }
44
+ };
45
+ const unsubscribe = toolbar.store.subscribe((state) => {
46
+ if (state.mode !== "placing" && hovering) {
47
+ hovering = false;
48
+ adapter.onHover?.(null);
49
+ }
50
+ if (active === state.activePinId) return;
51
+ const pin = state.pins.find((p) => p.id === state.activePinId);
52
+ if (state.activePinId && !pin) return;
53
+ active = state.activePinId;
54
+ controller?.abort();
55
+ if (pin) activation = activate(pin);
56
+ });
57
+ win.addEventListener("pointermove", move, true);
58
+ win.addEventListener("blur", clear);
59
+ surface.addEventListener("pointerleave", clear);
60
+ surface.addEventListener("pointerdown", focus, true);
61
+ return {
62
+ update() {
63
+ if (disposed || !adapter.onHover || !point || toolbar.store.get().mode !== "placing") return;
64
+ try {
65
+ const anchor = adapter.pick(point.x, point.y);
66
+ hovering = anchor !== null;
67
+ adapter.onHover(anchor);
68
+ } catch (cause) {
69
+ clear();
70
+ error(cause);
71
+ }
72
+ },
73
+ async openPin(id) {
74
+ if (disposed) return;
75
+ const pin = toolbar.store.get().pins.find((p) => p.id === id);
76
+ if (!pin) throw new Error("Pin not found.");
77
+ if (active === id) activation = activate(pin);
78
+ toolbar.store.update({
79
+ mode: "idle",
80
+ activePinId: id,
81
+ pinsHidden: false
82
+ });
83
+ await activation;
84
+ },
85
+ destroy() {
86
+ if (disposed) return;
87
+ disposed = true;
88
+ controller?.abort();
89
+ unsubscribe();
90
+ clear();
91
+ capture?.destroy();
92
+ win.removeEventListener("pointermove", move, true);
93
+ win.removeEventListener("blur", clear);
94
+ surface.removeEventListener("pointerleave", clear);
95
+ surface.removeEventListener("pointerdown", focus, true);
96
+ }
97
+ };
98
+ }
99
+ //#endregion
100
+ //#region src/model-ui.ts
101
+ /** Connect the real Pinbox toolbar, draft card and needle markers to a 3D viewer.
102
+ * Call update() after rendering a frame. Destroy before removing the viewer. */
103
+ function attachModelViewer(toolbar, adapter) {
104
+ const doc = adapter.surface.ownerDocument, win = doc.defaultView;
105
+ if (!win) throw new Error("The model surface must belong to a browser document");
106
+ const unregister = registerModelProjection(doc, adapter.project);
107
+ const hooks = attachModelHooks(toolbar, adapter);
108
+ let down;
109
+ const pointerDown = (e) => {
110
+ if (e.composedPath().includes(adapter.surface)) down = {
111
+ x: e.clientX,
112
+ y: e.clientY
113
+ };
114
+ };
115
+ const click = (e) => {
116
+ if (toolbar.store.get().mode !== "placing" || e.composedPath().includes(toolbar) || !e.composedPath().includes(adapter.surface)) return;
117
+ e.preventDefault();
118
+ e.stopImmediatePropagation();
119
+ if (down && Math.hypot(e.clientX - down.x, e.clientY - down.y) > 5) {
120
+ down = void 0;
121
+ return;
122
+ }
123
+ down = void 0;
124
+ const model = adapter.pick(e.clientX, e.clientY);
125
+ if (!model) return;
126
+ const at = {
127
+ x: e.clientX + win.scrollX,
128
+ y: e.clientY + win.scrollY
129
+ };
130
+ const captured = captureTarget(adapter.surface, { at });
131
+ captured.target.model = model;
132
+ captured.target.anchor = `3D · ${model.partId}`;
133
+ delete captured.target.spot;
134
+ toolbar.store.place({
135
+ target: captured,
136
+ placedAt: at
137
+ });
138
+ };
139
+ win.addEventListener("pointerdown", pointerDown, true);
140
+ win.addEventListener("click", click, true);
141
+ let destroyed = false;
142
+ return {
143
+ openPin: hooks.openPin,
144
+ update() {
145
+ if (destroyed || !toolbar.isConnected) return;
146
+ hooks.update();
147
+ const s = toolbar.store.get();
148
+ if (s.pins.some((p) => p.target?.model) || s.draft?.target.target.model) toolbar.store.update({ clock: s.clock });
149
+ },
150
+ destroy() {
151
+ if (destroyed) return;
152
+ destroyed = true;
153
+ hooks.destroy();
154
+ unregister();
155
+ win.removeEventListener("pointerdown", pointerDown, true);
156
+ win.removeEventListener("click", click, true);
157
+ }
158
+ };
159
+ }
160
+ //#endregion
161
+ //#region src/model.ts
162
+ function resolveModelAnchor(anchor, scene) {
163
+ if (anchor.modelId !== scene.modelId || anchor.revision !== scene.revision) return { status: "stale" };
164
+ const values = scene.partMatrix(anchor.partId);
165
+ if (values?.length !== 16 || !values.every(Number.isFinite)) return { status: "missing" };
166
+ const m = values;
167
+ const [x, y, z] = anchor.position;
168
+ const w = m[3] * x + m[7] * y + m[11] * z + m[15];
169
+ if (!Number.isFinite(w) || Math.abs(w) < 1e-12) return { status: "missing" };
170
+ const worldPosition = [
171
+ (m[0] * x + m[4] * y + m[8] * z + m[12]) / w,
172
+ (m[1] * x + m[5] * y + m[9] * z + m[13]) / w,
173
+ (m[2] * x + m[6] * y + m[10] * z + m[14]) / w
174
+ ];
175
+ if (!worldPosition.every(Number.isFinite)) return { status: "missing" };
176
+ return {
177
+ status: "visible",
178
+ worldPosition
179
+ };
180
+ }
181
+ //#endregion
182
+ export { attachModelViewer, resolveModelAnchor };
package/dist/react.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { n as PinboxConfig } from "./index-DENhglDF.js";
1
+ import { n as PinboxConfig } from "./index-xNXeJ2mR.js";
2
2
  import { ReactElement } from "react";
3
3
  //#region src/react.d.ts
4
4
  /**
package/dist/react.js CHANGED
@@ -1,4 +1,4 @@
1
- import { n as defineToolbarElement, r as PinboxToolbarElement } from "./src-BsWnyGwB.js";
1
+ import { n as defineToolbarElement, r as PinboxToolbarElement } from "./src-DnAkx29T.js";
2
2
  import { createElement, useEffect, useRef } from "react";
3
3
  //#region src/react.ts
4
4
  /**
@@ -1,3 +1,4 @@
1
+ import { a as targetLabel, c as captureKey, i as hitTest, l as loadCaptureMode, r as captureTarget, s as toggleToolbarCapture, t as projectModelTarget } from "./model-target-BykKL8H5.js";
1
2
  //#region src/anchor-watch.ts
2
3
  function watchAnchors(win, onChange) {
3
4
  let frame = 0;
@@ -22,23 +23,6 @@ function watchAnchors(win, onChange) {
22
23
  } };
23
24
  }
24
25
  //#endregion
25
- //#region src/capture-mode.ts
26
- function captureKey(prefix) {
27
- return `${prefix}:capture`;
28
- }
29
- function loadCaptureMode(storage, key, fallback) {
30
- try {
31
- const raw = storage?.getItem(key);
32
- if (raw === "dom" || raw === "tab") return raw;
33
- } catch {}
34
- return fallback;
35
- }
36
- function saveCaptureMode(storage, key, mode) {
37
- try {
38
- storage?.setItem(key, mode);
39
- } catch {}
40
- }
41
- //#endregion
42
26
  //#region src/ui/actions.ts
43
27
  const PIN_GLYPH = "<rect x=\"3\" y=\"1.5\" width=\"10\" height=\"6.5\" rx=\"1\"/><path d=\"M8 8v6.5\"/>";
44
28
  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\"/>";
@@ -208,10 +192,11 @@ function threadTail(thread) {
208
192
  ];
209
193
  }
210
194
  function block(pin, thread) {
211
- const { selector, url, source } = pin.target ?? {};
195
+ const { selector, url, source, model } = pin.target ?? {};
212
196
  return [
213
197
  `## Pin ${pin.n === void 0 ? pin.id : `#${pin.n} (${pin.id})`} — ${pin.status.toUpperCase()}`,
214
198
  `- label: ${line(label(pin))}`,
199
+ ...model ? [`- model: ${line(model.modelId)} / ${line(model.partId)} @ ${line(model.revision)}`, `- position: ${model.position.join(", ")} ${model.units}`] : [],
215
200
  ...selector === void 0 ? [] : [`- selector: \`${line(selector)}\``],
216
201
  ...(pin.target?.targets ?? []).map((t) => t.selector ?? t.anchor ?? t.tag).filter((locus) => locus !== void 0).map((locus) => `- also: \`${line(locus)}\``),
217
202
  ...source === void 0 ? [] : [`- source: ${line(source.line === void 0 ? source.file : `${source.file}:${source.line}`)}`],
@@ -808,256 +793,6 @@ function createMinimize(host) {
808
793
  };
809
794
  }
810
795
  //#endregion
811
- //#region src/targeting/dom.ts
812
- /**
813
- * Deepest element under (clientX, clientY) that the caller does not ignore, or null when there is
814
- * nothing there but page chrome (html/body).
815
- *
816
- * Looks THROUGH our own overlay rather than giving up at it. The single-element form could not:
817
- * the drag-aim grip sits exactly on the point being aimed at, so it is always the topmost thing
818
- * under the crosshair, and every probe came back "nothing" the moment touch aiming existed.
819
- */
820
- function hitTest(doc, x, y, ignore) {
821
- const stack = doc.elementsFromPoint?.(x, y) ?? [doc.elementFromPoint(x, y)];
822
- for (const el of stack) {
823
- if (!el || el === doc.body || el === doc.documentElement) return null;
824
- if (!ignore(el)) return el;
825
- }
826
- return null;
827
- }
828
- /** CLASS-or-TAG display name with a sibling index when needed (prototype nodeName). */
829
- function nodeName(el) {
830
- const key = el.classList[0];
831
- let name = (key ?? el.tagName).toUpperCase();
832
- const parent = el.parentElement;
833
- if (parent) {
834
- const sibs = [...parent.children].filter((c) => c.classList[0] === key && c.tagName === el.tagName);
835
- if (sibs.length > 1) name += ` ${sibs.indexOf(el) + 1}`;
836
- }
837
- return name;
838
- }
839
- /**
840
- * Human label for a target: an explicit data-pb-el annotation wins; otherwise a
841
- * CLASS/TAG ancestry chain of at most 3 parts joined with ›, terminating early
842
- * at the first annotated ancestor.
843
- */
844
- function targetLabel(el) {
845
- const own = el.getAttribute("data-pb-el");
846
- if (own) return own;
847
- const parts = [nodeName(el)];
848
- const body = el.ownerDocument.body;
849
- let node = el.parentElement;
850
- while (node && node !== body && parts.length < 3) {
851
- const anchor = node.getAttribute("data-pb-el");
852
- if (anchor) {
853
- parts.unshift(anchor);
854
- break;
855
- }
856
- if (node.classList[0]) parts.unshift(nodeName(node));
857
- node = node.parentElement;
858
- }
859
- return parts.join(" › ");
860
- }
861
- const SAFE_ID = /^[A-Za-z][\w-]*$/;
862
- /** Data attributes trusted as stable hooks, in priority order. */
863
- const STABLE_DATA_ATTRS = [
864
- "data-pb-anchor",
865
- "data-pb-el",
866
- "data-testid"
867
- ];
868
- function attrSegment(el, doc) {
869
- for (const attr of STABLE_DATA_ATTRS) {
870
- const value = el.getAttribute(attr);
871
- if (value === null || value.includes("\"") || value.includes("\\")) continue;
872
- const selector = `${el.tagName.toLowerCase()}[${attr}="${value}"]`;
873
- if (doc.querySelectorAll(selector).length === 1) return selector;
874
- }
875
- return null;
876
- }
877
- function nthSegment(el) {
878
- const tag = el.tagName.toLowerCase();
879
- const parent = el.parentElement;
880
- if (!parent) return tag;
881
- const sameTag = [...parent.children].filter((c) => c.tagName === el.tagName);
882
- return sameTag.length > 1 ? `${tag}:nth-of-type(${sameTag.indexOf(el) + 1})` : tag;
883
- }
884
- /**
885
- * Stable CSS path for an element: ids > stable data attributes > an
886
- * nth-of-type chain. Guaranteed round-trip: querySelector(buildSelector(el)) === el.
887
- */
888
- function buildSelector(el) {
889
- const doc = el.ownerDocument;
890
- const segments = [];
891
- let node = el;
892
- while (node && node !== doc.documentElement) {
893
- const id = node.getAttribute("id");
894
- if (id && SAFE_ID.test(id) && doc.querySelectorAll(`#${id}`).length === 1) {
895
- segments.unshift(`#${id}`);
896
- return segments.join(" > ");
897
- }
898
- const byAttr = attrSegment(node, doc);
899
- if (byAttr) {
900
- segments.unshift(byAttr);
901
- return segments.join(" > ");
902
- }
903
- segments.unshift(nthSegment(node));
904
- node = node.parentElement;
905
- }
906
- return segments.join(" > ");
907
- }
908
- //#endregion
909
- //#region src/capture.ts
910
- /** Curated computed-style subset — enough to reconstruct layout intent, tiny on the wire. */
911
- const STYLE_KEYS = [
912
- "display",
913
- "position",
914
- "font-size",
915
- "color",
916
- "background-color",
917
- "margin",
918
- "padding",
919
- "overflow"
920
- ];
921
- const NEARBY_TEXT_MAX = 160;
922
- function styleSubset(win, el) {
923
- let cs;
924
- try {
925
- cs = win.getComputedStyle(el);
926
- } catch {
927
- return;
928
- }
929
- const out = {};
930
- for (const key of STYLE_KEYS) {
931
- const value = cs.getPropertyValue(key);
932
- if (value !== "") out[key] = value;
933
- }
934
- return Object.keys(out).length > 0 ? out : void 0;
935
- }
936
- function ariaMap(el) {
937
- const out = {};
938
- for (const name of el.getAttributeNames()) if (name.startsWith("aria-")) out[name] = el.getAttribute(name) ?? "";
939
- return Object.keys(out).length > 0 ? out : void 0;
940
- }
941
- function nearbyText(el) {
942
- const text = (el.textContent ?? "").replace(/\s+/g, " ").trim();
943
- return text === "" ? void 0 : text.slice(0, NEARBY_TEXT_MAX);
944
- }
945
- /** The user's selection, only when it intersects the captured element. */
946
- function selectedText(win, el) {
947
- try {
948
- const sel = win.getSelection?.();
949
- if (!sel || sel.isCollapsed || sel.rangeCount === 0) return void 0;
950
- if (!sel.getRangeAt(0).intersectsNode(el)) return void 0;
951
- const text = sel.toString().trim();
952
- return text === "" ? void 0 : text;
953
- } catch {
954
- return;
955
- }
956
- }
957
- /** `fixed` detected via ancestry: any ancestor with computed position: fixed. */
958
- function isFixed(win, el) {
959
- for (let node = el; node !== null; node = node.parentElement) try {
960
- if (win.getComputedStyle(node).position === "fixed") return true;
961
- } catch {
962
- return false;
963
- }
964
- return false;
965
- }
966
- /** Beyond this an element is not a thing you pinned, it is a region. Too many to rewrite as a set. */
967
- const MAX_RUNS = 40;
968
- const MAX_RUN_LENGTH = 200;
969
- /** Text that is not content: a script body or a stylesheet is not something to rewrite. */
970
- const NON_CONTENT = /* @__PURE__ */ new Set([
971
- "SCRIPT",
972
- "STYLE",
973
- "NOSCRIPT",
974
- "TEMPLATE"
975
- ]);
976
- /**
977
- * The element's text, split the way the browser stores it: one entry per run of characters.
978
- *
979
- * This walks TEXT NODES, not elements, and that distinction is the whole point — it makes no
980
- * assumption about how a site is built. A heading is one run. A nav bar is one per link. A
981
- * paragraph with a bold word in the middle is three, in reading order, including the halves either
982
- * side of the bold. An earlier version keyed off "elements with no element children", which
983
- * quietly lost the "Hello " in `<p>Hello <b>world</b></p>` — text a person can obviously see and
984
- * would obviously expect to be able to change.
985
- *
986
- * `nearbyText` runs them all together, which is fine to read and useless to edit: it cannot tell
987
- * an agent that "work approach people contact" is four separate places. This can.
988
- */
989
- function textRuns(el) {
990
- const runs = [];
991
- const walk = (node) => {
992
- if (node.nodeType === 3) {
993
- const text = (node.nodeValue ?? "").trim();
994
- if (text.length > 0) runs.push(text.slice(0, MAX_RUN_LENGTH));
995
- return runs.length <= MAX_RUNS;
996
- }
997
- if (node.nodeType !== 1 || NON_CONTENT.has(node.tagName)) return true;
998
- for (const child of node.childNodes) if (!walk(child)) return false;
999
- return true;
1000
- };
1001
- if (!walk(el) || runs.length === 0) return void 0;
1002
- return runs;
1003
- }
1004
- function buildContext(win, el) {
1005
- const context = {};
1006
- if (el.classList.length > 0) context.classes = [...el.classList];
1007
- const styles = styleSubset(win, el);
1008
- if (styles !== void 0) context.styles = styles;
1009
- const aria = ariaMap(el);
1010
- if (aria !== void 0) context.aria = aria;
1011
- const nearby = nearbyText(el);
1012
- if (nearby !== void 0) context.nearbyText = nearby;
1013
- const selected = selectedText(win, el);
1014
- if (selected !== void 0) context.selectedText = selected;
1015
- const runs = textRuns(el);
1016
- if (runs !== void 0) context.textRuns = runs;
1017
- return Object.keys(context).length > 0 ? context : void 0;
1018
- }
1019
- /** Fills PinInput.target/env from a chosen element (shapes come from the pin schema). */
1020
- function captureTarget(el, opts) {
1021
- const win = el.ownerDocument.defaultView;
1022
- const r = el.getBoundingClientRect();
1023
- const target = {
1024
- url: win.location.href,
1025
- selector: buildSelector(el),
1026
- tag: el.tagName.toLowerCase(),
1027
- rect: {
1028
- x: r.left + win.scrollX,
1029
- y: r.top + win.scrollY,
1030
- width: r.width,
1031
- height: r.height
1032
- },
1033
- fixed: isFixed(win, el)
1034
- };
1035
- if (opts?.anchor !== void 0) target.anchor = opts.anchor;
1036
- if (opts?.at !== void 0 && r.width > 0 && r.height > 0) {
1037
- const fx = (opts.at.x - (r.left + win.scrollX)) / r.width;
1038
- const fy = (opts.at.y - (r.top + win.scrollY)) / r.height;
1039
- if (fx >= 0 && fx <= 1 && fy >= 0 && fy <= 1) target.spot = {
1040
- x: fx,
1041
- y: fy
1042
- };
1043
- }
1044
- const context = buildContext(win, el);
1045
- if (context !== void 0) target.context = context;
1046
- return {
1047
- target,
1048
- env: {
1049
- viewport: {
1050
- w: win.innerWidth,
1051
- h: win.innerHeight,
1052
- dpr: win.devicePixelRatio
1053
- },
1054
- browser: win.navigator.userAgent,
1055
- os: win.navigator.platform || "unknown",
1056
- colorScheme: win.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"
1057
- }
1058
- };
1059
- }
1060
- //#endregion
1061
796
  //#region src/ui/aim.ts
1062
797
  /**
1063
798
  * True when aiming has to be done by dragging rather than by pointing.
@@ -1311,7 +1046,8 @@ function createPlacement(deps) {
1311
1046
  function placeDraft(e) {
1312
1047
  e.preventDefault();
1313
1048
  e.stopPropagation();
1314
- const capture = captureTarget(hover ?? doc.body, { at: {
1049
+ const el = hover ?? doc.body;
1050
+ const capture = captureTarget(el, { at: {
1315
1051
  x: e.pageX,
1316
1052
  y: e.pageY
1317
1053
  } });
@@ -2368,8 +2104,8 @@ function createBar(doc, on) {
2368
2104
  inboxBtn.classList.toggle("lit", state.inboxOpen);
2369
2105
  const open = String(openTaskCount(state.pins));
2370
2106
  if (count.textContent !== open) count.textContent = open;
2371
- captureBtn.classList.toggle("lit", state.captureMode === "tab");
2372
- 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)";
2107
+ captureBtn.classList.toggle("lit", !state.captureLabel && state.captureMode === "tab");
2108
+ captureBtn.title = state.captureLabel ?? (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)");
2373
2109
  if (hideShown !== state.pinsHidden) {
2374
2110
  hideShown = state.pinsHidden;
2375
2111
  hideBtn.innerHTML = icon(state.pinsHidden ? EYE_GLYPH : EYE_OFF_GLYPH, 14);
@@ -2687,6 +2423,7 @@ function anchorRect(doc, pin) {
2687
2423
  }
2688
2424
  /** The same resolution for any captured target — a pin's, or the draft's before it commits. */
2689
2425
  function targetRect(doc, target) {
2426
+ if (target?.model) return projectModelTarget(doc, target.model);
2690
2427
  const stored = target?.rect;
2691
2428
  if (stored === void 0) return null;
2692
2429
  const win = doc.defaultView;
@@ -3016,6 +2753,7 @@ function anchorOf(root, pin, draft) {
3016
2753
  * labels the card without claiming an element that was never captured.
3017
2754
  */
3018
2755
  function labelOf(target) {
2756
+ if (target?.model) return `3D · ${target.model.partId}`;
3019
2757
  return target?.anchor ?? target?.tag?.toUpperCase() ?? "PIN";
3020
2758
  }
3021
2759
  function viewOf(root, state) {
@@ -3753,11 +3491,7 @@ var PinboxToolbarElement = class extends BaseElement {
3753
3491
  return loadCaptureMode(globalThis.localStorage, key, this.config?.capture ?? "dom");
3754
3492
  }
3755
3493
  #toggleCapture() {
3756
- const next = this.store.get().captureMode === "tab" ? "dom" : "tab";
3757
- this.store.update({ captureMode: next });
3758
- if (next === "dom") releaseCapture();
3759
- saveCaptureMode(globalThis.localStorage, captureKey(`pinbox:${this.config?.endpoint ?? ""}`), next);
3760
- return true;
3494
+ return toggleToolbarCapture(this, this.config?.endpoint ?? "", releaseCapture);
3761
3495
  }
3762
3496
  /** Theme from the OS when the host set none; the page-level CSS (placing cursor) into <head>. */
3763
3497
  #applyPageDefaults() {
package/dist/svelte.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { _ as applyHubEvent, a as HubEvent, b as upsertPin, c as WebSocketLike, d as PinboxToolbarElement, f as Draft, g as appendThreadMessage, h as UiStatus, i as ConnectionState, l as HubError, m as ToolbarState, n as PinboxConfig, o as HubTransport, p as Store, r as defineToolbarElement, s as TransportOptions, t as Pinbox, u as StorageLike, v as createStore, x as CaptureResult, y as deriveUiStatus } from "./index-DENhglDF.js";
1
+ import { _ as applyHubEvent, a as HubEvent, b as upsertPin, c as WebSocketLike, d as PinboxToolbarElement, f as Draft, g as appendThreadMessage, h as UiStatus, i as ConnectionState, l as HubError, m as ToolbarState, n as PinboxConfig, o as HubTransport, p as Store, r as defineToolbarElement, s as TransportOptions, t as Pinbox, u as StorageLike, v as createStore, x as CaptureResult, y as deriveUiStatus } from "./index-xNXeJ2mR.js";
2
2
  import { Action } from "svelte/action";
3
3
  //#region src/svelte.d.ts
4
4
  /**
package/dist/svelte.js CHANGED
@@ -1,4 +1,4 @@
1
- import { a as HubError, c as createStore, i as HubTransport, l as deriveUiStatus, n as defineToolbarElement, o as appendThreadMessage, r as PinboxToolbarElement, s as applyHubEvent, t as Pinbox, u as upsertPin } from "./src-BsWnyGwB.js";
1
+ import { a as HubError, c as createStore, i as HubTransport, l as deriveUiStatus, n as defineToolbarElement, o as appendThreadMessage, r as PinboxToolbarElement, s as applyHubEvent, t as Pinbox, u as upsertPin } from "./src-DnAkx29T.js";
2
2
  //#region src/svelte.ts
3
3
  /**
4
4
  * `<div use:pinbox={{ endpoint }} />` — creates + configures the element BEFORE insertion
@@ -210,10 +210,11 @@ var Pinbox = (function(exports) {
210
210
  ];
211
211
  }
212
212
  function block(pin, thread) {
213
- const { selector, url, source } = pin.target ?? {};
213
+ const { selector, url, source, model } = pin.target ?? {};
214
214
  return [
215
215
  `## Pin ${pin.n === void 0 ? pin.id : `#${pin.n} (${pin.id})`} — ${pin.status.toUpperCase()}`,
216
216
  `- label: ${line(label(pin))}`,
217
+ ...model ? [`- model: ${line(model.modelId)} / ${line(model.partId)} @ ${line(model.revision)}`, `- position: ${model.position.join(", ")} ${model.units}`] : [],
217
218
  ...selector === void 0 ? [] : [`- selector: \`${line(selector)}\``],
218
219
  ...(pin.target?.targets ?? []).map((t) => t.selector ?? t.anchor ?? t.tag).filter((locus) => locus !== void 0).map((locus) => `- also: \`${line(locus)}\``),
219
220
  ...source === void 0 ? [] : [`- source: ${line(source.line === void 0 ? source.file : `${source.file}:${source.line}`)}`],
@@ -810,6 +811,34 @@ var Pinbox = (function(exports) {
810
811
  };
811
812
  }
812
813
  //#endregion
814
+ //#region src/model-capture.ts
815
+ const handlers = /* @__PURE__ */ new WeakMap();
816
+ function runModelCapture(owner) {
817
+ const entries = handlers.get(owner);
818
+ const entry = entries?.[0];
819
+ if (!entries || !entry) return false;
820
+ if (entries.some((candidate) => candidate.busy)) return true;
821
+ entry.busy = true;
822
+ try {
823
+ Promise.resolve(entry.run()).catch(entry.error).finally(() => {
824
+ entry.busy = false;
825
+ });
826
+ } catch (error) {
827
+ entry.busy = false;
828
+ entry.error(error);
829
+ }
830
+ return true;
831
+ }
832
+ /** Shared by the camera button, puck and S shortcut; preserve the 2D fallback. */
833
+ function toggleToolbarCapture(owner, endpoint, release) {
834
+ if (runModelCapture(owner)) return true;
835
+ const next = owner.store.get().captureMode === "tab" ? "dom" : "tab";
836
+ owner.store.update({ captureMode: next });
837
+ if (next === "dom") release();
838
+ saveCaptureMode(globalThis.localStorage, captureKey(`pinbox:${endpoint}`), next);
839
+ return true;
840
+ }
841
+ //#endregion
813
842
  //#region src/targeting/dom.ts
814
843
  /**
815
844
  * Deepest element under (clientX, clientY) that the caller does not ignore, or null when there is
@@ -2370,8 +2399,8 @@ var Pinbox = (function(exports) {
2370
2399
  inboxBtn.classList.toggle("lit", state.inboxOpen);
2371
2400
  const open = String(openTaskCount(state.pins));
2372
2401
  if (count.textContent !== open) count.textContent = open;
2373
- captureBtn.classList.toggle("lit", state.captureMode === "tab");
2374
- 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)";
2402
+ captureBtn.classList.toggle("lit", !state.captureLabel && state.captureMode === "tab");
2403
+ captureBtn.title = state.captureLabel ?? (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)");
2375
2404
  if (hideShown !== state.pinsHidden) {
2376
2405
  hideShown = state.pinsHidden;
2377
2406
  hideBtn.innerHTML = icon(state.pinsHidden ? EYE_GLYPH : EYE_OFF_GLYPH, 14);
@@ -2644,6 +2673,21 @@ var Pinbox = (function(exports) {
2644
2673
  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>`;
2645
2674
  }
2646
2675
  //#endregion
2676
+ //#region src/model-target.ts
2677
+ const projectors = /* @__PURE__ */ new WeakMap();
2678
+ function projectModelTarget(doc, anchor) {
2679
+ for (const project of projectors.get(doc) ?? []) {
2680
+ const p = project(anchor);
2681
+ if (p && Number.isFinite(p.x) && Number.isFinite(p.y)) return {
2682
+ x: p.x,
2683
+ y: p.y,
2684
+ width: 0,
2685
+ height: 0
2686
+ };
2687
+ }
2688
+ return null;
2689
+ }
2690
+ //#endregion
2647
2691
  //#region src/ui/pins.ts
2648
2692
  /** The prototype's `_h` innerHTML memo, kept off the DOM node. */
2649
2693
  const chipMemo = /* @__PURE__ */ new WeakMap();
@@ -2689,6 +2733,7 @@ var Pinbox = (function(exports) {
2689
2733
  }
2690
2734
  /** The same resolution for any captured target — a pin's, or the draft's before it commits. */
2691
2735
  function targetRect(doc, target) {
2736
+ if (target?.model) return projectModelTarget(doc, target.model);
2692
2737
  const stored = target?.rect;
2693
2738
  if (stored === void 0) return null;
2694
2739
  const win = doc.defaultView;
@@ -3018,6 +3063,7 @@ var Pinbox = (function(exports) {
3018
3063
  * labels the card without claiming an element that was never captured.
3019
3064
  */
3020
3065
  function labelOf(target) {
3066
+ if (target?.model) return `3D · ${target.model.partId}`;
3021
3067
  return target?.anchor ?? target?.tag?.toUpperCase() ?? "PIN";
3022
3068
  }
3023
3069
  function viewOf(root, state) {
@@ -3755,11 +3801,7 @@ button { font: inherit; color: inherit; background: none; border: 0; cursor: poi
3755
3801
  return loadCaptureMode(globalThis.localStorage, key, this.config?.capture ?? "dom");
3756
3802
  }
3757
3803
  #toggleCapture() {
3758
- const next = this.store.get().captureMode === "tab" ? "dom" : "tab";
3759
- this.store.update({ captureMode: next });
3760
- if (next === "dom") releaseCapture();
3761
- saveCaptureMode(globalThis.localStorage, captureKey(`pinbox:${this.config?.endpoint ?? ""}`), next);
3762
- return true;
3804
+ return toggleToolbarCapture(this, this.config?.endpoint ?? "", releaseCapture);
3763
3805
  }
3764
3806
  /** Theme from the OS when the host set none; the page-level CSS (placing cursor) into <head>. */
3765
3807
  #applyPageDefaults() {
package/dist/vue.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { d as PinboxToolbarElement, n as PinboxConfig } from "./index-DENhglDF.js";
1
+ import { d as PinboxToolbarElement, n as PinboxConfig } from "./index-xNXeJ2mR.js";
2
2
  import { VNode } from "vue";
3
3
  //#region src/vue.d.ts
4
4
  /** The slice of the Vue component instance this wrapper touches. */
package/dist/vue.js CHANGED
@@ -1,4 +1,4 @@
1
- import { n as defineToolbarElement, r as PinboxToolbarElement } from "./src-BsWnyGwB.js";
1
+ import { n as defineToolbarElement, r as PinboxToolbarElement } from "./src-DnAkx29T.js";
2
2
  import { h } from "vue";
3
3
  //#region src/vue.ts
4
4
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@autono/pinbox-toolbar",
3
- "version": "0.19.0",
3
+ "version": "0.20.0",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -42,7 +42,11 @@
42
42
  "types": "./dist/plugins/next.d.ts",
43
43
  "default": "./dist/plugins/next.js"
44
44
  },
45
- "./package.json": "./package.json"
45
+ "./package.json": "./package.json",
46
+ "./model": {
47
+ "types": "./dist/model.d.ts",
48
+ "default": "./dist/model.js"
49
+ }
46
50
  },
47
51
  "scripts": {
48
52
  "typecheck": "tsc --noEmit",
@@ -71,7 +75,7 @@
71
75
  }
72
76
  },
73
77
  "devDependencies": {
74
- "@autono/pinbox-core": "0.19.0",
78
+ "@autono/pinbox-core": "0.20.0",
75
79
  "@types/react": "^19.2.0",
76
80
  "typescript": "^7.0.0",
77
81
  "tsdown": "^0.22.0",