|
|
@@ -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> |
|
|
|
) |
|
|
|