Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 32 additions & 1 deletion core/bubbles/bubble.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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;

Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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. */
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
}
}
120 changes: 33 additions & 87 deletions core/bubbles/textinput_bubble.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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;

Expand All @@ -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,
Expand All @@ -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. */
Expand All @@ -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. */
Expand All @@ -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,
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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) {
Expand All @@ -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(`
Expand Down
6 changes: 6 additions & 0 deletions core/comments/comment_editor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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,
Expand Down
9 changes: 0 additions & 9 deletions core/comments/rendered_workspace_comment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
},
);
}

/**
Expand Down
6 changes: 3 additions & 3 deletions core/icons/comment_icon.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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.
*/
Expand Down Expand Up @@ -340,7 +339,7 @@ export class CommentIcon extends Icon implements IHasBubble, ISerializable {
}

/** See IHasBubble.getBubble. */
getBubble(): IBubble | null {
getBubble(): TextInputBubble | null {
return this.textInputBubble;
}

Expand All @@ -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);
Expand Down
Loading