Skip to content

Commit

Permalink
Merge pull request #1390 from pau-tomas/feat/waveeditor-input
Browse files Browse the repository at this point in the history
Add an input to edit waveforms via text
  • Loading branch information
chrismaltby authored Apr 23, 2024
2 parents 5bde484 + cc088b9 commit 802ae2e
Show file tree
Hide file tree
Showing 5 changed files with 281 additions and 21 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Add ability to "Preview as Monochrome" when using mixed color mode by toggling button at bottom left of World view
- Add ability to provide color PNGs for backgrounds and extract palettes automatically by either clicking "Auto Color" button in brush toolbar or using dropdown on Scene sidebar next to "Background Palettes" label
- Add ability to override tile data for auto colored backgrounds by providing a matching *.mono.png in your assets/backgrounds folder containing a monochrome version of the background. When provided this file will be used for tiles data and the regular image will be used to extract the color palettes (useful for mixed color mode games when auto palettes isn't creating tile data as you'd like automatically)
- Add ability to edit waveforms in music editor using keyboard with ability to copy/paste [@pau-tomas](https://github.com/pau-tomas)

### Changed

Expand Down
28 changes: 17 additions & 11 deletions src/components/music/SongPianoRoll.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -868,17 +868,23 @@ export const SongPianoRoll = ({
};
}, [onKeyDown, onKeyUp]);

// Clipoard
const onCopy = useCallback(() => {
if (pattern) {
const parsedSelectedPattern = parsePatternToClipboard(
pattern,
selectedChannel,
selectedPatternCells
);
dispatch(clipboardActions.copyText(parsedSelectedPattern));
}
}, [selectedChannel, dispatch, pattern, selectedPatternCells]);
// Clipboard
const onCopy = useCallback(
(e) => {
if (e.target.nodeName === "INPUT") {
return;
}
if (pattern) {
const parsedSelectedPattern = parsePatternToClipboard(
pattern,
selectedChannel,
selectedPatternCells
);
dispatch(clipboardActions.copyText(parsedSelectedPattern));
}
},
[selectedChannel, dispatch, pattern, selectedPatternCells]
);

