This commit is contained in:
1 parent
891026ccbb
commit
adaae41e44
33 files changed
+2771
-396
No files matched your search
+21
-22
@@ -1,31 +1,30 @@
|
||||
export function ripple(node: HTMLElement) {
|
||||
function handleClick(event: MouseEvent) {
|
||||
const circle = document.createElement("span");
|
||||
function handleClick(event: MouseEvent) {
|
||||
const circle = document.createElement('span');
|
||||
|
||||
const rect = node.getBoundingClientRect();
|
||||
const size = Math.max(rect.width, rect.height);
|
||||
const rect = node.getBoundingClientRect();
|
||||
const size = Math.max(rect.width, rect.height);
|
||||
|
||||
circle.style.width = `${size}px`;
|
||||
circle.style.height = `${size}px`;
|
||||
circle.style.width = `${size}px`;
|
||||
circle.style.height = `${size}px`;
|
||||
|
||||
circle.style.left = `${event.clientX - rect.left - size / 2}px`;
|
||||
circle.style.top = `${event.clientY - rect.top - size / 2}px`;
|
||||
circle.style.left = `${event.clientX - rect.left - size / 2}px`;
|
||||
circle.style.top = `${event.clientY - rect.top - size / 2}px`;
|
||||
|
||||
circle.className =
|
||||
"absolute rounded-full bg-white/30 animate-ripple pointer-events-none";
|
||||
circle.className = 'absolute rounded-full bg-white/30 animate-ripple pointer-events-none';
|
||||
|
||||
node.appendChild(circle);
|
||||
node.appendChild(circle);
|
||||
|
||||
circle.addEventListener("animationend", () => {
|
||||
circle.remove();
|
||||
});
|
||||
}
|
||||
circle.addEventListener('animationend', () => {
|
||||
circle.remove();
|
||||
});
|
||||
}
|
||||
|
||||
node.addEventListener("click", handleClick);
|
||||
node.addEventListener('click', handleClick);
|
||||
|
||||
return {
|
||||
destroy() {
|
||||
node.removeEventListener("click", handleClick);
|
||||
}
|
||||
};
|
||||
}
|
||||
return {
|
||||
destroy() {
|
||||
node.removeEventListener('click', handleClick);
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
<script lang="ts">
|
||||
type Props = {
|
||||
onFilesSelected: (files: File[]) => void;
|
||||
onFilesRejected?: (files: File[]) => void;
|
||||
accept?: string;
|
||||
multiple?: boolean;
|
||||
ariaLabel?: string;
|
||||
label?: string;
|
||||
sublabel?: string;
|
||||
class?: string;
|
||||
};
|
||||
|
||||
let {
|
||||
onFilesSelected,
|
||||
onFilesRejected,
|
||||
accept = '.pdf,application/pdf',
|
||||
multiple = false,
|
||||
ariaLabel = 'PDF-Dateien hinzufügen',
|
||||
label = 'Dateien hinzufügen',
|
||||
sublabel = 'PDF hier ablegen oder zum Auswählen klicken',
|
||||
class: className = ''
|
||||
}: Props = $props();
|
||||
|
||||
let fileInput = $state<HTMLInputElement | null>(null);
|
||||
let isDragging = $state(false);
|
||||
let rejectionMessage = $state<string | null>(null);
|
||||
let dragDepth = 0;
|
||||
|
||||
const matchesAccept = (file: File) =>
|
||||
accept
|
||||
.split(',')
|
||||
.map((value) => value.trim().toLowerCase())
|
||||
.filter(Boolean)
|
||||
.some((filter) => {
|
||||
if (filter.startsWith('.')) return file.name.toLowerCase().endsWith(filter);
|
||||
if (filter.endsWith('/*')) return file.type.toLowerCase().startsWith(filter.slice(0, -1));
|
||||
return file.type.toLowerCase() === filter;
|
||||
});
|
||||
|
||||
const processFiles = (files: FileList | null | undefined) => {
|
||||
if (!files) return;
|
||||
|
||||
const received = Array.from(files);
|
||||
const accepted = received.filter(matchesAccept);
|
||||
const rejected = received.filter((file) => !matchesAccept(file));
|
||||
|
||||
if (rejected.length > 0) {
|
||||
rejectionMessage = `${rejected.map((file) => file.name).join(', ')}: Dateityp nicht unterstützt.`;
|
||||
onFilesRejected?.(rejected);
|
||||
}
|
||||
if (accepted.length > 0) {
|
||||
rejectionMessage = null;
|
||||
onFilesSelected(multiple ? accepted : accepted.slice(0, 1));
|
||||
}
|
||||
};
|
||||
|
||||
const openFileDialog = () => fileInput?.click();
|
||||
|
||||
const handleFileSelection = (event: Event) => {
|
||||
const input = event.currentTarget as HTMLInputElement;
|
||||
processFiles(input.files);
|
||||
input.value = '';
|
||||
};
|
||||
|
||||
const handleDragEnter = (event: DragEvent) => {
|
||||
event.preventDefault();
|
||||
dragDepth += 1;
|
||||
isDragging = true;
|
||||
};
|
||||
|
||||
const handleDragOver = (event: DragEvent) => {
|
||||
event.preventDefault();
|
||||
if (event.dataTransfer) event.dataTransfer.dropEffect = 'copy';
|
||||
};
|
||||
|
||||
const handleDragLeave = (event: DragEvent) => {
|
||||
event.preventDefault();
|
||||
dragDepth = Math.max(0, dragDepth - 1);
|
||||
if (dragDepth === 0) isDragging = false;
|
||||
};
|
||||
|
||||
const handleDrop = (event: DragEvent) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
dragDepth = 0;
|
||||
isDragging = false;
|
||||
processFiles(event.dataTransfer?.files);
|
||||
};
|
||||
</script>
|
||||
|
||||
<input
|
||||
bind:this={fileInput}
|
||||
class="hidden"
|
||||
type="file"
|
||||
{accept}
|
||||
{multiple}
|
||||
onchange={handleFileSelection}
|
||||
/>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full cursor-pointer flex-col items-center justify-center gap-2 rounded-2xl border-2 border-dashed border-primary-200 bg-primary-50/50 px-6 py-10 text-center transition focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-400 {isDragging
|
||||
? 'border-primary-400 bg-primary-100/60'
|
||||
: ''} {className}"
|
||||
aria-label={ariaLabel}
|
||||
data-dropzone-ready={fileInput ? 'true' : 'false'}
|
||||
onclick={openFileDialog}
|
||||
ondragenter={handleDragEnter}
|
||||
ondragover={handleDragOver}
|
||||
ondragleave={handleDragLeave}
|
||||
ondrop={handleDrop}
|
||||
>
|
||||
<div class="flex flex-row items-baseline justify-center gap-2">
|
||||
<span class="text-[18px] font-bold text-primary-900">{label}</span>
|
||||
<span class="text-[20px] font-extrabold text-primary">+</span>
|
||||
</div>
|
||||
{#if sublabel}
|
||||
<p class="text-[13px] text-primary-700">{sublabel}</p>
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
{#if rejectionMessage}
|
||||
<p class="mt-2 text-sm font-medium text-red-700" role="alert">{rejectionMessage}</p>
|
||||
{/if}
|
||||
@@ -0,0 +1,54 @@
|
||||
<script lang="ts">
|
||||
import type { PdfThumbnail } from '$lib/pdf-thumbnails';
|
||||
import { cn } from '$lib/utils';
|
||||
|
||||
type Props = {
|
||||
thumbnails: PdfThumbnail[];
|
||||
selectedPages?: Set<number>;
|
||||
onPageClick?: (pageNumber: number) => void;
|
||||
thumbnailWidth?: number;
|
||||
class?: string;
|
||||
};
|
||||
|
||||
let {
|
||||
thumbnails,
|
||||
selectedPages = new Set<number>(),
|
||||
onPageClick,
|
||||
thumbnailWidth = 120,
|
||||
class: className = ''
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
class={cn('grid gap-3', className)}
|
||||
style="grid-template-columns: repeat(auto-fill, minmax({thumbnailWidth}px, 1fr));"
|
||||
>
|
||||
{#each thumbnails as thumb}
|
||||
{@const isSelected = selectedPages.has(thumb.pageNumber)}
|
||||
<button
|
||||
type="button"
|
||||
class={cn(
|
||||
'group relative flex flex-col items-center gap-2 rounded-xl border-2 p-2 transition focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-400',
|
||||
isSelected
|
||||
? 'border-primary bg-primary/5'
|
||||
: 'border-transparent hover:border-primary-200 hover:bg-primary-50/50'
|
||||
)}
|
||||
onclick={() => onPageClick?.(thumb.pageNumber)}
|
||||
>
|
||||
<img
|
||||
src={thumb.imageUrl}
|
||||
alt="Seite {thumb.pageNumber}"
|
||||
class="w-full rounded border border-primary-100 bg-white object-contain"
|
||||
style="aspect-ratio: {thumb.width} / {thumb.height};"
|
||||
/>
|
||||
<span class="text-[12px] font-medium text-primary-700">Seite {thumb.pageNumber}</span>
|
||||
{#if isSelected}
|
||||
<div
|
||||
class="absolute top-1 right-1 flex h-5 w-5 items-center justify-center rounded-full bg-primary text-[10px] font-bold text-white"
|
||||
>
|
||||
✓
|
||||
</div>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
@@ -4,6 +4,7 @@
|
||||
import SubDocumentEditor from '$lib/components/SubDocumentEditor.svelte';
|
||||
import {
|
||||
buildArchiveExport,
|
||||
buildSubDocumentExport,
|
||||
createSubDocument,
|
||||
dissolveSubDocument,
|
||||
moveAttachmentIntoSubDocument,
|
||||
@@ -41,8 +42,11 @@
|
||||
|
||||
let downloadMode = $state<ExportMode>('single');
|
||||
let isMerging = $state(false);
|
||||
let downloadingSubDocumentId = $state<string | null>(null);
|
||||
let mergeError = $state<string | null>(null);
|
||||
|
||||
const isExportActive = $derived(isMerging || downloadingSubDocumentId !== null);
|
||||
|
||||
$effect(() => {
|
||||
archiveName = archive.name;
|
||||
looseAttachments = archive.attachments.map((attachment) => ({ ...attachment }));
|
||||
@@ -293,7 +297,7 @@
|
||||
return;
|
||||
}
|
||||
|
||||
if (isMerging) {
|
||||
if (isExportActive) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -313,6 +317,29 @@
|
||||
isMerging = false;
|
||||
}
|
||||
};
|
||||
|
||||
const downloadSubDocument = async (subDocument: SubDocument) => {
|
||||
if (isExportActive || subDocument.attachments.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
downloadingSubDocumentId = subDocument.id;
|
||||
mergeError = null;
|
||||
|
||||
try {
|
||||
const exportUnit = await buildSubDocumentExport(archiveName, subDocument);
|
||||
|
||||
downloadPdfBytes(exportUnit.bytes, exportUnit.name);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`Failed to export sub-document "${subDocument.name}" from archive ${archive.name}`,
|
||||
error
|
||||
);
|
||||
mergeError = `Teildokument „${subDocument.name}“ konnte nicht als PDF erstellt werden.`;
|
||||
} finally {
|
||||
downloadingSubDocumentId = null;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={handleWindowKeydown} />
|
||||
@@ -354,17 +381,21 @@
|
||||
|
||||
{#if hasSubDocuments}
|
||||
<div
|
||||
class="inline-flex items-center rounded-[16px] border border-primary-200 bg-white p-0.5"
|
||||
class={cn(
|
||||
'inline-flex items-center rounded-[16px] border border-primary-200 bg-white p-0.5',
|
||||
downloadingSubDocumentId !== null ? 'opacity-60' : ''
|
||||
)}
|
||||
role="radiogroup"
|
||||
aria-label="Export-Modus"
|
||||
>
|
||||
{#each [{ value: 'single', label: 'Eine PDF' }, { value: 'separate', label: 'Getrennt' }] as option (option.value)}
|
||||
<label
|
||||
class={cn(
|
||||
'cursor-pointer rounded-[14px] px-3 py-1 text-[13px] font-semibold transition',
|
||||
'rounded-[14px] px-3 py-1 text-[13px] font-semibold transition',
|
||||
downloadMode === option.value
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'text-primary-700 hover:bg-primary-50'
|
||||
: 'text-primary-700 hover:bg-primary-50',
|
||||
downloadingSubDocumentId !== null ? 'cursor-not-allowed' : 'cursor-pointer'
|
||||
)}
|
||||
>
|
||||
<input
|
||||
@@ -373,6 +404,7 @@
|
||||
name={`download-mode-${archive.name}`}
|
||||
value={option.value}
|
||||
checked={downloadMode === option.value}
|
||||
disabled={downloadingSubDocumentId !== null}
|
||||
onchange={() => {
|
||||
downloadMode = option.value as ExportMode;
|
||||
}}
|
||||
@@ -386,7 +418,7 @@
|
||||
<Button
|
||||
class="bg-primary"
|
||||
type="button"
|
||||
disabled={(looseAttachments.length === 0 && subDocuments.length === 0) || isMerging}
|
||||
disabled={(looseAttachments.length === 0 && subDocuments.length === 0) || isExportActive}
|
||||
onclick={downloadArchive}
|
||||
>
|
||||
{isMerging ? 'PDF wird erstellt' : 'PDF herunterladen'}
|
||||
@@ -495,6 +527,9 @@
|
||||
onBlockDragOver={handleBlockDragOver}
|
||||
onBlockDrop={handleBlockDrop}
|
||||
onBlockDragEnd={handleBlockDragEnd}
|
||||
onDownload={() => downloadSubDocument(subDocument)}
|
||||
isDownloading={downloadingSubDocumentId === subDocument.id}
|
||||
downloadDisabled={isExportActive}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
<script lang="ts">
|
||||
import { AlertTriangle } from '@lucide/svelte';
|
||||
import { isRasterizationTooLarge, shouldWarnAboutRasterization } from '$lib/rasterization-limits';
|
||||
|
||||
type Props = { file: File | null };
|
||||
let { file }: Props = $props();
|
||||
</script>
|
||||
|
||||
{#if shouldWarnAboutRasterization(file)}
|
||||
<div
|
||||
class="flex gap-3 rounded-lg border p-4 text-sm {isRasterizationTooLarge(file)
|
||||
? 'border-red-200 bg-red-50 text-red-800'
|
||||
: 'border-amber-200 bg-amber-50 text-amber-800'}"
|
||||
role={isRasterizationTooLarge(file) ? 'alert' : undefined}
|
||||
>
|
||||
<AlertTriangle class="mt-0.5 h-5 w-5 shrink-0" />
|
||||
<p>
|
||||
{isRasterizationTooLarge(file)
|
||||
? 'Diese Datei ist größer als 100 MB und kann aus Speichergründen nicht im Browser gerastert werden.'
|
||||
: 'Diese große Datei benötigt beim Rasterisieren viel Arbeitsspeicher. Schließen Sie andere speicherintensive Tabs.'}
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import AttachmentPreview from '$lib/components/AttachmentPreview.svelte';
|
||||
import Button from '$lib/components/ui/button.svelte';
|
||||
import type { AttachmentLocation, SubDocument, ZipAttachment } from '$lib/zip-processing';
|
||||
import { cn } from '$lib/utils';
|
||||
|
||||
@@ -16,6 +17,9 @@
|
||||
onBlockDragOver?: (blockIndex: number, event: DragEvent) => void;
|
||||
onBlockDrop?: (blockIndex: number, event: DragEvent) => void;
|
||||
onBlockDragEnd?: () => void;
|
||||
onDownload?: () => void;
|
||||
isDownloading?: boolean;
|
||||
downloadDisabled?: boolean;
|
||||
};
|
||||
|
||||
let {
|
||||
@@ -30,7 +34,10 @@
|
||||
onBlockDragStart = () => {},
|
||||
onBlockDragOver = () => {},
|
||||
onBlockDrop = () => {},
|
||||
onBlockDragEnd = () => {}
|
||||
onBlockDragEnd = () => {},
|
||||
onDownload,
|
||||
isDownloading = false,
|
||||
downloadDisabled = false
|
||||
}: Props = $props();
|
||||
|
||||
let subDocumentName = $state('');
|
||||
@@ -200,6 +207,19 @@
|
||||
{attachments.length} Datei{attachments.length === 1 ? '' : 'en'}
|
||||
</span>
|
||||
|
||||
{#if onDownload}
|
||||
<Button
|
||||
type="button"
|
||||
variant="bordered"
|
||||
disabled={downloadDisabled || isDownloading || attachments.length === 0}
|
||||
onclick={onDownload}
|
||||
draggable="false"
|
||||
aria-label={`PDF für ${subDocumentName} herunterladen`}
|
||||
>
|
||||
{isDownloading ? 'PDF wird erstellt' : 'PDF herunterladen'}
|
||||
</Button>
|
||||
{/if}
|
||||
|
||||
<button
|
||||
class="rounded-md border border-primary-200 bg-white px-2 py-1 text-[13px] font-medium text-primary-700 transition hover:border-red-300 hover:bg-red-50 hover:text-red-700"
|
||||
type="button"
|
||||
|
||||
@@ -1,76 +1,20 @@
|
||||
<script lang="ts">
|
||||
type Props = {
|
||||
onFilesSelected: (files: File[]) => void;
|
||||
class?: string;
|
||||
};
|
||||
import FileDropzone from '$lib/components/FileDropzone.svelte';
|
||||
|
||||
let { onFilesSelected, class: className = "" }: Props = $props();
|
||||
type Props = {
|
||||
onFilesSelected: (files: File[]) => void;
|
||||
class?: string;
|
||||
};
|
||||
|
||||
let fileInput: HTMLInputElement | null = null;
|
||||
let isDragging = $state(false);
|
||||
|
||||
const processFiles = (files: FileList | null | undefined) => {
|
||||
if (!files) return;
|
||||
|
||||
const filtered = Array.from(files).filter((file) => {
|
||||
const name = file.name.toLowerCase();
|
||||
return (
|
||||
name.endsWith('.zip') ||
|
||||
file.type === 'application/zip' ||
|
||||
file.type === 'application/x-zip-compressed'
|
||||
);
|
||||
});
|
||||
|
||||
if (filtered.length > 0) {
|
||||
onFilesSelected(filtered);
|
||||
}
|
||||
};
|
||||
|
||||
const openFileDialog = () => fileInput?.click();
|
||||
|
||||
const handleFileSelection = (event: Event) => {
|
||||
const input = event.currentTarget as HTMLInputElement;
|
||||
processFiles(input.files);
|
||||
input.value = ''; // Reset so same file can be uploaded twice
|
||||
};
|
||||
|
||||
const handleDragOver = (event: DragEvent) => {
|
||||
event.preventDefault();
|
||||
isDragging = true;
|
||||
};
|
||||
|
||||
const handleDragLeave = (event: DragEvent) => {
|
||||
event.preventDefault();
|
||||
isDragging = false;
|
||||
};
|
||||
|
||||
const handleDrop = (event: DragEvent) => {
|
||||
event.preventDefault();
|
||||
isDragging = false;
|
||||
processFiles(event.dataTransfer?.files);
|
||||
};
|
||||
let { onFilesSelected, class: className = '' }: Props = $props();
|
||||
</script>
|
||||
|
||||
<input
|
||||
bind:this={fileInput}
|
||||
class="hidden"
|
||||
type="file"
|
||||
accept=".zip,application/zip,application/x-zip-compressed"
|
||||
multiple
|
||||
onchange={handleFileSelection}
|
||||
<FileDropzone
|
||||
{onFilesSelected}
|
||||
accept=".zip,application/zip,application/x-zip-compressed"
|
||||
multiple={true}
|
||||
ariaLabel="ZIP-Dateien hinzufügen"
|
||||
label="Dateien hinzufügen"
|
||||
sublabel="ZIP-Dateien hier ablegen oder zum Auswählen klicken"
|
||||
class="min-h-[calc(-180px+100vh)] border-0 bg-transparent {className}"
|
||||
/>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="flex min-h-[calc(-180px+100vh)] w-full grow cursor-pointer flex-col justify-center border-0 text-primary bg-transparent p-0 text-center appearance-none focus:outline-none transition-colors rounded-2xl {isDragging ? 'bg-gray-100 border-2 border-dashed border-primary-500' : ''} {className}"
|
||||
aria-label="ZIP-Dateien hinzufügen"
|
||||
onclick={openFileDialog}
|
||||
ondragover={handleDragOver}
|
||||
ondragleave={handleDragLeave}
|
||||
ondrop={handleDrop}
|
||||
>
|
||||
<div class="flex flex-row items-baseline justify-center gap-2">
|
||||
<span class="text-[20px] font-bold">Dateien hinzufügen</span>
|
||||
<span class="text-[20px] font-extrabold">+</span>
|
||||
</div>
|
||||
</button>
|
||||
@@ -0,0 +1,25 @@
|
||||
export const downloadBlob = (
|
||||
bytes: Uint8Array,
|
||||
fileName: string,
|
||||
mimeType: string = 'application/pdf'
|
||||
) => {
|
||||
const buffer = new Uint8Array(bytes.byteLength);
|
||||
buffer.set(bytes);
|
||||
const blob = new Blob([buffer.buffer as ArrayBuffer], { type: mimeType });
|
||||
const objectUrl = URL.createObjectURL(blob);
|
||||
const downloadLink = document.createElement('a');
|
||||
|
||||
downloadLink.href = objectUrl;
|
||||
downloadLink.download = fileName;
|
||||
downloadLink.rel = 'noopener';
|
||||
downloadLink.click();
|
||||
window.setTimeout(() => URL.revokeObjectURL(objectUrl), 1000);
|
||||
};
|
||||
|
||||
export const downloadBlobUrl = (blobUrl: string, fileName: string) => {
|
||||
const downloadLink = document.createElement('a');
|
||||
downloadLink.href = blobUrl;
|
||||
downloadLink.download = fileName;
|
||||
downloadLink.rel = 'noopener';
|
||||
downloadLink.click();
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
import { PDFDocument } from 'pdf-lib';
|
||||
|
||||
export const mergePdfFiles = async (files: File[]): Promise<Uint8Array> => {
|
||||
if (files.length < 2) throw new Error('At least two PDF files are required.');
|
||||
|
||||
const mergedDocument = await PDFDocument.create();
|
||||
for (const file of files) {
|
||||
const sourceDocument = await PDFDocument.load(new Uint8Array(await file.arrayBuffer()));
|
||||
const copiedPages = await mergedDocument.copyPages(
|
||||
sourceDocument,
|
||||
sourceDocument.getPageIndices()
|
||||
);
|
||||
for (const page of copiedPages) mergedDocument.addPage(page);
|
||||
}
|
||||
|
||||
return mergedDocument.save();
|
||||
};
|
||||
@@ -0,0 +1,71 @@
|
||||
export type PdfThumbnail = {
|
||||
pageNumber: number;
|
||||
imageUrl: string;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
|
||||
export type PdfDocumentHandle = {
|
||||
pageCount: number;
|
||||
thumbnails: PdfThumbnail[];
|
||||
destroy: () => void;
|
||||
};
|
||||
|
||||
export async function loadPdfDocument(data: Uint8Array): Promise<PdfDocumentHandle> {
|
||||
const pdfjsLib = await import('pdfjs-dist');
|
||||
const { getDocument, GlobalWorkerOptions } = pdfjsLib;
|
||||
|
||||
GlobalWorkerOptions.workerSrc = await import('pdfjs-dist/build/pdf.worker.mjs?url').then(
|
||||
(module) => module.default
|
||||
);
|
||||
|
||||
const loadingTask = getDocument({ data: new Uint8Array(data) });
|
||||
const thumbnails: PdfThumbnail[] = [];
|
||||
|
||||
try {
|
||||
const pdfDocument = await loadingTask.promise;
|
||||
|
||||
for (let pageNumber = 1; pageNumber <= pdfDocument.numPages; pageNumber += 1) {
|
||||
const page = await pdfDocument.getPage(pageNumber);
|
||||
const viewport = page.getViewport({ scale: 0.5 });
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = Math.ceil(viewport.width);
|
||||
canvas.height = Math.ceil(viewport.height);
|
||||
const canvasContext = canvas.getContext('2d');
|
||||
|
||||
if (!canvasContext) throw new Error('Could not create thumbnail canvas context.');
|
||||
|
||||
await page.render({ canvas, canvasContext, viewport }).promise;
|
||||
const blob = await new Promise<Blob>((resolve, reject) => {
|
||||
canvas.toBlob(
|
||||
(result) =>
|
||||
result ? resolve(result) : reject(new Error('Could not encode PDF thumbnail.')),
|
||||
'image/png'
|
||||
);
|
||||
});
|
||||
|
||||
thumbnails.push({
|
||||
pageNumber,
|
||||
imageUrl: URL.createObjectURL(blob),
|
||||
width: viewport.width,
|
||||
height: viewport.height
|
||||
});
|
||||
}
|
||||
|
||||
let destroyed = false;
|
||||
return {
|
||||
pageCount: pdfDocument.numPages,
|
||||
thumbnails,
|
||||
destroy: () => {
|
||||
if (destroyed) return;
|
||||
destroyed = true;
|
||||
for (const thumbnail of thumbnails) URL.revokeObjectURL(thumbnail.imageUrl);
|
||||
void loadingTask.destroy();
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
for (const thumbnail of thumbnails) URL.revokeObjectURL(thumbnail.imageUrl);
|
||||
await loadingTask.destroy();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
let pendingZipFiles: File[] = [];
|
||||
|
||||
export const queueZipFiles = (files: File[]) => {
|
||||
pendingZipFiles = [...pendingZipFiles, ...files];
|
||||
};
|
||||
|
||||
export const takeQueuedZipFiles = () => {
|
||||
const files = pendingZipFiles;
|
||||
pendingZipFiles = [];
|
||||
return files;
|
||||
};
|
||||
@@ -0,0 +1,8 @@
|
||||
export const RASTERIZATION_WARNING_BYTES = 25 * 1024 * 1024;
|
||||
export const RASTERIZATION_MAX_BYTES = 100 * 1024 * 1024;
|
||||
|
||||
export const isRasterizationTooLarge = (file: File | null) =>
|
||||
file !== null && file.size > RASTERIZATION_MAX_BYTES;
|
||||
|
||||
export const shouldWarnAboutRasterization = (file: File | null) =>
|
||||
file !== null && file.size > RASTERIZATION_WARNING_BYTES;
|
||||
@@ -0,0 +1,51 @@
|
||||
export type Tool = {
|
||||
slug: string;
|
||||
title: string;
|
||||
description: string;
|
||||
icon: string;
|
||||
};
|
||||
|
||||
export const tools: Tool[] = [
|
||||
{
|
||||
slug: 'watermark',
|
||||
title: 'Wasserzeichen',
|
||||
description: 'Fügen Sie ein Wasserzeichen zu Ihrem PDF hinzu.',
|
||||
icon: 'droplets'
|
||||
},
|
||||
{
|
||||
slug: 'encrypt',
|
||||
title: 'Passwort setzen',
|
||||
description: 'Verschlüsseln Sie Ihr PDF mit einem Passwort.',
|
||||
icon: 'lock'
|
||||
},
|
||||
{
|
||||
slug: 'decrypt',
|
||||
title: 'Passwort entfernen',
|
||||
description: 'Entsperren Sie ein passwortgeschütztes PDF.',
|
||||
icon: 'unlock'
|
||||
},
|
||||
{
|
||||
slug: 'convert',
|
||||
title: 'Konvertieren',
|
||||
description: 'Konvertieren Sie ein PDF in Bilder.',
|
||||
icon: 'image'
|
||||
},
|
||||
{
|
||||
slug: 'compress',
|
||||
title: 'Komprimieren',
|
||||
description: 'Komprimieren Sie ein PDF für kleinere Dateigröße.',
|
||||
icon: 'minimize'
|
||||
},
|
||||
{
|
||||
slug: 'separate',
|
||||
title: 'Seiten entfernen',
|
||||
description: 'Entfernen Sie Seiten aus einem PDF.',
|
||||
icon: 'scissors'
|
||||
},
|
||||
{
|
||||
slug: 'merge',
|
||||
title: 'PDFs zusammenfügen',
|
||||
description: 'Fügen Sie mehrere PDFs zu einer Datei zusammen.',
|
||||
icon: 'merge'
|
||||
}
|
||||
];
|
||||
+4
-4
@@ -1,13 +1,13 @@
|
||||
import { clsx, type ClassValue } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { clsx, type ClassValue } from 'clsx';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export type WithoutChild<T> = T extends { child?: any } ? Omit<T, "child"> : T;
|
||||
export type WithoutChild<T> = T extends { child?: any } ? Omit<T, 'child'> : T;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export type WithoutChildren<T> = T extends { children?: any } ? Omit<T, "children"> : T;
|
||||
export type WithoutChildren<T> = T extends { children?: any } ? Omit<T, 'children'> : T;
|
||||
export type WithoutChildrenOrChild<T> = WithoutChildren<WithoutChild<T>>;
|
||||
export type WithElementRef<T, U extends HTMLElement = HTMLElement> = T & { ref?: U | null };
|
||||
@@ -572,6 +572,25 @@ export const dissolveSubDocument = (
|
||||
const stripPdfExtension = (name: string) =>
|
||||
name.toLowerCase().endsWith('.pdf') ? name.slice(0, -'.pdf'.length) : name;
|
||||
|
||||
/**
|
||||
* Build a single PDF export unit for one sub-document. The resulting filename
|
||||
* follows the same convention as the separate archive export:
|
||||
* `{archiveName} - {subDocumentName}` (without duplicate `.pdf` suffixes).
|
||||
*/
|
||||
export const buildSubDocumentExport = async (
|
||||
archiveName: string,
|
||||
subDocument: SubDocument
|
||||
): Promise<ExportUnit> => {
|
||||
if (subDocument.attachments.length === 0) {
|
||||
throw new Error('Cannot create a PDF from an empty sub-document.');
|
||||
}
|
||||
|
||||
return {
|
||||
name: `${stripPdfExtension(archiveName)} - ${stripPdfExtension(subDocument.name)}`,
|
||||
bytes: await mergeAttachments(subDocument.attachments)
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Build one or more PDF export units from the archive.
|
||||
*
|
||||
@@ -607,10 +626,7 @@ export const buildArchiveExport = async (
|
||||
continue;
|
||||
}
|
||||
|
||||
separateUnits.push({
|
||||
name: `${stripPdfExtension(archive.name)} - ${stripPdfExtension(subDocument.name)}`,
|
||||
bytes: await mergeAttachments(subDocument.attachments)
|
||||
});
|
||||
separateUnits.push(await buildSubDocumentExport(archive.name, subDocument));
|
||||
}
|
||||
|
||||
if (archive.attachments.length > 0) {
|
||||
|
||||
Reference in new issue
Block a user