|
| 1 | +<script lang="ts"> |
| 2 | + import { Viewport } from '@embedpdf/plugin-viewport/svelte'; |
| 3 | + import { Scroller, type RenderPageProps } from '@embedpdf/plugin-scroll/svelte'; |
| 4 | + import { RenderLayer } from '@embedpdf/plugin-render/svelte'; |
| 5 | + import { TilingLayer } from '@embedpdf/plugin-tiling/svelte'; |
| 6 | + import { AnnotationLayer, useAnnotation } from '@embedpdf/plugin-annotation/svelte'; |
| 7 | + import { PagePointerProvider } from '@embedpdf/plugin-interaction-manager/svelte'; |
| 8 | + import { SelectionLayer } from '@embedpdf/plugin-selection/svelte'; |
| 9 | + import { Rotate, useRotate } from '@embedpdf/plugin-rotate/svelte'; |
| 10 | + import { useZoom, ZoomMode } from '@embedpdf/plugin-zoom/svelte'; |
| 11 | + import type { PdfAnnotationFlagName } from '@embedpdf/models'; |
| 12 | + import { MousePointerClick, RotateCcw, RotateCw, Trash2, ZoomIn, ZoomOut } from 'lucide-svelte'; |
| 13 | + import { DEMO_ANNOTATIONS, DEMO_PAGE_INDEX } from './annotation-flags-example-seeds'; |
| 14 | +
|
| 15 | + interface Props { |
| 16 | + documentId: string; |
| 17 | + } |
| 18 | +
|
| 19 | + let { documentId }: Props = $props(); |
| 20 | +
|
| 21 | + const TOGGLEABLE_FLAGS: Array<{ |
| 22 | + flag: PdfAnnotationFlagName; |
| 23 | + description: string; |
| 24 | + }> = [ |
| 25 | + { flag: 'hidden', description: 'not rendered, not selectable' }, |
| 26 | + { flag: 'noView', description: 'not rendered, not selectable, still printable' }, |
| 27 | + { flag: 'readOnly', description: 'rendered but no interaction' }, |
| 28 | + { flag: 'locked', description: 'selectable but cannot be moved / resized / rotated' }, |
| 29 | + { flag: 'lockedContents', description: 'selectable and movable but text cannot be edited' }, |
| 30 | + ]; |
| 31 | +
|
| 32 | + const annotation = useAnnotation(() => documentId); |
| 33 | + const zoom = useZoom(() => documentId); |
| 34 | + const rotate = useRotate(() => documentId); |
| 35 | +
|
| 36 | + const selectedId = $derived(annotation.state.selectedUids[0] ?? null); |
| 37 | + const tracked = $derived(selectedId ? (annotation.state.byUid[selectedId] ?? null) : null); |
| 38 | + const selectedLabel = $derived( |
| 39 | + selectedId ? (DEMO_ANNOTATIONS.find((a) => a.id === selectedId)?.label ?? null) : null, |
| 40 | + ); |
| 41 | + const currentFlags = $derived<PdfAnnotationFlagName[]>(tracked?.object.flags ?? []); |
| 42 | + const flagEditorDisabled = $derived(!tracked || !annotation.provides); |
| 43 | + const zoomPercentage = $derived(Math.round((zoom.state.currentZoomLevel ?? 1) * 100)); |
| 44 | + const rotationDegrees = $derived(rotate.rotation * 90); |
| 45 | +
|
| 46 | + const toggleFlag = (flag: PdfAnnotationFlagName) => { |
| 47 | + const api = annotation.provides; |
| 48 | + const t = tracked; |
| 49 | + if (!api || !t) return; |
| 50 | + const flags = currentFlags; |
| 51 | + const next = flags.includes(flag) ? flags.filter((f) => f !== flag) : [...flags, flag]; |
| 52 | + api.updateAnnotation(DEMO_PAGE_INDEX, t.object.id, { flags: next }); |
| 53 | + }; |
| 54 | +
|
| 55 | + const clearFlags = () => { |
| 56 | + const api = annotation.provides; |
| 57 | + const t = tracked; |
| 58 | + if (!api || !t || currentFlags.length === 0) return; |
| 59 | + api.updateAnnotation(DEMO_PAGE_INDEX, t.object.id, { flags: [] }); |
| 60 | + }; |
| 61 | +
|
| 62 | + const restoreDemo = () => { |
| 63 | + const api = annotation.provides; |
| 64 | + if (!api) return; |
| 65 | + for (const { id, seed } of DEMO_ANNOTATIONS) { |
| 66 | + const existing = api.getAnnotationById(id); |
| 67 | + if (existing) { |
| 68 | + api.updateAnnotation(DEMO_PAGE_INDEX, id, { flags: [] }); |
| 69 | + } else { |
| 70 | + api.createAnnotation(DEMO_PAGE_INDEX, seed); |
| 71 | + } |
| 72 | + } |
| 73 | + }; |
| 74 | +
|
| 75 | + const handleDeleteFromMenu = (pageIndex: number, id: string, locked: boolean) => { |
| 76 | + if (locked) return; |
| 77 | + annotation.provides?.deleteAnnotation(pageIndex, id); |
| 78 | + }; |
| 79 | +
|
| 80 | + const statusLabelFor = (structurallyLocked: boolean, contentLocked: boolean) => { |
| 81 | + if (structurallyLocked) { |
| 82 | + return contentLocked ? 'structurally + content locked' : 'structurally locked'; |
| 83 | + } |
| 84 | + return contentLocked ? 'content locked' : 'interactive'; |
| 85 | + }; |
| 86 | +</script> |
| 87 | + |
| 88 | +<div |
| 89 | + class="overflow-hidden rounded-lg border border-gray-300 bg-white shadow-sm dark:border-gray-700 dark:bg-gray-900" |
| 90 | + style="user-select: none" |
| 91 | +> |
| 92 | + <!-- Toolbar --> |
| 93 | + <div |
| 94 | + class="flex flex-col gap-3 border-b border-gray-300 bg-gray-100 px-3 py-3 dark:border-gray-700 dark:bg-gray-800" |
| 95 | + > |
| 96 | + <div class="flex flex-wrap items-center gap-x-3 gap-y-2"> |
| 97 | + <!-- Zoom controls --> |
| 98 | + {#if zoom.provides} |
| 99 | + <div class="flex items-center gap-1.5"> |
| 100 | + <button |
| 101 | + type="button" |
| 102 | + onclick={() => zoom.provides?.zoomOut()} |
| 103 | + class="inline-flex h-7 w-7 items-center justify-center rounded-md bg-white text-gray-600 shadow-sm ring-1 ring-gray-300 transition-all hover:bg-gray-50 hover:text-gray-900 dark:bg-gray-700 dark:text-gray-300 dark:ring-gray-600 dark:hover:bg-gray-600 dark:hover:text-gray-100" |
| 104 | + title="Zoom out" |
| 105 | + > |
| 106 | + <ZoomOut size={14} /> |
| 107 | + </button> |
| 108 | + <div |
| 109 | + class="min-w-[52px] rounded-md bg-white px-2 py-0.5 text-center shadow-sm ring-1 ring-gray-300 dark:bg-gray-700 dark:ring-gray-600" |
| 110 | + > |
| 111 | + <span class="font-mono text-xs font-medium text-gray-700 dark:text-gray-200"> |
| 112 | + {zoomPercentage}% |
| 113 | + </span> |
| 114 | + </div> |
| 115 | + <button |
| 116 | + type="button" |
| 117 | + onclick={() => zoom.provides?.zoomIn()} |
| 118 | + class="inline-flex h-7 w-7 items-center justify-center rounded-md bg-white text-gray-600 shadow-sm ring-1 ring-gray-300 transition-all hover:bg-gray-50 hover:text-gray-900 dark:bg-gray-700 dark:text-gray-300 dark:ring-gray-600 dark:hover:bg-gray-600 dark:hover:text-gray-100" |
| 119 | + title="Zoom in" |
| 120 | + > |
| 121 | + <ZoomIn size={14} /> |
| 122 | + </button> |
| 123 | + <button |
| 124 | + type="button" |
| 125 | + onclick={() => zoom.provides?.requestZoom(ZoomMode.FitPage)} |
| 126 | + class="ml-1 inline-flex items-center gap-1 rounded-md bg-white px-2 py-1 text-xs font-medium text-gray-600 shadow-sm ring-1 ring-gray-300 transition-all hover:bg-gray-50 hover:text-gray-900 dark:bg-gray-700 dark:text-gray-300 dark:ring-gray-600 dark:hover:bg-gray-600 dark:hover:text-gray-100" |
| 127 | + title="Fit page" |
| 128 | + > |
| 129 | + <span>Fit</span> |
| 130 | + </button> |
| 131 | + </div> |
| 132 | + {/if} |
| 133 | + |
| 134 | + <div class="h-4 w-px bg-gray-300 dark:bg-gray-600"></div> |
| 135 | + |
| 136 | + <!-- Rotate controls --> |
| 137 | + {#if rotate.provides} |
| 138 | + <div class="flex items-center gap-1.5"> |
| 139 | + <button |
| 140 | + type="button" |
| 141 | + onclick={() => rotate.provides?.rotateBackward()} |
| 142 | + class="inline-flex h-7 w-7 items-center justify-center rounded-md bg-white text-gray-600 shadow-sm ring-1 ring-gray-300 transition-all hover:bg-gray-50 hover:text-gray-900 dark:bg-gray-700 dark:text-gray-300 dark:ring-gray-600 dark:hover:bg-gray-600 dark:hover:text-gray-100" |
| 143 | + title="Rotate counter-clockwise" |
| 144 | + > |
| 145 | + <RotateCcw size={14} /> |
| 146 | + </button> |
| 147 | + <div |
| 148 | + class="min-w-[52px] rounded-md bg-white px-2 py-0.5 text-center shadow-sm ring-1 ring-gray-300 dark:bg-gray-700 dark:ring-gray-600" |
| 149 | + > |
| 150 | + <span class="font-mono text-xs font-medium text-gray-700 dark:text-gray-300"> |
| 151 | + {rotationDegrees}° |
| 152 | + </span> |
| 153 | + </div> |
| 154 | + <button |
| 155 | + type="button" |
| 156 | + onclick={() => rotate.provides?.rotateForward()} |
| 157 | + class="inline-flex h-7 w-7 items-center justify-center rounded-md bg-white text-gray-600 shadow-sm ring-1 ring-gray-300 transition-all hover:bg-gray-50 hover:text-gray-900 dark:bg-gray-700 dark:text-gray-300 dark:ring-gray-600 dark:hover:bg-gray-600 dark:hover:text-gray-100" |
| 158 | + title="Rotate clockwise" |
| 159 | + > |
| 160 | + <RotateCw size={14} /> |
| 161 | + </button> |
| 162 | + </div> |
| 163 | + {/if} |
| 164 | + |
| 165 | + <div class="ml-auto"> |
| 166 | + <button |
| 167 | + type="button" |
| 168 | + onclick={restoreDemo} |
| 169 | + class="inline-flex items-center gap-1.5 rounded-md bg-gray-700 px-2.5 py-1.5 text-xs font-medium text-white shadow-sm transition-all hover:bg-gray-800 dark:bg-gray-600 dark:hover:bg-gray-500" |
| 170 | + title="Clear flags and restore all demo annotations" |
| 171 | + > |
| 172 | + <RotateCcw size={14} /> |
| 173 | + <span>Reset demo</span> |
| 174 | + </button> |
| 175 | + </div> |
| 176 | + </div> |
| 177 | + |
| 178 | + <!-- Flag editor: single panel driven by the current selection --> |
| 179 | + <div |
| 180 | + class="rounded-md border border-gray-200 bg-white p-3 dark:border-gray-700 dark:bg-gray-900" |
| 181 | + > |
| 182 | + <div class="mb-2 flex items-center justify-between gap-2"> |
| 183 | + <div class="flex min-w-0 items-center gap-2"> |
| 184 | + {#if tracked} |
| 185 | + <span |
| 186 | + class="inline-block h-2 w-2 shrink-0 rounded-full bg-emerald-500" |
| 187 | + aria-hidden="true" |
| 188 | + ></span> |
| 189 | + <span class="truncate text-xs font-semibold text-gray-800 dark:text-gray-100"> |
| 190 | + {selectedLabel ?? 'Selected annotation'} |
| 191 | + </span> |
| 192 | + <code |
| 193 | + class="hidden shrink-0 rounded bg-gray-50 px-1.5 py-0.5 font-mono text-[10px] text-gray-500 ring-1 ring-gray-200 sm:inline dark:bg-gray-800 dark:text-gray-400 dark:ring-gray-700" |
| 194 | + > |
| 195 | + flags: [{currentFlags.join(', ')}] |
| 196 | + </code> |
| 197 | + {:else} |
| 198 | + <MousePointerClick |
| 199 | + size={14} |
| 200 | + class="shrink-0 text-gray-400 dark:text-gray-500" |
| 201 | + aria-hidden="true" |
| 202 | + /> |
| 203 | + <span class="text-xs font-medium text-gray-500 dark:text-gray-400"> |
| 204 | + Click an annotation on the page to edit its flags |
| 205 | + </span> |
| 206 | + {/if} |
| 207 | + </div> |
| 208 | + <button |
| 209 | + type="button" |
| 210 | + onclick={clearFlags} |
| 211 | + disabled={flagEditorDisabled || currentFlags.length === 0} |
| 212 | + class="inline-flex shrink-0 items-center gap-1 rounded-md bg-white px-2 py-1 text-[11px] font-medium text-gray-600 shadow-sm ring-1 ring-gray-300 transition-all hover:bg-gray-50 hover:text-gray-900 disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:bg-white disabled:hover:text-gray-600 dark:bg-gray-700 dark:text-gray-300 dark:ring-gray-600 dark:hover:bg-gray-600 dark:hover:text-gray-100 dark:disabled:hover:bg-gray-700 dark:disabled:hover:text-gray-300" |
| 213 | + title="Clear all flags on the selected annotation" |
| 214 | + > |
| 215 | + Clear flags |
| 216 | + </button> |
| 217 | + </div> |
| 218 | + |
| 219 | + <div class="grid grid-cols-2 gap-1.5 sm:grid-cols-3 md:grid-cols-5"> |
| 220 | + {#each TOGGLEABLE_FLAGS as { flag, description } (flag)} |
| 221 | + <button |
| 222 | + type="button" |
| 223 | + onclick={() => toggleFlag(flag)} |
| 224 | + disabled={flagEditorDisabled} |
| 225 | + title={description} |
| 226 | + class={[ |
| 227 | + 'flex flex-col items-start gap-0.5 rounded-md px-2 py-1.5 text-left text-xs font-medium shadow-sm transition-all disabled:cursor-not-allowed disabled:opacity-40', |
| 228 | + currentFlags.includes(flag) |
| 229 | + ? 'bg-blue-500 text-white ring-1 ring-blue-600 hover:bg-blue-600' |
| 230 | + : 'bg-white text-gray-700 ring-1 ring-gray-300 hover:bg-gray-50 hover:text-gray-900 dark:bg-gray-800 dark:text-gray-200 dark:ring-gray-700 dark:hover:bg-gray-700', |
| 231 | + ].join(' ')} |
| 232 | + > |
| 233 | + <span class="font-mono text-[11px]">{flag}</span> |
| 234 | + <span |
| 235 | + class={[ |
| 236 | + 'text-[10px] font-normal leading-tight', |
| 237 | + currentFlags.includes(flag) ? 'text-blue-50' : 'text-gray-500 dark:text-gray-400', |
| 238 | + ].join(' ')} |
| 239 | + > |
| 240 | + {description} |
| 241 | + </span> |
| 242 | + </button> |
| 243 | + {/each} |
| 244 | + </div> |
| 245 | + </div> |
| 246 | + </div> |
| 247 | + |
| 248 | + <!-- PDF Viewer Area --> |
| 249 | + <div class="relative h-[500px] sm:h-[600px]"> |
| 250 | + {#snippet renderPage(page: RenderPageProps)} |
| 251 | + <Rotate {documentId} pageIndex={page.pageIndex}> |
| 252 | + <PagePointerProvider {documentId} pageIndex={page.pageIndex}> |
| 253 | + <RenderLayer |
| 254 | + {documentId} |
| 255 | + pageIndex={page.pageIndex} |
| 256 | + scale={1} |
| 257 | + class="pointer-events-none" |
| 258 | + /> |
| 259 | + <TilingLayer {documentId} pageIndex={page.pageIndex} class="pointer-events-none" /> |
| 260 | + <SelectionLayer {documentId} pageIndex={page.pageIndex} /> |
| 261 | + <AnnotationLayer {documentId} pageIndex={page.pageIndex}> |
| 262 | + {#snippet selectionMenuSnippet({ |
| 263 | + selected, |
| 264 | + context, |
| 265 | + menuWrapperProps, |
| 266 | + rect, |
| 267 | + placement, |
| 268 | + })} |
| 269 | + {#if selected} |
| 270 | + <span style={menuWrapperProps.style} use:menuWrapperProps.action> |
| 271 | + <div |
| 272 | + class="flex items-center gap-1.5 rounded-md border border-gray-200 bg-white px-1.5 py-1 text-xs shadow-md dark:border-gray-700 dark:bg-gray-800" |
| 273 | + style:position="absolute" |
| 274 | + style:pointer-events="auto" |
| 275 | + style:cursor="default" |
| 276 | + style:top={placement.suggestTop ? '-40px' : `${rect.size.height + 8}px`} |
| 277 | + > |
| 278 | + <button |
| 279 | + type="button" |
| 280 | + onclick={() => |
| 281 | + handleDeleteFromMenu( |
| 282 | + context.annotation.object.pageIndex, |
| 283 | + context.annotation.object.id, |
| 284 | + context.structurallyLocked, |
| 285 | + )} |
| 286 | + disabled={context.structurallyLocked} |
| 287 | + title={context.structurallyLocked |
| 288 | + ? 'Locked — cannot delete' |
| 289 | + : 'Delete annotation'} |
| 290 | + class="inline-flex h-6 w-6 items-center justify-center rounded text-gray-600 transition-colors hover:bg-red-50 hover:text-red-600 disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:bg-transparent disabled:hover:text-gray-600 dark:text-gray-300 dark:hover:bg-red-500/20 dark:hover:text-red-400 dark:disabled:hover:text-gray-300" |
| 291 | + aria-label="Delete annotation" |
| 292 | + > |
| 293 | + <Trash2 size={13} /> |
| 294 | + </button> |
| 295 | + <span |
| 296 | + class="border-l border-gray-200 pl-2 font-mono text-[10px] tracking-tight text-gray-500 dark:border-gray-700 dark:text-gray-400" |
| 297 | + > |
| 298 | + {statusLabelFor(context.structurallyLocked, context.contentLocked)} |
| 299 | + </span> |
| 300 | + </div> |
| 301 | + </span> |
| 302 | + {/if} |
| 303 | + {/snippet} |
| 304 | + </AnnotationLayer> |
| 305 | + </PagePointerProvider> |
| 306 | + </Rotate> |
| 307 | + {/snippet} |
| 308 | + <Viewport {documentId} class="absolute inset-0 bg-gray-200 dark:bg-gray-800"> |
| 309 | + <Scroller {documentId} {renderPage} /> |
| 310 | + </Viewport> |
| 311 | + </div> |
| 312 | +</div> |
0 commit comments