Skip to content
Projects
Groups
Snippets
Help
Loading...
Help
Submit feedback
Contribute to GitLab
Sign in
Toggle navigation
C
canifa_note
Project
Project
Details
Activity
Releases
Cycle Analytics
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Issues
0
Issues
0
List
Board
Labels
Milestones
Merge Requests
0
Merge Requests
0
CI / CD
CI / CD
Pipelines
Jobs
Schedules
Charts
Wiki
Wiki
Snippets
Snippets
Members
Members
Collapse sidebar
Close sidebar
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Open sidebar
Vũ Hoàng Anh
canifa_note
Commits
45036791
Unverified
Commit
45036791
authored
Mar 07, 2026
by
memoclaw
Committed by
GitHub
Mar 07, 2026
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
enhance: improve link memo dialog with rich previews (#5697)
Co-authored-by:
Claude Opus 4.6
<
noreply@anthropic.com
>
parent
e70149af
Changes
9
Show whitespace changes
Inline
Side-by-side
Showing
9 changed files
with
190 additions
and
62 deletions
+190
-62
markdown.go
plugin/markdown/markdown.go
+4
-4
markdown_test.go
plugin/markdown/markdown_test.go
+36
-0
InsertMenu.tsx
web/src/components/MemoEditor/Toolbar/InsertMenu.tsx
+1
-0
LinkMemoDialog.tsx
web/src/components/MemoEditor/components/LinkMemoDialog.tsx
+51
-52
useLinkMemo.ts
web/src/components/MemoEditor/hooks/useLinkMemo.ts
+20
-5
components.ts
web/src/components/MemoEditor/types/components.ts
+1
-0
MemoPreview.tsx
web/src/components/MemoPreview/MemoPreview.tsx
+75
-0
index.ts
web/src/components/MemoPreview/index.ts
+1
-0
MemoSnippetLink.tsx
web/src/components/MemoView/components/MemoSnippetLink.tsx
+1
-1
No files found.
plugin/markdown/markdown.go
View file @
45036791
...
@@ -212,9 +212,9 @@ func (s *service) GenerateSnippet(content []byte, maxLength int) (string, error)
...
@@ -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
)
{
err
=
gast
.
Walk
(
root
,
func
(
n
gast
.
Node
,
entering
bool
)
(
gast
.
WalkStatus
,
error
)
{
if
entering
{
if
entering
{
// Skip code blocks
and code spans entirely
// Skip code blocks
entirely (but keep inline code spans for snippet text)
switch
n
.
Kind
()
{
switch
n
.
Kind
()
{
case
gast
.
KindCodeBlock
,
gast
.
KindFencedCodeBlock
,
gast
.
KindCodeSpan
:
case
gast
.
KindCodeBlock
,
gast
.
KindFencedCodeBlock
:
return
gast
.
WalkSkipChildren
,
nil
return
gast
.
WalkSkipChildren
,
nil
default
:
default
:
// Continue walking for other node types
// Continue walking for other node types
...
@@ -222,7 +222,7 @@ func (s *service) GenerateSnippet(content []byte, maxLength int) (string, error)
...
@@ -222,7 +222,7 @@ func (s *service) GenerateSnippet(content []byte, maxLength int) (string, error)
// Add space before block elements (except first)
// Add space before block elements (except first)
switch
n
.
Kind
()
{
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
{
if
buf
.
Len
()
>
0
&&
lastNodeWasBlock
{
buf
.
WriteByte
(
' '
)
buf
.
WriteByte
(
' '
)
}
}
...
@@ -234,7 +234,7 @@ func (s *service) GenerateSnippet(content []byte, maxLength int) (string, error)
...
@@ -234,7 +234,7 @@ func (s *service) GenerateSnippet(content []byte, maxLength int) (string, error)
if
!
entering
{
if
!
entering
{
// Mark that we just exited a block element
// Mark that we just exited a block element
switch
n
.
Kind
()
{
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
lastNodeWasBlock
=
true
default
:
default
:
// Not a block element
// Not a block element
...
...
plugin/markdown/markdown_test.go
View file @
45036791
...
@@ -94,6 +94,42 @@ func TestGenerateSnippet(t *testing.T) {
...
@@ -94,6 +94,42 @@ func TestGenerateSnippet(t *testing.T) {
maxLength
:
100
,
maxLength
:
100
,
expected
:
"Item 1 Item 2 Item 3"
,
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"
,
name
:
"plain URL autolink"
,
content
:
"https://usememos.com"
,
content
:
"https://usememos.com"
,
...
...
web/src/components/MemoEditor/Toolbar/InsertMenu.tsx
View file @
45036791
...
@@ -194,6 +194,7 @@ const InsertMenu = (props: InsertMenuProps) => {
...
@@ -194,6 +194,7 @@ const InsertMenu = (props: InsertMenuProps) => {
filteredMemos=
{
linkMemo
.
filteredMemos
}
filteredMemos=
{
linkMemo
.
filteredMemos
}
isFetching=
{
linkMemo
.
isFetching
}
isFetching=
{
linkMemo
.
isFetching
}
onSelectMemo=
{
linkMemo
.
addMemoRelation
}
onSelectMemo=
{
linkMemo
.
addMemoRelation
}
isAlreadyLinked=
{
linkMemo
.
isAlreadyLinked
}
/>
/>
<
LocationDialog
<
LocationDialog
...
...
web/src/components/MemoEditor/components/LinkMemoDialog.tsx
View file @
45036791
import
{
timestampDate
}
from
"@bufbuild/protobuf/wkt"
;
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
{
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
{
useTranslate
}
from
"@/utils/i18n"
;
import
type
{
LinkMemoDialogProps
}
from
"../types"
;
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
=
({
export
const
LinkMemoDialog
=
({
open
,
open
,
onOpenChange
,
onOpenChange
,
...
@@ -37,44 +17,63 @@ export const LinkMemoDialog = ({
...
@@ -37,44 +17,63 @@ export const LinkMemoDialog = ({
filteredMemos
,
filteredMemos
,
isFetching
,
isFetching
,
onSelectMemo
,
onSelectMemo
,
isAlreadyLinked
,
}:
LinkMemoDialogProps
)
=>
{
}:
LinkMemoDialogProps
)
=>
{
const
t
=
useTranslate
();
const
t
=
useTranslate
();
return
(
return
(
<
Dialog
open=
{
open
}
onOpenChange=
{
onOpenChange
}
>
<
Dialog
open=
{
open
}
onOpenChange=
{
onOpenChange
}
>
<
DialogContent
>
<
DialogContent
className=
"max-w-[min(28rem,calc(100vw-2rem))] p-0!"
showCloseButton=
{
false
}
>
<
DialogHeader
>
<
VisuallyHidden
>
<
DialogClose
/>
</
VisuallyHidden
>
<
VisuallyHidden
>
<
DialogTitle
>
{
t
(
"tooltip.link-memo"
)
}
</
DialogTitle
>
<
DialogTitle
>
{
t
(
"tooltip.link-memo"
)
}
</
DialogTitle
>
</
DialogHeader
>
</
VisuallyHidden
>
<
div
className=
"flex flex-col gap-3"
>
<
VisuallyHidden
>
<
DialogDescription
>
Search and select a memo to link
</
DialogDescription
>
</
VisuallyHidden
>
<
div
className=
"flex flex-col"
>
<
div
className=
"p-3"
>
<
Input
<
Input
placeholder=
{
t
(
"reference.search-placeholder"
)
}
placeholder=
{
t
(
"reference.search-placeholder"
)
}
value=
{
searchText
}
value=
{
searchText
}
onChange=
{
(
e
)
=>
onSearchChange
(
e
.
target
.
value
)
}
onChange=
{
(
e
)
=>
onSearchChange
(
e
.
target
.
value
)
}
className=
"!text-sm"
className=
"!text-sm h-9"
autoFocus
/>
/>
<
div
className=
"max-h-[300px] overflow-y-auto border rounded-md"
>
</
div
>
<
div
className=
"border-t border-border"
/>
<
div
className=
"max-h-[320px] overflow-y-auto"
>
{
filteredMemos
.
length
===
0
?
(
{
filteredMemos
.
length
===
0
?
(
<
div
className=
"py-8 text-center text-sm text-muted-foreground"
>
<
div
className=
"py-8 text-center text-sm text-muted-foreground"
>
{
isFetching
?
"Loading..."
:
t
(
"reference.no-memos-found"
)
}
{
isFetching
?
"Loading..."
:
t
(
"reference.no-memos-found"
)
}
</
div
>
</
div
>
)
:
(
)
:
(
filteredMemos
.
map
((
memo
)
=>
(
filteredMemos
.
map
((
memo
)
=>
{
const
alreadyLinked
=
isAlreadyLinked
(
memo
.
name
);
return
(
<
div
<
div
key=
{
memo
.
name
}
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"
className=
{
cn
(
onClick=
{
()
=>
onSelectMemo
(
memo
)
}
"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 justify-start items-start
"
>
<
div
className=
"w-full flex flex-col gap-1
"
>
<
p
className=
"text-xs
text-muted-foreground select-none"
>
<
div
className=
"flex items-center gap-1.5 text-sm
text-muted-foreground select-none"
>
{
memo
.
displayTime
&&
timestampDate
(
memo
.
displayTime
).
toLocaleString
()
}
{
alreadyLinked
&&
<
LinkIcon
className=
"w-3 h-3 shrink-0"
/>
}
</
p
>
<
span
className=
"text-xs font-mono px-1 py-0.5 rounded border border-border bg-muted/40 shrink-0"
>
<
p
className=
"mt-0.5 text-sm leading-5 line-clamp-2"
>
{
extractMemoIdFromName
(
memo
.
name
).
slice
(
0
,
6
)
}
{
searchText
?
highlightSearchText
(
memo
.
content
,
searchText
)
:
memo
.
snippet
}
</
span
>
</
p
>
<
span
>
{
memo
.
displayTime
&&
timestampDate
(
memo
.
displayTime
).
toLocaleString
()
}
</
span
>
</
div
>
</
div
>
<
MemoPreview
content=
{
memo
.
content
}
attachments=
{
memo
.
attachments
}
/>
</
div
>
</
div
>
))
</
div
>
);
})
)
}
)
}
</
div
>
</
div
>
</
div
>
</
div
>
...
...
web/src/components/MemoEditor/hooks/useLinkMemo.ts
View file @
45036791
import
{
create
}
from
"@bufbuild/protobuf"
;
import
{
create
}
from
"@bufbuild/protobuf"
;
import
{
useState
}
from
"react"
;
import
{
use
Effect
,
useMemo
,
use
State
}
from
"react"
;
import
useDebounce
from
"react-use/lib/useDebounce"
;
import
useDebounce
from
"react-use/lib/useDebounce"
;
import
{
memoServiceClient
}
from
"@/connect"
;
import
{
memoServiceClient
}
from
"@/connect"
;
import
{
DEFAULT_LIST_MEMOS_PAGE_SIZE
}
from
"@/helpers/consts"
;
import
{
DEFAULT_LIST_MEMOS_PAGE_SIZE
}
from
"@/helpers/consts"
;
import
{
extractUserIdFromName
}
from
"@/helpers/resource-names"
;
import
{
extractUserIdFromName
}
from
"@/helpers/resource-names"
;
import
useCurrentUser
from
"@/hooks/useCurrentUser"
;
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
{
interface
UseLinkMemoParams
{
isOpen
:
boolean
;
isOpen
:
boolean
;
...
@@ -20,9 +26,17 @@ export const useLinkMemo = ({ isOpen, currentMemoName, existingRelations, onAddR
...
@@ -20,9 +26,17 @@ export const useLinkMemo = ({ isOpen, currentMemoName, existingRelations, onAddR
const
[
isFetching
,
setIsFetching
]
=
useState
(
true
);
const
[
isFetching
,
setIsFetching
]
=
useState
(
true
);
const
[
fetchedMemos
,
setFetchedMemos
]
=
useState
<
Memo
[]
>
([]);
const
[
fetchedMemos
,
setFetchedMemos
]
=
useState
<
Memo
[]
>
([]);
const
filteredMemos
=
fetchedMemos
.
filter
(
const
filteredMemos
=
fetchedMemos
.
filter
((
memo
)
=>
memo
.
name
!==
currentMemoName
);
(
memo
)
=>
memo
.
name
!==
currentMemoName
&&
!
existingRelations
.
some
((
relation
)
=>
relation
.
relatedMemo
?.
name
===
memo
.
name
),
);
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
(
useDebounce
(
async
()
=>
{
async
()
=>
{
...
@@ -66,5 +80,6 @@ export const useLinkMemo = ({ isOpen, currentMemoName, existingRelations, onAddR
...
@@ -66,5 +80,6 @@ export const useLinkMemo = ({ isOpen, currentMemoName, existingRelations, onAddR
isFetching,
isFetching,
filteredMemos,
filteredMemos,
addMemoRelation,
addMemoRelation,
isAlreadyLinked,
};
};
};
};
web/src/components/MemoEditor/types/components.ts
View file @
45036791
...
@@ -50,6 +50,7 @@ export interface LinkMemoDialogProps {
...
@@ -50,6 +50,7 @@ export interface LinkMemoDialogProps {
filteredMemos
:
Memo
[];
filteredMemos
:
Memo
[];
isFetching
:
boolean
;
isFetching
:
boolean
;
onSelectMemo
:
(
memo
:
Memo
)
=>
void
;
onSelectMemo
:
(
memo
:
Memo
)
=>
void
;
isAlreadyLinked
:
(
memoName
:
string
)
=>
boolean
;
}
}
export
interface
LocationDialogProps
{
export
interface
LocationDialogProps
{
...
...
web/src/components/MemoPreview/MemoPreview.tsx
0 → 100644
View file @
45036791
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
;
web/src/components/MemoPreview/index.ts
0 → 100644
View file @
45036791
export
{
default
as
MemoPreview
}
from
"./MemoPreview"
;
web/src/components/MemoView/components/MemoSnippetLink.tsx
View file @
45036791
...
@@ -26,7 +26,7 @@ const MemoSnippetLink = ({ name, snippet, to, state, className }: MemoSnippetLin
...
@@ -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"
>
<
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
)
}
{
memoId
.
slice
(
0
,
6
)
}
</
span
>
</
span
>
<
span
className=
"truncate"
>
{
snippet
}
</
span
>
<
span
className=
"truncate"
>
{
snippet
||
<
span
className=
"italic opacity-60"
>
No content
</
span
>
}
</
span
>
</
Link
>
</
Link
>
);
);
};
};
...
...
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment