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%',
|
||||
},
|
||||
]);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user