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
d5375910
Commit
d5375910
authored
Dec 22, 2025
by
Johnny
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
feat: add slash commands tooltip to InsertMenu
parent
f9dd7ad8
Changes
12
Show whitespace changes
Inline
Side-by-side
Showing
12 changed files
with
215 additions
and
178 deletions
+215
-178
SlashCommands.tsx
web/src/components/MemoEditor/Editor/SlashCommands.tsx
+10
-12
SuggestionsPopup.tsx
web/src/components/MemoEditor/Editor/SuggestionsPopup.tsx
+9
-17
TagSuggestions.tsx
web/src/components/MemoEditor/Editor/TagSuggestions.tsx
+15
-17
commands.ts
web/src/components/MemoEditor/Editor/commands.ts
+4
-4
index.tsx
web/src/components/MemoEditor/Editor/index.tsx
+59
-73
shortcuts.ts
web/src/components/MemoEditor/Editor/shortcuts.ts
+27
-20
useListCompletion.ts
web/src/components/MemoEditor/Editor/useListCompletion.ts
+60
-0
useSuggestions.ts
web/src/components/MemoEditor/Editor/useSuggestions.ts
+26
-32
InsertMenu.tsx
web/src/components/MemoEditor/Toolbar/InsertMenu.tsx
+1
-0
useMemoEditorHandlers.ts
web/src/components/MemoEditor/hooks/useMemoEditorHandlers.ts
+1
-1
useMemoEditorKeyboard.ts
web/src/components/MemoEditor/hooks/useMemoEditorKeyboard.ts
+1
-1
en.json
web/src/locales/en.json
+2
-1
No files found.
web/src/components/MemoEditor/Editor/
CommandSuggestion
s.tsx
→
web/src/components/MemoEditor/Editor/
SlashCommand
s.tsx
View file @
d5375910
import
{
observer
}
from
"mobx-react-lite"
;
import
OverflowTip
from
"@/components/kit/OverflowTip"
;
import
type
{
EditorRefActions
}
from
"."
;
import
type
{
Command
}
from
"./commands"
;
import
{
SuggestionsPopup
}
from
"./SuggestionsPopup"
;
import
{
useSuggestions
}
from
"./useSuggestions"
;
interface
CommandSuggestion
sProps
{
interface
SlashCommand
sProps
{
editorRef
:
React
.
RefObject
<
HTMLTextAreaElement
>
;
editorActions
:
React
.
ForwardedRef
<
EditorRefActions
>
;
commands
:
Command
[];
}
const
CommandSuggestions
=
observer
(({
editorRef
,
editorActions
,
commands
}:
CommandSuggestion
sProps
)
=>
{
const
SlashCommands
=
observer
(({
editorRef
,
editorActions
,
commands
}:
SlashCommand
sProps
)
=>
{
const
{
position
,
suggestions
,
selectedIndex
,
isVisible
,
handleItemSelect
}
=
useSuggestions
({
editorRef
,
editorActions
,
triggerChar
:
"/"
,
items
:
commands
,
filterItems
:
(
items
,
searchQuery
)
=>
{
if
(
!
searchQuery
)
return
items
;
// Filter commands by prefix match for intuitive searching
return
items
.
filter
((
cmd
)
=>
cmd
.
name
.
toLowerCase
().
startsWith
(
searchQuery
));
},
filterItems
:
(
items
,
query
)
=>
(
!
query
?
items
:
items
.
filter
((
cmd
)
=>
cmd
.
name
.
toLowerCase
().
startsWith
(
query
))),
onAutocomplete
:
(
cmd
,
word
,
index
,
actions
)
=>
{
// Replace the trigger word with the command output
actions
.
removeText
(
index
,
word
.
length
);
actions
.
insertText
(
cmd
.
run
());
// Position cursor if command specifies an offset
if
(
cmd
.
cursorOffset
)
{
actions
.
setCursorPosition
(
actions
.
getCursorPosition
()
+
cmd
.
cursorOffset
);
}
...
...
@@ -42,9 +35,14 @@ const CommandSuggestions = observer(({ editorRef, editorActions, commands }: Com
selectedIndex=
{
selectedIndex
}
onItemSelect=
{
handleItemSelect
}
getItemKey=
{
(
cmd
)
=>
cmd
.
name
}
renderItem=
{
(
cmd
)
=>
<
OverflowTip
>
/
{
cmd
.
name
}
</
OverflowTip
>
}
renderItem=
{
(
cmd
)
=>
(
<
span
className=
"font-medium tracking-wide"
>
<
span
className=
"text-muted-foreground"
>
/
</
span
>
{
cmd
.
name
}
</
span
>
)
}
/>
);
});
export
default
CommandSuggestion
s
;
export
default
SlashCommand
s
;
web/src/components/MemoEditor/Editor/SuggestionsPopup.tsx
View file @
d5375910
...
...
@@ -11,6 +11,12 @@ interface SuggestionsPopupProps<T> {
getItemKey
:
(
item
:
T
,
index
:
number
)
=>
string
;
}
const
POPUP_STYLES
=
{
container
:
"z-20 absolute p-1 mt-1 -ml-2 max-w-48 max-h-60 rounded border bg-popover text-popover-foreground shadow-lg font-mono flex flex-col overflow-y-auto overflow-x-hidden"
,
item
:
"rounded p-1 px-2 w-full text-sm cursor-pointer transition-colors select-none hover:bg-accent hover:text-accent-foreground"
,
};
export
function
SuggestionsPopup
<
T
>
({
position
,
suggestions
,
...
...
@@ -22,32 +28,18 @@ export function SuggestionsPopup<T>({
const
containerRef
=
useRef
<
HTMLDivElement
>
(
null
);
const
selectedItemRef
=
useRef
<
HTMLDivElement
>
(
null
);
// Scroll selected item into view when selection changes
useEffect
(()
=>
{
if
(
selectedItemRef
.
current
&&
containerRef
.
current
)
{
selectedItemRef
.
current
.
scrollIntoView
({
block
:
"nearest"
,
behavior
:
"smooth"
,
});
}
selectedItemRef
.
current
?.
scrollIntoView
({
block
:
"nearest"
,
behavior
:
"smooth"
});
},
[
selectedIndex
]);
return
(
<
div
ref=
{
containerRef
}
className=
"z-20 p-1 mt-1 -ml-2 absolute max-w-48 max-h-60 gap-px rounded font-mono flex flex-col overflow-y-auto overflow-x-hidden shadow-lg border bg-popover text-popover-foreground"
style=
{
{
left
:
position
.
left
,
top
:
position
.
top
+
position
.
height
}
}
>
<
div
ref=
{
containerRef
}
className=
{
POPUP_STYLES
.
container
}
style=
{
{
left
:
position
.
left
,
top
:
position
.
top
+
position
.
height
}
}
>
{
suggestions
.
map
((
item
,
i
)
=>
(
<
div
key=
{
getItemKey
(
item
,
i
)
}
ref=
{
i
===
selectedIndex
?
selectedItemRef
:
null
}
onMouseDown=
{
()
=>
onItemSelect
(
item
)
}
className=
{
cn
(
"rounded p-1 px-2 w-full text-sm cursor-pointer transition-colors select-none"
,
"hover:bg-accent hover:text-accent-foreground"
,
i
===
selectedIndex
?
"bg-accent text-accent-foreground"
:
""
,
)
}
className=
{
cn
(
POPUP_STYLES
.
item
,
i
===
selectedIndex
&&
"bg-accent text-accent-foreground"
)
}
>
{
renderItem
(
item
,
i
===
selectedIndex
)
}
</
div
>
...
...
web/src/components/MemoEditor/Editor/TagSuggestions.tsx
View file @
d5375910
...
...
@@ -2,7 +2,7 @@ import { observer } from "mobx-react-lite";
import
{
useMemo
}
from
"react"
;
import
OverflowTip
from
"@/components/kit/OverflowTip"
;
import
{
userStore
}
from
"@/store"
;
import
{
EditorRefActions
}
from
"."
;
import
type
{
EditorRefActions
}
from
"."
;
import
{
SuggestionsPopup
}
from
"./SuggestionsPopup"
;
import
{
useSuggestions
}
from
"./useSuggestions"
;
...
...
@@ -12,28 +12,21 @@ interface TagSuggestionsProps {
}
const
TagSuggestions
=
observer
(({
editorRef
,
editorActions
}:
TagSuggestionsProps
)
=>
{
// Sort tags by usage count (descending), then alphabetically for ties
const
sortedTags
=
useMemo
(
()
=>
Object
.
entries
(
userStore
.
state
.
tagCount
)
.
sort
((
a
,
b
)
=>
a
[
0
].
localeCompare
(
b
[
0
]))
.
sort
((
a
,
b
)
=>
b
[
1
]
-
a
[
1
])
.
map
(([
tag
])
=>
tag
),
[
userStore
.
state
.
tagCount
],
);
const
sortedTags
=
useMemo
(()
=>
{
const
tags
=
Object
.
entries
(
userStore
.
state
.
tagCount
)
.
sort
((
a
,
b
)
=>
b
[
1
]
-
a
[
1
])
// Sort by usage count (descending)
.
map
(([
tag
])
=>
tag
);
// Secondary sort by name for stable ordering
return
tags
.
sort
((
a
,
b
)
=>
(
userStore
.
state
.
tagCount
[
a
]
===
userStore
.
state
.
tagCount
[
b
]
?
a
.
localeCompare
(
b
)
:
0
));
},
[
userStore
.
state
.
tagCount
]);
const
{
position
,
suggestions
,
selectedIndex
,
isVisible
,
handleItemSelect
}
=
useSuggestions
({
editorRef
,
editorActions
,
triggerChar
:
"#"
,
items
:
sortedTags
,
filterItems
:
(
items
,
searchQuery
)
=>
{
if
(
!
searchQuery
)
return
items
;
// Filter tags by substring match for flexible searching
return
items
.
filter
((
tag
)
=>
tag
.
toLowerCase
().
includes
(
searchQuery
));
},
filterItems
:
(
items
,
query
)
=>
(
!
query
?
items
:
items
.
filter
((
tag
)
=>
tag
.
toLowerCase
().
includes
(
query
))),
onAutocomplete
:
(
tag
,
word
,
index
,
actions
)
=>
{
// Replace the trigger word with the complete tag and add a trailing space
actions
.
removeText
(
index
,
word
.
length
);
actions
.
insertText
(
`#
${
tag
}
`
);
},
...
...
@@ -48,7 +41,12 @@ const TagSuggestions = observer(({ editorRef, editorActions }: TagSuggestionsPro
selectedIndex=
{
selectedIndex
}
onItemSelect=
{
handleItemSelect
}
getItemKey=
{
(
tag
)
=>
tag
}
renderItem=
{
(
tag
)
=>
<
OverflowTip
>
#
{
tag
}
</
OverflowTip
>
}
renderItem=
{
(
tag
)
=>
(
<
OverflowTip
>
<
span
className=
"text-muted-foreground mr-1"
>
#
</
span
>
{
tag
}
</
OverflowTip
>
)
}
/>
);
});
...
...
web/src/components/MemoEditor/Editor/commands.ts
View file @
d5375910
...
...
@@ -8,21 +8,21 @@ export const editorCommands: Command[] = [
{
name
:
"todo"
,
run
:
()
=>
"- [ ] "
,
cursorOffset
:
6
,
// Places cursor after "- [ ] " to start typing task
cursorOffset
:
6
,
},
{
name
:
"code"
,
run
:
()
=>
"```
\n\n
```"
,
cursorOffset
:
4
,
// Places cursor on empty line between code fences
cursorOffset
:
4
,
},
{
name
:
"link"
,
run
:
()
=>
"[text](url)"
,
cursorOffset
:
1
,
// Places cursor after "[" to type link text
cursorOffset
:
1
,
},
{
name
:
"table"
,
run
:
()
=>
"| Header | Header |
\n
| ------ | ------ |
\n
| Cell | Cell |"
,
cursorOffset
:
1
,
// Places cursor after first "|" to edit first header
cursorOffset
:
1
,
},
];
web/src/components/MemoEditor/Editor/index.tsx
View file @
d5375910
import
{
forwardRef
,
useCallback
,
useEffect
,
useImperativeHandle
,
useRef
}
from
"react"
;
import
{
cn
}
from
"@/lib/utils"
;
import
{
EDITOR_HEIGHT
}
from
"../constants"
;
import
CommandSuggestions
from
"./CommandSuggestions"
;
import
{
editorCommands
}
from
"./commands"
;
import
SlashCommands
from
"./SlashCommands"
;
import
TagSuggestions
from
"./TagSuggestions"
;
import
{
useList
AutoCompletion
}
from
"./useListAuto
Completion"
;
import
{
useList
Completion
}
from
"./useList
Completion"
;
export
interface
EditorRefActions
{
getEditor
:
()
=>
HTMLTextAreaElement
|
null
;
...
...
@@ -56,108 +56,94 @@ const Editor = forwardRef(function Editor(props: Props, ref: React.ForwardedRef<
}
},
[]);
const
editorActions
=
{
getEditor
:
()
=>
{
return
editorRef
.
current
;
},
focus
:
()
=>
{
editorRef
.
current
?.
focus
();
},
scrollToCursor
:
()
=>
{
const
updateEditorHeight
=
()
=>
{
if
(
editorRef
.
current
)
{
editorRef
.
current
.
scrollTop
=
editorRef
.
current
.
scrollHeight
;
editorRef
.
current
.
style
.
height
=
"auto"
;
editorRef
.
current
.
style
.
height
=
(
editorRef
.
current
.
scrollHeight
??
0
)
+
"px"
;
}
};
const
updateContent
=
()
=>
{
if
(
editorRef
.
current
)
{
handleContentChangeCallback
(
editorRef
.
current
.
value
);
updateEditorHeight
();
}
};
const
editorActions
:
EditorRefActions
=
{
getEditor
:
()
=>
editorRef
.
current
,
focus
:
()
=>
editorRef
.
current
?.
focus
(),
scrollToCursor
:
()
=>
{
editorRef
.
current
&&
(
editorRef
.
current
.
scrollTop
=
editorRef
.
current
.
scrollHeight
);
},
insertText
:
(
content
=
""
,
prefix
=
""
,
suffix
=
""
)
=>
{
if
(
!
editorRef
.
current
)
{
return
;
}
const
editor
=
editorRef
.
current
;
if
(
!
editor
)
return
;
const
cursorPos
ition
=
editorRef
.
current
.
selectionStart
;
const
endPos
ition
=
editorRef
.
current
.
selectionEnd
;
const
prev
Value
=
editorRef
.
current
.
value
;
const
actual
Content
=
content
||
prevValue
.
slice
(
cursorPosition
,
endPosition
);
const
value
=
prevValue
.
slice
(
0
,
cursorPosition
)
+
prefix
+
actualContent
+
suffix
+
prevValue
.
slice
(
endPosition
);
const
cursorPos
=
editor
.
selectionStart
;
const
endPos
=
editor
.
selectionEnd
;
const
prev
=
editor
.
value
;
const
actual
=
content
||
prev
.
slice
(
cursorPos
,
endPos
);
editor
.
value
=
prev
.
slice
(
0
,
cursorPos
)
+
prefix
+
actual
+
suffix
+
prev
.
slice
(
endPos
);
editorRef
.
current
.
value
=
value
;
editorRef
.
current
.
focus
();
// Place cursor at the end of inserted content
const
newCursorPosition
=
cursorPosition
+
prefix
.
length
+
actualContent
.
length
+
suffix
.
length
;
editorRef
.
current
.
setSelectionRange
(
newCursorPosition
,
newCursorPosition
);
handleContentChangeCallback
(
editorRef
.
current
.
value
);
updateEditorHeight
();
editor
.
focus
();
editor
.
setSelectionRange
(
cursorPos
+
prefix
.
length
+
actual
.
length
,
cursorPos
+
prefix
.
length
+
actual
.
length
);
updateContent
();
},
removeText
:
(
start
:
number
,
length
:
number
)
=>
{
if
(
!
editorRef
.
current
)
{
return
;
}
const
editor
=
editorRef
.
current
;
if
(
!
editor
)
return
;
const
prevValue
=
editorRef
.
current
.
value
;
const
value
=
prevValue
.
slice
(
0
,
start
)
+
prevValue
.
slice
(
start
+
length
);
editorRef
.
current
.
value
=
value
;
editorRef
.
current
.
focus
();
editorRef
.
current
.
selectionEnd
=
start
;
handleContentChangeCallback
(
editorRef
.
current
.
value
);
updateEditorHeight
();
editor
.
value
=
editor
.
value
.
slice
(
0
,
start
)
+
editor
.
value
.
slice
(
start
+
length
);
editor
.
focus
();
editor
.
selectionEnd
=
start
;
updateContent
();
},
setContent
:
(
text
:
string
)
=>
{
if
(
editorRef
.
current
)
{
editorRef
.
current
.
value
=
text
;
handleContentChangeCallback
(
editorRef
.
current
.
value
)
;
update
EditorHeigh
t
();
const
editor
=
editorRef
.
current
;
if
(
editor
)
{
editor
.
value
=
text
;
update
Conten
t
();
}
},
getContent
:
():
string
=>
{
return
editorRef
.
current
?.
value
??
""
;
},
getCursorPosition
:
():
number
=>
{
return
editorRef
.
current
?.
selectionStart
??
0
;
},
getContent
:
()
=>
editorRef
.
current
?.
value
??
""
,
getCursorPosition
:
()
=>
editorRef
.
current
?.
selectionStart
??
0
,
getSelectedContent
:
()
=>
{
const
start
=
editorRef
.
current
?.
selectionStar
t
;
const
end
=
editorRef
.
current
?.
selectionEnd
;
return
editor
Ref
.
current
?.
value
.
slice
(
start
,
end
)
??
""
;
const
editor
=
editorRef
.
curren
t
;
if
(
!
editor
)
return
""
;
return
editor
.
value
.
slice
(
editor
.
selectionStart
,
editor
.
selectionEnd
)
;
},
setCursorPosition
:
(
startPos
:
number
,
endPos
?:
number
)
=>
{
const
_endPos
=
isNaN
(
endPos
as
number
)
?
startPos
:
(
endPos
as
number
);
editorRef
.
current
?.
setSelectionRange
(
startPos
,
_endPos
);
const
endPosition
=
isNaN
(
endPos
as
number
)
?
startPos
:
(
endPos
as
number
);
editorRef
.
current
?.
setSelectionRange
(
startPos
,
endPosition
);
},
getCursorLineNumber
:
()
=>
{
const
cursorPosition
=
editorRef
.
current
?.
selectionStart
??
0
;
const
lines
=
editorRef
.
current
?.
value
.
slice
(
0
,
cursorPosition
).
split
(
"
\n
"
)
??
[];
const
editor
=
editorRef
.
current
;
if
(
!
editor
)
return
0
;
const
lines
=
editor
.
value
.
slice
(
0
,
editor
.
selectionStart
).
split
(
"
\n
"
);
return
lines
.
length
-
1
;
},
getLine
:
(
lineNumber
:
number
)
=>
{
return
editorRef
.
current
?.
value
.
split
(
"
\n
"
)[
lineNumber
]
??
""
;
},
getLine
:
(
lineNumber
:
number
)
=>
editorRef
.
current
?.
value
.
split
(
"
\n
"
)[
lineNumber
]
??
""
,
setLine
:
(
lineNumber
:
number
,
text
:
string
)
=>
{
const
lines
=
editorRef
.
current
?.
value
.
split
(
"
\n
"
)
??
[];
const
editor
=
editorRef
.
current
;
if
(
!
editor
)
return
;
const
lines
=
editor
.
value
.
split
(
"
\n
"
);
lines
[
lineNumber
]
=
text
;
if
(
editorRef
.
current
)
{
editorRef
.
current
.
value
=
lines
.
join
(
"
\n
"
);
editorRef
.
current
.
focus
();
handleContentChangeCallback
(
editorRef
.
current
.
value
);
updateEditorHeight
();
}
editor
.
value
=
lines
.
join
(
"
\n
"
);
editor
.
focus
();
updateContent
();
},
};
useImperativeHandle
(
ref
,
()
=>
editorActions
,
[]);
const
updateEditorHeight
=
()
=>
{
if
(
editorRef
.
current
)
{
editorRef
.
current
.
style
.
height
=
"auto"
;
editorRef
.
current
.
style
.
height
=
(
editorRef
.
current
.
scrollHeight
??
0
)
+
"px"
;
}
};
const
handleEditorInput
=
useCallback
(()
=>
{
handleContentChangeCallback
(
editorRef
.
current
?.
value
??
""
);
updateEditorHeight
();
},
[]);
// Auto-complete markdown lists when pressing Enter
useList
Auto
Completion
({
useListCompletion
({
editorRef
,
editorActions
,
isInIME
,
...
...
@@ -185,7 +171,7 @@ const Editor = forwardRef(function Editor(props: Props, ref: React.ForwardedRef<
onCompositionEnd=
{
onCompositionEnd
}
></
textarea
>
<
TagSuggestions
editorRef=
{
editorRef
}
editorActions=
{
ref
}
/>
<
CommandSuggestion
s
editorRef=
{
editorRef
}
editorActions=
{
ref
}
commands=
{
editorCommands
}
/>
<
SlashCommand
s
editorRef=
{
editorRef
}
editorActions=
{
ref
}
commands=
{
editorCommands
}
/>
</
div
>
);
});
...
...
web/src/components/MemoEditor/Editor/
markdownS
hortcuts.ts
→
web/src/components/MemoEditor/Editor/
s
hortcuts.ts
View file @
d5375910
import
type
{
EditorRefActions
}
from
"./index"
;
const
SHORTCUTS
=
{
BOLD
:
{
key
:
"b"
,
delimiter
:
"**"
},
ITALIC
:
{
key
:
"i"
,
delimiter
:
"*"
},
LINK
:
{
key
:
"k"
},
}
as
const
;
const
URL_PLACEHOLDER
=
"url"
;
const
URL_REGEX
=
/^https
?
:
\/\/[^\s]
+$/
;
const
LINK_OFFSET
=
3
;
// Length of "]()"
export
function
handleMarkdownShortcuts
(
event
:
React
.
KeyboardEvent
,
editor
:
EditorRefActions
):
void
{
switch
(
event
.
key
.
toLowerCase
())
{
case
"b"
:
const
key
=
event
.
key
.
toLowerCase
();
if
(
key
===
SHORTCUTS
.
BOLD
.
key
)
{
event
.
preventDefault
();
toggleTextStyle
(
editor
,
"**"
);
break
;
case
"i"
:
toggleTextStyle
(
editor
,
SHORTCUTS
.
BOLD
.
delimiter
);
}
else
if
(
key
===
SHORTCUTS
.
ITALIC
.
key
)
{
event
.
preventDefault
();
toggleTextStyle
(
editor
,
"*"
);
break
;
case
"k"
:
toggleTextStyle
(
editor
,
SHORTCUTS
.
ITALIC
.
delimiter
);
}
else
if
(
key
===
SHORTCUTS
.
LINK
.
key
)
{
event
.
preventDefault
();
insertHyperlink
(
editor
);
break
;
}
}
export
function
insertHyperlink
(
editor
:
EditorRefActions
,
url
?:
string
):
void
{
const
cursorPosition
=
editor
.
getCursorPosition
();
const
selectedContent
=
editor
.
getSelectedContent
();
const
placeholderUrl
=
"url"
;
const
urlRegex
=
/^https
?
:
\/\/[^\s]
+$/
;
const
isUrlSelected
=
!
url
&&
URL_REGEX
.
test
(
selectedContent
.
trim
());
if
(
!
url
&&
urlRegex
.
test
(
selectedContent
.
trim
())
)
{
if
(
isUrlSelected
)
{
editor
.
insertText
(
`[](
${
selectedContent
}
)`
);
editor
.
setCursorPosition
(
cursorPosition
+
1
,
cursorPosition
+
1
);
return
;
}
const
href
=
url
??
placeholderUrl
;
const
href
=
url
??
URL_PLACEHOLDER
;
editor
.
insertText
(
`[
${
selectedContent
}
](
${
href
}
)`
);
if
(
href
===
placeholderUrl
)
{
const
urlStart
=
cursorPosition
+
selectedContent
.
length
+
3
;
if
(
href
===
URL_PLACEHOLDER
)
{
const
urlStart
=
cursorPosition
+
selectedContent
.
length
+
LINK_OFFSET
;
editor
.
setCursorPosition
(
urlStart
,
urlStart
+
href
.
length
);
}
}
...
...
@@ -41,8 +47,9 @@ export function insertHyperlink(editor: EditorRefActions, url?: string): void {
function
toggleTextStyle
(
editor
:
EditorRefActions
,
delimiter
:
string
):
void
{
const
cursorPosition
=
editor
.
getCursorPosition
();
const
selectedContent
=
editor
.
getSelectedContent
();
const
isStyled
=
selectedContent
.
startsWith
(
delimiter
)
&&
selectedContent
.
endsWith
(
delimiter
);
if
(
selectedContent
.
startsWith
(
delimiter
)
&&
selectedContent
.
endsWith
(
delimiter
)
)
{
if
(
isStyled
)
{
const
unstyled
=
selectedContent
.
slice
(
delimiter
.
length
,
-
delimiter
.
length
);
editor
.
insertText
(
unstyled
);
editor
.
setCursorPosition
(
cursorPosition
,
cursorPosition
+
unstyled
.
length
);
...
...
web/src/components/MemoEditor/Editor/useList
Auto
Completion.ts
→
web/src/components/MemoEditor/Editor/useListCompletion.ts
View file @
d5375910
...
...
@@ -2,14 +2,22 @@ import { useEffect, useRef } from "react";
import
{
detectLastListItem
,
generateListContinuation
}
from
"@/utils/markdown-list-detection"
;
import
{
EditorRefActions
}
from
"."
;
interface
UseList
Auto
CompletionOptions
{
interface
UseListCompletionOptions
{
editorRef
:
React
.
RefObject
<
HTMLTextAreaElement
>
;
editorActions
:
EditorRefActions
;
isInIME
:
boolean
;
}
export
function
useListAutoCompletion
({
editorRef
,
editorActions
,
isInIME
}:
UseListAutoCompletionOptions
)
{
// Use refs to avoid stale closures in event handlers
// Patterns to detect empty list items
const
EMPTY_LIST_PATTERNS
=
[
/^
(\s
*
)([
-*+
])\s
*$/
,
// Empty unordered list
/^
(\s
*
)([
-*+
])\s
+
\[([
xX
])\]\s
*$/
,
// Empty task list
/^
(\s
*
)(\d
+
)[
.)
]\s
*$/
,
// Empty ordered list
];
const
isEmptyListItem
=
(
line
:
string
)
=>
EMPTY_LIST_PATTERNS
.
some
((
pattern
)
=>
pattern
.
test
(
line
));
export
function
useListCompletion
({
editorRef
,
editorActions
,
isInIME
}:
UseListCompletionOptions
)
{
const
isInIMERef
=
useRef
(
isInIME
);
isInIMERef
.
current
=
isInIME
;
...
...
@@ -21,52 +29,32 @@ export function useListAutoCompletion({ editorRef, editorActions, isInIME }: Use
if
(
!
editor
)
return
;
const
handleKeyDown
=
(
event
:
KeyboardEvent
)
=>
{
// Only handle Enter key
if
(
event
.
key
!==
"Enter"
)
return
;
// Don't handle if in IME composition (for Asian languages)
if
(
isInIMERef
.
current
)
return
;
// Don't handle if modifier keys are pressed (user wants manual control)
if
(
event
.
shiftKey
||
event
.
ctrlKey
||
event
.
metaKey
||
event
.
altKey
)
return
;
if
(
event
.
key
!==
"Enter"
||
isInIMERef
.
current
||
event
.
shiftKey
||
event
.
ctrlKey
||
event
.
metaKey
||
event
.
altKey
)
{
return
;
}
const
actions
=
editorActionsRef
.
current
;
const
cursorPosition
=
actions
.
getCursorPosition
();
const
contentBeforeCursor
=
actions
.
getContent
().
substring
(
0
,
cursorPosition
);
// Detect if we're on a list item
const
listInfo
=
detectLastListItem
(
contentBeforeCursor
);
if
(
listInfo
.
type
)
{
if
(
!
listInfo
.
type
)
return
;
event
.
preventDefault
();
// Check if current list item is empty (GitHub-style behavior)
// Extract the current line
const
lines
=
contentBeforeCursor
.
split
(
"
\n
"
);
const
currentLine
=
lines
[
lines
.
length
-
1
];
// Check if line only contains list marker (no content after it)
const
isEmptyListItem
=
/^
(\s
*
)([
-*+
])\s
*$/
.
test
(
currentLine
)
||
// Empty unordered list
/^
(\s
*
)([
-*+
])\s
+
\[([
xX
])\]\s
*$/
.
test
(
currentLine
)
||
// Empty task list
/^
(\s
*
)(\d
+
)[
.)
]\s
*$/
.
test
(
currentLine
);
// Empty ordered list
if
(
isEmptyListItem
)
{
// Remove the empty list marker and exit list mode
if
(
isEmptyListItem
(
currentLine
))
{
const
lineStartPos
=
cursorPosition
-
currentLine
.
length
;
actions
.
removeText
(
lineStartPos
,
currentLine
.
length
);
}
else
{
// Continue the list with the next item
const
continuation
=
generateListContinuation
(
listInfo
);
actions
.
insertText
(
"
\n
"
+
continuation
);
}
}
};
editor
.
addEventListener
(
"keydown"
,
handleKeyDown
);
return
()
=>
{
editor
.
removeEventListener
(
"keydown"
,
handleKeyDown
);
};
},
[]);
// Editor ref is stable; state accessed via refs to avoid stale closures
return
()
=>
editor
.
removeEventListener
(
"keydown"
,
handleKeyDown
);
},
[]);
}
web/src/components/MemoEditor/Editor/useSuggestions.ts
View file @
d5375910
...
...
@@ -36,7 +36,6 @@ export function useSuggestions<T>({
const
[
position
,
setPosition
]
=
useState
<
Position
|
null
>
(
null
);
const
[
selectedIndex
,
setSelectedIndex
]
=
useState
(
0
);
// Use refs to avoid stale closures in event handlers
const
selectedRef
=
useRef
(
selectedIndex
);
selectedRef
.
current
=
selectedIndex
;
...
...
@@ -51,7 +50,6 @@ export function useSuggestions<T>({
const
hide
=
()
=>
setPosition
(
null
);
// Filter items based on the current word after the trigger character
const
suggestionsRef
=
useRef
<
T
[]
>
([]);
suggestionsRef
.
current
=
(()
=>
{
const
[
word
]
=
getCurrentWord
();
...
...
@@ -65,7 +63,7 @@ export function useSuggestions<T>({
const
handleAutocomplete
=
(
item
:
T
)
=>
{
if
(
!
editorActions
||
!
(
"current"
in
editorActions
)
||
!
editorActions
.
current
)
{
console
.
warn
(
"useSuggestions: editorActions not available
for autocomplete
"
);
console
.
warn
(
"useSuggestions: editorActions not available"
);
return
;
}
const
[
word
,
index
]
=
getCurrentWord
();
...
...
@@ -73,39 +71,37 @@ export function useSuggestions<T>({
hide
();
};
const
handleNavigation
=
(
e
:
KeyboardEvent
,
selected
:
number
,
suggestionsCount
:
number
)
=>
{
if
(
e
.
code
===
"ArrowDown"
)
{
setSelectedIndex
((
selected
+
1
)
%
suggestionsCount
);
e
.
preventDefault
();
e
.
stopPropagation
();
}
else
if
(
e
.
code
===
"ArrowUp"
)
{
setSelectedIndex
((
selected
-
1
+
suggestionsCount
)
%
suggestionsCount
);
e
.
preventDefault
();
e
.
stopPropagation
();
}
};
const
handleKeyDown
=
(
e
:
KeyboardEvent
)
=>
{
if
(
!
isVisibleRef
.
current
)
return
;
const
suggestions
=
suggestionsRef
.
current
;
const
selected
=
selectedRef
.
current
;
// Hide on Escape or horizontal arrows
if
([
"Escape"
,
"ArrowLeft"
,
"ArrowRight"
].
includes
(
e
.
code
))
{
hide
();
return
;
}
// Navigate down
if
(
e
.
code
===
"ArrowDown"
)
{
setSelectedIndex
((
selected
+
1
)
%
suggestions
.
length
);
e
.
preventDefault
();
e
.
stopPropagation
();
return
;
}
// Navigate up
if
(
e
.
code
===
"ArrowUp"
)
{
setSelectedIndex
((
selected
-
1
+
suggestions
.
length
)
%
suggestions
.
length
);
e
.
preventDefault
();
e
.
stopPropagation
();
if
([
"ArrowDown"
,
"ArrowUp"
].
includes
(
e
.
code
))
{
handleNavigation
(
e
,
selected
,
suggestions
.
length
);
return
;
}
// Accept suggestion
if
([
"Enter"
,
"Tab"
].
includes
(
e
.
code
))
{
handleAutocomplete
(
suggestions
[
selected
]);
e
.
preventDefault
();
// Prevent other listeners to be executed
e
.
stopImmediatePropagation
();
}
};
...
...
@@ -120,31 +116,29 @@ export function useSuggestions<T>({
const
isActive
=
word
.
startsWith
(
triggerChar
)
&&
currentChar
!==
triggerChar
;
if
(
isActive
)
{
const
c
aretCoordinate
s
=
getCaretCoordinates
(
editor
,
index
);
c
aretCoordinate
s
.
top
-=
editor
.
scrollTop
;
setPosition
(
c
aretCoordinate
s
);
const
c
oord
s
=
getCaretCoordinates
(
editor
,
index
);
c
oord
s
.
top
-=
editor
.
scrollTop
;
setPosition
(
c
oord
s
);
}
else
{
hide
();
}
};
// Register event listeners
useEffect
(()
=>
{
const
editor
=
editorRef
.
current
;
if
(
!
editor
)
return
;
editor
.
addEventListener
(
"click"
,
hide
)
;
editor
.
addEventListener
(
"blur"
,
hide
);
editor
.
addEventListener
(
"keydown"
,
handleKeyDown
);
editor
.
addEventListener
(
"input"
,
handleInput
);
const
handlers
=
{
click
:
hide
,
blur
:
hide
,
keydown
:
handleKeyDown
,
input
:
handleInput
}
;
Object
.
entries
(
handlers
).
forEach
(([
event
,
handler
])
=>
{
editor
.
addEventListener
(
event
,
handler
as
EventListener
);
}
);
return
()
=>
{
editor
.
removeEventListener
(
"click"
,
hide
);
editor
.
removeEventListener
(
"blur"
,
hide
);
editor
.
removeEventListener
(
"keydown"
,
handleKeyDown
);
editor
.
removeEventListener
(
"input"
,
handleInput
);
Object
.
entries
(
handlers
).
forEach
(([
event
,
handler
])
=>
{
editor
.
removeEventListener
(
event
,
handler
as
EventListener
);
});
};
},
[]);
// Empty deps - editor ref is stable, handlers use refs for fresh values
},
[]);
return
{
position
,
...
...
web/src/components/MemoEditor/Toolbar/InsertMenu.tsx
View file @
d5375910
...
...
@@ -183,6 +183,7 @@ const InsertMenu = observer((props: Props) => {
</
DropdownMenuItem
>
</
DropdownMenuSubContent
>
</
DropdownMenuSub
>
<
div
className=
"px-2 py-1 text-xs text-muted-foreground opacity-80"
>
{
t
(
"editor.slash-commands"
)
}
</
div
>
</
DropdownMenuContent
>
</
DropdownMenu
>
...
...
web/src/components/MemoEditor/hooks/useMemoEditorHandlers.ts
View file @
d5375910
import
{
useCallback
}
from
"react"
;
import
{
isValidUrl
}
from
"@/helpers/utils"
;
import
type
{
EditorRefActions
}
from
"../Editor"
;
import
{
hyperlinkHighlightedText
}
from
"../Editor/
markdownS
hortcuts"
;
import
{
hyperlinkHighlightedText
}
from
"../Editor/
s
hortcuts"
;
export
interface
UseMemoEditorHandlersOptions
{
editorRef
:
React
.
RefObject
<
EditorRefActions
>
;
...
...
web/src/components/MemoEditor/hooks/useMemoEditorKeyboard.ts
View file @
d5375910
...
...
@@ -2,7 +2,7 @@ import { useCallback } from "react";
import
{
TAB_SPACE_WIDTH
}
from
"@/helpers/consts"
;
import
{
FOCUS_MODE_EXIT_KEY
,
FOCUS_MODE_TOGGLE_KEY
}
from
"../constants"
;
import
type
{
EditorRefActions
}
from
"../Editor"
;
import
{
handleMarkdownShortcuts
}
from
"../Editor/
markdownS
hortcuts"
;
import
{
handleMarkdownShortcuts
}
from
"../Editor/
s
hortcuts"
;
export
interface
UseMemoEditorKeyboardOptions
{
editorRef
:
React
.
RefObject
<
EditorRefActions
>
;
...
...
web/src/locales/en.json
View file @
d5375910
...
...
@@ -122,7 +122,8 @@
"save"
:
"Save"
,
"no-changes-detected"
:
"No changes detected"
,
"focus-mode"
:
"Focus Mode"
,
"exit-focus-mode"
:
"Exit Focus Mode"
"exit-focus-mode"
:
"Exit Focus Mode"
,
"slash-commands"
:
"Type `/` for commands"
},
"filters"
:
{
"has-code"
:
"hasCode"
,
...
...
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