feat(card): Marken-Logos entfernt, Community-Logo unten rechts im Renderer
- card/config.ts: network und das Apple-Logo entfallen. CardConfig.logo ist die ID eines Community-Logos (Muster ID_PATTERN,
sonst null), DEFAULT_CONFIG.logo = null, serializeConfig schreibt {v, template, logo, details, brand, crop}. Ein altes network
wird ignoriert, brand 'apple' fällt auf den Standard. ID_PATTERN steht jetzt hier (die reine Konfiguration prüft die Logo-ID,
ohne Appwrite zu laden), account/cards.ts reicht sie weiter; single-sources.test.ts sorgt für genau eine Definition.
- card/geometry.ts: fitLogo und LOGO_MAX_H (125) ersetzen fitNetwork, Layout.network heißt Layout.logo.
- card/marks.ts: nur noch die AirCard-IOS-Marken (BRAND_MARKS, getBrandMark, svgAspect, tintSvg, loadMarkImage). Die 19 SVGs
der Netzwerke und von Apple sind per git rm gelöscht, ein Test hält assets/marks auf die zwei AirCard-Dateien.
- card/render.ts: LogoAsset, CardAssets.logo statt network, LoadedAssets.logoMissing. loadCardAssets(config, image, loadLogo =
loadLogoAsset): ohne Logo kein Aufruf, null vom Lader ergibt logoMissing, ein Ladefehler wirft. Der Renderer zeichnet das
Logo mit fitLogo(w / h) unten rechts, wenn Vorlage und Bilder eines haben.
- logos/logo-asset.ts (neu): loadLogoAsset(id, tone) holt Zeile (getLogo) und PNG (loadLogoBlob) und dekodiert mit
createImageBitmap, sonst über ein Image. Einfarbige Logos werden in einem eigenen Canvas mit source-in in der Details-Farbe
gefüllt, mehrfarbige bleiben. Fehlende Zeile, ausgeblendetes Logo (auch für Hochlader und Admins), Datei 404, unlesbare
Datei und mehr als 1 000 000 Pixel ergeben null. Je (id, tone) ein Ladevorgang, ein Fehler bleibt nicht im Cache.
rememberLogo, forgetLogo und LOGO_MISSING nach der Schnittstelle des Plans.
- editor/options-panel.ts: keine Netzwerk-Kacheln mehr, Überschrift „Logo oben links“ mit Symbol, Text und Kein Logo (ohne
Apple), dazu logoSlot als leerer Platz oben für Task 8 (editor.css: Details rückt bei leerem Platz wie bisher nach oben).
- editor/editor-page.ts: logoKey aus Logo, Details und Marke, der Logo-Zustand hält missing. Fehlt das Logo, wird die Karte
ohne es gezeichnet und der Hinweis LOGO_MISSING gezeigt, solange das so bleibt; ein Wechsel auf ein vorhandenes Logo oder
auf kein Logo nimmt nur diesen Hinweis weg (PNG-Größe und „Bild fehlt“ stehen getrennt und bleiben). Kein LOGO_ERROR bei
fehlendem Logo, nur bei echtem Ladefehler, auch beim Download.
- community/publish.ts: LOGO_MISSING_PUBLISH, renderForGallery (jetzt exportiert) bricht bei logoMissing vor dem Rendern ab,
es wird nichts hochgeladen. publish-dialog.ts lässt den Text im Wortlaut durch.
- i18n/en.ts: englische Texte zu LOGO_MISSING, LOGO_MISSING_PUBLISH und „Logo oben links“.
- Tests: config, geometry, marks, render und die Fixtures von cards, theme, cards-page auf das neue Modell; neu
logo-asset.test.ts (28) und options-panel.test.ts (10); editor-page (14 neue), publish und publish-dialog erweitert.
Vitest 45 Dateien, 1319 Tests grün, tsc und Build sauber.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
3051b3b3f9
commit
0be5abd954
44 files changed
+1401
-366
No files matched your search
@@ -0,0 +1,142 @@
|
||||
// Community-Logos als Bild für den Kartenrenderer: Zeile und PNG laden, dekodieren, einfarbige Logos in der Details-Farbe füllen.
|
||||
// Zeilen und fertige Bilder bleiben im Speicher (je Logo und Details-Farbe nur ein Ladevorgang). Ein Logo, das es nicht (mehr)
|
||||
// gibt, ergibt null; ein echter Ladefehler (Netz, Server) wirft und bleibt nicht im Zwischenspeicher.
|
||||
import { TONE_COLOR, type DetailsTone } from '../card/config';
|
||||
// Nur der Typ: render.ts importiert dieses Modul (Standard-Lader), zur Laufzeit darf kein Kreis entstehen.
|
||||
import type { LogoAsset } from '../card/render';
|
||||
import { msg } from '../i18n/i18n';
|
||||
import { getLogo, loadLogoBlob, type CommunityLogo } from './logos';
|
||||
|
||||
/** Hinweis im Editor, wenn die Karte ein Logo wählt, das gelöscht oder ausgeblendet wurde. */
|
||||
export const LOGO_MISSING = msg('Das gewählte Logo ist nicht mehr verfügbar.');
|
||||
|
||||
/**
|
||||
* Obergrenze für Breite × Höhe eines geladenen Logos. Der Server nimmt höchstens 720 × 250 an (rund 180 000 Pixel); alles
|
||||
* Größere kann nur eine veränderte Datei sein. Es gilt als nicht verfügbar und wird weder in einen Canvas noch auf die Karte gezeichnet.
|
||||
*/
|
||||
const MAX_LOGO_PIXELS = 1_000_000;
|
||||
|
||||
type Decoded = LogoAsset;
|
||||
|
||||
// Zeilen (auch „gibt es nicht“ = null) und fertige Bilder; die Schlüssel der Bilder sind „<id>:<tone>“ (IDs enthalten kein „:“).
|
||||
const rows = new Map<string, Promise<CommunityLogo | null>>();
|
||||
const assets = new Map<string, Promise<LogoAsset | null>>();
|
||||
|
||||
/** Verwirft die fertigen Bilder eines Logos (beide Details-Farben). */
|
||||
function dropImages(id: string): void {
|
||||
for (const key of assets.keys()) if (key.startsWith(`${id}:`)) assets.delete(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Legt eine Zeile, die der Aufrufer schon hat (Logo-Liste, frisch hochgeladenes Logo), in den Zwischenspeicher: spart `getLogo`.
|
||||
* Ist sie ausgeblendet, fallen schon geladene Bilder des Logos weg: ein gesperrtes Logo darf nicht mehr gezeichnet werden.
|
||||
*/
|
||||
export function rememberLogo(logo: CommunityLogo): void {
|
||||
rows.set(logo.id, Promise.resolve(logo));
|
||||
if (logo.hidden) dropImages(logo.id);
|
||||
}
|
||||
|
||||
/** Vergisst Zeile und Bilder eines Logos (nach Löschen oder Ausblenden); der nächste Zugriff lädt neu. */
|
||||
export function forgetLogo(id: string): void {
|
||||
rows.delete(id);
|
||||
dropImages(id);
|
||||
}
|
||||
|
||||
/** Die Zeile eines Logos, einmal je ID; ein Fehler wird nicht festgehalten. */
|
||||
function rowFor(id: string): Promise<CommunityLogo | null> {
|
||||
const cached = rows.get(id);
|
||||
if (cached) return cached;
|
||||
const request = getLogo(id);
|
||||
// Nur entfernen, was noch derselbe Eintrag ist: forgetLogo/rememberLogo können ihn inzwischen ersetzt haben.
|
||||
request.catch(() => {
|
||||
if (rows.get(id) === request) rows.delete(id);
|
||||
});
|
||||
rows.set(id, request);
|
||||
return request;
|
||||
}
|
||||
|
||||
/** Die Datei gibt es nicht (mehr): Appwrite antwortet mit 404 / `storage_file_not_found`. */
|
||||
function isFileMissing(err: unknown): boolean {
|
||||
if (typeof err !== 'object' || err === null) return false;
|
||||
const { code, type } = err as { code?: unknown; type?: unknown };
|
||||
return code === 404 || type === 'storage_file_not_found';
|
||||
}
|
||||
|
||||
/** Ersatz ohne `createImageBitmap`: ein `Image` aus einer Object-URL (die URL ist nach dem Laden wieder frei). */
|
||||
function decodeWithImage(blob: Blob): Promise<Decoded> {
|
||||
const url = URL.createObjectURL(blob);
|
||||
return new Promise<Decoded>((resolve, reject) => {
|
||||
const img = new Image();
|
||||
img.onload = () => resolve({ source: img, w: img.naturalWidth, h: img.naturalHeight });
|
||||
img.onerror = () => reject(new Error('Das Logo konnte nicht gelesen werden.'));
|
||||
img.src = url;
|
||||
}).finally(() => URL.revokeObjectURL(url));
|
||||
}
|
||||
|
||||
/** Dekodiert die PNG-Datei; null, wenn sie sich nicht lesen lässt oder keine Fläche hat. */
|
||||
async function decode(blob: Blob): Promise<Decoded | null> {
|
||||
try {
|
||||
let decoded: Decoded;
|
||||
if (typeof createImageBitmap === 'function') {
|
||||
const bitmap = await createImageBitmap(blob);
|
||||
decoded = { source: bitmap, w: bitmap.width, h: bitmap.height };
|
||||
} else {
|
||||
decoded = await decodeWithImage(blob);
|
||||
}
|
||||
return decoded.w > 0 && decoded.h > 0 ? decoded : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Ein Canvas in Bildgröße, in dem nur noch die Form des Bildes (sein Alphakanal) steht, gefüllt mit `color`. */
|
||||
function fillShape({ source, w, h }: Decoded, color: string): HTMLCanvasElement {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = w;
|
||||
canvas.height = h;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) throw new Error('Canvas wird in diesem Browser nicht unterstützt.');
|
||||
ctx.drawImage(source, 0, 0);
|
||||
// „source-in“: die Farbe bleibt nur dort, wo das Bild schon deckt.
|
||||
ctx.globalCompositeOperation = 'source-in';
|
||||
ctx.fillStyle = color;
|
||||
ctx.fillRect(0, 0, w, h);
|
||||
return canvas;
|
||||
}
|
||||
|
||||
async function build(id: string, tone: DetailsTone): Promise<LogoAsset | null> {
|
||||
const logo = await rowFor(id);
|
||||
// Ausgeblendete Logos können Hochlader und Admins noch lesen. Ein gesperrtes Logo darf aber nie in einer Karte stehen,
|
||||
// die sich veröffentlichen lässt: es gilt als nicht verfügbar.
|
||||
if (!logo || logo.hidden) return null;
|
||||
|
||||
let blob: Blob;
|
||||
try {
|
||||
blob = await loadLogoBlob(logo);
|
||||
} catch (err) {
|
||||
if (isFileMissing(err)) return null;
|
||||
throw err;
|
||||
}
|
||||
|
||||
const decoded = await decode(blob);
|
||||
if (!decoded || decoded.w * decoded.h > MAX_LOGO_PIXELS) return null;
|
||||
if (!logo.mono) return decoded;
|
||||
return { source: fillShape(decoded, TONE_COLOR[tone]), w: decoded.w, h: decoded.h };
|
||||
}
|
||||
|
||||
/**
|
||||
* Das Community-Logo `id` als Bild für den Renderer. Einfarbige Logos (`mono`) haben die Farbe der Details (Hell/Dunkel),
|
||||
* mehrfarbige bleiben unverändert. null: das Logo gibt es nicht (mehr), ist ausgeblendet, oder seine Datei ist unbrauchbar.
|
||||
* Wirft bei einem echten Ladefehler; der bleibt nicht im Zwischenspeicher, ein späterer Aufruf versucht es neu.
|
||||
*/
|
||||
export function loadLogoAsset(id: string, tone: DetailsTone): Promise<LogoAsset | null> {
|
||||
const key = `${id}:${tone}`;
|
||||
const cached = assets.get(key);
|
||||
if (cached) return cached;
|
||||
const request = build(id, tone);
|
||||
request.catch(() => {
|
||||
if (assets.get(key) === request) assets.delete(key);
|
||||
});
|
||||
assets.set(key, request);
|
||||
return request;
|
||||
}
|
||||
Reference in new issue
Block a user