Skip to content

Commit

Permalink
Split out cell components into own files (#128)
Browse files Browse the repository at this point in the history
  • Loading branch information
benjreinhart authored Jul 11, 2024
1 parent 00b7afa commit 45a9f07
Show file tree
Hide file tree
Showing 5 changed files with 586 additions and 564 deletions.
200 changes: 200 additions & 0 deletions packages/web/src/components/cells/code.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
import { useEffect, useRef, useState } from 'react';
import CodeMirror, { keymap, Prec } from '@uiw/react-codemirror';
import { javascript } from '@codemirror/lang-javascript';
import { Circle, Play, Trash2 } from 'lucide-react';
import {
CellType,
CodeCellType,
CodeCellUpdateAttrsType,
CellErrorPayloadType,
} from '@srcbook/shared';
import { cn } from '@/lib/utils';
import { SessionType } from '@/types';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import DeleteCellWithConfirmation from '@/components/delete-cell-dialog';
import { SessionChannel } from '@/clients/websocket';
import { useCells } from '@/components/use-cell';
import { CellOutput } from '@/components/cell-output';
import useTheme from '@/components/use-theme';

export default function CodeCell(props: {
session: SessionType;
cell: CodeCellType;
channel: SessionChannel;
onUpdateCell: (cell: CodeCellType, attrs: CodeCellUpdateAttrsType) => Promise<string | null>;
onDeleteCell: (cell: CellType) => void;
}) {
const { session, cell, channel, onUpdateCell, onDeleteCell } = props;
const [error, setError] = useState<string | null>(null);
const [showStdio, setShowStdio] = useState(false);

const { codeTheme } = useTheme();
const { updateCell, clearOutput } = useCells();

function onChangeSource(source: string) {
onUpdateCell(cell, { source });
}

function evaluateModEnter() {
runCell();
return true;
}

useEffect(() => {
function callback(payload: CellErrorPayloadType) {
if (payload.cellId !== cell.id) {
return;
}

const filenameError = payload.errors.find((e) => e.attribute === 'filename');

if (filenameError) {
setError(filenameError.message);
}
}

channel.on('cell:error', callback);

return () => channel.off('cell:error', callback);
}, [cell.id, channel]);

async function updateFilename(filename: string) {
setError(null);
channel.push('cell:update', {
cellId: cell.id,
sessionId: session.id,
updates: { filename },
});
}

function runCell() {
if (cell.status === 'running') {
return false;
}
setShowStdio(true);

// Update client side only. The server will know it's running from the 'cell:exec' event.
updateCell({ ...cell, status: 'running' });
clearOutput(cell.id);

channel.push('cell:exec', {
sessionId: session.id,
cellId: cell.id,
});
}

function stopCell() {
channel.push('cell:stop', { sessionId: session.id, cellId: cell.id });
}

return (
<div className="relative group/cell" id={`cell-${props.cell.id}`}>
<div
className={cn(
'border rounded-md group',
cell.status === 'running'
? 'ring-1 ring-run-ring border-run-ring'
: 'focus-within:ring-1 focus-within:ring-ring focus-within:border-ring',
)}
>
<div className="p-1 flex items-center justify-between gap-2">
<div className="flex items-center gap-2 w-[200px]">
<FilenameInput
filename={cell.filename}
onUpdate={updateFilename}
onChange={() => setError(null)}
className="group-hover:border-input"
/>
{error && <div className="text-red-600 text-sm">{error}</div>}
</div>
<div
className={cn(
'opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 transition-opacity flex gap-2',

cell.status === 'running' ? 'opacity-100' : '',
)}
>
<DeleteCellWithConfirmation onDeleteCell={() => onDeleteCell(cell)}>
<Button variant="icon" size="icon" tabIndex={1}>
<Trash2 size={16} />
</Button>
</DeleteCellWithConfirmation>
{cell.status === 'running' && (
<Button variant="run" size="default-with-icon" onClick={stopCell} tabIndex={1}>
<Circle size={16} /> Stop
</Button>
)}
{cell.status === 'idle' && (
<Button size="default-with-icon" onClick={runCell} tabIndex={1}>
<Play size={16} />
Run
</Button>
)}
</div>
</div>
<CodeMirror
value={cell.source}
theme={codeTheme}
extensions={[
javascript({ typescript: true }),
Prec.highest(keymap.of([{ key: 'Mod-Enter', run: evaluateModEnter }])),
]}
onChange={onChangeSource}
/>
<CellOutput cell={cell} show={showStdio} setShow={setShowStdio} />
</div>
</div>
);
}

function FilenameInput(props: {
filename: string;
className: string;
onUpdate: (filename: string) => Promise<void>;
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
}) {
const filename = props.filename;
const onUpdate = props.onUpdate;
const onChange = props.onChange;

const inputRef = useRef<HTMLInputElement | null>(null);

return (
<Input
onFocus={(e) => {
const input = e.target;
const value = input.value;
const dotIndex = value.lastIndexOf('.');
if (dotIndex !== -1) {
input.setSelectionRange(0, dotIndex);
} else {
input.select(); // In case there's no dot, select the whole value
}
}}
ref={inputRef}
onChange={onChange}
required
defaultValue={filename}
onBlur={() => {
if (!inputRef.current) {
return;
}

const updatedFilename = inputRef.current.value;
if (updatedFilename !== filename) {
onUpdate(updatedFilename);
}
}}
onKeyDown={(e) => {
if (e.key === 'Enter' && inputRef.current) {
inputRef.current.blur();
}
}}
className={cn(
'font-mono font-semibold text-xs border-transparent hover:border-input transition-colors',
props.className,
)}
/>
);
}
156 changes: 156 additions & 0 deletions packages/web/src/components/cells/markdown.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
import { useEffect, useState } from 'react';
import { marked } from 'marked';
import Markdown from 'marked-react';
import CodeMirror, { keymap, Prec, EditorView } from '@uiw/react-codemirror';
import { markdown } from '@codemirror/lang-markdown';
import { CircleAlert, Trash2, Pencil } from 'lucide-react';
import { CellType, MarkdownCellType, MarkdownCellUpdateAttrsType } from '@srcbook/shared';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import DeleteCellWithConfirmation from '@/components/delete-cell-dialog';
import useTheme from '@/components/use-theme';

marked.use({ gfm: true });

export default function MarkdownCell(props: {
cell: MarkdownCellType;
onUpdateCell: (
cell: MarkdownCellType,
attrs: MarkdownCellUpdateAttrsType,
getValidationError?: (cell: MarkdownCellType) => string | null,
) => Promise<string | null>;
onDeleteCell: (cell: CellType) => void;
}) {
const { codeTheme } = useTheme();
const cell = props.cell;
const defaultState = cell.text ? 'view' : 'edit';
const [status, setStatus] = useState<'edit' | 'view'>(defaultState);
const [text, setText] = useState(cell.text);
const [error, setError] = useState<string | null>(null);

useEffect(() => {
if (status === 'edit') {
setText(cell.text);
}
}, [status, cell]);

const keyMap = Prec.highest(
keymap.of([
{
key: 'Mod-Enter',
run: () => {
onSave();
return true;
},
},
{
key: 'Escape',
run: () => {
setStatus('view');
return true;
},
},
]),
);

function getValidationError(cell: MarkdownCellType) {
const tokens = marked.lexer(cell.text);
const hasH1 = tokens?.some((token) => token.type === 'heading' && token.depth === 1);
const hasH6 = tokens?.some((token) => token.type === 'heading' && token.depth === 6);

if (hasH1 || hasH6) {
return 'Markdown cells cannot use h1 or h6 headings, these are reserved for srcbook.';
}
return null;
}

async function onSave() {
const error = await props.onUpdateCell(cell, { text }, getValidationError);
setError(error);
if (error === null) {
setStatus('view');
return true;
}
}

return (
<div
id={`cell-${props.cell.id}`}
onDoubleClick={() => setStatus('edit')}
className={cn(
'group/cell relative w-full rounded-md border border-transparent hover:border-border transition-all',
status === 'edit' && 'ring-1 ring-ring border-ring hover:border-ring',
error && 'ring-1 ring-sb-red-30 border-sb-red-30 hover:border-sb-red-30',
)}
>
{status === 'view' ? (
<div>
<div className="p-1 w-full h-11 hidden group-hover/cell:flex items-center justify-between z-10">
<h5 className="pl-2 text-sm font-mono font-bold">Markdown</h5>
<div className="flex items-center gap-1">
<Button
variant="secondary"
size="icon"
className="border-transparent"
onClick={() => setStatus('edit')}
>
<Pencil size={16} />
</Button>

<DeleteCellWithConfirmation onDeleteCell={() => props.onDeleteCell(cell)}>
<Button variant="secondary" size="icon" className="border-transparent">
<Trash2 size={16} />
</Button>
</DeleteCellWithConfirmation>
</div>
</div>
<div className="sb-prose px-3 pt-11 group-hover/cell:pt-0">
<Markdown>{cell.text}</Markdown>
</div>
</div>
) : (
<>
{error && (
<div className="flex items-center gap-2 absolute bottom-1 right-1 px-2.5 py-2 text-sb-red-80 bg-sb-red-30 rounded-sm">
<CircleAlert size={16} />
<p className="text-xs">{error}</p>
</div>
)}
<div className="flex flex-col">
<div className="p-1 w-full flex items-center justify-between z-10">
<h5 className="pl-4 text-sm font-mono font-bold">Markdown</h5>
<div className="flex items-center gap-1">
<DeleteCellWithConfirmation onDeleteCell={() => props.onDeleteCell(cell)}>
<Button
variant="secondary"
size="icon"
className="border-secondary hover:border-muted"
>
<Trash2 size={16} />
</Button>
</DeleteCellWithConfirmation>

<Button variant="secondary" onClick={() => setStatus('view')}>
Cancel
</Button>

<Button onClick={onSave}>Save</Button>
</div>
</div>

<div className="px-3">
<CodeMirror
theme={codeTheme}
indentWithTab={false}
value={text}
basicSetup={{ lineNumbers: false, foldGutter: false }}
extensions={[markdown(), keyMap, EditorView.lineWrapping]}
onChange={(source) => setText(source)}
/>
</div>
</div>
</>
)}
</div>
);
}
Loading

0 comments on commit 45a9f07

Please sign in to comment.