Skip to content

Commit

Permalink
add new animation editor keyboard shortcuts
Browse files Browse the repository at this point in the history
  • Loading branch information
riknoll committed Jan 23, 2025
1 parent 67400a1 commit 99aad67
Show file tree
Hide file tree
Showing 5 changed files with 202 additions and 56 deletions.
15 changes: 9 additions & 6 deletions docs/asset-editor-shortcuts.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ These shortcuts allow you to quickly switch between the tools in the editor.
| **u** | Rectangle tool |
| **c** | Circle tool |
| **m** | Marquee tool |
| **h** | Pan tool |
| **q** | Pan tool |
| **space** | Temporarily enter pan mode (release space to return to previous tool) |
| **alt** | Temporarily enter eyedropper mode (release alt to return to previous tool)

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

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

| Shortcut | Description |
| -------------- | ----------- |
| **shift + h** | Flip horizontally |
| **shift + v** | Flip vertically |
| **]** | Rotate clockwise |
| **[** | Rotate counterclockwise |
| **Arrow Key** | Move marquee tool selection by one pixel |
| **shift + r** | Replace all instances of selected background color/tile with selected foreground color/tile |
| **backspace** | Delete current marquee tool selection |
| **h** | Flip horizontally |
| **v** | Flip vertically |
| **]** | Rotate clockwise |
| **[** | Rotate counterclockwise |
| **r** | Replace all instances of selected background color/tile with selected foreground color/tile |


## Image/Animation editor-only shortcuts
Expand All @@ -60,4 +61,6 @@ These shortcuts are only available in the image and animation editors (not the t
| ---------------------------------- | ----------- |
| **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) |
| **0-9** | Select a foreground color from the palette (first ten colors only) |
| **.** | Advance forwards one frame in the current animation |
| **,** | Advance backwards one frame in the current animation |

38 changes: 0 additions & 38 deletions webapp/src/components/ImageEditor/ImageCanvas.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -291,34 +291,6 @@ export class ImageCanvasImpl extends React.Component<ImageCanvasProps, {}> imple

this.hasInteracted = true;

if (this.shouldHandleCanvasShortcut() && this.editState?.floating?.image) {
let moved = false;

switch (ev.key) {
case 'ArrowLeft':
this.editState.layerOffsetX = Math.max(this.editState.layerOffsetX - 1, -this.editState.floating.image.width);
moved = true;
break;
case 'ArrowUp':
this.editState.layerOffsetY = Math.max(this.editState.layerOffsetY - 1, -this.editState.floating.image.height);
moved = true;
break;
case 'ArrowRight':
this.editState.layerOffsetX = Math.min(this.editState.layerOffsetX + 1, this.editState.width);
moved = true;
break;
case 'ArrowDown':
this.editState.layerOffsetY = Math.min(this.editState.layerOffsetY + 1, this.editState.height);
moved = true;
break;
}

if (moved) {
this.props.dispatchImageEdit(this.editState.toImageState());
ev.preventDefault();
}
}

if (!ev.repeat) {
// prevent blockly's ctrl+c / ctrl+v handler
if ((ev.ctrlKey || ev.metaKey) && (ev.key === 'c' || ev.key === 'v')) {
Expand All @@ -336,11 +308,6 @@ export class ImageCanvasImpl extends React.Component<ImageCanvasProps, {}> imple
ev.preventDefault();
}

if ((ev.key === "Backspace" || ev.key === "Delete") && this.editState?.floating?.image && this.shouldHandleCanvasShortcut()) {
this.deleteSelection();
ev.preventDefault();
}

// hotkeys for switching temporarily between tools
this.lastTool = this.props.tool;
switch (ev.keyCode) {
Expand Down Expand Up @@ -983,11 +950,6 @@ export class ImageCanvasImpl extends React.Component<ImageCanvasProps, {}> imple
this.props.dispatchImageEdit(this.editState.toImageState());
}

protected deleteSelection() {
this.editState.floating = null;
this.props.dispatchImageEdit(this.editState.toImageState());
}

protected cloneCanvasStyle(base: HTMLCanvasElement, target: HTMLCanvasElement) {
target.style.position = base.style.position;
target.style.width = base.style.width;
Expand Down
2 changes: 1 addition & 1 deletion webapp/src/components/ImageEditor/actions/dispatch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,4 +43,4 @@ export const dispatchDisableResize = () => ({ type: actions.DISABLE_RESIZE })
export const dispatchChangeAssetName = (name: string) => ({ type: actions.CHANGE_ASSET_NAME, name });

export const dispatchOpenAsset = (asset: pxt.Asset, keepPast: boolean, gallery?: GalleryTile[]) => ({ type: actions.OPEN_ASSET, asset, keepPast, gallery })
export const dispatchSetFrames = (frames: pxt.sprite.ImageState[]) => ({ type: actions.SET_FRAMES, frames });
export const dispatchSetFrames = (frames: pxt.sprite.ImageState[], currentFrame?: number) => ({ type: actions.SET_FRAMES, frames, currentFrame });
201 changes: 191 additions & 10 deletions webapp/src/components/ImageEditor/keyboardShortcuts.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@

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

function overrideBlocklyShortcuts(event: KeyboardEvent) {
if (event.key === "Backspace" || event.key === "Delete") {
handleKeyDown(event);
event.stopPropagation();
}
}
Expand All @@ -78,7 +79,7 @@ function handleKeyDown(event: KeyboardEvent) {
case "e":
setTool(ImageEditorTool.Erase);
break;
case "h":
case "q":
setTool(ImageEditorTool.Pan);
break;
case "b":
Expand Down Expand Up @@ -111,34 +112,68 @@ function handleKeyDown(event: KeyboardEvent) {
case "x":
swapForegroundBackground();
break;
case "H":
case "h":
flip(false);
break;
case "V":
case "v":
flip(true);
break;
case "H":
flipAllFrames(false);
break;
case "V":
flipAllFrames(true);
break;
case "[":
rotate(false);
break;
case "]":
rotate(true);
break;
case "{":
rotateAllFrames(false);
break;
case "}":
rotateAllFrames(true);
break;
case ">":
changeCursorSize(true);
break;
case "<":
changeCursorSize(false);
break;

case ".":
advanceFrame(true);
break;
case ",":
advanceFrame(false);
break;
case "r":
doReplace();
break;
case "R":
doReplaceAllFrames();
break;
case "ArrowLeft":
moveMarqueeSelection(-1, 0, event.shiftKey);
break;
case "ArrowRight":
moveMarqueeSelection(1, 0, event.shiftKey);
break;
case "ArrowUp":
moveMarqueeSelection(0, -1, event.shiftKey);
break;
case "ArrowDown":
moveMarqueeSelection(0, 1, event.shiftKey);
break;
case "Backspace":
case "Delete":
deleteSelection(event.shiftKey);
break;
}

const editorState = store.getState().editor;

if (event.shiftKey && event.code === "KeyR") {
replaceColor(editorState.backgroundColor, editorState.selectedColor);
return;
}

if (!editorState.isTilemap && /^Digit\d$/.test(event.code)) {
const keyAsNum = +event.code.slice(-1);
const color = keyAsNum + (event.shiftKey ? 9 : 0);
Expand Down Expand Up @@ -216,12 +251,20 @@ export function flip(vertical: boolean) {
dispatchAction(dispatchImageEdit(flipped.toImageState()));
}

export function flipAllFrames(vertical: boolean) {
editAllFrames(() => flip(vertical), editState => flipEdit(editState, vertical, false));
}

export function rotate(clockwise: boolean) {
const [ editState, type ] = currentEditState();
const rotated = rotateEdit(editState, clockwise, type === "tilemap", type === "animation");
dispatchAction(dispatchImageEdit(rotated.toImageState()));
}

export function rotateAllFrames(clockwise: boolean) {
editAllFrames(() => rotate(clockwise), editState => rotateEdit(editState, clockwise, false, true));
}

export function outline(color: number) {
const [ editState, type ] = currentEditState();

Expand All @@ -235,4 +278,142 @@ export function replaceColor(fromColor: number, toColor: number) {
const [ editState, type ] = currentEditState();
const replaced = replaceColorEdit(editState, fromColor, toColor);
dispatchAction(dispatchImageEdit(replaced.toImageState()));
}

function doReplace() {
const state = store.getState();

const fromColor = state.editor.backgroundColor;
const toColor = state.editor.selectedColor;

const [ editState ] = currentEditState();
const replaced = replaceColorEdit(editState, fromColor, toColor);
dispatchAction(dispatchImageEdit(replaced.toImageState()));
}

function doReplaceAllFrames() {
const state = store.getState();

const fromColor = state.editor.backgroundColor;
const toColor = state.editor.selectedColor;

editAllFrames(doReplace, editState => replaceColorEdit(editState, fromColor, toColor))
}

export function advanceFrame(forwards: boolean) {
const state = store.getState();

if (state.editor.isTilemap) return;

const present = state.store.present as AnimationState;

if (present.frames.length <= 1) return;

let nextFrame: number;
if (forwards) {
nextFrame = (present.currentFrame + 1) % present.frames.length;
}
else {
nextFrame = (present.currentFrame + present.frames.length - 1) % present.frames.length;
}

dispatchAction(dispatchChangeCurrentFrame(nextFrame));
}

function editAllFrames(singleFrameShortcut: () => void, doEdit: (editState: EditState) => EditState) {
const state = store.getState();

if (state.editor.isTilemap) {
singleFrameShortcut();
return;
}

const present = state.store.present as AnimationState;

if (present.frames.length === 1) {
singleFrameShortcut();
return;
}

const current = present.frames[present.currentFrame];

// if the current frame has a marquee selection, apply that selection
// to all frames
const hasFloatingLayer = !!current.floating;
const layerWidth = current.floating?.bitmap.width;
const layerHeight = current.floating?.bitmap.height;
const layerX = current.layerOffsetX;
const layerY = current.layerOffsetY;

const newFrames: pxt.sprite.ImageState[] = [];

for (const frame of present.frames) {
const editState = getEditState(frame, false);

if (hasFloatingLayer) {
if (editState.floating?.image) {
// check the existing floating layer to see if it matches before
// merging down. otherwise non-square rotations might lose
// information if they cause the floating layer to go off the canvas
if (editState.layerOffsetX !== layerX ||
editState.layerOffsetY !== layerY ||
editState.floating.image.width !== layerWidth ||
editState.floating.image.height !== layerHeight
) {
editState.mergeFloatingLayer();
editState.copyToLayer(layerX, layerY, layerWidth, layerHeight, true);
}
}
else {
editState.copyToLayer(layerX, layerY, layerWidth, layerHeight, true);
}
}
else {
editState.mergeFloatingLayer();
}

const edited = doEdit(editState);
newFrames.push(edited.toImageState());
}

dispatchAction(dispatchSetFrames(newFrames, present.currentFrame));
}

function moveMarqueeSelection(dx: number, dy: number, allFrames = false) {
const [ editState ] = currentEditState();

if (!editState.floating?.image) return;

const moveState = (editState: EditState) => {
editState.layerOffsetX += dx;
editState.layerOffsetY += dy;
return editState;
};


if (!allFrames) {
dispatchAction(dispatchImageEdit(moveState(editState).toImageState()));
}
else {
editAllFrames(() => moveMarqueeSelection(dx, dy), moveState);
}
}

function deleteSelection(allFrames = false) {
const [ editState ] = currentEditState();

if (!editState.floating?.image) return;

const deleteFloatingLayer = (editState: EditState) => {
editState.floating = null;
return editState;
};


if (!allFrames) {
dispatchAction(dispatchImageEdit(deleteFloatingLayer(editState).toImageState()));
}
else {
editAllFrames(() => deleteSelection(), deleteFloatingLayer);
}
}
2 changes: 1 addition & 1 deletion webapp/src/components/ImageEditor/store/imageReducer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -432,7 +432,7 @@ const animationReducer = (state: AnimationState, action: any): AnimationState =>
return {
...state,
frames: action.frames,
currentFrame: 0
currentFrame: action.currentFrame || 0
};
default:
return state;
Expand Down

0 comments on commit 99aad67

Please sign in to comment.