/** * @license * Copyright 2023 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import {Bubble} from './bubble.js'; import {Coordinate} from '../utils/coordinate.js'; import * as Css from '../css.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 touch from '../touch.js'; import {WorkspaceSvg} from '../workspace_svg.js'; import {browserEvents} from '../utils.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 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; /** * 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 text of this bubble. */ private textChangeListeners: (() => void)[] = []; /** Functions listening for changes to the size of this bubble. */ private sizeChangeListeners: (() => 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, 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, ); /** * @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. */ constructor( public readonly workspace: WorkspaceSvg, protected anchor: Coordinate, protected ownerRect?: Rect, ) { super(workspace, anchor, ownerRect); dom.addClass(this.svgRoot, 'blocklyTextInputBubble'); ({inputRoot: this.inputRoot, textArea: this.textArea} = this.createEditor( this.contentContainer, )); this.resizeGroup = this.createResizeHandle(this.svgRoot, workspace); this.setSize(this.DEFAULT_SIZE, true); } /** @returns the text of this bubble. */ getText(): string { return this.text; } /** Sets the text of this bubble. Calls change listeners. */ setText(text: string) { this.text = text; this.textArea.value = text; this.onTextChange(); } /** Adds a change listener to be notified when this bubble's text changes. */ addTextChangeListener(listener: () => void) { this.textChangeListeners.push(listener); } /** Adds a change listener to be notified when this bubble's size changes. */ addSizeChangeListener(listener: () => void) { this.sizeChangeListeners.push(listener); } /** Creates the editor UI for this bubble. */ private createEditor(container: SVGGElement): { inputRoot: SVGForeignObjectElement; textArea: HTMLTextAreaElement; } { 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'; const textArea = document.createElementNS( dom.HTML_NS, 'textarea', ) as HTMLTextAreaElement; textArea.className = 'blocklyTextarea blocklyText'; textArea.setAttribute('dir', this.workspace.RTL ? 'RTL' : 'LTR'); body.appendChild(textArea); inputRoot.appendChild(body); this.bindTextAreaEvents(textArea); setTimeout(() => { textArea.focus(); }, 0); return {inputRoot, textArea}; } /** Binds events to the text area element. */ private bindTextAreaEvents(textArea: HTMLTextAreaElement) { // Don't zoom with mousewheel. browserEvents.conditionalBind(textArea, 'wheel', this, (e: Event) => { e.stopPropagation(); }); browserEvents.conditionalBind( textArea, 'focus', this, this.onStartEdit, true, ); browserEvents.conditionalBind(textArea, 'change', this, this.onTextChange); } /** 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.inputRoot.setAttribute('width', `${widthMinusBorder}`); this.inputRoot.setAttribute('height', `${heightMinusBorder}`); this.textArea.style.width = `${widthMinusBorder - 4}px`; this.textArea.style.height = `${heightMinusBorder - 4}px`; if (this.workspace.RTL) { this.resizeGroup.setAttribute( 'transform', `translate(${Bubble.DOUBLE_BORDER}, ${heightMinusBorder}) scale(-1 1)`, ); } else { this.resizeGroup.setAttribute( 'transform', `translate(${widthMinusBorder}, ${heightMinusBorder})`, ); } super.setSize(size, relayout); this.onSizeChange(); } /** @returns the size of this bubble. */ getSize(): Size { // Overriden to be public. return super.getSize(); } /** Handles mouse down events on the resize target. */ private onResizePointerDown(e: PointerEvent) { this.bringToFront(); if (browserEvents.isRightButton(e)) { e.stopPropagation(); return; } this.workspace.startDrag( 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 = this.workspace.moveDrag(e); this.setSize( new Size(this.workspace.RTL ? -delta.x : delta.x, delta.y), false, ); this.onSizeChange(); } /** * Handles starting an edit of the text area. Brings the bubble to the front. */ private onStartEdit() { if (this.bringToFront()) { // Since the act of moving this node within the DOM causes a loss of // focus, we need to reapply the focus. this.textArea.focus(); } } /** 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) { listener(); } } } Css.register(` .blocklyCommentTextarea { background-color: #fef49c; border: 0; display: block; margin: 0; outline: 0; padding: 3px; resize: none; text-overflow: hidden; } `);