components / desk

desk.

the full markdown workbench: toolbar, image sidebar, split editor with a two-way synced preview, and floating drawing windows. bring your own markdown renderer; backend ops are callbacks.

the desk

guides / the-desk

save: cmd/ctrl+s
live preview

the desk

a full markdown workbench. select text on the left and a preview button appears; click a block on the right and the editor scrolls to it.

images

drag a row from the sidebar into the editor, or click insert. drop an image file straight onto the editor to upload it and splice the ref at the caret. press new drawing in the toolbar to open a floating paint window.

<Desk value={md} onChange={setMd} renderMarkdown={render} />
  • toolbar: edit / preview / split + format buttons
  • sidebar: insert, delete, drop-to-upload
  • editor: drop a file here to upload + insert
  • drawing windows: brush, eraser, undo/redo, zoom/pan
installation
bunx @justin06lee/chrome@latest add desk
props
nametypedefaultdescription
titleReactNode-
subtitleReactNode-
value *string-markdown source (controlled).
onChange *(value: string) => void-
renderMarkdown *(markdown: string, state: { highlightLine: number | null }) => ReactNode-renders the preview with line-sync — typically a <Prose lineSync /> call.
assetsAsset[][]
onInsertAsset(asset: Asset) => void-the markdown ref is also spliced at the caret.
onDeleteAsset(asset: Asset) => void-parent owns any confirm flow.
onUploadAssets(files: File[]) => void | Asset[] | Promise<Asset[] | void>-fired for files dropped on the sidebar drop zone or directly onto the editor textarea. return the created assets and a textarea drop also splices their markdown refs at the caret. omit to hide the drop zone and disable textarea drops.
onSave(value: string) => void | Promise<void>-fired by the built-in save button and cmd/ctrl+s; the button renders disabled without it.
onSaveDrawing(result: { dataUrl: string; darkDataUrl?: string }) => void | Promise<void>-
drawingDarkMappingboolean-use the drawing window's light-to-dark mapping mode.
actionsReactNode-extra toolbar actions before Save.
textareaPropsOmit<ComponentProps<'textarea'>, 'value' | 'defaultValue'>-escape hatch onto the underlying textarea — e.g. onKeyDown for a vim keymap. handlers compose: the desk's internal splice/save/drop glue runs first, then yours with the same event; className is merged.
transformSource(source: string) => { body: string; lineOffset: number }-strip a leading front-matter region from the preview: the preview renders body while line-sync shifts by lineOffset (editor line N ↔ preview block N − lineOffset; selections in the stripped region clamp to the first block). keep the reference stable.
size'sm' | 'md' | 'lg' | 'xl' | '2xl' | 'screen' | 'auto''screen'size preset setting height and width (width clamped to the container). 'screen' fills the container at viewport height (like justin06lee.dev/desk); sm–2xl step from 20×32rem up to 52×88rem; 'auto' opts out so className owns the sizing.
classNamestring-extra classes; h-*/w-* classes here override size.