chore: import upstream snapshot with attribution
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,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>
|
||||
)
|
||||
}
|
||||
+455
@@ -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'
|
||||
+39
@@ -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'
|
||||
+170
@@ -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>
|
||||
)
|
||||
}
|
||||
+7
@@ -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)
|
||||
+30
@@ -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>
|
||||
)
|
||||
}
|
||||
+29
@@ -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>
|
||||
)
|
||||
}
|
||||
+124
@@ -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>
|
||||
)
|
||||
}
|
||||
+14
@@ -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>
|
||||
)
|
||||
}
|
||||
+21
@@ -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>
|
||||
)
|
||||
}
|
||||
+27
@@ -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 }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
+87
@@ -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,
|
||||
}
|
||||
}
|
||||
+79
@@ -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'
|
||||
+46
@@ -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'
|
||||
+78
@@ -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>
|
||||
</>
|
||||
)
|
||||
})
|
||||
+51
@@ -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} />
|
||||
</>
|
||||
)
|
||||
})
|
||||
+1
@@ -0,0 +1 @@
|
||||
export { ownerCell } from './owner-cell'
|
||||
+52
@@ -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,
|
||||
}
|
||||
}
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
export type { ChromeActionSpec } from './resource-chrome-fallback'
|
||||
export { ResourceChromeFallback } from './resource-chrome-fallback'
|
||||
+91
@@ -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>
|
||||
)
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
export type {
|
||||
BreadcrumbEditing,
|
||||
BreadcrumbItem,
|
||||
DropdownOption,
|
||||
ResourceAction,
|
||||
} from './resource-header'
|
||||
export { ResourceHeader } from './resource-header'
|
||||
+657
@@ -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
|
||||
)
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
export type {
|
||||
ColumnOption,
|
||||
FilterConfig,
|
||||
FilterTag,
|
||||
SearchConfig,
|
||||
SearchTag,
|
||||
SortConfig,
|
||||
} from './resource-options'
|
||||
export { ResourceOptions, SortDropdown } from './resource-options'
|
||||
+300
@@ -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'
|
||||
+169
@@ -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'
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
'use client'
|
||||
|
||||
import { memo } from 'react'
|
||||
import { ChipConfirmModal } from '@sim/emcn'
|
||||
|
||||
interface DeleteConfirmModalProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
fileName?: string
|
||||
fileCount: number
|
||||
folderCount: number
|
||||
onDelete: () => void
|
||||
isPending: boolean
|
||||
}
|
||||
|
||||
export const DeleteConfirmModal = memo(function DeleteConfirmModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
fileName,
|
||||
fileCount,
|
||||
folderCount,
|
||||
onDelete,
|
||||
isPending,
|
||||
}: DeleteConfirmModalProps) {
|
||||
const totalCount = fileCount + folderCount
|
||||
const hasFolders = folderCount > 0
|
||||
const title = totalCount > 1 ? 'Delete Items' : hasFolders ? 'Delete Folder' : 'Delete File'
|
||||
const consequence = hasFolders
|
||||
? totalCount > 1
|
||||
? 'This will also delete files and folders inside any selected folders.'
|
||||
: 'This will also delete files and folders inside it.'
|
||||
: totalCount > 1
|
||||
? 'You can restore them from Recently Deleted in Settings.'
|
||||
: 'You can restore it from Recently Deleted in Settings.'
|
||||
|
||||
return (
|
||||
<ChipConfirmModal
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
srTitle={title}
|
||||
title={title}
|
||||
text={[
|
||||
'Are you sure you want to delete ',
|
||||
fileName
|
||||
? { text: fileName, bold: true }
|
||||
: `${totalCount} item${totalCount === 1 ? '' : 's'}`,
|
||||
`? ${consequence}`,
|
||||
]}
|
||||
confirm={{
|
||||
label: 'Delete',
|
||||
onClick: onDelete,
|
||||
pending: isPending,
|
||||
pendingLabel: 'Deleting...',
|
||||
}}
|
||||
/>
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1 @@
|
||||
export { DeleteConfirmModal } from './delete-confirm-modal'
|
||||
+121
@@ -0,0 +1,121 @@
|
||||
'use client'
|
||||
|
||||
import { memo } from 'react'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuTrigger,
|
||||
Eye,
|
||||
Folder,
|
||||
FolderInput,
|
||||
Pencil,
|
||||
} from '@sim/emcn'
|
||||
import { Download, Link, Trash } from '@sim/emcn/icons'
|
||||
import type { MoveOptionNode } from '@/app/workspace/[workspaceId]/files/move-options'
|
||||
import { renderMoveOption } from '@/app/workspace/[workspaceId]/files/move-options'
|
||||
|
||||
interface FileRowContextMenuProps {
|
||||
isOpen: boolean
|
||||
position: { x: number; y: number }
|
||||
onClose: () => void
|
||||
onOpen: () => void
|
||||
onDownload?: () => void
|
||||
onRename: () => void
|
||||
onDelete: () => void
|
||||
onMove?: (optionValue: string) => void
|
||||
onShare?: () => void
|
||||
moveOptions?: MoveOptionNode[]
|
||||
canEdit: boolean
|
||||
selectedCount: number
|
||||
}
|
||||
|
||||
export const FileRowContextMenu = memo(function FileRowContextMenu({
|
||||
isOpen,
|
||||
position,
|
||||
onClose,
|
||||
onOpen,
|
||||
onDownload,
|
||||
onRename,
|
||||
onDelete,
|
||||
onMove,
|
||||
onShare,
|
||||
moveOptions,
|
||||
canEdit,
|
||||
selectedCount,
|
||||
}: FileRowContextMenuProps) {
|
||||
const isMultiSelect = selectedCount > 1
|
||||
|
||||
return (
|
||||
<DropdownMenu open={isOpen} onOpenChange={(open) => !open && onClose()} modal={false}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<div
|
||||
className='pointer-events-none fixed h-px w-px'
|
||||
style={{ left: `${position.x}px`, top: `${position.y}px` }}
|
||||
tabIndex={-1}
|
||||
aria-hidden
|
||||
/>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align='start'
|
||||
side='bottom'
|
||||
sideOffset={4}
|
||||
onCloseAutoFocus={(e) => e.preventDefault()}
|
||||
>
|
||||
{!isMultiSelect && (
|
||||
<DropdownMenuItem onSelect={onOpen}>
|
||||
<Eye />
|
||||
Open
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{onDownload && (
|
||||
<DropdownMenuItem onSelect={onDownload}>
|
||||
<Download />
|
||||
{isMultiSelect ? `Download ${selectedCount} items` : 'Download'}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{canEdit && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
{!isMultiSelect && (
|
||||
<DropdownMenuItem onSelect={onRename}>
|
||||
<Pencil />
|
||||
Rename
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{!isMultiSelect && onShare && (
|
||||
<DropdownMenuItem onSelect={onShare}>
|
||||
<Link />
|
||||
Share
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{onMove && moveOptions && moveOptions.length > 0 && (
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>
|
||||
<FolderInput />
|
||||
{isMultiSelect ? `Move ${selectedCount} items` : 'Move to'}
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent>
|
||||
<DropdownMenuItem onSelect={() => onMove(moveOptions[0].value)}>
|
||||
<Folder />
|
||||
{moveOptions[0].label}
|
||||
</DropdownMenuItem>
|
||||
{moveOptions.length > 1 && <DropdownMenuSeparator />}
|
||||
{moveOptions.slice(1).map((option) => renderMoveOption(option, onMove))}
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
)}
|
||||
<DropdownMenuItem onSelect={onDelete}>
|
||||
<Trash />
|
||||
{isMultiSelect ? `Delete ${selectedCount} items` : 'Delete'}
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1 @@
|
||||
export { FileRowContextMenu } from './file-row-context-menu'
|
||||
@@ -0,0 +1,69 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useRef } from 'react'
|
||||
import { toast } from '@sim/emcn'
|
||||
import { generateId } from '@sim/utils/id'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { CSV_PREVIEW_MAX_ROWS } from '@/lib/api/contracts/workspace-file-table'
|
||||
import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace'
|
||||
import { useImportFileAsTable } from '@/hooks/queries/tables'
|
||||
import { useImportTrayStore } from '@/stores/table/import-tray/store'
|
||||
|
||||
export type CsvImportFileDescriptor = Pick<WorkspaceFileRecord, 'key' | 'name'>
|
||||
|
||||
/**
|
||||
* Wires the "Import as a table" affordance for a capped CSV preview. When the preview is
|
||||
* `truncated`, raises a one-time warning toast whose action kicks off a background import of the
|
||||
* existing workspace file — no re-upload, source preserved — and navigates to the new table.
|
||||
*/
|
||||
export function useCsvTruncationImport(
|
||||
workspaceId: string,
|
||||
file: CsvImportFileDescriptor,
|
||||
truncated: boolean,
|
||||
readOnly = false
|
||||
) {
|
||||
const router = useRouter()
|
||||
const importFile = useImportFileAsTable()
|
||||
|
||||
// Guards against a double-tap on the toast action kicking off two parallel imports of the same
|
||||
// file. Reset once the kickoff settles so a failed import can be retried.
|
||||
const importingRef = useRef(false)
|
||||
|
||||
const importAsTable = useCallback(() => {
|
||||
if (importingRef.current) return
|
||||
importingRef.current = true
|
||||
const pendingId = `pending_${generateId()}`
|
||||
useImportTrayStore
|
||||
.getState()
|
||||
.startUpload({ uploadId: pendingId, workspaceId, title: file.name })
|
||||
toast.success(`Importing "${file.name}" as a table`, {
|
||||
description: 'This runs in the background.',
|
||||
action: {
|
||||
label: 'View tables',
|
||||
onClick: () => router.push(`/workspace/${workspaceId}/tables`),
|
||||
},
|
||||
})
|
||||
importFile.mutate(
|
||||
{ workspaceId, fileKey: file.key, fileName: file.name },
|
||||
{
|
||||
onSettled: () => {
|
||||
importingRef.current = false
|
||||
useImportTrayStore.getState().endUpload(pendingId)
|
||||
},
|
||||
}
|
||||
)
|
||||
// importFile.mutate and router are stable references
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [workspaceId, file.key, file.name])
|
||||
|
||||
// Surface the cap as a warning toast with an import action, once per file.
|
||||
const notifiedKeyRef = useRef<string | null>(null)
|
||||
useEffect(() => {
|
||||
if (readOnly || !truncated || notifiedKeyRef.current === file.key) return
|
||||
notifiedKeyRef.current = file.key
|
||||
toast.warning(`Showing the first ${CSV_PREVIEW_MAX_ROWS.toLocaleString()} rows`, {
|
||||
description: 'Import this file as a table to view all of its rows.',
|
||||
action: { label: 'Import as a table', onClick: importAsTable },
|
||||
})
|
||||
}, [readOnly, truncated, file.key, importAsTable])
|
||||
}
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
'use client'
|
||||
|
||||
import { memo } from 'react'
|
||||
import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace'
|
||||
import { useWorkspaceCsvPreview } from '@/hooks/queries/workspace-file-table'
|
||||
import { useCsvTruncationImport } from './csv-import'
|
||||
import { DataTable } from './data-table'
|
||||
import { PreviewError, PreviewLoadingFrame, resolvePreviewError } from './preview-shared'
|
||||
|
||||
/**
|
||||
* Read-only preview for a CSV that is too large to load fully into the editor. Streams only the
|
||||
* first {@link CSV_PREVIEW_MAX_ROWS} rows from storage; when there are more, a warning toast offers
|
||||
* "Import as a table", which builds a full Table from the file (memory-safe streaming import).
|
||||
*/
|
||||
export const CsvTablePreview = memo(function CsvTablePreview({
|
||||
file,
|
||||
workspaceId,
|
||||
}: {
|
||||
file: WorkspaceFileRecord
|
||||
workspaceId: string
|
||||
}) {
|
||||
const version = Number(new Date(file.updatedAt)) || file.size
|
||||
const {
|
||||
data,
|
||||
isLoading,
|
||||
error: fetchError,
|
||||
} = useWorkspaceCsvPreview(workspaceId, file.id, file.key, version)
|
||||
useCsvTruncationImport(workspaceId, file, data?.truncated ?? false)
|
||||
|
||||
const error = resolvePreviewError((fetchError as Error | null) ?? null, null)
|
||||
if (error) return <PreviewError label='CSV' error={error} />
|
||||
if (isLoading || !data) {
|
||||
return <PreviewLoadingFrame className='flex flex-1 flex-col overflow-hidden' />
|
||||
}
|
||||
|
||||
if (data.headers.length === 0) {
|
||||
return (
|
||||
<div className='flex h-full items-center justify-center p-6'>
|
||||
<p className='text-[13px] text-[var(--text-muted)]'>No data to display</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='flex flex-1 flex-col overflow-auto p-6'>
|
||||
<DataTable headers={data.headers} rows={data.rows} />
|
||||
</div>
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,160 @@
|
||||
'use client'
|
||||
|
||||
import { forwardRef, memo, useCallback, useImperativeHandle, useRef, useState } from 'react'
|
||||
import { cn } from '@sim/emcn'
|
||||
|
||||
interface EditConfig {
|
||||
onCellChange: (row: number, col: number, value: string) => void
|
||||
onHeaderChange: (col: number, value: string) => void
|
||||
}
|
||||
|
||||
interface DataTableProps {
|
||||
headers: string[]
|
||||
rows: string[][]
|
||||
editConfig?: EditConfig
|
||||
}
|
||||
|
||||
export interface DataTableHandle {
|
||||
commitEdit: () => void
|
||||
}
|
||||
|
||||
type EditingCell = { row: number; col: number } | null
|
||||
|
||||
const DataTableBase = forwardRef<DataTableHandle, DataTableProps>(function DataTable(
|
||||
{ headers, rows, editConfig },
|
||||
ref
|
||||
) {
|
||||
const [editingCell, setEditingCell] = useState<EditingCell>(null)
|
||||
const [editValue, setEditValue] = useState('')
|
||||
|
||||
const editStateRef = useRef({ editingCell, editValue, editConfig })
|
||||
editStateRef.current = { editingCell, editValue, editConfig }
|
||||
|
||||
// Prevents double-commit if onBlur and imperative commitEdit fire concurrently
|
||||
const isCommittedRef = useRef(false)
|
||||
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
commitEdit: () => {
|
||||
if (isCommittedRef.current) return
|
||||
const { editingCell, editValue, editConfig } = editStateRef.current
|
||||
if (!editingCell || !editConfig) return
|
||||
isCommittedRef.current = true
|
||||
const { row, col } = editingCell
|
||||
if (row === -1) {
|
||||
editConfig.onHeaderChange(col, editValue)
|
||||
} else {
|
||||
editConfig.onCellChange(row, col, editValue)
|
||||
}
|
||||
setEditingCell(null)
|
||||
},
|
||||
}),
|
||||
[]
|
||||
)
|
||||
|
||||
const setInputRef = useCallback((node: HTMLInputElement | null) => {
|
||||
if (node) {
|
||||
node.focus()
|
||||
node.select()
|
||||
}
|
||||
}, [])
|
||||
|
||||
const startEdit = (row: number, col: number, currentValue: string) => {
|
||||
if (!editConfig) return
|
||||
isCommittedRef.current = false
|
||||
setEditingCell({ row, col })
|
||||
setEditValue(currentValue)
|
||||
}
|
||||
|
||||
const commitEdit = () => {
|
||||
if (isCommittedRef.current || !editingCell || !editConfig) return
|
||||
isCommittedRef.current = true
|
||||
const { row, col } = editingCell
|
||||
if (row === -1) {
|
||||
editConfig.onHeaderChange(col, editValue)
|
||||
} else {
|
||||
editConfig.onCellChange(row, col, editValue)
|
||||
}
|
||||
setEditingCell(null)
|
||||
}
|
||||
|
||||
const cancelEdit = () => setEditingCell(null)
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter' || e.key === 'Tab') {
|
||||
e.preventDefault()
|
||||
commitEdit()
|
||||
} else if (e.key === 'Escape') {
|
||||
cancelEdit()
|
||||
}
|
||||
}
|
||||
|
||||
const isEditing = (row: number, col: number) =>
|
||||
editingCell?.row === row && editingCell?.col === col
|
||||
|
||||
return (
|
||||
<div className='overflow-x-auto rounded-md border border-[var(--border)]'>
|
||||
<table className='w-full border-collapse text-[13px]'>
|
||||
<thead className='bg-[var(--surface-2)]'>
|
||||
<tr>
|
||||
{headers.map((header, i) => (
|
||||
<th
|
||||
key={i}
|
||||
className={cn(
|
||||
'whitespace-nowrap px-3 py-2 text-left font-semibold text-[12px] text-[var(--text-primary)]',
|
||||
editConfig && 'cursor-pointer select-none hover:bg-[var(--surface-3)]'
|
||||
)}
|
||||
onClick={() => editConfig && startEdit(-1, i, String(header ?? ''))}
|
||||
>
|
||||
{isEditing(-1, i) ? (
|
||||
<input
|
||||
ref={setInputRef}
|
||||
value={editValue}
|
||||
onChange={(e) => setEditValue(e.target.value)}
|
||||
onBlur={commitEdit}
|
||||
onKeyDown={handleKeyDown}
|
||||
className='w-full min-w-[60px] bg-transparent font-semibold text-[12px] text-[var(--text-primary)] outline-none ring-1 ring-[var(--brand-secondary)] ring-inset'
|
||||
/>
|
||||
) : (
|
||||
String(header ?? '')
|
||||
)}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row, ri) => (
|
||||
<tr key={ri} className='border-[var(--border)] border-t'>
|
||||
{headers.map((_, ci) => (
|
||||
<td
|
||||
key={ci}
|
||||
className={cn(
|
||||
'whitespace-nowrap px-3 py-2 text-[var(--text-secondary)]',
|
||||
editConfig && 'cursor-pointer select-none hover:bg-[var(--surface-2)]'
|
||||
)}
|
||||
onClick={() => editConfig && startEdit(ri, ci, String(row[ci] ?? ''))}
|
||||
>
|
||||
{isEditing(ri, ci) ? (
|
||||
<input
|
||||
ref={setInputRef}
|
||||
value={editValue}
|
||||
onChange={(e) => setEditValue(e.target.value)}
|
||||
onBlur={commitEdit}
|
||||
onKeyDown={handleKeyDown}
|
||||
className='w-full min-w-[60px] bg-transparent text-[13px] text-[var(--text-secondary)] outline-none ring-1 ring-[var(--brand-secondary)] ring-inset'
|
||||
/>
|
||||
) : (
|
||||
String(row[ci] ?? '')
|
||||
)}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
export const DataTable = memo(DataTableBase)
|
||||
@@ -0,0 +1,317 @@
|
||||
'use client'
|
||||
|
||||
import { memo, useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { cn } from '@sim/emcn'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { toError } from '@sim/utils/errors'
|
||||
import { sanitizeRenderedHyperlinks } from '@/lib/core/security/url-safety'
|
||||
import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace'
|
||||
import { PREVIEW_LOADING_OVERLAY, PreviewError, resolvePreviewError } from './preview-shared'
|
||||
import { PreviewToolbar } from './preview-toolbar'
|
||||
import { bindPreviewWheelZoom } from './preview-wheel-zoom'
|
||||
import { useDocPreviewBinary } from './use-doc-preview-binary'
|
||||
|
||||
const logger = createLogger('DocxPreview')
|
||||
|
||||
const DOCX_ZOOM_MIN = 25
|
||||
const DOCX_ZOOM_MAX = 400
|
||||
const DOCX_ZOOM_STEP = 20
|
||||
const DOCX_ZOOM_WHEEL_SENSITIVITY = 0.005
|
||||
const DOCX_RESIZE_DEBOUNCE_MS = 150
|
||||
|
||||
/**
|
||||
* Fit the rendered docx pages to the host container width using a CSS scale.
|
||||
* The library renders `<section class="docx">` at the document's natural page
|
||||
* width (in cm), which overflows narrow panels. 100% zoom means fit-to-width —
|
||||
* pages upscale past their natural print size in wide panels (CSS zoom of HTML
|
||||
* stays crisp), matching the PDF preview's semantics.
|
||||
*/
|
||||
function fitDocxToContainer(host: HTMLElement, viewport: HTMLElement, zoomPercent: number) {
|
||||
const wrapper = host.querySelector<HTMLElement>('.docx-wrapper')
|
||||
if (!wrapper) return
|
||||
const section = wrapper.querySelector<HTMLElement>('section.docx')
|
||||
if (!section) return
|
||||
|
||||
host.style.minWidth = ''
|
||||
host.style.minHeight = ''
|
||||
host.style.width = ''
|
||||
host.style.display = 'flex'
|
||||
host.style.flexDirection = 'column'
|
||||
host.style.alignItems = 'center'
|
||||
wrapper.style.zoom = ''
|
||||
wrapper.style.width = ''
|
||||
wrapper.style.flex = '0 0 auto'
|
||||
wrapper.style.marginRight = ''
|
||||
wrapper.style.marginBottom = ''
|
||||
|
||||
const naturalPageWidth = section.offsetWidth
|
||||
if (!naturalPageWidth) return
|
||||
|
||||
const wrapperStyle = window.getComputedStyle(wrapper)
|
||||
const horizontalPadding =
|
||||
Number.parseFloat(wrapperStyle.paddingLeft) + Number.parseFloat(wrapperStyle.paddingRight)
|
||||
const naturalWrapperWidth = naturalPageWidth + horizontalPadding
|
||||
const available = viewport.clientWidth
|
||||
const fitScale = available / naturalWrapperWidth
|
||||
const scale = fitScale * (zoomPercent / 100)
|
||||
const scaledWrapperWidth = naturalWrapperWidth * scale
|
||||
|
||||
wrapper.style.width = `${naturalWrapperWidth}px`
|
||||
wrapper.style.zoom = String(scale)
|
||||
host.style.width = `${Math.max(available, scaledWrapperWidth)}px`
|
||||
host.style.minWidth = `${scaledWrapperWidth}px`
|
||||
host.style.minHeight = `${wrapper.offsetHeight * scale}px`
|
||||
}
|
||||
|
||||
export const DocxPreview = memo(function DocxPreview({
|
||||
file,
|
||||
workspaceId,
|
||||
}: {
|
||||
file: WorkspaceFileRecord
|
||||
workspaceId: string
|
||||
}) {
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null)
|
||||
const zoomPercentRef = useRef(100)
|
||||
const preview = useDocPreviewBinary(workspaceId, file)
|
||||
const fileData = preview.data
|
||||
const [renderError, setRenderError] = useState<string | null>(null)
|
||||
const [rendering, setRendering] = useState(false)
|
||||
const [hasRenderedPreview, setHasRenderedPreview] = useState(false)
|
||||
const [zoomPercent, setZoomPercent] = useState(100)
|
||||
const [pageCount, setPageCount] = useState(0)
|
||||
const [currentPage, setCurrentPage] = useState(1)
|
||||
const [documentRenderVersion, setDocumentRenderVersion] = useState(0)
|
||||
|
||||
const applyPostRenderStyling = useCallback(() => {
|
||||
const container = containerRef.current
|
||||
const scrollContainer = scrollContainerRef.current
|
||||
if (!container || !scrollContainer) return
|
||||
const wrapper = container.querySelector<HTMLElement>('.docx-wrapper')
|
||||
if (wrapper) wrapper.style.background = 'transparent'
|
||||
const pages = Array.from(container.querySelectorAll<HTMLElement>('section.docx'))
|
||||
pages.forEach((page, index) => {
|
||||
page.style.boxShadow = 'var(--shadow-medium)'
|
||||
page.dataset.page = String(index + 1)
|
||||
})
|
||||
setPageCount((previous) => (previous === pages.length ? previous : pages.length))
|
||||
setCurrentPage((current) => (pages.length > 0 ? Math.min(current, pages.length) : 1))
|
||||
fitDocxToContainer(container, scrollContainer, zoomPercentRef.current)
|
||||
}, [])
|
||||
|
||||
/**
|
||||
* Resize refits are debounced: each one re-queries the rendered pages and
|
||||
* recomputes the fit scale, so per-tick refits during a panel-divider drag
|
||||
* would thrash layout continuously (the initial fit is applied directly by
|
||||
* the render path, not this observer). Mirrors the PDF preview's debounce.
|
||||
*/
|
||||
useEffect(() => {
|
||||
const scrollContainer = scrollContainerRef.current
|
||||
if (!scrollContainer) return
|
||||
let debounce: ReturnType<typeof setTimeout> | undefined
|
||||
const observer = new ResizeObserver(() => {
|
||||
clearTimeout(debounce)
|
||||
debounce = setTimeout(() => applyPostRenderStyling(), DOCX_RESIZE_DEBOUNCE_MS)
|
||||
})
|
||||
observer.observe(scrollContainer)
|
||||
return () => {
|
||||
clearTimeout(debounce)
|
||||
observer.disconnect()
|
||||
}
|
||||
}, [applyPostRenderStyling])
|
||||
|
||||
const applyZoomAt = useCallback(
|
||||
(nextZoom: number, anchorX: number, anchorY: number) => {
|
||||
const scrollContainer = scrollContainerRef.current
|
||||
if (!scrollContainer) return
|
||||
|
||||
const clampedZoom = Math.round(Math.min(Math.max(nextZoom, DOCX_ZOOM_MIN), DOCX_ZOOM_MAX))
|
||||
const wrapper = containerRef.current?.querySelector<HTMLElement>('.docx-wrapper')
|
||||
const containerRect = scrollContainer.getBoundingClientRect()
|
||||
const anchorClientX = containerRect.left + anchorX
|
||||
const anchorClientY = containerRect.top + anchorY
|
||||
const beforeRect = wrapper?.getBoundingClientRect()
|
||||
const anchorRatioX =
|
||||
beforeRect && beforeRect.width > 0
|
||||
? (anchorClientX - beforeRect.left) / beforeRect.width
|
||||
: 0
|
||||
const anchorRatioY =
|
||||
beforeRect && beforeRect.height > 0
|
||||
? (anchorClientY - beforeRect.top) / beforeRect.height
|
||||
: 0
|
||||
|
||||
zoomPercentRef.current = clampedZoom
|
||||
setZoomPercent(clampedZoom)
|
||||
applyPostRenderStyling()
|
||||
|
||||
const afterRect = wrapper?.getBoundingClientRect()
|
||||
if (!beforeRect || !afterRect) return
|
||||
|
||||
scrollContainer.scrollLeft += afterRect.left + anchorRatioX * afterRect.width - anchorClientX
|
||||
scrollContainer.scrollTop += afterRect.top + anchorRatioY * afterRect.height - anchorClientY
|
||||
},
|
||||
[applyPostRenderStyling]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const scrollContainer = scrollContainerRef.current
|
||||
if (!scrollContainer) return
|
||||
|
||||
return bindPreviewWheelZoom(scrollContainer, (event) => {
|
||||
const rect = scrollContainer.getBoundingClientRect()
|
||||
applyZoomAt(
|
||||
zoomPercentRef.current * (1 - event.deltaY * DOCX_ZOOM_WHEEL_SENSITIVITY),
|
||||
event.clientX - rect.left,
|
||||
event.clientY - rect.top
|
||||
)
|
||||
})
|
||||
}, [applyZoomAt])
|
||||
|
||||
useEffect(() => {
|
||||
const scrollContainer = scrollContainerRef.current
|
||||
const container = containerRef.current
|
||||
if (!scrollContainer || !container || pageCount === 0) return
|
||||
|
||||
const pages = Array.from(container.querySelectorAll<HTMLElement>('section.docx'))
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
for (const entry of entries) {
|
||||
if (entry.isIntersecting) {
|
||||
const page = Number((entry.target as HTMLElement).dataset.page)
|
||||
if (page) setCurrentPage(page)
|
||||
}
|
||||
}
|
||||
},
|
||||
{ root: scrollContainer, threshold: 0.5 }
|
||||
)
|
||||
|
||||
for (const page of pages) {
|
||||
observer.observe(page)
|
||||
}
|
||||
|
||||
return () => observer.disconnect()
|
||||
}, [pageCount, documentRenderVersion])
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerRef.current || !fileData) return
|
||||
|
||||
let cancelled = false
|
||||
|
||||
async function render() {
|
||||
try {
|
||||
setRendering(true)
|
||||
const { renderAsync } = await import('docx-preview')
|
||||
if (cancelled || !containerRef.current) return
|
||||
setRenderError(null)
|
||||
containerRef.current.innerHTML = ''
|
||||
await renderAsync(fileData, containerRef.current, undefined, {
|
||||
inWrapper: true,
|
||||
ignoreWidth: false,
|
||||
ignoreHeight: false,
|
||||
})
|
||||
if (!cancelled && containerRef.current) {
|
||||
sanitizeRenderedHyperlinks(containerRef.current)
|
||||
applyPostRenderStyling()
|
||||
setHasRenderedPreview(true)
|
||||
setDocumentRenderVersion((version) => version + 1)
|
||||
}
|
||||
} catch (err) {
|
||||
if (!cancelled) {
|
||||
const msg = toError(err).message || 'Failed to render document'
|
||||
logger.error('DOCX render failed', { error: msg })
|
||||
setRenderError(msg)
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setRendering(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
render()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [fileData, applyPostRenderStyling])
|
||||
|
||||
const error = resolvePreviewError(preview.error, renderError)
|
||||
if (error) return <PreviewError label='document' error={error} />
|
||||
|
||||
const showLoadingFrame = !hasRenderedPreview && (!fileData || rendering)
|
||||
|
||||
const scrollToPage = (page: number) => {
|
||||
const scrollContainer = scrollContainerRef.current
|
||||
const target = containerRef.current?.querySelector<HTMLElement>(
|
||||
`section.docx[data-page="${page}"]`
|
||||
)
|
||||
if (!scrollContainer || !target) return
|
||||
|
||||
if (zoomPercentRef.current !== 100) {
|
||||
applyZoomAt(100, scrollContainer.clientWidth / 2, scrollContainer.clientHeight / 2)
|
||||
}
|
||||
|
||||
scrollContainer.scrollTo({
|
||||
top: target.offsetTop - scrollContainer.offsetTop - 16,
|
||||
behavior: 'smooth',
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='flex h-full min-h-0 w-full flex-col overflow-hidden bg-[var(--surface-1)]'>
|
||||
<PreviewToolbar
|
||||
navigation={{
|
||||
current: currentPage,
|
||||
total: pageCount,
|
||||
label: 'page',
|
||||
canPrevious: pageCount > 0 && currentPage > 1,
|
||||
canNext: pageCount > 0 && currentPage < pageCount,
|
||||
onPrevious: () => {
|
||||
const previous = Math.max(1, currentPage - 1)
|
||||
setCurrentPage(previous)
|
||||
scrollToPage(previous)
|
||||
},
|
||||
onNext: () => {
|
||||
const next = Math.min(pageCount, currentPage + 1)
|
||||
setCurrentPage(next)
|
||||
scrollToPage(next)
|
||||
},
|
||||
}}
|
||||
zoom={{
|
||||
label: `${zoomPercent}%`,
|
||||
canZoomOut: zoomPercent > DOCX_ZOOM_MIN,
|
||||
canZoomIn: zoomPercent < DOCX_ZOOM_MAX,
|
||||
onReset: () => {
|
||||
const c = scrollContainerRef.current
|
||||
applyZoomAt(100, c ? c.clientWidth / 2 : 0, c ? c.clientHeight / 2 : 0)
|
||||
},
|
||||
onZoomOut: () => {
|
||||
const c = scrollContainerRef.current
|
||||
applyZoomAt(
|
||||
zoomPercent - DOCX_ZOOM_STEP,
|
||||
c ? c.clientWidth / 2 : 0,
|
||||
c ? c.clientHeight / 2 : 0
|
||||
)
|
||||
},
|
||||
onZoomIn: () => {
|
||||
const c = scrollContainerRef.current
|
||||
applyZoomAt(
|
||||
zoomPercent + DOCX_ZOOM_STEP,
|
||||
c ? c.clientWidth / 2 : 0,
|
||||
c ? c.clientHeight / 2 : 0
|
||||
)
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
ref={scrollContainerRef}
|
||||
className='relative min-h-0 flex-1 overflow-auto bg-[var(--surface-1)]'
|
||||
>
|
||||
{showLoadingFrame && PREVIEW_LOADING_OVERLAY}
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={cn('min-h-full w-full', showLoadingFrame && 'opacity-0')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
+103
@@ -0,0 +1,103 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuTrigger,
|
||||
} from '@sim/emcn'
|
||||
import { Clipboard, Duplicate, Search, SelectAll } from '@sim/emcn/icons'
|
||||
import { Scissors } from 'lucide-react'
|
||||
|
||||
interface EditorContextMenuProps {
|
||||
isOpen: boolean
|
||||
position: { x: number; y: number }
|
||||
onClose: () => void
|
||||
hasSelection: boolean
|
||||
canEdit: boolean
|
||||
onCut: () => void
|
||||
onCopy: () => void
|
||||
onCopyAll: () => void
|
||||
onPaste: () => void
|
||||
onSelectAll: () => void
|
||||
onFind: () => void
|
||||
}
|
||||
|
||||
export function EditorContextMenu({
|
||||
isOpen,
|
||||
position,
|
||||
onClose,
|
||||
hasSelection,
|
||||
canEdit,
|
||||
onCut,
|
||||
onCopy,
|
||||
onCopyAll,
|
||||
onPaste,
|
||||
onSelectAll,
|
||||
onFind,
|
||||
}: EditorContextMenuProps) {
|
||||
return (
|
||||
<DropdownMenu open={isOpen} onOpenChange={(open) => !open && onClose()} modal={false}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
left: `${position.x}px`,
|
||||
top: `${position.y}px`,
|
||||
width: '1px',
|
||||
height: '1px',
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
tabIndex={-1}
|
||||
aria-hidden
|
||||
/>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align='start'
|
||||
side='bottom'
|
||||
sideOffset={2}
|
||||
onCloseAutoFocus={(e) => e.preventDefault()}
|
||||
>
|
||||
{canEdit && (
|
||||
<DropdownMenuItem disabled={!hasSelection} onSelect={onCut}>
|
||||
<Scissors />
|
||||
Cut
|
||||
<DropdownMenuShortcut>⌘X</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem disabled={!hasSelection} onSelect={onCopy}>
|
||||
<Duplicate />
|
||||
Copy
|
||||
<DropdownMenuShortcut>⌘C</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={onCopyAll}>
|
||||
<Duplicate />
|
||||
Copy all
|
||||
</DropdownMenuItem>
|
||||
{canEdit && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onSelect={onPaste}>
|
||||
<Clipboard />
|
||||
Paste
|
||||
<DropdownMenuShortcut>⌘V</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onSelect={onSelectAll}>
|
||||
<SelectAll />
|
||||
Select all
|
||||
<DropdownMenuShortcut>⌘A</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={onFind}>
|
||||
<Search />
|
||||
Find
|
||||
<DropdownMenuShortcut>⌘F</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
+233
@@ -0,0 +1,233 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('@/lib/uploads/utils/validation', () => ({
|
||||
SUPPORTED_CODE_EXTENSIONS: ['js', 'ts', 'py', 'go', 'rs', 'sh', 'sql'],
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/uploads/utils/file-utils', () => ({
|
||||
getFileExtension: (filename: string): string => {
|
||||
const lastDot = filename.lastIndexOf('.')
|
||||
return lastDot !== -1 ? filename.slice(lastDot + 1).toLowerCase() : ''
|
||||
},
|
||||
}))
|
||||
|
||||
import { resolveFileCategory } from './file-category'
|
||||
|
||||
describe('resolveFileCategory — MIME type routing', () => {
|
||||
describe('text-editable', () => {
|
||||
it.each([
|
||||
'text/plain',
|
||||
'text/markdown',
|
||||
'application/json',
|
||||
'application/x-yaml',
|
||||
'text/csv',
|
||||
'text/html',
|
||||
'text/xml',
|
||||
'application/xml',
|
||||
'text/css',
|
||||
'text/javascript',
|
||||
'application/javascript',
|
||||
'application/typescript',
|
||||
'application/toml',
|
||||
'text/x-python',
|
||||
'text/x-sh',
|
||||
'text/x-sql',
|
||||
'image/svg+xml',
|
||||
'text/x-mermaid',
|
||||
])('%s → text-editable', (mime) => {
|
||||
expect(resolveFileCategory(mime, 'file.txt')).toBe('text-editable')
|
||||
})
|
||||
})
|
||||
|
||||
describe('iframe-previewable (PDF)', () => {
|
||||
it('application/pdf → iframe-previewable', () => {
|
||||
expect(resolveFileCategory('application/pdf', 'doc.pdf')).toBe('iframe-previewable')
|
||||
})
|
||||
|
||||
it('text/x-pdflibjs → iframe-previewable', () => {
|
||||
expect(resolveFileCategory('text/x-pdflibjs', 'generated.pdf')).toBe('iframe-previewable')
|
||||
})
|
||||
})
|
||||
|
||||
describe('image-previewable', () => {
|
||||
it.each(['image/png', 'image/jpeg', 'image/gif', 'image/webp'])(
|
||||
'%s → image-previewable',
|
||||
(mime) => {
|
||||
expect(resolveFileCategory(mime, 'img.png')).toBe('image-previewable')
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
describe('audio-previewable', () => {
|
||||
it.each([
|
||||
'audio/mpeg',
|
||||
'audio/mp4',
|
||||
'audio/wav',
|
||||
'audio/webm',
|
||||
'audio/ogg',
|
||||
'audio/flac',
|
||||
'audio/aac',
|
||||
'audio/opus',
|
||||
'audio/x-m4a',
|
||||
])('%s → audio-previewable', (mime) => {
|
||||
expect(resolveFileCategory(mime, 'audio.mp3')).toBe('audio-previewable')
|
||||
})
|
||||
})
|
||||
|
||||
describe('video-previewable', () => {
|
||||
it.each(['video/mp4', 'video/quicktime', 'video/x-msvideo', 'video/x-matroska', 'video/webm'])(
|
||||
'%s → video-previewable',
|
||||
(mime) => {
|
||||
expect(resolveFileCategory(mime, 'video.mp4')).toBe('video-previewable')
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
describe('docx-previewable', () => {
|
||||
it('application/vnd.openxmlformats-officedocument.wordprocessingml.document → docx-previewable', () => {
|
||||
expect(
|
||||
resolveFileCategory(
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
'doc.docx'
|
||||
)
|
||||
).toBe('docx-previewable')
|
||||
})
|
||||
|
||||
it('text/x-docxjs → docx-previewable', () => {
|
||||
expect(resolveFileCategory('text/x-docxjs', 'doc.docx')).toBe('docx-previewable')
|
||||
})
|
||||
})
|
||||
|
||||
describe('pptx-previewable', () => {
|
||||
it('application/vnd.openxmlformats-officedocument.presentationml.presentation → pptx-previewable', () => {
|
||||
expect(
|
||||
resolveFileCategory(
|
||||
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
||||
'deck.pptx'
|
||||
)
|
||||
).toBe('pptx-previewable')
|
||||
})
|
||||
|
||||
it('text/x-pptxgenjs → pptx-previewable', () => {
|
||||
expect(resolveFileCategory('text/x-pptxgenjs', 'deck.pptx')).toBe('pptx-previewable')
|
||||
})
|
||||
})
|
||||
|
||||
describe('xlsx-previewable', () => {
|
||||
it('application/vnd.openxmlformats-officedocument.spreadsheetml.sheet → xlsx-previewable', () => {
|
||||
expect(
|
||||
resolveFileCategory(
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
'data.xlsx'
|
||||
)
|
||||
).toBe('xlsx-previewable')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveFileCategory — extension fallback', () => {
|
||||
describe('text-editable extensions', () => {
|
||||
it.each(['md', 'txt', 'json', 'yaml', 'yml', 'csv', 'html', 'htm', 'svg', 'mmd'])(
|
||||
'.%s → text-editable',
|
||||
(ext) => {
|
||||
expect(resolveFileCategory(null, `file.${ext}`)).toBe('text-editable')
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
describe('code extensions from SUPPORTED_CODE_EXTENSIONS', () => {
|
||||
it.each(['js', 'ts', 'py', 'go', 'rs', 'sh', 'sql'])('.%s → text-editable', (ext) => {
|
||||
expect(resolveFileCategory(null, `file.${ext}`)).toBe('text-editable')
|
||||
})
|
||||
})
|
||||
|
||||
describe('pdf extension', () => {
|
||||
it('.pdf → iframe-previewable', () => {
|
||||
expect(resolveFileCategory(null, 'document.pdf')).toBe('iframe-previewable')
|
||||
})
|
||||
})
|
||||
|
||||
describe('image extensions', () => {
|
||||
it.each(['png', 'jpg', 'jpeg', 'gif', 'webp'])('.%s → image-previewable', (ext) => {
|
||||
expect(resolveFileCategory(null, `image.${ext}`)).toBe('image-previewable')
|
||||
})
|
||||
})
|
||||
|
||||
describe('audio extensions', () => {
|
||||
it.each(['mp3', 'm4a', 'wav', 'ogg', 'flac', 'aac', 'opus'])(
|
||||
'.%s → audio-previewable',
|
||||
(ext) => {
|
||||
expect(resolveFileCategory(null, `audio.${ext}`)).toBe('audio-previewable')
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
describe('video extensions', () => {
|
||||
it.each(['mp4', 'mov', 'avi', 'mkv', 'webm'])('.%s → video-previewable', (ext) => {
|
||||
expect(resolveFileCategory(null, `video.${ext}`)).toBe('video-previewable')
|
||||
})
|
||||
})
|
||||
|
||||
describe('docx extension', () => {
|
||||
it('.docx → docx-previewable', () => {
|
||||
expect(resolveFileCategory(null, 'doc.docx')).toBe('docx-previewable')
|
||||
})
|
||||
})
|
||||
|
||||
describe('pptx extension', () => {
|
||||
it('.pptx → pptx-previewable', () => {
|
||||
expect(resolveFileCategory(null, 'deck.pptx')).toBe('pptx-previewable')
|
||||
})
|
||||
})
|
||||
|
||||
describe('xlsx extension', () => {
|
||||
it('.xlsx → xlsx-previewable', () => {
|
||||
expect(resolveFileCategory(null, 'data.xlsx')).toBe('xlsx-previewable')
|
||||
})
|
||||
})
|
||||
|
||||
describe('unsupported', () => {
|
||||
it('unknown extension → unsupported', () => {
|
||||
expect(resolveFileCategory(null, 'file.xyz')).toBe('unsupported')
|
||||
})
|
||||
|
||||
it('unknown mime with unknown extension → unsupported', () => {
|
||||
expect(resolveFileCategory('application/octet-stream', 'file.bin')).toBe('unsupported')
|
||||
})
|
||||
|
||||
it('no extension, no mime → unsupported', () => {
|
||||
expect(resolveFileCategory(null, 'LICENSE')).toBe('unsupported')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveFileCategory — MIME priority', () => {
|
||||
it('text/plain MIME + .pdf extension → text-editable (MIME wins)', () => {
|
||||
expect(resolveFileCategory('text/plain', 'notes.pdf')).toBe('text-editable')
|
||||
})
|
||||
|
||||
it('application/pdf MIME + .txt extension → iframe-previewable (MIME wins)', () => {
|
||||
expect(resolveFileCategory('application/pdf', 'disguised.txt')).toBe('iframe-previewable')
|
||||
})
|
||||
|
||||
it('null MIME falls through to extension routing', () => {
|
||||
expect(resolveFileCategory(null, 'data.xlsx')).toBe('xlsx-previewable')
|
||||
})
|
||||
|
||||
it('unknown MIME falls through to extension routing', () => {
|
||||
expect(resolveFileCategory('application/octet-stream', 'data.xlsx')).toBe('xlsx-previewable')
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveFileCategory — extension case', () => {
|
||||
it('recognises uppercase extension via extension lookup (getFileExtension lowercases)', () => {
|
||||
expect(resolveFileCategory(null, 'README.MD')).toBe('text-editable')
|
||||
})
|
||||
|
||||
it('handles mixed-case correctly for json', () => {
|
||||
expect(resolveFileCategory(null, 'config.JSON')).toBe('text-editable')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,122 @@
|
||||
import { getFileExtension } from '@/lib/uploads/utils/file-utils'
|
||||
import { SUPPORTED_CODE_EXTENSIONS } from '@/lib/uploads/utils/validation'
|
||||
|
||||
const TEXT_EDITABLE_MIME_TYPES = new Set([
|
||||
'text/markdown',
|
||||
'text/plain',
|
||||
'application/json',
|
||||
'application/x-yaml',
|
||||
'text/csv',
|
||||
'text/html',
|
||||
'text/xml',
|
||||
'application/xml',
|
||||
'text/css',
|
||||
'text/javascript',
|
||||
'application/javascript',
|
||||
'application/typescript',
|
||||
'application/toml',
|
||||
'text/x-python',
|
||||
'text/x-sh',
|
||||
'text/x-sql',
|
||||
'image/svg+xml',
|
||||
'text/x-mermaid',
|
||||
])
|
||||
|
||||
const TEXT_EDITABLE_EXTENSIONS = new Set([
|
||||
'md',
|
||||
'txt',
|
||||
'json',
|
||||
'yaml',
|
||||
'yml',
|
||||
'csv',
|
||||
'html',
|
||||
'htm',
|
||||
'svg',
|
||||
'mmd',
|
||||
...SUPPORTED_CODE_EXTENSIONS,
|
||||
])
|
||||
|
||||
const IFRAME_PREVIEWABLE_MIME_TYPES = new Set([
|
||||
'application/pdf',
|
||||
'text/x-pdflibjs',
|
||||
'text/x-python-pdf',
|
||||
])
|
||||
const IFRAME_PREVIEWABLE_EXTENSIONS = new Set(['pdf'])
|
||||
|
||||
const IMAGE_PREVIEWABLE_MIME_TYPES = new Set(['image/png', 'image/jpeg', 'image/gif', 'image/webp'])
|
||||
const IMAGE_PREVIEWABLE_EXTENSIONS = new Set(['png', 'jpg', 'jpeg', 'gif', 'webp'])
|
||||
|
||||
const AUDIO_PREVIEWABLE_MIME_TYPES = new Set([
|
||||
'audio/mpeg',
|
||||
'audio/mp4',
|
||||
'audio/wav',
|
||||
'audio/webm',
|
||||
'audio/ogg',
|
||||
'audio/flac',
|
||||
'audio/aac',
|
||||
'audio/opus',
|
||||
'audio/x-m4a',
|
||||
])
|
||||
const AUDIO_PREVIEWABLE_EXTENSIONS = new Set(['mp3', 'm4a', 'wav', 'ogg', 'flac', 'aac', 'opus'])
|
||||
|
||||
const VIDEO_PREVIEWABLE_MIME_TYPES = new Set([
|
||||
'video/mp4',
|
||||
'video/quicktime',
|
||||
'video/x-msvideo',
|
||||
'video/x-matroska',
|
||||
'video/webm',
|
||||
])
|
||||
const VIDEO_PREVIEWABLE_EXTENSIONS = new Set(['mp4', 'mov', 'avi', 'mkv', 'webm'])
|
||||
|
||||
const PPTX_PREVIEWABLE_MIME_TYPES = new Set([
|
||||
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
||||
'text/x-pptxgenjs',
|
||||
])
|
||||
const PPTX_PREVIEWABLE_EXTENSIONS = new Set(['pptx'])
|
||||
|
||||
const DOCX_PREVIEWABLE_MIME_TYPES = new Set([
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
'text/x-docxjs',
|
||||
])
|
||||
const DOCX_PREVIEWABLE_EXTENSIONS = new Set(['docx'])
|
||||
|
||||
const XLSX_PREVIEWABLE_MIME_TYPES = new Set([
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
'text/x-python-xlsx',
|
||||
])
|
||||
const XLSX_PREVIEWABLE_EXTENSIONS = new Set(['xlsx'])
|
||||
|
||||
export type FileCategory =
|
||||
| 'text-editable'
|
||||
| 'iframe-previewable'
|
||||
| 'image-previewable'
|
||||
| 'audio-previewable'
|
||||
| 'video-previewable'
|
||||
| 'pptx-previewable'
|
||||
| 'docx-previewable'
|
||||
| 'xlsx-previewable'
|
||||
| 'unsupported'
|
||||
|
||||
export function resolveFileCategory(mimeType: string | null, filename: string): FileCategory {
|
||||
if (mimeType && TEXT_EDITABLE_MIME_TYPES.has(mimeType)) return 'text-editable'
|
||||
if (mimeType && IFRAME_PREVIEWABLE_MIME_TYPES.has(mimeType)) return 'iframe-previewable'
|
||||
if (mimeType && IMAGE_PREVIEWABLE_MIME_TYPES.has(mimeType)) return 'image-previewable'
|
||||
if (mimeType && AUDIO_PREVIEWABLE_MIME_TYPES.has(mimeType)) return 'audio-previewable'
|
||||
if (mimeType && VIDEO_PREVIEWABLE_MIME_TYPES.has(mimeType)) return 'video-previewable'
|
||||
if (mimeType && DOCX_PREVIEWABLE_MIME_TYPES.has(mimeType)) return 'docx-previewable'
|
||||
if (mimeType && PPTX_PREVIEWABLE_MIME_TYPES.has(mimeType)) return 'pptx-previewable'
|
||||
if (mimeType && XLSX_PREVIEWABLE_MIME_TYPES.has(mimeType)) return 'xlsx-previewable'
|
||||
|
||||
const ext = getFileExtension(filename)
|
||||
const nameKey = ext || filename.toLowerCase()
|
||||
if (TEXT_EDITABLE_EXTENSIONS.has(nameKey)) return 'text-editable'
|
||||
if (IFRAME_PREVIEWABLE_EXTENSIONS.has(ext)) return 'iframe-previewable'
|
||||
if (IMAGE_PREVIEWABLE_EXTENSIONS.has(ext)) return 'image-previewable'
|
||||
if (AUDIO_PREVIEWABLE_EXTENSIONS.has(ext)) return 'audio-previewable'
|
||||
if (VIDEO_PREVIEWABLE_EXTENSIONS.has(ext)) return 'video-previewable'
|
||||
if (DOCX_PREVIEWABLE_EXTENSIONS.has(ext)) return 'docx-previewable'
|
||||
if (PPTX_PREVIEWABLE_EXTENSIONS.has(ext)) return 'pptx-previewable'
|
||||
if (XLSX_PREVIEWABLE_EXTENSIONS.has(ext)) return 'xlsx-previewable'
|
||||
|
||||
return 'unsupported'
|
||||
}
|
||||
@@ -0,0 +1,421 @@
|
||||
'use client'
|
||||
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Music } from 'lucide-react'
|
||||
import dynamic from 'next/dynamic'
|
||||
import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace'
|
||||
import { getFileExtension } from '@/lib/uploads/utils/file-utils'
|
||||
import { useWorkspaceFileBinary, useWorkspaceFileContent } from '@/hooks/queries/workspace-files'
|
||||
import {
|
||||
createWorkspaceFileContentSource,
|
||||
type FileContentSource,
|
||||
FileContentSourceProvider,
|
||||
} from '@/hooks/use-file-content-source'
|
||||
import { CsvTablePreview } from './csv-table-preview'
|
||||
import { DocxPreview } from './docx-preview'
|
||||
import { resolveFileCategory } from './file-category'
|
||||
import { ImagePreview } from './image-preview'
|
||||
import type { PdfDocumentSource } from './pdf-viewer'
|
||||
import { PptxPreview } from './pptx-preview'
|
||||
import { PreviewPanel, resolvePreviewType } from './preview-panel'
|
||||
import {
|
||||
PREVIEW_LOADING_OVERLAY,
|
||||
PreviewError,
|
||||
PreviewErrorBoundary,
|
||||
PreviewLoadingFrame,
|
||||
resolvePreviewError,
|
||||
} from './preview-shared'
|
||||
import { TextEditor } from './text-editor'
|
||||
import { useDocPreviewBinary } from './use-doc-preview-binary'
|
||||
import { XlsxPreview } from './xlsx-preview'
|
||||
|
||||
const PdfViewerCore = dynamic(() => import('./pdf-viewer').then((m) => m.PdfViewerCore), {
|
||||
ssr: false,
|
||||
})
|
||||
|
||||
const RichMarkdownEditor = dynamic(
|
||||
() => import('./rich-markdown-editor/rich-markdown-editor').then((m) => m.RichMarkdownEditor),
|
||||
{ ssr: false, loading: () => <PreviewLoadingFrame className='flex flex-1 flex-col' /> }
|
||||
)
|
||||
|
||||
/**
|
||||
* CSVs at or below this size load fully into the editor (editable, with an inline preview).
|
||||
* Larger CSVs would OOM the browser on `response.text()`, so they render a read-only,
|
||||
* server-streamed preview of the first rows instead (see {@link CsvTablePreview}).
|
||||
*/
|
||||
const CSV_INLINE_EDIT_MAX_BYTES = 5 * 1024 * 1024
|
||||
|
||||
export function isTextEditable(file: { type: string; name: string }): boolean {
|
||||
return resolveFileCategory(file.type, file.name) === 'text-editable'
|
||||
}
|
||||
|
||||
export function isPreviewable(file: { type: string; name: string }): boolean {
|
||||
return resolvePreviewType(file.type, file.name) !== null
|
||||
}
|
||||
|
||||
/**
|
||||
* Markdown files render in the inline rich editor ({@link RichMarkdownEditor}) rather than
|
||||
* the raw Monaco editor. Toolbars use this to hide the raw/split/preview mode controls,
|
||||
* which don't apply to the single-surface editor.
|
||||
*/
|
||||
export function isMarkdownFile(file: { type: string; name: string }): boolean {
|
||||
return resolvePreviewType(file.type, file.name) === 'markdown'
|
||||
}
|
||||
|
||||
/**
|
||||
* A CSV larger than {@link CSV_INLINE_EDIT_MAX_BYTES} is shown as a streamed, read-only preview —
|
||||
* the editor would OOM loading the whole file. The viewer renders {@link CsvTablePreview} for it,
|
||||
* and toolbars use this to hide the edit/split/save controls (there is no editor to switch to).
|
||||
*/
|
||||
export function isCsvStreamOnly(file: {
|
||||
type: string | null
|
||||
name: string
|
||||
size?: number | null
|
||||
}): boolean {
|
||||
return (
|
||||
resolvePreviewType(file.type, file.name) === 'csv' &&
|
||||
(file.size ?? 0) > CSV_INLINE_EDIT_MAX_BYTES
|
||||
)
|
||||
}
|
||||
|
||||
export type PreviewMode = 'editor' | 'split' | 'preview'
|
||||
|
||||
interface FileViewerProps {
|
||||
file: WorkspaceFileRecord
|
||||
workspaceId: string
|
||||
/**
|
||||
* Content source for this view. Defaults to a workspace-scoped source derived from `workspaceId`;
|
||||
* the public share page passes a token-scoped source. Provided to descendants (renderers, embedded
|
||||
* images) via {@link FileContentSourceProvider}.
|
||||
*/
|
||||
contentSource?: FileContentSource
|
||||
canEdit: boolean
|
||||
/**
|
||||
* Render a read-only preview with no editing affordances. Text files render
|
||||
* through {@link PreviewPanel} (or a plain `<pre>`) instead of the editable
|
||||
* {@link TextEditor}. Used by the public share page.
|
||||
*/
|
||||
readOnly?: boolean
|
||||
previewMode?: PreviewMode
|
||||
autoFocus?: boolean
|
||||
onDirtyChange?: (isDirty: boolean) => void
|
||||
onSaveStatusChange?: (
|
||||
status: 'idle' | 'saving' | 'saved' | 'error',
|
||||
retry?: () => Promise<void>
|
||||
) => void
|
||||
saveRef?: React.MutableRefObject<(() => Promise<void>) | null>
|
||||
discardRef?: React.MutableRefObject<(() => void) | null>
|
||||
streamingContent?: string
|
||||
isAgentEditing?: boolean
|
||||
streamIsIncremental?: boolean
|
||||
disableStreamingAutoScroll?: boolean
|
||||
previewContextKey?: string
|
||||
}
|
||||
|
||||
export function FileViewer(props: FileViewerProps) {
|
||||
const { contentSource, workspaceId } = props
|
||||
const source = useMemo(
|
||||
() => contentSource ?? createWorkspaceFileContentSource(workspaceId),
|
||||
[contentSource, workspaceId]
|
||||
)
|
||||
return (
|
||||
<FileContentSourceProvider value={source}>
|
||||
<FileViewerContent {...props} />
|
||||
</FileContentSourceProvider>
|
||||
)
|
||||
}
|
||||
|
||||
function FileViewerContent({
|
||||
file,
|
||||
workspaceId,
|
||||
canEdit,
|
||||
readOnly = false,
|
||||
previewMode,
|
||||
autoFocus,
|
||||
onDirtyChange,
|
||||
onSaveStatusChange,
|
||||
saveRef,
|
||||
discardRef,
|
||||
streamingContent,
|
||||
isAgentEditing,
|
||||
streamIsIncremental,
|
||||
disableStreamingAutoScroll = false,
|
||||
previewContextKey,
|
||||
}: FileViewerProps) {
|
||||
const category = resolveFileCategory(file.type, file.name)
|
||||
|
||||
if (category === 'text-editable') {
|
||||
if (readOnly) {
|
||||
// ReadOnlyTextPreview loads the whole file as text; a large CSV would OOM the
|
||||
// browser. CsvTablePreview's streamed fallback is workspace-only, so on the
|
||||
// read-only public path a large CSV is download-only.
|
||||
if (isCsvStreamOnly(file)) {
|
||||
return <UnsupportedPreview file={file} />
|
||||
}
|
||||
// Markdown renders through the inline rich editor (non-editable) so the public share
|
||||
// surface matches the in-app reading experience; canEdit={false} disables autosave,
|
||||
// the bubble menu, and every other editing affordance.
|
||||
if (isMarkdownFile(file)) {
|
||||
return (
|
||||
<RichMarkdownEditor key={file.id} file={file} workspaceId={workspaceId} canEdit={false} />
|
||||
)
|
||||
}
|
||||
return <ReadOnlyTextPreview file={file} workspaceId={workspaceId} />
|
||||
}
|
||||
// A large CSV can't be loaded whole into the editor (the browser OOMs on the full text).
|
||||
// Render a streamed, read-only preview of the first rows + an "Import as a table" path instead.
|
||||
if (isCsvStreamOnly(file)) {
|
||||
return <CsvTablePreview key={file.id} file={file} workspaceId={workspaceId} />
|
||||
}
|
||||
|
||||
if (isMarkdownFile(file)) {
|
||||
return (
|
||||
<RichMarkdownEditor
|
||||
key={file.id}
|
||||
file={file}
|
||||
workspaceId={workspaceId}
|
||||
canEdit={canEdit}
|
||||
autoFocus={autoFocus}
|
||||
onDirtyChange={onDirtyChange}
|
||||
onSaveStatusChange={onSaveStatusChange}
|
||||
saveRef={saveRef}
|
||||
discardRef={discardRef}
|
||||
streamingContent={streamingContent}
|
||||
isAgentEditing={isAgentEditing}
|
||||
streamIsIncremental={streamIsIncremental}
|
||||
disableStreamingAutoScroll={disableStreamingAutoScroll}
|
||||
previewContextKey={previewContextKey}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<TextEditor
|
||||
file={file}
|
||||
workspaceId={workspaceId}
|
||||
canEdit={canEdit}
|
||||
previewMode={previewMode ?? 'editor'}
|
||||
autoFocus={autoFocus}
|
||||
onDirtyChange={onDirtyChange}
|
||||
onSaveStatusChange={onSaveStatusChange}
|
||||
saveRef={saveRef}
|
||||
discardRef={discardRef}
|
||||
streamingContent={streamingContent}
|
||||
isAgentEditing={isAgentEditing}
|
||||
disableStreamingAutoScroll={disableStreamingAutoScroll}
|
||||
previewContextKey={previewContextKey}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (category === 'iframe-previewable') {
|
||||
return <IframePreview key={file.id} file={file} workspaceId={workspaceId} />
|
||||
}
|
||||
|
||||
if (category === 'image-previewable') {
|
||||
return <ImagePreview key={file.key} file={file} />
|
||||
}
|
||||
|
||||
if (category === 'audio-previewable') {
|
||||
return <MediaPreview key={file.id} file={file} workspaceId={workspaceId} kind='audio' />
|
||||
}
|
||||
|
||||
if (category === 'video-previewable') {
|
||||
return <MediaPreview key={file.id} file={file} workspaceId={workspaceId} kind='video' />
|
||||
}
|
||||
|
||||
if (category === 'docx-previewable') {
|
||||
return <DocxPreview key={file.id} file={file} workspaceId={workspaceId} />
|
||||
}
|
||||
|
||||
if (category === 'pptx-previewable') {
|
||||
return <PptxPreview key={file.id} file={file} workspaceId={workspaceId} />
|
||||
}
|
||||
|
||||
if (category === 'xlsx-previewable') {
|
||||
return <XlsxPreview key={file.id} file={file} workspaceId={workspaceId} />
|
||||
}
|
||||
|
||||
return <UnsupportedPreview file={file} />
|
||||
}
|
||||
|
||||
/**
|
||||
* Read-only text/markdown/code preview. Renders rich types (markdown, csv, svg,
|
||||
* mermaid, html) through {@link PreviewPanel} and plain text/code in a `<pre>`.
|
||||
* Fetches content through the active content source, so it works for both
|
||||
* workspace files and public share links.
|
||||
*/
|
||||
const ReadOnlyTextPreview = memo(function ReadOnlyTextPreview({
|
||||
file,
|
||||
workspaceId,
|
||||
}: {
|
||||
file: WorkspaceFileRecord
|
||||
workspaceId: string
|
||||
}) {
|
||||
const {
|
||||
data: content,
|
||||
isLoading,
|
||||
error,
|
||||
} = useWorkspaceFileContent(workspaceId, file.id, file.key)
|
||||
|
||||
const resolvedError = resolvePreviewError((error as Error | null) ?? null, null)
|
||||
if (resolvedError) return <PreviewError label='file' error={resolvedError} />
|
||||
if (isLoading || content == null) return <PreviewLoadingFrame className='h-full' tone='surface' />
|
||||
|
||||
if (resolvePreviewType(file.type, file.name)) {
|
||||
return (
|
||||
<div className='h-full min-h-0 w-full overflow-auto'>
|
||||
<PreviewPanel
|
||||
content={content}
|
||||
mimeType={file.type}
|
||||
filename={file.name}
|
||||
workspaceId={workspaceId}
|
||||
fileKey={file.key}
|
||||
readOnly
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='h-full min-h-0 w-full overflow-auto bg-[var(--surface-1)] p-4'>
|
||||
<pre className='whitespace-pre-wrap break-words font-mono text-[13px] text-[var(--text-body)]'>
|
||||
{content}
|
||||
</pre>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
const IframePreview = memo(function IframePreview({
|
||||
file,
|
||||
workspaceId,
|
||||
}: {
|
||||
file: WorkspaceFileRecord
|
||||
workspaceId: string
|
||||
}) {
|
||||
const preview = useDocPreviewBinary(workspaceId, file)
|
||||
|
||||
const bufferSource = useMemo<PdfDocumentSource | null>(
|
||||
() => (preview.data ? { kind: 'buffer', buffer: preview.data } : null),
|
||||
[preview.data]
|
||||
)
|
||||
|
||||
const error = resolvePreviewError(preview.error, null)
|
||||
if (error) return <PreviewError label='PDF' error={error} />
|
||||
|
||||
if (!bufferSource) {
|
||||
return <div className='relative flex flex-1 overflow-hidden'>{PREVIEW_LOADING_OVERLAY}</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<PreviewErrorBoundary key={`${file.id}:${preview.dataUpdatedAt}`} label='PDF'>
|
||||
<PdfViewerCore source={bufferSource} filename={file.name} />
|
||||
</PreviewErrorBoundary>
|
||||
)
|
||||
})
|
||||
|
||||
function useBlobUrl(workspaceId: string, fileId: string, fileKey: string) {
|
||||
const { data: fileData, isLoading, error } = useWorkspaceFileBinary(workspaceId, fileId, fileKey)
|
||||
const [blobUrl, setBlobUrl] = useState<string | null>(null)
|
||||
const blobUrlRef = useRef<string | null>(null)
|
||||
|
||||
const replaceBlobUrl = useCallback((nextUrl: string | null) => {
|
||||
const previousUrl = blobUrlRef.current
|
||||
blobUrlRef.current = nextUrl
|
||||
setBlobUrl(nextUrl)
|
||||
if (previousUrl && previousUrl !== nextUrl) URL.revokeObjectURL(previousUrl)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (blobUrlRef.current) {
|
||||
URL.revokeObjectURL(blobUrlRef.current)
|
||||
blobUrlRef.current = null
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
return { fileData, isLoading, error, blobUrl, replaceBlobUrl }
|
||||
}
|
||||
|
||||
const MEDIA_FALLBACK_MIME = { audio: 'audio/mpeg', video: 'video/mp4' } as const
|
||||
|
||||
/**
|
||||
* Shared blob-backed preview for audio and video files — the fetch, blob-URL
|
||||
* lifecycle, and error/loading handling are identical; only the rendered
|
||||
* player differs.
|
||||
*/
|
||||
const MediaPreview = memo(function MediaPreview({
|
||||
file,
|
||||
workspaceId,
|
||||
kind,
|
||||
}: {
|
||||
file: WorkspaceFileRecord
|
||||
workspaceId: string
|
||||
kind: 'audio' | 'video'
|
||||
}) {
|
||||
const {
|
||||
fileData,
|
||||
isLoading,
|
||||
error: fetchError,
|
||||
blobUrl,
|
||||
replaceBlobUrl,
|
||||
} = useBlobUrl(workspaceId, file.id, file.key)
|
||||
|
||||
useEffect(() => {
|
||||
if (!fileData) return
|
||||
replaceBlobUrl(
|
||||
URL.createObjectURL(new Blob([fileData], { type: file.type || MEDIA_FALLBACK_MIME[kind] }))
|
||||
)
|
||||
}, [file.type, fileData, kind, replaceBlobUrl])
|
||||
|
||||
const error = blobUrl !== null ? null : resolvePreviewError(fetchError, null)
|
||||
if (error) return <PreviewError label={kind} error={error} />
|
||||
|
||||
if (isLoading && !blobUrl) {
|
||||
return <PreviewLoadingFrame className='h-full' tone='surface' />
|
||||
}
|
||||
|
||||
if (kind === 'audio') {
|
||||
return (
|
||||
<div className='flex h-full flex-col items-center justify-center gap-4 bg-[var(--surface-1)] p-8'>
|
||||
<div className='flex flex-col items-center gap-2 text-center'>
|
||||
<Music className='size-[32px] text-[var(--text-muted)]' strokeWidth={1.5} />
|
||||
<p className='font-medium text-[14px] text-[var(--text-primary)]'>{file.name}</p>
|
||||
</div>
|
||||
{blobUrl && (
|
||||
// biome-ignore lint/a11y/useMediaCaption: audio from workspace files
|
||||
<audio src={blobUrl} controls className='w-full max-w-[480px]' />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='flex h-full items-center justify-center bg-[var(--surface-1)]'>
|
||||
{blobUrl && (
|
||||
// biome-ignore lint/a11y/useMediaCaption: video from workspace files
|
||||
<video src={blobUrl} controls className='max-h-full max-w-full' />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
const UnsupportedPreview = memo(function UnsupportedPreview({
|
||||
file,
|
||||
}: {
|
||||
file: WorkspaceFileRecord
|
||||
}) {
|
||||
const ext = getFileExtension(file.name)
|
||||
|
||||
return (
|
||||
<div className='flex flex-1 flex-col items-center justify-center gap-[8px]'>
|
||||
<p className='font-medium text-[14px] text-[var(--text-primary)]'>
|
||||
Preview not available{ext ? ` for .${ext} files` : ' for this file'}
|
||||
</p>
|
||||
<p className='text-[13px] text-[var(--text-muted)]'>
|
||||
Use the download button to view this file
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,34 @@
|
||||
'use client'
|
||||
|
||||
import { memo, useState } from 'react'
|
||||
import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace'
|
||||
import { useFileContentSource } from '@/hooks/use-file-content-source'
|
||||
import { PREVIEW_LOADING_OVERLAY } from './preview-shared'
|
||||
import { ZoomablePreview } from './zoomable-preview'
|
||||
|
||||
export const ImagePreview = memo(function ImagePreview({ file }: { file: WorkspaceFileRecord }) {
|
||||
const source = useFileContentSource()
|
||||
const [hasSettled, setHasSettled] = useState(false)
|
||||
// Version the URL on updatedAt: overwrites keep the same storage key, so an unversioned
|
||||
// URL would resolve to a previously cached copy instead of the rewritten bytes.
|
||||
const serveUrl = source.buildUrl(file.key, {
|
||||
version: Number(new Date(file.updatedAt)) || file.size,
|
||||
})
|
||||
|
||||
return (
|
||||
<div className='relative flex min-h-0 flex-1 flex-col'>
|
||||
<ZoomablePreview className='flex flex-1' contentClassName='h-full w-full'>
|
||||
<img
|
||||
src={serveUrl}
|
||||
alt={file.name}
|
||||
className='max-h-full max-w-full select-none rounded-md object-contain'
|
||||
draggable={false}
|
||||
loading='eager'
|
||||
onLoad={() => setHasSettled(true)}
|
||||
onError={() => setHasSettled(true)}
|
||||
/>
|
||||
</ZoomablePreview>
|
||||
{!hasSettled && PREVIEW_LOADING_OVERLAY}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,10 @@
|
||||
export { resolveFileCategory } from './file-category'
|
||||
export type { PreviewMode } from './file-viewer'
|
||||
export {
|
||||
FileViewer,
|
||||
isCsvStreamOnly,
|
||||
isMarkdownFile,
|
||||
isPreviewable,
|
||||
isTextEditable,
|
||||
} from './file-viewer'
|
||||
export { PreviewPanel, RICH_PREVIEWABLE_EXTENSIONS, resolvePreviewType } from './preview-panel'
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { looksLikeMermaid } from './mermaid-diagram'
|
||||
|
||||
describe('looksLikeMermaid', () => {
|
||||
it('detects blocks whose first line opens with a diagram keyword', () => {
|
||||
expect(looksLikeMermaid('flowchart TD\n A --> B')).toBe(true)
|
||||
expect(looksLikeMermaid('graph LR\n A --> B')).toBe(true)
|
||||
expect(looksLikeMermaid('sequenceDiagram\n Alice->>Bob: Hi')).toBe(true)
|
||||
expect(looksLikeMermaid('pie title NETFLIX\n "A" : 90')).toBe(true)
|
||||
expect(looksLikeMermaid('stateDiagram-v2\n [*] --> S')).toBe(true)
|
||||
expect(looksLikeMermaid('gantt\n title A')).toBe(true)
|
||||
})
|
||||
|
||||
it('detects the remaining diagram openers', () => {
|
||||
expect(looksLikeMermaid('classDiagram\n Animal <|-- Duck')).toBe(true)
|
||||
expect(looksLikeMermaid('stateDiagram\n [*] --> S')).toBe(true)
|
||||
expect(looksLikeMermaid('erDiagram\n A ||--o{ B : has')).toBe(true)
|
||||
expect(looksLikeMermaid('journey\n title My day')).toBe(true)
|
||||
expect(looksLikeMermaid('quadrantChart\n title Reach')).toBe(true)
|
||||
expect(looksLikeMermaid('requirementDiagram')).toBe(true)
|
||||
expect(looksLikeMermaid('gitGraph')).toBe(true)
|
||||
expect(looksLikeMermaid('mindmap\n root')).toBe(true)
|
||||
expect(looksLikeMermaid('timeline\n title History')).toBe(true)
|
||||
expect(looksLikeMermaid('sankey-beta')).toBe(true)
|
||||
expect(looksLikeMermaid('xychart-beta')).toBe(true)
|
||||
expect(looksLikeMermaid('block-beta')).toBe(true)
|
||||
expect(looksLikeMermaid('packet-beta')).toBe(true)
|
||||
expect(looksLikeMermaid('kanban')).toBe(true)
|
||||
expect(looksLikeMermaid('architecture-beta')).toBe(true)
|
||||
expect(looksLikeMermaid('zenuml')).toBe(true)
|
||||
expect(looksLikeMermaid('C4Context\n title System')).toBe(true)
|
||||
expect(looksLikeMermaid('C4Container')).toBe(true)
|
||||
expect(looksLikeMermaid('C4Component')).toBe(true)
|
||||
expect(looksLikeMermaid('C4Dynamic')).toBe(true)
|
||||
expect(looksLikeMermaid('C4Deployment')).toBe(true)
|
||||
})
|
||||
|
||||
it('skips leading blank lines before the opener', () => {
|
||||
expect(looksLikeMermaid('\n\n flowchart TD\n A --> B')).toBe(true)
|
||||
expect(looksLikeMermaid('\n \n\t\nsequenceDiagram\n Alice->>Bob: Hi')).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects ordinary code that merely contains a keyword later', () => {
|
||||
expect(looksLikeMermaid('const graph = makeGraph()\nreturn graph')).toBe(false)
|
||||
expect(looksLikeMermaid('print("pie")')).toBe(false)
|
||||
expect(looksLikeMermaid('SELECT * FROM pies')).toBe(false)
|
||||
expect(looksLikeMermaid('')).toBe(false)
|
||||
expect(looksLikeMermaid('\n\n \n')).toBe(false)
|
||||
expect(looksLikeMermaid('# flowchart of the system')).toBe(false)
|
||||
expect(looksLikeMermaid(' // graph helpers')).toBe(false)
|
||||
})
|
||||
|
||||
it('requires a word boundary after the keyword', () => {
|
||||
expect(looksLikeMermaid('graphql query { user }')).toBe(false)
|
||||
expect(looksLikeMermaid('pieChart()')).toBe(false)
|
||||
expect(looksLikeMermaid('flowcharting()')).toBe(false)
|
||||
expect(looksLikeMermaid('ganttify')).toBe(false)
|
||||
expect(looksLikeMermaid('journeyman')).toBe(false)
|
||||
})
|
||||
})
|
||||
Binary file not shown.
@@ -0,0 +1,287 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* Must precede the react-pdf import: pdf.js calls the polyfilled APIs while
|
||||
* its module evaluates, which throws on Safari < 17.4 without them.
|
||||
*/
|
||||
import '@/lib/core/utils/browser-polyfills'
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { pdfjs, Document as ReactPdfDocument, Page as ReactPdfPage } from 'react-pdf'
|
||||
import 'react-pdf/dist/Page/TextLayer.css'
|
||||
import { PREVIEW_LOADING_OVERLAY } from '@/app/workspace/[workspaceId]/files/components/file-viewer/preview-shared'
|
||||
import { PreviewToolbar } from '@/app/workspace/[workspaceId]/files/components/file-viewer/preview-toolbar'
|
||||
import { bindPreviewWheelZoom } from '@/app/workspace/[workspaceId]/files/components/file-viewer/preview-wheel-zoom'
|
||||
|
||||
/**
|
||||
* The worker runs in its own context that browser-polyfills cannot reach, so
|
||||
* serve the legacy worker build, which bundles its own polyfills.
|
||||
*/
|
||||
pdfjs.GlobalWorkerOptions.workerSrc = new URL(
|
||||
'pdfjs-dist/legacy/build/pdf.worker.min.mjs',
|
||||
import.meta.url
|
||||
).href
|
||||
|
||||
const logger = createLogger('PdfViewer')
|
||||
|
||||
const PDF_ZOOM_MIN = 0.5
|
||||
const PDF_ZOOM_MAX = 3
|
||||
const PDF_ZOOM_DEFAULT = 1
|
||||
const PDF_ZOOM_STEP = 1.25
|
||||
const PDF_VIEWER_PADDING = 24
|
||||
const PDF_RESIZE_DEBOUNCE_MS = 150
|
||||
|
||||
export type PdfDocumentSource =
|
||||
| { kind: 'url'; url: string }
|
||||
| { kind: 'buffer'; buffer: ArrayBuffer }
|
||||
|
||||
interface PdfViewerCoreProps {
|
||||
source: PdfDocumentSource
|
||||
filename: string
|
||||
}
|
||||
|
||||
function PdfError({ error }: { error: string }) {
|
||||
return (
|
||||
<div className='flex flex-1 flex-col items-center justify-center gap-[8px]'>
|
||||
<p className='font-medium text-[14px] text-[var(--text-body)]'>Failed to preview PDF</p>
|
||||
<p className='text-[13px] text-[var(--text-muted)]'>{error}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const PdfViewerCore = memo(function PdfViewerCore({ source, filename }: PdfViewerCoreProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const paddingWrapperRef = useRef<HTMLDivElement>(null)
|
||||
const pagesWrapperRef = useRef<HTMLDivElement>(null)
|
||||
const pageRefs = useRef<(HTMLDivElement | null)[]>([])
|
||||
const pageWidthRef = useRef<number | undefined>(undefined)
|
||||
|
||||
const zoomRef = useRef(PDF_ZOOM_DEFAULT)
|
||||
|
||||
const [containerWidth, setContainerWidth] = useState(0)
|
||||
const [pageCount, setPageCount] = useState(0)
|
||||
const [isDocumentReady, setIsDocumentReady] = useState(false)
|
||||
const [displayZoom, setDisplayZoom] = useState(PDF_ZOOM_DEFAULT)
|
||||
const [currentPage, setCurrentPage] = useState(1)
|
||||
const [loadError, setLoadError] = useState<string | null>(null)
|
||||
|
||||
const sourceValue = source.kind === 'url' ? source.url : source.buffer
|
||||
/**
|
||||
* The buffer copy (`slice(0)`) is load-bearing: pdf.js transfers — and
|
||||
* detaches — the ArrayBuffer it receives to its worker, so handing over the
|
||||
* caller's buffer would leave it unusable on the next render or remount.
|
||||
*/
|
||||
const file = useMemo(
|
||||
() => (source.kind === 'url' ? source.url : { data: new Uint8Array(source.buffer.slice(0)) }),
|
||||
[sourceValue]
|
||||
)
|
||||
|
||||
/**
|
||||
* The first non-zero measurement applies immediately so the document renders
|
||||
* without delay (a hidden container reports zero width and must not consume
|
||||
* the immediate slot); subsequent ones (panel-divider drags) are debounced
|
||||
* because every pageWidth change makes pdf.js re-rasterise all page canvases
|
||||
* — per-tick updates during a drag would re-render the whole document
|
||||
* continuously.
|
||||
*/
|
||||
useEffect(() => {
|
||||
const container = containerRef.current
|
||||
if (!container) return
|
||||
let hasMeasured = false
|
||||
let debounce: ReturnType<typeof setTimeout> | undefined
|
||||
const observer = new ResizeObserver(([entry]) => {
|
||||
const { width } = entry.contentRect
|
||||
if (!hasMeasured) {
|
||||
if (width <= 0) return
|
||||
hasMeasured = true
|
||||
setContainerWidth(width)
|
||||
return
|
||||
}
|
||||
clearTimeout(debounce)
|
||||
debounce = setTimeout(() => setContainerWidth(width), PDF_RESIZE_DEBOUNCE_MS)
|
||||
})
|
||||
observer.observe(container)
|
||||
return () => {
|
||||
clearTimeout(debounce)
|
||||
observer.disconnect()
|
||||
}
|
||||
}, [])
|
||||
|
||||
/**
|
||||
* 100% zoom fits the page to the panel width (pdf.js re-renders the canvas
|
||||
* at the target width, so upscaling past the page's natural print size
|
||||
* stays crisp). Matches the DOCX preview's fit-to-width semantics.
|
||||
*/
|
||||
const pageWidth = containerWidth > 0 ? containerWidth - 2 * PDF_VIEWER_PADDING : undefined
|
||||
pageWidthRef.current = pageWidth
|
||||
|
||||
const applyZoomAt = useCallback((next: number, anchorX: number, anchorY: number) => {
|
||||
const container = containerRef.current
|
||||
const wrapper = pagesWrapperRef.current
|
||||
const padWrapper = paddingWrapperRef.current
|
||||
const pw = pageWidthRef.current
|
||||
if (!container || !wrapper) return
|
||||
const ratio = next / zoomRef.current
|
||||
wrapper.style.zoom = String(next)
|
||||
if (padWrapper && pw !== undefined) {
|
||||
padWrapper.style.minWidth = `${pw * next + 2 * PDF_VIEWER_PADDING}px`
|
||||
}
|
||||
// Padding is outside the zoom subtree, so offset the anchor by it before scaling.
|
||||
container.scrollLeft =
|
||||
(container.scrollLeft + anchorX - PDF_VIEWER_PADDING) * ratio + PDF_VIEWER_PADDING - anchorX
|
||||
container.scrollTop =
|
||||
(container.scrollTop + anchorY - PDF_VIEWER_PADDING) * ratio + PDF_VIEWER_PADDING - anchorY
|
||||
zoomRef.current = next
|
||||
setDisplayZoom(next)
|
||||
}, [])
|
||||
|
||||
const scrollToPage = (page: number) => {
|
||||
const container = containerRef.current
|
||||
if (container && zoomRef.current !== PDF_ZOOM_DEFAULT) {
|
||||
applyZoomAt(PDF_ZOOM_DEFAULT, container.clientWidth / 2, container.clientHeight / 2)
|
||||
}
|
||||
|
||||
const wrapper = pageRefs.current[page - 1]
|
||||
if (wrapper && container) {
|
||||
container.scrollTo({ top: wrapper.offsetTop - 16, behavior: 'smooth' })
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current
|
||||
if (!container || pageCount === 0) return
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
for (const entry of entries) {
|
||||
if (entry.isIntersecting) {
|
||||
const pageNum = Number((entry.target as HTMLElement).dataset.page)
|
||||
if (pageNum) setCurrentPage(pageNum)
|
||||
}
|
||||
}
|
||||
},
|
||||
{ root: container, threshold: 0.5 }
|
||||
)
|
||||
|
||||
for (const wrapper of pageRefs.current) {
|
||||
if (wrapper) observer.observe(wrapper)
|
||||
}
|
||||
|
||||
return () => observer.disconnect()
|
||||
}, [pageCount])
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current
|
||||
if (!container) return
|
||||
|
||||
return bindPreviewWheelZoom(container, (e) => {
|
||||
const next = Math.min(
|
||||
PDF_ZOOM_MAX,
|
||||
Math.max(PDF_ZOOM_MIN, zoomRef.current * (1 - e.deltaY * 0.005))
|
||||
)
|
||||
const rect = container.getBoundingClientRect()
|
||||
applyZoomAt(next, e.clientX - rect.left, e.clientY - rect.top)
|
||||
})
|
||||
}, [applyZoomAt])
|
||||
|
||||
return (
|
||||
<div className='flex flex-1 flex-col overflow-hidden'>
|
||||
{pageCount > 0 && !loadError && (
|
||||
<PreviewToolbar
|
||||
navigation={{
|
||||
current: currentPage,
|
||||
total: pageCount,
|
||||
label: 'page',
|
||||
onPrevious: () => {
|
||||
const prev = Math.max(1, currentPage - 1)
|
||||
setCurrentPage(prev)
|
||||
scrollToPage(prev)
|
||||
},
|
||||
onNext: () => {
|
||||
const next = Math.min(pageCount, currentPage + 1)
|
||||
setCurrentPage(next)
|
||||
scrollToPage(next)
|
||||
},
|
||||
}}
|
||||
zoom={{
|
||||
label: `${Math.round(displayZoom * 100)}%`,
|
||||
canZoomOut: displayZoom > PDF_ZOOM_MIN,
|
||||
canZoomIn: displayZoom < PDF_ZOOM_MAX,
|
||||
onZoomOut: () => {
|
||||
const c = containerRef.current
|
||||
applyZoomAt(
|
||||
Math.max(PDF_ZOOM_MIN, zoomRef.current / PDF_ZOOM_STEP),
|
||||
c ? c.clientWidth / 2 : 0,
|
||||
c ? c.clientHeight / 2 : 0
|
||||
)
|
||||
},
|
||||
onZoomIn: () => {
|
||||
const c = containerRef.current
|
||||
applyZoomAt(
|
||||
Math.min(PDF_ZOOM_MAX, zoomRef.current * PDF_ZOOM_STEP),
|
||||
c ? c.clientWidth / 2 : 0,
|
||||
c ? c.clientHeight / 2 : 0
|
||||
)
|
||||
},
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div
|
||||
ref={containerRef}
|
||||
className='relative flex flex-1 items-start overflow-auto bg-[var(--surface-1)]'
|
||||
>
|
||||
{!isDocumentReady && PREVIEW_LOADING_OVERLAY}
|
||||
<ReactPdfDocument
|
||||
file={file}
|
||||
onLoadSuccess={({ numPages }) => {
|
||||
setPageCount(numPages)
|
||||
setCurrentPage(1)
|
||||
setLoadError(null)
|
||||
setIsDocumentReady(true)
|
||||
}}
|
||||
onLoadError={(err) => {
|
||||
logger.error('PDF load failed', { error: err.message })
|
||||
setLoadError(err.message)
|
||||
setIsDocumentReady(true)
|
||||
}}
|
||||
error={<PdfError error={loadError ?? 'Failed to load PDF'} />}
|
||||
className='mx-auto'
|
||||
>
|
||||
<div
|
||||
ref={paddingWrapperRef}
|
||||
style={{
|
||||
padding: PDF_VIEWER_PADDING,
|
||||
minWidth:
|
||||
pageWidth !== undefined
|
||||
? `${pageWidth * displayZoom + 2 * PDF_VIEWER_PADDING}px`
|
||||
: undefined,
|
||||
}}
|
||||
>
|
||||
<div ref={pagesWrapperRef} style={{ width: pageWidth }}>
|
||||
{Array.from({ length: pageCount }, (_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
ref={(el) => {
|
||||
pageRefs.current[i] = el
|
||||
}}
|
||||
data-page={i + 1}
|
||||
className='mb-4 overflow-clip rounded-md shadow-medium'
|
||||
>
|
||||
<ReactPdfPage
|
||||
pageNumber={i + 1}
|
||||
width={pageWidth}
|
||||
className='!overflow-clip [&_.textLayer]:!overflow-clip'
|
||||
renderTextLayer
|
||||
renderAnnotationLayer={false}
|
||||
aria-label={`${filename} page ${i + 1}`}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</ReactPdfDocument>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,73 @@
|
||||
'use client'
|
||||
|
||||
import { memo, useEffect, useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace'
|
||||
import { PptxSandboxHost } from '@/app/workspace/[workspaceId]/files/components/file-viewer/pptx-sandbox-host'
|
||||
import {
|
||||
PREVIEW_LOADING_OVERLAY,
|
||||
PreviewError,
|
||||
PreviewLoadingFrame,
|
||||
resolvePreviewError,
|
||||
} from '@/app/workspace/[workspaceId]/files/components/file-viewer/preview-shared'
|
||||
import { useDocPreviewBinary } from '@/app/workspace/[workspaceId]/files/components/file-viewer/use-doc-preview-binary'
|
||||
|
||||
const logger = createLogger('PptxPreview')
|
||||
|
||||
function pptxCacheKey(fileId: string, dataUpdatedAt: number, byteLength: number): string {
|
||||
return `${fileId}:${dataUpdatedAt}:${byteLength}`
|
||||
}
|
||||
|
||||
export const PptxPreview = memo(function PptxPreview({
|
||||
file,
|
||||
workspaceId,
|
||||
}: {
|
||||
file: WorkspaceFileRecord
|
||||
workspaceId: string
|
||||
}) {
|
||||
const preview = useDocPreviewBinary(workspaceId, file)
|
||||
const fileData = preview.data
|
||||
const cacheKey = pptxCacheKey(file.id, preview.dataUpdatedAt, fileData?.byteLength ?? 0)
|
||||
|
||||
const [hasRendered, setHasRendered] = useState(false)
|
||||
const [renderError, setRenderError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
setRenderError(null)
|
||||
setHasRendered(false)
|
||||
}, [cacheKey])
|
||||
|
||||
function handleRenderStart() {
|
||||
setRenderError(null)
|
||||
}
|
||||
|
||||
function handleRenderComplete() {
|
||||
setHasRendered(true)
|
||||
}
|
||||
|
||||
function handleRenderError(message: string) {
|
||||
logger.error('PPTX render failed', { error: message })
|
||||
setRenderError(message || 'Failed to render presentation')
|
||||
}
|
||||
|
||||
const error = resolvePreviewError(preview.error, renderError)
|
||||
|
||||
if (error) return <PreviewError label='presentation' error={error} />
|
||||
|
||||
if (!fileData) {
|
||||
return <PreviewLoadingFrame className='h-full flex-1' tone='surface' />
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='relative flex h-full min-h-0 flex-1 overflow-hidden bg-[var(--surface-1)]'>
|
||||
<PptxSandboxHost
|
||||
buffer={fileData}
|
||||
requestId={cacheKey}
|
||||
onRenderStart={handleRenderStart}
|
||||
onRenderComplete={handleRenderComplete}
|
||||
onRenderError={handleRenderError}
|
||||
/>
|
||||
{!hasRendered && PREVIEW_LOADING_OVERLAY}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
+219
@@ -0,0 +1,219 @@
|
||||
'use client'
|
||||
|
||||
import { memo, useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { toError } from '@sim/utils/errors'
|
||||
import { openSimPptxViewer, type SimPptxViewerHandle } from '@/lib/pptx-renderer/sim-pptx-viewer'
|
||||
import { PreviewToolbar } from '@/app/workspace/[workspaceId]/files/components/file-viewer/preview-toolbar'
|
||||
import { bindPreviewWheelZoom } from '@/app/workspace/[workspaceId]/files/components/file-viewer/preview-wheel-zoom'
|
||||
|
||||
const logger = createLogger('PptxSandboxHost')
|
||||
|
||||
const ZOOM_MIN = 25
|
||||
const ZOOM_MAX = 400
|
||||
const ZOOM_STEP = 20
|
||||
const ZOOM_WHEEL_SENSITIVITY = 0.005
|
||||
|
||||
interface PptxSandboxHostProps {
|
||||
buffer: ArrayBuffer
|
||||
requestId: string
|
||||
onRenderStart?: () => void
|
||||
onRenderComplete?: () => void
|
||||
onRenderError?: (error: string) => void
|
||||
}
|
||||
|
||||
export const PptxSandboxHost = memo(function PptxSandboxHost({
|
||||
buffer,
|
||||
requestId,
|
||||
onRenderStart,
|
||||
onRenderComplete,
|
||||
onRenderError,
|
||||
}: PptxSandboxHostProps) {
|
||||
const stageRef = useRef<HTMLDivElement>(null)
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null)
|
||||
const activeHandleRef = useRef<SimPptxViewerHandle | null>(null)
|
||||
const activeContainerRef = useRef<HTMLDivElement | null>(null)
|
||||
const renderSequenceRef = useRef(0)
|
||||
const onRenderStartRef = useRef(onRenderStart)
|
||||
const onRenderCompleteRef = useRef(onRenderComplete)
|
||||
const onRenderErrorRef = useRef(onRenderError)
|
||||
const zoomPercentRef = useRef(100)
|
||||
|
||||
onRenderStartRef.current = onRenderStart
|
||||
onRenderCompleteRef.current = onRenderComplete
|
||||
onRenderErrorRef.current = onRenderError
|
||||
const [zoomPercent, setZoomPercent] = useState(100)
|
||||
const [slideCount, setSlideCount] = useState(0)
|
||||
const [currentSlide, setCurrentSlide] = useState(1)
|
||||
|
||||
useEffect(() => {
|
||||
const stage = stageRef.current
|
||||
if (!stage) return
|
||||
|
||||
const controller = new AbortController()
|
||||
const sequence = ++renderSequenceRef.current
|
||||
const nextContainer = document.createElement('div')
|
||||
nextContainer.dataset.requestId = requestId
|
||||
nextContainer.style.width = '100%'
|
||||
nextContainer.style.visibility = 'hidden'
|
||||
stage.appendChild(nextContainer)
|
||||
|
||||
onRenderStartRef.current?.()
|
||||
|
||||
async function render() {
|
||||
try {
|
||||
const handle = await openSimPptxViewer({
|
||||
buffer,
|
||||
container: nextContainer,
|
||||
scrollContainer: scrollContainerRef.current ?? undefined,
|
||||
signal: controller.signal,
|
||||
onSlideChange: (index) => setCurrentSlide(index + 1),
|
||||
onSlideError: (slideIndex, error) => {
|
||||
logger.warn('PPTX slide render failed', {
|
||||
slideIndex,
|
||||
error: toError(error).message,
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
if (controller.signal.aborted || sequence !== renderSequenceRef.current) {
|
||||
handle.destroy()
|
||||
nextContainer.remove()
|
||||
return
|
||||
}
|
||||
|
||||
const previousHandle = activeHandleRef.current
|
||||
const previousContainer = activeContainerRef.current
|
||||
activeHandleRef.current = handle
|
||||
activeContainerRef.current = nextContainer
|
||||
setSlideCount(handle.viewer.slideCount)
|
||||
setCurrentSlide(handle.viewer.currentSlideIndex + 1)
|
||||
if (zoomPercentRef.current !== 100) {
|
||||
await handle.viewer.setZoom(zoomPercentRef.current)
|
||||
}
|
||||
nextContainer.style.visibility = 'visible'
|
||||
previousHandle?.destroy()
|
||||
previousContainer?.remove()
|
||||
onRenderCompleteRef.current?.()
|
||||
} catch (error) {
|
||||
nextContainer.remove()
|
||||
// Aborted means a newer render superseded this one — don't report.
|
||||
if (controller.signal.aborted) return
|
||||
|
||||
const message = toError(error).message || 'Failed to render presentation'
|
||||
logger.warn('PPTX render failed', { error: message })
|
||||
onRenderErrorRef.current?.(message)
|
||||
}
|
||||
}
|
||||
|
||||
render()
|
||||
|
||||
return () => {
|
||||
controller.abort()
|
||||
if (activeContainerRef.current !== nextContainer) {
|
||||
nextContainer.remove()
|
||||
}
|
||||
}
|
||||
}, [buffer, requestId])
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
renderSequenceRef.current += 1
|
||||
activeHandleRef.current?.destroy()
|
||||
activeContainerRef.current?.remove()
|
||||
}
|
||||
}, [])
|
||||
|
||||
const applyZoomAt = useCallback(async (nextZoom: number, anchorX: number, anchorY: number) => {
|
||||
const container = scrollContainerRef.current
|
||||
if (!container) return
|
||||
|
||||
const clampedZoom = Math.round(Math.min(Math.max(nextZoom, ZOOM_MIN), ZOOM_MAX))
|
||||
const ratio = clampedZoom / zoomPercentRef.current
|
||||
const style = window.getComputedStyle(container)
|
||||
const paddingLeft = Number.parseFloat(style.paddingLeft) || 0
|
||||
const paddingTop = Number.parseFloat(style.paddingTop) || 0
|
||||
const previousScrollLeft = container.scrollLeft
|
||||
const previousScrollTop = container.scrollTop
|
||||
|
||||
zoomPercentRef.current = clampedZoom
|
||||
setZoomPercent(clampedZoom)
|
||||
await activeHandleRef.current?.viewer.setZoom(clampedZoom)
|
||||
|
||||
container.scrollLeft =
|
||||
(previousScrollLeft + anchorX - paddingLeft) * ratio + paddingLeft - anchorX
|
||||
container.scrollTop = (previousScrollTop + anchorY - paddingTop) * ratio + paddingTop - anchorY
|
||||
}, [])
|
||||
|
||||
const applyZoomFromCenter = useCallback(
|
||||
(nextZoom: number): Promise<void> => {
|
||||
const container = scrollContainerRef.current
|
||||
return applyZoomAt(
|
||||
nextZoom,
|
||||
container ? container.clientWidth / 2 : 0,
|
||||
container ? container.clientHeight / 2 : 0
|
||||
)
|
||||
},
|
||||
[applyZoomAt]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const container = scrollContainerRef.current
|
||||
if (!container) return
|
||||
|
||||
return bindPreviewWheelZoom(container, (event) => {
|
||||
const rect = container.getBoundingClientRect()
|
||||
void applyZoomAt(
|
||||
zoomPercentRef.current * (1 - event.deltaY * ZOOM_WHEEL_SENSITIVITY),
|
||||
event.clientX - rect.left,
|
||||
event.clientY - rect.top
|
||||
)
|
||||
})
|
||||
}, [applyZoomAt])
|
||||
|
||||
async function goToSlide(slideNumber: number) {
|
||||
if (!activeHandleRef.current || slideCount <= 0) return
|
||||
const clampedSlide = Math.min(Math.max(slideNumber, 1), slideCount)
|
||||
if (zoomPercentRef.current !== 100) {
|
||||
await applyZoomFromCenter(100)
|
||||
}
|
||||
setCurrentSlide(clampedSlide)
|
||||
await activeHandleRef.current.viewer.goToSlide(clampedSlide - 1)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='flex h-full min-h-0 w-full flex-1 flex-col overflow-hidden bg-[var(--surface-1)]'>
|
||||
<PreviewToolbar
|
||||
navigation={{
|
||||
current: currentSlide,
|
||||
total: slideCount,
|
||||
label: 'slide',
|
||||
canPrevious: slideCount > 0 && currentSlide > 1,
|
||||
canNext: slideCount > 0 && currentSlide < slideCount,
|
||||
onPrevious: () => goToSlide(currentSlide - 1),
|
||||
onNext: () => goToSlide(currentSlide + 1),
|
||||
}}
|
||||
zoom={{
|
||||
label: `${zoomPercent}%`,
|
||||
canZoomOut: zoomPercent > ZOOM_MIN,
|
||||
canZoomIn: zoomPercent < ZOOM_MAX,
|
||||
onReset: () => {
|
||||
void applyZoomFromCenter(100)
|
||||
},
|
||||
onZoomOut: () => {
|
||||
void applyZoomFromCenter(zoomPercent - ZOOM_STEP)
|
||||
},
|
||||
onZoomIn: () => {
|
||||
void applyZoomFromCenter(zoomPercent + ZOOM_STEP)
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
ref={scrollContainerRef}
|
||||
className='relative flex min-h-0 flex-1 items-start overflow-auto bg-[var(--surface-1)] p-6'
|
||||
>
|
||||
<div ref={stageRef} className='mx-auto w-full max-w-[960px]' />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,343 @@
|
||||
'use client'
|
||||
|
||||
import { memo, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import '@sim/emcn/components/code/code.css'
|
||||
import { CSV_PREVIEW_MAX_ROWS } from '@/lib/api/contracts/workspace-file-table'
|
||||
import { getFileExtension } from '@/lib/uploads/utils/file-utils'
|
||||
import { type CsvImportFileDescriptor, useCsvTruncationImport } from './csv-import'
|
||||
import { DataTable } from './data-table'
|
||||
import { MermaidDiagram } from './mermaid-diagram'
|
||||
import { ZoomablePreview } from './zoomable-preview'
|
||||
|
||||
type PreviewType = 'markdown' | 'html' | 'csv' | 'svg' | 'mermaid' | null
|
||||
|
||||
const PREVIEWABLE_MIME_TYPES: Record<string, PreviewType> = {
|
||||
'text/markdown': 'markdown',
|
||||
'text/html': 'html',
|
||||
'text/csv': 'csv',
|
||||
'image/svg+xml': 'svg',
|
||||
'text/x-mermaid': 'mermaid',
|
||||
}
|
||||
|
||||
const PREVIEWABLE_EXTENSIONS: Record<string, PreviewType> = {
|
||||
md: 'markdown',
|
||||
html: 'html',
|
||||
htm: 'html',
|
||||
csv: 'csv',
|
||||
svg: 'svg',
|
||||
mmd: 'mermaid',
|
||||
}
|
||||
|
||||
/** All extensions that have a rich preview renderer. */
|
||||
export const RICH_PREVIEWABLE_EXTENSIONS = new Set(Object.keys(PREVIEWABLE_EXTENSIONS))
|
||||
|
||||
export function resolvePreviewType(mimeType: string | null, filename: string): PreviewType {
|
||||
if (mimeType && PREVIEWABLE_MIME_TYPES[mimeType]) return PREVIEWABLE_MIME_TYPES[mimeType]
|
||||
const ext = getFileExtension(filename)
|
||||
return PREVIEWABLE_EXTENSIONS[ext] ?? null
|
||||
}
|
||||
|
||||
interface PreviewPanelProps {
|
||||
content: string
|
||||
mimeType: string | null
|
||||
filename: string
|
||||
workspaceId: string
|
||||
fileKey: string
|
||||
isStreaming?: boolean
|
||||
/**
|
||||
* Read-only surface (e.g. the public share page) — disables interactive
|
||||
* affordances such as the CSV "Import as a table" action, which needs an
|
||||
* authenticated workspace import.
|
||||
*/
|
||||
readOnly?: boolean
|
||||
}
|
||||
|
||||
export const PreviewPanel = memo(function PreviewPanel({
|
||||
content,
|
||||
mimeType,
|
||||
filename,
|
||||
workspaceId,
|
||||
fileKey,
|
||||
isStreaming,
|
||||
readOnly,
|
||||
}: PreviewPanelProps) {
|
||||
const previewType = resolvePreviewType(mimeType, filename)
|
||||
|
||||
if (previewType === 'html') return <HtmlPreview content={content} />
|
||||
if (previewType === 'csv')
|
||||
return (
|
||||
<CsvPreview
|
||||
content={content}
|
||||
workspaceId={workspaceId}
|
||||
file={{ key: fileKey, name: filename }}
|
||||
readOnly={readOnly}
|
||||
/>
|
||||
)
|
||||
if (previewType === 'svg') return <SvgPreview content={content} />
|
||||
if (previewType === 'mermaid')
|
||||
return <MermaidFilePreview content={content} isStreaming={isStreaming} />
|
||||
|
||||
return null
|
||||
})
|
||||
|
||||
const HTML_PREVIEW_BASE_URL = 'about:srcdoc'
|
||||
|
||||
const HTML_PREVIEW_CSP = [
|
||||
"default-src 'none'",
|
||||
"script-src 'unsafe-inline'",
|
||||
"style-src 'unsafe-inline'",
|
||||
'img-src data: blob:',
|
||||
'font-src data:',
|
||||
'media-src data: blob:',
|
||||
"connect-src 'none'",
|
||||
"form-action 'none'",
|
||||
"frame-src 'none'",
|
||||
"child-src 'none'",
|
||||
"object-src 'none'",
|
||||
].join('; ')
|
||||
|
||||
const HTML_PREVIEW_BOOTSTRAP = `<script>
|
||||
(() => {
|
||||
const allowHref = (href) => href.startsWith('#') || /^\\s*javascript:/i.test(href)
|
||||
|
||||
document.addEventListener(
|
||||
'click',
|
||||
(event) => {
|
||||
if (!(event.target instanceof Element)) return
|
||||
const anchor = event.target.closest('a[href]')
|
||||
if (!(anchor instanceof HTMLAnchorElement)) return
|
||||
const href = anchor.getAttribute('href') || ''
|
||||
if (allowHref(href)) return
|
||||
event.preventDefault()
|
||||
},
|
||||
true
|
||||
)
|
||||
|
||||
document.addEventListener(
|
||||
'submit',
|
||||
(event) => {
|
||||
event.preventDefault()
|
||||
},
|
||||
true
|
||||
)
|
||||
|
||||
})()
|
||||
</script>`
|
||||
|
||||
function buildHtmlPreviewDocument(content: string): string {
|
||||
const headInjection = [
|
||||
'<meta charset="utf-8">',
|
||||
`<base href="${HTML_PREVIEW_BASE_URL}">`,
|
||||
`<meta http-equiv="Content-Security-Policy" content="${HTML_PREVIEW_CSP}">`,
|
||||
HTML_PREVIEW_BOOTSTRAP,
|
||||
].join('')
|
||||
|
||||
if (/<head[\s>]/i.test(content)) {
|
||||
return content.replace(/<head(\s[^>]*)?>/i, (match) => `${match}${headInjection}`)
|
||||
}
|
||||
|
||||
if (/<html[\s>]/i.test(content)) {
|
||||
return content.replace(/<html(\s[^>]*)?>/i, (match) => `${match}<head>${headInjection}</head>`)
|
||||
}
|
||||
|
||||
return `<!DOCTYPE html><html><head>${headInjection}</head><body>${content}</body></html>`
|
||||
}
|
||||
|
||||
const HtmlPreview = memo(function HtmlPreview({ content }: { content: string }) {
|
||||
const wrappedContent = buildHtmlPreviewDocument(content)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const [isRenderable, setIsRenderable] = useState(false)
|
||||
const [resumeNonce, setResumeNonce] = useState(0)
|
||||
const pageWasHiddenRef = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current
|
||||
if (!container) return
|
||||
|
||||
const updateRenderability = (width: number, height: number) => {
|
||||
setIsRenderable(width > 0 && height > 0)
|
||||
}
|
||||
|
||||
const initialRect = container.getBoundingClientRect()
|
||||
updateRenderability(initialRect.width, initialRect.height)
|
||||
|
||||
const observer = new ResizeObserver((entries) => {
|
||||
const entry = entries[0]
|
||||
if (!entry) return
|
||||
updateRenderability(entry.contentRect.width, entry.contentRect.height)
|
||||
})
|
||||
observer.observe(container)
|
||||
|
||||
return () => observer.disconnect()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const handleVisibilityChange = () => {
|
||||
if (document.visibilityState === 'hidden') {
|
||||
pageWasHiddenRef.current = true
|
||||
return
|
||||
}
|
||||
|
||||
if (document.visibilityState === 'visible' && pageWasHiddenRef.current) {
|
||||
pageWasHiddenRef.current = false
|
||||
setResumeNonce((nonce) => nonce + 1)
|
||||
}
|
||||
}
|
||||
|
||||
const handlePageShow = (event: PageTransitionEvent) => {
|
||||
if (event.persisted) {
|
||||
setResumeNonce((nonce) => nonce + 1)
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('visibilitychange', handleVisibilityChange)
|
||||
window.addEventListener('pageshow', handlePageShow)
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('visibilitychange', handleVisibilityChange)
|
||||
window.removeEventListener('pageshow', handlePageShow)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className='h-full overflow-hidden'>
|
||||
{isRenderable && (
|
||||
<iframe
|
||||
key={resumeNonce}
|
||||
srcDoc={wrappedContent}
|
||||
sandbox='allow-scripts'
|
||||
referrerPolicy='no-referrer'
|
||||
title='HTML Preview'
|
||||
className='h-full w-full border-0 bg-[var(--surface-2)]'
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
function SvgPreview({ content }: { content: string }) {
|
||||
const [blobUrl, setBlobUrl] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
const url = URL.createObjectURL(new Blob([content], { type: 'image/svg+xml' }))
|
||||
setBlobUrl(url)
|
||||
return () => URL.revokeObjectURL(url)
|
||||
}, [content])
|
||||
|
||||
return (
|
||||
<ZoomablePreview className='h-full' contentClassName='h-full w-full'>
|
||||
{blobUrl && (
|
||||
<img
|
||||
src={blobUrl}
|
||||
alt='SVG preview'
|
||||
className='max-h-full max-w-full select-none object-contain'
|
||||
draggable={false}
|
||||
/>
|
||||
)}
|
||||
</ZoomablePreview>
|
||||
)
|
||||
}
|
||||
|
||||
function MermaidFilePreview({ content, isStreaming }: { content: string; isStreaming?: boolean }) {
|
||||
return (
|
||||
<div className='h-full overflow-auto p-6'>
|
||||
<MermaidDiagram
|
||||
definition={content}
|
||||
isStreaming={isStreaming}
|
||||
zoomable
|
||||
zoomClassName='h-full rounded-lg'
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const CsvPreview = memo(function CsvPreview({
|
||||
content,
|
||||
workspaceId,
|
||||
file,
|
||||
readOnly,
|
||||
}: {
|
||||
content: string
|
||||
workspaceId: string
|
||||
file: CsvImportFileDescriptor
|
||||
readOnly?: boolean
|
||||
}) {
|
||||
const { headers, rows, truncated } = useMemo(() => parseCsv(content), [content])
|
||||
useCsvTruncationImport(workspaceId, file, truncated, readOnly)
|
||||
|
||||
if (headers.length === 0) {
|
||||
return (
|
||||
<div className='flex h-full items-center justify-center p-6'>
|
||||
<p className='text-[13px] text-[var(--text-muted)]'>No data to display</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='h-full overflow-auto p-6'>
|
||||
<DataTable headers={headers} rows={rows} />
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
/**
|
||||
* Parses CSV text for the inline preview, capping at {@link CSV_PREVIEW_MAX_ROWS} rows so a
|
||||
* small-but-many-rows file doesn't render thousands of `<tr>`s. Slices before parsing so only
|
||||
* the capped rows are processed; `truncated` drives the "Import as a table" footer.
|
||||
*/
|
||||
function parseCsv(text: string): { headers: string[]; rows: string[][]; truncated: boolean } {
|
||||
const lines = text.split('\n').filter((line) => line.trim().length > 0)
|
||||
if (lines.length === 0) return { headers: [], rows: [], truncated: false }
|
||||
|
||||
const delimiter = detectDelimiter(lines[0])
|
||||
const headers = parseCsvLine(lines[0], delimiter)
|
||||
const dataLines = lines.slice(1)
|
||||
const truncated = dataLines.length > CSV_PREVIEW_MAX_ROWS
|
||||
const rows = dataLines.slice(0, CSV_PREVIEW_MAX_ROWS).map((line) => parseCsvLine(line, delimiter))
|
||||
|
||||
return { headers, rows, truncated }
|
||||
}
|
||||
|
||||
function detectDelimiter(line: string): string {
|
||||
const commaCount = (line.match(/,/g) || []).length
|
||||
const tabCount = (line.match(/\t/g) || []).length
|
||||
const semiCount = (line.match(/;/g) || []).length
|
||||
if (tabCount > commaCount && tabCount > semiCount) return '\t'
|
||||
if (semiCount > commaCount) return ';'
|
||||
return ','
|
||||
}
|
||||
|
||||
function parseCsvLine(line: string, delimiter: string): string[] {
|
||||
const fields: string[] = []
|
||||
let current = ''
|
||||
let inQuotes = false
|
||||
|
||||
for (let i = 0; i < line.length; i++) {
|
||||
const char = line[i]
|
||||
|
||||
if (inQuotes) {
|
||||
if (char === '"') {
|
||||
if (i + 1 < line.length && line[i + 1] === '"') {
|
||||
current += '"'
|
||||
i++
|
||||
} else {
|
||||
inQuotes = false
|
||||
}
|
||||
} else {
|
||||
current += char
|
||||
}
|
||||
} else {
|
||||
if (char === '"') {
|
||||
inQuotes = true
|
||||
} else if (char === delimiter) {
|
||||
fields.push(current.trim())
|
||||
current = ''
|
||||
} else {
|
||||
current += char
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fields.push(current.trim())
|
||||
return fields
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
'use client'
|
||||
|
||||
import { Component, type ErrorInfo, type ReactNode } from 'react'
|
||||
import { cn } from '@sim/emcn'
|
||||
import { Loader } from '@sim/emcn/icons'
|
||||
import { createLogger } from '@sim/logger'
|
||||
|
||||
const logger = createLogger('FilePreview')
|
||||
|
||||
export function PreviewError({ label, error }: { label: string; error: string }) {
|
||||
return (
|
||||
<div className='flex flex-1 flex-col items-center justify-center gap-[8px]'>
|
||||
<p className='font-medium text-[14px] text-[var(--text-primary)]'>
|
||||
Failed to preview {label}
|
||||
</p>
|
||||
<p className='text-[13px] text-[var(--text-muted)]'>{error}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface PreviewErrorBoundaryProps {
|
||||
/** Format label shown in the fallback, e.g. "PDF". */
|
||||
label: string
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
interface PreviewErrorBoundaryState {
|
||||
hasError: boolean
|
||||
error?: Error
|
||||
}
|
||||
|
||||
/**
|
||||
* Error boundary for preview renderers. Catches render-time crashes (including
|
||||
* a preview module whose dynamic import rejected) and degrades to the standard
|
||||
* PreviewError fallback instead of unwinding to the route-level error boundary
|
||||
* and replacing the whole workspace view.
|
||||
*
|
||||
* Callers must `key` this boundary by the identity of the rendered content
|
||||
* (e.g. file id + data version) — the error state resets only via remount, so
|
||||
* keying the child alone would leave a tripped boundary stuck on the fallback.
|
||||
*/
|
||||
export class PreviewErrorBoundary extends Component<
|
||||
PreviewErrorBoundaryProps,
|
||||
PreviewErrorBoundaryState
|
||||
> {
|
||||
public state: PreviewErrorBoundaryState = {
|
||||
hasError: false,
|
||||
}
|
||||
|
||||
public static getDerivedStateFromError(error: Error): PreviewErrorBoundaryState {
|
||||
return { hasError: true, error }
|
||||
}
|
||||
|
||||
public componentDidCatch(error: Error, errorInfo: ErrorInfo) {
|
||||
logger.error('Preview crashed', {
|
||||
label: this.props.label,
|
||||
error: error.message,
|
||||
componentStack: errorInfo.componentStack,
|
||||
})
|
||||
}
|
||||
|
||||
public render() {
|
||||
if (this.state.hasError) {
|
||||
return (
|
||||
<PreviewError
|
||||
label={this.props.label}
|
||||
error={this.state.error?.message ?? 'An unexpected error occurred'}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return this.props.children
|
||||
}
|
||||
}
|
||||
|
||||
export function resolvePreviewError(
|
||||
fetchError: Error | null,
|
||||
renderError: string | null
|
||||
): string | null {
|
||||
// A doc whose compiled artifact never appeared (the binary query exhausted its
|
||||
// "still generating" polls) — usually a source that failed to compile or a
|
||||
// legacy file with no artifact. Give a clear, actionable message instead of a
|
||||
// generic fetch error.
|
||||
if (fetchError?.name === 'DocNotReadyError') {
|
||||
return "Couldn't generate this document preview. Re-run the file generation to rebuild it."
|
||||
}
|
||||
if (fetchError) return fetchError.message
|
||||
return renderError
|
||||
}
|
||||
|
||||
/** Canonical content-area loading spinner, matching the rest of the app. */
|
||||
const PREVIEW_LOADING_SPINNER = (
|
||||
<Loader className='size-[20px] text-[var(--text-secondary)]' animate />
|
||||
)
|
||||
|
||||
/**
|
||||
* Canonical loading overlay for previews that render into a `--surface-1`
|
||||
* canvas. Absolutely covers the canvas (with `z-10` so it paints above
|
||||
* in-flow render targets) with a centered spinner until the preview is ready.
|
||||
*/
|
||||
export const PREVIEW_LOADING_OVERLAY = (
|
||||
<div className='absolute inset-0 z-10 flex items-center justify-center bg-[var(--surface-1)]'>
|
||||
{PREVIEW_LOADING_SPINNER}
|
||||
</div>
|
||||
)
|
||||
|
||||
interface PreviewLoadingFrameProps {
|
||||
/** Layout/sizing-only classes for the in-flow frame (e.g. `h-full`, `flex-1`). */
|
||||
className?: string
|
||||
/** Background token matching the loaded sibling's canvas. Defaults to `--bg`. */
|
||||
tone?: 'bg' | 'surface'
|
||||
}
|
||||
|
||||
/**
|
||||
* Canonical in-flow loading frame with a centered spinner, shown while a
|
||||
* preview is fetching or rendering. The `tone` must match the background of
|
||||
* the loaded state it is standing in for, so mount completion does not flash
|
||||
* a different token.
|
||||
*/
|
||||
export function PreviewLoadingFrame({ className, tone = 'bg' }: PreviewLoadingFrameProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center justify-center',
|
||||
tone === 'surface' ? 'bg-[var(--surface-1)]' : 'bg-[var(--bg)]',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{PREVIEW_LOADING_SPINNER}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { Chip, cn } from '@sim/emcn'
|
||||
import { ChevronLeft, ChevronRight, ZoomIn, ZoomOut } from 'lucide-react'
|
||||
|
||||
interface PreviewNavigationControls {
|
||||
current: number
|
||||
total: number
|
||||
label: string
|
||||
onPrevious: () => void
|
||||
onNext: () => void
|
||||
canPrevious?: boolean
|
||||
canNext?: boolean
|
||||
}
|
||||
|
||||
interface PreviewZoomControls {
|
||||
label: string
|
||||
onZoomOut: () => void
|
||||
onZoomIn: () => void
|
||||
canZoomOut?: boolean
|
||||
canZoomIn?: boolean
|
||||
onReset?: () => void
|
||||
}
|
||||
|
||||
interface PreviewToolbarProps {
|
||||
navigation?: PreviewNavigationControls
|
||||
zoom?: PreviewZoomControls
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function PreviewToolbar({ navigation, zoom, className }: PreviewToolbarProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex shrink-0 items-center justify-between border-[var(--border)] border-b bg-[var(--surface-1)] px-2 py-1',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div className='flex items-center'>
|
||||
{navigation && <PreviewNavigationControls {...navigation} />}
|
||||
</div>
|
||||
<div className='flex items-center'>{zoom && <PreviewZoomControls {...zoom} />}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function PreviewNavigationControls({
|
||||
current,
|
||||
total,
|
||||
label,
|
||||
onPrevious,
|
||||
onNext,
|
||||
canPrevious = current > 1,
|
||||
canNext = current < total,
|
||||
}: PreviewNavigationControls) {
|
||||
return (
|
||||
<>
|
||||
<Chip
|
||||
leftIcon={ChevronLeft}
|
||||
onClick={onPrevious}
|
||||
disabled={!canPrevious}
|
||||
aria-label={`Previous ${label}`}
|
||||
/>
|
||||
<span className='min-w-[4.5rem] text-center text-[var(--text-body)] text-sm'>
|
||||
{total > 0 ? `${current} / ${total}` : '0 / 0'}
|
||||
</span>
|
||||
<Chip
|
||||
leftIcon={ChevronRight}
|
||||
onClick={onNext}
|
||||
disabled={!canNext}
|
||||
aria-label={`Next ${label}`}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function PreviewZoomControls({
|
||||
label,
|
||||
onZoomOut,
|
||||
onZoomIn,
|
||||
canZoomOut = true,
|
||||
canZoomIn = true,
|
||||
onReset,
|
||||
}: PreviewZoomControls) {
|
||||
return (
|
||||
<>
|
||||
{onReset && (
|
||||
<Chip onClick={onReset} aria-label='Reset zoom'>
|
||||
Reset
|
||||
</Chip>
|
||||
)}
|
||||
<Chip leftIcon={ZoomOut} onClick={onZoomOut} disabled={!canZoomOut} aria-label='Zoom out' />
|
||||
<span className='min-w-[3.25rem] text-center text-[var(--text-body)] text-sm'>{label}</span>
|
||||
<Chip leftIcon={ZoomIn} onClick={onZoomIn} disabled={!canZoomIn} aria-label='Zoom in' />
|
||||
</>
|
||||
)
|
||||
}
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
interface BindPreviewWheelZoomOptions {
|
||||
/**
|
||||
* Called for non-modifier wheel events (two-finger scroll). When provided,
|
||||
* the container's native scrolling is suppressed and the consumer drives
|
||||
* pan via `deltaX` / `deltaY`. Use for transform-based viewers (e.g. image)
|
||||
* where the content is not a real scroll container.
|
||||
*/
|
||||
onPan?: (event: WheelEvent) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Bind browser pinch/ctrl-wheel zoom and horizontal wheel gestures for preview
|
||||
* scroll containers. Trackpad pinch fires `wheel` with `ctrlKey=true`; without
|
||||
* a non-passive native listener the browser falls back to page zoom. `metaKey`
|
||||
* is also accepted so Cmd+scroll zooms on macOS, matching Figma/tldraw/Excalidraw.
|
||||
*/
|
||||
export function bindPreviewWheelZoom(
|
||||
container: HTMLElement,
|
||||
onZoom: (event: WheelEvent) => void,
|
||||
options: BindPreviewWheelZoomOptions = {}
|
||||
): () => void {
|
||||
const { onPan } = options
|
||||
|
||||
const onWheel = (event: WheelEvent) => {
|
||||
if (event.ctrlKey || event.metaKey) {
|
||||
event.preventDefault()
|
||||
onZoom(event)
|
||||
return
|
||||
}
|
||||
|
||||
if (onPan) {
|
||||
event.preventDefault()
|
||||
onPan(event)
|
||||
return
|
||||
}
|
||||
|
||||
const horizontalDelta = event.deltaX !== 0 ? event.deltaX : event.shiftKey ? event.deltaY : 0
|
||||
if (horizontalDelta === 0 || container.scrollWidth <= container.clientWidth) return
|
||||
|
||||
event.preventDefault()
|
||||
container.scrollLeft += horizontalDelta
|
||||
}
|
||||
|
||||
container.addEventListener('wheel', onWheel, { capture: true, passive: false })
|
||||
return () => container.removeEventListener('wheel', onWheel, { capture: true })
|
||||
}
|
||||
+94
@@ -0,0 +1,94 @@
|
||||
import { Extension } from '@tiptap/core'
|
||||
import type { EditorState, Transaction } from '@tiptap/pm/state'
|
||||
import { TextSelection } from '@tiptap/pm/state'
|
||||
|
||||
/** The position range of the depth-1 block containing the cursor, or null at the document root. */
|
||||
function currentTopLevelBlock(state: EditorState): { from: number; to: number } | null {
|
||||
const { $from } = state.selection
|
||||
if ($from.depth === 0) return null
|
||||
return { from: $from.before(1), to: $from.after(1) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Swaps the current top-level block with its neighbour in `direction`, keeping the caret on the moved
|
||||
* block. Adjacent top-level blocks share a boundary position (no separator token between them), so the
|
||||
* move is a single `replaceWith` of the two-block span with the pair reordered. No-ops (returns false)
|
||||
* at the matching document edge or when the neighbour isn't a top-level block. `newBefore` is the moved
|
||||
* block's new `before(1)` position; adding the caret's original offset (`selection.from - from`, also
|
||||
* measured from `before(1)`) re-anchors the caret at the same spot within the block.
|
||||
*/
|
||||
function moveBlock(
|
||||
state: EditorState,
|
||||
dispatch: ((tr: Transaction) => void) | undefined,
|
||||
direction: 'up' | 'down'
|
||||
): boolean {
|
||||
const block = currentTopLevelBlock(state)
|
||||
if (!block) return false
|
||||
const { from, to } = block
|
||||
const up = direction === 'up'
|
||||
|
||||
if (up ? from === 0 : to >= state.doc.content.size) return false
|
||||
const $neighbour = state.doc.resolve(up ? from - 1 : to + 1)
|
||||
if ($neighbour.depth === 0) return false
|
||||
if (!dispatch) return true
|
||||
|
||||
const spanFrom = up ? $neighbour.before(1) : from
|
||||
const spanTo = up ? to : $neighbour.after(1)
|
||||
const moving = state.doc.slice(from, to).content
|
||||
const neighbour = up
|
||||
? state.doc.slice(spanFrom, from).content
|
||||
: state.doc.slice(to, spanTo).content
|
||||
const tr = state.tr.replaceWith(
|
||||
spanFrom,
|
||||
spanTo,
|
||||
up ? moving.append(neighbour) : neighbour.append(moving)
|
||||
)
|
||||
|
||||
const newBefore = up ? spanFrom : spanFrom + neighbour.size
|
||||
const offset = state.selection.from - from
|
||||
tr.setSelection(
|
||||
TextSelection.near(tr.doc.resolve(Math.min(newBefore + offset, newBefore + moving.size)))
|
||||
)
|
||||
dispatch(tr.scrollIntoView())
|
||||
return true
|
||||
}
|
||||
|
||||
declare module '@tiptap/core' {
|
||||
interface Commands<ReturnType> {
|
||||
blockMover: {
|
||||
/** Move the current top-level block up one position, carrying the caret. */
|
||||
moveBlockUp: () => ReturnType
|
||||
/** Move the current top-level block down one position, carrying the caret. */
|
||||
moveBlockDown: () => ReturnType
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reorders the current top-level block with `Mod-Shift-ArrowUp`/`ArrowDown` — the standard
|
||||
* keyboard block-move affordance (Notion/Obsidian). Pure UI interaction: no schema change, and the
|
||||
* caret rides along with the block. A no-op (returns false, falling through) at the document edges.
|
||||
*/
|
||||
export const BlockMover = Extension.create({
|
||||
name: 'blockMover',
|
||||
|
||||
addCommands() {
|
||||
return {
|
||||
moveBlockUp:
|
||||
() =>
|
||||
({ state, dispatch }) =>
|
||||
moveBlock(state, dispatch, 'up'),
|
||||
moveBlockDown:
|
||||
() =>
|
||||
({ state, dispatch }) =>
|
||||
moveBlock(state, dispatch, 'down'),
|
||||
}
|
||||
},
|
||||
|
||||
addKeyboardShortcuts() {
|
||||
return {
|
||||
'Mod-Shift-ArrowUp': ({ editor }) => editor.commands.moveBlockUp(),
|
||||
'Mod-Shift-ArrowDown': ({ editor }) => editor.commands.moveBlockDown(),
|
||||
}
|
||||
},
|
||||
})
|
||||
+264
@@ -0,0 +1,264 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import {
|
||||
chipVariants,
|
||||
cn,
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
useCopyToClipboard,
|
||||
} from '@sim/emcn'
|
||||
import type { JSONContent } from '@tiptap/core'
|
||||
import { CodeBlock } from '@tiptap/extension-code-block'
|
||||
import type { ReactNodeViewProps } from '@tiptap/react'
|
||||
import { NodeViewContent, NodeViewWrapper, ReactNodeViewRenderer } from '@tiptap/react'
|
||||
import { Check, ChevronDown, Code, Copy, Eye, WrapText } from 'lucide-react'
|
||||
import { looksLikeMermaid, MermaidDiagram } from '../mermaid-diagram'
|
||||
import { detectLanguage } from './detect-language'
|
||||
import { useEditorEditable } from './use-editor-editable'
|
||||
|
||||
const PLAIN = 'plain'
|
||||
const MERMAID = 'mermaid'
|
||||
|
||||
/** Languages the Prism highlighter has registered (see {@link CodeBlockHighlight}). Every non-plain
|
||||
* value MUST have a grammar registered in {@link CodeBlockHighlight} — enforced by a unit test. */
|
||||
export const LANGUAGE_OPTIONS = [
|
||||
{ value: PLAIN, label: 'Plain text' },
|
||||
{ value: 'bash', label: 'Bash' },
|
||||
{ value: 'c', label: 'C' },
|
||||
{ value: 'cpp', label: 'C++' },
|
||||
{ value: 'csharp', label: 'C#' },
|
||||
{ value: 'css', label: 'CSS' },
|
||||
{ value: 'go', label: 'Go' },
|
||||
{ value: 'java', label: 'Java' },
|
||||
{ value: 'javascript', label: 'JavaScript' },
|
||||
{ value: 'json', label: 'JSON' },
|
||||
{ value: 'markup', label: 'HTML' },
|
||||
{ value: 'php', label: 'PHP' },
|
||||
{ value: 'python', label: 'Python' },
|
||||
{ value: 'ruby', label: 'Ruby' },
|
||||
{ value: 'rust', label: 'Rust' },
|
||||
{ value: 'sql', label: 'SQL' },
|
||||
{ value: 'typescript', label: 'TypeScript' },
|
||||
{ value: 'yaml', label: 'YAML' },
|
||||
] as const
|
||||
|
||||
const CONTROL_CLASS =
|
||||
'flex size-[24px] items-center justify-center rounded-lg text-[var(--text-icon)] outline-none transition-colors hover-hover:bg-[var(--surface-hover)] hover-hover:text-[var(--text-body)] focus-visible:bg-[var(--surface-hover)] [&_svg]:size-[14px]'
|
||||
|
||||
/**
|
||||
* Code block view with hover controls (language picker, line-wrap, copy). When the block holds
|
||||
* Mermaid — tagged ```mermaid or {@link looksLikeMermaid auto-detected} — it renders as a diagram
|
||||
* whenever the cursor is outside it (and always in read-only), and as editable source while the
|
||||
* cursor is inside, re-rendering on blur (the Linear/GitHub model). The source `<pre>` stays mounted
|
||||
* (hidden behind the diagram) so ProseMirror keeps managing its contentDOM, and the node remains an
|
||||
* ordinary code block, so markdown round-trips unchanged.
|
||||
*/
|
||||
function CodeBlockView({ node, updateAttributes, editor, getPos }: ReactNodeViewProps) {
|
||||
const [wrap, setWrap] = useState(false)
|
||||
const [menuOpen, setMenuOpen] = useState(false)
|
||||
const [editingInline, setEditingInline] = useState(false)
|
||||
const [peekSource, setPeekSource] = useState(false)
|
||||
const { copied, copy } = useCopyToClipboard({ resetMs: 1500 })
|
||||
const editable = useEditorEditable(editor)
|
||||
|
||||
const explicitLanguage = node.attrs.language as string | null
|
||||
const text = node.textContent
|
||||
const isMermaid = explicitLanguage === MERMAID || (!explicitLanguage && looksLikeMermaid(text))
|
||||
|
||||
// Editable Mermaid shows source while the caret is focused inside the block and re-renders the
|
||||
// diagram on blur (the Linear/GitHub model). The Show source / Show diagram control drives this by
|
||||
// focusing into / blurring the block; read-only uses {@link peekSource} since there is no caret.
|
||||
useEffect(() => {
|
||||
if (!isMermaid || !editable) {
|
||||
setEditingInline(false)
|
||||
return
|
||||
}
|
||||
const sync = () => {
|
||||
const pos = typeof getPos === 'function' ? getPos() : null
|
||||
if (typeof pos !== 'number') {
|
||||
setEditingInline(false)
|
||||
return
|
||||
}
|
||||
const size = editor.state.doc.nodeAt(pos)?.nodeSize ?? 0
|
||||
const { from } = editor.state.selection
|
||||
setEditingInline(editor.isFocused && from > pos && from < pos + size)
|
||||
}
|
||||
sync()
|
||||
editor.on('selectionUpdate', sync)
|
||||
editor.on('focus', sync)
|
||||
editor.on('blur', sync)
|
||||
return () => {
|
||||
editor.off('selectionUpdate', sync)
|
||||
editor.off('focus', sync)
|
||||
editor.off('blur', sync)
|
||||
}
|
||||
}, [editor, getPos, isMermaid, editable])
|
||||
|
||||
const showSource = editable ? editingInline : peekSource
|
||||
const showDiagram = isMermaid && text.trim().length > 0 && !showSource
|
||||
|
||||
// Skip language detection on the mermaid path — the picker/label never render there.
|
||||
const language = explicitLanguage ?? (isMermaid ? null : detectLanguage(text)) ?? PLAIN
|
||||
const label =
|
||||
LANGUAGE_OPTIONS.find((option) => option.value === language)?.label ??
|
||||
explicitLanguage ??
|
||||
'Plain text'
|
||||
|
||||
const toggleSource = () => {
|
||||
if (!editable) {
|
||||
setPeekSource((value) => !value)
|
||||
return
|
||||
}
|
||||
const pos = typeof getPos === 'function' ? getPos() : null
|
||||
if (typeof pos !== 'number') return
|
||||
if (editingInline) {
|
||||
// Back to the diagram: select the whole node (reliable, and shows the same ring) rather than
|
||||
// relying on a blur event to fire.
|
||||
editor.commands.setNodeSelection(pos)
|
||||
return
|
||||
}
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.setTextSelection(pos + 1)
|
||||
.run()
|
||||
}
|
||||
|
||||
return (
|
||||
<NodeViewWrapper className='group relative'>
|
||||
<div
|
||||
className={cn(
|
||||
'absolute top-1.5 right-2 z-10 flex items-center gap-0.5 opacity-0 transition-opacity group-focus-within:opacity-100 group-hover:opacity-100',
|
||||
menuOpen && 'opacity-100'
|
||||
)}
|
||||
contentEditable={false}
|
||||
>
|
||||
{isMermaid && (
|
||||
<button
|
||||
type='button'
|
||||
aria-label={showSource ? 'Show diagram' : 'Show source'}
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onClick={toggleSource}
|
||||
className={CONTROL_CLASS}
|
||||
>
|
||||
{showSource ? <Eye /> : <Code />}
|
||||
</button>
|
||||
)}
|
||||
{!isMermaid &&
|
||||
(editable ? (
|
||||
// Editable: a language picker. Read-only: a static label — selecting a language calls
|
||||
// updateAttributes, which would mutate a doc that must not change.
|
||||
<DropdownMenu onOpenChange={setMenuOpen}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type='button'
|
||||
aria-label='Code language'
|
||||
className={cn(
|
||||
chipVariants({ variant: 'default', flush: true }),
|
||||
'h-[24px] gap-1 px-1.5 text-[var(--text-muted)] data-[state=open]:bg-[var(--surface-active)] data-[state=open]:text-[var(--text-body)]'
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
<ChevronDown className='size-[14px] text-[var(--text-icon)]' />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align='end'>
|
||||
{LANGUAGE_OPTIONS.map((option) => (
|
||||
<DropdownMenuItem
|
||||
key={option.value}
|
||||
onSelect={() =>
|
||||
updateAttributes({ language: option.value === PLAIN ? null : option.value })
|
||||
}
|
||||
>
|
||||
{option.label}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : (
|
||||
<span className='flex h-[24px] items-center px-1.5 text-[var(--text-muted)] text-caption'>
|
||||
{label}
|
||||
</span>
|
||||
))}
|
||||
{!isMermaid && editable && (
|
||||
<button
|
||||
type='button'
|
||||
aria-label='Toggle line wrap'
|
||||
aria-pressed={wrap}
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onClick={() => setWrap((value) => !value)}
|
||||
className={cn(
|
||||
CONTROL_CLASS,
|
||||
wrap && 'bg-[var(--surface-active)] text-[var(--text-body)]'
|
||||
)}
|
||||
>
|
||||
<WrapText />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type='button'
|
||||
aria-label='Copy code'
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onClick={() => copy(text)}
|
||||
className={CONTROL_CLASS}
|
||||
>
|
||||
{copied ? <Check /> : <Copy />}
|
||||
</button>
|
||||
</div>
|
||||
<pre className={cn('code-editor-theme pr-20', showDiagram && 'hidden')} data-wrap={wrap}>
|
||||
<NodeViewContent<'code'> as='code' />
|
||||
</pre>
|
||||
{showDiagram && (
|
||||
// Clicking the diagram selects the whole node (same selection ring as an image/code block)
|
||||
// instead of dropping a caret inside — preventDefault stops ProseMirror placing the caret,
|
||||
// which would otherwise flip to source. Editing is an explicit Show source / blur action.
|
||||
<div
|
||||
contentEditable={false}
|
||||
onMouseDown={(event) => {
|
||||
event.preventDefault()
|
||||
const pos = typeof getPos === 'function' ? getPos() : null
|
||||
if (typeof pos === 'number') editor.commands.setNodeSelection(pos)
|
||||
}}
|
||||
>
|
||||
<MermaidDiagram definition={text} className='mermaid-diagram-frame' />
|
||||
</div>
|
||||
)}
|
||||
</NodeViewWrapper>
|
||||
)
|
||||
}
|
||||
|
||||
function codeBlockText(node: JSONContent): string {
|
||||
return (node.content ?? []).map((child) => child.text ?? '').join('')
|
||||
}
|
||||
|
||||
/** Fence sized to one backtick longer than the longest run inside the code (CommonMark rule). */
|
||||
function fenceFor(text: string): string {
|
||||
const longestRun = Math.max(0, ...[...text.matchAll(/`+/g)].map((match) => match[0].length))
|
||||
return '`'.repeat(Math.max(3, longestRun + 1))
|
||||
}
|
||||
|
||||
/**
|
||||
* Code block whose markdown serializer sizes the fence to the interior backtick runs, so a code
|
||||
* block that itself contains a ``` line round-trips instead of shattering. Shared by the test
|
||||
* (plain) and live ({@link CodeBlockWithLanguage}) paths.
|
||||
*/
|
||||
export const MarkdownCodeBlock = CodeBlock.extend({
|
||||
renderMarkdown: (node: JSONContent) => {
|
||||
const language = typeof node.attrs?.language === 'string' ? node.attrs.language : ''
|
||||
const text = codeBlockText(node)
|
||||
const fence = fenceFor(text)
|
||||
return `${fence}${language}\n${text}\n${fence}`
|
||||
},
|
||||
})
|
||||
|
||||
/**
|
||||
* Code block with hover-revealed controls (language picker, line-wrap toggle, copy). The
|
||||
* `language` attribute drives {@link CodeBlockHighlight}'s Prism highlighting and serializes to
|
||||
* the ```lang fence on save; wrap is a view-only preference.
|
||||
*/
|
||||
export const CodeBlockWithLanguage = MarkdownCodeBlock.extend({
|
||||
addNodeView() {
|
||||
return ReactNodeViewRenderer(CodeBlockView)
|
||||
},
|
||||
})
|
||||
+81
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
import { Editor } from '@tiptap/core'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { buildDecorations, changeTouchesCodeBlock } from './code-highlight'
|
||||
import { createMarkdownContentExtensions } from './extensions'
|
||||
|
||||
let editor: Editor | null = null
|
||||
|
||||
/** Position just inside the first code block in the current editor doc. */
|
||||
function codeBlockPos(ed: Editor): number {
|
||||
let pos = -1
|
||||
ed.state.doc.descendants((node, p) => {
|
||||
if (pos === -1 && node.type.name === 'codeBlock') pos = p
|
||||
return pos === -1
|
||||
})
|
||||
if (pos === -1) throw new Error('no code block')
|
||||
return pos
|
||||
}
|
||||
|
||||
function decorationClassesFor(markdown: string): string[] {
|
||||
editor = new Editor({ extensions: createMarkdownContentExtensions() })
|
||||
editor.commands.setContent(markdown, { contentType: 'markdown' })
|
||||
const decorations = buildDecorations(editor.state.doc).find()
|
||||
editor.destroy()
|
||||
editor = null
|
||||
return decorations.map(
|
||||
(decoration) =>
|
||||
(decoration as unknown as { type: { attrs: { class: string } } }).type.attrs.class
|
||||
)
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
editor?.destroy()
|
||||
editor = null
|
||||
})
|
||||
|
||||
describe('code block syntax highlighting', () => {
|
||||
it('emits Prism token decorations for a known language', () => {
|
||||
const classes = decorationClassesFor('```js\nconst x = 1\n```')
|
||||
expect(classes.length).toBeGreaterThan(0)
|
||||
expect(classes.every((c) => c.startsWith('token'))).toBe(true)
|
||||
expect(classes.some((c) => c.includes('keyword'))).toBe(true)
|
||||
})
|
||||
|
||||
it('does not decorate plain prose', () => {
|
||||
expect(decorationClassesFor('just some text')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('does not decorate an unregistered language', () => {
|
||||
expect(decorationClassesFor('```unregistered-lang\n+++ foo\n```')).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('changeTouchesCodeBlock (incremental re-tokenization gate)', () => {
|
||||
function mount(markdown: string): Editor {
|
||||
editor = new Editor({ extensions: createMarkdownContentExtensions() })
|
||||
editor.commands.setContent(markdown, { contentType: 'markdown' })
|
||||
return editor
|
||||
}
|
||||
|
||||
it('is false when an edit lands only in prose (decorations are mapped, not rebuilt)', () => {
|
||||
const ed = mount('intro text\n\n```js\nconst x = 1\n```')
|
||||
const tr = ed.state.tr.insertText('Z', 1) // inside the leading paragraph
|
||||
expect(changeTouchesCodeBlock(tr, tr.doc)).toBe(false)
|
||||
})
|
||||
|
||||
it('is true when an edit lands inside a code block (forces a re-tokenize)', () => {
|
||||
const ed = mount('intro\n\n```js\nconst x = 1\n```')
|
||||
const tr = ed.state.tr.insertText('y', codeBlockPos(ed) + 1)
|
||||
expect(changeTouchesCodeBlock(tr, tr.doc)).toBe(true)
|
||||
})
|
||||
|
||||
it('is true when the code block language changes via setNodeMarkup', () => {
|
||||
const ed = mount('```js\nconst x = 1\n```')
|
||||
const pos = codeBlockPos(ed)
|
||||
const tr = ed.state.tr.setNodeMarkup(pos, undefined, { language: 'python' })
|
||||
expect(changeTouchesCodeBlock(tr, tr.doc)).toBe(true)
|
||||
})
|
||||
})
|
||||
+133
@@ -0,0 +1,133 @@
|
||||
import { Extension } from '@tiptap/core'
|
||||
import type { Node as ProseMirrorNode } from '@tiptap/pm/model'
|
||||
import { Plugin, PluginKey, type Transaction } from '@tiptap/pm/state'
|
||||
import { Decoration, DecorationSet } from '@tiptap/pm/view'
|
||||
import Prism, { type Token, type TokenStream } from 'prismjs'
|
||||
import 'prismjs/components/prism-bash'
|
||||
import 'prismjs/components/prism-css'
|
||||
import 'prismjs/components/prism-markup'
|
||||
import 'prismjs/components/prism-javascript'
|
||||
import 'prismjs/components/prism-typescript'
|
||||
import 'prismjs/components/prism-yaml'
|
||||
import 'prismjs/components/prism-sql'
|
||||
import 'prismjs/components/prism-python'
|
||||
import 'prismjs/components/prism-json'
|
||||
import 'prismjs/components/prism-c'
|
||||
import 'prismjs/components/prism-cpp'
|
||||
import 'prismjs/components/prism-csharp'
|
||||
import 'prismjs/components/prism-go'
|
||||
import 'prismjs/components/prism-java'
|
||||
import 'prismjs/components/prism-markup-templating'
|
||||
import 'prismjs/components/prism-php'
|
||||
import 'prismjs/components/prism-ruby'
|
||||
import 'prismjs/components/prism-rust'
|
||||
import { detectLanguage } from './detect-language'
|
||||
|
||||
const HIGHLIGHT_PLUGIN_KEY = new PluginKey('codeBlockHighlight')
|
||||
|
||||
function tokenClasses(token: Token): string {
|
||||
const classes = ['token', token.type]
|
||||
if (token.alias) classes.push(...(Array.isArray(token.alias) ? token.alias : [token.alias]))
|
||||
return classes.join(' ')
|
||||
}
|
||||
|
||||
/**
|
||||
* Walks Prism's token tree, emitting one inline decoration per token over its text range.
|
||||
* Nested tokens stack (ProseMirror nests overlapping inline decorations), reproducing the
|
||||
* `.token`-class structure Prism would render as HTML.
|
||||
*/
|
||||
function collectTokenDecorations(
|
||||
stream: TokenStream,
|
||||
base: number,
|
||||
offset: { value: number },
|
||||
decorations: Decoration[],
|
||||
limit: number
|
||||
) {
|
||||
const tokens = Array.isArray(stream) ? stream : [stream]
|
||||
for (const token of tokens) {
|
||||
if (typeof token === 'string') {
|
||||
offset.value += token.length
|
||||
continue
|
||||
}
|
||||
const start = offset.value
|
||||
collectTokenDecorations(token.content, base, offset, decorations, limit)
|
||||
const from = base + start
|
||||
const to = Math.min(base + offset.value, limit)
|
||||
if (to > from) decorations.push(Decoration.inline(from, to, { class: tokenClasses(token) }))
|
||||
}
|
||||
}
|
||||
|
||||
export function buildDecorations(doc: ProseMirrorNode): DecorationSet {
|
||||
const decorations: Decoration[] = []
|
||||
doc.descendants((node, pos) => {
|
||||
if (node.type.name !== 'codeBlock') return
|
||||
const language = (node.attrs.language as string | null) ?? detectLanguage(node.textContent)
|
||||
const grammar = language ? Prism.languages[language] : undefined
|
||||
if (!grammar) return
|
||||
// Defensive: a malformed grammar or a token/position mismatch must never throw here — a throw
|
||||
// in the decorations plugin blanks the whole editor. The `limit` clamps any over-long token.
|
||||
try {
|
||||
const base = pos + 1
|
||||
collectTokenDecorations(
|
||||
Prism.tokenize(node.textContent, grammar),
|
||||
base,
|
||||
{ value: 0 },
|
||||
decorations,
|
||||
base + node.content.size
|
||||
)
|
||||
} catch {}
|
||||
})
|
||||
return DecorationSet.create(doc, decorations)
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the transaction's changed ranges intersect any code block in the new doc — including
|
||||
* a `setNodeMarkup` language change (whose step range covers the node). When false, the cheap
|
||||
* path just maps existing decorations instead of re-tokenizing.
|
||||
*/
|
||||
export function changeTouchesCodeBlock(tr: Transaction, doc: ProseMirrorNode): boolean {
|
||||
let touches = false
|
||||
for (const map of tr.mapping.maps) {
|
||||
map.forEach((_oldStart, _oldEnd, newStart, newEnd) => {
|
||||
if (touches) return
|
||||
const from = Math.max(0, Math.min(newStart, doc.content.size))
|
||||
const to = Math.max(from, Math.min(newEnd, doc.content.size))
|
||||
doc.nodesBetween(from, to, (node) => {
|
||||
if (node.type.name === 'codeBlock') touches = true
|
||||
return !touches
|
||||
})
|
||||
})
|
||||
}
|
||||
return touches
|
||||
}
|
||||
|
||||
/**
|
||||
* Syntax-highlights fenced code blocks with Prism, emitting the same `.token` classes the
|
||||
* rest of the app uses so the `code-editor-theme` styles (light + dark) apply unchanged.
|
||||
* Re-tokenizes only when a change actually touches a code block (typing in prose just maps
|
||||
* the existing decorations), keeping the cost off the common keystroke path.
|
||||
*/
|
||||
export const CodeBlockHighlight = Extension.create({
|
||||
name: 'codeBlockHighlight',
|
||||
|
||||
addProseMirrorPlugins() {
|
||||
return [
|
||||
new Plugin({
|
||||
key: HIGHLIGHT_PLUGIN_KEY,
|
||||
state: {
|
||||
init: (_, { doc }) => buildDecorations(doc),
|
||||
apply: (tr, current) => {
|
||||
if (tr.steps.length === 0) return current
|
||||
if (!changeTouchesCodeBlock(tr, tr.doc)) return current.map(tr.mapping, tr.doc)
|
||||
return buildDecorations(tr.doc)
|
||||
},
|
||||
},
|
||||
props: {
|
||||
decorations(state) {
|
||||
return HIGHLIGHT_PLUGIN_KEY.getState(state)
|
||||
},
|
||||
},
|
||||
}),
|
||||
]
|
||||
},
|
||||
})
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*
|
||||
* Guards against drift between the code-block language picker and the Prism grammars actually
|
||||
* registered by CodeBlockHighlight: every selectable language must have a registered grammar, or it
|
||||
* would silently fall back to no highlighting.
|
||||
*/
|
||||
import Prism from 'prismjs'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { LANGUAGE_OPTIONS } from './code-block'
|
||||
// Importing the highlighter registers all the prism-* grammars as a side effect.
|
||||
import './code-highlight'
|
||||
|
||||
describe('code-block languages', () => {
|
||||
it('every selectable language has a registered Prism grammar', () => {
|
||||
for (const { value } of LANGUAGE_OPTIONS) {
|
||||
if (value === 'plain') continue
|
||||
expect(Prism.languages[value], `no Prism grammar registered for "${value}"`).toBeDefined()
|
||||
}
|
||||
})
|
||||
})
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { detectLanguage } from './detect-language'
|
||||
|
||||
describe('detectLanguage', () => {
|
||||
it('returns null for empty or unrecognizable content', () => {
|
||||
expect(detectLanguage('')).toBeNull()
|
||||
expect(detectLanguage(' \n ')).toBeNull()
|
||||
expect(detectLanguage('just some prose words here')).toBeNull()
|
||||
})
|
||||
|
||||
it('detects common languages from content shape', () => {
|
||||
expect(detectLanguage('{\n "a": 1,\n "b": [2, 3]\n}')).toBe('json')
|
||||
expect(detectLanguage('const x = 1\nfunction go() {}')).toBe('javascript')
|
||||
expect(detectLanguage('interface Foo { name: string }')).toBe('typescript')
|
||||
expect(detectLanguage('def main():\n print("hi")')).toBe('python')
|
||||
expect(detectLanguage('SELECT id FROM users WHERE id = 1')).toBe('sql')
|
||||
expect(detectLanguage('#!/bin/bash\necho hello')).toBe('bash')
|
||||
expect(detectLanguage('<div class="x">hi</div>')).toBe('markup')
|
||||
expect(detectLanguage('.btn { color: red; padding: 4px }')).toBe('css')
|
||||
})
|
||||
|
||||
it('does not misclassify a JS object as JSON', () => {
|
||||
expect(detectLanguage('const x = { a: 1 }')).toBe('javascript')
|
||||
})
|
||||
|
||||
it('detects Go, Rust, Java', () => {
|
||||
expect(detectLanguage('package main\n\nfunc main() {\n\tfmt.Println("hi")\n}')).toBe('go')
|
||||
expect(detectLanguage('type User struct {\n\tName string\n}')).toBe('go')
|
||||
expect(detectLanguage('fn main() {\n let mut x = 1;\n println!("{}", x);\n}')).toBe('rust')
|
||||
expect(detectLanguage('public class Box {\n private int n;\n}')).toBe('java')
|
||||
})
|
||||
|
||||
it('does not misread generics as HTML markup', () => {
|
||||
expect(detectLanguage('public class Box { private List<String> items; }')).toBe('java')
|
||||
expect(detectLanguage('let v: Vec<String> = Vec::new();\nfn f() {}')).toBe('rust')
|
||||
expect(detectLanguage('func Map[T any](s []T) {}\npackage x')).toBe('go')
|
||||
})
|
||||
})
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* Heuristic language detection for a fenced code block that has no explicit ` ```lang ` tag.
|
||||
* Used only to drive syntax highlighting + the picker label — the detected value is NEVER
|
||||
* written back to the markdown, so opening a file never mutates it. Restricted to the grammars
|
||||
* {@link CodeBlockHighlight} actually registers with Prism; returns `null` when unsure.
|
||||
*/
|
||||
const DETECTORS: ReadonlyArray<{ language: string; test: RegExp }> = [
|
||||
// Real HTML: a closing tag, an opening tag with an attribute, or a doctype/comment. Deliberately
|
||||
// NOT a bare `<Word>` so generics (`List<String>`, `Vec<T>`) aren't misread as markup.
|
||||
{ language: 'markup', test: /<\/[a-z][\w-]*\s*>|<[a-z][\w-]*\s+[\w:-]+=|<!(?:doctype\b|--)/i },
|
||||
{
|
||||
language: 'sql',
|
||||
test: /\b(?:select\s+[\w*]|insert\s+into|update\s+\w+\s+set|delete\s+from|create\s+table)/i,
|
||||
},
|
||||
{ language: 'python', test: /^\s*(def|class)\s+\w+|^\s*(import|from)\s+\w|\bprint\(|\belif\b/m },
|
||||
{
|
||||
language: 'bash',
|
||||
test: /^#!.*\b(ba)?sh\b|^\s*(sudo|apt|brew|npm|yarn|bun|git|cd|echo|export|chmod|mkdir)\s|\$\(/m,
|
||||
},
|
||||
{
|
||||
language: 'go',
|
||||
test: /^\s*package\s+\w+|\bfunc\s+(\(\w[^)]*\)\s+)?\w+\s*\(|\btype\s+\w+\s+(struct|interface)\b|\bfmt\.\w|:=/m,
|
||||
},
|
||||
{
|
||||
language: 'rust',
|
||||
test: /\bfn\s+\w+\s*[(<]|\blet\s+mut\b|\bimpl\b|\bpub\s+(fn|struct|enum|mod)\b|println!/,
|
||||
},
|
||||
{
|
||||
language: 'java',
|
||||
test: /\b(public|private|protected)\s+(static\s+)?(final\s+)?(class|void|int|String|boolean)\b|System\.out\.print/,
|
||||
},
|
||||
{
|
||||
language: 'typescript',
|
||||
test: /\b(interface|type)\s+\w+\s*[={]|:\s*(string|number|boolean)\b|\bimport\s+type\b|\bas\s+\w+\s*;/,
|
||||
},
|
||||
{
|
||||
language: 'javascript',
|
||||
test: /\b(const|let|var|function)\s|=>|console\.\w+|\brequire\(|\bexport\s+(default|const)\b/,
|
||||
},
|
||||
{ language: 'css', test: /[.#]?[\w-]+\s*\{[^}]*[\w-]+\s*:[^};]+;?[^}]*\}/ },
|
||||
{ language: 'yaml', test: /^[\w-]+:\s+\S/m },
|
||||
]
|
||||
|
||||
function looksLikeJson(sample: string): boolean {
|
||||
const trimmed = sample.trim()
|
||||
if (!/^[[{]/.test(trimmed)) return false
|
||||
try {
|
||||
JSON.parse(trimmed)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export function detectLanguage(code: string): string | null {
|
||||
const sample = code.slice(0, 2000)
|
||||
if (!sample.trim()) return null
|
||||
if (looksLikeJson(sample)) return 'json'
|
||||
for (const { language, test } of DETECTORS) {
|
||||
if (test.test(sample)) return language
|
||||
}
|
||||
return null
|
||||
}
|
||||
+106
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*
|
||||
* Regression guards for two bugs found while adding the `@` mention menu:
|
||||
*
|
||||
* 1. The `@` mention and `/` slash-command extensions each register a `@tiptap/suggestion` plugin.
|
||||
* They must use distinct plugin keys, or constructing any editor with the full set throws
|
||||
* "Adding different instances of a keyed plugin (suggestion$)".
|
||||
*
|
||||
* 2. A markdown file authored outside the editor (e.g. the former Monaco editor) is rarely in the
|
||||
* editor's canonical serialization. On open, a deferred view-plugin transaction re-serializes the
|
||||
* doc to canonical markdown and emits one update — which, compared against the raw saved bytes,
|
||||
* falsely marks the file dirty ("unsaved changes"). The fix normalizes the dirty-check baseline to
|
||||
* the canonical form; this asserts that normalized form equals what the live editor emits.
|
||||
*/
|
||||
|
||||
import { sleep } from '@sim/utils/helpers'
|
||||
import { Editor } from '@tiptap/core'
|
||||
import { afterEach, beforeAll, describe, expect, it } from 'vitest'
|
||||
import { createMarkdownEditorExtensions } from './editor-extensions'
|
||||
import {
|
||||
applyFrontmatter,
|
||||
postProcessSerializedMarkdown,
|
||||
splitFrontmatter,
|
||||
} from './markdown-fidelity'
|
||||
import { parseMarkdownToDoc } from './markdown-parse'
|
||||
import { normalizeMarkdownContent } from './normalize-content'
|
||||
|
||||
let editor: Editor | null = null
|
||||
let host: HTMLElement | null = null
|
||||
|
||||
beforeAll(() => {
|
||||
// jsdom lacks the layout APIs the Placeholder viewport plugin calls when a view mounts.
|
||||
// @ts-expect-error jsdom stub
|
||||
document.elementFromPoint = () => document.body
|
||||
// @ts-expect-error jsdom stub
|
||||
Range.prototype.getClientRects = () => [] as unknown as DOMRectList
|
||||
Range.prototype.getBoundingClientRect = () => new DOMRect()
|
||||
Element.prototype.getClientRects = () => [] as unknown as DOMRectList
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
editor?.destroy()
|
||||
editor = null
|
||||
host?.remove()
|
||||
host = null
|
||||
})
|
||||
|
||||
describe('full extension set', () => {
|
||||
it('mounts without a duplicate suggestion-plugin-key error (@ and / coexist)', () => {
|
||||
expect(() => {
|
||||
editor = new Editor({
|
||||
extensions: createMarkdownEditorExtensions({ placeholder: 'x' }),
|
||||
content: '',
|
||||
})
|
||||
}).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('normalizeMarkdownContent — dirty-on-open baseline', () => {
|
||||
it('normalizes non-canonical markdown to the editor canonical form', () => {
|
||||
expect(normalizeMarkdownContent('* one\n* two\n')).toBe('- one\n- two\n')
|
||||
})
|
||||
|
||||
it('is idempotent', () => {
|
||||
for (const md of [
|
||||
'* one\n* two\n',
|
||||
'| a | b |\n| --- | --- |\n| 1 | 2 |\n',
|
||||
'# H\n\nsome _emphasis_ here\n',
|
||||
]) {
|
||||
const once = normalizeMarkdownContent(md)
|
||||
expect(normalizeMarkdownContent(once)).toBe(once)
|
||||
}
|
||||
})
|
||||
|
||||
it('leaves round-trip-unsafe content untouched (read-only files keep their raw bytes)', () => {
|
||||
const unsafe = 'text with a footnote[^1]\n\n[^1]: the note\n'
|
||||
expect(normalizeMarkdownContent(unsafe)).toBe(unsafe)
|
||||
})
|
||||
})
|
||||
|
||||
describe('baseline neutralizes the mount-time dirty signal', () => {
|
||||
it('the editor mount serialization equals the normalized baseline (so isDirty stays false)', async () => {
|
||||
const raw = '# H\n\n* bullet\n\n| a | b |\n| --- | --- |\n| 1 | 2 |\n\n> quote\n'
|
||||
const { frontmatter, body } = splitFrontmatter(raw)
|
||||
host = document.createElement('div')
|
||||
document.body.appendChild(host)
|
||||
|
||||
let emitted: string | null = null
|
||||
editor = new Editor({
|
||||
element: host,
|
||||
extensions: createMarkdownEditorExtensions({ placeholder: 'x' }),
|
||||
content: parseMarkdownToDoc(body),
|
||||
onUpdate: ({ editor }) => {
|
||||
emitted = applyFrontmatter(frontmatter, postProcessSerializedMarkdown(editor.getMarkdown()))
|
||||
},
|
||||
})
|
||||
|
||||
await sleep(30)
|
||||
|
||||
// The deferred mount transaction re-serializes to canonical markdown; the baseline must match it
|
||||
// exactly, so `content === savedContent` and the file is never falsely dirty on open.
|
||||
expect(emitted).not.toBeNull()
|
||||
expect(emitted).toBe(normalizeMarkdownContent(raw))
|
||||
})
|
||||
})
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*
|
||||
* The rich editor uses TipTap's initial-content model: opening a file loads its markdown as the
|
||||
* editor's initial `content`, which must NOT emit an update — so a freshly opened file is never
|
||||
* marked dirty (no spurious autosave / "unsaved changes"). Only a genuine edit emits, which is what
|
||||
* flips the dirty/autosave state on. These two cases guard exactly that contract.
|
||||
*/
|
||||
import { Editor } from '@tiptap/core'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { createMarkdownContentExtensions } from './extensions'
|
||||
|
||||
let editor: Editor | null = null
|
||||
afterEach(() => {
|
||||
editor?.destroy()
|
||||
editor = null
|
||||
})
|
||||
|
||||
function mount(content: string, onUpdate: () => void): Editor {
|
||||
return new Editor({
|
||||
extensions: createMarkdownContentExtensions(),
|
||||
content,
|
||||
contentType: 'markdown',
|
||||
onUpdate,
|
||||
})
|
||||
}
|
||||
|
||||
describe('rich markdown editor — dirty signal', () => {
|
||||
it('opening a file emits no update (never dirty on open), including markdown that normalizes', () => {
|
||||
// A trailing newline and `_emphasis_` both normalize on serialization; opening must still be clean.
|
||||
let updates = 0
|
||||
editor = mount('# Title\n\nsome _emphasis_ here\n', () => {
|
||||
updates++
|
||||
})
|
||||
expect(updates).toBe(0)
|
||||
expect(editor.isEmpty).toBe(false)
|
||||
})
|
||||
|
||||
it('opening an empty file emits no update and is editable', () => {
|
||||
let updates = 0
|
||||
editor = mount('', () => {
|
||||
updates++
|
||||
})
|
||||
expect(updates).toBe(0)
|
||||
})
|
||||
|
||||
it('a genuine edit emits an update (marks dirty → triggers autosave)', () => {
|
||||
let updates = 0
|
||||
editor = mount('hello', () => {
|
||||
updates++
|
||||
})
|
||||
editor.commands.insertContent(' world')
|
||||
expect(updates).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
import type { Extensions } from '@tiptap/core'
|
||||
import Placeholder from '@tiptap/extension-placeholder'
|
||||
import { BlockMover } from './block-mover'
|
||||
import { CodeBlockWithLanguage } from './code-block'
|
||||
import { CodeBlockHighlight } from './code-highlight'
|
||||
import { LinkEmbed } from './embed/link-embed'
|
||||
import { createMarkdownContentExtensions } from './extensions'
|
||||
import { ResizableImage } from './image'
|
||||
import { RichMarkdownKeymap } from './keymap'
|
||||
import { MarkdownPaste } from './markdown-paste'
|
||||
import { Mention } from './mention/mention'
|
||||
import { MentionChip } from './mention/mention-chip'
|
||||
import { FootnoteDefWithView, RawHtmlBlockWithView } from './raw-markdown-snippet'
|
||||
import { SlashCommand } from './slash-command/slash-command'
|
||||
|
||||
interface MarkdownEditorExtensionOptions {
|
||||
placeholder: string
|
||||
/** Renders supported media links as live players beneath a standalone link. Off by default. */
|
||||
embeds?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* The full extension set for the live editor: the content extensions with their React node-view nodes
|
||||
* injected (code-block language picker, resizable image, mention chip) plus the UI-only extensions —
|
||||
* `CodeBlockHighlight` (Prism), `SlashCommand` (the `/` block menu), `Mention` (the `@` menu),
|
||||
* `RichMarkdownKeymap`, `MarkdownPaste`, `Placeholder`, and — when `embeds` is set — `LinkEmbed`
|
||||
* (media players for standalone links).
|
||||
*
|
||||
* Kept separate from `extensions.ts` so those node views (and the block registry the mention chip pulls
|
||||
* in for brand icons) stay out of the headless round-trip path, which only needs the schema.
|
||||
*/
|
||||
export function createMarkdownEditorExtensions({
|
||||
placeholder,
|
||||
embeds = false,
|
||||
}: MarkdownEditorExtensionOptions): Extensions {
|
||||
return [
|
||||
...createMarkdownContentExtensions({
|
||||
codeBlock: CodeBlockWithLanguage,
|
||||
image: ResizableImage,
|
||||
mention: MentionChip,
|
||||
rawHtmlBlock: RawHtmlBlockWithView,
|
||||
footnoteDef: FootnoteDefWithView,
|
||||
}),
|
||||
CodeBlockHighlight,
|
||||
SlashCommand,
|
||||
Mention,
|
||||
RichMarkdownKeymap,
|
||||
BlockMover,
|
||||
MarkdownPaste,
|
||||
Placeholder.configure({ placeholder }),
|
||||
...(embeds ? [LinkEmbed] : []),
|
||||
]
|
||||
}
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
import type { EmbedInfo } from '@sim/utils/media-embed'
|
||||
|
||||
/**
|
||||
* Iframes are rendered at native size then CSS-scaled down so embedded players keep their
|
||||
* intended layout inside the editor's reading column. Mirrors the note-block renderer.
|
||||
*/
|
||||
const EMBED_SCALE = 0.78
|
||||
const EMBED_INVERSE_SCALE = `${(1 / EMBED_SCALE) * 100}%`
|
||||
|
||||
const IFRAME_ALLOW =
|
||||
'accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share'
|
||||
|
||||
/**
|
||||
* Build the DOM player for a resolved {@link EmbedInfo}, matching the note-block renderer's
|
||||
* markup. Returned as a non-editable element so it can back a ProseMirror widget decoration
|
||||
* without entering the editable content.
|
||||
*/
|
||||
export function createEmbedDom(embedInfo: EmbedInfo): HTMLElement {
|
||||
const container = document.createElement('div')
|
||||
container.className = 'my-2 block w-full overflow-hidden rounded-md'
|
||||
container.contentEditable = 'false'
|
||||
|
||||
if (embedInfo.type === 'iframe') {
|
||||
const frame = document.createElement('div')
|
||||
frame.className = 'block overflow-hidden'
|
||||
frame.style.width = '100%'
|
||||
frame.style.aspectRatio = embedInfo.aspectRatio || '16/9'
|
||||
|
||||
const iframe = document.createElement('iframe')
|
||||
iframe.src = embedInfo.url
|
||||
iframe.title = 'Media'
|
||||
iframe.allow = IFRAME_ALLOW
|
||||
iframe.allowFullscreen = true
|
||||
iframe.loading = 'lazy'
|
||||
iframe.className = 'origin-top-left'
|
||||
iframe.style.width = EMBED_INVERSE_SCALE
|
||||
iframe.style.height = EMBED_INVERSE_SCALE
|
||||
iframe.style.transform = `scale(${EMBED_SCALE})`
|
||||
|
||||
frame.appendChild(iframe)
|
||||
container.appendChild(frame)
|
||||
return container
|
||||
}
|
||||
|
||||
if (embedInfo.type === 'video') {
|
||||
const video = document.createElement('video')
|
||||
video.src = embedInfo.url
|
||||
video.controls = true
|
||||
video.preload = 'metadata'
|
||||
video.className = 'aspect-video w-full'
|
||||
container.appendChild(video)
|
||||
return container
|
||||
}
|
||||
|
||||
const audio = document.createElement('audio')
|
||||
audio.src = embedInfo.url
|
||||
audio.controls = true
|
||||
audio.preload = 'metadata'
|
||||
audio.className = 'w-full'
|
||||
container.appendChild(audio)
|
||||
return container
|
||||
}
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
import { Editor } from '@tiptap/core'
|
||||
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
|
||||
import { createMarkdownEditorExtensions } from '../editor-extensions'
|
||||
|
||||
// jsdom lacks elementFromPoint, which TipTap's Placeholder viewport tracking calls on mount.
|
||||
beforeAll(() => {
|
||||
document.elementFromPoint = vi.fn(() => null)
|
||||
})
|
||||
|
||||
let editor: Editor | null = null
|
||||
|
||||
function editorWith(content: string, embeds = true): Editor {
|
||||
editor = new Editor({
|
||||
extensions: createMarkdownEditorExtensions({ placeholder: '', embeds }),
|
||||
content,
|
||||
})
|
||||
return editor
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
editor?.destroy()
|
||||
editor = null
|
||||
})
|
||||
|
||||
const YOUTUBE_LINK = '<p><a href="https://www.youtube.com/watch?v=dQw4w9WgXcQ">watch</a></p>'
|
||||
|
||||
describe('LinkEmbed', () => {
|
||||
it('renders a player beneath a standalone embeddable link', () => {
|
||||
const view = editorWith(YOUTUBE_LINK).view
|
||||
const iframe = view.dom.querySelector('iframe')
|
||||
expect(iframe?.getAttribute('src')).toBe('https://www.youtube.com/embed/dQw4w9WgXcQ')
|
||||
})
|
||||
|
||||
it('renders one player per link when the same URL appears twice', () => {
|
||||
const view = editorWith(`${YOUTUBE_LINK}${YOUTUBE_LINK}`).view
|
||||
expect(view.dom.querySelectorAll('iframe')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('keeps the underlying document a plain markdown link (lossless round-trip)', () => {
|
||||
const markdown = editorWith(YOUTUBE_LINK).getMarkdown()
|
||||
expect(markdown).toContain('https://www.youtube.com/watch?v=dQw4w9WgXcQ')
|
||||
expect(markdown).not.toContain('<iframe')
|
||||
})
|
||||
|
||||
it('does not embed an inline link inside surrounding text', () => {
|
||||
const view = editorWith(
|
||||
'<p>see <a href="https://www.youtube.com/watch?v=dQw4w9WgXcQ">here</a> now</p>'
|
||||
).view
|
||||
expect(view.dom.querySelector('iframe')).toBeNull()
|
||||
})
|
||||
|
||||
it('does not embed a non-embeddable standalone link', () => {
|
||||
const view = editorWith('<p><a href="https://example.com/article">read</a></p>').view
|
||||
expect(view.dom.querySelector('iframe')).toBeNull()
|
||||
})
|
||||
|
||||
it('does nothing when the embeds option is disabled', () => {
|
||||
const view = editorWith(YOUTUBE_LINK, false).view
|
||||
expect(view.dom.querySelector('iframe')).toBeNull()
|
||||
})
|
||||
})
|
||||
+88
@@ -0,0 +1,88 @@
|
||||
import { getEmbedInfo } from '@sim/utils/media-embed'
|
||||
import { Extension } from '@tiptap/core'
|
||||
import type { Node as ProseMirrorNode } from '@tiptap/pm/model'
|
||||
import { Plugin, PluginKey } from '@tiptap/pm/state'
|
||||
import { Decoration, DecorationSet } from '@tiptap/pm/view'
|
||||
import { createEmbedDom } from './embed-dom'
|
||||
|
||||
const LINK_EMBED_PLUGIN_KEY = new PluginKey('linkEmbed')
|
||||
|
||||
/**
|
||||
* The href of a paragraph that is a single, whole-text link (a "standalone link"), or null if
|
||||
* the paragraph is empty, holds non-text content, or mixes a link with other text. Only
|
||||
* standalone links become media embeds — a link inline within a sentence stays a plain link,
|
||||
* matching how Notion and Linear auto-embed.
|
||||
*/
|
||||
function getStandaloneLinkHref(paragraph: ProseMirrorNode): string | null {
|
||||
if (paragraph.childCount === 0) return null
|
||||
let href: string | null = null
|
||||
let isStandalone = true
|
||||
paragraph.forEach((child) => {
|
||||
if (!isStandalone) return
|
||||
const linkMark = child.isText
|
||||
? child.marks.find((mark) => mark.type.name === 'link')
|
||||
: undefined
|
||||
if (!linkMark) {
|
||||
isStandalone = false
|
||||
return
|
||||
}
|
||||
const childHref = linkMark.attrs.href as string
|
||||
if (href === null) href = childHref
|
||||
else if (href !== childHref) isStandalone = false
|
||||
})
|
||||
return isStandalone ? href : null
|
||||
}
|
||||
|
||||
function buildDecorations(doc: ProseMirrorNode): DecorationSet {
|
||||
const decorations: Decoration[] = []
|
||||
/** Per-source occurrence count, so repeated embeds of the same URL get distinct, stable keys. */
|
||||
const sourceCounts = new Map<string, number>()
|
||||
doc.descendants((node, pos) => {
|
||||
if (node.type.name !== 'paragraph') return undefined
|
||||
const href = getStandaloneLinkHref(node)
|
||||
if (href) {
|
||||
const embedInfo = getEmbedInfo(href)
|
||||
if (embedInfo) {
|
||||
const source = `embed:${embedInfo.type}:${embedInfo.url}`
|
||||
const index = sourceCounts.get(source) ?? 0
|
||||
sourceCounts.set(source, index + 1)
|
||||
decorations.push(
|
||||
Decoration.widget(pos + node.nodeSize, () => createEmbedDom(embedInfo), {
|
||||
side: 1,
|
||||
key: `${source}:${index}`,
|
||||
})
|
||||
)
|
||||
}
|
||||
}
|
||||
// Paragraphs hold only inline content, so there is nothing more to descend into.
|
||||
return false
|
||||
})
|
||||
return DecorationSet.create(doc, decorations)
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders supported media links (YouTube, Vimeo, Spotify, Dropbox, …) as live players beneath a
|
||||
* standalone link, in both the editing and read-only surfaces. Implemented as widget decorations
|
||||
* so the underlying document stays a plain markdown link — embeds never enter the schema or the
|
||||
* serialized markdown, keeping round-trips lossless.
|
||||
*/
|
||||
export const LinkEmbed = Extension.create({
|
||||
name: 'linkEmbed',
|
||||
|
||||
addProseMirrorPlugins() {
|
||||
return [
|
||||
new Plugin({
|
||||
key: LINK_EMBED_PLUGIN_KEY,
|
||||
state: {
|
||||
init: (_, { doc }) => buildDecorations(doc),
|
||||
apply: (tr, current) => (tr.docChanged ? buildDecorations(tr.doc) : current),
|
||||
},
|
||||
props: {
|
||||
decorations(state) {
|
||||
return LINK_EMBED_PLUGIN_KEY.getState(state)
|
||||
},
|
||||
},
|
||||
}),
|
||||
]
|
||||
},
|
||||
})
|
||||
+151
@@ -0,0 +1,151 @@
|
||||
import type { Extensions, JSONContent, MarkdownRendererHelpers, Node } from '@tiptap/core'
|
||||
import { Code } from '@tiptap/extension-code'
|
||||
import { TaskItem, TaskList } from '@tiptap/extension-list'
|
||||
import { Paragraph } from '@tiptap/extension-paragraph'
|
||||
import {
|
||||
renderTableToMarkdown,
|
||||
Table,
|
||||
TableCell,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@tiptap/extension-table'
|
||||
import { Markdown } from '@tiptap/markdown'
|
||||
import StarterKit from '@tiptap/starter-kit'
|
||||
import { MarkdownCodeBlock } from './code-block'
|
||||
import { Highlight } from './highlight'
|
||||
import { MarkdownImage } from './image'
|
||||
import { MarkdownLinkInputRule } from './link-input-rule'
|
||||
import { MarkdownMention } from './mention/mention-node'
|
||||
import { SIM_LINK_SCHEME } from './mention/sim-link'
|
||||
import { FootnoteDef, FootnoteRef, RawHtmlBlock, RawInlineHtml } from './raw-markdown-snippet'
|
||||
|
||||
/**
|
||||
* The `@`-mention link scheme, registered on the Link mark — without it the schema strips the
|
||||
* `sim:<kind>/<id>` href on parse/round-trip, dropping the mention. `optionalSlashes` allows the
|
||||
* slash-less `sim:kind/id` form.
|
||||
*/
|
||||
const SIM_LINK_PROTOCOL = { scheme: SIM_LINK_SCHEME, optionalSlashes: true } as const
|
||||
|
||||
/**
|
||||
* Inline code that can combine with bold/italic/strike (GFM permits `**`x`**`, `~~`x`~~`).
|
||||
* The stock Code mark sets `excludes: '_'`, which blocks every other mark from coexisting and
|
||||
* makes the bubble-menu toggles silently no-op over a code selection.
|
||||
*/
|
||||
const InlineCode = Code.extend({ excludes: '' })
|
||||
|
||||
/**
|
||||
* Table that escapes interior `|` characters when serializing cells. The upstream serializer
|
||||
* joins cells with `|` without escaping, so a cell containing a literal pipe silently splits
|
||||
* into phantom columns on round-trip (data loss). Escaping must happen on the `table` node —
|
||||
* `tableCell`/`tableHeader` have no markdown renderer; the table renders cell children directly. Only
|
||||
* `|` is escaped — `renderChildren` already escapes backslashes, so escaping them again would
|
||||
* double-escape and break round-trip idempotency (CodeQL's "missing backslash escape" is a false
|
||||
* positive here; covered by the table round-trip tests).
|
||||
*
|
||||
* The upstream serializer also wraps the table in its own leading/trailing blank lines; left in,
|
||||
* the block joiner adds another, so an interior table churns its surrounding whitespace to
|
||||
* `\n\n\n` on the first edit. Trimming the table's own output lets the joiner own the single
|
||||
* blank-line separator — without touching blank lines inside fenced code (those live in the code
|
||||
* node's text, not here).
|
||||
*/
|
||||
const PipeSafeTable = Table.extend({
|
||||
renderMarkdown: (node: JSONContent, h: MarkdownRendererHelpers) =>
|
||||
renderTableToMarkdown(node, {
|
||||
...h,
|
||||
renderChildren: (nodes, separator) =>
|
||||
h.renderChildren(nodes, separator).replace(/\|/g, '\\|'),
|
||||
})
|
||||
.replace(/^\n+/, '')
|
||||
.replace(/\n+$/, ''),
|
||||
})
|
||||
|
||||
/**
|
||||
* Guards a paragraph's serialized text so its leading characters don't re-parse it into a different
|
||||
* block on the next load:
|
||||
*
|
||||
* - **Leading whitespace** is stripped. It never renders in a paragraph (CommonMark strips up to three
|
||||
* leading spaces, and four or more would re-parse the paragraph as an indented code block), so
|
||||
* removing it is lossless and makes the round-trip idempotent.
|
||||
* - **A leading block marker** (`#`, `-`, `+`, `1.`, `1)`, or a bare `---`) is backslash-escaped so the
|
||||
* paragraph doesn't become a heading / list / thematic break. The upstream serializer escapes inline
|
||||
* delimiters (`* _ \` [ ] ~`, so `*` bullets and `>` quotes already round-trip) but not these
|
||||
* block-starting markers. Escaping is idempotent: parsing consumes the backslash, so the stored
|
||||
* ProseMirror text never carries it and re-serialization is stable.
|
||||
*/
|
||||
function guardParagraphLeading(text: string): string {
|
||||
const stripped = text.replace(/^[ \t]+/, '')
|
||||
if (/^(#{1,6}([ \t]|$)|[-+][ \t]|-(?:[ \t]*-){2,}[ \t]*$)/.test(stripped)) {
|
||||
return `\\${stripped}`
|
||||
}
|
||||
const ordered = /^(\d{1,9})([.)][ \t])/.exec(stripped)
|
||||
return ordered ? `${ordered[1]}\\${stripped.slice(ordered[1].length)}` : stripped
|
||||
}
|
||||
|
||||
/**
|
||||
* Paragraph that guards its leading characters on serialize (see {@link guardParagraphLeading}) —
|
||||
* otherwise a paragraph beginning with a block marker or an indent silently becomes a heading / list /
|
||||
* thematic break / code block on the next load. Block separators are owned by the parent joiner, so a
|
||||
* paragraph renders as just its inline children; this override wraps that with the leading guard.
|
||||
*/
|
||||
const BlockSafeParagraph = Paragraph.extend({
|
||||
renderMarkdown: (node: JSONContent, h: MarkdownRendererHelpers) =>
|
||||
guardParagraphLeading(h.renderChildren(node.content ?? [])),
|
||||
})
|
||||
|
||||
/**
|
||||
* Node-view variants the live editor injects in place of the headless defaults — the code-block
|
||||
* language picker, the resizable image, and the mention chip. The mention chip pulls the block registry
|
||||
* (for brand icons), so the headless round-trip path omits it: passing nothing keeps
|
||||
* {@link createMarkdownContentExtensions} free of the registry and constructs no React node views.
|
||||
*/
|
||||
export interface ContentNodeViews {
|
||||
codeBlock?: Node
|
||||
image?: Node
|
||||
mention?: Node
|
||||
rawHtmlBlock?: Node
|
||||
footnoteDef?: Node
|
||||
}
|
||||
|
||||
/**
|
||||
* The schema + serialization extensions: the nodes/marks the document can contain and the
|
||||
* Markdown ⇄ ProseMirror conversion. `StarterKit` provides core nodes/marks and the
|
||||
* Markdown-style input rules (`# `, `- `, `**bold**`, …); `TaskList`/`TaskItem` add
|
||||
* `- [ ]` checklists; `TableKit` adds GFM tables; `Markdown` serializes back to markdown.
|
||||
*
|
||||
* Headless by default (the `nodeViews` overrides are empty), so importing this module — e.g. for the
|
||||
* markdown round-trip in `markdown-parse.ts` — never constructs React node views or pulls the block
|
||||
* registry. The live editor passes the node-view nodes via {@link createMarkdownEditorExtensions}; the
|
||||
* schema and markdown output are identical either way.
|
||||
*/
|
||||
export function createMarkdownContentExtensions(nodeViews: ContentNodeViews = {}): Extensions {
|
||||
const codeBlock = (nodeViews.codeBlock ?? MarkdownCodeBlock).configure({
|
||||
HTMLAttributes: { class: 'code-editor-theme' },
|
||||
})
|
||||
return [
|
||||
StarterKit.configure({
|
||||
link: { openOnClick: false, protocols: [SIM_LINK_PROTOCOL] },
|
||||
underline: false,
|
||||
codeBlock: false,
|
||||
code: false,
|
||||
paragraph: false,
|
||||
}),
|
||||
BlockSafeParagraph,
|
||||
InlineCode,
|
||||
Highlight,
|
||||
codeBlock,
|
||||
(nodeViews.image ?? MarkdownImage).configure({ allowBase64: true }),
|
||||
nodeViews.mention ?? MarkdownMention,
|
||||
TaskList,
|
||||
TaskItem.configure({ nested: true }),
|
||||
PipeSafeTable.configure({ resizable: true }),
|
||||
TableRow,
|
||||
TableHeader,
|
||||
TableCell,
|
||||
nodeViews.rawHtmlBlock ?? RawHtmlBlock,
|
||||
nodeViews.footnoteDef ?? FootnoteDef,
|
||||
FootnoteRef,
|
||||
RawInlineHtml,
|
||||
MarkdownLinkInputRule,
|
||||
Markdown,
|
||||
]
|
||||
}
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
import { Editor } from '@tiptap/core'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { createMarkdownContentExtensions } from './extensions'
|
||||
import { findHeadingPos, slugifyHeading } from './heading-anchors'
|
||||
|
||||
let editor: Editor | null = null
|
||||
afterEach(() => {
|
||||
editor?.destroy()
|
||||
editor = null
|
||||
})
|
||||
|
||||
/** A ProseMirror doc parsed from markdown, for the position-resolution tests. */
|
||||
function docOf(markdown: string) {
|
||||
editor = new Editor({ extensions: createMarkdownContentExtensions() })
|
||||
editor.commands.setContent(markdown, { contentType: 'markdown' })
|
||||
return editor.state.doc
|
||||
}
|
||||
|
||||
describe('slugifyHeading', () => {
|
||||
it('lowercases, drops punctuation, and hyphenates whitespace (GitHub-style)', () => {
|
||||
expect(slugifyHeading('Getting Started')).toBe('getting-started')
|
||||
expect(slugifyHeading('API Reference!')).toBe('api-reference')
|
||||
expect(slugifyHeading(' Spaced Out ')).toBe('spaced-out')
|
||||
expect(slugifyHeading('Node.js & Bun')).toBe('nodejs-bun')
|
||||
})
|
||||
|
||||
it('returns an empty string for punctuation-only text', () => {
|
||||
expect(slugifyHeading('!!!')).toBe('')
|
||||
expect(slugifyHeading('')).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('findHeadingPos', () => {
|
||||
it('resolves a fragment slug to its heading position', () => {
|
||||
const doc = docOf('# Intro\n\ntext\n\n## Getting Started\n\nmore')
|
||||
expect(findHeadingPos(doc, 'intro')).toBeGreaterThanOrEqual(0)
|
||||
expect(findHeadingPos(doc, 'getting-started')).toBeGreaterThan(findHeadingPos(doc, 'intro'))
|
||||
})
|
||||
|
||||
it('disambiguates duplicate slugs GitHub-style (foo, foo-1, foo-2)', () => {
|
||||
const doc = docOf('# Notes\n\na\n\n# Notes\n\nb\n\n# Notes\n\nc')
|
||||
const first = findHeadingPos(doc, 'notes')
|
||||
const second = findHeadingPos(doc, 'notes-1')
|
||||
const third = findHeadingPos(doc, 'notes-2')
|
||||
expect(first).toBeGreaterThanOrEqual(0)
|
||||
expect(second).toBeGreaterThan(first)
|
||||
expect(third).toBeGreaterThan(second)
|
||||
})
|
||||
|
||||
it('returns -1 when no heading matches', () => {
|
||||
const doc = docOf('# Only Heading\n\nbody')
|
||||
expect(findHeadingPos(doc, 'missing')).toBe(-1)
|
||||
})
|
||||
})
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
import type { Node as ProseMirrorNode } from '@tiptap/pm/model'
|
||||
|
||||
/**
|
||||
* Slugify heading text GitHub-style (lowercase, drop punctuation, collapse whitespace to hyphens) so
|
||||
* that `[label](#slug)` fragment links — written against how GitHub renders the same markdown —
|
||||
* resolve to the matching heading. Mirrors what `rehype-slug` produced in the old preview.
|
||||
*/
|
||||
export function slugifyHeading(text: string): string {
|
||||
return text
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.replace(/[^\w\s-]/g, '')
|
||||
.replace(/\s+/g, '-')
|
||||
.replace(/-+/g, '-')
|
||||
}
|
||||
|
||||
/**
|
||||
* The document position of the heading a `#slug` fragment link targets, or -1 if none matches.
|
||||
* Computed on demand (at click time) rather than maintained as per-keystroke decorations. Duplicate
|
||||
* slugs are disambiguated GitHub-style: `intro`, `intro-1`, `intro-2`, …
|
||||
*/
|
||||
export function findHeadingPos(doc: ProseMirrorNode, slug: string): number {
|
||||
const seen = new Map<string, number>()
|
||||
let found = -1
|
||||
doc.descendants((node, pos) => {
|
||||
if (found >= 0) return false
|
||||
if (node.type.name !== 'heading') return true
|
||||
const base = slugifyHeading(node.textContent)
|
||||
if (!base) return true
|
||||
const n = seen.get(base) ?? 0
|
||||
seen.set(base, n + 1)
|
||||
if ((n === 0 ? base : `${base}-${n}`) === slug) found = pos
|
||||
return found < 0
|
||||
})
|
||||
return found
|
||||
}
|
||||
+105
@@ -0,0 +1,105 @@
|
||||
import type {
|
||||
JSONContent,
|
||||
MarkdownParseHelpers,
|
||||
MarkdownRendererHelpers,
|
||||
MarkdownToken,
|
||||
} from '@tiptap/core'
|
||||
import { Mark, markInputRule, markPasteRule, mergeAttributes } from '@tiptap/core'
|
||||
import type { Transaction } from '@tiptap/pm/state'
|
||||
import { Plugin } from '@tiptap/pm/state'
|
||||
|
||||
/**
|
||||
* `==text==` with non-space edges — the Pandoc/Obsidian highlight syntax. The body allows a lone `=`
|
||||
* (`=(?!=)`) but never `==`, so a highlight over text containing `=` (e.g. `==a=b==`) round-trips while
|
||||
* the closing `==` still terminates the run.
|
||||
*/
|
||||
const HIGHLIGHT_BODY = `(?:[^=]|=(?!=))+?`
|
||||
const HIGHLIGHT_TOKEN = new RegExp(String.raw`^==(?!\s)(${HIGHLIGHT_BODY})(?<!\s)==`)
|
||||
/** Input/paste rule form (anchored on a preceding boundary) so typing `==x==` toggles the mark. */
|
||||
const HIGHLIGHT_INPUT = new RegExp(String.raw`(?:^|\s)(==(?!\s)(${HIGHLIGHT_BODY})(?<!\s)==)$`)
|
||||
const HIGHLIGHT_PASTE = new RegExp(String.raw`(?:^|\s)(==(?!\s)(${HIGHLIGHT_BODY})(?<!\s)==)`, 'g')
|
||||
|
||||
/**
|
||||
* Highlight mark (`<mark>`), serialized to and parsed from `==text==`. CommonMark/`marked` has no
|
||||
* highlight token, so this registers a custom inline tokenizer (parsing the inner text as inline
|
||||
* markdown so nested marks like `==**bold**==` survive) and a `renderMarkdown` that wraps the content
|
||||
* in `==`. Mirrors the verbatim-node registration pattern in `./raw-markdown-snippet`.
|
||||
*
|
||||
* The tokenizer's `start` returns the index of the next `==` (a plain string search, not the
|
||||
* `createLexer()`-calling form the `RawHtmlBlock` caveat warns against) so `marked` breaks its inline
|
||||
* text run there and gives this tokenizer a chance mid-line — `=` is not a default break char like `[`.
|
||||
*
|
||||
* A lone `=` is allowed inside a highlight (so `==a=b==` round-trips), but `==` cannot be encoded in the
|
||||
* `==…==` delimiter (emitting `==a==b==` would split the highlight and corrupt the text on reload). The
|
||||
* tokenizer/input rules already exclude `==`; an `appendTransaction` guard removes the mark from any
|
||||
* text that ends up containing `==` (e.g. a toolbar highlight over `a==b`), so the doc never holds an
|
||||
* unrepresentable highlight and serialization stays lossless.
|
||||
*/
|
||||
export const Highlight = Mark.create({
|
||||
name: 'highlight',
|
||||
|
||||
parseHTML() {
|
||||
return [{ tag: 'mark' }]
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return ['mark', mergeAttributes(HTMLAttributes), 0]
|
||||
},
|
||||
|
||||
addInputRules() {
|
||||
return [markInputRule({ find: HIGHLIGHT_INPUT, type: this.type })]
|
||||
},
|
||||
|
||||
addPasteRules() {
|
||||
return [markPasteRule({ find: HIGHLIGHT_PASTE, type: this.type })]
|
||||
},
|
||||
|
||||
addKeyboardShortcuts() {
|
||||
return { 'Mod-Shift-h': () => this.editor.commands.toggleMark(this.name) }
|
||||
},
|
||||
|
||||
markdownTokenName: 'highlight',
|
||||
markdownTokenizer: {
|
||||
name: 'highlight',
|
||||
level: 'inline' as const,
|
||||
start: (src: string) => src.indexOf('=='),
|
||||
tokenize(src: string): MarkdownToken | undefined {
|
||||
const match = HIGHLIGHT_TOKEN.exec(src)
|
||||
if (!match) return undefined
|
||||
return { type: 'highlight', raw: match[0], text: match[1] }
|
||||
},
|
||||
},
|
||||
|
||||
parseMarkdown(token: MarkdownToken, helpers: MarkdownParseHelpers) {
|
||||
const inner = token.text ?? ''
|
||||
const tokens = helpers.tokenizeInline?.(inner)
|
||||
const content = tokens ? helpers.parseInline(tokens) : [{ type: 'text', text: inner }]
|
||||
return { mark: 'highlight', content }
|
||||
},
|
||||
|
||||
renderMarkdown(node: JSONContent, h: MarkdownRendererHelpers) {
|
||||
return `==${h.renderChildren(node.content ?? [])}==`
|
||||
},
|
||||
|
||||
addProseMirrorPlugins() {
|
||||
const markType = this.type
|
||||
return [
|
||||
new Plugin({
|
||||
appendTransaction(transactions, _oldState, newState) {
|
||||
if (!transactions.some((transaction) => transaction.docChanged)) return null
|
||||
let tr: Transaction | null = null
|
||||
newState.doc.descendants((node, pos) => {
|
||||
if (
|
||||
node.isText &&
|
||||
node.text?.includes('==') &&
|
||||
node.marks.some((mark) => mark.type === markType)
|
||||
) {
|
||||
tr = (tr ?? newState.tr).removeMark(pos, pos + node.nodeSize, markType)
|
||||
}
|
||||
})
|
||||
return tr
|
||||
},
|
||||
}),
|
||||
]
|
||||
},
|
||||
})
|
||||
+386
@@ -0,0 +1,386 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
import { Editor } from '@tiptap/core'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { extractEmbeddedFileRef } from '@/lib/uploads/utils/embedded-image-ref'
|
||||
import {
|
||||
createPublicFileContentSource,
|
||||
createWorkspaceFileContentSource,
|
||||
} from '@/hooks/use-file-content-source'
|
||||
import { createMarkdownEditorExtensions } from './editor-extensions'
|
||||
import {
|
||||
extractImageFiles,
|
||||
extractImgSrcs,
|
||||
findHostedImageAttrs,
|
||||
hasHostedImageHtml,
|
||||
htmlReferencesSrc,
|
||||
isInlineRouteSrc,
|
||||
shouldSkipFileUpload,
|
||||
toSameOriginPath,
|
||||
} from './image-paste'
|
||||
|
||||
// jsdom lacks `elementFromPoint`; the Placeholder extension's viewport tracking calls it on mount.
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal(
|
||||
'ResizeObserver',
|
||||
class {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
}
|
||||
)
|
||||
Element.prototype.scrollIntoView = vi.fn()
|
||||
document.elementFromPoint = vi.fn(() => null)
|
||||
})
|
||||
|
||||
function imageFile(name = 'shot.png'): File {
|
||||
return new File([''], name, { type: 'image/png' })
|
||||
}
|
||||
|
||||
function transfer(
|
||||
files: File[],
|
||||
items: Array<{ kind: string; type: string; file: File | null }> = []
|
||||
): DataTransfer {
|
||||
return {
|
||||
files,
|
||||
items: items.map((entry) => ({
|
||||
kind: entry.kind,
|
||||
type: entry.type,
|
||||
getAsFile: () => entry.file,
|
||||
})),
|
||||
} as unknown as DataTransfer
|
||||
}
|
||||
|
||||
describe('extractImageFiles', () => {
|
||||
it('returns nothing for a null payload or non-image files', () => {
|
||||
expect(extractImageFiles(null)).toEqual([])
|
||||
expect(extractImageFiles(transfer([new File([''], 'a.txt', { type: 'text/plain' })]))).toEqual(
|
||||
[]
|
||||
)
|
||||
})
|
||||
|
||||
it('reads images from the files list (drag-drop)', () => {
|
||||
const file = imageFile()
|
||||
expect(extractImageFiles(transfer([file]))).toEqual([file])
|
||||
})
|
||||
|
||||
it('falls back to items when files is empty (pasted screenshot)', () => {
|
||||
const file = imageFile()
|
||||
const result = extractImageFiles(transfer([], [{ kind: 'file', type: 'image/png', file }]))
|
||||
expect(result).toEqual([file])
|
||||
})
|
||||
|
||||
it('ignores non-file and non-image items', () => {
|
||||
const result = extractImageFiles(
|
||||
transfer(
|
||||
[],
|
||||
[
|
||||
{ kind: 'string', type: 'text/plain', file: null },
|
||||
{ kind: 'file', type: 'application/pdf', file: new File([''], 'a.pdf') },
|
||||
]
|
||||
)
|
||||
)
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('hasHostedImageHtml', () => {
|
||||
const isHosted = (src: string) => src.startsWith('/api/files/view/')
|
||||
|
||||
it('detects an <img> whose src is recognized as one of our own hosted files', () => {
|
||||
expect(hasHostedImageHtml('<img src="/api/files/view/wf_abc" alt="x">', isHosted)).toBe(true)
|
||||
})
|
||||
|
||||
it('is false when the html has no img, or the img src is not one of ours', () => {
|
||||
expect(hasHostedImageHtml('<p>hello</p>', isHosted)).toBe(false)
|
||||
expect(hasHostedImageHtml('<img src="https://other-site.com/photo.jpg">', isHosted)).toBe(false)
|
||||
expect(hasHostedImageHtml('', isHosted)).toBe(false)
|
||||
})
|
||||
|
||||
it('matches a hosted img among multiple candidates', () => {
|
||||
expect(
|
||||
hasHostedImageHtml(
|
||||
'<img src="https://other-site.com/a.png"><img src="/api/files/view/wf_abc">',
|
||||
isHosted
|
||||
)
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
// Regression: the browser doesn't put the node's persisted `attrs.src` (`/api/files/view/...`)
|
||||
// onto the clipboard when a rendered <img> is copied — it puts the actual DOM `src`, which is
|
||||
// `resolveImageSrc`'s REWRITTEN display URL (`/…/files/inline?key=…`/`?fileId=…`). A predicate
|
||||
// that only recognizes the persisted shape (as `extractEmbeddedFileRef` alone does) never matches
|
||||
// a real same-page copy, silently falling through to the re-upload path it exists to avoid.
|
||||
it('recognizes the real rendered <img src> end-to-end, not just the persisted reference shape', () => {
|
||||
const ws = createWorkspaceFileContentSource('ws-1')
|
||||
const renderedFromKey = ws.resolveImageSrc(
|
||||
'/api/files/serve/workspace/ws-1/1700000000000-deadbeefdeadbeef-photo.png'
|
||||
)
|
||||
const renderedFromFileId = ws.resolveImageSrc('/api/files/view/wf_abc')
|
||||
expect(renderedFromKey).toMatch(/^\/api\/workspaces\/ws-1\/files\/inline\?key=/)
|
||||
expect(renderedFromFileId).toBe('/api/workspaces/ws-1/files/inline?fileId=wf_abc')
|
||||
|
||||
// extractEmbeddedFileRef alone (the persisted-content recognizer) does NOT match either
|
||||
// rendered form — that's the exact gap isInlineRouteSrc closes.
|
||||
expect(extractEmbeddedFileRef(renderedFromKey as string)).toBeNull()
|
||||
expect(extractEmbeddedFileRef(renderedFromFileId as string)).toBeNull()
|
||||
|
||||
const isHostedReal = (src: string) => extractEmbeddedFileRef(src) !== null
|
||||
expect(hasHostedImageHtml(`<img src="${renderedFromKey}">`, isHostedReal)).toBe(true)
|
||||
expect(hasHostedImageHtml(`<img src="${renderedFromFileId}">`, isHostedReal)).toBe(true)
|
||||
})
|
||||
|
||||
it('recognizes the public-share inline route too', () => {
|
||||
const pub = createPublicFileContentSource('tok_1', '/api/files/public/tok_1/content')
|
||||
const rendered = pub.resolveImageSrc('/api/files/view/wf_abc')
|
||||
expect(rendered).toBe('/api/files/public/tok_1/inline?fileId=wf_abc')
|
||||
expect(hasHostedImageHtml(`<img src="${rendered}">`, () => false)).toBe(true)
|
||||
})
|
||||
|
||||
it('matches a valid unquoted src attribute (unquoted attribute values are valid HTML)', () => {
|
||||
expect(hasHostedImageHtml('<img src=/api/files/view/wf_abc>', isHosted)).toBe(true)
|
||||
expect(hasHostedImageHtml("<img alt='x' src=/api/files/view/wf_abc alt=y>", isHosted)).toBe(
|
||||
true
|
||||
)
|
||||
expect(hasHostedImageHtml('<img src=https://other-site.com/a.png>', isHosted)).toBe(false)
|
||||
})
|
||||
|
||||
it('matches single-quoted src attributes too', () => {
|
||||
expect(hasHostedImageHtml("<img src='/api/files/view/wf_abc'>", isHosted)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('extractImgSrcs', () => {
|
||||
it('extracts every img src in document order, including duplicates', () => {
|
||||
expect(
|
||||
extractImgSrcs('<img src="/a.png"><p>text</p><img src="/b.png"><img src="/a.png">')
|
||||
).toEqual(['/a.png', '/b.png', '/a.png'])
|
||||
})
|
||||
|
||||
it('returns an empty array for html with no img', () => {
|
||||
expect(extractImgSrcs('<p>hello</p>')).toEqual([])
|
||||
expect(extractImgSrcs('')).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('shouldSkipFileUpload (shared by paste and drop)', () => {
|
||||
const isHosted = (src: string) => src.startsWith('/api/files/view/')
|
||||
const hostedHtml = '<img src="/api/files/view/wf_abc">'
|
||||
|
||||
it('skips upload for a single already-hosted image', () => {
|
||||
expect(shouldSkipFileUpload([imageFile()], hostedHtml, isHosted)).toBe(true)
|
||||
})
|
||||
|
||||
it('does not skip when there is no html, or the html is not one of ours', () => {
|
||||
expect(shouldSkipFileUpload([imageFile()], '', isHosted)).toBe(false)
|
||||
expect(shouldSkipFileUpload([imageFile()], '<img src="https://x.com/a.png">', isHosted)).toBe(
|
||||
false
|
||||
)
|
||||
})
|
||||
|
||||
it('does not skip when there are no files to upload in the first place', () => {
|
||||
expect(shouldSkipFileUpload([], hostedHtml, isHosted)).toBe(false)
|
||||
})
|
||||
|
||||
// Regression: a genuinely mixed paste/drop (the hosted image plus a separate new one) must
|
||||
// still upload the new file — bailing out entirely here would silently drop it.
|
||||
it('does not skip a mixed paste/drop carrying more than one image file', () => {
|
||||
expect(
|
||||
shouldSkipFileUpload([imageFile('a.png'), imageFile('b.png')], hostedHtml, isHosted)
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
// Regression: this must be content-based (the accompanying html), not keyed off any mutable
|
||||
// per-drag flag like ProseMirror's `view.dragging` — that flag can go briefly stale (cleared up
|
||||
// to ~50ms late via `dragend` when a prior internal drag was dropped outside the view), and a
|
||||
// flag-based check would incorrectly suppress upload of an unrelated new file dropped in that
|
||||
// window. This function only reacts to what THIS specific event's `html`/`images` contain.
|
||||
it('is a pure function of the images/html actually offered, independent of any drag-session flag', () => {
|
||||
expect(shouldSkipFileUpload([imageFile()], '', isHosted)).toBe(false)
|
||||
expect(shouldSkipFileUpload([imageFile()], hostedHtml, isHosted)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isInlineRouteSrc', () => {
|
||||
it('recognizes the workspace- and public-scoped inline route with key or fileId', () => {
|
||||
expect(isInlineRouteSrc('/api/workspaces/ws-1/files/inline?key=workspace%2Fws-1%2Fa.png')).toBe(
|
||||
true
|
||||
)
|
||||
expect(isInlineRouteSrc('/api/workspaces/ws-1/files/inline?fileId=wf_abc')).toBe(true)
|
||||
expect(isInlineRouteSrc('/api/files/public/tok_1/inline?fileId=wf_abc')).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects non-inline paths, unrecognized query params, and external/absolute origins', () => {
|
||||
expect(isInlineRouteSrc('/api/files/serve/workspace/ws-1/a.png')).toBe(false)
|
||||
expect(isInlineRouteSrc('/api/workspaces/ws-1/files/inline')).toBe(false)
|
||||
expect(isInlineRouteSrc('/api/workspaces/ws-1/files/inline?other=1')).toBe(false)
|
||||
expect(isInlineRouteSrc('https://other-site.com/files/inline?key=x')).toBe(false)
|
||||
expect(isInlineRouteSrc('data:image/png;base64,aaaa')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('findHostedImageAttrs', () => {
|
||||
const ws = createWorkspaceFileContentSource('ws-1')
|
||||
|
||||
function docWithImages(...attrs: Array<Record<string, unknown>>): Editor {
|
||||
return new Editor({
|
||||
extensions: createMarkdownEditorExtensions({ placeholder: '' }),
|
||||
content: {
|
||||
type: 'doc',
|
||||
content: attrs.map((a) => ({ type: 'image', attrs: a })),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Regression: this is the exact mechanism that avoids persisting the display-layer inline URL
|
||||
// (Cursor's "Paste persists display image URLs" finding) — cloning the REAL node's attrs rather
|
||||
// than re-deriving a node from the clipboard html's rewritten src.
|
||||
it('finds the existing node whose RESOLVED (display) src matches, and returns its REAL persisted attrs', () => {
|
||||
const persistedSrc = '/api/files/view/wf_abc'
|
||||
const editor = docWithImages({ src: persistedSrc, alt: 'photo', width: '300' })
|
||||
const renderedSrc = ws.resolveImageSrc(persistedSrc) as string
|
||||
expect(renderedSrc).not.toBe(persistedSrc) // sanity: the rendered form really differs
|
||||
|
||||
const match = findHostedImageAttrs(editor.state.doc, [renderedSrc], ws.resolveImageSrc)
|
||||
expect(match).not.toBeNull()
|
||||
expect(match?.src).toBe(persistedSrc) // the REAL persisted src, not the rendered one
|
||||
expect(match?.alt).toBe('photo')
|
||||
expect(match?.width).toBe('300')
|
||||
})
|
||||
|
||||
it('returns null when no node in the doc resolves to any target src', () => {
|
||||
const editor = docWithImages({ src: '/api/files/view/wf_other' })
|
||||
const match = findHostedImageAttrs(
|
||||
editor.state.doc,
|
||||
['/api/workspaces/ws-1/files/inline?fileId=wf_abc'],
|
||||
ws.resolveImageSrc
|
||||
)
|
||||
expect(match).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null for an empty doc or an empty target list', () => {
|
||||
const editor = docWithImages()
|
||||
expect(findHostedImageAttrs(editor.state.doc, ['/anything'], ws.resolveImageSrc)).toBeNull()
|
||||
const editorWithImage = docWithImages({ src: '/api/files/view/wf_abc' })
|
||||
expect(findHostedImageAttrs(editorWithImage.state.doc, [], ws.resolveImageSrc)).toBeNull()
|
||||
})
|
||||
|
||||
it('matches the first of several images, not just the last', () => {
|
||||
const editor = docWithImages(
|
||||
{ src: '/api/files/view/wf_one', alt: 'one' },
|
||||
{ src: '/api/files/view/wf_two', alt: 'two' }
|
||||
)
|
||||
const renderedTwo = ws.resolveImageSrc('/api/files/view/wf_two') as string
|
||||
const match = findHostedImageAttrs(editor.state.doc, [renderedTwo], ws.resolveImageSrc)
|
||||
expect(match?.alt).toBe('two')
|
||||
})
|
||||
|
||||
it('returns a defensive copy, not a live reference to the node attrs object', () => {
|
||||
const persistedSrc = '/api/files/view/wf_abc'
|
||||
const editor = docWithImages({ src: persistedSrc, alt: 'photo' })
|
||||
const renderedSrc = ws.resolveImageSrc(persistedSrc) as string
|
||||
const match = findHostedImageAttrs(editor.state.doc, [renderedSrc], ws.resolveImageSrc)
|
||||
expect(match).not.toBeNull()
|
||||
if (match) match.alt = 'mutated'
|
||||
let originalAlt: unknown
|
||||
editor.state.doc.descendants((node) => {
|
||||
if (node.type.name === 'image') originalAlt = node.attrs.alt
|
||||
})
|
||||
expect(originalAlt).toBe('photo')
|
||||
})
|
||||
})
|
||||
|
||||
describe('origin-aware src normalization (browser-native drag/copy enrichment uses ABSOLUTE urls)', () => {
|
||||
const ORIGIN = 'https://www.staging.sim.ai'
|
||||
|
||||
it('toSameOriginPath: relative passes through; same-origin absolute → path+query; cross-origin → null', () => {
|
||||
expect(toSameOriginPath('/api/files/view/wf_a', ORIGIN)).toBe('/api/files/view/wf_a')
|
||||
expect(toSameOriginPath(`${ORIGIN}/api/workspaces/ws-1/files/inline?fileId=wf_a`, ORIGIN)).toBe(
|
||||
'/api/workspaces/ws-1/files/inline?fileId=wf_a'
|
||||
)
|
||||
expect(toSameOriginPath('https://evil.example.com/api/files/view/wf_a', ORIGIN)).toBeNull()
|
||||
})
|
||||
|
||||
it('isInlineRouteSrc accepts the same-origin ABSOLUTE inline route (the real dragged-img src on staging)', () => {
|
||||
expect(isInlineRouteSrc(`${ORIGIN}/api/workspaces/ws-1/files/inline?fileId=wf_a`, ORIGIN)).toBe(
|
||||
true
|
||||
)
|
||||
expect(
|
||||
isInlineRouteSrc(
|
||||
'https://evil.example.com/api/workspaces/ws-1/files/inline?fileId=wf_a',
|
||||
ORIGIN
|
||||
)
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('hasHostedImageHtml matches absolute same-origin srcs and rejects cross-origin ones', () => {
|
||||
const isHosted = (src: string) => extractEmbeddedFileRef(src) !== null
|
||||
expect(hasHostedImageHtml(`<img src="${ORIGIN}/api/files/view/wf_a">`, isHosted, ORIGIN)).toBe(
|
||||
true
|
||||
)
|
||||
expect(
|
||||
hasHostedImageHtml(
|
||||
`<img src="${ORIGIN}/api/workspaces/ws-1/files/inline?fileId=wf_a">`,
|
||||
isHosted,
|
||||
ORIGIN
|
||||
)
|
||||
).toBe(true)
|
||||
expect(
|
||||
hasHostedImageHtml(
|
||||
'<img src="https://evil.example.com/api/files/view/wf_a">',
|
||||
isHosted,
|
||||
ORIGIN
|
||||
)
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('findHostedImageAttrs matches when the clipboard html carries the absolute rendered url', () => {
|
||||
const ws = createWorkspaceFileContentSource('ws-1')
|
||||
const editor = new Editor({
|
||||
extensions: createMarkdownEditorExtensions({ placeholder: '' }),
|
||||
content: {
|
||||
type: 'doc',
|
||||
content: [{ type: 'image', attrs: { src: '/api/files/view/wf_abc', alt: 'photo' } }],
|
||||
},
|
||||
})
|
||||
const absolute = `${ORIGIN}/api/workspaces/ws-1/files/inline?fileId=wf_abc`
|
||||
const match = findHostedImageAttrs(editor.state.doc, [absolute], ws.resolveImageSrc, ORIGIN)
|
||||
expect(match?.src).toBe('/api/files/view/wf_abc')
|
||||
})
|
||||
})
|
||||
|
||||
describe('htmlReferencesSrc (the "this drop is MY dragged image" check)', () => {
|
||||
const ORIGIN = 'https://www.staging.sim.ai'
|
||||
const RESOLVED = '/api/workspaces/ws-1/files/inline?fileId=wf_a'
|
||||
|
||||
it('true when the html img src is the absolute form of the resolved src', () => {
|
||||
expect(htmlReferencesSrc(`<img src="${ORIGIN}${RESOLVED}">`, RESOLVED, ORIGIN)).toBe(true)
|
||||
})
|
||||
|
||||
it('true for the relative form too (ProseMirror-serialized html)', () => {
|
||||
expect(htmlReferencesSrc(`<img src="${RESOLVED}">`, RESOLVED, ORIGIN)).toBe(true)
|
||||
})
|
||||
|
||||
// Identity must work for cross-origin srcs too: a doc's image may legitimately point at a CDN or
|
||||
// badge URL, and dragging THAT node to reorder it must still match — under the previous
|
||||
// same-origin-path comparison it fell into the duplicate-upload branch instead.
|
||||
it('matches an external (cross-origin) image against its own exact absolute url', () => {
|
||||
const EXTERNAL = 'https://cdn.example.com/badge.svg'
|
||||
expect(htmlReferencesSrc(`<img src="${EXTERNAL}">`, EXTERNAL, ORIGIN)).toBe(true)
|
||||
expect(
|
||||
htmlReferencesSrc('<img src="https://cdn.example.com/other.svg">', EXTERNAL, ORIGIN)
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('false for a different image, empty html, missing resolved src, or cross-origin', () => {
|
||||
expect(htmlReferencesSrc(`<img src="${ORIGIN}/api/other?fileId=wf_b">`, RESOLVED, ORIGIN)).toBe(
|
||||
false
|
||||
)
|
||||
expect(htmlReferencesSrc('', RESOLVED, ORIGIN)).toBe(false)
|
||||
expect(htmlReferencesSrc(`<img src="${ORIGIN}${RESOLVED}">`, undefined, ORIGIN)).toBe(false)
|
||||
expect(
|
||||
htmlReferencesSrc(`<img src="https://evil.example.com${RESOLVED}">`, RESOLVED, ORIGIN)
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
+218
@@ -0,0 +1,218 @@
|
||||
/**
|
||||
* Extract image `File` objects from a paste/drop payload. Reads `files` first, then falls back to
|
||||
* `items` — many browsers expose a pasted or copied image (e.g. a screenshot) only through
|
||||
* `DataTransfer.items` with an empty `files` list, so reading `files` alone misses them.
|
||||
*/
|
||||
export function extractImageFiles(transfer: DataTransfer | null): File[] {
|
||||
if (!transfer) return []
|
||||
const fromFiles = Array.from(transfer.files).filter((file) => file.type.startsWith('image/'))
|
||||
if (fromFiles.length > 0) return fromFiles
|
||||
return Array.from(transfer.items)
|
||||
.filter((item) => item.kind === 'file' && item.type.startsWith('image/'))
|
||||
.map((item) => item.getAsFile())
|
||||
.filter((file): file is File => file !== null)
|
||||
}
|
||||
|
||||
/**
|
||||
* Matches `<img>` `src` attribute values: double-quoted, single-quoted, or (validly) unquoted per
|
||||
* the HTML spec — the browser's own clipboard serialization always quotes it, but other producers
|
||||
* of `text/html` are not obligated to.
|
||||
*/
|
||||
const IMG_SRC_RE = /<img\b[^>]*\bsrc\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'>]+))/gi
|
||||
|
||||
/** Query params under which the inline route addresses a workspace file. */
|
||||
const INLINE_ROUTE_QUERY_KEYS = new Set(['key', 'fileId'])
|
||||
|
||||
/**
|
||||
* The page's own origin — clipboard/dataTransfer srcs on any other origin are never ours.
|
||||
* Deliberately `window.location.origin`, NOT `getBaseUrl()`: the browser serializes a dragged/copied
|
||||
* `<img>`'s URL against the origin the page is ACTUALLY being viewed on, which can legitimately
|
||||
* differ from the configured `NEXT_PUBLIC_APP_URL` (localhost dev against a shared env, preview
|
||||
* deploys, apex-vs-www) — comparing against the configured URL would silently fail on any such
|
||||
* origin, which is the exact bug class this normalization exists to fix.
|
||||
*/
|
||||
function runtimeOrigin(): string {
|
||||
return typeof window === 'undefined' ? '' : window.location.origin
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes a clipboard/dataTransfer img `src` to an origin-relative `pathname?query`, or `null`
|
||||
* when it belongs to a different origin. The browser's NATIVE drag/copy enrichment (dragging or
|
||||
* "Copy Image" on a rendered `<img>`) serializes the ABSOLUTE resolved URL —
|
||||
* `https://host/api/workspaces/…/inline?…` — while everything the app compares against (persisted
|
||||
* refs, `resolveImageSrc` output) is origin-relative, so both must be brought into the same space
|
||||
* before comparing. A cross-origin src must never be treated as ours.
|
||||
*/
|
||||
export function toSameOriginPath(src: string, origin = runtimeOrigin()): string | null {
|
||||
try {
|
||||
const base = origin || 'http://placeholder'
|
||||
const parsed = new URL(src, base)
|
||||
if (parsed.origin !== base) return null
|
||||
return parsed.pathname + parsed.search
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* True for the *display-layer* inline route `resolveImageSrc` (see `use-file-content-source.tsx`)
|
||||
* rewrites an embed to — workspace-scoped `/api/workspaces/{workspaceId}/files/inline?key=…`/`?fileId=…`
|
||||
* or public-share-scoped `/api/files/public/{token}/inline?key=…`/`?fileId=…`. This is the shape
|
||||
* actually rendered into `<img src>`, and so what a same-page copy's `text/html` clipboard payload
|
||||
* actually contains (absolute — see {@link toSameOriginPath}) — NOT the raw stored reference
|
||||
* `extractEmbeddedFileRef` (in `@/lib/uploads/utils/embedded-image-ref`) recognizes, which only
|
||||
* matches the persisted `src` before that rewrite. Checked separately from (rather than folded into)
|
||||
* `extractEmbeddedFileRef` since that helper is shared with server-side authorization/export code
|
||||
* operating on persisted content, where this display-only shape should never legitimately appear.
|
||||
*/
|
||||
export function isInlineRouteSrc(src: string, origin = runtimeOrigin()): boolean {
|
||||
const path = toSameOriginPath(src, origin)
|
||||
if (path === null) return false
|
||||
try {
|
||||
const parsed = new URL(path, 'http://placeholder')
|
||||
if (!parsed.pathname.endsWith('/inline')) return false
|
||||
for (const key of parsed.searchParams.keys()) {
|
||||
if (INLINE_ROUTE_QUERY_KEYS.has(key)) return true
|
||||
}
|
||||
return false
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts every `<img>` `src` value found in `html`, in document order (may contain duplicates).
|
||||
*/
|
||||
export function extractImgSrcs(html: string): string[] {
|
||||
const srcs: string[] = []
|
||||
for (const match of html.matchAll(IMG_SRC_RE)) {
|
||||
const src = match[1] ?? match[2] ?? match[3]
|
||||
if (src) srcs.push(src)
|
||||
}
|
||||
return srcs
|
||||
}
|
||||
|
||||
/**
|
||||
* True when `html` contains an `<img>` whose `src` is already one of our own hosted workspace file
|
||||
* references. Copying a rendered `<img>` that's already on the page (e.g. Cmd+C after clicking it to
|
||||
* select it) makes the browser put BOTH `text/html` (the real serialized node, with its real hosted
|
||||
* `src`) AND a synthesized image `File` onto the clipboard — the same "drag a web image out" behavior
|
||||
* that {@link extractImageFiles} alone can't tell apart from a genuinely new external image paste.
|
||||
* Srcs are normalized origin-relative first ({@link toSameOriginPath}): ProseMirror's own clipboard
|
||||
* serialization writes the persisted relative src, but the BROWSER's native enrichment writes the
|
||||
* absolute resolved URL.
|
||||
*/
|
||||
export function hasHostedImageHtml(
|
||||
html: string,
|
||||
isHostedRef: (src: string) => boolean,
|
||||
origin = runtimeOrigin()
|
||||
): boolean {
|
||||
return extractImgSrcs(html).some((src) => {
|
||||
const path = toSameOriginPath(src, origin)
|
||||
return path !== null && (isHostedRef(path) || isInlineRouteSrc(path, origin))
|
||||
})
|
||||
}
|
||||
|
||||
/** Resolves `src` to a full absolute URL against `origin`, or `null` when unparseable. */
|
||||
function toAbsoluteUrl(src: string, origin: string): string | null {
|
||||
try {
|
||||
return new URL(src, origin || 'http://placeholder').href
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* True when `html` contains an `<img>` whose src resolves to the same ABSOLUTE URL as
|
||||
* `resolvedSrc` (a `resolveImageSrc` output for a node already in this document). This is the
|
||||
* "that drop is MY dragged image" check for internal drag-reorder: TipTap's node-view dragstart
|
||||
* bypasses ProseMirror's serialization entirely (no PM `text/html`, no `view.dragging`) but
|
||||
* NodeSelects the dragged image, and the browser's native enrichment carries the absolute rendered
|
||||
* URL of exactly that node.
|
||||
*
|
||||
* Deliberately compares full absolute URLs rather than same-origin paths ({@link toSameOriginPath}):
|
||||
* identity is the question here, not hosted-by-us membership — a doc's image may legitimately have a
|
||||
* cross-origin `src` (a README badge, a CDN image), and dragging THAT node to reorder it must match
|
||||
* too, or it falls into the duplicate-upload path. Relative and absolute spellings of the same URL
|
||||
* still compare equal because both sides resolve against the page origin.
|
||||
*/
|
||||
export function htmlReferencesSrc(
|
||||
html: string,
|
||||
resolvedSrc: string | undefined,
|
||||
origin = runtimeOrigin()
|
||||
): boolean {
|
||||
if (!html || !resolvedSrc) return false
|
||||
const target = toAbsoluteUrl(resolvedSrc, origin)
|
||||
if (target === null) return false
|
||||
return extractImgSrcs(html).some((src) => toAbsoluteUrl(src, origin) === target)
|
||||
}
|
||||
|
||||
/**
|
||||
* True when a paste or drop should be diverted away from the upload-from-file path — it carries
|
||||
* exactly one image file, and the accompanying `text/html` shows it's a same-page copy of an
|
||||
* already-hosted image (see {@link hasHostedImageHtml}) rather than a genuinely new external image.
|
||||
* Content-based (not `view.dragging`-based, for the drop case): `view.dragging` can go briefly stale
|
||||
* (cleared up to ~50ms late by ProseMirror's own `dragend` handler when a prior internal drag was
|
||||
* dropped outside this view) and must never suppress upload of an unrelated, genuinely new file that
|
||||
* happens to land in that window — this check only reacts to what THIS specific event's `html`
|
||||
* actually contains. Gated on exactly one file — a genuinely mixed paste/drop (the hosted image plus a
|
||||
* separate new one) must still upload the new file, not have the whole paste/drop diverted.
|
||||
*/
|
||||
export function shouldSkipFileUpload(
|
||||
images: File[],
|
||||
html: string,
|
||||
isHostedRef: (src: string) => boolean
|
||||
): boolean {
|
||||
return images.length === 1 && Boolean(html) && hasHostedImageHtml(html, isHostedRef)
|
||||
}
|
||||
|
||||
/** Minimal shape of a ProseMirror image node — just enough to read its type name and attrs. */
|
||||
interface ImageLikeNode {
|
||||
type: { name: string }
|
||||
attrs: Record<string, unknown>
|
||||
}
|
||||
|
||||
/** Minimal shape of a ProseMirror doc — just enough to walk its nodes. */
|
||||
interface DescendantsDoc {
|
||||
descendants: (callback: (node: ImageLikeNode) => boolean | undefined) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the first `image` node already in `doc` whose *rendered* src (`resolveImageSrc(node.attrs.src)`)
|
||||
* matches one of `targetSrcs`, and returns its attrs — a defensive copy, safe to hand straight to
|
||||
* `insertContentAt`. Returns `null` if no match is found (e.g. the source node was deleted, or this is
|
||||
* genuinely a different document than the one the html was copied from).
|
||||
*
|
||||
* Used to clone a same-page copy/drag of an already-hosted image faithfully — the exact persisted
|
||||
* `src` (and every other attribute: width, height, href, title…) — rather than re-deriving a node from
|
||||
* the clipboard/dataTransfer `html`, whose `src` is `resolveImageSrc`'s rewritten *display* URL, not the
|
||||
* real persisted one. Inserting a node built from that display URL would bake it into the document,
|
||||
* which public share/export/referenced-by-doc tracking don't recognize (they only match the persisted
|
||||
* shape) — this lookup avoids ever constructing such a node in the first place. Both sides of the
|
||||
* comparison are normalized origin-relative ({@link toSameOriginPath}): the clipboard html may carry
|
||||
* the browser's absolute URLs.
|
||||
*/
|
||||
export function findHostedImageAttrs(
|
||||
doc: DescendantsDoc,
|
||||
targetSrcs: string[],
|
||||
resolveImageSrc: (src: string | undefined) => string | undefined,
|
||||
origin = runtimeOrigin()
|
||||
): Record<string, unknown> | null {
|
||||
const targets = new Set(
|
||||
targetSrcs.map((src) => toSameOriginPath(src, origin)).filter((p): p is string => p !== null)
|
||||
)
|
||||
let found: Record<string, unknown> | null = null
|
||||
doc.descendants((node) => {
|
||||
if (found) return false
|
||||
if (node.type.name === 'image') {
|
||||
const resolved = resolveImageSrc(node.attrs.src as string | undefined)
|
||||
const resolvedPath = resolved ? toSameOriginPath(resolved, origin) : null
|
||||
if (resolvedPath && targets.has(resolvedPath)) {
|
||||
found = { ...node.attrs }
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
return found
|
||||
}
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
createPublicFileContentSource,
|
||||
createWorkspaceFileContentSource,
|
||||
} from '@/hooks/use-file-content-source'
|
||||
|
||||
const KEY = 'workspace/W1/1700000000000-deadbeefdeadbeef-photo.png'
|
||||
const ENCODED = encodeURIComponent(KEY)
|
||||
|
||||
describe('content-source resolveImageSrc', () => {
|
||||
it('in-app source rewrites embeds to the workspace-scoped inline route', () => {
|
||||
const src = createWorkspaceFileContentSource('ws-1')
|
||||
expect(src.resolveImageSrc(`/api/files/serve/${ENCODED}?context=workspace`)).toBe(
|
||||
`/api/workspaces/ws-1/files/inline?key=${encodeURIComponent(KEY)}`
|
||||
)
|
||||
expect(src.resolveImageSrc('/api/files/view/wf_abc')).toBe(
|
||||
'/api/workspaces/ws-1/files/inline?fileId=wf_abc'
|
||||
)
|
||||
})
|
||||
|
||||
it('public source rewrites embeds to the token-scoped inline route', () => {
|
||||
const src = createPublicFileContentSource('tok_1', '/api/files/public/tok_1/content')
|
||||
expect(src.resolveImageSrc('/api/files/view/wf_abc')).toBe(
|
||||
'/api/files/public/tok_1/inline?fileId=wf_abc'
|
||||
)
|
||||
})
|
||||
|
||||
it('passes external/data srcs through unchanged in both sources', () => {
|
||||
const ws = createWorkspaceFileContentSource('ws-1')
|
||||
const pub = createPublicFileContentSource('tok_1', '/c')
|
||||
expect(ws.resolveImageSrc('https://cdn.example.com/a.png')).toBe(
|
||||
'https://cdn.example.com/a.png'
|
||||
)
|
||||
expect(pub.resolveImageSrc('https://cdn.example.com/a.png')).toBe(
|
||||
'https://cdn.example.com/a.png'
|
||||
)
|
||||
expect(ws.resolveImageSrc(undefined)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
+325
@@ -0,0 +1,325 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { cn } from '@sim/emcn'
|
||||
import type { JSONContent } from '@tiptap/core'
|
||||
import { Image } from '@tiptap/extension-image'
|
||||
import { NodeSelection, Plugin } from '@tiptap/pm/state'
|
||||
import type { ReactNodeViewProps } from '@tiptap/react'
|
||||
import { NodeViewWrapper, ReactNodeViewRenderer } from '@tiptap/react'
|
||||
import { useFileContentSource } from '@/hooks/use-file-content-source'
|
||||
import { normalizeLinkHref } from './markdown-fidelity'
|
||||
import { useEditorEditable } from './use-editor-editable'
|
||||
|
||||
const MIN_WIDTH = 64
|
||||
|
||||
/**
|
||||
* A markdown linked image `[](href "t2")` — an image wrapped in a link, the canonical
|
||||
* form of a README badge. `@tiptap/markdown` parses this as a link mark over an image node, but an
|
||||
* image node can't carry inline marks, so the wrapping link is silently dropped. We instead tokenize
|
||||
* the whole construct ourselves and hang the link target on the image node's `href` attribute, so it
|
||||
* round-trips losslessly (and the file stays editable rather than opening read-only).
|
||||
*/
|
||||
const LINKED_IMAGE_RE =
|
||||
/^\[!\[([^\]]*)\]\(([^)\s]+)(?:\s+"([^"]*)")?\)\]\(([^)\s]+)(?:\s+"([^"]*)")?\)/
|
||||
|
||||
/** Escape a value for safe interpolation into a double-quoted HTML attribute. */
|
||||
function escapeAttr(value: string): string {
|
||||
return value
|
||||
.replace(/&/g, '&')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize an image to markdown when it has no explicit size, and to an HTML `<img>` tag when
|
||||
* it does — standard markdown has no width syntax, so a resized image must round-trip as HTML to
|
||||
* preserve its dimensions. Unsized images stay clean ``. An image with an `href` is
|
||||
* wrapped in a markdown link so a linked badge round-trips as `[](href)`.
|
||||
*
|
||||
* A *sized **and** linked* image is the one case markdown can't represent: the linked-image tokenizer
|
||||
* only recognizes `[](href)`, so emitting `[<img …>](href)` would silently drop the link on
|
||||
* reparse (and the round-trip-safety probe wouldn't catch it). We keep the link and fall back to the
|
||||
* unsized `[](href)` form — the link matters more than the exact dimensions for a badge.
|
||||
*/
|
||||
function imageMarkdown(node: JSONContent): string {
|
||||
const attrs = node.attrs ?? {}
|
||||
const src = typeof attrs.src === 'string' ? attrs.src : ''
|
||||
const alt = typeof attrs.alt === 'string' ? attrs.alt : ''
|
||||
const title = typeof attrs.title === 'string' ? attrs.title : ''
|
||||
const href = typeof attrs.href === 'string' ? attrs.href : ''
|
||||
const hrefTitle = typeof attrs.hrefTitle === 'string' ? attrs.hrefTitle : ''
|
||||
const width = attrs.width
|
||||
const height = attrs.height
|
||||
let image: string
|
||||
if ((width || height) && !href) {
|
||||
const parts = [`src="${escapeAttr(src)}"`]
|
||||
if (alt) parts.push(`alt="${escapeAttr(alt)}"`)
|
||||
if (title) parts.push(`title="${escapeAttr(title)}"`)
|
||||
if (width) parts.push(`width="${escapeAttr(String(width))}"`)
|
||||
if (height) parts.push(`height="${escapeAttr(String(height))}"`)
|
||||
image = `<img ${parts.join(' ')}>`
|
||||
} else {
|
||||
// Escape so an alt with `]`/`[` or a title with `"` can't break out of the `` syntax
|
||||
// and corrupt the round-trip; a src with spaces/parens goes in angle brackets (CommonMark).
|
||||
const titlePart = title ? ` "${title.replace(/["\\]/g, '\\$&')}"` : ''
|
||||
const safeSrc = /[\s()]/.test(src) ? `<${src}>` : src
|
||||
image = `![${alt.replace(/[\\[\]]/g, '\\$&')}](${safeSrc}${titlePart})`
|
||||
}
|
||||
if (!href) return image
|
||||
// Escape `"`/`\` so an href title can't break out of the `[…](href "title")` syntax (mirrors the
|
||||
// image title escaping above).
|
||||
const hrefTitlePart = hrefTitle ? ` "${hrefTitle.replace(/["\\]/g, '\\$&')}"` : ''
|
||||
return `[${image}](${href}${hrefTitlePart})`
|
||||
}
|
||||
|
||||
interface MarkdownImageToken {
|
||||
/** Set only by our linked-image tokenizer; absent on the built-in `` token. */
|
||||
src?: string
|
||||
alt?: string
|
||||
title?: string | null
|
||||
/** Built-in image token holds the source URL here; our linked token holds the link target. */
|
||||
href?: string
|
||||
hrefTitle?: string | null
|
||||
/** Built-in image token holds the alt text here. */
|
||||
text?: string
|
||||
}
|
||||
|
||||
/** Map both the built-in image token and our linked-image token onto the image node's attributes. */
|
||||
function parseImageToken(token: MarkdownImageToken): JSONContent {
|
||||
const isLinked = typeof token.src === 'string'
|
||||
return {
|
||||
type: 'image',
|
||||
attrs: isLinked
|
||||
? {
|
||||
src: token.src,
|
||||
alt: token.alt ?? '',
|
||||
title: token.title ?? null,
|
||||
href: token.href ?? null,
|
||||
hrefTitle: token.hrefTitle ?? null,
|
||||
}
|
||||
: {
|
||||
src: token.href ?? '',
|
||||
alt: token.text ?? '',
|
||||
title: token.title ?? null,
|
||||
href: null,
|
||||
hrefTitle: null,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const widthAttr = {
|
||||
default: null,
|
||||
parseHTML: (element: HTMLElement) => element.getAttribute('width'),
|
||||
renderHTML: (attributes: Record<string, unknown>) =>
|
||||
attributes.width ? { width: String(attributes.width) } : {},
|
||||
}
|
||||
|
||||
const heightAttr = {
|
||||
default: null,
|
||||
parseHTML: (element: HTMLElement) => element.getAttribute('height'),
|
||||
renderHTML: (attributes: Record<string, unknown>) =>
|
||||
attributes.height ? { height: String(attributes.height) } : {},
|
||||
}
|
||||
|
||||
/** Link target of a linked image — markdown-only state, never emitted as an HTML `<img>` attribute. */
|
||||
const hrefAttr = { default: null, rendered: false }
|
||||
const hrefTitleAttr = { default: null, rendered: false }
|
||||
|
||||
/**
|
||||
* Image node that carries optional `width`/`height` (serialized as an HTML `<img>` tag) and an
|
||||
* optional `href`/`hrefTitle` (a wrapping markdown link, for badges). Shared by the headless
|
||||
* round-trip path (no node view) and the live {@link ResizableImage}.
|
||||
*/
|
||||
export const MarkdownImage = Image.extend({
|
||||
addAttributes() {
|
||||
return {
|
||||
...this.parent?.(),
|
||||
width: widthAttr,
|
||||
height: heightAttr,
|
||||
href: hrefAttr,
|
||||
hrefTitle: hrefTitleAttr,
|
||||
}
|
||||
},
|
||||
markdownTokenizer: {
|
||||
name: 'image',
|
||||
level: 'inline',
|
||||
start: (src: string) => src.indexOf('[!['),
|
||||
tokenize: (src: string): (MarkdownImageToken & { type: string; raw: string }) | undefined => {
|
||||
const match = LINKED_IMAGE_RE.exec(src)
|
||||
if (!match) return undefined
|
||||
return {
|
||||
type: 'image',
|
||||
raw: match[0],
|
||||
alt: match[1] ?? '',
|
||||
src: match[2],
|
||||
title: match[3] ?? null,
|
||||
href: match[4],
|
||||
hrefTitle: match[5] ?? null,
|
||||
}
|
||||
},
|
||||
},
|
||||
parseMarkdown: parseImageToken,
|
||||
renderMarkdown: imageMarkdown,
|
||||
})
|
||||
|
||||
/**
|
||||
* Drag-to-resize image node view (handle at the bottom-right, revealed on selection). Dragging
|
||||
* commits the new pixel width to the `width` attribute, which serializes to `<img width>`.
|
||||
*/
|
||||
function ResizableImageView({ node, updateAttributes, selected, editor }: ReactNodeViewProps) {
|
||||
const source = useFileContentSource()
|
||||
const imageRef = useRef<HTMLImageElement>(null)
|
||||
const dragAbortRef = useRef<AbortController | null>(null)
|
||||
const dragWidthRef = useRef<number | null>(null)
|
||||
const [dragging, setDragging] = useState(false)
|
||||
/** Live width during a resize drag; kept out of the doc so the whole resize is one undo step. */
|
||||
const [dragWidth, setDragWidth] = useState<number | null>(null)
|
||||
/** Whether the current src failed to load; reset on src change so a retried/edited src can load. */
|
||||
const [failed, setFailed] = useState(false)
|
||||
const attrs = node.attrs as {
|
||||
src?: string
|
||||
alt?: string
|
||||
title?: string
|
||||
width?: string | null
|
||||
href?: string | null
|
||||
}
|
||||
|
||||
useEffect(() => () => dragAbortRef.current?.abort(), [])
|
||||
useEffect(() => setFailed(false), [attrs.src])
|
||||
|
||||
const startResize = (event: React.PointerEvent) => {
|
||||
event.preventDefault()
|
||||
const image = imageRef.current
|
||||
if (!image) return
|
||||
const startX = event.clientX
|
||||
const startWidth = image.offsetWidth
|
||||
setDragging(true)
|
||||
dragAbortRef.current?.abort()
|
||||
const controller = new AbortController()
|
||||
dragAbortRef.current = controller
|
||||
const { signal } = controller
|
||||
|
||||
window.addEventListener(
|
||||
'pointermove',
|
||||
(move) => {
|
||||
const next = Math.max(MIN_WIDTH, Math.round(startWidth + (move.clientX - startX)))
|
||||
dragWidthRef.current = next
|
||||
setDragWidth(next)
|
||||
},
|
||||
{ signal }
|
||||
)
|
||||
const finish = () => {
|
||||
const finalWidth = dragWidthRef.current
|
||||
setDragging(false)
|
||||
setDragWidth(null)
|
||||
dragWidthRef.current = null
|
||||
controller.abort()
|
||||
if (finalWidth !== null) updateAttributes({ width: String(finalWidth) })
|
||||
}
|
||||
window.addEventListener('pointerup', finish, { signal })
|
||||
window.addEventListener('pointercancel', finish, { signal })
|
||||
}
|
||||
|
||||
const committedWidth = attrs.width
|
||||
? /^\d+$/.test(attrs.width)
|
||||
? `${attrs.width}px`
|
||||
: attrs.width
|
||||
: undefined
|
||||
const widthStyle =
|
||||
dragWidth !== null
|
||||
? { width: `${dragWidth}px` }
|
||||
: committedWidth
|
||||
? { width: committedWidth }
|
||||
: undefined
|
||||
|
||||
// Sanitize the linked-image target before rendering the anchor — a parsed markdown href is
|
||||
// untrusted and could be `javascript:`/`data:`; an unsafe value drops the link (image only).
|
||||
const safeHref = normalizeLinkHref(typeof attrs.href === 'string' ? attrs.href : '')
|
||||
|
||||
// Read-only: no drag-to-reorder and no resize handle — both call updateAttributes / dispatch a move,
|
||||
// mutating a doc that must not change. The image still renders (and follows its link on click).
|
||||
const editable = useEditorEditable(editor)
|
||||
|
||||
const image = (
|
||||
<img
|
||||
ref={imageRef}
|
||||
src={source.resolveImageSrc(attrs.src)}
|
||||
alt={attrs.alt ?? ''}
|
||||
title={attrs.title ?? undefined}
|
||||
// When editable, the image itself is the drag handle — grab anywhere on it to reorder. (The node
|
||||
// view's wrapper is forced `draggable=false` by the React renderer, so the handle must be a child;
|
||||
// the resize button sits outside this element, so it keeps its own pointer behavior.)
|
||||
draggable={editable}
|
||||
data-drag-handle={editable ? '' : undefined}
|
||||
style={widthStyle}
|
||||
onError={() => setFailed(true)}
|
||||
onLoad={() => setFailed(false)}
|
||||
className={cn(
|
||||
'block max-w-full rounded-lg border border-[var(--border)]',
|
||||
editable && 'cursor-grab',
|
||||
failed &&
|
||||
'min-h-[72px] min-w-[140px] bg-[var(--surface-5)] p-3 text-[var(--text-muted)] text-caption'
|
||||
)}
|
||||
/>
|
||||
)
|
||||
|
||||
return (
|
||||
<NodeViewWrapper className='relative my-4 inline-block leading-none'>
|
||||
{safeHref ? (
|
||||
// The editor's handleClick is the sole navigator (gated on editable/modifier, like text links
|
||||
// via openOnClick:false): prevent the anchor's own navigation so a plain click in edit mode
|
||||
// places the caret / selects the node instead of opening a tab.
|
||||
<a
|
||||
href={safeHref}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='block'
|
||||
onClick={(event) => event.preventDefault()}
|
||||
>
|
||||
{image}
|
||||
</a>
|
||||
) : (
|
||||
image
|
||||
)}
|
||||
{editable && (selected || dragging) && (
|
||||
<button
|
||||
type='button'
|
||||
aria-label='Resize image'
|
||||
onPointerDown={startResize}
|
||||
className='absolute right-1 bottom-1 size-3 cursor-nwse-resize rounded-[3px] border border-[var(--bg)] bg-[var(--brand-secondary)]'
|
||||
/>
|
||||
)}
|
||||
</NodeViewWrapper>
|
||||
)
|
||||
}
|
||||
|
||||
/** Live image node with the drag-to-resize view; same schema + markdown output as the headless one. */
|
||||
export const ResizableImage = MarkdownImage.extend({
|
||||
addNodeView() {
|
||||
return ReactNodeViewRenderer(ResizableImageView)
|
||||
},
|
||||
/**
|
||||
* Guarantee a plain click on the image forms a node selection. The image body is also a native drag
|
||||
* source (grab-anywhere reorder), and while prosemirror-view ≥1.32.4 no longer implicitly selects on
|
||||
* drag, the reverse — a click reliably selecting — is not guaranteed for an atom whose body competes
|
||||
* with the drag gesture (see the ProseMirror "Draggable and NodeViews" discussion and TipTap #4526).
|
||||
* Selecting here makes it deterministic while leaving drag-to-reorder intact. Read-only clicks and
|
||||
* modified clicks (Cmd/Ctrl to follow a linked badge, Shift/Alt to extend) fall through to the editor's
|
||||
* `handleClick` / default behavior.
|
||||
*/
|
||||
addProseMirrorPlugins() {
|
||||
const nodeName = this.name
|
||||
return [
|
||||
new Plugin({
|
||||
props: {
|
||||
handleClickOn(view, _pos, node, nodePos, event) {
|
||||
if (!view.editable || node.type.name !== nodeName) return false
|
||||
if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return false
|
||||
view.dispatch(view.state.tr.setSelection(NodeSelection.create(view.state.doc, nodePos)))
|
||||
return true
|
||||
},
|
||||
},
|
||||
}),
|
||||
]
|
||||
},
|
||||
})
|
||||
+427
@@ -0,0 +1,427 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*
|
||||
* The leaf-selection arrow shortcuts (ArrowUp/ArrowDown → select an adjacent divider/image) run at a
|
||||
* high priority, so they must yield while a `/` or `@` suggestion menu is open — otherwise the arrow
|
||||
* selects the adjacent node instead of moving the menu selection. These assert the plugin state the
|
||||
* keymap's `isSuggestionMenuOpen` guard reads flips on when a menu opens.
|
||||
*/
|
||||
import { Editor } from '@tiptap/core'
|
||||
import { GapCursor } from '@tiptap/pm/gapcursor'
|
||||
import { AllSelection, NodeSelection } from '@tiptap/pm/state'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createMarkdownEditorExtensions } from './editor-extensions'
|
||||
import { MENTION_PLUGIN_KEY } from './mention'
|
||||
import { SLASH_COMMAND_PLUGIN_KEY } from './slash-command/slash-command'
|
||||
|
||||
function editorWith(content: string): Editor {
|
||||
return new Editor({ extensions: createMarkdownEditorExtensions({ placeholder: '' }), content })
|
||||
}
|
||||
|
||||
/** Block-type sequence of the top-level doc nodes. */
|
||||
function blockShape(editor: Editor): string[] {
|
||||
const shape: string[] = []
|
||||
editor.state.doc.forEach((node) => shape.push(node.type.name))
|
||||
return shape
|
||||
}
|
||||
|
||||
/** Position of the first node of `type`, or -1. */
|
||||
function firstPosOf(editor: Editor, type: string): number {
|
||||
let pos = -1
|
||||
editor.state.doc.descendants((node, p) => {
|
||||
if (pos < 0 && node.type.name === type) pos = p
|
||||
})
|
||||
return pos
|
||||
}
|
||||
|
||||
function pressKey(editor: Editor, key: string): void {
|
||||
editor.view.dom.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true })
|
||||
)
|
||||
}
|
||||
|
||||
function pressBackspace(editor: Editor): void {
|
||||
pressKey(editor, 'Backspace')
|
||||
}
|
||||
|
||||
/** Empties the item whose text is `word` (caret left at its start), the state before a boundary key. */
|
||||
function emptyItem(editor: Editor, word: string): void {
|
||||
let from = -1
|
||||
let to = -1
|
||||
editor.state.doc.descendants((node, pos) => {
|
||||
if (from < 0 && node.isText && node.text === word) {
|
||||
from = pos
|
||||
to = pos + word.length
|
||||
}
|
||||
})
|
||||
editor.commands.setTextSelection({ from, to })
|
||||
editor.commands.deleteSelection()
|
||||
}
|
||||
|
||||
/** Serialized markdown after re-parsing it once — equal to `getMarkdown()` only if it round-trips. */
|
||||
function markdownRoundTrip(editor: Editor): { md: string; reparsed: string } {
|
||||
const md = editor.getMarkdown()
|
||||
editor.commands.setContent(md, { contentType: 'markdown' })
|
||||
return { md, reparsed: editor.getMarkdown() }
|
||||
}
|
||||
|
||||
describe('suggestion-aware arrow keymap', () => {
|
||||
beforeEach(() => {
|
||||
// The suggestion render lifecycle uses these; jsdom lacks them.
|
||||
vi.stubGlobal(
|
||||
'ResizeObserver',
|
||||
class {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
}
|
||||
)
|
||||
Element.prototype.scrollIntoView = vi.fn()
|
||||
document.elementFromPoint = vi.fn(() => null)
|
||||
})
|
||||
|
||||
it('flags the mention menu active when `@` is typed before a divider', () => {
|
||||
const editor = editorWith('<p></p><hr>')
|
||||
editor.commands.focus()
|
||||
editor.commands.insertContent('@gma')
|
||||
|
||||
expect(MENTION_PLUGIN_KEY.getState(editor.state)?.active).toBe(true)
|
||||
editor.destroy()
|
||||
})
|
||||
|
||||
it('flags the slash menu active when `/` is typed', () => {
|
||||
const editor = editorWith('<p></p>')
|
||||
editor.commands.focus()
|
||||
editor.commands.insertContent('/')
|
||||
|
||||
expect(SLASH_COMMAND_PLUGIN_KEY.getState(editor.state)?.active).toBe(true)
|
||||
editor.destroy()
|
||||
})
|
||||
|
||||
it('keeps both menus inactive on plain text', () => {
|
||||
const editor = editorWith('<p>hello</p><hr>')
|
||||
editor.commands.focus()
|
||||
|
||||
expect(MENTION_PLUGIN_KEY.getState(editor.state)?.active).toBe(false)
|
||||
expect(SLASH_COMMAND_PLUGIN_KEY.getState(editor.state)?.active).toBe(false)
|
||||
editor.destroy()
|
||||
})
|
||||
})
|
||||
|
||||
describe('divider Backspace', () => {
|
||||
beforeEach(() => {
|
||||
Element.prototype.scrollIntoView = vi.fn()
|
||||
})
|
||||
|
||||
it('removes the empty line between two dividers and selects the higher divider', () => {
|
||||
const editor = editorWith('<p>before</p><hr><p></p><hr><p>after</p>')
|
||||
editor.commands.focus()
|
||||
let emptyPos = -1
|
||||
editor.state.doc.descendants((node, pos) => {
|
||||
if (emptyPos < 0 && node.type.name === 'paragraph' && node.content.size === 0)
|
||||
emptyPos = pos + 1
|
||||
})
|
||||
editor.commands.setTextSelection(emptyPos)
|
||||
pressBackspace(editor)
|
||||
|
||||
expect(blockShape(editor)).toEqual([
|
||||
'paragraph',
|
||||
'horizontalRule',
|
||||
'horizontalRule',
|
||||
'paragraph',
|
||||
])
|
||||
const { selection } = editor.state
|
||||
expect(selection instanceof NodeSelection).toBe(true)
|
||||
// The selected divider is the higher (first) one, not the lower.
|
||||
expect(selection.from).toBe(firstPosOf(editor, 'horizontalRule'))
|
||||
editor.destroy()
|
||||
})
|
||||
|
||||
it('selects the divider when Backspace is pressed at the start of a non-empty block below it', () => {
|
||||
const editor = editorWith('<p>before</p><hr><p>text</p>')
|
||||
editor.commands.focus()
|
||||
let textStart = -1
|
||||
editor.state.doc.descendants((node, pos) => {
|
||||
if (textStart < 0 && node.type.name === 'paragraph' && node.textContent === 'text')
|
||||
textStart = pos + 1
|
||||
})
|
||||
editor.commands.setTextSelection(textStart)
|
||||
pressBackspace(editor)
|
||||
|
||||
const { selection } = editor.state
|
||||
expect(selection instanceof NodeSelection).toBe(true)
|
||||
expect((selection as NodeSelection).node.type.name).toBe('horizontalRule')
|
||||
// The block is untouched — the divider is only highlighted, not deleted.
|
||||
expect(blockShape(editor)).toEqual(['paragraph', 'horizontalRule', 'paragraph'])
|
||||
editor.destroy()
|
||||
})
|
||||
|
||||
it('select-all spans the whole document, dividers included', () => {
|
||||
const editor = editorWith('<p>a</p><hr><p>b</p><hr><p>c</p>')
|
||||
editor.commands.selectAll()
|
||||
|
||||
const { selection, doc } = editor.state
|
||||
expect(selection instanceof AllSelection).toBe(true)
|
||||
expect(selection.from).toBe(0)
|
||||
expect(selection.to).toBe(doc.content.size)
|
||||
editor.destroy()
|
||||
})
|
||||
})
|
||||
|
||||
describe('empty wrapped-block Backspace', () => {
|
||||
beforeEach(() => {
|
||||
Element.prototype.scrollIntoView = vi.fn()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['bullet middle', '- one\n- two\n- three', 'two', '- one\n- three'],
|
||||
['bullet first', '- one\n- two\n- three', 'one', '- two\n- three'],
|
||||
['bullet last', '- one\n- two', 'two', '- one'],
|
||||
['ordered middle', '1. one\n2. two\n3. three', 'two', '1. one\n2. three'],
|
||||
['task middle', '- [ ] one\n- [ ] two\n- [ ] three', 'two', '- [ ] one\n- [ ] three'],
|
||||
['blockquote middle', '> one\n>\n> two\n>\n> three', 'two', '> one\n>\n> three'],
|
||||
['nested item', '- one\n - two\n- three', 'two', '- one\n- three'],
|
||||
])(
|
||||
'removes the emptied %s cleanly — one container, no stray paragraph, round-trips',
|
||||
(_label, markdown, word, expected) => {
|
||||
const editor = editorWith('')
|
||||
editor.commands.setContent(markdown, { contentType: 'markdown' })
|
||||
editor.commands.focus()
|
||||
emptyItem(editor, word)
|
||||
pressBackspace(editor)
|
||||
|
||||
const { md, reparsed } = markdownRoundTrip(editor)
|
||||
expect(md.trim()).toBe(expected)
|
||||
expect(reparsed).toBe(md)
|
||||
editor.destroy()
|
||||
}
|
||||
)
|
||||
|
||||
it('leaves a gap cursor — never a NodeSelection — when the removed bullet was followed by an image at doc start', () => {
|
||||
// Regression: `Selection.near` after the delete silently NodeSelected the following image, so a
|
||||
// second Backspace while "clearing the bullet" deleted the image (and typing would have replaced
|
||||
// it). The selection left behind must never make the next keystroke destructive.
|
||||
const editor = editorWith({
|
||||
type: 'doc',
|
||||
content: [
|
||||
{ type: 'bulletList', content: [{ type: 'listItem', content: [{ type: 'paragraph' }] }] },
|
||||
{ type: 'image', attrs: { src: '/api/files/view/wf_img', alt: 'photo' } },
|
||||
],
|
||||
})
|
||||
editor.commands.setTextSelection(3)
|
||||
pressBackspace(editor)
|
||||
|
||||
expect(blockShape(editor)).toEqual(['image', 'paragraph'])
|
||||
expect(editor.state.selection).not.toBeInstanceOf(NodeSelection)
|
||||
|
||||
pressBackspace(editor)
|
||||
expect(blockShape(editor)).toEqual(['image', 'paragraph'])
|
||||
editor.destroy()
|
||||
})
|
||||
|
||||
it('does not crash on Backspace from a gap cursor (top-level selection, depth 0)', () => {
|
||||
// Regression: `$from.before($from.depth)` with a gap cursor's depth of 0 threw
|
||||
// "RangeError: There is no position before the top-level node" — reachable whenever a gap
|
||||
// cursor sits between two leaves (the `data-gap-between-leaves` state) and Backspace is pressed.
|
||||
const editor = editorWith('<hr><hr>')
|
||||
const gapPos = editor.state.doc.firstChild ? editor.state.doc.firstChild.nodeSize : 1
|
||||
editor.view.dispatch(
|
||||
editor.state.tr.setSelection(new GapCursor(editor.state.doc.resolve(gapPos)))
|
||||
)
|
||||
expect(editor.state.selection).toBeInstanceOf(GapCursor)
|
||||
|
||||
expect(() => pressBackspace(editor)).not.toThrow()
|
||||
// TipTap appends a trailing paragraph after the final leaf; the dividers must both survive.
|
||||
expect(blockShape(editor)).toEqual(['horizontalRule', 'horizontalRule', 'paragraph'])
|
||||
editor.destroy()
|
||||
})
|
||||
|
||||
it('never NodeSelects a leaf BEFORE the removed bullet either (findFrom textOnly skips atoms)', () => {
|
||||
// `Selection.findFrom($gap, -1, true)` cannot return a NodeSelection: with textOnly,
|
||||
// prosemirror-state's findSelectionIn skips atoms entirely (`!text && isSelectable`). With an
|
||||
// image directly before the emptied bullet and no textblock behind it, the backward search
|
||||
// returns null and the gap-cursor branch takes over — the image is never silently selected.
|
||||
const editor = editorWith({
|
||||
type: 'doc',
|
||||
content: [
|
||||
{ type: 'image', attrs: { src: '/api/files/view/wf_img', alt: 'photo' } },
|
||||
{ type: 'bulletList', content: [{ type: 'listItem', content: [{ type: 'paragraph' }] }] },
|
||||
],
|
||||
})
|
||||
editor.commands.setTextSelection(4)
|
||||
expect(editor.state.selection.$from.parent.type.name).toBe('paragraph')
|
||||
pressBackspace(editor)
|
||||
|
||||
expect(blockShape(editor)).toEqual(['image', 'paragraph'])
|
||||
expect(editor.state.selection).not.toBeInstanceOf(NodeSelection)
|
||||
|
||||
pressBackspace(editor)
|
||||
expect(blockShape(editor)).toEqual(['image', 'paragraph'])
|
||||
editor.destroy()
|
||||
})
|
||||
|
||||
it('does not crash on Backspace from a gap cursor at the very start of the doc', () => {
|
||||
// Depth-0 + offset-0 is the worst case: our old code threw before(0), and TipTap's blockquote
|
||||
// handler crashes on $from.node(-1) if the key falls through — so it must be consumed.
|
||||
const editor = editorWith({
|
||||
type: 'doc',
|
||||
content: [{ type: 'image', attrs: { src: '/api/files/view/wf_img', alt: 'photo' } }],
|
||||
})
|
||||
editor.view.dispatch(editor.state.tr.setSelection(new GapCursor(editor.state.doc.resolve(0))))
|
||||
expect(editor.state.selection).toBeInstanceOf(GapCursor)
|
||||
|
||||
expect(() => pressBackspace(editor)).not.toThrow()
|
||||
expect(blockShape(editor)).toEqual(['image', 'paragraph'])
|
||||
editor.destroy()
|
||||
})
|
||||
|
||||
it('still prefers the previous textblock caret when one exists (image after the bullet untouched)', () => {
|
||||
const editor = editorWith({
|
||||
type: 'doc',
|
||||
content: [
|
||||
{ type: 'paragraph', content: [{ type: 'text', text: 'hello' }] },
|
||||
{ type: 'bulletList', content: [{ type: 'listItem', content: [{ type: 'paragraph' }] }] },
|
||||
{ type: 'image', attrs: { src: '/api/files/view/wf_img', alt: 'photo' } },
|
||||
],
|
||||
})
|
||||
editor.commands.setTextSelection(10)
|
||||
pressBackspace(editor)
|
||||
|
||||
expect(blockShape(editor)).toEqual(['paragraph', 'image', 'paragraph'])
|
||||
expect(editor.state.selection.empty).toBe(true)
|
||||
expect(editor.state.selection).not.toBeInstanceOf(NodeSelection)
|
||||
expect(editor.state.selection.$from.parent.textContent).toBe('hello')
|
||||
editor.destroy()
|
||||
})
|
||||
})
|
||||
|
||||
describe('empty list-item Enter', () => {
|
||||
beforeEach(() => {
|
||||
Element.prototype.scrollIntoView = vi.fn()
|
||||
})
|
||||
|
||||
it('removes an empty MIDDLE item instead of splitting the list into a stranded paragraph', () => {
|
||||
const editor = editorWith('')
|
||||
editor.commands.setContent('- one\n- two\n- three', { contentType: 'markdown' })
|
||||
editor.commands.focus()
|
||||
emptyItem(editor, 'two')
|
||||
pressKey(editor, 'Enter')
|
||||
|
||||
const { md, reparsed } = markdownRoundTrip(editor)
|
||||
expect(md.trim()).toBe('- one\n- three')
|
||||
expect(reparsed).toBe(md)
|
||||
editor.destroy()
|
||||
})
|
||||
|
||||
it('leaves an empty TRAILING item to the default (exits the list)', () => {
|
||||
const editor = editorWith('')
|
||||
editor.commands.setContent('- one\n- two', { contentType: 'markdown' })
|
||||
editor.commands.focus()
|
||||
emptyItem(editor, 'two')
|
||||
pressKey(editor, 'Enter')
|
||||
|
||||
const list = editor.getJSON().content?.find((node) => node.type === 'bulletList')
|
||||
expect(list?.content).toHaveLength(1)
|
||||
expect(editor.getJSON().content?.some((node) => node.type === 'paragraph')).toBe(true)
|
||||
editor.destroy()
|
||||
})
|
||||
})
|
||||
|
||||
describe('verbatim block boundary (isolating)', () => {
|
||||
beforeEach(() => {
|
||||
Element.prototype.scrollIntoView = vi.fn()
|
||||
})
|
||||
|
||||
function caretIntoNode(editor: Editor, nodeType: string): void {
|
||||
editor.state.doc.descendants((node, pos) => {
|
||||
if (node.type.name === nodeType) editor.commands.setTextSelection(pos + 1)
|
||||
})
|
||||
}
|
||||
|
||||
it.each([
|
||||
['footnote definition', 'body text[^x]\n\n[^x]: the note', 'footnoteDef'],
|
||||
['raw HTML block', 'body\n\n<div>\nhello\n</div>', 'rawHtmlBlock'],
|
||||
])(
|
||||
'Backspace at the start of a %s does not merge across its boundary and destroy it',
|
||||
(_label, markdown, nodeType) => {
|
||||
const editor = editorWith('')
|
||||
editor.commands.setContent(markdown, { contentType: 'markdown' })
|
||||
editor.commands.focus()
|
||||
expect(blockShape(editor)).toContain(nodeType)
|
||||
caretIntoNode(editor, nodeType)
|
||||
pressBackspace(editor)
|
||||
expect(blockShape(editor)).toContain(nodeType)
|
||||
editor.destroy()
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
describe('block reordering (Mod-Shift-Arrow)', () => {
|
||||
beforeEach(() => {
|
||||
Element.prototype.scrollIntoView = vi.fn()
|
||||
})
|
||||
|
||||
function caretInto(editor: Editor, word: string): void {
|
||||
editor.state.doc.descendants((node, pos) => {
|
||||
if (node.isText && node.text?.includes(word)) editor.commands.setTextSelection(pos + 1)
|
||||
})
|
||||
}
|
||||
|
||||
it('moves the current top-level block up, carrying the caret', () => {
|
||||
const editor = editorWith('')
|
||||
editor.commands.setContent('# One\n\nTwo para\n\n- item', { contentType: 'markdown' })
|
||||
editor.commands.focus()
|
||||
caretInto(editor, 'Two')
|
||||
editor.commands.moveBlockUp()
|
||||
expect(editor.getMarkdown().trim().startsWith('Two para')).toBe(true)
|
||||
editor.destroy()
|
||||
})
|
||||
|
||||
it('moves the current top-level block down', () => {
|
||||
const editor = editorWith('')
|
||||
editor.commands.setContent('# One\n\nTwo para', { contentType: 'markdown' })
|
||||
editor.commands.focus()
|
||||
caretInto(editor, 'One')
|
||||
editor.commands.moveBlockDown()
|
||||
expect(editor.getMarkdown().trim().startsWith('Two para')).toBe(true)
|
||||
editor.destroy()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['up', '# One\n\nabcdef'],
|
||||
['down', 'abcdef\n\n# Two'],
|
||||
])('keeps the caret at its original offset after moving %s (no off-by-one)', (direction, md) => {
|
||||
const editor = editorWith('')
|
||||
editor.commands.setContent(md, { contentType: 'markdown' })
|
||||
editor.commands.focus()
|
||||
let textPos = -1
|
||||
editor.state.doc.descendants((node, pos) => {
|
||||
if (node.isText && node.text === 'abcdef') textPos = pos
|
||||
})
|
||||
editor.commands.setTextSelection(textPos + 3)
|
||||
if (direction === 'up') editor.commands.moveBlockUp()
|
||||
else editor.commands.moveBlockDown()
|
||||
const at = editor.state.selection.from
|
||||
expect(editor.state.doc.textBetween(at - 1, at)).toBe('c')
|
||||
expect(editor.state.doc.textBetween(at, at + 1)).toBe('d')
|
||||
editor.destroy()
|
||||
})
|
||||
|
||||
it('is a no-op at the top edge and keeps a moved list intact', () => {
|
||||
const top = editorWith('')
|
||||
top.commands.setContent('# One\n\nTwo', { contentType: 'markdown' })
|
||||
top.commands.focus()
|
||||
caretInto(top, 'One')
|
||||
top.commands.moveBlockUp()
|
||||
expect(top.getMarkdown().trim().startsWith('# One')).toBe(true)
|
||||
top.destroy()
|
||||
|
||||
const list = editorWith('')
|
||||
list.commands.setContent('- a\n- b\n\npara', { contentType: 'markdown' })
|
||||
list.commands.focus()
|
||||
caretInto(list, 'para')
|
||||
list.commands.moveBlockUp()
|
||||
expect(list.getMarkdown().trim()).toBe('para\n\n- a\n- b')
|
||||
list.destroy()
|
||||
})
|
||||
})
|
||||
+268
@@ -0,0 +1,268 @@
|
||||
import type { Editor } from '@tiptap/core'
|
||||
import { Extension } from '@tiptap/core'
|
||||
import { GapCursor } from '@tiptap/pm/gapcursor'
|
||||
import type { ResolvedPos } from '@tiptap/pm/model'
|
||||
import { NodeSelection, Plugin, PluginKey, Selection } from '@tiptap/pm/state'
|
||||
import { Decoration, DecorationSet } from '@tiptap/pm/view'
|
||||
import { MENTION_PLUGIN_KEY } from './mention'
|
||||
import { SLASH_COMMAND_PLUGIN_KEY } from './slash-command/slash-command'
|
||||
|
||||
/** Leaf nodes that have no text position, so they can only be reached as a NodeSelection. */
|
||||
const SELECTABLE_LEAVES = new Set(['horizontalRule', 'image'])
|
||||
|
||||
/**
|
||||
* Wrapper nodes whose empty child a boundary key must remove cleanly rather than lift. Lifting an empty
|
||||
* block out of one of these splits the container in two and strands an empty paragraph — a visible gap
|
||||
* that also fails to round-trip through markdown (see {@link removeEmptyWrappedBlock}).
|
||||
*/
|
||||
const WRAPPER_TYPES = new Set(['listItem', 'taskItem', 'blockquote'])
|
||||
|
||||
/** Item node types a list is built from, used to detect an empty item's position within its list. */
|
||||
const LIST_ITEM_TYPES = new Set(['listItem', 'taskItem'])
|
||||
|
||||
/** True when the resolved position sits anywhere inside a {@link WRAPPER_TYPES} ancestor. */
|
||||
function isInsideWrapper($from: ResolvedPos): boolean {
|
||||
for (let depth = $from.depth - 1; depth >= 1; depth--) {
|
||||
if (WRAPPER_TYPES.has($from.node(depth).type.name)) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes the empty textblock at `$from`, deleting up through the outermost ancestor it is the sole
|
||||
* child of, then places the caret at the end of the preceding block. This keeps a list or blockquote
|
||||
* whole when its middle/first/last item is emptied — where ProseMirror's default lift would split the
|
||||
* container and strand an empty paragraph (a visible gap, and markdown that re-parses to a different
|
||||
* document). Walking up while `childCount === 1` deletes the whole now-empty wrapper (the emptied list
|
||||
* item, not just its paragraph) so no orphan `<li>` or empty continuation line is left behind.
|
||||
*
|
||||
* The selection left behind must be a CARET, never a NodeSelection: `Selection.near` can silently
|
||||
* land a NodeSelection on an adjacent leaf (deleting the sole bullet at the top of a doc whose next
|
||||
* block is an image selected that image), turning the user's next keystroke destructive — a second
|
||||
* Backspace while "clearing the bullet" deleted the image, and typing would have replaced it. So:
|
||||
* end of the previous textblock first; else a gap cursor at the deletion point when the neighbour is
|
||||
* a leaf (typing there inserts a new block where the emptied one was, instead of replacing the leaf);
|
||||
* else the next textblock.
|
||||
*/
|
||||
function removeEmptyWrappedBlock(editor: Editor, $from: ResolvedPos): boolean {
|
||||
let depth = $from.depth
|
||||
while (depth > 1 && $from.node(depth - 1).childCount === 1) depth--
|
||||
const start = $from.before(depth)
|
||||
const end = $from.after(depth)
|
||||
return editor.commands.command(({ tr, dispatch }) => {
|
||||
if (dispatch) {
|
||||
tr.delete(start, end)
|
||||
const $gap = tr.doc.resolve(start)
|
||||
tr.setSelection(
|
||||
Selection.findFrom($gap, -1, true) ??
|
||||
(isLeafGap($gap) ? new GapCursor($gap) : null) ??
|
||||
Selection.findFrom($gap, 1, true) ??
|
||||
Selection.near($gap, -1)
|
||||
)
|
||||
dispatch(tr.scrollIntoView())
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* True when `$pos` is a block boundary a gap cursor is valid at in this schema: the following node is
|
||||
* a selectable leaf (divider/image) and there is nothing before it, or another leaf — i.e. no textblock
|
||||
* on either side for a normal caret to land in.
|
||||
*/
|
||||
function isLeafGap($pos: ResolvedPos): boolean {
|
||||
const after = $pos.nodeAfter
|
||||
if (!after || !SELECTABLE_LEAVES.has(after.type.name)) return false
|
||||
const before = $pos.nodeBefore
|
||||
return !before || SELECTABLE_LEAVES.has(before.type.name)
|
||||
}
|
||||
|
||||
/**
|
||||
* True while a `/` or `@` suggestion menu is open. Arrow keys must reach that menu's own handler, so
|
||||
* the leaf-selection shortcuts below yield rather than stealing the key to select an adjacent divider.
|
||||
*/
|
||||
function isSuggestionMenuOpen(editor: Editor): boolean {
|
||||
const { state } = editor
|
||||
return (
|
||||
MENTION_PLUGIN_KEY.getState(state)?.active === true ||
|
||||
SLASH_COMMAND_PLUGIN_KEY.getState(state)?.active === true
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Selects the leaf (divider/image) immediately across `boundary` in `direction`, or returns false if
|
||||
* the neighbour isn't a selectable leaf — the shared tail of both arrow handlers below.
|
||||
*/
|
||||
function selectLeafAcross(editor: Editor, boundary: number, direction: 'up' | 'down'): boolean {
|
||||
const resolved = editor.state.doc.resolve(boundary)
|
||||
const adjacent = direction === 'up' ? resolved.nodeBefore : resolved.nodeAfter
|
||||
if (!adjacent || !SELECTABLE_LEAVES.has(adjacent.type.name)) return false
|
||||
return editor.commands.setNodeSelection(
|
||||
direction === 'up' ? boundary - adjacent.nodeSize : boundary
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Arrowing off the edge of a textblock toward an adjacent divider or image selects that node
|
||||
* (a NodeSelection), giving keyboard parity with clicking it. Without this the gap cursor swallows
|
||||
* the arrow and the node can never be selected — or deleted — from the keyboard.
|
||||
*/
|
||||
function selectAdjacentLeaf(editor: Editor, direction: 'up' | 'down'): boolean {
|
||||
const { selection } = editor.state
|
||||
if (!selection.empty || !editor.view.endOfTextblock(direction)) return false
|
||||
const { $from } = selection
|
||||
const boundary = direction === 'up' ? $from.before($from.depth) : $from.after($from.depth)
|
||||
return selectLeafAcross(editor, boundary, direction)
|
||||
}
|
||||
|
||||
/**
|
||||
* When a divider/image is already selected, arrowing toward an immediately-adjacent divider/image
|
||||
* selects that one directly instead of stopping on the gap cursor between them — so stepping through a
|
||||
* run of dividers is one press each. A non-leaf neighbour (a textblock) falls through to the default,
|
||||
* which moves the caret into it.
|
||||
*/
|
||||
function selectAdjacentSelectedLeaf(editor: Editor, direction: 'up' | 'down'): boolean {
|
||||
const { selection } = editor.state
|
||||
if (!(selection instanceof NodeSelection) || !SELECTABLE_LEAVES.has(selection.node.type.name)) {
|
||||
return false
|
||||
}
|
||||
const boundary = direction === 'up' ? selection.from : selection.to
|
||||
return selectLeafAcross(editor, boundary, direction)
|
||||
}
|
||||
|
||||
/**
|
||||
* Editor-specific keyboard behavior layered on top of StarterKit's defaults:
|
||||
*
|
||||
* - **Backspace** at the start of a heading reverts it to a paragraph (ProseMirror's default joins or
|
||||
* no-ops, stranding the heading style; a second Backspace then merges as usual). At the start of an
|
||||
* *empty block inside a list item, task item, or blockquote* it removes that whole emptied wrapper via
|
||||
* {@link removeEmptyWrappedBlock} instead of ProseMirror's default lift — lifting an empty item out of
|
||||
* the middle of a list/quote splits the container in two and strands an empty paragraph (a visible gap
|
||||
* that also re-parses to a different markdown document), while the default `joinBackward` alternately
|
||||
* no-ops on nested items (leaving them stuck) or merges an empty continuation paragraph into the
|
||||
* previous item. At the start of a block whose previous sibling is a divider or image, where
|
||||
* ProseMirror's `joinBackward` can't cross the leaf and no-ops: an *empty* block is deleted (clearing
|
||||
* the blank line between/below dividers without touching the divider itself), while a *non-empty*
|
||||
* block selects the leaf — so a first Backspace highlights what a second deletes, the same
|
||||
* highlight-before-delete affordance as clicking it and parity with the arrow-key leaf selection.
|
||||
* - **Enter** on an *empty, non-trailing list/task item* removes the empty item ({@link
|
||||
* removeEmptyWrappedBlock}) rather than letting the default split the list into two around a stranded
|
||||
* empty paragraph (which does not round-trip). A *trailing* empty item still falls through to the
|
||||
* default, which exits the list — the standard "press Enter on a blank bullet to leave the list".
|
||||
* - **Mod-A** inside a code block selects only that block's contents; pressing it again (when the
|
||||
* block is already fully selected) falls through to the default whole-document select-all, the
|
||||
* same scoped behavior as a code editor.
|
||||
* - **ArrowUp/ArrowDown** select an adjacent divider or image, whether arrowing off a textblock edge
|
||||
* ({@link selectAdjacentLeaf}) or stepping from one already-selected leaf to the next
|
||||
* ({@link selectAdjacentSelectedLeaf}). (The `Mod-Shift-Arrow` block-reorder chords live separately
|
||||
* in `./block-mover.ts`.)
|
||||
*
|
||||
* Plus a plugin that (a) highlights dividers/images falling inside a range selection (e.g. select-all),
|
||||
* which the browser's native text highlight skips because leaves carry no text, and (b) flags the
|
||||
* editor (`data-gap-between-leaves`) while a gap cursor sits between two leaves, so the CSS can hide its
|
||||
* otherwise-stray caret.
|
||||
*/
|
||||
export const RichMarkdownKeymap = Extension.create({
|
||||
name: 'richMarkdownKeymap',
|
||||
priority: 1000,
|
||||
|
||||
addKeyboardShortcuts() {
|
||||
return {
|
||||
Backspace: ({ editor }) => {
|
||||
const { selection, doc } = editor.state
|
||||
if (!selection.empty || selection.$from.parentOffset !== 0) return false
|
||||
const { $from } = selection
|
||||
// A gap cursor at the start of the doc resolves at the top level (`depth === 0`, offset 0):
|
||||
// `$from.before(0)` below throws, and falling through instead is no better — TipTap's
|
||||
// blockquote Backspace handler crashes on the same resolution (`$from.node(-1)` is
|
||||
// undefined). There is nothing before the gap for Backspace to act on, so consume the key.
|
||||
if ($from.depth === 0) return true
|
||||
if ($from.parent.type.name === 'heading') {
|
||||
return editor.commands.setParagraph()
|
||||
}
|
||||
if ($from.parent.content.size === 0 && isInsideWrapper($from)) {
|
||||
return removeEmptyWrappedBlock(editor, $from)
|
||||
}
|
||||
const blockStart = $from.before($from.depth)
|
||||
const nodeBefore = doc.resolve(blockStart).nodeBefore
|
||||
if (!nodeBefore || !SELECTABLE_LEAVES.has(nodeBefore.type.name)) return false
|
||||
const leafStart = blockStart - nodeBefore.nodeSize
|
||||
if ($from.parent.isTextblock && $from.parent.content.size === 0) {
|
||||
return editor.commands.command(({ tr, dispatch }) => {
|
||||
if (dispatch) {
|
||||
tr.delete(blockStart, $from.after($from.depth))
|
||||
tr.setSelection(NodeSelection.create(tr.doc, leafStart))
|
||||
dispatch(tr.scrollIntoView())
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
return editor.commands.setNodeSelection(leafStart)
|
||||
},
|
||||
Enter: ({ editor }) => {
|
||||
const { selection } = editor.state
|
||||
if (!selection.empty || selection.$from.parentOffset !== 0) return false
|
||||
const { $from } = selection
|
||||
if ($from.parent.content.size !== 0) return false
|
||||
const itemDepth = $from.depth - 1
|
||||
if (itemDepth < 1 || !LIST_ITEM_TYPES.has($from.node(itemDepth).type.name)) return false
|
||||
const listDepth = itemDepth - 1
|
||||
const isTrailingItem = $from.index(listDepth) === $from.node(listDepth).childCount - 1
|
||||
if (isTrailingItem) return false
|
||||
return removeEmptyWrappedBlock(editor, $from)
|
||||
},
|
||||
'Mod-a': ({ editor }) => {
|
||||
const { $from } = editor.state.selection
|
||||
if ($from.parent.type.name !== 'codeBlock') return false
|
||||
const from = $from.start($from.depth)
|
||||
const to = $from.end($from.depth)
|
||||
if (editor.state.selection.from === from && editor.state.selection.to === to) return false
|
||||
return editor.commands.setTextSelection({ from, to })
|
||||
},
|
||||
ArrowUp: ({ editor }) =>
|
||||
!isSuggestionMenuOpen(editor) &&
|
||||
(selectAdjacentSelectedLeaf(editor, 'up') || selectAdjacentLeaf(editor, 'up')),
|
||||
ArrowDown: ({ editor }) =>
|
||||
!isSuggestionMenuOpen(editor) &&
|
||||
(selectAdjacentSelectedLeaf(editor, 'down') || selectAdjacentLeaf(editor, 'down')),
|
||||
}
|
||||
},
|
||||
|
||||
addProseMirrorPlugins() {
|
||||
return [
|
||||
new Plugin({
|
||||
key: new PluginKey('richLeafSelectionHighlight'),
|
||||
props: {
|
||||
decorations(state) {
|
||||
const { selection } = state
|
||||
if (selection.empty || selection instanceof NodeSelection) return null
|
||||
const decorations: Decoration[] = []
|
||||
state.doc.nodesBetween(selection.from, selection.to, (node, pos) => {
|
||||
if (SELECTABLE_LEAVES.has(node.type.name)) {
|
||||
decorations.push(
|
||||
Decoration.node(pos, pos + node.nodeSize, { class: 'rich-leaf-in-selection' })
|
||||
)
|
||||
}
|
||||
})
|
||||
return decorations.length ? DecorationSet.create(state.doc, decorations) : null
|
||||
},
|
||||
attributes(state): Record<string, string> {
|
||||
const { selection } = state
|
||||
if (!(selection instanceof GapCursor)) return {}
|
||||
const before = selection.$head.nodeBefore
|
||||
const after = selection.$head.nodeAfter
|
||||
if (
|
||||
before &&
|
||||
after &&
|
||||
SELECTABLE_LEAVES.has(before.type.name) &&
|
||||
SELECTABLE_LEAVES.has(after.type.name)
|
||||
) {
|
||||
return { 'data-gap-between-leaves': 'true' }
|
||||
}
|
||||
return {}
|
||||
},
|
||||
},
|
||||
}),
|
||||
]
|
||||
},
|
||||
})
|
||||
+70
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*
|
||||
* Typing a markdown link `[text](url)` should convert to a real link mark on the closing `)`.
|
||||
* Input rules only fire on real text input, so these drive the editor's `handleTextInput` path
|
||||
* (NOT `insertContent`, which bypasses input rules).
|
||||
*/
|
||||
import { Editor } from '@tiptap/core'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { createMarkdownContentExtensions } from './extensions'
|
||||
|
||||
let editor: Editor | null = null
|
||||
|
||||
afterEach(() => {
|
||||
editor?.destroy()
|
||||
editor = null
|
||||
})
|
||||
|
||||
function mount(): Editor {
|
||||
return new Editor({ extensions: createMarkdownContentExtensions() })
|
||||
}
|
||||
|
||||
/** Type `prefix` (no input rules), then simulate typing the final char so the input rule fires. */
|
||||
function typeWithFinalChar(ed: Editor, prefix: string, finalChar: string): boolean {
|
||||
ed.commands.setContent('', { contentType: 'markdown' })
|
||||
ed.commands.insertContent(prefix)
|
||||
const pos = ed.state.selection.from
|
||||
return ed.view.someProp('handleTextInput', (fn) => fn(ed.view, pos, pos, finalChar)) === true
|
||||
}
|
||||
|
||||
describe('typed markdown link input rule', () => {
|
||||
it('converts [text](url) to a link mark on the closing paren', () => {
|
||||
editor = mount()
|
||||
typeWithFinalChar(editor, '[hi](https://example.com', ')')
|
||||
const json = JSON.stringify(editor.getJSON())
|
||||
expect(json).toContain('"type":"link"')
|
||||
expect(json).toContain('"href":"https://example.com"')
|
||||
expect(editor.getText()).toBe('hi')
|
||||
})
|
||||
|
||||
it('normalizes a bare domain to https (parity with paste)', () => {
|
||||
editor = mount()
|
||||
typeWithFinalChar(editor, '[site](www.example.com', ')')
|
||||
expect(JSON.stringify(editor.getJSON())).toContain('"href":"https://www.example.com"')
|
||||
})
|
||||
|
||||
it('preserves a link title', () => {
|
||||
editor = mount()
|
||||
typeWithFinalChar(editor, '[t](https://e.com "the title"', ')')
|
||||
const json = JSON.stringify(editor.getJSON())
|
||||
expect(json).toContain('"href":"https://e.com"')
|
||||
expect(json).toContain('"title":"the title"')
|
||||
})
|
||||
|
||||
it('refuses an unsafe scheme (leaves it literal)', () => {
|
||||
editor = mount()
|
||||
typeWithFinalChar(editor, '[x](javascript:alert(1)', ')')
|
||||
expect(JSON.stringify(editor.getJSON())).not.toContain('"type":"link"')
|
||||
})
|
||||
|
||||
it('does not fire inside a code block', () => {
|
||||
editor = mount()
|
||||
editor.commands.setContent('```\n\n```', { contentType: 'markdown' })
|
||||
editor.commands.setTextSelection(2)
|
||||
const pos = editor.state.selection.from
|
||||
editor.commands.insertContent('[x](https://e.com')
|
||||
editor.view.someProp('handleTextInput', (fn) => fn(editor!.view, pos, pos, ')'))
|
||||
expect(JSON.stringify(editor.getJSON())).not.toContain('"type":"link"')
|
||||
})
|
||||
})
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
import { Extension, InputRule } from '@tiptap/core'
|
||||
import { normalizeLinkHref } from './markdown-fidelity'
|
||||
|
||||
/**
|
||||
* Typed markdown link: `[text](url)` or `[text](url "title")`, completed by the closing `)`. The URL
|
||||
* is space-free (markdown requires `<url>` for spaces, which this intentionally skips). StarterKit's
|
||||
* Link ships no input rule — only paste/autolink — so without this, typed link syntax stays literal.
|
||||
*/
|
||||
const LINK_INPUT_RULE = /\[([^\]]+)]\(([^)\s]+)(?:\s+"([^"]*)")?\)$/
|
||||
|
||||
/**
|
||||
* Converts a typed markdown link into a real link mark on the closing `)`. The visible text is the
|
||||
* first capture group (so `markInputRule`, which keeps the *last* group, can't express this); the
|
||||
* href comes from the second group, normalized through {@link normalizeLinkHref} so a bare domain
|
||||
* gets `https://` and an unsafe scheme (`javascript:`) is refused (left as literal text). Skipped
|
||||
* inside code blocks, where the brackets are literal source.
|
||||
*/
|
||||
export const MarkdownLinkInputRule = Extension.create({
|
||||
name: 'markdownLinkInputRule',
|
||||
|
||||
addInputRules() {
|
||||
return [
|
||||
new InputRule({
|
||||
find: LINK_INPUT_RULE,
|
||||
handler: ({ state, range, match }) => {
|
||||
if (state.selection.$from.parent.type.spec.code) return null
|
||||
const linkType = state.schema.marks.link
|
||||
if (!linkType) return null
|
||||
const [fullMatch, text, rawHref, title] = match
|
||||
const href = normalizeLinkHref(rawHref ?? '')
|
||||
if (!href || !text) return null
|
||||
|
||||
const { tr } = state
|
||||
const textStart = range.from + fullMatch.indexOf(text)
|
||||
const textEnd = textStart + text.length
|
||||
if (textEnd < range.to) tr.delete(textEnd, range.to)
|
||||
if (textStart > range.from) tr.delete(range.from, textStart)
|
||||
const markEnd = range.from + text.length
|
||||
tr.addMark(range.from, markEnd, linkType.create({ href, title: title || null }))
|
||||
tr.removeStoredMark(linkType)
|
||||
},
|
||||
}),
|
||||
]
|
||||
},
|
||||
})
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user