Files
blockly/core/bubbles/textinput_bubble.ts
Aaron Dodson d5f3d15726 feat: Add support for keyboard navigation to/from block comments. (#9227)
* refactor: Update `TextInputBubble` to use `CommentEditor` for text editing.

* feat: Designate `Bubble` as implementing `IFocusableNode`.

* feat: Dismiss focused bubbles on Escape.

* feat: Add support for keyboard navigation to block comments.

* fix: Scroll comment editors rather than zooming the workspace.

* chore: Add param to docstring.
2025-07-11 10:54:19 -07:00

297 lines
8.6 KiB
TypeScript

/**
* @license
* Copyright 2023 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {CommentEditor} from '../comments/comment_editor.js';
import * as Css from '../css.js';
import {getFocusManager} from '../focus_manager.js';
import type {IFocusableNode} from '../interfaces/i_focusable_node.js';
import type {IHasBubble} from '../interfaces/i_has_bubble.js';
import * as touch from '../touch.js';
import {browserEvents} from '../utils.js';
import {Coordinate} from '../utils/coordinate.js';
import * as dom from '../utils/dom.js';
import * as drag from '../utils/drag.js';
import {Rect} from '../utils/rect.js';
import {Size} from '../utils/size.js';
import {Svg} from '../utils/svg.js';
import {WorkspaceSvg} from '../workspace_svg.js';
import {Bubble} from './bubble.js';
/**
* A bubble that displays editable text. It can also be resized by the user.
* Used by the comment icon.
*/
export class TextInputBubble extends Bubble {
/** The group containing the lines indicating the bubble is resizable. */
private resizeGroup: SVGGElement;
/**
* Event data associated with the listener for pointer up events on the
* resize group.
*/
private resizePointerUpListener: browserEvents.Data | null = null;
/**
* Event data associated with the listener for pointer move events on the
* resize group.
*/
private resizePointerMoveListener: browserEvents.Data | null = null;
/** Functions listening for changes to the size of this bubble. */
private sizeChangeListeners: (() => void)[] = [];
/** Functions listening for changes to the location of this bubble. */
private locationChangeListeners: (() => void)[] = [];
/** The default size of this bubble, including borders. */
private readonly DEFAULT_SIZE = new Size(
160 + Bubble.DOUBLE_BORDER,
80 + Bubble.DOUBLE_BORDER,
);
/** The minimum size of this bubble, including borders. */
private readonly MIN_SIZE = new Size(
45 + Bubble.DOUBLE_BORDER,
20 + Bubble.DOUBLE_BORDER,
);
private editable = true;
/** View responsible for supporting text editing. */
private editor: CommentEditor;
/**
* @param workspace The workspace this bubble belongs to.
* @param anchor The anchor location of the thing this bubble is attached to.
* The tail of the bubble will point to this location.
* @param ownerRect An optional rect we don't want the bubble to overlap with
* when automatically positioning.
* @param owner The object that owns/hosts this bubble.
*/
constructor(
public readonly workspace: WorkspaceSvg,
protected anchor: Coordinate,
protected ownerRect?: Rect,
protected owner?: IHasBubble & IFocusableNode,
) {
super(workspace, anchor, ownerRect, undefined, owner);
dom.addClass(this.svgRoot, 'blocklyTextInputBubble');
this.editor = new CommentEditor(workspace, this.id, () => {
getFocusManager().focusNode(this);
});
this.contentContainer.appendChild(this.editor.getDom());
this.resizeGroup = this.createResizeHandle(this.svgRoot, workspace);
this.setSize(this.DEFAULT_SIZE, true);
}
/** @returns the text of this bubble. */
getText(): string {
return this.editor.getText();
}
/** Sets the text of this bubble. Calls change listeners. */
setText(text: string) {
this.editor.setText(text);
}
/** Sets whether or not the text in the bubble is editable. */
setEditable(editable: boolean) {
this.editable = editable;
this.editor.setEditable(editable);
}
/** Returns whether or not the text in the bubble is editable. */
isEditable(): boolean {
return this.editable;
}
/** Adds a change listener to be notified when this bubble's text changes. */
addTextChangeListener(listener: () => void) {
this.editor.addTextChangeListener(listener);
}
/** Adds a change listener to be notified when this bubble's size changes. */
addSizeChangeListener(listener: () => void) {
this.sizeChangeListeners.push(listener);
}
/** Adds a change listener to be notified when this bubble's location changes. */
addLocationChangeListener(listener: () => void) {
this.locationChangeListeners.push(listener);
}
/** Creates the resize handler elements and binds events to them. */
private createResizeHandle(
container: SVGGElement,
workspace: WorkspaceSvg,
): SVGGElement {
const resizeHandle = dom.createSvgElement(
Svg.IMAGE,
{
'class': 'blocklyResizeHandle',
'href': `${workspace.options.pathToMedia}resize-handle.svg`,
},
container,
);
browserEvents.conditionalBind(
resizeHandle,
'pointerdown',
this,
this.onResizePointerDown,
);
return resizeHandle;
}
/**
* Sets the size of this bubble, including the border.
*
* @param size Sets the size of this bubble, including the border.
* @param relayout If true, reposition the bubble from scratch so that it is
* optimally visible. If false, reposition it so it maintains the same
* position relative to the anchor.
*/
setSize(size: Size, relayout = false) {
size.width = Math.max(size.width, this.MIN_SIZE.width);
size.height = Math.max(size.height, this.MIN_SIZE.height);
const widthMinusBorder = size.width - Bubble.DOUBLE_BORDER;
const heightMinusBorder = size.height - Bubble.DOUBLE_BORDER;
this.editor.updateSize(
new Size(widthMinusBorder, heightMinusBorder),
new Size(0, 0),
);
this.editor.getDom().setAttribute('x', `${Bubble.DOUBLE_BORDER / 2}`);
this.editor.getDom().setAttribute('y', `${Bubble.DOUBLE_BORDER / 2}`);
this.resizeGroup.setAttribute('y', `${heightMinusBorder}`);
if (this.workspace.RTL) {
this.resizeGroup.setAttribute('x', `${-Bubble.DOUBLE_BORDER}`);
} else {
this.resizeGroup.setAttribute('x', `${widthMinusBorder}`);
}
super.setSize(size, relayout);
this.onSizeChange();
}
/** @returns the size of this bubble. */
getSize(): Size {
// Overridden to be public.
return super.getSize();
}
override moveDuringDrag(newLoc: Coordinate) {
super.moveDuringDrag(newLoc);
this.onLocationChange();
}
override setPositionRelativeToAnchor(left: number, top: number) {
super.setPositionRelativeToAnchor(left, top);
this.onLocationChange();
}
protected override positionByRect(rect = new Rect(0, 0, 0, 0)) {
super.positionByRect(rect);
this.onLocationChange();
}
/** Handles mouse down events on the resize target. */
private onResizePointerDown(e: PointerEvent) {
this.bringToFront();
if (browserEvents.isRightButton(e)) {
e.stopPropagation();
return;
}
drag.start(
this.workspace,
e,
new Coordinate(
this.workspace.RTL ? -this.getSize().width : this.getSize().width,
this.getSize().height,
),
);
this.resizePointerUpListener = browserEvents.conditionalBind(
document,
'pointerup',
this,
this.onResizePointerUp,
);
this.resizePointerMoveListener = browserEvents.conditionalBind(
document,
'pointermove',
this,
this.onResizePointerMove,
);
this.workspace.hideChaff();
// This event has been handled. No need to bubble up to the document.
e.stopPropagation();
}
/** Handles pointer up events on the resize target. */
private onResizePointerUp(_e: PointerEvent) {
touch.clearTouchIdentifier();
if (this.resizePointerUpListener) {
browserEvents.unbind(this.resizePointerUpListener);
this.resizePointerUpListener = null;
}
if (this.resizePointerMoveListener) {
browserEvents.unbind(this.resizePointerMoveListener);
this.resizePointerMoveListener = null;
}
}
/** Handles pointer move events on the resize target. */
private onResizePointerMove(e: PointerEvent) {
const delta = drag.move(this.workspace, e);
this.setSize(
new Size(this.workspace.RTL ? -delta.x : delta.x, delta.y),
false,
);
this.onSizeChange();
}
/** Handles a size change event for the text area. Calls event listeners. */
private onSizeChange() {
for (const listener of this.sizeChangeListeners) {
listener();
}
}
/** Handles a location change event for the text area. Calls event listeners. */
private onLocationChange() {
for (const listener of this.locationChangeListeners) {
listener();
}
}
/**
* Returns the text editor component of this bubble.
*
* @internal
*/
getEditor() {
return this.editor;
}
}
Css.register(`
.blocklyTextInputBubble .blocklyTextarea {
background-color: var(--commentFillColour);
border: 0;
box-sizing: border-box;
display: block;
outline: 0;
padding: 5px;
resize: none;
width: 100%;
height: 100%;
}
`);