diff --git a/core/bump_objects.ts b/core/bump_objects.ts index 3ceae2dbcfd..f9495b3d822 100644 --- a/core/bump_objects.ts +++ b/core/bump_objects.ts @@ -12,6 +12,7 @@ import type {BlockCreate} from './events/events_block_create.js'; import type {BlockMove} from './events/events_block_move.js'; import type {CommentCreate} from './events/events_comment_create.js'; import type {CommentMove} from './events/events_comment_move.js'; +import type {CommentResize} from './events/events_comment_resize.js'; import type {ViewportChange} from './events/events_viewport.js'; import * as eventUtils from './events/utils.js'; import type {IBoundedElement} from './interfaces/i_bounded_element.js'; @@ -163,8 +164,9 @@ function extractObjectFromEvent( break; case eventUtils.COMMENT_CREATE: case eventUtils.COMMENT_MOVE: + case eventUtils.COMMENT_RESIZE: object = workspace.getCommentById( - (e as CommentCreate | CommentMove).commentId!, + (e as CommentCreate | CommentMove | CommentResize).commentId!, ) as RenderedWorkspaceComment; break; } diff --git a/core/comments/comment_view.ts b/core/comments/comment_view.ts index 52f92b56776..006238db9d6 100644 --- a/core/comments/comment_view.ts +++ b/core/comments/comment_view.ts @@ -125,7 +125,7 @@ export class CommentView implements IRenderedElement { workspace.getLayerManager()?.append(this, layers.BLOCK); // Set size to the default size. - this.setSize(this.size); + this.setSizeWithoutFiringEvents(this.size); // Set default transform (including inverted scale for RTL). this.moveTo(new Coordinate(0, 0)); @@ -298,7 +298,7 @@ export class CommentView implements IRenderedElement { * Sets the size of the comment in workspace units, and updates the view * elements to reflect the new size. */ - setSize(size: Size) { + setSizeWithoutFiringEvents(size: Size) { const topBarSize = this.topBarBackground.getBBox(); const deleteSize = this.deleteIcon.getBBox(); const foldoutSize = this.foldoutIcon.getBBox(); @@ -309,7 +309,6 @@ export class CommentView implements IRenderedElement { size, this.calcMinSize(topBarSize, foldoutSize, deleteSize), ); - const oldSize = this.size; this.size = size; this.svgRoot.setAttribute('height', `${size.height}`); @@ -328,7 +327,15 @@ export class CommentView implements IRenderedElement { resizeSize, ); this.updateResizeHandlePosition(size, resizeSize); + } + /** + * Sets the size of the comment in workspace units, updates the view + * elements to reflect the new size, and triggers size change listeners. + */ + setSize(size: Size) { + const oldSize = this.size; + this.setSizeWithoutFiringEvents(size); this.onSizeChange(oldSize, this.size); } @@ -472,7 +479,7 @@ export class CommentView implements IRenderedElement { /** * Triggers listeners when the size of the comment changes, either - * progrmatically or manually by the user. + * programmatically or manually by the user. */ private onSizeChange(oldSize: Size, newSize: Size) { // Loop through listeners backwards in case they remove themselves. @@ -550,13 +557,17 @@ export class CommentView implements IRenderedElement { browserEvents.unbind(this.resizePointerMoveListener); this.resizePointerMoveListener = null; } + // When ending a resize drag, notify size change listeners to fire an event. + this.setSize(this.size); } /** 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)); + const size = this.workspace.moveDrag(e); + this.setSizeWithoutFiringEvents( + new Size(this.workspace.RTL ? -size.x : size.x, size.y), + ); } /** Returns true if the comment is currently collapsed. */ @@ -573,7 +584,7 @@ export class CommentView implements IRenderedElement { dom.removeClass(this.svgRoot, 'blocklyCollapsed'); } // Repositions resize handle and such. - this.setSize(this.size); + this.setSizeWithoutFiringEvents(this.size); this.onCollapse(); } @@ -682,7 +693,7 @@ export class CommentView implements IRenderedElement { /** * Triggers listeners when the text of the comment changes, either - * progrmatically or manually by the user. + * programmatically or manually by the user. */ private onTextChange() { const oldText = this.text; diff --git a/core/comments/workspace_comment.ts b/core/comments/workspace_comment.ts index 3c23aba86a2..0764b5168d2 100644 --- a/core/comments/workspace_comment.ts +++ b/core/comments/workspace_comment.ts @@ -10,6 +10,7 @@ import {Coordinate} from '../utils/coordinate.js'; import * as idGenerator from '../utils/idgenerator.js'; import * as eventUtils from '../events/utils.js'; import {CommentMove} from '../events/events_comment_move.js'; +import {CommentResize} from '../events/events_comment_resize.js'; export class WorkspaceComment { /** The unique identifier for this comment. */ @@ -104,7 +105,14 @@ export class WorkspaceComment { /** Sets the comment's size in workspace units. */ setSize(size: Size) { + const event = new (eventUtils.get(eventUtils.COMMENT_RESIZE))( + this, + ) as CommentResize; + this.size = size; + + event.recordCurrentSizeAsNewSize(); + eventUtils.fire(event); } /** Returns the comment's size in workspace units. */ @@ -196,7 +204,7 @@ export class WorkspaceComment { this.location = location; event.recordNew(); - if (eventUtils.isEnabled()) eventUtils.fire(event); + eventUtils.fire(event); } /** Returns the position of the comment in workspace coordinates. */ diff --git a/core/dragging/comment_drag_strategy.ts b/core/dragging/comment_drag_strategy.ts index 804ad2e9c9e..2ad5fa144fe 100644 --- a/core/dragging/comment_drag_strategy.ts +++ b/core/dragging/comment_drag_strategy.ts @@ -29,6 +29,7 @@ export class CommentDragStrategy implements IDragStrategy { if (!eventUtils.getGroup()) { eventUtils.setGroup(true); } + this.fireDragStartEvent(); this.startLoc = this.comment.getRelativeToSurfaceXY(); this.workspace.setResizesEnabled(false); this.workspace.getLayerManager()?.moveToDragLayer(this.comment); @@ -40,6 +41,7 @@ export class CommentDragStrategy implements IDragStrategy { } endDrag(): void { + this.fireDragEndEvent(); this.fireMoveEvent(); this.workspace @@ -53,6 +55,25 @@ export class CommentDragStrategy implements IDragStrategy { eventUtils.setGroup(false); } + /** Fire a UI event at the start of a comment drag. */ + private fireDragStartEvent() { + const event = new (eventUtils.get(eventUtils.COMMENT_DRAG))( + this.comment, + true, + ); + eventUtils.fire(event); + } + + /** Fire a UI event at the end of a comment drag. */ + private fireDragEndEvent() { + const event = new (eventUtils.get(eventUtils.COMMENT_DRAG))( + this.comment, + false, + ); + eventUtils.fire(event); + } + + /** Fire a move event at the end of a comment drag. */ private fireMoveEvent() { if (this.comment.isDeadOrDying()) return; const event = new (eventUtils.get(eventUtils.COMMENT_MOVE))( diff --git a/core/events/events.ts b/core/events/events.ts index bb8011755fc..b31cf7dc788 100644 --- a/core/events/events.ts +++ b/core/events/events.ts @@ -24,6 +24,8 @@ import {CommentChange, CommentChangeJson} from './events_comment_change.js'; import {CommentCreate, CommentCreateJson} from './events_comment_create.js'; import {CommentDelete} from './events_comment_delete.js'; import {CommentMove, CommentMoveJson} from './events_comment_move.js'; +import {CommentResize, CommentResizeJson} from './events_comment_resize.js'; +import {CommentDrag, CommentDragJson} from './events_comment_drag.js'; import { CommentCollapse, CommentCollapseJson, @@ -77,6 +79,10 @@ export {CommentCreateJson}; export {CommentDelete}; export {CommentMove}; export {CommentMoveJson}; +export {CommentResize}; +export {CommentResizeJson}; +export {CommentDrag}; +export {CommentDragJson}; export {CommentCollapse}; export {CommentCollapseJson}; export {FinishedLoading}; @@ -119,6 +125,8 @@ export const COMMENT_CHANGE = eventUtils.COMMENT_CHANGE; export const COMMENT_CREATE = eventUtils.COMMENT_CREATE; export const COMMENT_DELETE = eventUtils.COMMENT_DELETE; export const COMMENT_MOVE = eventUtils.COMMENT_MOVE; +export const COMMENT_RESIZE = eventUtils.COMMENT_RESIZE; +export const COMMENT_DRAG = eventUtils.COMMENT_DRAG; export const CREATE = eventUtils.CREATE; export const DELETE = eventUtils.DELETE; export const FINISHED_LOADING = eventUtils.FINISHED_LOADING; diff --git a/core/events/events_comment_drag.ts b/core/events/events_comment_drag.ts new file mode 100644 index 00000000000..80caeea52ff --- /dev/null +++ b/core/events/events_comment_drag.ts @@ -0,0 +1,99 @@ +/** + * @license + * Copyright 2024 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Events fired when a workspace comment is dragged. + */ + +import type {WorkspaceComment} from '../comments/workspace_comment.js'; +import * as registry from '../registry.js'; +import {AbstractEventJson} from './events_abstract.js'; +import {UiBase} from './events_ui_base.js'; +import * as eventUtils from './utils.js'; +import {Workspace} from '../workspace.js'; + +/** + * Notifies listeners when a comment is being manually dragged/dropped. + */ +export class CommentDrag extends UiBase { + /** The ID of the top-level comment being dragged. */ + commentId?: string; + + /** True if this is the start of a drag, false if this is the end of one. */ + isStart?: boolean; + + override type = eventUtils.COMMENT_DRAG; + + /** + * @param opt_comment The comment that is being dragged. + * Undefined for a blank event. + * @param opt_isStart Whether this is the start of a comment drag. + * Undefined for a blank event. + */ + constructor(opt_comment?: WorkspaceComment, opt_isStart?: boolean) { + const workspaceId = opt_comment ? opt_comment.workspace.id : undefined; + super(workspaceId); + if (!opt_comment) return; + + this.commentId = opt_comment.id; + this.isStart = opt_isStart; + } + + /** + * Encode the event as JSON. + * + * @returns JSON representation. + */ + override toJson(): CommentDragJson { + const json = super.toJson() as CommentDragJson; + if (this.isStart === undefined) { + throw new Error( + 'Whether this event is the start of a drag is undefined. ' + + 'Either pass the value to the constructor, or call fromJson', + ); + } + if (this.commentId === undefined) { + throw new Error( + 'The comment ID is undefined. Either pass a comment to ' + + 'the constructor, or call fromJson', + ); + } + json['isStart'] = this.isStart; + json['commentId'] = this.commentId; + return json; + } + + /** + * Deserializes the JSON event. + * + * @param event The event to append new properties to. Should be a subclass + * of CommentDrag, but we can't specify that due to the fact that parameters + * to static methods in subclasses must be supertypes of parameters to + * static methods in superclasses. + * @internal + */ + static fromJson( + json: CommentDragJson, + workspace: Workspace, + event?: any, + ): CommentDrag { + const newEvent = super.fromJson( + json, + workspace, + event ?? new CommentDrag(), + ) as CommentDrag; + newEvent.isStart = json['isStart']; + newEvent.commentId = json['commentId']; + return newEvent; + } +} + +export interface CommentDragJson extends AbstractEventJson { + isStart: boolean; + commentId: string; +} + +registry.register(registry.Type.EVENT, eventUtils.COMMENT_DRAG, CommentDrag); diff --git a/core/events/events_comment_resize.ts b/core/events/events_comment_resize.ts new file mode 100644 index 00000000000..dfc785832f4 --- /dev/null +++ b/core/events/events_comment_resize.ts @@ -0,0 +1,174 @@ +/** + * @license + * Copyright 2024 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Class for comment resize event. + */ + +import * as registry from '../registry.js'; +import {Size} from '../utils/size.js'; +import type {WorkspaceComment} from '../comments/workspace_comment.js'; + +import {CommentBase, CommentBaseJson} from './events_comment_base.js'; +import * as eventUtils from './utils.js'; +import type {Workspace} from '../workspace.js'; + +/** + * Notifies listeners that a workspace comment has resized. + */ +export class CommentResize extends CommentBase { + override type = eventUtils.COMMENT_RESIZE; + + /** The size of the comment before the resize. */ + oldSize?: Size; + + /** The size of the comment after the resize. */ + newSize?: Size; + + /** + * @param opt_comment The comment that is being resized. Undefined for a blank + * event. + */ + constructor(opt_comment?: WorkspaceComment) { + super(opt_comment); + + if (!opt_comment) { + return; // Blank event to be populated by fromJson. + } + + this.oldSize = opt_comment.getSize(); + } + + /** + * Record the comment's new size. Called after the resize. Can only be + * called once. + */ + recordCurrentSizeAsNewSize() { + if (this.newSize) { + throw Error( + 'Tried to record the new size of a comment on the ' + + 'same event twice.', + ); + } + const workspace = this.getEventWorkspace_(); + if (!this.commentId) { + throw new Error( + 'The comment ID is undefined. Either pass a comment to ' + + 'the constructor, or call fromJson', + ); + } + const comment = workspace.getCommentById(this.commentId); + if (!comment) { + throw new Error( + 'The comment associated with the comment resize event ' + + 'could not be found', + ); + } + this.newSize = comment.getSize(); + } + + /** + * Encode the event as JSON. + * + * @returns JSON representation. + */ + override toJson(): CommentResizeJson { + const json = super.toJson() as CommentResizeJson; + if (!this.oldSize) { + throw new Error( + 'The old comment size is undefined. Either pass a comment to ' + + 'the constructor, or call fromJson', + ); + } + if (!this.newSize) { + throw new Error( + 'The new comment size is undefined. Either call ' + + 'recordCurrentSizeAsNewSize, or call fromJson', + ); + } + json['oldWidth'] = Math.round(this.oldSize.width); + json['oldHeight'] = Math.round(this.oldSize.height); + json['newWidth'] = Math.round(this.newSize.width); + json['newHeight'] = Math.round(this.newSize.height); + return json; + } + + /** + * Deserializes the JSON event. + * + * @param event The event to append new properties to. Should be a subclass + * of CommentResize, but we can't specify that due to the fact that + * parameters to static methods in subclasses must be supertypes of + * parameters to static methods in superclasses. + * @internal + */ + static fromJson( + json: CommentResizeJson, + workspace: Workspace, + event?: any, + ): CommentResize { + const newEvent = super.fromJson( + json, + workspace, + event ?? new CommentResize(), + ) as CommentResize; + newEvent.oldSize = new Size(json['oldWidth'], json['oldHeight']); + newEvent.newSize = new Size(json['newWidth'], json['newHeight']); + return newEvent; + } + + /** + * Does this event record any change of state? + * + * @returns False if something changed. + */ + override isNull(): boolean { + return Size.equals(this.oldSize, this.newSize); + } + + /** + * Run a resize event. + * + * @param forward True if run forward, false if run backward (undo). + */ + override run(forward: boolean) { + const workspace = this.getEventWorkspace_(); + if (!this.commentId) { + throw new Error( + 'The comment ID is undefined. Either pass a comment to ' + + 'the constructor, or call fromJson', + ); + } + const comment = workspace.getCommentById(this.commentId); + if (!comment) { + console.warn("Can't resize non-existent comment: " + this.commentId); + return; + } + + const size = forward ? this.newSize : this.oldSize; + if (!size) { + throw new Error( + 'Either oldSize or newSize is undefined. ' + + 'Either pass a comment to the constructor and call ' + + 'recordCurrentSizeAsNewSize, or call fromJson', + ); + } + comment.setSize(size); + } +} + +export interface CommentResizeJson extends CommentBaseJson { + oldWidth: number; + oldHeight: number; + newWidth: number; + newHeight: number; +} + +registry.register( + registry.Type.EVENT, + eventUtils.COMMENT_RESIZE, + CommentResize, +); diff --git a/core/events/utils.ts b/core/events/utils.ts index eacf0490673..2d434594b19 100644 --- a/core/events/utils.ts +++ b/core/events/utils.ts @@ -19,6 +19,7 @@ import type {BlockCreate} from './events_block_create.js'; import type {BlockMove} from './events_block_move.js'; import type {CommentCreate} from './events_comment_create.js'; import type {CommentMove} from './events_comment_move.js'; +import type {CommentResize} from './events_comment_resize.js'; import type {ViewportChange} from './events_viewport.js'; /** Group ID for new events. Grouped events are indivisible. */ @@ -116,7 +117,7 @@ export const VAR_RENAME = 'var_rename'; export const UI = 'ui'; /** - * Name of event that record a block drags a block. + * Name of event that drags a block. */ export const BLOCK_DRAG = 'drag'; @@ -180,7 +181,13 @@ export const COMMENT_CHANGE = 'comment_change'; */ export const COMMENT_MOVE = 'comment_move'; -/** Type of event that moves a comment. */ +/** Name of event that resizes a comment. */ +export const COMMENT_RESIZE = 'comment_resize'; + +/** Name of event that drags a comment. */ +export const COMMENT_DRAG = 'comment_drag'; + +/** Type of event that collapses a comment. */ export const COMMENT_COLLAPSE = 'comment_collapse'; /** @@ -201,7 +208,12 @@ const ORPHANED_BLOCK_DISABLED_REASON = 'ORPHANED_BLOCK'; * Not to be confused with bumping so that disconnected connections do not * appear connected. */ -export type BumpEvent = BlockCreate | BlockMove | CommentCreate | CommentMove; +export type BumpEvent = + | BlockCreate + | BlockMove + | CommentCreate + | CommentMove + | CommentResize; /** * List of events that cause objects to be bumped back into the visible diff --git a/core/utils/size.ts b/core/utils/size.ts index 705dc2c2897..4b5eede751b 100644 --- a/core/utils/size.ts +++ b/core/utils/size.ts @@ -34,7 +34,7 @@ export class Size { * @returns True iff the sizes have equal widths and equal heights, or if both * are null. */ - static equals(a: Size | null, b: Size | null): boolean { + static equals(a?: Size | null, b?: Size | null): boolean { if (a === b) { return true; } diff --git a/tests/mocha/event_comment_drag_test.js b/tests/mocha/event_comment_drag_test.js new file mode 100644 index 00000000000..d214e0adba1 --- /dev/null +++ b/tests/mocha/event_comment_drag_test.js @@ -0,0 +1,35 @@ +/** + * @license + * Copyright 2024 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {assert} from '../../node_modules/chai/chai.js'; +import { + sharedTestSetup, + sharedTestTeardown, +} from './test_helpers/setup_teardown.js'; + +suite('Comment Drag Event', function () { + setup(function () { + sharedTestSetup.call(this); + this.workspace = new Blockly.Workspace(); + }); + + teardown(function () { + sharedTestTeardown.call(this); + }); + + suite('Serialization', function () { + test('events round-trip through JSON', function () { + const comment = new Blockly.comments.WorkspaceComment(this.workspace); + comment.setText('test text'); + + const origEvent = new Blockly.Events.CommentDrag(comment, true); + const json = origEvent.toJson(); + const newEvent = new Blockly.Events.fromJson(json, this.workspace); + + assert.deepEqual(newEvent, origEvent); + }); + }); +}); diff --git a/tests/mocha/event_comment_resize_test.js b/tests/mocha/event_comment_resize_test.js new file mode 100644 index 00000000000..b74e1abb2bf --- /dev/null +++ b/tests/mocha/event_comment_resize_test.js @@ -0,0 +1,38 @@ +/** + * @license + * Copyright 2024 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {assert} from '../../node_modules/chai/chai.js'; +import { + sharedTestSetup, + sharedTestTeardown, +} from './test_helpers/setup_teardown.js'; + +suite('Comment Resize Event', function () { + setup(function () { + sharedTestSetup.call(this); + this.workspace = new Blockly.Workspace(); + }); + + teardown(function () { + sharedTestTeardown.call(this); + }); + + suite('Serialization', function () { + test('events round-trip through JSON', function () { + const comment = new Blockly.comments.WorkspaceComment(this.workspace); + comment.setText('test text'); + comment.setSize(new Blockly.utils.Size(100, 100)); + const origEvent = new Blockly.Events.CommentResize(comment); + comment.setSize(new Blockly.utils.Size(200, 200)); + origEvent.recordCurrentSizeAsNewSize(); + + const json = origEvent.toJson(); + const newEvent = new Blockly.Events.fromJson(json, this.workspace); + + assert.deepEqual(newEvent, origEvent); + }); + }); +}); diff --git a/tests/mocha/event_test.js b/tests/mocha/event_test.js index 4709297d845..0a4e96d2b93 100644 --- a/tests/mocha/event_test.js +++ b/tests/mocha/event_test.js @@ -862,7 +862,30 @@ suite('Events', function () { }, }), }, + { + title: 'Comment drag start', + class: Blockly.Events.CommentDrag, + getArgs: (thisObj) => [thisObj.comment, true], + getExpectedJson: (thisObj) => ({ + type: 'comment_drag', + group: '', + isStart: true, + commentId: thisObj.comment.id, + }), + }, + { + title: 'Comment drag end', + class: Blockly.Events.CommentDrag, + getArgs: (thisObj) => [thisObj.comment, false], + getExpectedJson: (thisObj) => ({ + type: 'comment_drag', + group: '', + isStart: false, + commentId: thisObj.comment.id, + }), + }, // TODO(#4577) Test serialization of move event coordinate properties. + // TODO(#4577) Test serialization of comment resize event properties. ]; const testSuites = [ { diff --git a/tests/mocha/index.html b/tests/mocha/index.html index e095f402a09..ff3467907d7 100644 --- a/tests/mocha/index.html +++ b/tests/mocha/index.html @@ -66,6 +66,8 @@ import './event_comment_create_test.js'; import './event_comment_delete_test.js'; import './event_comment_move_test.js'; + import './event_comment_drag_test.js'; + import './event_comment_resize_test.js'; import './event_marker_move_test.js'; import './event_selected_test.js'; import './event_theme_change_test.js'; diff --git a/tests/mocha/workspace_comment_test.js b/tests/mocha/workspace_comment_test.js index aa42cfbe2ac..ece1819e434 100644 --- a/tests/mocha/workspace_comment_test.js +++ b/tests/mocha/workspace_comment_test.js @@ -81,6 +81,28 @@ suite('Workspace comment', function () { ); }); + test('resize events are fired when a comment is resized', function () { + this.renderedComment = new Blockly.comments.RenderedWorkspaceComment( + this.workspace, + ); + const spy = createChangeListenerSpy(this.workspace); + + this.renderedComment.setSize(new Blockly.utils.Size(300, 200)); + + this.clock.runAll(); + + assertEventFired( + spy, + Blockly.Events.CommentResize, + { + commentId: this.renderedComment.id, + oldSize: {width: 120, height: 100}, + newSize: {width: 300, height: 200}, + }, + this.workspace.id, + ); + }); + test('change events are fired when a comments text is edited', function () { this.renderedComment = new Blockly.comments.RenderedWorkspaceComment( this.workspace,