// Import necessary dependencies
import clsx from 'clsx';
import { withHistory } from 'slate-history';
import { Editor, Transforms, Range, createEditor } from 'slate';
import { Slate, Editable, withReact, ReactEditor } from 'slate-react';
import React, { useMemo, useEffect, useCallback, useState, useRef } from 'react';
// locals
import useTeam from '../../hooks/useTeam';
import ApiSignal from '../../signals/Api';
import useResults from '../../hooks/useResults';
import { useSignal } from '../../signal';
import SlashCombobox, { withSlashCommands } from './Slash';
import Mention, { withMentions, insertMention, MentionCombobox, serializeMention } from './Mention';
// craftiq system user
const craftiqMention = {
user : {
id : 'system',
name : 'CraftIQ',
},
id : 'system',
};
// Define the main editor component
const ComponentEditor = (props = {}) => {
// sApi
const [sApi] = useSignal(ApiSignal);
// value
const [value, setValue] = useState(JSON.parse(initialEditorValue));
// team
const team = useTeam();
// State for mention functionality
const [mentionTarget, setMentionTarget] = useState();
const [mentionSearch, setMentionSearch] = useState('');
const [mentionIndex, setMentionIndex] = useState(0);
// State for slash command functionality
const [slashTarget, setSlashTarget] = useState();
const [slashSearch, setSlashSearch] = useState('');
const [slashIndex, setSlashIndex] = useState(0);
// items
const {
resetting,
items : members,
onItem,
loadNext,
} = useResults(sApi.Team.Member.list, 'members', {
sort : props.sort,
team : team?.id,
mine : false,
search : mentionSearch,
disable : !team?.id,
}, null, props.onResult);
// Reference to the editor DOM element
const editorRef = useRef(null);
// Memoize callback functions to prevent unnecessary re-renders
const renderLeaf = useCallback(props => , []);
const renderElement = useCallback(props => , []);
// Create and memoize the editor instance with plugins
const editor = useMemo(() => withSlashCommands(withMentions(withReact(withHistory(createEditor())))), []);
// Filter characters for mentions based on search input
const filteredMentions = [craftiqMention, ...members].filter(c =>
`${c.user?.name || ''} ${c.user?.email || ''}`.toLowerCase().startsWith(mentionSearch.toLowerCase())
).slice(0, 10);
// Filter slash commands based on search input
const filteredSlashCommands = SLASH_COMMANDS.filter(c =>
c.name.toLowerCase().startsWith(slashSearch.toLowerCase())
).slice(0, 10);
// Handle mention selection
const handleMentionSelect = (char) => {
// Select the target range in the editor
Transforms.select(editor, mentionTarget);
// Insert the mention at the selected range
insertMention(editor, char);
// Reset mention-related state
setMentionTarget(null);
setMentionIndex(0);
};
// Handle slash command selection
const handleSlashSelect = (command) => {
// Select the target range in the editor
Transforms.select(editor, slashTarget);
// Delete the slash command text
Transforms.delete(editor);
// Execute the selected command's action
command.action(editor);
// Reset slash command-related state
setSlashTarget(null);
setSlashIndex(0);
};
// Handle key down events for mention and slash command selection
const handleKeyDown = useCallback((event) => {
if (event.key === 'Enter' && !event.shiftKey && !mentionTarget && !slashTarget) {
// Prevent default to avoid inserting a new line
event.preventDefault();
if (props.onSend) props.onSend(editor.children.map(customSerialize).join('\n'));
return;
}
// If there's an active mention target
if (mentionTarget) {
if (event.key === 'ArrowDown') {
// Move mention selection down
event.preventDefault();
setMentionIndex((mentionIndex + 1) % filteredMentions.length);
} else if (event.key === 'ArrowUp') {
// Move mention selection up
event.preventDefault();
setMentionIndex((mentionIndex - 1 + filteredMentions.length) % filteredMentions.length);
} else if (event.key === 'Tab' || event.key === 'Enter') {
// Select the current mention
event.preventDefault();
handleMentionSelect(filteredMentions[mentionIndex]);
} else if (event.key === 'Escape') {
// Cancel mention selection
event.preventDefault();
setMentionTarget(null);
setMentionIndex(0);
}
} else if (slashTarget) {
// If there's an active slash command target
if (event.key === 'ArrowDown') {
// Move slash command selection down
event.preventDefault();
setSlashIndex((slashIndex + 1) % filteredSlashCommands.length);
} else if (event.key === 'ArrowUp') {
// Move slash command selection up
event.preventDefault();
setSlashIndex((slashIndex - 1 + filteredSlashCommands.length) % filteredSlashCommands.length);
} else if (event.key === 'Tab' || event.key === 'Enter') {
// Select the current slash command
event.preventDefault();
handleSlashSelect(filteredSlashCommands[slashIndex]);
} else if (event.key === 'Escape') {
// Cancel slash command selection
event.preventDefault();
setSlashTarget(null);
setSlashIndex(0);
}
}
}, [mentionTarget, filteredMentions, mentionIndex, handleMentionSelect, slashTarget, filteredSlashCommands, slashIndex, handleSlashSelect]);
// use effect
useEffect(() => {
// check editor
if (!editor) return;
if (!props.reset) return;
// Delete the entire content of the editor
Transforms.delete(editor, {
at: {
anchor: Editor.start(editor, []),
focus: Editor.end(editor, []),
},
});
// Insert the initial value
const initialValue = JSON.parse(initialEditorValue);
Transforms.insertNodes(editor, initialValue[0].children);
// Move the cursor to the start of the editor
Transforms.select(editor, Editor.start(editor, []));
// Focus the editor
ReactEditor.focus(editor);
}, [editor, props.reset]);
// return value
return (
// Wrap the editor with Slate context
{
// set value
setValue(value);
// is change
const isAstChange = editor.operations.some(
op => 'set_selection' !== op.type
);
if (isAstChange) {
// Serialize the value and call props.onChange
const content = value.map(customSerialize).join('\n');
if (props.onChange) props.onChange(content);
}
const { selection } = editor;
if (selection && Range.isCollapsed(selection)) {
const [start] = Range.edges(selection);
const beforeRange = Editor.range(editor, Editor.start(editor, []), start);
const beforeText = Editor.string(editor, beforeRange);
const beforeMatch = beforeText.match(/\S+$/);
const before = beforeMatch ? beforeMatch[0] : '';
const beforeWordRange = beforeMatch ? Editor.range(editor, { path: start.path, offset: start.offset - before.length }, start) : null;
const after = Editor.after(editor, start);
const afterRange = Editor.range(editor, start, after);
const afterText = Editor.string(editor, afterRange);
const afterMatch = afterText.match(/^(\s|$)/);
// Check if the current word starts with '/' and it's at the beginning of the text
const isSlashAtStart = before === '/' && beforeText.trim() === '/';
if (isSlashAtStart && afterMatch) {
setSlashTarget(beforeWordRange);
setSlashSearch('');
setMentionTarget(null);
setMentionSearch('');
} else if (before.startsWith('@') && afterMatch) {
setMentionTarget(beforeWordRange);
setMentionSearch(before.slice(1));
setSlashTarget(null);
setSlashSearch('');
} else {
setSlashTarget(null);
setSlashSearch('');
setMentionTarget(null);
setMentionSearch('');
}
}
}}
>
{/* Editable component for the rich text editor */}
{/* Render mention combobox if there's a mention target */}
{mentionTarget && (
)}
{/* Render slash command combobox if there's a slash target */}
{slashTarget && (
)}
);
};
// Component for rendering text with formatting
const Leaf = ({ attributes, children, leaf }) => {
// Apply appropriate classes based on text formatting
const classes = clsx({
'font-bold': leaf.bold,
'italic': leaf.italic,
'underline': leaf.underline,
'font-mono bg-gray-200 rounded px-1': leaf.code,
});
return {children};
};
// Component for rendering block elements
const Element = props => {
const { attributes, children, element } = props;
// Render different elements based on their type
if (element.type === 'mention') {
return ;
} else if (element.type === 'heading-one') {
return
{children}
;
} else if (element.type === 'heading-two') {
return {children}
;
} else if (element.type === 'bulleted-list') {
return ;
} else {
return {children}
;
}
};
// Custom serializer
const customSerialize = (node) => {
if (node.type === 'mention') {
return serializeMention(node)
}
if (node.children) {
return node.children.map(customSerialize).join('')
}
return node.text
};
// Initial value
const initialEditorValue = JSON.stringify([
{
type: 'paragraph',
children: [
{ text: '' },
],
},
]);
// slash commands
const SLASH_COMMANDS = [
{
name: 'heading1',
description: 'Add a large heading',
action: (editor) => {
Transforms.setNodes(editor, { type: 'heading-one' });
},
},
{
name: 'heading2',
description: 'Add a medium heading',
action: (editor) => {
Transforms.setNodes(editor, { type: 'heading-two' });
},
},
{
name: 'bullet-list',
description: 'Create a bulleted list',
action: (editor) => {
Transforms.setNodes(editor, { type: 'bulleted-list' });
},
},
// Add more slash commands as needed
];
// Export the ComponentEditor
export default ComponentEditor;