diff --git a/core/css.js b/core/css.js index b908ae2f4f..39dfa07ebe 100644 --- a/core/css.js +++ b/core/css.js @@ -1288,6 +1288,10 @@ const styles = ` height: 1.25rem; } + .blocklyComment { + --colour-commentBorder: #bcA903; + } + .blocklyCommentTopbar { height: 32px; --commentBorderColour: #e2db96; @@ -1307,7 +1311,7 @@ const styles = ` .blocklySelected .blocklyCommentHighlight, .blocklyCollapsed .blocklyCommentTopbarBackground, .blocklyCollapsed.blocklySelected .blocklyCommentTopbarBackground { - stroke: #bcA903; + stroke: var(--colour-commentBorder); stroke-width: 1px; } diff --git a/src/events_block_comment_base.js b/src/events_block_comment_base.js new file mode 100644 index 0000000000..248f978f89 --- /dev/null +++ b/src/events_block_comment_base.js @@ -0,0 +1,35 @@ +/** + * @license + * Copyright 2024 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as Blockly from "blockly/core"; + +export class BlockCommentBase extends Blockly.Events.Abstract { + constructor(opt_blockComment) { + super(); + this.isBlank = !opt_blockComment; + + if (!opt_blockComment) return; + + this.commentId = opt_blockComment.getId(); + this.blockId = opt_blockComment.getSourceBlock()?.id; + this.workspaceId = opt_blockComment.getSourceBlock()?.workspace.id; + } + + toJson() { + return { + ...super.toJson(), + commentId: this.commentId, + blockId: this.blockId, + }; + } + + static fromJson(json, workspace, event) { + const newEvent = super.fromJson(json, workspace, event); + newEvent.commentId = json["commentId"]; + newEvent.blockId = json["blockId"]; + return newEvent; + } +} diff --git a/src/events_block_comment_change.js b/src/events_block_comment_change.js new file mode 100644 index 0000000000..46299d942c --- /dev/null +++ b/src/events_block_comment_change.js @@ -0,0 +1,43 @@ +/** + * @license + * Copyright 2024 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as Blockly from "blockly/core"; +import { BlockCommentBase } from "./events_block_comment_base.js"; + +class BlockCommentChange extends BlockCommentBase { + constructor(opt_blockComment, oldContents, newContents) { + super(opt_blockComment); + this.type = "block_comment_change"; + this.oldContents_ = oldContents; + this.newContents_ = newContents; + // Disable undo because Blockly already tracks changes to comment text for + // undo purposes; this event exists solely to keep the Scratch VM apprised + // of the state of things. + this.recordUndo = false; + } + + toJson() { + return { + ...super.toJson(), + newContents: this.newContents_, + oldContents: this.oldContents_, + }; + } + + static fromJson(json, workspace, event) { + const newEvent = super.fromJson(json, workspace, event); + newEvent.newContents_ = json["newContents"]; + newEvent.oldContents_ = json["oldContents"]; + + return newEvent; + } +} + +Blockly.registry.register( + Blockly.registry.Type.EVENT, + "block_comment_change", + BlockCommentChange +); diff --git a/src/events_block_comment_collapse.js b/src/events_block_comment_collapse.js new file mode 100644 index 0000000000..ad982d79f8 --- /dev/null +++ b/src/events_block_comment_collapse.js @@ -0,0 +1,43 @@ +/** + * @license + * Copyright 2024 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as Blockly from "blockly/core"; +import { BlockCommentBase } from "./events_block_comment_base.js"; + +class BlockCommentCollapse extends BlockCommentBase { + constructor(opt_blockComment, collapsed) { + super(opt_blockComment); + this.type = "block_comment_collapse"; + this.newCollapsed = collapsed; + } + + toJson() { + return { + ...super.toJson(), + collapsed: this.newCollapsed, + }; + } + + static fromJson(json, workspace, event) { + const newEvent = super.fromJson(json, workspace, event); + newEvent.newCollapsed = json["collapsed"]; + + return newEvent; + } + + run(forward) { + const workspace = this.getEventWorkspace_(); + const block = workspace.getBlockById(this.blockId); + const comment = block.getIcon(Blockly.icons.IconType.COMMENT); + comment.setBubbleVisible(forward ? !this.newCollapsed : this.newCollapsed); + } +} + +Blockly.registry.register( + Blockly.registry.Type.EVENT, + "block_comment_collapse", + BlockCommentCollapse +); diff --git a/src/events_block_comment_create.js b/src/events_block_comment_create.js new file mode 100644 index 0000000000..97db3cf7dc --- /dev/null +++ b/src/events_block_comment_create.js @@ -0,0 +1,52 @@ +/** + * @license + * Copyright 2024 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as Blockly from "blockly/core"; +import { BlockCommentBase } from "./events_block_comment_base.js"; + +class BlockCommentCreate extends BlockCommentBase { + constructor(opt_blockComment) { + super(opt_blockComment); + this.type = "block_comment_create"; + const size = opt_blockComment.getSize(); + const location = opt_blockComment.getRelativeToSurfaceXY(); + this.json = { + x: location.x, + y: location.y, + width: size.width, + height: size.height, + }; + // Disable undo because Blockly already tracks comment creation for + // undo purposes; this event exists solely to keep the Scratch VM apprised + // of the state of things. + this.recordUndo = false; + } + + toJson() { + return { + ...super.toJson(), + json: this.json, + }; + } + + static fromJson(json, workspace, event) { + const newEvent = super.fromJson(json, workspace, event); + newEvent.json = { + x: json["json"]["x"], + y: json["json"]["y"], + width: json["json"]["width"], + height: json["json"]["height"], + }; + + return newEvent; + } +} + +Blockly.registry.register( + Blockly.registry.Type.EVENT, + "block_comment_create", + BlockCommentCreate +); diff --git a/src/events_block_comment_delete.js b/src/events_block_comment_delete.js new file mode 100644 index 0000000000..d9a6e60ff9 --- /dev/null +++ b/src/events_block_comment_delete.js @@ -0,0 +1,27 @@ +/** + * @license + * Copyright 2024 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as Blockly from "blockly/core"; +import { BlockCommentBase } from "./events_block_comment_base.js"; + +class BlockCommentDelete extends BlockCommentBase { + constructor(opt_blockComment, sourceBlock) { + super(opt_blockComment); + this.type = "block_comment_delete"; + this.blockId = sourceBlock.id; + this.workspaceId = sourceBlock.workspace.id; + // Disable undo because Blockly already tracks comment deletion for + // undo purposes; this event exists solely to keep the Scratch VM apprised + // of the state of things. + this.recordUndo = false; + } +} + +Blockly.registry.register( + Blockly.registry.Type.EVENT, + "block_comment_delete", + BlockCommentDelete +); diff --git a/src/events_block_comment_move.js b/src/events_block_comment_move.js new file mode 100644 index 0000000000..ffc098fd90 --- /dev/null +++ b/src/events_block_comment_move.js @@ -0,0 +1,54 @@ +/** + * @license + * Copyright 2024 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as Blockly from "blockly/core"; +import { BlockCommentBase } from "./events_block_comment_base.js"; + +class BlockCommentMove extends BlockCommentBase { + constructor(opt_blockComment, oldCoordinate, newCoordinate) { + super(opt_blockComment); + this.type = "block_comment_move"; + this.oldCoordinate_ = oldCoordinate; + this.newCoordinate_ = newCoordinate; + } + + toJson() { + return { + ...super.toJson(), + newCoordinate: this.newCoordinate_, + oldCoordinate: this.oldCoordinate_, + }; + } + + static fromJson(json, workspace, event) { + const newEvent = super.fromJson(json, workspace, event); + newEvent.newCoordinate_ = new Blockly.utils.Coordinate( + json["newCoordinate"]["x"], + json["newCoordinate"]["y"] + ); + newEvent.oldCoordinate_ = new Blockly.utils.Coordinate( + json["oldCoordinate"]["x"], + json["oldCoordinate"]["y"] + ); + + return newEvent; + } + + run(forward) { + const workspace = this.getEventWorkspace_(); + const block = workspace?.getBlockById(this.blockId); + const comment = block?.getIcon(Blockly.icons.IconType.COMMENT); + comment?.setBubbleLocation( + forward ? this.newCoordinate_ : this.oldCoordinate_ + ); + } +} + +Blockly.registry.register( + Blockly.registry.Type.EVENT, + "block_comment_move", + BlockCommentMove +); diff --git a/src/events_block_comment_resize.js b/src/events_block_comment_resize.js new file mode 100644 index 0000000000..6c7b5953a7 --- /dev/null +++ b/src/events_block_comment_resize.js @@ -0,0 +1,52 @@ +/** + * @license + * Copyright 2024 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as Blockly from "blockly/core"; +import { BlockCommentBase } from "./events_block_comment_base.js"; + +class BlockCommentResize extends BlockCommentBase { + constructor(opt_blockComment, oldSize, newSize) { + super(opt_blockComment); + this.type = "block_comment_resize"; + this.oldSize = oldSize; + this.newSize = newSize; + } + + toJson() { + return { + ...super.toJson(), + newSize: this.newSize, + oldSize: this.oldSize, + }; + } + + static fromJson(json, workspace, event) { + const newEvent = super.fromJson(json, workspace, event); + newEvent.newSize = new Blockly.utils.Size( + json["newSize"]["width"], + json["newSize"]["height"] + ); + newEvent.oldSize = new Blockly.utils.Size( + json["oldSize"]["width"], + json["oldSize"]["height"] + ); + + return newEvent; + } + + run(forward) { + const workspace = this.getEventWorkspace_(); + const block = workspace?.getBlockById(this.blockId); + const comment = block?.getIcon(Blockly.icons.IconType.COMMENT); + comment?.setBubbleSize(forward ? this.newSize : this.oldSize); + } +} + +Blockly.registry.register( + Blockly.registry.Type.EVENT, + "block_comment_resize", + BlockCommentResize +); diff --git a/src/index.js b/src/index.js index 1e2dc0ca8d..3f61eb2d01 100644 --- a/src/index.js +++ b/src/index.js @@ -34,6 +34,13 @@ import {CheckableContinuousFlyout} from './checkable_continuous_flyout.js'; import {buildGlowFilter, glowStack} from './glows.js'; import {ScratchContinuousToolbox} from './scratch_continuous_toolbox.js'; import './scratch_continuous_category.js'; +import './scratch_comment_icon.js'; +import './events_block_comment_change.js'; +import './events_block_comment_collapse.js'; +import './events_block_comment_create.js'; +import './events_block_comment_delete.js'; +import './events_block_comment_move.js'; +import './events_block_comment_resize.js'; import {buildShadowFilter} from './shadows.js'; export * from 'blockly'; diff --git a/src/scratch_comment_bubble.js b/src/scratch_comment_bubble.js new file mode 100644 index 0000000000..8a6b6d5c41 --- /dev/null +++ b/src/scratch_comment_bubble.js @@ -0,0 +1,168 @@ +/** + * @license + * Copyright 2024 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as Blockly from "blockly/core"; + +/** + * A Scratch-style comment bubble for block comments. + * @implements {IBubble} + * @implements {ISelectable} + */ +export class ScratchCommentBubble extends Blockly.comments.CommentView { + constructor(sourceBlock) { + super(sourceBlock.workspace); + this.sourceBlock = sourceBlock; + this.disposing = false; + this.id = Blockly.utils.idGenerator.genUid(); + this.getSvgRoot().setAttribute( + "style", + `--colour-commentBorder: ${sourceBlock.getColourTertiary()};` + ); + + Blockly.browserEvents.conditionalBind( + this.getSvgRoot(), + "pointerdown", + this, + this.startGesture + ); + // Don't zoom with mousewheel; let it scroll instead. + Blockly.browserEvents.conditionalBind( + this.getSvgRoot(), + "wheel", + this, + (e) => { + e.stopPropagation(); + } + ); + } + + setDeleteStyle(enable) {} + showContextMenu() {} + setDragging(start) {} + select() {} + unselect() {} + + isMovable() { + return true; + } + + moveDuringDrag(newLocation) { + this.moveTo(newLocation); + } + + moveTo(xOrCoordinate, y) { + const destination = + xOrCoordinate instanceof Blockly.utils.Coordinate + ? xOrCoordinate + : new Blockly.utils.Coordinate(xOrCoordinate, y); + super.moveTo(destination); + this.redrawAnchorChain(); + } + + startGesture(e) { + const gesture = this.workspace.getGesture(e); + if (gesture) { + gesture.handleCommentStart(e, this); + Blockly.common.setSelected(this); + } + } + + startDrag(event) { + this.dragStartLocation = this.getRelativeToSurfaceXY(); + this.workspace.setResizesEnabled(false); + this.workspace.getLayerManager()?.moveToDragLayer(this); + Blockly.utils.dom.addClass(this.getSvgRoot(), "blocklyDragging"); + } + + drag(newLocation, event) { + this.moveTo(newLocation); + } + + endDrag() { + this.workspace + .getLayerManager() + ?.moveOffDragLayer(this, Blockly.layers.BUBBLE); + this.workspace.setResizesEnabled(false); + Blockly.utils.dom.removeClass(this.getSvgRoot(), "blocklyDragging"); + Blockly.Events.fire( + new (Blockly.Events.get("block_comment_move"))( + this, + this.dragStartLocation, + this.getRelativeToSurfaceXY() + ) + ); + } + + revertDrag() { + this.moveTo(this.dragStartLocation); + } + + setAnchorLocation(newAnchor) { + const oldAnchor = this.anchor; + const alreadyAnchored = !!this.anchor; + this.anchor = newAnchor; + if (!alreadyAnchored) { + this.dropAnchor(); + } else { + const oldLocation = this.getRelativeToSurfaceXY(); + const delta = Blockly.utils.Coordinate.difference(this.anchor, oldAnchor); + const newLocation = Blockly.utils.Coordinate.sum(oldLocation, delta); + this.moveTo(newLocation); + } + } + + dropAnchor() { + this.moveTo(this.anchor.x + 40, this.anchor.y - 16); + const location = this.getRelativeToSurfaceXY(); + this.anchorChain = Blockly.utils.dom.createSvgElement( + Blockly.utils.Svg.LINE, + { + x1: this.anchor.x - location.x, + y1: this.anchor.y - location.y, + x2: this.getSize().width / 2, + y2: 16, + style: `stroke: ${this.sourceBlock.getColourTertiary()}; stroke-width: 1`, + }, + this.getSvgRoot() + ); + this.getSvgRoot().insertBefore( + this.anchorChain, + this.getSvgRoot().firstChild + ); + } + + redrawAnchorChain() { + if (!this.anchorChain) return; + + const location = this.getRelativeToSurfaceXY(); + this.anchorChain.setAttribute("x1", this.anchor.x - location.x); + this.anchorChain.setAttribute("y1", this.anchor.y - location.y); + } + + getId() { + return this.id; + } + + getSourceBlock() { + return this.sourceBlock; + } + + dispose() { + this.disposing = true; + Blockly.utils.dom.removeNode(this.anchorChain); + if (this.sourceBlock) { + Blockly.Events.fire( + new (Blockly.Events.get("block_comment_delete"))(this, this.sourceBlock) + ); + const block = this.sourceBlock; + this.sourceBlock = null; + if (!block.isDeadOrDying()) { + block.setCommentText(null); + } + } + super.dispose(); + } +} diff --git a/src/scratch_comment_icon.js b/src/scratch_comment_icon.js new file mode 100644 index 0000000000..a67589e2f4 --- /dev/null +++ b/src/scratch_comment_icon.js @@ -0,0 +1,195 @@ +/** + * @license + * Copyright 2024 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as Blockly from "blockly/core"; +import { ScratchCommentBubble } from "./scratch_comment_bubble.js"; + +/** + * Custom comment icon that draws no icon indicator, used for block comments. + * @implements {IHasBubble} + * @implements {ISerializable} + */ +class ScratchCommentIcon extends Blockly.icons.Icon { + constructor(sourceBlock) { + super(sourceBlock); + this.sourceBlock = sourceBlock; + this.commentBubble = new ScratchCommentBubble(this.sourceBlock); + Blockly.Events.fire( + new (Blockly.Events.get("block_comment_create"))(this.commentBubble) + ); + this.onTextChangedListener = this.onTextChanged.bind(this); + this.onSizeChangedListener = this.onSizeChanged.bind(this); + this.onCollapseListener = this.onCollapsed.bind(this); + this.commentBubble.addTextChangeListener(this.onTextChangedListener); + this.commentBubble.addSizeChangeListener(this.onSizeChangedListener); + this.commentBubble.addOnCollapseListener(this.onCollapseListener); + } + + getType() { + return Blockly.icons.IconType.COMMENT; + } + + initView(pointerDownListener) { + // Scratch comments have no indicator icon on the block. + return; + } + + getSize() { + // Awful hack to cancel out the default padding added to icons. + return new Blockly.utils.Size(-8, 0); + } + + getAnchorPoint() { + const blockRect = this.sourceBlock.getBoundingRectangleWithoutChildren(); + const y = blockRect.top + this.offsetInBlock.y; + const x = this.sourceBlock.workspace.RTL ? blockRect.left : blockRect.right; + return new Blockly.utils.Coordinate(x, y); + } + + onLocationChange(blockOrigin) { + if (!this.sourceBlock || !this.commentBubble) return; + + if (this.sourceBlock.isInsertionMarker()) { + this.commentBubble.dispose(); + return; + } + + super.onLocationChange(blockOrigin); + const oldBubbleLocation = this.commentBubble.getRelativeToSurfaceXY(); + this.commentBubble.setAnchorLocation(this.getAnchorPoint()); + const newBubbleLocation = this.commentBubble.getRelativeToSurfaceXY(); + Blockly.Events.fire( + new (Blockly.Events.get("block_comment_move"))( + this.commentBubble, + oldBubbleLocation, + newBubbleLocation + ) + ); + } + + setText(text) { + this.commentBubble?.setText(text); + } + + getText() { + return this.commentBubble?.getText() ?? ""; + } + + onTextChanged(oldText, newText) { + Blockly.Events.fire( + new (Blockly.Events.get(Blockly.Events.BLOCK_CHANGE))( + this.sourceBlock, + "comment", + null, + oldText, + newText + ) + ); + Blockly.Events.fire( + new (Blockly.Events.get("block_comment_change"))( + this.commentBubble, + oldText, + newText + ) + ); + } + + onCollapsed(collapsed) { + Blockly.Events.fire( + new (Blockly.Events.get("block_comment_collapse"))( + this.commentBubble, + collapsed + ) + ); + } + + onSizeChanged(oldSize, newSize) { + Blockly.Events.fire( + new (Blockly.Events.get("block_comment_resize"))( + this.commentBubble, + oldSize, + newSize + ) + ); + } + + setBubbleSize(size) { + this.commentBubble?.setSize(size); + } + + getBubbleSize() { + return this.commentBubble?.getSize() ?? new Blockly.utils.Size(0, 0); + } + + setBubbleLocation(newLocation) { + const oldLocation = this.getBubbleLocation(); + this.commentBubble?.moveTo(newLocation); + Blockly.Events.fire( + new (Blockly.Events.get("block_comment_move"))( + this.commentBubble, + oldLocation, + newLocation + ) + ); + } + + getBubbleLocation() { + return this.commentBubble?.getRelativeToSurfaceXY(); + } + + saveState() { + if (!this.commentBubble) return null; + + const size = this.getBubbleSize(); + const bubbleLocation = this.commentBubble.getRelativeToSurfaceXY(); + const delta = Blockly.utils.Coordinate.difference( + bubbleLocation, + this.workspaceLocation + ); + return { + text: this.getText(), + height: size.height, + width: size.width, + x: delta.x, + y: delta.y, + collapsed: this.commentBubble.isCollapsed(), + }; + } + + loadState(state) { + this.setText(state["text"]); + this.setBubbleSize(new Blockly.utils.Size(state["width"], state["height"])); + const delta = new Blockly.utils.Coordinate(state["x"], state["y"]); + const newBubbleLocation = Blockly.utils.Coordinate.sum( + this.workspaceLocation, + delta + ); + this.commentBubble.moveTo(newBubbleLocation); + this.commentBubble.setCollapsed(state["collapsed"]); + } + + bubbleIsVisible() { + return true; + } + + async setBubbleVisible(visible) { + this.commentBubble.setCollapsed(!visible); + } + + dispose() { + this.commentBubble?.dispose(); + this.commentBubble = null; + this.sourceBlock = null; + super.dispose(); + } +} + +Blockly.registry.register( + Blockly.registry.Type.ICON, + Blockly.icons.IconType.COMMENT.toString(), + ScratchCommentIcon, + true +);