mirror of https://github.com/usememos/memos
feat(memo): add image sharing in detail view
Keep the unpublished image-sharing flow scoped to memo detail pages. - add a dedicated share-image preview and export pipeline - measure the rendered memo card so preview and exported image stay aligned - move the entry point into the detail sidebar and drawer onlypull/5811/head
parent
2cbc70762b
commit
38fc22b754
@ -0,0 +1,114 @@
|
|||||||
|
import { DownloadIcon, ImageIcon, Loader2Icon, Share2Icon } from "lucide-react";
|
||||||
|
import { useCallback, useMemo, useRef, useState } from "react";
|
||||||
|
import { toast } from "react-hot-toast";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||||
|
import { useTranslate } from "@/utils/i18n";
|
||||||
|
import { useMemoViewContext } from "../MemoView/MemoViewContext";
|
||||||
|
import MemoShareImagePreview from "./MemoShareImagePreview";
|
||||||
|
import { buildMemoShareImageFileName, createMemoShareImageBlob, getMemoShareDialogWidth, getMemoSharePreviewWidth } from "./memoShareImage";
|
||||||
|
|
||||||
|
interface MemoShareImageDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MemoShareImageDialog = ({ open, onOpenChange }: MemoShareImageDialogProps) => {
|
||||||
|
const t = useTranslate();
|
||||||
|
const { memo, cardWidth } = useMemoViewContext();
|
||||||
|
const previewRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [isRendering, setIsRendering] = useState(false);
|
||||||
|
|
||||||
|
const previewWidth = useMemo(() => getMemoSharePreviewWidth(cardWidth), [cardWidth]);
|
||||||
|
const dialogWidth = useMemo(() => getMemoShareDialogWidth(previewWidth), [previewWidth]);
|
||||||
|
|
||||||
|
const createShareBlob = useCallback(async () => {
|
||||||
|
const preview = previewRef.current;
|
||||||
|
if (!preview) {
|
||||||
|
throw new Error("Preview is not ready");
|
||||||
|
}
|
||||||
|
|
||||||
|
return createMemoShareImageBlob(preview);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleDownload = useCallback(async () => {
|
||||||
|
setIsRendering(true);
|
||||||
|
try {
|
||||||
|
const blob = await createShareBlob();
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const anchor = document.createElement("a");
|
||||||
|
anchor.href = url;
|
||||||
|
anchor.download = buildMemoShareImageFileName(memo.name);
|
||||||
|
anchor.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
toast.success(t("memo.share.image-downloaded"));
|
||||||
|
} catch {
|
||||||
|
toast.error(t("memo.share.image-download-failed"));
|
||||||
|
} finally {
|
||||||
|
setIsRendering(false);
|
||||||
|
}
|
||||||
|
}, [createShareBlob, memo.name, t]);
|
||||||
|
|
||||||
|
const handleNativeShare = useCallback(async () => {
|
||||||
|
if (typeof navigator.share !== "function") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsRendering(true);
|
||||||
|
try {
|
||||||
|
const blob = await createShareBlob();
|
||||||
|
const file = new File([blob], buildMemoShareImageFileName(memo.name), { type: "image/png" });
|
||||||
|
if (typeof navigator.canShare === "function" && !navigator.canShare({ files: [file] })) {
|
||||||
|
toast.error(t("memo.share.image-share-failed"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await navigator.share({
|
||||||
|
files: [file],
|
||||||
|
title: memo.content.slice(0, 60),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (!(error instanceof DOMException && error.name === "AbortError")) {
|
||||||
|
toast.error(t("memo.share.image-share-failed"));
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setIsRendering(false);
|
||||||
|
}
|
||||||
|
}, [createShareBlob, memo.content, memo.name, t]);
|
||||||
|
|
||||||
|
const supportsNativeShare =
|
||||||
|
typeof navigator !== "undefined" && typeof navigator.share === "function" && typeof navigator.canShare === "function";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent size="full" className="md:w-auto md:max-w-none" style={{ width: `${dialogWidth}px` }}>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
<ImageIcon className="h-4 w-4" />
|
||||||
|
{t("memo.share.image-title")}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>{t("memo.share.image-description", { width: previewWidth })}</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="overflow-auto p-1 sm:p-2">
|
||||||
|
<MemoShareImagePreview ref={previewRef} width={previewWidth} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
{supportsNativeShare && (
|
||||||
|
<Button variant="outline" onClick={handleNativeShare} disabled={isRendering}>
|
||||||
|
{isRendering ? <Loader2Icon className="mr-2 h-4 w-4 animate-spin" /> : <Share2Icon className="mr-2 h-4 w-4" />}
|
||||||
|
{t("memo.share.image-share")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button onClick={handleDownload} disabled={isRendering}>
|
||||||
|
{isRendering ? <Loader2Icon className="mr-2 h-4 w-4 animate-spin" /> : <DownloadIcon className="mr-2 h-4 w-4" />}
|
||||||
|
{t("memo.share.image-download")}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default MemoShareImageDialog;
|
||||||
@ -0,0 +1,113 @@
|
|||||||
|
import { timestampDate } from "@bufbuild/protobuf/wkt";
|
||||||
|
import { forwardRef, useMemo } from "react";
|
||||||
|
import MemoContent from "@/components/MemoContent";
|
||||||
|
import { separateAttachments } from "@/components/MemoMetadata/Attachment/attachmentHelpers";
|
||||||
|
import UserAvatar from "@/components/UserAvatar";
|
||||||
|
import i18n from "@/i18n";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { useTranslate } from "@/utils/i18n";
|
||||||
|
import { buildAttachmentVisualItems, countLogicalAttachmentItems } from "@/utils/media-item";
|
||||||
|
import { useMemoViewContext } from "../MemoView/MemoViewContext";
|
||||||
|
import { getMemoSharePreviewAvatarUrl } from "./memoShareImage";
|
||||||
|
|
||||||
|
const MemoShareImagePreview = forwardRef<HTMLDivElement, { width: number }>(({ width }, ref) => {
|
||||||
|
const t = useTranslate();
|
||||||
|
const { memo, creator, blurred, showBlurredContent } = useMemoViewContext();
|
||||||
|
|
||||||
|
const displayName = creator?.displayName || creator?.username || t("common.memo");
|
||||||
|
const avatarUrl = getMemoSharePreviewAvatarUrl(creator?.avatarUrl);
|
||||||
|
const displayTime = memo.displayTime ? timestampDate(memo.displayTime) : memo.createTime ? timestampDate(memo.createTime) : undefined;
|
||||||
|
const formattedDisplayTime = displayTime?.toLocaleString(i18n.language, {
|
||||||
|
dateStyle: "medium",
|
||||||
|
timeStyle: "short",
|
||||||
|
});
|
||||||
|
const { attachmentCount, nonVisualAttachmentCount, visualItems } = useMemo(() => {
|
||||||
|
const attachmentGroups = separateAttachments(memo.attachments);
|
||||||
|
const previewVisualItems = buildAttachmentVisualItems(attachmentGroups.visual);
|
||||||
|
const totalAttachmentCount = countLogicalAttachmentItems(memo.attachments);
|
||||||
|
|
||||||
|
return {
|
||||||
|
attachmentCount: totalAttachmentCount,
|
||||||
|
nonVisualAttachmentCount: totalAttachmentCount - previewVisualItems.length,
|
||||||
|
visualItems: previewVisualItems,
|
||||||
|
};
|
||||||
|
}, [memo.attachments]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className="relative overflow-hidden rounded-[24px] border border-border/50 bg-linear-to-br from-background via-muted/15 to-background p-2.5 sm:p-3"
|
||||||
|
style={{ width }}
|
||||||
|
>
|
||||||
|
<div className="pointer-events-none absolute -top-16 right-0 h-32 w-32 rounded-full bg-sky-500/8 blur-3xl" />
|
||||||
|
<div className="pointer-events-none absolute -bottom-20 -left-10 h-36 w-36 rounded-full bg-amber-400/8 blur-3xl" />
|
||||||
|
|
||||||
|
<div className="relative overflow-hidden rounded-[20px] border border-border/60 bg-background/98 p-4 shadow-sm shadow-foreground/5 sm:p-5">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className="flex min-w-0 items-center gap-2.5">
|
||||||
|
<UserAvatar avatarUrl={avatarUrl} className="h-9 w-9 rounded-2xl" />
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="truncate text-[13px] font-semibold text-foreground">{displayName}</div>
|
||||||
|
{formattedDisplayTime && <div className="truncate text-xs text-muted-foreground">{formattedDisplayTime}</div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4">
|
||||||
|
<div className={cn("pointer-events-none", blurred && !showBlurredContent && "blur-lg")}>
|
||||||
|
<MemoContent content={memo.content} compact={false} contentClassName="text-[14px] leading-6.5 sm:text-[15px]" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{visualItems.length > 0 && (
|
||||||
|
<div className={cn("mt-4 grid gap-1.5", visualItems.length === 1 ? "grid-cols-1" : "grid-cols-2")}>
|
||||||
|
{visualItems.slice(0, 4).map((item, index) => (
|
||||||
|
<div
|
||||||
|
key={item.id}
|
||||||
|
className={cn(
|
||||||
|
"relative overflow-hidden rounded-[18px] border border-border/70 bg-muted/40",
|
||||||
|
visualItems.length === 1 ? "aspect-[4/3]" : "aspect-square",
|
||||||
|
visualItems.length === 3 && index === 0 && "col-span-2 aspect-[2.2/1]",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<img src={item.posterUrl} alt={item.filename} className="h-full w-full object-cover" loading="eager" decoding="async" />
|
||||||
|
{index === 3 && visualItems.length > 4 && (
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center bg-foreground/35 text-lg font-semibold text-background">
|
||||||
|
+{visualItems.length - 4}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(memo.tags.length > 0 || nonVisualAttachmentCount > 0) && (
|
||||||
|
<div className="mt-4 flex flex-wrap items-center gap-1.5">
|
||||||
|
{memo.tags.slice(0, 3).map((tag) => (
|
||||||
|
<span
|
||||||
|
key={tag}
|
||||||
|
className="inline-flex rounded-full border border-border/70 bg-muted/55 px-2 py-0.5 text-[11px] text-muted-foreground"
|
||||||
|
>
|
||||||
|
#{tag}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
{memo.tags.length > 3 && (
|
||||||
|
<span className="inline-flex rounded-full border border-border/70 bg-muted/55 px-2 py-0.5 text-[11px] text-muted-foreground">
|
||||||
|
+{memo.tags.length - 3}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{nonVisualAttachmentCount > 0 && (
|
||||||
|
<span className="inline-flex rounded-full border border-border/70 bg-muted/55 px-2 py-0.5 text-[11px] text-muted-foreground">
|
||||||
|
{attachmentCount} {t("common.attachments").toLowerCase()}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
MemoShareImagePreview.displayName = "MemoShareImagePreview";
|
||||||
|
|
||||||
|
export default MemoShareImagePreview;
|
||||||
@ -0,0 +1,110 @@
|
|||||||
|
import { toBlob } from "html-to-image";
|
||||||
|
|
||||||
|
const WINDOW_HORIZONTAL_MARGIN = 32;
|
||||||
|
|
||||||
|
export const MEMO_SHARE_IMAGE_CONFIG = {
|
||||||
|
dialogExtraWidth: 80,
|
||||||
|
maxWidth: 520,
|
||||||
|
minWidth: 260,
|
||||||
|
previewScale: 0.9,
|
||||||
|
viewportMargin: 48,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const clamp = (value: number, min: number, max: number) => Math.min(Math.max(value, min), max);
|
||||||
|
|
||||||
|
const isExportableImageUrl = (value?: string) => {
|
||||||
|
if (!value) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value.startsWith("/") || value.startsWith("data:") || value.startsWith("blob:")) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return new URL(value, window.location.origin).origin === window.location.origin;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const waitForPreviewAssets = async (node: HTMLElement) => {
|
||||||
|
try {
|
||||||
|
await document.fonts?.ready;
|
||||||
|
} catch {
|
||||||
|
// Ignore font loading failures and continue with the best available render.
|
||||||
|
}
|
||||||
|
|
||||||
|
const images = Array.from(node.querySelectorAll("img"));
|
||||||
|
await Promise.all(
|
||||||
|
images.map(
|
||||||
|
(image) =>
|
||||||
|
new Promise<void>((resolve) => {
|
||||||
|
if (image.complete) {
|
||||||
|
resolve();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
image.addEventListener("load", () => resolve(), { once: true });
|
||||||
|
image.addEventListener("error", () => resolve(), { once: true });
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const buildMemoShareImageFileName = (memoName: string) => {
|
||||||
|
const suffix = memoName.split("/").pop() ?? "memo";
|
||||||
|
return `memo-${suffix}.png`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getMemoSharePreviewWidth = (cardWidth: number) => {
|
||||||
|
const viewportWidth =
|
||||||
|
typeof window === "undefined" ? MEMO_SHARE_IMAGE_CONFIG.maxWidth : window.innerWidth - MEMO_SHARE_IMAGE_CONFIG.viewportMargin;
|
||||||
|
const baseWidth = cardWidth || viewportWidth;
|
||||||
|
|
||||||
|
return clamp(
|
||||||
|
Math.round(baseWidth * MEMO_SHARE_IMAGE_CONFIG.previewScale),
|
||||||
|
MEMO_SHARE_IMAGE_CONFIG.minWidth,
|
||||||
|
MEMO_SHARE_IMAGE_CONFIG.maxWidth,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getMemoShareDialogWidth = (previewWidth: number) => {
|
||||||
|
const viewportWidth =
|
||||||
|
typeof window === "undefined" ? previewWidth + MEMO_SHARE_IMAGE_CONFIG.dialogExtraWidth : window.innerWidth - WINDOW_HORIZONTAL_MARGIN;
|
||||||
|
return Math.min(previewWidth + MEMO_SHARE_IMAGE_CONFIG.dialogExtraWidth, viewportWidth);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getMemoSharePreviewAvatarUrl = (avatarUrl?: string) => (isExportableImageUrl(avatarUrl) ? avatarUrl : undefined);
|
||||||
|
|
||||||
|
export const createMemoShareImageBlob = async (node: HTMLElement) => {
|
||||||
|
await waitForPreviewAssets(node);
|
||||||
|
|
||||||
|
const rect = node.getBoundingClientRect();
|
||||||
|
const width = Math.ceil(rect.width || node.offsetWidth || node.clientWidth);
|
||||||
|
const height = Math.ceil(rect.height || node.offsetHeight || node.clientHeight);
|
||||||
|
|
||||||
|
const blob = await toBlob(node, {
|
||||||
|
cacheBust: true,
|
||||||
|
height,
|
||||||
|
pixelRatio: Math.max(2, Math.min(window.devicePixelRatio || 1, 3)),
|
||||||
|
width,
|
||||||
|
filter: (currentNode) => {
|
||||||
|
if (!(currentNode instanceof HTMLElement)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentNode instanceof HTMLImageElement) {
|
||||||
|
return isExportableImageUrl(currentNode.currentSrc || currentNode.src);
|
||||||
|
}
|
||||||
|
|
||||||
|
return !(currentNode instanceof HTMLVideoElement);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!blob) {
|
||||||
|
throw new Error("Failed to render image");
|
||||||
|
}
|
||||||
|
|
||||||
|
return blob;
|
||||||
|
};
|
||||||
Loading…
Reference in New Issue