-
Notifications
You must be signed in to change notification settings - Fork 239
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Split out cell components into own files (#128)
- Loading branch information
1 parent
00b7afa
commit 45a9f07
Showing
5 changed files
with
586 additions
and
564 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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, | ||
)} | ||
/> | ||
); | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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> | ||
); | ||
} |
Oops, something went wrong.