diff --git a/core/bubbles/bubble.ts b/core/bubbles/bubble.ts index 20e730abb..c42e60254 100644 --- a/core/bubbles/bubble.ts +++ b/core/bubbles/bubble.ts @@ -9,7 +9,9 @@ import * as common from '../common.js'; import {BubbleDragStrategy} from '../dragging/bubble_drag_strategy.js'; import {getFocusManager} from '../focus_manager.js'; import {IBubble} from '../interfaces/i_bubble.js'; +import type {IFocusableNode} from '../interfaces/i_focusable_node.js'; import type {IFocusableTree} from '../interfaces/i_focusable_tree.js'; +import type {IHasBubble} from '../interfaces/i_has_bubble.js'; import {ISelectable} from '../interfaces/i_selectable.js'; import {ContainerRegion} from '../metrics_manager.js'; import {Scrollbar} from '../scrollbar.js'; @@ -27,7 +29,7 @@ import {WorkspaceSvg} from '../workspace_svg.js'; * bubble, where it has a "tail" that points to the block, and a "head" that * displays arbitrary svg elements. */ -export abstract class Bubble implements IBubble, ISelectable { +export abstract class Bubble implements IBubble, ISelectable, IFocusableNode { /** The width of the border around the bubble. */ static readonly BORDER_WIDTH = 6; @@ -100,12 +102,14 @@ export abstract class Bubble implements IBubble, ISelectable { * element that's represented by this bubble (as a focusable node). This * element will have its ID overwritten. If not provided, the focusable * element of this node will default to the bubble's SVG root. + * @param owner The object responsible for hosting/spawning this bubble. */ constructor( public readonly workspace: WorkspaceSvg, protected anchor: Coordinate, protected ownerRect?: Rect, overriddenFocusableElement?: SVGElement | HTMLElement, + protected owner?: IHasBubble & IFocusableNode, ) { this.id = idGenerator.getNextUniqueId(); this.svgRoot = dom.createSvgElement( @@ -145,6 +149,13 @@ export abstract class Bubble implements IBubble, ISelectable { this, this.onMouseDown, ); + + browserEvents.conditionalBind( + this.focusableElement, + 'keydown', + this, + this.onKeyDown, + ); } /** Dispose of this bubble. */ @@ -229,6 +240,19 @@ export abstract class Bubble implements IBubble, ISelectable { getFocusManager().focusNode(this); } + /** + * Handles key events when this bubble is focused. By default, closes the + * bubble on Escape. + * + * @param e The keyboard event to handle. + */ + protected onKeyDown(e: KeyboardEvent) { + if (e.key === 'Escape' && this.owner) { + this.owner.setBubbleVisible(false); + getFocusManager().focusNode(this.owner); + } + } + /** Positions the bubble relative to its anchor. Does not render its tail. */ protected positionRelativeToAnchor() { let left = this.anchor.x; @@ -694,4 +718,11 @@ export abstract class Bubble implements IBubble, ISelectable { canBeFocused(): boolean { return true; } + + /** + * Returns the object that owns/hosts this bubble, if any. + */ + getOwner(): (IHasBubble & IFocusableNode) | undefined { + return this.owner; + } } diff --git a/core/bubbles/textinput_bubble.ts b/core/bubbles/textinput_bubble.ts index 7479c06cf..0bad5fabc 100644 --- a/core/bubbles/textinput_bubble.ts +++ b/core/bubbles/textinput_bubble.ts @@ -4,7 +4,11 @@ * 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'; @@ -21,12 +25,6 @@ import {Bubble} from './bubble.js'; * Used by the comment icon. */ export class TextInputBubble extends Bubble { - /** The root of the elements specific to the text element. */ - private inputRoot: SVGForeignObjectElement; - - /** The text input area element. */ - private textArea: HTMLTextAreaElement; - /** The group containing the lines indicating the bubble is resizable. */ private resizeGroup: SVGGElement; @@ -42,18 +40,12 @@ export class TextInputBubble extends Bubble { */ private resizePointerMoveListener: browserEvents.Data | null = null; - /** Functions listening for changes to the text of this bubble. */ - private textChangeListeners: (() => void)[] = []; - /** 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 text of this bubble. */ - private text = ''; - /** The default size of this bubble, including borders. */ private readonly DEFAULT_SIZE = new Size( 160 + Bubble.DOUBLE_BORDER, @@ -68,46 +60,47 @@ export class TextInputBubble extends Bubble { 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, TextInputBubble.createTextArea()); + super(workspace, anchor, ownerRect, undefined, owner); dom.addClass(this.svgRoot, 'blocklyTextInputBubble'); - this.textArea = this.getFocusableElement() as HTMLTextAreaElement; - this.inputRoot = this.createEditor(this.contentContainer, this.textArea); + 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.text; + return this.editor.getText(); } /** Sets the text of this bubble. Calls change listeners. */ setText(text: string) { - this.text = text; - this.textArea.value = text; - this.onTextChange(); + this.editor.setText(text); } /** Sets whether or not the text in the bubble is editable. */ setEditable(editable: boolean) { this.editable = editable; - if (this.editable) { - this.textArea.removeAttribute('readonly'); - } else { - this.textArea.setAttribute('readonly', ''); - } + this.editor.setEditable(editable); } /** Returns whether or not the text in the bubble is editable. */ @@ -117,7 +110,7 @@ export class TextInputBubble extends Bubble { /** Adds a change listener to be notified when this bubble's text changes. */ addTextChangeListener(listener: () => void) { - this.textChangeListeners.push(listener); + this.editor.addTextChangeListener(listener); } /** Adds a change listener to be notified when this bubble's size changes. */ @@ -130,58 +123,6 @@ export class TextInputBubble extends Bubble { this.locationChangeListeners.push(listener); } - /** Creates and returns the editable text area for this bubble's editor. */ - private static createTextArea(): HTMLTextAreaElement { - const textArea = document.createElementNS( - dom.HTML_NS, - 'textarea', - ) as HTMLTextAreaElement; - textArea.className = 'blocklyTextarea blocklyText'; - return textArea; - } - - /** Creates and returns the UI container element for this bubble's editor. */ - private createEditor( - container: SVGGElement, - textArea: HTMLTextAreaElement, - ): SVGForeignObjectElement { - const inputRoot = dom.createSvgElement( - Svg.FOREIGNOBJECT, - { - 'x': Bubble.BORDER_WIDTH, - 'y': Bubble.BORDER_WIDTH, - }, - container, - ); - - const body = document.createElementNS(dom.HTML_NS, 'body'); - body.setAttribute('xmlns', dom.HTML_NS); - body.className = 'blocklyMinimalBody'; - - textArea.setAttribute('dir', this.workspace.RTL ? 'RTL' : 'LTR'); - body.appendChild(textArea); - inputRoot.appendChild(body); - - this.bindTextAreaEvents(textArea); - - return inputRoot; - } - - /** Binds events to the text area element. */ - private bindTextAreaEvents(textArea: HTMLTextAreaElement) { - // Don't zoom with mousewheel; let it scroll instead. - browserEvents.conditionalBind(textArea, 'wheel', this, (e: Event) => { - e.stopPropagation(); - }); - // Don't let the pointerdown event get to the workspace. - browserEvents.conditionalBind(textArea, 'pointerdown', this, (e: Event) => { - e.stopPropagation(); - touch.clearTouchIdentifier(); - }); - - browserEvents.conditionalBind(textArea, 'change', this, this.onTextChange); - } - /** Creates the resize handler elements and binds events to them. */ private createResizeHandle( container: SVGGElement, @@ -220,8 +161,12 @@ export class TextInputBubble extends Bubble { const widthMinusBorder = size.width - Bubble.DOUBLE_BORDER; const heightMinusBorder = size.height - Bubble.DOUBLE_BORDER; - this.inputRoot.setAttribute('width', `${widthMinusBorder}`); - this.inputRoot.setAttribute('height', `${heightMinusBorder}`); + 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) { @@ -312,14 +257,6 @@ export class TextInputBubble extends Bubble { this.onSizeChange(); } - /** Handles a text change event for the text area. Calls event listeners. */ - private onTextChange() { - this.text = this.textArea.value; - for (const listener of this.textChangeListeners) { - listener(); - } - } - /** Handles a size change event for the text area. Calls event listeners. */ private onSizeChange() { for (const listener of this.sizeChangeListeners) { @@ -333,6 +270,15 @@ export class TextInputBubble extends Bubble { listener(); } } + + /** + * Returns the text editor component of this bubble. + * + * @internal + */ + getEditor() { + return this.editor; + } } Css.register(` diff --git a/core/comments/comment_editor.ts b/core/comments/comment_editor.ts index 69dadd884..ac1559c4b 100644 --- a/core/comments/comment_editor.ts +++ b/core/comments/comment_editor.ts @@ -53,6 +53,7 @@ export class CommentEditor implements IFocusableNode { 'textarea', ) as HTMLTextAreaElement; this.textArea.setAttribute('tabindex', '-1'); + this.textArea.setAttribute('dir', this.workspace.RTL ? 'RTL' : 'LTR'); dom.addClass(this.textArea, 'blocklyCommentText'); dom.addClass(this.textArea, 'blocklyTextarea'); dom.addClass(this.textArea, 'blocklyText'); @@ -86,6 +87,11 @@ export class CommentEditor implements IFocusableNode { }, ); + // Don't zoom with mousewheel; let it scroll instead. + browserEvents.conditionalBind(this.textArea, 'wheel', this, (e: Event) => { + e.stopPropagation(); + }); + // Register listener for keydown events that would finish editing. browserEvents.conditionalBind( this.textArea, diff --git a/core/comments/rendered_workspace_comment.ts b/core/comments/rendered_workspace_comment.ts index 3457e611a..c4c1f3d4e 100644 --- a/core/comments/rendered_workspace_comment.ts +++ b/core/comments/rendered_workspace_comment.ts @@ -74,15 +74,6 @@ export class RenderedWorkspaceComment this, this.startGesture, ); - // Don't zoom with mousewheel; let it scroll instead. - browserEvents.conditionalBind( - this.view.getSvgRoot(), - 'wheel', - this, - (e: Event) => { - e.stopPropagation(); - }, - ); } /** diff --git a/core/icons/comment_icon.ts b/core/icons/comment_icon.ts index 959eb2500..8f5a82c0d 100644 --- a/core/icons/comment_icon.ts +++ b/core/icons/comment_icon.ts @@ -11,7 +11,6 @@ import type {BlockSvg} from '../block_svg.js'; import {TextInputBubble} from '../bubbles/textinput_bubble.js'; import {EventType} from '../events/type.js'; import * as eventUtils from '../events/utils.js'; -import type {IBubble} from '../interfaces/i_bubble.js'; import type {IHasBubble} from '../interfaces/i_has_bubble.js'; import type {ISerializable} from '../interfaces/i_serializable.js'; import * as renderManagement from '../render_management.js'; @@ -62,7 +61,7 @@ export class CommentIcon extends Icon implements IHasBubble, ISerializable { /** * The visibility of the bubble for this comment. * - * This is used to track what the visibile state /should/ be, not necessarily + * This is used to track what the visible state /should/ be, not necessarily * what it currently /is/. E.g. sometimes this will be true, but the block * hasn't been rendered yet, so the bubble will not currently be visible. */ @@ -340,7 +339,7 @@ export class CommentIcon extends Icon implements IHasBubble, ISerializable { } /** See IHasBubble.getBubble. */ - getBubble(): IBubble | null { + getBubble(): TextInputBubble | null { return this.textInputBubble; } @@ -365,6 +364,7 @@ export class CommentIcon extends Icon implements IHasBubble, ISerializable { this.sourceBlock.workspace as WorkspaceSvg, this.getAnchorLocation(), this.getBubbleOwnerRect(), + this, ); this.textInputBubble.setText(this.getText()); this.textInputBubble.setSize(this.bubbleSize, true); diff --git a/core/keyboard_nav/block_comment_navigation_policy.ts b/core/keyboard_nav/block_comment_navigation_policy.ts new file mode 100644 index 000000000..f2f1ab7e1 --- /dev/null +++ b/core/keyboard_nav/block_comment_navigation_policy.ts @@ -0,0 +1,76 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {TextInputBubble} from '../bubbles/textinput_bubble.js'; +import type {IFocusableNode} from '../interfaces/i_focusable_node.js'; +import type {INavigationPolicy} from '../interfaces/i_navigation_policy.js'; + +/** + * Set of rules controlling keyboard navigation from an TextInputBubble. + */ +export class BlockCommentNavigationPolicy + implements INavigationPolicy +{ + /** + * Returns the first child of the given block comment. + * + * @param current The block comment to return the first child of. + * @returns The text editor of the given block comment bubble. + */ + getFirstChild(current: TextInputBubble): IFocusableNode | null { + return current.getEditor(); + } + + /** + * Returns the parent of the given block comment. + * + * @param current The block comment to return the parent of. + * @returns The parent block of the given block comment. + */ + getParent(current: TextInputBubble): IFocusableNode | null { + return current.getOwner() ?? null; + } + + /** + * Returns the next peer node of the given block comment. + * + * @param _current The block comment to find the following element of. + * @returns Null. + */ + getNextSibling(_current: TextInputBubble): IFocusableNode | null { + return null; + } + + /** + * Returns the previous peer node of the given block comment. + * + * @param _current The block comment to find the preceding element of. + * @returns Null. + */ + getPreviousSibling(_current: TextInputBubble): IFocusableNode | null { + return null; + } + + /** + * Returns whether or not the given block comment can be navigated to. + * + * @param current The instance to check for navigability. + * @returns True if the given block comment can be focused. + */ + isNavigable(current: TextInputBubble): boolean { + return current.canBeFocused(); + } + + /** + * Returns whether the given object can be navigated from by this policy. + * + * @param current The object to check if this policy applies to. + * @returns True if the object is an TextInputBubble. + */ + isApplicable(current: any): current is TextInputBubble { + return current instanceof TextInputBubble; + } +} diff --git a/core/keyboard_nav/comment_editor_navigation_policy.ts b/core/keyboard_nav/comment_editor_navigation_policy.ts new file mode 100644 index 000000000..456df8e97 --- /dev/null +++ b/core/keyboard_nav/comment_editor_navigation_policy.ts @@ -0,0 +1,54 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {CommentEditor} from '../comments/comment_editor.js'; +import type {IFocusableNode} from '../interfaces/i_focusable_node.js'; +import type {INavigationPolicy} from '../interfaces/i_navigation_policy.js'; + +/** + * Set of rules controlling keyboard navigation from a comment editor. + * This is a no-op placeholder (other than isNavigable/isApplicable) since + * comment editors handle their own navigation when editing ends. + */ +export class CommentEditorNavigationPolicy + implements INavigationPolicy +{ + getFirstChild(_current: CommentEditor): IFocusableNode | null { + return null; + } + + getParent(_current: CommentEditor): IFocusableNode | null { + return null; + } + + getNextSibling(_current: CommentEditor): IFocusableNode | null { + return null; + } + + getPreviousSibling(_current: CommentEditor): IFocusableNode | null { + return null; + } + + /** + * Returns whether or not the given comment editor can be navigated to. + * + * @param current The instance to check for navigability. + * @returns False. + */ + isNavigable(current: CommentEditor): boolean { + return current.canBeFocused(); + } + + /** + * Returns whether the given object can be navigated from by this policy. + * + * @param current The object to check if this policy applies to. + * @returns True if the object is a CommentEditor. + */ + isApplicable(current: any): current is CommentEditor { + return current instanceof CommentEditor; + } +} diff --git a/core/keyboard_nav/icon_navigation_policy.ts b/core/keyboard_nav/icon_navigation_policy.ts index 70631ce81..112239d06 100644 --- a/core/keyboard_nav/icon_navigation_policy.ts +++ b/core/keyboard_nav/icon_navigation_policy.ts @@ -6,6 +6,7 @@ import {BlockSvg} from '../block_svg.js'; import {getFocusManager} from '../focus_manager.js'; +import {CommentIcon} from '../icons/comment_icon.js'; import {Icon} from '../icons/icon.js'; import {MutatorIcon} from '../icons/mutator_icon.js'; import type {IFocusableNode} from '../interfaces/i_focusable_node.js'; @@ -29,6 +30,12 @@ export class IconNavigationPolicy implements INavigationPolicy { getFocusManager().getFocusedNode() === current ) { return current.getBubble()?.getWorkspace() ?? null; + } else if ( + current instanceof CommentIcon && + current.bubbleIsVisible() && + getFocusManager().getFocusedNode() === current + ) { + return current.getBubble()?.getEditor() ?? null; } return null; diff --git a/core/navigator.ts b/core/navigator.ts index 2f095f6f9..9c7c22f59 100644 --- a/core/navigator.ts +++ b/core/navigator.ts @@ -6,8 +6,10 @@ import type {IFocusableNode} from './interfaces/i_focusable_node.js'; import type {INavigationPolicy} from './interfaces/i_navigation_policy.js'; +import {BlockCommentNavigationPolicy} from './keyboard_nav/block_comment_navigation_policy.js'; import {BlockNavigationPolicy} from './keyboard_nav/block_navigation_policy.js'; import {CommentBarButtonNavigationPolicy} from './keyboard_nav/comment_bar_button_navigation_policy.js'; +import {CommentEditorNavigationPolicy} from './keyboard_nav/comment_editor_navigation_policy.js'; import {ConnectionNavigationPolicy} from './keyboard_nav/connection_navigation_policy.js'; import {FieldNavigationPolicy} from './keyboard_nav/field_navigation_policy.js'; import {IconNavigationPolicy} from './keyboard_nav/icon_navigation_policy.js'; @@ -33,6 +35,8 @@ export class Navigator { new IconNavigationPolicy(), new WorkspaceCommentNavigationPolicy(), new CommentBarButtonNavigationPolicy(), + new BlockCommentNavigationPolicy(), + new CommentEditorNavigationPolicy(), ]; /** diff --git a/core/workspace_svg.ts b/core/workspace_svg.ts index d713f11cf..4180c1099 100644 --- a/core/workspace_svg.ts +++ b/core/workspace_svg.ts @@ -22,6 +22,7 @@ import type {Block} from './block.js'; import type {BlockSvg} from './block_svg.js'; import type {BlocklyOptions} from './blockly_options.js'; import * as browserEvents from './browser_events.js'; +import {TextInputBubble} from './bubbles/textinput_bubble.js'; import {COMMENT_COLLAPSE_BAR_BUTTON_FOCUS_IDENTIFIER} from './comments/collapse_comment_bar_button.js'; import {COMMENT_EDITOR_FOCUS_IDENTIFIER} from './comments/comment_editor.js'; import {COMMENT_DELETE_BAR_BUTTON_FOCUS_IDENTIFIER} from './comments/delete_comment_bar_button.js'; @@ -2868,6 +2869,11 @@ export class WorkspaceSvg bubble.getFocusableElement().id === id ) { return bubble; + } else if ( + bubble instanceof TextInputBubble && + bubble.getEditor().getFocusableElement().id === id + ) { + return bubble.getEditor(); } } }