diff --git a/playground/src/app.tsx b/playground/src/app.tsx index 73d6e8d0..2ee2be42 100644 --- a/playground/src/app.tsx +++ b/playground/src/app.tsx @@ -1,8 +1,9 @@ -import { useState } from 'react'; +import { useMemo, useState } from 'react'; import clsx from 'clsx'; import { Console } from './components/console/console'; -import { debouncedUpdateEsm, updateEsm } from './utils/esm'; +import { updateEsm } from './utils/esm'; +import { debounce } from './utils/debounce'; import { Editor } from './components/editor/editor'; import { FileBrowser } from './components/file-browser/file-browser'; import { getSearchParam } from './utils/search-params'; @@ -16,26 +17,39 @@ import { useLogs } from './hooks/use-logs'; import styles from './app.module.css'; +const PREVIEW_DEBOUNCE_MS = 500; + export function App() { const [templatePickerOpen, setTemplatePickerOpen] = useState(false); const [autoPlay, setAutoPlay] = useState(getSearchParam('initialAutoPlay')); const [code, setCode] = useState(getSearchParam('initialCode')); const [compiledCode, setCompiledCode] = useState(''); + const [isCompiling, setIsCompiling] = useState(false); const [tab, setTab] = useState(getSearchParam('initialTab')); const [isLightMode, setIsLightMode] = useLightMode(getSearchParam('initialLightMode')); const devTool = useDevTool(); const { logs } = useLogs(); + // Unified run path: dispose any current engine, then re-import the compiled code. + // Safe on first press — disposeEngine is a guarded no-op when no engine exists. + const runCompiled = (text: string) => { + devTool.disposeEngine(); + updateEsm(text); + }; + + // Debounce dispose + re-import together so auto-play never leaves a window where the engine is + // disposed but not yet recreated. Stable instance is safe: disposeEngine reads the engine via + // the hook's ref, so the first-render closure never goes stale. + const debouncedRunCompiled = useMemo(() => debounce(runCompiled, PREVIEW_DEBOUNCE_MS), []); + const onCompiledChange = (text: string) => { setCompiledCode(text); if (autoPlay) { - debouncedUpdateEsm(text); + debouncedRunCompiled(text); } }; - const manualPlay = () => { - updateEsm(compiledCode); - }; + const run = () => runCompiled(compiledCode); return (
@@ -44,7 +58,7 @@ export function App() { autoPlay={autoPlay} code={code} isLightMode={isLightMode} - onPlay={manualPlay} + onPlay={run} setAutoPlay={setAutoPlay} setCode={setCode} setIsLightMode={setIsLightMode} @@ -61,7 +75,15 @@ export function App() { { label: 'Editor', value: 'editor', - content: + content: ( + + ) }, { label: 'Assets', @@ -79,7 +101,7 @@ export function App() {
- +
diff --git a/playground/src/components/button/button.module.css b/playground/src/components/button/button.module.css index 6d2772ef..91851ac0 100644 --- a/playground/src/components/button/button.module.css +++ b/playground/src/components/button/button.module.css @@ -47,3 +47,25 @@ opacity: 0.25; } } + +.icon { + --background-color: transparent; + --background-color-hover: var(--color-zinc-800); + --foreground-color: var(--color-zinc-100); + --border-color: var(--color-zinc-700); + + align-items: center; + border-radius: 50%; + display: inline-flex; + font-size: 14px; + justify-content: center; + padding: 0; + width: 28px; + + :global(html[data-theme='light']) & { + --background-color: transparent; + --background-color-hover: var(--color-zinc-200); + --foreground-color: var(--color-zinc-900); + --border-color: var(--color-zinc-600); + } +} diff --git a/playground/src/components/button/button.tsx b/playground/src/components/button/button.tsx index 984ecdc6..218969d4 100644 --- a/playground/src/components/button/button.tsx +++ b/playground/src/components/button/button.tsx @@ -6,15 +6,16 @@ import styles from './button.module.css'; type Props = { children: ReactNode; onClick: () => void; - variant: 'primary' | 'outline'; + variant: 'primary' | 'outline' | 'icon'; disabled?: boolean; + label?: string; }; export function Button(props: Props) { - const { children, disabled, onClick, variant } = props; + const { children, disabled, label, onClick, variant } = props; return ( - ); diff --git a/playground/src/components/editor/editor.tsx b/playground/src/components/editor/editor.tsx index 4b67c8d3..46841579 100644 --- a/playground/src/components/editor/editor.tsx +++ b/playground/src/components/editor/editor.tsx @@ -12,6 +12,7 @@ type Props = { isLightMode: boolean; onChange: (text: string) => void; onCompiledChange: (text: string) => void; + onCompilingChange?: (compiling: boolean) => void; value: string; }; @@ -25,7 +26,7 @@ const monacoOptions = { } satisfies editor.IStandaloneEditorConstructionOptions; export function Editor(props: Props) { - const { isLightMode, onChange, onCompiledChange, value } = props; + const { isLightMode, onChange, onCompiledChange, onCompilingChange, value } = props; const editorRef = useRef(null); const monacoRef = useRef(null); @@ -67,7 +68,9 @@ export function Editor(props: Props) { const compileCode = async () => { if (editorRef.current && monacoRef.current) { + onCompilingChange?.(true); const response = await emit(editorRef.current, monacoRef.current); + onCompilingChange?.(false); switch (response.status) { case 'valid': diff --git a/playground/src/components/icons/restart.tsx b/playground/src/components/icons/restart.tsx new file mode 100644 index 00000000..8c33545a --- /dev/null +++ b/playground/src/components/icons/restart.tsx @@ -0,0 +1,15 @@ +import type { SVGProps } from 'react'; + +export function Restart(props: SVGProps) { + return ( + + {/* Icon from Teenyicons by smhmd - https://github.com/teenyicons/teenyicons/blob/master/LICENSE */} + + + ); +} \ No newline at end of file diff --git a/playground/src/components/play-area/play-area.module.css b/playground/src/components/play-area/play-area.module.css index c294d577..814e077d 100644 --- a/playground/src/components/play-area/play-area.module.css +++ b/playground/src/components/play-area/play-area.module.css @@ -1,4 +1,50 @@ +.root { + height: 100%; + position: relative; +} + .canvas { /* Removes the trailing space */ display: block; } + +.restart { + position: absolute; + right: 8px; + top: 4px; + z-index: 2; +} + +.spinner { + align-items: center; + background-color: rgb(0 0 0 / 40%); + bottom: 0; + display: flex; + justify-content: center; + left: 0; + position: absolute; + right: 0; + top: 38px; + z-index: 3; + + :global(html[data-theme='light']) & { + background-color: rgb(255 255 255 / 50%); + } +} + +.spinnerIcon { + animation: ex-spin 0.8s linear infinite; + border: 3px solid var(--color-zinc-600); + border-radius: 50%; + border-top-color: var(--color-purple-500); + box-sizing: border-box; + display: block; + height: 28px; + width: 28px; +} + +@keyframes ex-spin { + to { + transform: rotate(360deg); + } +} diff --git a/playground/src/components/play-area/play-area.tsx b/playground/src/components/play-area/play-area.tsx index bba2c734..3e32a0dd 100644 --- a/playground/src/components/play-area/play-area.tsx +++ b/playground/src/components/play-area/play-area.tsx @@ -1,19 +1,58 @@ +import { useEffect, useState } from 'react'; +import { Button } from '../button/button'; +import { Restart } from '../icons/restart'; import { Tabs } from '../tabs/tabs'; import styles from './play-area.module.css'; -export function PlayArea() { +type Props = { + isCompiling?: boolean; + isEmbedded?: boolean; + onRestart?: () => void; +}; + +// Only show the spinner if compiling takes longer than this, so fast (incremental) compiles +// don't flash on every keystroke. +const SPINNER_GRACE_MS = 150; + +export function PlayArea(props: Props) { + const { isCompiling, isEmbedded, onRestart } = props; + const [showSpinner, setShowSpinner] = useState(false); + + useEffect(() => { + if (!isCompiling) { + setShowSpinner(false); + return; + } + const id = window.setTimeout(() => setShowSpinner(true), SPINNER_GRACE_MS); + return () => window.clearTimeout(id); + }, [isCompiling]); + return ( - {}} - items={[ - { - label: 'Preview', - value: 'preview', - content: - } - ]} - /> +
+ {}} + items={[ + { + label: 'Preview', + value: 'preview', + content: + } + ]} + /> + {isEmbedded && onRestart && ( +
+ +
+ )} + {showSpinner && ( +
+ +
+ )} +
); } diff --git a/playground/src/hooks/use-dev-tool.ts b/playground/src/hooks/use-dev-tool.ts index 33b98029..6b942531 100644 --- a/playground/src/hooks/use-dev-tool.ts +++ b/playground/src/hooks/use-dev-tool.ts @@ -37,8 +37,23 @@ export function useDevTool() { devtools.current.toggleDebug(); }; + const disposeEngine = () => { + const engine = devtools.current as any; + if (!engine || engine.isDisposed?.()) { + return; + } + engine.stop?.(); + engine.input?.pointers?.detach?.(); + engine.input?.toggleEnabled?.(false); + const scene = engine.currentScene ?? engine.director?.currentScene; + scene?.input?.pointers?.detach?.(); + scene?.input?.toggleEnabled?.(false); + engine.dispose?.(); + }; + return { version, - toggle + toggle, + disposeEngine }; } diff --git a/playground/src/types.ts b/playground/src/types.ts index 935d71a9..6984e38c 100644 --- a/playground/src/types.ts +++ b/playground/src/types.ts @@ -1,6 +1,12 @@ import * as monaco from 'monaco-editor'; -export type ExcaliburDevTool = { toggleDebug: () => void; version?: string }; +export type ExcaliburDevTool = { + toggleDebug: () => void; + version?: string; + stop?: () => void; + dispose?: () => void; + isDisposed?: () => boolean; +}; export type GlobalWithDevTool = typeof globalThis & { ___EXCALIBUR_DEVTOOL?: ExcaliburDevTool }; diff --git a/playground/src/utils/esm.ts b/playground/src/utils/esm.ts index 120e76c4..40d8f92a 100644 --- a/playground/src/utils/esm.ts +++ b/playground/src/utils/esm.ts @@ -1,7 +1,3 @@ -import { debounce } from './debounce'; - -const PREVIEW_DEBOUNCE_MS = 500; - /** * esm tagged template literal from Dr. Axel * https://2ality.com/2019/10/eval-via-import.html @@ -15,7 +11,9 @@ export function esm(templateStrings: TemplateStringsArray, ...substitutions: any } export function updateEsm(text: string) { - import(/* @vite-ignore */ esm`${text}`); + // Append (not prepend) a unique comment so the data-URL module specifier is always unique, + // keeping the browser's dynamic-import cache busted while preserving user line numbers in + // stack traces. Without this, Run/Restart of unchanged code silently no-ops (cached module). + const bust = `\n/* ${Math.random().toString(36).slice(2)} */`; + import(/* @vite-ignore */ esm`${text}${bust}`); } - -export const debouncedUpdateEsm = debounce(updateEsm, PREVIEW_DEBOUNCE_MS); diff --git a/src/engine/director/director.ts b/src/engine/director/director.ts index 8dc08f2f..5f01c834 100644 --- a/src/engine/director/director.ts +++ b/src/engine/director/director.ts @@ -628,4 +628,15 @@ export class Director { destinationName: destinationScene } as DirectorNavigationEvent); } + + /** + * Tears down any initialized scene's {@apilink InputHost}, removing native pointer listeners + * the scene attached to the canvas/window and disabling input. Called by {@apilink Engine.dispose}. + */ + public dispose() { + for (const scene of this._sceneToInstance.values()) { + scene.input?.pointers.detach(); + scene.input?.toggleEnabled(false); + } + } } diff --git a/src/engine/engine.ts b/src/engine/engine.ts index 66de703a..32134371 100644 --- a/src/engine/engine.ts +++ b/src/engine/engine.ts @@ -1398,6 +1398,8 @@ O|===|* >________________>\n\ this.stop(); this._garbageCollector.forceCollectAll(); this.input.toggleEnabled(false); + this.input.pointers.detach(); + this.director.dispose(); for (const plugin of this.plugins) { plugin.dispose?.(); diff --git a/src/engine/input/pointer-event-receiver.ts b/src/engine/input/pointer-event-receiver.ts index 75881661..c45fe66b 100644 --- a/src/engine/input/pointer-event-receiver.ts +++ b/src/engine/input/pointer-event-receiver.ts @@ -416,7 +416,7 @@ export class PointerEventReceiver { * Responsible for handling and parsing pointer events */ private _handle(ev: NativeTouchEvent | NativePointerEvent | NativeMouseEvent) { - if (!this._enabled) { + if (this.engine.isDisposed() || !this._enabled) { return; } ev.preventDefault(); @@ -477,7 +477,7 @@ export class PointerEventReceiver { } private _handleWheel(ev: NativeWheelEvent) { - if (!this._enabled) { + if (this.engine.isDisposed() || !this._enabled) { return; } // Should we prevent page scroll because of this event