This commit is contained in:
Ayasy El Ghofiqi 2025-12-16 14:02:59 +07:00 committed by GitHub
commit 708d246806
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 788 additions and 12 deletions

View File

@ -0,0 +1,46 @@
package httpgetter
import (
"strings"
"github.com/pkg/errors"
"golang.org/x/net/html"
"golang.org/x/net/html/atom"
)
// GetFirstImageURL returns the first <img src> found on the page, or empty string.
func GetFirstImageURL(urlStr string) (string, error) {
if err := validateURL(urlStr); err != nil {
return "", err
}
resp, err := httpClient.Get(urlStr)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
return "", errors.Errorf("failed to fetch page: status %d", resp.StatusCode)
}
tokenizer := html.NewTokenizer(resp.Body)
for {
tt := tokenizer.Next()
if tt == html.ErrorToken {
break
}
if tt == html.StartTagToken || tt == html.SelfClosingTagToken {
token := tokenizer.Token()
if token.DataAtom == atom.Img {
for _, attr := range token.Attr {
if strings.EqualFold(attr.Key, "src") && attr.Val != "" {
return attr.Val, nil
}
}
}
}
}
return "", nil
}

View File

@ -43,6 +43,10 @@ func GetHTMLMeta(urlStr string) (*HTMLMeta, error) {
}
defer response.Body.Close()
if response.StatusCode >= 400 {
return nil, errors.Errorf("failed to fetch page: status %d", response.StatusCode)
}
mediatype, err := getMediatype(response)
if err != nil {
return nil, err

View File

@ -0,0 +1,61 @@
syntax = "proto3";
package memos.api.v1;
import "google/api/annotations.proto";
import "google/api/client.proto";
import "google/api/field_behavior.proto";
import "google/api/resource.proto";
option go_package = "gen/api/v1";
service LinkService {
// GetLinkPreview fetches preview metadata for a URL (title, description, image).
rpc GetLinkPreview(GetLinkPreviewRequest) returns (GetLinkPreviewResponse) {
option (google.api.http) = {
get: "/api/v1/link:preview"
};
option (google.api.method_signature) = "url";
}
}
message LinkPreview {
option (google.api.resource) = {
type: "memos.api.v1/LinkPreview"
pattern: "linkPreviews/{link_preview}"
name_field: "name"
singular: "linkPreview"
plural: "linkPreviews"
};
// Resource name of the preview (server generated).
// Format: linkPreviews/{link_preview}
string name = 1 [
(google.api.field_behavior) = OUTPUT_ONLY,
(google.api.field_behavior) = IDENTIFIER
];
// The original URL that was fetched.
string url = 2 [(google.api.field_behavior) = REQUIRED];
// Extracted title of the page.
string title = 3;
// Extracted description of the page.
string description = 4;
// Resolved image URL for preview.
string image_url = 5;
// Human readable site/host name.
string site_name = 6;
}
message GetLinkPreviewRequest {
// URL to fetch metadata from.
string url = 1 [(google.api.field_behavior) = REQUIRED];
}
message GetLinkPreviewResponse {
LinkPreview preview = 1 [(google.api.field_behavior) = REQUIRED];
}

View File

@ -0,0 +1,117 @@
package v1
import (
"net/http"
"net/url"
"path"
"github.com/labstack/echo/v4"
"github.com/usememos/memos/plugin/httpgetter"
)
// RegisterLinkRoutes registers lightweight HTTP routes for link previews.
// We keep this as a REST handler (not gRPC) to avoid schema churn
// and to reuse existing safety checks in the httpgetter plugin.
func (s *APIV1Service) RegisterLinkRoutes(g *echo.Group) {
g.GET("/api/v1/link:preview", s.handleGetLinkPreview)
}
type linkPreviewResponse struct {
Preview linkPreview `json:"preview"`
}
type linkPreview struct {
Title string `json:"title"`
Description string `json:"description"`
ImageURL string `json:"imageUrl"`
SiteName string `json:"siteName"`
URL string `json:"url"`
}
func (s *APIV1Service) handleGetLinkPreview(c echo.Context) error {
_ = s
rawURL := c.QueryParam("url")
if rawURL == "" {
return echo.NewHTTPError(http.StatusBadRequest, "url is required")
}
meta, err := httpgetter.GetHTMLMeta(rawURL)
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, err.Error())
}
parsedURL, _ := url.Parse(rawURL)
siteName := ""
if parsedURL != nil {
siteName = parsedURL.Hostname()
}
imageURL := meta.Image
if parsedURL != nil && imageURL != "" {
if u, err := url.Parse(imageURL); err == nil {
if !u.IsAbs() {
// handle protocol-relative
if u.Host != "" {
u.Scheme = parsedURL.Scheme
imageURL = u.String()
} else {
// relative path -> join with base
u.Scheme = parsedURL.Scheme
u.Host = parsedURL.Host
if !path.IsAbs(u.Path) {
u.Path = path.Join(parsedURL.Path, "..", u.Path)
}
imageURL = u.String()
}
}
}
}
// If meta image missing, try first <img> on page.
if imageURL == "" {
if firstImg, err := httpgetter.GetFirstImageURL(rawURL); err == nil && firstImg != "" {
if parsedURL != nil {
imageURL = toAbsoluteFromBase(parsedURL, firstImg)
} else {
imageURL = firstImg
}
}
}
resp := linkPreviewResponse{
Preview: linkPreview{
Title: meta.Title,
Description: meta.Description,
ImageURL: imageURL,
SiteName: siteName,
URL: rawURL,
},
}
return c.JSON(http.StatusOK, resp)
}
func toAbsoluteFromBase(base *url.URL, raw string) string {
if raw == "" || base == nil {
return raw
}
u, err := url.Parse(raw)
if err != nil {
return raw
}
if u.IsAbs() {
return u.String()
}
// Protocol-relative //host/path
if u.Host != "" && u.Scheme == "" {
u.Scheme = base.Scheme
return u.String()
}
// Pure relative path
u.Scheme = base.Scheme
u.Host = base.Host
if !path.IsAbs(u.Path) {
u.Path = path.Join(path.Dir(base.Path), u.Path)
}
return u.String()
}

