mirror of https://github.com/usememos/memos.git
enhance: improve link memo dialog with rich previews (#5697)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
e70149af5f
commit
4503679155
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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: "",
|
||||
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",
|
||||
|
|
|
|||
|
|
@ -194,6 +194,7 @@ const InsertMenu = (props: InsertMenuProps) => {
|
|||
filteredMemos={linkMemo.filteredMemos}
|
||||
isFetching={linkMemo.isFetching}
|
||||
onSelectMemo={linkMemo.addMemoRelation}
|
||||
isAlreadyLinked={linkMemo.isAlreadyLinked}
|
||||
/>
|
||||
|
||||
<LocationDialog
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -50,6 +50,7 @@ export interface LinkMemoDialogProps {
|
|||
filteredMemos: Memo[];
|
||||
isFetching: boolean;
|
||||
onSelectMemo: (memo: Memo) => void;
|
||||
isAlreadyLinked: (memoName: string) => boolean;
|
||||
}
|
||||
|
||||
export interface LocationDialogProps {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { default as MemoPreview } from "./MemoPreview";
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in New Issue