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

+100
View File
@@ -0,0 +1,100 @@
<script lang="ts">
import { File, Folder, FolderPlus, MoreHorizontal, Scissors, Trash2 } from '@lucide/svelte';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu/index.js';
import type { ZipAttachment } from '$lib/zip-processing';
export type AttachmentMoveTarget = {
id: string;
name: string;
};
type Props = {
attachment: ZipAttachment;
/** Existing sub-document move targets (excluding the attachment's own). */
moveTargets: AttachmentMoveTarget[];
canSplit?: boolean;
onMoveToTarget: (targetId: string) => void;
onMoveToLoose?: () => void;
onSplit?: () => void;
onRemove?: () => void;
};
let {
attachment,
moveTargets,
canSplit = false,
onMoveToTarget,
onMoveToLoose,
onSplit,
onRemove
}: Props = $props();
</script>
<DropdownMenu.Root>
<DropdownMenu.Trigger aria-label={`Aktionen für ${attachment.name}`}>
{#snippet child({ props })}
<button
{...props}
type="button"
class="flex h-6 w-6 shrink-0 cursor-pointer items-center justify-center rounded-md border border-primary-200 bg-white text-primary-700 transition hover:border-primary-400 hover:bg-primary-50 focus-visible:ring-2 focus-visible:ring-primary-300"
aria-label={`Aktionen für ${attachment.name}`}
data-attachment-menu={attachment.path}
draggable="false"
ondragstart={(event) => event.preventDefault()}
>
<MoreHorizontal class="size-3.5" aria-hidden="true" />
</button>
{/snippet}
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end" side="top" sideOffset={6} class="w-56">
<DropdownMenu.Group>
<DropdownMenu.GroupHeading>Verschieben</DropdownMenu.GroupHeading>
{#each moveTargets as target (target.id)}
<DropdownMenu.Item
aria-label={`Nach „${target.name}“ verschieben`}
onclick={() => onMoveToTarget(target.id)}
>
<Folder aria-hidden="true" />
<span class="truncate">{target.name}</span>
</DropdownMenu.Item>
{/each}
<DropdownMenu.Item
aria-label="In neues Teildokument verschieben"
onclick={() => onMoveToTarget('new')}
>
<FolderPlus aria-hidden="true" />
<span class="truncate">Neues Teildokument</span>
</DropdownMenu.Item>
{#if onMoveToLoose}
<DropdownMenu.Item aria-label="Zu den losen Dateien verschieben" onclick={onMoveToLoose}>
<File aria-hidden="true" />
<span class="truncate">Zu den losen Dateien</span>
</DropdownMenu.Item>
{/if}
</DropdownMenu.Group>
{#if canSplit && onSplit}
<DropdownMenu.Separator />
<DropdownMenu.Item aria-label={`Teilen: ${attachment.name}`} onclick={onSplit}>
<Scissors aria-hidden="true" />
<span class="truncate">Teilen…</span>
</DropdownMenu.Item>
{/if}
{#if onRemove}
<DropdownMenu.Separator />
<DropdownMenu.Item
variant="destructive"
onclick={onRemove}
aria-label={`Entfernen: ${attachment.name}`}
>
<Trash2 aria-hidden="true" />
<span class="truncate">Entfernen</span>
</DropdownMenu.Item>
{/if}
</DropdownMenu.Content>
</DropdownMenu.Root>
@@ -41,7 +41,7 @@
let isExporting = $state(false);
const statusText = $derived.by(() => {
const zipText = `${selectedZipCount} ${selectedZipCount === 1 ? 'ZIP' : 'ZIPs'}`;
const fileText = `${selectedZipCount} ${selectedZipCount === 1 ? 'Datei' : 'Dateien'}`;
const archiveText = `${processedArchiveCount} ${
processedArchiveCount === 1 ? 'Archiv' : 'Archive'
} geöffnet`;
@@ -49,7 +49,7 @@
pendingZipCount > 0
? ` · ${pendingZipCount} ${pendingZipCount === 1 ? 'wird' : 'werden'} geöffnet`
: '';
return `${zipText} · ${archiveText}${pendingText}`;
return `${fileText} · ${archiveText}${pendingText}`;
});
const commit = async (action: () => void | Promise<void>) => {
@@ -93,12 +93,12 @@
<div class="flex flex-wrap items-center gap-2">
<ZipFilePicker
pickerId="replace-zip"
ariaLabel="Neue ZIP bearbeiten"
ariaLabel="Neue Datei bearbeiten"
disabled={isCommitting}
onFilesSelected={(files) => (pendingReplacementFiles = files)}
>
<FilePlus2 class="size-4" aria-hidden="true" />
Neue ZIP bearbeiten
Neue Datei bearbeiten
</ZipFilePicker>
<Popover.Root>
@@ -153,14 +153,14 @@
<ZipFilePicker
pickerId="append-zip"
ariaLabel="Weitere ZIP hinzufügen"
ariaLabel="Weitere Datei hinzufügen"
variant="bordered"
disabled={isCommitting}
class="w-full justify-start"
onFilesSelected={onAppendFiles}
>
<Plus class="size-4" aria-hidden="true" />
Weitere ZIP hinzufügen
Weitere Datei hinzufügen
</ZipFilePicker>
{#if processedArchiveCount >= 2}
@@ -208,7 +208,7 @@
</AlertDialog.Description>
<AlertDialog.Footer>
<AlertDialog.Cancel>Abbrechen</AlertDialog.Cancel>
<AlertDialog.Action onclick={confirmReplacement}>Neue ZIP öffnen</AlertDialog.Action>
<AlertDialog.Action onclick={confirmReplacement}>Neue Datei öffnen</AlertDialog.Action>
</AlertDialog.Footer>
</AlertDialog.Content>
</AlertDialog.Root>
+3
View File
@@ -70,6 +70,9 @@
✓
</div>
{/if}
{#if pageActions}
{@render pageActions(thumb.pageNumber)}
{/if}
</button>
{:else}
<div
+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>
@@ -1,16 +1,28 @@
<script lang="ts">
import { FilePlus2, Layers } from '@lucide/svelte';
import AttachmentMenu from '$lib/components/AttachmentMenu.svelte';
import AttachmentPreview from '$lib/components/AttachmentPreview.svelte';
import Button from '$lib/components/ui/button.svelte';
import * as AlertDialog from '$lib/components/ui/alert-dialog/index.js';
import PdfSplitDialog from '$lib/components/PdfSplitDialog.svelte';
import SubDocumentEditor from '$lib/components/SubDocumentEditor.svelte';
import {
applyPdfSplit,
buildArchiveExport,
buildSubDocumentExport,
createSubDocument,
createSubDocumentFromPaths,
dissolveSubDocument,
groupEachAttachmentAsSubDocument,
moveAttachmentIntoSubDocument,
moveAttachmentsByPathsToSubDocument,
moveSubDocument,
nextSubDocumentNumber,
removeAttachmentFromArchive,
removeAttachmentFromSubDocument,
type AttachmentLocation,
type ExportMode,
type PdfSplitResult,
type ProcessedZipArchive,
type SubDocument,
type ZipAttachment
@@ -31,8 +43,11 @@
let looseAttachments = $state<ZipAttachment[]>([]);
let subDocuments = $state<SubDocument[]>([]);
let selectionMode = $state(false);
let groupingMode = $state(false);
let selectedPaths = $state<Set<string>>(new Set());
let focusSubDocumentId = $state<string | null>(null);
let splitSource = $state<ZipAttachment | null>(null);
let removeCandidate = $state<ZipAttachment | null>(null);
let dragIndex = $state<number | null>(null);
let dropIndex = $state<number | null>(null);
@@ -46,6 +61,31 @@
let mergeError = $state<string | null>(null);
const isExportActive = $derived(isMerging || downloadingSubDocumentId !== null);
const hasSubDocuments = $derived(subDocuments.length > 0);
const subDocumentTargets = $derived(
subDocuments.map((subDocument) => ({ id: subDocument.id, name: subDocument.name }))
);
const allAttachmentPaths = $derived.by(() => {
const paths = new Set<string>();
for (const subDocument of subDocuments) {
for (const attachment of subDocument.attachments) {
paths.add(attachment.path);
}
}
for (const attachment of looseAttachments) {
paths.add(attachment.path);
}
return paths;
});
const selectedCount = $derived(
[...selectedPaths].filter((path) => allAttachmentPaths.has(path)).length
);
$effect(() => {
archiveName = archive.name;
@@ -94,8 +134,6 @@
onArchiveChange(composedArchive());
};
const hasSubDocuments = $derived(subDocuments.length > 0);
const toggleSelection = (path: string) => {
const next = new Set(selectedPaths);
@@ -108,48 +146,145 @@
selectedPaths = next;
};
const toggleSelectionMode = () => {
selectionMode = !selectionMode;
const toggleGroupingMode = () => {
groupingMode = !groupingMode;
selectedPaths = new Set();
};
const exitSelectionMode = () => {
selectionMode = false;
const exitGroupingMode = () => {
groupingMode = false;
selectedPaths = new Set();
};
const selectAllAttachments = () => {
selectedPaths = new Set(allAttachmentPaths);
};
const selectNoAttachments = () => {
selectedPaths = new Set();
};
const handleWindowKeydown = (event: KeyboardEvent) => {
if (event.key === 'Escape' && selectionMode) {
exitSelectionMode();
if (event.key === 'Escape' && groupingMode && !splitSource && !removeCandidate) {
exitGroupingMode();
}
};
const createSubDocumentFromSelection = () => {
if (selectedPaths.size === 0) {
const focusLastSubDocument = (nextArchive: ProcessedZipArchive) => {
focusSubDocumentId = nextArchive.subDocuments[nextArchive.subDocuments.length - 1]?.id ?? null;
};
const createGroupFromSelection = () => {
if (selectedCount === 0) {
return;
}
const selectedAttachments = looseAttachments.filter((attachment) =>
selectedPaths.has(attachment.path)
);
const remainingAttachments = looseAttachments.filter(
(attachment) => !selectedPaths.has(attachment.path)
);
const nextArchive = createSubDocumentFromPaths(composedArchive(), selectedPaths);
if (selectedAttachments.length === 0) {
return;
}
const subDocumentNumber = subDocuments.length + 1;
const nextSubDocument = createSubDocument(
`Teildokument ${subDocumentNumber}`,
selectedAttachments
);
looseAttachments = remainingAttachments;
subDocuments = [...subDocuments, nextSubDocument];
emitArchiveChange(nextArchive);
focusLastSubDocument(nextArchive);
selectedPaths = new Set();
commit();
};
const moveSelectionToTarget = (targetId: string) => {
if (targetId === '' || selectedCount === 0) {
return;
}
emitArchiveChange(
moveAttachmentsByPathsToSubDocument(composedArchive(), selectedPaths, targetId)
);
selectedPaths = new Set();
};
const createEmptySubDocument = () => {
const nextSubDocument = createSubDocument(
`Teildokument ${nextSubDocumentNumber(subDocuments)}`,
[]
);
const nextArchive = {
...composedArchive(),
subDocuments: [...subDocuments, nextSubDocument]
};
emitArchiveChange(nextArchive);
focusSubDocumentId = nextSubDocument.id;
};
const groupEachAttachment = () => {
if (looseAttachments.length === 0) {
return;
}
emitArchiveChange(groupEachAttachmentAsSubDocument(composedArchive()));
focusSubDocumentId = null;
};
const locateAttachment = (
sourceArchive: ProcessedZipArchive,
path: string
): AttachmentLocation | null => {
const looseIndex = sourceArchive.attachments.findIndex(
(attachment) => attachment.path === path
);
if (looseIndex !== -1) {
return { kind: 'loose', index: looseIndex };
}
for (const subDocument of sourceArchive.subDocuments) {
const index = subDocument.attachments.findIndex((attachment) => attachment.path === path);
if (index !== -1) {
return { kind: 'subDocument', id: subDocument.id, index };
}
}
return null;
};
const moveAttachmentToTarget = (path: string, targetId: string) => {
if (targetId === 'new') {
const nextArchive = createSubDocumentFromPaths(composedArchive(), new Set([path]));
emitArchiveChange(nextArchive);
focusLastSubDocument(nextArchive);
return;
}
emitArchiveChange(
moveAttachmentsByPathsToSubDocument(composedArchive(), new Set([path]), targetId)
);
};
const moveAttachmentToLoose = (path: string) => {
const location = locateAttachment(composedArchive(), path);
if (!location || location.kind !== 'subDocument') {
return;
}
emitArchiveChange(
removeAttachmentFromSubDocument(composedArchive(), location.id, location.index)
);
};
const confirmRemoveAttachment = () => {
if (!removeCandidate) {
return;
}
emitArchiveChange(removeAttachmentFromArchive(composedArchive(), removeCandidate.path));
removeCandidate = null;
};
const handleSplitApply = (result: PdfSplitResult) => {
if (!splitSource) {
return;
}
emitArchiveChange(applyPdfSplit(composedArchive(), splitSource.path, result));
splitSource = null;
};
const dissolveSubDocumentBlock = (subDocumentId: string) => {
@@ -219,7 +354,7 @@
};
const handleLooseDragStart = (index: number, event: DragEvent) => {
if (selectionMode) {
if (groupingMode) {
event.preventDefault();
return;
}
@@ -371,12 +506,27 @@
</div>
<div class="flex flex-wrap items-center gap-2">
{#if !groupingMode}
<Button type="button" variant="bordered" onclick={createEmptySubDocument}>
<FilePlus2 class="size-4" aria-hidden="true" />
Neues Teildokument
</Button>
{#if looseAttachments.length >= 2}
<Button type="button" variant="bordered" onclick={groupEachAttachment}>
<Layers class="size-4" aria-hidden="true" />
Jede Datei als Teildokument
</Button>
{/if}
{/if}
<Button
type="button"
variant={selectionMode ? 'secondary' : 'bordered'}
onclick={toggleSelectionMode}
variant={groupingMode ? 'secondary' : 'bordered'}
onclick={toggleGroupingMode}
>
{selectionMode ? 'Fertig' : 'Auswählen'}
<Layers class="size-4" aria-hidden="true" />
{groupingMode ? 'Fertig' : 'Gruppieren'}
</Button>
{#if hasSubDocuments}
@@ -432,12 +582,51 @@
</div>
{/if}
{#if selectionMode && selectedPaths.size > 0}
{#if groupingMode}
<div
class="flex flex-wrap items-center gap-3 rounded-xl border border-primary-200 bg-primary-50 px-4 py-2 text-[13px] font-medium text-primary-900"
>
<span>{selectedPaths.size} Datei{selectedPaths.size === 1 ? '' : 'en'} ausgewählt</span>
<Button type="button" onclick={createSubDocumentFromSelection}>Teildokument erstellen</Button>
<span aria-live="polite">
{selectedCount} Datei{selectedCount === 1 ? '' : 'en'} ausgewählt
</span>
<Button type="button" disabled={selectedCount === 0} onclick={createGroupFromSelection}>
Teildokument erstellen
</Button>
{#if hasSubDocuments}
<select
class="h-8 cursor-pointer rounded-[16px] border-2 border-[#E6E7EA] bg-white px-3 text-[13px] font-bold text-primary disabled:cursor-not-allowed disabled:text-[#0B1F4366]"
aria-label="Auswahl in Teildokument verschieben"
disabled={selectedCount === 0}
onchange={(event) => {
const select = event.currentTarget as HTMLSelectElement;
const targetId = select.value;
select.value = '';
moveSelectionToTarget(targetId);
}}
>
<option value="">In Teildokument verschieben…</option>
{#each subDocuments as subDocument (subDocument.id)}
<option value={subDocument.id}>{subDocument.name}</option>
{/each}
</select>
{/if}
<div class="ml-auto flex items-center gap-2">
<Button type="button" variant="tertiary" onclick={selectAllAttachments}>
Alle auswählen
</Button>
<Button
type="button"
variant="tertiary"
disabled={selectedCount === 0}
onclick={selectNoAttachments}
>
Auswahl aufheben
</Button>
</div>
</div>
{/if}
@@ -451,13 +640,13 @@
selectedPaths.has(attachment.path) ? 'border-primary-500 ring-2 ring-primary-300' : ''
)}
style={`width: ${thumbnailWidth}px;`}
draggable={selectionMode ? 'false' : 'true'}
draggable={groupingMode ? 'false' : 'true'}
ondragstart={(event) => handleLooseDragStart(index, event)}
ondragover={(event) => handleLooseDragOver(index, event)}
ondrop={(event) => handleLooseDrop(index, event)}
ondragend={handleLooseDragEnd}
>
{#if selectionMode}
{#if groupingMode}
<label class="flex cursor-pointer flex-col gap-2">
<div class="flex items-center gap-2 px-1 text-[13px] font-medium text-primary-900">
<input
@@ -480,7 +669,7 @@
<AttachmentPreview {attachment} />
</label>
{:else}
<div class="flex min-w-0 flex-1 flex-row items-baseline gap-2 py-1">
<div class="flex min-w-0 flex-1 flex-row items-center gap-1 py-1">
<button
class="w-fit cursor-grab rounded-md border border-primary-200 bg-white px-2 py-1 text-sm font-semibold leading-none text-primary-700 active:cursor-grabbing"
draggable="false"
@@ -491,11 +680,20 @@
</button>
<div
class="truncate text-[13px] font-medium leading-tight text-primary-900"
class="min-w-0 flex-1 truncate text-[13px] font-medium leading-tight text-primary-900"
title={attachment.name}
>
{attachment.name}
</div>
<AttachmentMenu
{attachment}
moveTargets={subDocumentTargets}
canSplit={attachment.kind === 'pdf'}
onMoveToTarget={(targetId) => moveAttachmentToTarget(attachment.path, targetId)}
onSplit={() => (splitSource = attachment)}
onRemove={() => (removeCandidate = attachment)}
/>
</div>
<AttachmentPreview {attachment} />
{/if}
@@ -520,6 +718,15 @@
{thumbnailWidth}
isBlockDragSource={blockDragIndex === blockIndex}
isBlockDropTarget={blockDropIndex === blockIndex && blockDragIndex !== blockIndex}
{groupingMode}
{selectedPaths}
onToggleSelection={toggleSelection}
autoFocusName={subDocument.id === focusSubDocumentId}
moveTargets={subDocumentTargets.filter((target) => target.id !== subDocument.id)}
onMoveToTarget={(path, targetId) => moveAttachmentToTarget(path, targetId)}
onMoveToLoose={moveAttachmentToLoose}
onSplit={(attachment) => (splitSource = attachment)}
onRemoveAttachment={(attachment) => (removeCandidate = attachment)}
onSubDocumentChange={handleSubDocumentChange}
onRemove={() => dissolveSubDocumentBlock(subDocument.id)}
onAttachmentMove={handleAttachmentMove}
@@ -535,3 +742,32 @@
</div>
{/if}
</section>
{#if splitSource}
<PdfSplitDialog
source={splitSource}
onApply={handleSplitApply}
onClose={() => (splitSource = null)}
/>
{/if}
<AlertDialog.Root
open={removeCandidate !== null}
onOpenChange={(open) => {
if (!open) removeCandidate = null;
}}
>
<AlertDialog.Content class="max-w-sm">
<AlertDialog.Title class="text-base font-bold text-primary-900">
Datei entfernen?
</AlertDialog.Title>
<AlertDialog.Description>
{removeCandidate?.name ?? ''} wird aus dem Archiv entfernt. Bereits heruntergeladene PDFs bleiben
erhalten.
</AlertDialog.Description>
<AlertDialog.Footer>
<AlertDialog.Cancel>Abbrechen</AlertDialog.Cancel>
<AlertDialog.Action onclick={confirmRemoveAttachment}>Entfernen</AlertDialog.Action>
</AlertDialog.Footer>
</AlertDialog.Content>
</AlertDialog.Root>
+89 -18
View File
@@ -1,4 +1,5 @@
<script lang="ts">
import AttachmentMenu, { type AttachmentMoveTarget } from '$lib/components/AttachmentMenu.svelte';
import AttachmentPreview from '$lib/components/AttachmentPreview.svelte';
import Button from '$lib/components/ui/button.svelte';
import type { AttachmentLocation, SubDocument, ZipAttachment } from '$lib/zip-processing';
@@ -10,6 +11,15 @@
thumbnailWidth: number;
isBlockDragSource?: boolean;
isBlockDropTarget?: boolean;
groupingMode?: boolean;
selectedPaths?: Set<string>;
onToggleSelection?: (path: string) => void;
autoFocusName?: boolean;
moveTargets?: AttachmentMoveTarget[];
onMoveToTarget?: (path: string, targetId: string) => void;
onMoveToLoose?: (path: string) => void;
onSplit?: (attachment: ZipAttachment) => void;
onRemoveAttachment?: (attachment: ZipAttachment) => void;
onSubDocumentChange?: (next: SubDocument) => void;
onRemove?: () => void;
onAttachmentMove?: (source: AttachmentLocation, target: AttachmentLocation) => void;
@@ -28,6 +38,15 @@
thumbnailWidth = 200,
isBlockDragSource = false,
isBlockDropTarget = false,
groupingMode = false,
selectedPaths = new Set<string>(),
onToggleSelection = () => {},
autoFocusName = false,
moveTargets = [],
onMoveToTarget = () => {},
onMoveToLoose = () => {},
onSplit = () => {},
onRemoveAttachment = () => {},
onSubDocumentChange = () => {},
onRemove = () => {},
onAttachmentMove = () => {},
@@ -40,6 +59,7 @@
downloadDisabled = false
}: Props = $props();
let nameInput = $state<HTMLInputElement | null>(null);
let subDocumentName = $state('');
let dragIndex = $state<number | null>(null);
let dropIndex = $state<number | null>(null);
@@ -48,6 +68,14 @@
subDocumentName = subDocument.name;
});
$effect(() => {
if (autoFocusName && nameInput) {
nameInput.focus();
nameInput.select();
nameInput.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
}
});
const emitChange = (overrides: Partial<SubDocument> = {}) => {
onSubDocumentChange({
...subDocument,
@@ -81,6 +109,11 @@
};
const handleCardDragStart = (index: number, event: DragEvent) => {
if (groupingMode) {
event.preventDefault();
return;
}
dragIndex = index;
dropIndex = index;
@@ -195,6 +228,7 @@
<label class="block min-w-0 flex-1">
<span class="sr-only">Teildokumentname</span>
<input
bind:this={nameInput}
class="w-full max-w-md rounded-md border border-primary-200 bg-white px-3 py-1.5 text-[15px] font-semibold text-primary-900 outline-none transition focus:border-primary-400 focus:ring-2 focus:ring-primary-200"
type="text"
value={subDocumentName}
@@ -272,33 +306,70 @@
dropIndex === index
? 'border-primary-400 ring-1 ring-primary-200'
: 'border-primary-100',
dragIndex === index ? 'opacity-60' : ''
dragIndex === index ? 'opacity-60' : '',
groupingMode && selectedPaths.has(attachment.path)
? 'border-primary-500 ring-2 ring-primary-300'
: ''
)}
style={`width: ${thumbnailWidth}px;`}
draggable="true"
draggable={groupingMode ? 'false' : 'true'}
ondragstart={(event) => handleCardDragStart(index, event)}
ondragover={(event) => handleCardDragOver(index, event)}
ondrop={(event) => handleCardDrop(index, event)}
ondragend={handleCardDragEnd}
>
<div class="flex min-w-0 flex-1 flex-row items-baseline gap-2 py-1">
<button
class="w-fit cursor-grab rounded-md border border-primary-200 bg-white px-2 py-1 text-sm font-semibold leading-none text-primary-700 active:cursor-grabbing"
draggable="false"
type="button"
aria-label="Datei verschieben"
>
::
</button>
{#if groupingMode}
<label class="flex cursor-pointer flex-col gap-2">
<div class="flex items-center gap-2 px-1 text-[13px] font-medium text-primary-900">
<input
class="h-4 w-4 accent-primary-700"
type="checkbox"
checked={selectedPaths.has(attachment.path)}
onchange={() => onToggleSelection(attachment.path)}
/>
<span class="truncate"
>{selectedPaths.has(attachment.path) ? 'Ausgewählt' : 'Auswählen'}</span
>
</div>
<div
class="truncate text-[13px] font-medium leading-tight text-primary-900"
title={attachment.name}
>
{attachment.name}
<div
class="truncate text-[13px] font-medium leading-tight text-primary-900"
title={attachment.name}
>
{attachment.name}
</div>
<AttachmentPreview {attachment} />
</label>
{:else}
<div class="flex min-w-0 flex-1 flex-row items-center gap-1 py-1">
<button
class="w-fit cursor-grab rounded-md border border-primary-200 bg-white px-2 py-1 text-sm font-semibold leading-none text-primary-700 active:cursor-grabbing"
draggable="false"
type="button"
aria-label="Datei verschieben"
>
::
</button>
<div
class="min-w-0 flex-1 truncate text-[13px] font-medium leading-tight text-primary-900"
title={attachment.name}
>
{attachment.name}
</div>
<AttachmentMenu
{attachment}
{moveTargets}
canSplit={attachment.kind === 'pdf'}
onMoveToTarget={(targetId) => onMoveToTarget(attachment.path, targetId)}
onMoveToLoose={() => onMoveToLoose(attachment.path)}
onSplit={() => onSplit(attachment)}
onRemove={() => onRemoveAttachment(attachment)}
/>
</div>
</div>
<AttachmentPreview {attachment} />
<AttachmentPreview {attachment} />
{/if}
</li>
{/each}
</ul>
+4 -4
View File
@@ -1,6 +1,6 @@
<script lang="ts">
import FileDropzone from '$lib/components/FileDropzone.svelte';
import { ZIP_ACCEPT_TYPES } from '$lib/zip-selection';
import { WORKSPACE_ACCEPT_TYPES } from '$lib/zip-selection';
type Props = {
onFilesSelected: (files: File[]) => void;
@@ -12,10 +12,10 @@
<FileDropzone
{onFilesSelected}
accept={ZIP_ACCEPT_TYPES}
accept={WORKSPACE_ACCEPT_TYPES}
multiple={true}
ariaLabel="beA-ZIP-Dateien hinzufügen"
ariaLabel="beA-ZIP- oder PDF-Dateien hinzufügen"
label="Dateien hinzufügen"
sublabel="beA-ZIP-Dateien hier ablegen oder zum Auswählen klicken"
sublabel="beA-ZIP oder PDF hier ablegen oder zum Auswählen klicken"
class="min-h-[calc(-180px+100vh)] border-0 bg-transparent {className}"
/>
+3 -3
View File
@@ -1,7 +1,7 @@
<script lang="ts">
import Button from '$lib/components/ui/button.svelte';
import type { ButtonVariant } from '$lib/components/ui/button.svelte';
import { isAcceptedZipFile, ZIP_ACCEPT_TYPES } from '$lib/zip-selection';
import { isAcceptedWorkspaceFile, WORKSPACE_ACCEPT_TYPES } from '$lib/zip-selection';
import type { Snippet } from 'svelte';
type Props = {
@@ -31,7 +31,7 @@
const handleFileSelection = (event: Event) => {
const input = event.currentTarget as HTMLInputElement;
const files = Array.from(input.files ?? []).filter(isAcceptedZipFile);
const files = Array.from(input.files ?? []).filter(isAcceptedWorkspaceFile);
// Reset first so selecting the same file again still fires `change`.
input.value = '';
@@ -45,7 +45,7 @@
bind:this={fileInput}
class="hidden"
type="file"
accept={ZIP_ACCEPT_TYPES}
accept={WORKSPACE_ACCEPT_TYPES}
{multiple}
data-zip-picker={pickerId}
onchange={handleFileSelection}
@@ -0,0 +1,16 @@
<script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui';
let {
ref = $bindable(null),
value = $bindable([]),
...restProps
}: DropdownMenuPrimitive.CheckboxGroupProps = $props();
</script>
<DropdownMenuPrimitive.CheckboxGroup
bind:ref
bind:value
data-slot="dropdown-menu-checkbox-group"
{...restProps}
/>
@@ -0,0 +1,46 @@
<script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui';
import MinusIcon from '@lucide/svelte/icons/minus';
import CheckIcon from '@lucide/svelte/icons/check';
import { cn, type WithoutChildrenOrChild } from '$lib/utils.js';
import type { Snippet } from 'svelte';
let {
ref = $bindable(null),
checked = $bindable(false),
indeterminate = $bindable(false),
class: className,
children: childrenProp,
closeOnSelect = false,
...restProps
}: WithoutChildrenOrChild<DropdownMenuPrimitive.CheckboxItemProps> & {
children?: Snippet;
} = $props();
</script>
<DropdownMenuPrimitive.CheckboxItem
bind:ref
{closeOnSelect}
bind:checked
bind:indeterminate
data-slot="dropdown-menu-checkbox-item"
class={cn(
"focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm data-inset:pl-7 [&_svg:not([class*='size-'])]:size-4 relative flex cursor-default items-center outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className
)}
{...restProps}
>
{#snippet children({ checked, indeterminate })}
<span
class="absolute right-2 flex items-center justify-center pointer-events-none"
data-slot="dropdown-menu-checkbox-item-indicator"
>
{#if indeterminate}
<MinusIcon />
{:else if checked}
<CheckIcon />
{/if}
</span>
{@render childrenProp?.()}
{/snippet}
</DropdownMenuPrimitive.CheckboxItem>
@@ -0,0 +1,31 @@
<script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui';
import { cn, type WithoutChildrenOrChild } from '$lib/utils.js';
import DropdownMenuPortal from './dropdown-menu-portal.svelte';
import type { ComponentProps } from 'svelte';
let {
ref = $bindable(null),
sideOffset = 4,
align = 'start',
portalProps,
class: className,
...restProps
}: DropdownMenuPrimitive.ContentProps & {
portalProps?: WithoutChildrenOrChild<ComponentProps<typeof DropdownMenuPortal>>;
} = $props();
</script>
<DropdownMenuPortal {...portalProps}>
<DropdownMenuPrimitive.Content
bind:ref
data-slot="dropdown-menu-content"
{sideOffset}
{align}
class={cn(
'data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 ring-foreground/10 bg-popover text-popover-foreground min-w-32 rounded-lg p-1 shadow-md ring-1 duration-100 z-50 max-h-(--bits-dropdown-menu-content-available-height) w-(--bits-dropdown-menu-anchor-width) origin-(--bits-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto data-closed:overflow-hidden',
className
)}
{...restProps}
/>
</DropdownMenuPortal>
@@ -0,0 +1,22 @@
<script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui';
import { cn } from '$lib/utils.js';
import type { ComponentProps } from 'svelte';
let {
ref = $bindable(null),
class: className,
inset,
...restProps
}: ComponentProps<typeof DropdownMenuPrimitive.GroupHeading> & {
inset?: boolean;
} = $props();
</script>
<DropdownMenuPrimitive.GroupHeading
bind:ref
data-slot="dropdown-menu-group-heading"
data-inset={inset}
class={cn('text-muted-foreground px-1.5 py-1 text-xs font-medium data-inset:pl-7', className)}
{...restProps}
/>
@@ -0,0 +1,7 @@
<script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui';
let { ref = $bindable(null), ...restProps }: DropdownMenuPrimitive.GroupProps = $props();
</script>
<DropdownMenuPrimitive.Group bind:ref data-slot="dropdown-menu-group" {...restProps} />
@@ -0,0 +1,27 @@
<script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui';
import { cn } from '$lib/utils.js';
let {
ref = $bindable(null),
class: className,
inset,
variant = 'default',
...restProps
}: DropdownMenuPrimitive.ItemProps & {
inset?: boolean;
variant?: 'default' | 'destructive';
} = $props();
</script>
<DropdownMenuPrimitive.Item
bind:ref
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
class={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:text-destructive not-data-[variant=destructive]:focus:**:text-accent-foreground gap-1.5 rounded-md px-1.5 py-1 text-sm data-inset:pl-7 [&_svg:not([class*='size-'])]:size-4 group/dropdown-menu-item relative flex cursor-default items-center outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className
)}
{...restProps}
/>
@@ -0,0 +1,24 @@
<script lang="ts">
import { cn, type WithElementRef } from '$lib/utils.js';
import type { HTMLAttributes } from 'svelte/elements';
let {
ref = $bindable(null),
class: className,
inset,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & {
inset?: boolean;
} = $props();
</script>
<div
bind:this={ref}
data-slot="dropdown-menu-label"
data-inset={inset}
class={cn('text-muted-foreground px-1.5 py-1 text-xs font-medium data-inset:pl-7', className)}
{...restProps}
>
{@render children?.()}
</div>
@@ -0,0 +1,7 @@
<script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui';
let { ...restProps }: DropdownMenuPrimitive.PortalProps = $props();
</script>
<DropdownMenuPrimitive.Portal {...restProps} />
@@ -0,0 +1,16 @@
<script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui';
let {
ref = $bindable(null),
value = $bindable(),
...restProps
}: DropdownMenuPrimitive.RadioGroupProps = $props();
</script>
<DropdownMenuPrimitive.RadioGroup
bind:ref
bind:value
data-slot="dropdown-menu-radio-group"
{...restProps}
/>
@@ -0,0 +1,36 @@
<script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui';
import CheckIcon from '@lucide/svelte/icons/check';
import { cn, type WithoutChild } from '$lib/utils.js';
let {
ref = $bindable(null),
class: className,
children: childrenProp,
closeOnSelect = false,
...restProps
}: WithoutChild<DropdownMenuPrimitive.RadioItemProps> = $props();
</script>
<DropdownMenuPrimitive.RadioItem
bind:ref
{closeOnSelect}
data-slot="dropdown-menu-radio-item"
class={cn(
"focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm data-inset:pl-7 [&_svg:not([class*='size-'])]:size-4 relative flex cursor-default items-center outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className
)}
{...restProps}
>
{#snippet children({ checked })}
<span
class="absolute right-2 flex items-center justify-center pointer-events-none"
data-slot="dropdown-menu-radio-item-indicator"
>
{#if checked}
<CheckIcon />
{/if}
</span>
{@render childrenProp?.({ checked })}
{/snippet}
</DropdownMenuPrimitive.RadioItem>
@@ -0,0 +1,17 @@
<script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui';
import { cn } from '$lib/utils.js';
let {
ref = $bindable(null),
class: className,
...restProps
}: DropdownMenuPrimitive.SeparatorProps = $props();
</script>
<DropdownMenuPrimitive.Separator
bind:ref
data-slot="dropdown-menu-separator"
class={cn('bg-border -mx-1 my-1 h-px', className)}
{...restProps}
/>
@@ -0,0 +1,23 @@
<script lang="ts">
import { cn, type WithElementRef } from '$lib/utils.js';
import type { HTMLAttributes } from 'svelte/elements';
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLSpanElement>> = $props();
</script>
<span
bind:this={ref}
data-slot="dropdown-menu-shortcut"
class={cn(
'text-muted-foreground group-focus/dropdown-menu-item:text-accent-foreground ml-auto text-xs tracking-widest',
className
)}
{...restProps}
>
{@render children?.()}
</span>
@@ -0,0 +1,31 @@
<script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui';
import { cn, type WithoutChildrenOrChild } from '$lib/utils.js';
import DropdownMenuPortal from './dropdown-menu-portal.svelte';
import type { ComponentProps } from 'svelte';
let {
ref = $bindable(null),
class: className,
align = 'start',
alignOffset = -3,
portalProps,
...restProps
}: DropdownMenuPrimitive.SubContentProps & {
portalProps?: WithoutChildrenOrChild<ComponentProps<typeof DropdownMenuPortal>>;
} = $props();
</script>
<DropdownMenuPortal {...portalProps}>
<DropdownMenuPrimitive.SubContent
bind:ref
data-slot="dropdown-menu-sub-content"
{align}
{alignOffset}
class={cn(
'data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 ring-foreground/10 bg-popover text-popover-foreground min-w-[96px] rounded-lg p-1 shadow-lg ring-1 duration-100 z-50 origin-(--bits-dropdown-menu-content-transform-origin) overflow-hidden',
className
)}
{...restProps}
/>
</DropdownMenuPortal>
@@ -0,0 +1,29 @@
<script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui';
import ChevronRightIcon from '@lucide/svelte/icons/chevron-right';
import { cn } from '$lib/utils.js';
let {
ref = $bindable(null),
class: className,
inset,
children,
...restProps
}: DropdownMenuPrimitive.SubTriggerProps & {
inset?: boolean;
} = $props();
</script>
<DropdownMenuPrimitive.SubTrigger
bind:ref
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
class={cn(
"focus:bg-accent focus:text-accent-foreground data-open:bg-accent data-open:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground gap-1.5 rounded-md px-1.5 py-1 text-sm data-inset:pl-7 [&_svg:not([class*='size-'])]:size-4 flex cursor-default items-center outline-hidden select-none [&_svg]:pointer-events-none [&_svg]:shrink-0",
className
)}
{...restProps}
>
{@render children?.()}
<ChevronRightIcon class="cn-rtl-flip ml-auto" />
</DropdownMenuPrimitive.SubTrigger>
@@ -0,0 +1,7 @@
<script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui';
let { open = $bindable(false), ...restProps }: DropdownMenuPrimitive.SubProps = $props();
</script>
<DropdownMenuPrimitive.Sub bind:open {...restProps} />
@@ -0,0 +1,7 @@
<script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui';
let { ref = $bindable(null), ...restProps }: DropdownMenuPrimitive.TriggerProps = $props();
</script>
<DropdownMenuPrimitive.Trigger bind:ref data-slot="dropdown-menu-trigger" {...restProps} />
@@ -0,0 +1,7 @@
<script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui';
let { open = $bindable(false), ...restProps }: DropdownMenuPrimitive.RootProps = $props();
</script>
<DropdownMenuPrimitive.Root bind:open {...restProps} />
@@ -0,0 +1,54 @@
import CheckboxGroup from './dropdown-menu-checkbox-group.svelte';
import CheckboxItem from './dropdown-menu-checkbox-item.svelte';
import Content from './dropdown-menu-content.svelte';
import GroupHeading from './dropdown-menu-group-heading.svelte';
import Group from './dropdown-menu-group.svelte';
import Item from './dropdown-menu-item.svelte';
import Label from './dropdown-menu-label.svelte';
import Portal from './dropdown-menu-portal.svelte';
import RadioGroup from './dropdown-menu-radio-group.svelte';
import RadioItem from './dropdown-menu-radio-item.svelte';
import Separator from './dropdown-menu-separator.svelte';
import Shortcut from './dropdown-menu-shortcut.svelte';
import SubContent from './dropdown-menu-sub-content.svelte';
import SubTrigger from './dropdown-menu-sub-trigger.svelte';
import Sub from './dropdown-menu-sub.svelte';
import Trigger from './dropdown-menu-trigger.svelte';
import Root from './dropdown-menu.svelte';
export {
CheckboxGroup,
CheckboxItem,
Content,
Portal,
Root as DropdownMenu,
CheckboxGroup as DropdownMenuCheckboxGroup,
CheckboxItem as DropdownMenuCheckboxItem,
Content as DropdownMenuContent,
Portal as DropdownMenuPortal,
Group as DropdownMenuGroup,
Item as DropdownMenuItem,
Label as DropdownMenuLabel,
RadioGroup as DropdownMenuRadioGroup,
RadioItem as DropdownMenuRadioItem,
Separator as DropdownMenuSeparator,
Shortcut as DropdownMenuShortcut,
Sub as DropdownMenuSub,
SubContent as DropdownMenuSubContent,
SubTrigger as DropdownMenuSubTrigger,
Trigger as DropdownMenuTrigger,
GroupHeading as DropdownMenuGroupHeading,
Group,
GroupHeading,
Item,
Label,
RadioGroup,
RadioItem,
Root,
Separator,
Shortcut,
Sub,
SubContent,
SubTrigger,
Trigger
};
+24
View File
@@ -15,3 +15,27 @@ export const mergePdfFiles = async (files: File[]): Promise<Uint8Array> => {
return mergedDocument.save();
};
/**
* Copy a set of pages (0-based indices) from `sourceBytes` into a fresh PDF.
* Shared by the separate/extract tools and the archive editor's page splitting
* so the page-copy semantics can never drift apart.
*/
export const copyPagesToNewPdf = async (
sourceBytes: Uint8Array,
pageIndexes: number[]
): Promise<Uint8Array> => {
if (pageIndexes.length === 0) {
throw new Error('Cannot create a PDF from an empty page selection.');
}
const sourceDocument = await PDFDocument.load(sourceBytes);
const outputDocument = await PDFDocument.create();
const copiedPages = await outputDocument.copyPages(sourceDocument, pageIndexes);
for (const page of copiedPages) {
outputDocument.addPage(page);
}
return outputDocument.save();
};
+355
View File
@@ -1,5 +1,6 @@
import { PDFDocument } from 'pdf-lib';
import { copyPagesToNewPdf } from './pdf-processing';
import { parseXJustizMetadata, type XJustizMetadata } from './services/xml-reading.service';
import { unzipArchive } from './services/zip-inflating.service';
@@ -642,3 +643,357 @@ export const buildArchiveExport = async (
return separateUnits;
};
/**
* One sub-document produced by splitting a PDF attachment into page groups.
*/
export type PdfSplitGroup = {
name: string;
attachment: ZipAttachment;
};
/**
* Result of a page-level PDF split. `source` is the remaining source
* attachment after cutting out the assigned pages; `null` removes the
* source from the archive entirely (all pages assigned or copy mode off).
*/
export type PdfSplitResult = {
groups: PdfSplitGroup[];
source: ZipAttachment | null;
};
/**
* Turn a PDF file dropped into the beA workspace into a single-attachment
* archive so the editor's splitting/grouping workflow can operate on it.
*/
export const pdfFileToArchive = async (file: File): Promise<ProcessedZipArchive> => {
const bytes = new Uint8Array(await file.arrayBuffer());
const sourcePdf = await PDFDocument.load(bytes);
if (sourcePdf.getPageCount() === 0) {
throw new Error('The PDF does not contain any pages.');
}
const name = file.name.toLowerCase().endsWith('.pdf') ? file.name : `${file.name}.pdf`;
return {
name,
attachments: [
{
name,
path: file.name,
kind: 'pdf',
data: bytes
}
],
subDocuments: []
};
};
/**
* Copy a set of pages (1-based) of a PDF attachment into a new synthetic
* attachment. The generated `path` carries a unique suffix because `path` is
* the identity used for keyed lists, drag payloads, and selection state.
*/
export const createAttachmentFromPages = async (
source: ZipAttachment,
pages: number[],
name?: string
): Promise<ZipAttachment> => {
if (pages.length === 0) {
throw new Error('Cannot create an attachment from an empty page selection.');
}
const minPage = Math.min(...pages);
const maxPage = Math.max(...pages);
const baseName = stripPdfExtension(source.name);
const attachmentName =
name ??
(pages.length === 1
? `${baseName}_Seite_${pages[0]}.pdf`
: `${baseName}_Seiten_${minPage}-${maxPage}.pdf`);
const bytes = await copyPagesToNewPdf(
source.data,
pages.map((page) => page - 1)
);
return {
name: attachmentName,
path: `${source.path}#seiten-${crypto.randomUUID()}`,
kind: 'pdf',
data: bytes
};
};
/**
* Remove a set of pages (1-based) from a PDF attachment. Returns the remaining
* attachment, `null` when no pages are left, or the unchanged source when the
* page set removes nothing.
*/
export const removePagesFromAttachment = async (
source: ZipAttachment,
pages: number[]
): Promise<ZipAttachment | null> => {
const sourcePdf = await PDFDocument.load(source.data);
const pageCount = sourcePdf.getPageCount();
const pagesToRemove = new Set(pages);
const keepIndexes: number[] = [];
for (let pageIndex = 0; pageIndex < pageCount; pageIndex += 1) {
if (!pagesToRemove.has(pageIndex + 1)) {
keepIndexes.push(pageIndex);
}
}
if (keepIndexes.length === 0) {
return null;
}
if (keepIndexes.length === pageCount) {
return { ...source };
}
return {
...source,
data: await copyPagesToNewPdf(source.data, keepIndexes)
};
};
/**
* Apply a completed PDF split to the archive: the source attachment (located
* by its unique `path`) is replaced by the remaining pages (or removed), and
* the new sub-documents are inserted directly after the source's parent
* sub-document so the reading order is preserved. A loose source appends the
* new sub-documents at the end.
*/
export const applyPdfSplit = (
archive: ProcessedZipArchive,
sourcePath: string,
result: PdfSplitResult
): ProcessedZipArchive => {
let sourceSubDocumentId: string | null = null;
let insertIndex = -1;
const nextSubDocuments: SubDocument[] = [];
for (const subDocument of archive.subDocuments) {
const sourceIndex = subDocument.attachments.findIndex(
(attachment) => attachment.path === sourcePath
);
if (sourceIndex === -1) {
nextSubDocuments.push(subDocument);
continue;
}
const remainingAttachments = subDocument.attachments.filter(
(attachment) => attachment.path !== sourcePath
);
if (result.source) {
remainingAttachments.splice(sourceIndex, 0, { ...result.source });
}
sourceSubDocumentId = subDocument.id;
insertIndex = nextSubDocuments.length;
nextSubDocuments.push({ ...subDocument, attachments: remainingAttachments });
}
const looseSourceIndex = archive.attachments.findIndex(
(attachment) => attachment.path === sourcePath
);
if (looseSourceIndex === -1 && sourceSubDocumentId === null) {
return archive;
}
const newSubDocuments = result.groups.map((group) =>
createSubDocument(group.name, [group.attachment])
);
const nextLooseAttachments = archive.attachments.filter(
(attachment) => attachment.path !== sourcePath
);
if (sourceSubDocumentId === null) {
if (result.source) {
nextLooseAttachments.splice(looseSourceIndex, 0, { ...result.source });
}
return {
...archive,
attachments: nextLooseAttachments,
subDocuments: [...nextSubDocuments, ...newSubDocuments]
};
}
nextSubDocuments.splice(insertIndex + 1, 0, ...newSubDocuments);
return {
...archive,
attachments: nextLooseAttachments,
subDocuments: nextSubDocuments
};
};
/**
* Next free "Teildokument N" number, derived from the maximum existing
* trailing number rather than the list length, so dissolve/re-create cycles
* cannot produce duplicate names.
*/
export const nextSubDocumentNumber = (subDocuments: SubDocument[]): number => {
const namePattern = /^Teildokument (\d+)$/;
let maxNumber = 0;
for (const subDocument of subDocuments) {
const match = namePattern.exec(subDocument.name.trim());
if (match) {
maxNumber = Math.max(maxNumber, Number.parseInt(match[1], 10));
}
}
return maxNumber + 1;
};
const detachAttachmentsByPaths = (
archive: ProcessedZipArchive,
paths: Set<string>
): { extracted: ZipAttachment[]; archive: ProcessedZipArchive } => {
const extracted: ZipAttachment[] = [];
const subDocuments = archive.subDocuments.map((subDocument) => ({
...subDocument,
attachments: subDocument.attachments.filter((attachment) => {
if (paths.has(attachment.path)) {
extracted.push(attachment);
return false;
}
return true;
})
}));
const attachments = archive.attachments.filter((attachment) => {
if (paths.has(attachment.path)) {
extracted.push(attachment);
return false;
}
return true;
});
return { extracted, archive: { ...archive, attachments, subDocuments } };
};
/**
* Group the attachments identified by `paths` (loose or inside any
* sub-document) into a new sub-document appended at the end. Returns the
* input archive unchanged when no selected attachment exists.
*/
export const createSubDocumentFromPaths = (
archive: ProcessedZipArchive,
paths: Set<string>
): ProcessedZipArchive => {
const { extracted, archive: strippedArchive } = detachAttachmentsByPaths(archive, paths);
if (extracted.length === 0) {
return archive;
}
const name = `Teildokument ${nextSubDocumentNumber(archive.subDocuments)}`;
return {
...strippedArchive,
subDocuments: [...strippedArchive.subDocuments, createSubDocument(name, extracted)]
};
};
/**
* Move the attachments identified by `paths` (loose or inside any
* sub-document) to the end of an existing sub-document's attachment list.
*/
export const moveAttachmentsByPathsToSubDocument = (
archive: ProcessedZipArchive,
paths: Set<string>,
targetSubDocumentId: string
): ProcessedZipArchive => {
const { extracted, archive: strippedArchive } = detachAttachmentsByPaths(archive, paths);
if (extracted.length === 0) {
return archive;
}
let targetFound = false;
const subDocuments = strippedArchive.subDocuments.map((subDocument) => {
if (subDocument.id !== targetSubDocumentId) {
return subDocument;
}
targetFound = true;
return {
...subDocument,
attachments: [
...subDocument.attachments,
...extracted.map((attachment) => ({ ...attachment }))
]
};
});
if (!targetFound) {
return archive;
}
return { ...strippedArchive, subDocuments };
};
/**
* Remove a single attachment from the archive, wherever it currently lives.
* Located by its unique `path`; returns the input archive unchanged when no
* attachment matches.
*/
export const removeAttachmentFromArchive = (
archive: ProcessedZipArchive,
path: string
): ProcessedZipArchive => {
const attachments = archive.attachments.filter((attachment) => attachment.path !== path);
let removed = attachments.length !== archive.attachments.length;
const subDocuments = archive.subDocuments.map((subDocument) => {
const nextAttachments = subDocument.attachments.filter(
(attachment) => attachment.path !== path
);
if (nextAttachments.length !== subDocument.attachments.length) {
removed = true;
}
return nextAttachments.length === subDocument.attachments.length
? subDocument
: { ...subDocument, attachments: nextAttachments };
});
return removed ? { ...archive, attachments, subDocuments } : archive;
};
/**
* Move every loose attachment into its own sub-document named after the
* attachment. The sub-document order matches the loose attachment order.
*/
export const groupEachAttachmentAsSubDocument = (
archive: ProcessedZipArchive
): ProcessedZipArchive => {
if (archive.attachments.length === 0) {
return archive;
}
const newSubDocuments = archive.attachments.map((attachment) =>
createSubDocument(stripPdfExtension(attachment.name), [attachment])
);
return {
...archive,
attachments: [],
subDocuments: [...archive.subDocuments, ...newSubDocuments]
};
};
+10
View File
@@ -1,6 +1,16 @@
export const ZIP_ACCEPT_TYPES = '.zip,application/zip,application/x-zip-compressed';
export const PDF_ACCEPT_TYPES = '.pdf,application/pdf';
export const WORKSPACE_ACCEPT_TYPES = `${ZIP_ACCEPT_TYPES},${PDF_ACCEPT_TYPES}`;
export const isAcceptedZipFile = (file: File): boolean => {
if (file.name.toLowerCase().endsWith('.zip')) return true;
return ['application/zip', 'application/x-zip-compressed'].includes(file.type.toLowerCase());
};
export const isAcceptedPdfFile = (file: File): boolean => {
if (file.name.toLowerCase().endsWith('.pdf')) return true;
return file.type.toLowerCase() === 'application/pdf';
};
export const isAcceptedWorkspaceFile = (file: File): boolean =>
isAcceptedZipFile(file) || isAcceptedPdfFile(file);