cards/apps/web/src/lib/components/ImageOcclusionEditor.svelte
Till JS 19a0036b82 feat(theming): forest variant from @mana/themes (sprint 9m)
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>
2026-05-09 18:01:37 +02:00

216 lines
6.1 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!--
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>