Files
bea-edit/src/lib/components/PdfSplitDialog.svelte
T
2026-10-06 22:52:35 +02:00

439 lines
12 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>