diff --git a/src/components/big-interactive-pages/editor.module.css b/src/components/big-interactive-pages/editor.module.css index 23e108bff..b0603e95e 100644 --- a/src/components/big-interactive-pages/editor.module.css +++ b/src/components/big-interactive-pages/editor.module.css @@ -95,7 +95,7 @@ background: var(--accent-light); } -/* .horizontalResizeBar { +.horizontalResizeBar { width: 100%; height: 6px; min-height: 6px; @@ -106,7 +106,7 @@ .horizontalResizeBar:hover, .horizontalResizeBar.resizing { background: var(--accent-light); -} */ +} .canvasWrapper { max-height: 100%; @@ -116,8 +116,9 @@ border: 2px solid var(--accent); display: block; background: #000000; - width: 100%; height: auto; + max-width: 100%; + aspect-ratio: 160 / 128; max-height: 80%; image-rendering: crisp-edges; image-rendering: pixelated; @@ -183,7 +184,4 @@ .helpContainer { max-width: 100%; width: 100%; - max-height: 100%; - height: 100%; - overflow: auto; } diff --git a/src/components/big-interactive-pages/editor.tsx b/src/components/big-interactive-pages/editor.tsx index 1a26384fc..8278cdef4 100644 --- a/src/components/big-interactive-pages/editor.tsx +++ b/src/components/big-interactive-pages/editor.tsx @@ -1,239 +1,335 @@ -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 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 ])) -} +const minHelpAreaHeight = 32; +let defaultHelpAreaHeight = 350; +const helpAreaHeightMargin = 0; // The margin between the screen and help area -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) +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); + + 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); + // Resize state storage const outputAreaSize = useSignal( Math.max( minOutputAreaWidth, cookies.outputAreaSize ?? defaultOutputAreaWidth ) - ) + ); + + const helpAreaSize = useSignal( + Math.max( + minHelpAreaHeight, + cookies.helpAreaSize ?? defaultHelpAreaHeight + ) + ); + + 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) + const maxOutputAreaSize = useSignal(outputAreaSize.value); + const maxHelpAreaSize = useSignal(helpAreaSize.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(); // load the theme value from localstorage switchTheme((localStorage.getItem("theme") ?? "light") as ThemeType); const updateMaxSize = () => { - maxOutputAreaSize.value = window.innerWidth - outputAreaWidthMargin - 100 - } - 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) + ) + ); // 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; + }; + window.addEventListener("mousemove", onMouseMove); + return () => window.removeEventListener("mousemove", onMouseMove); + }, []); + + useEffect(() => { + const onMouseMove = (event: MouseEvent) => { + if (!horizontalResizeState.value) return; + event.preventDefault(); + helpAreaSize.value = + horizontalResizeState.value.startValue + + horizontalResizeState.value.startMousePos - + event.clientY; + }; + 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 (cleanup.current) cleanup.current() + if (!screen.current) return; - } + 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 @@ -242,17 +338,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 (
@@ -264,113 +360,209 @@ 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" : "" + }`} ref={screen} tabIndex={0} - width='1000' - height='800' + width={realOutputAreaSize.value} + // 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} + /> )}
- ) + ); } diff --git a/src/components/popups-etc/help.tsx b/src/components/popups-etc/help.tsx index a91c2a6b0..4bb57653e 100644 --- a/src/components/popups-etc/help.tsx +++ b/src/components/popups-etc/help.tsx @@ -11,7 +11,9 @@ import ChatComponent from "./chat-component"; interface HelpProps { initialVisible?: boolean; tutorialContent?: string[]; - persistenceState?: Signal; + persistenceState: Signal; + defaultHelpAreaHeight: number; + helpAreaSize: Signal; showingTutorialWarning?: Signal; } const helpHtml = compiledContent(); @@ -103,18 +105,34 @@ export default function Help(props: HelpProps) { Help
)} -
{ showingChat.value = !showingChat.value; showingTutorial.value = false; }} > Chat -
+ +