blob: 0e3f4a7fc8b106e489d0c72931d72441c282ebf1 [file] [log] [blame]
// Copyright 2013 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'dart:html' as html;
import 'package:ui/ui.dart' as ui;
import '../browser_detection.dart';
import '../platform_dispatcher.dart';
import '../text_editing/text_editing.dart';
import 'semantics.dart';
/// Text editing used by accesibility mode.
///
/// [SemanticsTextEditingStrategy] assumes the caller will own the creation,
/// insertion and disposal of the DOM element. Due to this
/// [initializeElementPlacement], [initializeTextEditing] and
/// [disable] strategies are handled differently.
///
/// This class is still responsible for hooking up the DOM element with the
/// [HybridTextEditing] instance so that changes are communicated to Flutter.
class SemanticsTextEditingStrategy extends DefaultTextEditingStrategy {
/// Initializes the [SemanticsTextEditingStrategy] singleton.
///
/// This method must be called prior to accessing [instance].
static SemanticsTextEditingStrategy ensureInitialized(HybridTextEditing owner) {
if (_instance != null && instance.owner == owner) {
return instance;
}
return _instance = SemanticsTextEditingStrategy(owner);
}
/// The [SemanticsTextEditingStrategy] singleton.
static SemanticsTextEditingStrategy get instance => _instance!;
static SemanticsTextEditingStrategy? _instance;
/// Creates a [SemanticsTextEditingStrategy] that eagerly instantiates
/// [domElement] so the caller can insert it before calling
/// [SemanticsTextEditingStrategy.enable].
SemanticsTextEditingStrategy(HybridTextEditing owner)
: super(owner);
/// The text field whose DOM element is currently used for editing.
///
/// If this field is null, no editing takes place.
TextField? activeTextField;
/// Current input configuration supplied by the "flutter/textinput" channel.
InputConfiguration? inputConfig;
/// The semantics implementation does not operate on DOM nodes, but only
/// remembers the config and callbacks. This is because the DOM nodes are
/// supplied in the semantics update and enabled by [activate].
@override
void enable(
InputConfiguration inputConfig, {
required OnChangeCallback onChange,
required OnActionCallback onAction,
}) {
this.inputConfig = inputConfig;
this.onChange = onChange;
this.onAction = onAction;
}
/// Attaches the DOM element owned by [textField] to the text editing
/// strategy.
///
/// This method must be called after [enable] to name sure that [inputConfig],
/// [onChange], and [onAction] are not null.
void activate(TextField textField) {
assert(
inputConfig != null && onChange != null && onAction != null,
'"enable" should be called before "enableFromSemantics" and initialize input configuration',
);
if (activeTextField == textField) {
// The specified field is already active. Skip.
return;
} else if (activeTextField != null) {
// Another text field is currently active. Deactivate it before switching.
disable();
}
activeTextField = textField;
domElement = textField.editableElement;
_syncStyle();
super.enable(inputConfig!, onChange: onChange!, onAction: onAction!);
}
/// Detaches the DOM element owned by [textField] from this text editing
/// strategy.
///
/// Typically at this point the element loses focus (blurs) and stops being
/// used for editing.
void deactivate(TextField textField) {
if (activeTextField == textField) {
disable();
}
}
@override
void disable() {
// We don't want to remove the DOM element because the caller is responsible
// for that. However we still want to stop editing, cleanup the handlers.
if (!isEnabled) {
return;
}
isEnabled = false;
style = null;
geometry = null;
for (int i = 0; i < subscriptions.length; i++) {
subscriptions[i].cancel();
}
subscriptions.clear();
lastEditingState = null;
// If the text element still has focus, remove focus from the editable
// element to cause the on-screen keyboard, if any, to hide (e.g. on iOS,
// Android).
// Otherwise, the keyboard stays on screen even when the user navigates to
// a different screen (e.g. by hitting the "back" button).
domElement?.blur();
domElement = null;
activeTextField = null;
_queuedStyle = null;
}
@override
void addEventHandlers() {
if (inputConfiguration.autofillGroup != null) {
subscriptions
.addAll(inputConfiguration.autofillGroup!.addInputEventListeners());
}
// Subscribe to text and selection changes.
subscriptions.add(activeDomElement.onInput.listen(handleChange));
subscriptions.add(activeDomElement.onKeyDown.listen(maybeSendAction));
subscriptions.add(html.document.onSelectionChange.listen(handleChange));
preventDefaultForMouseEvents();
}
@override
void initializeTextEditing(InputConfiguration inputConfig,
{OnChangeCallback? onChange, OnActionCallback? onAction}) {
isEnabled = true;
inputConfiguration = inputConfig;
onChange = onChange;
onAction = onAction;
applyConfiguration(inputConfig);
}
@override
void placeElement() {
// If this text editing element is a part of an autofill group.
if (hasAutofillGroup) {
placeForm();
}
activeDomElement.focus();
}
@override
void initializeElementPlacement() {
// Element placement is done by [TextField].
}
@override
void placeForm() {
}
@override
void updateElementPlacement(EditableTextGeometry textGeometry) {
// Element placement is done by [TextField].
}
EditableTextStyle? _queuedStyle;
@override
void updateElementStyle(EditableTextStyle textStyle) {
_queuedStyle = textStyle;
_syncStyle();
}
/// Apply style to the element, if both style and element are available.
///
/// Because style is supplied by the "flutter/textinput" channel and the DOM
/// element is supplied by the semantics tree, the existence of both at the
/// same time is not guaranteed.
void _syncStyle() {
if (_queuedStyle == null || domElement == null) {
return;
}
super.updateElementStyle(_queuedStyle!);
}
}
/// Manages semantics objects that represent editable text fields.
///
/// This role is implemented via a content-editable HTML element. This role does
/// not proactively switch modes depending on the current
/// [EngineSemanticsOwner.gestureMode]. However, in Chrome on Android it ignores
/// browser gestures when in pointer mode. In Safari on iOS touch events are
/// used to detect text box invocation. This is because Safari issues touch
/// events even when Voiceover is enabled.
class TextField extends RoleManager {
TextField(SemanticsObject semanticsObject)
: super(Role.textField, semanticsObject) {
editableElement =
semanticsObject.hasFlag(ui.SemanticsFlag.isMultiline)
? html.TextAreaElement()
: html.InputElement();
_setupDomElement();
}
/// The element used for editing, e.g. `<input>`, `<textarea>`.
late final html.HtmlElement editableElement;
void _setupDomElement() {
// On iOS, even though the semantic text field is transparent, the cursor
// and text highlighting are still visible. The cursor and text selection
// are made invisible by CSS in [DomRenderer.reset].
// But there's one more case where iOS highlights text. That's when there's
// and autocorrect suggestion. To disable that, we have to do the following:
editableElement
..spellcheck = false
..setAttribute('autocorrect', 'off')
..setAttribute('autocomplete', 'off')
..setAttribute('data-semantics-role', 'text-field');
editableElement.style
..position = 'absolute'
// `top` and `left` are intentionally set to zero here.
//
// The text field would live inside a `<flt-semantics>` which should
// already be positioned using semantics.rect.
//
// See also:
//
// * [SemanticsObject.recomputePositionAndSize], which sets the position
// and size of the parent `<flt-semantics>` element.
..top = '0'
..left = '0'
..width = '${semanticsObject.rect!.width}px'
..height = '${semanticsObject.rect!.height}px';
semanticsObject.element.append(editableElement);
switch (browserEngine) {
case BrowserEngine.blink:
case BrowserEngine.samsung:
case BrowserEngine.edge:
case BrowserEngine.ie11:
case BrowserEngine.firefox:
case BrowserEngine.unknown:
_initializeForBlink();
break;
case BrowserEngine.webkit:
_initializeForWebkit();
break;
}
}
/// Chrome on Android reports text field activation as a "click" event.
///
/// When in browser gesture mode, the focus is forwarded to the framework as
/// a tap to initialize editing.
void _initializeForBlink() {
editableElement.addEventListener('focus', (html.Event event) {
if (semanticsObject.owner.gestureMode != GestureMode.browserGestures) {
return;
}
EnginePlatformDispatcher.instance.invokeOnSemanticsAction(
semanticsObject.id, ui.SemanticsAction.tap, null);
});
}
/// Safari on iOS reports text field activation via touch events.
///
/// This emulates a tap recognizer to detect the activation. Because touch
/// events are present regardless of whether accessibility is enabled or not,
/// this mode is always enabled.
void _initializeForWebkit() {
// Safari for desktop is also initialized as the other browsers.
if (operatingSystem == OperatingSystem.macOs) {
_initializeForBlink();
return;
}
num? lastTouchStartOffsetX;
num? lastTouchStartOffsetY;
editableElement.addEventListener('touchstart', (html.Event event) {
final html.TouchEvent touchEvent = event as html.TouchEvent;
lastTouchStartOffsetX = touchEvent.changedTouches!.last.client.x;
lastTouchStartOffsetY = touchEvent.changedTouches!.last.client.y;
}, true);
editableElement.addEventListener('touchend', (html.Event event) {
final html.TouchEvent touchEvent = event as html.TouchEvent;
if (lastTouchStartOffsetX != null) {
assert(lastTouchStartOffsetY != null);
final num offsetX = touchEvent.changedTouches!.last.client.x;
final num offsetY = touchEvent.changedTouches!.last.client.y;
// This should match the similar constant defined in:
//
// lib/src/gestures/constants.dart
//
// The value is pre-squared so we have to do less math at runtime.
const double kTouchSlop = 18.0 * 18.0; // Logical pixels squared
if (offsetX * offsetX + offsetY * offsetY < kTouchSlop) {
// Recognize it as a tap that requires a keyboard.
EnginePlatformDispatcher.instance.invokeOnSemanticsAction(
semanticsObject.id, ui.SemanticsAction.tap, null);
}
} else {
assert(lastTouchStartOffsetY == null);
}
lastTouchStartOffsetX = null;
lastTouchStartOffsetY = null;
}, true);
}
bool _hasFocused = false;
@override
void update() {
// The user is editing the semantic text field directly, so there's no need
// to do any update here.
if (semanticsObject.hasLabel) {
editableElement.setAttribute(
'aria-label',
semanticsObject.label!,
);
} else {
editableElement.removeAttribute('aria-label');
}
editableElement.style
..width = '${semanticsObject.rect!.width}px'
..height = '${semanticsObject.rect!.height}px';
// Whether we should request that the browser shift focus to the editable
// element, so that both the framework and the browser agree on what's
// currently focused.
bool needsDomFocusRequest = false;
final EditingState editingState = EditingState(
text: semanticsObject.value,
baseOffset: semanticsObject.textSelectionBase,
extentOffset: semanticsObject.textSelectionExtent,
);
if (semanticsObject.hasFocus) {
if (!_hasFocused) {
_hasFocused = true;
SemanticsTextEditingStrategy.instance.activate(this);
needsDomFocusRequest = true;
}
if (html.document.activeElement != editableElement) {
needsDomFocusRequest = true;
}
// Focused elements should have full text editing state applied.
SemanticsTextEditingStrategy.instance.setEditingState(editingState);
} else if (_hasFocused) {
SemanticsTextEditingStrategy.instance.deactivate(this);
// Only apply text, because this node is not focused.
editingState.applyTextToDomElement(editableElement);
if (_hasFocused && html.document.activeElement == editableElement) {
// Unlike `editableElement.focus()` we don't need to schedule `blur`
// post-update because `document.activeElement` implies that the
// element is already attached to the DOM. If it's not, it can't
// possibly be focused and therefore there's no need to blur.
editableElement.blur();
}
_hasFocused = false;
}
if (needsDomFocusRequest) {
// Schedule focus post-update to make sure the element is attached to
// the document. Otherwise focus() has no effect.
semanticsObject.owner.addOneTimePostUpdateCallback(() {
if (html.document.activeElement != editableElement) {
editableElement.focus();
}
});
}
}
@override
void dispose() {
editableElement.remove();
SemanticsTextEditingStrategy.instance.deactivate(this);
}
}