Skip to content

Commit

Permalink
refactor: Finalize mount/unmount/update
Browse files Browse the repository at this point in the history
  • Loading branch information
rschristian committed Jun 30, 2024
1 parent 6d2ed9b commit aafff22
Show file tree
Hide file tree
Showing 4 changed files with 106 additions and 102 deletions.
56 changes: 41 additions & 15 deletions src/components/code-editor/index.jsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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(),
Expand All @@ -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());
}
})
]
Expand All @@ -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 <div ref={editorParent} class={cx(style.codeEditor, props.class)} />;
}

/** @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;
}
21 changes: 7 additions & 14 deletions src/components/controllers/repl-page.jsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -15,7 +15,7 @@ export default function ReplPage() {
useTitle(meta.title);
useDescription(meta.description);

const [code, slug] = initialCode(query);
const [code] = initialCode(query);

return (
<div class={style.repl}>
Expand All @@ -28,7 +28,7 @@ export default function ReplPage() {
display: none !important;
}
`}</style>
<Repl code={code} slug={slug} />
<Repl code={code} />
</div>
);
}
Expand All @@ -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 {
Expand All @@ -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) {
Expand All @@ -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]);
}
Expand Down
54 changes: 21 additions & 33 deletions src/components/controllers/repl/index.jsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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);
});
};

Expand All @@ -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 {
Expand Down Expand Up @@ -94,13 +83,13 @@ export function Repl({ code, slug }) {
<header class={style.toolbar}>
<label>
Examples:{' '}
<select value={exampleSlug} onChange={applyExample}>
<select value={query.example || ''} onChange={(e) => applyExample(e.currentTarget.value)}>
<option value="" disabled>
Select Example...
</option>
{EXAMPLES.map(function item(ex) {
const selected =
ex.slug !== undefined && ex.slug === exampleSlug;
ex.slug !== undefined && ex.slug === query.example;
return ex.group ? (
<optgroup label={ex.group}>{ex.items.map(item)}</optgroup>
) : (
Expand Down Expand Up @@ -139,9 +128,8 @@ export function Repl({ code, slug }) {
>
<CodeEditor
class={style.code}
value={editorCode}
baseExampleSlug={exampleSlug}
error={error}
editorCode={editorCode}
slug={query.example}
onInput={onEditorInput}
/>
</Splitter>
Expand Down
77 changes: 37 additions & 40 deletions src/components/controllers/tutorial/index.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -58,10 +58,12 @@ export function Tutorial({ html, meta }) {
const hasCode = meta.code !== false;
const showCode = showCodeOverride && hasCode;

// 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('../repl/runner').default, CodeEditor: import('../../code-editor').default }}
*/
const { Runner, CodeEditor } = useResource(() => Promise.all([
import('../../code-editor'),
import('../repl/runner')
Expand All @@ -73,8 +75,7 @@ export function Tutorial({ html, meta }) {
solutionCtx.setSolved(false);
content.current.scrollTo(0, 0);
}
}, [html]);

}, [meta.tutorial?.initial]);

const useResult = fn => {
useEffect(() => {
Expand Down Expand Up @@ -140,47 +141,43 @@ export function Tutorial({ html, meta }) {
orientation="horizontal"
force={!showCode ? '100%' : undefined}
other={
// 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
typeof window === 'undefined'
? null
: <Splitter
orientation="vertical"
other={
<>
<div class={style.output}>
{error && (
<ErrorOverlay
name={error.name}
message={error.message}
stack={parseStackTrace(error)}
/>
)}
<Runner
ref={runner}
onSuccess={onSuccess}
onRealm={onRealm}
onError={onError}
code={editorCode}
<Splitter
orientation="vertical"
other={
<>
<div class={style.output}>
{error && (
<ErrorOverlay
name={error.name}
message={error.message}
stack={parseStackTrace(error)}
/>
</div>
{hasCode && (
<button
class={style.toggleCode}
title="Toggle Code"
onClick={toggleCode}
>
<span>Toggle Code</span>
</button>
)}
</>
}
>
<Runner
ref={runner}
onSuccess={onSuccess}
onRealm={onRealm}
onError={onError}
code={editorCode}
/>
</div>
{hasCode && (
<button
class={style.toggleCode}
title="Toggle Code"
onClick={toggleCode}
>
<span>Toggle Code</span>
</button>
)}
</>
}
>
<div class={style.codeWindow}>
<CodeEditor
class={style.code}
value={editorCode}
error={error}
editorCode={meta.tutorial?.initial || ''}
slug={path}
onInput={setEditorCode}
/>
</div>
Expand Down

0 comments on commit aafff22

Please sign in to comment.