Skip to content

Commit 38fc22b

Browse files
committed
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 only
1 parent 2cbc707 commit 38fc22b

File tree

15 files changed

+440
-13
lines changed

15 files changed

+440
-13
lines changed

web/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
"dayjs": "^1.11.20",
3939
"fuse.js": "^7.1.0",
4040
"highlight.js": "^11.11.1",
41+
"html-to-image": "^1.11.13",
4142
"i18next": "^25.8.18",
4243
"katex": "^0.16.38",
4344
"leaflet": "^1.9.4",

web/pnpm-lock.yaml

Lines changed: 8 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
import { DownloadIcon, ImageIcon, Loader2Icon, Share2Icon } from "lucide-react";
2+
import { useCallback, useMemo, useRef, useState } from "react";
3+
import { toast } from "react-hot-toast";
4+
import { Button } from "@/components/ui/button";
5+
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
6+
import { useTranslate } from "@/utils/i18n";
7+
import { useMemoViewContext } from "../MemoView/MemoViewContext";
8+
import MemoShareImagePreview from "./MemoShareImagePreview";
9+
import { buildMemoShareImageFileName, createMemoShareImageBlob, getMemoShareDialogWidth, getMemoSharePreviewWidth } from "./memoShareImage";
10+
11+
interface MemoShareImageDialogProps {
12+
open: boolean;
13+
onOpenChange: (open: boolean) => void;
14+
}
15+
16+
const MemoShareImageDialog = ({ open, onOpenChange }: MemoShareImageDialogProps) => {
17+
const t = useTranslate();
18+
const { memo, cardWidth } = useMemoViewContext();
19+
const previewRef = useRef<HTMLDivElement>(null);
20+
const [isRendering, setIsRendering] = useState(false);
21+
22+
const previewWidth = useMemo(() => getMemoSharePreviewWidth(cardWidth), [cardWidth]);
23+
const dialogWidth = useMemo(() => getMemoShareDialogWidth(previewWidth), [previewWidth]);
24+
25+
const createShareBlob = useCallback(async () => {
26+
const preview = previewRef.current;
27+
if (!preview) {
28+
throw new Error("Preview is not ready");
29+
}
30+
31+
return createMemoShareImageBlob(preview);
32+
}, []);
33+
34+
const handleDownload = useCallback(async () => {
35+
setIsRendering(true);
36+
try {
37+
const blob = await createShareBlob();
38+
const url = URL.createObjectURL(blob);
39+
const anchor = document.createElement("a");
40+
anchor.href = url;
41+
anchor.download = buildMemoShareImageFileName(memo.name);
42+
anchor.click();
43+
URL.revokeObjectURL(url);
44+
toast.success(t("memo.share.image-downloaded"));
45+
} catch {
46+
toast.error(t("memo.share.image-download-failed"));
47+
} finally {
48+
setIsRendering(false);
49+
}
50+
}, [createShareBlob, memo.name, t]);
51+
52+
const handleNativeShare = useCallback(async () => {
53+
if (typeof navigator.share !== "function") {
54+
return;
55+
}
56+
57+
setIsRendering(true);
58+
try {
59+
const blob = await createShareBlob();
60+
const file = new File([blob], buildMemoShareImageFileName(memo.name), { type: "image/png" });
61+
if (typeof navigator.canShare === "function" && !navigator.canShare({ files: [file] })) {
62+
toast.error(t("memo.share.image-share-failed"));
63+
return;
64+
}
65+
66+
await navigator.share({
67+
files: [file],
68+
title: memo.content.slice(0, 60),
69+
});
70+
} catch (error) {
71+
if (!(error instanceof DOMException && error.name === "AbortError")) {
72+
toast.error(t("memo.share.image-share-failed"));
73+
}
74+
} finally {
75+
setIsRendering(false);
76+
}
77+
}, [createShareBlob, memo.content, memo.name, t]);
78+
79+
const supportsNativeShare =
80+
typeof navigator !== "undefined" && typeof navigator.share === "function" && typeof navigator.canShare === "function";
81+
82+
return (
83+
<Dialog open={open} onOpenChange={onOpenChange}>
84+
<DialogContent size="full" className="md:w-auto md:max-w-none" style={{ width: `${dialogWidth}px` }}>
85+
<DialogHeader>
86+
<DialogTitle className="flex items-center gap-2">
87+
<ImageIcon className="h-4 w-4" />
88+
{t("memo.share.image-title")}
89+
</DialogTitle>
90+
<DialogDescription>{t("memo.share.image-description", { width: previewWidth })}</DialogDescription>
91+
</DialogHeader>
92+
93+
<div className="overflow-auto p-1 sm:p-2">
94+
<MemoShareImagePreview ref={previewRef} width={previewWidth} />
95+
</div>
96+
97+
<DialogFooter>
98+
{supportsNativeShare && (
99+
<Button variant="outline" onClick={handleNativeShare} disabled={isRendering}>
100+
{isRendering ? <Loader2Icon className="mr-2 h-4 w-4 animate-spin" /> : <Share2Icon className="mr-2 h-4 w-4" />}
101+
{t("memo.share.image-share")}
102+
</Button>
103+
)}
104+
<Button onClick={handleDownload} disabled={isRendering}>
105+
{isRendering ? <Loader2Icon className="mr-2 h-4 w-4 animate-spin" /> : <DownloadIcon className="mr-2 h-4 w-4" />}
106+
{t("memo.share.image-download")}
107+
</Button>
108+
</DialogFooter>
109+
</DialogContent>
110+
</Dialog>
111+
);
112+
};
113+
114+
export default MemoShareImageDialog;
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
import { timestampDate } from "@bufbuild/protobuf/wkt";
2+
import { forwardRef, useMemo } from "react";
3+
import MemoContent from "@/components/MemoContent";
4+
import { separateAttachments } from "@/components/MemoMetadata/Attachment/attachmentHelpers";
5+
import UserAvatar from "@/components/UserAvatar";
6+
import i18n from "@/i18n";
7+
import { cn } from "@/lib/utils";
8+
import { useTranslate } from "@/utils/i18n";
9+
import { buildAttachmentVisualItems, countLogicalAttachmentItems } from "@/utils/media-item";
10+
import { useMemoViewContext } from "../MemoView/MemoViewContext";
11+
import { getMemoSharePreviewAvatarUrl } from "./memoShareImage";
12+
13+
const MemoShareImagePreview = forwardRef<HTMLDivElement, { width: number }>(({ width }, ref) => {
14+
const t = useTranslate();
15+
const { memo, creator, blurred, showBlurredContent } = useMemoViewContext();
16+
17+
const displayName = creator?.displayName || creator?.username || t("common.memo");
18+
const avatarUrl = getMemoSharePreviewAvatarUrl(creator?.avatarUrl);
19+
const displayTime = memo.displayTime ? timestampDate(memo.displayTime) : memo.createTime ? timestampDate(memo.createTime) : undefined;
20+
const formattedDisplayTime = displayTime?.toLocaleString(i18n.language, {
21+
dateStyle: "medium",
22+
timeStyle: "short",
23+
});
24+
const { attachmentCount, nonVisualAttachmentCount, visualItems } = useMemo(() => {
25+
const attachmentGroups = separateAttachments(memo.attachments);
26+
const previewVisualItems = buildAttachmentVisualItems(attachmentGroups.visual);
27+
const totalAttachmentCount = countLogicalAttachmentItems(memo.attachments);
28+
29+
return {
30+
attachmentCount: totalAttachmentCount,
31+
nonVisualAttachmentCount: totalAttachmentCount - previewVisualItems.length,
32+
visualItems: previewVisualItems,
33+
};
34+
}, [memo.attachments]);
35+
36+
return (
37+
<div
38+
ref={ref}
39+
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"
40+
style={{ width }}
41+
>
42+
<div className="pointer-events-none absolute -top-16 right-0 h-32 w-32 rounded-full bg-sky-500/8 blur-3xl" />
43+
<div className="pointer-events-none absolute -bottom-20 -left-10 h-36 w-36 rounded-full bg-amber-400/8 blur-3xl" />
44+
45+
<div className="relative overflow-hidden rounded-[20px] border border-border/60 bg-background/98 p-4 shadow-sm shadow-foreground/5 sm:p-5">
46+
<div className="flex items-start gap-3">
47+
<div className="flex min-w-0 items-center gap-2.5">
48+
<UserAvatar avatarUrl={avatarUrl} className="h-9 w-9 rounded-2xl" />
49+
<div className="min-w-0">
50+
<div className="truncate text-[13px] font-semibold text-foreground">{displayName}</div>
51+
{formattedDisplayTime && <div className="truncate text-xs text-muted-foreground">{formattedDisplayTime}</div>}
52+
</div>
53+
</div>
54+
</div>
55+
56+
<div className="mt-4">
57+
<div className={cn("pointer-events-none", blurred && !showBlurredContent && "blur-lg")}>
58+
<MemoContent content={memo.content} compact={false} contentClassName="text-[14px] leading-6.5 sm:text-[15px]" />
59+
</div>
60+
</div>
61+
62+
{visualItems.length > 0 && (
63+
<div className={cn("mt-4 grid gap-1.5", visualItems.length === 1 ? "grid-cols-1" : "grid-cols-2")}>
64+
{visualItems.slice(0, 4).map((item, index) => (
65+
<div
66+
key={item.id}
67+
className={cn(
68+
"relative overflow-hidden rounded-[18px] border border-border/70 bg-muted/40",
69+
visualItems.length === 1 ? "aspect-[4/3]" : "aspect-square",
70+
visualItems.length === 3 && index === 0 && "col-span-2 aspect-[2.2/1]",
71+
)}
72+
>
73+
<img src={item.posterUrl} alt={item.filename} className="h-full w-full object-cover" loading="eager" decoding="async" />
74+
{index === 3 && visualItems.length > 4 && (
75+
<div className="absolute inset-0 flex items-center justify-center bg-foreground/35 text-lg font-semibold text-background">
76+
+{visualItems.length - 4}
77+
</div>
78+
)}
79+
</div>
80+
))}
81+
</div>
82+
)}
83+
84+
{(memo.tags.length > 0 || nonVisualAttachmentCount > 0) && (
85+
<div className="mt-4 flex flex-wrap items-center gap-1.5">
86+
{memo.tags.slice(0, 3).map((tag) => (
87+
<span
88+
key={tag}
89+
className="inline-flex rounded-full border border-border/70 bg-muted/55 px-2 py-0.5 text-[11px] text-muted-foreground"
90+
>
91+
#{tag}
92+
</span>
93+
))}
94+
{memo.tags.length > 3 && (
95+
<span className="inline-flex rounded-full border border-border/70 bg-muted/55 px-2 py-0.5 text-[11px] text-muted-foreground">
96+
+{memo.tags.length - 3}
97+
</span>
98+
)}
99+
{nonVisualAttachmentCount > 0 && (
100+
<span className="inline-flex rounded-full border border-border/70 bg-muted/55 px-2 py-0.5 text-[11px] text-muted-foreground">
101+
{attachmentCount} {t("common.attachments").toLowerCase()}
102+
</span>
103+
)}
104+
</div>
105+
)}
106+
</div>
107+
</div>
108+
);
109+
});
110+
111+
MemoShareImagePreview.displayName = "MemoShareImagePreview";
112+
113+
export default MemoShareImagePreview;
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
import { toBlob } from "html-to-image";
2+
3+
const WINDOW_HORIZONTAL_MARGIN = 32;
4+
5+
export const MEMO_SHARE_IMAGE_CONFIG = {
6+
dialogExtraWidth: 80,
7+
maxWidth: 520,
8+
minWidth: 260,
9+
previewScale: 0.9,
10+
viewportMargin: 48,
11+
} as const;
12+
13+
const clamp = (value: number, min: number, max: number) => Math.min(Math.max(value, min), max);
14+
15+
const isExportableImageUrl = (value?: string) => {
16+
if (!value) {
17+
return false;
18+
}
19+
20+
if (value.startsWith("/") || value.startsWith("data:") || value.startsWith("blob:")) {
21+
return true;
22+
}
23+
24+
try {
25+
return new URL(value, window.location.origin).origin === window.location.origin;
26+
} catch {
27+
return false;
28+
}
29+
};
30+
31+
const waitForPreviewAssets = async (node: HTMLElement) => {
32+
try {
33+
await document.fonts?.ready;
34+
} catch {
35+
// Ignore font loading failures and continue with the best available render.
36+
}
37+
38+
const images = Array.from(node.querySelectorAll("img"));
39+
await Promise.all(
40+
images.map(
41+
(image) =>
42+
new Promise<void>((resolve) => {
43+
if (image.complete) {
44+
resolve();
45+
return;
46+
}
47+
48+
image.addEventListener("load", () => resolve(), { once: true });
49+
image.addEventListener("error", () => resolve(), { once: true });
50+
}),
51+
),
52+
);
53+
};
54+
55+
export const buildMemoShareImageFileName = (memoName: string) => {
56+
const suffix = memoName.split("/").pop() ?? "memo";
57+
return `memo-${suffix}.png`;
58+
};
59+
60+
export const getMemoSharePreviewWidth = (cardWidth: number) => {
61+
const viewportWidth =
62+
typeof window === "undefined" ? MEMO_SHARE_IMAGE_CONFIG.maxWidth : window.innerWidth - MEMO_SHARE_IMAGE_CONFIG.viewportMargin;
63+
const baseWidth = cardWidth || viewportWidth;
64+
65+
return clamp(
66+
Math.round(baseWidth * MEMO_SHARE_IMAGE_CONFIG.previewScale),
67+
MEMO_SHARE_IMAGE_CONFIG.minWidth,
68+
MEMO_SHARE_IMAGE_CONFIG.maxWidth,
69+
);
70+
};
71+
72+
export const getMemoShareDialogWidth = (previewWidth: number) => {
73+
const viewportWidth =
74+
typeof window === "undefined" ? previewWidth + MEMO_SHARE_IMAGE_CONFIG.dialogExtraWidth : window.innerWidth - WINDOW_HORIZONTAL_MARGIN;
75+
return Math.min(previewWidth + MEMO_SHARE_IMAGE_CONFIG.dialogExtraWidth, viewportWidth);
76+
};
77+
78+
export const getMemoSharePreviewAvatarUrl = (avatarUrl?: string) => (isExportableImageUrl(avatarUrl) ? avatarUrl : undefined);
79+
80+
export const createMemoShareImageBlob = async (node: HTMLElement) => {
81+
await waitForPreviewAssets(node);
82+
83+
const rect = node.getBoundingClientRect();
84+
const width = Math.ceil(rect.width || node.offsetWidth || node.clientWidth);
85+
const height = Math.ceil(rect.height || node.offsetHeight || node.clientHeight);
86+
87+
const blob = await toBlob(node, {
88+
cacheBust: true,
89+
height,
90+
pixelRatio: Math.max(2, Math.min(window.devicePixelRatio || 1, 3)),
91+
width,
92+
filter: (currentNode) => {
93+
if (!(currentNode instanceof HTMLElement)) {
94+
return true;
95+
}
96+
97+
if (currentNode instanceof HTMLImageElement) {
98+
return isExportableImageUrl(currentNode.currentSrc || currentNode.src);
99+
}
100+
101+
return !(currentNode instanceof HTMLVideoElement);
102+
},
103+
});
104+
105+
if (!blob) {
106+
throw new Error("Failed to render image");
107+
}
108+
109+
return blob;
110+
};

0 commit comments

Comments
 (0)