diff --git a/src/components/code-editor/index.jsx b/src/components/code-editor/index.jsx index 8a051def2..68403c0ba 100644 --- a/src/components/code-editor/index.jsx +++ b/src/components/code-editor/index.jsx @@ -1,7 +1,7 @@ -import { useState, useRef, useEffect } from 'preact/hooks'; +import { useRef, useEffect } from 'preact/hooks'; import { EditorView } from 'codemirror'; import { lineNumbers, keymap, highlightActiveLineGutter, highlightActiveLine } from '@codemirror/view'; -import { EditorState } from '@codemirror/state'; +import { EditorState, Transaction } from '@codemirror/state'; import { defaultKeymap, history, historyKeymap, indentWithTab } from '@codemirror/commands'; import { javascript } from '@codemirror/lang-javascript'; import { syntaxHighlighting, HighlightStyle, indentUnit, bracketMatching } from '@codemirror/language'; @@ -26,21 +26,39 @@ const highlightStyle = HighlightStyle.define([ { tag: tags.invalid, class: 'cm-invalid' } ]); +/** + * @param {object} props + * @param {string} props.editorCode + * @param {(value: string) => void} props.onInput + * @param {string} props.slug + * @param {string} [props.class] + */ export default function CodeEditor(props) { const editorParent = useRef(null); + /** @type {{ current: EditorView | null }} */ const editor = useRef(null); - // eslint-disable-next-line no-unused-vars - const [_, setEditor] = useState(null); + + const routeHasChanged = useRef(false); + + useEffect(() => { + if (props.slug || !editor.current) routeHasChanged.current = true; + }, [props.slug]); useEffect(() => { - console.log('editor code:\n', props.value); - if (editor.current && !props.baseExampleSlug) return; - if (editor.current) editor.current.destroy(); + if (routeHasChanged.current === false) return; + routeHasChanged.current = false; + + if (editor.current) { + editor.current.dispatch({ + changes: { from: 0, to: editor.current.state.doc.length, insert: props.editorCode } + }); + return; + } const theme = EditorView.theme({}, { dark: true }); const state = EditorState.create({ - doc: props.value, + doc: props.editorCode, extensions: [ lineNumbers(), highlightActiveLine(), @@ -54,8 +72,9 @@ export default function CodeEditor(props) { keymap.of([indentWithTab, ...defaultKeymap, ...historyKeymap]), [theme, syntaxHighlighting(highlightStyle, { fallback: true })], EditorView.updateListener.of(update => { - if (update.docChanged) { - if (props.onInput) props.onInput({ value: update.state.doc.toString() }); + // Ignores changes from swapping out the editor code programmatically + if (isViewUpdateFromUserInput(update)) { + props.onInput(update.state.doc.toString()); } }) ] @@ -65,16 +84,23 @@ export default function CodeEditor(props) { state, parent: editorParent.current }); - - setEditor(editor.current); - }, [props.baseExampleSlug]); + }, [props.editorCode]); useEffect(() => ( () => { - editor.current.destroy(); - setEditor(null); + if (editor.current) editor.current.destroy(); } ), []); return
; } + +/** @param {import('@codemirror/view').ViewUpdate} viewUpdate */ +function isViewUpdateFromUserInput(viewUpdate) { + if (viewUpdate.docChanged) { + for (const transaction of viewUpdate.transactions) { + if (transaction.annotation(Transaction.userEvent)) return true; + } + } + return false; +} diff --git a/src/components/controllers/repl-page.jsx b/src/components/controllers/repl-page.jsx index 11854998e..6fc75d0c1 100644 --- a/src/components/controllers/repl-page.jsx +++ b/src/components/controllers/repl-page.jsx @@ -1,4 +1,4 @@ -import { useRoute } from 'preact-iso'; +import { useLocation, useRoute } from 'preact-iso'; import { Repl } from './repl'; import { useExample } from './repl/examples'; import { useContent, useResource } from '../../lib/use-resource'; @@ -15,7 +15,7 @@ export default function ReplPage() { useTitle(meta.title); useDescription(meta.description); - const [code, slug] = initialCode(query); + const [code] = initialCode(query); return (
@@ -28,7 +28,7 @@ export default function ReplPage() { display: none !important; } `} - +
); } @@ -39,6 +39,7 @@ export default function ReplPage() { * ?code -> ?example -> localStorage -> simple counter example */ function initialCode(query) { + const { route } = useLocation(); let code, slug; if (query.code) { try { @@ -48,13 +49,9 @@ function initialCode(query) { code = useExample([query.example]); if (code) { slug = query.example; - history.replaceState( - null, - null, - `/repl?example=${encodeURIComponent(slug)}` - ); + route(`/repl?example=${encodeURIComponent(slug)}`, true); } - else history.replaceState(null, null, '/repl'); + else route('/repl', true); } if (!code) { @@ -63,11 +60,7 @@ function initialCode(query) { } else { slug = 'counter'; if (typeof window !== 'undefined') { - history.replaceState( - null, - null, - `/repl?example=${encodeURIComponent(slug)}` - ); + route(`/repl?example=${encodeURIComponent(slug)}`, true); } code = useExample([slug]); } diff --git a/src/components/controllers/repl/index.jsx b/src/components/controllers/repl/index.jsx index 95c812798..a5e6d8a89 100644 --- a/src/components/controllers/repl/index.jsx +++ b/src/components/controllers/repl/index.jsx @@ -1,7 +1,8 @@ import { useState } from 'preact/hooks'; +import { useLocation, useRoute } from 'preact-iso'; import { Splitter } from '../../splitter'; -import { EXAMPLES, getExample, loadExample } from './examples'; import { ErrorOverlay } from './error-overlay'; +import { EXAMPLES, getExample, loadExample } from './examples'; import { useStoredValue } from '../../../lib/localstorage'; import { useResource } from '../../../lib/use-resource'; import { parseStackTrace } from './errors'; @@ -13,32 +14,29 @@ import REPL_CSS from './examples.css?raw'; * @param {string} props.code * @param {string} [props.slug] */ -export function Repl({ code, slug }) { +export function Repl({ code }) { + const { route } = useLocation(); + const { query } = useRoute(); const [editorCode, setEditorCode] = useStoredValue('preact-www-repl-code', code); - const [exampleSlug, setExampleSlug] = useState(slug || ''); const [error, setError] = useState(null); const [copied, setCopied] = useState(false); - // TODO: CodeMirror v5 cannot load in Node, and loading only the runner - // causes some bad jumping/pop-in. For the moment, this is the best option + // TODO: Needs some work for prerendering to not cause pop-in if (typeof window === 'undefined') return null; + /** + * @type {{ Runner: import('./runner').default, CodeEditor: import('../../code-editor').default }} + */ const { Runner, CodeEditor } = useResource(() => Promise.all([ import('../../code-editor'), import('./runner') ]).then(([CodeEditor, Runner]) => ({ CodeEditor: CodeEditor.default, Runner: Runner.default })), ['repl']); - const applyExample = (e) => { - const slug = e.target.value; + const applyExample = (slug) => { loadExample(getExample(slug).url) .then(code => { setEditorCode(code); - setExampleSlug(slug); - history.replaceState( - null, - null, - `/repl?example=${encodeURIComponent(slug)}` - ); + route(`/repl?example=${encodeURIComponent(slug)}`, true); }); }; @@ -47,25 +45,16 @@ export function Repl({ code, slug }) { // Clears the example & code query params when a user // begins to modify the code - if (!exampleSlug || !location.search) return; - const example = getExample(exampleSlug); - if (example) { - loadExample(example.url).then(exampleCode => { - if (exampleCode !== value) { - setExampleSlug(''); - history.replaceState(null, null, '/repl'); - } - }); + if (query.example || query.code) { + route('/repl', true); } }; const share = () => { - if (!exampleSlug) { - history.replaceState( - null, - null, - `/repl?code=${encodeURIComponent(btoa(editorCode))}` - ); + // No reason to share semi-sketchy btoa'd code if there's + // a perfectly good example we can use instead + if (!query.example) { + route(`/repl?code=${encodeURIComponent(btoa(editorCode))}`, true); } try { @@ -94,13 +83,13 @@ export function Repl({ code, slug }) {