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
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:
@@ -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'
|
||||
+57
@@ -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'
|
||||
+121
@@ -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])
|
||||
}
|
||||
+49
@@ -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>
|
||||
)
|
||||
})
|
||||
+103
@@ -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>
|
||||
)
|
||||
}
|
||||
+233
@@ -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'
|
||||
+63
@@ -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)
|
||||
})
|
||||
})
|
||||
Binary file not shown.
@@ -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>
|
||||
)
|
||||
})
|
||||
+219
@@ -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' />
|
||||
</>
|
||||
)
|
||||
}
|
||||
+46
@@ -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 })
|
||||
}
|
||||
+94
@@ -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(),
|
||||
}
|
||||
},
|
||||
})
|
||||
+264
@@ -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)
|
||||
},
|
||||
})
|
||||
+81
@@ -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)
|
||||
})
|
||||
})
|
||||
+133
@@ -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)
|
||||
},
|
||||
},
|
||||
}),
|
||||
]
|
||||
},
|
||||
})
|
||||
+21
@@ -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()
|
||||
}
|
||||
})
|
||||
})
|
||||
+38
@@ -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')
|
||||
})
|
||||
})
|
||||
+63
@@ -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
|
||||
}
|
||||
+106
@@ -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))
|
||||
})
|
||||
})
|
||||
+55
@@ -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)
|
||||
})
|
||||
})
|
||||
+53
@@ -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] : []),
|
||||
]
|
||||
}
|
||||
+62
@@ -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
|
||||
}
|
||||
+64
@@ -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()
|
||||
})
|
||||
})
|
||||
+88
@@ -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)
|
||||
},
|
||||
},
|
||||
}),
|
||||
]
|
||||
},
|
||||
})
|
||||
+151
@@ -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,
|
||||
]
|
||||
}
|
||||
+57
@@ -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)
|
||||
})
|
||||
})
|
||||
+36
@@ -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
|
||||
}
|
||||
+105
@@ -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
|
||||
},
|
||||
}),
|
||||
]
|
||||
},
|
||||
})
|
||||
+386
@@ -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)
|
||||
})
|
||||
})
|
||||
+218
@@ -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
|
||||
}
|
||||
+42
@@ -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()
|
||||
})
|
||||
})
|
||||
+325
@@ -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 `[](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, '&')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 ``. An image with an `href` is
|
||||
* wrapped in a markdown link so a linked badge round-trips as `[](href)`.
|
||||
*
|
||||
* A *sized **and** linked* image is the one case markdown can't represent: the linked-image tokenizer
|
||||
* only recognizes `[](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 `[](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 `` 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
|
||||
},
|
||||
},
|
||||
}),
|
||||
]
|
||||
},
|
||||
})
|
||||
+427
@@ -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()
|
||||
})
|
||||
})
|
||||
+268
@@ -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 {}
|
||||
},
|
||||
},
|
||||
}),
|
||||
]
|
||||
},
|
||||
})
|
||||
+70
@@ -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"')
|
||||
})
|
||||
})
|
||||
+45
@@ -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)
|
||||
},
|
||||
}),
|
||||
]
|
||||
},
|
||||
})
|
||||
+217
@@ -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)
|
||||
})
|
||||
})
|
||||
+121
@@ -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'
|
||||
)
|
||||
}
|
||||
+212
@@ -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',
|
||||
'\n\n[](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',
|
||||
() => '---',
|
||||
() => '',
|
||||
() => '[](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)
|
||||
})
|
||||
+158
@@ -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)))
|
||||
}
|
||||
+317
@@ -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, '[](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', '', '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"')
|
||||
})
|
||||
})
|
||||
+204
@@ -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)
|
||||
},
|
||||
},
|
||||
}),
|
||||
]
|
||||
},
|
||||
})
|
||||
+7
@@ -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'
|
||||
+81
@@ -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)]')
|
||||
})
|
||||
})
|
||||
+69
@@ -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)
|
||||
},
|
||||
})
|
||||
+17
@@ -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)
|
||||
})
|
||||
})
|
||||
+29
@@ -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
|
||||
}
|
||||
+166
@@ -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()
|
||||
})
|
||||
})
|
||||
+117
@@ -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>
|
||||
</>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
})
|
||||
+51
@@ -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()
|
||||
})
|
||||
})
|
||||
+132
@@ -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 ?? '') })
|
||||
)
|
||||
},
|
||||
}),
|
||||
]
|
||||
},
|
||||
})
|
||||
+32
@@ -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()
|
||||
},
|
||||
}
|
||||
}
|
||||
+89
@@ -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?.(),
|
||||
}),
|
||||
}),
|
||||
]
|
||||
},
|
||||
})
|
||||
+22
@@ -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()
|
||||
})
|
||||
})
|
||||
+38
@@ -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
|
||||
}
|
||||
}
|
||||
+29
@@ -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 }>
|
||||
}
|
||||
+41
@@ -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])
|
||||
}
|
||||
+113
@@ -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,
|
||||
])
|
||||
}
|
||||
+336
@@ -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>
|
||||
)
|
||||
}
|
||||
+53
@@ -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)]'
|
||||
/>
|
||||
)
|
||||
}
|
||||
+194
@@ -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
|
||||
)
|
||||
}
|
||||
+135
@@ -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>
|
||||
)
|
||||
}
|
||||
+23
@@ -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'
|
||||
+107
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
+131
@@ -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)
|
||||
})
|
||||
})
|
||||
+128
@@ -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>
|
||||
)
|
||||
}
|
||||
+49
@@ -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)]' />
|
||||
}
|
||||
+73
@@ -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 }
|
||||
}
|
||||
+17
@@ -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)
|
||||
}
|
||||
+125
@@ -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()
|
||||
})
|
||||
})
|
||||
+258
@@ -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('')
|
||||
})
|
||||
|
||||
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')
|
||||
})
|
||||
})
|
||||
+514
@@ -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)
|
||||
},
|
||||
})
|
||||
+452
@@ -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);
|
||||
}
|
||||
+590
@@ -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>
|
||||
)
|
||||
}
|
||||
+238
@@ -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} />
|
||||
}
|
||||
+293
@@ -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 & b < 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('[](https://e.com)')).toBe(true)
|
||||
expect(
|
||||
isRoundTripSafe('[](https://ci.example.com)')
|
||||
).toBe(true)
|
||||
expect(isRoundTripSafe('[](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')
|
||||
).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's here')).toBe(false)
|
||||
expect(isRoundTripSafe('© 2024')).toBe(false)
|
||||
expect(isRoundTripSafe('a b')).toBe(false)
|
||||
expect(isRoundTripSafe('a & b < c > d')).toBe(true)
|
||||
expect(isRoundTripSafe('AT&T and R&D')).toBe(true)
|
||||
expect(isRoundTripSafe('a & b')).toBe(false)
|
||||
expect(isRoundTripSafe('a < b > 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
|
||||
|
||||
[](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:
|
||||
|
||||

|
||||
|
||||
Use \`a & 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)
|
||||
})
|
||||
})
|
||||
+127
@@ -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 `[](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 `&`/`<`/`>` (e.g. `©`, `'`,
|
||||
* ` `, or the uppercase `&`) — 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 `&`/`<`/`>` 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 `[](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
|
||||
}
|
||||
}
|
||||
+537
@@ -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',
|
||||
'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('')
|
||||
expect(out).toContain('')
|
||||
})
|
||||
|
||||
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(
|
||||
'[](https://ci.example.com)'
|
||||
)
|
||||
expect(out).toContain(
|
||||
'[](https://ci.example.com)'
|
||||
)
|
||||
expect(roundTrip(out)).toBe(out)
|
||||
})
|
||||
|
||||
it('keeps a plain image plain (no spurious link wrapper)', () => {
|
||||
const out = roundTrip('')
|
||||
expect(out).not.toContain('](https://example.com/i.png)](')
|
||||
expect(out.trim()).toBe('')
|
||||
})
|
||||
|
||||
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('')).toContain('')
|
||||
})
|
||||
|
||||
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"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 , idempotent, no read-only trigger)',
|
||||
(count) => {
|
||||
const { md, emptyCount, idempotent } = serializeEmpties(count)
|
||||
expect(md).not.toContain(' ')
|
||||
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)
|
||||
})
|
||||
})
|
||||
+95
@@ -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()
|
||||
})
|
||||
})
|
||||
+173
@@ -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))
|
||||
)
|
||||
}
|
||||
+73
@@ -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>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
+75
@@ -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,
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
]
|
||||
},
|
||||
})
|
||||
+57
@@ -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)
|
||||
})
|
||||
})
|
||||
+27
@@ -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
|
||||
}
|
||||
+506
@@ -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')
|
||||
})
|
||||
})
|
||||
+219
@@ -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
Reference in New Issue
Block a user