Skip to content

Commit 48e25e0

Browse files
authored
Add new animation editor keyboard shortcuts (#10348)
* add new animation editor keyboard shortcuts * pr feedback
1 parent 67400a1 commit 48e25e0

File tree

5 files changed

+202
-56
lines changed

5 files changed

+202
-56
lines changed

docs/asset-editor-shortcuts.md

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ These shortcuts allow you to quickly switch between the tools in the editor.
1515
| **u** | Rectangle tool |
1616
| **c** | Circle tool |
1717
| **m** | Marquee tool |
18-
| **h** | Pan tool |
18+
| **q** | Pan tool |
1919
| **space** | Temporarily enter pan mode (release space to return to previous tool) |
2020
| **alt** | Temporarily enter eyedropper mode (release alt to return to previous tool)
2121

@@ -40,16 +40,17 @@ These shortcuts are used to perform advanced edit operations on sprites or tilem
4040

4141
Each of these shortcuts are affected by the marquee tool.
4242
If a portion of the asset is selected by the marquee tool, then the shortcut transformation will only apply to the selected area.
43+
If editing an animation, add the **shift** key to the shortcut to affect all frames at once.
4344

4445
| Shortcut | Description |
4546
| -------------- | ----------- |
46-
| **shift + h** | Flip horizontally |
47-
| **shift + v** | Flip vertically |
48-
| **]** | Rotate clockwise |
49-
| **[** | Rotate counterclockwise |
5047
| **Arrow Key** | Move marquee tool selection by one pixel |
51-
| **shift + r** | Replace all instances of selected background color/tile with selected foreground color/tile |
5248
| **backspace** | Delete current marquee tool selection |
49+
| **h** | Flip horizontally |
50+
| **v** | Flip vertically |
51+
| **]** | Rotate clockwise |
52+
| **[** | Rotate counterclockwise |
53+
| **r** | Replace all instances of selected background color/tile with selected foreground color/tile |
5354

5455

5556
## Image/Animation editor-only shortcuts
@@ -60,4 +61,6 @@ These shortcuts are only available in the image and animation editors (not the t
6061
| ---------------------------------- | ----------- |
6162
| **shift + 1-9** or **shift + a-f** | Outline the current image with the color in the palette corresponding to the selected number. For example, **shift + f** will outline with color number 15 (black) |
6263
| **0-9** | Select a foreground color from the palette (first ten colors only) |
64+
| **.** | Advance forwards one frame in the current animation |
65+
| **,** | Advance backwards one frame in the current animation |
6366

webapp/src/components/ImageEditor/ImageCanvas.tsx

Lines changed: 0 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -291,34 +291,6 @@ export class ImageCanvasImpl extends React.Component<ImageCanvasProps, {}> imple
291291

292292
this.hasInteracted = true;
293293

294-
if (this.shouldHandleCanvasShortcut() && this.editState?.floating?.image) {
295-
let moved = false;
296-
297-
switch (ev.key) {
298-
case 'ArrowLeft':
299-
this.editState.layerOffsetX = Math.max(this.editState.layerOffsetX - 1, -this.editState.floating.image.width);
300-
moved = true;
301-
break;
302-
case 'ArrowUp':
303-
this.editState.layerOffsetY = Math.max(this.editState.layerOffsetY - 1, -this.editState.floating.image.height);
304-
moved = true;
305-
break;
306-
case 'ArrowRight':
307-
this.editState.layerOffsetX = Math.min(this.editState.layerOffsetX + 1, this.editState.width);
308-
moved = true;
309-
break;
310-
case 'ArrowDown':
311-
this.editState.layerOffsetY = Math.min(this.editState.layerOffsetY + 1, this.editState.height);
312-
moved = true;
313-
break;
314-
}
315-
316-
if (moved) {
317-
this.props.dispatchImageEdit(this.editState.toImageState());
318-
ev.preventDefault();
319-
}
320-
}
321-
322294
if (!ev.repeat) {
323295
// prevent blockly's ctrl+c / ctrl+v handler
324296
if ((ev.ctrlKey || ev.metaKey) && (ev.key === 'c' || ev.key === 'v')) {
@@ -336,11 +308,6 @@ export class ImageCanvasImpl extends React.Component<ImageCanvasProps, {}> imple
336308
ev.preventDefault();
337309
}
338310

339-
if ((ev.key === "Backspace" || ev.key === "Delete") && this.editState?.floating?.image && this.shouldHandleCanvasShortcut()) {
340-
this.deleteSelection();
341-
ev.preventDefault();
342-
}
343-
344311
// hotkeys for switching temporarily between tools
345312
this.lastTool = this.props.tool;
346313
switch (ev.keyCode) {
@@ -983,11 +950,6 @@ export class ImageCanvasImpl extends React.Component<ImageCanvasProps, {}> imple
983950
this.props.dispatchImageEdit(this.editState.toImageState());
984951
}
985952

986-
protected deleteSelection() {
987-
this.editState.floating = null;
988-
this.props.dispatchImageEdit(this.editState.toImageState());
989-
}
990-
991953
protected cloneCanvasStyle(base: HTMLCanvasElement, target: HTMLCanvasElement) {
992954
target.style.position = base.style.position;
993955
target.style.width = base.style.width;

webapp/src/components/ImageEditor/actions/dispatch.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,4 +43,4 @@ export const dispatchDisableResize = () => ({ type: actions.DISABLE_RESIZE })
4343
export const dispatchChangeAssetName = (name: string) => ({ type: actions.CHANGE_ASSET_NAME, name });
4444

4545
export const dispatchOpenAsset = (asset: pxt.Asset, keepPast: boolean, gallery?: GalleryTile[]) => ({ type: actions.OPEN_ASSET, asset, keepPast, gallery })
46-
export const dispatchSetFrames = (frames: pxt.sprite.ImageState[]) => ({ type: actions.SET_FRAMES, frames });
46+
export const dispatchSetFrames = (frames: pxt.sprite.ImageState[], currentFrame?: number) => ({ type: actions.SET_FRAMES, frames, currentFrame });

webapp/src/components/ImageEditor/keyboardShortcuts.ts

Lines changed: 191 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11

22
import { Store } from 'redux';
33
import { ImageEditorTool, ImageEditorStore, TilemapState, AnimationState, CursorSize } from './store/imageReducer';
4-
import { dispatchChangeZoom, dispatchUndoImageEdit, dispatchRedoImageEdit, dispatchChangeImageTool, dispatchSwapBackgroundForeground, dispatchChangeSelectedColor, dispatchImageEdit, dispatchChangeCursorSize} from './actions/dispatch';
4+
import { dispatchChangeZoom, dispatchUndoImageEdit, dispatchRedoImageEdit, dispatchChangeImageTool, dispatchSwapBackgroundForeground, dispatchChangeSelectedColor, dispatchImageEdit, dispatchChangeCursorSize, dispatchChangeCurrentFrame, dispatchSetFrames} from './actions/dispatch';
55
import { mainStore } from './store/imageStore';
66
import { EditState, flipEdit, getEditState, outlineEdit, replaceColorEdit, rotateEdit } from './toolDefinitions';
77
let store = mainStore;
@@ -60,6 +60,7 @@ function handleUndoRedo(event: KeyboardEvent) {
6060

6161
function overrideBlocklyShortcuts(event: KeyboardEvent) {
6262
if (event.key === "Backspace" || event.key === "Delete") {
63+
handleKeyDown(event);
6364
event.stopPropagation();
6465
}
6566
}
@@ -78,7 +79,7 @@ function handleKeyDown(event: KeyboardEvent) {
7879
case "e":
7980
setTool(ImageEditorTool.Erase);
8081
break;
81-
case "h":
82+
case "q":
8283
setTool(ImageEditorTool.Pan);
8384
break;
8485
case "b":
@@ -111,34 +112,68 @@ function handleKeyDown(event: KeyboardEvent) {
111112
case "x":
112113
swapForegroundBackground();
113114
break;
114-
case "H":
115+
case "h":
115116
flip(false);
116117
break;
117-
case "V":
118+
case "v":
118119
flip(true);
119120
break;
121+
case "H":
122+
flipAllFrames(false);
123+
break;
124+
case "V":
125+
flipAllFrames(true);
126+
break;
120127
case "[":
121128
rotate(false);
122129
break;
123130
case "]":
124131
rotate(true);
125132
break;
133+
case "{":
134+
rotateAllFrames(false);
135+
break;
136+
case "}":
137+
rotateAllFrames(true);
138+
break;
126139
case ">":
127140
changeCursorSize(true);
128141
break;
129142
case "<":
130143
changeCursorSize(false);
131144
break;
132-
145+
case ".":
146+
advanceFrame(true);
147+
break;
148+
case ",":
149+
advanceFrame(false);
150+
break;
151+
case "r":
152+
doColorReplace();
153+
break;
154+
case "R":
155+
doColorReplaceAllFrames();
156+
break;
157+
case "ArrowLeft":
158+
moveMarqueeSelection(-1, 0, event.shiftKey);
159+
break;
160+
case "ArrowRight":
161+
moveMarqueeSelection(1, 0, event.shiftKey);
162+
break;
163+
case "ArrowUp":
164+
moveMarqueeSelection(0, -1, event.shiftKey);
165+
break;
166+
case "ArrowDown":
167+
moveMarqueeSelection(0, 1, event.shiftKey);
168+
break;
169+
case "Backspace":
170+
case "Delete":
171+
deleteSelection(event.shiftKey);
172+
break;
133173
}
134174

135175
const editorState = store.getState().editor;
136176

137-
if (event.shiftKey && event.code === "KeyR") {
138-
replaceColor(editorState.backgroundColor, editorState.selectedColor);
139-
return;
140-
}
141-
142177
if (!editorState.isTilemap && /^Digit\d$/.test(event.code)) {
143178
const keyAsNum = +event.code.slice(-1);
144179
const color = keyAsNum + (event.shiftKey ? 9 : 0);
@@ -216,12 +251,20 @@ export function flip(vertical: boolean) {
216251
dispatchAction(dispatchImageEdit(flipped.toImageState()));
217252
}
218253

254+
export function flipAllFrames(vertical: boolean) {
255+
editAllFrames(() => flip(vertical), editState => flipEdit(editState, vertical, false));
256+
}
257+
219258
export function rotate(clockwise: boolean) {
220259
const [ editState, type ] = currentEditState();
221260
const rotated = rotateEdit(editState, clockwise, type === "tilemap", type === "animation");
222261
dispatchAction(dispatchImageEdit(rotated.toImageState()));
223262
}
224263

264+
export function rotateAllFrames(clockwise: boolean) {
265+
editAllFrames(() => rotate(clockwise), editState => rotateEdit(editState, clockwise, false, true));
266+
}
267+
225268
export function outline(color: number) {
226269
const [ editState, type ] = currentEditState();
227270

@@ -235,4 +278,142 @@ export function replaceColor(fromColor: number, toColor: number) {
235278
const [ editState, type ] = currentEditState();
236279
const replaced = replaceColorEdit(editState, fromColor, toColor);
237280
dispatchAction(dispatchImageEdit(replaced.toImageState()));
281+
}
282+
283+
function doColorReplace() {
284+
const state = store.getState();
285+
286+
const fromColor = state.editor.backgroundColor;
287+
const toColor = state.editor.selectedColor;
288+
289+
const [ editState ] = currentEditState();
290+
const replaced = replaceColorEdit(editState, fromColor, toColor);
291+
dispatchAction(dispatchImageEdit(replaced.toImageState()));
292+
}
293+
294+
function doColorReplaceAllFrames() {
295+
const state = store.getState();
296+
297+
const fromColor = state.editor.backgroundColor;
298+
const toColor = state.editor.selectedColor;
299+
300+
editAllFrames(doColorReplace, editState => replaceColorEdit(editState, fromColor, toColor))
301+
}
302+
303+
export function advanceFrame(forwards: boolean) {
304+
const state = store.getState();
305+
306+
if (state.editor.isTilemap) return;
307+
308+
const present = state.store.present as AnimationState;
309+
310+
if (present.frames.length <= 1) return;
311+
312+
let nextFrame: number;
313+
if (forwards) {
314+
nextFrame = (present.currentFrame + 1) % present.frames.length;
315+
}
316+
else {
317+
nextFrame = (present.currentFrame + present.frames.length - 1) % present.frames.length;
318+
}
319+
320+
dispatchAction(dispatchChangeCurrentFrame(nextFrame));
321+
}
322+
323+
function editAllFrames(singleFrameShortcut: () => void, doEdit: (editState: EditState) => EditState) {
324+
const state = store.getState();
325+
326+
if (state.editor.isTilemap) {
327+
singleFrameShortcut();
328+
return;
329+
}
330+
331+
const present = state.store.present as AnimationState;
332+
333+
if (present.frames.length === 1) {
334+
singleFrameShortcut();
335+
return;
336+
}
337+
338+
const current = present.frames[present.currentFrame];
339+
340+
// if the current frame has a marquee selection, apply that selection
341+
// to all frames
342+
const hasFloatingLayer = !!current.floating;
343+
const layerWidth = current.floating?.bitmap.width;
344+
const layerHeight = current.floating?.bitmap.height;
345+
const layerX = current.layerOffsetX;
346+
const layerY = current.layerOffsetY;
347+
348+
const newFrames: pxt.sprite.ImageState[] = [];
349+
350+
for (const frame of present.frames) {
351+
const editState = getEditState(frame, false);
352+
353+
if (hasFloatingLayer) {
354+
if (editState.floating?.image) {
355+
// check the existing floating layer to see if it matches before
356+
// merging down. otherwise non-square rotations might lose
357+
// information if they cause the floating layer to go off the canvas
358+
if (editState.layerOffsetX !== layerX ||
359+
editState.layerOffsetY !== layerY ||
360+
editState.floating.image.width !== layerWidth ||
361+
editState.floating.image.height !== layerHeight
362+
) {
363+
editState.mergeFloatingLayer();
364+
editState.copyToLayer(layerX, layerY, layerWidth, layerHeight, true);
365+
}
366+
}
367+
else {
368+
editState.copyToLayer(layerX, layerY, layerWidth, layerHeight, true);
369+
}
370+
}
371+
else {
372+
editState.mergeFloatingLayer();
373+
}
374+
375+
const edited = doEdit(editState);
376+
newFrames.push(edited.toImageState());
377+
}
378+
379+
dispatchAction(dispatchSetFrames(newFrames, present.currentFrame));
380+
}
381+
382+
function moveMarqueeSelection(dx: number, dy: number, allFrames = false) {
383+
const [ editState ] = currentEditState();
384+
385+
if (!editState.floating?.image) return;
386+
387+
const moveState = (editState: EditState) => {
388+
editState.layerOffsetX += dx;
389+
editState.layerOffsetY += dy;
390+
return editState;
391+
};
392+
393+
394+
if (!allFrames) {
395+
dispatchAction(dispatchImageEdit(moveState(editState).toImageState()));
396+
}
397+
else {
398+
editAllFrames(() => moveMarqueeSelection(dx, dy), moveState);
399+
}
400+
}
401+
402+
function deleteSelection(allFrames = false) {
403+
const [ editState ] = currentEditState();
404+
405+
if (!editState.floating?.image) return;
406+
407+
const deleteFloatingLayer = (editState: EditState) => {
408+
editState.floating = null;
409+
return editState;
410+
};
411+
412+
413+
if (!allFrames) {
414+
dispatchAction(dispatchImageEdit(deleteFloatingLayer(editState).toImageState()));
415+
}
416+
else {
417+
editAllFrames(() => deleteSelection(), deleteFloatingLayer);
418+
}
238419
}

webapp/src/components/ImageEditor/store/imageReducer.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -432,7 +432,7 @@ const animationReducer = (state: AnimationState, action: any): AnimationState =>
432432
return {
433433
...state,
434434
frames: action.frames,
435-
currentFrame: 0
435+
currentFrame: action.currentFrame || 0
436436
};
437437
default:
438438
return state;

0 commit comments

Comments
 (0)