feat: merge copy paste work into develop

Merge pull request #7379 from google/operation-copy-that
This commit is contained in:
Beka Westberg
2023-08-11 11:47:15 -07:00
committed by GitHub
22 changed files with 566 additions and 107 deletions

View File

@@ -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),
};
}

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';
@@ -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};

View File

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

View 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());

View 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);
}

View 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());

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

@@ -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;

View File

@@ -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',

View File

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

View 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;
}

View File

@@ -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');
}
/**

View File

@@ -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);

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 {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],
};

View File

@@ -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;

View File

@@ -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(),
};
}

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';
@@ -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 {

View 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),
);
});
});
});

View File

@@ -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 () {

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',

View File

@@ -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',

View File

@@ -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));