enhance: improve link memo dialog with rich previews (#5697)

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
memoclaw 2026-03-07 17:54:13 +08:00 committed by GitHub
parent e70149af5f
commit 4503679155
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 190 additions and 62 deletions

View File

@ -212,9 +212,9 @@ func (s *service) GenerateSnippet(content []byte, maxLength int) (string, error)
err = gast.Walk(root, func(n gast.Node, entering bool) (gast.WalkStatus, error) {
if entering {
// Skip code blocks and code spans entirely
// Skip code blocks entirely (but keep inline code spans for snippet text)
switch n.Kind() {
case gast.KindCodeBlock, gast.KindFencedCodeBlock, gast.KindCodeSpan:
case gast.KindCodeBlock, gast.KindFencedCodeBlock:
return gast.WalkSkipChildren, nil
default:
// Continue walking for other node types
@ -222,7 +222,7 @@ func (s *service) GenerateSnippet(content []byte, maxLength int) (string, error)
// Add space before block elements (except first)
switch n.Kind() {
case gast.KindParagraph, gast.KindHeading, gast.KindListItem:
case gast.KindParagraph, gast.KindHeading, gast.KindListItem, east.KindTableCell, east.KindTableRow, east.KindTableHeader:
if buf.Len() > 0 && lastNodeWasBlock {
buf.WriteByte(' ')
}
@ -234,7 +234,7 @@ func (s *service) GenerateSnippet(content []byte, maxLength int) (string, error)
if !entering {
// Mark that we just exited a block element
switch n.Kind() {
case gast.KindParagraph, gast.KindHeading, gast.KindListItem:
case gast.KindParagraph, gast.KindHeading, gast.KindListItem, east.KindTableCell, east.KindTableRow, east.KindTableHeader:
lastNodeWasBlock = true
default:
// Not a block element

View File

@ -94,6 +94,42 @@ func TestGenerateSnippet(t *testing.T) {
maxLength: 100,
expected: "Item 1 Item 2 Item 3",
},
{
name: "inline code preserved",
content: "`console.log('hello')`",
maxLength: 100,
expected: "console.log('hello')",
},
{
name: "text with inline code",
content: "Use `fmt.Println` to print output.",
maxLength: 100,
expected: "Use fmt.Println to print output.",
},
{
name: "image alt text",
content: "![alt text](https://example.com/img.png)",
maxLength: 100,
expected: "alt text",
},
{
name: "strikethrough text",
content: "~~deleted text~~",
maxLength: 100,
expected: "deleted text",
},
{
name: "blockquote",
content: "> quoted text",
maxLength: 100,
expected: "quoted text",
},
{
name: "table cells spaced",
content: "| a | b |\n|---|---|\n| 1 | 2 |",
maxLength: 100,
expected: "a b 1 2",
},
{
name: "plain URL autolink",
content: "https://usememos.com",

View File

@ -194,6 +194,7 @@ const InsertMenu = (props: InsertMenuProps) => {
filteredMemos={linkMemo.filteredMemos}
isFetching={linkMemo.isFetching}
onSelectMemo={linkMemo.addMemoRelation}
isAlreadyLinked={linkMemo.isAlreadyLinked}
/>
<LocationDialog

View File

@ -1,34 +1,14 @@
import { timestampDate } from "@bufbuild/protobuf/wkt";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { LinkIcon } from "lucide-react";
import { MemoPreview } from "@/components/MemoPreview";
import { Dialog, DialogClose, DialogContent, DialogDescription, DialogTitle } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { VisuallyHidden } from "@/components/ui/visually-hidden";
import { extractMemoIdFromName } from "@/helpers/resource-names";
import { cn } from "@/lib/utils";
import { useTranslate } from "@/utils/i18n";
import type { LinkMemoDialogProps } from "../types";
function highlightSearchText(content: string, searchText: string): React.ReactNode {
if (!searchText) return content;
const index = content.toLowerCase().indexOf(searchText.toLowerCase());
if (index === -1) return content;
let before = content.slice(0, index);
if (before.length > 20) {
before = "..." + before.slice(before.length - 20);
}
const highlighted = content.slice(index, index + searchText.length);
let after = content.slice(index + searchText.length);
if (after.length > 20) {
after = after.slice(0, 20) + "...";
}
return (
<>
{before}
<mark className="font-medium">{highlighted}</mark>
{after}
</>
);
}
export const LinkMemoDialog = ({
open,
onOpenChange,
@ -37,44 +17,63 @@ export const LinkMemoDialog = ({
filteredMemos,
isFetching,
onSelectMemo,
isAlreadyLinked,
}: LinkMemoDialogProps) => {
const t = useTranslate();
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogContent className="max-w-[min(28rem,calc(100vw-2rem))] p-0!" showCloseButton={false}>
<VisuallyHidden>
<DialogClose />
</VisuallyHidden>
<VisuallyHidden>
<DialogTitle>{t("tooltip.link-memo")}</DialogTitle>
</DialogHeader>
<div className="flex flex-col gap-3">
<Input
placeholder={t("reference.search-placeholder")}
value={searchText}
onChange={(e) => onSearchChange(e.target.value)}
className="!text-sm"
/>
<div className="max-h-[300px] overflow-y-auto border rounded-md">
</VisuallyHidden>
<VisuallyHidden>
<DialogDescription>Search and select a memo to link</DialogDescription>
</VisuallyHidden>
<div className="flex flex-col">
<div className="p-3">
<Input
placeholder={t("reference.search-placeholder")}
value={searchText}
onChange={(e) => onSearchChange(e.target.value)}
className="!text-sm h-9"
autoFocus
/>
</div>
<div className="border-t border-border" />
<div className="max-h-[320px] overflow-y-auto">
{filteredMemos.length === 0 ? (
<div className="py-8 text-center text-sm text-muted-foreground">
{isFetching ? "Loading..." : t("reference.no-memos-found")}
</div>
) : (
filteredMemos.map((memo) => (
<div
key={memo.name}
className="relative flex cursor-pointer items-start gap-2 border-b last:border-b-0 px-3 py-2 hover:bg-accent hover:text-accent-foreground"
onClick={() => onSelectMemo(memo)}
>
<div className="w-full flex flex-col justify-start items-start">
<p className="text-xs text-muted-foreground select-none">
{memo.displayTime && timestampDate(memo.displayTime).toLocaleString()}
</p>
<p className="mt-0.5 text-sm leading-5 line-clamp-2">
{searchText ? highlightSearchText(memo.content, searchText) : memo.snippet}
</p>
filteredMemos.map((memo) => {
const alreadyLinked = isAlreadyLinked(memo.name);
return (
<div
key={memo.name}
className={cn(
"flex cursor-pointer items-start border-b border-border last:border-b-0 px-3 py-2.5 hover:bg-accent/50 transition-colors",
alreadyLinked && "opacity-40 cursor-default",
)}
onClick={() => !alreadyLinked && onSelectMemo(memo)}
>
<div className="w-full flex flex-col gap-1">
<div className="flex items-center gap-1.5 text-sm text-muted-foreground select-none">
{alreadyLinked && <LinkIcon className="w-3 h-3 shrink-0" />}
<span className="text-xs font-mono px-1 py-0.5 rounded border border-border bg-muted/40 shrink-0">
{extractMemoIdFromName(memo.name).slice(0, 6)}
</span>
<span>{memo.displayTime && timestampDate(memo.displayTime).toLocaleString()}</span>
</div>
<MemoPreview content={memo.content} attachments={memo.attachments} />
</div>
</div>
</div>
))
);
})
)}
</div>
</div>

View File

@ -1,11 +1,17 @@
import { create } from "@bufbuild/protobuf";
import { useState } from "react";
import { useEffect, useMemo, useState } from "react";
import useDebounce from "react-use/lib/useDebounce";
import { memoServiceClient } from "@/connect";
import { DEFAULT_LIST_MEMOS_PAGE_SIZE } from "@/helpers/consts";
import { extractUserIdFromName } from "@/helpers/resource-names";
import useCurrentUser from "@/hooks/useCurrentUser";
import { Memo, MemoRelation, MemoRelation_MemoSchema, MemoRelation_Type, MemoRelationSchema } from "@/types/proto/api/v1/memo_service_pb";
import {
type Memo,
type MemoRelation,
MemoRelation_MemoSchema,
MemoRelation_Type,
MemoRelationSchema,
} from "@/types/proto/api/v1/memo_service_pb";
interface UseLinkMemoParams {
isOpen: boolean;
@ -20,9 +26,17 @@ export const useLinkMemo = ({ isOpen, currentMemoName, existingRelations, onAddR
const [isFetching, setIsFetching] = useState(true);
const [fetchedMemos, setFetchedMemos] = useState<Memo[]>([]);
const filteredMemos = fetchedMemos.filter(
(memo) => memo.name !== currentMemoName && !existingRelations.some((relation) => relation.relatedMemo?.name === memo.name),
);
const filteredMemos = fetchedMemos.filter((memo) => memo.name !== currentMemoName);
const linkedMemoNames = useMemo(() => new Set(existingRelations.map((r) => r.relatedMemo?.name)), [existingRelations]);
const isAlreadyLinked = (memoName: string): boolean => linkedMemoNames.has(memoName);
useEffect(() => {
if (isOpen) {
setSearchText("");
}
}, [isOpen]);
useDebounce(
async () => {
@ -66,5 +80,6 @@ export const useLinkMemo = ({ isOpen, currentMemoName, existingRelations, onAddR
isFetching,
filteredMemos,
addMemoRelation,
isAlreadyLinked,
};
};

View File

@ -50,6 +50,7 @@ export interface LinkMemoDialogProps {
filteredMemos: Memo[];
isFetching: boolean;
onSelectMemo: (memo: Memo) => void;
isAlreadyLinked: (memoName: string) => boolean;
}
export interface LocationDialogProps {

View File

@ -0,0 +1,75 @@
import { create } from "@bufbuild/protobuf";
import { FileIcon } from "lucide-react";
import { cn } from "@/lib/utils";
import type { Attachment } from "@/types/proto/api/v1/attachment_service_pb";
import { MemoSchema } from "@/types/proto/api/v1/memo_service_pb";
import { getAttachmentType, getAttachmentUrl } from "@/utils/attachment";
import MemoContent from "../MemoContent";
import { MemoViewContext, type MemoViewContextValue } from "../MemoView/MemoViewContext";
interface MemoPreviewProps {
content: string;
attachments: Attachment[];
compact?: boolean;
className?: string;
}
const STUB_CONTEXT: MemoViewContextValue = {
memo: create(MemoSchema),
creator: undefined,
currentUser: undefined,
parentPage: "/",
isArchived: false,
readonly: true,
showNSFWContent: false,
nsfw: false,
};
const AttachmentThumbnails = ({ attachments }: { attachments: Attachment[] }) => {
const images: Attachment[] = [];
const others: Attachment[] = [];
for (const a of attachments) {
if (getAttachmentType(a) === "image/*") images.push(a);
else others.push(a);
}
return (
<div className="flex items-center gap-1.5 flex-wrap">
{images.map((a) => (
<img
key={a.name}
src={getAttachmentUrl(a)}
alt={a.filename}
className="w-10 h-10 rounded border border-border object-cover bg-muted/40"
loading="lazy"
/>
))}
{others.map((a) => (
<div key={a.name} className="flex items-center gap-1 text-[10px] text-muted-foreground">
<FileIcon className="w-3 h-3 shrink-0" />
<span className="truncate max-w-[80px]">{a.filename}</span>
</div>
))}
</div>
);
};
const MemoPreview = ({ content, attachments, compact = true, className }: MemoPreviewProps) => {
const hasContent = content.trim().length > 0;
const hasAttachments = attachments.length > 0;
if (!hasContent && !hasAttachments) {
return null;
}
return (
<MemoViewContext.Provider value={STUB_CONTEXT}>
<div className={cn("flex flex-col gap-1 pointer-events-none", className)}>
{hasContent && <MemoContent content={content} compact={compact} />}
{hasAttachments && <AttachmentThumbnails attachments={attachments} />}
</div>
</MemoViewContext.Provider>
);
};
export default MemoPreview;

View File

@ -0,0 +1 @@
export { default as MemoPreview } from "./MemoPreview";

View File

@ -26,7 +26,7 @@ const MemoSnippetLink = ({ name, snippet, to, state, className }: MemoSnippetLin
<span className="text-[8px] font-mono px-1 py-0.5 rounded border border-border bg-muted/40 group-hover:bg-accent/30 transition-colors shrink-0">
{memoId.slice(0, 6)}
</span>
<span className="truncate">{snippet}</span>
<span className="truncate">{snippet || <span className="italic opacity-60">No content</span>}</span>
</Link>
);
};