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:
@@ -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 }),
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user