diff --git a/core/comments/workspace_comment.ts b/core/comments/workspace_comment.ts index ebe0606e9..46123e534 100644 --- a/core/comments/workspace_comment.ts +++ b/core/comments/workspace_comment.ts @@ -53,6 +53,9 @@ export class WorkspaceComment { ) { this.id = id && !workspace.getCommentById(id) ? id : idGenerator.genUid(); + // TODO: File an issue to remove this once everything is migrated. + workspace.addTopComment(this as AnyDuringMigration); + // TODO(7909): Fire events. } @@ -162,6 +165,7 @@ export class WorkspaceComment { /** Disposes of this comment. */ dispose() { this.disposing = true; + this.workspace.removeTopComment(this as AnyDuringMigration); this.disposed = true; } diff --git a/core/serialization.ts b/core/serialization.ts index 8362f2ced..8e159bb2b 100644 --- a/core/serialization.ts +++ b/core/serialization.ts @@ -16,6 +16,7 @@ import * as procedures from './serialization/procedures.js'; import * as registry from './serialization/registry.js'; import * as variables from './serialization/variables.js'; import * as workspaces from './serialization/workspaces.js'; +import * as workspaceComments from './serialization/workspace_comments.js'; import {ISerializer} from './interfaces/i_serializer.js'; export { @@ -26,5 +27,6 @@ export { registry, variables, workspaces, + workspaceComments, ISerializer, }; diff --git a/core/serialization/priorities.ts b/core/serialization/priorities.ts index c0e781166..726242f01 100644 --- a/core/serialization/priorities.ts +++ b/core/serialization/priorities.ts @@ -20,3 +20,6 @@ export const PROCEDURES = 75; * The priority for deserializing blocks. */ export const BLOCKS = 50; + +/** The priority for deserializing workspace comments. */ +export const WORKSPACE_COMMENTS = 25; diff --git a/core/serialization/workspace_comments.ts b/core/serialization/workspace_comments.ts new file mode 100644 index 000000000..52cac8b81 --- /dev/null +++ b/core/serialization/workspace_comments.ts @@ -0,0 +1,145 @@ +/** + * @license + * Copyright 2024 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {ISerializer} from '../interfaces/i_serializer.js'; +import {Workspace} from '../workspace.js'; +import {WorkspaceSvg} from '../workspace_svg.js'; +import * as priorities from './priorities.js'; +import {WorkspaceComment} from '../comments/workspace_comment.js'; +import {RenderedWorkspaceComment} from '../comments/rendered_workspace_comment.js'; +import * as eventUtils from '../events/utils.js'; +import {Coordinate} from '../utils/coordinate.js'; +import * as serializationRegistry from './registry.js'; +import {Size} from '../utils/size.js'; + +export interface State { + id?: string; + text?: string; + x?: number; + y?: number; + width?: number; + height?: number; + collapsed?: boolean; + editable?: boolean; + movable?: boolean; + deletable?: boolean; +} + +/** Serializes the state of the given comment to JSON. */ +export function save( + comment: WorkspaceComment, + { + addCoordinates = false, + saveIds = true, + }: { + addCoordinates?: boolean; + saveIds?: boolean; + } = {}, +): State { + const state: State = Object.create(null); + + state.height = comment.getSize().height; + state.width = comment.getSize().width; + + if (saveIds) state.id = comment.id; + if (addCoordinates) { + state.x = comment.getRelativeToSurfaceXY().x; + state.y = comment.getRelativeToSurfaceXY().y; + } + + if (comment.getText()) state.text = comment.getText(); + if (comment.isCollapsed()) state.collapsed = true; + if (!comment.isOwnEditable()) state.editable = false; + if (!comment.isOwnMovable()) state.movable = false; + if (!comment.isOwnDeletable()) state.deletable = false; + + return state; +} + +/** Appends the comment defined by the given state to the given workspace. */ +export function append( + state: State, + workspace: Workspace, + {recordUndo = false}: {recordUndo?: boolean} = {}, +): WorkspaceComment { + const prevRecordUndo = eventUtils.getRecordUndo(); + eventUtils.setRecordUndo(recordUndo); + + const comment = + workspace instanceof WorkspaceSvg + ? new RenderedWorkspaceComment(workspace, state.id) + : new WorkspaceComment(workspace, state.id); + + if (state.text !== undefined) comment.setText(state.text); + if (state.x !== undefined || state.y !== undefined) { + const defaultLoc = comment.getRelativeToSurfaceXY(); + comment.moveTo( + new Coordinate(state.x ?? defaultLoc.x, state.y ?? defaultLoc.y), + ); + } + if (state.width !== undefined || state.height) { + const defaultSize = comment.getSize(); + comment.setSize( + new Size( + state.width ?? defaultSize.width, + state.height ?? defaultSize.height, + ), + ); + } + if (state.collapsed !== undefined) comment.setCollapsed(state.collapsed); + if (state.editable !== undefined) comment.setEditable(state.editable); + if (state.movable !== undefined) comment.setMovable(state.movable); + if (state.deletable !== undefined) comment.setDeletable(state.deletable); + + eventUtils.setRecordUndo(prevRecordUndo); + + return comment; +} + +// Alias to disambiguate saving within the serializer. +const saveComment = save; + +/** Serializer for saving and loading workspace comment state. */ +export class WorkspaceCommentSerializer implements ISerializer { + priority = priorities.WORKSPACE_COMMENTS; + + /** + * Returns the state of all workspace comments in the given workspace. + */ + save(workspace: Workspace): State[] | null { + const commentStates = []; + for (const comment of workspace.getTopComments()) { + const state = saveComment(comment as AnyDuringMigration, { + addCoordinates: true, + saveIds: true, + }); + if (state) commentStates.push(state); + } + return commentStates.length ? commentStates : null; + } + + /** + * Deserializes the comments defined by the given state into the given + * workspace. + */ + load(state: State[], workspace: Workspace) { + for (const commentState of state) { + append(commentState, workspace, {recordUndo: eventUtils.getRecordUndo()}); + } + } + + /** Disposes of any comments that exist on the given workspace. */ + clear(workspace: Workspace) { + for (const comment of workspace.getTopComments()) { + comment.dispose(); + } + } +} + +serializationRegistry.register( + 'workspaceComments', + new WorkspaceCommentSerializer(), +); diff --git a/tests/mocha/jso_serialization_test.js b/tests/mocha/jso_serialization_test.js index 04bc65b2e..66b6f4c97 100644 --- a/tests/mocha/jso_serialization_test.js +++ b/tests/mocha/jso_serialization_test.js @@ -894,4 +894,166 @@ suite('JSO Serialization', function () { ); }); }); + + suite('Workspace comments', function () { + suite('IDs', function () { + test('IDs are saved by default', function () { + const comment = new Blockly.comments.WorkspaceComment( + this.workspace, + 'testID', + ); + + const json = Blockly.serialization.workspaceComments.save(comment); + + assertProperty(json, 'id', 'testID'); + }); + + test('saving IDs can be disabled', function () { + const comment = new Blockly.comments.WorkspaceComment( + this.workspace, + 'testID', + ); + + const json = Blockly.serialization.workspaceComments.save(comment, { + saveIds: false, + }); + + assertNoProperty(json, 'id'); + }); + }); + + suite('Coordinates', function () { + test('coordinates are not saved by default', function () { + const comment = new Blockly.comments.WorkspaceComment(this.workspace); + comment.moveTo(new Blockly.utils.Coordinate(42, 1337)); + + const json = Blockly.serialization.workspaceComments.save(comment); + + assertNoProperty(json, 'x'); + assertNoProperty(json, 'y'); + }); + + test('saving coordinates can be enabled', function () { + const comment = new Blockly.comments.WorkspaceComment(this.workspace); + comment.moveTo(new Blockly.utils.Coordinate(42, 1337)); + + const json = Blockly.serialization.workspaceComments.save(comment, { + addCoordinates: true, + }); + + assertProperty(json, 'x', 42); + assertProperty(json, 'y', 1337); + }); + }); + + suite('Text', function () { + test('the empty string is not saved', function () { + const comment = new Blockly.comments.WorkspaceComment(this.workspace); + comment.setText(''); + + const json = Blockly.serialization.workspaceComments.save(comment); + + assertNoProperty(json, 'text'); + }); + + test('text is saved', function () { + const comment = new Blockly.comments.WorkspaceComment(this.workspace); + comment.setText('test text'); + + const json = Blockly.serialization.workspaceComments.save(comment); + + assertProperty(json, 'text', 'test text'); + }); + }); + + test('size is saved', function () { + const comment = new Blockly.comments.WorkspaceComment(this.workspace); + comment.setSize(new Blockly.utils.Size(42, 1337)); + + const json = Blockly.serialization.workspaceComments.save(comment); + + assertProperty(json, 'width', 42); + assertProperty(json, 'height', 1337); + }); + + suite('Collapsed', function () { + test('collapsed is not saved if false', function () { + const comment = new Blockly.comments.WorkspaceComment(this.workspace); + comment.setCollapsed(false); + + const json = Blockly.serialization.workspaceComments.save(comment); + + assertNoProperty(json, 'collapsed'); + }); + + test('collapsed is saved if true', function () { + const comment = new Blockly.comments.WorkspaceComment(this.workspace); + comment.setCollapsed(true); + + const json = Blockly.serialization.workspaceComments.save(comment); + + assertProperty(json, 'collapsed', true); + }); + }); + + suite('Editable', function () { + test('editable is not saved if true', function () { + const comment = new Blockly.comments.WorkspaceComment(this.workspace); + comment.setEditable(true); + + const json = Blockly.serialization.workspaceComments.save(comment); + + assertNoProperty(json, 'editable'); + }); + + test('editable is saved if false', function () { + const comment = new Blockly.comments.WorkspaceComment(this.workspace); + comment.setEditable(false); + + const json = Blockly.serialization.workspaceComments.save(comment); + + assertProperty(json, 'editable', false); + }); + }); + + suite('Movable', function () { + test('movable is not saved if true', function () { + const comment = new Blockly.comments.WorkspaceComment(this.workspace); + comment.setMovable(true); + + const json = Blockly.serialization.workspaceComments.save(comment); + + assertNoProperty(json, 'movable'); + }); + + test('movable is saved if false', function () { + const comment = new Blockly.comments.WorkspaceComment(this.workspace); + comment.setMovable(false); + + const json = Blockly.serialization.workspaceComments.save(comment); + + assertProperty(json, 'movable', false); + }); + }); + + suite('Deletable', function () { + test('deletable is not saved if true', function () { + const comment = new Blockly.comments.WorkspaceComment(this.workspace); + comment.setDeletable(true); + + const json = Blockly.serialization.workspaceComments.save(comment); + + assertNoProperty(json, 'deletable'); + }); + + test('deletable is saved if false', function () { + const comment = new Blockly.comments.WorkspaceComment(this.workspace); + comment.setDeletable(false); + + const json = Blockly.serialization.workspaceComments.save(comment); + + assertProperty(json, 'deletable', false); + }); + }); + }); });