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, Scripts,
ScrollRestoration, ScrollRestoration,
} from "@remix-run/react"; } from "@remix-run/react";
import stylesheet from "./tailwind.css";
import stylesheet from '~/tailwind.css';


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


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

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


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


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

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

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

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


const svgAnimationIdsListId = useId(); const svgAnimationIdsListId = useId();


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


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

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


const focusAnimationIdInput: FocusEventHandler<HTMLInputElement> = (e) => { const focusAnimationIdInput: FocusEventHandler<HTMLInputElement> = (e) => {
@@ -163,7 +180,7 @@ const useAnimationForm = () => {
const currentRotate = rotate[changedIndex]; const currentRotate = rotate[changedIndex];
const currentTranslateX = e.currentTarget.value; const currentTranslateX = e.currentTarget.value;
const currentTranslateY = translateY[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 translateYGroup = (group: SvgGroup): ChangeEventHandler<HTMLInputElement> => (e) => { const translateYGroup = (group: SvgGroup): ChangeEventHandler<HTMLInputElement> => (e) => {
@@ -196,7 +213,7 @@ const useAnimationForm = () => {
const currentRotate = rotate[changedIndex]; const currentRotate = rotate[changedIndex];
const currentTranslateX = translateX[changedIndex]; const currentTranslateX = translateX[changedIndex];
const currentTranslateY = e.currentTarget.value; 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) => { const rotateGroup = (group: SvgGroup): ChangeEventHandler<HTMLInputElement> => (e) => {
@@ -222,6 +239,41 @@ const useAnimationForm = () => {
}; };


const values = getFormValues(e.currentTarget.form!, { forceNumberValues: true }); 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 changed = Array.isArray(values.changed) ? values.changed : [values.changed];
const changedIndex = changed.indexOf(group.id); const changedIndex = changed.indexOf(group.id);
const translateX = Array.isArray(values.translateX) ? values.translateX : [values.translateX]; const translateX = Array.isArray(values.translateX) ? values.translateX : [values.translateX];
@@ -229,14 +281,78 @@ const useAnimationForm = () => {
const currentRotate = e.currentTarget.value; const currentRotate = e.currentTarget.value;
const currentTranslateX = translateX[changedIndex]; const currentTranslateX = translateX[changedIndex];
const currentTranslateY = translateY[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) => { const handleFormSubmit: FormEventHandler<HTMLFormElement> = (e) => {
e.preventDefault(); e.preventDefault();
const { submitter } = e.nativeEvent as unknown as { submitter: HTMLButtonElement }; 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 { return {
@@ -255,9 +371,12 @@ const useAnimationForm = () => {
addKeyframeAttribute, addKeyframeAttribute,
enabledGroupIds, enabledGroupIds,
rotateGroup, rotateGroup,
rotateGroupThroughNumber,
translateXGroup, translateXGroup,
translateYGroup, translateYGroup,
handleFormSubmit, handleFormSubmit,
savedSvgKeyframes,
updateTime,
} }
} }


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


return ( return (
<form className="lg:fixed lg:w-full lg:h-full flex flex-col" onSubmit={handleFormSubmit}> <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 loadedFileContents
&& ( && (
<> <>
<button
<Button
type="submit" type="submit"
className="whitespace-nowrap" className="whitespace-nowrap"
name="action"
value="saveSvgFile"
> >
Save SVG File Save SVG File
</button>
</Button>
<div className="text-center w-full"> <div className="text-center w-full">
{loadedFileName} {loadedFileName}
</div> </div>
@@ -334,7 +478,7 @@ const IndexPage = () => {
<input <input
list={svgAnimationIdsListId} list={svgAnimationIdsListId}
name="animationId" name="animationId"
className="w-full block"
className="w-full block h-full px-4 h-12"
defaultValue={svgAnimations?.[0] ?? ''} defaultValue={svgAnimations?.[0] ?? ''}
onInput={handleAnimationIdInput} onInput={handleAnimationIdInput}
onFocus={focusAnimationIdInput} onFocus={focusAnimationIdInput}
@@ -345,29 +489,29 @@ const IndexPage = () => {
{ {
isNewAnimationId && ( isNewAnimationId && (
<> <>
<button
<Button
type="submit" type="submit"
name="action" name="action"
value="renameCurrentAnimationId" value="renameCurrentAnimationId"
className="whitespace-nowrap" className="whitespace-nowrap"
> >
Rename Rename
</button>
<button
</Button>
<Button
type="submit" type="submit"
name="action" name="action"
value="createNewAnimation" value="createNewAnimation"
className="whitespace-nowrap" className="whitespace-nowrap"
> >
Create New Create New
</button>
<button
</Button>
<Button
type="reset" type="reset"
className="whitespace-nowrap" className="whitespace-nowrap"
onClick={cancelAnimationIdChange} onClick={cancelAnimationIdChange}
> >
Cancel Cancel
</button>
</Button>
</> </>
) )
} }
@@ -375,8 +519,24 @@ const IndexPage = () => {
<div <div
className="flex gap-4 h-full" 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) && ( Array.isArray(svgGroups) && (
<> <>
@@ -390,8 +550,10 @@ const IndexPage = () => {
{group.name} {group.name}
</legend> </legend>
<div className="flex"> <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} /> <input type="checkbox" name="changed" value={group.id} onChange={addKeyframeAttribute} />
{' '} {' '}
<span> <span>
@@ -404,14 +566,15 @@ const IndexPage = () => {
> >
<span className="sr-only">Translate X</span> <span className="sr-only">Translate X</span>
<input <input
className="w-full block"
type="range"
className="w-full block text-right"
type="number"
name="translateX" name="translateX"
min="-360" max="360" min="-360" max="360"
defaultValue="0" defaultValue="0"
step="any" step="any"
disabled={!enabledGroupIds?.includes(group.name)} disabled={!enabledGroupIds?.includes(group.name)}
onChange={translateXGroup(group)} onChange={translateXGroup(group)}
list="translationTickMarks"
/> />
</label> </label>
<label <label
@@ -419,18 +582,19 @@ const IndexPage = () => {
> >
<span className="sr-only">Translate Y</span> <span className="sr-only">Translate Y</span>
<input <input
className="w-full block"
type="range"
className="w-full block text-right"
type="number"
name="translateY" name="translateY"
min="-360" max="360" min="-360" max="360"
defaultValue="0" defaultValue="0"
step="any" step="any"
disabled={!enabledGroupIds?.includes(group.name)} disabled={!enabledGroupIds?.includes(group.name)}
onChange={translateYGroup(group)} onChange={translateYGroup(group)}
list="translationTickMarks"
/> />
</label> </label>
<label <label
className="w-16 block"
className="w-40 block"
> >
<span className="sr-only">Rotate</span> <span className="sr-only">Rotate</span>
<input <input
@@ -440,9 +604,26 @@ const IndexPage = () => {
min="-360" min="-360"
max="360" max="360"
defaultValue="0" defaultValue="0"
step="any"
step="5"
disabled={!enabledGroupIds?.includes(group.name)} disabled={!enabledGroupIds?.includes(group.name)}
onChange={rotateGroup(group)} 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> </label>
</div> </div>
@@ -454,17 +635,31 @@ const IndexPage = () => {
} }
</div> </div>
</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" type="submit"
name="action" name="action"
value="addKeyframe"
value="updateTimeKeyframes"
className="whitespace-nowrap" className="whitespace-nowrap"
> >
Add Keyframe
</button>
Update Current Time
</Button>
</div> </div>
</div> </div>
) )


Loading…
Cancel
Save