@oxvo/ai-live-assist 7.3.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/LICENSE +21 -0
- package/README.md +17 -0
- package/cjs/AiLiveAssist.d.ts +108 -0
- package/cjs/AiLiveAssist.js +1774 -0
- package/cjs/client.d.ts +53 -0
- package/cjs/client.js +193 -0
- package/cjs/context.d.ts +58 -0
- package/cjs/context.js +979 -0
- package/cjs/control.d.ts +31 -0
- package/cjs/control.js +190 -0
- package/cjs/experienceState.d.ts +18 -0
- package/cjs/experienceState.js +82 -0
- package/cjs/index.d.ts +13 -0
- package/cjs/index.js +32 -0
- package/cjs/media.d.ts +34 -0
- package/cjs/media.js +207 -0
- package/cjs/messages.d.ts +2 -0
- package/cjs/messages.js +95 -0
- package/cjs/package.json +1 -0
- package/cjs/placement.d.ts +52 -0
- package/cjs/placement.js +293 -0
- package/cjs/presentation.d.ts +41 -0
- package/cjs/presentation.js +483 -0
- package/cjs/recordingPolicy.d.ts +2 -0
- package/cjs/recordingPolicy.js +12 -0
- package/cjs/safeSvg.d.ts +1 -0
- package/cjs/safeSvg.js +157 -0
- package/cjs/tabLock.d.ts +31 -0
- package/cjs/tabLock.js +260 -0
- package/cjs/types.d.ts +299 -0
- package/cjs/types.js +2 -0
- package/cjs/ui.d.ts +184 -0
- package/cjs/ui.js +2353 -0
- package/cjs/version.d.ts +1 -0
- package/cjs/version.js +4 -0
- package/cjs/visualContext.d.ts +21 -0
- package/cjs/visualContext.js +72 -0
- package/cjs/voicePresenceUi.d.ts +148 -0
- package/cjs/voicePresenceUi.js +2182 -0
- package/lib/AiLiveAssist.d.ts +108 -0
- package/lib/AiLiveAssist.js +1769 -0
- package/lib/client.d.ts +53 -0
- package/lib/client.js +187 -0
- package/lib/context.d.ts +58 -0
- package/lib/context.js +975 -0
- package/lib/control.d.ts +31 -0
- package/lib/control.js +186 -0
- package/lib/experienceState.d.ts +18 -0
- package/lib/experienceState.js +78 -0
- package/lib/index.d.ts +13 -0
- package/lib/index.js +26 -0
- package/lib/media.d.ts +34 -0
- package/lib/media.js +203 -0
- package/lib/messages.d.ts +2 -0
- package/lib/messages.js +92 -0
- package/lib/placement.d.ts +52 -0
- package/lib/placement.js +286 -0
- package/lib/presentation.d.ts +41 -0
- package/lib/presentation.js +478 -0
- package/lib/recordingPolicy.d.ts +2 -0
- package/lib/recordingPolicy.js +8 -0
- package/lib/safeSvg.d.ts +1 -0
- package/lib/safeSvg.js +153 -0
- package/lib/tabLock.d.ts +31 -0
- package/lib/tabLock.js +256 -0
- package/lib/types.d.ts +299 -0
- package/lib/types.js +1 -0
- package/lib/ui.d.ts +184 -0
- package/lib/ui.js +2349 -0
- package/lib/version.d.ts +1 -0
- package/lib/version.js +1 -0
- package/lib/visualContext.d.ts +21 -0
- package/lib/visualContext.js +68 -0
- package/lib/voicePresenceUi.d.ts +148 -0
- package/lib/voicePresenceUi.js +2178 -0
- package/package.json +58 -0
|
@@ -0,0 +1,478 @@
|
|
|
1
|
+
const SVG_NAMESPACE = 'http://www.w3.org/2000/svg';
|
|
2
|
+
const OPAQUE_ID = /^[A-Za-z0-9_.:-]{8,128}$/;
|
|
3
|
+
const KINDS = new Set([
|
|
4
|
+
'pointer',
|
|
5
|
+
'spotlight',
|
|
6
|
+
'underline',
|
|
7
|
+
'circle',
|
|
8
|
+
'arrow',
|
|
9
|
+
'connector',
|
|
10
|
+
'sequence_marker',
|
|
11
|
+
'callout',
|
|
12
|
+
'clear',
|
|
13
|
+
]);
|
|
14
|
+
const INTENT_KEYS = new Set([
|
|
15
|
+
'presentationId',
|
|
16
|
+
'groupId',
|
|
17
|
+
'kind',
|
|
18
|
+
'targetId',
|
|
19
|
+
'secondaryTargetId',
|
|
20
|
+
'domRevision',
|
|
21
|
+
'message',
|
|
22
|
+
'emphasis',
|
|
23
|
+
'ttlMs',
|
|
24
|
+
'replaceGroup',
|
|
25
|
+
]);
|
|
26
|
+
export const parsePresentationIntent = (input) => {
|
|
27
|
+
if (Object.keys(input).some((key) => !INTENT_KEYS.has(key)))
|
|
28
|
+
return null;
|
|
29
|
+
const kind = input.kind;
|
|
30
|
+
const emphasis = input.emphasis;
|
|
31
|
+
const presentationId = input.presentationId;
|
|
32
|
+
const groupId = input.groupId;
|
|
33
|
+
const targetId = input.targetId;
|
|
34
|
+
const secondaryTargetId = input.secondaryTargetId;
|
|
35
|
+
const message = input.message;
|
|
36
|
+
const domRevision = input.domRevision;
|
|
37
|
+
const ttlMs = input.ttlMs;
|
|
38
|
+
if (typeof presentationId !== 'string' ||
|
|
39
|
+
!OPAQUE_ID.test(presentationId) ||
|
|
40
|
+
!KINDS.has(kind) ||
|
|
41
|
+
(groupId !== null &&
|
|
42
|
+
(typeof groupId !== 'string' || !OPAQUE_ID.test(groupId))) ||
|
|
43
|
+
(targetId !== null &&
|
|
44
|
+
(typeof targetId !== 'string' || !OPAQUE_ID.test(targetId))) ||
|
|
45
|
+
(secondaryTargetId !== null &&
|
|
46
|
+
(typeof secondaryTargetId !== 'string' ||
|
|
47
|
+
!OPAQUE_ID.test(secondaryTargetId))) ||
|
|
48
|
+
(message !== null &&
|
|
49
|
+
(typeof message !== 'string' ||
|
|
50
|
+
message.length > 240 ||
|
|
51
|
+
/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/u.test(message))) ||
|
|
52
|
+
!Number.isSafeInteger(domRevision) ||
|
|
53
|
+
domRevision < 0 ||
|
|
54
|
+
!Number.isSafeInteger(ttlMs) ||
|
|
55
|
+
ttlMs < 750 ||
|
|
56
|
+
ttlMs > 15000 ||
|
|
57
|
+
!['subtle', 'normal', 'strong'].includes(emphasis) ||
|
|
58
|
+
typeof input.replaceGroup !== 'boolean') {
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
if ((kind === 'clear' && (targetId !== null || secondaryTargetId !== null)) ||
|
|
62
|
+
(kind !== 'clear' && targetId === null) ||
|
|
63
|
+
(kind === 'connector' && secondaryTargetId === null) ||
|
|
64
|
+
(kind !== 'connector' && secondaryTargetId !== null) ||
|
|
65
|
+
(kind === 'callout' && (typeof message !== 'string' || !message.trim())) ||
|
|
66
|
+
(kind !== 'callout' && message !== null)) {
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
return {
|
|
70
|
+
presentationId,
|
|
71
|
+
groupId: groupId,
|
|
72
|
+
kind,
|
|
73
|
+
targetId: targetId,
|
|
74
|
+
secondaryTargetId: secondaryTargetId,
|
|
75
|
+
domRevision: domRevision,
|
|
76
|
+
message: message,
|
|
77
|
+
emphasis,
|
|
78
|
+
ttlMs: ttlMs,
|
|
79
|
+
replaceGroup: input.replaceGroup,
|
|
80
|
+
};
|
|
81
|
+
};
|
|
82
|
+
const style = `
|
|
83
|
+
:host {
|
|
84
|
+
all: initial;
|
|
85
|
+
position: fixed;
|
|
86
|
+
inset: 0;
|
|
87
|
+
z-index: 2147483645;
|
|
88
|
+
pointer-events: none;
|
|
89
|
+
contain: strict;
|
|
90
|
+
--presentation-accent: #2563eb;
|
|
91
|
+
}
|
|
92
|
+
svg { width: 100%; height: 100%; overflow: visible; pointer-events: none; }
|
|
93
|
+
.cue { opacity: 0; animation: cue-in 180ms ease-out forwards; }
|
|
94
|
+
.cue.suspended { opacity: .16; transition: opacity 120ms ease; }
|
|
95
|
+
.stroke { fill: none; stroke: var(--presentation-accent); stroke-width: 2.75; filter: drop-shadow(0 2px 3px rgba(15, 23, 42, .16)); vector-effect: non-scaling-stroke; }
|
|
96
|
+
.spatial-trail { fill: none; stroke: color-mix(in srgb, var(--presentation-accent) 78%, white); stroke-width: 2.25; stroke-linecap: round; stroke-dasharray: 5 5; opacity: .86; filter: drop-shadow(0 1px 2px rgba(15, 23, 42, .12)); vector-effect: non-scaling-stroke; }
|
|
97
|
+
.glass { fill: color-mix(in srgb, var(--presentation-accent) 16%, transparent); stroke: color-mix(in srgb, var(--presentation-accent) 88%, white); stroke-width: 2.25; filter: drop-shadow(0 3px 7px rgba(15, 23, 42, .14)); vector-effect: non-scaling-stroke; }
|
|
98
|
+
.pointer-core { fill: white; stroke: var(--presentation-accent); stroke-width: 2.25; filter: drop-shadow(0 5px 10px rgba(15, 23, 42, .25)); }
|
|
99
|
+
.pointer-mark { fill: var(--presentation-accent); font: 700 8px system-ui, sans-serif; text-anchor: middle; dominant-baseline: central; }
|
|
100
|
+
.callout-box { fill: rgba(255, 255, 255, .98); stroke: color-mix(in srgb, var(--presentation-accent) 72%, #d1d5db); stroke-width: 1.25; filter: drop-shadow(0 6px 14px rgba(15, 23, 42, .20)); }
|
|
101
|
+
.callout-text { fill: #17212b; font: 500 12px/1.35 system-ui, sans-serif; }
|
|
102
|
+
.sequence-text { fill: white; font: 700 11px system-ui, sans-serif; text-anchor: middle; dominant-baseline: central; }
|
|
103
|
+
@keyframes cue-in { to { opacity: 1; } }
|
|
104
|
+
@media (prefers-reduced-motion: reduce) {
|
|
105
|
+
.cue { animation: none; opacity: 1; }
|
|
106
|
+
}
|
|
107
|
+
@media (forced-colors: active) {
|
|
108
|
+
.stroke, .glass, .pointer-core { stroke: Highlight; }
|
|
109
|
+
.glass { fill: Canvas; }
|
|
110
|
+
}
|
|
111
|
+
`;
|
|
112
|
+
const svgElement = (name) => document.createElementNS(SVG_NAMESPACE, name);
|
|
113
|
+
const setAttributes = (element, attributes) => {
|
|
114
|
+
for (const [name, value] of Object.entries(attributes)) {
|
|
115
|
+
element.setAttribute(name, String(value));
|
|
116
|
+
}
|
|
117
|
+
};
|
|
118
|
+
const overlapArea = (first, second) => Math.max(0, Math.min(first.right, second.right) - Math.max(first.left, second.left)) *
|
|
119
|
+
Math.max(0, Math.min(first.bottom, second.bottom) - Math.max(first.top, second.top));
|
|
120
|
+
export class PresentationPlane {
|
|
121
|
+
constructor(accent, maxActiveCues, reducedMotion, callbacks) {
|
|
122
|
+
this.maxActiveCues = maxActiveCues;
|
|
123
|
+
this.reducedMotion = reducedMotion;
|
|
124
|
+
this.callbacks = callbacks;
|
|
125
|
+
this.cues = new Map();
|
|
126
|
+
this.raf = null;
|
|
127
|
+
this.suspendedUntil = 0;
|
|
128
|
+
this.resumeTimer = null;
|
|
129
|
+
this.destroyed = false;
|
|
130
|
+
this.scheduleFrame = () => {
|
|
131
|
+
if (this.raf !== null || this.cues.size === 0 || document.hidden)
|
|
132
|
+
return;
|
|
133
|
+
this.raf = requestAnimationFrame(() => {
|
|
134
|
+
this.raf = null;
|
|
135
|
+
if (Date.now() < this.suspendedUntil)
|
|
136
|
+
return;
|
|
137
|
+
for (const cue of this.cues.values())
|
|
138
|
+
this.draw(cue);
|
|
139
|
+
});
|
|
140
|
+
};
|
|
141
|
+
this.onVisitorActivity = (event) => {
|
|
142
|
+
if (event.composedPath().some((target) => target instanceof HTMLElement &&
|
|
143
|
+
target.dataset.oxvoAiLiveAssistRoot === 'true')) {
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
this.suspendedUntil = Date.now() + 750;
|
|
147
|
+
for (const cue of this.cues.values())
|
|
148
|
+
cue.group.classList.add('suspended');
|
|
149
|
+
if (this.resumeTimer)
|
|
150
|
+
clearTimeout(this.resumeTimer);
|
|
151
|
+
this.resumeTimer = setTimeout(() => {
|
|
152
|
+
this.resumeTimer = null;
|
|
153
|
+
for (const cue of this.cues.values())
|
|
154
|
+
cue.group.classList.remove('suspended');
|
|
155
|
+
this.scheduleFrame();
|
|
156
|
+
}, 750);
|
|
157
|
+
};
|
|
158
|
+
this.onVisibilityChange = () => {
|
|
159
|
+
if (document.hidden) {
|
|
160
|
+
if (this.raf !== null)
|
|
161
|
+
cancelAnimationFrame(this.raf);
|
|
162
|
+
this.raf = null;
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
this.scheduleFrame();
|
|
166
|
+
};
|
|
167
|
+
this.host = document.createElement('div');
|
|
168
|
+
this.host.dataset.oxvoAiLiveAssistRoot = 'true';
|
|
169
|
+
this.host.setAttribute('aria-hidden', 'true');
|
|
170
|
+
const shadow = this.host.attachShadow({ mode: 'closed' });
|
|
171
|
+
const sheet = document.createElement('style');
|
|
172
|
+
sheet.textContent = style;
|
|
173
|
+
this.svg = svgElement('svg');
|
|
174
|
+
this.svg.setAttribute('aria-hidden', 'true');
|
|
175
|
+
shadow.append(sheet, this.svg);
|
|
176
|
+
this.host.style.setProperty('--presentation-accent', accent);
|
|
177
|
+
document.documentElement.append(this.host);
|
|
178
|
+
document.addEventListener('pointerdown', this.onVisitorActivity, true);
|
|
179
|
+
document.addEventListener('keydown', this.onVisitorActivity, true);
|
|
180
|
+
document.addEventListener('input', this.onVisitorActivity, true);
|
|
181
|
+
window.addEventListener('wheel', this.onVisitorActivity, { passive: true });
|
|
182
|
+
window.addEventListener('touchstart', this.onVisitorActivity, {
|
|
183
|
+
passive: true,
|
|
184
|
+
capture: true,
|
|
185
|
+
});
|
|
186
|
+
window.addEventListener('resize', this.scheduleFrame, { passive: true });
|
|
187
|
+
document.addEventListener('scroll', this.scheduleFrame, {
|
|
188
|
+
capture: true,
|
|
189
|
+
passive: true,
|
|
190
|
+
});
|
|
191
|
+
window.visualViewport?.addEventListener('resize', this.scheduleFrame, {
|
|
192
|
+
passive: true,
|
|
193
|
+
});
|
|
194
|
+
window.visualViewport?.addEventListener('scroll', this.scheduleFrame, {
|
|
195
|
+
passive: true,
|
|
196
|
+
});
|
|
197
|
+
document.addEventListener('visibilitychange', this.onVisibilityChange);
|
|
198
|
+
}
|
|
199
|
+
render(intent) {
|
|
200
|
+
if (intent.kind === 'clear') {
|
|
201
|
+
this.clear(intent.groupId ? 'group' : 'all', intent.groupId);
|
|
202
|
+
return this.result(intent, 'cleared', 'OK', null);
|
|
203
|
+
}
|
|
204
|
+
if (!OPAQUE_ID.test(intent.presentationId) ||
|
|
205
|
+
!intent.targetId ||
|
|
206
|
+
!OPAQUE_ID.test(intent.targetId) ||
|
|
207
|
+
intent.ttlMs < 750 ||
|
|
208
|
+
intent.ttlMs > 15000 ||
|
|
209
|
+
(intent.message?.length ?? 0) > 240) {
|
|
210
|
+
return this.result(intent, 'blocked', 'PRESENTATION_TARGET_BLOCKED', null);
|
|
211
|
+
}
|
|
212
|
+
if (this.callbacks.kindEnabled && !this.callbacks.kindEnabled(intent.kind)) {
|
|
213
|
+
return this.result(intent, 'unsupported', 'PRESENTATION_DISABLED', null);
|
|
214
|
+
}
|
|
215
|
+
if (Date.now() < this.suspendedUntil) {
|
|
216
|
+
return this.result(intent, 'blocked', 'VISITOR_ACTIVITY', null);
|
|
217
|
+
}
|
|
218
|
+
const target = this.callbacks.resolveTarget(intent.targetId, intent.domRevision);
|
|
219
|
+
const secondaryTarget = intent.secondaryTargetId
|
|
220
|
+
? this.callbacks.resolveTarget(intent.secondaryTargetId, intent.domRevision)
|
|
221
|
+
: null;
|
|
222
|
+
if (!target || (intent.secondaryTargetId && !secondaryTarget)) {
|
|
223
|
+
return this.result(intent, 'stale', 'PRESENTATION_TARGET_STALE', target);
|
|
224
|
+
}
|
|
225
|
+
if (!target.safe || (secondaryTarget && !secondaryTarget.safe)) {
|
|
226
|
+
return this.result(intent, 'blocked', 'PRESENTATION_TARGET_BLOCKED', target);
|
|
227
|
+
}
|
|
228
|
+
if (intent.replaceGroup && intent.groupId)
|
|
229
|
+
this.clear('group', intent.groupId);
|
|
230
|
+
if (this.cues.has(intent.presentationId)) {
|
|
231
|
+
return this.result(intent, 'shown', 'OK', target);
|
|
232
|
+
}
|
|
233
|
+
while (this.cues.size >= Math.max(1, Math.min(5, this.maxActiveCues))) {
|
|
234
|
+
const oldest = this.cues.keys().next().value;
|
|
235
|
+
if (typeof oldest !== 'string')
|
|
236
|
+
break;
|
|
237
|
+
this.remove(oldest);
|
|
238
|
+
}
|
|
239
|
+
const group = svgElement('g');
|
|
240
|
+
group.classList.add('cue');
|
|
241
|
+
group.dataset.presentationId = intent.presentationId;
|
|
242
|
+
this.svg.append(group);
|
|
243
|
+
const timer = setTimeout(() => this.remove(intent.presentationId), intent.ttlMs);
|
|
244
|
+
const cue = { intent, group, target, secondaryTarget, timer };
|
|
245
|
+
this.cues.set(intent.presentationId, cue);
|
|
246
|
+
this.draw(cue);
|
|
247
|
+
this.emitVisibility();
|
|
248
|
+
this.scheduleFrame();
|
|
249
|
+
return this.result(intent, 'shown', this.reducedMotion ? 'REDUCED_MOTION_FALLBACK' : 'OK', target);
|
|
250
|
+
}
|
|
251
|
+
clear(reason, groupId = null) {
|
|
252
|
+
for (const [presentationId, cue] of this.cues) {
|
|
253
|
+
if (reason === 'group' && cue.intent.groupId !== groupId)
|
|
254
|
+
continue;
|
|
255
|
+
this.remove(presentationId);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
destroy() {
|
|
259
|
+
if (this.destroyed)
|
|
260
|
+
return;
|
|
261
|
+
this.destroyed = true;
|
|
262
|
+
this.clear('all');
|
|
263
|
+
if (this.raf !== null)
|
|
264
|
+
cancelAnimationFrame(this.raf);
|
|
265
|
+
if (this.resumeTimer)
|
|
266
|
+
clearTimeout(this.resumeTimer);
|
|
267
|
+
document.removeEventListener('pointerdown', this.onVisitorActivity, true);
|
|
268
|
+
document.removeEventListener('keydown', this.onVisitorActivity, true);
|
|
269
|
+
document.removeEventListener('input', this.onVisitorActivity, true);
|
|
270
|
+
window.removeEventListener('wheel', this.onVisitorActivity);
|
|
271
|
+
window.removeEventListener('touchstart', this.onVisitorActivity, true);
|
|
272
|
+
window.removeEventListener('resize', this.scheduleFrame);
|
|
273
|
+
document.removeEventListener('scroll', this.scheduleFrame, true);
|
|
274
|
+
window.visualViewport?.removeEventListener('resize', this.scheduleFrame);
|
|
275
|
+
window.visualViewport?.removeEventListener('scroll', this.scheduleFrame);
|
|
276
|
+
document.removeEventListener('visibilitychange', this.onVisibilityChange);
|
|
277
|
+
this.host.remove();
|
|
278
|
+
}
|
|
279
|
+
draw(cue) {
|
|
280
|
+
const current = this.callbacks.resolveTarget(cue.intent.targetId ?? '', cue.intent.domRevision);
|
|
281
|
+
if (!current?.safe) {
|
|
282
|
+
this.remove(cue.intent.presentationId);
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
cue.target = current;
|
|
286
|
+
if (cue.intent.secondaryTargetId) {
|
|
287
|
+
const secondary = this.callbacks.resolveTarget(cue.intent.secondaryTargetId, cue.intent.domRevision);
|
|
288
|
+
if (!secondary?.safe) {
|
|
289
|
+
this.remove(cue.intent.presentationId);
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
cue.secondaryTarget = secondary;
|
|
293
|
+
}
|
|
294
|
+
cue.group.replaceChildren();
|
|
295
|
+
const rect = cue.target.rect;
|
|
296
|
+
const padding = cue.intent.emphasis === 'strong' ? 10 : 7;
|
|
297
|
+
const centerX = rect.left + rect.width / 2;
|
|
298
|
+
const centerY = rect.top + rect.height / 2;
|
|
299
|
+
if (this.callbacks.spatialTrail &&
|
|
300
|
+
!this.reducedMotion &&
|
|
301
|
+
!['connector', 'sequence_marker'].includes(cue.intent.kind)) {
|
|
302
|
+
this.drawSpatialTrail(cue.group, centerX, centerY);
|
|
303
|
+
}
|
|
304
|
+
if (cue.intent.kind === 'pointer') {
|
|
305
|
+
const core = svgElement('circle');
|
|
306
|
+
core.classList.add('pointer-core');
|
|
307
|
+
setAttributes(core, { cx: rect.right + 9, cy: rect.top - 7, r: 11 });
|
|
308
|
+
const mark = svgElement('text');
|
|
309
|
+
mark.classList.add('pointer-mark');
|
|
310
|
+
setAttributes(mark, { x: rect.right + 9, y: rect.top - 7 });
|
|
311
|
+
mark.textContent = 'AI';
|
|
312
|
+
cue.group.append(core, mark);
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
if (cue.intent.kind === 'spotlight' || cue.intent.kind === 'circle') {
|
|
316
|
+
const shape = svgElement('rect');
|
|
317
|
+
shape.classList.add(cue.intent.kind === 'spotlight' ? 'glass' : 'stroke');
|
|
318
|
+
setAttributes(shape, {
|
|
319
|
+
x: rect.left - padding,
|
|
320
|
+
y: rect.top - padding,
|
|
321
|
+
width: rect.width + padding * 2,
|
|
322
|
+
height: rect.height + padding * 2,
|
|
323
|
+
rx: Math.min(12, Math.max(4, rect.height / 4)),
|
|
324
|
+
});
|
|
325
|
+
cue.group.append(shape);
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
if (cue.intent.kind === 'underline') {
|
|
329
|
+
const line = svgElement('path');
|
|
330
|
+
line.classList.add('stroke');
|
|
331
|
+
setAttributes(line, {
|
|
332
|
+
d: `M ${rect.left} ${rect.bottom + 5} Q ${centerX} ${rect.bottom + 8} ${rect.right} ${rect.bottom + 4}`,
|
|
333
|
+
});
|
|
334
|
+
cue.group.append(line);
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
if (cue.intent.kind === 'arrow' || cue.intent.kind === 'connector') {
|
|
338
|
+
const secondary = cue.secondaryTarget?.rect;
|
|
339
|
+
const destinationX = secondary ? secondary.left + secondary.width / 2 : centerX;
|
|
340
|
+
const destinationY = secondary ? secondary.top + secondary.height / 2 : centerY;
|
|
341
|
+
const originX = secondary ? centerX : Math.max(16, rect.left - 42);
|
|
342
|
+
const originY = secondary ? centerY : Math.max(16, rect.top - 28);
|
|
343
|
+
const line = svgElement('path');
|
|
344
|
+
line.classList.add('stroke');
|
|
345
|
+
setAttributes(line, {
|
|
346
|
+
d: `M ${originX} ${originY} Q ${(originX + destinationX) / 2} ${Math.min(originY, destinationY) - 18} ${destinationX} ${destinationY}`,
|
|
347
|
+
});
|
|
348
|
+
cue.group.append(line);
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
if (cue.intent.kind === 'sequence_marker') {
|
|
352
|
+
const marker = svgElement('circle');
|
|
353
|
+
marker.classList.add('glass');
|
|
354
|
+
marker.style.fill = 'var(--presentation-accent)';
|
|
355
|
+
setAttributes(marker, { cx: rect.left - 9, cy: centerY, r: 12 });
|
|
356
|
+
const label = svgElement('text');
|
|
357
|
+
label.classList.add('sequence-text');
|
|
358
|
+
setAttributes(label, { x: rect.left - 9, y: centerY });
|
|
359
|
+
const sequence = Array.from(this.cues.values())
|
|
360
|
+
.filter((candidate) => candidate.intent.kind === 'sequence_marker' &&
|
|
361
|
+
candidate.intent.groupId === cue.intent.groupId)
|
|
362
|
+
.findIndex((candidate) => candidate.intent.presentationId === cue.intent.presentationId);
|
|
363
|
+
label.textContent = String(Math.max(1, sequence + 1));
|
|
364
|
+
cue.group.append(marker, label);
|
|
365
|
+
return;
|
|
366
|
+
}
|
|
367
|
+
if (cue.intent.kind === 'callout') {
|
|
368
|
+
const text = (cue.intent.message ?? '').replace(/\s+/g, ' ').trim().slice(0, 120);
|
|
369
|
+
const availableWidth = Math.max(72, (window.visualViewport?.width ?? window.innerWidth) - 16);
|
|
370
|
+
const width = Math.min(260, availableWidth, Math.max(120, text.length * 6.2 + 20));
|
|
371
|
+
const placement = this.calloutPlacement(rect, width, 34);
|
|
372
|
+
const box = svgElement('rect');
|
|
373
|
+
box.classList.add('callout-box');
|
|
374
|
+
setAttributes(box, {
|
|
375
|
+
x: placement.left,
|
|
376
|
+
y: placement.top,
|
|
377
|
+
width,
|
|
378
|
+
height: 34,
|
|
379
|
+
rx: 7,
|
|
380
|
+
});
|
|
381
|
+
const label = svgElement('text');
|
|
382
|
+
label.classList.add('callout-text');
|
|
383
|
+
setAttributes(label, { x: placement.left + 10, y: placement.top + 21 });
|
|
384
|
+
const maximumCharacters = Math.max(8, Math.floor((width - 20) / 6.2));
|
|
385
|
+
label.textContent =
|
|
386
|
+
text.length > maximumCharacters
|
|
387
|
+
? `${text.slice(0, maximumCharacters - 3).trimEnd()}...`
|
|
388
|
+
: text;
|
|
389
|
+
cue.group.append(box, label);
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
drawSpatialTrail(group, targetX, targetY) {
|
|
393
|
+
const launcher = this.callbacks.launcherRect?.();
|
|
394
|
+
if (!launcher || launcher.width < 1 || launcher.height < 1)
|
|
395
|
+
return;
|
|
396
|
+
const launcherX = launcher.left + launcher.width / 2;
|
|
397
|
+
const launcherY = launcher.top + launcher.height / 2;
|
|
398
|
+
const deltaX = launcherX - targetX;
|
|
399
|
+
const deltaY = launcherY - targetY;
|
|
400
|
+
const distance = Math.hypot(deltaX, deltaY);
|
|
401
|
+
if (distance < 24)
|
|
402
|
+
return;
|
|
403
|
+
const trailLength = Math.min(112, distance * 0.34);
|
|
404
|
+
const originX = targetX + (deltaX / distance) * trailLength;
|
|
405
|
+
const originY = targetY + (deltaY / distance) * trailLength;
|
|
406
|
+
const normalX = (-deltaY / distance) * Math.min(14, trailLength * 0.12);
|
|
407
|
+
const normalY = (deltaX / distance) * Math.min(14, trailLength * 0.12);
|
|
408
|
+
const trail = svgElement('path');
|
|
409
|
+
trail.classList.add('spatial-trail');
|
|
410
|
+
setAttributes(trail, {
|
|
411
|
+
d: `M ${originX} ${originY} Q ${(originX + targetX) / 2 + normalX} ${(originY + targetY) / 2 + normalY} ${targetX} ${targetY}`,
|
|
412
|
+
});
|
|
413
|
+
group.append(trail);
|
|
414
|
+
}
|
|
415
|
+
calloutPlacement(target, width, height) {
|
|
416
|
+
const visual = window.visualViewport;
|
|
417
|
+
const viewport = {
|
|
418
|
+
left: visual?.offsetLeft ?? 0,
|
|
419
|
+
top: visual?.offsetTop ?? 0,
|
|
420
|
+
right: (visual?.offsetLeft ?? 0) + (visual?.width ?? window.innerWidth),
|
|
421
|
+
bottom: (visual?.offsetTop ?? 0) + (visual?.height ?? window.innerHeight),
|
|
422
|
+
};
|
|
423
|
+
const centerX = target.left + target.width / 2;
|
|
424
|
+
const centerY = target.top + target.height / 2;
|
|
425
|
+
const rawCandidates = [
|
|
426
|
+
{ left: centerX - width / 2, top: target.top - height - 12 },
|
|
427
|
+
{ left: centerX - width / 2, top: target.bottom + 12 },
|
|
428
|
+
{ left: target.right + 12, top: centerY - height / 2 },
|
|
429
|
+
{ left: target.left - width - 12, top: centerY - height / 2 },
|
|
430
|
+
];
|
|
431
|
+
const obstacles = [target, ...(this.callbacks.obstacleRects?.() ?? [])];
|
|
432
|
+
const candidates = rawCandidates.map((candidate, index) => {
|
|
433
|
+
const left = Math.max(viewport.left + 8, Math.min(viewport.right - width - 8, candidate.left));
|
|
434
|
+
const top = Math.max(viewport.top + 8, Math.min(viewport.bottom - height - 8, candidate.top));
|
|
435
|
+
const value = {
|
|
436
|
+
x: left,
|
|
437
|
+
y: top,
|
|
438
|
+
left,
|
|
439
|
+
top,
|
|
440
|
+
right: left + width,
|
|
441
|
+
bottom: top + height,
|
|
442
|
+
width,
|
|
443
|
+
height,
|
|
444
|
+
toJSON: () => ({}),
|
|
445
|
+
};
|
|
446
|
+
const overlap = obstacles.reduce((total, obstacle) => total + overlapArea(value, obstacle), 0);
|
|
447
|
+
return { value, score: overlap * 1000 + index };
|
|
448
|
+
});
|
|
449
|
+
return candidates.sort((first, second) => first.score - second.score)[0].value;
|
|
450
|
+
}
|
|
451
|
+
result(intent, status, reasonCode, target) {
|
|
452
|
+
return {
|
|
453
|
+
presentationId: intent.presentationId,
|
|
454
|
+
status,
|
|
455
|
+
reasonCode,
|
|
456
|
+
pageId: target?.pageId ?? this.callbacks.currentPage().pageId,
|
|
457
|
+
domRevision: intent.domRevision,
|
|
458
|
+
shownAt: status === 'shown' ? new Date().toISOString() : null,
|
|
459
|
+
};
|
|
460
|
+
}
|
|
461
|
+
remove(presentationId) {
|
|
462
|
+
const cue = this.cues.get(presentationId);
|
|
463
|
+
if (!cue)
|
|
464
|
+
return;
|
|
465
|
+
clearTimeout(cue.timer);
|
|
466
|
+
cue.group.remove();
|
|
467
|
+
this.cues.delete(presentationId);
|
|
468
|
+
if (this.cues.size === 0 && this.raf !== null) {
|
|
469
|
+
cancelAnimationFrame(this.raf);
|
|
470
|
+
this.raf = null;
|
|
471
|
+
}
|
|
472
|
+
this.emitVisibility();
|
|
473
|
+
}
|
|
474
|
+
emitVisibility() {
|
|
475
|
+
const first = this.cues.values().next().value;
|
|
476
|
+
this.callbacks.onVisibility(first?.target.pageId ?? 'page_unknown', first?.intent.domRevision ?? 0, [...this.cues.keys()]);
|
|
477
|
+
}
|
|
478
|
+
}
|
package/lib/safeSvg.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const createSafeSvgIcon: (value: string | null | undefined, targetDocument?: Document) => SVGSVGElement | null;
|
package/lib/safeSvg.js
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
const SVG_NAMESPACE = "http://www.w3.org/2000/svg";
|
|
2
|
+
const MAX_SVG_BYTES = 16384;
|
|
3
|
+
const MAX_SVG_ELEMENTS = 64;
|
|
4
|
+
const SAFE_TAGS = new Set([
|
|
5
|
+
"svg",
|
|
6
|
+
"g",
|
|
7
|
+
"path",
|
|
8
|
+
"circle",
|
|
9
|
+
"ellipse",
|
|
10
|
+
"line",
|
|
11
|
+
"polyline",
|
|
12
|
+
"polygon",
|
|
13
|
+
"rect",
|
|
14
|
+
]);
|
|
15
|
+
const SAFE_ATTRIBUTES = new Set([
|
|
16
|
+
"viewBox",
|
|
17
|
+
"d",
|
|
18
|
+
"x",
|
|
19
|
+
"y",
|
|
20
|
+
"x1",
|
|
21
|
+
"y1",
|
|
22
|
+
"x2",
|
|
23
|
+
"y2",
|
|
24
|
+
"cx",
|
|
25
|
+
"cy",
|
|
26
|
+
"r",
|
|
27
|
+
"rx",
|
|
28
|
+
"ry",
|
|
29
|
+
"width",
|
|
30
|
+
"height",
|
|
31
|
+
"points",
|
|
32
|
+
"transform",
|
|
33
|
+
"fill",
|
|
34
|
+
"stroke",
|
|
35
|
+
"stroke-width",
|
|
36
|
+
"stroke-linecap",
|
|
37
|
+
"stroke-linejoin",
|
|
38
|
+
"fill-rule",
|
|
39
|
+
"clip-rule",
|
|
40
|
+
"opacity",
|
|
41
|
+
]);
|
|
42
|
+
const IGNORED_ATTRIBUTES = new Set(["class", "aria-hidden", "focusable"]);
|
|
43
|
+
const PATH_VALUE = /^[\s,0-9+\-.eEaAcChHlLmMqQsStTvVzZ]+$/;
|
|
44
|
+
const NUMBER_LIST = /^[\s,0-9+\-.eE]+$/;
|
|
45
|
+
const TRANSFORM_VALUE = /^(?:(?:matrix|translate|scale|rotate|skewX|skewY)\([\s,0-9+\-.eE]+\)\s*)+$/;
|
|
46
|
+
const COLOR_VALUE = /^(?:none|currentColor|transparent|#(?:[0-9a-f]{3}|[0-9a-f]{4}|[0-9a-f]{6}|[0-9a-f]{8}))$/i;
|
|
47
|
+
const utf8ByteLength = (value) => {
|
|
48
|
+
let length = 0;
|
|
49
|
+
for (const character of value) {
|
|
50
|
+
const codePoint = character.codePointAt(0) ?? 0;
|
|
51
|
+
length +=
|
|
52
|
+
codePoint <= 0x7f
|
|
53
|
+
? 1
|
|
54
|
+
: codePoint <= 0x7ff
|
|
55
|
+
? 2
|
|
56
|
+
: codePoint <= 0xffff
|
|
57
|
+
? 3
|
|
58
|
+
: 4;
|
|
59
|
+
}
|
|
60
|
+
return length;
|
|
61
|
+
};
|
|
62
|
+
const hasInvalidControlCharacter = (value) => Array.from(value).some((character) => {
|
|
63
|
+
const codePoint = character.codePointAt(0) ?? 0;
|
|
64
|
+
return (codePoint < 32 && codePoint !== 9 && codePoint !== 10 && codePoint !== 13);
|
|
65
|
+
});
|
|
66
|
+
const safeAttribute = (name, value) => {
|
|
67
|
+
if (value.length > 4096 || hasInvalidControlCharacter(value)) {
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
if (name === "d")
|
|
71
|
+
return value.length > 0 && PATH_VALUE.test(value);
|
|
72
|
+
if (name === "points")
|
|
73
|
+
return NUMBER_LIST.test(value);
|
|
74
|
+
if (name === "viewBox") {
|
|
75
|
+
const values = value
|
|
76
|
+
.trim()
|
|
77
|
+
.split(/[\s,]+/)
|
|
78
|
+
.map(Number);
|
|
79
|
+
return values.length === 4 && values.every(Number.isFinite);
|
|
80
|
+
}
|
|
81
|
+
if (name === "transform")
|
|
82
|
+
return TRANSFORM_VALUE.test(value.trim());
|
|
83
|
+
if (name === "fill" || name === "stroke")
|
|
84
|
+
return COLOR_VALUE.test(value);
|
|
85
|
+
if (name === "stroke-linecap")
|
|
86
|
+
return ["butt", "round", "square"].includes(value);
|
|
87
|
+
if (name === "stroke-linejoin")
|
|
88
|
+
return ["miter", "round", "bevel"].includes(value);
|
|
89
|
+
if (name === "fill-rule" || name === "clip-rule") {
|
|
90
|
+
return value === "nonzero" || value === "evenodd";
|
|
91
|
+
}
|
|
92
|
+
if (name === "opacity") {
|
|
93
|
+
const parsed = Number(value);
|
|
94
|
+
return Number.isFinite(parsed) && parsed >= 0 && parsed <= 1;
|
|
95
|
+
}
|
|
96
|
+
return true;
|
|
97
|
+
};
|
|
98
|
+
const copyElement = (source, targetDocument, counter) => {
|
|
99
|
+
if (!SAFE_TAGS.has(source.localName) || counter.value >= MAX_SVG_ELEMENTS)
|
|
100
|
+
return null;
|
|
101
|
+
counter.value += 1;
|
|
102
|
+
const target = targetDocument.createElementNS(SVG_NAMESPACE, source.localName);
|
|
103
|
+
for (const attribute of Array.from(source.attributes)) {
|
|
104
|
+
if (attribute.name === "xmlns" && attribute.value === SVG_NAMESPACE) {
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
if (attribute.namespaceURI)
|
|
108
|
+
return null;
|
|
109
|
+
if (IGNORED_ATTRIBUTES.has(attribute.name))
|
|
110
|
+
continue;
|
|
111
|
+
if (!SAFE_ATTRIBUTES.has(attribute.name) ||
|
|
112
|
+
!safeAttribute(attribute.name, attribute.value.trim())) {
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
if (source.localName === "svg" &&
|
|
116
|
+
["width", "height"].includes(attribute.name))
|
|
117
|
+
continue;
|
|
118
|
+
target.setAttribute(attribute.name, attribute.value.trim());
|
|
119
|
+
}
|
|
120
|
+
for (const child of Array.from(source.childNodes)) {
|
|
121
|
+
if (child.nodeType === Node.TEXT_NODE && !child.textContent?.trim())
|
|
122
|
+
continue;
|
|
123
|
+
if (child.nodeType !== Node.ELEMENT_NODE)
|
|
124
|
+
return null;
|
|
125
|
+
const copied = copyElement(child, targetDocument, counter);
|
|
126
|
+
if (!copied)
|
|
127
|
+
return null;
|
|
128
|
+
target.append(copied);
|
|
129
|
+
}
|
|
130
|
+
return target;
|
|
131
|
+
};
|
|
132
|
+
export const createSafeSvgIcon = (value, targetDocument = document) => {
|
|
133
|
+
const source = value?.trim();
|
|
134
|
+
if (!source || utf8ByteLength(source) > MAX_SVG_BYTES)
|
|
135
|
+
return null;
|
|
136
|
+
if (/<!doctype|<!entity|<\?xml-stylesheet/i.test(source))
|
|
137
|
+
return null;
|
|
138
|
+
const parsed = new DOMParser().parseFromString(source, "image/svg+xml");
|
|
139
|
+
if (parsed.querySelector("parsererror") ||
|
|
140
|
+
parsed.documentElement.localName !== "svg") {
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
143
|
+
const copied = copyElement(parsed.documentElement, targetDocument, {
|
|
144
|
+
value: 0,
|
|
145
|
+
});
|
|
146
|
+
if (!copied || copied.localName !== "svg")
|
|
147
|
+
return null;
|
|
148
|
+
if (!copied.hasAttribute("viewBox"))
|
|
149
|
+
copied.setAttribute("viewBox", "0 0 24 24");
|
|
150
|
+
copied.setAttribute("aria-hidden", "true");
|
|
151
|
+
copied.setAttribute("focusable", "false");
|
|
152
|
+
return copied;
|
|
153
|
+
};
|
package/lib/tabLock.d.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
export declare class TabActivityLock {
|
|
2
|
+
private readonly ownerId;
|
|
3
|
+
private readonly onOtherTabChange;
|
|
4
|
+
private readonly onTakeoverRequest;
|
|
5
|
+
private readonly onHandoff;
|
|
6
|
+
private readonly key;
|
|
7
|
+
private readonly channel;
|
|
8
|
+
private refreshTimer;
|
|
9
|
+
private takeoverRetryTimer;
|
|
10
|
+
private owned;
|
|
11
|
+
private takeoverRequestId;
|
|
12
|
+
private takeoverOwnerId;
|
|
13
|
+
private takeoverAttempts;
|
|
14
|
+
constructor(siteKey: string, ownerId: string, onOtherTabChange: (active: boolean) => void, onTakeoverRequest?: (requesterId: string, requestId: string) => void, onHandoff?: (payload: unknown) => void);
|
|
15
|
+
acquire(): boolean;
|
|
16
|
+
requestTakeover(): boolean;
|
|
17
|
+
handoff(requesterId: string, requestId: string, payload: unknown): boolean;
|
|
18
|
+
isOwnedByOther(): boolean;
|
|
19
|
+
release(): void;
|
|
20
|
+
destroy(): void;
|
|
21
|
+
private read;
|
|
22
|
+
private writeOwner;
|
|
23
|
+
private startRefresh;
|
|
24
|
+
private stopRefresh;
|
|
25
|
+
private reportOtherTab;
|
|
26
|
+
private sendTakeoverRequest;
|
|
27
|
+
private clearTakeoverRequest;
|
|
28
|
+
private expireIncompleteHandoff;
|
|
29
|
+
private onStorage;
|
|
30
|
+
private onMessage;
|
|
31
|
+
}
|