Browse Source

Update data binding

Handle data binding for keyframes.
master
TheoryOfNekomata 1 year ago
parent
commit
214c1d7e51
4 changed files with 282 additions and 48 deletions
  1. +16
    -0
      tools/animation-workbench/app/components/Button/index.tsx
  2. +23
    -0
      tools/animation-workbench/app/components/FileButton/index.tsx
  3. +1
    -1
      tools/animation-workbench/app/root.tsx
  4. +242
    -47
      tools/animation-workbench/app/routes/index.tsx

+ 16
- 0
tools/animation-workbench/app/components/Button/index.tsx View File

@@ -0,0 +1,16 @@
import {forwardRef, HTMLProps, ReactNode} from 'react';

export interface ButtonProps extends Omit<HTMLProps<HTMLButtonElement>, 'type'> {
type?: 'button' | 'reset' | 'submit';
}

export const Button = forwardRef<HTMLButtonElement, ButtonProps>(({
className,
...etcProps
}, ref) => (
<button
{...etcProps}
ref={ref}
className={`h-12 px-4 border-2 rounded ${className}`}
/>
))

+ 23
- 0
tools/animation-workbench/app/components/FileButton/index.tsx View File

@@ -0,0 +1,23 @@
import {forwardRef, HTMLProps, ReactNode} from 'react';

export interface FileButtonProps extends Omit<HTMLProps<HTMLInputElement>, 'type'> {
children: ReactNode;
}

export const FileButton = forwardRef<HTMLInputElement, FileButtonProps>(({
className,
children,
...etcProps
}, ref) => (
<label>
<input
{...etcProps}
ref={ref}
type="file"
className="sr-only"
/>
<span className={`h-12 px-4 border-2 rounded inline-flex justify-center items-center cursor-pointer ${className}`}>
{children}
</span>
</label>
))

+ 1
- 1
tools/animation-workbench/app/root.tsx View File

@@ -7,7 +7,7 @@ import {
Scripts,
ScrollRestoration,
} from "@remix-run/react";
import stylesheet from "./tailwind.css";
import stylesheet from '~/tailwind.css';

