subdocument-splitting-plan
This commit is contained in:
1 parent
f09384d2e6
commit
ca06ca2050
47 files changed
+4097
-136
No files matched your search
@@ -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>
|
||||
Reference in new issue
Block a user