UNPKG

react-smart-editor

Version:
1,048 lines (1,037 loc) 129 kB
'use strict'; Object.defineProperty(exports, '__esModule', { value: true }); var React = require('react'); var slate = require('slate'); var slateReact = require('slate-react'); var slateHistory = require('slate-history'); function _interopDefaultLegacy (e) { return e && typeof e === 'object' && 'default' in e ? e : { 'default': e }; } var React__default = /*#__PURE__*/_interopDefaultLegacy(React); 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] = slate.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); slate.Transforms.setNodes(editor, { [format]: isActive ? null : true }, { match: slate.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); slate.Transforms.unwrapNodes(editor, { match: (n) => { const node = n; return LIST_TYPES.includes(node.type); }, split: true, }); slate.Transforms.setNodes(editor, { type: isActive ? 'paragraph' : isList ? 'list-item' : format, }); if (!isActive && isList) { const block = { type: format, children: [] }; slate.Transforms.wrapNodes(editor, block); } }; /** * Gets the current block type * @param editor - Current editor * @returns Current block type */ const getCurrentBlockType = (editor) => { var _a; const [match] = slate.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 = slate.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 = slateReact.useSlate(); const [isExpanded, setIsExpanded] = React.useState(false); const [showExpandButton, setShowExpandButton] = React.useState(false); const toolbarRef = React.useRef(null); const actionsRef = React.useRef(null); const toggleExpanded = () => { setIsExpanded(!isExpanded); }; /** * Checks if the toolbar is overflowing and shows the expand button if it is */ React.useEffect(() => { const checkOverflow = () => { if (toolbarRef.current) { const toolbarWidth = toolbarRef.current.offsetWidth; setShowExpandButton(toolbarWidth < (showActions ? 390 : 330)); } }; checkOverflow(); window.addEventListener('resize', checkOverflow); return () => { window.removeEventListener('resize', checkOverflow); }; }, [showActions]); /** * 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__default["default"].createElement("button", { onMouseDown: (e) => { e.preventDefault(); toggleFormat(editor, format); }, className: isActive ? 'active' : '', title: format, type: 'button', disabled: disabled }, isIcon ? React__default["default"].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__default["default"].createElement("button", { onMouseDown: (e) => { e.preventDefault(); toggleBlock(editor, format); }, className: isActive ? 'active' : '', title: format, type: 'button', disabled: disabled }, isIcon ? React__default["default"].createElement("i", { className: `icon-${name}` }) : name)); }; /** * Renders a select for block type * @returns JSX element of block type select */ const renderHeadSelect = () => { return (React__default["default"].createElement("select", { onChange: (e) => { e.preventDefault(); toggleBlock(editor, e.target.value); }, value: getCurrentBlockType(editor), className: 'head-select', disabled: disabled }, React__default["default"].createElement("option", { value: 'heading-one' }, "Heading 1"), React__default["default"].createElement("option", { value: 'heading-two' }, "Heading 2"), React__default["default"].createElement("option", { value: 'heading-three' }, "Heading 3"), React__default["default"].createElement("option", { value: 'paragraph' }, "Normal"))); }; const handleApprove = (e) => { e.preventDefault(); onApprove(); }; const handleReject = (e) => { e.preventDefault(); onReject(); }; return hideFormattingToolbarActions ? (showActions ? (React__default["default"].createElement("div", { className: 'change-actions-toolbar', ref: actionsRef }, React__default["default"].createElement("button", { className: 'approve', onClick: handleApprove, title: 'Approve All', type: 'button', disabled: disabled, "aria-label": 'Approve All' }, "\u2714"), React__default["default"].createElement("button", { className: 'reject', onClick: handleReject, "aria-label": 'Reject All', title: 'Reject All', type: 'button', disabled: disabled }, "\u2718"))) : null) : (React__default["default"].createElement("div", { className: 'formatting-toolbar', ref: toolbarRef }, renderHeadSelect(), React__default["default"].createElement("div", { className: 'toolbar-main-buttons' }, showExpandButton ? (React__default["default"].createElement("button", { className: `expand-button ${isExpanded ? 'expanded' : ''}`, onClick: toggleExpanded, title: isExpanded ? 'Collapse' : 'Expand', type: 'button', disabled: disabled, "aria-label": isExpanded ? 'Collapse' : 'Expand' }, React__default["default"].createElement("i", { className: 'icon-chevron-down' }))) : (React__default["default"].createElement(React__default["default"].Fragment, null, renderFormatButton('bold', 'bold', true), renderFormatButton('italic', 'italic', true), renderFormatButton('underline', 'underline', true), renderBlockButton('bulleted-list', 'list', true), renderBlockButton('numbered-list', 'list-num', true), React__default["default"].createElement("input", { disabled: disabled, type: 'color', onChange: (e) => { e.preventDefault(); toggleFormat(editor, 'color'); slate.Editor.addMark(editor, 'color', e.target.value); }, title: 'Text color', value: getCurrentColor(editor) })))), showActions && (React__default["default"].createElement("div", { className: 'change-actions-toolbar', ref: actionsRef }, React__default["default"].createElement("button", { className: 'approve', onClick: handleApprove, title: 'Approve All', type: 'button', disabled: disabled, "aria-label": 'Approve All' }, "\u2714"), React__default["default"].createElement("button", { className: 'reject', onClick: handleReject, "aria-label": 'Reject All', title: 'Reject All', type: 'button', disabled: disabled }, "\u2718"))), React__default["default"].createElement("div", { className: `toolbar-expanded-buttons ${isExpanded && showExpandButton ? 'expanded' : ''}` }, renderFormatButton('bold', 'bold', true), renderFormatButton('italic', 'italic', true), renderFormatButton('underline', 'underline', true), renderBlockButton('bulleted-list', 'list', true), renderBlockButton('numbered-list', 'list-num', true), React__default["default"].createElement("input", { disabled: disabled, type: 'color', onChange: (e) => { e.preventDefault(); toggleFormat(editor, 'color'); slate.Editor.addMark(editor, 'color', e.target.value); }, title: 'Text color', value: getCurrentColor(editor) })))); }; /** * 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__default["default"].createElement("div", { className: 'change-tooltip', style: style }, React__default["default"].createElement("div", { className: 'change-info' }, React__default["default"].createElement("div", { className: 'change-info-user' }, React__default["default"].createElement("p", null, React__default["default"].createElement("strong", null, change.userName)), (showApprove || showReject) && (React__default["default"].createElement("div", { className: 'change-actions' }, showApprove && React__default["default"].createElement("button", { onClick: onApprove }, "\u2714"), showReject && React__default["default"].createElement("button", { onClick: onReject }, "\u2718")))), React__default["default"].createElement("p", { className: 'change-info-action' }, "at: ", new Date(change.date).toLocaleDateString(), ",", ' ', new Date(change.date).toLocaleTimeString().slice(0, 5), ' ', React__default["default"].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, userRole: user.role, 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 * @param autoHeight - The minimum height of the editor */ const ReactSmartEditor = ({ initialContent, user, disabled, onChange, onAutoSave, onApprove, onReject, onFocus, onBlur, formattingToolbarTop, hideFormattingToolbarActions, autoHeight, showOwnerChanges = true, }) => { const [document, setDocument] = React.useState(initialContent); /** * Tracks the editor */ const editor = React.useMemo(() => slateHistory.withHistory(slateReact.withReact(slate.createEditor())), []); /** * Tracks the pasting */ const isPastingRef = React.useRef(false); /** * Tracks the hovered change */ const [hoveredChange, setHoveredChange] = React.useState(null); /** * Tracks the tooltip position */ const [tooltipPosition, setTooltipPosition] = React.useState({ x: 0, y: 0 }); /** * Tracks the tooltip visibility */ const [isTooltipVisible, setIsTooltipVisible] = React.useState(false); /** * Tracks the tooltip timeout */ const tooltipTimeoutRef = React.useRef(undefined); /** * Tracks the tooltip */ const tooltipRef = React.useRef(null); /** * Tracks the last change */ const lastChangeRef = React.useRef({ position: null, id: null, text: '', endPosition: null, type: null, }); /** * Tracks the selection changing */ const isSelectionChanging = React.useRef(false); /** * Tracks the replacement state of the editor */ const replacementState = React.useRef({ active: false }); /** * Tracks the focus of the editor */ const [isFocused, setIsFocused] = React.useState(false); /** * Tracks the updated status of the document */ const [isUpdatedStatus, setIsUpdatedStatus] = React.useState(false); /** * Cleans up empty changes (changes that have no corresponding content in the editor) */ const cleanupEmptyChanges = React.useCallback(() => { const validChangeIds = new Set(); // Collect all changeIds that actually exist in the editor content const collectChangeIds = (nodes) => { nodes.forEach((node) => { if (slate.Text.isText(node) && node.changeId && node.text !== '') { validChangeIds.add(node.changeId); } else if (slate.Element.isElement(node)) { collectChangeIds(node.children); } }); }; collectChangeIds(document.content); // Filter out changes that don't have corresponding content const filteredChanges = document.changes.filter((change) => { // Keep changes that have corresponding content in the editor if (validChangeIds.has(change.id)) { return true; } // Keep changes that are not pending (accepted/rejected) if (change.status !== 'pending') { return true; } // Special handling for newline changes if (change.content === '\n') { // For newline changes, we need to check if there are multiple blocks in the editor // because newlines create new blocks in Slate const blockCount = (() => { const countBlocks = (nodes) => { let count = 0; for (const node of nodes) { if (slate.Element.isElement(node) && slate.Editor.isBlock(editor, node)) { count++; count += countBlocks(node.children); } } return count; }; return countBlocks(document.content); })(); // If there are multiple blocks, it means newlines were added // We'll keep the newline change if there are at least 2 blocks if (blockCount >= 2) { return true; } // If there's only one block, the newline was probably removed return false; } // Special handling for changes that contain newlines along with other content if (change.content.includes('\n')) { // Check if there are any text nodes from this user that contain newlines const hasUserContentWithNewlines = (() => { const checkForUserContent = (nodes) => { for (const node of nodes) { if (slate.Text.isText(node) && node.text.includes('\n') && node.changeId) { // Check if this content belongs to the same user as the change const nodeChange = document.changes.find((c) => c.id === node.changeId); if (nodeChange && nodeChange.userId === change.userId) { return true; } } else if (slate.Element.isElement(node)) { if (checkForUserContent(node.children)) { return true; } } } return false; }; return checkForUserContent(document.content); })(); // If there is content with newlines from this user in the editor, keep the change if (hasUserContentWithNewlines) { return true; } } // Remove pending changes that have no content in the editor return false; }); if (filteredChanges.length !== document.changes.length) { setDocument((prev) => (Object.assign(Object.assign({}, prev), { changes: filteredChanges }))); } }, [document.content, document.changes]); // /** // * 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 */ React.useEffect(() => { // Clean up empty changes whenever document content changes cleanupEmptyChanges(); 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, cleanupEmptyChanges]); /** * Tracks the change of the user */ React.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) => slate.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 = React.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 = React.useCallback((value) => { setDocument((prev) => (Object.assign(Object.assign({}, prev), { content: value }))); // Clean up empty changes after content update setTimeout(() => { cleanupEmptyChanges(); }, 0); }, [cleanupEmptyChanges]); /** * Handles the key down event * @param event - The key down event */ const handleKeyDown = React.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' && !showOwnerChanges) { const defaultTextInput = (event, selection) => { const point = slate.Editor.point(editor, selection); const newTextNode = { text: event.key, bold: undefined, italic: undefined, underline: undefined, color: undefined, changeId: undefined, }; slate.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(); slate.Editor.withoutNormalizing(editor, () => { if (event.key === 'Enter') { slate.Editor.above(editor, { match: (n) => slate.Editor.isBlock(editor, n), }); slate.Transforms.splitNodes(editor, { always: true, }); setDocument((prev) => (Object.assign(Object.assign({}, prev), { hasOwnerChanges: true }))); } else { const { selection } = editor; if (!selection) return; if (slate.Range.isExpanded(selection)) { const nodes = Array.from(slate.Editor.nodes(editor, { at: selection, match: slate.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 slate.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(slate.Editor.nodes(editor, { at: selection, match: slate.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] = slate.Editor.above(editor, { match: (n) => slate.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 = slate.Editor.point(editor, selection); const prevPoint = slate.Editor.before(editor, point, { unit: 'character' }); if (!prevPoint) return false; const [node] = slate.Editor.node(editor, prevPoint.path); if (slate.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 = slate.Editor.point(editor, selection); const prevPoint = slate.Editor.before(editor, point, { unit: 'character' }); const nextPoint = slate.Editor.after(editor, point, { unit: 'character' }); const checkPoint = prevPoint || nextPoint; if (!checkPoint) return false; const [node] = slate.Editor.node(editor, checkPoint.path); if (slate.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); slate.Editor.withoutNormalizing(editor, () => { const point = slate.Editor.point(editor, selection); if (event.key === 'Enter') { slate.Transforms.splitNodes(editor, { always: true, }); const newPoint = slate.Editor.after(editor, point.path) || point; slate.Transforms.select(editor, newPoint); } else { const newTextNode = { text: event.key, bold: undefined, italic: undefined, underline: undefined, color: undefined, changeId: insertMetadata.id, }; slate.Transforms.insertNodes(editor, newTextNode, { at: point, select: true, hanging: true, voids: true, }); } }); setDocument((prev) => (Object.assign(Object.assign({}, prev), { changes: [...prev.changes, insertMetadata] }))); // Clean up empty changes after creating new change setTimeout(() => { cleanupEmptyChanges(); }, 0); 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 slate.Editor.withoutNormalizing(editor, () => { const point = slate.Editor.point(editor, selection); if (event.key === 'Enter') { slate.Transforms.splitNodes(editor, { always: true, }); const newPoint = slate.Editor.after(editor, point.path) || point; slate.Transforms.select(editor, newPoint); } else { const newTextNode = { text: event.key, bold: undefined, italic: undefined, underline: undefined, color: undefined, changeId: lastChange.id, }; slate.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) }))); // Clean up empty changes after updating existing change setTimeout(() => { cleanupEmptyChanges(); }, 0); 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); slate.Editor.withoutNormalizing(editor, () => { const point = slate.Editor.point(editor, selection); if (event.key === 'Enter') { slate.Transforms.splitNodes(editor, { always: true, }); const newPoint = slate.Editor.after(editor, point.path) || point; slate.Transforms.select(editor, newPoint); } else { const newTextNode = { text: event.key, bold: undefined, italic: undefined, underline: undefined, color: undefined, changeId: insertMetadata.id, }; slate.Transforms.insertNodes(editor, newTextNode, { at: point, select: true, hanging: true, voids: true, }); } }); setDocument((prev) => (Object.assign(Object.assign({}, prev), { changes: [...prev.changes, insertMetadata] }))); // Clean up empty changes after creating new change setTimeout(() => { cleanupEmptyChanges(); }, 0); }; // 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] }))); // Clean up empty changes after creating change on Escape setTimeout(() => { cleanupEmptyChanges(); }, 0); } return; } const { selection } = editor; if (!selection || !slate.Range.isRange(selection)) return; // Checks if there is a selection and it is not collapsed if (slate.Range.isExpanded(selection)) { // Checks if the selected text belongs to the current user const nodes = Array.from(slate.Editor.nodes(editor, { at: selection, match: slate.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(); slate.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 = slate.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(); slate.Editor.withoutNormalizing(editor, () => { // Gets the text that will be replaced const fragment = slate.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 = slate.Editor.rangeRef(editor, selection); if (rangeRef.current) { // Forces the text to be split at the boundaries of the selection slate.Transforms.setNodes(editor, {}, { at: rangeRef.current, match: slate.Text.isText, split: true, }); // First, mark the existing text as deleted slate.Transforms.setNodes(editor, { changeId: deleteMetadata.id }, { at: rangeRef.current, match: slate.Text.isText, split: true, }); // Inserts the new text after the selected text const point = slate.Editor.end(editor, rangeRef.current); const newTextNode = { text: '\n', changeId: insertMetadata.id, }; slate.Transforms.insertNodes(editor, newTextNode, { at: point, select: true, }); // Create new block slate.Transforms.splitNodes(editor, { always: true, }); // Moves the cursor to the end of the new text const newPoint = slate.Editor.after(editor, point.path) || point; slate.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 = slate.Editor.rangeRef(editor, selection); slate.Editor.withoutNormalizing(editor, () => { if (rangeRef.current) { // Forces the text to be split at the boundaries of the selection slate.Transforms.setNodes(editor, {}, { at: rangeRef.current, match: slate.Text.isText, split: true, }); // First, mark the existing text as deleted slate.Transforms.setNodes(editor, { changeId: deleteMetadata.id }, { at: rangeRef.current, match: slate.Text.isText, split: true, }); // Inserts the new text after the selected text const point = slate.Editor.end(editor, rangeRef.current); const newTextNode = { text: event.key === 'Enter' ? '\n' : event.key, changeId: insertMetadata.id, }; slate.Transforms.insertNodes(editor, newTextNode, { at: point, select: true, }); const isEnd = slate.Editor.isEnd(editor, point, point.path); // Moves the cursor to the end of the new text const newPoint = slate.Editor.after(editor, point.path) || point; const textLength = event.key === 'Enter' ? 1 : eve