Files
blockly/core/comments/comment_editor.ts
Aaron Dodson e3d17becbd fix: Improve workspace comment keyboard navigation behavior. (#9211)
* fix: Prevent tabbing into workspace comments.

* fix: Focus workspace comments when navigating to them using the keyboard.
2025-07-07 15:28:54 -07:00

192 lines
5.6 KiB
TypeScript

/**
* @license
* Copyright 2024 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import * as browserEvents from '../browser_events.js';
import {getFocusManager} from '../focus_manager.js';
import {IFocusableNode} from '../interfaces/i_focusable_node.js';
import {IFocusableTree} from '../interfaces/i_focusable_tree.js';
import * as touch from '../touch.js';
import * as dom from '../utils/dom.js';
import {Size} from '../utils/size.js';
import {Svg} from '../utils/svg.js';
import {WorkspaceSvg} from '../workspace_svg.js';
/**
* String added to the ID of a workspace comment to identify
* the focusable node for the comment editor.
*/
export const COMMENT_EDITOR_FOCUS_IDENTIFIER = '_comment_textarea_';
/** The part of a comment that can be typed into. */
export class CommentEditor implements IFocusableNode {
id?: string;
/** The foreignObject containing the HTML text area. */
private foreignObject: SVGForeignObjectElement;
/** The text area where the user can type. */
private textArea: HTMLTextAreaElement;
/** Listeners for changes to text. */
private textChangeListeners: Array<
(oldText: string, newText: string) => void
> = [];
/** The current text of the comment. Updates on text area change. */
private text: string = '';
constructor(
public workspace: WorkspaceSvg,
commentId?: string,
private onFinishEditing?: () => void,
) {
this.foreignObject = dom.createSvgElement(Svg.FOREIGNOBJECT, {
'class': 'blocklyCommentForeignObject',
});
const body = document.createElementNS(dom.HTML_NS, 'body');
body.setAttribute('xmlns', dom.HTML_NS);
body.className = 'blocklyMinimalBody';
this.textArea = document.createElementNS(
dom.HTML_NS,
'textarea',
) as HTMLTextAreaElement;
this.textArea.setAttribute('tabindex', '-1');
dom.addClass(this.textArea, 'blocklyCommentText');
dom.addClass(this.textArea, 'blocklyTextarea');
dom.addClass(this.textArea, 'blocklyText');
body.appendChild(this.textArea);
this.foreignObject.appendChild(body);
if (commentId) {
this.id = commentId + COMMENT_EDITOR_FOCUS_IDENTIFIER;
this.textArea.setAttribute('id', this.id);
}
// Register browser event listeners for the user typing in the textarea.
browserEvents.conditionalBind(
this.textArea,
'change',
this,
this.onTextChange,
);
// Register listener for pointerdown to focus the textarea.
browserEvents.conditionalBind(
this.textArea,
'pointerdown',
this,
(e: PointerEvent) => {
// don't allow this event to bubble up
// and steal focus away from the editor/comment.
e.stopPropagation();
getFocusManager().focusNode(this);
touch.clearTouchIdentifier();
},
);
// Register listener for keydown events that would finish editing.
browserEvents.conditionalBind(
this.textArea,
'keydown',
this,
this.handleKeyDown,
);
}
/** Gets the dom structure for this comment editor. */
getDom(): SVGForeignObjectElement {
return this.foreignObject;
}
/** Gets the current text of the comment. */
getText(): string {
return this.text;
}
/** Sets the current text of the comment and fires change listeners. */
setText(text: string) {
this.textArea.value = text;
this.onTextChange();
}
/**
* Triggers listeners when the text of the comment changes, either
* programmatically or manually by the user.
*/
private onTextChange() {
const oldText = this.text;
this.text = this.textArea.value;
// Loop through listeners backwards in case they remove themselves.
for (let i = this.textChangeListeners.length - 1; i >= 0; i--) {
this.textChangeListeners[i](oldText, this.text);
}
}
/**
* Do something when the user indicates they've finished editing.
*
* @param e Keyboard event.
*/
private handleKeyDown(e: KeyboardEvent) {
if (e.key === 'Escape' || (e.key === 'Enter' && (e.ctrlKey || e.metaKey))) {
if (this.onFinishEditing) this.onFinishEditing();
e.stopPropagation();
}
}
/** Registers a callback that listens for text changes. */
addTextChangeListener(listener: (oldText: string, newText: string) => void) {
this.textChangeListeners.push(listener);
}
/** Removes the given listener from the list of text change listeners. */
removeTextChangeListener(listener: () => void) {
this.textChangeListeners.splice(
this.textChangeListeners.indexOf(listener),
1,
);
}
/** Sets the placeholder text displayed for an empty comment. */
setPlaceholderText(text: string) {
this.textArea.placeholder = text;
}
/** Sets whether the textarea is editable. If not, the textarea will be readonly. */
setEditable(isEditable: boolean) {
if (isEditable) {
this.textArea.removeAttribute('readonly');
} else {
this.textArea.setAttribute('readonly', 'true');
}
}
/** Update the size of the comment editor element. */
updateSize(size: Size, topBarSize: Size) {
this.foreignObject.setAttribute(
'height',
`${size.height - topBarSize.height}`,
);
this.foreignObject.setAttribute('width', `${size.width}`);
this.foreignObject.setAttribute('y', `${topBarSize.height}`);
if (this.workspace.RTL) {
this.foreignObject.setAttribute('x', `${-size.width}`);
}
}
getFocusableElement(): HTMLElement | SVGElement {
return this.textArea;
}
getFocusableTree(): IFocusableTree {
return this.workspace;
}
onNodeFocus(): void {}
onNodeBlur(): void {}
canBeFocused(): boolean {
if (this.id) return true;
return false;
}
}