Files
readest/apps/readest-app/src/styles/textures.ts
T
Huang Xin de6529523f feat(sync): cross-device background texture sync (#4079)
Plug the texture replica adapter into the kind-agnostic primitives
shipped in #4077. Textures imported on one device download and
become available on every signed-in device, with the same shape as
the font sync stack (single-file binary, contentId from
partialMD5+size+filename, bundleDir layout, replica-publish on
import, full activation on auto-download).

Includes legacy flat-path migration so pre-existing textures sync
without re-import, ColorPanel import flow now publishes the row
and queues the binary upload, and createCustomTexture preserves
contentId/bundleDir/byteSize through addTexture (mirrors the
font-import fix). Server allowlist gains 'texture' with a
single-image Zod schema; useBackgroundTexture passes replica
metadata through addTexture so the boot-time "ensure selected
texture is in store" path doesn't silently un-publish a remote
record.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 09:55:32 +02:00

137 lines
4.5 KiB
TypeScript

import { getFilename } from '@/utils/path';
import { md5Fingerprint } from '@/utils/md5';
export interface BackgroundTexture {
id: string;
name: string;
path?: string;
url?: string;
animated?: boolean;
/**
* Cross-device content hash. Set on imports new enough to participate
* in replica sync (`partialMD5 + byteSize + filename`). Legacy textures
* (created before replica sync) leave this undefined and never publish
* — re-import to enable cloud sync.
*/
contentId?: string;
/**
* Per-texture directory name relative to the `Images` base. New imports
* land at `<bundleDir>/<filename>`; legacy imports keep their flat
* `<filename>` path with bundleDir undefined.
*/
bundleDir?: string;
/** File size in bytes — used by the replica manifest, optional for legacy. */
byteSize?: number;
/**
* On a remote-pulled placeholder, set to true until the binary download
* lands. The transfer-complete handler clears it via the texture store's
* markAvailable hook.
*/
unavailable?: boolean;
/**
* Reincarnation token — opaque value that revives a tombstoned remote
* row. Mirrors the font / dictionary mechanism.
*/
reincarnation?: string;
downloadedAt?: number;
deletedAt?: number;
blobUrl?: string;
loaded?: boolean;
error?: string;
}
export type CustomTexture = BackgroundTexture & { path: string };
export type CustomTextureInfo = Partial<BackgroundTexture> &
Required<Pick<BackgroundTexture, 'path' | 'name'>>;
export const PREDEFINED_TEXTURES: BackgroundTexture[] = [
{ id: 'none', name: 'None', url: '', loaded: true },
{ id: 'concrete', name: 'Concrete', url: '/images/concrete-texture.png', loaded: true },
{ id: 'paper', name: 'Paper', url: '/images/paper-texture.png', loaded: true },
{ id: 'sand', name: 'Sand', url: '/images/sand-texture.jpg', loaded: true },
{ id: 'parchment', name: 'Parchment', url: '/images/parchment-paper.jpg', loaded: true },
{ id: 'scrapbook', name: 'Scrapbook', url: '/images/scrapbook-texture.jpg', loaded: true },
{ id: 'leaves', name: 'Leaves', url: '/images/leaves-pattern.jpg', loaded: true },
{ id: 'moon', name: 'Moon Sky', url: '/images/moon-sky.jpg', loaded: true },
{ id: 'night-sky', name: 'Night Sky', url: '/images/night-sky.jpg', loaded: true },
];
export function getTextureName(path: string): string {
const fileName = getFilename(path);
return fileName.replace(/\.(jpg|jpeg|png|gif|bmp|webp|mp4)$/i, '');
}
export function getTextureId(name: string): string {
return md5Fingerprint(name);
}
export function createCustomTexture(
path: string,
options?: Partial<Omit<CustomTexture, 'id' | 'path'>>,
): CustomTexture {
const name = options?.name || getTextureName(path);
// Spread options first so replica-sync fields (contentId, bundleDir,
// byteSize) flow through from the import path. Mirrors the
// createCustomFont fix — without the spread, addTexture silently
// drops those fields and publishFontUpsert / replica binary upload
// both no-op on missing contentId.
return {
...options,
id: getTextureId(name),
name,
path,
};
}
const createTextureCSS = (texture: BackgroundTexture) => {
const css = `
.sidebar-container, .notebook-container, .foliate-viewer {
position: relative;
}
body::before, .sidebar-container::before, .notebook-container::before,
.foliate-viewer::before {
content: "";
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
pointer-events: none;
z-index: 0;
background-image: url("${texture.blobUrl || texture.url}");
background-repeat: repeat;
background-size: var(--bg-texture-size, cover);
mix-blend-mode: var(--bg-texture-blend-mode, multiply);
opacity: var(--bg-texture-opacity, 0.6);
}
body::before {
height: 100vh;
}
`;
return css;
};
const textureStyleId = 'background-texture';
export const mountBackgroundTexture = (document: Document, texture: BackgroundTexture) => {
const styleElement = document.getElementById(textureStyleId) || document.createElement('style');
styleElement.id = textureStyleId;
styleElement.textContent = createTextureCSS(texture);
if (!styleElement.parentNode) {
document.head.appendChild(styleElement);
}
};
export const unmountBackgroundTexture = (document: Document) => {
const styleElement = document.getElementById(textureStyleId);
if (styleElement && styleElement.parentNode) {
styleElement.parentNode.removeChild(styleElement);
}
};