439 lines
12 KiB
Svelte
439 lines
12 KiB
Svelte
<script lang="ts">
|
||
import { onDestroy, onMount } from 'svelte';
|
||
import { X } from '@lucide/svelte';
|
||
import Button from '$lib/components/ui/button.svelte';
|
||
import PdfPageGrid from '$lib/components/PdfPageGrid.svelte';
|
||
import { loadPdfDocument, type PdfDocumentHandle } from '$lib/pdf-thumbnails';
|
||
import {
|
||
createAttachmentFromPages,
|
||
removePagesFromAttachment,
|
||
type PdfSplitResult,
|
||
type ZipAttachment
|
||
} from '$lib/zip-processing';
|
||
import { cn } from '$lib/utils';
|
||
|
||
type SplitMode = 'manual' | 'perPage' | 'everyN';
|
||
|
||
type AssignedGroup = {
|
||
name: string;
|
||
pages: number[];
|
||
};
|
||
|
||
type Props = {
|
||
source: ZipAttachment;
|
||
onApply: (result: PdfSplitResult) => void;
|
||
onClose: () => void;
|
||
};
|
||
|
||
let { source, onApply, onClose }: Props = $props();
|
||
|
||
let panel = $state<HTMLDivElement | null>(null);
|
||
let pdfHandle = $state<PdfDocumentHandle | null>(null);
|
||
let loadError = $state<string | null>(null);
|
||
let splitError = $state<string | null>(null);
|
||
let isProcessing = $state(false);
|
||
let mode = $state<SplitMode>('manual');
|
||
let selectedPages = $state(new Set<number>());
|
||
let assignedGroups = $state<AssignedGroup[]>([]);
|
||
let groupName = $state('');
|
||
let keepSource = $state(false);
|
||
let chunkSize = $state(2);
|
||
|
||
let cancelled = false;
|
||
|
||
const pageCount = $derived(pdfHandle?.pageCount ?? 0);
|
||
const assignedPages = $derived(new Set(assignedGroups.flatMap((group) => group.pages)));
|
||
|
||
const modes: Array<{ value: SplitMode; label: string }> = [
|
||
{ value: 'manual', label: 'Seiten auswählen' },
|
||
{ value: 'perPage', label: 'Jede Seite einzeln' },
|
||
{ value: 'everyN', label: 'Alle N Seiten' }
|
||
];
|
||
|
||
const isContiguous = (pages: number[]) => {
|
||
const sorted = [...pages].sort((a, b) => a - b);
|
||
return sorted.every((page, index) => index === 0 || page === sorted[index - 1] + 1);
|
||
};
|
||
|
||
const describePages = (pages: number[]) => {
|
||
const sorted = [...pages].sort((a, b) => a - b);
|
||
|
||
if (sorted.length === 1) {
|
||
return `Seite ${sorted[0]}`;
|
||
}
|
||
|
||
if (isContiguous(sorted)) {
|
||
return `Seiten ${sorted[0]}–${sorted[sorted.length - 1]}`;
|
||
}
|
||
|
||
return `Seiten ${sorted.join(', ')}`;
|
||
};
|
||
|
||
const defaultGroupName = (pages: number[]) =>
|
||
pages.length === 1 ? `Seite ${pages[0]}` : describePages(pages);
|
||
|
||
const setMode = (nextMode: SplitMode) => {
|
||
mode = nextMode;
|
||
selectedPages = new Set();
|
||
};
|
||
|
||
const togglePage = (pageNumber: number) => {
|
||
if (assignedPages.has(pageNumber)) {
|
||
return;
|
||
}
|
||
|
||
const next = new Set(selectedPages);
|
||
|
||
if (next.has(pageNumber)) {
|
||
next.delete(pageNumber);
|
||
} else {
|
||
next.add(pageNumber);
|
||
}
|
||
|
||
selectedPages = next;
|
||
groupName = defaultGroupName([...next]);
|
||
};
|
||
|
||
const assignSelection = () => {
|
||
if (selectedPages.size === 0) {
|
||
return;
|
||
}
|
||
|
||
const pages = [...selectedPages].sort((a, b) => a - b);
|
||
const name = groupName.trim() || defaultGroupName(pages);
|
||
|
||
assignedGroups = [...assignedGroups, { name, pages }];
|
||
selectedPages = new Set();
|
||
groupName = '';
|
||
};
|
||
|
||
const removeGroup = (index: number) => {
|
||
assignedGroups = assignedGroups.filter((_, groupIndex) => groupIndex !== index);
|
||
};
|
||
|
||
const bulkChunks = $derived.by(() => {
|
||
if (mode !== 'perPage' && mode !== 'everyN') {
|
||
return [];
|
||
}
|
||
|
||
const chunks: number[][] = [];
|
||
|
||
if (mode === 'perPage') {
|
||
for (let pageNumber = 1; pageNumber <= pageCount; pageNumber += 1) {
|
||
chunks.push([pageNumber]);
|
||
}
|
||
|
||
return chunks;
|
||
}
|
||
|
||
const size = Math.max(2, Number.isFinite(chunkSize) ? Math.floor(chunkSize) : 2);
|
||
|
||
for (let first = 1; first <= pageCount; first += size) {
|
||
const chunk: number[] = [];
|
||
|
||
for (let page = first; page < Math.min(first + size, pageCount + 1); page += 1) {
|
||
chunk.push(page);
|
||
}
|
||
|
||
if (chunk.length > 0) {
|
||
chunks.push(chunk);
|
||
}
|
||
}
|
||
|
||
return chunks;
|
||
});
|
||
|
||
const buildResult = async (groups: AssignedGroup[]): Promise<PdfSplitResult> => {
|
||
const splitGroups = [];
|
||
|
||
for (const group of groups) {
|
||
const attachment = await createAttachmentFromPages(source, group.pages);
|
||
|
||
splitGroups.push({ name: group.name, attachment });
|
||
}
|
||
|
||
const assignedAllPages = groups.flatMap((group) => group.pages);
|
||
const remainingSource = keepSource
|
||
? source
|
||
: await removePagesFromAttachment(source, assignedAllPages);
|
||
|
||
return { groups: splitGroups, source: remainingSource };
|
||
};
|
||
|
||
const applyGroups = async (groups: AssignedGroup[]) => {
|
||
if (groups.length === 0 || isProcessing) {
|
||
return;
|
||
}
|
||
|
||
isProcessing = true;
|
||
splitError = null;
|
||
|
||
try {
|
||
onApply(await buildResult(groups));
|
||
} catch (error) {
|
||
console.error(`Failed to split PDF attachment ${source.path}`, error);
|
||
splitError = 'Das PDF konnte nicht geteilt werden.';
|
||
} finally {
|
||
isProcessing = false;
|
||
}
|
||
};
|
||
|
||
const applyBulk = () => {
|
||
void applyGroups(bulkChunks.map((pages) => ({ name: defaultGroupName(pages), pages })));
|
||
};
|
||
|
||
const applyManual = () => {
|
||
void applyGroups(assignedGroups);
|
||
};
|
||
|
||
const handleKeydown = (event: KeyboardEvent) => {
|
||
if (event.key === 'Escape') {
|
||
onClose();
|
||
}
|
||
};
|
||
|
||
onMount(() => {
|
||
panel?.focus();
|
||
|
||
(async () => {
|
||
try {
|
||
const loaded = await loadPdfDocument(new Uint8Array(source.data));
|
||
|
||
if (cancelled) {
|
||
loaded.destroy();
|
||
return;
|
||
}
|
||
|
||
pdfHandle = loaded;
|
||
} catch (error) {
|
||
console.error(`Failed to load PDF attachment ${source.path}`, error);
|
||
|
||
if (!cancelled) {
|
||
loadError = 'PDF konnte nicht geladen werden.';
|
||
}
|
||
}
|
||
})();
|
||
});
|
||
|
||
onDestroy(() => {
|
||
cancelled = true;
|
||
pdfHandle?.destroy();
|
||
pdfHandle = null;
|
||
});
|
||
</script>
|
||
|
||
<svelte:window onkeydown={handleKeydown} />
|
||
|
||
<div
|
||
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
|
||
role="presentation"
|
||
>
|
||
<div
|
||
bind:this={panel}
|
||
class="flex max-h-[90vh] w-full max-w-3xl flex-col overflow-hidden rounded-2xl bg-white shadow-xl outline-none"
|
||
role="dialog"
|
||
aria-modal="true"
|
||
aria-label="PDF teilen"
|
||
data-split-dialog
|
||
tabindex="-1"
|
||
>
|
||
<header class="flex items-center justify-between gap-3 border-b border-primary-100 px-5 py-4">
|
||
<div class="min-w-0">
|
||
<h2 class="text-[16px] font-bold text-primary-900">PDF teilen</h2>
|
||
<p class="truncate text-[13px] text-primary-700" title={source.name}>
|
||
{source.name} — {pageCount} Seite{pageCount === 1 ? '' : 'n'}
|
||
</p>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
class="flex h-8 w-8 shrink-0 cursor-pointer items-center justify-center rounded-full border border-primary-200 bg-white text-primary-700 transition hover:border-primary-400 hover:bg-primary-50"
|
||
aria-label="Teilen abbrechen"
|
||
onclick={onClose}
|
||
>
|
||
<X class="size-4" aria-hidden="true" />
|
||
</button>
|
||
</header>
|
||
|
||
<div class="flex-1 space-y-4 overflow-y-auto px-5 py-4">
|
||
<div
|
||
class="inline-flex flex-wrap items-center rounded-[16px] border border-primary-200 bg-white p-0.5"
|
||
role="radiogroup"
|
||
aria-label="Art des Teilens"
|
||
>
|
||
{#each modes as modeOption (modeOption.value)}
|
||
<label
|
||
class={cn(
|
||
'cursor-pointer rounded-[14px] px-3 py-1 text-[13px] font-semibold transition',
|
||
mode === modeOption.value
|
||
? 'bg-primary text-primary-foreground'
|
||
: 'text-primary-700 hover:bg-primary-50'
|
||
)}
|
||
>
|
||
<input
|
||
class="sr-only"
|
||
type="radio"
|
||
name="split-mode"
|
||
value={modeOption.value}
|
||
checked={mode === modeOption.value}
|
||
onchange={() => setMode(modeOption.value)}
|
||
/>
|
||
{modeOption.label}
|
||
</label>
|
||
{/each}
|
||
</div>
|
||
|
||
{#if mode === 'everyN'}
|
||
<label class="flex flex-wrap items-center gap-2 text-[13px] font-semibold text-primary-900">
|
||
Seiten pro Teildokument
|
||
<input
|
||
class="w-20 rounded-md border border-primary-200 bg-white px-2 py-1 text-[13px] font-medium text-primary-900 outline-none transition focus:border-primary-400 focus:ring-2 focus:ring-primary-200"
|
||
type="number"
|
||
min={2}
|
||
value={chunkSize}
|
||
oninput={(event) => {
|
||
chunkSize = Number((event.currentTarget as HTMLInputElement).value);
|
||
}}
|
||
/>
|
||
</label>
|
||
{/if}
|
||
|
||
{#if mode !== 'manual' && pageCount > 0}
|
||
<p class="text-[13px] font-medium text-primary-700" aria-live="polite">
|
||
Es werden {bulkChunks.length}
|
||
Teildokument{bulkChunks.length === 1 ? '' : 'e'} erstellt.
|
||
</p>
|
||
{/if}
|
||
|
||
{#if loadError}
|
||
<div class="rounded-container bg-red-50 px-4 py-3 text-[14px] font-medium text-red-900">
|
||
{loadError}
|
||
</div>
|
||
{:else if pdfHandle}
|
||
<PdfPageGrid
|
||
thumbnails={pdfHandle.thumbnails}
|
||
{selectedPages}
|
||
onPageClick={mode === 'manual' ? togglePage : undefined}
|
||
class={mode === 'manual' ? '' : 'pointer-events-none'}
|
||
cardClass={(pageNumber) =>
|
||
assignedPages.has(pageNumber) ? 'bg-primary/10 rounded-md p-1' : ''}
|
||
>
|
||
{#snippet pageActions(pageNumber)}
|
||
{#if assignedPages.has(pageNumber)}
|
||
<span
|
||
class="mt-1 rounded-full bg-primary px-2 py-0.5 text-[11px] font-bold text-primary-foreground"
|
||
>
|
||
✓ Zugewiesen
|
||
</span>
|
||
{/if}
|
||
{/snippet}
|
||
</PdfPageGrid>
|
||
{:else}
|
||
<div
|
||
class="flex items-center justify-center rounded-md border border-dashed border-primary-200 bg-primary-50 px-4 py-10 text-[13px] text-primary-700"
|
||
>
|
||
Vorschau wird erstellt
|
||
</div>
|
||
{/if}
|
||
|
||
{#if mode === 'manual' && pdfHandle}
|
||
<div class="flex flex-wrap items-center gap-2">
|
||
<label class="flex min-w-0 flex-1 items-center gap-2">
|
||
<span class="sr-only">Name des Teildokuments</span>
|
||
<input
|
||
class="w-full min-w-40 rounded-md border border-primary-200 bg-white px-3 py-1.5 text-[13px] font-semibold text-primary-900 outline-none transition focus:border-primary-400 focus:ring-2 focus:ring-primary-200"
|
||
type="text"
|
||
placeholder={selectedPages.size > 0
|
||
? defaultGroupName([...selectedPages])
|
||
: 'Name des Teildokuments'}
|
||
value={groupName}
|
||
oninput={(event) => {
|
||
groupName = (event.currentTarget as HTMLInputElement).value;
|
||
}}
|
||
/>
|
||
</label>
|
||
<Button
|
||
type="button"
|
||
disabled={selectedPages.size === 0 || isProcessing}
|
||
onclick={assignSelection}
|
||
>
|
||
Als Teildokument übernehmen
|
||
</Button>
|
||
</div>
|
||
<p class="text-[13px] text-primary-700" aria-live="polite">
|
||
{selectedPages.size} von {pageCount} Seiten ausgewählt
|
||
</p>
|
||
{/if}
|
||
|
||
{#if assignedGroups.length > 0}
|
||
<ul class="flex flex-col gap-2">
|
||
{#each assignedGroups as group, index (index)}
|
||
<li
|
||
class="flex flex-wrap items-center gap-2 rounded-xl border border-primary-200 bg-primary-50 px-3 py-1.5"
|
||
>
|
||
<span class="min-w-0 flex-1 truncate text-[13px] font-semibold text-primary-900">
|
||
{group.name}
|
||
</span>
|
||
<span class="text-[12px] font-medium text-primary-700">
|
||
{describePages(group.pages)}
|
||
</span>
|
||
<button
|
||
type="button"
|
||
class="flex h-6 w-6 cursor-pointer items-center justify-center rounded-full text-primary-600 transition hover:bg-white hover:text-red-700"
|
||
aria-label={`Zuordnung „${group.name}“ aufheben`}
|
||
onclick={() => removeGroup(index)}
|
||
>
|
||
<X class="size-3.5" aria-hidden="true" />
|
||
</button>
|
||
</li>
|
||
{/each}
|
||
</ul>
|
||
{/if}
|
||
|
||
{#if splitError}
|
||
<div class="rounded-container bg-red-50 px-4 py-3 text-[14px] font-medium text-red-900">
|
||
{splitError}
|
||
</div>
|
||
{/if}
|
||
|
||
<label class="flex items-center gap-2 text-[13px] font-medium text-primary-900">
|
||
<input
|
||
class="h-4 w-4 accent-primary-700"
|
||
type="checkbox"
|
||
checked={keepSource}
|
||
onchange={(event) => {
|
||
keepSource = (event.currentTarget as HTMLInputElement).checked;
|
||
}}
|
||
/>
|
||
Seiten im Original behalten (kopieren statt ausschneiden)
|
||
</label>
|
||
</div>
|
||
|
||
<footer
|
||
class="flex flex-wrap items-center justify-end gap-2 border-t border-primary-100 px-5 py-4"
|
||
>
|
||
<Button type="button" variant="bordered" disabled={isProcessing} onclick={onClose}>
|
||
Abbrechen
|
||
</Button>
|
||
{#if mode === 'manual'}
|
||
<Button
|
||
type="button"
|
||
disabled={assignedGroups.length === 0 || isProcessing}
|
||
onclick={applyManual}
|
||
data-split-apply
|
||
>
|
||
{isProcessing ? 'Teildokumente werden erstellt' : 'Teilen'}
|
||
</Button>
|
||
{:else}
|
||
<Button
|
||
type="button"
|
||
disabled={bulkChunks.length === 0 || isProcessing}
|
||
onclick={applyBulk}
|
||
data-split-apply
|
||
>
|
||
{isProcessing ? 'Teildokumente werden erstellt' : 'Teilen'}
|
||
</Button>
|
||
{/if}
|
||
</footer>
|
||
</div>
|
||
</div>
|