subdocument-splitting-plan

This commit is contained in:
rehlert committed 2026-10-06 22:52:35 +02:00
1 parent f09384d2e6
commit ca06ca2050
47 files changed
+4097 -136

No files matched your search

+438
View File
@@ -0,0 +1,438 @@
<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>