Skip to content

Commit 1bf78c4

Browse files
authored
Merge pull request #601 from embedpdf/feature/add-hidden-noview-flags
Support for noView and hidden flags
2 parents 2834e16 + 59c7c71 commit 1bf78c4

33 files changed

Lines changed: 2176 additions & 96 deletions
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@embedpdf/models': patch
3+
---
4+
5+
Add `PdfAnnotationFlags.LOCKED_CONTENTS` (`1 << 9`) and map it to the `'lockedContents'` `PdfAnnotationFlagName`, extending flag parsing helpers (`flagsToNames`, `namesToFlags`) accordingly.
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@embedpdf/plugin-annotation': patch
3+
---
4+
5+
Add PDF `LOCKED_CONTENTS` flag handling and granular lock helpers (`hasNoViewFlag`, `hasHiddenFlag`, `hasReadOnlyFlag`, `hasLockedContentsFlag`). Expose `isAnnotationInteractive`, `isAnnotationStructurallyLocked`, `isAnnotationContentLocked`, and `isAnnotationSelectable` on the plugin API. Update annotation rendering across React/Preact, Svelte, and Vue to skip `noView`/`hidden` annotations and gate interactions using the new predicates. Thread `structurallyLocked` and `contentLocked` through the selection menu context on all three stacks so custom menus can disable structural or content edits without re-reading flag arrays.
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@embedpdf/snippet': patch
3+
---
4+
5+
Align annotation fill-mode toolbar commands with plugin-configured default lock state via `getDefaultAnnotationLock`, and swap unlock vs form-only lock behavior so defaults match the intended modes. Re-export `LockModeType` from the snippet’s public `embedpdf` entry for consumers.
Lines changed: 312 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,312 @@
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

Comments
 (0)