react-smart-editor
Version:
React rich text editor with track changes functionality
1,152 lines (1,139 loc) • 153 kB
JavaScript
'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