From 6b41326c40a3ef07d606eb914b59b736a1d2d1e7 Mon Sep 17 00:00:00 2001 From: fayd Date: Wed, 8 May 2024 20:11:11 +0300 Subject: [PATCH] feat: sprig ai (#1619) Co-authored-by: Josias Aurel --- package.json | 2 + .../big-interactive-pages/editor.module.css | 26 +- .../big-interactive-pages/editor.tsx | 655 +++++++---- src/components/navbar-editor.tsx | 997 ++++++++++------ .../popups-etc/chat-component.module.css | 106 ++ src/components/popups-etc/chat-component.tsx | 165 +++ src/components/popups-etc/help.module.css | 63 +- src/components/popups-etc/help.tsx | 93 +- src/lib/hooks/use-local-storage.ts | 46 + yarn.lock | 1036 ++++++++++++++++- 10 files changed, 2572 insertions(+), 617 deletions(-) create mode 100644 src/components/popups-etc/chat-component.module.css create mode 100644 src/components/popups-etc/chat-component.tsx create mode 100644 src/lib/hooks/use-local-storage.ts diff --git a/package.json b/package.json index 975542e05d..031c757a89 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "@lezer/common": "^1.0.2", "@preact/signals": "^1.1.3", "@sendgrid/mail": "^7.7.0", + "@wcj/markdown-to-html": "^3.0.2", "astro": "2.7.0", "dotenv": "^16.3.1", "firebase-admin": "^11.10.1", @@ -50,6 +51,7 @@ "@types/debounce": "^1.2.1", "@types/esprima": "^4.0.3", "@types/grecaptcha": "^3.0.4", + "@types/js-beautify": "^1.14.3", "@types/three": "^0.149.0", "@types/throttle-debounce": "^5.0.0", "@types/w3c-web-serial": "^1.0.3" diff --git a/src/components/big-interactive-pages/editor.module.css b/src/components/big-interactive-pages/editor.module.css index 35cc86b1f5..423eb72971 100644 --- a/src/components/big-interactive-pages/editor.module.css +++ b/src/components/big-interactive-pages/editor.module.css @@ -49,7 +49,6 @@ z-index: -1; } - .codeContainer .errors { font-family: var(--font-code); font-size: 0.9em; @@ -96,6 +95,20 @@ background: var(--accent-light); } +.horizontalResizeBar { + position: relative; + width: 100%; + height: 6px; + min-height: 6px; + cursor: row-resize; + background: transparent; +} + +.horizontalResizeBar:hover, +.horizontalResizeBar.resizing { + background: var(--accent-light); +} + .canvasWrapper { max-height: 100%; } @@ -127,7 +140,8 @@ padding: 8px 0; } -.screenControls .mute, .screenControls .stop { +.screenControls .mute, +.screenControls .stop { cursor: var(--cursor-interactive); user-select: none; color: inherit; @@ -142,7 +156,8 @@ gap: 6px; } -.screenControls .mute :global(svg), .screenControls .stop :global(svg) { +.screenControls .mute :global(svg), +.screenControls .stop :global(svg) { --size: 24px; display: block; width: var(--size); @@ -167,5 +182,8 @@ } .helpContainer { + position: relative; + height: auto; max-width: 100%; -} + width: 100%; +} \ No newline at end of file diff --git a/src/components/big-interactive-pages/editor.tsx b/src/components/big-interactive-pages/editor.tsx index 84d43a2e73..194479626d 100644 --- a/src/components/big-interactive-pages/editor.tsx +++ b/src/components/big-interactive-pages/editor.tsx @@ -1,133 +1,205 @@ -import styles from './editor.module.css' -import CodeMirror from '../codemirror' -import Navbar from '../navbar-editor' -import { IoClose, IoPlayCircleOutline, IoStopCircleOutline, IoVolumeHighOutline, IoVolumeMuteOutline } from 'react-icons/io5' -import { Signal, useComputed, useSignal, useSignalEffect } from '@preact/signals' -import { useEffect, useRef } from 'preact/hooks' -import { codeMirror, errorLog, muted, PersistenceState } from '../../lib/state' -import EditorModal from '../popups-etc/editor-modal' -import { runGame } from '../../lib/engine' -import DraftWarningModal from '../popups-etc/draft-warning' -import Button from '../design-system/button' -import { debounce } from 'throttle-debounce' -import Help from '../popups-etc/help' -import { collapseRanges } from '../../lib/codemirror/util' -import { defaultExampleCode } from '../../lib/examples' -import MigrateToast from '../popups-etc/migrate-toast' -import { nanoid } from 'nanoid' -import TutorialWarningModal from '../popups-etc/tutorial-warning' -import { editSessionLength, switchTheme, ThemeType } from '../../lib/state' +import styles from "./editor.module.css"; +import CodeMirror from "../codemirror"; +import Navbar from "../navbar-editor"; +import { + IoClose, + IoPlayCircleOutline, + IoStopCircleOutline, + IoVolumeHighOutline, + IoVolumeMuteOutline, +} from "react-icons/io5"; +import { + Signal, + useComputed, + useSignal, + useSignalEffect, +} from "@preact/signals"; +import { useEffect, useRef } from "preact/hooks"; +import { codeMirror, errorLog, muted, PersistenceState } from "../../lib/state"; +import EditorModal from "../popups-etc/editor-modal"; +import { runGame } from "../../lib/engine"; +import DraftWarningModal from "../popups-etc/draft-warning"; +import Button from "../design-system/button"; +import { debounce } from "throttle-debounce"; +import Help from "../popups-etc/help"; +import { collapseRanges } from "../../lib/codemirror/util"; +import { defaultExampleCode } from "../../lib/examples"; +import MigrateToast from "../popups-etc/migrate-toast"; +import { nanoid } from "nanoid"; +import TutorialWarningModal from "../popups-etc/tutorial-warning"; +import { editSessionLength, switchTheme, ThemeType } from "../../lib/state"; interface EditorProps { - persistenceState: Signal + persistenceState: Signal; cookies: { - outputAreaSize: number | null - helpAreaSize: number | null - hideHelp: boolean - } + outputAreaSize: number | null; + helpAreaSize: number | null; + hideHelp: boolean; + }; } interface ResizeState { - startMousePos: number - startValue: number + startMousePos: number; + startValue: number; } // Output area is the area with the game view and help -const minOutputAreaWidth = 360 -const defaultOutputAreaWidth = 400 -const outputAreaWidthMargin = 130 // The margin between the editor and output area +const minOutputAreaWidth = 380; +const defaultOutputAreaWidth = 400; +const outputAreaWidthMargin = 130; // The margin between the editor and output area -const minHelpAreaHeight = 200 +const minHelpAreaHeight = 32; +let defaultHelpAreaHeight = 350; +const helpAreaHeightMargin = 0; // The margin between the screen and help area const foldAllTemplateLiterals = () => { - if (!codeMirror.value) return - const code = codeMirror.value.state.doc.toString() ?? '' - const matches = [ ...code.matchAll(/(map|bitmap|tune)`[\s\S]*?`/g) ]; - collapseRanges(codeMirror.value, matches.map((match) => [ match.index!, match.index! + 1 ])) -} - -let lastSavePromise = Promise.resolve() -let saveQueueSize = 0 -export const saveGame = debounce(800, (persistenceState: Signal, code: string) => { - const doSave = async () => { - const attemptSaveGame = async () => { - try { - const game = (persistenceState.value.kind === 'PERSISTED' && persistenceState.value.game !== 'LOADING') ? persistenceState.value.game : null - const res = await fetch('/api/games/save', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ code, gameId: game?.id, tutorialName: game?.tutorialName, tutorialIndex: game?.tutorialIndex }) - }) - if (!res.ok) throw new Error(`Error saving game: ${await res.text()}`) - return true; - } catch (error) { - console.error(error) + if (!codeMirror.value) return; + const code = codeMirror.value.state.doc.toString() ?? ""; + const matches = [...code.matchAll(/(map|bitmap|tune)`[\s\S]*?`/g)]; + collapseRanges( + codeMirror.value, + matches.map((match) => [match.index!, match.index! + 1]) + ); +}; + +let lastSavePromise = Promise.resolve(); +let saveQueueSize = 0; +export const saveGame = debounce( + 800, + (persistenceState: Signal, code: string) => { + const doSave = async () => { + const attemptSaveGame = async () => { + try { + const game = + persistenceState.value.kind === "PERSISTED" && + persistenceState.value.game !== "LOADING" + ? persistenceState.value.game + : null; + const res = await fetch("/api/games/save", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + code, + gameId: game?.id, + tutorialName: game?.tutorialName, + tutorialIndex: game?.tutorialIndex, + }), + }); + if (!res.ok) + throw new Error( + `Error saving game: ${await res.text()}` + ); + return true; + } catch (error) { + console.error(error); + + persistenceState.value = { + ...persistenceState.value, + cloudSaveState: "ERROR", + } as any; + return false; + } + }; + + while (!(await attemptSaveGame())) { + await new Promise((resolve) => setTimeout(resolve, 2000)); // retry saving the game every 2 seconds + } + saveQueueSize--; + if ( + saveQueueSize === 0 && + persistenceState.value.kind === "PERSISTED" + ) { persistenceState.value = { ...persistenceState.value, - cloudSaveState: 'ERROR' - } as any; - return false; + cloudSaveState: "SAVED", + }; } - } - - while (!await attemptSaveGame()) { - await new Promise(resolve => setTimeout(resolve, 2000)); // retry saving the game every 2 seconds - } + }; - saveQueueSize-- - if (saveQueueSize === 0 && persistenceState.value.kind === 'PERSISTED') { - persistenceState.value = { - ...persistenceState.value, - cloudSaveState: 'SAVED' - } - } + saveQueueSize++; + lastSavePromise = (lastSavePromise ?? Promise.resolve()).then(doSave); } - - saveQueueSize++ - lastSavePromise = (lastSavePromise ?? Promise.resolve()).then(doSave) -}) +); const exitTutorial = (persistenceState: Signal) => { - if (persistenceState.value.kind === 'PERSISTED') { - delete persistenceState.value.tutorial - if (typeof persistenceState.value.game !== 'string') { - delete persistenceState.value.game.tutorialName + if (persistenceState.value.kind === "PERSISTED") { + delete persistenceState.value.tutorial; + if (typeof persistenceState.value.game !== "string") { + delete persistenceState.value.game.tutorialName; } persistenceState.value = { ...persistenceState.value, stale: true, - cloudSaveState: 'SAVING' - } - saveGame(persistenceState, codeMirror.value!.state.doc.toString()) + cloudSaveState: "SAVING", + }; + saveGame(persistenceState, codeMirror.value!.state.doc.toString()); } else { - if (persistenceState.value.kind == 'SHARED') - delete persistenceState.value.tutorial + if (persistenceState.value.kind == "SHARED") + delete persistenceState.value.tutorial; } -} +}; export default function Editor({ persistenceState, cookies }: EditorProps) { + const outputArea = useRef(null); + const screenContainer = useRef(null); + const screenControls = useRef(null); + // Resize state storage const outputAreaSize = useSignal( Math.max( minOutputAreaWidth, cookies.outputAreaSize ?? defaultOutputAreaWidth ) - ) + ); + + // this is initially setting the helpAreaSize + const helpAreaSize = useSignal( + Math.max( + minHelpAreaHeight, + cookies.helpAreaSize ?? defaultHelpAreaHeight + ) + ); + + const canvasScreenSize = useSignal({ + height: outputArea.current?.clientHeight! - helpAreaSize.value - screenControls.current?.clientHeight!, + maxHeight: screenContainer.current?.clientHeight + }); + + // this runs when the screenContainer and the outputArea refs change + useEffect(() => { + if (!outputArea.current || !screenContainer.current) return; + defaultHelpAreaHeight = + outputArea.current.clientHeight - + screenContainer.current.clientHeight; + helpAreaSize.value = + outputArea.current.clientHeight - + screenContainer.current.clientHeight; + }, [outputArea.current, screenContainer.current]); useSignalEffect(() => { document.cookie = `outputAreaSize=${ outputAreaSize.value - };path=/;max-age=${60 * 60 * 24 * 365}` - }) + };path=/;max-age=${60 * 60 * 24 * 365}`; + }); + + useSignalEffect(() => { + document.cookie = `helpAreaSize=${helpAreaSize.value};path=/;max-age=${ + 60 * 60 * 24 * 365 + }`; + }); // Exit tutorial warning modal - const showingTutorialWarning = useSignal(false) + const showingTutorialWarning = useSignal(false); + + // Max width of the output area + const maxOutputAreaSize = useSignal(outputAreaSize.value); + + // Max height of help area + const maxHelpAreaSize = useSignal(helpAreaSize.value); - // Max height - const maxOutputAreaSize = useSignal(outputAreaSize.value) useEffect(() => { - // re-intialize the value of the editing session length to since the editor was opened + // re-intialize the value of the editing session length to since the editor was opened editSessionLength.value = new Date(); try { @@ -141,108 +213,160 @@ export default function Editor({ persistenceState, cookies }: EditorProps) { const updateMaxSize = () => { - maxOutputAreaSize.value = (window.innerWidth - outputAreaWidthMargin) / 2.5 - } - window.addEventListener("resize", updateMaxSize, { passive: true }) - updateMaxSize() - return () => window.removeEventListener("resize", updateMaxSize) - }, []) + maxOutputAreaSize.value = + window.innerWidth - outputAreaWidthMargin - 100; + maxHelpAreaSize.value = window.innerHeight - helpAreaHeightMargin; + }; + window.addEventListener("resize", updateMaxSize, { passive: true }); + updateMaxSize(); + return () => window.removeEventListener("resize", updateMaxSize); + }, []); + const realOutputAreaSize = useComputed(() => Math.min( maxOutputAreaSize.value, Math.max(minOutputAreaWidth, outputAreaSize.value) ) - ) + ); + + const realHelpAreaSize = useComputed(() => + Math.min( + maxHelpAreaSize.value, + Math.max(minHelpAreaHeight, helpAreaSize.value) + ) + ); + + // compute the height and max height of the canvas screen + function computeCanvasScreenHeights() { + // compute the new canvas screen height + const canvasScreenHeight = outputArea.current?.clientHeight! - realHelpAreaSize.value - screenControls.current?.clientHeight!; + + // calculate canvas screen max height + // the max height is such that (width/height) == 1.25 + // that is to respect the 1000 / 800 aspect ratio + const canvasScreenMaxHeight = outputArea.current?.clientWidth! / 1.25; + + canvasScreenSize.value = { + height: canvasScreenHeight, + maxHeight: canvasScreenMaxHeight + }; + } // Resize bar logic - const resizeState = useSignal(null) + const resizeState = useSignal(null); + const horizontalResizeState = useSignal(null); useEffect(() => { const onMouseMove = (event: MouseEvent) => { - if (!resizeState.value) return - event.preventDefault() + if (!resizeState.value) return; + event.preventDefault(); outputAreaSize.value = resizeState.value.startValue + resizeState.value.startMousePos - - event.clientX - } - window.addEventListener("mousemove", onMouseMove) - return () => window.removeEventListener("mousemove", onMouseMove) - }, []) + event.clientX; + computeCanvasScreenHeights(); + }; + window.addEventListener("mousemove", onMouseMove); + return () => window.removeEventListener("mousemove", onMouseMove); + }, []); + + // this reacts to change of the helpArea resizes and adjusts things accordingly + useEffect(() => { + const onMouseMove = (event: MouseEvent) => { + if (!horizontalResizeState.value) return; + event.preventDefault(); + helpAreaSize.value = + horizontalResizeState.value.startValue + + horizontalResizeState.value.startMousePos - + event.clientY; + + computeCanvasScreenHeights(); + }; + window.addEventListener("mousemove", onMouseMove); + return () => window.removeEventListener("mousemove", onMouseMove); + }, []); // We like running games! - const screen = useRef(null) - const cleanup = useRef<(() => void) | null>(null) - const screenShake = useSignal(0) + const screen = useRef(null); + const cleanup = useRef<(() => void) | null>(null); + const screenShake = useSignal(0); const onRun = async () => { - foldAllTemplateLiterals() - if (!screen.current) return + foldAllTemplateLiterals(); + if (!screen.current) return; - if (cleanup.current) cleanup.current() - errorLog.value = [] + if (cleanup.current) cleanup.current(); + errorLog.value = []; - const code = codeMirror.value?.state.doc.toString() ?? '' + const code = codeMirror.value?.state.doc.toString() ?? ""; const res = runGame(code, screen.current, (error) => { - errorLog.value = [...errorLog.value, error] - }) + errorLog.value = [...errorLog.value, error]; + }); - screen.current.focus() - screenShake.value++ - setTimeout(() => screenShake.value--, 200) + screen.current.focus(); + screenShake.value++; + setTimeout(() => screenShake.value--, 200); - cleanup.current = res.cleanup + cleanup.current = res.cleanup; if (res.error) { - console.error(res.error.raw) - errorLog.value = [ ...errorLog.value, res.error ] + console.error(res.error.raw); + errorLog.value = [...errorLog.value, res.error]; } - } + }; const onStop = async () => { - if (!screen.current) return + if (!screen.current) return; - if (cleanup.current) cleanup.current() + if (cleanup.current) cleanup.current(); + }; - } - - useEffect(() => () => cleanup.current?.(), []) + useEffect(() => () => cleanup.current?.(), []); // Warn before leave useSignalEffect(() => { - let needsWarning = false - if ([ 'SHARED', 'IN_MEMORY' ].includes(persistenceState.value.kind)) { - needsWarning = persistenceState.value.stale - } else if (persistenceState.value.kind === 'PERSISTED' && persistenceState.value.stale && persistenceState.value.game !== 'LOADING') { - needsWarning = persistenceState.value.cloudSaveState !== 'SAVED' + let needsWarning = false; + if (["SHARED", "IN_MEMORY"].includes(persistenceState.value.kind)) { + needsWarning = persistenceState.value.stale; + } else if ( + persistenceState.value.kind === "PERSISTED" && + persistenceState.value.stale && + persistenceState.value.game !== "LOADING" + ) { + needsWarning = persistenceState.value.cloudSaveState !== "SAVED"; } if (needsWarning) { const onBeforeUnload = (event: BeforeUnloadEvent) => { - event.preventDefault() - event.returnValue = '' - return '' - } - window.addEventListener('beforeunload', onBeforeUnload) - return () => window.removeEventListener('beforeunload', onBeforeUnload) + event.preventDefault(); + event.returnValue = ""; + return ""; + }; + window.addEventListener("beforeunload", onBeforeUnload); + return () => + window.removeEventListener("beforeunload", onBeforeUnload); } else { - return () => {} + return () => {}; } - }) + }); // Disable native save shortcut useEffect(() => { const handler = (event: KeyboardEvent) => { - if (event.key === 's' && (event.metaKey || event.ctrlKey)) event.preventDefault() - } - window.addEventListener('keydown', handler) - return () => window.removeEventListener('keydown', handler) - }, []) - - let initialCode = '' - if (persistenceState.value.kind === 'PERSISTED' && persistenceState.value.game !== 'LOADING') - initialCode = persistenceState.value.game.code - else if (persistenceState.value.kind === 'SHARED') - initialCode = persistenceState.value.code - else if (persistenceState.value.kind === 'IN_MEMORY') - initialCode = localStorage.getItem('sprigMemory') ?? defaultExampleCode + if (event.key === "s" && (event.metaKey || event.ctrlKey)) + event.preventDefault(); + }; + window.addEventListener("keydown", handler); + return () => window.removeEventListener("keydown", handler); + }, []); + + let initialCode = ""; + if ( + persistenceState.value.kind === "PERSISTED" && + persistenceState.value.game !== "LOADING" + ) + initialCode = persistenceState.value.game.code; + else if (persistenceState.value.kind === "SHARED") + initialCode = persistenceState.value.code; + else if (persistenceState.value.kind === "IN_MEMORY") + initialCode = localStorage.getItem("sprigMemory") ?? defaultExampleCode; // Firefox has weird tab restoring logic. When you, for example, Ctrl-Shift-T, it opens // a kinda broken cached version of the page. And for some reason this reverts the CM // state. Seems like manipulating Preact state is unpredictable, but sessionStorage is @@ -251,17 +375,17 @@ export default function Editor({ persistenceState, cookies }: EditorProps) { // // See https://github.com/hackclub/sprig/issues/919 for a bug report this fixes. useEffect(() => { - const pageId = nanoid() - window.addEventListener('unload', () => { - sessionStorage.setItem(pageId, pageId) - }) - window.addEventListener('load', () => { + const pageId = nanoid(); + window.addEventListener("unload", () => { + sessionStorage.setItem(pageId, pageId); + }); + window.addEventListener("load", () => { if (sessionStorage.getItem(pageId)) { - sessionStorage.removeItem('pageId') - window.location.reload() + sessionStorage.removeItem("pageId"); + window.location.reload(); } - }) - }, [ initialCode ]) + }); + }, [initialCode]); return (
@@ -273,117 +397,210 @@ export default function Editor({ persistenceState, cookies }: EditorProps) { class={styles.code} initialCode={initialCode} onEditorView={(editor) => { - codeMirror.value = editor - setTimeout(() => foldAllTemplateLiterals(), 100) // Fold after the document is parsed (gross) + codeMirror.value = editor; + setTimeout(() => foldAllTemplateLiterals(), 100); // Fold after the document is parsed (gross) }} onRunShortcut={onRun} onCodeChange={() => { persistenceState.value = { ...persistenceState.value, - stale: true - } - if (persistenceState.value.kind === 'PERSISTED') { + stale: true, + }; + if (persistenceState.value.kind === "PERSISTED") { persistenceState.value = { ...persistenceState.value, - cloudSaveState: 'SAVING' - } - saveGame(persistenceState, codeMirror.value!.state.doc.toString()) + cloudSaveState: "SAVING", + }; + saveGame( + persistenceState, + codeMirror.value!.state.doc.toString() + ); } - if (persistenceState.value.kind === 'IN_MEMORY') { - localStorage.setItem('sprigMemory', codeMirror.value!.state.doc.toString()) + if (persistenceState.value.kind === "IN_MEMORY") { + localStorage.setItem( + "sprigMemory", + codeMirror.value!.state.doc.toString() + ); } }} /> {errorLog.value.length > 0 && (
- {errorLog.value.map((error, i) => ( -
{error.description}
+
+ {error.description} +
))}
)} - -
{ - document.documentElement.style.cursor = 'col-resize' - resizeState.value = { startMousePos: event.clientX, startValue: realOutputAreaSize.value } - window.addEventListener('mouseup', () => { - resizeState.value = null - document.documentElement.style.cursor = '' - }, { once: true }) + document.documentElement.style.cursor = "col-resize"; + resizeState.value = { + startMousePos: event.clientX, + startValue: realOutputAreaSize.value, + }; + window.addEventListener( + "mouseup", + () => { + resizeState.value = null; + document.documentElement.style.cursor = ""; + }, + { once: true } + ); }} /> -
-
+
+
0 ? 'shake' : ''}`} + class={`${styles.screen} ${ + screenShake.value > 0 ? "shake" : "" + }`} + style={ outputArea.current ? { height: canvasScreenSize.value.height, maxHeight: canvasScreenSize.value.maxHeight }: { } } ref={screen} tabIndex={0} - width='1000' - height='800' + width="1000" + height="800" />
-
- - -
(Sprig screen is 1/8" / 160×128 px)
+
+ (Sprig screen is 1/8" / 160×128 px) +
- -
- {!( - (persistenceState.value.kind === "SHARED" || - persistenceState.value.kind === "PERSISTED") && - persistenceState.value.tutorial - ) && } - - {(persistenceState.value.kind === "SHARED" || - persistenceState.value.kind === "PERSISTED") && - persistenceState.value.tutorial && ( +
+
{ + document.documentElement.style.cursor = + "col-resize"; + horizontalResizeState.value = { + startMousePos: event.clientY, + startValue: realHelpAreaSize.value, + }; + window.addEventListener( + "mouseup", + () => { + horizontalResizeState.value = null; + document.documentElement.style.cursor = + ""; + }, + { once: true } + ); + }} + /> +
+ {!( + (persistenceState.value.kind === "SHARED" || + persistenceState.value.kind === + "PERSISTED") && + persistenceState.value.tutorial + ) && ( )} + + {(persistenceState.value.kind === "SHARED" || + persistenceState.value.kind === "PERSISTED") && + persistenceState.value.tutorial && ( + + )} +
- {persistenceState.value.kind === 'IN_MEMORY' && persistenceState.value.showInitialWarning && ( - - )} + {persistenceState.value.kind === "IN_MEMORY" && + persistenceState.value.showInitialWarning && ( + + )} {showingTutorialWarning.value && ( - exitTutorial(persistenceState)} showingTutorialWarning={showingTutorialWarning} /> + exitTutorial(persistenceState)} + showingTutorialWarning={showingTutorialWarning} + /> )}
- ) -} + ); +} \ No newline at end of file diff --git a/src/components/navbar-editor.tsx b/src/components/navbar-editor.tsx index a9430fbbd5..811fe1edfd 100644 --- a/src/components/navbar-editor.tsx +++ b/src/components/navbar-editor.tsx @@ -1,102 +1,144 @@ -import { Signal, useSignal, useSignalEffect } from '@preact/signals' -import { codeMirror, PersistenceState, errorLog, editSessionLength, themes, theme, switchTheme } from '../lib/state' +import { Signal, useSignal, useSignalEffect } from "@preact/signals"; +import { + codeMirror, + PersistenceState, + errorLog, + editSessionLength, + themes, + theme, + switchTheme, +} from "../lib/state"; import type { ThemeType } from "../lib/state"; -import Button from './design-system/button' -import Textarea from './design-system/textarea' -import SavePrompt from './popups-etc/save-prompt' -import styles from './navbar.module.css' -import { persist } from '../lib/game-saving/auth-helper' -import InlineInput from './design-system/inline-input' -import { throttle } from 'throttle-debounce' -import SharePopup from './popups-etc/share-popup' -import { IoChevronDown, IoLogoGithub, IoPlay, IoSaveOutline, IoShareOutline, IoShuffle, IoWarning, IoBrush } from 'react-icons/io5' +import Button from "./design-system/button"; +import Textarea from "./design-system/textarea"; +import SavePrompt from "./popups-etc/save-prompt"; +import styles from "./navbar.module.css"; +import { persist } from "../lib/game-saving/auth-helper"; +import InlineInput from "./design-system/inline-input"; +import { throttle } from "throttle-debounce"; +import SharePopup from "./popups-etc/share-popup"; +import { + IoChevronDown, + IoLogoGithub, + IoPlay, + IoSaveOutline, + IoShareOutline, + IoShuffle, + IoWarning, +} from "react-icons/io5"; import { FaBrush } from "react-icons/fa"; -import { usePopupCloseClick } from '../lib/utils/popup-close-click' -import { upload, uploadState } from '../lib/upload' -import { VscLoading } from 'react-icons/vsc' -import { defaultExampleCode } from '../lib/examples' +import { usePopupCloseClick } from "../lib/utils/popup-close-click"; +import { upload, uploadState } from "../lib/upload"; +import { VscLoading } from "react-icons/vsc"; +import { defaultExampleCode } from "../lib/examples"; import beautifier from "js-beautify"; -import { collapseRanges } from '../lib/codemirror/util' +import { collapseRanges } from "../lib/codemirror/util"; const saveName = throttle(500, async (gameId: string, newName: string) => { try { - const res = await fetch('/api/games/rename', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ gameId, newName }) - }) - if (!res.ok) throw new Error(`Error renaming game: ${await res.text()}`) + const res = await fetch("/api/games/rename", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ gameId, newName }), + }); + if (!res.ok) + throw new Error(`Error renaming game: ${await res.text()}`); } catch (error) { - console.error(error) + console.error(error); } -}) - -const onNameEdit = (persistenceState: Signal, newName: string) => { - if (persistenceState.value.kind !== 'PERSISTED' || persistenceState.value.game === 'LOADING') return - saveName(persistenceState.value.game.id, newName) +}); + +const onNameEdit = ( + persistenceState: Signal, + newName: string +) => { + if ( + persistenceState.value.kind !== "PERSISTED" || + persistenceState.value.game === "LOADING" + ) + return; + saveName(persistenceState.value.game.id, newName); persistenceState.value = { ...persistenceState.value, game: { ...persistenceState.value.game, - name: newName - } - } -} + name: newName, + }, + }; +}; const canDelete = (persistenceState: Signal) => { - return true - && persistenceState.value.kind === 'PERSISTED' - && persistenceState.value.game !== 'LOADING' - && !persistenceState.value.game.unprotected -} + return ( + true && + persistenceState.value.kind === "PERSISTED" && + persistenceState.value.game !== "LOADING" && + !persistenceState.value.game.unprotected + ); +}; interface EditorNavbarProps { - persistenceState: Signal + persistenceState: Signal; } -type StuckCategory = "Logic Error" | "Syntax Error" | "Other" | "UI" | "Code Compilation" | "Bitmap Editor" | "Tune Editor" | "Help/Tutorial Window" | "AI Chat" | "Website"; +type StuckCategory = + | "Logic Error" + | "Syntax Error" + | "Other" + | "UI" + | "Code Compilation" + | "Bitmap Editor" + | "Tune Editor" + | "Help/Tutorial Window" + | "AI Chat" + | "Website"; type StuckData = { - category: StuckCategory - description: string -} + category: StuckCategory; + description: string; +}; const prettifyCode = () => { + // Check if the codeMirror is ready + if (!codeMirror.value) return; + + // Get the code + const code = codeMirror.value.state.doc.toString(); + + // Set the options for js_beautify + const options = { + indent_size: 2, // Indent by 2 spaces + brace_style: "collapse,preserve-inline", // Collapse braces and preserve inline + }; + + const { js_beautify } = beautifier; + // Format the code + const formattedCode = js_beautify(code, options); + + // Create an update transaction with the formatted code + const updateTransaction = codeMirror.value.state.update({ + changes: { + from: 0, + to: codeMirror.value.state.doc.length, + insert: formattedCode, + }, + }); - // Check if the codeMirror is ready - if (!codeMirror.value) return; - - // Get the code - const code = codeMirror.value.state.doc.toString(); - - // Set the options for js_beautify - const options = { - indent_size: 2, // Indent by 2 spaces - "brace_style": "collapse,preserve-inline", // Collapse braces and preserve inline - }; - - const { js_beautify } = beautifier; - // Format the code - const formattedCode = js_beautify(code, options); - - // Create an update transaction with the formatted code - const updateTransaction = codeMirror.value.state.update({ - changes: { from: 0, to: codeMirror.value.state.doc.length, insert: formattedCode } - }); - - // Find all the matches of the code, bitmap and tune blocks - const matches = [ ...formattedCode.matchAll(/(map|bitmap|tune)`[\s\S]*?`/g) ]; + // Find all the matches of the code, bitmap and tune blocks + const matches = [...formattedCode.matchAll(/(map|bitmap|tune)`[\s\S]*?`/g)]; - // Apply the update to the editor - codeMirror.value.dispatch(updateTransaction); + // Apply the update to the editor + codeMirror.value.dispatch(updateTransaction); - // Collapse the ranges of the matches - collapseRanges(codeMirror.value, matches.map((match) => [ match.index!, match.index! + 1 ])) + // Collapse the ranges of the matches + collapseRanges( + codeMirror.value, + matches.map((match) => [match.index!, match.index! + 1]) + ); }; export default function EditorNavbar(props: EditorNavbarProps) { - const showNavPopup = useSignal(false) - const showStuckPopup = useSignal(false) - const showThemePicker = useSignal(false) + const showNavPopup = useSignal(false); + const showStuckPopup = useSignal(false); + const showThemePicker = useSignal(false); // we will accept the current user's // - name, @@ -104,323 +146,538 @@ export default function EditorNavbar(props: EditorNavbarProps) { // - their description of the issue const stuckData = useSignal({ category: "Other", - description: "" + description: "", }); // keep track of the submit status for "I'm stuck" requests const isSubmitting = useSignal(false); const isLoggedIn = props.persistenceState.value.session ? true : false; - const showSavePrompt = useSignal(false) - const showSharePopup = useSignal(false) + const showSavePrompt = useSignal(false); + const showSharePopup = useSignal(false); - const deleteState = useSignal<'idle' | 'confirm' | 'deleting'>('idle') - const resetState = useSignal<'idle' | 'confirm'>('idle') + const deleteState = useSignal<"idle" | "confirm" | "deleting">("idle"); + const resetState = useSignal<"idle" | "confirm">("idle"); useSignalEffect(() => { - const _showNavPopup = showNavPopup.value - const _deleteState = deleteState.value - const _resetState = resetState.value - if (!_showNavPopup && _deleteState === 'confirm') deleteState.value = 'idle' - if (!_showNavPopup && _resetState === 'confirm') resetState.value = 'idle' - }) + const _showNavPopup = showNavPopup.value; + const _deleteState = deleteState.value; + const _resetState = resetState.value; + if (!_showNavPopup && _deleteState === "confirm") + deleteState.value = "idle"; + if (!_showNavPopup && _resetState === "confirm") + resetState.value = "idle"; + }); // usePopupCloseClick closes a popup when you click outside of its area - usePopupCloseClick(styles.navPopup!, () => showNavPopup.value = false, showNavPopup.value) - usePopupCloseClick(styles.stuckPopup!, () => showStuckPopup.value = false, showStuckPopup.value) - usePopupCloseClick(styles.themePicker!, () => showThemePicker.value = false, showThemePicker.value) - - let saveState - let actionButton - let errorBlink = false - if (props.persistenceState.value.kind === 'IN_MEMORY') { - saveState = 'Your work is unsaved!' - - actionButton = - } else if (props.persistenceState.value.kind === 'SHARED') { + usePopupCloseClick( + styles.navPopup!, + () => (showNavPopup.value = false), + showNavPopup.value + ); + usePopupCloseClick( + styles.stuckPopup!, + () => (showStuckPopup.value = false), + showStuckPopup.value + ); + usePopupCloseClick( + styles.themePicker!, + () => (showThemePicker.value = false), + showThemePicker.value + ); + + let saveState; + let actionButton; + let errorBlink = false; + if (props.persistenceState.value.kind === "IN_MEMORY") { + saveState = "Your work is unsaved!"; + + actionButton = ( + + ); + } else if (props.persistenceState.value.kind === "SHARED") { saveState = props.persistenceState.value.stale - ? 'Your changes are unsaved!' - : 'No changes to save' - - actionButton = - } else if (props.persistenceState.value.kind === 'PERSISTED') { + ? "Your changes are unsaved!" + : "No changes to save"; + + actionButton = ( + + ); + } else if (props.persistenceState.value.kind === "PERSISTED") { saveState = { - SAVED: `Saved to ${props.persistenceState.value.session?.user.email ?? '???'}`, - SAVING: 'Saving...', - ERROR: 'Error saving to cloud' - }[props.persistenceState.value.cloudSaveState] - if (props.persistenceState.value.cloudSaveState === 'ERROR') - errorBlink = true - - actionButton = + SAVED: `Saved to ${ + props.persistenceState.value.session?.user.email ?? "???" + }`, + SAVING: "Saving...", + ERROR: "Error saving to cloud", + }[props.persistenceState.value.cloudSaveState]; + if (props.persistenceState.value.cloudSaveState === "ERROR") + errorBlink = true; + + actionButton = ( + + ); } - return (<> - - - {/* */} - {showSavePrompt.value && showSavePrompt.value = false} - />} - {showSharePopup.value && showSharePopup.value = false} - />} - - {showThemePicker.value && ( -
    - {Object.keys(themes).map(themeKey => { - const themeValue = themes[themeKey as ThemeType]; - return ( -
  • { - theme.value = themeKey as ThemeType; - switchTheme(theme.value); - }}> - - - {themeKey} -
  • - ) - })} -
- )} - - {showStuckPopup.value && ( -
-
{ - event.preventDefault(); // prevent the browser from reloading after form submit - - isSubmitting.value = true; - - // 'from' and 'to' represent the index of character where the selection is started to where it's ended - // if 'from' and 'to' are equal, then it's the cursor position - // from && to being -1 means the cursor is not in the editor - const selectionRange = codeMirror.value?.state.selection.ranges[0] ?? { from: -1, to: -1 }; - - // Store a copy of the user's code, currently active errors and the length of their editing session - // along with their description of the issue - const payload = { - selection: JSON.stringify({ from: selectionRange.from, to: selectionRange.to }), - email: props.persistenceState.value.session?.user.email, - code: codeMirror.value?.state.doc.toString(), - error: errorLog.value, - sessionLength: (new Date().getTime() - editSessionLength.value.getTime()) / 1000, // calculate the session length in seconds - ...stuckData.value - }; - - try { - const response = await fetch("/api/bug-report", { - method: "POST", - body: JSON.stringify(payload) - }) - // Let the user know we'll get back to them after we've receive their complaint - if (response.ok) { - alert("We received your bug report! Thanks!") - } else alert("We couldn't send your request. Please make sure you're connected and try again.") - - } catch (err) { - console.error(err); - } finally { - isSubmitting.value = false; + +
  • {actionButton}
  • + + + {/* */} + {showSavePrompt.value && ( + - - - -