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",
|
"dayjs": "^1.11.20",
|
||||||
"fuse.js": "^7.1.0",
|
"fuse.js": "^7.1.0",
|
||||||
"highlight.js": "^11.11.1",
|
"highlight.js": "^11.11.1",
|
||||||
|
"html-to-image": "^1.11.13",
|
||||||
"i18next": "^25.8.18",
|
"i18next": "^25.8.18",
|
||||||
"katex": "^0.16.38",
|
"katex": "^0.16.38",
|
||||||
"leaflet": "^1.9.4",
|
"leaflet": "^1.9.4",
|
||||||
|
|
|
||||||
|
|
@ -83,6 +83,9 @@ importers:
|
||||||
highlight.js:
|
highlight.js:
|
||||||
specifier: ^11.11.1
|
specifier: ^11.11.1
|
||||||
version: 11.11.1
|
version: 11.11.1
|
||||||
|
html-to-image:
|
||||||
|
specifier: ^1.11.13
|
||||||
|
version: 1.11.13
|
||||||
i18next:
|
i18next:
|
||||||
specifier: ^25.8.18
|
specifier: ^25.8.18
|
||||||
version: 25.8.18(typescript@6.0.2)
|
version: 25.8.18(typescript@6.0.2)
|
||||||
|
|
@ -2013,6 +2016,9 @@ packages:
|
||||||
html-parse-stringify@3.0.1:
|
html-parse-stringify@3.0.1:
|
||||||
resolution: {integrity: sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==}
|
resolution: {integrity: sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==}
|
||||||
|
|
||||||
|
html-to-image@1.11.13:
|
||||||
|
resolution: {integrity: sha512-cuOPoI7WApyhBElTTb9oqsawRvZ0rHhaHwghRLlTuffoD1B2aDemlCruLeZrUIIdvG7gs9xeELEPm6PhuASqrg==}
|
||||||
|
|
||||||
html-url-attributes@3.0.1:
|
html-url-attributes@3.0.1:
|
||||||
resolution: {integrity: sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==}
|
resolution: {integrity: sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==}
|
||||||
|
|
||||||
|
|
@ -4688,6 +4694,8 @@ snapshots:
|
||||||
dependencies:
|
dependencies:
|
||||||
void-elements: 3.1.0
|
void-elements: 3.1.0
|
||||||
|
|
||||||
|
html-to-image@1.11.13: {}
|
||||||
|
|
||||||
html-url-attributes@3.0.1: {}
|
html-url-attributes@3.0.1: {}
|
||||||
|
|
||||||
html-void-elements@3.0.0: {}
|
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 { create } from "@bufbuild/protobuf";
|
||||||
import { timestampDate } from "@bufbuild/protobuf/wkt";
|
import { timestampDate } from "@bufbuild/protobuf/wkt";
|
||||||
import { isEqual } from "lodash-es";
|
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 { useMemo, useState } from "react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import useCurrentUser from "@/hooks/useCurrentUser";
|
import useCurrentUser from "@/hooks/useCurrentUser";
|
||||||
|
|
@ -16,6 +16,7 @@ import MemoSharePanel from "./MemoSharePanel";
|
||||||
interface Props {
|
interface Props {
|
||||||
memo: Memo;
|
memo: Memo;
|
||||||
className?: string;
|
className?: string;
|
||||||
|
onShareImageOpen?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PropertyBadge {
|
interface PropertyBadge {
|
||||||
|
|
@ -39,7 +40,7 @@ const PROPERTY_BADGE_CLASSES =
|
||||||
const TAG_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";
|
"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 t = useTranslate();
|
||||||
const currentUser = useCurrentUser();
|
const currentUser = useCurrentUser();
|
||||||
const [sharePanelOpen, setSharePanelOpen] = useState(false);
|
const [sharePanelOpen, setSharePanelOpen] = useState(false);
|
||||||
|
|
@ -64,12 +65,22 @@ const MemoDetailSidebar = ({ memo, className }: Props) => {
|
||||||
</SidebarSection>
|
</SidebarSection>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{canManageShares && (
|
{(canManageShares || onShareImageOpen) && (
|
||||||
<SidebarSection label={t("memo.share.section-label")}>
|
<SidebarSection label={t("memo.share.section-label")}>
|
||||||
<Button variant="outline" className="w-full justify-start gap-2" onClick={() => setSharePanelOpen(true)}>
|
<div className="flex flex-col gap-2">
|
||||||
<Share2Icon className="w-4 h-4" />
|
{onShareImageOpen && (
|
||||||
{t("memo.share.open-panel")}
|
<Button variant="outline" className="w-full justify-start gap-2" onClick={onShareImageOpen}>
|
||||||
</Button>
|
<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>
|
</SidebarSection>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,9 +8,10 @@ import MemoDetailSidebar from "./MemoDetailSidebar";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
memo: Memo;
|
memo: Memo;
|
||||||
|
onShareImageOpen?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const MemoDetailSidebarDrawer = ({ memo }: Props) => {
|
const MemoDetailSidebarDrawer = ({ memo, onShareImageOpen }: Props) => {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
|
|
@ -26,7 +27,7 @@ const MemoDetailSidebarDrawer = ({ memo }: Props) => {
|
||||||
</Button>
|
</Button>
|
||||||
</SheetTrigger>
|
</SheetTrigger>
|
||||||
<SheetContent side="right" className="w-full sm:w-80 px-4 bg-background">
|
<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>
|
</SheetContent>
|
||||||
</Sheet>
|
</Sheet>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,7 @@ const STUB_CONTEXT: MemoViewContextValue = {
|
||||||
creator: undefined,
|
creator: undefined,
|
||||||
currentUser: undefined,
|
currentUser: undefined,
|
||||||
parentPage: "/",
|
parentPage: "/",
|
||||||
|
cardWidth: 0,
|
||||||
isArchived: false,
|
isArchived: false,
|
||||||
readonly: true,
|
readonly: true,
|
||||||
showBlurredContent: false,
|
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 { useLocation } from "react-router-dom";
|
||||||
import { useInstance } from "@/contexts/InstanceContext";
|
import { useInstance } from "@/contexts/InstanceContext";
|
||||||
import useCurrentUser from "@/hooks/useCurrentUser";
|
import useCurrentUser from "@/hooks/useCurrentUser";
|
||||||
|
|
@ -7,6 +7,7 @@ import { findTagMetadata } from "@/lib/tag";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { State } from "@/types/proto/api/v1/common_pb";
|
import { State } from "@/types/proto/api/v1/common_pb";
|
||||||
import { isSuperUser } from "@/utils/user";
|
import { isSuperUser } from "@/utils/user";
|
||||||
|
import MemoShareImageDialog from "../MemoActionMenu/MemoShareImageDialog";
|
||||||
import MemoEditor from "../MemoEditor";
|
import MemoEditor from "../MemoEditor";
|
||||||
import PreviewImageDialog from "../PreviewImageDialog";
|
import PreviewImageDialog from "../PreviewImageDialog";
|
||||||
import { MemoBody, MemoCommentListView, MemoHeader } from "./components";
|
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 { memo: memoData, className, parentPage: parentPageProp, compact, showCreator, showVisibility, showPinned } = props;
|
||||||
const cardRef = useRef<HTMLDivElement>(null);
|
const cardRef = useRef<HTMLDivElement>(null);
|
||||||
const [showEditor, setShowEditor] = useState(false);
|
const [showEditor, setShowEditor] = useState(false);
|
||||||
|
const [cardWidth, setCardWidth] = useState(0);
|
||||||
|
|
||||||
const currentUser = useCurrentUser();
|
const currentUser = useCurrentUser();
|
||||||
const { tagsSetting } = useInstance();
|
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 isInMemoDetailPage = location.pathname.startsWith(`/${memoData.name}`) || location.pathname.startsWith("/memos/shares/");
|
||||||
const showCommentPreview = !isInMemoDetailPage && computeCommentAmount(memoData) > 0;
|
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(
|
const contextValue = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
memo: memoData,
|
memo: memoData,
|
||||||
creator,
|
creator,
|
||||||
currentUser,
|
currentUser,
|
||||||
parentPage,
|
parentPage,
|
||||||
|
cardWidth,
|
||||||
isArchived,
|
isArchived,
|
||||||
readonly,
|
readonly,
|
||||||
showBlurredContent,
|
showBlurredContent,
|
||||||
|
|
@ -60,6 +90,7 @@ const MemoView: React.FC<MemoViewProps> = (props: MemoViewProps) => {
|
||||||
creator,
|
creator,
|
||||||
currentUser,
|
currentUser,
|
||||||
parentPage,
|
parentPage,
|
||||||
|
cardWidth,
|
||||||
isArchived,
|
isArchived,
|
||||||
readonly,
|
readonly,
|
||||||
showBlurredContent,
|
showBlurredContent,
|
||||||
|
|
@ -100,6 +131,10 @@ const MemoView: React.FC<MemoViewProps> = (props: MemoViewProps) => {
|
||||||
items={previewState.items}
|
items={previewState.items}
|
||||||
initialIndex={previewState.index}
|
initialIndex={previewState.index}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{props.onShareImageDialogOpenChange && (
|
||||||
|
<MemoShareImageDialog open={Boolean(props.shareImageDialogOpen)} onOpenChange={props.onShareImageDialogOpenChange} />
|
||||||
|
)}
|
||||||
</article>
|
</article>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ export interface MemoViewContextValue {
|
||||||
creator: User | undefined;
|
creator: User | undefined;
|
||||||
currentUser: User | undefined;
|
currentUser: User | undefined;
|
||||||
parentPage: string;
|
parentPage: string;
|
||||||
|
cardWidth: number;
|
||||||
isArchived: boolean;
|
isArchived: boolean;
|
||||||
readonly: boolean;
|
readonly: boolean;
|
||||||
showBlurredContent: boolean;
|
showBlurredContent: boolean;
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,8 @@ export interface MemoViewProps {
|
||||||
showPinned?: boolean;
|
showPinned?: boolean;
|
||||||
className?: string;
|
className?: string;
|
||||||
parentPage?: string;
|
parentPage?: string;
|
||||||
|
shareImageDialogOpen?: boolean;
|
||||||
|
onShareImageDialogOpenChange?: (open: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MemoHeaderProps {
|
export interface MemoHeaderProps {
|
||||||
|
|
|
||||||
|
|
@ -249,10 +249,19 @@
|
||||||
"expiry-7-days": "7 days",
|
"expiry-7-days": "7 days",
|
||||||
"expiry-label": "Expires",
|
"expiry-label": "Expires",
|
||||||
"expiry-never": "Never",
|
"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}}",
|
"expires-on": "Expires {{date}}",
|
||||||
"invalid-link": "This link is invalid or has expired.",
|
"invalid-link": "This link is invalid or has expired.",
|
||||||
"never-expires": "Never expires",
|
"never-expires": "Never expires",
|
||||||
"no-links": "No share links yet. Create one below.",
|
"no-links": "No share links yet. Create one below.",
|
||||||
|
"open-image": "Share as image",
|
||||||
"open-panel": "Manage share links",
|
"open-panel": "Manage share links",
|
||||||
"revoke": "Revoke",
|
"revoke": "Revoke",
|
||||||
"revoke-failed": "Failed to revoke link",
|
"revoke-failed": "Failed to revoke link",
|
||||||
|
|
|
||||||
|
|
@ -221,10 +221,19 @@
|
||||||
"expiry-7-days": "7天",
|
"expiry-7-days": "7天",
|
||||||
"expiry-label": "到期时间",
|
"expiry-label": "到期时间",
|
||||||
"expiry-never": "永不",
|
"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}} 到期",
|
"expires-on": "{{date}} 到期",
|
||||||
"invalid-link": "该链接无效或已过期。",
|
"invalid-link": "该链接无效或已过期。",
|
||||||
"never-expires": "永不过期",
|
"never-expires": "永不过期",
|
||||||
"no-links": "还没有分享链接。在下面创建一个。",
|
"no-links": "还没有分享链接。在下面创建一个。",
|
||||||
|
"open-image": "分享成图片",
|
||||||
"open-panel": "管理分享链接",
|
"open-panel": "管理分享链接",
|
||||||
"revoke": "撤销",
|
"revoke": "撤销",
|
||||||
"revoke-failed": "撤销链接失败",
|
"revoke-failed": "撤销链接失败",
|
||||||
|
|
|
||||||
|
|
@ -221,10 +221,19 @@
|
||||||
"expiry-7-days": "7天",
|
"expiry-7-days": "7天",
|
||||||
"expiry-label": "到期時間",
|
"expiry-label": "到期時間",
|
||||||
"expiry-never": "永不",
|
"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}} 到期",
|
"expires-on": "{{date}} 到期",
|
||||||
"invalid-link": "該連結無效或已過期。",
|
"invalid-link": "該連結無效或已過期。",
|
||||||
"never-expires": "永不過期",
|
"never-expires": "永不過期",
|
||||||
"no-links": "還沒有分享連結,請在下方建立。",
|
"no-links": "還沒有分享連結,請在下方建立。",
|
||||||
|
"open-image": "分享成圖片",
|
||||||
"open-panel": "管理分享連結",
|
"open-panel": "管理分享連結",
|
||||||
"revoke": "撤銷",
|
"revoke": "撤銷",
|
||||||
"revoke-failed": "撤銷連結失敗",
|
"revoke-failed": "撤銷連結失敗",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { Code, ConnectError } from "@connectrpc/connect";
|
import { Code, ConnectError } from "@connectrpc/connect";
|
||||||
import { ArrowUpLeftFromCircleIcon } from "lucide-react";
|
import { ArrowUpLeftFromCircleIcon } from "lucide-react";
|
||||||
import { useEffect } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { Link, Navigate, useLocation, useParams } from "react-router-dom";
|
import { Link, Navigate, useLocation, useParams } from "react-router-dom";
|
||||||
import MemoCommentSection from "@/components/MemoCommentSection";
|
import MemoCommentSection from "@/components/MemoCommentSection";
|
||||||
import { MemoDetailSidebar, MemoDetailSidebarDrawer } from "@/components/MemoDetailSidebar";
|
import { MemoDetailSidebar, MemoDetailSidebarDrawer } from "@/components/MemoDetailSidebar";
|
||||||
|
|
@ -16,6 +16,7 @@ import type { Attachment } from "@/types/proto/api/v1/attachment_service_pb";
|
||||||
|
|
||||||
const MemoDetail = () => {
|
const MemoDetail = () => {
|
||||||
const md = useMediaQuery("md");
|
const md = useMediaQuery("md");
|
||||||
|
const [shareImageDialogOpen, setShareImageDialogOpen] = useState(false);
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const { state: locationState, hash } = location;
|
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">
|
<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 && (
|
{!md && (
|
||||||
<MobileHeader>
|
<MobileHeader>
|
||||||
<MemoDetailSidebarDrawer memo={displayMemo} />
|
<MemoDetailSidebarDrawer memo={displayMemo} onShareImageOpen={() => setShareImageDialogOpen(true)} />
|
||||||
</MobileHeader>
|
</MobileHeader>
|
||||||
)}
|
)}
|
||||||
<div className={cn("w-full flex flex-row justify-start items-start px-4 sm:px-6 gap-4")}>
|
<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}
|
memo={displayMemo}
|
||||||
compact={false}
|
compact={false}
|
||||||
parentPage={locationState?.from}
|
parentPage={locationState?.from}
|
||||||
|
shareImageDialogOpen={shareImageDialogOpen}
|
||||||
showCreator
|
showCreator
|
||||||
showVisibility
|
showVisibility
|
||||||
showPinned
|
showPinned
|
||||||
|
onShareImageDialogOpenChange={setShareImageDialogOpen}
|
||||||
/>
|
/>
|
||||||
<MemoCommentSection memo={displayMemo} comments={comments} parentPage={locationState?.from} />
|
<MemoCommentSection memo={displayMemo} comments={comments} parentPage={locationState?.from} />
|
||||||
</div>
|
</div>
|
||||||
{md && (
|
{md && (
|
||||||
<div className="sticky top-0 left-0 shrink-0 -mt-6 w-56 h-full">
|
<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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue