@reticlehq/browser 3.8.2 → 3.10.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.
Files changed (60) hide show
  1. package/dist/actions/action-report.d.ts +14 -0
  2. package/dist/actions/action-report.js +95 -22
  3. package/dist/actions/actions-dom.d.ts +17 -6
  4. package/dist/actions/actions-dom.js +98 -47
  5. package/dist/actions/actions-press.d.ts +10 -0
  6. package/dist/actions/actions-press.js +146 -2
  7. package/dist/actions/actions.js +64 -24
  8. package/dist/actions/insert-text.d.ts +3 -0
  9. package/dist/actions/insert-text.js +90 -0
  10. package/dist/actions/scroll.d.ts +1 -1
  11. package/dist/actions/scroll.js +25 -1
  12. package/dist/actions/value-input.d.ts +2 -0
  13. package/dist/actions/value-input.js +30 -9
  14. package/dist/commands/commands.js +85 -9
  15. package/dist/connect-options.d.ts +11 -1
  16. package/dist/connect-options.js +27 -1
  17. package/dist/dom/a11y.d.ts +1 -1
  18. package/dist/dom/a11y.js +11 -10
  19. package/dist/dom/chart-geometry.d.ts +7 -0
  20. package/dist/dom/chart-geometry.js +39 -0
  21. package/dist/dom/occlusion.js +21 -0
  22. package/dist/dom/query-finders.d.ts +1 -1
  23. package/dist/dom/query-finders.js +10 -7
  24. package/dist/dom/query.d.ts +6 -2
  25. package/dist/dom/query.js +165 -54
  26. package/dist/dom/scope-roots.d.ts +8 -0
  27. package/dist/dom/scope-roots.js +44 -0
  28. package/dist/dom/snapshot.d.ts +3 -0
  29. package/dist/dom/snapshot.js +9 -2
  30. package/dist/dom/text-match.js +6 -4
  31. package/dist/dom/theme.js +6 -7
  32. package/dist/dom/visibility.d.ts +3 -0
  33. package/dist/dom/visibility.js +125 -10
  34. package/dist/observers/canvas.d.ts +1 -0
  35. package/dist/observers/canvas.js +10 -1
  36. package/dist/observers/console.js +46 -35
  37. package/dist/observers/dom.js +28 -4
  38. package/dist/observers/net-detail/resource-timing.d.ts +12 -0
  39. package/dist/observers/net-detail/resource-timing.js +69 -0
  40. package/dist/observers/network.js +13 -44
  41. package/dist/observers/perf.js +15 -1
  42. package/dist/presenter/chrome/presenter-verbs.js +1 -0
  43. package/dist/presenter/presenter-config.d.ts +1 -0
  44. package/dist/presenter/presenter-config.js +1 -0
  45. package/dist/presenter/presenter-shell.d.ts +1 -0
  46. package/dist/presenter/presenter-shell.js +6 -3
  47. package/dist/presenter/tour/tour.js +4 -0
  48. package/dist/recorder/recorder.js +82 -39
  49. package/dist/reticle-inject.js +618 -618
  50. package/dist/reticle.d.ts +3 -19
  51. package/dist/reticle.js +76 -110
  52. package/dist/review/annotator.js +4 -0
  53. package/dist/security/serialization.js +35 -0
  54. package/dist/session-continuity.d.ts +1 -0
  55. package/dist/session-continuity.js +18 -0
  56. package/dist/storage-keys.js +2 -1
  57. package/dist/timers/native/native-timers.js +21 -4
  58. package/dist/transport/bridge-url.d.ts +8 -0
  59. package/dist/transport/bridge-url.js +52 -0
  60. package/package.json +10 -10
@@ -7,7 +7,21 @@ function asString(value, fallback = '') {
7
7
  return 'string' === typeof value ? value : fallback;
8
8
  }
9
9
  export function pressKey(args) {
10
- return pressKeyFromArgs(args);
10
+ return splitChord(pressKeyFromArgs(args)).key;
11
+ }
12
+ function splitChord(raw) {
13
+ const at = raw.lastIndexOf('+', raw.length - 2);
14
+ const flags = 0 < at
15
+ ? raw
16
+ .slice(0, at)
17
+ .split('+')
18
+ .map((name) => MODIFIER_FLAG_ALIASES[name.toLowerCase()])
19
+ : [];
20
+ if (0 === flags.length || flags.some((flag) => flag === undefined)) {
21
+ return { key: CODE_KEYS[raw] ?? raw, flags: [] };
22
+ }
23
+ const key = raw.slice(at + 1);
24
+ return { key: CODE_KEYS[key] ?? key, flags: flags.filter((flag) => flag !== undefined) };
11
25
  }
12
26
  const MODIFIER_FLAG_KEYS = [
13
27
  'metaKey',
@@ -37,6 +51,9 @@ export function pressModifiers(args) {
37
51
  if (flag !== undefined)
38
52
  flags[flag] = true;
39
53
  }
54
+ for (const flag of splitChord(pressKeyFromArgs(args)).flags) {
55
+ flags[flag] = true;
56
+ }
40
57
  return flags;
41
58
  }
