diff --git a/webapp/src/assetEditor.tsx b/webapp/src/assetEditor.tsx index 6deb11e92326..2af976b1467e 100644 --- a/webapp/src/assetEditor.tsx +++ b/webapp/src/assetEditor.tsx @@ -5,7 +5,7 @@ import * as React from "react"; import * as ReactDOM from "react-dom"; import { ImageFieldEditor } from "./components/ImageFieldEditor"; -import { setTelemetryFunction } from './components/ImageEditor/store/imageReducer'; +import { setTelemetryFunction } from './components/ImageEditor/state'; import { IFrameEmbeddedClient } from "../../pxtservices/iframeEmbeddedClient"; diff --git a/webapp/src/components/ImageEditor/Alert.tsx b/webapp/src/components/ImageEditor/Alert.tsx index d15bb9e858e6..40c4e5d354b9 100644 --- a/webapp/src/components/ImageEditor/Alert.tsx +++ b/webapp/src/components/ImageEditor/Alert.tsx @@ -1,8 +1,5 @@ import * as React from 'react'; -import { connect } from 'react-redux'; -import { ImageEditorStore } from './store/imageReducer'; -import { dispatchHideAlert } from './actions/dispatch'; -import { IconButton } from "./Button"; +import { hideAlert, ImageEditorContext } from './state'; export interface AlertOption { label: string; @@ -15,23 +12,22 @@ export interface AlertInfo { options?: AlertOption[]; } -interface AlertProps extends AlertInfo { - dispatchHideAlert: () => void; -} +export const Alert = (props: AlertInfo) => { + const { title, text, options } = props; + + const { dispatch } = React.useContext(ImageEditorContext); -class AlertImpl extends React.Component { - constructor(props: AlertProps) { - super(props); + const onCloseClick = () => { + dispatch(hideAlert()); } - render() { - const { title, text, options, dispatchHideAlert } = this.props; - return
+ return ( +
{title} - +
{text}
{options &&
@@ -39,16 +35,5 @@ class AlertImpl extends React.Component {
}
- } -} - -function mapStateToProps({ editor }: ImageEditorStore, ownProps: any) { - if (!editor) return {}; - return ownProps; -} - -const mapDispatchToProps = { - dispatchHideAlert -}; - -export const Alert = connect(mapStateToProps, mapDispatchToProps)(AlertImpl); \ No newline at end of file + ); +} \ No newline at end of file diff --git a/webapp/src/components/ImageEditor/BottomBar.tsx b/webapp/src/components/ImageEditor/BottomBar.tsx index 8107117e47c1..32f6e926c256 100644 --- a/webapp/src/components/ImageEditor/BottomBar.tsx +++ b/webapp/src/components/ImageEditor/BottomBar.tsx @@ -1,259 +1,115 @@ import * as React from "react"; -import { connect } from 'react-redux'; -import { ImageEditorStore, AnimationState, TilemapState } from './store/imageReducer'; -import { dispatchChangeImageDimensions, dispatchUndoImageEdit, dispatchRedoImageEdit, dispatchToggleAspectRatioLocked, dispatchChangeZoom, dispatchToggleOnionSkinEnabled, dispatchChangeAssetName } from './actions/dispatch'; import { IconButton } from "./Button"; import { fireClickOnlyOnEnter } from "./util"; import { isNameTaken } from "../../assets"; import { obtainShortcutLock, releaseShortcutLock } from "./keyboardShortcuts"; import { classList } from "../../../../react-common/components/util"; +import { changeAssetName, changeCanvasZoom, changeImageDimensions, ImageEditorContext, redoImageEdit, toggleAspectRatio, toggleOnionSkinEnabled, undoImageEdit, AnimationState, TilemapState } from "./state"; -export interface BottomBarProps { - dispatchChangeImageDimensions: (dimensions: [number, number]) => void; - dispatchChangeZoom: (zoomDelta: number) => void; - imageDimensions: [number, number]; - cursorLocation: [number, number]; - - resizeDisabled: boolean; - hasUndo: boolean; - hasRedo: boolean; - assetName?: string; - - aspectRatioLocked: boolean; - onionSkinEnabled: boolean; - hideAssetName: boolean; - - dispatchUndoImageEdit: () => void; - dispatchRedoImageEdit: () => void; - dispatchToggleAspectRatioLocked: () => void; - dispatchToggleOnionSkinEnabled: () => void; - dispatchChangeAssetName: (name: string) => void; +export interface BottomBarProps { + hideAssetName?: boolean; singleFrame?: boolean; - isTilemap?: boolean; - - onDoneClick?: () => void; hideDoneButton?: boolean; + onDoneClick?: () => void; } -export interface BottomBarState { - width?: string; - height?: string; - assetNameMessage?: string; - assetName?: string; -} +export const BottomBar = (props: BottomBarProps) => { + const { state, dispatch } = React.useContext(ImageEditorContext); -export class BottomBarImpl extends React.Component { - protected shortcutLock: number; - - constructor(props: BottomBarProps) { - super(props); - this.state = {}; - } - - render() { - const { - imageDimensions, - cursorLocation, - hasUndo, - hasRedo, - dispatchUndoImageEdit, - dispatchRedoImageEdit, - aspectRatioLocked, - onionSkinEnabled, - dispatchToggleAspectRatioLocked, - dispatchToggleOnionSkinEnabled, - resizeDisabled, - singleFrame, - onDoneClick, - assetName, - hideDoneButton, - hideAssetName - } = this.props; - - const { assetNameMessage } = this.state; - - const width = this.state.width == null ? imageDimensions[0] : this.state.width; - const height = this.state.height == null ? imageDimensions[1] : this.state.height; - - const assetNameState = this.state.assetName == null ? (assetName || "") : this.state.assetName; - - return ( -
- { !resizeDisabled && -
- + const shortcutLock = React.useRef(0); + const [widthInputValue, setWidthInputValue] = React.useState(); + const [heightInputValue, setHeightInputValue] = React.useState(); + const [assetNameInputValue, setAssetNameInputValue] = React.useState(); + const [assetNameErrorMessage, setAssetNameErrorMessage] = React.useState(); - + const { hideAssetName, singleFrame, hideDoneButton, onDoneClick } = props; + const { resizeDisabled, cursorLocation, isTilemap, onionSkinEnabled } = state.editor; - -
- } - { !singleFrame &&
} - { !singleFrame &&
- -
} - { !resizeDisabled &&
} -
- {cursorLocation && `${cursorLocation[0]}, ${cursorLocation[1]}`} -
-
- {!hideAssetName && - <> - - {assetNameMessage &&
- {assetNameMessage} -
} - - } -
-
- - -
-
-
- - -
- {!hideDoneButton &&
- {lf("Done")} -
} -
- ); - } + const editState = state.store.present; + const { aspectRatioLocked } = editState; + + const bitmap = isTilemap ? + (editState as TilemapState).tilemap.bitmap : + (editState as AnimationState).frames[(editState as AnimationState).currentFrame].bitmap; + + const bitmapWidth = bitmap.width; + const bitmapHeight = bitmap.height; + + const hasUndo = !!(state.store.past.length); + const hasRedo = !!(state.store.future.length); + + const assetName = editState.asset?.meta.displayName; + + const setShortcutsEnabled = React.useCallback((enabled: boolean) => { + if (enabled && shortcutLock.current) { + releaseShortcutLock(shortcutLock.current); + shortcutLock.current = undefined; + } + else if (!enabled && !shortcutLock.current) { + shortcutLock.current = obtainShortcutLock(); + } + }, []); - protected disableShortcutsOnFocus = () => { - this.setShortcutsEnabled(false); - } + const disableShortcutsOnFocus = React.useCallback(() => { + setShortcutsEnabled(false); + }, [setShortcutsEnabled]); - protected handleWidthChange = (event: React.ChangeEvent) => { + const handleWidthChange = React.useCallback((event: React.ChangeEvent) => { const text = event.target.value; const value = parseInt(text); - const { aspectRatioLocked, imageDimensions } = this.props; - if (!isNaN(value) && aspectRatioLocked) { - this.setState({ - width: value + "", - height: Math.floor(value * (imageDimensions[1] / imageDimensions[0])) + "" - }) + setWidthInputValue(value + ""); + setHeightInputValue(Math.floor(value * (bitmapHeight / bitmapWidth)) + ""); } else { - this.setState({ width: text }); + setWidthInputValue(text); } - } + }, [bitmapWidth, bitmapHeight, aspectRatioLocked]); - protected handleHeightChange = (event: React.ChangeEvent) => { + const handleHeightChange = React.useCallback((event: React.ChangeEvent) => { const text = event.target.value; const value = parseInt(text); - const { aspectRatioLocked, imageDimensions } = this.props; - if (!isNaN(value) && aspectRatioLocked) { - this.setState({ - height: value + "", - width: Math.floor(value * (imageDimensions[0] / imageDimensions[1])) + "" - }) + setHeightInputValue(value + ""); + setWidthInputValue(Math.floor(value * (bitmapWidth / bitmapHeight)) + ""); } else { - this.setState({ height: text }); + setHeightInputValue(text); } - } + }, [bitmapWidth, bitmapHeight, aspectRatioLocked]); - protected handleDimensionalBlur = () => { - const { imageDimensions, isTilemap, dispatchChangeImageDimensions } = this.props; - - const widthVal = parseInt(this.state.width); - const heightVal = parseInt(this.state.height); + const handleDimensionalBlur = React.useCallback(() => { + const widthVal = parseInt(widthInputValue); + const heightVal = parseInt(heightInputValue); // tilemaps store in location as 1 byte, so max is 255x255 const maxSize = isTilemap ? 255 : 512; - const width = isNaN(widthVal) ? imageDimensions[0] : Math.min(Math.max(widthVal, 1), maxSize); - const height = isNaN(heightVal) ? imageDimensions[1] : Math.min(Math.max(heightVal, 1), maxSize); + const width = isNaN(widthVal) ? bitmapWidth: Math.min(Math.max(widthVal, 1), maxSize); + const height = isNaN(heightVal) ? bitmapHeight : Math.min(Math.max(heightVal, 1), maxSize); - if (width !== imageDimensions[0] || height !== imageDimensions[1]) { - dispatchChangeImageDimensions([width, height]); + if (width !== bitmapWidth || height !== bitmapHeight) { + dispatch(changeImageDimensions(width, height)); } - this.setState({ - width: null, - height: null - }); - this.setShortcutsEnabled(true); - } + setWidthInputValue(null); + setHeightInputValue(null); + + setShortcutsEnabled(true); + }, [bitmapWidth, bitmapHeight, widthInputValue, heightInputValue, dispatch, setShortcutsEnabled]); - protected handleDimensionalKeydown = (event: React.KeyboardEvent) => { + const handleDimensionalKeydown = React.useCallback((event: React.KeyboardEvent) => { const charCode = (typeof event.which == "number") ? event.which : event.keyCode if (charCode === 13) { event.currentTarget.blur(); } - } + }, []); - protected handleAssetNameChange = (event: React.ChangeEvent) => { + const handleAssetNameChange = React.useCallback((event: React.ChangeEvent) => { let errorMessage = null; const name = event.target.value || ""; // don't trim the state otherwise they won't be able to type spaces @@ -262,73 +118,163 @@ export class BottomBarImpl extends React.Component { - const { dispatchChangeAssetName, assetName } = this.props; + setAssetNameInputValue(name); + setAssetNameErrorMessage(errorMessage); + }, [assetName]); - if (this.state.assetName) { - let newName = this.state.assetName.trim(); + const handleAssetNameBlur = React.useCallback(() => { + if (assetNameInputValue) { + let newName = assetNameInputValue.trim(); if (newName !== assetName && pxt.validateAssetName(newName) && !isNameTaken(newName)) { - dispatchChangeAssetName(newName); + dispatch(changeAssetName(newName)) } } - this.setState({ assetName: null, assetNameMessage: null }); - this.setShortcutsEnabled(true); - } - - protected zoomIn = () => { - this.props.dispatchChangeZoom(1) - } - - protected zoomOut = () => { - this.props.dispatchChangeZoom(-1) - } - - protected setShortcutsEnabled(enabled: boolean) { - if (enabled && this.shortcutLock) { - releaseShortcutLock(this.shortcutLock); - this.shortcutLock = undefined; - } - else if (!enabled && !this.shortcutLock) { - this.shortcutLock = obtainShortcutLock(); - } - } -} - -function mapStateToProps({store: { present: state, past, future }, editor}: ImageEditorStore, ownProps: any) { - if (!state) return {}; - - const bitmap = editor.isTilemap ? (state as TilemapState).tilemap.bitmap : (state as AnimationState).frames[(state as AnimationState).currentFrame].bitmap; - - return { - imageDimensions: [ bitmap.width, bitmap.height ], - aspectRatioLocked: state.aspectRatioLocked, - onionSkinEnabled: editor.onionSkinEnabled, - cursorLocation: editor.cursorLocation, - resizeDisabled: state.asset?.type === pxt.AssetType.Tile, - assetName: state.asset?.meta?.displayName, - hasUndo: !!past.length, - hasRedo: !!future.length, - isTilemap: editor.isTilemap, - }; -} - -const mapDispatchToProps = { - dispatchChangeImageDimensions, - dispatchUndoImageEdit, - dispatchRedoImageEdit, - dispatchToggleAspectRatioLocked, - dispatchToggleOnionSkinEnabled, - dispatchChangeZoom, - dispatchChangeAssetName -}; + setAssetNameInputValue(null); + setAssetNameErrorMessage(null); + setShortcutsEnabled(true); + }, [assetName, dispatch, setShortcutsEnabled]); + + const onToggleAspectRatioLockedClick = React.useCallback(() => { + dispatch(toggleAspectRatio()); + }, [dispatch]); + + const onToggleOnionSkinEnabledClick = React.useCallback(() => { + dispatch(toggleOnionSkinEnabled()); + }, [dispatch]); + + const onUndoClick = React.useCallback(() => { + dispatch(undoImageEdit()); + }, [dispatch]); + + const onRedoClick = React.useCallback(() => { + dispatch(redoImageEdit()); + }, [dispatch]); + + const onZoomInClick = React.useCallback(() => { + dispatch(changeCanvasZoom(1)) + }, [dispatch]); + + const onZoomOutClick = React.useCallback(() => { + dispatch(changeCanvasZoom(-1)) + }, [dispatch]); + + return ( +
+ {!resizeDisabled && +
+ + -export const BottomBar = connect(mapStateToProps, mapDispatchToProps)(BottomBarImpl); + +
+ } + {!singleFrame && +
+ } + {!singleFrame && +
+ +
+ } + {!resizeDisabled && +
+ } +
+ {cursorLocation && `${cursorLocation[0]}, ${cursorLocation[1]}`} +
+
+ {!hideAssetName && + <> + + {assetNameErrorMessage && +
+ {assetNameErrorMessage} +
+ } + + } +
+
+ + +
+
+
+ + +
+ {!hideDoneButton && +
+ {lf("Done")} +
+ } +
+ ); +} diff --git a/webapp/src/components/ImageEditor/Button.tsx b/webapp/src/components/ImageEditor/Button.tsx index 5cf9a43fd3d9..95a38f0e20e9 100644 --- a/webapp/src/components/ImageEditor/Button.tsx +++ b/webapp/src/components/ImageEditor/Button.tsx @@ -11,20 +11,18 @@ export interface ButtonProps { noTab?: boolean; } -export class IconButton extends React.Component { - render() { - const { title, iconClass, onClick, toggle, disabled, noTab } = this.props; +export const IconButton = (props: ButtonProps) => { + const { title, iconClass, onClick, toggle, disabled, noTab } = props; - return ( -
- -
- ); - } + return ( +
+ +
+ ); } diff --git a/webapp/src/components/ImageEditor/CursorSizes.tsx b/webapp/src/components/ImageEditor/CursorSizes.tsx index dfece1f1702f..37a873f71ab5 100644 --- a/webapp/src/components/ImageEditor/CursorSizes.tsx +++ b/webapp/src/components/ImageEditor/CursorSizes.tsx @@ -1,54 +1,48 @@ import * as React from 'react'; -import { ImageEditorStore, CursorSize } from './store/imageReducer'; -import { dispatchChangeCursorSize } from './actions/dispatch'; -import { connect } from 'react-redux'; - -interface CursorSizesProps { - selected: CursorSize; - dispatchChangeCursorSize: (size: CursorSize) => void; -} - - -class CursorSizesImpl extends React.Component { - protected handlers: (() => void)[] = []; - - render() { - const { selected } = this.props; - return
-
-
-
-
-
-
-
-
-
+import { CursorSize, ImageEditorContext, changeCursorSize } from './state'; +import { classList } from '../../../../react-common/components/util'; + +export const CursorSizes = () => { + return ( +
+ + +
- } - - clickHandler(size: CursorSize) { - if (!this.handlers[size]) { - this.handlers[size] = () => { - const { dispatchChangeCursorSize } = this.props; - dispatchChangeCursorSize(size); - } - } - return this.handlers[size]; - } + ); } +const CursorButton = (props: {size: CursorSize, title: string, className: string}) => { + const { state, dispatch } = React.useContext(ImageEditorContext); -function mapStateToProps({ editor }: ImageEditorStore, ownProps: any) { - if (!editor) return {}; - return { - selected: editor.cursorSize - }; -} + const { size, title, className } = props; -const mapDispatchToProps = { - dispatchChangeCursorSize -}; + const onClick = React.useCallback(() => { + dispatch(changeCursorSize(size)); + }, [size, dispatch]); + const isSelected = state.editor.cursorSize === size; -export const CursorSizes = connect(mapStateToProps, mapDispatchToProps)(CursorSizesImpl); \ No newline at end of file + return ( +
+
+
+ ); +} diff --git a/webapp/src/components/ImageEditor/ImageCanvas.tsx b/webapp/src/components/ImageEditor/ImageCanvas.tsx index 9b200c39ad34..5de1cb931331 100644 --- a/webapp/src/components/ImageEditor/ImageCanvas.tsx +++ b/webapp/src/components/ImageEditor/ImageCanvas.tsx @@ -1,18 +1,11 @@ import * as React from 'react'; -import { connect } from 'react-redux'; - -import { ImageEditorStore, ImageEditorTool, AnimationState, TilemapState, TileDrawingMode, GalleryTile } from './store/imageReducer'; -import { - dispatchImageEdit, dispatchChangeZoom, dispatchChangeCursorLocation, - dispatchChangeImageTool, dispatchChangeSelectedColor, dispatchChangeBackgroundColor, - dispatchCreateNewTile -} from "./actions/dispatch"; -import { GestureTarget, ClientCoordinates, bindGestureEvents, TilemapPatch, createTilemapPatchFromFloatingLayer } from './util'; +import { GestureTarget, ClientCoordinates, bindGestureEvents, TilemapPatch, createTilemapPatchFromFloatingLayer, useGestureEvents } from './util'; import { Edit, EditState, getEdit, getEditState, ToolCursor, tools } from './toolDefinitions'; import { createTile } from '../../assets'; import { areShortcutsEnabled } from './keyboardShortcuts'; import { LIGHT_MODE_TRANSPARENT } from './ImageEditor'; +import { ImageEditorContext, ImageEditorStore, ImageEditorTool, AnimationState, TilemapState, TileDrawingMode, GalleryTile, imageEdit, changeCanvasZoom, changeCursorLocation, changeImageTool, changeSelectedColor, changeBackgroundColor, createNewTile } from './state'; const IMAGE_MIME_TYPE = "image/x-mkcd-f4" @@ -1131,7 +1124,7 @@ export class ImageCanvasImpl extends React.Component imple } } -function mapStateToProps({ store: { present }, editor }: ImageEditorStore, ownProps: any) { +function mapStateToProps({ store: { present }, editor }: ImageEditorStore) { if (editor.isTilemap) { let state = (present as TilemapState); if (!state) return {} as ImageCanvasProps; @@ -1170,14 +1163,26 @@ function mapStateToProps({ store: { present }, editor }: ImageEditorStore, ownPr } as ImageCanvasProps } -const mapDispatchToProps = { - dispatchImageEdit, - dispatchChangeCursorLocation, - dispatchChangeZoom, - dispatchChangeImageTool, - dispatchChangeSelectedColor, - dispatchChangeBackgroundColor, - dispatchCreateNewTile -}; - -export const ImageCanvas = connect(mapStateToProps, mapDispatchToProps)(ImageCanvasImpl); +interface Props { + suppressShortcuts: boolean; + lightMode: boolean; +} + +export const ImageCanvas = (props: Props) => { + const { state, dispatch } = React.useContext(ImageEditorContext); + const mappedProps = mapStateToProps(state) + + return ( + dispatch(imageEdit(state))} + dispatchChangeZoom={zoom => dispatch(changeCanvasZoom(zoom))} + dispatchChangeCursorLocation={location => dispatch(changeCursorLocation(location as unknown as [number, number]))} + dispatchChangeImageTool={tool => dispatch(changeImageTool(tool))} + dispatchChangeSelectedColor={color => dispatch(changeSelectedColor(color))} + dispatchChangeBackgroundColor={color => dispatch(changeBackgroundColor(color))} + dispatchCreateNewTile={(tile, fg, bg, qname) => dispatch(createNewTile(tile, fg, bg, qname))} + /> + ); +} diff --git a/webapp/src/components/ImageEditor/ImageEditor.tsx b/webapp/src/components/ImageEditor/ImageEditor.tsx index 150593bd0db9..89ca9b3eaf52 100644 --- a/webapp/src/components/ImageEditor/ImageEditor.tsx +++ b/webapp/src/components/ImageEditor/ImageEditor.tsx @@ -1,8 +1,5 @@ import * as React from 'react'; -import { Store } from 'redux'; -import { Provider } from 'react-redux'; -import { mainStore, tileEditorStore } from './store/imageStore' import { SideBar } from './SideBar'; import { BottomBar } from './BottomBar'; import { TopBar } from './TopBar'; @@ -10,15 +7,12 @@ import { ImageCanvas } from './ImageCanvas'; import { Alert, AlertInfo } from './Alert'; import { Timeline } from './Timeline'; -import { addKeyListener, removeKeyListener, setStore } from './keyboardShortcuts'; - -import { dispatchSetInitialState, dispatchImageEdit, dispatchChangeZoom, dispatchOpenAsset, dispatchCloseTileEditor, dispatchDisableResize, dispatchChangeAssetName, dispatchChangeImageDimensions, dispatchSetFrames } from './actions/dispatch'; -import { EditorState, AnimationState, TilemapState, GalleryTile, ImageEditorStore } from './store/imageReducer'; import { imageStateToBitmap, imageStateToTilemap, applyBitmapData } from './util'; -import { Unsubscribe, Action } from 'redux'; import { createNewImageAsset, getNewInternalID } from '../../assets'; import { AssetEditorCore } from '../ImageFieldEditor'; import { classList } from '../../../../react-common/components/util'; +import { Action, ImageEditorContext, ImageEditorStateProvider, TileEditorStateProvider, EditorState, AnimationState, TilemapState, GalleryTile, ImageEditorStore, setInitialState, changeImageDimensions, imageEdit, disableResize, closeTileEditor, changeCanvasZoom, openAsset, changeAssetName, setFrames } from './state'; +import { useKeyboardShortcuts } from './keyboardShortcuts'; export const LIGHT_MODE_TRANSPARENT = "#dedede"; @@ -31,7 +25,6 @@ export interface ImageEditorProps { singleFrame?: boolean; onChange?: (value: string) => void; asset?: pxt.Asset; - store?: Store; onDoneClicked?: (value: pxt.Asset) => void; onTileEditorOpenClose?: (open: boolean) => void; nested?: boolean; @@ -48,7 +41,7 @@ export interface ImageEditorState { } export class ImageEditor extends React.Component implements AssetEditorCore { - protected unsubscribeChangeListener: Unsubscribe; + protected store: { state: ImageEditorStore, dispatch: (a: Action) => void }; constructor(props: ImageEditorProps) { super(props); @@ -57,49 +50,48 @@ export class ImageEditor extends React.Component - -
- -
- - - {isAnimationEditor && !singleFrame ? : undefined} -
- - {alert && alert.title && } -
+ + + {context => { + if (this.store && this.store.state !== context.state) { + this.store = context; + this.onStoreChange(); + } + else { + this.store = context; + } + + const isAnimationEditor = context.state.store.present.kind === "Animation"; + return ( +
+ +
+ + + {isAnimationEditor && !singleFrame ? : undefined} +
+ + {alert && alert.title && } +
+ ); + }} +
{editingTile && ({ bitmap: b })))); + this.store.dispatch(setFrames(asset.frames.map(b => ({ bitmap: b })))); break; } break; @@ -154,11 +146,11 @@ export class ImageEditor extends React.Component { if (this.props.onChange) { this.props.onChange(this.props.singleFrame ? pxt.sprite.bitmapToImageLiteral(this.getCurrentFrame(), "typescript") : "") } - const store = this.getStore(); - const state = store.getState(); - setStore(store); - - if (state.editor) this.setState({ alert: state.editor.alert }); - - if (!!state.editor.editingTile != !!this.state.editingTile) { - if (state.editor.editingTile) { - const index = state.editor.editingTile.tilesetIndex; - if (index) { - const tile = (state.store.present as TilemapState).tileset.tiles[index]; - this.setState({ - editingTile: true, - tileToEdit: tile - }); + const { state } = this.store; + + + setTimeout(() => { + if (state.editor) this.setState({ alert: state.editor.alert }); + if (!!state.editor.editingTile != !!this.state.editingTile) { + if (state.editor.editingTile) { + const index = state.editor.editingTile.tilesetIndex; + if (index) { + const tile = (state.store.present as TilemapState).tileset.tiles[index]; + this.setState({ + editingTile: true, + tileToEdit: tile + }); + } + else { + const tileWidth = (state.store.present as TilemapState).tileset.tileWidth; + const emptyTile = createNewImageAsset(pxt.AssetType.Tile, tileWidth, tileWidth, lf("myTile")) as pxt.Tile; + this.setState({ + editingTile: true, + tileToEdit: emptyTile + }); + } + if (this.props.onTileEditorOpenClose) this.props.onTileEditorOpenClose(true); } else { - const tileWidth = (state.store.present as TilemapState).tileset.tileWidth; - const emptyTile = createNewImageAsset(pxt.AssetType.Tile, tileWidth, tileWidth, lf("myTile")) as pxt.Tile; this.setState({ - editingTile: true, - tileToEdit: emptyTile + editingTile: false }); + if (this.props.onTileEditorOpenClose) this.props.onTileEditorOpenClose(false); } - if (this.props.onTileEditorOpenClose) this.props.onTileEditorOpenClose(true); - } - else { - this.setState({ - editingTile: false - }); - if (this.props.onTileEditorOpenClose) this.props.onTileEditorOpenClose(false); } - } + }) } protected onDoneClick = () => { @@ -367,14 +365,40 @@ export class ImageEditor extends React.Component { - const store = this.getStore(); - const tileEditState = store.getState().editor.editingTile; + const { state } = this.store; + const tileEditState = state.editor.editingTile; tile.isProjectTile = true; - this.dispatchOnStore(dispatchCloseTileEditor(tile, tileEditState.tilesetIndex)) + this.store.dispatch(closeTileEditor(tile, tileEditState.tilesetIndex)); } +} + +const Provider = (props: React.PropsWithChildren<{ nested: boolean }>) => { + const { nested, children } = props; - protected dispatchOnStore(action: Action) { - this.getStore().dispatch(action); + if (nested) { + return ( + + {children}; + + ); } + + return ( + + + {children}; + + + ); +} + + +const KeyboardShortcut = ({ children }: React.PropsWithChildren<{}>) => { + useKeyboardShortcuts(); + return ( + <> + {children} + + ) } \ No newline at end of file diff --git a/webapp/src/components/ImageEditor/SideBar.tsx b/webapp/src/components/ImageEditor/SideBar.tsx index 9c3ab4a456a5..89a88b701b8e 100644 --- a/webapp/src/components/ImageEditor/SideBar.tsx +++ b/webapp/src/components/ImageEditor/SideBar.tsx @@ -1,69 +1,46 @@ import * as React from "react"; -import { connect } from "react-redux"; import { tools } from "./toolDefinitions"; import { IconButton } from "./Button"; -import { ImageEditorTool, ImageEditorStore } from "./store/imageReducer"; -import { dispatchChangeImageTool } from "./actions/dispatch"; import { Palette } from "./sprite/Palette"; import { TilePalette } from "./tilemap/TilePalette"; import { Minimap } from "./tilemap/Minimap"; +import { changeImageTool, ImageEditorContext, ImageEditorTool } from "./state"; interface SideBarProps { - selectedTool: ImageEditorTool; - isTilemap: boolean; - dispatchChangeImageTool: (tool: ImageEditorTool) => void; lightMode: boolean; } -export class SideBarImpl extends React.Component { - protected handlers: (() => void)[] = []; +export const SideBar = (props: SideBarProps) => { + const { state, dispatch } = React.useContext(ImageEditorContext); - render() { - const { selectedTool, isTilemap, lightMode } = this.props; - return ( -
- {isTilemap && -
- -
- } -
- {tools.filter(td => !td.hiddenTool).map(td => - - )} -
-
- { isTilemap ? : } -
-
- ); - } + const { lightMode } = props; + const { isTilemap, tool } = state.editor; - protected clickHandler(tool: number) { - if (!this.handlers[tool]) this.handlers[tool] = () => this.props.dispatchChangeImageTool(tool); + const onToolSelected = React.useCallback((tool: ImageEditorTool) => { + dispatch(changeImageTool(tool)); + }, [dispatch]); - return this.handlers[tool]; - } -} - -function mapStateToProps({ editor }: ImageEditorStore, ownProps: any) { - if (!editor) return {}; - return { - isTilemap: editor.isTilemap, - selectedTool: editor.tool - }; + return ( +
+ {isTilemap && +
+ +
+ } +
+ {tools.filter(td => !td.hiddenTool).map(td => + onToolSelected(td.tool)} /> + )} +
+
+ { isTilemap ? : } +
+
+ ); } - -const mapDispatchToProps = { - dispatchChangeImageTool -}; - - -export const SideBar = connect(mapStateToProps, mapDispatchToProps)(SideBarImpl); - diff --git a/webapp/src/components/ImageEditor/Timeline.tsx b/webapp/src/components/ImageEditor/Timeline.tsx index 2d7462d48721..764faef8ef5f 100644 --- a/webapp/src/components/ImageEditor/Timeline.tsx +++ b/webapp/src/components/ImageEditor/Timeline.tsx @@ -1,247 +1,225 @@ import * as React from "react"; -import { connect } from "react-redux"; - -import { ImageEditorStore, AnimationState } from "./store/imageReducer"; -import { dispatchChangeCurrentFrame, dispatchNewFrame, dispatchDuplicateFrame, dispatchDeleteFrame, dispatchMoveFrame } from "./actions/dispatch"; import { TimelineFrame } from "./TimelineFrame"; import { bindGestureEvents, ClientCoordinates } from "./util"; +import { Action, deleteFrame, duplicateFrame, ImageEditorContext, moveFrame, newFrame, AnimationState, changeCurrentFrame } from "./state"; +import { classList } from "../../../../react-common/components/util"; -interface TimelineProps { - colors: string[]; - frames: pxt.sprite.ImageState[]; +interface DragState { + scrollOffset: number; + dragEnd: boolean; currentFrame: number; - interval: number; - previewAnimating: boolean; - - dispatchChangeCurrentFrame: (index: number) => void; - dispatchNewFrame: () => void; - dispatchDuplicateFrame: (index: number) => void; - dispatchDeleteFrame: (index: number) => void; - dispatchMoveFrame: (oldIndex: number, newIndex: number) => void; -} - -interface TimelineState { - isMovingFrame?: boolean; + isMovingFrame: boolean; + dispatch: (a: Action) => void; dropPreviewIndex?: number; } -export class TimelineImpl extends React.Component { - protected handlers: (() => void)[] = []; - protected frameScroller: HTMLDivElement; - protected scrollOffset = 0; - protected dragEnd = false; +export const Timeline = () => { + const { state, dispatch } = React.useContext(ImageEditorContext); + const [isMovingFrame, setIsMovingFrame] = React.useState(false); + const [dropPreviewIndex, setDropPreviewIndex] = React.useState(null); - constructor(props: TimelineProps) { - super(props); - this.state = {}; - } + const dragState = React.useRef({ scrollOffset: 0, dragEnd: false, isMovingFrame, dropPreviewIndex, dispatch, currentFrame: 0 }); + const dragFrameRef = React.useRef(); + const scrollerRef = React.useRef(); - render() { - const { frames, colors, currentFrame, interval, previewAnimating } = this.props; - const { isMovingFrame, dropPreviewIndex } = this.state; + const { previewAnimating } = state.editor - let renderFrames = frames.slice(); - let dragFrame: pxt.sprite.ImageState; + const editState = state.store.present as AnimationState; + const { frames, currentFrame, interval, colors } = editState; - if (isMovingFrame) { - dragFrame = frames[currentFrame]; + let renderFrames = frames.slice(); + let dragFrame: pxt.sprite.ImageState; - renderFrames.splice(currentFrame, 1); - renderFrames.splice(dropPreviewIndex, 0, null) - } + if (isMovingFrame) { + dragFrame = frames[currentFrame]; - return ( -
-
- -
-
-
- { renderFrames.map((frame, index) => { - const isActive = !isMovingFrame && index === currentFrame; - if (!frame) return
- - return
- -
- } - ) } - { dragFrame && -
- -
- } -
- -
-
-
-
- ); + renderFrames.splice(currentFrame, 1); + renderFrames.splice(dropPreviewIndex, 0, null) } - componentDidMount() { - this.frameScroller = this.refs["frame-scroller-ref"] as HTMLDivElement; + const onNewFrameClick = React.useCallback(() => { + dispatch(newFrame()); + }, [dispatch]); + + const onDuplicateFrameClick = React.useCallback(() => { + dispatch(duplicateFrame(currentFrame)); + }, [currentFrame, dispatch]); + const onDeleteFrameClick = React.useCallback(() => { + dispatch(deleteFrame(currentFrame)); + }, [currentFrame, dispatch]); + + const onFrameSelected = React.useCallback((index: number) => { + if (dragState.current.dragEnd) { + dragState.current.dragEnd = false; + } + else if (index != currentFrame) { + dispatch(changeCurrentFrame(index)); + } + }, [dispatch, currentFrame]) + + React.useEffect(() => { let last: number; let isScroll = false; - bindGestureEvents(this.frameScroller, { - onClick: coord => { - this.dragEnd = false; + + const updateDragDrop = (coord: ClientCoordinates) => { + const parent = scrollerRef.current.getBoundingClientRect(); + const scrollY = coord.clientY - parent.top; + + const unit = scrollerRef.current.firstElementChild.getBoundingClientRect().height; + const index = Math.floor(scrollY / unit); + + if (!dragState.current.isMovingFrame) { + setIsMovingFrame(true); + setDropPreviewIndex(dragState.current.currentFrame); + } + else if (dragFrameRef.current) { + dragFrameRef.current.style.top = scrollY + "px"; + setDropPreviewIndex(index); + } + } + + bindGestureEvents(scrollerRef.current, { + onClick: () => { + dragState.current.dragEnd = false; }, onDragStart: coord => { last = coord.clientY; - pxt.BrowserUtils.addClass(this.frameScroller, "scrolling"); + pxt.BrowserUtils.addClass(scrollerRef.current, "scrolling"); - const parent = this.frameScroller.getBoundingClientRect(); + const parent = scrollerRef.current.getBoundingClientRect(); const scrollY = coord.clientY - parent.top; - const unit = this.frameScroller.firstElementChild.getBoundingClientRect().height; + const unit = scrollerRef.current.firstElementChild.getBoundingClientRect().height; const index = Math.floor(scrollY / unit); - isScroll = index !== this.props.currentFrame; + isScroll = index !== dragState.current.currentFrame; }, onDragMove: coord => { if (isScroll) { - this.scrollOffset -= last - coord.clientY; + dragState.current.scrollOffset -= last - coord.clientY; last = coord.clientY; try { - const rect = this.frameScroller.getBoundingClientRect(); - const parent = this.frameScroller.parentElement.getBoundingClientRect(); + const rect = scrollerRef.current.getBoundingClientRect(); + const parent = scrollerRef.current.parentElement.getBoundingClientRect(); if (rect.height > parent.height) { - this.scrollOffset = Math.max(Math.min(this.scrollOffset, 0), parent.height - rect.height - 15); + dragState.current.scrollOffset = Math.max(Math.min(dragState.current.scrollOffset, 0), parent.height - rect.height - 15); } else { - this.scrollOffset = 0; + dragState.current.scrollOffset = 0; } - this.frameScroller.parentElement.scrollTop = -this.scrollOffset; + scrollerRef.current.parentElement.scrollTop = -dragState.current.scrollOffset; } catch (e) { // Some browsers throw if you get the bounds while not in the dom. Ignore it. } } else { - this.updateDragDrop(coord); + updateDragDrop(coord); } }, onDragEnd: () => { last = null; - pxt.BrowserUtils.removeClass(this.frameScroller, "scrolling"); - this.dragEnd = true; - - if (this.state.isMovingFrame) { - const { dispatchMoveFrame, currentFrame } = this.props; + pxt.BrowserUtils.removeClass(scrollerRef.current, "scrolling"); + dragState.current.dragEnd = true; - dispatchMoveFrame(currentFrame, this.state.dropPreviewIndex); + if (dragState.current.isMovingFrame) { + const { dispatch, currentFrame } = dragState.current; - this.setState({ - isMovingFrame: false - }); + dispatch(moveFrame(currentFrame, dragState.current.dropPreviewIndex)); + setIsMovingFrame(false); } }, }); - this.frameScroller.addEventListener("wheel", ev => { - this.scrollOffset -= ev.deltaY + scrollerRef.current.addEventListener("wheel", ev => { + dragState.current.scrollOffset -= ev.deltaY try { - const rect = this.frameScroller.getBoundingClientRect(); - const parent = this.frameScroller.parentElement.getBoundingClientRect(); + const rect = scrollerRef.current.getBoundingClientRect(); + const parent = scrollerRef.current.parentElement.getBoundingClientRect(); if (rect.height > parent.height) { - this.scrollOffset = Math.max(Math.min(this.scrollOffset, 0), parent.height - rect.height - 15); + dragState.current.scrollOffset = Math.max(Math.min(dragState.current.scrollOffset, 0), parent.height - rect.height - 15); } else { - this.scrollOffset = 0; + dragState.current.scrollOffset = 0; } - this.frameScroller.parentElement.scrollTop = -this.scrollOffset; + scrollerRef.current.parentElement.scrollTop = -dragState.current.scrollOffset; } catch (e) { // Some browsers throw if you get the bounds while not in the dom. Ignore it. } }); - } - - protected clickHandler(index: number) { - if (!this.handlers[index]) this.handlers[index] = () => { - const { currentFrame, dispatchChangeCurrentFrame } = this.props; - if (this.dragEnd) this.dragEnd = false; - else if (index != currentFrame) dispatchChangeCurrentFrame(index); - }; - - return this.handlers[index]; - } - - protected duplicateFrame = () => { - const { currentFrame, dispatchDuplicateFrame } = this.props; - dispatchDuplicateFrame(currentFrame); - } - - protected deleteFrame = () => { - const { currentFrame, dispatchDeleteFrame } = this.props; - dispatchDeleteFrame(currentFrame); - } - - protected newFrame = () => { - const { dispatchNewFrame } = this.props; - if (this.dragEnd) this.dragEnd = false; - else dispatchNewFrame(); - } - - protected updateDragDrop(coord: ClientCoordinates) { - const parent = this.frameScroller.getBoundingClientRect(); - const scrollY = coord.clientY - parent.top; - - const unit = this.frameScroller.firstElementChild.getBoundingClientRect().height; - const index = Math.floor(scrollY / unit); - - if (!this.state.isMovingFrame) { - this.setState({ - isMovingFrame: true, - dropPreviewIndex: this.props.currentFrame, - }); - } - else if (this.refs["floating-frame"]) { - const floating = this.refs["floating-frame"] as HTMLDivElement; - floating.style.top = scrollY + "px"; - - this.setState({ dropPreviewIndex: index }); - } - } -} + }); + + // FIXME: Obviously this is a little cursed, need to refactor how + // we do drag and drop behavior so that it doesn't need these values + // passed through. Can't pass them in the use effect dependencies because + // doing so will break the drag state if the state is changed while + // a drag is happening. + dragState.current.isMovingFrame = isMovingFrame; + dragState.current.currentFrame = currentFrame; + dragState.current.dropPreviewIndex = dropPreviewIndex; + dragState.current.dispatch = dispatch; + + return ( +
+
+ +
+
+
+ {renderFrames.map((frame, index) => { + const isActive = !isMovingFrame && index === currentFrame; + if (!frame) { + return ( +
+ ); + } -function mapStateToProps({ store: { present }, editor }: ImageEditorStore, ownProps: any) { - let state = present as AnimationState; - if (!state) return {}; - return { - frames: state.frames, - currentFrame: state.currentFrame, - colors: state.colors, - interval: state.interval, - previewAnimating: editor.previewAnimating - }; + return ( +
onFrameSelected(index)} + > + +
+ ); + })} + {dragFrame && +
+ +
+ } +
+ +
+
+
+
+ ); } - -const mapDispatchToProps = { - dispatchDuplicateFrame, - dispatchDeleteFrame, - dispatchChangeCurrentFrame, - dispatchNewFrame, - dispatchMoveFrame -}; - - -export const Timeline = connect(mapStateToProps, mapDispatchToProps)(TimelineImpl); - diff --git a/webapp/src/components/ImageEditor/TopBar.tsx b/webapp/src/components/ImageEditor/TopBar.tsx index 2f36a4d73369..971490fb9132 100644 --- a/webapp/src/components/ImageEditor/TopBar.tsx +++ b/webapp/src/components/ImageEditor/TopBar.tsx @@ -1,122 +1,119 @@ import * as React from "react"; -import { connect } from 'react-redux'; -import { ImageEditorStore, AnimationState } from './store/imageReducer'; -import { dispatchChangeInterval, dispatchChangePreviewAnimating, dispatchChangeOverlayEnabled } from './actions/dispatch'; import { IconButton } from "./Button"; import { CursorSizes } from "./CursorSizes"; import { Toggle } from "./Toggle"; -import { flip, rotate } from "./keyboardShortcuts"; +import { changeInterval, changeOverlayEnabled, changePreviewAnimating, imageEdit, ImageEditorContext, TilemapState, AnimationState } from "./state"; +import { flipEdit, getEditState, rotateEdit } from "./toolDefinitions"; export interface TopBarProps { - dispatchChangeInterval: (interval: number) => void; - interval: number; - previewAnimating: boolean; - dispatchChangePreviewAnimating: (animating: boolean) => void; - dispatchChangeOverlayEnabled: () => void; singleFrame?: boolean; - isTilemap?: boolean; } -export interface TopBarState { - interval?: string; -} - -export class TopBarImpl extends React.Component { - constructor(props: TopBarProps) { - super(props); - this.state = {}; - } - - render() { - const { interval, previewAnimating, singleFrame, isTilemap, dispatchChangeOverlayEnabled } = this.props; - - const intervalVal = this.state.interval == null ? interval : this.state.interval; - - return ( -
-
- -
-
-
- - - - -
-
- { !singleFrame &&
} - { !singleFrame && -
- -
- -
-
- -
-
- } - { isTilemap && - - } -
- ); - } - - protected togglePreviewAnimating = () => this.props.dispatchChangePreviewAnimating(!this.props.previewAnimating); - - protected flipVertical = () => flip(true); - protected flipHorizontal = () => flip(false); - protected rotateClockwise = () => rotate(true); - protected rotateCounterclockwise = () => rotate(false); +export const TopBar = (props: TopBarProps) => { + const { state, dispatch } = React.useContext(ImageEditorContext); + const [intervalInputValue, setIntervalInputValue] = React.useState(null); + const { isTilemap, previewAnimating, drawingMode } = state.editor; + const assetState = state.store.present; - protected handleIntervalChange = (event: React.ChangeEvent) => { - this.setState({ interval: event.target.value }); - } + const { interval } = assetState as AnimationState; + const { singleFrame } = props; - protected handleIntervalBlur = () => { - const { dispatchChangeInterval } = this.props; + const isAnimation = !isTilemap && (assetState as AnimationState).frames.length > 1; - const interval = parseInt(this.state.interval); + const createEditState = React.useCallback(() => { + if (isTilemap) { + return getEditState((assetState as TilemapState).tilemap, isTilemap, drawingMode); + } + return getEditState((assetState as AnimationState).frames[(assetState as AnimationState).currentFrame], isTilemap); + }, [assetState, isTilemap, drawingMode]); + + const onFlipVerticalClick = React.useCallback(() => { + const editState = createEditState(); + const flipped = flipEdit(editState, true, isTilemap); + dispatch(imageEdit(flipped.toImageState())); + }, [dispatch, createEditState, isTilemap]); + + const onFlipHorizontalClick = React.useCallback(() => { + const editState = createEditState(); + const flipped = flipEdit(editState, false, isTilemap); + dispatch(imageEdit(flipped.toImageState())); + }, [dispatch, createEditState, isTilemap]); + + const onRotateClockwiseClick = React.useCallback(() => { + const editState = createEditState(); + const rotated = rotateEdit(editState, true, isTilemap, isAnimation); + dispatch(imageEdit(rotated.toImageState())); + }, [dispatch, createEditState, isTilemap, isAnimation]); + + const onRotateCounterClockwiseClick = React.useCallback(() => { + const editState = createEditState(); + const rotated = rotateEdit(editState, false, isTilemap, isAnimation); + dispatch(imageEdit(rotated.toImageState())); + }, [dispatch, createEditState, isTilemap, isAnimation]); + + const onTogglePreviewAnimatingClick = React.useCallback(() => { + dispatch(changePreviewAnimating(!previewAnimating)); + }, [dispatch, previewAnimating]); + + const onShowWallsClick = React.useCallback((value: boolean) => { + dispatch(changeOverlayEnabled(value)); + }, [dispatch]); + + const onIntervalChange = React.useCallback((event: React.ChangeEvent) => { + setIntervalInputValue(event.target.value); + }, []); + + const onIntervalBlur = React.useCallback(() => { + const interval = parseInt(intervalInputValue); if (!isNaN(interval)) { - dispatchChangeInterval(Math.min(Math.max(interval, 50), 1000000)); + dispatch(changeInterval(Math.min(Math.max(interval, 50), 1000000))); } - this.setState({ interval: null }); - } -} - -function mapStateToProps({ store: { present }, editor }: ImageEditorStore, ownProps: any) { - let state = present as AnimationState; - if (!state) return {} as TopBarProps; + setIntervalInputValue(null); + }, [dispatch, intervalInputValue]); - return { - interval: state.interval, - previewAnimating: editor.previewAnimating, - isTilemap: editor.isTilemap - } as TopBarProps + return ( +
+
+ +
+
+
+ + + + +
+
+ { !singleFrame &&
} + { !singleFrame && +
+ +
+ +
+
+ +
+
+ } + { isTilemap && + + } +
+ ); } - -const mapDispatchToProps = { - dispatchChangeInterval, - dispatchChangePreviewAnimating, - dispatchChangeOverlayEnabled -}; - - -export const TopBar = connect(mapStateToProps, mapDispatchToProps)(TopBarImpl); diff --git a/webapp/src/components/ImageEditor/actions/dispatch.ts b/webapp/src/components/ImageEditor/actions/dispatch.ts deleted file mode 100644 index a4d8e70f2ab3..000000000000 --- a/webapp/src/components/ImageEditor/actions/dispatch.ts +++ /dev/null @@ -1,46 +0,0 @@ -import * as actions from './types' -import { ImageEditorTool, CursorSize, EditorState, AnimationState, TileCategory, TileDrawingMode, GalleryTile } from '../store/imageReducer'; -import { AlertOption } from '../Alert'; - -export const dispatchChangeImageTool = (tool: ImageEditorTool) => ({ type: actions.CHANGE_IMAGE_TOOL, tool }); -export const dispatchChangeCursorSize = (cursorSize: CursorSize) => ({ type: actions.CHANGE_CURSOR_SIZE, cursorSize }); -export const dispatchChangeSelectedColor = (selectedColor: number) => ({ type: actions.CHANGE_SELECTED_COLOR, selectedColor }); -export const dispatchChangeImageDimensions = (imageDimensions: [number, number]) => ({ type: actions.CHANGE_IMAGE_DIMENSIONS, imageDimensions }); -export const dispatchChangeKeyModifiers = (keyModifiers: number) => ({ type: actions.CHANGE_KEY_MODIFIERS, keyModifiers }); -export const dispatchChangeCursorLocation = (cursorLocation: [number, number]) => ({ type: actions.CHANGE_CURSOR_LOCATION, cursorLocation }); - -export const dispatchImageEdit = (newState: pxt.sprite.ImageState) => ({ type: actions.IMAGE_EDIT, newState }); -export const dispatchUndoImageEdit = () => ({ type: actions.UNDO_IMAGE_EDIT }); -export const dispatchRedoImageEdit = () => ({ type: actions.REDO_IMAGE_EDIT }); - -export const dispatchToggleAspectRatioLocked = () => ({ type: actions.TOGGLE_ASPECT_RATIO }); - -export const dispatchNewFrame = (index?: number) => ({ type: actions.NEW_FRAME, index }); -export const dispatchDeleteFrame = (index: number) => ({ type: actions.DELETE_FRAME, index }); -export const dispatchDuplicateFrame = (index: number) => ({ type: actions.DUPLICATE_FRAME, index }); -export const dispatchChangeCurrentFrame = (index: number) => ({ type: actions.CHANGE_CURRENT_FRAME, index }); -export const dispatchMoveFrame = (oldIndex: number, newIndex: number) => ({ type: actions.MOVE_FRAME, oldIndex, newIndex }); -export const dispatchChangeInterval = (newInterval: number) => ({ type: actions.CHANGE_INTERVAL, newInterval }); -export const dispatchChangePreviewAnimating = (animating: boolean) => ({ type: actions.CHANGE_PREVIEW_ANIMATING, animating }); -export const dispatchToggleOnionSkinEnabled = () => ({ type: actions.TOGGLE_ONION_SKIN_ENABLED }) -export const dispatchChangeOverlayEnabled = (enabled: boolean) => ({ type: actions.CHANGE_OVERLAY_ENABLED, enabled }) -export const dispatchChangeZoom = (zoom: number) => ({ type: actions.CHANGE_CANVAS_ZOOM, zoom }); -export const dispatchShowAlert = (title: string, text: string, options?: AlertOption[]) => ({ type: actions.SHOW_ALERT, title, text, options }); -export const dispatchHideAlert = () => ({ type: actions.HIDE_ALERT }); - -export const dispatchSwapBackgroundForeground = () => ({ type: actions.SWAP_FOREGROUND_BACKGROUND }); -export const dispatchChangeBackgroundColor = (backgroundColor: number) => ({ type: actions.CHANGE_BACKGROUND_COLOR, backgroundColor }) -export const dispatchSetInitialState = (state: EditorState, past: AnimationState[]) => ({ type: actions.SET_INITIAL_STATE, state, past }); -export const dispatchChangeTilePaletteCategory = (category: TileCategory) => ({ type: actions.CHANGE_TILE_PALETTE_CATEGORY, category }); -export const dispatchChangeTilePalettePage = (page: number) => ({ type: actions.CHANGE_TILE_PALETTE_PAGE, page }); -export const dispatchChangeDrawingMode = (drawingMode: TileDrawingMode) => ({ type: actions.CHANGE_DRAWING_MODE, drawingMode }); -export const dispatchCreateNewTile = (tile: pxt.Tile, foreground: number, background: number, qualifiedName?: string) => ({ type: actions.CREATE_NEW_TILE, tile, foreground, background, qualifiedName }); -export const dispatchSetGalleryOpen = (open: boolean) => ({ type: actions.SET_GALLERY_OPEN, open }) -export const dispatchOpenTileEditor = (editIndex?: number, editID?: string) => ({ type: actions.OPEN_TILE_EDITOR, index: editIndex, id: editID }) -export const dispatchCloseTileEditor = (result?: pxt.Tile, index?: number) => ({ type: actions.CLOSE_TILE_EDITOR, result, index }) -export const dispatchDeleteTile = (index: number, id: string) => ({ type: actions.DELETE_TILE, id, index }); -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 diff --git a/webapp/src/components/ImageEditor/actions/types.ts b/webapp/src/components/ImageEditor/actions/types.ts deleted file mode 100644 index 2bc586b36572..000000000000 --- a/webapp/src/components/ImageEditor/actions/types.ts +++ /dev/null @@ -1,45 +0,0 @@ -export const SET_INITIAL_STATE = "SET_INITIAL_STATE"; -export const SET_FRAMES = "SET_FRAMES"; - -export const CHANGE_IMAGE_TOOL = "CHANGE_IMAGE_TOOL"; -export const CHANGE_CURSOR_SIZE = "CHANGE_CURSOR_SIZE"; -export const CHANGE_SELECTED_COLOR = "CHANGE_SELECTED_COLOR"; -export const CHANGE_IMAGE_DIMENSIONS = "CHANGE_IMAGE_DIMENSIONS"; -export const CHANGE_KEY_MODIFIERS = "CHANGE_KEY_MODIFIERS"; -export const CHANGE_CURSOR_LOCATION = "CHANGE_CURSOR_LOCATION"; - -export const IMAGE_EDIT = "IMAGE_EDIT"; -export const UNDO_IMAGE_EDIT = "UNDO_IMAGE_EDIT"; -export const REDO_IMAGE_EDIT = "REDO_IMAGE_EDIT"; - -export const TOGGLE_ASPECT_RATIO = "TOGGLE_ASPECT_RATIO"; -export const SET_GALLERY_OPEN = "SET_GALLERY_OPEN"; - -export const NEW_FRAME = "NEW_FRAME"; -export const DELETE_FRAME = "DELETE_FRAME"; -export const DUPLICATE_FRAME = "DUPLICATE_FRAME"; -export const MOVE_FRAME = "MOVE_FRAME"; -export const CHANGE_CURRENT_FRAME = "CHANGE_CURRENT_FRAME"; -export const CHANGE_INTERVAL = "CHANGE_INTERVAL"; -export const CHANGE_PREVIEW_ANIMATING = "CHANGE_PREVIEW_ANIMATING"; -export const TOGGLE_ONION_SKIN_ENABLED = "TOGGLE_ONION_SKIN_ENABLED"; -export const CHANGE_OVERLAY_ENABLED = "CHANGE_OVERLAY_ENABLED"; -export const CHANGE_CANVAS_ZOOM = "CHANGE_CANVAS_ZOOM"; -export const SHOW_ALERT = "SHOW_ALERT"; -export const HIDE_ALERT = "HIDE_ALERT"; - -export const SWAP_FOREGROUND_BACKGROUND = "SWAP_FOREGROUND_BACKGROUND"; -export const CHANGE_BACKGROUND_COLOR = "CHANGE_BACKGROUND_COLOR"; - -export const CHANGE_TILE_PALETTE_PAGE = "CHANGE_TILE_PALETTE_PAGE"; -export const CHANGE_TILE_PALETTE_CATEGORY = "CHANGE_TILE_PALETTE_CATEGORY"; - -export const CHANGE_DRAWING_MODE = "CHANGE_DRAWING_MODE"; -export const CREATE_NEW_TILE = "CREATE_NEW_TILE"; -export const OPEN_TILE_EDITOR = "OPEN_TILE_EDITOR"; -export const CLOSE_TILE_EDITOR = "CLOSE_TILE_EDITOR"; -export const DELETE_TILE = "DELETE_TILE"; -export const DISABLE_RESIZE = "DISABLE_RESIZE"; -export const CHANGE_ASSET_NAME = "CHANGE_ASSET_NAME"; - -export const OPEN_ASSET = "OPEN_ASSET"; \ No newline at end of file diff --git a/webapp/src/components/ImageEditor/keyboardShortcuts.ts b/webapp/src/components/ImageEditor/keyboardShortcuts.ts index fb34db68ec89..ea1fde287359 100644 --- a/webapp/src/components/ImageEditor/keyboardShortcuts.ts +++ b/webapp/src/components/ImageEditor/keyboardShortcuts.ts @@ -1,24 +1,211 @@ -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 { mainStore } from './store/imageStore'; import { EditState, flipEdit, getEditState, outlineEdit, replaceColorEdit, rotateEdit } from './toolDefinitions'; -let store = mainStore; +import { ImageEditorContext, ImageEditorTool, ImageEditorStore, TilemapState, AnimationState, CursorSize, undoImageEdit, redoImageEdit, changeImageTool, changeSelectedColor, changeCanvasZoom, swapForegroundBackground, changeCursorSize, imageEdit } from './state'; +import { useContext, useEffect } from 'react'; let lockRefs: number[] = []; -export function addKeyListener() { - lockRefs = []; - document.addEventListener("keydown", handleKeyDown); - document.addEventListener("keydown", handleUndoRedo, true); - document.addEventListener("keydown", overrideBlocklyShortcuts, true); -} - -export function removeKeyListener() { - document.removeEventListener("keydown", handleKeyDown); - document.removeEventListener("keydown", handleUndoRedo, true); - document.removeEventListener("keydown", overrideBlocklyShortcuts, true); +export function useKeyboardShortcuts() { + const { state, dispatch } = useContext(ImageEditorContext); + + const animationState = state.store.present as AnimationState; + const tilemapState = state.store.present as TilemapState; + + const { selectedColor, backgroundColor, isTilemap, drawingMode, cursorSize } = state.editor + + useEffect(() => { + function handleKeyDown(event: KeyboardEvent) { + if (!areShortcutsEnabled()) return; + + if (event.shiftKey && /^(?:Digit[1-9])|(?:Key[A-F])$/.test(event.code)) { + if (event.code.indexOf("Digit") == 0) outline(parseInt(event.code.substring(5))) + else outline(parseInt(event.code.substring(3), 16)); + return; + } + + // Mostly copied from the photoshop shortcuts + switch (event.key) { + case "e": + setTool(ImageEditorTool.Erase); + break; + case "h": + setTool(ImageEditorTool.Pan); + break; + case "b": + case "p": + setTool(ImageEditorTool.Paint); + break; + case "g": + setTool(ImageEditorTool.Fill); + break; + case "m": + setTool(ImageEditorTool.Marquee); + break; + case "u": + setTool(ImageEditorTool.Rect); + break; + case "l": + setTool(ImageEditorTool.Line); + break; + case "c": + setTool(ImageEditorTool.Circle); + break; + case "-": + case "_": + zoom(-1); + break; + case "=": + case "+": + zoom(1); + break; + case "x": + swapForegroundBackgroundColors(); + break; + case "H": + flip(false); + break; + case "V": + flip(true); + break; + case "[": + rotate(false); + break; + case "]": + rotate(true); + break; + case ">": + changeCursor(true); + break; + case "<": + changeCursor(false); + break; + + } + + if (event.shiftKey && event.code === "KeyR") { + replaceColor(backgroundColor, selectedColor); + return; + } + + if (!isTilemap && /^Digit\d$/.test(event.code)) { + const keyAsNum = +event.code.slice(-1); + const color = keyAsNum + (event.shiftKey ? 9 : 0); + // TODO: if we need to generalize for different numbers of colors, + // will need to fix the magic 16 here + if (color >= 0 && color < 16) + setColor(color); + } + } + + function handleUndoRedo(event: KeyboardEvent) { + const controlOrMeta = event.ctrlKey || event.metaKey; // ctrl on windows, meta on mac + if (event.key === "Undo" || (controlOrMeta && event.key === "z" && !event.shiftKey)) { + undo(); + event.preventDefault(); + event.stopPropagation(); + } else if (event.key === "Redo" || (controlOrMeta && event.key === "y") || (controlOrMeta && event.key === "Z" && event.shiftKey)) { + redo(); + event.preventDefault(); + event.stopPropagation(); + } + } + + function overrideBlocklyShortcuts(event: KeyboardEvent) { + if (event.key === "Backspace" || event.key === "Delete") { + event.stopPropagation(); + } + } + + function currentEditState(): [EditState, "tilemap" | "animation" | "image"] { + if (isTilemap) { + return [getEditState(tilemapState.tilemap, true, drawingMode), "tilemap"] + } + else { + return [getEditState(animationState.frames[animationState.currentFrame], false, drawingMode), animationState.frames.length > 1 ? "animation" : "image" ] + } + } + + function undo() { + dispatch(undoImageEdit()); + } + + function redo() { + dispatch(redoImageEdit()); + } + + function setTool(tool: ImageEditorTool) { + dispatch(changeImageTool(tool)); + } + + function setColor(selectedColor: number) { + dispatch(changeSelectedColor(selectedColor)); + } + + function zoom(delta: number) { + dispatch(changeCanvasZoom(delta)); + } + + function swapForegroundBackgroundColors() { + dispatch(swapForegroundBackground()); + } + + function changeCursor(larger: boolean) { + let nextSize: CursorSize; + const currentSize = cursorSize; + + switch (currentSize) { + case CursorSize.One: + nextSize = larger ? CursorSize.Three : CursorSize.One; + break; + case CursorSize.Three: + nextSize = larger ? CursorSize.Five : CursorSize.One; + break; + case CursorSize.Five: + nextSize = larger ? CursorSize.Five : CursorSize.Three; + } + + if (currentSize !== nextSize) { + dispatch(changeCursorSize(nextSize)); + } + } + + function flip(vertical: boolean) { + const [ editState, type ] = currentEditState(); + const flipped = flipEdit(editState, vertical, type === "tilemap"); + dispatch(imageEdit(flipped.toImageState())); + } + + function rotate(clockwise: boolean) { + const [ editState, type ] = currentEditState(); + const rotated = rotateEdit(editState, clockwise, type === "tilemap", type === "animation"); + dispatch(imageEdit(rotated.toImageState())); + } + + function outline(color: number) { + const [ editState, type ] = currentEditState(); + + if (type === "tilemap") return; + + const outlined = outlineEdit(editState, color); + dispatch(imageEdit(outlined.toImageState())); + } + + function replaceColor(fromColor: number, toColor: number) { + const [ editState, type ] = currentEditState(); + const replaced = replaceColorEdit(editState, fromColor, toColor); + dispatch(imageEdit(replaced.toImageState())); + } + + document.addEventListener("keydown", handleKeyDown); + document.addEventListener("keydown", handleUndoRedo, true); + document.addEventListener("keydown", overrideBlocklyShortcuts, true); + + return () => { + document.removeEventListener("keydown", handleKeyDown); + document.removeEventListener("keydown", handleUndoRedo, true); + document.removeEventListener("keydown", overrideBlocklyShortcuts, true); + } + }, [selectedColor, backgroundColor, isTilemap, drawingMode, cursorSize, animationState, tilemapState]); } // Disables shortcuts and returns a ref. Enable by passing the ref to release shortcut lock @@ -40,199 +227,3 @@ export function releaseShortcutLock(ref: number) { export function areShortcutsEnabled() { return !lockRefs.length; } - -export function setStore(newStore?: Store) { - store = newStore || mainStore; -} - -function handleUndoRedo(event: KeyboardEvent) { - const controlOrMeta = event.ctrlKey || event.metaKey; // ctrl on windows, meta on mac - if (event.key === "Undo" || (controlOrMeta && event.key === "z" && !event.shiftKey)) { - undo(); - event.preventDefault(); - event.stopPropagation(); - } else if (event.key === "Redo" || (controlOrMeta && event.key === "y") || (controlOrMeta && event.key === "Z" && event.shiftKey)) { - redo(); - event.preventDefault(); - event.stopPropagation(); - } -} - -function overrideBlocklyShortcuts(event: KeyboardEvent) { - if (event.key === "Backspace" || event.key === "Delete") { - event.stopPropagation(); - } -} - -function handleKeyDown(event: KeyboardEvent) { - if (!areShortcutsEnabled()) return; - - if (event.shiftKey && /^(?:Digit[1-9])|(?:Key[A-F])$/.test(event.code)) { - if (event.code.indexOf("Digit") == 0) outline(parseInt(event.code.substring(5))) - else outline(parseInt(event.code.substring(3), 16)); - return; - } - - // Mostly copied from the photoshop shortcuts - switch (event.key) { - case "e": - setTool(ImageEditorTool.Erase); - break; - case "h": - setTool(ImageEditorTool.Pan); - break; - case "b": - case "p": - setTool(ImageEditorTool.Paint); - break; - case "g": - setTool(ImageEditorTool.Fill); - break; - case "m": - setTool(ImageEditorTool.Marquee); - break; - case "u": - setTool(ImageEditorTool.Rect); - break; - case "l": - setTool(ImageEditorTool.Line); - break; - case "c": - setTool(ImageEditorTool.Circle); - break; - case "-": - case "_": - zoom(-1); - break; - case "=": - case "+": - zoom(1); - break; - case "x": - swapForegroundBackground(); - break; - case "H": - flip(false); - break; - case "V": - flip(true); - break; - case "[": - rotate(false); - break; - case "]": - rotate(true); - break; - case ">": - changeCursorSize(true); - break; - case "<": - changeCursorSize(false); - 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); - // TODO: if we need to generalize for different numbers of colors, - // will need to fix the magic 16 here - if (color >= 0 && color < 16) - setColor(color); - } -} - -function currentEditState(): [EditState, "tilemap" | "animation" | "image"] { - const state = store.getState(); - - if (state.editor.isTilemap) { - const tilemapState = state.store.present as TilemapState; - return [getEditState(tilemapState.tilemap, true, state.editor.drawingMode), "tilemap"] - } - else { - const animationState = state.store.present as AnimationState; - return [getEditState(animationState.frames[animationState.currentFrame], false, state.editor.drawingMode), animationState.frames.length > 1 ? "animation" : "image" ] - } -} - -function undo() { - dispatchAction(dispatchUndoImageEdit()); -} - -function redo() { - dispatchAction(dispatchRedoImageEdit()); -} - -function setTool(tool: ImageEditorTool) { - dispatchAction(dispatchChangeImageTool(tool)); -} - -function setColor(selectedColor: number) { - dispatchAction(dispatchChangeSelectedColor(selectedColor)) -} - -function zoom(delta: number) { - dispatchAction(dispatchChangeZoom(delta)); -} - -function swapForegroundBackground() { - dispatchAction(dispatchSwapBackgroundForeground()); -} - -function dispatchAction(action: any) { - store.dispatch(action); -} - -function changeCursorSize(larger: boolean) { - let nextSize: CursorSize; - const currentSize = store.getState().editor.cursorSize; - - switch (currentSize) { - case CursorSize.One: - nextSize = larger ? CursorSize.Three : CursorSize.One; - break; - case CursorSize.Three: - nextSize = larger ? CursorSize.Five : CursorSize.One; - break; - case CursorSize.Five: - nextSize = larger ? CursorSize.Five : CursorSize.Three; - } - - if (currentSize !== nextSize) { - dispatchAction(dispatchChangeCursorSize(nextSize)); - } -} - -export function flip(vertical: boolean) { - const [ editState, type ] = currentEditState(); - const flipped = flipEdit(editState, vertical, type === "tilemap"); - dispatchAction(dispatchImageEdit(flipped.toImageState())); -} - -export function rotate(clockwise: boolean) { - const [ editState, type ] = currentEditState(); - const rotated = rotateEdit(editState, clockwise, type === "tilemap", type === "animation"); - dispatchAction(dispatchImageEdit(rotated.toImageState())); -} - -export function outline(color: number) { - const [ editState, type ] = currentEditState(); - - if (type === "tilemap") return; - - const outlined = outlineEdit(editState, color); - dispatchAction(dispatchImageEdit(outlined.toImageState())); -} - -export function replaceColor(fromColor: number, toColor: number) { - const [ editState, type ] = currentEditState(); - const replaced = replaceColorEdit(editState, fromColor, toColor); - dispatchAction(dispatchImageEdit(replaced.toImageState())); -} \ No newline at end of file diff --git a/webapp/src/components/ImageEditor/sprite/Palette.tsx b/webapp/src/components/ImageEditor/sprite/Palette.tsx index 43da36da74f7..3ec906f49a53 100644 --- a/webapp/src/components/ImageEditor/sprite/Palette.tsx +++ b/webapp/src/components/ImageEditor/sprite/Palette.tsx @@ -1,29 +1,40 @@ import * as React from 'react'; -import { connect } from 'react-redux'; -import { ImageEditorStore, AnimationState } from '../store/imageReducer'; -import { dispatchChangeSelectedColor, dispatchChangeBackgroundColor, dispatchSwapBackgroundForeground } from '../actions/dispatch'; +import { changeBackgroundColor, changeSelectedColor, ImageEditorContext, swapForegroundBackground } from '../state'; -export interface PaletteProps { - colors: string[]; - selected: number; - backgroundColor: number; - dispatchChangeSelectedColor: (index: number) => void; - dispatchChangeBackgroundColor: (index: number) => void; - dispatchSwapBackgroundForeground: () => void; -} -class PaletteImpl extends React.Component { - protected handlers: ((ev: React.MouseEvent) => void)[] = []; +export const Palette = () => { + const { state, dispatch } = React.useContext(ImageEditorContext); + + const { selectedColor, backgroundColor } = state.editor + const { colors } = state.store.present; + + const SPACER = 1; + const HEIGHT = 10; + + const width = 3 * SPACER + 2 * HEIGHT; + + const onColorSelected = (index: number, ev: React.MouseEvent) => { + if (ev.button === 0) { + dispatch(changeSelectedColor(index)); + } + else { + dispatch(changeBackgroundColor(index)); + ev.preventDefault(); + ev.stopPropagation(); + } + } - render() { - const { colors, selected, backgroundColor, dispatchSwapBackgroundForeground } = this.props; - const SPACER = 1; - const HEIGHT = 10; + const preventContextMenu = React.useCallback((ev: React.MouseEvent) => { + ev.preventDefault(); + }, []) - const width = 3 * SPACER + 2 * HEIGHT; + const onBackgroundForegroundClick = React.useCallback(() => { + dispatch(swapForegroundBackground()); + }, [dispatch]) - return
- + return ( +
+ @@ -44,7 +55,7 @@ class PaletteImpl extends React.Component { {colorTooltip(backgroundColor, colors[backgroundColor])} { stroke="#3c3c3c" strokeWidth="0.5" > - {colorTooltip(selected, colors[selected])} + {colorTooltip(selectedColor, colors[selectedColor])} -
- {this.props.colors.map((color, index) => { - return
+ {colors.map((color, index) => +
- })} + onMouseDown={ev => onColorSelected(index, ev)} + style={index === 0 ? null : { backgroundColor: color }} + /> + )}
-
; - } - - protected clickHandler(index: number) { - if (!this.handlers[index]) this.handlers[index] = (ev: React.MouseEvent) => { - if (ev.button === 0) { - this.props.dispatchChangeSelectedColor(index); - } - else { - this.props.dispatchChangeBackgroundColor(index); - ev.preventDefault(); - ev.stopPropagation(); - } - } - - return this.handlers[index]; - } - - protected preventContextMenu = (ev: React.MouseEvent) => ev.preventDefault(); +
+ ); } function colorTooltip(index: number, color: string) { @@ -134,22 +129,3 @@ function hexToNamedColor(color: string) { return undefined; } } - -function mapStateToProps({ store: { present }, editor }: ImageEditorStore, ownProps: any) { - let state = (present as AnimationState); - if (!state) return {}; - return { - selected: editor.selectedColor, - backgroundColor: editor.backgroundColor, - colors: state.colors - }; -} - -const mapDispatchToProps = { - dispatchChangeSelectedColor, - dispatchChangeBackgroundColor, - dispatchSwapBackgroundForeground, -}; - - -export const Palette = connect(mapStateToProps, mapDispatchToProps)(PaletteImpl); \ No newline at end of file diff --git a/webapp/src/components/ImageEditor/state/actions.ts b/webapp/src/components/ImageEditor/state/actions.ts new file mode 100644 index 000000000000..0a1d5724305a --- /dev/null +++ b/webapp/src/components/ImageEditor/state/actions.ts @@ -0,0 +1,466 @@ +import { AlertOption } from "../Alert"; +import { AnimationState, CursorSize, EditorState, GalleryTile, ImageEditorTool, TileCategory, TileDrawingMode } from "./state"; + +type ActionBase = { + type: string; +} + +type SetInitialState = ActionBase & { + type: "SET_INITIAL_STATE"; + state: EditorState; + past: AnimationState[]; +} + +type SetFrames = ActionBase & { + type: "SET_FRAMES"; + frames: pxt.sprite.ImageState[]; +} + +type ChangeImageTool = ActionBase & { + type: "CHANGE_IMAGE_TOOL"; + tool: ImageEditorTool; +} + +type ChangeCursorSize = ActionBase & { + type: "CHANGE_CURSOR_SIZE"; + cursorSize: CursorSize; +} + +type ChangeSelectedColor = ActionBase & { + type: "CHANGE_SELECTED_COLOR"; + selectedColor: number; +} + +type ChangeImageDimensions = ActionBase & { + type: "CHANGE_IMAGE_DIMENSIONS"; + width: number; + height: number; +} + +type ChangeKeyModifiers = ActionBase & { + type: "CHANGE_KEY_MODIFIERS"; + keyModifiers: number; +} + +type ChangeCursorLocation = ActionBase & { + type: "CHANGE_CURSOR_LOCATION"; + cursor: [number, number]; +} + +type ImageEdit = ActionBase & { + type: "IMAGE_EDIT"; + newState: pxt.sprite.ImageState; +} + +type UndoImageEdit = ActionBase & { + type: "UNDO_IMAGE_EDIT"; +} + +type RedoImageEdit = ActionBase & { + type: "REDO_IMAGE_EDIT"; +} + +type ToggleAspectRatio = ActionBase & { + type: "TOGGLE_ASPECT_RATIO"; +} + +type SetGalleryOpen = ActionBase & { + type: "SET_GALLERY_OPEN"; + isOpen: boolean; +} + +type NewFrame = ActionBase & { + type: "NEW_FRAME"; + index?: number; +} + +type DeleteFrame = ActionBase & { + type: "DELETE_FRAME"; + index: number; +} + +type DuplicateFrame = ActionBase & { + type: "DUPLICATE_FRAME"; + index: number; +} + +type MoveFrame = ActionBase & { + type: "MOVE_FRAME"; + oldIndex: number; + newIndex: number; +} + +type ChangeCurrentFrame = ActionBase & { + type: "CHANGE_CURRENT_FRAME"; + index: number; +} + +type ChangeInterval = ActionBase & { + type: "CHANGE_INTERVAL"; + interval: number; +} + +type ChangePreviewAnimating = ActionBase & { + type: "CHANGE_PREVIEW_ANIMATING"; + isAnimating: boolean; +} + +type ToggleOnionSkinEnabled = ActionBase & { + type: "TOGGLE_ONION_SKIN_ENABLED"; +} + +type ChangeOverlayEnabled = ActionBase & { + type: "CHANGE_OVERLAY_ENABLED"; + isEnabled: boolean; +} + +type ChangeCanvasZoom = ActionBase & { + type: "CHANGE_CANVAS_ZOOM"; + zoom: number; +} + +type ShowAlert = ActionBase & { + type: "SHOW_ALERT"; + title: string; + text: string; + options?: AlertOption[] +} + +type HideAlert = ActionBase & { + type: "HIDE_ALERT"; +} + +type SwapForegroundBackground = ActionBase & { + type: "SWAP_FOREGROUND_BACKGROUND"; +} + +type ChangeBackgroundColor = ActionBase & { + type: "CHANGE_BACKGROUND_COLOR"; + color: number; +} + +type ChangeTilePalettePage = ActionBase & { + type: "CHANGE_TILE_PALETTE_PAGE"; + page: number; +} + +type ChangeTilePaletteCategory = ActionBase & { + type: "CHANGE_TILE_PALETTE_CATEGORY"; + category: TileCategory; +} + +type ChangeDrawingMode = ActionBase & { + type: "CHANGE_DRAWING_MODE"; + drawingMode: TileDrawingMode; +} + +type CreateNewTile = ActionBase & { + type: "CREATE_NEW_TILE"; + tile: pxt.Tile; + foreground: number; + background: number; + qualifiedName?: string; +} + +type OpenTileEditor = ActionBase & { + type: "OPEN_TILE_EDITOR"; + index?: number; + id?: string; +} + +type CloseTileEditor = ActionBase & { + type: "CLOSE_TILE_EDITOR"; + result?: pxt.Tile; + index?: number; +} + +type DeleteTile = ActionBase & { + type: "DELETE_TILE"; + index: number; + id: string; +} + +type DisableResize = ActionBase & { + type: "DISABLE_RESIZE"; +} + +type ChangeAssetName = ActionBase & { + type: "CHANGE_ASSET_NAME"; + name: string; +} + +type OpenAsset = ActionBase & { + type: "OPEN_ASSET"; + asset: pxt.Asset; + keepPast: boolean; + gallery?: GalleryTile[]; +} + +export type Action = + | SetInitialState + | SetFrames + | ChangeImageTool + | ChangeCursorSize + | ChangeSelectedColor + | ChangeImageDimensions + | ChangeKeyModifiers + | ChangeCursorLocation + | ImageEdit + | UndoImageEdit + | RedoImageEdit + | ToggleAspectRatio + | SetGalleryOpen + | NewFrame + | DeleteFrame + | DuplicateFrame + | MoveFrame + | ChangeCurrentFrame + | ChangeInterval + | ChangePreviewAnimating + | ToggleOnionSkinEnabled + | ChangeOverlayEnabled + | ChangeCanvasZoom + | ShowAlert + | HideAlert + | SwapForegroundBackground + | ChangeBackgroundColor + | ChangeTilePalettePage + | ChangeTilePaletteCategory + | ChangeDrawingMode + | CreateNewTile + | OpenTileEditor + | CloseTileEditor + | DeleteTile + | DisableResize + | ChangeAssetName + | OpenAsset + +const setInitialState = (state: EditorState, past: AnimationState[]): SetInitialState => ({ + type: "SET_INITIAL_STATE", + state, + past +}); + +const setFrames = (frames: pxt.sprite.ImageState[]): SetFrames => ({ + type: "SET_FRAMES", + frames +}); + +const changeImageTool = (tool: ImageEditorTool): ChangeImageTool => ({ + type: "CHANGE_IMAGE_TOOL", + tool +}); + +const changeCursorSize = (cursorSize: CursorSize): ChangeCursorSize => ({ + type: "CHANGE_CURSOR_SIZE", + cursorSize +}); + +const changeSelectedColor = (selectedColor: number): ChangeSelectedColor => ({ + type: "CHANGE_SELECTED_COLOR", + selectedColor +}); + +const changeImageDimensions = (width: number, height: number): ChangeImageDimensions => ({ + type: "CHANGE_IMAGE_DIMENSIONS", + width, + height +}); + +const changeKeyModifiers = (keyModifiers: number): ChangeKeyModifiers => ({ + type: "CHANGE_KEY_MODIFIERS", + keyModifiers +}); + +const changeCursorLocation = (cursor: [number, number]): ChangeCursorLocation => ({ + type: "CHANGE_CURSOR_LOCATION", + cursor +}); + +const imageEdit = (newState: pxt.sprite.ImageState): ImageEdit => ({ + type: "IMAGE_EDIT", + newState +}); + +const undoImageEdit = (): UndoImageEdit => ({ + type: "UNDO_IMAGE_EDIT", +}); + +const redoImageEdit = (): RedoImageEdit => ({ + type: "REDO_IMAGE_EDIT", +}); + +const toggleAspectRatio = (): ToggleAspectRatio => ({ + type: "TOGGLE_ASPECT_RATIO", +}); + +const setGalleryOpen = (isOpen: boolean): SetGalleryOpen => ({ + type: "SET_GALLERY_OPEN", + isOpen +}); + +const newFrame = (): NewFrame => ({ + type: "NEW_FRAME", +}); + +const deleteFrame = (index: number): DeleteFrame => ({ + type: "DELETE_FRAME", + index +}); + +const duplicateFrame = (index: number): DuplicateFrame => ({ + type: "DUPLICATE_FRAME", + index +}); + +const moveFrame = (oldIndex: number, newIndex: number): MoveFrame => ({ + type: "MOVE_FRAME", + oldIndex, + newIndex +}); + +const changeCurrentFrame = (index: number): ChangeCurrentFrame => ({ + type: "CHANGE_CURRENT_FRAME", + index +}); + +const changeInterval = (interval: number): ChangeInterval => ({ + type: "CHANGE_INTERVAL", + interval +}); + +const changePreviewAnimating = (isAnimating: boolean): ChangePreviewAnimating => ({ + type: "CHANGE_PREVIEW_ANIMATING", + isAnimating +}); + +const toggleOnionSkinEnabled = (): ToggleOnionSkinEnabled => ({ + type: "TOGGLE_ONION_SKIN_ENABLED", +}); + +const changeOverlayEnabled = (isEnabled: boolean): ChangeOverlayEnabled => ({ + type: "CHANGE_OVERLAY_ENABLED", + isEnabled +}); + +const changeCanvasZoom = (zoom: number): ChangeCanvasZoom => ({ + type: "CHANGE_CANVAS_ZOOM", + zoom +}); + +const showAlert = (title: string, text: string, options?: AlertOption[]): ShowAlert => ({ + type: "SHOW_ALERT", + title, + text, + options +}); + +const hideAlert = (): HideAlert => ({ + type: "HIDE_ALERT", +}); + +const swapForegroundBackground = (): SwapForegroundBackground => ({ + type: "SWAP_FOREGROUND_BACKGROUND", +}); + +const changeBackgroundColor = (color: number): ChangeBackgroundColor => ({ + type: "CHANGE_BACKGROUND_COLOR", + color +}); + +const changeTilePalettePage = (page: number): ChangeTilePalettePage => ({ + type: "CHANGE_TILE_PALETTE_PAGE", + page +}); + +const changeTilePaletteCategory = (category: TileCategory): ChangeTilePaletteCategory => ({ + type: "CHANGE_TILE_PALETTE_CATEGORY", + category +}); + +const changeDrawingMode = (drawingMode: TileDrawingMode): ChangeDrawingMode => ({ + type: "CHANGE_DRAWING_MODE", + drawingMode +}); + +const createNewTile = (tile: pxt.Tile, foreground: number, background: number, qualifiedName?: string): CreateNewTile => ({ + type: "CREATE_NEW_TILE", + tile, + foreground, + background, + qualifiedName +}); + +const openTileEditor = (index?: number, id?: string): OpenTileEditor => ({ + type: "OPEN_TILE_EDITOR", + index, + id +}); + +const closeTileEditor = (result?: pxt.Tile, index?: number): CloseTileEditor => ({ + type: "CLOSE_TILE_EDITOR", + result, + index +}); + +const deleteTile = (index: number, id: string): DeleteTile => ({ + type: "DELETE_TILE", + index, + id +}); + +const disableResize = (): DisableResize => ({ + type: "DISABLE_RESIZE", +}); + +const changeAssetName = (name: string): ChangeAssetName => ({ + type: "CHANGE_ASSET_NAME", + name +}); + +const openAsset = (asset: pxt.Asset, keepPast: boolean, gallery?: GalleryTile[]): OpenAsset => ({ + type: "OPEN_ASSET", + asset, + keepPast, + gallery +}); + +export { + setInitialState, + setFrames, + changeImageTool, + changeCursorSize, + changeSelectedColor, + changeImageDimensions, + changeKeyModifiers, + changeCursorLocation, + imageEdit, + undoImageEdit, + redoImageEdit, + toggleAspectRatio, + setGalleryOpen, + newFrame, + deleteFrame, + duplicateFrame, + moveFrame, + changeCurrentFrame, + changeInterval, + changePreviewAnimating, + toggleOnionSkinEnabled, + changeOverlayEnabled, + changeCanvasZoom, + showAlert, + hideAlert, + swapForegroundBackground, + changeBackgroundColor, + changeTilePalettePage, + changeTilePaletteCategory, + changeDrawingMode, + createNewTile, + openTileEditor, + closeTileEditor, + deleteTile, + disableResize, + changeAssetName, + openAsset, +} \ No newline at end of file diff --git a/webapp/src/components/ImageEditor/state/context.ts b/webapp/src/components/ImageEditor/state/context.ts new file mode 100644 index 000000000000..2e07cb4bc20b --- /dev/null +++ b/webapp/src/components/ImageEditor/state/context.ts @@ -0,0 +1,36 @@ +import { createContext, Dispatch } from "react"; +import { ImageEditorStore } from "./state"; +import { Action } from "./actions"; + +let state: ImageEditorStore; +let dispatch: Dispatch; + +let initializationComplete: () => void; + +// This promise will resolve when the app state is initialized and available outside the React context. +export const AppStateReady: Promise = new Promise(resolve => { + initializationComplete = () => resolve(true); +}); + +// Never cache `state` and `dispatch`. They can and will be updated frequently. +export const stateAndDispatch = () => { + return { state, dispatch }; +}; + +type ImageEditorContextProps = { + state: ImageEditorStore; + dispatch: Dispatch; +}; + +const initialAppStateContextProps: ImageEditorContextProps = { + state: undefined!, + dispatch: undefined!, +}; + +export const _useStateAndDispatch = (state_: ImageEditorStore, dispatch_: Dispatch) => { + state = state_; + dispatch = dispatch_; + initializationComplete(); +} + +export const ImageEditorContext = createContext(initialAppStateContextProps); diff --git a/webapp/src/components/ImageEditor/state/index.ts b/webapp/src/components/ImageEditor/state/index.ts new file mode 100644 index 000000000000..f1bcf6de21b2 --- /dev/null +++ b/webapp/src/components/ImageEditor/state/index.ts @@ -0,0 +1,6 @@ +export * from "./provider"; +export * from "./actions"; + +export * from "./state"; +export { stateAndDispatch, AppStateReady, ImageEditorContext } from "./context"; +export { setTelemetryFunction } from "./reducer"; \ No newline at end of file diff --git a/webapp/src/components/ImageEditor/state/provider.tsx b/webapp/src/components/ImageEditor/state/provider.tsx new file mode 100644 index 000000000000..db7d3a6c15e3 --- /dev/null +++ b/webapp/src/components/ImageEditor/state/provider.tsx @@ -0,0 +1,46 @@ +import { useReducer } from "react"; +import { ImageEditorContext, _useStateAndDispatch } from "./context"; +import reducer from "./reducer"; +import { initialStore } from "./state"; + +export function ImageEditorStateProvider(props: React.PropsWithChildren): React.ReactElement { + // Create the application state and state change mechanism (dispatch) + const [state_, dispatch_] = useReducer(reducer, { + ...initialStore + }); + + // Make state and dispatch available outside the React context + _useStateAndDispatch(state_, dispatch_); + + return ( + // Provide current state and dispatch mechanism to all child components + + {props.children} + + ); +} + + +export function TileEditorStateProvider(props: React.PropsWithChildren): React.ReactElement { + // Create the application state and state change mechanism (dispatch) + const [state_, dispatch_] = useReducer(reducer, { + ...initialStore + }); + + return ( + // Provide current state and dispatch mechanism to all child components + + {props.children} + + ); +} diff --git a/webapp/src/components/ImageEditor/store/imageReducer.ts b/webapp/src/components/ImageEditor/state/reducer.ts similarity index 72% rename from webapp/src/components/ImageEditor/store/imageReducer.ts rename to webapp/src/components/ImageEditor/state/reducer.ts index 97762decc714..443d2c27bc24 100644 --- a/webapp/src/components/ImageEditor/store/imageReducer.ts +++ b/webapp/src/components/ImageEditor/state/reducer.ts @@ -1,206 +1,34 @@ -import { lookupAsset } from '../../../assets'; -import * as actions from '../actions/types' -import { AlertInfo } from '../Alert'; - -export enum ImageEditorTool { - Paint, - Fill, - Line, - Erase, - Circle, - Rect, - ColorSelect, - Marquee, - Pan -} - -export const enum CursorSize { - One = 1, - Three = 3, - Five = 5 -} - -export const enum KeyModifiers { - Alt = 1 << 0, - Shift = 1 << 1 -} - -export enum TileCategory { - Forest, - Aquatic, - Dungeon, - Misc, -} - -export enum TileDrawingMode { - Default = "default", - Wall = "wall" -} - -// State that goes on the undo/redo stack -export interface AnimationState { - kind: "Animation"; - asset?: pxt.Asset; - visible: boolean; - colors: string[]; - - aspectRatioLocked: boolean; - - currentFrame: number; - frames: pxt.sprite.ImageState[]; - interval: number; -} - -export interface TilemapState { - kind: "Tilemap"; - asset?: pxt.Asset; - tileset: pxt.TileSet; - aspectRatioLocked: boolean; - tilemap: pxt.sprite.ImageState; - colors: string[]; - nextId: number; -} - -// State that is not on the undo/redo stack -export interface EditorState { - selectedColor: number; - backgroundColor: number; - - tilemapPalette?: TilemapPaletteState; - tileGalleryOpen?: boolean; - - isTilemap: boolean; - referencedTiles?: string[]; - deletedTiles?: string[]; - editedTiles?: string[]; - - // The state below this comment is not persisted between editor reloads - previewAnimating: boolean; - tool: ImageEditorTool; - cursorLocation?: [number, number]; - zoomDelta?: number; - onionSkinEnabled: boolean; - drawingMode?: TileDrawingMode; - tileGallery?: GalleryTile[]; - editingTile?: TileEditContext; - cursorSize: CursorSize; - overlayEnabled?: boolean; - alert?: AlertInfo; - resizeDisabled?: boolean; - tilesetRevision: number; // used to track changes to the tileset and invalidate the tile cache in ImageCanvas -} - -export interface GalleryTile { - qualifiedName: string; - bitmap: pxt.sprite.BitmapData; - tags: string[]; - tileWidth: number; -} - -export interface TilemapPaletteState { - category: TileCategory; - page: number; -} +import { lookupAsset } from "../../../assets"; +import { Action } from "./actions"; +import { AnimationState, EditorState, EditorStore, GalleryTile, ImageEditorStore, ImageEditorTool, initialStore, TileCategory, TileDrawingMode, TilemapState } from "./state"; -export interface ImageEditorStore { - store: EditorStore; - - editor: EditorState; -} - -export interface TileEditContext { - type: "new" | "edit"; - tilesetIndex: number; -} - -export interface EditorStore { - past: (AnimationState | TilemapState)[]; - present: AnimationState | TilemapState; - future: (AnimationState | TilemapState)[]; -} - -export interface AnimationStore { -} - -export interface TilemapStore { - past: TilemapState[]; - present: TilemapState; - future: TilemapState[]; -} - -const initialState: AnimationState = { - kind: "Animation", - visible: true, - colors: [ - "#000000", - "#ffffff", - "#ff2121", - "#ff93c4", - "#ff8135", - "#fff609", - "#249ca3", - "#78dc52", - "#003fad", - "#87f2ff", - "#8e2ec4", - "#a4839f", - "#5c406c", - "#e5cdc4", - "#91463d", - "#000000" - ], - - aspectRatioLocked: false, - - currentFrame: 0, - frames: [emptyFrame(16, 16)], - interval: 200 -} - -const initialStore: ImageEditorStore = { - store: { - present: initialState, - past: [], - future: [], - }, - editor: { - selectedColor: 3, - tool: ImageEditorTool.Paint, - cursorSize: CursorSize.One, - backgroundColor: 1, - previewAnimating: false, - onionSkinEnabled: false, - overlayEnabled: true, - tilesetRevision: 0, - isTilemap: false - } -} +let tickCallback: (event: string) => void; -const topReducer = (state: ImageEditorStore = initialStore, action: any): ImageEditorStore => { +export default function reducer(state: ImageEditorStore, action: Action): ImageEditorStore { switch (action.type) { - case actions.OPEN_TILE_EDITOR: - case actions.CHANGE_PREVIEW_ANIMATING: - case actions.CHANGE_CANVAS_ZOOM: - case actions.CHANGE_IMAGE_TOOL: - case actions.CHANGE_CURSOR_SIZE: - case actions.CHANGE_SELECTED_COLOR: - case actions.CHANGE_CURSOR_LOCATION: - case actions.CHANGE_BACKGROUND_COLOR: - case actions.SWAP_FOREGROUND_BACKGROUND: - case actions.TOGGLE_ONION_SKIN_ENABLED: - case actions.CHANGE_OVERLAY_ENABLED: - case actions.CHANGE_TILE_PALETTE_PAGE: - case actions.CHANGE_TILE_PALETTE_CATEGORY: - case actions.CHANGE_DRAWING_MODE: - case actions.SET_GALLERY_OPEN: - case actions.SHOW_ALERT: - case actions.HIDE_ALERT: - case actions.DISABLE_RESIZE: + case "OPEN_TILE_EDITOR": + case "CHANGE_PREVIEW_ANIMATING": + case "CHANGE_CANVAS_ZOOM": + case "CHANGE_IMAGE_TOOL": + case "CHANGE_CURSOR_SIZE": + case "CHANGE_SELECTED_COLOR": + case "CHANGE_CURSOR_LOCATION": + case "CHANGE_BACKGROUND_COLOR": + case "SWAP_FOREGROUND_BACKGROUND": + case "TOGGLE_ONION_SKIN_ENABLED": + case "CHANGE_OVERLAY_ENABLED": + case "CHANGE_TILE_PALETTE_PAGE": + case "CHANGE_TILE_PALETTE_CATEGORY": + case "CHANGE_DRAWING_MODE": + case "SET_GALLERY_OPEN": + case "SHOW_ALERT": + case "HIDE_ALERT": + case "DISABLE_RESIZE": return { ...state, editor: editorReducer(state.editor, action, state.store) }; - case actions.SET_INITIAL_STATE: + case "SET_INITIAL_STATE": const restored: EditorState = action.state; return { ...state, @@ -220,7 +48,7 @@ const topReducer = (state: ImageEditorStore = initialStore, action: any): ImageE future: action.past ? [] : state.store.future } }; - case actions.OPEN_ASSET: + case "OPEN_ASSET": const toOpen: pxt.Asset = action.asset; const gallery = action.gallery || (action.keepPast ? state.editor.tileGallery : []) @@ -228,10 +56,10 @@ const topReducer = (state: ImageEditorStore = initialStore, action: any): ImageE if (toOpen.type === pxt.AssetType.Tilemap) { // Add the first gallery tile to the tileset so the editor will have a default // selected color. If unused, this will be trimmed when the editor is closed. - const tilemapData = action.asset.data as pxt.sprite.TilemapData; + const tilemapData = toOpen.data; if (pxt.sprite.isEmptyTilemap(tilemapData)) { - const tiles = tilemapData.tileset.tiles as pxt.Tile[] || []; - const firstTileName = (gallery as GalleryTile[]).find(t => t.tags.indexOf("forest") !== -1)?.qualifiedName; + const tiles = tilemapData.tileset.tiles || []; + const firstTileName = gallery.find(t => t.tags.indexOf("forest") !== -1)?.qualifiedName; const firstTile = lookupAsset(pxt.AssetType.Tile, firstTileName) as pxt.Tile; if (firstTile && !tiles.find(t => t.id === firstTileName)) { tiles.push(firstTile); @@ -285,7 +113,7 @@ const topReducer = (state: ImageEditorStore = initialStore, action: any): ImageE } }; - case actions.CHANGE_ASSET_NAME: + case "CHANGE_ASSET_NAME": tickEvent("change-asset-name"); return { ...state, @@ -304,7 +132,7 @@ const topReducer = (state: ImageEditorStore = initialStore, action: any): ImageE future: [] } }; - case actions.UNDO_IMAGE_EDIT: + case "UNDO_IMAGE_EDIT": if (!state.store.past.length) return state; tickEvent(`undo`); @@ -321,7 +149,7 @@ const topReducer = (state: ImageEditorStore = initialStore, action: any): ImageE tilesetRevision: state.editor.tilesetRevision + 1 } }; - case actions.REDO_IMAGE_EDIT: + case "REDO_IMAGE_EDIT": if (!state.store.future.length) return state; tickEvent(`redo`); @@ -353,20 +181,20 @@ const topReducer = (state: ImageEditorStore = initialStore, action: any): ImageE } -const animationReducer = (state: AnimationState, action: any): AnimationState => { +const animationReducer = (state: AnimationState, action: Action): AnimationState => { switch (action.type) { - case actions.TOGGLE_ASPECT_RATIO: + case "TOGGLE_ASPECT_RATIO": tickEvent(`toggle-aspect-ratio-lock`); return { ...state, aspectRatioLocked: !state.aspectRatioLocked }; - case actions.CHANGE_CURRENT_FRAME: + case "CHANGE_CURRENT_FRAME": tickEvent(`change-frame`); return { ...state, currentFrame: action.index }; - case actions.CHANGE_INTERVAL: + case "CHANGE_INTERVAL": tickEvent(`change-interval`); - return { ...state, interval: action.newInterval }; - case actions.CHANGE_IMAGE_DIMENSIONS: + return { ...state, interval: action.interval }; + case "CHANGE_IMAGE_DIMENSIONS": tickEvent(`change-dimensions`); - const [width, height] = action.imageDimensions as [number, number]; + const { width, height } = action; return { ...state, frames: state.frames.map((frame, index) => ({ @@ -374,14 +202,14 @@ const animationReducer = (state: AnimationState, action: any): AnimationState => bitmap: pxt.sprite.Bitmap.fromData(frame.bitmap).resize(width, height).data() })) }; - case actions.IMAGE_EDIT: + case "IMAGE_EDIT": tickEvent(`image-edit`); return { ...state, frames: state.frames.map((frame, index) => ( index === state.currentFrame ? action.newState : frame)) }; - case actions.DELETE_FRAME: + case "DELETE_FRAME": if (state.frames.length === 1) return state; tickEvent(`delete-frame`); @@ -398,7 +226,7 @@ const animationReducer = (state: AnimationState, action: any): AnimationState => currentFrame: newFrame, frames: newFrames } - case actions.DUPLICATE_FRAME: + case "DUPLICATE_FRAME": tickEvent(`duplicate-frame`); const frames = state.frames.slice(); frames.splice(action.index, 0, cloneImage(state.frames[action.index])) @@ -407,14 +235,14 @@ const animationReducer = (state: AnimationState, action: any): AnimationState => frames, currentFrame: action.index + 1 }; - case actions.NEW_FRAME: + case "NEW_FRAME": tickEvent(`new-frame`); return { ...state, frames: [...state.frames, emptyFrame(state.frames[0].bitmap.width, state.frames[0].bitmap.height)], currentFrame: state.frames.length, }; - case actions.MOVE_FRAME: + case "MOVE_FRAME": if (action.newIndex < 0 || action.newIndex >= state.frames.length || action.newIndex < 0 || action.newIndex >= state.frames.length) return state; @@ -427,7 +255,7 @@ const animationReducer = (state: AnimationState, action: any): AnimationState => frames: movedFrames, currentFrame: action.oldIndex === state.currentFrame ? action.newIndex : state.currentFrame } - case actions.SET_FRAMES: + case "SET_FRAMES": tickEvent(`set-frames`); return { ...state, @@ -439,24 +267,24 @@ const animationReducer = (state: AnimationState, action: any): AnimationState => } } -const editorReducer = (state: EditorState, action: any, store: EditorStore): EditorState => { +const editorReducer = (state: EditorState, action: Action, store: EditorStore): EditorState => { let editedTiles: string[]; switch (action.type) { - case actions.CHANGE_PREVIEW_ANIMATING: - tickEvent(`preview-animate-${action.animating ? "on" : "off"}`) - return { ...state, previewAnimating: action.animating }; - case actions.CHANGE_CANVAS_ZOOM: + case "CHANGE_PREVIEW_ANIMATING": + tickEvent(`preview-animate-${action.isAnimating ? "on" : "off"}`) + return { ...state, previewAnimating: action.isAnimating }; + case "CHANGE_CANVAS_ZOOM": if (action.zoom > 0 || action.zoom < 0) { tickEvent(`zoom-${action.zoom > 0 ? "in" : "out"}`); } return { ...state, zoomDelta: action.zoom }; - case actions.CHANGE_IMAGE_TOOL: + case "CHANGE_IMAGE_TOOL": tickEvent(`change-tool-${ImageEditorTool[action.tool]}`); return { ...state, tool: action.tool }; - case actions.CHANGE_CURSOR_SIZE: + case "CHANGE_CURSOR_SIZE": tickEvent(`change-cursor-size-${action.cursorSize}`); return { ...state, cursorSize: action.cursorSize }; - case actions.CHANGE_SELECTED_COLOR: + case "CHANGE_SELECTED_COLOR": tickEvent(`foreground-color-${action.selectedColor}`); // If the selected tool is the eraser, make sure to switch to pencil @@ -465,30 +293,30 @@ const editorReducer = (state: EditorState, action: any, store: EditorStore): Edi selectedColor: action.selectedColor, tool: state.tool === ImageEditorTool.Erase ? ImageEditorTool.Paint : state.tool }; - case actions.CHANGE_CURSOR_LOCATION: - return { ...state, cursorLocation: action.cursorLocation }; - case actions.CHANGE_BACKGROUND_COLOR: - tickEvent(`background-color-${action.backgroundColor}`); - return { ...state, backgroundColor: action.backgroundColor }; - case actions.SWAP_FOREGROUND_BACKGROUND: + case "CHANGE_CURSOR_LOCATION": + return { ...state, cursorLocation: action.cursor }; + case "CHANGE_BACKGROUND_COLOR": + tickEvent(`background-color-${action.color}`); + return { ...state, backgroundColor: action.color }; + case "SWAP_FOREGROUND_BACKGROUND": tickEvent(`swap-foreground-background`); return { ...state, backgroundColor: state.selectedColor, selectedColor: state.backgroundColor }; - case actions.TOGGLE_ONION_SKIN_ENABLED: + case "TOGGLE_ONION_SKIN_ENABLED": tickEvent(`toggle-onion-skin`); return { ...state, onionSkinEnabled: !state.onionSkinEnabled }; - case actions.CHANGE_TILE_PALETTE_CATEGORY: + case "CHANGE_TILE_PALETTE_CATEGORY": tickEvent(`change-tile-category-${TileCategory[action.category]}`); return { ...state, tilemapPalette: { ...state.tilemapPalette, category: action.category, page: 0 } }; - case actions.CHANGE_TILE_PALETTE_PAGE: + case "CHANGE_TILE_PALETTE_PAGE": tickEvent(`change-tile-page`); return { ...state, tilemapPalette: { ...state.tilemapPalette, page: action.page } }; - case actions.CHANGE_DRAWING_MODE: + case "CHANGE_DRAWING_MODE": tickEvent(`change-drawing-mode`); return { ...state, drawingMode: action.drawingMode || TileDrawingMode.Default }; - case actions.CHANGE_OVERLAY_ENABLED: + case "CHANGE_OVERLAY_ENABLED": tickEvent(`change-overlay-enabled`); - return { ...state, overlayEnabled: action.enabled }; - case actions.CREATE_NEW_TILE: + return { ...state, overlayEnabled: action.isEnabled }; + case "CREATE_NEW_TILE": // tick event covered elsewhere editedTiles = state.editedTiles; if (action.tile && (!editedTiles || editedTiles.indexOf(action.tile.id) === -1)) { @@ -500,10 +328,10 @@ const editorReducer = (state: EditorState, action: any, store: EditorStore): Edi selectedColor: action.foreground, backgroundColor: action.background }; - case actions.SET_GALLERY_OPEN: - tickEvent(`set-gallery-open-${action.open}`); - return { ...state, tileGalleryOpen: action.open, tilemapPalette: { ...state.tilemapPalette, page: 0 } }; - case actions.DELETE_TILE: + case "SET_GALLERY_OPEN": + tickEvent(`set-gallery-open-${action.isOpen}`); + return { ...state, tileGalleryOpen: action.isOpen, tilemapPalette: { ...state.tilemapPalette, page: 0 } }; + case "DELETE_TILE": return { ...state, deletedTiles: (state.deletedTiles || []).concat([action.id]), @@ -511,7 +339,7 @@ const editorReducer = (state: EditorState, action: any, store: EditorStore): Edi backgroundColor: action.index === state.backgroundColor ? 0 : state.backgroundColor, tilesetRevision: state.tilesetRevision + 1 }; - case actions.OPEN_TILE_EDITOR: + case "OPEN_TILE_EDITOR": const editType = action.index ? "edit" : "new"; tickEvent(`open-tile-editor-${editType}`); @@ -522,7 +350,7 @@ const editorReducer = (state: EditorState, action: any, store: EditorStore): Edi tilesetIndex: action.index } }; - case actions.CLOSE_TILE_EDITOR: + case "CLOSE_TILE_EDITOR": editedTiles = state.editedTiles; if (action.result && (!editedTiles || editedTiles.indexOf(action.result.id) === -1)) { editedTiles = (editedTiles || []).concat([action.result.id]) @@ -534,7 +362,7 @@ const editorReducer = (state: EditorState, action: any, store: EditorStore): Edi editingTile: undefined, tilesetRevision: state.tilesetRevision + 1 }; - case actions.SHOW_ALERT: + case "SHOW_ALERT": tickEvent("show-alert"); return { ...state, @@ -544,13 +372,13 @@ const editorReducer = (state: EditorState, action: any, store: EditorStore): Edi options: action.options } } - case actions.HIDE_ALERT: + case "HIDE_ALERT": tickEvent("hide-alert"); return { ...state, alert: null } - case actions.DISABLE_RESIZE: + case "DISABLE_RESIZE": // no tick, this is not initiated by the user return { ...state, @@ -560,14 +388,14 @@ const editorReducer = (state: EditorState, action: any, store: EditorStore): Edi return state; } -const tilemapReducer = (state: TilemapState, action: any): TilemapState => { +const tilemapReducer = (state: TilemapState, action: Action): TilemapState => { switch (action.type) { - case actions.TOGGLE_ASPECT_RATIO: + case "TOGGLE_ASPECT_RATIO": tickEvent(`toggle-aspect-ratio-lock`); return { ...state, aspectRatioLocked: !state.aspectRatioLocked }; - case actions.CHANGE_IMAGE_DIMENSIONS: + case "CHANGE_IMAGE_DIMENSIONS": tickEvent(`change-dimensions`); - const [width, height] = action.imageDimensions as [number, number]; + const { width, height } = action; return { ...state, tilemap: { @@ -576,7 +404,7 @@ const tilemapReducer = (state: TilemapState, action: any): TilemapState => { overlayLayers: state.tilemap.overlayLayers && state.tilemap.overlayLayers.map(o => resizeBitmap(o, width, height)) } }; - case actions.CREATE_NEW_TILE: + case "CREATE_NEW_TILE": const isCustomTile = !action.qualifiedName; tickEvent(!isCustomTile ? `used-tile-${action.qualifiedName}` : `new-tile`); @@ -597,7 +425,7 @@ const tilemapReducer = (state: TilemapState, action: any): TilemapState => { }, nextId: isCustomTile ? state.nextId + 1 : state.nextId } - case actions.CLOSE_TILE_EDITOR: + case "CLOSE_TILE_EDITOR": tickEvent("close-tile-editor"); if (!action.result) return state; else if (action.index) { @@ -616,7 +444,7 @@ const tilemapReducer = (state: TilemapState, action: any): TilemapState => { nextId: state.nextId + 1 } } - case actions.DELETE_TILE: + case "DELETE_TILE": tickEvent("delete-tile"); const newTiles = state.tileset.tiles.slice(); newTiles.splice(action.index, 1); @@ -632,7 +460,7 @@ const tilemapReducer = (state: TilemapState, action: any): TilemapState => { tiles: newTiles } } - case actions.IMAGE_EDIT: + case "IMAGE_EDIT": tickEvent(`image-edit`); return { ...state, @@ -671,8 +499,6 @@ function cloneImage(state: pxt.sprite.ImageState): pxt.sprite.ImageState { }; } -let tickCallback: (event: string) => void; - function tickEvent(event: string) { if (tickCallback) { tickCallback(event) @@ -762,6 +588,4 @@ function initialTilemapStateForAsset(asset: pxt.ProjectTilemap, gallery: Gallery export function setTelemetryFunction(cb: (event: string) => void) { tickCallback = cb; -} - -export default topReducer; \ No newline at end of file +} \ No newline at end of file diff --git a/webapp/src/components/ImageEditor/state/state.ts b/webapp/src/components/ImageEditor/state/state.ts new file mode 100644 index 000000000000..92f8510f8cc4 --- /dev/null +++ b/webapp/src/components/ImageEditor/state/state.ts @@ -0,0 +1,176 @@ +import { AlertInfo } from "../Alert"; +import { emptyFrame } from "../util"; + +export enum ImageEditorTool { + Paint, + Fill, + Line, + Erase, + Circle, + Rect, + ColorSelect, + Marquee, + Pan +} + +export const enum CursorSize { + One = 1, + Three = 3, + Five = 5 +} + +export const enum KeyModifiers { + Alt = 1 << 0, + Shift = 1 << 1 +} + +export enum TileCategory { + Forest, + Aquatic, + Dungeon, + Misc, +} + +export enum TileDrawingMode { + Default = "default", + Wall = "wall" +} + +// State that goes on the undo/redo stack +export interface AnimationState { + kind: "Animation"; + asset?: pxt.Asset; + visible: boolean; + colors: string[]; + + aspectRatioLocked: boolean; + + currentFrame: number; + frames: pxt.sprite.ImageState[]; + interval: number; +} + +export interface TilemapState { + kind: "Tilemap"; + asset?: pxt.Asset; + tileset: pxt.TileSet; + aspectRatioLocked: boolean; + tilemap: pxt.sprite.ImageState; + colors: string[]; + nextId: number; +} + +// State that is not on the undo/redo stack +export interface EditorState { + selectedColor: number; + backgroundColor: number; + + tilemapPalette?: TilemapPaletteState; + tileGalleryOpen?: boolean; + + isTilemap: boolean; + referencedTiles?: string[]; + deletedTiles?: string[]; + editedTiles?: string[]; + + // The state below this comment is not persisted between editor reloads + previewAnimating: boolean; + tool: ImageEditorTool; + cursorLocation?: [number, number]; + zoomDelta?: number; + onionSkinEnabled: boolean; + drawingMode?: TileDrawingMode; + tileGallery?: GalleryTile[]; + editingTile?: TileEditContext; + cursorSize: CursorSize; + overlayEnabled?: boolean; + alert?: AlertInfo; + resizeDisabled?: boolean; + tilesetRevision: number; // used to track changes to the tileset and invalidate the tile cache in ImageCanvas +} + +export interface GalleryTile { + qualifiedName: string; + bitmap: pxt.sprite.BitmapData; + tags: string[]; + tileWidth: number; +} + +export interface TilemapPaletteState { + category: TileCategory; + page: number; +} + +export interface ImageEditorStore { + store: EditorStore; + + editor: EditorState; +} + +export interface TileEditContext { + type: "new" | "edit"; + tilesetIndex: number; +} + +export interface EditorStore { + past: (AnimationState | TilemapState)[]; + present: AnimationState | TilemapState; + future: (AnimationState | TilemapState)[]; +} + +export interface AnimationStore { +} + +export interface TilemapStore { + past: TilemapState[]; + present: TilemapState; + future: TilemapState[]; +} + +const initialState: AnimationState = { + kind: "Animation", + visible: true, + colors: [ + "#000000", + "#ffffff", + "#ff2121", + "#ff93c4", + "#ff8135", + "#fff609", + "#249ca3", + "#78dc52", + "#003fad", + "#87f2ff", + "#8e2ec4", + "#a4839f", + "#5c406c", + "#e5cdc4", + "#91463d", + "#000000" + ], + + aspectRatioLocked: false, + + currentFrame: 0, + frames: [emptyFrame(16, 16)], + interval: 200 +} + +export const initialStore: ImageEditorStore = { + store: { + present: initialState, + past: [], + future: [], + }, + editor: { + selectedColor: 3, + tool: ImageEditorTool.Paint, + cursorSize: CursorSize.One, + backgroundColor: 1, + previewAnimating: false, + onionSkinEnabled: false, + overlayEnabled: true, + tilesetRevision: 0, + isTilemap: false + } +} \ No newline at end of file diff --git a/webapp/src/components/ImageEditor/store/imageStore.ts b/webapp/src/components/ImageEditor/store/imageStore.ts deleted file mode 100644 index 2ea90ef33aae..000000000000 --- a/webapp/src/components/ImageEditor/store/imageStore.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { createStore } from 'redux'; - -import topReducer from './imageReducer'; - -const store = createStore(topReducer); -export default store; - -export const tileEditorStore = createStore(topReducer); -export const mainStore = store; \ No newline at end of file diff --git a/webapp/src/components/ImageEditor/tilemap/Minimap.tsx b/webapp/src/components/ImageEditor/tilemap/Minimap.tsx index 1a208993e3f7..cc33978d5463 100644 --- a/webapp/src/components/ImageEditor/tilemap/Minimap.tsx +++ b/webapp/src/components/ImageEditor/tilemap/Minimap.tsx @@ -1,57 +1,52 @@ import * as React from 'react'; -import { connect } from 'react-redux'; import { LIGHT_MODE_TRANSPARENT } from '../ImageEditor'; -import { ImageEditorStore, TilemapState, TileCategory } from '../store/imageReducer'; +import { ImageEditorContext, TilemapState } from '../state'; export interface MinimapProps { - colors: string[]; - tileset: pxt.TileSet; - tilemap: pxt.sprite.ImageState; lightMode: boolean; } const SCALE = pxt.BrowserUtils.isEdge() ? 25 : 1; -class MinimapImpl extends React.Component { - protected tileColors: string[] = []; - protected canvas: HTMLCanvasElement; +export const Minimap = (props: MinimapProps) => { + const { state } = React.useContext(ImageEditorContext); + const canvasRef = React.useRef(); - componentDidMount() { - this.canvas = this.refs["minimap-canvas"] as HTMLCanvasElement; - this.redrawCanvas(); - } + const { lightMode } = props; + const { colors, tilemap, tileset } = (state.store.present as TilemapState); - componentDidUpdate() { - this.redrawCanvas(); - } - - render() { - return
- -
- } - - redrawCanvas() { - const { tilemap, lightMode } = this.props; + React.useEffect(() => { let { bitmap, floating, layerOffsetX, layerOffsetY } = tilemap; - const context = this.canvas.getContext("2d"); + const context = canvasRef.current.getContext("2d"); const image = pxt.sprite.Tilemap.fromData(bitmap); const floatingImage = floating && floating.bitmap ? pxt.sprite.Tilemap.fromData(floating.bitmap) : null; - this.canvas.width = image.width * SCALE; - this.canvas.height = image.height * SCALE; - this.tileColors = []; + canvasRef.current.width = image.width * SCALE; + canvasRef.current.height = image.height * SCALE; + const tileColors: string[] = []; + + const getColor = (index: number) => { + if (!tileColors[index]) { + if (index >= tileset.tiles.length) { + return "#ffffff"; + } + const bitmap = pxt.sprite.Bitmap.fromData(tileset.tiles[index].bitmap); + tileColors[index] = pxt.sprite.computeAverageColor(bitmap, colors); + } + + return tileColors[index]; + } for (let x = 0; x < image.width; x++) { for (let y = 0; y < image.height; y++) { const float = floatingImage ? floatingImage.get(x - layerOffsetX, y - layerOffsetY) : null; const index = image.get(x, y); if (float) { - context.fillStyle = this.getColor(float); + context.fillStyle = getColor(float); context.fillRect(x * SCALE, y * SCALE, SCALE, SCALE); } else if (index) { - context.fillStyle = this.getColor(index); + context.fillStyle = getColor(index); context.fillRect(x * SCALE, y * SCALE, SCALE, SCALE); } else if (lightMode) { @@ -62,37 +57,11 @@ class MinimapImpl extends React.Component { } } } - } - - protected getColor(index: number) { - if (!this.tileColors[index]) { - const { tileset, colors } = this.props; - - if (index >= tileset.tiles.length) { - return "#ffffff"; - } - const bitmap = pxt.sprite.Bitmap.fromData(tileset.tiles[index].bitmap); - this.tileColors[index] = pxt.sprite.computeAverageColor(bitmap, colors); - } - - return this.tileColors[index]; - } -} - + }, [lightMode, colors, tilemap, tileset]); -function mapStateToProps({ store: { present }, editor }: ImageEditorStore, ownProps: any) { - let state = (present as TilemapState); - if (!state) return {}; - return { - tilemap: state.tilemap, - tileset: state.tileset, - colors: state.colors - }; + return ( +
+ +
+ ); } - -const mapDispatchToProps = { - -}; - - -export const Minimap = connect(mapStateToProps, mapDispatchToProps)(MinimapImpl); \ No newline at end of file diff --git a/webapp/src/components/ImageEditor/tilemap/TilePalette.tsx b/webapp/src/components/ImageEditor/tilemap/TilePalette.tsx index 8d1493f584ea..59553cb0afd3 100644 --- a/webapp/src/components/ImageEditor/tilemap/TilePalette.tsx +++ b/webapp/src/components/ImageEditor/tilemap/TilePalette.tsx @@ -1,16 +1,13 @@ import * as React from 'react'; -import { connect } from 'react-redux'; -import { ImageEditorStore, TilemapState, TileCategory, TileDrawingMode, GalleryTile } from '../store/imageReducer'; -import { dispatchChangeSelectedColor, dispatchChangeBackgroundColor, dispatchSwapBackgroundForeground, - dispatchChangeTilePaletteCategory, dispatchChangeTilePalettePage, dispatchChangeDrawingMode, - dispatchCreateNewTile, dispatchSetGalleryOpen, dispatchOpenTileEditor, dispatchDeleteTile, - dispatchShowAlert, dispatchHideAlert } from '../actions/dispatch'; + import { TimelineFrame } from '../TimelineFrame'; import { Dropdown, DropdownOption } from '../Dropdown'; import { Pivot, PivotOption } from '../Pivot'; import { IconButton } from '../Button'; import { AlertOption } from '../Alert'; import { createTile } from '../../../assets'; +import { changeBackgroundColor, changeDrawingMode, changeSelectedColor, changeTilePaletteCategory, changeTilePalettePage, createNewTile, deleteTile, hideAlert, ImageEditorContext, openTileEditor, setGalleryOpen, showAlert, swapForegroundBackground, TilemapState, TileCategory, TileDrawingMode, GalleryTile } from '../state'; +import { classList } from '../../../../../react-common/components/util'; export interface TilePaletteProps { colors: string[]; @@ -90,337 +87,146 @@ interface UserTile { bitmap: pxt.sprite.BitmapData; } -type RenderedTile = GalleryTile | UserTile - -class TilePaletteImpl extends React.Component { - protected canvas: HTMLCanvasElement; - protected renderedTiles: RenderedTile[]; - protected categoryTiles: RenderedTile[]; - protected categories: Category[]; +type RenderedTile = GalleryTile | UserTile; - constructor(props: TilePaletteProps) { - super(props); - - const { gallery } = props; +interface GalleryState { + categoryTiles: RenderedTile[]; + renderedTiles: RenderedTile[]; + categories: Category[]; + selectedColor?: number; + backgroundColor?: number; +} - this.refreshGallery(props); +export const TilePalette = () => { + const { state, dispatch } = React.useContext(ImageEditorContext); - if (gallery) { - const extraCategories: pxt.Map = {}; - for (const tile of gallery) { - const categoryName = tile.tags.find(t => pxt.Util.startsWith(t, "category-")); - if (categoryName) { - if (!extraCategories[categoryName]) { - extraCategories[categoryName] = { - id: categoryName, - text: pxt.Util.rlf(`{id:tilecategory}${categoryName.substr(9)}`), - tiles: [] - }; - } + const { drawingMode, selectedColor, backgroundColor, tileGalleryOpen, referencedTiles, tilemapPalette, tileGallery } = state.editor; + const { page, category } = tilemapPalette; + const { tilemap, tileset, colors } = (state.store.present as TilemapState); - extraCategories[categoryName].tiles.push(tile); - } - } + const fg = tileset.tiles[selectedColor] ? tileset.tiles[selectedColor].bitmap : emptyTile.data(); + const bg = tileset.tiles[backgroundColor] ? tileset.tiles[backgroundColor].bitmap : emptyTile.data(); - this.categories = options.concat(Object.keys(extraCategories).map(key => extraCategories[key])); - } else { - this.categories = []; - } - } + const galleryState = React.useRef({ categories: [], renderedTiles: [], categoryTiles: [] }); - componentDidMount() { - this.canvas = this.refs["tile-canvas-surface"] as HTMLCanvasElement; - this.updateGalleryTiles(); - this.redrawCanvas(); + if (!galleryState.current.categories.length) { + galleryState.current.categories = getGalleryCategories(tileGallery, tileset) } - UNSAFE_componentWillReceiveProps(nextProps: TilePaletteProps) { - if (this.props.selected != nextProps.selected) { - this.jumpToPageContaining(nextProps.selected); - } else if (this.props.backgroundColor != nextProps.backgroundColor) { - this.jumpToPageContaining(nextProps.backgroundColor); + const updateGalleryTiles = React.useCallback(() => { + if (tileGalleryOpen) { + galleryState.current.categoryTiles = galleryState.current.categories[category].tiles; + } + else { + galleryState.current.categoryTiles = getCustomTiles(tileset).map(([t, i]) => ({ index: i, bitmap: t.bitmap })); } - this.refreshGallery(nextProps); - } - - componentDidUpdate() { - this.updateGalleryTiles(); - this.redrawCanvas(); - } - render() { - const { colors, selected, backgroundColor, tileset, category, page, drawingMode, galleryOpen } = this.props; + const startIndex = page * TILES_PER_PAGE; + galleryState.current.renderedTiles = galleryState.current.categoryTiles.slice(startIndex, startIndex + TILES_PER_PAGE); + }, [page, tileGallery, tileGalleryOpen, tileset, category]); - const fg = tileset.tiles[selected] ? tileset.tiles[selected].bitmap : emptyTile.data(); - const bg = tileset.tiles[backgroundColor] ? tileset.tiles[backgroundColor].bitmap : emptyTile.data(); - const wall = emptyTile.data(); - this.updateGalleryTiles(); + updateGalleryTiles(); - let totalPages = Math.ceil(this.categoryTiles.length / TILES_PER_PAGE); + let totalPages = Math.ceil(galleryState.current.categoryTiles.length / TILES_PER_PAGE); - // Add an empty page for the tile create button if the last page is full - if (!galleryOpen && this.categoryTiles.length % 16 === 0) totalPages++; + // Add an empty page for the tile create button if the last page is full + if (!tileGalleryOpen && galleryState.current.categoryTiles.length % 16 === 0) totalPages++; - const showCreateTile = !galleryOpen && (totalPages === 1 || page === totalPages - 1); - const controlsDisabled = galleryOpen || !this.renderedTiles.some(t => !isGalleryTile(t) && t.index === selected); + const showCreateTile = !tileGalleryOpen && (totalPages === 1 || page === totalPages - 1); + const controlsDisabled = tileGalleryOpen || !galleryState.current.renderedTiles.some(t => !isGalleryTile(t) && t.index === selectedColor); - return
-
-
- -
-
- - -
-
- - - -
-
- -
- { galleryOpen && !!c.tiles.length)} selected={category} /> } + const canvasRef = React.useRef(); + const createTileButtonRef = React.useRef(); - { !galleryOpen && -
- - - -
- } -
- -
-
- - { showCreateTile && -
- -
- } -
-
- { pageControls(totalPages, page, this.pageHandler) } -
-
-
; - } - - protected updateGalleryTiles() { - const { page, category, galleryOpen } = this.props; - - if (galleryOpen) { - this.categoryTiles = this.categories[category].tiles; + const onForegroundBackgroundClick = React.useCallback(() => { + if (drawingMode != TileDrawingMode.Default) { + dispatch(changeDrawingMode(TileDrawingMode.Default)); } else { - this.categoryTiles = this.getCustomTiles().map(([t, i]) => ({ index: i, bitmap: t.bitmap })); + dispatch(swapForegroundBackground()); } + }, [dispatch, drawingMode]); - const startIndex = page * TILES_PER_PAGE; - this.renderedTiles = this.categoryTiles.slice(startIndex, startIndex + TILES_PER_PAGE); - } - - protected jumpToPageContaining(index: number) { - const { tileset, dispatchSetGalleryOpen, dispatchChangeTilePaletteCategory, - dispatchChangeTilePalettePage } = this.props; - if (!index || index < 0 || index >= tileset.tiles.length) return; - - const tile = tileset.tiles[index]; - if (!tile.isProjectTile) { - // For gallery tile, find the category then the page within the category - const category = this.categories.find(opt => opt.tiles.findIndex(t => t.qualifiedName == tile.id) !== -1); - if (!category || !category.tiles) return; - const page = Math.max(Math.floor(category.tiles.findIndex(t => t.qualifiedName == tile.id) / TILES_PER_PAGE), 0); - - dispatchSetGalleryOpen(true); - dispatchChangeTilePaletteCategory(this.categories.indexOf(category) as TileCategory); - dispatchChangeTilePalettePage(page); - } else { - // For custom tile, find the page - const categoryTiles = this.getCustomTiles().map(([t, i]) => t); - if (!categoryTiles) return; - const page = Math.max(Math.floor(categoryTiles.findIndex(t => t.id == tile.id) / TILES_PER_PAGE), 0); - - dispatchSetGalleryOpen(false); - dispatchChangeTilePalettePage(page); + const onWallClick = React.useCallback(() => { + if (drawingMode === TileDrawingMode.Wall) { + dispatch(changeDrawingMode(TileDrawingMode.Default)); } - } - - protected redrawCanvas() { - const columns = 4; - const rows = 4; - const margin = 1; - - const { tileset, page, selected } = this.props; - - const startIndex = page * columns * rows; - - const width = tileset.tileWidth + margin; + else { + dispatch(changeDrawingMode(TileDrawingMode.Wall)); + } + }, [dispatch, drawingMode]); - this.canvas.width = (width * columns + margin) * SCALE; - this.canvas.height = (width * rows + margin) * SCALE; + const onTileEditClick = React.useCallback(() => { + const tileToEdit = tileset.tiles[selectedColor]; + if (!tileToEdit?.isProjectTile || selectedColor === 0) return; - const context = this.canvas.getContext("2d"); + dispatch(openTileEditor(selectedColor, tileToEdit.id)); + }, [dispatch, selectedColor, tileset]); - for (let r = 0; r < rows; r++) { - for (let c = 0; c < columns; c++) { - const tile = this.categoryTiles[startIndex + r * columns + c]; + const onTileDuplicateClick = React.useCallback(() => { + const tile = tileset.tiles[selectedColor]; + if (!tile?.isProjectTile || selectedColor === 0) return; - if (tile) { - if (!isGalleryTile(tile) && tile.index === selected) { - context.fillStyle = "#ff0000"; - context.fillRect(c * width, r * width, width + 1, width + 1); - } - - context.fillStyle = "#333333"; - context.fillRect(c * width + 1, r * width + 1, width - 1, width - 1); - - this.drawBitmap(pxt.sprite.Bitmap.fromData(tile.bitmap), 1 + c * width, 1 + r * width) - } - } - } + dispatch(createNewTile(createTile(tile.bitmap, null, tile.meta?.displayName), tileset.tiles.length, backgroundColor)); + }, [dispatch, selectedColor, tileset, backgroundColor]); - this.positionCreateTileButton(); - } + const onTileDeleteClick = React.useCallback(() => { + const info = tileset.tiles[selectedColor]; - protected drawBitmap(bitmap: pxt.sprite.Bitmap, x0 = 0, y0 = 0, transparent = true, cellWidth = SCALE, target = this.canvas) { - const { colors } = this.props; + if (!selectedColor || !info || !info.isProjectTile) return; - const context = target.getContext("2d"); - context.imageSmoothingEnabled = false; - for (let x = 0; x < bitmap.width; x++) { - for (let y = 0; y < bitmap.height; y++) { - const index = bitmap.get(x, y); + const hideAlertCallback = () => dispatch(hideAlert()); + const deleteTileCallback = () => { + const deleted = tileset.tiles[selectedColor]; - if (index) { - context.fillStyle = colors[index]; - context.fillRect((x + x0) * cellWidth, (y + y0) * cellWidth, cellWidth, cellWidth); - } - else { - if (!transparent) context.clearRect((x + x0) * cellWidth, (y + y0) * cellWidth, cellWidth, cellWidth); - } + if (deleted) { + dispatch(deleteTile(selectedColor, deleted.id)); } } - } - - protected dropdownHandler = (option: DropdownOption, index: number) => { - this.props.dispatchChangeTilePaletteCategory(index); - } - - protected pivotHandler = (option: PivotOption, index: number) => { - this.props.dispatchSetGalleryOpen(index === 1); - } - - protected pageHandler = (page: number) => { - this.props.dispatchChangeTilePalettePage(page); - } - - protected tileCreateHandler = () => { - this.props.dispatchOpenTileEditor(); - } - - protected tileEditHandler = () => { - const { tileset, selected, dispatchOpenTileEditor } = this.props; - - const tileToEdit = tileset.tiles[selected]; - if (!tileToEdit?.isProjectTile || selected === 0) return; - - dispatchOpenTileEditor(selected, tileToEdit.id); - } - - protected tileDuplicateHandler = () => { - const { tileset, selected, backgroundColor, dispatchCreateNewTile } = this.props; - - if (!tileset.tiles[selected] || !tileset.tiles[selected].isProjectTile || selected === 0) return; - - const tile = tileset.tiles[selected]; - dispatchCreateNewTile(createTile(tile.bitmap, null, tile.meta?.displayName), tileset.tiles.length, backgroundColor); - } - - protected tileDeleteAlertHandler = () => { - const { tileset, selected, dispatchShowAlert, dispatchHideAlert, referencedTiles } = this.props; - - const info = tileset.tiles[selected]; - - if (!selected || !info || !info.isProjectTile) return; - // tile cannot be deleted because it is referenced in the code if (referencedTiles && referencedTiles.indexOf(info.id) !== -1) { - dispatchShowAlert(lf("Unable to delete"), + dispatch(showAlert( + lf("Unable to delete"), lf("This tile is used in your game. Remove all blocks using the tile before deleting."), - [{ label: lf("Cancel"), onClick: dispatchHideAlert }]); + [{ label: lf("Cancel"), onClick: () => hideAlertCallback }] + )); return; } - dispatchShowAlert(lf("Are you sure?"), + dispatch(showAlert( + lf("Are you sure?"), lf("Deleting this tile will remove it from all other tile maps in your game."), - [{ label: lf("Yes"), onClick: this.deleteTile}, { label: lf("No"), onClick: dispatchHideAlert }]); - } - - protected deleteTile = () => { - const deleted = this.props.tileset.tiles[this.props.selected]; + [ + { label: lf("Yes"), onClick: deleteTileCallback}, + { label: lf("No"), onClick: () => hideAlertCallback } + ] + )); + }, [dispatch, referencedTiles, tileset, selectedColor]); + + const onTileCreateClick = React.useCallback(() => { + dispatch(openTileEditor()); + }, [dispatch]); + + const handleCanvasClickCore = React.useCallback((clientX: number, clientY: number, isRightClick: boolean) => { + const getTileIndex = (tile: GalleryTile) => { + for (let i = 0; i < tileset.tiles.length; i++) { + if (tileset.tiles[i].id === tile.qualifiedName) return i; + } - if (deleted) { - this.props.dispatchDeleteTile(this.props.selected, deleted.id); + return -1; } - } - - protected canvasClickHandler = (ev: React.MouseEvent) => { - this.handleCanvasClickCore(ev.clientX, ev.clientY, ev.button > 0); - } - - protected canvasTouchHandler = (ev: React.TouchEvent) => { - this.handleCanvasClickCore(ev.changedTouches[0].clientX, ev.changedTouches[0].clientY, false); - } - - protected handleCanvasClickCore(clientX: number, clientY: number, isRightClick: boolean) { - const bounds = this.canvas.getBoundingClientRect(); + const bounds = canvasRef.current.getBoundingClientRect(); const column = ((clientX - bounds.left) / (bounds.width / 4)) | 0; const row = ((clientY - bounds.top) / (bounds.height / 4)) | 0; - const tile = this.renderedTiles[row * 4 + column]; + const tile = galleryState.current.renderedTiles[row * 4 + column]; if (tile) { let index: number; let qname: string; if (isGalleryTile(tile)) { - index = this.getTileIndex(tile); + index = getTileIndex(tile); qname = tile.qualifiedName; } else { @@ -428,155 +234,355 @@ class TilePaletteImpl extends React.Component { } if (index >= 0) { - if (isRightClick) this.props.dispatchChangeBackgroundColor(index); - else this.props.dispatchChangeSelectedColor(index); + if (isRightClick) { + dispatch(changeBackgroundColor(index)); + } + else { + dispatch(changeSelectedColor(index)); + } } else { - const { selected, backgroundColor, tileset } = this.props; const newIndex = tileset.tiles.length || 1; // transparent is index 0, so default to 1 - this.props.dispatchCreateNewTile( + dispatch(createNewTile( null, - isRightClick ? selected : newIndex, + isRightClick ? selectedColor : newIndex, isRightClick ? newIndex : backgroundColor, qname - ); + )); } // automatically switch into tile drawing mode - this.props.dispatchChangeDrawingMode(TileDrawingMode.Default); + dispatch(changeDrawingMode(TileDrawingMode.Default)) } - } + }, [dispatch, selectedColor, backgroundColor, tileset]); + + const onCanvasClick = React.useCallback((ev: React.MouseEvent) => { + handleCanvasClickCore(ev.clientX, ev.clientY, ev.button > 0); + }, [handleCanvasClickCore]); + + const onCanvasTouch = React.useCallback((ev: React.TouchEvent) => { + handleCanvasClickCore(ev.changedTouches[0].clientX, ev.changedTouches[0].clientY, false); + }, [handleCanvasClickCore]); + + const preventContextMenu = React.useCallback((ev: React.MouseEvent) => { + ev.preventDefault(); + }, []); + + const pivotHandler = React.useCallback((option: PivotOption, index: number) => { + dispatch(setGalleryOpen(index === 1)); + }, [dispatch]); + + const dropdownHandler = React.useCallback((option: DropdownOption, index: number) => { + dispatch(changeTilePaletteCategory(index)); + }, [dispatch]); + + const onPageSelected = React.useCallback((index: number) => { + dispatch(changeTilePalettePage(index)); + }, [dispatch]); + + React.useEffect(() => { + updateGalleryTiles(); - protected refreshGallery(props: TilePaletteProps) { - const { gallery, tileset } = props; - if (gallery) { - options.forEach(opt => { - opt.tiles = gallery.filter(t => t.tags.indexOf(opt.id) !== -1 && t.tileWidth === tileset.tileWidth); - }); + // Draw the tile palette canvas + const columns = 4; + const rows = 4; + const margin = 1; + + const startIndex = page * columns * rows; + + const width = tileset.tileWidth + margin; + + canvasRef.current.width = (width * columns + margin) * SCALE; + canvasRef.current.height = (width * rows + margin) * SCALE; + + const context = canvasRef.current.getContext("2d"); + + for (let r = 0; r < rows; r++) { + for (let c = 0; c < columns; c++) { + const tile = galleryState.current.categoryTiles[startIndex + r * columns + c]; + + if (tile) { + if (!isGalleryTile(tile) && tile.index === selectedColor) { + context.fillStyle = "#ff0000"; + context.fillRect(c * width, r * width, width + 1, width + 1); + } + + context.fillStyle = "#333333"; + context.fillRect(c * width + 1, r * width + 1, width - 1, width - 1); + + drawBitmap(colors, pxt.sprite.Bitmap.fromData(tile.bitmap), 1 + c * width, 1 + r * width, true, SCALE, canvasRef.current); + } + } } - } - protected positionCreateTileButton() { - const button = this.refs["create-tile-ref"] as HTMLDivElement; + // Position the tile create button + const button = createTileButtonRef.current; if (button) { - const column = this.categoryTiles.length % 4; - const row = Math.floor(this.categoryTiles.length / 4) % 4; + const column = galleryState.current.categoryTiles.length % 4; + const row = Math.floor(galleryState.current.categoryTiles.length / 4) % 4; button.style.position = "absolute"; button.style.left = "calc(" + (column / 4) + " * (100% - 0.5rem) + 0.25rem)"; button.style.top = "calc(" + (row / 4) + " * (100% - 0.5rem) + 0.25rem)"; } - } + }, [tileset, page, category, tileGalleryOpen, selectedColor, colors, updateGalleryTiles]); + + React.useEffect(() => { + refreshGallery(tileGallery, tileset); + }, [tileGallery, tileset]); + + React.useEffect(() => { + const jumpToPageContaining = (index: number) => { + if (!index || index < 0 || index >= tileset.tiles.length) return; + + const tile = tileset.tiles[index]; + if (!tile.isProjectTile) { + // For gallery tile, find the category then the page within the category + const category = galleryState.current.categories.find(opt => opt.tiles.findIndex(t => t.qualifiedName == tile.id) !== -1); + if (!category || !category.tiles) return; + const page = Math.max(Math.floor(category.tiles.findIndex(t => t.qualifiedName == tile.id) / TILES_PER_PAGE), 0); + + dispatch(setGalleryOpen(true)); + dispatch(changeTilePaletteCategory(galleryState.current.categories.indexOf(category) as TileCategory)); + dispatch(changeTilePalettePage(page)); + } + else { + // For custom tile, find the page + const categoryTiles = getCustomTiles(tileset).map(([t, i]) => t); + if (!categoryTiles) return; + const page = Math.max(Math.floor(categoryTiles.findIndex(t => t.id == tile.id) / TILES_PER_PAGE), 0); - protected foregroundBackgroundClickHandler = () => { - if (this.props.drawingMode != TileDrawingMode.Default) { - this.props.dispatchChangeDrawingMode(TileDrawingMode.Default); - } else { - this.props.dispatchSwapBackgroundForeground(); + dispatch(setGalleryOpen(false)); + dispatch(changeTilePalettePage(page)); + } } - } - protected wallClickHandler = () => { - if (this.props.drawingMode === TileDrawingMode.Wall) { - this.props.dispatchChangeDrawingMode(TileDrawingMode.Default); + if (selectedColor !== galleryState.current.selectedColor) { + jumpToPageContaining(selectedColor); } - else { - this.props.dispatchChangeDrawingMode(TileDrawingMode.Wall); + else if (backgroundColor !== galleryState.current.backgroundColor) { + jumpToPageContaining(backgroundColor) } - } + galleryState.current.selectedColor = selectedColor; + galleryState.current.backgroundColor = backgroundColor; + }, [tileset, selectedColor, backgroundColor]) - protected preventContextMenu = (ev: React.MouseEvent) => ev.preventDefault(); - - // Returns all custom tiles and their index in the entire tileset, sorted by index. - protected getCustomTiles() { - return this.props.tileset.tiles - .map((t, i) => ([t, i] as [pxt.Tile, number])) - .filter(([t]) => t.isProjectTile) - .sort(([a], [b]) => { - const transparency = "myTiles.transparency" + this.props.tileset.tileWidth; - if (a.id == transparency) return -1 - else if (b.id == transparency) return 1 - else return a.internalID - b.internalID; - }); - } - - protected getTileIndex(g: GalleryTile) { - const { tileset } = this.props; + return ( +
+
+
+ +
+
+ + +
+
+ + + +
+
+ +
+ {tileGalleryOpen && + !!c.tiles.length)} + selected={category} + /> + } - for (let i = 0; i < tileset.tiles.length; i++) { - if (tileset.tiles[i].id === g.qualifiedName) return i; - } + {!tileGalleryOpen && +
+ + + +
+ } +
- return -1; - } +
+
+ + { showCreateTile && +
+ +
+ } +
+
+ +
+
+
+ ); } -function pageControls(pages: number, selected: number, onClick: (index: number) => void) { +const PageControls = (props: { pages: number, selected: number, onPageSelected: (index: number) => void }) => { + const { pages, selected, onPageSelected } = props; + const width = 16 + (pages - 1) * 5; const pageMap: boolean[] = []; for (let i = 0; i < pages; i++) pageMap[i] = i === selected; - return - onClick(selected - 1) : undefined} /> - { - pageMap.map((isSelected, index) => - onClick(index) : undefined}/> - ) + return ( + + onPageSelected(selected - 1) : undefined} + /> + { + pageMap.map((isSelected, index) => + onPageSelected(index) : undefined} + /> + ) + } + onPageSelected(selected + 1) : undefined} + /> + + ) +} + + +function getGalleryCategories(gallery: GalleryTile[], tileset: pxt.TileSet) { + refreshGallery(gallery, tileset); + + if (gallery) { + const extraCategories: pxt.Map = {}; + for (const tile of gallery) { + const categoryName = tile.tags.find(t => pxt.Util.startsWith(t, "category-")); + if (categoryName) { + if (!extraCategories[categoryName]) { + extraCategories[categoryName] = { + id: categoryName, + text: pxt.Util.rlf(`{id:tilecategory}${categoryName.substr(9)}`), + tiles: [] + }; + } + + extraCategories[categoryName].tiles.push(tile); + } } - onClick(selected + 1) : undefined} /> - + + return options.concat(Object.keys(extraCategories).map(key => extraCategories[key])); + } + + return []; +} + +function refreshGallery(gallery: GalleryTile[], tileset: pxt.TileSet) { + if (gallery) { + options.forEach(opt => { + opt.tiles = gallery.filter(t => t.tags.indexOf(opt.id) !== -1 && t.tileWidth === tileset.tileWidth); + }); + } } -function mapStateToProps({ store: { present }, editor }: ImageEditorStore, ownProps: any) { - let state = (present as TilemapState); - if (!state) return {}; - return { - selected: editor.selectedColor, - tileset: state.tileset, - backgroundColor: editor.backgroundColor, - category: editor.tilemapPalette.category, - page: editor.tilemapPalette.page, - colors: state.colors, - drawingMode: editor.drawingMode, - gallery: editor.tileGallery, - galleryOpen: editor.tileGalleryOpen, - referencedTiles: editor.referencedTiles - }; +function getCustomTiles(tileset: pxt.TileSet) { + const transparency = "myTiles.transparency" + tileset.tileWidth; + + return tileset.tiles + .map((t, i) => ([t, i] as [pxt.Tile, number])) + .filter(([t]) => t.isProjectTile) + .sort(([a], [b]) => { + if (a.id == transparency) return -1 + else if (b.id == transparency) return 1 + else return a.internalID - b.internalID; + }); +} + +function drawBitmap(colors: string[], bitmap: pxt.sprite.Bitmap, x0 = 0, y0 = 0, transparent = true, cellWidth = SCALE, target: HTMLCanvasElement) { + const context = target.getContext("2d"); + context.imageSmoothingEnabled = false; + for (let x = 0; x < bitmap.width; x++) { + for (let y = 0; y < bitmap.height; y++) { + const index = bitmap.get(x, y); + + if (index) { + context.fillStyle = colors[index]; + context.fillRect((x + x0) * cellWidth, (y + y0) * cellWidth, cellWidth, cellWidth); + } + else { + if (!transparent) context.clearRect((x + x0) * cellWidth, (y + y0) * cellWidth, cellWidth, cellWidth); + } + } + } } function isGalleryTile(t: RenderedTile): t is GalleryTile { return !!(t as GalleryTile).qualifiedName; } - -const mapDispatchToProps = { - dispatchChangeSelectedColor, - dispatchChangeBackgroundColor, - dispatchSwapBackgroundForeground, - dispatchChangeTilePalettePage, - dispatchChangeTilePaletteCategory, - dispatchChangeDrawingMode, - dispatchCreateNewTile, - dispatchSetGalleryOpen, - dispatchOpenTileEditor, - dispatchDeleteTile, - dispatchShowAlert, - dispatchHideAlert -}; - - -export const TilePalette = connect(mapStateToProps, mapDispatchToProps)(TilePaletteImpl); diff --git a/webapp/src/components/ImageEditor/toolDefinitions.ts b/webapp/src/components/ImageEditor/toolDefinitions.ts index a6997f79d13e..8f19c7ddae3c 100644 --- a/webapp/src/components/ImageEditor/toolDefinitions.ts +++ b/webapp/src/components/ImageEditor/toolDefinitions.ts @@ -1,4 +1,4 @@ -import { ImageEditorTool, TileDrawingMode } from "./store/imageReducer"; +import { ImageEditorTool, TileDrawingMode } from "./state"; export enum ToolCursor { None = "none", diff --git a/webapp/src/components/ImageEditor/util.ts b/webapp/src/components/ImageEditor/util.ts index 97eca9f929e3..21270c3e7036 100644 --- a/webapp/src/components/ImageEditor/util.ts +++ b/webapp/src/components/ImageEditor/util.ts @@ -1,3 +1,4 @@ +import { useEffect } from "react"; import { EditState } from "./toolDefinitions"; export const DRAG_RADIUS = 3; @@ -168,79 +169,109 @@ export class GestureState { export function bindGestureEvents(el: HTMLElement, target: GestureTarget) { if (hasPointerEvents()) { - bindPointerEvents(el, target); + return bindPointerEvents(el, target); } else if (isTouchEnabled()) { - bindTouchEvents(el, target); + return bindTouchEvents(el, target); } else { - bindMouseEvents(el, target); + return bindMouseEvents(el, target); } } function bindPointerEvents(el: HTMLElement, target: GestureTarget) { let state: GestureState; - el.addEventListener("pointerup", ev => { + const pointerUp = (ev: PointerEvent) => { if (state) { state.end(clientCoord(ev)); ev.preventDefault(); } state = undefined; - }); + }; - el.addEventListener("pointerdown", ev => { + const pointerDown = (ev: PointerEvent) => { if (state) state.end(); state = new GestureState(target, clientCoord(ev), isRightClick(ev)); ev.preventDefault(); - }); + }; - el.addEventListener("pointermove", ev => { + const pointerMove = (ev: PointerEvent) => { if (state) { state.update(clientCoord(ev)); ev.preventDefault(); } - }); + }; - el.addEventListener("pointerleave", ev => { + const pointerLeave = (ev: PointerEvent) => { if (state) { state.end(clientCoord(ev)); ev.preventDefault(); } state = undefined; - }); + }; + + el.addEventListener("pointerup", pointerUp); + el.addEventListener("pointerdown", pointerDown); + el.addEventListener("pointermove", pointerMove); + el.addEventListener("pointerleave", pointerLeave); + + return () => { + el.removeEventListener("pointerup", pointerUp); + el.removeEventListener("pointerdown", pointerDown); + el.removeEventListener("pointermove", pointerMove); + el.removeEventListener("pointerleave", pointerLeave); + }; } function bindMouseEvents(el: HTMLElement, target: GestureTarget) { let state: GestureState; - el.addEventListener("mouseup", ev => { - if (state) state.end(clientCoord(ev)); + const pointerUp = (ev: MouseEvent) => { + if (state) { + state.end(clientCoord(ev)); + } state = undefined; - }); + }; - el.addEventListener("mousedown", ev => { + const pointerDown = (ev: MouseEvent) => { if (state) state.end(); state = new GestureState(target, clientCoord(ev), isRightClick(ev)); - }); + }; - el.addEventListener("mousemove", ev => { - if (state) state.update(clientCoord(ev)); - }); + const pointerMove = (ev: MouseEvent) => { + if (state) { + state.update(clientCoord(ev)); + } + }; - el.addEventListener("mouseleave", ev => { - if (state) state.end(clientCoord(ev)); + const pointerLeave = (ev: MouseEvent) => { + if (state) { + state.end(clientCoord(ev)); + } state = undefined; - }); + }; + + el.addEventListener("mouseup", pointerUp); + el.addEventListener("mousedown", pointerDown); + el.addEventListener("mousemove", pointerMove); + el.addEventListener("mouseleave", pointerLeave); + + return () => { + el.removeEventListener("mouseup", pointerUp); + el.removeEventListener("mousedown", pointerDown); + el.removeEventListener("mousemove", pointerMove); + el.removeEventListener("mouseleave", pointerLeave); + }; } function bindTouchEvents(el: HTMLElement, target: GestureTarget) { let state: GestureState; let touchIdentifier: number | undefined; - el.addEventListener("touchend", ev => { + const touchEnd = (ev: TouchEvent) => { if (state && touchIdentifier) { const touch = getTouch(ev, touchIdentifier); @@ -250,16 +281,16 @@ function bindTouchEvents(el: HTMLElement, target: GestureTarget) { ev.preventDefault(); } } - }); + }; - el.addEventListener("touchstart", ev => { + const touchStart = (ev: TouchEvent) => { if (state) state.end(); touchIdentifier = ev.changedTouches[0].identifier; state = new GestureState(target, ev.changedTouches[0], isRightClick(ev)); - }); + }; - el.addEventListener("touchmove", ev => { + const touchMove = (ev: TouchEvent) => { if (state && touchIdentifier) { const touch = getTouch(ev, touchIdentifier); @@ -268,9 +299,9 @@ function bindTouchEvents(el: HTMLElement, target: GestureTarget) { ev.preventDefault(); } } - }); + }; - el.addEventListener("touchcancel", ev => { + const touchCancel = (ev: TouchEvent) => { if (state && touchIdentifier) { const touch = getTouch(ev, touchIdentifier); @@ -280,7 +311,19 @@ function bindTouchEvents(el: HTMLElement, target: GestureTarget) { ev.preventDefault(); } } - }); + }; + + el.addEventListener("touchend", touchEnd); + el.addEventListener("touchstart", touchStart); + el.addEventListener("touchmove", touchMove); + el.addEventListener("touchcancel", touchCancel); + + return () => { + el.removeEventListener("touchend", touchEnd); + el.removeEventListener("touchstart", touchStart); + el.removeEventListener("touchmove", touchMove); + el.removeEventListener("touchcancel", touchCancel); + }; } function getTouch(ev: TouchEvent, identifier: number) { @@ -374,4 +417,184 @@ export function createTilemapPatchFromFloatingLayer(editState: EditState, tilese layers, tiles: referencedTiles.map(t => pxt.sprite.base64EncodeBitmap(t.bitmap)) }; +} + +export function emptyFrame(width: number, height: number): pxt.sprite.ImageState { + return { + bitmap: new pxt.sprite.Bitmap(width, height).data() + } +} + +export function useGestureEvents(el: React.RefObject, target: GestureTarget) { + if (hasPointerEvents()) { + usePointerEvents(el, target); + } + else if (isTouchEnabled()) { + useTouchEvents(el, target); + } + else { + useMouseEvents(el, target); + } +} + +function usePointerEvents(el: React.RefObject, target: GestureTarget) { + const state = React.useRef(); + + useEffect(() => { + const element = el.current; + if (!element) return undefined; + + const pointerUp = (ev: PointerEvent) => { + if (state.current) { + state.current.end(clientCoord(ev)); + ev.preventDefault(); + } + state.current = undefined; + }; + + const pointerDown = (ev: PointerEvent) => { + if (state.current) state.current.end(); + + state.current = new GestureState(target, clientCoord(ev), isRightClick(ev)); + ev.preventDefault(); + }; + + const pointerMove = (ev: PointerEvent) => { + if (state.current) { + state.current.update(clientCoord(ev)); + ev.preventDefault(); + } + }; + + const pointerLeave = (ev: PointerEvent) => { + if (state.current) { + state.current.end(clientCoord(ev)); + ev.preventDefault(); + } + state.current = undefined; + }; + + element.addEventListener("pointerup", pointerUp); + element.addEventListener("pointerdown", pointerDown); + element.addEventListener("pointermove", pointerMove); + element.addEventListener("pointerleave", pointerLeave); + + return () => { + element.removeEventListener("pointerup", pointerUp); + element.removeEventListener("pointerdown", pointerDown); + element.removeEventListener("pointermove", pointerMove); + element.removeEventListener("pointerleave", pointerLeave); + }; + }, [target]); +} + +function useMouseEvents(el: React.RefObject, target: GestureTarget) { + const state = React.useRef(); + + React.useEffect(() => { + const element = el.current; + if (!element) return undefined; + + const pointerUp = (ev: MouseEvent) => { + if (state.current) { + state.current.end(clientCoord(ev)); + } + state.current = undefined; + }; + + const pointerDown = (ev: MouseEvent) => { + if (state.current) state.current.end(); + + state.current = new GestureState(target, clientCoord(ev), isRightClick(ev)); + }; + + const pointerMove = (ev: MouseEvent) => { + if (state.current) { + state.current.update(clientCoord(ev)); + } + }; + + const pointerLeave = (ev: MouseEvent) => { + if (state.current) { + state.current.end(clientCoord(ev)); + } + state.current = undefined; + }; + + element.addEventListener("mouseup", pointerUp); + element.addEventListener("mousedown", pointerDown); + element.addEventListener("mousemove", pointerMove); + element.addEventListener("mouseleave", pointerLeave); + + return () => { + element.removeEventListener("mouseup", pointerUp); + element.removeEventListener("mousedown", pointerDown); + element.removeEventListener("mousemove", pointerMove); + element.removeEventListener("mouseleave", pointerLeave); + }; + }, [target]) +} + +function useTouchEvents(el: React.RefObject, target: GestureTarget) { + const state = React.useRef(); + const touchIdentifier = React.useRef(); + + React.useEffect(() => { + const element = el.current; + if (!element) return undefined; + + const touchEnd = (ev: TouchEvent) => { + if (state.current && touchIdentifier.current) { + const touch = getTouch(ev, touchIdentifier.current); + + if (touch) { + state.current.end(touch); + state.current = undefined; + ev.preventDefault(); + } + } + }; + + const touchStart = (ev: TouchEvent) => { + if (state.current) state.current.end(); + + touchIdentifier.current = ev.changedTouches[0].identifier; + state.current = new GestureState(target, ev.changedTouches[0], isRightClick(ev)); + }; + + const touchMove = (ev: TouchEvent) => { + if (state.current && touchIdentifier.current) { + const touch = getTouch(ev, touchIdentifier.current); + + if (touch) { + state.current.update(touch); + ev.preventDefault(); + } + } + }; + + const touchCancel = (ev: TouchEvent) => { + if (state.current && touchIdentifier.current) { + const touch = getTouch(ev, touchIdentifier.current); + + if (touch) { + state.current.end(touch); + state.current = undefined; + ev.preventDefault(); + } + } + }; + + element.addEventListener("touchend", touchEnd); + element.addEventListener("touchstart", touchStart); + element.addEventListener("touchmove", touchMove); + element.addEventListener("touchcancel", touchCancel); + + return () => { + element.removeEventListener("touchend", touchEnd); + element.removeEventListener("touchstart", touchStart); + element.removeEventListener("touchmove", touchMove); + element.removeEventListener("touchcancel", touchCancel); + }; + }, [target]); } \ No newline at end of file diff --git a/webapp/src/components/ImageFieldEditor.tsx b/webapp/src/components/ImageFieldEditor.tsx index e1e0adcaa500..a722295c0a03 100644 --- a/webapp/src/components/ImageFieldEditor.tsx +++ b/webapp/src/components/ImageFieldEditor.tsx @@ -5,7 +5,7 @@ import { AssetCardView } from "./assetEditor/assetCard"; import { assetToGalleryItem, getAssets } from "../assets"; import { ImageEditor } from "./ImageEditor/ImageEditor"; import { obtainShortcutLock, releaseShortcutLock } from "./ImageEditor/keyboardShortcuts"; -import { GalleryTile, setTelemetryFunction } from './ImageEditor/store/imageReducer'; +import { GalleryTile, setTelemetryFunction } from './ImageEditor/state'; import { FilterPanel } from './FilterPanel'; import { fireClickOnEnter } from "../util"; import { EditorToggle, EditorToggleItem, BasicEditorToggleItem } from "../../../react-common/components/controls/EditorToggle"; diff --git a/webapp/src/components/TilemapFieldEditor.tsx b/webapp/src/components/TilemapFieldEditor.tsx index 999075b0b058..1cee0ce5abaf 100644 --- a/webapp/src/components/TilemapFieldEditor.tsx +++ b/webapp/src/components/TilemapFieldEditor.tsx @@ -2,7 +2,7 @@ import * as React from "react"; import { FieldEditorComponent } from '../blocklyFieldView'; import { ImageEditor } from "./ImageEditor/ImageEditor"; -import { setTelemetryFunction, GalleryTile } from './ImageEditor/store/imageReducer'; +import { setTelemetryFunction, GalleryTile } from './ImageEditor/state'; export interface TilemapFieldEditorProps { }