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