chore(recorder): use designMode for text selection tool (#27936)

This allows us to barely support shadow dom, compared to no support at
all.
This commit is contained in:
Dmitry Gozman 2023-11-02 13:47:53 -07:00 committed by GitHub
parent c59483c5fb
commit a7fd515626
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

View File

@ -34,8 +34,10 @@ interface RecorderDelegate {
interface RecorderTool { interface RecorderTool {
cursor(): string; cursor(): string;
enable?(): void;
disable?(): void; disable?(): void;
onClick?(event: MouseEvent): void; onClick?(event: MouseEvent): void;
onDragStart?(event: DragEvent): void;
onInput?(event: Event): void; onInput?(event: Event): void;
onKeyDown?(event: KeyboardEvent): void; onKeyDown?(event: KeyboardEvent): void;
onKeyUp?(event: KeyboardEvent): void; onKeyUp?(event: KeyboardEvent): void;
@ -414,7 +416,8 @@ class RecordActionTool implements RecorderTool {
} }
class TextAssertionTool implements RecorderTool { class TextAssertionTool implements RecorderTool {
private _selectionModel: SelectionModel | null = null; private _selectionHighlight: HighlightModel | null = null;
private _inputIsFocused = false;
constructor(private _recorder: Recorder) { constructor(private _recorder: Recorder) {
} }
@ -423,18 +426,21 @@ class TextAssertionTool implements RecorderTool {
return 'text'; return 'text';
} }
enable() {
this._recorder.injectedScript.document.designMode = 'on';
}
disable() { disable() {
this._selectionModel = null; this._recorder.injectedScript.document.designMode = 'off';
this._syncDocumentSelection(); this._selectionHighlight = null;
this._inputIsFocused = false;
} }
onClick(event: MouseEvent) { onClick(event: MouseEvent) {
consumeEvent(event); consumeEvent(event);
if (event.detail !== 1 || this._getSelectionText())
return;
const target = this._recorder.deepEventTarget(event);
if (['INPUT', 'TEXTAREA'].includes(target.nodeName) || target.isContentEditable) { const target = this._recorder.deepEventTarget(event);
if (event.detail === 1 && ['INPUT', 'TEXTAREA'].includes(target.nodeName)) {
const highlight = generateSelector(this._recorder.injectedScript, target, { testIdAttributeName: this._recorder.state.testIdAttributeName }); const highlight = generateSelector(this._recorder.injectedScript, target, { testIdAttributeName: this._recorder.state.testIdAttributeName });
if (target.nodeName === 'INPUT' && ['checkbox', 'radio'].includes((target as HTMLInputElement).type.toLowerCase())) { if (target.nodeName === 'INPUT' && ['checkbox', 'radio'].includes((target as HTMLInputElement).type.toLowerCase())) {
this._recorder.delegate.recordAction?.({ this._recorder.delegate.recordAction?.({
@ -452,71 +458,72 @@ class TextAssertionTool implements RecorderTool {
value: target.isContentEditable ? target.innerText : (target as HTMLInputElement).value, value: target.isContentEditable ? target.innerText : (target as HTMLInputElement).value,
}); });
} }
this._recorder.updateHighlight(highlight, true, '#6fdcbd38');
return; return;
} }
const text = target ? elementText(new Map(), target).full : ''; const selection = this._recorder.document.getSelection();
if (text) { if (event.detail === 1 && selection && !selection.toString()) {
this._selectionModel = { anchor: { node: target, offset: 0 }, focus: { node: target, offset: target.childNodes.length }, highlight: null }; selection.selectAllChildren(target);
this._syncDocumentSelection();
this._updateSelectionHighlight(); this._updateSelectionHighlight();
} }
} }
onMouseDown(event: MouseEvent) { onMouseDown(event: MouseEvent) {
consumeEvent(event);
const target = this._recorder.deepEventTarget(event); const target = this._recorder.deepEventTarget(event);
if (['INPUT', 'TEXTAREA'].includes(target.nodeName) || target.isContentEditable) { if (['INPUT', 'TEXTAREA'].includes(target.nodeName)) {
this._selectionModel = null; this._recorder.injectedScript.window.getSelection()?.empty();
this._syncDocumentSelection(); this._selectionHighlight = generateSelector(this._recorder.injectedScript, target, { testIdAttributeName: this._recorder.state.testIdAttributeName });
const highlight = generateSelector(this._recorder.injectedScript, target, { testIdAttributeName: this._recorder.state.testIdAttributeName }); this._inputIsFocused = true;
this._recorder.updateHighlight(highlight, true, '#6fdcbd38'); this._recorder.updateHighlight(this._selectionHighlight, true, '#6fdcbd38');
consumeEvent(event);
return; return;
} }
const pos = this._selectionPosition(event);
if (pos && event.detail <= 1) { this._inputIsFocused = false;
this._selectionModel = { anchor: pos, focus: pos, highlight: null }; this._updateSelectionHighlight();
this._syncDocumentSelection();
this._updateSelectionHighlight();
}
} }
onMouseUp(event: MouseEvent) { onMouseUp(event: MouseEvent) {
consumeEvent(event); this._updateSelectionHighlight();
} }
onMouseMove(event: MouseEvent) { onMouseMove(event: MouseEvent) {
this._updateSelectionHighlight();
}
onDragStart(event: DragEvent) {
consumeEvent(event); consumeEvent(event);
if (!event.buttons)
return;
const pos = this._selectionPosition(event);
if (pos && this._selectionModel) {
this._selectionModel.focus = pos;
this._syncDocumentSelection();
this._updateSelectionHighlight();
}
} }
onKeyDown(event: KeyboardEvent) { onKeyDown(event: KeyboardEvent) {
consumeEvent(event);
if (event.key === 'Escape') { if (event.key === 'Escape') {
this._selectionModel = null; this._resetSelectionAndHighlight();
this._syncDocumentSelection(); consumeEvent(event);
this._recorder.updateHighlight(null, false); return;
} }
if (event.key === 'Enter' && this._selectionModel?.highlight) {
const text = this._getSelectionText(); if (event.key === 'Enter') {
this._recorder.delegate.recordAction?.({ const selection = this._recorder.document.getSelection();
name: 'assertText', if (selection && this._selectionHighlight) {
selector: this._selectionModel.highlight.selector, const selectedText = normalizeWhiteSpace(selection.toString());
signals: [], const fullText = normalizeWhiteSpace(elementText(new Map(), this._selectionHighlight.elements[0]).full);
text, this._recorder.delegate.recordAction?.({
substring: normalizeWhiteSpace(elementText(new Map(), this._selectionModel.highlight.elements[0]).full) !== text, name: 'assertText',
}); selector: this._selectionHighlight.selector,
this._selectionModel = null; signals: [],
this._syncDocumentSelection(); text: selectedText,
this._recorder.updateHighlight(null, false); substring: fullText !== selectedText,
});
this._resetSelectionAndHighlight();
}
consumeEvent(event);
return;
}
// Only allow keys that control text selection.
if (!['ArrowLeft', 'ArrowUp', 'ArrowRight', 'ArrowDown', 'Shift', 'Control', 'Meta', 'Alt', 'AltGraph'].includes(event.key)) {
consumeEvent(event);
return;
} }
} }
@ -525,50 +532,30 @@ class TextAssertionTool implements RecorderTool {
} }
onScroll(event: Event) { onScroll(event: Event) {
this._recorder.updateHighlight(this._selectionModel ? this._selectionModel.highlight : null, false, '#6fdcbd38'); this._recorder.updateHighlight(this._selectionHighlight, false, '#6fdcbd38');
} }
private _selectionPosition(event: MouseEvent) { private _resetSelectionAndHighlight() {
if ((this._recorder.document as any).caretPositionFromPoint) { this._selectionHighlight = null;
const range = (this._recorder.document as any).caretPositionFromPoint(event.clientX, event.clientY); this._recorder.injectedScript.window.getSelection()?.empty();
return range ? { node: range.offsetNode, offset: range.offset } : undefined; this._recorder.updateHighlight(null, false);
}
if ((this._recorder.document as any).caretRangeFromPoint) {
const range = this._recorder.document.caretRangeFromPoint(event.clientX, event.clientY);
return range ? { node: range.startContainer, offset: range.startOffset } : undefined;
}
}
private _syncDocumentSelection() {
if (!this._selectionModel) {
this._recorder.document.getSelection()?.empty();
return;
}
this._recorder.document.getSelection()?.setBaseAndExtent(
this._selectionModel.anchor.node,
this._selectionModel.anchor.offset,
this._selectionModel.focus.node,
this._selectionModel.focus.offset,
);
}
private _getSelectionText() {
this._syncDocumentSelection();
// TODO: use elementText() passing |range=selection.getRangeAt(0)| for proper text.
return normalizeWhiteSpace(this._recorder.document.getSelection()?.toString() || '');
} }
private _updateSelectionHighlight() { private _updateSelectionHighlight() {
if (!this._selectionModel) if (this._inputIsFocused)
return; return;
const focusElement = enclosingElement(this._selectionModel.focus.node); const selection = this._recorder.document.getSelection();
let lcaElement = focusElement ? enclosingElement(this._selectionModel.anchor.node) : undefined; let highlight: HighlightModel | null = null;
while (lcaElement && !isInsideScope(lcaElement, focusElement)) if (selection && selection.focusNode && selection.anchorNode && selection.toString()) {
lcaElement = parentElementOrShadowHost(lcaElement); const focusElement = enclosingElement(selection.focusNode);
const highlight = lcaElement ? generateSelector(this._recorder.injectedScript, lcaElement, { testIdAttributeName: this._recorder.state.testIdAttributeName, forTextExpect: true }) : null; let lcaElement = focusElement ? enclosingElement(selection.anchorNode) : undefined;
if (highlight?.selector === this._selectionModel.highlight?.selector) while (lcaElement && !isInsideScope(lcaElement, focusElement))
lcaElement = parentElementOrShadowHost(lcaElement);
highlight = lcaElement ? generateSelector(this._recorder.injectedScript, lcaElement, { testIdAttributeName: this._recorder.state.testIdAttributeName, forTextExpect: true }) : null;
}
if (highlight?.selector === this._selectionHighlight?.selector)
return; return;
this._selectionModel.highlight = highlight; this._selectionHighlight = highlight;
this._recorder.updateHighlight(highlight, true, '#6fdcbd38'); this._recorder.updateHighlight(highlight, true, '#6fdcbd38');
} }
} }
@ -794,6 +781,7 @@ export class Recorder {
this._listeners = [ this._listeners = [
addEventListener(this.document, 'click', event => this._onClick(event as MouseEvent), true), addEventListener(this.document, 'click', event => this._onClick(event as MouseEvent), true),
addEventListener(this.document, 'auxclick', event => this._onClick(event as MouseEvent), true), addEventListener(this.document, 'auxclick', event => this._onClick(event as MouseEvent), true),
addEventListener(this.document, 'dragstart', event => this._onDragStart(event as DragEvent), true),
addEventListener(this.document, 'input', event => this._onInput(event), true), addEventListener(this.document, 'input', event => this._onInput(event), true),
addEventListener(this.document, 'keydown', event => this._onKeyDown(event as KeyboardEvent), true), addEventListener(this.document, 'keydown', event => this._onKeyDown(event as KeyboardEvent), true),
addEventListener(this.document, 'keyup', event => this._onKeyUp(event as KeyboardEvent), true), addEventListener(this.document, 'keyup', event => this._onKeyUp(event as KeyboardEvent), true),
@ -816,7 +804,8 @@ export class Recorder {
this._currentTool.disable?.(); this._currentTool.disable?.();
this.clearHighlight(); this.clearHighlight();
this._currentTool = newTool; this._currentTool = newTool;
this.injectedScript.document.body.setAttribute('data-pw-cursor', newTool.cursor()); this._currentTool.enable?.();
this.injectedScript.document.body?.setAttribute('data-pw-cursor', newTool.cursor());
} }
setUIState(state: UIState, delegate: RecorderDelegate) { setUIState(state: UIState, delegate: RecorderDelegate) {
@ -860,6 +849,14 @@ export class Recorder {
this._currentTool.onClick?.(event); this._currentTool.onClick?.(event);
} }
private _onDragStart(event: DragEvent) {
if (!event.isTrusted)
return;
if (this._ignoreOverlayEvent(event))
return;
this._currentTool.onDragStart?.(event);
}
private _onMouseDown(event: MouseEvent) { private _onMouseDown(event: MouseEvent) {
if (!event.isTrusted) if (!event.isTrusted)
return; return;
@ -993,12 +990,6 @@ type HighlightModel = {
elements: Element[]; elements: Element[];
}; };
type SelectionModel = {
anchor: { node: Node, offset: number };
focus: { node: Node, offset: number };
highlight: HighlightModel | null;
};
function asCheckbox(node: Node | null): HTMLInputElement | null { function asCheckbox(node: Node | null): HTMLInputElement | null {
if (!node || node.nodeName !== 'INPUT') if (!node || node.nodeName !== 'INPUT')
return null; return null;