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,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>
|
||||
|
||||
@@ -70,6 +70,9 @@
|
||||
✓
|
||||
</div>
|
||||
{/if}
|
||||
{#if pageActions}
|
||||
{@render pageActions(thumb.pageNumber)}
|
||||
{/if}
|
||||
</button>
|
||||
{:else}
|
||||
<div
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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}"
|
||||
/>
|
||||
@@ -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
|
||||
};
|
||||
Reference in new issue
Block a user