export const links: LinksFunction = () => [
{ rel: "stylesheet", href: stylesheet },


+ 242
- 47
tools/animation-workbench/app/routes/index.tsx View File

@@ -8,21 +8,35 @@ import {
useState,
} from 'react';
import { getFormValues, setFormValues } from '@theoryofnekomata/formxtra';
import { Button } from '~/components/Button';
import {FileButton} from '~/components/FileButton';

type SvgGroup = {
id: string;
name: string;
}

type KeyframeAttribute = {
group: SvgGroup;
translateX: number;
translateY: number;
rotate: number;
}

type Keyframe = {
time: number;
attributes: KeyframeAttribute[];
}

const useAnimationForm = () => {
const [loadedFileName, setLoadedFileName] = useState<string>();
const [loadedFileContents, setLoadedFileContents] = useState<string>();

const [svgAnimations, setSvgAnimations] = useState<string[]>();
const [svgGroups, setSvgGroups] = useState<SvgGroup[]>();
const [isNewAnimationId, setIsNewAnimationId] = useState<boolean>();
const [currentAnimationId, setCurrentAnimationId] = useState<string>();
const [enabledGroupIds, setEnabledGroupIds] = useState<string[]>();
const [savedSvgKeyframes, setSavedSvgKeyframes] = useState<Keyframe[]>();

const svgAnimationIdsListId = useId();

@@ -76,9 +90,9 @@ const useAnimationForm = () => {
break;
default:
// we select from the datalist
const target = e.currentTarget;
setIsNewAnimationId(false);
setCurrentAnimationId(e.currentTarget.value);
const target = e.currentTarget;
setTimeout(() => {
target.blur();
});
@@ -102,10 +116,12 @@ const useAnimationForm = () => {
return
}

const svgPreviewRoot = svgPreviewRef.current.children[0];

setSvgAnimations(['default']);
setSvgGroups(
Array
.from(svgPreviewRef.current.children[0].children)
.from(svgPreviewRoot.children)
.map((g) => ({
id: g.id,
name: g.id,
@@ -113,6 +129,7 @@ const useAnimationForm = () => {
);
setCurrentAnimationId('default');
setEnabledGroupIds([]); // TODO - compute enabled groupIds based on current animation id
setSavedSvgKeyframes([]); // TODO - load keyframes from file
}, [loadedFileContents, svgPreviewRef.current]);

const focusAnimationIdInput: FocusEventHandler<HTMLInputElement> = (e) => {
@@ -163,7 +180,7 @@ const useAnimationForm = () => {
const currentRotate = rotate[changedIndex];
const currentTranslateX = e.currentTarget.value;
const currentTranslateY = translateY[changedIndex];
targetGroup.setAttribute('transform', `rotate(${currentRotate} ${rotationCenter.cx} ${rotationCenter.cy}) translate(${currentTranslateX} ${currentTranslateY})`);
targetGroup.setAttribute('transform', `translate(${currentTranslateX} ${currentTranslateY}) rotate(${currentRotate} ${rotationCenter.cx} ${rotationCenter.cy})`);
}

const translateYGroup = (group: SvgGroup): ChangeEventHandler<HTMLInputElement> => (e) => {
@@ -196,7 +213,7 @@ const useAnimationForm = () => {
const currentRotate = rotate[changedIndex];
const currentTranslateX = translateX[changedIndex];
const currentTranslateY = e.currentTarget.value;
targetGroup.setAttribute('transform', `rotate(${currentRotate} ${rotationCenter.cx} ${rotationCenter.cy}) translate(${currentTranslateX} ${currentTranslateY})`);
targetGroup.setAttribute('transform', `translate(${currentTranslateX} ${currentTranslateY}) rotate(${currentRotate} ${rotationCenter.cx} ${rotationCenter.cy})`);
}

const rotateGroup = (group: SvgGroup): ChangeEventHandler<HTMLInputElement> => (e) => {
@@ -222,6 +239,41 @@ const useAnimationForm = () => {
};

const values = getFormValues(e.currentTarget.form!, { forceNumberValues: true });
setFormValues(e.currentTarget.form!, { rotateNumber: values.rotate });
const changed = Array.isArray(values.changed) ? values.changed : [values.changed];
const changedIndex = changed.indexOf(group.id);
const translateX = Array.isArray(values.translateX) ? values.translateX : [values.translateX];
const translateY = Array.isArray(values.translateY) ? values.translateY : [values.translateY];
const currentRotate = e.currentTarget.value;
const currentTranslateX = translateX[changedIndex];
const currentTranslateY = translateY[changedIndex];
targetGroup.setAttribute('transform', `translate(${currentTranslateX} ${currentTranslateY}) rotate(${currentRotate} ${rotationCenter.cx} ${rotationCenter.cy})`);
}

const rotateGroupThroughNumber = (group: SvgGroup): ChangeEventHandler<HTMLInputElement> => (e) => {
const { current: svgPreviewWrapper } = svgPreviewRef;
if (!svgPreviewWrapper) {
return;
}

const svgPreviewRoot = svgPreviewWrapper.children[0] as SVGSVGElement | null;
if (!svgPreviewRoot) {
return;
}

const targetGroup = Array.from(svgPreviewRoot.children).find(g => g.id === group.id) as SVGGraphicsElement;
if (!targetGroup) {
return;
}

const rotationCenterReference = Array.from(targetGroup.children).find(e => e.tagName === 'circle') as SVGCircleElement | undefined
const rotationCenter = {
cx: rotationCenterReference?.getAttribute?.('cx') ?? 0,
cy: rotationCenterReference?.getAttribute?.('cy') ?? 0,
};

const values = getFormValues(e.currentTarget.form!, { forceNumberValues: true });
setFormValues(e.currentTarget.form!, { rotate: values.rotateNumber });
const changed = Array.isArray(values.changed) ? values.changed : [values.changed];
const changedIndex = changed.indexOf(group.id);
const translateX = Array.isArray(values.translateX) ? values.translateX : [values.translateX];
@@ -229,14 +281,78 @@ const useAnimationForm = () => {
const currentRotate = e.currentTarget.value;
const currentTranslateX = translateX[changedIndex];
const currentTranslateY = translateY[changedIndex];
targetGroup.setAttribute('transform', `rotate(${currentRotate} ${rotationCenter.cx} ${rotationCenter.cy}) translate(${currentTranslateX} ${currentTranslateY})`);
targetGroup.setAttribute('transform', `translate(${currentTranslateX} ${currentTranslateY}) rotate(${currentRotate} ${rotationCenter.cx} ${rotationCenter.cy})`);
}

const handleFormSubmit: FormEventHandler<HTMLFormElement> = (e) => {
e.preventDefault();
const { submitter } = e.nativeEvent as unknown as { submitter: HTMLButtonElement };
const values = getFormValues(e.currentTarget, { submitter });
console.log(values);
const values = getFormValues(e.currentTarget, { submitter, forceNumberValues: true });
const changed = Array.isArray(values.changed) ? values.changed : [values.changed];
const translateX = Array.isArray(values.translateX) ? values.translateX : [values.translateX];
const translateY = Array.isArray(values.translateY) ? values.translateY : [values.translateY];
const rotate = Array.isArray(values.rotate) ? values.rotate : [values.rotate];
switch (values.action) {
case 'updateTimeKeyframes':
if ('changed' in values) {
// TODO handle keyframes on different animations
// TODO update SVG preview when updating current time
setSavedSvgKeyframes((oldSavedSvgKeyframes = []) => {
const newKeyframe = {
time: values.time as number,
attributes: changed.map((attribute, i) => ({
group: {
id: attribute,
name: attribute,
},
translateX: translateX[i],
translateY: translateY[i],
rotate: rotate[i],
})),
};
if (oldSavedSvgKeyframes.some((k) => k.time === values.time as number)) {
return oldSavedSvgKeyframes.map((k) => k.time === values.time ? newKeyframe : k);
}
return [
...oldSavedSvgKeyframes,
newKeyframe,
]
})
return;
}
setSavedSvgKeyframes((oldSavedSvgKeyframes = []) => oldSavedSvgKeyframes.filter(k => k.time !== values.time as number));
return;
default:
break;
}
}

const updateTime: ChangeEventHandler<HTMLInputElement> = (e) => {
const value = e.currentTarget.valueAsNumber;
const form = e.currentTarget.form as HTMLFormElement
const keyframe = savedSvgKeyframes?.find((k) => k.time === value);
if (!keyframe) {
setEnabledGroupIds([]);
setFormValues(form, {
changed: svgGroups?.map(() => false),
translateX: svgGroups?.map(() => 0),
translateY: svgGroups?.map(() => 0),
rotate: svgGroups?.map(() => 0),
rotateNumber: svgGroups?.map(() => 0),
})
return;
}
const changed = keyframe.attributes.map((a) => a.group.id);
setEnabledGroupIds(changed);
setTimeout(() => {
setFormValues(form, {
changed: changed,
translateX: keyframe.attributes.map((a) => a.translateX),
translateY: keyframe.attributes.map((a) => a.translateY),
rotate: keyframe.attributes.map((a) => a.rotate),
rotateNumber: keyframe.attributes.map((a) => a.rotate),
});
});
}

return {
@@ -255,9 +371,12 @@ const useAnimationForm = () => {
addKeyframeAttribute,
enabledGroupIds,
rotateGroup,
rotateGroupThroughNumber,
translateXGroup,
translateYGroup,
handleFormSubmit,
savedSvgKeyframes,
updateTime,
}
}

@@ -278,36 +397,61 @@ const IndexPage = () => {
addKeyframeAttribute,
enabledGroupIds,
rotateGroup,
rotateGroupThroughNumber,
translateXGroup,
translateYGroup,
handleFormSubmit,
savedSvgKeyframes,
updateTime,
} = useAnimationForm();

return (
<form className="lg:fixed lg:w-full lg:h-full flex flex-col" onSubmit={handleFormSubmit}>
<div className="flex gap-4">
<label>
<input
type="file"
name="svgFile"
accept="*.svg"
onChange={loadSvgFile}
className="sr-only"
/>
<span className="whitespace-nowrap">
Load SVG File
</span>
</label>
<datalist id="translationTickMarks">
<option value="0">0</option>
</datalist>
<datalist id="rotationTickMarks">
<option value="-360">-360</option>
<option value="-270">-270</option>
<option value="-180">-180</option>
<option value="-90">-90</option>
<option value="0">0</option>
<option value="90">90</option>
<option value="180">180</option>
<option value="270">270</option>
<option value="360">360</option>
</datalist>
<datalist id="timeTickMarks">
{savedSvgKeyframes?.map((k) => (
<option
key={k.time}
value={k.time}
>
{k.time}
</option>
))}
</datalist>
<div className="flex gap-4 px-4 py-2 items-center">
<FileButton
name="svgFile"
accept="*.svg"
onChange={loadSvgFile}
className="whitespace-nowrap"
>
Load SVG File
</FileButton>
{
loadedFileContents
&& (
<>
<button
<Button
type="submit"
className="whitespace-nowrap"
name="action"
value="saveSvgFile"
>
Save SVG File
</button>
</Button>
<div className="text-center w-full">
{loadedFileName}
</div>
@@ -334,7 +478,7 @@ const IndexPage = () => {
<input
list={svgAnimationIdsListId}
name="animationId"
className="w-full block"
className="w-full block h-full px-4 h-12"
defaultValue={svgAnimations?.[0] ?? ''}
onInput={handleAnimationIdInput}
onFocus={focusAnimationIdInput}
@@ -345,29 +489,29 @@ const IndexPage = () => {
{
isNewAnimationId && (
<>
<button
<Button
type="submit"
name="action"
value="renameCurrentAnimationId"
className="whitespace-nowrap"
>
Rename
</button>
<button
</Button>
<Button
type="submit"
name="action"
value="createNewAnimation"
className="whitespace-nowrap"
>
Create New
</button>
<button
</Button>
<Button
type="reset"
className="whitespace-nowrap"
onClick={cancelAnimationIdChange}
>
Cancel
</button>
</Button>
</>
)
}
@@ -375,8 +519,24 @@ const IndexPage = () => {
<div
className="flex gap-4 h-full"
>
<div className="w-full" ref={svgPreviewRef} dangerouslySetInnerHTML={{ __html: loadedFileContents ?? '' }} />
<div className="w-full">
<div
className="overflow-auto bg-[black] relative w-full"
>
<div
className="absolute p-4"
>
<div
className="bg-[white] p-4"
ref={svgPreviewRef}
dangerouslySetInnerHTML={{ __html: loadedFileContents ?? '' }}
/>
</div>
</div>
<div
style={{
width: 560
}}
>
{
Array.isArray(svgGroups) && (
<>
@@ -390,8 +550,10 @@ const IndexPage = () => {
{group.name}
</legend>
<div className="flex">
<div className="w-full">
<label>
<div className="flex-auto">
<label
className="whitespace-nowrap"
>
<input type="checkbox" name="changed" value={group.id} onChange={addKeyframeAttribute} />
{' '}
<span>
@@ -404,14 +566,15 @@ const IndexPage = () => {
>
<span className="sr-only">Translate X</span>
<input
className="w-full block"
type="range"
className="w-full block text-right"
type="number"
name="translateX"
min="-360" max="360"
defaultValue="0"
step="any"
disabled={!enabledGroupIds?.includes(group.name)}
onChange={translateXGroup(group)}
list="translationTickMarks"
/>
</label>
<label
@@ -419,18 +582,19 @@ const IndexPage = () => {
>
<span className="sr-only">Translate Y</span>
<input
className="w-full block"
type="range"
className="w-full block text-right"
type="number"
name="translateY"
min="-360" max="360"
defaultValue="0"
step="any"
disabled={!enabledGroupIds?.includes(group.name)}
onChange={translateYGroup(group)}
list="translationTickMarks"
/>
</label>
<label
className="w-16 block"
className="w-40 block"
>
<span className="sr-only">Rotate</span>
<input
@@ -440,9 +604,26 @@ const IndexPage = () => {
min="-360"
max="360"
defaultValue="0"
step="any"
step="5"
disabled={!enabledGroupIds?.includes(group.name)}
onChange={rotateGroup(group)}
list="rotationTickMarks"
/>
</label>
<label
className="w-12 block"
>
<span className="sr-only">Rotate</span>
<input
className="w-full block text-right"
type="number"
name="rotateNumber"
min="-360"
max="360"
defaultValue="0"
step="5"
disabled={!enabledGroupIds?.includes(group.name)}
onChange={rotateGroupThroughNumber(group)}
/>
</label>
</div>
@@ -454,17 +635,31 @@ const IndexPage = () => {
}
</div>
</div>
<div className="flex gap-4">
<input type="number" name="length" min="1" defaultValue="10" step="1" />
<input className="w-full" type="range" name="time" min="0" max="10" defaultValue="0" step="1" />
<button
<div className="flex gap-4 items-center px-4 py-2">
<div
className="w-16"
>
<input type="number" name="length" min="1" defaultValue="10" step="1" className="block w-full" />
</div>
<input
className="w-full"
list="timeTickMarks"
type="range"
name="time"
min="0"
max="10"
defaultValue="0"
step="1"
onChange={updateTime}
/>
<Button
type="submit"
name="action"
value="addKeyframe"
value="updateTimeKeyframes"
className="whitespace-nowrap"
>
Add Keyframe
</button>
Update Current Time
</Button>
</div>
</div>
)


Loading…
Cancel
Save