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', + ); + }); }); }); });