extend
uidocx-viewersource: ok · refreshed
DOCX Viewer
A DOCX viewer with upload, zoom, theme controls, and read-only rendering.
npx shadcn add "https://ui.extend.ai/r/docx-viewer.json"free
No selection tags yet — empty means no evidence, not lack of support.
Dependencies
Bundle weight · light@extend-ai/react-docx@^0.9.2@tanstack/react-virtual@^3.13.12
Registry dependencies
@extend/document-viewer-sidebar@extend/file-thumbnailutilsbuttondropdown-menuinputscroll-areaselectseparatortooltip@extend/docx-editorbadgecard
Files2 · source cached 10/8/2026, 8:51:06 AM
components/extend/docx-viewer.tsx
"use client"
import * as React from "react"
import { ScrollArea as ScrollAreaPrimitive } from "@base-ui/react/scroll-area"
import {
DocxEditorViewer,
useDocxComments,
useDocxEditor,
useDocxPageLayout,
useDocxTrackChanges,
useDocxViewerThumbnails,
type DocxDocumentTheme,
type DocxEditorController,
type DocxPageThumbnailItem,
type ViewerZoomLevel,
type ViewerZoomState,
} from "@extend-ai/react-docx"
import { useVirtualizer } from "@tanstack/react-virtual"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { Input } from "@/components/ui/input"
import {
ScrollArea as InlineScrollArea,
ScrollBar,
} from "@/components/ui/scroll-area"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { Separator } from "@/components/ui/separator"
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip"
import {
DocumentViewerThumbnailSidebar,
useElementWidth,
useInlineThumbnailSidebar,
} from "@/components/extend/document-viewer-sidebar"
import {
createDocxCommentCardRenderer,
createDocxTrackedChangeCardRenderer,
} from "@/components/extend/docx-annotation-card"
import { FileThumbnail } from "@/components/extend/file-thumbnail"
import { IconPlaceholder } from "@/components/icon-placeholder"
const DOCX_MIME_TYPE =
"application/vnd.openxmlformats-officedocument.wordprocessingml.document"
const DOCX_LOADING_INDICATOR_DELAY_MS = 300
const DOCX_THUMBNAIL_WIDTH = 92
const DOCX_THUMBNAIL_LIST_PADDING = 16
const DOCX_THUMBNAIL_ROW_ESTIMATE = 172
const DEFAULT_ZOOM = 50
const ZOOM_OPTIONS = [50, 75, 100, 125, 150, 175, 200] as const
const ZOOM_MODE_LABELS = {
"fit-page": "Fit page",
"fit-width": "Fit width",
automatic: "Automatic",
} satisfies Record<Exclude<ViewerZoomLevel, number>, string>
const DOCX_PADDING_WARNING_TEXT = "a style property during rerender"
const DOCX_THUMBNAIL_FOCUS_RING_CLASS =
"group-focus-visible/docx-thumbnail-sidebar:ring-2 group-focus-visible/docx-thumbnail-sidebar:ring-ring group-focus-visible/docx-thumbnail-sidebar:ring-offset-1 group-focus-visible/docx-thumbnail-sidebar:ring-offset-background"
const DOCX_THUMBNAIL_PREFETCH_ROWS = 4
type UploadedDocxFile = {
file: File
identity: string
sourceUrl: string | undefined
}
type DocxActivePageStore = {
getSnapshot: () => number
setActivePage: React.Dispatch<React.SetStateAction<number>>
subscribe: (listener: () => void) => () => void
}
type DocxThumbnailRenderWindowState = {
visiblePageIndexes: number[]
prefetchPageIndexes: number[]
}
function createDocxActivePageStore(): DocxActivePageStore {
let activePage = 1
const listeners = new Set<() => void>()
return {
getSnapshot: () => activePage,
setActivePage: (nextPage) => {
const value =
typeof nextPage === "function" ? nextPage(activePage) : nextPage
const normalizedValue = Math.max(1, Math.round(value || 1))
if (normalizedValue === activePage) return
activePage = normalizedValue
listeners.forEach((listener) => listener())
},
subscribe: (listener) => {
listeners.add(listener)
return () => listeners.delete(listener)
},
}
}
function useDocxActivePage(activePageStore: DocxActivePageStore) {
return React.useSyncExternalStore(
activePageStore.subscribe,
activePageStore.getSnapshot,
activePageStore.getSnapshot
)
}
function areNumberArraysEqual(left: number[], right: number[]) {
return (
left.length === right.length &&
left.every((value, index) => value === right[index])
)
}
async function loadDocxFile(
url: string,
displayFileName: string
): Promise<File> {
const response = await fetch(url)
if (!response.ok) {
throw new Error(`Failed to fetch DOCX (${response.status})`)
}
const blob = await response.blob()
return new File([blob], displayFileName, {
type: blob.type || DOCX_MIME_TYPE,
})
}
function formatDocumentName(fileName: string | undefined, url: string) {
if (fileName?.trim()) return fileName
const pathname = url.split("?")[0] ?? ""
const rawName = pathname.split("/").pop() ?? "document.docx"
try {
return decodeURIComponent(rawName)
} catch {
return rawName
}
}
function ensureDocxExtension(fileName: string) {
return fileName.toLowerCase().endsWith(".docx")
? fileName
: `${fileName}.docx`
}
function downloadBlob(blob: Blob, fileName: string) {
const url = URL.createObjectURL(blob)
const anchor = document.createElement("a")
anchor.href = url
anchor.download = fileName
anchor.rel = "noopener"
document.body.append(anchor)
anchor.click()
anchor.remove()
window.setTimeout(() => URL.revokeObjectURL(url), 0)
}
async function downloadDocxFile({
file,
fileName,
url,
}: {
file?: File
fileName: string
url?: string
}) {
if (file) {
downloadBlob(file, ensureDocxExtension(fileName))
return
}
if (!url) return
const response = await fetch(url)
if (!response.ok) {
throw new Error(`Failed to download DOCX (${response.status})`)
}
downloadBlob(await response.blob(), ensureDocxExtension(fileName))
}
function getNextZoomScale(currentZoomScale: number, direction: 1 | -1) {
if (direction > 0) {
return (
ZOOM_OPTIONS.find((value) => value > currentZoomScale) ?? currentZoomScale
)
}
for (let index = ZOOM_OPTIONS.length - 1; index >= 0; index -= 1) {
const value = ZOOM_OPTIONS[index]
if (value < currentZoomScale) return value
}
return currentZoomScale
}
function normalizeDocxZoomLevel(
value: ViewerZoomLevel | undefined
): ViewerZoomLevel {
return value ?? DEFAULT_ZOOM
}
function isZoomMode(value: string): value is Exclude<ViewerZoomLevel, number> {
return value in ZOOM_MODE_LABELS
}
function useDelayedLoadingIndicator(isLoading: boolean, delayMs: number) {
const [showSpinner, setShowSpinner] = React.useState(false)
const [previousIsLoading, setPreviousIsLoading] = React.useState(isLoading)
if (previousIsLoading !== isLoading) {
setPreviousIsLoading(isLoading)
setShowSpinner(false)
}
React.useEffect(() => {
if (!isLoading) return
const timeoutId = window.setTimeout(() => {
setShowSpinner(true)
}, delayMs)
return () => window.clearTimeout(timeoutId)
}, [delayMs, isLoading])
return isLoading && showSpinner
}
function isDocxPaddingWarning(args: unknown[]) {
return (
typeof args[0] === "string" &&
args[0].includes(DOCX_PADDING_WARNING_TEXT) &&
args.some((arg) => String(arg).includes("padding"))
)
}
function useSuppressDocxPaddingWarning(enabled: boolean) {
React.useEffect(() => {
if (!enabled) return
const originalConsoleError = console.error
console.error = (...args: unknown[]) => {
if (isDocxPaddingWarning(args)) return
originalConsoleError(...args)
}
return () => {
console.error = originalConsoleError
}
}, [enabled])
}
function isInteractiveViewerTarget(target: EventTarget | null) {
return (
target instanceof Element &&
Boolean(
target.closest(
'a[href], button, input, select, textarea, [contenteditable="true"], [role="button"], [role="link"]'
)
)
)
}
function ToolbarTooltip({
label,
children,
}: {
label: string
children: React.ReactNode
}) {
return (
<Tooltip>
<TooltipTrigger
render={<span className="inline-flex">{children}</span>}
></TooltipTrigger>
<TooltipContent side="bottom">{label}</TooltipContent>
</Tooltip>
)
}
function ViewerLoadingSurface({
showSpinner = true,
}: {
showSpinner?: boolean
}) {
return (
<div className="grid h-full min-h-52 place-items-center bg-transparent">
{showSpinner ? <InlineSpinner className="size-4" /> : null}
</div>
)
}
function DocxFileActionsMenu({
controlsDisabled,
downloadDisabled,
isPreparingDownload,
isDark,
onDownload,
onShowCommentsChange,
onShowTrackedChangesChange,
onIsDarkChange,
onUploadClick,
showComments,
showDownloadButton,
showNightRenderToggle,
showTrackedChanges,
showUploadButton,
}: {
controlsDisabled: boolean
downloadDisabled: boolean
isPreparingDownload: boolean
isDark: boolean
onDownload: () => void
onShowCommentsChange: (checked: boolean) => void
onShowTrackedChangesChange: (checked: boolean) => void
onIsDarkChange: (checked: boolean) => void
onUploadClick: () => void
showComments: boolean
showDownloadButton: boolean
showNightRenderToggle: boolean
showTrackedChanges: boolean
showUploadButton: boolean
}) {
const showFileActions = showDownloadButton || showUploadButton
return (
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label="Open DOCX actions"
>
<IconPlaceholder
lucide="Ellipsis"
tabler="IconDots"
hugeicons="MoreHorizontalIcon"
phosphor="DotsThreeIcon"
remixicon="RiMoreLine"
className="size-4"
/>
</Button>
}
></DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-52">
{showNightRenderToggle ? (
<>
<DropdownMenuCheckboxItem
checked={isDark}
disabled={controlsDisabled}
onCheckedChange={(checked) => onIsDarkChange(checked === true)}
className={cn("justify-between")}
>
<span className="flex min-w-0 items-center gap-2">
<IconPlaceholder
lucide="Moon"
tabler="IconMoon"
hugeicons="Moon02Icon"
phosphor="MoonIcon"
remixicon="RiMoonLine"
className="size-4"
/>
Dark mode
</span>
</DropdownMenuCheckboxItem>
<DropdownMenuSeparator />
</>
) : null}
<DropdownMenuCheckboxItem
checked={showComments}
disabled={controlsDisabled}
onCheckedChange={(checked) => onShowCommentsChange(checked === true)}
className={cn("justify-between")}
>
<span className="flex min-w-0 items-center gap-2">
<IconPlaceholder
lucide="MessageSquare"
tabler="IconMessage"
hugeicons="Comment01Icon"
phosphor="ChatIcon"
remixicon="RiChat1Line"
className="size-4"
/>
Comments
</span>
</DropdownMenuCheckboxItem>
<DropdownMenuCheckboxItem
checked={showTrackedChanges}
disabled={controlsDisabled}
onCheckedChange={(checked) =>
onShowTrackedChangesChange(checked === true)
}
className={cn("justify-between")}
>
<span className="flex min-w-0 items-center gap-2">
<IconPlaceholder
lucide="FileDiff"
tabler="IconFileDiff"
hugeicons="FileDiffIcon"
phosphor="GitDiffIcon"
remixicon="RiFileHistoryLine"
className="size-4"
/>
Edits
</span>
</DropdownMenuCheckboxItem>
{showFileActions ? <DropdownMenuSeparator /> : null}
{showDownloadButton ? (
<DropdownMenuItem disabled={downloadDisabled} onClick={onDownload}>
{isPreparingDownload ? (
<InlineSpinner className="size-4" />
) : (
<IconPlaceholder
lucide="Download"
tabler="IconDownload"
hugeicons="Download01Icon"
phosphor="DownloadSimpleIcon"
remixicon="RiDownload2Line"
className="size-4"
/>
)}
Download
</DropdownMenuItem>
) : null}
{showUploadButton ? (
<DropdownMenuItem onClick={onUploadClick}>
<IconPlaceholder
lucide="Upload"
tabler="IconUpload"
hugeicons="Upload01Icon"
phosphor="UploadSimpleIcon"
remixicon="RiUpload2Line"
className="size-4"
/>
Upload
</DropdownMenuItem>
) : null}
</DropdownMenuContent>
</DropdownMenu>
)
}
function DocxPageNumberControl({
activePageStore,
controlsDisabled,
onPageChange,
pageCount,
}: {
activePageStore: DocxActivePageStore
controlsDisabled: boolean
onPageChange: (pageNumber: number) => void
pageCount: number
}) {
const activePage = useDocxActivePage(activePageStore)
const inputRef = React.useRef<HTMLInputElement>(null)
const displayPage = pageCount ? activePage : 1
const [isEditing, setIsEditing] = React.useState(false)
const [draftPage, setDraftPage] = React.useState(() => String(displayPage))
React.useEffect(() => {
if (!isEditing) return
inputRef.current?.focus()
inputRef.current?.select()
}, [isEditing])
const applyPageDraft = React.useCallback(
(value: string) => {
const trimmedValue = value.trim()
if (!trimmedValue) return
const parsedPage = Number(trimmedValue)
if (!Number.isInteger(parsedPage)) return
onPageChange(Math.min(Math.max(parsedPage, 1), Math.max(pageCount, 1)))
},
[onPageChange, pageCount]
)
return (
<div className="flex items-center text-sm whitespace-nowrap text-primary">
<span>Page</span>
{isEditing ? (
<Input
ref={inputRef}
aria-label="Page number"
inputMode="numeric"
pattern="[0-9]*"
value={draftPage}
onBlur={() => setIsEditing(false)}
onChange={(event: React.ChangeEvent<HTMLInputElement>) => {
const nextValue = event.target.value
setDraftPage(nextValue)
applyPageDraft(nextValue)
}}
onKeyDown={(event: React.KeyboardEvent<HTMLInputElement>) => {
if (event.key === "Enter" || event.key === "Escape") {
event.currentTarget.blur()
}
}}
className={cn(
"h-8 px-2.5",
"mx-1 w-14 min-w-14 rounded-md [&_[data-slot=input]]:text-center"
)}
/>
) : (
<Button
type="button"
variant="ghost"
size="sm"
className="font-normal"
aria-label={`Current page ${displayPage}. Edit page number`}
disabled={controlsDisabled || !pageCount}
onClick={() => {
setDraftPage(String(displayPage))
setIsEditing(true)
}}
>
{displayPage}
</Button>
)}
<span>of {pageCount || "-"}</span>
</div>
)
}
function DocxToolbar({
activePageStore,
controlsDisabled,
isDark,
isPreparingDownload,
onDownload,
onIsDarkChange,
onPageChange,
onShowCommentsChange,
onShowTrackedChangesChange,
onToggleSidebar,
onUploadClick,
onZoomChange,
pageCount,
resolvedZoom,
showComments,
showDownloadButton = true,
showNightRenderToggle,
showTrackedChanges,
showUploadButton = true,
toolbarActions,
zoomLevel,
}: {
activePageStore: DocxActivePageStore
controlsDisabled: boolean
isDark: boolean
isPreparingDownload: boolean
onDownload: () => void
onIsDarkChange: (checked: boolean) => void
onPageChange: (pageNumber: number) => void
onShowCommentsChange: (checked: boolean) => void
onShowTrackedChangesChange: (checked: boolean) => void
onToggleSidebar: () => void
onUploadClick: () => void
onZoomChange: (zoomLevel: ViewerZoomLevel) => void
pageCount: number
resolvedZoom: number
showComments: boolean
showDownloadButton?: boolean
showNightRenderToggle: boolean
showTrackedChanges: boolean
showUploadButton?: boolean
toolbarActions?: React.ReactNode
zoomLevel: ViewerZoomLevel
}) {
const canZoomIn = resolvedZoom < ZOOM_OPTIONS[ZOOM_OPTIONS.length - 1]
const canZoomOut = resolvedZoom > ZOOM_OPTIONS[0]
const selectValue =
typeof zoomLevel === "number" ? zoomLevel.toString() : zoomLevel
const roundedZoom = Number(resolvedZoom.toFixed(2))
const zoomOptions = ZOOM_OPTIONS.includes(
roundedZoom as (typeof ZOOM_OPTIONS)[number]
)
? ZOOM_OPTIONS
: [...ZOOM_OPTIONS, roundedZoom].sort((left, right) => left - right)
return (
<div className="flex min-h-12 flex-wrap items-center justify-between gap-2 border-b bg-background px-3 py-2">
<TooltipProvider>
<div className="flex min-w-0 flex-wrap items-center gap-2">
<ToolbarTooltip label="Toggle thumbnails">
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label="Toggle thumbnails"
disabled={controlsDisabled}
onClick={onToggleSidebar}
>
<IconPlaceholder
lucide="PanelLeft"
tabler="IconLayoutSidebar"
hugeicons="SidebarLeftIcon"
phosphor="SidebarIcon"
remixicon="RiLayoutLeftLine"
className="size-4"
/>
</Button>
</ToolbarTooltip>
<DocxPageNumberControl
activePageStore={activePageStore}
controlsDisabled={controlsDisabled}
onPageChange={onPageChange}
pageCount={pageCount}
/>
</div>
<div className="ml-auto flex min-w-0 flex-wrap items-center justify-end gap-1">
<div className="flex flex-none items-center gap-1">
<ToolbarTooltip label="Zoom out">
<Button
type="button"
variant="ghost"
size="icon-sm"
disabled={controlsDisabled || !canZoomOut}
aria-label="Zoom out"
onClick={() => onZoomChange(getNextZoomScale(resolvedZoom, -1))}
>
<IconPlaceholder
lucide="CircleMinus"
tabler="IconCircleMinus"
hugeicons="MinusSignCircleIcon"
phosphor="MinusCircleIcon"
remixicon="RiIndeterminateCircleLine"
className="size-4"
/>
</Button>
</ToolbarTooltip>
<Select
value={selectValue}
onValueChange={(value) => {
if (value === null) return
onZoomChange(isZoomMode(value) ? value : Number(value))
}}
disabled={controlsDisabled}
modal={false}
>
<SelectTrigger
size="sm"
className="w-[104px] min-w-[104px]"
aria-label="Zoom level"
>
<SelectValue>{Math.round(resolvedZoom)}%</SelectValue>
</SelectTrigger>
<SelectContent align="end" alignItemWithTrigger={false}>
{Object.entries(ZOOM_MODE_LABELS).map(([value, label]) => (
<SelectItem key={value} value={value}>
{label}
</SelectItem>
))}
{zoomOptions.map((value) => (
<SelectItem key={value} value={value.toString()}>
{value}%
</SelectItem>
))}
</SelectContent>
</Select>
<ToolbarTooltip label="Zoom in">
<Button
type="button"
variant="ghost"
size="icon-sm"
disabled={controlsDisabled || !canZoomIn}
aria-label="Zoom in"
onClick={() => onZoomChange(getNextZoomScale(resolvedZoom, 1))}
>
<IconPlaceholder
lucide="CirclePlusIcon"
tabler="IconCirclePlusFilled"
hugeicons="PlusSignCircleIcon"
phosphor="PlusCircleIcon"
remixicon="RiAddCircleFill"
className="size-4"
/>
</Button>
</ToolbarTooltip>
</div>
{toolbarActions ? (
<>
<Separator
orientation="vertical"
className="mx-1 h-4 self-center"
/>
{toolbarActions}
</>
) : null}
<Separator orientation="vertical" className="mx-1 h-4 self-center" />
<DocxFileActionsMenu
controlsDisabled={controlsDisabled}
downloadDisabled={controlsDisabled || isPreparingDownload}
isPreparingDownload={isPreparingDownload}
isDark={isDark}
onDownload={onDownload}
onIsDarkChange={onIsDarkChange}
onShowCommentsChange={onShowCommentsChange}
onShowTrackedChangesChange={onShowTrackedChangesChange}
onUploadClick={onUploadClick}
showComments={showComments}
showDownloadButton={showDownloadButton}
showNightRenderToggle={showNightRenderToggle}
showTrackedChanges={showTrackedChanges}
showUploadButton={showUploadButton}
/>
</div>
</TooltipProvider>
</div>
)
}
function DocxSidebarThumbnail({
canvasRef,
displayFileName,
hasError,
isActive,
isLoading,
pageNumber,
pixelHeightPx,
pixelWidthPx,
previewAspectRatio,
}: {
canvasRef: React.RefCallback<HTMLCanvasElement>
displayFileName: string
hasError: boolean
isActive: boolean
isLoading: boolean
pageNumber: number
pixelHeightPx: number
pixelWidthPx: number
previewAspectRatio: number
}) {
return (
<FileThumbnail
file={{
name: `${displayFileName} page ${pageNumber}`,
type: DOCX_MIME_TYPE,
}}
previewAspectRatio={previewAspectRatio}
previewClassName="rounded-md bg-white"
previewContent={
<canvas
ref={canvasRef}
width={pixelWidthPx}
height={pixelHeightPx}
className="!size-full bg-white object-cover object-top"
/>
}
isLoading={isLoading}
hasError={hasError}
className={cn(
"w-[92px] rounded-md border-0 shadow-xs ring-0 transition-shadow duration-150",
isActive && "shadow-sm"
)}
/>
)
}
function DocxThumbnailSidebarList({
activePage,
displayFileName,
isLoadingDocument,
onSelectPage,
onThumbnailRenderWindowChange,
pageCount,
sidebarOpen,
thumbnails,
}: {
activePage: number
displayFileName: string
isLoadingDocument: boolean
onSelectPage: (pageNumber: number) => void
onThumbnailRenderWindowChange: (
renderWindow: DocxThumbnailRenderWindowState
) => void
pageCount: number
sidebarOpen: boolean
thumbnails: DocxPageThumbnailItem[]
}) {
const viewportRef = React.useRef<HTMLDivElement | null>(null)
const thumbnailListboxId = React.useId()
const visibleThumbnails = React.useMemo(
() => thumbnails.slice(0, pageCount || 0),
[pageCount, thumbnails]
)
const activeDescendantId =
activePage > 0 && visibleThumbnails.length
? `${thumbnailListboxId}-page-${activePage}`
: undefined
const virtualizer = useVirtualizer({
count: visibleThumbnails.length,
estimateSize: () => DOCX_THUMBNAIL_ROW_ESTIMATE,
getItemKey: (index) => visibleThumbnails[index]?.pageIndex ?? index,
getScrollElement: () => viewportRef.current,
overscan: 3,
})
const virtualItems = virtualizer.getVirtualItems()
const renderWindowSignature = virtualItems
.map((virtualRow) => virtualRow.index)
.join(",")
React.useEffect(() => {
if (!sidebarOpen || isLoadingDocument || !visibleThumbnails.length) {
onThumbnailRenderWindowChange({
prefetchPageIndexes: [],
visiblePageIndexes: [],
})
return
}
const visiblePageIndexes = virtualItems
.map((virtualRow) => visibleThumbnails[virtualRow.index]?.pageIndex)
.filter((pageIndex): pageIndex is number => pageIndex !== undefined)
const firstVirtualIndex = virtualItems[0]?.index ?? 0
const lastVirtualIndex =
virtualItems[virtualItems.length - 1]?.index ?? firstVirtualIndex
const firstPrefetchIndex = Math.max(
0,
firstVirtualIndex - DOCX_THUMBNAIL_PREFETCH_ROWS
)
const lastPrefetchIndex = Math.min(
visibleThumbnails.length - 1,
lastVirtualIndex + DOCX_THUMBNAIL_PREFETCH_ROWS
)
const visiblePageIndexSet = new Set(visiblePageIndexes)
const prefetchPageIndexes: number[] = []
for (
let index = firstPrefetchIndex;
index <= lastPrefetchIndex;
index += 1
) {
const pageIndex = visibleThumbnails[index]?.pageIndex
if (pageIndex !== undefined && !visiblePageIndexSet.has(pageIndex)) {
prefetchPageIndexes.push(pageIndex)
}
}
onThumbnailRenderWindowChange({
prefetchPageIndexes,
visiblePageIndexes,
})
}, [
isLoadingDocument,
onThumbnailRenderWindowChange,
renderWindowSignature,
sidebarOpen,
visibleThumbnails,
virtualItems,
])
React.useEffect(() => {
if (!sidebarOpen || activePage < 1 || !visibleThumbnails.length) return
virtualizer.scrollToIndex(
Math.min(activePage - 1, visibleThumbnails.length - 1),
{ align: "auto" }
)
}, [activePage, sidebarOpen, virtualizer, visibleThumbnails.length])
const handleKeyDown = React.useCallback(
(event: React.KeyboardEvent<HTMLDivElement>) => {
if (pageCount < 1) return
const currentPage = activePage > 0 ? activePage : 1
let nextPage: number | null = null
if (event.key === "ArrowDown") {
nextPage = Math.min(pageCount, currentPage + 1)
} else if (event.key === "ArrowUp") {
nextPage = Math.max(1, currentPage - 1)
} else if (event.key === "Home") {
nextPage = 1
} else if (event.key === "End") {
nextPage = pageCount
}
if (nextPage === null) return
event.preventDefault()
onSelectPage(nextPage)
},
[activePage, onSelectPage, pageCount]
)
return (
<InlineScrollArea2
className="h-full"
scrollFade
viewportClassName="group/docx-thumbnail-sidebar focus-visible:ring-0 focus-visible:ring-offset-0"
viewportProps={{
"aria-activedescendant": activeDescendantId,
"aria-busy": isLoadingDocument || undefined,
"aria-label": "DOCX pages",
onKeyDown: handleKeyDown,
onMouseDown: (event) => {
event.currentTarget.focus({ preventScroll: true })
},
role: "listbox",
tabIndex: 0,
}}
viewportRef={viewportRef}
>
{isLoadingDocument ? (
<div className="p-4">
<div className="mx-auto h-28 w-20 overflow-hidden rounded-md bg-background shadow-xs">
<div className="h-full animate-pulse bg-muted" />
</div>
<div className="mx-auto mt-3 h-3 w-10 rounded-full bg-muted" />
</div>
) : visibleThumbnails.length ? (
<div
className="relative"
style={{
height:
virtualizer.getTotalSize() + DOCX_THUMBNAIL_LIST_PADDING * 2,
}}
>
{virtualItems.map((virtualRow) => {
const thumbnail = visibleThumbnails[virtualRow.index]
if (!thumbnail) return null
return (
<div
key={virtualRow.key}
ref={virtualizer.measureElement}
data-index={virtualRow.index}
className={cn(
"absolute top-0 right-3 left-3 pb-3 [contain:layout]",
thumbnail.pageNumber === activePage && "z-10"
)}
style={{
transform: `translateY(${virtualRow.start + DOCX_THUMBNAIL_LIST_PADDING}px)`,
}}
>
<div
id={`${thumbnailListboxId}-page-${thumbnail.pageNumber}`}
role="option"
aria-current={
thumbnail.pageNumber === activePage ? "page" : undefined
}
aria-label={`Page ${thumbnail.pageNumber}`}
aria-posinset={thumbnail.pageNumber}
aria-selected={thumbnail.pageNumber === activePage}
aria-setsize={pageCount}
data-docx-viewer-thumbnail-option={thumbnail.pageNumber}
className={cn(
"flex h-auto w-full cursor-default flex-col items-center gap-2 rounded-md p-2 text-xs transition-shadow outline-none select-none hover:bg-sidebar-accent",
thumbnail.pageNumber === activePage &&
"bg-sidebar-accent text-foreground",
thumbnail.pageNumber !== activePage &&
"text-muted-foreground",
thumbnail.pageNumber === activePage &&
DOCX_THUMBNAIL_FOCUS_RING_CLASS
)}
onClick={() => onSelectPage(thumbnail.pageNumber)}
>
<DocxSidebarThumbnail
canvasRef={thumbnail.canvasRef}
displayFileName={displayFileName}
hasError={thumbnail.status === "error"}
isActive={thumbnail.pageNumber === activePage}
isLoading={
thumbnail.status !== "ready" &&
thumbnail.status !== "error"
}
pageNumber={thumbnail.pageNumber}
pixelHeightPx={thumbnail.pixelHeightPx}
pixelWidthPx={thumbnail.pixelWidthPx}
previewAspectRatio={thumbnail.aspectRatio}
/>
{thumbnail.pageNumber}
</div>
</div>
)
})}
</div>
) : null}
</InlineScrollArea2>
)
}
function DocxThumbnailSidebarContent({
activePageStore,
displayFileName,
editor,
isLoadingDocument,
onSelectPage,
pageCount,
reportedPageCount,
sidebarOpen,
}: {
activePageStore: DocxActivePageStore
displayFileName: string
editor: DocxEditorController
isLoadingDocument: boolean
onSelectPage: (pageNumber: number) => void
pageCount: number
reportedPageCount: number
sidebarOpen: boolean
}) {
const [thumbnailRenderWindow, setThumbnailRenderWindow] =
React.useState<DocxThumbnailRenderWindowState>({
prefetchPageIndexes: [],
visiblePageIndexes: [],
})
const thumbnailEditor = React.useMemo<DocxEditorController>(
() => ({
...editor,
totalPages: Math.max(editor.totalPages, reportedPageCount),
}),
[editor, reportedPageCount]
)
const thumbnailOptions = React.useMemo(
() => ({
// Detached thumbnail rendering handles offscreen pages; keep the raster
// queue dormant while the sidebar is closed.
disabled: !sidebarOpen,
pixelRatio: 2,
renderWindow: thumbnailRenderWindow,
resolution: {
maxHeight: DOCX_THUMBNAIL_WIDTH * 1.35,
maxWidth: DOCX_THUMBNAIL_WIDTH,
},
}),
[sidebarOpen, thumbnailRenderWindow]
)
const { thumbnails } = useDocxViewerThumbnails(
thumbnailEditor,
thumbnailOptions
)
const activePage = useDocxActivePage(activePageStore)
const handleThumbnailRenderWindowChange = React.useCallback(
(nextRenderWindow: DocxThumbnailRenderWindowState) => {
setThumbnailRenderWindow((currentRenderWindow) => {
if (
areNumberArraysEqual(
currentRenderWindow.visiblePageIndexes,
nextRenderWindow.visiblePageIndexes
) &&
areNumberArraysEqual(
currentRenderWindow.prefetchPageIndexes,
nextRenderWindow.prefetchPageIndexes
)
) {
return currentRenderWindow
}
return nextRenderWindow
})
},
[]
)
if (!sidebarOpen) return null
return (
<DocxThumbnailSidebarList
activePage={activePage}
displayFileName={displayFileName}
isLoadingDocument={isLoadingDocument}
onSelectPage={onSelectPage}
onThumbnailRenderWindowChange={handleThumbnailRenderWindowChange}
pageCount={pageCount}
sidebarOpen={sidebarOpen}
thumbnails={thumbnails}
/>
)
}
export function DocxViewerPreview({
className,
defaultZoom = DEFAULT_ZOOM,
fileName,
isDark,
onIsDarkChange,
showDownload = true,
showToolbar = true,
showUpload = true,
src,
toolbarActions,
}: {
className?: string
defaultZoom?: ViewerZoomLevel
fileName?: string
isDark: boolean
onIsDarkChange: (isDark: boolean) => void
showDownload?: boolean
showToolbar?: boolean
showUpload?: boolean
src?: string
toolbarActions?: React.ReactNode
}) {
return (
<DocxViewerContent
className={className}
defaultZoom={defaultZoom}
effectiveIsDark={isDark}
fileName={fileName}
setNightRenderEnabled={onIsDarkChange}
shouldRenderNightMode
showDownload={showDownload}
showToolbar={showToolbar}
showUpload={showUpload}
toolbarActions={toolbarActions}
url={src}
/>
)
}
function DocxViewerContent({
className,
defaultZoom,
effectiveIsDark,
fileName,
setNightRenderEnabled,
shouldRenderNightMode,
showDownload,
showToolbar = true,
showUpload,
toolbarActions,
url,
}: {
className?: string
defaultZoom?: ViewerZoomLevel
effectiveIsDark: boolean
fileName?: string
setNightRenderEnabled: (checked: boolean) => void
shouldRenderNightMode: boolean
showDownload: boolean
showToolbar?: boolean
showUpload: boolean
toolbarActions?: React.ReactNode
url?: string
}) {
const fileInputRef = React.useRef<HTMLInputElement>(null)
const viewportRef = React.useRef<HTMLDivElement | null>(null)
const [viewportElement, setViewportElement] =
React.useState<HTMLDivElement | null>(null)
const [viewerShellRef, viewerShellWidth] = useElementWidth<HTMLDivElement>()
const [uploadedDocxFile, setUploadedDocxFile] =
React.useState<UploadedDocxFile | null>(null)
const [sidebarOpen, setSidebarOpen] = React.useState(false)
const activePageStore = React.useMemo(() => createDocxActivePageStore(), [])
const resolvedDefaultZoomLevel = normalizeDocxZoomLevel(defaultZoom)
const activeUploadedDocxFile =
uploadedDocxFile?.sourceUrl === url ? uploadedDocxFile : null
const documentKey = activeUploadedDocxFile?.identity ?? url ?? ""
const setActivePage = activePageStore.setActivePage
const sidebarInline = useInlineThumbnailSidebar(viewerShellWidth)
const viewerBackgroundColor =
"color-mix(in oklab, var(--muted) 40%, transparent)"
const displayFileName = React.useMemo(
() =>
activeUploadedDocxFile?.file.name ??
(url ? formatDocumentName(fileName, url) : (fileName ?? "document.docx")),
[activeUploadedDocxFile?.file.name, fileName, url]
)
const [initialDocumentTheme] = React.useState<DocxDocumentTheme>(() =>
effectiveIsDark ? "dark" : "light"
)
const editorOptions = React.useMemo(
() => ({
initialDocumentTheme,
initialFileName: displayFileName,
}),
[displayFileName, initialDocumentTheme]
)
const editor = useDocxEditor(editorOptions)
const { layout: pageLayout } = useDocxPageLayout(editor)
const { importDocxFile, setDocumentTheme, status } = editor
const { showComments, setShowComments } = useDocxComments(editor)
const { showTrackedChanges, setShowTrackedChanges } =
useDocxTrackChanges(editor)
const [reportedPageCount, setReportedPageCount] = React.useState(0)
const [zoomState, setZoomState] = React.useState({
documentKey: "",
level: resolvedDefaultZoomLevel,
resolvedZoom:
typeof resolvedDefaultZoomLevel === "number"
? resolvedDefaultZoomLevel
: DEFAULT_ZOOM,
})
const activeZoomState =
zoomState.documentKey === documentKey
? zoomState
: {
documentKey,
level: resolvedDefaultZoomLevel,
resolvedZoom:
typeof resolvedDefaultZoomLevel === "number"
? resolvedDefaultZoomLevel
: DEFAULT_ZOOM,
}
const setZoomLevel = React.useCallback(
(level: ViewerZoomLevel) => {
setZoomState({
documentKey,
level,
resolvedZoom:
typeof level === "number" ? level : activeZoomState.resolvedZoom,
})
},
[activeZoomState.resolvedZoom, documentKey]
)
const handleZoomChange = React.useCallback(
(state: ViewerZoomState) => {
setZoomState({ documentKey, ...state })
},
[documentKey]
)
const [loadError, setLoadError] = React.useState<string>()
const [isLoadingDocument, setIsLoadingDocument] = React.useState(true)
const [isPreparingDownload, setIsPreparingDownload] = React.useState(false)
const shouldShowDocumentSpinner = useDelayedLoadingIndicator(
isLoadingDocument,
DOCX_LOADING_INDICATOR_DELAY_MS
)
const loadingState = (
<ViewerLoadingSurface showSpinner={shouldShowDocumentSpinner} />
)
const documentTheme = effectiveIsDark ? "dark" : "light"
const renderTrackedChangeCard = React.useMemo(
() => createDocxTrackedChangeCardRenderer(documentTheme),
[documentTheme]
)
const renderCommentCard = React.useMemo(
() => createDocxCommentCardRenderer(documentTheme),
[documentTheme]
)
const hasDocument = Boolean(url || activeUploadedDocxFile)
const pageCount =
hasDocument && !isLoadingDocument && !loadError
? Math.max(1, reportedPageCount || editor.totalPages)
: 0
const thumbnailSidebarVisible = Boolean(
sidebarOpen && (pageCount || isLoadingDocument)
)
const controlsDisabled =
!hasDocument || isLoadingDocument || Boolean(loadError)
const handlePageCountChange = React.useCallback((nextPageCount: number) => {
setReportedPageCount(Math.max(1, Math.round(nextPageCount || 1)))
}, [])
const setViewportRef = React.useCallback((element: HTMLDivElement | null) => {
viewportRef.current = element
setViewportElement(element)
}, [])
const pageVirtualization = React.useMemo(
() => ({
enabled: true,
overscan: 1,
scrollElement: viewportElement,
}),
[viewportElement]
)
const handleDownload = React.useCallback(async () => {
if (isPreparingDownload) return
if (!activeUploadedDocxFile && !url) return
setIsPreparingDownload(true)
try {
await downloadDocxFile({
file: activeUploadedDocxFile?.file,
fileName: displayFileName,
url,
})
} catch (error) {
console.error(error)
} finally {
setIsPreparingDownload(false)
}
}, [activeUploadedDocxFile, displayFileName, isPreparingDownload, url])
useSuppressDocxPaddingWarning(!isLoadingDocument && !loadError)
React.useEffect(() => {
setActivePage(1)
viewportRef.current?.scrollTo({ top: 0, left: 0 })
}, [documentKey, setActivePage])
React.useEffect(() => {
setDocumentTheme(effectiveIsDark ? "dark" : "light")
}, [effectiveIsDark, setDocumentTheme])
React.useEffect(() => {
if (
status.startsWith("Failed to load file") ||
status === "Only .docx files are supported"
) {
const frame = window.requestAnimationFrame(() => {
setLoadError(status)
setIsLoadingDocument(false)
})
return () => window.cancelAnimationFrame(frame)
}
}, [status])
// Imports mutate the shared editor instance; concurrent calls (effect
// re-runs, StrictMode double-invoke) race inside the parser and surface as
// bogus "Invalid DOCX ZIP" errors, so every import is chained through here.
const importQueueRef = React.useRef<Promise<void>>(Promise.resolve())
React.useEffect(() => {
let isCurrent = true
async function load() {
// Superseded while queued — let the newest import run instead.
if (!isCurrent) return
if (!activeUploadedDocxFile && !url) {
setIsLoadingDocument(false)
setLoadError(undefined)
setReportedPageCount(0)
return
}
setIsLoadingDocument(true)
setLoadError(undefined)
setReportedPageCount(0)
try {
const docxFile =
activeUploadedDocxFile?.file ??
(url ? await loadDocxFile(url, displayFileName) : null)
if (!docxFile) return
await importDocxFile(docxFile)
if (isCurrent) {
setIsLoadingDocument(false)
setActivePage(1)
viewportRef.current?.scrollTo({ top: 0, left: 0 })
}
} catch (error) {
if (isCurrent) {
setLoadError(
error instanceof Error ? error.message : "Unknown DOCX load error"
)
setIsLoadingDocument(false)
}
}
}
importQueueRef.current = importQueueRef.current.then(load)
return () => {
isCurrent = false
}
}, [
activeUploadedDocxFile,
displayFileName,
importDocxFile,
setActivePage,
url,
])
const updateActivePageFromViewport = React.useCallback(() => {
const viewport = viewportRef.current
if (!viewport || !pageCount) return
const viewportRect = viewport.getBoundingClientRect()
const viewportCenter = viewportRect.top + viewportRect.height / 2
let closestPage = 1
let closestDistance = Number.POSITIVE_INFINITY
viewport
.querySelectorAll<HTMLElement>(
'[data-docx-page-wrapper="true"][data-index]'
)
.forEach((page) => {
const pageIndex = Number(page.dataset.index)
if (!Number.isFinite(pageIndex)) return
const pageRect = page.getBoundingClientRect()
const pageCenter = pageRect.top + pageRect.height / 2
const distance = Math.abs(pageCenter - viewportCenter)
if (distance < closestDistance) {
closestDistance = distance
closestPage = pageIndex + 1
}
})
activePageStore.setActivePage((currentPage) =>
currentPage === closestPage ? currentPage : closestPage
)
}, [activePageStore, pageCount])
React.useEffect(() => {
const viewport = viewportRef.current
if (!viewport || !pageCount) return
let frameId = 0
const handleScroll = () => {
window.cancelAnimationFrame(frameId)
frameId = window.requestAnimationFrame(updateActivePageFromViewport)
}
frameId = window.requestAnimationFrame(updateActivePageFromViewport)
viewport.addEventListener("scroll", handleScroll, { passive: true })
return () => {
window.cancelAnimationFrame(frameId)
viewport.removeEventListener("scroll", handleScroll)
}
}, [pageCount, updateActivePageFromViewport])
const scrollToPage = React.useCallback(
(pageNumber: number) => {
const viewport = viewportRef.current
const targetPageIndex = pageNumber - 1
const page = viewport?.querySelector<HTMLElement>(
`[data-docx-page-wrapper="true"][data-index="${targetPageIndex}"]`
)
setActivePage(pageNumber)
if (!viewport) return
if (!page) {
const pageStridePx =
(pageLayout.pageHeightPx + pageLayout.viewportDefaults.pageGapPx) *
(activeZoomState.resolvedZoom / 100)
viewport.scrollTo({
top: Math.max(0, targetPageIndex * pageStridePx - 24),
behavior: "auto",
})
return
}
viewport.scrollTo({
top:
page.getBoundingClientRect().top -
viewport.getBoundingClientRect().top +
viewport.scrollTop -
24,
behavior: "auto",
})
},
[
pageLayout.pageHeightPx,
pageLayout.viewportDefaults.pageGapPx,
setActivePage,
activeZoomState.resolvedZoom,
]
)
async function handleUpload(event: React.ChangeEvent<HTMLInputElement>) {
const file = event.target.files?.[0]
event.target.value = ""
if (!file) return
setZoomLevel(resolvedDefaultZoomLevel)
setActivePage(1)
setReportedPageCount(0)
setUploadedDocxFile({
file,
identity: `${file.name}-${file.size}-${file.lastModified}`,
sourceUrl: url,
})
}
return (
<div
className={cn(
"flex h-[640px] min-h-0 flex-col overflow-hidden bg-background",
className
)}
>
<input
ref={fileInputRef}
type="file"
accept=".doc,.docx,application/msword,application/vnd.openxmlformats-officedocument.wordprocessingml.document"
className="hidden"
onChange={handleUpload}
/>
{showToolbar ? (
<DocxToolbar
activePageStore={activePageStore}
controlsDisabled={controlsDisabled}
isDark={effectiveIsDark}
isPreparingDownload={isPreparingDownload}
onDownload={handleDownload}
onIsDarkChange={setNightRenderEnabled}
onPageChange={scrollToPage}
onShowCommentsChange={setShowComments}
onShowTrackedChangesChange={setShowTrackedChanges}
onToggleSidebar={() => setSidebarOpen((open) => !open)}
onUploadClick={() => fileInputRef.current?.click()}
pageCount={pageCount}
onZoomChange={setZoomLevel}
showComments={showComments}
showDownloadButton={showDownload}
showNightRenderToggle={shouldRenderNightMode}
showTrackedChanges={showTrackedChanges}
showUploadButton={showUpload}
toolbarActions={toolbarActions}
resolvedZoom={activeZoomState.resolvedZoom}
zoomLevel={activeZoomState.level}
/>
) : null}
<div
ref={viewerShellRef}
className="relative flex min-h-0 flex-1 overflow-hidden bg-muted/30"
>
<DocumentViewerThumbnailSidebar
inline={sidebarInline}
open={thumbnailSidebarVisible}
>
<DocxThumbnailSidebarContent
activePageStore={activePageStore}
displayFileName={displayFileName}
editor={editor}
isLoadingDocument={isLoadingDocument}
onSelectPage={scrollToPage}
pageCount={pageCount}
reportedPageCount={reportedPageCount}
sidebarOpen={thumbnailSidebarVisible}
/>
</DocumentViewerThumbnailSidebar>
<InlineScrollArea2
className="min-h-0 flex-1"
style={{ backgroundColor: viewerBackgroundColor }}
viewportClassName="px-4 py-6"
viewportProps={{
"aria-label": "DOCX document",
onMouseDown: (event) => {
if (isInteractiveViewerTarget(event.target)) return
event.currentTarget.focus({ preventScroll: true })
},
tabIndex: 0,
}}
viewportRef={setViewportRef}
>
{!url && !activeUploadedDocxFile ? (
<div className="grid h-full min-h-96 place-items-center p-6 text-center">
<div className="max-w-md rounded-lg border bg-background p-4 text-sm shadow-xs">
<div className="font-medium">
Upload a Word document to preview
</div>
<div className="mt-1 text-muted-foreground">
Pass a DOCX URL with the <code>src</code> prop or upload a
file.
</div>
<div className="mt-1 text-muted-foreground">
Legacy <code>.doc</code> support is limited and experimental;
convert to DOCX for best fidelity.
</div>
<Button
type="button"
variant="outline"
size="sm"
className="mt-4"
onClick={() => fileInputRef.current?.click()}
>
<IconPlaceholder
lucide="Upload"
tabler="IconUpload"
hugeicons="Upload01Icon"
phosphor="UploadSimpleIcon"
remixicon="RiUpload2Line"
className="size-4"
/>
Upload Word document
</Button>
</div>
</div>
) : loadError ? (
<div className="grid h-full min-h-96 place-items-center p-6 text-center">
<div className="max-w-md rounded-lg border bg-background p-4 text-sm text-destructive shadow-xs">
<div className="font-medium">Unable to display DOCX</div>
<div className="mt-1 text-muted-foreground">{loadError}</div>
</div>
</div>
) : isLoadingDocument ? (
loadingState
) : (
<div className="flex min-h-full w-max min-w-full justify-center">
<div className={cn(effectiveIsDark && "docx-night-reader-shell")}>
<DocxEditorViewer
editor={editor}
mode="read-only"
zoom={activeZoomState.level}
onZoomChange={handleZoomChange}
showTrackedChanges={showTrackedChanges}
renderTrackedChangeCard={renderTrackedChangeCard}
showComments={showComments}
renderCommentCard={renderCommentCard}
loadingState={loadingState}
pageBackgroundColor={effectiveIsDark ? "#0a0a0a" : undefined}
pageGapBackgroundColor={viewerBackgroundColor}
pageVirtualization={pageVirtualization}
deferInitialPaginationPaint={false}
onPageCountChange={handlePageCountChange}
/>
</div>
</div>
)}
</InlineScrollArea2>
</div>
</div>
)
}
function InlineScrollArea2({
className,
children,
orientation = "both",
scrollFade = false,
scrollbarGutter = false,
scrollbarOverflowOnly = false,
viewportClassName,
viewportProps,
viewportRef,
...props
}: InlineScrollAreaProps) {
const {
className: viewportPropsClassName,
ref: viewportPropsRef,
...resolvedViewportProps
} = viewportProps ?? {}
const composedViewportRef = React.useMemo(
() => InlineComposeRefs(viewportPropsRef, viewportRef),
[viewportPropsRef, viewportRef]
)
if (
!viewportProps &&
!viewportRef &&
!viewportClassName &&
!scrollFade &&
!scrollbarGutter &&
!scrollbarOverflowOnly
) {
return (
<InlineScrollArea
{...props}
className={cn(
"size-full min-h-0",
orientation === "horizontal" &&
"[&>[data-orientation=vertical]]:hidden",
className
)}
>
{children}
{orientation !== "vertical" ? (
<ScrollBar orientation="horizontal" />
) : null}
</InlineScrollArea>
)
}
return (
<ScrollAreaPrimitive.Root
className={cn(
"size-full min-h-0",
scrollbarOverflowOnly &&
"[&:not(:has([data-slot=scroll-area-viewport][data-has-overflow-x]))_[data-orientation=horizontal]]:hidden [&:not(:has([data-slot=scroll-area-viewport][data-has-overflow-y]))_[data-orientation=vertical]]:hidden",
className
)}
{...props}
>
<ScrollAreaPrimitive.Viewport
{...resolvedViewportProps}
ref={composedViewportRef}
className={cn(
"h-full rounded-[inherit] outline-none focus-visible:ring-2 focus-visible:ring-ring",
scrollFade &&
"mask-t-from-[calc(100%-min(var(--fade-size),var(--scroll-area-overflow-y-start)))] mask-r-from-[calc(100%-min(var(--fade-size),var(--scroll-area-overflow-x-end)))] mask-b-from-[calc(100%-min(var(--fade-size),var(--scroll-area-overflow-y-end)))] mask-l-from-[calc(100%-min(var(--fade-size),var(--scroll-area-overflow-x-start)))] [--fade-size:1.5rem]",
scrollbarGutter && orientation !== "vertical" && "pb-3.5",
scrollbarGutter && orientation !== "horizontal" && "pe-3.5",
viewportPropsClassName,
viewportClassName
)}
data-slot="scroll-area-viewport"
>
{children}
</ScrollAreaPrimitive.Viewport>
{orientation !== "horizontal" ? (
<ScrollBar orientation="vertical" />
) : null}
{orientation !== "vertical" ? (
<ScrollBar orientation="horizontal" />
) : null}
{orientation === "both" ? <ScrollAreaPrimitive.Corner /> : null}
</ScrollAreaPrimitive.Root>
)
}
type InlineScrollAreaProps = ScrollAreaPrimitive.Root.Props & {
orientation?: "vertical" | "horizontal" | "both"
scrollFade?: boolean
scrollbarGutter?: boolean
scrollbarOverflowOnly?: boolean
viewportClassName?: string
viewportProps?: ScrollAreaPrimitive.Viewport.Props
viewportRef?: React.Ref<HTMLDivElement>
}
function InlineComposeRefs<T>(...refs: Array<React.Ref<T> | undefined>) {
return (node: T | null) => {
for (const ref of refs) {
if (!ref) continue
if (typeof ref === "function") ref(node)
else ref.current = node
}
}
}
function InlineSpinner({ className, ...props }: InlineRegistryIconProps) {
return (
<IconPlaceholder
lucide="LoaderCircle"
tabler="IconLoader2"
hugeicons="Loading03Icon"
phosphor="CircleNotchIcon"
remixicon="RiLoader4Line"
role="status"
aria-label="Loading"
className={cn("size-4 animate-spin", className)}
{...props}
/>
)
}
type InlineRegistryIconProps = Omit<
React.ComponentProps<"svg">,
"children" | "strokeWidth"
> & { strokeWidth?: number }
components/extend/docx-annotation-card.tsx
"use client"
import * as React from "react"
import type {
DocxCommentCardRenderProps,
DocxDocumentTheme,
DocxTrackedChangeCardRenderProps,
} from "@extend-ai/react-docx"
import { cn } from "@/lib/utils"
import { Badge } from "@/components/ui/badge"
import { Card } from "@/components/ui/card"
type AnnotationBadgeVariant =
| "outline"
| "secondary"
| "success"
| "error"
| "warning"
| "info"
function trackedChangeBadgeVariant(
kind: DocxTrackedChangeCardRenderProps["change"]["kind"]
): AnnotationBadgeVariant {
switch (kind) {
case "insertion":
case "move-to":
return "success"
case "deletion":
case "move-from":
return "error"
default:
return "warning"
}
}
function trackedChangeBadgeLabel({
change,
kindLabel,
}: Pick<DocxTrackedChangeCardRenderProps, "change" | "kindLabel">) {
switch (change.kind) {
case "insertion":
return "Inserted"
case "deletion":
return "Removed"
case "move-from":
return "Moved from"
case "move-to":
return "Moved to"
default:
return kindLabel
}
}
function DocxAnnotationCard({
anchorText,
badge,
badgeVariant = "outline",
date,
meta,
documentTheme,
snippet,
style,
}: {
anchorText?: string
badge: string
badgeVariant?: AnnotationBadgeVariant
date?: string
documentTheme: DocxDocumentTheme
meta: string
snippet: string
style: React.CSSProperties
}) {
const isDarkDocument = documentTheme === "dark"
const cardStyle: React.CSSProperties = {
...style,
backgroundColor: isDarkDocument
? "rgb(24 24 27 / 0.95)"
: "rgb(255 255 255 / 0.95)",
color: isDarkDocument ? "#f4f4f5" : "#18181b",
}
const mutedTextColor = isDarkDocument ? "#a1a1aa" : "#71717a"
const anchorStyle: React.CSSProperties = {
backgroundColor: isDarkDocument
? "rgb(63 63 70 / 0.55)"
: "rgb(244 244 245 / 0.75)",
color: mutedTextColor,
}
return (
<Card
style={cardStyle}
className="pointer-events-auto box-border gap-2 rounded-lg p-2 shadow-sm before:rounded-[7px]"
>
<div className="flex min-w-0 items-start justify-between gap-2">
<div
className="min-w-0 text-[11px] leading-tight font-medium"
style={{ color: mutedTextColor }}
>
<div className="truncate">{meta}</div>
{date ? <div className="mt-0.5 truncate">{date}</div> : null}
</div>
<Badge
variant={badgeVariant === "outline" ? "outline" : "secondary"}
className={cn(
"h-4 px-1 text-[10px]",
cn(
"max-w-[92px] truncate",
badgeVariant === "success" &&
"bg-green-500/10 text-green-700 dark:text-green-300",
badgeVariant === "error" && "bg-destructive/10 text-destructive",
badgeVariant === "warning" &&
"bg-amber-500/10 text-amber-700 dark:text-amber-300",
badgeVariant === "info" &&
"bg-blue-500/10 text-blue-700 dark:text-blue-300"
)
)}
>
{badge}
</Badge>
</div>
{anchorText ? (
<div
className="rounded-md px-2 py-1 text-[11px] leading-snug italic"
style={anchorStyle}
>
{anchorText}
</div>
) : null}
<div className="text-xs leading-snug break-words">{snippet}</div>
</Card>
)
}
export function createDocxTrackedChangeCardRenderer(
documentTheme: DocxDocumentTheme
) {
return function renderDocxTrackedChangeCard({
change,
formattedDate,
kindLabel,
snippet,
style,
}: DocxTrackedChangeCardRenderProps) {
return (
<DocxAnnotationCard
badge={trackedChangeBadgeLabel({ change, kindLabel })}
badgeVariant={trackedChangeBadgeVariant(change.kind)}
date={formattedDate}
documentTheme={documentTheme}
meta={change.author?.trim() || "Unknown author"}
snippet={snippet}
style={style}
/>
)
}
}
export function createDocxCommentCardRenderer(
documentTheme: DocxDocumentTheme
) {
return function renderDocxCommentCard({
comment,
formattedDate,
snippet,
style,
}: DocxCommentCardRenderProps) {
const badge = comment.resolved
? "Resolved"
: comment.parentId !== undefined
? "Reply"
: "Comment"
return (
<DocxAnnotationCard
anchorText={comment.anchorText}
badge={badge}
badgeVariant={comment.resolved ? "secondary" : "info"}
date={formattedDate}
documentTheme={documentTheme}
meta={comment.author?.trim() || "Unknown author"}
snippet={snippet}
style={style}
/>
)
}
}