mirror of
https://github.com/google/blockly.git
synced 2026-01-06 00:20:37 +01:00
* refactor: Make focusable elements responsible for scrolling themselves into bounds. * chore: Add tests for scrolling focused elements into view. * fix: Removed inadvertent `.only`. * fix: Scroll parent block of connections into bounds on focus.
209 lines
6.2 KiB
TypeScript
209 lines
6.2 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 {Rect} from '../utils/rect.js';
|
|
import {Size} from '../utils/size.js';
|
|
import {Svg} from '../utils/svg.js';
|
|
import * as svgMath from '../utils/svg_math.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');
|
|
this.textArea.setAttribute('dir', this.workspace.RTL ? 'RTL' : 'LTR');
|
|
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();
|
|
},
|
|
);
|
|
|
|
// 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,
|
|
'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 {
|
|
const bbox = Rect.from(this.foreignObject.getBoundingClientRect());
|
|
this.workspace.scrollBoundsIntoView(
|
|
Rect.createFromPoint(
|
|
svgMath.screenToWsCoordinates(this.workspace, bbox.getOrigin()),
|
|
bbox.getWidth(),
|
|
bbox.getHeight(),
|
|
),
|
|
);
|
|
}
|
|
onNodeBlur(): void {}
|
|
canBeFocused(): boolean {
|
|
if (this.id) return true;
|
|
return false;
|
|
}
|
|
}
|