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
15c146cf
Unverified
Commit
15c146cf
authored
Aug 08, 2025
by
Tobias Waslowski
Committed by
GitHub
Aug 08, 2025
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
feat(editor): create text-based autocompleting commands (#4971)
parent
f4bdfa28
Changes
4
Hide whitespace changes
Inline
Side-by-side
Showing
4 changed files
with
176 additions
and
0 deletions
+176
-0
CommandSuggestions.tsx
web/src/components/MemoEditor/Editor/CommandSuggestions.tsx
+131
-0
commands.ts
web/src/components/MemoEditor/Editor/commands.ts
+34
-0
index.tsx
web/src/components/MemoEditor/Editor/index.tsx
+5
-0
command.ts
web/src/components/MemoEditor/types/command.ts
+6
-0
No files found.
web/src/components/MemoEditor/Editor/CommandSuggestions.tsx
0 → 100644
View file @
15c146cf
import
{
observer
}
from
"mobx-react-lite"
;
import
{
useEffect
,
useRef
,
useState
}
from
"react"
;
import
getCaretCoordinates
from
"textarea-caret"
;
import
OverflowTip
from
"@/components/kit/OverflowTip"
;
import
{
cn
}
from
"@/lib/utils"
;
import
{
EditorRefActions
}
from
"."
;
import
{
Command
}
from
"../types/command"
;
type
Props
=
{
editorRef
:
React
.
RefObject
<
HTMLTextAreaElement
>
;
editorActions
:
React
.
ForwardedRef
<
EditorRefActions
>
;
commands
:
Command
[];
};
type
Position
=
{
left
:
number
;
top
:
number
;
height
:
number
};
const
CommandSuggestions
=
observer
(({
editorRef
,
editorActions
,
commands
}:
Props
)
=>
{
const
[
position
,
setPosition
]
=
useState
<
Position
|
null
>
(
null
);
const
[
selected
,
select
]
=
useState
(
0
);
const
selectedRef
=
useRef
(
selected
);
selectedRef
.
current
=
selected
;
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
];
};
// Filter commands based on the current word after the slash
const
suggestionsRef
=
useRef
<
Command
[]
>
([]);
suggestionsRef
.
current
=
(()
=>
{
const
[
word
]
=
getCurrentWord
();
if
(
!
word
.
startsWith
(
"/"
))
return
[];
const
search
=
word
.
slice
(
1
).
toLowerCase
();
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
)
{
editorActions
.
current
.
setCursorPosition
(
editorActions
.
current
.
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
=
()
=>
{
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
(
<
div
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"
style=
{
{
left
:
position
.
left
,
top
:
position
.
top
+
position
.
height
}
}
>
{
suggestionsRef
.
current
.
map
((
cmd
,
i
)
=>
(
<
div
key=
{
cmd
.
name
}
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
>
{
cmd
.
description
&&
<
span
className=
"ml-2 text-xs text-muted-foreground"
>
{
cmd
.
description
}
</
span
>
}
</
div
>
))
}
</
div
>
);
});
export
default
CommandSuggestions
;
web/src/components/MemoEditor/Editor/commands.ts
0 → 100644
View file @
15c146cf
import
{
Command
}
from
"@/components/MemoEditor/types/command"
;
export
const
editorCommands
:
Command
[]
=
[
{
name
:
"todo"
,
description
:
"Insert a task checkbox"
,
run
:
()
=>
"- [ ] "
,
cursorOffset
:
6
,
},
{
name
:
"code"
,
description
:
"Insert a code block"
,
run
:
()
=>
"```
\n\n
```"
,
cursorOffset
:
4
,
},
{
name
:
"link"
,
description
:
"Insert a link"
,
run
:
()
=>
"[text](url)"
,
cursorOffset
:
1
,
},
{
name
:
"table"
,
description
:
"Insert a table"
,
run
:
()
=>
"| Header | Header |
\n
| ------ | ------ |
\n
| Cell | Cell |"
,
cursorOffset
:
1
,
},
{
name
:
"highlight"
,
description
:
"Insert highlighted text"
,
run
:
()
=>
"==text=="
,
cursorOffset
:
2
,
},
];
web/src/components/MemoEditor/Editor/index.tsx
View file @
15c146cf
...
...
@@ -3,7 +3,10 @@ import { forwardRef, ReactNode, useCallback, useEffect, useImperativeHandle, use
import
{
markdownServiceClient
}
from
"@/grpcweb"
;
import
{
cn
}
from
"@/lib/utils"
;
import
{
Node
,
NodeType
,
OrderedListItemNode
,
TaskListItemNode
,
UnorderedListItemNode
}
from
"@/types/proto/api/v1/markdown_service"
;
import
{
Command
}
from
"../types/command"
;
import
CommandSuggestions
from
"./CommandSuggestions"
;
import
TagSuggestions
from
"./TagSuggestions"
;
import
{
editorCommands
}
from
"./commands"
;
export
interface
EditorRefActions
{
getEditor
:
()
=>
HTMLTextAreaElement
|
null
;
...
...
@@ -26,6 +29,7 @@ interface Props {
initialContent
:
string
;
placeholder
:
string
;
tools
?:
ReactNode
;
commands
?:
Command
[];
onContentChange
:
(
content
:
string
)
=>
void
;
onPaste
:
(
event
:
React
.
ClipboardEvent
)
=>
void
;
}
...
...
@@ -226,6 +230,7 @@ const Editor = forwardRef(function Editor(props: Props, ref: React.ForwardedRef<
onCompositionEnd=
{
()
=>
setTimeout
(()
=>
setIsInIME
(
false
))
}
></
textarea
>
<
TagSuggestions
editorRef=
{
editorRef
}
editorActions=
{
ref
}
/>
<
CommandSuggestions
editorRef=
{
editorRef
}
editorActions=
{
ref
}
commands=
{
editorCommands
}
/>
</
div
>
);
});
...
...
web/src/components/MemoEditor/types/command.ts
0 → 100644
View file @
15c146cf
export
type
Command
=
{
name
:
string
;
description
?:
string
;
run
:
()
=>
string
;
cursorOffset
?:
number
;
};
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