Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
114 changes: 84 additions & 30 deletions src/components/CodeEmbed/index.jsx
Original file line number Diff line number Diff line change
@@ -1,58 +1,53 @@
import { useState, useEffect, useRef } from "preact/hooks";
import { useLiveRegion } from '../hooks/useLiveRegion';
import { useLiveRegion } from "../hooks/useLiveRegion";
import CodeMirror, { EditorView } from "@uiw/react-codemirror";
import { javascript } from "@codemirror/lang-javascript";
import { keymap } from "@codemirror/view";
import { Prec, EditorState } from "@codemirror/state";
import { insertTab } from "@codemirror/commands";
import { cdnLibraryUrl, cdnSoundUrl } from "@/src/globals/globals";

import { CodeFrame } from "./frame";
import { CopyCodeButton } from "../CopyCodeButton";
import CircleButton from "../CircleButton";
import { Icon } from "../Icon";

/*
* A more featured code embed component that uses CodeMirror
*
* Props: {
* initialValue?: string;
* editable: boolean;
* previewable: boolean;
* previewHeight?: number;
* previewWidth?: number;
* base?: string;
* lazyLoad?: boolean;
* TODO: refactor this prop behavior
* allowSideBySide?: boolean
* fullWidth?: boolean
* includeSound?: boolean
* }
*/
export const CodeEmbed = (props) => {
const { ref: liveRegionRef, announce } = useLiveRegion();
const [rendered, setRendered] = useState(false);
const [enableTabIndent, setEnableTabIndent] = useState(false);

const initialCode = props.initialValue ?? "";
// Source code from Google Docs sometimes uses a unicode non-breaking space
// instead of a normal one, but these break the code frame, so we replace them here.
// We also replace them in CodeFrame, but replacing here too ensures people don't
// accidentally copy-and-paste them out of the embedded editor.
const [codeString, setCodeString] = useState(
initialCode.replace(/\u00A0/g, " "),
);

let { previewWidth, previewHeight } = props;
const canvasMatch = /createCanvas\(\s*(\d+),\s*(\d+)\s*(?:,\s*(?:P2D|WEBGL)\s*)?\)/m.exec(initialCode);

const canvasMatch =
/createCanvas\(\s*(\d+),\s*(\d+)\s*(?:,\s*(?:P2D|WEBGL)\s*)?\)/m.exec(
initialCode,
);

if (canvasMatch) {
previewWidth = previewWidth || parseFloat(canvasMatch[1]);
previewHeight = previewHeight || parseFloat(canvasMatch[2]);
}

const largeSketch = previewWidth && previewWidth > 770 - 60;

// Quick hack to make room for DOM that gets added below the canvas by default
const domMatch = /create(Button|Select|P|Div|Input|ColorPicker)/.exec(initialCode);
const domMatch =
/create(Button|Select|P|Div|Input|ColorPicker)/.exec(initialCode);

if (domMatch && previewHeight) {
previewHeight += 100;
}

const codeFrameRef = useRef(null);
const [previewCodeString, setPreviewCodeString] = useState(codeString);

const updateOrReRun = () => {
if (codeString === previewCodeString) {
Expand All @@ -64,12 +59,9 @@ export const CodeEmbed = (props) => {
announce("Sketch is running");
};

const [previewCodeString, setPreviewCodeString] = useState(codeString);

useEffect(() => {
setRendered(true);

// Includes p5.min.js script to be used by `CodeFrame` iframe(s)
if (!document.getElementById("p5ScriptTag")) {
const p5ScriptElement = document.createElement("script");
p5ScriptElement.id = "p5ScriptTag";
Expand All @@ -80,13 +72,31 @@ export const CodeEmbed = (props) => {

if (!rendered) return <div className="code-placeholder" />;

const escToBlur = EditorView.domEventHandlers({
keydown(event, view) {
if (event.key === "Escape") {
view.contentDOM.blur();
return true;
}
return false;
},
});

return (
<div
className={`my-md flex w-full flex-col gap-[20px] overflow-hidden ${props.allowSideBySide ? "lg:flex-row" : ""} ${props.fullWidth ? "full-width" : ""}`}
className={`my-md flex w-full flex-col gap-[20px] overflow-hidden ${
props.allowSideBySide ? "lg:flex-row" : ""
} ${props.fullWidth ? "full-width" : ""}`}
>
{props.previewable ? (
<div
className={`ml-0 flex w-fit gap-[20px] ${largeSketch ? "flex-col" : (props.allowSideBySide ? "" : "flex-col lg:flex-row")}`}
className={`ml-0 flex w-fit gap-[20px] ${
largeSketch
? "flex-col"
: props.allowSideBySide
? ""
: "flex-col lg:flex-row"
}`}
>
<div>
<CodeFrame
Expand All @@ -99,7 +109,11 @@ export const CodeEmbed = (props) => {
scripts={props.includeSound ? [cdnSoundUrl] : []}
/>
</div>
<div className={`flex gap-2.5 ${largeSketch ? "flex-row" : "md:flex-row lg:flex-col"}`}>
<div
className={`flex gap-2.5 ${
largeSketch ? "flex-row" : "md:flex-row lg:flex-col"
}`}
>
<CircleButton
className="bg-bg-gray-40"
onClick={updateOrReRun}
Expand All @@ -120,6 +134,24 @@ export const CodeEmbed = (props) => {
</div>
</div>
) : null}

{/* Tab indentation toggle */}
<label
style={{
display: "flex",
alignItems: "center",
gap: "0.5rem",
marginBottom: "0.5rem",
}}
>
<input
type="checkbox"
checked={enableTabIndent}
onChange={(e) => setEnableTabIndent(e.target.checked)}
/>
<b>Enable tab indentation</b>
</label>

<div className="code-editor-container relative w-full">
<CodeMirror
value={codeString}
Expand All @@ -138,14 +170,35 @@ export const CodeEmbed = (props) => {
foldGutter: false,
autocompletion: false,
}}
indentWithTab={false}
extensions={[javascript(), EditorView.lineWrapping]}
extensions={[
javascript(),
EditorView.lineWrapping,
EditorState.tabSize.of(4),

Prec.high(
keymap.of([
{
key: "Tab",
run: (view) => {
if (!enableTabIndent) {
view.contentDOM.blur();
return true;
}
return insertTab(view);
},
},
]),
),

escToBlur,
]}
onChange={(val) => setCodeString(val)}
editable={props.editable}
onCreateEditor={(editorView) =>
(editorView.contentDOM.ariaLabel = "Code Editor")
}
/>

<div className="absolute right-0 top-0 flex flex-col gap-xs p-xs md:flex-row">
<CopyCodeButton textToCopy={codeString || initialCode} />
<CircleButton
Expand All @@ -161,6 +214,7 @@ export const CodeEmbed = (props) => {
</CircleButton>
</div>
</div>

<span ref={liveRegionRef} aria-live="polite" class="sr-only" />
</div>
);
Expand Down