chore: import upstream snapshot with attribution
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 13:20:55 +08:00
commit d25d482dc2
13754 changed files with 4996608 additions and 0 deletions
@@ -0,0 +1,126 @@
'use client'
import {
Button,
cn,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
Folder,
Tooltip,
Trash,
} from '@sim/emcn'
import { Download } from '@sim/emcn/icons'
import { AnimatePresence, domAnimation, LazyMotion, m } from 'framer-motion'
import type { MoveOptionNode } from '@/app/workspace/[workspaceId]/files/move-options'
import { renderMoveOption } from '@/app/workspace/[workspaceId]/files/move-options'
interface FilesActionBarProps {
selectedCount: number
onDownload?: () => void
onMove?: (optionValue: string) => void
moveOptions?: MoveOptionNode[]
onDelete?: () => void
isLoading?: boolean
className?: string
}
export function FilesActionBar({
selectedCount,
onDownload,
onMove,
moveOptions,
onDelete,
isLoading = false,
className,
}: FilesActionBarProps) {
return (
<LazyMotion features={domAnimation}>
<AnimatePresence>
{selectedCount > 0 && (
<m.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 10 }}
transition={{ duration: 0.2 }}
className={cn(
'-translate-x-1/2 fixed bottom-6 left-1/2 z-[var(--z-dropdown)] transform',
className
)}
>
<div className='flex items-center gap-2 rounded-[10px] border border-[var(--border)] bg-[var(--surface-2)] px-2 py-1.5'>
<span className='px-1 text-[var(--text-secondary)] text-small'>
{selectedCount} selected
</span>
<div className='flex items-center gap-[5px]'>
{onDownload && (
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
variant='ghost'
onClick={onDownload}
disabled={isLoading}
className='hover-hover:!text-[var(--text-inverse)] size-[28px] rounded-lg bg-[var(--surface-5)] p-0 text-[var(--text-secondary)] hover-hover:bg-[var(--brand-secondary)]'
>
<Download className='size-[12px]' />
</Button>
</Tooltip.Trigger>
<Tooltip.Content side='top'>Download</Tooltip.Content>
</Tooltip.Root>
)}
{onMove && moveOptions && (
<DropdownMenu>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<DropdownMenuTrigger asChild>
<Button
variant='ghost'
disabled={isLoading}
className='hover-hover:!text-[var(--text-inverse)] size-[28px] rounded-lg bg-[var(--surface-5)] p-0 text-[var(--text-secondary)] hover-hover:bg-[var(--brand-secondary)]'
>
<Folder className='size-[12px]' />
</Button>
</DropdownMenuTrigger>
</Tooltip.Trigger>
<Tooltip.Content side='top'>Move</Tooltip.Content>
</Tooltip.Root>
<DropdownMenuContent
side='top'
align='center'
className='max-h-[240px] overflow-y-auto'
>
{moveOptions.length > 0 && (
<DropdownMenuItem onSelect={() => onMove(moveOptions[0].value)}>
<Folder />
{moveOptions[0].label}
</DropdownMenuItem>
)}
{moveOptions.length > 1 && <DropdownMenuSeparator />}
{moveOptions.slice(1).map((option) => renderMoveOption(option, onMove))}
</DropdownMenuContent>
</DropdownMenu>
)}
{onDelete && (
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
variant='ghost'
onClick={onDelete}
disabled={isLoading}
className='hover-hover:!text-[var(--text-inverse)] size-[28px] rounded-lg bg-[var(--surface-5)] p-0 text-[var(--text-secondary)] hover-hover:bg-[var(--brand-secondary)]'
>
<Trash className='size-[12px]' />
</Button>
</Tooltip.Trigger>
<Tooltip.Content side='top'>Delete</Tooltip.Content>
</Tooltip.Root>
)}
</div>
</div>
</m.div>
)}
</AnimatePresence>
</LazyMotion>
)
}
@@ -0,0 +1 @@
export { FilesActionBar } from './action-bar'
@@ -0,0 +1,57 @@
'use client'
import { memo } from 'react'
import { ChipConfirmModal } from '@sim/emcn'
interface DeleteConfirmModalProps {
open: boolean
onOpenChange: (open: boolean) => void
fileName?: string
fileCount: number
folderCount: number
onDelete: () => void
isPending: boolean
}
export const DeleteConfirmModal = memo(function DeleteConfirmModal({
open,
onOpenChange,
fileName,
fileCount,
folderCount,
onDelete,
isPending,
}: DeleteConfirmModalProps) {
const totalCount = fileCount + folderCount
const hasFolders = folderCount > 0
const title = totalCount > 1 ? 'Delete Items' : hasFolders ? 'Delete Folder' : 'Delete File'
const consequence = hasFolders
? totalCount > 1
? 'This will also delete files and folders inside any selected folders.'
: 'This will also delete files and folders inside it.'
: totalCount > 1
? 'You can restore them from Recently Deleted in Settings.'
: 'You can restore it from Recently Deleted in Settings.'
return (
<ChipConfirmModal
open={open}
onOpenChange={onOpenChange}
srTitle={title}
title={title}
text={[
'Are you sure you want to delete ',
fileName
? { text: fileName, bold: true }
: `${totalCount} item${totalCount === 1 ? '' : 's'}`,
`? ${consequence}`,
]}
confirm={{
label: 'Delete',
onClick: onDelete,
pending: isPending,
pendingLabel: 'Deleting...',
}}
/>
)
})
@@ -0,0 +1 @@
export { DeleteConfirmModal } from './delete-confirm-modal'
@@ -0,0 +1,121 @@
'use client'
import { memo } from 'react'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
Eye,
Folder,
FolderInput,
Pencil,
} from '@sim/emcn'
import { Download, Link, Trash } from '@sim/emcn/icons'
import type { MoveOptionNode } from '@/app/workspace/[workspaceId]/files/move-options'
import { renderMoveOption } from '@/app/workspace/[workspaceId]/files/move-options'
interface FileRowContextMenuProps {
isOpen: boolean
position: { x: number; y: number }
onClose: () => void
onOpen: () => void
onDownload?: () => void
onRename: () => void
onDelete: () => void
onMove?: (optionValue: string) => void
onShare?: () => void
moveOptions?: MoveOptionNode[]
canEdit: boolean
selectedCount: number
}
export const FileRowContextMenu = memo(function FileRowContextMenu({
isOpen,
position,
onClose,
onOpen,
onDownload,
onRename,
onDelete,
onMove,
onShare,
moveOptions,
canEdit,
selectedCount,
}: FileRowContextMenuProps) {
const isMultiSelect = selectedCount > 1
return (
<DropdownMenu open={isOpen} onOpenChange={(open) => !open && onClose()} modal={false}>
<DropdownMenuTrigger asChild>
<div
className='pointer-events-none fixed h-px w-px'
style={{ left: `${position.x}px`, top: `${position.y}px` }}
tabIndex={-1}
aria-hidden
/>
</DropdownMenuTrigger>
<DropdownMenuContent
align='start'
side='bottom'
sideOffset={4}
onCloseAutoFocus={(e) => e.preventDefault()}
>
{!isMultiSelect && (
<DropdownMenuItem onSelect={onOpen}>
<Eye />
Open
</DropdownMenuItem>
)}
{onDownload && (
<DropdownMenuItem onSelect={onDownload}>
<Download />
{isMultiSelect ? `Download ${selectedCount} items` : 'Download'}
</DropdownMenuItem>
)}
{canEdit && (
<>
<DropdownMenuSeparator />
{!isMultiSelect && (
<DropdownMenuItem onSelect={onRename}>
<Pencil />
Rename
</DropdownMenuItem>
)}
{!isMultiSelect && onShare && (
<DropdownMenuItem onSelect={onShare}>
<Link />
Share
</DropdownMenuItem>
)}
{onMove && moveOptions && moveOptions.length > 0 && (
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<FolderInput />
{isMultiSelect ? `Move ${selectedCount} items` : 'Move to'}
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
<DropdownMenuItem onSelect={() => onMove(moveOptions[0].value)}>
<Folder />
{moveOptions[0].label}
</DropdownMenuItem>
{moveOptions.length > 1 && <DropdownMenuSeparator />}
{moveOptions.slice(1).map((option) => renderMoveOption(option, onMove))}
</DropdownMenuSubContent>
</DropdownMenuSub>
)}
<DropdownMenuItem onSelect={onDelete}>
<Trash />
{isMultiSelect ? `Delete ${selectedCount} items` : 'Delete'}
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
)
})
@@ -0,0 +1 @@
export { FileRowContextMenu } from './file-row-context-menu'
@@ -0,0 +1,69 @@
'use client'
import { useCallback, useEffect, useRef } from 'react'
import { toast } from '@sim/emcn'
import { generateId } from '@sim/utils/id'
import { useRouter } from 'next/navigation'
import { CSV_PREVIEW_MAX_ROWS } from '@/lib/api/contracts/workspace-file-table'
import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace'
import { useImportFileAsTable } from '@/hooks/queries/tables'
import { useImportTrayStore } from '@/stores/table/import-tray/store'
export type CsvImportFileDescriptor = Pick<WorkspaceFileRecord, 'key' | 'name'>
/**
* Wires the "Import as a table" affordance for a capped CSV preview. When the preview is
* `truncated`, raises a one-time warning toast whose action kicks off a background import of the
* existing workspace file — no re-upload, source preserved — and navigates to the new table.
*/
export function useCsvTruncationImport(
workspaceId: string,
file: CsvImportFileDescriptor,
truncated: boolean,
readOnly = false
) {
const router = useRouter()
const importFile = useImportFileAsTable()
// Guards against a double-tap on the toast action kicking off two parallel imports of the same
// file. Reset once the kickoff settles so a failed import can be retried.
const importingRef = useRef(false)
const importAsTable = useCallback(() => {
if (importingRef.current) return
importingRef.current = true
const pendingId = `pending_${generateId()}`
useImportTrayStore
.getState()
.startUpload({ uploadId: pendingId, workspaceId, title: file.name })
toast.success(`Importing "${file.name}" as a table`, {
description: 'This runs in the background.',
action: {
label: 'View tables',
onClick: () => router.push(`/workspace/${workspaceId}/tables`),
},
})
importFile.mutate(
{ workspaceId, fileKey: file.key, fileName: file.name },
{
onSettled: () => {
importingRef.current = false
useImportTrayStore.getState().endUpload(pendingId)
},
}
)
// importFile.mutate and router are stable references
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [workspaceId, file.key, file.name])
// Surface the cap as a warning toast with an import action, once per file.
const notifiedKeyRef = useRef<string | null>(null)
useEffect(() => {
if (readOnly || !truncated || notifiedKeyRef.current === file.key) return
notifiedKeyRef.current = file.key
toast.warning(`Showing the first ${CSV_PREVIEW_MAX_ROWS.toLocaleString()} rows`, {
description: 'Import this file as a table to view all of its rows.',
action: { label: 'Import as a table', onClick: importAsTable },
})
}, [readOnly, truncated, file.key, importAsTable])
}
@@ -0,0 +1,49 @@
'use client'
import { memo } from 'react'
import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace'
import { useWorkspaceCsvPreview } from '@/hooks/queries/workspace-file-table'
import { useCsvTruncationImport } from './csv-import'
import { DataTable } from './data-table'
import { PreviewError, PreviewLoadingFrame, resolvePreviewError } from './preview-shared'
/**
* Read-only preview for a CSV that is too large to load fully into the editor. Streams only the
* first {@link CSV_PREVIEW_MAX_ROWS} rows from storage; when there are more, a warning toast offers
* "Import as a table", which builds a full Table from the file (memory-safe streaming import).
*/
export const CsvTablePreview = memo(function CsvTablePreview({
file,
workspaceId,
}: {
file: WorkspaceFileRecord
workspaceId: string
}) {
const version = Number(new Date(file.updatedAt)) || file.size
const {
data,
isLoading,
error: fetchError,
} = useWorkspaceCsvPreview(workspaceId, file.id, file.key, version)
useCsvTruncationImport(workspaceId, file, data?.truncated ?? false)
const error = resolvePreviewError((fetchError as Error | null) ?? null, null)
if (error) return <PreviewError label='CSV' error={error} />
if (isLoading || !data) {
return <PreviewLoadingFrame className='flex flex-1 flex-col overflow-hidden' />
}
if (data.headers.length === 0) {
return (
<div className='flex h-full items-center justify-center p-6'>
<p className='text-[13px] text-[var(--text-muted)]'>No data to display</p>
</div>
)
}
return (
<div className='flex flex-1 flex-col overflow-auto p-6'>
<DataTable headers={data.headers} rows={data.rows} />
</div>
)
})
@@ -0,0 +1,160 @@
'use client'
import { forwardRef, memo, useCallback, useImperativeHandle, useRef, useState } from 'react'
import { cn } from '@sim/emcn'
interface EditConfig {
onCellChange: (row: number, col: number, value: string) => void
onHeaderChange: (col: number, value: string) => void
}
interface DataTableProps {
headers: string[]
rows: string[][]
editConfig?: EditConfig
}
export interface DataTableHandle {
commitEdit: () => void
}
type EditingCell = { row: number; col: number } | null
const DataTableBase = forwardRef<DataTableHandle, DataTableProps>(function DataTable(
{ headers, rows, editConfig },
ref
) {
const [editingCell, setEditingCell] = useState<EditingCell>(null)
const [editValue, setEditValue] = useState('')
const editStateRef = useRef({ editingCell, editValue, editConfig })
editStateRef.current = { editingCell, editValue, editConfig }
// Prevents double-commit if onBlur and imperative commitEdit fire concurrently
const isCommittedRef = useRef(false)
useImperativeHandle(
ref,
() => ({
commitEdit: () => {
if (isCommittedRef.current) return
const { editingCell, editValue, editConfig } = editStateRef.current
if (!editingCell || !editConfig) return
isCommittedRef.current = true
const { row, col } = editingCell
if (row === -1) {
editConfig.onHeaderChange(col, editValue)
} else {
editConfig.onCellChange(row, col, editValue)
}
setEditingCell(null)
},
}),
[]
)
const setInputRef = useCallback((node: HTMLInputElement | null) => {
if (node) {
node.focus()
node.select()
}
}, [])
const startEdit = (row: number, col: number, currentValue: string) => {
if (!editConfig) return
isCommittedRef.current = false
setEditingCell({ row, col })
setEditValue(currentValue)
}
const commitEdit = () => {
if (isCommittedRef.current || !editingCell || !editConfig) return
isCommittedRef.current = true
const { row, col } = editingCell
if (row === -1) {
editConfig.onHeaderChange(col, editValue)
} else {
editConfig.onCellChange(row, col, editValue)
}
setEditingCell(null)
}
const cancelEdit = () => setEditingCell(null)
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter' || e.key === 'Tab') {
e.preventDefault()
commitEdit()
} else if (e.key === 'Escape') {
cancelEdit()
}
}
const isEditing = (row: number, col: number) =>
editingCell?.row === row && editingCell?.col === col
return (
<div className='overflow-x-auto rounded-md border border-[var(--border)]'>
<table className='w-full border-collapse text-[13px]'>
<thead className='bg-[var(--surface-2)]'>
<tr>
{headers.map((header, i) => (
<th
key={i}
className={cn(
'whitespace-nowrap px-3 py-2 text-left font-semibold text-[12px] text-[var(--text-primary)]',
editConfig && 'cursor-pointer select-none hover:bg-[var(--surface-3)]'
)}
onClick={() => editConfig && startEdit(-1, i, String(header ?? ''))}
>
{isEditing(-1, i) ? (
<input
ref={setInputRef}
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
onBlur={commitEdit}
onKeyDown={handleKeyDown}
className='w-full min-w-[60px] bg-transparent font-semibold text-[12px] text-[var(--text-primary)] outline-none ring-1 ring-[var(--brand-secondary)] ring-inset'
/>
) : (
String(header ?? '')
)}
</th>
))}
</tr>
</thead>
<tbody>
{rows.map((row, ri) => (
<tr key={ri} className='border-[var(--border)] border-t'>
{headers.map((_, ci) => (
<td
key={ci}
className={cn(
'whitespace-nowrap px-3 py-2 text-[var(--text-secondary)]',
editConfig && 'cursor-pointer select-none hover:bg-[var(--surface-2)]'
)}
onClick={() => editConfig && startEdit(ri, ci, String(row[ci] ?? ''))}
>
{isEditing(ri, ci) ? (
<input
ref={setInputRef}
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
onBlur={commitEdit}
onKeyDown={handleKeyDown}
className='w-full min-w-[60px] bg-transparent text-[13px] text-[var(--text-secondary)] outline-none ring-1 ring-[var(--brand-secondary)] ring-inset'
/>
) : (
String(row[ci] ?? '')
)}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
)
})
export const DataTable = memo(DataTableBase)
@@ -0,0 +1,317 @@
'use client'
import { memo, useCallback, useEffect, useRef, useState } from 'react'
import { cn } from '@sim/emcn'
import { createLogger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
import { sanitizeRenderedHyperlinks } from '@/lib/core/security/url-safety'
import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace'
import { PREVIEW_LOADING_OVERLAY, PreviewError, resolvePreviewError } from './preview-shared'
import { PreviewToolbar } from './preview-toolbar'
import { bindPreviewWheelZoom } from './preview-wheel-zoom'
import { useDocPreviewBinary } from './use-doc-preview-binary'
const logger = createLogger('DocxPreview')
const DOCX_ZOOM_MIN = 25
const DOCX_ZOOM_MAX = 400
const DOCX_ZOOM_STEP = 20
const DOCX_ZOOM_WHEEL_SENSITIVITY = 0.005
const DOCX_RESIZE_DEBOUNCE_MS = 150
/**
* Fit the rendered docx pages to the host container width using a CSS scale.
* The library renders `<section class="docx">` at the document's natural page
* width (in cm), which overflows narrow panels. 100% zoom means fit-to-width —
* pages upscale past their natural print size in wide panels (CSS zoom of HTML
* stays crisp), matching the PDF preview's semantics.
*/
function fitDocxToContainer(host: HTMLElement, viewport: HTMLElement, zoomPercent: number) {
const wrapper = host.querySelector<HTMLElement>('.docx-wrapper')
if (!wrapper) return
const section = wrapper.querySelector<HTMLElement>('section.docx')
if (!section) return
host.style.minWidth = ''
host.style.minHeight = ''
host.style.width = ''
host.style.display = 'flex'
host.style.flexDirection = 'column'
host.style.alignItems = 'center'
wrapper.style.zoom = ''
wrapper.style.width = ''
wrapper.style.flex = '0 0 auto'
wrapper.style.marginRight = ''
wrapper.style.marginBottom = ''
const naturalPageWidth = section.offsetWidth
if (!naturalPageWidth) return
const wrapperStyle = window.getComputedStyle(wrapper)
const horizontalPadding =
Number.parseFloat(wrapperStyle.paddingLeft) + Number.parseFloat(wrapperStyle.paddingRight)
const naturalWrapperWidth = naturalPageWidth + horizontalPadding
const available = viewport.clientWidth
const fitScale = available / naturalWrapperWidth
const scale = fitScale * (zoomPercent / 100)
const scaledWrapperWidth = naturalWrapperWidth * scale
wrapper.style.width = `${naturalWrapperWidth}px`
wrapper.style.zoom = String(scale)
host.style.width = `${Math.max(available, scaledWrapperWidth)}px`
host.style.minWidth = `${scaledWrapperWidth}px`
host.style.minHeight = `${wrapper.offsetHeight * scale}px`
}
export const DocxPreview = memo(function DocxPreview({
file,
workspaceId,
}: {
file: WorkspaceFileRecord
workspaceId: string
}) {
const containerRef = useRef<HTMLDivElement>(null)
const scrollContainerRef = useRef<HTMLDivElement>(null)
const zoomPercentRef = useRef(100)
const preview = useDocPreviewBinary(workspaceId, file)
const fileData = preview.data
const [renderError, setRenderError] = useState<string | null>(null)
const [rendering, setRendering] = useState(false)
const [hasRenderedPreview, setHasRenderedPreview] = useState(false)
const [zoomPercent, setZoomPercent] = useState(100)
const [pageCount, setPageCount] = useState(0)
const [currentPage, setCurrentPage] = useState(1)
const [documentRenderVersion, setDocumentRenderVersion] = useState(0)
const applyPostRenderStyling = useCallback(() => {
const container = containerRef.current
const scrollContainer = scrollContainerRef.current
if (!container || !scrollContainer) return
const wrapper = container.querySelector<HTMLElement>('.docx-wrapper')
if (wrapper) wrapper.style.background = 'transparent'
const pages = Array.from(container.querySelectorAll<HTMLElement>('section.docx'))
pages.forEach((page, index) => {
page.style.boxShadow = 'var(--shadow-medium)'
page.dataset.page = String(index + 1)
})
setPageCount((previous) => (previous === pages.length ? previous : pages.length))
setCurrentPage((current) => (pages.length > 0 ? Math.min(current, pages.length) : 1))
fitDocxToContainer(container, scrollContainer, zoomPercentRef.current)
}, [])
/**
* Resize refits are debounced: each one re-queries the rendered pages and
* recomputes the fit scale, so per-tick refits during a panel-divider drag
* would thrash layout continuously (the initial fit is applied directly by
* the render path, not this observer). Mirrors the PDF preview's debounce.
*/
useEffect(() => {
const scrollContainer = scrollContainerRef.current
if (!scrollContainer) return
let debounce: ReturnType<typeof setTimeout> | undefined
const observer = new ResizeObserver(() => {
clearTimeout(debounce)
debounce = setTimeout(() => applyPostRenderStyling(), DOCX_RESIZE_DEBOUNCE_MS)
})
observer.observe(scrollContainer)
return () => {
clearTimeout(debounce)
observer.disconnect()
}
}, [applyPostRenderStyling])
const applyZoomAt = useCallback(
(nextZoom: number, anchorX: number, anchorY: number) => {
const scrollContainer = scrollContainerRef.current
if (!scrollContainer) return
const clampedZoom = Math.round(Math.min(Math.max(nextZoom, DOCX_ZOOM_MIN), DOCX_ZOOM_MAX))
const wrapper = containerRef.current?.querySelector<HTMLElement>('.docx-wrapper')
const containerRect = scrollContainer.getBoundingClientRect()
const anchorClientX = containerRect.left + anchorX
const anchorClientY = containerRect.top + anchorY
const beforeRect = wrapper?.getBoundingClientRect()
const anchorRatioX =
beforeRect && beforeRect.width > 0
? (anchorClientX - beforeRect.left) / beforeRect.width
: 0
const anchorRatioY =
beforeRect && beforeRect.height > 0
? (anchorClientY - beforeRect.top) / beforeRect.height
: 0
zoomPercentRef.current = clampedZoom
setZoomPercent(clampedZoom)
applyPostRenderStyling()
const afterRect = wrapper?.getBoundingClientRect()
if (!beforeRect || !afterRect) return
scrollContainer.scrollLeft += afterRect.left + anchorRatioX * afterRect.width - anchorClientX
scrollContainer.scrollTop += afterRect.top + anchorRatioY * afterRect.height - anchorClientY
},
[applyPostRenderStyling]
)
useEffect(() => {
const scrollContainer = scrollContainerRef.current
if (!scrollContainer) return
return bindPreviewWheelZoom(scrollContainer, (event) => {
const rect = scrollContainer.getBoundingClientRect()
applyZoomAt(
zoomPercentRef.current * (1 - event.deltaY * DOCX_ZOOM_WHEEL_SENSITIVITY),
event.clientX - rect.left,
event.clientY - rect.top
)
})
}, [applyZoomAt])
useEffect(() => {
const scrollContainer = scrollContainerRef.current
const container = containerRef.current
if (!scrollContainer || !container || pageCount === 0) return
const pages = Array.from(container.querySelectorAll<HTMLElement>('section.docx'))
const observer = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (entry.isIntersecting) {
const page = Number((entry.target as HTMLElement).dataset.page)
if (page) setCurrentPage(page)
}
}
},
{ root: scrollContainer, threshold: 0.5 }
)
for (const page of pages) {
observer.observe(page)
}
return () => observer.disconnect()
}, [pageCount, documentRenderVersion])
useEffect(() => {
if (!containerRef.current || !fileData) return
let cancelled = false
async function render() {
try {
setRendering(true)
const { renderAsync } = await import('docx-preview')
if (cancelled || !containerRef.current) return
setRenderError(null)
containerRef.current.innerHTML = ''
await renderAsync(fileData, containerRef.current, undefined, {
inWrapper: true,
ignoreWidth: false,
ignoreHeight: false,
})
if (!cancelled && containerRef.current) {
sanitizeRenderedHyperlinks(containerRef.current)
applyPostRenderStyling()
setHasRenderedPreview(true)
setDocumentRenderVersion((version) => version + 1)
}
} catch (err) {
if (!cancelled) {
const msg = toError(err).message || 'Failed to render document'
logger.error('DOCX render failed', { error: msg })
setRenderError(msg)
}
} finally {
if (!cancelled) {
setRendering(false)
}
}
}
render()
return () => {
cancelled = true
}
}, [fileData, applyPostRenderStyling])
const error = resolvePreviewError(preview.error, renderError)
if (error) return <PreviewError label='document' error={error} />
const showLoadingFrame = !hasRenderedPreview && (!fileData || rendering)
const scrollToPage = (page: number) => {
const scrollContainer = scrollContainerRef.current
const target = containerRef.current?.querySelector<HTMLElement>(
`section.docx[data-page="${page}"]`
)
if (!scrollContainer || !target) return
if (zoomPercentRef.current !== 100) {
applyZoomAt(100, scrollContainer.clientWidth / 2, scrollContainer.clientHeight / 2)
}
scrollContainer.scrollTo({
top: target.offsetTop - scrollContainer.offsetTop - 16,
behavior: 'smooth',
})
}
return (
<div className='flex h-full min-h-0 w-full flex-col overflow-hidden bg-[var(--surface-1)]'>
<PreviewToolbar
navigation={{
current: currentPage,
total: pageCount,
label: 'page',
canPrevious: pageCount > 0 && currentPage > 1,
canNext: pageCount > 0 && currentPage < pageCount,
onPrevious: () => {
const previous = Math.max(1, currentPage - 1)
setCurrentPage(previous)
scrollToPage(previous)
},
onNext: () => {
const next = Math.min(pageCount, currentPage + 1)
setCurrentPage(next)
scrollToPage(next)
},
}}
zoom={{
label: `${zoomPercent}%`,
canZoomOut: zoomPercent > DOCX_ZOOM_MIN,
canZoomIn: zoomPercent < DOCX_ZOOM_MAX,
onReset: () => {
const c = scrollContainerRef.current
applyZoomAt(100, c ? c.clientWidth / 2 : 0, c ? c.clientHeight / 2 : 0)
},
onZoomOut: () => {
const c = scrollContainerRef.current
applyZoomAt(
zoomPercent - DOCX_ZOOM_STEP,
c ? c.clientWidth / 2 : 0,
c ? c.clientHeight / 2 : 0
)
},
onZoomIn: () => {
const c = scrollContainerRef.current
applyZoomAt(
zoomPercent + DOCX_ZOOM_STEP,
c ? c.clientWidth / 2 : 0,
c ? c.clientHeight / 2 : 0
)
},
}}
/>
<div
ref={scrollContainerRef}
className='relative min-h-0 flex-1 overflow-auto bg-[var(--surface-1)]'
>
{showLoadingFrame && PREVIEW_LOADING_OVERLAY}
<div
ref={containerRef}
className={cn('min-h-full w-full', showLoadingFrame && 'opacity-0')}
/>
</div>
</div>
)
})
@@ -0,0 +1,103 @@
'use client'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuTrigger,
} from '@sim/emcn'
import { Clipboard, Duplicate, Search, SelectAll } from '@sim/emcn/icons'
import { Scissors } from 'lucide-react'
interface EditorContextMenuProps {
isOpen: boolean
position: { x: number; y: number }
onClose: () => void
hasSelection: boolean
canEdit: boolean
onCut: () => void
onCopy: () => void
onCopyAll: () => void
onPaste: () => void
onSelectAll: () => void
onFind: () => void
}
export function EditorContextMenu({
isOpen,
position,
onClose,
hasSelection,
canEdit,
onCut,
onCopy,
onCopyAll,
onPaste,
onSelectAll,
onFind,
}: EditorContextMenuProps) {
return (
<DropdownMenu open={isOpen} onOpenChange={(open) => !open && onClose()} modal={false}>
<DropdownMenuTrigger asChild>
<div
style={{
position: 'fixed',
left: `${position.x}px`,
top: `${position.y}px`,
width: '1px',
height: '1px',
pointerEvents: 'none',
}}
tabIndex={-1}
aria-hidden
/>
</DropdownMenuTrigger>
<DropdownMenuContent
align='start'
side='bottom'
sideOffset={2}
onCloseAutoFocus={(e) => e.preventDefault()}
>
{canEdit && (
<DropdownMenuItem disabled={!hasSelection} onSelect={onCut}>
<Scissors />
Cut
<DropdownMenuShortcut>X</DropdownMenuShortcut>
</DropdownMenuItem>
)}
<DropdownMenuItem disabled={!hasSelection} onSelect={onCopy}>
<Duplicate />
Copy
<DropdownMenuShortcut>C</DropdownMenuShortcut>
</DropdownMenuItem>
<DropdownMenuItem onSelect={onCopyAll}>
<Duplicate />
Copy all
</DropdownMenuItem>
{canEdit && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={onPaste}>
<Clipboard />
Paste
<DropdownMenuShortcut>V</DropdownMenuShortcut>
</DropdownMenuItem>
</>
)}
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={onSelectAll}>
<SelectAll />
Select all
<DropdownMenuShortcut>A</DropdownMenuShortcut>
</DropdownMenuItem>
<DropdownMenuItem onSelect={onFind}>
<Search />
Find
<DropdownMenuShortcut>F</DropdownMenuShortcut>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}
@@ -0,0 +1,233 @@
/**
* @vitest-environment node
*/
import { describe, expect, it, vi } from 'vitest'
vi.mock('@/lib/uploads/utils/validation', () => ({
SUPPORTED_CODE_EXTENSIONS: ['js', 'ts', 'py', 'go', 'rs', 'sh', 'sql'],
}))
vi.mock('@/lib/uploads/utils/file-utils', () => ({
getFileExtension: (filename: string): string => {
const lastDot = filename.lastIndexOf('.')
return lastDot !== -1 ? filename.slice(lastDot + 1).toLowerCase() : ''
},
}))
import { resolveFileCategory } from './file-category'
describe('resolveFileCategory — MIME type routing', () => {
describe('text-editable', () => {
it.each([
'text/plain',
'text/markdown',
'application/json',
'application/x-yaml',
'text/csv',
'text/html',
'text/xml',
'application/xml',
'text/css',
'text/javascript',
'application/javascript',
'application/typescript',
'application/toml',
'text/x-python',
'text/x-sh',
'text/x-sql',
'image/svg+xml',
'text/x-mermaid',
])('%s → text-editable', (mime) => {
expect(resolveFileCategory(mime, 'file.txt')).toBe('text-editable')
})
})
describe('iframe-previewable (PDF)', () => {
it('application/pdf → iframe-previewable', () => {
expect(resolveFileCategory('application/pdf', 'doc.pdf')).toBe('iframe-previewable')
})
it('text/x-pdflibjs → iframe-previewable', () => {
expect(resolveFileCategory('text/x-pdflibjs', 'generated.pdf')).toBe('iframe-previewable')
})
})
describe('image-previewable', () => {
it.each(['image/png', 'image/jpeg', 'image/gif', 'image/webp'])(
'%s → image-previewable',
(mime) => {
expect(resolveFileCategory(mime, 'img.png')).toBe('image-previewable')
}
)
})
describe('audio-previewable', () => {
it.each([
'audio/mpeg',
'audio/mp4',
'audio/wav',
'audio/webm',
'audio/ogg',
'audio/flac',
'audio/aac',
'audio/opus',
'audio/x-m4a',
])('%s → audio-previewable', (mime) => {
expect(resolveFileCategory(mime, 'audio.mp3')).toBe('audio-previewable')
})
})
describe('video-previewable', () => {
it.each(['video/mp4', 'video/quicktime', 'video/x-msvideo', 'video/x-matroska', 'video/webm'])(
'%s → video-previewable',
(mime) => {
expect(resolveFileCategory(mime, 'video.mp4')).toBe('video-previewable')
}
)
})
describe('docx-previewable', () => {
it('application/vnd.openxmlformats-officedocument.wordprocessingml.document → docx-previewable', () => {
expect(
resolveFileCategory(
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'doc.docx'
)
).toBe('docx-previewable')
})
it('text/x-docxjs → docx-previewable', () => {
expect(resolveFileCategory('text/x-docxjs', 'doc.docx')).toBe('docx-previewable')
})
})
describe('pptx-previewable', () => {
it('application/vnd.openxmlformats-officedocument.presentationml.presentation → pptx-previewable', () => {
expect(
resolveFileCategory(
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
'deck.pptx'
)
).toBe('pptx-previewable')
})
it('text/x-pptxgenjs → pptx-previewable', () => {
expect(resolveFileCategory('text/x-pptxgenjs', 'deck.pptx')).toBe('pptx-previewable')
})
})
describe('xlsx-previewable', () => {
it('application/vnd.openxmlformats-officedocument.spreadsheetml.sheet → xlsx-previewable', () => {
expect(
resolveFileCategory(
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'data.xlsx'
)
).toBe('xlsx-previewable')
})
})
})
describe('resolveFileCategory — extension fallback', () => {
describe('text-editable extensions', () => {
it.each(['md', 'txt', 'json', 'yaml', 'yml', 'csv', 'html', 'htm', 'svg', 'mmd'])(
'.%s → text-editable',
(ext) => {
expect(resolveFileCategory(null, `file.${ext}`)).toBe('text-editable')
}
)
})
describe('code extensions from SUPPORTED_CODE_EXTENSIONS', () => {
it.each(['js', 'ts', 'py', 'go', 'rs', 'sh', 'sql'])('.%s → text-editable', (ext) => {
expect(resolveFileCategory(null, `file.${ext}`)).toBe('text-editable')
})
})
describe('pdf extension', () => {
it('.pdf → iframe-previewable', () => {
expect(resolveFileCategory(null, 'document.pdf')).toBe('iframe-previewable')
})
})
describe('image extensions', () => {
it.each(['png', 'jpg', 'jpeg', 'gif', 'webp'])('.%s → image-previewable', (ext) => {
expect(resolveFileCategory(null, `image.${ext}`)).toBe('image-previewable')
})
})
describe('audio extensions', () => {
it.each(['mp3', 'm4a', 'wav', 'ogg', 'flac', 'aac', 'opus'])(
'.%s → audio-previewable',
(ext) => {
expect(resolveFileCategory(null, `audio.${ext}`)).toBe('audio-previewable')
}
)
})
describe('video extensions', () => {
it.each(['mp4', 'mov', 'avi', 'mkv', 'webm'])('.%s → video-previewable', (ext) => {
expect(resolveFileCategory(null, `video.${ext}`)).toBe('video-previewable')
})
})
describe('docx extension', () => {
it('.docx → docx-previewable', () => {
expect(resolveFileCategory(null, 'doc.docx')).toBe('docx-previewable')
})
})
describe('pptx extension', () => {
it('.pptx → pptx-previewable', () => {
expect(resolveFileCategory(null, 'deck.pptx')).toBe('pptx-previewable')
})
})
describe('xlsx extension', () => {
it('.xlsx → xlsx-previewable', () => {
expect(resolveFileCategory(null, 'data.xlsx')).toBe('xlsx-previewable')
})
})
describe('unsupported', () => {
it('unknown extension → unsupported', () => {
expect(resolveFileCategory(null, 'file.xyz')).toBe('unsupported')
})
it('unknown mime with unknown extension → unsupported', () => {
expect(resolveFileCategory('application/octet-stream', 'file.bin')).toBe('unsupported')
})
it('no extension, no mime → unsupported', () => {
expect(resolveFileCategory(null, 'LICENSE')).toBe('unsupported')
})
})
})
describe('resolveFileCategory — MIME priority', () => {
it('text/plain MIME + .pdf extension → text-editable (MIME wins)', () => {
expect(resolveFileCategory('text/plain', 'notes.pdf')).toBe('text-editable')
})
it('application/pdf MIME + .txt extension → iframe-previewable (MIME wins)', () => {
expect(resolveFileCategory('application/pdf', 'disguised.txt')).toBe('iframe-previewable')
})
it('null MIME falls through to extension routing', () => {
expect(resolveFileCategory(null, 'data.xlsx')).toBe('xlsx-previewable')
})
it('unknown MIME falls through to extension routing', () => {
expect(resolveFileCategory('application/octet-stream', 'data.xlsx')).toBe('xlsx-previewable')
})
})
describe('resolveFileCategory — extension case', () => {
it('recognises uppercase extension via extension lookup (getFileExtension lowercases)', () => {
expect(resolveFileCategory(null, 'README.MD')).toBe('text-editable')
})
it('handles mixed-case correctly for json', () => {
expect(resolveFileCategory(null, 'config.JSON')).toBe('text-editable')
})
})
@@ -0,0 +1,122 @@
import { getFileExtension } from '@/lib/uploads/utils/file-utils'
import { SUPPORTED_CODE_EXTENSIONS } from '@/lib/uploads/utils/validation'
const TEXT_EDITABLE_MIME_TYPES = new Set([
'text/markdown',
'text/plain',
'application/json',
'application/x-yaml',
'text/csv',
'text/html',
'text/xml',
'application/xml',
'text/css',
'text/javascript',
'application/javascript',
'application/typescript',
'application/toml',
'text/x-python',
'text/x-sh',
'text/x-sql',
'image/svg+xml',
'text/x-mermaid',
])
const TEXT_EDITABLE_EXTENSIONS = new Set([
'md',
'txt',
'json',
'yaml',
'yml',
'csv',
'html',
'htm',
'svg',
'mmd',
...SUPPORTED_CODE_EXTENSIONS,
])
const IFRAME_PREVIEWABLE_MIME_TYPES = new Set([
'application/pdf',
'text/x-pdflibjs',
'text/x-python-pdf',
])
const IFRAME_PREVIEWABLE_EXTENSIONS = new Set(['pdf'])
const IMAGE_PREVIEWABLE_MIME_TYPES = new Set(['image/png', 'image/jpeg', 'image/gif', 'image/webp'])
const IMAGE_PREVIEWABLE_EXTENSIONS = new Set(['png', 'jpg', 'jpeg', 'gif', 'webp'])
const AUDIO_PREVIEWABLE_MIME_TYPES = new Set([
'audio/mpeg',
'audio/mp4',
'audio/wav',
'audio/webm',
'audio/ogg',
'audio/flac',
'audio/aac',
'audio/opus',
'audio/x-m4a',
])
const AUDIO_PREVIEWABLE_EXTENSIONS = new Set(['mp3', 'm4a', 'wav', 'ogg', 'flac', 'aac', 'opus'])
const VIDEO_PREVIEWABLE_MIME_TYPES = new Set([
'video/mp4',
'video/quicktime',
'video/x-msvideo',
'video/x-matroska',
'video/webm',
])
const VIDEO_PREVIEWABLE_EXTENSIONS = new Set(['mp4', 'mov', 'avi', 'mkv', 'webm'])
const PPTX_PREVIEWABLE_MIME_TYPES = new Set([
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
'text/x-pptxgenjs',
])
const PPTX_PREVIEWABLE_EXTENSIONS = new Set(['pptx'])
const DOCX_PREVIEWABLE_MIME_TYPES = new Set([
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'text/x-docxjs',
])
const DOCX_PREVIEWABLE_EXTENSIONS = new Set(['docx'])
const XLSX_PREVIEWABLE_MIME_TYPES = new Set([
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'text/x-python-xlsx',
])
const XLSX_PREVIEWABLE_EXTENSIONS = new Set(['xlsx'])
export type FileCategory =
| 'text-editable'
| 'iframe-previewable'
| 'image-previewable'
| 'audio-previewable'
| 'video-previewable'
| 'pptx-previewable'
| 'docx-previewable'
| 'xlsx-previewable'
| 'unsupported'
export function resolveFileCategory(mimeType: string | null, filename: string): FileCategory {
if (mimeType && TEXT_EDITABLE_MIME_TYPES.has(mimeType)) return 'text-editable'
if (mimeType && IFRAME_PREVIEWABLE_MIME_TYPES.has(mimeType)) return 'iframe-previewable'
if (mimeType && IMAGE_PREVIEWABLE_MIME_TYPES.has(mimeType)) return 'image-previewable'
if (mimeType && AUDIO_PREVIEWABLE_MIME_TYPES.has(mimeType)) return 'audio-previewable'
if (mimeType && VIDEO_PREVIEWABLE_MIME_TYPES.has(mimeType)) return 'video-previewable'
if (mimeType && DOCX_PREVIEWABLE_MIME_TYPES.has(mimeType)) return 'docx-previewable'
if (mimeType && PPTX_PREVIEWABLE_MIME_TYPES.has(mimeType)) return 'pptx-previewable'
if (mimeType && XLSX_PREVIEWABLE_MIME_TYPES.has(mimeType)) return 'xlsx-previewable'
const ext = getFileExtension(filename)
const nameKey = ext || filename.toLowerCase()
if (TEXT_EDITABLE_EXTENSIONS.has(nameKey)) return 'text-editable'
if (IFRAME_PREVIEWABLE_EXTENSIONS.has(ext)) return 'iframe-previewable'
if (IMAGE_PREVIEWABLE_EXTENSIONS.has(ext)) return 'image-previewable'
if (AUDIO_PREVIEWABLE_EXTENSIONS.has(ext)) return 'audio-previewable'
if (VIDEO_PREVIEWABLE_EXTENSIONS.has(ext)) return 'video-previewable'
if (DOCX_PREVIEWABLE_EXTENSIONS.has(ext)) return 'docx-previewable'
if (PPTX_PREVIEWABLE_EXTENSIONS.has(ext)) return 'pptx-previewable'
if (XLSX_PREVIEWABLE_EXTENSIONS.has(ext)) return 'xlsx-previewable'
return 'unsupported'
}
@@ -0,0 +1,421 @@
'use client'
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Music } from 'lucide-react'
import dynamic from 'next/dynamic'
import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace'
import { getFileExtension } from '@/lib/uploads/utils/file-utils'
import { useWorkspaceFileBinary, useWorkspaceFileContent } from '@/hooks/queries/workspace-files'
import {
createWorkspaceFileContentSource,
type FileContentSource,
FileContentSourceProvider,
} from '@/hooks/use-file-content-source'
import { CsvTablePreview } from './csv-table-preview'
import { DocxPreview } from './docx-preview'
import { resolveFileCategory } from './file-category'
import { ImagePreview } from './image-preview'
import type { PdfDocumentSource } from './pdf-viewer'
import { PptxPreview } from './pptx-preview'
import { PreviewPanel, resolvePreviewType } from './preview-panel'
import {
PREVIEW_LOADING_OVERLAY,
PreviewError,
PreviewErrorBoundary,
PreviewLoadingFrame,
resolvePreviewError,
} from './preview-shared'
import { TextEditor } from './text-editor'
import { useDocPreviewBinary } from './use-doc-preview-binary'
import { XlsxPreview } from './xlsx-preview'
const PdfViewerCore = dynamic(() => import('./pdf-viewer').then((m) => m.PdfViewerCore), {
ssr: false,
})
const RichMarkdownEditor = dynamic(
() => import('./rich-markdown-editor/rich-markdown-editor').then((m) => m.RichMarkdownEditor),
{ ssr: false, loading: () => <PreviewLoadingFrame className='flex flex-1 flex-col' /> }
)
/**
* CSVs at or below this size load fully into the editor (editable, with an inline preview).
* Larger CSVs would OOM the browser on `response.text()`, so they render a read-only,
* server-streamed preview of the first rows instead (see {@link CsvTablePreview}).
*/
const CSV_INLINE_EDIT_MAX_BYTES = 5 * 1024 * 1024
export function isTextEditable(file: { type: string; name: string }): boolean {
return resolveFileCategory(file.type, file.name) === 'text-editable'
}
export function isPreviewable(file: { type: string; name: string }): boolean {
return resolvePreviewType(file.type, file.name) !== null
}
/**
* Markdown files render in the inline rich editor ({@link RichMarkdownEditor}) rather than
* the raw Monaco editor. Toolbars use this to hide the raw/split/preview mode controls,
* which don't apply to the single-surface editor.
*/
export function isMarkdownFile(file: { type: string; name: string }): boolean {
return resolvePreviewType(file.type, file.name) === 'markdown'
}
/**
* A CSV larger than {@link CSV_INLINE_EDIT_MAX_BYTES} is shown as a streamed, read-only preview —
* the editor would OOM loading the whole file. The viewer renders {@link CsvTablePreview} for it,
* and toolbars use this to hide the edit/split/save controls (there is no editor to switch to).
*/
export function isCsvStreamOnly(file: {
type: string | null
name: string
size?: number | null
}): boolean {
return (
resolvePreviewType(file.type, file.name) === 'csv' &&
(file.size ?? 0) > CSV_INLINE_EDIT_MAX_BYTES
)
}
export type PreviewMode = 'editor' | 'split' | 'preview'
interface FileViewerProps {
file: WorkspaceFileRecord
workspaceId: string
/**
* Content source for this view. Defaults to a workspace-scoped source derived from `workspaceId`;
* the public share page passes a token-scoped source. Provided to descendants (renderers, embedded
* images) via {@link FileContentSourceProvider}.
*/
contentSource?: FileContentSource
canEdit: boolean
/**
* Render a read-only preview with no editing affordances. Text files render
* through {@link PreviewPanel} (or a plain `<pre>`) instead of the editable
* {@link TextEditor}. Used by the public share page.
*/
readOnly?: boolean
previewMode?: PreviewMode
autoFocus?: boolean
onDirtyChange?: (isDirty: boolean) => void
onSaveStatusChange?: (
status: 'idle' | 'saving' | 'saved' | 'error',
retry?: () => Promise<void>
) => void
saveRef?: React.MutableRefObject<(() => Promise<void>) | null>
discardRef?: React.MutableRefObject<(() => void) | null>
streamingContent?: string
isAgentEditing?: boolean
streamIsIncremental?: boolean
disableStreamingAutoScroll?: boolean
previewContextKey?: string
}
export function FileViewer(props: FileViewerProps) {
const { contentSource, workspaceId } = props
const source = useMemo(
() => contentSource ?? createWorkspaceFileContentSource(workspaceId),
[contentSource, workspaceId]
)
return (
<FileContentSourceProvider value={source}>
<FileViewerContent {...props} />
</FileContentSourceProvider>
)
}
function FileViewerContent({
file,
workspaceId,
canEdit,
readOnly = false,
previewMode,
autoFocus,
onDirtyChange,
onSaveStatusChange,
saveRef,
discardRef,
streamingContent,
isAgentEditing,
streamIsIncremental,
disableStreamingAutoScroll = false,
previewContextKey,
}: FileViewerProps) {
const category = resolveFileCategory(file.type, file.name)
if (category === 'text-editable') {
if (readOnly) {
// ReadOnlyTextPreview loads the whole file as text; a large CSV would OOM the
// browser. CsvTablePreview's streamed fallback is workspace-only, so on the
// read-only public path a large CSV is download-only.
if (isCsvStreamOnly(file)) {
return <UnsupportedPreview file={file} />
}
// Markdown renders through the inline rich editor (non-editable) so the public share
// surface matches the in-app reading experience; canEdit={false} disables autosave,
// the bubble menu, and every other editing affordance.
if (isMarkdownFile(file)) {
return (
<RichMarkdownEditor key={file.id} file={file} workspaceId={workspaceId} canEdit={false} />
)
}
return <ReadOnlyTextPreview file={file} workspaceId={workspaceId} />
}
// A large CSV can't be loaded whole into the editor (the browser OOMs on the full text).
// Render a streamed, read-only preview of the first rows + an "Import as a table" path instead.
if (isCsvStreamOnly(file)) {
return <CsvTablePreview key={file.id} file={file} workspaceId={workspaceId} />
}
if (isMarkdownFile(file)) {
return (
<RichMarkdownEditor
key={file.id}
file={file}
workspaceId={workspaceId}
canEdit={canEdit}
autoFocus={autoFocus}
onDirtyChange={onDirtyChange}
onSaveStatusChange={onSaveStatusChange}
saveRef={saveRef}
discardRef={discardRef}
streamingContent={streamingContent}
isAgentEditing={isAgentEditing}
streamIsIncremental={streamIsIncremental}
disableStreamingAutoScroll={disableStreamingAutoScroll}
previewContextKey={previewContextKey}
/>
)
}
return (
<TextEditor
file={file}
workspaceId={workspaceId}
canEdit={canEdit}
previewMode={previewMode ?? 'editor'}
autoFocus={autoFocus}
onDirtyChange={onDirtyChange}
onSaveStatusChange={onSaveStatusChange}
saveRef={saveRef}
discardRef={discardRef}
streamingContent={streamingContent}
isAgentEditing={isAgentEditing}
disableStreamingAutoScroll={disableStreamingAutoScroll}
previewContextKey={previewContextKey}
/>
)
}
if (category === 'iframe-previewable') {
return <IframePreview key={file.id} file={file} workspaceId={workspaceId} />
}
if (category === 'image-previewable') {
return <ImagePreview key={file.key} file={file} />
}
if (category === 'audio-previewable') {
return <MediaPreview key={file.id} file={file} workspaceId={workspaceId} kind='audio' />
}
if (category === 'video-previewable') {
return <MediaPreview key={file.id} file={file} workspaceId={workspaceId} kind='video' />
}
if (category === 'docx-previewable') {
return <DocxPreview key={file.id} file={file} workspaceId={workspaceId} />
}
if (category === 'pptx-previewable') {
return <PptxPreview key={file.id} file={file} workspaceId={workspaceId} />
}
if (category === 'xlsx-previewable') {
return <XlsxPreview key={file.id} file={file} workspaceId={workspaceId} />
}
return <UnsupportedPreview file={file} />
}
/**
* Read-only text/markdown/code preview. Renders rich types (markdown, csv, svg,
* mermaid, html) through {@link PreviewPanel} and plain text/code in a `<pre>`.
* Fetches content through the active content source, so it works for both
* workspace files and public share links.
*/
const ReadOnlyTextPreview = memo(function ReadOnlyTextPreview({
file,
workspaceId,
}: {
file: WorkspaceFileRecord
workspaceId: string
}) {
const {
data: content,
isLoading,
error,
} = useWorkspaceFileContent(workspaceId, file.id, file.key)
const resolvedError = resolvePreviewError((error as Error | null) ?? null, null)
if (resolvedError) return <PreviewError label='file' error={resolvedError} />
if (isLoading || content == null) return <PreviewLoadingFrame className='h-full' tone='surface' />
if (resolvePreviewType(file.type, file.name)) {
return (
<div className='h-full min-h-0 w-full overflow-auto'>
<PreviewPanel
content={content}
mimeType={file.type}
filename={file.name}
workspaceId={workspaceId}
fileKey={file.key}
readOnly
/>
</div>
)
}
return (
<div className='h-full min-h-0 w-full overflow-auto bg-[var(--surface-1)] p-4'>
<pre className='whitespace-pre-wrap break-words font-mono text-[13px] text-[var(--text-body)]'>
{content}
</pre>
</div>
)
})
const IframePreview = memo(function IframePreview({
file,
workspaceId,
}: {
file: WorkspaceFileRecord
workspaceId: string
}) {
const preview = useDocPreviewBinary(workspaceId, file)
const bufferSource = useMemo<PdfDocumentSource | null>(
() => (preview.data ? { kind: 'buffer', buffer: preview.data } : null),
[preview.data]
)
const error = resolvePreviewError(preview.error, null)
if (error) return <PreviewError label='PDF' error={error} />
if (!bufferSource) {
return <div className='relative flex flex-1 overflow-hidden'>{PREVIEW_LOADING_OVERLAY}</div>
}
return (
<PreviewErrorBoundary key={`${file.id}:${preview.dataUpdatedAt}`} label='PDF'>
<PdfViewerCore source={bufferSource} filename={file.name} />
</PreviewErrorBoundary>
)
})
function useBlobUrl(workspaceId: string, fileId: string, fileKey: string) {
const { data: fileData, isLoading, error } = useWorkspaceFileBinary(workspaceId, fileId, fileKey)
const [blobUrl, setBlobUrl] = useState<string | null>(null)
const blobUrlRef = useRef<string | null>(null)
const replaceBlobUrl = useCallback((nextUrl: string | null) => {
const previousUrl = blobUrlRef.current
blobUrlRef.current = nextUrl
setBlobUrl(nextUrl)
if (previousUrl && previousUrl !== nextUrl) URL.revokeObjectURL(previousUrl)
}, [])
useEffect(() => {
return () => {
if (blobUrlRef.current) {
URL.revokeObjectURL(blobUrlRef.current)
blobUrlRef.current = null
}
}
}, [])
return { fileData, isLoading, error, blobUrl, replaceBlobUrl }
}
const MEDIA_FALLBACK_MIME = { audio: 'audio/mpeg', video: 'video/mp4' } as const
/**
* Shared blob-backed preview for audio and video files — the fetch, blob-URL
* lifecycle, and error/loading handling are identical; only the rendered
* player differs.
*/
const MediaPreview = memo(function MediaPreview({
file,
workspaceId,
kind,
}: {
file: WorkspaceFileRecord
workspaceId: string
kind: 'audio' | 'video'
}) {
const {
fileData,
isLoading,
error: fetchError,
blobUrl,
replaceBlobUrl,
} = useBlobUrl(workspaceId, file.id, file.key)
useEffect(() => {
if (!fileData) return
replaceBlobUrl(
URL.createObjectURL(new Blob([fileData], { type: file.type || MEDIA_FALLBACK_MIME[kind] }))
)
}, [file.type, fileData, kind, replaceBlobUrl])
const error = blobUrl !== null ? null : resolvePreviewError(fetchError, null)
if (error) return <PreviewError label={kind} error={error} />
if (isLoading && !blobUrl) {
return <PreviewLoadingFrame className='h-full' tone='surface' />
}
if (kind === 'audio') {
return (
<div className='flex h-full flex-col items-center justify-center gap-4 bg-[var(--surface-1)] p-8'>
<div className='flex flex-col items-center gap-2 text-center'>
<Music className='size-[32px] text-[var(--text-muted)]' strokeWidth={1.5} />
<p className='font-medium text-[14px] text-[var(--text-primary)]'>{file.name}</p>
</div>
{blobUrl && (
// biome-ignore lint/a11y/useMediaCaption: audio from workspace files
<audio src={blobUrl} controls className='w-full max-w-[480px]' />
)}
</div>
)
}
return (
<div className='flex h-full items-center justify-center bg-[var(--surface-1)]'>
{blobUrl && (
// biome-ignore lint/a11y/useMediaCaption: video from workspace files
<video src={blobUrl} controls className='max-h-full max-w-full' />
)}
</div>
)
})
const UnsupportedPreview = memo(function UnsupportedPreview({
file,
}: {
file: WorkspaceFileRecord
}) {
const ext = getFileExtension(file.name)
return (
<div className='flex flex-1 flex-col items-center justify-center gap-[8px]'>
<p className='font-medium text-[14px] text-[var(--text-primary)]'>
Preview not available{ext ? ` for .${ext} files` : ' for this file'}
</p>
<p className='text-[13px] text-[var(--text-muted)]'>
Use the download button to view this file
</p>
</div>
)
})
@@ -0,0 +1,34 @@
'use client'
import { memo, useState } from 'react'
import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace'
import { useFileContentSource } from '@/hooks/use-file-content-source'
import { PREVIEW_LOADING_OVERLAY } from './preview-shared'
import { ZoomablePreview } from './zoomable-preview'
export const ImagePreview = memo(function ImagePreview({ file }: { file: WorkspaceFileRecord }) {
const source = useFileContentSource()
const [hasSettled, setHasSettled] = useState(false)
// Version the URL on updatedAt: overwrites keep the same storage key, so an unversioned
// URL would resolve to a previously cached copy instead of the rewritten bytes.
const serveUrl = source.buildUrl(file.key, {
version: Number(new Date(file.updatedAt)) || file.size,
})
return (
<div className='relative flex min-h-0 flex-1 flex-col'>
<ZoomablePreview className='flex flex-1' contentClassName='h-full w-full'>
<img
src={serveUrl}
alt={file.name}
className='max-h-full max-w-full select-none rounded-md object-contain'
draggable={false}
loading='eager'
onLoad={() => setHasSettled(true)}
onError={() => setHasSettled(true)}
/>
</ZoomablePreview>
{!hasSettled && PREVIEW_LOADING_OVERLAY}
</div>
)
})
@@ -0,0 +1,10 @@
export { resolveFileCategory } from './file-category'
export type { PreviewMode } from './file-viewer'
export {
FileViewer,
isCsvStreamOnly,
isMarkdownFile,
isPreviewable,
isTextEditable,
} from './file-viewer'
export { PreviewPanel, RICH_PREVIEWABLE_EXTENSIONS, resolvePreviewType } from './preview-panel'
@@ -0,0 +1,63 @@
/**
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import { looksLikeMermaid } from './mermaid-diagram'
describe('looksLikeMermaid', () => {
it('detects blocks whose first line opens with a diagram keyword', () => {
expect(looksLikeMermaid('flowchart TD\n A --> B')).toBe(true)
expect(looksLikeMermaid('graph LR\n A --> B')).toBe(true)
expect(looksLikeMermaid('sequenceDiagram\n Alice->>Bob: Hi')).toBe(true)
expect(looksLikeMermaid('pie title NETFLIX\n "A" : 90')).toBe(true)
expect(looksLikeMermaid('stateDiagram-v2\n [*] --> S')).toBe(true)
expect(looksLikeMermaid('gantt\n title A')).toBe(true)
})
it('detects the remaining diagram openers', () => {
expect(looksLikeMermaid('classDiagram\n Animal <|-- Duck')).toBe(true)
expect(looksLikeMermaid('stateDiagram\n [*] --> S')).toBe(true)
expect(looksLikeMermaid('erDiagram\n A ||--o{ B : has')).toBe(true)
expect(looksLikeMermaid('journey\n title My day')).toBe(true)
expect(looksLikeMermaid('quadrantChart\n title Reach')).toBe(true)
expect(looksLikeMermaid('requirementDiagram')).toBe(true)
expect(looksLikeMermaid('gitGraph')).toBe(true)
expect(looksLikeMermaid('mindmap\n root')).toBe(true)
expect(looksLikeMermaid('timeline\n title History')).toBe(true)
expect(looksLikeMermaid('sankey-beta')).toBe(true)
expect(looksLikeMermaid('xychart-beta')).toBe(true)
expect(looksLikeMermaid('block-beta')).toBe(true)
expect(looksLikeMermaid('packet-beta')).toBe(true)
expect(looksLikeMermaid('kanban')).toBe(true)
expect(looksLikeMermaid('architecture-beta')).toBe(true)
expect(looksLikeMermaid('zenuml')).toBe(true)
expect(looksLikeMermaid('C4Context\n title System')).toBe(true)
expect(looksLikeMermaid('C4Container')).toBe(true)
expect(looksLikeMermaid('C4Component')).toBe(true)
expect(looksLikeMermaid('C4Dynamic')).toBe(true)
expect(looksLikeMermaid('C4Deployment')).toBe(true)
})
it('skips leading blank lines before the opener', () => {
expect(looksLikeMermaid('\n\n flowchart TD\n A --> B')).toBe(true)
expect(looksLikeMermaid('\n \n\t\nsequenceDiagram\n Alice->>Bob: Hi')).toBe(true)
})
it('rejects ordinary code that merely contains a keyword later', () => {
expect(looksLikeMermaid('const graph = makeGraph()\nreturn graph')).toBe(false)
expect(looksLikeMermaid('print("pie")')).toBe(false)
expect(looksLikeMermaid('SELECT * FROM pies')).toBe(false)
expect(looksLikeMermaid('')).toBe(false)
expect(looksLikeMermaid('\n\n \n')).toBe(false)
expect(looksLikeMermaid('# flowchart of the system')).toBe(false)
expect(looksLikeMermaid(' // graph helpers')).toBe(false)
})
it('requires a word boundary after the keyword', () => {
expect(looksLikeMermaid('graphql query { user }')).toBe(false)
expect(looksLikeMermaid('pieChart()')).toBe(false)
expect(looksLikeMermaid('flowcharting()')).toBe(false)
expect(looksLikeMermaid('ganttify')).toBe(false)
expect(looksLikeMermaid('journeyman')).toBe(false)
})
})
@@ -0,0 +1,287 @@
'use client'
/**
* Must precede the react-pdf import: pdf.js calls the polyfilled APIs while
* its module evaluates, which throws on Safari < 17.4 without them.
*/
import '@/lib/core/utils/browser-polyfills'
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
import { pdfjs, Document as ReactPdfDocument, Page as ReactPdfPage } from 'react-pdf'
import 'react-pdf/dist/Page/TextLayer.css'
import { PREVIEW_LOADING_OVERLAY } from '@/app/workspace/[workspaceId]/files/components/file-viewer/preview-shared'
import { PreviewToolbar } from '@/app/workspace/[workspaceId]/files/components/file-viewer/preview-toolbar'
import { bindPreviewWheelZoom } from '@/app/workspace/[workspaceId]/files/components/file-viewer/preview-wheel-zoom'
/**
* The worker runs in its own context that browser-polyfills cannot reach, so
* serve the legacy worker build, which bundles its own polyfills.
*/
pdfjs.GlobalWorkerOptions.workerSrc = new URL(
'pdfjs-dist/legacy/build/pdf.worker.min.mjs',
import.meta.url
).href
const logger = createLogger('PdfViewer')
const PDF_ZOOM_MIN = 0.5
const PDF_ZOOM_MAX = 3
const PDF_ZOOM_DEFAULT = 1
const PDF_ZOOM_STEP = 1.25
const PDF_VIEWER_PADDING = 24
const PDF_RESIZE_DEBOUNCE_MS = 150
export type PdfDocumentSource =
| { kind: 'url'; url: string }
| { kind: 'buffer'; buffer: ArrayBuffer }
interface PdfViewerCoreProps {
source: PdfDocumentSource
filename: string
}
function PdfError({ error }: { error: string }) {
return (
<div className='flex flex-1 flex-col items-center justify-center gap-[8px]'>
<p className='font-medium text-[14px] text-[var(--text-body)]'>Failed to preview PDF</p>
<p className='text-[13px] text-[var(--text-muted)]'>{error}</p>
</div>
)
}
export const PdfViewerCore = memo(function PdfViewerCore({ source, filename }: PdfViewerCoreProps) {
const containerRef = useRef<HTMLDivElement>(null)
const paddingWrapperRef = useRef<HTMLDivElement>(null)
const pagesWrapperRef = useRef<HTMLDivElement>(null)
const pageRefs = useRef<(HTMLDivElement | null)[]>([])
const pageWidthRef = useRef<number | undefined>(undefined)
const zoomRef = useRef(PDF_ZOOM_DEFAULT)
const [containerWidth, setContainerWidth] = useState(0)
const [pageCount, setPageCount] = useState(0)
const [isDocumentReady, setIsDocumentReady] = useState(false)
const [displayZoom, setDisplayZoom] = useState(PDF_ZOOM_DEFAULT)
const [currentPage, setCurrentPage] = useState(1)
const [loadError, setLoadError] = useState<string | null>(null)
const sourceValue = source.kind === 'url' ? source.url : source.buffer
/**
* The buffer copy (`slice(0)`) is load-bearing: pdf.js transfers — and
* detaches — the ArrayBuffer it receives to its worker, so handing over the
* caller's buffer would leave it unusable on the next render or remount.
*/
const file = useMemo(
() => (source.kind === 'url' ? source.url : { data: new Uint8Array(source.buffer.slice(0)) }),
[sourceValue]
)
/**
* The first non-zero measurement applies immediately so the document renders
* without delay (a hidden container reports zero width and must not consume
* the immediate slot); subsequent ones (panel-divider drags) are debounced
* because every pageWidth change makes pdf.js re-rasterise all page canvases
* — per-tick updates during a drag would re-render the whole document
* continuously.
*/
useEffect(() => {
const container = containerRef.current
if (!container) return
let hasMeasured = false
let debounce: ReturnType<typeof setTimeout> | undefined
const observer = new ResizeObserver(([entry]) => {
const { width } = entry.contentRect
if (!hasMeasured) {
if (width <= 0) return
hasMeasured = true
setContainerWidth(width)
return
}
clearTimeout(debounce)
debounce = setTimeout(() => setContainerWidth(width), PDF_RESIZE_DEBOUNCE_MS)
})
observer.observe(container)
return () => {
clearTimeout(debounce)
observer.disconnect()
}
}, [])
/**
* 100% zoom fits the page to the panel width (pdf.js re-renders the canvas
* at the target width, so upscaling past the page's natural print size
* stays crisp). Matches the DOCX preview's fit-to-width semantics.
*/
const pageWidth = containerWidth > 0 ? containerWidth - 2 * PDF_VIEWER_PADDING : undefined
pageWidthRef.current = pageWidth
const applyZoomAt = useCallback((next: number, anchorX: number, anchorY: number) => {
const container = containerRef.current
const wrapper = pagesWrapperRef.current
const padWrapper = paddingWrapperRef.current
const pw = pageWidthRef.current
if (!container || !wrapper) return
const ratio = next / zoomRef.current
wrapper.style.zoom = String(next)
if (padWrapper && pw !== undefined) {
padWrapper.style.minWidth = `${pw * next + 2 * PDF_VIEWER_PADDING}px`
}
// Padding is outside the zoom subtree, so offset the anchor by it before scaling.
container.scrollLeft =
(container.scrollLeft + anchorX - PDF_VIEWER_PADDING) * ratio + PDF_VIEWER_PADDING - anchorX
container.scrollTop =
(container.scrollTop + anchorY - PDF_VIEWER_PADDING) * ratio + PDF_VIEWER_PADDING - anchorY
zoomRef.current = next
setDisplayZoom(next)
}, [])
const scrollToPage = (page: number) => {
const container = containerRef.current
if (container && zoomRef.current !== PDF_ZOOM_DEFAULT) {
applyZoomAt(PDF_ZOOM_DEFAULT, container.clientWidth / 2, container.clientHeight / 2)
}
const wrapper = pageRefs.current[page - 1]
if (wrapper && container) {
container.scrollTo({ top: wrapper.offsetTop - 16, behavior: 'smooth' })
}
}
useEffect(() => {
const container = containerRef.current
if (!container || pageCount === 0) return
const observer = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (entry.isIntersecting) {
const pageNum = Number((entry.target as HTMLElement).dataset.page)
if (pageNum) setCurrentPage(pageNum)
}
}
},
{ root: container, threshold: 0.5 }
)
for (const wrapper of pageRefs.current) {
if (wrapper) observer.observe(wrapper)
}
return () => observer.disconnect()
}, [pageCount])
useEffect(() => {
const container = containerRef.current
if (!container) return
return bindPreviewWheelZoom(container, (e) => {
const next = Math.min(
PDF_ZOOM_MAX,
Math.max(PDF_ZOOM_MIN, zoomRef.current * (1 - e.deltaY * 0.005))
)
const rect = container.getBoundingClientRect()
applyZoomAt(next, e.clientX - rect.left, e.clientY - rect.top)
})
}, [applyZoomAt])
return (
<div className='flex flex-1 flex-col overflow-hidden'>
{pageCount > 0 && !loadError && (
<PreviewToolbar
navigation={{
current: currentPage,
total: pageCount,
label: 'page',
onPrevious: () => {
const prev = Math.max(1, currentPage - 1)
setCurrentPage(prev)
scrollToPage(prev)
},
onNext: () => {
const next = Math.min(pageCount, currentPage + 1)
setCurrentPage(next)
scrollToPage(next)
},
}}
zoom={{
label: `${Math.round(displayZoom * 100)}%`,
canZoomOut: displayZoom > PDF_ZOOM_MIN,
canZoomIn: displayZoom < PDF_ZOOM_MAX,
onZoomOut: () => {
const c = containerRef.current
applyZoomAt(
Math.max(PDF_ZOOM_MIN, zoomRef.current / PDF_ZOOM_STEP),
c ? c.clientWidth / 2 : 0,
c ? c.clientHeight / 2 : 0
)
},
onZoomIn: () => {
const c = containerRef.current
applyZoomAt(
Math.min(PDF_ZOOM_MAX, zoomRef.current * PDF_ZOOM_STEP),
c ? c.clientWidth / 2 : 0,
c ? c.clientHeight / 2 : 0
)
},
}}
/>
)}
<div
ref={containerRef}
className='relative flex flex-1 items-start overflow-auto bg-[var(--surface-1)]'
>
{!isDocumentReady && PREVIEW_LOADING_OVERLAY}
<ReactPdfDocument
file={file}
onLoadSuccess={({ numPages }) => {
setPageCount(numPages)
setCurrentPage(1)
setLoadError(null)
setIsDocumentReady(true)
}}
onLoadError={(err) => {
logger.error('PDF load failed', { error: err.message })
setLoadError(err.message)
setIsDocumentReady(true)
}}
error={<PdfError error={loadError ?? 'Failed to load PDF'} />}
className='mx-auto'
>
<div
ref={paddingWrapperRef}
style={{
padding: PDF_VIEWER_PADDING,
minWidth:
pageWidth !== undefined
? `${pageWidth * displayZoom + 2 * PDF_VIEWER_PADDING}px`
: undefined,
}}
>
<div ref={pagesWrapperRef} style={{ width: pageWidth }}>
{Array.from({ length: pageCount }, (_, i) => (
<div
key={i}
ref={(el) => {
pageRefs.current[i] = el
}}
data-page={i + 1}
className='mb-4 overflow-clip rounded-md shadow-medium'
>
<ReactPdfPage
pageNumber={i + 1}
width={pageWidth}
className='!overflow-clip [&_.textLayer]:!overflow-clip'
renderTextLayer
renderAnnotationLayer={false}
aria-label={`${filename} page ${i + 1}`}
/>
</div>
))}
</div>
</div>
</ReactPdfDocument>
</div>
</div>
)
})
@@ -0,0 +1,73 @@
'use client'
import { memo, useEffect, useState } from 'react'
import { createLogger } from '@sim/logger'
import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace'
import { PptxSandboxHost } from '@/app/workspace/[workspaceId]/files/components/file-viewer/pptx-sandbox-host'
import {
PREVIEW_LOADING_OVERLAY,
PreviewError,
PreviewLoadingFrame,
resolvePreviewError,
} from '@/app/workspace/[workspaceId]/files/components/file-viewer/preview-shared'
import { useDocPreviewBinary } from '@/app/workspace/[workspaceId]/files/components/file-viewer/use-doc-preview-binary'
const logger = createLogger('PptxPreview')
function pptxCacheKey(fileId: string, dataUpdatedAt: number, byteLength: number): string {
return `${fileId}:${dataUpdatedAt}:${byteLength}`
}
export const PptxPreview = memo(function PptxPreview({
file,
workspaceId,
}: {
file: WorkspaceFileRecord
workspaceId: string
}) {
const preview = useDocPreviewBinary(workspaceId, file)
const fileData = preview.data
const cacheKey = pptxCacheKey(file.id, preview.dataUpdatedAt, fileData?.byteLength ?? 0)
const [hasRendered, setHasRendered] = useState(false)
const [renderError, setRenderError] = useState<string | null>(null)
useEffect(() => {
setRenderError(null)
setHasRendered(false)
}, [cacheKey])
function handleRenderStart() {
setRenderError(null)
}
function handleRenderComplete() {
setHasRendered(true)
}
function handleRenderError(message: string) {
logger.error('PPTX render failed', { error: message })
setRenderError(message || 'Failed to render presentation')
}
const error = resolvePreviewError(preview.error, renderError)
if (error) return <PreviewError label='presentation' error={error} />
if (!fileData) {
return <PreviewLoadingFrame className='h-full flex-1' tone='surface' />
}
return (
<div className='relative flex h-full min-h-0 flex-1 overflow-hidden bg-[var(--surface-1)]'>
<PptxSandboxHost
buffer={fileData}
requestId={cacheKey}
onRenderStart={handleRenderStart}
onRenderComplete={handleRenderComplete}
onRenderError={handleRenderError}
/>
{!hasRendered && PREVIEW_LOADING_OVERLAY}
</div>
)
})
@@ -0,0 +1,219 @@
'use client'
import { memo, useCallback, useEffect, useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
import { openSimPptxViewer, type SimPptxViewerHandle } from '@/lib/pptx-renderer/sim-pptx-viewer'
import { PreviewToolbar } from '@/app/workspace/[workspaceId]/files/components/file-viewer/preview-toolbar'
import { bindPreviewWheelZoom } from '@/app/workspace/[workspaceId]/files/components/file-viewer/preview-wheel-zoom'
const logger = createLogger('PptxSandboxHost')
const ZOOM_MIN = 25
const ZOOM_MAX = 400
const ZOOM_STEP = 20
const ZOOM_WHEEL_SENSITIVITY = 0.005
interface PptxSandboxHostProps {
buffer: ArrayBuffer
requestId: string
onRenderStart?: () => void
onRenderComplete?: () => void
onRenderError?: (error: string) => void
}
export const PptxSandboxHost = memo(function PptxSandboxHost({
buffer,
requestId,
onRenderStart,
onRenderComplete,
onRenderError,
}: PptxSandboxHostProps) {
const stageRef = useRef<HTMLDivElement>(null)
const scrollContainerRef = useRef<HTMLDivElement>(null)
const activeHandleRef = useRef<SimPptxViewerHandle | null>(null)
const activeContainerRef = useRef<HTMLDivElement | null>(null)
const renderSequenceRef = useRef(0)
const onRenderStartRef = useRef(onRenderStart)
const onRenderCompleteRef = useRef(onRenderComplete)
const onRenderErrorRef = useRef(onRenderError)
const zoomPercentRef = useRef(100)
onRenderStartRef.current = onRenderStart
onRenderCompleteRef.current = onRenderComplete
onRenderErrorRef.current = onRenderError
const [zoomPercent, setZoomPercent] = useState(100)
const [slideCount, setSlideCount] = useState(0)
const [currentSlide, setCurrentSlide] = useState(1)
useEffect(() => {
const stage = stageRef.current
if (!stage) return
const controller = new AbortController()
const sequence = ++renderSequenceRef.current
const nextContainer = document.createElement('div')
nextContainer.dataset.requestId = requestId
nextContainer.style.width = '100%'
nextContainer.style.visibility = 'hidden'
stage.appendChild(nextContainer)
onRenderStartRef.current?.()
async function render() {
try {
const handle = await openSimPptxViewer({
buffer,
container: nextContainer,
scrollContainer: scrollContainerRef.current ?? undefined,
signal: controller.signal,
onSlideChange: (index) => setCurrentSlide(index + 1),
onSlideError: (slideIndex, error) => {
logger.warn('PPTX slide render failed', {
slideIndex,
error: toError(error).message,
})
},
})
if (controller.signal.aborted || sequence !== renderSequenceRef.current) {
handle.destroy()
nextContainer.remove()
return
}
const previousHandle = activeHandleRef.current
const previousContainer = activeContainerRef.current
activeHandleRef.current = handle
activeContainerRef.current = nextContainer
setSlideCount(handle.viewer.slideCount)
setCurrentSlide(handle.viewer.currentSlideIndex + 1)
if (zoomPercentRef.current !== 100) {
await handle.viewer.setZoom(zoomPercentRef.current)
}
nextContainer.style.visibility = 'visible'
previousHandle?.destroy()
previousContainer?.remove()
onRenderCompleteRef.current?.()
} catch (error) {
nextContainer.remove()
// Aborted means a newer render superseded this one — don't report.
if (controller.signal.aborted) return
const message = toError(error).message || 'Failed to render presentation'
logger.warn('PPTX render failed', { error: message })
onRenderErrorRef.current?.(message)
}
}
render()
return () => {
controller.abort()
if (activeContainerRef.current !== nextContainer) {
nextContainer.remove()
}
}
}, [buffer, requestId])
useEffect(() => {
return () => {
renderSequenceRef.current += 1
activeHandleRef.current?.destroy()
activeContainerRef.current?.remove()
}
}, [])
const applyZoomAt = useCallback(async (nextZoom: number, anchorX: number, anchorY: number) => {
const container = scrollContainerRef.current
if (!container) return
const clampedZoom = Math.round(Math.min(Math.max(nextZoom, ZOOM_MIN), ZOOM_MAX))
const ratio = clampedZoom / zoomPercentRef.current
const style = window.getComputedStyle(container)
const paddingLeft = Number.parseFloat(style.paddingLeft) || 0
const paddingTop = Number.parseFloat(style.paddingTop) || 0
const previousScrollLeft = container.scrollLeft
const previousScrollTop = container.scrollTop
zoomPercentRef.current = clampedZoom
setZoomPercent(clampedZoom)
await activeHandleRef.current?.viewer.setZoom(clampedZoom)
container.scrollLeft =
(previousScrollLeft + anchorX - paddingLeft) * ratio + paddingLeft - anchorX
container.scrollTop = (previousScrollTop + anchorY - paddingTop) * ratio + paddingTop - anchorY
}, [])
const applyZoomFromCenter = useCallback(
(nextZoom: number): Promise<void> => {
const container = scrollContainerRef.current
return applyZoomAt(
nextZoom,
container ? container.clientWidth / 2 : 0,
container ? container.clientHeight / 2 : 0
)
},
[applyZoomAt]
)
useEffect(() => {
const container = scrollContainerRef.current
if (!container) return
return bindPreviewWheelZoom(container, (event) => {
const rect = container.getBoundingClientRect()
void applyZoomAt(
zoomPercentRef.current * (1 - event.deltaY * ZOOM_WHEEL_SENSITIVITY),
event.clientX - rect.left,
event.clientY - rect.top
)
})
}, [applyZoomAt])
async function goToSlide(slideNumber: number) {
if (!activeHandleRef.current || slideCount <= 0) return
const clampedSlide = Math.min(Math.max(slideNumber, 1), slideCount)
if (zoomPercentRef.current !== 100) {
await applyZoomFromCenter(100)
}
setCurrentSlide(clampedSlide)
await activeHandleRef.current.viewer.goToSlide(clampedSlide - 1)
}
return (
<div className='flex h-full min-h-0 w-full flex-1 flex-col overflow-hidden bg-[var(--surface-1)]'>
<PreviewToolbar
navigation={{
current: currentSlide,
total: slideCount,
label: 'slide',
canPrevious: slideCount > 0 && currentSlide > 1,
canNext: slideCount > 0 && currentSlide < slideCount,
onPrevious: () => goToSlide(currentSlide - 1),
onNext: () => goToSlide(currentSlide + 1),
}}
zoom={{
label: `${zoomPercent}%`,
canZoomOut: zoomPercent > ZOOM_MIN,
canZoomIn: zoomPercent < ZOOM_MAX,
onReset: () => {
void applyZoomFromCenter(100)
},
onZoomOut: () => {
void applyZoomFromCenter(zoomPercent - ZOOM_STEP)
},
onZoomIn: () => {
void applyZoomFromCenter(zoomPercent + ZOOM_STEP)
},
}}
/>
<div
ref={scrollContainerRef}
className='relative flex min-h-0 flex-1 items-start overflow-auto bg-[var(--surface-1)] p-6'
>
<div ref={stageRef} className='mx-auto w-full max-w-[960px]' />
</div>
</div>
)
})
@@ -0,0 +1,343 @@
'use client'
import { memo, useEffect, useMemo, useRef, useState } from 'react'
import '@sim/emcn/components/code/code.css'
import { CSV_PREVIEW_MAX_ROWS } from '@/lib/api/contracts/workspace-file-table'
import { getFileExtension } from '@/lib/uploads/utils/file-utils'
import { type CsvImportFileDescriptor, useCsvTruncationImport } from './csv-import'
import { DataTable } from './data-table'
import { MermaidDiagram } from './mermaid-diagram'
import { ZoomablePreview } from './zoomable-preview'
type PreviewType = 'markdown' | 'html' | 'csv' | 'svg' | 'mermaid' | null
const PREVIEWABLE_MIME_TYPES: Record<string, PreviewType> = {
'text/markdown': 'markdown',
'text/html': 'html',
'text/csv': 'csv',
'image/svg+xml': 'svg',
'text/x-mermaid': 'mermaid',
}
const PREVIEWABLE_EXTENSIONS: Record<string, PreviewType> = {
md: 'markdown',
html: 'html',
htm: 'html',
csv: 'csv',
svg: 'svg',
mmd: 'mermaid',
}
/** All extensions that have a rich preview renderer. */
export const RICH_PREVIEWABLE_EXTENSIONS = new Set(Object.keys(PREVIEWABLE_EXTENSIONS))
export function resolvePreviewType(mimeType: string | null, filename: string): PreviewType {
if (mimeType && PREVIEWABLE_MIME_TYPES[mimeType]) return PREVIEWABLE_MIME_TYPES[mimeType]
const ext = getFileExtension(filename)
return PREVIEWABLE_EXTENSIONS[ext] ?? null
}
interface PreviewPanelProps {
content: string
mimeType: string | null
filename: string
workspaceId: string
fileKey: string
isStreaming?: boolean
/**
* Read-only surface (e.g. the public share page) — disables interactive
* affordances such as the CSV "Import as a table" action, which needs an
* authenticated workspace import.
*/
readOnly?: boolean
}
export const PreviewPanel = memo(function PreviewPanel({
content,
mimeType,
filename,
workspaceId,
fileKey,
isStreaming,
readOnly,
}: PreviewPanelProps) {
const previewType = resolvePreviewType(mimeType, filename)
if (previewType === 'html') return <HtmlPreview content={content} />
if (previewType === 'csv')
return (
<CsvPreview
content={content}
workspaceId={workspaceId}
file={{ key: fileKey, name: filename }}
readOnly={readOnly}
/>
)
if (previewType === 'svg') return <SvgPreview content={content} />
if (previewType === 'mermaid')
return <MermaidFilePreview content={content} isStreaming={isStreaming} />
return null
})
const HTML_PREVIEW_BASE_URL = 'about:srcdoc'
const HTML_PREVIEW_CSP = [
"default-src 'none'",
"script-src 'unsafe-inline'",
"style-src 'unsafe-inline'",
'img-src data: blob:',
'font-src data:',
'media-src data: blob:',
"connect-src 'none'",
"form-action 'none'",
"frame-src 'none'",
"child-src 'none'",
"object-src 'none'",
].join('; ')
const HTML_PREVIEW_BOOTSTRAP = `<script>
(() => {
const allowHref = (href) => href.startsWith('#') || /^\\s*javascript:/i.test(href)
document.addEventListener(
'click',
(event) => {
if (!(event.target instanceof Element)) return
const anchor = event.target.closest('a[href]')
if (!(anchor instanceof HTMLAnchorElement)) return
const href = anchor.getAttribute('href') || ''
if (allowHref(href)) return
event.preventDefault()
},
true
)
document.addEventListener(
'submit',
(event) => {
event.preventDefault()
},
true
)
})()
</script>`
function buildHtmlPreviewDocument(content: string): string {
const headInjection = [
'<meta charset="utf-8">',
`<base href="${HTML_PREVIEW_BASE_URL}">`,
`<meta http-equiv="Content-Security-Policy" content="${HTML_PREVIEW_CSP}">`,
HTML_PREVIEW_BOOTSTRAP,
].join('')
if (/<head[\s>]/i.test(content)) {
return content.replace(/<head(\s[^>]*)?>/i, (match) => `${match}${headInjection}`)
}
if (/<html[\s>]/i.test(content)) {
return content.replace(/<html(\s[^>]*)?>/i, (match) => `${match}<head>${headInjection}</head>`)
}
return `<!DOCTYPE html><html><head>${headInjection}</head><body>${content}</body></html>`
}
const HtmlPreview = memo(function HtmlPreview({ content }: { content: string }) {
const wrappedContent = buildHtmlPreviewDocument(content)
const containerRef = useRef<HTMLDivElement>(null)
const [isRenderable, setIsRenderable] = useState(false)
const [resumeNonce, setResumeNonce] = useState(0)
const pageWasHiddenRef = useRef(false)
useEffect(() => {
const container = containerRef.current
if (!container) return
const updateRenderability = (width: number, height: number) => {
setIsRenderable(width > 0 && height > 0)
}
const initialRect = container.getBoundingClientRect()
updateRenderability(initialRect.width, initialRect.height)
const observer = new ResizeObserver((entries) => {
const entry = entries[0]
if (!entry) return
updateRenderability(entry.contentRect.width, entry.contentRect.height)
})
observer.observe(container)
return () => observer.disconnect()
}, [])
useEffect(() => {
const handleVisibilityChange = () => {
if (document.visibilityState === 'hidden') {
pageWasHiddenRef.current = true
return
}
if (document.visibilityState === 'visible' && pageWasHiddenRef.current) {
pageWasHiddenRef.current = false
setResumeNonce((nonce) => nonce + 1)
}
}
const handlePageShow = (event: PageTransitionEvent) => {
if (event.persisted) {
setResumeNonce((nonce) => nonce + 1)
}
}
document.addEventListener('visibilitychange', handleVisibilityChange)
window.addEventListener('pageshow', handlePageShow)
return () => {
document.removeEventListener('visibilitychange', handleVisibilityChange)
window.removeEventListener('pageshow', handlePageShow)
}
}, [])
return (
<div ref={containerRef} className='h-full overflow-hidden'>
{isRenderable && (
<iframe
key={resumeNonce}
srcDoc={wrappedContent}
sandbox='allow-scripts'
referrerPolicy='no-referrer'
title='HTML Preview'
className='h-full w-full border-0 bg-[var(--surface-2)]'
/>
)}
</div>
)
})
function SvgPreview({ content }: { content: string }) {
const [blobUrl, setBlobUrl] = useState('')
useEffect(() => {
const url = URL.createObjectURL(new Blob([content], { type: 'image/svg+xml' }))
setBlobUrl(url)
return () => URL.revokeObjectURL(url)
}, [content])
return (
<ZoomablePreview className='h-full' contentClassName='h-full w-full'>
{blobUrl && (
<img
src={blobUrl}
alt='SVG preview'
className='max-h-full max-w-full select-none object-contain'
draggable={false}
/>
)}
</ZoomablePreview>
)
}
function MermaidFilePreview({ content, isStreaming }: { content: string; isStreaming?: boolean }) {
return (
<div className='h-full overflow-auto p-6'>
<MermaidDiagram
definition={content}
isStreaming={isStreaming}
zoomable
zoomClassName='h-full rounded-lg'
/>
</div>
)
}
const CsvPreview = memo(function CsvPreview({
content,
workspaceId,
file,
readOnly,
}: {
content: string
workspaceId: string
file: CsvImportFileDescriptor
readOnly?: boolean
}) {
const { headers, rows, truncated } = useMemo(() => parseCsv(content), [content])
useCsvTruncationImport(workspaceId, file, truncated, readOnly)
if (headers.length === 0) {
return (
<div className='flex h-full items-center justify-center p-6'>
<p className='text-[13px] text-[var(--text-muted)]'>No data to display</p>
</div>
)
}
return (
<div className='h-full overflow-auto p-6'>
<DataTable headers={headers} rows={rows} />
</div>
)
})
/**
* Parses CSV text for the inline preview, capping at {@link CSV_PREVIEW_MAX_ROWS} rows so a
* small-but-many-rows file doesn't render thousands of `<tr>`s. Slices before parsing so only
* the capped rows are processed; `truncated` drives the "Import as a table" footer.
*/
function parseCsv(text: string): { headers: string[]; rows: string[][]; truncated: boolean } {
const lines = text.split('\n').filter((line) => line.trim().length > 0)
if (lines.length === 0) return { headers: [], rows: [], truncated: false }
const delimiter = detectDelimiter(lines[0])
const headers = parseCsvLine(lines[0], delimiter)
const dataLines = lines.slice(1)
const truncated = dataLines.length > CSV_PREVIEW_MAX_ROWS
const rows = dataLines.slice(0, CSV_PREVIEW_MAX_ROWS).map((line) => parseCsvLine(line, delimiter))
return { headers, rows, truncated }
}
function detectDelimiter(line: string): string {
const commaCount = (line.match(/,/g) || []).length
const tabCount = (line.match(/\t/g) || []).length
const semiCount = (line.match(/;/g) || []).length
if (tabCount > commaCount && tabCount > semiCount) return '\t'
if (semiCount > commaCount) return ';'
return ','
}
function parseCsvLine(line: string, delimiter: string): string[] {
const fields: string[] = []
let current = ''
let inQuotes = false
for (let i = 0; i < line.length; i++) {
const char = line[i]
if (inQuotes) {
if (char === '"') {
if (i + 1 < line.length && line[i + 1] === '"') {
current += '"'
i++
} else {
inQuotes = false
}
} else {
current += char
}
} else {
if (char === '"') {
inQuotes = true
} else if (char === delimiter) {
fields.push(current.trim())
current = ''
} else {
current += char
}
}
}
fields.push(current.trim())
return fields
}
@@ -0,0 +1,132 @@
'use client'
import { Component, type ErrorInfo, type ReactNode } from 'react'
import { cn } from '@sim/emcn'
import { Loader } from '@sim/emcn/icons'
import { createLogger } from '@sim/logger'
const logger = createLogger('FilePreview')
export function PreviewError({ label, error }: { label: string; error: string }) {
return (
<div className='flex flex-1 flex-col items-center justify-center gap-[8px]'>
<p className='font-medium text-[14px] text-[var(--text-primary)]'>
Failed to preview {label}
</p>
<p className='text-[13px] text-[var(--text-muted)]'>{error}</p>
</div>
)
}
interface PreviewErrorBoundaryProps {
/** Format label shown in the fallback, e.g. "PDF". */
label: string
children: ReactNode
}
interface PreviewErrorBoundaryState {
hasError: boolean
error?: Error
}
/**
* Error boundary for preview renderers. Catches render-time crashes (including
* a preview module whose dynamic import rejected) and degrades to the standard
* PreviewError fallback instead of unwinding to the route-level error boundary
* and replacing the whole workspace view.
*
* Callers must `key` this boundary by the identity of the rendered content
* (e.g. file id + data version) — the error state resets only via remount, so
* keying the child alone would leave a tripped boundary stuck on the fallback.
*/
export class PreviewErrorBoundary extends Component<
PreviewErrorBoundaryProps,
PreviewErrorBoundaryState
> {
public state: PreviewErrorBoundaryState = {
hasError: false,
}
public static getDerivedStateFromError(error: Error): PreviewErrorBoundaryState {
return { hasError: true, error }
}
public componentDidCatch(error: Error, errorInfo: ErrorInfo) {
logger.error('Preview crashed', {
label: this.props.label,
error: error.message,
componentStack: errorInfo.componentStack,
})
}
public render() {
if (this.state.hasError) {
return (
<PreviewError
label={this.props.label}
error={this.state.error?.message ?? 'An unexpected error occurred'}
/>
)
}
return this.props.children
}
}
export function resolvePreviewError(
fetchError: Error | null,
renderError: string | null
): string | null {
// A doc whose compiled artifact never appeared (the binary query exhausted its
// "still generating" polls) — usually a source that failed to compile or a
// legacy file with no artifact. Give a clear, actionable message instead of a
// generic fetch error.
if (fetchError?.name === 'DocNotReadyError') {
return "Couldn't generate this document preview. Re-run the file generation to rebuild it."
}
if (fetchError) return fetchError.message
return renderError
}
/** Canonical content-area loading spinner, matching the rest of the app. */
const PREVIEW_LOADING_SPINNER = (
<Loader className='size-[20px] text-[var(--text-secondary)]' animate />
)
/**
* Canonical loading overlay for previews that render into a `--surface-1`
* canvas. Absolutely covers the canvas (with `z-10` so it paints above
* in-flow render targets) with a centered spinner until the preview is ready.
*/
export const PREVIEW_LOADING_OVERLAY = (
<div className='absolute inset-0 z-10 flex items-center justify-center bg-[var(--surface-1)]'>
{PREVIEW_LOADING_SPINNER}
</div>
)
interface PreviewLoadingFrameProps {
/** Layout/sizing-only classes for the in-flow frame (e.g. `h-full`, `flex-1`). */
className?: string
/** Background token matching the loaded sibling's canvas. Defaults to `--bg`. */
tone?: 'bg' | 'surface'
}
/**
* Canonical in-flow loading frame with a centered spinner, shown while a
* preview is fetching or rendering. The `tone` must match the background of
* the loaded state it is standing in for, so mount completion does not flash
* a different token.
*/
export function PreviewLoadingFrame({ className, tone = 'bg' }: PreviewLoadingFrameProps) {
return (
<div
className={cn(
'flex items-center justify-center',
tone === 'surface' ? 'bg-[var(--surface-1)]' : 'bg-[var(--bg)]',
className
)}
>
{PREVIEW_LOADING_SPINNER}
</div>
)
}
@@ -0,0 +1,95 @@
import { Chip, cn } from '@sim/emcn'
import { ChevronLeft, ChevronRight, ZoomIn, ZoomOut } from 'lucide-react'
interface PreviewNavigationControls {
current: number
total: number
label: string
onPrevious: () => void
onNext: () => void
canPrevious?: boolean
canNext?: boolean
}
interface PreviewZoomControls {
label: string
onZoomOut: () => void
onZoomIn: () => void
canZoomOut?: boolean
canZoomIn?: boolean
onReset?: () => void
}
interface PreviewToolbarProps {
navigation?: PreviewNavigationControls
zoom?: PreviewZoomControls
className?: string
}
export function PreviewToolbar({ navigation, zoom, className }: PreviewToolbarProps) {
return (
<div
className={cn(
'flex shrink-0 items-center justify-between border-[var(--border)] border-b bg-[var(--surface-1)] px-2 py-1',
className
)}
>
<div className='flex items-center'>
{navigation && <PreviewNavigationControls {...navigation} />}
</div>
<div className='flex items-center'>{zoom && <PreviewZoomControls {...zoom} />}</div>
</div>
)
}
function PreviewNavigationControls({
current,
total,
label,
onPrevious,
onNext,
canPrevious = current > 1,
canNext = current < total,
}: PreviewNavigationControls) {
return (
<>
<Chip
leftIcon={ChevronLeft}
onClick={onPrevious}
disabled={!canPrevious}
aria-label={`Previous ${label}`}
/>
<span className='min-w-[4.5rem] text-center text-[var(--text-body)] text-sm'>
{total > 0 ? `${current} / ${total}` : '0 / 0'}
</span>
<Chip
leftIcon={ChevronRight}
onClick={onNext}
disabled={!canNext}
aria-label={`Next ${label}`}
/>
</>
)
}
function PreviewZoomControls({
label,
onZoomOut,
onZoomIn,
canZoomOut = true,
canZoomIn = true,
onReset,
}: PreviewZoomControls) {
return (
<>
{onReset && (
<Chip onClick={onReset} aria-label='Reset zoom'>
Reset
</Chip>
)}
<Chip leftIcon={ZoomOut} onClick={onZoomOut} disabled={!canZoomOut} aria-label='Zoom out' />
<span className='min-w-[3.25rem] text-center text-[var(--text-body)] text-sm'>{label}</span>
<Chip leftIcon={ZoomIn} onClick={onZoomIn} disabled={!canZoomIn} aria-label='Zoom in' />
</>
)
}
@@ -0,0 +1,46 @@
interface BindPreviewWheelZoomOptions {
/**
* Called for non-modifier wheel events (two-finger scroll). When provided,
* the container's native scrolling is suppressed and the consumer drives
* pan via `deltaX` / `deltaY`. Use for transform-based viewers (e.g. image)
* where the content is not a real scroll container.
*/
onPan?: (event: WheelEvent) => void
}
/**
* Bind browser pinch/ctrl-wheel zoom and horizontal wheel gestures for preview
* scroll containers. Trackpad pinch fires `wheel` with `ctrlKey=true`; without
* a non-passive native listener the browser falls back to page zoom. `metaKey`
* is also accepted so Cmd+scroll zooms on macOS, matching Figma/tldraw/Excalidraw.
*/
export function bindPreviewWheelZoom(
container: HTMLElement,
onZoom: (event: WheelEvent) => void,
options: BindPreviewWheelZoomOptions = {}
): () => void {
const { onPan } = options
const onWheel = (event: WheelEvent) => {
if (event.ctrlKey || event.metaKey) {
event.preventDefault()
onZoom(event)
return
}
if (onPan) {
event.preventDefault()
onPan(event)
return
}
const horizontalDelta = event.deltaX !== 0 ? event.deltaX : event.shiftKey ? event.deltaY : 0
if (horizontalDelta === 0 || container.scrollWidth <= container.clientWidth) return
event.preventDefault()
container.scrollLeft += horizontalDelta
}
container.addEventListener('wheel', onWheel, { capture: true, passive: false })
return () => container.removeEventListener('wheel', onWheel, { capture: true })
}
@@ -0,0 +1,94 @@
import { Extension } from '@tiptap/core'
import type { EditorState, Transaction } from '@tiptap/pm/state'
import { TextSelection } from '@tiptap/pm/state'
/** The position range of the depth-1 block containing the cursor, or null at the document root. */
function currentTopLevelBlock(state: EditorState): { from: number; to: number } | null {
const { $from } = state.selection
if ($from.depth === 0) return null
return { from: $from.before(1), to: $from.after(1) }
}
/**
* Swaps the current top-level block with its neighbour in `direction`, keeping the caret on the moved
* block. Adjacent top-level blocks share a boundary position (no separator token between them), so the
* move is a single `replaceWith` of the two-block span with the pair reordered. No-ops (returns false)
* at the matching document edge or when the neighbour isn't a top-level block. `newBefore` is the moved
* block's new `before(1)` position; adding the caret's original offset (`selection.from - from`, also
* measured from `before(1)`) re-anchors the caret at the same spot within the block.
*/
function moveBlock(
state: EditorState,
dispatch: ((tr: Transaction) => void) | undefined,
direction: 'up' | 'down'
): boolean {
const block = currentTopLevelBlock(state)
if (!block) return false
const { from, to } = block
const up = direction === 'up'
if (up ? from === 0 : to >= state.doc.content.size) return false
const $neighbour = state.doc.resolve(up ? from - 1 : to + 1)
if ($neighbour.depth === 0) return false
if (!dispatch) return true
const spanFrom = up ? $neighbour.before(1) : from
const spanTo = up ? to : $neighbour.after(1)
const moving = state.doc.slice(from, to).content
const neighbour = up
? state.doc.slice(spanFrom, from).content
: state.doc.slice(to, spanTo).content
const tr = state.tr.replaceWith(
spanFrom,
spanTo,
up ? moving.append(neighbour) : neighbour.append(moving)
)
const newBefore = up ? spanFrom : spanFrom + neighbour.size
const offset = state.selection.from - from
tr.setSelection(
TextSelection.near(tr.doc.resolve(Math.min(newBefore + offset, newBefore + moving.size)))
)
dispatch(tr.scrollIntoView())
return true
}
declare module '@tiptap/core' {
interface Commands<ReturnType> {
blockMover: {
/** Move the current top-level block up one position, carrying the caret. */
moveBlockUp: () => ReturnType
/** Move the current top-level block down one position, carrying the caret. */
moveBlockDown: () => ReturnType
}
}
}
/**
* Reorders the current top-level block with `Mod-Shift-ArrowUp`/`ArrowDown` — the standard
* keyboard block-move affordance (Notion/Obsidian). Pure UI interaction: no schema change, and the
* caret rides along with the block. A no-op (returns false, falling through) at the document edges.
*/
export const BlockMover = Extension.create({
name: 'blockMover',
addCommands() {
return {
moveBlockUp:
() =>
({ state, dispatch }) =>
moveBlock(state, dispatch, 'up'),
moveBlockDown:
() =>
({ state, dispatch }) =>
moveBlock(state, dispatch, 'down'),
}
},
addKeyboardShortcuts() {
return {
'Mod-Shift-ArrowUp': ({ editor }) => editor.commands.moveBlockUp(),
'Mod-Shift-ArrowDown': ({ editor }) => editor.commands.moveBlockDown(),
}
},
})
@@ -0,0 +1,264 @@
import { useEffect, useState } from 'react'
import {
chipVariants,
cn,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
useCopyToClipboard,
} from '@sim/emcn'
import type { JSONContent } from '@tiptap/core'
import { CodeBlock } from '@tiptap/extension-code-block'
import type { ReactNodeViewProps } from '@tiptap/react'
import { NodeViewContent, NodeViewWrapper, ReactNodeViewRenderer } from '@tiptap/react'
import { Check, ChevronDown, Code, Copy, Eye, WrapText } from 'lucide-react'
import { looksLikeMermaid, MermaidDiagram } from '../mermaid-diagram'
import { detectLanguage } from './detect-language'
import { useEditorEditable } from './use-editor-editable'
const PLAIN = 'plain'
const MERMAID = 'mermaid'
/** Languages the Prism highlighter has registered (see {@link CodeBlockHighlight}). Every non-plain
* value MUST have a grammar registered in {@link CodeBlockHighlight} — enforced by a unit test. */
export const LANGUAGE_OPTIONS = [
{ value: PLAIN, label: 'Plain text' },
{ value: 'bash', label: 'Bash' },
{ value: 'c', label: 'C' },
{ value: 'cpp', label: 'C++' },
{ value: 'csharp', label: 'C#' },
{ value: 'css', label: 'CSS' },
{ value: 'go', label: 'Go' },
{ value: 'java', label: 'Java' },
{ value: 'javascript', label: 'JavaScript' },
{ value: 'json', label: 'JSON' },
{ value: 'markup', label: 'HTML' },
{ value: 'php', label: 'PHP' },
{ value: 'python', label: 'Python' },
{ value: 'ruby', label: 'Ruby' },
{ value: 'rust', label: 'Rust' },
{ value: 'sql', label: 'SQL' },
{ value: 'typescript', label: 'TypeScript' },
{ value: 'yaml', label: 'YAML' },
] as const
const CONTROL_CLASS =
'flex size-[24px] items-center justify-center rounded-lg text-[var(--text-icon)] outline-none transition-colors hover-hover:bg-[var(--surface-hover)] hover-hover:text-[var(--text-body)] focus-visible:bg-[var(--surface-hover)] [&_svg]:size-[14px]'
/**
* Code block view with hover controls (language picker, line-wrap, copy). When the block holds
* Mermaid — tagged ```mermaid or {@link looksLikeMermaid auto-detected} — it renders as a diagram
* whenever the cursor is outside it (and always in read-only), and as editable source while the
* cursor is inside, re-rendering on blur (the Linear/GitHub model). The source `<pre>` stays mounted
* (hidden behind the diagram) so ProseMirror keeps managing its contentDOM, and the node remains an
* ordinary code block, so markdown round-trips unchanged.
*/
function CodeBlockView({ node, updateAttributes, editor, getPos }: ReactNodeViewProps) {
const [wrap, setWrap] = useState(false)
const [menuOpen, setMenuOpen] = useState(false)
const [editingInline, setEditingInline] = useState(false)
const [peekSource, setPeekSource] = useState(false)
const { copied, copy } = useCopyToClipboard({ resetMs: 1500 })
const editable = useEditorEditable(editor)
const explicitLanguage = node.attrs.language as string | null
const text = node.textContent
const isMermaid = explicitLanguage === MERMAID || (!explicitLanguage && looksLikeMermaid(text))
// Editable Mermaid shows source while the caret is focused inside the block and re-renders the
// diagram on blur (the Linear/GitHub model). The Show source / Show diagram control drives this by
// focusing into / blurring the block; read-only uses {@link peekSource} since there is no caret.
useEffect(() => {
if (!isMermaid || !editable) {
setEditingInline(false)
return
}
const sync = () => {
const pos = typeof getPos === 'function' ? getPos() : null
if (typeof pos !== 'number') {
setEditingInline(false)
return
}
const size = editor.state.doc.nodeAt(pos)?.nodeSize ?? 0
const { from } = editor.state.selection
setEditingInline(editor.isFocused && from > pos && from < pos + size)
}
sync()
editor.on('selectionUpdate', sync)
editor.on('focus', sync)
editor.on('blur', sync)
return () => {
editor.off('selectionUpdate', sync)
editor.off('focus', sync)
editor.off('blur', sync)
}
}, [editor, getPos, isMermaid, editable])
const showSource = editable ? editingInline : peekSource
const showDiagram = isMermaid && text.trim().length > 0 && !showSource
// Skip language detection on the mermaid path — the picker/label never render there.
const language = explicitLanguage ?? (isMermaid ? null : detectLanguage(text)) ?? PLAIN
const label =
LANGUAGE_OPTIONS.find((option) => option.value === language)?.label ??
explicitLanguage ??
'Plain text'
const toggleSource = () => {
if (!editable) {
setPeekSource((value) => !value)
return
}
const pos = typeof getPos === 'function' ? getPos() : null
if (typeof pos !== 'number') return
if (editingInline) {
// Back to the diagram: select the whole node (reliable, and shows the same ring) rather than
// relying on a blur event to fire.
editor.commands.setNodeSelection(pos)
return
}
editor
.chain()
.focus()
.setTextSelection(pos + 1)
.run()
}
return (
<NodeViewWrapper className='group relative'>
<div
className={cn(
'absolute top-1.5 right-2 z-10 flex items-center gap-0.5 opacity-0 transition-opacity group-focus-within:opacity-100 group-hover:opacity-100',
menuOpen && 'opacity-100'
)}
contentEditable={false}
>
{isMermaid && (
<button
type='button'
aria-label={showSource ? 'Show diagram' : 'Show source'}
onMouseDown={(event) => event.preventDefault()}
onClick={toggleSource}
className={CONTROL_CLASS}
>
{showSource ? <Eye /> : <Code />}
</button>
)}
{!isMermaid &&
(editable ? (
// Editable: a language picker. Read-only: a static label — selecting a language calls
// updateAttributes, which would mutate a doc that must not change.
<DropdownMenu onOpenChange={setMenuOpen}>
<DropdownMenuTrigger asChild>
<button
type='button'
aria-label='Code language'
className={cn(
chipVariants({ variant: 'default', flush: true }),
'h-[24px] gap-1 px-1.5 text-[var(--text-muted)] data-[state=open]:bg-[var(--surface-active)] data-[state=open]:text-[var(--text-body)]'
)}
>
{label}
<ChevronDown className='size-[14px] text-[var(--text-icon)]' />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align='end'>
{LANGUAGE_OPTIONS.map((option) => (
<DropdownMenuItem
key={option.value}
onSelect={() =>
updateAttributes({ language: option.value === PLAIN ? null : option.value })
}
>
{option.label}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
) : (
<span className='flex h-[24px] items-center px-1.5 text-[var(--text-muted)] text-caption'>
{label}
</span>
))}
{!isMermaid && editable && (
<button
type='button'
aria-label='Toggle line wrap'
aria-pressed={wrap}
onMouseDown={(event) => event.preventDefault()}
onClick={() => setWrap((value) => !value)}
className={cn(
CONTROL_CLASS,
wrap && 'bg-[var(--surface-active)] text-[var(--text-body)]'
)}
>
<WrapText />
</button>
)}
<button
type='button'
aria-label='Copy code'
onMouseDown={(event) => event.preventDefault()}
onClick={() => copy(text)}
className={CONTROL_CLASS}
>
{copied ? <Check /> : <Copy />}
</button>
</div>
<pre className={cn('code-editor-theme pr-20', showDiagram && 'hidden')} data-wrap={wrap}>
<NodeViewContent<'code'> as='code' />
</pre>
{showDiagram && (
// Clicking the diagram selects the whole node (same selection ring as an image/code block)
// instead of dropping a caret inside — preventDefault stops ProseMirror placing the caret,
// which would otherwise flip to source. Editing is an explicit Show source / blur action.
<div
contentEditable={false}
onMouseDown={(event) => {
event.preventDefault()
const pos = typeof getPos === 'function' ? getPos() : null
if (typeof pos === 'number') editor.commands.setNodeSelection(pos)
}}
>
<MermaidDiagram definition={text} className='mermaid-diagram-frame' />
</div>
)}
</NodeViewWrapper>
)
}
function codeBlockText(node: JSONContent): string {
return (node.content ?? []).map((child) => child.text ?? '').join('')
}
/** Fence sized to one backtick longer than the longest run inside the code (CommonMark rule). */
function fenceFor(text: string): string {
const longestRun = Math.max(0, ...[...text.matchAll(/`+/g)].map((match) => match[0].length))
return '`'.repeat(Math.max(3, longestRun + 1))
}
/**
* Code block whose markdown serializer sizes the fence to the interior backtick runs, so a code
* block that itself contains a ``` line round-trips instead of shattering. Shared by the test
* (plain) and live ({@link CodeBlockWithLanguage}) paths.
*/
export const MarkdownCodeBlock = CodeBlock.extend({
renderMarkdown: (node: JSONContent) => {
const language = typeof node.attrs?.language === 'string' ? node.attrs.language : ''
const text = codeBlockText(node)
const fence = fenceFor(text)
return `${fence}${language}\n${text}\n${fence}`
},
})
/**
* Code block with hover-revealed controls (language picker, line-wrap toggle, copy). The
* `language` attribute drives {@link CodeBlockHighlight}'s Prism highlighting and serializes to
* the ```lang fence on save; wrap is a view-only preference.
*/
export const CodeBlockWithLanguage = MarkdownCodeBlock.extend({
addNodeView() {
return ReactNodeViewRenderer(CodeBlockView)
},
})
@@ -0,0 +1,81 @@
/**
* @vitest-environment jsdom
*/
import { Editor } from '@tiptap/core'
import { afterEach, describe, expect, it } from 'vitest'
import { buildDecorations, changeTouchesCodeBlock } from './code-highlight'
import { createMarkdownContentExtensions } from './extensions'
let editor: Editor | null = null
/** Position just inside the first code block in the current editor doc. */
function codeBlockPos(ed: Editor): number {
let pos = -1
ed.state.doc.descendants((node, p) => {
if (pos === -1 && node.type.name === 'codeBlock') pos = p
return pos === -1
})
if (pos === -1) throw new Error('no code block')
return pos
}
function decorationClassesFor(markdown: string): string[] {
editor = new Editor({ extensions: createMarkdownContentExtensions() })
editor.commands.setContent(markdown, { contentType: 'markdown' })
const decorations = buildDecorations(editor.state.doc).find()
editor.destroy()
editor = null
return decorations.map(
(decoration) =>
(decoration as unknown as { type: { attrs: { class: string } } }).type.attrs.class
)
}
afterEach(() => {
editor?.destroy()
editor = null
})
describe('code block syntax highlighting', () => {
it('emits Prism token decorations for a known language', () => {
const classes = decorationClassesFor('```js\nconst x = 1\n```')
expect(classes.length).toBeGreaterThan(0)
expect(classes.every((c) => c.startsWith('token'))).toBe(true)
expect(classes.some((c) => c.includes('keyword'))).toBe(true)
})
it('does not decorate plain prose', () => {
expect(decorationClassesFor('just some text')).toHaveLength(0)
})
it('does not decorate an unregistered language', () => {
expect(decorationClassesFor('```unregistered-lang\n+++ foo\n```')).toHaveLength(0)
})
})
describe('changeTouchesCodeBlock (incremental re-tokenization gate)', () => {
function mount(markdown: string): Editor {
editor = new Editor({ extensions: createMarkdownContentExtensions() })
editor.commands.setContent(markdown, { contentType: 'markdown' })
return editor
}
it('is false when an edit lands only in prose (decorations are mapped, not rebuilt)', () => {
const ed = mount('intro text\n\n```js\nconst x = 1\n```')
const tr = ed.state.tr.insertText('Z', 1) // inside the leading paragraph
expect(changeTouchesCodeBlock(tr, tr.doc)).toBe(false)
})
it('is true when an edit lands inside a code block (forces a re-tokenize)', () => {
const ed = mount('intro\n\n```js\nconst x = 1\n```')
const tr = ed.state.tr.insertText('y', codeBlockPos(ed) + 1)
expect(changeTouchesCodeBlock(tr, tr.doc)).toBe(true)
})
it('is true when the code block language changes via setNodeMarkup', () => {
const ed = mount('```js\nconst x = 1\n```')
const pos = codeBlockPos(ed)
const tr = ed.state.tr.setNodeMarkup(pos, undefined, { language: 'python' })
expect(changeTouchesCodeBlock(tr, tr.doc)).toBe(true)
})
})
@@ -0,0 +1,133 @@
import { Extension } from '@tiptap/core'
import type { Node as ProseMirrorNode } from '@tiptap/pm/model'
import { Plugin, PluginKey, type Transaction } from '@tiptap/pm/state'
import { Decoration, DecorationSet } from '@tiptap/pm/view'
import Prism, { type Token, type TokenStream } from 'prismjs'
import 'prismjs/components/prism-bash'
import 'prismjs/components/prism-css'
import 'prismjs/components/prism-markup'
import 'prismjs/components/prism-javascript'
import 'prismjs/components/prism-typescript'
import 'prismjs/components/prism-yaml'
import 'prismjs/components/prism-sql'
import 'prismjs/components/prism-python'
import 'prismjs/components/prism-json'
import 'prismjs/components/prism-c'
import 'prismjs/components/prism-cpp'
import 'prismjs/components/prism-csharp'
import 'prismjs/components/prism-go'
import 'prismjs/components/prism-java'
import 'prismjs/components/prism-markup-templating'
import 'prismjs/components/prism-php'
import 'prismjs/components/prism-ruby'
import 'prismjs/components/prism-rust'
import { detectLanguage } from './detect-language'
const HIGHLIGHT_PLUGIN_KEY = new PluginKey('codeBlockHighlight')
function tokenClasses(token: Token): string {
const classes = ['token', token.type]
if (token.alias) classes.push(...(Array.isArray(token.alias) ? token.alias : [token.alias]))
return classes.join(' ')
}
/**
* Walks Prism's token tree, emitting one inline decoration per token over its text range.
* Nested tokens stack (ProseMirror nests overlapping inline decorations), reproducing the
* `.token`-class structure Prism would render as HTML.
*/
function collectTokenDecorations(
stream: TokenStream,
base: number,
offset: { value: number },
decorations: Decoration[],
limit: number
) {
const tokens = Array.isArray(stream) ? stream : [stream]
for (const token of tokens) {
if (typeof token === 'string') {
offset.value += token.length
continue
}
const start = offset.value
collectTokenDecorations(token.content, base, offset, decorations, limit)
const from = base + start
const to = Math.min(base + offset.value, limit)
if (to > from) decorations.push(Decoration.inline(from, to, { class: tokenClasses(token) }))
}
}
export function buildDecorations(doc: ProseMirrorNode): DecorationSet {
const decorations: Decoration[] = []
doc.descendants((node, pos) => {
if (node.type.name !== 'codeBlock') return
const language = (node.attrs.language as string | null) ?? detectLanguage(node.textContent)
const grammar = language ? Prism.languages[language] : undefined
if (!grammar) return
// Defensive: a malformed grammar or a token/position mismatch must never throw here — a throw
// in the decorations plugin blanks the whole editor. The `limit` clamps any over-long token.
try {
const base = pos + 1
collectTokenDecorations(
Prism.tokenize(node.textContent, grammar),
base,
{ value: 0 },
decorations,
base + node.content.size
)
} catch {}
})
return DecorationSet.create(doc, decorations)
}
/**
* Whether the transaction's changed ranges intersect any code block in the new doc — including
* a `setNodeMarkup` language change (whose step range covers the node). When false, the cheap
* path just maps existing decorations instead of re-tokenizing.
*/
export function changeTouchesCodeBlock(tr: Transaction, doc: ProseMirrorNode): boolean {
let touches = false
for (const map of tr.mapping.maps) {
map.forEach((_oldStart, _oldEnd, newStart, newEnd) => {
if (touches) return
const from = Math.max(0, Math.min(newStart, doc.content.size))
const to = Math.max(from, Math.min(newEnd, doc.content.size))
doc.nodesBetween(from, to, (node) => {
if (node.type.name === 'codeBlock') touches = true
return !touches
})
})
}
return touches
}
/**
* Syntax-highlights fenced code blocks with Prism, emitting the same `.token` classes the
* rest of the app uses so the `code-editor-theme` styles (light + dark) apply unchanged.
* Re-tokenizes only when a change actually touches a code block (typing in prose just maps
* the existing decorations), keeping the cost off the common keystroke path.
*/
export const CodeBlockHighlight = Extension.create({
name: 'codeBlockHighlight',
addProseMirrorPlugins() {
return [
new Plugin({
key: HIGHLIGHT_PLUGIN_KEY,
state: {
init: (_, { doc }) => buildDecorations(doc),
apply: (tr, current) => {
if (tr.steps.length === 0) return current
if (!changeTouchesCodeBlock(tr, tr.doc)) return current.map(tr.mapping, tr.doc)
return buildDecorations(tr.doc)
},
},
props: {
decorations(state) {
return HIGHLIGHT_PLUGIN_KEY.getState(state)
},
},
}),
]
},
})
@@ -0,0 +1,21 @@
/**
* @vitest-environment jsdom
*
* Guards against drift between the code-block language picker and the Prism grammars actually
* registered by CodeBlockHighlight: every selectable language must have a registered grammar, or it
* would silently fall back to no highlighting.
*/
import Prism from 'prismjs'
import { describe, expect, it } from 'vitest'
import { LANGUAGE_OPTIONS } from './code-block'
// Importing the highlighter registers all the prism-* grammars as a side effect.
import './code-highlight'
describe('code-block languages', () => {
it('every selectable language has a registered Prism grammar', () => {
for (const { value } of LANGUAGE_OPTIONS) {
if (value === 'plain') continue
expect(Prism.languages[value], `no Prism grammar registered for "${value}"`).toBeDefined()
}
})
})
@@ -0,0 +1,38 @@
import { describe, expect, it } from 'vitest'
import { detectLanguage } from './detect-language'
describe('detectLanguage', () => {
it('returns null for empty or unrecognizable content', () => {
expect(detectLanguage('')).toBeNull()
expect(detectLanguage(' \n ')).toBeNull()
expect(detectLanguage('just some prose words here')).toBeNull()
})
it('detects common languages from content shape', () => {
expect(detectLanguage('{\n "a": 1,\n "b": [2, 3]\n}')).toBe('json')
expect(detectLanguage('const x = 1\nfunction go() {}')).toBe('javascript')
expect(detectLanguage('interface Foo { name: string }')).toBe('typescript')
expect(detectLanguage('def main():\n print("hi")')).toBe('python')
expect(detectLanguage('SELECT id FROM users WHERE id = 1')).toBe('sql')
expect(detectLanguage('#!/bin/bash\necho hello')).toBe('bash')
expect(detectLanguage('<div class="x">hi</div>')).toBe('markup')
expect(detectLanguage('.btn { color: red; padding: 4px }')).toBe('css')
})
it('does not misclassify a JS object as JSON', () => {
expect(detectLanguage('const x = { a: 1 }')).toBe('javascript')
})
it('detects Go, Rust, Java', () => {
expect(detectLanguage('package main\n\nfunc main() {\n\tfmt.Println("hi")\n}')).toBe('go')
expect(detectLanguage('type User struct {\n\tName string\n}')).toBe('go')
expect(detectLanguage('fn main() {\n let mut x = 1;\n println!("{}", x);\n}')).toBe('rust')
expect(detectLanguage('public class Box {\n private int n;\n}')).toBe('java')
})
it('does not misread generics as HTML markup', () => {
expect(detectLanguage('public class Box { private List<String> items; }')).toBe('java')
expect(detectLanguage('let v: Vec<String> = Vec::new();\nfn f() {}')).toBe('rust')
expect(detectLanguage('func Map[T any](s []T) {}\npackage x')).toBe('go')
})
})
@@ -0,0 +1,63 @@
/**
* Heuristic language detection for a fenced code block that has no explicit ` ```lang ` tag.
* Used only to drive syntax highlighting + the picker label — the detected value is NEVER
* written back to the markdown, so opening a file never mutates it. Restricted to the grammars
* {@link CodeBlockHighlight} actually registers with Prism; returns `null` when unsure.
*/
const DETECTORS: ReadonlyArray<{ language: string; test: RegExp }> = [
// Real HTML: a closing tag, an opening tag with an attribute, or a doctype/comment. Deliberately
// NOT a bare `<Word>` so generics (`List<String>`, `Vec<T>`) aren't misread as markup.
{ language: 'markup', test: /<\/[a-z][\w-]*\s*>|<[a-z][\w-]*\s+[\w:-]+=|<!(?:doctype\b|--)/i },
{
language: 'sql',
test: /\b(?:select\s+[\w*]|insert\s+into|update\s+\w+\s+set|delete\s+from|create\s+table)/i,
},
{ language: 'python', test: /^\s*(def|class)\s+\w+|^\s*(import|from)\s+\w|\bprint\(|\belif\b/m },
{
language: 'bash',
test: /^#!.*\b(ba)?sh\b|^\s*(sudo|apt|brew|npm|yarn|bun|git|cd|echo|export|chmod|mkdir)\s|\$\(/m,
},
{
language: 'go',
test: /^\s*package\s+\w+|\bfunc\s+(\(\w[^)]*\)\s+)?\w+\s*\(|\btype\s+\w+\s+(struct|interface)\b|\bfmt\.\w|:=/m,
},
{
language: 'rust',
test: /\bfn\s+\w+\s*[(<]|\blet\s+mut\b|\bimpl\b|\bpub\s+(fn|struct|enum|mod)\b|println!/,
},
{
language: 'java',
test: /\b(public|private|protected)\s+(static\s+)?(final\s+)?(class|void|int|String|boolean)\b|System\.out\.print/,
},
{
language: 'typescript',
test: /\b(interface|type)\s+\w+\s*[={]|:\s*(string|number|boolean)\b|\bimport\s+type\b|\bas\s+\w+\s*;/,
},
{
language: 'javascript',
test: /\b(const|let|var|function)\s|=>|console\.\w+|\brequire\(|\bexport\s+(default|const)\b/,
},
{ language: 'css', test: /[.#]?[\w-]+\s*\{[^}]*[\w-]+\s*:[^};]+;?[^}]*\}/ },
{ language: 'yaml', test: /^[\w-]+:\s+\S/m },
]
function looksLikeJson(sample: string): boolean {
const trimmed = sample.trim()
if (!/^[[{]/.test(trimmed)) return false
try {
JSON.parse(trimmed)
return true
} catch {
return false
}
}
export function detectLanguage(code: string): string | null {
const sample = code.slice(0, 2000)
if (!sample.trim()) return null
if (looksLikeJson(sample)) return 'json'
for (const { language, test } of DETECTORS) {
if (test.test(sample)) return language
}
return null
}
@@ -0,0 +1,106 @@
/**
* @vitest-environment jsdom
*
* Regression guards for two bugs found while adding the `@` mention menu:
*
* 1. The `@` mention and `/` slash-command extensions each register a `@tiptap/suggestion` plugin.
* They must use distinct plugin keys, or constructing any editor with the full set throws
* "Adding different instances of a keyed plugin (suggestion$)".
*
* 2. A markdown file authored outside the editor (e.g. the former Monaco editor) is rarely in the
* editor's canonical serialization. On open, a deferred view-plugin transaction re-serializes the
* doc to canonical markdown and emits one update — which, compared against the raw saved bytes,
* falsely marks the file dirty ("unsaved changes"). The fix normalizes the dirty-check baseline to
* the canonical form; this asserts that normalized form equals what the live editor emits.
*/
import { sleep } from '@sim/utils/helpers'
import { Editor } from '@tiptap/core'
import { afterEach, beforeAll, describe, expect, it } from 'vitest'
import { createMarkdownEditorExtensions } from './editor-extensions'
import {
applyFrontmatter,
postProcessSerializedMarkdown,
splitFrontmatter,
} from './markdown-fidelity'
import { parseMarkdownToDoc } from './markdown-parse'
import { normalizeMarkdownContent } from './normalize-content'
let editor: Editor | null = null
let host: HTMLElement | null = null
beforeAll(() => {
// jsdom lacks the layout APIs the Placeholder viewport plugin calls when a view mounts.
// @ts-expect-error jsdom stub
document.elementFromPoint = () => document.body
// @ts-expect-error jsdom stub
Range.prototype.getClientRects = () => [] as unknown as DOMRectList
Range.prototype.getBoundingClientRect = () => new DOMRect()
Element.prototype.getClientRects = () => [] as unknown as DOMRectList
})
afterEach(() => {
editor?.destroy()
editor = null
host?.remove()
host = null
})
describe('full extension set', () => {
it('mounts without a duplicate suggestion-plugin-key error (@ and / coexist)', () => {
expect(() => {
editor = new Editor({
extensions: createMarkdownEditorExtensions({ placeholder: 'x' }),
content: '',
})
}).not.toThrow()
})
})
describe('normalizeMarkdownContent — dirty-on-open baseline', () => {
it('normalizes non-canonical markdown to the editor canonical form', () => {
expect(normalizeMarkdownContent('* one\n* two\n')).toBe('- one\n- two\n')
})
it('is idempotent', () => {
for (const md of [
'* one\n* two\n',
'| a | b |\n| --- | --- |\n| 1 | 2 |\n',
'# H\n\nsome _emphasis_ here\n',
]) {
const once = normalizeMarkdownContent(md)
expect(normalizeMarkdownContent(once)).toBe(once)
}
})
it('leaves round-trip-unsafe content untouched (read-only files keep their raw bytes)', () => {
const unsafe = 'text with a footnote[^1]\n\n[^1]: the note\n'
expect(normalizeMarkdownContent(unsafe)).toBe(unsafe)
})
})
describe('baseline neutralizes the mount-time dirty signal', () => {
it('the editor mount serialization equals the normalized baseline (so isDirty stays false)', async () => {
const raw = '# H\n\n* bullet\n\n| a | b |\n| --- | --- |\n| 1 | 2 |\n\n> quote\n'
const { frontmatter, body } = splitFrontmatter(raw)
host = document.createElement('div')
document.body.appendChild(host)
let emitted: string | null = null
editor = new Editor({
element: host,
extensions: createMarkdownEditorExtensions({ placeholder: 'x' }),
content: parseMarkdownToDoc(body),
onUpdate: ({ editor }) => {
emitted = applyFrontmatter(frontmatter, postProcessSerializedMarkdown(editor.getMarkdown()))
},
})
await sleep(30)
// The deferred mount transaction re-serializes to canonical markdown; the baseline must match it
// exactly, so `content === savedContent` and the file is never falsely dirty on open.
expect(emitted).not.toBeNull()
expect(emitted).toBe(normalizeMarkdownContent(raw))
})
})
@@ -0,0 +1,55 @@
/**
* @vitest-environment jsdom
*
* The rich editor uses TipTap's initial-content model: opening a file loads its markdown as the
* editor's initial `content`, which must NOT emit an update — so a freshly opened file is never
* marked dirty (no spurious autosave / "unsaved changes"). Only a genuine edit emits, which is what
* flips the dirty/autosave state on. These two cases guard exactly that contract.
*/
import { Editor } from '@tiptap/core'
import { afterEach, describe, expect, it } from 'vitest'
import { createMarkdownContentExtensions } from './extensions'
let editor: Editor | null = null
afterEach(() => {
editor?.destroy()
editor = null
})
function mount(content: string, onUpdate: () => void): Editor {
return new Editor({
extensions: createMarkdownContentExtensions(),
content,
contentType: 'markdown',
onUpdate,
})
}
describe('rich markdown editor — dirty signal', () => {
it('opening a file emits no update (never dirty on open), including markdown that normalizes', () => {
// A trailing newline and `_emphasis_` both normalize on serialization; opening must still be clean.
let updates = 0
editor = mount('# Title\n\nsome _emphasis_ here\n', () => {
updates++
})
expect(updates).toBe(0)
expect(editor.isEmpty).toBe(false)
})
it('opening an empty file emits no update and is editable', () => {
let updates = 0
editor = mount('', () => {
updates++
})
expect(updates).toBe(0)
})
it('a genuine edit emits an update (marks dirty → triggers autosave)', () => {
let updates = 0
editor = mount('hello', () => {
updates++
})
editor.commands.insertContent(' world')
expect(updates).toBeGreaterThan(0)
})
})
@@ -0,0 +1,53 @@
import type { Extensions } from '@tiptap/core'
import Placeholder from '@tiptap/extension-placeholder'
import { BlockMover } from './block-mover'
import { CodeBlockWithLanguage } from './code-block'
import { CodeBlockHighlight } from './code-highlight'
import { LinkEmbed } from './embed/link-embed'
import { createMarkdownContentExtensions } from './extensions'
import { ResizableImage } from './image'
import { RichMarkdownKeymap } from './keymap'
import { MarkdownPaste } from './markdown-paste'
import { Mention } from './mention/mention'
import { MentionChip } from './mention/mention-chip'
import { FootnoteDefWithView, RawHtmlBlockWithView } from './raw-markdown-snippet'
import { SlashCommand } from './slash-command/slash-command'
interface MarkdownEditorExtensionOptions {
placeholder: string
/** Renders supported media links as live players beneath a standalone link. Off by default. */
embeds?: boolean
}
/**
* The full extension set for the live editor: the content extensions with their React node-view nodes
* injected (code-block language picker, resizable image, mention chip) plus the UI-only extensions —
* `CodeBlockHighlight` (Prism), `SlashCommand` (the `/` block menu), `Mention` (the `@` menu),
* `RichMarkdownKeymap`, `MarkdownPaste`, `Placeholder`, and — when `embeds` is set — `LinkEmbed`
* (media players for standalone links).
*
* Kept separate from `extensions.ts` so those node views (and the block registry the mention chip pulls
* in for brand icons) stay out of the headless round-trip path, which only needs the schema.
*/
export function createMarkdownEditorExtensions({
placeholder,
embeds = false,
}: MarkdownEditorExtensionOptions): Extensions {
return [
...createMarkdownContentExtensions({
codeBlock: CodeBlockWithLanguage,
image: ResizableImage,
mention: MentionChip,
rawHtmlBlock: RawHtmlBlockWithView,
footnoteDef: FootnoteDefWithView,
}),
CodeBlockHighlight,
SlashCommand,
Mention,
RichMarkdownKeymap,
BlockMover,
MarkdownPaste,
Placeholder.configure({ placeholder }),
...(embeds ? [LinkEmbed] : []),
]
}
@@ -0,0 +1,62 @@
import type { EmbedInfo } from '@sim/utils/media-embed'
/**
* Iframes are rendered at native size then CSS-scaled down so embedded players keep their
* intended layout inside the editor's reading column. Mirrors the note-block renderer.
*/
const EMBED_SCALE = 0.78
const EMBED_INVERSE_SCALE = `${(1 / EMBED_SCALE) * 100}%`
const IFRAME_ALLOW =
'accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share'
/**
* Build the DOM player for a resolved {@link EmbedInfo}, matching the note-block renderer's
* markup. Returned as a non-editable element so it can back a ProseMirror widget decoration
* without entering the editable content.
*/
export function createEmbedDom(embedInfo: EmbedInfo): HTMLElement {
const container = document.createElement('div')
container.className = 'my-2 block w-full overflow-hidden rounded-md'
container.contentEditable = 'false'
if (embedInfo.type === 'iframe') {
const frame = document.createElement('div')
frame.className = 'block overflow-hidden'
frame.style.width = '100%'
frame.style.aspectRatio = embedInfo.aspectRatio || '16/9'
const iframe = document.createElement('iframe')
iframe.src = embedInfo.url
iframe.title = 'Media'
iframe.allow = IFRAME_ALLOW
iframe.allowFullscreen = true
iframe.loading = 'lazy'
iframe.className = 'origin-top-left'
iframe.style.width = EMBED_INVERSE_SCALE
iframe.style.height = EMBED_INVERSE_SCALE
iframe.style.transform = `scale(${EMBED_SCALE})`
frame.appendChild(iframe)
container.appendChild(frame)
return container
}
if (embedInfo.type === 'video') {
const video = document.createElement('video')
video.src = embedInfo.url
video.controls = true
video.preload = 'metadata'
video.className = 'aspect-video w-full'
container.appendChild(video)
return container
}
const audio = document.createElement('audio')
audio.src = embedInfo.url
audio.controls = true
audio.preload = 'metadata'
audio.className = 'w-full'
container.appendChild(audio)
return container
}
@@ -0,0 +1,64 @@
/**
* @vitest-environment jsdom
*/
import { Editor } from '@tiptap/core'
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
import { createMarkdownEditorExtensions } from '../editor-extensions'
// jsdom lacks elementFromPoint, which TipTap's Placeholder viewport tracking calls on mount.
beforeAll(() => {
document.elementFromPoint = vi.fn(() => null)
})
let editor: Editor | null = null
function editorWith(content: string, embeds = true): Editor {
editor = new Editor({
extensions: createMarkdownEditorExtensions({ placeholder: '', embeds }),
content,
})
return editor
}
afterEach(() => {
editor?.destroy()
editor = null
})
const YOUTUBE_LINK = '<p><a href="https://www.youtube.com/watch?v=dQw4w9WgXcQ">watch</a></p>'
describe('LinkEmbed', () => {
it('renders a player beneath a standalone embeddable link', () => {
const view = editorWith(YOUTUBE_LINK).view
const iframe = view.dom.querySelector('iframe')
expect(iframe?.getAttribute('src')).toBe('https://www.youtube.com/embed/dQw4w9WgXcQ')
})
it('renders one player per link when the same URL appears twice', () => {
const view = editorWith(`${YOUTUBE_LINK}${YOUTUBE_LINK}`).view
expect(view.dom.querySelectorAll('iframe')).toHaveLength(2)
})
it('keeps the underlying document a plain markdown link (lossless round-trip)', () => {
const markdown = editorWith(YOUTUBE_LINK).getMarkdown()
expect(markdown).toContain('https://www.youtube.com/watch?v=dQw4w9WgXcQ')
expect(markdown).not.toContain('<iframe')
})
it('does not embed an inline link inside surrounding text', () => {
const view = editorWith(
'<p>see <a href="https://www.youtube.com/watch?v=dQw4w9WgXcQ">here</a> now</p>'
).view
expect(view.dom.querySelector('iframe')).toBeNull()
})
it('does not embed a non-embeddable standalone link', () => {
const view = editorWith('<p><a href="https://example.com/article">read</a></p>').view
expect(view.dom.querySelector('iframe')).toBeNull()
})
it('does nothing when the embeds option is disabled', () => {
const view = editorWith(YOUTUBE_LINK, false).view
expect(view.dom.querySelector('iframe')).toBeNull()
})
})
@@ -0,0 +1,88 @@
import { getEmbedInfo } from '@sim/utils/media-embed'
import { Extension } from '@tiptap/core'
import type { Node as ProseMirrorNode } from '@tiptap/pm/model'
import { Plugin, PluginKey } from '@tiptap/pm/state'
import { Decoration, DecorationSet } from '@tiptap/pm/view'
import { createEmbedDom } from './embed-dom'
const LINK_EMBED_PLUGIN_KEY = new PluginKey('linkEmbed')
/**
* The href of a paragraph that is a single, whole-text link (a "standalone link"), or null if
* the paragraph is empty, holds non-text content, or mixes a link with other text. Only
* standalone links become media embeds — a link inline within a sentence stays a plain link,
* matching how Notion and Linear auto-embed.
*/
function getStandaloneLinkHref(paragraph: ProseMirrorNode): string | null {
if (paragraph.childCount === 0) return null
let href: string | null = null
let isStandalone = true
paragraph.forEach((child) => {
if (!isStandalone) return
const linkMark = child.isText
? child.marks.find((mark) => mark.type.name === 'link')
: undefined
if (!linkMark) {
isStandalone = false
return
}
const childHref = linkMark.attrs.href as string
if (href === null) href = childHref
else if (href !== childHref) isStandalone = false
})
return isStandalone ? href : null
}
function buildDecorations(doc: ProseMirrorNode): DecorationSet {
const decorations: Decoration[] = []
/** Per-source occurrence count, so repeated embeds of the same URL get distinct, stable keys. */
const sourceCounts = new Map<string, number>()
doc.descendants((node, pos) => {
if (node.type.name !== 'paragraph') return undefined
const href = getStandaloneLinkHref(node)
if (href) {
const embedInfo = getEmbedInfo(href)
if (embedInfo) {
const source = `embed:${embedInfo.type}:${embedInfo.url}`
const index = sourceCounts.get(source) ?? 0
sourceCounts.set(source, index + 1)
decorations.push(
Decoration.widget(pos + node.nodeSize, () => createEmbedDom(embedInfo), {
side: 1,
key: `${source}:${index}`,
})
)
}
}
// Paragraphs hold only inline content, so there is nothing more to descend into.
return false
})
return DecorationSet.create(doc, decorations)
}
/**
* Renders supported media links (YouTube, Vimeo, Spotify, Dropbox, …) as live players beneath a
* standalone link, in both the editing and read-only surfaces. Implemented as widget decorations
* so the underlying document stays a plain markdown link — embeds never enter the schema or the
* serialized markdown, keeping round-trips lossless.
*/
export const LinkEmbed = Extension.create({
name: 'linkEmbed',
addProseMirrorPlugins() {
return [
new Plugin({
key: LINK_EMBED_PLUGIN_KEY,
state: {
init: (_, { doc }) => buildDecorations(doc),
apply: (tr, current) => (tr.docChanged ? buildDecorations(tr.doc) : current),
},
props: {
decorations(state) {
return LINK_EMBED_PLUGIN_KEY.getState(state)
},
},
}),
]
},
})
@@ -0,0 +1,151 @@
import type { Extensions, JSONContent, MarkdownRendererHelpers, Node } from '@tiptap/core'
import { Code } from '@tiptap/extension-code'
import { TaskItem, TaskList } from '@tiptap/extension-list'
import { Paragraph } from '@tiptap/extension-paragraph'
import {
renderTableToMarkdown,
Table,
TableCell,
TableHeader,
TableRow,
} from '@tiptap/extension-table'
import { Markdown } from '@tiptap/markdown'
import StarterKit from '@tiptap/starter-kit'
import { MarkdownCodeBlock } from './code-block'
import { Highlight } from './highlight'
import { MarkdownImage } from './image'
import { MarkdownLinkInputRule } from './link-input-rule'
import { MarkdownMention } from './mention/mention-node'
import { SIM_LINK_SCHEME } from './mention/sim-link'
import { FootnoteDef, FootnoteRef, RawHtmlBlock, RawInlineHtml } from './raw-markdown-snippet'
/**
* The `@`-mention link scheme, registered on the Link mark — without it the schema strips the
* `sim:<kind>/<id>` href on parse/round-trip, dropping the mention. `optionalSlashes` allows the
* slash-less `sim:kind/id` form.
*/
const SIM_LINK_PROTOCOL = { scheme: SIM_LINK_SCHEME, optionalSlashes: true } as const
/**
* Inline code that can combine with bold/italic/strike (GFM permits `**`x`**`, `~~`x`~~`).
* The stock Code mark sets `excludes: '_'`, which blocks every other mark from coexisting and
* makes the bubble-menu toggles silently no-op over a code selection.
*/
const InlineCode = Code.extend({ excludes: '' })
/**
* Table that escapes interior `|` characters when serializing cells. The upstream serializer
* joins cells with `|` without escaping, so a cell containing a literal pipe silently splits
* into phantom columns on round-trip (data loss). Escaping must happen on the `table` node —
* `tableCell`/`tableHeader` have no markdown renderer; the table renders cell children directly. Only
* `|` is escaped — `renderChildren` already escapes backslashes, so escaping them again would
* double-escape and break round-trip idempotency (CodeQL's "missing backslash escape" is a false
* positive here; covered by the table round-trip tests).
*
* The upstream serializer also wraps the table in its own leading/trailing blank lines; left in,
* the block joiner adds another, so an interior table churns its surrounding whitespace to
* `\n\n\n` on the first edit. Trimming the table's own output lets the joiner own the single
* blank-line separator — without touching blank lines inside fenced code (those live in the code
* node's text, not here).
*/
const PipeSafeTable = Table.extend({
renderMarkdown: (node: JSONContent, h: MarkdownRendererHelpers) =>
renderTableToMarkdown(node, {
...h,
renderChildren: (nodes, separator) =>
h.renderChildren(nodes, separator).replace(/\|/g, '\\|'),
})
.replace(/^\n+/, '')
.replace(/\n+$/, ''),
})
/**
* Guards a paragraph's serialized text so its leading characters don't re-parse it into a different
* block on the next load:
*
* - **Leading whitespace** is stripped. It never renders in a paragraph (CommonMark strips up to three
* leading spaces, and four or more would re-parse the paragraph as an indented code block), so
* removing it is lossless and makes the round-trip idempotent.
* - **A leading block marker** (`#`, `-`, `+`, `1.`, `1)`, or a bare `---`) is backslash-escaped so the
* paragraph doesn't become a heading / list / thematic break. The upstream serializer escapes inline
* delimiters (`* _ \` [ ] ~`, so `*` bullets and `>` quotes already round-trip) but not these
* block-starting markers. Escaping is idempotent: parsing consumes the backslash, so the stored
* ProseMirror text never carries it and re-serialization is stable.
*/
function guardParagraphLeading(text: string): string {
const stripped = text.replace(/^[ \t]+/, '')
if (/^(#{1,6}([ \t]|$)|[-+][ \t]|-(?:[ \t]*-){2,}[ \t]*$)/.test(stripped)) {
return `\\${stripped}`
}
const ordered = /^(\d{1,9})([.)][ \t])/.exec(stripped)
return ordered ? `${ordered[1]}\\${stripped.slice(ordered[1].length)}` : stripped
}
/**
* Paragraph that guards its leading characters on serialize (see {@link guardParagraphLeading}) —
* otherwise a paragraph beginning with a block marker or an indent silently becomes a heading / list /
* thematic break / code block on the next load. Block separators are owned by the parent joiner, so a
* paragraph renders as just its inline children; this override wraps that with the leading guard.
*/
const BlockSafeParagraph = Paragraph.extend({
renderMarkdown: (node: JSONContent, h: MarkdownRendererHelpers) =>
guardParagraphLeading(h.renderChildren(node.content ?? [])),
})
/**
* Node-view variants the live editor injects in place of the headless defaults — the code-block
* language picker, the resizable image, and the mention chip. The mention chip pulls the block registry
* (for brand icons), so the headless round-trip path omits it: passing nothing keeps
* {@link createMarkdownContentExtensions} free of the registry and constructs no React node views.
*/
export interface ContentNodeViews {
codeBlock?: Node
image?: Node
mention?: Node
rawHtmlBlock?: Node
footnoteDef?: Node
}
/**
* The schema + serialization extensions: the nodes/marks the document can contain and the
* Markdown ⇄ ProseMirror conversion. `StarterKit` provides core nodes/marks and the
* Markdown-style input rules (`# `, `- `, `**bold**`, …); `TaskList`/`TaskItem` add
* `- [ ]` checklists; `TableKit` adds GFM tables; `Markdown` serializes back to markdown.
*
* Headless by default (the `nodeViews` overrides are empty), so importing this module — e.g. for the
* markdown round-trip in `markdown-parse.ts` — never constructs React node views or pulls the block
* registry. The live editor passes the node-view nodes via {@link createMarkdownEditorExtensions}; the
* schema and markdown output are identical either way.
*/
export function createMarkdownContentExtensions(nodeViews: ContentNodeViews = {}): Extensions {
const codeBlock = (nodeViews.codeBlock ?? MarkdownCodeBlock).configure({
HTMLAttributes: { class: 'code-editor-theme' },
})
return [
StarterKit.configure({
link: { openOnClick: false, protocols: [SIM_LINK_PROTOCOL] },
underline: false,
codeBlock: false,
code: false,
paragraph: false,
}),
BlockSafeParagraph,
InlineCode,
Highlight,
codeBlock,
(nodeViews.image ?? MarkdownImage).configure({ allowBase64: true }),
nodeViews.mention ?? MarkdownMention,
TaskList,
TaskItem.configure({ nested: true }),
PipeSafeTable.configure({ resizable: true }),
TableRow,
TableHeader,
TableCell,
nodeViews.rawHtmlBlock ?? RawHtmlBlock,
nodeViews.footnoteDef ?? FootnoteDef,
FootnoteRef,
RawInlineHtml,
MarkdownLinkInputRule,
Markdown,
]
}
@@ -0,0 +1,57 @@
/**
* @vitest-environment jsdom
*/
import { Editor } from '@tiptap/core'
import { afterEach, describe, expect, it } from 'vitest'
import { createMarkdownContentExtensions } from './extensions'
import { findHeadingPos, slugifyHeading } from './heading-anchors'
let editor: Editor | null = null
afterEach(() => {
editor?.destroy()
editor = null
})
/** A ProseMirror doc parsed from markdown, for the position-resolution tests. */
function docOf(markdown: string) {
editor = new Editor({ extensions: createMarkdownContentExtensions() })
editor.commands.setContent(markdown, { contentType: 'markdown' })
return editor.state.doc
}
describe('slugifyHeading', () => {
it('lowercases, drops punctuation, and hyphenates whitespace (GitHub-style)', () => {
expect(slugifyHeading('Getting Started')).toBe('getting-started')
expect(slugifyHeading('API Reference!')).toBe('api-reference')
expect(slugifyHeading(' Spaced Out ')).toBe('spaced-out')
expect(slugifyHeading('Node.js & Bun')).toBe('nodejs-bun')
})
it('returns an empty string for punctuation-only text', () => {
expect(slugifyHeading('!!!')).toBe('')
expect(slugifyHeading('')).toBe('')
})
})
describe('findHeadingPos', () => {
it('resolves a fragment slug to its heading position', () => {
const doc = docOf('# Intro\n\ntext\n\n## Getting Started\n\nmore')
expect(findHeadingPos(doc, 'intro')).toBeGreaterThanOrEqual(0)
expect(findHeadingPos(doc, 'getting-started')).toBeGreaterThan(findHeadingPos(doc, 'intro'))
})
it('disambiguates duplicate slugs GitHub-style (foo, foo-1, foo-2)', () => {
const doc = docOf('# Notes\n\na\n\n# Notes\n\nb\n\n# Notes\n\nc')
const first = findHeadingPos(doc, 'notes')
const second = findHeadingPos(doc, 'notes-1')
const third = findHeadingPos(doc, 'notes-2')
expect(first).toBeGreaterThanOrEqual(0)
expect(second).toBeGreaterThan(first)
expect(third).toBeGreaterThan(second)
})
it('returns -1 when no heading matches', () => {
const doc = docOf('# Only Heading\n\nbody')
expect(findHeadingPos(doc, 'missing')).toBe(-1)
})
})
@@ -0,0 +1,36 @@
import type { Node as ProseMirrorNode } from '@tiptap/pm/model'
/**
* Slugify heading text GitHub-style (lowercase, drop punctuation, collapse whitespace to hyphens) so
* that `[label](#slug)` fragment links — written against how GitHub renders the same markdown —
* resolve to the matching heading. Mirrors what `rehype-slug` produced in the old preview.
*/
export function slugifyHeading(text: string): string {
return text
.toLowerCase()
.trim()
.replace(/[^\w\s-]/g, '')
.replace(/\s+/g, '-')
.replace(/-+/g, '-')
}
/**
* The document position of the heading a `#slug` fragment link targets, or -1 if none matches.
* Computed on demand (at click time) rather than maintained as per-keystroke decorations. Duplicate
* slugs are disambiguated GitHub-style: `intro`, `intro-1`, `intro-2`, …
*/
export function findHeadingPos(doc: ProseMirrorNode, slug: string): number {
const seen = new Map<string, number>()
let found = -1
doc.descendants((node, pos) => {
if (found >= 0) return false
if (node.type.name !== 'heading') return true
const base = slugifyHeading(node.textContent)
if (!base) return true
const n = seen.get(base) ?? 0
seen.set(base, n + 1)
if ((n === 0 ? base : `${base}-${n}`) === slug) found = pos
return found < 0
})
return found
}
@@ -0,0 +1,105 @@
import type {
JSONContent,
MarkdownParseHelpers,
MarkdownRendererHelpers,
MarkdownToken,
} from '@tiptap/core'
import { Mark, markInputRule, markPasteRule, mergeAttributes } from '@tiptap/core'
import type { Transaction } from '@tiptap/pm/state'
import { Plugin } from '@tiptap/pm/state'
/**
* `==text==` with non-space edges — the Pandoc/Obsidian highlight syntax. The body allows a lone `=`
* (`=(?!=)`) but never `==`, so a highlight over text containing `=` (e.g. `==a=b==`) round-trips while
* the closing `==` still terminates the run.
*/
const HIGHLIGHT_BODY = `(?:[^=]|=(?!=))+?`
const HIGHLIGHT_TOKEN = new RegExp(String.raw`^==(?!\s)(${HIGHLIGHT_BODY})(?<!\s)==`)
/** Input/paste rule form (anchored on a preceding boundary) so typing `==x==` toggles the mark. */
const HIGHLIGHT_INPUT = new RegExp(String.raw`(?:^|\s)(==(?!\s)(${HIGHLIGHT_BODY})(?<!\s)==)$`)
const HIGHLIGHT_PASTE = new RegExp(String.raw`(?:^|\s)(==(?!\s)(${HIGHLIGHT_BODY})(?<!\s)==)`, 'g')
/**
* Highlight mark (`<mark>`), serialized to and parsed from `==text==`. CommonMark/`marked` has no
* highlight token, so this registers a custom inline tokenizer (parsing the inner text as inline
* markdown so nested marks like `==**bold**==` survive) and a `renderMarkdown` that wraps the content
* in `==`. Mirrors the verbatim-node registration pattern in `./raw-markdown-snippet`.
*
* The tokenizer's `start` returns the index of the next `==` (a plain string search, not the
* `createLexer()`-calling form the `RawHtmlBlock` caveat warns against) so `marked` breaks its inline
* text run there and gives this tokenizer a chance mid-line — `=` is not a default break char like `[`.
*
* A lone `=` is allowed inside a highlight (so `==a=b==` round-trips), but `==` cannot be encoded in the
* `==…==` delimiter (emitting `==a==b==` would split the highlight and corrupt the text on reload). The
* tokenizer/input rules already exclude `==`; an `appendTransaction` guard removes the mark from any
* text that ends up containing `==` (e.g. a toolbar highlight over `a==b`), so the doc never holds an
* unrepresentable highlight and serialization stays lossless.
*/
export const Highlight = Mark.create({
name: 'highlight',
parseHTML() {
return [{ tag: 'mark' }]
},
renderHTML({ HTMLAttributes }) {
return ['mark', mergeAttributes(HTMLAttributes), 0]
},
addInputRules() {
return [markInputRule({ find: HIGHLIGHT_INPUT, type: this.type })]
},
addPasteRules() {
return [markPasteRule({ find: HIGHLIGHT_PASTE, type: this.type })]
},
addKeyboardShortcuts() {
return { 'Mod-Shift-h': () => this.editor.commands.toggleMark(this.name) }
},
markdownTokenName: 'highlight',
markdownTokenizer: {
name: 'highlight',
level: 'inline' as const,
start: (src: string) => src.indexOf('=='),
tokenize(src: string): MarkdownToken | undefined {
const match = HIGHLIGHT_TOKEN.exec(src)
if (!match) return undefined
return { type: 'highlight', raw: match[0], text: match[1] }
},
},
parseMarkdown(token: MarkdownToken, helpers: MarkdownParseHelpers) {
const inner = token.text ?? ''
const tokens = helpers.tokenizeInline?.(inner)
const content = tokens ? helpers.parseInline(tokens) : [{ type: 'text', text: inner }]
return { mark: 'highlight', content }
},
renderMarkdown(node: JSONContent, h: MarkdownRendererHelpers) {
return `==${h.renderChildren(node.content ?? [])}==`
},
addProseMirrorPlugins() {
const markType = this.type
return [
new Plugin({
appendTransaction(transactions, _oldState, newState) {
if (!transactions.some((transaction) => transaction.docChanged)) return null
let tr: Transaction | null = null
newState.doc.descendants((node, pos) => {
if (
node.isText &&
node.text?.includes('==') &&
node.marks.some((mark) => mark.type === markType)
) {
tr = (tr ?? newState.tr).removeMark(pos, pos + node.nodeSize, markType)
}
})
return tr
},
}),
]
},
})
@@ -0,0 +1,386 @@
/**
* @vitest-environment jsdom
*/
import { Editor } from '@tiptap/core'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { extractEmbeddedFileRef } from '@/lib/uploads/utils/embedded-image-ref'
import {
createPublicFileContentSource,
createWorkspaceFileContentSource,
} from '@/hooks/use-file-content-source'
import { createMarkdownEditorExtensions } from './editor-extensions'
import {
extractImageFiles,
extractImgSrcs,
findHostedImageAttrs,
hasHostedImageHtml,
htmlReferencesSrc,
isInlineRouteSrc,
shouldSkipFileUpload,
toSameOriginPath,
} from './image-paste'
// jsdom lacks `elementFromPoint`; the Placeholder extension's viewport tracking calls it on mount.
beforeEach(() => {
vi.stubGlobal(
'ResizeObserver',
class {
observe() {}
unobserve() {}
disconnect() {}
}
)
Element.prototype.scrollIntoView = vi.fn()
document.elementFromPoint = vi.fn(() => null)
})
function imageFile(name = 'shot.png'): File {
return new File([''], name, { type: 'image/png' })
}
function transfer(
files: File[],
items: Array<{ kind: string; type: string; file: File | null }> = []
): DataTransfer {
return {
files,
items: items.map((entry) => ({
kind: entry.kind,
type: entry.type,
getAsFile: () => entry.file,
})),
} as unknown as DataTransfer
}
describe('extractImageFiles', () => {
it('returns nothing for a null payload or non-image files', () => {
expect(extractImageFiles(null)).toEqual([])
expect(extractImageFiles(transfer([new File([''], 'a.txt', { type: 'text/plain' })]))).toEqual(
[]
)
})
it('reads images from the files list (drag-drop)', () => {
const file = imageFile()
expect(extractImageFiles(transfer([file]))).toEqual([file])
})
it('falls back to items when files is empty (pasted screenshot)', () => {
const file = imageFile()
const result = extractImageFiles(transfer([], [{ kind: 'file', type: 'image/png', file }]))
expect(result).toEqual([file])
})
it('ignores non-file and non-image items', () => {
const result = extractImageFiles(
transfer(
[],
[
{ kind: 'string', type: 'text/plain', file: null },
{ kind: 'file', type: 'application/pdf', file: new File([''], 'a.pdf') },
]
)
)
expect(result).toEqual([])
})
})
describe('hasHostedImageHtml', () => {
const isHosted = (src: string) => src.startsWith('/api/files/view/')
it('detects an <img> whose src is recognized as one of our own hosted files', () => {
expect(hasHostedImageHtml('<img src="/api/files/view/wf_abc" alt="x">', isHosted)).toBe(true)
})
it('is false when the html has no img, or the img src is not one of ours', () => {
expect(hasHostedImageHtml('<p>hello</p>', isHosted)).toBe(false)
expect(hasHostedImageHtml('<img src="https://other-site.com/photo.jpg">', isHosted)).toBe(false)
expect(hasHostedImageHtml('', isHosted)).toBe(false)
})
it('matches a hosted img among multiple candidates', () => {
expect(
hasHostedImageHtml(
'<img src="https://other-site.com/a.png"><img src="/api/files/view/wf_abc">',
isHosted
)
).toBe(true)
})
// Regression: the browser doesn't put the node's persisted `attrs.src` (`/api/files/view/...`)
// onto the clipboard when a rendered <img> is copied — it puts the actual DOM `src`, which is
// `resolveImageSrc`'s REWRITTEN display URL (`/…/files/inline?key=…`/`?fileId=…`). A predicate
// that only recognizes the persisted shape (as `extractEmbeddedFileRef` alone does) never matches
// a real same-page copy, silently falling through to the re-upload path it exists to avoid.
it('recognizes the real rendered <img src> end-to-end, not just the persisted reference shape', () => {
const ws = createWorkspaceFileContentSource('ws-1')
const renderedFromKey = ws.resolveImageSrc(
'/api/files/serve/workspace/ws-1/1700000000000-deadbeefdeadbeef-photo.png'
)
const renderedFromFileId = ws.resolveImageSrc('/api/files/view/wf_abc')
expect(renderedFromKey).toMatch(/^\/api\/workspaces\/ws-1\/files\/inline\?key=/)
expect(renderedFromFileId).toBe('/api/workspaces/ws-1/files/inline?fileId=wf_abc')
// extractEmbeddedFileRef alone (the persisted-content recognizer) does NOT match either
// rendered form — that's the exact gap isInlineRouteSrc closes.
expect(extractEmbeddedFileRef(renderedFromKey as string)).toBeNull()
expect(extractEmbeddedFileRef(renderedFromFileId as string)).toBeNull()
const isHostedReal = (src: string) => extractEmbeddedFileRef(src) !== null
expect(hasHostedImageHtml(`<img src="${renderedFromKey}">`, isHostedReal)).toBe(true)
expect(hasHostedImageHtml(`<img src="${renderedFromFileId}">`, isHostedReal)).toBe(true)
})
it('recognizes the public-share inline route too', () => {
const pub = createPublicFileContentSource('tok_1', '/api/files/public/tok_1/content')
const rendered = pub.resolveImageSrc('/api/files/view/wf_abc')
expect(rendered).toBe('/api/files/public/tok_1/inline?fileId=wf_abc')
expect(hasHostedImageHtml(`<img src="${rendered}">`, () => false)).toBe(true)
})
it('matches a valid unquoted src attribute (unquoted attribute values are valid HTML)', () => {
expect(hasHostedImageHtml('<img src=/api/files/view/wf_abc>', isHosted)).toBe(true)
expect(hasHostedImageHtml("<img alt='x' src=/api/files/view/wf_abc alt=y>", isHosted)).toBe(
true
)
expect(hasHostedImageHtml('<img src=https://other-site.com/a.png>', isHosted)).toBe(false)
})
it('matches single-quoted src attributes too', () => {
expect(hasHostedImageHtml("<img src='/api/files/view/wf_abc'>", isHosted)).toBe(true)
})
})
describe('extractImgSrcs', () => {
it('extracts every img src in document order, including duplicates', () => {
expect(
extractImgSrcs('<img src="/a.png"><p>text</p><img src="/b.png"><img src="/a.png">')
).toEqual(['/a.png', '/b.png', '/a.png'])
})
it('returns an empty array for html with no img', () => {
expect(extractImgSrcs('<p>hello</p>')).toEqual([])
expect(extractImgSrcs('')).toEqual([])
})
})
describe('shouldSkipFileUpload (shared by paste and drop)', () => {
const isHosted = (src: string) => src.startsWith('/api/files/view/')
const hostedHtml = '<img src="/api/files/view/wf_abc">'
it('skips upload for a single already-hosted image', () => {
expect(shouldSkipFileUpload([imageFile()], hostedHtml, isHosted)).toBe(true)
})
it('does not skip when there is no html, or the html is not one of ours', () => {
expect(shouldSkipFileUpload([imageFile()], '', isHosted)).toBe(false)
expect(shouldSkipFileUpload([imageFile()], '<img src="https://x.com/a.png">', isHosted)).toBe(
false
)
})
it('does not skip when there are no files to upload in the first place', () => {
expect(shouldSkipFileUpload([], hostedHtml, isHosted)).toBe(false)
})
// Regression: a genuinely mixed paste/drop (the hosted image plus a separate new one) must
// still upload the new file — bailing out entirely here would silently drop it.
it('does not skip a mixed paste/drop carrying more than one image file', () => {
expect(
shouldSkipFileUpload([imageFile('a.png'), imageFile('b.png')], hostedHtml, isHosted)
).toBe(false)
})
// Regression: this must be content-based (the accompanying html), not keyed off any mutable
// per-drag flag like ProseMirror's `view.dragging` — that flag can go briefly stale (cleared up
// to ~50ms late via `dragend` when a prior internal drag was dropped outside the view), and a
// flag-based check would incorrectly suppress upload of an unrelated new file dropped in that
// window. This function only reacts to what THIS specific event's `html`/`images` contain.
it('is a pure function of the images/html actually offered, independent of any drag-session flag', () => {
expect(shouldSkipFileUpload([imageFile()], '', isHosted)).toBe(false)
expect(shouldSkipFileUpload([imageFile()], hostedHtml, isHosted)).toBe(true)
})
})
describe('isInlineRouteSrc', () => {
it('recognizes the workspace- and public-scoped inline route with key or fileId', () => {
expect(isInlineRouteSrc('/api/workspaces/ws-1/files/inline?key=workspace%2Fws-1%2Fa.png')).toBe(
true
)
expect(isInlineRouteSrc('/api/workspaces/ws-1/files/inline?fileId=wf_abc')).toBe(true)
expect(isInlineRouteSrc('/api/files/public/tok_1/inline?fileId=wf_abc')).toBe(true)
})
it('rejects non-inline paths, unrecognized query params, and external/absolute origins', () => {
expect(isInlineRouteSrc('/api/files/serve/workspace/ws-1/a.png')).toBe(false)
expect(isInlineRouteSrc('/api/workspaces/ws-1/files/inline')).toBe(false)
expect(isInlineRouteSrc('/api/workspaces/ws-1/files/inline?other=1')).toBe(false)
expect(isInlineRouteSrc('https://other-site.com/files/inline?key=x')).toBe(false)
expect(isInlineRouteSrc('data:image/png;base64,aaaa')).toBe(false)
})
})
describe('findHostedImageAttrs', () => {
const ws = createWorkspaceFileContentSource('ws-1')
function docWithImages(...attrs: Array<Record<string, unknown>>): Editor {
return new Editor({
extensions: createMarkdownEditorExtensions({ placeholder: '' }),
content: {
type: 'doc',
content: attrs.map((a) => ({ type: 'image', attrs: a })),
},
})
}
// Regression: this is the exact mechanism that avoids persisting the display-layer inline URL
// (Cursor's "Paste persists display image URLs" finding) — cloning the REAL node's attrs rather
// than re-deriving a node from the clipboard html's rewritten src.
it('finds the existing node whose RESOLVED (display) src matches, and returns its REAL persisted attrs', () => {
const persistedSrc = '/api/files/view/wf_abc'
const editor = docWithImages({ src: persistedSrc, alt: 'photo', width: '300' })
const renderedSrc = ws.resolveImageSrc(persistedSrc) as string
expect(renderedSrc).not.toBe(persistedSrc) // sanity: the rendered form really differs
const match = findHostedImageAttrs(editor.state.doc, [renderedSrc], ws.resolveImageSrc)
expect(match).not.toBeNull()
expect(match?.src).toBe(persistedSrc) // the REAL persisted src, not the rendered one
expect(match?.alt).toBe('photo')
expect(match?.width).toBe('300')
})
it('returns null when no node in the doc resolves to any target src', () => {
const editor = docWithImages({ src: '/api/files/view/wf_other' })
const match = findHostedImageAttrs(
editor.state.doc,
['/api/workspaces/ws-1/files/inline?fileId=wf_abc'],
ws.resolveImageSrc
)
expect(match).toBeNull()
})
it('returns null for an empty doc or an empty target list', () => {
const editor = docWithImages()
expect(findHostedImageAttrs(editor.state.doc, ['/anything'], ws.resolveImageSrc)).toBeNull()
const editorWithImage = docWithImages({ src: '/api/files/view/wf_abc' })
expect(findHostedImageAttrs(editorWithImage.state.doc, [], ws.resolveImageSrc)).toBeNull()
})
it('matches the first of several images, not just the last', () => {
const editor = docWithImages(
{ src: '/api/files/view/wf_one', alt: 'one' },
{ src: '/api/files/view/wf_two', alt: 'two' }
)
const renderedTwo = ws.resolveImageSrc('/api/files/view/wf_two') as string
const match = findHostedImageAttrs(editor.state.doc, [renderedTwo], ws.resolveImageSrc)
expect(match?.alt).toBe('two')
})
it('returns a defensive copy, not a live reference to the node attrs object', () => {
const persistedSrc = '/api/files/view/wf_abc'
const editor = docWithImages({ src: persistedSrc, alt: 'photo' })
const renderedSrc = ws.resolveImageSrc(persistedSrc) as string
const match = findHostedImageAttrs(editor.state.doc, [renderedSrc], ws.resolveImageSrc)
expect(match).not.toBeNull()
if (match) match.alt = 'mutated'
let originalAlt: unknown
editor.state.doc.descendants((node) => {
if (node.type.name === 'image') originalAlt = node.attrs.alt
})
expect(originalAlt).toBe('photo')
})
})
describe('origin-aware src normalization (browser-native drag/copy enrichment uses ABSOLUTE urls)', () => {
const ORIGIN = 'https://www.staging.sim.ai'
it('toSameOriginPath: relative passes through; same-origin absolute → path+query; cross-origin → null', () => {
expect(toSameOriginPath('/api/files/view/wf_a', ORIGIN)).toBe('/api/files/view/wf_a')
expect(toSameOriginPath(`${ORIGIN}/api/workspaces/ws-1/files/inline?fileId=wf_a`, ORIGIN)).toBe(
'/api/workspaces/ws-1/files/inline?fileId=wf_a'
)
expect(toSameOriginPath('https://evil.example.com/api/files/view/wf_a', ORIGIN)).toBeNull()
})
it('isInlineRouteSrc accepts the same-origin ABSOLUTE inline route (the real dragged-img src on staging)', () => {
expect(isInlineRouteSrc(`${ORIGIN}/api/workspaces/ws-1/files/inline?fileId=wf_a`, ORIGIN)).toBe(
true
)
expect(
isInlineRouteSrc(
'https://evil.example.com/api/workspaces/ws-1/files/inline?fileId=wf_a',
ORIGIN
)
).toBe(false)
})
it('hasHostedImageHtml matches absolute same-origin srcs and rejects cross-origin ones', () => {
const isHosted = (src: string) => extractEmbeddedFileRef(src) !== null
expect(hasHostedImageHtml(`<img src="${ORIGIN}/api/files/view/wf_a">`, isHosted, ORIGIN)).toBe(
true
)
expect(
hasHostedImageHtml(
`<img src="${ORIGIN}/api/workspaces/ws-1/files/inline?fileId=wf_a">`,
isHosted,
ORIGIN
)
).toBe(true)
expect(
hasHostedImageHtml(
'<img src="https://evil.example.com/api/files/view/wf_a">',
isHosted,
ORIGIN
)
).toBe(false)
})
it('findHostedImageAttrs matches when the clipboard html carries the absolute rendered url', () => {
const ws = createWorkspaceFileContentSource('ws-1')
const editor = new Editor({
extensions: createMarkdownEditorExtensions({ placeholder: '' }),
content: {
type: 'doc',
content: [{ type: 'image', attrs: { src: '/api/files/view/wf_abc', alt: 'photo' } }],
},
})
const absolute = `${ORIGIN}/api/workspaces/ws-1/files/inline?fileId=wf_abc`
const match = findHostedImageAttrs(editor.state.doc, [absolute], ws.resolveImageSrc, ORIGIN)
expect(match?.src).toBe('/api/files/view/wf_abc')
})
})
describe('htmlReferencesSrc (the "this drop is MY dragged image" check)', () => {
const ORIGIN = 'https://www.staging.sim.ai'
const RESOLVED = '/api/workspaces/ws-1/files/inline?fileId=wf_a'
it('true when the html img src is the absolute form of the resolved src', () => {
expect(htmlReferencesSrc(`<img src="${ORIGIN}${RESOLVED}">`, RESOLVED, ORIGIN)).toBe(true)
})
it('true for the relative form too (ProseMirror-serialized html)', () => {
expect(htmlReferencesSrc(`<img src="${RESOLVED}">`, RESOLVED, ORIGIN)).toBe(true)
})
// Identity must work for cross-origin srcs too: a doc's image may legitimately point at a CDN or
// badge URL, and dragging THAT node to reorder it must still match — under the previous
// same-origin-path comparison it fell into the duplicate-upload branch instead.
it('matches an external (cross-origin) image against its own exact absolute url', () => {
const EXTERNAL = 'https://cdn.example.com/badge.svg'
expect(htmlReferencesSrc(`<img src="${EXTERNAL}">`, EXTERNAL, ORIGIN)).toBe(true)
expect(
htmlReferencesSrc('<img src="https://cdn.example.com/other.svg">', EXTERNAL, ORIGIN)
).toBe(false)
})
it('false for a different image, empty html, missing resolved src, or cross-origin', () => {
expect(htmlReferencesSrc(`<img src="${ORIGIN}/api/other?fileId=wf_b">`, RESOLVED, ORIGIN)).toBe(
false
)
expect(htmlReferencesSrc('', RESOLVED, ORIGIN)).toBe(false)
expect(htmlReferencesSrc(`<img src="${ORIGIN}${RESOLVED}">`, undefined, ORIGIN)).toBe(false)
expect(
htmlReferencesSrc(`<img src="https://evil.example.com${RESOLVED}">`, RESOLVED, ORIGIN)
).toBe(false)
})
})
@@ -0,0 +1,218 @@
/**
* Extract image `File` objects from a paste/drop payload. Reads `files` first, then falls back to
* `items` — many browsers expose a pasted or copied image (e.g. a screenshot) only through
* `DataTransfer.items` with an empty `files` list, so reading `files` alone misses them.
*/
export function extractImageFiles(transfer: DataTransfer | null): File[] {
if (!transfer) return []
const fromFiles = Array.from(transfer.files).filter((file) => file.type.startsWith('image/'))
if (fromFiles.length > 0) return fromFiles
return Array.from(transfer.items)
.filter((item) => item.kind === 'file' && item.type.startsWith('image/'))
.map((item) => item.getAsFile())
.filter((file): file is File => file !== null)
}
/**
* Matches `<img>` `src` attribute values: double-quoted, single-quoted, or (validly) unquoted per
* the HTML spec — the browser's own clipboard serialization always quotes it, but other producers
* of `text/html` are not obligated to.
*/
const IMG_SRC_RE = /<img\b[^>]*\bsrc\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'>]+))/gi
/** Query params under which the inline route addresses a workspace file. */
const INLINE_ROUTE_QUERY_KEYS = new Set(['key', 'fileId'])
/**
* The page's own origin — clipboard/dataTransfer srcs on any other origin are never ours.
* Deliberately `window.location.origin`, NOT `getBaseUrl()`: the browser serializes a dragged/copied
* `<img>`'s URL against the origin the page is ACTUALLY being viewed on, which can legitimately
* differ from the configured `NEXT_PUBLIC_APP_URL` (localhost dev against a shared env, preview
* deploys, apex-vs-www) — comparing against the configured URL would silently fail on any such
* origin, which is the exact bug class this normalization exists to fix.
*/
function runtimeOrigin(): string {
return typeof window === 'undefined' ? '' : window.location.origin
}
/**
* Normalizes a clipboard/dataTransfer img `src` to an origin-relative `pathname?query`, or `null`
* when it belongs to a different origin. The browser's NATIVE drag/copy enrichment (dragging or
* "Copy Image" on a rendered `<img>`) serializes the ABSOLUTE resolved URL —
* `https://host/api/workspaces/…/inline?…` — while everything the app compares against (persisted
* refs, `resolveImageSrc` output) is origin-relative, so both must be brought into the same space
* before comparing. A cross-origin src must never be treated as ours.
*/
export function toSameOriginPath(src: string, origin = runtimeOrigin()): string | null {
try {
const base = origin || 'http://placeholder'
const parsed = new URL(src, base)
if (parsed.origin !== base) return null
return parsed.pathname + parsed.search
} catch {
return null
}
}
/**
* True for the *display-layer* inline route `resolveImageSrc` (see `use-file-content-source.tsx`)
* rewrites an embed to — workspace-scoped `/api/workspaces/{workspaceId}/files/inline?key=…`/`?fileId=…`
* or public-share-scoped `/api/files/public/{token}/inline?key=…`/`?fileId=…`. This is the shape
* actually rendered into `<img src>`, and so what a same-page copy's `text/html` clipboard payload
* actually contains (absolute — see {@link toSameOriginPath}) — NOT the raw stored reference
* `extractEmbeddedFileRef` (in `@/lib/uploads/utils/embedded-image-ref`) recognizes, which only
* matches the persisted `src` before that rewrite. Checked separately from (rather than folded into)
* `extractEmbeddedFileRef` since that helper is shared with server-side authorization/export code
* operating on persisted content, where this display-only shape should never legitimately appear.
*/
export function isInlineRouteSrc(src: string, origin = runtimeOrigin()): boolean {
const path = toSameOriginPath(src, origin)
if (path === null) return false
try {
const parsed = new URL(path, 'http://placeholder')
if (!parsed.pathname.endsWith('/inline')) return false
for (const key of parsed.searchParams.keys()) {
if (INLINE_ROUTE_QUERY_KEYS.has(key)) return true
}
return false
} catch {
return false
}
}
/**
* Extracts every `<img>` `src` value found in `html`, in document order (may contain duplicates).
*/
export function extractImgSrcs(html: string): string[] {
const srcs: string[] = []
for (const match of html.matchAll(IMG_SRC_RE)) {
const src = match[1] ?? match[2] ?? match[3]
if (src) srcs.push(src)
}
return srcs
}
/**
* True when `html` contains an `<img>` whose `src` is already one of our own hosted workspace file
* references. Copying a rendered `<img>` that's already on the page (e.g. Cmd+C after clicking it to
* select it) makes the browser put BOTH `text/html` (the real serialized node, with its real hosted
* `src`) AND a synthesized image `File` onto the clipboard — the same "drag a web image out" behavior
* that {@link extractImageFiles} alone can't tell apart from a genuinely new external image paste.
* Srcs are normalized origin-relative first ({@link toSameOriginPath}): ProseMirror's own clipboard
* serialization writes the persisted relative src, but the BROWSER's native enrichment writes the
* absolute resolved URL.
*/
export function hasHostedImageHtml(
html: string,
isHostedRef: (src: string) => boolean,
origin = runtimeOrigin()
): boolean {
return extractImgSrcs(html).some((src) => {
const path = toSameOriginPath(src, origin)
return path !== null && (isHostedRef(path) || isInlineRouteSrc(path, origin))
})
}
/** Resolves `src` to a full absolute URL against `origin`, or `null` when unparseable. */
function toAbsoluteUrl(src: string, origin: string): string | null {
try {
return new URL(src, origin || 'http://placeholder').href
} catch {
return null
}
}
/**
* True when `html` contains an `<img>` whose src resolves to the same ABSOLUTE URL as
* `resolvedSrc` (a `resolveImageSrc` output for a node already in this document). This is the
* "that drop is MY dragged image" check for internal drag-reorder: TipTap's node-view dragstart
* bypasses ProseMirror's serialization entirely (no PM `text/html`, no `view.dragging`) but
* NodeSelects the dragged image, and the browser's native enrichment carries the absolute rendered
* URL of exactly that node.
*
* Deliberately compares full absolute URLs rather than same-origin paths ({@link toSameOriginPath}):
* identity is the question here, not hosted-by-us membership — a doc's image may legitimately have a
* cross-origin `src` (a README badge, a CDN image), and dragging THAT node to reorder it must match
* too, or it falls into the duplicate-upload path. Relative and absolute spellings of the same URL
* still compare equal because both sides resolve against the page origin.
*/
export function htmlReferencesSrc(
html: string,
resolvedSrc: string | undefined,
origin = runtimeOrigin()
): boolean {
if (!html || !resolvedSrc) return false
const target = toAbsoluteUrl(resolvedSrc, origin)
if (target === null) return false
return extractImgSrcs(html).some((src) => toAbsoluteUrl(src, origin) === target)
}
/**
* True when a paste or drop should be diverted away from the upload-from-file path — it carries
* exactly one image file, and the accompanying `text/html` shows it's a same-page copy of an
* already-hosted image (see {@link hasHostedImageHtml}) rather than a genuinely new external image.
* Content-based (not `view.dragging`-based, for the drop case): `view.dragging` can go briefly stale
* (cleared up to ~50ms late by ProseMirror's own `dragend` handler when a prior internal drag was
* dropped outside this view) and must never suppress upload of an unrelated, genuinely new file that
* happens to land in that window — this check only reacts to what THIS specific event's `html`
* actually contains. Gated on exactly one file — a genuinely mixed paste/drop (the hosted image plus a
* separate new one) must still upload the new file, not have the whole paste/drop diverted.
*/
export function shouldSkipFileUpload(
images: File[],
html: string,
isHostedRef: (src: string) => boolean
): boolean {
return images.length === 1 && Boolean(html) && hasHostedImageHtml(html, isHostedRef)
}
/** Minimal shape of a ProseMirror image node — just enough to read its type name and attrs. */
interface ImageLikeNode {
type: { name: string }
attrs: Record<string, unknown>
}
/** Minimal shape of a ProseMirror doc — just enough to walk its nodes. */
interface DescendantsDoc {
descendants: (callback: (node: ImageLikeNode) => boolean | undefined) => void
}
/**
* Finds the first `image` node already in `doc` whose *rendered* src (`resolveImageSrc(node.attrs.src)`)
* matches one of `targetSrcs`, and returns its attrs — a defensive copy, safe to hand straight to
* `insertContentAt`. Returns `null` if no match is found (e.g. the source node was deleted, or this is
* genuinely a different document than the one the html was copied from).
*
* Used to clone a same-page copy/drag of an already-hosted image faithfully — the exact persisted
* `src` (and every other attribute: width, height, href, title…) — rather than re-deriving a node from
* the clipboard/dataTransfer `html`, whose `src` is `resolveImageSrc`'s rewritten *display* URL, not the
* real persisted one. Inserting a node built from that display URL would bake it into the document,
* which public share/export/referenced-by-doc tracking don't recognize (they only match the persisted
* shape) — this lookup avoids ever constructing such a node in the first place. Both sides of the
* comparison are normalized origin-relative ({@link toSameOriginPath}): the clipboard html may carry
* the browser's absolute URLs.
*/
export function findHostedImageAttrs(
doc: DescendantsDoc,
targetSrcs: string[],
resolveImageSrc: (src: string | undefined) => string | undefined,
origin = runtimeOrigin()
): Record<string, unknown> | null {
const targets = new Set(
targetSrcs.map((src) => toSameOriginPath(src, origin)).filter((p): p is string => p !== null)
)
let found: Record<string, unknown> | null = null
doc.descendants((node) => {
if (found) return false
if (node.type.name === 'image') {
const resolved = resolveImageSrc(node.attrs.src as string | undefined)
const resolvedPath = resolved ? toSameOriginPath(resolved, origin) : null
if (resolvedPath && targets.has(resolvedPath)) {
found = { ...node.attrs }
return false
}
}
return true
})
return found
}
@@ -0,0 +1,42 @@
/**
* @vitest-environment jsdom
*/
import { describe, expect, it } from 'vitest'
import {
createPublicFileContentSource,
createWorkspaceFileContentSource,
} from '@/hooks/use-file-content-source'
const KEY = 'workspace/W1/1700000000000-deadbeefdeadbeef-photo.png'
const ENCODED = encodeURIComponent(KEY)
describe('content-source resolveImageSrc', () => {
it('in-app source rewrites embeds to the workspace-scoped inline route', () => {
const src = createWorkspaceFileContentSource('ws-1')
expect(src.resolveImageSrc(`/api/files/serve/${ENCODED}?context=workspace`)).toBe(
`/api/workspaces/ws-1/files/inline?key=${encodeURIComponent(KEY)}`
)
expect(src.resolveImageSrc('/api/files/view/wf_abc')).toBe(
'/api/workspaces/ws-1/files/inline?fileId=wf_abc'
)
})
it('public source rewrites embeds to the token-scoped inline route', () => {
const src = createPublicFileContentSource('tok_1', '/api/files/public/tok_1/content')
expect(src.resolveImageSrc('/api/files/view/wf_abc')).toBe(
'/api/files/public/tok_1/inline?fileId=wf_abc'
)
})
it('passes external/data srcs through unchanged in both sources', () => {
const ws = createWorkspaceFileContentSource('ws-1')
const pub = createPublicFileContentSource('tok_1', '/c')
expect(ws.resolveImageSrc('https://cdn.example.com/a.png')).toBe(
'https://cdn.example.com/a.png'
)
expect(pub.resolveImageSrc('https://cdn.example.com/a.png')).toBe(
'https://cdn.example.com/a.png'
)
expect(ws.resolveImageSrc(undefined)).toBeUndefined()
})
})
@@ -0,0 +1,325 @@
import { useEffect, useRef, useState } from 'react'
import { cn } from '@sim/emcn'
import type { JSONContent } from '@tiptap/core'
import { Image } from '@tiptap/extension-image'
import { NodeSelection, Plugin } from '@tiptap/pm/state'
import type { ReactNodeViewProps } from '@tiptap/react'
import { NodeViewWrapper, ReactNodeViewRenderer } from '@tiptap/react'
import { useFileContentSource } from '@/hooks/use-file-content-source'
import { normalizeLinkHref } from './markdown-fidelity'
import { useEditorEditable } from './use-editor-editable'
const MIN_WIDTH = 64
/**
* A markdown linked image `[![alt](src "t")](href "t2")` — an image wrapped in a link, the canonical
* form of a README badge. `@tiptap/markdown` parses this as a link mark over an image node, but an
* image node can't carry inline marks, so the wrapping link is silently dropped. We instead tokenize
* the whole construct ourselves and hang the link target on the image node's `href` attribute, so it
* round-trips losslessly (and the file stays editable rather than opening read-only).
*/
const LINKED_IMAGE_RE =
/^\[!\[([^\]]*)\]\(([^)\s]+)(?:\s+"([^"]*)")?\)\]\(([^)\s]+)(?:\s+"([^"]*)")?\)/
/** Escape a value for safe interpolation into a double-quoted HTML attribute. */
function escapeAttr(value: string): string {
return value
.replace(/&/g, '&amp;')
.replace(/"/g, '&quot;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
}
/**
* Serialize an image to markdown when it has no explicit size, and to an HTML `<img>` tag when
* it does — standard markdown has no width syntax, so a resized image must round-trip as HTML to
* preserve its dimensions. Unsized images stay clean `![alt](src)`. An image with an `href` is
* wrapped in a markdown link so a linked badge round-trips as `[![alt](src)](href)`.
*
* A *sized **and** linked* image is the one case markdown can't represent: the linked-image tokenizer
* only recognizes `[![alt](src)](href)`, so emitting `[<img …>](href)` would silently drop the link on
* reparse (and the round-trip-safety probe wouldn't catch it). We keep the link and fall back to the
* unsized `[![alt](src)](href)` form — the link matters more than the exact dimensions for a badge.
*/
function imageMarkdown(node: JSONContent): string {
const attrs = node.attrs ?? {}
const src = typeof attrs.src === 'string' ? attrs.src : ''
const alt = typeof attrs.alt === 'string' ? attrs.alt : ''
const title = typeof attrs.title === 'string' ? attrs.title : ''
const href = typeof attrs.href === 'string' ? attrs.href : ''
const hrefTitle = typeof attrs.hrefTitle === 'string' ? attrs.hrefTitle : ''
const width = attrs.width
const height = attrs.height
let image: string
if ((width || height) && !href) {
const parts = [`src="${escapeAttr(src)}"`]
if (alt) parts.push(`alt="${escapeAttr(alt)}"`)
if (title) parts.push(`title="${escapeAttr(title)}"`)
if (width) parts.push(`width="${escapeAttr(String(width))}"`)
if (height) parts.push(`height="${escapeAttr(String(height))}"`)
image = `<img ${parts.join(' ')}>`
} else {
// Escape so an alt with `]`/`[` or a title with `"` can't break out of the `![…](… "…")` syntax
// and corrupt the round-trip; a src with spaces/parens goes in angle brackets (CommonMark).
const titlePart = title ? ` "${title.replace(/["\\]/g, '\\$&')}"` : ''
const safeSrc = /[\s()]/.test(src) ? `<${src}>` : src
image = `![${alt.replace(/[\\[\]]/g, '\\$&')}](${safeSrc}${titlePart})`
}
if (!href) return image
// Escape `"`/`\` so an href title can't break out of the `[…](href "title")` syntax (mirrors the
// image title escaping above).
const hrefTitlePart = hrefTitle ? ` "${hrefTitle.replace(/["\\]/g, '\\$&')}"` : ''
return `[${image}](${href}${hrefTitlePart})`
}
interface MarkdownImageToken {
/** Set only by our linked-image tokenizer; absent on the built-in `![](src)` token. */
src?: string
alt?: string
title?: string | null
/** Built-in image token holds the source URL here; our linked token holds the link target. */
href?: string
hrefTitle?: string | null
/** Built-in image token holds the alt text here. */
text?: string
}
/** Map both the built-in image token and our linked-image token onto the image node's attributes. */
function parseImageToken(token: MarkdownImageToken): JSONContent {
const isLinked = typeof token.src === 'string'
return {
type: 'image',
attrs: isLinked
? {
src: token.src,
alt: token.alt ?? '',
title: token.title ?? null,
href: token.href ?? null,
hrefTitle: token.hrefTitle ?? null,
}
: {
src: token.href ?? '',
alt: token.text ?? '',
title: token.title ?? null,
href: null,
hrefTitle: null,
},
}
}
const widthAttr = {
default: null,
parseHTML: (element: HTMLElement) => element.getAttribute('width'),
renderHTML: (attributes: Record<string, unknown>) =>
attributes.width ? { width: String(attributes.width) } : {},
}
const heightAttr = {
default: null,
parseHTML: (element: HTMLElement) => element.getAttribute('height'),
renderHTML: (attributes: Record<string, unknown>) =>
attributes.height ? { height: String(attributes.height) } : {},
}
/** Link target of a linked image — markdown-only state, never emitted as an HTML `<img>` attribute. */
const hrefAttr = { default: null, rendered: false }
const hrefTitleAttr = { default: null, rendered: false }
/**
* Image node that carries optional `width`/`height` (serialized as an HTML `<img>` tag) and an
* optional `href`/`hrefTitle` (a wrapping markdown link, for badges). Shared by the headless
* round-trip path (no node view) and the live {@link ResizableImage}.
*/
export const MarkdownImage = Image.extend({
addAttributes() {
return {
...this.parent?.(),
width: widthAttr,
height: heightAttr,
href: hrefAttr,
hrefTitle: hrefTitleAttr,
}
},
markdownTokenizer: {
name: 'image',
level: 'inline',
start: (src: string) => src.indexOf('[!['),
tokenize: (src: string): (MarkdownImageToken & { type: string; raw: string }) | undefined => {
const match = LINKED_IMAGE_RE.exec(src)
if (!match) return undefined
return {
type: 'image',
raw: match[0],
alt: match[1] ?? '',
src: match[2],
title: match[3] ?? null,
href: match[4],
hrefTitle: match[5] ?? null,
}
},
},
parseMarkdown: parseImageToken,
renderMarkdown: imageMarkdown,
})
/**
* Drag-to-resize image node view (handle at the bottom-right, revealed on selection). Dragging
* commits the new pixel width to the `width` attribute, which serializes to `<img width>`.
*/
function ResizableImageView({ node, updateAttributes, selected, editor }: ReactNodeViewProps) {
const source = useFileContentSource()
const imageRef = useRef<HTMLImageElement>(null)
const dragAbortRef = useRef<AbortController | null>(null)
const dragWidthRef = useRef<number | null>(null)
const [dragging, setDragging] = useState(false)
/** Live width during a resize drag; kept out of the doc so the whole resize is one undo step. */
const [dragWidth, setDragWidth] = useState<number | null>(null)
/** Whether the current src failed to load; reset on src change so a retried/edited src can load. */
const [failed, setFailed] = useState(false)
const attrs = node.attrs as {
src?: string
alt?: string
title?: string
width?: string | null
href?: string | null
}
useEffect(() => () => dragAbortRef.current?.abort(), [])
useEffect(() => setFailed(false), [attrs.src])
const startResize = (event: React.PointerEvent) => {
event.preventDefault()
const image = imageRef.current
if (!image) return
const startX = event.clientX
const startWidth = image.offsetWidth
setDragging(true)
dragAbortRef.current?.abort()
const controller = new AbortController()
dragAbortRef.current = controller
const { signal } = controller
window.addEventListener(
'pointermove',
(move) => {
const next = Math.max(MIN_WIDTH, Math.round(startWidth + (move.clientX - startX)))
dragWidthRef.current = next
setDragWidth(next)
},
{ signal }
)
const finish = () => {
const finalWidth = dragWidthRef.current
setDragging(false)
setDragWidth(null)
dragWidthRef.current = null
controller.abort()
if (finalWidth !== null) updateAttributes({ width: String(finalWidth) })
}
window.addEventListener('pointerup', finish, { signal })
window.addEventListener('pointercancel', finish, { signal })
}
const committedWidth = attrs.width
? /^\d+$/.test(attrs.width)
? `${attrs.width}px`
: attrs.width
: undefined
const widthStyle =
dragWidth !== null
? { width: `${dragWidth}px` }
: committedWidth
? { width: committedWidth }
: undefined
// Sanitize the linked-image target before rendering the anchor — a parsed markdown href is
// untrusted and could be `javascript:`/`data:`; an unsafe value drops the link (image only).
const safeHref = normalizeLinkHref(typeof attrs.href === 'string' ? attrs.href : '')
// Read-only: no drag-to-reorder and no resize handle — both call updateAttributes / dispatch a move,
// mutating a doc that must not change. The image still renders (and follows its link on click).
const editable = useEditorEditable(editor)
const image = (
<img
ref={imageRef}
src={source.resolveImageSrc(attrs.src)}
alt={attrs.alt ?? ''}
title={attrs.title ?? undefined}
// When editable, the image itself is the drag handle — grab anywhere on it to reorder. (The node
// view's wrapper is forced `draggable=false` by the React renderer, so the handle must be a child;
// the resize button sits outside this element, so it keeps its own pointer behavior.)
draggable={editable}
data-drag-handle={editable ? '' : undefined}
style={widthStyle}
onError={() => setFailed(true)}
onLoad={() => setFailed(false)}
className={cn(
'block max-w-full rounded-lg border border-[var(--border)]',
editable && 'cursor-grab',
failed &&
'min-h-[72px] min-w-[140px] bg-[var(--surface-5)] p-3 text-[var(--text-muted)] text-caption'
)}
/>
)
return (
<NodeViewWrapper className='relative my-4 inline-block leading-none'>
{safeHref ? (
// The editor's handleClick is the sole navigator (gated on editable/modifier, like text links
// via openOnClick:false): prevent the anchor's own navigation so a plain click in edit mode
// places the caret / selects the node instead of opening a tab.
<a
href={safeHref}
target='_blank'
rel='noopener noreferrer'
className='block'
onClick={(event) => event.preventDefault()}
>
{image}
</a>
) : (
image
)}
{editable && (selected || dragging) && (
<button
type='button'
aria-label='Resize image'
onPointerDown={startResize}
className='absolute right-1 bottom-1 size-3 cursor-nwse-resize rounded-[3px] border border-[var(--bg)] bg-[var(--brand-secondary)]'
/>
)}
</NodeViewWrapper>
)
}
/** Live image node with the drag-to-resize view; same schema + markdown output as the headless one. */
export const ResizableImage = MarkdownImage.extend({
addNodeView() {
return ReactNodeViewRenderer(ResizableImageView)
},
/**
* Guarantee a plain click on the image forms a node selection. The image body is also a native drag
* source (grab-anywhere reorder), and while prosemirror-view ≥1.32.4 no longer implicitly selects on
* drag, the reverse — a click reliably selecting — is not guaranteed for an atom whose body competes
* with the drag gesture (see the ProseMirror "Draggable and NodeViews" discussion and TipTap #4526).
* Selecting here makes it deterministic while leaving drag-to-reorder intact. Read-only clicks and
* modified clicks (Cmd/Ctrl to follow a linked badge, Shift/Alt to extend) fall through to the editor's
* `handleClick` / default behavior.
*/
addProseMirrorPlugins() {
const nodeName = this.name
return [
new Plugin({
props: {
handleClickOn(view, _pos, node, nodePos, event) {
if (!view.editable || node.type.name !== nodeName) return false
if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return false
view.dispatch(view.state.tr.setSelection(NodeSelection.create(view.state.doc, nodePos)))
return true
},
},
}),
]
},
})
@@ -0,0 +1,427 @@
/**
* @vitest-environment jsdom
*
* The leaf-selection arrow shortcuts (ArrowUp/ArrowDown → select an adjacent divider/image) run at a
* high priority, so they must yield while a `/` or `@` suggestion menu is open — otherwise the arrow
* selects the adjacent node instead of moving the menu selection. These assert the plugin state the
* keymap's `isSuggestionMenuOpen` guard reads flips on when a menu opens.
*/
import { Editor } from '@tiptap/core'
import { GapCursor } from '@tiptap/pm/gapcursor'
import { AllSelection, NodeSelection } from '@tiptap/pm/state'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createMarkdownEditorExtensions } from './editor-extensions'
import { MENTION_PLUGIN_KEY } from './mention'
import { SLASH_COMMAND_PLUGIN_KEY } from './slash-command/slash-command'
function editorWith(content: string): Editor {
return new Editor({ extensions: createMarkdownEditorExtensions({ placeholder: '' }), content })
}
/** Block-type sequence of the top-level doc nodes. */
function blockShape(editor: Editor): string[] {
const shape: string[] = []
editor.state.doc.forEach((node) => shape.push(node.type.name))
return shape
}
/** Position of the first node of `type`, or -1. */
function firstPosOf(editor: Editor, type: string): number {
let pos = -1
editor.state.doc.descendants((node, p) => {
if (pos < 0 && node.type.name === type) pos = p
})
return pos
}
function pressKey(editor: Editor, key: string): void {
editor.view.dom.dispatchEvent(
new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true })
)
}
function pressBackspace(editor: Editor): void {
pressKey(editor, 'Backspace')
}
/** Empties the item whose text is `word` (caret left at its start), the state before a boundary key. */
function emptyItem(editor: Editor, word: string): void {
let from = -1
let to = -1
editor.state.doc.descendants((node, pos) => {
if (from < 0 && node.isText && node.text === word) {
from = pos
to = pos + word.length
}
})
editor.commands.setTextSelection({ from, to })
editor.commands.deleteSelection()
}
/** Serialized markdown after re-parsing it once — equal to `getMarkdown()` only if it round-trips. */
function markdownRoundTrip(editor: Editor): { md: string; reparsed: string } {
const md = editor.getMarkdown()
editor.commands.setContent(md, { contentType: 'markdown' })
return { md, reparsed: editor.getMarkdown() }
}
describe('suggestion-aware arrow keymap', () => {
beforeEach(() => {
// The suggestion render lifecycle uses these; jsdom lacks them.
vi.stubGlobal(
'ResizeObserver',
class {
observe() {}
unobserve() {}
disconnect() {}
}
)
Element.prototype.scrollIntoView = vi.fn()
document.elementFromPoint = vi.fn(() => null)
})
it('flags the mention menu active when `@` is typed before a divider', () => {
const editor = editorWith('<p></p><hr>')
editor.commands.focus()
editor.commands.insertContent('@gma')
expect(MENTION_PLUGIN_KEY.getState(editor.state)?.active).toBe(true)
editor.destroy()
})
it('flags the slash menu active when `/` is typed', () => {
const editor = editorWith('<p></p>')
editor.commands.focus()
editor.commands.insertContent('/')
expect(SLASH_COMMAND_PLUGIN_KEY.getState(editor.state)?.active).toBe(true)
editor.destroy()
})
it('keeps both menus inactive on plain text', () => {
const editor = editorWith('<p>hello</p><hr>')
editor.commands.focus()
expect(MENTION_PLUGIN_KEY.getState(editor.state)?.active).toBe(false)
expect(SLASH_COMMAND_PLUGIN_KEY.getState(editor.state)?.active).toBe(false)
editor.destroy()
})
})
describe('divider Backspace', () => {
beforeEach(() => {
Element.prototype.scrollIntoView = vi.fn()
})
it('removes the empty line between two dividers and selects the higher divider', () => {
const editor = editorWith('<p>before</p><hr><p></p><hr><p>after</p>')
editor.commands.focus()
let emptyPos = -1
editor.state.doc.descendants((node, pos) => {
if (emptyPos < 0 && node.type.name === 'paragraph' && node.content.size === 0)
emptyPos = pos + 1
})
editor.commands.setTextSelection(emptyPos)
pressBackspace(editor)
expect(blockShape(editor)).toEqual([
'paragraph',
'horizontalRule',
'horizontalRule',
'paragraph',
])
const { selection } = editor.state
expect(selection instanceof NodeSelection).toBe(true)
// The selected divider is the higher (first) one, not the lower.
expect(selection.from).toBe(firstPosOf(editor, 'horizontalRule'))
editor.destroy()
})
it('selects the divider when Backspace is pressed at the start of a non-empty block below it', () => {
const editor = editorWith('<p>before</p><hr><p>text</p>')
editor.commands.focus()
let textStart = -1
editor.state.doc.descendants((node, pos) => {
if (textStart < 0 && node.type.name === 'paragraph' && node.textContent === 'text')
textStart = pos + 1
})
editor.commands.setTextSelection(textStart)
pressBackspace(editor)
const { selection } = editor.state
expect(selection instanceof NodeSelection).toBe(true)
expect((selection as NodeSelection).node.type.name).toBe('horizontalRule')
// The block is untouched — the divider is only highlighted, not deleted.
expect(blockShape(editor)).toEqual(['paragraph', 'horizontalRule', 'paragraph'])
editor.destroy()
})
it('select-all spans the whole document, dividers included', () => {
const editor = editorWith('<p>a</p><hr><p>b</p><hr><p>c</p>')
editor.commands.selectAll()
const { selection, doc } = editor.state
expect(selection instanceof AllSelection).toBe(true)
expect(selection.from).toBe(0)
expect(selection.to).toBe(doc.content.size)
editor.destroy()
})
})
describe('empty wrapped-block Backspace', () => {
beforeEach(() => {
Element.prototype.scrollIntoView = vi.fn()
})
it.each([
['bullet middle', '- one\n- two\n- three', 'two', '- one\n- three'],
['bullet first', '- one\n- two\n- three', 'one', '- two\n- three'],
['bullet last', '- one\n- two', 'two', '- one'],
['ordered middle', '1. one\n2. two\n3. three', 'two', '1. one\n2. three'],
['task middle', '- [ ] one\n- [ ] two\n- [ ] three', 'two', '- [ ] one\n- [ ] three'],
['blockquote middle', '> one\n>\n> two\n>\n> three', 'two', '> one\n>\n> three'],
['nested item', '- one\n - two\n- three', 'two', '- one\n- three'],
])(
'removes the emptied %s cleanly — one container, no stray paragraph, round-trips',
(_label, markdown, word, expected) => {
const editor = editorWith('')
editor.commands.setContent(markdown, { contentType: 'markdown' })
editor.commands.focus()
emptyItem(editor, word)
pressBackspace(editor)
const { md, reparsed } = markdownRoundTrip(editor)
expect(md.trim()).toBe(expected)
expect(reparsed).toBe(md)
editor.destroy()
}
)
it('leaves a gap cursor — never a NodeSelection — when the removed bullet was followed by an image at doc start', () => {
// Regression: `Selection.near` after the delete silently NodeSelected the following image, so a
// second Backspace while "clearing the bullet" deleted the image (and typing would have replaced
// it). The selection left behind must never make the next keystroke destructive.
const editor = editorWith({
type: 'doc',
content: [
{ type: 'bulletList', content: [{ type: 'listItem', content: [{ type: 'paragraph' }] }] },
{ type: 'image', attrs: { src: '/api/files/view/wf_img', alt: 'photo' } },
],
})
editor.commands.setTextSelection(3)
pressBackspace(editor)
expect(blockShape(editor)).toEqual(['image', 'paragraph'])
expect(editor.state.selection).not.toBeInstanceOf(NodeSelection)
pressBackspace(editor)
expect(blockShape(editor)).toEqual(['image', 'paragraph'])
editor.destroy()
})
it('does not crash on Backspace from a gap cursor (top-level selection, depth 0)', () => {
// Regression: `$from.before($from.depth)` with a gap cursor's depth of 0 threw
// "RangeError: There is no position before the top-level node" — reachable whenever a gap
// cursor sits between two leaves (the `data-gap-between-leaves` state) and Backspace is pressed.
const editor = editorWith('<hr><hr>')
const gapPos = editor.state.doc.firstChild ? editor.state.doc.firstChild.nodeSize : 1
editor.view.dispatch(
editor.state.tr.setSelection(new GapCursor(editor.state.doc.resolve(gapPos)))
)
expect(editor.state.selection).toBeInstanceOf(GapCursor)
expect(() => pressBackspace(editor)).not.toThrow()
// TipTap appends a trailing paragraph after the final leaf; the dividers must both survive.
expect(blockShape(editor)).toEqual(['horizontalRule', 'horizontalRule', 'paragraph'])
editor.destroy()
})
it('never NodeSelects a leaf BEFORE the removed bullet either (findFrom textOnly skips atoms)', () => {
// `Selection.findFrom($gap, -1, true)` cannot return a NodeSelection: with textOnly,
// prosemirror-state's findSelectionIn skips atoms entirely (`!text && isSelectable`). With an
// image directly before the emptied bullet and no textblock behind it, the backward search
// returns null and the gap-cursor branch takes over — the image is never silently selected.
const editor = editorWith({
type: 'doc',
content: [
{ type: 'image', attrs: { src: '/api/files/view/wf_img', alt: 'photo' } },
{ type: 'bulletList', content: [{ type: 'listItem', content: [{ type: 'paragraph' }] }] },
],
})
editor.commands.setTextSelection(4)
expect(editor.state.selection.$from.parent.type.name).toBe('paragraph')
pressBackspace(editor)
expect(blockShape(editor)).toEqual(['image', 'paragraph'])
expect(editor.state.selection).not.toBeInstanceOf(NodeSelection)
pressBackspace(editor)
expect(blockShape(editor)).toEqual(['image', 'paragraph'])
editor.destroy()
})
it('does not crash on Backspace from a gap cursor at the very start of the doc', () => {
// Depth-0 + offset-0 is the worst case: our old code threw before(0), and TipTap's blockquote
// handler crashes on $from.node(-1) if the key falls through — so it must be consumed.
const editor = editorWith({
type: 'doc',
content: [{ type: 'image', attrs: { src: '/api/files/view/wf_img', alt: 'photo' } }],
})
editor.view.dispatch(editor.state.tr.setSelection(new GapCursor(editor.state.doc.resolve(0))))
expect(editor.state.selection).toBeInstanceOf(GapCursor)
expect(() => pressBackspace(editor)).not.toThrow()
expect(blockShape(editor)).toEqual(['image', 'paragraph'])
editor.destroy()
})
it('still prefers the previous textblock caret when one exists (image after the bullet untouched)', () => {
const editor = editorWith({
type: 'doc',
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'hello' }] },
{ type: 'bulletList', content: [{ type: 'listItem', content: [{ type: 'paragraph' }] }] },
{ type: 'image', attrs: { src: '/api/files/view/wf_img', alt: 'photo' } },
],
})
editor.commands.setTextSelection(10)
pressBackspace(editor)
expect(blockShape(editor)).toEqual(['paragraph', 'image', 'paragraph'])
expect(editor.state.selection.empty).toBe(true)
expect(editor.state.selection).not.toBeInstanceOf(NodeSelection)
expect(editor.state.selection.$from.parent.textContent).toBe('hello')
editor.destroy()
})
})
describe('empty list-item Enter', () => {
beforeEach(() => {
Element.prototype.scrollIntoView = vi.fn()
})
it('removes an empty MIDDLE item instead of splitting the list into a stranded paragraph', () => {
const editor = editorWith('')
editor.commands.setContent('- one\n- two\n- three', { contentType: 'markdown' })
editor.commands.focus()
emptyItem(editor, 'two')
pressKey(editor, 'Enter')
const { md, reparsed } = markdownRoundTrip(editor)
expect(md.trim()).toBe('- one\n- three')
expect(reparsed).toBe(md)
editor.destroy()
})
it('leaves an empty TRAILING item to the default (exits the list)', () => {
const editor = editorWith('')
editor.commands.setContent('- one\n- two', { contentType: 'markdown' })
editor.commands.focus()
emptyItem(editor, 'two')
pressKey(editor, 'Enter')
const list = editor.getJSON().content?.find((node) => node.type === 'bulletList')
expect(list?.content).toHaveLength(1)
expect(editor.getJSON().content?.some((node) => node.type === 'paragraph')).toBe(true)
editor.destroy()
})
})
describe('verbatim block boundary (isolating)', () => {
beforeEach(() => {
Element.prototype.scrollIntoView = vi.fn()
})
function caretIntoNode(editor: Editor, nodeType: string): void {
editor.state.doc.descendants((node, pos) => {
if (node.type.name === nodeType) editor.commands.setTextSelection(pos + 1)
})
}
it.each([
['footnote definition', 'body text[^x]\n\n[^x]: the note', 'footnoteDef'],
['raw HTML block', 'body\n\n<div>\nhello\n</div>', 'rawHtmlBlock'],
])(
'Backspace at the start of a %s does not merge across its boundary and destroy it',
(_label, markdown, nodeType) => {
const editor = editorWith('')
editor.commands.setContent(markdown, { contentType: 'markdown' })
editor.commands.focus()
expect(blockShape(editor)).toContain(nodeType)
caretIntoNode(editor, nodeType)
pressBackspace(editor)
expect(blockShape(editor)).toContain(nodeType)
editor.destroy()
}
)
})
describe('block reordering (Mod-Shift-Arrow)', () => {
beforeEach(() => {
Element.prototype.scrollIntoView = vi.fn()
})
function caretInto(editor: Editor, word: string): void {
editor.state.doc.descendants((node, pos) => {
if (node.isText && node.text?.includes(word)) editor.commands.setTextSelection(pos + 1)
})
}
it('moves the current top-level block up, carrying the caret', () => {
const editor = editorWith('')
editor.commands.setContent('# One\n\nTwo para\n\n- item', { contentType: 'markdown' })
editor.commands.focus()
caretInto(editor, 'Two')
editor.commands.moveBlockUp()
expect(editor.getMarkdown().trim().startsWith('Two para')).toBe(true)
editor.destroy()
})
it('moves the current top-level block down', () => {
const editor = editorWith('')
editor.commands.setContent('# One\n\nTwo para', { contentType: 'markdown' })
editor.commands.focus()
caretInto(editor, 'One')
editor.commands.moveBlockDown()
expect(editor.getMarkdown().trim().startsWith('Two para')).toBe(true)
editor.destroy()
})
it.each([
['up', '# One\n\nabcdef'],
['down', 'abcdef\n\n# Two'],
])('keeps the caret at its original offset after moving %s (no off-by-one)', (direction, md) => {
const editor = editorWith('')
editor.commands.setContent(md, { contentType: 'markdown' })
editor.commands.focus()
let textPos = -1
editor.state.doc.descendants((node, pos) => {
if (node.isText && node.text === 'abcdef') textPos = pos
})
editor.commands.setTextSelection(textPos + 3)
if (direction === 'up') editor.commands.moveBlockUp()
else editor.commands.moveBlockDown()
const at = editor.state.selection.from
expect(editor.state.doc.textBetween(at - 1, at)).toBe('c')
expect(editor.state.doc.textBetween(at, at + 1)).toBe('d')
editor.destroy()
})
it('is a no-op at the top edge and keeps a moved list intact', () => {
const top = editorWith('')
top.commands.setContent('# One\n\nTwo', { contentType: 'markdown' })
top.commands.focus()
caretInto(top, 'One')
top.commands.moveBlockUp()
expect(top.getMarkdown().trim().startsWith('# One')).toBe(true)
top.destroy()
const list = editorWith('')
list.commands.setContent('- a\n- b\n\npara', { contentType: 'markdown' })
list.commands.focus()
caretInto(list, 'para')
list.commands.moveBlockUp()
expect(list.getMarkdown().trim()).toBe('para\n\n- a\n- b')
list.destroy()
})
})
@@ -0,0 +1,268 @@
import type { Editor } from '@tiptap/core'
import { Extension } from '@tiptap/core'
import { GapCursor } from '@tiptap/pm/gapcursor'
import type { ResolvedPos } from '@tiptap/pm/model'
import { NodeSelection, Plugin, PluginKey, Selection } from '@tiptap/pm/state'
import { Decoration, DecorationSet } from '@tiptap/pm/view'
import { MENTION_PLUGIN_KEY } from './mention'
import { SLASH_COMMAND_PLUGIN_KEY } from './slash-command/slash-command'
/** Leaf nodes that have no text position, so they can only be reached as a NodeSelection. */
const SELECTABLE_LEAVES = new Set(['horizontalRule', 'image'])
/**
* Wrapper nodes whose empty child a boundary key must remove cleanly rather than lift. Lifting an empty
* block out of one of these splits the container in two and strands an empty paragraph — a visible gap
* that also fails to round-trip through markdown (see {@link removeEmptyWrappedBlock}).
*/
const WRAPPER_TYPES = new Set(['listItem', 'taskItem', 'blockquote'])
/** Item node types a list is built from, used to detect an empty item's position within its list. */
const LIST_ITEM_TYPES = new Set(['listItem', 'taskItem'])
/** True when the resolved position sits anywhere inside a {@link WRAPPER_TYPES} ancestor. */
function isInsideWrapper($from: ResolvedPos): boolean {
for (let depth = $from.depth - 1; depth >= 1; depth--) {
if (WRAPPER_TYPES.has($from.node(depth).type.name)) return true
}
return false
}
/**
* Removes the empty textblock at `$from`, deleting up through the outermost ancestor it is the sole
* child of, then places the caret at the end of the preceding block. This keeps a list or blockquote
* whole when its middle/first/last item is emptied — where ProseMirror's default lift would split the
* container and strand an empty paragraph (a visible gap, and markdown that re-parses to a different
* document). Walking up while `childCount === 1` deletes the whole now-empty wrapper (the emptied list
* item, not just its paragraph) so no orphan `<li>` or empty continuation line is left behind.
*
* The selection left behind must be a CARET, never a NodeSelection: `Selection.near` can silently
* land a NodeSelection on an adjacent leaf (deleting the sole bullet at the top of a doc whose next
* block is an image selected that image), turning the user's next keystroke destructive — a second
* Backspace while "clearing the bullet" deleted the image, and typing would have replaced it. So:
* end of the previous textblock first; else a gap cursor at the deletion point when the neighbour is
* a leaf (typing there inserts a new block where the emptied one was, instead of replacing the leaf);
* else the next textblock.
*/
function removeEmptyWrappedBlock(editor: Editor, $from: ResolvedPos): boolean {
let depth = $from.depth
while (depth > 1 && $from.node(depth - 1).childCount === 1) depth--
const start = $from.before(depth)
const end = $from.after(depth)
return editor.commands.command(({ tr, dispatch }) => {
if (dispatch) {
tr.delete(start, end)
const $gap = tr.doc.resolve(start)
tr.setSelection(
Selection.findFrom($gap, -1, true) ??
(isLeafGap($gap) ? new GapCursor($gap) : null) ??
Selection.findFrom($gap, 1, true) ??
Selection.near($gap, -1)
)
dispatch(tr.scrollIntoView())
}
return true
})
}
/**
* True when `$pos` is a block boundary a gap cursor is valid at in this schema: the following node is
* a selectable leaf (divider/image) and there is nothing before it, or another leaf — i.e. no textblock
* on either side for a normal caret to land in.
*/
function isLeafGap($pos: ResolvedPos): boolean {
const after = $pos.nodeAfter
if (!after || !SELECTABLE_LEAVES.has(after.type.name)) return false
const before = $pos.nodeBefore
return !before || SELECTABLE_LEAVES.has(before.type.name)
}
/**
* True while a `/` or `@` suggestion menu is open. Arrow keys must reach that menu's own handler, so
* the leaf-selection shortcuts below yield rather than stealing the key to select an adjacent divider.
*/
function isSuggestionMenuOpen(editor: Editor): boolean {
const { state } = editor
return (
MENTION_PLUGIN_KEY.getState(state)?.active === true ||
SLASH_COMMAND_PLUGIN_KEY.getState(state)?.active === true
)
}
/**
* Selects the leaf (divider/image) immediately across `boundary` in `direction`, or returns false if
* the neighbour isn't a selectable leaf — the shared tail of both arrow handlers below.
*/
function selectLeafAcross(editor: Editor, boundary: number, direction: 'up' | 'down'): boolean {
const resolved = editor.state.doc.resolve(boundary)
const adjacent = direction === 'up' ? resolved.nodeBefore : resolved.nodeAfter
if (!adjacent || !SELECTABLE_LEAVES.has(adjacent.type.name)) return false
return editor.commands.setNodeSelection(
direction === 'up' ? boundary - adjacent.nodeSize : boundary
)
}
/**
* Arrowing off the edge of a textblock toward an adjacent divider or image selects that node
* (a NodeSelection), giving keyboard parity with clicking it. Without this the gap cursor swallows
* the arrow and the node can never be selected — or deleted — from the keyboard.
*/
function selectAdjacentLeaf(editor: Editor, direction: 'up' | 'down'): boolean {
const { selection } = editor.state
if (!selection.empty || !editor.view.endOfTextblock(direction)) return false
const { $from } = selection
const boundary = direction === 'up' ? $from.before($from.depth) : $from.after($from.depth)
return selectLeafAcross(editor, boundary, direction)
}
/**
* When a divider/image is already selected, arrowing toward an immediately-adjacent divider/image
* selects that one directly instead of stopping on the gap cursor between them — so stepping through a
* run of dividers is one press each. A non-leaf neighbour (a textblock) falls through to the default,
* which moves the caret into it.
*/
function selectAdjacentSelectedLeaf(editor: Editor, direction: 'up' | 'down'): boolean {
const { selection } = editor.state
if (!(selection instanceof NodeSelection) || !SELECTABLE_LEAVES.has(selection.node.type.name)) {
return false
}
const boundary = direction === 'up' ? selection.from : selection.to
return selectLeafAcross(editor, boundary, direction)
}
/**
* Editor-specific keyboard behavior layered on top of StarterKit's defaults:
*
* - **Backspace** at the start of a heading reverts it to a paragraph (ProseMirror's default joins or
* no-ops, stranding the heading style; a second Backspace then merges as usual). At the start of an
* *empty block inside a list item, task item, or blockquote* it removes that whole emptied wrapper via
* {@link removeEmptyWrappedBlock} instead of ProseMirror's default lift — lifting an empty item out of
* the middle of a list/quote splits the container in two and strands an empty paragraph (a visible gap
* that also re-parses to a different markdown document), while the default `joinBackward` alternately
* no-ops on nested items (leaving them stuck) or merges an empty continuation paragraph into the
* previous item. At the start of a block whose previous sibling is a divider or image, where
* ProseMirror's `joinBackward` can't cross the leaf and no-ops: an *empty* block is deleted (clearing
* the blank line between/below dividers without touching the divider itself), while a *non-empty*
* block selects the leaf — so a first Backspace highlights what a second deletes, the same
* highlight-before-delete affordance as clicking it and parity with the arrow-key leaf selection.
* - **Enter** on an *empty, non-trailing list/task item* removes the empty item ({@link
* removeEmptyWrappedBlock}) rather than letting the default split the list into two around a stranded
* empty paragraph (which does not round-trip). A *trailing* empty item still falls through to the
* default, which exits the list — the standard "press Enter on a blank bullet to leave the list".
* - **Mod-A** inside a code block selects only that block's contents; pressing it again (when the
* block is already fully selected) falls through to the default whole-document select-all, the
* same scoped behavior as a code editor.
* - **ArrowUp/ArrowDown** select an adjacent divider or image, whether arrowing off a textblock edge
* ({@link selectAdjacentLeaf}) or stepping from one already-selected leaf to the next
* ({@link selectAdjacentSelectedLeaf}). (The `Mod-Shift-Arrow` block-reorder chords live separately
* in `./block-mover.ts`.)
*
* Plus a plugin that (a) highlights dividers/images falling inside a range selection (e.g. select-all),
* which the browser's native text highlight skips because leaves carry no text, and (b) flags the
* editor (`data-gap-between-leaves`) while a gap cursor sits between two leaves, so the CSS can hide its
* otherwise-stray caret.
*/
export const RichMarkdownKeymap = Extension.create({
name: 'richMarkdownKeymap',
priority: 1000,
addKeyboardShortcuts() {
return {
Backspace: ({ editor }) => {
const { selection, doc } = editor.state
if (!selection.empty || selection.$from.parentOffset !== 0) return false
const { $from } = selection
// A gap cursor at the start of the doc resolves at the top level (`depth === 0`, offset 0):
// `$from.before(0)` below throws, and falling through instead is no better — TipTap's
// blockquote Backspace handler crashes on the same resolution (`$from.node(-1)` is
// undefined). There is nothing before the gap for Backspace to act on, so consume the key.
if ($from.depth === 0) return true
if ($from.parent.type.name === 'heading') {
return editor.commands.setParagraph()
}
if ($from.parent.content.size === 0 && isInsideWrapper($from)) {
return removeEmptyWrappedBlock(editor, $from)
}
const blockStart = $from.before($from.depth)
const nodeBefore = doc.resolve(blockStart).nodeBefore
if (!nodeBefore || !SELECTABLE_LEAVES.has(nodeBefore.type.name)) return false
const leafStart = blockStart - nodeBefore.nodeSize
if ($from.parent.isTextblock && $from.parent.content.size === 0) {
return editor.commands.command(({ tr, dispatch }) => {
if (dispatch) {
tr.delete(blockStart, $from.after($from.depth))
tr.setSelection(NodeSelection.create(tr.doc, leafStart))
dispatch(tr.scrollIntoView())
}
return true
})
}
return editor.commands.setNodeSelection(leafStart)
},
Enter: ({ editor }) => {
const { selection } = editor.state
if (!selection.empty || selection.$from.parentOffset !== 0) return false
const { $from } = selection
if ($from.parent.content.size !== 0) return false
const itemDepth = $from.depth - 1
if (itemDepth < 1 || !LIST_ITEM_TYPES.has($from.node(itemDepth).type.name)) return false
const listDepth = itemDepth - 1
const isTrailingItem = $from.index(listDepth) === $from.node(listDepth).childCount - 1
if (isTrailingItem) return false
return removeEmptyWrappedBlock(editor, $from)
},
'Mod-a': ({ editor }) => {
const { $from } = editor.state.selection
if ($from.parent.type.name !== 'codeBlock') return false
const from = $from.start($from.depth)
const to = $from.end($from.depth)
if (editor.state.selection.from === from && editor.state.selection.to === to) return false
return editor.commands.setTextSelection({ from, to })
},
ArrowUp: ({ editor }) =>
!isSuggestionMenuOpen(editor) &&
(selectAdjacentSelectedLeaf(editor, 'up') || selectAdjacentLeaf(editor, 'up')),
ArrowDown: ({ editor }) =>
!isSuggestionMenuOpen(editor) &&
(selectAdjacentSelectedLeaf(editor, 'down') || selectAdjacentLeaf(editor, 'down')),
}
},
addProseMirrorPlugins() {
return [
new Plugin({
key: new PluginKey('richLeafSelectionHighlight'),
props: {
decorations(state) {
const { selection } = state
if (selection.empty || selection instanceof NodeSelection) return null
const decorations: Decoration[] = []
state.doc.nodesBetween(selection.from, selection.to, (node, pos) => {
if (SELECTABLE_LEAVES.has(node.type.name)) {
decorations.push(
Decoration.node(pos, pos + node.nodeSize, { class: 'rich-leaf-in-selection' })
)
}
})
return decorations.length ? DecorationSet.create(state.doc, decorations) : null
},
attributes(state): Record<string, string> {
const { selection } = state
if (!(selection instanceof GapCursor)) return {}
const before = selection.$head.nodeBefore
const after = selection.$head.nodeAfter
if (
before &&
after &&
SELECTABLE_LEAVES.has(before.type.name) &&
SELECTABLE_LEAVES.has(after.type.name)
) {
return { 'data-gap-between-leaves': 'true' }
}
return {}
},
},
}),
]
},
})
@@ -0,0 +1,70 @@
/**
* @vitest-environment jsdom
*
* Typing a markdown link `[text](url)` should convert to a real link mark on the closing `)`.
* Input rules only fire on real text input, so these drive the editor's `handleTextInput` path
* (NOT `insertContent`, which bypasses input rules).
*/
import { Editor } from '@tiptap/core'
import { afterEach, describe, expect, it } from 'vitest'
import { createMarkdownContentExtensions } from './extensions'
let editor: Editor | null = null
afterEach(() => {
editor?.destroy()
editor = null
})
function mount(): Editor {
return new Editor({ extensions: createMarkdownContentExtensions() })
}
/** Type `prefix` (no input rules), then simulate typing the final char so the input rule fires. */
function typeWithFinalChar(ed: Editor, prefix: string, finalChar: string): boolean {
ed.commands.setContent('', { contentType: 'markdown' })
ed.commands.insertContent(prefix)
const pos = ed.state.selection.from
return ed.view.someProp('handleTextInput', (fn) => fn(ed.view, pos, pos, finalChar)) === true
}
describe('typed markdown link input rule', () => {
it('converts [text](url) to a link mark on the closing paren', () => {
editor = mount()
typeWithFinalChar(editor, '[hi](https://example.com', ')')
const json = JSON.stringify(editor.getJSON())
expect(json).toContain('"type":"link"')
expect(json).toContain('"href":"https://example.com"')
expect(editor.getText()).toBe('hi')
})
it('normalizes a bare domain to https (parity with paste)', () => {
editor = mount()
typeWithFinalChar(editor, '[site](www.example.com', ')')
expect(JSON.stringify(editor.getJSON())).toContain('"href":"https://www.example.com"')
})
it('preserves a link title', () => {
editor = mount()
typeWithFinalChar(editor, '[t](https://e.com "the title"', ')')
const json = JSON.stringify(editor.getJSON())
expect(json).toContain('"href":"https://e.com"')
expect(json).toContain('"title":"the title"')
})
it('refuses an unsafe scheme (leaves it literal)', () => {
editor = mount()
typeWithFinalChar(editor, '[x](javascript:alert(1)', ')')
expect(JSON.stringify(editor.getJSON())).not.toContain('"type":"link"')
})
it('does not fire inside a code block', () => {
editor = mount()
editor.commands.setContent('```\n\n```', { contentType: 'markdown' })
editor.commands.setTextSelection(2)
const pos = editor.state.selection.from
editor.commands.insertContent('[x](https://e.com')
editor.view.someProp('handleTextInput', (fn) => fn(editor!.view, pos, pos, ')'))
expect(JSON.stringify(editor.getJSON())).not.toContain('"type":"link"')
})
})
@@ -0,0 +1,45 @@
import { Extension, InputRule } from '@tiptap/core'
import { normalizeLinkHref } from './markdown-fidelity'
/**
* Typed markdown link: `[text](url)` or `[text](url "title")`, completed by the closing `)`. The URL
* is space-free (markdown requires `<url>` for spaces, which this intentionally skips). StarterKit's
* Link ships no input rule — only paste/autolink — so without this, typed link syntax stays literal.
*/
const LINK_INPUT_RULE = /\[([^\]]+)]\(([^)\s]+)(?:\s+"([^"]*)")?\)$/
/**
* Converts a typed markdown link into a real link mark on the closing `)`. The visible text is the
* first capture group (so `markInputRule`, which keeps the *last* group, can't express this); the
* href comes from the second group, normalized through {@link normalizeLinkHref} so a bare domain
* gets `https://` and an unsafe scheme (`javascript:`) is refused (left as literal text). Skipped
* inside code blocks, where the brackets are literal source.
*/
export const MarkdownLinkInputRule = Extension.create({
name: 'markdownLinkInputRule',
addInputRules() {
return [
new InputRule({
find: LINK_INPUT_RULE,
handler: ({ state, range, match }) => {
if (state.selection.$from.parent.type.spec.code) return null
const linkType = state.schema.marks.link
if (!linkType) return null
const [fullMatch, text, rawHref, title] = match
const href = normalizeLinkHref(rawHref ?? '')
if (!href || !text) return null
const { tr } = state
const textStart = range.from + fullMatch.indexOf(text)
const textEnd = textStart + text.length
if (textEnd < range.to) tr.delete(textEnd, range.to)
if (textStart > range.from) tr.delete(range.from, textStart)
const markEnd = range.from + text.length
tr.addMark(range.from, markEnd, linkType.create({ href, title: title || null }))
tr.removeStoredMark(linkType)
},
}),
]
},
})
@@ -0,0 +1,217 @@
/**
* @vitest-environment jsdom
*
* A mark stacked on top of another color-setting context must not steal that context's color: an
* element's own explicit `color` rule always wins over an inherited value, regardless of how specific
* the ancestor's selector is. Two contexts hit this in practice:
*
* - A link's blue, stacked with bold/italic/strikethrough/inline-code. `strong`/`em`/`code` no longer
* set their own `color` at all (it was redundant with the prose default anyway, and — like the
* `mark`/highlight rule's own `color: inherit` — the absence of a rule is what lets inheritance
* carry through correctly from ANY ambient context, not just links). `del`/`s` genuinely need their
* own dimmer default, so they keep an explicit `color` plus an override for the link case.
* - `h6`'s intentionally dimmer `--text-secondary` (vs. every other heading and the prose default,
* both `--text-primary`), stacked with bold/italic — before strong/em's color was removed, a bold
* run inside an `h6` incorrectly showed the brighter `--text-primary` instead of `h6`'s own tone.
*
* These load the real, shipped `rich-markdown-editor.css` (not a copy) and assert against
* `getComputedStyle` in jsdom. jsdom's CSS engine does not resolve `var(...)` references to actual
* color values, but it does correctly resolve the cascade/specificity/inheritance winner and returns
* that declaration's authored value verbatim — so asserting the winning value is `var(--brand-secondary)`
* (vs. e.g. `var(--text-primary)`) is a precise, real assertion about which CSS rule wins, not a proxy.
*/
import { readFileSync } from 'node:fs'
import path from 'node:path'
import { afterEach, beforeAll, describe, expect, it } from 'vitest'
const CSS_PATH = path.join(__dirname, 'rich-markdown-editor.css')
const LINK_COLOR = 'var(--brand-secondary)'
beforeAll(() => {
const style = document.createElement('style')
style.textContent = readFileSync(CSS_PATH, 'utf-8')
document.head.appendChild(style)
})
let container: HTMLDivElement | null = null
afterEach(() => {
container?.remove()
container = null
})
/** Mounts `html` inside a `.rich-markdown-prose` container (the real editor's root class) and returns it. */
function mount(html: string): HTMLDivElement {
container = document.createElement('div')
container.className = 'rich-markdown-prose'
container.innerHTML = html
document.body.appendChild(container)
return container
}
function colorOf(el: Element | null): string {
if (!el) throw new Error('element not found')
return getComputedStyle(el).color
}
describe('link color baseline (no stacked mark)', () => {
it('a plain link is brand-secondary colored', () => {
const root = mount('<a href="#">link</a>')
expect(colorOf(root.querySelector('a'))).toBe(LINK_COLOR)
})
})
describe('a mark with no link keeps its own color (regression guard: the fix must not force every mark blue)', () => {
it.each([
{ tag: 'strong', html: '<strong>bold</strong>', color: 'var(--text-primary)' },
{ tag: 'em', html: '<em>italic</em>', color: 'var(--text-primary)' },
{ tag: 'del', html: '<del>struck</del>', color: 'var(--text-tertiary)' },
{ tag: 's', html: '<s>struck</s>', color: 'var(--text-tertiary)' },
{ tag: 'code', html: '<code>code</code>', color: 'var(--text-primary)' },
])('$tag alone renders $color, not link color', ({ tag, html, color }) => {
const root = mount(html)
expect(colorOf(root.querySelector(tag))).toBe(color)
expect(colorOf(root.querySelector(tag))).not.toBe(LINK_COLOR)
})
})
describe('mark nested INSIDE a link (`<a><mark>text</mark></a>`) — link color wins', () => {
it.each([
{ tag: 'strong', html: '<a href="#"><strong>bold link</strong></a>' },
{ tag: 'em', html: '<a href="#"><em>italic link</em></a>' },
{ tag: 'del', html: '<a href="#"><del>struck link</del></a>' },
{ tag: 's', html: '<a href="#"><s>struck link</s></a>' },
{ tag: 'code', html: '<a href="#"><code>code link</code></a>' },
])('$tag inside a link is link-colored, not its own default color', ({ tag, html }) => {
const root = mount(html)
expect(colorOf(root.querySelector(tag))).toBe(LINK_COLOR)
})
})
describe('mark WRAPPING a link (`<mark><a>text</a></mark>`) — link color still wins', () => {
it.each([
{ tag: 'strong', html: '<strong><a href="#">bold link</a></strong>' },
{ tag: 'em', html: '<em><a href="#">italic link</a></em>' },
{ tag: 'del', html: '<del><a href="#">struck link</a></del>' },
{ tag: 's', html: '<s><a href="#">struck link</a></s>' },
{ tag: 'code', html: '<code><a href="#">code link</a></code>' },
])('a link inside $tag is link-colored regardless of nesting direction', ({ tag, html }) => {
const root = mount(html)
expect(colorOf(root.querySelector('a'))).toBe(LINK_COLOR)
})
})
describe('each mark keeps its own non-color styling even when link-colored', () => {
it('bold link keeps font-weight 600', () => {
const root = mount('<a href="#"><strong>bold link</strong></a>')
const strong = root.querySelector('strong') as HTMLElement
expect(colorOf(strong)).toBe(LINK_COLOR)
expect(getComputedStyle(strong).fontWeight).toBe('600')
})
it('italic link keeps font-style italic', () => {
const root = mount('<a href="#"><em>italic link</em></a>')
const em = root.querySelector('em') as HTMLElement
expect(colorOf(em)).toBe(LINK_COLOR)
expect(getComputedStyle(em).fontStyle).toBe('italic')
})
it('strikethrough link keeps text-decoration line-through (del and s)', () => {
for (const tag of ['del', 's']) {
const root = mount(`<a href="#"><${tag}>struck link</${tag}></a>`)
const el = root.querySelector(tag) as HTMLElement
expect(colorOf(el)).toBe(LINK_COLOR)
expect(getComputedStyle(el).textDecoration).toContain('line-through')
container?.remove()
}
})
it('inline-code link keeps its monospace font and background', () => {
const root = mount('<a href="#"><code>code link</code></a>')
const code = root.querySelector('code') as HTMLElement
const computed = getComputedStyle(code)
expect(colorOf(code)).toBe(LINK_COLOR)
expect(computed.fontFamily).toContain('mono')
expect(computed.background).toContain('var(--surface-5)')
})
})
describe('multiple marks stacked together with a link', () => {
it('bold + italic + link: link color wins, both font-weight and font-style are preserved', () => {
const root = mount('<a href="#"><strong><em>bold italic link</em></strong></a>')
const em = root.querySelector('em') as HTMLElement
const strong = root.querySelector('strong') as HTMLElement
expect(colorOf(em)).toBe(LINK_COLOR)
expect(colorOf(strong)).toBe(LINK_COLOR)
expect(getComputedStyle(em).fontStyle).toBe('italic')
expect(getComputedStyle(strong).fontWeight).toBe('600')
})
it('bold + italic + strikethrough + link: link color wins at every nesting level', () => {
const root = mount(
'<a href="#"><strong><em><del>bold italic struck link</del></em></strong></a>'
)
for (const tag of ['strong', 'em', 'del']) {
expect(colorOf(root.querySelector(tag))).toBe(LINK_COLOR)
}
})
it('a mark stack with NO link present is unaffected by the link-precedence rule', () => {
const root = mount('<strong><em>bold italic, no link</em></strong>')
expect(colorOf(root.querySelector('em'))).toBe('var(--text-primary)')
expect(colorOf(root.querySelector('strong'))).toBe('var(--text-primary)')
})
})
describe('a link elsewhere in the document does not bleed color into unrelated marks', () => {
it('a sibling bold run outside any link keeps its own color', () => {
const root = mount('<p><a href="#">a link</a> and <strong>bold text</strong></p>')
expect(colorOf(root.querySelector('a'))).toBe(LINK_COLOR)
expect(colorOf(root.querySelector('strong'))).toBe('var(--text-primary)')
})
})
describe('headings stack correctly with marks (same bug class, a different ambient color)', () => {
it.each(['h1', 'h2', 'h3', 'h4', 'h5'])(
'%s carries --text-primary, same as bold/italic inside it — no visible regression either way',
(tag) => {
const root = mount(`<${tag}><strong>bold</strong> <em>italic</em></${tag}>`)
expect(colorOf(root.querySelector(tag))).toBe('var(--text-primary)')
expect(colorOf(root.querySelector('strong'))).toBe('var(--text-primary)')
expect(colorOf(root.querySelector('em'))).toBe('var(--text-primary)')
}
)
// h6 is the one heading with its own dimmer tone (--text-secondary, not --text-primary like every
// other heading and the prose default) — the exact shape of ambient color strong/em must inherit
// rather than reset, the same failure mode as the link case above just triggered by a heading
// instead of an <a>.
it('h6 keeps its dimmer --text-secondary, and bold/italic inside it inherit that tone (not --text-primary)', () => {
const root = mount('<h6><strong>bold</strong> <em>italic</em> plain</h6>')
const h6 = root.querySelector('h6') as HTMLElement
expect(colorOf(h6)).toBe('var(--text-secondary)')
expect(colorOf(root.querySelector('strong'))).toBe('var(--text-secondary)')
expect(colorOf(root.querySelector('em'))).toBe('var(--text-secondary)')
})
it('inline code inside h6 also inherits --text-secondary, not its own hardcoded default', () => {
const root = mount('<h6><code>code</code></h6>')
expect(colorOf(root.querySelector('code'))).toBe('var(--text-secondary)')
})
it('a struck-through run inside h6 keeps its own dimmer tertiary tone (mark-own-color still wins over a non-link ambient context)', () => {
const root = mount('<h6><del>struck</del></h6>')
expect(colorOf(root.querySelector('del'))).toBe('var(--text-tertiary)')
})
it('an anchor heading (link wrapping the whole heading) still shows link color, unaffected by h6', () => {
const root = mount('<h6><a href="#">linked heading</a></h6>')
expect(colorOf(root.querySelector('a'))).toBe(LINK_COLOR)
})
it('bold + italic + link inside h6: link color wins over both the mark defaults and h6 itself', () => {
const root = mount('<h6><a href="#"><strong><em>bold italic link</em></strong></a></h6>')
expect(colorOf(root.querySelector('em'))).toBe(LINK_COLOR)
expect(colorOf(root.querySelector('strong'))).toBe(LINK_COLOR)
})
})
@@ -0,0 +1,121 @@
/**
* Fidelity helpers that keep markdown TipTap can't model losslessly intact across an edit
* cycle. YAML frontmatter is held out of the editor entirely (TipTap parses `---` as a
* thematic break and corrupts it), and a couple of serializer quirks are smoothed over.
*/
const BOM = '\uFEFF'
const FRONTMATTER_REGEX = /^---\r?\n(?:[\s\S]*?\r?\n)?---[ \t]*(?:\r?\n)*/
const ESCAPED_CALLOUT_REGEX = /^(\s*>(?:\s*>)*\s*)\\\[!([A-Za-z]+)\\\]/gm
/**
* Alternates a code region (fenced block or inline span \u2014 never rewritten) with an inline link whose
* destination has no title and isn't angle-bracketed. The code branch is listed first so a link inside
* code is consumed as code and left untouched. The destination stops at `)` / whitespace, so a link
* carrying a title (`[x](url "t")`) never matches and is preserved verbatim.
*/
const CODE_OR_PLAIN_LINK_REGEX =
/(```[\s\S]*?```|~~~[\s\S]*?~~~|`[^`\n]+`)|\[([^\]]+)]\(([^)\s<>]+)\)/g
const HTTP_URL_REGEX = /^https?:\/\/\S+$/i
/**
* Collapses an autolinked destination back to its bare form: our normalizing serializer rewrites a bare
* URL or `<url>` autolink to `[url](url)` and a bare email to `[a@b.com](mailto:a@b.com)`, which churns
* every README's links into explicit-link syntax on the first save. When the visible text already equals
* the destination (a plain `http(s)` URL, or an email behind `mailto:`), GFM re-autolinks the bare form,
* so emitting it round-trips identically with a far quieter diff. Links inside code and titled links are
* left untouched (see {@link CODE_OR_PLAIN_LINK_REGEX}).
*/
function collapseAutolinkedUrls(markdown: string): string {
return markdown.replace(CODE_OR_PLAIN_LINK_REGEX, (match, code, text, href) => {
if (code) return code
if (text === href && HTTP_URL_REGEX.test(href)) return href
if (href === `mailto:${text}`) return text
return match
})
}
export interface SplitMarkdown {
/** Out-of-band leading prefix (a BOM and/or the frontmatter block), byte-exact, or `''`. */
frontmatter: string
body: string
}
/**
* Splits the leading out-of-band prefix — an optional UTF-8 BOM and YAML frontmatter — from
* the body. `frontmatter + body` reconstructs the input exactly, so {@link applyFrontmatter}
* can re-attach it without rewriting any whitespace, and the body never reaches TipTap with a
* BOM (which would defeat the frontmatter anchor and corrupt it).
*/
export function splitFrontmatter(markdown: string): SplitMarkdown {
const bom = markdown.startsWith(BOM) ? BOM : ''
const rest = bom ? markdown.slice(1) : markdown
const match = rest.match(FRONTMATTER_REGEX)
if (!match || !isYamlFrontmatterBlock(match[0])) return { frontmatter: bom, body: rest }
return { frontmatter: bom + match[0], body: rest.slice(match[0].length) }
}
/**
* A leading `---…---` block is YAML frontmatter unless its first content line is markdown rather than
* a `key:` — so a doc that opens with a `---` thematic break (e.g. a changelog whose next `---` closes
* the regex) stays in the editor body instead of being held out-of-band and hidden. An empty block
* (`---\n---`) is still treated as (empty) frontmatter.
*/
function isYamlFrontmatterBlock(block: string): boolean {
const interior = block.replace(/^---[ \t]*\r?\n/, '')
for (const rawLine of interior.split('\n')) {
const line = rawLine.trim()
if (line === '') continue
if (line.startsWith('---')) return true
return /^[A-Za-z0-9_-]+[ \t]*:/.test(line)
}
return true
}
export function applyFrontmatter(frontmatter: string, body: string): string {
return frontmatter + body
}
/** A leading `scheme://` URL (network protocol). */
const SCHEME_URL = /^([a-z][a-z0-9+.-]*):\/\//i
/** A leading `scheme:` token (per the URL grammar). */
const HAS_SCHEME = /^[a-z][a-z0-9+.-]*:/i
/** A bare `host:port` (digits after the colon) — looks scheme-like but is really a domain. */
const HOST_PORT = /^[a-z0-9.-]+:\d+(?:[/?#]|$)/i
/**
* Normalize a user-entered link target: prefix a bare domain with `https://` so it doesn't resolve
* as an in-app relative URL, while leaving already-qualified, relative (`./other.md`, `../doc.md`), and
* protocol-relative URLs intact. Dangerous schemes are rejected outright rather than trusted or mangled:
* any `scheme:` without `//` other than `mailto:`/`tel:` (so `javascript:`, `data:`, `vbscript:`,
* `blob:`, …), and `file://` (local file access). Other network `scheme://` URLs (`http(s)`, `ftp`, …)
* pass through. A bare `host:port` (digits after the colon) is a domain, not a scheme, so it still gets
* the `https://` prefix.
*/
export function normalizeLinkHref(href: string): string {
const trimmed = href.trim()
if (!trimmed) return ''
if (/^[#?]/.test(trimmed)) return trimmed
if (trimmed.startsWith('//')) return `https:${trimmed}`
if (trimmed.startsWith('/')) return trimmed
if (trimmed.startsWith('./') || trimmed.startsWith('../')) return trimmed
if (/^(?:mailto|tel):/i.test(trimmed)) return trimmed
const schemed = trimmed.match(SCHEME_URL)
if (schemed) return /^file$/i.test(schemed[1]) ? '' : trimmed
if (HAS_SCHEME.test(trimmed) && !HOST_PORT.test(trimmed)) return ''
return `https://${trimmed}`
}
/**
* Cleans up serializer output: restores callout markers the serializer backslash-escapes
* (`> \[!NOTE\]` → `> [!NOTE]`) and collapses trailing blank lines to a single newline. The
* table serializer's spurious surrounding blank lines are trimmed at the source (PipeSafeTable),
* so no global leading-newline strip is needed here — avoiding clobbering content that legitimately
* begins with whitespace.
*/
export function postProcessSerializedMarkdown(markdown: string): string {
return collapseAutolinkedUrls(markdown.replace(ESCAPED_CALLOUT_REGEX, '$1[!$2]')).replace(
/\n+$/,
'\n'
)
}
@@ -0,0 +1,212 @@
/**
* @vitest-environment jsdom
*/
import { Editor } from '@tiptap/core'
import { afterEach, describe, expect, it } from 'vitest'
import { createMarkdownContentExtensions } from './extensions'
import { parseMarkdownToDoc, serializeMarkdownBody, splitMarkdownBlocks } from './markdown-parse'
import { isRoundTripSafe } from './round-trip-safety'
let editor: Editor | null = null
afterEach(() => {
editor?.destroy()
editor = null
})
/** The current whole-document path: parse markdown in one shot, serialize back. */
function oneShot(body: string): string {
editor = new Editor({ extensions: createMarkdownContentExtensions() })
editor.commands.setContent(body, { contentType: 'markdown' })
const out = editor.getMarkdown()
editor.destroy()
editor = null
return out
}
/**
* Chunked parsing must be byte-identical to the one-shot path — these are the structures a naive
* blank-line split would shatter (loose lists span blank lines, list items hold multiple paragraphs,
* blockquotes and fenced code contain blank lines), so they're the real fidelity test.
*/
const CASES: Array<[string, string]> = [
[
'heading + inline marks',
'# Heading\n\nA paragraph with **bold**, *italic*, `code`, and a [link](https://x.com).',
],
['tight list', '- tight a\n- tight b\n- tight c'],
['loose list (blank lines between items)', '- loose a\n\n- loose b\n\n- loose c'],
['multi-paragraph list item', '1. first\n\n second paragraph in item one\n\n2. second item'],
['nested list', '- outer\n - nested one\n - nested two\n - deeper\n- outer two'],
[
'nested blockquote with blank lines',
'> a blockquote\n>\n> with two paragraphs\n>\n> > and a nested quote',
],
['gfm table', '| col a | col b |\n| --- | --- |\n| 1 | 2 |\n| 3 | 4 |'],
[
'fenced code with internal blank line',
'```ts\nconst x = 1\n\nfunction f() {\n return x\n}\n```',
],
['task list', '- [ ] task one\n- [x] task two done\n - [ ] subtask'],
['thematic break between paragraphs', 'Para before.\n\n---\n\nPara after a divider.'],
[
'image + linked-image badge',
'![alt](https://img.example/a.png)\n\n[![badge](https://img.shields.io/x.svg)](https://link.example)',
],
// The editor serializes nested lists with sub-3-space indentation, which a strict external lexer
// would mis-nest — this is the exact case that must survive re-parsing (idempotency).
[
'reduced-indent nested list (editor output shape)',
'1. First\n - sub bullet\n - another\n 1. deep ordered\n 2. item\n2. Second',
],
['heading-separated sections', '# A\n\nalpha\n\n## B\n\nbeta\n\n## C\n\ngamma'],
]
describe('parseMarkdownToDoc (chunked)', () => {
it('produces a doc node', () => {
const doc = parseMarkdownToDoc('# Hi\n\nbody')
expect(doc.type).toBe('doc')
expect(Array.isArray(doc.content)).toBe(true)
})
it.each(CASES)('chunked parse round-trips identically to one-shot: %s', (_label, body) => {
expect(serializeMarkdownBody(body)).toBe(oneShot(body))
})
// The editor re-parses its own output on every settle/repeat-stream, so a second pass must not
// drift — otherwise editing + saving + reopening would slowly corrupt structure (this is the bug
// that an external lexer introduced for sub-3-space nested lists).
it.each(CASES)('is idempotent (a second pass changes nothing): %s', (_label, body) => {
const once = serializeMarkdownBody(body)
expect(serializeMarkdownBody(once)).toBe(once)
})
it('empty and whitespace-only input produce an empty doc', () => {
expect(parseMarkdownToDoc('').type).toBe('doc')
expect(parseMarkdownToDoc(' \n\n ').type).toBe('doc')
expect(splitMarkdownBlocks('')).toEqual([])
expect(splitMarkdownBlocks('\n\n \n')).toEqual([])
})
it('parses reference-style links whole (non-chunkable) without dropping the definition', () => {
const body = 'See [the docs][ref] for details.\n\n[ref]: https://example.com/docs'
expect(serializeMarkdownBody(body)).toBe(oneShot(body))
})
// Block-level HTML can wrap blank lines; it routes to the whole-document fallback so chunked output
// still matches one-shot exactly. (Such docs open read-only via the round-trip-safety probe, so
// they're never re-serialized — the editor itself isn't idempotent on raw HTML.)
it.each([
['html block', '<div class="x">\n\ncontent\n\n</div>'],
['html comment', 'before\n\n<!-- a note -->\n\nafter'],
['html table', '<table>\n\n<tr><td>a</td></tr>\n\n</table>'],
])(
'block HTML renders via the whole-document fallback, matching one-shot: %s',
(_label, body) => {
expect(serializeMarkdownBody(body)).toBe(oneShot(body))
}
)
describe('splitMarkdownBlocks keeps ambiguous structures atomic', () => {
it('a loose list (blank lines between items) stays one block', () => {
expect(splitMarkdownBlocks('- a\n\n- b\n\n- c')).toEqual(['- a\n\n- b\n\n- c'])
})
it('a nested list (no blank lines) stays one block', () => {
expect(splitMarkdownBlocks('1. First\n - sub\n - two\n2. Second')).toHaveLength(1)
})
it('independent paragraphs split into separate blocks', () => {
expect(splitMarkdownBlocks('para one\n\npara two\n\npara three')).toHaveLength(3)
})
it('headings and paragraphs split; fenced code with blank lines stays one block', () => {
expect(splitMarkdownBlocks('# H\n\ntext\n\n```\na\n\nb\n```')).toEqual([
'# H',
'text',
'```\na\n\nb\n```',
])
})
it('a multi-paragraph list item (indented continuation) stays one block', () => {
expect(splitMarkdownBlocks('1. first\n\n second para\n\n2. next')).toHaveLength(1)
})
it('CRLF line endings still split (a closing fence ending in \\r must close)', () => {
// A Windows-authored file with fenced code must not collapse to one block (which would defeat
// the chunker); the closer ending in `\r` has to match. Assert block COUNT, not just fidelity.
const crlf = '```ts\r\nx\r\n```\r\n\r\npara1\r\n\r\npara2\r\n\r\npara3'
expect(splitMarkdownBlocks(crlf)).toEqual(['```ts\nx\n```', 'para1', 'para2', 'para3'])
})
})
it('matches one-shot on a large mixed document (the case the chunker exists for)', () => {
const blocks: string[] = ['# Big Doc']
for (let i = 0; i < 300; i++) {
blocks.push(
`## Section ${i}\n\nProse with **bold** and a [link](https://x.com/${i}) and \`code\`.`
)
if (i % 10 === 0) blocks.push(`\`\`\`ts\nconst x = ${i}\n\`\`\``)
if (i % 9 === 0) blocks.push('- item a\n\n- item b\n\n- item c')
if (i % 7 === 0) blocks.push('| a | b |\n| --- | --- |\n| 1 | 2 |')
}
const body = blocks.join('\n\n')
expect(serializeMarkdownBody(body)).toBe(oneShot(body))
})
})
/** Deterministic PRNG (mulberry32) so a failure is always reproducible from its seed. */
function rng(seed: number) {
return () => {
seed |= 0
seed = (seed + 0x6d2b79f5) | 0
let t = Math.imul(seed ^ (seed >>> 15), 1 | seed)
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
}
}
const FUZZ_BLOCKS: Array<(r: () => number) => string> = [
() => '# Heading one',
() => '### Heading three',
(r) =>
`A paragraph with **bold**, *italic*, \`code\`, and a [link](https://x.com/${Math.floor(r() * 99)}).`,
() => '- tight a\n- tight b\n- tight c',
() => '- loose a\n\n- loose b\n\n- loose c',
() => '1. ordered one\n2. ordered two\n3. ordered three',
() => '1. First\n - sub bullet\n - another\n 1. deep ordered\n 2. item\n2. Second',
() => '1. item\n\n a second paragraph inside the item\n\n2. next item',
() => '> a blockquote\n> spanning lines\n>\n> > and a nested one',
() => '| col a | col b |\n| --- | --- |\n| 1 | 2 |\n| 3 | 4 |',
() => '```ts\nconst x = 1\n\nfunction f() {\n return x\n}\n```',
() => '- [ ] todo one\n- [x] done two\n - [ ] subtask',
() => '---',
() => '![alt](https://img.example/a.png)',
() => '[![badge](https://img.shields.io/x.svg)](https://link.example)',
() => 'Text with ~~strikethrough~~ and a soft \nline break inside it.',
// Raw HTML / reference defs route to the whole-document fallback, so fidelity must still hold even
// though the doc itself opens read-only (idempotency is only asserted for editable docs).
() => '<div class="note">\n\nwrapped content\n\n</div>',
() => 'See [the docs][ref].\n\n[ref]: https://example.com/docs',
]
function buildFuzzDoc(seed: number): string {
const r = rng(seed)
const count = 2 + Math.floor(r() * 8)
const parts: string[] = []
for (let i = 0; i < count; i++) parts.push(FUZZ_BLOCKS[Math.floor(r() * FUZZ_BLOCKS.length)](r))
return parts.join('\n\n')
}
describe('chunked parse — property test over randomized documents', () => {
it('chunked === one-shot for every document, and idempotent for every editable one', () => {
const failures: Array<{ seed: number; kind: string }> = []
for (let seed = 1; seed <= 400; seed++) {
const body = buildFuzzDoc(seed)
const chunked = serializeMarkdownBody(body)
// Fidelity is the load-bearing invariant — chunked must never diverge from the whole-document
// parse, for ANY input; idempotency only needs to hold where the doc is editable (raw HTML is
// non-idempotent in the underlying editor regardless of chunking, which is why it opens read-only).
if (chunked !== oneShot(body)) failures.push({ seed, kind: 'fidelity' })
else if (isRoundTripSafe(body) && serializeMarkdownBody(chunked) !== chunked) {
failures.push({ seed, kind: 'idempotency' })
}
}
expect(failures).toEqual([])
// 400 docs each parsed+serialized twice — generous timeout so it can't flake under parallel load.
}, 30000)
})
@@ -0,0 +1,158 @@
import { Editor, type JSONContent } from '@tiptap/core'
import { createMarkdownContentExtensions } from './extensions'
import {
applyFrontmatter,
postProcessSerializedMarkdown,
splitFrontmatter,
} from './markdown-fidelity'
/**
* A single reused editor for chunked markdown parse/serialize, created lazily so importing this
* module — including during SSR — never constructs it. `MarkdownManager.parse` is pure and re-entrant
* (it builds its own lexer and never reads the editor's document), so sharing one instance is safe;
* `serializeMarkdownBody` additionally reuses it as a scratchpad, overwriting its document via
* `setContent`. Both are safe because all access is synchronous and single-threaded — each call fully
* completes before the next — so no call ever observes another's partial state. One bounded instance
* for the session, not a per-call allocation.
*/
let parser: Editor | null = null
function parserEditor(): Editor {
if (!parser) parser = new Editor({ extensions: createMarkdownContentExtensions() })
return parser
}
function markdownManager() {
const manager = parserEditor().markdown
if (!manager) throw new Error('Markdown extension is not installed on the parser editor')
return manager
}
/**
* Constructs whose meaning spans blank-line boundaries, so the document can't be split into blocks
* without changing how they parse — these documents parse whole (correct, if slower; they're
* uncommon and almost always round-trip-unsafe and read-only anyway):
* - A link/image *reference definition* (`[id]: url`) or footnote definition can sit far from its
* `[text][id]` / `[^id]` use; splitting them apart would drop the reference. The editor never
* *emits* reference-style links, so this only matters on the first open of such a file.
* - A block-level HTML element (`<div>…</div>`, `<table>…`) or HTML comment can wrap blank lines; the
* splitter would shatter it (matched here by a line that opens an HTML tag/comment, not inline
* `<https://…>` autolinks).
*/
const NON_CHUNKABLE =
/^[ ]{0,3}(?:\[(?:\^[^\]]+|[^\]^][^\]]*)\]:\s|<(?:!--|\/?[a-zA-Z][a-zA-Z0-9-]*[\s/>]))/m
const FENCE_OPEN = /^ {0,3}(`{3,}|~{3,})/
const FENCE_CLOSE = /^ {0,3}(`{3,}|~{3,})[ \t]*$/
const LIST_MARKER = /^[ ]{0,3}(?:[-*+]|\d+[.)])\s/
const BLOCKQUOTE = /^[ ]{0,3}>/
/**
* Split a markdown body into top-level blocks that can each be parsed independently and reassembled
* without changing meaning. Blank lines separate candidate groups (fenced code blocks stay atomic),
* then adjacent groups are merged back together whenever they could form one logical block: any
* indented (continuation) group, and consecutive list/blockquote groups (which would otherwise be a
* single loose list/quote). Merging is intentionally conservative — over-merging only yields a larger
* chunk, whereas under-merging would shatter a structure — and every block is parsed by
* `@tiptap/markdown`'s own lexer, so block boundaries always match the parser.
*
* The indent-merge rule is load-bearing for fenced code indented past 3 spaces (e.g. inside a list
* item): {@link FENCE_OPEN} only tracks fences at the document margin, so a nested fence's interior
* blank lines are held together by the indent merge, not the fence tracker. Weakening that merge
* would silently shatter nested fences.
*/
export function splitMarkdownBlocks(body: string): string[] {
// Normalize CRLF/CR first: the fence/list/blockquote line tests anchor on `$`, so a trailing `\r`
// would stop a closing fence matching and swallow the rest of a Windows-authored file into one
// block (defeating the chunker). The editor normalizes `\r` on parse anyway, so meaning is unchanged.
const lines = body.replace(/\r\n?/g, '\n').split('\n')
const groups: string[] = []
let current: string[] = []
let fence: string | null = null
const flush = () => {
if (current.length > 0) groups.push(current.join('\n'))
current = []
}
for (const line of lines) {
if (fence) {
current.push(line)
const closer = line.match(FENCE_CLOSE)
if (closer && closer[1][0] === fence[0] && closer[1].length >= fence.length) fence = null
continue
}
const open = line.match(FENCE_OPEN)
if (open) {
current.push(line)
fence = open[1]
continue
}
if (line.trim() === '') {
flush()
continue
}
current.push(line)
}
flush()
// Build continuation runs and join each once — concatenating onto the growing block per group would be
// O(n²) for one long loose list. A group continues the run when indented, or when its first line and the
// group open the same marker kind (list or blockquote) — i.e. they form one loose list/quote.
const runs: string[][] = []
for (const group of groups) {
const head = runs.length > 0 ? runs[runs.length - 1][0] : null
const continues =
head !== null &&
(/^\s/.test(group) ||
(LIST_MARKER.test(head) && LIST_MARKER.test(group)) ||
(BLOCKQUOTE.test(head) && BLOCKQUOTE.test(group)))
if (continues) runs[runs.length - 1].push(group)
else runs.push([group])
}
return runs.map((run) => run.join('\n\n'))
}
/**
* Parse a markdown body into a ProseMirror doc by splitting it into top-level blocks and parsing each
* independently, then assembling the results.
*
* `@tiptap/markdown`'s `setContent(md, 'markdown')` is superlinear (~O(n²)) in document size, which
* freezes the main thread at mount for large files. Parsing block-by-block is linear — measured ~22ms
* vs ~1270ms at 61KB — and byte-identical, because each block is parsed with the same tokenizers.
* Documents whose constructs span blocks ({@link NON_CHUNKABLE}) parse whole, and any failure falls
* back to a single whole-document parse, so correctness never depends on the splitter.
*/
export function parseMarkdownToDoc(body: string): JSONContent {
const manager = markdownManager()
if (NON_CHUNKABLE.test(body)) return manager.parse(body)
try {
const content: JSONContent[] = []
for (const block of splitMarkdownBlocks(body)) {
// `MarkdownManager.parse` always returns a doc node with a `content` array; spread its blocks.
content.push(...(manager.parse(block).content ?? []))
}
return { type: 'doc', content }
} catch {
return manager.parse(body)
}
}
/**
* Round-trip a markdown body through the editor pipeline (chunked parse → serialize), linearly. The
* doc is loaded via `setContent` (not serialized directly) so it passes through the same schema
* normalization the live editor applies, keeping the output identical to `editor.getMarkdown()`.
*/
export function serializeMarkdownBody(body: string): string {
const editor = parserEditor()
editor.commands.setContent(parseMarkdownToDoc(body), { contentType: 'json' })
return editor.getMarkdown()
}
/**
* Serialize a full markdown document to the editor's canonical form: frontmatter is held aside and
* re-attached byte-exact while the body round-trips through {@link serializeMarkdownBody}. The single
* source of this pipeline (the dirty-check baseline and the round-trip-safety probe both use it).
*/
export function serializeMarkdownDocument(content: string): string {
const { frontmatter, body } = splitFrontmatter(content)
return applyFrontmatter(frontmatter, postProcessSerializedMarkdown(serializeMarkdownBody(body)))
}
@@ -0,0 +1,317 @@
/**
* @vitest-environment jsdom
*
* Pasting markdown source should render as rich content (links, images, badges) rather than literal
* `[text](url)` text — except inside a code block, where it must stay literal.
*/
import { Editor } from '@tiptap/core'
import { afterEach, describe, expect, it } from 'vitest'
import { createMarkdownContentExtensions } from './extensions'
import { MarkdownPaste } from './markdown-paste'
let editor: Editor | null = null
afterEach(() => {
editor?.destroy()
editor = null
})
function mount(editable = true): Editor {
return new Editor({ extensions: [...createMarkdownContentExtensions(), MarkdownPaste], editable })
}
/** Run the plugin paste handlers the way ProseMirror would, with a mocked clipboard. */
function paste(ed: Editor, text: string, html = '', extra: Record<string, string> = {}): boolean {
const event = {
clipboardData: {
getData: (type: string) =>
type === 'text/plain' ? text : type === 'text/html' ? html : (extra[type] ?? ''),
},
} as unknown as ClipboardEvent
for (const plugin of ed.view.state.plugins) {
if (plugin.props?.handlePaste?.(ed.view, event, ed.view.state.selection.content())) {
return true
}
}
return false
}
/** Run the plugin `transformPastedHTML` chain the way ProseMirror would. */
function transformHtml(ed: Editor, html: string): string {
let out = html
for (const plugin of ed.view.state.plugins) {
const fn = plugin.props?.transformPastedHTML
if (fn) out = fn.call(plugin.props, out, ed.view)
}
return out
}
describe('markdown paste', () => {
it('renders a pasted inline link as a link mark', () => {
editor = mount()
expect(paste(editor, '[inline link](https://example.com)')).toBe(true)
const json = JSON.stringify(editor.getJSON())
expect(json).toContain('"type":"link"')
expect(json).toContain('https://example.com')
expect(json).not.toContain('[inline link]')
})
it('renders a pasted badge as a linked image', () => {
editor = mount()
expect(paste(editor, '[![build](https://e.com/i.png)](https://ci.example.com)')).toBe(true)
const json = JSON.stringify(editor.getJSON())
expect(json).toContain('"type":"image"')
expect(json).toContain('"href":"https://ci.example.com"')
})
it('leaves plain (non-markdown) text to the default handler', () => {
editor = mount()
expect(paste(editor, 'just a normal sentence with no syntax')).toBe(false)
})
it("prefers the markdown parser over DOM mapping when the HTML sibling's plain-text side also looks like markdown", () => {
editor = mount()
expect(paste(editor, '# heading', '<h1>heading</h1>')).toBe(true)
const json = JSON.stringify(editor.getJSON())
expect(json).toContain('"type":"heading"')
})
it('preserves GFM table alignment on a paste that carries both text/plain and text/html', () => {
editor = mount()
const table = '| a | b |\n| :-- | --: |\n| 1 | 2 |'
const html = '<table><tr><td>a</td><td>b</td></tr><tr><td>1</td><td>2</td></tr></table>'
expect(paste(editor, table, html)).toBe(true)
const json = JSON.stringify(editor.getJSON())
expect(json).toContain('"align":"left"')
expect(json).toContain('"align":"right"')
})
it('still defers to DOM mapping when the HTML sibling has no markdown-shaped plain-text counterpart', () => {
editor = mount()
expect(
paste(
editor,
'just a normal sentence with no syntax',
'<p>just a normal sentence with no syntax</p>'
)
).toBe(false)
})
it('keeps pasted markdown literal inside a code block', () => {
editor = mount()
editor.commands.setContent('```js\ncode here\n```', { contentType: 'markdown' })
editor.commands.setTextSelection(5)
expect(editor.isActive('codeBlock')).toBe(true)
expect(paste(editor, '[link](https://example.com)')).toBe(false)
})
it('keeps pasted markdown literal inside inline code', () => {
editor = mount()
editor.commands.setContent('a `codehere` b', { contentType: 'markdown' })
editor.commands.setTextSelection(6)
expect(editor.isActive('code')).toBe(true)
expect(paste(editor, '*italic*')).toBe(false)
})
it('rejects the paste entirely in a read-only editor', () => {
editor = mount(false)
expect(paste(editor, '# heading\n\n- one\n- two')).toBe(false)
expect(editor.getText()).toBe('')
})
it.each([
['empty string', ''],
['whitespace only', ' \n\n '],
['a bare thematic break (ambiguous — needs another markdown signal)', '---'],
])('leaves %s to the default handler', (_label, text) => {
editor = mount()
expect(paste(editor, text)).toBe(false)
})
it.each([
['heading', '# Heading', 'heading'],
['bold', 'a **bold** word', 'bold'],
['italic', 'an *italic* word', 'italic'],
['underscore italic', 'an _italic_ word', 'italic'],
['underscore bold', 'a __bold__ word', 'bold'],
['strikethrough', 'a ~~struck~~ word', 'strike'],
['highlight', 'a ==marked== word', 'highlight'],
['highlight with interior equals', 'x ==a=b== y', 'highlight'],
['inline code', 'some `code` here', 'code'],
['bullet list', '- one\n- two', 'bulletList'],
['ordered list', '1. one\n2. two', 'orderedList'],
['task list', '- [x] done\n- [ ] todo', 'taskList'],
['blockquote', '> a quote', 'blockquote'],
['fenced code block', '```ts\nconst x = 1\n```', 'codeBlock'],
['standalone image', '![alt](https://e.com/i.png)', 'image'],
['thematic break within a document', '# Title\n\n---\n\nbody', 'horizontalRule'],
])('renders pasted %s as rich content', (_label, md, nodeType) => {
editor = mount()
expect(paste(editor, md)).toBe(true)
expect(JSON.stringify(editor.getJSON())).toContain(`"type":"${nodeType}"`)
})
it.each([
['italic', 'an *italic* word', '<p>an <em>italic</em> word</p>'],
['strikethrough', 'a ~~struck~~ word', '<p>a <del>struck</del> word</p>'],
['inline code', 'some `code` here', '<p>some <code>code</code> here</p>'],
])('defers inline-only %s to a rich HTML sibling (keeps its structure)', (_label, text, html) => {
editor = mount()
expect(paste(editor, text, html)).toBe(false)
})
it.each([
['space-flanked asterisks', 'area = 5 * width * height'],
['python args and kwargs', 'def foo(*args, **kwargs): pass'],
['snake_case identifiers', 'call user_name and file_path_here'],
])('claims %s but leaves it byte-for-byte literal (strict CommonMark)', (_label, text) => {
editor = mount()
expect(paste(editor, text)).toBe(true)
const json = JSON.stringify(editor.getJSON())
expect(json).not.toContain('"type":"italic"')
expect(json).not.toContain('"type":"bold"')
expect(editor.getText()).toBe(text)
})
it('parses markdown-shaped plain text even when an HTML sibling is present', () => {
editor = mount()
const html = '<h1>Title</h1><ul><li>a</li><li>b</li></ul>'
expect(paste(editor, '# Title\n\n- a\n- b', html)).toBe(true)
const json = JSON.stringify(editor.getJSON())
expect(json).toContain('"type":"heading"')
expect(json).toContain('"type":"bulletList"')
expect(json).not.toContain('# Title')
})
it('preserves the structural blocks of a multi-block document, in order, on paste', () => {
editor = mount()
expect(paste(editor, '# Title\n\nA paragraph.\n\n- a\n- b\n\n> quote')).toBe(true)
const structural = (editor.getJSON().content ?? [])
.map((node) => node.type)
.filter((type) => type !== 'paragraph')
expect(structural).toEqual(['heading', 'bulletList', 'blockquote'])
})
it('pastes VSCode code (vscode-editor-data) as a fenced code block with its language', () => {
editor = mount()
const code = 'const x: number = 1\nreturn x'
const handled = paste(editor, code, '<div><span>const</span></div>', {
'vscode-editor-data': JSON.stringify({ mode: 'typescript' }),
})
expect(handled).toBe(true)
const block = (editor.getJSON().content ?? []).find((n) => n.type === 'codeBlock')
expect(block).toBeDefined()
expect(block?.attrs?.language).toBe('typescript')
expect(block?.content?.[0]?.text).toBe(code)
})
it.each([
['html', 'markup'],
['shellscript', 'bash'],
])('maps VSCode language id %s to our code-block value %s', (mode, expected) => {
editor = mount()
paste(editor, 'code', '', { 'vscode-editor-data': JSON.stringify({ mode }) })
const block = (editor.getJSON().content ?? []).find((n) => n.type === 'codeBlock')
expect(block?.attrs?.language).toBe(expected)
})
it.each(['markdown', 'md', 'mdx', 'plaintext'])(
'does NOT force a code block for VSCode %s copies (parses as markdown instead)',
(mode) => {
editor = mount()
const handled = paste(editor, '# Title\n\n- item', '', {
'vscode-editor-data': JSON.stringify({ mode }),
})
expect(handled).toBe(true)
const types = (editor.getJSON().content ?? []).map((n) => n.type)
expect(types).not.toContain('codeBlock')
expect(types).toContain('heading')
expect(types).toContain('bulletList')
}
)
it('strips <style>/<script> from pasted HTML so their text never leaks into the doc', () => {
editor = mount()
const gsheets =
'<google-sheets-html-origin><style>td{mso-1:2}</style><table><tr><td>a</td></tr></table></google-sheets-html-origin>'
const cleaned = transformHtml(editor, gsheets)
expect(cleaned).not.toContain('<style>')
expect(cleaned).not.toContain('mso-1')
expect(cleaned).toContain('<td>a</td>')
expect(transformHtml(editor, 'a<script>alert(1)</script>b')).toBe('ab')
})
it('strips nested/repeated <script> tags in a single pass, even deeply nested', () => {
editor = mount()
expect(transformHtml(editor, 'a<script>x<script>y</script></script>b')).toBe('ab')
const deeplyNested = `a${'<script>'.repeat(50)}x${'</script>'.repeat(50)}b`
expect(transformHtml(editor, deeplyNested)).toBe('ab')
})
it('drops an unterminated <script>/<style> and everything after it, without duplicating the prefix', () => {
editor = mount()
expect(transformHtml(editor, 'abc<script>never-closes')).toBe('abc')
expect(transformHtml(editor, 'abc<style>never-closes')).toBe('abc')
expect(transformHtml(editor, '<script>x<script>y</script>')).toBe('')
})
})
describe('linkify a selection on URL paste', () => {
function linkify(
pasted: string,
from = 1,
to = 10
): { handled: boolean; href?: string; text: string } {
editor = mount()
editor.commands.setContent('select me here', { contentType: 'markdown' })
editor.commands.setTextSelection({ from, to })
const handled = paste(editor, pasted)
return {
handled,
href: JSON.stringify(editor.getJSON()).match(/"href":"([^"]+)"/)?.[1],
text: editor.getText(),
}
}
it('wraps a non-empty text selection in a link when a URL is pasted (keeping the text)', () => {
const r = linkify('https://sim.ai')
expect(r.handled).toBe(true)
expect(r.href).toBe('https://sim.ai')
expect(r.text).toBe('select me here')
})
it('prepends https:// to a bare www host and mailto: to a bare email', () => {
expect(linkify('www.sim.ai').href).toBe('https://www.sim.ai')
expect(linkify('a@b.com').href).toBe('mailto:a@b.com')
})
it('does not linkify a collapsed caret (empty selection)', () => {
const r = linkify('https://sim.ai', 5, 5)
expect(r.handled).toBe(false)
})
it('does not linkify a multi-word paste over a selection', () => {
expect(linkify('not a url just words').handled).toBe(false)
})
it('does not linkify an unsafe javascript: url', () => {
const r = linkify('javascript:alert(1)')
expect(r.handled).toBe(false)
expect(r.href).toBeUndefined()
})
it('links a real mailto: but not a crafted mailto: payload', () => {
expect(linkify('mailto:a@b.com').href).toBe('mailto:a@b.com')
const crafted = linkify('mailto:javascript:alert(1)')
expect(crafted.handled).toBe(false)
expect(crafted.href).toBeUndefined()
})
it('does not linkify a selection spanning multiple blocks', () => {
editor = mount()
editor.commands.setContent('alpha\n\nbeta', { contentType: 'markdown' })
editor.commands.setTextSelection({ from: 3, to: 9 })
expect(paste(editor, 'https://sim.ai')).toBe(false)
expect(JSON.stringify(editor.getJSON())).not.toContain('"type":"link"')
})
})
@@ -0,0 +1,204 @@
import { Extension } from '@tiptap/core'
import { Plugin } from '@tiptap/pm/state'
import { normalizeLinkHref } from './markdown-fidelity'
import { parseMarkdownToDoc } from './markdown-parse'
/**
* A single link the paste can wrap a selection in: an http(s) URL, a `mailto:` to a real address, a bare
* `www.` host, or a bare email. `mailto:` requires an actual `user@host.tld` payload so a crafted value
* like `mailto:javascript:…` (no `@`) never matches and falls through to a normal paste.
*/
const HTTP_URL = /^https?:\/\/\S+$/i
const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
const MAILTO_URL = /^mailto:[^\s@]+@[^\s@]+\.[^\s@]+$/i
const BARE_WWW = /^www\.\S+\.\S+$/i
/**
* If pasted text is a single link, return the href to wrap a selection in — `www.` gets `https://`, a
* bare email gets `mailto:`. Returns null for anything else (a multi-word or non-URL paste falls through
* to normal insertion). The caller still runs the result through `normalizeLinkHref` for scheme safety.
*/
function pastedLinkHref(text: string): string | null {
if (HTTP_URL.test(text) || MAILTO_URL.test(text)) return text
if (BARE_WWW.test(text)) return `https://${text}`
if (EMAIL.test(text)) return `mailto:${text}`
return null
}
/**
* Structural markdown — strong signals the plain text is genuinely markdown (a link, image, badge,
* list, heading, blockquote, fenced block, or GFM table). Our parser round-trips these more faithfully
* than generic HTML→DOM mapping (GFM alignment, escaping, the `./raw-markdown-snippet.ts` constructs),
* so they are parsed even when the clipboard also carries an HTML sibling.
*/
const STRUCTURAL_MARKDOWN_HINTS: ReadonlyArray<RegExp> = [
/^#{1,6}\s/m,
/\*\*[^*]+\*\*/,
/\[[^\]]*]\([^)]+\)/,
/^\s*[-*+]\s/m,
/^\s*\d+\.\s/m,
/^>\s/m,
/```/,
/^\|.*\|.*\|/m,
]
/**
* Inline marks — weaker markdown signals (`*italic*` / `_italic_`, `~~strike~~`, `` `code` ``) that a
* rich HTML sibling encodes just as well. Parsed for a plain-text-only paste (so markdown copied from a
* terminal or `.md` source renders), but deferred to an HTML sibling: its presence means the source was
* rich, and it may carry structure the plain text can't (a copied table's plain form is tab-separated,
* not a `| … |` grid, so parsing it would flatten the table).
*/
const INLINE_MARK_HINTS: ReadonlyArray<RegExp> = [
/\*[^*\n]+\*/,
/_[^_\n]+_/,
/~~[^~\n]+~~/,
/`[^`\n]+`/,
/==(?:[^=\n]|=(?!=))+==/,
]
function hasAny(hints: ReadonlyArray<RegExp>, text: string): boolean {
return hints.some((hint) => hint.test(text))
}
/**
* VSCode language ids that differ from our code-block language values. `markdown`/`plaintext` map to
* the empty string so they are NOT forced into a code block — markdown copied from VSCode should parse
* as markdown, and plain text should paste as text; other ids pass through as-is.
*/
const VSCODE_LANGUAGE_ALIASES: Readonly<Record<string, string>> = {
html: 'markup',
shellscript: 'bash',
shell: 'bash',
jsonc: 'json',
plaintext: '',
markdown: '',
md: '',
mdx: '',
}
/**
* Extracts the source language from VSCode's `vscode-editor-data` clipboard payload (a JSON blob with a
* `mode` field), mapping the few ids that differ from our code-block values. Returns `''` when the
* payload is absent, unparseable, or a non-code mode (plaintext/markdown). A real code language makes
* the paste handler emit a fenced code block — otherwise VSCode's per-token colored-span HTML would
* fall through to ProseMirror's default parser and flatten into plain paragraphs — while an empty
* result falls through so markdown copied from VSCode still parses as markdown.
*/
function parseVscodeLanguage(data: string | undefined): string {
if (!data) return ''
try {
const mode = (JSON.parse(data) as { mode?: unknown }).mode
if (typeof mode !== 'string') return ''
return VSCODE_LANGUAGE_ALIASES[mode] ?? mode
} catch {
return ''
}
}
/** A `<style>`/`<script>` open or close tag token, scanned one at a time (never the element body). */
const NON_CONTENT_TAG = /<\/?\s*(style|script)\b[^>]*>/gi
/**
* Strips `<style>`/`<script>` elements from pasted HTML. Google Sheets and Word prepend a `<style>`
* block of CSS (and Sheets a `<google-sheets-html-origin>` wrapper); ProseMirror's DOM parser has no
* rule for `<style>`, so it would walk the element's CSS text into the document as literal paragraphs.
* Removing these before parsing keeps the pasted content clean (PM already discards unknown wrappers).
*
* Scans tag tokens in a single linear pass, tracking nesting depth of the currently-open tag name, so
* nested/overlapping tags — e.g. `<script><script>x</script>` — can't leave a surviving `<script>`
* behind. A naive single `replace()` pass over `<tag>[\s\S]*?<\/tag>` matches only the innermost pair
* and leaves the outer tag dangling; repeating that replace until stable fixes correctness but costs
* O(depth) full-string rescans on attacker-controlled clipboard input. This does it in one pass instead.
* A stray close tag encountered outside any open element (depth 0) is left in place untouched. `cursor`
* advances past an open tag the moment it opens (not when it closes), so if the input ends before the
* element closes — truncated or malformed clipboard HTML — the unterminated element and everything
* after it is dropped rather than reappearing unstripped in the final `html.slice(cursor)` flush.
*/
function stripNonContentHtml(html: string): string {
let result = ''
let cursor = 0
let depth = 0
let openTagName = ''
NON_CONTENT_TAG.lastIndex = 0
let match: RegExpExecArray | null
while ((match = NON_CONTENT_TAG.exec(html))) {
const isClosing = match[0][1] === '/'
const tagName = match[1].toLowerCase()
if (depth === 0) {
if (isClosing) continue
result += html.slice(cursor, match.index)
openTagName = tagName
depth = 1
cursor = match.index + match[0].length
} else if (tagName === openTagName) {
depth += isClosing ? -1 : 1
if (depth === 0) cursor = match.index + match[0].length
}
}
if (depth === 0) result += html.slice(cursor)
return result
}
/**
* Parses pasted plain text that looks like markdown into rich content, via the strict CommonMark
* parser ({@link parseMarkdownToDoc}, `marked`). Pastes inside a code block or inline code are left
* untouched (code is meant to stay literal).
*
* Provenance decides plain-text-vs-HTML: a `text/html` sibling (copied from a browser, Slack, Notion,
* GitHub, or this editor) is the signal the source was rich. Structural markdown is still parsed from
* the plain-text sibling regardless — our parser is more faithful for GFM tables and escaping. But
* inline-only marks are equally expressible in HTML, so when a rich sibling is present we defer to the
* DOM path, which preserves structure the plain text can't encode. A plain-text-only clipboard (a
* terminal, a code editor, a `.md` file) always parses.
*
* The strictness of the parse matters: `marked` follows CommonMark flanking rules, so `*text*` becomes
* emphasis but a space-flanked `5 * width * height` stays literal. The editor sets `enablePasteRules:
* false` so StarterKit's lenient mark paste rules (which would mangle that expression on either path)
* never run — emphasis is owned by this parser on the plain path and by real HTML tags on the DOM path.
*/
export const MarkdownPaste = Extension.create({
name: 'markdownPaste',
addProseMirrorPlugins() {
const { editor } = this
return [
new Plugin({
props: {
transformPastedHTML: (html) => stripNonContentHtml(html),
handlePaste: (view, event) => {
if (!editor.isEditable) return false
if (editor.isActive('codeBlock') || editor.isActive('code')) return false
const text = event.clipboardData?.getData('text/plain')
if (!text) return false
const { selection } = view.state
if (
!selection.empty &&
selection.$from.sameParent(selection.$to) &&
selection.$from.parent.inlineContent
) {
const href = pastedLinkHref(text.trim())
const safeHref = href ? normalizeLinkHref(href) : ''
if (safeHref) return editor.commands.setLink({ href: safeHref })
}
const language = parseVscodeLanguage(event.clipboardData?.getData('vscode-editor-data'))
if (language) {
return editor.commands.insertContent({
type: 'codeBlock',
attrs: { language },
content: [{ type: 'text', text }],
})
}
if (!hasAny(STRUCTURAL_MARKDOWN_HINTS, text)) {
if (!hasAny(INLINE_MARK_HINTS, text)) return false
if (event.clipboardData?.getData('text/html')) return false
}
const doc = parseMarkdownToDoc(text)
if (!doc.content?.length) return false
return editor.commands.insertContent(doc)
},
},
}),
]
},
})
@@ -0,0 +1,7 @@
export { MENTION_PLUGIN_KEY, Mention, type MentionStorage } from './mention'
export { MentionChip } from './mention-chip'
export { MarkdownMention } from './mention-node'
export { SIM_LINK_SCHEME, simLinkPath, toSimHref } from './sim-link'
export type { MentionItem, MentionKind } from './types'
export { useEditorMentions } from './use-editor-mentions'
export { useMarkdownMentions } from './use-markdown-mentions'
@@ -0,0 +1,81 @@
/**
* @vitest-environment jsdom
*
* The chip label must never carry its own explicit text color — see the comment on `CHIP_CLASS` in
* `mention-chip.tsx`. An element's own explicit `color` always wins over an inherited one regardless
* of ancestor specificity, so hardcoding a color here would silently override any ambient color a
* mention's container legitimately sets (a link's blue, an `h6` heading's dimmer `--text-secondary`) —
* the same bug class already fixed for `strong`/`em`/`code` in `rich-markdown-editor.css`.
*/
import { act } from 'react'
import type { Editor } from '@tiptap/react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, describe, expect, it, vi } from 'vitest'
vi.mock('next/navigation', () => ({
useRouter: () => ({ push: vi.fn() }),
useParams: () => ({}),
}))
// Override the global `getAllBlocks: () => ({})` stub — `getIconColorMap` iterates it as an array.
vi.mock('@/blocks/registry', () => ({
getAllBlocks: () => [],
}))
const { MentionChipView } = await import('./mention-chip')
function fakeNode(attrs: Record<string, unknown>) {
return { attrs } as unknown as Parameters<typeof MentionChipView>[0]['node']
}
function fakeEditor(): Editor {
return { storage: { mention: { navigable: false } } } as unknown as Editor
}
let container: HTMLDivElement | null = null
let root: Root | null = null
afterEach(() => {
if (root) act(() => root?.unmount())
container?.remove()
container = null
root = null
})
describe('MentionChipView', () => {
it('renders its wrapper with no explicit text-color utility class', async () => {
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
container = document.createElement('div')
document.body.appendChild(container)
root = createRoot(container)
await act(async () => {
root?.render(
MentionChipView({
node: fakeNode({ kind: 'file', id: 'f1', label: 'notes.md' }),
editor: fakeEditor(),
} as Parameters<typeof MentionChipView>[0])
)
})
const chip = container.querySelector('.mention-chip') as HTMLElement
expect(chip).not.toBeNull()
// Any `text-*` utility targeting the wrapper itself — bare, or Tailwind's self-targeting
// `[&]:text-*` arbitrary variant (as opposed to a descendant variant like `[&>svg]:text-*`,
// which the icon rule below legitimately uses) — would regress this fix, not just the specific
// old `text-[var(--text-primary)]` class. Rather than enumerate every Tailwind color-naming
// scheme (arbitrary value, shade-suffixed, semantic theme tokens like `text-primary`/
// `text-muted-foreground`, keywords), flag ANY such token: none is legitimate on this wrapper
// today, so this can only ever be a color utility slipping back in. A genuinely new, non-color
// `text-*` need (e.g. a font-size utility) should fail this test and force an explicit update,
// not be silently allowed through.
const ownTextUtilities = chip.className
.split(/\s+/)
.filter((cls) => cls.startsWith('text-') || cls.startsWith('[&]:text-'))
expect(ownTextUtilities).toEqual([])
// The icon's own monochrome fallback is unrelated and must be untouched by this fix.
expect(chip.className).toContain('[&>svg]:text-[var(--text-icon)]')
})
})
@@ -0,0 +1,69 @@
import type { MouseEvent } from 'react'
import { cn } from '@sim/emcn'
import type { ReactNodeViewProps } from '@tiptap/react'
import { NodeViewWrapper, ReactNodeViewRenderer } from '@tiptap/react'
import { useParams, useRouter } from 'next/navigation'
import { getBareIconStyle, type StyleableIcon } from '@/blocks/icon-color'
import { mentionIcon } from './mention-icon'
import { MarkdownMention, type MentionAttrs } from './mention-node'
import { simLinkPath } from './sim-link'
/**
* Mirrors the home chat input's mention rendering (the textarea mirror overlay
* in `prompt-editor.tsx`): a borderless inline icon + label that flows with the
* surrounding prose — no pill background, no padding, normal weight, body text
* color, and a 12px icon. Integration icons keep their brand color via
* {@link getBareIconStyle} (see {@link MentionChipView}); other kinds stay
* monochrome through the `--text-icon` fallback below.
*
* No explicit label color — an element's own explicit `color` always wins over an inherited one
* regardless of ancestor specificity, so hardcoding `--text-primary` here (redundant with the prose
* default anyway) would silently override any ambient color a ancestor legitimately sets — a link's
* blue, or `h6`'s dimmer `--text-secondary` — since a mention is inline content and can appear inside
* either. Omitting it lets the label inherit correctly in both cases, same fix as `strong`/`em`/`code`
* in rich-markdown-editor.css.
*/
const CHIP_CLASS =
'mention-chip mx-px inline-flex items-center gap-1 align-middle leading-[1.5] [&>svg]:size-[12px] [&>svg]:shrink-0 [&>svg]:text-[var(--text-icon)]'
/**
* Live chip: an entity icon + label matching the chat input's mention rendering. Where the host opted
* into navigation (the file viewer), Cmd/Ctrl-click routes to the resource; in a modal field it stays
* inert so a click can't navigate away from an unsaved edit. This view pulls the block registry (for
* integration brand icons), so it's kept out of the headless {@link MarkdownMention} module.
*/
export function MentionChipView({ node, editor }: ReactNodeViewProps) {
const router = useRouter()
const params = useParams()
const { kind, id, label } = node.attrs as MentionAttrs
const Icon = mentionIcon(kind, id) as StyleableIcon
const iconStyle = getBareIconStyle(Icon)
const navigable = editor.storage.mention?.navigable === true
const workspaceId = typeof params.workspaceId === 'string' ? params.workspaceId : undefined
const path = navigable && workspaceId ? simLinkPath(workspaceId, kind, id) : null
const handleClick = (event: MouseEvent) => {
if (!path || !(event.metaKey || event.ctrlKey)) return
event.preventDefault()
router.push(path)
}
return (
<NodeViewWrapper
as='span'
className={cn(CHIP_CLASS, path && 'cursor-pointer')}
onClick={path ? handleClick : undefined}
title={label}
>
<Icon style={iconStyle} />
<span>{label}</span>
</NodeViewWrapper>
)
}
/** Live mention node with the chip view; same schema + markdown output as the headless one. */
export const MentionChip = MarkdownMention.extend({
addNodeView() {
return ReactNodeViewRenderer(MentionChipView)
},
})
@@ -0,0 +1,17 @@
/** @vitest-environment node */
import { Box, File } from 'lucide-react'
import { describe, expect, it } from 'vitest'
import { mentionIcon } from './mention-icon'
import type { MentionKind } from './types'
describe('mentionIcon', () => {
it('returns the category icon for a known kind', () => {
expect(mentionIcon('file', 'x')).toBe(File)
})
it('falls back to a generic icon for an empty or unrecognized kind (never undefined)', () => {
// The schema default is '' and a sim: link could carry a future kind — neither may crash render.
expect(mentionIcon('' as unknown as MentionKind, 'x')).toBe(Box)
expect(mentionIcon('dataset' as unknown as MentionKind, 'x')).toBe(Box)
})
})
@@ -0,0 +1,29 @@
import type { ComponentType } from 'react'
import { Box, Database, File, Folder, Sparkles, Table, Workflow } from 'lucide-react'
import { getBlock } from '@/blocks/registry'
import type { MentionKind } from './types'
/** Icon component shape both the lucide kind-icons and the brand block icons satisfy. */
export type MentionIcon = ComponentType<{ className?: string }>
const KIND_ICONS: Record<Exclude<MentionKind, 'integration'>, MentionIcon> = {
file: File,
folder: Folder,
table: Table,
knowledge: Database,
workflow: Workflow,
skill: Sparkles,
}
/**
* Resolves the icon for a mention. Integrations use their brand icon from the block registry (keyed by
* blockType, which is the mention `id`), falling back to a generic icon if the block was since removed;
* every other kind uses a lucide category icon, falling back to the same generic icon for an empty or
* unrecognized kind (the schema default is `''`, and a `sim:` link could carry a kind a future version
* adds) — so the result is always a real component and the chip is never icon-less. Shared by the menu
* rows and the inserted chip so both render the same icon.
*/
export function mentionIcon(kind: MentionKind, id: string): MentionIcon {
if (kind === 'integration') return (getBlock(id)?.icon as MentionIcon | undefined) ?? Box
return KIND_ICONS[kind] ?? Box
}
@@ -0,0 +1,166 @@
/**
* @vitest-environment jsdom
*
* Guards the `@` menu's keyboard navigation against the async-data race: the suggestion plugin grabs
* the list's `onKeyDown` handle once, but workspace items arrive later via the store. The handle must
* read live values so arrow/enter work after the data lands (otherwise keys fall through to the editor).
* The second test drives the real `ReactRenderer` path the suggestion plugin actually uses.
*/
import { act, createRef } from 'react'
import { Editor } from '@tiptap/core'
import { EditorContent, ReactRenderer } from '@tiptap/react'
import { File } from 'lucide-react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createMarkdownEditorExtensions } from '../editor-extensions'
import { MentionList, type MentionListHandle } from './mention-list'
import { createMentionStore } from './mention-store'
import type { MentionItem } from './types'
const items: MentionItem[] = [
{ kind: 'file', id: 'a', label: 'Alpha', group: 'Files', icon: File },
{ kind: 'file', id: 'b', label: 'Beta', group: 'Files', icon: File },
]
const arrowDown = { event: new KeyboardEvent('keydown', { key: 'ArrowDown' }) }
const enter = { event: new KeyboardEvent('keydown', { key: 'Enter' }) }
const tab = { event: new KeyboardEvent('keydown', { key: 'Tab' }) }
describe('MentionList keyboard nav', () => {
let container: HTMLElement
let root: ReturnType<typeof import('react-dom/client').createRoot>
let editor: Editor
beforeEach(async () => {
// jsdom implements neither — both are exercised by scroll-into-view and ProseMirror.
Element.prototype.scrollIntoView = vi.fn()
document.elementFromPoint = vi.fn(() => null)
editor = new Editor({ extensions: createMarkdownEditorExtensions({ placeholder: '' }) })
const { createRoot } = await import('react-dom/client')
container = document.createElement('div')
document.body.appendChild(container)
root = createRoot(container)
})
afterEach(() => {
act(() => root.unmount())
container.remove()
editor.destroy()
})
it('navigates with arrows + inserts on enter once async items have loaded', () => {
const ref = createRef<MentionListHandle>()
const command = vi.fn()
const store = createMentionStore()
// Menu opens before the workspace data resolves — the store is still empty.
act(() => {
root.render(
<MentionList ref={ref} query='' command={command} store={store} editor={editor} />
)
})
expect(ref.current?.onKeyDown(arrowDown)).toBe(false)
// Async data lands; the captured handle must now see the items and intercept the keys.
act(() => store.set(items))
let handled: boolean | undefined
act(() => {
handled = ref.current?.onKeyDown(arrowDown)
})
expect(handled).toBe(true)
act(() => {
ref.current?.onKeyDown(enter)
})
expect(command).toHaveBeenCalledWith(items[1])
})
it('an active query is exempt from the per-group cap (search reaches every match)', () => {
const ref = createRef<MentionListHandle>()
const command = vi.fn()
const store = createMentionStore()
// 12 matches in one group — more than MAX_PER_GROUP (8).
const many: MentionItem[] = Array.from({ length: 12 }, (_, i) => ({
kind: 'file',
id: `x${i}`,
label: `report-${i}`,
group: 'Files',
icon: File,
}))
act(() => {
root.render(
<MentionList ref={ref} query='report' command={command} store={store} editor={editor} />
)
})
act(() => store.set(many))
expect(container.querySelectorAll('[role="option"]').length).toBe(12)
})
it('bounds the filtered list so a broad query cannot flood the menu', () => {
const ref = createRef<MentionListHandle>()
const command = vi.fn()
const store = createMentionStore()
// 200 matches — far beyond any reasonable render; the list must cap the total.
const flood: MentionItem[] = Array.from({ length: 200 }, (_, i) => ({
kind: 'file',
id: `f${i}`,
label: `alpha-${i}`,
group: 'Files',
icon: File,
}))
act(() => {
root.render(
<MentionList ref={ref} query='alpha' command={command} store={store} editor={editor} />
)
})
act(() => store.set(flood))
expect(container.querySelectorAll('[role="option"]').length).toBe(50)
})
it('accepts the active item on Tab, like Enter', () => {
const ref = createRef<MentionListHandle>()
const command = vi.fn()
const store = createMentionStore()
act(() => {
root.render(
<MentionList ref={ref} query='' command={command} store={store} editor={editor} />
)
})
act(() => store.set(items))
let handled: boolean | undefined
act(() => {
handled = ref.current?.onKeyDown(tab)
})
expect(handled).toBe(true)
expect(command).toHaveBeenCalledWith(items[0])
})
it('exposes a working onKeyDown through ReactRenderer (the suggestion plugin path)', async () => {
act(() => {
root.render(<EditorContent editor={editor} />)
})
const command = vi.fn()
const store = createMentionStore()
const renderer = new ReactRenderer<MentionListHandle>(MentionList, {
editor,
props: { query: '', command, store, editor },
})
// Let the portal mount so ReactRenderer captures the imperative handle.
await act(async () => {})
expect(renderer.ref).not.toBeNull()
expect(renderer.ref?.onKeyDown(arrowDown)).toBe(false)
act(() => store.set(items))
expect(renderer.ref?.onKeyDown(arrowDown)).toBe(true)
renderer.destroy()
})
})
@@ -0,0 +1,117 @@
import { forwardRef, useImperativeHandle, useMemo, useRef, useSyncExternalStore } from 'react'
import type { Editor } from '@tiptap/core'
import { SuggestionList } from '../menus/suggestion-list'
import {
type SuggestionKeyDownHandler,
useSuggestionKeyboard,
} from '../menus/use-suggestion-keyboard'
import type { MentionStore } from './mention-store'
import type { MentionItem } from './types'
export type MentionListHandle = SuggestionKeyDownHandler
interface MentionListProps {
/** The text typed after `@`, used to filter. */
query: string
/** Inserts the chosen mention (wired to the suggestion `command`). */
command: (item: MentionItem) => void
/** Live data source the host keeps populated. */
store: MentionStore
/** The editor, wired as the ARIA combobox while the menu is open. */
editor: Editor
}
/** Per-group cap so a large workspace can't flood the *unfiltered* menu; an active query is exempt so
* search reaches every match, not just the first eight in a category. */
const MAX_PER_GROUP = 8
/** Total cap while filtering: lifts the per-group limit so search reaches deep matches, but still bounds
* the (non-virtualized) list so a broad single-char query on a huge workspace can't render thousands of
* rows. Generous enough that a real search is never truncated before the user narrows further. */
const MAX_WHEN_FILTERED = 50
/** Category heading order in the menu. */
const GROUP_ORDER = [
'Files',
'Folders',
'Tables',
'Knowledge bases',
'Workflows',
'Skills',
'Integrations',
] as const
/**
* The `@` mention popup. Sibling of {@link SlashCommandList} with identical chrome and arrow/enter
* navigation, but its items come reactively from the editor's {@link MentionStore} (via
* `useSyncExternalStore`) rather than props — so the list fills in as async workspace data lands.
*/
export const MentionList = forwardRef<MentionListHandle, MentionListProps>(function MentionList(
{ query, command, store, editor },
ref
) {
const rawItems = useSyncExternalStore(store.subscribe, store.getSnapshot, store.getSnapshot)
const containerRef = useRef<HTMLDivElement>(null)
/**
* Filtered, flattened in category order; `index` is the flat position for nav. A single pass over the
* full set filters by label and buckets by group, then reads the buckets in category order — avoiding
* a separate filter pass per group. Without a query each group is capped ({@link MAX_PER_GROUP}); with
* a query the per-group cap is lifted (so search reaches deep matches) but the total is bounded
* ({@link MAX_WHEN_FILTERED}) so a broad query can't flood the non-virtualized list.
*/
const { flat, groups } = useMemo(() => {
const q = query.trim().toLowerCase()
const byGroup = new Map<string, MentionItem[]>()
let shown = 0
for (const item of rawItems) {
if (q && !item.label.toLowerCase().includes(q)) continue
if (q && shown >= MAX_WHEN_FILTERED) break
const bucket = byGroup.get(item.group)
if (!q && bucket && bucket.length >= MAX_PER_GROUP) continue
if (bucket) bucket.push(item)
else byGroup.set(item.group, [item])
shown++
}
const ordered: { group: string; items: { item: MentionItem; index: number }[] }[] = []
const flat: MentionItem[] = []
for (const group of GROUP_ORDER) {
const inGroup = byGroup.get(group)
if (!inGroup) continue
ordered.push({ group, items: inGroup.map((item) => ({ item, index: flat.push(item) - 1 })) })
}
return { flat, groups: ordered }
}, [rawItems, query])
const { activeIndex, setActiveIndex, onKeyDown } = useSuggestionKeyboard(
flat,
command,
containerRef
)
useImperativeHandle(ref, () => ({ onKeyDown }), [onKeyDown])
return (
<SuggestionList
editor={editor}
containerRef={containerRef}
groups={groups}
activeIndex={activeIndex}
setActiveIndex={setActiveIndex}
command={command}
ariaLabel='Mentions'
idPrefix='mention'
emptyLabel={rawItems.length === 0 ? 'Loading…' : 'No results'}
itemKey={(item) => `${item.kind}:${item.id}`}
renderItem={(item) => {
const Icon = item.icon
return (
<>
{Icon && <Icon />}
<span>{item.label}</span>
</>
)
}}
/>
)
})
@@ -0,0 +1,51 @@
/**
* @vitest-environment jsdom
*
* The `@`-mention is stored as a portable `[label](sim:<kind>/<id>)` markdown link but parses into a
* dedicated `mention` node (rendered live as a chip). These guard that the parse → node → serialize
* cycle is lossless, so the chat-portable wire format and the chip rendering stay in sync.
*/
import type { JSONContent } from '@tiptap/core'
import { describe, expect, it } from 'vitest'
import { parseMarkdownToDoc, serializeMarkdownBody } from '../markdown-parse'
function findMention(node: JSONContent): JSONContent | null {
if (node.type === 'mention') return node
for (const child of node.content ?? []) {
const found = findMention(child)
if (found) return found
}
return null
}
describe('mention node round-trip', () => {
it('parses a sim: link into a mention node with kind/id/label', () => {
const doc = parseMarkdownToDoc('See [Airweave](sim:integration/airweave) here')
const mention = findMention(doc)
expect(mention).not.toBeNull()
expect(mention?.attrs).toEqual({ kind: 'integration', id: 'airweave', label: 'Airweave' })
})
it('serializes a mention node back to the portable sim: link', () => {
for (const input of [
'See [Airweave](sim:integration/airweave) here',
'[my-skill](sim:skill/abc-123)',
'a [Spec.md](sim:file/xyz_789) b',
]) {
expect(serializeMarkdownBody(input).trim()).toBe(input)
}
})
it('round-trips a label containing brackets (e.g. a bracketed file name) as a chip', () => {
const input = '[data\\[1\\].csv](sim:file/abc)'
const doc = parseMarkdownToDoc(input)
const mention = findMention(doc)
expect(mention?.attrs).toEqual({ kind: 'file', id: 'abc', label: 'data[1].csv' })
expect(serializeMarkdownBody(input).trim()).toBe(input)
})
it('leaves a normal http link as a link, not a mention', () => {
const doc = parseMarkdownToDoc('[Sim](https://sim.ai)')
expect(findMention(doc)).toBeNull()
})
})
@@ -0,0 +1,132 @@
import type { JSONContent, MarkdownToken } from '@tiptap/core'
import { InputRule, Node } from '@tiptap/core'
import { toSimHref } from './sim-link'
import type { MentionKind } from './types'
export interface MentionAttrs {
kind: MentionKind
id: string
label: string
}
/**
* The markdown form of a mention — the chat's portable `[label](sim:<kind>/<id>)` link. The label
* group accepts backslash-escaped characters so a label containing `[`/`]` (e.g. a file named
* `data[1].csv`) still round-trips into a chip instead of degrading to a plain link.
*/
const MENTION_MD_RE = /^\[((?:\\.|[^\]\\])+)\]\(sim:([a-z_]+)\/([^)\s]+)\)/
/** Escape `\`, `[`, `]` in a mention label so brackets in entity names can't break the link syntax. */
function escapeLabel(label: string): string {
return label.replace(/[\\[\]]/g, '\\$&')
}
/** Inverse of {@link escapeLabel}, applied when parsing a mention back from markdown. */
function unescapeLabel(label: string): string {
return label.replace(/\\([\\[\]])/g, '$1')
}
/** Custom fields the mention tokenizer hangs on the marked token (all optional, like the image token). */
interface MentionTokenFields {
label?: string
kind?: string
id?: string
}
/**
* Inline atom node for an `@`-mention. Renders (live) as a chip with the entity's icon, but serializes
* to the portable `[label](sim:<kind>/<id>)` markdown link — so the saved content is identical to a
* plain link (agent-readable, round-trips through the chat's `chip-clipboard-codec`) while the editor
* shows it as a chip rather than a blue link. This module is schema + markdown only (no React, no icon
* registry) so the headless round-trip path stays light; the live {@link MentionChip} node view lives in
* `mention-chip.tsx`, mirroring the image node's split. `renderText` emits the same portable link (an
* atom otherwise contributes no text), so copying a chip into a plain-text target — e.g. the chat
* composer — pastes back as a mention.
*/
export const MarkdownMention = Node.create({
name: 'mention',
inline: true,
group: 'inline',
atom: true,
selectable: true,
draggable: false,
addAttributes() {
return {
kind: { default: '' },
id: { default: '' },
label: { default: '' },
}
},
parseHTML() {
return [
{
tag: 'span[data-mention]',
getAttrs: (element) => ({
kind: element.getAttribute('data-kind') ?? '',
id: element.getAttribute('data-id') ?? '',
label: element.textContent ?? '',
}),
},
]
},
renderHTML({ node }) {
const { kind, id, label } = node.attrs as MentionAttrs
return ['span', { 'data-mention': '', 'data-kind': kind, 'data-id': id }, label]
},
markdownTokenizer: {
name: 'mention',
level: 'inline' as const,
start: (src: string) => src.indexOf('['),
tokenize: (src: string): (MentionTokenFields & { type: string; raw: string }) | undefined => {
const match = MENTION_MD_RE.exec(src)
if (!match) return undefined
return { type: 'mention', raw: match[0], label: match[1], kind: match[2], id: match[3] }
},
},
parseMarkdown: (token: MarkdownToken): JSONContent => {
const { kind, id, label } = token as MentionTokenFields
return {
type: 'mention',
attrs: { kind: kind ?? '', id: id ?? '', label: unescapeLabel(label ?? '') },
}
},
renderMarkdown: (node: JSONContent): string => {
const { kind, id, label } = (node.attrs ?? {}) as MentionAttrs
return `[${escapeLabel(label)}](${toSimHref(kind, id)})`
},
renderText: ({ node }) => {
const { kind, id, label } = node.attrs as MentionAttrs
return `[${escapeLabel(label)}](${toSimHref(kind, id)})`
},
/**
* Typing the portable `[label](sim:<kind>/<id>)` syntax inline turns it into a chip on the closing
* paren — so live typing matches the paste/load path (which converts it via the tokenizer above). The
* rule lives on this node, which sits before {@link MarkdownLinkInputRule} in the extension list, so it
* claims the `sim:` form first; every other `[text](url)` falls through to the link rule untouched.
*/
addInputRules() {
const type = this.type
return [
new InputRule({
find: /\[((?:\\.|[^\]\\])+)\]\(sim:([a-z_]+)\/([^)\s]+)\)$/,
handler: ({ state, range, match }) => {
const [, rawLabel, kind, id] = match
if (!kind || !id) return null
// Replace the whole `[label](sim:…)` match with the chip (nodeInputRule would keep the
// surrounding brackets, as it only swaps the first capture group).
state.tr.replaceWith(
range.from,
range.to,
type.create({ kind, id, label: unescapeLabel(rawLabel ?? '') })
)
},
}),
]
},
})
@@ -0,0 +1,32 @@
import type { MentionItem } from './types'
/**
* A tiny external store bridging React Query data (host component) into the `@` menu list, which is
* rendered by TipTap's `ReactRenderer` as a detached root with no access to the app's React context
* providers. The host pushes the latest items via {@link MentionStore.set}; the list subscribes with
* `useSyncExternalStore` and re-renders when async data lands — so the menu populates live even if it
* was opened before the data finished loading. One store instance lives per editor (in extension
* storage).
*/
export interface MentionStore {
getSnapshot: () => MentionItem[]
subscribe: (listener: () => void) => () => void
set: (items: MentionItem[]) => void
}
export function createMentionStore(): MentionStore {
let items: MentionItem[] = []
const listeners = new Set<() => void>()
return {
getSnapshot: () => items,
subscribe: (listener) => {
listeners.add(listener)
return () => listeners.delete(listener)
},
set: (next) => {
if (next === items) return
items = next
for (const listener of listeners) listener()
},
}
}
@@ -0,0 +1,89 @@
import { Extension } from '@tiptap/core'
import { PluginKey } from '@tiptap/pm/state'
import Suggestion from '@tiptap/suggestion'
import { createSuggestionPopupRenderer } from '../menus/suggestion-popup'
import { MentionList } from './mention-list'
import { createMentionStore, type MentionStore } from './mention-store'
import type { MentionItem } from './types'
/** Distinct from the `/` slash command's key — two plugins can't share one key. Exported so the keymap can detect an open menu. */
export const MENTION_PLUGIN_KEY = new PluginKey('mention')
/**
* Per-editor storage for the `@` mention extension. The host component populates {@link store} with
* the current workspace mention data and may set {@link onOpen} to lazily start fetching that data the
* first time the menu is triggered. {@link enabled} gates the menu off entirely (e.g. a field with no
* workspace scope) so `@` stays literal text. {@link navigable} lets a chip Cmd/Ctrl-click to its
* resource — on for the file viewer, off inside a modal field so it can't route away from an edit.
*/
export interface MentionStorage {
store: MentionStore
onOpen: (() => void) | null
enabled: boolean
navigable: boolean
}
declare module '@tiptap/core' {
interface Storage {
mention: MentionStorage
}
}
/**
* Adds the `@` mention menu to the editor. Typing `@` at the start of a block — or after whitespace, so
* `@` inside an email/handle (`name@host`) stays literal — opens {@link MentionList}; selecting an
* entity inserts it as a portable `sim:<kind>/<id>` markdown link (same wire format as the chat
* composer's `chip-clipboard-codec`), so it round-trips natively through the editor's link + markdown
* machinery. The plugin's `items` is an empty gate; the real list is sourced reactively from the store
* inside {@link MentionList}, populated by the host via the extension's `mention` storage.
*/
export const Mention = Extension.create<Record<string, never>, MentionStorage>({
name: 'mention',
addStorage() {
return { store: createMentionStore(), onOpen: null, enabled: true, navigable: false }
},
addProseMirrorPlugins() {
return [
Suggestion<MentionItem, MentionItem>({
editor: this.editor,
pluginKey: MENTION_PLUGIN_KEY,
char: '@',
allowSpaces: false,
startOfLine: false,
allow: ({ editor, range }) => {
if (!editor.storage.mention.enabled) return false
if (editor.isActive('codeBlock') || editor.isActive('link') || editor.isActive('code')) {
return false
}
const $from = editor.state.doc.resolve(range.from)
if ($from.parentOffset === 0) return true
return /\s/.test($from.parent.textBetween($from.parentOffset - 1, $from.parentOffset))
},
items: () => [],
command: ({ editor, range, props }) => {
editor
.chain()
.focus()
.deleteRange(range)
.insertContent([
{ type: 'mention', attrs: { kind: props.kind, id: props.id, label: props.label } },
{ type: 'text', text: ' ' },
])
.run()
},
render: createSuggestionPopupRenderer({
component: MentionList,
mapProps: (props) => ({
query: props.query,
command: props.command,
store: props.editor.storage.mention.store,
editor: props.editor,
}),
onOpen: (props) => props.editor.storage.mention?.onOpen?.(),
}),
}),
]
},
})
@@ -0,0 +1,22 @@
import { describe, expect, it } from 'vitest'
import { simLinkPath } from './sim-link'
describe('simLinkPath', () => {
const ws = 'ws1'
// Each destination must match a real route — skills/folders deep-link via query params (no [id] route).
it('resolves every kind to its real in-app route', () => {
expect(simLinkPath(ws, 'file', 'f1')).toBe('/workspace/ws1/files/f1/view')
expect(simLinkPath(ws, 'folder', 'd1')).toBe('/workspace/ws1/files?folderId=d1')
expect(simLinkPath(ws, 'table', 't1')).toBe('/workspace/ws1/tables/t1')
expect(simLinkPath(ws, 'knowledge', 'k1')).toBe('/workspace/ws1/knowledge/k1')
expect(simLinkPath(ws, 'workflow', 'w1')).toBe('/workspace/ws1/w/w1')
expect(simLinkPath(ws, 'skill', 's1')).toBe('/workspace/ws1/skills?skillId=s1')
})
it('returns null for kinds with no navigable resource (integration) and unknown kinds', () => {
// An integration mention's id is a block type, not a routable resource.
expect(simLinkPath(ws, 'integration', 'slack')).toBeNull()
expect(simLinkPath(ws, 'mystery', 'x')).toBeNull()
})
})
@@ -0,0 +1,38 @@
/**
* The link scheme for `@`-mention links — `[label](sim:<kind>/<id>)`. Matches the chat composer's
* portable chip format (`chip-clipboard-codec.ts`), so a mention authored here is parseable there.
*/
export const SIM_LINK_SCHEME = 'sim'
/** Builds the link target for a mention of `kind`/`id`. */
export function toSimHref(kind: string, id: string): string {
return `${SIM_LINK_SCHEME}:${kind}/${id}`
}
/**
* Resolves the in-app route for a clicked `sim:` mention, or `null` when the kind has no navigable
* destination. Each path matches the entity's real route: files open the file detail view,
* folders/skills deep-link the file browser / skills modal via their query params, the rest hit their
* `[id]` route. Integrations are intentionally non-navigable — a mention's id is a block *type*
* (`gmail_v2`), which isn't a routable resource (no per-type page; it maps to zero-or-many
* credentials), so the chip stays display-only.
*/
export function simLinkPath(workspaceId: string, kind: string, id: string): string | null {
const base = `/workspace/${workspaceId}`
switch (kind) {
case 'file':
return `${base}/files/${id}/view`
case 'folder':
return `${base}/files?folderId=${id}`
case 'table':
return `${base}/tables/${id}`
case 'knowledge':
return `${base}/knowledge/${id}`
case 'workflow':
return `${base}/w/${id}`
case 'skill':
return `${base}/skills?skillId=${id}`
default:
return null
}
}
@@ -0,0 +1,29 @@
import type { ComponentType } from 'react'
/**
* The workspace entity kinds that can be `@`-mentioned in a markdown editor. A deliberate subset of
* the chat's portable kinds (`chip-clipboard-codec.ts`) — the workspace-scoped ones that exist
* without a workflow runtime context. The string values match that codec's `sim:<kind>/<id>` scheme,
* so a mention link inserted here is parseable by `parseChipLinks()`.
*/
export type MentionKind =
| 'file'
| 'folder'
| 'table'
| 'knowledge'
| 'workflow'
| 'skill'
| 'integration'
/** A single selectable entry in the `@` menu. */
export interface MentionItem {
kind: MentionKind
/** Entity id used as `sim:<kind>/<id>` in the inserted link. */
id: string
/** Display + link text. */
label: string
/** Category heading the item is shown under. */
group: string
/** Optional per-item icon (Lucide category icon or a brand block icon). */
icon?: ComponentType<{ className?: string }>
}
@@ -0,0 +1,41 @@
import { useEffect, useState } from 'react'
import type { Editor } from '@tiptap/react'
import { useMarkdownMentions } from './use-markdown-mentions'
interface UseEditorMentionsOptions {
/** Whether a chip can Cmd/Ctrl-click to its resource. On for the file viewer, off in modal fields. */
navigable?: boolean
/** Force the `@` insertion menu off even with a workspace; existing tags still render. */
disableTagging?: boolean
}
/**
* Wires an editor's `@` mention menu to its workspace data: gates the menu on a workspace scope,
* lazily fetches the data on the first open, and feeds it into the menu's reactive store. Shared by
* every editor surface that mounts the mention extension (the file editor and the modal field).
*/
export function useEditorMentions(
editor: Editor | null,
workspaceId: string | undefined,
options?: UseEditorMentionsOptions
): void {
const [active, setActive] = useState(false)
const items = useMarkdownMentions(workspaceId, { enabled: active })
const navigable = options?.navigable ?? false
const disableTagging = options?.disableTagging ?? false
useEffect(() => {
if (!editor) return
const taggingOn = Boolean(workspaceId) && !disableTagging
editor.storage.mention.enabled = taggingOn
editor.storage.mention.navigable = navigable
editor.storage.mention.onOpen = taggingOn ? () => setActive(true) : null
return () => {
editor.storage.mention.onOpen = null
}
}, [editor, workspaceId, navigable, disableTagging])
useEffect(() => {
editor?.storage.mention.store.set(items)
}, [editor, items])
}
@@ -0,0 +1,113 @@
import { useMemo } from 'react'
import { listIntegrations } from '@/blocks/integration-matcher'
import { useKnowledgeBasesQuery } from '@/hooks/queries/kb/knowledge'
import { useSkills } from '@/hooks/queries/skills'
import { useTablesList } from '@/hooks/queries/tables'
import { useWorkflows } from '@/hooks/queries/workflows'
import { useWorkspaceFileFolders } from '@/hooks/queries/workspace-file-folders'
import { useWorkspaceFiles } from '@/hooks/queries/workspace-files'
import { mentionIcon } from './mention-icon'
import type { MentionItem } from './types'
/**
* Aggregates the workspace-scoped entities the `@` menu can reference, composing the canonical
* per-resource React Query hooks (never the chat-coupled `useAvailableResources` aggregator). All
* queries stay disabled until `enabled` flips true — the host activates it on the first `@` trigger —
* so a markdown field that never opens the menu fetches nothing.
*/
export function useMarkdownMentions(
workspaceId: string | undefined,
options: { enabled: boolean }
): MentionItem[] {
const active = options.enabled && Boolean(workspaceId)
// When inactive, `ws` is undefined and `wsStr` is '' so every query stays disabled until the first
// `@`: the hooks that expose an `enabled` option get it explicitly; the rest (which take no options)
// self-gate internally on the falsy workspaceId — both empty string and undefined read as disabled.
const ws = active ? workspaceId : undefined
const wsStr = ws ?? ''
const files = useWorkspaceFiles(wsStr, 'active', { enabled: active })
const folders = useWorkspaceFileFolders(wsStr, 'active')
const tables = useTablesList(ws, 'active')
const knowledgeBases = useKnowledgeBasesQuery(ws, { enabled: active })
const workflows = useWorkflows(ws)
const skills = useSkills(wsStr)
// The integration registry is static — materialize it once rather than on every resource refetch.
const integrationItems = useMemo<MentionItem[]>(() => {
if (!active) return []
return listIntegrations().map((integration) => ({
kind: 'integration',
id: integration.blockType,
label: integration.name,
group: 'Integrations',
icon: mentionIcon('integration', integration.blockType),
}))
}, [active])
return useMemo(() => {
if (!active) return []
const items: MentionItem[] = []
for (const file of files.data ?? [])
items.push({
kind: 'file',
id: file.id,
label: file.name,
group: 'Files',
icon: mentionIcon('file', file.id),
})
for (const folder of folders.data ?? [])
items.push({
kind: 'folder',
id: folder.id,
label: folder.name,
group: 'Folders',
icon: mentionIcon('folder', folder.id),
})
for (const table of tables.data ?? [])
items.push({
kind: 'table',
id: table.id,
label: table.name,
group: 'Tables',
icon: mentionIcon('table', table.id),
})
for (const kb of knowledgeBases.data ?? [])
items.push({
kind: 'knowledge',
id: kb.id,
label: kb.name,
group: 'Knowledge bases',
icon: mentionIcon('knowledge', kb.id),
})
for (const workflow of workflows.data ?? [])
items.push({
kind: 'workflow',
id: workflow.id,
label: workflow.name,
group: 'Workflows',
icon: mentionIcon('workflow', workflow.id),
})
for (const skill of skills.data ?? [])
items.push({
kind: 'skill',
id: skill.id,
label: skill.name,
group: 'Skills',
icon: mentionIcon('skill', skill.id),
})
items.push(...integrationItems)
return items
}, [
active,
files.data,
folders.data,
tables.data,
knowledgeBases.data,
workflows.data,
skills.data,
integrationItems,
])
}
@@ -0,0 +1,336 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { posToDOMRect } from '@tiptap/core'
import { PluginKey } from '@tiptap/pm/state'
import type { Editor } from '@tiptap/react'
import { useEditorState } from '@tiptap/react'
import { BubbleMenu } from '@tiptap/react/menus'
import {
Bold,
Check,
Code,
Heading1,
Heading2,
Highlighter,
Italic,
Link as LinkIcon,
List,
ListChecks,
ListOrdered,
Strikethrough,
TextQuote,
Unlink,
} from 'lucide-react'
import { applyLink, LinkUrlInput } from './link-editing'
import { ToolbarButton, ToolbarDivider } from './toolbar-button'
/**
* Whether the formatting toolbar may show for the given range: the editor is editable, the range
* isn't inside a code block, and it covers some non-whitespace text. Single source of truth shared by
* `shouldShow` and the pointer-release reveal so the two can't drift apart.
*/
function hasFormattableSelection(editor: Editor, from: number, to: number): boolean {
if (!editor.isEditable || editor.isActive('codeBlock')) return false
return editor.state.doc.textBetween(from, to, ' ').trim().length > 0
}
/**
* Reveals the bubble menu for the current selection. Both calls are required and must stay in order:
* `show` alone leaves the bar visible but unpositioned (its internal `updatePosition` no-ops until the
* menu is shown), so the follow-up `updatePosition` anchors it. Both are step-free transactions, so
* neither marks the document dirty.
*/
function revealBubbleMenu(editor: Editor, key: PluginKey): void {
editor.commands.setMeta(key, 'show')
editor.commands.setMeta(key, 'updatePosition')
}
/** Pins the toolbar to the viewport so it stays put while the document scrolls instead of tracking the text. */
const FLOATING_OPTIONS = { strategy: 'fixed' } as const
/** Renders into the body so a transformed/clipping ancestor can't reparent the fixed toolbar and shift it. */
const APPEND_TO_BODY = () => document.body
interface EditorBubbleMenuProps {
editor: Editor
/** The editor's scrollable viewport, used to keep the toolbar on-screen for selections taller than it. */
scrollContainerRef: React.RefObject<HTMLDivElement | null>
}
/**
* Floating formatting toolbar shown on text selection. Marks and the common
* block types; the link button swaps the bar into an inline URL editor. Richer block inserts
* live in the `/` slash menu. Active states are read through {@link useEditorState} so the bar
* stays correct without re-rendering the editor on every transaction.
*/
export function EditorBubbleMenu({ editor, scrollContainerRef }: EditorBubbleMenuProps) {
const [linkValue, setLinkValue] = useState<string | null>(null)
const linkInputRef = useRef<HTMLInputElement>(null)
const linkRangeRef = useRef<{ from: number; to: number } | null>(null)
const isEditingLink = linkValue !== null
const [bubbleMenuKey] = useState(() => new PluginKey('markdownBubbleMenu'))
const isPointerDownRef = useRef(false)
const active = useEditorState({
editor,
selector: ({ editor: e }) => ({
bold: e.isActive('bold'),
italic: e.isActive('italic'),
strike: e.isActive('strike'),
highlight: e.isActive('highlight'),
code: e.isActive('code'),
link: e.isActive('link'),
heading1: e.isActive('heading', { level: 1 }),
heading2: e.isActive('heading', { level: 2 }),
bulletList: e.isActive('bulletList'),
orderedList: e.isActive('orderedList'),
taskList: e.isActive('taskList'),
blockquote: e.isActive('blockquote'),
}),
})
useEffect(() => {
if (isEditingLink) linkInputRef.current?.focus()
}, [isEditingLink])
useEffect(() => {
const exitOnCollapse = () => {
const { from, to } = editor.state.selection
if (from === to) setLinkValue(null)
}
editor.on('selectionUpdate', exitOnCollapse)
return () => {
editor.off('selectionUpdate', exitOnCollapse)
}
}, [editor])
/**
* Linear-style reveal: the toolbar stays hidden while the pointer is down (the drag gate in
* `shouldShow`) and surfaces on release. `mouseup`/`blur` listen on `window` so a release outside
* the editor — or off-screen, where no `mouseup` fires — still clears the drag flag; otherwise it
* could wedge `true` and suppress the toolbar for later keyboard selections.
*/
useEffect(() => {
const dom = editor.view.dom
const onPointerDown = () => {
isPointerDownRef.current = true
}
const onPointerUp = () => {
if (!isPointerDownRef.current || editor.isDestroyed) return
isPointerDownRef.current = false
const { from, to } = editor.state.selection
if (hasFormattableSelection(editor, from, to)) revealBubbleMenu(editor, bubbleMenuKey)
}
const onWindowBlur = () => {
isPointerDownRef.current = false
}
dom.addEventListener('mousedown', onPointerDown)
window.addEventListener('mouseup', onPointerUp)
window.addEventListener('blur', onWindowBlur)
return () => {
dom.removeEventListener('mousedown', onPointerDown)
window.removeEventListener('mouseup', onPointerUp)
window.removeEventListener('blur', onWindowBlur)
}
}, [editor, bubbleMenuKey])
const openLinkEditor = () => {
if (editor.isActive('codeBlock') || editor.isActive('code')) return
const { from, to } = editor.state.selection
linkRangeRef.current = { from, to }
setLinkValue(editor.getAttributes('link').href ?? '')
}
useEffect(() => {
const dom = editor.view.dom
const openLinkOnShortcut = (event: KeyboardEvent) => {
if (!editor.isEditable) return
if (!(event.metaKey || event.ctrlKey) || event.isComposing) return
if (event.key?.toLowerCase() !== 'k') return
const { from, to } = editor.state.selection
if (from === to || editor.isActive('codeBlock') || editor.isActive('code')) return
event.preventDefault()
linkRangeRef.current = { from, to }
setLinkValue(editor.getAttributes('link').href ?? '')
}
dom.addEventListener('keydown', openLinkOnShortcut)
return () => {
dom.removeEventListener('keydown', openLinkOnShortcut)
}
}, [editor])
// The captured range can outlive a programmatic doc change (image insert, content sync), so
// clamp it to the current document before re-selecting to avoid a "position out of range" throw.
const selectCapturedRange = (chain: ReturnType<Editor['chain']>) => {
const range = linkRangeRef.current
if (!range) return chain
const max = editor.state.doc.content.size
return chain.setTextSelection({ from: Math.min(range.from, max), to: Math.min(range.to, max) })
}
const commitLink = () => {
applyLink(selectCapturedRange(editor.chain().focus()), linkValue ?? '')
setLinkValue(null)
}
const removeLink = () => {
applyLink(selectCapturedRange(editor.chain().focus()), '')
setLinkValue(null)
}
const anchorCacheRef = useRef<{ key: string; rect: DOMRect } | null>(null)
const resolveAnchor = useCallback(() => {
const { view, state } = editor
if (!view.dom.isConnected) return null
const { from, to } = state.selection
const key = `${from}:${to}`
if (anchorCacheRef.current?.key !== key) {
const selection = posToDOMRect(view, from, to)
const viewport = scrollContainerRef.current?.getBoundingClientRect()
const rect =
viewport && selection.height > viewport.height
? new DOMRect(
selection.left,
Math.min(Math.max(selection.top, viewport.top), viewport.bottom),
selection.width,
0
)
: selection
anchorCacheRef.current = { key, rect }
}
const { rect } = anchorCacheRef.current
return { getBoundingClientRect: () => rect, getClientRects: () => [rect] }
}, [editor, scrollContainerRef])
return (
<BubbleMenu
editor={editor}
pluginKey={bubbleMenuKey}
getReferencedVirtualElement={resolveAnchor}
options={FLOATING_OPTIONS}
appendTo={APPEND_TO_BODY}
role='toolbar'
aria-label='Text formatting'
updateDelay={0}
shouldShow={({ editor: e, from, to }) => {
// Read-only never shows the menu — even mid-link-edit (e.g. a stream starting) — so a link
// can't be applied to a doc that must not mutate.
if (!e.isEditable) return false
if (isEditingLink) return true
if (isPointerDownRef.current) return false
return hasFormattableSelection(e, from, to)
}}
className='fade-in-0 z-[var(--z-popover)] flex animate-in items-center gap-0.5 rounded-lg border border-[var(--border)] bg-[var(--bg)] p-1 shadow-sm duration-150 ease-out motion-reduce:animate-none'
>
{isEditingLink ? (
<>
<LinkUrlInput
inputRef={linkInputRef}
value={linkValue ?? ''}
onChange={setLinkValue}
onCommit={commitLink}
onCancel={() => setLinkValue(null)}
/>
{active.link && (
<ToolbarButton
icon={Unlink}
label='Remove link'
isActive={false}
onClick={removeLink}
/>
)}
<ToolbarButton icon={Check} label='Apply link' isActive={false} onClick={commitLink} />
</>
) : (
<>
<ToolbarButton
icon={Bold}
label='Bold'
shortcut='⌘B'
isActive={active.bold}
onClick={() => editor.chain().focus().toggleBold().run()}
/>
<ToolbarButton
icon={Italic}
label='Italic'
shortcut='⌘I'
isActive={active.italic}
onClick={() => editor.chain().focus().toggleItalic().run()}
/>
<ToolbarButton
icon={Strikethrough}
label='Strikethrough'
shortcut='⌘⇧S'
isActive={active.strike}
onClick={() => editor.chain().focus().toggleStrike().run()}
/>
<ToolbarButton
icon={Highlighter}
label='Highlight'
shortcut='⌘⇧H'
isActive={active.highlight}
onClick={() => editor.chain().focus().toggleMark('highlight').run()}
/>
<ToolbarButton
icon={Code}
label='Code'
shortcut='⌘E'
isActive={active.code}
onClick={() => editor.chain().focus().toggleCode().run()}
/>
<ToolbarButton
icon={LinkIcon}
label='Link'
shortcut='⌘K'
isActive={active.link}
onClick={openLinkEditor}
/>
<ToolbarDivider />
<ToolbarButton
icon={Heading1}
label='Heading 1'
shortcut='⌘⌥1'
isActive={active.heading1}
onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()}
/>
<ToolbarButton
icon={Heading2}
label='Heading 2'
shortcut='⌘⌥2'
isActive={active.heading2}
onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
/>
<ToolbarDivider />
<ToolbarButton
icon={List}
label='Bulleted list'
shortcut='⌘⇧8'
isActive={active.bulletList}
onClick={() => editor.chain().focus().toggleBulletList().run()}
/>
<ToolbarButton
icon={ListOrdered}
label='Numbered list'
shortcut='⌘⇧7'
isActive={active.orderedList}
onClick={() => editor.chain().focus().toggleOrderedList().run()}
/>
<ToolbarButton
icon={ListChecks}
label='Checklist'
shortcut='⌘⇧9'
isActive={active.taskList}
onClick={() => editor.chain().focus().toggleTaskList().run()}
/>
<ToolbarButton
icon={TextQuote}
label='Quote'
shortcut='⌘⇧B'
isActive={active.blockquote}
onClick={() => editor.chain().focus().toggleBlockquote().run()}
/>
</>
)}
</BubbleMenu>
)
}
@@ -0,0 +1,53 @@
import type { Ref } from 'react'
import type { ChainedCommands } from '@tiptap/core'
import { normalizeLinkHref } from '../markdown-fidelity'
/**
* Applies a link to the chain's current selection: normalizes `rawHref`, expands to the full link
* mark, and sets it — or removes the link when the href is empty/unsafe. The caller supplies a chain
* already focused with the target selection (the captured bubble-menu range / the hovered link range).
*/
export function applyLink(chain: ChainedCommands, rawHref: string): void {
const href = normalizeLinkHref(rawHref.trim())
chain.extendMarkRange('link')
if (href) chain.setLink({ href })
else chain.unsetLink()
chain.run()
}
interface LinkUrlInputProps {
value: string
onChange: (value: string) => void
onCommit: () => void
onCancel: () => void
inputRef: Ref<HTMLInputElement>
}
/**
* The inline link-URL field shared by the bubble menu and the link hover card — Enter commits, Escape
* cancels. Styled to sit flush in the 28px floating micro-toolbar (a `ChipInput` would impose its own
* field chrome and break the bar), so this is a deliberate raw `<input>`.
*/
export function LinkUrlInput({ value, onChange, onCommit, onCancel, inputRef }: LinkUrlInputProps) {
return (
<input
ref={inputRef}
aria-label='Link URL'
type='text'
inputMode='url'
value={value}
onChange={(event) => onChange(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.preventDefault()
onCommit()
} else if (event.key === 'Escape') {
event.preventDefault()
onCancel()
}
}}
placeholder='Paste or type a link…'
className='h-[28px] w-[220px] bg-transparent px-2 text-[var(--text-body)] text-small outline-none placeholder:text-[var(--text-subtle)]'
/>
)
}
@@ -0,0 +1,194 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { autoUpdate, computePosition, flip, offset, shift } from '@floating-ui/dom'
import { useCopyToClipboard } from '@sim/emcn'
import { getMarkRange } from '@tiptap/core'
import type { Editor } from '@tiptap/react'
import { Check, Copy, Pencil, Unlink } from 'lucide-react'
import { createPortal } from 'react-dom'
import { normalizeLinkHref } from '../markdown-fidelity'
import { applyLink, LinkUrlInput } from './link-editing'
import { ToolbarButton } from './toolbar-button'
interface LinkHoverCardProps {
editor: Editor
}
interface LinkRange {
from: number
to: number
href: string
}
/** Resolves the document range and href of the link rendered by `el`, or null if it isn't a link. */
function resolveLinkRange(editor: Editor, el: HTMLElement): LinkRange | null {
const { state } = editor.view
const linkType = state.schema.marks.link
if (!linkType) return null
const pos = editor.view.posAtDOM(el, 0)
if (pos < 0) return null
const range =
getMarkRange(state.doc.resolve(pos), linkType) ??
getMarkRange(state.doc.resolve(pos + 1), linkType)
if (!range) return null
const href = el.getAttribute('href') ?? ''
return { from: range.from, to: range.to, href }
}
/**
* Floating card shown when hovering a link, so the destination is visible even when the link text
* differs from the URL. The URL opens in a new tab; Copy is always available, while Edit (inline) and
* Remove require an editable document. Positioned with Floating UI against the hovered anchor; a short
* close delay plus the card's own hover bridge let the pointer travel from the link into the card.
*/
export function LinkHoverCard({ editor }: LinkHoverCardProps) {
const [activeLink, setActiveLink] = useState<HTMLElement | null>(null)
const [draftHref, setDraftHref] = useState<string | null>(null)
const [position, setPosition] = useState<{ x: number; y: number } | null>(null)
const isEditing = draftHref !== null
const editInputRef = useRef<HTMLInputElement>(null)
const floatingRef = useRef<HTMLDivElement>(null)
const { copied, copy } = useCopyToClipboard()
const hideTimerRef = useRef<number | undefined>(undefined)
// Keep the card anchored to the hovered link with Floating UI's DOM core (the same primitive the
// bubble menu positions through) — no React wrapper, so the harness/app share one React instance.
useEffect(() => {
const floating = floatingRef.current
if (!activeLink || !floating) {
setPosition(null)
return
}
return autoUpdate(activeLink, floating, () => {
computePosition(activeLink, floating, {
strategy: 'fixed',
placement: 'top',
middleware: [offset(8), flip({ padding: 8 }), shift({ padding: 8 })],
}).then(({ x, y }) => setPosition({ x, y }))
})
}, [activeLink])
const cancelHide = useCallback(() => window.clearTimeout(hideTimerRef.current), [])
const dismiss = useCallback(() => {
cancelHide()
setActiveLink(null)
setDraftHref(null)
}, [cancelHide])
const scheduleHide = useCallback(() => {
cancelHide()
hideTimerRef.current = window.setTimeout(() => {
setActiveLink(null)
setDraftHref(null)
}, 120)
}, [cancelHide])
useEffect(() => {
const dom = editor.view.dom
const onOver = (event: Event) => {
// Don't compete with the selection toolbar while text is selected.
if (!editor.state.selection.empty) return
const link = (event.target as HTMLElement | null)?.closest('a')
if (link && dom.contains(link)) {
cancelHide()
setActiveLink(link)
}
}
const onOut = (event: MouseEvent) => {
const link = (event.target as HTMLElement | null)?.closest('a')
if (!link) return
// Ignore moves that stay within the same link.
if (link.contains(event.relatedTarget as Node | null)) return
scheduleHide()
}
dom.addEventListener('mouseover', onOver)
dom.addEventListener('mouseout', onOut)
return () => {
dom.removeEventListener('mouseover', onOver)
dom.removeEventListener('mouseout', onOut)
window.clearTimeout(hideTimerRef.current)
}
}, [editor, cancelHide, scheduleHide])
useEffect(() => {
if (isEditing) editInputRef.current?.focus()
}, [isEditing])
if (!activeLink) return null
const rawHref = activeLink.getAttribute('href') ?? ''
const safeHref = normalizeLinkHref(rawHref)
const canEdit = editor.isEditable
const startEdit = () => setDraftHref(rawHref)
const commitEdit = () => {
const range = resolveLinkRange(editor, activeLink)
if (range) applyLink(editor.chain().focus().setTextSelection(range), draftHref ?? '')
dismiss()
}
const removeLink = () => {
const range = resolveLinkRange(editor, activeLink)
if (range) applyLink(editor.chain().focus().setTextSelection(range), '')
dismiss()
}
return createPortal(
<div
ref={floatingRef}
style={{
position: 'fixed',
top: 0,
left: 0,
transform: position ? `translate(${position.x}px, ${position.y}px)` : undefined,
opacity: position ? 1 : 0,
pointerEvents: position ? undefined : 'none',
}}
role='dialog'
aria-label='Link'
onMouseEnter={cancelHide}
onMouseLeave={scheduleHide}
className='z-[var(--z-popover)] flex items-center gap-0.5 rounded-lg border border-[var(--border)] bg-[var(--bg)] p-1 shadow-sm transition-opacity duration-150 ease-out'
>
{isEditing ? (
<>
<LinkUrlInput
inputRef={editInputRef}
value={draftHref ?? ''}
onChange={setDraftHref}
onCommit={commitEdit}
onCancel={() => setDraftHref(null)}
/>
<ToolbarButton icon={Check} label='Apply link' onClick={commitEdit} />
</>
) : (
<>
{safeHref ? (
<a
href={safeHref}
target='_blank'
rel='noopener noreferrer'
title={rawHref}
className='max-w-[260px] truncate px-2 text-[var(--text-body)] text-small hover:underline'
>
{rawHref}
</a>
) : (
<span className='max-w-[260px] truncate px-2 text-[var(--text-muted)] text-small'>
{rawHref}
</span>
)}
<ToolbarButton
icon={copied ? Check : Copy}
label={copied ? 'Copied' : 'Copy link'}
onClick={() => {
void copy(rawHref)
}}
/>
{canEdit && <ToolbarButton icon={Pencil} label='Edit link' onClick={startEdit} />}
{canEdit && <ToolbarButton icon={Unlink} label='Remove link' onClick={removeLink} />}
</>
)}
</div>,
document.body
)
}
@@ -0,0 +1,135 @@
import { type ReactNode, type RefObject, useEffect } from 'react'
import { cn } from '@sim/emcn'
import type { Editor } from '@tiptap/core'
import {
SUGGESTION_GROUP_LABEL_CLASS,
SUGGESTION_ITEM_CLASS,
SUGGESTION_SCROLL_CLASS,
SUGGESTION_SURFACE_CLASS,
} from './suggestion-menu-chrome'
/** A labeled run of items; `index` is each item's flat position, used for keyboard nav + scroll. */
export interface SuggestionGroup<T> {
group: string
items: { item: T; index: number }[]
}
interface SuggestionListProps<T> {
/** The editor whose contenteditable keeps focus while the menu is open — wired as the ARIA combobox. */
editor: Editor
/** Scroll container ref, shared with the list's `useSuggestionKeyboard` for scroll-into-view. */
containerRef: RefObject<HTMLDivElement | null>
groups: SuggestionGroup<T>[]
activeIndex: number
setActiveIndex: (index: number) => void
/** Inserts the chosen item (the suggestion plugin's `command`). */
command: (item: T) => void
ariaLabel: string
/** Prefix for each row's element id (`${idPrefix}-${index}`) and the listbox id. */
idPrefix: string
/** Shown in place of the list when there are no groups (e.g. "No results" / "Loading…"). */
emptyLabel: string
itemKey: (item: T) => string
renderItem: (item: T) => ReactNode
}
/**
* The shared grouped-list shell for the `/` and `@` suggestion menus: the bordered surface, the empty
* state, the `role="listbox"` → `role="group"` → option-button structure, and the active-row / hover /
* mousedown-select wiring. Each menu computes its own `groups` and supplies `itemKey`/`renderItem`;
* everything else (chrome, a11y, navigation hooks) lives here so the two menus stay identical.
*
* Accessibility: focus stays in the editor's contenteditable while the user arrows the menu, so the
* editor is wired as the combobox — it gets `aria-haspopup`/`aria-expanded`/`aria-controls` and an
* `aria-activedescendant` pointing at the active option's id, the standard pattern for announcing the
* active row without moving focus. The attributes are removed when the menu closes (unmount).
*/
export function SuggestionList<T>({
editor,
containerRef,
groups,
activeIndex,
setActiveIndex,
command,
ariaLabel,
idPrefix,
emptyLabel,
itemKey,
renderItem,
}: SuggestionListProps<T>) {
const listboxId = `${idPrefix}-listbox`
const hasOptions = groups.length > 0
const activeOptionId = hasOptions ? `${idPrefix}-${activeIndex}` : null
useEffect(() => {
const dom = editor.view.dom
dom.setAttribute('aria-haspopup', 'listbox')
dom.setAttribute('aria-expanded', 'true')
return () => {
dom.removeAttribute('aria-haspopup')
dom.removeAttribute('aria-expanded')
dom.removeAttribute('aria-controls')
dom.removeAttribute('aria-activedescendant')
}
}, [editor])
useEffect(() => {
const dom = editor.view.dom
if (activeOptionId) {
dom.setAttribute('aria-controls', listboxId)
dom.setAttribute('aria-activedescendant', activeOptionId)
} else {
dom.removeAttribute('aria-controls')
dom.removeAttribute('aria-activedescendant')
}
}, [editor, listboxId, activeOptionId])
if (!hasOptions) {
return (
<div className={SUGGESTION_SURFACE_CLASS}>
<p role='status' className='px-2 py-1.5 text-[var(--text-tertiary)] text-caption'>
{emptyLabel}
</p>
</div>
)
}
return (
<div
ref={containerRef}
id={listboxId}
role='listbox'
aria-label={ariaLabel}
className={cn(SUGGESTION_SURFACE_CLASS, SUGGESTION_SCROLL_CLASS)}
>
{groups.map((group) => (
<div key={group.group} role='group' aria-label={group.group}>
<p aria-hidden='true' className={SUGGESTION_GROUP_LABEL_CLASS}>
{group.group}
</p>
{group.items.map(({ item, index }) => (
<button
key={itemKey(item)}
type='button'
role='option'
id={`${idPrefix}-${index}`}
aria-selected={index === activeIndex}
data-index={index}
className={cn(
SUGGESTION_ITEM_CLASS,
index === activeIndex && 'bg-[var(--surface-active)]'
)}
onMouseEnter={() => setActiveIndex(index)}
onMouseDown={(event) => {
event.preventDefault()
command(item)
}}
>
{renderItem(item)}
</button>
))}
</div>
))}
</div>
)
}
@@ -0,0 +1,23 @@
/**
* Shared chrome for the editor's keyboard-driven suggestion popups — the `/` slash-command menu and
* the `@` mention menu. Single source of truth so the two read identically; never re-derive these
* class strings per consumer.
*/
/** The floating panel: bordered card with the enter animation, width-capped like the chat mention menu. */
export const SUGGESTION_SURFACE_CLASS =
'min-w-[220px] max-w-[320px] origin-top-left animate-in rounded-xl border border-[var(--border)] bg-[var(--bg)] p-1.5 shadow-sm duration-100 fade-in-0 zoom-in-95 slide-in-from-top-2 motion-reduce:animate-none'
/**
* A scrollable list body, added alongside {@link SUGGESTION_SURFACE_CLASS}. Caps the height and scrolls
* — matching the chat composer's `@` menu — so a long workspace list never overflows its container.
*/
export const SUGGESTION_SCROLL_CLASS = 'max-h-[240px] scroll-py-1.5 overflow-y-auto overscroll-none'
/** A selectable row: icon + label, 14px icon in `--text-icon`, truncating label. */
export const SUGGESTION_ITEM_CLASS =
'relative flex w-full min-w-0 cursor-pointer select-none items-center gap-2 rounded-[5px] px-2 py-1.5 text-left font-medium text-[var(--text-body)] text-caption outline-none transition-colors [&>span]:min-w-0 [&>span]:truncate [&_svg]:pointer-events-none [&_svg]:size-[14px] [&_svg]:shrink-0 [&_svg]:text-[var(--text-icon)]'
/** A group heading above a run of rows. */
export const SUGGESTION_GROUP_LABEL_CLASS =
'px-2 pt-1.5 pb-1 font-medium text-[var(--text-muted)] text-micro uppercase tracking-wide'
@@ -0,0 +1,107 @@
import type { ForwardRefExoticComponent, PropsWithoutRef, RefAttributes } from 'react'
import { autoUpdate, computePosition, flip, offset, shift } from '@floating-ui/dom'
import { ReactRenderer } from '@tiptap/react'
import type { SuggestionOptions, SuggestionProps } from '@tiptap/suggestion'
/** The imperative handle every suggestion list exposes so the popup can forward arrow/enter keys to it. */
export interface SuggestionListHandle {
onKeyDown: (props: { event: KeyboardEvent }) => boolean
}
type AnySuggestionProps = SuggestionProps<unknown, unknown>
function positionPopup(element: HTMLElement, getRect: AnySuggestionProps['clientRect']) {
const rect = getRect?.()
if (!rect) return
const virtualEl = { getBoundingClientRect: () => rect }
computePosition(virtualEl, element, {
placement: 'bottom-start',
strategy: 'fixed',
middleware: [offset(6), flip({ padding: 8 }), shift({ padding: 8 })],
}).then(({ x, y }) => {
if (!element.isConnected) return
element.style.left = `${x}px`
element.style.top = `${y}px`
})
}
interface SuggestionPopupConfig<P, H extends SuggestionListHandle> {
/** The React list component, mounted via `ReactRenderer` into a detached, floating body element. */
component: ForwardRefExoticComponent<PropsWithoutRef<P> & RefAttributes<H>>
/** Maps the live suggestion props to the list component's props. */
mapProps: (props: AnySuggestionProps) => P
/** Called once when the popup opens, before mount — e.g. to lazily start data fetching. */
onOpen?: (props: AnySuggestionProps) => void
}
/**
* Builds the `render` lifecycle for a `@tiptap/suggestion` popup: mounts a React list into a fixed,
* floating-ui-positioned body element, repositions on update/scroll, forwards keys to the list's
* imperative handle, and tears everything down on exit / Escape / editor-destroy. Shared by the `/`
* slash command and the `@` mention menu so the popup mechanics live in exactly one place.
*
* `onStart` runs after an async suggestion update (it awaits `items()`), so it can fire once the editor
* is already destroyed — e.g. a modal closed while the menu was opening — and bails in that case before
* touching the now-unavailable view/storage. The popup mounts inside the host `[role="dialog"]` when
* the editor is in a modal: Radix's scroll-lock blocks wheel events outside the dialog subtree, so a
* body-level popup couldn't be scrolled; `position: fixed` keeps it viewport-positioned (the modal
* centers via flex, no transform) so it isn't clipped.
*/
export function createSuggestionPopupRenderer<P, H extends SuggestionListHandle>(
config: SuggestionPopupConfig<P, H>
): NonNullable<SuggestionOptions['render']> {
return () => {
let component: ReactRenderer<H> | null = null
let popup: HTMLElement | null = null
let boundEditor: AnySuggestionProps['editor'] | null = null
let stopAutoUpdate: (() => void) | null = null
const teardown = () => {
stopAutoUpdate?.()
stopAutoUpdate = null
boundEditor?.off('destroy', teardown)
boundEditor = null
popup?.remove()
component?.destroy()
popup = null
component = null
}
return {
onStart: (props) => {
teardown()
if (props.editor.isDestroyed) return
config.onOpen?.(props)
component = new ReactRenderer(config.component, {
props: config.mapProps(props) as Record<string, unknown>,
editor: props.editor,
})
popup = document.createElement('div')
popup.className = 'fixed top-0 left-0 z-[var(--z-popover)]'
popup.appendChild(component.element)
const host = props.editor.view.dom.closest('[role="dialog"]') ?? document.body
host.appendChild(popup)
boundEditor = props.editor
boundEditor.on('destroy', teardown)
const reference = { getBoundingClientRect: () => props.clientRect?.() ?? new DOMRect() }
const surface = popup
stopAutoUpdate = autoUpdate(reference, surface, () =>
positionPopup(surface, props.clientRect)
)
},
onUpdate: (props) => {
component?.updateProps(config.mapProps(props) as Record<string, unknown>)
if (popup) positionPopup(popup, props.clientRect)
},
onKeyDown: (props) => {
if (props.event.isComposing) return false
if (props.event.key === 'Escape') {
teardown()
return true
}
return component?.ref?.onKeyDown(props) ?? false
},
onExit: teardown,
}
}
}
@@ -0,0 +1,131 @@
/**
* @vitest-environment jsdom
*
* `TableBubbleMenu` (table-menu.tsx) is a thin UI wrapper around `@tiptap/extension-table`'s stock
* commands — the button that matters is the command it calls, not the floating-toolbar chrome. These
* exercise the exact commands the toolbar wires up (`addRowBefore`/`addRowAfter`/`deleteRow`,
* `addColumnBefore`/`addColumnAfter`/`deleteColumn`, `toggleHeaderRow`, `deleteTable`) against a real
* editor and assert the result round-trips through `PipeSafeTable` to clean, correctly-shaped GFM.
*/
import { Editor } from '@tiptap/core'
import { afterEach, describe, expect, it } from 'vitest'
import { createMarkdownContentExtensions } from '../extensions'
let editor: Editor | null = null
afterEach(() => {
editor?.destroy()
editor = null
})
function mount(markdown: string): Editor {
return new Editor({
extensions: createMarkdownContentExtensions(),
content: markdown,
contentType: 'markdown',
})
}
function firstCellPos(ed: Editor): number {
let pos = -1
ed.state.doc.descendants((node, p) => {
if (pos < 0 && (node.type.name === 'tableCell' || node.type.name === 'tableHeader')) pos = p + 1
})
return pos
}
describe('table toolbar commands', () => {
it('inserts a row after the current row and it round-trips as a clean GFM table', () => {
editor = mount('| a | b |\n| --- | --- |\n| 1 | 2 |')
editor.commands.setTextSelection(firstCellPos(editor))
expect(editor.commands.addRowAfter()).toBe(true)
const rows = editor.state.doc.firstChild
expect(rows?.type.name).toBe('table')
expect(rows?.childCount).toBe(3) // header + original row + inserted row
const md = editor.getMarkdown().trim()
expect(md.split('\n')).toHaveLength(4)
expect(md).toContain('| a')
expect(md).toContain('| --- | --- |')
})
it('inserts a row before the current row', () => {
editor = mount('| a | b |\n| --- | --- |\n| 1 | 2 |')
editor.commands.setTextSelection(firstCellPos(editor))
expect(editor.commands.addRowBefore()).toBe(true)
expect(editor.state.doc.firstChild?.childCount).toBe(3)
})
it('deletes the current row', () => {
editor = mount('| a | b |\n| --- | --- |\n| 1 | 2 |\n| 3 | 4 |')
// Select the second body row (skip header).
let pos = -1
let seen = 0
editor.state.doc.descendants((node, p) => {
if (node.type.name === 'tableRow') {
seen++
if (seen === 3) pos = p + 2
}
})
editor.commands.setTextSelection(pos)
expect(editor.commands.deleteRow()).toBe(true)
expect(editor.state.doc.firstChild?.childCount).toBe(2)
expect(editor.getMarkdown()).toContain('| 1 | 2 |')
expect(editor.getMarkdown()).not.toContain('3')
})
it('inserts and deletes a column', () => {
editor = mount('| a | b |\n| --- | --- |\n| 1 | 2 |')
editor.commands.setTextSelection(firstCellPos(editor))
expect(editor.commands.addColumnAfter()).toBe(true)
let cols = 0
editor.state.doc.descendants((node) => {
if (node.type.name === 'tableRow' && cols === 0) cols = node.childCount
})
expect(cols).toBe(3)
// The insert shifted positions — the cursor's old cell no longer maps to the same column, so
// re-select the first cell before deleting, exactly as a real user would click a cell first.
editor.commands.setTextSelection(firstCellPos(editor))
expect(editor.commands.deleteColumn()).toBe(true)
editor.state.doc.descendants((node) => {
if (node.type.name === 'tableRow') cols = node.childCount
})
expect(cols).toBe(2)
})
it('toggles the header row', () => {
editor = mount('| a | b |\n| --- | --- |\n| 1 | 2 |')
editor.commands.setTextSelection(firstCellPos(editor))
const before = editor.isActive('tableHeader')
expect(editor.commands.toggleHeaderRow()).toBe(true)
expect(editor.isActive('tableHeader')).toBe(!before)
})
it('deletes the whole table', () => {
editor = mount('before\n\n| a | b |\n| --- | --- |\n| 1 | 2 |\n\nafter')
editor.commands.setTextSelection(firstCellPos(editor))
expect(editor.commands.deleteTable()).toBe(true)
const types: string[] = []
editor.state.doc.forEach((node) => types.push(node.type.name))
expect(types).not.toContain('table')
expect(editor.getMarkdown()).toContain('before')
expect(editor.getMarkdown()).toContain('after')
})
it('a full add-row + add-column + delete-row sequence stays idempotent on re-serialize', () => {
editor = mount('| a | b |\n| --- | --- |\n| 1 | 2 |')
editor.commands.setTextSelection(firstCellPos(editor))
editor.commands.addRowAfter()
editor.commands.addColumnAfter()
const once = editor.getMarkdown().trim()
const reparsed = new Editor({
extensions: createMarkdownContentExtensions(),
content: once,
contentType: 'markdown',
})
const twice = reparsed.getMarkdown().trim()
reparsed.destroy()
expect(twice).toBe(once)
})
})
@@ -0,0 +1,128 @@
import { useCallback, useState } from 'react'
import { posToDOMRect } from '@tiptap/core'
import { PluginKey } from '@tiptap/pm/state'
import type { Editor } from '@tiptap/react'
import { useEditorState } from '@tiptap/react'
import { BubbleMenu } from '@tiptap/react/menus'
import {
ArrowDown,
ArrowLeft,
ArrowRight,
ArrowUp,
Columns3,
Rows3,
Table as TableIcon,
Trash2,
} from 'lucide-react'
import { ToolbarButton, ToolbarDivider } from './toolbar-button'
/** Pins the toolbar to the viewport instead of tracking the (often wide) table as it scrolls horizontally. */
const FLOATING_OPTIONS = { strategy: 'fixed' } as const
/** Renders into the body so a transformed/clipping ancestor can't reparent the fixed toolbar and shift it. */
const APPEND_TO_BODY = () => document.body
interface TableBubbleMenuProps {
editor: Editor
/** The editor's scrollable viewport, used to keep the toolbar on-screen for a table taller than it. */
scrollContainerRef: React.RefObject<HTMLDivElement | null>
}
/**
* Floating toolbar shown whenever the selection is inside a table: row/column insert-before/after,
* row/column delete, header-row toggle, and delete-table. `@tiptap/extension-table` already exposes
* all of these as editor commands (`addRowBefore`, `addColumnAfter`, …) — this is UI only, no schema
* or serializer change.
*/
export function TableBubbleMenu({ editor, scrollContainerRef }: TableBubbleMenuProps) {
const [menuKey] = useState(() => new PluginKey('markdownTableMenu'))
const active = useEditorState({
editor,
selector: ({ editor: e }) => ({
headerRow: e.isActive('tableHeader'),
}),
})
// Recomputed on every call (not cached by selection key) — the same table cell can land at a
// different screen position purely from scrolling with no selection change, and Floating UI's
// `autoUpdate` re-invokes this on scroll/resize expecting a fresh rect each time.
const resolveAnchor = useCallback(() => {
const { view, state } = editor
if (!view.dom.isConnected) return null
const { from, to } = state.selection
const selection = posToDOMRect(view, from, to)
const viewport = scrollContainerRef.current?.getBoundingClientRect()
const rect =
viewport && selection.top < viewport.top
? new DOMRect(selection.left, viewport.top, selection.width, 0)
: selection
return { getBoundingClientRect: () => rect, getClientRects: () => [rect] }
}, [editor, scrollContainerRef])
return (
<BubbleMenu
editor={editor}
pluginKey={menuKey}
getReferencedVirtualElement={resolveAnchor}
options={FLOATING_OPTIONS}
appendTo={APPEND_TO_BODY}
role='toolbar'
aria-label='Table editing'
updateDelay={0}
shouldShow={({ editor: e }) => e.isEditable && e.isActive('table')}
className='fade-in-0 z-[var(--z-popover)] flex animate-in items-center gap-0.5 rounded-lg border border-[var(--border)] bg-[var(--bg)] p-1 shadow-sm duration-150 ease-out motion-reduce:animate-none'
>
<ToolbarButton
icon={ArrowUp}
label='Insert row above'
isActive={false}
onClick={() => editor.chain().focus().addRowBefore().run()}
/>
<ToolbarButton
icon={ArrowDown}
label='Insert row below'
isActive={false}
onClick={() => editor.chain().focus().addRowAfter().run()}
/>
<ToolbarButton
icon={Rows3}
label='Delete row'
isActive={false}
onClick={() => editor.chain().focus().deleteRow().run()}
/>
<ToolbarDivider />
<ToolbarButton
icon={ArrowLeft}
label='Insert column left'
isActive={false}
onClick={() => editor.chain().focus().addColumnBefore().run()}
/>
<ToolbarButton
icon={ArrowRight}
label='Insert column right'
isActive={false}
onClick={() => editor.chain().focus().addColumnAfter().run()}
/>
<ToolbarButton
icon={Columns3}
label='Delete column'
isActive={false}
onClick={() => editor.chain().focus().deleteColumn().run()}
/>
<ToolbarDivider />
<ToolbarButton
icon={TableIcon}
label='Toggle header row'
isActive={active.headerRow}
onClick={() => editor.chain().focus().toggleHeaderRow().run()}
/>
<ToolbarButton
icon={Trash2}
label='Delete table'
isActive={false}
onClick={() => editor.chain().focus().deleteTable().run()}
/>
</BubbleMenu>
)
}
@@ -0,0 +1,49 @@
import { cn, Tooltip } from '@sim/emcn'
import type { LucideIcon } from 'lucide-react'
interface ToolbarButtonProps {
icon: LucideIcon
label: string
shortcut?: string
isActive?: boolean
onClick: () => void
}
/** A single icon button for the editor's floating toolbars (bubble menu, link hover card). */
export function ToolbarButton({
icon: Icon,
label,
shortcut,
isActive = false,
onClick,
}: ToolbarButtonProps) {
return (
<Tooltip.Root>
<Tooltip.Trigger asChild>
<button
type='button'
aria-label={label}
aria-pressed={isActive}
onMouseDown={(event) => event.preventDefault()}
onClick={onClick}
className={cn(
'flex size-[28px] items-center justify-center rounded-md text-[var(--text-icon)] outline-none transition-colors focus-visible:bg-[var(--surface-hover)] [&_svg]:size-[14px]',
isActive
? 'bg-[var(--surface-active)] text-[var(--text-body)]'
: 'hover-hover:bg-[var(--surface-hover)]'
)}
>
<Icon />
</button>
</Tooltip.Trigger>
<Tooltip.Content>
{shortcut ? <Tooltip.Shortcut keys={shortcut}>{label}</Tooltip.Shortcut> : label}
</Tooltip.Content>
</Tooltip.Root>
)
}
/** Thin vertical separator between groups of {@link ToolbarButton}s. */
export function ToolbarDivider() {
return <div className='mx-0.5 h-[18px] w-px bg-[var(--border-1)]' />
}
@@ -0,0 +1,73 @@
import {
type Dispatch,
type RefObject,
type SetStateAction,
useCallback,
useEffect,
useRef,
useState,
} from 'react'
/** The imperative `onKeyDown` every suggestion list forwards from the popup. */
export interface SuggestionKeyDownHandler {
onKeyDown: (props: { event: KeyboardEvent }) => boolean
}
interface SuggestionKeyboard extends SuggestionKeyDownHandler {
activeIndex: number
setActiveIndex: Dispatch<SetStateAction<number>>
}
/**
* Shared arrow/enter/tab navigation for the `/` and `@` suggestion lists. Owns the active-row state,
* resets it to the top during render when the item set changes (so the highlight is never briefly
* stale), scrolls the active row into view, and exposes an `onKeyDown` handle for the suggestion
* plugin. Up/Down wrap; Enter and Tab both accept the active item (Tab matches the chat composer). The
* handle is stable and reads live values through a ref, because the suggestion plugin captures it once
* via `ReactRenderer.ref` while the items may still be loading; Enter/Tab clamp the active index as a
* safety net.
*/
export function useSuggestionKeyboard<T>(
items: T[],
onSelect: (item: T) => void,
containerRef: RefObject<HTMLElement | null>
): SuggestionKeyboard {
const [activeIndex, setActiveIndex] = useState(0)
const [prevItems, setPrevItems] = useState(items)
if (items !== prevItems) {
setPrevItems(items)
setActiveIndex(0)
}
useEffect(() => {
containerRef.current
?.querySelector<HTMLElement>(`[data-index="${activeIndex}"]`)
?.scrollIntoView({ block: 'nearest' })
}, [activeIndex, containerRef])
const latest = useRef({ items, activeIndex, onSelect })
latest.current = { items, activeIndex, onSelect }
const onKeyDown = useCallback(({ event }: { event: KeyboardEvent }) => {
const { items, activeIndex, onSelect } = latest.current
if (items.length === 0) return false
if (event.key === 'ArrowUp') {
setActiveIndex((i) => (i + items.length - 1) % items.length)
return true
}
if (event.key === 'ArrowDown') {
setActiveIndex((i) => (i + 1) % items.length)
return true
}
if (event.key === 'Enter' || event.key === 'Tab') {
const item = items[Math.min(activeIndex, items.length - 1)]
if (!item) return false
onSelect(item)
return true
}
return false
}, [])
return { activeIndex, setActiveIndex, onKeyDown }
}
@@ -0,0 +1,17 @@
import { serializeMarkdownDocument } from './markdown-parse'
import { isRoundTripSafe } from './round-trip-safety'
/**
* The canonical form the rich editor serializes a document to (`*`→`-` bullets, padded table cells,
* `_em_`→`*em*`, …). A markdown file authored elsewhere (e.g. the former Monaco editor) is rarely in
* this form, so the editor's first mount-time re-serialization would otherwise read as an unsaved edit
* and falsely mark the file dirty. Normalizing the dirty-check baseline to this exact form on open
* neutralizes that — verified to match the live editor's own serialization byte-for-byte.
*
* Round-trip-UNSAFE content (raw HTML, footnotes, >256KB) is returned untouched: those files open
* read-only and must display their original bytes, never a lossy re-serialization.
*/
export function normalizeMarkdownContent(raw: string): string {
if (!isRoundTripSafe(raw)) return raw
return serializeMarkdownDocument(raw)
}
@@ -0,0 +1,125 @@
/**
* @vitest-environment jsdom
*
* Integration coverage for the *live* editor stack (`createMarkdownEditorExtensions` — the same
* extension set the real component mounts, including the React node views): raw HTML/footnote
* content renders with its wrapper class and exact source in the DOM (not just parsing correctly
* headlessly), and — the point of holding it as `content: 'text*'` rather than an opaque blob — the
* text inside is genuinely editable via a normal ProseMirror transaction, surviving serialization
* back to markdown.
*/
import { sleep } from '@sim/utils/helpers'
import { Editor } from '@tiptap/core'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createMarkdownEditorExtensions } from './editor-extensions'
let editor: Editor | null = null
beforeEach(() => {
// The live extension set's placeholder viewport-tracking and suggestion popups use these; jsdom
// lacks them (see keymap.test.ts for the same stub).
vi.stubGlobal(
'ResizeObserver',
class {
observe() {}
unobserve() {}
disconnect() {}
}
)
Element.prototype.scrollIntoView = vi.fn()
document.elementFromPoint = vi.fn(() => null)
})
afterEach(() => {
editor?.destroy()
editor = null
})
function mount(markdown: string): Editor {
return new Editor({
extensions: createMarkdownEditorExtensions({ placeholder: '' }),
content: markdown,
contentType: 'markdown',
})
}
function posOf(ed: Editor, typeName: string): number {
let pos = -1
ed.state.doc.descendants((node, p) => {
if (pos < 0 && node.type.name === typeName) pos = p
})
return pos
}
/** React node views flush on a microtask after mount, so DOM assertions need one tick. */
function nextTick(): Promise<void> {
return sleep(0)
}
// The hover "Raw HTML"/"Footnote" badge is rendered by `RawBlockView` through
// `ReactNodeViewRenderer`, which only flushes its React portal once `@tiptap/react`'s
// `contentComponent` is set — that requires mounting through `<EditorContent>` (a real React render
// tree), which this repo's tests don't do for this directory (no `@testing-library/react` installed
// here) and constructing a plain `new Editor()` doesn't provide. What IS verifiable and matters more
// at this level — the node renders with the correct wrapper class and holds the exact raw source
// text — is covered below; the badge itself is decorative chrome, checked manually.
describe('raw markdown snippet node views (live editor)', () => {
it('renders a raw HTML block with the correct wrapper class and exact raw source', async () => {
editor = mount('<details><summary>More</summary>\n\nbody\n\n</details>')
await nextTick()
const el = editor.view.dom
const block = el.querySelector('.raw-markdown-block')
expect(block).not.toBeNull()
expect(block?.textContent).toContain('<details><summary>More</summary>')
})
it('renders a footnote definition block with the correct wrapper class and exact raw source', async () => {
editor = mount('a claim[^1]\n\n[^1]: the source')
await nextTick()
const el = editor.view.dom
const block = el.querySelector('.raw-markdown-block')
expect(block).not.toBeNull()
expect(block?.textContent).toContain('[^1]: the source')
})
it('renders inline raw HTML as a distinct inline chip, not a plain paragraph', async () => {
editor = mount('a <kbd>Ctrl</kbd> b')
await nextTick()
const el = editor.view.dom
const inline = el.querySelector('.raw-markdown-inline')
expect(inline).not.toBeNull()
expect(inline?.textContent).toBe('<kbd>Ctrl</kbd>')
})
it('the raw HTML block text is genuinely editable — a text edit round-trips into the markdown', () => {
editor = mount('<div align="center">\n\ncentered\n\n</div>')
const pos = posOf(editor, 'rawHtmlBlock')
expect(pos).toBeGreaterThanOrEqual(0)
// Insert text right after the opening tag, simulating a user fixing the raw source in place.
const insertAt = pos + '<div align="center">'.length + 1
editor.commands.insertContentAt(insertAt, '!')
expect(editor.getMarkdown()).toContain('<div align="center">!')
})
it('the footnote definition text is genuinely editable', () => {
editor = mount('a claim[^1]\n\n[^1]: old text')
const pos = posOf(editor, 'footnoteDef')
expect(pos).toBeGreaterThanOrEqual(0)
const node = editor.state.doc.nodeAt(pos)
const insertAt = pos + (node?.nodeSize ?? 1) - 1
editor.commands.insertContentAt(insertAt, ' EDITED')
expect(editor.getMarkdown()).toContain('[^1]: old text EDITED')
})
it('a table, a raw HTML block, and a code block all coexist with working node views', async () => {
editor = mount(
'<!-- note -->\n\n| a | b |\n| --- | --- |\n| 1 | 2 |\n\n```js\nconst x = 1\n```'
)
await nextTick()
const el = editor.view.dom
expect(el.querySelector('.raw-markdown-block')).not.toBeNull()
expect(el.querySelector('table')).not.toBeNull()
expect(el.querySelector('pre.code-editor-theme')).not.toBeNull()
})
})
@@ -0,0 +1,258 @@
/**
* @vitest-environment jsdom
*
* Parse → serialize round-trip fixtures for the verbatim snippet nodes: raw HTML blocks, HTML
* comments, footnotes (def + ref), and inline raw HTML. Each must reproduce its input byte-for-byte
* and reach a fixpoint on a second pass (see `serializeMarkdownDocument` in `./markdown-parse.ts`).
*/
import { describe, expect, it } from 'vitest'
import { parseMarkdownToDoc, serializeMarkdownDocument } from './markdown-parse'
function roundTrip(input: string): string {
return serializeMarkdownDocument(input).trim()
}
/** Top-level node type names of the parsed doc, for structural (not just string) assertions. */
function topLevelTypes(input: string): (string | undefined)[] {
return (parseMarkdownToDoc(input).content ?? []).map((n) => n.type)
}
describe('raw markdown snippet nodes', () => {
it('preserves a standalone HTML comment', () => {
const input = '<!-- a note -->\n\ntext'
expect(roundTrip(input)).toBe(input)
expect(roundTrip(roundTrip(input))).toBe(roundTrip(input))
})
it('preserves a multi-line raw HTML block spanning blank lines', () => {
const input = '<details><summary>More</summary>\n\nbody\n\n</details>'
expect(roundTrip(input)).toBe(input)
expect(roundTrip(roundTrip(input))).toBe(roundTrip(input))
})
it('preserves a raw HTML block with attributes', () => {
const input = '<div align="center">\n\ncentered\n\n</div>'
expect(roundTrip(input)).toBe(input)
})
it('preserves a footnote reference and definition', () => {
const input = 'a claim[^1]\n\n[^1]: the source'
expect(roundTrip(input)).toBe(input)
expect(roundTrip(roundTrip(input))).toBe(roundTrip(input))
})
it('preserves an inline raw HTML tag the schema has no mark/node for', () => {
for (const input of ['a <sub>b</sub> c', 'press <kbd>Ctrl</kbd> now', 'a <mark>hit</mark> b']) {
expect(roundTrip(input)).toBe(input)
}
})
it('leaves recognized inline tags to their real mark (not captured as raw)', () => {
expect(roundTrip('a <em>b</em> c')).toBe('a *b* c')
expect(roundTrip('a <strong>b</strong> c')).toBe('a **b** c')
})
it('leaves a lone <img>/<br> block tag to the stock image/hard-break handling', () => {
expect(roundTrip('<img src="/x.png" alt="a">')).toContain('![a](/x.png)')
})
it('preserves a raw HTML block inside a blockquote', () => {
const input = '> <div>\n>\n> quoted\n>\n> </div>'
expect(roundTrip(roundTrip(input))).toBe(roundTrip(input))
})
it('preserves a footnote reference inside a list item', () => {
const input = '- a claim[^1]\n- another line\n\n[^1]: the source'
expect(roundTrip(roundTrip(input))).toBe(roundTrip(input))
})
it('does not interfere with an adjacent table or code block', () => {
const input =
'<!-- note -->\n\n| a | b |\n| --- | --- |\n| 1 | 2 |\n\n```js\nconst x = 1\n```'
expect(roundTrip(input)).toBe(input)
})
it('preserves a footnote definition with an indented continuation line', () => {
const input = 'a claim[^1]\n\n[^1]: the source\n continued here'
expect(roundTrip(input)).toBe(input)
expect(roundTrip(roundTrip(input))).toBe(roundTrip(input))
})
it('preserves a footnote definition with a blank line between continuation paragraphs', () => {
const input = 'a claim[^1]\n\n[^1]: first paragraph\n\n second paragraph'
expect(roundTrip(input)).toBe(input)
})
it('does not swallow the next block into a footnote definition without continuation', () => {
const input = 'a claim[^1]\n\n[^1]: the source\n\nafter'
const out = roundTrip(input)
expect(out).toContain('[^1]: the source')
expect(out).toContain('after')
})
it('preserves nested same-tag inline HTML (balanced close, not first-match)', () => {
const input = 'a <span>outer <span>inner</span></span> b'
expect(roundTrip(input)).toBe(input)
expect(roundTrip(roundTrip(input))).toBe(roundTrip(input))
})
it('preserves a self-closing same-name tag nested inside an inline HTML element', () => {
const input = 'a <span>before<span/>after</span> b'
expect(roundTrip(input)).toBe(input)
})
})
describe('raw HTML block: does not fragment across blank lines', () => {
it('a <details><summary> block with a blank-line-separated body is ONE node, not three', () => {
const input =
'<details>\n<summary>Click to expand</summary>\n\nThis is inside a details/summary block.\n\n</details>'
expect(topLevelTypes(input)).toEqual(['rawHtmlBlock'])
expect(roundTrip(input)).toBe(input)
expect(roundTrip(roundTrip(input))).toBe(roundTrip(input))
})
it('a <div> with multiple blank-line-separated paragraphs inside is ONE node', () => {
const input = '<div>\n\nfirst paragraph\n\nsecond paragraph\n\n</div>'
expect(topLevelTypes(input)).toEqual(['rawHtmlBlock'])
expect(roundTrip(input)).toBe(input)
})
it('nested same-tag block HTML balances depth across blank lines', () => {
const input = '<div>\nouter\n\n<div>\n\ninner\n\n</div>\n\nstill outer\n</div>'
expect(topLevelTypes(input)).toEqual(['rawHtmlBlock'])
expect(roundTrip(input)).toBe(input)
expect(roundTrip(roundTrip(input))).toBe(roundTrip(input))
})
it('a paragraph starting with a non-block-list inline tag is NOT captured as a raw block', () => {
// `em`/`a` aren't in the CommonMark block-HTML tag whitelist — they can legitimately start an
// ordinary paragraph, and must keep parsing as real marks, not freeze as raw source.
expect(topLevelTypes('<em>hi</em> there, this is a normal paragraph')).toEqual(['paragraph'])
expect(roundTrip('<em>hi</em> there')).toBe('*hi* there')
})
it('a stray inline-only tag alone on its own line is left to the stock (non-whitelisted) path', () => {
// `<span>` isn't in the block whitelist, so the new block tokenizer must not claim it — it falls
// through to marked's own (stricter) block-HTML detection, unaffected by this change.
const input = '<span>\n\nnot a block-html tag\n\n</span>'
expect(() => roundTrip(input)).not.toThrow()
})
it('an unterminated block tag falls back gracefully (no crash, no infinite loop)', () => {
const input = '<details>\n<summary>never closed</summary>\n\nbody'
expect(() => roundTrip(input)).not.toThrow()
})
it('a block comment spanning blank lines still round-trips via the new shared tokenizer path', () => {
const input = '<!--\n\nmulti-line comment\n\n-->\n\ntext after'
expect(topLevelTypes(input)[0]).toBe('rawHtmlBlock')
expect(roundTrip(input)).toBe(input)
})
it('a table and code block adjacent to a fragmenting-prone details block still coexist correctly', () => {
const input =
'<details>\n<summary>s</summary>\n\nbody\n\n</details>\n\n| a | b |\n| --- | --- |\n| 1 | 2 |\n\n```js\nconst x = 1\n```'
expect(roundTrip(input)).toBe(input)
})
it('preserves an indented (up to 3 spaces) block-HTML opening line', () => {
// `roundTrip`'s `.trim()` would strip the very leading indent this test verifies, so check the
// parsed node's own text (and the untrimmed serialization) instead of the trimmed helper.
for (const indent of [' ', ' ', ' ']) {
const input = `${indent}<details>\n<summary>x</summary>\n\nbody\n\n</details>`
const doc = parseMarkdownToDoc(input)
expect(doc.content?.map((n) => n.type)).toEqual(['rawHtmlBlock'])
expect(doc.content?.[0].content?.[0].text).toBe(input)
expect(serializeMarkdownDocument(input)).toBe(`${input}\n`)
}
})
it('preserves a quoted attribute value containing a literal >', () => {
const input = '<div data-example="a > b">\n\ncontent\n\n</div>'
expect(topLevelTypes(input)).toEqual(['rawHtmlBlock'])
expect(roundTrip(input)).toBe(input)
expect(roundTrip(roundTrip(input))).toBe(roundTrip(input))
})
it('a quoted attribute containing a nested same-tag mention does not confuse the balance scan', () => {
// Without attribute-aware matching, `<div>` inside the quoted value below would be miscounted as
// a real nested open tag, throwing off the depth count entirely.
const input = '<div title="a <div> b">\n\ncontent\n\n</div>'
expect(topLevelTypes(input)).toEqual(['rawHtmlBlock'])
expect(roundTrip(input)).toBe(input)
})
it('does not mistake a tag name mentioned inside an inline code span for a real closing tag', () => {
const input = '<details>\n<summary>x</summary>\n\nSee `</details>` in the docs.\n\n</details>'
expect(topLevelTypes(input)).toEqual(['rawHtmlBlock'])
expect(roundTrip(input)).toBe(input)
})
it('does not mistake a tag name mentioned inside a fenced code block for a real closing tag', () => {
const input = '<div>\n\nExample:\n\n```html\n<div>example</div>\n```\n\nmore body\n\n</div>'
expect(topLevelTypes(input)).toEqual(['rawHtmlBlock'])
expect(roundTrip(input)).toBe(input)
})
it('does not mistake a tag name mentioned inside an HTML comment for a real closing tag', () => {
const input = '<div>\n\n<!-- see </div> below for the closing tag -->\n\nmore body\n\n</div>'
expect(topLevelTypes(input)).toEqual(['rawHtmlBlock'])
expect(roundTrip(input)).toBe(input)
})
it('a bare (unescaped, un-fenced) tag-name mention never crashes and always converges to a stable save', () => {
// Known, inherent limitation of regex-based (non-DOM) tag matching, shared by any HTML-block
// scanner (and by real HTML parsers given the same ambiguous input) — a bare mention outside
// code can still be misread as the real closer. The bar this file holds itself to is: never
// crash, never lose text, and always settle to a fixpoint after one save (isRoundTripSafe's own
// documented tolerance for single-pass normalization) — not a perfect, DOM-aware parse.
const input =
'<details>\n<summary>x</summary>\n\nSee the literal text </details> in docs.\n\nmore body\n\n</details>'
expect(() => roundTrip(input)).not.toThrow()
const once = roundTrip(input)
const twice = roundTrip(once)
expect(once).toBe(twice)
// No word from the original is dropped, even though the structure/whitespace may be reflowed.
for (const word of ['See', 'the', 'literal', 'text', 'in', 'docs', 'more', 'body']) {
expect(once).toContain(word)
}
})
it('does not mistake a tag name mentioned inside a blockquoted fenced code block for a real closing tag', () => {
const input =
'<div>\n\n> Example:\n>\n> ```html\n> <div>example</div>\n> ```\n\nmore body\n\n</div>'
expect(topLevelTypes(input)).toEqual(['rawHtmlBlock'])
expect(roundTrip(input)).toBe(input)
})
it('does not mistake a tag name mentioned inside an indented (no blockquote) fenced code block for a real closing tag', () => {
const input =
'<div>\n\nExample:\n\n ```html\n <div>example</div>\n ```\n\nmore body\n\n</div>'
expect(topLevelTypes(input)).toEqual(['rawHtmlBlock'])
expect(roundTrip(input)).toBe(input)
})
it('treats a void block tag (no closing tag exists) as complete right after the open tag', () => {
// `link`/`meta`/`base`/`hr` are in the CommonMark block-HTML whitelist but are void elements —
// scanning for a `</meta>` that will never legitimately appear would risk grabbing unrelated
// later content (or a stray same-name mention) into the block.
for (const input of [
'<link rel="stylesheet" href="x.css">\n\nafter',
'<meta charset="utf-8">\n\nafter',
'<hr>\n\nafter',
]) {
const doc = parseMarkdownToDoc(input)
expect(doc.content?.[0].type).toBe('rawHtmlBlock')
expect(roundTrip(input)).toContain('after')
}
})
it('a void block tag does not swallow a later, unrelated mention of its own tag name', () => {
const input = '<meta charset="utf-8">\n\nSee the `<meta>` tag in docs.\n\nmore body'
const doc = parseMarkdownToDoc(input)
// The <meta> is its own complete block; the later mention (in code) stays in a separate paragraph.
expect(doc.content?.[0].type).toBe('rawHtmlBlock')
expect(doc.content?.[0].content?.[0].text).toBe('<meta charset="utf-8">')
expect(roundTrip(input)).toContain('more body')
})
})
@@ -0,0 +1,514 @@
import type { JSONContent, MarkdownToken } from '@tiptap/core'
import { mergeAttributes, Node } from '@tiptap/core'
import type { ReactNodeViewProps } from '@tiptap/react'
import { NodeViewContent, NodeViewWrapper, ReactNodeViewRenderer } from '@tiptap/react'
/**
* Constructs the schema has no node/mark for: raw HTML blocks (`<div>`, `<details>`, …), HTML
* comments, and footnotes. Before this file, all four made the *entire* document open read-only
* (see {@link isRoundTripSafe in ./round-trip-safety}) because the stock pipeline silently drops
* or mangles them. Each node below instead holds the exact source text as its content and
* re-emits it byte-for-byte on serialize — the same "hold raw source, re-render specially" shape
* `MarkdownCodeBlock` uses for Mermaid (see `./code-block.tsx`), just without the diagram render.
*
* Inline tags already covered by a real mark/node — `em`/`i`, `strong`/`b`, `s`/`del`/`strike`,
* `code`, `a`, `br`, `img` — are deliberately excluded from {@link RawInlineHtml} so they keep
* parsing into their proper mark (e.g. `<em>x</em>` → italic) instead of freezing as raw source.
*/
const HANDLED_INLINE_TAGS = new Set([
'br',
'img',
'em',
'i',
'strong',
'b',
's',
'del',
'strike',
'code',
'a',
])
const VOID_TAGS = new Set([
'area',
'base',
'br',
'col',
'embed',
'hr',
'img',
'input',
'link',
'meta',
'param',
'source',
'track',
'wbr',
])
function verbatimText(node: JSONContent): string {
return (node.content ?? []).map((child) => child.text ?? '').join('')
}
const RAW_HTML_COMMENT_RE = /^<!--[\s\S]*?-->/
/**
* One HTML attribute: `name` or `name="value"`/`name='value'`/`name=bare`. The quoted-value
* alternatives are what matter — `[^"]*`/`[^']*` consume a literal `>` inside the quotes as part of
* the value, so an attribute like `data-example="a > b"` is treated as one unit instead of ending
* the tag match at the internal `>`.
*/
const ATTRS_RE_SOURCE = String.raw`(?:\s+[^\s"'=<>\`]+(?:\s*=\s*(?:"[^"]*"|'[^']*'|[^\s"'=<>\`]+))?)*`
/** Matches one opening HTML tag, attributes included (see {@link ATTRS_RE_SOURCE}). Group 1 is the
* tag name, group 2 is the self-closing `/` if present — shared by inline and block tokenizing. */
const OPEN_TAG_RE = new RegExp(`^<([a-z][\\w-]*)\\b${ATTRS_RE_SOURCE}\\s*(/)?>`, 'i')
/** A fenced block's opening/closing marker may sit inside a blockquote (each line prefixed with
* up to 3 spaces then one or more `>` markers, each optionally followed by a space) and/or be
* independently indented up to 3 spaces with no blockquote at all (CommonMark's own fence-indent
* tolerance — matches `FENCE_OPEN`/`FENCE_CLOSE` in `./markdown-parse.ts`) — matched on both the
* open and close fence line so `> \`\`\`` and ` \`\`\`` both mask correctly. */
const FENCE_PREFIX_SOURCE = '(?:[ ]{0,3}>[ ]?)*[ ]{0,3}'
/** Same as {@link RAW_HTML_COMMENT_RE} but not anchored to the start of the string — used by
* {@link maskCodeRegions} to find a comment anywhere in the scanned text, not just at position 0. */
const HTML_COMMENT_ANYWHERE_RE = /<!--[\s\S]*?-->/g
/**
* Mask fenced code blocks, inline code spans, and HTML comments with same-length filler (newlines
* kept, everything else replaced with a space) so a tag-like mention *inside one of these* —
* `` `</details>` ``, a fenced example showing HTML syntax, or a comment documenting the tag
* (`<!-- see </div> below -->`) — is never mistaken for a real balancing tag while scanning. Mirrors
* the fenced/inline patterns `stripCode` in `./round-trip-safety.ts` matches (extended to also
* tolerate an indented and/or blockquoted fence marker via {@link FENCE_PREFIX_SOURCE}, since a raw
* HTML block can itself be indented or quoted), but preserves length/position (masks in place)
* instead of deleting, so match indices still map onto the original, unmodified `src` the caller
* slices from.
*/
function maskCodeRegions(src: string): string {
const fenceRe = new RegExp(
`^${FENCE_PREFIX_SOURCE}([\`~]{3,})[^\\n]*\\n[\\s\\S]*?^${FENCE_PREFIX_SOURCE}\\1[\`~]*[ \\t]*$`,
'gm'
)
return src
.replace(fenceRe, (m) => m.replace(/[^\n]/g, ' '))
.replace(/`+[^`\n]*`+/g, (m) => ' '.repeat(m.length))
.replace(HTML_COMMENT_ANYWHERE_RE, (m) => m.replace(/[^\n]/g, ' '))
}
/**
* Find the end of the close tag that balances the open tag of `tag` ending at `src[0, fromIndex)`,
* tracking nesting depth from `fromIndex` onward so `<span>outer <span>inner</span></span>` consumes
* both levels instead of stopping at the first (inner) `</span>`. Returns -1 if unterminated. A
* nested self-closing same-name tag (`<span/>`) is skipped — it neither opens nor closes a level.
* Shared by the inline tokenizer (single line) and the block tokenizer (spans blank lines).
*
* Scans a {@link maskCodeRegions}-masked copy of `src` so a tag name mentioned inside code doesn't
* count as real markup — this narrows, but can't eliminate, the inherent ambiguity of regex-based
* (non-DOM) tag matching: a *bare, unescaped* mention of the same tag name in plain prose (not in
* code) is indistinguishable from a real closing tag here, exactly as it would be to a real HTML
* parser given the same ambiguous input (there is no valid way to "escape" a literal `</tag>` inside
* real HTML content other than an entity or code region). Verified this can't lose data even in that
* case — the result still reaches a stable fixpoint on save, just restructured — matching this file's
* "reject on doubt, but never require doubt-free input" gate (`isRoundTripSafe`).
*/
function findBalancedCloseEnd(src: string, tag: string, fromIndex: number): number {
const masked = maskCodeRegions(src)
const tagRe = new RegExp(`<(/?)${tag}\\b${ATTRS_RE_SOURCE}\\s*(/)?>`, 'gi')
tagRe.lastIndex = fromIndex
let depth = 1
for (let match = tagRe.exec(masked); match; match = tagRe.exec(masked)) {
const isClose = match[1] === '/'
const isSelfClosing = Boolean(match[2])
if (isSelfClosing) continue
if (isClose) {
depth -= 1
if (depth === 0) return match.index + match[0].length
} else {
depth += 1
}
}
return -1
}
/**
* Marked's own block tokenizer greedily consumes the blank-line run *after* an HTML block/comment
* or a def line as part of that token's own `raw` (the same behavior `PipeSafeTable` in
* `./extensions.ts` works around for tables) — storing it verbatim would double it up with the
* block joiner's own separator, growing by two newlines on every save. Block-level callers trim it;
* inline callers never carry one (inline tokens can't span a blank line), so trimming is a no-op there.
*/
function verbatimParse(raw: string): JSONContent[] {
const trimmed = raw.replace(/\n+$/, '')
return trimmed ? [{ type: 'text', text: trimmed }] : []
}
interface VerbatimNodeOptions {
name: string
/** Whether this node sits among block content (own line) or inline content (mid-paragraph). */
inline: boolean
badgeLabel: string
}
/**
* Shared shape for a node that holds a markdown construct's exact source text and re-emits it
* unchanged — parsing and rendering never inspect or transform the text, so there is nothing for
* these constructs to lose. `markdownTokenName`/`parseMarkdown`/`renderMarkdown` are read directly
* off the returned config by `@tiptap/markdown`'s `MarkdownManager` (see
* `node_modules/@tiptap/markdown/src/MarkdownManager.ts`), independent of the node's `name`.
*/
function verbatimNodeConfig({ name, inline, badgeLabel }: VerbatimNodeOptions) {
return {
name,
inline,
group: inline ? 'inline' : 'block',
content: 'text*',
marks: '',
code: true,
defining: !inline,
// Block verbatim nodes hold exact source text; `isolating` stops a boundary Backspace/Delete from
// joining across their edge, which would otherwise merge their raw markdown into an adjacent
// paragraph as HTML-escaped prose and destroy the node (silent data loss on save).
isolating: !inline,
selectable: true,
atom: false,
parseHTML() {
return [
{
tag: `${inline ? 'span' : 'div'}[data-raw-markdown="${name}"]`,
preserveWhitespace: 'full' as const,
},
]
},
renderHTML({ HTMLAttributes }: { HTMLAttributes: Record<string, unknown> }) {
return [
inline ? 'span' : 'div',
mergeAttributes(HTMLAttributes, {
'data-raw-markdown': name,
'data-raw-markdown-label': badgeLabel,
class: inline ? 'raw-markdown-inline' : 'raw-markdown-block',
}),
0,
] as const
},
renderMarkdown(node: JSONContent) {
return verbatimText(node)
},
}
}
/**
* Tag names CommonMark/GFM treat as "block-starting" HTML (marked's own type-6 list — see
* `_tag` in `node_modules/marked/src/rules.ts`, verified against the CommonMark spec): a block
* opening with one of these ends at its *matching closing tag*, not at the first blank line. Tags
* NOT in this list (`em`, `a`, `span`, `code`, `kbd`, …) can legitimately start an ordinary
* paragraph (`<em>hi</em> there`), so they're deliberately left to marked's own stricter, single-line
* block-HTML detection below — claiming them here would risk swallowing a paragraph that merely
* starts with inline HTML.
*/
const BLOCK_HTML_TAG_NAMES = new Set([
'address',
'article',
'aside',
'base',
'basefont',
'blockquote',
'body',
'caption',
'center',
'col',
'colgroup',
'dd',
'details',
'dialog',
'dir',
'div',
'dl',
'dt',
'fieldset',
'figcaption',
'figure',
'footer',
'form',
'frame',
'frameset',
'h1',
'h2',
'h3',
'h4',
'h5',
'h6',
'head',
'header',
'hr',
'html',
'iframe',
'legend',
'li',
'link',
'main',
'menu',
'menuitem',
'meta',
'nav',
'noframes',
'ol',
'optgroup',
'option',
'p',
'param',
'search',
'section',
'summary',
'table',
'tbody',
'td',
'tfoot',
'th',
'thead',
'title',
'tr',
'track',
'ul',
])
/**
* Marked's built-in block-HTML rule ends a `<details>`/`<div>`/… block at the *first blank line*
* (CommonMark's HTML-block-type-6 rule) — correct for normal rendering, but wrong for verbatim
* preservation: any real-world `<details>` with a paragraph inside would fragment into a raw chip,
* an ordinary (rendered) paragraph, and a second raw chip, stranding genuine content in between.
* This tokenizer instead scans to the tag's *matching* close via {@link findBalancedCloseEnd}, blank
* lines included, for tags in {@link BLOCK_HTML_TAG_NAMES}; anything else returns `undefined` and
* falls through to the existing `markdownTokenName: 'html'` handling below (marked's own block
* tokenizer, unchanged). Comments are matched the same way as the inline case — marked's own comment
* rule already spans blank lines correctly, but routing through one path keeps the two tokenizers
* symmetric and independently testable. CommonMark allows up to 3 leading spaces before a block-HTML
* opening line, so the leading indent is split off, matched against separately, and stitched back
* onto `raw` — everything after that first line (including the tag's own body) can be indented
* however the author wrote it, since the balanced scan doesn't care about column position there.
*/
function tokenizeRawHtmlBlockTag(src: string): MarkdownToken | undefined {
const indent = /^ {0,3}/.exec(src)?.[0] ?? ''
const rest = src.slice(indent.length)
const comment = RAW_HTML_COMMENT_RE.exec(rest)
if (comment) {
const raw = indent + comment[0]
return { type: 'html', raw, text: raw, block: true }
}
const open = OPEN_TAG_RE.exec(rest)
if (!open) return undefined
const tag = open[1].toLowerCase()
if (!BLOCK_HTML_TAG_NAMES.has(tag)) return undefined
// A handful of BLOCK_HTML_TAG_NAMES entries (link, meta, base, col, …) are void elements with no
// closing tag at all — treat them as complete right after the open tag (like an explicit `/>`),
// same as `tokenizeRawInlineHtml` already does via VOID_TAGS. Without this, scanning for a
// `</meta>`/`</link>` that will never legitimately appear risks grabbing unrelated later content
// (or a stray same-name mention) as if it belonged to this block.
if (open[2] || VOID_TAGS.has(tag)) {
const raw = indent + open[0]
return { type: 'html', raw, text: raw, block: true }
}
const end = findBalancedCloseEnd(rest, tag, open[0].length)
if (end < 0) return undefined
const raw = indent + rest.slice(0, end)
return { type: 'html', raw, text: raw, block: true }
}
const SKIP_BLOCK_HTML_TAGS = /^<(img|br)\b[^>]*\/?>\s*$/i
export const RawHtmlBlock = Node.create({
...verbatimNodeConfig({ name: 'rawHtmlBlock', inline: false, badgeLabel: 'Raw HTML' }),
markdownTokenName: 'html',
markdownTokenizer: {
name: 'rawHtmlBlockTag',
level: 'block' as const,
// Always -1 (never claims an early interrupt point): when `start` is omitted, `@tiptap/markdown`
// auto-generates one that calls `this.createLexer()` on every paragraph-continuation check, which
// corrupts the in-progress lexer's shared state (verified directly — every other construct on the
// page silently loses its content once a tokenizer without an explicit `start` is registered).
// The other custom tokenizers below all reference this comment rather than repeating it.
//
// The tokenizer above emits `type: 'html'` explicitly, so its tokens flow into the same
// `markdownTokenName: 'html'` parse registration as marked's own block-HTML tokens below — the
// distinct `name` here only avoids colliding with marked's own built-in `html` extension.
start: () => -1,
tokenize: tokenizeRawHtmlBlockTag,
},
parseMarkdown(token: MarkdownToken) {
if (!token.block) return []
const raw = token.raw ?? token.text ?? ''
if (!raw.trim()) return []
// A lone `<img>`/`<br>` tag block — leave it to the stock path (Image node / hard break),
// matching the same exclusion `round-trip-safety.ts` used to carve out for these two tags.
if (SKIP_BLOCK_HTML_TAGS.test(raw.trim())) return []
return { type: 'rawHtmlBlock', content: verbatimParse(raw) }
},
})
const FOOTNOTE_DEF_HEAD_RE = /^ {0,3}\[\^[^\]]+\]:/
const FOOTNOTE_CONTINUATION_RE = /^ {4,}\S/
/**
* Consume a footnote definition's opening line plus any continuation lines GFM allows — indented by
* ≥4 spaces, optionally with blank lines between them (a multi-paragraph definition). Stops at the
* first line that is neither indented nor blank, and never consumes a blank line that isn't followed
* by further continuation (that blank line belongs to whatever block comes next).
*/
function tokenizeFootnoteDef(src: string): MarkdownToken | undefined {
const lines = src.split('\n')
if (!FOOTNOTE_DEF_HEAD_RE.test(lines[0])) return undefined
let lineCount = 1
while (lineCount < lines.length) {
const line = lines[lineCount]
if (FOOTNOTE_CONTINUATION_RE.test(line)) {
lineCount += 1
continue
}
if (line === '' && FOOTNOTE_CONTINUATION_RE.test(lines[lineCount + 1] ?? '')) {
lineCount += 2
continue
}
break
}
const raw = lines.slice(0, lineCount).join('\n')
return { type: 'footnoteDef', raw, text: raw }
}
/** Footnote definition (`[^id]: the note`, with optional ≥4-space-indented continuation lines) —
* marked has no footnote syntax at all, so without this tokenizer the definition is swallowed as a
* plain paragraph and the reference/definition link is lost. */
export const FootnoteDef = Node.create({
...verbatimNodeConfig({ name: 'footnoteDef', inline: false, badgeLabel: 'Footnote' }),
markdownTokenName: 'footnoteDef',
markdownTokenizer: {
name: 'footnoteDef',
level: 'block' as const,
// See the comment on `RawHtmlBlock`'s `start` — omitting it corrupts the shared lexer. The cost
// here is narrow and safe: a footnote def sharing a line-run with the preceding paragraph (no
// blank line between them) is picked up on the next block boundary instead of interrupting early.
start: () => -1,
tokenize: tokenizeFootnoteDef,
},
parseMarkdown(token: MarkdownToken) {
const raw = token.raw ?? token.text ?? ''
if (!raw) return []
return { type: 'footnoteDef', content: verbatimParse(raw) }
},
})
const FOOTNOTE_REF_RE = /^\[\^[^\]]+\]/
/** Footnote reference (`text[^id]`) — verbatim passthrough, same rationale as {@link FootnoteDef}. */
export const FootnoteRef = Node.create({
...verbatimNodeConfig({ name: 'footnoteRef', inline: true, badgeLabel: 'Footnote ref' }),
markdownTokenName: 'footnoteRef',
markdownTokenizer: {
name: 'footnoteRef',
level: 'inline' as const,
// See the comment on `RawHtmlBlock`'s `start` — omitting it corrupts the shared lexer.
start: () => -1,
tokenize(src: string) {
const match = FOOTNOTE_REF_RE.exec(src)
if (!match) return undefined
return { type: 'footnoteRef', raw: match[0], text: match[0] }
},
},
parseMarkdown(token: MarkdownToken) {
const raw = token.raw ?? token.text ?? ''
if (!raw) return []
return { type: 'footnoteRef', content: verbatimParse(raw) }
},
})
/**
* Attempt to consume an inline HTML comment or a tag (with its matching close tag, or as a single
* void/self-closing element) starting at `src[0]`. Returns `undefined` for a tag this schema
* already has a real mark/node for ({@link HANDLED_INLINE_TAGS}) so it keeps parsing normally, and
* for an unterminated open tag (rare/malformed input — falls back to the stock, lossy behavior
* rather than risk mis-consuming the rest of the document).
*/
function tokenizeRawInlineHtml(src: string): MarkdownToken | undefined {
const comment = RAW_HTML_COMMENT_RE.exec(src)
if (comment) return { type: 'rawInlineHtml', raw: comment[0], text: comment[0] }
const open = OPEN_TAG_RE.exec(src)
if (!open) return undefined
const tag = open[1].toLowerCase()
if (HANDLED_INLINE_TAGS.has(tag)) return undefined
if (open[2] || VOID_TAGS.has(tag)) {
return { type: 'rawInlineHtml', raw: open[0], text: open[0] }
}
const end = findBalancedCloseEnd(src, tag, open[0].length)
if (end < 0) return undefined
const raw = src.slice(0, end)
return { type: 'rawInlineHtml', raw, text: raw }
}
/** Inline raw HTML — `<kbd>`, `<sub>`, `<mark>`, `<span>`, `<u>` (no Underline mark is registered),
* and any other tag this schema has no mark/node for, plus an inline-position HTML comment. Marked
* classifies inline HTML as its own `'html'` token type, and `@tiptap/markdown`'s inline parser
* hardcodes handling for that type *before* checking its extension registry (unlike block tokens) —
* so claiming it here needs a custom tokenizer, registered under a different token name
* (`rawInlineHtml`) so it's never confused with the stock `'html'` inline path. marked.js runs
* custom extension tokenizers before its own built-ins at both block and inline level (see
* `blockTokens`/`inlineTokens` in `node_modules/marked/lib/marked.esm.js`), so this reliably wins
* the race against marked's default inline HTML/tag tokenizer. */
export const RawInlineHtml = Node.create({
...verbatimNodeConfig({ name: 'rawInlineHtml', inline: true, badgeLabel: 'Raw HTML' }),
markdownTokenName: 'rawInlineHtml',
markdownTokenizer: {
name: 'rawInlineHtml',
level: 'inline' as const,
// See the comment on `RawHtmlBlock`'s `start` — omitting it corrupts the shared lexer.
start: () => -1,
tokenize: tokenizeRawInlineHtml,
},
parseMarkdown(token: MarkdownToken) {
const raw = token.raw ?? token.text ?? ''
if (!raw) return []
return { type: 'rawInlineHtml', content: verbatimParse(raw) }
},
})
const BLOCK_CONTROL_CLASS =
'pointer-events-none absolute top-1.5 right-2 select-none rounded-md bg-[var(--surface-4)] px-1.5 py-0.5 text-[10px] text-[var(--text-muted)] uppercase tracking-wide opacity-0 transition-opacity group-focus-within:opacity-100 group-hover:opacity-100'
/** Badge text per block node type name — kept here rather than threaded through node options since
* {@link NodeViewProps} exposes no options/extension reference to the rendering component. */
const BLOCK_BADGE_LABEL: Record<string, string> = {
rawHtmlBlock: 'Raw HTML',
footnoteDef: 'Footnote',
}
function RawBlockView({ node }: ReactNodeViewProps) {
const label = BLOCK_BADGE_LABEL[node.type.name] ?? 'Raw'
return (
<NodeViewWrapper className='group relative'>
<span className={BLOCK_CONTROL_CLASS} contentEditable={false}>
{label}
</span>
<div className='raw-markdown-block'>
<NodeViewContent<'span'> as='span' />
</div>
</NodeViewWrapper>
)
}
/** Live variant of {@link RawHtmlBlock} with a hover "Raw HTML" badge — same schema/serializer. */
export const RawHtmlBlockWithView = RawHtmlBlock.extend({
addNodeView() {
return ReactNodeViewRenderer(RawBlockView)
},
})
/** Live variant of {@link FootnoteDef} with a hover "Footnote" badge — same schema/serializer. */
export const FootnoteDefWithView = FootnoteDef.extend({
addNodeView() {
return ReactNodeViewRenderer(RawBlockView)
},
})
@@ -0,0 +1,452 @@
.rich-markdown-prose {
flex: 1 1 auto;
outline: none;
color: var(--text-primary);
font-family: var(--font-inter);
font-size: 15px;
font-weight: 430;
line-height: 25px;
letter-spacing: 0;
overflow-wrap: anywhere;
}
.rich-markdown-prose img {
max-width: 100%;
height: auto;
border-radius: 8px;
border: 1px solid var(--border);
}
/* One consistent ring for every node that can only be selected as a whole (divider, image,
code block, table) — so a mouse click and a keyboard NodeSelection look identical. */
.rich-markdown-prose .ProseMirror-selectednode {
outline: 2px solid var(--brand-secondary);
outline-offset: 2px;
border-radius: 4px;
}
/* An image is its own framed element; ring the image itself so the indicator hugs the picture
rather than the node's bounding box, which can be far wider/taller than a small image. */
.rich-markdown-prose .ProseMirror-selectednode:has(img) {
outline: none;
}
.rich-markdown-prose .ProseMirror-selectednode img {
outline: 2px solid var(--brand-secondary);
outline-offset: 2px;
}
/* The inline mention chip isn't a block leaf, so it skips the heavy outline ring above and just uses
the same native text-selection highlight as the surrounding prose. ProseMirror puts `selectednode`
on the node-view wrapper, so match it via `:has` (the chip span itself when it's the selected node). */
.rich-markdown-prose .mention-chip.ProseMirror-selectednode,
.rich-markdown-prose .ProseMirror-selectednode:has(.mention-chip) {
outline: none;
}
.rich-markdown-prose > * + * {
margin-top: 0.6em;
}
.rich-markdown-prose > :first-child {
margin-top: 0;
}
.rich-markdown-prose h1,
.rich-markdown-prose h2,
.rich-markdown-prose h3,
.rich-markdown-prose h4,
.rich-markdown-prose h5,
.rich-markdown-prose h6 {
font-weight: 600;
line-height: 1.3;
color: var(--text-primary);
}
.rich-markdown-prose h1 {
font-size: 1.6em;
margin-top: 1.4em;
}
.rich-markdown-prose h2 {
font-size: 1.3em;
margin-top: 1.3em;
}
.rich-markdown-prose h3 {
font-size: 1.1em;
margin-top: 1.2em;
}
.rich-markdown-prose h4 {
font-size: 1em;
margin-top: 1.1em;
}
.rich-markdown-prose h5 {
font-size: 0.875em;
margin-top: 1.1em;
}
.rich-markdown-prose h6 {
font-size: 0.8em;
margin-top: 1.1em;
color: var(--text-secondary);
}
/* No explicit `color` on strong/em — an inherited value never beats an element's own explicit rule
* regardless of the ancestor selector's specificity, so a hardcoded color here would silently win
* over ANY ambient color a ancestor sets that differs from the prose default (a link's blue, `h6`'s
* dimmer `--text-secondary`, or any future colored container) — the same failure mode `color:
* inherit` on the `mark` (highlight) rule below already avoids. Bold/italic text is meant to carry
* the surrounding color, not reset it; omitting `color` here lets normal CSS inheritance do that
* correctly in every context, including ones this file doesn't know about yet. */
.rich-markdown-prose strong {
font-weight: 600;
}
.rich-markdown-prose em {
font-style: italic;
}
/* del/s DO need their own explicit dimmer color (distinct from the surrounding text) as their
* default — but see the link-color override below for why that must still yield inside a link. */
.rich-markdown-prose del,
.rich-markdown-prose s {
color: var(--text-tertiary);
text-decoration: line-through;
}
.rich-markdown-prose a {
color: var(--brand-secondary);
cursor: pointer;
}
.rich-markdown-prose a:hover {
text-decoration: underline;
}
/* Render the gap cursor (e.g. above a leading divider) as a normal vertical caret rather
than ProseMirror's default short horizontal bar, which reads as a stray underscore. */
.rich-markdown-prose .ProseMirror-gapcursor::after {
top: 0;
width: 2px;
height: 1.25em;
border-top: none;
background-color: var(--text-primary);
}
/* …except between two adjacent leaves (dividers/images), where that caret floats as a stray mark in
empty space the view gives no hint about. Hide it there (matching Linear): the gap cursor stays
functional — typing still inserts a block between them — it just isn't drawn. Leading/trailing gaps
keep theirs. The keymap plugin sets `data-gap-between-leaves` when both neighbours are leaves. */
.rich-markdown-prose[data-gap-between-leaves] .ProseMirror-gapcursor::after {
display: none;
}
.rich-markdown-prose ul,
.rich-markdown-prose ol {
padding-left: 1.25em;
}
.rich-markdown-prose ul {
list-style: disc;
}
.rich-markdown-prose ol {
list-style: decimal;
}
.rich-markdown-prose li > p {
margin: 0;
}
.rich-markdown-prose li::marker {
color: var(--text-primary);
}
.rich-markdown-prose ul[data-type="taskList"] {
list-style: none;
padding-left: 0;
}
.rich-markdown-prose ul[data-type="taskList"] li {
display: flex;
align-items: flex-start;
gap: 0.5em;
}
/* One line tall with the box centered, so it aligns with the item's first line. */
.rich-markdown-prose ul[data-type="taskList"] li > label {
display: flex;
align-items: center;
height: 1.6667em; /* = the prose 25px line-height at 15px font */
flex-shrink: 0;
user-select: none;
}
.rich-markdown-prose ul[data-type="taskList"] li > div {
flex: 1 1 auto;
min-width: 0;
}
/* TaskItem nests content as li > div > p, which the `li > p` reset misses, leaving UA margins. */
.rich-markdown-prose ul[data-type="taskList"] li > div > p {
margin: 0;
}
/* Match the design-system Checkbox (emcn) rather than the platform-native control. */
.rich-markdown-prose ul[data-type="taskList"] input[type="checkbox"] {
appearance: none;
-webkit-appearance: none;
display: inline-grid;
place-content: center;
width: 16px;
height: 16px;
margin: 0;
border: 1px solid var(--border-1);
border-radius: 3px;
background: transparent;
cursor: pointer;
}
.rich-markdown-prose ul[data-type="taskList"] input[type="checkbox"]:checked {
background-color: var(--text-primary);
border-color: var(--text-primary);
}
.rich-markdown-prose ul[data-type="taskList"] input[type="checkbox"]:checked::after {
content: "";
width: 10px;
height: 10px;
background-color: var(--surface-2);
clip-path: polygon(14% 44%, 0 65%, 50% 100%, 100% 16%, 80% 0%, 43% 62%);
}
.rich-markdown-prose blockquote {
border-left: 2px solid var(--divider);
padding-left: 1rem;
color: var(--text-primary);
font-style: italic;
}
/* No explicit `color` — see the strong/em comment above; inline code composites its own font/
* background/radius over whatever color the surrounding context (link, heading, prose default)
* already supplies. */
.rich-markdown-prose code {
font-family: var(--font-martian-mono, ui-monospace, monospace);
font-size: 0.875em;
background: var(--surface-5);
border-radius: 4px;
padding: 0.125rem 0.375rem;
}
/* del/s are the one mark that both needs its own explicit default color (dimmer than the prose
* default, unlike strong/em/code above) AND must still yield it to a link's blue — an inherited
* value never beats an element's own explicit rule regardless of the ancestor selector's
* specificity, so without this override a struck-through link would show the dimmer tertiary color
* instead of the link's blue. Covers both DOM nesting orders since ProseMirror's mark array — and so
* the render order marks nest in — depends on which was toggled first, not a fixed schema order.
* Declared after the del/s rule above so it wins on source order too, not just specificity. */
.rich-markdown-prose a del,
.rich-markdown-prose a s,
.rich-markdown-prose del a,
.rich-markdown-prose s a {
color: var(--brand-secondary);
}
.rich-markdown-prose pre,
.rich-markdown-prose .mermaid-diagram-frame {
background: var(--surface-5);
border-radius: 8px;
padding: 1rem;
overflow-x: auto;
}
/* A rendered Mermaid diagram wears the same frame as a code block (single source of truth above),
centered within it. */
.rich-markdown-prose .mermaid-diagram-frame {
display: flex;
justify-content: center;
}
/* Override ProseMirror's built-in `.ProseMirror pre { white-space: pre-wrap }` (the
`.code-editor-theme` class raises specificity so this wins): code blocks scroll by default and
only wrap when the line-wrap toggle sets data-wrap, breaking long unbroken tokens too. The
`overflow-wrap`/`word-break` resets undo the editor-wide `overflow-wrap: anywhere`, which would
otherwise still break a long unbroken token even under `white-space: pre`. */
.rich-markdown-prose pre.code-editor-theme,
.rich-markdown-prose pre.code-editor-theme code {
white-space: pre;
overflow-wrap: normal;
word-break: normal;
}
.rich-markdown-prose pre.code-editor-theme[data-wrap="true"],
.rich-markdown-prose pre.code-editor-theme[data-wrap="true"] code {
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.dark .rich-markdown-prose pre,
.dark .rich-markdown-prose .mermaid-diagram-frame {
background: var(--code-bg);
}
.rich-markdown-prose pre code {
background: none;
padding: 0;
font-size: 13px;
line-height: 21px;
}
/* Raw, unrendered markdown constructs the schema has no real node/mark for (raw HTML blocks,
comments, footnotes) — held verbatim and re-emitted byte-for-byte on save (./raw-markdown-snippet.ts).
Same neutral surface as `code`/`pre` below (no color tint — a tint reads as a warning/error state,
which isn't the signal here); the hover "Raw HTML"/"Footnote" badge is what conveys "not interpreted". */
.rich-markdown-prose .raw-markdown-block,
.rich-markdown-prose .raw-markdown-inline {
font-family: var(--font-martian-mono, ui-monospace, monospace);
font-size: 0.875em;
color: var(--text-muted);
background: var(--surface-5);
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.dark .rich-markdown-prose .raw-markdown-block,
.dark .rich-markdown-prose .raw-markdown-inline {
background: var(--code-bg);
}
.rich-markdown-prose .raw-markdown-block {
border-radius: 8px;
padding: 0.75rem 1rem;
}
.rich-markdown-prose .raw-markdown-inline {
border-radius: 4px;
padding: 0.0625rem 0.3rem;
}
.rich-markdown-prose hr {
border: none;
border-top: 1px solid var(--divider);
margin: 1.5em 0;
}
/* A divider/image swept into a range selection (e.g. select-all) — the browser's native ::selection
highlight skips leaf nodes (they hold no text), so the keymap's decoration paints the band itself.
A divider is a void <hr> (no pseudo-elements), so a box-shadow spreads the selection band around its
hairline without shifting layout; an image rings instead. */
.rich-markdown-prose hr.rich-leaf-in-selection {
box-shadow: 0 0 0 0.4em var(--selection-bg);
border-radius: 1px;
}
.dark .rich-markdown-prose hr.rich-leaf-in-selection {
box-shadow: 0 0 0 0.4em var(--selection-dark);
}
.rich-markdown-prose .rich-leaf-in-selection:has(img) img,
.rich-markdown-prose img.rich-leaf-in-selection {
outline: 2px solid var(--selection-bg);
outline-offset: 2px;
border-radius: 4px;
}
.rich-markdown-prose table {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
margin: 1rem 0;
overflow: hidden;
}
.rich-markdown-prose th,
.rich-markdown-prose td {
position: relative;
border: 1px solid var(--divider);
padding: 0.5rem 0.75rem;
text-align: left;
vertical-align: top;
font-size: 14px;
line-height: 1.5rem;
}
.rich-markdown-prose th {
background: var(--surface-4);
font-weight: 600;
}
.rich-markdown-prose th > p,
.rich-markdown-prose td > p {
margin: 0;
}
.rich-markdown-prose .selectedCell::after {
content: "";
position: absolute;
inset: 0;
background: var(--surface-active);
opacity: 0.5;
pointer-events: none;
}
.rich-markdown-prose .column-resize-handle {
position: absolute;
right: -2px;
top: 0;
bottom: 0;
width: 3px;
background: var(--brand-secondary);
pointer-events: none;
}
/*
* prosemirror-tables' column-resizing plugin toggles the `resize-cursor` class on the editor root
* while the pointer is over a column boundary; without this rule the handle shows but the cursor
* never changes to the resize affordance.
*/
.rich-markdown-prose.resize-cursor {
cursor: col-resize;
}
.rich-markdown-prose p.is-editor-empty:first-child::before {
content: attr(data-placeholder);
color: var(--text-subtle);
float: left;
height: 0;
pointer-events: none;
}
/*
* Highlight mark (`==text==`). An opacity-based amber tint so it reads on both light and dark
* surfaces without a theme override; `color: inherit` keeps the text at the surrounding body color
* and `box-decoration-break: clone` keeps the tint clean where a highlight wraps across lines. The
* horizontal padding is cancelled by an equal negative margin so the tint bleeds slightly past the
* text without ever shifting the text (or any following text) as the highlight is applied/removed.
*/
.rich-markdown-prose mark {
background-color: rgba(255, 212, 0, 0.4);
color: inherit;
border-radius: 2px;
padding: 0 0.1em;
margin: 0 -0.1em;
box-decoration-break: clone;
-webkit-box-decoration-break: clone;
}
/*
* Field variant (modal embed): match the surrounding chip fields' typography exactly —
* body at the chip `text-sm` (14px) scale and the placeholder at `--text-muted` (not the
* lighter document `--text-subtle`), so the editor reads as one of the form's fields.
*/
.rich-markdown-field-prose {
font-size: 14px;
line-height: 22px;
}
.rich-markdown-field-prose p.is-editor-empty:first-child::before {
color: var(--text-muted);
}
@@ -0,0 +1,590 @@
'use client'
import { memo, useEffect, useRef, useState } from 'react'
import { cn, toast } from '@sim/emcn'
import type { JSONContent } from '@tiptap/core'
import { Fragment, Slice } from '@tiptap/pm/model'
import { NodeSelection } from '@tiptap/pm/state'
import { dropPoint } from '@tiptap/pm/transform'
import type { Editor } from '@tiptap/react'
import { EditorContent, useEditor } from '@tiptap/react'
import { useRouter } from 'next/navigation'
import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace'
import { extractEmbeddedFileRef } from '@/lib/uploads/utils/embedded-image-ref'
import { useUploadWorkspaceFile } from '@/hooks/queries/workspace-files'
import type { SaveStatus } from '@/hooks/use-autosave'
import { useFileContentSource } from '@/hooks/use-file-content-source'
import { PreviewLoadingFrame } from '../preview-shared'
import { useEditableFileContent } from '../use-editable-file-content'
import { createMarkdownEditorExtensions } from './editor-extensions'
import { findHeadingPos } from './heading-anchors'
import {
extractImageFiles,
extractImgSrcs,
findHostedImageAttrs,
htmlReferencesSrc,
shouldSkipFileUpload,
} from './image-paste'
import {
applyFrontmatter,
normalizeLinkHref,
postProcessSerializedMarkdown,
splitFrontmatter,
} from './markdown-fidelity'
import { parseMarkdownToDoc } from './markdown-parse'
import { useEditorMentions } from './mention'
import { EditorBubbleMenu } from './menus/bubble-menu'
import { LinkHoverCard } from './menus/link-hover-card'
import { TableBubbleMenu } from './menus/table-menu'
import { normalizeMarkdownContent } from './normalize-content'
import { isRoundTripSafe } from './round-trip-safety'
import '@sim/emcn/components/code/code.css'
import './rich-markdown-editor.css'
const EXTENSIONS = createMarkdownEditorExtensions({
placeholder: "Write something, or press '/' for commands…",
embeds: true,
})
/** Throttle the per-frame full re-parse above this body size so a large streaming file can't saturate the main thread. */
const STREAM_REPARSE_THROTTLE_THRESHOLD = 40_000
const STREAM_REPARSE_THROTTLE_MS = 120
interface RichMarkdownEditorProps {
file: WorkspaceFileRecord
workspaceId: string
canEdit: boolean
autoFocus?: boolean
onDirtyChange?: (isDirty: boolean) => void
onSaveStatusChange?: (status: SaveStatus, retry?: () => Promise<void>) => void
saveRef?: React.MutableRefObject<(() => Promise<void>) | null>
discardRef?: React.MutableRefObject<(() => void) | null>
streamingContent?: string
isAgentEditing?: boolean
/**
* True when the stream delivers complete full-file snapshots (an `append`/`patch` edit built on the
* existing file) rather than a from-scratch rebuild (`create`/`update`). Incremental snapshots are
* applied live; a rebuild is only revealed while it extends what's shown (see the streaming tick).
*/
streamIsIncremental?: boolean
disableStreamingAutoScroll?: boolean
previewContextKey?: string
/** Disable the `@` tag-insertion menu (existing tags still render). Defaults off — the file editor keeps tagging. */
disableTagging?: boolean
}
/** Inline WYSIWYG markdown editor: agent output streams in read-only, then the same instance becomes editable on settle. */
export const RichMarkdownEditor = memo(function RichMarkdownEditor({
file,
workspaceId,
canEdit,
autoFocus,
onDirtyChange,
onSaveStatusChange,
saveRef,
discardRef,
streamingContent,
isAgentEditing,
streamIsIncremental,
disableStreamingAutoScroll = false,
previewContextKey,
disableTagging,
}: RichMarkdownEditorProps) {
const {
content,
setDraftContent,
isStreamInteractionLocked,
isContentLoading,
hasContentError,
saveImmediately,
} = useEditableFileContent({
file,
workspaceId,
canEdit,
streamingContent,
isAgentEditing,
onDirtyChange,
onSaveStatusChange,
saveRef,
discardRef,
normalizeBaseline: normalizeMarkdownContent,
})
if (isContentLoading) return <PreviewLoadingFrame className='flex flex-1 flex-col' />
if (hasContentError) {
return (
<div className='flex flex-1 items-center justify-center'>
<p className='text-[var(--text-muted)] text-small'>Failed to load file content</p>
</div>
)
}
return (
<LoadedRichMarkdownEditor
key={previewContextKey ? `${file.id}:${previewContextKey}` : file.id}
file={file}
workspaceId={workspaceId}
content={content}
isStreaming={isStreamInteractionLocked}
canEdit={canEdit}
autoFocus={autoFocus}
streamIsIncremental={streamIsIncremental}
disableStreamingAutoScroll={disableStreamingAutoScroll}
disableTagging={disableTagging}
onChange={setDraftContent}
onSaveShortcut={saveImmediately}
/>
)
})
interface LoadedRichMarkdownEditorProps {
file: WorkspaceFileRecord
workspaceId: string
/** The live content from the engine — grows as the agent streams, then settles to the saved doc. */
content: string
/** True while agent output is streaming in: the editor renders it read-only and syncs each chunk. */
isStreaming: boolean
canEdit: boolean
autoFocus?: boolean
/** See {@link RichMarkdownEditorProps.streamIsIncremental}. */
streamIsIncremental?: boolean
disableStreamingAutoScroll?: boolean
disableTagging?: boolean
onChange: (markdown: string) => void
onSaveShortcut: () => Promise<void>
}
interface SettledContent {
frontmatter: string
verdict: boolean
}
/** Locks the round-trip verdict + frontmatter once; a round-trip-unsafe doc (raw HTML, footnotes, >256KB) opens read-only. */
function lockSettled(content: string): SettledContent {
return { frontmatter: splitFrontmatter(content).frontmatter, verdict: isRoundTripSafe(content) }
}
/** The single TipTap editor: read-only while streaming, editable on settle; frontmatter is held aside and re-applied. */
export function LoadedRichMarkdownEditor({
file,
workspaceId,
content,
isStreaming,
canEdit,
autoFocus,
streamIsIncremental,
disableStreamingAutoScroll,
disableTagging,
onChange,
onSaveShortcut,
}: LoadedRichMarkdownEditorProps) {
/** Whether this editor mounted mid-stream — if so it starts empty and syncs streamed chunks until settle. */
const streamingAtMountRef = useRef(isStreaming)
/** Verdict + frontmatter, locked once (at mount if settled, else on settle); null reads as read-only. */
const settledRef = useRef<SettledContent | null>(null)
if (!streamingAtMountRef.current && settledRef.current === null) {
settledRef.current = lockSettled(content)
}
const isEditable = canEdit && !isStreaming && (settledRef.current?.verdict ?? false)
/** Seed the doc once via lazy init — chunked parse is linear vs the editor's ~O(n²) whole-body markdown parse. */
const [initialContent] = useState<JSONContent | string>(() =>
streamingAtMountRef.current ? '' : parseMarkdownToDoc(splitFrontmatter(content).body)
)
/**
* The body currently shown in the editor: seeded from a settled mount, updated on local edits (via
* onUpdate) and on each streamed sync. Incremental edits (append/patch) stream complete snapshots and
* always apply; a from-scratch rebuild (create/update) only applies while it still extends this, so a
* rewrite holds the current content instead of collapsing to a partial result.
*/
const lastSyncedBodyRef = useRef<string | null>(
streamingAtMountRef.current ? null : splitFrontmatter(content).body
)
const onChangeRef = useRef(onChange)
onChangeRef.current = onChange
const onSaveShortcutRef = useRef(onSaveShortcut)
onSaveShortcutRef.current = onSaveShortcut
/**
* Read in the RAF tick so an already-scheduled tick still sees the latest edit kind (it can change
* between sessions within one turn, e.g. an append followed by a rewrite).
*/
const streamIsIncrementalRef = useRef(streamIsIncremental)
streamIsIncrementalRef.current = streamIsIncremental
const router = useRouter()
const routerRef = useRef(router)
routerRef.current = router
const containerRef = useRef<HTMLDivElement>(null)
const uploadFile = useUploadWorkspaceFile()
const editorInstanceRef = useRef<Editor | null>(null)
const source = useFileContentSource()
const resolveImageSrcRef = useRef(source.resolveImageSrc)
resolveImageSrcRef.current = source.resolveImageSrc
/**
* The `/Image` slash command opens this hidden picker; `pendingImagePosRef` holds the caret position
* captured when the command ran, so the upload inserts where `/Image` was typed.
*/
const imageInputRef = useRef<HTMLInputElement>(null)
const pendingImagePosRef = useRef<number | null>(null)
/**
* Upload then insert each image at `at` (paste caret / drop point), sequentially; held in a ref so
* handlers reach the latest. A persistent (`duration: 0`) progress toast shows per image during the
* upload and is dismissed once it settles, when the upload hook's own "Uploaded"/"Failed" toast takes over.
*/
const insertImagesRef = useRef<(images: File[], at: number) => Promise<void>>(() =>
Promise.resolve()
)
insertImagesRef.current = async (images, at) => {
let position = at
for (const image of images) {
const uploadingToastId = toast.info(`Uploading "${image.name}"…`, { duration: 0 })
const result = await uploadFile
.mutateAsync({ workspaceId, file: image, folderId: file.folderId ?? null })
.catch(() => null)
toast.dismiss(uploadingToastId)
const editor = editorInstanceRef.current
if (!result || !editor) continue
const safePosition = Math.min(position, editor.state.doc.content.size)
try {
editor
.chain()
.insertContentAt(safePosition, {
type: 'image',
attrs: { src: result.file.url, alt: image.name },
})
.run()
position = editor.state.selection.to
} catch {
position = editor.state.doc.content.size
}
}
}
/**
* A same-page copy/drag of an already-hosted `<img>` carries the clipboard/dataTransfer `html`'s
* *display* src (`source.resolveImageSrc`'s rewrite), not the real persisted one — inserting a node
* built straight from that html would bake the display-only URL into the document, breaking public
* share/export/referenced-by-doc tracking for it (they only recognize the persisted shape).
* `findHostedImageAttrs` finds the real, already-present node with a matching resolved src instead,
* so the clone gets the exact real `src` (and every other attribute — width, href, title…) rather
* than a re-derived guess. Returns `false` (falls through to a normal upload) if no match is found,
* which is always correct, just occasionally a redundant upload — unlike blindly trusting the html.
*/
const cloneHostedImageRef = useRef<(imgSrcs: string[], at: number) => boolean>(() => false)
cloneHostedImageRef.current = (imgSrcs, at) => {
const editor = editorInstanceRef.current
if (!editor) return false
const matchedAttrs = findHostedImageAttrs(editor.state.doc, imgSrcs, source.resolveImageSrc)
if (!matchedAttrs) return false
const safePosition = Math.min(at, editor.state.doc.content.size)
try {
editor.chain().insertContentAt(safePosition, { type: 'image', attrs: matchedAttrs }).run()
return true
} catch {
return false
}
}
const editor = useEditor({
extensions: EXTENSIONS,
editable: isEditable,
enablePasteRules: false,
autofocus: streamingAtMountRef.current ? false : autoFocus ? 'end' : false,
immediatelyRender: false,
shouldRerenderOnTransaction: false,
content: initialContent,
editorProps: {
attributes: { class: 'rich-markdown-prose', 'data-owned-shortcuts': 'Mod+K' },
handleKeyDown: (_view, event) => {
const isSaveShortcut = (event.metaKey || event.ctrlKey) && event.key?.toLowerCase() === 's'
if (!isSaveShortcut) return false
event.preventDefault()
void onSaveShortcutRef.current()
return true
},
/**
* Follows a clicked link. While editing a modifier is required (a plain click places the cursor);
* read-only follows directly. A same-page anchor (`[x](#slug)`) scrolls to the matching heading; a
* same-origin in-app path navigates within the SPA (same tab); everything else opens a new tab.
*/
handleClick: (view, _pos, event) => {
const href = (event.target as HTMLElement | null)?.closest('a')?.getAttribute('href')
if (!href) return false
if (view.editable && !(event.metaKey || event.ctrlKey)) return false
if (href.startsWith('#')) {
const pos = findHeadingPos(view.state.doc, href.slice(1))
if (pos < 0) return false
;(view.nodeDOM(pos) as HTMLElement | null)?.scrollIntoView({
behavior: 'smooth',
block: 'start',
})
return true
}
const normalized = normalizeLinkHref(href)
if (!normalized) return false
if (
!(event.metaKey || event.ctrlKey) &&
normalized.startsWith('/') &&
!normalized.startsWith('//')
) {
routerRef.current.push(normalized)
return true
}
window.open(normalized, '_blank', 'noopener,noreferrer')
return true
},
/**
* Inserts pasted image files at the caret. A same-page copy of an already-hosted `<img>` (e.g.
* Cmd+C after clicking it to select it) makes the browser add BOTH `text/html` (the real node,
* with its real hosted `src`) AND a synthesized image `File` to the clipboard — indistinguishable
* from a genuine external image paste by `clipboardData` files/items alone. When the HTML sibling
* already names one of our own hosted files, look up the matching node already in this doc and
* clone ITS real attrs (see `cloneHostedImageRef`) instead of re-uploading the pasted bytes as a
* brand-new, distinct file — letting the editor's DEFAULT html-based paste do that clone instead
* would persist the html's display-layer src rather than the real one. Only applied when exactly
* one image file is offered: a genuinely mixed paste (the hosted image plus a separate new one)
* must still upload the new file rather than have the whole paste diverted by this bypass.
*/
handlePaste: (view, event) => {
if (!view.editable) return false
const images = extractImageFiles(event.clipboardData)
const html = event.clipboardData?.getData('text/html') ?? ''
if (shouldSkipFileUpload(images, html, (src) => extractEmbeddedFileRef(src) !== null)) {
const cloned = cloneHostedImageRef.current(
extractImgSrcs(html),
view.state.selection.from
)
if (cloned) {
event.preventDefault()
return true
}
}
if (images.length === 0) return false
event.preventDefault()
void insertImagesRef.current(images, view.state.selection.from)
return true
},
/**
* Inserts dropped image files at the drop point. Any other file drop (e.g. a PDF) is swallowed so
* the browser doesn't navigate away from the editor; internal text drags carry no files and fall
* through to the default behavior.
*
* Drag-REORDER of an image node is the deceptive case. TipTap's node-view dragstart bypasses
* ProseMirror's own drag serialization entirely — no PM `text/html`, no `view.dragging` — but it
* DOES NodeSelect the dragged image; what the drop carries instead is the browser's native
* enrichment for a dragged `<img>`: an image `File` plus `text/html` whose src is the ABSOLUTE
* rendered URL of that exact node. So when the drop's html points at the currently-selected image
* node ({@link htmlReferencesSrc}), this drop IS that node being moved, and the move must be
* performed here: uploading would duplicate it (the original never moves), and falling through to
* ProseMirror is no better — with `view.dragging` unset its default drop PARSES the html into a
* copy (persisting the display-layer src, which share/export tracking don't recognize) and never
* deletes the original. The gate accepts at most one file (not exactly one): some drag transports
* (e.g. CDP-driven input) carry the html alone, and a genuinely external drop can never reference
* the currently-selected node's own resolved src.
*
* The move itself is the same shape as ProseMirror's own: compute the drop point on the
* pre-delete doc, delete the source, map the insert position through that delete. A null
* `dropPoint` (no valid insertion point) is a handled no-op — the node stays put, still
* selected — never a raw-position fallback, which `tr.insert` could throw on (PM's own null
* fallback is only safe because it uses the forgiving `replaceRangeWith`).
*
* PM-serialized drags (a text selection spanning an image, dragged from a textblock) still reach
* the `shouldSkipFileUpload` bail below: PM set `view.dragging` for those itself, so its default
* move logic is correct there.
*/
handleDrop: (view, event) => {
if (!view.editable) return false
const images = extractImageFiles(event.dataTransfer)
const html = event.dataTransfer?.getData('text/html') ?? ''
const { selection } = view.state
if (
images.length <= 1 &&
selection instanceof NodeSelection &&
selection.node.type.name === 'image' &&
htmlReferencesSrc(html, resolveImageSrcRef.current(selection.node.attrs.src))
) {
event.preventDefault()
const coords = view.posAtCoords({ left: event.clientX, top: event.clientY })
if (!coords) return true
const node = selection.node
const tr = view.state.tr
const insertPos = dropPoint(
view.state.doc,
coords.pos,
new Slice(Fragment.from(node), 0, 0)
)
if (insertPos === null) return true
tr.delete(selection.from, selection.to)
const mapped = tr.mapping.map(insertPos)
tr.insert(mapped, node)
tr.setSelection(NodeSelection.create(tr.doc, mapped))
view.dispatch(tr.scrollIntoView())
return true
}
if (shouldSkipFileUpload(images, html, (src) => extractEmbeddedFileRef(src) !== null)) {
return false
}
if (images.length > 0) {
event.preventDefault()
const dropPos = view.posAtCoords({ left: event.clientX, top: event.clientY })?.pos
void insertImagesRef.current(images, dropPos ?? view.state.selection.from)
return true
}
if (event.dataTransfer?.files.length) {
event.preventDefault()
return true
}
return false
},
},
onUpdate: ({ editor }) => {
const md = postProcessSerializedMarkdown(editor.getMarkdown())
lastSyncedBodyRef.current = md
onChangeRef.current(applyFrontmatter(settledRef.current?.frontmatter ?? '', md))
},
})
editorInstanceRef.current = editor
/**
* Wire the `/Image` slash command to the hidden picker (per-editor storage, since the extension set is
* shared across instances). Reads only refs, so the handler stays stable across the editor's life.
*/
useEffect(() => {
if (!editor) return
editor.storage.slashCommand.insertImage = (at: number) => {
pendingImagePosRef.current = at
imageInputRef.current?.click()
}
return () => {
editor.storage.slashCommand.insertImage = null
}
}, [editor])
useEditorMentions(editor, workspaceId, { navigable: true, disableTagging })
const wasStreamingRef = useRef(streamingAtMountRef.current)
const pendingStreamBodyRef = useRef<string | null>(null)
const streamRafRef = useRef<number | null>(null)
const lastStreamParseAtRef = useRef(0)
useEffect(() => {
if (!editor) return
const syncEditorBody = (body: string) => {
if (body === lastSyncedBodyRef.current) return
lastSyncedBodyRef.current = body
editor.commands.setContent(parseMarkdownToDoc(body), {
contentType: 'json',
emitUpdate: false,
})
}
if (isStreaming) {
wasStreamingRef.current = true
if (editor.isEditable) editor.setEditable(false)
const body = splitFrontmatter(content).body
if (body === lastSyncedBodyRef.current) return
pendingStreamBodyRef.current = body
if (streamRafRef.current !== null) return
/** Self-re-arming tick: parse the latest pending body, but throttle a large one (cheap re-check, no parse) until due. */
const tick = () => {
const pending = pendingStreamBodyRef.current
if (pending === null || pending === lastSyncedBodyRef.current) {
streamRafRef.current = null
return
}
const shownBody = lastSyncedBodyRef.current
const extendsShown = shownBody === null || pending.startsWith(shownBody)
if (!streamIsIncrementalRef.current && !extendsShown) {
streamRafRef.current = null
return
}
if (
pending.length > STREAM_REPARSE_THROTTLE_THRESHOLD &&
performance.now() - lastStreamParseAtRef.current < STREAM_REPARSE_THROTTLE_MS
) {
streamRafRef.current = requestAnimationFrame(tick)
return
}
streamRafRef.current = null
lastSyncedBodyRef.current = pending
lastStreamParseAtRef.current = performance.now()
const el = containerRef.current
const pinnedToBottom = el ? el.scrollHeight - el.scrollTop - el.clientHeight < 80 : false
if (editor.isEditable) editor.setEditable(false)
editor.commands.setContent(parseMarkdownToDoc(pending), {
contentType: 'json',
emitUpdate: false,
})
if (!disableStreamingAutoScroll && el && pinnedToBottom) el.scrollTop = el.scrollHeight
}
streamRafRef.current = requestAnimationFrame(tick)
return
}
if (streamRafRef.current !== null) {
cancelAnimationFrame(streamRafRef.current)
streamRafRef.current = null
}
/** Settle: re-lock the verdict + frontmatter on the freshly-settled content (every stream→settle, not just the first). */
const isInitialSettle = settledRef.current === null
if (isInitialSettle || wasStreamingRef.current) {
wasStreamingRef.current = false
settledRef.current = lockSettled(content)
syncEditorBody(splitFrontmatter(content).body)
// `setContent` maps any pre-existing selection onto the new doc rather than clearing it — a
// select-all survives as "select everything," permanently painting every divider/image with the
// `rich-leaf-in-selection` decoration (keymap.ts) until the user clicks elsewhere. This must run
// on every settle regardless of whether `setContent` ran just above: the last streaming tick
// already syncs `lastSyncedBodyRef` to the final body before settle, so `body` usually already
// equals it here — collapsing only inside that `if` would skip the common streamed-content case
// entirely. `setTextSelection` (not `.focus()`) so this never steals DOM focus from whatever the
// user is doing outside the editor.
editor.commands.setTextSelection(editor.state.doc.content.size)
editor.setEditable(canEdit && settledRef.current.verdict)
if (isInitialSettle && autoFocus) editor.commands.focus('end')
return
}
syncEditorBody(splitFrontmatter(content).body)
if (settledRef.current) editor.setEditable(canEdit && settledRef.current.verdict)
}, [editor, content, isStreaming, canEdit, autoFocus, disableStreamingAutoScroll])
useEffect(
() => () => {
if (streamRafRef.current !== null) cancelAnimationFrame(streamRafRef.current)
},
[]
)
return (
<div
ref={containerRef}
className={cn('flex flex-1 flex-col overflow-y-auto', isEditable && 'cursor-text')}
>
{editor && <EditorBubbleMenu editor={editor} scrollContainerRef={containerRef} />}
{editor && <TableBubbleMenu editor={editor} scrollContainerRef={containerRef} />}
{editor && <LinkHoverCard editor={editor} />}
<input
ref={imageInputRef}
type='file'
accept='image/*'
multiple
hidden
onChange={(event) => {
const input = event.currentTarget
const images = Array.from(input.files ?? []).filter((f) => f.type.startsWith('image/'))
const at =
pendingImagePosRef.current ?? editorInstanceRef.current?.state.selection.from ?? 0
pendingImagePosRef.current = null
input.value = ''
if (images.length > 0) void insertImagesRef.current(images, at)
}}
/>
<EditorContent
editor={editor}
className='mx-auto flex w-full max-w-[48rem] flex-1 flex-col px-8 py-6 selection:bg-[var(--selection-bg)] selection:text-[var(--text-primary)] dark:selection:bg-[var(--selection-dark)] dark:selection:text-white'
/>
</div>
)
}
@@ -0,0 +1,238 @@
'use client'
import { useEffect, useRef, useState } from 'react'
import { ChipTextarea, chipFieldSurfaceClass, cn } from '@sim/emcn'
import type { JSONContent } from '@tiptap/core'
import { EditorContent, useEditor } from '@tiptap/react'
import { createMarkdownEditorExtensions } from './editor-extensions'
import {
applyFrontmatter,
postProcessSerializedMarkdown,
splitFrontmatter,
} from './markdown-fidelity'
import { parseMarkdownToDoc } from './markdown-parse'
import { useEditorMentions } from './mention'
import { EditorBubbleMenu } from './menus/bubble-menu'
import { LinkHoverCard } from './menus/link-hover-card'
import { normalizeMarkdownContent } from './normalize-content'
import { isRoundTripSafe } from './round-trip-safety'
import '@sim/emcn/components/code/code.css'
import './rich-markdown-editor.css'
interface RichMarkdownFieldProps {
/** Current markdown value. Seeds the editor once on mount; external changes only apply while {@link isStreaming}. */
value: string
/** Fires with the serialized markdown on every local edit. */
onChange: (markdown: string) => void
placeholder?: string
/** Renders the editor read-only (e.g. while saving). */
disabled?: boolean
/** True while `value` is being pushed in externally (AI generation) — the editor turns read-only and mirrors each update. */
isStreaming?: boolean
autoFocus?: boolean
/** Min height of the scroll box in px. */
minHeight?: number
/** Max height of the scroll box in px before it scrolls. */
maxHeight?: number
/** Swaps the border to the error token (the message itself is rendered by the surrounding field). */
error?: boolean
/** Enables the `@` mention menu scoped to this workspace. Omit to disable mentions. */
workspaceId?: string
/** Force the `@` tag-insertion menu off even with a workspace set (existing tags still render). */
disableTagging?: boolean
/**
* Intercepts a plain-text paste before the editor handles it. Return `true` to consume the paste
* (e.g. a full document the host destructures elsewhere); `false` to fall through to normal
* markdown paste.
*/
onPasteText?: (text: string) => boolean
}
/**
* The WYSIWYG editor for round-trip-safe content (chosen by {@link RichMarkdownField}). The file-less
* sibling of {@link RichMarkdownEditor}'s loaded editor: same TipTap extensions, parser, and menus but
* no file loading, autosave, or image upload.
*/
function LoadedRichMarkdownField({
value,
onChange,
placeholder = "Write something, or press '/' for commands…",
disabled = false,
isStreaming = false,
autoFocus = false,
minHeight = 140,
maxHeight = 360,
error = false,
workspaceId,
disableTagging,
onPasteText,
}: RichMarkdownFieldProps) {
const containerRef = useRef<HTMLDivElement>(null)
/**
* Frontmatter is held out-of-band and re-attached on serialize, exactly like the file editor. Split
* once at mount — the refs and the seed doc all derive from this initial value.
*/
const [initialSplit] = useState(() => splitFrontmatter(value))
const frontmatterRef = useRef(initialSplit.frontmatter)
/** The body last reflected into the editor — updated on local edits and on each streamed sync. */
const lastSyncedBodyRef = useRef(initialSplit.body)
const onChangeRef = useRef(onChange)
onChangeRef.current = onChange
const onPasteTextRef = useRef(onPasteText)
onPasteTextRef.current = onPasteText
/**
* The original value verbatim, plus its canonical serialization. The editor only ever emits canonical
* markdown, so an already-non-canonical input would re-serialize on mount and read as an unsaved edit;
* reporting the original when the doc matches its canonical form keeps the field clean until a real edit.
*/
const initialValueRef = useRef(value)
const [canonicalSeed] = useState(() => normalizeMarkdownContent(value))
/** TipTap extensions are stateful — build them once per mount so each field gets its own placeholder. */
const [extensions] = useState(() => createMarkdownEditorExtensions({ placeholder }))
const [initialContent] = useState<JSONContent>(() => parseMarkdownToDoc(initialSplit.body))
const editor = useEditor({
extensions,
editable: !disabled && !isStreaming,
enablePasteRules: false,
autofocus: autoFocus ? 'end' : false,
immediatelyRender: false,
shouldRerenderOnTransaction: false,
content: initialContent,
editorProps: {
attributes: {
class: 'rich-markdown-prose rich-markdown-field-prose',
// Claim ⌘K so the bubble-menu link editor wins over the global search palette.
'data-owned-shortcuts': 'Mod+K',
},
handlePaste: (_view, event) => {
const handler = onPasteTextRef.current
if (!handler) return false
const text = event.clipboardData?.getData('text/plain')
if (!text) return false
return handler(text)
},
/**
* The field has no image upload; swallow any file drop so the browser doesn't navigate to the
* dropped file and tear down the modal. Internal text drags carry no files and fall through.
*/
handleDrop: (_view, event) => {
if (event.dataTransfer?.files.length) {
event.preventDefault()
return true
}
return false
},
},
onUpdate: ({ editor }) => {
const md = postProcessSerializedMarkdown(editor.getMarkdown())
lastSyncedBodyRef.current = md
const serialized = applyFrontmatter(frontmatterRef.current, md)
onChangeRef.current(serialized === canonicalSeed ? initialValueRef.current : serialized)
},
})
/** Mirrors an externally-driven value (AI generation) into the editor, then settles to editable. */
const wasStreamingRef = useRef(isStreaming)
useEffect(() => {
if (!editor) return
const { frontmatter, body } = splitFrontmatter(value)
frontmatterRef.current = frontmatter
if (isStreaming) {
wasStreamingRef.current = true
if (editor.isEditable) editor.setEditable(false)
if (body === lastSyncedBodyRef.current) return
lastSyncedBodyRef.current = body
const el = containerRef.current
const pinnedToBottom = el ? el.scrollHeight - el.scrollTop - el.clientHeight < 60 : false
editor.commands.setContent(parseMarkdownToDoc(body), {
contentType: 'json',
emitUpdate: false,
})
if (el && pinnedToBottom) el.scrollTop = el.scrollHeight
return
}
if (wasStreamingRef.current) {
wasStreamingRef.current = false
if (body !== lastSyncedBodyRef.current) {
lastSyncedBodyRef.current = body
editor.commands.setContent(parseMarkdownToDoc(body), {
contentType: 'json',
emitUpdate: false,
})
}
}
if (editor.isEditable !== !disabled) editor.setEditable(!disabled)
}, [editor, value, isStreaming, disabled])
useEditorMentions(editor, workspaceId, { disableTagging })
return (
<div
ref={containerRef}
className={cn(
'flex flex-col overflow-y-auto px-3 py-2',
chipFieldSurfaceClass,
error && 'border-[var(--text-error)]',
!disabled && !isStreaming && 'cursor-text'
)}
style={{ minHeight, maxHeight }}
>
{editor && <EditorBubbleMenu editor={editor} scrollContainerRef={containerRef} />}
{editor && <LinkHoverCard editor={editor} />}
<EditorContent
editor={editor}
className='flex flex-1 flex-col selection:bg-[var(--selection-bg)] selection:text-[var(--text-primary)] dark:selection:bg-[var(--selection-dark)] dark:selection:text-white'
/>
</div>
)
}
/**
* Raw-text fallback for content the rich editor can't round-trip losslessly — editing the markdown
* source directly so an edit can't silently drop footnotes, raw HTML, or comments. Honors the same
* `onPasteText` hook as the WYSIWYG path (e.g. skill `SKILL.md` destructuring) so a full-document paste
* is intercepted here too.
*/
function RawMarkdownField({
value,
onChange,
placeholder,
disabled = false,
isStreaming = false,
minHeight = 140,
maxHeight = 360,
error = false,
onPasteText,
}: RichMarkdownFieldProps) {
return (
<ChipTextarea
value={value}
onChange={(event) => onChange(event.target.value)}
onPaste={(event) => {
const text = event.clipboardData.getData('text/plain')
if (text && onPasteText?.(text)) event.preventDefault()
}}
placeholder={placeholder}
error={error}
readOnly={disabled || isStreaming}
style={{ minHeight, maxHeight }}
/>
)
}
/**
* A controlled, string-valued markdown editor for modal fields. Drop it inside a `ChipModalField
* type='custom'`. Mirrors the file editor's safety gate (decided once from the initial value):
* round-trip-safe content opens in the WYSIWYG editor, while lossy markdown (raw HTML, footnotes,
* comments) falls back to raw-text editing so an edit can't silently drop those constructs.
*/
export function RichMarkdownField(props: RichMarkdownFieldProps) {
const [isSafe] = useState(() => isRoundTripSafe(props.value))
return isSafe ? <LoadedRichMarkdownField {...props} /> : <RawMarkdownField {...props} />
}
@@ -0,0 +1,293 @@
/**
* @vitest-environment jsdom
*/
import { describe, expect, it } from 'vitest'
import { isRoundTripSafe } from './round-trip-safety'
describe('isRoundTripSafe', () => {
it('passes ordinary markdown and lossless normalizations', () => {
expect(isRoundTripSafe('# Title\n\nA **bold** word and a [link](https://sim.ai).')).toBe(true)
expect(isRoundTripSafe('- one\n- two\n\n```js\nconst x = 1\n```')).toBe(true)
expect(isRoundTripSafe('| a | b |\n| :-- | --: |\n| 1 | 2 |')).toBe(true)
expect(isRoundTripSafe('- [ ] a\n - [x] b')).toBe(true)
expect(isRoundTripSafe('line one \nline two')).toBe(true)
expect(isRoundTripSafe('value $x^2 + y$ here')).toBe(true)
expect(isRoundTripSafe('a &amp; b &lt; c')).toBe(true)
expect(isRoundTripSafe('Title\n=====\n\nbody')).toBe(true)
expect(isRoundTripSafe('')).toBe(true)
})
it('passes a linked image / badge (round-trips through the image node href)', () => {
expect(isRoundTripSafe('[![alt](https://e.com/i.png)](https://e.com)')).toBe(true)
expect(
isRoundTripSafe('[![build](https://img.shields.io/badge/x-green)](https://ci.example.com)')
).toBe(true)
expect(isRoundTripSafe('[![alt](https://e.com/i.png "t")](https://e.com "h")')).toBe(true)
})
it('passes inline code without an interior backtick', () => {
expect(isRoundTripSafe('use `npm install` here')).toBe(true)
})
it('passes a code block followed by other content (idempotent block separation)', () => {
expect(isRoundTripSafe('```\ncode\n```\n\ntext after')).toBe(true)
expect(
isRoundTripSafe('```markdown\n\n```\n\n![s](/api/files/serve/x.png?context=workspace)')
).toBe(true)
expect(isRoundTripSafe('> ```\n> code\n> ```')).toBe(true)
})
it('preserves footnotes, HTML comments, and raw HTML tags via the verbatim snippet nodes', () => {
expect(isRoundTripSafe('text[^1]\n\n[^1]: the note')).toBe(true)
expect(isRoundTripSafe('<!-- a note -->\n\ntext')).toBe(true)
expect(isRoundTripSafe('<details><summary>x</summary>body</details>')).toBe(true)
expect(isRoundTripSafe('a <sub>b</sub> c')).toBe(true)
})
it('rejects a hard break inside a heading (serializer splits the heading)', () => {
expect(isRoundTripSafe('# one \ntwo')).toBe(false)
expect(isRoundTripSafe('## title\\\nmore')).toBe(false)
})
it('rejects HTML entities other than the canonical three (escaped to literal source)', () => {
expect(isRoundTripSafe('it&#39;s here')).toBe(false)
expect(isRoundTripSafe('&copy; 2024')).toBe(false)
expect(isRoundTripSafe('a&nbsp;b')).toBe(false)
expect(isRoundTripSafe('a &amp; b &lt; c &gt; d')).toBe(true)
expect(isRoundTripSafe('AT&T and R&D')).toBe(true)
expect(isRoundTripSafe('a &AMP; b')).toBe(false)
expect(isRoundTripSafe('a &LT; b &GT; c')).toBe(false)
})
it('rejects an orphan reference definition (serializer drops it) but allows used ones', () => {
expect(isRoundTripSafe('Some text.\n\n[unused]: https://example.com "title"')).toBe(false)
expect(isRoundTripSafe('[a]: u1\n[b]: u2\n\nuse only [a]')).toBe(false)
expect(isRoundTripSafe('See [x][1].\n\n[1]: https://example.com "T"')).toBe(true)
expect(isRoundTripSafe('A [shortcut] ref.\n\n[shortcut]: https://example.com')).toBe(true)
expect(isRoundTripSafe('Case [Foo] insensitive.\n\n[foo]: https://example.com')).toBe(true)
expect(isRoundTripSafe('A note.\n\n[^x]: the footnote body')).toBe(true)
expect(isRoundTripSafe('See [ foo ] here.\n\n[foo]: https://example.com')).toBe(true)
})
it('does not flag HTML/comments/entities inside tilde or nested code fences', () => {
expect(isRoundTripSafe('~~~html\n<!-- c -->\n~~~')).toBe(true)
expect(isRoundTripSafe('````md\n```\n<div>x</div>\n```\n````')).toBe(true)
})
it('rejects non-idempotent churn', () => {
expect(isRoundTripSafe('render `` a`b `` inline')).toBe(false)
})
it('does not flag <br> outside a table (converts losslessly to a hard break)', () => {
expect(isRoundTripSafe('a<br>b')).toBe(true)
expect(isRoundTripSafe('a line\n\nwith | a pipe but no break')).toBe(true)
expect(isRoundTripSafe('Use a<br>break or the pipe | operator.')).toBe(true)
})
it('rejects <br> inside a table cell (flattened to a space)', () => {
expect(isRoundTripSafe('| a | b |\n| --- | --- |\n| one<br>two | x |')).toBe(false)
})
it('allows <img> (a supported, resizable image node)', () => {
expect(isRoundTripSafe('<img src="https://e.com/i.png" width="320">')).toBe(true)
})
it('does not flag a fenced block that merely contains html or backticks', () => {
expect(isRoundTripSafe('```html\n<div>hi</div>\n```')).toBe(true)
expect(isRoundTripSafe('````md\n```\ncode\n```\n````')).toBe(true)
})
it('does not flag markdown autolinks as raw html', () => {
expect(isRoundTripSafe('see <https://sim.ai> for more')).toBe(true)
})
it('probes documents up to the size cap but falls back (read-only) above it', () => {
// ~100KB of simple safe prose is under the 256KB cap → probed and editable.
expect(isRoundTripSafe(`# Title\n\n${'word '.repeat(20000)}`)).toBe(true)
// ~300KB is over the cap → opens read-only (too many DOM nodes to edit comfortably).
expect(isRoundTripSafe(`# Title\n\n${'word '.repeat(60000)}`)).toBe(false)
})
})
const README = `# Acme CLI
[![build](https://img.shields.io/badge/build-passing-green)](https://example.com)
Acme is a fast, friendly command-line tool.
## Installation
\`\`\`bash
npm install -g acme
acme --help
\`\`\`
## Usage
Run \`acme init\` to scaffold a project, then:
1. Edit \`acme.config.json\`
2. Run \`acme build\`
3. Ship it
> **Note:** requires Node 18+.
| Flag | Description | Default |
| --- | --- | --- |
| \`--watch\` | Rebuild on change | \`false\` |
| \`--out\` | Output directory | \`dist\` |
### Features
- Zero-config defaults
- Incremental builds
- Caches by content hash
- Skips unchanged files
- Plugin system
See the [docs](https://example.com/docs) for more.
`
const MEETING_NOTES = `# Weekly Sync — 2026-06-18
**Attendees:** Alice, Bob, Carol
## Agenda
1. Roadmap review
2. Incident retro
3. Open questions
## Notes
- Roadmap is *on track* for Q3.
- The **incident** on Monday was a config regression.
1. Root cause: a stale cache key
2. Fix: invalidate on deploy
- Carol will own the migration.
### Action items
- [x] Write the retro doc
- [ ] Schedule the migration window
- [ ] Email the customers affected
\`\`\`sql
SELECT count(*) FROM events WHERE created_at > now() - interval '7 days';
\`\`\`
That's all for today.
`
const CHANGELOG = `# Changelog
All notable changes are documented here.
## [1.4.0] - 2026-06-01
### Added
- New \`--json\` output mode
- Support for \`AT&T\` style names and \`R&D\` labels
### Fixed
- A crash when the input was empty
- Off-by-one in the progress bar
## [1.3.2] - 2026-05-12
### Changed
- Bumped dependencies
---
Older entries omitted.
`
const NESTED_AND_QUOTES = `# Deep Doc
> A blockquote
> spanning two lines.
>
> > And a nested one.
1. First
- sub bullet with \`code\`
- another
1. deep ordered
2. item
2. Second
\`\`\`typescript
function add(a: number, b: number): number {
return a + b
}
\`\`\`
A paragraph with _emphasis_, **strong**, and ~~strikethrough~~ text.
Math-ish prose like value $x^2 + y$ stays literal.
`
const TABLES_AND_LINKS = `# Reference
| Method | Path | Auth |
| :----- | :--: | ---: |
| GET | \`/items\` | yes |
| POST | \`/items\` | yes |
Inline autolink: <https://sim.ai>
A normal link to [the site](https://sim.ai "title") and an image:
![diagram](https://example.com/diagram.png)
Use \`a &amp; b\` and \`x < y\` in code freely.
`
const EDITABLE_CORPUS: Record<string, string> = {
README,
MEETING_NOTES,
CHANGELOG,
NESTED_AND_QUOTES,
TABLES_AND_LINKS,
}
// Certainty corpus for the editability gate: realistic, full-length markdown (READMEs, notes,
// changelogs, nested lists, tables, task lists, blockquotes, fenced code) must ALL stay editable —
// the probe may only ever refuse genuinely lossy constructs, never ordinary prose.
describe('editability gate — realistic documents stay editable', () => {
for (const [name, doc] of Object.entries(EDITABLE_CORPUS)) {
it(`opens editable: ${name}`, () => {
expect(isRoundTripSafe(doc)).toBe(true)
})
}
it('a large-but-ordinary document (just under the probe limit) stays editable', () => {
const big = `# Big Doc\n\n${'A paragraph of perfectly ordinary prose. '.repeat(5000)}`
expect(big.length).toBeLessThan(256 * 1024)
expect(big.length).toBeGreaterThan(128 * 1024)
expect(isRoundTripSafe(big)).toBe(true)
})
it('frontmatter does not gate editability', () => {
expect(isRoundTripSafe('---\ntitle: Hello\ntags: [a, b]\n---\n\n# Body\n\nText.')).toBe(true)
})
})
// The flip side and exact boundary of the gate: constructs the WYSIWYG schema genuinely cannot
// represent open read-only so an edit can't silently corrupt them. Raw HTML blocks, comments, and
// footnotes used to be the canonical examples here — `./raw-markdown-snippet.ts` now holds each
// verbatim (including a multi-line block spanning blank lines, via the same `NON_CHUNKABLE`
// whole-document parse path `markdown-parse.ts` already uses for these constructs), so they moved
// to the "preserved" test above instead of staying here.
describe('editability gate — genuinely lossy constructs open read-only', () => {
it('raw HTML blocks (<details>, <div align>) are preserved verbatim, not locked read-only', () => {
expect(isRoundTripSafe('<details><summary>More</summary>\n\nbody\n\n</details>')).toBe(true)
expect(isRoundTripSafe('<div align="center">\n\ncentered\n\n</div>')).toBe(true)
})
it('HTML comments and footnotes are preserved verbatim, not locked read-only', () => {
expect(isRoundTripSafe('<!-- TODO: revise -->\n\ntext')).toBe(true)
expect(isRoundTripSafe('a claim[^1]\n\n[^1]: the source')).toBe(true)
})
})
@@ -0,0 +1,127 @@
import { serializeMarkdownDocument } from './markdown-parse'
/**
* Above this size the file opens read-only. Parsing is chunked and linear now (see
* {@link serializeMarkdownBody}), so this is no longer about parse cost — it guards ProseMirror's
* whole-document-in-DOM rendering, which has no virtualization and gets sluggish to edit for very
* large documents. 256KB sits past the p99 of real markdown files while keeping a giant outlier from
* mounting thousands of editable DOM nodes.
*/
const PROBE_SIZE_LIMIT = 256 * 1024
/**
* Constructs the editor drops or mangles in a way that survives a second serialization
* unchanged — so the idempotency probe below can't see the loss. Each must be matched directly.
* (Linked images `[![alt](img)](href)` are handled by the image node and verified separately by
* the link-count check in {@link isRoundTripSafe}, not here.)
*
* Footnotes, HTML comments, and raw HTML tags (`<div>`, `<details>`, `<kbd>`, …) used to be listed
* here — the schema had no node for any of them, so they were dropped or stripped (content kept,
* structure lost). `./raw-markdown-snippet.ts` now holds each construct's exact source text and
* re-emits it byte-for-byte, so none of them lose data on round-trip and none need a pattern below.
*
* - **`<br>` inside a table cell** — a GFM cell can't hold a real line break, so the serializer
* flattens `one<br>two` to `one two`. Matched on a table-shaped line (≥2 pipes) containing a `<br>`.
* - **Hard break inside a heading** (trailing two spaces or a backslash) — the serializer splits
* the heading, ejecting the second line into a separate paragraph.
* - **HTML entity** other than the lowercase canonical `&amp;`/`&lt;`/`&gt;` (e.g. `&copy;`, `&#39;`,
* `&nbsp;`, or the uppercase `&AMP;`) — the serializer escapes the `&`, turning the rendered character
* into literal entity source. The safe-list is deliberately case-*sensitive*: `@tiptap/markdown` only
* round-trips the lowercase forms, so `&AMP;`/`&LT;`/`&GT;` must fall through to read-only rather than
* be treated as safe. A bare `&` with no matching `;`-terminated name is left alone (harmless churn).
*/
const STABLE_LOSS_PATTERNS: ReadonlyArray<RegExp> = [
/^(?=(?:[^\n]*\|){2})[^\n]*<br\s*\/?>/im,
/^#{1,6}\s.*(?: {2,}|\\)$/m,
/&(?!(?:amp|lt|gt);)(?:#x?[0-9a-fA-F]+|[a-zA-Z][a-zA-Z0-9]*);/,
]
/**
* Strip code regions so the patterns above don't fire on code samples: fenced blocks (backtick
* or tilde, length-matched on the closer so nested fences strip as one unit) and inline code.
* Indented (4-space) code is deliberately NOT stripped — list/paragraph continuation lines are
* also indented, and over-stripping would risk missing a real unsafe construct (a false negative,
* which is worse than the rare false positive of an indented code block opening read-only).
*/
function stripCode(content: string): string {
return content
.replace(/^([`~]{3,})[^\n]*\n[\s\S]*?^\1[`~]*[ \t]*$/gm, '')
.replace(/`+[^`\n]*`+/g, '')
}
/**
* Linked images `[![alt](src)](href)`. The image node round-trips the common forms (clean URLs,
* optional titles) via its `href` attribute, but an exotic one it can't tokenize falls back to the
* stock parser, which drops the wrapping link — an invisible, stable loss. So instead of matching a
* fixed pattern, {@link isRoundTripSafe} counts these before and after one serialization and rejects
* if any disappeared.
*/
const LINKED_IMAGE_PATTERN = /\[\s*!\[[^\]]*]\([^)]*\)\s*]\([^)]*\)/g
function linkedImageCount(content: string): number {
return content.match(LINKED_IMAGE_PATTERN)?.length ?? 0
}
/**
* A link/image reference definition line: `[label]: destination "optional title"` (up to 3 leading
* spaces). The `(?!\^)` excludes GFM footnote definitions (`[^id]: …`) — those are preserved verbatim
* by the footnote node and round-trip regardless of whether their reference is present, so they must
* not be treated as droppable orphan definitions.
*/
const REFERENCE_DEFINITION = /^ {0,3}\[(?!\^)([^\]]+)]:[ \t]+\S[^\n]*$/gm
/** CommonMark reference labels match case-insensitively with internal whitespace collapsed. */
function normalizeReferenceLabel(label: string): string {
return label.trim().replace(/\s+/g, ' ').toLowerCase()
}
/**
* True when `content` defines a link/image reference that nothing uses. A *used* reference inlines
* losslessly on serialize (`[x][id]` + `[id]: url` → `[x](url)`), but an *unused* definition is dropped
* entirely — a silent deletion the idempotency probe can't see (the drop happens on the first pass,
* which is then stable). We open such a file read-only rather than lose the definition on first edit.
* Conservative: a label counts as used if it appears bracketed anywhere in the body, so the rare
* inline-text collision errs toward editable, never toward a false read-only.
*/
function hasOrphanReferenceDefinition(content: string): boolean {
const labels = new Set<string>()
for (const match of content.matchAll(REFERENCE_DEFINITION)) {
labels.add(normalizeReferenceLabel(match[1]))
}
if (labels.size === 0) return false
const body = content
.replace(REFERENCE_DEFINITION, '')
.replace(/\s+/g, ' ')
.replace(/\[\s+/g, '[')
.replace(/\s+\]/g, ']')
.toLowerCase()
for (const label of labels) {
if (!body.includes(`[${label}]`)) return true
}
return false
}
/**
* Whether `content` survives the editor's markdown round-trip without data loss or autosave
* churn. The editor opens the content read-only when this is false, so the probe is deliberately
* conservative: it rejects on any doubt rather than risk an edit silently corrupting a file.
*
* Two complementary checks: known stable-loss constructs are matched directly (the idempotency
* probe is blind to them), and everything else must reach a fixpoint — `serializeMarkdownDocument(x)`
* twice in a row must be byte-identical, so the first edit can't churn the file. Lossless
* normalizations (`_`→`*`, setext→ATX, autolink→inline, loose→tight lists) reach a fixpoint after one
* pass and are allowed through; genuine churn (a blockquote wrapping a code fence keeps growing) is not.
*/
export function isRoundTripSafe(content: string): boolean {
if (content.length > PROBE_SIZE_LIMIT) return false
const stripped = stripCode(content)
if (STABLE_LOSS_PATTERNS.some((pattern) => pattern.test(stripped))) return false
if (hasOrphanReferenceDefinition(stripped)) return false
try {
const once = serializeMarkdownDocument(content)
if (linkedImageCount(stripped) !== linkedImageCount(stripCode(once))) return false
return serializeMarkdownDocument(once) === once
} catch {
return false
}
}
@@ -0,0 +1,537 @@
/**
* @vitest-environment jsdom
*
* Round-trip fidelity: markdown → editor → markdown must preserve meaning and, critically,
* be idempotent (a second pass changes nothing) so autosave never churns. Mirrors the exact
* pipeline the editor uses: split frontmatter out, serialize the body, re-attach + clean up.
*/
import { Editor } from '@tiptap/core'
import { afterEach, describe, expect, it } from 'vitest'
import { createMarkdownContentExtensions } from './extensions'
import {
applyFrontmatter,
normalizeLinkHref,
postProcessSerializedMarkdown,
splitFrontmatter,
} from './markdown-fidelity'
let editor: Editor | null = null
function roundTrip(input: string): string {
const { frontmatter, body } = splitFrontmatter(input)
editor = new Editor({ extensions: createMarkdownContentExtensions() })
editor.commands.setContent(body, { contentType: 'markdown' })
const out = applyFrontmatter(frontmatter, postProcessSerializedMarkdown(editor.getMarkdown()))
editor.destroy()
editor = null
return out
}
afterEach(() => {
editor?.destroy()
editor = null
})
describe('markdown-fidelity utils', () => {
it('splits a frontmatter block and its trailing whitespace from the body', () => {
const fm = '---\ntitle: Hello\ntags: [a, b]\n---'
const { frontmatter, body } = splitFrontmatter(`${fm}\n\n# Body`)
expect(frontmatter).toBe(`${fm}\n\n`)
expect(body).toBe('# Body')
expect(applyFrontmatter(frontmatter, body)).toBe(`${fm}\n\n# Body`)
})
it('preserves the exact frontmatter/body separator (no whitespace churn)', () => {
for (const original of [
'---\na: 1\n---\nbody',
'---\na: 1\n---\n\nbody',
'---\na: 1\n---\n\n\n\nbody',
'---\na: 1\n---\r\n\r\nbody',
]) {
const { frontmatter, body } = splitFrontmatter(original)
expect(frontmatter + body).toBe(original)
}
})
it('recognizes empty and minimal frontmatter blocks', () => {
const empty = splitFrontmatter('---\n---\n\n# Title')
expect(empty.frontmatter).toBe('---\n---\n\n')
expect(empty.body).toBe('# Title')
const onlyFm = splitFrontmatter('---\ntitle: x\n---')
expect(onlyFm.frontmatter).toBe('---\ntitle: x\n---')
expect(onlyFm.body).toBe('')
const crlf = splitFrontmatter('---\r\n---\r\nbody')
expect(crlf.frontmatter + crlf.body).toBe('---\r\n---\r\nbody')
expect(crlf.body).toBe('body')
})
it('treats content with no frontmatter as all body', () => {
expect(splitFrontmatter('# Just a heading')).toEqual({
frontmatter: '',
body: '# Just a heading',
})
expect(applyFrontmatter('', '# Body')).toBe('# Body')
})
it('does not treat a horizontal rule as frontmatter', () => {
const md = 'above\n\n---\n\nbelow'
expect(splitFrontmatter(md)).toEqual({ frontmatter: '', body: md })
})
it('does not treat a leading `---` thematic break as frontmatter (keeps the top section visible)', () => {
// A changelog whose second `---` would close the regex: the `## v2.0` section must stay in body.
const md = '---\n\n## v2.0\n\nnotes\n\n---\n\n## v1.0'
expect(splitFrontmatter(md)).toEqual({ frontmatter: '', body: md })
})
it('holds a UTF-8 BOM out of band so frontmatter survives', () => {
const input = '\uFEFF---\ntitle: x\n---\n\nbody'
const { frontmatter, body } = splitFrontmatter(input)
expect(frontmatter.startsWith('\uFEFF')).toBe(true)
expect(body).toBe('body')
expect(applyFrontmatter(frontmatter, body)).toBe(input)
})
it('restores escaped callout markers', () => {
expect(postProcessSerializedMarkdown('> \\[!NOTE\\]\n> hi')).toBe('> [!NOTE]\n> hi')
})
it('restores escaped callout markers in nested blockquotes', () => {
expect(postProcessSerializedMarkdown('> > \\[!WARNING\\]\n> > hi')).toBe(
'> > [!WARNING]\n> > hi'
)
})
it('normalizes link hrefs', () => {
expect(normalizeLinkHref('')).toBe('')
expect(normalizeLinkHref('sim.ai')).toBe('https://sim.ai')
expect(normalizeLinkHref('example.com/path')).toBe('https://example.com/path')
expect(normalizeLinkHref('https://x.com')).toBe('https://x.com')
expect(normalizeLinkHref('HTTP://x.com')).toBe('HTTP://x.com')
expect(normalizeLinkHref('mailto:a@b.com')).toBe('mailto:a@b.com')
expect(normalizeLinkHref('#anchor')).toBe('#anchor')
expect(normalizeLinkHref('/relative')).toBe('/relative')
// Relative paths stay relative (not prefixed into `https://./…`).
expect(normalizeLinkHref('./other.md')).toBe('./other.md')
expect(normalizeLinkHref('../doc.md')).toBe('../doc.md')
expect(normalizeLinkHref(' https://x.com ')).toBe('https://x.com')
expect(normalizeLinkHref('javascript:alert(1)')).toBe('')
expect(normalizeLinkHref('data:text/html,<script>')).toBe('')
expect(normalizeLinkHref('//cdn.example.com/a.js')).toBe('https://cdn.example.com/a.js')
expect(normalizeLinkHref('ftp://host/file')).toBe('ftp://host/file')
// Dangerous schemes rejected; a bare host:port is still treated as a domain.
expect(normalizeLinkHref('file:///etc/passwd')).toBe('')
expect(normalizeLinkHref('blob:https://x.com/uuid')).toBe('')
expect(normalizeLinkHref('vbscript:msgbox(1)')).toBe('')
expect(normalizeLinkHref('localhost:3000/path')).toBe('https://localhost:3000/path')
})
it('collapses trailing blank lines and preserves leading whitespace', () => {
expect(postProcessSerializedMarkdown('| a |\n| --- |\n\n\n')).toBe('| a |\n| --- |\n')
// No global leading-newline strip (the table trims its own at the source), so content that
// legitimately begins with a blank line is no longer clobbered on save.
expect(postProcessSerializedMarkdown('\nbody\n')).toBe('\nbody\n')
})
})
describe('editor markdown round-trip', () => {
const cases: Record<string, string> = {
headings: '# H1\n\n## H2\n\n### H3',
bold: 'a **bold** word',
link: 'see [Sim](https://sim.ai)',
'nested bullets': '- one\n- two\n - nested',
ordered: '1. one\n2. two',
'task list': '- [ ] todo\n- [x] done',
quote: '> a quote',
'code block': '```js\nconst x = 1\n```',
'code block then paragraph': '```\ncode\n```\n\ntext after',
'code block then image':
'```markdown\n\n```\n\n![shot](/api/files/serve/x.png?context=workspace)',
'paragraph then code block': 'text before\n\n```\ncode\n```',
'two code blocks': '```\na\n```\n\n```\nb\n```',
mermaid: '```mermaid\ngraph TD\n A --> B\n```',
'horizontal rule': 'above\n\n---\n\nbelow',
table: '| a | b |\n| --- | --- |\n| 1 | 2 |',
'strike code': '~~`x`~~',
'bold code': '**`x`**',
'heading strike code': '# ~~`x`~~',
'table with pipe': '| x \\| y | 2 |\n| --- | --- |\n| a | b |',
'bold italic nested': '**bold _italic_ word**',
'strike bold nested': '~~**struck bold**~~',
'bold code inline': '**bold `code` here**',
'triple nested marks': '*i **b ~~s~~** i*',
'all marks in heading': '# **b** ~~s~~ *i* `c`',
'marks in bullet': '- **a** ~~b~~ `c`',
'marks in quote': '> **a** ~~b~~ *c*',
'nested list marks': '- **a**\n - ~~b~~\n - *c*',
'bold link': '[**bold link**](https://x.com)',
'link inside bold': '**see [x](https://x.com)**',
'table with marks': '| **b** | ~~s~~ | `c` |\n| --- | --- | --- |\n| *i* | a | b |',
'bold across code boundary': '**a** `b` **c**',
highlight: 'a ==marked== word',
'highlight in heading': '# a ==mark== b',
'highlight nested in bold': '**bold ==mark== here**',
'highlight in list': '- ==a== item',
'highlight with interior equals': 'x ==a=b== y',
}
for (const [name, input] of Object.entries(cases)) {
it(`is idempotent for ${name}`, () => {
const once = roundTrip(input)
const twice = roundTrip(once)
expect(twice).toBe(once)
})
}
// The `@`-mention link scheme must survive the schema, or the mention is silently stripped to
// plain text (which idempotency above can't detect). See the `sim` protocol in extensions.ts.
it('preserves a @-mention sim: link', () => {
const input = 'see [my-skill](sim:skill/abc123) and [Spec](sim:file/xyz-789)'
expect(roundTrip(input)).toBe(input)
})
it('preserves frontmatter through a full round-trip', () => {
const input = '---\ntitle: Hello\ntags: [a, b]\n---\n\n# Body\n\ntext'
const out = roundTrip(input)
expect(out).toContain('---\ntitle: Hello\ntags: [a, b]\n---')
expect(out).toContain('# Body')
expect(out).toBe(roundTrip(out))
})
it('keeps GFM callout markers unescaped', () => {
expect(roundTrip('> [!NOTE]\n> Heads up')).toContain('[!NOTE]')
})
it('preserves an image url (does not drop the src)', () => {
const out = roundTrip('![alt](https://example.com/i.png)')
expect(out).toContain('![alt](https://example.com/i.png)')
})
it('round-trips an image whose alt/title contain delimiter characters (idempotent)', () => {
const input = '![a [b] c](https://example.com/i.png "ti\\"tle")'
const out = roundTrip(input)
expect(roundTrip(out)).toBe(out)
expect(out).toContain('https://example.com/i.png')
})
it('round-trips a linked image / badge (keeps the wrapping link)', () => {
const out = roundTrip(
'[![build](https://img.shields.io/badge/x-green)](https://ci.example.com)'
)
expect(out).toContain(
'[![build](https://img.shields.io/badge/x-green)](https://ci.example.com)'
)
expect(roundTrip(out)).toBe(out)
})
it('keeps a plain image plain (no spurious link wrapper)', () => {
const out = roundTrip('![alt](https://example.com/i.png)')
expect(out).not.toContain('](https://example.com/i.png)](')
expect(out.trim()).toBe('![alt](https://example.com/i.png)')
})
it('round-trips a sized image as an HTML <img>, plain images as markdown', () => {
const sized = roundTrip('<img src="https://e.com/i.png" alt="d" width="320">')
expect(sized).toContain('<img src="https://e.com/i.png" alt="d" width="320">')
expect(roundTrip(sized)).toBe(sized)
expect(roundTrip('![a](https://e.com/i.png)')).toContain('![a](https://e.com/i.png)')
})
it('preserves a sized base64 image and escapes quotes in attributes', () => {
const dataUrl = '<img src="data:image/png;base64,iVBORw0KGgo=" width="200">'
expect(roundTrip(dataUrl)).toContain('data:image/png;base64,iVBORw0KGgo=')
expect(roundTrip(dataUrl)).toBe(roundTrip(roundTrip(dataUrl)))
const quoted = roundTrip('<img src="/x.png" alt=\'a"b\' width="320">')
expect(quoted).toContain('alt="a&quot;b"')
expect(roundTrip(quoted)).toBe(quoted)
})
it('round-trips a code block that contains a fence line (sized fence)', () => {
const out = roundTrip('````md\n```\ncode\n```\n````')
expect(out).toContain('```\ncode\n```')
expect(roundTrip(out)).toBe(out)
})
it('keeps a mermaid block as a fenced code block', () => {
expect(roundTrip('```mermaid\ngraph TD\n A --> B\n```')).toContain('```mermaid')
})
it('keeps task list checkbox state', () => {
const out = roundTrip('- [ ] todo\n- [x] done')
expect(out).toContain('- [ ] todo')
expect(out).toContain('- [x] done')
})
it('keeps a table as a GFM pipe table with no leading blank line', () => {
const out = roundTrip('| a | b |\n| --- | --- |\n| 1 | 2 |')
expect(out.startsWith('|')).toBe(true)
expect(out).toContain('| --- |')
})
it('escapes only interior cell pipes, not the structural delimiters', () => {
const out = roundTrip('| a | b |\n| --- | --- |\n| one \\| two | three |')
expect(out).toContain('one \\| two')
expect(out).toContain('| three |')
// Every row keeps exactly its two structural columns (3 pipes per line).
for (const line of out.trim().split('\n')) {
expect((line.match(/(?<!\\)\|/g) ?? []).length).toBe(3)
}
expect(roundTrip(out)).toBe(out)
})
it('combines strikethrough with inline code (relaxed code mark)', () => {
expect(roundTrip('~~`x`~~')).toContain('~~`x`~~')
expect(roundTrip('# ~~`x`~~')).toContain('# ~~`x`~~')
})
it('escapes interior pipes in table cells (no phantom column split)', () => {
const out = roundTrip('| x \\| y | 2 |\n| --- | --- |\n| a | b |')
expect(out).toContain('x \\| y')
})
it('does not churn blank lines around an interior table', () => {
const out = roundTrip('before\n\n| a | b |\n| --- | --- |\n| 1 | 2 |\n\nafter')
expect(out).not.toContain('\n\n\n')
expect(out).toContain('before')
expect(out).toContain('after')
expect(roundTrip(out)).toBe(out)
})
it('does not churn blank lines between two adjacent tables', () => {
const out = roundTrip('| a |\n| --- |\n| 1 |\n\n| b |\n| --- |\n| 2 |')
expect(out).not.toContain('\n\n\n')
expect(roundTrip(out)).toBe(out)
})
it('preserves blank lines inside a fenced code block (table trim must not touch code)', () => {
const out = roundTrip('```js\na\n\n\nb\n```')
expect(out).toContain('a\n\n\nb')
expect(roundTrip(out)).toBe(out)
})
})
/**
* Links come from arbitrary file content (a README the editor opens, agent-written markdown), not
* just user-typed text — so the rendered anchor must never carry a dangerous scheme. This locks the
* guarantee in our own test rather than trusting a transitive TipTap default to keep neutralizing
* `javascript:`/`data:`/`vbscript:` across version bumps.
*/
describe('link href sanitization — dangerous schemes from file content are neutralized', () => {
function renderedHrefs(markdown: string): Array<string | null> {
editor = new Editor({ extensions: createMarkdownContentExtensions() })
editor.commands.setContent(markdown, { contentType: 'markdown' })
const html = editor.getHTML()
editor.destroy()
editor = null
const doc = new DOMParser().parseFromString(html, 'text/html')
return Array.from(doc.querySelectorAll('a')).map((a) => a.getAttribute('href'))
}
it.each([
'javascript:alert(document.cookie)',
'JaVaScRiPt:alert(1)',
' javascript:alert(1)',
'data:text/html,<script>alert(1)</script>',
'vbscript:msgbox(1)',
])('does not render %s as a clickable href', (scheme) => {
for (const href of renderedHrefs(`[click me](${scheme})`)) {
expect((href ?? '').replace(/\s/g, '')).not.toMatch(/^(javascript|data|vbscript):/i)
}
})
it('preserves safe http/https/mailto links', () => {
const hrefs = renderedHrefs('[a](https://ok.example.com)\n\n[b](mailto:x@y.com)')
expect(hrefs).toContain('https://ok.example.com')
expect(hrefs).toContain('mailto:x@y.com')
})
})
describe('paragraph leading guard (marker escaping + indent stripping)', () => {
/** Serialize a doc whose first paragraph literally starts with `text`, then re-parse its first node. */
function serializeParagraph(text: string): {
md: string
reparsedType: string
idempotent: boolean
} {
editor = new Editor({ extensions: createMarkdownContentExtensions() })
editor.commands.setContent(
{ type: 'doc', content: [{ type: 'paragraph', content: [{ type: 'text', text }] }] },
{ contentType: 'json' }
)
const md = postProcessSerializedMarkdown(editor.getMarkdown())
editor.commands.setContent(md, { contentType: 'markdown' })
const reparsedType = editor.getJSON().content?.[0]?.type ?? ''
const idempotent = postProcessSerializedMarkdown(editor.getMarkdown()) === md
editor.destroy()
editor = null
return { md, reparsedType, idempotent }
}
it.each([
['# note', '\\# note'],
['###### note', '\\###### note'],
['#', '\\#'],
['- item', '\\- item'],
['+ item', '\\+ item'],
['1. step', '1\\. step'],
['1) step', '1\\) step'],
['---', '\\---'],
['- - -', '\\- - -'],
])('escapes a paragraph starting with %j so it stays a paragraph', (text, expectedMd) => {
const { md, reparsedType, idempotent } = serializeParagraph(text)
expect(md.trim()).toBe(expectedMd)
expect(reparsedType).toBe('paragraph')
expect(idempotent).toBe(true)
})
it.each([
['#hashtag'], // no space after # → not a heading
['-5 degrees'], // no space after - → not a bullet
['plain text'],
])('does not over-escape %j', (text) => {
const { md, reparsedType, idempotent } = serializeParagraph(text)
expect(md.trim()).toBe(text)
expect(reparsedType).toBe('paragraph')
expect(idempotent).toBe(true)
})
it.each([
[' four spaces', 'four spaces'],
['\ttab indent', 'tab indent'],
[' eight spaces', 'eight spaces'],
[' # indented marker', '\\# indented marker'],
])(
'strips leading indent so %j stays a paragraph instead of an indented code block',
(text, expectedMd) => {
const { md, reparsedType, idempotent } = serializeParagraph(text)
expect(md.trim()).toBe(expectedMd)
expect(reparsedType).toBe('paragraph')
expect(idempotent).toBe(true)
}
)
})
describe('consecutive empty paragraphs', () => {
/** Doc with `a`, then `count` empty paragraphs, then `b`; serialized and round-tripped. */
function serializeEmpties(count: number) {
editor = new Editor({ extensions: createMarkdownContentExtensions() })
const emptyParas = Array.from({ length: count }, () => ({ type: 'paragraph', content: [] }))
editor.commands.setContent(
{
type: 'doc',
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'a' }] },
...emptyParas,
{ type: 'paragraph', content: [{ type: 'text', text: 'b' }] },
],
},
{ contentType: 'json' }
)
const md = postProcessSerializedMarkdown(editor.getMarkdown())
editor.commands.setContent(md, { contentType: 'markdown' })
const emptyCount = (editor.getJSON().content ?? []).filter(
(n) => n.type === 'paragraph' && !n.content?.length
).length
const idempotent = postProcessSerializedMarkdown(editor.getMarkdown()) === md
editor.destroy()
editor = null
return { md, emptyCount, idempotent }
}
it.each([[1], [2], [3], [4]])(
'preserves %i empty paragraph(s) via blank lines (no &nbsp;, idempotent, no read-only trigger)',
(count) => {
const { md, emptyCount, idempotent } = serializeEmpties(count)
expect(md).not.toContain('&nbsp;')
expect(md).not.toContain(String.fromCharCode(0x00a0))
expect(emptyCount).toBe(count)
expect(idempotent).toBe(true)
}
)
})
describe('highlight ==mark==', () => {
function markPresent(src: string): boolean {
editor = new Editor({ extensions: createMarkdownContentExtensions() })
editor.commands.setContent(src, { contentType: 'markdown' })
const has = JSON.stringify(editor.getJSON()).includes('"type":"highlight"')
editor.destroy()
editor = null
return has
}
it('parses ==text== into a highlight mark, including mid-line and in headings', () => {
expect(markPresent('a ==marked== word')).toBe(true)
expect(markPresent('# a ==mark== b')).toBe(true)
expect(markPresent('**bold ==mark== here**')).toBe(true)
})
it('parses a highlight body containing a lone `=` (so ==a=b== round-trips)', () => {
expect(markPresent('x ==a=b== y')).toBe(true)
})
it('strips a highlight whose text contains `==` (unrepresentable), keeping the text', () => {
editor = new Editor({ extensions: createMarkdownContentExtensions() })
editor.commands.setContent('x a==b y', { contentType: 'markdown' })
let from = -1
let to = -1
editor.state.doc.descendants((node, pos) => {
if (node.isText) {
const i = node.text?.indexOf('a==b') ?? -1
if (i >= 0) {
from = pos + i
to = from + 4
}
}
})
editor.commands.setTextSelection({ from, to })
editor.commands.toggleMark('highlight')
const md = postProcessSerializedMarkdown(editor.getMarkdown())
expect(JSON.stringify(editor.getJSON())).not.toContain('"type":"highlight"')
expect(md).not.toContain('==a==b==')
expect(editor.getText().trim()).toBe('x a==b y')
editor.destroy()
editor = null
})
it('leaves comparison / spaced == operators as literal text', () => {
expect(markPresent('if x == y then z')).toBe(false)
expect(markPresent('a == b == c')).toBe(false)
})
})
describe('autolink / bare-URL preservation', () => {
it('keeps a bare URL bare instead of rewriting it to [url](url)', () => {
expect(roundTrip('visit https://sim.ai today').trim()).toBe('visit https://sim.ai today')
expect(roundTrip('both https://a.com and https://b.com').trim()).toBe(
'both https://a.com and https://b.com'
)
})
it('collapses an angle autolink and a bare email to their bare form', () => {
expect(roundTrip('see <https://sim.ai> here').trim()).toBe('see https://sim.ai here')
expect(roundTrip('mail <a@b.com> now').trim()).toBe('mail a@b.com now')
})
it('preserves explicit and titled links (only bare autolinks collapse)', () => {
expect(roundTrip('[Sim](https://sim.ai)').trim()).toBe('[Sim](https://sim.ai)')
expect(roundTrip('[https://a.com](https://b.com)').trim()).toBe(
'[https://a.com](https://b.com)'
)
expect(roundTrip('[text](https://x.com "t")').trim()).toBe('[text](https://x.com "t")')
})
it('never collapses a URL that only appears inside code', () => {
expect(roundTrip('```\nvisit https://sim.ai\n```').trim()).toBe(
'```\nvisit https://sim.ai\n```'
)
expect(roundTrip('inline `https://sim.ai` code').trim()).toBe('inline `https://sim.ai` code')
})
it('round-trips a bare URL idempotently', () => {
const once = roundTrip('go to https://sim.ai now')
expect(roundTrip(once)).toBe(once)
})
})
@@ -0,0 +1,95 @@
/**
* @vitest-environment node
*/
import type { Editor, Range } from '@tiptap/core'
import { describe, expect, it, vi } from 'vitest'
import { filterSlashCommands, SLASH_COMMANDS } from './commands'
describe('filterSlashCommands', () => {
it('returns a copy of all commands for an empty query', () => {
const all = filterSlashCommands('')
expect(all).toHaveLength(SLASH_COMMANDS.length)
expect(all).not.toBe(SLASH_COMMANDS)
})
it('matches on title case-insensitively', () => {
expect(filterSlashCommands('HEAD').map((c) => c.title)).toEqual([
'Heading 1',
'Heading 2',
'Heading 3',
])
})
it('matches on alias', () => {
expect(filterSlashCommands('todo').map((c) => c.title)).toContain('Checklist')
expect(filterSlashCommands('hr').map((c) => c.title)).toContain('Divider')
})
it('trims whitespace in the query', () => {
expect(filterSlashCommands(' table ').map((c) => c.title)).toEqual(['Table'])
})
it('returns empty for no match', () => {
expect(filterSlashCommands('zzz')).toEqual([])
})
it('drops the Image command when image insertion is disallowed', () => {
expect(filterSlashCommands('', { allowImages: false }).map((c) => c.title)).not.toContain(
'Image'
)
expect(filterSlashCommands('image', { allowImages: false })).toEqual([])
expect(filterSlashCommands('image', { allowImages: true }).map((c) => c.title)).toContain(
'Image'
)
})
})
describe('SLASH_COMMANDS registry', () => {
it('every command has the required fields', () => {
for (const command of SLASH_COMMANDS) {
expect(command.title).toBeTruthy()
expect(command.group).toBeTruthy()
expect(command.icon).toBeTruthy()
expect(Array.isArray(command.aliases)).toBe(true)
expect(typeof command.run).toBe('function')
}
})
it('has unique titles (stable React keys)', () => {
const titles = SLASH_COMMANDS.map((c) => c.title)
expect(new Set(titles).size).toBe(titles.length)
})
it('Image command replaces the trigger and hands the caret to the host insertImage handler', () => {
const insertImage = vi.fn()
const deleteRange = vi.fn(() => chain)
const chain = { focus: () => chain, deleteRange, run: () => true }
const editor = {
chain: () => chain,
storage: { slashCommand: { insertImage } },
state: { selection: { from: 7 } },
} as unknown as Editor
const image = SLASH_COMMANDS.find((c) => c.title === 'Image')
expect(image).toBeDefined()
image?.run({ editor, range: { from: 5, to: 6 } as Range })
expect(deleteRange).toHaveBeenCalledWith({ from: 5, to: 6 })
expect(insertImage).toHaveBeenCalledWith(7)
})
it('Image command is a no-op when no handler is wired', () => {
const chain = { focus: () => chain, deleteRange: () => chain, run: () => true }
const editor = {
chain: () => chain,
storage: { slashCommand: { insertImage: null } },
state: { selection: { from: 0 } },
} as unknown as Editor
expect(() =>
SLASH_COMMANDS.find((c) => c.title === 'Image')?.run({
editor,
range: { from: 0, to: 1 } as Range,
})
).not.toThrow()
})
})
@@ -0,0 +1,173 @@
import type { Editor, Range } from '@tiptap/core'
import {
Code2,
Heading1,
Heading2,
Heading3,
Image as ImageIcon,
List,
ListChecks,
ListOrdered,
type LucideIcon,
Minus,
Pilcrow,
Table as TableIcon,
TextQuote,
} from 'lucide-react'
export interface SlashCommandContext {
editor: Editor
range: Range
}
/**
* Per-editor storage on the `slashCommand` extension. The host editor component sets `insertImage`
* after mount; it opens an image file picker and uploads + inserts the chosen image(s) at `at`. Null
* in headless/read-only contexts, where the Image command is a no-op.
*/
export interface SlashCommandStorage {
insertImage: ((at: number) => void) | null
}
export interface SlashCommandItem {
title: string
/** Group heading the item is shown under in the menu. */
group: string
icon: LucideIcon
/** Extra search terms matched against the slash query, beyond the title. */
aliases: string[]
/** Keyboard shortcut shown on the right of the item (omitted when there is none). */
shortcut?: string
run: (ctx: SlashCommandContext) => void
}
/**
* The blocks insertable via the `/` menu. Each `run` first deletes the typed `/query`
* (`deleteRange(range)`) so the command replaces the trigger text rather than appending.
* Kept to blocks that round-trip cleanly through markdown — no media/embeds.
*/
export const SLASH_COMMANDS: readonly SlashCommandItem[] = [
{
title: 'Text',
group: 'Basic',
icon: Pilcrow,
aliases: ['paragraph', 'body'],
run: ({ editor, range }) => editor.chain().focus().deleteRange(range).setParagraph().run(),
},
{
title: 'Heading 1',
group: 'Basic',
icon: Heading1,
aliases: ['h1', 'title'],
shortcut: '⌘⌥1',
run: ({ editor, range }) =>
editor.chain().focus().deleteRange(range).setHeading({ level: 1 }).run(),
},
{
title: 'Heading 2',
group: 'Basic',
icon: Heading2,
aliases: ['h2', 'subtitle'],
shortcut: '⌘⌥2',
run: ({ editor, range }) =>
editor.chain().focus().deleteRange(range).setHeading({ level: 2 }).run(),
},
{
title: 'Heading 3',
group: 'Basic',
icon: Heading3,
aliases: ['h3'],
shortcut: '⌘⌥3',
run: ({ editor, range }) =>
editor.chain().focus().deleteRange(range).setHeading({ level: 3 }).run(),
},
{
title: 'Bulleted list',
group: 'Lists',
icon: List,
aliases: ['unordered', 'ul', 'bullet'],
shortcut: '⌘⇧8',
run: ({ editor, range }) => editor.chain().focus().deleteRange(range).toggleBulletList().run(),
},
{
title: 'Numbered list',
group: 'Lists',
icon: ListOrdered,
aliases: ['ordered', 'ol'],
shortcut: '⌘⇧7',
run: ({ editor, range }) => editor.chain().focus().deleteRange(range).toggleOrderedList().run(),
},
{
title: 'Checklist',
group: 'Lists',
icon: ListChecks,
aliases: ['todo', 'task', 'checkbox'],
shortcut: '⌘⇧9',
run: ({ editor, range }) => editor.chain().focus().deleteRange(range).toggleTaskList().run(),
},
{
title: 'Quote',
group: 'Blocks',
icon: TextQuote,
aliases: ['blockquote', 'citation'],
shortcut: '⌘⇧B',
run: ({ editor, range }) => editor.chain().focus().deleteRange(range).toggleBlockquote().run(),
},
{
title: 'Code block',
group: 'Blocks',
icon: Code2,
aliases: ['codeblock', 'snippet', 'fence'],
shortcut: '⌘⌥C',
run: ({ editor, range }) => editor.chain().focus().deleteRange(range).toggleCodeBlock().run(),
},
{
title: 'Table',
group: 'Blocks',
icon: TableIcon,
aliases: ['grid', 'rows', 'columns'],
run: ({ editor, range }) =>
editor
.chain()
.focus()
.deleteRange(range)
.insertTable({ rows: 3, cols: 3, withHeaderRow: true })
.run(),
},
{
title: 'Divider',
group: 'Blocks',
icon: Minus,
aliases: ['hr', 'horizontal rule', 'separator'],
run: ({ editor, range }) => editor.chain().focus().deleteRange(range).setHorizontalRule().run(),
},
{
title: 'Image',
group: 'Media',
icon: ImageIcon,
aliases: ['picture', 'photo', 'upload', 'img'],
run: ({ editor, range }) => {
editor.chain().focus().deleteRange(range).run()
editor.storage.slashCommand.insertImage?.(editor.state.selection.from)
},
},
]
/**
* Filters commands by a case-insensitive match against title or aliases. Order is preserved so the
* menu stays stable as the query narrows. The Image command is dropped when image insertion isn't
* available (`allowImages: false`) — e.g. the modal field editors, which have no upload affordance.
*/
export function filterSlashCommands(
query: string,
options?: { allowImages?: boolean }
): SlashCommandItem[] {
const allowImages = options?.allowImages ?? true
const available = allowImages ? SLASH_COMMANDS : SLASH_COMMANDS.filter((c) => c.title !== 'Image')
const q = query.trim().toLowerCase()
if (!q) return [...available]
return available.filter(
(command) =>
command.title.toLowerCase().includes(q) || command.aliases.some((alias) => alias.includes(q))
)
}
@@ -0,0 +1,73 @@
import { forwardRef, useImperativeHandle, useMemo, useRef } from 'react'
import type { Editor } from '@tiptap/core'
import { SuggestionList } from '../menus/suggestion-list'
import {
type SuggestionKeyDownHandler,
useSuggestionKeyboard,
} from '../menus/use-suggestion-keyboard'
import type { SlashCommandItem } from './commands'
export type SlashCommandListHandle = SuggestionKeyDownHandler
interface SlashCommandListProps {
items: SlashCommandItem[]
command: (item: SlashCommandItem) => void
/** The editor, wired as the ARIA combobox while the menu is open. */
editor: Editor
}
/**
* The `/` command popup. Mirrors the Chat composer's skills menu — same item chrome,
* grouped headings, and arrow/enter keyboard navigation — so the two feel identical.
* Exposes an imperative `onKeyDown` driven by the TipTap suggestion plugin.
*/
export const SlashCommandList = forwardRef<SlashCommandListHandle, SlashCommandListProps>(
function SlashCommandList({ items, command, editor }, ref) {
const containerRef = useRef<HTMLDivElement>(null)
const { activeIndex, setActiveIndex, onKeyDown } = useSuggestionKeyboard(
items,
command,
containerRef
)
useImperativeHandle(ref, () => ({ onKeyDown }), [onKeyDown])
const groups = useMemo(() => {
const ordered: { group: string; items: { item: SlashCommandItem; index: number }[] }[] = []
items.forEach((item, index) => {
const bucket = ordered.find((g) => g.group === item.group)
if (bucket) bucket.items.push({ item, index })
else ordered.push({ group: item.group, items: [{ item, index }] })
})
return ordered
}, [items])
return (
<SuggestionList
editor={editor}
containerRef={containerRef}
groups={groups}
activeIndex={activeIndex}
setActiveIndex={setActiveIndex}
command={command}
ariaLabel='Commands'
idPrefix='slash-command'
emptyLabel='No results'
itemKey={(item) => item.title}
renderItem={(item) => {
const Icon = item.icon
return (
<>
<Icon />
<span>{item.title}</span>
{item.shortcut && (
<span className='ml-auto shrink-0 pl-4 text-[var(--text-subtle)] text-micro'>
{item.shortcut}
</span>
)}
</>
)
}}
/>
)
}
)
@@ -0,0 +1,75 @@
import { Extension } from '@tiptap/core'
import { PluginKey } from '@tiptap/pm/state'
import Suggestion from '@tiptap/suggestion'
import { createSuggestionPopupRenderer } from '../menus/suggestion-popup'
import {
filterSlashCommands,
type SlashCommandContext,
type SlashCommandItem,
type SlashCommandStorage,
} from './commands'
import { SlashCommandList } from './slash-command-list'
declare module '@tiptap/core' {
interface Storage {
slashCommand: SlashCommandStorage
}
}
/** Explicit key (distinct from the `@` mention's) so the keymap can detect an open menu. */
export const SLASH_COMMAND_PLUGIN_KEY = new PluginKey('slashCommand')
/**
* Adds the `/` slash-command menu to the editor. Typing `/` at the start of a block — or after
* whitespace — opens {@link SlashCommandList}; selecting an item runs its block transform. The Image
* command appears only where image upload is wired (the file viewer); modal field editors never set
* `insertImage`, so it stays hidden there.
*/
export const SlashCommand = Extension.create<Record<string, never>, SlashCommandStorage>({
name: 'slashCommand',
addStorage() {
return { insertImage: null }
},
addProseMirrorPlugins() {
return [
Suggestion<SlashCommandItem, SlashCommandItem>({
editor: this.editor,
pluginKey: SLASH_COMMAND_PLUGIN_KEY,
char: '/',
allowSpaces: false,
startOfLine: false,
allow: ({ editor, range }) => {
if (
editor.isActive('codeBlock') ||
editor.isActive('table') ||
editor.isActive('link') ||
editor.isActive('code')
) {
return false
}
const $from = editor.state.doc.resolve(range.from)
if ($from.parentOffset === 0) return true
return /\s/.test($from.parent.textBetween($from.parentOffset - 1, $from.parentOffset))
},
items: ({ editor, query }) =>
filterSlashCommands(query, {
allowImages: editor.storage.slashCommand.insertImage != null,
}),
command: ({ editor, range, props }) => {
const ctx: SlashCommandContext = { editor, range }
props.run(ctx)
},
render: createSuggestionPopupRenderer({
component: SlashCommandList,
mapProps: (props) => ({
items: props.items as SlashCommandItem[],
command: props.command,
editor: props.editor,
}),
}),
}),
]
},
})
@@ -0,0 +1,57 @@
/**
* @vitest-environment jsdom
*
* A select-all (`AllSelection`) maps through a whole-document `setContent` replace as "select
* everything in the new doc" — a real, non-empty range. If that range happens to sweep a divider or
* image, `keymap.ts`'s `richLeafSelectionHighlight` decoration paints it with `rich-leaf-in-selection`
* (a thick highlight, see `rich-markdown-editor.css`), and nothing collapses it afterward. This is
* exactly what `rich-markdown-editor.tsx`'s stream-settle handler works around by calling
* `editor.commands.setTextSelection(editor.state.doc.content.size)` after every settle-time
* `setContent` — this test locks in that the fix actually collapses the selection.
*/
import { Editor } from '@tiptap/core'
import { afterEach, describe, expect, it } from 'vitest'
import { createMarkdownContentExtensions } from './extensions'
import { parseMarkdownToDoc } from './markdown-parse'
let editor: Editor | null = null
afterEach(() => {
editor?.destroy()
editor = null
})
describe('stream-settle selection collapse', () => {
it('a select-all survives a whole-document setContent as a non-empty range', () => {
editor = new Editor({
extensions: createMarkdownContentExtensions(),
content: '# Title\n\n---\n\nbody',
contentType: 'markdown',
})
editor.commands.selectAll()
expect(editor.state.selection.empty).toBe(false)
editor.commands.setContent(parseMarkdownToDoc('# New title\n\n---\n\nnew body'), {
contentType: 'json',
emitUpdate: false,
})
// The bug: without an explicit selection reset, the mapped select-all still spans the new doc.
expect(editor.state.selection.empty).toBe(false)
})
it('setTextSelection(doc size) after setContent collapses the selection (the fix)', () => {
editor = new Editor({
extensions: createMarkdownContentExtensions(),
content: '# Title\n\n---\n\nbody',
contentType: 'markdown',
})
editor.commands.selectAll()
editor.commands.setContent(parseMarkdownToDoc('# New title\n\n---\n\nnew body'), {
contentType: 'json',
emitUpdate: false,
})
editor.commands.setTextSelection(editor.state.doc.content.size)
expect(editor.state.selection.empty).toBe(true)
})
})
@@ -0,0 +1,27 @@
import { useEffect, useState } from 'react'
import type { Editor } from '@tiptap/react'
/**
* Reactively tracks `editor.isEditable` for React node views.
*
* The editor runs with `shouldRerenderOnTransaction: false`, and `editor.setEditable()` updates the
* option + re-applies view state but does NOT change any node-view prop — so a node view that reads
* `editor.isEditable` once at render keeps a stale value after the editability toggles (e.g. an agent
* stream settling into the doc). That leaves images showing no drag/resize/selection affordances and
* code blocks stuck on their read-only label until the node happens to re-render. Subscribing to the
* editor's `transaction`/`update` events re-reads the flag so the node view stays in sync.
*/
export function useEditorEditable(editor: Editor): boolean {
const [editable, setEditable] = useState(editor.isEditable)
useEffect(() => {
const sync = () => setEditable(editor.isEditable)
sync()
editor.on('transaction', sync)
editor.on('update', sync)
return () => {
editor.off('transaction', sync)
editor.off('update', sync)
}
}, [editor])
return editable
}
@@ -0,0 +1,506 @@
/**
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import {
INITIAL_TEXT_EDITOR_CONTENT_STATE,
syncTextEditorContentState,
type TextEditorContentState,
textEditorContentReducer,
} from './text-editor-state'
function ready(content: string, savedContent = content): TextEditorContentState {
return { phase: 'ready', content, savedContent, lastStreamedContent: null, hasBaseline: true }
}
function streaming(
content: string,
lastStreamedContent: string,
savedContent = '',
hasBaseline = true
): TextEditorContentState {
return { phase: 'streaming', content, savedContent, lastStreamedContent, hasBaseline }
}
function reconciling(
content: string,
savedContent = '',
hasBaseline = true
): TextEditorContentState {
return { phase: 'reconciling', content, savedContent, lastStreamedContent: null, hasBaseline }
}
describe("reducer 'edit' action", () => {
it('updates content when phase is ready and content differs', () => {
const state = ready('old')
const next = textEditorContentReducer(state, { type: 'edit', content: 'new' })
expect(next.content).toBe('new')
expect(next.savedContent).toBe('old')
expect(next.phase).toBe('ready')
})
it('returns same reference when content is unchanged', () => {
const state = ready('same')
const next = textEditorContentReducer(state, { type: 'edit', content: 'same' })
expect(next).toBe(state)
})
it('ignores edit when phase is streaming', () => {
const state = streaming('streamed', 'streamed')
const next = textEditorContentReducer(state, { type: 'edit', content: 'edited' })
expect(next).toBe(state)
})
it('ignores edit when phase is reconciling', () => {
const state = reconciling('current')
const next = textEditorContentReducer(state, { type: 'edit', content: 'edited' })
expect(next).toBe(state)
})
it('ignores edit when phase is uninitialized', () => {
const next = textEditorContentReducer(INITIAL_TEXT_EDITOR_CONTENT_STATE, {
type: 'edit',
content: 'anything',
})
expect(next).toBe(INITIAL_TEXT_EDITOR_CONTENT_STATE)
})
})
describe("reducer 'save-success' action", () => {
it('marks content as saved', () => {
const state = ready('new content', 'old saved')
const next = textEditorContentReducer(state, { type: 'save-success', content: 'new content' })
expect(next.savedContent).toBe('new content')
expect(next.content).toBe('new content')
expect(next.phase).toBe('ready')
expect(next.lastStreamedContent).toBeNull()
})
it('returns same reference when already clean', () => {
const state = ready('x')
const next = textEditorContentReducer(state, { type: 'save-success', content: 'x' })
expect(next).toBe(state)
})
it('clears lastStreamedContent after save', () => {
const state = streaming('content', 'content')
const next = textEditorContentReducer(state, { type: 'save-success', content: 'content' })
expect(next.lastStreamedContent).toBeNull()
expect(next.phase).toBe('ready')
})
it('does not revert a keystroke typed while the save was in flight', () => {
const state = ready('ABC', 'old')
const next = textEditorContentReducer(state, { type: 'save-success', content: 'AB' })
expect(next.content).toBe('ABC')
expect(next.savedContent).toBe('AB')
expect(next.content === next.savedContent).toBe(false)
})
})
describe('syncTextEditorContentState — initialization', () => {
it('transitions from uninitialized to ready when fetchedContent arrives', () => {
const next = syncTextEditorContentState(INITIAL_TEXT_EDITOR_CONTENT_STATE, {
canReconcileToFetchedContent: true,
fetchedContent: 'loaded',
streamingContent: undefined,
})
expect(next.phase).toBe('ready')
expect(next.content).toBe('loaded')
expect(next.savedContent).toBe('loaded')
})
it('stays uninitialized when fetchedContent is undefined', () => {
const next = syncTextEditorContentState(INITIAL_TEXT_EDITOR_CONTENT_STATE, {
canReconcileToFetchedContent: true,
fetchedContent: undefined,
streamingContent: undefined,
})
expect(next).toBe(INITIAL_TEXT_EDITOR_CONTENT_STATE)
})
})
describe('syncTextEditorContentState — static fetch updates', () => {
it('does not update content if fetchedContent matches savedContent', () => {
const state = ready('v1')
const next = syncTextEditorContentState(state, {
canReconcileToFetchedContent: true,
fetchedContent: 'v1',
streamingContent: undefined,
})
expect(next).toBe(state)
})
it('updates content when fetched advances and no local edits', () => {
const state = ready('v1')
const next = syncTextEditorContentState(state, {
canReconcileToFetchedContent: true,
fetchedContent: 'v2',
streamingContent: undefined,
})
expect(next.content).toBe('v2')
expect(next.savedContent).toBe('v2')
})
it('preserves local edits when fetchedContent advances but user has changes', () => {
// User edited to 'user edit', but savedContent was 'v1' and fetched is 'v2'
const state: TextEditorContentState = {
phase: 'ready',
content: 'user edit',
savedContent: 'v1',
lastStreamedContent: null,
hasBaseline: true,
}
const next = syncTextEditorContentState(state, {
canReconcileToFetchedContent: true,
fetchedContent: 'v2',
streamingContent: undefined,
})
// Local edits take precedence — content should remain 'user edit'
expect(next.content).toBe('user edit')
expect(next.phase).toBe('ready')
})
})
describe('syncTextEditorContentState — streaming', () => {
it('enters streaming phase when streamingContent arrives (replace mode)', () => {
const state = ready('existing')
const next = syncTextEditorContentState(state, {
canReconcileToFetchedContent: false,
fetchedContent: 'existing',
streamingContent: 'streamed chunk',
})
expect(next.phase).toBe('streaming')
expect(next.content).toBe('streamed chunk')
expect(next.lastStreamedContent).toBe('streamed chunk')
})
it('returns same reference when streaming state is already current', () => {
const state = streaming('addition', 'addition', 'base')
const next = syncTextEditorContentState(state, {
canReconcileToFetchedContent: false,
fetchedContent: 'base',
streamingContent: 'addition',
})
expect(next).toBe(state)
})
it('finalizes to ready when fetched matches lastStreamedContent', () => {
const state = streaming('base\nchunk', 'base\nchunk', '')
const next = syncTextEditorContentState(state, {
canReconcileToFetchedContent: true,
fetchedContent: 'base\nchunk',
streamingContent: undefined,
})
expect(next.phase).toBe('ready')
expect(next.content).toBe('base\nchunk')
expect(next.savedContent).toBe('base\nchunk')
expect(next.lastStreamedContent).toBeNull()
})
it('moves to reconciling when streaming ends but fetched has not caught up', () => {
const state = streaming('streamed', 'streamed', '')
const next = syncTextEditorContentState(state, {
canReconcileToFetchedContent: true,
fetchedContent: undefined,
streamingContent: undefined,
})
expect(next.phase).toBe('reconciling')
})
it('finalizes immediately when streaming ends and canReconcile is false', () => {
const state = streaming('streamed', 'streamed', '')
const next = syncTextEditorContentState(state, {
canReconcileToFetchedContent: false,
fetchedContent: undefined,
streamingContent: undefined,
})
expect(next.phase).toBe('ready')
expect(next.content).toBe('streamed')
})
})
describe('syncTextEditorContentState — reconciling', () => {
it('stays reconciling when fetchedContent has not advanced', () => {
const state = reconciling('streamed', '')
const next = syncTextEditorContentState(state, {
canReconcileToFetchedContent: true,
fetchedContent: '',
streamingContent: undefined,
})
expect(next.phase).toBe('reconciling')
})
it('returns same reconciling reference when already reconciling', () => {
const state = reconciling('x')
const next = syncTextEditorContentState(state, {
canReconcileToFetchedContent: true,
fetchedContent: undefined,
streamingContent: undefined,
})
expect(next).toBe(state)
})
it('finalizes when fetchedContent has advanced during reconciling', () => {
const state: TextEditorContentState = {
phase: 'reconciling',
content: 'streamed',
savedContent: 'v1',
lastStreamedContent: null,
hasBaseline: true,
}
const next = syncTextEditorContentState(state, {
canReconcileToFetchedContent: true,
fetchedContent: 'v2',
streamingContent: undefined,
})
expect(next.phase).toBe('ready')
expect(next.content).toBe('v2')
})
it('finalizes immediately when canReconcile is false', () => {
const state = reconciling('streamed')
const next = syncTextEditorContentState(state, {
canReconcileToFetchedContent: false,
fetchedContent: 'v99',
streamingContent: undefined,
})
expect(next.phase).toBe('ready')
expect(next.content).toBe('streamed')
})
})
describe('syncTextEditorContentState — streaming finalize shortcuts', () => {
it('finalizes immediately when fetched already equals resolved streaming content', () => {
// ready + user hasn't edited + fetched === what streaming would produce
const state = ready('v1')
const next = syncTextEditorContentState(state, {
canReconcileToFetchedContent: false,
fetchedContent: 'v2',
streamingContent: 'v2',
})
expect(next.phase).toBe('ready')
expect(next.content).toBe('v2')
})
it('finalizes from streaming when fetched has advanced beyond saved', () => {
const state = streaming('v1 chunk', 'v1 chunk', 'v1')
const next = syncTextEditorContentState(state, {
canReconcileToFetchedContent: true,
fetchedContent: 'v2',
streamingContent: 'chunk',
})
expect(next.phase).toBe('ready')
expect(next.content).toBe('v2')
})
})
describe('syncTextEditorContentState — inter-session content shrink (replace mode)', () => {
it('replaces long linger content with a short first chunk from a new session', () => {
const lingerState = streaming(
'a very long document with many paragraphs',
'a very long document with many paragraphs',
''
)
const next = syncTextEditorContentState(lingerState, {
canReconcileToFetchedContent: false,
fetchedContent: undefined,
streamingContent: 'short',
})
expect(next.phase).toBe('streaming')
expect(next.content).toBe('short')
expect(next.lastStreamedContent).toBe('short')
})
it('correctly transitions to the new chunk even when it is a single character', () => {
const lingerState = streaming(
'full document\nmany lines\nof content',
'full document\nmany lines\nof content',
''
)
const next = syncTextEditorContentState(lingerState, {
canReconcileToFetchedContent: false,
fetchedContent: undefined,
streamingContent: '#',
})
expect(next.phase).toBe('streaming')
expect(next.content).toBe('#')
})
it('does not finalize early when the new short chunk happens to equal savedContent', () => {
const lingerState = streaming('long content', 'long content', 'old saved')
const next = syncTextEditorContentState(lingerState, {
canReconcileToFetchedContent: false,
fetchedContent: undefined,
streamingContent: '',
})
expect(next.phase).toBe('streaming')
expect(next.content).toBe('')
})
it('stays streaming across multiple growing chunks after the shrink', () => {
const lingerState = streaming('final long document', 'final long document', '')
const chunk1 = syncTextEditorContentState(lingerState, {
canReconcileToFetchedContent: false,
fetchedContent: undefined,
streamingContent: '# New',
})
expect(chunk1.phase).toBe('streaming')
expect(chunk1.content).toBe('# New')
const chunk2 = syncTextEditorContentState(chunk1, {
canReconcileToFetchedContent: false,
fetchedContent: undefined,
streamingContent: '# New Section\n\nSome text',
})
expect(chunk2.phase).toBe('streaming')
expect(chunk2.content).toBe('# New Section\n\nSome text')
const chunk3 = syncTextEditorContentState(chunk2, {
canReconcileToFetchedContent: false,
fetchedContent: undefined,
streamingContent: '# New Section\n\nSome text that is now longer than the original',
})
expect(chunk3.phase).toBe('streaming')
expect(chunk3.content).toBe('# New Section\n\nSome text that is now longer than the original')
})
it('synthetic file (canReconcile=false) finalizes with current content when streaming ends', () => {
const finalChunk = streaming(
'# Complete Document\n\nAll done.',
'# Complete Document\n\nAll done.',
''
)
const next = syncTextEditorContentState(finalChunk, {
canReconcileToFetchedContent: false,
fetchedContent: undefined,
streamingContent: undefined,
})
expect(next.phase).toBe('ready')
expect(next.content).toBe('# Complete Document\n\nAll done.')
expect(next.savedContent).toBe('# Complete Document\n\nAll done.')
expect(next.lastStreamedContent).toBeNull()
})
})
/**
* The chat resource view (mothership) streams agent output into an existing, initially-empty file
* (the agent's `create_file` writes an empty buffer, then `edit_content` persists the real content
* server-side). The editor must never autosave during this handoff — a save would race the agent's
* server write and could clobber it with empty/stale content. The engine guarantees this by staying
* stream-locked (phase `streaming`/`reconciling`, where autosave is disabled) from the first chunk
* until fetched content reconciles to the agent's saved write — at which point `content` and
* `savedContent` both equal that write, so the now-enabled autosave sees a clean doc and never fires.
*/
describe('syncTextEditorContentState — mothership streamed-file lifecycle (replace mode)', () => {
const isStreamLocked = (s: TextEditorContentState) =>
s.phase === 'streaming' || s.phase === 'reconciling'
it('stays locked through streaming + reconcile, then finalizes to the agent write with no empty save', () => {
const opts = (fetchedContent: string | undefined, streamingContent: string | undefined) => ({
canReconcileToFetchedContent: true,
fetchedContent,
streamingContent,
})
// 1. Empty file (create_file wrote an empty buffer); first streamed chunk arrives.
let state = syncTextEditorContentState(
INITIAL_TEXT_EDITOR_CONTENT_STATE,
opts('', '# Story\n\nOnce')
)
expect(state.phase).toBe('streaming')
expect(isStreamLocked(state)).toBe(true)
// 2. More chunks stream in (replace mode → content tracks the latest snapshot).
state = syncTextEditorContentState(state, opts('', '# Story\n\nOnce upon a time'))
expect(state.content).toBe('# Story\n\nOnce upon a time')
expect(isStreamLocked(state)).toBe(true)
// 3. Stream completes (streamingContent cleared) but the agent's server write hasn't been
// refetched yet — must hold in reconciling (still locked, autosave still disabled).
state = syncTextEditorContentState(state, opts('', undefined))
expect(state.phase).toBe('reconciling')
expect(isStreamLocked(state)).toBe(true)
expect(state.savedContent).toBe('')
// 4. The agent's `edit_content` write lands in the refetched content → finalize to ready with
// content === savedContent === the agent write. Never an empty savedContent.
const agentWrite = '# Story\n\nOnce upon a time, the end.'
state = syncTextEditorContentState(state, opts(agentWrite, undefined))
expect(state.phase).toBe('ready')
expect(isStreamLocked(state)).toBe(false)
expect(state.content).toBe(agentWrite)
expect(state.savedContent).toBe(agentWrite)
expect(state.lastStreamedContent).toBeNull()
// 5. Now-enabled autosave compares content vs savedContent: equal → it never fires a save.
expect(state.content).toBe(state.savedContent)
})
})
/**
* When the user opens an existing, non-empty file's tab while the agent is already mid-stream on it,
* streaming begins from `uninitialized` before the content fetch resolves — so `savedContent` is the
* placeholder `''`. The first fetched value to arrive is the file's PRE-EDIT content, not the agent's
* write; it must be adopted as the baseline, never finalized to (which would flash stale content and,
* if the agent had stopped, let the user edit over the agent's write).
*/
describe('syncTextEditorContentState — stream begins before fetch on an existing file', () => {
it('adopts the first fetched content as the baseline instead of finalizing to it mid-stream', () => {
const preEdit = '# Original\n\nold content'
const agentWrite = '# Original\n\nold content, plus a new section.'
// 1. Editor mounts mid-stream: chunk arrives before the fetch resolves.
let state = syncTextEditorContentState(INITIAL_TEXT_EDITOR_CONTENT_STATE, {
canReconcileToFetchedContent: true,
fetchedContent: undefined,
streamingContent: '# Original\n\nold',
})
expect(state.phase).toBe('streaming')
expect(state.savedContent).toBe('')
expect(state.hasBaseline).toBe(false)
// 2. The fetch resolves to the file's pre-edit content WHILE streaming. Adopt it as the baseline;
// do NOT finalize (the agent hasn't persisted its write yet).
state = syncTextEditorContentState(state, {
canReconcileToFetchedContent: true,
fetchedContent: preEdit,
streamingContent: '# Original\n\nold content, plus',
})
expect(state.phase).toBe('streaming')
expect(state.content).toBe('# Original\n\nold content, plus')
expect(state.savedContent).toBe(preEdit)
expect(state.hasBaseline).toBe(true)
// 3. Stream ends; the refetch is still the pre-edit content → hold in reconciling, never finalize
// to stale (savedContent === fetched, so it has not "advanced").
state = syncTextEditorContentState(state, {
canReconcileToFetchedContent: true,
fetchedContent: preEdit,
streamingContent: undefined,
})
expect(state.phase).toBe('reconciling')
// 4. The agent's write lands (advanced past the adopted baseline) → finalize to it.
state = syncTextEditorContentState(state, {
canReconcileToFetchedContent: true,
fetchedContent: agentWrite,
streamingContent: undefined,
})
expect(state.phase).toBe('ready')
expect(state.content).toBe(agentWrite)
expect(state.savedContent).toBe(agentWrite)
})
it('still finalizes mid-stream once a real baseline is established (no regression)', () => {
// With hasBaseline=true, an advancing fetch finalizes immediately — the established-baseline path.
const next = syncTextEditorContentState(streaming('v1 chunk', 'v1 chunk', 'v1'), {
canReconcileToFetchedContent: true,
fetchedContent: 'v2',
streamingContent: 'chunk',
})
expect(next.phase).toBe('ready')
expect(next.content).toBe('v2')
})
})
@@ -0,0 +1,219 @@
export type TextEditorContentPhase = 'uninitialized' | 'ready' | 'streaming' | 'reconciling'
export interface TextEditorContentState {
phase: TextEditorContentPhase
content: string
savedContent: string
lastStreamedContent: string | null
/**
* Whether `savedContent` is the file's real baseline (not the initial placeholder). False only
* before the first fetched content has been observed — e.g. a stream that began before the initial
* fetch resolved. While false, a fetched value is treated as the baseline to adopt, not as the
* agent's write advancing past the baseline (which would finalize the editor to stale content).
*/
hasBaseline: boolean
}
export interface SyncTextEditorContentStateOptions {
canReconcileToFetchedContent: boolean
fetchedContent?: string
streamingContent?: string
}
export type TextEditorContentAction =
| ({ type: 'sync-external' } & SyncTextEditorContentStateOptions)
| { type: 'edit'; content: string }
| { type: 'save-success'; content: string }
export const INITIAL_TEXT_EDITOR_CONTENT_STATE: TextEditorContentState = {
phase: 'uninitialized',
content: '',
savedContent: '',
lastStreamedContent: null,
hasBaseline: false,
}
function finalizeTextEditorContentState(
state: TextEditorContentState,
nextContent: string
): TextEditorContentState {
if (
state.phase === 'ready' &&
state.content === nextContent &&
state.savedContent === nextContent &&
state.lastStreamedContent === null &&
state.hasBaseline
) {
return state
}
return {
phase: 'ready',
content: nextContent,
savedContent: nextContent,
lastStreamedContent: null,
hasBaseline: true,
}
}
function moveTextEditorContentStateToStreaming(
state: TextEditorContentState,
nextContent: string,
fetchedBaseline?: string
): TextEditorContentState {
// A stream that begins before the initial fetch resolves leaves `savedContent` at its placeholder.
// The first fetched value to arrive during the stream IS the file's pre-edit baseline (the agent
// hasn't persisted its write yet), so adopt it. Without this, a later refetch of that same pre-edit
// content would read as an "advance" past the placeholder and finalize the editor to stale content
// mid-stream. Empty-file creates are unaffected: their baseline genuinely is ''.
const adoptBaseline = !state.hasBaseline && fetchedBaseline !== undefined
const savedContent = adoptBaseline ? fetchedBaseline : state.savedContent
const hasBaseline = state.hasBaseline || adoptBaseline
if (
state.phase === 'streaming' &&
state.content === nextContent &&
state.lastStreamedContent === nextContent &&
state.savedContent === savedContent &&
state.hasBaseline === hasBaseline
) {
return state
}
return {
...state,
phase: 'streaming',
content: nextContent,
lastStreamedContent: nextContent,
savedContent,
hasBaseline,
}
}
function moveTextEditorContentStateToReconcile(
state: TextEditorContentState
): TextEditorContentState {
if (state.phase === 'reconciling') {
return state
}
return {
...state,
phase: 'reconciling',
}
}
export function syncTextEditorContentState(
state: TextEditorContentState,
options: SyncTextEditorContentStateOptions
): TextEditorContentState {
const { canReconcileToFetchedContent, fetchedContent, streamingContent } = options
if (streamingContent !== undefined) {
const nextContent = streamingContent
const fetchedMatchesNextContent = fetchedContent !== undefined && fetchedContent === nextContent
const fetchedMatchesLastStreamedContent =
fetchedContent !== undefined &&
state.lastStreamedContent !== null &&
fetchedContent === state.lastStreamedContent
// Only an ESTABLISHED baseline makes "fetched differs from savedContent" mean "the agent's write
// advanced". Before the baseline is established (stream started before the fetch resolved),
// savedContent is a placeholder, so the file's own pre-edit content would falsely read as an
// advance and finalize to stale content; instead it is adopted as the baseline in moveToStreaming.
const hasFetchedAdvanced =
fetchedContent !== undefined && state.hasBaseline && fetchedContent !== state.savedContent
if (
(state.phase === 'streaming' || state.phase === 'reconciling') &&
(hasFetchedAdvanced || fetchedMatchesLastStreamedContent || fetchedMatchesNextContent)
) {
return finalizeTextEditorContentState(state, fetchedContent)
}
if (
state.phase === 'ready' &&
state.content === state.savedContent &&
fetchedMatchesNextContent &&
fetchedContent !== undefined
) {
return finalizeTextEditorContentState(state, fetchedContent)
}
return moveTextEditorContentStateToStreaming(state, nextContent, fetchedContent)
}
if (state.phase === 'streaming' || state.phase === 'reconciling') {
if (!canReconcileToFetchedContent) {
return finalizeTextEditorContentState(state, state.content)
}
if (fetchedContent !== undefined) {
const hasFetchedAdvanced = fetchedContent !== state.savedContent
const fetchedMatchesLastStreamedContent =
state.lastStreamedContent !== null && fetchedContent === state.lastStreamedContent
if (hasFetchedAdvanced || fetchedMatchesLastStreamedContent) {
return finalizeTextEditorContentState(state, fetchedContent)
}
}
return moveTextEditorContentStateToReconcile(state)
}
if (fetchedContent === undefined) {
return state
}
if (state.phase === 'uninitialized') {
return finalizeTextEditorContentState(state, fetchedContent)
}
if (fetchedContent === state.savedContent) {
return state
}
if (state.content === state.savedContent) {
return finalizeTextEditorContentState(state, fetchedContent)
}
return state
}
export function textEditorContentReducer(
state: TextEditorContentState,
action: TextEditorContentAction
): TextEditorContentState {
switch (action.type) {
case 'sync-external':
return syncTextEditorContentState(state, action)
case 'edit':
if (state.phase !== 'ready' || action.content === state.content) {
return state
}
return {
...state,
content: action.content,
}
case 'save-success':
// Advance only the saved baseline. Never roll `content` back to the saved snapshot: a
// keystroke landing while the save was in flight makes `content` newer than `action.content`,
// and overwriting it would silently drop that edit (and leave the doc looking clean so it's
// never re-saved). Leaving `content` ahead keeps the doc dirty so the trailing edit autosaves.
if (
state.phase === 'ready' &&
state.savedContent === action.content &&
state.lastStreamedContent === null
) {
return state
}
return {
...state,
phase: 'ready',
savedContent: action.content,
lastStreamedContent: null,
hasBaseline: true,
}
default:
return state
}
}

Some files were not shown because too many files have changed in this diff Show More