feat: add copy api and paste into correct workspace (#9215)

* feat: add copy api and paste into correct workspace

* fix: dont paste into unrendered workspaces

* fix: paste precondition and add test
This commit is contained in:
Maribeth Moffatt
2025-07-08 16:05:53 -07:00
committed by GitHub
parent 89af298918
commit c0489b41e0
4 changed files with 181 additions and 48 deletions

View File

@@ -9,6 +9,7 @@
import {BlockCopyData, BlockPaster} from './clipboard/block_paster.js';
import * as registry from './clipboard/registry.js';
import type {ICopyData, ICopyable} from './interfaces/i_copyable.js';
import {isSelectable} from './interfaces/i_selectable.js';
import * as globalRegistry from './registry.js';
import {Coordinate} from './utils/coordinate.js';
import {WorkspaceSvg} from './workspace_svg.js';
@@ -18,18 +19,119 @@ let stashedCopyData: ICopyData | null = null;
let stashedWorkspace: WorkspaceSvg | null = null;
let stashedCoordinates: Coordinate | undefined = undefined;
/**
* Private version of copy for stubbing in tests.
* Copy a copyable item, and record its data and the workspace it was
* copied from.
*
* This function does not perform any checks to ensure the copy
* should be allowed, e.g. to ensure the block is deletable. Such
* checks should be done before calling this function.
*
* Note that if the copyable item is not an `ISelectable` or its
* `workspace` property is not a `WorkspaceSvg`, the copy will be
* successful, but there will be no saved workspace data. This will
* impact the ability to paste the data unless you explictily pass
* a workspace into the paste method.
*
* @param toCopy item to copy.
* @param location location to save as a potential paste location.
* @returns the copied data if copy was successful, otherwise null.
*/
function copyInternal<T extends ICopyData>(toCopy: ICopyable<T>): T | null {
export function copy<T extends ICopyData>(
toCopy: ICopyable<T>,
location?: Coordinate,
): T | null {
const data = toCopy.toCopyData();
stashedCopyData = data;
stashedWorkspace = (toCopy as any).workspace ?? null;
if (isSelectable(toCopy) && toCopy.workspace instanceof WorkspaceSvg) {
stashedWorkspace = toCopy.workspace;
} else {
stashedWorkspace = null;
}
stashedCoordinates = location;
return data;
}
/**
* Paste a pasteable element into the workspace.
* Gets the copy data for the last item copied. This is useful if you
* are implementing custom copy/paste behavior. If you want the default
* behavior, just use the copy and paste methods directly.
*
* @returns copy data for the last item copied, or null if none set.
*/
export function getLastCopiedData() {
return stashedCopyData;
}
/**
* Sets the last copied item. You should call this method if you implement
* custom copy behavior, so that other callers are working with the correct
* data. This method is called automatically if you use the built-in copy
* method.
*
* @param copyData copy data for the last item copied.
*/
export function setLastCopiedData(copyData: ICopyData) {
stashedCopyData = copyData;
}
/**
* Gets the workspace that was last copied from. This is useful if you
* are implementing custom copy/paste behavior and want to paste on the
* same workspace that was copied from. If you want the default behavior,
* just use the copy and paste methods directly.
*
* @returns workspace that was last copied from, or null if none set.
*/
export function getLastCopiedWorkspace() {
return stashedWorkspace;
}
/**
* Sets the workspace that was last copied from. You should call this method
* if you implement custom copy behavior, so that other callers are working
* with the correct data. This method is called automatically if you use the
* built-in copy method.
*
* @param workspace workspace that was last copied from.
*/
export function setLastCopiedWorkspace(workspace: WorkspaceSvg) {
stashedWorkspace = workspace;
}
/**
* Gets the location that was last copied from. This is useful if you
* are implementing custom copy/paste behavior. If you want the
* default behavior, just use the copy and paste methods directly.
*
* @returns last saved location, or null if none set.
*/
export function getLastCopiedLocation() {
return stashedCoordinates;
}
/**
* Sets the location that was last copied from. You should call this method
* if you implement custom copy behavior, so that other callers are working
* with the correct data. This method is called automatically if you use the
* built-in copy method.
*
* @param location last saved location, which can be used to paste at.
*/
export function setLastCopiedLocation(location: Coordinate) {
stashedCoordinates = location;
}
/**
* Paste a pasteable element into the given workspace.
*
* This function does not perform any checks to ensure the paste
* is allowed, e.g. that the workspace is rendered or the block
* is pasteable. Such checks should be done before calling this
* function.
*
* @param copyData The data to paste into the workspace.
* @param workspace The workspace to paste the data into.
@@ -43,7 +145,7 @@ export function paste<T extends ICopyData>(
): ICopyable<T> | null;
/**
* Pastes the last copied ICopyable into the workspace.
* Pastes the last copied ICopyable into the last copied-from workspace.
*
* @returns the pasted thing if the paste was successful, null otherwise.
*/
@@ -65,7 +167,7 @@ export function paste<T extends ICopyData>(
): ICopyable<ICopyData> | null {
if (!copyData || !workspace) {
if (!stashedCopyData || !stashedWorkspace) return null;
return pasteFromData(stashedCopyData, stashedWorkspace);
return pasteFromData(stashedCopyData, stashedWorkspace, stashedCoordinates);
}
return pasteFromData(copyData, workspace, coordinate);
}
@@ -85,31 +187,11 @@ function pasteFromData<T extends ICopyData>(
): ICopyable<T> | null {
workspace = workspace.isMutator
? workspace
: (workspace.getRootWorkspace() ?? workspace);
: // Use the parent workspace if it exists (e.g. for pasting into flyouts)
(workspace.options.parentWorkspace ?? workspace);
return (globalRegistry
.getObject(globalRegistry.Type.PASTER, copyData.paster, false)
?.paste(copyData, workspace, coordinate) ?? null) as ICopyable<T> | null;
}
/**
* Private version of duplicate for stubbing in tests.
*/
function duplicateInternal<
U extends ICopyData,
T extends ICopyable<U> & IHasWorkspace,
>(toDuplicate: T): T | null {
const data = toDuplicate.toCopyData();
if (!data) return null;
return paste(data, toDuplicate.workspace) as T;
}
interface IHasWorkspace {
workspace: WorkspaceSvg;
}
export const TEST_ONLY = {
duplicateInternal,
copyInternal,
};
export {BlockCopyData, BlockPaster, registry};

View File

@@ -11,7 +11,7 @@ import * as clipboard from './clipboard.js';
import {RenderedWorkspaceComment} from './comments.js';
import * as eventUtils from './events/utils.js';
import {getFocusManager} from './focus_manager.js';
import {ICopyData, isCopyable as isICopyable} from './interfaces/i_copyable.js';
import {isCopyable as isICopyable} from './interfaces/i_copyable.js';
import {isDeletable as isIDeletable} from './interfaces/i_deletable.js';
import {isDraggable} from './interfaces/i_draggable.js';
import {IFocusableNode} from './interfaces/i_focusable_node.js';
@@ -92,9 +92,6 @@ export function registerDelete() {
ShortcutRegistry.registry.register(deleteShortcut);
}
let copyData: ICopyData | null = null;
let copyCoords: Coordinate | null = null;
/**
* Determine if a focusable node can be copied.
*
@@ -175,12 +172,12 @@ export function registerCopy() {
if (!focused.workspace.isFlyout) {
targetWorkspace.hideChaff();
}
copyData = focused.toCopyData();
copyCoords =
const copyCoords =
isDraggable(focused) && focused.workspace == targetWorkspace
? focused.getRelativeToSurfaceXY()
: null;
return !!copyData;
: undefined;
return !!clipboard.copy(focused, copyCoords);
},
keyCodes: [ctrlC, metaC],
};
@@ -215,10 +212,10 @@ export function registerCut() {
if (!focused || !isCuttable(focused) || !isICopyable(focused)) {
return false;
}
copyData = focused.toCopyData();
copyCoords = isDraggable(focused)
const copyCoords = isDraggable(focused)
? focused.getRelativeToSurfaceXY()
: null;
: undefined;
const copyData = clipboard.copy(focused, copyCoords);
if (focused instanceof BlockSvg) {
focused.checkAndDelete();
@@ -246,12 +243,19 @@ export function registerPaste() {
const pasteShortcut: KeyboardShortcut = {
name: names.PASTE,
preconditionFn(workspace) {
preconditionFn() {
// Regardless of the currently focused workspace, we will only
// paste into the last-copied-from workspace.
const workspace = clipboard.getLastCopiedWorkspace();
// If we don't know where we copied from, we don't know where to paste.
// If the workspace isn't rendered (e.g. closed mutator workspace),
// we can't paste into it.
if (!workspace || !workspace.rendered) return false;
const targetWorkspace = workspace.isFlyout
? workspace.targetWorkspace
: workspace;
return (
!!copyData &&
!!clipboard.getLastCopiedData() &&
!!targetWorkspace &&
!targetWorkspace.isReadOnly() &&
!targetWorkspace.isDragging() &&
@@ -259,10 +263,15 @@ export function registerPaste() {
);
},
callback(workspace: WorkspaceSvg, e: Event) {
const copyData = clipboard.getLastCopiedData();
if (!copyData) return false;
const targetWorkspace = workspace.isFlyout
? workspace.targetWorkspace
: workspace;
const copyWorkspace = clipboard.getLastCopiedWorkspace();
if (!copyWorkspace) return false;
const targetWorkspace = copyWorkspace.isFlyout
? copyWorkspace.targetWorkspace
: copyWorkspace;
if (!targetWorkspace || targetWorkspace.isReadOnly()) return false;
if (e instanceof PointerEvent) {
@@ -278,6 +287,7 @@ export function registerPaste() {
return !!clipboard.paste(copyData, targetWorkspace, mouseCoords);
}
const copyCoords = clipboard.getLastCopiedLocation();
if (!copyCoords) {
// If we don't have location data about the original copyable, let the
// paster determine position.

View File

@@ -76,7 +76,7 @@ suite('Clipboard', function () {
await mutatorIcon.setBubbleVisible(true);
const mutatorWorkspace = mutatorIcon.getWorkspace();
const elseIf = mutatorWorkspace.getBlocksByType('controls_if_elseif')[0];
assert.notEqual(elseIf, undefined);
assert.isDefined(elseIf);
assert.lengthOf(mutatorWorkspace.getAllBlocks(), 2);
assert.lengthOf(this.workspace.getAllBlocks(), 1);
const data = elseIf.toCopyData();
@@ -85,6 +85,34 @@ suite('Clipboard', function () {
assert.lengthOf(this.workspace.getAllBlocks(), 1);
});
test('pasting into a mutator flyout pastes into the mutator workspace', async function () {
const block = Blockly.serialization.blocks.append(
{
'type': 'controls_if',
'id': 'blockId',
'extraState': {
'elseIfCount': 1,
},
},
this.workspace,
);
const mutatorIcon = block.getIcon(Blockly.icons.IconType.MUTATOR);
await mutatorIcon.setBubbleVisible(true);
const mutatorWorkspace = mutatorIcon.getWorkspace();
const mutatorFlyoutWorkspace = mutatorWorkspace
.getFlyout()
.getWorkspace();
const elseIf =
mutatorFlyoutWorkspace.getBlocksByType('controls_if_elseif')[0];
assert.isDefined(elseIf);
assert.lengthOf(mutatorWorkspace.getAllBlocks(), 2);
assert.lengthOf(this.workspace.getAllBlocks(), 1);
const data = elseIf.toCopyData();
Blockly.clipboard.paste(data, mutatorFlyoutWorkspace);
assert.lengthOf(mutatorWorkspace.getAllBlocks(), 3);
assert.lengthOf(this.workspace.getAllBlocks(), 1);
});
suite('pasted blocks are placed in unambiguous locations', function () {
test('pasted blocks are bumped to not overlap', function () {
const block = Blockly.serialization.blocks.append(
@@ -139,8 +167,7 @@ suite('Clipboard', function () {
});
suite('pasting comments', function () {
// TODO: Reenable test when we readd copy-paste.
test.skip('pasted comments are bumped to not overlap', function () {
test('pasted comments are bumped to not overlap', function () {
Blockly.Xml.domToWorkspace(
Blockly.utils.xml.textToDom(
'<xml><comment id="test" x=10 y=10/></xml>',
@@ -153,7 +180,7 @@ suite('Clipboard', function () {
const newComment = Blockly.clipboard.paste(data, this.workspace);
assert.deepEqual(
newComment.getRelativeToSurfaceXY(),
new Blockly.utils.Coordinate(60, 60),
new Blockly.utils.Coordinate(40, 40),
);
});
});

View File

@@ -5,6 +5,7 @@
*/
import * as Blockly from '../../build/src/core/blockly.js';
import {assert} from '../../node_modules/chai/chai.js';
import {defineStackBlock} from './test_helpers/block_definitions.js';
import {
sharedTestSetup,
@@ -399,6 +400,19 @@ suite('Keyboard Shortcut Items', function () {
});
});
suite('Paste', function () {
test('Disabled when nothing has been copied', function () {
const pasteShortcut =
Blockly.ShortcutRegistry.registry.getRegistry()[
Blockly.ShortcutItems.names.PASTE
];
Blockly.clipboard.setLastCopiedData(undefined);
const isPasteEnabled = pasteShortcut.preconditionFn();
assert.isFalse(isPasteEnabled);
});
});
suite('Undo', function () {
setup(function () {
this.undoSpy = sinon.spy(this.workspace, 'undo');