42
59
  function modifierFlagFor(key) {
@@ -53,7 +70,7 @@ export function pressCode(args, key) {
53
70
  return `Key${key.toUpperCase()}`;
54
71
  if (/[0-9]/.test(key))
55
72
  return `Digit${key}`;
56
- return '';
73
+ return PUNCTUATION_CODES[key] ?? '';
57
74
  }
58
75
  return /^[A-Z][A-Za-z0-9]*$/.test(key) && KNOWN_NAMED_KEYS.has(key) ? key : '';
59
76
  }
@@ -74,6 +91,53 @@ export function dispatchKeypress(el, key, code, mods, repeat = false) {
74
91
  }
75
92
  asSyntheticInput(() => el.dispatchEvent(event));
76
93
  }
94
+ const PUNCTUATION_CODES = {
95
+ '/': 'Slash',
96
+ '?': 'Slash',
97
+ '.': 'Period',
98
+ '>': 'Period',
99
+ ',': 'Comma',
100
+ '<': 'Comma',
101
+ ';': 'Semicolon',
102
+ ':': 'Semicolon',
103
+ "'": 'Quote',
104
+ '"': 'Quote',
105
+ '[': 'BracketLeft',
106
+ '{': 'BracketLeft',
107
+ ']': 'BracketRight',
108
+ '}': 'BracketRight',
109
+ '\\': 'Backslash',
110
+ '|': 'Backslash',
111
+ '-': 'Minus',
112
+ _: 'Minus',
113
+ '=': 'Equal',
114
+ '+': 'Equal',
115
+ '`': 'Backquote',
116
+ '~': 'Backquote',
117
+ '!': 'Digit1',
118
+ '@': 'Digit2',
119
+ '#': 'Digit3',
120
+ $: 'Digit4',
121
+ '%': 'Digit5',
122
+ '^': 'Digit6',
123
+ '&': 'Digit7',
124
+ '*': 'Digit8',
125
+ '(': 'Digit9',
126
+ ')': 'Digit0',
127
+ };
128
+ const CODE_KEYS = {
129
+ Slash: '/',
130
+ Period: '.',
131
+ Comma: ',',
132
+ Semicolon: ';',
133
+ Quote: "'",
134
+ BracketLeft: '[',
135
+ BracketRight: ']',
136
+ Backslash: '\\',
137
+ Minus: '-',
138
+ Equal: '=',
139
+ Backquote: '`',
140
+ };
77
141
  const ENTER_KEY = 'Enter';
78
142
  const ENTER_CHAR_CODE = 13;
79
143
  const LEGACY_KEY_CODE_FIELDS = ['keyCode', 'charCode', 'which'];
@@ -233,3 +297,83 @@ export function closeModalOnEscape(el, key, keydownProceeded) {
233
297
  return;
234
298
  }
235
299
  }
