Skip to content

Commit

Permalink
Add multi selection and context menu to frames in Sprite Editor
Browse files Browse the repository at this point in the history
  • Loading branch information
chrismaltby committed Nov 6, 2024
1 parent 999478b commit fc4c766
Show file tree
Hide file tree
Showing 23 changed files with 2,762 additions and 374 deletions.
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Add ability to change collision layer opacity in World view when collision tool is selected [@Q-Bert-Reynolds](https://github.com/Q-Bert-Reynolds)
- Add ability for engine plugins to define new, per scene, collision tile types in `engine.json` file [@Q-Bert-Reynolds]
- Add ability to show the raw collision tile values when editing collisions in world view
- Add ability to ctrl/cmd + click frames in Sprite Editor to toggle multi select or shift + click to select range
- Add right click context menu to frames in Sprite Editor allowing copy/paste/clone/delete to be performed on all selected frames

### Changed

Expand Down
4 changes: 4 additions & 0 deletions src/components/pages/SpritesPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,9 @@ const SpritesPage = () => {
const metaspriteId = useAppSelector(
(state) => state.editor.selectedMetaspriteId
);
const selectedAdditionalMetaspriteIds = useAppSelector(
(state) => state.editor.selectedAdditionalMetaspriteIds
);
const precisionTileMode = useAppSelector(
(state) => state.editor.precisionTileMode
);
Expand Down Expand Up @@ -427,6 +430,7 @@ const SpritesPage = () => {
spriteSheetId={viewSpriteId}
animationId={selectedAnimation?.id || ""}
metaspriteId={selectedMetaspriteId}
additionalMetaspriteIds={selectedAdditionalMetaspriteIds}
/>
)}
</div>
Expand Down
13 changes: 11 additions & 2 deletions src/components/sprites/MetaspriteEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,9 @@ const MetaspriteEditor = ({
const defaultSpritePaletteIds = useAppSelector(
(state) => state.project.present.settings.defaultSpritePaletteIds
);
const selectedAdditionalMetaspriteIds = useAppSelector(
(state) => state.editor.selectedAdditionalMetaspriteIds
);
const [draggingSelection, setDraggingSelection] = useState(false);
const [draggingMetasprite, setDraggingMetasprite] = useState(false);
const dragMetasprites = useRef<MetaspriteSelection[]>([]);
Expand Down Expand Up @@ -601,12 +604,18 @@ const MetaspriteEditor = ({
}, [dispatch, selectedTileIds]);

const onCopyMetasprite = useCallback(() => {
const selectionIds =
selectedAdditionalMetaspriteIds.length === 0
? [metaspriteId]
: selectedAdditionalMetaspriteIds;

dispatch(
clipboardActions.copyMetasprites({
metaspriteIds: [metaspriteId],
metaspriteIds: selectionIds,
spriteAnimationId: animationId,
})
);
}, [dispatch, metaspriteId]);
}, [animationId, dispatch, metaspriteId, selectedAdditionalMetaspriteIds]);

const onCopy = useCallback(() => {
if (selectedTileIds.length > 0) {
Expand Down
247 changes: 128 additions & 119 deletions src/components/sprites/SpriteAnimationTimeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,43 +6,23 @@ import React, {
useState,
} from "react";
import styled from "styled-components";
import throttle from "lodash/throttle";
import { spriteAnimationSelectors } from "store/features/entities/entitiesState";
import entitiesActions from "store/features/entities/entitiesActions";
import editorActions from "store/features/editor/editorActions";
import { CloneIcon, PlusIcon } from "ui/icons/Icons";
import SpriteAnimationTimelineFrame from "./SpriteAnimationTimelineFrame";
import { FixedSpacer } from "ui/spacing/Spacing";
import { SpriteAnimationTimelineFrame } from "./SpriteAnimationTimelineFrame";
import { FixedSpacer, FlexGrow } from "ui/spacing/Spacing";
import l10n from "shared/lib/lang/l10n";
import { useAppDispatch, useAppSelector } from "store/hooks";
import { SortableList } from "ui/lists/SortableList";

interface SpriteAnimationTimelineProps {
spriteSheetId: string;
animationId: string;
metaspriteId: string;
additionalMetaspriteIds: string[];
}

const Wrapper = styled.div`
min-width: 0;
`;

const ScrollWrapper = styled.div`
overflow: auto;
max-width: 100%;
max-height: 100%;
overflow-x: scroll;
display: flex;
background: ${(props) => props.theme.colors.sidebar.background};
overflow-x: auto;
padding: 10px;
padding-right: 50px;
& > * {
margin-right: 10px;
flex-shrink: 0;
}
`;

const AddFrameButton = styled.button`
background: ${(props) => props.theme.colors.button.nestedBackground};
width: 50px;
Expand All @@ -61,118 +41,126 @@ const SpriteAnimationTimeline = ({
spriteSheetId,
animationId,
metaspriteId,
additionalMetaspriteIds,
}: SpriteAnimationTimelineProps) => {
const lastIndex = useRef(-1);
const dispatch = useAppDispatch();

const [hasFocus, setHasFocus] = useState(false);
const [cloneFrame, setCloneFrame] = useState(false);

const animation = useAppSelector((state) =>
spriteAnimationSelectors.selectById(state, animationId)
);

const frames = useMemo(() => animation?.frames || [], [animation?.frames]);
const [cloneFrame, setCloneFrame] = useState(false);

const onMoveFrames = useCallback(
(fromIndex: number, toIndex: number) => {
dispatch(
entitiesActions.moveSpriteAnimationFrame({
spriteSheetId,
spriteAnimationId: animationId,
fromIndex,
toIndex,
})
);
const onSetFrame = useCallback(
(frameId: string, multiSelection?: boolean) => {
lastIndex.current = frames.indexOf(frameId);
if (multiSelection) {
dispatch(editorActions.toggleMultiSelectedMetaspriteId(frameId));
} else {
dispatch(editorActions.setSelectedMetaspriteId(frameId));
}
},
[animationId, dispatch, spriteSheetId]
[dispatch, frames]
);

const onSetFrame = useCallback(
const onSetFrameRange = useCallback(
(frameId: string) => {
dispatch(editorActions.setSelectedMetaspriteId(frameId));
const thisFrame = frames.indexOf(frameId);
const fromFrame = lastIndex.current;
const from = Math.min(fromFrame, thisFrame);
const to = Math.max(fromFrame, thisFrame);
const addFrames: string[] = [];
for (let i = from; i <= to; i++) {
if (frames[i]) {
addFrames.push(frames[i]);
}
}
dispatch(editorActions.addMetaspriteIdsToMultiSelection(addFrames));
},
[dispatch]
[dispatch, frames]
);

const onClearMultiSelect = useCallback(() => {
dispatch(editorActions.clearMultiSelectedMetaspriteId());
}, [dispatch]);

const onAddFrame = useCallback(() => {
dispatch(
entitiesActions.addMetasprite({
spriteSheetId,
spriteAnimationId: animationId,
afterMetaspriteId: metaspriteId,
})
);
}, [animationId, dispatch, spriteSheetId]);
}, [animationId, dispatch, metaspriteId, spriteSheetId]);

const onCloneFrame = useCallback(() => {
dispatch(
entitiesActions.cloneMetasprite({
entitiesActions.cloneMetasprites({
spriteSheetId,
spriteAnimationId: animationId,
metaspriteId,
metaspriteIds:
additionalMetaspriteIds.length === 0
? [metaspriteId]
: additionalMetaspriteIds,
})
);
}, [dispatch, spriteSheetId, animationId, metaspriteId]);

const onDeleteFrame = useCallback(() => {
}, [
dispatch,
spriteSheetId,
animationId,
additionalMetaspriteIds,
metaspriteId,
]);

const onDeleteFrames = useCallback(() => {
dispatch(
entitiesActions.removeMetasprite({
entitiesActions.removeMetasprites({
spriteSheetId,
spriteAnimationId: animationId,
metaspriteId,
metaspriteIds: additionalMetaspriteIds,
})
);
}, [dispatch, spriteSheetId, animationId, metaspriteId]);
}, [dispatch, spriteSheetId, animationId, additionalMetaspriteIds]);

const handleKeys = useCallback(
(e: KeyboardEvent) => {
if (e.altKey) {
setCloneFrame(true);
}

if (!hasFocus) {
const onMoveFrames = useCallback(
(fromIndex: number, toIndex: number) => {
const multiSelectionIndexes = additionalMetaspriteIds.map((id) =>
frames.findIndex((i) => i === id)
);
if (multiSelectionIndexes.includes(toIndex)) {
return;
}
if (e.key === "ArrowRight") {
e.preventDefault();
throttledNext.current(frames, metaspriteId || "");
} else if (e.key === "ArrowLeft") {
e.preventDefault();
throttledPrev.current(frames, metaspriteId || "");
} else if (e.key === "Home") {
onSetFrame(frames[0]);
} else if (e.key === "End") {
onSetFrame(frames[frames.length - 1]);
} else if (e.key === "Backspace" || e.key === "Delete") {
onDeleteFrame();
}

dispatch(
entitiesActions.moveSpriteAnimationFrames({
spriteSheetId,
spriteAnimationId: animationId,
fromIndexes:
multiSelectionIndexes.length > 0
? multiSelectionIndexes
: [fromIndex],
toIndex,
})
);
},
[hasFocus, frames, metaspriteId, onSetFrame, onDeleteFrame]
[additionalMetaspriteIds, animationId, dispatch, frames, spriteSheetId]
);

const handleKeys = useCallback((e: KeyboardEvent) => {
if (e.altKey) {
setCloneFrame(true);
}
}, []);

const handleKeysUp = useCallback((e: KeyboardEvent) => {
if (!e.altKey) {
setCloneFrame(false);
}
}, []);

const throttledNext = useRef(
throttle((frames: string[], selectedId: string) => {
const currentIndex = frames.indexOf(selectedId);
const nextIndex = (currentIndex + 1) % frames.length;
const nextItem = frames[nextIndex];
onSetFrame(nextItem);
}, 150)
);

const throttledPrev = useRef(
throttle((frames: string[], selectedId: string) => {
const currentIndex = frames.indexOf(selectedId);
const prevIndex = (frames.length + currentIndex - 1) % frames.length;
const prevItem = frames[prevIndex];
onSetFrame(prevItem);
}, 150)
);

useEffect(() => {
window.addEventListener("keydown", handleKeys);
window.addEventListener("keyup", handleKeysUp);
Expand All @@ -184,37 +172,58 @@ const SpriteAnimationTimeline = ({
});

return (
<Wrapper
tabIndex={0}
onFocus={() => setHasFocus(true)}
onBlur={() => setHasFocus(false)}
>
<ScrollWrapper>
{frames.map((frameMetaspriteId, i) => {
return (
<SpriteAnimationTimelineFrame
key={frameMetaspriteId}
index={i}
id={frameMetaspriteId}
spriteSheetId={spriteSheetId}
text={frameMetaspriteId}
selected={frameMetaspriteId === metaspriteId}
moveCard={onMoveFrames}
onSelect={onSetFrame}
/>
);
})}
<AddFrameButton
onClick={cloneFrame ? onCloneFrame : onAddFrame}
title={
cloneFrame ? l10n("FIELD_CLONE_FRAME") : l10n("FIELD_ADD_FRAME")
<SortableList
itemType={"frame"}
items={frames}
extractKey={(frameId) => frameId}
selectedIndex={frames.indexOf(metaspriteId)}
renderItem={(frameId, { isDragging, isDraggingAny }) => (
<SpriteAnimationTimelineFrame
selected={frameId === metaspriteId}
multiSelected={additionalMetaspriteIds.includes(frameId)}
isDragging={
isDragging ||
(isDraggingAny &&
frameId !== metaspriteId &&
additionalMetaspriteIds.includes(frameId))
}
>
{cloneFrame ? <CloneIcon /> : <PlusIcon />}
</AddFrameButton>
<FixedSpacer width={10} />
</ScrollWrapper>
</Wrapper>
id={frameId}
spriteSheetId={spriteSheetId}
animationId={animationId}
index={frames.indexOf(frameId)}
/>
)}
onSelect={(frameId, e) => {
if (e?.shiftKey) {
onSetFrameRange(frameId);
} else {
onSetFrame(frameId, e?.ctrlKey || e?.metaKey);
}
}}
moveItems={onMoveFrames}
onKeyDown={(e) => {
if (e.key === "Escape") {
onClearMultiSelect();
return true;
} else if (e.key === "Backspace" || e.key === "Delete") {
onDeleteFrames();
}
}}
appendComponent={
<>
<AddFrameButton
onClick={cloneFrame ? onCloneFrame : onAddFrame}
title={
cloneFrame ? l10n("FIELD_CLONE_FRAME") : l10n("FIELD_ADD_FRAME")
}
>
{cloneFrame ? <CloneIcon /> : <PlusIcon />}
</AddFrameButton>
<FixedSpacer width={10} />
<FlexGrow onClick={onClearMultiSelect} />
</>
}
/>
);
};

Expand Down
Loading

0 comments on commit fc4c766

Please sign in to comment.