Files
dbx/apps/desktop/src/lib/app/appBackgroundImage.ts
T

177 lines
7.5 KiB
TypeScript

export const BACKGROUND_IMAGE_STORAGE_LIMIT_BYTES = 20 * 1024 * 1024;
export const BACKGROUND_IMAGE_MIN_OPACITY = 0.05;
export const BACKGROUND_IMAGE_MAX_OPACITY = 1;
// Surface opacity: how opaque the app surfaces render over the wallpaper.
// 1 = solid surfaces (no image visible), 0.05 = strongest wallpaper.
export const BACKGROUND_IMAGE_DEFAULT_OPACITY = 0.8;
export const BACKGROUND_IMAGE_MAX_BLUR_PX = 20;
export const BACKGROUND_IMAGE_DEFAULT_BLUR_PX = 0;
export const BACKGROUND_IMAGE_FILE_EXTENSIONS = ["png", "jpg", "jpeg", "webp", "bmp", "gif"] as const;
/** Classic desktop-wallpaper presets; default "fill" crops to cover the window. */
export const BACKGROUND_IMAGE_DISPLAY_MODES = ["fill", "fit", "stretch", "center", "tile"] as const;
export type BackgroundImageDisplayMode = (typeof BACKGROUND_IMAGE_DISPLAY_MODES)[number];
export const BACKGROUND_IMAGE_DEFAULT_DISPLAY_MODE: BackgroundImageDisplayMode = "fill";
export type BackgroundImageSettings = {
filePath: string | null;
fileName: string | null;
opacity: number;
blur: number;
displayMode: BackgroundImageDisplayMode;
};
export function defaultBackgroundImageSettings(): BackgroundImageSettings {
return {
filePath: null,
fileName: null,
opacity: BACKGROUND_IMAGE_DEFAULT_OPACITY,
blur: BACKGROUND_IMAGE_DEFAULT_BLUR_PX,
displayMode: BACKGROUND_IMAGE_DEFAULT_DISPLAY_MODE,
};
}
function clampNumber(value: unknown, min: number, max: number, fallback: number): number {
return typeof value === "number" && Number.isFinite(value) ? Math.min(max, Math.max(min, value)) : fallback;
}
function normalizeOptionalText(value: unknown): string | null {
if (typeof value !== "string") return null;
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
}
export function normalizeBackgroundImageDisplayMode(value: unknown): BackgroundImageDisplayMode {
return (BACKGROUND_IMAGE_DISPLAY_MODES as readonly string[]).includes(value as string) ? (value as BackgroundImageDisplayMode) : BACKGROUND_IMAGE_DEFAULT_DISPLAY_MODE;
}
export function normalizeBackgroundImageSettings(value: unknown): BackgroundImageSettings {
const source = (value && typeof value === "object" ? value : {}) as Record<string, unknown>;
return {
filePath: normalizeOptionalText(source.filePath),
fileName: normalizeOptionalText(source.fileName),
opacity: clampNumber(source.opacity, BACKGROUND_IMAGE_MIN_OPACITY, BACKGROUND_IMAGE_MAX_OPACITY, BACKGROUND_IMAGE_DEFAULT_OPACITY),
blur: clampNumber(source.blur, 0, BACKGROUND_IMAGE_MAX_BLUR_PX, BACKGROUND_IMAGE_DEFAULT_BLUR_PX),
displayMode: normalizeBackgroundImageDisplayMode(source.displayMode),
};
}
/** Extension (lowercase, no dot) for dialog filters and stored-copy naming; null when the name has none. */
export function backgroundImageFileExtension(fileName: string): string | null {
const match = /\.([a-z0-9]+)$/i.exec(fileName.trim());
if (!match) return null;
const extension = match[1].toLowerCase();
return (BACKGROUND_IMAGE_FILE_EXTENSIONS as readonly string[]).includes(extension) ? extension : null;
}
/**
* Paint style for the wallpaper layer. Modes follow the classic desktop
* wallpaper presets: fill/fit/stretch scale the image, center paints it at
* its natural size, tile repeats it. Blur always scales the layer slightly
* so blurred edges never reveal transparent margins.
*/
export function backgroundImageStyle(settings: Pick<BackgroundImageSettings, "displayMode" | "blur">, objectUrl: string | null): Record<string, string> {
if (!objectUrl) return {};
const style: Record<string, string> = { backgroundImage: `url("${objectUrl}")` };
switch (settings.displayMode) {
case "fit":
style.backgroundSize = "contain";
style.backgroundPosition = "center";
style.backgroundRepeat = "no-repeat";
break;
case "stretch":
style.backgroundSize = "100% 100%";
style.backgroundRepeat = "no-repeat";
break;
case "center":
style.backgroundPosition = "center";
style.backgroundRepeat = "no-repeat";
break;
case "tile":
style.backgroundRepeat = "repeat";
break;
case "fill":
default:
style.backgroundSize = "cover";
style.backgroundPosition = "center";
style.backgroundRepeat = "no-repeat";
break;
}
if (settings.blur > 0) {
style.filter = `blur(${settings.blur}px)`;
style.transform = "scale(1.06)";
}
return style;
}
/**
* Root surface variables that become translucent while a wallpaper is active.
* Card/popover/dialog surfaces stay opaque on purpose: floating panels keep
* their text readable regardless of what the wallpaper shows behind them.
*/
export const BACKGROUND_IMAGE_SURFACE_VARS = ["--background", "--sidebar", "--muted", "--secondary", "--accent", "--dbx-chrome", "--dbx-chrome-muted", "--dbx-content", "--dbx-editor-toolbar", "--dbx-gutter", "--dbx-sidebar-header"] as const;
/** Alpha for the surface variables derived from the surface-opacity setting. */
export function backgroundImageSurfaceAlpha(settings: Pick<BackgroundImageSettings, "opacity">): number {
return clampNumber(settings.opacity, BACKGROUND_IMAGE_MIN_OPACITY, BACKGROUND_IMAGE_MAX_OPACITY, BACKGROUND_IMAGE_DEFAULT_OPACITY);
}
/** Parses "#rgb", "#rrggbb", "rgb(r g b)", "rgb(r, g, b)" and "rgb(r g b / a)" into an rgb triplet; null when unparsable. */
export function parseCssColorTriplet(value: string): [number, number, number] | null {
const trimmed = value.trim();
if (!trimmed) return null;
const rgbMatch = /^rgba?\((\d+)\s*,?\s+(\d+)\s*,?\s+(\d+)(?:\s*\/\s*[\d.]+)?\)$/i.exec(trimmed);
if (rgbMatch) return [Number(rgbMatch[1]), Number(rgbMatch[2]), Number(rgbMatch[3])];
const hex = trimmed.startsWith("#") ? trimmed.slice(1) : null;
if (hex && (hex.length === 3 || hex.length === 6)) {
const expand =
hex.length === 3
? hex
.split("")
.map((char) => char + char)
.join("")
: hex;
return [parseInt(expand.slice(0, 2), 16), parseInt(expand.slice(2, 4), 16), parseInt(expand.slice(4, 6), 16)];
}
return null;
}
/**
* Re-emit a surface color with the given alpha, e.g. "rgb(17 18 20 / 0.8)".
* Keeps the source notation when possible: wide-gamut palettes declare their
* tokens in oklch (see the @supports block in globals.css) and converting them
* to rgb would clip them; hsl stays hsl, oklch stays oklch, hex/rgb become rgb.
* Also accepts raw "H S% L%" channel triplets as used by shadcn-style themes.
*/
export function surfaceColorWithAlpha(color: string, alpha: number): string | null {
const trimmed = color.trim();
if (!trimmed) return null;
const fnMatch = /^([a-zA-Z-]+)\(([^()]+)\)$/.exec(trimmed);
if (fnMatch) {
const fn = fnMatch[1].toLowerCase();
const inner = fnMatch[2].trim();
if (fn === "rgb" || fn === "rgba") {
const triplet = parseCssColorTriplet(trimmed);
return triplet ? `rgb(${triplet[0]} ${triplet[1]} ${triplet[2]} / ${alpha})` : null;
}
if (inner.includes(",")) {
const parts = inner.split(",").map((part) => part.trim());
if (parts.length !== 3 || parts.some((part) => !part)) return null;
return `${fn}(${parts[0]}, ${parts[1]}, ${parts[2]}, ${alpha})`;
}
const base = inner.split("/")[0].trim();
if (!base) return null;
return `${fn}(${base} / ${alpha})`;
}
if (trimmed.startsWith("#")) {
const triplet = parseCssColorTriplet(trimmed);
return triplet ? `rgb(${triplet[0]} ${triplet[1]} ${triplet[2]} / ${alpha})` : null;
}
if (/^[\d.]+%?\s+[\d.]+%?\s+[\d.]+%?$/.test(trimmed)) {
return `hsl(${trimmed} / ${alpha})`;
}
return null;
}