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

This commit is contained in:
wehub-resource-sync
2026-07-13 13:20:55 +08:00
commit d25d482dc2
13754 changed files with 4996608 additions and 0 deletions
@@ -0,0 +1,15 @@
'use client'
import { type ErrorBoundaryProps, ErrorState } from '@/app/workspace/[workspaceId]/components'
export default function ChatError({ error, reset }: ErrorBoundaryProps) {
return (
<ErrorState
error={error}
reset={reset}
title='Failed to load chat'
description='Something went wrong while loading this chat. Please try again.'
loggerName='ChatError'
/>
)
}
@@ -0,0 +1,3 @@
export default function ChatLayout({ children }: { children: React.ReactNode }) {
return <div className='flex h-full flex-1 flex-col overflow-hidden'>{children}</div>
}
@@ -0,0 +1,30 @@
import { Suspense } from 'react'
import type { Metadata } from 'next'
import { getSession } from '@/lib/auth'
import { Home } from '@/app/workspace/[workspaceId]/home/home'
import { HomeFallback } from '@/app/workspace/[workspaceId]/home/home-fallback'
export const metadata: Metadata = {
title: 'Chat',
}
interface ChatPageProps {
params: Promise<{
workspaceId: string
chatId: string
}>
}
export default async function ChatPage({ params }: ChatPageProps) {
const [{ chatId }, session] = await Promise.all([params, getSession()])
return (
<Suspense fallback={<HomeFallback />}>
<Home
key={chatId}
chatId={chatId}
userName={session?.user?.name}
userId={session?.user?.id}
/>
</Suspense>
)
}
@@ -0,0 +1,455 @@
'use client'
import { type ComponentType, type KeyboardEvent, useEffect, useMemo, useRef, useState } from 'react'
import {
Badge,
ChipModal,
ChipModalBody,
ChipModalError,
ChipModalField,
ChipModalFooter,
ChipModalHeader,
InfoCard,
InfoCardItem,
InfoCardList,
} from '@sim/emcn'
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { useSession } from '@/lib/auth/auth-client'
import type { OAuthReturnContext } from '@/lib/credentials/client-state'
import { ADD_CONNECTOR_SEARCH_PARAM, writeOAuthReturnContext } from '@/lib/credentials/client-state'
import {
getProviderIdFromServiceId,
OAUTH_PROVIDERS,
type OAuthProvider,
parseProvider,
} from '@/lib/oauth'
import { getScopeDescription } from '@/lib/oauth/utils'
import { useCreateCredentialDraft, useWorkspaceCredentials } from '@/hooks/queries/credentials'
import { useConnectOAuthService } from '@/hooks/queries/oauth/oauth-connections'
const logger = createLogger('ConnectOAuthModal')
/** Server-enforced max for `WorkspaceCredential.displayName` — see `lib/api/contracts/credentials.ts`. */
const DISPLAY_NAME_MAX_LENGTH = 255
/**
* Reserved tail budget when truncating the username so the auto-numbering
* disambiguator (e.g. `" 9999"`) always fits within {@link DISPLAY_NAME_MAX_LENGTH}.
*/
const COLLISION_SUFFIX_RESERVATION = 5
/** Upper bound for the auto-numbering search — pathological if ever reached. */
const MAX_COLLISION_INDEX = 10000
const EMPTY_SCOPES: readonly string[] = []
type ServiceIcon = ComponentType<{ className?: string }>
/** Scopes hidden from the permissions list — always present on Google flows. */
function isHiddenScope(scope: string): boolean {
return scope.includes('userinfo.email') || scope.includes('userinfo.profile')
}
/**
* Default credential display name. Produces `"{Name}'s {Service}"` when the
* user's name is known, falling back to `"My {Service}"` otherwise. The
* username is truncated so the full string (including any auto-numbering
* disambiguator) stays within {@link DISPLAY_NAME_MAX_LENGTH}.
*
* When the base name collides with an existing credential in `takenNames`,
* `" 2"`, `" 3"`, ... are appended until an unused name is found. Comparison
* is case-insensitive to match the duplicate-detection used elsewhere in the
* modal.
*/
function defaultDisplayName(
userName: string | null | undefined,
serviceName: string,
takenNames: ReadonlySet<string>
): string {
const trimmed = userName?.trim()
let base: string
if (trimmed) {
const suffix = `'s ${serviceName}`
const nameBudget = Math.max(
0,
DISPLAY_NAME_MAX_LENGTH - suffix.length - COLLISION_SUFFIX_RESERVATION
)
const safeName = trimmed.length > nameBudget ? trimmed.slice(0, nameBudget) : trimmed
base = `${safeName}${suffix}`
} else {
base = `My ${serviceName}`
}
if (!takenNames.has(base.toLowerCase())) return base
for (let n = 2; n < MAX_COLLISION_INDEX; n++) {
const candidate = `${base} ${n}`
if (!takenNames.has(candidate.toLowerCase())) return candidate
}
return base
}
/**
* Resolves the display name + icon for an OAuth `provider`/`serviceId` pair,
* preferring the most specific service entry and falling back to the base
* provider config, then to the raw provider id. Used when the caller does not
* supply explicit `serviceName`/`serviceIcon`.
*/
function resolveService(
provider: OAuthProvider,
serviceId: string
): { providerName: string; ProviderIcon: ServiceIcon } {
const { baseProvider } = parseProvider(provider)
const baseProviderConfig = OAUTH_PROVIDERS[baseProvider]
let providerName = baseProviderConfig?.name || provider
let ProviderIcon: ServiceIcon = baseProviderConfig?.icon || (() => null)
if (baseProviderConfig) {
for (const [key, service] of Object.entries(baseProviderConfig.services)) {
if (key === serviceId || service.providerId === provider) {
providerName = service.name
ProviderIcon = service.icon
break
}
}
}
return { providerName, ProviderIcon }
}
interface ConnectOAuthModalBaseProps {
open: boolean
onOpenChange: (open: boolean) => void
/**
* Canonical provider id (e.g. `google-email`). When omitted it is derived
* from `serviceId`. Used for the credential draft and return context.
*/
providerId?: string
/**
* Optional explicit display name/icon. When omitted, both are resolved from
* `provider` + `serviceId`. The integrations catalog supplies these directly;
* workflow/KB callers rely on resolution.
*/
serviceName?: string
serviceIcon?: ServiceIcon
/** Used to resolve display metadata and the provider id when not supplied directly. */
provider?: OAuthProvider
serviceId?: string
}
/**
* Connect mode. Creates the credential draft and writes the origin-specific
* OAuth return context before handing off to the provider via
* {@link useConnectOAuthService}.
*/
type ConnectOAuthModalConnectProps = ConnectOAuthModalBaseProps & {
mode: 'connect'
workspaceId: string
requiredScopes: readonly string[]
} & (
| { origin: 'workflow'; workflowId: string }
| { origin: 'kb-connectors'; knowledgeBaseId: string; connectorType?: string }
| { origin: 'integrations' }
)
/**
* Reauthorize mode. Updates the scopes on an existing credential for
* `toolName`. `newScopes` are surfaced with a "New" badge. An optional
* `onConnect` override short-circuits the default provider hand-off.
*/
interface ConnectOAuthModalReauthorizeProps extends ConnectOAuthModalBaseProps {
mode: 'reauthorize'
toolName: string
requiredScopes?: readonly string[]
newScopes?: readonly string[]
onConnect?: () => Promise<void> | void
}
export type ConnectOAuthModalProps =
| ConnectOAuthModalConnectProps
| ConnectOAuthModalReauthorizeProps
/**
* Unified connect/reauthorize OAuth credential modal (ChipModal UI). Mounted by
* the integrations catalog, the workflow editor's credential selectors, and the
* knowledge-base connector flows. After the redirect lands back on
* `window.location.href`, the host page's OAuth return router consumes the
* context written here.
*/
export function ConnectOAuthModal(props: ConnectOAuthModalProps) {
const { open, onOpenChange, mode } = props
const isConnect = mode === 'connect'
const providerId = useMemo(
() => props.providerId ?? (props.serviceId ? getProviderIdFromServiceId(props.serviceId) : ''),
[props.providerId, props.serviceId]
)
const [displayName, setDisplayName] = useState('')
const [description, setDescription] = useState('')
const [validationError, setValidationError] = useState<string | null>(null)
const [submitError, setSubmitError] = useState<string | null>(null)
const { data: session } = useSession()
const userName = session?.user?.name
const { providerName, ProviderIcon } = useMemo(() => {
if (props.serviceName && props.serviceIcon) {
return { providerName: props.serviceName, ProviderIcon: props.serviceIcon }
}
const provider = (props.provider ?? providerId) as OAuthProvider
return resolveService(provider, props.serviceId ?? providerId)
}, [props.serviceName, props.serviceIcon, props.provider, props.serviceId, providerId])
const workspaceId = isConnect ? props.workspaceId : ''
const { data: credentials = [], isPending: credentialsLoading } = useWorkspaceCredentials({
workspaceId,
enabled: isConnect && Boolean(workspaceId) && open,
})
const createDraft = useCreateCredentialDraft()
const connectOAuthService = useConnectOAuthService()
/**
* Lowercased set of OAuth credential names already in the workspace. Drives
* both the prefill's auto-numbering and the inline duplicate-name error.
*/
const takenNames = useMemo(
() =>
new Set(
credentials
.filter((credential) => credential.type === 'oauth')
.map((credential) => credential.displayName.toLowerCase())
),
[credentials]
)
const requiredScopes = props.requiredScopes ?? EMPTY_SCOPES
const newScopes = !isConnect ? (props.newScopes ?? EMPTY_SCOPES) : EMPTY_SCOPES
const newScopesSet = useMemo(
() => new Set([...newScopes].filter((scope) => !isHiddenScope(scope))),
[newScopes]
)
const displayScopes = useMemo(() => {
const filtered = [...requiredScopes].filter((scope) => !isHiddenScope(scope))
if (isConnect) return filtered
return filtered.sort((a, b) => {
const aIsNew = newScopesSet.has(a)
const bIsNew = newScopesSet.has(b)
if (aIsNew && !bIsNew) return -1
if (!aIsNew && bIsNew) return 1
return 0
})
}, [isConnect, requiredScopes, newScopesSet])
/**
* Initialize the connect form once per open session, after credentials have
* loaded so auto-numbering can see them. The `prefilled` ref ensures session
* refetches or other prop churn while the modal is open won't overwrite the
* user's typed value.
*/
const prefilled = useRef(false)
useEffect(() => {
if (!open) {
prefilled.current = false
return
}
if (!isConnect || prefilled.current || credentialsLoading) return
prefilled.current = true
setDisplayName(defaultDisplayName(userName, providerName, takenNames))
setDescription('')
setValidationError(null)
setSubmitError(null)
}, [open, isConnect, credentialsLoading, userName, providerName, takenNames])
const existingCredential = useMemo(() => {
if (!isConnect) return null
const name = displayName.trim().toLowerCase()
if (!name || !takenNames.has(name)) return null
return (
credentials.find((row) => row.type === 'oauth' && row.displayName.toLowerCase() === name) ??
null
)
}, [isConnect, credentials, displayName, takenNames])
const handleClose = () => {
setSubmitError(null)
onOpenChange(false)
}
const handleConnect = async () => {
setValidationError(null)
setSubmitError(null)
try {
let connectorType: string | undefined
if (isConnect) {
const trimmed = displayName.trim()
if (!trimmed) {
setValidationError('Display name is required.')
return
}
await createDraft.mutateAsync({
workspaceId,
providerId,
displayName: trimmed,
description: description.trim() || undefined,
})
const preCount = credentials.filter(
(c) => c.type === 'oauth' && c.providerId === providerId
).length
const baseContext = {
displayName: trimmed,
providerId,
preCount,
workspaceId,
requestedAt: Date.now(),
}
let returnContext: OAuthReturnContext
if (props.origin === 'kb-connectors') {
connectorType = props.connectorType
returnContext = {
...baseContext,
origin: 'kb-connectors',
knowledgeBaseId: props.knowledgeBaseId,
connectorType: props.connectorType,
}
} else if (props.origin === 'workflow') {
returnContext = { ...baseContext, origin: 'workflow', workflowId: props.workflowId }
} else {
returnContext = { ...baseContext, origin: 'integrations' }
}
writeOAuthReturnContext(returnContext)
} else if (props.onConnect) {
await props.onConnect()
handleClose()
return
} else {
logger.info('Reauthorizing OAuth2', {
providerId,
requiredScopes,
hasNewScopes: newScopes.length > 0,
})
}
const callbackURL = new URL(window.location.href)
if (connectorType) {
callbackURL.searchParams.set(ADD_CONNECTOR_SEARCH_PARAM, connectorType)
}
await connectOAuthService.mutateAsync({
providerId,
callbackURL: callbackURL.toString(),
})
handleClose()
} catch (err: unknown) {
const message = getErrorMessage(err, 'Failed to start OAuth connection')
setSubmitError(message)
logger.error('Failed to connect OAuth service', err)
}
}
const isPending = (isConnect && createDraft.isPending) || connectOAuthService.isPending
const isDisabled = isConnect
? !displayName.trim() || isPending || Boolean(existingCredential)
: isPending
/**
* Submits the connect form on Enter, mirroring the Connect button's enabled
* state and excluding the multi-line description. Restores the keyboard
* affordance the pre-consolidation workflow modal provided.
*/
const handleBodyKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
if (event.key !== 'Enter' || !isConnect || isDisabled) return
if (event.target instanceof HTMLTextAreaElement) return
event.preventDefault()
void handleConnect()
}
const displayNameError =
validationError ??
(existingCredential
? `An integration named "${existingCredential.displayName}" already exists.`
: undefined)
const title = `Connect ${providerName}`
return (
<ChipModal open={open} onOpenChange={onOpenChange} srTitle={title}>
<ChipModalHeader icon={ProviderIcon} onClose={handleClose}>
{title}
</ChipModalHeader>
<ChipModalBody onKeyDown={handleBodyKeyDown}>
{!isConnect && (
<p className='text-[var(--text-tertiary)] text-caption'>
The "{props.toolName}" tool requires access to your account.
</p>
)}
{isConnect && (
<ChipModalField
type='input'
title='Display name'
value={displayName}
onChange={(value) => {
setDisplayName(value)
if (validationError) setValidationError(null)
}}
placeholder='Integration name'
autoComplete='off'
required
error={displayNameError}
/>
)}
{isConnect && (
<ChipModalField
type='textarea'
title='Description'
value={description}
onChange={setDescription}
placeholder='Optional description'
maxLength={500}
minHeight={80}
/>
)}
{displayScopes.length > 0 && (
<ChipModalField type='custom' title='Permissions requested'>
<InfoCard>
<InfoCardList>
{displayScopes.map((scope) => (
<InfoCardItem key={scope}>
<span className='flex items-center gap-2'>
{getScopeDescription(scope)}
{!isConnect && newScopesSet.has(scope) && (
<Badge variant='amber' size='sm'>
New
</Badge>
)}
</span>
</InfoCardItem>
))}
</InfoCardList>
</InfoCard>
</ChipModalField>
)}
<ChipModalError>{submitError}</ChipModalError>
</ChipModalBody>
<ChipModalFooter
onCancel={handleClose}
cancelDisabled={isPending}
primaryAction={{
label: isPending ? 'Connecting...' : 'Connect',
onClick: handleConnect,
disabled: isDisabled,
}}
/>
</ChipModal>
)
}
@@ -0,0 +1,4 @@
export {
ConnectOAuthModal,
type ConnectOAuthModalProps,
} from './connect-oauth-modal'
@@ -0,0 +1,39 @@
import type { ReactNode } from 'react'
import { cn } from '@sim/emcn'
interface ConversationListItemProps {
title: string
isActive?: boolean
isUnread?: boolean
className?: string
titleClassName?: string
statusIndicatorClassName?: string
actions?: ReactNode
}
export function ConversationListItem({
title,
isActive = false,
isUnread = false,
className,
titleClassName,
statusIndicatorClassName,
actions,
}: ConversationListItemProps) {
const showStatusDot = isActive || isUnread
return (
<div className={cn('flex w-full min-w-0 items-center gap-2', className)}>
<span className={cn('min-w-0 flex-1 truncate', titleClassName)}>{title}</span>
{showStatusDot && (
<span
aria-hidden='true'
className={cn('size-[6px] flex-shrink-0 rounded-full', statusIndicatorClassName)}
style={{
backgroundColor: isActive ? '#EAB308' : 'var(--brand-accent)',
}}
/>
)}
{actions && <div className='ml-auto flex flex-shrink-0 items-center'>{actions}</div>}
</div>
)
}
@@ -0,0 +1 @@
export { ConversationListItem } from './conversation-list-item'
@@ -0,0 +1,170 @@
'use client'
import { useCallback, useMemo, useState } from 'react'
import {
ChipModal,
ChipModalBody,
ChipModalError,
ChipModalField,
ChipModalFooter,
ChipModalHeader,
} from '@sim/emcn'
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { useWorkspacePermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import {
useUpsertWorkspaceCredentialMember,
useWorkspaceCredentialMembers,
type WorkspaceCredentialRole,
} from '@/hooks/queries/credentials'
import { ROLE_OPTIONS } from '../roles'
import { partitionSettledFailures, resolveAddEmail } from '../sharing'
const logger = createLogger('AddPeopleModal')
interface AddPeopleModalProps {
credentialId: string
open: boolean
onOpenChange: (open: boolean) => void
}
/**
* Shared "Add people" modal: grants existing workspace members access to a
* credential with a chosen role. Emails are validated against the workspace
* roster and current membership; each add is an idempotent upsert and partial
* failures keep only the people that still need adding.
*/
export function AddPeopleModal({ credentialId, open, onOpenChange }: AddPeopleModalProps) {
const { workspacePermissions } = useWorkspacePermissionsContext()
const { data: members = [] } = useWorkspaceCredentialMembers(credentialId)
const upsertMember = useUpsertWorkspaceCredentialMember()
const [emailsToAdd, setEmailsToAdd] = useState<string[]>([])
const [roleToAdd, setRoleToAdd] = useState<WorkspaceCredentialRole>('member')
const [isAdding, setIsAdding] = useState(false)
const [submitError, setSubmitError] = useState<string | null>(null)
const workspaceUserIdByEmail = useMemo(
() =>
new Map(
(workspacePermissions?.users ?? []).map((user) => [user.email.toLowerCase(), user.userId])
),
[workspacePermissions?.users]
)
const existingMemberEmails = useMemo(
() =>
new Set(
members
.filter((member) => member.status === 'active')
.map((member) => (member.userEmail ?? '').toLowerCase())
.filter(Boolean)
),
[members]
)
const validateAddEmail = useCallback(
(email: string): string | null => {
const result = resolveAddEmail(email, { workspaceUserIdByEmail, existingMemberEmails })
return 'error' in result ? result.error : null
},
[workspaceUserIdByEmail, existingMemberEmails]
)
const handleClose = useCallback(() => {
setEmailsToAdd([])
setRoleToAdd('member')
setSubmitError(null)
onOpenChange(false)
}, [onOpenChange])
const handleAddPeople = useCallback(async () => {
if (emailsToAdd.length === 0 || isAdding) return
setSubmitError(null)
const targets = emailsToAdd
.map((email) => {
const result = resolveAddEmail(email, { workspaceUserIdByEmail, existingMemberEmails })
return 'userId' in result ? { email, userId: result.userId } : null
})
.filter((target): target is { email: string; userId: string } => target !== null)
if (targets.length === 0) return
setIsAdding(true)
try {
const results = await Promise.allSettled(
targets.map((target) =>
upsertMember.mutateAsync({ credentialId, userId: target.userId, role: roleToAdd })
)
)
const failures = partitionSettledFailures(targets, results)
if (failures.length === 0) {
handleClose()
return
}
setEmailsToAdd(failures.map((target) => target.email))
const firstError = results.find(
(result): result is PromiseRejectedResult => result.status === 'rejected'
)
logger.error('Failed to add some credential members', firstError?.reason)
const reason = getErrorMessage(firstError?.reason, 'Please try again in a moment.')
setSubmitError(
failures.length === targets.length
? `Couldn't add people. ${reason}`
: `Couldn't add ${failures.length} of ${targets.length} people. ${reason}`
)
} finally {
setIsAdding(false)
}
}, [
credentialId,
emailsToAdd,
isAdding,
workspaceUserIdByEmail,
existingMemberEmails,
roleToAdd,
upsertMember,
handleClose,
])
return (
<ChipModal
open={open}
onOpenChange={(next) => {
if (!next) handleClose()
}}
srTitle='Add people'
>
<ChipModalHeader onClose={handleClose}>Add people</ChipModalHeader>
<ChipModalBody>
<ChipModalField
type='emails'
title='Emails'
value={emailsToAdd}
onChange={setEmailsToAdd}
validate={validateAddEmail}
placeholder='Enter emails'
disabled={isAdding}
/>
<ChipModalField
type='dropdown'
title='Role'
options={ROLE_OPTIONS}
value={roleToAdd}
placeholder='Select role'
align='start'
onChange={(role) => setRoleToAdd(role as WorkspaceCredentialRole)}
disabled={isAdding}
/>
<ChipModalError>{submitError}</ChipModalError>
</ChipModalBody>
<ChipModalFooter
onCancel={handleClose}
primaryAction={{
label: isAdding ? 'Adding...' : 'Add',
onClick: handleAddPeople,
disabled: emailsToAdd.length === 0 || isAdding,
}}
/>
</ChipModal>
)
}
@@ -0,0 +1,7 @@
import { chipFieldSurfaceClass, chipFieldTextClass, cn } from '@sim/emcn'
/** Pill wrapper. Override height/alignment (e.g. a textarea) via `cn`. */
export const CHIP_FIELD_SHELL = cn('flex h-[30px] items-center gap-1.5 px-2', chipFieldSurfaceClass)
/** Borderless input/textarea hosted inside {@link CHIP_FIELD_SHELL}. */
export const CHIP_FIELD_INPUT = cn('h-full w-full bg-transparent', chipFieldTextClass)
@@ -0,0 +1,30 @@
import type { ReactNode } from 'react'
interface CredentialDetailHeadingProps {
/** Leading visual (icon tile or brand tile). */
leading: ReactNode
title: ReactNode
subtitle?: ReactNode
}
/**
* Header row shared by credential detail surfaces: a leading visual beside a
* title over a muted subtitle.
*/
export function CredentialDetailHeading({
leading,
title,
subtitle,
}: CredentialDetailHeadingProps) {
return (
<div className='flex items-center gap-2.5'>
{leading}
<div className='flex min-w-0 flex-1 flex-col'>
<span className='truncate text-[14px] text-[var(--text-body)]'>{title}</span>
{subtitle ? (
<span className='truncate text-[12px] text-[var(--text-muted)]'>{subtitle}</span>
) : null}
</div>
</div>
)
}
@@ -0,0 +1,29 @@
import type { ReactNode } from 'react'
interface CredentialDetailLayoutProps {
/** Back link rendered at the start of the fixed action bar. */
back: ReactNode
/** Optional controls grouped at the end of the action bar. */
actions?: ReactNode
children: ReactNode
}
/**
* Page shell shared by the credential detail surfaces: a fixed action bar
* (back link + grouped actions) above a scrollable, centered body. Surfaces
* supply the slots and body sections; all layout chrome lives here so callsites
* stay free of bespoke styling.
*/
export function CredentialDetailLayout({ back, actions, children }: CredentialDetailLayoutProps) {
return (
<div className='flex h-full flex-col bg-[var(--bg)]'>
<div className='flex flex-shrink-0 items-center justify-between bg-[var(--bg)] px-[16px] pt-[8.5px] pb-[8.5px]'>
{back}
{actions ? <div className='flex items-center'>{actions}</div> : null}
</div>
<div className='min-h-0 flex-1 overflow-y-auto px-6 [scrollbar-gutter:stable_both-edges]'>
<div className='mx-auto flex max-w-[48rem] flex-col gap-7 pb-3'>{children}</div>
</div>
</div>
)
}
@@ -0,0 +1,124 @@
'use client'
import { Avatar, AvatarFallback, Chip, ChipDropdown, cn } from '@sim/emcn'
import { createLogger } from '@sim/logger'
import { credentialRoleLockReason, RoleLockTooltip } from '@/components/permissions'
import { getUserColor } from '@/lib/workspaces/colors'
import {
useRemoveWorkspaceCredentialMember,
useUpsertWorkspaceCredentialMember,
useWorkspaceCredentialMembers,
type WorkspaceCredentialRole,
} from '@/hooks/queries/credentials'
import { ROLE_OPTIONS } from '../roles'
import { DetailSection } from './detail-section'
const logger = createLogger('CredentialMembersSection')
interface CredentialMembersSectionProps {
credentialId: string
isAdmin: boolean
}
/**
* Active-member list for a credential: avatar + identity, a role dropdown, and a
* remove action. The last remaining admin cannot be demoted or removed. Shared
* by every credential detail surface.
*/
export function CredentialMembersSection({ credentialId, isAdmin }: CredentialMembersSectionProps) {
const { data: members = [], isPending: membersLoading } =
useWorkspaceCredentialMembers(credentialId)
const upsertMember = useUpsertWorkspaceCredentialMember()
const removeMember = useRemoveWorkspaceCredentialMember()
const activeMembers = members.filter((member) => member.status === 'active')
const explicitAdminCount = activeMembers.filter(
(member) => member.role === 'admin' && member.roleSource !== 'workspace-admin'
).length
const handleChangeMemberRole = async (userId: string, role: WorkspaceCredentialRole) => {
const current = activeMembers.find((member) => member.userId === userId)
if (current?.role === role) return
try {
await upsertMember.mutateAsync({ credentialId, userId, role })
} catch (error) {
logger.error('Failed to change member role', error)
}
}
const handleRemoveMember = async (userId: string) => {
try {
await removeMember.mutateAsync({ credentialId, userId })
} catch (error) {
logger.error('Failed to remove credential member', error)
}
}
return (
<DetailSection title={`Members (${activeMembers.length})`}>
{membersLoading ? null : (
<div className='flex flex-col gap-2'>
{activeMembers.map((member) => {
const lockReason = credentialRoleLockReason(member.roleSource)
const roleLocked =
member.role === 'admin' &&
member.roleSource !== 'workspace-admin' &&
explicitAdminCount <= 1
const roleDisabled = !isAdmin || roleLocked || lockReason !== null
const removeDisabled = roleLocked || lockReason !== null
return (
<div
key={member.id}
className={cn(
'grid items-center gap-2',
isAdmin ? 'grid-cols-[1fr_120px_72px]' : 'grid-cols-[1fr_200px]'
)}
>
<div className='flex min-w-0 items-center gap-2.5'>
<Avatar className='size-9 flex-shrink-0'>
<AvatarFallback
style={{
background: getUserColor(member.userId || member.userEmail || ''),
}}
className='border border-[var(--border-1)] text-small text-white'
>
{(member.userName || member.userEmail || '?').charAt(0).toUpperCase()}
</AvatarFallback>
</Avatar>
<div className='flex min-w-0 flex-col'>
<span className='truncate text-[14px] text-[var(--text-body)]'>
{member.userName || member.userEmail || member.userId}
</span>
<span className='truncate text-[12px] text-[var(--text-muted)]'>
{member.userEmail || member.userId}
</span>
</div>
</div>
<RoleLockTooltip reason={lockReason}>
<ChipDropdown
options={ROLE_OPTIONS}
value={member.role}
placeholder='Role'
disabled={roleDisabled}
onChange={(role) =>
handleChangeMemberRole(member.userId, role as WorkspaceCredentialRole)
}
/>
</RoleLockTooltip>
{isAdmin && (
<Chip
onClick={() => handleRemoveMember(member.userId)}
disabled={removeDisabled}
flush
className='justify-self-end'
>
Remove
</Chip>
)}
</div>
)
})}
</div>
)}
</DetailSection>
)
}
@@ -0,0 +1,14 @@
import type { ComponentType } from 'react'
interface DetailIconTileProps {
icon: ComponentType<{ className?: string }>
}
/** Square tile with a centered icon, used as a detail header's leading visual. */
export function DetailIconTile({ icon: Icon }: DetailIconTileProps) {
return (
<div className='flex size-9 flex-shrink-0 items-center justify-center overflow-hidden rounded-lg bg-[var(--surface-5)]'>
<Icon className='size-[18px] text-[var(--text-tertiary)]' />
</div>
)
}
@@ -0,0 +1,21 @@
import type { ReactNode } from 'react'
interface DetailSectionProps {
title: ReactNode
children: ReactNode
}
/**
* Labeled section with a muted title and a thin inset divider above the body.
* Shared by the credential detail surfaces so every section keeps the same
* vertical rhythm without repeating markup at the callsites.
*/
export function DetailSection({ title, children }: DetailSectionProps) {
return (
<section className='flex flex-col'>
<span className='pl-0.5 text-[var(--text-muted)] text-small'>{title}</span>
<div className='mt-[9px] mb-3 h-px bg-[var(--border)]' />
{children}
</section>
)
}
@@ -0,0 +1,27 @@
import { ChipConfirmModal } from '@sim/emcn'
interface UnsavedChangesModalProps {
open: boolean
onOpenChange: (open: boolean) => void
/** Confirmed discard: abandon edits and continue (navigate away / close). */
onDiscard: () => void
}
/**
* Confirmation shown when leaving a surface with unsaved edits. Shared by the
* credential detail surfaces and the secrets list so the copy and affordances
* stay identical.
*/
export function UnsavedChangesModal({ open, onOpenChange, onDiscard }: UnsavedChangesModalProps) {
return (
<ChipConfirmModal
open={open}
onOpenChange={onOpenChange}
srTitle='Unsaved Changes'
title='Unsaved Changes'
text='You have unsaved changes. Are you sure you want to discard them?'
dismissLabel='Keep editing'
confirm={{ label: 'Discard Changes', onClick: onDiscard }}
/>
)
}
@@ -0,0 +1,87 @@
'use client'
import { useCallback, useEffect, useState } from 'react'
import { toast } from '@sim/emcn'
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { useUpdateWorkspaceCredential, type WorkspaceCredential } from '@/hooks/queries/credentials'
import { useUnsavedChangesGuard } from './use-unsaved-changes-guard'
const logger = createLogger('CredentialDetailForm')
interface UseCredentialDetailFormParams {
credential: WorkspaceCredential | null
isAdmin: boolean
/** Where the back link / discard navigates to. */
backHref: string
}
/**
* Shared editable-metadata controller for a credential detail page: Display Name
* and Description drafts seeded from the credential, dirty tracking, an
* admin-only save, and the shared unsaved-changes guard.
*/
export function useCredentialDetailForm({
credential,
isAdmin,
backHref,
}: UseCredentialDetailFormParams) {
const updateCredential = useUpdateWorkspaceCredential()
const [displayNameDraft, setDisplayNameDraft] = useState('')
const [descriptionDraft, setDescriptionDraft] = useState('')
useEffect(() => {
setDisplayNameDraft(credential?.displayName ?? '')
setDescriptionDraft(credential?.description ?? '')
}, [credential?.id, credential?.displayName, credential?.description])
const isDisplayNameDirty = credential ? displayNameDraft !== credential.displayName : false
const isDescriptionDirty = credential
? descriptionDraft !== (credential.description || '')
: false
const isDirty = isDisplayNameDirty || isDescriptionDirty
const guard = useUnsavedChangesGuard({ isDirty, backHref })
const save = useCallback(async () => {
if (!credential || !isAdmin || !isDirty || updateCredential.isPending) return
try {
await updateCredential.mutateAsync({
credentialId: credential.id,
...(isDisplayNameDirty ? { displayName: displayNameDraft.trim() } : {}),
...(isDescriptionDirty ? { description: descriptionDraft.trim() || null } : {}),
})
if (isDisplayNameDirty) setDisplayNameDraft((value) => value.trim())
if (isDescriptionDirty) setDescriptionDraft((value) => value.trim())
} catch (error) {
toast.error("Couldn't save changes", {
description: getErrorMessage(error, 'Please try again in a moment.'),
})
logger.error('Failed to save credential details', error)
}
}, [
credential,
isAdmin,
isDirty,
isDisplayNameDirty,
isDescriptionDirty,
displayNameDraft,
descriptionDraft,
updateCredential,
])
return {
displayNameDraft,
setDisplayNameDraft,
descriptionDraft,
setDescriptionDraft,
isDirty,
save,
isSaving: updateCredential.isPending,
handleBackClick: guard.handleBackClick,
showUnsavedAlert: guard.showUnsavedAlert,
setShowUnsavedAlert: guard.setShowUnsavedAlert,
confirmDiscard: guard.confirmDiscard,
}
}
@@ -0,0 +1,79 @@
'use client'
import type { MouseEvent } from 'react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useRouter } from 'next/navigation'
interface UseUnsavedChangesGuardParams {
isDirty: boolean
/** Where a confirmed discard navigates to. */
backHref: string
}
/**
* Guards a detail surface against losing unsaved edits: warns on browser unload
* while dirty, intercepts the in-app back link, and traps the browser
* back/forward button to confirm before leaving. Shared by every credential
* detail surface so the behavior is identical.
*
* Native Back is trapped by seeding a single same-URL history entry while dirty,
* so Back pops that entry (no route change) and fires popstate. The seed is
* removed once the form is clean again (save/revert) so it never accumulates
* across edit cycles — and that removal runs in the effect body (only while
* still mounted), never in cleanup, so an intentional discard/navigation away is
* not reversed.
*/
export function useUnsavedChangesGuard({ isDirty, backHref }: UseUnsavedChangesGuardParams) {
const router = useRouter()
const [showUnsavedAlert, setShowUnsavedAlert] = useState(false)
const hasSentinelRef = useRef(false)
useEffect(() => {
if (!isDirty) {
// Clean again while still mounted (saved/reverted): pop the seeded entry so
// it can't pile up across edit/save cycles. This runs in the effect body,
// never on unmount, so navigating away mid-edit is never reversed.
if (hasSentinelRef.current) {
hasSentinelRef.current = false
window.history.back()
}
return
}
// Seed one same-URL entry so Back pops it (no route change) and fires
// popstate, letting us confirm before the page actually leaves.
if (!hasSentinelRef.current) {
window.history.pushState(null, '', window.location.href)
hasSentinelRef.current = true
}
const handleBeforeUnload = (event: BeforeUnloadEvent) => {
event.preventDefault()
}
const handlePopState = () => {
window.history.pushState(null, '', window.location.href)
setShowUnsavedAlert(true)
}
window.addEventListener('beforeunload', handleBeforeUnload)
window.addEventListener('popstate', handlePopState)
return () => {
window.removeEventListener('beforeunload', handleBeforeUnload)
window.removeEventListener('popstate', handlePopState)
}
}, [isDirty])
const handleBackClick = useCallback(
(event: MouseEvent<HTMLAnchorElement>) => {
if (isDirty) {
event.preventDefault()
setShowUnsavedAlert(true)
}
},
[isDirty]
)
const confirmDiscard = useCallback(() => {
setShowUnsavedAlert(false)
router.push(backHref)
}, [router, backHref])
return { showUnsavedAlert, setShowUnsavedAlert, handleBackClick, confirmDiscard }
}
@@ -0,0 +1,10 @@
export { AddPeopleModal } from './components/add-people-modal'
export { CHIP_FIELD_INPUT, CHIP_FIELD_SHELL } from './components/chip-field'
export { CredentialDetailHeading } from './components/credential-detail-heading'
export { CredentialDetailLayout } from './components/credential-detail-layout'
export { CredentialMembersSection } from './components/credential-members-section'
export { DetailIconTile } from './components/detail-icon-tile'
export { DetailSection } from './components/detail-section'
export { UnsavedChangesModal } from './components/unsaved-changes-modal'
export { useCredentialDetailForm } from './hooks/use-credential-detail-form'
export { useUnsavedChangesGuard } from './hooks/use-unsaved-changes-guard'
@@ -0,0 +1,15 @@
import type { WorkspaceCredentialRole } from '@/hooks/queries/credentials'
export interface CredentialRoleOption {
value: WorkspaceCredentialRole
label: string
}
/**
* Roles assignable to a credential member. Shared by every credential detail
* surface (Integrations, Secrets) so role choices never drift between them.
*/
export const ROLE_OPTIONS: readonly CredentialRoleOption[] = [
{ value: 'member', label: 'Member' },
{ value: 'admin', label: 'Admin' },
] as const
@@ -0,0 +1,69 @@
/**
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import { partitionSettledFailures, resolveAddEmail } from './sharing'
describe('resolveAddEmail', () => {
const ctx = {
workspaceUserIdByEmail: new Map([
['ada@sim.dev', 'user-ada'],
['grace@sim.dev', 'user-grace'],
]),
existingMemberEmails: new Set(['grace@sim.dev']),
}
it('returns the userId for a workspace member who is not already on the credential', () => {
expect(resolveAddEmail('ada@sim.dev', ctx)).toEqual({ userId: 'user-ada' })
})
it('rejects an email that does not belong to any workspace member', () => {
expect(resolveAddEmail('nope@sim.dev', ctx)).toEqual({
error: "nope@sim.dev isn't a member of this workspace",
})
})
it('rejects an email that already has access to the credential', () => {
expect(resolveAddEmail('grace@sim.dev', ctx)).toEqual({
error: 'grace@sim.dev already has access',
})
})
it('matches case-insensitively while echoing the original email in errors', () => {
expect(resolveAddEmail('ADA@Sim.dev', ctx)).toEqual({ userId: 'user-ada' })
expect(resolveAddEmail('Grace@SIM.dev', ctx)).toEqual({
error: 'Grace@SIM.dev already has access',
})
})
})
describe('partitionSettledFailures', () => {
const targets = [{ email: 'a' }, { email: 'b' }, { email: 'c' }]
it('returns no failures when every result is fulfilled', () => {
const results: PromiseSettledResult<unknown>[] = [
{ status: 'fulfilled', value: 1 },
{ status: 'fulfilled', value: 2 },
{ status: 'fulfilled', value: 3 },
]
expect(partitionSettledFailures(targets, results)).toEqual([])
})
it('returns only the rejected targets (index-aligned) on partial failure', () => {
const results: PromiseSettledResult<unknown>[] = [
{ status: 'fulfilled', value: 1 },
{ status: 'rejected', reason: new Error('boom') },
{ status: 'fulfilled', value: 3 },
]
expect(partitionSettledFailures(targets, results)).toEqual([{ email: 'b' }])
})
it('returns all targets when every result rejected', () => {
const results: PromiseSettledResult<unknown>[] = [
{ status: 'rejected', reason: new Error('1') },
{ status: 'rejected', reason: new Error('2') },
{ status: 'rejected', reason: new Error('3') },
]
expect(partitionSettledFailures(targets, results)).toEqual(targets)
})
})
@@ -0,0 +1,37 @@
export interface ResolveAddEmailContext {
/** Lowercased email -> workspace userId for every workspace member. */
workspaceUserIdByEmail: Map<string, string>
/** Lowercased emails that already have access to the credential. */
existingMemberEmails: Set<string>
}
export type ResolveAddEmailResult = { userId: string } | { error: string }
/**
* Decide whether a format-valid email can be added to a credential: it must
* belong to a workspace member and not already have access. Matching is
* case-insensitive (the context map/set are keyed by lowercased email) while
* error messages echo the email as the user typed it. Returns the resolved
* `userId` on success, or a user-facing `error` message.
*/
export function resolveAddEmail(
email: string,
{ workspaceUserIdByEmail, existingMemberEmails }: ResolveAddEmailContext
): ResolveAddEmailResult {
const normalized = email.toLowerCase()
const userId = workspaceUserIdByEmail.get(normalized)
if (!userId) return { error: `${email} isn't a member of this workspace` }
if (existingMemberEmails.has(normalized)) return { error: `${email} already has access` }
return { userId }
}
/**
* Given the targets passed to a batch add and the index-aligned
* `Promise.allSettled` results, return the targets whose settle rejected.
*/
export function partitionSettledFailures<T>(
targets: readonly T[],
results: readonly PromiseSettledResult<unknown>[]
): T[] {
return targets.filter((_, index) => results[index]?.status === 'rejected')
}
@@ -0,0 +1,42 @@
'use client'
import { useState } from 'react'
import { cn } from '@sim/emcn'
interface DropZoneProps {
onDrop: (e: React.DragEvent) => void
children: React.ReactNode
className?: string
}
/** File drop target with a dashed accent overlay while dragging. Shared by the
* whitelabeling settings and the deploy-as-block icon upload. */
export function DropZone({ onDrop, children, className }: DropZoneProps) {
const [isDragging, setIsDragging] = useState(false)
return (
<div
className={cn('relative', className)}
onDragOver={(e) => {
if (e.dataTransfer.types.includes('Files')) {
e.preventDefault()
setIsDragging(true)
}
}}
onDragLeave={(e) => {
if (!e.currentTarget.contains(e.relatedTarget as Node)) {
setIsDragging(false)
}
}}
onDrop={(e) => {
setIsDragging(false)
onDrop(e)
}}
>
{children}
{isDragging && (
<div className='pointer-events-none absolute inset-0 z-10 rounded-lg border-[1.5px] border-[var(--brand-accent)] border-dashed bg-[color-mix(in_srgb,var(--brand-accent)_8%,transparent)]' />
)}
</div>
)
}
@@ -0,0 +1,88 @@
'use client'
import { type ReactNode, useEffect } from 'react'
import { Button } from '@sim/emcn'
import { createLogger } from '@sim/logger'
import { TriangleAlert } from 'lucide-react'
/** Props shape required by Next.js error boundary files (`error.tsx`). */
export interface ErrorBoundaryProps {
error: Error & { digest?: string }
reset: () => void
}
export interface ErrorStateProps extends ErrorBoundaryProps {
title: string
description: string
loggerName: string
/** Optional glyph for the framed mark. Defaults to `TriangleAlert`. */
icon?: ReactNode
/** Extra action buttons rendered before the default "Try again". */
children?: ReactNode
}
interface ErrorShellProps {
title: string
description: string
icon?: ReactNode
children: ReactNode
}
/**
* Centered layout shared by the workspace error boundary and not-found page.
* Renders a framed glyph, serif headline, supporting paragraph, and a row of
* action buttons.
*/
export function ErrorShell({ title, description, icon, children }: ErrorShellProps) {
return (
<div className='flex h-full flex-1 items-center justify-center bg-[var(--bg)] px-6 py-12'>
<div className='flex w-full max-w-[420px] flex-col items-center gap-5 text-center'>
<div className='size-[52px] shrink-0 rounded-2xl border border-[var(--border-muted)] bg-[var(--surface-4)] p-[3px] shadow-sm dark:bg-[var(--surface-5)]'>
<div className='flex size-full items-center justify-center rounded-[11px] border border-[var(--border-1)] bg-[var(--bg)] text-[var(--text-icon)]'>
{icon ?? <TriangleAlert className='size-[22px]' strokeWidth={1.55} />}
</div>
</div>
<div className='flex flex-col items-center gap-2'>
<h2 className='text-balance font-[430] font-season text-[26px] text-[var(--text-primary)] leading-[1.15] tracking-[-0.01em] sm:text-[28px]'>
{title}
</h2>
<p className='max-w-[340px] text-[14px] text-[var(--text-tertiary)] leading-[1.55]'>
{description}
</p>
</div>
<div className='flex flex-wrap items-center justify-center gap-2 pt-1'>{children}</div>
</div>
</div>
)
}
/**
* Workspace error boundary view. Logs the error once per occurrence and renders
* `ErrorShell` with a primary "Try again" action. Pass extra buttons (e.g. "Go
* back") via `children` — they render before the "Try again" button.
*/
export function ErrorState({
error,
reset,
title,
description,
loggerName,
icon,
children,
}: ErrorStateProps) {
useEffect(() => {
createLogger(loggerName).error(`${loggerName} error:`, {
error: error.message,
digest: error.digest,
})
}, [error.message, error.digest, loggerName])
return (
<ErrorShell title={title} description={description} icon={icon}>
{children}
<Button variant='primary' size='md' onClick={reset}>
Refresh
</Button>
</ErrorShell>
)
}
@@ -0,0 +1,2 @@
export type { ErrorBoundaryProps, ErrorStateProps } from './error'
export { ErrorShell, ErrorState } from './error'
@@ -0,0 +1,46 @@
'use client'
import { useState } from 'react'
import { Banner } from '@sim/emcn'
import { useSession } from '@/lib/auth/auth-client'
import { useStopImpersonating } from '@/hooks/queries/admin-users'
function getImpersonationBannerText(userLabel: string, userEmail?: string) {
return `Impersonating ${userLabel}${userEmail ? ` (${userEmail})` : ''}. Changes will apply to this account until you switch back.`
}
export function ImpersonationBanner() {
const { data: session, isPending } = useSession()
const stopImpersonating = useStopImpersonating()
const [isRedirecting, setIsRedirecting] = useState(false)
const userLabel = session?.user?.name || 'this user'
const userEmail = session?.user?.email
if (isPending || !session?.session?.impersonatedBy) {
return null
}
return (
<Banner
variant='destructive'
text={getImpersonationBannerText(userLabel, userEmail)}
textClassName='text-red-700 dark:text-red-300'
actionLabel={
stopImpersonating.isPending || isRedirecting ? 'Returning...' : 'Stop impersonating'
}
actionVariant='destructive'
actionDisabled={stopImpersonating.isPending || isRedirecting}
onAction={() =>
stopImpersonating.mutate(undefined, {
onError: () => {
setIsRedirecting(false)
},
onSuccess: () => {
setIsRedirecting(true)
window.location.assign('/workspace')
},
})
}
/>
)
}
@@ -0,0 +1 @@
export { ImpersonationBanner } from './impersonation-banner'
@@ -0,0 +1,39 @@
export { ConversationListItem } from './conversation-list-item'
export type { ErrorBoundaryProps, ErrorStateProps } from './error'
export { ErrorShell, ErrorState } from './error'
export { InlineRenameInput } from './inline-rename-input'
export { MessageActions } from './message-actions'
export { FloatingOverflowText } from './resource/components/floating-overflow-text'
export { ownerCell } from './resource/components/owner-cell'
export {
type ChromeActionSpec,
ResourceChromeFallback,
} from './resource/components/resource-chrome-fallback'
export type {
BreadcrumbEditing,
BreadcrumbItem,
DropdownOption,
ResourceAction,
} from './resource/components/resource-header'
export type {
ColumnOption,
FilterConfig,
FilterTag,
SearchConfig,
SearchTag,
SortConfig,
} from './resource/components/resource-options'
export { SortDropdown } from './resource/components/resource-options'
export { timeCell } from './resource/components/time-cell'
export type {
PaginationConfig,
ResourceCell,
ResourceCellEditing,
ResourceColumn,
ResourceRow,
ResourceTableHandle,
RowDragDropConfig,
SelectableConfig,
} from './resource/resource'
export { EMPTY_CELL_PLACEHOLDER, Resource } from './resource/resource'
export { SkillTile } from './skill-tile'
@@ -0,0 +1 @@
export { InlineRenameInput } from './inline-rename-input'
@@ -0,0 +1,78 @@
'use client'
import { useEffect, useRef } from 'react'
interface InlineRenameInputProps {
value: string
onChange: (value: string) => void
onSubmit: () => void
onCancel: () => void
/**
* Disables the field while the rename is in flight, mirroring the sidebar's
* `disabled={isRenaming}`. Threaded from `useInlineRename`'s `isSaving`.
*/
disabled?: boolean
}
/**
* Inline rename field used by every resource rename surface (tables, files,
* knowledge), triggered from a context menu. Matches the sidebar workflow rename
* (`useItemRename`) input verbatim: same focus-reset className and attribute set
* (`maxLength`, autocomplete/correct/capitalize off, spellCheck off), focus +
* select on mount, commit on blur, Enter to submit, Escape to cancel, disabled
* while saving. The only intentional delta is the table-cell `size={...}`
* auto-width, which the sidebar (full-width row) does not need.
*
* The triggering menu uses `onCloseAutoFocus={(e) => e.preventDefault()}`, so the
* Radix focus-scope teardown never steals focus back from this freshly-focused
* input. `onSubmit` (from `useInlineRename`) is idempotent via its `doneRef`
* guard, so a blur racing an Enter/Escape commit is a harmless no-op.
*
* TODO: the resource rename still intermittently unfocuses; the deeper re-render
* cause (parent remounting the editing cell) is tracked separately. This input is
* aligned to the proven sidebar pattern as the first step.
*/
export function InlineRenameInput({
value,
onChange,
onSubmit,
onCancel,
disabled = false,
}: InlineRenameInputProps) {
const inputRef = useRef<HTMLInputElement>(null)
useEffect(() => {
const el = inputRef.current
if (!el) return
el.focus()
el.select()
}, [])
return (
<input
ref={inputRef}
type='text'
value={value}
size={Math.max(value.length + 2, 5)}
onChange={(e) => onChange(e.target.value)}
onBlur={onSubmit}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault()
onSubmit()
} else if (e.key === 'Escape') {
e.preventDefault()
onCancel()
}
}}
onClick={(e) => e.stopPropagation()}
className='w-full min-w-0 border-0 bg-transparent p-0 text-[var(--text-body)] text-sm outline-none focus:outline-none focus:ring-0 focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0'
maxLength={100}
disabled={disabled}
autoComplete='off'
autoCorrect='off'
autoCapitalize='off'
spellCheck='false'
/>
)
}
@@ -0,0 +1 @@
export { MessageActions } from './message-actions'
@@ -0,0 +1,294 @@
'use client'
import { memo, useEffect, useRef, useState } from 'react'
import {
Check,
ChipModal,
ChipModalBody,
ChipModalField,
ChipModalFooter,
ChipModalHeader,
cn,
Duplicate,
ThumbsDown,
ThumbsUp,
Tooltip,
toast,
} from '@sim/emcn'
import { GitBranch } from 'lucide-react'
import { useParams, useRouter } from 'next/navigation'
import { useChatSurface } from '@/app/workspace/[workspaceId]/home/components/chat-surface-context'
import { useSubmitCopilotFeedback } from '@/hooks/queries/copilot-feedback'
import { useForkMothershipChat } from '@/hooks/queries/mothership-chats'
import { useFolderStore } from '@/stores/folders/store'
const SPECIAL_TAGS = 'thinking|options|usage_upgrade|credential|mothership-error|file'
function toPlainText(raw: string): string {
return (
raw
// Strip special tags and their contents
.replace(new RegExp(`<\\/?(${SPECIAL_TAGS})(?:>[\\s\\S]*?<\\/(${SPECIAL_TAGS})>|>)`, 'g'), '')
// Strip markdown
.replace(/^#{1,6}\s+/gm, '')
.replace(/\*\*(.+?)\*\*/g, '$1')
.replace(/\*(.+?)\*/g, '$1')
.replace(/`{3}[\s\S]*?`{3}/g, '')
.replace(/`(.+?)`/g, '$1')
.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1')
.replace(/^[>\-*]\s+/gm, '')
.replace(/!\[[^\]]*\]\([^)]+\)/g, '')
// Normalize whitespace
.replace(/\n{3,}/g, '\n\n')
.trim()
)
}
const ICON_CLASS = 'size-[14px]'
const BUTTON_CLASS =
'flex size-[26px] items-center justify-center rounded-[6px] text-[var(--text-icon)] transition-colors hover-hover:bg-[var(--surface-hover)] focus-visible:outline-none'
interface MessageActionsProps {
content: string
userQuery?: string
requestId?: string
messageId?: string
}
export const MessageActions = memo(function MessageActions({
content,
userQuery,
requestId,
messageId,
}: MessageActionsProps) {
const router = useRouter()
const params = useParams<{ workspaceId: string }>()
const { chatId } = useChatSurface()
const [copied, setCopied] = useState(false)
const [copiedRequestId, setCopiedRequestId] = useState(false)
const [pendingFeedback, setPendingFeedback] = useState<'up' | 'down' | null>(null)
const [feedbackText, setFeedbackText] = useState('')
const resetTimeoutRef = useRef<number | null>(null)
const requestIdTimeoutRef = useRef<number | null>(null)
const submitFeedback = useSubmitCopilotFeedback()
const forkChat = useForkMothershipChat(params.workspaceId)
useEffect(() => {
return () => {
if (resetTimeoutRef.current !== null) {
window.clearTimeout(resetTimeoutRef.current)
}
if (requestIdTimeoutRef.current !== null) {
window.clearTimeout(requestIdTimeoutRef.current)
}
}
}, [])
const copyToClipboard = async () => {
if (!content) return
const text = toPlainText(content)
if (!text) return
try {
await navigator.clipboard.writeText(text)
setCopied(true)
if (resetTimeoutRef.current !== null) {
window.clearTimeout(resetTimeoutRef.current)
}
resetTimeoutRef.current = window.setTimeout(() => setCopied(false), 1500)
} catch {
/* clipboard unavailable */
}
}
const copyRequestId = async () => {
if (!requestId) return
try {
await navigator.clipboard.writeText(requestId)
setCopiedRequestId(true)
if (requestIdTimeoutRef.current !== null) {
window.clearTimeout(requestIdTimeoutRef.current)
}
requestIdTimeoutRef.current = window.setTimeout(() => setCopiedRequestId(false), 1500)
} catch {
/* clipboard unavailable */
}
}
const handleFeedbackClick = (type: 'up' | 'down') => {
if (chatId && userQuery) {
setPendingFeedback(type)
setFeedbackText('')
setCopiedRequestId(false)
}
}
const handleSubmitFeedback = () => {
if (!pendingFeedback || !chatId || !userQuery) return
const text = feedbackText.trim()
if (!text) {
setPendingFeedback(null)
setFeedbackText('')
return
}
submitFeedback.mutate({
chatId,
userQuery,
agentResponse: content,
isPositiveFeedback: pendingFeedback === 'up',
feedback: text,
})
setPendingFeedback(null)
setFeedbackText('')
}
const handleModalClose = (open: boolean) => {
if (!open) {
setPendingFeedback(null)
setFeedbackText('')
setCopiedRequestId(false)
}
}
const handleFork = async () => {
if (!chatId || !messageId || forkChat.isPending) return
try {
const result = await forkChat.mutateAsync({ chatId, upToMessageId: messageId })
useFolderStore.getState().clearChatSelection()
router.push(`/workspace/${params.workspaceId}/chat/${result.id}`)
} catch {
toast.error('Failed to fork chat')
}
}
const hasContent = Boolean(content)
const canSubmitFeedback = Boolean(chatId && userQuery)
const canFork = false
if (!hasContent && !canSubmitFeedback && !canFork) return null
return (
<>
<div className='flex items-center gap-0.5'>
{hasContent && (
<Tooltip.Root>
<Tooltip.Trigger asChild>
<button
type='button'
aria-label='Copy message'
onClick={copyToClipboard}
className={BUTTON_CLASS}
>
{copied ? <Check className={ICON_CLASS} /> : <Duplicate className={ICON_CLASS} />}
</button>
</Tooltip.Trigger>
<Tooltip.Content side='top'>
{copied ? 'Copied message' : 'Copy message'}
</Tooltip.Content>
</Tooltip.Root>
)}
{canSubmitFeedback && (
<>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<button
type='button'
aria-label='Like'
onClick={() => handleFeedbackClick('up')}
className={BUTTON_CLASS}
>
<ThumbsUp className={ICON_CLASS} />
</button>
</Tooltip.Trigger>
<Tooltip.Content side='top'>Good response</Tooltip.Content>
</Tooltip.Root>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<button
type='button'
aria-label='Dislike'
onClick={() => handleFeedbackClick('down')}
className={BUTTON_CLASS}
>
<ThumbsDown className={ICON_CLASS} />
</button>
</Tooltip.Trigger>
<Tooltip.Content side='top'>Bad response</Tooltip.Content>
</Tooltip.Root>
</>
)}
{canFork && (
<Tooltip.Root>
<Tooltip.Trigger asChild>
<button
type='button'
aria-label='Fork from here'
onClick={handleFork}
disabled={forkChat.isPending}
className={cn(BUTTON_CLASS, forkChat.isPending && 'cursor-not-allowed opacity-50')}
>
<GitBranch className={ICON_CLASS} />
</button>
</Tooltip.Trigger>
<Tooltip.Content side='top'>Fork from here</Tooltip.Content>
</Tooltip.Root>
)}
</div>
<ChipModal
open={pendingFeedback !== null}
onOpenChange={handleModalClose}
srTitle='Give feedback'
>
<ChipModalHeader onClose={() => handleModalClose(false)}>Give feedback</ChipModalHeader>
<ChipModalBody>
<div className='flex items-start justify-between gap-2 px-2'>
<p className='font-medium text-[var(--text-secondary)] text-sm'>
{pendingFeedback === 'up' ? 'What did you like?' : 'What could be improved?'}
</p>
{pendingFeedback === 'down' && requestId && (
<Tooltip.Root>
<Tooltip.Trigger asChild>
<button
type='button'
aria-label='Copy request ID'
onClick={copyRequestId}
className='flex size-[22px] shrink-0 items-center justify-center rounded-full text-[var(--text-icon)] transition-colors hover-hover:bg-[var(--surface-hover)] focus-visible:outline-none'
>
{copiedRequestId ? (
<Check className='size-[14px]' />
) : (
<Duplicate className='size-[14px]' />
)}
</button>
</Tooltip.Trigger>
<Tooltip.Content side='top'>
{copiedRequestId ? 'Copied request ID' : 'Copy request ID'}
</Tooltip.Content>
</Tooltip.Root>
)}
</div>
<ChipModalField
type='textarea'
title='Feedback'
value={feedbackText}
onChange={setFeedbackText}
rows={6}
minHeight={140}
resizable
placeholder={
pendingFeedback === 'up'
? 'Tell us what was helpful...'
: 'Tell us what went wrong...'
}
/>
</ChipModalBody>
<ChipModalFooter
onCancel={() => handleModalClose(false)}
primaryAction={{
label: 'Submit',
onClick: handleSubmitFeedback,
}}
/>
</ChipModal>
</>
)
})
@@ -0,0 +1,51 @@
'use client'
import type React from 'react'
import { memo } from 'react'
import { cn, FloatingTooltip, isTextClipped, useFloatingTooltip, useIsOverflowing } from '@sim/emcn'
interface FloatingOverflowTextProps {
/** Full text shown in the tooltip and used as the default visible content. */
label: string
/** Optional custom visible content (e.g. highlighted text); defaults to `label`. */
children?: React.ReactNode
className?: string
/** Forces the tooltip even when the text is not visually clipped (e.g. content truncated upstream). */
showWhen?: boolean
}
/**
* Truncating text that fades its clipped edge and reveals the full value in a
* pointer-reactive floating tooltip on hover or focus.
*/
export const FloatingOverflowText = memo(function FloatingOverflowText({
label,
children,
className,
showWhen,
}: FloatingOverflowTextProps) {
const { ref: textRef, node, isOverflowing } = useIsOverflowing<HTMLSpanElement>()
const { state, handlers } = useFloatingTooltip(() => {
const element = node.current
if (!element || label.length === 0) return false
return Boolean(showWhen) || isTextClipped(element)
})
return (
<>
<span
ref={textRef}
className={cn(
'min-w-0',
isOverflowing &&
'[mask-image:linear-gradient(to_right,black_calc(100%-18px),transparent)] hover:[mask-image:none] focus-visible:[mask-image:none]',
className
)}
{...handlers}
>
{children ?? label}
</span>
<FloatingTooltip label={label} state={state} />
</>
)
})
@@ -0,0 +1 @@
export { ownerCell } from './owner-cell'
@@ -0,0 +1,52 @@
import { memo } from 'react'
import type { ResourceCell } from '@/app/workspace/[workspaceId]/components/resource/resource'
import type { WorkspaceMember } from '@/hooks/queries/workspace'
interface OwnerAvatarProps {
name: string
image: string | null
}
const OwnerAvatar = memo(function OwnerAvatar({ name, image }: OwnerAvatarProps) {
if (image) {
return (
<img
src={image}
alt={name}
referrerPolicy='no-referrer'
className='size-[14px] rounded-full border border-[var(--border)] object-cover'
/>
)
}
return (
<span className='flex size-[14px] items-center justify-center rounded-full border border-[var(--border)] bg-[var(--surface-3)] font-medium text-[8px] text-[var(--text-secondary)]'>
{name.charAt(0).toUpperCase()}
</span>
)
})
/**
* Resolves a user ID into a ResourceCell with an avatar icon and display name.
* Returns null label while members are still loading to avoid flashing raw IDs.
*
* Accepts either the raw member array or a precomputed `userId → member` map.
* Prefer the map form when resolving many rows so lookups stay O(1) instead of
* scanning the array per row.
*/
export function ownerCell(
userId: string | null | undefined,
members?: WorkspaceMember[] | Map<string, WorkspaceMember>
): ResourceCell {
if (!userId) return { label: null }
if (!members) return { label: null }
const member =
members instanceof Map ? members.get(userId) : members.find((m) => m.userId === userId)
if (!member) return { label: null }
return {
icon: <OwnerAvatar name={member.name} image={member.image} />,
label: member.name,
}
}
@@ -0,0 +1,2 @@
export type { ChromeActionSpec } from './resource-chrome-fallback'
export { ResourceChromeFallback } from './resource-chrome-fallback'
@@ -0,0 +1,91 @@
'use client'
import type { ComponentType } from 'react'
import { noop } from '@sim/utils/helpers'
import type { BreadcrumbItem } from '@/app/workspace/[workspaceId]/components/resource/components/resource-header'
import {
Resource,
type ResourceColumn,
} from '@/app/workspace/[workspaceId]/components/resource/resource'
/**
* The static visual shape of a header action chip. The loading fallback only
* needs the chrome (text/icon/variant/active) — handlers are no-ops during a
* route transition — so the dynamic `onSelect`/`disabled` are intentionally
* omitted. Mirrors {@link ResourceAction}'s chrome fields.
*/
export interface ChromeActionSpec {
text: string
icon?: ComponentType<{ className?: string }>
variant?: 'primary' | 'destructive'
active?: boolean
}
interface ResourceChromeFallbackProps {
/** Title-mode icon (list pages) or breadcrumb-root icon (detail pages). */
icon?: ComponentType<{ className?: string }>
/** Title-mode label. Omit when `breadcrumbs` is supplied. */
title?: string
/**
* Static breadcrumb trail for detail-route fallbacks. The live leaf name is
* unknown at load, so pass the known root crumb(s) plus a terminal `…`
* placeholder; it fills in once the page mounts.
*/
breadcrumbs?: BreadcrumbItem[]
/** Table column headers. Omit on bodies that don't render `Resource.Table` (the table editor). */
columns?: ResourceColumn[]
/** The page's exact header action chips (handlers wired to a no-op). */
actions?: ChromeActionSpec[]
/** Search placeholder. Omit to hide the search box (matches a page with no search). */
searchPlaceholder?: string
/** Paint the Sort chip (its menu never opens during the fallback, so the option list is irrelevant). */
hasSort?: boolean
/** Paint the Filter chip. */
hasFilter?: boolean
}
/**
* Route-transition fallback rendered by each resource route's `loading.tsx`. It
* paints the REAL resource chrome — the header (icon/title or breadcrumbs + the
* page's exact action chips), the options bar (search + the Filter/Sort chips),
* and the table's column headers — with an empty body and no-op handlers, so a
* navigation never shows a blank frame or a skeleton. Only the breadcrumb leaf
* and the row data are unknown at load; everything else matches the loaded page.
*/
export function ResourceChromeFallback({
icon,
title,
breadcrumbs,
columns,
actions,
searchPlaceholder,
hasSort = false,
hasFilter = false,
}: ResourceChromeFallbackProps) {
return (
<Resource>
<Resource.Header
icon={icon}
title={title}
breadcrumbs={breadcrumbs}
actions={actions?.map((action) => ({
text: action.text,
icon: action.icon,
variant: action.variant,
active: action.active,
onSelect: noop,
}))}
/>
<Resource.Options
search={
searchPlaceholder !== undefined
? { value: '', onChange: noop, placeholder: searchPlaceholder }
: undefined
}
sort={hasSort ? { options: [], active: null, onSort: noop } : undefined}
filter={hasFilter ? { content: null } : undefined}
/>
{columns ? <Resource.Table columns={columns} rows={[]} /> : null}
</Resource>
)
}
@@ -0,0 +1,7 @@
export type {
BreadcrumbEditing,
BreadcrumbItem,
DropdownOption,
ResourceAction,
} from './resource-header'
export { ResourceHeader } from './resource-header'
@@ -0,0 +1,657 @@
import {
type ComponentType,
Fragment,
forwardRef,
memo,
type ReactNode,
useEffect,
useRef,
useState,
} from 'react'
import {
Chip,
ChipChevronDown,
chipContentIconClass,
chipGeometryClass,
chipVariants,
cn,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
FloatingTooltip,
POPOVER_ANIMATION_CLASSES,
Popover,
PopoverAnchor,
PopoverContent,
PopoverItem,
PopoverSection,
useFloatingTooltip,
useIsOverflowing,
} from '@sim/emcn'
import { ArrowUpLeft } from 'lucide-react'
import { createPortal } from 'react-dom'
import { InlineRenameInput } from '@/app/workspace/[workspaceId]/components/inline-rename-input'
import { FloatingOverflowText } from '@/app/workspace/[workspaceId]/components/resource/components/floating-overflow-text'
export interface DropdownOption {
label: string
icon?: React.ElementType
onClick: () => void
disabled?: boolean
}
export interface BreadcrumbEditing {
isEditing: boolean
value: string
onChange: (value: string) => void
onSubmit: () => void
onCancel: () => void
/**
* Disables the rename field while the save is in flight, mirroring
* {@link ResourceCellEditing.disabled} on table cells. Threaded from
* `useInlineRename`'s `isSaving`. Optional so existing consumers keep
* working unchanged.
*/
disabled?: boolean
}
export interface BreadcrumbItem {
label: string
icon?: React.ElementType
onClick?: () => void
dropdownItems?: DropdownOption[]
editing?: BreadcrumbEditing
/**
* Marks a non-navigable trailing crumb (e.g. "New Chunk", "Loading...") so the
* header sizes it as the terminal segment rather than the current resource.
*/
terminal?: boolean
}
/**
* The single, strict contract for a top-right header action. Every action renders
* as a {@link Chip} — consumers describe intent through these fields and nothing
* else, so the action row looks identical on every page and cannot drift. Omit
* `variant` for the default chip; use `primary`/`destructive` for emphasis. Express
* a selected/toggle state with `active` (e.g. the Logs/Dashboard view toggle).
*/
export interface ResourceAction {
icon?: ComponentType<{ className?: string }>
text: string
variant?: 'primary' | 'destructive'
active?: boolean
onSelect: () => void
disabled?: boolean
}
interface ResourceHeaderProps {
icon?: React.ElementType
title?: string
breadcrumbs?: BreadcrumbItem[]
/** Strict top-right action chips. List pages use ONLY this. */
actions?: ResourceAction[]
/**
* Supplementary right-aligned content rendered before `actions` — custom
* widgets that cannot collapse into the strict {@link ResourceAction} chip
* contract, e.g. the table editor's run/stop control, an import-progress
* menu, or a create dropdown. Anything that fits the chip contract belongs
* in `actions`; never stuff primary actions in here.
*/
aside?: ReactNode
}
export const ResourceHeader = memo(function ResourceHeader({
icon: Icon,
title,
breadcrumbs,
actions,
aside,
}: ResourceHeaderProps) {
const headerRef = useRef<HTMLDivElement>(null)
/**
* Breadcrumb mode is reserved for nested pages (length > 1). A single-crumb
* "breadcrumb" is just the current page, so it falls through to the static
* title below — keeping the top-left non-interactive and hover-free,
* identical to a title-only page (e.g. the Files root matches the Tables root).
*/
const hasBreadcrumbs = breadcrumbs != null && breadcrumbs.length > 1
const rootCrumb = breadcrumbs?.length === 1 ? breadcrumbs[0] : undefined
const TitleIcon = Icon ?? rootCrumb?.icon
const titleLabel = title ?? rootCrumb?.label
const terminalBreadcrumbIndex =
hasBreadcrumbs && breadcrumbs[breadcrumbs.length - 1].terminal ? breadcrumbs.length - 1 : -1
const currentResourceIndex =
terminalBreadcrumbIndex > -1
? terminalBreadcrumbIndex - 1
: hasBreadcrumbs && breadcrumbs.length > 2
? breadcrumbs.length - 1
: -1
return (
<div
ref={headerRef}
className='flex min-h-[48px] items-center border-[var(--border)] border-b px-4 py-[8.5px]'
>
<div className='flex min-w-0 flex-1 items-center justify-between gap-3'>
<div className='flex min-w-0 flex-1 items-center gap-2 overflow-hidden'>
{hasBreadcrumbs ? (
breadcrumbs.map((crumb, i) => {
const segmentClassName = getBreadcrumbSegmentClassName(
i,
breadcrumbs.length,
currentResourceIndex,
terminalBreadcrumbIndex
)
const LocationIcon = i === 0 ? (crumb.icon ?? Icon) : undefined
/**
* The first crumb on a nested page opens the hover "path" popover
* (back-navigation). Single-crumb roots never reach here — they
* render as the static title above.
*/
const showLocationPopover = LocationIcon != null
return (
<Fragment key={`${crumb.label}-${i}`}>
{i > 0 && (
<span className='mx-0.5 shrink-0 select-none text-[var(--text-icon)] text-sm'>
/
</span>
)}
{showLocationPopover ? (
<BreadcrumbLocationPopover
icon={LocationIcon}
breadcrumbs={breadcrumbs}
className={segmentClassName}
veilBoundaryRef={headerRef}
/>
) : (
<BreadcrumbSegment
icon={LocationIcon ?? crumb.icon}
label={crumb.label}
onClick={crumb.onClick}
dropdownItems={crumb.dropdownItems}
editing={crumb.editing}
className={segmentClassName}
/>
)}
</Fragment>
)
})
) : (
/**
* Root titles are short static labels ("Tables", "Files"), so the
* span is non-shrinkable and the label never truncates — matching
* the `shrink-0` guarantee the breadcrumb root crumb gets from
* {@link getBreadcrumbSegmentClassName}. Without this, the
* `flex-1` left column collapses during transient initial-load
* layout (the JS-driven `--sidebar-width` settling) and the title
* CSS-truncates to "T…" while the `shrink-0` actions hold width.
*/
<span className={cn(chipGeometryClass, 'inline-flex shrink-0 cursor-default')}>
{TitleIcon && <TitleIcon className={chipContentIconClass} />}
{titleLabel && (
<FloatingOverflowText
label={titleLabel}
className='block whitespace-nowrap text-[var(--text-body)] text-sm'
/>
)}
</span>
)}
</div>
{(aside || (actions && actions.length > 0)) && (
<div className='flex shrink-0 items-center'>
{aside}
{actions?.map((action) => (
<Chip
key={action.text}
variant={action.variant}
active={action.active}
leftIcon={action.icon}
onClick={action.onSelect}
disabled={action.disabled}
>
{action.text}
</Chip>
))}
</div>
)}
</div>
</div>
)
})
function getBreadcrumbSegmentClassName(
index: number,
total: number,
currentResourceIndex: number,
terminalBreadcrumbIndex: number
): string {
if (index === terminalBreadcrumbIndex) {
return 'shrink-0 max-w-[10rem]'
}
if (index === 0) {
return 'shrink-0'
}
if (currentResourceIndex > -1) {
if (index === currentResourceIndex) {
return 'min-w-0 flex-[0_1_auto] max-w-[56%]'
}
return 'min-w-0 flex-[0_1_auto] max-w-[34%]'
}
if (total > 2) {
return 'min-w-0 flex-[0_1_auto] max-w-[42%]'
}
return 'min-w-0 flex-[0_1_auto] max-w-[min(32rem,55vw)]'
}
interface BreadcrumbSegmentProps {
icon?: React.ElementType
label: string
onClick?: () => void
dropdownItems?: DropdownOption[]
editing?: BreadcrumbEditing
className?: string
}
const BreadcrumbSegment = memo(function BreadcrumbSegment({
icon: Icon,
label,
onClick,
dropdownItems,
editing,
className,
}: BreadcrumbSegmentProps) {
const { ref: labelRef, node: labelNode, isOverflowing } = useIsOverflowing<HTMLSpanElement>()
const { state: tooltipState, handlers: tooltipHandlers } = useFloatingTooltip((target) =>
isBreadcrumbTextClipped(labelNode.current, target)
)
if (editing?.isEditing) {
return (
<span className={cn(chipGeometryClass, 'inline-flex min-w-0 justify-start', className)}>
{Icon && <Icon className={chipContentIconClass} />}
<InlineRenameInput
value={editing.value}
onChange={editing.onChange}
onSubmit={editing.onSubmit}
onCancel={editing.onCancel}
disabled={editing.disabled}
/>
</span>
)
}
const content = (
<>
{Icon && <Icon className={chipContentIconClass} />}
<BreadcrumbLabel ref={labelRef} isOverflowing={isOverflowing} label={label} />
</>
)
/**
* Interactive crumbs use a plain `<button>` with bare-chip geometry — NEVER
* the Button component, whose buttonVariants inject font-medium /
* rounded-[5px] / justify-center and break chip parity with the static/title
* crumbs.
*/
const triggerClassName = cn(
chipVariants({ flush: true }),
'group min-w-0 max-w-full justify-start'
)
if (dropdownItems && dropdownItems.length > 0) {
return (
<>
<DropdownMenu>
<FloatingTooltip label={label} state={tooltipState} />
<DropdownMenuTrigger asChild>
<button type='button' className={cn(triggerClassName, className)} {...tooltipHandlers}>
{content}
<ChipChevronDown className='ml-auto' />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align='start'>
{dropdownItems.map((item) => {
const ItemIcon = item.icon
return (
<DropdownMenuItem key={item.label} onClick={item.onClick} disabled={item.disabled}>
{ItemIcon && <ItemIcon className='size-[14px]' />}
{item.label}
</DropdownMenuItem>
)
})}
</DropdownMenuContent>
</DropdownMenu>
</>
)
}
if (onClick) {
return (
<>
<FloatingTooltip label={label} state={tooltipState} />
<button
type='button'
className={cn(triggerClassName, className)}
onClick={onClick}
{...tooltipHandlers}
>
{content}
</button>
</>
)
}
return (
<>
<FloatingTooltip label={label} state={tooltipState} />
<span
className={cn(
chipGeometryClass,
'group inline-flex min-w-0 max-w-full cursor-default justify-start',
className
)}
{...tooltipHandlers}
>
{content}
</span>
</>
)
})
interface BreadcrumbLocationPopoverProps {
icon: React.ElementType
breadcrumbs: BreadcrumbItem[]
className?: string
veilBoundaryRef: React.RefObject<HTMLDivElement | null>
}
/**
* Grace period before a hover-out dismisses the path popover. Covers the gap
* the pointer crosses between the trigger and the popover content (and brief
* jitter at their edges); re-entering either within this window cancels the
* close. Standard hover-intent close delay — not tied to any navigation timing.
*/
const POPOVER_CLOSE_DELAY_MS = 120
function BreadcrumbLocationPopover({
icon: Icon,
breadcrumbs,
className,
veilBoundaryRef,
}: BreadcrumbLocationPopoverProps) {
const [open, setOpen] = useState(false)
const closeTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const rootBreadcrumb = breadcrumbs[0]
const cancelScheduledClose = () => {
if (closeTimeoutRef.current) {
clearTimeout(closeTimeoutRef.current)
closeTimeoutRef.current = null
}
}
/**
* Hover-intent open. Driven only by pointer-/keyboard-enter — never by
* pointer movement. This is what makes the popover dismiss cleanly on a
* click-to-navigate: a stationary click fires no enter event, so once
* {@link navigateAndClose} sets `open` false nothing re-opens it before the
* route swaps. (A move-driven open would re-fire under the resting cursor and
* flash the popover/veil back in mid-navigation.)
*/
const openPopover = () => {
cancelScheduledClose()
setOpen(true)
}
const scheduleClose = () => {
cancelScheduledClose()
closeTimeoutRef.current = setTimeout(() => {
setOpen(false)
closeTimeoutRef.current = null
}, POPOVER_CLOSE_DELAY_MS)
}
/**
* Closes the popover up front, then runs the crumb's handler. Closing first
* lets the veil fade and the popover play its exit animation instead of
* snapping away when navigation unmounts the header.
*/
const navigateAndClose = (onClick?: () => void) => {
if (!onClick) return
cancelScheduledClose()
setOpen(false)
onClick()
}
useEffect(() => {
return () => {
if (closeTimeoutRef.current) clearTimeout(closeTimeoutRef.current)
}
}, [])
return (
<>
<LocationFocusVeil visible={open} boundaryRef={veilBoundaryRef} />
<Popover size='md' open={open} onOpenChange={setOpen}>
<PopoverAnchor asChild>
<button
type='button'
aria-label={rootBreadcrumb?.label ?? 'Path'}
onClick={() => navigateAndClose(rootBreadcrumb?.onClick)}
onFocus={openPopover}
onBlur={scheduleClose}
onMouseEnter={openPopover}
onMouseLeave={scheduleClose}
className={cn(
chipVariants({ flush: true }),
'max-w-none gap-1.5 px-2 transition-colors',
open && 'relative z-[var(--z-popover)]',
className
)}
>
<span className='relative inline-grid size-[16px] shrink-0 place-items-center'>
<Icon className='col-start-1 row-start-1 size-[16px] text-[var(--text-icon)] opacity-100 blur-0 transition-[opacity,filter,transform] duration-200 ease-in-out group-hover:scale-[0.25] group-hover:opacity-0 group-hover:blur-[2px] group-focus-visible:scale-[0.25] group-focus-visible:opacity-0 group-focus-visible:blur-[2px] motion-reduce:transition-none' />
<ArrowUpLeft
strokeWidth={1.55}
className='col-start-1 row-start-1 size-[16px] scale-[0.25] text-[var(--text-icon)] opacity-0 blur-[2px] transition-[opacity,filter,transform] duration-200 ease-in-out group-hover:scale-100 group-hover:opacity-100 group-hover:blur-0 group-focus-visible:scale-100 group-focus-visible:opacity-100 group-focus-visible:blur-0 motion-reduce:transition-none'
/>
</span>
{rootBreadcrumb?.label && (
<span className='shrink-0 truncate text-[var(--text-body)] text-sm'>
{rootBreadcrumb.label}
</span>
)}
</button>
</PopoverAnchor>
<PopoverContent
side='bottom'
align='start'
sideOffset={6}
minWidth={220}
maxWidth={300}
maxHeight={420}
border
className={cn(
POPOVER_ANIMATION_CLASSES,
'bg-[var(--bg)] p-1.5 text-[var(--text-body)] shadow-sm'
)}
onMouseEnter={openPopover}
onMouseLeave={scheduleClose}
>
<PopoverSection className='px-1.5 py-0.5 text-[var(--text-muted)] text-xs'>
<span className='inline-flex items-center gap-1'>
<span>Path</span>
<span className='opacity-70'>/</span>
</span>
</PopoverSection>
<div className='flex flex-col gap-0.5'>
{breadcrumbs.map((crumb, index) => (
<BreadcrumbLocationItem
key={`${crumb.label}-${index}`}
icon={crumb.icon || (index === 0 ? Icon : undefined)}
label={crumb.label}
onClick={crumb.onClick ? () => navigateAndClose(crumb.onClick) : undefined}
active={index === breadcrumbs.length - 1}
/>
))}
</div>
</PopoverContent>
</Popover>
</>
)
}
function LocationFocusVeil({
visible,
boundaryRef,
}: {
visible: boolean
boundaryRef: React.RefObject<HTMLDivElement | null>
}) {
const [bounds, setBounds] = useState({ top: 0, left: 0 })
/**
* Portal-mount gate. The veil must render `null` on BOTH the server render
* and the first client (hydration) render — branching on
* `typeof document === 'undefined'` made the two renders diverge, which
* failed hydration and forced React to regenerate the whole page tree on
* the client (a visible header flash during load).
*/
const [mounted, setMounted] = useState(false)
useEffect(() => {
setMounted(true)
}, [])
useEffect(() => {
if (!visible) return
const updateBounds = () => {
const boundary = boundaryRef.current
if (!boundary) return
const rect = boundary.getBoundingClientRect()
setBounds({ top: rect.top, left: rect.left })
}
updateBounds()
window.addEventListener('resize', updateBounds)
window.addEventListener('scroll', updateBounds, true)
return () => {
window.removeEventListener('resize', updateBounds)
window.removeEventListener('scroll', updateBounds, true)
}
}, [boundaryRef, visible])
if (!mounted) return null
return createPortal(
<div
aria-hidden='true'
className={cn(
'pointer-events-none fixed right-0 bottom-0 z-[calc(var(--z-popover)-1)] bg-[var(--bg)] transition-opacity duration-150 ease-out motion-reduce:transition-none',
visible ? 'opacity-60' : 'opacity-0'
)}
style={{ top: bounds.top, left: bounds.left }}
/>,
document.body
)
}
interface BreadcrumbLocationItemProps {
icon?: React.ElementType
label: string
onClick?: () => void
active: boolean
}
function BreadcrumbLocationItem({
icon: Icon,
label,
onClick,
active,
}: BreadcrumbLocationItemProps) {
const labelContent = (
<>
<span className='flex size-[18px] shrink-0 items-center justify-center'>
{Icon ? (
<Icon className='size-3 text-[var(--text-icon)]' />
) : (
<span className='size-1.5 rounded-full bg-[var(--text-muted)]' />
)}
</span>
<span className='min-w-0 flex-1 truncate text-left'>{label}</span>
</>
)
if (onClick) {
return (
<PopoverItem
active={active}
onClick={onClick}
className='h-7 items-center gap-1.5 px-1.5 py-0 text-xs'
>
{labelContent}
</PopoverItem>
)
}
return (
<div
className={cn(
'flex h-7 min-w-0 items-center gap-1.5 rounded-lg px-1.5 text-[var(--text-body)] text-xs',
active && 'bg-[var(--surface-active)]'
)}
>
{labelContent}
</div>
)
}
const BreadcrumbLabel = memo(
forwardRef<HTMLSpanElement, BreadcrumbLabelProps>(function BreadcrumbLabel(
{ isOverflowing, label },
ref
) {
return (
<span
ref={ref}
className={cn(
'min-w-0 truncate text-[var(--text-body)]',
isOverflowing &&
'[mask-image:linear-gradient(to_right,black_calc(100%-18px),transparent)] group-hover:[mask-image:none] group-focus-visible:[mask-image:none]'
)}
>
{label}
</span>
)
})
)
interface BreadcrumbLabelProps {
isOverflowing: boolean
label: string
}
function isBreadcrumbTextClipped(
labelElement: HTMLSpanElement | null,
triggerElement: HTMLElement
): boolean {
if (!labelElement) return false
const labelWidth = labelElement.getBoundingClientRect().width
const triggerWidth = triggerElement.getBoundingClientRect().width
const visibleLabelWidth = Math.min(labelWidth, triggerWidth)
return (
labelElement.scrollWidth > labelElement.clientWidth + 1 ||
triggerElement.scrollWidth > triggerElement.clientWidth + 1 ||
labelElement.scrollWidth > visibleLabelWidth + 1
)
}
@@ -0,0 +1,9 @@
export type {
ColumnOption,
FilterConfig,
FilterTag,
SearchConfig,
SearchTag,
SortConfig,
} from './resource-options'
export { ResourceOptions, SortDropdown } from './resource-options'
@@ -0,0 +1,300 @@
import { memo, type ReactNode, useState } from 'react'
import * as PopoverPrimitive from '@radix-ui/react-popover'
import {
ArrowDown,
ArrowUp,
ArrowUpDown,
Chip,
cn,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
ListFilter,
POPOVER_ANIMATION_CLASSES,
Search,
X,
} from '@sim/emcn'
import { FloatingOverflowText } from '@/app/workspace/[workspaceId]/components/resource/components/floating-overflow-text'
const SEARCH_ICON = (
<Search className='pointer-events-none size-[14px] shrink-0 text-[var(--text-muted)]' />
)
const RESOURCE_MENU_EDGE_OFFSET = 6
type SortDirection = 'asc' | 'desc'
export interface ColumnOption {
id: string
label: string
type?: string
icon?: React.ElementType
}
export interface SortConfig {
options: ColumnOption[]
active: { column: string; direction: SortDirection } | null
onSort: (column: string, direction: SortDirection) => void
onClear?: () => void
}
export interface FilterTag {
label: string
onRemove: () => void
}
export interface SearchTag {
label: string
value: string
onRemove: () => void
}
export interface SearchConfig {
value: string
onChange: (value: string) => void
placeholder?: string
inputRef?: React.RefObject<HTMLInputElement | null>
onKeyDown?: (e: React.KeyboardEvent<HTMLInputElement>) => void
onFocus?: () => void
onBlur?: () => void
tags?: SearchTag[]
highlightedTagIndex?: number | null
onClearAll?: () => void
dropdown?: ReactNode
dropdownRef?: React.RefObject<HTMLDivElement | null>
}
/**
* The Filter control has two shapes, picked by `mode`:
* - `popover` (default): list pages pass the filter controls as `content`; the
* button opens them in a popover. `active` highlights the button.
* - `toggle`: detail views (e.g. the table editor) that render their filter as a
* separate panel toggle the button instead of opening a popover.
*/
export type FilterConfig =
| { mode?: 'popover'; content: ReactNode; active?: boolean }
| { mode: 'toggle'; active: boolean; onToggle: () => void }
interface ResourceOptionsProps {
search?: SearchConfig
sort?: SortConfig
filter?: FilterConfig
filterTags?: FilterTag[]
/**
* Lightweight control rendered immediately to the LEFT of the filter/sort
* cluster, forming one group with it — e.g. the knowledge view's
* connected-source badge or the table editor's embedded run/stop control. With
* a search the group is pushed right (opposite the search); without one it
* stays left-aligned (the embedded table editor). Keep it to badges/status
* widgets; primary actions belong in the header's `actions`.
*/
aside?: ReactNode
}
export const ResourceOptions = memo(function ResourceOptions({
search,
sort,
filter,
filterTags,
aside,
}: ResourceOptionsProps) {
/**
* Coordinates the Filter popover and Sort menu as a single menu bar: clicking
* one while the other is open switches to it in a single click. Functional
* updates make the close→open ordering race-proof, so whichever menu the click
* targets wins regardless of which `onOpenChange` fires first.
*/
const [openMenu, setOpenMenu] = useState<'filter' | 'sort' | null>(null)
const isToggleFilter = filter?.mode === 'toggle'
const popoverFilter = filter && filter.mode !== 'toggle' ? filter : null
const hasContent = search || sort || filter || aside || (filterTags && filterTags.length > 0)
if (!hasContent) return null
return (
<div className={cn('border-[var(--border)] border-b py-2.5', search ? 'px-6' : 'px-4')}>
<div className='flex items-center'>
{search && <SearchSection search={search} />}
<div className={cn('flex shrink-0 items-center gap-1.5', search && 'ml-auto')}>
{aside}
<div className='flex items-center'>
{filterTags?.map((tag) => (
<Chip key={tag.label} rightIcon={X} onClick={tag.onRemove}>
{tag.label}
</Chip>
))}
{isToggleFilter && filter.mode === 'toggle' ? (
<Chip active={filter.active} leftIcon={ListFilter} onClick={filter.onToggle}>
Filter
</Chip>
) : popoverFilter ? (
<PopoverPrimitive.Root
open={openMenu === 'filter'}
onOpenChange={(open) =>
setOpenMenu((current) =>
open ? 'filter' : current === 'filter' ? null : current
)
}
>
<PopoverPrimitive.Anchor asChild>
<div className='flex items-center'>
<PopoverPrimitive.Trigger asChild>
<Chip active={popoverFilter.active} leftIcon={ListFilter}>
Filter
</Chip>
</PopoverPrimitive.Trigger>
{sort && (
<SortDropdown
config={sort}
open={openMenu === 'sort'}
onOpenChange={(open) =>
setOpenMenu((current) =>
open ? 'sort' : current === 'sort' ? null : current
)
}
/>
)}
</div>
</PopoverPrimitive.Anchor>
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
align='end'
alignOffset={RESOURCE_MENU_EDGE_OFFSET}
collisionPadding={6}
sideOffset={6}
className={cn(
POPOVER_ANIMATION_CLASSES,
'z-50 w-fit origin-[--radix-popover-content-transform-origin] rounded-xl border border-[var(--border)] bg-[var(--bg)] shadow-sm'
)}
>
{popoverFilter.content}
</PopoverPrimitive.Content>
</PopoverPrimitive.Portal>
</PopoverPrimitive.Root>
) : null}
{sort && (isToggleFilter || !popoverFilter) && <SortDropdown config={sort} />}
</div>
</div>
</div>
</div>
)
})
const SearchSection = memo(function SearchSection({ search }: { search: SearchConfig }) {
return (
<div className='relative flex flex-1 items-center gap-1.5'>
{SEARCH_ICON}
<div className='flex flex-1 items-center gap-1.5 overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden'>
{search.tags?.map((tag, i) => (
<Chip
key={`${tag.label}-${tag.value}`}
rightIcon={X}
onClick={tag.onRemove}
active={search.highlightedTagIndex === i}
className='max-w-[280px] shrink-0'
>
<FloatingOverflowText label={`${tag.label}: ${tag.value}`} className='block truncate'>
{tag.label}: {tag.value}
</FloatingOverflowText>
</Chip>
))}
<input
ref={search.inputRef}
type='text'
value={search.value}
onChange={(e) => search.onChange(e.target.value)}
onKeyDown={search.onKeyDown}
onFocus={search.onFocus}
onBlur={search.onBlur}
placeholder={search.tags?.length ? '' : (search.placeholder ?? 'Search...')}
className='min-w-[80px] flex-1 bg-transparent py-1 text-[var(--text-body)] text-sm outline-none placeholder:text-[var(--text-muted)]'
/>
</div>
{search.tags?.length || search.value ? (
<button
type='button'
className='mr-0.5 flex size-[14px] shrink-0 items-center justify-center text-[var(--text-muted)] transition-colors hover-hover:text-[var(--text-body)]'
onClick={search.onClearAll ?? (() => search.onChange(''))}
>
<span className='text-caption'></span>
</button>
) : null}
{search.dropdown && (
<div
ref={search.dropdownRef}
className='absolute top-full left-0 z-50 mt-1.5 w-full rounded-lg border border-[var(--border)] bg-[var(--bg)] shadow-sm'
>
{search.dropdown}
</div>
)}
</div>
)
})
interface SortDropdownProps {
config: SortConfig
/** Controlled open state — omit for standalone (uncontrolled) usage. */
open?: boolean
/** Controlled open-change handler, paired with {@link SortDropdownProps.open}. */
onOpenChange?: (open: boolean) => void
}
export const SortDropdown = memo(function SortDropdown({
config,
open,
onOpenChange,
}: SortDropdownProps) {
const { options, active, onSort, onClear } = config
return (
<DropdownMenu modal={false} open={open} onOpenChange={onOpenChange}>
<DropdownMenuTrigger asChild>
<Chip active={Boolean(active)} leftIcon={ArrowUpDown}>
Sort
</Chip>
</DropdownMenuTrigger>
<DropdownMenuContent
align='end'
alignOffset={RESOURCE_MENU_EDGE_OFFSET}
className='max-h-[var(--radix-dropdown-menu-content-available-height,400px)]'
>
{active && onClear && (
<>
<DropdownMenuItem onSelect={onClear}>
<X />
Clear sort
</DropdownMenuItem>
<DropdownMenuSeparator />
</>
)}
{options.map((option) => {
const isActive = active?.column === option.id
const Icon = option.icon
const DirectionIcon = isActive ? (active.direction === 'asc' ? ArrowUp : ArrowDown) : null
return (
<DropdownMenuItem
key={option.id}
onSelect={() => {
if (isActive) {
onSort(option.id, active.direction === 'asc' ? 'desc' : 'asc')
} else {
onSort(option.id, 'desc')
}
}}
>
{Icon && <Icon />}
{option.label}
{DirectionIcon && (
<DirectionIcon className='ml-auto size-[12px] text-[var(--text-tertiary)]' />
)}
</DropdownMenuItem>
)
})}
</DropdownMenuContent>
</DropdownMenu>
)
})
@@ -0,0 +1,81 @@
import type { ResourceCell } from '@/app/workspace/[workspaceId]/components/resource/resource'
const SECOND = 1000
const MINUTE = 60 * SECOND
const HOUR = 60 * MINUTE
const DAY = 24 * HOUR
const ORDINAL_RULES: [number, string][] = [
[1, 'st'],
[2, 'nd'],
[3, 'rd'],
]
function ordinalSuffix(day: number): string {
if (day >= 11 && day <= 13) return 'th'
return ORDINAL_RULES.find(([d]) => day % 10 === d)?.[1] ?? 'th'
}
const MONTH_NAMES = [
'January',
'February',
'March',
'April',
'May',
'June',
'July',
'August',
'September',
'October',
'November',
'December',
] as const
function formatFullDate(date: Date): string {
const month = MONTH_NAMES[date.getMonth()]
const day = date.getDate()
const year = date.getFullYear()
return `${month} ${day}${ordinalSuffix(day)}, ${year}`
}
function pluralize(value: number, unit: string): string {
return `${value} ${unit}${value === 1 ? '' : 's'}`
}
/**
* Formats a date string into a human-friendly relative time label.
*
* - Within ~1 minute of now: "Now"
* - Under 1 hour: "X minute(s) ago" / "X minute(s)"
* - Under 24 hours: "X hour(s) ago" / "X hour(s)"
* - Under 2 days: "X day(s) ago" / "X day(s)"
* - Beyond 2 days: full date (e.g. "March 4th, 2026")
*/
export function timeCell(dateValue: string | Date | null | undefined): ResourceCell {
if (!dateValue) return { label: null }
const date = dateValue instanceof Date ? dateValue : new Date(dateValue)
const now = new Date()
const diff = now.getTime() - date.getTime()
const absDiff = Math.abs(diff)
const isPast = diff > 0
if (absDiff < MINUTE) return { label: 'Now' }
if (absDiff < HOUR) {
const minutes = Math.floor(absDiff / MINUTE)
return { label: isPast ? `${pluralize(minutes, 'minute')} ago` : pluralize(minutes, 'minute') }
}
if (absDiff < DAY) {
const hours = Math.floor(absDiff / HOUR)
return { label: isPast ? `${pluralize(hours, 'hour')} ago` : pluralize(hours, 'hour') }
}
if (absDiff < 2 * DAY) {
const days = Math.floor(absDiff / DAY)
return { label: isPast ? `${pluralize(days, 'day')} ago` : pluralize(days, 'day') }
}
return { label: formatFullDate(date) }
}
@@ -0,0 +1,701 @@
'use client'
import {
type CSSProperties,
type DragEvent,
memo,
type ReactNode,
type RefObject,
useCallback,
useEffect,
useImperativeHandle,
useMemo,
useRef,
useState,
} from 'react'
import {
Button,
Checkbox,
cellIconNodeClass,
chipContentGap,
chipContentLabelClass,
cn,
Loader,
} from '@sim/emcn'
import { useVirtualizer } from '@tanstack/react-virtual'
import { ChevronLeft, ChevronRight } from 'lucide-react'
import { InlineRenameInput } from '@/app/workspace/[workspaceId]/components/inline-rename-input'
import { FloatingOverflowText } from '@/app/workspace/[workspaceId]/components/resource/components/floating-overflow-text'
import { ResourceHeader } from '@/app/workspace/[workspaceId]/components/resource/components/resource-header'
import { ResourceOptions } from '@/app/workspace/[workspaceId]/components/resource/components/resource-options'
export interface ResourceColumn {
id: string
header: string
widthMultiplier?: number
}
export interface ResourceCellEditing {
value: string
onChange: (value: string) => void
onSubmit: () => void
onCancel: () => void
/**
* Disables the rename field while the save is in flight, mirroring the
* sidebar's `disabled={isRenaming}`. Threaded from `useInlineRename`'s
* `isSaving`. Optional so existing consumers keep working unchanged.
*/
disabled?: boolean
}
export interface ResourceCell {
icon?: ReactNode
label?: string | null
content?: ReactNode
/**
* When set, the cell renders an inline rename field inside the canonical cell
* chrome (icon + {@link InlineRenameInput}). Consumers pass structured handlers
* instead of hand-rolling a `content` node, so every rename cell matches the
* resting cell exactly (same gap, weight, icon size).
*/
editing?: ResourceCellEditing
}
export interface ResourceRow {
id: string
cells: Record<string, ResourceCell>
}
export interface SelectableConfig {
selectedIds: Set<string>
onSelectRow: (id: string, checked: boolean, shiftKey?: boolean) => void
onSelectAll: (checked: boolean) => void
isAllSelected: boolean
disabled?: boolean
}
export interface RowDragDropConfig {
activeDropTargetId?: string | null
draggedRowIds?: Set<string>
isAnyDragActive?: boolean
isRowDraggable?: (rowId: string) => boolean
isRowDropTarget?: (rowId: string) => boolean
onDragStart?: (e: DragEvent<HTMLDivElement>, rowId: string) => void
onDragOver?: (e: DragEvent<HTMLDivElement>, rowId: string) => void
onDragLeave?: (e: DragEvent<HTMLDivElement>, rowId: string) => void
onDrop?: (e: DragEvent<HTMLDivElement>, rowId: string) => void
onDragEnd?: (e: DragEvent<HTMLDivElement>, rowId: string) => void
}
export interface PaginationConfig {
currentPage: number
totalPages: number
onPageChange: (page: number) => void
}
export const EMPTY_CELL_PLACEHOLDER = '—'
/**
* Seed height (px) for each virtualized row before it is measured. Every
* consumer renders single-line `py-2.5` cells, so this matches the resting row
* height closely; `measureElement` then corrects each row to its exact pixel
* height after mount, so the estimate only affects pre-measure scroll math.
*/
const ROW_HEIGHT_ESTIMATE = 41 as const
/** Rows rendered above/below the viewport to avoid blank flashes on fast scroll. */
const ROW_OVERSCAN = 8 as const
const CHECKBOX_COLUMN_WIDTH = '52px'
/**
* Builds the shared CSS grid track list for the header and every body row from
* the same first-column-weighted ratios the legacy `<colgroup>` used, so the
* virtualized grid layout reproduces the exact column widths. The checkbox
* column, when present, is a fixed leading track.
*/
function buildGridTemplateColumns(columns: ResourceColumn[], hasCheckbox: boolean): string {
const weights = columns.map(
(col, colIdx) => (colIdx === 0 ? 2.5 : 1.0) * (col.widthMultiplier ?? 1)
)
const total = weights.reduce((s, w) => s + w, 0)
const tracks = columns.map((_, colIdx) => `minmax(0, ${(weights[colIdx] / total).toFixed(6)}fr)`)
return hasCheckbox ? `${CHECKBOX_COLUMN_WIDTH} ${tracks.join(' ')}` : tracks.join(' ')
}
interface ResourceProps {
children: ReactNode
onContextMenu?: (e: React.MouseEvent) => void
}
/**
* Compound page shell for resource pages (tables, files, knowledge, schedules,
* logs, and the detail editors). Consumers import only `Resource` and fill the
* defined slots as children:
*
* - `Resource.Header` — required, the top bar (title/breadcrumbs + action chips)
* - `Resource.Options` — required, the search/filter/sort toolbar
* - `Resource.Table` — optional; swap for any custom body (dashboard, grid, …)
*
* Invariant: the shell renders identically for every consumer. Consumers supply
* content (columns, rows, cells) and behavior (handlers, configs) only — no
* prop changes the shell's chrome, spacing, or structure. The only sanctioned
* variation is replacing `Resource.Table` with a custom body.
*
* The shell owns the fixed column layout and is the positioning context for
* absolutely-positioned overlays (action bars, slide-out sidebars); the
* children own their own chrome.
*/
function ResourceRoot({ children, onContextMenu }: ResourceProps) {
return (
<div
className='relative flex h-full flex-1 flex-col overflow-hidden bg-[var(--bg)]'
onContextMenu={onContextMenu}
>
{children}
</div>
)
}
/**
* Imperative handle for `Resource.Table`. Lets a consumer drive virtualizer-aware
* scrolling — required for keyboard navigation, since a `querySelector` on the
* selected row's DOM node silently no-ops once that row is windowed out.
*/
export interface ResourceTableHandle {
/** Scroll the row with the given id into view via the virtualizer (works even when the row is not in the DOM). */
scrollToRow: (rowId: string) => void
}
interface ResourceTableProps {
columns: ResourceColumn[]
rows: ResourceRow[]
selectedRowId?: string | null
/** Optional imperative handle exposing {@link ResourceTableHandle} (e.g. for keyboard-nav scrolling). */
apiRef?: RefObject<ResourceTableHandle | null>
/**
* Window the row list with `@tanstack/react-virtual`, keeping only the visible
* slice in the DOM. Opt-in because it removes off-screen rows — consumers that
* depend on every row being mounted (e.g. drag-and-drop drop targets) must stay
* on the full-DOM path. Enable it only for unbounded, accumulating lists (logs).
*/
virtualized?: boolean
selectable?: SelectableConfig
rowDragDrop?: RowDragDropConfig
onRowClick?: (rowId: string) => void
onRowHover?: (rowId: string) => void
onRowContextMenu?: (e: React.MouseEvent, rowId: string) => void
onLoadMore?: () => void
hasMore?: boolean
isLoadingMore?: boolean
pagination?: PaginationConfig
/**
* Sanctioned overlay slot. Rendered absolutely against the table region
* (action bars, slide-out sidebars, drop targets). The overlay owns its own
* chrome and positioning; it never alters the table's rendering.
*/
overlay?: ReactNode
}
/**
* Data table body, module-private and exposed only as `Resource.Table` — the
* compound member is the sole way consumers render it.
*
* Chrome guarantee: the table region and column headers render unconditionally —
* no prop or row state (empty, loading, error) ever drops them. Structural
* additions (checkbox column, load-more sentinel, pagination bar) are driven
* purely by which configs the consumer supplies and always render the canonical
* chrome.
*
* The table is built from `<div>`s carrying explicit ARIA roles (`table`,
* `rowgroup`, `row`, `columnheader`, `cell`) rather than native table elements:
* the rows use CSS grid for column alignment, and `display: grid` on a native
* `<table>` strips its implicit table semantics, so the roles are declared
* directly. Column widths come from a shared grid track list (see
* {@link buildGridTemplateColumns}) reproducing the legacy `<colgroup>` ratios.
* When `virtualized`, the body windows with `@tanstack/react-virtual` so only
* the visible row slice is in the DOM, bounding DOM size and memory on lists
* that accumulate many pages.
*/
const ResourceTable = memo(function ResourceTable({
columns,
rows,
selectedRowId,
apiRef,
virtualized = false,
selectable,
rowDragDrop,
onRowClick,
onRowHover,
onRowContextMenu,
onLoadMore,
hasMore,
isLoadingMore,
pagination,
overlay,
}: ResourceTableProps) {
const scrollRef = useRef<HTMLDivElement>(null)
const loadMoreRef = useRef<HTMLDivElement>(null)
const [contextMenuRowId, setContextMenuRowId] = useState<string | null>(null)
const wrappedOnRowContextMenu = useCallback(
(e: React.MouseEvent, rowId: string) => {
setContextMenuRowId(rowId)
onRowContextMenu?.(e, rowId)
},
[onRowContextMenu]
)
useEffect(() => {
if (!contextMenuRowId) return
const clear = () => setContextMenuRowId(null)
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
document.removeEventListener('keydown', handleKeyDown)
clear()
}
}
const timeoutId = setTimeout(() => {
document.addEventListener('pointerdown', clear, { once: true })
document.addEventListener('keydown', handleKeyDown)
}, 0)
return () => {
clearTimeout(timeoutId)
document.removeEventListener('pointerdown', clear)
document.removeEventListener('keydown', handleKeyDown)
}
}, [contextMenuRowId])
useEffect(() => {
if (!onLoadMore || !hasMore) return
const el = loadMoreRef.current
if (!el) return
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) onLoadMore()
},
{ rootMargin: '200px' }
)
observer.observe(el)
return () => observer.disconnect()
}, [onLoadMore, hasMore])
const hasCheckbox = selectable != null
const handleSelectAll = useCallback(
(checked: boolean | 'indeterminate') => {
selectable?.onSelectAll(checked as boolean)
},
[selectable]
)
const gridTemplateColumns = useMemo(
() => buildGridTemplateColumns(columns, hasCheckbox),
[columns, hasCheckbox]
)
/**
* Windows the row list so only the visible slice (plus overscan) is in the
* DOM, bounding DOM size and memory regardless of how many pages a consumer
* accumulates. Rows are measured via {@link rowVirtualizer.measureElement} so
* any single-line height variance stays pixel-exact.
*/
const rowVirtualizer = useVirtualizer({
count: rows.length,
getScrollElement: () => scrollRef.current,
estimateSize: () => ROW_HEIGHT_ESTIMATE,
overscan: ROW_OVERSCAN,
getItemKey: (index) => rows[index].id,
})
useImperativeHandle(
apiRef,
() => ({
scrollToRow: (rowId: string) => {
const index = rows.findIndex((row) => row.id === rowId)
if (index >= 0) rowVirtualizer.scrollToIndex(index, { align: 'auto' })
},
}),
[rows, rowVirtualizer]
)
const virtualRows = rowVirtualizer.getVirtualItems()
const totalSize = rowVirtualizer.getTotalSize()
return (
<div className='relative flex min-h-0 flex-1 flex-col overflow-hidden'>
<div ref={scrollRef} className='min-h-0 flex-1 overflow-auto overscroll-none'>
<div role='table' className='grid w-full text-small'>
<div
role='rowgroup'
className='sticky top-0 z-10 grid bg-[var(--bg)] shadow-[inset_0_-1px_0_var(--border)]'
>
<div role='row' className='grid' style={{ gridTemplateColumns }}>
{hasCheckbox && (
<div
role='columnheader'
className='flex h-10 items-center py-1.5 pr-0 pl-5 text-left'
>
<Checkbox
size='sm'
checked={selectable.isAllSelected}
onCheckedChange={handleSelectAll}
disabled={selectable.disabled}
aria-label='Select all'
/>
</div>
)}
{columns.map((col) => (
<div
key={col.id}
role='columnheader'
className='flex h-10 min-w-0 items-center px-6 py-1.5 text-left font-normal text-[var(--text-muted)] text-small'
>
<span className='min-w-0 truncate'>{col.header}</span>
</div>
))}
</div>
</div>
<div
role='rowgroup'
className={cn('grid', virtualized && 'relative')}
style={virtualized ? { height: totalSize } : undefined}
>
{virtualized
? virtualRows.map((virtualRow) => {
const row = rows[virtualRow.index]
return (
<DataRow
key={virtualRow.key}
ref={rowVirtualizer.measureElement}
dataIndex={virtualRow.index}
translateY={virtualRow.start}
gridTemplateColumns={gridTemplateColumns}
row={row}
columns={columns}
selectedRowId={selectedRowId}
selectable={selectable}
rowDragDrop={rowDragDrop}
onRowClick={onRowClick}
onRowHover={onRowHover}
onRowContextMenu={onRowContextMenu ? wrappedOnRowContextMenu : undefined}
isContextMenuTarget={contextMenuRowId === row.id}
hasCheckbox={hasCheckbox}
/>
)
})
: rows.map((row) => (
<DataRow
key={row.id}
gridTemplateColumns={gridTemplateColumns}
row={row}
columns={columns}
selectedRowId={selectedRowId}
selectable={selectable}
rowDragDrop={rowDragDrop}
onRowClick={onRowClick}
onRowHover={onRowHover}
onRowContextMenu={onRowContextMenu ? wrappedOnRowContextMenu : undefined}
isContextMenuTarget={contextMenuRowId === row.id}
hasCheckbox={hasCheckbox}
/>
))}
</div>
</div>
{hasMore && (
<div ref={loadMoreRef} className='flex items-center justify-center py-3'>
{isLoadingMore && (
<Loader className='size-[16px] text-[var(--text-secondary)]' animate />
)}
</div>
)}
</div>
{overlay}
{pagination && pagination.totalPages > 1 && (
<Pagination
currentPage={pagination.currentPage}
totalPages={pagination.totalPages}
onPageChange={pagination.onPageChange}
/>
)}
</div>
)
})
const Pagination = memo(function Pagination({
currentPage,
totalPages,
onPageChange,
}: {
currentPage: number
totalPages: number
onPageChange: (page: number) => void
}) {
return (
<div className='flex items-center justify-center border-[var(--border)] border-t bg-[var(--bg)] px-4 py-2.5'>
<div className='flex items-center gap-1'>
<Button
variant='ghost'
onClick={() => onPageChange(currentPage - 1)}
disabled={currentPage <= 1}
>
<ChevronLeft className='size-3.5' />
</Button>
<div className='mx-3 flex items-center gap-4'>
{Array.from({ length: Math.min(totalPages, 5) }, (_, i) => {
let page: number
if (totalPages <= 5) {
page = i + 1
} else if (currentPage <= 3) {
page = i + 1
} else if (currentPage >= totalPages - 2) {
page = totalPages - 4 + i
} else {
page = currentPage - 2 + i
}
if (page < 1 || page > totalPages) return null
return (
<Button
key={page}
type='button'
variant='ghost'
onClick={() => onPageChange(page)}
className={cn(
'h-auto p-0 font-medium text-sm transition-colors hover-hover:bg-transparent hover-hover:text-[var(--text-body)]',
page === currentPage ? 'text-[var(--text-body)]' : 'text-[var(--text-secondary)]'
)}
>
{page}
</Button>
)
})}
</div>
<Button
variant='ghost'
onClick={() => onPageChange(currentPage + 1)}
disabled={currentPage >= totalPages}
>
<ChevronRight className='size-3.5' />
</Button>
</div>
</div>
)
})
interface CellContentProps {
/** Pre-rendered icon node (svg/img/span avatar); auto-sized to the chip icon size. */
icon?: ReactNode
label: string
content?: ReactNode
editing?: ResourceCellEditing
}
const CellContent = memo(function CellContent({ icon, label, content, editing }: CellContentProps) {
if (editing) {
return (
<span className={cn('flex min-w-0 items-center', chipContentGap)}>
{icon && <span className={cellIconNodeClass}>{icon}</span>}
<InlineRenameInput
value={editing.value}
onChange={editing.onChange}
onSubmit={editing.onSubmit}
onCancel={editing.onCancel}
disabled={editing.disabled}
/>
</span>
)
}
if (content) return <>{content}</>
return (
<span className={cn('flex min-w-0 items-center', chipContentGap)}>
{icon && <span className={cellIconNodeClass}>{icon}</span>}
<FloatingOverflowText label={label} className={cn('block', chipContentLabelClass)} />
</span>
)
})
interface DataRowProps {
row: ResourceRow
columns: ResourceColumn[]
selectedRowId?: string | null
selectable?: SelectableConfig
rowDragDrop?: RowDragDropConfig
onRowClick?: (rowId: string) => void
onRowHover?: (rowId: string) => void
onRowContextMenu?: (e: React.MouseEvent, rowId: string) => void
isContextMenuTarget?: boolean
hasCheckbox: boolean
/** CSS grid track list shared with the header so columns stay aligned. */
gridTemplateColumns: string
/**
* Virtual row offset. When set, the row is absolutely positioned within the
* sized tbody (windowed mode); when omitted, the row renders in normal grid
* flow (full-DOM mode).
*/
translateY?: number
/** Virtual index, consumed by the virtualizer's `measureElement` ref (windowed mode only). */
dataIndex?: number
/** Forwarded from the virtualizer so each mounted row is measured exactly (windowed mode only). */
ref?: (node: HTMLDivElement | null) => void
}
const DataRow = memo(function DataRow({
row,
columns,
selectedRowId,
selectable,
rowDragDrop,
onRowClick,
onRowHover,
onRowContextMenu,
isContextMenuTarget,
hasCheckbox,
gridTemplateColumns,
translateY,
dataIndex,
ref,
}: DataRowProps) {
const isSelected = selectable?.selectedIds.has(row.id) ?? false
const isDraggable = rowDragDrop?.isRowDraggable?.(row.id) ?? false
const isDropTarget = rowDragDrop?.isRowDropTarget?.(row.id) ?? false
const isActiveDropTarget = rowDragDrop?.activeDropTargetId === row.id
const isDragging = rowDragDrop?.draggedRowIds?.has(row.id) ?? false
const isAnyDragActive = rowDragDrop?.isAnyDragActive ?? false
const hasActiveSelection = (selectable?.selectedIds.size ?? 0) > 0
const handleClick = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
if (
selectable &&
!selectable.disabled &&
(e.shiftKey || e.metaKey || e.ctrlKey || !onRowClick || hasActiveSelection)
) {
e.preventDefault()
selectable.onSelectRow(row.id, !isSelected, e.shiftKey)
return
}
onRowClick?.(row.id)
},
[hasActiveSelection, isSelected, onRowClick, row.id, selectable]
)
const handleMouseEnter = useCallback(() => {
onRowHover?.(row.id)
}, [onRowHover, row.id])
const handleContextMenu = useCallback(
(e: React.MouseEvent) => {
onRowContextMenu?.(e, row.id)
},
[onRowContextMenu, row.id]
)
const shiftKeyRef = useRef(false)
const handleSelectRowClick = useCallback((e: React.MouseEvent) => {
e.stopPropagation()
shiftKeyRef.current = e.shiftKey
}, [])
const handleSelectRow = useCallback(
(checked: boolean | 'indeterminate') => {
selectable?.onSelectRow(row.id, checked as boolean, shiftKeyRef.current)
shiftKeyRef.current = false
},
[selectable, row.id]
)
const handleDragStart = (e: DragEvent<HTMLDivElement>) => {
rowDragDrop?.onDragStart?.(e, row.id)
}
const handleDragOver = (e: DragEvent<HTMLDivElement>) => {
rowDragDrop?.onDragOver?.(e, row.id)
}
const handleDragLeave = (e: DragEvent<HTMLDivElement>) => {
rowDragDrop?.onDragLeave?.(e, row.id)
}
const handleDrop = (e: DragEvent<HTMLDivElement>) => {
rowDragDrop?.onDrop?.(e, row.id)
}
const handleDragEnd = (e: DragEvent<HTMLDivElement>) => {
rowDragDrop?.onDragEnd?.(e, row.id)
}
const isWindowed = translateY !== undefined
const rowStyle: CSSProperties = isWindowed
? { gridTemplateColumns, transform: `translateY(${translateY}px)` }
: { gridTemplateColumns }
return (
<div
ref={ref}
role='row'
data-index={dataIndex}
data-resource-row
data-row-id={row.id}
className={cn(
'grid w-full transition-colors',
isWindowed && 'absolute top-0 left-0',
!isAnyDragActive && 'hover-hover:bg-[var(--surface-3)]',
onRowClick && 'cursor-pointer',
isDraggable && 'cursor-grab active:cursor-grabbing',
isDropTarget && 'data-[drop-target=true]:outline-offset-[-1px]',
(selectedRowId === row.id || isSelected || isContextMenuTarget) && 'bg-[var(--surface-3)]',
isActiveDropTarget && 'bg-[var(--surface-4)] outline outline-1 outline-[var(--accent)]',
(isDragging || (isAnyDragActive && isSelected && !isActiveDropTarget)) && 'opacity-50'
)}
style={rowStyle}
data-drop-target={isDropTarget || undefined}
draggable={isDraggable}
onClick={onRowClick || selectable ? handleClick : undefined}
onMouseEnter={handleMouseEnter}
onContextMenu={onRowContextMenu ? handleContextMenu : undefined}
onDragStart={isDraggable ? handleDragStart : undefined}
onDragOver={isDropTarget ? handleDragOver : undefined}
onDragLeave={isDropTarget ? handleDragLeave : undefined}
onDrop={isDropTarget ? handleDrop : undefined}
onDragEnd={isDraggable ? handleDragEnd : undefined}
>
{hasCheckbox && selectable && (
<div role='cell' className='flex items-center py-2.5 pr-0 pl-5'>
<Checkbox
size='sm'
checked={isSelected}
onCheckedChange={handleSelectRow}
disabled={selectable.disabled}
aria-label='Select row'
onClick={handleSelectRowClick}
/>
</div>
)}
{columns.map((col) => {
const cell = row.cells[col.id]
return (
<div key={col.id} role='cell' className='flex min-w-0 items-center px-6 py-2.5'>
<CellContent
icon={cell?.icon}
label={cell?.label || EMPTY_CELL_PLACEHOLDER}
content={cell?.content}
editing={cell?.editing}
/>
</div>
)
})}
</div>
)
})
/**
* The single public entry point. `Resource` is the layout shell; its compound
* members are the only building blocks consumers compose. Import `Resource` and
* nothing else from this module.
*/
export const Resource = Object.assign(ResourceRoot, {
Header: ResourceHeader,
Options: ResourceOptions,
Table: ResourceTable,
})
@@ -0,0 +1 @@
export { SkillTile } from './skill-tile'
@@ -0,0 +1,16 @@
import { AgentSkillsIcon } from '@/components/icons'
/**
* Square tile bearing the agent-skills glyph. Shared chrome for any surface
* that lists a skill (the Skills page and integration detail pages) so the two
* do not drift.
*/
export function SkillTile() {
return (
<div className='size-9 flex-shrink-0'>
<div className='flex size-full items-center justify-center rounded-xl border border-[var(--border-1)] bg-[var(--surface-4)] dark:bg-[var(--surface-5)]'>
<AgentSkillsIcon className='size-5 text-[var(--text-icon)]' />
</div>
</div>
)
}
@@ -0,0 +1 @@
export { WorkspaceChrome } from './workspace-chrome'
@@ -0,0 +1,169 @@
'use client'
import { useEffect, useLayoutEffect, useRef } from 'react'
import { cn } from '@sim/emcn'
import { usePathname } from 'next/navigation'
import { Sidebar } from '@/app/workspace/[workspaceId]/w/components/sidebar/sidebar'
import { useFullscreenOriginStore } from '@/stores/fullscreen-origin'
import { useSidebarStore } from '@/stores/sidebar/store'
const FULLSCREEN_SUFFIXES = ['/upgrade'] as const
/** Slide timing for the fullscreen sidebar collapse and content shift. */
const SLIDE_TRANSITION =
'duration-[175ms] ease-[cubic-bezier(0.25,0.1,0.25,1)] motion-reduce:transition-none'
interface WorkspaceChromeProps {
children: React.ReactNode
/** Cookie-derived collapse state from the server layout; seeds the sidebar's first render. */
initialSidebarCollapsed?: boolean
}
function isFullscreenPath(pathname: string | null): boolean {
return FULLSCREEN_SUFFIXES.some((s) => pathname?.endsWith(s))
}
/**
* Renders the workspace chrome as a single persistent tree. The sidebar is
* always mounted; on a fullscreen route (`/upgrade`) its wrapper collapses to
* zero width while the inner shell slides off the left edge, revealing the route
* content. Because this component lives in the workspace layout it persists
* across navigations, so the pathname-driven class toggle animates smoothly.
*
* Leaving a fullscreen route is instant: App Router swaps `children` to the
* origin page and the fullscreen page is simply unmounted, while the sidebar
* slides back in. There is no exit fade — the new page just loads in place.
*
* Because the chrome observes every pathname transition, it records the page a
* fullscreen route was launched from into {@link useFullscreenOriginStore}. The
* route's Back control reads that origin to return deterministically, so any
* trigger that merely pushes a fullscreen route gets correct return-to-origin
* without per-call-site wiring.
*
* On a direct load of a fullscreen route the wrapper mounts already collapsed,
* so no slide plays (CSS transitions don't run on mount).
*/
export function WorkspaceChrome({
children,
initialSidebarCollapsed = false,
}: WorkspaceChromeProps) {
const rafRef = useRef(0)
const pathname = usePathname()
const isFullscreen = isFullscreenPath(pathname)
const setOrigin = useFullscreenOriginStore((s) => s.setOrigin)
const storeIsCollapsed = useSidebarStore((s) => s.isCollapsed)
const hasHydrated = useSidebarStore((s) => s._hasHydrated)
const syncSidebarWidth = useSidebarStore((s) => s.syncWidth)
/**
* Single source of collapse for the whole chrome, driving the rail's structure,
* labels, and width. The server renders from the `sidebar_collapsed` cookie
* (`initialSidebarCollapsed`) and the store seeds from the same cookie — after
* the pre-paint script migrates any legacy `localStorage` flag — so prop and
* store agree. The prop is used until the store hydrates (keeping the first
* client render identical to the server), then the store takes over.
*/
const isCollapsed = hasHydrated ? storeIsCollapsed : initialSidebarCollapsed
/**
* Suppresses sidebar transitions across the initial hydration window. The
* pre-paint script already set the correct `--sidebar-width`, but the store
* rehydration below re-applies it a tick later; without this guard that
* re-apply animates the rail, reading as a collapse -> expand flash on a
* fresh load. Applied before the rehydrate effect so the class is in place
* ahead of the width mutation, then lifted after the first paint so
* user-driven collapse toggles and the fullscreen slide still animate.
*/
useLayoutEffect(() => {
const root = document.documentElement
root.classList.add('sidebar-booting')
const raf1 = requestAnimationFrame(() => {
const raf2 = requestAnimationFrame(() => root.classList.remove('sidebar-booting'))
rafRef.current = raf2
})
rafRef.current = raf1
return () => {
cancelAnimationFrame(rafRef.current)
root.classList.remove('sidebar-booting')
}
}, [])
// Hydrate the persisted width before paint (collapse comes from the cookie/prop).
useLayoutEffect(() => {
void useSidebarStore.persist.rehydrate()
}, [])
// Remember the last non-fullscreen page so a fullscreen route's Back control
// can return there, deterministically and for any trigger.
useEffect(() => {
if (pathname && !isFullscreen) setOrigin(pathname)
}, [pathname, isFullscreen, setOrigin])
// Re-apply the sidebar width whenever this persistent shell sees a navigation.
// The blocking script in the document head only runs on full page loads and
// store rehydration only fires once, so a soft navigation can leave
// `--sidebar-width` stuck at its `0px` default — collapsing the sidebar to
// nothing with no reachable control to bring it back. Re-syncing here recovers
// that state. Gated on hydration so it never clobbers the persisted value with
// store defaults during the pre-hydration window.
useEffect(() => {
if (hasHydrated) syncSidebarWidth()
}, [pathname, hasHydrated, syncSidebarWidth])
// Re-clamp the width when the window shrinks below what the persisted width
// allows, so the sidebar can never grow wider than the viewport permits.
useEffect(() => {
let rafId: number | null = null
const onResize = () => {
if (rafId !== null) return
rafId = requestAnimationFrame(() => {
rafId = null
syncSidebarWidth()
})
}
window.addEventListener('resize', onResize)
return () => {
if (rafId !== null) cancelAnimationFrame(rafId)
window.removeEventListener('resize', onResize)
}
}, [syncSidebarWidth])
return (
<div className='flex min-h-0 flex-1'>
<div
className={cn(
'sidebar-shell-outer shrink-0 overflow-hidden transition-[width]',
SLIDE_TRANSITION,
isFullscreen ? 'w-0' : 'w-[var(--sidebar-width)]'
)}
data-collapsed={isCollapsed || undefined}
aria-hidden={isFullscreen || undefined}
suppressHydrationWarning
>
<div
className={cn(
'sidebar-shell-inner h-full w-[var(--sidebar-width)] shrink-0 transition-transform',
SLIDE_TRANSITION,
isFullscreen && '-translate-x-full'
)}
>
<Sidebar isCollapsed={isCollapsed} />
</div>
</div>
<div
className={cn(
'flex min-w-0 flex-1 flex-col p-[8px] transition-[padding]',
SLIDE_TRANSITION,
!isFullscreen && 'pl-0'
)}
>
<div className='flex-1 overflow-hidden rounded-[8px] border border-[var(--border)] bg-[var(--bg)]'>
{children}
</div>
</div>
</div>
)
}
@@ -0,0 +1,15 @@
'use client'
import { type ErrorBoundaryProps, ErrorState } from '@/app/workspace/[workspaceId]/components'
export default function WorkspaceError({ error, reset }: ErrorBoundaryProps) {
return (
<ErrorState
error={error}
reset={reset}
title='Something went wrong'
description='An unexpected error occurred. Please try again or refresh the page.'
loggerName='WorkspaceError'
/>
)
}
@@ -0,0 +1,16 @@
import { Suspense } from 'react'
import type { Metadata } from 'next'
import { Files } from '../files'
export const metadata: Metadata = {
title: 'Files',
robots: { index: false },
}
export default function FilesFilePage() {
return (
<Suspense fallback={null}>
<Files />
</Suspense>
)
}
@@ -0,0 +1,34 @@
'use client'
import { createLogger } from '@sim/logger'
import { useParams } from 'next/navigation'
import { useWorkspaceFileRecord } from '@/hooks/queries/workspace-files'
const logger = createLogger('FileViewer')
export function FileViewer() {
const params = useParams()
const workspaceId = params?.workspaceId as string
const fileId = params?.fileId as string
const { data: file, isLoading } = useWorkspaceFileRecord(workspaceId, fileId)
if (isLoading || !file) {
return null
}
const serveUrl = `/api/files/serve/${encodeURIComponent(file.key)}?context=workspace&t=${file.size}`
return (
<div className='fixed inset-0 z-50 bg-[var(--bg)]'>
<iframe
src={serveUrl}
className='h-full w-full border-0'
title={file.name}
onError={() => {
logger.error(`Failed to load file: ${file.name}`)
}}
/>
</div>
)
}
@@ -0,0 +1,9 @@
import type { Metadata } from 'next'
import { FileViewer } from './file-viewer'
export const metadata: Metadata = {
title: 'File',
robots: { index: false },
}
export default FileViewer
@@ -0,0 +1,126 @@
'use client'
import {
Button,
cn,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
Folder,
Tooltip,
Trash,
} from '@sim/emcn'
import { Download } from '@sim/emcn/icons'
import { AnimatePresence, domAnimation, LazyMotion, m } from 'framer-motion'
import type { MoveOptionNode } from '@/app/workspace/[workspaceId]/files/move-options'
import { renderMoveOption } from '@/app/workspace/[workspaceId]/files/move-options'
interface FilesActionBarProps {
selectedCount: number
onDownload?: () => void
onMove?: (optionValue: string) => void
moveOptions?: MoveOptionNode[]
onDelete?: () => void
isLoading?: boolean
className?: string
}
export function FilesActionBar({
selectedCount,
onDownload,
onMove,
moveOptions,
onDelete,
isLoading = false,
className,
}: FilesActionBarProps) {
return (
<LazyMotion features={domAnimation}>
<AnimatePresence>
{selectedCount > 0 && (
<m.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 10 }}
transition={{ duration: 0.2 }}
className={cn(
'-translate-x-1/2 fixed bottom-6 left-1/2 z-[var(--z-dropdown)] transform',
className
)}
>
<div className='flex items-center gap-2 rounded-[10px] border border-[var(--border)] bg-[var(--surface-2)] px-2 py-1.5'>
<span className='px-1 text-[var(--text-secondary)] text-small'>
{selectedCount} selected
</span>
<div className='flex items-center gap-[5px]'>
{onDownload && (
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
variant='ghost'
onClick={onDownload}
disabled={isLoading}
className='hover-hover:!text-[var(--text-inverse)] size-[28px] rounded-lg bg-[var(--surface-5)] p-0 text-[var(--text-secondary)] hover-hover:bg-[var(--brand-secondary)]'
>
<Download className='size-[12px]' />
</Button>
</Tooltip.Trigger>
<Tooltip.Content side='top'>Download</Tooltip.Content>
</Tooltip.Root>
)}
{onMove && moveOptions && (
<DropdownMenu>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<DropdownMenuTrigger asChild>
<Button
variant='ghost'
disabled={isLoading}
className='hover-hover:!text-[var(--text-inverse)] size-[28px] rounded-lg bg-[var(--surface-5)] p-0 text-[var(--text-secondary)] hover-hover:bg-[var(--brand-secondary)]'
>
<Folder className='size-[12px]' />
</Button>
</DropdownMenuTrigger>
</Tooltip.Trigger>
<Tooltip.Content side='top'>Move</Tooltip.Content>
</Tooltip.Root>
<DropdownMenuContent
side='top'
align='center'
className='max-h-[240px] overflow-y-auto'
>
{moveOptions.length > 0 && (
<DropdownMenuItem onSelect={() => onMove(moveOptions[0].value)}>
<Folder />
{moveOptions[0].label}
</DropdownMenuItem>
)}
{moveOptions.length > 1 && <DropdownMenuSeparator />}
{moveOptions.slice(1).map((option) => renderMoveOption(option, onMove))}
</DropdownMenuContent>
</DropdownMenu>
)}
{onDelete && (
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
variant='ghost'
onClick={onDelete}
disabled={isLoading}
className='hover-hover:!text-[var(--text-inverse)] size-[28px] rounded-lg bg-[var(--surface-5)] p-0 text-[var(--text-secondary)] hover-hover:bg-[var(--brand-secondary)]'
>
<Trash className='size-[12px]' />
</Button>
</Tooltip.Trigger>
<Tooltip.Content side='top'>Delete</Tooltip.Content>
</Tooltip.Root>
)}
</div>
</div>
</m.div>
)}
</AnimatePresence>
</LazyMotion>
)
}
@@ -0,0 +1 @@
export { FilesActionBar } from './action-bar'
@@ -0,0 +1,57 @@
'use client'
import { memo } from 'react'
import { ChipConfirmModal } from '@sim/emcn'
interface DeleteConfirmModalProps {
open: boolean
onOpenChange: (open: boolean) => void
fileName?: string
fileCount: number
folderCount: number
onDelete: () => void
isPending: boolean
}
export const DeleteConfirmModal = memo(function DeleteConfirmModal({
open,
onOpenChange,
fileName,
fileCount,
folderCount,
onDelete,
isPending,
}: DeleteConfirmModalProps) {
const totalCount = fileCount + folderCount
const hasFolders = folderCount > 0
const title = totalCount > 1 ? 'Delete Items' : hasFolders ? 'Delete Folder' : 'Delete File'
const consequence = hasFolders
? totalCount > 1
? 'This will also delete files and folders inside any selected folders.'
: 'This will also delete files and folders inside it.'
: totalCount > 1
? 'You can restore them from Recently Deleted in Settings.'
: 'You can restore it from Recently Deleted in Settings.'
return (
<ChipConfirmModal
open={open}
onOpenChange={onOpenChange}
srTitle={title}
title={title}
text={[
'Are you sure you want to delete ',
fileName
? { text: fileName, bold: true }
: `${totalCount} item${totalCount === 1 ? '' : 's'}`,
`? ${consequence}`,
]}
confirm={{
label: 'Delete',
onClick: onDelete,
pending: isPending,
pendingLabel: 'Deleting...',
}}
/>
)
})
@@ -0,0 +1 @@
export { DeleteConfirmModal } from './delete-confirm-modal'
@@ -0,0 +1,121 @@
'use client'
import { memo } from 'react'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
Eye,
Folder,
FolderInput,
Pencil,
} from '@sim/emcn'
import { Download, Link, Trash } from '@sim/emcn/icons'
import type { MoveOptionNode } from '@/app/workspace/[workspaceId]/files/move-options'
import { renderMoveOption } from '@/app/workspace/[workspaceId]/files/move-options'
interface FileRowContextMenuProps {
isOpen: boolean
position: { x: number; y: number }
onClose: () => void
onOpen: () => void
onDownload?: () => void
onRename: () => void
onDelete: () => void
onMove?: (optionValue: string) => void
onShare?: () => void
moveOptions?: MoveOptionNode[]
canEdit: boolean
selectedCount: number
}
export const FileRowContextMenu = memo(function FileRowContextMenu({
isOpen,
position,
onClose,
onOpen,
onDownload,
onRename,
onDelete,
onMove,
onShare,
moveOptions,
canEdit,
selectedCount,
}: FileRowContextMenuProps) {
const isMultiSelect = selectedCount > 1
return (
<DropdownMenu open={isOpen} onOpenChange={(open) => !open && onClose()} modal={false}>
<DropdownMenuTrigger asChild>
<div
className='pointer-events-none fixed h-px w-px'
style={{ left: `${position.x}px`, top: `${position.y}px` }}
tabIndex={-1}
aria-hidden
/>
</DropdownMenuTrigger>
<DropdownMenuContent
align='start'
side='bottom'
sideOffset={4}
onCloseAutoFocus={(e) => e.preventDefault()}
>
{!isMultiSelect && (
<DropdownMenuItem onSelect={onOpen}>
<Eye />
Open
</DropdownMenuItem>
)}
{onDownload && (
<DropdownMenuItem onSelect={onDownload}>
<Download />
{isMultiSelect ? `Download ${selectedCount} items` : 'Download'}
</DropdownMenuItem>
)}
{canEdit && (
<>
<DropdownMenuSeparator />
{!isMultiSelect && (
<DropdownMenuItem onSelect={onRename}>
<Pencil />
Rename
</DropdownMenuItem>
)}
{!isMultiSelect && onShare && (
<DropdownMenuItem onSelect={onShare}>
<Link />
Share
</DropdownMenuItem>
)}
{onMove && moveOptions && moveOptions.length > 0 && (
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<FolderInput />
{isMultiSelect ? `Move ${selectedCount} items` : 'Move to'}
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
<DropdownMenuItem onSelect={() => onMove(moveOptions[0].value)}>
<Folder />
{moveOptions[0].label}
</DropdownMenuItem>
{moveOptions.length > 1 && <DropdownMenuSeparator />}
{moveOptions.slice(1).map((option) => renderMoveOption(option, onMove))}
</DropdownMenuSubContent>
</DropdownMenuSub>
)}
<DropdownMenuItem onSelect={onDelete}>
<Trash />
{isMultiSelect ? `Delete ${selectedCount} items` : 'Delete'}
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
)
})
@@ -0,0 +1 @@
export { FileRowContextMenu } from './file-row-context-menu'
@@ -0,0 +1,69 @@
'use client'
import { useCallback, useEffect, useRef } from 'react'
import { toast } from '@sim/emcn'
import { generateId } from '@sim/utils/id'
import { useRouter } from 'next/navigation'
import { CSV_PREVIEW_MAX_ROWS } from '@/lib/api/contracts/workspace-file-table'
import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace'
import { useImportFileAsTable } from '@/hooks/queries/tables'
import { useImportTrayStore } from '@/stores/table/import-tray/store'
export type CsvImportFileDescriptor = Pick<WorkspaceFileRecord, 'key' | 'name'>
/**
* Wires the "Import as a table" affordance for a capped CSV preview. When the preview is
* `truncated`, raises a one-time warning toast whose action kicks off a background import of the
* existing workspace file — no re-upload, source preserved — and navigates to the new table.
*/
export function useCsvTruncationImport(
workspaceId: string,
file: CsvImportFileDescriptor,
truncated: boolean,
readOnly = false
) {
const router = useRouter()
const importFile = useImportFileAsTable()
// Guards against a double-tap on the toast action kicking off two parallel imports of the same
// file. Reset once the kickoff settles so a failed import can be retried.
const importingRef = useRef(false)
const importAsTable = useCallback(() => {
if (importingRef.current) return
importingRef.current = true
const pendingId = `pending_${generateId()}`
useImportTrayStore
.getState()
.startUpload({ uploadId: pendingId, workspaceId, title: file.name })
toast.success(`Importing "${file.name}" as a table`, {
description: 'This runs in the background.',
action: {
label: 'View tables',
onClick: () => router.push(`/workspace/${workspaceId}/tables`),
},
})
importFile.mutate(
{ workspaceId, fileKey: file.key, fileName: file.name },
{
onSettled: () => {
importingRef.current = false
useImportTrayStore.getState().endUpload(pendingId)
},
}
)
// importFile.mutate and router are stable references
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [workspaceId, file.key, file.name])
// Surface the cap as a warning toast with an import action, once per file.
const notifiedKeyRef = useRef<string | null>(null)
useEffect(() => {
if (readOnly || !truncated || notifiedKeyRef.current === file.key) return
notifiedKeyRef.current = file.key
toast.warning(`Showing the first ${CSV_PREVIEW_MAX_ROWS.toLocaleString()} rows`, {
description: 'Import this file as a table to view all of its rows.',
action: { label: 'Import as a table', onClick: importAsTable },
})
}, [readOnly, truncated, file.key, importAsTable])
}
@@ -0,0 +1,49 @@
'use client'
import { memo } from 'react'
import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace'
import { useWorkspaceCsvPreview } from '@/hooks/queries/workspace-file-table'
import { useCsvTruncationImport } from './csv-import'
import { DataTable } from './data-table'
import { PreviewError, PreviewLoadingFrame, resolvePreviewError } from './preview-shared'
/**
* Read-only preview for a CSV that is too large to load fully into the editor. Streams only the
* first {@link CSV_PREVIEW_MAX_ROWS} rows from storage; when there are more, a warning toast offers
* "Import as a table", which builds a full Table from the file (memory-safe streaming import).
*/
export const CsvTablePreview = memo(function CsvTablePreview({
file,
workspaceId,
}: {
file: WorkspaceFileRecord
workspaceId: string
}) {
const version = Number(new Date(file.updatedAt)) || file.size
const {
data,
isLoading,
error: fetchError,
} = useWorkspaceCsvPreview(workspaceId, file.id, file.key, version)
useCsvTruncationImport(workspaceId, file, data?.truncated ?? false)
const error = resolvePreviewError((fetchError as Error | null) ?? null, null)
if (error) return <PreviewError label='CSV' error={error} />
if (isLoading || !data) {
return <PreviewLoadingFrame className='flex flex-1 flex-col overflow-hidden' />
}
if (data.headers.length === 0) {
return (
<div className='flex h-full items-center justify-center p-6'>
<p className='text-[13px] text-[var(--text-muted)]'>No data to display</p>
</div>
)
}
return (
<div className='flex flex-1 flex-col overflow-auto p-6'>
<DataTable headers={data.headers} rows={data.rows} />
</div>
)
})
@@ -0,0 +1,160 @@
'use client'
import { forwardRef, memo, useCallback, useImperativeHandle, useRef, useState } from 'react'
import { cn } from '@sim/emcn'
interface EditConfig {
onCellChange: (row: number, col: number, value: string) => void
onHeaderChange: (col: number, value: string) => void
}
interface DataTableProps {
headers: string[]
rows: string[][]
editConfig?: EditConfig
}
export interface DataTableHandle {
commitEdit: () => void
}
type EditingCell = { row: number; col: number } | null
const DataTableBase = forwardRef<DataTableHandle, DataTableProps>(function DataTable(
{ headers, rows, editConfig },
ref
) {
const [editingCell, setEditingCell] = useState<EditingCell>(null)
const [editValue, setEditValue] = useState('')
const editStateRef = useRef({ editingCell, editValue, editConfig })
editStateRef.current = { editingCell, editValue, editConfig }
// Prevents double-commit if onBlur and imperative commitEdit fire concurrently
const isCommittedRef = useRef(false)
useImperativeHandle(
ref,
() => ({
commitEdit: () => {
if (isCommittedRef.current) return
const { editingCell, editValue, editConfig } = editStateRef.current
if (!editingCell || !editConfig) return
isCommittedRef.current = true
const { row, col } = editingCell
if (row === -1) {
editConfig.onHeaderChange(col, editValue)
} else {
editConfig.onCellChange(row, col, editValue)
}
setEditingCell(null)
},
}),
[]
)
const setInputRef = useCallback((node: HTMLInputElement | null) => {
if (node) {
node.focus()
node.select()
}
}, [])
const startEdit = (row: number, col: number, currentValue: string) => {
if (!editConfig) return
isCommittedRef.current = false
setEditingCell({ row, col })
setEditValue(currentValue)
}
const commitEdit = () => {
if (isCommittedRef.current || !editingCell || !editConfig) return
isCommittedRef.current = true
const { row, col } = editingCell
if (row === -1) {
editConfig.onHeaderChange(col, editValue)
} else {
editConfig.onCellChange(row, col, editValue)
}
setEditingCell(null)
}
const cancelEdit = () => setEditingCell(null)
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter' || e.key === 'Tab') {
e.preventDefault()
commitEdit()
} else if (e.key === 'Escape') {
cancelEdit()
}
}
const isEditing = (row: number, col: number) =>
editingCell?.row === row && editingCell?.col === col
return (
<div className='overflow-x-auto rounded-md border border-[var(--border)]'>
<table className='w-full border-collapse text-[13px]'>
<thead className='bg-[var(--surface-2)]'>
<tr>
{headers.map((header, i) => (
<th
key={i}
className={cn(
'whitespace-nowrap px-3 py-2 text-left font-semibold text-[12px] text-[var(--text-primary)]',
editConfig && 'cursor-pointer select-none hover:bg-[var(--surface-3)]'
)}
onClick={() => editConfig && startEdit(-1, i, String(header ?? ''))}
>
{isEditing(-1, i) ? (
<input
ref={setInputRef}
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
onBlur={commitEdit}
onKeyDown={handleKeyDown}
className='w-full min-w-[60px] bg-transparent font-semibold text-[12px] text-[var(--text-primary)] outline-none ring-1 ring-[var(--brand-secondary)] ring-inset'
/>
) : (
String(header ?? '')
)}
</th>
))}
</tr>
</thead>
<tbody>
{rows.map((row, ri) => (
<tr key={ri} className='border-[var(--border)] border-t'>
{headers.map((_, ci) => (
<td
key={ci}
className={cn(
'whitespace-nowrap px-3 py-2 text-[var(--text-secondary)]',
editConfig && 'cursor-pointer select-none hover:bg-[var(--surface-2)]'
)}
onClick={() => editConfig && startEdit(ri, ci, String(row[ci] ?? ''))}
>
{isEditing(ri, ci) ? (
<input
ref={setInputRef}
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
onBlur={commitEdit}
onKeyDown={handleKeyDown}
className='w-full min-w-[60px] bg-transparent text-[13px] text-[var(--text-secondary)] outline-none ring-1 ring-[var(--brand-secondary)] ring-inset'
/>
) : (
String(row[ci] ?? '')
)}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
)
})
export const DataTable = memo(DataTableBase)
@@ -0,0 +1,317 @@
'use client'
import { memo, useCallback, useEffect, useRef, useState } from 'react'
import { cn } from '@sim/emcn'
import { createLogger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
import { sanitizeRenderedHyperlinks } from '@/lib/core/security/url-safety'
import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace'
import { PREVIEW_LOADING_OVERLAY, PreviewError, resolvePreviewError } from './preview-shared'
import { PreviewToolbar } from './preview-toolbar'
import { bindPreviewWheelZoom } from './preview-wheel-zoom'
import { useDocPreviewBinary } from './use-doc-preview-binary'
const logger = createLogger('DocxPreview')
const DOCX_ZOOM_MIN = 25
const DOCX_ZOOM_MAX = 400
const DOCX_ZOOM_STEP = 20
const DOCX_ZOOM_WHEEL_SENSITIVITY = 0.005
const DOCX_RESIZE_DEBOUNCE_MS = 150
/**
* Fit the rendered docx pages to the host container width using a CSS scale.
* The library renders `<section class="docx">` at the document's natural page
* width (in cm), which overflows narrow panels. 100% zoom means fit-to-width —
* pages upscale past their natural print size in wide panels (CSS zoom of HTML
* stays crisp), matching the PDF preview's semantics.
*/
function fitDocxToContainer(host: HTMLElement, viewport: HTMLElement, zoomPercent: number) {
const wrapper = host.querySelector<HTMLElement>('.docx-wrapper')
if (!wrapper) return
const section = wrapper.querySelector<HTMLElement>('section.docx')
if (!section) return
host.style.minWidth = ''
host.style.minHeight = ''
host.style.width = ''
host.style.display = 'flex'
host.style.flexDirection = 'column'
host.style.alignItems = 'center'
wrapper.style.zoom = ''
wrapper.style.width = ''
wrapper.style.flex = '0 0 auto'
wrapper.style.marginRight = ''
wrapper.style.marginBottom = ''
const naturalPageWidth = section.offsetWidth
if (!naturalPageWidth) return
const wrapperStyle = window.getComputedStyle(wrapper)
const horizontalPadding =
Number.parseFloat(wrapperStyle.paddingLeft) + Number.parseFloat(wrapperStyle.paddingRight)
const naturalWrapperWidth = naturalPageWidth + horizontalPadding
const available = viewport.clientWidth
const fitScale = available / naturalWrapperWidth
const scale = fitScale * (zoomPercent / 100)
const scaledWrapperWidth = naturalWrapperWidth * scale
wrapper.style.width = `${naturalWrapperWidth}px`
wrapper.style.zoom = String(scale)
host.style.width = `${Math.max(available, scaledWrapperWidth)}px`
host.style.minWidth = `${scaledWrapperWidth}px`
host.style.minHeight = `${wrapper.offsetHeight * scale}px`
}
export const DocxPreview = memo(function DocxPreview({
file,
workspaceId,
}: {
file: WorkspaceFileRecord
workspaceId: string
}) {
const containerRef = useRef<HTMLDivElement>(null)
const scrollContainerRef = useRef<HTMLDivElement>(null)
const zoomPercentRef = useRef(100)
const preview = useDocPreviewBinary(workspaceId, file)
const fileData = preview.data
const [renderError, setRenderError] = useState<string | null>(null)
const [rendering, setRendering] = useState(false)
const [hasRenderedPreview, setHasRenderedPreview] = useState(false)
const [zoomPercent, setZoomPercent] = useState(100)
const [pageCount, setPageCount] = useState(0)
const [currentPage, setCurrentPage] = useState(1)
const [documentRenderVersion, setDocumentRenderVersion] = useState(0)
const applyPostRenderStyling = useCallback(() => {
const container = containerRef.current
const scrollContainer = scrollContainerRef.current
if (!container || !scrollContainer) return
const wrapper = container.querySelector<HTMLElement>('.docx-wrapper')
if (wrapper) wrapper.style.background = 'transparent'
const pages = Array.from(container.querySelectorAll<HTMLElement>('section.docx'))
pages.forEach((page, index) => {
page.style.boxShadow = 'var(--shadow-medium)'
page.dataset.page = String(index + 1)
})
setPageCount((previous) => (previous === pages.length ? previous : pages.length))
setCurrentPage((current) => (pages.length > 0 ? Math.min(current, pages.length) : 1))
fitDocxToContainer(container, scrollContainer, zoomPercentRef.current)
}, [])
/**
* Resize refits are debounced: each one re-queries the rendered pages and
* recomputes the fit scale, so per-tick refits during a panel-divider drag
* would thrash layout continuously (the initial fit is applied directly by
* the render path, not this observer). Mirrors the PDF preview's debounce.
*/
useEffect(() => {
const scrollContainer = scrollContainerRef.current
if (!scrollContainer) return
let debounce: ReturnType<typeof setTimeout> | undefined
const observer = new ResizeObserver(() => {
clearTimeout(debounce)
debounce = setTimeout(() => applyPostRenderStyling(), DOCX_RESIZE_DEBOUNCE_MS)
})
observer.observe(scrollContainer)
return () => {
clearTimeout(debounce)
observer.disconnect()
}
}, [applyPostRenderStyling])
const applyZoomAt = useCallback(
(nextZoom: number, anchorX: number, anchorY: number) => {
const scrollContainer = scrollContainerRef.current
if (!scrollContainer) return
const clampedZoom = Math.round(Math.min(Math.max(nextZoom, DOCX_ZOOM_MIN), DOCX_ZOOM_MAX))
const wrapper = containerRef.current?.querySelector<HTMLElement>('.docx-wrapper')
const containerRect = scrollContainer.getBoundingClientRect()
const anchorClientX = containerRect.left + anchorX
const anchorClientY = containerRect.top + anchorY
const beforeRect = wrapper?.getBoundingClientRect()
const anchorRatioX =
beforeRect && beforeRect.width > 0
? (anchorClientX - beforeRect.left) / beforeRect.width
: 0
const anchorRatioY =
beforeRect && beforeRect.height > 0
? (anchorClientY - beforeRect.top) / beforeRect.height
: 0
zoomPercentRef.current = clampedZoom
setZoomPercent(clampedZoom)
applyPostRenderStyling()
const afterRect = wrapper?.getBoundingClientRect()
if (!beforeRect || !afterRect) return
scrollContainer.scrollLeft += afterRect.left + anchorRatioX * afterRect.width - anchorClientX
scrollContainer.scrollTop += afterRect.top + anchorRatioY * afterRect.height - anchorClientY
},
[applyPostRenderStyling]
)
useEffect(() => {
const scrollContainer = scrollContainerRef.current
if (!scrollContainer) return
return bindPreviewWheelZoom(scrollContainer, (event) => {
const rect = scrollContainer.getBoundingClientRect()
applyZoomAt(
zoomPercentRef.current * (1 - event.deltaY * DOCX_ZOOM_WHEEL_SENSITIVITY),
event.clientX - rect.left,
event.clientY - rect.top
)
})
}, [applyZoomAt])
useEffect(() => {
const scrollContainer = scrollContainerRef.current
const container = containerRef.current
if (!scrollContainer || !container || pageCount === 0) return
const pages = Array.from(container.querySelectorAll<HTMLElement>('section.docx'))
const observer = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (entry.isIntersecting) {
const page = Number((entry.target as HTMLElement).dataset.page)
if (page) setCurrentPage(page)
}
}
},
{ root: scrollContainer, threshold: 0.5 }
)
for (const page of pages) {
observer.observe(page)
}
return () => observer.disconnect()
}, [pageCount, documentRenderVersion])
useEffect(() => {
if (!containerRef.current || !fileData) return
let cancelled = false
async function render() {
try {
setRendering(true)
const { renderAsync } = await import('docx-preview')
if (cancelled || !containerRef.current) return
setRenderError(null)
containerRef.current.innerHTML = ''
await renderAsync(fileData, containerRef.current, undefined, {
inWrapper: true,
ignoreWidth: false,
ignoreHeight: false,
})
if (!cancelled && containerRef.current) {
sanitizeRenderedHyperlinks(containerRef.current)
applyPostRenderStyling()
setHasRenderedPreview(true)
setDocumentRenderVersion((version) => version + 1)
}
} catch (err) {
if (!cancelled) {
const msg = toError(err).message || 'Failed to render document'
logger.error('DOCX render failed', { error: msg })
setRenderError(msg)
}
} finally {
if (!cancelled) {
setRendering(false)
}
}
}
render()
return () => {
cancelled = true
}
}, [fileData, applyPostRenderStyling])
const error = resolvePreviewError(preview.error, renderError)
if (error) return <PreviewError label='document' error={error} />
const showLoadingFrame = !hasRenderedPreview && (!fileData || rendering)
const scrollToPage = (page: number) => {
const scrollContainer = scrollContainerRef.current
const target = containerRef.current?.querySelector<HTMLElement>(
`section.docx[data-page="${page}"]`
)
if (!scrollContainer || !target) return
if (zoomPercentRef.current !== 100) {
applyZoomAt(100, scrollContainer.clientWidth / 2, scrollContainer.clientHeight / 2)
}
scrollContainer.scrollTo({
top: target.offsetTop - scrollContainer.offsetTop - 16,
behavior: 'smooth',
})
}
return (
<div className='flex h-full min-h-0 w-full flex-col overflow-hidden bg-[var(--surface-1)]'>
<PreviewToolbar
navigation={{
current: currentPage,
total: pageCount,
label: 'page',
canPrevious: pageCount > 0 && currentPage > 1,
canNext: pageCount > 0 && currentPage < pageCount,
onPrevious: () => {
const previous = Math.max(1, currentPage - 1)
setCurrentPage(previous)
scrollToPage(previous)
},
onNext: () => {
const next = Math.min(pageCount, currentPage + 1)
setCurrentPage(next)
scrollToPage(next)
},
}}
zoom={{
label: `${zoomPercent}%`,
canZoomOut: zoomPercent > DOCX_ZOOM_MIN,
canZoomIn: zoomPercent < DOCX_ZOOM_MAX,
onReset: () => {
const c = scrollContainerRef.current
applyZoomAt(100, c ? c.clientWidth / 2 : 0, c ? c.clientHeight / 2 : 0)
},
onZoomOut: () => {
const c = scrollContainerRef.current
applyZoomAt(
zoomPercent - DOCX_ZOOM_STEP,
c ? c.clientWidth / 2 : 0,
c ? c.clientHeight / 2 : 0
)
},
onZoomIn: () => {
const c = scrollContainerRef.current
applyZoomAt(
zoomPercent + DOCX_ZOOM_STEP,
c ? c.clientWidth / 2 : 0,
c ? c.clientHeight / 2 : 0
)
},
}}
/>
<div
ref={scrollContainerRef}
className='relative min-h-0 flex-1 overflow-auto bg-[var(--surface-1)]'
>
{showLoadingFrame && PREVIEW_LOADING_OVERLAY}
<div
ref={containerRef}
className={cn('min-h-full w-full', showLoadingFrame && 'opacity-0')}
/>
</div>
</div>
)
})
@@ -0,0 +1,103 @@
'use client'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuTrigger,
} from '@sim/emcn'
import { Clipboard, Duplicate, Search, SelectAll } from '@sim/emcn/icons'
import { Scissors } from 'lucide-react'
interface EditorContextMenuProps {
isOpen: boolean
position: { x: number; y: number }
onClose: () => void
hasSelection: boolean
canEdit: boolean
onCut: () => void
onCopy: () => void
onCopyAll: () => void
onPaste: () => void
onSelectAll: () => void
onFind: () => void
}
export function EditorContextMenu({
isOpen,
position,
onClose,
hasSelection,
canEdit,
onCut,
onCopy,
onCopyAll,
onPaste,
onSelectAll,
onFind,
}: EditorContextMenuProps) {
return (
<DropdownMenu open={isOpen} onOpenChange={(open) => !open && onClose()} modal={false}>
<DropdownMenuTrigger asChild>
<div
style={{
position: 'fixed',
left: `${position.x}px`,
top: `${position.y}px`,
width: '1px',
height: '1px',
pointerEvents: 'none',
}}
tabIndex={-1}
aria-hidden
/>
</DropdownMenuTrigger>
<DropdownMenuContent
align='start'
side='bottom'
sideOffset={2}
onCloseAutoFocus={(e) => e.preventDefault()}
>
{canEdit && (
<DropdownMenuItem disabled={!hasSelection} onSelect={onCut}>
<Scissors />
Cut
<DropdownMenuShortcut>X</DropdownMenuShortcut>
</DropdownMenuItem>
)}
<DropdownMenuItem disabled={!hasSelection} onSelect={onCopy}>
<Duplicate />
Copy
<DropdownMenuShortcut>C</DropdownMenuShortcut>
</DropdownMenuItem>
<DropdownMenuItem onSelect={onCopyAll}>
<Duplicate />
Copy all
</DropdownMenuItem>
{canEdit && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={onPaste}>
<Clipboard />
Paste
<DropdownMenuShortcut>V</DropdownMenuShortcut>
</DropdownMenuItem>
</>
)}
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={onSelectAll}>
<SelectAll />
Select all
<DropdownMenuShortcut>A</DropdownMenuShortcut>
</DropdownMenuItem>
<DropdownMenuItem onSelect={onFind}>
<Search />
Find
<DropdownMenuShortcut>F</DropdownMenuShortcut>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}
@@ -0,0 +1,233 @@
/**
* @vitest-environment node
*/
import { describe, expect, it, vi } from 'vitest'
vi.mock('@/lib/uploads/utils/validation', () => ({
SUPPORTED_CODE_EXTENSIONS: ['js', 'ts', 'py', 'go', 'rs', 'sh', 'sql'],
}))
vi.mock('@/lib/uploads/utils/file-utils', () => ({
getFileExtension: (filename: string): string => {
const lastDot = filename.lastIndexOf('.')
return lastDot !== -1 ? filename.slice(lastDot + 1).toLowerCase() : ''
},
}))
import { resolveFileCategory } from './file-category'
describe('resolveFileCategory — MIME type routing', () => {
describe('text-editable', () => {
it.each([
'text/plain',
'text/markdown',
'application/json',
'application/x-yaml',
'text/csv',
'text/html',
'text/xml',
'application/xml',
'text/css',
'text/javascript',
'application/javascript',
'application/typescript',
'application/toml',
'text/x-python',
'text/x-sh',
'text/x-sql',
'image/svg+xml',
'text/x-mermaid',
])('%s → text-editable', (mime) => {
expect(resolveFileCategory(mime, 'file.txt')).toBe('text-editable')
})
})
describe('iframe-previewable (PDF)', () => {
it('application/pdf → iframe-previewable', () => {
expect(resolveFileCategory('application/pdf', 'doc.pdf')).toBe('iframe-previewable')
})
it('text/x-pdflibjs → iframe-previewable', () => {
expect(resolveFileCategory('text/x-pdflibjs', 'generated.pdf')).toBe('iframe-previewable')
})
})
describe('image-previewable', () => {
it.each(['image/png', 'image/jpeg', 'image/gif', 'image/webp'])(
'%s → image-previewable',
(mime) => {
expect(resolveFileCategory(mime, 'img.png')).toBe('image-previewable')
}
)
})
describe('audio-previewable', () => {
it.each([
'audio/mpeg',
'audio/mp4',
'audio/wav',
'audio/webm',
'audio/ogg',
'audio/flac',
'audio/aac',
'audio/opus',
'audio/x-m4a',
])('%s → audio-previewable', (mime) => {
expect(resolveFileCategory(mime, 'audio.mp3')).toBe('audio-previewable')
})
})
describe('video-previewable', () => {
it.each(['video/mp4', 'video/quicktime', 'video/x-msvideo', 'video/x-matroska', 'video/webm'])(
'%s → video-previewable',
(mime) => {
expect(resolveFileCategory(mime, 'video.mp4')).toBe('video-previewable')
}
)
})
describe('docx-previewable', () => {
it('application/vnd.openxmlformats-officedocument.wordprocessingml.document → docx-previewable', () => {
expect(
resolveFileCategory(
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'doc.docx'
)
).toBe('docx-previewable')
})
it('text/x-docxjs → docx-previewable', () => {
expect(resolveFileCategory('text/x-docxjs', 'doc.docx')).toBe('docx-previewable')
})
})
describe('pptx-previewable', () => {
it('application/vnd.openxmlformats-officedocument.presentationml.presentation → pptx-previewable', () => {
expect(
resolveFileCategory(
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
'deck.pptx'
)
).toBe('pptx-previewable')
})
it('text/x-pptxgenjs → pptx-previewable', () => {
expect(resolveFileCategory('text/x-pptxgenjs', 'deck.pptx')).toBe('pptx-previewable')
})
})
describe('xlsx-previewable', () => {
it('application/vnd.openxmlformats-officedocument.spreadsheetml.sheet → xlsx-previewable', () => {
expect(
resolveFileCategory(
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'data.xlsx'
)
).toBe('xlsx-previewable')
})
})
})
describe('resolveFileCategory — extension fallback', () => {
describe('text-editable extensions', () => {
it.each(['md', 'txt', 'json', 'yaml', 'yml', 'csv', 'html', 'htm', 'svg', 'mmd'])(
'.%s → text-editable',
(ext) => {
expect(resolveFileCategory(null, `file.${ext}`)).toBe('text-editable')
}
)
})
describe('code extensions from SUPPORTED_CODE_EXTENSIONS', () => {
it.each(['js', 'ts', 'py', 'go', 'rs', 'sh', 'sql'])('.%s → text-editable', (ext) => {
expect(resolveFileCategory(null, `file.${ext}`)).toBe('text-editable')
})
})
describe('pdf extension', () => {
it('.pdf → iframe-previewable', () => {
expect(resolveFileCategory(null, 'document.pdf')).toBe('iframe-previewable')
})
})
describe('image extensions', () => {
it.each(['png', 'jpg', 'jpeg', 'gif', 'webp'])('.%s → image-previewable', (ext) => {
expect(resolveFileCategory(null, `image.${ext}`)).toBe('image-previewable')
})
})
describe('audio extensions', () => {
it.each(['mp3', 'm4a', 'wav', 'ogg', 'flac', 'aac', 'opus'])(
'.%s → audio-previewable',
(ext) => {
expect(resolveFileCategory(null, `audio.${ext}`)).toBe('audio-previewable')
}
)
})
describe('video extensions', () => {
it.each(['mp4', 'mov', 'avi', 'mkv', 'webm'])('.%s → video-previewable', (ext) => {
expect(resolveFileCategory(null, `video.${ext}`)).toBe('video-previewable')
})
})
describe('docx extension', () => {
it('.docx → docx-previewable', () => {
expect(resolveFileCategory(null, 'doc.docx')).toBe('docx-previewable')
})
})
describe('pptx extension', () => {
it('.pptx → pptx-previewable', () => {
expect(resolveFileCategory(null, 'deck.pptx')).toBe('pptx-previewable')
})
})
describe('xlsx extension', () => {
it('.xlsx → xlsx-previewable', () => {
expect(resolveFileCategory(null, 'data.xlsx')).toBe('xlsx-previewable')
})
})
describe('unsupported', () => {
it('unknown extension → unsupported', () => {
expect(resolveFileCategory(null, 'file.xyz')).toBe('unsupported')
})
it('unknown mime with unknown extension → unsupported', () => {
expect(resolveFileCategory('application/octet-stream', 'file.bin')).toBe('unsupported')
})
it('no extension, no mime → unsupported', () => {
expect(resolveFileCategory(null, 'LICENSE')).toBe('unsupported')
})
})
})
describe('resolveFileCategory — MIME priority', () => {
it('text/plain MIME + .pdf extension → text-editable (MIME wins)', () => {
expect(resolveFileCategory('text/plain', 'notes.pdf')).toBe('text-editable')
})
it('application/pdf MIME + .txt extension → iframe-previewable (MIME wins)', () => {
expect(resolveFileCategory('application/pdf', 'disguised.txt')).toBe('iframe-previewable')
})
it('null MIME falls through to extension routing', () => {
expect(resolveFileCategory(null, 'data.xlsx')).toBe('xlsx-previewable')
})
it('unknown MIME falls through to extension routing', () => {
expect(resolveFileCategory('application/octet-stream', 'data.xlsx')).toBe('xlsx-previewable')
})
})
describe('resolveFileCategory — extension case', () => {
it('recognises uppercase extension via extension lookup (getFileExtension lowercases)', () => {
expect(resolveFileCategory(null, 'README.MD')).toBe('text-editable')
})
it('handles mixed-case correctly for json', () => {
expect(resolveFileCategory(null, 'config.JSON')).toBe('text-editable')
})
})
@@ -0,0 +1,122 @@
import { getFileExtension } from '@/lib/uploads/utils/file-utils'
import { SUPPORTED_CODE_EXTENSIONS } from '@/lib/uploads/utils/validation'
const TEXT_EDITABLE_MIME_TYPES = new Set([
'text/markdown',
'text/plain',
'application/json',
'application/x-yaml',
'text/csv',
'text/html',
'text/xml',
'application/xml',
'text/css',
'text/javascript',
'application/javascript',
'application/typescript',
'application/toml',
'text/x-python',
'text/x-sh',
'text/x-sql',
'image/svg+xml',
'text/x-mermaid',
])
const TEXT_EDITABLE_EXTENSIONS = new Set([
'md',
'txt',
'json',
'yaml',
'yml',
'csv',
'html',
'htm',
'svg',
'mmd',
...SUPPORTED_CODE_EXTENSIONS,
])
const IFRAME_PREVIEWABLE_MIME_TYPES = new Set([
'application/pdf',
'text/x-pdflibjs',
'text/x-python-pdf',
])
const IFRAME_PREVIEWABLE_EXTENSIONS = new Set(['pdf'])
const IMAGE_PREVIEWABLE_MIME_TYPES = new Set(['image/png', 'image/jpeg', 'image/gif', 'image/webp'])
const IMAGE_PREVIEWABLE_EXTENSIONS = new Set(['png', 'jpg', 'jpeg', 'gif', 'webp'])
const AUDIO_PREVIEWABLE_MIME_TYPES = new Set([
'audio/mpeg',
'audio/mp4',
'audio/wav',
'audio/webm',
'audio/ogg',
'audio/flac',
'audio/aac',
'audio/opus',
'audio/x-m4a',
])
const AUDIO_PREVIEWABLE_EXTENSIONS = new Set(['mp3', 'm4a', 'wav', 'ogg', 'flac', 'aac', 'opus'])
const VIDEO_PREVIEWABLE_MIME_TYPES = new Set([
'video/mp4',
'video/quicktime',
'video/x-msvideo',
'video/x-matroska',
'video/webm',
])
const VIDEO_PREVIEWABLE_EXTENSIONS = new Set(['mp4', 'mov', 'avi', 'mkv', 'webm'])
const PPTX_PREVIEWABLE_MIME_TYPES = new Set([
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
'text/x-pptxgenjs',
])
const PPTX_PREVIEWABLE_EXTENSIONS = new Set(['pptx'])
const DOCX_PREVIEWABLE_MIME_TYPES = new Set([
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'text/x-docxjs',
])
const DOCX_PREVIEWABLE_EXTENSIONS = new Set(['docx'])
const XLSX_PREVIEWABLE_MIME_TYPES = new Set([
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'text/x-python-xlsx',
])
const XLSX_PREVIEWABLE_EXTENSIONS = new Set(['xlsx'])
export type FileCategory =
| 'text-editable'
| 'iframe-previewable'
| 'image-previewable'
| 'audio-previewable'
| 'video-previewable'
| 'pptx-previewable'
| 'docx-previewable'
| 'xlsx-previewable'
| 'unsupported'
export function resolveFileCategory(mimeType: string | null, filename: string): FileCategory {
if (mimeType && TEXT_EDITABLE_MIME_TYPES.has(mimeType)) return 'text-editable'
if (mimeType && IFRAME_PREVIEWABLE_MIME_TYPES.has(mimeType)) return 'iframe-previewable'
if (mimeType && IMAGE_PREVIEWABLE_MIME_TYPES.has(mimeType)) return 'image-previewable'
if (mimeType && AUDIO_PREVIEWABLE_MIME_TYPES.has(mimeType)) return 'audio-previewable'
if (mimeType && VIDEO_PREVIEWABLE_MIME_TYPES.has(mimeType)) return 'video-previewable'
if (mimeType && DOCX_PREVIEWABLE_MIME_TYPES.has(mimeType)) return 'docx-previewable'
if (mimeType && PPTX_PREVIEWABLE_MIME_TYPES.has(mimeType)) return 'pptx-previewable'
if (mimeType && XLSX_PREVIEWABLE_MIME_TYPES.has(mimeType)) return 'xlsx-previewable'
const ext = getFileExtension(filename)
const nameKey = ext || filename.toLowerCase()
if (TEXT_EDITABLE_EXTENSIONS.has(nameKey)) return 'text-editable'
if (IFRAME_PREVIEWABLE_EXTENSIONS.has(ext)) return 'iframe-previewable'
if (IMAGE_PREVIEWABLE_EXTENSIONS.has(ext)) return 'image-previewable'
if (AUDIO_PREVIEWABLE_EXTENSIONS.has(ext)) return 'audio-previewable'
if (VIDEO_PREVIEWABLE_EXTENSIONS.has(ext)) return 'video-previewable'
if (DOCX_PREVIEWABLE_EXTENSIONS.has(ext)) return 'docx-previewable'
if (PPTX_PREVIEWABLE_EXTENSIONS.has(ext)) return 'pptx-previewable'
if (XLSX_PREVIEWABLE_EXTENSIONS.has(ext)) return 'xlsx-previewable'
return 'unsupported'
}
@@ -0,0 +1,421 @@
'use client'
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Music } from 'lucide-react'
import dynamic from 'next/dynamic'
import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace'
import { getFileExtension } from '@/lib/uploads/utils/file-utils'
import { useWorkspaceFileBinary, useWorkspaceFileContent } from '@/hooks/queries/workspace-files'
import {
createWorkspaceFileContentSource,
type FileContentSource,
FileContentSourceProvider,
} from '@/hooks/use-file-content-source'
import { CsvTablePreview } from './csv-table-preview'
import { DocxPreview } from './docx-preview'
import { resolveFileCategory } from './file-category'
import { ImagePreview } from './image-preview'
import type { PdfDocumentSource } from './pdf-viewer'
import { PptxPreview } from './pptx-preview'
import { PreviewPanel, resolvePreviewType } from './preview-panel'
import {
PREVIEW_LOADING_OVERLAY,
PreviewError,
PreviewErrorBoundary,
PreviewLoadingFrame,
resolvePreviewError,
} from './preview-shared'
import { TextEditor } from './text-editor'
import { useDocPreviewBinary } from './use-doc-preview-binary'
import { XlsxPreview } from './xlsx-preview'
const PdfViewerCore = dynamic(() => import('./pdf-viewer').then((m) => m.PdfViewerCore), {
ssr: false,
})
const RichMarkdownEditor = dynamic(
() => import('./rich-markdown-editor/rich-markdown-editor').then((m) => m.RichMarkdownEditor),
{ ssr: false, loading: () => <PreviewLoadingFrame className='flex flex-1 flex-col' /> }
)
/**
* CSVs at or below this size load fully into the editor (editable, with an inline preview).
* Larger CSVs would OOM the browser on `response.text()`, so they render a read-only,
* server-streamed preview of the first rows instead (see {@link CsvTablePreview}).
*/
const CSV_INLINE_EDIT_MAX_BYTES = 5 * 1024 * 1024
export function isTextEditable(file: { type: string; name: string }): boolean {
return resolveFileCategory(file.type, file.name) === 'text-editable'
}
export function isPreviewable(file: { type: string; name: string }): boolean {
return resolvePreviewType(file.type, file.name) !== null
}
/**
* Markdown files render in the inline rich editor ({@link RichMarkdownEditor}) rather than
* the raw Monaco editor. Toolbars use this to hide the raw/split/preview mode controls,
* which don't apply to the single-surface editor.
*/
export function isMarkdownFile(file: { type: string; name: string }): boolean {
return resolvePreviewType(file.type, file.name) === 'markdown'
}
/**
* A CSV larger than {@link CSV_INLINE_EDIT_MAX_BYTES} is shown as a streamed, read-only preview —
* the editor would OOM loading the whole file. The viewer renders {@link CsvTablePreview} for it,
* and toolbars use this to hide the edit/split/save controls (there is no editor to switch to).
*/
export function isCsvStreamOnly(file: {
type: string | null
name: string
size?: number | null
}): boolean {
return (
resolvePreviewType(file.type, file.name) === 'csv' &&
(file.size ?? 0) > CSV_INLINE_EDIT_MAX_BYTES
)
}
export type PreviewMode = 'editor' | 'split' | 'preview'
interface FileViewerProps {
file: WorkspaceFileRecord
workspaceId: string
/**
* Content source for this view. Defaults to a workspace-scoped source derived from `workspaceId`;
* the public share page passes a token-scoped source. Provided to descendants (renderers, embedded
* images) via {@link FileContentSourceProvider}.
*/
contentSource?: FileContentSource
canEdit: boolean
/**
* Render a read-only preview with no editing affordances. Text files render
* through {@link PreviewPanel} (or a plain `<pre>`) instead of the editable
* {@link TextEditor}. Used by the public share page.
*/
readOnly?: boolean
previewMode?: PreviewMode
autoFocus?: boolean
onDirtyChange?: (isDirty: boolean) => void
onSaveStatusChange?: (
status: 'idle' | 'saving' | 'saved' | 'error',
retry?: () => Promise<void>
) => void
saveRef?: React.MutableRefObject<(() => Promise<void>) | null>
discardRef?: React.MutableRefObject<(() => void) | null>
streamingContent?: string
isAgentEditing?: boolean
streamIsIncremental?: boolean
disableStreamingAutoScroll?: boolean
previewContextKey?: string
}
export function FileViewer(props: FileViewerProps) {
const { contentSource, workspaceId } = props
const source = useMemo(
() => contentSource ?? createWorkspaceFileContentSource(workspaceId),
[contentSource, workspaceId]
)
return (
<FileContentSourceProvider value={source}>
<FileViewerContent {...props} />
</FileContentSourceProvider>
)
}
function FileViewerContent({
file,
workspaceId,
canEdit,
readOnly = false,
previewMode,
autoFocus,
onDirtyChange,
onSaveStatusChange,
saveRef,
discardRef,
streamingContent,
isAgentEditing,
streamIsIncremental,
disableStreamingAutoScroll = false,
previewContextKey,
}: FileViewerProps) {
const category = resolveFileCategory(file.type, file.name)
if (category === 'text-editable') {
if (readOnly) {
// ReadOnlyTextPreview loads the whole file as text; a large CSV would OOM the
// browser. CsvTablePreview's streamed fallback is workspace-only, so on the
// read-only public path a large CSV is download-only.
if (isCsvStreamOnly(file)) {
return <UnsupportedPreview file={file} />
}
// Markdown renders through the inline rich editor (non-editable) so the public share
// surface matches the in-app reading experience; canEdit={false} disables autosave,
// the bubble menu, and every other editing affordance.
if (isMarkdownFile(file)) {
return (
<RichMarkdownEditor key={file.id} file={file} workspaceId={workspaceId} canEdit={false} />
)
}
return <ReadOnlyTextPreview file={file} workspaceId={workspaceId} />
}
// A large CSV can't be loaded whole into the editor (the browser OOMs on the full text).
// Render a streamed, read-only preview of the first rows + an "Import as a table" path instead.
if (isCsvStreamOnly(file)) {
return <CsvTablePreview key={file.id} file={file} workspaceId={workspaceId} />
}
if (isMarkdownFile(file)) {
return (
<RichMarkdownEditor
key={file.id}
file={file}
workspaceId={workspaceId}
canEdit={canEdit}
autoFocus={autoFocus}
onDirtyChange={onDirtyChange}
onSaveStatusChange={onSaveStatusChange}
saveRef={saveRef}
discardRef={discardRef}
streamingContent={streamingContent}
isAgentEditing={isAgentEditing}
streamIsIncremental={streamIsIncremental}
disableStreamingAutoScroll={disableStreamingAutoScroll}
previewContextKey={previewContextKey}
/>
)
}
return (
<TextEditor
file={file}
workspaceId={workspaceId}
canEdit={canEdit}
previewMode={previewMode ?? 'editor'}
autoFocus={autoFocus}
onDirtyChange={onDirtyChange}
onSaveStatusChange={onSaveStatusChange}
saveRef={saveRef}
discardRef={discardRef}
streamingContent={streamingContent}
isAgentEditing={isAgentEditing}
disableStreamingAutoScroll={disableStreamingAutoScroll}
previewContextKey={previewContextKey}
/>
)
}
if (category === 'iframe-previewable') {
return <IframePreview key={file.id} file={file} workspaceId={workspaceId} />
}
if (category === 'image-previewable') {
return <ImagePreview key={file.key} file={file} />
}
if (category === 'audio-previewable') {
return <MediaPreview key={file.id} file={file} workspaceId={workspaceId} kind='audio' />
}
if (category === 'video-previewable') {
return <MediaPreview key={file.id} file={file} workspaceId={workspaceId} kind='video' />
}
if (category === 'docx-previewable') {
return <DocxPreview key={file.id} file={file} workspaceId={workspaceId} />
}
if (category === 'pptx-previewable') {
return <PptxPreview key={file.id} file={file} workspaceId={workspaceId} />
}
if (category === 'xlsx-previewable') {
return <XlsxPreview key={file.id} file={file} workspaceId={workspaceId} />
}
return <UnsupportedPreview file={file} />
}
/**
* Read-only text/markdown/code preview. Renders rich types (markdown, csv, svg,
* mermaid, html) through {@link PreviewPanel} and plain text/code in a `<pre>`.
* Fetches content through the active content source, so it works for both
* workspace files and public share links.
*/
const ReadOnlyTextPreview = memo(function ReadOnlyTextPreview({
file,
workspaceId,
}: {
file: WorkspaceFileRecord
workspaceId: string
}) {
const {
data: content,
isLoading,
error,
} = useWorkspaceFileContent(workspaceId, file.id, file.key)
const resolvedError = resolvePreviewError((error as Error | null) ?? null, null)
if (resolvedError) return <PreviewError label='file' error={resolvedError} />
if (isLoading || content == null) return <PreviewLoadingFrame className='h-full' tone='surface' />
if (resolvePreviewType(file.type, file.name)) {
return (
<div className='h-full min-h-0 w-full overflow-auto'>
<PreviewPanel
content={content}
mimeType={file.type}
filename={file.name}
workspaceId={workspaceId}
fileKey={file.key}
readOnly
/>
</div>
)
}
return (
<div className='h-full min-h-0 w-full overflow-auto bg-[var(--surface-1)] p-4'>
<pre className='whitespace-pre-wrap break-words font-mono text-[13px] text-[var(--text-body)]'>
{content}
</pre>
</div>
)
})
const IframePreview = memo(function IframePreview({
file,
workspaceId,
}: {
file: WorkspaceFileRecord
workspaceId: string
}) {
const preview = useDocPreviewBinary(workspaceId, file)
const bufferSource = useMemo<PdfDocumentSource | null>(
() => (preview.data ? { kind: 'buffer', buffer: preview.data } : null),
[preview.data]
)
const error = resolvePreviewError(preview.error, null)
if (error) return <PreviewError label='PDF' error={error} />
if (!bufferSource) {
return <div className='relative flex flex-1 overflow-hidden'>{PREVIEW_LOADING_OVERLAY}</div>
}
return (
<PreviewErrorBoundary key={`${file.id}:${preview.dataUpdatedAt}`} label='PDF'>
<PdfViewerCore source={bufferSource} filename={file.name} />
</PreviewErrorBoundary>
)
})
function useBlobUrl(workspaceId: string, fileId: string, fileKey: string) {
const { data: fileData, isLoading, error } = useWorkspaceFileBinary(workspaceId, fileId, fileKey)
const [blobUrl, setBlobUrl] = useState<string | null>(null)
const blobUrlRef = useRef<string | null>(null)
const replaceBlobUrl = useCallback((nextUrl: string | null) => {
const previousUrl = blobUrlRef.current
blobUrlRef.current = nextUrl
setBlobUrl(nextUrl)
if (previousUrl && previousUrl !== nextUrl) URL.revokeObjectURL(previousUrl)
}, [])
useEffect(() => {
return () => {
if (blobUrlRef.current) {
URL.revokeObjectURL(blobUrlRef.current)
blobUrlRef.current = null
}
}
}, [])
return { fileData, isLoading, error, blobUrl, replaceBlobUrl }
}
const MEDIA_FALLBACK_MIME = { audio: 'audio/mpeg', video: 'video/mp4' } as const
/**
* Shared blob-backed preview for audio and video files — the fetch, blob-URL
* lifecycle, and error/loading handling are identical; only the rendered
* player differs.
*/
const MediaPreview = memo(function MediaPreview({
file,
workspaceId,
kind,
}: {
file: WorkspaceFileRecord
workspaceId: string
kind: 'audio' | 'video'
}) {
const {
fileData,
isLoading,
error: fetchError,
blobUrl,
replaceBlobUrl,
} = useBlobUrl(workspaceId, file.id, file.key)
useEffect(() => {
if (!fileData) return
replaceBlobUrl(
URL.createObjectURL(new Blob([fileData], { type: file.type || MEDIA_FALLBACK_MIME[kind] }))
)
}, [file.type, fileData, kind, replaceBlobUrl])
const error = blobUrl !== null ? null : resolvePreviewError(fetchError, null)
if (error) return <PreviewError label={kind} error={error} />
if (isLoading && !blobUrl) {
return <PreviewLoadingFrame className='h-full' tone='surface' />
}
if (kind === 'audio') {
return (
<div className='flex h-full flex-col items-center justify-center gap-4 bg-[var(--surface-1)] p-8'>
<div className='flex flex-col items-center gap-2 text-center'>
<Music className='size-[32px] text-[var(--text-muted)]' strokeWidth={1.5} />
<p className='font-medium text-[14px] text-[var(--text-primary)]'>{file.name}</p>
</div>
{blobUrl && (
// biome-ignore lint/a11y/useMediaCaption: audio from workspace files
<audio src={blobUrl} controls className='w-full max-w-[480px]' />
)}
</div>
)
}
return (
<div className='flex h-full items-center justify-center bg-[var(--surface-1)]'>
{blobUrl && (
// biome-ignore lint/a11y/useMediaCaption: video from workspace files
<video src={blobUrl} controls className='max-h-full max-w-full' />
)}
</div>
)
})
const UnsupportedPreview = memo(function UnsupportedPreview({
file,
}: {
file: WorkspaceFileRecord
}) {
const ext = getFileExtension(file.name)
return (
<div className='flex flex-1 flex-col items-center justify-center gap-[8px]'>
<p className='font-medium text-[14px] text-[var(--text-primary)]'>
Preview not available{ext ? ` for .${ext} files` : ' for this file'}
</p>
<p className='text-[13px] text-[var(--text-muted)]'>
Use the download button to view this file
</p>
</div>
)
})
@@ -0,0 +1,34 @@
'use client'
import { memo, useState } from 'react'
import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace'
import { useFileContentSource } from '@/hooks/use-file-content-source'
import { PREVIEW_LOADING_OVERLAY } from './preview-shared'
import { ZoomablePreview } from './zoomable-preview'
export const ImagePreview = memo(function ImagePreview({ file }: { file: WorkspaceFileRecord }) {
const source = useFileContentSource()
const [hasSettled, setHasSettled] = useState(false)
// Version the URL on updatedAt: overwrites keep the same storage key, so an unversioned
// URL would resolve to a previously cached copy instead of the rewritten bytes.
const serveUrl = source.buildUrl(file.key, {
version: Number(new Date(file.updatedAt)) || file.size,
})
return (
<div className='relative flex min-h-0 flex-1 flex-col'>
<ZoomablePreview className='flex flex-1' contentClassName='h-full w-full'>
<img
src={serveUrl}
alt={file.name}
className='max-h-full max-w-full select-none rounded-md object-contain'
draggable={false}
loading='eager'
onLoad={() => setHasSettled(true)}
onError={() => setHasSettled(true)}
/>
</ZoomablePreview>
{!hasSettled && PREVIEW_LOADING_OVERLAY}
</div>
)
})
@@ -0,0 +1,10 @@
export { resolveFileCategory } from './file-category'
export type { PreviewMode } from './file-viewer'
export {
FileViewer,
isCsvStreamOnly,
isMarkdownFile,
isPreviewable,
isTextEditable,
} from './file-viewer'
export { PreviewPanel, RICH_PREVIEWABLE_EXTENSIONS, resolvePreviewType } from './preview-panel'
@@ -0,0 +1,63 @@
/**
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import { looksLikeMermaid } from './mermaid-diagram'
describe('looksLikeMermaid', () => {
it('detects blocks whose first line opens with a diagram keyword', () => {
expect(looksLikeMermaid('flowchart TD\n A --> B')).toBe(true)
expect(looksLikeMermaid('graph LR\n A --> B')).toBe(true)
expect(looksLikeMermaid('sequenceDiagram\n Alice->>Bob: Hi')).toBe(true)
expect(looksLikeMermaid('pie title NETFLIX\n "A" : 90')).toBe(true)
expect(looksLikeMermaid('stateDiagram-v2\n [*] --> S')).toBe(true)
expect(looksLikeMermaid('gantt\n title A')).toBe(true)
})
it('detects the remaining diagram openers', () => {
expect(looksLikeMermaid('classDiagram\n Animal <|-- Duck')).toBe(true)
expect(looksLikeMermaid('stateDiagram\n [*] --> S')).toBe(true)
expect(looksLikeMermaid('erDiagram\n A ||--o{ B : has')).toBe(true)
expect(looksLikeMermaid('journey\n title My day')).toBe(true)
expect(looksLikeMermaid('quadrantChart\n title Reach')).toBe(true)
expect(looksLikeMermaid('requirementDiagram')).toBe(true)
expect(looksLikeMermaid('gitGraph')).toBe(true)
expect(looksLikeMermaid('mindmap\n root')).toBe(true)
expect(looksLikeMermaid('timeline\n title History')).toBe(true)
expect(looksLikeMermaid('sankey-beta')).toBe(true)
expect(looksLikeMermaid('xychart-beta')).toBe(true)
expect(looksLikeMermaid('block-beta')).toBe(true)
expect(looksLikeMermaid('packet-beta')).toBe(true)
expect(looksLikeMermaid('kanban')).toBe(true)
expect(looksLikeMermaid('architecture-beta')).toBe(true)
expect(looksLikeMermaid('zenuml')).toBe(true)
expect(looksLikeMermaid('C4Context\n title System')).toBe(true)
expect(looksLikeMermaid('C4Container')).toBe(true)
expect(looksLikeMermaid('C4Component')).toBe(true)
expect(looksLikeMermaid('C4Dynamic')).toBe(true)
expect(looksLikeMermaid('C4Deployment')).toBe(true)
})
it('skips leading blank lines before the opener', () => {
expect(looksLikeMermaid('\n\n flowchart TD\n A --> B')).toBe(true)
expect(looksLikeMermaid('\n \n\t\nsequenceDiagram\n Alice->>Bob: Hi')).toBe(true)
})
it('rejects ordinary code that merely contains a keyword later', () => {
expect(looksLikeMermaid('const graph = makeGraph()\nreturn graph')).toBe(false)
expect(looksLikeMermaid('print("pie")')).toBe(false)
expect(looksLikeMermaid('SELECT * FROM pies')).toBe(false)
expect(looksLikeMermaid('')).toBe(false)
expect(looksLikeMermaid('\n\n \n')).toBe(false)
expect(looksLikeMermaid('# flowchart of the system')).toBe(false)
expect(looksLikeMermaid(' // graph helpers')).toBe(false)
})
it('requires a word boundary after the keyword', () => {
expect(looksLikeMermaid('graphql query { user }')).toBe(false)
expect(looksLikeMermaid('pieChart()')).toBe(false)
expect(looksLikeMermaid('flowcharting()')).toBe(false)
expect(looksLikeMermaid('ganttify')).toBe(false)
expect(looksLikeMermaid('journeyman')).toBe(false)
})
})
@@ -0,0 +1,287 @@
'use client'
/**
* Must precede the react-pdf import: pdf.js calls the polyfilled APIs while
* its module evaluates, which throws on Safari < 17.4 without them.
*/
import '@/lib/core/utils/browser-polyfills'
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
import { pdfjs, Document as ReactPdfDocument, Page as ReactPdfPage } from 'react-pdf'
import 'react-pdf/dist/Page/TextLayer.css'
import { PREVIEW_LOADING_OVERLAY } from '@/app/workspace/[workspaceId]/files/components/file-viewer/preview-shared'
import { PreviewToolbar } from '@/app/workspace/[workspaceId]/files/components/file-viewer/preview-toolbar'
import { bindPreviewWheelZoom } from '@/app/workspace/[workspaceId]/files/components/file-viewer/preview-wheel-zoom'
/**
* The worker runs in its own context that browser-polyfills cannot reach, so
* serve the legacy worker build, which bundles its own polyfills.
*/
pdfjs.GlobalWorkerOptions.workerSrc = new URL(
'pdfjs-dist/legacy/build/pdf.worker.min.mjs',
import.meta.url
).href
const logger = createLogger('PdfViewer')
const PDF_ZOOM_MIN = 0.5
const PDF_ZOOM_MAX = 3
const PDF_ZOOM_DEFAULT = 1
const PDF_ZOOM_STEP = 1.25
const PDF_VIEWER_PADDING = 24
const PDF_RESIZE_DEBOUNCE_MS = 150
export type PdfDocumentSource =
| { kind: 'url'; url: string }
| { kind: 'buffer'; buffer: ArrayBuffer }
interface PdfViewerCoreProps {
source: PdfDocumentSource
filename: string
}
function PdfError({ error }: { error: string }) {
return (
<div className='flex flex-1 flex-col items-center justify-center gap-[8px]'>
<p className='font-medium text-[14px] text-[var(--text-body)]'>Failed to preview PDF</p>
<p className='text-[13px] text-[var(--text-muted)]'>{error}</p>
</div>
)
}
export const PdfViewerCore = memo(function PdfViewerCore({ source, filename }: PdfViewerCoreProps) {
const containerRef = useRef<HTMLDivElement>(null)
const paddingWrapperRef = useRef<HTMLDivElement>(null)
const pagesWrapperRef = useRef<HTMLDivElement>(null)
const pageRefs = useRef<(HTMLDivElement | null)[]>([])
const pageWidthRef = useRef<number | undefined>(undefined)
const zoomRef = useRef(PDF_ZOOM_DEFAULT)
const [containerWidth, setContainerWidth] = useState(0)
const [pageCount, setPageCount] = useState(0)
const [isDocumentReady, setIsDocumentReady] = useState(false)
const [displayZoom, setDisplayZoom] = useState(PDF_ZOOM_DEFAULT)
const [currentPage, setCurrentPage] = useState(1)
const [loadError, setLoadError] = useState<string | null>(null)
const sourceValue = source.kind === 'url' ? source.url : source.buffer
/**
* The buffer copy (`slice(0)`) is load-bearing: pdf.js transfers — and
* detaches — the ArrayBuffer it receives to its worker, so handing over the
* caller's buffer would leave it unusable on the next render or remount.
*/
const file = useMemo(
() => (source.kind === 'url' ? source.url : { data: new Uint8Array(source.buffer.slice(0)) }),
[sourceValue]
)
/**
* The first non-zero measurement applies immediately so the document renders
* without delay (a hidden container reports zero width and must not consume
* the immediate slot); subsequent ones (panel-divider drags) are debounced
* because every pageWidth change makes pdf.js re-rasterise all page canvases
* — per-tick updates during a drag would re-render the whole document
* continuously.
*/
useEffect(() => {
const container = containerRef.current
if (!container) return
let hasMeasured = false
let debounce: ReturnType<typeof setTimeout> | undefined
const observer = new ResizeObserver(([entry]) => {
const { width } = entry.contentRect
if (!hasMeasured) {
if (width <= 0) return
hasMeasured = true
setContainerWidth(width)
return
}
clearTimeout(debounce)
debounce = setTimeout(() => setContainerWidth(width), PDF_RESIZE_DEBOUNCE_MS)
})
observer.observe(container)
return () => {
clearTimeout(debounce)
observer.disconnect()
}
}, [])
/**
* 100% zoom fits the page to the panel width (pdf.js re-renders the canvas
* at the target width, so upscaling past the page's natural print size
* stays crisp). Matches the DOCX preview's fit-to-width semantics.
*/
const pageWidth = containerWidth > 0 ? containerWidth - 2 * PDF_VIEWER_PADDING : undefined
pageWidthRef.current = pageWidth
const applyZoomAt = useCallback((next: number, anchorX: number, anchorY: number) => {
const container = containerRef.current
const wrapper = pagesWrapperRef.current
const padWrapper = paddingWrapperRef.current
const pw = pageWidthRef.current
if (!container || !wrapper) return
const ratio = next / zoomRef.current
wrapper.style.zoom = String(next)
if (padWrapper && pw !== undefined) {
padWrapper.style.minWidth = `${pw * next + 2 * PDF_VIEWER_PADDING}px`
}
// Padding is outside the zoom subtree, so offset the anchor by it before scaling.
container.scrollLeft =
(container.scrollLeft + anchorX - PDF_VIEWER_PADDING) * ratio + PDF_VIEWER_PADDING - anchorX
container.scrollTop =
(container.scrollTop + anchorY - PDF_VIEWER_PADDING) * ratio + PDF_VIEWER_PADDING - anchorY
zoomRef.current = next
setDisplayZoom(next)
}, [])
const scrollToPage = (page: number) => {
const container = containerRef.current
if (container && zoomRef.current !== PDF_ZOOM_DEFAULT) {
applyZoomAt(PDF_ZOOM_DEFAULT, container.clientWidth / 2, container.clientHeight / 2)
}
const wrapper = pageRefs.current[page - 1]
if (wrapper && container) {
container.scrollTo({ top: wrapper.offsetTop - 16, behavior: 'smooth' })
}
}
useEffect(() => {
const container = containerRef.current
if (!container || pageCount === 0) return
const observer = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (entry.isIntersecting) {
const pageNum = Number((entry.target as HTMLElement).dataset.page)
if (pageNum) setCurrentPage(pageNum)
}
}
},
{ root: container, threshold: 0.5 }
)
for (const wrapper of pageRefs.current) {
if (wrapper) observer.observe(wrapper)
}
return () => observer.disconnect()
}, [pageCount])
useEffect(() => {
const container = containerRef.current
if (!container) return
return bindPreviewWheelZoom(container, (e) => {
const next = Math.min(
PDF_ZOOM_MAX,
Math.max(PDF_ZOOM_MIN, zoomRef.current * (1 - e.deltaY * 0.005))
)
const rect = container.getBoundingClientRect()
applyZoomAt(next, e.clientX - rect.left, e.clientY - rect.top)
})
}, [applyZoomAt])
return (
<div className='flex flex-1 flex-col overflow-hidden'>
{pageCount > 0 && !loadError && (
<PreviewToolbar
navigation={{
current: currentPage,
total: pageCount,
label: 'page',
onPrevious: () => {
const prev = Math.max(1, currentPage - 1)
setCurrentPage(prev)
scrollToPage(prev)
},
onNext: () => {
const next = Math.min(pageCount, currentPage + 1)
setCurrentPage(next)
scrollToPage(next)
},
}}
zoom={{
label: `${Math.round(displayZoom * 100)}%`,
canZoomOut: displayZoom > PDF_ZOOM_MIN,
canZoomIn: displayZoom < PDF_ZOOM_MAX,
onZoomOut: () => {
const c = containerRef.current
applyZoomAt(
Math.max(PDF_ZOOM_MIN, zoomRef.current / PDF_ZOOM_STEP),
c ? c.clientWidth / 2 : 0,
c ? c.clientHeight / 2 : 0
)
},
onZoomIn: () => {
const c = containerRef.current
applyZoomAt(
Math.min(PDF_ZOOM_MAX, zoomRef.current * PDF_ZOOM_STEP),
c ? c.clientWidth / 2 : 0,
c ? c.clientHeight / 2 : 0
)
},
}}
/>
)}
<div
ref={containerRef}
className='relative flex flex-1 items-start overflow-auto bg-[var(--surface-1)]'
>
{!isDocumentReady && PREVIEW_LOADING_OVERLAY}
<ReactPdfDocument
file={file}
onLoadSuccess={({ numPages }) => {
setPageCount(numPages)
setCurrentPage(1)
setLoadError(null)
setIsDocumentReady(true)
}}
onLoadError={(err) => {
logger.error('PDF load failed', { error: err.message })
setLoadError(err.message)
setIsDocumentReady(true)
}}
error={<PdfError error={loadError ?? 'Failed to load PDF'} />}
className='mx-auto'
>
<div
ref={paddingWrapperRef}
style={{
padding: PDF_VIEWER_PADDING,
minWidth:
pageWidth !== undefined
? `${pageWidth * displayZoom + 2 * PDF_VIEWER_PADDING}px`
: undefined,
}}
>
<div ref={pagesWrapperRef} style={{ width: pageWidth }}>
{Array.from({ length: pageCount }, (_, i) => (
<div
key={i}
ref={(el) => {
pageRefs.current[i] = el
}}
data-page={i + 1}
className='mb-4 overflow-clip rounded-md shadow-medium'
>
<ReactPdfPage
pageNumber={i + 1}
width={pageWidth}
className='!overflow-clip [&_.textLayer]:!overflow-clip'
renderTextLayer
renderAnnotationLayer={false}
aria-label={`${filename} page ${i + 1}`}
/>
</div>
))}
</div>
</div>
</ReactPdfDocument>
</div>
</div>
)
})
@@ -0,0 +1,73 @@
'use client'
import { memo, useEffect, useState } from 'react'
import { createLogger } from '@sim/logger'
import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace'
import { PptxSandboxHost } from '@/app/workspace/[workspaceId]/files/components/file-viewer/pptx-sandbox-host'
import {
PREVIEW_LOADING_OVERLAY,
PreviewError,
PreviewLoadingFrame,
resolvePreviewError,
} from '@/app/workspace/[workspaceId]/files/components/file-viewer/preview-shared'
import { useDocPreviewBinary } from '@/app/workspace/[workspaceId]/files/components/file-viewer/use-doc-preview-binary'
const logger = createLogger('PptxPreview')
function pptxCacheKey(fileId: string, dataUpdatedAt: number, byteLength: number): string {
return `${fileId}:${dataUpdatedAt}:${byteLength}`
}
export const PptxPreview = memo(function PptxPreview({
file,
workspaceId,
}: {
file: WorkspaceFileRecord
workspaceId: string
}) {
const preview = useDocPreviewBinary(workspaceId, file)
const fileData = preview.data
const cacheKey = pptxCacheKey(file.id, preview.dataUpdatedAt, fileData?.byteLength ?? 0)
const [hasRendered, setHasRendered] = useState(false)
const [renderError, setRenderError] = useState<string | null>(null)
useEffect(() => {
setRenderError(null)
setHasRendered(false)
}, [cacheKey])
function handleRenderStart() {
setRenderError(null)
}
function handleRenderComplete() {
setHasRendered(true)
}
function handleRenderError(message: string) {
logger.error('PPTX render failed', { error: message })
setRenderError(message || 'Failed to render presentation')
}
const error = resolvePreviewError(preview.error, renderError)
if (error) return <PreviewError label='presentation' error={error} />
if (!fileData) {
return <PreviewLoadingFrame className='h-full flex-1' tone='surface' />
}
return (
<div className='relative flex h-full min-h-0 flex-1 overflow-hidden bg-[var(--surface-1)]'>
<PptxSandboxHost
buffer={fileData}
requestId={cacheKey}
onRenderStart={handleRenderStart}
onRenderComplete={handleRenderComplete}
onRenderError={handleRenderError}
/>
{!hasRendered && PREVIEW_LOADING_OVERLAY}
</div>
)
})
@@ -0,0 +1,219 @@
'use client'
import { memo, useCallback, useEffect, useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
import { openSimPptxViewer, type SimPptxViewerHandle } from '@/lib/pptx-renderer/sim-pptx-viewer'
import { PreviewToolbar } from '@/app/workspace/[workspaceId]/files/components/file-viewer/preview-toolbar'
import { bindPreviewWheelZoom } from '@/app/workspace/[workspaceId]/files/components/file-viewer/preview-wheel-zoom'
const logger = createLogger('PptxSandboxHost')
const ZOOM_MIN = 25
const ZOOM_MAX = 400
const ZOOM_STEP = 20
const ZOOM_WHEEL_SENSITIVITY = 0.005
interface PptxSandboxHostProps {
buffer: ArrayBuffer
requestId: string
onRenderStart?: () => void
onRenderComplete?: () => void
onRenderError?: (error: string) => void
}
export const PptxSandboxHost = memo(function PptxSandboxHost({
buffer,
requestId,
onRenderStart,
onRenderComplete,
onRenderError,
}: PptxSandboxHostProps) {
const stageRef = useRef<HTMLDivElement>(null)
const scrollContainerRef = useRef<HTMLDivElement>(null)
const activeHandleRef = useRef<SimPptxViewerHandle | null>(null)
const activeContainerRef = useRef<HTMLDivElement | null>(null)
const renderSequenceRef = useRef(0)
const onRenderStartRef = useRef(onRenderStart)
const onRenderCompleteRef = useRef(onRenderComplete)
const onRenderErrorRef = useRef(onRenderError)
const zoomPercentRef = useRef(100)
onRenderStartRef.current = onRenderStart
onRenderCompleteRef.current = onRenderComplete
onRenderErrorRef.current = onRenderError
const [zoomPercent, setZoomPercent] = useState(100)
const [slideCount, setSlideCount] = useState(0)
const [currentSlide, setCurrentSlide] = useState(1)
useEffect(() => {
const stage = stageRef.current
if (!stage) return
const controller = new AbortController()
const sequence = ++renderSequenceRef.current
const nextContainer = document.createElement('div')
nextContainer.dataset.requestId = requestId
nextContainer.style.width = '100%'
nextContainer.style.visibility = 'hidden'
stage.appendChild(nextContainer)
onRenderStartRef.current?.()
async function render() {
try {
const handle = await openSimPptxViewer({
buffer,
container: nextContainer,
scrollContainer: scrollContainerRef.current ?? undefined,
signal: controller.signal,
onSlideChange: (index) => setCurrentSlide(index + 1),
onSlideError: (slideIndex, error) => {
logger.warn('PPTX slide render failed', {
slideIndex,
error: toError(error).message,
})
},
})
if (controller.signal.aborted || sequence !== renderSequenceRef.current) {
handle.destroy()
nextContainer.remove()
return
}
const previousHandle = activeHandleRef.current
const previousContainer = activeContainerRef.current
activeHandleRef.current = handle
activeContainerRef.current = nextContainer
setSlideCount(handle.viewer.slideCount)
setCurrentSlide(handle.viewer.currentSlideIndex + 1)
if (zoomPercentRef.current !== 100) {
await handle.viewer.setZoom(zoomPercentRef.current)
}
nextContainer.style.visibility = 'visible'
previousHandle?.destroy()
previousContainer?.remove()
onRenderCompleteRef.current?.()
} catch (error) {
nextContainer.remove()
// Aborted means a newer render superseded this one — don't report.
if (controller.signal.aborted) return
const message = toError(error).message || 'Failed to render presentation'
logger.warn('PPTX render failed', { error: message })
onRenderErrorRef.current?.(message)
}
}
render()
return () => {
controller.abort()
if (activeContainerRef.current !== nextContainer) {
nextContainer.remove()
}
}
}, [buffer, requestId])
useEffect(() => {
return () => {
renderSequenceRef.current += 1
activeHandleRef.current?.destroy()
activeContainerRef.current?.remove()
}
}, [])
const applyZoomAt = useCallback(async (nextZoom: number, anchorX: number, anchorY: number) => {
const container = scrollContainerRef.current
if (!container) return
const clampedZoom = Math.round(Math.min(Math.max(nextZoom, ZOOM_MIN), ZOOM_MAX))
const ratio = clampedZoom / zoomPercentRef.current
const style = window.getComputedStyle(container)
const paddingLeft = Number.parseFloat(style.paddingLeft) || 0
const paddingTop = Number.parseFloat(style.paddingTop) || 0
const previousScrollLeft = container.scrollLeft
const previousScrollTop = container.scrollTop
zoomPercentRef.current = clampedZoom
setZoomPercent(clampedZoom)
await activeHandleRef.current?.viewer.setZoom(clampedZoom)
container.scrollLeft =
(previousScrollLeft + anchorX - paddingLeft) * ratio + paddingLeft - anchorX
container.scrollTop = (previousScrollTop + anchorY - paddingTop) * ratio + paddingTop - anchorY
}, [])
const applyZoomFromCenter = useCallback(
(nextZoom: number): Promise<void> => {
const container = scrollContainerRef.current
return applyZoomAt(
nextZoom,
container ? container.clientWidth / 2 : 0,
container ? container.clientHeight / 2 : 0
)
},
[applyZoomAt]
)
useEffect(() => {
const container = scrollContainerRef.current
if (!container) return
return bindPreviewWheelZoom(container, (event) => {
const rect = container.getBoundingClientRect()
void applyZoomAt(
zoomPercentRef.current * (1 - event.deltaY * ZOOM_WHEEL_SENSITIVITY),
event.clientX - rect.left,
event.clientY - rect.top
)
})
}, [applyZoomAt])
async function goToSlide(slideNumber: number) {
if (!activeHandleRef.current || slideCount <= 0) return
const clampedSlide = Math.min(Math.max(slideNumber, 1), slideCount)
if (zoomPercentRef.current !== 100) {
await applyZoomFromCenter(100)
}
setCurrentSlide(clampedSlide)
await activeHandleRef.current.viewer.goToSlide(clampedSlide - 1)
}
return (
<div className='flex h-full min-h-0 w-full flex-1 flex-col overflow-hidden bg-[var(--surface-1)]'>
<PreviewToolbar
navigation={{
current: currentSlide,
total: slideCount,
label: 'slide',
canPrevious: slideCount > 0 && currentSlide > 1,
canNext: slideCount > 0 && currentSlide < slideCount,
onPrevious: () => goToSlide(currentSlide - 1),
onNext: () => goToSlide(currentSlide + 1),
}}
zoom={{
label: `${zoomPercent}%`,
canZoomOut: zoomPercent > ZOOM_MIN,
canZoomIn: zoomPercent < ZOOM_MAX,
onReset: () => {
void applyZoomFromCenter(100)
},
onZoomOut: () => {
void applyZoomFromCenter(zoomPercent - ZOOM_STEP)
},
onZoomIn: () => {
void applyZoomFromCenter(zoomPercent + ZOOM_STEP)
},
}}
/>
<div
ref={scrollContainerRef}
className='relative flex min-h-0 flex-1 items-start overflow-auto bg-[var(--surface-1)] p-6'
>
<div ref={stageRef} className='mx-auto w-full max-w-[960px]' />
</div>
</div>
)
})
@@ -0,0 +1,343 @@
'use client'
import { memo, useEffect, useMemo, useRef, useState } from 'react'
import '@sim/emcn/components/code/code.css'
import { CSV_PREVIEW_MAX_ROWS } from '@/lib/api/contracts/workspace-file-table'
import { getFileExtension } from '@/lib/uploads/utils/file-utils'
import { type CsvImportFileDescriptor, useCsvTruncationImport } from './csv-import'
import { DataTable } from './data-table'
import { MermaidDiagram } from './mermaid-diagram'
import { ZoomablePreview } from './zoomable-preview'
type PreviewType = 'markdown' | 'html' | 'csv' | 'svg' | 'mermaid' | null
const PREVIEWABLE_MIME_TYPES: Record<string, PreviewType> = {
'text/markdown': 'markdown',
'text/html': 'html',
'text/csv': 'csv',
'image/svg+xml': 'svg',
'text/x-mermaid': 'mermaid',
}
const PREVIEWABLE_EXTENSIONS: Record<string, PreviewType> = {
md: 'markdown',
html: 'html',
htm: 'html',
csv: 'csv',
svg: 'svg',
mmd: 'mermaid',
}
/** All extensions that have a rich preview renderer. */
export const RICH_PREVIEWABLE_EXTENSIONS = new Set(Object.keys(PREVIEWABLE_EXTENSIONS))
export function resolvePreviewType(mimeType: string | null, filename: string): PreviewType {
if (mimeType && PREVIEWABLE_MIME_TYPES[mimeType]) return PREVIEWABLE_MIME_TYPES[mimeType]
const ext = getFileExtension(filename)
return PREVIEWABLE_EXTENSIONS[ext] ?? null
}
interface PreviewPanelProps {
content: string
mimeType: string | null
filename: string
workspaceId: string
fileKey: string
isStreaming?: boolean
/**
* Read-only surface (e.g. the public share page) — disables interactive
* affordances such as the CSV "Import as a table" action, which needs an
* authenticated workspace import.
*/
readOnly?: boolean
}
export const PreviewPanel = memo(function PreviewPanel({
content,
mimeType,
filename,
workspaceId,
fileKey,
isStreaming,
readOnly,
}: PreviewPanelProps) {
const previewType = resolvePreviewType(mimeType, filename)
if (previewType === 'html') return <HtmlPreview content={content} />
if (previewType === 'csv')
return (
<CsvPreview
content={content}
workspaceId={workspaceId}
file={{ key: fileKey, name: filename }}
readOnly={readOnly}
/>
)
if (previewType === 'svg') return <SvgPreview content={content} />
if (previewType === 'mermaid')
return <MermaidFilePreview content={content} isStreaming={isStreaming} />
return null
})
const HTML_PREVIEW_BASE_URL = 'about:srcdoc'
const HTML_PREVIEW_CSP = [
"default-src 'none'",
"script-src 'unsafe-inline'",
"style-src 'unsafe-inline'",
'img-src data: blob:',
'font-src data:',
'media-src data: blob:',
"connect-src 'none'",
"form-action 'none'",
"frame-src 'none'",
"child-src 'none'",
"object-src 'none'",
].join('; ')
const HTML_PREVIEW_BOOTSTRAP = `<script>
(() => {
const allowHref = (href) => href.startsWith('#') || /^\\s*javascript:/i.test(href)
document.addEventListener(
'click',
(event) => {
if (!(event.target instanceof Element)) return
const anchor = event.target.closest('a[href]')
if (!(anchor instanceof HTMLAnchorElement)) return
const href = anchor.getAttribute('href') || ''
if (allowHref(href)) return
event.preventDefault()
},
true
)
document.addEventListener(
'submit',
(event) => {
event.preventDefault()
},
true
)
})()
</script>`
function buildHtmlPreviewDocument(content: string): string {
const headInjection = [
'<meta charset="utf-8">',
`<base href="${HTML_PREVIEW_BASE_URL}">`,
`<meta http-equiv="Content-Security-Policy" content="${HTML_PREVIEW_CSP}">`,
HTML_PREVIEW_BOOTSTRAP,
].join('')
if (/<head[\s>]/i.test(content)) {
return content.replace(/<head(\s[^>]*)?>/i, (match) => `${match}${headInjection}`)
}
if (/<html[\s>]/i.test(content)) {
return content.replace(/<html(\s[^>]*)?>/i, (match) => `${match}<head>${headInjection}</head>`)
}
return `<!DOCTYPE html><html><head>${headInjection}</head><body>${content}</body></html>`
}
const HtmlPreview = memo(function HtmlPreview({ content }: { content: string }) {
const wrappedContent = buildHtmlPreviewDocument(content)
const containerRef = useRef<HTMLDivElement>(null)
const [isRenderable, setIsRenderable] = useState(false)
const [resumeNonce, setResumeNonce] = useState(0)
const pageWasHiddenRef = useRef(false)
useEffect(() => {
const container = containerRef.current
if (!container) return
const updateRenderability = (width: number, height: number) => {
setIsRenderable(width > 0 && height > 0)
}
const initialRect = container.getBoundingClientRect()
updateRenderability(initialRect.width, initialRect.height)
const observer = new ResizeObserver((entries) => {
const entry = entries[0]
if (!entry) return
updateRenderability(entry.contentRect.width, entry.contentRect.height)
})
observer.observe(container)
return () => observer.disconnect()
}, [])
useEffect(() => {
const handleVisibilityChange = () => {
if (document.visibilityState === 'hidden') {
pageWasHiddenRef.current = true
return
}
if (document.visibilityState === 'visible' && pageWasHiddenRef.current) {
pageWasHiddenRef.current = false
setResumeNonce((nonce) => nonce + 1)
}
}
const handlePageShow = (event: PageTransitionEvent) => {
if (event.persisted) {
setResumeNonce((nonce) => nonce + 1)
}
}
document.addEventListener('visibilitychange', handleVisibilityChange)
window.addEventListener('pageshow', handlePageShow)
return () => {
document.removeEventListener('visibilitychange', handleVisibilityChange)
window.removeEventListener('pageshow', handlePageShow)
}
}, [])
return (
<div ref={containerRef} className='h-full overflow-hidden'>
{isRenderable && (
<iframe
key={resumeNonce}
srcDoc={wrappedContent}
sandbox='allow-scripts'
referrerPolicy='no-referrer'
title='HTML Preview'
className='h-full w-full border-0 bg-[var(--surface-2)]'
/>
)}
</div>
)
})
function SvgPreview({ content }: { content: string }) {
const [blobUrl, setBlobUrl] = useState('')
useEffect(() => {
const url = URL.createObjectURL(new Blob([content], { type: 'image/svg+xml' }))
setBlobUrl(url)
return () => URL.revokeObjectURL(url)
}, [content])
return (
<ZoomablePreview className='h-full' contentClassName='h-full w-full'>
{blobUrl && (
<img
src={blobUrl}
alt='SVG preview'
className='max-h-full max-w-full select-none object-contain'
draggable={false}
/>
)}
</ZoomablePreview>
)
}
function MermaidFilePreview({ content, isStreaming }: { content: string; isStreaming?: boolean }) {
return (
<div className='h-full overflow-auto p-6'>
<MermaidDiagram
definition={content}
isStreaming={isStreaming}
zoomable
zoomClassName='h-full rounded-lg'
/>
</div>
)
}
const CsvPreview = memo(function CsvPreview({
content,
workspaceId,
file,
readOnly,
}: {
content: string
workspaceId: string
file: CsvImportFileDescriptor
readOnly?: boolean
}) {
const { headers, rows, truncated } = useMemo(() => parseCsv(content), [content])
useCsvTruncationImport(workspaceId, file, truncated, readOnly)
if (headers.length === 0) {
return (
<div className='flex h-full items-center justify-center p-6'>
<p className='text-[13px] text-[var(--text-muted)]'>No data to display</p>
</div>
)
}
return (
<div className='h-full overflow-auto p-6'>
<DataTable headers={headers} rows={rows} />
</div>
)
})
/**
* Parses CSV text for the inline preview, capping at {@link CSV_PREVIEW_MAX_ROWS} rows so a
* small-but-many-rows file doesn't render thousands of `<tr>`s. Slices before parsing so only
* the capped rows are processed; `truncated` drives the "Import as a table" footer.
*/
function parseCsv(text: string): { headers: string[]; rows: string[][]; truncated: boolean } {
const lines = text.split('\n').filter((line) => line.trim().length > 0)
if (lines.length === 0) return { headers: [], rows: [], truncated: false }
const delimiter = detectDelimiter(lines[0])
const headers = parseCsvLine(lines[0], delimiter)
const dataLines = lines.slice(1)
const truncated = dataLines.length > CSV_PREVIEW_MAX_ROWS
const rows = dataLines.slice(0, CSV_PREVIEW_MAX_ROWS).map((line) => parseCsvLine(line, delimiter))
return { headers, rows, truncated }
}
function detectDelimiter(line: string): string {
const commaCount = (line.match(/,/g) || []).length
const tabCount = (line.match(/\t/g) || []).length
const semiCount = (line.match(/;/g) || []).length
if (tabCount > commaCount && tabCount > semiCount) return '\t'
if (semiCount > commaCount) return ';'
return ','
}
function parseCsvLine(line: string, delimiter: string): string[] {
const fields: string[] = []
let current = ''
let inQuotes = false
for (let i = 0; i < line.length; i++) {
const char = line[i]
if (inQuotes) {
if (char === '"') {
if (i + 1 < line.length && line[i + 1] === '"') {
current += '"'
i++
} else {
inQuotes = false
}
} else {
current += char
}
} else {
if (char === '"') {
inQuotes = true
} else if (char === delimiter) {
fields.push(current.trim())
current = ''
} else {
current += char
}
}
}
fields.push(current.trim())
return fields
}
@@ -0,0 +1,132 @@
'use client'
import { Component, type ErrorInfo, type ReactNode } from 'react'
import { cn } from '@sim/emcn'
import { Loader } from '@sim/emcn/icons'
import { createLogger } from '@sim/logger'
const logger = createLogger('FilePreview')
export function PreviewError({ label, error }: { label: string; error: string }) {
return (
<div className='flex flex-1 flex-col items-center justify-center gap-[8px]'>
<p className='font-medium text-[14px] text-[var(--text-primary)]'>
Failed to preview {label}
</p>
<p className='text-[13px] text-[var(--text-muted)]'>{error}</p>
</div>
)
}
interface PreviewErrorBoundaryProps {
/** Format label shown in the fallback, e.g. "PDF". */
label: string
children: ReactNode
}
interface PreviewErrorBoundaryState {
hasError: boolean
error?: Error
}
/**
* Error boundary for preview renderers. Catches render-time crashes (including
* a preview module whose dynamic import rejected) and degrades to the standard
* PreviewError fallback instead of unwinding to the route-level error boundary
* and replacing the whole workspace view.
*
* Callers must `key` this boundary by the identity of the rendered content
* (e.g. file id + data version) — the error state resets only via remount, so
* keying the child alone would leave a tripped boundary stuck on the fallback.
*/
export class PreviewErrorBoundary extends Component<
PreviewErrorBoundaryProps,
PreviewErrorBoundaryState
> {
public state: PreviewErrorBoundaryState = {
hasError: false,
}
public static getDerivedStateFromError(error: Error): PreviewErrorBoundaryState {
return { hasError: true, error }
}
public componentDidCatch(error: Error, errorInfo: ErrorInfo) {
logger.error('Preview crashed', {
label: this.props.label,
error: error.message,
componentStack: errorInfo.componentStack,
})
}
public render() {
if (this.state.hasError) {
return (
<PreviewError
label={this.props.label}
error={this.state.error?.message ?? 'An unexpected error occurred'}
/>
)
}
return this.props.children
}
}
export function resolvePreviewError(
fetchError: Error | null,
renderError: string | null
): string | null {
// A doc whose compiled artifact never appeared (the binary query exhausted its
// "still generating" polls) — usually a source that failed to compile or a
// legacy file with no artifact. Give a clear, actionable message instead of a
// generic fetch error.
if (fetchError?.name === 'DocNotReadyError') {
return "Couldn't generate this document preview. Re-run the file generation to rebuild it."
}
if (fetchError) return fetchError.message
return renderError
}
/** Canonical content-area loading spinner, matching the rest of the app. */
const PREVIEW_LOADING_SPINNER = (
<Loader className='size-[20px] text-[var(--text-secondary)]' animate />
)
/**
* Canonical loading overlay for previews that render into a `--surface-1`
* canvas. Absolutely covers the canvas (with `z-10` so it paints above
* in-flow render targets) with a centered spinner until the preview is ready.
*/
export const PREVIEW_LOADING_OVERLAY = (
<div className='absolute inset-0 z-10 flex items-center justify-center bg-[var(--surface-1)]'>
{PREVIEW_LOADING_SPINNER}
</div>
)
interface PreviewLoadingFrameProps {
/** Layout/sizing-only classes for the in-flow frame (e.g. `h-full`, `flex-1`). */
className?: string
/** Background token matching the loaded sibling's canvas. Defaults to `--bg`. */
tone?: 'bg' | 'surface'
}
/**
* Canonical in-flow loading frame with a centered spinner, shown while a
* preview is fetching or rendering. The `tone` must match the background of
* the loaded state it is standing in for, so mount completion does not flash
* a different token.
*/
export function PreviewLoadingFrame({ className, tone = 'bg' }: PreviewLoadingFrameProps) {
return (
<div
className={cn(
'flex items-center justify-center',
tone === 'surface' ? 'bg-[var(--surface-1)]' : 'bg-[var(--bg)]',
className
)}
>
{PREVIEW_LOADING_SPINNER}
</div>
)
}
@@ -0,0 +1,95 @@
import { Chip, cn } from '@sim/emcn'
import { ChevronLeft, ChevronRight, ZoomIn, ZoomOut } from 'lucide-react'
interface PreviewNavigationControls {
current: number
total: number
label: string
onPrevious: () => void
onNext: () => void
canPrevious?: boolean
canNext?: boolean
}
interface PreviewZoomControls {
label: string
onZoomOut: () => void
onZoomIn: () => void
canZoomOut?: boolean
canZoomIn?: boolean
onReset?: () => void
}
interface PreviewToolbarProps {
navigation?: PreviewNavigationControls
zoom?: PreviewZoomControls
className?: string
}
export function PreviewToolbar({ navigation, zoom, className }: PreviewToolbarProps) {
return (
<div
className={cn(
'flex shrink-0 items-center justify-between border-[var(--border)] border-b bg-[var(--surface-1)] px-2 py-1',
className
)}
>
<div className='flex items-center'>
{navigation && <PreviewNavigationControls {...navigation} />}
</div>
<div className='flex items-center'>{zoom && <PreviewZoomControls {...zoom} />}</div>
</div>
)
}
function PreviewNavigationControls({
current,
total,
label,
onPrevious,
onNext,
canPrevious = current > 1,
canNext = current < total,
}: PreviewNavigationControls) {
return (
<>
<Chip
leftIcon={ChevronLeft}
onClick={onPrevious}
disabled={!canPrevious}
aria-label={`Previous ${label}`}
/>
<span className='min-w-[4.5rem] text-center text-[var(--text-body)] text-sm'>
{total > 0 ? `${current} / ${total}` : '0 / 0'}
</span>
<Chip
leftIcon={ChevronRight}
onClick={onNext}
disabled={!canNext}
aria-label={`Next ${label}`}
/>
</>
)
}
function PreviewZoomControls({
label,
onZoomOut,
onZoomIn,
canZoomOut = true,
canZoomIn = true,
onReset,
}: PreviewZoomControls) {
return (
<>
{onReset && (
<Chip onClick={onReset} aria-label='Reset zoom'>
Reset
</Chip>
)}
<Chip leftIcon={ZoomOut} onClick={onZoomOut} disabled={!canZoomOut} aria-label='Zoom out' />
<span className='min-w-[3.25rem] text-center text-[var(--text-body)] text-sm'>{label}</span>
<Chip leftIcon={ZoomIn} onClick={onZoomIn} disabled={!canZoomIn} aria-label='Zoom in' />
</>
)
}
@@ -0,0 +1,46 @@
interface BindPreviewWheelZoomOptions {
/**
* Called for non-modifier wheel events (two-finger scroll). When provided,
* the container's native scrolling is suppressed and the consumer drives
* pan via `deltaX` / `deltaY`. Use for transform-based viewers (e.g. image)
* where the content is not a real scroll container.
*/
onPan?: (event: WheelEvent) => void
}
/**
* Bind browser pinch/ctrl-wheel zoom and horizontal wheel gestures for preview
* scroll containers. Trackpad pinch fires `wheel` with `ctrlKey=true`; without
* a non-passive native listener the browser falls back to page zoom. `metaKey`
* is also accepted so Cmd+scroll zooms on macOS, matching Figma/tldraw/Excalidraw.
*/
export function bindPreviewWheelZoom(
container: HTMLElement,
onZoom: (event: WheelEvent) => void,
options: BindPreviewWheelZoomOptions = {}
): () => void {
const { onPan } = options
const onWheel = (event: WheelEvent) => {
if (event.ctrlKey || event.metaKey) {
event.preventDefault()
onZoom(event)
return
}
if (onPan) {
event.preventDefault()
onPan(event)
return
}
const horizontalDelta = event.deltaX !== 0 ? event.deltaX : event.shiftKey ? event.deltaY : 0
if (horizontalDelta === 0 || container.scrollWidth <= container.clientWidth) return
event.preventDefault()
container.scrollLeft += horizontalDelta
}
container.addEventListener('wheel', onWheel, { capture: true, passive: false })
return () => container.removeEventListener('wheel', onWheel, { capture: true })
}
@@ -0,0 +1,94 @@
import { Extension } from '@tiptap/core'
import type { EditorState, Transaction } from '@tiptap/pm/state'
import { TextSelection } from '@tiptap/pm/state'
/** The position range of the depth-1 block containing the cursor, or null at the document root. */
function currentTopLevelBlock(state: EditorState): { from: number; to: number } | null {
const { $from } = state.selection
if ($from.depth === 0) return null
return { from: $from.before(1), to: $from.after(1) }
}
/**
* Swaps the current top-level block with its neighbour in `direction`, keeping the caret on the moved
* block. Adjacent top-level blocks share a boundary position (no separator token between them), so the
* move is a single `replaceWith` of the two-block span with the pair reordered. No-ops (returns false)
* at the matching document edge or when the neighbour isn't a top-level block. `newBefore` is the moved
* block's new `before(1)` position; adding the caret's original offset (`selection.from - from`, also
* measured from `before(1)`) re-anchors the caret at the same spot within the block.
*/
function moveBlock(
state: EditorState,
dispatch: ((tr: Transaction) => void) | undefined,
direction: 'up' | 'down'
): boolean {
const block = currentTopLevelBlock(state)
if (!block) return false
const { from, to } = block
const up = direction === 'up'
if (up ? from === 0 : to >= state.doc.content.size) return false
const $neighbour = state.doc.resolve(up ? from - 1 : to + 1)
if ($neighbour.depth === 0) return false
if (!dispatch) return true
const spanFrom = up ? $neighbour.before(1) : from
const spanTo = up ? to : $neighbour.after(1)
const moving = state.doc.slice(from, to).content
const neighbour = up
? state.doc.slice(spanFrom, from).content
: state.doc.slice(to, spanTo).content
const tr = state.tr.replaceWith(
spanFrom,
spanTo,
up ? moving.append(neighbour) : neighbour.append(moving)
)
const newBefore = up ? spanFrom : spanFrom + neighbour.size
const offset = state.selection.from - from
tr.setSelection(
TextSelection.near(tr.doc.resolve(Math.min(newBefore + offset, newBefore + moving.size)))
)
dispatch(tr.scrollIntoView())
return true
}
declare module '@tiptap/core' {
interface Commands<ReturnType> {
blockMover: {
/** Move the current top-level block up one position, carrying the caret. */
moveBlockUp: () => ReturnType
/** Move the current top-level block down one position, carrying the caret. */
moveBlockDown: () => ReturnType
}
}
}
/**
* Reorders the current top-level block with `Mod-Shift-ArrowUp`/`ArrowDown` — the standard
* keyboard block-move affordance (Notion/Obsidian). Pure UI interaction: no schema change, and the
* caret rides along with the block. A no-op (returns false, falling through) at the document edges.
*/
export const BlockMover = Extension.create({
name: 'blockMover',
addCommands() {
return {
moveBlockUp:
() =>
({ state, dispatch }) =>
moveBlock(state, dispatch, 'up'),
moveBlockDown:
() =>
({ state, dispatch }) =>
moveBlock(state, dispatch, 'down'),
}
},
addKeyboardShortcuts() {
return {
'Mod-Shift-ArrowUp': ({ editor }) => editor.commands.moveBlockUp(),
'Mod-Shift-ArrowDown': ({ editor }) => editor.commands.moveBlockDown(),
}
},
})
@@ -0,0 +1,264 @@
import { useEffect, useState } from 'react'
import {
chipVariants,
cn,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
useCopyToClipboard,
} from '@sim/emcn'
import type { JSONContent } from '@tiptap/core'
import { CodeBlock } from '@tiptap/extension-code-block'
import type { ReactNodeViewProps } from '@tiptap/react'
import { NodeViewContent, NodeViewWrapper, ReactNodeViewRenderer } from '@tiptap/react'
import { Check, ChevronDown, Code, Copy, Eye, WrapText } from 'lucide-react'
import { looksLikeMermaid, MermaidDiagram } from '../mermaid-diagram'
import { detectLanguage } from './detect-language'
import { useEditorEditable } from './use-editor-editable'
const PLAIN = 'plain'
const MERMAID = 'mermaid'
/** Languages the Prism highlighter has registered (see {@link CodeBlockHighlight}). Every non-plain
* value MUST have a grammar registered in {@link CodeBlockHighlight} — enforced by a unit test. */
export const LANGUAGE_OPTIONS = [
{ value: PLAIN, label: 'Plain text' },
{ value: 'bash', label: 'Bash' },
{ value: 'c', label: 'C' },
{ value: 'cpp', label: 'C++' },
{ value: 'csharp', label: 'C#' },
{ value: 'css', label: 'CSS' },
{ value: 'go', label: 'Go' },
{ value: 'java', label: 'Java' },
{ value: 'javascript', label: 'JavaScript' },
{ value: 'json', label: 'JSON' },
{ value: 'markup', label: 'HTML' },
{ value: 'php', label: 'PHP' },
{ value: 'python', label: 'Python' },
{ value: 'ruby', label: 'Ruby' },
{ value: 'rust', label: 'Rust' },
{ value: 'sql', label: 'SQL' },
{ value: 'typescript', label: 'TypeScript' },
{ value: 'yaml', label: 'YAML' },
] as const
const CONTROL_CLASS =
'flex size-[24px] items-center justify-center rounded-lg text-[var(--text-icon)] outline-none transition-colors hover-hover:bg-[var(--surface-hover)] hover-hover:text-[var(--text-body)] focus-visible:bg-[var(--surface-hover)] [&_svg]:size-[14px]'
/**
* Code block view with hover controls (language picker, line-wrap, copy). When the block holds
* Mermaid — tagged ```mermaid or {@link looksLikeMermaid auto-detected} — it renders as a diagram
* whenever the cursor is outside it (and always in read-only), and as editable source while the
* cursor is inside, re-rendering on blur (the Linear/GitHub model). The source `<pre>` stays mounted
* (hidden behind the diagram) so ProseMirror keeps managing its contentDOM, and the node remains an
* ordinary code block, so markdown round-trips unchanged.
*/
function CodeBlockView({ node, updateAttributes, editor, getPos }: ReactNodeViewProps) {
const [wrap, setWrap] = useState(false)
const [menuOpen, setMenuOpen] = useState(false)
const [editingInline, setEditingInline] = useState(false)
const [peekSource, setPeekSource] = useState(false)
const { copied, copy } = useCopyToClipboard({ resetMs: 1500 })
const editable = useEditorEditable(editor)
const explicitLanguage = node.attrs.language as string | null
const text = node.textContent
const isMermaid = explicitLanguage === MERMAID || (!explicitLanguage && looksLikeMermaid(text))
// Editable Mermaid shows source while the caret is focused inside the block and re-renders the
// diagram on blur (the Linear/GitHub model). The Show source / Show diagram control drives this by
// focusing into / blurring the block; read-only uses {@link peekSource} since there is no caret.
useEffect(() => {
if (!isMermaid || !editable) {
setEditingInline(false)
return
}
const sync = () => {
const pos = typeof getPos === 'function' ? getPos() : null
if (typeof pos !== 'number') {
setEditingInline(false)
return
}
const size = editor.state.doc.nodeAt(pos)?.nodeSize ?? 0
const { from } = editor.state.selection
setEditingInline(editor.isFocused && from > pos && from < pos + size)
}
sync()
editor.on('selectionUpdate', sync)
editor.on('focus', sync)
editor.on('blur', sync)
return () => {
editor.off('selectionUpdate', sync)
editor.off('focus', sync)
editor.off('blur', sync)
}
}, [editor, getPos, isMermaid, editable])
const showSource = editable ? editingInline : peekSource
const showDiagram = isMermaid && text.trim().length > 0 && !showSource
// Skip language detection on the mermaid path — the picker/label never render there.
const language = explicitLanguage ?? (isMermaid ? null : detectLanguage(text)) ?? PLAIN
const label =
LANGUAGE_OPTIONS.find((option) => option.value === language)?.label ??
explicitLanguage ??
'Plain text'
const toggleSource = () => {
if (!editable) {
setPeekSource((value) => !value)
return
}
const pos = typeof getPos === 'function' ? getPos() : null
if (typeof pos !== 'number') return
if (editingInline) {
// Back to the diagram: select the whole node (reliable, and shows the same ring) rather than
// relying on a blur event to fire.
editor.commands.setNodeSelection(pos)
return
}
editor
.chain()
.focus()
.setTextSelection(pos + 1)
.run()
}
return (
<NodeViewWrapper className='group relative'>
<div
className={cn(
'absolute top-1.5 right-2 z-10 flex items-center gap-0.5 opacity-0 transition-opacity group-focus-within:opacity-100 group-hover:opacity-100',
menuOpen && 'opacity-100'
)}
contentEditable={false}
>
{isMermaid && (
<button
type='button'
aria-label={showSource ? 'Show diagram' : 'Show source'}
onMouseDown={(event) => event.preventDefault()}
onClick={toggleSource}
className={CONTROL_CLASS}
>
{showSource ? <Eye /> : <Code />}
</button>
)}
{!isMermaid &&
(editable ? (
// Editable: a language picker. Read-only: a static label — selecting a language calls
// updateAttributes, which would mutate a doc that must not change.
<DropdownMenu onOpenChange={setMenuOpen}>
<DropdownMenuTrigger asChild>
<button
type='button'
aria-label='Code language'
className={cn(
chipVariants({ variant: 'default', flush: true }),
'h-[24px] gap-1 px-1.5 text-[var(--text-muted)] data-[state=open]:bg-[var(--surface-active)] data-[state=open]:text-[var(--text-body)]'
)}
>
{label}
<ChevronDown className='size-[14px] text-[var(--text-icon)]' />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align='end'>
{LANGUAGE_OPTIONS.map((option) => (
<DropdownMenuItem
key={option.value}
onSelect={() =>
updateAttributes({ language: option.value === PLAIN ? null : option.value })
}
>
{option.label}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
) : (
<span className='flex h-[24px] items-center px-1.5 text-[var(--text-muted)] text-caption'>
{label}
</span>
))}
{!isMermaid && editable && (
<button
type='button'
aria-label='Toggle line wrap'
aria-pressed={wrap}
onMouseDown={(event) => event.preventDefault()}
onClick={() => setWrap((value) => !value)}
className={cn(
CONTROL_CLASS,
wrap && 'bg-[var(--surface-active)] text-[var(--text-body)]'
)}
>
<WrapText />
</button>
)}
<button
type='button'
aria-label='Copy code'
onMouseDown={(event) => event.preventDefault()}
onClick={() => copy(text)}
className={CONTROL_CLASS}
>
{copied ? <Check /> : <Copy />}
</button>
</div>
<pre className={cn('code-editor-theme pr-20', showDiagram && 'hidden')} data-wrap={wrap}>
<NodeViewContent<'code'> as='code' />
</pre>
{showDiagram && (
// Clicking the diagram selects the whole node (same selection ring as an image/code block)
// instead of dropping a caret inside — preventDefault stops ProseMirror placing the caret,
// which would otherwise flip to source. Editing is an explicit Show source / blur action.
<div
contentEditable={false}
onMouseDown={(event) => {
event.preventDefault()
const pos = typeof getPos === 'function' ? getPos() : null
if (typeof pos === 'number') editor.commands.setNodeSelection(pos)
}}
>
<MermaidDiagram definition={text} className='mermaid-diagram-frame' />
</div>
)}
</NodeViewWrapper>
)
}
function codeBlockText(node: JSONContent): string {
return (node.content ?? []).map((child) => child.text ?? '').join('')
}
/** Fence sized to one backtick longer than the longest run inside the code (CommonMark rule). */
function fenceFor(text: string): string {
const longestRun = Math.max(0, ...[...text.matchAll(/`+/g)].map((match) => match[0].length))
return '`'.repeat(Math.max(3, longestRun + 1))
}
/**
* Code block whose markdown serializer sizes the fence to the interior backtick runs, so a code
* block that itself contains a ``` line round-trips instead of shattering. Shared by the test
* (plain) and live ({@link CodeBlockWithLanguage}) paths.
*/
export const MarkdownCodeBlock = CodeBlock.extend({
renderMarkdown: (node: JSONContent) => {
const language = typeof node.attrs?.language === 'string' ? node.attrs.language : ''
const text = codeBlockText(node)
const fence = fenceFor(text)
return `${fence}${language}\n${text}\n${fence}`
},
})
/**
* Code block with hover-revealed controls (language picker, line-wrap toggle, copy). The
* `language` attribute drives {@link CodeBlockHighlight}'s Prism highlighting and serializes to
* the ```lang fence on save; wrap is a view-only preference.
*/
export const CodeBlockWithLanguage = MarkdownCodeBlock.extend({
addNodeView() {
return ReactNodeViewRenderer(CodeBlockView)
},
})
@@ -0,0 +1,81 @@
/**
* @vitest-environment jsdom
*/
import { Editor } from '@tiptap/core'
import { afterEach, describe, expect, it } from 'vitest'
import { buildDecorations, changeTouchesCodeBlock } from './code-highlight'
import { createMarkdownContentExtensions } from './extensions'
let editor: Editor | null = null
/** Position just inside the first code block in the current editor doc. */
function codeBlockPos(ed: Editor): number {
let pos = -1
ed.state.doc.descendants((node, p) => {
if (pos === -1 && node.type.name === 'codeBlock') pos = p
return pos === -1
})
if (pos === -1) throw new Error('no code block')
return pos
}
function decorationClassesFor(markdown: string): string[] {
editor = new Editor({ extensions: createMarkdownContentExtensions() })
editor.commands.setContent(markdown, { contentType: 'markdown' })
const decorations = buildDecorations(editor.state.doc).find()
editor.destroy()
editor = null
return decorations.map(
(decoration) =>
(decoration as unknown as { type: { attrs: { class: string } } }).type.attrs.class
)
}
afterEach(() => {
editor?.destroy()
editor = null
})
describe('code block syntax highlighting', () => {
it('emits Prism token decorations for a known language', () => {
const classes = decorationClassesFor('```js\nconst x = 1\n```')
expect(classes.length).toBeGreaterThan(0)
expect(classes.every((c) => c.startsWith('token'))).toBe(true)
expect(classes.some((c) => c.includes('keyword'))).toBe(true)
})
it('does not decorate plain prose', () => {
expect(decorationClassesFor('just some text')).toHaveLength(0)
})
it('does not decorate an unregistered language', () => {
expect(decorationClassesFor('```unregistered-lang\n+++ foo\n```')).toHaveLength(0)
})
})
describe('changeTouchesCodeBlock (incremental re-tokenization gate)', () => {
function mount(markdown: string): Editor {
editor = new Editor({ extensions: createMarkdownContentExtensions() })
editor.commands.setContent(markdown, { contentType: 'markdown' })
return editor
}
it('is false when an edit lands only in prose (decorations are mapped, not rebuilt)', () => {
const ed = mount('intro text\n\n```js\nconst x = 1\n```')
const tr = ed.state.tr.insertText('Z', 1) // inside the leading paragraph
expect(changeTouchesCodeBlock(tr, tr.doc)).toBe(false)
})
it('is true when an edit lands inside a code block (forces a re-tokenize)', () => {
const ed = mount('intro\n\n```js\nconst x = 1\n```')
const tr = ed.state.tr.insertText('y', codeBlockPos(ed) + 1)
expect(changeTouchesCodeBlock(tr, tr.doc)).toBe(true)
})
it('is true when the code block language changes via setNodeMarkup', () => {
const ed = mount('```js\nconst x = 1\n```')
const pos = codeBlockPos(ed)
const tr = ed.state.tr.setNodeMarkup(pos, undefined, { language: 'python' })
expect(changeTouchesCodeBlock(tr, tr.doc)).toBe(true)
})
})
@@ -0,0 +1,133 @@
import { Extension } from '@tiptap/core'
import type { Node as ProseMirrorNode } from '@tiptap/pm/model'
import { Plugin, PluginKey, type Transaction } from '@tiptap/pm/state'
import { Decoration, DecorationSet } from '@tiptap/pm/view'
import Prism, { type Token, type TokenStream } from 'prismjs'
import 'prismjs/components/prism-bash'
import 'prismjs/components/prism-css'
import 'prismjs/components/prism-markup'
import 'prismjs/components/prism-javascript'
import 'prismjs/components/prism-typescript'
import 'prismjs/components/prism-yaml'
import 'prismjs/components/prism-sql'
import 'prismjs/components/prism-python'
import 'prismjs/components/prism-json'
import 'prismjs/components/prism-c'
import 'prismjs/components/prism-cpp'
import 'prismjs/components/prism-csharp'
import 'prismjs/components/prism-go'
import 'prismjs/components/prism-java'
import 'prismjs/components/prism-markup-templating'
import 'prismjs/components/prism-php'
import 'prismjs/components/prism-ruby'
import 'prismjs/components/prism-rust'
import { detectLanguage } from './detect-language'
const HIGHLIGHT_PLUGIN_KEY = new PluginKey('codeBlockHighlight')
function tokenClasses(token: Token): string {
const classes = ['token', token.type]
if (token.alias) classes.push(...(Array.isArray(token.alias) ? token.alias : [token.alias]))
return classes.join(' ')
}
/**
* Walks Prism's token tree, emitting one inline decoration per token over its text range.
* Nested tokens stack (ProseMirror nests overlapping inline decorations), reproducing the
* `.token`-class structure Prism would render as HTML.
*/
function collectTokenDecorations(
stream: TokenStream,
base: number,
offset: { value: number },
decorations: Decoration[],
limit: number
) {
const tokens = Array.isArray(stream) ? stream : [stream]
for (const token of tokens) {
if (typeof token === 'string') {
offset.value += token.length
continue
}
const start = offset.value
collectTokenDecorations(token.content, base, offset, decorations, limit)
const from = base + start
const to = Math.min(base + offset.value, limit)
if (to > from) decorations.push(Decoration.inline(from, to, { class: tokenClasses(token) }))
}
}
export function buildDecorations(doc: ProseMirrorNode): DecorationSet {
const decorations: Decoration[] = []
doc.descendants((node, pos) => {
if (node.type.name !== 'codeBlock') return
const language = (node.attrs.language as string | null) ?? detectLanguage(node.textContent)
const grammar = language ? Prism.languages[language] : undefined
if (!grammar) return
// Defensive: a malformed grammar or a token/position mismatch must never throw here — a throw
// in the decorations plugin blanks the whole editor. The `limit` clamps any over-long token.
try {
const base = pos + 1
collectTokenDecorations(
Prism.tokenize(node.textContent, grammar),
base,
{ value: 0 },
decorations,
base + node.content.size
)
} catch {}
})
return DecorationSet.create(doc, decorations)
}
/**
* Whether the transaction's changed ranges intersect any code block in the new doc — including
* a `setNodeMarkup` language change (whose step range covers the node). When false, the cheap
* path just maps existing decorations instead of re-tokenizing.
*/
export function changeTouchesCodeBlock(tr: Transaction, doc: ProseMirrorNode): boolean {
let touches = false
for (const map of tr.mapping.maps) {
map.forEach((_oldStart, _oldEnd, newStart, newEnd) => {
if (touches) return
const from = Math.max(0, Math.min(newStart, doc.content.size))
const to = Math.max(from, Math.min(newEnd, doc.content.size))
doc.nodesBetween(from, to, (node) => {
if (node.type.name === 'codeBlock') touches = true
return !touches
})
})
}
return touches
}
/**
* Syntax-highlights fenced code blocks with Prism, emitting the same `.token` classes the
* rest of the app uses so the `code-editor-theme` styles (light + dark) apply unchanged.
* Re-tokenizes only when a change actually touches a code block (typing in prose just maps
* the existing decorations), keeping the cost off the common keystroke path.
*/
export const CodeBlockHighlight = Extension.create({
name: 'codeBlockHighlight',
addProseMirrorPlugins() {
return [
new Plugin({
key: HIGHLIGHT_PLUGIN_KEY,
state: {
init: (_, { doc }) => buildDecorations(doc),
apply: (tr, current) => {
if (tr.steps.length === 0) return current
if (!changeTouchesCodeBlock(tr, tr.doc)) return current.map(tr.mapping, tr.doc)
return buildDecorations(tr.doc)
},
},
props: {
decorations(state) {
return HIGHLIGHT_PLUGIN_KEY.getState(state)
},
},
}),
]
},
})
@@ -0,0 +1,21 @@
/**
* @vitest-environment jsdom
*
* Guards against drift between the code-block language picker and the Prism grammars actually
* registered by CodeBlockHighlight: every selectable language must have a registered grammar, or it
* would silently fall back to no highlighting.
*/
import Prism from 'prismjs'
import { describe, expect, it } from 'vitest'
import { LANGUAGE_OPTIONS } from './code-block'
// Importing the highlighter registers all the prism-* grammars as a side effect.
import './code-highlight'
describe('code-block languages', () => {
it('every selectable language has a registered Prism grammar', () => {
for (const { value } of LANGUAGE_OPTIONS) {
if (value === 'plain') continue
expect(Prism.languages[value], `no Prism grammar registered for "${value}"`).toBeDefined()
}
})
})
@@ -0,0 +1,38 @@
import { describe, expect, it } from 'vitest'
import { detectLanguage } from './detect-language'
describe('detectLanguage', () => {
it('returns null for empty or unrecognizable content', () => {
expect(detectLanguage('')).toBeNull()
expect(detectLanguage(' \n ')).toBeNull()
expect(detectLanguage('just some prose words here')).toBeNull()
})
it('detects common languages from content shape', () => {
expect(detectLanguage('{\n "a": 1,\n "b": [2, 3]\n}')).toBe('json')
expect(detectLanguage('const x = 1\nfunction go() {}')).toBe('javascript')
expect(detectLanguage('interface Foo { name: string }')).toBe('typescript')
expect(detectLanguage('def main():\n print("hi")')).toBe('python')
expect(detectLanguage('SELECT id FROM users WHERE id = 1')).toBe('sql')
expect(detectLanguage('#!/bin/bash\necho hello')).toBe('bash')
expect(detectLanguage('<div class="x">hi</div>')).toBe('markup')
expect(detectLanguage('.btn { color: red; padding: 4px }')).toBe('css')
})
it('does not misclassify a JS object as JSON', () => {
expect(detectLanguage('const x = { a: 1 }')).toBe('javascript')
})
it('detects Go, Rust, Java', () => {
expect(detectLanguage('package main\n\nfunc main() {\n\tfmt.Println("hi")\n}')).toBe('go')
expect(detectLanguage('type User struct {\n\tName string\n}')).toBe('go')
expect(detectLanguage('fn main() {\n let mut x = 1;\n println!("{}", x);\n}')).toBe('rust')
expect(detectLanguage('public class Box {\n private int n;\n}')).toBe('java')
})
it('does not misread generics as HTML markup', () => {
expect(detectLanguage('public class Box { private List<String> items; }')).toBe('java')
expect(detectLanguage('let v: Vec<String> = Vec::new();\nfn f() {}')).toBe('rust')
expect(detectLanguage('func Map[T any](s []T) {}\npackage x')).toBe('go')
})
})
@@ -0,0 +1,63 @@
/**
* Heuristic language detection for a fenced code block that has no explicit ` ```lang ` tag.
* Used only to drive syntax highlighting + the picker label — the detected value is NEVER
* written back to the markdown, so opening a file never mutates it. Restricted to the grammars
* {@link CodeBlockHighlight} actually registers with Prism; returns `null` when unsure.
*/
const DETECTORS: ReadonlyArray<{ language: string; test: RegExp }> = [
// Real HTML: a closing tag, an opening tag with an attribute, or a doctype/comment. Deliberately
// NOT a bare `<Word>` so generics (`List<String>`, `Vec<T>`) aren't misread as markup.
{ language: 'markup', test: /<\/[a-z][\w-]*\s*>|<[a-z][\w-]*\s+[\w:-]+=|<!(?:doctype\b|--)/i },
{
language: 'sql',
test: /\b(?:select\s+[\w*]|insert\s+into|update\s+\w+\s+set|delete\s+from|create\s+table)/i,
},
{ language: 'python', test: /^\s*(def|class)\s+\w+|^\s*(import|from)\s+\w|\bprint\(|\belif\b/m },
{
language: 'bash',
test: /^#!.*\b(ba)?sh\b|^\s*(sudo|apt|brew|npm|yarn|bun|git|cd|echo|export|chmod|mkdir)\s|\$\(/m,
},
{
language: 'go',
test: /^\s*package\s+\w+|\bfunc\s+(\(\w[^)]*\)\s+)?\w+\s*\(|\btype\s+\w+\s+(struct|interface)\b|\bfmt\.\w|:=/m,
},
{
language: 'rust',
test: /\bfn\s+\w+\s*[(<]|\blet\s+mut\b|\bimpl\b|\bpub\s+(fn|struct|enum|mod)\b|println!/,
},
{
language: 'java',
test: /\b(public|private|protected)\s+(static\s+)?(final\s+)?(class|void|int|String|boolean)\b|System\.out\.print/,
},
{
language: 'typescript',
test: /\b(interface|type)\s+\w+\s*[={]|:\s*(string|number|boolean)\b|\bimport\s+type\b|\bas\s+\w+\s*;/,
},
{
language: 'javascript',
test: /\b(const|let|var|function)\s|=>|console\.\w+|\brequire\(|\bexport\s+(default|const)\b/,
},
{ language: 'css', test: /[.#]?[\w-]+\s*\{[^}]*[\w-]+\s*:[^};]+;?[^}]*\}/ },
{ language: 'yaml', test: /^[\w-]+:\s+\S/m },
]
function looksLikeJson(sample: string): boolean {
const trimmed = sample.trim()
if (!/^[[{]/.test(trimmed)) return false
try {
JSON.parse(trimmed)
return true
} catch {
return false
}
}
export function detectLanguage(code: string): string | null {
const sample = code.slice(0, 2000)
if (!sample.trim()) return null
if (looksLikeJson(sample)) return 'json'
for (const { language, test } of DETECTORS) {
if (test.test(sample)) return language
}
return null
}
@@ -0,0 +1,106 @@
/**
* @vitest-environment jsdom
*
* Regression guards for two bugs found while adding the `@` mention menu:
*
* 1. The `@` mention and `/` slash-command extensions each register a `@tiptap/suggestion` plugin.
* They must use distinct plugin keys, or constructing any editor with the full set throws
* "Adding different instances of a keyed plugin (suggestion$)".
*
* 2. A markdown file authored outside the editor (e.g. the former Monaco editor) is rarely in the
* editor's canonical serialization. On open, a deferred view-plugin transaction re-serializes the
* doc to canonical markdown and emits one update — which, compared against the raw saved bytes,
* falsely marks the file dirty ("unsaved changes"). The fix normalizes the dirty-check baseline to
* the canonical form; this asserts that normalized form equals what the live editor emits.
*/
import { sleep } from '@sim/utils/helpers'
import { Editor } from '@tiptap/core'
import { afterEach, beforeAll, describe, expect, it } from 'vitest'
import { createMarkdownEditorExtensions } from './editor-extensions'
import {
applyFrontmatter,
postProcessSerializedMarkdown,
splitFrontmatter,
} from './markdown-fidelity'
import { parseMarkdownToDoc } from './markdown-parse'
import { normalizeMarkdownContent } from './normalize-content'
let editor: Editor | null = null
let host: HTMLElement | null = null
beforeAll(() => {
// jsdom lacks the layout APIs the Placeholder viewport plugin calls when a view mounts.
// @ts-expect-error jsdom stub
document.elementFromPoint = () => document.body
// @ts-expect-error jsdom stub
Range.prototype.getClientRects = () => [] as unknown as DOMRectList
Range.prototype.getBoundingClientRect = () => new DOMRect()
Element.prototype.getClientRects = () => [] as unknown as DOMRectList
})
afterEach(() => {
editor?.destroy()
editor = null
host?.remove()
host = null
})
describe('full extension set', () => {
it('mounts without a duplicate suggestion-plugin-key error (@ and / coexist)', () => {
expect(() => {
editor = new Editor({
extensions: createMarkdownEditorExtensions({ placeholder: 'x' }),
content: '',
})
}).not.toThrow()
})
})
describe('normalizeMarkdownContent — dirty-on-open baseline', () => {
it('normalizes non-canonical markdown to the editor canonical form', () => {
expect(normalizeMarkdownContent('* one\n* two\n')).toBe('- one\n- two\n')
})
it('is idempotent', () => {
for (const md of [
'* one\n* two\n',
'| a | b |\n| --- | --- |\n| 1 | 2 |\n',
'# H\n\nsome _emphasis_ here\n',
]) {
const once = normalizeMarkdownContent(md)
expect(normalizeMarkdownContent(once)).toBe(once)
}
})
it('leaves round-trip-unsafe content untouched (read-only files keep their raw bytes)', () => {
const unsafe = 'text with a footnote[^1]\n\n[^1]: the note\n'
expect(normalizeMarkdownContent(unsafe)).toBe(unsafe)
})
})
describe('baseline neutralizes the mount-time dirty signal', () => {
it('the editor mount serialization equals the normalized baseline (so isDirty stays false)', async () => {
const raw = '# H\n\n* bullet\n\n| a | b |\n| --- | --- |\n| 1 | 2 |\n\n> quote\n'
const { frontmatter, body } = splitFrontmatter(raw)
host = document.createElement('div')
document.body.appendChild(host)
let emitted: string | null = null
editor = new Editor({
element: host,
extensions: createMarkdownEditorExtensions({ placeholder: 'x' }),
content: parseMarkdownToDoc(body),
onUpdate: ({ editor }) => {
emitted = applyFrontmatter(frontmatter, postProcessSerializedMarkdown(editor.getMarkdown()))
},
})
await sleep(30)
// The deferred mount transaction re-serializes to canonical markdown; the baseline must match it
// exactly, so `content === savedContent` and the file is never falsely dirty on open.
expect(emitted).not.toBeNull()
expect(emitted).toBe(normalizeMarkdownContent(raw))
})
})
@@ -0,0 +1,55 @@
/**
* @vitest-environment jsdom
*
* The rich editor uses TipTap's initial-content model: opening a file loads its markdown as the
* editor's initial `content`, which must NOT emit an update — so a freshly opened file is never
* marked dirty (no spurious autosave / "unsaved changes"). Only a genuine edit emits, which is what
* flips the dirty/autosave state on. These two cases guard exactly that contract.
*/
import { Editor } from '@tiptap/core'
import { afterEach, describe, expect, it } from 'vitest'
import { createMarkdownContentExtensions } from './extensions'
let editor: Editor | null = null
afterEach(() => {
editor?.destroy()
editor = null
})
function mount(content: string, onUpdate: () => void): Editor {
return new Editor({
extensions: createMarkdownContentExtensions(),
content,
contentType: 'markdown',
onUpdate,
})
}
describe('rich markdown editor — dirty signal', () => {
it('opening a file emits no update (never dirty on open), including markdown that normalizes', () => {
// A trailing newline and `_emphasis_` both normalize on serialization; opening must still be clean.
let updates = 0
editor = mount('# Title\n\nsome _emphasis_ here\n', () => {
updates++
})
expect(updates).toBe(0)
expect(editor.isEmpty).toBe(false)
})
it('opening an empty file emits no update and is editable', () => {
let updates = 0
editor = mount('', () => {
updates++
})
expect(updates).toBe(0)
})
it('a genuine edit emits an update (marks dirty → triggers autosave)', () => {
let updates = 0
editor = mount('hello', () => {
updates++
})
editor.commands.insertContent(' world')
expect(updates).toBeGreaterThan(0)
})
})
@@ -0,0 +1,53 @@
import type { Extensions } from '@tiptap/core'
import Placeholder from '@tiptap/extension-placeholder'
import { BlockMover } from './block-mover'
import { CodeBlockWithLanguage } from './code-block'
import { CodeBlockHighlight } from './code-highlight'
import { LinkEmbed } from './embed/link-embed'
import { createMarkdownContentExtensions } from './extensions'
import { ResizableImage } from './image'
import { RichMarkdownKeymap } from './keymap'
import { MarkdownPaste } from './markdown-paste'
import { Mention } from './mention/mention'
import { MentionChip } from './mention/mention-chip'
import { FootnoteDefWithView, RawHtmlBlockWithView } from './raw-markdown-snippet'
import { SlashCommand } from './slash-command/slash-command'
interface MarkdownEditorExtensionOptions {
placeholder: string
/** Renders supported media links as live players beneath a standalone link. Off by default. */
embeds?: boolean
}
/**
* The full extension set for the live editor: the content extensions with their React node-view nodes
* injected (code-block language picker, resizable image, mention chip) plus the UI-only extensions —
* `CodeBlockHighlight` (Prism), `SlashCommand` (the `/` block menu), `Mention` (the `@` menu),
* `RichMarkdownKeymap`, `MarkdownPaste`, `Placeholder`, and — when `embeds` is set — `LinkEmbed`
* (media players for standalone links).
*
* Kept separate from `extensions.ts` so those node views (and the block registry the mention chip pulls
* in for brand icons) stay out of the headless round-trip path, which only needs the schema.
*/
export function createMarkdownEditorExtensions({
placeholder,
embeds = false,
}: MarkdownEditorExtensionOptions): Extensions {
return [
...createMarkdownContentExtensions({
codeBlock: CodeBlockWithLanguage,
image: ResizableImage,
mention: MentionChip,
rawHtmlBlock: RawHtmlBlockWithView,
footnoteDef: FootnoteDefWithView,
}),
CodeBlockHighlight,
SlashCommand,
Mention,
RichMarkdownKeymap,
BlockMover,
MarkdownPaste,
Placeholder.configure({ placeholder }),
...(embeds ? [LinkEmbed] : []),
]
}
@@ -0,0 +1,62 @@
import type { EmbedInfo } from '@sim/utils/media-embed'
/**
* Iframes are rendered at native size then CSS-scaled down so embedded players keep their
* intended layout inside the editor's reading column. Mirrors the note-block renderer.
*/
const EMBED_SCALE = 0.78
const EMBED_INVERSE_SCALE = `${(1 / EMBED_SCALE) * 100}%`
const IFRAME_ALLOW =
'accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share'
/**
* Build the DOM player for a resolved {@link EmbedInfo}, matching the note-block renderer's
* markup. Returned as a non-editable element so it can back a ProseMirror widget decoration
* without entering the editable content.
*/
export function createEmbedDom(embedInfo: EmbedInfo): HTMLElement {
const container = document.createElement('div')
container.className = 'my-2 block w-full overflow-hidden rounded-md'
container.contentEditable = 'false'
if (embedInfo.type === 'iframe') {
const frame = document.createElement('div')
frame.className = 'block overflow-hidden'
frame.style.width = '100%'
frame.style.aspectRatio = embedInfo.aspectRatio || '16/9'
const iframe = document.createElement('iframe')
iframe.src = embedInfo.url
iframe.title = 'Media'
iframe.allow = IFRAME_ALLOW
iframe.allowFullscreen = true
iframe.loading = 'lazy'
iframe.className = 'origin-top-left'
iframe.style.width = EMBED_INVERSE_SCALE
iframe.style.height = EMBED_INVERSE_SCALE
iframe.style.transform = `scale(${EMBED_SCALE})`
frame.appendChild(iframe)
container.appendChild(frame)
return container
}
if (embedInfo.type === 'video') {
const video = document.createElement('video')
video.src = embedInfo.url
video.controls = true
video.preload = 'metadata'
video.className = 'aspect-video w-full'
container.appendChild(video)
return container
}
const audio = document.createElement('audio')
audio.src = embedInfo.url
audio.controls = true
audio.preload = 'metadata'
audio.className = 'w-full'
container.appendChild(audio)
return container
}
@@ -0,0 +1,64 @@
/**
* @vitest-environment jsdom
*/
import { Editor } from '@tiptap/core'
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
import { createMarkdownEditorExtensions } from '../editor-extensions'
// jsdom lacks elementFromPoint, which TipTap's Placeholder viewport tracking calls on mount.
beforeAll(() => {
document.elementFromPoint = vi.fn(() => null)
})
let editor: Editor | null = null
function editorWith(content: string, embeds = true): Editor {
editor = new Editor({
extensions: createMarkdownEditorExtensions({ placeholder: '', embeds }),
content,
})
return editor
}
afterEach(() => {
editor?.destroy()
editor = null
})
const YOUTUBE_LINK = '<p><a href="https://www.youtube.com/watch?v=dQw4w9WgXcQ">watch</a></p>'
describe('LinkEmbed', () => {
it('renders a player beneath a standalone embeddable link', () => {
const view = editorWith(YOUTUBE_LINK).view
const iframe = view.dom.querySelector('iframe')
expect(iframe?.getAttribute('src')).toBe('https://www.youtube.com/embed/dQw4w9WgXcQ')
})
it('renders one player per link when the same URL appears twice', () => {
const view = editorWith(`${YOUTUBE_LINK}${YOUTUBE_LINK}`).view
expect(view.dom.querySelectorAll('iframe')).toHaveLength(2)
})
it('keeps the underlying document a plain markdown link (lossless round-trip)', () => {
const markdown = editorWith(YOUTUBE_LINK).getMarkdown()
expect(markdown).toContain('https://www.youtube.com/watch?v=dQw4w9WgXcQ')
expect(markdown).not.toContain('<iframe')
})
it('does not embed an inline link inside surrounding text', () => {
const view = editorWith(
'<p>see <a href="https://www.youtube.com/watch?v=dQw4w9WgXcQ">here</a> now</p>'
).view
expect(view.dom.querySelector('iframe')).toBeNull()
})
it('does not embed a non-embeddable standalone link', () => {
const view = editorWith('<p><a href="https://example.com/article">read</a></p>').view
expect(view.dom.querySelector('iframe')).toBeNull()
})
it('does nothing when the embeds option is disabled', () => {
const view = editorWith(YOUTUBE_LINK, false).view
expect(view.dom.querySelector('iframe')).toBeNull()
})
})
@@ -0,0 +1,88 @@
import { getEmbedInfo } from '@sim/utils/media-embed'
import { Extension } from '@tiptap/core'
import type { Node as ProseMirrorNode } from '@tiptap/pm/model'
import { Plugin, PluginKey } from '@tiptap/pm/state'
import { Decoration, DecorationSet } from '@tiptap/pm/view'
import { createEmbedDom } from './embed-dom'
const LINK_EMBED_PLUGIN_KEY = new PluginKey('linkEmbed')
/**
* The href of a paragraph that is a single, whole-text link (a "standalone link"), or null if
* the paragraph is empty, holds non-text content, or mixes a link with other text. Only
* standalone links become media embeds — a link inline within a sentence stays a plain link,
* matching how Notion and Linear auto-embed.
*/
function getStandaloneLinkHref(paragraph: ProseMirrorNode): string | null {
if (paragraph.childCount === 0) return null
let href: string | null = null
let isStandalone = true
paragraph.forEach((child) => {
if (!isStandalone) return
const linkMark = child.isText
? child.marks.find((mark) => mark.type.name === 'link')
: undefined
if (!linkMark) {
isStandalone = false
return
}
const childHref = linkMark.attrs.href as string
if (href === null) href = childHref
else if (href !== childHref) isStandalone = false
})
return isStandalone ? href : null
}
function buildDecorations(doc: ProseMirrorNode): DecorationSet {
const decorations: Decoration[] = []
/** Per-source occurrence count, so repeated embeds of the same URL get distinct, stable keys. */
const sourceCounts = new Map<string, number>()
doc.descendants((node, pos) => {
if (node.type.name !== 'paragraph') return undefined
const href = getStandaloneLinkHref(node)
if (href) {
const embedInfo = getEmbedInfo(href)
if (embedInfo) {
const source = `embed:${embedInfo.type}:${embedInfo.url}`
const index = sourceCounts.get(source) ?? 0
sourceCounts.set(source, index + 1)
decorations.push(
Decoration.widget(pos + node.nodeSize, () => createEmbedDom(embedInfo), {
side: 1,
key: `${source}:${index}`,
})
)
}
}
// Paragraphs hold only inline content, so there is nothing more to descend into.
return false
})
return DecorationSet.create(doc, decorations)
}
/**
* Renders supported media links (YouTube, Vimeo, Spotify, Dropbox, …) as live players beneath a
* standalone link, in both the editing and read-only surfaces. Implemented as widget decorations
* so the underlying document stays a plain markdown link — embeds never enter the schema or the
* serialized markdown, keeping round-trips lossless.
*/
export const LinkEmbed = Extension.create({
name: 'linkEmbed',
addProseMirrorPlugins() {
return [
new Plugin({
key: LINK_EMBED_PLUGIN_KEY,
state: {
init: (_, { doc }) => buildDecorations(doc),
apply: (tr, current) => (tr.docChanged ? buildDecorations(tr.doc) : current),
},
props: {
decorations(state) {
return LINK_EMBED_PLUGIN_KEY.getState(state)
},
},
}),
]
},
})
@@ -0,0 +1,151 @@
import type { Extensions, JSONContent, MarkdownRendererHelpers, Node } from '@tiptap/core'
import { Code } from '@tiptap/extension-code'
import { TaskItem, TaskList } from '@tiptap/extension-list'
import { Paragraph } from '@tiptap/extension-paragraph'
import {
renderTableToMarkdown,
Table,
TableCell,
TableHeader,
TableRow,
} from '@tiptap/extension-table'
import { Markdown } from '@tiptap/markdown'
import StarterKit from '@tiptap/starter-kit'
import { MarkdownCodeBlock } from './code-block'
import { Highlight } from './highlight'
import { MarkdownImage } from './image'
import { MarkdownLinkInputRule } from './link-input-rule'
import { MarkdownMention } from './mention/mention-node'
import { SIM_LINK_SCHEME } from './mention/sim-link'
import { FootnoteDef, FootnoteRef, RawHtmlBlock, RawInlineHtml } from './raw-markdown-snippet'
/**
* The `@`-mention link scheme, registered on the Link mark — without it the schema strips the
* `sim:<kind>/<id>` href on parse/round-trip, dropping the mention. `optionalSlashes` allows the
* slash-less `sim:kind/id` form.
*/
const SIM_LINK_PROTOCOL = { scheme: SIM_LINK_SCHEME, optionalSlashes: true } as const
/**
* Inline code that can combine with bold/italic/strike (GFM permits `**`x`**`, `~~`x`~~`).
* The stock Code mark sets `excludes: '_'`, which blocks every other mark from coexisting and
* makes the bubble-menu toggles silently no-op over a code selection.
*/
const InlineCode = Code.extend({ excludes: '' })
/**
* Table that escapes interior `|` characters when serializing cells. The upstream serializer
* joins cells with `|` without escaping, so a cell containing a literal pipe silently splits
* into phantom columns on round-trip (data loss). Escaping must happen on the `table` node —
* `tableCell`/`tableHeader` have no markdown renderer; the table renders cell children directly. Only
* `|` is escaped — `renderChildren` already escapes backslashes, so escaping them again would
* double-escape and break round-trip idempotency (CodeQL's "missing backslash escape" is a false
* positive here; covered by the table round-trip tests).
*
* The upstream serializer also wraps the table in its own leading/trailing blank lines; left in,
* the block joiner adds another, so an interior table churns its surrounding whitespace to
* `\n\n\n` on the first edit. Trimming the table's own output lets the joiner own the single
* blank-line separator — without touching blank lines inside fenced code (those live in the code
* node's text, not here).
*/
const PipeSafeTable = Table.extend({
renderMarkdown: (node: JSONContent, h: MarkdownRendererHelpers) =>
renderTableToMarkdown(node, {
...h,
renderChildren: (nodes, separator) =>
h.renderChildren(nodes, separator).replace(/\|/g, '\\|'),
})
.replace(/^\n+/, '')
.replace(/\n+$/, ''),
})
/**
* Guards a paragraph's serialized text so its leading characters don't re-parse it into a different
* block on the next load:
*
* - **Leading whitespace** is stripped. It never renders in a paragraph (CommonMark strips up to three
* leading spaces, and four or more would re-parse the paragraph as an indented code block), so
* removing it is lossless and makes the round-trip idempotent.
* - **A leading block marker** (`#`, `-`, `+`, `1.`, `1)`, or a bare `---`) is backslash-escaped so the
* paragraph doesn't become a heading / list / thematic break. The upstream serializer escapes inline
* delimiters (`* _ \` [ ] ~`, so `*` bullets and `>` quotes already round-trip) but not these
* block-starting markers. Escaping is idempotent: parsing consumes the backslash, so the stored
* ProseMirror text never carries it and re-serialization is stable.
*/
function guardParagraphLeading(text: string): string {
const stripped = text.replace(/^[ \t]+/, '')
if (/^(#{1,6}([ \t]|$)|[-+][ \t]|-(?:[ \t]*-){2,}[ \t]*$)/.test(stripped)) {
return `\\${stripped}`
}
const ordered = /^(\d{1,9})([.)][ \t])/.exec(stripped)
return ordered ? `${ordered[1]}\\${stripped.slice(ordered[1].length)}` : stripped
}
/**
* Paragraph that guards its leading characters on serialize (see {@link guardParagraphLeading}) —
* otherwise a paragraph beginning with a block marker or an indent silently becomes a heading / list /
* thematic break / code block on the next load. Block separators are owned by the parent joiner, so a
* paragraph renders as just its inline children; this override wraps that with the leading guard.
*/
const BlockSafeParagraph = Paragraph.extend({
renderMarkdown: (node: JSONContent, h: MarkdownRendererHelpers) =>
guardParagraphLeading(h.renderChildren(node.content ?? [])),
})
/**
* Node-view variants the live editor injects in place of the headless defaults — the code-block
* language picker, the resizable image, and the mention chip. The mention chip pulls the block registry
* (for brand icons), so the headless round-trip path omits it: passing nothing keeps
* {@link createMarkdownContentExtensions} free of the registry and constructs no React node views.
*/
export interface ContentNodeViews {
codeBlock?: Node
image?: Node
mention?: Node
rawHtmlBlock?: Node
footnoteDef?: Node
}
/**
* The schema + serialization extensions: the nodes/marks the document can contain and the
* Markdown ⇄ ProseMirror conversion. `StarterKit` provides core nodes/marks and the
* Markdown-style input rules (`# `, `- `, `**bold**`, …); `TaskList`/`TaskItem` add
* `- [ ]` checklists; `TableKit` adds GFM tables; `Markdown` serializes back to markdown.
*
* Headless by default (the `nodeViews` overrides are empty), so importing this module — e.g. for the
* markdown round-trip in `markdown-parse.ts` — never constructs React node views or pulls the block
* registry. The live editor passes the node-view nodes via {@link createMarkdownEditorExtensions}; the
* schema and markdown output are identical either way.
*/
export function createMarkdownContentExtensions(nodeViews: ContentNodeViews = {}): Extensions {
const codeBlock = (nodeViews.codeBlock ?? MarkdownCodeBlock).configure({
HTMLAttributes: { class: 'code-editor-theme' },
})
return [
StarterKit.configure({
link: { openOnClick: false, protocols: [SIM_LINK_PROTOCOL] },
underline: false,
codeBlock: false,
code: false,
paragraph: false,
}),
BlockSafeParagraph,
InlineCode,
Highlight,
codeBlock,
(nodeViews.image ?? MarkdownImage).configure({ allowBase64: true }),
nodeViews.mention ?? MarkdownMention,
TaskList,
TaskItem.configure({ nested: true }),
PipeSafeTable.configure({ resizable: true }),
TableRow,
TableHeader,
TableCell,
nodeViews.rawHtmlBlock ?? RawHtmlBlock,
nodeViews.footnoteDef ?? FootnoteDef,
FootnoteRef,
RawInlineHtml,
MarkdownLinkInputRule,
Markdown,
]
}
@@ -0,0 +1,57 @@
/**
* @vitest-environment jsdom
*/
import { Editor } from '@tiptap/core'
import { afterEach, describe, expect, it } from 'vitest'
import { createMarkdownContentExtensions } from './extensions'
import { findHeadingPos, slugifyHeading } from './heading-anchors'
let editor: Editor | null = null
afterEach(() => {
editor?.destroy()
editor = null
})
/** A ProseMirror doc parsed from markdown, for the position-resolution tests. */
function docOf(markdown: string) {
editor = new Editor({ extensions: createMarkdownContentExtensions() })
editor.commands.setContent(markdown, { contentType: 'markdown' })
return editor.state.doc
}
describe('slugifyHeading', () => {
it('lowercases, drops punctuation, and hyphenates whitespace (GitHub-style)', () => {
expect(slugifyHeading('Getting Started')).toBe('getting-started')
expect(slugifyHeading('API Reference!')).toBe('api-reference')
expect(slugifyHeading(' Spaced Out ')).toBe('spaced-out')
expect(slugifyHeading('Node.js & Bun')).toBe('nodejs-bun')
})
it('returns an empty string for punctuation-only text', () => {
expect(slugifyHeading('!!!')).toBe('')
expect(slugifyHeading('')).toBe('')
})
})
describe('findHeadingPos', () => {
it('resolves a fragment slug to its heading position', () => {
const doc = docOf('# Intro\n\ntext\n\n## Getting Started\n\nmore')
expect(findHeadingPos(doc, 'intro')).toBeGreaterThanOrEqual(0)
expect(findHeadingPos(doc, 'getting-started')).toBeGreaterThan(findHeadingPos(doc, 'intro'))
})
it('disambiguates duplicate slugs GitHub-style (foo, foo-1, foo-2)', () => {
const doc = docOf('# Notes\n\na\n\n# Notes\n\nb\n\n# Notes\n\nc')
const first = findHeadingPos(doc, 'notes')
const second = findHeadingPos(doc, 'notes-1')
const third = findHeadingPos(doc, 'notes-2')
expect(first).toBeGreaterThanOrEqual(0)
expect(second).toBeGreaterThan(first)
expect(third).toBeGreaterThan(second)
})
it('returns -1 when no heading matches', () => {
const doc = docOf('# Only Heading\n\nbody')
expect(findHeadingPos(doc, 'missing')).toBe(-1)
})
})
@@ -0,0 +1,36 @@
import type { Node as ProseMirrorNode } from '@tiptap/pm/model'
/**
* Slugify heading text GitHub-style (lowercase, drop punctuation, collapse whitespace to hyphens) so
* that `[label](#slug)` fragment links — written against how GitHub renders the same markdown —
* resolve to the matching heading. Mirrors what `rehype-slug` produced in the old preview.
*/
export function slugifyHeading(text: string): string {
return text
.toLowerCase()
.trim()
.replace(/[^\w\s-]/g, '')
.replace(/\s+/g, '-')
.replace(/-+/g, '-')
}
/**
* The document position of the heading a `#slug` fragment link targets, or -1 if none matches.
* Computed on demand (at click time) rather than maintained as per-keystroke decorations. Duplicate
* slugs are disambiguated GitHub-style: `intro`, `intro-1`, `intro-2`, …
*/
export function findHeadingPos(doc: ProseMirrorNode, slug: string): number {
const seen = new Map<string, number>()
let found = -1
doc.descendants((node, pos) => {
if (found >= 0) return false
if (node.type.name !== 'heading') return true
const base = slugifyHeading(node.textContent)
if (!base) return true
const n = seen.get(base) ?? 0
seen.set(base, n + 1)
if ((n === 0 ? base : `${base}-${n}`) === slug) found = pos
return found < 0
})
return found
}
@@ -0,0 +1,105 @@
import type {
JSONContent,
MarkdownParseHelpers,
MarkdownRendererHelpers,
MarkdownToken,
} from '@tiptap/core'
import { Mark, markInputRule, markPasteRule, mergeAttributes } from '@tiptap/core'
import type { Transaction } from '@tiptap/pm/state'
import { Plugin } from '@tiptap/pm/state'
/**
* `==text==` with non-space edges — the Pandoc/Obsidian highlight syntax. The body allows a lone `=`
* (`=(?!=)`) but never `==`, so a highlight over text containing `=` (e.g. `==a=b==`) round-trips while
* the closing `==` still terminates the run.
*/
const HIGHLIGHT_BODY = `(?:[^=]|=(?!=))+?`
const HIGHLIGHT_TOKEN = new RegExp(String.raw`^==(?!\s)(${HIGHLIGHT_BODY})(?<!\s)==`)
/** Input/paste rule form (anchored on a preceding boundary) so typing `==x==` toggles the mark. */
const HIGHLIGHT_INPUT = new RegExp(String.raw`(?:^|\s)(==(?!\s)(${HIGHLIGHT_BODY})(?<!\s)==)$`)
const HIGHLIGHT_PASTE = new RegExp(String.raw`(?:^|\s)(==(?!\s)(${HIGHLIGHT_BODY})(?<!\s)==)`, 'g')
/**
* Highlight mark (`<mark>`), serialized to and parsed from `==text==`. CommonMark/`marked` has no
* highlight token, so this registers a custom inline tokenizer (parsing the inner text as inline
* markdown so nested marks like `==**bold**==` survive) and a `renderMarkdown` that wraps the content
* in `==`. Mirrors the verbatim-node registration pattern in `./raw-markdown-snippet`.
*
* The tokenizer's `start` returns the index of the next `==` (a plain string search, not the
* `createLexer()`-calling form the `RawHtmlBlock` caveat warns against) so `marked` breaks its inline
* text run there and gives this tokenizer a chance mid-line — `=` is not a default break char like `[`.
*
* A lone `=` is allowed inside a highlight (so `==a=b==` round-trips), but `==` cannot be encoded in the
* `==…==` delimiter (emitting `==a==b==` would split the highlight and corrupt the text on reload). The
* tokenizer/input rules already exclude `==`; an `appendTransaction` guard removes the mark from any
* text that ends up containing `==` (e.g. a toolbar highlight over `a==b`), so the doc never holds an
* unrepresentable highlight and serialization stays lossless.
*/
export const Highlight = Mark.create({
name: 'highlight',
parseHTML() {
return [{ tag: 'mark' }]
},
renderHTML({ HTMLAttributes }) {
return ['mark', mergeAttributes(HTMLAttributes), 0]
},
addInputRules() {
return [markInputRule({ find: HIGHLIGHT_INPUT, type: this.type })]
},
addPasteRules() {
return [markPasteRule({ find: HIGHLIGHT_PASTE, type: this.type })]
},
addKeyboardShortcuts() {
return { 'Mod-Shift-h': () => this.editor.commands.toggleMark(this.name) }
},
markdownTokenName: 'highlight',
markdownTokenizer: {
name: 'highlight',
level: 'inline' as const,
start: (src: string) => src.indexOf('=='),
tokenize(src: string): MarkdownToken | undefined {
const match = HIGHLIGHT_TOKEN.exec(src)
if (!match) return undefined
return { type: 'highlight', raw: match[0], text: match[1] }
},
},
parseMarkdown(token: MarkdownToken, helpers: MarkdownParseHelpers) {
const inner = token.text ?? ''
const tokens = helpers.tokenizeInline?.(inner)
const content = tokens ? helpers.parseInline(tokens) : [{ type: 'text', text: inner }]
return { mark: 'highlight', content }
},
renderMarkdown(node: JSONContent, h: MarkdownRendererHelpers) {
return `==${h.renderChildren(node.content ?? [])}==`
},
addProseMirrorPlugins() {
const markType = this.type
return [
new Plugin({
appendTransaction(transactions, _oldState, newState) {
if (!transactions.some((transaction) => transaction.docChanged)) return null
let tr: Transaction | null = null
newState.doc.descendants((node, pos) => {
if (
node.isText &&
node.text?.includes('==') &&
node.marks.some((mark) => mark.type === markType)
) {
tr = (tr ?? newState.tr).removeMark(pos, pos + node.nodeSize, markType)
}
})
return tr
},
}),
]
},
})
@@ -0,0 +1,386 @@
/**
* @vitest-environment jsdom
*/
import { Editor } from '@tiptap/core'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { extractEmbeddedFileRef } from '@/lib/uploads/utils/embedded-image-ref'
import {
createPublicFileContentSource,
createWorkspaceFileContentSource,
} from '@/hooks/use-file-content-source'
import { createMarkdownEditorExtensions } from './editor-extensions'
import {
extractImageFiles,
extractImgSrcs,
findHostedImageAttrs,
hasHostedImageHtml,
htmlReferencesSrc,
isInlineRouteSrc,
shouldSkipFileUpload,
toSameOriginPath,
} from './image-paste'
// jsdom lacks `elementFromPoint`; the Placeholder extension's viewport tracking calls it on mount.
beforeEach(() => {
vi.stubGlobal(
'ResizeObserver',
class {
observe() {}
unobserve() {}
disconnect() {}
}
)
Element.prototype.scrollIntoView = vi.fn()
document.elementFromPoint = vi.fn(() => null)
})
function imageFile(name = 'shot.png'): File {
return new File([''], name, { type: 'image/png' })
}
function transfer(
files: File[],
items: Array<{ kind: string; type: string; file: File | null }> = []
): DataTransfer {
return {
files,
items: items.map((entry) => ({
kind: entry.kind,
type: entry.type,
getAsFile: () => entry.file,
})),
} as unknown as DataTransfer
}
describe('extractImageFiles', () => {
it('returns nothing for a null payload or non-image files', () => {
expect(extractImageFiles(null)).toEqual([])
expect(extractImageFiles(transfer([new File([''], 'a.txt', { type: 'text/plain' })]))).toEqual(
[]
)
})
it('reads images from the files list (drag-drop)', () => {
const file = imageFile()
expect(extractImageFiles(transfer([file]))).toEqual([file])
})
it('falls back to items when files is empty (pasted screenshot)', () => {
const file = imageFile()
const result = extractImageFiles(transfer([], [{ kind: 'file', type: 'image/png', file }]))
expect(result).toEqual([file])
})
it('ignores non-file and non-image items', () => {
const result = extractImageFiles(
transfer(
[],
[
{ kind: 'string', type: 'text/plain', file: null },
{ kind: 'file', type: 'application/pdf', file: new File([''], 'a.pdf') },
]
)
)
expect(result).toEqual([])
})
})
describe('hasHostedImageHtml', () => {
const isHosted = (src: string) => src.startsWith('/api/files/view/')
it('detects an <img> whose src is recognized as one of our own hosted files', () => {
expect(hasHostedImageHtml('<img src="/api/files/view/wf_abc" alt="x">', isHosted)).toBe(true)
})
it('is false when the html has no img, or the img src is not one of ours', () => {
expect(hasHostedImageHtml('<p>hello</p>', isHosted)).toBe(false)
expect(hasHostedImageHtml('<img src="https://other-site.com/photo.jpg">', isHosted)).toBe(false)
expect(hasHostedImageHtml('', isHosted)).toBe(false)
})
it('matches a hosted img among multiple candidates', () => {
expect(
hasHostedImageHtml(
'<img src="https://other-site.com/a.png"><img src="/api/files/view/wf_abc">',
isHosted
)
).toBe(true)
})
// Regression: the browser doesn't put the node's persisted `attrs.src` (`/api/files/view/...`)
// onto the clipboard when a rendered <img> is copied — it puts the actual DOM `src`, which is
// `resolveImageSrc`'s REWRITTEN display URL (`/…/files/inline?key=…`/`?fileId=…`). A predicate
// that only recognizes the persisted shape (as `extractEmbeddedFileRef` alone does) never matches
// a real same-page copy, silently falling through to the re-upload path it exists to avoid.
it('recognizes the real rendered <img src> end-to-end, not just the persisted reference shape', () => {
const ws = createWorkspaceFileContentSource('ws-1')
const renderedFromKey = ws.resolveImageSrc(
'/api/files/serve/workspace/ws-1/1700000000000-deadbeefdeadbeef-photo.png'
)
const renderedFromFileId = ws.resolveImageSrc('/api/files/view/wf_abc')
expect(renderedFromKey).toMatch(/^\/api\/workspaces\/ws-1\/files\/inline\?key=/)
expect(renderedFromFileId).toBe('/api/workspaces/ws-1/files/inline?fileId=wf_abc')
// extractEmbeddedFileRef alone (the persisted-content recognizer) does NOT match either
// rendered form — that's the exact gap isInlineRouteSrc closes.
expect(extractEmbeddedFileRef(renderedFromKey as string)).toBeNull()
expect(extractEmbeddedFileRef(renderedFromFileId as string)).toBeNull()
const isHostedReal = (src: string) => extractEmbeddedFileRef(src) !== null
expect(hasHostedImageHtml(`<img src="${renderedFromKey}">`, isHostedReal)).toBe(true)
expect(hasHostedImageHtml(`<img src="${renderedFromFileId}">`, isHostedReal)).toBe(true)
})
it('recognizes the public-share inline route too', () => {
const pub = createPublicFileContentSource('tok_1', '/api/files/public/tok_1/content')
const rendered = pub.resolveImageSrc('/api/files/view/wf_abc')
expect(rendered).toBe('/api/files/public/tok_1/inline?fileId=wf_abc')
expect(hasHostedImageHtml(`<img src="${rendered}">`, () => false)).toBe(true)
})
it('matches a valid unquoted src attribute (unquoted attribute values are valid HTML)', () => {
expect(hasHostedImageHtml('<img src=/api/files/view/wf_abc>', isHosted)).toBe(true)
expect(hasHostedImageHtml("<img alt='x' src=/api/files/view/wf_abc alt=y>", isHosted)).toBe(
true
)
expect(hasHostedImageHtml('<img src=https://other-site.com/a.png>', isHosted)).toBe(false)
})
it('matches single-quoted src attributes too', () => {
expect(hasHostedImageHtml("<img src='/api/files/view/wf_abc'>", isHosted)).toBe(true)
})
})
describe('extractImgSrcs', () => {
it('extracts every img src in document order, including duplicates', () => {
expect(
extractImgSrcs('<img src="/a.png"><p>text</p><img src="/b.png"><img src="/a.png">')
).toEqual(['/a.png', '/b.png', '/a.png'])
})
it('returns an empty array for html with no img', () => {
expect(extractImgSrcs('<p>hello</p>')).toEqual([])
expect(extractImgSrcs('')).toEqual([])
})
})
describe('shouldSkipFileUpload (shared by paste and drop)', () => {
const isHosted = (src: string) => src.startsWith('/api/files/view/')
const hostedHtml = '<img src="/api/files/view/wf_abc">'
it('skips upload for a single already-hosted image', () => {
expect(shouldSkipFileUpload([imageFile()], hostedHtml, isHosted)).toBe(true)
})
it('does not skip when there is no html, or the html is not one of ours', () => {
expect(shouldSkipFileUpload([imageFile()], '', isHosted)).toBe(false)
expect(shouldSkipFileUpload([imageFile()], '<img src="https://x.com/a.png">', isHosted)).toBe(
false
)
})
it('does not skip when there are no files to upload in the first place', () => {
expect(shouldSkipFileUpload([], hostedHtml, isHosted)).toBe(false)
})
// Regression: a genuinely mixed paste/drop (the hosted image plus a separate new one) must
// still upload the new file — bailing out entirely here would silently drop it.
it('does not skip a mixed paste/drop carrying more than one image file', () => {
expect(
shouldSkipFileUpload([imageFile('a.png'), imageFile('b.png')], hostedHtml, isHosted)
).toBe(false)
})
// Regression: this must be content-based (the accompanying html), not keyed off any mutable
// per-drag flag like ProseMirror's `view.dragging` — that flag can go briefly stale (cleared up
// to ~50ms late via `dragend` when a prior internal drag was dropped outside the view), and a
// flag-based check would incorrectly suppress upload of an unrelated new file dropped in that
// window. This function only reacts to what THIS specific event's `html`/`images` contain.
it('is a pure function of the images/html actually offered, independent of any drag-session flag', () => {
expect(shouldSkipFileUpload([imageFile()], '', isHosted)).toBe(false)
expect(shouldSkipFileUpload([imageFile()], hostedHtml, isHosted)).toBe(true)
})
})
describe('isInlineRouteSrc', () => {
it('recognizes the workspace- and public-scoped inline route with key or fileId', () => {
expect(isInlineRouteSrc('/api/workspaces/ws-1/files/inline?key=workspace%2Fws-1%2Fa.png')).toBe(
true
)
expect(isInlineRouteSrc('/api/workspaces/ws-1/files/inline?fileId=wf_abc')).toBe(true)
expect(isInlineRouteSrc('/api/files/public/tok_1/inline?fileId=wf_abc')).toBe(true)
})
it('rejects non-inline paths, unrecognized query params, and external/absolute origins', () => {
expect(isInlineRouteSrc('/api/files/serve/workspace/ws-1/a.png')).toBe(false)
expect(isInlineRouteSrc('/api/workspaces/ws-1/files/inline')).toBe(false)
expect(isInlineRouteSrc('/api/workspaces/ws-1/files/inline?other=1')).toBe(false)
expect(isInlineRouteSrc('https://other-site.com/files/inline?key=x')).toBe(false)
expect(isInlineRouteSrc('data:image/png;base64,aaaa')).toBe(false)
})
})
describe('findHostedImageAttrs', () => {
const ws = createWorkspaceFileContentSource('ws-1')
function docWithImages(...attrs: Array<Record<string, unknown>>): Editor {
return new Editor({
extensions: createMarkdownEditorExtensions({ placeholder: '' }),
content: {
type: 'doc',
content: attrs.map((a) => ({ type: 'image', attrs: a })),
},
})
}
// Regression: this is the exact mechanism that avoids persisting the display-layer inline URL
// (Cursor's "Paste persists display image URLs" finding) — cloning the REAL node's attrs rather
// than re-deriving a node from the clipboard html's rewritten src.
it('finds the existing node whose RESOLVED (display) src matches, and returns its REAL persisted attrs', () => {
const persistedSrc = '/api/files/view/wf_abc'
const editor = docWithImages({ src: persistedSrc, alt: 'photo', width: '300' })
const renderedSrc = ws.resolveImageSrc(persistedSrc) as string
expect(renderedSrc).not.toBe(persistedSrc) // sanity: the rendered form really differs
const match = findHostedImageAttrs(editor.state.doc, [renderedSrc], ws.resolveImageSrc)
expect(match).not.toBeNull()
expect(match?.src).toBe(persistedSrc) // the REAL persisted src, not the rendered one
expect(match?.alt).toBe('photo')
expect(match?.width).toBe('300')
})
it('returns null when no node in the doc resolves to any target src', () => {
const editor = docWithImages({ src: '/api/files/view/wf_other' })
const match = findHostedImageAttrs(
editor.state.doc,
['/api/workspaces/ws-1/files/inline?fileId=wf_abc'],
ws.resolveImageSrc
)
expect(match).toBeNull()
})
it('returns null for an empty doc or an empty target list', () => {
const editor = docWithImages()
expect(findHostedImageAttrs(editor.state.doc, ['/anything'], ws.resolveImageSrc)).toBeNull()
const editorWithImage = docWithImages({ src: '/api/files/view/wf_abc' })
expect(findHostedImageAttrs(editorWithImage.state.doc, [], ws.resolveImageSrc)).toBeNull()
})
it('matches the first of several images, not just the last', () => {
const editor = docWithImages(
{ src: '/api/files/view/wf_one', alt: 'one' },
{ src: '/api/files/view/wf_two', alt: 'two' }
)
const renderedTwo = ws.resolveImageSrc('/api/files/view/wf_two') as string
const match = findHostedImageAttrs(editor.state.doc, [renderedTwo], ws.resolveImageSrc)
expect(match?.alt).toBe('two')
})
it('returns a defensive copy, not a live reference to the node attrs object', () => {
const persistedSrc = '/api/files/view/wf_abc'
const editor = docWithImages({ src: persistedSrc, alt: 'photo' })
const renderedSrc = ws.resolveImageSrc(persistedSrc) as string
const match = findHostedImageAttrs(editor.state.doc, [renderedSrc], ws.resolveImageSrc)
expect(match).not.toBeNull()
if (match) match.alt = 'mutated'
let originalAlt: unknown
editor.state.doc.descendants((node) => {
if (node.type.name === 'image') originalAlt = node.attrs.alt
})
expect(originalAlt).toBe('photo')
})
})
describe('origin-aware src normalization (browser-native drag/copy enrichment uses ABSOLUTE urls)', () => {
const ORIGIN = 'https://www.staging.sim.ai'
it('toSameOriginPath: relative passes through; same-origin absolute → path+query; cross-origin → null', () => {
expect(toSameOriginPath('/api/files/view/wf_a', ORIGIN)).toBe('/api/files/view/wf_a')
expect(toSameOriginPath(`${ORIGIN}/api/workspaces/ws-1/files/inline?fileId=wf_a`, ORIGIN)).toBe(
'/api/workspaces/ws-1/files/inline?fileId=wf_a'
)
expect(toSameOriginPath('https://evil.example.com/api/files/view/wf_a', ORIGIN)).toBeNull()
})
it('isInlineRouteSrc accepts the same-origin ABSOLUTE inline route (the real dragged-img src on staging)', () => {
expect(isInlineRouteSrc(`${ORIGIN}/api/workspaces/ws-1/files/inline?fileId=wf_a`, ORIGIN)).toBe(
true
)
expect(
isInlineRouteSrc(
'https://evil.example.com/api/workspaces/ws-1/files/inline?fileId=wf_a',
ORIGIN
)
).toBe(false)
})
it('hasHostedImageHtml matches absolute same-origin srcs and rejects cross-origin ones', () => {
const isHosted = (src: string) => extractEmbeddedFileRef(src) !== null
expect(hasHostedImageHtml(`<img src="${ORIGIN}/api/files/view/wf_a">`, isHosted, ORIGIN)).toBe(
true
)
expect(
hasHostedImageHtml(
`<img src="${ORIGIN}/api/workspaces/ws-1/files/inline?fileId=wf_a">`,
isHosted,
ORIGIN
)
).toBe(true)
expect(
hasHostedImageHtml(
'<img src="https://evil.example.com/api/files/view/wf_a">',
isHosted,
ORIGIN
)
).toBe(false)
})
it('findHostedImageAttrs matches when the clipboard html carries the absolute rendered url', () => {
const ws = createWorkspaceFileContentSource('ws-1')
const editor = new Editor({
extensions: createMarkdownEditorExtensions({ placeholder: '' }),
content: {
type: 'doc',
content: [{ type: 'image', attrs: { src: '/api/files/view/wf_abc', alt: 'photo' } }],
},
})
const absolute = `${ORIGIN}/api/workspaces/ws-1/files/inline?fileId=wf_abc`
const match = findHostedImageAttrs(editor.state.doc, [absolute], ws.resolveImageSrc, ORIGIN)
expect(match?.src).toBe('/api/files/view/wf_abc')
})
})
describe('htmlReferencesSrc (the "this drop is MY dragged image" check)', () => {
const ORIGIN = 'https://www.staging.sim.ai'
const RESOLVED = '/api/workspaces/ws-1/files/inline?fileId=wf_a'
it('true when the html img src is the absolute form of the resolved src', () => {
expect(htmlReferencesSrc(`<img src="${ORIGIN}${RESOLVED}">`, RESOLVED, ORIGIN)).toBe(true)
})
it('true for the relative form too (ProseMirror-serialized html)', () => {
expect(htmlReferencesSrc(`<img src="${RESOLVED}">`, RESOLVED, ORIGIN)).toBe(true)
})
// Identity must work for cross-origin srcs too: a doc's image may legitimately point at a CDN or
// badge URL, and dragging THAT node to reorder it must still match — under the previous
// same-origin-path comparison it fell into the duplicate-upload branch instead.
it('matches an external (cross-origin) image against its own exact absolute url', () => {
const EXTERNAL = 'https://cdn.example.com/badge.svg'
expect(htmlReferencesSrc(`<img src="${EXTERNAL}">`, EXTERNAL, ORIGIN)).toBe(true)
expect(
htmlReferencesSrc('<img src="https://cdn.example.com/other.svg">', EXTERNAL, ORIGIN)
).toBe(false)
})
it('false for a different image, empty html, missing resolved src, or cross-origin', () => {
expect(htmlReferencesSrc(`<img src="${ORIGIN}/api/other?fileId=wf_b">`, RESOLVED, ORIGIN)).toBe(
false
)
expect(htmlReferencesSrc('', RESOLVED, ORIGIN)).toBe(false)
expect(htmlReferencesSrc(`<img src="${ORIGIN}${RESOLVED}">`, undefined, ORIGIN)).toBe(false)
expect(
htmlReferencesSrc(`<img src="https://evil.example.com${RESOLVED}">`, RESOLVED, ORIGIN)
).toBe(false)
})
})
@@ -0,0 +1,218 @@
/**
* Extract image `File` objects from a paste/drop payload. Reads `files` first, then falls back to
* `items` — many browsers expose a pasted or copied image (e.g. a screenshot) only through
* `DataTransfer.items` with an empty `files` list, so reading `files` alone misses them.
*/
export function extractImageFiles(transfer: DataTransfer | null): File[] {
if (!transfer) return []
const fromFiles = Array.from(transfer.files).filter((file) => file.type.startsWith('image/'))
if (fromFiles.length > 0) return fromFiles
return Array.from(transfer.items)
.filter((item) => item.kind === 'file' && item.type.startsWith('image/'))
.map((item) => item.getAsFile())
.filter((file): file is File => file !== null)
}
/**
* Matches `<img>` `src` attribute values: double-quoted, single-quoted, or (validly) unquoted per
* the HTML spec — the browser's own clipboard serialization always quotes it, but other producers
* of `text/html` are not obligated to.
*/
const IMG_SRC_RE = /<img\b[^>]*\bsrc\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'>]+))/gi
/** Query params under which the inline route addresses a workspace file. */
const INLINE_ROUTE_QUERY_KEYS = new Set(['key', 'fileId'])
/**
* The page's own origin — clipboard/dataTransfer srcs on any other origin are never ours.
* Deliberately `window.location.origin`, NOT `getBaseUrl()`: the browser serializes a dragged/copied
* `<img>`'s URL against the origin the page is ACTUALLY being viewed on, which can legitimately
* differ from the configured `NEXT_PUBLIC_APP_URL` (localhost dev against a shared env, preview
* deploys, apex-vs-www) — comparing against the configured URL would silently fail on any such
* origin, which is the exact bug class this normalization exists to fix.
*/
function runtimeOrigin(): string {
return typeof window === 'undefined' ? '' : window.location.origin
}
/**
* Normalizes a clipboard/dataTransfer img `src` to an origin-relative `pathname?query`, or `null`
* when it belongs to a different origin. The browser's NATIVE drag/copy enrichment (dragging or
* "Copy Image" on a rendered `<img>`) serializes the ABSOLUTE resolved URL —
* `https://host/api/workspaces/…/inline?…` — while everything the app compares against (persisted
* refs, `resolveImageSrc` output) is origin-relative, so both must be brought into the same space
* before comparing. A cross-origin src must never be treated as ours.
*/
export function toSameOriginPath(src: string, origin = runtimeOrigin()): string | null {
try {
const base = origin || 'http://placeholder'
const parsed = new URL(src, base)
if (parsed.origin !== base) return null
return parsed.pathname + parsed.search
} catch {
return null
}
}
/**
* True for the *display-layer* inline route `resolveImageSrc` (see `use-file-content-source.tsx`)
* rewrites an embed to — workspace-scoped `/api/workspaces/{workspaceId}/files/inline?key=…`/`?fileId=…`
* or public-share-scoped `/api/files/public/{token}/inline?key=…`/`?fileId=…`. This is the shape
* actually rendered into `<img src>`, and so what a same-page copy's `text/html` clipboard payload
* actually contains (absolute — see {@link toSameOriginPath}) — NOT the raw stored reference
* `extractEmbeddedFileRef` (in `@/lib/uploads/utils/embedded-image-ref`) recognizes, which only
* matches the persisted `src` before that rewrite. Checked separately from (rather than folded into)
* `extractEmbeddedFileRef` since that helper is shared with server-side authorization/export code
* operating on persisted content, where this display-only shape should never legitimately appear.
*/
export function isInlineRouteSrc(src: string, origin = runtimeOrigin()): boolean {
const path = toSameOriginPath(src, origin)
if (path === null) return false
try {
const parsed = new URL(path, 'http://placeholder')
if (!parsed.pathname.endsWith('/inline')) return false
for (const key of parsed.searchParams.keys()) {
if (INLINE_ROUTE_QUERY_KEYS.has(key)) return true
}
return false
} catch {
return false
}
}
/**
* Extracts every `<img>` `src` value found in `html`, in document order (may contain duplicates).
*/
export function extractImgSrcs(html: string): string[] {
const srcs: string[] = []
for (const match of html.matchAll(IMG_SRC_RE)) {
const src = match[1] ?? match[2] ?? match[3]
if (src) srcs.push(src)
}
return srcs
}
/**
* True when `html` contains an `<img>` whose `src` is already one of our own hosted workspace file
* references. Copying a rendered `<img>` that's already on the page (e.g. Cmd+C after clicking it to
* select it) makes the browser put BOTH `text/html` (the real serialized node, with its real hosted
* `src`) AND a synthesized image `File` onto the clipboard — the same "drag a web image out" behavior
* that {@link extractImageFiles} alone can't tell apart from a genuinely new external image paste.
* Srcs are normalized origin-relative first ({@link toSameOriginPath}): ProseMirror's own clipboard
* serialization writes the persisted relative src, but the BROWSER's native enrichment writes the
* absolute resolved URL.
*/
export function hasHostedImageHtml(
html: string,
isHostedRef: (src: string) => boolean,
origin = runtimeOrigin()
): boolean {
return extractImgSrcs(html).some((src) => {
const path = toSameOriginPath(src, origin)
return path !== null && (isHostedRef(path) || isInlineRouteSrc(path, origin))
})
}
/** Resolves `src` to a full absolute URL against `origin`, or `null` when unparseable. */
function toAbsoluteUrl(src: string, origin: string): string | null {
try {
return new URL(src, origin || 'http://placeholder').href
} catch {
return null
}
}
/**
* True when `html` contains an `<img>` whose src resolves to the same ABSOLUTE URL as
* `resolvedSrc` (a `resolveImageSrc` output for a node already in this document). This is the
* "that drop is MY dragged image" check for internal drag-reorder: TipTap's node-view dragstart
* bypasses ProseMirror's serialization entirely (no PM `text/html`, no `view.dragging`) but
* NodeSelects the dragged image, and the browser's native enrichment carries the absolute rendered
* URL of exactly that node.
*
* Deliberately compares full absolute URLs rather than same-origin paths ({@link toSameOriginPath}):
* identity is the question here, not hosted-by-us membership — a doc's image may legitimately have a
* cross-origin `src` (a README badge, a CDN image), and dragging THAT node to reorder it must match
* too, or it falls into the duplicate-upload path. Relative and absolute spellings of the same URL
* still compare equal because both sides resolve against the page origin.
*/
export function htmlReferencesSrc(
html: string,
resolvedSrc: string | undefined,
origin = runtimeOrigin()
): boolean {
if (!html || !resolvedSrc) return false
const target = toAbsoluteUrl(resolvedSrc, origin)
if (target === null) return false
return extractImgSrcs(html).some((src) => toAbsoluteUrl(src, origin) === target)
}
/**
* True when a paste or drop should be diverted away from the upload-from-file path — it carries
* exactly one image file, and the accompanying `text/html` shows it's a same-page copy of an
* already-hosted image (see {@link hasHostedImageHtml}) rather than a genuinely new external image.
* Content-based (not `view.dragging`-based, for the drop case): `view.dragging` can go briefly stale
* (cleared up to ~50ms late by ProseMirror's own `dragend` handler when a prior internal drag was
* dropped outside this view) and must never suppress upload of an unrelated, genuinely new file that
* happens to land in that window — this check only reacts to what THIS specific event's `html`
* actually contains. Gated on exactly one file — a genuinely mixed paste/drop (the hosted image plus a
* separate new one) must still upload the new file, not have the whole paste/drop diverted.
*/
export function shouldSkipFileUpload(
images: File[],
html: string,
isHostedRef: (src: string) => boolean
): boolean {
return images.length === 1 && Boolean(html) && hasHostedImageHtml(html, isHostedRef)
}
/** Minimal shape of a ProseMirror image node — just enough to read its type name and attrs. */
interface ImageLikeNode {
type: { name: string }
attrs: Record<string, unknown>
}
/** Minimal shape of a ProseMirror doc — just enough to walk its nodes. */
interface DescendantsDoc {
descendants: (callback: (node: ImageLikeNode) => boolean | undefined) => void
}
/**
* Finds the first `image` node already in `doc` whose *rendered* src (`resolveImageSrc(node.attrs.src)`)
* matches one of `targetSrcs`, and returns its attrs — a defensive copy, safe to hand straight to
* `insertContentAt`. Returns `null` if no match is found (e.g. the source node was deleted, or this is
* genuinely a different document than the one the html was copied from).
*
* Used to clone a same-page copy/drag of an already-hosted image faithfully — the exact persisted
* `src` (and every other attribute: width, height, href, title…) — rather than re-deriving a node from
* the clipboard/dataTransfer `html`, whose `src` is `resolveImageSrc`'s rewritten *display* URL, not the
* real persisted one. Inserting a node built from that display URL would bake it into the document,
* which public share/export/referenced-by-doc tracking don't recognize (they only match the persisted
* shape) — this lookup avoids ever constructing such a node in the first place. Both sides of the
* comparison are normalized origin-relative ({@link toSameOriginPath}): the clipboard html may carry
* the browser's absolute URLs.
*/
export function findHostedImageAttrs(
doc: DescendantsDoc,
targetSrcs: string[],
resolveImageSrc: (src: string | undefined) => string | undefined,
origin = runtimeOrigin()
): Record<string, unknown> | null {
const targets = new Set(
targetSrcs.map((src) => toSameOriginPath(src, origin)).filter((p): p is string => p !== null)
)
let found: Record<string, unknown> | null = null
doc.descendants((node) => {
if (found) return false
if (node.type.name === 'image') {
const resolved = resolveImageSrc(node.attrs.src as string | undefined)
const resolvedPath = resolved ? toSameOriginPath(resolved, origin) : null
if (resolvedPath && targets.has(resolvedPath)) {
found = { ...node.attrs }
return false
}
}
return true
})
return found
}
@@ -0,0 +1,42 @@
/**
* @vitest-environment jsdom
*/
import { describe, expect, it } from 'vitest'
import {
createPublicFileContentSource,
createWorkspaceFileContentSource,
} from '@/hooks/use-file-content-source'
const KEY = 'workspace/W1/1700000000000-deadbeefdeadbeef-photo.png'
const ENCODED = encodeURIComponent(KEY)
describe('content-source resolveImageSrc', () => {
it('in-app source rewrites embeds to the workspace-scoped inline route', () => {
const src = createWorkspaceFileContentSource('ws-1')
expect(src.resolveImageSrc(`/api/files/serve/${ENCODED}?context=workspace`)).toBe(
`/api/workspaces/ws-1/files/inline?key=${encodeURIComponent(KEY)}`
)
expect(src.resolveImageSrc('/api/files/view/wf_abc')).toBe(
'/api/workspaces/ws-1/files/inline?fileId=wf_abc'
)
})
it('public source rewrites embeds to the token-scoped inline route', () => {
const src = createPublicFileContentSource('tok_1', '/api/files/public/tok_1/content')
expect(src.resolveImageSrc('/api/files/view/wf_abc')).toBe(
'/api/files/public/tok_1/inline?fileId=wf_abc'
)
})
it('passes external/data srcs through unchanged in both sources', () => {
const ws = createWorkspaceFileContentSource('ws-1')
const pub = createPublicFileContentSource('tok_1', '/c')
expect(ws.resolveImageSrc('https://cdn.example.com/a.png')).toBe(
'https://cdn.example.com/a.png'
)
expect(pub.resolveImageSrc('https://cdn.example.com/a.png')).toBe(
'https://cdn.example.com/a.png'
)
expect(ws.resolveImageSrc(undefined)).toBeUndefined()
})
})
@@ -0,0 +1,325 @@
import { useEffect, useRef, useState } from 'react'
import { cn } from '@sim/emcn'
import type { JSONContent } from '@tiptap/core'
import { Image } from '@tiptap/extension-image'
import { NodeSelection, Plugin } from '@tiptap/pm/state'
import type { ReactNodeViewProps } from '@tiptap/react'
import { NodeViewWrapper, ReactNodeViewRenderer } from '@tiptap/react'
import { useFileContentSource } from '@/hooks/use-file-content-source'
import { normalizeLinkHref } from './markdown-fidelity'
import { useEditorEditable } from './use-editor-editable'
const MIN_WIDTH = 64
/**
* A markdown linked image `[![alt](src "t")](href "t2")` — an image wrapped in a link, the canonical
* form of a README badge. `@tiptap/markdown` parses this as a link mark over an image node, but an
* image node can't carry inline marks, so the wrapping link is silently dropped. We instead tokenize
* the whole construct ourselves and hang the link target on the image node's `href` attribute, so it
* round-trips losslessly (and the file stays editable rather than opening read-only).
*/
const LINKED_IMAGE_RE =
/^\[!\[([^\]]*)\]\(([^)\s]+)(?:\s+"([^"]*)")?\)\]\(([^)\s]+)(?:\s+"([^"]*)")?\)/
/** Escape a value for safe interpolation into a double-quoted HTML attribute. */
function escapeAttr(value: string): string {
return value
.replace(/&/g, '&amp;')
.replace(/"/g, '&quot;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
}
/**
* Serialize an image to markdown when it has no explicit size, and to an HTML `<img>` tag when
* it does — standard markdown has no width syntax, so a resized image must round-trip as HTML to
* preserve its dimensions. Unsized images stay clean `![alt](src)`. An image with an `href` is
* wrapped in a markdown link so a linked badge round-trips as `[![alt](src)](href)`.
*
* A *sized **and** linked* image is the one case markdown can't represent: the linked-image tokenizer
* only recognizes `[![alt](src)](href)`, so emitting `[<img …>](href)` would silently drop the link on
* reparse (and the round-trip-safety probe wouldn't catch it). We keep the link and fall back to the
* unsized `[![alt](src)](href)` form — the link matters more than the exact dimensions for a badge.
*/
function imageMarkdown(node: JSONContent): string {
const attrs = node.attrs ?? {}
const src = typeof attrs.src === 'string' ? attrs.src : ''
const alt = typeof attrs.alt === 'string' ? attrs.alt : ''
const title = typeof attrs.title === 'string' ? attrs.title : ''
const href = typeof attrs.href === 'string' ? attrs.href : ''
const hrefTitle = typeof attrs.hrefTitle === 'string' ? attrs.hrefTitle : ''
const width = attrs.width
const height = attrs.height
let image: string
if ((width || height) && !href) {
const parts = [`src="${escapeAttr(src)}"`]
if (alt) parts.push(`alt="${escapeAttr(alt)}"`)
if (title) parts.push(`title="${escapeAttr(title)}"`)
if (width) parts.push(`width="${escapeAttr(String(width))}"`)
if (height) parts.push(`height="${escapeAttr(String(height))}"`)
image = `<img ${parts.join(' ')}>`
} else {
// Escape so an alt with `]`/`[` or a title with `"` can't break out of the `![…](… "…")` syntax
// and corrupt the round-trip; a src with spaces/parens goes in angle brackets (CommonMark).
const titlePart = title ? ` "${title.replace(/["\\]/g, '\\$&')}"` : ''
const safeSrc = /[\s()]/.test(src) ? `<${src}>` : src
image = `![${alt.replace(/[\\[\]]/g, '\\$&')}](${safeSrc}${titlePart})`
}
if (!href) return image
// Escape `"`/`\` so an href title can't break out of the `[…](href "title")` syntax (mirrors the
// image title escaping above).
const hrefTitlePart = hrefTitle ? ` "${hrefTitle.replace(/["\\]/g, '\\$&')}"` : ''
return `[${image}](${href}${hrefTitlePart})`
}
interface MarkdownImageToken {
/** Set only by our linked-image tokenizer; absent on the built-in `![](src)` token. */
src?: string
alt?: string
title?: string | null
/** Built-in image token holds the source URL here; our linked token holds the link target. */
href?: string
hrefTitle?: string | null
/** Built-in image token holds the alt text here. */
text?: string
}
/** Map both the built-in image token and our linked-image token onto the image node's attributes. */
function parseImageToken(token: MarkdownImageToken): JSONContent {
const isLinked = typeof token.src === 'string'
return {
type: 'image',
attrs: isLinked
? {
src: token.src,
alt: token.alt ?? '',
title: token.title ?? null,
href: token.href ?? null,
hrefTitle: token.hrefTitle ?? null,
}
: {
src: token.href ?? '',
alt: token.text ?? '',
title: token.title ?? null,
href: null,
hrefTitle: null,
},
}
}
const widthAttr = {
default: null,
parseHTML: (element: HTMLElement) => element.getAttribute('width'),
renderHTML: (attributes: Record<string, unknown>) =>
attributes.width ? { width: String(attributes.width) } : {},
}
const heightAttr = {
default: null,
parseHTML: (element: HTMLElement) => element.getAttribute('height'),
renderHTML: (attributes: Record<string, unknown>) =>
attributes.height ? { height: String(attributes.height) } : {},
}
/** Link target of a linked image — markdown-only state, never emitted as an HTML `<img>` attribute. */
const hrefAttr = { default: null, rendered: false }
const hrefTitleAttr = { default: null, rendered: false }
/**
* Image node that carries optional `width`/`height` (serialized as an HTML `<img>` tag) and an
* optional `href`/`hrefTitle` (a wrapping markdown link, for badges). Shared by the headless
* round-trip path (no node view) and the live {@link ResizableImage}.
*/
export const MarkdownImage = Image.extend({
addAttributes() {
return {
...this.parent?.(),
width: widthAttr,
height: heightAttr,
href: hrefAttr,
hrefTitle: hrefTitleAttr,
}
},
markdownTokenizer: {
name: 'image',
level: 'inline',
start: (src: string) => src.indexOf('[!['),
tokenize: (src: string): (MarkdownImageToken & { type: string; raw: string }) | undefined => {
const match = LINKED_IMAGE_RE.exec(src)
if (!match) return undefined
return {
type: 'image',
raw: match[0],
alt: match[1] ?? '',
src: match[2],
title: match[3] ?? null,
href: match[4],
hrefTitle: match[5] ?? null,
}
},
},
parseMarkdown: parseImageToken,
renderMarkdown: imageMarkdown,
})
/**
* Drag-to-resize image node view (handle at the bottom-right, revealed on selection). Dragging
* commits the new pixel width to the `width` attribute, which serializes to `<img width>`.
*/
function ResizableImageView({ node, updateAttributes, selected, editor }: ReactNodeViewProps) {
const source = useFileContentSource()
const imageRef = useRef<HTMLImageElement>(null)
const dragAbortRef = useRef<AbortController | null>(null)
const dragWidthRef = useRef<number | null>(null)
const [dragging, setDragging] = useState(false)
/** Live width during a resize drag; kept out of the doc so the whole resize is one undo step. */
const [dragWidth, setDragWidth] = useState<number | null>(null)
/** Whether the current src failed to load; reset on src change so a retried/edited src can load. */
const [failed, setFailed] = useState(false)
const attrs = node.attrs as {
src?: string
alt?: string
title?: string
width?: string | null
href?: string | null
}
useEffect(() => () => dragAbortRef.current?.abort(), [])
useEffect(() => setFailed(false), [attrs.src])
const startResize = (event: React.PointerEvent) => {
event.preventDefault()
const image = imageRef.current
if (!image) return
const startX = event.clientX
const startWidth = image.offsetWidth
setDragging(true)
dragAbortRef.current?.abort()
const controller = new AbortController()
dragAbortRef.current = controller
const { signal } = controller
window.addEventListener(
'pointermove',
(move) => {
const next = Math.max(MIN_WIDTH, Math.round(startWidth + (move.clientX - startX)))
dragWidthRef.current = next
setDragWidth(next)
},
{ signal }
)
const finish = () => {
const finalWidth = dragWidthRef.current
setDragging(false)
setDragWidth(null)
dragWidthRef.current = null
controller.abort()
if (finalWidth !== null) updateAttributes({ width: String(finalWidth) })
}
window.addEventListener('pointerup', finish, { signal })
window.addEventListener('pointercancel', finish, { signal })
}
const committedWidth = attrs.width
? /^\d+$/.test(attrs.width)
? `${attrs.width}px`
: attrs.width
: undefined
const widthStyle =
dragWidth !== null
? { width: `${dragWidth}px` }
: committedWidth
? { width: committedWidth }
: undefined
// Sanitize the linked-image target before rendering the anchor — a parsed markdown href is
// untrusted and could be `javascript:`/`data:`; an unsafe value drops the link (image only).
const safeHref = normalizeLinkHref(typeof attrs.href === 'string' ? attrs.href : '')
// Read-only: no drag-to-reorder and no resize handle — both call updateAttributes / dispatch a move,
// mutating a doc that must not change. The image still renders (and follows its link on click).
const editable = useEditorEditable(editor)
const image = (
<img
ref={imageRef}
src={source.resolveImageSrc(attrs.src)}
alt={attrs.alt ?? ''}
title={attrs.title ?? undefined}
// When editable, the image itself is the drag handle — grab anywhere on it to reorder. (The node
// view's wrapper is forced `draggable=false` by the React renderer, so the handle must be a child;
// the resize button sits outside this element, so it keeps its own pointer behavior.)
draggable={editable}
data-drag-handle={editable ? '' : undefined}
style={widthStyle}
onError={() => setFailed(true)}
onLoad={() => setFailed(false)}
className={cn(
'block max-w-full rounded-lg border border-[var(--border)]',
editable && 'cursor-grab',
failed &&
'min-h-[72px] min-w-[140px] bg-[var(--surface-5)] p-3 text-[var(--text-muted)] text-caption'
)}
/>
)
return (
<NodeViewWrapper className='relative my-4 inline-block leading-none'>
{safeHref ? (
// The editor's handleClick is the sole navigator (gated on editable/modifier, like text links
// via openOnClick:false): prevent the anchor's own navigation so a plain click in edit mode
// places the caret / selects the node instead of opening a tab.
<a
href={safeHref}
target='_blank'
rel='noopener noreferrer'
className='block'
onClick={(event) => event.preventDefault()}
>
{image}
</a>
) : (
image
)}
{editable && (selected || dragging) && (
<button
type='button'
aria-label='Resize image'
onPointerDown={startResize}
className='absolute right-1 bottom-1 size-3 cursor-nwse-resize rounded-[3px] border border-[var(--bg)] bg-[var(--brand-secondary)]'
/>
)}
</NodeViewWrapper>
)
}
/** Live image node with the drag-to-resize view; same schema + markdown output as the headless one. */
export const ResizableImage = MarkdownImage.extend({
addNodeView() {
return ReactNodeViewRenderer(ResizableImageView)
},
/**
* Guarantee a plain click on the image forms a node selection. The image body is also a native drag
* source (grab-anywhere reorder), and while prosemirror-view ≥1.32.4 no longer implicitly selects on
* drag, the reverse — a click reliably selecting — is not guaranteed for an atom whose body competes
* with the drag gesture (see the ProseMirror "Draggable and NodeViews" discussion and TipTap #4526).
* Selecting here makes it deterministic while leaving drag-to-reorder intact. Read-only clicks and
* modified clicks (Cmd/Ctrl to follow a linked badge, Shift/Alt to extend) fall through to the editor's
* `handleClick` / default behavior.
*/
addProseMirrorPlugins() {
const nodeName = this.name
return [
new Plugin({
props: {
handleClickOn(view, _pos, node, nodePos, event) {
if (!view.editable || node.type.name !== nodeName) return false
if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return false
view.dispatch(view.state.tr.setSelection(NodeSelection.create(view.state.doc, nodePos)))
return true
},
},
}),
]
},
})
@@ -0,0 +1,427 @@
/**
* @vitest-environment jsdom
*
* The leaf-selection arrow shortcuts (ArrowUp/ArrowDown → select an adjacent divider/image) run at a
* high priority, so they must yield while a `/` or `@` suggestion menu is open — otherwise the arrow
* selects the adjacent node instead of moving the menu selection. These assert the plugin state the
* keymap's `isSuggestionMenuOpen` guard reads flips on when a menu opens.
*/
import { Editor } from '@tiptap/core'
import { GapCursor } from '@tiptap/pm/gapcursor'
import { AllSelection, NodeSelection } from '@tiptap/pm/state'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createMarkdownEditorExtensions } from './editor-extensions'
import { MENTION_PLUGIN_KEY } from './mention'
import { SLASH_COMMAND_PLUGIN_KEY } from './slash-command/slash-command'
function editorWith(content: string): Editor {
return new Editor({ extensions: createMarkdownEditorExtensions({ placeholder: '' }), content })
}
/** Block-type sequence of the top-level doc nodes. */
function blockShape(editor: Editor): string[] {
const shape: string[] = []
editor.state.doc.forEach((node) => shape.push(node.type.name))
return shape
}
/** Position of the first node of `type`, or -1. */
function firstPosOf(editor: Editor, type: string): number {
let pos = -1
editor.state.doc.descendants((node, p) => {
if (pos < 0 && node.type.name === type) pos = p
})
return pos
}
function pressKey(editor: Editor, key: string): void {
editor.view.dom.dispatchEvent(
new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true })
)
}
function pressBackspace(editor: Editor): void {
pressKey(editor, 'Backspace')
}
/** Empties the item whose text is `word` (caret left at its start), the state before a boundary key. */
function emptyItem(editor: Editor, word: string): void {
let from = -1
let to = -1
editor.state.doc.descendants((node, pos) => {
if (from < 0 && node.isText && node.text === word) {
from = pos
to = pos + word.length
}
})
editor.commands.setTextSelection({ from, to })
editor.commands.deleteSelection()
}
/** Serialized markdown after re-parsing it once — equal to `getMarkdown()` only if it round-trips. */
function markdownRoundTrip(editor: Editor): { md: string; reparsed: string } {
const md = editor.getMarkdown()
editor.commands.setContent(md, { contentType: 'markdown' })
return { md, reparsed: editor.getMarkdown() }
}
describe('suggestion-aware arrow keymap', () => {
beforeEach(() => {
// The suggestion render lifecycle uses these; jsdom lacks them.
vi.stubGlobal(
'ResizeObserver',
class {
observe() {}
unobserve() {}
disconnect() {}
}
)
Element.prototype.scrollIntoView = vi.fn()
document.elementFromPoint = vi.fn(() => null)
})
it('flags the mention menu active when `@` is typed before a divider', () => {
const editor = editorWith('<p></p><hr>')
editor.commands.focus()
editor.commands.insertContent('@gma')
expect(MENTION_PLUGIN_KEY.getState(editor.state)?.active).toBe(true)
editor.destroy()
})
it('flags the slash menu active when `/` is typed', () => {
const editor = editorWith('<p></p>')
editor.commands.focus()
editor.commands.insertContent('/')
expect(SLASH_COMMAND_PLUGIN_KEY.getState(editor.state)?.active).toBe(true)
editor.destroy()
})
it('keeps both menus inactive on plain text', () => {
const editor = editorWith('<p>hello</p><hr>')
editor.commands.focus()
expect(MENTION_PLUGIN_KEY.getState(editor.state)?.active).toBe(false)
expect(SLASH_COMMAND_PLUGIN_KEY.getState(editor.state)?.active).toBe(false)
editor.destroy()
})
})
describe('divider Backspace', () => {
beforeEach(() => {
Element.prototype.scrollIntoView = vi.fn()
})
it('removes the empty line between two dividers and selects the higher divider', () => {
const editor = editorWith('<p>before</p><hr><p></p><hr><p>after</p>')
editor.commands.focus()
let emptyPos = -1
editor.state.doc.descendants((node, pos) => {
if (emptyPos < 0 && node.type.name === 'paragraph' && node.content.size === 0)
emptyPos = pos + 1
})
editor.commands.setTextSelection(emptyPos)
pressBackspace(editor)
expect(blockShape(editor)).toEqual([
'paragraph',
'horizontalRule',
'horizontalRule',
'paragraph',
])
const { selection } = editor.state
expect(selection instanceof NodeSelection).toBe(true)
// The selected divider is the higher (first) one, not the lower.
expect(selection.from).toBe(firstPosOf(editor, 'horizontalRule'))
editor.destroy()
})
it('selects the divider when Backspace is pressed at the start of a non-empty block below it', () => {
const editor = editorWith('<p>before</p><hr><p>text</p>')
editor.commands.focus()
let textStart = -1
editor.state.doc.descendants((node, pos) => {
if (textStart < 0 && node.type.name === 'paragraph' && node.textContent === 'text')
textStart = pos + 1
})
editor.commands.setTextSelection(textStart)
pressBackspace(editor)
const { selection } = editor.state
expect(selection instanceof NodeSelection).toBe(true)
expect((selection as NodeSelection).node.type.name).toBe('horizontalRule')
// The block is untouched — the divider is only highlighted, not deleted.
expect(blockShape(editor)).toEqual(['paragraph', 'horizontalRule', 'paragraph'])
editor.destroy()
})
it('select-all spans the whole document, dividers included', () => {
const editor = editorWith('<p>a</p><hr><p>b</p><hr><p>c</p>')
editor.commands.selectAll()
const { selection, doc } = editor.state
expect(selection instanceof AllSelection).toBe(true)
expect(selection.from).toBe(0)
expect(selection.to).toBe(doc.content.size)
editor.destroy()
})
})
describe('empty wrapped-block Backspace', () => {
beforeEach(() => {
Element.prototype.scrollIntoView = vi.fn()
})
it.each([
['bullet middle', '- one\n- two\n- three', 'two', '- one\n- three'],
['bullet first', '- one\n- two\n- three', 'one', '- two\n- three'],
['bullet last', '- one\n- two', 'two', '- one'],
['ordered middle', '1. one\n2. two\n3. three', 'two', '1. one\n2. three'],
['task middle', '- [ ] one\n- [ ] two\n- [ ] three', 'two', '- [ ] one\n- [ ] three'],
['blockquote middle', '> one\n>\n> two\n>\n> three', 'two', '> one\n>\n> three'],
['nested item', '- one\n - two\n- three', 'two', '- one\n- three'],
])(
'removes the emptied %s cleanly — one container, no stray paragraph, round-trips',
(_label, markdown, word, expected) => {
const editor = editorWith('')
editor.commands.setContent(markdown, { contentType: 'markdown' })
editor.commands.focus()
emptyItem(editor, word)
pressBackspace(editor)
const { md, reparsed } = markdownRoundTrip(editor)
expect(md.trim()).toBe(expected)
expect(reparsed).toBe(md)
editor.destroy()
}
)
it('leaves a gap cursor — never a NodeSelection — when the removed bullet was followed by an image at doc start', () => {
// Regression: `Selection.near` after the delete silently NodeSelected the following image, so a
// second Backspace while "clearing the bullet" deleted the image (and typing would have replaced
// it). The selection left behind must never make the next keystroke destructive.
const editor = editorWith({
type: 'doc',
content: [
{ type: 'bulletList', content: [{ type: 'listItem', content: [{ type: 'paragraph' }] }] },
{ type: 'image', attrs: { src: '/api/files/view/wf_img', alt: 'photo' } },
],
})
editor.commands.setTextSelection(3)
pressBackspace(editor)
expect(blockShape(editor)).toEqual(['image', 'paragraph'])
expect(editor.state.selection).not.toBeInstanceOf(NodeSelection)
pressBackspace(editor)
expect(blockShape(editor)).toEqual(['image', 'paragraph'])
editor.destroy()
})
it('does not crash on Backspace from a gap cursor (top-level selection, depth 0)', () => {
// Regression: `$from.before($from.depth)` with a gap cursor's depth of 0 threw
// "RangeError: There is no position before the top-level node" — reachable whenever a gap
// cursor sits between two leaves (the `data-gap-between-leaves` state) and Backspace is pressed.
const editor = editorWith('<hr><hr>')
const gapPos = editor.state.doc.firstChild ? editor.state.doc.firstChild.nodeSize : 1
editor.view.dispatch(
editor.state.tr.setSelection(new GapCursor(editor.state.doc.resolve(gapPos)))
)
expect(editor.state.selection).toBeInstanceOf(GapCursor)
expect(() => pressBackspace(editor)).not.toThrow()
// TipTap appends a trailing paragraph after the final leaf; the dividers must both survive.
expect(blockShape(editor)).toEqual(['horizontalRule', 'horizontalRule', 'paragraph'])
editor.destroy()
})
it('never NodeSelects a leaf BEFORE the removed bullet either (findFrom textOnly skips atoms)', () => {
// `Selection.findFrom($gap, -1, true)` cannot return a NodeSelection: with textOnly,
// prosemirror-state's findSelectionIn skips atoms entirely (`!text && isSelectable`). With an
// image directly before the emptied bullet and no textblock behind it, the backward search
// returns null and the gap-cursor branch takes over — the image is never silently selected.
const editor = editorWith({
type: 'doc',
content: [
{ type: 'image', attrs: { src: '/api/files/view/wf_img', alt: 'photo' } },
{ type: 'bulletList', content: [{ type: 'listItem', content: [{ type: 'paragraph' }] }] },
],
})
editor.commands.setTextSelection(4)
expect(editor.state.selection.$from.parent.type.name).toBe('paragraph')
pressBackspace(editor)
expect(blockShape(editor)).toEqual(['image', 'paragraph'])
expect(editor.state.selection).not.toBeInstanceOf(NodeSelection)
pressBackspace(editor)
expect(blockShape(editor)).toEqual(['image', 'paragraph'])
editor.destroy()
})
it('does not crash on Backspace from a gap cursor at the very start of the doc', () => {
// Depth-0 + offset-0 is the worst case: our old code threw before(0), and TipTap's blockquote
// handler crashes on $from.node(-1) if the key falls through — so it must be consumed.
const editor = editorWith({
type: 'doc',
content: [{ type: 'image', attrs: { src: '/api/files/view/wf_img', alt: 'photo' } }],
})
editor.view.dispatch(editor.state.tr.setSelection(new GapCursor(editor.state.doc.resolve(0))))
expect(editor.state.selection).toBeInstanceOf(GapCursor)
expect(() => pressBackspace(editor)).not.toThrow()
expect(blockShape(editor)).toEqual(['image', 'paragraph'])
editor.destroy()
})
it('still prefers the previous textblock caret when one exists (image after the bullet untouched)', () => {
const editor = editorWith({
type: 'doc',
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'hello' }] },
{ type: 'bulletList', content: [{ type: 'listItem', content: [{ type: 'paragraph' }] }] },
{ type: 'image', attrs: { src: '/api/files/view/wf_img', alt: 'photo' } },
],
})
editor.commands.setTextSelection(10)
pressBackspace(editor)
expect(blockShape(editor)).toEqual(['paragraph', 'image', 'paragraph'])
expect(editor.state.selection.empty).toBe(true)
expect(editor.state.selection).not.toBeInstanceOf(NodeSelection)
expect(editor.state.selection.$from.parent.textContent).toBe('hello')
editor.destroy()
})
})
describe('empty list-item Enter', () => {
beforeEach(() => {
Element.prototype.scrollIntoView = vi.fn()
})
it('removes an empty MIDDLE item instead of splitting the list into a stranded paragraph', () => {
const editor = editorWith('')
editor.commands.setContent('- one\n- two\n- three', { contentType: 'markdown' })
editor.commands.focus()
emptyItem(editor, 'two')
pressKey(editor, 'Enter')
const { md, reparsed } = markdownRoundTrip(editor)
expect(md.trim()).toBe('- one\n- three')
expect(reparsed).toBe(md)
editor.destroy()
})
it('leaves an empty TRAILING item to the default (exits the list)', () => {
const editor = editorWith('')
editor.commands.setContent('- one\n- two', { contentType: 'markdown' })
editor.commands.focus()
emptyItem(editor, 'two')
pressKey(editor, 'Enter')
const list = editor.getJSON().content?.find((node) => node.type === 'bulletList')
expect(list?.content).toHaveLength(1)
expect(editor.getJSON().content?.some((node) => node.type === 'paragraph')).toBe(true)
editor.destroy()
})
})
describe('verbatim block boundary (isolating)', () => {
beforeEach(() => {
Element.prototype.scrollIntoView = vi.fn()
})
function caretIntoNode(editor: Editor, nodeType: string): void {
editor.state.doc.descendants((node, pos) => {
if (node.type.name === nodeType) editor.commands.setTextSelection(pos + 1)
})
}
it.each([
['footnote definition', 'body text[^x]\n\n[^x]: the note', 'footnoteDef'],
['raw HTML block', 'body\n\n<div>\nhello\n</div>', 'rawHtmlBlock'],
])(
'Backspace at the start of a %s does not merge across its boundary and destroy it',
(_label, markdown, nodeType) => {
const editor = editorWith('')
editor.commands.setContent(markdown, { contentType: 'markdown' })
editor.commands.focus()
expect(blockShape(editor)).toContain(nodeType)
caretIntoNode(editor, nodeType)
pressBackspace(editor)
expect(blockShape(editor)).toContain(nodeType)
editor.destroy()
}
)
})
describe('block reordering (Mod-Shift-Arrow)', () => {
beforeEach(() => {
Element.prototype.scrollIntoView = vi.fn()
})
function caretInto(editor: Editor, word: string): void {
editor.state.doc.descendants((node, pos) => {
if (node.isText && node.text?.includes(word)) editor.commands.setTextSelection(pos + 1)
})
}
it('moves the current top-level block up, carrying the caret', () => {
const editor = editorWith('')
editor.commands.setContent('# One\n\nTwo para\n\n- item', { contentType: 'markdown' })
editor.commands.focus()
caretInto(editor, 'Two')
editor.commands.moveBlockUp()
expect(editor.getMarkdown().trim().startsWith('Two para')).toBe(true)
editor.destroy()
})
it('moves the current top-level block down', () => {
const editor = editorWith('')
editor.commands.setContent('# One\n\nTwo para', { contentType: 'markdown' })
editor.commands.focus()
caretInto(editor, 'One')
editor.commands.moveBlockDown()
expect(editor.getMarkdown().trim().startsWith('Two para')).toBe(true)
editor.destroy()
})
it.each([
['up', '# One\n\nabcdef'],
['down', 'abcdef\n\n# Two'],
])('keeps the caret at its original offset after moving %s (no off-by-one)', (direction, md) => {
const editor = editorWith('')
editor.commands.setContent(md, { contentType: 'markdown' })
editor.commands.focus()
let textPos = -1
editor.state.doc.descendants((node, pos) => {
if (node.isText && node.text === 'abcdef') textPos = pos
})
editor.commands.setTextSelection(textPos + 3)
if (direction === 'up') editor.commands.moveBlockUp()
else editor.commands.moveBlockDown()
const at = editor.state.selection.from
expect(editor.state.doc.textBetween(at - 1, at)).toBe('c')
expect(editor.state.doc.textBetween(at, at + 1)).toBe('d')
editor.destroy()
})
it('is a no-op at the top edge and keeps a moved list intact', () => {
const top = editorWith('')
top.commands.setContent('# One\n\nTwo', { contentType: 'markdown' })
top.commands.focus()
caretInto(top, 'One')
top.commands.moveBlockUp()
expect(top.getMarkdown().trim().startsWith('# One')).toBe(true)
top.destroy()
const list = editorWith('')
list.commands.setContent('- a\n- b\n\npara', { contentType: 'markdown' })
list.commands.focus()
caretInto(list, 'para')
list.commands.moveBlockUp()
expect(list.getMarkdown().trim()).toBe('para\n\n- a\n- b')
list.destroy()
})
})
@@ -0,0 +1,268 @@
import type { Editor } from '@tiptap/core'
import { Extension } from '@tiptap/core'
import { GapCursor } from '@tiptap/pm/gapcursor'
import type { ResolvedPos } from '@tiptap/pm/model'
import { NodeSelection, Plugin, PluginKey, Selection } from '@tiptap/pm/state'
import { Decoration, DecorationSet } from '@tiptap/pm/view'
import { MENTION_PLUGIN_KEY } from './mention'
import { SLASH_COMMAND_PLUGIN_KEY } from './slash-command/slash-command'
/** Leaf nodes that have no text position, so they can only be reached as a NodeSelection. */
const SELECTABLE_LEAVES = new Set(['horizontalRule', 'image'])
/**
* Wrapper nodes whose empty child a boundary key must remove cleanly rather than lift. Lifting an empty
* block out of one of these splits the container in two and strands an empty paragraph — a visible gap
* that also fails to round-trip through markdown (see {@link removeEmptyWrappedBlock}).
*/
const WRAPPER_TYPES = new Set(['listItem', 'taskItem', 'blockquote'])
/** Item node types a list is built from, used to detect an empty item's position within its list. */
const LIST_ITEM_TYPES = new Set(['listItem', 'taskItem'])
/** True when the resolved position sits anywhere inside a {@link WRAPPER_TYPES} ancestor. */
function isInsideWrapper($from: ResolvedPos): boolean {
for (let depth = $from.depth - 1; depth >= 1; depth--) {
if (WRAPPER_TYPES.has($from.node(depth).type.name)) return true
}
return false
}
/**
* Removes the empty textblock at `$from`, deleting up through the outermost ancestor it is the sole
* child of, then places the caret at the end of the preceding block. This keeps a list or blockquote
* whole when its middle/first/last item is emptied — where ProseMirror's default lift would split the
* container and strand an empty paragraph (a visible gap, and markdown that re-parses to a different
* document). Walking up while `childCount === 1` deletes the whole now-empty wrapper (the emptied list
* item, not just its paragraph) so no orphan `<li>` or empty continuation line is left behind.
*
* The selection left behind must be a CARET, never a NodeSelection: `Selection.near` can silently
* land a NodeSelection on an adjacent leaf (deleting the sole bullet at the top of a doc whose next
* block is an image selected that image), turning the user's next keystroke destructive — a second
* Backspace while "clearing the bullet" deleted the image, and typing would have replaced it. So:
* end of the previous textblock first; else a gap cursor at the deletion point when the neighbour is
* a leaf (typing there inserts a new block where the emptied one was, instead of replacing the leaf);
* else the next textblock.
*/
function removeEmptyWrappedBlock(editor: Editor, $from: ResolvedPos): boolean {
let depth = $from.depth
while (depth > 1 && $from.node(depth - 1).childCount === 1) depth--
const start = $from.before(depth)
const end = $from.after(depth)
return editor.commands.command(({ tr, dispatch }) => {
if (dispatch) {
tr.delete(start, end)
const $gap = tr.doc.resolve(start)
tr.setSelection(
Selection.findFrom($gap, -1, true) ??
(isLeafGap($gap) ? new GapCursor($gap) : null) ??
Selection.findFrom($gap, 1, true) ??
Selection.near($gap, -1)
)
dispatch(tr.scrollIntoView())
}
return true
})
}
/**
* True when `$pos` is a block boundary a gap cursor is valid at in this schema: the following node is
* a selectable leaf (divider/image) and there is nothing before it, or another leaf — i.e. no textblock
* on either side for a normal caret to land in.
*/
function isLeafGap($pos: ResolvedPos): boolean {
const after = $pos.nodeAfter
if (!after || !SELECTABLE_LEAVES.has(after.type.name)) return false
const before = $pos.nodeBefore
return !before || SELECTABLE_LEAVES.has(before.type.name)
}
/**
* True while a `/` or `@` suggestion menu is open. Arrow keys must reach that menu's own handler, so
* the leaf-selection shortcuts below yield rather than stealing the key to select an adjacent divider.
*/
function isSuggestionMenuOpen(editor: Editor): boolean {
const { state } = editor
return (
MENTION_PLUGIN_KEY.getState(state)?.active === true ||
SLASH_COMMAND_PLUGIN_KEY.getState(state)?.active === true
)
}
/**
* Selects the leaf (divider/image) immediately across `boundary` in `direction`, or returns false if
* the neighbour isn't a selectable leaf — the shared tail of both arrow handlers below.
*/
function selectLeafAcross(editor: Editor, boundary: number, direction: 'up' | 'down'): boolean {
const resolved = editor.state.doc.resolve(boundary)
const adjacent = direction === 'up' ? resolved.nodeBefore : resolved.nodeAfter
if (!adjacent || !SELECTABLE_LEAVES.has(adjacent.type.name)) return false
return editor.commands.setNodeSelection(
direction === 'up' ? boundary - adjacent.nodeSize : boundary
)
}
/**
* Arrowing off the edge of a textblock toward an adjacent divider or image selects that node
* (a NodeSelection), giving keyboard parity with clicking it. Without this the gap cursor swallows
* the arrow and the node can never be selected — or deleted — from the keyboard.
*/
function selectAdjacentLeaf(editor: Editor, direction: 'up' | 'down'): boolean {
const { selection } = editor.state
if (!selection.empty || !editor.view.endOfTextblock(direction)) return false
const { $from } = selection
const boundary = direction === 'up' ? $from.before($from.depth) : $from.after($from.depth)
return selectLeafAcross(editor, boundary, direction)
}
/**
* When a divider/image is already selected, arrowing toward an immediately-adjacent divider/image
* selects that one directly instead of stopping on the gap cursor between them — so stepping through a
* run of dividers is one press each. A non-leaf neighbour (a textblock) falls through to the default,
* which moves the caret into it.
*/
function selectAdjacentSelectedLeaf(editor: Editor, direction: 'up' | 'down'): boolean {
const { selection } = editor.state
if (!(selection instanceof NodeSelection) || !SELECTABLE_LEAVES.has(selection.node.type.name)) {
return false
}
const boundary = direction === 'up' ? selection.from : selection.to
return selectLeafAcross(editor, boundary, direction)
}
/**
* Editor-specific keyboard behavior layered on top of StarterKit's defaults:
*
* - **Backspace** at the start of a heading reverts it to a paragraph (ProseMirror's default joins or
* no-ops, stranding the heading style; a second Backspace then merges as usual). At the start of an
* *empty block inside a list item, task item, or blockquote* it removes that whole emptied wrapper via
* {@link removeEmptyWrappedBlock} instead of ProseMirror's default lift — lifting an empty item out of
* the middle of a list/quote splits the container in two and strands an empty paragraph (a visible gap
* that also re-parses to a different markdown document), while the default `joinBackward` alternately
* no-ops on nested items (leaving them stuck) or merges an empty continuation paragraph into the
* previous item. At the start of a block whose previous sibling is a divider or image, where
* ProseMirror's `joinBackward` can't cross the leaf and no-ops: an *empty* block is deleted (clearing
* the blank line between/below dividers without touching the divider itself), while a *non-empty*
* block selects the leaf — so a first Backspace highlights what a second deletes, the same
* highlight-before-delete affordance as clicking it and parity with the arrow-key leaf selection.
* - **Enter** on an *empty, non-trailing list/task item* removes the empty item ({@link
* removeEmptyWrappedBlock}) rather than letting the default split the list into two around a stranded
* empty paragraph (which does not round-trip). A *trailing* empty item still falls through to the
* default, which exits the list — the standard "press Enter on a blank bullet to leave the list".
* - **Mod-A** inside a code block selects only that block's contents; pressing it again (when the
* block is already fully selected) falls through to the default whole-document select-all, the
* same scoped behavior as a code editor.
* - **ArrowUp/ArrowDown** select an adjacent divider or image, whether arrowing off a textblock edge
* ({@link selectAdjacentLeaf}) or stepping from one already-selected leaf to the next
* ({@link selectAdjacentSelectedLeaf}). (The `Mod-Shift-Arrow` block-reorder chords live separately
* in `./block-mover.ts`.)
*
* Plus a plugin that (a) highlights dividers/images falling inside a range selection (e.g. select-all),
* which the browser's native text highlight skips because leaves carry no text, and (b) flags the
* editor (`data-gap-between-leaves`) while a gap cursor sits between two leaves, so the CSS can hide its
* otherwise-stray caret.
*/
export const RichMarkdownKeymap = Extension.create({
name: 'richMarkdownKeymap',
priority: 1000,
addKeyboardShortcuts() {
return {
Backspace: ({ editor }) => {
const { selection, doc } = editor.state
if (!selection.empty || selection.$from.parentOffset !== 0) return false
const { $from } = selection
// A gap cursor at the start of the doc resolves at the top level (`depth === 0`, offset 0):
// `$from.before(0)` below throws, and falling through instead is no better — TipTap's
// blockquote Backspace handler crashes on the same resolution (`$from.node(-1)` is
// undefined). There is nothing before the gap for Backspace to act on, so consume the key.
if ($from.depth === 0) return true
if ($from.parent.type.name === 'heading') {
return editor.commands.setParagraph()
}
if ($from.parent.content.size === 0 && isInsideWrapper($from)) {
return removeEmptyWrappedBlock(editor, $from)
}
const blockStart = $from.before($from.depth)
const nodeBefore = doc.resolve(blockStart).nodeBefore
if (!nodeBefore || !SELECTABLE_LEAVES.has(nodeBefore.type.name)) return false
const leafStart = blockStart - nodeBefore.nodeSize
if ($from.parent.isTextblock && $from.parent.content.size === 0) {
return editor.commands.command(({ tr, dispatch }) => {
if (dispatch) {
tr.delete(blockStart, $from.after($from.depth))
tr.setSelection(NodeSelection.create(tr.doc, leafStart))
dispatch(tr.scrollIntoView())
}
return true
})
}
return editor.commands.setNodeSelection(leafStart)
},
Enter: ({ editor }) => {
const { selection } = editor.state
if (!selection.empty || selection.$from.parentOffset !== 0) return false
const { $from } = selection
if ($from.parent.content.size !== 0) return false
const itemDepth = $from.depth - 1
if (itemDepth < 1 || !LIST_ITEM_TYPES.has($from.node(itemDepth).type.name)) return false
const listDepth = itemDepth - 1
const isTrailingItem = $from.index(listDepth) === $from.node(listDepth).childCount - 1
if (isTrailingItem) return false
return removeEmptyWrappedBlock(editor, $from)
},
'Mod-a': ({ editor }) => {
const { $from } = editor.state.selection
if ($from.parent.type.name !== 'codeBlock') return false
const from = $from.start($from.depth)
const to = $from.end($from.depth)
if (editor.state.selection.from === from && editor.state.selection.to === to) return false
return editor.commands.setTextSelection({ from, to })
},
ArrowUp: ({ editor }) =>
!isSuggestionMenuOpen(editor) &&
(selectAdjacentSelectedLeaf(editor, 'up') || selectAdjacentLeaf(editor, 'up')),
ArrowDown: ({ editor }) =>
!isSuggestionMenuOpen(editor) &&
(selectAdjacentSelectedLeaf(editor, 'down') || selectAdjacentLeaf(editor, 'down')),
}
},
addProseMirrorPlugins() {
return [
new Plugin({
key: new PluginKey('richLeafSelectionHighlight'),
props: {
decorations(state) {
const { selection } = state
if (selection.empty || selection instanceof NodeSelection) return null
const decorations: Decoration[] = []
state.doc.nodesBetween(selection.from, selection.to, (node, pos) => {
if (SELECTABLE_LEAVES.has(node.type.name)) {
decorations.push(
Decoration.node(pos, pos + node.nodeSize, { class: 'rich-leaf-in-selection' })
)
}
})
return decorations.length ? DecorationSet.create(state.doc, decorations) : null
},
attributes(state): Record<string, string> {
const { selection } = state
if (!(selection instanceof GapCursor)) return {}
const before = selection.$head.nodeBefore
const after = selection.$head.nodeAfter
if (
before &&
after &&
SELECTABLE_LEAVES.has(before.type.name) &&
SELECTABLE_LEAVES.has(after.type.name)
) {
return { 'data-gap-between-leaves': 'true' }
}
return {}
},
},
}),
]
},
})
@@ -0,0 +1,70 @@
/**
* @vitest-environment jsdom
*
* Typing a markdown link `[text](url)` should convert to a real link mark on the closing `)`.
* Input rules only fire on real text input, so these drive the editor's `handleTextInput` path
* (NOT `insertContent`, which bypasses input rules).
*/
import { Editor } from '@tiptap/core'
import { afterEach, describe, expect, it } from 'vitest'
import { createMarkdownContentExtensions } from './extensions'
let editor: Editor | null = null
afterEach(() => {
editor?.destroy()
editor = null
})
function mount(): Editor {
return new Editor({ extensions: createMarkdownContentExtensions() })
}
/** Type `prefix` (no input rules), then simulate typing the final char so the input rule fires. */
function typeWithFinalChar(ed: Editor, prefix: string, finalChar: string): boolean {
ed.commands.setContent('', { contentType: 'markdown' })
ed.commands.insertContent(prefix)
const pos = ed.state.selection.from
return ed.view.someProp('handleTextInput', (fn) => fn(ed.view, pos, pos, finalChar)) === true
}
describe('typed markdown link input rule', () => {
it('converts [text](url) to a link mark on the closing paren', () => {
editor = mount()
typeWithFinalChar(editor, '[hi](https://example.com', ')')
const json = JSON.stringify(editor.getJSON())
expect(json).toContain('"type":"link"')
expect(json).toContain('"href":"https://example.com"')
expect(editor.getText()).toBe('hi')
})
it('normalizes a bare domain to https (parity with paste)', () => {
editor = mount()
typeWithFinalChar(editor, '[site](www.example.com', ')')
expect(JSON.stringify(editor.getJSON())).toContain('"href":"https://www.example.com"')
})
it('preserves a link title', () => {
editor = mount()
typeWithFinalChar(editor, '[t](https://e.com "the title"', ')')
const json = JSON.stringify(editor.getJSON())
expect(json).toContain('"href":"https://e.com"')
expect(json).toContain('"title":"the title"')
})
it('refuses an unsafe scheme (leaves it literal)', () => {
editor = mount()
typeWithFinalChar(editor, '[x](javascript:alert(1)', ')')
expect(JSON.stringify(editor.getJSON())).not.toContain('"type":"link"')
})
it('does not fire inside a code block', () => {
editor = mount()
editor.commands.setContent('```\n\n```', { contentType: 'markdown' })
editor.commands.setTextSelection(2)
const pos = editor.state.selection.from
editor.commands.insertContent('[x](https://e.com')
editor.view.someProp('handleTextInput', (fn) => fn(editor!.view, pos, pos, ')'))
expect(JSON.stringify(editor.getJSON())).not.toContain('"type":"link"')
})
})
@@ -0,0 +1,45 @@
import { Extension, InputRule } from '@tiptap/core'
import { normalizeLinkHref } from './markdown-fidelity'
/**
* Typed markdown link: `[text](url)` or `[text](url "title")`, completed by the closing `)`. The URL
* is space-free (markdown requires `<url>` for spaces, which this intentionally skips). StarterKit's
* Link ships no input rule — only paste/autolink — so without this, typed link syntax stays literal.
*/
const LINK_INPUT_RULE = /\[([^\]]+)]\(([^)\s]+)(?:\s+"([^"]*)")?\)$/
/**
* Converts a typed markdown link into a real link mark on the closing `)`. The visible text is the
* first capture group (so `markInputRule`, which keeps the *last* group, can't express this); the
* href comes from the second group, normalized through {@link normalizeLinkHref} so a bare domain
* gets `https://` and an unsafe scheme (`javascript:`) is refused (left as literal text). Skipped
* inside code blocks, where the brackets are literal source.
*/
export const MarkdownLinkInputRule = Extension.create({
name: 'markdownLinkInputRule',
addInputRules() {
return [
new InputRule({
find: LINK_INPUT_RULE,
handler: ({ state, range, match }) => {
if (state.selection.$from.parent.type.spec.code) return null
const linkType = state.schema.marks.link
if (!linkType) return null
const [fullMatch, text, rawHref, title] = match
const href = normalizeLinkHref(rawHref ?? '')
if (!href || !text) return null
const { tr } = state
const textStart = range.from + fullMatch.indexOf(text)
const textEnd = textStart + text.length
if (textEnd < range.to) tr.delete(textEnd, range.to)
if (textStart > range.from) tr.delete(range.from, textStart)
const markEnd = range.from + text.length
tr.addMark(range.from, markEnd, linkType.create({ href, title: title || null }))
tr.removeStoredMark(linkType)
},
}),
]
},
})

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