TTyko

Пакети / Зображення

@tykonpm/image-browser

v0.3.1

Стискання й ресайз фото прямо в браузері через canvas, масова генерація з прогресом.

$ npm i @tykonpm/image-browser

Read this before using the packages. Every package README (shipped in node_modules/@tykonpm/*/README.md) repeats its own part.

What it is

Small, framework-light toolkit for **responsive image presets**: decide which preview widths to make, generate them in the browser, render <img srcset>, and pick images from a folder library. **Storage is not included** — you plug your own via adapters.

PackagePurposeRuns in
@tykonpm/image-corePure rules: widths, variant choice, srcset, paths, adapter types, folder tree, in-memory libraryanywhere (browser, SSR, Workers, Node)
@tykonpm/image-react<TykoImage>, <ImageLibraryDialog>React ≥18, client
@tykonpm/image-browserResize files into variants via canvasbrowser only

Principles (hard guarantees)

  • **Never crops.** TykoImage and library thumbnails default to object-fit: contain. fit="cover" is opt-in only.
  • **Never upscales.** Widths larger than the original are skipped; small originals are served as-is.
  • **No backend dependency.** No Supabase/S3 SDK; no network calls of its own.
  • **No bundled images.**
  • Default preset widths: 160, 320, 800, 1600 (override per call). Originals under 320px are flagged "small" (isSmallOriginal).

Data model

ts
ImageEntry   { src: string; originalWidth?: number; variants: { width: number; url: string }[] }
ImageManifest  Record<src, ImageEntry>
MediaFolder  { id; name; parentId: string | null }
MediaFile    { id; name; url; folderId: string | null; width?; height? }

Adapters you implement

ts
interface ImageStorageAdapter {        // variants for TykoImage
  upload(path, data: Blob, contentType): Promise<string>;   // returns public URL
  saveVariants(src, entry: ImageEntry): Promise<void>;
  loadVariants(srcs: string[]): Promise<ImageManifest>;
  remove?(urls: string[]): Promise<void>;  // optional, for replaceVariants
}
interface MediaLibraryAdapter {        // ImageLibraryDialog
  listFolders(): Promise<MediaFolder[]>;
  listFiles(folderId | null): Promise<MediaFile[]>;          // null = ALL files
  upload(file: Blob, name, folderId | null): Promise<MediaFile>;
  addUrl?(url, name, folderId | null): Promise<MediaFile>;   // optional
}

Typical managed-backend implementation: a public storage bucket + tables media_folders(id, name, parent_id) and media_files(id, name, url, folder_id, width, height). Variant path convention: variantPath(src, w) → /thumbs/{w}/{name}.webp.

Typical flow

1. User picks/uploads in <ImageLibraryDialog prepare={...}>. 2. resizeToVariants(file) → WebP blobs at planned widths. 3. Upload each via your adapter, save ImageEntry. 4. Render with <TykoImage entry={entry} width={displayPx} alt="…" />.

API cheat sheet

  • core: planWidths(orig, widths?), pickVariant(entry, displayW, dpr?), buildSrcSet(entry), variantPath(src, w, base?, ext?), isSmallOriginal(w), folderTree(folders), createMemoryLibrary(folders?, files?), publishVariants(storage, src, { blob, width }, variants, opts?), createMemoryStorage(), DEFAULT_WIDTHS, SMALL_ORIGINAL_WIDTH; types Variant, ImageEntry, ImageManifest, VariantBlob, PublishOptions. Also: normalizePreset({ widths?, quality?, type?, auto? }) → { config, issues } (invalid values → safe defaults, DEFAULT_PRESET); missingWidths(entry, widths?); mergeVariants(entry, add); runBatch(items, job, { concurrency?, signal?, onProgress?, id? }) → { ok, failed, skipped, cancelled }; describeImage(entry, { displayWidth, dpr }?, widths?).
  • react: <TykoImage entry? src? width priority? placeholder? fit? sizes?> — no entry yet → src original or placeholder; broken variant → original; priority = eager + fetchpriority high; useImageManifest(loader, srcs, { bypassCache? }) → { manifest, loading, error }; <TykoImage entry width fit? sizes? …img props> (lazy + async decoding by default); <ImageLibraryDialog open onClose onSelect adapter prepare? labels? className? narrowBelow?=640> (title? is deprecated → labels.title). <ImageInfo entry displayWidth? dpr? widths? decoded? labels?> (optional info panel, IMAGE_INFO_LABELS_UK/EN); <Lightbox items={[{ entry?, src?, alt, caption? }]} index open onClose onIndexChange labels? maxZoom?=5> — fullscreen viewer: wheel/trackpad/pinch zoom anchored at the pointer, double-tap 2.5×, drag pan, swipe prev/next, swipe down closes, keys ←/→/Esc/+/−/0, Fullscreen API button, focus trap/restore, LIGHTBOX_LABELS_UK/EN; best variant at 1×, original when zoomed; never crops.
  • browser: resizeToVariants(file, { widths?, type?='image/webp', quality?=0.82 }) → {width,height,blob}[]; fitWidth(size, w); readImageSize(file); resizeOne(file, width, opts?) (one size); probeImage(url) → real decoded size or null (= unknown).

Full preset cycle

resizeToVariants(file, { widths: [160,320,800,1600] }) (image-browser) → publishVariants(storage, src, { blob: file, width }, variants) (image-core) uploads original + all variants, then calls storage.saveVariants; nothing is saved if any upload fails. Render with <TykoImage entry width> — it picks the best variant for the display width × DPR. For demos/tests swap your storage for createMemoryStorage() (object URLs, data lost on reload); the rest of the code is unchanged.

ImageLibraryDialog behaviour

Loading / error states with retry; stale list responses ignored; repeated upload blocked while busy; below narrowBelow (640px dialog width) it becomes full-screen with one column (folders → files, back button); focus is trapped, Escape closes, focus returns to the opener. All texts via labels (LIBRARY_LABELS_UK default, LIBRARY_LABELS_EN exported).

Shared manifest cache (opt-in)

ts
const loader = createManifestLoader(storage, { maxEntries: 500, missingTtlMs: 60_000 });
await loader.load(["/a.jpg", "/b.jpg"]);              // one loadVariants call for all uncached srcs
await loader.load(["/a.jpg"], { bypassCache: true });  // force fresh read (check presets)
loader.set(src, entry); loader.invalidate(src);
// React: const { manifest, loading, error } = useImageManifest(loader, visibleSrcs);

LRU eviction; concurrent loads of the same src share one request; "not found" remembered only for missingTtlMs; failed requests are not cached. Create ONE loader per app (module scope) and pass only visible/needed srcs.

Safe regeneration (opt-in)

replaceVariants(storage, src, { url, width }, variants, { previous, version? }) → { entry, leftovers }. Uploads new variants under versioned names (/thumbs/{w}/{name}.{version}.webp), saves the entry, and ONLY THEN removes previous variants. If an upload or save fails, newly uploaded files are removed and the old entry stays. The original is never uploaded or changed. Needs optional storage.remove(urls); without it old URLs are returned in leftovers. publishVariants is unchanged.

Point / bulk generation (opt-in)

ts
const need = missingWidths(entry);                         // "create missing copies"
const report = await runBatch(selected, async (photo, signal) => {
  const v = await resizeOne(photo.file, 320);              // or resizeToVariants(file, { widths: need })
  const url = await storage.upload(variantPath(photo.src, v.width), v.blob, "image/webp");
  await storage.saveVariants(photo.src, mergeVariants(photo.entry, [{ width: v.width, url }]));
}, { signal: abort.signal, onProgress: setProgress, id: (p) => p.src });
// report.failed → show errors; report.skipped → cancelled ones

One failed photo never stops the batch. Unreachable files are "unknown" (probeImage → null), not "missing".

Limitations / gotchas

  • image-browser needs createImageBitmap + OffscreenCanvas (modern Chrome/Edge/Firefox, Safari ≥16.4). **Do not call during SSR** — only in event handlers/effects.
  • Resizing does not read EXIF orientation explicitly; modern browsers apply it in createImageBitmap. HEIC is not supported by browsers.
  • EXIF/metadata is stripped by re-encoding (good for privacy, loses camera data).
  • No server-side resizing; no image-cli yet (planned).
  • ImageLibraryDialog: one level of folder filtering (selecting a parent does NOT include child folders' files); no search, rename, delete, or pagination — load lists in your adapter; no multi-select (picks one file). Labels default to Ukrainian — pass labels for other languages.
  • Dialog styling is inline; theme with CSS vars --tyko-bg, --tyko-fg, --tyko-muted, --tyko-border. It does not use Tailwind/shadcn.
  • createMemoryLibrary is for demos/tests only — data disappears on reload.
  • TykoImage needs width (CSS px) to choose fallback src and default sizes; give accurate sizes for fluid layouts.
  • Versions < 1.0: minor releases may change APIs; check CHANGELOG.md in each package.

Install / update in an AI platform

Chat: "Install @tykonpm/image-core, @tykonpm/image-react and @tykonpm/image-browser from npm and follow docs/AI-GUIDE.md from github.com/tykonpm/tyko-packages." Update: "Update @tykonpm/* to latest."