extend
uipdf-editor-coresource: ok · hit
PDF Editor Core
Shared PDF editor behavior and state for the Base UI and Radix UI artifacts.
npx shadcn add "https://ui.extend.ai/r/pdf-editor-core.json"free
Where it goes
stats
Interaction
animated-counter
Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.
Dependencies
Bundle weight · light@embedpdf/core@^2.15.0@embedpdf/engines@^2.15.0@embedpdf/models@^2.15.0@embedpdf/plugin-annotation@^2.15.0@embedpdf/plugin-attachment@^2.15.0@embedpdf/plugin-bookmark@^2.15.0@embedpdf/plugin-capture@^2.15.0@embedpdf/plugin-document-manager@^2.15.0@embedpdf/plugin-export@^2.15.0@embedpdf/plugin-form@^2.15.0@embedpdf/plugin-fullscreen@^2.15.0@embedpdf/plugin-history@^2.15.0@embedpdf/plugin-interaction-manager@^2.15.0@embedpdf/plugin-pan@^2.15.0@embedpdf/plugin-print@^2.15.0@embedpdf/plugin-redaction@^2.15.0@embedpdf/plugin-render@^2.15.0@embedpdf/plugin-rotate@^2.15.0@embedpdf/plugin-scroll@^2.15.0@embedpdf/plugin-search@^2.15.0@embedpdf/plugin-selection@^2.15.0@embedpdf/plugin-signature@^2.15.0@embedpdf/plugin-spread@^2.15.0@embedpdf/plugin-stamp@^2.15.0@embedpdf/plugin-thumbnail@^2.15.0@embedpdf/plugin-tiling@^2.15.0@embedpdf/plugin-viewport@^2.15.0@embedpdf/plugin-zoom@^2.15.0pdf-lib@^1.17.1
Registry dependencies
@extend/document-viewer-sidebarresizableutilsbadgebuttondropdown-menuinputpopoverscroll-areaselecttooltip@extend/document-color-picker@extend/document-toastkbddialogswitchtoggle-group
Files7 · source cached 10/8/2026, 2:57:59 PM
components/extend/pdf-editor.tsx
"use client"
import * as React from "react"
import { ScrollArea as ScrollAreaPrimitive } from "@base-ui/react/scroll-area"
import { createPluginRegistration } from "@embedpdf/core"
import {
EmbedPDF,
PDFContext,
useDocumentPermissions,
useRegistry,
} from "@embedpdf/core/react"
import {
PdfAnnotationSubtype,
PdfErrorCode,
PdfPageFlattenFlag,
uuidV4,
type PdfAnnotationObject,
type PdfDocumentObject,
type PdfEngine,
type PdfLinkAnnoObject,
type PdfPrintOptions,
} from "@embedpdf/models"
import {
AnnotationLayer,
AnnotationPluginPackage,
getSelectedAnnotations,
LockModeType,
useAnnotation,
useAnnotationCapability,
type AnnotationEvent,
type AnnotationSelectionMenuProps,
type AnnotationTransferItem,
type GroupSelectionMenuProps,
} from "@embedpdf/plugin-annotation/react"
import { AttachmentPluginPackage } from "@embedpdf/plugin-attachment/react"
import { BookmarkPluginPackage } from "@embedpdf/plugin-bookmark/react"
import {
CapturePluginPackage,
MarqueeCapture,
useCapture,
type CaptureAreaEvent,
} from "@embedpdf/plugin-capture/react"
import {
DocumentManagerPluginPackage,
useActiveDocument,
useDocumentManagerCapability,
} from "@embedpdf/plugin-document-manager/react"
import { ExportPluginPackage, useExport } from "@embedpdf/plugin-export/react"
import {
FormPluginPackage,
useFormCapability,
} from "@embedpdf/plugin-form/react"
import {
FullscreenPluginPackage,
useFullscreen,
} from "@embedpdf/plugin-fullscreen/react"
import {
HistoryPluginPackage,
useHistoryCapability,
} from "@embedpdf/plugin-history/react"
import {
GlobalPointerProvider,
InteractionManagerPluginPackage,
PagePointerProvider,
} from "@embedpdf/plugin-interaction-manager/react"
import { PanPluginPackage, usePan } from "@embedpdf/plugin-pan/react"
import { PrintPluginPackage, usePrint } from "@embedpdf/plugin-print/react"
import {
RedactionLayer,
RedactionMode,
RedactionPluginPackage,
useRedaction,
type RedactionSelectionMenuProps,
} from "@embedpdf/plugin-redaction/react"
import { RenderLayer, RenderPluginPackage } from "@embedpdf/plugin-render/react"
import {
Rotate,
RotatePluginPackage,
useRotate,
} from "@embedpdf/plugin-rotate/react"
import {
Scroller,
ScrollPluginPackage,
ScrollStrategy,
useScroll,
useScrollCapability,
type PageLayout,
} from "@embedpdf/plugin-scroll/react"
import {
SearchLayer,
SearchPluginPackage,
useSearch,
} from "@embedpdf/plugin-search/react"
import {
CopyToClipboard,
SelectionLayer,
SelectionPluginPackage,
useSelectionCapability,
type SelectionSelectionMenuProps,
} from "@embedpdf/plugin-selection/react"
import {
deserializeEntries,
serializeEntries,
SignatureMode,
SignaturePluginPackage,
useSignatureCapability,
} from "@embedpdf/plugin-signature/react"
import {
SpreadMode,
SpreadPluginPackage,
useSpread,
} from "@embedpdf/plugin-spread/react"
import { StampPluginPackage } from "@embedpdf/plugin-stamp/react"
import { ThumbnailPluginPackage } from "@embedpdf/plugin-thumbnail/react"
import { TilingLayer, TilingPluginPackage } from "@embedpdf/plugin-tiling/react"
import {
useIsViewportGated,
useViewportCapability,
useViewportElement,
useViewportRef,
ViewportElementContext,
ViewportPluginPackage,
} from "@embedpdf/plugin-viewport/react"
import {
MarqueeZoom,
useZoom,
ZoomMode,
ZoomPluginPackage,
} from "@embedpdf/plugin-zoom/react"
import { loadSharedPdfEngine } from "@/lib/pdf-thumbnail-utils"
import { cn } from "@/lib/utils"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { Input } from "@/components/ui/input"
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover"
import {
ScrollArea as InlineScrollArea,
ScrollBar,
} from "@/components/ui/scroll-area"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { TooltipProvider } from "@/components/ui/tooltip"
import { ColorPicker } from "@/components/extend/document-color-picker"
import {
useElementWidth,
useInlineThumbnailSidebar,
} from "@/components/extend/document-viewer-sidebar"
import {
PdfEditorCaptureDialog,
PdfEditorConfirmDialog,
PdfEditorLinkDialog,
PdfEditorPasswordPrompt,
PdfEditorPrintDialog,
PdfEditorPropertiesDialog,
PdfEditorSecurityDialog,
PdfEditorShortcutsDialog,
PdfEditorSignatureDialog,
} from "@/components/extend/pdf-editor-dialogs"
import {
PdfEditorAttachmentsPanel,
PdfEditorCommentsPanel,
PdfEditorFormsPanel,
PdfEditorOutlinePanel,
PdfEditorPagesPanel,
PdfEditorRedactionsPanel,
PdfEditorSignaturesPanel,
PdfEditorStampsPanel,
PdfEditorThumbnailsPanel,
} from "@/components/extend/pdf-editor-panels"
import { PdfEditorPropertiesPanel } from "@/components/extend/pdf-editor-properties"
import {
BookOpenGlyph,
CameraGlyph,
ChevronLeftGlyph,
ChevronRightGlyph,
CloseGlyph,
CommentGlyph,
CommentsGlyph,
CopyGlyph,
CursorGlyph,
downloadArrayBuffer,
downloadBlob,
DownloadGlyph,
EditGlyph,
ExitFullscreenGlyph,
ExternalLinkGlyph,
EyeOffGlyph,
FileTextGlyph,
FullscreenGlyph,
getPdfFileName,
GridGlyph,
GroupGlyph,
HandGlyph,
HighlighterGlyph,
InfoGlyph,
isTypingTarget,
KeyboardGlyph,
LayersGlyph,
LinkGlyph,
ListTreeGlyph,
LockGlyph,
MarqueeZoomGlyph,
MoreGlyph,
noop,
PaletteGlyph,
PanelLeftGlyph,
PanelRightGlyph,
PaperclipGlyph,
PDF_EDITOR_ANNOTATION_TOOL_GROUPS,
PDF_EDITOR_DEFAULT_COLOR_PRESETS,
PDF_EDITOR_DEFAULT_FEATURES,
PDF_EDITOR_DEFAULT_SIGNATURE_FONTS_URL,
PDF_EDITOR_DEFAULT_STAMP_MANIFEST_URL,
PDF_EDITOR_FORM_TOOLS,
PDF_EDITOR_MODE_OPTIONS,
PDF_EDITOR_SELECTION_COLOR,
PdfEditorContextProvider,
PdfEditorScrollArea,
PdfEditorScrollAreaResolverContext,
PdfEditorToolbarSeparator,
PdfEditorToolButton,
PdfEditorTooltip,
PlusGlyph,
PrintGlyph,
RedactAreaGlyph,
RedoGlyph,
resolveDefaultScrollAreaViewport,
RotateGlyph,
SearchGlyph,
ShieldGlyph,
SignatureGlyph,
SlidersGlyph,
StampGlyph,
StrikethroughGlyph,
TextFieldGlyph,
TextGlyph,
toNormalizedRect,
TrashGlyph,
UnderlineGlyph,
UndoGlyph,
UngroupGlyph,
UnlockGlyph,
UploadGlyph,
usePdfEditor,
ZoomInGlyph,
ZoomOutGlyph,
type PdfEditorContextValue,
type PdfEditorDialogRequest,
type PdfEditorFeatureFlags,
type PdfEditorGlyph,
type PdfEditorLeftPanel,
type PdfEditorMode,
type PdfEditorNoticeTone,
type PdfEditorResolvedFeatures,
type PdfEditorRightPanel,
type PdfEditorScrollAreaViewportResolver,
type PdfEditorSelectionAction,
type PdfEditorSelectionPage,
type PdfEditorSelectionPayload,
} from "@/components/extend/pdf-editor-shared"
import { PdfEditorWorkspace } from "@/components/extend/pdf-editor-workspace"
import { ToastPrimitive, ToastProvider } from "@/components/extend/ui-toast"
import { IconPlaceholder } from "@/components/icon-placeholder"
export type {
PdfEditorFeatureFlags,
PdfEditorLeftPanel,
PdfEditorMode,
PdfEditorNormalizedRect,
PdfEditorRightPanel,
PdfEditorSelectionAction,
PdfEditorSelectionPayload,
} from "@/components/extend/pdf-editor-shared"
/* -------------------------------------------------------------------------- */
/* Public types */
/* -------------------------------------------------------------------------- */
export type PDFEditorPageOverlayProps = {
pageNumber: number
pageWidth: number
pageHeight: number
scale: number
rotation: number
}
export type PDFEditorZoomLevel = number | "fit-page" | "fit-width" | "automatic"
export type PDFEditorToast = {
message: string
tone: PdfEditorNoticeTone
}
export type PDFEditorHandle = {
/** Current PDF bytes including annotations, form values, and pending security changes. */
getDocumentBuffer: () => Promise<ArrayBuffer>
download: (fileName?: string) => Promise<void>
print: (options?: PdfPrintOptions) => Promise<void>
exportAnnotations: () => Promise<AnnotationTransferItem[]>
importAnnotations: (items: AnnotationTransferItem[]) => void
getFormValues: () => Record<string, string>
setFormValues: (values: Record<string, string>) => Promise<void>
/** Permanently applies every pending redaction mark. */
applyRedactions: () => Promise<void>
setMode: (mode: PdfEditorMode) => void
/** Activates an EmbedPDF annotation tool id such as `highlight` or `ink`; `null` returns to selection. */
setActiveTool: (toolId: string | null) => void
scrollToPage: (pageNumber: number, options?: ScrollIntoViewOptions) => void
undo: () => void
redo: () => void
getViewportElement: () => HTMLDivElement | null
}
export type PDFEditorProps = {
className?: string
/** A URL, or the PDF bytes as an ArrayBuffer, Uint8Array, Blob, or File. */
src?: string | ArrayBuffer | Uint8Array | Blob
fileName?: string
defaultZoom?: PDFEditorZoomLevel
defaultMode?: PdfEditorMode
/** Author written into new annotations. Defaults to "Guest". */
annotationAuthor?: string
colorPresets?: string[]
/** Turn individual editing capabilities off. Everything is enabled by default. */
features?: PdfEditorFeatureFlags
showToolbar?: boolean
showUpload?: boolean
showDownload?: boolean
toolbarActions?: React.ReactNode
/** Extra actions for the text selection menu. Each receives normalized page coordinates. */
selectionActions?: PdfEditorSelectionAction[]
/** Persist saved signatures in localStorage. Pass a string to use a custom storage key. */
persistSignatures?: boolean | string
/** Additional rubber stamp manifests to load next to the built-in library. */
stampManifests?: string[]
/** Stylesheet used for the typed-signature fonts. Pass `null` to skip loading web fonts. */
signatureFontsStylesheetUrl?: string | null
resolveScrollAreaViewport?: PdfEditorScrollAreaViewportResolver
renderPageOverlay?: (props: PDFEditorPageOverlayProps) => React.ReactNode
onDocumentLoadSuccess?: (info: {
numPages: number
documentId: string
fileName: string
}) => void
onDocumentLoadError?: (message: string) => void
onActivePageChange?: (pageNumber: number) => void
onModeChange?: (mode: PdfEditorMode) => void
onAnnotationEvent?: (event: AnnotationEvent) => void
/** Fires with the full annotation set after every committed change (debounced). */
onAnnotationsChange?: (items: AnnotationTransferItem[]) => void
onFormValuesChange?: (values: Record<string, string>) => void
onRedactionsApplied?: () => void
onSave?: (result: { buffer: ArrayBuffer; fileName: string }) => void
onCapture?: (event: CaptureAreaEvent) => void
onPdfUpload?: (file: File) => void
/** Routes editor notices to a host-owned toast system instead of the built-in viewport. */
onToast?: (toast: PDFEditorToast) => void
onSelectionAction?: (
actionId: string,
payload: PdfEditorSelectionPayload
) => void
}
/* -------------------------------------------------------------------------- */
/* Constants */
/* -------------------------------------------------------------------------- */
const PAGE_GAP = 24
const THUMBNAIL_PAGE_WIDTH = 92
const THUMBNAIL_IMAGE_PADDING = 8
const THUMBNAIL_WIDTH = THUMBNAIL_PAGE_WIDTH + THUMBNAIL_IMAGE_PADDING * 2
const THUMBNAIL_LABEL_HEIGHT = 24
const THUMBNAIL_GAP = 12
const THUMBNAIL_PANE_PADDING_Y = 16
const RIGHT_PANEL_INLINE_MIN_WIDTH = 1100
const TOOLBAR_STACK_MAX_WIDTH = 640
const ZOOM_PRESETS = [0.25, 0.5, 0.75, 1, 1.25, 1.5, 2, 3, 4]
const MIN_ZOOM = 0.1
const MAX_ZOOM = 8
const PAGE_BASE_RENDER_MAX_SCALE = 1
const PAGE_BASE_RENDER_DPR = 1
const SEARCH_DEBOUNCE_MS = 300
const NOTICE_DURATION_MS = 4200
const TOOLTIP_DELAY_MS = 5
const SELECTION_MENU_OFFSET = 8
const SELECTION_MENU_HEIGHT = 36
const DEFAULT_SIGNATURE_STORAGE_KEY = "pdf-editor:signatures"
const DEFAULT_ANNOTATION_AUTHOR = "Guest"
const ZOOM_MODE_LABELS: Record<ZoomMode, string> = {
[ZoomMode.FitPage]: "Fit page",
[ZoomMode.FitWidth]: "Fit width",
[ZoomMode.Automatic]: "Automatic",
}
const SPREAD_MODE_OPTIONS = [
{ label: "Single page", value: SpreadMode.None },
{ label: "Two pages", value: SpreadMode.Odd },
{ label: "Two pages, cover", value: SpreadMode.Even },
]
const SCROLL_DIRECTION_OPTIONS = [
{ label: "Scroll down", value: "vertical" },
{ label: "Scroll sideways", value: "horizontal" },
]
function toZoomLevel(level: PDFEditorZoomLevel): ZoomMode | number {
if (level === "fit-page") return ZoomMode.FitPage
if (level === "fit-width") return ZoomMode.FitWidth
if (level === "automatic") return ZoomMode.Automatic
return level
}
function isZoomMode(value: unknown): value is ZoomMode {
return (
value === ZoomMode.FitPage ||
value === ZoomMode.FitWidth ||
value === ZoomMode.Automatic
)
}
/* -------------------------------------------------------------------------- */
/* Engine + document source */
/* -------------------------------------------------------------------------- */
function useSharedPdfEngine() {
const [engine, setEngine] = React.useState<PdfEngine | null>(null)
const [error, setError] = React.useState<Error | null>(null)
React.useEffect(() => {
let cancelled = false
loadSharedPdfEngine().then(
(loadedEngine) => {
if (!cancelled) setEngine(loadedEngine)
},
(loadError: Error) => {
if (!cancelled) setError(loadError)
}
)
return () => {
cancelled = true
}
}, [])
return { engine, error }
}
type EditorSource =
| {
key: string
kind: "url"
url: string
name: string
}
| {
key: string
kind: "buffer"
buffer: ArrayBuffer
name: string
}
let sourceCounter = 0
function nextSourceKey() {
sourceCounter += 1
return `pdf-editor-source-${sourceCounter}`
}
function bytesToArrayBuffer(bytes: Uint8Array) {
const copy = new ArrayBuffer(bytes.byteLength)
new Uint8Array(copy).set(bytes)
return copy
}
function useEditorSource(
src: PDFEditorProps["src"],
fileName: string | undefined
) {
const sourceId = React.useId()
const [input, setInput] = React.useState({ src, fileName, version: 0 })
if (input.src !== src || input.fileName !== fileName) {
setInput({ src, fileName, version: input.version + 1 })
}
const immediateSource = React.useMemo<EditorSource | null>(() => {
const key = `${sourceId}-${input.version}`
if (typeof input.src === "string") {
return {
key,
kind: "url",
url: input.src,
name: getPdfFileName(input.fileName, input.src),
}
}
if (input.src instanceof ArrayBuffer || input.src instanceof Uint8Array) {
return {
key,
kind: "buffer",
buffer:
input.src instanceof ArrayBuffer
? input.src.slice(0)
: bytesToArrayBuffer(input.src),
name: getPdfFileName(input.fileName),
}
}
return null
}, [input, sourceId])
const [resolved, setResolved] = React.useState<{
input: typeof input
source: EditorSource
} | null>(null)
React.useEffect(() => {
if (!(input.src instanceof Blob)) return
let cancelled = false
const blobName =
input.fileName ?? (input.src instanceof File ? input.src.name : undefined)
input.src.arrayBuffer().then((buffer) => {
if (cancelled) return
setResolved({
input,
source: {
key: nextSourceKey(),
kind: "buffer",
buffer,
name: getPdfFileName(blobName),
},
})
})
return () => {
cancelled = true
}
}, [input])
const replace = React.useCallback(
(buffer: ArrayBuffer, name: string) => {
setResolved({
input,
source: { key: nextSourceKey(), kind: "buffer", buffer, name },
})
},
[input]
)
const source = resolved?.input === input ? resolved.source : immediateSource
return { source, loading: input.src instanceof Blob && !source, replace }
}
/* -------------------------------------------------------------------------- */
/* Annotation JSON serialization */
/* -------------------------------------------------------------------------- */
function arrayBufferToBase64(buffer: ArrayBuffer) {
const bytes = new Uint8Array(buffer)
let binary = ""
for (let index = 0; index < bytes.length; index += 0x8000) {
binary += String.fromCharCode(...bytes.subarray(index, index + 0x8000))
}
return btoa(binary)
}
function base64ToArrayBuffer(value: string) {
const binary = atob(value)
const bytes = new Uint8Array(binary.length)
for (let index = 0; index < binary.length; index += 1) {
bytes[index] = binary.charCodeAt(index)
}
return bytes.buffer
}
type SerializedAnnotationItem = {
annotation: PdfAnnotationObject
ctx?: {
data: string
mimeType?: string
}
}
export function serializeAnnotationItems(items: AnnotationTransferItem[]) {
return {
format: "extend-ui/pdf-editor-annotations",
version: 1,
exportedAt: new Date().toISOString(),
annotations: items.map((item): SerializedAnnotationItem => {
const context = item.ctx as
| {
data?: ArrayBuffer
mimeType?: string
}
| undefined
return {
annotation: item.annotation,
...(context?.data instanceof ArrayBuffer
? {
ctx: {
data: arrayBufferToBase64(context.data),
mimeType: context.mimeType,
},
}
: {}),
}
}),
}
}
export function deserializeAnnotationItems(
payload: unknown
): AnnotationTransferItem[] {
const list = Array.isArray(payload)
? payload
: payload &&
typeof payload === "object" &&
Array.isArray(
(
payload as {
annotations?: unknown
}
).annotations
)
? (
payload as {
annotations: unknown[]
}
).annotations
: null
if (!list) throw new Error("Unrecognized annotation export.")
return list.map((entry) => {
const item = entry as SerializedAnnotationItem
if (!item || typeof item !== "object" || !item.annotation) {
throw new Error("Unrecognized annotation export.")
}
return {
annotation: item.annotation,
...(item.ctx?.data
? {
ctx: {
data: base64ToArrayBuffer(item.ctx.data),
mimeType: item.ctx.mimeType,
} as AnnotationTransferItem["ctx"],
}
: {}),
}
})
}
/* -------------------------------------------------------------------------- */
/* Viewport */
/* -------------------------------------------------------------------------- */
function PdfEditorViewport({
children,
className,
documentId,
}: {
children: React.ReactNode
className?: string
documentId: string
}) {
const viewportRef = useViewportRef(documentId)
const { provides: viewport } = useViewportCapability()
const isGated = useIsViewportGated(documentId)
const viewportGap = viewport?.getViewportGap() ?? 0
return (
<ViewportElementContext.Provider value={viewportRef}>
<PdfEditorScrollArea
className={className}
orientation="both"
scrollbarGutter={false}
viewportClassName="relative select-none selection:bg-transparent selection:text-inherit"
viewportProps={{ style: { padding: viewportGap } }}
viewportRef={viewportRef}
>
{isGated ? null : children}
</PdfEditorScrollArea>
</ViewportElementContext.Provider>
)
}
function PdfEditorViewportBridge({
viewportElementRef,
}: {
viewportElementRef: React.MutableRefObject<HTMLDivElement | null>
}) {
const elementRef = useViewportElement()
React.useEffect(() => {
viewportElementRef.current = elementRef?.current ?? null
})
return null
}
/* -------------------------------------------------------------------------- */
/* Selection menus rendered inside the page layers */
/* -------------------------------------------------------------------------- */
function SelectionMenuShell({
rect,
placement,
menuWrapperProps,
children,
}: {
rect: SelectionSelectionMenuProps["rect"]
placement: SelectionSelectionMenuProps["placement"]
menuWrapperProps: SelectionSelectionMenuProps["menuWrapperProps"]
children: React.ReactNode
}) {
return (
<div {...menuWrapperProps}>
<div
data-slot="pdf-editor-selection-menu"
className="flex items-center gap-0.5 rounded-lg border bg-popover p-1 text-popover-foreground shadow-lg"
style={{
position: "absolute",
left: 0,
top: placement.suggestTop
? -(SELECTION_MENU_HEIGHT + SELECTION_MENU_OFFSET)
: rect.size.height + SELECTION_MENU_OFFSET,
pointerEvents: "auto",
cursor: "default",
whiteSpace: "nowrap",
}}
onPointerDown={(event) => event.stopPropagation()}
>
<TooltipProvider delay={TOOLTIP_DELAY_MS}>{children}</TooltipProvider>
</div>
</div>
)
}
function MenuButton({
label,
glyph: Glyph,
onClick,
disabled,
destructive,
}: {
label: string
glyph: PdfEditorGlyph
onClick: () => void
disabled?: boolean
destructive?: boolean
}) {
return (
<PdfEditorToolButton
label={label}
size="icon-xs"
disabled={disabled}
className={cn(destructive && "text-destructive hover:text-destructive")}
onClick={(event) => {
event.stopPropagation()
onClick()
}}
>
<Glyph className="size-3.5" />
</PdfEditorToolButton>
)
}
async function buildSelectionPayload(
documentId: string,
document: PdfDocumentObject | null,
selectionScope: ReturnType<
NonNullable<
ReturnType<typeof useSelectionCapability>["provides"]
>["forDocument"]
>
): Promise<PdfEditorSelectionPayload | null> {
const formatted = selectionScope.getFormattedSelection()
if (formatted.length === 0) return null
let text = ""
try {
const lines = await selectionScope.getSelectedText().toPromise()
text = lines.join("\n")
} catch {
text = ""
}
const pages: PdfEditorSelectionPage[] = formatted.map((page) => {
const pageSize = document?.pages[page.pageIndex]?.size ?? {
width: 1,
height: 1,
}
return {
pageIndex: page.pageIndex,
pageNumber: page.pageIndex + 1,
pageSize,
rect: page.rect,
rects: page.segmentRects,
normalizedRect: toNormalizedRect(page.rect, pageSize),
normalizedRects: page.segmentRects.map((rect) =>
toNormalizedRect(rect, pageSize)
),
}
})
return { documentId, text, pages, ...pages[0] }
}
function PdfEditorTextSelectionMenu({
documentId,
rect,
placement,
menuWrapperProps,
}: SelectionSelectionMenuProps & {
documentId: string
}) {
const { provides: selectionCapability } = useSelectionCapability()
const { provides: annotationCapability } = useAnnotationCapability()
const { provides: annotation } = useAnnotation(documentId)
const { provides: redaction } = useRedaction(documentId)
const editor = usePdfEditor()
const selectionScope = React.useMemo(
() => selectionCapability?.forDocument(documentId) ?? null,
[documentId, selectionCapability]
)
const canAnnotate =
editor.features.annotate && editor.permissions.canModifyAnnotations
const canRedact =
editor.features.redact && editor.permissions.canModifyContents
const createMarkup = (
toolId: string,
subtype:
| PdfAnnotationSubtype.HIGHLIGHT
| PdfAnnotationSubtype.UNDERLINE
| PdfAnnotationSubtype.STRIKEOUT,
options: {
select?: boolean
openComments?: boolean
} = {}
) => {
if (!selectionScope || !annotation) return
const formatted = selectionScope.getFormattedSelection()
if (formatted.length === 0) return
const defaults = (annotationCapability?.getTool(toolId)?.defaults ??
{}) as Record<string, unknown>
const finish = (text: string[]) => {
let firstId: {
pageIndex: number
id: string
} | null = null
for (const page of formatted) {
const id = uuidV4()
annotation.createAnnotation(page.pageIndex, {
...defaults,
id,
type: subtype,
pageIndex: page.pageIndex,
rect: page.rect,
segmentRects: page.segmentRects,
author: editor.annotationAuthor,
created: new Date(),
modified: new Date(),
flags: ["print"],
custom: text.length ? { text: text.join("\n") } : undefined,
} as PdfAnnotationObject)
firstId ??= { pageIndex: page.pageIndex, id }
}
selectionScope.clear()
if (options.select && firstId) {
annotation.selectAnnotation(firstId.pageIndex, firstId.id)
}
if (options.openComments) editor.setRightPanel("comments")
}
selectionScope.getSelectedText().wait(finish, () => finish([]))
}
const runCustomAction = async (action: PdfEditorSelectionAction) => {
if (!selectionScope) return
const payload = await buildSelectionPayload(
documentId,
editor.document,
selectionScope
)
if (!payload) return
action.onSelect?.(payload)
editor.onSelectionAction?.(action.id, payload)
if (!action.keepSelection) selectionScope.clear()
}
return (
<SelectionMenuShell
rect={rect}
placement={placement}
menuWrapperProps={menuWrapperProps}
>
<MenuButton
label="Copy"
glyph={CopyGlyph}
onClick={() => {
selectionScope?.copyToClipboard()
selectionScope?.clear()
}}
/>
{canAnnotate ? (
<>
<PdfEditorToolbarSeparator />
<MenuButton
label="Highlight"
glyph={HighlighterGlyph}
onClick={() =>
createMarkup("highlight", PdfAnnotationSubtype.HIGHLIGHT)
}
/>
<MenuButton
label="Underline"
glyph={UnderlineGlyph}
onClick={() =>
createMarkup("underline", PdfAnnotationSubtype.UNDERLINE)
}
/>
<MenuButton
label="Strikethrough"
glyph={StrikethroughGlyph}
onClick={() =>
createMarkup("strikeout", PdfAnnotationSubtype.STRIKEOUT)
}
/>
{editor.features.comments ? (
<MenuButton
label="Comment"
glyph={CommentGlyph}
onClick={() =>
createMarkup("highlight", PdfAnnotationSubtype.HIGHLIGHT, {
select: true,
openComments: true,
})
}
/>
) : null}
<MenuButton
label="Add link"
glyph={LinkGlyph}
onClick={() =>
editor.openDialog({ type: "link", source: "selection" })
}
/>
</>
) : null}
{canRedact ? (
<>
<PdfEditorToolbarSeparator />
<MenuButton
label="Mark for redaction"
glyph={EyeOffGlyph}
onClick={() => {
redaction?.queueCurrentSelectionAsPending().wait(
() => {
selectionScope?.clear()
editor.setRightPanel("redactions")
},
() =>
editor.notify("The selection could not be marked.", "error")
)
}}
/>
</>
) : null}
{editor.selectionActions.length > 0 ? (
<>
<PdfEditorToolbarSeparator />
{editor.selectionActions.map((action) => (
<PdfEditorTooltip key={action.id} label={action.label}>
<Button
type="button"
size="xs"
variant="ghost"
onClick={(event) => {
event.stopPropagation()
void runCustomAction(action)
}}
>
{action.icon}
{action.label}
</Button>
</PdfEditorTooltip>
))}
</>
) : null}
</SelectionMenuShell>
)
}
const STAMP_SOURCE_TYPES = new Set<PdfAnnotationSubtype>([
PdfAnnotationSubtype.INK,
PdfAnnotationSubtype.SQUARE,
PdfAnnotationSubtype.CIRCLE,
PdfAnnotationSubtype.LINE,
PdfAnnotationSubtype.POLYLINE,
PdfAnnotationSubtype.POLYGON,
PdfAnnotationSubtype.FREETEXT,
PdfAnnotationSubtype.STAMP,
PdfAnnotationSubtype.TEXT,
])
function PdfEditorAnnotationMenu({
documentId,
selected,
context,
rect,
placement,
menuWrapperProps,
}: AnnotationSelectionMenuProps & {
documentId: string
}) {
const { provides: annotation } = useAnnotation(documentId)
const { provides: redaction } = useRedaction(documentId)
const editor = usePdfEditor()
if (!selected || !annotation) return null
const object = context.annotation.object
const { pageIndex, id } = object
const isRedact = object.type === PdfAnnotationSubtype.REDACT
const isWidget = object.type === PdfAnnotationSubtype.WIDGET
const isLink = object.type === PdfAnnotationSubtype.LINK
const isLocked = (object.flags ?? []).includes("locked")
const canModify = editor.permissions.canModifyAnnotations
const canDelete = canModify && !context.structurallyLocked
const remove = () => annotation.deleteAnnotation(pageIndex, id)
if (isRedact) {
return (
<SelectionMenuShell
rect={rect}
placement={placement}
menuWrapperProps={menuWrapperProps}
>
<MenuButton
label="Apply redaction"
glyph={EyeOffGlyph}
disabled={!editor.permissions.canModifyContents}
onClick={() =>
editor.openDialog({
type: "confirm",
title: "Apply this redaction?",
description: "The marked content is removed permanently.",
confirmLabel: "Apply",
onConfirm: () => {
redaction
?.commitPending(pageIndex, id)
.wait(noop, () =>
editor.notify(
"The redaction could not be applied.",
"error"
)
)
},
})
}
/>
<MenuButton
label="Style"
glyph={SlidersGlyph}
onClick={() => editor.setRightPanel("properties")}
/>
<MenuButton
label="Remove mark"
glyph={TrashGlyph}
destructive
onClick={remove}
/>
</SelectionMenuShell>
)
}
if (isLink) {
const target = (object as PdfLinkAnnoObject).target
return (
<SelectionMenuShell
rect={rect}
placement={placement}
menuWrapperProps={menuWrapperProps}
>
<MenuButton
label="Open link"
glyph={ExternalLinkGlyph}
disabled={!target}
onClick={() => {
if (!target) return
annotation.navigateTarget(target).wait((result) => {
if (result.outcome === "uri") {
window.open(result.uri, "_blank", "noopener")
}
}, noop)
}}
/>
<MenuButton
label="Edit link"
glyph={EditGlyph}
disabled={!canModify}
onClick={() =>
editor.openDialog({ type: "link", source: "annotation" })
}
/>
<MenuButton
label="Delete"
glyph={TrashGlyph}
destructive
disabled={!canDelete}
onClick={remove}
/>
</SelectionMenuShell>
)
}
return (
<SelectionMenuShell
rect={rect}
placement={placement}
menuWrapperProps={menuWrapperProps}
>
{!isWidget && editor.features.comments ? (
<MenuButton
label="Comment"
glyph={CommentGlyph}
onClick={() => editor.setRightPanel("comments")}
/>
) : null}
<MenuButton
label="Properties"
glyph={SlidersGlyph}
onClick={() => editor.setRightPanel("properties")}
/>
{!isWidget && canModify ? (
<MenuButton
label="Add link"
glyph={LinkGlyph}
onClick={() =>
editor.openDialog({ type: "link", source: "annotation" })
}
/>
) : null}
{!isWidget &&
editor.features.stamps &&
canModify &&
STAMP_SOURCE_TYPES.has(object.type) ? (
<MenuButton
label="Save as stamp"
glyph={StampGlyph}
onClick={() => editor.setRightPanel("stamps")}
/>
) : null}
{!isWidget && canModify ? (
<MenuButton
label={isLocked ? "Unlock" : "Lock"}
glyph={isLocked ? UnlockGlyph : LockGlyph}
onClick={() => {
const flags = object.flags ?? []
annotation.updateAnnotation(pageIndex, id, {
flags: isLocked
? flags.filter((flag) => flag !== "locked")
: [...flags, "locked"],
})
}}
/>
) : null}
<MenuButton
label="Delete"
glyph={TrashGlyph}
destructive
disabled={!canDelete}
onClick={remove}
/>
</SelectionMenuShell>
)
}
function PdfEditorGroupMenu({
documentId,
selected,
context,
rect,
placement,
menuWrapperProps,
}: GroupSelectionMenuProps & {
documentId: string
}) {
const { provides: annotation } = useAnnotation(documentId)
const editor = usePdfEditor()
if (!selected || !annotation) return null
const groupingAction = annotation.getGroupingAction()
const canModify = editor.permissions.canModifyAnnotations
return (
<SelectionMenuShell
rect={rect}
placement={placement}
menuWrapperProps={menuWrapperProps}
>
<span className="px-1.5 text-xs text-muted-foreground">
{context.annotations.length} selected
</span>
<MenuButton
label={groupingAction === "ungroup" ? "Ungroup" : "Group"}
glyph={groupingAction === "ungroup" ? UngroupGlyph : GroupGlyph}
disabled={!canModify || groupingAction === "disabled"}
onClick={() => {
if (groupingAction === "ungroup") {
annotation.ungroupAnnotations(context.annotations[0].object.id)
} else {
annotation.groupAnnotations()
}
}}
/>
<MenuButton
label="Properties"
glyph={SlidersGlyph}
onClick={() => editor.setRightPanel("properties")}
/>
<MenuButton
label="Delete all"
glyph={TrashGlyph}
destructive
disabled={!canModify}
onClick={() =>
annotation.deleteAnnotations(
context.annotations.map((item) => ({
pageIndex: item.object.pageIndex,
id: item.object.id,
}))
)
}
/>
</SelectionMenuShell>
)
}
function PdfEditorRedactionMenu({
documentId,
selected,
context,
rect,
placement,
menuWrapperProps,
}: RedactionSelectionMenuProps & {
documentId: string
}) {
const { provides: redaction } = useRedaction(documentId)
const editor = usePdfEditor()
if (!selected || !redaction) return null
const { page, id } = context.item
return (
<SelectionMenuShell
rect={rect}
placement={placement}
menuWrapperProps={menuWrapperProps}
>
<MenuButton
label="Apply redaction"
glyph={EyeOffGlyph}
disabled={!editor.permissions.canModifyContents}
onClick={() =>
editor.openDialog({
type: "confirm",
title: "Apply this redaction?",
description: "The marked content is removed permanently.",
confirmLabel: "Apply",
onConfirm: () => {
redaction
.commitPending(page, id)
.wait(noop, () =>
editor.notify("The redaction could not be applied.", "error")
)
},
})
}
/>
<MenuButton
label="Remove mark"
glyph={TrashGlyph}
destructive
onClick={() => redaction.removePending(page, id)}
/>
</SelectionMenuShell>
)
}
/* -------------------------------------------------------------------------- */
/* Toolbar controls */
/* -------------------------------------------------------------------------- */
function PdfEditorPageControl({
activePage,
numPages,
disabled,
onPageChange,
}: {
activePage: number
numPages: number
disabled: boolean
onPageChange: (pageNumber: number) => void
}) {
const inputRef = React.useRef<HTMLInputElement>(null)
const displayPage = numPages ? 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(numPages, 1)))
},
[numPages, onPageChange]
)
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={disabled || !numPages}
onClick={() => {
setDraftPage(String(displayPage))
setIsEditing(true)
}}
>
{displayPage}
</Button>
)}
<span>of {numPages || "–"}</span>
</div>
)
}
function PdfEditorZoomControl({
documentId,
disabled,
}: {
documentId: string
disabled: boolean
}) {
const { state, provides: zoom } = useZoom(documentId)
const current = state.currentZoomLevel
const selectValue = isZoomMode(state.zoomLevel)
? state.zoomLevel
: String(Number(current.toFixed(2)))
const presets = ZOOM_PRESETS.includes(Number(current.toFixed(2)))
? ZOOM_PRESETS
: [...ZOOM_PRESETS, Number(current.toFixed(2))].sort((a, b) => a - b)
return (
<div className="flex items-center gap-0.5">
<PdfEditorToolButton
label="Zoom out"
shortcut="mod+-"
disabled={disabled || current <= MIN_ZOOM}
onClick={() => zoom?.zoomOut()}
>
<ZoomOutGlyph className="size-4" />
</PdfEditorToolButton>
<Select
value={selectValue}
disabled={disabled}
modal={false}
onValueChange={(value) => {
const next = String(value)
zoom?.requestZoom(isZoomMode(next) ? next : Number(next))
}}
>
<SelectTrigger
size="sm"
className="w-[104px] min-w-[104px]"
aria-label="Zoom level"
>
<SelectValue placeholder="Zoom">
{Math.round(current * 100)}%
</SelectValue>
</SelectTrigger>
<SelectContent alignItemWithTrigger={false}>
{(Object.keys(ZOOM_MODE_LABELS) as ZoomMode[]).map((mode) => (
<SelectItem key={mode} value={mode}>
{ZOOM_MODE_LABELS[mode]}
</SelectItem>
))}
{presets.map((preset) => (
<SelectItem key={preset} value={String(preset)}>
{Math.round(preset * 100)}%
</SelectItem>
))}
</SelectContent>
</Select>
<PdfEditorToolButton
label="Zoom in"
shortcut="mod+="
disabled={disabled || current >= MAX_ZOOM}
onClick={() => zoom?.zoomIn()}
>
<ZoomInGlyph className="size-4" />
</PdfEditorToolButton>
</div>
)
}
function PdfEditorSearchControl({
documentId,
disabled,
open,
onOpenChange,
}: {
documentId: string
disabled: boolean
open: boolean
onOpenChange: (open: boolean) => void
}) {
const { state, provides } = useSearch(documentId)
const { provides: scroll } = useScroll(documentId)
const [draft, setDraft] = React.useState("")
const [query, setQuery] = React.useState("")
const [isSearching, setIsSearching] = React.useState(false)
const providesRef = React.useRef(provides)
const scrollRef = React.useRef(scroll)
const requestIdRef = React.useRef(0)
const inputRef = React.useRef<HTMLInputElement>(null)
React.useEffect(() => {
providesRef.current = provides
scrollRef.current = scroll
}, [provides, scroll])
React.useEffect(() => {
if (!open) return
const frame = window.requestAnimationFrame(() => inputRef.current?.focus())
return () => window.cancelAnimationFrame(frame)
}, [open])
const scrollToResult = React.useCallback(
(index: number) => {
const result = state.results[index]
if (!result || !scroll) return
const firstRect = result.rects[0]
scroll.scrollToPage({
pageNumber: result.pageIndex + 1,
...(firstRect
? {
pageCoordinates: { x: firstRect.origin.x, y: firstRect.origin.y },
alignY: 30,
}
: {}),
behavior: "auto",
})
},
[scroll, state.results]
)
const runSearch = React.useCallback((rawQuery: string) => {
const nextQuery = rawQuery.trim()
const requestId = requestIdRef.current + 1
requestIdRef.current = requestId
setQuery(nextQuery)
const searchProvider = providesRef.current
const scrollProvider = scrollRef.current
if (!searchProvider) {
setIsSearching(false)
return
}
if (!nextQuery) {
searchProvider.stopSearch()
setIsSearching(false)
return
}
setIsSearching(true)
searchProvider.startSearch()
searchProvider.searchAllPages(nextQuery).wait(
(result) => {
if (requestIdRef.current !== requestId) return
const first = result.results[0]
if (first && scrollProvider) {
searchProvider.goToResult(0)
const firstRect = first.rects[0]
scrollProvider.scrollToPage({
pageNumber: first.pageIndex + 1,
...(firstRect
? {
pageCoordinates: {
x: firstRect.origin.x,
y: firstRect.origin.y,
},
alignY: 30,
}
: {}),
behavior: "auto",
})
}
setIsSearching(false)
},
() => {
if (requestIdRef.current === requestId) setIsSearching(false)
}
)
}, [])
React.useEffect(() => {
if (!draft.trim()) return
const timeout = window.setTimeout(
() => runSearch(draft),
SEARCH_DEBOUNCE_MS
)
return () => window.clearTimeout(timeout)
}, [draft, runSearch])
const clear = () => {
requestIdRef.current += 1
setDraft("")
setQuery("")
setIsSearching(false)
provides?.stopSearch()
}
const navigate = (direction: 1 | -1) => {
if (!provides || state.total === 0) return
scrollToResult(
direction === 1 ? provides.nextResult() : provides.previousResult()
)
}
const resultLabel = isSearching
? "Searching…"
: !query
? "Type to search"
: state.total
? `${state.activeResultIndex + 1} / ${state.total}`
: "No results"
return (
<Popover open={open} onOpenChange={onOpenChange}>
<PdfEditorTooltip label="Search" shortcut="mod+F">
<PopoverTrigger
render={
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label="Search"
disabled={disabled}
className={cn(query && "text-primary")}
>
<SearchGlyph className="size-4" />
</Button>
}
></PopoverTrigger>
</PdfEditorTooltip>
<PopoverContent
align="end"
className="w-72 p-0 [&>[data-slot=popover-viewport]]:p-3.5!"
>
<div className="space-y-3">
<Input
ref={inputRef}
placeholder="Search text"
value={draft}
onChange={(event: React.ChangeEvent<HTMLInputElement>) => {
const next = event.target.value
setDraft(next)
if (next.trim()) {
setIsSearching(true)
} else {
clear()
}
}}
onKeyDown={(event: React.KeyboardEvent<HTMLInputElement>) => {
if (event.key !== "Enter") return
event.preventDefault()
if (state.total) {
navigate(event.shiftKey ? -1 : 1)
} else if (draft.trim()) {
runSearch(draft)
}
}}
/>
<div className="flex items-center justify-between gap-2">
<div className="min-w-0 truncate text-xs text-muted-foreground">
{resultLabel}
</div>
<div className="flex shrink-0 items-center gap-1">
<Button
type="button"
variant="outline"
size="icon-sm"
aria-label="Previous result"
disabled={isSearching || state.total === 0}
onClick={() => navigate(-1)}
>
<ChevronLeftGlyph className="size-4" />
</Button>
<Button
type="button"
variant="outline"
size="icon-sm"
aria-label="Next result"
disabled={isSearching || state.total === 0}
onClick={() => navigate(1)}
>
<ChevronRightGlyph className="size-4" />
</Button>
<Button type="button" variant="outline" size="sm" onClick={clear}>
Clear
</Button>
</div>
</div>
</div>
</PopoverContent>
</Popover>
)
}
function PdfEditorModeSwitch({
mode,
features,
compact = false,
onModeChange,
}: {
mode: PdfEditorMode
features: PdfEditorResolvedFeatures
compact?: boolean
onModeChange: (mode: PdfEditorMode) => void
}) {
const options = PDF_EDITOR_MODE_OPTIONS.filter(
(option) => option.feature === null || features[option.feature]
)
return (
<div
role="group"
aria-label="Editor mode"
className="flex shrink-0 items-center gap-0.5 rounded-lg bg-muted p-0.5"
>
{options.map((option) => {
const Glyph = option.glyph
const active = option.id === mode
return (
<Button
key={option.id}
type="button"
size={compact ? "icon-xs" : "xs"}
variant={active ? "default" : "ghost"}
aria-pressed={active}
aria-label={option.label}
className={cn("gap-1.5", !active && "text-muted-foreground")}
onClick={() => onModeChange(option.id)}
>
<Glyph className="size-3.5" />
{compact ? null : (
<span className="hidden md:inline">{option.label}</span>
)}
</Button>
)
})}
</div>
)
}
/* -------------------------------------------------------------------------- */
/* Side panel shells */
/* -------------------------------------------------------------------------- */
function PanelTabStrip<T extends string>({
tabs,
value,
onChange,
onClose,
label,
disabled = false,
}: {
tabs: Array<{
id: T
label: string
glyph: PdfEditorGlyph
}>
value: T | null
onChange: (value: T) => void
onClose: () => void
label: string
disabled?: boolean
}) {
return (
<div className="flex shrink-0 items-center justify-between gap-1 border-b px-1.5 py-1">
<TooltipProvider delay={TOOLTIP_DELAY_MS}>
<div
className="flex min-w-0 items-center gap-0.5 overflow-x-auto overflow-y-hidden"
role="tablist"
aria-label={label}
>
{tabs.map((tab) => {
const Glyph = tab.glyph
return (
<PdfEditorToolButton
key={tab.id}
label={tab.label}
active={value === tab.id}
role="tab"
aria-selected={value === tab.id}
disabled={disabled}
onClick={() => onChange(tab.id)}
>
<Glyph className="size-4" />
</PdfEditorToolButton>
)
})}
</div>
<PdfEditorToolButton
label="Close panel"
size="icon-xs"
disabled={disabled}
onClick={onClose}
>
<CloseGlyph className="size-3.5" />
</PdfEditorToolButton>
</TooltipProvider>
</div>
)
}
/* -------------------------------------------------------------------------- */
/* Signature persistence + engine warm-up utilities */
/* -------------------------------------------------------------------------- */
function PdfEditorSignaturePersistence({
storageKey,
}: {
storageKey: string | null
}) {
const { provides: signatureCapability } = useSignatureCapability()
React.useEffect(() => {
if (!signatureCapability || !storageKey) return
try {
const raw = window.localStorage.getItem(storageKey)
if (raw && signatureCapability.getEntries().length === 0) {
signatureCapability.loadEntries(deserializeEntries(JSON.parse(raw)))
}
} catch {
// Ignore corrupt or unavailable storage.
}
return signatureCapability.onEntriesChange((entries) => {
try {
window.localStorage.setItem(
storageKey,
JSON.stringify(serializeEntries(entries))
)
} catch {
// Storage may be unavailable (private mode, quota).
}
})
}, [signatureCapability, storageKey])
return null
}
/* -------------------------------------------------------------------------- */
/* Fallback shell (engine loading, document loading, errors, empty) */
/* -------------------------------------------------------------------------- */
function PdfEditorDocumentStatus({
state,
errorMessage,
onUploadFile,
}: {
state: "loading" | "error" | "empty"
errorMessage?: string
onUploadFile?: (file: File) => void
}) {
const inputRef = React.useRef<HTMLInputElement>(null)
return (
<div
data-slot="pdf-editor-document-status"
data-state={state}
className="grid min-h-0 flex-1 place-items-center p-6 text-center text-sm text-muted-foreground"
>
{state === "loading" ? (
<InlineSpinner className="size-4" />
) : (
<div className="flex max-w-sm flex-col items-center gap-3">
<div role={state === "error" ? "alert" : undefined}>
{state === "error"
? (errorMessage ?? "The PDF could not be loaded.")
: "Open a PDF to start editing"}
</div>
{onUploadFile ? (
<>
<input
ref={inputRef}
type="file"
accept="application/pdf,.pdf"
className="sr-only"
tabIndex={-1}
onChange={(event) => {
const file = event.target.files?.[0]
if (file) onUploadFile(file)
event.currentTarget.value = ""
}}
/>
<Button
type="button"
size="sm"
variant="outline"
onClick={() => inputRef.current?.click()}
>
<UploadGlyph className="size-4" />
Open PDF
</Button>
</>
) : null}
</div>
)}
</div>
)
}
const EMPTY_EDITOR_CONTEXT: React.ContextType<typeof PDFContext> = {
registry: null,
coreState: null,
isInitializing: true,
pluginsReady: false,
activeDocumentId: null,
activeDocument: null,
documents: {},
documentStates: [],
}
/* -------------------------------------------------------------------------- */
/* Editor (per loaded document) */
/* -------------------------------------------------------------------------- */
type EditorHandleImpl = PDFEditorHandle
type PdfEditorInnerProps = {
documentId: string
document: PdfDocumentObject | null
documentState: "loading" | "ready" | "empty" | "error" | "password"
unavailableContent?: React.ReactNode
fileName: string
className?: string
defaultZoom: PDFEditorZoomLevel
defaultMode: PdfEditorMode
leftPanel: PdfEditorLeftPanel | null
setLeftPanel: React.Dispatch<React.SetStateAction<PdfEditorLeftPanel | null>>
formDesignMode: boolean
setFormDesignMode: React.Dispatch<React.SetStateAction<boolean>>
annotationAuthor: string
features: PdfEditorResolvedFeatures
showToolbar: boolean
showUpload: boolean
showDownload: boolean
toolbarActions?: React.ReactNode
selectionActions: PdfEditorSelectionAction[]
signatureFontsStylesheetUrl: string | null
renderPageOverlay?: (props: PDFEditorPageOverlayProps) => React.ReactNode
handleRef: React.MutableRefObject<EditorHandleImpl | null>
onReplaceDocument: (buffer: ArrayBuffer, fileName: string) => void
onUploadFile: (file: File) => void
onActivePageChange?: (pageNumber: number) => void
onModeChange?: (mode: PdfEditorMode) => void
onAnnotationEvent?: (event: AnnotationEvent) => void
onAnnotationsChange?: (items: AnnotationTransferItem[]) => void
onFormValuesChange?: (values: Record<string, string>) => void
onRedactionsApplied?: () => void
onSave?: (result: { buffer: ArrayBuffer; fileName: string }) => void
onCapture?: (event: CaptureAreaEvent) => void
onToast?: (toast: PDFEditorToast) => void
onSelectionAction?: (
actionId: string,
payload: PdfEditorSelectionPayload
) => void
}
const LEFT_PANEL_TABS: Array<{
id: PdfEditorLeftPanel
label: string
glyph: PdfEditorGlyph
feature: keyof PdfEditorFeatureFlags | null
}> = [
{ id: "thumbnails", label: "Thumbnails", glyph: GridGlyph, feature: null },
{ id: "outline", label: "Outline", glyph: ListTreeGlyph, feature: "outline" },
{
id: "attachments",
label: "Attachments",
glyph: PaperclipGlyph,
feature: "attachments",
},
{
id: "pages",
label: "Organize pages",
glyph: LayersGlyph,
feature: "pages",
},
]
const RIGHT_PANEL_TABS: Array<{
id: PdfEditorRightPanel
label: string
glyph: PdfEditorGlyph
feature: keyof PdfEditorFeatureFlags | null
}> = [
{ id: "properties", label: "Properties", glyph: SlidersGlyph, feature: null },
{
id: "comments",
label: "Comments",
glyph: CommentsGlyph,
feature: "comments",
},
{
id: "redactions",
label: "Redactions",
glyph: EyeOffGlyph,
feature: "redact",
},
{ id: "stamps", label: "Stamps", glyph: StampGlyph, feature: "stamps" },
{
id: "signatures",
label: "Signatures",
glyph: SignatureGlyph,
feature: "sign",
},
{
id: "forms",
label: "Form fields",
glyph: TextFieldGlyph,
feature: "forms",
},
]
function PdfEditorInner({
documentId,
document: pdfDocument,
documentState,
unavailableContent,
fileName,
className,
defaultZoom,
defaultMode,
leftPanel,
setLeftPanel,
formDesignMode,
setFormDesignMode,
annotationAuthor,
features,
showToolbar,
showUpload,
showDownload,
toolbarActions,
selectionActions,
signatureFontsStylesheetUrl,
renderPageOverlay,
handleRef,
onReplaceDocument,
onUploadFile,
onActivePageChange,
onModeChange,
onAnnotationEvent,
onAnnotationsChange,
onFormValuesChange,
onRedactionsApplied,
onSave,
onCapture,
onToast,
onSelectionAction,
}: PdfEditorInnerProps) {
const { registry } = useRegistry()
const permissions = useDocumentPermissions(documentId)
const { state: scrollState, provides: scroll } = useScroll(documentId)
const { provides: scrollCapability } = useScrollCapability()
const { state: zoomState, provides: zoom } = useZoom(documentId)
const { provides: rotate } = useRotate(documentId)
const { spreadMode, provides: spread } = useSpread(documentId)
const { provides: pan, isPanning } = usePan(documentId)
const { state: captureState, provides: capture } = useCapture(documentId)
const { provides: fullscreen, state: fullscreenState } = useFullscreen()
const { provides: historyCapability } = useHistoryCapability()
const { provides: annotationCapability } = useAnnotationCapability()
const { state: annotationState, provides: annotation } =
useAnnotation(documentId)
const { state: redactionState, provides: redaction } =
useRedaction(documentId)
const { provides: exportScope } = useExport(documentId)
const { provides: print } = usePrint(documentId)
const { provides: formCapability } = useFormCapability()
const { provides: selectionCapability } = useSelectionCapability()
const [mode, setModeState] = React.useState<PdfEditorMode>(defaultMode)
const [rightPanel, setRightPanel] =
React.useState<PdfEditorRightPanel | null>(null)
const [dialog, setDialog] = React.useState<PdfEditorDialogRequest | null>(
null
)
const [searchOpen, setSearchOpen] = React.useState(false)
const [historyState, setHistoryState] = React.useState({
canUndo: false,
canRedo: false,
})
const [horizontalScroll, setHorizontalScroll] = React.useState(false)
const [isDownloading, setIsDownloading] = React.useState(false)
const rootRef = React.useRef<HTMLDivElement | null>(null)
const [toastContainer, setToastContainer] =
React.useState<HTMLDivElement | null>(null)
const [editorToastManager] = React.useState(() =>
ToastPrimitive.createToastManager()
)
const viewportElementRef = React.useRef<HTMLDivElement | null>(null)
const uploadInputRef = React.useRef<HTMLInputElement>(null)
const annotationImportInputRef = React.useRef<HTMLInputElement>(null)
const [shellRef, shellWidth] = useElementWidth<HTMLDivElement>()
const leftInline = useInlineThumbnailSidebar(shellWidth)
const rightInline = shellWidth >= RIGHT_PANEL_INLINE_MIN_WIDTH
const compactToolbar = shellWidth > 0 && shellWidth <= TOOLBAR_STACK_MAX_WIDTH
const activePage = scrollState.currentPage
const numPages = pdfDocument?.pageCount ?? 0
const controlsDisabled = !numPages
const currentZoom = zoomState.currentZoomLevel
const selectedAnnotations = React.useMemo(
() => getSelectedAnnotations(annotationState),
[annotationState]
)
const activeToolId = annotationState.activeToolId
const activeTool = React.useMemo(
() =>
activeToolId && annotationCapability
? annotationCapability.getTool(activeToolId)
: null,
[activeToolId, annotationCapability]
)
const historyScope = React.useMemo(
() => historyCapability?.forDocument(documentId) ?? null,
[documentId, historyCapability]
)
const formScope = React.useMemo(
() => formCapability?.forDocument(documentId) ?? null,
[documentId, formCapability]
)
const selectionScope = React.useMemo(
() => selectionCapability?.forDocument(documentId) ?? null,
[documentId, selectionCapability]
)
/* ---- notices ---------------------------------------------------------- */
const notify = React.useCallback(
(message: string, tone: PdfEditorNoticeTone = "info") => {
if (onToast) {
onToast({ message, tone })
return
}
editorToastManager.add({ title: message, type: tone })
},
[editorToastManager, onToast]
)
const setRootElement = React.useCallback((element: HTMLDivElement | null) => {
rootRef.current = element
setToastContainer(element)
}, [])
/* ---- tool helpers ----------------------------------------------------- */
const deactivateTools = React.useCallback(() => {
annotation?.setActiveTool(null)
if (redactionState.isRedacting) redaction?.endRedact()
if (isPanning) pan?.disablePan()
if (captureState.isMarqueeCaptureActive) capture?.disableMarqueeCapture()
if (zoomState.isMarqueeZoomActive) zoom?.disableMarqueeZoom()
}, [
annotation,
capture,
captureState.isMarqueeCaptureActive,
isPanning,
pan,
redaction,
redactionState.isRedacting,
zoom,
zoomState.isMarqueeZoomActive,
])
const setActiveTool = React.useCallback(
(toolId: string | null) => {
if (!annotation) return
if (isPanning) pan?.disablePan()
if (captureState.isMarqueeCaptureActive) capture?.disableMarqueeCapture()
if (zoomState.isMarqueeZoomActive) zoom?.disableMarqueeZoom()
if (redactionState.isRedacting) redaction?.endRedact()
annotation.setActiveTool(
toolId !== null && annotationState.activeToolId === toolId
? null
: toolId
)
},
[
annotation,
annotationState.activeToolId,
capture,
captureState.isMarqueeCaptureActive,
isPanning,
pan,
redaction,
redactionState.isRedacting,
zoom,
zoomState.isMarqueeZoomActive,
]
)
const setMode = React.useCallback(
(nextMode: PdfEditorMode) => {
setModeState((previous) => {
if (previous === nextMode) return previous
onModeChange?.(nextMode)
return nextMode
})
const defaultPanel: Record<PdfEditorMode, PdfEditorRightPanel | null> = {
view: null,
annotate: "properties",
sign: "signatures",
forms: "forms",
redact: "redactions",
}
const panel = defaultPanel[nextMode]
if (panel) setRightPanel(panel)
else setRightPanel(null)
},
[onModeChange]
)
React.useEffect(() => {
deactivateTools()
annotation?.deselectAnnotation()
// Only react to mode changes here; tool state is otherwise managed by the toolbar.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [mode])
React.useEffect(() => {
annotation?.setLocked(
mode === "forms" && formDesignMode
? { type: LockModeType.None }
: { type: LockModeType.Include, categories: ["form"] }
)
}, [annotation, formDesignMode, mode])
React.useEffect(() => {
if (mode !== "forms" && formDesignMode) setFormDesignMode(false)
}, [formDesignMode, mode, setFormDesignMode])
/* ---- history ---------------------------------------------------------- */
React.useEffect(() => {
if (!historyScope) return
const sync = () => {
const state = historyScope.getHistoryState()
setHistoryState({
canUndo: state.global.canUndo,
canRedo: state.global.canRedo,
})
}
sync()
return historyScope.onHistoryChange(sync)
}, [historyScope])
/* ---- callbacks: page, annotations, forms, redactions ------------------ */
React.useEffect(() => {
if (!controlsDisabled && activePage > 0) onActivePageChange?.(activePage)
}, [activePage, controlsDisabled, onActivePageChange])
const onAnnotationEventRef = React.useRef(onAnnotationEvent)
const onAnnotationsChangeRef = React.useRef(onAnnotationsChange)
const onFormValuesChangeRef = React.useRef(onFormValuesChange)
const onRedactionsAppliedRef = React.useRef(onRedactionsApplied)
React.useEffect(() => {
onAnnotationEventRef.current = onAnnotationEvent
onAnnotationsChangeRef.current = onAnnotationsChange
onFormValuesChangeRef.current = onFormValuesChange
onRedactionsAppliedRef.current = onRedactionsApplied
})
React.useEffect(() => {
if (!annotation) return
let timeout: number | null = null
const unsubscribe = annotation.onAnnotationEvent((event) => {
onAnnotationEventRef.current?.(event)
if (!onAnnotationsChangeRef.current) return
if (event.type === "loaded" || !event.committed) return
if (timeout !== null) window.clearTimeout(timeout)
timeout = window.setTimeout(() => {
timeout = null
annotation.exportAnnotations().wait((items) => {
onAnnotationsChangeRef.current?.(items)
}, noop)
}, 400)
})
return () => {
if (timeout !== null) window.clearTimeout(timeout)
unsubscribe()
}
}, [annotation])
React.useEffect(() => {
if (!formScope) return
return formScope.onFieldValueChange(() => {
onFormValuesChangeRef.current?.(formScope.getFormValues())
})
}, [formScope])
React.useEffect(() => {
if (!redaction) return
return redaction.onRedactionEvent((event) => {
if (event.type !== "commit") return
if (event.success) {
notify(
"Redactions applied. The content was removed from the document.",
"success"
)
onRedactionsAppliedRef.current?.()
} else {
notify("The redactions could not be applied.", "error")
}
})
}, [notify, redaction])
/* ---- initial zoom (numeric defaults never lift the viewport gate) ----- */
const initialZoomDocumentRef = React.useRef<string | null>(null)
React.useEffect(() => {
if (!pdfDocument || !zoom) return
if (initialZoomDocumentRef.current === documentId) return
initialZoomDocumentRef.current = documentId
zoom.requestZoom(toZoomLevel(defaultZoom))
}, [defaultZoom, documentId, pdfDocument, zoom])
/* ---- document actions ------------------------------------------------- */
const scrollToPage = React.useCallback(
(
pageNumber: number,
options?:
| {
smooth?: boolean
}
| ScrollIntoViewOptions
) => {
const smooth =
options && "smooth" in options
? options.smooth
: options && "behavior" in options
? options.behavior === "smooth"
: false
scroll?.scrollToPage({ pageNumber, behavior: smooth ? "smooth" : "auto" })
},
[scroll]
)
const scrollToAnnotation = React.useCallback(
(target: PdfAnnotationObject) => {
scroll?.scrollToPage({
pageNumber: target.pageIndex + 1,
pageCoordinates: { x: target.rect.origin.x, y: target.rect.origin.y },
alignX: 50,
alignY: 25,
behavior: "smooth",
})
},
[scroll]
)
const getDocumentBuffer = React.useCallback(async () => {
if (!exportScope) throw new Error("The document is not ready.")
if (annotation && annotationState.hasPendingChanges) {
try {
await annotation.commit().toPromise()
} catch {
// Continue with whatever the engine currently holds.
}
}
return exportScope.saveAsCopy().toPromise()
}, [annotation, annotationState.hasPendingChanges, exportScope])
const downloadDocument = React.useCallback(
async (name?: string) => {
const targetName = getPdfFileName(name ?? fileName)
setIsDownloading(true)
try {
const buffer = await getDocumentBuffer()
downloadArrayBuffer(buffer, targetName)
onSave?.({ buffer, fileName: targetName })
} catch (error) {
console.error(error)
notify("The document could not be saved.", "error")
} finally {
setIsDownloading(false)
}
},
[fileName, getDocumentBuffer, notify, onSave]
)
const flattenDocument = React.useCallback(() => {
setDialog({
type: "confirm",
title: "Flatten annotations and form fields?",
description:
"Annotations and filled form fields become part of the page content and can no longer be edited.",
confirmLabel: "Flatten",
destructive: true,
onConfirm: async () => {
if (!registry || !pdfDocument) return
try {
if (annotation && annotationState.hasPendingChanges) {
await annotation.commit().toPromise()
}
const engine = registry.getEngine()
for (const page of pdfDocument.pages) {
await engine
.flattenPage(pdfDocument, page, {
flag: PdfPageFlattenFlag.Display,
})
.toPromise()
}
const buffer = await engine.saveAsCopy(pdfDocument).toPromise()
onReplaceDocument(buffer, fileName)
notify("Annotations flattened into the page content.", "success")
} catch (error) {
console.error(error)
notify("The document could not be flattened.", "error")
}
},
})
}, [
annotation,
annotationState.hasPendingChanges,
fileName,
notify,
onReplaceDocument,
pdfDocument,
registry,
])
const exportAnnotationsJson = React.useCallback(() => {
if (!annotation) return
annotation.exportAnnotations().wait(
(items) => {
downloadBlob(
new Blob([JSON.stringify(serializeAnnotationItems(items), null, 2)], {
type: "application/json",
}),
`${fileName.replace(/\.pdf$/i, "")}-annotations.json`
)
notify(
`Exported ${items.length} annotation${items.length === 1 ? "" : "s"}.`,
"success"
)
},
() => notify("The annotations could not be exported.", "error")
)
}, [annotation, fileName, notify])
const importAnnotationsFile = React.useCallback(
async (file: File) => {
if (!annotation) return
try {
const items = deserializeAnnotationItems(JSON.parse(await file.text()))
annotation.importAnnotations(items)
notify(
`Imported ${items.length} annotation${items.length === 1 ? "" : "s"}.`,
"success"
)
} catch {
notify("The file is not a valid annotation export.", "error")
}
},
[annotation, notify]
)
const deleteSelectedAnnotations = React.useCallback(() => {
if (!annotation || selectedAnnotations.length === 0) return
const deletable = selectedAnnotations.filter(
(item) => !annotation.isAnnotationStructurallyLocked(item.object)
)
if (deletable.length === 0) return
annotation.deleteAnnotations(
deletable.map((item) => ({
pageIndex: item.object.pageIndex,
id: item.object.id,
}))
)
}, [annotation, selectedAnnotations])
const applyAllRedactions = React.useCallback(() => {
if (!redaction || redactionState.pendingCount === 0) return
setDialog({
type: "confirm",
title: `Apply ${redactionState.pendingCount} redaction${redactionState.pendingCount === 1 ? "" : "s"}?`,
description:
"Redaction permanently removes the marked content from the document. This cannot be undone.",
confirmLabel: "Apply redactions",
onConfirm: () => {
redaction.commitAllPending().wait(noop, noop)
},
})
}, [redaction, redactionState.pendingCount])
/* ---- imperative handle ------------------------------------------------ */
React.useEffect(() => {
if (controlsDisabled) return
handleRef.current = {
getDocumentBuffer,
download: downloadDocument,
print: async (options) => {
if (!print) throw new Error("Printing is not available.")
await print.print(options).toPromise()
},
exportAnnotations: async () => {
if (!annotation) return []
return annotation.exportAnnotations().toPromise()
},
importAnnotations: (items) => annotation?.importAnnotations(items),
getFormValues: () => formScope?.getFormValues() ?? {},
setFormValues: async (values) => {
if (!formScope) return
await formScope.setFormValues(values).toPromise()
},
applyRedactions: async () => {
if (!redaction) return
await redaction.commitAllPending().toPromise()
},
setMode,
setActiveTool: (toolId) => annotation?.setActiveTool(toolId),
scrollToPage: (pageNumber, options) => scrollToPage(pageNumber, options),
undo: () => historyScope?.undo(),
redo: () => historyScope?.redo(),
getViewportElement: () => viewportElementRef.current,
}
return () => {
handleRef.current = null
}
}, [
annotation,
controlsDisabled,
downloadDocument,
formScope,
getDocumentBuffer,
handleRef,
historyScope,
print,
redaction,
scrollToPage,
setMode,
])
/* ---- keyboard shortcuts ----------------------------------------------- */
const handleKeyDown = React.useCallback(
(event: React.KeyboardEvent<HTMLDivElement>) => {
if (controlsDisabled) return
const key = event.key
const mod = event.metaKey || event.ctrlKey
if (isTypingTarget(event.target)) {
if (key === "Escape") (event.target as HTMLElement).blur()
return
}
if (key === "Escape") {
deactivateTools()
annotation?.deselectAnnotation()
selectionScope?.clear()
setSearchOpen(false)
event.preventDefault()
return
}
if (
(key === "Delete" || key === "Backspace") &&
selectedAnnotations.length > 0
) {
deleteSelectedAnnotations()
event.preventDefault()
return
}
if (mod && key.toLowerCase() === "z") {
if (event.shiftKey) historyScope?.redo()
else historyScope?.undo()
event.preventDefault()
return
}
if (mod && key.toLowerCase() === "y") {
historyScope?.redo()
event.preventDefault()
return
}
if (mod && (key === "=" || key === "+")) {
zoom?.zoomIn()
event.preventDefault()
return
}
if (mod && key === "-") {
zoom?.zoomOut()
event.preventDefault()
return
}
if (mod && key === "0") {
zoom?.requestZoom(ZoomMode.FitPage)
event.preventDefault()
return
}
if (mod && key.toLowerCase() === "p" && features.print) {
setDialog({ type: "print" })
event.preventDefault()
return
}
if (mod && key.toLowerCase() === "s" && showDownload) {
void downloadDocument()
event.preventDefault()
return
}
if (mod && key.toLowerCase() === "f") {
setSearchOpen(true)
event.preventDefault()
return
}
if (mod && key.toLowerCase() === "a" && annotation && mode !== "view") {
const ids = annotationState.pages[Math.max(0, activePage - 1)] ?? []
if (ids.length > 0) {
annotation.setSelection(ids)
event.preventDefault()
}
return
}
if (!mod && !event.altKey && key.toLowerCase() === "h") {
pan?.togglePan()
event.preventDefault()
return
}
if (!mod && !event.altKey && key.toLowerCase() === "v") {
deactivateTools()
event.preventDefault()
}
},
[
activePage,
annotation,
annotationState.pages,
controlsDisabled,
deactivateTools,
deleteSelectedAnnotations,
downloadDocument,
features.print,
historyScope,
mode,
pan,
selectedAnnotations.length,
selectionScope,
showDownload,
zoom,
]
)
const handlePointerDownCapture = React.useCallback(
(event: React.PointerEvent<HTMLDivElement>) => {
const root = rootRef.current
if (!root) return
const active = window.document.activeElement
if (active && root.contains(active) && active !== root) return
if (!(event.target instanceof Node) || !root.contains(event.target))
return
if (isTypingTarget(event.target)) return
root.focus({ preventScroll: true })
},
[]
)
/* ---- context ---------------------------------------------------------- */
const openDialog = React.useCallback(
(request: PdfEditorDialogRequest) => setDialog(request),
[]
)
const toggleLeftPanel = React.useCallback(
(panel: PdfEditorLeftPanel) =>
setLeftPanel((previous) => (previous === panel ? null : panel)),
[setLeftPanel]
)
const toggleRightPanel = React.useCallback(
(panel: PdfEditorRightPanel) =>
setRightPanel((previous) => (previous === panel ? null : panel)),
[]
)
const contextValue = React.useMemo<PdfEditorContextValue>(
() => ({
documentId,
document: pdfDocument,
fileName,
mode,
setMode,
leftPanel,
setLeftPanel,
toggleLeftPanel,
rightPanel,
setRightPanel,
toggleRightPanel,
openDialog,
notify,
features,
permissions,
annotationAuthor,
selectionActions,
formDesignMode,
setFormDesignMode,
replaceDocument: (buffer, name) =>
onReplaceDocument(buffer, name ?? fileName),
downloadDocument,
getDocumentBuffer,
scrollToPage,
scrollToAnnotation,
onCapture,
onSelectionAction,
signatureFontsStylesheetUrl,
}),
[
annotationAuthor,
documentId,
downloadDocument,
features,
fileName,
formDesignMode,
getDocumentBuffer,
leftPanel,
mode,
notify,
onCapture,
onReplaceDocument,
onSelectionAction,
openDialog,
pdfDocument,
permissions,
rightPanel,
scrollToAnnotation,
scrollToPage,
selectionActions,
setFormDesignMode,
setLeftPanel,
setMode,
signatureFontsStylesheetUrl,
toggleLeftPanel,
toggleRightPanel,
]
)
/* ---- page renderer ---------------------------------------------------- */
const renderPage = React.useCallback(
(page: PageLayout) => (
<Rotate documentId={documentId} pageIndex={page.pageIndex}>
<PagePointerProvider
documentId={documentId}
pageIndex={page.pageIndex}
data-pdf-editor-page={page.pageNumber}
className="relative border border-transparent bg-transparent shadow-xs select-none selection:bg-transparent selection:text-inherit"
style={{ backgroundColor: "transparent" }}
>
<div
aria-hidden="true"
className="pointer-events-none absolute inset-0 bg-white"
/>
<RenderLayer
documentId={documentId}
pageIndex={page.pageIndex}
scale={Math.min(currentZoom, PAGE_BASE_RENDER_MAX_SCALE)}
dpr={PAGE_BASE_RENDER_DPR}
className="pointer-events-none absolute inset-0 h-full w-full object-fill opacity-100 blur-[0.35px] transition-none"
/>
<TilingLayer
documentId={documentId}
pageIndex={page.pageIndex}
className="pointer-events-none opacity-100 transition-none [&_img]:opacity-100 [&_img]:transition-none"
/>
<SearchLayer
documentId={documentId}
pageIndex={page.pageIndex}
className="pointer-events-none"
highlightColor="rgba(253, 224, 71, 0.45)"
activeHighlightColor="rgba(249, 115, 22, 0.55)"
/>
<MarqueeZoom documentId={documentId} pageIndex={page.pageIndex} />
<MarqueeCapture documentId={documentId} pageIndex={page.pageIndex} />
<SelectionLayer
documentId={documentId}
pageIndex={page.pageIndex}
textStyle={{ background: "rgba(59, 130, 246, 0.3)" }}
selectionMenu={(props) => (
<PdfEditorTextSelectionMenu {...props} documentId={documentId} />
)}
/>
<AnnotationLayer
documentId={documentId}
pageIndex={page.pageIndex}
selectionOutline={{
color: PDF_EDITOR_SELECTION_COLOR,
style: "solid",
width: 1,
offset: 2,
}}
groupSelectionOutline={{
color: PDF_EDITOR_SELECTION_COLOR,
style: "dashed",
width: 1,
offset: 3,
}}
resizeUI={{ size: 10, color: PDF_EDITOR_SELECTION_COLOR }}
vertexUI={{ size: 10, color: PDF_EDITOR_SELECTION_COLOR }}
rotationUI={{
size: 18,
color: "#ffffff",
iconColor: PDF_EDITOR_SELECTION_COLOR,
connectorColor: PDF_EDITOR_SELECTION_COLOR,
showConnector: true,
}}
selectionMenu={(props) => (
<PdfEditorAnnotationMenu {...props} documentId={documentId} />
)}
groupSelectionMenu={(props) => (
<PdfEditorGroupMenu {...props} documentId={documentId} />
)}
/>
<RedactionLayer
documentId={documentId}
pageIndex={page.pageIndex}
selectionMenu={(props) => (
<PdfEditorRedactionMenu {...props} documentId={documentId} />
)}
/>
{renderPageOverlay?.({
pageNumber: page.pageNumber,
pageWidth: page.width,
pageHeight: page.height,
scale: currentZoom,
rotation: 0,
})}
</PagePointerProvider>
</Rotate>
),
[currentZoom, documentId, renderPageOverlay]
)
/* ---- toolbar pieces --------------------------------------------------- */
const activeToolColorKey = React.useMemo(() => {
if (!activeTool) return null
const defaults = activeTool.defaults as Record<string, unknown>
if ("fontColor" in defaults) return "fontColor"
if ("strokeColor" in defaults) return "strokeColor"
if ("color" in defaults) return "color"
return null
}, [activeTool])
const activeToolColor = activeToolColorKey
? String(
(activeTool?.defaults as Record<string, unknown>)[activeToolColorKey] ??
"#111827"
)
: null
const visibleLeftTabs = LEFT_PANEL_TABS.filter(
(tab) => tab.feature === null || features[tab.feature]
)
const visibleRightTabs = RIGHT_PANEL_TABS.filter(
(tab) => tab.feature === null || features[tab.feature]
)
const canAnnotate = features.annotate && permissions.canModifyAnnotations
const canRedact = features.redact && permissions.canModifyContents
const canDesignForms = features.forms && permissions.canModifyAnnotations
const selectToolButton = (
<PdfEditorToolButton
label="Select"
shortcut="V"
active={
!activeToolId &&
!isPanning &&
!captureState.isMarqueeCaptureActive &&
!zoomState.isMarqueeZoomActive &&
!redactionState.isRedacting
}
onClick={deactivateTools}
>
<CursorGlyph className="size-4" />
</PdfEditorToolButton>
)
const renderToolButton = (
tool: {
id: string
label: string
glyph: PdfEditorGlyph
hint?: string
},
disabled: boolean
) => {
const Glyph = tool.glyph
return (
<PdfEditorToolButton
key={tool.id}
label={tool.hint ? `${tool.label} · ${tool.hint}` : tool.label}
active={activeToolId === tool.id}
disabled={disabled}
onClick={() => setActiveTool(tool.id)}
>
<Glyph className="size-4" />
</PdfEditorToolButton>
)
}
let ribbon: React.ReactNode = null
if (mode === "view") {
ribbon = (
<>
{selectToolButton}
<PdfEditorToolButton
label="Hand tool"
shortcut="H"
active={isPanning}
onClick={() => pan?.togglePan()}
>
<HandGlyph className="size-4" />
</PdfEditorToolButton>
<PdfEditorToolButton
label="Zoom to area"
active={zoomState.isMarqueeZoomActive}
onClick={() => zoom?.toggleMarqueeZoom()}
>
<MarqueeZoomGlyph className="size-4" />
</PdfEditorToolButton>
{features.capture ? (
<PdfEditorToolButton
label="Capture area as image"
active={captureState.isMarqueeCaptureActive}
onClick={() => capture?.toggleMarqueeCapture()}
>
<CameraGlyph className="size-4" />
</PdfEditorToolButton>
) : null}
<PdfEditorToolbarSeparator />
<Select
items={SPREAD_MODE_OPTIONS}
value={spreadMode}
disabled={controlsDisabled}
modal={false}
onValueChange={(value) =>
spread?.setSpreadMode(String(value) as SpreadMode)
}
>
<SelectTrigger
size="sm"
className="w-[132px]"
aria-label="Page layout"
>
<BookOpenGlyph className="size-4 text-muted-foreground" />
<SelectValue placeholder="Layout" />
</SelectTrigger>
<SelectContent alignItemWithTrigger={false}>
{SPREAD_MODE_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Select
items={SCROLL_DIRECTION_OPTIONS}
value={horizontalScroll ? "horizontal" : "vertical"}
disabled={controlsDisabled}
modal={false}
onValueChange={(value) => {
const horizontal = value === "horizontal"
setHorizontalScroll(horizontal)
scrollCapability?.setScrollStrategy(
horizontal ? ScrollStrategy.Horizontal : ScrollStrategy.Vertical,
documentId
)
}}
>
<SelectTrigger
size="sm"
className="w-[128px]"
aria-label="Scroll direction"
>
<SelectValue placeholder="Scroll" />
</SelectTrigger>
<SelectContent alignItemWithTrigger={false}>
{SCROLL_DIRECTION_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</>
)
} else if (mode === "annotate") {
ribbon = (
<>
{selectToolButton}
{PDF_EDITOR_ANNOTATION_TOOL_GROUPS.map((group) => (
<React.Fragment key={group.id}>
<PdfEditorToolbarSeparator />
{group.tools.map((tool) => renderToolButton(tool, !canAnnotate))}
</React.Fragment>
))}
{features.stamps ? (
<PdfEditorToolButton
label="Rubber stamps"
active={rightPanel === "stamps"}
disabled={!canAnnotate}
onClick={() => toggleRightPanel("stamps")}
>
<StampGlyph className="size-4" />
</PdfEditorToolButton>
) : null}
{activeTool && activeToolColorKey && activeToolColor ? (
<>
<PdfEditorToolbarSeparator />
<ColorPicker
label={`${activeTool.name} color`}
icon={PaletteGlyph}
color={activeToolColor}
onChange={(color) =>
annotationCapability?.setToolDefaults(activeTool.id, {
[activeToolColorKey]: color,
})
}
/>
</>
) : null}
<div className="ml-auto flex items-center gap-0.5">
<PdfEditorToolButton
label="Delete selected"
shortcut="Delete"
disabled={selectedAnnotations.length === 0}
onClick={deleteSelectedAnnotations}
>
<TrashGlyph className="size-4" />
</PdfEditorToolButton>
{features.comments ? (
<PdfEditorToolButton
label="Comments"
active={rightPanel === "comments"}
onClick={() => toggleRightPanel("comments")}
>
<CommentsGlyph className="size-4" />
</PdfEditorToolButton>
) : null}
<PdfEditorToolButton
label="Properties"
active={rightPanel === "properties"}
onClick={() => toggleRightPanel("properties")}
>
<SlidersGlyph className="size-4" />
</PdfEditorToolButton>
</div>
</>
)
} else if (mode === "sign") {
ribbon = (
<>
{selectToolButton}
<PdfEditorToolbarSeparator />
<Button
type="button"
size="sm"
variant="outline"
disabled={!canAnnotate}
onClick={() => setDialog({ type: "signature" })}
>
<PlusGlyph className="size-4" />
Create signature
</Button>
<PdfEditorToolButton
label="Saved signatures"
active={rightPanel === "signatures"}
onClick={() => toggleRightPanel("signatures")}
>
<SignatureGlyph className="size-4" />
</PdfEditorToolButton>
<PdfEditorToolbarSeparator />
{renderToolButton(
{
id: "freeText",
label: "Text box",
glyph: TextGlyph,
hint: "Click to add text",
},
!canAnnotate
)}
<PdfEditorToolButton
label="Date · Click to add today's date"
disabled={!canAnnotate}
active={
activeToolId === "freeText" &&
Boolean(annotationState.activeToolContext?.date)
}
onClick={() => {
if (!annotation) return
annotationCapability?.setToolDefaults("freeText", {
contents: new Date().toLocaleDateString(),
})
annotation.setActiveTool("freeText", { date: true })
}}
>
<FileTextGlyph className="size-4" />
</PdfEditorToolButton>
{renderToolButton(
{ id: "ink", label: "Draw", glyph: EditGlyph, hint: "Sign by hand" },
!canAnnotate
)}
</>
)
} else if (mode === "forms") {
ribbon = (
<>
{selectToolButton}
<PdfEditorToolbarSeparator />
<div
role="group"
aria-label="Form mode"
className="flex shrink-0 items-center gap-0.5 rounded-lg bg-muted p-0.5"
>
<Button
type="button"
size="xs"
variant={formDesignMode ? "ghost" : "default"}
aria-pressed={!formDesignMode}
onClick={() => setFormDesignMode(false)}
>
Fill
</Button>
<Button
type="button"
size="xs"
variant={formDesignMode ? "default" : "ghost"}
aria-pressed={formDesignMode}
disabled={!canDesignForms}
onClick={() => setFormDesignMode(true)}
>
Design
</Button>
</div>
{formDesignMode ? (
<>
<PdfEditorToolbarSeparator />
{PDF_EDITOR_FORM_TOOLS.map((tool) =>
renderToolButton(tool, !canDesignForms)
)}
</>
) : null}
<div className="ml-auto flex items-center gap-0.5">
<PdfEditorToolButton
label="Delete selected"
shortcut="Delete"
disabled={selectedAnnotations.length === 0 || !formDesignMode}
onClick={deleteSelectedAnnotations}
>
<TrashGlyph className="size-4" />
</PdfEditorToolButton>
<PdfEditorToolButton
label="Form fields"
active={rightPanel === "forms"}
onClick={() => toggleRightPanel("forms")}
>
<TextFieldGlyph className="size-4" />
</PdfEditorToolButton>
<PdfEditorToolButton
label="Properties"
active={rightPanel === "properties"}
onClick={() => toggleRightPanel("properties")}
>
<SlidersGlyph className="size-4" />
</PdfEditorToolButton>
</div>
</>
)
} else if (mode === "redact") {
const activeType = redactionState.activeType
const pendingCount = redactionState.pendingCount
ribbon = (
<>
{selectToolButton}
<PdfEditorToolbarSeparator />
<PdfEditorToolButton
label="Redact · Select text or drag an area"
active={activeType === RedactionMode.Redact}
disabled={!canRedact}
onClick={() => {
deactivateTools()
if (activeType !== RedactionMode.Redact) redaction?.enableRedact()
}}
>
<EyeOffGlyph className="size-4" />
</PdfEditorToolButton>
<PdfEditorToolButton
label="Mark text"
active={activeType === RedactionMode.RedactSelection}
disabled={!canRedact}
onClick={() => {
deactivateTools()
if (activeType !== RedactionMode.RedactSelection)
redaction?.enableRedactSelection()
}}
>
<StrikethroughGlyph className="size-4" />
</PdfEditorToolButton>
<PdfEditorToolButton
label="Mark area"
active={activeType === RedactionMode.MarqueeRedact}
disabled={!canRedact}
onClick={() => {
deactivateTools()
if (activeType !== RedactionMode.MarqueeRedact)
redaction?.enableMarqueeRedact()
}}
>
<RedactAreaGlyph className="size-4" />
</PdfEditorToolButton>
<PdfEditorToolbarSeparator />
<Badge
variant={pendingCount > 0 ? "secondary" : "outline"}
className="me-1 tabular-nums"
>
{pendingCount} pending
</Badge>
<Button
type="button"
size="sm"
disabled={!canRedact || pendingCount === 0}
onClick={applyAllRedactions}
>
Apply all
</Button>
<Button
type="button"
size="sm"
variant="ghost"
disabled={pendingCount === 0}
onClick={() => redaction?.clearPending()}
>
Clear
</Button>
<div className="ml-auto flex items-center gap-0.5">
<PdfEditorToolButton
label="Redactions"
active={rightPanel === "redactions"}
onClick={() => toggleRightPanel("redactions")}
>
<EyeOffGlyph className="size-4" />
</PdfEditorToolButton>
<PdfEditorToolButton
label="Properties"
active={rightPanel === "properties"}
onClick={() => toggleRightPanel("properties")}
>
<SlidersGlyph className="size-4" />
</PdfEditorToolButton>
</div>
</>
)
}
let rightPanelContent: React.ReactNode = null
switch (rightPanel) {
case "properties":
rightPanelContent = <PdfEditorPropertiesPanel documentId={documentId} />
break
case "comments":
rightPanelContent = <PdfEditorCommentsPanel documentId={documentId} />
break
case "redactions":
rightPanelContent = <PdfEditorRedactionsPanel documentId={documentId} />
break
case "stamps":
rightPanelContent = <PdfEditorStampsPanel documentId={documentId} />
break
case "signatures":
rightPanelContent = <PdfEditorSignaturesPanel documentId={documentId} />
break
case "forms":
rightPanelContent = <PdfEditorFormsPanel documentId={documentId} />
break
default:
rightPanelContent = null
}
let leftPanelContent: React.ReactNode = null
switch (leftPanel) {
case "thumbnails":
leftPanelContent = (
<PdfEditorThumbnailsPanel
documentId={documentId}
activePage={activePage}
pageCount={numPages}
onSelectPage={(pageNumber) => scrollToPage(pageNumber)}
/>
)
break
case "outline":
leftPanelContent = <PdfEditorOutlinePanel documentId={documentId} />
break
case "attachments":
leftPanelContent = <PdfEditorAttachmentsPanel documentId={documentId} />
break
case "pages":
leftPanelContent = (
<PdfEditorPagesPanel documentId={documentId} activePage={activePage} />
)
break
default:
leftPanelContent = null
}
return (
<PdfEditorContextProvider value={contextValue}>
<ToastProvider
limit={3}
timeout={NOTICE_DURATION_MS}
toastManager={editorToastManager}
portalProps={{ container: toastContainer }}
viewportProps={{ className: "absolute" }}
>
<div
data-slot="pdf-editor"
data-mode={mode}
data-state={documentState}
aria-busy={documentState === "loading"}
data-fullscreen={fullscreenState.isFullscreen ? "" : undefined}
className={cn(
"flex h-full max-h-full min-h-0 w-full flex-col overflow-hidden bg-background",
className,
// The fullscreen plugin auto-mounts a wrapper around the EmbedPDF
// tree; when that wrapper is fullscreen, fill it instead of the
// consumer-provided height.
fullscreenState.isFullscreen && "h-full max-h-full"
)}
>
<div
ref={setRootElement}
tabIndex={-1}
className="relative flex h-full min-h-0 flex-1 flex-col outline-none"
onKeyDown={handleKeyDown}
onPointerDownCapture={handlePointerDownCapture}
>
<input
ref={uploadInputRef}
type="file"
accept="application/pdf,.pdf"
className="sr-only"
tabIndex={-1}
onChange={(event) => {
const file = event.target.files?.[0]
if (file) onUploadFile(file)
event.currentTarget.value = ""
}}
/>
<input
ref={annotationImportInputRef}
type="file"
accept="application/json,.json"
className="sr-only"
tabIndex={-1}
onChange={(event) => {
const file = event.target.files?.[0]
if (file) void importAnnotationsFile(file)
event.currentTarget.value = ""
}}
/>
{show
[truncated — showing 100000 of 140309 characters]components/extend/pdf-editor-workspace.tsx
"use client"
import * as React from "react"
import { cn } from "@/lib/utils"
import {
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
} from "@/components/ui/resizable"
function PanelBorder({ side }: { side: "left" | "right" }) {
return (
<ResizableHandle
aria-label={`Resize ${side} panel`}
className={cn(
"pointer-events-auto z-40 w-px bg-border before:absolute before:inset-y-2 before:w-px before:bg-primary/50 before:[mask-image:linear-gradient(to_bottom,transparent,black_8%,black_92%,transparent)] before:opacity-0 before:transition-opacity before:duration-200 before:ease-in-out hover:before:opacity-100 focus-visible:ring-0 focus-visible:ring-offset-0 focus-visible:before:opacity-100 data-[separator=active]:before:opacity-100 data-[separator=hover]:before:opacity-100 motion-reduce:before:transition-none",
side === "left" ? "before:-left-px" : "before:left-full"
)}
/>
)
}
function SidebarPanel({
defaultSize,
...props
}: React.ComponentProps<typeof ResizablePanel>) {
// Keep the registration size stable until the panel is mounted again.
const [initialSize] = React.useState(defaultSize)
return <ResizablePanel {...props} defaultSize={initialSize} />
}
export function PdfEditorWorkspace({
children,
left,
right,
leftInline,
rightInline,
}: {
children: React.ReactNode
left: React.ReactNode
right: React.ReactNode
leftInline: boolean
rightInline: boolean
}) {
const id = React.useId()
const [leftSize, setLeftSize] = React.useState<string | number>("14rem")
const [rightSize, setRightSize] = React.useState<string | number>("21rem")
const leftElement = React.useRef<HTMLDivElement>(null)
const rightElement = React.useRef<HTMLDivElement>(null)
const leftId = `${id}-left`
const rightId = `${id}-right`
const rememberSizes = () => {
if (leftElement.current)
setLeftSize(leftElement.current.getBoundingClientRect().width)
if (rightElement.current)
setRightSize(rightElement.current.getBoundingClientRect().width)
}
const sidebar = (side: "left" | "right", content: React.ReactNode) => (
<SidebarPanel
id={side === "left" ? leftId : rightId}
defaultSize={side === "left" ? leftSize : rightSize}
minSize={side === "left" ? "14rem" : "21rem"}
maxSize={side === "left" ? "28rem" : "32rem"}
groupResizeBehavior="preserve-pixel-size"
elementRef={side === "left" ? leftElement : rightElement}
className="pointer-events-auto h-full min-h-0 overflow-hidden bg-sidebar"
>
<aside
data-slot={`pdf-editor-${side}-panel`}
className="flex h-full min-h-0 flex-col"
>
{content}
</aside>
</SidebarPanel>
)
return (
<div className="relative h-full min-h-0 w-full flex-1 overflow-hidden">
<ResizablePanelGroup
orientation="horizontal"
onLayoutChanged={rememberSizes}
>
{left && leftInline ? sidebar("left", left) : null}
{left && leftInline ? <PanelBorder side="left" /> : null}
<ResizablePanel
id={`${id}-document`}
minSize={leftInline || rightInline ? "15rem" : "0%"}
className="relative flex h-full min-h-0 min-w-0 flex-col overflow-hidden"
>
{children}
</ResizablePanel>
{right && rightInline ? <PanelBorder side="right" /> : null}
{right && rightInline ? sidebar("right", right) : null}
</ResizablePanelGroup>
{(["left", "right"] as const).map((side) => {
const content = side === "left" ? left : right
const inline = side === "left" ? leftInline : rightInline
if (!content || inline) return null
return (
<ResizablePanelGroup
key={side}
orientation="horizontal"
onLayoutChanged={rememberSizes}
className={cn(
"pointer-events-none absolute inset-y-0 z-30",
side === "left" ? "left-0 min-w-60" : "right-0 min-w-88"
)}
>
{side === "right" ? (
<ResizablePanel id={`${id}-right-space`} minSize="1rem" />
) : null}
{side === "right" ? <PanelBorder side="right" /> : null}
{sidebar(side, content)}
{side === "left" ? <PanelBorder side="left" /> : null}
{side === "left" ? (
<ResizablePanel id={`${id}-left-space`} minSize="1rem" />
) : null}
</ResizablePanelGroup>
)
})}
</div>
)
}
components/extend/pdf-editor-shared.tsx
"use client"
import * as React from "react"
import { ScrollArea as ScrollAreaPrimitive } from "@base-ui/react/scroll-area"
import type { DocumentPermissions } from "@embedpdf/core/react"
import type {
PdfAnnotationObject,
PdfDocumentObject,
Rect,
Size,
} from "@embedpdf/models"
import { PdfAnnotationSubtype } from "@embedpdf/models"
import type { CaptureAreaEvent } from "@embedpdf/plugin-capture/react"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Kbd } from "@/components/ui/kbd"
import {
ScrollArea as InlineScrollArea,
ScrollBar,
} from "@/components/ui/scroll-area"
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip"
import { IconPlaceholder } from "@/components/icon-placeholder"
/* -------------------------------------------------------------------------- */
/* Types */
/* -------------------------------------------------------------------------- */
export type PdfEditorMode = "view" | "annotate" | "sign" | "forms" | "redact"
export type PdfEditorLeftPanel =
| "thumbnails"
| "outline"
| "attachments"
| "pages"
export type PdfEditorRightPanel =
| "properties"
| "comments"
| "redactions"
| "stamps"
| "signatures"
| "forms"
export type PdfEditorFeatureFlags = {
/** Text markup, drawing, shapes, text boxes, notes, links, and image stamps. */
annotate?: boolean
/** Mark text or areas for redaction and permanently apply them. */
redact?: boolean
/** Fill AcroForm fields and design new form widgets. */
forms?: boolean
/** Draw, type, or upload signatures and initials, then place them. */
sign?: boolean
/** Rubber stamp libraries and custom stamps. */
stamps?: boolean
/** Rotate, reorder, delete, insert, extract, and merge pages. */
pages?: boolean
/** Password protection and permission restrictions. */
security?: boolean
print?: boolean
/** Area screenshot capture. */
capture?: boolean
/** Bookmark outline sidebar. */
outline?: boolean
/** Embedded file attachments sidebar. */
attachments?: boolean
/** Comment threads sidebar. */
comments?: boolean
fullscreen?: boolean
}
export type PdfEditorResolvedFeatures = Required<PdfEditorFeatureFlags>
export const PDF_EDITOR_DEFAULT_FEATURES: PdfEditorResolvedFeatures = {
annotate: true,
redact: true,
forms: true,
sign: true,
stamps: true,
pages: true,
security: true,
print: true,
capture: true,
outline: true,
attachments: true,
comments: true,
fullscreen: true,
}
/** A rectangle expressed as percentages (0-100) of the page size. */
export type PdfEditorNormalizedRect = {
top: number
left: number
width: number
height: number
}
export type PdfEditorSelectionPage = {
pageIndex: number
pageNumber: number
pageSize: Size
/** Bounding box in PDF points (origin top-left). */
rect: Rect
/** Per-line rectangles in PDF points. */
rects: Rect[]
normalizedRect: PdfEditorNormalizedRect
normalizedRects: PdfEditorNormalizedRect[]
}
/** Emitted for custom text-selection actions. Coordinates are page-relative. */
export type PdfEditorSelectionPayload = PdfEditorSelectionPage & {
documentId: string
/** The selected text across all pages, joined with newlines. */
text: string
/** Every page that the selection spans (usually one). */
pages: PdfEditorSelectionPage[]
}
export type PdfEditorSelectionAction = {
id: string
label: string
icon?: React.ReactNode
/** Called with page coordinates and the selected text. */
onSelect?: (payload: PdfEditorSelectionPayload) => void
/** Keep the text selected after the action runs. Defaults to false. */
keepSelection?: boolean
}
export type PdfEditorDialogRequest =
| {
type: "print"
}
| {
type: "security"
}
| {
type: "properties"
}
| {
type: "shortcuts"
}
| {
type: "signature"
}
| {
type: "link"
source: "annotation" | "selection"
}
| {
type: "confirm"
title: string
description?: string
confirmLabel?: string
destructive?: boolean
onConfirm: () => void
}
export type PdfEditorNoticeTone = "info" | "success" | "error"
export type PdfEditorContextValue = {
documentId: string
document: PdfDocumentObject | null
fileName: string
mode: PdfEditorMode
setMode: (mode: PdfEditorMode) => void
leftPanel: PdfEditorLeftPanel | null
setLeftPanel: (panel: PdfEditorLeftPanel | null) => void
toggleLeftPanel: (panel: PdfEditorLeftPanel) => void
rightPanel: PdfEditorRightPanel | null
setRightPanel: (panel: PdfEditorRightPanel | null) => void
toggleRightPanel: (panel: PdfEditorRightPanel) => void
openDialog: (request: PdfEditorDialogRequest) => void
notify: (message: string, tone?: PdfEditorNoticeTone) => void
features: PdfEditorResolvedFeatures
permissions: DocumentPermissions
annotationAuthor: string
selectionActions: PdfEditorSelectionAction[]
formDesignMode: boolean
setFormDesignMode: React.Dispatch<React.SetStateAction<boolean>>
/** Replaces the open document with new PDF bytes (page operations, flattening). */
replaceDocument: (buffer: ArrayBuffer, fileName?: string) => void
/** Downloads the current document, including pending edits. */
downloadDocument: (fileName?: string) => Promise<void>
/** Returns the current document bytes, including pending edits. */
getDocumentBuffer: () => Promise<ArrayBuffer>
scrollToPage: (
pageNumber: number,
options?: {
smooth?: boolean
}
) => void
scrollToAnnotation: (annotation: PdfAnnotationObject) => void
onCapture?: (event: CaptureAreaEvent) => void
onSelectionAction?: (
actionId: string,
payload: PdfEditorSelectionPayload
) => void
signatureFontsStylesheetUrl: string | null
}
const PdfEditorContext = React.createContext<PdfEditorContextValue | null>(null)
export const PdfEditorContextProvider = PdfEditorContext.Provider
export function usePdfEditor() {
const context = React.useContext(PdfEditorContext)
if (!context) {
throw new Error("usePdfEditor must be used inside a PDFEditor.")
}
return context
}
/* -------------------------------------------------------------------------- */
/* Constants */
/* -------------------------------------------------------------------------- */
export const PDF_EDITOR_DEFAULT_COLOR_PRESETS = [
"#111827",
"#e11d48",
"#f97316",
"#facc15",
"#22c55e",
"#0ea5e9",
"#2563eb",
"#8b5cf6",
"#ec4899",
"#64748b",
"#ffffff",
]
export const PDF_EDITOR_SELECTION_COLOR = "#2563eb"
export const PDF_EDITOR_DEFAULT_STAMP_MANIFEST_URL =
"https://cdn.jsdelivr.net/npm/@embedpdf/default-stamps/{locale}/manifest.json"
export const PDF_EDITOR_DEFAULT_SIGNATURE_FONTS_URL =
"https://fonts.googleapis.com/css2?family=Caveat&family=Dancing+Script&family=Great+Vibes&family=Pacifico&display=swap"
export const PDF_EDITOR_SIGNATURE_FONTS: ReadonlyArray<{
name: string
family: string
}> = [
{ name: "Dancing Script", family: "'Dancing Script', cursive" },
{ name: "Great Vibes", family: "'Great Vibes', cursive" },
{ name: "Pacifico", family: "'Pacifico', cursive" },
{ name: "Caveat", family: "'Caveat', cursive" },
]
/* -------------------------------------------------------------------------- */
/* Icons */
/* */
/* Icons that end up in value position (tool catalogs, switch statements) are */
/* wrapped in small `*Glyph` components so the shadcn CLI can still swap the */
/* placeholder for the consumer's icon library at install time. */
/* -------------------------------------------------------------------------- */
export type PdfEditorGlyphProps = {
className?: string
style?: React.CSSProperties
}
export type PdfEditorGlyph = React.ComponentType<PdfEditorGlyphProps>
export function PanelLeftGlyph({ className, style }: PdfEditorGlyphProps) {
return (
<IconPlaceholder
lucide="PanelLeft"
tabler="IconLayoutSidebar"
hugeicons="SidebarLeftIcon"
phosphor="SidebarIcon"
remixicon="RiLayoutLeftLine"
className={className}
style={style}
/>
)
}
export function PanelRightGlyph({ className, style }: PdfEditorGlyphProps) {
return (
<IconPlaceholder
lucide="PanelRight"
tabler="IconLayoutSidebarRight"
hugeicons="SidebarRightIcon"
phosphor="SidebarSimpleIcon"
remixicon="RiLayoutRightLine"
className={className}
style={style}
/>
)
}
export function UndoGlyph({ className, style }: PdfEditorGlyphProps) {
return (
<IconPlaceholder
lucide="Undo2"
tabler="IconArrowBackUp"
hugeicons="Undo02Icon"
phosphor="ArrowUUpLeftIcon"
remixicon="RiArrowGoBackLine"
className={className}
style={style}
/>
)
}
export function RedoGlyph({ className, style }: PdfEditorGlyphProps) {
return (
<IconPlaceholder
lucide="Redo2"
tabler="IconArrowForwardUp"
hugeicons="Redo02Icon"
phosphor="ArrowUUpRightIcon"
remixicon="RiArrowGoForwardLine"
className={className}
style={style}
/>
)
}
export function ZoomOutGlyph({ className, style }: PdfEditorGlyphProps) {
return (
<IconPlaceholder
lucide="CircleMinus"
tabler="IconCircleMinus"
hugeicons="MinusSignCircleIcon"
phosphor="MinusCircleIcon"
remixicon="RiIndeterminateCircleLine"
className={className}
style={style}
/>
)
}
export function ZoomInGlyph({ className, style }: PdfEditorGlyphProps) {
return (
<IconPlaceholder
lucide="CirclePlus"
tabler="IconCirclePlus"
hugeicons="PlusSignCircleIcon"
phosphor="PlusCircleIcon"
remixicon="RiAddCircleLine"
className={className}
style={style}
/>
)
}
export function RotateGlyph({ className, style }: PdfEditorGlyphProps) {
return (
<IconPlaceholder
lucide="RotateCw"
tabler="IconRotateClockwise"
hugeicons="RotateClockwiseIcon"
phosphor="ArrowClockwiseIcon"
remixicon="RiClockwiseLine"
className={className}
style={style}
/>
)
}
export function SearchGlyph({ className, style }: PdfEditorGlyphProps) {
return (
<IconPlaceholder
lucide="Search"
tabler="IconSearch"
hugeicons="Search01Icon"
phosphor="MagnifyingGlassIcon"
remixicon="RiSearchLine"
className={className}
style={style}
/>
)
}
export function MoreGlyph({ className, style }: PdfEditorGlyphProps) {
return (
<IconPlaceholder
lucide="Ellipsis"
tabler="IconDots"
hugeicons="MoreHorizontalIcon"
phosphor="DotsThreeIcon"
remixicon="RiMoreLine"
className={className}
style={style}
/>
)
}
export function DownloadGlyph({ className, style }: PdfEditorGlyphProps) {
return (
<IconPlaceholder
lucide="Download"
tabler="IconDownload"
hugeicons="Download01Icon"
phosphor="DownloadSimpleIcon"
remixicon="RiDownload2Line"
className={className}
style={style}
/>
)
}
export function UploadGlyph({ className, style }: PdfEditorGlyphProps) {
return (
<IconPlaceholder
lucide="Upload"
tabler="IconUpload"
hugeicons="Upload01Icon"
phosphor="UploadSimpleIcon"
remixicon="RiUpload2Line"
className={className}
style={style}
/>
)
}
export function PrintGlyph({ className, style }: PdfEditorGlyphProps) {
return (
<IconPlaceholder
lucide="Printer"
tabler="IconPrinter"
hugeicons="PrinterIcon"
phosphor="PrinterIcon"
remixicon="RiPrinterLine"
className={className}
style={style}
/>
)
}
export function FullscreenGlyph({ className, style }: PdfEditorGlyphProps) {
return (
<IconPlaceholder
lucide="Maximize"
tabler="IconMaximize"
hugeicons="FullScreenIcon"
phosphor="CornersOutIcon"
remixicon="RiFullscreenLine"
className={className}
style={style}
/>
)
}
export function ExitFullscreenGlyph({ className, style }: PdfEditorGlyphProps) {
return (
<IconPlaceholder
lucide="Minimize"
tabler="IconMinimize"
hugeicons="MinimizeScreenIcon"
phosphor="CornersInIcon"
remixicon="RiFullscreenExitLine"
className={className}
style={style}
/>
)
}
export function HandGlyph({ className, style }: PdfEditorGlyphProps) {
return (
<IconPlaceholder
lucide="Hand"
tabler="IconHandStop"
hugeicons="HandIcon"
phosphor="HandIcon"
remixicon="RiHand"
className={className}
style={style}
/>
)
}
export function CursorGlyph({ className, style }: PdfEditorGlyphProps) {
return (
<IconPlaceholder
lucide="MousePointer2"
tabler="IconPointer"
hugeicons="Cursor01Icon"
phosphor="CursorIcon"
remixicon="RiCursorLine"
className={className}
style={style}
/>
)
}
export function CameraGlyph({ className, style }: PdfEditorGlyphProps) {
return (
<IconPlaceholder
lucide="Camera"
tabler="IconCamera"
hugeicons="Camera01Icon"
phosphor="CameraIcon"
remixicon="RiScreenshotLine"
className={className}
style={style}
/>
)
}
export function MarqueeZoomGlyph({ className, style }: PdfEditorGlyphProps) {
return (
<IconPlaceholder
lucide="ScanSearch"
tabler="IconZoomInArea"
hugeicons="ZoomInAreaIcon"
phosphor="MagnifyingGlassPlusIcon"
remixicon="RiZoomInLine"
className={className}
style={style}
/>
)
}
export function BookOpenGlyph({ className, style }: PdfEditorGlyphProps) {
return (
<IconPlaceholder
lucide="BookOpen"
tabler="IconBook"
hugeicons="Book01Icon"
phosphor="BookOpenIcon"
remixicon="RiBookOpenLine"
className={className}
style={style}
/>
)
}
export function FileGlyph({ className, style }: PdfEditorGlyphProps) {
return (
<IconPlaceholder
lucide="File"
tabler="IconFile"
hugeicons="File01Icon"
phosphor="FileIcon"
remixicon="RiFileLine"
className={className}
style={style}
/>
)
}
export function ArrowsHorizontalGlyph({
className,
style,
}: PdfEditorGlyphProps) {
return (
<IconPlaceholder
lucide="MoveHorizontal"
tabler="IconArrowsHorizontal"
hugeicons="ArrowLeftRightIcon"
phosphor="ArrowsHorizontalIcon"
remixicon="RiArrowLeftRightLine"
className={className}
style={style}
/>
)
}
export function ArrowsVerticalGlyph({ className, style }: PdfEditorGlyphProps) {
return (
<IconPlaceholder
lucide="MoveVertical"
tabler="IconArrowsVertical"
hugeicons="ArrowUpDownIcon"
phosphor="ArrowsVerticalIcon"
remixicon="RiArrowUpDownLine"
className={className}
style={style}
/>
)
}
export function HighlighterGlyph({ className, style }: PdfEditorGlyphProps) {
return (
<IconPlaceholder
lucide="Highlighter"
tabler="IconHighlight"
hugeicons="HighlighterIcon"
phosphor="HighlighterIcon"
remixicon="RiMarkPenLine"
className={className}
style={style}
/>
)
}
export function UnderlineGlyph({ className, style }: PdfEditorGlyphProps) {
return (
<IconPlaceholder
lucide="Underline"
tabler="IconUnderline"
hugeicons="TextUnderlineIcon"
phosphor="TextUnderlineIcon"
remixicon="RiUnderline"
className={className}
style={style}
/>
)
}
export function StrikethroughGlyph({ className, style }: PdfEditorGlyphProps) {
return (
<IconPlaceholder
lucide="Strikethrough"
tabler="IconStrikethrough"
hugeicons="TextStrikethroughIcon"
phosphor="TextStrikethroughIcon"
remixicon="RiStrikethrough"
className={className}
style={style}
/>
)
}
export function SquigglyGlyph({ className, style }: PdfEditorGlyphProps) {
return (
<IconPlaceholder
lucide="LineSquiggle"
tabler="IconWaveSine"
hugeicons="WaveIcon"
phosphor="WaveSineIcon"
remixicon="RiPulseLine"
className={className}
style={style}
/>
)
}
export function PenGlyph({ className, style }: PdfEditorGlyphProps) {
return (
<IconPlaceholder
lucide="Pen"
tabler="IconPencil"
hugeicons="Pen01Icon"
phosphor="PenIcon"
remixicon="RiPencilLine"
className={className}
style={style}
/>
)
}
export function BrushGlyph({ className, style }: PdfEditorGlyphProps) {
return (
<IconPlaceholder
lucide="Brush"
tabler="IconBrush"
hugeicons="BrushIcon"
phosphor="PaintBrushIcon"
remixicon="RiBrushLine"
className={className}
style={style}
/>
)
}
export function TextGlyph({ className, style }: PdfEditorGlyphProps) {
return (
<IconPlaceholder
lucide="Type"
tabler="IconTypography"
hugeicons="TextIcon"
phosphor="TextTIcon"
remixicon="RiText"
className={className}
style={style}
/>
)
}
export function TextFontGlyph({ className, style }: PdfEditorGlyphProps) {
return (
<IconPlaceholder
lucide="Type"
tabler="IconTypography"
hugeicons="TextFontIcon"
phosphor="TextAaIcon"
remixicon="RiFontSize"
className={className}
style={style}
/>
)
}
export function CalloutGlyph({ className, style }: PdfEditorGlyphProps) {
return (
<IconPlaceholder
lucide="MessageSquareText"
tabler="IconMessage2"
hugeicons="BubbleChatIcon"
phosphor="ChatTextIcon"
remixicon="RiChat3Line"
className={className}
style={style}
/>
)
}
export function StickyNoteGlyph({ className, style }: PdfEditorGlyphProps) {
return (
<IconPlaceholder
lucide="StickyNote"
tabler="IconNote"
hugeicons="StickyNote01Icon"
phosphor="NoteIcon"
remixicon="RiStickyNoteLine"
className={className}
style={style}
/>
)
}
export function SquareGlyph({ className, style }: PdfEditorGlyphProps) {
return (
<IconPlaceholder
lucide="Square"
tabler="IconSquare"
hugeicons="SquareIcon"
phosphor="SquareIcon"
remixicon="RiCheckboxBlankLine"
className={className}
style={style}
/>
)
}
export function CircleGlyph({ className, style }: PdfEditorGlyphProps) {
return (
<IconPlaceholder
lucide="Circle"
tabler="IconCircle"
hugeicons="CircleIcon"
phosphor="CircleIcon"
remixicon="RiCircleLine"
className={className}
style={style}
/>
)
}
export function LineGlyph({ className, style }: PdfEditorGlyphProps) {
return (
<IconPlaceholder
lucide="Minus"
tabler="IconLine"
hugeicons="MinusSignIcon"
phosphor="LineSegmentIcon"
remixicon="RiSubtractLine"
className={className}
style={style}
/>
)
}
export function ArrowGlyph({ className, style }: PdfEditorGlyphProps) {
return (
<IconPlaceholder
lucide="MoveUpRight"
tabler="IconArrowUpRight"
hugeicons="ArrowUpRight01Icon"
phosphor="ArrowUpRightIcon"
remixicon="RiArrowRightUpLine"
className={className}
style={style}
/>
)
}
export function PolylineGlyph({ className, style }: PdfEditorGlyphProps) {
return (
<IconPlaceholder
lucide="Spline"
tabler="IconVectorSpline"
hugeicons="SplinePointerIcon"
phosphor="VectorTwoIcon"
remixicon="RiRouteLine"
className={className}
style={style}
/>
)
}
export function PolygonGlyph({ className, style }: PdfEditorGlyphProps) {
return (
<IconPlaceholder
lucide="Pentagon"
tabler="IconPolygon"
hugeicons="PolygonIcon"
phosphor="PolygonIcon"
remixicon="RiPentagonLine"
className={className}
style={style}
/>
)
}
export function ImageGlyph({ className, style }: PdfEditorGlyphProps) {
return (
<IconPlaceholder
lucide="Image"
tabler="IconPhoto"
hugeicons="Image01Icon"
phosphor="ImageIcon"
remixicon="RiImageLine"
className={className}
style={style}
/>
)
}
export function StampGlyph({ className, style }: PdfEditorGlyphProps) {
return (
<IconPlaceholder
lucide="Stamp"
tabler="IconRubberStamp"
hugeicons="StampIcon"
phosphor="StampIcon"
remixicon="RiVerifiedBadgeLine"
className={className}
style={style}
/>
)
}
export function LinkGlyph({ className, style }: PdfEditorGlyphProps) {
return (
<IconPlaceholder
lucide="Link"
tabler="IconLink"
hugeicons="Link02Icon"
phosphor="LinkIcon"
remixicon="RiLink"
className={className}
style={style}
/>
)
}
export function InsertTextGlyph({ className, style }: PdfEditorGlyphProps) {
return (
<IconPlaceholder
lucide="TextCursorInput"
tabler="IconCursorText"
hugeicons="CursorTextIcon"
phosphor="CursorTextIcon"
remixicon="RiInputCursorMove"
className={className}
style={style}
/>
)
}
export function ReplaceTextGlyph({ className, style }: PdfEditorGlyphProps) {
return (
<IconPlaceholder
lucide="Replace"
tabler="IconReplace"
hugeicons="ReplaceIcon"
phosphor="ArrowsLeftRightIcon"
remixicon="RiFindReplaceLine"
className={className}
style={style}
/>
)
}
export function TrashGlyph({ className, style }: PdfEditorGlyphProps) {
return (
<IconPlaceholder
lucide="Trash2"
tabler="IconTrash"
hugeicons="Delete02Icon"
phosphor="TrashIcon"
remixicon="RiDeleteBinLine"
className={className}
style={style}
/>
)
}
export function GroupGlyph({ className, style }: PdfEditorGlyphProps) {
return (
<IconPlaceholder
lucide="Group"
tabler="IconLayersUnion"
hugeicons="GroupItemsIcon"
phosphor="StackIcon"
remixicon="RiStackLine"
className={className}
style={style}
/>
)
}
export function UngroupGlyph({ className, style }: PdfEditorGlyphProps) {
return (
<IconPlaceholder
lucide="Ungroup"
tabler="IconLayersSubtract"
hugeicons="UngroupItemsIcon"
phosphor="StackSimpleIcon"
remixicon="RiStackLine"
className={className}
style={style}
/>
)
}
export function LockGlyph({ className, style }: PdfEditorGlyphProps) {
return (
<IconPlaceholder
lucide="Lock"
tabler="IconLock"
hugeicons="SquareLock02Icon"
phosphor="LockIcon"
remixicon="RiLockLine"
className={className}
style={style}
/>
)
}
export function UnlockGlyph({ className, style }: PdfEditorGlyphProps) {
return (
<IconPlaceholder
lucide="LockOpen"
tabler="IconLockOpen"
hugeicons="SquareUnlock02Icon"
phosphor="LockOpenIcon"
remixicon="RiLockUnlockLine"
className={className}
style={style}
/>
)
}
export function CommentGlyph({ className, style }: PdfEditorGlyphProps) {
return (
<IconPlaceholder
lucide="MessageCircle"
tabler="IconMessageCircle"
hugeicons="Comment01Icon"
phosphor="ChatCircleIcon"
remixicon="RiChat1Line"
className={className}
style={style}
/>
)
}
export function CommentsGlyph({ className, style }: PdfEditorGlyphProps) {
return (
<IconPlaceholder
lucide="MessagesSquare"
tabler="IconMessages"
hugeicons="CommentAdd01Icon"
phosphor="ChatsIcon"
remixicon="RiChat3Line"
className={className}
style={style}
/>
)
}
export function CheckGlyph({ className, style }: PdfEditorGlyphProps) {
return (
<IconPlaceholder
lucide="Check"
tabler="IconCheck"
hugeicons="Tick02Icon"
phosphor="CheckIcon"
remixicon="RiCheckLine"
className={className}
style={style}
/>
)
}
export function CloseGlyph({ className, style }: PdfEditorGlyphProps) {
return (
<IconPlaceholder
lucide="X"
tabler="IconX"
hugeicons="Cancel01Icon"
phosphor="XIcon"
remixicon="RiCloseLine"
className={className}
style={style}
/>
)
}
export function PlusGlyph({ className, style }: PdfEditorGlyphProps) {
return (
<IconPlaceholder
lucide="Plus"
tabler="IconPlus"
hugeicons="Add01Icon"
phosphor="PlusIcon"
remixicon="RiAddLine"
className={className}
style={style}
/>
)
}
export function MinusGlyph({ className, style }: PdfEditorGlyphProps) {
return (
<IconPlaceholder
lucide="Minus"
tabler="IconMinus"
hugeicons="MinusSignIcon"
phosphor="MinusIcon"
remixicon="RiSubtractLine"
className={className}
style={style}
/>
)
}
export function CopyGlyph({ className, style }: PdfEditorGlyphProps) {
return (
<IconPlaceholder
lucide="Copy"
tabler="IconCopy"
hugeicons="Copy01Icon"
phosphor="CopyIcon"
remixicon="RiFileCopyLine"
className={className}
style={style}
/>
)
}
export function EyeOffGlyph({ className, style }: PdfEditorGlyphProps) {
return (
<IconPlaceholder
lucide="EyeOff"
tabler="IconEyeOff"
hugeicons="ViewOffIcon"
phosphor="EyeSlashIcon"
remixicon="RiEyeOffLine"
className={className}
style={style}
/>
)
}
export function EyeGlyph({ className, style }: PdfEditorGlyphProps) {
return (
<IconPlaceholder
lucide="Eye"
tabler="IconEye"
hugeicons="ViewIcon"
phosphor="EyeIcon"
remixicon="RiEyeLine"
className={className}
style={style}
/>
)
}
export function RedactAreaGlyph({ className, style }: PdfEditorGlyphProps) {
return (
<IconPlaceholder
lucide="SquareDashed"
tabler="IconSquareDashed"
hugeicons="CursorRectangleSelection01Icon"
phosphor="SelectionIcon"
remixicon="RiCropLine"
className={className}
style={style}
/>
)
}
export function SignatureGlyph({ className, style }: PdfEditorGlyphProps) {
return (
<IconPlaceholder
lucide="Signature"
tabler="IconSignature"
hugeicons="SignatureIcon"
phosphor="SignatureIcon"
remixicon="RiQuillPenLine"
className={className}
style={style}
/>
)
}
export function TextFieldGlyph({ className, style }: PdfEditorGlyphProps) {
return (
<IconPlaceholder
lucide="RectangleEllipsis"
tabler="IconForms"
hugeicons="InputTextIcon"
phosphor="TextboxIcon"
remixicon="RiInputField"
className={className}
style={style}
/>
)
}
export function CheckboxGlyph({ className, style }: PdfEditorGlyphProps) {
return (
<IconPlaceholder
lucide="SquareCheck"
tabler="IconSquareCheck"
hugeicons="CheckmarkSquare01Icon"
phosphor="CheckSquareIcon"
remixicon="RiCheckboxLine"
className={className}
style={style}
/>
)
}
export function RadioGlyph({ className, style }: PdfEditorGlyphProps) {
return (
<IconPlaceholder
lucide="CircleDot"
tabler="IconCircleDot"
hugeicons="RadioButtonIcon"
phosphor="RadioButtonIcon"
remixicon="RiRadioButtonLine"
className={className}
style={style}
/>
)
}
export function ComboboxGlyph({ className, style }: PdfEditorGlyphProps) {
return (
<IconPlaceholder
lucide="ChevronsUpDown"
tabler="IconSelect"
hugeicons="UnfoldMoreIcon"
phosphor="CaretUpDownIcon"
remixicon="RiArrowUpDownLine"
className={className}
style={style}
/>
)
}
export function ListboxGlyph({ className, style }: PdfEditorGlyphProps) {
return (
<IconPlaceholder
lucide="List"
tabler="IconList"
hugeicons="LeftToRightListBulletIcon"
phosphor="ListIcon"
remixicon="RiListUnordered"
className={className}
style={style}
/>
)
}
export function ListTreeGlyph({ className, style }: PdfEditorGlyphProps) {
return (
<IconPlaceholder
lucide="ListTree"
tabler="IconListTree"
hugeicons="ListTreeIcon"
phosphor="TreeStructureIcon"
remixicon="RiNodeTree"
className={className}
style={style}
/>
)
}
export function PaperclipGlyph({ className, style }: PdfEditorGlyphProps) {
return (
<IconPlaceholder
lucide="Paperclip"
tabler="IconPaperclip"
hugeicons="Attachment01Icon"
phosphor="PaperclipIcon"
remixicon="RiAttachment2"
className={className}
style={style}
/>
)
}
export function GridGlyph({ className, style }: PdfEditorGlyphProps) {
return (
<IconPlaceholder
lucide="LayoutGrid"
tabler="IconLayoutGrid"
hugeicons="GridViewIcon"
phosphor="SquaresFourIcon"
remixicon="RiLayoutGridLine"
className={className}
style={style}
/>
)
}
export function SlidersGlyph({ className, style }: PdfEditorGlyphProps) {
return (
<IconPlaceholder
lucide="SlidersHorizontal"
tabler="IconAdjustmentsHorizontal"
hugeicons="SlidersHorizontalIcon"
phosphor="SlidersHorizontalIcon"
remixicon="RiEqualizerLine"
className={className}
style={style}
/>
)
}
export function InfoGlyph({ className, style }: PdfEditorGlyphProps) {
return (
<IconPlaceholder
lucide="Info"
tabler="IconInfoCircle"
hugeicons="InformationCircleIcon"
phosphor="InfoIcon"
remixicon="RiInformationLine"
className={className}
style={style}
/>
)
}
export function ShieldGlyph({ className, style }: PdfEditorGlyphProps) {
return (
<IconPlaceholder
lucide="ShieldCheck"
tabler="IconShieldLock"
hugeicons="Shield01Icon"
phosphor="ShieldCheckIcon"
remixicon="RiShieldKeyholeLine"
className={className}
style={style}
/>
)
}
export function FileTextGlyph({ className, style }: PdfEditorGlyphProps) {
return (
<IconPlaceholder
lucide="FileText"
tabler="IconFileText"
hugeicons="File01Icon"
phosphor="FileTextIcon"
remixicon="RiFileTextLine"
className={className}
style={style}
/>
)
}
export function ChevronDownGlyph({ className, style }: PdfEditorGlyphProps) {
return (
<IconPlaceholder
lucide="ChevronDown"
tabler="IconChevronDown"
hugeicons="ArrowDown01Icon"
phosphor="CaretDownIcon"
remixicon="RiArrowDownSLine"
className={className}
style={style}
/>
)
}
export function ChevronUpGlyph({ className, style }: PdfEditorGlyphProps) {
return (
<IconPlaceholder
lucide="ChevronUp"
tabler="IconChevronUp"
hugeicons="ArrowUp01Icon"
phosphor="CaretUpIcon"
remixicon="RiArrowUpSLine"
className={className}
style={style}
/>
)
}
export function ChevronLeftGlyph({ className, style }: PdfEditorGlyphProps) {
return (
<IconPlaceholder
lucide="ChevronLeft"
tabler="IconChevronLeft"
hugeicons="ArrowLeft01Icon"
phosphor="CaretLeftIcon"
remixicon="RiArrowLeftSLine"
className={className}
style={style}
/>
)
}
export function ChevronRightGlyph({ className, style }: PdfEditorGlyphProps) {
return (
<IconPlaceholder
lucide="ChevronRight"
tabler="IconChevronRight"
hugeicons="ArrowRight01Icon"
phosphor="CaretRightIcon"
remixicon="RiArrowRightSLine"
className={className}
style={style}
/>
)
}
export function LayersGlyph({ className, style }: PdfEditorGlyphProps) {
return (
<IconPlaceholder
lucide="Layers"
tabler="IconStack2"
hugeicons="Layers01Icon"
phosphor="StackIcon"
remixicon="RiStackLine"
className={className}
style={style}
/>
)
}
export function FilePlusGlyph({ className, style }: PdfEditorGlyphProps) {
return (
<IconPlaceholder
lucide="FilePlus"
tabler="IconFilePlus"
hugeicons="FileAddIcon"
phosphor="FilePlusIcon"
remixicon="RiFileAddLine"
className={className}
style={style}
/>
)
}
export function ScissorsGlyph({ className, style }: PdfEditorGlyphProps) {
return (
<IconPlaceholder
lucide="Scissors"
tabler="IconScissors"
hugeicons="Scissor01Icon"
phosphor="ScissorsIcon"
remixicon="RiScissorsLine"
className={className}
style={style}
/>
)
}
export function MergeGlyph({ className, style }: PdfEditorGlyphProps) {
return (
<IconPlaceholder
lucide="Merge"
tabler="IconArrowMerge"
hugeicons="GitMergeIcon"
phosphor="GitMergeIcon"
remixicon="RiGitMergeLine"
className={className}
style={style}
/>
)
}
export function RefreshGlyph({ className, style }: PdfEditorGlyphProps) {
return (
<IconPlaceholder
lucide="RefreshCw"
tabler="IconRefresh"
hugeicons="RefreshIcon"
phosphor="ArrowsClockwiseIcon"
remixicon="RiRefreshLine"
className={className}
style={style}
/>
)
}
export function SaveGlyph({ className, style }: PdfEditorGlyphProps) {
return (
<IconPlaceholder
lucide="Save"
tabler="IconDeviceFloppy"
hugeicons="FloppyDiskIcon"
phosphor="FloppyDiskIcon"
remixicon="RiSaveLine"
className={className}
style={style}
/>
)
}
export function KeyboardGlyph({ className, style }: PdfEditorGlyphProps) {
return (
<IconPlaceholder
lucide="Keyboard"
tabler="IconKeyboard"
hugeicons="KeyboardIcon"
phosphor="KeyboardIcon"
remixicon="RiKeyboardLine"
className={className}
style={style}
/>
)
}
export function PaletteGlyph({ className, style }: PdfEditorGlyphProps) {
return (
<IconPlaceholder
lucide="Palette"
tabler="IconPalette"
hugeicons="PaintBoardIcon"
phosphor="PaletteIcon"
remixicon="RiPaletteLine"
className={className}
style={style}
/>
)
}
export function BoldGlyph({ className, style }: PdfEditorGlyphProps) {
return (
<IconPlaceholder
lucide="Bold"
tabler="IconBold"
hugeicons="TextBoldIcon"
phosphor="TextBIcon"
remixicon="RiBold"
className={className}
style={style}
/>
)
}
export function ItalicGlyph({ className, style }: PdfEditorGlyphProps) {
return (
<IconPlaceholder
lucide="Italic"
tabler="IconItalic"
hugeicons="TextItalicIcon"
phosphor="TextItalicIcon"
remixicon="RiItalic"
className={className}
style={style}
/>
)
}
export function AlignLeftGlyph({ className, style }: PdfEditorGlyphProps) {
return (
<IconPlaceholder
lucide="TextAlignStart"
tabler="IconAlignLeft"
hugeicons="TextAlignLeft01Icon"
phosphor="TextAlignLeftIcon"
remixicon="RiAlignLeft"
className={className}
style={style}
/>
)
}
export function AlignCenterGlyph({ className, style }: PdfEditorGlyphProps) {
return (
<IconPlaceholder
lucide="TextAlignCenter"
tabler="IconAlignCenter"
hugeicons="TextAlignCenterIcon"
phosphor="TextAlignCenterIcon"
remixicon="RiAlignCenter"
className={className}
style={style}
/>
)
}
export function AlignRightGlyph({ className, style }: PdfEditorGlyphProps) {
return (
<IconPlaceholder
lucide="TextAlignEnd"
tabler="IconAlignRight"
hugeicons="TextAlignRight01Icon"
phosphor="TextAlignRightIcon"
remixicon="RiAlignRight"
className={className}
style={style}
/>
)
}
export function AlignTopGlyph({ className, style }: PdfEditorGlyphProps) {
return (
<IconPlaceholder
lucide="ArrowUpToLine"
tabler="IconAlignBoxTopCenter"
hugeicons="AlignBoxTopCenterIcon"
phosphor="AlignTopIcon"
remixicon="RiAlignTop"
className={className}
style={style}
/>
)
}
export function AlignMiddleGlyph({ className, style }: PdfEditorGlyphProps) {
return (
<IconPlaceholder
lucide="AlignVerticalJustifyCenter"
tabler="IconAlignBoxCenterMiddle"
hugeicons="AlignBoxMiddleCenterIcon"
phosphor="AlignCenterVerticalIcon"
remixicon="RiAlignVertically"
className={className}
style={style}
/>
)
}
export function AlignBottomGlyph({ className, style }: PdfEditorGlyphProps) {
return (
<IconPlaceholder
lucide="ArrowDownToLine"
tabler="IconAlignBoxBottomCenter"
hugeicons="AlignBoxBottomCenterIcon"
phosphor="AlignBottomIcon"
remixicon="RiAlignBottom"
className={className}
style={style}
/>
)
}
export function FlagGlyph({ className, style }: PdfEditorGlyphProps) {
return (
<IconPlaceholder
lucide="Flag"
tabler="IconFlag"
hugeicons="Flag01Icon"
phosphor="FlagIcon"
remixicon="RiFlagLine"
className={className}
style={style}
/>
)
}
export function ClipboardGlyph({ className, style }: PdfEditorGlyphProps) {
return (
<IconPlaceholder
lucide="Clipboard"
tabler="IconClipboard"
hugeicons="ClipboardIcon"
phosphor="ClipboardIcon"
remixicon="RiClipboardLine"
className={className}
style={style}
/>
)
}
export function KeyGlyph({ className, style }: PdfEditorGlyphProps) {
return (
<IconPlaceholder
lucide="Key"
tabler="IconKey"
hugeicons="Key01Icon"
phosphor="KeyIcon"
remixicon="RiKey2Line"
className={className}
style={style}
/>
)
}
export function ExternalLinkGlyph({ className, style }: PdfEditorGlyphProps) {
return (
<IconPlaceholder
lucide="ExternalLink"
tabler="IconExternalLink"
hugeicons="ExternalLinkIcon"
phosphor="ArrowSquareOutIcon"
remixicon="RiExternalLinkLine"
className={className}
style={style}
/>
)
}
export function MoveGlyph({ className, style }: PdfEditorGlyphProps) {
return (
<IconPlaceholder
lucide="Move"
tabler="IconArrowsMove"
hugeicons="MoveIcon"
phosphor="ArrowsOutCardinalIcon"
remixicon="RiDragMove2Line"
className={className}
style={style}
/>
)
}
export function AlertGlyph({ className, style }: PdfEditorGlyphProps) {
return (
<IconPlaceholder
lucide="TriangleAlert"
tabler="IconAlertTriangle"
hugeicons="Alert01Icon"
phosphor="WarningIcon"
remixicon="RiAlertLine"
className={className}
style={style}
/>
)
}
export function ArrowUpGlyph({ className, style }: PdfEditorGlyphProps) {
return (
<IconPlaceholder
lucide="ArrowUp"
tabler="IconArrowUp"
hugeicons="ArrowUp02Icon"
phosphor="ArrowUpIcon"
remixicon="RiArrowUpLine"
className={className}
style={style}
/>
)
}
export function ArrowDownGlyph({ className, style }: PdfEditorGlyphProps) {
return (
<IconPlaceholder
lucide="ArrowDown"
tabler="IconArrowDown"
hugeicons="ArrowDown02Icon"
phosphor="ArrowDownIcon"
remixicon="RiArrowDownLine"
className={className}
style={style}
/>
)
}
export function EditGlyph({ className, style }: PdfEditorGlyphProps) {
return (
<IconPlaceholder
lucide="Pencil"
tabler="IconEdit"
hugeicons="PencilEdit01Icon"
phosphor="PencilSimpleIcon"
remixicon="RiEditLine"
className={className}
style={style}
/>
)
}
export function SparklesGlyph({ className, style }: PdfEditorGlyphProps) {
return (
<IconPlaceholder
lucide="Sparkles"
tabler="IconSparkles"
hugeicons="SparklesIcon"
phosphor="SparkleIcon"
remixicon="RiSparklingLine"
className={className}
style={style}
/>
)
}
/* -------------------------------------------------------------------------- */
/* Tool catalogs */
/* -------------------------------------------------------------------------- */
export type PdfEditorToolDefinition = {
/** EmbedPDF annotation tool id. */
id: string
label: string
glyph: PdfEditorGlyph
/** Short hint shown in the tooltip. */
hint?: string
}
export type PdfEditorToolGroup = {
id: string
label: string
tools: PdfEditorToolDefinition[]
}
export const PDF_EDITOR_ANNOTATION_TOOL_GROUPS: PdfEditorToolGroup[] = [
{
id: "markup",
label: "Text markup",
tools: [
{
id: "highlight",
label: "Highlight",
glyph: HighlighterGlyph,
hint: "Select text to highlight",
},
{
id: "underline",
label: "Underline",
glyph: UnderlineGlyph,
hint: "Select text to underline",
},
{
id: "strikeout",
label: "Strikethrough",
glyph: StrikethroughGlyph,
hint: "Select text to strike through",
},
{
id: "squiggly",
label: "Squiggly",
glyph: SquigglyGlyph,
hint: "Select text for a squiggly underline",
},
],
},
{
id: "draw",
label: "Draw",
tools: [
{
id: "ink",
label: "Pen",
glyph: PenGlyph,
hint: "Draw freehand",
},
{
id: "inkHighlighter",
label: "Highlighter pen",
glyph: BrushGlyph,
hint: "Draw a translucent highlighter stroke",
},
],
},
{
id: "text",
label: "Text",
tools: [
{
id: "freeText",
label: "Text box",
glyph: TextGlyph,
hint: "Click or drag to add text",
},
{
id: "freeTextCallout",
label: "Callout",
glyph: CalloutGlyph,
hint: "Drag to add a callout with a leader line",
},
{
id: "textComment",
label: "Sticky note",
glyph: StickyNoteGlyph,
hint: "Click to add a note",
},
],
},
{
id: "shapes",
label: "Shapes",
tools: [
{ id: "square", label: "Rectangle", glyph: SquareGlyph },
{ id: "circle", label: "Ellipse", glyph: CircleGlyph },
{ id: "line", label: "Line", glyph: LineGlyph },
{ id: "lineArrow", label: "Arrow", glyph: ArrowGlyph },
{
id: "polyline",
label: "Polyline",
glyph: PolylineGlyph,
hint: "Click to add points, double-click to finish",
},
{
id: "polygon",
label: "Polygon",
glyph: PolygonGlyph,
hint: "Click to add points, double-click to finish",
},
],
},
{
id: "insert",
label: "Insert",
tools: [
{
id: "stamp",
label: "Image",
glyph: ImageGlyph,
hint: "Click the page to place an image",
},
{
id: "link",
label: "Link area",
glyph: LinkGlyph,
hint: "Drag to add a link rectangle",
},
{
id: "insertText",
label: "Insert text mark",
glyph: InsertTextGlyph,
hint: "Select text to mark an insertion point",
},
{
id: "replaceText",
label: "Replace text mark",
glyph: ReplaceTextGlyph,
hint: "Select text to mark it for replacement",
},
],
},
]
export const PDF_EDITOR_ANNOTATION_TOOLS: PdfEditorToolDefinition[] =
PDF_EDITOR_ANNOTATION_TOOL_GROUPS.flatMap((group) => group.tools)
export const PDF_EDITOR_FORM_TOOLS: PdfEditorToolDefinition[] = [
{
id: "formTextField",
label: "Text field",
glyph: TextFieldGlyph,
hint: "Click or drag to add a text field",
},
{
id: "formCheckbox",
label: "Checkbox",
glyph: CheckboxGlyph,
hint: "Click to add a checkbox",
},
{
id: "formRadioButton",
label: "Radio button",
glyph: RadioGlyph,
hint: "Click to add a radio button",
},
{
id: "formCombobox",
label: "Dropdown",
glyph: ComboboxGlyph,
hint: "Click or drag to add a dropdown",
},
{
id: "formListbox",
label: "List box",
glyph: ListboxGlyph,
hint: "Click or drag to add a list box",
},
]
export const PDF_EDITOR_MODE_OPTIONS: Array<{
id: PdfEditorMode
label: string
glyph: PdfEditorGlyph
feature: keyof PdfEditorFeatureFlags | null
}> = [
{ id: "view", label: "View", glyph: EyeGlyph, feature: null },
{ id: "annotate", label: "Annotate", glyph: PenGlyph, feature: "annotate" },
{ id: "sign", label: "Sign", glyph: SignatureGlyph, feature: "sign" },
{ id: "forms", label: "Forms", glyph: TextFieldGlyph, feature: "forms" },
{ id: "redact", label: "Redact", glyph: EyeOffGlyph, feature: "redact" },
]
export type PdfEditorAnnotationMeta = {
label: string
glyph: PdfEditorGlyph
/** The most representative color of the annotation, if any. */
color?: string
}
export function getAnnotationColor(
annotation: PdfAnnotationObject
): string | undefined {
const record = annotation as unknown as Record<string, unknown>
const candidates = [record.strokeColor, record.fontColor, record.color]
for (const candidate of candidates) {
if (typeof candidate === "string" && candidate.trim()) return candidate
}
return undefined
}
export function getAnnotationMeta(
annotation: PdfAnnotationObject
): PdfEditorAnnotationMeta {
const color = getAnnotationColor(annotation)
const intent = annotation.intent ?? ""
switch (annotation.type) {
case PdfAnnotationSubtype.HIGHLIGHT:
return { label: "Highlight", glyph: HighlighterGlyph, color }
case PdfAnnotationSubtype.UNDERLINE:
return { label: "Underline", glyph: UnderlineGlyph, color }
case PdfAnnotationSubtype.STRIKEOUT:
return intent === "StrikeOutTextReplace"
? { label: "Replace text", glyph: ReplaceTextGlyph, color }
: { label: "Strikethrough", glyph: StrikethroughGlyph, color }
case PdfAnnotationSubtype.SQUIGGLY:
return { label: "Squiggly", glyph: SquigglyGlyph, color }
case PdfAnnotationSubtype.INK:
return intent === "InkHighlight"
? { label: "Highlighter pen", glyph: BrushGlyph, color }
: { label: "Ink", glyph: PenGlyph, color }
case PdfAnnotationSubtype.SQUARE:
return { label: "Rectangle", glyph: SquareGlyph, color }
case PdfAnnotationSubtype.CIRCLE:
return { label: "Ellipse", glyph: CircleGlyph, color }
case PdfAnnotationSubtype.LINE:
return intent === "LineArrow"
? { label: "Arrow", glyph: ArrowGlyph, color }
: { label: "Line", glyph: LineGlyph, color }
case PdfAnnotationSubtype.POLYLINE:
return { label: "Polyline", glyph: PolylineGlyph, color }
case PdfAnnotationSubtype.POLYGON:
return { label: "Polygon", glyph: PolygonGlyph, color }
case PdfAnnotationSubtype.FREETEXT:
return intent === "FreeTextCallout"
? { label: "Callout", glyph: CalloutGlyph, color }
: { label: "Text box", glyph: TextGlyph, color }
case PdfAnnotationSubtype.TEXT:
return { label: "Note", glyph: StickyNoteGlyph, color }
case PdfAnnotationSubtype.STAMP:
return { label: "Stamp", glyph: StampGlyph, color }
case PdfAnnotationSubtype.LINK:
return { label: "Link", glyph: LinkGlyph, color }
case PdfAnnotationSubtype.CARET:
return { label: "Insert text", glyph: InsertTextGlyph, color }
case PdfAnnotationSubtype.REDACT:
return { label: "Redaction", glyph: EyeOffGlyph, color }
case PdfAnnotationSubtype.WIDGET:
return { label: "Form field", glyph: TextFieldGlyph, color }
case PdfAnnotationSubtype.FILEATTACHMENT:
return { label: "Attachment", glyph: PaperclipGlyph, color }
default:
return { label: "Annotation", glyph: FlagGlyph, color }
}
}
/* -------------------------------------------------------------------------- */
/* Helpers */
/* -------------------------------------------------------------------------- */
export function clamp(value: number, min: number, max: number) {
return Math.min(max, Math.max(min, value))
}
export function ensurePdfExtension(fileName: string) {
return fileName.toLowerCase().endsWith(".pdf") ? fileName : `${fileName}.pdf`
}
export function getPdfFileName(fileName: string | undefined, src?: string) {
if (fileName?.trim()) return ensurePdfExtension(fileName.trim())
if (src && !src.startsWith("blob:") && !src.startsWith("data:")) {
const pathname = src.split(/[?#]/)[0] ?? ""
const rawName = pathname.split("/").pop() || ""
if (rawName) {
try {
return ensurePdfExtension(decodeURIComponent(rawName))
} catch {
return ensurePdfExtension(rawName)
}
}
}
return "document.pdf"
}
export function withFileNameSuffix(fileName: string, suffix: string) {
return ensurePdfExtension(fileName.replace(/\.pdf$/i, "") + suffix)
}
export 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)
}
export function downloadArrayBuffer(
buffer: ArrayBuffer | Uint8Array,
fileName: string,
type = "application/pdf"
) {
const bytes = buffer instanceof Uint8Array ? buffer : new Uint8Array(buffer)
const copy = new ArrayBuffer(bytes.byteLength)
new Uint8Array(copy).set(bytes)
downloadBlob(new Blob([copy], { type }), fileName)
}
export function toArrayBuffer(bytes: Uint8Array): ArrayBuffer {
const copy = new ArrayBuffer(bytes.byteLength)
new Uint8Array(copy).set(bytes)
return copy
}
export function readFileAsArrayBuffer(file: Blob) {
return file.arrayBuffer()
}
export function formatBytes(bytes: number | undefined) {
if (bytes === undefined || Number.isNaN(bytes)) return "—"
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
}
export function formatDateTime(date: Date | undefined | null) {
if (!date) return "—"
const value = date instanceof Date ? date : new Date(date)
if (Number.isNaN(value.getTime())) return "—"
return value.toLocaleString(undefined, {
dateStyle: "medium",
timeStyle: "short",
})
}
export function formatRelativeDate(date: Date | undefined | null) {
if (!date) return ""
const value = date instanceof Date ? date : new Date(date)
if (Number.isNaN(value.getTime())) return ""
const diff = Date.now() - value.getTime()
const minutes = Math.round(diff / 60000)
if (minutes < 1) return "just now"
if (minutes < 60) return `${minutes}m ago`
const hours = Math.round(minutes / 60)
if (hours < 24) return `${hours}h ago`
return value.toLocaleDateString(undefined, {
month: "short",
day: "numeric",
})
}
export function toNormalizedRect(
rect: Rect,
pageSize: Size
): PdfEditorNormalizedRect {
const width = pageSize.width || 1
const height = pageSize.height || 1
return {
top: (rect.origin.y / height) * 100,
left: (rect.origin.x / width) * 100,
width: (rect.size.width / width) * 100,
height: (rect.size.height / height) * 100,
}
}
export function isTypingTarget(target: EventTarget | null) {
if (!(target instanceof HTMLElement)) return false
if (target.isContentEditable) return true
return Boolean(
target.closest(
"input, textarea, select, [contenteditable='true'], [contenteditable='']"
)
)
}
export function isMacPlatform() {
if (typeof navigator === "undefined") return false
return /mac|iphone|ipad|ipod/i.test(navigator.platform)
}
export function formatShortcut(shortcut: string) {
const mac = isMacPlatform()
return shortcut
.replace(/mod/gi, mac ? "⌘" : "Ctrl")
.replace(/shift/gi, mac ? "⇧" : "Shift")
.replace(/alt/gi, mac ? "⌥" : "Alt")
}
export function uniqueName(base: string, existing: Iterable<string>) {
const taken = new Set(existing)
if (!taken.has(base)) return base
let index = 2
while (taken.has(`${base} ${index}`)) index += 1
return `${base} ${index}`
}
export function noop() {}
/* -------------------------------------------------------------------------- */
/* Small UI primitives shared by the editor files */
/* -------------------------------------------------------------------------- */
export function PdfEditorTooltip({
label,
shortcut,
children,
}: {
label: React.ReactNode
shortcut?: string
children: React.ReactNode
}) {
return (
<Tooltip>
<TooltipTrigger
render={<span className="inline-flex">{children}</span>}
></TooltipTrigger>
<TooltipContent side="bottom">
<span className="inline-flex items-center gap-2">
{label}
{shortcut ? (
<Kbd className="h-4 min-w-4 px-1 text-[10px]">
{formatShortcut(shortcut)}
</Kbd>
) : null}
</span>
</TooltipContent>
</Tooltip>
)
}
export function PdfEditorToolButton({
label,
shortcut,
active = false,
className,
children,
size = "icon-sm",
variant = "ghost",
...props
}: Omit<ButtonProps, "variant" | "size" | "children"> & {
label: string
shortcut?: string
active?: boolean
size?: ButtonProps["size"]
variant?: ButtonProps["variant"]
children: React.ReactNode
}) {
return (
<PdfEditorTooltip label={label} shortcut={shortcut}>
<Button
type="button"
variant={variant}
size={size}
aria-label={label}
aria-pressed={active}
data-active={active ? "" : undefined}
className={cn(
active &&
"bg-accent text-accent-foreground ring-1 ring-ring/40 ring-inset",
className
)}
{...props}
>
{children}
</Button>
</PdfEditorTooltip>
)
}
export function PdfEditorToolbarSeparator({
className,
}: {
className?: string
}) {
return (
<div
aria-hidden="true"
className={cn("mx-1 h-4 w-px shrink-0 self-center bg-border", className)}
/>
)
}
export function PdfEditorPanelHeader({
title,
description,
actions,
onClose,
}: {
title: React.ReactNode
description?: React.ReactNode
actions?: React.ReactNode
onClose?: () => void
}) {
return (
<div className="flex shrink-0 items-start justify-between gap-2 border-b px-3 py-2.5">
<div className="min-w-0">
<div className="truncate text-sm font-medium text-foreground">
{title}
</div>
{description ? (
<div className="mt-0.5 text-xs text-muted-foreground">
{description}
</div>
) : null}
</div>
<div className="flex shrink-0 items-center gap-1">
{actions}
{onClose ? (
<PdfEditorToolButton
label="Close panel"
size="icon-xs"
onClick={onClose}
>
<CloseGlyph className="size-3.5" />
</PdfEditorToolButton>
) : null}
</div>
</div>
)
}
export function PdfEditorEmptyState({
glyph: Glyph,
title,
description,
action,
className,
}: {
glyph?: PdfEditorGlyph
title: React.ReactNode
description?: React.ReactNode
action?: React.ReactNode
className?: string
}) {
return (
<div
className={cn(
"flex flex-col items-center gap-2 px-4 py-10 text-center",
className
)}
>
{Glyph ? (
<div className="grid size-10 place-items-center rounded-full bg-muted text-muted-foreground">
<Glyph className="size-5" />
</div>
) : null}
<div className="text-sm font-medium text-foreground">{title}</div>
{description ? (
<div className="max-w-56 text-xs text-muted-foreground">
{description}
</div>
) : null}
{action ? <div className="mt-2">{action}</div> : null}
</div>
)
}
export function PdfEditorSection({
title,
actions,
children,
className,
}: {
title?: React.ReactNode
actions?: React.ReactNode
children: React.ReactNode
className?: string
}) {
return (
<section className={cn("space-y-2", className)}>
{title || actions ? (
<div className="flex items-center justify-between gap-2">
{title ? (
<div className="text-xs font-medium text-muted-foreground">
{title}
</div>
) : (
<span />
)}
{actions}
</div>
) : null}
{children}
</section>
)
}
export function PdfEditorFieldLabel({
children,
htmlFor,
className,
}: {
children: React.ReactNode
htmlFor?: string
className?: string
}) {
return (
<label
htmlFor={htmlFor}
className={cn("block text-xs font-medium text-foreground", className)}
>
{children}
</label>
)
}
export function PdfEditorCheckbox({
checked,
disabled,
label,
description,
onCheckedChange,
className,
}: {
checked: boolean
disabled?: boolean
label: React.ReactNode
description?: React.ReactNode
onCheckedChange: (checked: boolean) => void
className?: string
}) {
return (
<button
type="button"
role="checkbox"
aria-checked={checked}
disabled={disabled}
onClick={() => onCheckedChange(!checked)}
className={cn(
"flex w-full items-start gap-2 rounded-md px-1 py-1 text-left text-sm outline-none hover:bg-accent/60 focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50",
className
)}
>
<span
aria-hidden="true"
className={cn(
"mt-0.5 grid size-4 shrink-0 place-items-center rounded border transition-colors",
checked
? "border-primary bg-primary text-primary-foreground"
: "border-input bg-background"
)}
>
{checked ? <CheckGlyph className="size-3" /> : null}
</span>
<span className="min-w-0">
<span className="block leading-4">{label}</span>
{description ? (
<span className="block text-xs text-muted-foreground">
{description}
</span>
) : null}
</span>
</button>
)
}
export function PdfEditorSwatch({
color,
active,
label,
onClick,
size = "md",
}: {
color: string
active?: boolean
label?: string
onClick?: () => void
size?: "sm" | "md"
}) {
const isTransparent = color === "transparent"
const accessibleLabel = label ?? (isTransparent ? "No fill" : color)
const swatch = (
<button
type="button"
aria-label={accessibleLabel}
aria-pressed={active}
onClick={onClick}
className={cn(
"relative shrink-0 rounded-full border border-black/10 transition-transform outline-none focus-visible:ring-2 focus-visible:ring-ring dark:border-white/15",
size === "sm" ? "size-4" : "size-5",
active && "ring-2 ring-ring ring-offset-2 ring-offset-background"
)}
style={
isTransparent
? {
backgroundImage:
"linear-gradient(45deg, transparent 45%, #ef4444 45%, #ef4444 55%, transparent 55%)",
backgroundColor: "#fff",
}
: { backgroundColor: color }
}
/>
)
return label ? (
<PdfEditorTooltip label={label}>{swatch}</PdfEditorTooltip>
) : (
swatch
)
}
/* -------------------------------------------------------------------------- */
/* Scroll area with a resolvable viewport (mirrors the PDF viewer) */
/* -------------------------------------------------------------------------- */
export type PdfEditorScrollAreaViewportResolver = (
container: HTMLDivElement
) => HTMLDivElement | null
const DEFAULT_SCROLL_AREA_VIEWPORT_SELECTOR =
'[data-slot="scroll-area-viewport"]'
export function resolveDefaultScrollAreaViewport(container: HTMLDivElement) {
return container.querySelector<HTMLDivElement>(
DEFAULT_SCROLL_AREA_VIEWPORT_SELECTOR
)
}
export const PdfEditorScrollAreaResolverContext =
React.createContext<PdfEditorScrollAreaViewportResolver>(
resolveDefaultScrollAreaViewport
)
function setRefValue<T>(ref: React.Ref<T> | undefined, value: T | null) {
if (!ref) return
if (typeof ref === "function") {
ref(value)
} else {
ref.current = value
}
}
export function PdfEditorScrollArea({
children,
className,
orientation = "vertical",
scrollbarGutter = true,
viewportClassName,
viewportProps,
viewportRef,
}: {
children: React.ReactNode
className?: string
orientation?: "vertical" | "horizontal" | "both"
scrollbarGutter?: boolean
viewportClassName?: string
viewportProps?: React.HTMLAttributes<HTMLDivElement>
viewportRef?: React.Ref<HTMLDivElement>
}) {
const resolveViewport = React.useContext(PdfEditorScrollAreaResolverContext)
const containerRef = React.useRef<HTMLDivElement | null>(null)
const { className: viewportPropsClassName, ...resolvedViewportProps } =
viewportProps ?? {}
const setViewportRef = React.useCallback(
(viewport: HTMLDivElement | null) => {
setRefValue(viewportRef, viewport)
},
[viewportRef]
)
React.useLayoutEffect(() => {
const container = containerRef.current
if (!container) return
const viewport = resolveViewport(container)
if (!viewport) {
console.error(
`PDFEditor could not resolve the scroll viewport. Add ${DEFAULT_SCROLL_AREA_VIEWPORT_SELECTOR} to your ScrollArea viewport or pass resolveScrollAreaViewport.`
)
return
}
setViewportRef(viewport)
return () => setViewportRef(null)
}, [resolveViewport, setViewportRef])
return (
<div
ref={containerRef}
data-slot="pdf-editor-scroll-area"
className={cn("size-full min-h-0", className)}
>
<InlineScrollArea2
className="size-full min-h-0"
orientation={orientation}
scrollbarGutter={scrollbarGutter}
>
<div
{...resolvedViewportProps}
data-slot="pdf-editor-scroll-content"
className={cn(
"min-h-full",
viewportPropsClassName,
viewportClassName
)}
>
{children}
</div>
</InlineScrollArea2>
</div>
)
}
type ButtonProps = React.ComponentProps<typeof Button>
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
}
}
}
components/extend/pdf-editor-dialogs.tsx
"use client"
import * as React from "react"
import type { DocumentState } from "@embedpdf/core"
import {
useDocumentPermissions,
useDocumentState,
useRegistry,
} from "@embedpdf/core/react"
import {
ignore,
PdfActionType,
PdfAnnotationBorderStyle,
PdfAnnotationReplyType,
PdfAnnotationSubtype,
PdfBlendMode,
PdfErrorCode,
PdfPermissionFlag,
PdfZoomMode,
uuidV4,
type PdfLinkAnnoObject,
type PdfLinkTarget,
type PdfMetadataObject,
type PdfPrintOptions,
} from "@embedpdf/models"
import { useAnnotationCapability } from "@embedpdf/plugin-annotation/react"
import { useCapture } from "@embedpdf/plugin-capture/react"
import { useDocumentManagerCapability } from "@embedpdf/plugin-document-manager/react"
import { usePrint } from "@embedpdf/plugin-print/react"
import { useScroll } from "@embedpdf/plugin-scroll/react"
import { useSelectionCapability } from "@embedpdf/plugin-selection/react"
import {
SignatureDrawPad,
SignatureMode,
SignatureTypePad,
useSignatureCapability,
useSignatureUpload,
type SignatureDrawPadHandle,
type SignatureFieldDefinition,
type SignatureTypePadHandle,
} from "@embedpdf/plugin-signature/react"
import { cn } from "@/lib/utils"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { Input } from "@/components/ui/input"
import { Kbd } from "@/components/ui/kbd"
import { ScrollArea } from "@/components/ui/scroll-area"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { ColorPicker } from "@/components/extend/document-color-picker"
import {
CopyGlyph,
downloadBlob,
DownloadGlyph,
formatBytes,
formatDateTime,
formatShortcut,
KeyGlyph,
LockGlyph,
PDF_EDITOR_SIGNATURE_FONTS,
PdfEditorCheckbox,
PdfEditorFieldLabel,
PdfEditorSection,
PdfEditorSwatch,
ShieldGlyph,
UnlockGlyph,
usePdfEditor,
} from "@/components/extend/pdf-editor-shared"
import { IconPlaceholder } from "@/components/icon-placeholder"
/* -------------------------------------------------------------------------- */
/* Confirm */
/* -------------------------------------------------------------------------- */
export function PdfEditorConfirmDialog({
open,
onOpenChange,
title,
description,
confirmLabel = "Confirm",
destructive = false,
onConfirm,
}: {
open: boolean
onOpenChange: (open: boolean) => void
title: string
description?: string
confirmLabel?: string
destructive?: boolean
onConfirm: () => void
}) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
{description ? (
<DialogDescription>{description}</DialogDescription>
) : null}
</DialogHeader>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
>
Cancel
</Button>
<Button
type="button"
variant={destructive ? "destructive" : "default"}
onClick={() => {
onConfirm()
onOpenChange(false)
}}
>
{confirmLabel}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
/* -------------------------------------------------------------------------- */
/* Print */
/* -------------------------------------------------------------------------- */
type PrintPageSelection = "all" | "current" | "custom"
export function PdfEditorPrintDialog({
documentId,
open,
onOpenChange,
}: {
documentId: string
open: boolean
onOpenChange: (open: boolean) => void
}) {
const { provides: print } = usePrint(documentId)
const { state: scrollState } = useScroll(documentId)
const { notify } = usePdfEditor()
const [selection, setSelection] = React.useState<PrintPageSelection>("all")
const [customPages, setCustomPages] = React.useState("")
const [includeAnnotations, setIncludeAnnotations] = React.useState(true)
const [isPrinting, setIsPrinting] = React.useState(false)
const [error, setError] = React.useState<string | null>(null)
const [previousOpen, setPreviousOpen] = React.useState(open)
if (previousOpen !== open) {
setPreviousOpen(open)
if (!open) {
setSelection("all")
setCustomPages("")
setIncludeAnnotations(true)
setIsPrinting(false)
setError(null)
}
}
const canSubmit =
!isPrinting && (selection !== "custom" || customPages.trim().length > 0)
const handlePrint = () => {
if (!print || !canSubmit) return
const options: PdfPrintOptions = {
includeAnnotations,
pageRange:
selection === "current"
? String(scrollState.currentPage)
: selection === "custom"
? customPages.trim()
: undefined,
}
setIsPrinting(true)
setError(null)
print.print(options).wait(
() => {
setIsPrinting(false)
onOpenChange(false)
},
(failure) => {
setIsPrinting(false)
const message =
failure?.reason?.message ?? "The document could not be printed."
setError(message)
notify(message, "error")
}
)
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Print</DialogTitle>
<DialogDescription>
Prepares a print-ready copy and opens the browser print dialog.
</DialogDescription>
</DialogHeader>
<InlineDialogPanel className="space-y-5">
<PdfEditorSection title="Pages">
<div className="space-y-1">
<PdfEditorCheckbox
checked={selection === "all"}
onCheckedChange={() => setSelection("all")}
label="All pages"
/>
<PdfEditorCheckbox
checked={selection === "current"}
onCheckedChange={() => setSelection("current")}
label={`Current page (${scrollState.currentPage || 1})`}
/>
<PdfEditorCheckbox
checked={selection === "custom"}
onCheckedChange={() => setSelection("custom")}
label="Page range"
/>
</div>
<Input
placeholder="e.g. 1-3, 5, 8-10"
value={customPages}
disabled={selection !== "custom"}
onChange={(event: React.ChangeEvent<HTMLInputElement>) =>
setCustomPages(event.target.value)
}
onFocus={() => setSelection("custom")}
/>
<div className="text-xs text-muted-foreground">
{scrollState.totalPages} pages in this document.
</div>
</PdfEditorSection>
<PdfEditorSection title="Options">
<PdfEditorCheckbox
checked={includeAnnotations}
onCheckedChange={setIncludeAnnotations}
label="Include annotations and form values"
/>
</PdfEditorSection>
{error ? (
<div className="text-xs text-destructive">{error}</div>
) : null}
</InlineDialogPanel>
<DialogFooter>
<Button
type="button"
variant="outline"
disabled={isPrinting}
onClick={() => onOpenChange(false)}
>
Cancel
</Button>
<InlineLoadingButton
type="button"
loading={isPrinting}
disabled={!canSubmit || !print}
onClick={handlePrint}
>
Print
</InlineLoadingButton>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
/* -------------------------------------------------------------------------- */
/* Capture */
/* -------------------------------------------------------------------------- */
type CaptureResult = {
pageIndex: number
blob: Blob
imageType: string
url: string
}
export function PdfEditorCaptureDialog({ documentId }: { documentId: string }) {
const { provides: capture } = useCapture(documentId)
const { fileName, notify, onCapture } = usePdfEditor()
const [result, setResult] = React.useState<CaptureResult | null>(null)
const onCaptureRef = React.useRef(onCapture)
React.useEffect(() => {
onCaptureRef.current = onCapture
})
React.useEffect(() => {
if (!capture) return
return capture.onCaptureArea((event) => {
onCaptureRef.current?.(event)
setResult((previous) => {
if (previous) URL.revokeObjectURL(previous.url)
return {
pageIndex: event.pageIndex,
blob: event.blob,
imageType: event.imageType,
url: URL.createObjectURL(event.blob),
}
})
})
}, [capture])
React.useEffect(
() => () => {
if (result) URL.revokeObjectURL(result.url)
},
[result]
)
const close = () => setResult(null)
const extension = result?.imageType.split("/")[1] ?? "png"
const captureFileName = `${fileName.replace(/\.pdf$/i, "")}-page-${(result?.pageIndex ?? 0) + 1}.${extension}`
const canCopy =
typeof navigator !== "undefined" &&
typeof window !== "undefined" &&
"ClipboardItem" in window &&
Boolean(navigator.clipboard?.write)
return (
<Dialog
open={result !== null}
onOpenChange={(open) => {
if (!open) close()
}}
>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>Captured area</DialogTitle>
<DialogDescription>
Page {(result?.pageIndex ?? 0) + 1} ·{" "}
{formatBytes(result?.blob.size)}
</DialogDescription>
</DialogHeader>
<InlineDialogPanel>
<div className="grid place-items-center rounded-lg border bg-muted/40 p-3">
{result ? (
<img
src={result.url}
alt={`Captured area from page ${result.pageIndex + 1}`}
className="max-h-[52vh] max-w-full rounded-md shadow-sm"
/>
) : null}
</div>
</InlineDialogPanel>
<DialogFooter>
{canCopy ? (
<Button
type="button"
variant="outline"
onClick={async () => {
if (!result) return
try {
await navigator.clipboard.write([
new ClipboardItem({ [result.imageType]: result.blob }),
])
notify("Image copied to the clipboard.", "success")
} catch {
notify("The image could not be copied.", "error")
}
}}
>
<CopyGlyph className="size-4" />
Copy image
</Button>
) : null}
<Button
type="button"
onClick={() => {
if (!result) return
downloadBlob(result.blob, captureFileName)
close()
}}
>
<DownloadGlyph className="size-4" />
Download
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
/* -------------------------------------------------------------------------- */
/* Security */
/* -------------------------------------------------------------------------- */
const PERMISSION_OPTIONS: Array<{
flag: PdfPermissionFlag
label: string
description: string
}> = [
{
flag: PdfPermissionFlag.Print,
label: "Print",
description: "Allow printing, possibly at reduced quality.",
},
{
flag: PdfPermissionFlag.PrintHighQuality,
label: "Print in high quality",
description: "Allow full-resolution printing.",
},
{
flag: PdfPermissionFlag.ModifyContents,
label: "Modify contents",
description: "Allow editing the page content.",
},
{
flag: PdfPermissionFlag.CopyContents,
label: "Copy text and images",
description: "Allow copying and extracting content.",
},
{
flag: PdfPermissionFlag.ModifyAnnotations,
label: "Annotate",
description: "Allow adding and changing annotations.",
},
{
flag: PdfPermissionFlag.FillForms,
label: "Fill forms",
description: "Allow filling in existing form fields.",
},
{
flag: PdfPermissionFlag.ExtractForAccessibility,
label: "Extract for accessibility",
description: "Allow assistive technologies to read the content.",
},
{
flag: PdfPermissionFlag.AssembleDocument,
label: "Assemble pages",
description: "Allow inserting, rotating, and deleting pages.",
},
]
function PasswordInput({
id,
value,
onChange,
placeholder,
autoFocus,
onEnter,
}: {
id?: string
value: string
onChange: (value: string) => void
placeholder?: string
autoFocus?: boolean
onEnter?: () => void
}) {
return (
<Input
id={id}
type="password"
autoComplete="off"
value={value}
placeholder={placeholder}
autoFocus={autoFocus}
onChange={(event: React.ChangeEvent<HTMLInputElement>) =>
onChange(event.target.value)
}
onKeyDown={(event: React.KeyboardEvent<HTMLInputElement>) => {
if (event.key === "Enter") onEnter?.()
}}
/>
)
}
export function PdfEditorSecurityDialog({
documentId,
open,
onOpenChange,
}: {
documentId: string
open: boolean
onOpenChange: (open: boolean) => void
}) {
const { provides: documentManager } = useDocumentManagerCapability()
const documentState = useDocumentState(documentId)
const permissions = useDocumentPermissions(documentId)
const { notify } = usePdfEditor()
const document = documentState?.document ?? null
const isEncrypted = document?.isEncrypted ?? false
const isOwnerUnlocked = document?.isOwnerUnlocked ?? false
const needsOwnerUnlock = isEncrypted && !isOwnerUnlocked
const [unlockPassword, setUnlockPassword] = React.useState("")
const [isUnlocking, setIsUnlocking] = React.useState(false)
const [requireOpenPassword, setRequireOpenPassword] = React.useState(false)
const [openPassword, setOpenPassword] = React.useState("")
const [confirmOpenPassword, setConfirmOpenPassword] = React.useState("")
const [restrictPermissions, setRestrictPermissions] = React.useState(false)
const [ownerPassword, setOwnerPassword] = React.useState("")
const [confirmOwnerPassword, setConfirmOwnerPassword] = React.useState("")
const [allowedFlags, setAllowedFlags] = React.useState<
Set<PdfPermissionFlag>
>(() => new Set(PERMISSION_OPTIONS.map((option) => option.flag)))
const [isApplying, setIsApplying] = React.useState(false)
const [error, setError] = React.useState<string | null>(null)
const [previousOpen, setPreviousOpen] = React.useState(open)
if (previousOpen !== open) {
setPreviousOpen(open)
if (open) {
setUnlockPassword("")
setIsUnlocking(false)
setRequireOpenPassword(false)
setOpenPassword("")
setConfirmOpenPassword("")
setRestrictPermissions(false)
setOwnerPassword("")
setConfirmOwnerPassword("")
setAllowedFlags(new Set(PERMISSION_OPTIONS.map((option) => option.flag)))
setIsApplying(false)
setError(null)
}
}
const openPasswordValid =
!requireOpenPassword ||
(openPassword.length > 0 && openPassword === confirmOpenPassword)
const ownerPasswordValid =
!restrictPermissions ||
(ownerPassword.length > 0 && ownerPassword === confirmOwnerPassword)
const canApply =
!isApplying &&
(requireOpenPassword || restrictPermissions) &&
openPasswordValid &&
ownerPasswordValid
const handleUnlock = () => {
if (!documentManager || !unlockPassword.trim()) return
setIsUnlocking(true)
setError(null)
documentManager.unlockOwnerPermissions(documentId, unlockPassword).wait(
(success) => {
setIsUnlocking(false)
if (success) {
setUnlockPassword("")
notify("Owner permissions unlocked.", "success")
} else {
setError("The owner password is incorrect.")
}
},
(failure) => {
setIsUnlocking(false)
setError(
failure?.reason?.message ?? "The document could not be unlocked."
)
}
)
}
const handleApply = () => {
if (!documentManager || !canApply) return
let flags = PdfPermissionFlag.AllowAll as number
if (restrictPermissions) {
flags = 0
for (const flag of allowedFlags) flags |= flag
}
const userPassword = requireOpenPassword ? openPassword : ""
const resolvedOwnerPassword = restrictPermissions
? ownerPassword
: requireOpenPassword
? openPassword
: ""
setIsApplying(true)
setError(null)
documentManager
.setDocumentEncryption(documentId, {
userPassword,
ownerPassword: resolvedOwnerPassword,
allowedFlags: flags,
})
.wait(
(success) => {
setIsApplying(false)
if (success) {
notify(
"Protection is applied the next time you download the document.",
"success"
)
onOpenChange(false)
} else {
setError("The protection settings could not be applied.")
}
},
(failure) => {
setIsApplying(false)
setError(
failure?.reason?.message ??
"The protection settings could not be applied."
)
}
)
}
const handleRemove = () => {
if (!documentManager) return
setIsApplying(true)
setError(null)
documentManager.removeEncryption(documentId).wait(
(success) => {
setIsApplying(false)
if (success) {
notify(
"Protection is removed the next time you download the document.",
"success"
)
onOpenChange(false)
} else {
setError("The protection could not be removed.")
}
},
(failure) => {
setIsApplying(false)
setError(
failure?.reason?.message ?? "The protection could not be removed."
)
}
)
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>Security</DialogTitle>
<DialogDescription>
Passwords and permissions are written into the PDF when you download
it.
</DialogDescription>
</DialogHeader>
<InlineDialogPanel className="space-y-5">
<PdfEditorSection title="Current status">
<div className="flex flex-wrap items-center gap-2 text-sm">
{isEncrypted ? (
<Badge variant="secondary">
<LockGlyph className="size-3" />
Encrypted
</Badge>
) : (
<Badge variant="outline">
<UnlockGlyph className="size-3" />
Not encrypted
</Badge>
)}
{isEncrypted ? (
<Badge variant={isOwnerUnlocked ? "secondary" : "outline"}>
<KeyGlyph className="size-3" />
{isOwnerUnlocked ? "Owner unlocked" : "Owner locked"}
</Badge>
) : null}
</div>
<div className="grid grid-cols-2 gap-x-4 gap-y-1 text-xs text-muted-foreground">
{PERMISSION_OPTIONS.map((option) => {
const allowed = permissions.hasPermission(option.flag)
return (
<div key={option.flag} className="flex items-center gap-1.5">
<span
aria-hidden="true"
className={cn(
"size-1.5 rounded-full",
allowed ? "bg-emerald-500" : "bg-destructive"
)}
/>
<span className={cn(!allowed && "line-through")}>
{option.label}
</span>
</div>
)
})}
</div>
</PdfEditorSection>
{needsOwnerUnlock ? (
<PdfEditorSection title="Unlock owner permissions">
<div className="text-xs text-muted-foreground">
Enter the owner password to change protection settings.
</div>
<div className="flex gap-2">
<PasswordInput
value={unlockPassword}
placeholder="Owner password"
onChange={setUnlockPassword}
onEnter={handleUnlock}
/>
<InlineLoadingButton
type="button"
variant="outline"
loading={isUnlocking}
disabled={!unlockPassword.trim()}
onClick={handleUnlock}
>
Unlock
</InlineLoadingButton>
</div>
</PdfEditorSection>
) : null}
<PdfEditorSection title="Open password">
<PdfEditorCheckbox
checked={requireOpenPassword}
disabled={needsOwnerUnlock}
onCheckedChange={setRequireOpenPassword}
label="Require a password to open the document"
/>
{requireOpenPassword ? (
<div className="grid gap-2 sm:grid-cols-2">
<PasswordInput
value={openPassword}
placeholder="Password"
onChange={setOpenPassword}
/>
<PasswordInput
value={confirmOpenPassword}
placeholder="Confirm password"
onChange={setConfirmOpenPassword}
/>
{!openPasswordValid && confirmOpenPassword ? (
<div className="text-xs text-destructive sm:col-span-2">
The passwords do not match.
</div>
) : null}
</div>
) : null}
</PdfEditorSection>
<PdfEditorSection title="Permissions">
<PdfEditorCheckbox
checked={restrictPermissions}
disabled={needsOwnerUnlock}
onCheckedChange={setRestrictPermissions}
label="Restrict what readers can do"
description="Requires an owner password to lift the restrictions later."
/>
{restrictPermissions ? (
<div className="space-y-3">
<div className="grid gap-2 sm:grid-cols-2">
<PasswordInput
value={ownerPassword}
placeholder="Owner password"
onChange={setOwnerPassword}
/>
<PasswordInput
value={confirmOwnerPassword}
placeholder="Confirm owner password"
onChange={setConfirmOwnerPassword}
/>
{!ownerPasswordValid && confirmOwnerPassword ? (
<div className="text-xs text-destructive sm:col-span-2">
The passwords do not match.
</div>
) : null}
</div>
<div className="grid gap-1 sm:grid-cols-2">
{PERMISSION_OPTIONS.map((option) => (
<PdfEditorCheckbox
key={option.flag}
checked={allowedFlags.has(option.flag)}
onCheckedChange={(checked) =>
setAllowedFlags((previous) => {
const next = new Set(previous)
if (checked) {
next.add(option.flag)
} else {
next.delete(option.flag)
}
return next
})
}
label={option.label}
description={option.description}
/>
))}
</div>
</div>
) : null}
</PdfEditorSection>
{error ? (
<div className="text-xs text-destructive">{error}</div>
) : null}
</InlineDialogPanel>
<DialogFooter>
{isEncrypted && !needsOwnerUnlock ? (
<Button
type="button"
disabled={isApplying}
onClick={handleRemove}
variant={"outline"}
className={cn(
"border-destructive/32 text-destructive hover:bg-destructive/10",
"sm:mr-auto"
)}
>
Remove protection
</Button>
) : null}
<Button
type="button"
variant="outline"
disabled={isApplying}
onClick={() => onOpenChange(false)}
>
Cancel
</Button>
<InlineLoadingButton
type="button"
loading={isApplying}
disabled={!canApply || needsOwnerUnlock}
onClick={handleApply}
>
<ShieldGlyph className="size-4" />
Apply protection
</InlineLoadingButton>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
/* -------------------------------------------------------------------------- */
/* Document properties (metadata) */
/* -------------------------------------------------------------------------- */
type EditableMetadata = {
title: string
author: string
subject: string
keywords: string
creator: string
producer: string
}
const EMPTY_METADATA: EditableMetadata = {
title: "",
author: "",
subject: "",
keywords: "",
creator: "",
producer: "",
}
export function PdfEditorPropertiesDialog({
documentId,
open,
onOpenChange,
}: {
documentId: string
open: boolean
onOpenChange: (open: boolean) => void
}) {
const { registry } = useRegistry()
const documentState = useDocumentState(documentId)
const permissions = useDocumentPermissions(documentId)
const { fileName, notify } = usePdfEditor()
const document = documentState?.document ?? null
const [metadata, setMetadata] =
React.useState<EditableMetadata>(EMPTY_METADATA)
const [dates, setDates] = React.useState<{
creationDate: Date | null
modificationDate: Date | null
}>({ creationDate: null, modificationDate: null })
const [isLoading, setIsLoading] = React.useState(
Boolean(open && registry && document)
)
const [isSaving, setIsSaving] = React.useState(false)
const [error, setError] = React.useState<string | null>(null)
const [metadataRequest, setMetadataRequest] = React.useState({
document,
open,
registry,
})
if (
metadataRequest.document !== document ||
metadataRequest.open !== open ||
metadataRequest.registry !== registry
) {
setMetadataRequest({ document, open, registry })
setIsLoading(Boolean(open && registry && document))
setError(null)
}
React.useEffect(() => {
if (!open || !registry || !document) return
let cancelled = false
registry
.getEngine()
.getMetadata(document)
.wait(
(result: PdfMetadataObject) => {
if (cancelled) return
setMetadata({
title: result.title ?? "",
author: result.author ?? "",
subject: result.subject ?? "",
keywords: result.keywords ?? "",
creator: result.creator ?? "",
producer: result.producer ?? "",
})
setDates({
creationDate: result.creationDate ?? null,
modificationDate: result.modificationDate ?? null,
})
setIsLoading(false)
},
(failure) => {
if (cancelled) return
setError(
failure?.reason?.message ??
"The document metadata could not be read."
)
setIsLoading(false)
}
)
return () => {
cancelled = true
}
}, [document, open, registry])
const updateField = (key: keyof EditableMetadata, value: string) =>
setMetadata((previous) => ({ ...previous, [key]: value }))
const handleSave = () => {
if (!registry || !document) return
setIsSaving(true)
setError(null)
registry
.getEngine()
.setMetadata(document, {
title: metadata.title,
author: metadata.author,
subject: metadata.subject,
keywords: metadata.keywords,
creator: metadata.creator,
producer: metadata.producer,
modificationDate: new Date(),
})
.wait(
() => {
setIsSaving(false)
notify("Document properties saved.", "success")
onOpenChange(false)
},
(failure) => {
setIsSaving(false)
setError(
failure?.reason?.message ??
"The document metadata could not be saved."
)
}
)
}
const firstPage = document?.pages[0]
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>Document properties</DialogTitle>
<DialogDescription>{fileName}</DialogDescription>
</DialogHeader>
<InlineDialogPanel className="space-y-5">
{isLoading ? (
<div className="grid place-items-center py-8">
<InlineSpinner className="size-4" />
</div>
) : (
<>
<PdfEditorSection title="Description">
<div className="grid gap-3">
{(
[
["title", "Title"],
["author", "Author"],
["subject", "Subject"],
["keywords", "Keywords"],
["creator", "Creator"],
["producer", "Producer"],
] as Array<[keyof EditableMetadata, string]>
).map(([key, label]) => (
<div key={key} className="grid gap-1">
<PdfEditorFieldLabel htmlFor={`pdf-editor-meta-${key}`}>
{label}
</PdfEditorFieldLabel>
<Input
id={`pdf-editor-meta-${key}`}
value={metadata[key]}
disabled={!permissions.canModifyContents}
onChange={(
event: React.ChangeEvent<HTMLInputElement>
) => updateField(key, event.target.value)}
className={cn("h-8 px-2.5")}
/>
</div>
))}
</div>
</PdfEditorSection>
<PdfEditorSection title="Details">
<dl className="grid grid-cols-[auto_1fr] gap-x-4 gap-y-1.5 text-xs">
<dt className="text-muted-foreground">Pages</dt>
<dd>{document?.pageCount ?? "—"}</dd>
<dt className="text-muted-foreground">Page size</dt>
<dd>
{firstPage
? `${Math.round(firstPage.size.width)} × ${Math.round(firstPage.size.height)} pt`
: "—"}
</dd>
<dt className="text-muted-foreground">Created</dt>
<dd>{formatDateTime(dates.creationDate)}</dd>
<dt className="text-muted-foreground">Modified</dt>
<dd>{formatDateTime(dates.modificationDate)}</dd>
<dt className="text-muted-foreground">Security</dt>
<dd>
{document?.isEncrypted
? "Password protected"
: "No protection"}
</dd>
</dl>
</PdfEditorSection>
{error ? (
<div className="text-xs text-destructive">{error}</div>
) : null}
</>
)}
</InlineDialogPanel>
<DialogFooter>
<Button
type="button"
variant="outline"
disabled={isSaving}
onClick={() => onOpenChange(false)}
>
Cancel
</Button>
<InlineLoadingButton
type="button"
loading={isSaving}
disabled={isLoading || !permissions.canModifyContents}
onClick={handleSave}
>
Save
</InlineLoadingButton>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
/* -------------------------------------------------------------------------- */
/* Signature creation */
/* -------------------------------------------------------------------------- */
type SignatureCreationTab = "draw" | "type" | "upload"
type SignatureTarget = "signature" | "initials"
const SIGNATURE_COLORS = [
{ name: "Black", value: "#111827" },
{ name: "Blue", value: "#1d4ed8" },
{ name: "Red", value: "#dc2626" },
]
function useSignatureFontsStylesheet(url: string | null, enabled: boolean) {
React.useEffect(() => {
if (!enabled || !url || typeof document === "undefined") return
const existing = document.querySelector<HTMLLinkElement>(
`link[data-pdf-editor-signature-fonts="${CSS.escape(url)}"]`
)
if (existing) return
const link = document.createElement("link")
link.rel = "stylesheet"
link.href = url
link.dataset.pdfEditorSignatureFonts = url
document.head.append(link)
}, [enabled, url])
}
type SignatureUploadZoneProps = {
inputRef: ReturnType<typeof useSignatureUpload>["inputRef"]
accept: string
isDragging: boolean
previewUrl: string | null
placeholder: string
onOpen: () => void
onFileInputChange: (event: Event) => void
onDrop: (event: DragEvent) => void
onDragOver: (event: DragEvent) => void
onDragLeave: () => void
}
function SignatureUploadZone({
inputRef,
accept,
isDragging,
previewUrl,
placeholder,
onOpen,
onFileInputChange,
onDrop,
onDragOver,
onDragLeave,
}: SignatureUploadZoneProps) {
return (
<>
<input
ref={inputRef}
type="file"
accept={accept}
className="sr-only"
tabIndex={-1}
onChange={(event) => onFileInputChange(event.nativeEvent)}
/>
<button
type="button"
onClick={onOpen}
onDrop={(event) => {
event.preventDefault()
onDrop(event.nativeEvent)
}}
onDragOver={(event) => {
event.preventDefault()
onDragOver(event.nativeEvent)
}}
onDragLeave={() => onDragLeave()}
className={cn(
"flex h-full w-full items-center justify-center rounded-md border-2 border-dashed transition-colors",
isDragging
? "border-primary bg-primary/5"
: "border-input hover:border-muted-foreground/60"
)}
>
{previewUrl ? (
<img
src={previewUrl}
alt="Uploaded signature"
className="max-h-[90%] max-w-[90%] object-contain"
/>
) : (
<span className="px-4 text-center text-xs text-muted-foreground">
{placeholder}
</span>
)}
</button>
</>
)
}
function SignaturePad({
tab,
color,
fontFamily,
placeholder,
uploadPlaceholder,
drawRef,
typeRef,
upload,
onResult,
}: {
tab: SignatureCreationTab
color: string
fontFamily: string
placeholder: string
uploadPlaceholder: string
drawRef: React.MutableRefObject<SignatureDrawPadHandle | null>
typeRef: React.MutableRefObject<SignatureTypePadHandle | null>
upload: ReturnType<typeof useSignatureUpload>
onResult: (result: SignatureFieldDefinition | null) => void
}) {
return (
<div className="h-full w-full">
{tab === "draw" ? (
<SignatureDrawPad
onResult={onResult}
padRef={(handle) => {
drawRef.current = handle
}}
strokeColor={color}
strokeWidth={3}
className="h-full w-full rounded-md border border-input bg-white"
/>
) : null}
{tab === "type" ? (
<SignatureTypePad
onResult={onResult}
padRef={(handle) => {
typeRef.current = handle
}}
fontFamily={fontFamily}
fontSize={40}
color={color}
placeholder={placeholder}
className="h-full w-full rounded-md border border-input bg-white px-3 py-2 text-3xl text-slate-950 outline-none dark:bg-white dark:text-slate-950"
/>
) : null}
{tab === "upload" ? (
<SignatureUploadZone
inputRef={upload.inputRef}
accept={upload.accept}
isDragging={upload.isDragging}
previewUrl={upload.previewUrl}
placeholder={uploadPlaceholder}
onOpen={upload.openFilePicker}
onFileInputChange={upload.handleFileInputChange}
onDrop={upload.handleDrop}
onDragOver={upload.handleDragOver}
onDragLeave={upload.handleDragLeave}
/>
) : null}
</div>
)
}
export function PdfEditorSignatureDialog({
open,
onOpenChange,
}: {
open: boolean
onOpenChange: (open: boolean) => void
}) {
const { provides: signatureCapability } = useSignatureCapability()
const { notify, signatureFontsStylesheetUrl } = usePdfEditor()
const supportsInitials =
(signatureCapability?.mode ?? SignatureMode.SignatureOnly) ===
SignatureMode.SignatureAndInitials
const [tab, setTab] = React.useState<SignatureCreationTab>("draw")
const [color, setColor] = React.useState(SIGNATURE_COLORS[0].value)
const [fontFamily, setFontFamily] = React.useState(
PDF_EDITOR_SIGNATURE_FONTS[0].family
)
const [signature, setSignature] =
React.useState<SignatureFieldDefinition | null>(null)
const [initials, setInitials] =
React.useState<SignatureFieldDefinition | null>(null)
const [activeTarget, setActiveTarget] =
React.useState<SignatureTarget>("signature")
const signatureDrawRef = React.useRef<SignatureDrawPadHandle | null>(null)
const initialsDrawRef = React.useRef<SignatureDrawPadHandle | null>(null)
const signatureTypeRef = React.useRef<SignatureTypePadHandle | null>(null)
const initialsTypeRef = React.useRef<SignatureTypePadHandle | null>(null)
useSignatureFontsStylesheet(signatureFontsStylesheetUrl, open)
const signatureUpload = useSignatureUpload({ onResult: setSignature })
const initialsUpload = useSignatureUpload({ onResult: setInitials })
const clearSignatureUpload = signatureUpload.clear
const clearInitialsUpload = initialsUpload.clear
const clearAll = React.useCallback(() => {
signatureDrawRef.current?.clear()
initialsDrawRef.current?.clear()
signatureTypeRef.current?.clear()
initialsTypeRef.current?.clear()
clearSignatureUpload()
clearInitialsUpload()
setSignature(null)
setInitials(null)
}, [clearInitialsUpload, clearSignatureUpload])
const [previousOpen, setPreviousOpen] = React.useState(open)
if (previousOpen !== open) {
setPreviousOpen(open)
if (!open) {
setTab("draw")
setColor(SIGNATURE_COLORS[0].value)
setFontFamily(PDF_EDITOR_SIGNATURE_FONTS[0].family)
setActiveTarget("signature")
setSignature(null)
setInitials(null)
}
}
const handleTabChange = (nextTab: SignatureCreationTab) => {
clearAll()
setTab(nextTab)
}
const canSave = Boolean(signature || initials)
const handleSave = () => {
const entrySignature = signature ?? initials
if (!entrySignature || !signatureCapability) return
signatureCapability.addEntry({
signature: entrySignature,
...(initials ? { initials } : {}),
})
notify(signature ? "Signature saved." : "Initials saved.", "success")
onOpenChange(false)
}
const tabs: Array<{
id: SignatureCreationTab
label: string
}> = [
{ id: "draw", label: "Draw" },
{ id: "type", label: "Type" },
{ id: "upload", label: "Upload" },
]
const fontOptions = PDF_EDITOR_SIGNATURE_FONTS.map((font) => ({
label: font.name,
value: font.family,
}))
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>Create signature</DialogTitle>
<DialogDescription>
Saved signatures can be placed anywhere in the document.
</DialogDescription>
</DialogHeader>
<InlineDialogPanel className="space-y-4">
<div className="flex items-center gap-1 rounded-lg bg-muted p-0.5">
{tabs.map((item) => (
<button
key={item.id}
type="button"
onClick={() => handleTabChange(item.id)}
className={cn(
"flex-1 rounded-md px-3 py-1.5 text-sm font-medium transition-colors",
tab === item.id
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground"
)}
>
{item.label}
</button>
))}
</div>
<div className="space-y-2">
<div className="flex items-center justify-between gap-3">
{supportsInitials ? (
<div className="flex items-center gap-1 rounded-lg bg-muted p-0.5">
{(["signature", "initials"] as const).map((target) => (
<button
key={target}
type="button"
aria-pressed={activeTarget === target}
onClick={() => setActiveTarget(target)}
className={cn(
"rounded-md px-3 py-1 text-xs font-medium transition-colors",
activeTarget === target
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground"
)}
>
{target === "signature" ? "Signature" : "Initials"}
</button>
))}
</div>
) : (
<PdfEditorFieldLabel>Signature</PdfEditorFieldLabel>
)}
<Button
type="button"
variant="ghost"
size="xs"
onClick={() => {
if (activeTarget === "signature") {
if (tab === "draw") signatureDrawRef.current?.clear()
else if (tab === "type") signatureTypeRef.current?.clear()
else clearSignatureUpload()
setSignature(null)
} else {
if (tab === "draw") initialsDrawRef.current?.clear()
else if (tab === "type") initialsTypeRef.current?.clear()
else clearInitialsUpload()
setInitials(null)
}
}}
>
Clear
</Button>
</div>
<div
className={cn(
"h-36 w-full",
activeTarget !== "signature" && "hidden"
)}
>
<SignaturePad
tab={tab}
color={color}
fontFamily={fontFamily}
placeholder="Type your name"
uploadPlaceholder="Click or drop an image of your signature"
drawRef={signatureDrawRef}
typeRef={signatureTypeRef}
upload={signatureUpload}
onResult={setSignature}
/>
</div>
{supportsInitials ? (
<div
className={cn(
"w-full",
activeTarget !== "initials" && "hidden"
)}
>
<div className="aspect-[20/7] h-36 max-w-full">
<SignaturePad
tab={tab}
color={color}
fontFamily={fontFamily}
placeholder="Initials"
uploadPlaceholder="Click or drop an image"
drawRef={initialsDrawRef}
typeRef={initialsTypeRef}
upload={initialsUpload}
onResult={setInitials}
/>
</div>
</div>
) : null}
</div>
<div className="flex flex-wrap items-center gap-4">
{tab !== "upload" ? (
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground">Color</span>
<div className="flex items-center gap-1.5">
{SIGNATURE_COLORS.map((option) => (
<PdfEditorSwatch
key={option.value}
color={option.value}
label={option.name}
active={color === option.value}
onClick={() => setColor(option.value)}
/>
))}
<ColorPicker
label="Custom signature color"
rainbowTrigger
color={color}
onChange={setColor}
/>
</div>
</div>
) : null}
{tab === "type" ? (
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground">Font</span>
<Select
items={fontOptions}
value={fontFamily}
onValueChange={(value) => setFontFamily(String(value))}
>
<SelectTrigger size="sm" className="w-40">
<SelectValue placeholder="Font" />
</SelectTrigger>
<SelectContent alignItemWithTrigger={false}>
{fontOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
<span style={{ fontFamily: option.value }}>
{option.label}
</span>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
) : null}
</div>
</InlineDialogPanel>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
>
Cancel
</Button>
<Button type="button" disabled={!canSave} onClick={handleSave}>
Save
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
/* -------------------------------------------------------------------------- */
/* Links */
/* -------------------------------------------------------------------------- */
type LinkTab = "url" | "page"
function readLinkTarget(target: PdfLinkTarget | undefined): {
tab: LinkTab
url: string
pageNumber: number | null
} {
if (!target) return { tab: "url", url: "", pageNumber: null }
if (target.type === "action") {
if (target.action.type === PdfActionType.URI) {
return { tab: "url", url: target.action.uri, pageNumber: null }
}
if (
target.action.type === PdfActionType.Goto ||
target.action.type === PdfActionType.RemoteGoto
) {
return {
tab: "page",
url: "",
pageNumber: target.action.destination.pageIndex + 1,
}
}
return { tab: "url", url: "", pageNumber: null }
}
return {
tab: "page",
url: "",
pageNumber: target.destination.pageIndex + 1,
}
}
export function PdfEditorLinkDialog({
documentId,
open,
onOpenChange,
source,
}: {
documentId: string
open: boolean
onOpenChange: (open: boolean) => void
source: "annotation" | "selection"
}) {
const { provides: annotationCapability } = useAnnotationCapability()
const { provides: selectionCapability } = useSelectionCapability()
const { state: scrollState } = useScroll(documentId)
const { notify } = usePdfEditor()
const [tab, setTab] = React.useState<LinkTab>("url")
const [url, setUrl] = React.useState("")
const [pageNumber, setPageNumber] = React.useState(1)
const totalPages = Math.max(scrollState.totalPages, 1)
const annotationScope = React.useMemo(
() => annotationCapability?.forDocument(documentId) ?? null,
[annotationCapability, documentId]
)
const selectionScope = React.useMemo(
() => selectionCapability?.forDocument(documentId) ?? null,
[documentId, selectionCapability]
)
const [linkRequest, setLinkRequest] = React.useState({
annotationScope,
open: false,
source,
})
if (
linkRequest.annotationScope !== annotationScope ||
linkRequest.open !== open ||
linkRequest.source !== source
) {
setLinkRequest({ annotationScope, open, source })
if (open) {
const selected =
source === "annotation"
? annotationScope?.getSelectedAnnotation()
: null
const existing =
selected?.object.type === PdfAnnotationSubtype.LINK
? readLinkTarget((selected.object as PdfLinkAnnoObject).target)
: { tab: "url" as const, url: "", pageNumber: 1 }
setTab(existing.tab)
setUrl(existing.url)
setPageNumber(existing.pageNumber ?? 1)
}
}
const buildTarget = (): PdfLinkTarget | null => {
if (tab === "url") {
const trimmed = url.trim()
if (!trimmed) return null
const normalized = /^[a-z][a-z0-9+.-]*:/i.test(trimmed)
? trimmed
: `https://${trimmed}`
return {
type: "action",
action: { type: PdfActionType.URI, uri: normalized },
}
}
return {
type: "destination",
destination: {
pageIndex: Math.min(Math.max(pageNumber, 1), totalPages) - 1,
zoom: { mode: PdfZoomMode.FitPage },
view: [],
},
}
}
const handleSubmit = () => {
const target = buildTarget()
if (!target || !annotationScope) return
const selected =
source === "annotation" ? annotationScope.getSelectedAnnotation() : null
if (selected && selected.object.type === PdfAnnotationSubtype.LINK) {
annotationScope.updateAnnotation(
selected.object.pageIndex,
selected.object.id,
{
target,
} as Partial<PdfLinkAnnoObject>
)
notify("Link updated.", "success")
onOpenChange(false)
return
}
if (selected) {
const rects =
"segmentRects" in selected.object &&
Array.isArray(selected.object.segmentRects) &&
selected.object.segmentRects.length > 0
? selected.object.segmentRects
: [selected.object.rect]
for (const rect of rects) {
annotationScope.createAnnotation<PdfLinkAnnoObject>(
selected.object.pageIndex,
{
id: uuidV4(),
type: PdfAnnotationSubtype.LINK,
pageIndex: selected.object.pageIndex,
rect,
inReplyToId: selected.object.id,
replyType: PdfAnnotationReplyType.Group,
target,
strokeStyle: PdfAnnotationBorderStyle.UNDERLINE,
strokeColor: "#2563eb",
strokeWidth: 2,
created: new Date(),
}
)
}
notify("Link added.", "success")
onOpenChange(false)
return
}
const formattedSelection = selectionScope?.getFormattedSelection() ?? []
if (formattedSelection.length === 0) {
notify("Select text or an annotation first.", "error")
return
}
const applyLinks = (text: string[]) => {
for (const page of formattedSelection) {
const highlightId = uuidV4()
annotationScope.createAnnotation(page.pageIndex, {
id: highlightId,
created: new Date(),
flags: ["print"],
type: PdfAnnotationSubtype.HIGHLIGHT,
blendMode: PdfBlendMode.Multiply,
pageIndex: page.pageIndex,
rect: page.rect,
segmentRects: page.segmentRects,
strokeColor: "#ffffff",
opacity: 0,
custom: { text: text.join("\n") },
})
for (const rect of page.segmentRects) {
annotationScope.createAnnotation<PdfLinkAnnoObject>(page.pageIndex, {
id: uuidV4(),
type: PdfAnnotationSubtype.LINK,
pageIndex: page.pageIndex,
rect,
inReplyToId: highlightId,
replyType: PdfAnnotationReplyType.Group,
target,
strokeStyle: PdfAnnotationBorderStyle.UNDERLINE,
strokeColor: "#2563eb",
strokeWidth: 2,
created: new Date(),
})
}
}
selectionScope?.clear()
notify("Link added.", "success")
onOpenChange(false)
}
const textTask = selectionScope?.getSelectedText()
if (textTask) {
textTask.wait(applyLinks, () => applyLinks([]))
} else {
applyLinks([])
}
}
const canSubmit = tab === "url" ? url.trim().length > 0 : pageNumber >= 1
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Add link</DialogTitle>
<DialogDescription>
{source === "selection"
? "The selected text becomes a clickable link."
: "The selected annotation becomes a clickable link."}
</DialogDescription>
</DialogHeader>
<InlineDialogPanel className="space-y-4">
<div className="flex items-center gap-1 rounded-lg bg-muted p-0.5">
{(
[
["url", "Web address"],
["page", "Page in document"],
] as Array<[LinkTab, string]>
).map(([id, label]) => (
<button
key={id}
type="button"
onClick={() => setTab(id)}
className={cn(
"flex-1 rounded-md px-3 py-1.5 text-sm font-medium transition-colors",
tab === id
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground"
)}
>
{label}
</button>
))}
</div>
{tab === "url" ? (
<div className="grid gap-1">
<PdfEditorFieldLabel htmlFor="pdf-editor-link-url">
URL
</PdfEditorFieldLabel>
<Input
id="pdf-editor-link-url"
autoFocus
placeholder="https://example.com"
value={url}
onChange={(event: React.ChangeEvent<HTMLInputElement>) =>
setUrl(event.target.value)
}
onKeyDown={(event: React.KeyboardEvent<HTMLInputElement>) => {
if (event.key === "Enter" && canSubmit) handleSubmit()
}}
/>
</div>
) : (
<div className="grid gap-1">
<PdfEditorFieldLabel htmlFor="pdf-editor-link-page">
Page number (1–{totalPages})
</PdfEditorFieldLabel>
<Input
id="pdf-editor-link-page"
type="number"
min={1}
max={totalPages}
value={pageNumber}
onChange={(event: React.ChangeEvent<HTMLInputElement>) =>
setPageNumber(Number(event.target.value) || 1)
}
/>
</div>
)}
</InlineDialogPanel>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
>
Cancel
</Button>
<Button type="button" disabled={!canSubmit} onClick={handleSubmit}>
Save link
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
/* -------------------------------------------------------------------------- */
/* Keyboard shortcuts */
/* -------------------------------------------------------------------------- */
export const PDF_EDITOR_SHORTCUTS: Array<{
group: string
items: Array<{
keys: string[]
label: string
}>
}> = [
{
group: "Document",
items: [
{ keys: ["mod+S"], label: "Download" },
{ keys: ["mod+P"], label: "Print" },
{ keys: ["mod+F"], label: "Search" },
{ keys: ["mod+Z"], label: "Undo" },
{ keys: ["mod+shift+Z"], label: "Redo" },
],
},
{
group: "View",
items: [
{ keys: ["mod+="], label: "Zoom in" },
{ keys: ["mod+-"], label: "Zoom out" },
{ keys: ["mod+0"], label: "Fit page" },
{ keys: ["H"], label: "Hand tool" },
{ keys: ["V"], label: "Select tool" },
],
},
{
group: "Editing",
items: [
{ keys: ["Esc"], label: "Cancel tool / clear selection" },
{ keys: ["Delete"], label: "Delete selected annotations" },
{ keys: ["mod+A"], label: "Select all annotations on the page" },
],
},
]
export function PdfEditorShortcutsDialog({
open,
onOpenChange,
}: {
open: boolean
onOpenChange: (open: boolean) => void
}) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Keyboard shortcuts</DialogTitle>
</DialogHeader>
<InlineDialogPanel className="space-y-4">
{PDF_EDITOR_SHORTCUTS.map((group) => (
<PdfEditorSection key={group.group} title={group.group}>
<div className="divide-y rounded-lg border">
{group.items.map((item) => (
<div
key={item.label}
className="flex items-center justify-between gap-3 px-3 py-1.5 text-sm"
>
<span>{item.label}</span>
<span className="flex items-center gap-1">
{item.keys.map((key) => (
<Kbd key={key}>{formatShortcut(key)}</Kbd>
))}
</span>
</div>
))}
</div>
</PdfEditorSection>
))}
</InlineDialogPanel>
<DialogFooter>
<Button type="button" onClick={() => onOpenChange(false)}>
Done
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
/* -------------------------------------------------------------------------- */
/* Password prompt (inline) */
/* -------------------------------------------------------------------------- */
export function PdfEditorPasswordPrompt({
documentState,
}: {
documentState: DocumentState
}) {
const { provides: documentManager } = useDocumentManagerCapability()
const [password, setPassword] = React.useState("")
const [isRetrying, setIsRetrying] = React.useState(false)
const isPasswordError = documentState.errorCode === PdfErrorCode.Password
const isIncorrect = isPasswordError && documentState.passwordProvided
const handleRetry = () => {
if (!documentManager || !password.trim()) return
setIsRetrying(true)
documentManager.retryDocument(documentState.id, { password }).wait(
() => {
setPassword("")
setIsRetrying(false)
},
() => setIsRetrying(false)
)
}
if (!isPasswordError) {
return (
<div className="grid h-full place-items-center p-6">
<div className="max-w-sm space-y-2 text-center">
<div className="text-sm font-medium text-foreground">
The document could not be opened
</div>
<div className="text-sm text-muted-foreground">
{documentState.error ?? "An unknown error occurred."}
</div>
</div>
</div>
)
}
return (
<div className="grid h-full place-items-center p-6">
<div className="w-full max-w-sm space-y-4 rounded-xl border bg-background p-5 shadow-sm">
<div className="flex items-start gap-3">
<div className="grid size-9 shrink-0 place-items-center rounded-full bg-muted text-muted-foreground">
<LockGlyph className="size-4" />
</div>
<div className="space-y-1">
<div className="text-sm font-medium text-foreground">
Password required
</div>
<div className="text-xs text-muted-foreground">
{isIncorrect
? "The password was incorrect. Try again."
: "This document is protected. Enter the password to open it."}
</div>
</div>
</div>
<PasswordInput
value={password}
autoFocus
placeholder="Document password"
onChange={setPassword}
onEnter={handleRetry}
/>
<div className="flex justify-end">
<InlineLoadingButton
type="button"
loading={isRetrying}
disabled={!password.trim()}
onClick={handleRetry}
>
Open document
</InlineLoadingButton>
</div>
</div>
</div>
)
}
export { ignore as ignorePdfTask }
function InlineDialogPanel({
className,
children,
...props
}: React.ComponentProps<"div">) {
return (
<ScrollArea className="min-h-0">
<div
data-slot="dialog-panel"
className={cn("min-h-0", className)}
{...props}
>
{children}
</div>
</ScrollArea>
)
}
function InlineLoadingButton({
loading,
disabled,
children,
className,
...props
}: React.ComponentProps<typeof Button> & {
loading?: boolean
}) {
return (
<Button
{...props}
className={cn("relative", className)}
disabled={disabled || loading}
aria-busy={loading || undefined}
>
<span className={cn("contents", loading && "invisible")}>{children}</span>
{loading ? (
<InlineSpinner className="pointer-events-none absolute" />
) : null}
</Button>
)
}
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/pdf-editor-properties.tsx
"use client"
import * as React from "react"
import {
blendModeLabel,
blendModeValues,
makeStandardFont,
PDF_FORM_FIELD_FLAG,
PDF_FORM_FIELD_TYPE,
PdfAnnotationBorderStyle,
PdfAnnotationLineEnding,
PdfAnnotationSubtype,
PdfStandardFont,
PdfStandardFontFamily,
PdfTextAlignment,
PdfVerticalAlignment,
STANDARD_FONT_FAMILIES,
standardFontFamily,
standardFontIsBold,
standardFontIsItalic,
type LineEndings,
type PdfAnnotationFlagName,
type PdfAnnotationObject,
type PdfTextWidgetAnnoField,
type PdfWidgetAnnoField,
type PdfWidgetAnnoObject,
type PdfWidgetAnnoOption,
} from "@embedpdf/models"
import {
getSelectedAnnotations,
useAnnotation,
useAnnotationCapability,
type AnnotationTool,
type TrackedAnnotation,
} from "@embedpdf/plugin-annotation/react"
import { useFormCapability } from "@embedpdf/plugin-form/react"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { Switch } from "@/components/ui/switch"
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"
import { ColorPicker } from "@/components/extend/document-color-picker"
import {
AlignBottomGlyph,
AlignCenterGlyph,
AlignLeftGlyph,
AlignMiddleGlyph,
AlignRightGlyph,
AlignTopGlyph,
BoldGlyph,
formatDateTime,
getAnnotationMeta,
ItalicGlyph,
PdfEditorEmptyState,
PdfEditorFieldLabel,
PdfEditorSection,
PdfEditorSwatch,
SlidersGlyph,
TrashGlyph,
usePdfEditor,
} from "@/components/extend/pdf-editor-shared"
/* -------------------------------------------------------------------------- */
/* Property schema */
/* -------------------------------------------------------------------------- */
type PropertyKind =
| "color"
| "colorWithTransparent"
| "opacity"
| "strokeWidth"
| "strokeStyle"
| "strokeStyleWithCloudy"
| "linkStrokeStyle"
| "lineEnding"
| "lineEndings"
| "fontFamily"
| "fontSize"
| "fontColor"
| "textAlign"
| "verticalAlign"
| "blendMode"
| "overlayText"
| "rotation"
type PropertyConfig = {
key: string
kind: PropertyKind
label: string
/** Only shown while editing an existing annotation, never for tool defaults. */
editOnly?: boolean
}
const PROPERTY_CONFIGS: Record<string, PropertyConfig> = {
color: { key: "color", kind: "colorWithTransparent", label: "Fill" },
strokeColor: {
key: "strokeColor",
kind: "colorWithTransparent",
label: "Stroke",
},
markupColor: { key: "strokeColor", kind: "color", label: "Color" },
opacity: { key: "opacity", kind: "opacity", label: "Opacity" },
strokeWidth: {
key: "strokeWidth",
kind: "strokeWidth",
label: "Stroke width",
},
strokeStyle: {
key: "strokeStyle",
kind: "strokeStyle",
label: "Border style",
},
strokeStyleWithCloudy: {
key: "strokeStyle",
kind: "strokeStyleWithCloudy",
label: "Border style",
},
linkStrokeStyle: {
key: "strokeStyle",
kind: "linkStrokeStyle",
label: "Border style",
},
lineEnding: {
key: "lineEnding",
kind: "lineEnding",
label: "Line ending",
},
lineEndings: {
key: "lineEndings",
kind: "lineEndings",
label: "Line endings",
},
fontFamily: { key: "fontFamily", kind: "fontFamily", label: "Font" },
fontSize: { key: "fontSize", kind: "fontSize", label: "Font size" },
fontColor: { key: "fontColor", kind: "fontColor", label: "Text color" },
textAlign: { key: "textAlign", kind: "textAlign", label: "Alignment" },
verticalAlign: {
key: "verticalAlign",
kind: "verticalAlign",
label: "Vertical alignment",
},
blendMode: { key: "blendMode", kind: "blendMode", label: "Blend mode" },
overlayText: {
key: "overlayText",
kind: "overlayText",
label: "Overlay text",
},
rotation: {
key: "rotation",
kind: "rotation",
label: "Rotation",
editOnly: true,
},
}
/** Ordered property lists keyed by EmbedPDF tool id. */
const TOOL_PROPERTIES: Record<string, string[]> = {
highlight: ["markupColor", "opacity", "blendMode"],
underline: ["markupColor", "opacity", "blendMode"],
strikeout: ["markupColor", "opacity", "blendMode"],
squiggly: ["markupColor", "opacity", "blendMode"],
ink: ["markupColor", "opacity", "strokeWidth", "rotation"],
inkHighlighter: [
"markupColor",
"opacity",
"strokeWidth",
"blendMode",
"rotation",
],
signatureInk: ["markupColor", "opacity", "strokeWidth", "rotation"],
circle: [
"color",
"opacity",
"strokeColor",
"strokeStyleWithCloudy",
"strokeWidth",
"rotation",
],
square: [
"color",
"opacity",
"strokeColor",
"strokeStyleWithCloudy",
"strokeWidth",
"rotation",
],
polygon: [
"strokeColor",
"opacity",
"strokeStyleWithCloudy",
"strokeWidth",
"color",
"rotation",
],
line: [
"strokeColor",
"opacity",
"strokeStyle",
"strokeWidth",
"lineEndings",
"color",
"rotation",
],
lineArrow: [
"strokeColor",
"opacity",
"strokeStyle",
"strokeWidth",
"lineEndings",
"color",
"rotation",
],
polyline: [
"strokeColor",
"opacity",
"strokeStyle",
"strokeWidth",
"lineEndings",
"color",
"rotation",
],
textComment: ["markupColor", "opacity"],
insertText: ["markupColor", "opacity"],
replaceText: ["markupColor", "opacity"],
freeText: [
"fontFamily",
"fontSize",
"fontColor",
"textAlign",
"verticalAlign",
"opacity",
"color",
"rotation",
],
freeTextCallout: [
"fontFamily",
"fontSize",
"fontColor",
"textAlign",
"verticalAlign",
"opacity",
"color",
"markupColor",
"strokeWidth",
"lineEnding",
],
stamp: ["rotation"],
rubberStamp: ["rotation"],
signatureStamp: ["rotation"],
redact: ["strokeColor", "color", "opacity", "overlayText"],
link: ["strokeColor", "strokeWidth", "linkStrokeStyle"],
formTextField: [
"fontSize",
"fontColor",
"strokeColor",
"strokeWidth",
"color",
],
formCheckbox: ["strokeColor", "color", "strokeWidth"],
formCombobox: [
"fontSize",
"fontColor",
"strokeColor",
"strokeWidth",
"color",
],
formListbox: [
"fontFamily",
"fontSize",
"fontColor",
"strokeColor",
"strokeWidth",
"color",
],
formRadioButton: ["strokeColor", "color", "strokeWidth"],
}
function getSharedProperties(toolIds: string[]) {
const unique = [...new Set(toolIds)]
if (unique.length === 0) return []
const first = new Set(TOOL_PROPERTIES[unique[0]] ?? [])
for (const id of unique.slice(1)) {
const own = new Set(TOOL_PROPERTIES[id] ?? [])
for (const property of first) {
if (!own.has(property)) first.delete(property)
}
}
return (TOOL_PROPERTIES[unique[0]] ?? []).filter((property) =>
first.has(property)
)
}
const FONT_SIZE_PRESETS = [
8, 9, 10, 11, 12, 14, 16, 18, 20, 24, 28, 32, 36, 48, 72,
]
const LINE_ENDING_OPTIONS: Array<{
value: PdfAnnotationLineEnding
label: string
}> = [
{ value: PdfAnnotationLineEnding.None, label: "None" },
{ value: PdfAnnotationLineEnding.OpenArrow, label: "Open arrow" },
{ value: PdfAnnotationLineEnding.ClosedArrow, label: "Closed arrow" },
{ value: PdfAnnotationLineEnding.ROpenArrow, label: "Reverse open arrow" },
{
value: PdfAnnotationLineEnding.RClosedArrow,
label: "Reverse closed arrow",
},
{ value: PdfAnnotationLineEnding.Square, label: "Square" },
{ value: PdfAnnotationLineEnding.Circle, label: "Circle" },
{ value: PdfAnnotationLineEnding.Diamond, label: "Diamond" },
{ value: PdfAnnotationLineEnding.Butt, label: "Butt" },
{ value: PdfAnnotationLineEnding.Slash, label: "Slash" },
]
const FLAG_OPTIONS: Array<{
flag: PdfAnnotationFlagName
label: string
description: string
}> = [
{
flag: "locked",
label: "Lock position",
description: "Prevents moving, resizing, and deleting.",
},
{
flag: "lockedContents",
label: "Lock contents",
description: "Prevents editing the text.",
},
{
flag: "print",
label: "Print",
description: "Include when printing.",
},
]
/* -------------------------------------------------------------------------- */
/* Controls */
/* -------------------------------------------------------------------------- */
function useDebouncedCommit<T>(
value: T,
onCommit: (value: T) => void,
delay = 150
) {
const [draft, setDraft] = React.useState(value)
const timeoutRef = React.useRef<number | null>(null)
const onCommitRef = React.useRef(onCommit)
React.useEffect(() => {
onCommitRef.current = onCommit
})
const [previousValue, setPreviousValue] = React.useState(value)
if (!Object.is(previousValue, value)) {
setPreviousValue(value)
setDraft(value)
}
React.useEffect(
() => () => {
if (timeoutRef.current !== null) window.clearTimeout(timeoutRef.current)
},
[]
)
const update = React.useCallback(
(next: T) => {
setDraft(next)
if (timeoutRef.current !== null) window.clearTimeout(timeoutRef.current)
timeoutRef.current = window.setTimeout(() => {
timeoutRef.current = null
onCommitRef.current(next)
}, delay)
},
[delay]
)
return [draft, update] as const
}
function PropertySwitch({
checked,
disabled,
label,
description,
onCheckedChange,
}: {
checked: boolean
disabled?: boolean
label: React.ReactNode
description?: React.ReactNode
onCheckedChange: (checked: boolean) => void
}) {
const id = React.useId()
return (
<label
htmlFor={id}
data-slot="label"
className={cn(
"flex items-center justify-between gap-4 rounded-md px-1 py-1",
disabled ? "cursor-not-allowed opacity-64" : "cursor-pointer"
)}
>
<span className="min-w-0">
<span className="block text-sm leading-4">{label}</span>
{description ? (
<span className="block text-xs text-muted-foreground">
{description}
</span>
) : null}
</span>
<Switch
id={id}
checked={checked}
disabled={disabled}
onCheckedChange={onCheckedChange}
/>
</label>
)
}
function ColorControl({
label,
value,
presets,
allowTransparent,
onChange,
}: {
label: string
value: string | undefined
presets: string[]
allowTransparent: boolean
onChange: (value: string) => void
}) {
const current = (value ?? "").toLowerCase()
const isTransparent = !value || current === "transparent"
const pickerSource = isTransparent ? "#111827" : (value ?? "#111827")
return (
<div className="space-y-1.5">
<div className="flex items-center justify-between">
<PdfEditorFieldLabel>{label}</PdfEditorFieldLabel>
<span className="text-[11px] text-muted-foreground tabular-nums">
{isTransparent ? "None" : current}
</span>
</div>
<div className="flex flex-wrap items-center gap-1.5">
{presets.map((preset) => (
<PdfEditorSwatch
key={preset}
color={preset}
active={!isTransparent && current === preset.toLowerCase()}
onClick={() => onChange(preset)}
/>
))}
{allowTransparent ? (
<PdfEditorSwatch
color="transparent"
label="No fill"
active={isTransparent}
onClick={() => onChange("transparent")}
/>
) : null}
<ColorPicker
label={`Custom ${label.toLowerCase()}`}
rainbowTrigger
color={pickerSource}
onChange={onChange}
/>
</div>
</div>
)
}
function SliderControl({
label,
value,
min,
max,
step,
format,
onChange,
}: {
label: string
value: number
min: number
max: number
step: number
format: (value: number) => string
onChange: (value: number) => void
}) {
const [draft, update] = useDebouncedCommit(value, onChange)
return (
<div className="space-y-1.5">
<div className="flex items-center justify-between">
<PdfEditorFieldLabel>{label}</PdfEditorFieldLabel>
<span className="text-[11px] text-muted-foreground tabular-nums">
{format(draft)}
</span>
</div>
<input
type="range"
min={min}
max={max}
step={step}
value={draft}
aria-label={label}
onChange={(event) => update(Number(event.target.value))}
className="h-1.5 w-full cursor-pointer appearance-none rounded-full bg-muted accent-primary"
/>
</div>
)
}
type StrokeStyleValue =
| "solid"
| "dotted"
| "short-dashed"
| "dashed"
| "long-dashed"
| "dash-dot"
| "long-dash-dot"
| "dash-dot-dot"
| "cloudy-1"
| "cloudy-2"
| "underline"
| "custom"
const STROKE_STYLE_PRESETS: Array<{
value: Exclude<
StrokeStyleValue,
"cloudy-1" | "cloudy-2" | "underline" | "custom"
>
label: string
dashArray?: number[]
}> = [
{ value: "solid", label: "Solid" },
{ value: "dotted", label: "Dotted", dashArray: [1, 2] },
{ value: "short-dashed", label: "Short dashed", dashArray: [2, 2] },
{ value: "dashed", label: "Dashed", dashArray: [4, 3] },
{ value: "long-dashed", label: "Long dashed", dashArray: [8, 4] },
{ value: "dash-dot", label: "Dash dot", dashArray: [6, 3, 1, 3] },
{
value: "long-dash-dot",
label: "Long dash dot",
dashArray: [10, 4, 1, 4],
},
{
value: "dash-dot-dot",
label: "Dash dot dot",
dashArray: [6, 3, 1, 3, 1, 3],
},
]
function getStrokeDashArray(value: StrokeStyleValue) {
return STROKE_STYLE_PRESETS.find((preset) => preset.value === value)
?.dashArray
}
function StrokeStylePreview({
value,
dashArray,
}: {
value: StrokeStyleValue
dashArray?: number[]
}) {
const path =
value === "cloudy-2"
? "M1 8 Q3 2 5 8 Q7 2 9 8 Q11 2 13 8 Q15 2 17 8 Q19 2 21 8 Q23 2 25 8 Q27 2 29 8 Q31 2 33 8 Q35 2 37 8 Q39 2 41 8 Q43 2 45 8 Q47 2 49 8 Q51 2 55 8"
: "M1 8 Q5 1 9 8 Q13 1 17 8 Q21 1 25 8 Q29 1 33 8 Q37 1 41 8 Q45 1 49 8 Q53 1 55 8"
const resolvedDashArray = dashArray ?? getStrokeDashArray(value)
return (
<svg
aria-hidden="true"
viewBox="0 0 56 12"
className="h-6! w-24! shrink-0 text-foreground"
>
<path
d={value.startsWith("cloudy") ? path : "M1 6H55"}
fill="none"
stroke="currentColor"
strokeDasharray={resolvedDashArray?.join(" ")}
strokeLinecap="round"
strokeWidth="2.25"
/>
</svg>
)
}
function SelectControl({
label,
value,
options,
onChange,
}: {
label: string
value: string
options: Array<{
value: string
label: string
preview?: React.ReactNode
}>
onChange: (value: string) => void
}) {
const selectedOption =
options.find((option) => option.value === value) ?? null
return (
<div className="space-y-1.5">
<PdfEditorFieldLabel>{label}</PdfEditorFieldLabel>
<Select
items={options}
value={value}
onValueChange={(next) => {
if (next) onChange(String(next))
}}
>
<SelectTrigger size="sm" className="w-full">
<SelectValue placeholder={label}>
{selectedOption ? (
<span className="grid w-full min-w-0 grid-cols-[minmax(0,1fr)_auto] items-center gap-3">
<span className="truncate">{selectedOption.label}</span>
{selectedOption.preview}
</span>
) : (
label
)}
</SelectValue>
</SelectTrigger>
<SelectContent alignItemWithTrigger={false}>
{options.map((option) => (
<SelectItem
key={option.value}
value={option.value}
className={cn(option.preview && "py-0.5 pe-2")}
>
<span className="grid w-full min-w-0 grid-cols-[minmax(0,1fr)_auto] items-center gap-3">
<span className="truncate">{option.label}</span>
{option.preview}
</span>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)
}
function getStrokeStyleValue(
strokeStyle: PdfAnnotationBorderStyle | undefined,
cloudyBorderIntensity: number | undefined,
strokeDashArray: number[] | undefined
): StrokeStyleValue {
if (
strokeStyle === PdfAnnotationBorderStyle.CLOUDY ||
(cloudyBorderIntensity ?? 0) > 0
) {
return (cloudyBorderIntensity ?? 1) >= 2 ? "cloudy-2" : "cloudy-1"
}
if (strokeStyle === PdfAnnotationBorderStyle.UNDERLINE) return "underline"
if (strokeStyle === PdfAnnotationBorderStyle.DASHED) {
const preset = STROKE_STYLE_PRESETS.find((item) => {
if (!item.dashArray || !strokeDashArray) return false
if (item.dashArray.length !== strokeDashArray.length) return false
return item.dashArray.every(
(part, index) => part === strokeDashArray[index]
)
})
return preset?.value ?? "custom"
}
return "solid"
}
function getStrokeStylePatch(
value: StrokeStyleValue,
customDashArray?: number[]
) {
const dashArray = getStrokeDashArray(value)
if (dashArray || value === "custom") {
return {
strokeStyle: PdfAnnotationBorderStyle.DASHED,
strokeDashArray: dashArray ?? customDashArray ?? [4, 3],
cloudyBorderIntensity: 0,
}
}
switch (value) {
case "underline":
return {
strokeStyle: PdfAnnotationBorderStyle.UNDERLINE,
strokeDashArray: undefined,
cloudyBorderIntensity: 0,
}
case "cloudy-1":
return {
strokeStyle: PdfAnnotationBorderStyle.CLOUDY,
strokeDashArray: undefined,
cloudyBorderIntensity: 1,
}
case "cloudy-2":
return {
strokeStyle: PdfAnnotationBorderStyle.CLOUDY,
strokeDashArray: undefined,
cloudyBorderIntensity: 2,
}
default:
return {
strokeStyle: PdfAnnotationBorderStyle.SOLID,
strokeDashArray: undefined,
cloudyBorderIntensity: 0,
}
}
}
function FontControl({
value,
onChange,
}: {
value: PdfStandardFont | undefined
onChange: (value: PdfStandardFont) => void
}) {
const font = value ?? PdfStandardFont.Helvetica
const family = standardFontFamily(font)
const bold = standardFontIsBold(font)
const italic = standardFontIsItalic(font)
const supportsStyles =
family !== PdfStandardFontFamily.Symbol &&
family !== PdfStandardFontFamily.ZapfDingbats
const familyOptions = STANDARD_FONT_FAMILIES.filter(
(item) => item !== PdfStandardFontFamily.Unknown
).map((item) => ({ label: item, value: item }))
const apply = (
nextFamily: PdfStandardFontFamily,
nextBold: boolean,
nextItalic: boolean
) => {
const next =
makeStandardFont(nextFamily, { bold: nextBold, italic: nextItalic }) ??
makeStandardFont(nextFamily, { bold: false, italic: false })
if (next !== undefined && next !== PdfStandardFont.Unknown) onChange(next)
}
return (
<div className="space-y-1.5">
<PdfEditorFieldLabel>Font</PdfEditorFieldLabel>
<div className="flex items-center gap-1.5">
<Select
items={familyOptions}
value={family}
onValueChange={(next) =>
apply(String(next) as PdfStandardFontFamily, bold, italic)
}
>
<SelectTrigger size="sm" className="min-w-0 flex-1">
<SelectValue placeholder="Font" />
</SelectTrigger>
<SelectContent alignItemWithTrigger={false}>
{familyOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
<ToggleGroup
multiple
value={[bold ? "bold" : "", italic ? "italic" : ""].filter(Boolean)}
onValueChange={(next: string[]) =>
apply(family, next.includes("bold"), next.includes("italic"))
}
className="shrink-0"
spacing={0}
>
<ToggleGroupItem
value="bold"
size="sm"
variant="outline"
aria-label="Bold"
disabled={!supportsStyles}
>
<BoldGlyph className="size-3.5" />
</ToggleGroupItem>
<ToggleGroupItem
value="italic"
size="sm"
variant="outline"
aria-label="Italic"
disabled={!supportsStyles}
>
<ItalicGlyph className="size-3.5" />
</ToggleGroupItem>
</ToggleGroup>
</div>
</div>
)
}
function FontSizeControl({
value,
onChange,
}: {
value: number | undefined
onChange: (value: number) => void
}) {
const [draft, update] = useDebouncedCommit(value ?? 12, onChange, 250)
const sizeOptions = (
FONT_SIZE_PRESETS.includes(draft)
? FONT_SIZE_PRESETS
: [...FONT_SIZE_PRESETS, draft].sort((a, b) => a - b)
).map((size) => ({ label: `${size} pt`, value: String(size) }))
return (
<div className="space-y-1.5">
<PdfEditorFieldLabel>Font size</PdfEditorFieldLabel>
<div className="flex items-center gap-1.5">
<Input
type="number"
min={4}
max={200}
value={draft}
onChange={(event: React.ChangeEvent<HTMLInputElement>) => {
const next = Number(event.target.value)
if (Number.isFinite(next) && next > 0) update(next)
}}
className={cn("h-8 px-2.5", "w-20")}
/>
<Select
items={sizeOptions}
value={String(draft)}
onValueChange={(next) => onChange(Number(next))}
>
<SelectTrigger size="sm" className="min-w-0 flex-1">
<SelectValue placeholder="Size" />
</SelectTrigger>
<SelectContent alignItemWithTrigger={false}>
{sizeOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
)
}
function AlignControl({
label,
value,
onChange,
options,
}: {
label: string
value: string
onChange: (value: string) => void
options: Array<{
value: string
label: string
glyph: React.ReactNode
}>
}) {
return (
<div className="space-y-1.5">
<PdfEditorFieldLabel>{label}</PdfEditorFieldLabel>
<ToggleGroup
value={[value]}
onValueChange={(next: string[]) => {
const selected = next[0]
if (selected !== undefined) onChange(selected)
}}
spacing={0}
>
{options.map((option) => (
<ToggleGroupItem
key={option.value}
value={option.value}
size="sm"
variant="outline"
aria-label={option.label}
>
{option.glyph}
</ToggleGroupItem>
))}
</ToggleGroup>
</div>
)
}
function LineEndingPreview({ value }: { value: PdfAnnotationLineEnding }) {
const isOpenArrow =
value === PdfAnnotationLineEnding.OpenArrow ||
value === PdfAnnotationLineEnding.ROpenArrow
const isClosedArrow =
value === PdfAnnotationLineEnding.ClosedArrow ||
value === PdfAnnotationLineEnding.RClosedArrow
const isReverse =
value === PdfAnnotationLineEnding.ROpenArrow ||
value === PdfAnnotationLineEnding.RClosedArrow
const lineEndX = isReverse
? isClosedArrow
? 58
: 56
: isClosedArrow
? 68
: 66
return (
<svg
aria-hidden="true"
viewBox="0 0 72 16"
className="h-6! w-24! shrink-0 text-foreground"
>
<path
d={`M2 8H${lineEndX}`}
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeWidth="2.5"
/>
{isOpenArrow ? (
<path
d={isReverse ? "M56 8 66 2M56 8l10 6" : "M66 8 56 2M66 8l-10 6"}
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2.5"
/>
) : null}
{isClosedArrow ? (
<path
d={isReverse ? "m58 8 10-6v12Z" : "m68 8-10-6v12Z"}
fill="currentColor"
/>
) : null}
{value === PdfAnnotationLineEnding.Square ? (
<rect x="60" y="2" width="12" height="12" fill="currentColor" />
) : null}
{value === PdfAnnotationLineEnding.Circle ? (
<circle cx="66" cy="8" r="6" fill="currentColor" />
) : null}
{value === PdfAnnotationLineEnding.Diamond ? (
<path d="m66 2 6 6-6 6-6-6Z" fill="currentColor" />
) : null}
{value === PdfAnnotationLineEnding.Butt ? (
<path d="M66 2v12" stroke="currentColor" strokeWidth="2.5" />
) : null}
{value === PdfAnnotationLineEnding.Slash ? (
<path d="m60 14 12-12" stroke="currentColor" strokeWidth="2.5" />
) : null}
</svg>
)
}
function LineEndingsControl({
value,
onChange,
}: {
value: LineEndings | undefined
onChange: (value: LineEndings) => void
}) {
const start = value?.start ?? PdfAnnotationLineEnding.None
const end = value?.end ?? PdfAnnotationLineEnding.None
const options = LINE_ENDING_OPTIONS.map((option) => ({
value: String(option.value),
label: option.label,
preview: <LineEndingPreview value={option.value} />,
}))
return (
<div className="space-y-2">
<SelectControl
label="Line start"
value={String(start)}
options={options}
onChange={(next) =>
onChange({ start: Number(next) as PdfAnnotationLineEnding, end })
}
/>
<SelectControl
label="Line end"
value={String(end)}
options={options}
onChange={(next) =>
onChange({ start, end: Number(next) as PdfAnnotationLineEnding })
}
/>
</div>
)
}
function LineEndingControl({
label,
value,
onChange,
}: {
label: string
value: PdfAnnotationLineEnding | undefined
onChange: (value: PdfAnnotationLineEnding) => void
}) {
const options = LINE_ENDING_OPTIONS.map((option) => ({
value: String(option.value),
label: option.label,
preview: <LineEndingPreview value={option.value} />,
}))
return (
<SelectControl
label={label}
value={String(value ?? PdfAnnotationLineEnding.None)}
options={options}
onChange={(next) => onChange(Number(next) as PdfAnnotationLineEnding)}
/>
)
}
function TextControl({
label,
value,
placeholder,
onChange,
}: {
label: string
value: string
placeholder?: string
onChange: (value: string) => void
}) {
const [draft, update] = useDebouncedCommit(value, onChange, 400)
return (
<div className="space-y-1.5">
<PdfEditorFieldLabel>{label}</PdfEditorFieldLabel>
<Input
value={draft}
placeholder={placeholder}
onChange={(event: React.ChangeEvent<HTMLInputElement>) =>
update(event.target.value)
}
className={cn("h-8 px-2.5")}
/>
</div>
)
}
function RotationControl({
value,
onChange,
}: {
value: number
onChange: (value: number) => void
}) {
const [draft, update] = useDebouncedCommit(value, onChange, 250)
return (
<div className="space-y-1.5">
<PdfEditorFieldLabel>Rotation</PdfEditorFieldLabel>
<div className="flex items-center gap-1.5">
<Input
type="number"
min={0}
max={359}
value={Math.round(draft)}
onChange={(event: React.ChangeEvent<HTMLInputElement>) =>
update(((Number(event.target.value) % 360) + 360) % 360)
}
className={cn("h-8 px-2.5", "w-20")}
/>
<span className="text-xs text-muted-foreground">degrees</span>
{[0, 90, 180, 270].map((preset) => (
<Button
key={preset}
type="button"
size="xs"
variant={draft === preset ? "secondary" : "ghost"}
onClick={() => {
update(preset)
}}
>
{preset}°
</Button>
))}
</div>
</div>
)
}
/* -------------------------------------------------------------------------- */
/* Property list */
/* -------------------------------------------------------------------------- */
function PropertyControl({
config,
values,
colorPresets,
onPatch,
onRotate,
}: {
config: PropertyConfig
values: Record<string, unknown>
colorPresets: string[]
onPatch: (patch: Record<string, unknown>) => void
onRotate?: (rotation: number) => void
}) {
const value = values[config.key]
switch (config.kind) {
case "color":
case "colorWithTransparent":
case "fontColor":
return (
<ColorControl
label={config.label}
value={typeof value === "string" ? value : undefined}
presets={colorPresets}
allowTransparent={config.kind === "colorWithTransparent"}
onChange={(next) => onPatch({ [config.key]: next })}
/>
)
case "opacity":
return (
<SliderControl
label={config.label}
value={typeof value === "number" ? value : 1}
min={0}
max={1}
step={0.05}
format={(item) => `${Math.round(item * 100)}%`}
onChange={(next) => onPatch({ [config.key]: next })}
/>
)
case "strokeWidth":
return (
<SliderControl
label={config.label}
value={typeof value === "number" ? value : 1}
min={1}
max={30}
step={1}
format={(item) => `${item} pt`}
onChange={(next) => onPatch({ [config.key]: next })}
/>
)
case "strokeStyle":
case "strokeStyleWithCloudy":
case "linkStrokeStyle": {
const strokeDashArray = values.strokeDashArray as number[] | undefined
const selectedValue = getStrokeStyleValue(
value as PdfAnnotationBorderStyle | undefined,
values.cloudyBorderIntensity as number | undefined,
strokeDashArray
)
const options: Array<{
value: string
label: string
preview: React.ReactNode
}> = STROKE_STYLE_PRESETS.map((preset) => ({
value: preset.value,
label: preset.label,
preview: (
<StrokeStylePreview
value={preset.value}
dashArray={preset.dashArray}
/>
),
}))
if (config.kind === "linkStrokeStyle") {
options.splice(1, 0, {
value: "underline",
label: "Underline",
preview: <StrokeStylePreview value="underline" />,
})
}
if (config.kind === "strokeStyleWithCloudy") {
options.push(
{
value: "cloudy-1",
label: "Cloudy",
preview: <StrokeStylePreview value="cloudy-1" />,
},
{
value: "cloudy-2",
label: "Cloudy (dense)",
preview: <StrokeStylePreview value="cloudy-2" />,
}
)
}
if (selectedValue === "custom") {
options.push({
value: "custom",
label: "Custom dashed",
preview: (
<StrokeStylePreview value="custom" dashArray={strokeDashArray} />
),
})
}
return (
<SelectControl
label={config.label}
value={selectedValue}
options={options}
onChange={(next) =>
onPatch(
getStrokeStylePatch(next as StrokeStyleValue, strokeDashArray)
)
}
/>
)
}
case "lineEnding":
return (
<LineEndingControl
label={config.label}
value={value as PdfAnnotationLineEnding | undefined}
onChange={(next) => onPatch({ [config.key]: next })}
/>
)
case "lineEndings":
return (
<LineEndingsControl
value={value as LineEndings | undefined}
onChange={(next) => onPatch({ lineEndings: next })}
/>
)
case "fontFamily":
return (
<FontControl
value={value as PdfStandardFont | undefined}
onChange={(next) => onPatch({ fontFamily: next })}
/>
)
case "fontSize":
return (
<FontSizeControl
value={typeof value === "number" ? value : undefined}
onChange={(next) => onPatch({ fontSize: next })}
/>
)
case "textAlign":
return (
<AlignControl
label={config.label}
value={String(value ?? PdfTextAlignment.Left)}
onChange={(next) => onPatch({ textAlign: Number(next) })}
options={[
{
value: String(PdfTextAlignment.Left),
label: "Align left",
glyph: <AlignLeftGlyph className="size-3.5" />,
},
{
value: String(PdfTextAlignment.Center),
label: "Align center",
glyph: <AlignCenterGlyph className="size-3.5" />,
},
{
value: String(PdfTextAlignment.Right),
label: "Align right",
glyph: <AlignRightGlyph className="size-3.5" />,
},
]}
/>
)
case "verticalAlign":
return (
<AlignControl
label={config.label}
value={String(value ?? PdfVerticalAlignment.Top)}
onChange={(next) => onPatch({ verticalAlign: Number(next) })}
options={[
{
value: String(PdfVerticalAlignment.Top),
label: "Align top",
glyph: <AlignTopGlyph className="size-3.5" />,
},
{
value: String(PdfVerticalAlignment.Middle),
label: "Align middle",
glyph: <AlignMiddleGlyph className="size-3.5" />,
},
{
value: String(PdfVerticalAlignment.Bottom),
label: "Align bottom",
glyph: <AlignBottomGlyph className="size-3.5" />,
},
]}
/>
)
case "blendMode":
return (
<SelectControl
label={config.label}
value={String(value ?? 0)}
options={blendModeValues.map((mode) => ({
value: String(mode),
label: blendModeLabel(mode),
}))}
onChange={(next) => onPatch({ blendMode: Number(next) })}
/>
)
case "overlayText":
return (
<TextControl
label={config.label}
value={typeof value === "string" ? value : ""}
placeholder="e.g. REDACTED"
onChange={(next) => onPatch({ overlayText: next })}
/>
)
case "rotation":
return (
<RotationControl
value={typeof value === "number" ? value : 0}
onChange={(next) =>
onRotate ? onRotate(next) : onPatch({ rotation: next })
}
/>
)
default:
return null
}
}
/* -------------------------------------------------------------------------- */
/* Widget (form field) properties */
/* -------------------------------------------------------------------------- */
const FIELD_TYPE_LABELS: Partial<Record<PDF_FORM_FIELD_TYPE, string>> = {
[PDF_FORM_FIELD_TYPE.TEXTFIELD]: "Text field",
[PDF_FORM_FIELD_TYPE.CHECKBOX]: "Checkbox",
[PDF_FORM_FIELD_TYPE.RADIOBUTTON]: "Radio button",
[PDF_FORM_FIELD_TYPE.COMBOBOX]: "Dropdown",
[PDF_FORM_FIELD_TYPE.LISTBOX]: "List box",
[PDF_FORM_FIELD_TYPE.PUSHBUTTON]: "Button",
[PDF_FORM_FIELD_TYPE.SIGNATURE]: "Signature field",
}
export function getFormFieldTypeLabel(type: PDF_FORM_FIELD_TYPE) {
return FIELD_TYPE_LABELS[type] ?? "Form field"
}
function WidgetProperties({
documentId,
widget,
}: {
documentId: string
widget: PdfWidgetAnnoObject
}) {
const { provides: annotation } = useAnnotation(documentId)
const { provides: form } = useFormCapability()
const { openDialog, notify } = usePdfEditor()
const [nameDraft, setNameDraft] = React.useState(widget.field.name)
const [nameError, setNameError] = React.useState<string | null>(null)
const field = widget.field
const fieldLabel = getFormFieldTypeLabel(field.type)
const [previousField, setPreviousField] = React.useState({
id: widget.id,
name: widget.field.name,
})
if (
previousField.id !== widget.id ||
previousField.name !== widget.field.name
) {
setPreviousField({ id: widget.id, name: widget.field.name })
setNameDraft(widget.field.name)
setNameError(null)
}
const updateField = React.useCallback(
(patch: Partial<PdfWidgetAnnoField>) => {
annotation?.updateAnnotations([
{
pageIndex: widget.pageIndex,
id: widget.id,
patch: { field: { ...widget.field, ...patch } as PdfWidgetAnnoField },
},
])
},
[annotation, widget.field, widget.id, widget.pageIndex]
)
const toggleFlag = (flag: PDF_FORM_FIELD_FLAG, enabled: boolean) => {
const current = field.flag ?? PDF_FORM_FIELD_FLAG.NONE
updateField({ flag: enabled ? current | flag : current & ~flag })
}
const commitName = () => {
const nextName = nameDraft.trim()
if (!form || !nextName || nextName === field.name) {
setNameDraft(field.name)
return
}
form.renameField(widget.id, nextName, documentId).wait(
(result) => {
setNameError(null)
if (result.outcome === "conflict") {
openDialog({
type: "confirm",
title: "Share this field?",
description: `A ${fieldLabel.toLowerCase()} named "${result.fieldName}" already exists. Sharing makes both widgets show the same value.`,
confirmLabel: "Share field",
onConfirm: () => {
form
.shareField(widget.id, result.targetAnnotationId, documentId)
.wait(
() => notify("Field shared.", "success"),
(failure) => {
setNameError(failure.reason.message)
setNameDraft(field.name)
}
)
},
})
}
},
(failure) => {
setNameError(failure.reason.message)
setNameDraft(field.name)
}
)
}
const options: PdfWidgetAnnoOption[] =
field.type === PDF_FORM_FIELD_TYPE.COMBOBOX ||
field.type === PDF_FORM_FIELD_TYPE.LISTBOX
? ((
field as {
options?: PdfWidgetAnnoOption[]
}
).options ?? [])
: []
const setOptions = (next: PdfWidgetAnnoOption[]) =>
updateField({ options: next } as Partial<PdfWidgetAnnoField>)
const isTextField = field.type === PDF_FORM_FIELD_TYPE.TEXTFIELD
const textField = field as PdfTextWidgetAnnoField
const maxLength = isTextField ? (textField.maxLen ?? 0) : 0
const isComb = Boolean(field.flag & PDF_FORM_FIELD_FLAG.TEXT_COMB)
return (
<PdfEditorSection title={`${fieldLabel} settings`}>
<div className="space-y-3">
<div className="space-y-1.5">
<PdfEditorFieldLabel htmlFor={`pdf-editor-field-name-${widget.id}`}>
Field name
</PdfEditorFieldLabel>
<Input
id={`pdf-editor-field-name-${widget.id}`}
value={nameDraft}
onChange={(event: React.ChangeEvent<HTMLInputElement>) =>
setNameDraft(event.target.value)
}
onBlur={commitName}
onKeyDown={(event: React.KeyboardEvent<HTMLInputElement>) => {
if (event.key === "Enter") event.currentTarget.blur()
}}
className={cn("h-8 px-2.5")}
/>
{nameError ? (
<div className="text-xs text-destructive">{nameError}</div>
) : null}
</div>
{isTextField ? (
<>
<TextControl
label="Default value"
value={field.value ?? ""}
onChange={(next) => updateField({ value: next })}
/>
<div className="space-y-1.5">
<PdfEditorFieldLabel>Max length</PdfEditorFieldLabel>
<Input
type="number"
min={0}
placeholder="Unlimited"
value={maxLength || ""}
onChange={(event: React.ChangeEvent<HTMLInputElement>) => {
const parsed = Number.parseInt(event.target.value, 10)
const next =
Number.isNaN(parsed) || parsed <= 0 ? undefined : parsed
updateField({ maxLen: next } as Partial<PdfWidgetAnnoField>)
if (!next && isComb)
toggleFlag(PDF_FORM_FIELD_FLAG.TEXT_COMB, false)
}}
className={cn("h-8 px-2.5")}
/>
</div>
</>
) : null}
{options.length > 0 ||
field.type === PDF_FORM_FIELD_TYPE.COMBOBOX ||
field.type === PDF_FORM_FIELD_TYPE.LISTBOX ? (
<div className="space-y-1.5">
<PdfEditorFieldLabel>Options</PdfEditorFieldLabel>
<div className="space-y-1.5">
{options.map((option, index) => (
<div key={index} className="flex items-center gap-1.5">
<Input
defaultValue={option.label}
onBlur={(event: React.FocusEvent<HTMLInputElement>) => {
const nextLabel = event.target.value
if (nextLabel === option.label) return
setOptions(
options.map((item, itemIndex) =>
itemIndex === index
? { ...item, label: nextLabel }
: item
)
)
}}
onKeyDown={(
event: React.KeyboardEvent<HTMLInputElement>
) => {
if (event.key === "Enter") event.currentTarget.blur()
}}
className={cn("h-8 px-2.5")}
/>
<Button
type="button"
variant="ghost"
size="icon-xs"
aria-label="Remove option"
onClick={() =>
setOptions(
options.filter((_, itemIndex) => itemIndex !== index)
)
}
>
<TrashGlyph className="size-3.5" />
</Button>
</div>
))}
<Button
type="button"
variant="outline"
size="xs"
onClick={() =>
setOptions([
...options,
{
label: `Option ${options.length + 1}`,
isSelected: false,
},
])
}
>
Add option
</Button>
</div>
</div>
) : null}
<div className="space-y-0.5">
<PropertySwitch
checked={Boolean(field.flag & PDF_FORM_FIELD_FLAG.READONLY)}
onCheckedChange={(checked) =>
toggleFlag(PDF_FORM_FIELD_FLAG.READONLY, checked)
}
label="Read only"
/>
<PropertySwitch
checked={Boolean(field.flag & PDF_FORM_FIELD_FLAG.REQUIRED)}
onCheckedChange={(checked) =>
toggleFlag(PDF_FORM_FIELD_FLAG.REQUIRED, checked)
}
label="Required"
/>
{isTextField ? (
<>
<PropertySwitch
checked={Boolean(
field.flag & PDF_FORM_FIELD_FLAG.TEXT_MULTIPLINE
)}
onCheckedChange={(checked) =>
toggleFlag(PDF_FORM_FIELD_FLAG.TEXT_MULTIPLINE, checked)
}
label="Multiline"
/>
<PropertySwitch
checked={isComb}
disabled={maxLength <= 0}
onCheckedChange={(checked) =>
toggleFlag(PDF_FORM_FIELD_FLAG.TEXT_COMB, checked)
}
label="Comb of characters"
description="Requires a max length."
/>
</>
) : null}
{field.type === PDF_FORM_FIELD_TYPE.LISTBOX ? (
<PropertySwitch
checked={Boolean(
field.flag & PDF_FORM_FIELD_FLAG.CHOICE_MULTL_SELECT
)}
onCheckedChange={(checked) =>
toggleFlag(PDF_FORM_FIELD_FLAG.CHOICE_MULTL_SELECT, checked)
}
label="Allow multiple selections"
/>
) : null}
</div>
</div>
</PdfEditorSection>
)
}
/* -------------------------------------------------------------------------- */
/* Panel */
/* -------------------------------------------------------------------------- */
export function PdfEditorPropertiesPanel({
documentId,
}: {
documentId: string
}) {
const { provides: annotationCapability } = useAnnotationCapability()
const { provides: annotation, state } = useAnnotation(documentId)
const { permissions } = usePdfEditor()
const [toolSnapshot, setToolSnapshot] = React.useState<{
source: typeof annotationCapability
tools: AnnotationTool[]
} | null>(null)
React.useEffect(() => {
if (!annotationCapability) return
return annotationCapability.onToolsChange(({ tools }) =>
setToolSnapshot({ source: annotationCapability, tools })
)
}, [annotationCapability])
const selected = React.useMemo(() => getSelectedAnnotations(state), [state])
const tools =
toolSnapshot?.source === annotationCapability
? toolSnapshot.tools
: (annotationCapability?.getTools() ?? [])
const activeTool: AnnotationTool | null =
state.activeToolId && annotationCapability
? (tools.find((tool) => tool.id === state.activeToolId) ?? null)
: null
const colorPresets = annotationCapability?.getColorPresets() ?? []
const selectedToolIds = React.useMemo(
() =>
selected
.map(
(item) => annotationCapability?.findToolForAnnotation(item.object)?.id
)
.filter((id): id is string => Boolean(id)),
[annotationCapability, selected]
)
const isEditing = selected.length > 0
const propertyKeys = isEditing
? getSharedProperties(selectedToolIds)
: activeTool
? (TOOL_PROPERTIES[activeTool.id] ?? [])
: []
const configs = propertyKeys
.map((key) => PROPERTY_CONFIGS[key])
.filter((config): config is PropertyConfig => Boolean(config))
.filter((config) => isEditing || !config.editOnly)
const values = React.useMemo<Record<string, unknown>>(() => {
if (isEditing)
return selected[0].object as unknown as Record<string, unknown>
if (activeTool)
return activeTool.defaults as unknown as Record<string, unknown>
return {}
}, [activeTool, isEditing, selected])
const canEdit = permissions.canModifyAnnotations
const structurallyLocked = selected.some((item) =>
annotation ? annotation.isAnnotationStructurallyLocked(item.object) : false
)
const applyPatch = React.useCallback(
(patch: Record<string, unknown>) => {
if (isEditing) {
annotation?.updateAnnotations(
selected.map((item) => ({
pageIndex: item.object.pageIndex,
id: item.object.id,
patch: {
...patch,
modified: new Date(),
} as Partial<PdfAnnotationObject>,
}))
)
return
}
if (activeTool && annotationCapability) {
annotationCapability.setToolDefaults(
activeTool.id,
patch as Partial<PdfAnnotationObject> & Record<string, unknown>
)
}
},
[activeTool, annotation, annotationCapability, isEditing, selected]
)
const applyRotation = React.useCallback(
(rotation: number) => {
if (!annotation || !annotationCapability) return
for (const item of selected) {
const current = item.object.rotation ?? 0
let patch: Partial<PdfAnnotationObject>
try {
patch = annotationCapability.transformAnnotation(item.object, {
type: "rotate",
changes: { rotation },
metadata: {
rotationAngle: rotation,
rotationDelta: rotation - current,
},
})
} catch {
patch = { rotation }
}
annotation.updateAnnotation(item.object.pageIndex, item.object.id, {
...patch,
modified: new Date(),
})
}
},
[annotation, annotationCapability, selected]
)
const single: TrackedAnnotation | null =
selected.length === 1 ? selected[0] : null
const singleMeta = single ? getAnnotationMeta(single.object) : null
const isWidget = single?.object.type === PdfAnnotationSubtype.WIDGET
const title = isEditing
? selected.length > 1
? `${selected.length} annotations`
: (singleMeta?.label ?? "Annotation")
: activeTool
? `${activeTool.name} defaults`
: "Properties"
if (!isEditing && !activeTool) {
return (
<PdfEditorEmptyState
glyph={SlidersGlyph}
title="Nothing selected"
description="Select an annotation on the page, or pick a tool to edit its default style."
/>
)
}
const flags = single?.object.flags ?? []
const contents =
single && !isWidget && typeof single.object.contents === "string"
? single.object.contents
: ""
const verticalAlignmentConfig = configs.find(
(config) => config.kind === "verticalAlign"
)
return (
<div className="space-y-5 p-3">
<div>
<div className="text-sm font-medium text-foreground">{title}</div>
<div className="text-xs text-muted-foreground">
{isEditing
? structurallyLocked
? "This annotation is locked. Unlock it to make changes."
: canEdit
? "Changes apply immediately."
: "This document does not allow annotation changes."
: "New annotations use these settings."}
</div>
</div>
{configs.length > 0 ? (
<PdfEditorSection title="Appearance">
<fieldset
disabled={isEditing && (!canEdit || structurallyLocked)}
className="space-y-4 disabled:opacity-60"
>
{configs.map((config) => {
if (
config.kind === "verticalAlign" &&
configs.some((item) => item.kind === "textAlign")
) {
return null
}
if (config.kind === "textAlign" && verticalAlignmentConfig) {
return (
<div
key="alignment-controls"
className="grid grid-cols-2 items-start gap-3"
>
<PropertyControl
config={config}
values={values}
colorPresets={colorPresets}
onPatch={applyPatch}
onRotate={isEditing ? applyRotation : undefined}
/>
<PropertyControl
config={verticalAlignmentConfig}
values={values}
colorPresets={colorPresets}
onPatch={applyPatch}
onRotate={isEditing ? applyRotation : undefined}
/>
</div>
)
}
return (
<PropertyControl
key={config.key + config.kind}
config={config}
values={values}
colorPresets={colorPresets}
onPatch={applyPatch}
onRotate={isEditing ? applyRotation : undefined}
/>
)
})}
</fieldset>
</PdfEditorSection>
) : null}
{single && isWidget ? (
<WidgetProperties
documentId={documentId}
widget={single.object as PdfWidgetAnnoObject}
/>
) : null}
{single && !isWidget ? (
<PdfEditorSection title="Note">
<NoteEditor
key={single.object.id}
value={contents}
disabled={!canEdit}
onChange={(next) =>
annotation?.updateAnnotation(
single.object.pageIndex,
single.object.id,
{
contents: next,
modified: new Date(),
}
)
}
/>
</PdfEditorSection>
) : null}
{isEditing ? (
<PdfEditorSection title="Behavior">
<div className="space-y-0.5">
{FLAG_OPTIONS.map((option) => {
const checked = selected.every((item) =>
(item.object.flags ?? []).includes(option.flag)
)
return (
<PropertySwitch
key={option.flag}
checked={checked}
disabled={!canEdit}
onCheckedChange={(next) => {
annotation?.updateAnnotations(
selected.map((item) => {
const current = item.object.flags ?? []
const nextFlags = next
? current.includes(option.flag)
? current
: [...current, option.flag]
: current.filter((flag) => flag !== option.flag)
return {
pageIndex: item.object.pageIndex,
id: item.object.id,
patch: { flags: nextFlags },
}
})
)
}}
label={option.label}
description={option.description}
/>
)
})}
</div>
</PdfEditorSection>
) : null}
{single ? (
<PdfEditorSection title="Details">
<dl className="grid grid-cols-[auto_1fr] gap-x-3 gap-y-1 text-xs">
<dt className="text-muted-foreground">Type</dt>
<dd>{singleMeta?.label}</dd>
<dt className="text-muted-foreground">Page</dt>
<dd>{single.object.pageIndex + 1}</dd>
<dt className="text-muted-foreground">Author</dt>
<dd className="truncate">{single.object.author || "—"}</dd>
<dt className="text-muted-foreground">Created</dt>
<dd>{formatDateTime(single.object.created)}</dd>
<dt className="text-muted-foreground">Modified</dt>
<dd>{formatDateTime(single.object.modified)}</dd>
{flags.length > 0 ? (
<>
<dt className="text-muted-foreground">Flags</dt>
<dd className="truncate">{flags.join(", ")}</dd>
</>
) : null}
</dl>
</PdfEditorSection>
) : null}
</div>
)
}
function NoteEditor({
value,
disabled,
onChange,
}: {
value: string
disabled?: boolean
onChange: (value: string) => void
}) {
const [draft, setDraft] = React.useState(value)
const [previousValue, setPreviousValue] = React.useState(value)
if (!Object.is(previousValue, value)) {
setPreviousValue(value)
setDraft(value)
}
return (
<textarea
value={draft}
disabled={disabled}
rows={3}
placeholder="Add a note…"
onChange={(event) => setDraft(event.target.value)}
onBlur={() => {
if (draft !== value) onChange(draft)
}}
className={cn(
"w-full resize-y rounded-lg border border-input bg-background px-2.5 py-2 text-sm outline-none placeholder:text-muted-foreground focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-60"
)}
/>
)
}
components/extend/pdf-editor-panels.tsx
"use client"
import * as React from "react"
import { useDocumentState, useRegistry } from "@embedpdf/core/react"
import {
PDF_FORM_FIELD_TYPE,
PdfActionType,
PdfAnnotationName,
PdfAnnotationSubtype,
PdfZoomMode,
uuidV4,
type PdfAttachmentObject,
type PdfBookmarkObject,
type PdfDestinationObject,
type PdfWidgetAnnoObject,
} from "@embedpdf/models"
import {
getGroupLeaderId,
getSelectedAnnotations,
getSidebarAnnotationsWithRepliesGroupedByPage,
useAnnotation,
type SidebarAnnotationEntry,
type TrackedAnnotation,
} from "@embedpdf/plugin-annotation/react"
import { useAttachmentCapability } from "@embedpdf/plugin-attachment/react"
import { useBookmarkCapability } from "@embedpdf/plugin-bookmark/react"
import {
useFormCapability,
type FormFieldInfo,
} from "@embedpdf/plugin-form/react"
import {
useRedaction,
type RedactionItem,
} from "@embedpdf/plugin-redaction/react"
import { useScrollCapability } from "@embedpdf/plugin-scroll/react"
import {
SignatureFieldKind,
useActivePlacement,
useSignatureEntries,
} from "@embedpdf/plugin-signature/react"
import {
StampImg,
useActiveStamp,
useStampCapability,
useStampLibraries,
useStampsByLibrary,
type StampDefinition,
} from "@embedpdf/plugin-stamp/react"
import {
ThumbImg,
useThumbnailPlugin,
type ThumbMeta,
type WindowState,
} from "@embedpdf/plugin-thumbnail/react"
import type { degrees, ParseSpeeds, PDFDocument } from "pdf-lib"
import { cn } from "@/lib/utils"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { Switch } from "@/components/ui/switch"
import { getFormFieldTypeLabel } from "@/components/extend/pdf-editor-properties"
import {
ArrowDownGlyph,
ArrowUpGlyph,
CheckGlyph,
ChevronDownGlyph,
ChevronRightGlyph,
CommentsGlyph,
downloadArrayBuffer,
downloadBlob,
DownloadGlyph,
EditGlyph,
EyeOffGlyph,
FilePlusGlyph,
formatBytes,
formatRelativeDate,
getAnnotationMeta,
GridGlyph,
ListTreeGlyph,
MergeGlyph,
PaperclipGlyph,
PdfEditorEmptyState,
PdfEditorScrollArea,
PdfEditorToolbarSeparator,
PdfEditorToolButton,
PlusGlyph,
RedactAreaGlyph,
RotateGlyph,
ScissorsGlyph,
SignatureGlyph,
StampGlyph,
TextFieldGlyph,
toArrayBuffer,
TrashGlyph,
UploadGlyph,
usePdfEditor,
withFileNameSuffix,
} from "@/components/extend/pdf-editor-shared"
import { IconPlaceholder } from "@/components/icon-placeholder"
/* -------------------------------------------------------------------------- */
/* Thumbnails */
/* -------------------------------------------------------------------------- */
const THUMBNAIL_FOCUS_RING_CLASS =
"group-focus-visible/pdf-editor-thumbnails:ring-2 group-focus-visible/pdf-editor-thumbnails:ring-ring group-focus-visible/pdf-editor-thumbnails:ring-offset-1 group-focus-visible/pdf-editor-thumbnails:ring-offset-background"
function createFormFieldStore(
scope: {
getFormFields: () => FormFieldInfo[]
onFormReady: (listener: (fields: FormFieldInfo[]) => void) => () => void
onFieldValueChange: (listener: () => void) => () => void
} | null
) {
let fields: FormFieldInfo[] = scope?.getFormFields() ?? []
return {
getSnapshot: () => fields,
subscribe: (listener: () => void) => {
if (!scope) return () => {}
const update = (next: FormFieldInfo[]) => {
fields = next
listener()
}
const unsubscribeReady = scope.onFormReady(update)
const unsubscribeChange = scope.onFieldValueChange(() =>
update(scope.getFormFields())
)
update(scope.getFormFields())
return () => {
unsubscribeReady()
unsubscribeChange()
}
},
}
}
export function PdfEditorThumbnailsPanel({
documentId,
activePage,
pageCount,
onSelectPage,
}: {
documentId: string
activePage: number
pageCount: number
onSelectPage: (pageNumber: number) => void
}) {
const { plugin: thumbnailPlugin } = useThumbnailPlugin()
const listboxId = React.useId()
const viewportRef = React.useRef<HTMLDivElement | null>(null)
const scope = React.useMemo(
() => thumbnailPlugin?.provides().forDocument(documentId) ?? null,
[documentId, thumbnailPlugin]
)
const windowState = React.useSyncExternalStore<WindowState | null>(
React.useCallback(
(onStoreChange) => {
if (!scope) return () => undefined
return scope.onWindow(() => onStoreChange())
},
[scope]
),
React.useCallback(() => scope?.getWindow() ?? null, [scope]),
() => null
)
const paddingY = thumbnailPlugin?.cfg.paddingY ?? 0
React.useEffect(() => {
const viewport = viewportRef.current
if (!viewport || !scope) return
const updateWindow = () => {
scope.updateWindow(viewport.scrollTop, viewport.clientHeight)
}
updateWindow()
viewport.addEventListener("scroll", updateWindow, { passive: true })
const resizeObserver = new ResizeObserver(updateWindow)
resizeObserver.observe(viewport)
return () => {
viewport.removeEventListener("scroll", updateWindow)
resizeObserver.disconnect()
}
}, [scope])
React.useEffect(() => {
const viewport = viewportRef.current
if (!viewport || !scope) return
return scope.onScrollTo(({ top, behavior }) => {
viewport.scrollTo({ top, behavior })
})
}, [scope])
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 (
<PdfEditorScrollArea
className="h-full w-full"
viewportClassName="group/pdf-editor-thumbnails px-4 focus-visible:ring-0 focus-visible:ring-offset-0"
viewportProps={{
"aria-activedescendant":
activePage > 0 ? `${listboxId}-page-${activePage}` : undefined,
"aria-label": "Pages",
onKeyDown: handleKeyDown,
onMouseDown: (event) => {
event.currentTarget.focus({ preventScroll: true })
},
role: "listbox",
style: { paddingBottom: paddingY, paddingTop: paddingY },
tabIndex: 0,
}}
viewportRef={viewportRef}
>
<div
className="relative"
style={{ height: windowState?.totalHeight ?? 0 }}
>
{windowState?.items.map((meta: ThumbMeta) => {
const pageNumber = meta.pageIndex + 1
const isActive = pageNumber === activePage
const imagePadding = meta.padding ?? 0
return (
<div
key={meta.pageIndex}
className={cn(
"absolute right-0 left-0 flex justify-center",
isActive && "z-10"
)}
style={{ top: meta.top, height: meta.wrapperHeight }}
>
<div
id={`${listboxId}-page-${pageNumber}`}
role="option"
aria-selected={isActive}
aria-current={isActive ? "page" : undefined}
aria-label={`Page ${pageNumber}`}
style={{ maxWidth: meta.width + imagePadding * 2 + 16 }}
className={cn(
"flex h-full w-full cursor-default flex-col items-center justify-between rounded-md px-2 text-xs transition-shadow outline-none select-none hover:bg-sidebar-accent",
isActive
? cn(
"bg-sidebar-accent text-foreground",
THUMBNAIL_FOCUS_RING_CLASS
)
: "text-muted-foreground"
)}
onClick={() => onSelectPage(pageNumber)}
>
<span
className="flex items-center justify-center overflow-hidden rounded-md"
style={{
width: meta.width + imagePadding * 2,
height: meta.height + imagePadding * 2,
padding: imagePadding,
}}
>
<ThumbImg
documentId={documentId}
meta={meta}
className="block rounded-sm object-contain shadow-xs"
style={{ width: meta.width, height: meta.height }}
/>
</span>
<span
className="flex items-center justify-center tabular-nums"
style={{ height: meta.labelHeight }}
>
{pageNumber}
</span>
</div>
</div>
)
})}
</div>
</PdfEditorScrollArea>
)
}
/* -------------------------------------------------------------------------- */
/* Outline */
/* -------------------------------------------------------------------------- */
function resolveBookmarkDestination(bookmark: PdfBookmarkObject): {
destination: PdfDestinationObject | null
uri: string | null
} {
const target = bookmark.target
if (!target) return { destination: null, uri: null }
if (target.type === "destination") {
return { destination: target.destination, uri: null }
}
const action = target.action
if (
action.type === PdfActionType.Goto ||
action.type === PdfActionType.RemoteGoto
) {
return { destination: action.destination, uri: null }
}
if (action.type === PdfActionType.URI) {
return { destination: null, uri: action.uri }
}
return { destination: null, uri: null }
}
export function PdfEditorOutlinePanel({ documentId }: { documentId: string }) {
const { provides: bookmark } = useBookmarkCapability()
const { provides: scroll } = useScrollCapability()
const documentState = useDocumentState(documentId)
const document = documentState?.document ?? null
const [bookmarks, setBookmarks] = React.useState<PdfBookmarkObject[] | null>(
null
)
const [expanded, setExpanded] = React.useState<Set<string>>(() => new Set())
React.useEffect(() => {
if (!bookmark || !document) return
let cancelled = false
const task = bookmark.forDocument(documentId).getBookmarks()
task.wait(
(result) => {
if (cancelled) return
setBookmarks(result.bookmarks)
setExpanded(
new Set(result.bookmarks.map((_, index) => `bookmark-${index}`))
)
},
() => {
if (!cancelled) setBookmarks([])
}
)
return () => {
cancelled = true
}
}, [bookmark, document, documentId])
const navigate = React.useCallback(
(item: PdfBookmarkObject) => {
const { destination, uri } = resolveBookmarkDestination(item)
if (uri) {
window.open(uri, "_blank", "noopener")
return
}
if (!destination || !scroll) return
const scope = scroll.forDocument(documentId)
const page = document?.pages[destination.pageIndex]
if (destination.zoom.mode === PdfZoomMode.XYZ && page) {
scope.scrollToPage({
pageNumber: destination.pageIndex + 1,
pageCoordinates: {
x: destination.zoom.params.x,
y: page.size.height - destination.zoom.params.y,
},
behavior: "smooth",
})
return
}
scope.scrollToPage({
pageNumber: destination.pageIndex + 1,
behavior: "smooth",
})
},
[document, documentId, scroll]
)
if (bookmarks === null) {
return (
<div className="grid h-full place-items-center">
<InlineSpinner className="size-4" />
</div>
)
}
if (bookmarks.length === 0) {
return (
<PdfEditorEmptyState
glyph={ListTreeGlyph}
title="No outline"
description="This document does not include bookmarks."
/>
)
}
const renderBookmark = (
item: PdfBookmarkObject,
id: string,
level: number
): React.ReactNode => {
const hasChildren = Boolean(item.children && item.children.length > 0)
const isExpanded = expanded.has(id)
return (
<div key={id}>
<div
className="group flex cursor-default items-center gap-1 rounded-md py-1 pr-2 text-sm hover:bg-sidebar-accent"
style={{ paddingLeft: level * 12 + 4 }}
>
{hasChildren ? (
<button
type="button"
aria-label={isExpanded ? "Collapse" : "Expand"}
className="grid size-5 shrink-0 place-items-center rounded text-muted-foreground hover:text-foreground"
onClick={(event) => {
event.stopPropagation()
setExpanded((previous) => {
const next = new Set(previous)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})
}}
>
{isExpanded ? (
<ChevronDownGlyph className="size-3.5" />
) : (
<ChevronRightGlyph className="size-3.5" />
)}
</button>
) : (
<span className="size-5 shrink-0" />
)}
<button
type="button"
className="min-w-0 flex-1 truncate text-left text-foreground/90 outline-none"
onClick={() => navigate(item)}
>
{item.title || "Untitled"}
</button>
</div>
{hasChildren && isExpanded
? item.children?.map((child, index) =>
renderBookmark(child, `${id}-${index}`, level + 1)
)
: null}
</div>
)
}
return (
<PdfEditorScrollArea className="h-full w-full" viewportClassName="p-2">
{bookmarks.map((item, index) =>
renderBookmark(item, `bookmark-${index}`, 0)
)}
</PdfEditorScrollArea>
)
}
/* -------------------------------------------------------------------------- */
/* Attachments */
/* -------------------------------------------------------------------------- */
export function PdfEditorAttachmentsPanel({
documentId,
}: {
documentId: string
}) {
const { provides: attachment } = useAttachmentCapability()
const { registry } = useRegistry()
const documentState = useDocumentState(documentId)
const { notify, permissions } = usePdfEditor()
const document = documentState?.document ?? null
const [attachments, setAttachments] = React.useState<
PdfAttachmentObject[] | null
>(null)
const [version, setVersion] = React.useState(0)
const [busy, setBusy] = React.useState(false)
const inputRef = React.useRef<HTMLInputElement>(null)
React.useEffect(() => {
if (!attachment || !document) return
let cancelled = false
attachment
.forDocument(documentId)
.getAttachments()
.wait(
(result) => {
if (!cancelled) setAttachments(result)
},
() => {
if (!cancelled) setAttachments([])
}
)
return () => {
cancelled = true
}
}, [attachment, document, documentId, version])
const refresh = () => setVersion((value) => value + 1)
const handleDownload = (item: PdfAttachmentObject) => {
attachment
?.forDocument(documentId)
.downloadAttachment(item)
.wait(
(buffer) =>
downloadArrayBuffer(
buffer,
item.name,
item.mimeType || "application/octet-stream"
),
() => notify("The attachment could not be downloaded.", "error")
)
}
const handleRemove = (item: PdfAttachmentObject) => {
if (!registry || !document) return
setBusy(true)
registry
.getEngine()
.removeAttachment(document, item)
.wait(
() => {
setBusy(false)
refresh()
notify("Attachment removed.", "success")
},
() => {
setBusy(false)
notify("The attachment could not be removed.", "error")
}
)
}
const handleAdd = async (file: File) => {
if (!registry || !document) return
setBusy(true)
try {
const data = await file.arrayBuffer()
registry
.getEngine()
.addAttachment(document, {
name: file.name,
description: "",
mimeType: file.type || "application/octet-stream",
data,
})
.wait(
() => {
setBusy(false)
refresh()
notify("Attachment added.", "success")
},
() => {
setBusy(false)
notify("The attachment could not be added.", "error")
}
)
} catch {
setBusy(false)
notify("The file could not be read.", "error")
}
}
const canModify = permissions.canModifyContents && !busy
return (
<div className="flex h-full flex-col">
<div className="flex shrink-0 items-center gap-2 border-b px-3 py-2">
<input
ref={inputRef}
type="file"
className="sr-only"
tabIndex={-1}
onChange={(event) => {
const file = event.target.files?.[0]
if (file) void handleAdd(file)
event.currentTarget.value = ""
}}
/>
<Button
type="button"
size="sm"
variant="outline"
disabled={!canModify}
onClick={() => inputRef.current?.click()}
>
<PlusGlyph className="size-4" />
Attach file
</Button>
{busy ? <InlineSpinner className="size-4" /> : null}
</div>
{attachments === null ? (
<div className="grid flex-1 place-items-center">
<InlineSpinner className="size-4" />
</div>
) : attachments.length === 0 ? (
<PdfEditorEmptyState
glyph={PaperclipGlyph}
title="No attachments"
description="Files embedded in the PDF appear here."
/>
) : (
<PdfEditorScrollArea className="min-h-0 flex-1" viewportClassName="p-2">
<div className="space-y-1">
{attachments.map((item) => (
<div
key={`${item.index}-${item.name}`}
className="flex items-center gap-2 rounded-lg border px-2.5 py-2"
>
<div className="grid size-8 shrink-0 place-items-center rounded-md bg-muted text-muted-foreground">
<PaperclipGlyph className="size-4" />
</div>
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium">
{item.name}
</div>
<div className="truncate text-xs text-muted-foreground">
{[item.mimeType, formatBytes(item.size), item.description]
.filter(Boolean)
.join(" · ")}
</div>
</div>
<PdfEditorToolButton
label="Download"
size="icon-xs"
onClick={() => handleDownload(item)}
>
<DownloadGlyph className="size-3.5" />
</PdfEditorToolButton>
<PdfEditorToolButton
label="Remove"
size="icon-xs"
disabled={!canModify}
onClick={() => handleRemove(item)}
>
<TrashGlyph className="size-3.5" />
</PdfEditorToolButton>
</div>
))}
</div>
</PdfEditorScrollArea>
)}
</div>
)
}
/* -------------------------------------------------------------------------- */
/* Pages (organizer) */
/* -------------------------------------------------------------------------- */
const PAGE_TILE_WIDTH = 76
type PdfLibModule = {
PDFDocument: typeof PDFDocument
ParseSpeeds: typeof ParseSpeeds
degrees: typeof degrees
}
let pdfLibPromise: Promise<PdfLibModule> | null = null
function loadPdfLib() {
pdfLibPromise ??= import("pdf-lib")
return pdfLibPromise
}
export function PdfEditorPagesPanel({
documentId,
activePage,
}: {
documentId: string
activePage: number
}) {
const { registry } = useRegistry()
const documentState = useDocumentState(documentId)
const { state: annotationState, provides: annotation } =
useAnnotation(documentId)
const {
fileName,
getDocumentBuffer,
notify,
openDialog,
permissions,
replaceDocument,
scrollToPage,
} = usePdfEditor()
const document = documentState?.document ?? null
const pages = React.useMemo(() => document?.pages ?? [], [document])
const [selected, setSelected] = React.useState<Set<number>>(() => new Set())
const [busy, setBusy] = React.useState<string | null>(null)
const anchorRef = React.useRef<number | null>(null)
const appendInputRef = React.useRef<HTMLInputElement>(null)
React.useEffect(() => {
void loadPdfLib()
}, [])
const metas = React.useMemo<ThumbMeta[]>(
() =>
pages.map((page) => {
const height = Math.max(
1,
Math.round(PAGE_TILE_WIDTH * (page.size.height / page.size.width))
)
return {
pageIndex: page.index,
width: PAGE_TILE_WIDTH,
height,
wrapperHeight: height,
top: 0,
labelHeight: 0,
padding: 0,
}
}),
[pages]
)
const selectedIndices = React.useMemo(
() => [...selected].sort((a, b) => a - b),
[selected]
)
const isEncrypted = document?.isEncrypted ?? false
const canAssemble =
permissions.canAssembleDocument && !isEncrypted && busy === null
const hasSelection = selectedIndices.length > 0
const isContiguous =
hasSelection &&
selectedIndices[selectedIndices.length - 1] - selectedIndices[0] ===
selectedIndices.length - 1
const select = (pageIndex: number, mode: "replace" | "toggle" | "range") => {
setSelected((previous) => {
if (mode === "range" && anchorRef.current !== null) {
const start = Math.min(anchorRef.current, pageIndex)
const end = Math.max(anchorRef.current, pageIndex)
const next = new Set<number>()
for (let index = start; index <= end; index += 1) next.add(index)
return next
}
if (mode === "toggle") {
const next = new Set(previous)
if (next.has(pageIndex)) next.delete(pageIndex)
else next.add(pageIndex)
anchorRef.current = pageIndex
return next
}
anchorRef.current = pageIndex
return new Set([pageIndex])
})
}
const runOperation = async (
label: string,
mutate: (doc: PDFDocument, lib: PdfLibModule) => Promise<void> | void,
options: {
replace?: boolean
} = { replace: true }
) => {
setBusy(label)
try {
const [buffer, lib] = await Promise.all([
getDocumentBuffer(),
loadPdfLib(),
])
const doc = await lib.PDFDocument.load(buffer, {
ignoreEncryption: true,
parseSpeed: lib.ParseSpeeds.Fastest,
updateMetadata: false,
})
await mutate(doc, lib)
if (options.replace !== false) {
const bytes = await doc.save({ objectsPerTick: 500 })
replaceDocument(toArrayBuffer(bytes), fileName)
notify(`${label} applied.`, "success")
}
} catch (error) {
console.error(error)
notify(`${label} failed.`, "error")
} finally {
setBusy(null)
}
}
const rotateSelected = (delta: 90 | -90) =>
runOperation("Rotate pages", (doc, lib) => {
for (const index of selectedIndices) {
const page = doc.getPage(index)
page.setRotation(
lib.degrees((page.getRotation().angle + delta + 360) % 360)
)
}
})
const deleteSelected = () => {
if (selectedIndices.length >= pages.length) {
notify("A document needs at least one page.", "error")
return
}
openDialog({
type: "confirm",
title: `Delete ${selectedIndices.length === 1 ? "this page" : `${selectedIndices.length} pages`}?`,
description:
"Content and annotations on the deleted pages are removed permanently.",
confirmLabel: "Delete",
destructive: true,
onConfirm: async () => {
setBusy("Delete pages")
try {
if (!registry || !document) {
throw new Error("The document is not ready.")
}
if (annotation && annotationState.hasPendingChanges) {
await annotation.commit().toPromise()
}
const engine = registry.getEngine()
for (const index of [...selectedIndices].reverse()) {
await engine.deletePage(document, index).toPromise()
}
const buffer = await engine.saveAsCopy(document).toPromise()
replaceDocument(buffer, fileName)
notify("Delete pages applied.", "success")
} catch (error) {
console.error(error)
notify("Delete pages failed.", "error")
} finally {
setBusy(null)
}
},
})
}
const moveSelected = (direction: -1 | 1) => {
const first = selectedIndices[0]
const last = selectedIndices[selectedIndices.length - 1]
if (direction === -1 && first === 0) return
if (direction === 1 && last === pages.length - 1) return
void runOperation("Move pages", (doc) => {
// pdf-lib has no move API: detach the neighbouring page from the page
// tree and re-insert it on the other side of the selected block.
if (direction === -1) {
const page = doc.getPage(first - 1)
doc.removePage(first - 1)
doc.insertPage(last, page)
} else {
const page = doc.getPage(last + 1)
doc.removePage(last + 1)
doc.insertPage(first, page)
}
})
}
const insertBlankAfter = () => {
const referenceIndex = hasSelection
? selectedIndices[selectedIndices.length - 1]
: Math.max(0, activePage - 1)
void runOperation("Insert page", (doc) => {
const reference = doc.getPage(referenceIndex)
const { width, height } = reference.getSize()
doc.insertPage(referenceIndex + 1, [width, height])
})
}
const extractSelected = () =>
runOperation(
"Extract pages",
async (doc, lib) => {
const output = await lib.PDFDocument.create()
const copied = await output.copyPages(doc, selectedIndices)
copied.forEach((page) => output.addPage(page))
const bytes = await output.save()
downloadArrayBuffer(bytes, withFileNameSuffix(fileName, "-pages"))
notify(
`Extracted ${selectedIndices.length} page${selectedIndices.length === 1 ? "" : "s"}.`,
"success"
)
},
{ replace: false }
)
const appendFile = (file: File) =>
runOperation("Append PDF", async (doc, lib) => {
const source = await lib.PDFDocument.load(await file.arrayBuffer(), {
ignoreEncryption: true,
parseSpeed: lib.ParseSpeeds.Fastest,
updateMetadata: false,
})
const copied = await doc.copyPages(source, source.getPageIndices())
copied.forEach((page) => doc.addPage(page))
})
return (
<div className="flex h-full flex-col">
<div className="flex shrink-0 flex-wrap items-center gap-0.5 border-b px-2 py-1.5">
<PdfEditorToolButton
label="Rotate left"
disabled={!canAssemble || !hasSelection}
onClick={() => rotateSelected(-90)}
>
<RotateGlyph className="size-4 -scale-x-100" />
</PdfEditorToolButton>
<PdfEditorToolButton
label="Rotate right"
disabled={!canAssemble || !hasSelection}
onClick={() => rotateSelected(90)}
>
<RotateGlyph className="size-4" />
</PdfEditorToolButton>
<PdfEditorToolbarSeparator />
<PdfEditorToolButton
label="Move up"
disabled={!canAssemble || !isContiguous || selectedIndices[0] === 0}
onClick={() => moveSelected(-1)}
>
<ArrowUpGlyph className="size-4" />
</PdfEditorToolButton>
<PdfEditorToolButton
label="Move down"
disabled={
!canAssemble ||
!isContiguous ||
selectedIndices[selectedIndices.length - 1] === pages.length - 1
}
onClick={() => moveSelected(1)}
>
<ArrowDownGlyph className="size-4" />
</PdfEditorToolButton>
<PdfEditorToolbarSeparator />
<PdfEditorToolButton
label="Insert blank page after"
disabled={!canAssemble}
onClick={insertBlankAfter}
>
<FilePlusGlyph className="size-4" />
</PdfEditorToolButton>
<PdfEditorToolButton
label="Append PDF"
disabled={!canAssemble}
onClick={() => appendInputRef.current?.click()}
>
<MergeGlyph className="size-4" />
</PdfEditorToolButton>
<PdfEditorToolButton
label="Extract selected pages"
disabled={busy !== null || !hasSelection}
onClick={() => void extractSelected()}
>
<ScissorsGlyph className="size-4" />
</PdfEditorToolButton>
<PdfEditorToolButton
label="Delete pages"
disabled={!canAssemble || !hasSelection}
onClick={deleteSelected}
>
<TrashGlyph className="size-4" />
</PdfEditorToolButton>
<input
ref={appendInputRef}
type="file"
accept="application/pdf,.pdf"
className="sr-only"
tabIndex={-1}
onChange={(event) => {
const file = event.target.files?.[0]
if (file) void appendFile(file)
event.currentTarget.value = ""
}}
/>
</div>
<div className="flex shrink-0 items-center justify-between gap-2 border-b px-3 py-1.5 text-xs text-muted-foreground">
<span>
{hasSelection
? `${selectedIndices.length} of ${pages.length} selected`
: `${pages.length} page${pages.length === 1 ? "" : "s"}`}
</span>
{busy ? (
<span className="flex items-center gap-1.5">
<InlineSpinner className="size-3" />
{busy}…
</span>
) : isEncrypted ? (
<span>Remove protection to edit pages.</span>
) : hasSelection ? (
<button
type="button"
className="underline-offset-2 hover:underline"
onClick={() => setSelected(new Set())}
>
Clear
</button>
) : null}
</div>
<PdfEditorScrollArea
className="min-h-0 flex-1"
viewportClassName="px-2.5 py-3"
>
<div
data-slot="pdf-editor-pages-grid"
className="grid grid-cols-[repeat(auto-fill,minmax(5.5rem,1fr))] items-start gap-2"
>
{metas.map((meta) => {
const pageNumber = meta.pageIndex + 1
const isSelected = selected.has(meta.pageIndex)
const isActive = pageNumber === activePage
return (
<button
key={meta.pageIndex}
type="button"
aria-pressed={isSelected}
className={cn(
"group flex min-w-0 flex-col items-center gap-1.5 rounded-lg border p-1.5 text-xs transition-colors outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
isSelected
? "border-primary bg-primary/5"
: "border-transparent hover:bg-sidebar-accent",
isActive && !isSelected && "border-border"
)}
onClick={(event) => {
select(
meta.pageIndex,
event.shiftKey
? "range"
: event.metaKey || event.ctrlKey
? "toggle"
: "replace"
)
}}
onDoubleClick={() => scrollToPage(pageNumber)}
>
<span className="grid w-full place-items-center rounded-md border border-border/60 bg-muted/30 p-1">
<span
className="grid w-full place-items-center overflow-hidden rounded-sm bg-white shadow-xs"
style={{ aspectRatio: `${meta.width} / ${meta.height}` }}
>
<ThumbImg
documentId={documentId}
meta={meta}
className="block size-full object-contain"
/>
</span>
</span>
<span
className={cn(
"tabular-nums",
isSelected ? "text-foreground" : "text-muted-foreground"
)}
>
{pageNumber}
</span>
</button>
)
})}
</div>
</PdfEditorScrollArea>
</div>
)
}
/* -------------------------------------------------------------------------- */
/* Comments */
/* -------------------------------------------------------------------------- */
function CommentComposer({
placeholder,
initialValue = "",
autoFocus,
submitLabel = "Save",
onSubmit,
onCancel,
}: {
placeholder: string
initialValue?: string
autoFocus?: boolean
submitLabel?: string
onSubmit: (value: string) => void
onCancel?: () => void
}) {
const [value, setValue] = React.useState(initialValue)
return (
<div className="space-y-1.5">
<textarea
value={value}
autoFocus={autoFocus}
rows={2}
placeholder={placeholder}
onChange={(event) => setValue(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter" && (event.metaKey || event.ctrlKey)) {
event.preventDefault()
if (value.trim()) onSubmit(value.trim())
}
if (event.key === "Escape") onCancel?.()
}}
className="w-full resize-none rounded-md border border-input bg-background px-2 py-1.5 text-sm outline-none placeholder:text-muted-foreground focus-visible:ring-2 focus-visible:ring-ring"
/>
<div className="flex justify-end gap-1">
{onCancel ? (
<Button type="button" size="xs" variant="ghost" onClick={onCancel}>
Cancel
</Button>
) : null}
<Button
type="button"
size="xs"
disabled={!value.trim()}
onClick={() => onSubmit(value.trim())}
>
{submitLabel}
</Button>
</div>
</div>
)
}
function CommentCard({
entry,
isSelected,
readOnly,
onSelect,
onUpdate,
onDelete,
onReply,
}: {
entry: SidebarAnnotationEntry
isSelected: boolean
readOnly: boolean
onSelect: () => void
onUpdate: (annotation: TrackedAnnotation, contents: string) => void
onDelete: (annotation: TrackedAnnotation) => void
onReply: (annotation: TrackedAnnotation, contents: string) => void
}) {
const { annotation, replies } = entry
const [isEditing, setIsEditing] = React.useState(false)
const [isReplying, setIsReplying] = React.useState(false)
const meta = getAnnotationMeta(annotation.object)
const contents =
typeof annotation.object.contents === "string"
? annotation.object.contents
: ""
const quotedText =
annotation.object.custom &&
typeof annotation.object.custom === "object" &&
typeof (
annotation.object.custom as {
text?: unknown
}
).text === "string"
? ((
annotation.object.custom as {
text: string
}
).text as string)
: null
const Glyph = meta.glyph
return (
<div
role="button"
tabIndex={0}
onClick={onSelect}
onKeyDown={(event) => {
if (event.key === "Enter") onSelect()
}}
className={cn(
"rounded-lg border bg-background p-3 text-left shadow-xs transition-colors outline-none focus-visible:ring-2 focus-visible:ring-ring",
isSelected
? "border-primary ring-1 ring-primary/40"
: "hover:bg-accent/40"
)}
>
<div className="flex items-start gap-2">
<span
className="grid size-7 shrink-0 place-items-center rounded-full bg-muted"
style={meta.color ? { color: meta.color } : undefined}
>
<Glyph className="size-3.5" />
</span>
<div className="min-w-0 flex-1">
<div className="flex items-center justify-between gap-2">
<div className="min-w-0">
<div className="truncate text-sm font-medium">
{annotation.object.author || "Guest"}
</div>
<div className="text-[11px] text-muted-foreground">
{meta.label} · page {annotation.object.pageIndex + 1}
{annotation.object.modified || annotation.object.created
? ` · ${formatRelativeDate(annotation.object.modified ?? annotation.object.created)}`
: ""}
</div>
</div>
{!readOnly ? (
<div className="flex shrink-0 items-center gap-0.5">
<PdfEditorToolButton
label={contents ? "Edit comment" : "Add comment"}
size="icon-xs"
onClick={(event) => {
event.stopPropagation()
setIsEditing(true)
}}
>
<EditGlyph className="size-3.5" />
</PdfEditorToolButton>
<PdfEditorToolButton
label="Delete"
size="icon-xs"
onClick={(event) => {
event.stopPropagation()
onDelete(annotation)
}}
>
<TrashGlyph className="size-3.5" />
</PdfEditorToolButton>
</div>
) : null}
</div>
{quotedText ? (
<blockquote className="mt-1.5 line-clamp-3 border-l-2 pl-2 text-xs text-muted-foreground italic">
{quotedText}
</blockquote>
) : null}
{isEditing ? (
<div className="mt-2" onClick={(event) => event.stopPropagation()}>
<CommentComposer
placeholder="Write a comment…"
initialValue={contents}
autoFocus
onSubmit={(value) => {
onUpdate(annotation, value)
setIsEditing(false)
}}
onCancel={() => setIsEditing(false)}
/>
</div>
) : contents ? (
<p className="mt-1.5 text-sm whitespace-pre-wrap">{contents}</p>
) : (
<p className="mt-1.5 text-xs text-muted-foreground">
No comment yet.
</p>
)}
</div>
</div>
{replies.length > 0 ? (
<div className="mt-2 space-y-2 border-l pl-3">
{replies.map((reply) => (
<div key={reply.object.id} className="text-sm">
<div className="flex items-center justify-between gap-2">
<span className="text-xs font-medium">
{reply.object.author || "Guest"}
<span className="ml-1.5 font-normal text-muted-foreground">
{formatRelativeDate(
reply.object.modified ?? reply.object.created
)}
</span>
</span>
{!readOnly ? (
<PdfEditorToolButton
label="Delete reply"
size="icon-xs"
onClick={(event) => {
event.stopPropagation()
onDelete(reply)
}}
>
<TrashGlyph className="size-3" />
</PdfEditorToolButton>
) : null}
</div>
<p className="whitespace-pre-wrap">{reply.object.contents}</p>
</div>
))}
</div>
) : null}
{!readOnly ? (
<div className="mt-2" onClick={(event) => event.stopPropagation()}>
{isReplying ? (
<CommentComposer
placeholder="Reply…"
autoFocus
submitLabel="Reply"
onSubmit={(value) => {
onReply(annotation, value)
setIsReplying(false)
}}
onCancel={() => setIsReplying(false)}
/>
) : (
<Button
type="button"
size="xs"
variant="ghost"
className="text-muted-foreground"
onClick={() => setIsReplying(true)}
>
Reply
</Button>
)}
</div>
) : null}
</div>
)
}
export function PdfEditorCommentsPanel({ documentId }: { documentId: string }) {
const { provides: annotation, state } = useAnnotation(documentId)
const { annotationAuthor, permissions, scrollToAnnotation } = usePdfEditor()
const grouped = React.useMemo(
() => getSidebarAnnotationsWithRepliesGroupedByPage(state),
[state]
)
const selectedLeaderIds = React.useMemo(() => {
const ids = new Set<string>()
for (const item of getSelectedAnnotations(state)) {
ids.add(getGroupLeaderId(state, item.object.id) ?? item.object.id)
}
return ids
}, [state])
const pageNumbers = React.useMemo(
() =>
Object.keys(grouped)
.map(Number)
.sort((a, b) => a - b),
[grouped]
)
const readOnly = !permissions.canModifyAnnotations
const cardRefs = React.useRef<Map<string, HTMLDivElement>>(new Map())
const firstSelectedId = selectedLeaderIds.values().next().value
React.useEffect(() => {
if (!firstSelectedId) return
const element = cardRefs.current.get(firstSelectedId)
element?.scrollIntoView({ block: "nearest", behavior: "smooth" })
}, [firstSelectedId])
if (pageNumbers.length === 0) {
return (
<PdfEditorEmptyState
glyph={CommentsGlyph}
title="No comments yet"
description="Annotations and notes you add to the document show up here."
/>
)
}
return (
<PdfEditorScrollArea className="h-full w-full" viewportClassName="p-3">
<div className="space-y-5">
{pageNumbers.map((pageIndex) => (
<div key={pageIndex} className="space-y-2">
<div className="text-xs font-medium text-muted-foreground">
Page {pageIndex + 1}
</div>
<div className="space-y-2">
{grouped[pageIndex].map((entry) => (
<div
key={entry.annotation.object.id}
ref={(element) => {
if (element) {
cardRefs.current.set(entry.annotation.object.id, element)
} else {
cardRefs.current.delete(entry.annotation.object.id)
}
}}
>
<CommentCard
entry={entry}
isSelected={selectedLeaderIds.has(
entry.annotation.object.id
)}
readOnly={readOnly}
onSelect={() => {
annotation?.selectAnnotation(
entry.annotation.object.pageIndex,
entry.annotation.object.id
)
scrollToAnnotation(entry.annotation.object)
}}
onUpdate={(item, contents) =>
annotation?.updateAnnotation(
item.object.pageIndex,
item.object.id,
{
contents,
modified: new Date(),
}
)
}
onDelete={(item) =>
annotation?.deleteAnnotation(
item.object.pageIndex,
item.object.id
)
}
onReply={(parent, contents) =>
annotation?.createAnnotation(parent.object.pageIndex, {
id: uuidV4(),
type: PdfAnnotationSubtype.TEXT,
pageIndex: parent.object.pageIndex,
rect: {
origin: {
x: parent.object.rect.origin.x,
y: parent.object.rect.origin.y,
},
size: { width: 24, height: 24 },
},
contents,
inReplyToId: parent.object.id,
flags: ["noRotate", "noZoom", "print"],
name: PdfAnnotationName.Comment,
author: annotationAuthor,
created: new Date(),
modified: new Date(),
})
}
/>
</div>
))}
</div>
</div>
))}
</div>
</PdfEditorScrollArea>
)
}
/* -------------------------------------------------------------------------- */
/* Redactions */
/* -------------------------------------------------------------------------- */
export function PdfEditorRedactionsPanel({
documentId,
}: {
documentId: string
}) {
const { state, provides } = useRedaction(documentId)
const { provides: scroll } = useScrollCapability()
const { openDialog, permissions, notify } = usePdfEditor()
const items = React.useMemo(
() =>
Object.entries(state.pending)
.flatMap(([page, pageItems]) =>
pageItems.map((item) => ({ item, pageNumber: Number(page) + 1 }))
)
.sort((a, b) => a.pageNumber - b.pageNumber),
[state.pending]
)
const [isApplying, setIsApplying] = React.useState(false)
const canApply = permissions.canModifyContents && !isApplying
const selectItem = (item: RedactionItem) => {
provides?.selectPending(item.page, item.id)
scroll?.forDocument(documentId).scrollToPage({
pageNumber: item.page + 1,
pageCoordinates: { x: item.rect.origin.x, y: item.rect.origin.y },
alignX: 50,
alignY: 25,
behavior: "smooth",
})
}
const applyAll = () =>
openDialog({
type: "confirm",
title: `Apply ${items.length} redaction${items.length === 1 ? "" : "s"}?`,
description:
"Redaction permanently removes the marked content from the document. This cannot be undone.",
confirmLabel: "Apply redactions",
onConfirm: () => {
if (!provides) return
setIsApplying(true)
provides.commitAllPending().wait(
() => setIsApplying(false),
() => {
setIsApplying(false)
notify("The redactions could not be applied.", "error")
}
)
},
})
const applyOne = (item: RedactionItem) =>
openDialog({
type: "confirm",
title: "Apply this redaction?",
description: "The marked content is removed permanently.",
confirmLabel: "Apply",
onConfirm: () => {
provides?.commitPending(item.page, item.id).wait(
() => undefined,
() => notify("The redaction could not be applied.", "error")
)
},
})
return (
<div className="flex h-full flex-col">
{items.length === 0 ? (
<PdfEditorEmptyState
glyph={EyeOffGlyph}
title="No pending redactions"
description="Use the Redact tools to mark text or areas. Marks stay editable until you apply them."
/>
) : (
<PdfEditorScrollArea className="min-h-0 flex-1" viewportClassName="p-2">
<div className="space-y-1.5">
{items.map(({ item, pageNumber }) => {
const isSelected = state.selected?.id === item.id
const Glyph = item.kind === "text" ? EyeOffGlyph : RedactAreaGlyph
return (
<div
key={item.id}
role="button"
tabIndex={0}
onClick={() => selectItem(item)}
onKeyDown={(event) => {
if (event.key === "Enter") selectItem(item)
}}
className={cn(
"flex items-start gap-2 rounded-lg border px-2.5 py-2 text-left outline-none focus-visible:ring-2 focus-visible:ring-ring",
isSelected
? "border-primary ring-1 ring-primary/40"
: "hover:bg-accent/40"
)}
>
<span
className="mt-0.5 grid size-6 shrink-0 place-items-center rounded-md"
style={{
backgroundColor: `${item.markColor}22`,
color: item.markColor,
}}
>
<Glyph className="size-3.5" />
</span>
<div className="min-w-0 flex-1">
<div className="text-sm font-medium">
{item.kind === "text" ? "Text" : "Area"}
<span className="ml-1.5 text-xs font-normal text-muted-foreground">
page {pageNumber}
</span>
</div>
{item.kind === "text" && item.text ? (
<div className="truncate text-xs text-muted-foreground italic">
“{item.text}”
</div>
) : null}
</div>
<div className="flex shrink-0 items-center gap-0.5">
<PdfEditorToolButton
label="Apply"
size="icon-xs"
disabled={!canApply}
onClick={(event) => {
event.stopPropagation()
applyOne(item)
}}
>
<CheckGlyph className="size-3.5" />
</PdfEditorToolButton>
<PdfEditorToolButton
label="Remove mark"
size="icon-xs"
onClick={(event) => {
event.stopPropagation()
provides?.removePending(item.page, item.id)
}}
>
<TrashGlyph className="size-3.5" />
</PdfEditorToolButton>
</div>
</div>
)
})}
</div>
</PdfEditorScrollArea>
)}
{items.length > 0 ? (
<div className="flex shrink-0 gap-2 border-t p-2">
<Button
type="button"
variant="outline"
size="sm"
className="flex-1"
disabled={isApplying}
onClick={() => provides?.clearPending()}
>
Clear all
</Button>
<InlineLoadingButton
type="button"
size="sm"
className="flex-1"
loading={isApplying}
disabled={!canApply}
onClick={applyAll}
>
Apply all
</InlineLoadingButton>
</div>
) : null}
</div>
)
}
/* -------------------------------------------------------------------------- */
/* Stamps */
/* -------------------------------------------------------------------------- */
const CUSTOM_STAMP_LIBRARY_ID = "custom"
const STAMP_THUMB_WIDTH = 120
const UNSUPPORTED_STAMP_SOURCE_TYPES = new Set<PdfAnnotationSubtype>([
PdfAnnotationSubtype.REDACT,
PdfAnnotationSubtype.HIGHLIGHT,
PdfAnnotationSubtype.SQUIGGLY,
PdfAnnotationSubtype.UNDERLINE,
PdfAnnotationSubtype.STRIKEOUT,
PdfAnnotationSubtype.CARET,
PdfAnnotationSubtype.WIDGET,
PdfAnnotationSubtype.LINK,
])
export function PdfEditorStampsPanel({ documentId }: { documentId: string }) {
const { libraries } = useStampLibraries()
const { provides: stampCapability } = useStampCapability()
const { provides: annotation, state: annotationState } =
useAnnotation(documentId)
const { notify, permissions } = usePdfEditor()
const [libraryId, setLibraryId] = React.useState("all")
const stamps = useStampsByLibrary(libraryId === "all" ? undefined : libraryId)
const activeStamp = useActiveStamp(documentId)
const [busy, setBusy] = React.useState(false)
const importInputRef = React.useRef<HTMLInputElement>(null)
const selected = React.useMemo(
() => getSelectedAnnotations(annotationState),
[annotationState]
)
const canCreateFromSelection =
selected.length === 1 &&
!UNSUPPORTED_STAMP_SOURCE_TYPES.has(selected[0].object.type)
const customLibrary = libraries.find(
(library) => library.id === CUSTOM_STAMP_LIBRARY_ID
)
const libraryOptions = [
{ label: "All libraries", value: "all" },
...libraries.map((library) => ({
label: library.name,
value: library.id,
})),
]
if (
libraryId !== "all" &&
!libraries.some((library) => library.id === libraryId)
) {
setLibraryId("all")
}
const placeStamp = (targetLibraryId: string, stamp: StampDefinition) => {
if (!stampCapability) return
const isActive =
activeStamp?.libraryId === targetLibraryId &&
activeStamp.stamp.id === stamp.id
if (isActive) {
annotation?.setActiveTool(null)
return
}
stampCapability
.forDocument(documentId)
.activateStampPlacement(targetLibraryId, stamp)
.wait(
() => undefined,
() => notify("The stamp could not be activated.", "error")
)
}
const createFromSelection = () => {
if (!stampCapability || !canCreateFromSelection) return
const count = customLibrary?.stamps.length ?? 0
setBusy(true)
stampCapability
.forDocument(documentId)
.createStampFromAnnotation(
selected[0].object,
{
name: PdfAnnotationName.Custom,
subject: `Custom stamp ${count + 1}`,
categories: ["custom", "sidebar"],
},
CUSTOM_STAMP_LIBRARY_ID
)
.wait(
() => {
setBusy(false)
setLibraryId(CUSTOM_STAMP_LIBRARY_ID)
notify("Stamp saved to your custom library.", "success")
},
() => {
setBusy(false)
notify("The stamp could not be created from the selection.", "error")
}
)
}
const exportLibrary = (targetLibraryId: string) => {
stampCapability?.exportLibrary(targetLibraryId).wait(
(exported) => downloadArrayBuffer(exported.pdf, `${exported.name}.pdf`),
() => notify("The library could not be exported.", "error")
)
}
const importLibrary = async (file: File) => {
if (!stampCapability) return
setBusy(true)
try {
const buffer = await file.arrayBuffer()
const lib = await import("pdf-lib")
const pageCount = (
await lib.PDFDocument.load(buffer, { ignoreEncryption: true })
).getPageCount()
const name = file.name.replace(/\.pdf$/i, "") || "Imported stamps"
stampCapability
.loadLibrary({
name,
pdf: buffer,
categories: ["sidebar"],
stamps: Array.from({ length: pageCount }, (_, index) => ({
id: `page-${index + 1}`,
pageIndex: index,
name: PdfAnnotationName.Custom,
subject: `${name} ${index + 1}`,
})),
})
.wait(
(id) => {
setBusy(false)
setLibraryId(id)
notify(
`Imported ${pageCount} stamp${pageCount === 1 ? "" : "s"}.`,
"success"
)
},
() => {
setBusy(false)
notify("The stamp library could not be imported.", "error")
}
)
} catch {
setBusy(false)
notify("The file could not be read as a PDF.", "error")
}
}
return (
<div className="flex h-full flex-col">
<div className="shrink-0 space-y-2 border-b px-3 py-2">
<div className="flex items-center gap-1.5">
<Select
items={libraryOptions}
value={libraryId}
onValueChange={(value) => setLibraryId(String(value))}
>
<SelectTrigger size="sm" className="min-w-0 flex-1">
<SelectValue placeholder="Library" />
</SelectTrigger>
<SelectContent alignItemWithTrigger={false}>
{libraryOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
<PdfEditorToolButton
label="Import stamps from a PDF"
disabled={busy}
onClick={() => importInputRef.current?.click()}
>
<UploadGlyph className="size-4" />
</PdfEditorToolButton>
<PdfEditorToolButton
label="Export library"
disabled={
busy ||
(libraryId === "all"
? libraries.length === 0
: !libraries.some((library) => library.id === libraryId))
}
onClick={() => {
const targets =
libraryId === "all"
? libraries.map((library) => library.id)
: [libraryId]
targets.forEach(exportLibrary)
}}
>
<DownloadGlyph className="size-4" />
</PdfEditorToolButton>
</div>
<Button
type="button"
size="sm"
variant="outline"
className="w-full"
disabled={
!canCreateFromSelection || busy || !permissions.canModifyAnnotations
}
onClick={createFromSelection}
>
<StampGlyph className="size-4" />
Save selection as stamp
</Button>
<input
ref={importInputRef}
type="file"
accept="application/pdf,.pdf"
className="sr-only"
tabIndex={-1}
onChange={(event) => {
const file = event.target.files?.[0]
if (file) void importLibrary(file)
event.currentTarget.value = ""
}}
/>
</div>
{stamps.length === 0 ? (
<PdfEditorEmptyState
glyph={StampGlyph}
title={libraries.length === 0 ? "Loading stamps…" : "No stamps"}
description="Pick a library, import stamps from a PDF, or save a selected annotation as a stamp."
/>
) : (
<PdfEditorScrollArea className="min-h-0 flex-1" viewportClassName="p-3">
<div className="grid grid-cols-2 gap-2.5">
{stamps.map(({ library, stamp }) => {
const isActive =
activeStamp?.libraryId === library.id &&
activeStamp.stamp.id === stamp.id
return (
<div
key={`${library.id}-${stamp.id}`}
className="group relative"
>
<button
type="button"
aria-label={stamp.label ?? stamp.subject ?? "Rubber stamp"}
aria-pressed={isActive}
disabled={!permissions.canModifyAnnotations}
className={cn(
"flex aspect-square w-full items-center justify-center rounded-lg border p-2 transition-colors outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50",
isActive
? "border-primary bg-primary/5 ring-1 ring-primary/40"
: "hover:bg-accent/50"
)}
onClick={() => placeStamp(library.id, stamp)}
>
<StampImg
libraryId={library.id}
pageIndex={stamp.pageIndex}
width={STAMP_THUMB_WIDTH}
style={{
maxWidth: "85%",
maxHeight: "85%",
objectFit: "contain",
}}
/>
</button>
{!library.readonly ? (
<button
type="button"
aria-label="Remove stamp"
className="absolute top-1 right-1 grid size-5 place-items-center rounded-full border bg-background text-muted-foreground opacity-0 shadow-sm transition-opacity group-hover:opacity-100 hover:text-foreground focus-visible:opacity-100"
onClick={() =>
stampCapability
?.removeStampFromLibrary(library.id, stamp.id)
.wait(
() => undefined,
() =>
notify("The stamp could not be removed.", "error")
)
}
>
<TrashGlyph className="size-3" />
</button>
) : null}
</div>
)
})}
</div>
</PdfEditorScrollArea>
)}
{activeStamp ? (
<div className="shrink-0 border-t px-3 py-2 text-xs text-muted-foreground">
Click a page to place{" "}
<span className="font-medium text-foreground">
{activeStamp.stamp.subject}
</span>
.
</div>
) : null}
</div>
)
}
/* -------------------------------------------------------------------------- */
/* Signatures */
/* -------------------------------------------------------------------------- */
export function PdfEditorSignaturesPanel({
documentId,
}: {
documentId: string
}) {
const { entries, provides } = useSignatureEntries()
const activePlacement = useActivePlacement(documentId)
const { openDialog, permissions } = usePdfEditor()
const canPlace = permissions.canModifyAnnotations
const isActive = (entryId: string, kind: SignatureFieldKind) =>
activePlacement?.entryId === entryId && activePlacement.kind === kind
const togglePlacement = (entryId: string, kind: SignatureFieldKind) => {
if (!provides) return
const scope = provides.forDocument(documentId)
if (isActive(entryId, kind)) {
scope.deactivatePlacement()
return
}
if (kind === SignatureFieldKind.Initials) {
scope.activateInitialsPlacement(entryId)
} else {
scope.activateSignaturePlacement(entryId)
}
}
return (
<div className="flex h-full flex-col">
<div className="shrink-0 border-b px-3 py-2">
<Button
type="button"
size="sm"
className="w-full"
disabled={!canPlace}
onClick={() => openDialog({ type: "signature" })}
>
<PlusGlyph className="size-4" />
Create signature
</Button>
</div>
{entries.length === 0 ? (
<PdfEditorEmptyState
glyph={SignatureGlyph}
title="No signatures yet"
description="Draw, type, or upload a signature, then click it to place it on the page."
/>
) : (
<PdfEditorScrollArea className="min-h-0 flex-1" viewportClassName="p-3">
<div className="space-y-3">
{entries.map((entry) => (
<div key={entry.id} className="space-y-2 rounded-lg border p-2.5">
<div className="flex items-center justify-between">
<span className="text-[11px] font-medium text-muted-foreground">
Saved {formatRelativeDate(new Date(entry.createdAt))}
</span>
<PdfEditorToolButton
label="Delete signature"
size="icon-xs"
onClick={() => provides?.removeEntry(entry.id)}
>
<TrashGlyph className="size-3.5" />
</PdfEditorToolButton>
</div>
<div className="flex gap-2">
<button
type="button"
disabled={!canPlace}
aria-pressed={isActive(
entry.id,
SignatureFieldKind.Signature
)}
className={cn(
"flex h-16 min-w-0 flex-[2] items-center justify-center rounded-md border border-dashed bg-white transition-colors outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50",
isActive(entry.id, SignatureFieldKind.Signature)
? "border-primary ring-1 ring-primary/40"
: "hover:border-muted-foreground/60"
)}
onClick={() =>
togglePlacement(entry.id, SignatureFieldKind.Signature)
}
>
<img
src={entry.signature.previewDataUrl}
alt="Signature"
className="h-12 max-w-[90%] object-contain"
/>
</button>
{entry.initials ? (
<button
type="button"
disabled={!canPlace}
aria-pressed={isActive(
entry.id,
SignatureFieldKind.Initials
)}
className={cn(
"flex h-16 min-w-0 flex-1 items-center justify-center rounded-md border border-dashed bg-white transition-colors outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50",
isActive(entry.id, SignatureFieldKind.Initials)
? "border-primary ring-1 ring-primary/40"
: "hover:border-muted-foreground/60"
)}
onClick={() =>
togglePlacement(entry.id, SignatureFieldKind.Initials)
}
>
<img
src={entry.initials.previewDataUrl}
alt="Initials"
className="h-10 max-w-[80%] object-contain"
/>
</button>
) : null}
</div>
</div>
))}
</div>
</PdfEditorScrollArea>
)}
{activePlacement ? (
<div className="shrink-0 border-t px-3 py-2 text-xs text-muted-foreground">
Click a page to place your{" "}
{activePlacement.kind === SignatureFieldKind.Initials
? "initials"
: "signature"}
.
</div>
) : null}
</div>
)
}
/* -------------------------------------------------------------------------- */
/* Forms */
/* -------------------------------------------------------------------------- */
export function PdfEditorFormsPanel({ documentId }: { documentId: string }) {
const { provides: form } = useFormCapability()
const { provides: annotation, state: annotationState } =
useAnnotation(documentId)
const {
features,
fileName,
formDesignMode,
notify,
permissions,
scrollToAnnotation,
setFormDesignMode,
} = usePdfEditor()
const scope = React.useMemo(
() => form?.forDocument(documentId) ?? null,
[documentId, form]
)
const fieldStore = React.useMemo(() => createFormFieldStore(scope), [scope])
const fields = React.useSyncExternalStore(
fieldStore.subscribe,
fieldStore.getSnapshot,
fieldStore.getSnapshot
)
const importInputRef = React.useRef<HTMLInputElement>(null)
const designModeId = React.useId()
const widgetsByName = React.useMemo(() => {
const map = new Map<string, PdfWidgetAnnoObject>()
for (const tracked of Object.values(annotationState.byUid)) {
if (tracked.object.type !== PdfAnnotationSubtype.WIDGET) continue
const widget = tracked.object as PdfWidgetAnnoObject
if (!map.has(widget.field.name)) map.set(widget.field.name, widget)
}
return map
}, [annotationState.byUid])
const focusField = (field: FormFieldInfo) => {
const widget = widgetsByName.get(field.name)
if (!widget) return
if (formDesignMode) {
annotation?.selectAnnotation(widget.pageIndex, widget.id)
} else {
scope?.selectField(widget.id)
}
scrollToAnnotation(widget)
}
const exportValues = () => {
if (!scope) return
const values = scope.getFormValues()
downloadBlob(
new Blob([JSON.stringify(values, null, 2)], { type: "application/json" }),
`${fileName.replace(/\.pdf$/i, "")}-form-values.json`
)
}
const importValues = async (file: File) => {
if (!scope) return
try {
const parsed = JSON.parse(await file.text()) as Record<string, unknown>
const values = Object.fromEntries(
Object.entries(parsed).map(([key, value]) => [key, String(value ?? "")])
)
scope.setFormValues(values).wait(
() => notify("Form values imported.", "success"),
() => notify("The form values could not be applied.", "error")
)
} catch {
notify("The file is not valid JSON.", "error")
}
}
const clearValues = () => {
if (!scope || fields.length === 0) return
const values = Object.fromEntries(
fields.map((field) => [
field.name,
field.type === PDF_FORM_FIELD_TYPE.CHECKBOX ||
field.type === PDF_FORM_FIELD_TYPE.RADIOBUTTON
? "Off"
: "",
])
)
scope.setFormValues(values).wait(
() => notify("Form cleared.", "success"),
() => notify("The form could not be cleared.", "error")
)
}
return (
<div className="flex h-full flex-col">
<div className="shrink-0 space-y-2 border-b px-3 py-2">
{features.forms && permissions.canModifyAnnotations ? (
<label
htmlFor={designModeId}
data-slot="label"
className="flex cursor-pointer items-center justify-between gap-4 py-1"
>
<span className="flex min-w-0 flex-col gap-0.5">
<span className="text-sm font-medium">Design mode</span>
<span className="text-xs text-muted-foreground">
Move, resize, and configure fields instead of filling them.
</span>
</span>
<Switch
id={designModeId}
checked={formDesignMode}
onCheckedChange={setFormDesignMode}
/>
</label>
) : null}
<div className="flex flex-wrap gap-1.5">
<Button
type="button"
size="xs"
variant="outline"
disabled={fields.length === 0}
onClick={exportValues}
>
<DownloadGlyph className="size-3.5" />
Export values
</Button>
<Button
type="button"
size="xs"
variant="outline"
disabled={!permissions.canFillForms}
onClick={() => importInputRef.current?.click()}
>
<UploadGlyph className="size-3.5" />
Import values
</Button>
<Button
type="button"
size="xs"
variant="outline"
disabled={fields.length === 0 || !permissions.canFillForms}
onClick={clearValues}
>
Clear
</Button>
</div>
<input
ref={importInputRef}
type="file"
accept="application/json,.json"
className="sr-only"
tabIndex={-1}
onChange={(event) => {
const file = event.target.files?.[0]
if (file) void importValues(file)
event.currentTarget.value = ""
}}
/>
</div>
{fields.length === 0 ? (
<PdfEditorEmptyState
glyph={TextFieldGlyph}
title="No form fields"
description={
features.forms
? "Switch to the Forms mode to add text fields, checkboxes, radio buttons, dropdowns, and list boxes."
: "This document has no fillable fields."
}
/>
) : (
<PdfEditorScrollArea className="min-h-0 flex-1" viewportClassName="p-2">
<div className="space-y-1">
{fields.map((field) => (
<button
key={field.name}
type="button"
className="flex w-full items-center gap-2 rounded-lg border px-2.5 py-2 text-left outline-none hover:bg-accent/40 focus-visible:ring-2 focus-visible:ring-ring"
onClick={() => focusField(field)}
>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5">
<span className="truncate text-sm font-medium">
{field.name}
</span>
{field.readOnly ? (
<Badge variant="outline" className="h-4 px-1 text-[10px]">
Read only
</Badge>
) : null}
</div>
<div className="truncate text-xs text-muted-foreground">
{getFormFieldTypeLabel(field.type)}
{field.value ? ` · ${field.value}` : ""}
</div>
</div>
</button>
))}
</div>
</PdfEditorScrollArea>
)}
</div>
)
}
export { GridGlyph as PdfEditorPagesGlyph }
function InlineLoadingButton({
loading,
disabled,
children,
className,
...props
}: React.ComponentProps<typeof Button> & {
loading?: boolean
}) {
return (
<Button
{...props}
className={cn("relative", className)}
disabled={disabled || loading}
aria-busy={loading || undefined}
>
<span className={cn("contents", loading && "invisible")}>{children}</span>
{loading ? (
<InlineSpinner className="pointer-events-none absolute" />
) : null}
</Button>
)
}
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 }
lib/pdf-thumbnail-utils.ts
import type { PdfDocumentObject, PdfEngine } from "@embedpdf/models"
const PDFIUM_VERSION = "2.15.0"
const PDFIUM_WASM_URL = `https://cdn.jsdelivr.net/npm/@embedpdf/pdfium@${PDFIUM_VERSION}/dist/pdfium.wasm`
let sharedEnginePromise: Promise<PdfEngine> | null = null
const pdfDocumentCache = new Map<string, Promise<PdfDocumentObject>>()
const thumbnailUrlCache = new Map<string, Promise<string | null>>()
export function loadSharedPdfEngine() {
sharedEnginePromise ??= import("@embedpdf/engines/pdfium-worker-engine").then(
({ createPdfiumEngine }) => createPdfiumEngine(PDFIUM_WASM_URL, {})
)
return sharedEnginePromise
}
export async function loadPdfDocument(url: string) {
let documentPromise = pdfDocumentCache.get(url)
if (!documentPromise) {
documentPromise = loadSharedPdfEngine().then((engine) =>
engine
.openDocumentUrl(
{ id: url, url },
{ mode: url.startsWith("blob:") ? "full-fetch" : "auto" }
)
.toPromise()
)
pdfDocumentCache.set(url, documentPromise)
}
return documentPromise
}
export async function getPdfPageCount(url: string) {
return (await loadPdfDocument(url)).pageCount
}
export function renderPdfThumbnailUrl({
dpr = typeof window === "undefined" ? 1 : window.devicePixelRatio || 1,
pageIndex,
url,
width,
}: {
dpr?: number
pageIndex: number
url: string
width: number
}) {
const cacheKey = `${url}#${pageIndex}@${width}x${dpr}`
let thumbnailPromise = thumbnailUrlCache.get(cacheKey)
if (!thumbnailPromise) {
thumbnailPromise = (async () => {
const [engine, document] = await Promise.all([
loadSharedPdfEngine(),
loadPdfDocument(url),
])
const page = document.pages[pageIndex]
if (!page) return null
const blob = await engine
.renderThumbnail(document, page, {
dpr,
imageType: "image/png",
scaleFactor: width / page.size.width,
withAnnotations: true,
})
.toPromise()
return URL.createObjectURL(blob)
})()
thumbnailUrlCache.set(cacheKey, thumbnailPromise)
}
return thumbnailPromise
}