From 38fc22b7541b8a9ddcd848cf77054fcf844eb87f Mon Sep 17 00:00:00 2001 From: boojack Date: Mon, 6 Apr 2026 18:30:01 +0800 Subject: [PATCH] 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 --- web/package.json | 1 + web/pnpm-lock.yaml | 8 ++ .../MemoActionMenu/MemoShareImageDialog.tsx | 114 ++++++++++++++++++ .../MemoActionMenu/MemoShareImagePreview.tsx | 113 +++++++++++++++++ .../MemoActionMenu/memoShareImage.ts | 110 +++++++++++++++++ .../MemoDetailSidebar/MemoDetailSidebar.tsx | 25 ++-- .../MemoDetailSidebarDrawer.tsx | 5 +- .../components/MemoPreview/MemoPreview.tsx | 1 + web/src/components/MemoView/MemoView.tsx | 37 +++++- .../components/MemoView/MemoViewContext.tsx | 1 + web/src/components/MemoView/types.ts | 2 + web/src/locales/en.json | 9 ++ web/src/locales/zh-Hans.json | 9 ++ web/src/locales/zh-Hant.json | 9 ++ web/src/pages/MemoDetail.tsx | 9 +- 15 files changed, 440 insertions(+), 13 deletions(-) create mode 100644 web/src/components/MemoActionMenu/MemoShareImageDialog.tsx create mode 100644 web/src/components/MemoActionMenu/MemoShareImagePreview.tsx create mode 100644 web/src/components/MemoActionMenu/memoShareImage.ts diff --git a/web/package.json b/web/package.json index 561114057..7b5031f5c 100644 --- a/web/package.json +++ b/web/package.json @@ -38,6 +38,7 @@ "dayjs": "^1.11.20", "fuse.js": "^7.1.0", "highlight.js": "^11.11.1", + "html-to-image": "^1.11.13", "i18next": "^25.8.18", "katex": "^0.16.38", "leaflet": "^1.9.4", diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index 39a13a1f3..805e5a6c1 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -83,6 +83,9 @@ importers: highlight.js: specifier: ^11.11.1 version: 11.11.1 + html-to-image: + specifier: ^1.11.13 + version: 1.11.13 i18next: specifier: ^25.8.18 version: 25.8.18(typescript@6.0.2) @@ -2013,6 +2016,9 @@ packages: html-parse-stringify@3.0.1: resolution: {integrity: sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==} + html-to-image@1.11.13: + resolution: {integrity: sha512-cuOPoI7WApyhBElTTb9oqsawRvZ0rHhaHwghRLlTuffoD1B2aDemlCruLeZrUIIdvG7gs9xeELEPm6PhuASqrg==} + html-url-attributes@3.0.1: resolution: {integrity: sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==} @@ -4688,6 +4694,8 @@ snapshots: dependencies: void-elements: 3.1.0 + html-to-image@1.11.13: {} + html-url-attributes@3.0.1: {} html-void-elements@3.0.0: {} diff --git a/web/src/components/MemoActionMenu/MemoShareImageDialog.tsx b/web/src/components/MemoActionMenu/MemoShareImageDialog.tsx new file mode 100644 index 000000000..3427a41de --- /dev/null +++ b/web/src/components/MemoActionMenu/MemoShareImageDialog.tsx @@ -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(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 ( + + + + + + {t("memo.share.image-title")} + + {t("memo.share.image-description", { width: previewWidth })} + + +
+ +
+ + + {supportsNativeShare && ( + + )} + + +
+
+ ); +}; + +export default MemoShareImageDialog; diff --git a/web/src/components/MemoActionMenu/MemoShareImagePreview.tsx b/web/src/components/MemoActionMenu/MemoShareImagePreview.tsx new file mode 100644 index 000000000..e86617cc3 --- /dev/null +++ b/web/src/components/MemoActionMenu/MemoShareImagePreview.tsx @@ -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(({ 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 ( +
+
+
+ +
+
+
+ +
+
{displayName}
+ {formattedDisplayTime &&
{formattedDisplayTime}
} +
+
+
+ +
+
+ +
+
+ + {visualItems.length > 0 && ( +
+ {visualItems.slice(0, 4).map((item, index) => ( +
+ {item.filename} + {index === 3 && visualItems.length > 4 && ( +
+ +{visualItems.length - 4} +
+ )} +
+ ))} +
+ )} + + {(memo.tags.length > 0 || nonVisualAttachmentCount > 0) && ( +
+ {memo.tags.slice(0, 3).map((tag) => ( + + #{tag} + + ))} + {memo.tags.length > 3 && ( + + +{memo.tags.length - 3} + + )} + {nonVisualAttachmentCount > 0 && ( + + {attachmentCount} {t("common.attachments").toLowerCase()} + + )} +
+ )} +
+
+ ); +}); + +MemoShareImagePreview.displayName = "MemoShareImagePreview"; + +export default MemoShareImagePreview; diff --git a/web/src/components/MemoActionMenu/memoShareImage.ts b/web/src/components/MemoActionMenu/memoShareImage.ts new file mode 100644 index 000000000..92b744d60 --- /dev/null +++ b/web/src/components/MemoActionMenu/memoShareImage.ts @@ -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((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; +}; diff --git a/web/src/components/MemoDetailSidebar/MemoDetailSidebar.tsx b/web/src/components/MemoDetailSidebar/MemoDetailSidebar.tsx index 18b09fab4..c944bca6b 100644 --- a/web/src/components/MemoDetailSidebar/MemoDetailSidebar.tsx +++ b/web/src/components/MemoDetailSidebar/MemoDetailSidebar.tsx @@ -1,7 +1,7 @@ import { create } from "@bufbuild/protobuf"; import { timestampDate } from "@bufbuild/protobuf/wkt"; import { isEqual } from "lodash-es"; -import { CheckCircleIcon, Code2Icon, HashIcon, LinkIcon, type LucideIcon, Share2Icon } from "lucide-react"; +import { CheckCircleIcon, Code2Icon, HashIcon, ImageIcon, LinkIcon, type LucideIcon, Share2Icon } from "lucide-react"; import { useMemo, useState } from "react"; import { Button } from "@/components/ui/button"; import useCurrentUser from "@/hooks/useCurrentUser"; @@ -16,6 +16,7 @@ import MemoSharePanel from "./MemoSharePanel"; interface Props { memo: Memo; className?: string; + onShareImageOpen?: () => void; } interface PropertyBadge { @@ -39,7 +40,7 @@ const PROPERTY_BADGE_CLASSES = const TAG_BADGE_CLASSES = "inline-flex items-center gap-1 px-1 rounded-md border border-border/60 bg-muted/60 text-sm text-muted-foreground hover:bg-muted hover:text-foreground/80 transition-colors cursor-pointer"; -const MemoDetailSidebar = ({ memo, className }: Props) => { +const MemoDetailSidebar = ({ memo, className, onShareImageOpen }: Props) => { const t = useTranslate(); const currentUser = useCurrentUser(); const [sharePanelOpen, setSharePanelOpen] = useState(false); @@ -64,12 +65,22 @@ const MemoDetailSidebar = ({ memo, className }: Props) => { )} - {canManageShares && ( + {(canManageShares || onShareImageOpen) && ( - +
+ {onShareImageOpen && ( + + )} + {canManageShares && ( + + )} +
)} diff --git a/web/src/components/MemoDetailSidebar/MemoDetailSidebarDrawer.tsx b/web/src/components/MemoDetailSidebar/MemoDetailSidebarDrawer.tsx index 9c058a9d3..9a36774fd 100644 --- a/web/src/components/MemoDetailSidebar/MemoDetailSidebarDrawer.tsx +++ b/web/src/components/MemoDetailSidebar/MemoDetailSidebarDrawer.tsx @@ -8,9 +8,10 @@ import MemoDetailSidebar from "./MemoDetailSidebar"; interface Props { memo: Memo; + onShareImageOpen?: () => void; } -const MemoDetailSidebarDrawer = ({ memo }: Props) => { +const MemoDetailSidebarDrawer = ({ memo, onShareImageOpen }: Props) => { const location = useLocation(); const [open, setOpen] = useState(false); @@ -26,7 +27,7 @@ const MemoDetailSidebarDrawer = ({ memo }: Props) => { - + ); diff --git a/web/src/components/MemoPreview/MemoPreview.tsx b/web/src/components/MemoPreview/MemoPreview.tsx index 5da49f3ba..53a4549b0 100644 --- a/web/src/components/MemoPreview/MemoPreview.tsx +++ b/web/src/components/MemoPreview/MemoPreview.tsx @@ -27,6 +27,7 @@ const STUB_CONTEXT: MemoViewContextValue = { creator: undefined, currentUser: undefined, parentPage: "/", + cardWidth: 0, isArchived: false, readonly: true, showBlurredContent: false, diff --git a/web/src/components/MemoView/MemoView.tsx b/web/src/components/MemoView/MemoView.tsx index e5c43997a..7f45321ee 100644 --- a/web/src/components/MemoView/MemoView.tsx +++ b/web/src/components/MemoView/MemoView.tsx @@ -1,4 +1,4 @@ -import { memo, useCallback, useMemo, useRef, useState } from "react"; +import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useLocation } from "react-router-dom"; import { useInstance } from "@/contexts/InstanceContext"; import useCurrentUser from "@/hooks/useCurrentUser"; @@ -7,6 +7,7 @@ import { findTagMetadata } from "@/lib/tag"; import { cn } from "@/lib/utils"; import { State } from "@/types/proto/api/v1/common_pb"; import { isSuperUser } from "@/utils/user"; +import MemoShareImageDialog from "../MemoActionMenu/MemoShareImageDialog"; import MemoEditor from "../MemoEditor"; import PreviewImageDialog from "../PreviewImageDialog"; import { MemoBody, MemoCommentListView, MemoHeader } from "./components"; @@ -19,6 +20,7 @@ const MemoView: React.FC = (props: MemoViewProps) => { const { memo: memoData, className, parentPage: parentPageProp, compact, showCreator, showVisibility, showPinned } = props; const cardRef = useRef(null); const [showEditor, setShowEditor] = useState(false); + const [cardWidth, setCardWidth] = useState(0); const currentUser = useCurrentUser(); const { tagsSetting } = useInstance(); @@ -41,12 +43,40 @@ const MemoView: React.FC = (props: MemoViewProps) => { const isInMemoDetailPage = location.pathname.startsWith(`/${memoData.name}`) || location.pathname.startsWith("/memos/shares/"); const showCommentPreview = !isInMemoDetailPage && computeCommentAmount(memoData) > 0; + useEffect(() => { + const card = cardRef.current; + if (!card) { + return; + } + + const updateWidth = (nextWidth?: number) => { + const width = Math.round(nextWidth ?? card.getBoundingClientRect().width); + setCardWidth((prev) => (prev === width ? prev : width)); + }; + + updateWidth(); + + if (typeof ResizeObserver === "undefined") { + const handleResize = () => updateWidth(); + window.addEventListener("resize", handleResize); + return () => window.removeEventListener("resize", handleResize); + } + + const resizeObserver = new ResizeObserver((entries) => { + updateWidth(entries[0]?.contentRect.width); + }); + + resizeObserver.observe(card); + return () => resizeObserver.disconnect(); + }, []); + const contextValue = useMemo( () => ({ memo: memoData, creator, currentUser, parentPage, + cardWidth, isArchived, readonly, showBlurredContent, @@ -60,6 +90,7 @@ const MemoView: React.FC = (props: MemoViewProps) => { creator, currentUser, parentPage, + cardWidth, isArchived, readonly, showBlurredContent, @@ -100,6 +131,10 @@ const MemoView: React.FC = (props: MemoViewProps) => { items={previewState.items} initialIndex={previewState.index} /> + + {props.onShareImageDialogOpenChange && ( + + )} ); diff --git a/web/src/components/MemoView/MemoViewContext.tsx b/web/src/components/MemoView/MemoViewContext.tsx index 79413313e..569934b11 100644 --- a/web/src/components/MemoView/MemoViewContext.tsx +++ b/web/src/components/MemoView/MemoViewContext.tsx @@ -12,6 +12,7 @@ export interface MemoViewContextValue { creator: User | undefined; currentUser: User | undefined; parentPage: string; + cardWidth: number; isArchived: boolean; readonly: boolean; showBlurredContent: boolean; diff --git a/web/src/components/MemoView/types.ts b/web/src/components/MemoView/types.ts index 0025e12fb..b99ca73c4 100644 --- a/web/src/components/MemoView/types.ts +++ b/web/src/components/MemoView/types.ts @@ -8,6 +8,8 @@ export interface MemoViewProps { showPinned?: boolean; className?: string; parentPage?: string; + shareImageDialogOpen?: boolean; + onShareImageDialogOpenChange?: (open: boolean) => void; } export interface MemoHeaderProps { diff --git a/web/src/locales/en.json b/web/src/locales/en.json index 34686cebc..492dbf601 100644 --- a/web/src/locales/en.json +++ b/web/src/locales/en.json @@ -249,10 +249,19 @@ "expiry-7-days": "7 days", "expiry-label": "Expires", "expiry-never": "Never", + "image-description": "Preview rendered at your current device width ({{width}}px).", + "image-download": "Download PNG", + "image-download-failed": "Failed to export image", + "image-downloaded": "Image saved", + "image-footer": "Shared from Memos", + "image-share": "Share image", + "image-share-failed": "Failed to share image", + "image-title": "Share as image", "expires-on": "Expires {{date}}", "invalid-link": "This link is invalid or has expired.", "never-expires": "Never expires", "no-links": "No share links yet. Create one below.", + "open-image": "Share as image", "open-panel": "Manage share links", "revoke": "Revoke", "revoke-failed": "Failed to revoke link", diff --git a/web/src/locales/zh-Hans.json b/web/src/locales/zh-Hans.json index 5f814637c..5c8fa6626 100644 --- a/web/src/locales/zh-Hans.json +++ b/web/src/locales/zh-Hans.json @@ -221,10 +221,19 @@ "expiry-7-days": "7天", "expiry-label": "到期时间", "expiry-never": "永不", + "image-description": "预览会按你当前设备上的展示宽度({{width}}px)渲染。", + "image-download": "下载 PNG", + "image-download-failed": "导出图片失败", + "image-downloaded": "图片已保存", + "image-footer": "由 Memos 生成分享图", + "image-share": "分享图片", + "image-share-failed": "分享图片失败", + "image-title": "分享成图片", "expires-on": "{{date}} 到期", "invalid-link": "该链接无效或已过期。", "never-expires": "永不过期", "no-links": "还没有分享链接。在下面创建一个。", + "open-image": "分享成图片", "open-panel": "管理分享链接", "revoke": "撤销", "revoke-failed": "撤销链接失败", diff --git a/web/src/locales/zh-Hant.json b/web/src/locales/zh-Hant.json index 549fb4882..da7616fd0 100644 --- a/web/src/locales/zh-Hant.json +++ b/web/src/locales/zh-Hant.json @@ -221,10 +221,19 @@ "expiry-7-days": "7天", "expiry-label": "到期時間", "expiry-never": "永不", + "image-description": "預覽會依照你目前裝置上的顯示寬度({{width}}px)渲染。", + "image-download": "下載 PNG", + "image-download-failed": "匯出圖片失敗", + "image-downloaded": "圖片已儲存", + "image-footer": "由 Memos 產生分享圖", + "image-share": "分享圖片", + "image-share-failed": "分享圖片失敗", + "image-title": "分享成圖片", "expires-on": "{{date}} 到期", "invalid-link": "該連結無效或已過期。", "never-expires": "永不過期", "no-links": "還沒有分享連結,請在下方建立。", + "open-image": "分享成圖片", "open-panel": "管理分享連結", "revoke": "撤銷", "revoke-failed": "撤銷連結失敗", diff --git a/web/src/pages/MemoDetail.tsx b/web/src/pages/MemoDetail.tsx index 9f1776beb..4b60f885f 100644 --- a/web/src/pages/MemoDetail.tsx +++ b/web/src/pages/MemoDetail.tsx @@ -1,6 +1,6 @@ import { Code, ConnectError } from "@connectrpc/connect"; import { ArrowUpLeftFromCircleIcon } from "lucide-react"; -import { useEffect } from "react"; +import { useEffect, useState } from "react"; import { Link, Navigate, useLocation, useParams } from "react-router-dom"; import MemoCommentSection from "@/components/MemoCommentSection"; import { MemoDetailSidebar, MemoDetailSidebarDrawer } from "@/components/MemoDetailSidebar"; @@ -16,6 +16,7 @@ import type { Attachment } from "@/types/proto/api/v1/attachment_service_pb"; const MemoDetail = () => { const md = useMediaQuery("md"); + const [shareImageDialogOpen, setShareImageDialogOpen] = useState(false); const params = useParams(); const location = useLocation(); const { state: locationState, hash } = location; @@ -77,7 +78,7 @@ const MemoDetail = () => {
{!md && ( - + setShareImageDialogOpen(true)} /> )}
@@ -100,15 +101,17 @@ const MemoDetail = () => { memo={displayMemo} compact={false} parentPage={locationState?.from} + shareImageDialogOpen={shareImageDialogOpen} showCreator showVisibility showPinned + onShareImageDialogOpenChange={setShareImageDialogOpen} />
{md && (
- + setShareImageDialogOpen(true)} />
)}