Skip to content

Commit 305f6ca

Browse files
authored
Merge pull request #452 from embedpdf/feature/annotation-rotations
Feature/annotation rotations
2 parents 11accd5 + b064b8e commit 305f6ca

107 files changed

Lines changed: 10155 additions & 3157 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
'@embedpdf/plugin-annotation': minor
3+
---
4+
5+
- Add support for rotating annotations.
6+
- Add `rotationUI` prop to `AnnotationLayer` and `AnnotationContainer`.
7+
- Add `isRotatable` and `isGroupRotatable` properties to `AnnotationTool`.
8+
- Add `insertUpright` behavior for stamps and free text.
9+
- Update `AnnotationLayer` to support custom rotation handles via slots/components.

.changeset/rotate-engines.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
'@embedpdf/engines': minor
3+
---
4+
5+
- Update PDFium engine to support saving and loading rotated annotations.
6+
- Add support for `EPDFAnnot_SetRotate`, `EPDFAnnot_SetExtendedRotation`, and `EPDFAnnot_SetUnrotatedRect`.
7+
- Implement unrotated rendering path for rotated annotations.

.changeset/rotate-models.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'@embedpdf/models': minor
3+
---
4+
5+
- Add rotation geometry utilities: `rotatePointAround`, `calculateRotatedRectAABB`, `inferRotationCenterFromRects`.
6+
- Add `rotation` and `unrotatedRect` properties to `PdfAnnotationObjectBase`.

.changeset/rotate-pdfium.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'@embedpdf/pdfium': minor
3+
---
4+
5+
- Export new rotation-related PDFium functions: `EPDFAnnot_SetRotate`, `EPDFAnnot_GetRotate`, `EPDFAnnot_SetExtendedRotation`, etc.
6+
- Update WASM build.

.changeset/rotate-redaction.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@embedpdf/plugin-redaction': patch
3+
---
4+
5+
- Explicitly disable rotation for redaction tools.

.changeset/rotate-snippet.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'@embedpdf/snippet': minor
3+
---
4+
5+
- Add rotation property control to the annotation sidebar.
6+
- Update selection menu to handle rotated annotations.

