Files
readest/apps/readest-app/src/app/reader/components/ZoomControls.tsx
T
Huang Xin 7185dca1a2 feat(reader): add save/share button to image gallery toolbar (#4680)
* 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>
2026-06-20 06:28:08 +02:00

100 lines
2.9 KiB
TypeScript

import React from 'react';
import {
IoClose,
IoExpand,
IoAdd,
IoRemove,
IoShareOutline,
IoDownloadOutline,
} from 'react-icons/io5';
import { useTranslation } from '@/hooks/useTranslation';
import { useThemeStore } from '@/store/themeStore';
import { Insets } from '@/types/misc';
interface ZoomControlsProps {
gridInsets: Insets;
// Save/Share is image-specific; omit `onSave` (e.g. the table viewer) to hide it.
canShare?: boolean;
onClose: () => void;
onSave?: (e: React.MouseEvent<HTMLButtonElement>) => void;
onZoomIn: () => void;
onZoomOut: () => void;
onReset: () => void;
}
const ZoomControls: React.FC<ZoomControlsProps> = ({
gridInsets,
canShare,
onClose,
onSave,
onZoomIn,
onZoomOut,
onReset,
}) => {
const _ = useTranslation();
const { systemUIVisible, statusBarHeight } = useThemeStore();
return (
<div
className='absolute right-4 top-2 z-10 grid grid-cols-1 gap-4 text-white'
style={{
marginTop: systemUIVisible
? `${Math.max(gridInsets.top, statusBarHeight)}px`
: `${gridInsets.top}px`,
}}
>
<button
onClick={onClose}
className='eink-bordered flex h-10 w-10 cursor-pointer items-center justify-center rounded-full bg-black/50 transition-colors hover:bg-black/70'
aria-label={_('Close')}
title={_('Close')}
>
<IoClose className='h-6 w-6' />
</button>
{onSave && (
<button
onClick={onSave}
className='eink-bordered flex h-10 w-10 cursor-pointer items-center justify-center rounded-full bg-black/50 transition-colors hover:bg-black/70'
aria-label={canShare ? _('Share Image') : _('Save Image')}
title={canShare ? _('Share Image') : _('Save Image')}
>
{canShare ? (
<IoShareOutline className='h-6 w-6' />
) : (
<IoDownloadOutline className='h-6 w-6' />
)}
</button>
)}
<button
onClick={onZoomIn}
className='eink-bordered flex h-10 w-10 cursor-pointer items-center justify-center rounded-full bg-black/50 transition-colors hover:bg-black/70'
aria-label={_('Zoom In')}
title={_('Zoom In')}
>
<IoAdd className='h-6 w-6' />
</button>
<button
onClick={onZoomOut}
className='eink-bordered flex h-10 w-10 cursor-pointer items-center justify-center rounded-full bg-black/50 transition-colors hover:bg-black/70'
aria-label={_('Zoom Out')}
title={_('Zoom Out')}
>
<IoRemove className='h-6 w-6' />
</button>
<button
onClick={onReset}
className='eink-bordered flex h-10 w-10 cursor-pointer items-center justify-center rounded-full bg-black/50 transition-colors hover:bg-black/70'
aria-label={_('Reset Zoom')}
title={_('Reset Zoom')}
>
<IoExpand className='h-6 w-6' />
</button>
</div>
);
};
export default ZoomControls;