300
+ export const SPACE_KEY = ' ';
301
+ const ENTER = 'Enter';
302
+ const TAB = 'Tab';
303
+ const ENTER_ACTIVATES = 'button, a[href], area[href], summary, input[type="submit"], input[type="button"], input[type="reset"], input[type="image"]';
304
+ const SPACE_ACTIVATES = 'button, summary, input[type="submit"], input[type="button"], input[type="reset"], input[type="image"], input[type="checkbox"], input[type="radio"]';
305
+ const IMPLICIT_SUBMIT_TYPES = new Set([
306
+ 'text',
307
+ 'search',
308
+ 'url',
309
+ 'tel',
310
+ 'email',
311
+ 'password',
312
+ 'date',
313
+ 'month',
314
+ 'week',
315
+ 'time',
316
+ 'datetime-local',
317
+ 'number',
318
+ ]);
319
+ const FOCUSABLE = 'a[href], area[href], button, input, select, textarea, summary, iframe, [tabindex], [contenteditable=""], [contenteditable="true"]';
320
+ const shortcut = (held) => held.metaKey || held.ctrlKey || held.altKey;
321
+ export function runKeydownDefault(el, key, held) {
322
+ if (shortcut(held)) {
323
+ return;
324
+ }
325
+ if (ENTER === key && !held.shiftKey) {
326
+ enterDefault(el);
327
+ return;
328
+ }
329
+ if (TAB === key) {
330
+ moveFocus(el, held.shiftKey ? -1 : 1);
331
+ }
332
+ }
333
+ export function runKeyupDefault(el, key, held) {
334
+ if (SPACE_KEY !== key || shortcut(held) || held.shiftKey) {
335
+ return;
336
+ }
337
+ if (el.matches(SPACE_ACTIVATES) && !isDisabled(el)) {
338
+ el.click();
339
+ }
340
+ }
341
+ function enterDefault(el) {
342
+ if (el.matches(ENTER_ACTIVATES)) {
343
+ if (!isDisabled(el)) {
344
+ el.click();
345
+ }
346
+ return;
347
+ }
348
+ const type = 'input' === el.localName ? (el.getAttribute('type') ?? 'text').toLowerCase() : '';
349
+ if (!IMPLICIT_SUBMIT_TYPES.has(type)) {
350
+ return;
351
+ }
352
+ const form = el.form;
353
+ form?.requestSubmit();
354
+ }
355
+ function isDisabled(el) {
356
+ return el.matches(':disabled');
357
+ }
358
+ function tabbable(el) {
359
+ if (isDisabled(el) || null !== el.closest('[inert], [hidden]')) {
360
+ return false;
361
+ }
362
+ const html = el;
363
+ if (html.tabIndex < 0) {
364
+ return false;
365
+ }
366
+ return 'hidden' !== el.type;
367
+ }
368
+ function moveFocus(from, step) {
369
+ const doc = from.ownerDocument;
370
+ const all = [...doc.querySelectorAll(FOCUSABLE)].filter(tabbable);
371
+ const positive = all.filter((el) => el.tabIndex > 0).sort((a, b) => a.tabIndex - b.tabIndex);
372
+ const order = [...positive, ...all.filter((el) => 0 === el.tabIndex)];
373
+ let at = order.indexOf(from);
374
+ if (at < 0) {
375
+ at = order.findIndex((el) => from.compareDocumentPosition(el) & Node.DOCUMENT_POSITION_FOLLOWING);
376
+ at = at < 0 ? order.length : at - (1 === step ? 1 : 0);
377
+ }
378
+ order[at + step]?.focus();
379
+ }
@@ -1,10 +1,10 @@
1
- import { ActionType, ActionWarning, clampHoldMs, clickInputProblem, DANGEROUS_ACTION_CONFIRM_ARG, ElementState, NATIVE_INPUT_ARG, SettleReason, } from '@reticlehq/core';
1
+ import { ACT_POINT_ARG, ActionType, ActionWarning, actPointProblem, boxFractionFromArgs, clampHoldMs, clickInputProblem, DRAG_TO_POINT_ARG, DANGEROUS_ACTION_CONFIRM_ARG, ElementState, NATIVE_INPUT_ARG, SettleReason, } from '@reticlehq/core';
2
2
  import { isHiddenNativeOption } from '../dom/query-finders.js';
3
- import { isReticleUi } from '../dom/dom-ignore.js';
3
+ import { isReticleUi, isUnobserved } from '../dom/dom-ignore.js';
4
4
  import { asSyntheticInput } from './synthetic/synthetic-input.js';
5
5
  import { echoRef, refs } from '../dom/addressing/refs.js';
6
6
  import { requiresDangerousConfirmation, assertOutwardAllowed, assertPaymentEntryAllowed, submitControlFor, } from './danger-context.js';
7
- import { assertEditable, assertNotRichText, isAutofillSafeTarget, setNativeValue, } from './value-input.js';
7
+ import { assertEditable, assertNotEditorTextArea, assertNotRichText, isAutofillSafeTarget, isRichText, setNativeValue, } from './value-input.js';
8
8
  import { getRole, getStates } from '../dom/a11y.js';
9
9
  import { isVisible } from '../dom/visibility.js';
10
10
  import { selectInCombobox } from './select-combobox.js';
@@ -14,7 +14,7 @@ import { asSettleBudget, nativeSetTimeout, settle } from '../timers/native/nativ
14
14
  import { publishSettledField, watchActedField } from '../observers/field.js';
15
15
  import { AppearedText } from './appeared-text.js';
16
16
  import { alreadyAtCheckedState, assertAriaToggleLanded, isAriaToggle } from './check-state.js';
17
- import { focusedOrDocument, isReflessDocumentPress, pressCode, pressKey, pressModifiers, pressKeys, holdKey, pressCombo, dispatchKeypress, closeModalOnEscape, keyboardEvent, } from './actions-press.js';
17
+ import { focusedOrDocument, isReflessDocumentPress, pressCode, pressKey, pressModifiers, pressKeys, holdKey, pressCombo, dispatchKeypress, closeModalOnEscape, keyboardEvent, runKeydownDefault, runKeyupDefault, SPACE_KEY, } from './actions-press.js';
18
18
  import { anchorOf, result, visibleCanvases, } from './action-report.js';
