mirror of
https://github.com/google/blockly.git
synced 2025-12-16 06:10:12 +01:00
feat: merge copy paste work into develop
Merge pull request #7379 from google/operation-copy-that
This commit is contained in:
@@ -37,7 +37,7 @@ import {FieldLabel} from './field_label.js';
|
||||
import type {Input} from './inputs/input.js';
|
||||
import type {IASTNodeLocationSvg} from './interfaces/i_ast_node_location_svg.js';
|
||||
import type {IBoundedElement} from './interfaces/i_bounded_element.js';
|
||||
import type {CopyData, ICopyable} from './interfaces/i_copyable.js';
|
||||
import type {ICopyable} from './interfaces/i_copyable.js';
|
||||
import type {IDraggable} from './interfaces/i_draggable.js';
|
||||
import {IIcon} from './interfaces/i_icon.js';
|
||||
import * as internalConstants from './internal_constants.js';
|
||||
@@ -62,6 +62,7 @@ import type {WorkspaceSvg} from './workspace_svg.js';
|
||||
import * as renderManagement from './render_management.js';
|
||||
import * as deprecation from './utils/deprecation.js';
|
||||
import {IconType} from './icons/icon_types.js';
|
||||
import {BlockCopyData, BlockPaster} from './clipboard/block_paster.js';
|
||||
|
||||
/**
|
||||
* Class for a block's SVG representation.
|
||||
@@ -69,7 +70,11 @@ import {IconType} from './icons/icon_types.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.
|
||||
@@ -823,18 +828,17 @@ export class BlockSvg
|
||||
* Encode a block for copying.
|
||||
*
|
||||
* @returns Copy metadata, or null if the block is an insertion marker.
|
||||
* @internal
|
||||
*/
|
||||
toCopyData(): CopyData | null {
|
||||
toCopyData(): BlockCopyData | null {
|
||||
if (this.isInsertionMarker_) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
saveInfo: blocks.save(this, {
|
||||
paster: BlockPaster.TYPE,
|
||||
blockState: blocks.save(this, {
|
||||
addCoordinates: true,
|
||||
addNextBlocks: false,
|
||||
}) as blocks.State,
|
||||
source: this.workspace,
|
||||
typeCounts: common.getBlockTypeCounts(this, true),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
@@ -152,6 +152,7 @@ import {IKeyboardAccessible} from './interfaces/i_keyboard_accessible.js';
|
||||
import {IMetricsManager} from './interfaces/i_metrics_manager.js';
|
||||
import {IMovable} from './interfaces/i_movable.js';
|
||||
import {IObservable, isObservable} from './interfaces/i_observable.js';
|
||||
import {IPaster, isPaster} from './interfaces/i_paster.js';
|
||||
import {IPositionable} from './interfaces/i_positionable.js';
|
||||
import {IRegistrable} from './interfaces/i_registrable.js';
|
||||
import {ISelectable} from './interfaces/i_selectable.js';
|
||||
@@ -591,7 +592,7 @@ export {IComponent};
|
||||
export {IConnectionChecker};
|
||||
export {IContextMenu};
|
||||
export {icons};
|
||||
export {ICopyable};
|
||||
export {ICopyable, isCopyable};
|
||||
export {IDeletable};
|
||||
export {IDeleteArea};
|
||||
export {IDragTarget};
|
||||
@@ -606,6 +607,7 @@ export {Input};
|
||||
export {inputs};
|
||||
export {InsertionMarkerManager};
|
||||
export {IObservable, isObservable};
|
||||
export {IPaster, isPaster};
|
||||
export {IPositionable};
|
||||
export {IRegistrable};
|
||||
export {ISelectable};
|
||||
|
||||
@@ -7,78 +7,149 @@
|
||||
import * as goog from '../closure/goog/goog.js';
|
||||
goog.declareModuleId('Blockly.clipboard');
|
||||
|
||||
import type {CopyData, ICopyable} from './interfaces/i_copyable.js';
|
||||
import type {ICopyData, ICopyable} from './interfaces/i_copyable.js';
|
||||
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: CopyData | null = null;
|
||||
let stashedCopyData: ICopyData | 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();
|
||||
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 = copyData.source;
|
||||
if (workspace.isFlyout) {
|
||||
workspace = workspace.targetWorkspace!;
|
||||
}
|
||||
if (
|
||||
copyData.typeCounts &&
|
||||
workspace.isCapacityAvailable(copyData.typeCounts)
|
||||
) {
|
||||
return workspace.paste(copyData.saveInfo);
|
||||
}
|
||||
return 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);
|
||||
const pastedThing =
|
||||
toDuplicate.toCopyData()?.source?.paste(copyData!.saveInfo) ?? 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 = {
|
||||
duplicateInternal,
|
||||
copyInternal,
|
||||
};
|
||||
|
||||
export {BlockPaster, registry};
|
||||
|
||||
120
core/clipboard/block_paster.ts
Normal file
120
core/clipboard/block_paster.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2023 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {BlockSvg} from '../block_svg.js';
|
||||
import * as registry from './registry.js';
|
||||
import {ICopyData} from '../interfaces/i_copyable.js';
|
||||
import {IPaster} from '../interfaces/i_paster.js';
|
||||
import {State, append} from '../serialization/blocks.js';
|
||||
import {Coordinate} from '../utils/coordinate.js';
|
||||
import {WorkspaceSvg} from '../workspace_svg.js';
|
||||
import * as eventUtils from '../events/utils.js';
|
||||
import {config} from '../config.js';
|
||||
|
||||
export class BlockPaster implements IPaster<BlockCopyData, BlockSvg> {
|
||||
static TYPE = 'block';
|
||||
|
||||
paste(
|
||||
copyData: BlockCopyData,
|
||||
workspace: WorkspaceSvg,
|
||||
coordinate?: Coordinate,
|
||||
): BlockSvg | null {
|
||||
if (!workspace.isCapacityAvailable(copyData.typeCounts!)) return null;
|
||||
|
||||
if (coordinate) {
|
||||
copyData.blockState['x'] = coordinate.x;
|
||||
copyData.blockState['y'] = coordinate.y;
|
||||
}
|
||||
|
||||
eventUtils.disable();
|
||||
let block;
|
||||
try {
|
||||
block = append(copyData.blockState, workspace) as BlockSvg;
|
||||
moveBlockToNotConflict(block);
|
||||
} finally {
|
||||
eventUtils.enable();
|
||||
}
|
||||
|
||||
if (!block) return block;
|
||||
|
||||
if (eventUtils.isEnabled() && !block.isShadow()) {
|
||||
eventUtils.fire(new (eventUtils.get(eventUtils.BLOCK_CREATE))(block));
|
||||
}
|
||||
block.select();
|
||||
return block;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves the given block to a location where it does not: (1) overlap exactly
|
||||
* with any other blocks, or (2) look like it is connected to any other blocks.
|
||||
*
|
||||
* Exported for testing.
|
||||
*
|
||||
* @param block The block to move to an unambiguous location.
|
||||
* @internal
|
||||
*/
|
||||
export function moveBlockToNotConflict(block: BlockSvg) {
|
||||
const workspace = block.workspace;
|
||||
const snapRadius = config.snapRadius;
|
||||
const coord = block.getRelativeToSurfaceXY();
|
||||
const offset = new Coordinate(0, 0);
|
||||
// getRelativeToSurfaceXY is really expensive, so we want to cache this.
|
||||
const otherCoords = workspace
|
||||
.getAllBlocks(false)
|
||||
.filter((otherBlock) => otherBlock.id != block.id)
|
||||
.map((b) => b.getRelativeToSurfaceXY());
|
||||
|
||||
while (
|
||||
blockOverlapsOtherExactly(Coordinate.sum(coord, offset), otherCoords) ||
|
||||
blockIsInSnapRadius(block, offset, snapRadius)
|
||||
) {
|
||||
if (workspace.RTL) {
|
||||
offset.translate(-snapRadius, snapRadius * 2);
|
||||
} else {
|
||||
offset.translate(snapRadius, snapRadius * 2);
|
||||
}
|
||||
}
|
||||
|
||||
block!.moveTo(Coordinate.sum(coord, offset));
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns true if the given block coordinates are less than a delta of 1 from
|
||||
* any of the other coordinates.
|
||||
*/
|
||||
function blockOverlapsOtherExactly(
|
||||
coord: Coordinate,
|
||||
otherCoords: Coordinate[],
|
||||
): boolean {
|
||||
return otherCoords.some(
|
||||
(otherCoord) =>
|
||||
Math.abs(otherCoord.x - coord.x) <= 1 &&
|
||||
Math.abs(otherCoord.y - coord.y) <= 1,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns true if the given block (when offset by the given amount) is close
|
||||
* enough to any other connections (within the snap radius) that it looks
|
||||
* like they could connect.
|
||||
*/
|
||||
function blockIsInSnapRadius(
|
||||
block: BlockSvg,
|
||||
offset: Coordinate,
|
||||
snapRadius: number,
|
||||
): boolean {
|
||||
return block
|
||||
.getConnections_(false)
|
||||
.some((connection) => !!connection.closest(snapRadius, offset).connection);
|
||||
}
|
||||
|
||||
export interface BlockCopyData extends ICopyData {
|
||||
blockState: State;
|
||||
typeCounts: {[key: string]: number};
|
||||
}
|
||||
|
||||
registry.register(BlockPaster.TYPE, new BlockPaster());
|
||||
31
core/clipboard/registry.ts
Normal file
31
core/clipboard/registry.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2023 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {ICopyable, ICopyData} from '../interfaces/i_copyable.js';
|
||||
import type {IPaster} from '../interfaces/i_paster.js';
|
||||
import * as registry from '../registry.js';
|
||||
|
||||
/**
|
||||
* Registers the given paster so that it cna be used for pasting.
|
||||
*
|
||||
* @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<U>>(
|
||||
type: string,
|
||||
paster: IPaster<U, T>,
|
||||
) {
|
||||
registry.register(registry.Type.PASTER, type, paster);
|
||||
}
|
||||
|
||||
/**
|
||||
* Unregisters the paster associated with the given type.
|
||||
*
|
||||
* @param type The type of the paster to unregister.
|
||||
*/
|
||||
export function unregister(type: string) {
|
||||
registry.unregister(registry.Type.PASTER, type);
|
||||
}
|
||||
45
core/clipboard/workspace_comment_paster.ts
Normal file
45
core/clipboard/workspace_comment_paster.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2023 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {IPaster} from '../interfaces/i_paster.js';
|
||||
import {ICopyData} from '../interfaces/i_copyable.js';
|
||||
import {Coordinate} from '../utils/coordinate.js';
|
||||
import {WorkspaceSvg} from '../workspace_svg.js';
|
||||
import {WorkspaceCommentSvg} from '../workspace_comment_svg.js';
|
||||
import * as registry from './registry.js';
|
||||
|
||||
export class WorkspaceCommentPaster
|
||||
implements IPaster<WorkspaceCommentCopyData, WorkspaceCommentSvg>
|
||||
{
|
||||
static TYPE = 'workspace-comment';
|
||||
|
||||
paste(
|
||||
copyData: WorkspaceCommentCopyData,
|
||||
workspace: WorkspaceSvg,
|
||||
coordinate?: Coordinate,
|
||||
): WorkspaceCommentSvg {
|
||||
const state = copyData.commentState;
|
||||
if (coordinate) {
|
||||
state.setAttribute('x', `${coordinate.x}`);
|
||||
state.setAttribute('y', `${coordinate.y}`);
|
||||
} else {
|
||||
const x = parseInt(state.getAttribute('x') ?? '0') + 50;
|
||||
const y = parseInt(state.getAttribute('y') ?? '0') + 50;
|
||||
state.setAttribute('x', `${x}`);
|
||||
state.setAttribute('y', `${y}`);
|
||||
}
|
||||
return WorkspaceCommentSvg.fromXmlRendered(
|
||||
copyData.commentState,
|
||||
workspace,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export interface WorkspaceCommentCopyData extends ICopyData {
|
||||
commentState: Element;
|
||||
}
|
||||
|
||||
registry.register(WorkspaceCommentPaster.TYPE, new WorkspaceCommentPaster());
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -297,7 +297,9 @@ export function commentDuplicateOption(
|
||||
text: Msg['DUPLICATE_COMMENT'],
|
||||
enabled: true,
|
||||
callback: function () {
|
||||
clipboard.duplicate(comment);
|
||||
const data = comment.toCopyData();
|
||||
if (!data) return;
|
||||
clipboard.paste(data, comment.workspace);
|
||||
},
|
||||
};
|
||||
return duplicateOption;
|
||||
|
||||
@@ -331,9 +331,10 @@ export function registerDuplicate() {
|
||||
return 'hidden';
|
||||
},
|
||||
callback(scope: Scope) {
|
||||
if (scope.block) {
|
||||
clipboard.duplicate(scope.block);
|
||||
}
|
||||
if (!scope.block) return;
|
||||
const data = scope.block.toCopyData();
|
||||
if (!data) return;
|
||||
clipboard.paste(data, scope.block.workspace);
|
||||
},
|
||||
scopeType: ContextMenuRegistry.ScopeType.BLOCK,
|
||||
id: 'blockDuplicate',
|
||||
|
||||
@@ -7,25 +7,26 @@
|
||||
import * as goog from '../../closure/goog/goog.js';
|
||||
goog.declareModuleId('Blockly.ICopyable');
|
||||
|
||||
import type {WorkspaceSvg} from '../workspace_svg.js';
|
||||
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.
|
||||
* @internal
|
||||
*/
|
||||
toCopyData(): CopyData | null;
|
||||
toCopyData(): T | null;
|
||||
}
|
||||
|
||||
export namespace ICopyable {
|
||||
export interface CopyData {
|
||||
saveInfo: Object | Element;
|
||||
source: WorkspaceSvg;
|
||||
typeCounts: {[key: string]: number} | null;
|
||||
export interface ICopyData {
|
||||
paster: string;
|
||||
}
|
||||
}
|
||||
|
||||
export type CopyData = ICopyable.CopyData;
|
||||
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;
|
||||
}
|
||||
|
||||
25
core/interfaces/i_paster.ts
Normal file
25
core/interfaces/i_paster.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2023 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {Coordinate} from '../utils/coordinate.js';
|
||||
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<U>> {
|
||||
paste(
|
||||
copyData: U,
|
||||
workspace: WorkspaceSvg,
|
||||
coordinate?: Coordinate,
|
||||
): T | null;
|
||||
}
|
||||
|
||||
/** @returns True if the given object is a paster. */
|
||||
export function isPaster(
|
||||
obj: any,
|
||||
): obj is IPaster<ICopyData, ICopyable<ICopyData>> {
|
||||
return obj.paste !== undefined;
|
||||
}
|
||||
@@ -22,6 +22,8 @@ import type {Options} from './options.js';
|
||||
import type {Renderer} from './renderers/common/renderer.js';
|
||||
import type {Theme} from './theme.js';
|
||||
import type {ToolboxItem} from './toolbox/toolbox_item.js';
|
||||
import {IPaster} from './interfaces/i_paster.js';
|
||||
import {ICopyData, ICopyable} from './interfaces/i_copyable.js';
|
||||
|
||||
/**
|
||||
* A map of maps. With the keys being the type and name of the class we are
|
||||
@@ -96,6 +98,9 @@ export class Type<_T> {
|
||||
|
||||
/** @internal */
|
||||
static ICON = new Type<IIcon>('icon');
|
||||
|
||||
/** @internal */
|
||||
static PASTER = new Type<IPaster<ICopyData, ICopyable<ICopyData>>>('paster');
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -396,7 +396,9 @@ export function appendInternal(
|
||||
const block = appendPrivate(state, workspace, {parentConnection, isShadow});
|
||||
|
||||
eventUtils.enable();
|
||||
eventUtils.fire(new (eventUtils.get(eventUtils.BLOCK_CREATE))(block));
|
||||
if (eventUtils.isEnabled()) {
|
||||
eventUtils.fire(new (eventUtils.get(eventUtils.BLOCK_CREATE))(block));
|
||||
}
|
||||
eventUtils.setGroup(existingGroup);
|
||||
eventUtils.setRecordUndo(prevRecordUndo);
|
||||
|
||||
|
||||
@@ -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 {ICopyData, 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';
|
||||
@@ -81,6 +81,9 @@ export function registerDelete() {
|
||||
ShortcutRegistry.registry.register(deleteShortcut);
|
||||
}
|
||||
|
||||
let copyData: ICopyData | null = null;
|
||||
let copyWorkspace: WorkspaceSvg | null = null;
|
||||
|
||||
/**
|
||||
* Keyboard shortcut to copy a block on ctrl+c, cmd+c, or alt+c.
|
||||
*/
|
||||
@@ -104,18 +107,20 @@ export function registerCopy() {
|
||||
!Gesture.inProgress() &&
|
||||
selected != null &&
|
||||
selected.isDeletable() &&
|
||||
selected.isMovable()
|
||||
selected.isMovable() &&
|
||||
isCopyable(selected)
|
||||
);
|
||||
},
|
||||
callback(workspace, e) {
|
||||
// Prevent the default copy behavior, which may beep or otherwise indicate
|
||||
// an error due to the lack of a selection.
|
||||
e.preventDefault();
|
||||
// AnyDuringMigration because: Property 'hideChaff' does not exist on
|
||||
// type 'Workspace'.
|
||||
(workspace as AnyDuringMigration).hideChaff();
|
||||
clipboard.copy(common.getSelected() as ICopyable);
|
||||
return true;
|
||||
workspace.hideChaff();
|
||||
const selected = common.getSelected();
|
||||
if (!selected || !isCopyable(selected)) return false;
|
||||
copyData = selected.toCopyData();
|
||||
copyWorkspace = workspace;
|
||||
return !!copyData;
|
||||
},
|
||||
keyCodes: [ctrlC, altC, metaC],
|
||||
};
|
||||
@@ -150,13 +155,11 @@ export function registerCut() {
|
||||
!selected.workspace!.isFlyout
|
||||
);
|
||||
},
|
||||
callback() {
|
||||
callback(workspace) {
|
||||
const selected = common.getSelected();
|
||||
if (!selected) {
|
||||
// Shouldn't happen but appeases the type system
|
||||
return false;
|
||||
}
|
||||
clipboard.copy(selected);
|
||||
if (!selected || !isCopyable(selected)) return false;
|
||||
copyData = selected.toCopyData();
|
||||
copyWorkspace = workspace;
|
||||
(selected as BlockSvg).checkAndDelete();
|
||||
return true;
|
||||
},
|
||||
@@ -186,7 +189,8 @@ export function registerPaste() {
|
||||
return !workspace.options.readOnly && !Gesture.inProgress();
|
||||
},
|
||||
callback() {
|
||||
return !!clipboard.paste();
|
||||
if (!copyData || !copyWorkspace) return false;
|
||||
return !!clipboard.paste(copyData, copyWorkspace);
|
||||
},
|
||||
keyCodes: [ctrlV, altV, metaV],
|
||||
};
|
||||
|
||||
@@ -15,7 +15,7 @@ goog.declareModuleId('Blockly.ShortcutRegistry');
|
||||
|
||||
import {KeyCodes} from './utils/keycodes.js';
|
||||
import * as object from './utils/object.js';
|
||||
import type {Workspace} from './workspace.js';
|
||||
import {WorkspaceSvg} from './workspace_svg.js';
|
||||
|
||||
/**
|
||||
* Class for the registry of keyboard shortcuts. This is intended to be a
|
||||
@@ -224,7 +224,7 @@ export class ShortcutRegistry {
|
||||
* @param e The key down event.
|
||||
* @returns True if the event was handled, false otherwise.
|
||||
*/
|
||||
onKeyDown(workspace: Workspace, e: KeyboardEvent): boolean {
|
||||
onKeyDown(workspace: WorkspaceSvg, e: KeyboardEvent): boolean {
|
||||
const key = this.serializeKeyEvent_(e);
|
||||
const shortcutNames = this.getShortcutNamesByKeyCode(key);
|
||||
if (!shortcutNames) {
|
||||
@@ -346,9 +346,9 @@ export class ShortcutRegistry {
|
||||
|
||||
export namespace ShortcutRegistry {
|
||||
export interface KeyboardShortcut {
|
||||
callback?: (p1: Workspace, p2: Event, p3: KeyboardShortcut) => boolean;
|
||||
callback?: (p1: WorkspaceSvg, p2: Event, p3: KeyboardShortcut) => boolean;
|
||||
name: string;
|
||||
preconditionFn?: (p1: Workspace) => boolean;
|
||||
preconditionFn?: (p1: WorkspaceSvg) => boolean;
|
||||
metadata?: object;
|
||||
keyCodes?: (number | string)[];
|
||||
allowCollision?: boolean;
|
||||
|
||||
@@ -23,7 +23,7 @@ import type {CommentMove} from './events/events_comment_move.js';
|
||||
import * as eventUtils from './events/utils.js';
|
||||
import type {IBoundedElement} from './interfaces/i_bounded_element.js';
|
||||
import type {IBubble} from './interfaces/i_bubble.js';
|
||||
import type {CopyData, ICopyable} from './interfaces/i_copyable.js';
|
||||
import type {ICopyable} from './interfaces/i_copyable.js';
|
||||
import * as Touch from './touch.js';
|
||||
import {Coordinate} from './utils/coordinate.js';
|
||||
import * as dom from './utils/dom.js';
|
||||
@@ -32,6 +32,10 @@ import {Svg} from './utils/svg.js';
|
||||
import * as svgMath from './utils/svg_math.js';
|
||||
import {WorkspaceComment} from './workspace_comment.js';
|
||||
import type {WorkspaceSvg} from './workspace_svg.js';
|
||||
import {
|
||||
WorkspaceCommentCopyData,
|
||||
WorkspaceCommentPaster,
|
||||
} from './clipboard/workspace_comment_paster.js';
|
||||
|
||||
/** Size of the resize icon. */
|
||||
const RESIZE_SIZE = 8;
|
||||
@@ -47,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
|
||||
@@ -566,13 +570,11 @@ export class WorkspaceCommentSvg
|
||||
* Encode a comment for copying.
|
||||
*
|
||||
* @returns Copy metadata.
|
||||
* @internal
|
||||
*/
|
||||
toCopyData(): CopyData {
|
||||
toCopyData(): WorkspaceCommentCopyData {
|
||||
return {
|
||||
saveInfo: this.toXmlWithXY(),
|
||||
source: this.workspace,
|
||||
typeCounts: null,
|
||||
paster: WorkspaceCommentPaster.TYPE,
|
||||
commentState: this.toXmlWithXY(),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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';
|
||||
@@ -1294,7 +1294,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;
|
||||
}
|
||||
@@ -1382,6 +1382,10 @@ export class WorkspaceSvg extends Workspace implements IASTNodeLocationSvg {
|
||||
for (let i = 0, connection; (connection = connections[i]); i++) {
|
||||
const neighbour = connection.closest(
|
||||
config.snapRadius,
|
||||
// TODO: This code doesn't work because it's passing an absolute
|
||||
// coordinate instead of a relative coordinate. Need to
|
||||
// figure out if I'm deprecating this function or if I
|
||||
// need to fix this.
|
||||
new Coordinate(blockX, blockY),
|
||||
);
|
||||
if (neighbour.connection) {
|
||||
@@ -1435,6 +1439,9 @@ export class WorkspaceSvg extends Workspace implements IASTNodeLocationSvg {
|
||||
// with any blocks.
|
||||
commentX += 50;
|
||||
commentY += 50;
|
||||
// TODO: This code doesn't work because it's using absolute coords
|
||||
// where relative coords are expected. Need to figure out what I'm
|
||||
// doing with this function and if I need to fix it.
|
||||
comment.moveBy(commentX, commentY);
|
||||
}
|
||||
} finally {
|
||||
|
||||
136
tests/mocha/clipboard_test.js
Normal file
136
tests/mocha/clipboard_test.js
Normal file
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2023 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
goog.declareModuleId('Blockly.test.clipboard');
|
||||
|
||||
import {
|
||||
sharedTestSetup,
|
||||
sharedTestTeardown,
|
||||
} from './test_helpers/setup_teardown.js';
|
||||
import {
|
||||
assertEventFired,
|
||||
createChangeListenerSpy,
|
||||
} from './test_helpers/events.js';
|
||||
|
||||
suite('Clipboard', function () {
|
||||
setup(function () {
|
||||
this.clock = sharedTestSetup.call(this, {fireEventsNow: false}).clock;
|
||||
this.workspace = Blockly.inject('blocklyDiv');
|
||||
});
|
||||
|
||||
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');
|
||||
});
|
||||
|
||||
suite('pasting blocks', function () {
|
||||
test('pasting blocks fires a create event', function () {
|
||||
const eventSpy = createChangeListenerSpy(this.workspace);
|
||||
const block = Blockly.serialization.blocks.append(
|
||||
{
|
||||
'type': 'controls_if',
|
||||
'id': 'blockId',
|
||||
},
|
||||
this.workspace,
|
||||
);
|
||||
const data = block.toCopyData();
|
||||
this.clock.runAll();
|
||||
eventSpy.resetHistory();
|
||||
|
||||
Blockly.clipboard.paste(data, this.workspace);
|
||||
this.clock.runAll();
|
||||
|
||||
assertEventFired(
|
||||
eventSpy,
|
||||
Blockly.Events.BlockCreate,
|
||||
{'recordUndo': true, 'type': Blockly.Events.BLOCK_CREATE},
|
||||
this.workspace.id,
|
||||
);
|
||||
});
|
||||
|
||||
suite('pasted blocks are placed in unambiguous locations', function () {
|
||||
test('pasted blocks are bumped to not overlap', function () {
|
||||
const block = Blockly.serialization.blocks.append(
|
||||
{
|
||||
'type': 'controls_if',
|
||||
'x': 38,
|
||||
'y': 13,
|
||||
},
|
||||
this.workspace,
|
||||
);
|
||||
const data = block.toCopyData();
|
||||
|
||||
const newBlock = Blockly.clipboard.paste(data, this.workspace);
|
||||
chai.assert.deepEqual(
|
||||
newBlock.getRelativeToSurfaceXY(),
|
||||
new Blockly.utils.Coordinate(66, 69),
|
||||
);
|
||||
});
|
||||
|
||||
test('pasted blocks are bumped to be outside the connection snap radius', function () {
|
||||
Blockly.serialization.workspaces.load(
|
||||
{
|
||||
'blocks': {
|
||||
'languageVersion': 0,
|
||||
'blocks': [
|
||||
{
|
||||
'type': 'controls_if',
|
||||
'id': 'sourceBlockId',
|
||||
'x': 38,
|
||||
'y': 13,
|
||||
},
|
||||
{
|
||||
'type': 'logic_compare',
|
||||
'x': 113,
|
||||
'y': 63,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
this.workspace,
|
||||
);
|
||||
this.clock.runAll(); // Update the connection DB.
|
||||
const data = this.workspace.getBlockById('sourceBlockId').toCopyData();
|
||||
|
||||
const newBlock = Blockly.clipboard.paste(data, this.workspace);
|
||||
chai.assert.deepEqual(
|
||||
newBlock.getRelativeToSurfaceXY(),
|
||||
new Blockly.utils.Coordinate(94, 125),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
suite('pasting comments', 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>',
|
||||
),
|
||||
this.workspace,
|
||||
);
|
||||
const comment = this.workspace.getTopComments(false)[0];
|
||||
const data = comment.toCopyData();
|
||||
|
||||
const newComment = Blockly.clipboard.paste(data, this.workspace);
|
||||
chai.assert.deepEqual(
|
||||
newComment.getRelativeToSurfaceXY(),
|
||||
new Blockly.utils.Coordinate(60, 60),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -419,13 +419,13 @@ suite('Context Menu Items', function () {
|
||||
);
|
||||
});
|
||||
|
||||
test('Calls duplicate', function () {
|
||||
const spy = sinon.spy(Blockly.clipboard.TEST_ONLY, 'duplicateInternal');
|
||||
|
||||
test('the block is duplicated', function () {
|
||||
this.duplicateOption.callback(this.scope);
|
||||
|
||||
sinon.assert.calledOnce(spy);
|
||||
sinon.assert.calledWith(spy, this.block);
|
||||
chai.assert.equal(
|
||||
this.workspace.getTopBlocks(false).length,
|
||||
2,
|
||||
'Expected a second block',
|
||||
);
|
||||
});
|
||||
|
||||
test('Has correct label', function () {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -26,10 +26,13 @@ suite('Key Down', function () {
|
||||
/**
|
||||
* Creates a block and sets it as Blockly.selected.
|
||||
* @param {Blockly.Workspace} workspace The workspace to create a new block on.
|
||||
* @return {Blockly.Block} The block being selected.
|
||||
*/
|
||||
function setSelectedBlock(workspace) {
|
||||
defineStackBlock();
|
||||
Blockly.common.setSelected(workspace.newBlock('stack_block'));
|
||||
const block = workspace.newBlock('stack_block');
|
||||
Blockly.common.setSelected(block);
|
||||
return block;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -109,8 +112,8 @@ suite('Key Down', function () {
|
||||
|
||||
suite('Copy', function () {
|
||||
setup(function () {
|
||||
setSelectedBlock(this.workspace);
|
||||
this.copySpy = sinon.spy(Blockly.clipboard.TEST_ONLY, 'copyInternal');
|
||||
this.block = setSelectedBlock(this.workspace);
|
||||
this.copySpy = sinon.spy(this.block, 'toCopyData');
|
||||
this.hideChaffSpy = sinon.spy(
|
||||
Blockly.WorkspaceSvg.prototype,
|
||||
'hideChaff',
|
||||
|
||||
@@ -149,13 +149,10 @@ export function assertEventFired(
|
||||
expectedWorkspaceId,
|
||||
expectedBlockId,
|
||||
) {
|
||||
expectedProperties = Object.assign(
|
||||
{
|
||||
workspaceId: expectedWorkspaceId,
|
||||
blockId: expectedBlockId,
|
||||
},
|
||||
expectedProperties,
|
||||
);
|
||||
const baseProps = {};
|
||||
if (expectedWorkspaceId) baseProps.workspaceId = expectedWorkspaceId;
|
||||
if (expectedBlockId) baseProps.blockId = expectedBlockId;
|
||||
expectedProperties = Object.assign(baseProps, expectedProperties);
|
||||
const expectedEvent = sinon.match
|
||||
.instanceOf(instanceType)
|
||||
.and(sinon.match(expectedProperties));
|
||||
|
||||
Reference in New Issue
Block a user