diff --git a/docs/asset-editor-shortcuts.md b/docs/asset-editor-shortcuts.md index 43682bee5674..141157af5f1a 100644 --- a/docs/asset-editor-shortcuts.md +++ b/docs/asset-editor-shortcuts.md @@ -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) @@ -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 @@ -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 | diff --git a/webapp/src/components/ImageEditor/ImageCanvas.tsx b/webapp/src/components/ImageEditor/ImageCanvas.tsx index 9b200c39ad34..7b8bee1ea193 100644 --- a/webapp/src/components/ImageEditor/ImageCanvas.tsx +++ b/webapp/src/components/ImageEditor/ImageCanvas.tsx @@ -291,34 +291,6 @@ export class ImageCanvasImpl extends React.Component 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')) { @@ -336,11 +308,6 @@ export class ImageCanvasImpl extends React.Component 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) { @@ -983,11 +950,6 @@ export class ImageCanvasImpl extends React.Component 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; diff --git a/webapp/src/components/ImageEditor/actions/dispatch.ts b/webapp/src/components/ImageEditor/actions/dispatch.ts index a4d8e70f2ab3..f7a0bc195574 100644 --- a/webapp/src/components/ImageEditor/actions/dispatch.ts +++ b/webapp/src/components/ImageEditor/actions/dispatch.ts @@ -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 }); \ No newline at end of file +export const dispatchSetFrames = (frames: pxt.sprite.ImageState[], currentFrame?: number) => ({ type: actions.SET_FRAMES, frames, currentFrame }); \ No newline at end of file diff --git a/webapp/src/components/ImageEditor/keyboardShortcuts.ts b/webapp/src/components/ImageEditor/keyboardShortcuts.ts index fb34db68ec89..fea1f5783056 100644 --- a/webapp/src/components/ImageEditor/keyboardShortcuts.ts +++ b/webapp/src/components/ImageEditor/keyboardShortcuts.ts @@ -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; @@ -60,6 +60,7 @@ function handleUndoRedo(event: KeyboardEvent) { function overrideBlocklyShortcuts(event: KeyboardEvent) { if (event.key === "Backspace" || event.key === "Delete") { + handleKeyDown(event); event.stopPropagation(); } } @@ -78,7 +79,7 @@ function handleKeyDown(event: KeyboardEvent) { case "e": setTool(ImageEditorTool.Erase); break; - case "h": + case "q": setTool(ImageEditorTool.Pan); break; case "b": @@ -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": + doColorReplace(); + break; + case "R": + doColorReplaceAllFrames(); + 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); @@ -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(); @@ -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 doColorReplace() { + 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 doColorReplaceAllFrames() { + const state = store.getState(); + + const fromColor = state.editor.backgroundColor; + const toColor = state.editor.selectedColor; + + editAllFrames(doColorReplace, 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); + } } \ No newline at end of file diff --git a/webapp/src/components/ImageEditor/store/imageReducer.ts b/webapp/src/components/ImageEditor/store/imageReducer.ts index 97762decc714..7887802faa3a 100644 --- a/webapp/src/components/ImageEditor/store/imageReducer.ts +++ b/webapp/src/components/ImageEditor/store/imageReducer.ts @@ -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;