|
|
@@ -0,0 +1,476 @@ |
|
|
|
import { |
|
|
|
ChangeEventHandler, FocusEventHandler, |
|
|
|
FormEventHandler, |
|
|
|
MouseEventHandler, |
|
|
|
useEffect, |
|
|
|
useId, |
|
|
|
useRef, |
|
|
|
useState, |
|
|
|
} from 'react'; |
|
|
|
import { getFormValues, setFormValues } from '@theoryofnekomata/formxtra'; |
|
|
|
|
|
|
|
type SvgGroup = { |
|
|
|
id: string; |
|
|
|
name: string; |
|
|
|
} |
|
|
|
|
|
|
|
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 svgAnimationIdsListId = useId(); |
|
|
|
|
|
|
|
const svgPreviewRef = useRef<HTMLDivElement>(null); |
|
|
|
|
|
|
|
const readFile = async (file: File) => { |
|
|
|
const fileReader = new FileReader() |
|
|
|
|
|
|
|
return new Promise<string>((resolve, reject) => { |
|
|
|
fileReader.addEventListener('load', (e) => { |
|
|
|
if (!e.target) { |
|
|
|
reject(new Error('Invalid target')) |
|
|
|
return; |
|
|
|
} |
|
|
|
|
|
|
|
resolve(e.target.result as string); |
|
|
|
}) |
|
|
|
|
|
|
|
fileReader.addEventListener('error', () => { |
|
|
|
reject(new Error('Error reading file')) |
|
|
|
}); |
|
|
|
|
|
|
|
fileReader.readAsText(file); |
|
|
|
}); |
|
|
|
} |
|
|
|
|
|
|
|
const loadSvgFile: ChangeEventHandler<HTMLInputElement> = async (e) => { |
|
|
|
const filePicker = e.currentTarget; |
|
|
|
if (!filePicker.files) { |
|
|
|
return; |
|
|
|
} |
|
|
|
const [file] = Array.from(filePicker.files); |
|
|
|
if (!file) { |
|
|
|
return; |
|
|
|
} |
|
|
|
setLoadedFileName(file.name); |
|
|
|
setIsNewAnimationId(false); |
|
|
|
const result = await readFile(file); |
|
|
|
setLoadedFileContents(result); |
|
|
|
filePicker.value = '' |
|
|
|
} |
|
|
|
|
|
|
|
const handleAnimationIdInput: FormEventHandler<HTMLInputElement> = (e) => { |
|
|
|
const nativeEvent = e.nativeEvent as unknown as { inputType?: string }; |
|
|
|
switch (nativeEvent.inputType) { |
|
|
|
case 'deleteContentBackward': |
|
|
|
case 'deleteContentForward': |
|
|
|
case 'insertText': |
|
|
|
// we type into the input |
|
|
|
setIsNewAnimationId(true); |
|
|
|
break; |
|
|
|
default: |
|
|
|
// we select from the datalist |
|
|
|
setIsNewAnimationId(false); |
|
|
|
setCurrentAnimationId(e.currentTarget.value); |
|
|
|
const target = e.currentTarget; |
|
|
|
setTimeout(() => { |
|
|
|
target.blur(); |
|
|
|
}); |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
const cancelAnimationIdChange: MouseEventHandler<HTMLButtonElement> = (e) => { |
|
|
|
e.preventDefault(); |
|
|
|
setIsNewAnimationId(false); |
|
|
|
setFormValues(e.currentTarget.form!, { |
|
|
|
animationId: currentAnimationId, |
|
|
|
}) |
|
|
|
} |
|
|
|
|
|
|
|
useEffect(() => { |
|
|
|
if (!loadedFileContents) { |
|
|
|
return |
|
|
|
} |
|
|
|
|
|
|
|
if (!svgPreviewRef.current) { |
|
|
|
return |
|
|
|
} |
|
|
|
|
|
|
|
setSvgAnimations(['default']); |
|
|
|
setSvgGroups( |
|
|
|
Array |
|
|
|
.from(svgPreviewRef.current.children[0].children) |
|
|
|
.map((g) => ({ |
|
|
|
id: g.id, |
|
|
|
name: g.id, |
|
|
|
})) |
|
|
|
); |
|
|
|
setCurrentAnimationId('default'); |
|
|
|
setEnabledGroupIds([]); // TODO - compute enabled groupIds based on current animation id |
|
|
|
}, [loadedFileContents, svgPreviewRef.current]); |
|
|
|
|
|
|
|
const focusAnimationIdInput: FocusEventHandler<HTMLInputElement> = (e) => { |
|
|
|
e.currentTarget.value = ''; |
|
|
|
} |
|
|
|
|
|
|
|
const blurAnimationIdInput: FocusEventHandler<HTMLInputElement> = (e) => { |
|
|
|
e.currentTarget.value = currentAnimationId as string; |
|
|
|
setIsNewAnimationId(false); |
|
|
|
} |
|
|
|
|
|
|
|
const addKeyframeAttribute: ChangeEventHandler<HTMLInputElement> = (e) => { |
|
|
|
const { currentTarget } = e; |
|
|
|
if (currentTarget.checked) { |
|
|
|
setEnabledGroupIds((oldEnabledGroupIds = []) => [...oldEnabledGroupIds, currentTarget.value]); |
|
|
|
return |
|
|
|
} |
|
|
|
setEnabledGroupIds((oldEnabledGroupIds = []) => oldEnabledGroupIds.filter((i) => i !== currentTarget.value)); |
|
|
|
} |
|
|
|
|
|
|
|
const translateXGroup = (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 }); |
|
|
|
const changed = Array.isArray(values.changed) ? values.changed : [values.changed]; |
|
|
|
const changedIndex = changed.indexOf(group.id); |
|
|
|
const rotate = Array.isArray(values.rotate) ? values.rotate : [values.rotate]; |
|
|
|
const translateY = Array.isArray(values.translateY) ? values.translateY : [values.translateY]; |
|
|
|
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})`); |
|
|
|
} |
|
|
|
|
|
|
|
const translateYGroup = (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 }); |
|
|
|
const changed = Array.isArray(values.changed) ? values.changed : [values.changed]; |
|
|
|
const changedIndex = changed.indexOf(group.id); |
|
|
|
const rotate = Array.isArray(values.rotate) ? values.rotate : [values.rotate]; |
|
|
|
const translateX = Array.isArray(values.translateX) ? values.translateX : [values.translateX]; |
|
|
|
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})`); |
|
|
|
} |
|
|
|
|
|
|
|
const rotateGroup = (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 }); |
|
|
|
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', `rotate(${currentRotate} ${rotationCenter.cx} ${rotationCenter.cy}) translate(${currentTranslateX} ${currentTranslateY})`); |
|
|
|
} |
|
|
|
|
|
|
|
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); |
|
|
|
} |
|
|
|
|
|
|
|
return { |
|
|
|
isNewAnimationId, |
|
|
|
svgAnimations, |
|
|
|
loadedFileName, |
|
|
|
loadedFileContents, |
|
|
|
loadSvgFile, |
|
|
|
svgPreviewRef, |
|
|
|
svgGroups, |
|
|
|
svgAnimationIdsListId, |
|
|
|
handleAnimationIdInput, |
|
|
|
cancelAnimationIdChange, |
|
|
|
focusAnimationIdInput, |
|
|
|
blurAnimationIdInput, |
|
|
|
addKeyframeAttribute, |
|
|
|
enabledGroupIds, |
|
|
|
rotateGroup, |
|
|
|
translateXGroup, |
|
|
|
translateYGroup, |
|
|
|
handleFormSubmit, |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
const IndexPage = () => { |
|
|
|
const { |
|
|
|
loadedFileName, |
|
|
|
loadedFileContents, |
|
|
|
loadSvgFile, |
|
|
|
svgPreviewRef, |
|
|
|
svgGroups, |
|
|
|
svgAnimations, |
|
|
|
svgAnimationIdsListId, |
|
|
|
handleAnimationIdInput, |
|
|
|
isNewAnimationId, |
|
|
|
cancelAnimationIdChange, |
|
|
|
focusAnimationIdInput, |
|
|
|
blurAnimationIdInput, |
|
|
|
addKeyframeAttribute, |
|
|
|
enabledGroupIds, |
|
|
|
rotateGroup, |
|
|
|
translateXGroup, |
|
|
|
translateYGroup, |
|
|
|
handleFormSubmit, |
|
|
|
} = 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> |
|
|
|
{ |
|
|
|
loadedFileContents |
|
|
|
&& ( |
|
|
|
<> |
|
|
|
<button |
|
|
|
type="submit" |
|
|
|
className="whitespace-nowrap" |
|
|
|
> |
|
|
|
Save SVG File |
|
|
|
</button> |
|
|
|
<div className="text-center w-full"> |
|
|
|
{loadedFileName} |
|
|
|
</div> |
|
|
|
</> |
|
|
|
) |
|
|
|
} |
|
|
|
</div> |
|
|
|
{ |
|
|
|
loadedFileContents |
|
|
|
&& ( |
|
|
|
<div className="h-full flex flex-col"> |
|
|
|
<div className="flex gap-4"> |
|
|
|
<datalist |
|
|
|
id={svgAnimationIdsListId} |
|
|
|
> |
|
|
|
{svgAnimations?.map(s => ( |
|
|
|
<option key={s}>{s}</option> |
|
|
|
))} |
|
|
|
</datalist> |
|
|
|
<label className="block w-full"> |
|
|
|
<span className="sr-only"> |
|
|
|
Animation ID |
|
|
|
</span> |
|
|
|
<input |
|
|
|
list={svgAnimationIdsListId} |
|
|
|
name="animationId" |
|
|
|
className="w-full block" |
|
|
|
defaultValue={svgAnimations?.[0] ?? ''} |
|
|
|
onInput={handleAnimationIdInput} |
|
|
|
onFocus={focusAnimationIdInput} |
|
|
|
onBlur={blurAnimationIdInput} |
|
|
|
autoComplete="off" |
|
|
|
/> |
|
|
|
</label> |
|
|
|
{ |
|
|
|
isNewAnimationId && ( |
|
|
|
<> |
|
|
|
<button |
|
|
|
type="submit" |
|
|
|
name="action" |
|
|
|
value="renameCurrentAnimationId" |
|
|
|
className="whitespace-nowrap" |
|
|
|
> |
|
|
|
Rename |
|
|
|
</button> |
|
|
|
<button |
|
|
|
type="submit" |
|
|
|
name="action" |
|
|
|
value="createNewAnimation" |
|
|
|
className="whitespace-nowrap" |
|
|
|
> |
|
|
|
Create New |
|
|
|
</button> |
|
|
|
<button |
|
|
|
type="reset" |
|
|
|
className="whitespace-nowrap" |
|
|
|
onClick={cancelAnimationIdChange} |
|
|
|
> |
|
|
|
Cancel |
|
|
|
</button> |
|
|
|
</> |
|
|
|
) |
|
|
|
} |
|
|
|
</div> |
|
|
|
<div |
|
|
|
className="flex gap-4 h-full" |
|
|
|
> |
|
|
|
<div className="w-full" ref={svgPreviewRef} dangerouslySetInnerHTML={{ __html: loadedFileContents ?? '' }} /> |
|
|
|
<div className="w-full"> |
|
|
|
{ |
|
|
|
Array.isArray(svgGroups) && ( |
|
|
|
<> |
|
|
|
{svgGroups.map((group) => { |
|
|
|
return ( |
|
|
|
<fieldset |
|
|
|
key={group.id} |
|
|
|
className="contents" |
|
|
|
> |
|
|
|
<legend className="sr-only"> |
|
|
|
{group.name} |
|
|
|
</legend> |
|
|
|
<div className="flex"> |
|
|
|
<div className="w-full"> |
|
|
|
<label> |
|
|
|
<input type="checkbox" name="changed" value={group.id} onChange={addKeyframeAttribute} /> |
|
|
|
{' '} |
|
|
|
<span> |
|
|
|
{group.name} |
|
|
|
</span> |
|
|
|
</label> |
|
|
|
</div> |
|
|
|
<label |
|
|
|
className="w-16 block" |
|
|
|
> |
|
|
|
<span className="sr-only">Translate X</span> |
|
|
|
<input |
|
|
|
className="w-full block" |
|
|
|
type="range" |
|
|
|
name="translateX" |
|
|
|
min="-360" max="360" |
|
|
|
defaultValue="0" |
|
|
|
step="any" |
|
|
|
disabled={!enabledGroupIds?.includes(group.name)} |
|
|
|
onChange={translateXGroup(group)} |
|
|
|
/> |
|
|
|
</label> |
|
|
|
<label |
|
|
|
className="w-16 block" |
|
|
|
> |
|
|
|
<span className="sr-only">Translate Y</span> |
|
|
|
<input |
|
|
|
className="w-full block" |
|
|
|
type="range" |
|
|
|
name="translateY" |
|
|
|
min="-360" max="360" |
|
|
|
defaultValue="0" |
|
|
|
step="any" |
|
|
|
disabled={!enabledGroupIds?.includes(group.name)} |
|
|
|
onChange={translateYGroup(group)} |
|
|
|
/> |
|
|
|
</label> |
|
|
|
<label |
|
|
|
className="w-16 block" |
|
|
|
> |
|
|
|
<span className="sr-only">Rotate</span> |
|
|
|
<input |
|
|
|
className="w-full block" |
|
|
|
type="range" |
|
|
|
name="rotate" |
|
|
|
min="-360" |
|
|
|
max="360" |
|
|
|
defaultValue="0" |
|
|
|
step="any" |
|
|
|
disabled={!enabledGroupIds?.includes(group.name)} |
|
|
|
onChange={rotateGroup(group)} |
|
|
|
/> |
|
|
|
</label> |
|
|
|
</div> |
|
|
|
</fieldset> |
|
|
|
) |
|
|
|
})} |
|
|
|
</> |
|
|
|
) |
|
|
|
} |
|
|
|
</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 |
|
|
|
type="submit" |
|
|
|
name="action" |
|
|
|
value="addKeyframe" |
|
|
|
className="whitespace-nowrap" |
|
|
|
> |
|
|
|
Add Keyframe |
|
|
|
</button> |
|
|
|
</div> |
|
|
|
</div> |
|
|
|
) |
|
|
|
} |
|
|
|
</form> |
|
|
|
); |
|
|
|
} |
|
|
|
|
|
|
|
export default IndexPage; |