Logos (PNG, JPEG, WebP) are decoded, trimmed to their ink box, downscaled to 1200 px and re-encoded as a content-addressed print PNG plus a white knockout variant when the logo has alpha; aspect, ink density, mean colour and kind (wordmark, mark, tall) are stored with the settings and frozen in the issue snapshot. Legacy logos are derived at startup. The Test Vendor logo PDF shrinks from 334 KB to 92 KB. Settings gains a Branding section with white and dark previews and a business-name toggle (migration M5).
166 lines
6.0 KiB
TypeScript
166 lines
6.0 KiB
TypeScript
import { useEffect, useState } from "react";
|
|
import { Button, InlineLoading, InlineNotification, RadioButton, RadioButtonGroup } from "@carbon/react";
|
|
import { TrashCan, Upload } from "@carbon/icons-react";
|
|
import { open } from "@tauri-apps/plugin-dialog";
|
|
import { api } from "../lib/api";
|
|
import {
|
|
loadLogoImages,
|
|
logoFacts,
|
|
logoIncludesName,
|
|
parseLogoMeta,
|
|
type LogoAsset,
|
|
type LogoImages,
|
|
type LogoIncludesName,
|
|
type LogoSettings,
|
|
} from "../lib/logo";
|
|
import type { Settings } from "../lib/types";
|
|
|
|
type LogoPatch = Pick<Settings, "logoPath" | "logoOriginalPath" | "logoKnockoutPath" | "logoMeta" | "logoIncludesName">;
|
|
|
|
interface LogoBrandingProps {
|
|
/** Id prefix, so two instances on a page never share control ids. */
|
|
idPrefix: string;
|
|
value: LogoSettings;
|
|
onChange: (patch: Partial<LogoPatch>) => void;
|
|
disabled?: boolean;
|
|
}
|
|
|
|
/**
|
|
* Logo upload with the pipeline's result: the trimmed logo on a white and on a dark surface, what
|
|
* was measured, and whether the logo already contains the business name.
|
|
*/
|
|
export function LogoBranding({ idPrefix, value, onChange, disabled }: LogoBrandingProps) {
|
|
const [images, setImages] = useState<LogoImages | null>(null);
|
|
const [busy, setBusy] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
useEffect(() => {
|
|
let active = true;
|
|
if (!value.logoPath) {
|
|
setImages(null);
|
|
return;
|
|
}
|
|
loadLogoImages(value, api.readAssetDataUri)
|
|
.then((loaded) => active && setImages(loaded))
|
|
.catch(() => active && setImages(null));
|
|
return () => {
|
|
active = false;
|
|
};
|
|
}, [value.logoPath, value.logoKnockoutPath]);
|
|
|
|
const pick = async () => {
|
|
setError(null);
|
|
try {
|
|
const selected = await open({
|
|
multiple: false,
|
|
directory: false,
|
|
filters: [{ name: "Images", extensions: ["png", "jpg", "jpeg", "webp"] }],
|
|
});
|
|
if (typeof selected !== "string") return;
|
|
setBusy(true);
|
|
const asset: LogoAsset = await api.importLogo(selected);
|
|
onChange({
|
|
logoPath: asset.printPath,
|
|
logoOriginalPath: asset.originalPath,
|
|
logoKnockoutPath: asset.knockoutPath,
|
|
logoMeta: JSON.stringify(asset.metrics),
|
|
});
|
|
} catch (e) {
|
|
setError(String(e));
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
};
|
|
|
|
const remove = async () => {
|
|
setError(null);
|
|
try {
|
|
await api.removeLogo();
|
|
onChange({ logoPath: null, logoOriginalPath: null, logoKnockoutPath: null, logoMeta: "", logoIncludesName: "auto" });
|
|
} catch (e) {
|
|
setError(String(e));
|
|
}
|
|
};
|
|
|
|
const meta = parseLogoMeta(value.logoMeta);
|
|
const automaticYes = logoIncludesName("auto", meta?.kind ?? null);
|
|
|
|
return (
|
|
<div className="voiced-stack">
|
|
<div className="voiced-muted">Vendor logo (PNG, JPEG or WebP). A transparent PNG works best.</div>
|
|
|
|
{value.logoPath ? (
|
|
<div className="voiced-logo-preview">
|
|
<figure className="voiced-logo-preview__item">
|
|
<div className="voiced-logo-preview__surface voiced-logo-preview__surface--light">
|
|
{images ? <img src={images.printUri} alt="Logo on a white background" /> : <InlineLoading />}
|
|
</div>
|
|
<figcaption className="voiced-muted">On white</figcaption>
|
|
</figure>
|
|
<figure className="voiced-logo-preview__item">
|
|
<div className="voiced-logo-preview__surface voiced-logo-preview__surface--dark">
|
|
{images ? (
|
|
images.knockoutUri ? (
|
|
<img src={images.knockoutUri} alt="White logo on a dark background" />
|
|
) : (
|
|
<span className="voiced-logo-preview__chip">
|
|
<img src={images.printUri} alt="Logo on a white chip over a dark background" />
|
|
</span>
|
|
)
|
|
) : (
|
|
<InlineLoading />
|
|
)}
|
|
</div>
|
|
<figcaption className="voiced-muted">
|
|
{images && !images.knockoutUri ? "On dark (white chip)" : "On dark"}
|
|
</figcaption>
|
|
</figure>
|
|
</div>
|
|
) : (
|
|
<div className="voiced-muted">{busy ? <InlineLoading description="Importing" /> : "No logo selected"}</div>
|
|
)}
|
|
|
|
{meta ? <div className="voiced-muted">{logoFacts(meta)}</div> : null}
|
|
|
|
{meta?.opaqueBackground ? (
|
|
<InlineNotification
|
|
kind="warning"
|
|
lowContrast
|
|
hideCloseButton
|
|
title="Solid background"
|
|
subtitle="This logo has a solid background. A transparent PNG gives better results on coloured templates."
|
|
/>
|
|
) : null}
|
|
|
|
<div className="voiced-header-action">
|
|
<Button kind="tertiary" size="sm" renderIcon={Upload} onClick={pick} disabled={disabled || busy}>
|
|
{value.logoPath ? "Replace logo" : "Choose image"}
|
|
</Button>
|
|
{value.logoPath ? (
|
|
<Button kind="ghost" size="sm" renderIcon={TrashCan} onClick={remove} disabled={disabled || busy}>
|
|
Remove
|
|
</Button>
|
|
) : null}
|
|
{busy && value.logoPath ? <InlineLoading description="Importing" /> : null}
|
|
</div>
|
|
|
|
{error ? <InlineNotification kind="error" lowContrast hideCloseButton title="Could not use that logo" subtitle={error} /> : null}
|
|
|
|
{value.logoPath ? (
|
|
<RadioButtonGroup
|
|
legendText="Logo already includes the business name"
|
|
name={`${idPrefix}-logoIncludesName`}
|
|
orientation="horizontal"
|
|
valueSelected={value.logoIncludesName}
|
|
helperText={`Automatic treats ${automaticYes ? "this logo as including the name" : "this logo as a mark without the name"}.`}
|
|
onChange={(next) => onChange({ logoIncludesName: next as LogoIncludesName })}
|
|
>
|
|
<RadioButton id={`${idPrefix}-lin-auto`} labelText="Automatic" value="auto" />
|
|
<RadioButton id={`${idPrefix}-lin-yes`} labelText="Yes" value="yes" />
|
|
<RadioButton id={`${idPrefix}-lin-no`} labelText="No" value="no" />
|
|
</RadioButtonGroup>
|
|
) : null}
|
|
</div>
|
|
);
|
|
}
|