7185dca1a2
* feat(reader): add save/share button to image gallery toolbar Add a button to the top-right toolbar of the fullscreen image viewer that saves the currently viewed image to the device. It uses the native or web Share flow where available (iOS/Android/macOS, navigator.share) and falls back to a save dialog or browser download otherwise, reusing the existing export path via appService.saveFile. The button icon and label reflect the active flow (share vs save). Adds dataUrlToBytes/imageExtensionFromMime helpers, unit and component tests, and translations for the new strings across all locales. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(share): write shareable file to a Temp subdirectory to avoid 0-byte share On Android, Tauri's Temp dir is the app cache dir, and the sharekit plugin copies the shared file to <cacheDir>/<name> before firing the share intent. When saveFile wrote the shareable file to the Temp root, that copy became a copy onto itself whose output stream truncated the source to 0 bytes, so the shared image (and any shared export) arrived as a 0 KB file. Write the file to a Temp subdirectory instead so the plugin's copy has a distinct source. Verified on a Xiaomi device: sharing a file in the Temp root truncated it to 0 bytes, while sharing from the subdirectory produced a real, non-empty copy. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(reader): save image to system gallery on Android The Android share sheet cannot save an image to a file (no file manager registers as an ACTION_SEND target), so the Save Image button now writes the image straight into the system photo gallery via MediaStore. It lands in Pictures/Readest, visible in Gallery and the Files app, with no picker and no storage permission on Android 10+. Adds a save_image_to_gallery command to the native-bridge plugin (Rust + Kotlin MediaStore insert) and an appService.saveImageToGallery method. On Android the Save button uses it; iOS/macOS/desktop/web keep the existing share/export flow, and the button label/icon reflect the actual action. Also includes local agent memory notes that were staged alongside. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
240 lines
6.8 KiB
TypeScript
240 lines
6.8 KiB
TypeScript
export interface ParsedDataUrl {
|
|
bytes: Uint8Array;
|
|
mimeType: string;
|
|
}
|
|
|
|
/**
|
|
* Decode a `data:` URL into raw bytes plus its MIME type. Used to turn the
|
|
* in-memory image shown in the gallery viewer back into a file for the
|
|
* share / export flow.
|
|
*/
|
|
export function dataUrlToBytes(dataUrl: string): ParsedDataUrl {
|
|
const match = /^data:([^;,]*)(;base64)?,(.*)$/s.exec(dataUrl);
|
|
if (!match) {
|
|
throw new Error('Not a data URL');
|
|
}
|
|
const mimeType = match[1] || 'application/octet-stream';
|
|
const isBase64 = !!match[2];
|
|
const data = match[3] ?? '';
|
|
if (isBase64) {
|
|
const binary = atob(data);
|
|
const bytes = new Uint8Array(binary.length);
|
|
for (let i = 0; i < binary.length; i++) {
|
|
bytes[i] = binary.charCodeAt(i);
|
|
}
|
|
return { bytes, mimeType };
|
|
}
|
|
// Non-base64 data URLs hold percent-encoded text (e.g. inline SVG).
|
|
return { bytes: new TextEncoder().encode(decodeURIComponent(data)), mimeType };
|
|
}
|
|
|
|
/** Derive a file extension from an image MIME type (e.g. image/svg+xml -> svg). */
|
|
export function imageExtensionFromMime(mimeType: string): string {
|
|
const subtype = (mimeType.split('/')[1] || 'png').toLowerCase();
|
|
const base = subtype.split('+')[0]!;
|
|
return base === 'jpeg' ? 'jpg' : base;
|
|
}
|
|
|
|
/**
|
|
* Process book cover for Discord Rich Presence:
|
|
* - Fit to 512x512 with transparent background
|
|
* - Add Readest icon overlay at bottom right (10px padding)
|
|
* - Return as JPEG blob
|
|
*/
|
|
export async function processDiscordCover(coverUrl: string, iconUrl: string): Promise<Blob> {
|
|
const SIZE = 512;
|
|
const ICON_WIDTH = 224;
|
|
const ICON_HEIGHT = 182;
|
|
const PADDING = 10;
|
|
|
|
try {
|
|
const coverResponse = await fetch(coverUrl);
|
|
const coverBlob = await coverResponse.blob();
|
|
const coverImg = new Image();
|
|
coverImg.crossOrigin = 'anonymous';
|
|
|
|
const iconResponse = await fetch(iconUrl);
|
|
const iconBlob = await iconResponse.blob();
|
|
const iconImg = new Image();
|
|
iconImg.crossOrigin = 'anonymous';
|
|
|
|
return new Promise((resolve, reject) => {
|
|
let coverLoaded = false;
|
|
let iconLoaded = false;
|
|
|
|
const checkBothLoaded = () => {
|
|
if (!coverLoaded || !iconLoaded) return;
|
|
|
|
try {
|
|
const canvas = document.createElement('canvas');
|
|
const ctx = canvas.getContext('2d');
|
|
|
|
if (!ctx) {
|
|
reject(new Error('Failed to get canvas context'));
|
|
return;
|
|
}
|
|
|
|
canvas.width = SIZE;
|
|
canvas.height = SIZE;
|
|
|
|
// Calculate cover dimensions to fit in 512x512
|
|
const aspectRatio = coverImg.width / coverImg.height;
|
|
let drawWidth, drawHeight, offsetX, offsetY;
|
|
|
|
if (aspectRatio > 1) {
|
|
// Wider than tall
|
|
drawWidth = SIZE;
|
|
drawHeight = SIZE / aspectRatio;
|
|
offsetX = 0;
|
|
offsetY = (SIZE - drawHeight) / 2;
|
|
} else {
|
|
// Taller than wide
|
|
drawHeight = SIZE;
|
|
drawWidth = SIZE * aspectRatio;
|
|
offsetX = (SIZE - drawWidth) / 2;
|
|
offsetY = 0;
|
|
}
|
|
|
|
// Draw cover image centered
|
|
ctx.imageSmoothingEnabled = true;
|
|
ctx.imageSmoothingQuality = 'high';
|
|
ctx.drawImage(coverImg, offsetX, offsetY, drawWidth, drawHeight);
|
|
|
|
// Draw icon at bottom right
|
|
ctx.drawImage(
|
|
iconImg,
|
|
SIZE - ICON_WIDTH - PADDING,
|
|
SIZE - ICON_HEIGHT - PADDING,
|
|
ICON_WIDTH,
|
|
ICON_HEIGHT,
|
|
);
|
|
|
|
// Convert to JPEG blob
|
|
canvas.toBlob(
|
|
(blob) => {
|
|
if (blob) {
|
|
resolve(blob);
|
|
} else {
|
|
reject(new Error('Failed to create blob'));
|
|
}
|
|
},
|
|
'image/jpeg',
|
|
0.9,
|
|
);
|
|
} catch (error) {
|
|
reject(new Error(`Failed to process cover: ${error}`));
|
|
}
|
|
};
|
|
|
|
coverImg.onload = () => {
|
|
coverLoaded = true;
|
|
checkBothLoaded();
|
|
};
|
|
|
|
iconImg.onload = () => {
|
|
iconLoaded = true;
|
|
checkBothLoaded();
|
|
};
|
|
|
|
coverImg.onerror = () => reject(new Error('Failed to load cover image'));
|
|
iconImg.onerror = () => reject(new Error('Failed to load icon image'));
|
|
|
|
const coverObjectUrl = URL.createObjectURL(coverBlob);
|
|
const iconObjectUrl = URL.createObjectURL(iconBlob);
|
|
|
|
coverImg.src = coverObjectUrl;
|
|
iconImg.src = iconObjectUrl;
|
|
|
|
coverImg.onload = function () {
|
|
URL.revokeObjectURL(coverObjectUrl);
|
|
coverLoaded = true;
|
|
checkBothLoaded();
|
|
};
|
|
|
|
iconImg.onload = function () {
|
|
URL.revokeObjectURL(iconObjectUrl);
|
|
iconLoaded = true;
|
|
checkBothLoaded();
|
|
};
|
|
});
|
|
} catch (error) {
|
|
console.error('Error processing Discord cover:', error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
export async function fetchImageAsBase64(
|
|
url: string,
|
|
options: {
|
|
targetWidth?: number;
|
|
format?: 'image/jpeg' | 'image/png' | 'image/webp';
|
|
quality?: number;
|
|
} = {},
|
|
): Promise<string> {
|
|
const { targetWidth = 256, format = 'image/jpeg', quality = 0.85 } = options;
|
|
|
|
try {
|
|
const response = await fetch(url);
|
|
if (!response.ok) {
|
|
throw new Error(`Failed to fetch image: ${response.status} ${response.statusText}`);
|
|
}
|
|
const blob = await response.blob();
|
|
|
|
const img = new Image();
|
|
img.crossOrigin = 'anonymous';
|
|
|
|
return new Promise((resolve, reject) => {
|
|
img.onload = () => {
|
|
try {
|
|
const aspectRatio = img.height / img.width;
|
|
const newWidth = targetWidth;
|
|
const newHeight = Math.round(newWidth * aspectRatio);
|
|
|
|
const canvas = document.createElement('canvas');
|
|
const ctx = canvas.getContext('2d');
|
|
|
|
if (!ctx) {
|
|
reject(new Error('Failed to get canvas context'));
|
|
return;
|
|
}
|
|
|
|
canvas.width = newWidth;
|
|
canvas.height = newHeight;
|
|
|
|
ctx.imageSmoothingEnabled = true;
|
|
ctx.imageSmoothingQuality = 'high';
|
|
|
|
ctx.drawImage(img, 0, 0, newWidth, newHeight);
|
|
|
|
const base64 = canvas.toDataURL(format, quality);
|
|
resolve(base64);
|
|
} catch (error) {
|
|
reject(new Error(`Failed to scale image: ${error}`));
|
|
}
|
|
};
|
|
|
|
img.onerror = () => reject(new Error('Failed to load image for scaling'));
|
|
|
|
const objectUrl = URL.createObjectURL(blob);
|
|
img.src = objectUrl;
|
|
|
|
const cleanup = () => URL.revokeObjectURL(objectUrl);
|
|
const originalOnload = img.onload;
|
|
const originalOnerror = img.onerror;
|
|
|
|
img.onload = function (ev) {
|
|
cleanup();
|
|
if (originalOnload) originalOnload.call(this, ev);
|
|
};
|
|
|
|
img.onerror = function (ev) {
|
|
cleanup();
|
|
if (originalOnerror) originalOnerror.call(this, ev);
|
|
};
|
|
});
|
|
} catch (error) {
|
|
console.error('Error fetching and encoding image:', error);
|
|
throw error;
|
|
}
|
|
}
|