2D Run-and-gun shooter inspired by One Man's Doomsday, Counter-Strike, and Metal Slug.
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671
  1. import {
  2. ChangeEventHandler, FocusEventHandler,
  3. FormEventHandler,
  4. MouseEventHandler,
  5. useEffect,
  6. useId,
  7. useRef,
  8. useState,
  9. } from 'react';
  10. import { getFormValues, setFormValues } from '@theoryofnekomata/formxtra';
  11. import { Button } from '~/components/Button';
  12. import {FileButton} from '~/components/FileButton';
  13. type SvgGroup = {
  14. id: string;
  15. name: string;
  16. }
  17. type KeyframeAttribute = {
  18. group: SvgGroup;
  19. translateX: number;
  20. translateY: number;
  21. rotate: number;
  22. }
  23. type Keyframe = {
  24. time: number;
  25. attributes: KeyframeAttribute[];
  26. }
  27. const useAnimationForm = () => {
  28. const [loadedFileName, setLoadedFileName] = useState<string>();
  29. const [loadedFileContents, setLoadedFileContents] = useState<string>();
  30. const [svgAnimations, setSvgAnimations] = useState<string[]>();
  31. const [svgGroups, setSvgGroups] = useState<SvgGroup[]>();
  32. const [isNewAnimationId, setIsNewAnimationId] = useState<boolean>();
  33. const [currentAnimationId, setCurrentAnimationId] = useState<string>();
  34. const [enabledGroupIds, setEnabledGroupIds] = useState<string[]>();
  35. const [savedSvgKeyframes, setSavedSvgKeyframes] = useState<Keyframe[]>();
  36. const svgAnimationIdsListId = useId();
  37. const svgPreviewRef = useRef<HTMLDivElement>(null);
  38. const readFile = async (file: File) => {
  39. const fileReader = new FileReader()
  40. return new Promise<string>((resolve, reject) => {
  41. fileReader.addEventListener('load', (e) => {
  42. if (!e.target) {
  43. reject(new Error('Invalid target'))
  44. return;
  45. }
  46. resolve(e.target.result as string);
  47. })
  48. fileReader.addEventListener('error', () => {
  49. reject(new Error('Error reading file'))
  50. });
  51. fileReader.readAsText(file);
  52. });
  53. }
  54. const loadSvgFile: ChangeEventHandler<HTMLInputElement> = async (e) => {
  55. const filePicker = e.currentTarget;
  56. if (!filePicker.files) {
  57. return;
  58. }
  59. const [file] = Array.from(filePicker.files);
  60. if (!file) {
  61. return;
  62. }
  63. setLoadedFileName(file.name);
  64. setIsNewAnimationId(false);
  65. const result = await readFile(file);
  66. setLoadedFileContents(result);
  67. filePicker.value = ''
  68. }
  69. const handleAnimationIdInput: FormEventHandler<HTMLInputElement> = (e) => {
  70. const nativeEvent = e.nativeEvent as unknown as { inputType?: string };
  71. switch (nativeEvent.inputType) {
  72. case 'deleteContentBackward':
  73. case 'deleteContentForward':
  74. case 'insertText':
  75. // we type into the input
  76. setIsNewAnimationId(true);
  77. break;
  78. default:
  79. // we select from the datalist
  80. const target = e.currentTarget;
  81. setIsNewAnimationId(false);
  82. setCurrentAnimationId(e.currentTarget.value);
  83. setTimeout(() => {
  84. target.blur();
  85. });
  86. }
  87. }
  88. const cancelAnimationIdChange: MouseEventHandler<HTMLButtonElement> = (e) => {
  89. e.preventDefault();
  90. setIsNewAnimationId(false);
  91. setFormValues(e.currentTarget.form!, {
  92. animationId: currentAnimationId,
  93. })
  94. }
  95. useEffect(() => {
  96. if (!loadedFileContents) {
  97. return
  98. }
  99. if (!svgPreviewRef.current) {
  100. return
  101. }
  102. const svgPreviewRoot = svgPreviewRef.current.children[0];
  103. setSvgAnimations(['default']);
  104. setSvgGroups(
  105. Array
  106. .from(svgPreviewRoot.children)
  107. .map((g) => ({
  108. id: g.id,
  109. name: g.id,
  110. }))
  111. );
  112. setCurrentAnimationId('default');
  113. setEnabledGroupIds([]); // TODO - compute enabled groupIds based on current animation id
  114. setSavedSvgKeyframes([]); // TODO - load keyframes from file
  115. }, [loadedFileContents, svgPreviewRef.current]);
  116. const focusAnimationIdInput: FocusEventHandler<HTMLInputElement> = (e) => {
  117. e.currentTarget.value = '';
  118. }
  119. const blurAnimationIdInput: FocusEventHandler<HTMLInputElement> = (e) => {
  120. e.currentTarget.value = currentAnimationId as string;
  121. setIsNewAnimationId(false);
  122. }
  123. const addKeyframeAttribute: ChangeEventHandler<HTMLInputElement> = (e) => {
  124. const { currentTarget } = e;
  125. if (currentTarget.checked) {
  126. setEnabledGroupIds((oldEnabledGroupIds = []) => [...oldEnabledGroupIds, currentTarget.value]);
  127. return
  128. }
  129. setEnabledGroupIds((oldEnabledGroupIds = []) => oldEnabledGroupIds.filter((i) => i !== currentTarget.value));
  130. }
  131. const translateXGroup = (group: SvgGroup): ChangeEventHandler<HTMLInputElement> => (e) => {
  132. const { current: svgPreviewWrapper } = svgPreviewRef;
  133. if (!svgPreviewWrapper) {
  134. return;
  135. }
  136. const svgPreviewRoot = svgPreviewWrapper.children[0] as SVGSVGElement | null;
  137. if (!svgPreviewRoot) {
  138. return;
  139. }
  140. const targetGroup = Array.from(svgPreviewRoot.children).find(g => g.id === group.id) as SVGGraphicsElement;
  141. if (!targetGroup) {
  142. return;
  143. }
  144. const rotationCenterReference = Array.from(targetGroup.children).find(e => e.tagName === 'circle') as SVGCircleElement | undefined
  145. const rotationCenter = {
  146. cx: rotationCenterReference?.getAttribute?.('cx') ?? 0,
  147. cy: rotationCenterReference?.getAttribute?.('cy') ?? 0,
  148. };
  149. const values = getFormValues(e.currentTarget.form!, { forceNumberValues: true });
  150. const changed = Array.isArray(values.changed) ? values.changed : [values.changed];
  151. const changedIndex = changed.indexOf(group.id);
  152. const rotate = Array.isArray(values.rotate) ? values.rotate : [values.rotate];
  153. const translateY = Array.isArray(values.translateY) ? values.translateY : [values.translateY];
  154. const currentRotate = rotate[changedIndex];
  155. const currentTranslateX = e.currentTarget.value;
  156. const currentTranslateY = translateY[changedIndex];
  157. targetGroup.setAttribute('transform', `translate(${currentTranslateX} ${currentTranslateY}) rotate(${currentRotate} ${rotationCenter.cx} ${rotationCenter.cy})`);
  158. }
  159. const translateYGroup = (group: SvgGroup): ChangeEventHandler<HTMLInputElement> => (e) => {
  160. const { current: svgPreviewWrapper } = svgPreviewRef;
  161. if (!svgPreviewWrapper) {
  162. return;
  163. }
  164. const svgPreviewRoot = svgPreviewWrapper.children[0] as SVGSVGElement | null;
  165. if (!svgPreviewRoot) {
  166. return;
  167. }
  168. const targetGroup = Array.from(svgPreviewRoot.children).find(g => g.id === group.id) as SVGGraphicsElement;
  169. if (!targetGroup) {
  170. return;
  171. }
  172. const rotationCenterReference = Array.from(targetGroup.children).find(e => e.tagName === 'circle') as SVGCircleElement | undefined
  173. const rotationCenter = {
  174. cx: rotationCenterReference?.getAttribute?.('cx') ?? 0,
  175. cy: rotationCenterReference?.getAttribute?.('cy') ?? 0,
  176. };
  177. const values = getFormValues(e.currentTarget.form!, { forceNumberValues: true });
  178. const changed = Array.isArray(values.changed) ? values.changed : [values.changed];
  179. const changedIndex = changed.indexOf(group.id);
  180. const rotate = Array.isArray(values.rotate) ? values.rotate : [values.rotate];
  181. const translateX = Array.isArray(values.translateX) ? values.translateX : [values.translateX];
  182. const currentRotate = rotate[changedIndex];
  183. const currentTranslateX = translateX[changedIndex];
  184. const currentTranslateY = e.currentTarget.value;
  185. targetGroup.setAttribute('transform', `translate(${currentTranslateX} ${currentTranslateY}) rotate(${currentRotate} ${rotationCenter.cx} ${rotationCenter.cy})`);
  186. }
  187. const rotateGroup = (group: SvgGroup): ChangeEventHandler<HTMLInputElement> => (e) => {
  188. const { current: svgPreviewWrapper } = svgPreviewRef;
  189. if (!svgPreviewWrapper) {
  190. return;
  191. }
  192. const svgPreviewRoot = svgPreviewWrapper.children[0] as SVGSVGElement | null;
  193. if (!svgPreviewRoot) {
  194. return;
  195. }
  196. const targetGroup = Array.from(svgPreviewRoot.children).find(g => g.id === group.id) as SVGGraphicsElement;
  197. if (!targetGroup) {
  198. return;
  199. }
  200. const rotationCenterReference = Array.from(targetGroup.children).find(e => e.tagName === 'circle') as SVGCircleElement | undefined
  201. const rotationCenter = {
  202. cx: rotationCenterReference?.getAttribute?.('cx') ?? 0,
  203. cy: rotationCenterReference?.getAttribute?.('cy') ?? 0,
  204. };
  205. const values = getFormValues(e.currentTarget.form!, { forceNumberValues: true });
  206. setFormValues(e.currentTarget.form!, { rotateNumber: values.rotate });
  207. const changed = Array.isArray(values.changed) ? values.changed : [values.changed];
  208. const changedIndex = changed.indexOf(group.id);
  209. const translateX = Array.isArray(values.translateX) ? values.translateX : [values.translateX];
  210. const translateY = Array.isArray(values.translateY) ? values.translateY : [values.translateY];
  211. const currentRotate = e.currentTarget.value;
  212. const currentTranslateX = translateX[changedIndex];
  213. const currentTranslateY = translateY[changedIndex];
  214. targetGroup.setAttribute('transform', `translate(${currentTranslateX} ${currentTranslateY}) rotate(${currentRotate} ${rotationCenter.cx} ${rotationCenter.cy})`);
  215. }
  216. const rotateGroupThroughNumber = (group: SvgGroup): ChangeEventHandler<HTMLInputElement> => (e) => {
  217. const { current: svgPreviewWrapper } = svgPreviewRef;
  218. if (!svgPreviewWrapper) {
  219. return;
  220. }
  221. const svgPreviewRoot = svgPreviewWrapper.children[0] as SVGSVGElement | null;
  222. if (!svgPreviewRoot) {
  223. return;
  224. }
  225. const targetGroup = Array.from(svgPreviewRoot.children).find(g => g.id === group.id) as SVGGraphicsElement;
  226. if (!targetGroup) {
  227. return;
  228. }
  229. const rotationCenterReference = Array.from(targetGroup.children).find(e => e.tagName === 'circle') as SVGCircleElement | undefined
  230. const rotationCenter = {
  231. cx: rotationCenterReference?.getAttribute?.('cx') ?? 0,
  232. cy: rotationCenterReference?.getAttribute?.('cy') ?? 0,
  233. };
  234. const values = getFormValues(e.currentTarget.form!, { forceNumberValues: true });
  235. setFormValues(e.currentTarget.form!, { rotate: values.rotateNumber });
  236. const changed = Array.isArray(values.changed) ? values.changed : [values.changed];
  237. const changedIndex = changed.indexOf(group.id);
  238. const translateX = Array.isArray(values.translateX) ? values.translateX : [values.translateX];
  239. const translateY = Array.isArray(values.translateY) ? values.translateY : [values.translateY];
  240. const currentRotate = e.currentTarget.value;
  241. const currentTranslateX = translateX[changedIndex];
  242. const currentTranslateY = translateY[changedIndex];
  243. targetGroup.setAttribute('transform', `translate(${currentTranslateX} ${currentTranslateY}) rotate(${currentRotate} ${rotationCenter.cx} ${rotationCenter.cy})`);
  244. }
  245. const handleFormSubmit: FormEventHandler<HTMLFormElement> = (e) => {
  246. e.preventDefault();
  247. const { submitter } = e.nativeEvent as unknown as { submitter: HTMLButtonElement };
  248. const values = getFormValues(e.currentTarget, { submitter, forceNumberValues: true });
  249. const changed = Array.isArray(values.changed) ? values.changed : [values.changed];
  250. const translateX = Array.isArray(values.translateX) ? values.translateX : [values.translateX];
  251. const translateY = Array.isArray(values.translateY) ? values.translateY : [values.translateY];
  252. const rotate = Array.isArray(values.rotate) ? values.rotate : [values.rotate];
  253. switch (values.action) {
  254. case 'updateTimeKeyframes':
  255. if ('changed' in values) {
  256. // TODO handle keyframes on different animations
  257. // TODO update SVG preview when updating current time
  258. setSavedSvgKeyframes((oldSavedSvgKeyframes = []) => {
  259. const newKeyframe = {
  260. time: values.time as number,
  261. attributes: changed.map((attribute, i) => ({
  262. group: {
  263. id: attribute,
  264. name: attribute,
  265. },
  266. translateX: translateX[i],
  267. translateY: translateY[i],
  268. rotate: rotate[i],
  269. })),
  270. };
  271. if (oldSavedSvgKeyframes.some((k) => k.time === values.time as number)) {
  272. return oldSavedSvgKeyframes.map((k) => k.time === values.time ? newKeyframe : k);
  273. }
  274. return [
  275. ...oldSavedSvgKeyframes,
  276. newKeyframe,
  277. ]
  278. })
  279. return;
  280. }
  281. setSavedSvgKeyframes((oldSavedSvgKeyframes = []) => oldSavedSvgKeyframes.filter(k => k.time !== values.time as number));
  282. return;
  283. default:
  284. break;
  285. }
  286. }
  287. const updateTime: ChangeEventHandler<HTMLInputElement> = (e) => {
  288. const value = e.currentTarget.valueAsNumber;
  289. const form = e.currentTarget.form as HTMLFormElement
  290. const keyframe = savedSvgKeyframes?.find((k) => k.time === value);
  291. if (!keyframe) {
  292. setEnabledGroupIds([]);
  293. setFormValues(form, {
  294. changed: svgGroups?.map(() => false),
  295. translateX: svgGroups?.map(() => 0),
  296. translateY: svgGroups?.map(() => 0),
  297. rotate: svgGroups?.map(() => 0),
  298. rotateNumber: svgGroups?.map(() => 0),
  299. })
  300. return;
  301. }
  302. const changed = keyframe.attributes.map((a) => a.group.id);
  303. setEnabledGroupIds(changed);
  304. setTimeout(() => {
  305. setFormValues(form, {
  306. changed: changed,
  307. translateX: keyframe.attributes.map((a) => a.translateX),
  308. translateY: keyframe.attributes.map((a) => a.translateY),
  309. rotate: keyframe.attributes.map((a) => a.rotate),
  310. rotateNumber: keyframe.attributes.map((a) => a.rotate),
  311. });
  312. });
  313. }
  314. return {
  315. isNewAnimationId,
  316. svgAnimations,
  317. loadedFileName,
  318. loadedFileContents,
  319. loadSvgFile,
  320. svgPreviewRef,
  321. svgGroups,
  322. svgAnimationIdsListId,
  323. handleAnimationIdInput,
  324. cancelAnimationIdChange,
  325. focusAnimationIdInput,
  326. blurAnimationIdInput,
  327. addKeyframeAttribute,
  328. enabledGroupIds,
  329. rotateGroup,
  330. rotateGroupThroughNumber,
  331. translateXGroup,
  332. translateYGroup,
  333. handleFormSubmit,
  334. savedSvgKeyframes,
  335. updateTime,
  336. }
  337. }
  338. const IndexPage = () => {
  339. const {
  340. loadedFileName,
  341. loadedFileContents,
  342. loadSvgFile,
  343. svgPreviewRef,
  344. svgGroups,
  345. svgAnimations,
  346. svgAnimationIdsListId,
  347. handleAnimationIdInput,
  348. isNewAnimationId,
  349. cancelAnimationIdChange,
  350. focusAnimationIdInput,
  351. blurAnimationIdInput,
  352. addKeyframeAttribute,
  353. enabledGroupIds,
  354. rotateGroup,
  355. rotateGroupThroughNumber,
  356. translateXGroup,
  357. translateYGroup,
  358. handleFormSubmit,
  359. savedSvgKeyframes,
  360. updateTime,
  361. } = useAnimationForm();
  362. return (
  363. <form className="lg:fixed lg:w-full lg:h-full flex flex-col" onSubmit={handleFormSubmit}>
  364. <datalist id="translationTickMarks">
  365. <option value="0">0</option>
  366. </datalist>
  367. <datalist id="rotationTickMarks">
  368. <option value="-360">-360</option>
  369. <option value="-270">-270</option>
  370. <option value="-180">-180</option>
  371. <option value="-90">-90</option>
  372. <option value="0">0</option>
  373. <option value="90">90</option>
  374. <option value="180">180</option>
  375. <option value="270">270</option>
  376. <option value="360">360</option>
  377. </datalist>
  378. <datalist id="timeTickMarks">
  379. {savedSvgKeyframes?.map((k) => (
  380. <option
  381. key={k.time}
  382. value={k.time}
  383. >
  384. {k.time}
  385. </option>
  386. ))}
  387. </datalist>
  388. <div className="flex gap-4 px-4 py-2 items-center">
  389. <FileButton
  390. name="svgFile"
  391. accept="*.svg"
  392. onChange={loadSvgFile}
  393. className="whitespace-nowrap"
  394. >
  395. Load SVG File
  396. </FileButton>
  397. {
  398. loadedFileContents
  399. && (
  400. <>
  401. <Button
  402. type="submit"
  403. className="whitespace-nowrap"
  404. name="action"
  405. value="saveSvgFile"
  406. >
  407. Save SVG File
  408. </Button>
  409. <div className="text-center w-full">
  410. {loadedFileName}
  411. </div>
  412. </>
  413. )
  414. }
  415. </div>
  416. {
  417. loadedFileContents
  418. && (
  419. <div className="h-full flex flex-col">
  420. <div className="flex gap-4">
  421. <datalist
  422. id={svgAnimationIdsListId}
  423. >
  424. {svgAnimations?.map(s => (
  425. <option key={s}>{s}</option>
  426. ))}
  427. </datalist>
  428. <label className="block w-full">
  429. <span className="sr-only">
  430. Animation ID
  431. </span>
  432. <input
  433. list={svgAnimationIdsListId}
  434. name="animationId"
  435. className="w-full block h-full px-4 h-12"
  436. defaultValue={svgAnimations?.[0] ?? ''}
  437. onInput={handleAnimationIdInput}
  438. onFocus={focusAnimationIdInput}
  439. onBlur={blurAnimationIdInput}
  440. autoComplete="off"
  441. />
  442. </label>
  443. {
  444. isNewAnimationId && (
  445. <>
  446. <Button
  447. type="submit"
  448. name="action"
  449. value="renameCurrentAnimationId"
  450. className="whitespace-nowrap"
  451. >
  452. Rename
  453. </Button>
  454. <Button
  455. type="submit"
  456. name="action"
  457. value="createNewAnimation"
  458. className="whitespace-nowrap"
  459. >
  460. Create New
  461. </Button>
  462. <Button
  463. type="reset"
  464. className="whitespace-nowrap"
  465. onClick={cancelAnimationIdChange}
  466. >
  467. Cancel
  468. </Button>
  469. </>
  470. )
  471. }
  472. </div>
  473. <div
  474. className="flex gap-4 h-full"
  475. >
  476. <div
  477. className="overflow-auto bg-[black] relative w-full"
  478. >
  479. <div
  480. className="absolute p-4"
  481. >
  482. <div
  483. className="bg-[white] p-4"
  484. ref={svgPreviewRef}
  485. dangerouslySetInnerHTML={{ __html: loadedFileContents ?? '' }}
  486. />
  487. </div>
  488. </div>
  489. <div
  490. style={{
  491. width: 560
  492. }}
  493. >
  494. {
  495. Array.isArray(svgGroups) && (
  496. <>
  497. {svgGroups.map((group) => {
  498. return (
  499. <fieldset
  500. key={group.id}
  501. className="contents"
  502. >
  503. <legend className="sr-only">
  504. {group.name}
  505. </legend>
  506. <div className="flex">
  507. <div className="flex-auto">
  508. <label
  509. className="whitespace-nowrap"
  510. >
  511. <input type="checkbox" name="changed" value={group.id} onChange={addKeyframeAttribute} />
  512. {' '}
  513. <span>
  514. {group.name}
  515. </span>
  516. </label>
  517. </div>
  518. <label
  519. className="w-16 block"
  520. >
  521. <span className="sr-only">Translate X</span>
  522. <input
  523. className="w-full block text-right"
  524. type="number"
  525. name="translateX"
  526. min="-360" max="360"
  527. defaultValue="0"
  528. step="any"
  529. disabled={!enabledGroupIds?.includes(group.name)}
  530. onChange={translateXGroup(group)}
  531. list="translationTickMarks"
  532. />
  533. </label>
  534. <label
  535. className="w-16 block"
  536. >
  537. <span className="sr-only">Translate Y</span>
  538. <input
  539. className="w-full block text-right"
  540. type="number"
  541. name="translateY"
  542. min="-360" max="360"
  543. defaultValue="0"
  544. step="any"
  545. disabled={!enabledGroupIds?.includes(group.name)}
  546. onChange={translateYGroup(group)}
  547. list="translationTickMarks"
  548. />
  549. </label>
  550. <label
  551. className="w-40 block"
  552. >
  553. <span className="sr-only">Rotate</span>
  554. <input
  555. className="w-full block"
  556. type="range"
  557. name="rotate"
  558. min="-360"
  559. max="360"
  560. defaultValue="0"
  561. step="5"
  562. disabled={!enabledGroupIds?.includes(group.name)}
  563. onChange={rotateGroup(group)}
  564. list="rotationTickMarks"
  565. />
  566. </label>
  567. <label
  568. className="w-12 block"
  569. >
  570. <span className="sr-only">Rotate</span>
  571. <input
  572. className="w-full block text-right"
  573. type="number"
  574. name="rotateNumber"
  575. min="-360"
  576. max="360"
  577. defaultValue="0"
  578. step="5"
  579. disabled={!enabledGroupIds?.includes(group.name)}
  580. onChange={rotateGroupThroughNumber(group)}
  581. />
  582. </label>
  583. </div>
  584. </fieldset>
  585. )
  586. })}
  587. </>
  588. )
  589. }
  590. </div>
  591. </div>
  592. <div className="flex gap-4 items-center px-4 py-2">
  593. <div
  594. className="w-16"
  595. >
  596. <input type="number" name="length" min="1" defaultValue="10" step="1" className="block w-full" />
  597. </div>
  598. <input
  599. className="w-full"
  600. list="timeTickMarks"
  601. type="range"
  602. name="time"
  603. min="0"
  604. max="10"
  605. defaultValue="0"
  606. step="1"
  607. onChange={updateTime}
  608. />
  609. <Button
  610. type="submit"
  611. name="action"
  612. value="updateTimeKeyframes"
  613. className="whitespace-nowrap"
  614. >
  615. Update Current Time
  616. </Button>
  617. </div>
  618. </div>
  619. )
  620. }
  621. </form>
  622. );
  623. }
  624. export default IndexPage;