@vrplatform/voice-chat 0.1.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/package.json +35 -0
- package/src/SupportAssistantDock.tsx +4060 -0
- package/src/VRPilotHighlightProvider.tsx +942 -0
- package/src/_styles.ts +911 -0
- package/src/index.ts +33 -0
- package/src/supportAssistantActivity.ts +115 -0
|
@@ -0,0 +1,942 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { createStyles, keyframes } from '@mantine/emotion';
|
|
4
|
+
import {
|
|
5
|
+
type ReactNode,
|
|
6
|
+
createContext,
|
|
7
|
+
useCallback,
|
|
8
|
+
useContext,
|
|
9
|
+
useEffect,
|
|
10
|
+
useMemo,
|
|
11
|
+
useRef,
|
|
12
|
+
useState,
|
|
13
|
+
} from 'react';
|
|
14
|
+
import { createPortal } from 'react-dom';
|
|
15
|
+
|
|
16
|
+
export type VRPilotHighlightRole =
|
|
17
|
+
| 'any'
|
|
18
|
+
| 'button'
|
|
19
|
+
| 'checkbox'
|
|
20
|
+
| 'heading'
|
|
21
|
+
| 'input'
|
|
22
|
+
| 'link'
|
|
23
|
+
| 'menuitem'
|
|
24
|
+
| 'radio'
|
|
25
|
+
| 'select'
|
|
26
|
+
| 'tab';
|
|
27
|
+
|
|
28
|
+
export type VRPilotHighlightTargetRequest = {
|
|
29
|
+
targetKey?: string;
|
|
30
|
+
selector?: string;
|
|
31
|
+
text?: string;
|
|
32
|
+
label?: string;
|
|
33
|
+
role?: VRPilotHighlightRole;
|
|
34
|
+
reason?: string;
|
|
35
|
+
durationMs?: number;
|
|
36
|
+
scrollIntoView?: boolean;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export type VRPilotFillFieldMode = 'replace' | 'append';
|
|
40
|
+
|
|
41
|
+
export type VRPilotFillFieldRequest = Omit<
|
|
42
|
+
VRPilotHighlightTargetRequest,
|
|
43
|
+
'durationMs'
|
|
44
|
+
> & {
|
|
45
|
+
value?: string;
|
|
46
|
+
checked?: boolean;
|
|
47
|
+
mode?: VRPilotFillFieldMode;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
export type VRPilotHighlightResult = {
|
|
51
|
+
ok: boolean;
|
|
52
|
+
message: string;
|
|
53
|
+
targetDescription?: string;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
export type VRPilotFillFieldResult = VRPilotHighlightResult & {
|
|
57
|
+
appliedValue?: string;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
type HighlightRect = {
|
|
61
|
+
top: number;
|
|
62
|
+
left: number;
|
|
63
|
+
width: number;
|
|
64
|
+
height: number;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
type ActiveHighlight = {
|
|
68
|
+
rect: HighlightRect;
|
|
69
|
+
label?: string;
|
|
70
|
+
reason?: string;
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
type VRPilotHighlightContextValue = {
|
|
74
|
+
highlightTarget: (
|
|
75
|
+
request: VRPilotHighlightTargetRequest
|
|
76
|
+
) => VRPilotHighlightResult;
|
|
77
|
+
fillField: (request: VRPilotFillFieldRequest) => VRPilotFillFieldResult;
|
|
78
|
+
clearHighlight: () => void;
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
const highlightPulse = keyframes({
|
|
82
|
+
'0%, 100%': {
|
|
83
|
+
boxShadow:
|
|
84
|
+
'0 0 0 3px rgba(27, 105, 222, 0.44), 0 0 0 10px rgba(27, 105, 222, 0.18)',
|
|
85
|
+
},
|
|
86
|
+
'50%': {
|
|
87
|
+
boxShadow:
|
|
88
|
+
'0 0 0 4px rgba(27, 105, 222, 0.72), 0 0 0 16px rgba(27, 105, 222, 0.08)',
|
|
89
|
+
},
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
const useStyles = createStyles((theme) => ({
|
|
93
|
+
root: {
|
|
94
|
+
position: 'fixed',
|
|
95
|
+
inset: 0,
|
|
96
|
+
pointerEvents: 'none',
|
|
97
|
+
zIndex: 2_147_482_900,
|
|
98
|
+
},
|
|
99
|
+
|
|
100
|
+
ring: {
|
|
101
|
+
position: 'fixed',
|
|
102
|
+
border: `3px solid ${theme.colors.vrplatform[5]}`,
|
|
103
|
+
borderRadius: theme.radius.md,
|
|
104
|
+
background: 'rgba(27, 105, 222, 0.08)',
|
|
105
|
+
transition:
|
|
106
|
+
'top 120ms ease, left 120ms ease, width 120ms ease, height 120ms ease',
|
|
107
|
+
animation: `${highlightPulse} 1.1s ease-in-out infinite`,
|
|
108
|
+
},
|
|
109
|
+
|
|
110
|
+
label: {
|
|
111
|
+
position: 'fixed',
|
|
112
|
+
maxWidth: 320,
|
|
113
|
+
padding: '6px 10px',
|
|
114
|
+
borderRadius: theme.radius.sm,
|
|
115
|
+
background: theme.colors.vrplatform[9],
|
|
116
|
+
boxShadow: '0 12px 28px rgba(15, 23, 42, 0.24)',
|
|
117
|
+
color: theme.white,
|
|
118
|
+
fontSize: 12,
|
|
119
|
+
fontWeight: 700,
|
|
120
|
+
lineHeight: 1.2,
|
|
121
|
+
whiteSpace: 'nowrap',
|
|
122
|
+
overflow: 'hidden',
|
|
123
|
+
textOverflow: 'ellipsis',
|
|
124
|
+
},
|
|
125
|
+
|
|
126
|
+
'@media (prefers-reduced-motion: reduce)': {
|
|
127
|
+
ring: {
|
|
128
|
+
animation: 'none',
|
|
129
|
+
},
|
|
130
|
+
},
|
|
131
|
+
}));
|
|
132
|
+
|
|
133
|
+
const defaultHighlightContext: VRPilotHighlightContextValue = {
|
|
134
|
+
highlightTarget: () => ({
|
|
135
|
+
ok: false,
|
|
136
|
+
message: 'ChatVRT highlight provider is not mounted.',
|
|
137
|
+
}),
|
|
138
|
+
fillField: () => ({
|
|
139
|
+
ok: false,
|
|
140
|
+
message: 'ChatVRT highlight provider is not mounted.',
|
|
141
|
+
}),
|
|
142
|
+
clearHighlight: () => undefined,
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
const VRPilotHighlightContext = createContext<VRPilotHighlightContextValue>(
|
|
146
|
+
defaultHighlightContext
|
|
147
|
+
);
|
|
148
|
+
|
|
149
|
+
const interactiveSelector = [
|
|
150
|
+
'button',
|
|
151
|
+
'a[href]',
|
|
152
|
+
'input',
|
|
153
|
+
'select',
|
|
154
|
+
'textarea',
|
|
155
|
+
'[role="button"]',
|
|
156
|
+
'[role="checkbox"]',
|
|
157
|
+
'[role="link"]',
|
|
158
|
+
'[role="menuitem"]',
|
|
159
|
+
'[role="tab"]',
|
|
160
|
+
'[role="switch"]',
|
|
161
|
+
'[aria-label]',
|
|
162
|
+
'[data-testid]',
|
|
163
|
+
'[data-vrpilot-target]',
|
|
164
|
+
].join(',');
|
|
165
|
+
|
|
166
|
+
const formFieldSelector = [
|
|
167
|
+
'input:not([type="hidden"])',
|
|
168
|
+
'select',
|
|
169
|
+
'textarea',
|
|
170
|
+
'[contenteditable="true"]',
|
|
171
|
+
].join(',');
|
|
172
|
+
|
|
173
|
+
const blockedInputTypes = new Set([
|
|
174
|
+
'button',
|
|
175
|
+
'file',
|
|
176
|
+
'hidden',
|
|
177
|
+
'image',
|
|
178
|
+
'password',
|
|
179
|
+
'reset',
|
|
180
|
+
'submit',
|
|
181
|
+
]);
|
|
182
|
+
|
|
183
|
+
const sensitiveFieldPattern =
|
|
184
|
+
/\b(password|passwort|secret|token|api\s*key|credential|credit\s*card|card\s*number|cvv|cvc|iban|ssn|social\s*security|tax\s*id|bank\s*account|routing\s*number)\b/i;
|
|
185
|
+
|
|
186
|
+
function getCssEscape() {
|
|
187
|
+
if (typeof CSS !== 'undefined' && typeof CSS.escape === 'function') {
|
|
188
|
+
return CSS.escape;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
return (value: string) => value.replace(/[^a-zA-Z0-9_-]/g, '\\$&');
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function escapeAttributeValue(value: string) {
|
|
195
|
+
return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function normalizeText(value: string) {
|
|
199
|
+
return value.trim().replace(/\s+/g, ' ').toLowerCase();
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function getVisibleRect(element: Element): HighlightRect | null {
|
|
203
|
+
const rect = element.getBoundingClientRect();
|
|
204
|
+
|
|
205
|
+
if (rect.width <= 0 || rect.height <= 0) {
|
|
206
|
+
return null;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
if (
|
|
210
|
+
rect.bottom < 0 ||
|
|
211
|
+
rect.right < 0 ||
|
|
212
|
+
rect.top > window.innerHeight ||
|
|
213
|
+
rect.left > window.innerWidth
|
|
214
|
+
) {
|
|
215
|
+
return null;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const padding = 6;
|
|
219
|
+
|
|
220
|
+
return {
|
|
221
|
+
top: Math.max(8, rect.top - padding),
|
|
222
|
+
left: Math.max(8, rect.left - padding),
|
|
223
|
+
width: Math.min(window.innerWidth - 16, rect.width + padding * 2),
|
|
224
|
+
height: Math.min(window.innerHeight - 16, rect.height + padding * 2),
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function isVisibleElement(element: Element) {
|
|
229
|
+
return Boolean(getVisibleRect(element));
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function queryElement(selector: string) {
|
|
233
|
+
try {
|
|
234
|
+
return document.querySelector(selector);
|
|
235
|
+
} catch {
|
|
236
|
+
return null;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function getRoleSelector(role?: VRPilotHighlightRole) {
|
|
241
|
+
if (!role || role === 'any') {
|
|
242
|
+
return interactiveSelector;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
if (role === 'button') {
|
|
246
|
+
return 'button,[role="button"],input[type="button"],input[type="submit"]';
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
if (role === 'checkbox') {
|
|
250
|
+
return 'input[type="checkbox"],[role="checkbox"],[role="switch"]';
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
if (role === 'heading') {
|
|
254
|
+
return 'h1,h2,h3,h4,h5,h6,[role="heading"]';
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
if (role === 'input') {
|
|
258
|
+
return 'input,select,textarea,[role="textbox"],[contenteditable="true"]';
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
if (role === 'link') {
|
|
262
|
+
return 'a[href],[role="link"]';
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
if (role === 'menuitem') {
|
|
266
|
+
return '[role="menuitem"],[role="menuitemradio"],[role="menuitemcheckbox"]';
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
if (role === 'radio') {
|
|
270
|
+
return 'input[type="radio"],[role="radio"]';
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
if (role === 'select') {
|
|
274
|
+
return 'select,[role="combobox"],[role="listbox"]';
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
if (role === 'tab') {
|
|
278
|
+
return '[role="tab"]';
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
return interactiveSelector;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function getElementText(element: Element) {
|
|
285
|
+
return normalizeText(
|
|
286
|
+
[
|
|
287
|
+
element.getAttribute('aria-label'),
|
|
288
|
+
getAssociatedLabelText(element),
|
|
289
|
+
element.getAttribute('title'),
|
|
290
|
+
element.getAttribute('placeholder'),
|
|
291
|
+
element.getAttribute('name'),
|
|
292
|
+
element.getAttribute('id'),
|
|
293
|
+
element.getAttribute('data-testid'),
|
|
294
|
+
element.textContent,
|
|
295
|
+
]
|
|
296
|
+
.filter((value): value is string => typeof value === 'string')
|
|
297
|
+
.join(' ')
|
|
298
|
+
);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function getAssociatedLabelText(element: Element) {
|
|
302
|
+
const labels: string[] = [];
|
|
303
|
+
|
|
304
|
+
if (
|
|
305
|
+
element instanceof HTMLInputElement ||
|
|
306
|
+
element instanceof HTMLTextAreaElement ||
|
|
307
|
+
element instanceof HTMLSelectElement
|
|
308
|
+
) {
|
|
309
|
+
for (const label of Array.from(element.labels ?? [])) {
|
|
310
|
+
labels.push(label.textContent ?? '');
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
const id = element.getAttribute('id');
|
|
315
|
+
if (id) {
|
|
316
|
+
const attributeValue = escapeAttributeValue(id);
|
|
317
|
+
for (const label of Array.from(
|
|
318
|
+
document.querySelectorAll(`label[for="${attributeValue}"]`)
|
|
319
|
+
)) {
|
|
320
|
+
labels.push(label.textContent ?? '');
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
const labelledBy = element.getAttribute('aria-labelledby');
|
|
325
|
+
if (labelledBy) {
|
|
326
|
+
for (const labelId of labelledBy.split(/\s+/)) {
|
|
327
|
+
const labelElement = document.getElementById(labelId);
|
|
328
|
+
labels.push(labelElement?.textContent ?? '');
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
labels.push(element.closest('label')?.textContent ?? '');
|
|
333
|
+
|
|
334
|
+
return labels.filter(Boolean).join(' ');
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function findByText(request: VRPilotHighlightTargetRequest) {
|
|
338
|
+
const rawNeedle = request.label ?? request.text;
|
|
339
|
+
if (!rawNeedle) {
|
|
340
|
+
return null;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
const needle = normalizeText(rawNeedle);
|
|
344
|
+
if (!needle) {
|
|
345
|
+
return null;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
const candidates = Array.from(
|
|
349
|
+
document.querySelectorAll(getRoleSelector(request.role))
|
|
350
|
+
);
|
|
351
|
+
|
|
352
|
+
const matches = candidates.filter((element) => {
|
|
353
|
+
const haystack = getElementText(element);
|
|
354
|
+
|
|
355
|
+
return haystack === needle || haystack.includes(needle);
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
return (
|
|
359
|
+
matches.find((element) => isVisibleElement(element)) ?? matches[0] ?? null
|
|
360
|
+
);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
function describeElement(
|
|
364
|
+
element: Element,
|
|
365
|
+
request: VRPilotHighlightTargetRequest
|
|
366
|
+
) {
|
|
367
|
+
return (
|
|
368
|
+
request.label ??
|
|
369
|
+
request.text ??
|
|
370
|
+
element.getAttribute('aria-label') ??
|
|
371
|
+
element.getAttribute('title') ??
|
|
372
|
+
element.getAttribute('data-vrpilot-target') ??
|
|
373
|
+
element.getAttribute('data-testid') ??
|
|
374
|
+
element.textContent?.trim().replace(/\s+/g, ' ').slice(0, 80) ??
|
|
375
|
+
element.tagName.toLowerCase()
|
|
376
|
+
);
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
function resolveHighlightElement(request: VRPilotHighlightTargetRequest) {
|
|
380
|
+
const cssEscape = getCssEscape();
|
|
381
|
+
const selectors: string[] = [];
|
|
382
|
+
|
|
383
|
+
if (request.targetKey) {
|
|
384
|
+
const attributeValue = escapeAttributeValue(request.targetKey);
|
|
385
|
+
selectors.push(`[data-vrpilot-target="${attributeValue}"]`);
|
|
386
|
+
selectors.push(`[data-testid="${attributeValue}"]`);
|
|
387
|
+
selectors.push(`[data-test="${attributeValue}"]`);
|
|
388
|
+
selectors.push(`#${cssEscape(request.targetKey)}`);
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
if (request.selector) {
|
|
392
|
+
selectors.push(request.selector);
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
if (request.label) {
|
|
396
|
+
const attributeValue = escapeAttributeValue(request.label);
|
|
397
|
+
selectors.push(`[aria-label="${attributeValue}"]`);
|
|
398
|
+
selectors.push(`[title="${attributeValue}"]`);
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
for (const selector of selectors) {
|
|
402
|
+
const element = queryElement(selector);
|
|
403
|
+
if (element) {
|
|
404
|
+
return element;
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
return findByText(request);
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
function getFillRoleSelector(role?: VRPilotHighlightRole) {
|
|
412
|
+
if (role === 'checkbox') {
|
|
413
|
+
return 'input[type="checkbox"]';
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
if (role === 'radio') {
|
|
417
|
+
return 'input[type="radio"]';
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
if (role === 'select') {
|
|
421
|
+
return 'select';
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
return formFieldSelector;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
function getFillControl(element: Element): Element | null {
|
|
428
|
+
if (isSupportedFillField(element)) {
|
|
429
|
+
return element;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
return element.querySelector(formFieldSelector);
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
function findFieldByText(request: VRPilotFillFieldRequest) {
|
|
436
|
+
const rawNeedle = request.label ?? request.text;
|
|
437
|
+
if (!rawNeedle) {
|
|
438
|
+
return null;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
const needle = normalizeText(rawNeedle);
|
|
442
|
+
if (!needle) {
|
|
443
|
+
return null;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
const candidates = Array.from(
|
|
447
|
+
document.querySelectorAll(getFillRoleSelector(request.role))
|
|
448
|
+
);
|
|
449
|
+
|
|
450
|
+
const matches = candidates.filter((element) => {
|
|
451
|
+
const haystack = getElementText(element);
|
|
452
|
+
|
|
453
|
+
return haystack === needle || haystack.includes(needle);
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
return (
|
|
457
|
+
matches.find((element) => isVisibleElement(element)) ?? matches[0] ?? null
|
|
458
|
+
);
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
function resolveFillElement(request: VRPilotFillFieldRequest) {
|
|
462
|
+
const cssEscape = getCssEscape();
|
|
463
|
+
const selectors: string[] = [];
|
|
464
|
+
|
|
465
|
+
if (request.targetKey) {
|
|
466
|
+
const attributeValue = escapeAttributeValue(request.targetKey);
|
|
467
|
+
selectors.push(`[data-vrpilot-target="${attributeValue}"]`);
|
|
468
|
+
selectors.push(`[data-testid="${attributeValue}"]`);
|
|
469
|
+
selectors.push(`[data-test="${attributeValue}"]`);
|
|
470
|
+
selectors.push(`#${cssEscape(request.targetKey)}`);
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
if (request.selector) {
|
|
474
|
+
selectors.push(request.selector);
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
if (request.label) {
|
|
478
|
+
const attributeValue = escapeAttributeValue(request.label);
|
|
479
|
+
selectors.push(`[aria-label="${attributeValue}"]`);
|
|
480
|
+
selectors.push(`[title="${attributeValue}"]`);
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
for (const selector of selectors) {
|
|
484
|
+
const element = queryElement(selector);
|
|
485
|
+
const field = element ? getFillControl(element) : null;
|
|
486
|
+
|
|
487
|
+
if (field) {
|
|
488
|
+
return field;
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
return findFieldByText(request);
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
function isSupportedFillField(element: Element) {
|
|
496
|
+
return (
|
|
497
|
+
element instanceof HTMLInputElement ||
|
|
498
|
+
element instanceof HTMLTextAreaElement ||
|
|
499
|
+
element instanceof HTMLSelectElement ||
|
|
500
|
+
(element instanceof HTMLElement && element.isContentEditable)
|
|
501
|
+
);
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
function isBlockedFillField(element: Element) {
|
|
505
|
+
if (element instanceof HTMLInputElement) {
|
|
506
|
+
const type = element.type.toLowerCase();
|
|
507
|
+
if (blockedInputTypes.has(type)) {
|
|
508
|
+
return true;
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
return sensitiveFieldPattern.test(getElementText(element));
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
function getBooleanIntent(value?: string, checked?: boolean) {
|
|
516
|
+
if (typeof checked === 'boolean') {
|
|
517
|
+
return checked;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
if (!value) {
|
|
521
|
+
return undefined;
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
const normalized = normalizeText(value);
|
|
525
|
+
if (['true', 'yes', 'on', 'checked', 'selected'].includes(normalized)) {
|
|
526
|
+
return true;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
if (['false', 'no', 'off', 'unchecked', 'cleared'].includes(normalized)) {
|
|
530
|
+
return false;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
return undefined;
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
function setNativeInputValue(
|
|
537
|
+
element: HTMLInputElement | HTMLTextAreaElement,
|
|
538
|
+
value: string
|
|
539
|
+
) {
|
|
540
|
+
const prototype = Object.getPrototypeOf(element) as
|
|
541
|
+
| HTMLInputElement
|
|
542
|
+
| HTMLTextAreaElement;
|
|
543
|
+
const descriptor = Object.getOwnPropertyDescriptor(prototype, 'value');
|
|
544
|
+
|
|
545
|
+
if (descriptor?.set) {
|
|
546
|
+
descriptor.set.call(element, value);
|
|
547
|
+
return;
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
element.value = value;
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
function setNativeCheckedValue(element: HTMLInputElement, checked: boolean) {
|
|
554
|
+
const descriptor = Object.getOwnPropertyDescriptor(
|
|
555
|
+
HTMLInputElement.prototype,
|
|
556
|
+
'checked'
|
|
557
|
+
);
|
|
558
|
+
|
|
559
|
+
if (descriptor?.set) {
|
|
560
|
+
descriptor.set.call(element, checked);
|
|
561
|
+
return;
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
element.checked = checked;
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
function dispatchFieldEvents(element: EventTarget) {
|
|
568
|
+
element.dispatchEvent(new Event('input', { bubbles: true }));
|
|
569
|
+
element.dispatchEvent(new Event('change', { bubbles: true }));
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
function getSelectOption(select: HTMLSelectElement, value: string) {
|
|
573
|
+
const normalizedValue = normalizeText(value);
|
|
574
|
+
const options = Array.from(select.options);
|
|
575
|
+
|
|
576
|
+
return (
|
|
577
|
+
options.find((option) => normalizeText(option.value) === normalizedValue) ??
|
|
578
|
+
options.find(
|
|
579
|
+
(option) => normalizeText(option.textContent ?? '') === normalizedValue
|
|
580
|
+
) ??
|
|
581
|
+
options.find((option) =>
|
|
582
|
+
normalizeText(option.textContent ?? '').includes(normalizedValue)
|
|
583
|
+
) ??
|
|
584
|
+
null
|
|
585
|
+
);
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
function applyFieldValue(
|
|
589
|
+
element: Element,
|
|
590
|
+
request: VRPilotFillFieldRequest
|
|
591
|
+
): VRPilotFillFieldResult {
|
|
592
|
+
const targetDescription = describeElement(element, request);
|
|
593
|
+
const mode = request.mode === 'append' ? 'append' : 'replace';
|
|
594
|
+
|
|
595
|
+
if (isBlockedFillField(element)) {
|
|
596
|
+
return {
|
|
597
|
+
ok: false,
|
|
598
|
+
message: 'This field is blocked because it looks sensitive or unsafe.',
|
|
599
|
+
targetDescription,
|
|
600
|
+
};
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
if (element instanceof HTMLInputElement) {
|
|
604
|
+
const type = element.type.toLowerCase();
|
|
605
|
+
|
|
606
|
+
if (type === 'checkbox' || type === 'radio') {
|
|
607
|
+
const checked = getBooleanIntent(request.value, request.checked);
|
|
608
|
+
if (checked === undefined) {
|
|
609
|
+
return {
|
|
610
|
+
ok: false,
|
|
611
|
+
message: 'A checked value is required for this field.',
|
|
612
|
+
targetDescription,
|
|
613
|
+
};
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
setNativeCheckedValue(element, checked);
|
|
617
|
+
dispatchFieldEvents(element);
|
|
618
|
+
|
|
619
|
+
return {
|
|
620
|
+
ok: true,
|
|
621
|
+
message: 'Updated the matching form field.',
|
|
622
|
+
appliedValue: String(checked),
|
|
623
|
+
targetDescription,
|
|
624
|
+
};
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
if (request.value === undefined) {
|
|
628
|
+
return {
|
|
629
|
+
ok: false,
|
|
630
|
+
message: 'A value is required for this field.',
|
|
631
|
+
targetDescription,
|
|
632
|
+
};
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
const nextValue =
|
|
636
|
+
mode === 'append' ? `${element.value}${request.value}` : request.value;
|
|
637
|
+
setNativeInputValue(element, nextValue);
|
|
638
|
+
dispatchFieldEvents(element);
|
|
639
|
+
|
|
640
|
+
return {
|
|
641
|
+
ok: true,
|
|
642
|
+
message: 'Updated the matching form field.',
|
|
643
|
+
appliedValue: nextValue,
|
|
644
|
+
targetDescription,
|
|
645
|
+
};
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
if (element instanceof HTMLTextAreaElement) {
|
|
649
|
+
if (request.value === undefined) {
|
|
650
|
+
return {
|
|
651
|
+
ok: false,
|
|
652
|
+
message: 'A value is required for this field.',
|
|
653
|
+
targetDescription,
|
|
654
|
+
};
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
const nextValue =
|
|
658
|
+
mode === 'append' ? `${element.value}${request.value}` : request.value;
|
|
659
|
+
setNativeInputValue(element, nextValue);
|
|
660
|
+
dispatchFieldEvents(element);
|
|
661
|
+
|
|
662
|
+
return {
|
|
663
|
+
ok: true,
|
|
664
|
+
message: 'Updated the matching form field.',
|
|
665
|
+
appliedValue: nextValue,
|
|
666
|
+
targetDescription,
|
|
667
|
+
};
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
if (element instanceof HTMLSelectElement) {
|
|
671
|
+
if (request.value === undefined) {
|
|
672
|
+
return {
|
|
673
|
+
ok: false,
|
|
674
|
+
message: 'A value is required for this field.',
|
|
675
|
+
targetDescription,
|
|
676
|
+
};
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
const option = getSelectOption(element, request.value);
|
|
680
|
+
if (!option) {
|
|
681
|
+
return {
|
|
682
|
+
ok: false,
|
|
683
|
+
message: 'No matching option was found for this select field.',
|
|
684
|
+
targetDescription,
|
|
685
|
+
};
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
element.value = option.value;
|
|
689
|
+
dispatchFieldEvents(element);
|
|
690
|
+
|
|
691
|
+
return {
|
|
692
|
+
ok: true,
|
|
693
|
+
message: 'Updated the matching form field.',
|
|
694
|
+
appliedValue: option.value,
|
|
695
|
+
targetDescription,
|
|
696
|
+
};
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
if (element instanceof HTMLElement && element.isContentEditable) {
|
|
700
|
+
if (request.value === undefined) {
|
|
701
|
+
return {
|
|
702
|
+
ok: false,
|
|
703
|
+
message: 'A value is required for this field.',
|
|
704
|
+
targetDescription,
|
|
705
|
+
};
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
const currentValue = element.textContent ?? '';
|
|
709
|
+
const nextValue =
|
|
710
|
+
mode === 'append' ? `${currentValue}${request.value}` : request.value;
|
|
711
|
+
element.textContent = nextValue;
|
|
712
|
+
dispatchFieldEvents(element);
|
|
713
|
+
|
|
714
|
+
return {
|
|
715
|
+
ok: true,
|
|
716
|
+
message: 'Updated the matching editable field.',
|
|
717
|
+
appliedValue: nextValue,
|
|
718
|
+
targetDescription,
|
|
719
|
+
};
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
return {
|
|
723
|
+
ok: false,
|
|
724
|
+
message: 'The matching UI target is not a supported editable field.',
|
|
725
|
+
targetDescription,
|
|
726
|
+
};
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
function clampDuration(durationMs?: number) {
|
|
730
|
+
if (!Number.isFinite(durationMs)) {
|
|
731
|
+
return 5_000;
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
return Math.max(1_200, Math.min(12_000, Math.round(durationMs ?? 5_000)));
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
export function VRPilotHighlightProvider({
|
|
738
|
+
children,
|
|
739
|
+
}: {
|
|
740
|
+
children: ReactNode;
|
|
741
|
+
}) {
|
|
742
|
+
const { classes } = useStyles();
|
|
743
|
+
const activeElementRef = useRef<Element | null>(null);
|
|
744
|
+
const timeoutRef = useRef<number | null>(null);
|
|
745
|
+
const [activeHighlight, setActiveHighlight] =
|
|
746
|
+
useState<ActiveHighlight | null>(null);
|
|
747
|
+
|
|
748
|
+
const clearHighlight = useCallback(() => {
|
|
749
|
+
if (timeoutRef.current !== null) {
|
|
750
|
+
window.clearTimeout(timeoutRef.current);
|
|
751
|
+
timeoutRef.current = null;
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
activeElementRef.current = null;
|
|
755
|
+
setActiveHighlight(null);
|
|
756
|
+
}, []);
|
|
757
|
+
|
|
758
|
+
const updateHighlightRect = useCallback(() => {
|
|
759
|
+
const element = activeElementRef.current;
|
|
760
|
+
if (!element) {
|
|
761
|
+
return;
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
const rect = getVisibleRect(element);
|
|
765
|
+
if (!rect) {
|
|
766
|
+
clearHighlight();
|
|
767
|
+
return;
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
setActiveHighlight((current) => (current ? { ...current, rect } : current));
|
|
771
|
+
}, [clearHighlight]);
|
|
772
|
+
|
|
773
|
+
const highlightTarget = useCallback(
|
|
774
|
+
(request: VRPilotHighlightTargetRequest): VRPilotHighlightResult => {
|
|
775
|
+
const element = resolveHighlightElement(request);
|
|
776
|
+
|
|
777
|
+
if (!element) {
|
|
778
|
+
return {
|
|
779
|
+
ok: false,
|
|
780
|
+
message: 'No visible matching UI target was found.',
|
|
781
|
+
};
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
if (request.scrollIntoView !== false) {
|
|
785
|
+
element.scrollIntoView({
|
|
786
|
+
block: 'center',
|
|
787
|
+
inline: 'nearest',
|
|
788
|
+
behavior: 'auto',
|
|
789
|
+
});
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
const rect = getVisibleRect(element);
|
|
793
|
+
if (!rect) {
|
|
794
|
+
return {
|
|
795
|
+
ok: false,
|
|
796
|
+
message: 'The matching UI target is not visible.',
|
|
797
|
+
};
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
const targetDescription = describeElement(element, request);
|
|
801
|
+
|
|
802
|
+
if (timeoutRef.current !== null) {
|
|
803
|
+
window.clearTimeout(timeoutRef.current);
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
activeElementRef.current = element;
|
|
807
|
+
setActiveHighlight({
|
|
808
|
+
rect,
|
|
809
|
+
label: targetDescription,
|
|
810
|
+
reason: request.reason,
|
|
811
|
+
});
|
|
812
|
+
window.setTimeout(updateHighlightRect, 180);
|
|
813
|
+
timeoutRef.current = window.setTimeout(
|
|
814
|
+
clearHighlight,
|
|
815
|
+
clampDuration(request.durationMs)
|
|
816
|
+
);
|
|
817
|
+
|
|
818
|
+
return {
|
|
819
|
+
ok: true,
|
|
820
|
+
message: 'Highlighted the matching UI target.',
|
|
821
|
+
targetDescription,
|
|
822
|
+
};
|
|
823
|
+
},
|
|
824
|
+
[clearHighlight, updateHighlightRect]
|
|
825
|
+
);
|
|
826
|
+
|
|
827
|
+
const fillField = useCallback(
|
|
828
|
+
(request: VRPilotFillFieldRequest): VRPilotFillFieldResult => {
|
|
829
|
+
const element = resolveFillElement(request);
|
|
830
|
+
|
|
831
|
+
if (!element) {
|
|
832
|
+
return {
|
|
833
|
+
ok: false,
|
|
834
|
+
message: 'No matching editable form field was found.',
|
|
835
|
+
};
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
if (request.scrollIntoView !== false) {
|
|
839
|
+
element.scrollIntoView({
|
|
840
|
+
block: 'center',
|
|
841
|
+
inline: 'nearest',
|
|
842
|
+
behavior: 'auto',
|
|
843
|
+
});
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
const rect = getVisibleRect(element);
|
|
847
|
+
if (!rect) {
|
|
848
|
+
return {
|
|
849
|
+
ok: false,
|
|
850
|
+
message: 'The matching form field is not visible.',
|
|
851
|
+
};
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
const result = applyFieldValue(element, request);
|
|
855
|
+
if (!result.ok) {
|
|
856
|
+
return result;
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
if (timeoutRef.current !== null) {
|
|
860
|
+
window.clearTimeout(timeoutRef.current);
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
activeElementRef.current = element;
|
|
864
|
+
setActiveHighlight({
|
|
865
|
+
rect,
|
|
866
|
+
label: result.targetDescription,
|
|
867
|
+
reason: request.reason,
|
|
868
|
+
});
|
|
869
|
+
window.setTimeout(updateHighlightRect, 180);
|
|
870
|
+
timeoutRef.current = window.setTimeout(clearHighlight, 3_000);
|
|
871
|
+
|
|
872
|
+
return result;
|
|
873
|
+
},
|
|
874
|
+
[clearHighlight, updateHighlightRect]
|
|
875
|
+
);
|
|
876
|
+
|
|
877
|
+
useEffect(() => {
|
|
878
|
+
if (!activeHighlight) {
|
|
879
|
+
return;
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
window.addEventListener('resize', updateHighlightRect);
|
|
883
|
+
window.addEventListener('scroll', updateHighlightRect, true);
|
|
884
|
+
|
|
885
|
+
return () => {
|
|
886
|
+
window.removeEventListener('resize', updateHighlightRect);
|
|
887
|
+
window.removeEventListener('scroll', updateHighlightRect, true);
|
|
888
|
+
};
|
|
889
|
+
}, [activeHighlight, updateHighlightRect]);
|
|
890
|
+
|
|
891
|
+
useEffect(() => clearHighlight, [clearHighlight]);
|
|
892
|
+
|
|
893
|
+
const value = useMemo(
|
|
894
|
+
() => ({
|
|
895
|
+
clearHighlight,
|
|
896
|
+
fillField,
|
|
897
|
+
highlightTarget,
|
|
898
|
+
}),
|
|
899
|
+
[clearHighlight, fillField, highlightTarget]
|
|
900
|
+
);
|
|
901
|
+
|
|
902
|
+
const overlay =
|
|
903
|
+
activeHighlight && typeof document !== 'undefined'
|
|
904
|
+
? createPortal(
|
|
905
|
+
<div className={classes.root} aria-hidden="true">
|
|
906
|
+
<div
|
|
907
|
+
className={classes.ring}
|
|
908
|
+
style={{
|
|
909
|
+
top: activeHighlight.rect.top,
|
|
910
|
+
left: activeHighlight.rect.left,
|
|
911
|
+
width: activeHighlight.rect.width,
|
|
912
|
+
height: activeHighlight.rect.height,
|
|
913
|
+
}}
|
|
914
|
+
/>
|
|
915
|
+
{activeHighlight.label ? (
|
|
916
|
+
<div
|
|
917
|
+
className={classes.label}
|
|
918
|
+
style={{
|
|
919
|
+
top: Math.max(8, activeHighlight.rect.top - 34),
|
|
920
|
+
left: activeHighlight.rect.left,
|
|
921
|
+
}}
|
|
922
|
+
title={activeHighlight.reason}
|
|
923
|
+
>
|
|
924
|
+
{activeHighlight.label}
|
|
925
|
+
</div>
|
|
926
|
+
) : null}
|
|
927
|
+
</div>,
|
|
928
|
+
document.body
|
|
929
|
+
)
|
|
930
|
+
: null;
|
|
931
|
+
|
|
932
|
+
return (
|
|
933
|
+
<VRPilotHighlightContext.Provider value={value}>
|
|
934
|
+
{children}
|
|
935
|
+
{overlay}
|
|
936
|
+
</VRPilotHighlightContext.Provider>
|
|
937
|
+
);
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
export function useVRPilotHighlights() {
|
|
941
|
+
return useContext(VRPilotHighlightContext);
|
|
942
|
+
}
|