feat: implement command handling and custom rendering in editor and message components
This commit is contained in:
@@ -33,6 +33,7 @@ import { TextViewer } from './text-viewer';
|
||||
import { testMatrixTo } from '../plugins/matrix-to';
|
||||
import { IImageContent } from '../../types/matrix/common';
|
||||
import { filterDisabledUrls } from '../hooks/useRoomEmbedFilters';
|
||||
import { pluginRegistry } from '../features/settings/plugins/PluginAPI';
|
||||
|
||||
type RenderMessageContentProps = {
|
||||
displayName: string;
|
||||
@@ -309,5 +310,36 @@ export function RenderMessageContent({
|
||||
return <MBadEncrypted />;
|
||||
}
|
||||
|
||||
// Check for custom renderer from plugins FIRST
|
||||
const customRenderer = pluginRegistry.getRenderer('message');
|
||||
console.log('[RenderMessageContent] customRenderer function:', typeof customRenderer);
|
||||
if (customRenderer) {
|
||||
try {
|
||||
const messageData = {
|
||||
msgtype: msgType,
|
||||
...getContent(),
|
||||
};
|
||||
console.log('[RenderMessageContent] Calling customRenderer with msgtype:', msgType);
|
||||
console.log('[RenderMessageContent] messageData:', messageData);
|
||||
const customContent = customRenderer(
|
||||
messageData,
|
||||
() => {
|
||||
console.log('[RenderMessageContent] defaultRenderer called');
|
||||
return null;
|
||||
}
|
||||
);
|
||||
console.log('[RenderMessageContent] customRenderer returned:', customContent);
|
||||
console.log('[RenderMessageContent] customContent type:', typeof customContent);
|
||||
if (customContent) {
|
||||
console.log('[RenderMessageContent] Returning custom content');
|
||||
return customContent as React.ReactElement;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[RenderMessageContent] Custom renderer error:', error);
|
||||
}
|
||||
} else {
|
||||
console.log('[RenderMessageContent] No custom renderer registered');
|
||||
}
|
||||
|
||||
return <UnsupportedContent />;
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import React, {
|
||||
useState,
|
||||
} from 'react';
|
||||
import { Box, Scroll, Text } from 'folds';
|
||||
import { Descendant, Editor, createEditor } from 'slate';
|
||||
import { Descendant, Editor, createEditor, Transforms, Range, Element as SlateElement, Text as SlateText, Point } from 'slate';
|
||||
import {
|
||||
Slate,
|
||||
Editable,
|
||||
@@ -23,6 +23,7 @@ import { RenderElement, RenderLeaf } from './Elements';
|
||||
import { CustomElement } from './slate';
|
||||
import * as css from './Editor.css';
|
||||
import { toggleKeyboardShortcut } from './keyboard';
|
||||
import { createCommandElement } from './utils';
|
||||
|
||||
const initialValue: CustomElement[] = [
|
||||
{
|
||||
@@ -52,8 +53,25 @@ const withVoid = (editor: Editor): Editor => {
|
||||
return editor;
|
||||
};
|
||||
|
||||
type CommandValidatorFn = (commandName: string) => boolean;
|
||||
|
||||
let commandValidator: CommandValidatorFn | null = null;
|
||||
|
||||
export const setCommandValidator = (validator: CommandValidatorFn) => {
|
||||
commandValidator = validator;
|
||||
};
|
||||
|
||||
const withCommandAutoConvert = (editor: Editor): Editor => {
|
||||
// Removed auto-conversion on space to prevent focus issues
|
||||
// Commands are now highlighted visually via decorations
|
||||
// and executed when Enter is pressed
|
||||
return editor;
|
||||
};
|
||||
|
||||
export const useEditor = (): Editor => {
|
||||
const [editor] = useState(() => withInline(withVoid(withReact(withHistory(createEditor())))));
|
||||
const [editor] = useState(() =>
|
||||
withCommandAutoConvert(withInline(withVoid(withReact(withHistory(createEditor())))))
|
||||
);
|
||||
return editor;
|
||||
};
|
||||
|
||||
@@ -97,6 +115,49 @@ export const CustomEditor = forwardRef<HTMLDivElement, CustomEditorProps>(
|
||||
|
||||
const renderLeaf = useCallback((props: RenderLeafProps) => <RenderLeaf {...props} />, []);
|
||||
|
||||
const decorate = useCallback(([node, path]: [any, number[]]) => {
|
||||
const ranges: any[] = [];
|
||||
|
||||
// Only decorate text nodes in the first paragraph
|
||||
if (
|
||||
path.length === 2 &&
|
||||
path[0] === 0 &&
|
||||
path[1] === 0 &&
|
||||
SlateText.isText(node)
|
||||
) {
|
||||
const firstChild = editor.children[0];
|
||||
|
||||
if (SlateElement.isElement(firstChild) && firstChild.type === BlockType.Paragraph) {
|
||||
const [firstInline, secondInline] = firstChild.children;
|
||||
|
||||
// Don't decorate if we already have a CommandElement
|
||||
if (SlateElement.isElement(secondInline) && secondInline.type === BlockType.Command) {
|
||||
return ranges;
|
||||
}
|
||||
|
||||
// Check if the text matches /command pattern
|
||||
const text = node.text;
|
||||
const match = text.match(/^(\s*\/\w+)/);
|
||||
|
||||
if (match && commandValidator) {
|
||||
const commandText = match[1].trim();
|
||||
const commandName = commandText.substring(1);
|
||||
|
||||
if (commandValidator(commandName)) {
|
||||
// Create decoration for the command text
|
||||
ranges.push({
|
||||
anchor: { path, offset: 0 },
|
||||
focus: { path, offset: match[1].length },
|
||||
pendingCommand: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ranges;
|
||||
}, [editor]);
|
||||
|
||||
const handleKeydown: KeyboardEventHandler = useCallback(
|
||||
(evt) => {
|
||||
onKeyDown?.(evt);
|
||||
@@ -143,6 +204,7 @@ export const CustomEditor = forwardRef<HTMLDivElement, CustomEditorProps>(
|
||||
renderPlaceholder={renderPlaceholder}
|
||||
renderElement={renderElement}
|
||||
renderLeaf={renderLeaf}
|
||||
decorate={decorate}
|
||||
onKeyDown={handleKeydown}
|
||||
onKeyUp={onKeyUp}
|
||||
onPaste={onPaste}
|
||||
|
||||
@@ -269,6 +269,13 @@ export function RenderLeaf({ attributes, leaf, children }: RenderLeafProps) {
|
||||
{child}
|
||||
</span>
|
||||
);
|
||||
if (leaf.pendingCommand)
|
||||
child = (
|
||||
<span className={css.Command({ active: true })} {...attributes}>
|
||||
<InlineChromiumBugfix />
|
||||
{child}
|
||||
</span>
|
||||
);
|
||||
|
||||
if (child !== children) return child;
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export * from './autocomplete';
|
||||
export * from './utils';
|
||||
export * from './Editor';
|
||||
export { CustomEditor, useEditor, setCommandValidator } from './Editor';
|
||||
export * from './Elements';
|
||||
export * from './keyboard';
|
||||
export * from './output';
|
||||
|
||||
1
src/app/components/editor/slate.d.ts
vendored
1
src/app/components/editor/slate.d.ts
vendored
@@ -18,6 +18,7 @@ export type FormattedText = Text & {
|
||||
strikeThrough?: boolean;
|
||||
code?: boolean;
|
||||
spoiler?: boolean;
|
||||
pendingCommand?: boolean;
|
||||
};
|
||||
|
||||
export type LinkElement = {
|
||||
|
||||
@@ -1,20 +1,23 @@
|
||||
import { style } from '@vanilla-extract/css';
|
||||
import { DefaultReset } from 'folds';
|
||||
import { DefaultReset, toRem } from 'folds';
|
||||
|
||||
export const Image = style([
|
||||
DefaultReset,
|
||||
{
|
||||
objectFit: 'cover',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
display: 'block',
|
||||
maxWidth: '100%',
|
||||
maxHeight: '100%',
|
||||
imageRendering: 'auto',
|
||||
backfaceVisibility: 'hidden',
|
||||
transform: 'translateZ(0)',
|
||||
},
|
||||
]);
|
||||
|
||||
export const Video = style([
|
||||
DefaultReset,
|
||||
{
|
||||
objectFit: 'contain',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
display: 'block',
|
||||
maxWidth: '100%',
|
||||
maxHeight: '100%',
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -195,15 +195,10 @@ export function MImage({ content, renderImageContent, outlined }: MImageProps) {
|
||||
if (typeof mxcUrl !== 'string') {
|
||||
return <BrokenContent />;
|
||||
}
|
||||
const height = scaleYDimension(imgInfo?.w || 400, 400, imgInfo?.h || 400);
|
||||
|
||||
return (
|
||||
<Attachment outlined={outlined}>
|
||||
<AttachmentBox
|
||||
style={{
|
||||
height: toRem(height < 48 ? 48 : height),
|
||||
}}
|
||||
>
|
||||
<AttachmentBox>
|
||||
{renderImageContent({
|
||||
body: content.body || 'Image',
|
||||
info: imgInfo,
|
||||
@@ -245,8 +240,6 @@ export function MVideo({ content, renderAsFile, renderVideoContent, outlined }:
|
||||
return <BrokenContent />;
|
||||
}
|
||||
|
||||
const height = scaleYDimension(videoInfo.w || 400, 400, videoInfo.h || 400);
|
||||
|
||||
const filename = content.filename ?? content.body ?? 'Video';
|
||||
|
||||
return (
|
||||
@@ -265,11 +258,7 @@ export function MVideo({ content, renderAsFile, renderVideoContent, outlined }:
|
||||
}
|
||||
/>
|
||||
</AttachmentHeader>
|
||||
<AttachmentBox
|
||||
style={{
|
||||
height: toRem(height < 48 ? 48 : height),
|
||||
}}
|
||||
>
|
||||
<AttachmentBox>
|
||||
{renderVideoContent({
|
||||
body: content.body || 'Video',
|
||||
info: videoInfo,
|
||||
|
||||
@@ -8,8 +8,7 @@ export const Attachment = recipe({
|
||||
color: color.SurfaceVariant.OnContainer,
|
||||
borderRadius: config.radii.R400,
|
||||
overflow: 'hidden',
|
||||
maxWidth: '100%',
|
||||
width: toRem(400),
|
||||
maxWidth: toRem(400),
|
||||
},
|
||||
variants: {
|
||||
outlined: {
|
||||
@@ -29,9 +28,8 @@ export const AttachmentHeader = style({
|
||||
export const AttachmentBox = style([
|
||||
DefaultReset,
|
||||
{
|
||||
maxWidth: '100%',
|
||||
maxHeight: toRem(600),
|
||||
width: toRem(400),
|
||||
maxWidth: toRem(400),
|
||||
maxHeight: toRem(400),
|
||||
overflow: 'hidden',
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -5,19 +5,20 @@ export const RelativeBase = style([
|
||||
DefaultReset,
|
||||
{
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
maxWidth: '100%',
|
||||
maxHeight: '100%',
|
||||
},
|
||||
]);
|
||||
|
||||
export const AbsoluteContainer = style([
|
||||
DefaultReset,
|
||||
{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
maxWidth: '100%',
|
||||
maxHeight: '100%',
|
||||
},
|
||||
]);
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
PopOut,
|
||||
Scroll,
|
||||
Text,
|
||||
color,
|
||||
config,
|
||||
toRem,
|
||||
} from 'folds';
|
||||
@@ -53,6 +54,7 @@ import {
|
||||
getBeginCommand,
|
||||
trimCommand,
|
||||
getMentions,
|
||||
setCommandValidator,
|
||||
} from '../../components/editor';
|
||||
import { EmojiBoard, EmojiBoardTab } from '../../components/emoji-board';
|
||||
import { UseStateProvider } from '../../components/UseStateProvider';
|
||||
@@ -186,9 +188,22 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
||||
const [toolbar, setToolbar] = useSetting(settingsAtom, 'editorToolbar');
|
||||
const [autocompleteQuery, setAutocompleteQuery] =
|
||||
useState<AutocompleteQuery<AutocompletePrefix>>();
|
||||
const [commandHint, setCommandHint] = useState<{
|
||||
command: string;
|
||||
description: string;
|
||||
args?: string[];
|
||||
}>();
|
||||
|
||||
const sendTypingStatus = useTypingStatusUpdater(mx, roomId);
|
||||
|
||||
useEffect(() => {
|
||||
setCommandValidator((commandName: string) => {
|
||||
const isBuiltInCommand = commands[commandName as Command];
|
||||
const isPluginCommand = pluginRegistry.getCommands().some(cmd => cmd.name === commandName);
|
||||
return !!(isBuiltInCommand || isPluginCommand);
|
||||
});
|
||||
}, [commands]);
|
||||
|
||||
const handleFiles = useCallback(
|
||||
async (files: File[]) => {
|
||||
setUploadBoard(true);
|
||||
@@ -312,7 +327,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
||||
const submit = useCallback(async () => {
|
||||
uploadBoardHandlers.current?.handleSend();
|
||||
|
||||
const commandName = getBeginCommand(editor);
|
||||
let commandName = getBeginCommand(editor);
|
||||
let plainText = toPlainText(editor.children, isMarkdown).trim();
|
||||
let customHtml = trimCustomHtml(
|
||||
toMatrixCustomHTML(editor.children, {
|
||||
@@ -323,6 +338,21 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
||||
);
|
||||
let msgType = MsgType.Text;
|
||||
|
||||
// If no CommandElement, check if text starts with a slash command
|
||||
if (!commandName && plainText.startsWith('/')) {
|
||||
const match = plainText.match(/^\/(\w+)(\s|$)/);
|
||||
if (match) {
|
||||
const potentialCommand = match[1];
|
||||
// Check if it's a valid command (built-in or plugin)
|
||||
const isBuiltInCommand = commands[potentialCommand as Command];
|
||||
const isPluginCommand = pluginRegistry.getCommands().some(cmd => cmd.name === potentialCommand);
|
||||
|
||||
if (isBuiltInCommand || isPluginCommand) {
|
||||
commandName = potentialCommand;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (commandName) {
|
||||
plainText = trimCommand(commandName, plainText);
|
||||
customHtml = trimCommand(commandName, customHtml);
|
||||
@@ -347,13 +377,14 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
||||
commandContent.exe(plainText);
|
||||
resetEditor(editor);
|
||||
resetEditorHistory(editor);
|
||||
setCommandHint(undefined);
|
||||
sendTypingStatus(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check plugin commands
|
||||
try {
|
||||
const result = await pluginRegistry.executeCommand(commandName, plainText);
|
||||
const result = await pluginRegistry.executeCommand(commandName, plainText, roomId);
|
||||
if (result) {
|
||||
// If command returns text, send it as a message
|
||||
const content: IContent = {
|
||||
@@ -368,6 +399,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
||||
}
|
||||
resetEditor(editor);
|
||||
resetEditorHistory(editor);
|
||||
setCommandHint(undefined);
|
||||
sendTypingStatus(false);
|
||||
return;
|
||||
}
|
||||
@@ -445,6 +477,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
||||
resetEditor(editor);
|
||||
resetEditorHistory(editor);
|
||||
setReplyDraft(undefined);
|
||||
setCommandHint(undefined);
|
||||
sendTypingStatus(false);
|
||||
}, [mx, roomId, threadRootId, editor, replyDraft, sendTypingStatus, setReplyDraft, isMarkdown, commands, autoConvertEmoticons]);
|
||||
|
||||
@@ -463,10 +496,14 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
||||
setAutocompleteQuery(undefined);
|
||||
return;
|
||||
}
|
||||
if (commandHint) {
|
||||
setCommandHint(undefined);
|
||||
return;
|
||||
}
|
||||
setReplyDraft(undefined);
|
||||
}
|
||||
},
|
||||
[submit, setReplyDraft, enterForNewline, autocompleteQuery, isComposing]
|
||||
[submit, setReplyDraft, enterForNewline, autocompleteQuery, isComposing, commandHint]
|
||||
);
|
||||
|
||||
const handleKeyUp: KeyboardEventHandler = useCallback(
|
||||
@@ -485,8 +522,35 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
||||
? getAutocompleteQuery<AutocompletePrefix>(editor, prevWordRange, AUTOCOMPLETE_PREFIXES)
|
||||
: undefined;
|
||||
setAutocompleteQuery(query);
|
||||
|
||||
// Check if user is typing a command and show hint
|
||||
const plainText = toPlainText(editor.children, false).trim();
|
||||
const commandMatch = plainText.match(/^\/(\w+)(\s|$)/);
|
||||
|
||||
if (commandMatch) {
|
||||
const commandName = commandMatch[1];
|
||||
const builtInCmd = commands[commandName as Command];
|
||||
const pluginCmd = pluginRegistry.getCommands().find(cmd => cmd.name === commandName);
|
||||
|
||||
if (builtInCmd) {
|
||||
setCommandHint({
|
||||
command: commandName,
|
||||
description: builtInCmd.description,
|
||||
});
|
||||
} else if (pluginCmd) {
|
||||
setCommandHint({
|
||||
command: commandName,
|
||||
description: pluginCmd.command.description || '',
|
||||
args: pluginCmd.command.args,
|
||||
});
|
||||
} else {
|
||||
setCommandHint(undefined);
|
||||
}
|
||||
} else {
|
||||
setCommandHint(undefined);
|
||||
}
|
||||
},
|
||||
[editor, sendTypingStatus, hideActivity]
|
||||
[editor, sendTypingStatus, hideActivity, commands]
|
||||
);
|
||||
|
||||
const handleCloseAutocomplete = useCallback(() => {
|
||||
@@ -597,7 +661,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
||||
requestClose={handleCloseAutocomplete}
|
||||
/>
|
||||
)}
|
||||
{autocompleteQuery?.prefix === AutocompletePrefix.Command && (
|
||||
{autocompleteQuery?.prefix === AutocompletePrefix.Command && !commandHint && (
|
||||
<CommandAutocomplete
|
||||
room={room}
|
||||
editor={editor}
|
||||
@@ -614,7 +678,35 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
||||
onKeyUp={handleKeyUp}
|
||||
onPaste={handlePaste}
|
||||
top={
|
||||
replyDraft && (
|
||||
<>
|
||||
{commandHint && (
|
||||
<Box
|
||||
style={{
|
||||
padding: `${config.space.S200} ${config.space.S300}`,
|
||||
backgroundColor: color.SurfaceVariant.Container,
|
||||
border: `${config.borderWidth.B300} solid ${color.SurfaceVariant.ContainerLine}`,
|
||||
borderBottom: 'none',
|
||||
borderTopLeftRadius: config.radii.R400,
|
||||
borderTopRightRadius: config.radii.R400,
|
||||
marginBottom: config.space.S100,
|
||||
}}
|
||||
direction="Column"
|
||||
gap="100"
|
||||
>
|
||||
<Text size="T200" priority="400">
|
||||
<b>/{commandHint.command}</b>
|
||||
{commandHint.args && commandHint.args.length > 0 && (
|
||||
<span style={{ opacity: 0.6 }}> {commandHint.args.map(arg => `[${arg}]`).join(' ')}</span>
|
||||
)}
|
||||
</Text>
|
||||
{commandHint.description && (
|
||||
<Text size="T200" priority="300" style={{ opacity: 0.7 }}>
|
||||
{commandHint.description}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
{replyDraft && (
|
||||
<div>
|
||||
<Box
|
||||
alignItems="Center"
|
||||
@@ -650,7 +742,8 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
||||
</Box>
|
||||
</Box>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</>
|
||||
}
|
||||
before={
|
||||
<IconButton
|
||||
|
||||
@@ -14,10 +14,14 @@ interface PluginLoaderProps {
|
||||
* Should be placed high in the component tree after MatrixClient is available.
|
||||
*/
|
||||
export function PluginLoader({ matrixClient, children }: PluginLoaderProps) {
|
||||
console.log('[PluginLoader] Component rendering, matrixClient:', !!matrixClient);
|
||||
|
||||
useEffect(() => {
|
||||
const loadPlugins = async () => {
|
||||
try {
|
||||
console.log('[PluginLoader] Loading plugins...');
|
||||
console.log('[PluginLoader] window.electron:', !!window.electron);
|
||||
console.log('[PluginLoader] window.electron.plugins:', !!window.electron?.plugins);
|
||||
|
||||
if (!window.electron?.plugins) {
|
||||
console.log('[PluginLoader] Running in web mode, plugins not supported');
|
||||
@@ -27,6 +31,7 @@ export function PluginLoader({ matrixClient, children }: PluginLoaderProps) {
|
||||
const pluginsToLoad = await pluginMarketplaceManager.listEnabledPlugins();
|
||||
|
||||
console.log('[PluginLoader] Plugins to load:', pluginsToLoad.length);
|
||||
console.log('[PluginLoader] Plugin list:', pluginsToLoad.map(p => p.id));
|
||||
|
||||
for (const installedPlugin of pluginsToLoad) {
|
||||
try {
|
||||
@@ -65,6 +70,7 @@ export function PluginLoader({ matrixClient, children }: PluginLoaderProps) {
|
||||
);
|
||||
|
||||
const compatContext = Object.assign(context as Record<string, unknown>, {
|
||||
matrixClient,
|
||||
matrix: {
|
||||
on: (eventType: string, handler: (...args: unknown[]) => void) =>
|
||||
matrixClient.on(eventType as any, handler as any),
|
||||
@@ -74,6 +80,10 @@ export function PluginLoader({ matrixClient, children }: PluginLoaderProps) {
|
||||
React,
|
||||
});
|
||||
|
||||
console.log('[PluginLoader] compatContext.React:', !!compatContext.React);
|
||||
console.log('[PluginLoader] compatContext.ui:', !!compatContext.ui);
|
||||
console.log('[PluginLoader] About to register plugin and call onLoad...');
|
||||
|
||||
pluginRegistry.registerPlugin(installedPlugin.id, plugin, context);
|
||||
await plugin.onLoad(compatContext as any);
|
||||
console.log(`[PluginLoader] Successfully loaded plugin: ${installedPlugin.id}`);
|
||||
|
||||
Reference in New Issue
Block a user