/** * @license * Copyright 2023 Google LLC * SPDX-License-Identifier: Apache-2.0 */ // Former goog.module ID: Blockly.Comment import type {Block} from '../block.js'; import type {BlockSvg} from '../block_svg.js'; import {IconType} from './icon_types.js'; import {Coordinate} from '../utils.js'; import * as dom from '../utils/dom.js'; import * as eventUtils from '../events/utils.js'; import {Icon} from './icon.js'; import type {IHasBubble} from '../interfaces/i_has_bubble.js'; import type {ISerializable} from '../interfaces/i_serializable.js'; import {Rect} from '../utils/rect.js'; import * as registry from './registry.js'; import {Size} from '../utils/size.js'; import {Svg} from '../utils/svg.js'; import {TextBubble} from '../bubbles/text_bubble.js'; import {TextInputBubble} from '../bubbles/textinput_bubble.js'; import type {WorkspaceSvg} from '../workspace_svg.js'; import * as renderManagement from '../render_management.js'; /** The size of the comment icon in workspace-scale units. */ const SIZE = 17; /** The default width in workspace-scale units of the text input bubble. */ const DEFAULT_BUBBLE_WIDTH = 160; /** The default height in workspace-scale units of the text input bubble. */ const DEFAULT_BUBBLE_HEIGHT = 80; /** * An icon which allows the user to add comment text to a block. */ export class CommentIcon extends Icon implements IHasBubble, ISerializable { /** The type string used to identify this icon. */ static readonly TYPE = IconType.COMMENT; /** * The weight this icon has relative to other icons. Icons with more positive * weight values are rendered farther toward the end of the block. */ static readonly WEIGHT = 3; /** The bubble used to show editable text to the user. */ private textInputBubble: TextInputBubble | null = null; /** The bubble used to show non-editable text to the user. */ private textBubble: TextBubble | null = null; /** The text of this comment. */ private text = ''; /** The size of this comment (which is applied to the editable bubble). */ private bubbleSize = new Size(DEFAULT_BUBBLE_WIDTH, DEFAULT_BUBBLE_HEIGHT); /** * The visibility of the bubble for this comment. * * This is used to track what the visibile 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. */ private bubbleVisiblity = false; constructor(protected readonly sourceBlock: Block) { super(sourceBlock); } override getType(): IconType { return CommentIcon.TYPE; } override initView(pointerdownListener: (e: PointerEvent) => void): void { if (this.svgRoot) return; // Already initialized. super.initView(pointerdownListener); // Circle. dom.createSvgElement( Svg.CIRCLE, {'class': 'blocklyIconShape', 'r': '8', 'cx': '8', 'cy': '8'}, this.svgRoot, ); // Can't use a real '?' text character since different browsers and // operating systems render it differently. Body of question mark. dom.createSvgElement( Svg.PATH, { 'class': 'blocklyIconSymbol', 'd': 'm6.8,10h2c0.003,-0.617 0.271,-0.962 0.633,-1.266 2.875,-2.405' + '0.607,-5.534 -3.765,-3.874v1.7c3.12,-1.657 3.698,0.118 2.336,1.25' + '-1.201,0.998 -1.201,1.528 -1.204,2.19z', }, this.svgRoot, ); // Dot of question mark. dom.createSvgElement( Svg.RECT, { 'class': 'blocklyIconSymbol', 'x': '6.8', 'y': '10.78', 'height': '2', 'width': '2', }, this.svgRoot, ); dom.addClass(this.svgRoot!, 'blockly-icon-comment'); } override dispose() { super.dispose(); this.textInputBubble?.dispose(); this.textBubble?.dispose(); } override getWeight(): number { return CommentIcon.WEIGHT; } override getSize(): Size { return new Size(SIZE, SIZE); } override applyColour(): void { super.applyColour(); const colour = (this.sourceBlock as BlockSvg).style.colourPrimary; this.textInputBubble?.setColour(colour); this.textBubble?.setColour(colour); } /** * Updates the state of the bubble (editable / noneditable) to reflect the * state of the bubble if the bubble is currently shown. */ override async updateEditable(): Promise { super.updateEditable(); if (this.bubbleIsVisible()) { // Close and reopen the bubble to display the correct UI. await this.setBubbleVisible(false); await this.setBubbleVisible(true); } } override onLocationChange(blockOrigin: Coordinate): void { super.onLocationChange(blockOrigin); const anchorLocation = this.getAnchorLocation(); this.textInputBubble?.setAnchorLocation(anchorLocation); this.textBubble?.setAnchorLocation(anchorLocation); } /** Sets the text of this comment. Updates any bubbles if they are visible. */ setText(text: string) { const oldText = this.text; eventUtils.fire( new (eventUtils.get(eventUtils.BLOCK_CHANGE))( this.sourceBlock, 'comment', null, oldText, text, ), ); this.text = text; this.textInputBubble?.setText(this.text); this.textBubble?.setText(this.text); } /** Returns the text of this comment. */ getText(): string { return this.text; } /** * Sets the size of the editable bubble for this comment. Resizes the * bubble if it is visible. */ setBubbleSize(size: Size) { this.bubbleSize = size; this.textInputBubble?.setSize(this.bubbleSize, true); } /** @returns the size of the editable bubble for this comment. */ getBubbleSize(): Size { return this.bubbleSize; } /** * @returns the state of the comment as a JSON serializable value if the * comment has text. Otherwise returns null. */ saveState(): CommentState | null { if (this.text) { return { 'text': this.text, 'pinned': this.bubbleIsVisible(), 'height': this.bubbleSize.height, 'width': this.bubbleSize.width, }; } return null; } /** Applies the given state to this comment. */ loadState(state: CommentState) { this.text = state['text'] ?? ''; this.bubbleSize = new Size( state['width'] ?? DEFAULT_BUBBLE_WIDTH, state['height'] ?? DEFAULT_BUBBLE_HEIGHT, ); this.bubbleVisiblity = state['pinned'] ?? false; this.setBubbleVisible(this.bubbleVisiblity); } override onClick(): void { super.onClick(); this.setBubbleVisible(!this.bubbleIsVisible()); } override isClickableInFlyout(): boolean { return false; } /** * Updates the text of this comment in response to changes in the text of * the input bubble. */ onTextChange(): void { if (!this.textInputBubble) return; const newText = this.textInputBubble.getText(); if (this.text === newText) return; eventUtils.fire( new (eventUtils.get(eventUtils.BLOCK_CHANGE))( this.sourceBlock, 'comment', null, this.text, newText, ), ); this.text = newText; } /** * Updates the size of this icon in response to changes in the size of the * input bubble. */ onSizeChange(): void { if (this.textInputBubble) { this.bubbleSize = this.textInputBubble.getSize(); } } bubbleIsVisible(): boolean { return this.bubbleVisiblity; } async setBubbleVisible(visible: boolean): Promise { if (this.bubbleVisiblity === visible) return; this.bubbleVisiblity = visible; await renderManagement.finishQueuedRenders(); if ( !this.sourceBlock.rendered || this.sourceBlock.isInFlyout || this.sourceBlock.isInsertionMarker() ) return; if (visible) { if (this.sourceBlock.isEditable()) { this.showEditableBubble(); } else { this.showNonEditableBubble(); } this.applyColour(); } else { this.hideBubble(); } eventUtils.fire( new (eventUtils.get(eventUtils.BUBBLE_OPEN))( this.sourceBlock, visible, 'comment', ), ); } /** * Shows the editable text bubble for this comment, and adds change listeners * to update the state of this icon in response to changes in the bubble. */ private showEditableBubble() { this.textInputBubble = new TextInputBubble( this.sourceBlock.workspace as WorkspaceSvg, this.getAnchorLocation(), this.getBubbleOwnerRect(), ); this.textInputBubble.setText(this.getText()); this.textInputBubble.setSize(this.bubbleSize, true); this.textInputBubble.addTextChangeListener(() => this.onTextChange()); this.textInputBubble.addSizeChangeListener(() => this.onSizeChange()); } /** Shows the non editable text bubble for this comment. */ private showNonEditableBubble() { this.textBubble = new TextBubble( this.getText(), this.sourceBlock.workspace as WorkspaceSvg, this.getAnchorLocation(), this.getBubbleOwnerRect(), ); } /** Hides any open bubbles owned by this comment. */ private hideBubble() { this.textInputBubble?.dispose(); this.textInputBubble = null; this.textBubble?.dispose(); this.textBubble = null; } /** * @returns the location the bubble should be anchored to. * I.E. the middle of this icon. */ private getAnchorLocation(): Coordinate { const midIcon = SIZE / 2; return Coordinate.sum( this.workspaceLocation, new Coordinate(midIcon, midIcon), ); } /** * @returns the rect the bubble should avoid overlapping. * I.E. the block that owns this icon. */ private getBubbleOwnerRect(): Rect { const bbox = (this.sourceBlock as BlockSvg).getSvgRoot().getBBox(); return new Rect(bbox.y, bbox.y + bbox.height, bbox.x, bbox.x + bbox.width); } } /** The save state format for a comment icon. */ export interface CommentState { /** The text of the comment. */ text?: string; /** True if the comment is open, false otherwise. */ pinned?: boolean; /** The height of the comment bubble. */ height?: number; /** The width of the comment bubble. */ width?: number; } registry.register(CommentIcon.TYPE, CommentIcon);