Cards is the first app on the new 12-token mana-vereinsweite theming system (mana/docs/THEMING.md). Forest-Variant aus @mana/themes/variants/forest.css konsumiert via app.css-Import, data-theme="forest" in app.html. Token-Welt umgestellt — 158 renames + 304 hsl-wraps in 17 Files (Python-Refactor, BSD-sed war zu unzuverlässig): - --color-bg → --color-background - --color-fg → --color-foreground - --color-muted → --color-muted-foreground - --color-primary-fg → --color-primary-foreground - --color-danger → --color-error - bare var(--color-X) → hsl(var(--color-X)) durchgängig Bridge-Aliase in app.css mappen die shared-ui@0.1.x-Erwartungen (card, accent, surface-elevated-*, …) auf das 12er-Set. Mit shared-ui@2.0-Refactor entfällt diese Sektion. --brand-cards-forest als App-Identitäts-Hex separiert von Theme-Tokens. Header konsumiert PillTabGroup aus @mana/shared-ui@0.1.1 für die Routen-Navigation (Decks/Lernen/Library/Import/Stats) und den DE/EN-Sprach-Switcher — visuell konsistent mit Vereins-Standard. Cards' primary-Grün wurde dabei von 142 76% 36% (alter Live-Stand) auf 142 76% 28% verdunkelt, damit primary-foreground/primary- Kontrast WCAG-AA-konform (≥4.5) ist. Der alte Live-Stand hatte Ratio 3.35. i18n: deck_stack.aria_label, deck_detail.fan_aria, deck_detail. card_open, decks.card_count_more, study_session.manage_link. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
216 lines
6.1 KiB
Svelte
216 lines
6.1 KiB
Svelte
<!--
|
||
Image-Occlusion-Editor: Bild auswählen, mit Maus rechteckige Masken
|
||
auf das Bild zeichnen, jede wird zu einem eigenen Review.
|
||
|
||
Modell: alle Coordinaten in 0..1 relativ zum Bild — der Renderer
|
||
skaliert auf die tatsächliche Display-Größe. So überleben Masken
|
||
auch Browser-Resizing und Mobile-Display.
|
||
-->
|
||
<script lang="ts">
|
||
import { type MaskRegion, parseMaskRegions } from '@cards/domain';
|
||
import { uploadMedia } from '$lib/api/media.ts';
|
||
import { API_BASE } from '$lib/api/client.ts';
|
||
import { toasts } from '$lib/stores/toasts.svelte.ts';
|
||
import { t } from '$lib/i18n/index.svelte.ts';
|
||
|
||
let {
|
||
imageRef = $bindable(''),
|
||
maskRegionsJson = $bindable('[]'),
|
||
}: {
|
||
imageRef: string;
|
||
maskRegionsJson: string;
|
||
} = $props();
|
||
|
||
let imgEl: HTMLImageElement | null = $state(null);
|
||
let containerEl: HTMLDivElement | null = $state(null);
|
||
let uploading = $state(false);
|
||
let dragStart = $state<{ x: number; y: number } | null>(null);
|
||
let dragCurrent = $state<{ x: number; y: number } | null>(null);
|
||
|
||
const masks = $derived(parseMaskRegions(maskRegionsJson));
|
||
const imageUrl = $derived(imageRef ? `${API_BASE}/api/v1/media/${imageRef}` : '');
|
||
|
||
async function onFile(e: Event) {
|
||
const input = e.currentTarget as HTMLInputElement;
|
||
const file = input.files?.[0];
|
||
if (!file) return;
|
||
uploading = true;
|
||
try {
|
||
const r = await uploadMedia(file);
|
||
if (r.kind !== 'image') {
|
||
toasts.error(t('image_occlusion.not_an_image'));
|
||
return;
|
||
}
|
||
imageRef = r.id;
|
||
maskRegionsJson = '[]';
|
||
} catch (err) {
|
||
toasts.error(`${(err as Error).message}`);
|
||
} finally {
|
||
uploading = false;
|
||
input.value = '';
|
||
}
|
||
}
|
||
|
||
function rel(e: MouseEvent | PointerEvent): { x: number; y: number } | null {
|
||
if (!containerEl) return null;
|
||
const rect = containerEl.getBoundingClientRect();
|
||
const x = (e.clientX - rect.left) / rect.width;
|
||
const y = (e.clientY - rect.top) / rect.height;
|
||
return { x: Math.max(0, Math.min(1, x)), y: Math.max(0, Math.min(1, y)) };
|
||
}
|
||
|
||
function onPointerDown(e: PointerEvent) {
|
||
if (!imageRef) return;
|
||
const p = rel(e);
|
||
if (!p) return;
|
||
dragStart = p;
|
||
dragCurrent = p;
|
||
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
|
||
}
|
||
|
||
function onPointerMove(e: PointerEvent) {
|
||
if (!dragStart) return;
|
||
dragCurrent = rel(e);
|
||
}
|
||
|
||
function onPointerUp(e: PointerEvent) {
|
||
if (!dragStart || !dragCurrent) {
|
||
dragStart = null;
|
||
dragCurrent = null;
|
||
return;
|
||
}
|
||
const x = Math.min(dragStart.x, dragCurrent.x);
|
||
const y = Math.min(dragStart.y, dragCurrent.y);
|
||
const w = Math.abs(dragCurrent.x - dragStart.x);
|
||
const h = Math.abs(dragCurrent.y - dragStart.y);
|
||
|
||
// Ignoriere zu kleine Dragger (Klick statt Drag).
|
||
if (w >= 0.02 && h >= 0.02) {
|
||
const id = `m${Date.now().toString(36)}`;
|
||
const next: MaskRegion = { id, x, y, w, h };
|
||
maskRegionsJson = JSON.stringify([...masks, next]);
|
||
}
|
||
dragStart = null;
|
||
dragCurrent = null;
|
||
(e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId);
|
||
}
|
||
|
||
function onPointerCancel(e: PointerEvent) {
|
||
dragStart = null;
|
||
dragCurrent = null;
|
||
(e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId);
|
||
}
|
||
|
||
function deleteMask(id: string) {
|
||
maskRegionsJson = JSON.stringify(masks.filter((m) => m.id !== id));
|
||
}
|
||
|
||
function setLabel(id: string, label: string) {
|
||
maskRegionsJson = JSON.stringify(
|
||
masks.map((m) => (m.id === id ? { ...m, label: label || undefined } : m))
|
||
);
|
||
}
|
||
|
||
const dragRect = $derived.by(() => {
|
||
if (!dragStart || !dragCurrent) return null;
|
||
return {
|
||
x: Math.min(dragStart.x, dragCurrent.x),
|
||
y: Math.min(dragStart.y, dragCurrent.y),
|
||
w: Math.abs(dragCurrent.x - dragStart.x),
|
||
h: Math.abs(dragCurrent.y - dragStart.y),
|
||
};
|
||
});
|
||
</script>
|
||
|
||
<div class="space-y-3">
|
||
<label class="block text-sm">
|
||
<span class="font-medium">{t('image_occlusion.image_label')}</span>
|
||
<input
|
||
type="file"
|
||
accept="image/*"
|
||
class="mt-1 block text-xs"
|
||
disabled={uploading}
|
||
onchange={onFile}
|
||
/>
|
||
</label>
|
||
{#if uploading}
|
||
<p class="text-xs text-[hsl(var(--color-muted-foreground))]">{t('image_occlusion.uploading')}</p>
|
||
{/if}
|
||
|
||
{#if imageRef}
|
||
<div
|
||
bind:this={containerEl}
|
||
class="relative inline-block max-w-full select-none touch-none"
|
||
onpointerdown={onPointerDown}
|
||
onpointermove={onPointerMove}
|
||
onpointerup={onPointerUp}
|
||
onpointercancel={onPointerCancel}
|
||
role="application"
|
||
aria-label={t('image_occlusion.canvas_aria')}
|
||
>
|
||
<img
|
||
bind:this={imgEl}
|
||
src={imageUrl}
|
||
alt=""
|
||
class="block max-w-full"
|
||
draggable="false"
|
||
/>
|
||
<svg
|
||
class="absolute inset-0 h-full w-full pointer-events-none"
|
||
viewBox="0 0 1 1"
|
||
preserveAspectRatio="none"
|
||
>
|
||
{#each masks as m (m.id)}
|
||
<rect
|
||
x={m.x}
|
||
y={m.y}
|
||
width={m.w}
|
||
height={m.h}
|
||
fill="rgba(255,180,0,0.6)"
|
||
stroke="rgba(255,140,0,1)"
|
||
stroke-width="0.005"
|
||
/>
|
||
{/each}
|
||
{#if dragRect}
|
||
<rect
|
||
x={dragRect.x}
|
||
y={dragRect.y}
|
||
width={dragRect.w}
|
||
height={dragRect.h}
|
||
fill="rgba(0,140,255,0.4)"
|
||
stroke="rgba(0,100,200,1)"
|
||
stroke-width="0.005"
|
||
stroke-dasharray="0.01,0.005"
|
||
/>
|
||
{/if}
|
||
</svg>
|
||
</div>
|
||
|
||
<p class="text-xs text-[hsl(var(--color-muted-foreground))]">{t('image_occlusion.draw_hint')}</p>
|
||
|
||
{#if masks.length > 0}
|
||
<ul class="space-y-2 text-sm">
|
||
{#each masks as m, i (m.id)}
|
||
<li class="flex items-center gap-3 rounded border border-[hsl(var(--color-border))] px-3 py-2">
|
||
<span class="text-xs text-[hsl(var(--color-muted-foreground))] tabular-nums">{i + 1}</span>
|
||
<input
|
||
type="text"
|
||
placeholder={t('image_occlusion.label_placeholder')}
|
||
value={m.label ?? ''}
|
||
oninput={(e) => setLabel(m.id, (e.currentTarget as HTMLInputElement).value)}
|
||
class="flex-1 rounded border bg-[hsl(var(--color-card))] border-[hsl(var(--color-border))] px-2 py-1 text-sm"
|
||
/>
|
||
<button
|
||
type="button"
|
||
onclick={() => deleteMask(m.id)}
|
||
class="text-xs text-[hsl(var(--color-muted-foreground))] hover:text-[hsl(var(--color-error))]"
|
||
aria-label={t('image_occlusion.delete_mask')}
|
||
>
|
||
×
|
||
</button>
|
||
</li>
|
||
{/each}
|
||
</ul>
|
||
{/if}
|
||
{/if}
|
||
</div>
|