// 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
    {children}
; } 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;