feat: make ICopyable generic and update clipboard APIs (#7348)

* chore: rename module-local variables to not conflict

* feat: make ICopyable generic and update clipboard APIs

* chore: switch over more things to use generic ICopyables

* chore: fix shortcut items using copy paste

* chore: add test for interface between clipboard and pasters

* chore: export isCopyable

* chore: format

* chore: fixup PR comments

* chore: add deprecation tags
This commit is contained in:
Beka Westberg
2023-08-03 15:33:58 -07:00
committed by GitHub
parent ce1678e8a7
commit 001d9ff2c9
13 changed files with 169 additions and 63 deletions

View File

@@ -70,7 +70,11 @@ import {BlockCopyData, BlockPaster} from './clipboard/block_paster.js';
*/
export class BlockSvg
extends Block
implements IASTNodeLocationSvg, IBoundedElement, ICopyable, IDraggable
implements
IASTNodeLocationSvg,
IBoundedElement,
ICopyable<BlockCopyData>,
IDraggable
{
/**
* Constant for identifying rows that are to be rendered inline.

View File

@@ -140,7 +140,7 @@ import {ICollapsibleToolboxItem} from './interfaces/i_collapsible_toolbox_item.j
import {IComponent} from './interfaces/i_component.js';
import {IConnectionChecker} from './interfaces/i_connection_checker.js';
import {IContextMenu} from './interfaces/i_contextmenu.js';
import {ICopyable} from './interfaces/i_copyable.js';
import {ICopyable, isCopyable} from './interfaces/i_copyable.js';
import {IDeletable} from './interfaces/i_deletable.js';
import {IDeleteArea} from './interfaces/i_delete_area.js';
import {IDragTarget} from './interfaces/i_drag_target.js';
@@ -592,7 +592,7 @@ export {IComponent};
export {IConnectionChecker};
export {IContextMenu};
export {icons};
export {ICopyable};
export {ICopyable, isCopyable};
export {IDeletable};
export {IDeleteArea};
export {IDragTarget};

View File

@@ -12,79 +12,139 @@ import {BlockPaster} from './clipboard/block_paster.js';
import * as globalRegistry from './registry.js';
import {WorkspaceSvg} from './workspace_svg.js';
import * as registry from './clipboard/registry.js';
import {Coordinate} from './utils/coordinate.js';
import * as deprecation from './utils/deprecation.js';
/** Metadata about the object that is currently on the clipboard. */
let copyData: ICopyData | null = null;
let stashedCopyData: ICopyData | null = null;
let source: WorkspaceSvg | null = null;
let stashedWorkspace: WorkspaceSvg | null = null;
/**
* Copy a block or workspace comment onto the local clipboard.
* Copy a copyable element onto the local clipboard.
*
* @param toCopy Block or Workspace Comment to be copied.
* @param toCopy The copyable element to be copied.
* @deprecated v11. Use `myCopyable.toCopyData()` instead. To be removed v12.
* @internal
*/
export function copy(toCopy: ICopyable) {
TEST_ONLY.copyInternal(toCopy);
export function copy<T extends ICopyData>(toCopy: ICopyable<T>): T | null {
deprecation.warn(
'Blockly.clipboard.copy',
'v11',
'v12',
'myCopyable.toCopyData()',
);
return TEST_ONLY.copyInternal(toCopy);
}
/**
* Private version of copy for stubbing in tests.
*/
function copyInternal(toCopy: ICopyable) {
copyData = toCopy.toCopyData();
source = (toCopy as any).workspace ?? null;
function copyInternal<T extends ICopyData>(toCopy: ICopyable<T>): T | null {
const data = toCopy.toCopyData();
stashedCopyData = data;
stashedWorkspace = (toCopy as any).workspace ?? null;
return data;
}
/**
* Paste a block or workspace comment on to the main workspace.
* Paste a pasteable element into the workspace.
*
* @param copyData The data to paste into the workspace.
* @param workspace The workspace to paste the data into.
* @param coordinate The location to paste the thing at.
* @returns The pasted thing if the paste was successful, null otherwise.
* @internal
*/
export function paste(): ICopyable | null {
if (!copyData) {
return null;
export function paste<T extends ICopyData>(
copyData: T,
workspace: WorkspaceSvg,
coordinate?: Coordinate,
): ICopyable<T> | null;
/**
* Pastes the last copied ICopyable into the workspace.
*
* @returns the pasted thing if the paste was successful, null otherwise.
*/
export function paste(): ICopyable<ICopyData> | null;
/**
* Pastes the given data into the workspace, or the last copied ICopyable if
* no data is passed.
*
* @param copyData The data to paste into the workspace.
* @param workspace The workspace to paste the data into.
* @param coordinate The location to paste the thing at.
* @returns The pasted thing if the paste was successful, null otherwise.
*/
export function paste<T extends ICopyData>(
copyData?: T,
workspace?: WorkspaceSvg,
coordinate?: Coordinate,
): ICopyable<ICopyData> | null {
if (!copyData || !workspace) {
if (!stashedCopyData || !stashedWorkspace) return null;
return pasteFromData(stashedCopyData, stashedWorkspace);
}
// Pasting always pastes to the main workspace, even if the copy
// started in a flyout workspace.
let workspace = source;
if (workspace?.isFlyout) {
workspace = workspace.targetWorkspace!;
}
if (!workspace) return null;
return (
globalRegistry
.getObject(globalRegistry.Type.PASTER, copyData.paster, false)
?.paste(copyData, workspace) ?? null
);
return pasteFromData(copyData, workspace, coordinate);
}
/**
* Duplicate this block and its children, or a workspace comment.
* Paste a pasteable element into the workspace.
*
* @param toDuplicate Block or Workspace Comment to be duplicated.
* @returns The block or workspace comment that was duplicated, or null if the
* duplication failed.
* @param copyData The data to paste into the workspace.
* @param workspace The workspace to paste the data into.
* @param coordinate The location to paste the thing at.
* @returns The pasted thing if the paste was successful, null otherwise.
*/
function pasteFromData<T extends ICopyData>(
copyData: T,
workspace: WorkspaceSvg,
coordinate?: Coordinate,
): ICopyable<T> | null {
workspace = workspace.getRootWorkspace() ?? workspace;
return (globalRegistry
.getObject(globalRegistry.Type.PASTER, copyData.paster, false)
?.paste(copyData, workspace, coordinate) ?? null) as ICopyable<T> | null;
}
/**
* Duplicate this copy-paste-able element.
*
* @param toDuplicate The element to be duplicated.
* @returns The element that was duplicated, or null if the duplication failed.
* @deprecated v11. Use
* `Blockly.clipboard.paste(myCopyable.toCopyData(), myWorkspace)` instead.
* To be removed v12.
* @internal
*/
export function duplicate(toDuplicate: ICopyable): ICopyable | null {
export function duplicate<
U extends ICopyData,
T extends ICopyable<U> & IHasWorkspace,
>(toDuplicate: T): T | null {
deprecation.warn(
'Blockly.clipboard.duplicate',
'v11',
'v12',
'Blockly.clipboard.paste(myCopyable.toCopyData(), myWorkspace)',
);
return TEST_ONLY.duplicateInternal(toDuplicate);
}
/**
* Private version of duplicate for stubbing in tests.
*/
function duplicateInternal(toDuplicate: ICopyable): ICopyable | null {
const oldCopyData = copyData;
copy(toDuplicate);
if (!copyData || !source) return null;
const pastedThing =
globalRegistry
.getObject(globalRegistry.Type.PASTER, copyData.paster, false)
?.paste(copyData, source) ?? null;
copyData = oldCopyData;
return pastedThing;
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 = {

View File

@@ -14,7 +14,7 @@ import * as registry from '../registry.js';
* @param type The type of the paster to register, e.g. 'block', 'comment', etc.
* @param paster The paster to register.
*/
export function register<U extends ICopyData, T extends ICopyable>(
export function register<U extends ICopyData, T extends ICopyable<U>>(
type: string,
paster: IPaster<U, T>,
) {

View File

@@ -9,9 +9,9 @@ goog.declareModuleId('Blockly.common');
/* eslint-disable-next-line no-unused-vars */
import type {Block} from './block.js';
import {ISelectable} from './blockly.js';
import {BlockDefinition, Blocks} from './blocks.js';
import type {Connection} from './connection.js';
import type {ICopyable} from './interfaces/i_copyable.js';
import type {Workspace} from './workspace.js';
import type {WorkspaceSvg} from './workspace_svg.js';
@@ -88,12 +88,12 @@ export function setMainWorkspace(workspace: Workspace) {
/**
* Currently selected copyable object.
*/
let selected: ICopyable | null = null;
let selected: ISelectable | null = null;
/**
* Returns the currently selected copyable object.
*/
export function getSelected(): ICopyable | null {
export function getSelected(): ISelectable | null {
return selected;
}
@@ -105,7 +105,7 @@ export function getSelected(): ICopyable | null {
* @param newSelection The newly selected block.
* @internal
*/
export function setSelected(newSelection: ICopyable | null) {
export function setSelected(newSelection: ISelectable | null) {
selected = newSelection;
}

View File

@@ -9,13 +9,13 @@ goog.declareModuleId('Blockly.ICopyable');
import type {ISelectable} from './i_selectable.js';
export interface ICopyable extends ISelectable {
export interface ICopyable<T extends ICopyData> extends ISelectable {
/**
* Encode for copying.
*
* @returns Copy metadata.
*/
toCopyData(): ICopyData | null;
toCopyData(): T | null;
}
export namespace ICopyable {
@@ -25,3 +25,8 @@ export namespace ICopyable {
}
export type ICopyData = ICopyable.ICopyData;
/** @returns true if the given object is copyable. */
export function isCopyable(obj: any): obj is ICopyable<ICopyData> {
return obj.toCopyData !== undefined;
}

View File

@@ -9,7 +9,7 @@ import {WorkspaceSvg} from '../workspace_svg.js';
import {ICopyable, ICopyData} from './i_copyable.js';
/** An object that can paste data into a workspace. */
export interface IPaster<U extends ICopyData, T extends ICopyable> {
export interface IPaster<U extends ICopyData, T extends ICopyable<U>> {
paste(
copyData: U,
workspace: WorkspaceSvg,
@@ -18,6 +18,8 @@ export interface IPaster<U extends ICopyData, T extends ICopyable> {
}
/** @returns True if the given object is a paster. */
export function isPaster(obj: any): obj is IPaster<ICopyData, ICopyable> {
export function isPaster(
obj: any,
): obj is IPaster<ICopyData, ICopyable<ICopyData>> {
return obj.paste !== undefined;
}

View File

@@ -100,7 +100,7 @@ export class Type<_T> {
static ICON = new Type<IIcon>('icon');
/** @internal */
static PASTER = new Type<IPaster<ICopyData, ICopyable>>('paster');
static PASTER = new Type<IPaster<ICopyData, ICopyable<ICopyData>>>('paster');
}
/**

View File

@@ -11,7 +11,7 @@ import {BlockSvg} from './block_svg.js';
import * as clipboard from './clipboard.js';
import * as common from './common.js';
import {Gesture} from './gesture.js';
import type {ICopyable} from './interfaces/i_copyable.js';
import {isCopyable} from './interfaces/i_copyable.js';
import {KeyboardShortcut, ShortcutRegistry} from './shortcut_registry.js';
import {KeyCodes} from './utils/keycodes.js';
import type {WorkspaceSvg} from './workspace_svg.js';
@@ -114,7 +114,9 @@ export function registerCopy() {
// AnyDuringMigration because: Property 'hideChaff' does not exist on
// type 'Workspace'.
(workspace as AnyDuringMigration).hideChaff();
clipboard.copy(common.getSelected() as ICopyable);
const selected = common.getSelected();
if (!selected || !isCopyable(selected)) return false;
clipboard.copy(selected);
return true;
},
keyCodes: [ctrlC, altC, metaC],
@@ -152,10 +154,7 @@ export function registerCut() {
},
callback() {
const selected = common.getSelected();
if (!selected) {
// Shouldn't happen but appeases the type system
return false;
}
if (!selected || !isCopyable(selected)) return false;
clipboard.copy(selected);
(selected as BlockSvg).checkAndDelete();
return true;

View File

@@ -51,7 +51,7 @@ const TEXTAREA_OFFSET = 2;
*/
export class WorkspaceCommentSvg
extends WorkspaceComment
implements IBoundedElement, IBubble, ICopyable
implements IBoundedElement, IBubble, ICopyable<WorkspaceCommentCopyData>
{
/**
* The width and height to use to size a workspace comment when it is first

View File

@@ -36,7 +36,7 @@ import {Gesture} from './gesture.js';
import {Grid} from './grid.js';
import type {IASTNodeLocationSvg} from './interfaces/i_ast_node_location_svg.js';
import type {IBoundedElement} from './interfaces/i_bounded_element.js';
import type {ICopyable} from './interfaces/i_copyable.js';
import type {ICopyData, ICopyable} from './interfaces/i_copyable.js';
import type {IDragTarget} from './interfaces/i_drag_target.js';
import type {IFlyout} from './interfaces/i_flyout.js';
import type {IMetricsManager} from './interfaces/i_metrics_manager.js';
@@ -1300,7 +1300,7 @@ export class WorkspaceSvg extends Workspace implements IASTNodeLocationSvg {
*/
paste(
state: AnyDuringMigration | Element | DocumentFragment,
): ICopyable | null {
): ICopyable<ICopyData> | null {
if (!this.rendered || (!state['type'] && !state['tagName'])) {
return null;
}

View File

@@ -0,0 +1,35 @@
/**
* @license
* Copyright 2019 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
goog.declareModuleId('Blockly.test.clipboard');
import {
sharedTestSetup,
sharedTestTeardown,
} from './test_helpers/setup_teardown.js';
suite('Clipboard', function () {
setup(function () {
this.clock = sharedTestSetup.call(this, {fireEventsNow: false}).clock;
this.workspace = new Blockly.WorkspaceSvg(new Blockly.Options({}));
});
teardown(function () {
sharedTestTeardown.call(this);
});
test('a paster registered with a given type is called when pasting that type', function () {
const paster = {
paste: sinon.stub().returns(null),
};
Blockly.clipboard.registry.register('test-paster', paster);
Blockly.clipboard.paste({paster: 'test-paster'}, this.workspace);
chai.assert.isTrue(paster.paste.calledOnce);
Blockly.clipboard.registry.unregister('test-paster');
});
});

View File

@@ -38,6 +38,7 @@
'Blockly.test.astNode',
'Blockly.test.blockJson',
'Blockly.test.blocks',
'Blockly.test.clipboard',
'Blockly.test.comments',
'Blockly.test.commentDeserialization',
'Blockly.test.connectionChecker',