View File

@ -76,6 +76,8 @@ func NewServer(ctx context.Context, profile *profile.Profile, store *store.Store
// Create and register RSS routes (needs markdown service from apiV1Service).
rss.NewRSSService(s.Profile, s.Store, apiV1Service.MarkdownService).RegisterRoutes(rootGroup)
// Link preview helper route (REST).
apiV1Service.RegisterLinkRoutes(rootGroup)
// Register gRPC gateway as api v1.
if err := apiV1Service.RegisterGateway(ctx, echoServer); err != nil {
return nil, errors.Wrap(err, "failed to register gRPC gateway")

View File

@ -0,0 +1,33 @@
import { type LinkPreview, LinkPreviewCard } from "@/components/memo-metadata";
interface LinkPreviewBlockProps extends React.HTMLAttributes<HTMLDivElement> {
node?: any;
}
const LinkPreviewBlock = ({ node, ...rest }: LinkPreviewBlockProps) => {
const props = node?.properties || {};
const preview: LinkPreview = {
id: props["data-id"] || props["dataId"] || cryptoId(),
url: props["data-url"] || props.dataUrl || "",
title: props["data-title"] || props.dataTitle || "Link preview",
description: props["data-description"] || props.dataDescription || "",
imageUrl: props["data-image"] || props.dataImage || "",
siteName: props["data-site"] || props.dataSite || props["data-site-name"],
};
return (
<div {...rest}>
<LinkPreviewCard preview={preview} mode="view" />
</div>
);
};
export const isLinkPreviewNode = (node: any): boolean => {
return node?.properties?.["data-memo-link-preview"] === "true" || node?.properties?.dataMemoLinkPreview === "true";
};
function cryptoId(): string {
return typeof crypto !== "undefined" && "randomUUID" in crypto ? crypto.randomUUID() : `${Date.now()}`;
}
export default LinkPreviewBlock;

View File

@ -20,7 +20,7 @@ export const SANITIZE_SCHEMA = {
...defaultSchema,
attributes: {
...defaultSchema.attributes,
div: [...(defaultSchema.attributes?.div || []), "className"],
div: [...(defaultSchema.attributes?.div || []), "className", ["data*"]],
span: [...(defaultSchema.attributes?.span || []), "className", "style", ["aria*"], ["data*"]],
// MathML attributes for KaTeX rendering
annotation: ["encoding"],

View File

@ -19,6 +19,7 @@ import { CodeBlock } from "./CodeBlock";
import { createConditionalComponent, isTagNode, isTaskListItemNode } from "./ConditionalComponent";
import { SANITIZE_SCHEMA } from "./constants";
import { useCompactLabel, useCompactMode } from "./hooks";
import LinkPreviewBlock, { isLinkPreviewNode } from "./LinkPreviewBlock";
import { MemoContentContext } from "./MemoContentContext";
import { Tag } from "./Tag";
import { TaskListItem } from "./TaskListItem";
@ -65,6 +66,7 @@ const MemoContent = observer((props: MemoContentProps) => {
components={{
// Conditionally render custom components based on AST node type
input: createConditionalComponent(TaskListItem, "input", isTaskListItemNode),
div: createConditionalComponent(LinkPreviewBlock, "div", isLinkPreviewNode),
span: createConditionalComponent(Tag, "span", isTagNode),
pre: CodeBlock,
a: ({ href, children, ...props }) => (

View File

@ -1,9 +1,9 @@
import { LatLng } from "leaflet";
import { uniqBy } from "lodash-es";
import { FileIcon, LinkIcon, LoaderIcon, MapPinIcon, Maximize2Icon, MoreHorizontalIcon, PlusIcon } from "lucide-react";
import { FileIcon, LinkIcon, LoaderIcon, MapPinIcon, Maximize2Icon, MoreHorizontalIcon, PlusIcon, SparklesIcon } from "lucide-react";
import { observer } from "mobx-react-lite";
import { useContext, useState } from "react";
import type { LocalFile } from "@/components/memo-metadata";
import type { LinkPreview, LocalFile } from "@/components/memo-metadata";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
@ -16,7 +16,7 @@ import {
} from "@/components/ui/dropdown-menu";
import type { Location, MemoRelation } from "@/types/proto/api/v1/memo_service_pb";
import { useTranslate } from "@/utils/i18n";
import { LinkMemoDialog, LocationDialog } from "../components";
import { LinkMemoDialog, LinkPreviewDialog, LocationDialog } from "../components";
import { GEOCODING } from "../constants";
import { useFileUpload, useLinkMemo, useLocation } from "../hooks";
import { useAbortController } from "../hooks/useAbortController";
@ -26,6 +26,7 @@ interface Props {
isUploading?: boolean;
location?: Location;
onLocationChange: (location?: Location) => void;
onLinkPreviewAdd: (preview: LinkPreview) => void;
onToggleFocusMode?: () => void;
}
@ -34,6 +35,7 @@ const InsertMenu = observer((props: Props) => {
const context = useContext(MemoEditorContext);
const [linkDialogOpen, setLinkDialogOpen] = useState(false);
const [linkPreviewDialogOpen, setLinkPreviewDialogOpen] = useState(false);
const [locationDialogOpen, setLocationDialogOpen] = useState(false);
// Abort controller for canceling geocoding requests
@ -153,6 +155,10 @@ const InsertMenu = observer((props: Props) => {
<LinkIcon className="w-4 h-4" />
{t("tooltip.link-memo")}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setLinkPreviewDialogOpen(true)}>
<SparklesIcon className="w-4 h-4" />
{t("editor.link-preview")}
</DropdownMenuItem>
<DropdownMenuItem onClick={handleLocationClick}>
<MapPinIcon className="w-4 h-4" />
{t("tooltip.select-location")}
@ -195,6 +201,15 @@ const InsertMenu = observer((props: Props) => {
onSelectMemo={linkMemo.addMemoRelation}
/>
<LinkPreviewDialog
open={linkPreviewDialogOpen}
onOpenChange={setLinkPreviewDialogOpen}
onAddPreview={(preview) => {
props.onLinkPreviewAdd(preview);
setLinkPreviewDialogOpen(false);
}}
/>
<LocationDialog
open={locationDialogOpen}
onOpenChange={setLocationDialogOpen}

View File

@ -0,0 +1,243 @@
import { LoaderIcon, SparklesIcon } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import type { LinkPreview } from "@/components/memo-metadata";
import LinkPreviewCard from "@/components/memo-metadata/LinkPreviewCard";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { useTranslate } from "@/utils/i18n";
interface LinkPreviewDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onAddPreview: (preview: LinkPreview) => void;
}
const LinkPreviewDialog = ({ open, onOpenChange, onAddPreview }: LinkPreviewDialogProps) => {
const t = useTranslate();
const [inputUrl, setInputUrl] = useState("");
const [status, setStatus] = useState<"idle" | "loading" | "ready" | "error">("idle");
const [error, setError] = useState<string>("");
const [preview, setPreview] = useState<LinkPreview | null>(null);
useEffect(() => {
if (!open) {
setStatus("idle");
setPreview(null);
setError("");
}
}, [open]);
const placeholder = useMemo(() => {
try {
return new URL(inputUrl).hostname.replace(/^www\./, "");
} catch {
return "example.com";
}
}, [inputUrl]);
const handlePreview = () => {
if (!inputUrl.trim()) {
setError(t("editor.link-preview-invalid"));
setStatus("error");
return;
}
try {
new URL(inputUrl);
} catch (_err) {
setError(t("editor.link-preview-invalid"));
setStatus("error");
return;
}
setStatus("loading");
setError("");
setPreview(null);
fetchPreview(inputUrl, { timeoutMs: 8000 })
.then((res) => {
setPreview(res);
setStatus("ready");
})
.catch((err: unknown) => {
const isAbort = err instanceof DOMException && err.name === "AbortError";
setError(isAbort ? t("editor.link-preview-timeout") : t("editor.link-preview-fetch-error"));
setStatus("error");
setPreview(null);
});
};
const handleAddPreview = () => {
if (!preview) return;
onAddPreview(preview);
onOpenChange(false);
setInputUrl("");
setPreview(null);
setStatus("idle");
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-xl">
<DialogHeader>
<DialogTitle>{t("editor.scrape-link")}</DialogTitle>
<DialogDescription>{t("editor.scrape-link-description")}</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-3">
<div className="space-y-2">
<Label htmlFor="link-preview-url" className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
URL
</Label>
<div className="flex flex-col gap-2 sm:flex-row">
<Input
id="link-preview-url"
placeholder="https://example.com/article"
value={inputUrl}
onChange={(e) => setInputUrl(e.target.value)}
className="flex-1"
/>
<Button variant="secondary" onClick={handlePreview} disabled={status === "loading"}>
{status === "loading" ? <LoaderIcon className="h-4 w-4 animate-spin" /> : t("editor.scrape-link")}
</Button>
</div>
<div className="flex items-center gap-1 text-xs text-muted-foreground">
<SparklesIcon className="h-3.5 w-3.5" />
<span>{t("editor.scrape-link-description")}</span>
</div>
</div>
<div className="rounded-md border bg-muted/40 p-3">
{status === "loading" && <Skeleton placeholder={placeholder} />}
{status !== "loading" && preview && <LinkPreviewCard preview={preview} mode="edit" />}
{status === "idle" && !preview && (
<div className="flex flex-col items-start gap-1 text-sm text-muted-foreground">
<span className="font-medium text-foreground">{t("common.preview")}</span>
<span>{t("editor.link-preview-empty")}</span>
</div>
)}
{error && <p className="mt-2 text-sm text-destructive">{error}</p>}
</div>
{preview && (
<div className="grid grid-cols-1 gap-3 rounded-md border bg-card/30 p-3">
<div className="grid gap-1">
<Label htmlFor="link-preview-title" className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
{t("common.title")}
</Label>
<Input
id="link-preview-title"
value={preview.title}
onChange={(e) => setPreview({ ...preview, title: e.target.value })}
placeholder="Edit title"
/>
</div>
<div className="grid gap-1">
<Label htmlFor="link-preview-description" className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
{t("common.description")}
</Label>
<Textarea
id="link-preview-description"
value={preview.description}
onChange={(e) => setPreview({ ...preview, description: e.target.value })}
placeholder="Edit description"
className="min-h-20"
/>
</div>
<div className="grid gap-1">
<Label htmlFor="link-preview-image" className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
Cover image URL
</Label>
<Input
id="link-preview-image"
value={preview.imageUrl}
onChange={(e) => setPreview({ ...preview, imageUrl: e.target.value })}
placeholder="https://example.com/cover.png"
/>
</div>
<div className="grid gap-1">
<Label htmlFor="link-preview-site" className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
Site name
</Label>
<Input
id="link-preview-site"
value={preview.siteName ?? ""}
onChange={(e) => setPreview({ ...preview, siteName: e.target.value })}
placeholder={placeholder}
/>
</div>
</div>
)}
</div>
<DialogFooter>
<Button variant="ghost" onClick={() => onOpenChange(false)}>
{t("common.close")}
</Button>
<Button onClick={handleAddPreview} disabled={!preview || status === "loading"}>
{t("common.add")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
const Skeleton = ({ placeholder }: { placeholder: string }) => {
return (
<div className="flex w-full animate-pulse flex-col gap-2">
<div className="h-24 w-full rounded-md bg-gradient-to-r from-muted to-muted-foreground/20" />
<div className="space-y-2">
<div className="h-3 w-2/3 rounded bg-muted-foreground/30" />
<div className="h-3 w-3/5 rounded bg-muted-foreground/20" />
<div className="h-3 w-1/2 rounded bg-muted-foreground/10" />
<div className="flex items-center gap-2 text-[11px] text-muted-foreground">
<SparklesIcon className="h-3 w-3" />
<span>{placeholder}</span>
</div>
</div>
</div>
);
};
function _getHostname(url: string): string {
try {
return new URL(url).hostname.replace(/^www\./, "");
} catch {
return "";
}
}
async function fetchPreview(url: string, options?: { timeoutMs?: number }): Promise<LinkPreview> {
const controller = new AbortController();
const timeout = options?.timeoutMs ? window.setTimeout(() => controller.abort(), options.timeoutMs) : undefined;
const response = await fetch(`/api/v1/link:preview?url=${encodeURIComponent(url)}`, {
credentials: "include",
signal: controller.signal,
}).finally(() => {
if (timeout) window.clearTimeout(timeout);
});
if (!response.ok) {
throw new Error("failed to fetch preview");
}
const data = (await response.json()) as {
preview: { title: string; description: string; imageUrl: string; siteName: string; url: string };
};
return {
id: cryptoId(),
url: data.preview.url,
title: data.preview.title,
description: data.preview.description,
imageUrl: data.preview.imageUrl,
siteName: data.preview.siteName,
};
}
function cryptoId(): string {
return typeof crypto !== "undefined" && "randomUUID" in crypto ? crypto.randomUUID() : `${Date.now()}`;
}
export default LinkPreviewDialog;

View File

@ -2,4 +2,5 @@
export { default as ErrorBoundary } from "./ErrorBoundary";
export { FocusModeExitButton, FocusModeOverlay } from "./FocusModeOverlay";
export { LinkMemoDialog } from "./LinkMemoDialog";
export { default as LinkPreviewDialog } from "./LinkPreviewDialog";
export { LocationDialog } from "./LocationDialog";

View File

@ -1,5 +1,6 @@
import { timestampDate } from "@bufbuild/protobuf/wkt";
import { useEffect, useState } from "react";
import type { LinkPreview } from "@/components/memo-metadata";
import useAsyncEffect from "@/hooks/useAsyncEffect";
import { instanceStore, memoStore, userStore } from "@/store";
import type { Attachment } from "@/types/proto/api/v1/attachment_service_pb";
@ -7,6 +8,7 @@ import type { Location, MemoRelation } from "@/types/proto/api/v1/memo_service_p
import { Visibility } from "@/types/proto/api/v1/memo_service_pb";
import { convertVisibilityFromString } from "@/utils/memo";
import type { EditorRefActions } from "../Editor";
import { extractLinkPreviewsFromContent } from "../utils/linkPreviewSerializer";
export interface UseMemoEditorInitOptions {
editorRef: React.RefObject<EditorRefActions>;
@ -19,6 +21,7 @@ export interface UseMemoEditorInitOptions {
onAttachmentsChange: (attachments: Attachment[]) => void;
onRelationsChange: (relations: MemoRelation[]) => void;
onLocationChange: (location: Location | undefined) => void;
onLinkPreviewsChange?: (previews: LinkPreview[]) => void;
}
export interface UseMemoEditorInitReturn {
@ -49,7 +52,11 @@ export const useMemoEditorInit = (options: UseMemoEditorInitOptions): UseMemoEdi
// Initialize content cache
useEffect(() => {
editorRef.current?.setContent(contentCache || "");
const { cleanedContent, previews } = extractLinkPreviewsFromContent(contentCache || "");
if (previews.length > 0) {
options.onLinkPreviewsChange?.(previews);
}
editorRef.current?.setContent(cleanedContent || "");
}, []);
// Auto-focus if requested
@ -88,7 +95,11 @@ export const useMemoEditorInit = (options: UseMemoEditorInitOptions): UseMemoEdi
onRelationsChange(memo.relations);
onLocationChange(memo.location);
if (!contentCache) {
editorRef.current?.setContent(memo.content ?? "");
const { cleanedContent, previews } = extractLinkPreviewsFromContent(memo.content ?? "");
if (previews.length > 0) {
options.onLinkPreviewsChange?.(previews);
}
editorRef.current?.setContent(cleanedContent);
}
}
}, [memoName]);

View File

@ -1,4 +1,5 @@
import { useCallback, useState } from "react";
import type { LinkPreview } from "@/components/memo-metadata";
import type { Attachment } from "@/types/proto/api/v1/attachment_service_pb";
import type { Location, MemoRelation } from "@/types/proto/api/v1/memo_service_pb";
import { Visibility } from "@/types/proto/api/v1/memo_service_pb";
@ -7,6 +8,7 @@ interface MemoEditorState {
memoVisibility: Visibility;
attachmentList: Attachment[];
relationList: MemoRelation[];
linkPreviews: LinkPreview[];
location: Location | undefined;
isFocusMode: boolean;
isUploadingAttachment: boolean;
@ -28,6 +30,7 @@ export const useMemoEditorState = (initialVisibility: Visibility = Visibility.PR
isFocusMode: false,
attachmentList: [],
relationList: [],
linkPreviews: [],
location: undefined,
isUploadingAttachment: false,
isRequesting: false,
@ -49,6 +52,10 @@ export const useMemoEditorState = (initialVisibility: Visibility = Visibility.PR
setState((prev) => ({ ...prev, relationList: v }));
}, []);
const setLinkPreviews = useCallback((v: LinkPreview[]) => {
setState((prev) => ({ ...prev, linkPreviews: v }));
}, []);
const setLocation = useCallback((v: Location | undefined) => {
setState((prev) => ({ ...prev, location: v }));
}, []);
@ -79,6 +86,7 @@ export const useMemoEditorState = (initialVisibility: Visibility = Visibility.PR
isRequesting: false,
attachmentList: [],
relationList: [],
linkPreviews: [],
location: undefined,
isDraggingFile: false,
}));
@ -89,6 +97,7 @@ export const useMemoEditorState = (initialVisibility: Visibility = Visibility.PR
setMemoVisibility,
setAttachmentList,
setRelationList,
setLinkPreviews,
setLocation,
toggleFocusMode,
setUploadingAttachment,

View File

@ -13,7 +13,7 @@ import { extractMemoIdFromName } from "@/store/common";
import { MemoRelation_Type } from "@/types/proto/api/v1/memo_service_pb";
import { useTranslate } from "@/utils/i18n";
import DateTimeInput from "../DateTimeInput";
import { AttachmentList, LocationDisplay, RelationList } from "../memo-metadata";
import { AttachmentList, type LinkPreview, LinkPreviewList, LocationDisplay, RelationList } from "../memo-metadata";
import { ErrorBoundary, FocusModeExitButton, FocusModeOverlay } from "./components";
import { FOCUS_MODE_STYLES, LOCALSTORAGE_DEBOUNCE_DELAY } from "./constants";
import Editor, { type EditorRefActions } from "./Editor";
@ -30,6 +30,7 @@ import {
import InsertMenu from "./Toolbar/InsertMenu";
import VisibilitySelector from "./Toolbar/VisibilitySelector";
import { MemoEditorContext } from "./types";
import { appendLinkPreviewsToContent } from "./utils/linkPreviewSerializer";
export interface Props {
className?: string;
@ -62,6 +63,7 @@ const MemoEditor = observer((props: Props) => {
memoVisibility,
attachmentList,
relationList,
linkPreviews,
location,
isFocusMode,
isUploadingAttachment,
@ -71,6 +73,7 @@ const MemoEditor = observer((props: Props) => {
setMemoVisibility,
setAttachmentList,
setRelationList,
setLinkPreviews,
setLocation,
toggleFocusMode,
setUploadingAttachment,
@ -103,6 +106,7 @@ const MemoEditor = observer((props: Props) => {
onAttachmentsChange: setAttachmentList,
onRelationsChange: setRelationList,
onLocationChange: setLocation,
onLinkPreviewsChange: setLinkPreviews,
});
// Memo save hook - handles create/update logic
@ -131,7 +135,8 @@ const MemoEditor = observer((props: Props) => {
return;
}
const content = editorRef.current?.getContent() ?? "";
await saveMemo(content, {
const contentWithPreviews = appendLinkPreviewsToContent(content, linkPreviews);
await saveMemo(contentWithPreviews, {
memoName,
parentMemoName,
visibility: memoVisibility,
@ -150,6 +155,7 @@ const MemoEditor = observer((props: Props) => {
memoVisibility,
attachmentList,
relationList,
linkPreviews,
location,
localFiles,
createTime,
@ -203,6 +209,20 @@ const MemoEditor = observer((props: Props) => {
return relationList.filter((relation) => relation.type === MemoRelation_Type.REFERENCE);
}, [memoName, relationList]);
const handleAddLinkPreview = useCallback(
(preview: LinkPreview) => {
setLinkPreviews([preview, ...linkPreviews.filter((item) => item.url !== preview.url)]);
},
[linkPreviews, setLinkPreviews],
);
const handleRemoveLinkPreview = useCallback(
(id: string) => {
setLinkPreviews(linkPreviews.filter((item) => item.id !== id));
},
[linkPreviews, setLinkPreviews],
);
const editorConfig = useMemo(
() => ({
className: "",
@ -221,7 +241,10 @@ const MemoEditor = observer((props: Props) => {
[i18n.language, isFocusMode, isComposing, handlePasteEvent, handleCompositionStart, handleCompositionEnd, saveContentToCache],
);
const allowSave = (hasContent || attachmentList.length > 0 || localFiles.length > 0) && !isUploadingAttachment && !isRequesting;
const allowSave =
(hasContent || attachmentList.length > 0 || localFiles.length > 0 || linkPreviews.length > 0) &&
!isUploadingAttachment &&
!isRequesting;
return (
<ErrorBoundary>
@ -266,6 +289,7 @@ const MemoEditor = observer((props: Props) => {
localFiles={localFiles}
onRemoveLocalFile={removeFile}
/>
<LinkPreviewList mode="edit" previews={linkPreviews} onRemove={handleRemoveLinkPreview} />
<RelationList mode="edit" relations={referenceRelations} onRelationsChange={setRelationList} />
<div className="relative w-full flex flex-row justify-between items-center pt-2 gap-2" onFocus={(e) => e.stopPropagation()}>
<div className="flex flex-row justify-start items-center gap-1">
@ -273,6 +297,7 @@ const MemoEditor = observer((props: Props) => {
isUploading={isUploadingAttachment}
location={location}
onLocationChange={setLocation}
onLinkPreviewAdd={handleAddLinkPreview}
onToggleFocusMode={toggleFocusMode}
/>
</div>

View File

@ -0,0 +1,68 @@
import type { LinkPreview } from "@/components/memo-metadata";
const PREVIEW_REGEX = /<div[^>]*data-memo-link-preview=["']true["'][^>]*><\/div>/gi;
export function serializeLinkPreviews(previews: LinkPreview[]): string {
return previews
.map((preview) => {
const attrs = [
`data-memo-link-preview="true"`,
`data-id="${escapeAttribute(preview.id)}"`,
`data-url="${escapeAttribute(preview.url)}"`,
`data-title="${escapeAttribute(preview.title)}"`,
`data-description="${escapeAttribute(preview.description)}"`,
`data-image="${escapeAttribute(preview.imageUrl)}"`,
`data-site="${escapeAttribute(preview.siteName || "")}"`,
];
return `<div class="memo-link-preview" ${attrs.join(" ")}></div>`;
})
.join("\n\n");
}
export function appendLinkPreviewsToContent(content: string, previews: LinkPreview[]): string {
if (previews.length === 0) return content;
const serialized = serializeLinkPreviews(previews);
const trimmedContent = content.trimEnd();
if (!trimmedContent.trim()) return serialized;
return `${trimmedContent}\n\n${serialized}`;
}
export function extractLinkPreviewsFromContent(content: string): { cleanedContent: string; previews: LinkPreview[] } {
const matches = content.match(PREVIEW_REGEX) || [];
const previews: LinkPreview[] = matches.map((snippet) => parsePreviewSnippet(snippet)).filter(Boolean) as LinkPreview[];
const cleanedContent = content.replace(PREVIEW_REGEX, "").trimEnd();
return { cleanedContent, previews };
}
function parsePreviewSnippet(snippet: string): LinkPreview | null {
if (typeof document === "undefined") return null;
const container = document.createElement("div");
container.innerHTML = snippet;
const el = container.firstElementChild as HTMLElement | null;
if (!el) return null;
return {
id: el.getAttribute("data-id") || cryptoId(),
url: unescapeAttribute(el.getAttribute("data-url") || ""),
title: unescapeAttribute(el.getAttribute("data-title") || "Link preview"),
description: unescapeAttribute(el.getAttribute("data-description") || ""),
imageUrl: unescapeAttribute(el.getAttribute("data-image") || ""),
siteName: unescapeAttribute(el.getAttribute("data-site") || ""),
};
}
function escapeAttribute(value: string): string {
return value.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}
function unescapeAttribute(value: string): string {
return value
.replace(/&quot;/g, '"')
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&amp;/g, "&");
}
function cryptoId(): string {
return typeof crypto !== "undefined" && "randomUUID" in crypto ? crypto.randomUUID() : `${Date.now()}`;
}

View File

@ -0,0 +1,81 @@
import { Globe2Icon, SparklesIcon, XIcon } from "lucide-react";
import { cn } from "@/lib/utils";
import type { DisplayMode, LinkPreview } from "./types";
interface LinkPreviewCardProps {
preview: LinkPreview;
mode: DisplayMode;
onRemove?: () => void;
className?: string;
}
const LinkPreviewCard = ({ preview, mode, onRemove, className }: LinkPreviewCardProps) => {
const hostname = getHostname(preview.url);
const Wrapper: React.FC<{ children: React.ReactNode }> = ({ children }) =>
preview.url ? (
<a href={preview.url} target="_blank" rel="noopener noreferrer" className="no-underline">
{children}
</a>
) : (
<div>{children}</div>
);
return (
<Wrapper>
<div
className={cn(
"relative flex w-full gap-3 rounded-md border bg-background/80 p-2 transition-colors",
"hover:border-muted-foreground/60 hover:bg-accent/40",
preview.url && "cursor-pointer",
className,
)}
>
<div className="relative h-24 w-28 shrink-0 overflow-hidden rounded-md border bg-muted/60">
<img src={preview.imageUrl} alt={preview.title} className="h-full w-full object-cover" loading="lazy" />
{hostname && (
<span className="absolute bottom-1 left-1 rounded border bg-background/90 px-2 py-0.5 text-[10px] font-medium text-muted-foreground">
{preview.siteName || hostname}
</span>
)}
</div>
<div className="min-w-0 flex-1 space-y-1.5">
<div className="flex items-start gap-2">
<div className="min-w-0 space-y-0.5">
<p className="line-clamp-2 text-sm font-semibold leading-5">{preview.title}</p>
<p className="line-clamp-2 text-xs leading-4 text-muted-foreground">{preview.description}</p>
</div>
{mode === "edit" && onRemove && (
<button
className="rounded-sm p-1 text-muted-foreground/80 transition-colors hover:bg-accent hover:text-foreground"
onClick={(e) => {
e.preventDefault();
onRemove();
}}
aria-label="Remove preview"
>
<XIcon className="h-4 w-4" />
</button>
)}
</div>
<div className="flex items-center gap-1.5 truncate text-xs text-primary">
<Globe2Icon className="h-3.5 w-3.5 shrink-0" />
<span className="truncate">{preview.url}</span>
</div>
</div>
</div>
</Wrapper>
);
};
function getHostname(url: string): string {
try {
return new URL(url).hostname.replace(/^www\./, "");
} catch (_error) {
return "";
}
}
export default LinkPreviewCard;

View File

@ -0,0 +1,33 @@
import { SparklesIcon } from "lucide-react";
import { cn } from "@/lib/utils";
import { useTranslate } from "@/utils/i18n";
import LinkPreviewCard from "./LinkPreviewCard";
import type { BaseMetadataProps, LinkPreview } from "./types";
interface LinkPreviewListProps extends BaseMetadataProps {
previews: LinkPreview[];
onRemove?: (id: string) => void;
}
const LinkPreviewList = ({ previews, mode, onRemove, className }: LinkPreviewListProps) => {
const t = useTranslate();
if (previews.length === 0) {
return null;
}
return (
<div className={cn("mt-2 flex w-full flex-col gap-2", className)}>
<div className="flex items-center gap-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
<SparklesIcon className="h-3.5 w-3.5" />
<span>{t("editor.link-preview")}</span>
</div>
{previews.map((preview) => (
<LinkPreviewCard key={preview.id} preview={preview} mode={mode} onRemove={onRemove ? () => onRemove(preview.id) : undefined} />
))}
</div>
);
};
export default LinkPreviewList;

View File

@ -1,5 +1,7 @@
export { default as AttachmentCard } from "./AttachmentCard";
export { default as AttachmentList } from "./AttachmentList";
export { default as LinkPreviewCard } from "./LinkPreviewCard";
export { default as LinkPreviewList } from "./LinkPreviewList";
export { default as LocationDisplay } from "./LocationDisplay";
// Base components (can be used for other metadata types)
@ -8,5 +10,5 @@ export { default as RelationCard } from "./RelationCard";
export { default as RelationList } from "./RelationList";
// Types
export type { AttachmentItem, BaseMetadataProps, DisplayMode, FileCategory, LocalFile } from "./types";
export type { AttachmentItem, BaseMetadataProps, DisplayMode, FileCategory, LinkPreview, LocalFile } from "./types";
export { attachmentToItem, fileToItem, filterByCategory, separateMediaAndDocs, toAttachmentItems } from "./types";

View File

@ -62,6 +62,15 @@ export interface LocalFile {
readonly previewUrl: string;
}
export interface LinkPreview {
id: string;
url: string;
title: string;
description: string;
imageUrl: string;
siteName?: string;
}
export function toAttachmentItems(attachments: Attachment[], localFiles: LocalFile[] = []): AttachmentItem[] {
return [...attachments.map(attachmentToItem), ...localFiles.map(({ file, previewUrl }) => fileToItem(file, previewUrl))];
}

View File

@ -122,7 +122,14 @@
"save": "Save",
"no-changes-detected": "No changes detected",
"focus-mode": "Focus Mode",
"exit-focus-mode": "Exit Focus Mode"
"exit-focus-mode": "Exit Focus Mode",
"link-preview": "Link preview",
"scrape-link": "Scrape link preview",
"scrape-link-description": "Paste a URL to fetch its title, description, and cover image.",
"link-preview-empty": "Paste a link to generate a preview card.",
"link-preview-invalid": "Please enter a valid URL.",
"link-preview-fetch-error": "Failed to fetch preview. Please try again.",
"link-preview-timeout": "Request timed out. Please retry."
},
"filters": {
"has-code": "hasCode",

View File

@ -114,7 +114,14 @@
"add-your-comment-here": "Tambahkan komentar Anda di sini...",
"any-thoughts": "Punya pemikiran...",
"save": "Simpan",
"no-changes-detected": "Tidak ada perubahan yang terdeteksi"
"no-changes-detected": "Tidak ada perubahan yang terdeteksi",
"link-preview": "Pratinjau tautan",
"scrape-link": "Ambil pratinjau tautan",
"scrape-link-description": "Tempel URL untuk mengambil judul, deskripsi, dan gambar sampul.",
"link-preview-empty": "Tempel tautan untuk membuat kartu pratinjau.",
"link-preview-invalid": "Masukkan URL yang valid.",
"link-preview-fetch-error": "Gagal mengambil pratinjau. Silakan coba lagi.",
"link-preview-timeout": "Permintaan habis waktu. Silakan coba lagi."
},
"filters": {
"has-code": "Memiliki kode",