UNPKG

react-smart-editor

Version:
1,152 lines (1,139 loc) 153 kB
'use strict'; Object.defineProperty(exports, '__esModule', { value: true }); var React = require('react'); var reactDom = require('react-dom'); 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'; }; /** * Plugin to handle inline links * @param editor - The Slate editor * @returns The editor with link support */ const withLinks = (editor) => { const { isInline, insertText, insertBreak } = editor; editor.isInline = (element) => { return element.type === 'link' ? true : isInline(element); }; // Override insertText to exit link when typing at the end editor.insertText = (text) => { const { selection } = editor; if (selection && slate.Range.isCollapsed(selection)) { const [link] = slate.Editor.nodes(editor, { match: (n) => !slate.Editor.isEditor(n) && slate.Element.isElement(n) && n.type === 'link', }); if (link) { const [linkNode, linkPath] = link; slate.Editor.end(editor, linkPath); // Check if cursor is at the end of the link if (slate.Editor.isEnd(editor, selection.anchor, linkPath)) { // Move cursor outside the link before inserting text slate.Transforms.move(editor, { unit: 'offset' }); // Insert text outside the link insertText(text); return; } } } insertText(text); }; // Override insertBreak to exit link when pressing Enter editor.insertBreak = () => { const { selection } = editor; if (selection && slate.Range.isCollapsed(selection)) { const [link] = slate.Editor.nodes(editor, { match: (n) => !slate.Editor.isEditor(n) && slate.Element.isElement(n) && n.type === 'link', }); if (link) { // Insert break and ensure cursor is not in link insertBreak(); slate.Transforms.unwrapNodes(editor, { match: (n) => !slate.Editor.isEditor(n) && slate.Element.isElement(n) && n.type === 'link', split: true, }); return; } } insertBreak(); }; return editor; }; /** * Check if a link is active at the current selection * @param editor - The Slate editor * @returns True if a link is active */ const isLinkActive = (editor) => { const [link] = slate.Editor.nodes(editor, { match: (n) => !slate.Editor.isEditor(n) && slate.Element.isElement(n) && n.type === 'link', }); return !!link; }; /** * Get the active link element and its path * @param editor - The Slate editor * @returns The link element and path, or null */ const getActiveLink = (editor) => { const [link] = slate.Editor.nodes(editor, { match: (n) => !slate.Editor.isEditor(n) && slate.Element.isElement(n) && n.type === 'link', }); if (link) { return link; } return null; }; /** * Unwrap link at current selection * @param editor - The Slate editor */ const unwrapLink = (editor) => { slate.Transforms.unwrapNodes(editor, { match: (n) => !slate.Editor.isEditor(n) && slate.Element.isElement(n) && n.type === 'link', }); }; /** * Wrap selection in a link with track changes support * @param editor - The Slate editor * @param url - The URL for the link * @param text - Optional text for the link * @param user - User making the change (optional, for track changes) * @param createChangeMetadata - Function to create change metadata (optional) * @returns Array of change metadata if tracking changes */ const wrapLink = (editor, url, text, user, createChangeMetadata) => { if (isLinkActive(editor)) { unwrapLink(editor); } const { selection } = editor; const isCollapsed = selection && slate.Range.isCollapsed(selection); const changes = []; if (isCollapsed) { // Insert new link with text at cursor position if (user && createChangeMetadata) { const insertMetadata = createChangeMetadata('insert', text || url, 'Added link', user); changes.push(insertMetadata); const link = { type: 'link', url, children: [{ text: text || url, changeId: insertMetadata.id }], }; slate.Transforms.insertNodes(editor, link); slate.Transforms.move(editor); } else { const link = { type: 'link', url, children: [{ text: text || url }], }; slate.Transforms.insertNodes(editor, link); slate.Transforms.move(editor); } } else { // Wrap selected text in link with track changes if (user && createChangeMetadata && selection) { // Get selected text const fragment = slate.Editor.fragment(editor, selection); const selectedText = fragment.map((node) => slate.Node.string(node)).join('\n'); // Create delete metadata for old text const deleteMetadata = createChangeMetadata('delete', selectedText, 'Deleted', user); changes.push(deleteMetadata); // Create insert metadata for new link const insertMetadata = createChangeMetadata('insert', text || selectedText, 'Added link', user); changes.push(insertMetadata); const rangeRef = slate.Editor.rangeRef(editor, selection); slate.Editor.withoutNormalizing(editor, () => { if (rangeRef.current) { // Split nodes at selection boundaries slate.Transforms.setNodes(editor, {}, { at: rangeRef.current, match: slate.Text.isText, split: true, }); // Mark selected text as deleted slate.Transforms.setNodes(editor, { changeId: deleteMetadata.id }, { at: rangeRef.current, match: slate.Text.isText, split: true, }); // Insert new link after deleted text const endPoint = slate.Editor.end(editor, rangeRef.current); const link = { type: 'link', url, children: [{ text: text || selectedText, changeId: insertMetadata.id }], }; slate.Transforms.insertNodes(editor, link, { at: endPoint }); // Move cursor after the link const afterPoint = slate.Editor.after(editor, endPoint); if (afterPoint) { slate.Transforms.select(editor, afterPoint); } } }); rangeRef.unref(); } else { // No track changes - simple wrap const link = { type: 'link', url, children: [], }; slate.Transforms.wrapNodes(editor, link, { split: true }); slate.Transforms.collapse(editor, { edge: 'end' }); } } return changes; }; /** * Insert a link at the current selection with track changes support * @param editor - The Slate editor * @param url - The URL for the link * @param text - The text for the link * @param user - User making the change (optional) * @param createChangeMetadata - Function to create change metadata (optional) * @returns Array of change metadata if tracking changes */ const insertLink = (editor, url, text, user, createChangeMetadata) => { if (!url) return []; return wrapLink(editor, url, text, user, createChangeMetadata); }; /** * Update an existing link * @param editor - The Slate editor * @param url - The new URL * @param text - The new text (optional) */ const updateLink = (editor, url, text) => { const linkEntry = getActiveLink(editor); if (linkEntry) { const [link, path] = linkEntry; // Update URL slate.Transforms.setNodes(editor, { url }, { at: path }); // Update text if provided if (text) { const currentText = slate.Node.string(link); // Delete old text and insert new text const textPath = [...path, 0]; slate.Transforms.delete(editor, { at: { anchor: { path: textPath, offset: 0 }, focus: { path: textPath, offset: currentText.length }, }, }); slate.Transforms.insertText(editor, text, { at: textPath }); } } }; /** * Remove a link * @param editor - The Slate editor */ const removeLink = (editor) => { unwrapLink(editor); }; /** * Get selected text * @param editor - The Slate editor * @returns The selected text */ const getSelectedText = (editor) => { const { selection } = editor; if (selection && slate.Range.isExpanded(selection)) { return slate.Editor.string(editor, selection); } return ''; }; /** * Component for displaying formatting toolbar * @returns JSX element of formatting toolbar */ const FormattingToolbar = ({ disabled, onApprove, onReject, showActions, hideFormattingToolbarActions, onLinkClick, }) => { 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 button for link * @returns JSX element of link button */ const renderLinkButton = () => { const isActive = isLinkActive(editor); return (React__default["default"].createElement("button", { onMouseDown: (e) => { e.preventDefault(); onLinkClick === null || onLinkClick === void 0 ? void 0 : onLinkClick(); }, className: isActive ? 'active' : '', title: 'Link', type: 'button', disabled: disabled }, React__default["default"].createElement("i", { className: 'icon-link' }))); }; /** * 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), renderLinkButton(), 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), renderLinkButton(), 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, }) => { const handleMouseDown = (e) => { // Prevent editor from losing focus e.preventDefault(); e.stopPropagation(); }; const handleApprove = (e) => { e.preventDefault(); e.stopPropagation(); onApprove === null || onApprove === void 0 ? void 0 : onApprove(); }; const handleReject = (e) => { e.preventDefault(); e.stopPropagation(); onReject === null || onReject === void 0 ? void 0 : onReject(); }; return (React__default["default"].createElement("div", { className: 'change-tooltip', style: style, contentEditable: false, suppressContentEditableWarning: true, onMouseDown: handleMouseDown }, 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: handleApprove, onMouseDown: handleMouseDown }, "\u2714")), showReject && (React__default["default"].createElement("button", { onClick: handleReject, onMouseDown: handleMouseDown }, "\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))))); }; /** * Modal component for adding/editing links * @param isOpen - Whether the modal is open * @param initialUrl - Initial URL value * @param initialText - Initial text value * @param onSave - Callback when saving the link * @param onRemove - Callback when removing the link * @param onClose - Callback when closing the modal */ const LinkModal = ({ isOpen, initialUrl = '', initialText = '', position, editorElement, editor, onSave, onRemove, onClose, }) => { const [url, setUrl] = React.useState(initialUrl); const [text, setText] = React.useState(initialText); const urlInputRef = React.useRef(null); const modalRef = React.useRef(null); React.useEffect(() => { if (isOpen) { setUrl(initialUrl); setText(initialText); // Don't auto-focus to preserve editor selection } }, [isOpen, initialUrl, initialText]); const handleSave = () => { if (url.trim()) { // Восстанавливаем фокус в редактор перед сохранением if (editor) { try { slateReact.ReactEditor.focus(editor); } catch (err) { console.error('Failed to focus editor:', err); } } // Add protocol if missing let finalUrl = url.trim(); if (!/^https?:\/\//i.test(finalUrl)) { finalUrl = 'https://' + finalUrl; } onSave(finalUrl, text.trim() || finalUrl); onClose(); } }; const handleRemove = () => { // Восстанавливаем фокус в редактор перед удалением if (editor) { try { slateReact.ReactEditor.focus(editor); } catch (err) { console.error('Failed to focus editor:', err); } } if (onRemove) { onRemove(); onClose(); } }; const handleKeyDown = (e) => { // Don't let the event bubble to the editor e.stopPropagation(); if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSave(); } else if (e.key === 'Escape') { e.preventDefault(); onClose(); } }; const handleMouseDown = (e) => { // Prevent editor from losing focus e.stopPropagation(); }; const handleInputMouseDown = (e) => { e.stopPropagation(); // Позволяем инпуту получить фокус для ввода }; React.useEffect(() => { const handleClickOutside = (e) => { if (modalRef.current && !modalRef.current.contains(e.target)) { onClose(); } }; if (isOpen) { setTimeout(() => { document.addEventListener('mousedown', handleClickOutside); }, 0); } return () => { document.removeEventListener('mousedown', handleClickOutside); }; }, [isOpen, onClose]); if (!isOpen) return null; const style = Object.assign({ position: 'absolute' }, (position && { left: `${position.x}px`, top: `${position.y}px`, })); return (React__default["default"].createElement("div", { className: 'link-modal-inline', style: style, ref: modalRef, contentEditable: false, onMouseDown: handleMouseDown, suppressContentEditableWarning: true }, React__default["default"].createElement("div", { className: 'link-modal-header' }, React__default["default"].createElement("h3", null, initialUrl ? 'Edit link' : 'Add link'), React__default["default"].createElement("button", { className: 'link-modal-close', onClick: onClose, title: 'Close' }, "\u00D7")), React__default["default"].createElement("div", { className: 'link-modal-body' }, React__default["default"].createElement("div", { className: 'link-modal-field' }, React__default["default"].createElement("label", { htmlFor: 'link-url' }, "URL:"), React__default["default"].createElement("input", { ref: urlInputRef, id: 'link-url', type: 'text', value: url, onChange: (e) => setUrl(e.target.value), onKeyDown: handleKeyDown, onMouseDown: handleInputMouseDown, placeholder: 'https://example.com' })), React__default["default"].createElement("div", { className: 'link-modal-field' }, React__default["default"].createElement("label", { htmlFor: 'link-text' }, "Text:"), React__default["default"].createElement("input", { id: 'link-text', type: 'text', value: text, onChange: (e) => setText(e.target.value), onKeyDown: handleKeyDown, onMouseDown: handleInputMouseDown, placeholder: 'Link text (optional)' }))), React__default["default"].createElement("div", { className: 'link-modal-footer' }, React__default["default"].createElement("div", { className: 'link-modal-actions-left' }, onRemove && initialUrl && (React__default["default"].createElement("button", { className: 'link-modal-button danger', onClick: handleRemove }, "Remove link"))), React__default["default"].createElement("div", { className: 'link-modal-actions-right' }, React__default["default"].createElement("button", { className: 'link-modal-button secondary', onClick: onClose }, "Cancel"), React__default["default"].createElement("button", { className: 'link-modal-button primary', onClick: handleSave, disabled: !url.trim() }, initialUrl ? 'Save' : 'Add'))))); }; /** * 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; const tag = childElement.tagName.toLowerCase(); if (tag === 'a') { // Process <a> as a link element const href = childElement.getAttribute('href') || ''; return { type: 'link', url: href, children: [{ text: childElement.textContent || '', changeId: insertMetadata.id }], }; } else if (tag === 'li') { // Process <li> as a separate element return parseElement(childElement); } else if (tag === '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); const editorId = React.useId(); /** * Tracks the editor */ const editor = React.useMemo(() => withLinks(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); /** * Tracks the link modal state */ const [isLinkModalOpen, setIsLinkModalOpen] = React.useState(false); const [linkModalData, setLinkModalData] = React.useState({ url: '', text: '' }); const [linkModalPosition, setLinkModalPosition] = React.useState({ x: 0, y: 0, }); const savedSelectionRef = React.useRef(null); const editableRef = React.useRef(null); /** * 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; } // Check if cursor is at the end of a link and exit it before inserting text if (editor.selection && slate.Range.isCollapsed(editor.selection) && (event.key.length === 1 || event.key === 'Enter')) { const [link] = slate.Editor.nodes(editor, { match: (n) => !slate.Editor.isEditor(n) && slate.Element.isElement(n) && n.type === 'link', }); if (link) { const [, linkPath] = link; // Check if cursor is at the end of the link if (slate.Editor.isEnd(editor, editor.selection.anchor, linkPath)) { // Move cursor outside the link before continuing slate.Transforms.move(editor, { unit: 'offset' }); // For Enter key, also split nodes if (event.key === 'Enter') { event.preventDefault(); slate.Transforms.splitNodes(editor, { always: true }); return; } // For regular text, continue with normal flow (cursor already moved) } } } // 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, currentBlock