From 450367915564532b93e924445fd1fcddf4276ab1 Mon Sep 17 00:00:00 2001 From: memoclaw Date: Sat, 7 Mar 2026 17:54:13 +0800 Subject: [PATCH] enhance: improve link memo dialog with rich previews (#5697) Co-authored-by: Claude Opus 4.6 --- plugin/markdown/markdown.go | 8 +- plugin/markdown/markdown_test.go | 36 ++++++ .../MemoEditor/Toolbar/InsertMenu.tsx | 1 + .../MemoEditor/components/LinkMemoDialog.tsx | 103 +++++++++--------- .../MemoEditor/hooks/useLinkMemo.ts | 25 ++++- .../components/MemoEditor/types/components.ts | 1 + .../components/MemoPreview/MemoPreview.tsx | 75 +++++++++++++ web/src/components/MemoPreview/index.ts | 1 + .../MemoView/components/MemoSnippetLink.tsx | 2 +- 9 files changed, 190 insertions(+), 62 deletions(-) create mode 100644 web/src/components/MemoPreview/MemoPreview.tsx create mode 100644 web/src/components/MemoPreview/index.ts diff --git a/plugin/markdown/markdown.go b/plugin/markdown/markdown.go index dbafa5c4f..ba5935629 100644 --- a/plugin/markdown/markdown.go +++ b/plugin/markdown/markdown.go @@ -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 diff --git a/plugin/markdown/markdown_test.go b/plugin/markdown/markdown_test.go index 56da08b38..820fc1d30 100644 --- a/plugin/markdown/markdown_test.go +++ b/plugin/markdown/markdown_test.go @@ -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", diff --git a/web/src/components/MemoEditor/Toolbar/InsertMenu.tsx b/web/src/components/MemoEditor/Toolbar/InsertMenu.tsx index ec962b5e7..776573185 100644 --- a/web/src/components/MemoEditor/Toolbar/InsertMenu.tsx +++ b/web/src/components/MemoEditor/Toolbar/InsertMenu.tsx @@ -194,6 +194,7 @@ const InsertMenu = (props: InsertMenuProps) => { filteredMemos={linkMemo.filteredMemos} isFetching={linkMemo.isFetching} onSelectMemo={linkMemo.addMemoRelation} + isAlreadyLinked={linkMemo.isAlreadyLinked} /> 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} - {highlighted} - {after} - - ); -} - export const LinkMemoDialog = ({ open, onOpenChange, @@ -37,44 +17,63 @@ export const LinkMemoDialog = ({ filteredMemos, isFetching, onSelectMemo, + isAlreadyLinked, }: LinkMemoDialogProps) => { const t = useTranslate(); return ( - - + + + + + {t("tooltip.link-memo")} - -
- onSearchChange(e.target.value)} - className="!text-sm" - /> -
+ + + Search and select a memo to link + +
+
+ onSearchChange(e.target.value)} + className="!text-sm h-9" + autoFocus + /> +
+
+
{filteredMemos.length === 0 ? (
{isFetching ? "Loading..." : t("reference.no-memos-found")}
) : ( - filteredMemos.map((memo) => ( -
onSelectMemo(memo)} - > -
-

- {memo.displayTime && timestampDate(memo.displayTime).toLocaleString()} -

-

- {searchText ? highlightSearchText(memo.content, searchText) : memo.snippet} -

+ filteredMemos.map((memo) => { + const alreadyLinked = isAlreadyLinked(memo.name); + return ( +
!alreadyLinked && onSelectMemo(memo)} + > +
+
+ {alreadyLinked && } + + {extractMemoIdFromName(memo.name).slice(0, 6)} + + {memo.displayTime && timestampDate(memo.displayTime).toLocaleString()} +
+ +
-
- )) + ); + }) )}
diff --git a/web/src/components/MemoEditor/hooks/useLinkMemo.ts b/web/src/components/MemoEditor/hooks/useLinkMemo.ts index fd1524136..81e76322a 100644 --- a/web/src/components/MemoEditor/hooks/useLinkMemo.ts +++ b/web/src/components/MemoEditor/hooks/useLinkMemo.ts @@ -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([]); - 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, }; }; diff --git a/web/src/components/MemoEditor/types/components.ts b/web/src/components/MemoEditor/types/components.ts index df1a439be..efa80bf36 100644 --- a/web/src/components/MemoEditor/types/components.ts +++ b/web/src/components/MemoEditor/types/components.ts @@ -50,6 +50,7 @@ export interface LinkMemoDialogProps { filteredMemos: Memo[]; isFetching: boolean; onSelectMemo: (memo: Memo) => void; + isAlreadyLinked: (memoName: string) => boolean; } export interface LocationDialogProps { diff --git a/web/src/components/MemoPreview/MemoPreview.tsx b/web/src/components/MemoPreview/MemoPreview.tsx new file mode 100644 index 000000000..e63f111d1 --- /dev/null +++ b/web/src/components/MemoPreview/MemoPreview.tsx @@ -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 ( +
+ {images.map((a) => ( + {a.filename} + ))} + {others.map((a) => ( +
+ + {a.filename} +
+ ))} +
+ ); +}; + +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 ( + +
+ {hasContent && } + {hasAttachments && } +
+
+ ); +}; + +export default MemoPreview; diff --git a/web/src/components/MemoPreview/index.ts b/web/src/components/MemoPreview/index.ts new file mode 100644 index 000000000..301b34f88 --- /dev/null +++ b/web/src/components/MemoPreview/index.ts @@ -0,0 +1 @@ +export { default as MemoPreview } from "./MemoPreview"; diff --git a/web/src/components/MemoView/components/MemoSnippetLink.tsx b/web/src/components/MemoView/components/MemoSnippetLink.tsx index c094c56df..d68ce217e 100644 --- a/web/src/components/MemoView/components/MemoSnippetLink.tsx +++ b/web/src/components/MemoView/components/MemoSnippetLink.tsx @@ -26,7 +26,7 @@ const MemoSnippetLink = ({ name, snippet, to, state, className }: MemoSnippetLin {memoId.slice(0, 6)} - {snippet} + {snippet || No content} ); };