19
19
  function asString(value, fallback = '') {
20
20
  return 'string' === typeof value ? value : fallback;
@@ -34,6 +34,7 @@ const FILL_LIKE = new Set([
34
34
  ActionType.TYPE,
35
35
  ActionType.CLEAR,
36
36
  ActionType.SELECT,
37
+ ActionType.INSERT_TEXT,
37
38
  ]);
38
39
  const isFillLike = (action) => FILL_LIKE.has(action);
39
40
  const CLICK_LIKE = new Set([
@@ -72,7 +73,7 @@ function assertActionAllowed(el, action, args) {
72
73
  action === ActionType.UNCHECK ||
73
74
  action === ActionType.DRAG ||
74
75
  action === ActionType.SUBMIT ||
75
- (action === ActionType.PRESS && 'Enter' === pressKey(args));
76
+ (action === ActionType.PRESS && ('Enter' === pressKey(args) || SPACE_KEY === pressKey(args)));
76
77
  const dragTarget = action === ActionType.DRAG ? refs.resolve(dragTargetRef(args)) : null;
77
78
  const submitter = action === ActionType.PRESS && 'Enter' === pressKey(args) ? submitControlFor(el) : null;
78
79
  if (canTrigger) {
@@ -100,7 +101,7 @@ function optionValueFor(options, named) {
100
101
  const byLabel = options.filter((option) => option.label.trim() === named.trim());
101
102
  return 1 === byLabel.length ? (byLabel[0]?.value ?? named) : named;
102
103
  }
103
- import { NO_GEOMETRY, fireClickSequence, fireTapSequence, clickGeometry, firePointer, firePointerNonBubbling, dragElement, mouseEventFor, assertNotLeaving, clickInputOf, } from './actions-dom.js';
104
+ import { NO_GEOMETRY, fireClickSequence, fireTapSequence, clickGeometry, firePointer, firePointerNonBubbling, leavePreviousHover, dragElement, mouseEventFor, assertNotLeaving, scrollIntoViewNow, clickInputOf, coordsIn, } from './actions-dom.js';
104
105
  import { scrollFrom } from './scroll.js';
105
106
  import { clickHitsNoControl } from './click-target.js';
106
107
  function enabledOf(el) {
@@ -110,6 +111,9 @@ function valueOf(el) {
110
111
  if (isInput(el) || isTextArea(el) || isSelect(el)) {
111
112
  return el.value;
112
113
  }
114
+ if (isRichText(el)) {
115
+ return el.textContent ?? '';
116
+ }
113
117
  return undefined;
114
118
  }
115
119
  function activeRef(el) {
@@ -122,11 +126,11 @@ function activeRef(el) {
122
126
  async function dispatchFor(el, action, args) {
123
127
  if (ActionType.CLICK === action) {
124
128
  const hold = clampHoldMs(args['holdMs']);
125
- return await fireClickSequence(el, 0 === hold ? undefined : { ms: hold, sleep, now: () => Date.now() }, undefined, clickInputOf(args));
129
+ return await fireClickSequence(el, 0 === hold ? undefined : { ms: hold, sleep, now: () => Date.now() }, undefined, clickInputOf(args), boxFractionFromArgs(args, ACT_POINT_ARG));
126
130
  }
127
131
  if (ActionType.TAP === action) {
128
132
  const hold = clampHoldMs(args['holdMs']);
129
- return await fireTapSequence(el, 0 === hold ? undefined : { ms: hold, sleep, now: () => Date.now() });
133
+ return await fireTapSequence(el, 0 === hold ? undefined : { ms: hold, sleep, now: () => Date.now() }, boxFractionFromArgs(args, ACT_POINT_ARG));
130
134
  }
131
135
  return { prevented: await dispatchOther(el, action, args), heldMs: 0 };
132
136
  }
@@ -145,24 +149,26 @@ async function dispatchOther(el, action, args) {
145
149
  switch (action) {
146
150
  case ActionType.DBLCLICK: {
147
151
  const input = clickInputOf(args);
148
- await fireClickSequence(el, undefined, 1, input);
149
- await fireClickSequence(el, undefined, 2, input);
152
+ const point = boxFractionFromArgs(args, ACT_POINT_ARG);
153
+ await fireClickSequence(el, undefined, 1, input, point);
154
+ await fireClickSequence(el, undefined, 2, input, point);
150
155
  return !asSyntheticInput(() => el.dispatchEvent(mouseEventFor(el, 'dblclick', {
151
156
  bubbles: true,
152
157
  cancelable: true,
153
158
  detail: 2,
154
159
  ...input.modifiers,
160
+ ...coordsIn(el, point),
155
161
  })));
156
162
  }
157
163
  case ActionType.HOVER: {
158
- const doc = el.ownerDocument;
159
- const from = doc.activeElement ?? doc.body;
160
- firePointer(el, 'pointerover', from);
161
- el.dispatchEvent(mouseEventFor(el, 'mouseover', { bubbles: true, relatedTarget: from }));
162
- firePointerNonBubbling(el, 'pointerenter', from);
163
- el.dispatchEvent(mouseEventFor(el, 'mouseenter', { relatedTarget: from }));
164
- firePointer(el, 'pointermove', from);
165
- const moved = el.dispatchEvent(mouseEventFor(el, 'mousemove', { bubbles: true, cancelable: true }));
164
+ const coords = coordsIn(el, boxFractionFromArgs(args, ACT_POINT_ARG));
165
+ const from = leavePreviousHover(el);
166
+ firePointer(el, 'pointerover', from, coords);
167
+ el.dispatchEvent(mouseEventFor(el, 'mouseover', { bubbles: true, relatedTarget: from, ...coords }));
168
+ firePointerNonBubbling(el, 'pointerenter', from, coords);
169
+ el.dispatchEvent(mouseEventFor(el, 'mouseenter', { relatedTarget: from, ...coords }));
170
+ firePointer(el, 'pointermove', from, coords);
171
+ const moved = el.dispatchEvent(mouseEventFor(el, 'mousemove', { bubbles: true, cancelable: true, ...coords }));
166
172
  const holdMs = clampHoldMs(args['holdMs']);
167
173
  if (holdMs > 0) {
168
174
  await sleep(holdMs);
@@ -184,6 +190,7 @@ async function dispatchOther(el, action, args) {
184
190
  return false;
185
191
  }
186
192
  case ActionType.FILL:
193
+ assertNotEditorTextArea(el, 'fill');
187
194
  if (isInput(el) || isTextArea(el)) {
188
195
  if (typeof args['value'] !== 'string') {
189
196
  throw new Error("fill requires a string `value` — pass it nested, as args: { value: '…' }. " +
@@ -198,6 +205,7 @@ async function dispatchOther(el, action, args) {
198
205
  assertNotRichText(el, 'fill');
199
206
  throw new Error(`cannot fill a <${el.tagName.toLowerCase()}>`);
200
207
  case ActionType.TYPE:
208
+ assertNotEditorTextArea(el, 'type into');
201
209
  if (isInput(el) || isTextArea(el)) {
202
210
  if (typeof args['text'] !== 'string') {
203
211
  throw new Error("type requires a string `text` — pass it nested, as args: { text: '…' }.");
@@ -209,10 +217,15 @@ async function dispatchOther(el, action, args) {
209
217
  assertNotRichText(el, 'type into');
210
218
  throw new Error(`cannot type into a <${el.tagName.toLowerCase()}>`);
211
219
  case ActionType.CLEAR:
220
+ assertNotEditorTextArea(el, 'clear');
212
221
  if (isInput(el) || isTextArea(el)) {
213
222
  return setNativeValue(el, '');
214
223
  }
215
224
  throw new Error(`cannot clear a <${el.tagName.toLowerCase()}>`);
225
+ case ActionType.INSERT_TEXT: {
226
+ const { insertText } = await import('./insert-text.js');
227
+ return insertText(el, args['text']);
228
+ }
216
229
  case ActionType.SELECT:
217
230
  if (isSelect(el)) {
218
231
  if (typeof args['value'] !== 'string') {
@@ -281,16 +294,20 @@ async function dispatchOther(el, action, args) {
281
294
  closeModalOnEscape(el, key, down);
282
295
  if (down) {
283
296
  dispatchKeypress(el, key, code, mods);
297
+ runKeydownDefault(el, key, mods);
284
298
  }
285
299
  const hold = clampHoldMs(args['holdMs']);
286
300
  if (hold > 0) {
287
301
  await holdKey(el, key, code, mods, hold);
288
302
  }
289
303
  asSyntheticInput(() => el.dispatchEvent(keyboardEvent('keyup', { key, code, bubbles: true, ...mods })));
304
+ if (down) {
305
+ runKeyupDefault(el, key, mods);
306
+ }
290
307
  return !down;
291
308
  }
292
309
  case ActionType.SCROLL_INTO_VIEW:
293
- el.scrollIntoView();
310
+ scrollIntoViewNow(el);
294
311
  return false;
295
312
  case ActionType.ZOOM:
296
313
  throw new Error(ZOOM_NEEDS_REAL_BROWSER_MSG);
@@ -298,7 +315,7 @@ async function dispatchOther(el, action, args) {
298
315
  const dy = asFiniteNumber(args['dy']);
299
316
  const dx = asFiniteNumber(args['dx']);
300
317
  const fraction = asFiniteNumber(args['fraction']);
301
- scrollFrom(el, dy, fraction, dx);
318
+ scrollFrom(el, dy, fraction, dx, true === args['wheel']);
302
319
  return false;
303
320
  }
304
321
  case ActionType.UPLOAD: {
@@ -328,22 +345,35 @@ async function dispatchOther(el, action, args) {
328
345
  }
329
346
  case ActionType.DRAG: {
330
347
  const toRef = dragTargetRef(args);
348
+ const points = {
349
+ at: boxFractionFromArgs(args, ACT_POINT_ARG),
350
+ to: boxFractionFromArgs(args, DRAG_TO_POINT_ARG),
351
+ };
331
352
  if ('' === toRef) {
332
- return await dragElement(el, null, args['data']);
353
+ return await dragElement(el, null, args['data'], points, clickInputOf(args));
333
354
  }
334
355
  const resolved = refs.resolve(toRef);
335
356
  if (!isActionTarget(resolved)) {
336
357
  throw new Error(`drag target '${echoRef(toRef)}' did not resolve to an element — pass a ref from ` +
337
358
  'reticle_snapshot or reticle_query as args.toRef (alias: args.target)');
338
359
  }
339
- return await dragElement(el, resolved, args['data']);
360
+ return await dragElement(el, resolved, args['data'], points, clickInputOf(args));
340
361
  }
341
362
  default:
342
363
  throw new Error(`unknown action '${action}'`);
343
364
  }
344
365
  }
366
+ function isPageMutation(record) {
367
+ const target = record.target;
368
+ const el = target instanceof Element ? target : target.parentElement;
369
+ return null === el || !isUnobserved(el);
370
+ }
345
371
  export async function executeAction(ref, action, args = {}) {
346
372
  const named = 0 < ref.length;
373
+ const pointProblem = actPointProblem(action, args);
374
+ if (pointProblem !== undefined) {
375
+ throw new Error(`cannot ${action}: ${pointProblem}`);
376
+ }
347
377
  if (ActionType.CLICK === action || ActionType.DBLCLICK === action) {
348
378
  const problem = clickInputProblem(action, args);
349
379
  if (problem !== undefined) {
@@ -358,9 +388,19 @@ export async function executeAction(ref, action, args = {}) {
358
388
  assertNotLeaving(el);
359
389
  }
360
390
  const anchor = named ? anchorOf(el) : {};
391
+ const prevFocus = activeRef(el);
392
+ if ((ActionType.CLICK === action || ActionType.DBLCLICK === action) && !enabledOf(el)) {
393
+ const active = el.ownerDocument.activeElement;
394
+ if (active !== null &&
395
+ active !== el &&
396
+ active !== el.ownerDocument.body &&
397
+ isActionTarget(active)) {
398
+ active.blur();
399
+ await Promise.resolve();
400
+ }
401
+ }
361
402
  const visible = isVisible(el);
362
403
  const enabled = enabledOf(el);
363
- const prevFocus = activeRef(el);
364
404
  const valueBefore = valueOf(el);
365
405
  const actedField = watchActedField(el);
366
406
  const geometry = CLICK_LIKE.has(action) ? clickGeometry(el) : NO_GEOMETRY;
@@ -369,7 +409,7 @@ export async function executeAction(ref, action, args = {}) {
369
409
  let mutated = 0;
370
410
  const said = new AppearedText();
371
411
  const obs = new MutationObserver((records) => {
372
- mutated += records.length;
412
+ mutated += records.filter(isPageMutation).length;
373
413
  said.collect(records);
374
414
  });
375
415
  obs.observe(el.ownerDocument.documentElement, {
@@ -0,0 +1,3 @@
1
+ import type { ActionTarget } from '../dom/realm.js';
2
+ export declare function insertText(el: ActionTarget, text: unknown): boolean;
3
+ export declare function insertTextInto(el: ActionTarget, text: string): boolean;
@@ -0,0 +1,90 @@
1
+ import { isRichText } from './value-input.js';
2
+ export function insertText(el, text) {
3
+ if (typeof text !== 'string') {
4
+ throw new Error("insertText requires a string `text` — pass it nested, as args: { text: '…' }.");
5
+ }
6
+ if (!isRichText(el)) {
7
+ throw new Error(`cannot insertText into a <${el.tagName.toLowerCase()}> — insertText needs a contenteditable rich-text editor (Tiptap, ProseMirror, Quill), not this surface`);
8
+ }
9
+ if (hasEditContext(el)) {
10
+ throw new Error('cannot insertText into an EditContext editor (Monaco, CodeMirror 6): code that calls updateText does not fire textupdate, so the editor model would keep the old text. Use press for a key.');
11
+ }
12
+ return insertTextInto(el, text);
13
+ }
14
+ function hasEditContext(el) {
15
+ if (!('editContext' in el)) {
16
+ return false;
17
+ }
18
+ const surface = el;
19
+ return null !== surface.editContext && undefined !== surface.editContext;
20
+ }
21
+ export function insertTextInto(el, text) {
22
+ el.focus();
23
+ const selection = window.getSelection();
24
+ if (selection !== null && !selectionInside(selection, el)) {
25
+ const range = document.createRange();
26
+ range.selectNodeContents(el);
27
+ range.collapse(false);
28
+ selection.removeAllRanges();
29
+ selection.addRange(range);
30
+ }
31
+ const beforeInput = new InputEvent('beforeinput', {
32
+ inputType: 'insertText',
33
+ data: text,
34
+ bubbles: true,
35
+ cancelable: true,
36
+ });
37
+ if (!el.dispatchEvent(beforeInput)) {
38
+ return true;
39
+ }
40
+ if ('function' !== typeof el.ownerDocument.execCommand) {
41
+ insertAtCaret(el, text);
42
+ const input = new InputEvent('input', {
43
+ inputType: 'insertText',
44
+ data: text,
45
+ bubbles: true,
46
+ cancelable: true,
47
+ });
48
+ return !el.dispatchEvent(input);
49
+ }
50
+ let sawInput = false;
51
+ const recordInput = (event) => {
52
+ if ('input' === event.type) {
53
+ sawInput = true;
54
+ }
55
+ };
56
+ window.addEventListener('input', recordInput, false);
57
+ try {
58
+ if (el.ownerDocument.execCommand('insertText', false, text)) {
59
+ return false;
60
+ }
61
+ }
62
+ catch {
63
+ }
64
+ finally {
65
+ window.removeEventListener('input', recordInput, false);
66
+ }
67
+ if (sawInput) {
68
+ return false;
69
+ }
70
+ throw new Error('the editor did not accept the text: the native insertText command failed, so the editor model is unchanged. Check that the surface is editable and focused.');
71
+ }
72
+ function insertAtCaret(el, text) {
73
+ const selection = window.getSelection();
74
+ const range = selection !== null && 0 < selection.rangeCount ? selection.getRangeAt(0) : null;
75
+ if (range !== null && el.contains(range.commonAncestorContainer)) {
76
+ range.deleteContents();
77
+ const node = el.ownerDocument.createTextNode(text);
78
+ range.insertNode(node);
79
+ range.setStartAfter(node);
80
+ range.collapse(true);
81
+ selection?.removeAllRanges();
82
+ selection?.addRange(range);
83
+ return;
84
+ }
85
+ el.appendChild(el.ownerDocument.createTextNode(text));
86
+ }
87
+ function selectionInside(selection, el) {
88
+ const anchor = selection.anchorNode;
89
+ return anchor !== null && el.contains(anchor);
90
+ }
@@ -14,5 +14,5 @@ interface ScrollResult {
14
14
  export declare const SCROLL_FRACTION_RANGE_ERROR_PREFIX = "scroll fraction must be between 0 and 1, got ";
15
15
  export declare function fractionOutOfRangeMessage(fraction: number): string;
16
16
  export declare function scrollContainer(ref: string | undefined, dy: number | undefined, fraction?: number, dx?: number): ScrollResult;
17
- export declare function scrollFrom(base: Element | undefined, dy: number | undefined, fraction?: number, dx?: number): ScrollResult;
17
+ export declare function scrollFrom(base: Element | undefined, dy: number | undefined, fraction?: number, dx?: number, wheel?: boolean): ScrollResult;
18
18
  export {};
@@ -2,6 +2,7 @@ import { refs } from '../dom/addressing/refs.js';
2
2
  const FALLBACK_STEP_PX = 400;
3
3
  const VIEWPORT_FRACTION = 0.8;
4
4
  export const SCROLL_FRACTION_RANGE_ERROR_PREFIX = 'scroll fraction must be between 0 and 1, got ';
5
+ const SCROLL_WHEEL_FRACTION_ERROR = 'scroll with wheel: true cannot use fraction; pass dy or dx';
5
6
  export function fractionOutOfRangeMessage(fraction) {
6
7
  return `${SCROLL_FRACTION_RANGE_ERROR_PREFIX}${fraction}`;
7
8
  }
@@ -21,7 +22,7 @@ export function scrollContainer(ref, dy, fraction, dx) {
21
22
  const base = ref !== undefined ? refs.resolve(ref) : null;
22
23
  return scrollFrom(base instanceof Element ? base : undefined, dy, fraction, dx);
23
24
  }
24
- export function scrollFrom(base, dy, fraction, dx) {
25
+ export function scrollFrom(base, dy, fraction, dx, wheel) {
25
26
  if (fraction !== undefined && (fraction < 0 || fraction > 1 || Number.isNaN(fraction))) {
26
27
  throw new RangeError(fractionOutOfRangeMessage(fraction));
27
28
  }
@@ -31,6 +32,26 @@ export function scrollFrom(base, dy, fraction, dx) {
31
32
  : (document.scrollingElement ?? document.documentElement);
32
33
  const before = target.scrollTop;
33
34
  const beforeLeft = target.scrollLeft;
35
+ if (true === wheel) {
36
+ if (fraction !== undefined) {
37
+ throw new RangeError(SCROLL_WHEEL_FRACTION_ERROR);
38
+ }
39
+ const deltaY = dy ?? (Math.round(target.clientHeight * VIEWPORT_FRACTION) || FALLBACK_STEP_PX);
40
+ const deltaX = dx ?? 0;
41
+ const wheelTarget = base ?? target;
42
+ const rect = wheelTarget.getBoundingClientRect();
43
+ wheelTarget.dispatchEvent(new WheelEvent('wheel', {
44
+ deltaX,
45
+ deltaY,
46
+ deltaMode: WheelEvent.DOM_DELTA_PIXEL,
47
+ clientX: rect.width > 0 ? rect.left + rect.width / 2 : 0,
48
+ clientY: rect.height > 0 ? rect.top + rect.height / 2 : 0,
49
+ bubbles: true,
50
+ cancelable: true,
51
+ composed: true,
52
+ }));
53
+ return scrollResult(target, before, beforeLeft);
54
+ }
34
55
  if (fraction !== undefined) {
35
56
  target.scrollTop = Math.round(target.scrollHeight * fraction);
36
57
  }
@@ -44,6 +65,9 @@ export function scrollFrom(base, dy, fraction, dx) {
44
65
  }
45
66
  }
46
67
  target.dispatchEvent(new Event('scroll', { bubbles: false }));
68
+ return scrollResult(target, before, beforeLeft);
69
+ }
70
+ function scrollResult(target, before, beforeLeft) {
47
71
  const after = target.scrollTop;
48
72
  const afterLeft = target.scrollLeft;
49
73
  return {
@@ -1,5 +1,7 @@
1
1
  import { type ActionTarget } from '../dom/realm.js';
2
2
  export declare function isAutofillSafeTarget(el: Element, ua?: string): boolean;
3
3
  export declare function setNativeValue(el: HTMLInputElement | HTMLTextAreaElement, value: string): boolean;
4
+ export declare function isRichText(el: Element): boolean;
4
5
  export declare function assertNotRichText(el: ActionTarget, action: string): void;
6
+ export declare function assertNotEditorTextArea(el: ActionTarget, action: string): void;
5
7
  export declare function assertEditable(el: HTMLInputElement | HTMLTextAreaElement, action: string): void;
@@ -1,14 +1,16 @@
1
1
  import { captureValueSetter } from '../patching/capture-method.js';
2
- import { isHtmlElement, valuePrototypeOf } from '../dom/realm.js';
2
+ import { isHtmlElement, isTextArea, valuePrototypeOf } from '../dom/realm.js';
3
3
  const PASSWORD_AUTOCOMPLETE = /\b(current|new)-password\b/;
4
4
  const NOT_WEBKIT = /Chrome|Chromium|Edg\/|Firefox|Gecko\//;
5
5
  export function isAutofillSafeTarget(el, ua = navigator.userAgent) {
6
- if (!(el instanceof HTMLInputElement))
6
+ if (!(el instanceof HTMLInputElement)) {
7
7
  return false;
8
+ }
8
9
  const password = 'password' === el.type.toLowerCase() ||
9
10
  PASSWORD_AUTOCOMPLETE.test(el.getAttribute('autocomplete') ?? '');
10
- if (!password || NOT_WEBKIT.test(ua))
11
+ if (!password || NOT_WEBKIT.test(ua)) {
11
12
  return false;
13
+ }
12
14
  return 600 <= Number(/AppleWebKit\/(\d+)/.exec(ua)?.[1] ?? 0);
13
15
  }
14
16
  export function setNativeValue(el, value) {
@@ -24,21 +26,40 @@ export function setNativeValue(el, value) {
24
26
  el.dispatchEvent(new Event('change', { bubbles: true }));
25
27
  return !notPrevented;
26
28
  }
27
- function isRichText(el) {
29
+ export function isRichText(el) {
28
30
  const flag = el.getAttribute('contenteditable');
29
- if ((isHtmlElement(el) && el.isContentEditable) || (null !== flag && 'false' !== flag))
31
+ if ((isHtmlElement(el) && el.isContentEditable) || (null !== flag && 'false' !== flag)) {
30
32
  return true;
31
- if ('textbox' === el.getAttribute('role'))
33
+ }
34
+ if ('textbox' === el.getAttribute('role')) {
32
35
  return true;
33
- if (!('editContext' in el))
36
+ }
37
+ if (!('editContext' in el)) {
34
38
  return false;
39
+ }
35
40
  const surface = el;
36
41
  return null !== surface.editContext && undefined !== surface.editContext;
37
42
  }
43
+ const EDITOR_ROOT = '.monaco-editor, .CodeMirror, .cm-editor';
44
+ function isEditorTextArea(el) {
45
+ if (!isTextArea(el)) {
46
+ return false;
47
+ }
48
+ return 'textbox' === el.getAttribute('role') || null !== el.closest(EDITOR_ROOT);
49
+ }
50
+ function refuseEditor(action) {
51
+ throw new Error(`cannot ${action} a contenteditable or EditContext editor; use insertText, or press for a key`);
52
+ }
38
53
  export function assertNotRichText(el, action) {
39
- if (!isRichText(el))
54
+ if (!isRichText(el)) {
40
55
  return;
41
- throw new Error(`cannot ${action} a contenteditable or EditContext editor; use press or an input`);
56
+ }
57
+ refuseEditor(action);
58
+ }
59
+ export function assertNotEditorTextArea(el, action) {
60
+ if (isEditorTextArea(el)) {
61
+ refuseEditor(action);
62
+ }
42
63
  }
43
64
  export function assertEditable(el, action) {
44
65
  if (el.disabled) {