.changeset/rotate-utils.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
'@embedpdf/utils': minor
3+
---
4+
5+
- Update `DragResizeController` to support rotation interactions.
6+
- Add `useInteractionHandles` support for rotation handles.
7+
- Add rotation snapping and constraints.
Lines changed: 237 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,237 @@
1+
<script lang="ts">
2+
import { onMount, onDestroy } from 'svelte';
3+
import { Viewport } from '@embedpdf/plugin-viewport/svelte';
4+
import { Scroller, type RenderPageProps } from '@embedpdf/plugin-scroll/svelte';
5+
import { RenderLayer } from '@embedpdf/plugin-render/svelte';
6+
import {
7+
AnnotationLayer,
8+
useAnnotationCapability,
9+
type HandleProps,
10+
type RotationHandleComponentProps,
11+
} from '@embedpdf/plugin-annotation/svelte';
12+
import { PagePointerProvider } from '@embedpdf/plugin-interaction-manager/svelte';
13+
import { SelectionLayer } from '@embedpdf/plugin-selection/svelte';
14+
import { Pencil, Square, GitBranch, Stamp, Trash2 } from 'lucide-svelte';
15+
16+
interface Props {
17+
documentId: string;
18+
}
19+
20+
let { documentId }: Props = $props();
21+
22+
let activeTool = $state<string | null>(null);
23+
let canDelete = $state(false);
24+
25+
const annotationCapability = useAnnotationCapability();
26+
const annotationApi = $derived(annotationCapability.provides?.forDocument(documentId));
27+
28+
const tools = [
29+
{ id: 'square', name: 'Square', icon: Square },
30+
{ id: 'ink', name: 'Pen', icon: Pencil },
31+
{ id: 'polyline', name: 'Polyline', icon: GitBranch },
32+
{ id: 'stampImage', name: 'Stamp', icon: Stamp },
33+
];
34+
35+
let unsubscribeToolChange: (() => void) | undefined;
36+
let unsubscribeStateChange: (() => void) | undefined;
37+
38+
onMount(() => {
39+
if (!annotationApi) return;
40+
41+
unsubscribeToolChange = annotationApi.onActiveToolChange((tool) => {
42+
activeTool = tool?.id ?? null;
43+
});
44+
45+
unsubscribeStateChange = annotationApi.onStateChange((state) => {
46+
canDelete = !!state.selectedUid;
47+
});
48+
});
49+
50+
onDestroy(() => {
51+
unsubscribeToolChange?.();
52+
unsubscribeStateChange?.();
53+
});
54+
55+
const handleToolClick = (toolId: string) => {
56+
annotationApi?.setActiveTool(activeTool === toolId ? null : toolId);
57+
};
58+
59+
const handleDelete = () => {
60+
const selection = annotationApi?.getSelectedAnnotation();
61+
if (selection) {
62+
annotationApi?.deleteAnnotation(selection.object.pageIndex, selection.object.id);
63+
}
64+
};
65+
66+
const handleDeleteFromMenu = (pageIndex: number, id: string) => {
67+
annotationApi?.deleteAnnotation(pageIndex, id);
68+
};
69+
</script>
70+
71+
<!-- Custom square resize handles -->
72+
{#snippet resizeHandleSnippet({ style, backgroundColor, key: _key, ...rest }: HandleProps)}
73+
<div
74+
{...rest}
75+
style="{style}; background-color: transparent; border: 2px solid {backgroundColor ??
76+
'#475569'}; border-radius: 2px;"
77+
></div>
78+
{/snippet}
79+
80+
<!-- Custom diamond vertex handles (rotated 45deg squares) -->
81+
{#snippet vertexHandleSnippet({ style, backgroundColor, key: _key, ...rest }: HandleProps)}
82+
<div
83+
{...rest}
84+
style="{style}; background-color: {backgroundColor ??
85+
'#475569'}; border-radius: 1px; rotate: 45deg;"
86+
></div>
87+
{/snippet}
88+
89+
<!-- Custom pill-shaped rotation handle with connector -->
90+
{#snippet rotationHandleSnippet({
91+
style,
92+
backgroundColor,
93+
connectorStyle,
94+
showConnector,
95+
iconColor,
96+
opacity,
97+
border: _border,
98+
...rest
99+
}: RotationHandleComponentProps)}
100+
{#if showConnector && connectorStyle}
101+
<div style={connectorStyle}></div>
102+
{/if}
103+
<div
104+
{...rest}
105+
style="{style}; background-color: {backgroundColor ??
106+
'#475569'}; border-radius: 999px; display: flex; align-items: center; justify-content: center; box-shadow: 0 1px 3px rgba(0,0,0,0.25); opacity: {opacity};"
107+
>
108+
<svg
109+
width="12"
110+
height="12"
111+
viewBox="0 0 24 24"
112+
fill="none"
113+
stroke={iconColor ?? 'white'}
114+
stroke-width="2.5"
115+
stroke-linecap="round"
116+
stroke-linejoin="round"
117+
>
118+
<path d="M1 4v6h6" />
119+
<path d="M23 20v-6h-6" />
120+
<path d="M20.49 9A9 9 0 0 0 5.64 5.64L1 10M23 14l-4.64 4.36A9 9 0 0 1 3.51 15" />
121+
</svg>
122+
</div>
123+
{/snippet}
124+
125+
<div
126+
class="overflow-hidden rounded-lg border border-gray-300 bg-white shadow-sm dark:border-gray-700 dark:bg-gray-900"
127+
style="user-select: none"
128+
>
129+
<!-- Toolbar -->
130+
<div
131+
class="flex flex-wrap items-center gap-3 border-b border-gray-300 bg-gray-100 px-3 py-2 dark:border-gray-700 dark:bg-gray-800"
132+
>
133+
<span class="text-xs font-medium uppercase tracking-wide text-gray-600 dark:text-gray-300">
134+
Tools
135+
</span>
136+
<div class="h-4 w-px bg-gray-300 dark:bg-gray-600"></div>
137+
<div class="flex items-center gap-1.5">
138+
{#each tools as tool (tool.id)}
139+
<button
140+
type="button"
141+
onclick={() => handleToolClick(tool.id)}
142+
class={[
143+
'inline-flex items-center gap-1.5 rounded-md px-2.5 py-1.5 text-xs font-medium shadow-sm transition-all',
144+
activeTool === tool.id
145+
? 'bg-slate-600 text-white ring-1 ring-slate-700'
146+
: 'bg-white text-gray-600 ring-1 ring-gray-300 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',
147+
].join(' ')}
148+
title={tool.name}
149+
>
150+
<tool.icon size={14} />
151+
<span class="hidden sm:inline">{tool.name}</span>
152+
</button>
153+
{/each}
154+
</div>
155+
<div class="h-4 w-px bg-gray-300 dark:bg-gray-600"></div>
156+
<button
157+
type="button"
158+
onclick={handleDelete}
159+
disabled={!canDelete}
160+
class="inline-flex items-center gap-1.5 rounded-md bg-red-500 px-2.5 py-1.5 text-xs font-medium text-white shadow-sm transition-all hover:bg-red-600 disabled:cursor-not-allowed disabled:opacity-50"
161+
>
162+
<Trash2 size={14} />
163+
<span class="hidden sm:inline">Delete</span>
164+
</button>
165+
166+
{#if activeTool}
167+
<span class="hidden animate-pulse text-xs text-slate-600 lg:inline dark:text-slate-400">
168+
Click on the PDF to add annotation
169+
</span>
170+
{/if}
171+
</div>
172+
173+
<!-- PDF Viewer Area -->
174+
<div class="relative h-[450px] sm:h-[550px]">
175+
{#snippet renderPage(page: RenderPageProps)}
176+
<PagePointerProvider {documentId} pageIndex={page.pageIndex}>
177+
<RenderLayer
178+
{documentId}
179+
pageIndex={page.pageIndex}
180+
scale={1}
181+
class="pointer-events-none"
182+
/>
183+
<SelectionLayer {documentId} pageIndex={page.pageIndex} />
184+
<AnnotationLayer
185+
{documentId}
186+
pageIndex={page.pageIndex}
187+
resizeUI={{ size: 10, color: '#475569', component: resizeHandleSnippet }}
188+
vertexUI={{ size: 10, color: '#475569', component: vertexHandleSnippet }}
189+
rotationUI={{
190+
size: 24,
191+
color: '#475569',
192+
iconColor: 'white',
193+
margin: 28,
194+
showConnector: true,
195+
connectorColor: '#94a3b8',
196+
component: rotationHandleSnippet,
197+
}}
198+
selectionOutline={{ color: '#475569', style: 'solid', width: 1, offset: 2 }}
199+
groupSelectionOutline={{ color: '#64748b', style: 'dashed', width: 2, offset: 3 }}
200+
>
201+
{#snippet selectionMenuSnippet({ selected, context, menuWrapperProps, rect })}
202+
{#if selected}
203+
<span style={menuWrapperProps.style} use:menuWrapperProps.action>
204+
<div
205+
class="rounded-lg border border-gray-200 bg-white shadow-lg dark:border-gray-700 dark:bg-gray-800"
206+
style:position="absolute"
207+
style:top="{rect.size.height + 8}px"
208+
style:pointer-events="auto"
209+
style:cursor="default"
210+
>
211+
<div class="flex items-center gap-1 px-2 py-1">
212+
<button
213+
type="button"
214+
onclick={() =>
215+
handleDeleteFromMenu(
216+
context.annotation.object.pageIndex,
217+
context.annotation.object.id,
218+
)}
219+
class="flex items-center justify-center rounded p-1.5 text-gray-600 transition-colors hover:bg-gray-100 hover:text-red-600 dark:text-gray-300 dark:hover:bg-gray-700 dark:hover:text-red-400"
220+
aria-label="Delete annotation"
221+
title="Delete annotation"
222+
>
223+
<Trash2 size={16} />
224+
</button>
225+
</div>
226+
</div>
227+
</span>
228+
{/if}
229+
{/snippet}
230+
</AnnotationLayer>
231+
</PagePointerProvider>
232+
{/snippet}
233+
<Viewport {documentId} class="absolute inset-0 bg-gray-200 dark:bg-gray-800">
234+
<Scroller {documentId} {renderPage} />
235+
</Viewport>
236+
</div>
237+
</div>
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
<script lang="ts">
2+
import { usePdfiumEngine } from '@embedpdf/engines/svelte';
3+
import { EmbedPDF } from '@embedpdf/core/svelte';
4+
import { createPluginRegistration, type PluginRegistry } from '@embedpdf/core';
5+
import {
6+
DocumentManagerPluginPackage,
7+
DocumentContent,
8+
} from '@embedpdf/plugin-document-manager/svelte';
9+
import { ViewportPluginPackage } from '@embedpdf/plugin-viewport/svelte';
10+
import { ScrollPluginPackage } from '@embedpdf/plugin-scroll/svelte';
11+
import { RenderPluginPackage } from '@embedpdf/plugin-render/svelte';
12+
import {
13+
type AnnotationPlugin,
14+
AnnotationPluginPackage,
15+
type AnnotationTool,
16+
} from '@embedpdf/plugin-annotation/svelte';
17+
import { InteractionManagerPluginPackage } from '@embedpdf/plugin-interaction-manager/svelte';
18+
import { SelectionPluginPackage } from '@embedpdf/plugin-selection/svelte';
19+
import { HistoryPluginPackage } from '@embedpdf/plugin-history/svelte';
20+
import { PdfAnnotationSubtype, type PdfStampAnnoObject } from '@embedpdf/models';
21+
import AnnotationCustomUIContent from './annotation-custom-ui-example-content.svelte';
22+
23+
const pdfEngine = usePdfiumEngine();
24+
25+
const plugins = [
26+
createPluginRegistration(DocumentManagerPluginPackage, {
27+
initialDocuments: [{ url: 'https://snippet.embedpdf.com/ebook.pdf' }],
28+
}),
29+
createPluginRegistration(ViewportPluginPackage),
30+
createPluginRegistration(ScrollPluginPackage),
31+
createPluginRegistration(RenderPluginPackage),
32+
createPluginRegistration(InteractionManagerPluginPackage),
33+
createPluginRegistration(SelectionPluginPackage),
34+
createPluginRegistration(HistoryPluginPackage),
35+
createPluginRegistration(AnnotationPluginPackage, {
36+
annotationAuthor: 'EmbedPDF User',
37+
}),
38+
];
39+
40+
const handleInitialized = async (registry: PluginRegistry) => {
41+
const annotation = registry.getPlugin<AnnotationPlugin>('annotation')?.provides();
42+
43+
annotation?.addTool<AnnotationTool<PdfStampAnnoObject>>({
44+
id: 'stampImage',
45+
name: 'Image Stamp',
46+
interaction: {
47+
exclusive: false,
48+
cursor: 'crosshair',
49+
},
50+
matchScore: () => 0,
51+
defaults: {
52+
type: PdfAnnotationSubtype.STAMP,
53+
imageSrc: '/circle-checkmark.png',
54+
imageSize: { width: 40, height: 40 },
55+
},
56+
});
57+
};
58+
</script>
59+
60+
{#if pdfEngine.isLoading || !pdfEngine.engine}
61+
<div>Loading PDF Engine...</div>
62+
{:else}
63+
<EmbedPDF engine={pdfEngine.engine} {plugins} onInitialized={handleInitialized}>
64+
{#snippet children({ activeDocumentId })}
65+
{#if activeDocumentId}
66+
<DocumentContent documentId={activeDocumentId}>
67+
{#snippet children(documentContent)}
68+
{#if documentContent.isLoaded}
69+
<AnnotationCustomUIContent documentId={activeDocumentId} />
70+
{/if}
71+
{/snippet}
72+
</DocumentContent>
73+
{/if}
74+
{/snippet}
75+
</EmbedPDF>
76+
{/if}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { default } from './annotation-custom-ui-example.svelte';

0 commit comments

Comments
 (0)