Skip to content

Commit fdffd65

Browse files
authored
fix: Make cut/copy/paste work consistently and as expected (#9107)
* Work on cut/copy/paste preconditions * Cleanup and fixes to cut/copy/paste * Fix tests * Remove editable check from isCopyable and isCuttable
1 parent 3ccfba9 commit fdffd65

File tree

2 files changed

+82
-28
lines changed

2 files changed

+82
-28
lines changed

core/shortcut_items.ts

Lines changed: 75 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,7 @@ let copyWorkspace: WorkspaceSvg | null = null;
104104
let copyCoords: Coordinate | null = null;
105105

106106
/**
107-
* Determine if a focusable node can be copied using cut or copy.
107+
* Determine if a focusable node can be copied.
108108
*
109109
* Unfortunately the ICopyable interface doesn't include an isCopyable
110110
* method, so we must use some other criteria to make the decision.
@@ -113,8 +113,8 @@ let copyCoords: Coordinate | null = null;
113113
* - It must be an ICopyable.
114114
* - So that a pasted copy can be manipluated and/or disposed of, it
115115
* must be both an IDraggable and an IDeletable.
116-
* - Additionally, both .isMovable() and .isDeletable() must return
117-
* true (i.e., can currently be moved and deleted).
116+
* - Additionally, both .isOwnMovable() and .isOwnDeletable() must return
117+
* true (i.e., the copy could be moved and deleted).
118118
*
119119
* TODO(#9098): Revise these criteria. The latter criteria prevents
120120
* shadow blocks from being copied; additionally, there are likely to
@@ -127,6 +127,40 @@ let copyCoords: Coordinate | null = null;
127127
function isCopyable(
128128
focused: IFocusableNode,
129129
): focused is ICopyable<ICopyData> & IDeletable & IDraggable {
130+
if (!(focused instanceof BlockSvg)) return false;
131+
return (
132+
isICopyable(focused) &&
133+
isIDeletable(focused) &&
134+
focused.isOwnDeletable() &&
135+
isDraggable(focused) &&
136+
focused.isOwnMovable()
137+
);
138+
}
139+
140+
/**
141+
* Determine if a focusable node can be cut.
142+
*
143+
* Unfortunately the ICopyable interface doesn't include an isCuttable
144+
* method, so we must use some other criteria to make the decision.
145+
* Specifically,
146+
*
147+
* - It must be an ICopyable.
148+
* - So that a pasted copy can be manipluated and/or disposed of, it
149+
* must be both an IDraggable and an IDeletable.
150+
* - Additionally, both .isMovable() and .isDeletable() must return
151+
* true (i.e., can currently be moved and deleted). This is the main
152+
* difference with isCopyable.
153+
*
154+
* TODO(#9098): Revise these criteria. The latter criteria prevents
155+
* shadow blocks from being copied; additionally, there are likely to
156+
* be other circumstances were it is desirable to allow movable /
157+
* copyable copies of a currently-unmovable / -copyable block to be
158+
* made.
159+
*
160+
* @param focused The focused object.
161+
*/
162+
function isCuttable(focused: IFocusableNode): boolean {
163+
if (!(focused instanceof BlockSvg)) return false;
130164
return (
131165
isICopyable(focused) &&
132166
isIDeletable(focused) &&
@@ -151,29 +185,45 @@ export function registerCopy() {
151185
name: names.COPY,
152186
preconditionFn(workspace, scope) {
153187
const focused = scope.focusedNode;
188+
if (!(focused instanceof BlockSvg)) return false;
189+
190+
const targetWorkspace = workspace.isFlyout
191+
? workspace.targetWorkspace
192+
: workspace;
154193
return (
155-
!workspace.isReadOnly() &&
156-
!Gesture.inProgress() &&
157194
!!focused &&
158-
isCopyable(focused) &&
159-
!getFocusManager().ephemeralFocusTaken()
195+
!!targetWorkspace &&
196+
!targetWorkspace.isReadOnly() &&
197+
!targetWorkspace.isDragging() &&
198+
!getFocusManager().ephemeralFocusTaken() &&
199+
isCopyable(focused)
160200
);
161201
},
162202
callback(workspace, e, shortcut, scope) {
163203
// Prevent the default copy behavior, which may beep or otherwise indicate
164204
// an error due to the lack of a selection.
165205
e.preventDefault();
166-
workspace.hideChaff();
206+
167207
const focused = scope.focusedNode;
168-
if (!focused || !isICopyable(focused)) return false;
169-
copyData = focused.toCopyData();
170-
copyWorkspace =
208+
if (!focused || !isCopyable(focused)) return false;
209+
let targetWorkspace: WorkspaceSvg | null =
171210
focused.workspace instanceof WorkspaceSvg
172211
? focused.workspace
173212
: workspace;
174-
copyCoords = isDraggable(focused)
175-
? focused.getRelativeToSurfaceXY()
176-
: null;
213+
targetWorkspace = targetWorkspace.isFlyout
214+
? targetWorkspace.targetWorkspace
215+
: targetWorkspace;
216+
if (!targetWorkspace) return false;
217+
218+
if (!focused.workspace.isFlyout) {
219+
targetWorkspace.hideChaff();
220+
}
221+
copyData = focused.toCopyData();
222+
copyWorkspace = targetWorkspace;
223+
copyCoords =
224+
isDraggable(focused) && focused.workspace == targetWorkspace
225+
? focused.getRelativeToSurfaceXY()
226+
: null;
177227
return !!copyData;
178228
},
179229
keyCodes: [ctrlC, metaC],
@@ -197,14 +247,11 @@ export function registerCut() {
197247
preconditionFn(workspace, scope) {
198248
const focused = scope.focusedNode;
199249
return (
200-
!workspace.isReadOnly() &&
201-
!Gesture.inProgress() &&
202250
!!focused &&
203-
isCopyable(focused) &&
204-
// Extra criteria for cut (not just copy):
205-
!focused.workspace.isFlyout &&
206-
focused.isDeletable() &&
207-
!getFocusManager().ephemeralFocusTaken()
251+
!workspace.isReadOnly() &&
252+
!workspace.isDragging() &&
253+
!getFocusManager().ephemeralFocusTaken() &&
254+
isCuttable(focused)
208255
);
209256
},
210257
callback(workspace, e, shortcut, scope) {
@@ -251,9 +298,14 @@ export function registerPaste() {
251298
const pasteShortcut: KeyboardShortcut = {
252299
name: names.PASTE,
253300
preconditionFn(workspace) {
301+
const targetWorkspace = workspace.isFlyout
302+
? workspace.targetWorkspace
303+
: workspace;
254304
return (
255-
!workspace.isReadOnly() &&
256-
!Gesture.inProgress() &&
305+
!!copyData &&
306+
!!targetWorkspace &&
307+
!targetWorkspace.isReadOnly() &&
308+
!targetWorkspace.isDragging() &&
257309
!getFocusManager().ephemeralFocusTaken()
258310
);
259311
},

tests/mocha/shortcut_items_test.js

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -181,13 +181,13 @@ suite('Keyboard Shortcut Items', function () {
181181
runReadOnlyTest(keyEvent, testCaseName);
182182
});
183183
});
184-
// Do not copy a block if a gesture is in progress.
185-
suite('Gesture in progress', function () {
184+
// Do not copy a block if a drag is in progress.
185+
suite('Drag in progress', function () {
186186
testCases.forEach(function (testCase) {
187187
const testCaseName = testCase[0];
188188
const keyEvent = testCase[1];
189189
test(testCaseName, function () {
190-
sinon.stub(Blockly.Gesture, 'inProgress').returns(true);
190+
sinon.stub(this.workspace, 'isDragging').returns(true);
191191
this.injectionDiv.dispatchEvent(keyEvent);
192192
sinon.assert.notCalled(this.copySpy);
193193
sinon.assert.notCalled(this.hideChaffSpy);
@@ -201,7 +201,7 @@ suite('Keyboard Shortcut Items', function () {
201201
const keyEvent = testCase[1];
202202
test(testCaseName, function () {
203203
sinon
204-
.stub(Blockly.common.getSelected(), 'isDeletable')
204+
.stub(Blockly.common.getSelected(), 'isOwnDeletable')
205205
.returns(false);
206206
this.injectionDiv.dispatchEvent(keyEvent);
207207
sinon.assert.notCalled(this.copySpy);
@@ -215,7 +215,9 @@ suite('Keyboard Shortcut Items', function () {
215215
const testCaseName = testCase[0];
216216
const keyEvent = testCase[1];
217217
test(testCaseName, function () {
218-
sinon.stub(Blockly.common.getSelected(), 'isMovable').returns(false);
218+
sinon
219+
.stub(Blockly.common.getSelected(), 'isOwnMovable')
220+
.returns(false);
219221
this.injectionDiv.dispatchEvent(keyEvent);
220222
sinon.assert.notCalled(this.copySpy);
221223
sinon.assert.notCalled(this.hideChaffSpy);

0 commit comments

Comments
 (0)