import { ChangeEventHandler, FocusEventHandler, FormEventHandler, MouseEventHandler, useEffect, useRef, useState, } from 'react'; import { getFormValues, setFormValues } from '@theoryofnekomata/formxtra'; import { Button } from '~/components/Button'; import { FileButton } from '~/components/FileButton'; import * as FileModule from '~/modules/file'; import {ComboBox} from '~/components/ComboBox'; 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(); const [loadedFileContents, setLoadedFileContents] = useState(); const [svgAnimations, setSvgAnimations] = useState(); const [svgGroups, setSvgGroups] = useState(); const [isNewAnimationId, setIsNewAnimationId] = useState(); const [currentAnimationId, setCurrentAnimationId] = useState(); const [enabledGroupIds, setEnabledGroupIds] = useState(); const [savedSvgKeyframes, setSavedSvgKeyframes] = useState(); const svgPreviewRef = useRef(null); const loadSvgFile: ChangeEventHandler = 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 FileModule.readFile(file); setLoadedFileContents(result); filePicker.value = '' } const selectNewAnimationId: FormEventHandler = (e) => { const target = e.currentTarget; setIsNewAnimationId(false); setCurrentAnimationId(e.currentTarget.value); setTimeout(() => { target.blur(); }); } const proposeNewAnimationId: FormEventHandler = () => { setIsNewAnimationId(true); } const cancelAnimationIdChange: MouseEventHandler = (e) => { e.preventDefault(); setIsNewAnimationId(false); setFormValues(e.currentTarget.form!, { animationId: currentAnimationId, }) } const onLoaded = (loadedFileContents?: string, svgPreviewRefCurrent?: HTMLElement | null) => { if (!loadedFileContents) { return } if (!svgPreviewRefCurrent) { return } const svgPreviewRoot = svgPreviewRefCurrent.children[0]; setSvgAnimations(['default']); setSvgGroups( Array .from(svgPreviewRoot.children) .map((g) => ({ id: g.id, name: g.id, })) ); setCurrentAnimationId('default'); setEnabledGroupIds([]); // TODO - compute enabled groupIds based on current animation id setSavedSvgKeyframes([]); // TODO - load keyframes from file } useEffect(() => { onLoaded(loadedFileContents, svgPreviewRef.current); }, [loadedFileContents, svgPreviewRef.current]); const focusAnimationIdInput: FocusEventHandler = (e) => { e.currentTarget.value = ''; } const blurAnimationIdInput: FocusEventHandler = (e) => { e.currentTarget.value = currentAnimationId as string; setIsNewAnimationId(false); } const addKeyframeAttribute: ChangeEventHandler = (e) => { const { currentTarget } = e; if (currentTarget.checked) { setEnabledGroupIds((oldEnabledGroupIds = []) => [...oldEnabledGroupIds, currentTarget.value]); return } setEnabledGroupIds((oldEnabledGroupIds = []) => oldEnabledGroupIds.filter((i) => i !== currentTarget.value)); } const handleAttributeValueChange = (group: SvgGroup): ChangeEventHandler => (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 form = e.currentTarget.form as HTMLFormElement const values = getFormValues(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 rotateNumber = Array.isArray(values.rotateNumber) ? values.rotateNumber : [values.rotateNumber]; const translateX = Array.isArray(values.translateX) ? values.translateX : [values.translateX]; const translateY = Array.isArray(values.translateY) ? values.translateY : [values.translateY]; const currentRotate = e.currentTarget.name.startsWith('rotate') ? e.currentTarget.valueAsNumber : rotate[changedIndex]; const currentTranslateX = e.currentTarget.name.startsWith('translateX') ? e.currentTarget.valueAsNumber : translateX[changedIndex]; const currentTranslateY = e.currentTarget.name.startsWith('translateY') ? e.currentTarget.valueAsNumber : translateY[changedIndex]; if (e.currentTarget.name === 'rotate') { setFormValues(form, { rotateNumber: rotate, }) } else if (e.currentTarget.name === 'rotateNumber') { setFormValues(form, { rotate: rotateNumber, }) } targetGroup.setAttribute('transform', `translate(${currentTranslateX} ${currentTranslateY}) rotate(${currentRotate} ${rotationCenter.cx} ${rotationCenter.cy})`); } const handleFormSubmit: FormEventHandler = (e) => { e.preventDefault(); const { submitter } = e.nativeEvent as unknown as { submitter: HTMLButtonElement }; 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; } console.log(values); } const updateTime: ChangeEventHandler = (e) => { const value = e.currentTarget.valueAsNumber; const form = e.currentTarget.form as HTMLFormElement; const theSavedSvgKeyframes = savedSvgKeyframes ?? []; const sortedSavedSvgKeyframes = theSavedSvgKeyframes.sort((k1, k2) => k1.time - k2.time); const previousKeyframe = [...sortedSavedSvgKeyframes].reverse().find((k) => k.time <= value) ?? sortedSavedSvgKeyframes[0]; const nextKeyframe = sortedSavedSvgKeyframes.find((k) => k.time > value) ?? sortedSavedSvgKeyframes.slice(-1)[0]; const keyframe = savedSvgKeyframes?.find((k) => k.time === value); const hasKeyframeRange = (previousKeyframe || nextKeyframe); if (!keyframe) { setEnabledGroupIds(svgGroups?.map(g => g.id)); // todo interpolate across individual keyframes // get previous keyframe with respect to group // get next keyframe with respect to group // |----------------| // |-----| const interpolatedKeyframe = previousKeyframe; const interpolatedValues = { changed: svgGroups?.map(() => false), translateX: svgGroups?.map((g) => { if (!hasKeyframeRange) { return 0; } }) } setTimeout(() => { // todo set interpolation setFormValues(form, { changed: svgGroups?.map(() => false), translateX: svgGroups?.map(() => 0), translateY: svgGroups?.map(() => 0), rotate: svgGroups?.map(() => 0), rotateNumber: svgGroups?.map(() => 0), }); setEnabledGroupIds([]); }); 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 { isNewAnimationId, svgAnimations, loadedFileName, loadedFileContents, loadSvgFile, svgPreviewRef, svgGroups, selectNewAnimationId, proposeNewAnimationId, cancelAnimationIdChange, focusAnimationIdInput, blurAnimationIdInput, addKeyframeAttribute, enabledGroupIds, handleAttributeValueChange, handleFormSubmit, savedSvgKeyframes, updateTime, } } const IndexPage = () => { const { loadedFileName, loadedFileContents, loadSvgFile, svgPreviewRef, svgGroups, svgAnimations, selectNewAnimationId, proposeNewAnimationId, isNewAnimationId, cancelAnimationIdChange, focusAnimationIdInput, blurAnimationIdInput, addKeyframeAttribute, enabledGroupIds, handleAttributeValueChange, handleFormSubmit, savedSvgKeyframes, updateTime, } = useAnimationForm(); return (
{savedSvgKeyframes?.map((k) => ( ))}
Load SVG File { loadedFileContents && ( <>
{loadedFileName}
) }
{ loadedFileContents && (
{ isNewAnimationId && ( <> ) }
{ Array.isArray(svgGroups) && (
Group
X
Y
Rotate
{svgGroups.map((group) => { return (
{group.name}
) })}
) }
) } ); } export default IndexPage;