Share a card's image from the preview

A share button beside the preview's close button sends the face being
shown at Scryfall's large size: on desktop the image is decoded and put on
the clipboard (tauri-plugin-clipboard-manager); on Android it's saved to
the cache and handed to the system share sheet by a small in-repo plugin
(SharePlugin.kt) through the app's FileProvider. The preview's corner
buttons are also a little larger.
This commit is contained in:
2026-10-24 15:20:27 -05:00
parent 7415289257
commit 226878fdd7
10 changed files with 597 additions and 5 deletions

View File

@@ -508,6 +508,9 @@ export const api = {
isCommander = false,
isMaybeboard = false,
) => invoke<void>("set_deck_card", { deckId, oracleId, quantity, isCommander, isMaybeboard }),
/** Copy (desktop) or share (Android) a card image; says which happened. */
shareCardImage: (url: string, name: string) =>
invoke<"copied" | "shared">("share_card_image", { url, name }),
deckContents: (deckId: number) => invoke<DeckEntry[]>("deck_contents", { deckId }),
suggestCards: (deckId: number, offset = 0, options: { unusual?: boolean; owned_only?: boolean } = {}) =>
invoke<CardSuggestion[]>("suggest_cards", { deckId, offset, options }),

View File

@@ -98,6 +98,15 @@ const ICON_UPLOAD = icon(
<path d="M4 18v1a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-1" />
</>,
);
const ICON_SHARE = icon(
<>
<circle cx="18" cy="5" r="3" />
<circle cx="6" cy="12" r="3" />
<circle cx="18" cy="19" r="3" />
<path d="M8.6 13.5l6.8 4M15.4 6.5l-6.8 4" />
</>,
);
const ICON_CHECK = icon(<polyline points="20 6 9 17 4 12" />);
// A box (as on the "Not owned" filter) with a plus: add to the library.
const ICON_LIBRARY_ADD = icon(
<>
@@ -156,6 +165,8 @@ export const Icons = {
download: ICON_DOWNLOAD,
upload: ICON_UPLOAD,
libraryAdd: ICON_LIBRARY_ADD,
share: ICON_SHARE,
check: ICON_CHECK,
};
// --- Dialogs ------------------------------------------------------------
@@ -1294,6 +1305,46 @@ function PreviewPaneToggle({
);
}
// Android gets the system share sheet; desktop copies the image instead.
const CAN_SHARE_SHEET = /Android/i.test(navigator.userAgent);
/** Shares the card image being shown — copies it on desktop — and confirms
* with a brief check mark (or the error, in the tooltip). */
function ShareImageButton({ url, name, overlay }: { url: string; name: string; overlay?: boolean }) {
const [state, setState] = useState<"idle" | "busy" | "done" | { error: string }>("idle");
useEffect(() => {
if (state !== "done" && typeof state !== "object") return;
const t = setTimeout(() => setState("idle"), state === "done" ? 1500 : 4000);
return () => clearTimeout(t);
}, [state]);
const idleLabel = CAN_SHARE_SHEET ? "Share image" : "Copy image";
const label =
state === "done"
? CAN_SHARE_SHEET
? "Shared"
: "Copied"
: typeof state === "object"
? `Couldn't ${CAN_SHARE_SHEET ? "share" : "copy"} the image: ${state.error}`
: idleLabel;
return (
<IconButton
icon={state === "done" ? Icons.check : Icons.share}
label={label}
overlay={overlay}
disabled={state === "busy"}
onClick={async () => {
setState("busy");
try {
await api.shareCardImage(url, name);
setState("done");
} catch (err) {
setState({ error: String(err) });
}
}}
/>
);
}
function ClickPreviewModal({
state,
onClose,
@@ -1309,6 +1360,8 @@ function ClickPreviewModal({
const [art, setArt] = useState<CardArt | null>(null);
const card: PreviewCard = art ? { ...state.card, ...art } : state.card;
const hasBack = !!card.back_image_uri;
// What sharing sends: the face being looked at.
const shownImage = showBack && hasBack ? card.back_image_uri : card.image_uri;
const backdropRef = useRef<HTMLDivElement>(null);
const modalRef = useRef<HTMLDivElement>(null);
const infoRef = useRef<HTMLDivElement>(null);
@@ -1423,6 +1476,9 @@ function ClickPreviewModal({
<span className="preview-modal-close">
<IconButton icon={Icons.close} label="Close (Esc)" onClick={onClose} overlay />
</span>
<span className="preview-modal-share">
<ShareImageButton url={shownImage} name={card.name} overlay />
</span>
{hasBack && (
<button className="preview-modal-flip" onClick={onFlip} title="Show the other side">
⟲ Flip
@@ -1433,7 +1489,10 @@ function ClickPreviewModal({
{compact && (
<div className="preview-modal-bar">
<PreviewPaneToggle hasBack={hasBack} showBack={showBack} pane={pane} onSelect={selectPane} />
<IconButton icon={Icons.close} label="Close (Esc)" onClick={onClose} overlay />
<span className="row" style={{ gap: 8 }}>
<ShareImageButton url={shownImage} name={card.name} overlay />
<IconButton icon={Icons.close} label="Close (Esc)" onClick={onClose} overlay />
</span>
</div>
)}
</div>

View File

@@ -2220,6 +2220,7 @@ tbody tr:hover {
max-width: min(720px, 90vw);
}
.preview-modal-compact .preview-modal-close,
.preview-modal-compact .preview-modal-share,
.preview-modal-compact .preview-modal-flip,
.preview-modal-compact:not(.preview-modal-show-info) .preview-info,
.preview-modal-show-info .preview-modal-faces {
@@ -2227,8 +2228,20 @@ tbody tr:hover {
}
.preview-modal-close {
position: absolute;
top: -14px;
right: -14px;
top: -16px;
right: -16px;
}
/* The preview's corner buttons are its main controls, so a little larger
than the overlay buttons on tiles. */
.preview-modal-close .icon-btn,
.preview-modal-share .icon-btn {
width: 32px;
height: 32px;
}
.preview-modal-close .icon-btn svg,
.preview-modal-share .icon-btn svg {
width: 18px;
height: 18px;
}
.preview-modal-close .icon-btn {
box-shadow: var(--shadow-md);
@@ -2237,6 +2250,15 @@ tbody tr:hover {
background: var(--danger);
color: #fff;
}
/* Beside the close button, to its left: past its 32px, plus a 12px gap. */
.preview-modal-share {
position: absolute;
top: -16px;
right: 28px;
}
.preview-modal-share .icon-btn {
box-shadow: var(--shadow-md);
}
.preview-modal-flip {
position: absolute;
bottom: 14px;