/* eslint-disable no-param-reassign */ import React, { ClipboardEventHandler, KeyboardEventHandler, ReactNode, forwardRef, useCallback, useState, } from 'react'; import { Box, Scroll, Text } from 'folds'; import { Descendant, Editor, createEditor, Transforms, Range, Element as SlateElement, Text as SlateText, Point } from 'slate'; import { ReactEditor, Slate, Editable, withReact, RenderLeafProps, RenderElementProps, RenderPlaceholderProps, } from 'slate-react'; import { withHistory } from 'slate-history'; import { BlockType } from './types'; 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[] = [ { type: BlockType.Paragraph, children: [{ text: '' }], }, ]; const withInline = (editor: Editor): Editor => { const { isInline } = editor; editor.isInline = (element) => [BlockType.Mention, BlockType.Emoticon, BlockType.Link, BlockType.Command].includes( element.type ) || isInline(element); return editor; }; const withVoid = (editor: Editor): Editor => { const { isVoid } = editor; editor.isVoid = (element) => [BlockType.Mention, BlockType.Emoticon, BlockType.Command].includes(element.type) || isVoid(element); 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(() => withCommandAutoConvert(withInline(withVoid(withReact(withHistory(createEditor()))))) ); return editor; }; export type EditorChangeHandler = (value: Descendant[]) => void; type CustomEditorProps = { editableName?: string; top?: ReactNode; bottom?: ReactNode; before?: ReactNode; after?: ReactNode; maxHeight?: string; /** Stretch the editable area to fill the parent (e.g. post modal). */ fillHeight?: boolean; editor: Editor; placeholder?: string; onKeyDown?: KeyboardEventHandler; onKeyUp?: KeyboardEventHandler; onChange?: EditorChangeHandler; onPaste?: ClipboardEventHandler; }; export const CustomEditor = forwardRef( ( { editableName, top, bottom, before, after, maxHeight = '50vh', fillHeight = false, editor, placeholder, onKeyDown, onKeyUp, onChange, onPaste, }, ref ) => { const renderElement = useCallback( (props: RenderElementProps) => , [] ); const renderLeaf = useCallback((props: RenderLeafProps) => , []); 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); const shortcutToggled = toggleKeyboardShortcut(editor, evt); if (shortcutToggled) evt.preventDefault(); }, [editor, onKeyDown] ); /** * Spellcheck / right-click autocorrect (insertReplacementText). * Must run as Editable's onDOMBeforeInput and return true so Slate skips its * own insert — slate-react ignores event.defaultPrevented when this prop is unset, * which previously duplicated the corrected word (especially on Windows Electron). */ const handleDOMBeforeInput = useCallback( (event: InputEvent) => { if (event.inputType !== 'insertReplacementText') return false; const data = event.dataTransfer?.getData('text/plain') || event.data || ''; if (!data) return false; event.preventDefault(); const [targetRange] = event.getTargetRanges(); if (targetRange) { const slateRange = ReactEditor.toSlateRange(editor, targetRange, { exactMatch: false, suppressThrow: true, }); if (slateRange) { Transforms.select(editor, slateRange); } } else if (editor.selection && !Range.isCollapsed(editor.selection)) { Transforms.delete(editor, { at: editor.selection }); } Transforms.insertText(editor, data); return true; }, [editor] ); const renderPlaceholder = useCallback( ({ attributes, children }: RenderPlaceholderProps) => ( {/* Inner component to style the actual text position and appearance */} {children} ), [] ); const scrollStyle = fillHeight ? { flex: '1 1 auto', minHeight: maxHeight, height: '100%' } : { maxHeight }; return (
{top} {before && ( {before} )} {after && ( {after} )} {bottom}
); } );