Make twilight/mocha scrollbar tracks transparent so the editor no longer shows a vertical strip, size standalone emoji to 100px, loosen zalgo clipping with padded overflow, and handle insertReplacementText via onDOMBeforeInput so Slate does not insert the correction twice.
281 lines
8.2 KiB
TypeScript
281 lines
8.2 KiB
TypeScript
/* 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<HTMLDivElement, CustomEditorProps>(
|
|
(
|
|
{
|
|
editableName,
|
|
top,
|
|
bottom,
|
|
before,
|
|
after,
|
|
maxHeight = '50vh',
|
|
fillHeight = false,
|
|
editor,
|
|
placeholder,
|
|
onKeyDown,
|
|
onKeyUp,
|
|
onChange,
|
|
onPaste,
|
|
},
|
|
ref
|
|
) => {
|
|
const renderElement = useCallback(
|
|
(props: RenderElementProps) => <RenderElement {...props} />,
|
|
[]
|
|
);
|
|
|
|
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);
|
|
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) => (
|
|
<span {...attributes} className={css.EditorPlaceholderContainer}>
|
|
{/* Inner component to style the actual text position and appearance */}
|
|
<Text as="span" className={css.EditorPlaceholderTextVisual} truncate>
|
|
{children}
|
|
</Text>
|
|
</span>
|
|
),
|
|
[]
|
|
);
|
|
|
|
const scrollStyle = fillHeight
|
|
? { flex: '1 1 auto', minHeight: maxHeight, height: '100%' }
|
|
: { maxHeight };
|
|
|
|
return (
|
|
<div
|
|
className={css.Editor}
|
|
ref={ref}
|
|
style={
|
|
fillHeight
|
|
? { display: 'flex', flexDirection: 'column', height: '100%', minHeight: maxHeight }
|
|
: undefined
|
|
}
|
|
>
|
|
<Slate editor={editor} initialValue={initialValue} onChange={onChange}>
|
|
{top}
|
|
<Box
|
|
alignItems={fillHeight ? 'Start' : 'Center'}
|
|
grow={fillHeight ? 'Yes' : undefined}
|
|
style={fillHeight ? { width: '100%', minHeight: 0, flex: '1 1 auto' } : undefined}
|
|
>
|
|
{before && (
|
|
<Box className={css.EditorOptions} alignItems="Center" gap="100" shrink="No">
|
|
{before}
|
|
</Box>
|
|
)}
|
|
<Scroll
|
|
className={css.EditorTextareaScroll}
|
|
variant="SurfaceVariant"
|
|
style={scrollStyle}
|
|
size="300"
|
|
visibility="Hover"
|
|
hideTrack
|
|
>
|
|
<Editable
|
|
data-editable-name={editableName}
|
|
className={css.EditorTextarea}
|
|
placeholder={placeholder}
|
|
renderPlaceholder={renderPlaceholder}
|
|
renderElement={renderElement}
|
|
renderLeaf={renderLeaf}
|
|
decorate={decorate}
|
|
onDOMBeforeInput={handleDOMBeforeInput}
|
|
onKeyDown={handleKeydown}
|
|
onKeyUp={onKeyUp}
|
|
onPaste={onPaste}
|
|
/>
|
|
</Scroll>
|
|
{after && (
|
|
<Box className={css.EditorOptions} alignItems="Center" gap="100" shrink="No">
|
|
{after}
|
|
</Box>
|
|
)}
|
|
</Box>
|
|
{bottom}
|
|
</Slate>
|
|
</div>
|
|
);
|
|
}
|
|
);
|