diff --git a/core/blockly.ts b/core/blockly.ts
index 2a5dba653..e68199854 100644
--- a/core/blockly.ts
+++ b/core/blockly.ts
@@ -36,6 +36,7 @@ import {ConnectionType} from './connection_type.js';
import * as ContextMenu from './contextmenu.js';
import * as ContextMenuItems from './contextmenu_items.js';
import {ContextMenuRegistry} from './contextmenu_registry.js';
+import * as comments from './comments.js';
import * as Css from './css.js';
import {DeleteArea} from './delete_area.js';
import * as dialog from './dialog.js';
@@ -480,6 +481,7 @@ export {ConnectionType};
export {ConnectionChecker};
export {ConnectionDB};
export {ContextMenuRegistry};
+export {comments};
export {Cursor};
export {DeleteArea};
export {DragTarget};
diff --git a/core/comments.ts b/core/comments.ts
new file mode 100644
index 000000000..d7a9aed3a
--- /dev/null
+++ b/core/comments.ts
@@ -0,0 +1,7 @@
+/**
+ * @license
+ * Copyright 2024 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+export {CommentView} from './comments/comment_view.js';
diff --git a/core/comments/comment_view.ts b/core/comments/comment_view.ts
new file mode 100644
index 000000000..21d2ebcfd
--- /dev/null
+++ b/core/comments/comment_view.ts
@@ -0,0 +1,741 @@
+/**
+ * @license
+ * Copyright 2024 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import {IRenderedElement} from '../interfaces/i_rendered_element.js';
+import {WorkspaceSvg} from '../workspace_svg.js';
+import * as dom from '../utils/dom.js';
+import {Svg} from '../utils/svg.js';
+import * as layers from '../layers.js';
+import * as css from '../css.js';
+import {Coordinate, Size, browserEvents} from '../utils.js';
+import * as touch from '../touch.js';
+
+const MIN_SIZE = new Size(100, 60);
+export class CommentView implements IRenderedElement {
+ /** The root group element of the comment view. */
+ private svgRoot: SVGGElement;
+
+ /** The rect background for the top bar. */
+ private topBar: SVGRectElement;
+
+ /** The delete icon that goes in the top bar. */
+ private deleteIcon: SVGImageElement;
+
+ /** The foldout icon that goes in the top bar. */
+ private foldoutIcon: SVGImageElement;
+
+ /** The text element that goes in the top bar. */
+ private textPreview: SVGTextElement;
+
+ /** The actual text node in the text preview. */
+ private textPreviewNode: Text;
+
+ /** The resize handle element. */
+ private resizeHandle: SVGImageElement;
+
+ /** The foreignObject containing the HTML text area. */
+ private foreignObject: SVGForeignObjectElement;
+
+ /** The text area where the user can type. */
+ private textArea: HTMLTextAreaElement;
+
+ /** The current size of the comment in workspace units. */
+ private size: Size = new Size(120, 100);
+
+ /** Whether the comment is collapsed or not. */
+ private collapsed: boolean = false;
+
+ /** Whether the comment is editable or not. */
+ private editable: boolean = true;
+
+ /** The current location of the comment in workspace coordinates. */
+ private location: Coordinate = new Coordinate(0, 0);
+
+ /** The current text of the comment. Updates on text area change. */
+ private text: string = '';
+
+ /** Listeners for changes to text. */
+ private textChangeListeners: Array<
+ (oldText: string, newText: string) => void
+ > = [];
+
+ /** Listeners for changes to size. */
+ private sizeChangeListeners: Array<(oldSize: Size, newSize: Size) => void> =
+ [];
+
+ /** Listeners for disposal. */
+ private disposeListeners: Array<() => void> = [];
+
+ /** Listeners for collapsing. */
+ private collapseChangeListeners: Array<(newCollapse: boolean) => void> = [];
+
+ /**
+ * Event data for the pointer up event on the resize handle. Used to
+ * unregister the listener.
+ */
+ private resizePointerUpListener: browserEvents.Data | null = null;
+
+ /**
+ * Event data for the pointer move event on the resize handle. Used to
+ * unregister the listener.
+ */
+ private resizePointerMoveListener: browserEvents.Data | null = null;
+
+ /** Whether this comment view is currently being disposed or not. */
+ private disposing = false;
+
+ /** Whether this comment view has been disposed or not. */
+ private disposed = false;
+
+ constructor(private readonly workspace: WorkspaceSvg) {
+ this.svgRoot = dom.createSvgElement(Svg.G, {
+ 'class': 'blocklyComment blocklyEditable',
+ });
+
+ ({
+ topBar: this.topBar,
+ deleteIcon: this.deleteIcon,
+ foldoutIcon: this.foldoutIcon,
+ textPreview: this.textPreview,
+ textPreviewNode: this.textPreviewNode,
+ } = this.createTopBar(this.svgRoot, workspace));
+
+ ({foreignObject: this.foreignObject, textArea: this.textArea} =
+ this.createTextArea(this.svgRoot));
+
+ this.resizeHandle = this.createResizeHandle(this.svgRoot, workspace);
+
+ // TODO: Remove this comment before merging.
+ // I think we want comments to exist on the same layer as blocks.
+ workspace.getLayerManager()?.append(this, layers.BLOCK);
+
+ // Set size to the default size.
+ this.setSize(this.size);
+ }
+
+ /**
+ * Creates the top bar and the elements visually within it.
+ * Registers event listeners.
+ */
+ private createTopBar(
+ svgRoot: SVGGElement,
+ workspace: WorkspaceSvg,
+ ): {
+ topBar: SVGRectElement;
+ deleteIcon: SVGImageElement;
+ foldoutIcon: SVGImageElement;
+ textPreview: SVGTextElement;
+ textPreviewNode: Text;
+ } {
+ const topBar = dom.createSvgElement(
+ Svg.RECT,
+ {
+ 'class': 'blocklyCommentTopbar',
+ 'x': 0,
+ 'y': 0,
+ },
+ svgRoot,
+ );
+ // TODO: Before merging, does this mean to override an individual image,
+ // folks need to replace the whole media folder?
+ const deleteIcon = dom.createSvgElement(
+ Svg.IMAGE,
+ {
+ 'class': 'blocklyDeleteIcon',
+ 'href': `${workspace.options.pathToMedia}delete-icon.svg`,
+ },
+ svgRoot,
+ );
+ const foldoutIcon = dom.createSvgElement(
+ Svg.IMAGE,
+ {
+ 'class': 'blocklyFoldoutIcon',
+ 'href': `${workspace.options.pathToMedia}arrow-dropdown.svg`,
+ },
+ svgRoot,
+ );
+ const textPreview = dom.createSvgElement(
+ Svg.TEXT,
+ {
+ 'class': 'blocklyCommentPreview blocklyCommentText blocklyText',
+ },
+ svgRoot,
+ );
+ const textPreviewNode = document.createTextNode('');
+ textPreview.appendChild(textPreviewNode);
+
+ // TODO(toychest): Triggering this on pointerdown means that we can't start
+ // drags on the foldout icon. We need to open up the gesture system
+ // to fix this.
+ browserEvents.conditionalBind(
+ foldoutIcon,
+ 'pointerdown',
+ this,
+ this.onFoldoutDown,
+ );
+ browserEvents.conditionalBind(
+ deleteIcon,
+ 'pointerdown',
+ this,
+ this.onDeleteDown,
+ );
+
+ return {topBar, deleteIcon, foldoutIcon, textPreview, textPreviewNode};
+ }
+
+ /**
+ * Creates the text area where users can type. Registers event listeners.
+ */
+ private createTextArea(svgRoot: SVGGElement): {
+ foreignObject: SVGForeignObjectElement;
+ textArea: HTMLTextAreaElement;
+ } {
+ const foreignObject = dom.createSvgElement(
+ Svg.FOREIGNOBJECT,
+ {
+ 'class': 'blocklyCommentForeignObject',
+ },
+ svgRoot,
+ );
+ 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;
+ dom.addClass(textArea, 'blocklyCommentText');
+ dom.addClass(textArea, 'blocklyTextarea');
+ dom.addClass(textArea, 'blocklyText');
+ body.appendChild(textArea);
+ foreignObject.appendChild(body);
+
+ browserEvents.conditionalBind(textArea, 'change', this, this.onTextChange);
+
+ return {foreignObject, textArea};
+ }
+
+ /** Creates the DOM elements for the comment resize handle. */
+ private createResizeHandle(
+ svgRoot: SVGGElement,
+ workspace: WorkspaceSvg,
+ ): SVGImageElement {
+ const resizeHandle = dom.createSvgElement(
+ Svg.IMAGE,
+ {
+ 'class': 'blocklyResizeHandle',
+ 'href': `${workspace.options.pathToMedia}resize-handle.svg`,
+ },
+ svgRoot,
+ );
+
+ browserEvents.conditionalBind(
+ resizeHandle,
+ 'pointerdown',
+ this,
+ this.onResizePointerDown,
+ );
+
+ return resizeHandle;
+ }
+
+ /** Returns the root SVG group element of the comment view. */
+ getSvgRoot(): SVGGElement {
+ return this.svgRoot;
+ }
+
+ /** Returns the current size of the comment in workspace units. */
+ getSize(): Size {
+ return this.size;
+ }
+
+ /**
+ * Sets the size of the comment in workspace units, and updates the view
+ * elements to reflect the new size.
+ */
+ setSize(size: Size) {
+ size = new Size(
+ Math.max(size.width, MIN_SIZE.width),
+ Math.max(size.height, MIN_SIZE.height),
+ );
+
+ const oldSize = this.size;
+ this.size = size;
+ const topBarSize = this.topBar.getBBox();
+ const deleteSize = this.deleteIcon.getBBox();
+ const foldoutSize = this.foldoutIcon.getBBox();
+ const textPreviewSize = this.textPreview.getBBox();
+ const resizeSize = this.resizeHandle.getBBox();
+
+ this.svgRoot.setAttribute('height', `${size.height}`);
+ this.svgRoot.setAttribute('width', `${size.width}`);
+
+ this.topBar.setAttribute('width', `${size.width}`);
+
+ this.updateTextAreaSize(size, topBarSize);
+ this.updateDeleteIconPosition(size, topBarSize, deleteSize);
+ this.updateFoldoutIconPosition(topBarSize, foldoutSize);
+ this.updateTextPreviewSize(
+ size,
+ topBarSize,
+ textPreviewSize,
+ deleteSize,
+ resizeSize,
+ );
+
+ this.resizeHandle.setAttribute('x', `${size.width - resizeSize.width}`);
+ this.resizeHandle.setAttribute('y', `${size.height - resizeSize.height}`);
+
+ this.onSizeChange(oldSize, this.size);
+ }
+
+ /** Updates the size of the text area elements to reflect the new size. */
+ private updateTextAreaSize(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}`);
+ }
+ }
+
+ /**
+ * Updates the position of the delete icon elements to reflect the new size.
+ */
+ private updateDeleteIconPosition(
+ size: Size,
+ topBarSize: Size,
+ deleteSize: Size,
+ ) {
+ const deleteMargin = (topBarSize.height - deleteSize.height) / 2;
+ this.deleteIcon.setAttribute('y', `${deleteMargin}`);
+ this.deleteIcon.setAttribute(
+ 'x',
+ `${size.width - deleteSize.width - deleteMargin}`,
+ );
+ }
+
+ /**
+ * Updates the position of the foldout icon elements to reflect the new size.
+ */
+ private updateFoldoutIconPosition(topBarSize: Size, foldoutSize: Size) {
+ const foldoutMargin = (topBarSize.height - foldoutSize.height) / 2;
+ this.foldoutIcon.setAttribute('y', `${foldoutMargin}`);
+ this.foldoutIcon.setAttribute('x', `${foldoutMargin}`);
+ }
+
+ /**
+ * Updates the size and position of the text preview elements to reflect the new size.
+ */
+ private updateTextPreviewSize(
+ size: Size,
+ topBarSize: Size,
+ textPreviewSize: Size,
+ deleteSize: Size,
+ foldoutSize: Size,
+ ) {
+ const textPreviewMargin = (topBarSize.height - textPreviewSize.height) / 2;
+ const deleteMargin = (topBarSize.height - deleteSize.height) / 2;
+ const foldoutMargin = (topBarSize.height - foldoutSize.height) / 2;
+
+ const textPreviewWidth =
+ size.width -
+ foldoutSize.width -
+ foldoutMargin * 2 -
+ deleteSize.width -
+ deleteMargin * 2;
+ this.textPreview.setAttribute(
+ 'x',
+ `${
+ foldoutSize.width + foldoutMargin * 2 * (this.workspace.RTL ? -1 : 1)
+ }`,
+ );
+ this.textPreview.setAttribute(
+ 'y',
+ `${textPreviewMargin + textPreviewSize.height / 2}`,
+ );
+ this.textPreview.setAttribute('width', `${textPreviewWidth}`);
+ }
+
+ /**
+ * Triggers listeners when the size of the comment changes, either
+ * progrmatically or manually by the user.
+ */
+ private onSizeChange(oldSize: Size, newSize: Size) {
+ // Loop through listeners backwards in case they remove themselves.
+ for (let i = this.sizeChangeListeners.length - 1; i >= 0; i--) {
+ this.sizeChangeListeners[i](oldSize, newSize);
+ }
+ }
+
+ /**
+ * Registers a callback that listens for size changes.
+ *
+ * @param listener Receives callbacks when the size of the comment changes.
+ * The new and old size are in workspace units.
+ */
+ addSizeChangeListener(listener: (oldSize: Size, newSize: Size) => void) {
+ this.sizeChangeListeners.push(listener);
+ }
+
+ /** Removes the given listener from the list of size change listeners. */
+ removeSizeChangeListener(listener: () => void) {
+ this.sizeChangeListeners.splice(
+ this.sizeChangeListeners.indexOf(listener),
+ 1,
+ );
+ }
+
+ /**
+ * Handles starting an interaction with the resize handle to resize the
+ * comment.
+ */
+ private onResizePointerDown(e: PointerEvent) {
+ this.bringToFront();
+ if (browserEvents.isRightButton(e)) {
+ e.stopPropagation();
+ return;
+ }
+
+ // TODO(#7926): Move this into a utils file.
+ 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();
+
+ e.stopPropagation();
+ }
+
+ /** Ends an interaction with the resize handle. */
+ 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;
+ }
+ }
+
+ /** Resizes the comment in response to a drag on the resize handle. */
+ private onResizePointerMove(e: PointerEvent) {
+ // TODO(#7926): Move this into a utils file.
+ const delta = this.workspace.moveDrag(e);
+ this.setSize(new Size(this.workspace.RTL ? -delta.x : delta.x, delta.y));
+ }
+
+ /** Returns true if the comment is currently collapsed. */
+ isCollapsed(): boolean {
+ return this.collapsed;
+ }
+
+ /** Sets whether the comment is currently collapsed or not. */
+ setCollapsed(collapsed: boolean) {
+ this.collapsed = collapsed;
+ if (collapsed) {
+ dom.addClass(this.svgRoot, 'blocklyCollapsed');
+ } else {
+ dom.removeClass(this.svgRoot, 'blocklyCollapsed');
+ }
+ // Repositions resize handle and such.
+ this.setSize(this.size);
+ this.onCollapse();
+ }
+
+ /**
+ * Triggers listeners when the collapsed-ness of the comment changes, either
+ * progrmatically or manually by the user.
+ */
+ private onCollapse() {
+ // Loop through listeners backwards in case they remove themselves.
+ for (let i = this.collapseChangeListeners.length - 1; i >= 0; i--) {
+ this.collapseChangeListeners[i](this.collapsed);
+ }
+ }
+
+ /** Registers a callback that listens for collapsed-ness changes. */
+ addOnCollapseListener(listener: (newCollapse: boolean) => void) {
+ this.collapseChangeListeners.push(listener);
+ }
+
+ /** Removes the given listener from the list of on collapse listeners. */
+ removeOnCollapseListener(listener: () => void) {
+ this.collapseChangeListeners.splice(
+ this.collapseChangeListeners.indexOf(listener),
+ 1,
+ );
+ }
+
+ /**
+ * Toggles the collapsedness of the block when we receive a pointer down
+ * event on the foldout icon.
+ */
+ private onFoldoutDown(e: PointerEvent) {
+ this.bringToFront();
+ if (browserEvents.isRightButton(e)) {
+ e.stopPropagation();
+ return;
+ }
+
+ this.setCollapsed(!this.collapsed);
+
+ this.workspace.hideChaff();
+
+ e.stopPropagation();
+ }
+
+ /** Returns true if the comment is currently editable. */
+ isEditable(): boolean {
+ return this.editable;
+ }
+
+ /** Sets the editability of the comment. */
+ setEditable(editable: boolean) {
+ this.editable = editable;
+ if (this.editable) {
+ dom.addClass(this.svgRoot, 'blocklyEditable');
+ dom.removeClass(this.svgRoot, 'blocklyReadonly');
+ this.textArea.removeAttribute('readonly');
+ } else {
+ dom.removeClass(this.svgRoot, 'blocklyEditable');
+ dom.addClass(this.svgRoot, 'blocklyReadonly');
+ this.textArea.setAttribute('readonly', 'true');
+ }
+ }
+
+ /** Returns the current location of the comment in workspace coordinates. */
+ getRelativeToSurfaceXY(): Coordinate {
+ return this.location;
+ }
+
+ /**
+ * Moves the comment view to the given location.
+ *
+ * @param location The location to move to in workspace coordinates.
+ */
+ moveTo(location: Coordinate) {
+ this.location = location;
+ this.svgRoot.setAttribute(
+ 'transform',
+ `translate(${location.x}, ${location.y})`,
+ );
+ }
+
+ /** Retursn the current text of the comment. */
+ getText() {
+ return this.text;
+ }
+
+ /** Sets the current text of the comment. */
+ setText(text: string) {
+ this.textArea.value = text;
+ this.onTextChange();
+ }
+
+ /** 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,
+ );
+ }
+
+ /**
+ * Triggers listeners when the text of the comment changes, either
+ * progrmatically or manually by the user.
+ */
+ private onTextChange() {
+ const oldText = this.text;
+ this.text = this.textArea.value;
+ this.textPreviewNode.textContent = this.truncateText(this.text);
+ // 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);
+ }
+ }
+
+ /** Truncates the text to fit within the top view. */
+ private truncateText(text: string): string {
+ return text.length >= 12 ? `${text.substring(0, 9)}...` : text;
+ }
+
+ /** Brings the workspace comment to the front of its layer. */
+ private bringToFront() {
+ const parent = this.svgRoot.parentNode;
+ const childNodes = parent!.childNodes;
+ // Avoid moving the comment if it's already at the bottom.
+ if (childNodes[childNodes.length - 1] !== this.svgRoot) {
+ parent!.appendChild(this.svgRoot);
+ }
+ }
+
+ /**
+ * Handles disposing of the comment when we get a pointer down event on the
+ * delete icon.
+ */
+ private onDeleteDown(e: PointerEvent) {
+ if (browserEvents.isRightButton(e)) {
+ e.stopPropagation();
+ return;
+ }
+
+ this.dispose();
+ e.stopPropagation();
+ }
+
+ /** Disposes of this comment view. */
+ dispose() {
+ this.disposing = true;
+ dom.removeNode(this.svgRoot);
+ // Loop through listeners backwards in case they remove themselves.
+ for (let i = this.disposeListeners.length - 1; i >= 0; i--) {
+ this.disposeListeners[i]();
+ }
+ this.disposed = true;
+ }
+
+ /** Returns whether this comment view has been disposed or not. */
+ isDisposed(): boolean {
+ return this.disposed;
+ }
+
+ /**
+ * Returns true if this comment view is currently being disposed or has
+ * already been disposed.
+ */
+ isDeadOrDying(): boolean {
+ return this.disposing || this.disposed;
+ }
+
+ /** Registers a callback that listens for disposal of this view. */
+ addDisposeListener(listener: () => void) {
+ this.disposeListeners.push(listener);
+ }
+
+ /** Removes the given listener from the list of disposal listeners. */
+ removeDisposeListener(listener: () => void) {
+ this.disposeListeners.splice(this.disposeListeners.indexOf(listener), 1);
+ }
+}
+
+css.register(`
+.blocklyWorkspace {
+ --commentFillColour: #FFFCC7;
+ --commentBorderColour: #F2E49B;
+ --commentIconColour: #1A1A1A
+}
+
+.blocklyComment .blocklyTextarea {
+ background-color: var(--commentFillColour);
+ border: 1px solid var(--commentBorderColour);
+ outline: 0;
+ resize: none;
+ overflow: hidden;
+ box-sizing: border-box;
+ padding: 8px;
+ width: 100%;
+ height: 100%;
+ display: block;
+}
+
+.blocklyReadonly.blocklyComment .blocklyTextarea {
+ cursor: inherit;
+}
+
+.blocklyDeleteIcon {
+ width: 20px;
+ height: 20px;
+ display: none;
+ fill: var(--commentIconColour);
+ cursor: pointer;
+}
+
+.blocklyFoldoutIcon {
+ width: 20px;
+ height: 20px;
+ fill: var(--commentIconColour);
+ transform-origin: 12px 12px;
+ cursor: pointer;
+}
+.blocklyResizeHandle {
+ width: 12px;
+ height: 12px;
+ stroke: var(--commentIconColour);
+ cursor: se-resize;
+}
+
+.blocklyCommentTopbar {
+ fill: var(--commentBorderColour);
+ height: 24px;
+}
+
+.blocklyComment .blocklyCommentPreview.blocklyText {
+ fill: var(--commentIconColour);
+ dominant-baseline: middle;
+ display: none;
+}
+
+.blocklyCollapsed.blocklyComment .blocklyCommentPreview {
+ display: block;
+}
+
+.blocklyCollapsed.blocklyComment .blocklyCommentForeignObject,
+.blocklyCollapsed.blocklyComment .blocklyResizeHandle {
+ display: none;
+}
+
+.blocklyCollapsed.blocklyComment .blocklyFoldoutIcon {
+ transform: rotate(-90deg);
+}
+
+.blocklyRTL .blocklyComment {
+ transform: scale(-1, 1);
+}
+
+.blocklyRTL .blocklyCommentForeignObject {
+ /* Revert the scale and control RTL using direction instead. */
+ transform: scale(-1, 1);
+ direction: rtl;
+}
+
+.blocklyRTL .blocklyCommentPreview {
+ /* Revert the scale and control RTL using direction instead. */
+ transform: scale(-1, 1);
+ direction: rtl;
+}
+
+.blocklyRTL .blocklyResizeHandle {
+ cursor: sw-resize;
+}
+`);
diff --git a/core/inject.ts b/core/inject.ts
index b938abaa4..323191817 100644
--- a/core/inject.ts
+++ b/core/inject.ts
@@ -53,7 +53,10 @@ export function inject(
}
const options = new Options(opt_options || ({} as BlocklyOptions));
const subContainer = document.createElement('div');
- subContainer.className = 'injectionDiv';
+ dom.addClass(subContainer, 'injectionDiv');
+ if (opt_options?.rtl) {
+ dom.addClass(subContainer, 'blocklyRTL');
+ }
subContainer.tabIndex = 0;
aria.setState(subContainer, aria.State.LABEL, Msg['WORKSPACE_ARIA_LABEL']);
diff --git a/media/arrow-dropdown.svg b/media/arrow-dropdown.svg
new file mode 100644
index 000000000..7aeb5b174
--- /dev/null
+++ b/media/arrow-dropdown.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/media/resize-handle.svg b/media/resize-handle.svg
new file mode 100644
index 000000000..4002304e2
--- /dev/null
+++ b/media/resize-handle.svg
@@ -0,0 +1,3 @@
+
diff --git a/tests/mocha/index.html b/tests/mocha/index.html
index 6c4e5ad0c..7cebc68ed 100644
--- a/tests/mocha/index.html
+++ b/tests/mocha/index.html
@@ -106,6 +106,8 @@
import './metrics_test.js';
import './mutator_test.js';
import './names_test.js';
+ // TODO: Remove these tests.
+ import './old_workspace_comment_test.js';
import './procedure_map_test.js';
import './blocks/procedures_test.js';
import './registry_test.js';
diff --git a/tests/mocha/old_workspace_comment_test.js b/tests/mocha/old_workspace_comment_test.js
new file mode 100644
index 000000000..f2126dea2
--- /dev/null
+++ b/tests/mocha/old_workspace_comment_test.js
@@ -0,0 +1,267 @@
+/**
+ * @license
+ * Copyright 2020 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import {
+ sharedTestSetup,
+ sharedTestTeardown,
+} from './test_helpers/setup_teardown.js';
+
+suite('Workspace comment', function () {
+ setup(function () {
+ sharedTestSetup.call(this);
+ this.workspace = new Blockly.Workspace();
+ });
+
+ teardown(function () {
+ sharedTestTeardown.call(this);
+ });
+
+ suite('getTopComments(ordered=true)', function () {
+ test('No comments', function () {
+ chai.assert.equal(this.workspace.getTopComments(true).length, 0);
+ });
+
+ test('One comment', function () {
+ const comment = new Blockly.WorkspaceComment(
+ this.workspace,
+ 'comment text',
+ 0,
+ 0,
+ 'comment id',
+ );
+ chai.assert.equal(this.workspace.getTopComments(true).length, 1);
+ chai.assert.equal(this.workspace.commentDB.get('comment id'), comment);
+ });
+
+ test('After clear empty workspace', function () {
+ this.workspace.clear();
+ chai.assert.equal(this.workspace.getTopComments(true).length, 0);
+ });
+
+ test('After clear non-empty workspace', function () {
+ new Blockly.WorkspaceComment(
+ this.workspace,
+ 'comment text',
+ 0,
+ 0,
+ 'comment id',
+ );
+ this.workspace.clear();
+ chai.assert.equal(this.workspace.getTopComments(true).length, 0);
+ chai.assert.isFalse(this.workspace.commentDB.has('comment id'));
+ });
+
+ test('After dispose', function () {
+ const comment = new Blockly.WorkspaceComment(
+ this.workspace,
+ 'comment text',
+ 0,
+ 0,
+ 'comment id',
+ );
+ comment.dispose();
+ chai.assert.equal(this.workspace.getTopComments(true).length, 0);
+ chai.assert.isFalse(this.workspace.commentDB.has('comment id'));
+ });
+ });
+
+ suite('getTopComments(ordered=false)', function () {
+ test('No comments', function () {
+ chai.assert.equal(this.workspace.getTopComments(false).length, 0);
+ });
+
+ test('One comment', function () {
+ const comment = new Blockly.WorkspaceComment(
+ this.workspace,
+ 'comment text',
+ 0,
+ 0,
+ 'comment id',
+ );
+ chai.assert.equal(this.workspace.getTopComments(false).length, 1);
+ chai.assert.equal(this.workspace.commentDB.get('comment id'), comment);
+ });
+
+ test('After clear empty workspace', function () {
+ this.workspace.clear();
+ chai.assert.equal(this.workspace.getTopComments(false).length, 0);
+ });
+
+ test('After clear non-empty workspace', function () {
+ new Blockly.WorkspaceComment(
+ this.workspace,
+ 'comment text',
+ 0,
+ 0,
+ 'comment id',
+ );
+ this.workspace.clear();
+ chai.assert.equal(this.workspace.getTopComments(false).length, 0);
+ chai.assert.isFalse(this.workspace.commentDB.has('comment id'));
+ });
+
+ test('After dispose', function () {
+ const comment = new Blockly.WorkspaceComment(
+ this.workspace,
+ 'comment text',
+ 0,
+ 0,
+ 'comment id',
+ );
+ comment.dispose();
+ chai.assert.equal(this.workspace.getTopComments(false).length, 0);
+ chai.assert.isFalse(this.workspace.commentDB.has('comment id'));
+ });
+ });
+
+ suite('getCommentById', function () {
+ test('Trivial', function () {
+ const comment = new Blockly.WorkspaceComment(
+ this.workspace,
+ 'comment text',
+ 0,
+ 0,
+ 'comment id',
+ );
+ chai.assert.equal(this.workspace.getCommentById(comment.id), comment);
+ });
+
+ test('Null id', function () {
+ chai.assert.isNull(this.workspace.getCommentById(null));
+ });
+
+ test('Non-existent id', function () {
+ chai.assert.isNull(this.workspace.getCommentById('badId'));
+ });
+
+ test('After dispose', function () {
+ const comment = new Blockly.WorkspaceComment(
+ this.workspace,
+ 'comment text',
+ 0,
+ 0,
+ 'comment id',
+ );
+ comment.dispose();
+ chai.assert.isNull(this.workspace.getCommentById(comment.id));
+ });
+ });
+
+ suite('dispose', function () {
+ test('Called twice', function () {
+ const comment = new Blockly.WorkspaceComment(
+ this.workspace,
+ 'comment text',
+ 0,
+ 0,
+ 'comment id',
+ );
+ comment.dispose();
+ // Nothing should go wrong the second time dispose is called.
+ comment.dispose();
+ });
+ });
+
+ suite('Width and height', function () {
+ setup(function () {
+ this.comment = new Blockly.WorkspaceComment(
+ this.workspace,
+ 'comment text',
+ 10,
+ 20,
+ 'comment id',
+ );
+ });
+
+ test('Initial values', function () {
+ chai.assert.equal(this.comment.getWidth(), 20, 'Width');
+ chai.assert.equal(this.comment.getHeight(), 10, 'Height');
+ });
+
+ test('setWidth does not affect height', function () {
+ this.comment.setWidth(30);
+ chai.assert.equal(this.comment.getWidth(), 30, 'Width');
+ chai.assert.equal(this.comment.getHeight(), 10, 'Height');
+ });
+
+ test('setHeight does not affect width', function () {
+ this.comment.setHeight(30);
+ chai.assert.equal(this.comment.getWidth(), 20, 'Width');
+ chai.assert.equal(this.comment.getHeight(), 30, 'Height');
+ });
+ });
+
+ suite('XY position', function () {
+ setup(function () {
+ this.comment = new Blockly.WorkspaceComment(
+ this.workspace,
+ 'comment text',
+ 10,
+ 20,
+ 'comment id',
+ );
+ });
+
+ test('Initial position', function () {
+ const xy = this.comment.getRelativeToSurfaceXY();
+ chai.assert.equal(xy.x, 0, 'Initial X position');
+ chai.assert.equal(xy.y, 0, 'Initial Y position');
+ });
+
+ test('moveBy', function () {
+ this.comment.moveBy(10, 100);
+ const xy = this.comment.getRelativeToSurfaceXY();
+ chai.assert.equal(xy.x, 10, 'New X position');
+ chai.assert.equal(xy.y, 100, 'New Y position');
+ });
+ });
+
+ suite('Content', function () {
+ setup(function () {
+ this.comment = new Blockly.WorkspaceComment(
+ this.workspace,
+ 'comment text',
+ 0,
+ 0,
+ 'comment id',
+ );
+ });
+
+ teardown(function () {
+ sinon.restore();
+ });
+
+ test('After creation', function () {
+ chai.assert.equal(this.comment.getContent(), 'comment text');
+ chai.assert.equal(
+ this.workspace.undoStack_.length,
+ 1,
+ 'Workspace undo stack',
+ );
+ });
+
+ test('Set to same value', function () {
+ this.comment.setContent('comment text');
+ chai.assert.equal(this.comment.getContent(), 'comment text');
+ // Setting the text to the old value does not fire an event.
+ chai.assert.equal(
+ this.workspace.undoStack_.length,
+ 1,
+ 'Workspace undo stack',
+ );
+ });
+
+ test('Set to different value', function () {
+ this.comment.setContent('new comment text');
+ chai.assert.equal(this.comment.getContent(), 'new comment text');
+ chai.assert.equal(
+ this.workspace.undoStack_.length,
+ 2,
+ 'Workspace undo stack',
+ );
+ });
+ });
+});
diff --git a/tests/mocha/workspace_comment_test.js b/tests/mocha/workspace_comment_test.js
index f2126dea2..6650848e5 100644
--- a/tests/mocha/workspace_comment_test.js
+++ b/tests/mocha/workspace_comment_test.js
@@ -1,6 +1,6 @@
/**
* @license
- * Copyright 2020 Google LLC
+ * Copyright 2024 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
@@ -12,256 +12,188 @@ import {
suite('Workspace comment', function () {
setup(function () {
sharedTestSetup.call(this);
- this.workspace = new Blockly.Workspace();
+ this.workspace = new Blockly.inject('blocklyDiv', {});
+ this.commentView = new Blockly.comments.CommentView(this.workspace);
});
teardown(function () {
sharedTestTeardown.call(this);
});
- suite('getTopComments(ordered=true)', function () {
- test('No comments', function () {
- chai.assert.equal(this.workspace.getTopComments(true).length, 0);
+ suite('Listeners', function () {
+ suite('Text change listeners', function () {
+ test('text change listeners are called when text is changed', function () {
+ const spy = sinon.spy();
+ this.commentView.addTextChangeListener(spy);
+
+ this.commentView.setText('test');
+
+ chai.assert.isTrue(
+ spy.calledOnce,
+ 'Expected the spy to be called once',
+ );
+ chai.assert.isTrue(
+ spy.calledWith('', 'test'),
+ 'Expected the spy to be called with the given args',
+ );
+ });
+
+ test('text change listeners can remove themselves without skipping others', function () {
+ const fake1 = sinon.fake();
+ const fake2 = sinon.fake(() =>
+ this.commentView.removeTextChangeListener(fake2),
+ );
+ const fake3 = sinon.fake();
+ this.commentView.addTextChangeListener(fake1);
+ this.commentView.addTextChangeListener(fake2);
+ this.commentView.addTextChangeListener(fake3);
+
+ this.commentView.setText('test');
+
+ chai.assert.isTrue(
+ fake1.calledOnce,
+ 'Expected the first listener to be called',
+ );
+ chai.assert.isTrue(
+ fake2.calledOnce,
+ 'Expected the second listener to be called',
+ );
+ chai.assert.isTrue(
+ fake3.calledOnce,
+ 'Expected the third listener to be called',
+ );
+ });
});
- test('One comment', function () {
- const comment = new Blockly.WorkspaceComment(
- this.workspace,
- 'comment text',
- 0,
- 0,
- 'comment id',
- );
- chai.assert.equal(this.workspace.getTopComments(true).length, 1);
- chai.assert.equal(this.workspace.commentDB.get('comment id'), comment);
+ suite('Size change listeners', function () {
+ test('size change listeners are called when text is changed', function () {
+ const spy = sinon.spy();
+ this.commentView.addSizeChangeListener(spy);
+ const originalSize = this.commentView.getSize();
+ const newSize = new Blockly.utils.Size(1337, 1337);
+
+ this.commentView.setSize(newSize);
+
+ chai.assert.isTrue(
+ spy.calledOnce,
+ 'Expected the spy to be called once',
+ );
+ chai.assert.isTrue(
+ spy.calledWith(originalSize, newSize),
+ 'Expected the spy to be called with the given args',
+ );
+ });
+
+ test('size change listeners can remove themselves without skipping others', function () {
+ const fake1 = sinon.fake();
+ const fake2 = sinon.fake(() =>
+ this.commentView.removeSizeChangeListener(fake2),
+ );
+ const fake3 = sinon.fake();
+ this.commentView.addSizeChangeListener(fake1);
+ this.commentView.addSizeChangeListener(fake2);
+ this.commentView.addSizeChangeListener(fake3);
+ const newSize = new Blockly.utils.Size(1337, 1337);
+
+ this.commentView.setSize(newSize);
+
+ chai.assert.isTrue(
+ fake1.calledOnce,
+ 'Expected the first listener to be called',
+ );
+ chai.assert.isTrue(
+ fake2.calledOnce,
+ 'Expected the second listener to be called',
+ );
+ chai.assert.isTrue(
+ fake3.calledOnce,
+ 'Expected the third listener to be called',
+ );
+ });
});
- test('After clear empty workspace', function () {
- this.workspace.clear();
- chai.assert.equal(this.workspace.getTopComments(true).length, 0);
+ suite('Collapse change listeners', function () {
+ test('collapse change listeners are called when text is changed', function () {
+ const spy = sinon.spy();
+ this.commentView.addOnCollapseListener(spy);
+
+ this.commentView.setCollapsed(true);
+
+ chai.assert.isTrue(
+ spy.calledOnce,
+ 'Expected the spy to be called once',
+ );
+ chai.assert.isTrue(
+ spy.calledWith(true),
+ 'Expected the spy to be called with the given args',
+ );
+ });
+
+ test('collapse change listeners can remove themselves without skipping others', function () {
+ const fake1 = sinon.fake();
+ const fake2 = sinon.fake(() =>
+ this.commentView.removeOnCollapseListener(fake2),
+ );
+ const fake3 = sinon.fake();
+ this.commentView.addOnCollapseListener(fake1);
+ this.commentView.addOnCollapseListener(fake2);
+ this.commentView.addOnCollapseListener(fake3);
+
+ this.commentView.setCollapsed(true);
+
+ chai.assert.isTrue(
+ fake1.calledOnce,
+ 'Expected the first listener to be called',
+ );
+ chai.assert.isTrue(
+ fake2.calledOnce,
+ 'Expected the second listener to be called',
+ );
+ chai.assert.isTrue(
+ fake3.calledOnce,
+ 'Expected the third listener to be called',
+ );
+ });
});
- test('After clear non-empty workspace', function () {
- new Blockly.WorkspaceComment(
- this.workspace,
- 'comment text',
- 0,
- 0,
- 'comment id',
- );
- this.workspace.clear();
- chai.assert.equal(this.workspace.getTopComments(true).length, 0);
- chai.assert.isFalse(this.workspace.commentDB.has('comment id'));
- });
+ suite('Dispose change listeners', function () {
+ test('dispose listeners are called when text is changed', function () {
+ const spy = sinon.spy();
+ this.commentView.addDisposeListener(spy);
- test('After dispose', function () {
- const comment = new Blockly.WorkspaceComment(
- this.workspace,
- 'comment text',
- 0,
- 0,
- 'comment id',
- );
- comment.dispose();
- chai.assert.equal(this.workspace.getTopComments(true).length, 0);
- chai.assert.isFalse(this.workspace.commentDB.has('comment id'));
- });
- });
+ this.commentView.dispose();
- suite('getTopComments(ordered=false)', function () {
- test('No comments', function () {
- chai.assert.equal(this.workspace.getTopComments(false).length, 0);
- });
+ chai.assert.isTrue(
+ spy.calledOnce,
+ 'Expected the spy to be called once',
+ );
+ });
- test('One comment', function () {
- const comment = new Blockly.WorkspaceComment(
- this.workspace,
- 'comment text',
- 0,
- 0,
- 'comment id',
- );
- chai.assert.equal(this.workspace.getTopComments(false).length, 1);
- chai.assert.equal(this.workspace.commentDB.get('comment id'), comment);
- });
+ test('dispose listeners can remove themselves without skipping others', function () {
+ const fake1 = sinon.fake();
+ const fake2 = sinon.fake(() =>
+ this.commentView.removeDisposeListener(fake2),
+ );
+ const fake3 = sinon.fake();
+ this.commentView.addDisposeListener(fake1);
+ this.commentView.addDisposeListener(fake2);
+ this.commentView.addDisposeListener(fake3);
- test('After clear empty workspace', function () {
- this.workspace.clear();
- chai.assert.equal(this.workspace.getTopComments(false).length, 0);
- });
+ this.commentView.dispose();
- test('After clear non-empty workspace', function () {
- new Blockly.WorkspaceComment(
- this.workspace,
- 'comment text',
- 0,
- 0,
- 'comment id',
- );
- this.workspace.clear();
- chai.assert.equal(this.workspace.getTopComments(false).length, 0);
- chai.assert.isFalse(this.workspace.commentDB.has('comment id'));
- });
-
- test('After dispose', function () {
- const comment = new Blockly.WorkspaceComment(
- this.workspace,
- 'comment text',
- 0,
- 0,
- 'comment id',
- );
- comment.dispose();
- chai.assert.equal(this.workspace.getTopComments(false).length, 0);
- chai.assert.isFalse(this.workspace.commentDB.has('comment id'));
- });
- });
-
- suite('getCommentById', function () {
- test('Trivial', function () {
- const comment = new Blockly.WorkspaceComment(
- this.workspace,
- 'comment text',
- 0,
- 0,
- 'comment id',
- );
- chai.assert.equal(this.workspace.getCommentById(comment.id), comment);
- });
-
- test('Null id', function () {
- chai.assert.isNull(this.workspace.getCommentById(null));
- });
-
- test('Non-existent id', function () {
- chai.assert.isNull(this.workspace.getCommentById('badId'));
- });
-
- test('After dispose', function () {
- const comment = new Blockly.WorkspaceComment(
- this.workspace,
- 'comment text',
- 0,
- 0,
- 'comment id',
- );
- comment.dispose();
- chai.assert.isNull(this.workspace.getCommentById(comment.id));
- });
- });
-
- suite('dispose', function () {
- test('Called twice', function () {
- const comment = new Blockly.WorkspaceComment(
- this.workspace,
- 'comment text',
- 0,
- 0,
- 'comment id',
- );
- comment.dispose();
- // Nothing should go wrong the second time dispose is called.
- comment.dispose();
- });
- });
-
- suite('Width and height', function () {
- setup(function () {
- this.comment = new Blockly.WorkspaceComment(
- this.workspace,
- 'comment text',
- 10,
- 20,
- 'comment id',
- );
- });
-
- test('Initial values', function () {
- chai.assert.equal(this.comment.getWidth(), 20, 'Width');
- chai.assert.equal(this.comment.getHeight(), 10, 'Height');
- });
-
- test('setWidth does not affect height', function () {
- this.comment.setWidth(30);
- chai.assert.equal(this.comment.getWidth(), 30, 'Width');
- chai.assert.equal(this.comment.getHeight(), 10, 'Height');
- });
-
- test('setHeight does not affect width', function () {
- this.comment.setHeight(30);
- chai.assert.equal(this.comment.getWidth(), 20, 'Width');
- chai.assert.equal(this.comment.getHeight(), 30, 'Height');
- });
- });
-
- suite('XY position', function () {
- setup(function () {
- this.comment = new Blockly.WorkspaceComment(
- this.workspace,
- 'comment text',
- 10,
- 20,
- 'comment id',
- );
- });
-
- test('Initial position', function () {
- const xy = this.comment.getRelativeToSurfaceXY();
- chai.assert.equal(xy.x, 0, 'Initial X position');
- chai.assert.equal(xy.y, 0, 'Initial Y position');
- });
-
- test('moveBy', function () {
- this.comment.moveBy(10, 100);
- const xy = this.comment.getRelativeToSurfaceXY();
- chai.assert.equal(xy.x, 10, 'New X position');
- chai.assert.equal(xy.y, 100, 'New Y position');
- });
- });
-
- suite('Content', function () {
- setup(function () {
- this.comment = new Blockly.WorkspaceComment(
- this.workspace,
- 'comment text',
- 0,
- 0,
- 'comment id',
- );
- });
-
- teardown(function () {
- sinon.restore();
- });
-
- test('After creation', function () {
- chai.assert.equal(this.comment.getContent(), 'comment text');
- chai.assert.equal(
- this.workspace.undoStack_.length,
- 1,
- 'Workspace undo stack',
- );
- });
-
- test('Set to same value', function () {
- this.comment.setContent('comment text');
- chai.assert.equal(this.comment.getContent(), 'comment text');
- // Setting the text to the old value does not fire an event.
- chai.assert.equal(
- this.workspace.undoStack_.length,
- 1,
- 'Workspace undo stack',
- );
- });
-
- test('Set to different value', function () {
- this.comment.setContent('new comment text');
- chai.assert.equal(this.comment.getContent(), 'new comment text');
- chai.assert.equal(
- this.workspace.undoStack_.length,
- 2,
- 'Workspace undo stack',
- );
+ chai.assert.isTrue(
+ fake1.calledOnce,
+ 'Expected the first listener to be called',
+ );
+ chai.assert.isTrue(
+ fake2.calledOnce,
+ 'Expected the second listener to be called',
+ );
+ chai.assert.isTrue(
+ fake3.calledOnce,
+ 'Expected the third listener to be called',
+ );
+ });
});
});
});