@tineira/apc-web 0.0.0-stage → 0.7.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.
@@ -0,0 +1,598 @@
1
+ import { APC_JSON_MAX_BYTES, APC_JSON_PATH, couldNotCheckNote, directOnlyNote, parseApcManifest, parseFocusAttrs, urlOnOrigin } from "@tineira/apc-protocol";
2
+ const profileLoads = new Map();
3
+ /** How long the page waits for its own apc.json: a held drop never waits longer. */
4
+ export const APC_JSON_LOAD_MS = 3000;
5
+ /** A body as text, at most `cap` bytes, or null past it; the rest is never read. */
6
+ async function readCapped(response, cap) {
7
+ if (!response.body)
8
+ return "";
9
+ const reader = response.body.getReader();
10
+ const chunks = [];
11
+ let size = 0;
12
+ for (;;) {
13
+ const { done, value } = await reader.read();
14
+ if (done)
15
+ break;
16
+ size += value.byteLength;
17
+ if (size > cap) {
18
+ void reader.cancel().catch(() => { });
19
+ return null;
20
+ }
21
+ chunks.push(value);
22
+ }
23
+ const all = new Uint8Array(size);
24
+ let at = 0;
25
+ for (const c of chunks) {
26
+ all.set(c, at);
27
+ at += c.byteLength;
28
+ }
29
+ return new TextDecoder().decode(all);
30
+ }
31
+ /**
32
+ * The profiles the page's own app offers, from its apc.json (APC 0.5), for `enablePinning({ profiles })`:
33
+ * read without cookies, with no redirect followed, within APC_JSON_LOAD_MS for headers and body, and at
34
+ * most APC_JSON_MAX_BYTES, as the hub and the extension read it. Kept per origin once read; null when
35
+ * there is no valid file in time, and then read again on the next call.
36
+ */
37
+ export function loadApcProfiles(origin = location.origin, fetcher = (...a) => fetch(...a)) {
38
+ let load = profileLoads.get(origin);
39
+ if (!load) {
40
+ const signal = AbortSignal.timeout(APC_JSON_LOAD_MS);
41
+ const ends = new Promise((resolve) => signal.addEventListener("abort", () => resolve(null), { once: true }));
42
+ const read = fetcher(`${origin}${APC_JSON_PATH}`, { credentials: "omit", redirect: "error", signal, headers: { accept: "application/json" } })
43
+ .then((r) => (r.ok ? readCapped(r, APC_JSON_MAX_BYTES) : null))
44
+ .then((text) => {
45
+ if (text === null)
46
+ return null;
47
+ const parsed = parseApcManifest(JSON.parse(text), origin);
48
+ return parsed.ok ? parsed.manifest.profiles : null;
49
+ })
50
+ .catch(() => null);
51
+ // The time limit covers the body too: a file that never ends gives null on time.
52
+ load = Promise.race([read, ends]);
53
+ profileLoads.set(origin, load);
54
+ void load.then((p) => p || profileLoads.delete(origin));
55
+ }
56
+ return load;
57
+ }
58
+ /**
59
+ * The default answer to a dock agent dropped on a Direct-only app: a note at the bottom of the page
60
+ * with the words, a link to the app's connect page, and ×. It stays 20 s, or until closed. A new drop
61
+ * replaces it: never a stack of notes.
62
+ */
63
+ let shownNote = null;
64
+ export function showDockUnsupported(answer) {
65
+ if (shownNote) {
66
+ clearTimeout(shownNote.timer);
67
+ shownNote.box.remove();
68
+ shownNote = null;
69
+ }
70
+ const box = document.createElement("div");
71
+ box.setAttribute("role", "status");
72
+ box.setAttribute("data-apc-dock-unsupported", "");
73
+ Object.assign(box.style, { position: "fixed", zIndex: "2147483647", right: "16px", bottom: "calc(16px + max(var(--apc-bar-height, 0px), env(safe-area-inset-bottom, 0px)))", maxWidth: "340px", padding: "12px 14px", borderRadius: "10px", background: "#1f1f1f", color: "#fff", font: "13px/1.45 system-ui, sans-serif", boxShadow: "0 4px 16px rgba(0,0,0,.25)" });
74
+ const text = document.createElement("p");
75
+ text.textContent = answer.message;
76
+ Object.assign(text.style, { margin: "0 0 8px" });
77
+ box.appendChild(text);
78
+ // Only an http(s) page of this app: the same check apc.json's direct.connect passes (no blob:, javascript: or another site).
79
+ const connect = typeof location === "undefined" ? null : urlOnOrigin(answer.connect, location.origin);
80
+ if (connect) {
81
+ const link = document.createElement("a");
82
+ link.setAttribute("href", connect);
83
+ link.textContent = `Connect an agent to ${answer.name}`;
84
+ Object.assign(link.style, { color: "#f2a46b", fontWeight: "600" });
85
+ box.appendChild(link);
86
+ }
87
+ const close = document.createElement("button");
88
+ close.setAttribute("type", "button");
89
+ close.setAttribute("aria-label", "Close");
90
+ close.textContent = "×";
91
+ Object.assign(close.style, { position: "absolute", top: "4px", right: "6px", background: "none", border: "0", color: "#ccc", font: "16px system-ui, sans-serif", cursor: "pointer" });
92
+ const remove = () => {
93
+ box.remove();
94
+ if (shownNote?.box === box) {
95
+ clearTimeout(shownNote.timer);
96
+ shownNote = null;
97
+ }
98
+ };
99
+ close.addEventListener("click", remove);
100
+ box.appendChild(close);
101
+ document.body.appendChild(box);
102
+ shownNote = { box, timer: setTimeout(remove, 20_000) };
103
+ return box;
104
+ }
105
+ /** The drag type a dock puts on an agent: its payload is agentDragPayload(handle), never a secret. */
106
+ export const APC_AGENT_TYPE = "application/x-apc-agent";
107
+ /**
108
+ * Set on the page's root element while enablePinning takes native agent drops (APC 0.5): the page
109
+ * answers every drop of a dock agent itself, so the browser extension stands back even on a
110
+ * Direct-only app. A Direct-only app without it gets the extension's answer instead.
111
+ */
112
+ export const APC_DROPS_ATTR = "data-apc-native-drops";
113
+ /**
114
+ * What an agent's drag carries: a public handle and nothing else. Any page the agent is dropped on
115
+ * can read it, so it must never hold a grant secret or an MCP URL.
116
+ */
117
+ export function agentDragPayload(handle, pairing) {
118
+ return JSON.stringify(pairing ? { v: 1, agent: handle, hub: pairing.hub, ticket: pairing.ticket } : { v: 1, agent: handle });
119
+ }
120
+ /**
121
+ * A drag payload, or null when it is not one (another app's data, a wrong version, junk). A hub
122
+ * that is not a bare http(s) origin, or a ticket that is not a short token, is dropped, not the drag.
123
+ */
124
+ export function parseAgentDrag(text) {
125
+ try {
126
+ const value = JSON.parse(text);
127
+ if (value?.v !== 1 || typeof value.agent !== "string" || !/^[\w.:-]{1,128}$/.test(value.agent))
128
+ return null;
129
+ const drag = { v: 1, agent: value.agent };
130
+ const hubOk = typeof value.hub === "string" && value.hub.length <= 200 && /^https?:\/\/[a-z0-9.-]+(:\d{1,5})?$/.test(value.hub);
131
+ const ticketOk = typeof value.ticket === "string" && /^[\w.-]{16,200}$/.test(value.ticket);
132
+ if (hubOk && ticketOk)
133
+ Object.assign(drag, { hub: value.hub, ticket: value.ticket });
134
+ return drag;
135
+ }
136
+ catch {
137
+ return null;
138
+ }
139
+ }
140
+ /** The handle in a drag payload, or null when it is not one. */
141
+ export function parseAgentDragPayload(text) {
142
+ return parseAgentDrag(text)?.agent ?? null;
143
+ }
144
+ /** The innermost pointable element at a point, with its parsed target. */
145
+ export function targetAt(x, y) {
146
+ return targetOf(document.elementFromPoint(x, y));
147
+ }
148
+ /** The innermost pointable element at or above a node, with its parsed target. */
149
+ export function targetOf(start) {
150
+ let node = start;
151
+ while (node) {
152
+ const host = node.closest("[data-focus]");
153
+ if (!host)
154
+ return null;
155
+ const target = parseFocusAttrs(host.getAttribute("data-focus"), host.getAttribute("data-focus-part"), host.getAttribute("data-focus-params"));
156
+ if (target)
157
+ return { element: host, target };
158
+ node = host.parentElement;
159
+ }
160
+ return null;
161
+ }
162
+ export function createTapPin(options) {
163
+ let armed = null;
164
+ const set = (next) => {
165
+ if (next === armed)
166
+ return;
167
+ armed = next;
168
+ options.onChange?.(armed);
169
+ };
170
+ return {
171
+ get armed() {
172
+ return armed;
173
+ },
174
+ toggle(grantId) {
175
+ set(armed === grantId ? null : grantId);
176
+ return armed;
177
+ },
178
+ cancel() {
179
+ set(null);
180
+ },
181
+ tap(hit) {
182
+ if (!armed)
183
+ return "idle";
184
+ if (!hit)
185
+ return "no_target";
186
+ if (options.canTarget && !options.canTarget(armed, hit.target))
187
+ return "refused";
188
+ const grantId = armed;
189
+ set(null);
190
+ options.onPin(grantId, hit.target, hit.element);
191
+ return "pinned";
192
+ },
193
+ };
194
+ }
195
+ /** The window event fired when an agent is armed or let go (detail.id: the grant id or null): the agent bar marks the armed mark. */
196
+ export const APC_ARMED_EVENT = "apc-armed";
197
+ /** Less than this many pixels between a touch's down and up is a tap, not a drag. */
198
+ export const TAP_SLOP_PX = 10;
199
+ const ARMED_STYLE_ID = "apc-pinning-style";
200
+ const ARMED_CSS = `
201
+ body.apc-armed [data-focus] { outline: 2px dashed var(--apc-accent, #b85209); outline-offset: 2px; cursor: pointer; }
202
+ [data-apc-mark][aria-pressed="true"] { outline: 3px solid var(--apc-accent, #b85209); outline-offset: 2px; }
203
+ `;
204
+ /** The note shown while an agent is armed: what to do, and a way out. Fixed above the agent bar (--apc-bar-height). */
205
+ function armedNote(name, onCancel) {
206
+ const box = document.createElement("div");
207
+ box.setAttribute("role", "status");
208
+ box.setAttribute("data-apc-tap-pin", "");
209
+ Object.assign(box.style, { position: "fixed", zIndex: "2147483646", left: "12px", right: "12px", bottom: "calc(max(var(--apc-bar-height, 0px), env(safe-area-inset-bottom, 0px)) + 12px)", display: "flex", alignItems: "center", gap: "10px", padding: "10px 12px", borderRadius: "12px", background: "#1f1f1f", color: "#fff", font: "14px/1.35 system-ui, sans-serif", boxShadow: "0 4px 16px rgba(0,0,0,.3)" });
210
+ const text = document.createElement("span");
211
+ text.style.flex = "1";
212
+ const say = (t) => {
213
+ text.textContent = t;
214
+ };
215
+ say(`Tap what to point ${name} at.`);
216
+ const cancel = document.createElement("button");
217
+ cancel.setAttribute("type", "button");
218
+ cancel.setAttribute("data-apc-tap-cancel", "");
219
+ cancel.textContent = "Cancel";
220
+ Object.assign(cancel.style, { minHeight: "44px", minWidth: "64px", padding: "0 14px", borderRadius: "10px", border: "1px solid #666", background: "transparent", color: "#fff", font: "600 14px system-ui, sans-serif", cursor: "pointer" });
221
+ cancel.addEventListener("click", onCancel);
222
+ box.append(text, cancel);
223
+ return { box, say };
224
+ }
225
+ /**
226
+ * Drag an agent's mark onto anything that carries data-focus. While dragging,
227
+ * the body has class apc-dragging (valid targets can outline themselves) and
228
+ * the target under the pointer has class apc-target. Escape cancels.
229
+ *
230
+ * Touch (APC 0.6): a finger can drag a mark the same way (pointer events), and a plain tap on a mark
231
+ * (a touch or pen press that moves under TAP_SLOP_PX, or a keyboard activation) arms the agent
232
+ * instead: "tap to pin". The body gets class apc-armed (and apc-dragging), every target is outlined,
233
+ * a note at the bottom of the page says what to do and has Cancel, and the next tap on a target pins
234
+ * the agent there (the click that follows is swallowed, so the target's own action does not run).
235
+ * Tapping the mark again, Cancel or Escape lets go. A mouse click on a mark does nothing, as before.
236
+ * A pin badge (<apc-pin>) can be dragged by touch but does not arm: it is where an agent points,
237
+ * not where agents are.
238
+ * Returns a function that removes the listeners.
239
+ */
240
+ export function enablePinning(options) {
241
+ const root = options.root ?? document;
242
+ const markSelector = options.markSelector ?? "[data-apc-mark]";
243
+ let drag = null;
244
+ // Tap to pin: the agent armed by a tap on its mark, and the note that says so.
245
+ let note = null;
246
+ // The one click that follows a pinning (or refused) tap or key: swallowed once, and only on the element that was pinned.
247
+ let swallowClickUntil = 0;
248
+ let swallowOn = null;
249
+ const trusted = options.trusted ?? ((e) => e.isTrusted === true);
250
+ // Armed from the keyboard: targets can be focused and pinned with Enter or Space. What was changed is put back on let go.
251
+ let restore = [];
252
+ let watch = null;
253
+ const tapPin = createTapPin({
254
+ canTarget: options.canTarget,
255
+ onPin: options.onPin,
256
+ onChange: (armed) => {
257
+ note?.box.remove();
258
+ note = null;
259
+ for (const [el, prev] of restore)
260
+ (prev === null ? el.removeAttribute("tabindex") : el.setAttribute("tabindex", prev));
261
+ restore = [];
262
+ if (watch)
263
+ clearInterval(watch);
264
+ watch = null;
265
+ for (const m of document.querySelectorAll(markSelector))
266
+ m.removeAttribute("aria-pressed");
267
+ if (!armed) {
268
+ document.body.classList.remove("apc-armed", "apc-dragging");
269
+ document.documentElement.removeAttribute("data-apc-armed");
270
+ }
271
+ else {
272
+ if (!document.getElementById(ARMED_STYLE_ID)) {
273
+ const style = document.createElement("style");
274
+ style.id = ARMED_STYLE_ID;
275
+ style.textContent = ARMED_CSS;
276
+ document.head.append(style);
277
+ }
278
+ const mark = [...document.querySelectorAll(markSelector)].find((m) => m.getAttribute("data-grant") === armed && !m.closest("apc-pin"));
279
+ const name = mark?.getAttribute("aria-label") || mark?.getAttribute("title") || "your agent";
280
+ note = armedNote(name, () => tapPin.cancel());
281
+ document.body.append(note.box);
282
+ document.body.classList.add("apc-armed", "apc-dragging");
283
+ document.documentElement.setAttribute("data-apc-armed", armed);
284
+ mark?.setAttribute("aria-pressed", "true");
285
+ // A route change (pushState too) or a hidden page lets go: the person is never left armed on another screen.
286
+ const href = location.href;
287
+ watch = setInterval(() => {
288
+ if (location.href !== href || document.visibilityState === "hidden")
289
+ tapPin.cancel();
290
+ }, 250);
291
+ }
292
+ window.dispatchEvent(new CustomEvent(APC_ARMED_EVENT, { detail: { id: armed } }));
293
+ },
294
+ });
295
+ /** A tap on a mark of the agent bar (not a pin badge) arms its agent. */
296
+ const armFromMark = (mark, grantId) => {
297
+ if (!mark.closest("apc-pin"))
298
+ tapPin.toggle(grantId);
299
+ };
300
+ /** From the keyboard: arm, make every target focusable, move focus to the first and say how to pin. */
301
+ const armFromKey = (mark, grantId) => {
302
+ if (mark.closest("apc-pin"))
303
+ return;
304
+ if (tapPin.toggle(grantId) !== grantId)
305
+ return;
306
+ const targets = [...document.querySelectorAll("[data-focus]")].filter((el) => targetOf(el)?.element === el);
307
+ for (const el of targets) {
308
+ if (el.hasAttribute("tabindex"))
309
+ continue;
310
+ restore.push([el, null]);
311
+ el.setAttribute("tabindex", "0");
312
+ }
313
+ note?.say("Pointing your agent: Tab to an outlined item, then press Enter or Space to pin it. Escape cancels.");
314
+ targets[0]?.focus();
315
+ };
316
+ const setCurrent = (next) => {
317
+ if (!drag)
318
+ return;
319
+ drag.current?.element.classList.remove("apc-target");
320
+ drag.current = next;
321
+ next?.element.classList.add("apc-target");
322
+ };
323
+ const end = (commit) => {
324
+ if (!drag)
325
+ return;
326
+ const { grantId, ghost, current } = drag;
327
+ current?.element.classList.remove("apc-target");
328
+ ghost.remove();
329
+ // Armed (tap to pin), the page stays in its pinning state: only a drag's own class goes.
330
+ if (!tapPin.armed)
331
+ document.body.classList.remove("apc-dragging");
332
+ drag = null;
333
+ if (commit && current)
334
+ options.onPin(grantId, current.target, current.element);
335
+ };
336
+ const onDown = (event) => {
337
+ if (!trusted(event))
338
+ return;
339
+ const pointer = event;
340
+ const mark = pointer.target?.closest?.(markSelector);
341
+ const grantId = mark?.getAttribute("data-grant");
342
+ if (!mark || !grantId || pointer.button !== 0)
343
+ return;
344
+ pointer.preventDefault();
345
+ const ghost = mark.cloneNode(true);
346
+ ghost.classList.add("apc-ghost");
347
+ Object.assign(ghost.style, { outline: "none", position: "fixed", pointerEvents: "none", left: `${pointer.clientX - 12}px`, top: `${pointer.clientY - 12}px`, zIndex: "1000" });
348
+ document.body.appendChild(ghost);
349
+ document.body.classList.add("apc-dragging");
350
+ drag = { grantId, mark, ghost, current: null, x: pointer.clientX, y: pointer.clientY, moved: 0, touch: pointer.pointerType === "touch" || pointer.pointerType === "pen" };
351
+ };
352
+ const onMove = (event) => {
353
+ if (!drag || !trusted(event))
354
+ return;
355
+ drag.moved = Math.max(drag.moved, Math.hypot(event.clientX - drag.x, event.clientY - drag.y));
356
+ drag.ghost.style.left = `${event.clientX - 12}px`;
357
+ drag.ghost.style.top = `${event.clientY - 12}px`;
358
+ const hit = targetAt(event.clientX, event.clientY);
359
+ const allowed = hit && (options.canTarget ? options.canTarget(drag.grantId, hit.target) : true);
360
+ setCurrent(allowed ? hit : null);
361
+ };
362
+ const onUp = (event) => {
363
+ if (!trusted(event))
364
+ return;
365
+ // A touch or pen press on a mark that did not move is a tap: arm the agent (or let go of it).
366
+ if (drag && drag.touch && drag.moved < TAP_SLOP_PX) {
367
+ const { grantId, mark } = drag;
368
+ end(false);
369
+ armFromMark(mark, grantId);
370
+ return;
371
+ }
372
+ end(true);
373
+ };
374
+ const onCancel = () => end(false);
375
+ const letGo = () => tapPin.cancel();
376
+ // Armed: a tap on a target pins the agent there. The press is watched on the window, so a scroll
377
+ // (the browser cancels the pointer) or a move past the slop is not a tap.
378
+ let press = null;
379
+ const inUi = (node) => !!node?.closest?.("[data-apc-tap-pin], [data-apc-mark], apc-agents");
380
+ const onAnyDown = (event) => {
381
+ const p = event;
382
+ swallowClickUntil = 0; // a new press: only the click of the tap that pins is swallowed
383
+ press = tapPin.armed && trusted(event) ? { x: p.clientX, y: p.clientY, target: p.target } : null;
384
+ };
385
+ const onAnyUp = (event) => {
386
+ const p = event;
387
+ const start = press;
388
+ press = null;
389
+ if (!tapPin.armed || !start || !trusted(event) || inUi(start.target) || inUi(p.target))
390
+ return;
391
+ if (Math.hypot(p.clientX - start.x, p.clientY - start.y) >= TAP_SLOP_PX)
392
+ return;
393
+ const hit = targetAt(p.clientX, p.clientY);
394
+ const result = tapPin.tap(hit);
395
+ // A tap on something that is no target lets go and passes through: a link, tab or button works at once.
396
+ if (result === "no_target")
397
+ return tapPin.cancel();
398
+ // The tap that pinned, or was refused, must not run the target's own action.
399
+ swallowClickUntil = Date.now() + 500;
400
+ swallowOn = hit?.element ?? null;
401
+ if (result === "refused")
402
+ note?.say("This agent cannot point at that. Tap another outlined target, or Cancel.");
403
+ };
404
+ // Only the click that follows a pinning (or refused) tap is swallowed; every other click goes through.
405
+ const onClickCapture = (event) => {
406
+ if (Date.now() > swallowClickUntil || inUi(event.target))
407
+ return;
408
+ if (swallowOn && !swallowOn.contains(event.target))
409
+ return;
410
+ swallowClickUntil = 0; // one-shot: the next click is the person's own
411
+ event.preventDefault();
412
+ event.stopImmediatePropagation();
413
+ };
414
+ // A keyboard (or assistive technology) activation of a mark arms it too: no pointer was involved.
415
+ const onClick = (event) => {
416
+ if (event.detail !== 0 || !trusted(event))
417
+ return;
418
+ const mark = event.target?.closest?.(markSelector);
419
+ const grantId = mark?.getAttribute("data-grant");
420
+ if (mark && grantId)
421
+ armFromKey(mark, grantId);
422
+ };
423
+ // Native drops from another window (opt-in). During dragover only the types are readable, so the
424
+ // target lights up for any APC agent; who it is and whether it may point there is checked on drop.
425
+ let native = null;
426
+ // An app that does not offer Dock (APC 0.5) takes a dock agent's drop only to answer it. Its
427
+ // profiles are known (a list), still loading (`loading`), could not be read (`unread`), or not given
428
+ // at all (undefined: the app takes dock agents as before 0.5).
429
+ const given = options.profiles;
430
+ const isPromise = (p) => !!p && typeof p.then === "function";
431
+ let profiles = Array.isArray(given) ? given : undefined;
432
+ let loading = false;
433
+ let ready = Promise.resolve();
434
+ const load = (p) => {
435
+ loading = true;
436
+ ready = Promise.resolve(p).then((v) => {
437
+ profiles = v ?? null;
438
+ loading = false;
439
+ }, () => {
440
+ profiles = null;
441
+ loading = false;
442
+ });
443
+ };
444
+ if (typeof given === "function")
445
+ load(given());
446
+ else if (isPromise(given))
447
+ load(given);
448
+ const unread = () => profiles === null && !loading;
449
+ const dockless = () => !!profiles && !profiles.includes("dock");
450
+ const isAgentDrag = (event) => !!event.dataTransfer && [...event.dataTransfer.types].includes(APC_AGENT_TYPE) && (!!options.acceptNativeDrops || dockless() || loading || unread());
451
+ const nativeEnd = () => {
452
+ native?.current?.element.classList.remove("apc-target");
453
+ native = null;
454
+ document.body.classList.remove("apc-dragging");
455
+ };
456
+ const onDragEnter = (event) => {
457
+ if (!isAgentDrag(event))
458
+ return;
459
+ if (!native) {
460
+ native = { current: null, depth: 0 };
461
+ document.body.classList.add("apc-dragging");
462
+ }
463
+ native.depth++;
464
+ };
465
+ const onDragOver = (event) => {
466
+ if (!isAgentDrag(event) || !native)
467
+ return;
468
+ // A Direct-only app lights no target for a dragged agent: it cannot point a dock agent at anything.
469
+ const hit = dockless() ? null : targetAt(event.clientX, event.clientY);
470
+ if (hit?.element !== native.current?.element) {
471
+ native.current?.element.classList.remove("apc-target");
472
+ hit?.element.classList.add("apc-target");
473
+ native.current = hit;
474
+ }
475
+ // On a Direct-only app the drop is taken anywhere, so the person always gets the answer; so it is
476
+ // while the app's profiles are still loading, since the drop is answered once they are known.
477
+ if (hit || dockless() || loading || unread()) {
478
+ event.preventDefault();
479
+ if (event.dataTransfer)
480
+ event.dataTransfer.dropEffect = "link";
481
+ }
482
+ };
483
+ const onDragLeave = (event) => {
484
+ if (!native || !isAgentDrag(event))
485
+ return;
486
+ if (--native.depth <= 0)
487
+ nativeEnd();
488
+ };
489
+ const onDrop = (event) => {
490
+ if (!isAgentDrag(event))
491
+ return;
492
+ event.preventDefault();
493
+ const drag = parseAgentDrag(event.dataTransfer.getData(APC_AGENT_TYPE));
494
+ const hit = targetAt(event.clientX, event.clientY);
495
+ nativeEnd();
496
+ const fromDock = !!(drag?.hub && drag.ticket);
497
+ // Profiles that could not be read are asked again on this drop, when the app gave a loader.
498
+ if (fromDock && unread() && typeof given === "function")
499
+ load(given());
500
+ // A dock agent dropped before the app's profiles are known: held until they are, within the
501
+ // loader's own time limit (loadApcProfiles: 3 s; the ticket lives 60 s).
502
+ if (fromDock && loading) {
503
+ void ready.then(() => deliver(drag, hit));
504
+ return;
505
+ }
506
+ deliver(drag, hit);
507
+ };
508
+ const deliver = (drag, hit) => {
509
+ // A dock agent on an app that does not offer Dock, or whose profiles could not be read: answered,
510
+ // never sent to pairing (an app that cannot check its own file pairs nothing on a guess).
511
+ if (drag?.hub && drag.ticket && (dockless() || unread())) {
512
+ const name = options.direct?.name ?? "This app";
513
+ const reason = dockless() ? "direct_only" : "unchecked";
514
+ const answer = { drag, target: hit?.target ?? null, name, connect: options.direct?.connect ?? null, reason, message: reason === "direct_only" ? directOnlyNote(name) : couldNotCheckNote(name) };
515
+ (options.onDockUnsupported ?? showDockUnsupported)(answer);
516
+ return;
517
+ }
518
+ // A drop held while the profiles loaded, on an app that turns out to offer Dock but takes no native drops.
519
+ if (!options.acceptNativeDrops)
520
+ return;
521
+ if (!drag || !hit)
522
+ return;
523
+ const grantId = options.resolveAgent ? options.resolveAgent(drag.agent) : drag.agent;
524
+ if (!grantId) {
525
+ if (drag.hub && drag.ticket)
526
+ options.onUnknownAgent?.({ ...drag, hub: drag.hub, ticket: drag.ticket }, hit.target, hit.element);
527
+ return;
528
+ }
529
+ if (options.canTarget && !options.canTarget(grantId, hit.target))
530
+ return;
531
+ options.onPin(grantId, hit.target, hit.element);
532
+ };
533
+ const onKey = (event) => {
534
+ swallowClickUntil = 0; // a new key press: nothing from before is swallowed
535
+ if (event.key === "Escape") {
536
+ end(false);
537
+ tapPin.cancel();
538
+ return;
539
+ }
540
+ // Armed from the keyboard: Enter or Space on a focused target pins there.
541
+ if ((event.key === "Enter" || event.key === " ") && tapPin.armed && trusted(event) && !inUi(event.target)) {
542
+ const hit = targetOf(event.target);
543
+ if (!hit)
544
+ return;
545
+ event.preventDefault();
546
+ // The click the browser makes from this key (Space: on keyup) must not run the target's own action either.
547
+ swallowClickUntil = Date.now() + 500;
548
+ swallowOn = hit.element;
549
+ if (tapPin.tap(hit) === "refused")
550
+ note?.say("This agent cannot point at that. Tab to another outlined item, or press Escape.");
551
+ }
552
+ };
553
+ root.addEventListener("pointerdown", onDown);
554
+ window.addEventListener("pointermove", onMove);
555
+ window.addEventListener("pointerup", onUp);
556
+ window.addEventListener("pointercancel", onCancel);
557
+ window.addEventListener("popstate", letGo);
558
+ window.addEventListener("hashchange", letGo);
559
+ window.addEventListener("pagehide", letGo);
560
+ window.addEventListener("visibilitychange", letGo);
561
+ window.addEventListener("keydown", onKey);
562
+ window.addEventListener("pointerdown", onAnyDown, true);
563
+ window.addEventListener("pointerup", onAnyUp, true);
564
+ window.addEventListener("click", onClickCapture, true);
565
+ root.addEventListener("click", onClick);
566
+ const takesNative = !!(options.acceptNativeDrops || options.profiles);
567
+ if (takesNative) {
568
+ document.documentElement?.setAttribute(APC_DROPS_ATTR, "");
569
+ window.addEventListener("dragenter", onDragEnter);
570
+ window.addEventListener("dragover", onDragOver);
571
+ window.addEventListener("dragleave", onDragLeave);
572
+ window.addEventListener("drop", onDrop);
573
+ }
574
+ return () => {
575
+ nativeEnd();
576
+ if (takesNative)
577
+ document.documentElement?.removeAttribute(APC_DROPS_ATTR);
578
+ window.removeEventListener("dragenter", onDragEnter);
579
+ window.removeEventListener("dragover", onDragOver);
580
+ window.removeEventListener("dragleave", onDragLeave);
581
+ window.removeEventListener("drop", onDrop);
582
+ end(false);
583
+ root.removeEventListener("pointerdown", onDown);
584
+ window.removeEventListener("pointermove", onMove);
585
+ window.removeEventListener("pointerup", onUp);
586
+ window.removeEventListener("pointercancel", onCancel);
587
+ window.removeEventListener("popstate", letGo);
588
+ window.removeEventListener("hashchange", letGo);
589
+ window.removeEventListener("pagehide", letGo);
590
+ window.removeEventListener("visibilitychange", letGo);
591
+ window.removeEventListener("keydown", onKey);
592
+ window.removeEventListener("pointerdown", onAnyDown, true);
593
+ window.removeEventListener("pointerup", onAnyUp, true);
594
+ window.removeEventListener("click", onClickCapture, true);
595
+ root.removeEventListener("click", onClick);
596
+ tapPin.cancel();
597
+ };
598
+ }
@@ -0,0 +1,18 @@
1
+ import type { PresenceEvent } from "@tineira/apc-protocol";
2
+ export declare const IDLE_AFTER_MS: number;
3
+ export declare const GONE_AFTER_MS: number;
4
+ export declare const FLASH_MS = 4000;
5
+ /**
6
+ * Typeset initials from a self-declared client name: two words give their first
7
+ * letters, one word its first two; a collision gets a digit. No logos: the name
8
+ * is not verified, and a logo would claim it was.
9
+ */
10
+ export declare function initials(name: string, taken?: ReadonlySet<string>): string;
11
+ export type MarkState = "active" | "idle" | "gone";
12
+ export declare function markState(lastActiveAt: string | null, now?: number): MarkState;
13
+ /**
14
+ * Show an agent's initials on each element its call named, for four seconds:
15
+ * data-apc-presence is "read" (dotted) or "write" (solid). Returns how many
16
+ * elements on the page were touched.
17
+ */
18
+ export declare function flashTargets(event: PresenceEvent, agentInitials: string, root?: ParentNode): number;
@@ -0,0 +1,50 @@
1
+ export const IDLE_AFTER_MS = 2 * 60_000;
2
+ export const GONE_AFTER_MS = 5 * 60_000;
3
+ export const FLASH_MS = 4000;
4
+ /**
5
+ * Typeset initials from a self-declared client name: two words give their first
6
+ * letters, one word its first two; a collision gets a digit. No logos: the name
7
+ * is not verified, and a logo would claim it was.
8
+ */
9
+ export function initials(name, taken = new Set()) {
10
+ const words = name.replace(/[^\p{L}\p{N}]+/gu, " ").trim().split(/\s+/).filter(Boolean);
11
+ const first = words[0] ?? "AG";
12
+ const base = (words.length >= 2 ? `${first[0]}${words[1][0]}` : first.slice(0, 2)).toUpperCase().padEnd(2, "X");
13
+ if (!taken.has(base))
14
+ return base;
15
+ for (let n = 2; n < 10; n++) {
16
+ const candidate = `${base[0]}${n}`;
17
+ if (!taken.has(candidate))
18
+ return candidate;
19
+ }
20
+ return base;
21
+ }
22
+ export function markState(lastActiveAt, now = Date.now()) {
23
+ if (!lastActiveAt)
24
+ return "gone";
25
+ const quiet = now - Date.parse(lastActiveAt);
26
+ if (quiet >= GONE_AFTER_MS)
27
+ return "gone";
28
+ return quiet >= IDLE_AFTER_MS ? "idle" : "active";
29
+ }
30
+ /**
31
+ * Show an agent's initials on each element its call named, for four seconds:
32
+ * data-apc-presence is "read" (dotted) or "write" (solid). Returns how many
33
+ * elements on the page were touched.
34
+ */
35
+ export function flashTargets(event, agentInitials, root = document) {
36
+ let touched = 0;
37
+ for (const target of event.targets) {
38
+ const selector = `[data-focus="${CSS.escape(target.kind)}:${CSS.escape(target.id)}"]`;
39
+ for (const element of root.querySelectorAll(selector)) {
40
+ element.setAttribute("data-apc-presence", event.kind);
41
+ element.setAttribute("data-apc-agent", agentInitials);
42
+ touched++;
43
+ window.setTimeout(() => {
44
+ element.removeAttribute("data-apc-presence");
45
+ element.removeAttribute("data-apc-agent");
46
+ }, FLASH_MS);
47
+ }
48
+ }
49
+ return touched;
50
+ }