Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: make ICopyable generic and update clipboard APIs #7348

Merged
6 changes: 5 additions & 1 deletion core/block_svg.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 2 additions & 2 deletions core/blockly.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -592,7 +592,7 @@ export {IComponent};
export {IConnectionChecker};
export {IContextMenu};
export {icons};
export {ICopyable};
export {ICopyable, isCopyable};
BeksOmega marked this conversation as resolved.
Show resolved Hide resolved
export {IDeletable};
export {IDeleteArea};
export {IDragTarget};
Expand Down
122 changes: 87 additions & 35 deletions core/clipboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,52 +12,92 @@ 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';

/** 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.
*
* @param toCopy Block or Workspace Comment to be copied.
BeksOmega marked this conversation as resolved.
Show resolved Hide resolved
* @internal
*/
export function copy(toCopy: ICopyable) {
TEST_ONLY.copyInternal(toCopy);
export function copy<T extends ICopyData>(toCopy: ICopyable<T>): T | null {
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; // Necessary for propery typing. This is why state sucks.
BeksOmega marked this conversation as resolved.
Show resolved Hide resolved
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;
}
// 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!;
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>(
BeksOmega marked this conversation as resolved.
Show resolved Hide resolved
copyData?: T,
workspace?: WorkspaceSvg,
coordinate?: Coordinate,
): ICopyable<ICopyData> | null {
if (!copyData || !workspace) {
if (!stashedCopyData || !stashedWorkspace) return null;
return pasteFromData(stashedCopyData, stashedWorkspace);
}
if (!workspace) return null;
return (
globalRegistry
.getObject(globalRegistry.Type.PASTER, copyData.paster, false)
?.paste(copyData, workspace) ?? null
);
return pasteFromData(copyData, workspace, coordinate);
}

/**
* 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.
*/
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;
}

/**
Expand All @@ -68,23 +108,35 @@ export function paste(): ICopyable | null {
* duplication failed.
BeksOmega marked this conversation as resolved.
Show resolved Hide resolved
* @internal
*/
export function duplicate(toDuplicate: ICopyable): ICopyable | null {
export function duplicate<
U extends ICopyData,
T extends ICopyable<U> & IHasWorkspace,
>(toDuplicate: T): T | null {
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 oldCopyData = stashedCopyData;
const oldWorkspace = stashedWorkspace;

const data = copy(toDuplicate);

// I hate side effects.
stashedCopyData = oldCopyData;
BeksOmega marked this conversation as resolved.
Show resolved Hide resolved
stashedWorkspace = oldWorkspace;

if (!data) return null;
return paste(data, toDuplicate.workspace) as T;
}

interface IHasWorkspace {
BeksOmega marked this conversation as resolved.
Show resolved Hide resolved
workspace: WorkspaceSvg;
}

export const TEST_ONLY = {
Expand Down
2 changes: 1 addition & 1 deletion core/clipboard/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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>,
) {
Expand Down
8 changes: 4 additions & 4 deletions core/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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;
}

Expand All @@ -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;
}

Expand Down
9 changes: 7 additions & 2 deletions core/interfaces/i_copyable.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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;
}
6 changes: 4 additions & 2 deletions core/interfaces/i_paster.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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;
}
2 changes: 1 addition & 1 deletion core/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
}

/**
Expand Down
11 changes: 5 additions & 6 deletions core/shortcut_items.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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],
Expand Down Expand Up @@ -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;
Expand Down
2 changes: 1 addition & 1 deletion core/workspace_comment_svg.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions core/workspace_svg.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;
}
Expand Down
35 changes: 35 additions & 0 deletions tests/mocha/clipboard_test.js
Original file line number Diff line number Diff line change
@@ -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');
});
});
1 change: 1 addition & 0 deletions tests/mocha/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
Loading