Files
supabase/apps/studio/components/interfaces/Explorer/NotebookEditor.tsx
Joshen Lim b648db233b joshen/fe 4113 explorer markdown cells (#48846)
## Context

More groundwork for the Explorer - this PR introduces the Markdown cells
and some basic (non data persisting) editing

Markdown cells will save either on
- Save button click
- Losing focus on the code editor

Hitting esc will cancel the changes

<img width="1387" height="674" alt="image"
src="https://github.com/user-attachments/assets/f0614b37-7a11-404f-9940-8bcd4de25c57"
/>

<img width="1087" height="516" alt="image"
src="https://github.com/user-attachments/assets/0a104168-2001-4ef7-934a-7e864956b3cb"
/>


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Added editable Markdown cells to notebooks, with save and cancel
controls.
* Added drag-and-drop reordering for notebook cells, including keyboard
support.
* New notebooks now include sample Markdown content to help users get
started.
* **Improvements**
* Improved drag-handle placement and consistency across sortable
sections.
* Updated notebook empty and populated states to reflect the current
cell content.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-08-11 10:41:59 +07:00

192 lines
6.4 KiB
TypeScript

import {
DndContext,
DragEndEvent,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
} from '@dnd-kit/core'
import {
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
verticalListSortingStrategy,
} from '@dnd-kit/sortable'
import { useParams } from 'common'
import { Edit, Notebook, NotebookText, Play, Save } from 'lucide-react'
import { useEffect, useEffectEvent, useState } from 'react'
import { AiIconAnimation, Button } from 'ui'
import { Input } from 'ui-patterns/DataInputs/Input'
import { EmptyStatePresentational } from 'ui-patterns/EmptyStatePresentational'
import { MarkdownCell } from './MarkdownCell'
import { ButtonTooltip } from '@/components/ui/ButtonTooltip'
import { useNotebooksStateSnapshot } from '@/state/notebooks/notebooks-state'
import { createTabId, useTabsStateSnapshot } from '@/state/tabs'
export const NotebookEditor = () => {
const { id } = useParams()
const tabs = useTabsStateSnapshot()
const snap = useNotebooksStateSnapshot()
const stateNotebook = id ? snap.notebooks[id] : undefined
const { name, content } = stateNotebook?.notebook ?? {}
const [titleValue, setTitleValue] = useState<string>(name ?? '')
const [isEditingTitle, setIsEditingTitle] = useState(false)
const cells = content?.cells ?? []
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 8 } }),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })
)
const handleSaveTitle = () => {
const trimmedName = titleValue.trim()
if (id && trimmedName && trimmedName !== name) {
snap.renameNotebook({ id, name: trimmedName })
tabs.updateTab(createTabId('notebook', { id }), { label: trimmedName })
}
setIsEditingTitle(false)
}
const handleUpdateCellText = (cellId: string, text: string) => {
if (!id) return
const nextCells = cells.map((cell) => (cell.id === cellId ? { ...cell, text } : cell))
snap.updateCells({ id, cells: nextCells })
}
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event
if (!id || !over || active.id === over.id) return
const oldIndex = cells.findIndex((cell) => cell.id === active.id)
const newIndex = cells.findIndex((cell) => cell.id === over.id)
if (oldIndex === -1 || newIndex === -1) return
snap.updateCells({ id, cells: arrayMove([...cells], oldIndex, newIndex) })
}
const registerTab = useEffectEvent(() => {
if (!id) return
tabs.addTab({
id: createTabId('notebook', { id }),
type: 'notebook',
label: name ?? 'New Notebook',
metadata: { notebookId: id },
isPreview: false,
})
})
useEffect(() => registerTab(), [id])
return (
<div className="flex flex-col h-full bg-surface-100">
<div className="border-b min-h-10 flex items-center justify-between px-4">
<div className="flex items-center gap-x-2">
<NotebookText size={14} className="text-foreground-light" />
{isEditingTitle ? (
<Input
autoFocus
size="tiny"
value={titleValue}
onChange={(e) => setTitleValue(e.target.value)}
onBlur={() => {
if (isEditingTitle) handleSaveTitle()
}}
onKeyDown={(e) => {
if (e.key === 'Escape') {
e.preventDefault()
e.stopPropagation()
setIsEditingTitle(false)
setTitleValue(name ?? '')
} else if (e.key === 'Enter') {
e.preventDefault()
e.stopPropagation()
handleSaveTitle()
}
}}
/>
) : (
<Button
variant="text"
className="group"
onClick={() => {
setTitleValue(name ?? '')
setIsEditingTitle(true)
}}
iconRight={<Edit className="opacity-0 group-hover:opacity-100" />}
>
{name}
</Button>
)}
</div>
<div className="flex items-center gap-x-2">
<Button variant="outline" icon={<AiIconAnimation size={16} />}>
Analyze
</Button>
<ButtonTooltip
variant="outline"
icon={<Play />}
className="px-1"
tooltip={{ content: { side: 'bottom', text: 'Run notebook' } }}
/>
<ButtonTooltip
variant="outline"
icon={<Save />}
className="px-1"
tooltip={{ content: { side: 'bottom', text: 'Save changes' } }}
/>
</div>
</div>
<div className="w-full mx-auto flex-grow min-h-0 overflow-y-auto">
<div className="p-4">
{cells.length === 0 && (
<EmptyStatePresentational
icon={<Notebook className="text-foreground-lighter" />}
title="This notebook is empty"
description="Add a query cell to run SQL against your database or logs."
contentClassName="[&>h3]:text-sm [&>p]:text-xs"
>
<div className="flex items-center gap-x-2">
<Button variant="default">Add query cell</Button>
<Button variant="default">Add markdown cell</Button>
</div>
</EmptyStatePresentational>
)}
{cells.length > 0 && (
<DndContext sensors={sensors} onDragEnd={handleDragEnd}>
<SortableContext
items={cells.map((cell) => cell.id)}
strategy={verticalListSortingStrategy}
>
<div className="flex flex-col gap-y-2">
{cells.map((cell) => {
switch (cell._tag) {
case 'markdown_cell':
return (
<MarkdownCell
key={cell.id}
cell={cell}
onCommitChanges={(text) => handleUpdateCellText(cell.id, text)}
/>
)
case 'database_cell':
case 'log_cell':
// [Joshen] Will eventually hook it up
return null
}
})}
</div>
</SortableContext>
</DndContext>
)}
</div>
</div>
</div>
)
}