import * as React from 'react';
import type { TranslationBundle } from '@jupyterlab/translation';
/**
* xtralab's image diff, registered in place of `@jupyterlab/git:image-diff`.
*
* The git server base64-encodes binary content, so both diff hosts receive
* the two sides as base64 strings through the same `content()` getters used
* for text. This module turns those strings into `` elements and
* offers the 2-up / swipe / onion-skin comparison modes; no diffing library
* is involved (`@pierre/diffs` is text-only).
*/
/**
* Raster image extensions xtralab renders as an image diff, mapped to the
* MIME subtype used in the `data:image/;base64,…` URL.
*
* `.svg` is intentionally absent: it is XML text, so the `@pierre/diffs`
* text diff (the fallback provider) is more useful for it than an image
* comparison.
*/
const IMAGE_DATA_TYPES: Record = {
'.png': 'png',
'.jpg': 'jpeg',
'.jpeg': 'jpeg',
'.gif': 'gif',
'.webp': 'webp',
'.bmp': 'bmp',
'.ico': 'x-icon'
};
/**
* The file extensions registered as the xtralab image diff provider.
*/
export const IMAGE_DIFF_EXTENSIONS = Object.keys(IMAGE_DATA_TYPES);
/**
* The `data:` MIME subtype for a path xtralab renders as an image, or
* `null` when the path is not a supported raster image.
*/
export function imageDataType(path: string): string | null {
const lower = path.toLowerCase();
const dot = lower.lastIndexOf('.');
if (dot < 0) {
return null;
}
return IMAGE_DATA_TYPES[lower.slice(dot)] ?? null;
}
/**
* Comparison layouts, matching jupyterlab-git's image diff modes.
*/
type ImageDiffViewMode = '2-up' | 'swipe' | 'onion';
const IMAGE_DIFF_VIEW_MODE_STORAGE_KEY = 'xtralab:image-diff-view-mode';
function readStoredImageViewMode(): ImageDiffViewMode {
try {
const raw = window.localStorage.getItem(IMAGE_DIFF_VIEW_MODE_STORAGE_KEY);
if (raw === '2-up' || raw === 'swipe' || raw === 'onion') {
return raw;
}
} catch {
// localStorage can throw in privacy/sandboxed contexts — 2-up is the
// sensible default anyway.
}
return '2-up';
}
function writeStoredImageViewMode(mode: ImageDiffViewMode): void {
try {
window.localStorage.setItem(IMAGE_DIFF_VIEW_MODE_STORAGE_KEY, mode);
} catch {
// Best-effort.
}
}
/**
* Strip whitespace from a base64 payload. Python's `base64.encodebytes`
* (used by the git server) inserts a newline every 76 characters; most
* browsers tolerate that inside a `data:` URL but some are stricter, and
* the cleaned length also lets us size the file accurately.
*/
function cleanBase64(value: string): string {
return value.replace(/\s+/g, '');
}
/**
* Decoded byte length of a (whitespace-free) base64 string.
*/
function base64ByteLength(clean: string): number {
if (clean.length === 0) {
return 0;
}
const padding = clean.endsWith('==') ? 2 : clean.endsWith('=') ? 1 : 0;
return Math.floor((clean.length * 3) / 4) - padding;
}
function formatBytes(bytes: number): string {
if (bytes < 1024) {
return `${bytes} B`;
}
const units = ['KB', 'MB', 'GB'];
let value = bytes / 1024;
let unit = 0;
while (value >= 1024 && unit < units.length - 1) {
value /= 1024;
unit += 1;
}
return `${value.toFixed(value < 10 ? 1 : 0)} ${units[unit]}`;
}
interface IImageSide {
/**
* Cleaned base64 payload; empty string means the side does not exist.
*/
data: string;
present: boolean;
uri: string | null;
bytes: number;
}
function toSide(raw: string, fileType: string): IImageSide {
const data = cleanBase64(raw);
const present = data.length > 0;
return {
data,
present,
uri: present ? `data:image/${fileType};base64,${data}` : null,
bytes: base64ByteLength(data)
};
}
interface IImageDiffViewProps {
/**
* Base64 of the reference (old) revision; empty when added.
*/
reference: string;
/**
* Base64 of the challenger (new) revision; empty when deleted.
*/
challenger: string;
/**
* MIME subtype from {@link imageDataType}.
*/
fileType: string;
/**
* Translation bundle for user-facing strings.
*/
trans: TranslationBundle;
}
/**
* The image comparison surface: a segmented mode selector (reusing the
* diff segmented-control styling so it matches the Notebook/JSON toggle)
* plus the selected 2-up / swipe / onion-skin view.
*/
export function ImageDiffView(props: IImageDiffViewProps): React.ReactElement {
const { reference, challenger, fileType, trans } = props;
const ref = React.useMemo(
() => toSide(reference, fileType),
[reference, fileType]
);
const chall = React.useMemo(
() => toSide(challenger, fileType),
[challenger, fileType]
);
const [mode, setMode] = React.useState(() =>
readStoredImageViewMode()
);
const changeMode = React.useCallback((next: ImageDiffViewMode) => {
setMode(next);
writeStoredImageViewMode(next);
}, []);
return (