const onCut = useCallback(() => {
if (pattern) {
Expand Down
26 changes: 16 additions & 10 deletions src/components/music/SongTracker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -737,16 +737,22 @@ export const SongTracker = ({
setSelectionRect(undefined);
}, []);

const onCopy = useCallback(() => {
if (pattern && selectedTrackerFields) {
// const parsedSelectedPattern = parsePatternToClipboard(pattern);
const parsedSelectedPattern = parsePatternFieldsToClipboard(
pattern,
selectedTrackerFields
);
dispatch(clipboardActions.copyText(parsedSelectedPattern));
}
}, [dispatch, pattern, selectedTrackerFields]);
const onCopy = useCallback(
(e) => {
if (e.target.nodeName === "INPUT") {
return;
}
if (pattern && selectedTrackerFields) {
// const parsedSelectedPattern = parsePatternToClipboard(pattern);
const parsedSelectedPattern = parsePatternFieldsToClipboard(
pattern,
selectedTrackerFields
);
dispatch(clipboardActions.copyText(parsedSelectedPattern));
}
},
[dispatch, pattern, selectedTrackerFields]
);

const onCut = useCallback(() => {
if (pattern && selectedTrackerFields) {
Expand Down
4 changes: 4 additions & 0 deletions src/components/music/WaveEditorForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import l10n from "shared/lib/lang/l10n";
import trackerDocumentActions from "store/features/trackerDocument/trackerDocumentActions";
import { FormRow, FormField } from "ui/form/FormLayout";
import { ThemeContext } from "styled-components";
import { WaveEditorInput } from "components/music/WaveEditorInput";

interface WaveEditorFormProps {
waveId: number;
Expand Down Expand Up @@ -188,6 +189,9 @@ export const WaveEditorForm = ({ waveId, onChange }: WaveEditorFormProps) => {
height={120}
/>
</FormRow>
<FormRow>
<WaveEditorInput waveId={waveId} onEditWave={onEditWave} />
</FormRow>
</>
);
};
243 changes: 243 additions & 0 deletions src/components/music/WaveEditorInput.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,243 @@
import React, { useCallback, useEffect, useRef, useState } from "react";
import API from "renderer/lib/api";
import clamp from "shared/lib/helpers/clamp";
import clipboardActions from "store/features/clipboard/clipboardActions";
import { useAppDispatch, useAppSelector } from "store/hooks";
import styled, { css } from "styled-components";

interface WaveEditorInputProps {
waveId: number;
onEditWave: (newWave: Uint8Array) => void;
}

const validKeys = [
"0",
"1",
"2",
"3",
"4",
"5",
"6",
"7",
"8",
"9",
"A",
"B",
"C",
"D",
"F",
];

interface InputWrapperProps {
focus?: boolean;
}
const InputWrapper = styled.div<InputWrapperProps>`
position: relative;
font-family: monospace;
display: flex;
align-items: center;
justify-content: space-around;
background: ${(props) => props.theme.colors.input.background};
color: ${(props) => props.theme.colors.input.text};
border: 1px solid ${(props) => props.theme.colors.input.border};
font-size: ${(props) => props.theme.typography.fontSize};
border-radius: ${(props) => props.theme.borderRadius}px;
padding: 5px;
box-sizing: border-box;
width: 100%;
height: 28px;
cursor: text;
:hover {
background: ${(props) => props.theme.colors.input.hoverBackground};
}
${(props) =>
props.focus &&
css`
outline: none;
border: 1px solid ${(props) => props.theme.colors.highlight} !important;
box-shadow: 0 0 0px 2px ${(props) => props.theme.colors.highlight} !important;
background: ${(props) => props.theme.colors.input.activeBackground};
transition: box-shadow 0.2s cubic-bezier(0.175, 0.885, 0.71, 2.65);
`}
:disabled {
opacity: 0.5;
}
input {
opacity: 0;
position: absolute;
pointer-events: none;
top: 0;
bottom: 0;
left: 0;
right: 0;
}
`;

interface InputCellProps {
active: boolean;
}
const InputCell = styled.span<InputCellProps>`
display: flex;
align-items: center;
flex-grow: 1;
height: 28px;
box-sizing: border-box;
color: ${(props) => (props.active ? props.theme.colors.highlight : "")};
`;

export const WaveEditorInput = ({
waveId,
onEditWave,
}: WaveEditorInputProps) => {
const dispatch = useAppDispatch();
const song = useAppSelector((state) => state.trackerDocument.present.song);

const wave = Array.from(song?.waves[waveId] ?? []);

const [editPosition, setEditPosition] = useState(0);
const [hasFocus, setHasFocus] = useState(false);

const handleKeyDown = useCallback(
(e: KeyboardEvent) => {
if (e.metaKey || e.ctrlKey || e.altKey) {
return;
}

e.stopPropagation();
// e.preventDefault();

let tmpEditPosition = editPosition;
if (e.key === "ArrowLeft") {
tmpEditPosition -= 1;
}
if (e.key === "ArrowRight") {
tmpEditPosition += 1;
}

if (tmpEditPosition !== editPosition) {
const newEditPosition = ((tmpEditPosition % 32) + 32) % 32;
setEditPosition(newEditPosition);
return;
}

if (e.key === "ArrowDown") {
wave[editPosition] = (((wave[editPosition] - 1) % 16) + 16) % 16;
onEditWave(Uint8Array.from(wave));
} else if (e.key === "ArrowUp") {
wave[editPosition] = (((wave[editPosition] + 1) % 16) + 16) % 16;
onEditWave(Uint8Array.from(wave));
} else if (e.key === "Backspace" || e.key === "Delete") {
wave[editPosition] = 0;
const newEditPosition = clamp(editPosition - 1, 0, 31);
setEditPosition(newEditPosition);
onEditWave(Uint8Array.from(wave));
} else if (validKeys.includes(e.key.toUpperCase())) {
const newValue = parseInt(e.key, 16);
if (!isNaN(newValue)) {
wave[editPosition] = newValue;
const newEditPosition = clamp(editPosition + 1, 0, 31);
setEditPosition(newEditPosition);
}
onEditWave(Uint8Array.from(wave));
}
},
[editPosition, onEditWave, wave]
);

useEffect(() => {
if (hasFocus) {
window.addEventListener("keydown", handleKeyDown);
}
return () => {
window.removeEventListener("keydown", handleKeyDown);
};
}, [handleKeyDown, hasFocus]);

const onCopy = useCallback(() => {
dispatch(
clipboardActions.copyText(
wave.map((value) => value.toString(16).toUpperCase()).join("")
)
);
}, [dispatch, wave]);

const onCut = useCallback(() => {
dispatch(clipboardActions.copyText(wave.toString()));
}, [dispatch, wave]);

const onPaste = useCallback(async () => {
const newWaveString = await API.clipboard.readText();
if (newWaveString) {
const isValid = newWaveString.length === 32;
if (isValid) {
const newWave = [];
for (let i = 0; i < newWaveString.length; i++) {
newWave.push(parseInt(newWaveString[i], 16));
}
onEditWave(Uint8Array.from(newWave));
}
}
}, [onEditWave]);

// Clipboard
useEffect(() => {
if (hasFocus) {
window.addEventListener("copy", onCopy);
window.addEventListener("cut", onCut);
window.addEventListener("paste", onPaste);
return () => {
window.removeEventListener("copy", onCopy);
window.removeEventListener("cut", onCut);
window.removeEventListener("paste", onPaste);
};
}
}, [onCopy, onCut, hasFocus, onPaste]);

const inputRef = useRef<HTMLInputElement>(null);
const handleFocus = useCallback(() => {
if (!hasFocus && inputRef && inputRef.current) {
inputRef.current.focus();
setHasFocus(true);
}
}, [hasFocus]);

const handleBlur = useCallback(() => {
setHasFocus(false);
}, []);

return (
<InputWrapper tabIndex={-1} focus={hasFocus}>
{wave &&
wave.map((w, i) => {
return (
<InputCell
active={hasFocus && i === editPosition}
onClick={() => {
setEditPosition(i);
handleFocus();
}}
>
{w.toString(16).toUpperCase()}
</InputCell>
);
})}
<input
tabIndex={0}
ref={inputRef}
onFocus={handleFocus}
onBlur={handleBlur}
onChange={(e) => (e.target.value = "")}
/>
</InputWrapper>
);
};

0 comments on commit 802ae2e

Please sign in to comment.