fix: Add isCopyable to the ICopyable interface and use it for cut/copy preconditions

Merge pull request #9134 from RoboErikG/is-copyable
This commit is contained in:
RoboErikG
2025-06-16 12:47:53 -07:00
committed by GitHub
7 changed files with 241 additions and 101 deletions

View File

@@ -791,6 +791,7 @@ export class Block {
isDeletable(): boolean { isDeletable(): boolean {
return ( return (
this.deletable && this.deletable &&
!this.isInFlyout &&
!this.shadow && !this.shadow &&
!this.isDeadOrDying() && !this.isDeadOrDying() &&
!this.workspace.isReadOnly() !this.workspace.isReadOnly()
@@ -824,6 +825,7 @@ export class Block {
isMovable(): boolean { isMovable(): boolean {
return ( return (
this.movable && this.movable &&
!this.isInFlyout &&
!this.shadow && !this.shadow &&
!this.isDeadOrDying() && !this.isDeadOrDying() &&
!this.workspace.isReadOnly() !this.workspace.isReadOnly()

View File

@@ -1721,6 +1721,11 @@ export class BlockSvg
this.dragStrategy = dragStrategy; this.dragStrategy = dragStrategy;
} }
/** Returns whether this block is copyable or not. */
isCopyable(): boolean {
return this.isOwnDeletable() && this.isOwnMovable();
}
/** Returns whether this block is movable or not. */ /** Returns whether this block is movable or not. */
override isMovable(): boolean { override isMovable(): boolean {
return this.dragStrategy.isMovable(); return this.dragStrategy.isMovable();

View File

@@ -244,6 +244,11 @@ export class RenderedWorkspaceComment
} }
} }
/** Returns whether this comment is copyable or not */
isCopyable(): boolean {
return this.isOwnMovable() && this.isOwnDeletable();
}
/** Returns whether this comment is movable or not. */ /** Returns whether this comment is movable or not. */
isMovable(): boolean { isMovable(): boolean {
return this.dragStrategy.isMovable(); return this.dragStrategy.isMovable();

View File

@@ -165,7 +165,11 @@ export class WorkspaceComment {
* workspace is read-only. * workspace is read-only.
*/ */
isMovable() { isMovable() {
return this.isOwnMovable() && !this.workspace.isReadOnly(); return (
this.isOwnMovable() &&
!this.workspace.isReadOnly() &&
!this.workspace.isFlyout
);
} }
/** /**
@@ -189,7 +193,8 @@ export class WorkspaceComment {
return ( return (
this.isOwnDeletable() && this.isOwnDeletable() &&
!this.isDeadOrDying() && !this.isDeadOrDying() &&
!this.workspace.isReadOnly() !this.workspace.isReadOnly() &&
!this.workspace.isFlyout
); );
} }

View File

@@ -15,6 +15,14 @@ export interface ICopyable<T extends ICopyData> extends ISelectable {
* @returns Copy metadata. * @returns Copy metadata.
*/ */
toCopyData(): T | null; toCopyData(): T | null;
/**
* Whether this instance is currently copyable. The standard implementation
* is to return true if isOwnDeletable and isOwnMovable return true.
*
* @returns True if it can currently be copied.
*/
isCopyable?(): boolean;
} }
export namespace ICopyable { export namespace ICopyable {
@@ -25,7 +33,7 @@ export namespace ICopyable {
export type ICopyData = ICopyable.ICopyData; export type ICopyData = ICopyable.ICopyData;
/** @returns true if the given object is copyable. */ /** @returns true if the given object is an ICopyable. */
export function isCopyable(obj: any): obj is ICopyable<ICopyData> { export function isCopyable(obj: any): obj is ICopyable<ICopyData> {
return obj.toCopyData !== undefined; return obj.toCopyData !== undefined;
} }

View File

@@ -8,19 +8,13 @@
import {BlockSvg} from './block_svg.js'; import {BlockSvg} from './block_svg.js';
import * as clipboard from './clipboard.js'; import * as clipboard from './clipboard.js';
import {RenderedWorkspaceComment} from './comments.js';
import * as eventUtils from './events/utils.js'; import * as eventUtils from './events/utils.js';
import {getFocusManager} from './focus_manager.js'; import {getFocusManager} from './focus_manager.js';
import {Gesture} from './gesture.js'; import {Gesture} from './gesture.js';
import { import {ICopyData, isCopyable as isICopyable} from './interfaces/i_copyable.js';
ICopyable, import {isDeletable as isIDeletable} from './interfaces/i_deletable.js';
ICopyData, import {isDraggable} from './interfaces/i_draggable.js';
isCopyable as isICopyable,
} from './interfaces/i_copyable.js';
import {
IDeletable,
isDeletable as isIDeletable,
} from './interfaces/i_deletable.js';
import {IDraggable, isDraggable} from './interfaces/i_draggable.js';
import {IFocusableNode} from './interfaces/i_focusable_node.js'; import {IFocusableNode} from './interfaces/i_focusable_node.js';
import {KeyboardShortcut, ShortcutRegistry} from './shortcut_registry.js'; import {KeyboardShortcut, ShortcutRegistry} from './shortcut_registry.js';
import {Coordinate} from './utils/coordinate.js'; import {Coordinate} from './utils/coordinate.js';
@@ -100,74 +94,43 @@ export function registerDelete() {
} }
let copyData: ICopyData | null = null; let copyData: ICopyData | null = null;
let copyWorkspace: WorkspaceSvg | null = null;
let copyCoords: Coordinate | null = null; let copyCoords: Coordinate | null = null;
/** /**
* Determine if a focusable node can be copied. * Determine if a focusable node can be copied.
* *
* Unfortunately the ICopyable interface doesn't include an isCopyable * This will use the isCopyable method if the node implements it, otherwise
* method, so we must use some other criteria to make the decision. * it will fall back to checking if the node is deletable and draggable not
* Specifically, * considering the workspace's edit state.
*
* - It must be an ICopyable.
* - So that a pasted copy can be manipluated and/or disposed of, it
* must be both an IDraggable and an IDeletable.
* - Additionally, both .isOwnMovable() and .isOwnDeletable() must return
* true (i.e., the copy could be moved and deleted).
*
* TODO(#9098): Revise these criteria. The latter criteria prevents
* shadow blocks from being copied; additionally, there are likely to
* be other circumstances were it is desirable to allow movable /
* copyable copies of a currently-unmovable / -copyable block to be
* made.
* *
* @param focused The focused object. * @param focused The focused object.
*/ */
function isCopyable( function isCopyable(focused: IFocusableNode): boolean {
focused: IFocusableNode, if (!isICopyable(focused) || !isIDeletable(focused) || !isDraggable(focused))
): focused is ICopyable<ICopyData> & IDeletable & IDraggable { return false;
if (!(focused instanceof BlockSvg)) return false; if (focused.isCopyable) {
return ( return focused.isCopyable();
isICopyable(focused) && } else if (
isIDeletable(focused) && focused instanceof BlockSvg ||
focused.isOwnDeletable() && focused instanceof RenderedWorkspaceComment
isDraggable(focused) && ) {
focused.isOwnMovable() return focused.isOwnDeletable() && focused.isOwnMovable();
); }
// This isn't a class Blockly knows about, so fall back to the stricter
// checks for deletable and movable.
return focused.isDeletable() && focused.isMovable();
} }
/** /**
* Determine if a focusable node can be cut. * Determine if a focusable node can be cut.
* *
* Unfortunately the ICopyable interface doesn't include an isCuttable * This will check if the node can be both copied and deleted in its current
* method, so we must use some other criteria to make the decision. * workspace.
* Specifically,
*
* - It must be an ICopyable.
* - So that a pasted copy can be manipluated and/or disposed of, it
* must be both an IDraggable and an IDeletable.
* - Additionally, both .isMovable() and .isDeletable() must return
* true (i.e., can currently be moved and deleted). This is the main
* difference with isCopyable.
*
* TODO(#9098): Revise these criteria. The latter criteria prevents
* shadow blocks from being copied; additionally, there are likely to
* be other circumstances were it is desirable to allow movable /
* copyable copies of a currently-unmovable / -copyable block to be
* made.
* *
* @param focused The focused object. * @param focused The focused object.
*/ */
function isCuttable(focused: IFocusableNode): boolean { function isCuttable(focused: IFocusableNode): boolean {
if (!(focused instanceof BlockSvg)) return false; return isCopyable(focused) && isIDeletable(focused) && focused.isDeletable();
return (
isICopyable(focused) &&
isIDeletable(focused) &&
focused.isDeletable() &&
isDraggable(focused) &&
focused.isMovable()
);
} }
/** /**
@@ -185,7 +148,6 @@ export function registerCopy() {
name: names.COPY, name: names.COPY,
preconditionFn(workspace, scope) { preconditionFn(workspace, scope) {
const focused = scope.focusedNode; const focused = scope.focusedNode;
if (!(focused instanceof BlockSvg)) return false;
const targetWorkspace = workspace.isFlyout const targetWorkspace = workspace.isFlyout
? workspace.targetWorkspace ? workspace.targetWorkspace
@@ -193,7 +155,6 @@ export function registerCopy() {
return ( return (
!!focused && !!focused &&
!!targetWorkspace && !!targetWorkspace &&
!targetWorkspace.isReadOnly() &&
!targetWorkspace.isDragging() && !targetWorkspace.isDragging() &&
!getFocusManager().ephemeralFocusTaken() && !getFocusManager().ephemeralFocusTaken() &&
isCopyable(focused) isCopyable(focused)
@@ -205,21 +166,17 @@ export function registerCopy() {
e.preventDefault(); e.preventDefault();
const focused = scope.focusedNode; const focused = scope.focusedNode;
if (!focused || !isCopyable(focused)) return false; if (!focused || !isICopyable(focused) || !isCopyable(focused))
let targetWorkspace: WorkspaceSvg | null = return false;
focused.workspace instanceof WorkspaceSvg const targetWorkspace = workspace.isFlyout
? focused.workspace ? workspace.targetWorkspace
: workspace; : workspace;
targetWorkspace = targetWorkspace.isFlyout
? targetWorkspace.targetWorkspace
: targetWorkspace;
if (!targetWorkspace) return false; if (!targetWorkspace) return false;
if (!focused.workspace.isFlyout) { if (!focused.workspace.isFlyout) {
targetWorkspace.hideChaff(); targetWorkspace.hideChaff();
} }
copyData = focused.toCopyData(); copyData = focused.toCopyData();
copyWorkspace = targetWorkspace;
copyCoords = copyCoords =
isDraggable(focused) && focused.workspace == targetWorkspace isDraggable(focused) && focused.workspace == targetWorkspace
? focused.getRelativeToSurfaceXY() ? focused.getRelativeToSurfaceXY()
@@ -256,27 +213,20 @@ export function registerCut() {
}, },
callback(workspace, e, shortcut, scope) { callback(workspace, e, shortcut, scope) {
const focused = scope.focusedNode; const focused = scope.focusedNode;
if (!focused || !isCuttable(focused) || !isICopyable(focused)) {
return false;
}
copyData = focused.toCopyData();
copyCoords = isDraggable(focused)
? focused.getRelativeToSurfaceXY()
: null;
if (focused instanceof BlockSvg) { if (focused instanceof BlockSvg) {
copyData = focused.toCopyData();
copyWorkspace = workspace;
copyCoords = focused.getRelativeToSurfaceXY();
focused.checkAndDelete(); focused.checkAndDelete();
return true; } else if (isIDeletable(focused)) {
} else if (
isIDeletable(focused) &&
focused.isDeletable() &&
isICopyable(focused)
) {
copyData = focused.toCopyData();
copyWorkspace = workspace;
copyCoords = isDraggable(focused)
? focused.getRelativeToSurfaceXY()
: null;
focused.dispose(); focused.dispose();
return true;
} }
return false; return !!copyData;
}, },
keyCodes: [ctrlX, metaX], keyCodes: [ctrlX, metaX],
}; };
@@ -310,7 +260,11 @@ export function registerPaste() {
); );
}, },
callback(workspace: WorkspaceSvg, e: Event) { callback(workspace: WorkspaceSvg, e: Event) {
if (!copyData || !copyWorkspace) return false; if (!copyData) return false;
const targetWorkspace = workspace.isFlyout
? workspace.targetWorkspace
: workspace;
if (!targetWorkspace || targetWorkspace.isReadOnly()) return false;
if (e instanceof PointerEvent) { if (e instanceof PointerEvent) {
// The event that triggers a shortcut would conventionally be a KeyboardEvent. // The event that triggers a shortcut would conventionally be a KeyboardEvent.
@@ -319,19 +273,19 @@ export function registerPaste() {
// at the mouse coordinates where the menu was opened, and this PointerEvent // at the mouse coordinates where the menu was opened, and this PointerEvent
// is where the menu was opened. // is where the menu was opened.
const mouseCoords = svgMath.screenToWsCoordinates( const mouseCoords = svgMath.screenToWsCoordinates(
copyWorkspace, targetWorkspace,
new Coordinate(e.clientX, e.clientY), new Coordinate(e.clientX, e.clientY),
); );
return !!clipboard.paste(copyData, copyWorkspace, mouseCoords); return !!clipboard.paste(copyData, targetWorkspace, mouseCoords);
} }
if (!copyCoords) { if (!copyCoords) {
// If we don't have location data about the original copyable, let the // If we don't have location data about the original copyable, let the
// paster determine position. // paster determine position.
return !!clipboard.paste(copyData, copyWorkspace); return !!clipboard.paste(copyData, targetWorkspace);
} }
const {left, top, width, height} = copyWorkspace const {left, top, width, height} = targetWorkspace
.getMetricsManager() .getMetricsManager()
.getViewMetrics(true); .getViewMetrics(true);
const viewportRect = new Rect(top, top + height, left, left + width); const viewportRect = new Rect(top, top + height, left, left + width);
@@ -339,12 +293,12 @@ export function registerPaste() {
if (viewportRect.contains(copyCoords.x, copyCoords.y)) { if (viewportRect.contains(copyCoords.x, copyCoords.y)) {
// If the original copyable is inside the viewport, let the paster // If the original copyable is inside the viewport, let the paster
// determine position. // determine position.
return !!clipboard.paste(copyData, copyWorkspace); return !!clipboard.paste(copyData, targetWorkspace);
} }
// Otherwise, paste in the middle of the viewport. // Otherwise, paste in the middle of the viewport.
const centerCoords = new Coordinate(left + width / 2, top + height / 2); const centerCoords = new Coordinate(left + width / 2, top + height / 2);
return !!clipboard.paste(copyData, copyWorkspace, centerCoords); return !!clipboard.paste(copyData, targetWorkspace, centerCoords);
}, },
keyCodes: [ctrlV, metaV], keyCodes: [ctrlV, metaV],
}; };

View File

@@ -47,6 +47,16 @@ suite('Keyboard Shortcut Items', function () {
.returns(block.nextConnection); .returns(block.nextConnection);
} }
/**
* Creates a workspace comment and set it as the focused node.
* @param {Blockly.Workspace} workspace The workspace to create a new comment on.
*/
function setSelectedComment(workspace) {
const comment = workspace.newComment();
sinon.stub(Blockly.getFocusManager(), 'getFocusedNode').returns(comment);
return comment;
}
/** /**
* Creates a test for not running keyDown events when the workspace is in read only mode. * Creates a test for not running keyDown events when the workspace is in read only mode.
* @param {Object} keyEvent Mocked key down event. Use createKeyDownEvent. * @param {Object} keyEvent Mocked key down event. Use createKeyDownEvent.
@@ -173,12 +183,17 @@ suite('Keyboard Shortcut Items', function () {
}); });
}); });
}); });
// Do not copy a block if a workspace is in readonly mode. // Allow copying a block if a workspace is in readonly mode.
suite('Not called when readOnly is true', function () { suite('Called when readOnly is true', function () {
testCases.forEach(function (testCase) { testCases.forEach(function (testCase) {
const testCaseName = testCase[0]; const testCaseName = testCase[0];
const keyEvent = testCase[1]; const keyEvent = testCase[1];
runReadOnlyTest(keyEvent, testCaseName); test(testCaseName, function () {
this.workspace.setIsReadOnly(true);
this.injectionDiv.dispatchEvent(keyEvent);
sinon.assert.calledOnce(this.copySpy);
sinon.assert.calledOnce(this.hideChaffSpy);
});
}); });
}); });
// Do not copy a block if a drag is in progress. // Do not copy a block if a drag is in progress.
@@ -236,6 +251,152 @@ suite('Keyboard Shortcut Items', function () {
sinon.assert.notCalled(this.copySpy); sinon.assert.notCalled(this.copySpy);
sinon.assert.notCalled(this.hideChaffSpy); sinon.assert.notCalled(this.hideChaffSpy);
}); });
// Copy a comment.
test('Workspace comment', function () {
testCases.forEach(function (testCase) {
const testCaseName = testCase[0];
const keyEvent = testCase[1];
test(testCaseName, function () {
Blockly.getFocusManager().getFocusedNode.restore();
this.comment = setSelectedComment(this.workspace);
this.copySpy = sinon.spy(this.comment, 'toCopyData');
this.injectionDiv.dispatchEvent(keyEvent);
sinon.assert.calledOnce(this.copySpy);
sinon.assert.calledOnce(this.hideChaffSpy);
});
});
});
});
suite('Cut', function () {
setup(function () {
this.block = setSelectedBlock(this.workspace);
this.copySpy = sinon.spy(this.block, 'toCopyData');
this.disposeSpy = sinon.spy(this.block, 'dispose');
this.hideChaffSpy = sinon.spy(
Blockly.WorkspaceSvg.prototype,
'hideChaff',
);
});
const testCases = [
[
'Control X',
createKeyDownEvent(Blockly.utils.KeyCodes.X, [
Blockly.utils.KeyCodes.CTRL,
]),
],
[
'Meta X',
createKeyDownEvent(Blockly.utils.KeyCodes.X, [
Blockly.utils.KeyCodes.META,
]),
],
];
// Cut a block.
suite('Simple', function () {
testCases.forEach(function (testCase) {
const testCaseName = testCase[0];
const keyEvent = testCase[1];
test(testCaseName, function () {
this.injectionDiv.dispatchEvent(keyEvent);
sinon.assert.calledOnce(this.copySpy);
sinon.assert.calledOnce(this.disposeSpy);
sinon.assert.calledOnce(this.hideChaffSpy);
});
});
});
// Do not cut a block if a workspace is in readonly mode.
suite('Not called when readOnly is true', function () {
testCases.forEach(function (testCase) {
const testCaseName = testCase[0];
const keyEvent = testCase[1];
test(testCaseName, function () {
this.workspace.setIsReadOnly(true);
this.injectionDiv.dispatchEvent(keyEvent);
sinon.assert.notCalled(this.copySpy);
sinon.assert.notCalled(this.disposeSpy);
sinon.assert.notCalled(this.hideChaffSpy);
});
});
});
// Do not cut a block if a drag is in progress.
suite('Drag in progress', function () {
testCases.forEach(function (testCase) {
const testCaseName = testCase[0];
const keyEvent = testCase[1];
test(testCaseName, function () {
sinon.stub(this.workspace, 'isDragging').returns(true);
this.injectionDiv.dispatchEvent(keyEvent);
sinon.assert.notCalled(this.copySpy);
sinon.assert.notCalled(this.disposeSpy);
sinon.assert.notCalled(this.hideChaffSpy);
});
});
});
// Do not cut a block if is is not deletable.
suite('Block is not deletable', function () {
testCases.forEach(function (testCase) {
const testCaseName = testCase[0];
const keyEvent = testCase[1];
test(testCaseName, function () {
sinon
.stub(Blockly.common.getSelected(), 'isOwnDeletable')
.returns(false);
this.injectionDiv.dispatchEvent(keyEvent);
sinon.assert.notCalled(this.copySpy);
sinon.assert.notCalled(this.disposeSpy);
sinon.assert.notCalled(this.hideChaffSpy);
});
});
});
// Do not cut a block if it is not movable.
suite('Block is not movable', function () {
testCases.forEach(function (testCase) {
const testCaseName = testCase[0];
const keyEvent = testCase[1];
test(testCaseName, function () {
sinon
.stub(Blockly.common.getSelected(), 'isOwnMovable')
.returns(false);
this.injectionDiv.dispatchEvent(keyEvent);
sinon.assert.notCalled(this.copySpy);
sinon.assert.notCalled(this.disposeSpy);
sinon.assert.notCalled(this.hideChaffSpy);
});
});
});
test('Not called when connection is focused', function () {
// Restore the stub behavior called during setup
Blockly.getFocusManager().getFocusedNode.restore();
setSelectedConnection(this.workspace);
const event = createKeyDownEvent(Blockly.utils.KeyCodes.C, [
Blockly.utils.KeyCodes.CTRL,
]);
this.injectionDiv.dispatchEvent(event);
sinon.assert.notCalled(this.copySpy);
sinon.assert.notCalled(this.disposeSpy);
sinon.assert.notCalled(this.hideChaffSpy);
});
// Cut a comment.
suite('Workspace comment', function () {
testCases.forEach(function (testCase) {
const testCaseName = testCase[0];
const keyEvent = testCase[1];
test(testCaseName, function () {
Blockly.getFocusManager().getFocusedNode.restore();
this.comment = setSelectedComment(this.workspace);
this.copySpy = sinon.spy(this.comment, 'toCopyData');
this.disposeSpy = sinon.spy(this.comment, 'dispose');
this.injectionDiv.dispatchEvent(keyEvent);
sinon.assert.calledOnce(this.copySpy);
sinon.assert.calledOnce(this.disposeSpy);
});
});
});
}); });
suite('Undo', function () { suite('Undo', function () {