UNPKG

react-smart-editor

Version:
1,054 lines (1,046 loc) 113 kB
import React, { useState, useMemo, useRef, useEffect, useCallback } from 'react'; import { Transforms, Text, Editor, createEditor, Range, Path, Element, Node } from 'slate'; import { useSlate, withReact, Slate, Editable } from 'slate-react'; import { withHistory } from 'slate-history'; const LIST_TYPES = ['numbered-list', 'bulleted-list']; const HEADING_TYPES = ['heading-one', 'heading-two', 'heading-three']; /** * Checks if the format is active in the current block * @param editor - Current editor * @param format - Format to check * @returns true if format is active, false otherwise */ const isFormatActive = (editor, format) => { const [match] = Editor.nodes(editor, { match: (n) => { const node = n; if (format === 'bold' || format === 'italic' || format === 'underline') { return node[format] === true; } return node.type === format; }, universal: true, }); return !!match; }; /** * Toggles the format in the current block * @param editor - Current editor * @param format - Format to toggle */ const toggleFormat = (editor, format) => { const isActive = isFormatActive(editor, format); Transforms.setNodes(editor, { [format]: isActive ? null : true }, { match: Text.isText, split: true }); }; /** * Toggles the block format * @param editor - Current editor * @param format - Format to toggle */ const toggleBlock = (editor, format) => { const isActive = isFormatActive(editor, format); const isList = LIST_TYPES.includes(format); Transforms.unwrapNodes(editor, { match: (n) => { const node = n; return LIST_TYPES.includes(node.type); }, split: true, }); Transforms.setNodes(editor, { type: isActive ? 'paragraph' : isList ? 'list-item' : format, }); if (!isActive && isList) { const block = { type: format, children: [] }; Transforms.wrapNodes(editor, block); } }; /** * Gets the current block type * @param editor - Current editor * @returns Current block type */ const getCurrentBlockType = (editor) => { var _a; const [match] = Editor.nodes(editor, { match: (n) => { const node = n; return HEADING_TYPES.includes(node.type); }, }); return ((_a = match === null || match === void 0 ? void 0 : match[0]) === null || _a === void 0 ? void 0 : _a.type) || 'paragraph'; }; /** * Gets the current color * @param editor - Current editor * @returns Current color */ const getCurrentColor = (editor) => { const marks = Editor.marks(editor); return (marks === null || marks === void 0 ? void 0 : marks.color) || '#000000'; }; /** * Component for displaying formatting toolbar * @returns JSX element of formatting toolbar */ const FormattingToolbar = ({ disabled, onApprove, onReject, showActions, hideFormattingToolbarActions, }) => { const editor = useSlate(); /** * Renders a button for formatting * @param format - Format to apply * @param name - Name of the format * @param isIcon - Whether to use an icon * @returns JSX element of formatting button */ const renderFormatButton = (format, name, isIcon) => { const isActive = isFormatActive(editor, format); return (React.createElement("button", { onMouseDown: (e) => { e.preventDefault(); toggleFormat(editor, format); }, className: isActive ? 'active' : '', title: format, type: 'button', disabled: disabled }, isIcon ? React.createElement("i", { className: `icon-${name}` }) : name)); }; /** * Renders a button for block formatting * @param format - Format to apply * @param name - Name of the format * @param isIcon - Whether to use an icon * @returns JSX element of block formatting button */ const renderBlockButton = (format, name, isIcon) => { const isActive = isFormatActive(editor, format); return (React.createElement("button", { onMouseDown: (e) => { e.preventDefault(); toggleBlock(editor, format); }, className: isActive ? 'active' : '', title: format, type: 'button', disabled: disabled }, isIcon ? React.createElement("i", { className: `icon-${name}` }) : name)); }; /** * Renders a select for block type * @returns JSX element of block type select */ const renderHeadSelect = () => { return (React.createElement("select", { onChange: (e) => { e.preventDefault(); toggleBlock(editor, e.target.value); }, value: getCurrentBlockType(editor), className: 'head-select', disabled: disabled }, React.createElement("option", { value: 'heading-one' }, "Heading 1"), React.createElement("option", { value: 'heading-two' }, "Heading 2"), React.createElement("option", { value: 'heading-three' }, "Heading 3"), React.createElement("option", { value: 'paragraph' }, "Normal"))); }; const handleApprove = (e) => { e.preventDefault(); onApprove(); }; const handleReject = (e) => { e.preventDefault(); onReject(); }; return hideFormattingToolbarActions ? (showActions ? (React.createElement("div", { className: 'change-actions-toolbar' }, React.createElement("button", { className: 'approve', onClick: handleApprove, title: 'Approve All', type: 'button', disabled: disabled, "aria-label": 'Approve All' }, "\u2714"), React.createElement("button", { className: 'reject', onClick: handleReject, "aria-label": 'Reject All', title: 'Reject All', type: 'button', disabled: disabled }, "\u2718"))) : null) : (React.createElement("div", { className: 'formatting-toolbar' }, renderHeadSelect(), renderFormatButton('bold', 'bold', true), renderFormatButton('italic', 'italic', true), renderFormatButton('underline', 'underline', true), renderBlockButton('bulleted-list', 'list', true), renderBlockButton('numbered-list', 'list-num', true), React.createElement("input", { disabled: disabled, type: 'color', onChange: (e) => { e.preventDefault(); toggleFormat(editor, 'color'); Editor.addMark(editor, 'color', e.target.value); }, title: 'Text color', value: getCurrentColor(editor) }), showActions && (React.createElement("div", { className: 'change-actions-toolbar' }, React.createElement("button", { className: 'approve', onClick: handleApprove, title: 'Approve All', type: 'button', disabled: disabled, "aria-label": 'Approve All' }, "\u2714"), React.createElement("button", { className: 'reject', onClick: handleReject, "aria-label": 'Reject All', title: 'Reject All', type: 'button', disabled: disabled }, "\u2718"))))); }; /** * Component for displaying a tooltip with change information * @param change - Change metadata * @param onApprove - Change approval handler * @param onReject - Change rejection handler * @param showApprove - Flag to show approve button * @param showReject - Flag to show reject button * @param style - CSS styles for the tooltip */ const ChangeTooltip = ({ change, onApprove, onReject, showApprove, showReject, style, }) => { return (React.createElement("div", { className: 'change-tooltip', style: style }, React.createElement("div", { className: 'change-info' }, React.createElement("div", { className: 'change-info-user' }, React.createElement("p", null, React.createElement("strong", null, change.userName)), (showApprove || showReject) && (React.createElement("div", { className: 'change-actions' }, showApprove && React.createElement("button", { onClick: onApprove }, "\u2714"), showReject && React.createElement("button", { onClick: onReject }, "\u2718")))), React.createElement("p", { className: 'change-info-action' }, "at: ", new Date(change.date).toLocaleDateString(), ",", ' ', new Date(change.date).toLocaleTimeString().slice(0, 5), ' ', React.createElement("strong", null, change.description))))); }; /** * Creates a change metadata object * @param type - The type of change * @param content - The content of the change * @param description - The description of the change * @param user - The user who made the change */ const createChangeMetadata = (type, content, description, user) => { return { id: Math.random().toString(36).substr(2, 9), userId: user.id, userName: user.name, userColor: user.color, date: new Date().toISOString(), type, description, content, status: 'pending', }; }; /** * Merges text styles from an HTML element into a CustomText object * @param element - The HTML element to merge styles from * @param children - The children of the element * @param insertMetadata - The metadata for the change * @returns The merged CustomText object */ const mergeTextStyles = (element, children, insertMetadata) => { var _a; const textNode = (_a = children[0]) !== null && _a !== void 0 ? _a : { text: '', changeId: insertMetadata.id }; if (element.style.fontWeight === 'bold') { textNode.bold = true; } if (element.style.fontStyle === 'italic') { textNode.italic = true; } if (element.style.textDecoration.includes('underline')) { textNode.underline = true; } if (element.style.color && element.style.color !== 'inherit') { textNode.color = element.style.color; } return textNode; }; /** * Parses HTML to Slate nodes * @param html - HTML string * @param insertMetadata - Change metadata * @returns Slate nodes */ const parseHtmlToNodes = (html, insertMetadata) => { const parser = new DOMParser(); const doc = parser.parseFromString(html, 'text/html'); const body = doc.body; const parseElement = (element) => { const children = Array.from(element.childNodes).flatMap((node) => { if (node.nodeType === 3) { // Node.TEXT_NODE return { text: node.textContent || '', changeId: insertMetadata.id }; } else if (node.nodeType === 1) { // Node.ELEMENT_NODE const childElement = node; if (childElement.tagName.toLowerCase() === 'li') { // Process <li> as a separate element return parseElement(childElement); } else if (childElement.tagName.toLowerCase() === 'span') { // Process <span> as an inline element return mergeTextStyles(childElement, [{ text: childElement.textContent || '', changeId: insertMetadata.id }], insertMetadata); } else { // Process other elements recursively return parseElement(childElement).children.map((child) => (Object.assign(Object.assign({}, child), { changeId: insertMetadata.id }))); } } return []; }); const tag = element.tagName.toLowerCase(); if (tag === 'h1') { return { type: 'heading-one', children, changeId: insertMetadata.id }; } if (tag === 'h2') { return { type: 'heading-two', children, changeId: insertMetadata.id }; } if (tag === 'h3') { return { type: 'heading-three', children, changeId: insertMetadata.id, }; } if (tag === 'li') { return { type: 'list-item', children, changeId: insertMetadata.id }; } if (tag === 'ul' || tag === 'ol') { return { type: 'bulleted-list', children, changeId: insertMetadata.id, }; } // Default to process as a paragraph return { type: 'paragraph', children, changeId: insertMetadata.id }; }; const nodes = Array.from(body.children).map((element) => parseElement(element)); return nodes; }; /** * ReactSmartEditor component * @param initialContent - The initial content of the editor * @param user - The user of the editor * @param disabled - Set editor disabled * @param onChange - The onChange event * @param onApprove - The onApprove event * @param onReject - The onReject event * @param onFocus - The onFocus event * @param onBlur - The onBlur event * @param formattingToolbarTop - The top of the formatting toolbar * @param hideFormattingToolbarActions - Hides all formatting actions except approve and reject for owner */ const ReactSmartEditor = ({ initialContent, user, disabled, onChange, onAutoSave, onApprove, onReject, onFocus, onBlur, formattingToolbarTop, hideFormattingToolbarActions, }) => { const [document, setDocument] = useState(initialContent); /** * Tracks the editor */ const editor = useMemo(() => withHistory(withReact(createEditor())), []); /** * Tracks the pasting */ const isPastingRef = useRef(false); /** * Tracks the hovered change */ const [hoveredChange, setHoveredChange] = useState(null); /** * Tracks the tooltip position */ const [tooltipPosition, setTooltipPosition] = useState({ x: 0, y: 0 }); /** * Tracks the tooltip visibility */ const [isTooltipVisible, setIsTooltipVisible] = useState(false); /** * Tracks the tooltip timeout */ const tooltipTimeoutRef = useRef(undefined); /** * Tracks the tooltip */ const tooltipRef = useRef(null); /** * Tracks the last change */ const lastChangeRef = useRef({ position: null, id: null, text: '', endPosition: null, type: null, }); /** * Tracks the selection changing */ const isSelectionChanging = useRef(false); /** * Tracks the replacement state of the editor */ const replacementState = useRef({ active: false }); /** * Tracks the focus of the editor */ const [isFocused, setIsFocused] = useState(false); /** * Tracks the updated status of the document */ const [isUpdatedStatus, setIsUpdatedStatus] = useState(false); // /** // * Fix \n in changes // */ // useEffect(() => { // if (document.changes.some((el) => el.content === '\n')) { // setDocument((prev) => ({ // ...prev, // changes: prev.changes.filter((el) => el.content !== '\n'), // })) // } // }, [document]) /** * Tracks the change of the document */ useEffect(() => { if (isFocused) { const timer = setInterval(() => { if (document.changes.length > 0 || document.content.length > 0) { onAutoSave === null || onAutoSave === void 0 ? void 0 : onAutoSave(document); } }, 3000); setIsUpdatedStatus(false); return () => clearInterval(timer); } if (isUpdatedStatus) { if (document.changes.length > 0 || document.content.length > 0) { onChange === null || onChange === void 0 ? void 0 : onChange(document); } setIsUpdatedStatus(false); } }, [document, onChange, isFocused]); /** * Tracks the change of the user */ useEffect(() => { // Resets the replacement state when the user changes replacementState.current = { active: false }; // Forces the editor state to update setDocument((prev) => (Object.assign(Object.assign({}, prev), { content: [...prev.content] }))); }, [user.id]); /** * Gets the text from nodes * @param nodes - The nodes to get the text from * @returns The text from the nodes */ const getTextFromNodes = (nodes) => { return nodes.map((node) => Node.string(node)).join('\n'); }; /** * Optimizes the shouldGroupWithPreviousChange function * @param position - The position of the change * @param type - The type of the change * @param _nodes - The nodes of the change * @param userId - The ID of the user * @returns The shouldGroup and changeId */ const shouldGroupWithPreviousChange = useCallback((position, type, _nodes, userId) => { const lastChange = document.changes[document.changes.length - 1]; if (!lastChange || lastChangeRef.current.position === null || lastChangeRef.current.id === null || lastChange.userId !== userId || lastChange.type !== type || lastChange.status !== 'pending') { return { shouldGroup: false, changeId: null }; } // Check if the last change is a newline const isLastChangeNewline = lastChange.content === '\n'; // If the last change is a newline, always group with it if (isLastChangeNewline) { return { shouldGroup: true, changeId: lastChangeRef.current.id, }; } return { shouldGroup: true, changeId: lastChangeRef.current.id, }; }, [document.changes]); /** * Handles the change event * @param value - The value of the change */ const handleChange = useCallback((value) => { setDocument((prev) => (Object.assign(Object.assign({}, prev), { content: value }))); }, []); /** * Handles the key down event * @param event - The key down event */ const handleKeyDown = useCallback((event) => { var _a, _b, _c, _d; // If a combination of keys is triggered, do nothing if (event.ctrlKey || event.metaKey || event.shiftKey) { if ((event.key === 'Delete' || event.key === 'Backspace' || event.key === 'x') && (event.ctrlKey || event.metaKey || event.shiftKey)) { event.preventDefault(); return; } } if (event.ctrlKey || event.metaKey) return; if (((event.ctrlKey || event.metaKey) && (event.key.length === 1 || event.key === 'Enter' || event.key.startsWith('Arrow'))) || (event.shiftKey && event.key.startsWith('Arrow'))) return; // Check if cursor is inside a deletion proposal or if selected text is a deletion proposal const isInsideDeletion = checkIsInsideDeletion(); if (isInsideDeletion) { event.preventDefault(); return; } // Owner logic if (user.role === 'owner') { const defaultTextInput = (event, selection) => { const point = Editor.point(editor, selection); const newTextNode = { text: event.key, bold: undefined, italic: undefined, underline: undefined, color: undefined, changeId: undefined, }; Transforms.insertNodes(editor, newTextNode, { at: point, select: true, hanging: true, voids: true, }); setDocument((prev) => (Object.assign(Object.assign({}, prev), { hasOwnerChanges: true }))); }; // For owner, always enter text without metadata if (event.key.length === 1 || event.key === 'Enter') { event.preventDefault(); Editor.withoutNormalizing(editor, () => { if (event.key === 'Enter') { Editor.above(editor, { match: (n) => Editor.isBlock(editor, n), }); Transforms.splitNodes(editor, { always: true, }); setDocument((prev) => (Object.assign(Object.assign({}, prev), { hasOwnerChanges: true }))); } else { const { selection } = editor; if (!selection) return; if (Range.isExpanded(selection)) { const nodes = Array.from(Editor.nodes(editor, { at: selection, match: Text.isText, })); // Checks if there is text with metadata belonging to another user const hasForeignChange = nodes.some(([node]) => { if (node.changeId) { const change = document.changes.find((c) => c.id === node.changeId); return (change === null || change === void 0 ? void 0 : change.userId) !== user.id && (change === null || change === void 0 ? void 0 : change.status) === 'pending'; } return false; }); // If there is text with metadata belonging to another user, prevent actions if (hasForeignChange) { event.preventDefault(); return; } // If there is no foreign changes, delete the selected text and insert a new one Transforms.delete(editor, { at: selection }); defaultTextInput(event, selection); return; } defaultTextInput(event, selection); } }); return; } // Checks if the owner can delete text else if (event.key === 'Backspace' || event.key === 'Delete') { // Gets all text nodes in the selection const { selection } = editor; if (!selection) return; const nodes = Array.from(Editor.nodes(editor, { at: selection, match: Text.isText, })); // Checks if there is text with a pending change const hasPendingChange = nodes.some(([node]) => { if (node.changeId) { const change = document.changes.find((c) => c.id === node.changeId); return (change === null || change === void 0 ? void 0 : change.status) === 'pending'; } return false; }); // If there is text with a pending change, prevent deletion if (hasPendingChange) { event.preventDefault(); return; } // If there is no pending changes, allow deletion setDocument((prev) => (Object.assign(Object.assign({}, prev), { hasOwnerChanges: true }))); return; } } // Editor logic const normalEditorTextInput = (event, selection, newChange = true) => { event.preventDefault(); if (event.key === 'Backspace' || event.key === 'Delete') return; // Gets the current block, in which the cursor is located const [currentBlock, currentBlockPath] = Editor.above(editor, { match: (n) => Editor.isBlock(editor, n), }) || [null, null]; if (!currentBlock || !currentBlockPath) return; // Check if there is deleted text before the current position const hasDeletedTextBefore = (() => { if (!selection) return false; const point = Editor.point(editor, selection); const prevPoint = Editor.before(editor, point, { unit: 'character' }); if (!prevPoint) return false; const [node] = Editor.node(editor, prevPoint.path); if (Text.isText(node)) { if (node.changeId) { const change = document.changes.find((c) => c.id === node.changeId); return (change === null || change === void 0 ? void 0 : change.type) === 'delete' && (change === null || change === void 0 ? void 0 : change.status) === 'pending'; } } return false; })(); // If there is deleted text before the current position, create a new change if (hasDeletedTextBefore) { newChange = true; } // Checks if there is foreign text in the block const hasForeignText = (() => { if (!selection) return false; const point = Editor.point(editor, selection); const prevPoint = Editor.before(editor, point, { unit: 'character' }); const nextPoint = Editor.after(editor, point, { unit: 'character' }); const checkPoint = prevPoint || nextPoint; if (!checkPoint) return false; const [node] = Editor.node(editor, checkPoint.path); if (Text.isText(node)) { if (node.changeId) { const change = document.changes.find((c) => c.id === node.changeId); return (change === null || change === void 0 ? void 0 : change.userId) !== user.id && (change === null || change === void 0 ? void 0 : change.status) === 'pending'; } } return false; })(); // If there is foreign text, create a new change if (hasForeignText) { const insertMetadata = createChangeMetadata('insert', event.key === 'Enter' ? '\n' : event.key, 'Added', user); Editor.withoutNormalizing(editor, () => { const point = Editor.point(editor, selection); if (event.key === 'Enter') { Transforms.splitNodes(editor, { always: true, }); const newPoint = Editor.after(editor, point.path) || point; Transforms.select(editor, newPoint); } else { const newTextNode = { text: event.key, bold: undefined, italic: undefined, underline: undefined, color: undefined, changeId: insertMetadata.id, }; Transforms.insertNodes(editor, newTextNode, { at: point, select: true, hanging: true, voids: true, }); } }); setDocument((prev) => (Object.assign(Object.assign({}, prev), { changes: [...prev.changes, insertMetadata] }))); return; } // If there is no foreign text, check if there is a last change from the current user const lastChange = document.changes[document.changes.length - 1]; const isLastChangeFromCurrentUser = (lastChange === null || lastChange === void 0 ? void 0 : lastChange.userId) === user.id && (lastChange === null || lastChange === void 0 ? void 0 : lastChange.status) === 'pending'; if (isLastChangeFromCurrentUser && !newChange) { // Updates the existing change Editor.withoutNormalizing(editor, () => { const point = Editor.point(editor, selection); if (event.key === 'Enter') { Transforms.splitNodes(editor, { always: true, }); const newPoint = Editor.after(editor, point.path) || point; Transforms.select(editor, newPoint); } else { const newTextNode = { text: event.key, bold: undefined, italic: undefined, underline: undefined, color: undefined, changeId: lastChange.id, }; Transforms.insertNodes(editor, newTextNode, { at: point, select: true, hanging: true, voids: true, }); } }); setDocument((prev) => (Object.assign(Object.assign({}, prev), { changes: prev.changes.map((c) => c.id === lastChange.id ? Object.assign(Object.assign({}, c), { content: c.content + (event.key === 'Enter' ? '\n' : event.key) }) : c) }))); return; } // If there is no last change from the current user, create a new one const insertMetadata = createChangeMetadata('insert', event.key === 'Enter' ? '\n' : event.key, 'Added', user); Editor.withoutNormalizing(editor, () => { const point = Editor.point(editor, selection); if (event.key === 'Enter') { Transforms.splitNodes(editor, { always: true, }); const newPoint = Editor.after(editor, point.path) || point; Transforms.select(editor, newPoint); } else { const newTextNode = { text: event.key, bold: undefined, italic: undefined, underline: undefined, color: undefined, changeId: insertMetadata.id, }; Transforms.insertNodes(editor, newTextNode, { at: point, select: true, hanging: true, voids: true, }); } }); setDocument((prev) => (Object.assign(Object.assign({}, prev), { changes: [...prev.changes, insertMetadata] }))); }; // Resets the replacement state when the Escape key is pressed if (event.key === 'Escape') { if (replacementState.current.active) { // Creates a new change for the subsequent text const insertMetadata = createChangeMetadata('insert', '', 'Added', user); replacementState.current = { active: true, insertMetadata, }; // Adds the new change to the list setDocument((prev) => (Object.assign(Object.assign({}, prev), { changes: [...prev.changes, insertMetadata] }))); } return; } const { selection } = editor; if (!selection || !Range.isRange(selection)) return; // Checks if there is a selection and it is not collapsed if (Range.isExpanded(selection)) { // Checks if the selected text belongs to the current user const nodes = Array.from(Editor.nodes(editor, { at: selection, match: Text.isText, })); // Checks if the selected text contains changes of different types and from different users const hasMixedChanges = nodes.some(([node], index, array) => { if (node.changeId) { const change = document.changes.find((c) => c.id === node.changeId); if (index > 0) { const prevNode = array[index - 1][0]; const prevChange = document.changes.find((c) => c.id === prevNode.changeId); return (change === null || change === void 0 ? void 0 : change.userId) !== (prevChange === null || prevChange === void 0 ? void 0 : prevChange.userId) || (change === null || change === void 0 ? void 0 : change.type) !== (prevChange === null || prevChange === void 0 ? void 0 : prevChange.type); } } return false; }); // If there are changes of different types and from different users, block the replacement logic if (hasMixedChanges) { event.preventDefault(); return; } const isCurrentEditorText = nodes.every(([node]) => { if (node.changeId) { const change = document.changes.find((c) => c.id === node.changeId); return ((change === null || change === void 0 ? void 0 : change.userId) === user.id && (change === null || change === void 0 ? void 0 : change.status) === 'pending' && (change === null || change === void 0 ? void 0 : change.type) !== 'delete'); } return false; }); // If the selected text belongs to the current user, simply overwrite it if (isCurrentEditorText) { event.preventDefault(); Transforms.delete(editor, { at: selection }); normalEditorTextInput(event, selection, false); return; } // Checks if there is text with metadata belonging to another user const hasForeignChange = nodes.some(([node]) => { if (node.changeId) { const change = document.changes.find((c) => c.id === node.changeId); return (change === null || change === void 0 ? void 0 : change.userId) !== user.id && (change === null || change === void 0 ? void 0 : change.status) === 'pending'; } return false; }); // If there is text with metadata belonging to another user, prevent actions if (hasForeignChange) { event.preventDefault(); return; } // If there is a selection, process it as a replacement if (event.key.length === 1 || event.key === 'Enter') { event.preventDefault(); const insertMetadata = createChangeMetadata('insert', event.key === 'Enter' ? '\n' : event.key, 'Added', user); isSelectionChanging.current = true; // Gets the text that will be replaced const fragment = Editor.fragment(editor, selection); const selectedText = getTextFromNodes(fragment); // Creates metadata for the deleted and new text const deleteMetadata = createChangeMetadata('delete', selectedText, 'Deleted', user); if (event.key === 'Enter') { event.preventDefault(); Editor.withoutNormalizing(editor, () => { // Gets the text that will be replaced const fragment = Editor.fragment(editor, selection); const selectedText = getTextFromNodes(fragment); // Creates metadata for the deleted and new text const deleteMetadata = createChangeMetadata('delete', selectedText, 'Deleted', user); const insertMetadata = createChangeMetadata('insert', '\n', 'Added', user); // Activates the replacement mode replacementState.current = { active: true, deleteMetadata, insertMetadata, originalText: selectedText, }; const rangeRef = Editor.rangeRef(editor, selection); if (rangeRef.current) { // Forces the text to be split at the boundaries of the selection Transforms.setNodes(editor, {}, { at: rangeRef.current, match: Text.isText, split: true, }); // First, mark the existing text as deleted Transforms.setNodes(editor, { changeId: deleteMetadata.id }, { at: rangeRef.current, match: Text.isText, split: true, }); // Inserts the new text after the selected text const point = Editor.end(editor, rangeRef.current); const newTextNode = { text: '\n', changeId: insertMetadata.id, }; Transforms.insertNodes(editor, newTextNode, { at: point, select: true, }); // Create new block Transforms.splitNodes(editor, { always: true, }); // Moves the cursor to the end of the new text const newPoint = Editor.after(editor, point.path) || point; Transforms.select(editor, { anchor: { path: newPoint.path, offset: newPoint.offset + 1 }, focus: { path: newPoint.path, offset: newPoint.offset + 1 }, }); } rangeRef.unref(); // Adds both changes to the list setDocument((prev) => (Object.assign(Object.assign({}, prev), { changes: [...prev.changes, deleteMetadata, insertMetadata] }))); }); return; } // Activates the replacement mode replacementState.current = { active: true, deleteMetadata, insertMetadata, originalText: selectedText, }; const rangeRef = Editor.rangeRef(editor, selection); Editor.withoutNormalizing(editor, () => { if (rangeRef.current) { // Forces the text to be split at the boundaries of the selection Transforms.setNodes(editor, {}, { at: rangeRef.current, match: Text.isText, split: true, }); // First, mark the existing text as deleted Transforms.setNodes(editor, { changeId: deleteMetadata.id }, { at: rangeRef.current, match: Text.isText, split: true, }); // Inserts the new text after the selected text const point = Editor.end(editor, rangeRef.current); const newTextNode = { text: event.key === 'Enter' ? '\n' : event.key, changeId: insertMetadata.id, }; Transforms.insertNodes(editor, newTextNode, { at: point, select: true, }); const isEnd = Editor.isEnd(editor, point, point.path); // Moves the cursor to the end of the new text const newPoint = Editor.after(editor, point.path) || point; const textLength = event.key === 'Enter' ? 1 : event.key.length; Transforms.select(editor, { anchor: { path: newPoint.path, offset: newPoint.offset + (isEnd ? textLength : 0) }, focus: { path: newPoint.path, offset: newPoint.offset + (isEnd ? textLength : 0) }, }); } }); rangeRef.unref(); // Adds both changes to the list setDocument((prev) => (Object.assign(Object.assign({}, prev), { changes: [...prev.changes, deleteMetadata, insertMetadata] }))); setTimeout(() => { isSelectionChanging.current = false; }, 0); return; } } else if (replacementState.current.active && replacementState.current.insertMetadata) { const point = editor.selection ? Editor.point(editor, editor.selection) : Editor.end(editor, []); const previousNode = Editor.previous(editor, { at: point, match: Text.isText }); // If the previous node does not correspond to the current change, end the change if (!previousNode || (((_a = previousNode[0]) === null || _a === void 0 ? void 0 : _a.changeId) !== ((_b = replacementState.current.insertMetadata) === null || _b === void 0 ? void 0 : _b.id) && ((_c = previousNode[0]) === null || _c === void 0 ? void 0 : _c.changeId) !== ((_d = replacementState.current.deleteMetadata) === null || _d === void 0 ? void 0 : _d.id))) { replacementState.current = { active: false }; // Continues the existing change if ((event.key.length === 1 || event.key === 'Enter') && replacementState.current.active && replacementState.current.insertMetadata) { event.preventDefault(); const point = editor.selection ? Editor.point(editor, editor.selection) : Editor.end(editor, []); Transforms.insertNodes(editor, { text: event.key === 'Enter' ? '\n' : event.key, changeId: replacementState.current.insertMetadata.id, }, { at: point }); // Moves the cursor to the end const newPoint = Editor.after(editor, point.path) || point; Transforms.select(editor, newPoint); // Updates the content in the metadata setDocument((prev) => (Object.assign(Object.assign({}, prev), { changes: prev.changes.map((c) => { var _a; return c.id === ((_a = replacementState.current.insertMetadata) === null || _a === void 0 ? void 0 : _a.id) ? Object.assign(Object.assign({}, c), { content: c.content + (event.key === 'Enter' ? '\n' : event.key) }) : c; }) }))); return; } // Normal text input (not a replacement) else if (event.key.length === 1 || event.key === 'Enter') { normalEditorTextInput(event, selection); return; } } } else { // Normal text input (not a replacement) const nodes = Array.from(Editor.nodes(editor, { at: selection, match: Text.isText, })); // Checks if there is text with metadata belonging to another user if (event.key === 'Backspace' || event.key === 'Delete') { const hasForeignChange = nodes.some(([node]) => { if (node.changeId) { const change = document.changes.find((c) => c.id === node.changeId); return (change === null || change === void 0 ? void 0 : change.userId) !== user.id && (change === null || change === void 0 ? void 0 : change.status) === 'pending'; } return false; }); // If there is text with metadata belonging to another user, prevent actions if (hasForeignChange) { event.preventDefault(); return; } } // Checks if the text belongs to the current user const isCurrentEditorText = nodes.every(([node]) => { if (node.changeId) { const change = document.changes.find((c) => c.id === node.changeId); return (change === null || change === void 0 ? void 0 : change.userId) === user.id && (change === null || change === void 0 ? void 0 : change.status) === 'pending'; } return false; }); if (event.key.length === 1 || event.key === 'Enter') { if (isCurrentEditorText) { normalEditorTextInput(event, selection, false); } else { normalEditorTextInput(event, selection); } return; } } // Processing the delete key for the editor if (event.key === 'Backspace' || event.key === 'Delete') { const nodes = Array.from(Editor.nodes(editor, { at: event.key === 'Delete' ? { anchor: Editor.after(editor, selection) || selection.anchor, focus: Editor.after(editor, selection) || selection.anchor, } : selection, match: Text.isText, })); // Check if cursor is at the start of line and Delete key is pressed if (event.key === 'Delete' && Editor.isStart(editor, selection.anchor, selection.anchor.path)) { // Get the next node to delete const nextPoint = Editor.after(editor, selection.anchor, { unit: 'character' }); if (!nextPoint) { event.preventDefault(); return; } const range = { anchor: selection.anchor, focus: nextPoint }; const textToDelete = Editor.string(editor, range); if (!textToDelete) { event.preventDefault(); return; } // Check if the text belongs to the current user and its status is "pending" const [node] = Editor.node(editor, nextPoint.path); if (Text.isText(node)) { const change = node.changeId ? document.changes.find((c) => c.id === node.changeId) : null; if ((change === null || change === void 0 ? void 0 : change.userId) === user.id && change.status === 'pending' && change.type !== 'delete') { return; } } event.preventDefault(); // Check if we can group with the previous change const { shouldGroup, changeId } = shouldGroupWithPreviousChange(selection.anchor.offset, 'delete', editor.children, user.id); if (shouldGroup && changeId) { // Update the existing change const existingChange = document.changes.find((c) => c.id === changeId); if (existingChange) { Editor.withoutNormalizing(editor, () => { // Split the text at the boundaries const rangeRef = Editor.rangeRef(editor, range, { affinity: 'inward' }); Transforms.setNodes(editor, {}, { at: range, match: Text.isText, split: true, }); if (rangeRef.current) { // Mark the text with the same changeId Transforms.setNodes(editor, { changeId: existingChange.id }, { at: rangeRef.current, match: Text.isText, }); } rangeRef.unref(); }); // Update the content of the existing change setDocument((prev) => (Object.assign(Object.assign({}, prev), { changes: prev.changes.map((c) => c.id === existingChange.id ? Object.assign(Object.assign({}, c), { content: c.content + textToDelete }) : c) }))); // Update lastChangeRef lastChangeRef.current = {