mirror of https://github.com/usememos/memos.git
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
This commit is contained in:
parent
2cbc70762b
commit
38fc22b754
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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: {}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
|
@ -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) => {
|
|||
</SidebarSection>
|
||||
)}
|
||||
|
||||
{canManageShares && (
|
||||
{(canManageShares || onShareImageOpen) && (
|
||||
<SidebarSection label={t("memo.share.section-label")}>
|
||||
<Button variant="outline" className="w-full justify-start gap-2" onClick={() => setSharePanelOpen(true)}>
|
||||
<Share2Icon className="w-4 h-4" />
|
||||
{t("memo.share.open-panel")}
|
||||
</Button>
|
||||
<div className="flex flex-col gap-2">
|
||||
{onShareImageOpen && (
|
||||
<Button variant="outline" className="w-full justify-start gap-2" onClick={onShareImageOpen}>
|
||||
<ImageIcon className="w-4 h-4" />
|
||||
{t("memo.share.open-image")}
|
||||
</Button>
|
||||
)}
|
||||
{canManageShares && (
|
||||
<Button variant="outline" className="w-full justify-start gap-2" onClick={() => setSharePanelOpen(true)}>
|
||||
<Share2Icon className="w-4 h-4" />
|
||||
{t("memo.share.open-panel")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</SidebarSection>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -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) => {
|
|||
</Button>
|
||||
</SheetTrigger>
|
||||
<SheetContent side="right" className="w-full sm:w-80 px-4 bg-background">
|
||||
<MemoDetailSidebar className="py-4" memo={memo} />
|
||||
<MemoDetailSidebar className="py-4" memo={memo} onShareImageOpen={onShareImageOpen} />
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ const STUB_CONTEXT: MemoViewContextValue = {
|
|||
creator: undefined,
|
||||
currentUser: undefined,
|
||||
parentPage: "/",
|
||||
cardWidth: 0,
|
||||
isArchived: false,
|
||||
readonly: true,
|
||||
showBlurredContent: false,
|
||||
|
|
|
|||
|
|
@ -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<MemoViewProps> = (props: MemoViewProps) => {
|
|||
const { memo: memoData, className, parentPage: parentPageProp, compact, showCreator, showVisibility, showPinned } = props;
|
||||
const cardRef = useRef<HTMLDivElement>(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<MemoViewProps> = (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<MemoViewProps> = (props: MemoViewProps) => {
|
|||
creator,
|
||||
currentUser,
|
||||
parentPage,
|
||||
cardWidth,
|
||||
isArchived,
|
||||
readonly,
|
||||
showBlurredContent,
|
||||
|
|
@ -100,6 +131,10 @@ const MemoView: React.FC<MemoViewProps> = (props: MemoViewProps) => {
|
|||
items={previewState.items}
|
||||
initialIndex={previewState.index}
|
||||
/>
|
||||
|
||||
{props.onShareImageDialogOpenChange && (
|
||||
<MemoShareImageDialog open={Boolean(props.shareImageDialogOpen)} onOpenChange={props.onShareImageDialogOpenChange} />
|
||||
)}
|
||||
</article>
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ export interface MemoViewContextValue {
|
|||
creator: User | undefined;
|
||||
currentUser: User | undefined;
|
||||
parentPage: string;
|
||||
cardWidth: number;
|
||||
isArchived: boolean;
|
||||
readonly: boolean;
|
||||
showBlurredContent: boolean;
|
||||
|
|
|
|||
|
|
@ -8,6 +8,8 @@ export interface MemoViewProps {
|
|||
showPinned?: boolean;
|
||||
className?: string;
|
||||
parentPage?: string;
|
||||
shareImageDialogOpen?: boolean;
|
||||
onShareImageDialogOpenChange?: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export interface MemoHeaderProps {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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": "撤销链接失败",
|
||||
|
|
|
|||
|
|
@ -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": "撤銷連結失敗",
|
||||
|
|
|
|||
|
|
@ -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 = () => {
|
|||
<section className="@container w-full max-w-5xl min-h-full flex flex-col justify-start items-center sm:pt-3 md:pt-6 pb-8">
|
||||
{!md && (
|
||||
<MobileHeader>
|
||||
<MemoDetailSidebarDrawer memo={displayMemo} />
|
||||
<MemoDetailSidebarDrawer memo={displayMemo} onShareImageOpen={() => setShareImageDialogOpen(true)} />
|
||||
</MobileHeader>
|
||||
)}
|
||||
<div className={cn("w-full flex flex-row justify-start items-start px-4 sm:px-6 gap-4")}>
|
||||
|
|
@ -100,15 +101,17 @@ const MemoDetail = () => {
|
|||
memo={displayMemo}
|
||||
compact={false}
|
||||
parentPage={locationState?.from}
|
||||
shareImageDialogOpen={shareImageDialogOpen}
|
||||
showCreator
|
||||
showVisibility
|
||||
showPinned
|
||||
onShareImageDialogOpenChange={setShareImageDialogOpen}
|
||||
/>
|
||||
<MemoCommentSection memo={displayMemo} comments={comments} parentPage={locationState?.from} />
|
||||
</div>
|
||||
{md && (
|
||||
<div className="sticky top-0 left-0 shrink-0 -mt-6 w-56 h-full">
|
||||
<MemoDetailSidebar className="py-6" memo={displayMemo} />
|
||||
<MemoDetailSidebar className="py-6" memo={displayMemo} onShareImageOpen={() => setShareImageDialogOpen(true)} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Reference in New Issue