mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
Issue: #426 Issue: #427 Issue: #428 Issue: #431 Issue: #432 Issue: #434 User-Visible: no
251 lines
9.4 KiB
TypeScript
251 lines
9.4 KiB
TypeScript
/**
|
|
* #39: the shared plan-file pick flow — classification, safe encoding and the
|
|
* reduced-copy path. One module feeds BOTH lazy runtimes (editor space dialog
|
|
* and the onboarding first-space dialog), so the guard cannot drift between
|
|
* them. Nothing here runs in the eager View graph.
|
|
*/
|
|
import { html, type TemplateResult } from 'lit';
|
|
import {
|
|
DOWNSCALE_JPEG_QUALITY, DOWNSCALE_TARGET_PX, DOWNSCALE_TIMEOUT_MS,
|
|
HARD_DIMENSION, downscaleDimensions, probeBackdrop, type BackdropProbe,
|
|
} from './backdrop-probe';
|
|
import type { I18nKey } from './i18n';
|
|
|
|
export interface PlanFilePayload {
|
|
ext: string;
|
|
b64: string;
|
|
aspect: number;
|
|
name: string;
|
|
}
|
|
|
|
export interface BackdropGuardState {
|
|
file: File;
|
|
ext: string;
|
|
probe: BackdropProbe;
|
|
busy: boolean;
|
|
}
|
|
|
|
export interface BackdropPickHost {
|
|
_t: (key: I18nKey, vars?: Record<string, string | number>) => string;
|
|
_showToast: (text: string) => void;
|
|
_backdropGuard: BackdropGuardState | null;
|
|
requestUpdate?: () => void;
|
|
}
|
|
|
|
const EXT_BY_MIME: Record<string, string> = {
|
|
'image/svg+xml': 'svg', 'image/png': 'png', 'image/jpeg': 'jpg', 'image/webp': 'webp',
|
|
};
|
|
|
|
export function planFileExt(file: File): string {
|
|
return EXT_BY_MIME[file.type] || (file.name.toLowerCase().endsWith('.svg') ? 'svg' : '');
|
|
}
|
|
|
|
/**
|
|
* Base64 via FileReader: the browser encodes natively instead of the old
|
|
* char-by-char string build, cutting the JS-heap peak roughly in half on
|
|
* every upload (spec §UX/safe). Output is byte-identical to btoa(binary).
|
|
*/
|
|
export function fileToBase64(blob: Blob): Promise<string> {
|
|
return new Promise((resolve, reject) => {
|
|
const reader = new FileReader();
|
|
reader.onerror = () => reject(reader.error || new Error('read failed'));
|
|
reader.onload = () => {
|
|
const url = String(reader.result || '');
|
|
const comma = url.indexOf(',');
|
|
if (comma < 0) { reject(new Error('unexpected data url')); return; }
|
|
resolve(url.slice(comma + 1));
|
|
};
|
|
reader.readAsDataURL(blob);
|
|
});
|
|
}
|
|
|
|
async function aspectOf(blob: Blob, fallback = 1.414): Promise<number> {
|
|
const url = URL.createObjectURL(blob);
|
|
try {
|
|
return await new Promise<number>((resolve) => {
|
|
const image = new Image();
|
|
image.onload = () => resolve(image.naturalWidth && image.naturalHeight
|
|
? image.naturalWidth / image.naturalHeight : fallback);
|
|
image.onerror = () => resolve(fallback);
|
|
image.src = url;
|
|
});
|
|
} finally {
|
|
URL.revokeObjectURL(url);
|
|
}
|
|
}
|
|
|
|
export async function encodePlanFile(blob: Blob, ext: string, name: string): Promise<PlanFilePayload> {
|
|
const [b64, aspect] = await Promise.all([fileToBase64(blob), aspectOf(blob)]);
|
|
return { ext, b64, aspect, name };
|
|
}
|
|
|
|
/**
|
|
* Classify one picked file. 'svg' and small rasters go straight through; a
|
|
* big or unreadable raster returns the guard state for the dialog. The probe
|
|
* reads header bytes only — no decode happens on this path (spec §Диагностика).
|
|
*/
|
|
export async function classifyPlanFile(file: File, guardAboveBytes = Infinity): Promise<
|
|
| { kind: 'reject' }
|
|
| { kind: 'pass'; ext: string }
|
|
| { kind: 'guard'; state: BackdropGuardState }
|
|
> {
|
|
const ext = planFileExt(file);
|
|
if (!ext) return { kind: 'reject' };
|
|
if (ext === 'svg') return { kind: 'pass', ext };
|
|
const bytes = new Uint8Array(await file.arrayBuffer());
|
|
const probe = probeBackdrop(bytes, ext);
|
|
if (probe.kind === 'safe' && file.size <= guardAboveBytes) return { kind: 'pass', ext };
|
|
return { kind: 'guard', state: { file, ext, probe, busy: false } };
|
|
}
|
|
|
|
/** Test-only escape hatch for the 10s decode timeout (spec r3, reviewer Low). */
|
|
const decodeTimeoutMs = (): number =>
|
|
(globalThis as { __HP_BACKDROP_TIMEOUT_MS?: number }).__HP_BACKDROP_TIMEOUT_MS
|
|
?? DOWNSCALE_TIMEOUT_MS;
|
|
|
|
/**
|
|
* Build the reduced copy: EXIF-aware decode, aspect-preserving resize, PNG for
|
|
* alpha, JPEG for opaque. Throws on decode failure or timeout — the caller
|
|
* owns the honest phase-2 UX (toast, clean staging, no silent fallback).
|
|
*/
|
|
export async function downscaleBackdrop(
|
|
state: BackdropGuardState,
|
|
): Promise<{ blob: Blob; ext: string; name: string }> {
|
|
const decode = createImageBitmap(state.file, { imageOrientation: 'from-image' });
|
|
const bitmap = await Promise.race([
|
|
decode,
|
|
new Promise<never>((_, reject) => {
|
|
setTimeout(() => reject(new Error('decode timeout')), decodeTimeoutMs());
|
|
}),
|
|
]);
|
|
try {
|
|
const { width, height } = downscaleDimensions(bitmap.width, bitmap.height, DOWNSCALE_TARGET_PX);
|
|
const alpha = state.probe.alpha;
|
|
const type = alpha ? 'image/png' : 'image/jpeg';
|
|
let blob: Blob;
|
|
if (typeof OffscreenCanvas === 'function') {
|
|
const canvas = new OffscreenCanvas(width, height);
|
|
const ctx = canvas.getContext('2d');
|
|
if (!ctx) throw new Error('no 2d context');
|
|
ctx.drawImage(bitmap, 0, 0, width, height);
|
|
blob = await canvas.convertToBlob({ type, quality: DOWNSCALE_JPEG_QUALITY });
|
|
} else {
|
|
const canvas = document.createElement('canvas');
|
|
canvas.width = width; canvas.height = height;
|
|
const ctx = canvas.getContext('2d');
|
|
if (!ctx) throw new Error('no 2d context');
|
|
ctx.drawImage(bitmap, 0, 0, width, height);
|
|
blob = await new Promise<Blob>((resolve, reject) => {
|
|
canvas.toBlob((out) => (out ? resolve(out) : reject(new Error('encode failed'))),
|
|
type, DOWNSCALE_JPEG_QUALITY);
|
|
});
|
|
}
|
|
const ext = alpha ? 'png' : 'jpg';
|
|
const base = state.file.name.replace(/\.[^.]+$/, '') || 'plan';
|
|
return { blob, ext, name: `${base}-reduced.${ext}` };
|
|
} finally {
|
|
bitmap.close();
|
|
}
|
|
}
|
|
|
|
const megabytes = (bytes: number): string => (bytes / 1048576).toFixed(bytes >= 10 * 1048576 ? 0 : 1);
|
|
|
|
/**
|
|
* The guard dialog, shared verbatim by both runtimes. `apply` receives the
|
|
* encoded payload of whichever copy the user chose; `close` clears the state.
|
|
*/
|
|
export function renderBackdropGuard(
|
|
host: BackdropPickHost,
|
|
apply: (payload: PlanFilePayload) => void,
|
|
close: () => void,
|
|
hass: unknown,
|
|
blobApply?: (blob: Blob, name: string) => Promise<void>,
|
|
allowOriginal = true,
|
|
): TemplateResult | null {
|
|
const guard = host._backdropGuard;
|
|
if (!guard) return null;
|
|
const { probe } = guard;
|
|
const hard = probe.kind === 'hard';
|
|
const reducedDimensions = probe.width && probe.height
|
|
? downscaleDimensions(probe.width, probe.height, DOWNSCALE_TARGET_PX)
|
|
: null;
|
|
const body = hard
|
|
? host._t('backdrop.too_large_body', {
|
|
w: probe.width ?? 0, h: probe.height ?? 0, limit: HARD_DIMENSION,
|
|
})
|
|
: probe.kind === 'unknown'
|
|
? host._t('backdrop.unknown_body')
|
|
: host._t('backdrop.large_body', {
|
|
w: probe.width ?? 0,
|
|
h: probe.height ?? 0,
|
|
fileMb: megabytes(guard.file.size),
|
|
decodedMb: megabytes(probe.decodedBytes ?? 0),
|
|
});
|
|
// r1-M1: while a decision is executing, dismissal must not race it — the
|
|
// dialog stays up (buttons are disabled), and even if the guard somehow
|
|
// vanished mid-flight, a stale flow must not apply its result silently.
|
|
const dismiss = (): void => {
|
|
if (host._backdropGuard?.busy) return;
|
|
close();
|
|
};
|
|
const stillCurrent = (): boolean => host._backdropGuard?.file === guard.file;
|
|
const original = async (): Promise<void> => {
|
|
if (host._backdropGuard?.busy) return;
|
|
if (blobApply) {
|
|
host._backdropGuard = { ...guard, busy: true };
|
|
host.requestUpdate?.();
|
|
await blobApply(guard.file, guard.file.name);
|
|
if (stillCurrent()) close();
|
|
return;
|
|
}
|
|
const payload = await encodePlanFile(guard.file, guard.ext, guard.file.name);
|
|
if (!stillCurrent()) return;
|
|
apply(payload);
|
|
close();
|
|
};
|
|
const reduced = async (): Promise<void> => {
|
|
if (host._backdropGuard?.busy) return;
|
|
host._backdropGuard = { ...guard, busy: true };
|
|
host.requestUpdate?.();
|
|
try {
|
|
const out = await downscaleBackdrop(guard);
|
|
if (blobApply) {
|
|
await blobApply(out.blob, out.name);
|
|
if (stillCurrent()) close();
|
|
return;
|
|
}
|
|
const payload = await encodePlanFile(out.blob, out.ext, out.name);
|
|
if (!stillCurrent()) return;
|
|
apply(payload);
|
|
close();
|
|
} catch {
|
|
// Honest phase 2 (spec §UX): no silent fallback to the original the
|
|
// user just declined — staging stays clean, the toast says what happened.
|
|
if (!stillCurrent()) return;
|
|
close();
|
|
host._showToast(host._t('backdrop.downscale_failed'));
|
|
}
|
|
};
|
|
return html`<hp-dialog .hass=${hass}
|
|
.title=${host._t(hard ? 'backdrop.too_large_title' : 'backdrop.large_title')}
|
|
icon="mdi:image-size-select-large" dismiss-on-scrim @hp-close=${() => dismiss()}>
|
|
<div class="body"><p>${body}</p>
|
|
${reducedDimensions ? html`<p>${host._t('backdrop.reduced_dimensions', {
|
|
w: reducedDimensions.width, h: reducedDimensions.height,
|
|
})}</p>` : null}
|
|
</div>
|
|
<div class="row" slot="footer">
|
|
<button class="btn ghost" ?disabled=${guard.busy} @click=${() => dismiss()}>
|
|
${host._t('btn.cancel')}</button>
|
|
<span class="spacer"></span>
|
|
${hard ? null : html`
|
|
${allowOriginal ? html`
|
|
<button class="btn ghost" ?disabled=${guard.busy} @click=${() => original()}>
|
|
${host._t('backdrop.keep_original')}</button>` : null}
|
|
<button class="btn on" ?disabled=${guard.busy} @click=${() => reduced()}>
|
|
${guard.busy ? host._t('backdrop.reducing') : host._t('backdrop.use_downscaled')}
|
|
</button>`}
|
|
</div>
|
|
</hp-dialog>`;
|
|
}
|