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
e8b02734
Unverified
Commit
e8b02734
authored
Nov 08, 2025
by
Johnny
Committed by
GitHub
Nov 08, 2025
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
chore(web): improve CommandSuggestions and TagSuggestions components (#5239)
Co-authored-by:
Claude
<
noreply@anthropic.com
>
parent
bd21338f
Changes
6
Show whitespace changes
Inline
Side-by-side
Showing
6 changed files
with
430 additions
and
250 deletions
+430
-250
CommandSuggestions.tsx
web/src/components/MemoEditor/Editor/CommandSuggestions.tsx
+46
-113
SuggestionsPopup.tsx
web/src/components/MemoEditor/Editor/SuggestionsPopup.tsx
+67
-0
TagSuggestions.tsx
web/src/components/MemoEditor/Editor/TagSuggestions.tsx
+50
-117
index.tsx
web/src/components/MemoEditor/Editor/index.tsx
+7
-20
useListAutoCompletion.ts
...src/components/MemoEditor/Editor/useListAutoCompletion.ts
+66
-0
useSuggestions.ts
web/src/components/MemoEditor/Editor/useSuggestions.ts
+194
-0
No files found.
web/src/components/MemoEditor/Editor/CommandSuggestions.tsx
View file @
e8b02734
import
{
observer
}
from
"mobx-react-lite"
;
import
{
observer
}
from
"mobx-react-lite"
;
import
{
useEffect
,
useRef
,
useState
}
from
"react"
;
import
getCaretCoordinates
from
"textarea-caret"
;
import
OverflowTip
from
"@/components/kit/OverflowTip"
;
import
OverflowTip
from
"@/components/kit/OverflowTip"
;
import
{
cn
}
from
"@/lib/utils"
;
import
{
EditorRefActions
}
from
"."
;
import
{
EditorRefActions
}
from
"."
;
import
{
Command
}
from
"../types/command"
;
import
{
Command
}
from
"../types/command"
;
import
{
SuggestionsPopup
}
from
"./SuggestionsPopup"
;
import
{
useSuggestions
}
from
"./useSuggestions"
;
type
Props
=
{
interface
CommandSuggestionsProps
{
editorRef
:
React
.
RefObject
<
HTMLTextAreaElement
>
;
editorRef
:
React
.
RefObject
<
HTMLTextAreaElement
>
;
editorActions
:
React
.
ForwardedRef
<
EditorRefActions
>
;
editorActions
:
React
.
ForwardedRef
<
EditorRefActions
>
;
commands
:
Command
[];
commands
:
Command
[];
}
;
}
type
Position
=
{
left
:
number
;
top
:
number
;
height
:
number
};
/**
* Command suggestions popup that appears when typing "/" in the editor.
const
CommandSuggestions
=
observer
(({
editorRef
,
editorActions
,
commands
}:
Props
)
=>
{
* Shows available editor commands like formatting options, insertions, etc.
const
[
position
,
setPosition
]
=
useState
<
Position
|
null
>
(
null
);
*
const
[
selected
,
select
]
=
useState
(
0
);
* Usage:
const
selectedRef
=
useRef
(
selected
);
* - Type "/" to trigger
selectedRef
.
current
=
selected
;
* - Continue typing to filter commands
* - Use Arrow keys to navigate, Enter/Tab to select
const
hide
=
()
=>
setPosition
(
null
);
*/
const
CommandSuggestions
=
observer
(({
editorRef
,
editorActions
,
commands
}:
CommandSuggestionsProps
)
=>
{
const
getCurrentWord
=
():
[
word
:
string
,
startIndex
:
number
]
=>
{
const
{
position
,
suggestions
,
selectedIndex
,
isVisible
,
handleItemSelect
}
=
useSuggestions
({
const
editor
=
editorRef
.
current
;
editorRef
,
if
(
!
editor
)
return
[
""
,
0
];
editorActions
,
const
cursorPos
=
editor
.
selectionEnd
;
triggerChar
:
"/"
,
const
before
=
editor
.
value
.
slice
(
0
,
cursorPos
).
match
(
/
\S
*$/
)
||
{
0
:
""
,
index
:
cursorPos
};
items
:
commands
,
const
after
=
editor
.
value
.
slice
(
cursorPos
).
match
(
/^
\S
*/
)
||
{
0
:
""
};
filterItems
:
(
items
,
searchQuery
)
=>
{
return
[
before
[
0
]
+
after
[
0
],
before
.
index
??
cursorPos
];
if
(
!
searchQuery
)
return
items
;
};
// Filter commands by prefix match for intuitive searching
return
items
.
filter
((
cmd
)
=>
cmd
.
name
.
toLowerCase
().
startsWith
(
searchQuery
));
// Filter commands based on the current word after the slash
},
const
suggestionsRef
=
useRef
<
Command
[]
>
([]);
onAutocomplete
:
(
cmd
,
word
,
index
,
actions
)
=>
{
suggestionsRef
.
current
=
(()
=>
{
// Replace the trigger word with the command output
const
[
word
]
=
getCurrentWord
();
actions
.
removeText
(
index
,
word
.
length
);
if
(
!
word
.
startsWith
(
"/"
))
return
[];
actions
.
insertText
(
cmd
.
run
());
const
search
=
word
.
slice
(
1
).
toLowerCase
();
// Position cursor if command specifies an offset
if
(
!
search
)
return
commands
;
return
commands
.
filter
((
cmd
)
=>
cmd
.
name
.
toLowerCase
().
startsWith
(
search
));
})();
const
isVisibleRef
=
useRef
(
false
);
isVisibleRef
.
current
=
!!
(
position
&&
suggestionsRef
.
current
.
length
>
0
);
const
autocomplete
=
(
cmd
:
Command
)
=>
{
if
(
!
editorActions
||
!
(
"current"
in
editorActions
)
||
!
editorActions
.
current
)
return
;
const
[
word
,
index
]
=
getCurrentWord
();
editorActions
.
current
.
removeText
(
index
,
word
.
length
);
editorActions
.
current
.
insertText
(
cmd
.
run
());
if
(
cmd
.
cursorOffset
)
{
if
(
cmd
.
cursorOffset
)
{
editorActions
.
current
.
setCursorPosition
(
editorActions
.
current
.
getCursorPosition
()
+
cmd
.
cursorOffset
);
actions
.
setCursorPosition
(
actions
.
getCursorPosition
()
+
cmd
.
cursorOffset
);
}
hide
();
};
const
handleKeyDown
=
(
e
:
KeyboardEvent
)
=>
{
if
(
!
isVisibleRef
.
current
)
return
;
const
suggestions
=
suggestionsRef
.
current
;
const
selected
=
selectedRef
.
current
;
if
([
"Escape"
,
"ArrowLeft"
,
"ArrowRight"
].
includes
(
e
.
code
))
hide
();
if
(
"ArrowDown"
===
e
.
code
)
{
select
((
selected
+
1
)
%
suggestions
.
length
);
e
.
preventDefault
();
e
.
stopPropagation
();
}
if
(
"ArrowUp"
===
e
.
code
)
{
select
((
selected
-
1
+
suggestions
.
length
)
%
suggestions
.
length
);
e
.
preventDefault
();
e
.
stopPropagation
();
}
}
if
([
"Enter"
,
"Tab"
].
includes
(
e
.
code
))
{
},
autocomplete
(
suggestions
[
selected
]);
});
e
.
preventDefault
();
e
.
stopPropagation
();
}
};
const
handleInput
=
()
=>
{
if
(
!
isVisible
||
!
position
)
return
null
;
const
editor
=
editorRef
.
current
;
if
(
!
editor
)
return
;
select
(
0
);
const
[
word
,
index
]
=
getCurrentWord
();
const
currentChar
=
editor
.
value
[
editor
.
selectionEnd
];
const
isActive
=
word
.
startsWith
(
"/"
)
&&
currentChar
!==
"/"
;
const
caretCordinates
=
getCaretCoordinates
(
editor
,
index
);
caretCordinates
.
top
-=
editor
.
scrollTop
;
if
(
isActive
)
{
setPosition
(
caretCordinates
);
}
else
{
hide
();
}
};
const
listenersAreRegisteredRef
=
useRef
(
false
);
const
registerListeners
=
()
=>
{
const
editor
=
editorRef
.
current
;
if
(
!
editor
||
listenersAreRegisteredRef
.
current
)
return
;
editor
.
addEventListener
(
"click"
,
hide
);
editor
.
addEventListener
(
"blur"
,
hide
);
editor
.
addEventListener
(
"keydown"
,
handleKeyDown
);
editor
.
addEventListener
(
"input"
,
handleInput
);
listenersAreRegisteredRef
.
current
=
true
;
};
useEffect
(
registerListeners
,
[
!!
editorRef
.
current
]);
if
(
!
isVisibleRef
.
current
||
!
position
)
return
null
;
return
(
return
(
<
div
<
SuggestionsPopup
className=
"z-20 p-1 mt-1 -ml-2 absolute max-w-48 gap-px rounded font-mono flex flex-col justify-start items-start overflow-auto shadow bg-popover"
position=
{
position
}
style=
{
{
left
:
position
.
left
,
top
:
position
.
top
+
position
.
height
}
}
suggestions=
{
suggestions
}
>
selectedIndex=
{
selectedIndex
}
{
suggestionsRef
.
current
.
map
((
cmd
,
i
)
=>
(
onItemSelect=
{
handleItemSelect
}
<
div
getItemKey=
{
(
cmd
)
=>
cmd
.
name
}
key=
{
cmd
.
name
}
renderItem=
{
(
cmd
)
=>
(
onMouseDown=
{
()
=>
autocomplete
(
cmd
)
}
<>
className=
{
cn
(
"rounded p-1 px-2 w-full truncate text-sm cursor-pointer hover:bg-accent hover:text-accent-foreground"
,
i
===
selected
?
"bg-accent text-accent-foreground"
:
""
,
)
}
>
<
OverflowTip
>
/
{
cmd
.
name
}
</
OverflowTip
>
<
OverflowTip
>
/
{
cmd
.
name
}
</
OverflowTip
>
{
cmd
.
description
&&
<
span
className=
"ml-2 text-xs text-muted-foreground"
>
{
cmd
.
description
}
</
span
>
}
{
cmd
.
description
&&
<
span
className=
"ml-2 text-xs text-muted-foreground"
>
{
cmd
.
description
}
</
span
>
}
</
div
>
</>
)
)
}
)
}
</
div
>
/
>
);
);
});
});
...
...
web/src/components/MemoEditor/Editor/SuggestionsPopup.tsx
0 → 100644
View file @
e8b02734
import
{
ReactNode
,
useEffect
,
useRef
}
from
"react"
;
import
{
cn
}
from
"@/lib/utils"
;
import
{
Position
}
from
"./useSuggestions"
;
interface
SuggestionsPopupProps
<
T
>
{
position
:
Position
;
suggestions
:
T
[];
selectedIndex
:
number
;
onItemSelect
:
(
item
:
T
)
=>
void
;
renderItem
:
(
item
:
T
,
isSelected
:
boolean
)
=>
ReactNode
;
getItemKey
:
(
item
:
T
,
index
:
number
)
=>
string
;
}
/**
* Shared popup component for displaying suggestion items.
* Provides consistent styling and behavior across different suggestion types.
*
* Features:
* - Automatically scrolls selected item into view
* - Handles keyboard navigation highlighting
* - Prevents text selection during mouse interaction
* - Consistent styling with max height constraints
*/
export
function
SuggestionsPopup
<
T
>
({
position
,
suggestions
,
selectedIndex
,
onItemSelect
,
renderItem
,
getItemKey
,
}:
SuggestionsPopupProps
<
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"
,
});
}
},
[
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
}
}
>
{
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"
:
""
,
)
}
>
{
renderItem
(
item
,
i
===
selectedIndex
)
}
</
div
>
))
}
</
div
>
);
}
web/src/components/MemoEditor/Editor/TagSuggestions.tsx
View file @
e8b02734
import
Fuse
from
"fuse.js"
;
import
{
observer
}
from
"mobx-react-lite"
;
import
{
observer
}
from
"mobx-react-lite"
;
import
{
useEffect
,
useRef
,
useState
}
from
"react"
;
import
{
useMemo
}
from
"react"
;
import
getCaretCoordinates
from
"textarea-caret"
;
import
OverflowTip
from
"@/components/kit/OverflowTip"
;
import
OverflowTip
from
"@/components/kit/OverflowTip"
;
import
{
cn
}
from
"@/lib/utils"
;
import
{
userStore
}
from
"@/store"
;
import
{
userStore
}
from
"@/store"
;
import
{
EditorRefActions
}
from
"."
;
import
{
EditorRefActions
}
from
"."
;
import
{
SuggestionsPopup
}
from
"./SuggestionsPopup"
;
import
{
useSuggestions
}
from
"./useSuggestions"
;
type
Props
=
{
interface
TagSuggestionsProps
{
editorRef
:
React
.
RefObject
<
HTMLTextAreaElement
>
;
editorRef
:
React
.
RefObject
<
HTMLTextAreaElement
>
;
editorActions
:
React
.
ForwardedRef
<
EditorRefActions
>
;
editorActions
:
React
.
ForwardedRef
<
EditorRefActions
>
;
}
;
}
type
Position
=
{
left
:
number
;
top
:
number
;
height
:
number
};
/**
* Tag suggestions popup that appears when typing "#" in the editor.
const
TagSuggestions
=
observer
(({
editorRef
,
editorActions
}:
Props
)
=>
{
* Shows previously used tags sorted by frequency.
const
[
position
,
setPosition
]
=
useState
<
Position
|
null
>
(
null
);
*
const
[
selected
,
select
]
=
useState
(
0
);
* Usage:
const
selectedRef
=
useRef
(
selected
);
* - Type "#" to trigger
selectedRef
.
current
=
selected
;
* - Continue typing to filter tags
const
tags
=
Object
.
entries
(
userStore
.
state
.
tagCount
)
* - Use Arrow keys to navigate, Enter/Tab to select
* - Tags are sorted by usage count (most used first)
*/
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
)
=>
a
[
0
].
localeCompare
(
b
[
0
]))
.
sort
((
a
,
b
)
=>
b
[
1
]
-
a
[
1
])
.
sort
((
a
,
b
)
=>
b
[
1
]
-
a
[
1
])
.
map
(([
tag
])
=>
tag
);
.
map
(([
tag
])
=>
tag
),
[
userStore
.
state
.
tagCount
],
const
hide
=
()
=>
setPosition
(
null
);
);
const
getCurrentWord
=
():
[
word
:
string
,
startIndex
:
number
]
=>
{
const
editor
=
editorRef
.
current
;
if
(
!
editor
)
return
[
""
,
0
];
const
cursorPos
=
editor
.
selectionEnd
;
const
before
=
editor
.
value
.
slice
(
0
,
cursorPos
).
match
(
/
\S
*$/
)
||
{
0
:
""
,
index
:
cursorPos
};
const
after
=
editor
.
value
.
slice
(
cursorPos
).
match
(
/^
\S
*/
)
||
{
0
:
""
};
return
[
before
[
0
]
+
after
[
0
],
before
.
index
??
cursorPos
];
};
const
suggestionsRef
=
useRef
<
string
[]
>
([]);
suggestionsRef
.
current
=
(()
=>
{
const
search
=
getCurrentWord
()[
0
].
slice
(
1
).
toLowerCase
();
if
(
search
===
""
)
{
return
tags
;
// Show all tags when no search term
}
const
fuse
=
new
Fuse
(
tags
);
return
fuse
.
search
(
search
).
map
((
result
)
=>
result
.
item
);
})();
const
isVisibleRef
=
useRef
(
false
);
isVisibleRef
.
current
=
!!
(
position
&&
suggestionsRef
.
current
.
length
>
0
);
const
autocomplete
=
(
tag
:
string
)
=>
{
if
(
!
editorActions
||
!
(
"current"
in
editorActions
)
||
!
editorActions
.
current
)
return
;
const
[
word
,
index
]
=
getCurrentWord
();
editorActions
.
current
.
removeText
(
index
,
word
.
length
);
editorActions
.
current
.
insertText
(
`#
${
tag
}
`
);
hide
();
};
const
handleKeyDown
=
(
e
:
KeyboardEvent
)
=>
{
if
(
!
isVisibleRef
.
current
)
return
;
const
suggestions
=
suggestionsRef
.
current
;
const
selected
=
selectedRef
.
current
;
if
([
"Escape"
,
"ArrowLeft"
,
"ArrowRight"
].
includes
(
e
.
code
))
hide
();
if
(
"ArrowDown"
===
e
.
code
)
{
select
((
selected
+
1
)
%
suggestions
.
length
);
e
.
preventDefault
();
e
.
stopPropagation
();
}
if
(
"ArrowUp"
===
e
.
code
)
{
select
((
selected
-
1
+
suggestions
.
length
)
%
suggestions
.
length
);
e
.
preventDefault
();
e
.
stopPropagation
();
}
if
([
"Enter"
,
"Tab"
].
includes
(
e
.
code
))
{
autocomplete
(
suggestions
[
selected
]);
e
.
preventDefault
();
e
.
stopPropagation
();
}
};
const
handleInput
=
()
=>
{
const
editor
=
editorRef
.
current
;
if
(
!
editor
)
return
;
select
(
0
);
const
[
word
,
index
]
=
getCurrentWord
();
const
currentChar
=
editor
.
value
[
editor
.
selectionEnd
];
const
isActive
=
word
.
startsWith
(
"#"
)
&&
currentChar
!==
"#"
;
const
caretCordinates
=
getCaretCoordinates
(
editor
,
index
);
const
{
position
,
suggestions
,
selectedIndex
,
isVisible
,
handleItemSelect
}
=
useSuggestions
({
caretCordinates
.
top
-=
editor
.
scrollTop
;
editorRef
,
if
(
isActive
)
{
editorActions
,
setPosition
(
caretCordinates
);
triggerChar
:
"#"
,
}
else
{
items
:
sortedTags
,
hide
();
filterItems
:
(
items
,
searchQuery
)
=>
{
}
if
(
!
searchQuery
)
return
items
;
};
// Filter tags by substring match for flexible searching
return
items
.
filter
((
tag
)
=>
tag
.
toLowerCase
().
includes
(
searchQuery
));
},
onAutocomplete
:
(
tag
,
word
,
index
,
actions
)
=>
{
// Replace the trigger word with the complete tag
actions
.
removeText
(
index
,
word
.
length
);
actions
.
insertText
(
`#
${
tag
}
`
);
},
});
const
listenersAreRegisteredRef
=
useRef
(
false
);
if
(
!
isVisible
||
!
position
)
return
null
;
const
registerListeners
=
()
=>
{
const
editor
=
editorRef
.
current
;
if
(
!
editor
||
listenersAreRegisteredRef
.
current
)
return
;
editor
.
addEventListener
(
"click"
,
hide
);
editor
.
addEventListener
(
"blur"
,
hide
);
editor
.
addEventListener
(
"keydown"
,
handleKeyDown
);
editor
.
addEventListener
(
"input"
,
handleInput
);
listenersAreRegisteredRef
.
current
=
true
;
};
useEffect
(
registerListeners
,
[
!!
editorRef
.
current
]);
if
(
!
isVisibleRef
.
current
||
!
position
)
return
null
;
return
(
return
(
<
div
<
SuggestionsPopup
className=
"z-20 p-1 mt-1 -ml-2 absolute max-w-48 gap-px rounded font-mono flex flex-col justify-start items-start overflow-auto shadow bg-popover"
position=
{
position
}
style=
{
{
left
:
position
.
left
,
top
:
position
.
top
+
position
.
height
}
}
suggestions=
{
suggestions
}
>
selectedIndex=
{
selectedIndex
}
{
suggestionsRef
.
current
.
map
((
tag
,
i
)
=>
(
onItemSelect=
{
handleItemSelect
}
<
div
getItemKey=
{
(
tag
)
=>
tag
}
key=
{
tag
}
renderItem=
{
(
tag
)
=>
<
OverflowTip
>
#
{
tag
}
</
OverflowTip
>
}
onMouseDown=
{
()
=>
autocomplete
(
tag
)
}
/>
className=
{
cn
(
"rounded p-1 px-2 w-full truncate text-sm cursor-pointer hover:bg-accent hover:text-accent-foreground"
,
i
===
selected
?
"bg-accent text-accent-foreground"
:
""
,
)
}
>
<
OverflowTip
>
#
{
tag
}
</
OverflowTip
>
</
div
>
))
}
</
div
>
);
);
});
});
...
...
web/src/components/MemoEditor/Editor/index.tsx
View file @
e8b02734
import
{
forwardRef
,
ReactNode
,
useCallback
,
useEffect
,
useImperativeHandle
,
useRef
,
useState
}
from
"react"
;
import
{
forwardRef
,
ReactNode
,
useCallback
,
useEffect
,
useImperativeHandle
,
useRef
,
useState
}
from
"react"
;
import
{
cn
}
from
"@/lib/utils"
;
import
{
cn
}
from
"@/lib/utils"
;
import
{
detectLastListItem
,
generateListContinuation
}
from
"@/utils/markdown-list-detection"
;
import
{
Command
}
from
"../types/command"
;
import
{
Command
}
from
"../types/command"
;
import
CommandSuggestions
from
"./CommandSuggestions"
;
import
CommandSuggestions
from
"./CommandSuggestions"
;
import
TagSuggestions
from
"./TagSuggestions"
;
import
TagSuggestions
from
"./TagSuggestions"
;
import
{
editorCommands
}
from
"./commands"
;
import
{
editorCommands
}
from
"./commands"
;
import
{
useListAutoCompletion
}
from
"./useListAutoCompletion"
;
export
interface
EditorRefActions
{
export
interface
EditorRefActions
{
getEditor
:
()
=>
HTMLTextAreaElement
|
null
;
getEditor
:
()
=>
HTMLTextAreaElement
|
null
;
...
@@ -152,24 +152,12 @@ const Editor = forwardRef(function Editor(props: Props, ref: React.ForwardedRef<
...
@@ -152,24 +152,12 @@ const Editor = forwardRef(function Editor(props: Props, ref: React.ForwardedRef<
updateEditorHeight
();
updateEditorHeight
();
},
[]);
},
[]);
const
handleEditorKeyDown
=
async
(
event
:
React
.
KeyboardEvent
<
HTMLTextAreaElement
>
)
=>
{
// Auto-complete markdown lists when pressing Enter
if
(
event
.
key
===
"Enter"
&&
!
isInIME
)
{
useListAutoCompletion
({
if
(
event
.
shiftKey
||
event
.
ctrlKey
||
event
.
metaKey
||
event
.
altKey
)
{
editorRef
,
return
;
editorActions
,
}
isInIME
,
});
const
cursorPosition
=
editorActions
.
getCursorPosition
();
const
prevContent
=
editorActions
.
getContent
().
substring
(
0
,
cursorPosition
);
// Detect list item using regex-based detection
const
listInfo
=
detectLastListItem
(
prevContent
);
if
(
listInfo
.
type
)
{
event
.
preventDefault
();
const
insertText
=
"
\n
"
+
generateListContinuation
(
listInfo
);
editorActions
.
insertText
(
insertText
);
}
}
};
return
(
return
(
<
div
className=
{
cn
(
"flex flex-col justify-start items-start relative w-full h-auto max-h-[50vh] bg-inherit"
,
className
)
}
>
<
div
className=
{
cn
(
"flex flex-col justify-start items-start relative w-full h-auto max-h-[50vh] bg-inherit"
,
className
)
}
>
...
@@ -180,7 +168,6 @@ const Editor = forwardRef(function Editor(props: Props, ref: React.ForwardedRef<
...
@@ -180,7 +168,6 @@ const Editor = forwardRef(function Editor(props: Props, ref: React.ForwardedRef<
ref=
{
editorRef
}
ref=
{
editorRef
}
onPaste=
{
onPaste
}
onPaste=
{
onPaste
}
onInput=
{
handleEditorInput
}
onInput=
{
handleEditorInput
}
onKeyDown=
{
handleEditorKeyDown
}
onCompositionStart=
{
()
=>
setIsInIME
(
true
)
}
onCompositionStart=
{
()
=>
setIsInIME
(
true
)
}
onCompositionEnd=
{
()
=>
setTimeout
(()
=>
setIsInIME
(
false
))
}
onCompositionEnd=
{
()
=>
setTimeout
(()
=>
setIsInIME
(
false
))
}
></
textarea
>
></
textarea
>
...
...
web/src/components/MemoEditor/Editor/useListAutoCompletion.ts
0 → 100644
View file @
e8b02734
import
{
useEffect
,
useRef
}
from
"react"
;
import
{
detectLastListItem
,
generateListContinuation
}
from
"@/utils/markdown-list-detection"
;
import
{
EditorRefActions
}
from
"."
;
interface
UseListAutoCompletionOptions
{
editorRef
:
React
.
RefObject
<
HTMLTextAreaElement
>
;
editorActions
:
EditorRefActions
;
isInIME
:
boolean
;
}
/**
* Custom hook for handling markdown list auto-completion.
* When the user presses Enter on a list item, this hook automatically
* continues the list with the appropriate formatting.
*
* Supports:
* - Ordered lists (1. item, 2. item, etc.)
* - Unordered lists (- item, * item, + item)
* - Task lists (- [ ] task, - [x] task)
* - Nested lists with proper indentation
*
* This hook manages its own event listeners and cleanup.
*/
export
function
useListAutoCompletion
({
editorRef
,
editorActions
,
isInIME
}:
UseListAutoCompletionOptions
)
{
// Use refs to avoid stale closures in event handlers
const
isInIMERef
=
useRef
(
isInIME
);
isInIMERef
.
current
=
isInIME
;
const
editorActionsRef
=
useRef
(
editorActions
);
editorActionsRef
.
current
=
editorActions
;
useEffect
(()
=>
{
const
editor
=
editorRef
.
current
;
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
;
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
)
{
event
.
preventDefault
();
const
continuation
=
generateListContinuation
(
listInfo
);
actions
.
insertText
(
"
\n
"
+
continuation
);
}
};
editor
.
addEventListener
(
"keydown"
,
handleKeyDown
);
return
()
=>
{
editor
.
removeEventListener
(
"keydown"
,
handleKeyDown
);
};
},
[
editorRef
.
current
]);
}
web/src/components/MemoEditor/Editor/useSuggestions.ts
0 → 100644
View file @
e8b02734
import
{
useEffect
,
useRef
,
useState
}
from
"react"
;
import
getCaretCoordinates
from
"textarea-caret"
;
import
{
EditorRefActions
}
from
"."
;
export
interface
Position
{
left
:
number
;
top
:
number
;
height
:
number
;
}
export
interface
UseSuggestionsOptions
<
T
>
{
/** Reference to the textarea element */
editorRef
:
React
.
RefObject
<
HTMLTextAreaElement
>
;
/** Reference to editor actions for text manipulation */
editorActions
:
React
.
ForwardedRef
<
EditorRefActions
>
;
/** Character that triggers the suggestions (e.g., '/', '#', '@') */
triggerChar
:
string
;
/** Array of items to show in suggestions */
items
:
T
[];
/** Function to filter items based on search query */
filterItems
:
(
items
:
T
[],
searchQuery
:
string
)
=>
T
[];
/** Callback when an item is selected for autocomplete */
onAutocomplete
:
(
item
:
T
,
word
:
string
,
startIndex
:
number
,
actions
:
EditorRefActions
)
=>
void
;
}
export
interface
UseSuggestionsReturn
<
T
>
{
/** Current position of the popup, or null if hidden */
position
:
Position
|
null
;
/** Filtered suggestions based on current search */
suggestions
:
T
[];
/** Index of the currently selected suggestion */
selectedIndex
:
number
;
/** Whether the suggestions popup is visible */
isVisible
:
boolean
;
/** Handler to select a suggestion item */
handleItemSelect
:
(
item
:
T
)
=>
void
;
}
/**
* Shared hook for managing suggestion popups in the editor.
* Handles positioning, keyboard navigation, filtering, and autocomplete logic.
*
* Features:
* - Auto-positioning based on caret location
* - Keyboard navigation (Arrow Up/Down, Enter, Tab, Escape)
* - Smart filtering based on trigger character
* - Proper event cleanup
*
* @example
* ```tsx
* const { position, suggestions, selectedIndex, isVisible, handleItemSelect } = useSuggestions({
* editorRef,
* editorActions,
* triggerChar: '#',
* items: tags,
* filterItems: (items, query) => items.filter(tag => tag.includes(query)),
* onAutocomplete: (tag, word, index, actions) => {
* actions.removeText(index, word.length);
* actions.insertText(`#${tag}`);
* },
* });
* ```
*/
export
function
useSuggestions
<
T
>
({
editorRef
,
editorActions
,
triggerChar
,
items
,
filterItems
,
onAutocomplete
,
}:
UseSuggestionsOptions
<
T
>
):
UseSuggestionsReturn
<
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
;
const
getCurrentWord
=
():
[
word
:
string
,
startIndex
:
number
]
=>
{
const
editor
=
editorRef
.
current
;
if
(
!
editor
)
return
[
""
,
0
];
const
cursorPos
=
editor
.
selectionEnd
;
const
before
=
editor
.
value
.
slice
(
0
,
cursorPos
).
match
(
/
\S
*$/
)
||
{
0
:
""
,
index
:
cursorPos
};
const
after
=
editor
.
value
.
slice
(
cursorPos
).
match
(
/^
\S
*/
)
||
{
0
:
""
};
return
[
before
[
0
]
+
after
[
0
],
before
.
index
??
cursorPos
];
};
const
hide
=
()
=>
setPosition
(
null
);
// Filter items based on the current word after the trigger character
const
suggestionsRef
=
useRef
<
T
[]
>
([]);
suggestionsRef
.
current
=
(()
=>
{
const
[
word
]
=
getCurrentWord
();
if
(
!
word
.
startsWith
(
triggerChar
))
return
[];
const
searchQuery
=
word
.
slice
(
triggerChar
.
length
).
toLowerCase
();
return
filterItems
(
items
,
searchQuery
);
})();
const
isVisibleRef
=
useRef
(
false
);
isVisibleRef
.
current
=
!!
(
position
&&
suggestionsRef
.
current
.
length
>
0
);
const
handleAutocomplete
=
(
item
:
T
)
=>
{
if
(
!
editorActions
||
!
(
"current"
in
editorActions
)
||
!
editorActions
.
current
)
{
console
.
warn
(
"useSuggestions: editorActions not available for autocomplete"
);
return
;
}
const
[
word
,
index
]
=
getCurrentWord
();
onAutocomplete
(
item
,
word
,
index
,
editorActions
.
current
);
hide
();
};
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
();
return
;
}
// Accept suggestion
if
([
"Enter"
,
"Tab"
].
includes
(
e
.
code
))
{
handleAutocomplete
(
suggestions
[
selected
]);
e
.
preventDefault
();
e
.
stopPropagation
();
}
};
const
handleInput
=
()
=>
{
const
editor
=
editorRef
.
current
;
if
(
!
editor
)
return
;
setSelectedIndex
(
0
);
const
[
word
,
index
]
=
getCurrentWord
();
const
currentChar
=
editor
.
value
[
editor
.
selectionEnd
];
const
isActive
=
word
.
startsWith
(
triggerChar
)
&&
currentChar
!==
triggerChar
;
if
(
isActive
)
{
const
caretCoordinates
=
getCaretCoordinates
(
editor
,
index
);
caretCoordinates
.
top
-=
editor
.
scrollTop
;
setPosition
(
caretCoordinates
);
}
else
{
hide
();
}
};
// Register event listeners
const
listenersRegisteredRef
=
useRef
(
false
);
useEffect
(()
=>
{
const
editor
=
editorRef
.
current
;
if
(
!
editor
||
listenersRegisteredRef
.
current
)
return
;
editor
.
addEventListener
(
"click"
,
hide
);
editor
.
addEventListener
(
"blur"
,
hide
);
editor
.
addEventListener
(
"keydown"
,
handleKeyDown
);
editor
.
addEventListener
(
"input"
,
handleInput
);
listenersRegisteredRef
.
current
=
true
;
return
()
=>
{
editor
.
removeEventListener
(
"click"
,
hide
);
editor
.
removeEventListener
(
"blur"
,
hide
);
editor
.
removeEventListener
(
"keydown"
,
handleKeyDown
);
editor
.
removeEventListener
(
"input"
,
handleInput
);
listenersRegisteredRef
.
current
=
false
;
};
},
[
editorRef
.
current
]);
return
{
position
,
suggestions
:
suggestionsRef
.
current
,
selectedIndex
,
isVisible
:
isVisibleRef
.
current
,
handleItemSelect
:
handleAutocomplete
,
};
}
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