diff --git a/packages/lexical-link/src/index.js b/packages/lexical-link/src/index.js index 7468d4f2072..56f8353d328 100644 --- a/packages/lexical-link/src/index.js +++ b/packages/lexical-link/src/index.js @@ -90,7 +90,7 @@ export class LinkNode extends ElementNode { return false; } - canInsertTextAfter(): boolean { + canInsertTextAfter(): false { return false; } diff --git a/packages/lexical-list/src/LexicalListItemNode.js b/packages/lexical-list/src/LexicalListItemNode.js index 726f89c63b8..1a05d47b099 100644 --- a/packages/lexical-list/src/LexicalListItemNode.js +++ b/packages/lexical-list/src/LexicalListItemNode.js @@ -271,8 +271,8 @@ export class ListItemNode extends ElementNode { if ($isListItemNode(nodeToInsert)) { const parent = this.getParentOrThrow(); if ($isListNode(parent)) { - // mark subsequent list items dirty so we update their value attribute. - updateChildrenListItemValue(parent); + const siblings = this.getNextSiblings(); + updateChildrenListItemValue(parent, siblings); } } return super.insertBefore(nodeToInsert); diff --git a/packages/lexical-playground/src/App.jsx b/packages/lexical-playground/src/App.jsx index c6ddb97a0fa..ab1877f7113 100644 --- a/packages/lexical-playground/src/App.jsx +++ b/packages/lexical-playground/src/App.jsx @@ -69,9 +69,10 @@ export default function PlaygroundApp(): React$Node { border: '0', color: '#fff', fill: '#151513', + left: '0', position: 'absolute', - right: '0', top: '0', + transform: 'scale(-1,1)', }} aria-hidden="true"> diff --git a/packages/lexical-playground/src/Editor.jsx b/packages/lexical-playground/src/Editor.jsx index 5d3b6e19154..f11224440c5 100644 --- a/packages/lexical-playground/src/Editor.jsx +++ b/packages/lexical-playground/src/Editor.jsx @@ -35,6 +35,7 @@ import AutoLinkPlugin from './plugins/AutoLinkPlugin'; import CharacterStylesPopupPlugin from './plugins/CharacterStylesPopupPlugin'; import ClickableLinkPlugin from './plugins/ClickableLinkPlugin'; import CodeHighlightPlugin from './plugins/CodeHighlightPlugin'; +import CommentPlugin from './plugins/CommentPlugin'; import EmojisPlugin from './plugins/EmojisPlugin'; import EquationsPlugin from './plugins/EquationsPlugin'; import ExcalidrawPlugin from './plugins/ExcalidrawPlugin'; @@ -171,15 +172,12 @@ export default function Editor(): React$Node { - - - - + {!isCollab && } {isRichText ? ( <> {isCollab ? ( @@ -211,6 +209,10 @@ export default function Editor(): React$Node { + + + + > ) : ( <> diff --git a/packages/lexical-playground/src/images/icons/chat-left-text.svg b/packages/lexical-playground/src/images/icons/chat-left-text.svg new file mode 100644 index 00000000000..2b69a9891d5 --- /dev/null +++ b/packages/lexical-playground/src/images/icons/chat-left-text.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/packages/lexical-playground/src/images/icons/chat-right-dots.svg b/packages/lexical-playground/src/images/icons/chat-right-dots.svg new file mode 100644 index 00000000000..423d221dc2d --- /dev/null +++ b/packages/lexical-playground/src/images/icons/chat-right-dots.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/packages/lexical-playground/src/images/icons/chat-right-text.svg b/packages/lexical-playground/src/images/icons/chat-right-text.svg new file mode 100644 index 00000000000..d8b600464b2 --- /dev/null +++ b/packages/lexical-playground/src/images/icons/chat-right-text.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/packages/lexical-playground/src/images/icons/chat-right.svg b/packages/lexical-playground/src/images/icons/chat-right.svg new file mode 100644 index 00000000000..b702b5d18b1 --- /dev/null +++ b/packages/lexical-playground/src/images/icons/chat-right.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/packages/lexical-playground/src/images/icons/comments.svg b/packages/lexical-playground/src/images/icons/comments.svg new file mode 100644 index 00000000000..f6530ca27e4 --- /dev/null +++ b/packages/lexical-playground/src/images/icons/comments.svg @@ -0,0 +1,4 @@ + + + + diff --git a/packages/lexical-playground/src/images/icons/send.svg b/packages/lexical-playground/src/images/icons/send.svg new file mode 100644 index 00000000000..c81fc9553f3 --- /dev/null +++ b/packages/lexical-playground/src/images/icons/send.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/packages/lexical-playground/src/images/icons/trash3.svg b/packages/lexical-playground/src/images/icons/trash3.svg new file mode 100644 index 00000000000..1d5f42eed91 --- /dev/null +++ b/packages/lexical-playground/src/images/icons/trash3.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/packages/lexical-playground/src/index.css b/packages/lexical-playground/src/index.css index dcbb46bba50..6b4656e1068 100644 --- a/packages/lexical-playground/src/index.css +++ b/packages/lexical-playground/src/index.css @@ -129,10 +129,6 @@ pre::-webkit-scrollbar-thumb { background-color: #444; } -.editor-dev-button + .editor-dev-button { - margin-left: 8px; -} - .editor-dev-button::after { content: ''; position: absolute; @@ -195,13 +191,13 @@ pre::-webkit-scrollbar-thumb { #options-button { position: fixed; - right: 20px; + left: 20px; bottom: 20px; } #test-recorder-button { position: fixed; - right: 70px; + left: 70px; bottom: 20px; } @@ -264,7 +260,7 @@ pre::-webkit-scrollbar-thumb { .link-editor { position: absolute; - z-index: 100; + z-index: 10; top: -10000px; left: -10000px; margin-top: -6px; @@ -585,7 +581,7 @@ select.font-family { } .dropdown { - z-index: 5; + z-index: 10; display: block; position: absolute; box-shadow: 0 12px 28px 0 rgba(0, 0, 0, 0.2), 0 2px 4px 0 rgba(0, 0, 0, 0.1), @@ -700,7 +696,7 @@ select.font-family { .switches { z-index: 6; position: fixed; - right: 10px; + left: 10px; bottom: 70px; animation: slide-in 0.4s ease; } @@ -708,7 +704,7 @@ select.font-family { @keyframes slide-in { 0% { opacity: 0; - transform: translateX(200px); + transform: translateX(-200px); } 100% { opacity: 1; @@ -1140,6 +1136,7 @@ button.action-button:disabled { border-top-right-radius: 10px; vertical-align: middle; overflow: auto; + height: 36px; } .toolbar button.toolbar-item { @@ -1256,7 +1253,7 @@ button.action-button:disabled { .sticky-note-container { position: absolute; - z-index: 20; + z-index: 9; width: 120px; display: inline-block; } @@ -1371,7 +1368,7 @@ button.action-button:disabled { padding: 4px; vertical-align: middle; position: absolute; - z-index: 100; + z-index: 10; top: -10000px; left: -10000px; margin-top: -6px; diff --git a/packages/lexical-playground/src/nodes/MarkNode.js b/packages/lexical-playground/src/nodes/MarkNode.js new file mode 100644 index 00000000000..910fc3dfb01 --- /dev/null +++ b/packages/lexical-playground/src/nodes/MarkNode.js @@ -0,0 +1,119 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict + */ + +import type {EditorConfig, LexicalNode, NodeKey, RangeSelection} from 'lexical'; + +import {addClassNamesToElement} from '@lexical/utils'; +import {$isElementNode, ElementNode} from 'lexical'; + +export class MarkNode extends ElementNode { + __ids: Array; + + static getType(): string { + return 'mark'; + } + + static clone(node: MarkNode): MarkNode { + return new MarkNode(node.__ids, node.__key); + } + + constructor(ids: Array, key?: NodeKey): void { + super(key); + this.__ids = ids || []; + } + + createDOM(config: EditorConfig): HTMLElement { + const element = document.createElement('mark'); + addClassNamesToElement(element, config.theme.mark); + return element; + } + + updateDOM(): boolean { + return false; + } + + hasID(id: string): boolean { + const ids = this.getIDs(); + for (let i = 0; i < ids.length; i++) { + if (id === ids[i]) { + return true; + } + } + return false; + } + + getIDs(): Array { + const self = this.getLatest(); + return self.__ids; + } + + addID(id: string): void { + const self = this.getWritable(); + const ids = Array.from(self.__ids); + self.__ids = ids; + for (let i = 0; i < ids.length; i++) { + // If we already have it, don't add again + if (id === ids[i]) { + return; + } + } + ids.push(id); + } + + deleteID(id: string): void { + const self = this.getWritable(); + const ids = Array.from(self.__ids); + self.__ids = ids; + for (let i = 0; i < ids.length; i++) { + if (id === ids[i]) { + ids.splice(i, 1); + return; + } + } + } + + insertNewAfter(selection: RangeSelection): null | ElementNode { + const element = this.getParentOrThrow().insertNewAfter(selection); + if ($isElementNode(element)) { + const linkNode = $createMarkNode(this.__ids); + element.append(linkNode); + return linkNode; + } + return null; + } + + canInsertTextBefore(): false { + return false; + } + + canInsertTextAfter(): false { + return false; + } + + canBeEmpty(): false { + return false; + } + + isInline(): true { + return true; + } + + // TODO: It seems excludeFromCopy doesn't work as expected anymore. + // excludeFromCopy(): true { + // return true; + // } +} + +export function $createMarkNode(ids: Array): MarkNode { + return new MarkNode(ids); +} + +export function $isMarkNode(node: ?LexicalNode): boolean %checks { + return node instanceof MarkNode; +} diff --git a/packages/lexical-playground/src/nodes/PlaygroundNodes.js b/packages/lexical-playground/src/nodes/PlaygroundNodes.js index fafecd9d070..ec7b32731aa 100644 --- a/packages/lexical-playground/src/nodes/PlaygroundNodes.js +++ b/packages/lexical-playground/src/nodes/PlaygroundNodes.js @@ -23,6 +23,7 @@ import {EquationNode} from './EquationNode'; import {ExcalidrawNode} from './ExcalidrawNode'; import {ImageNode} from './ImageNode'; import {KeywordNode} from './KeywordNode'; +import {MarkNode} from './MarkNode'; import {MentionNode} from './MentionNode'; import {PollNode} from './PollNode'; import {StickyNode} from './StickyNode'; @@ -56,6 +57,7 @@ const PlaygroundNodes: Array> = [ HorizontalRuleNode, TweetNode, YouTubeNode, + MarkNode, ]; export default PlaygroundNodes; diff --git a/packages/lexical-playground/src/plugins/CharacterStylesPopupPlugin.jsx b/packages/lexical-playground/src/plugins/CharacterStylesPopupPlugin.jsx index 0c1dbf92747..33d5dd05126 100644 --- a/packages/lexical-playground/src/plugins/CharacterStylesPopupPlugin.jsx +++ b/packages/lexical-playground/src/plugins/CharacterStylesPopupPlugin.jsx @@ -27,18 +27,24 @@ import React, {useCallback, useEffect, useRef, useState} from 'react'; // $FlowFixMe import {createPortal} from 'react-dom'; -function setPopupPosition(editor, rect) { - if (rect === null) { - editor.style.opacity = '0'; - editor.style.top = '-1000px'; - editor.style.left = '-1000px'; - } else { - editor.style.opacity = '1'; - editor.style.top = `${rect.top - 8 + window.pageYOffset}px`; - editor.style.left = `${ - rect.left + 230 + window.pageXOffset - editor.offsetWidth + rect.width - }px`; +function setPopupPosition( + editor: HTMLElement, + rect: ClientRect, + rootElementRect: ClientRect, +): void { + let top = rect.top - 8 + window.pageYOffset; + let left = + rect.left + 230 + window.pageXOffset - editor.offsetWidth + rect.width; + if (rect.width >= rootElementRect.width - 20) { + left = rect.left; + top = rect.top - 50 + window.pageYOffset; + } + if (top < rootElementRect.top) { + top = rect.bottom + 20; } + editor.style.opacity = '1'; + editor.style.top = `${top}px`; + editor.style.left = `${left}px`; } function FloatingCharacterStylesEditor({ @@ -87,7 +93,9 @@ function FloatingCharacterStylesEditor({ rootElement.contains(nativeSelection.anchorNode) ) { const domRange = nativeSelection.getRangeAt(0); + const rootElementRect = rootElement.getBoundingClientRect(); let rect; + if (nativeSelection.anchorNode === rootElement) { let inner = rootElement; while (inner.firstElementChild != null) { @@ -99,7 +107,7 @@ function FloatingCharacterStylesEditor({ } if (!mouseDownRef.current) { - setPopupPosition(popupCharStylesEditorElem, rect); + setPopupPosition(popupCharStylesEditorElem, rect, rootElementRect); } } }, [editor]); @@ -203,44 +211,62 @@ function useCharacterStylesPopup(editor: LexicalEditor): React$Node { const [isStrikethrough, setIsStrikethrough] = useState(false); const [isCode, setIsCode] = useState(false); - useEffect(() => { - return editor.registerUpdateListener(({editorState}) => { - editorState.read(() => { - const selection = $getSelection(); + const updatePopup = useCallback(() => { + editor.getEditorState().read(() => { + const selection = $getSelection(); + const nativeSelection = window.getSelection(); + const rootElement = editor.getRootElement(); - if (!$isRangeSelection(selection)) { - return; - } + if ( + !$isRangeSelection(selection) || + rootElement === null || + !rootElement.contains(nativeSelection.anchorNode) + ) { + setIsText(false); + return; + } - const node = getSelectedNode(selection); + const node = getSelectedNode(selection); - // Update text format - setIsBold(selection.hasFormat('bold')); - setIsItalic(selection.hasFormat('italic')); - setIsUnderline(selection.hasFormat('underline')); - setIsStrikethrough(selection.hasFormat('strikethrough')); - setIsCode(selection.hasFormat('code')); + // Update text format + setIsBold(selection.hasFormat('bold')); + setIsItalic(selection.hasFormat('italic')); + setIsUnderline(selection.hasFormat('underline')); + setIsStrikethrough(selection.hasFormat('strikethrough')); + setIsCode(selection.hasFormat('code')); - // Update links - const parent = node.getParent(); - if ($isLinkNode(parent) || $isLinkNode(node)) { - setIsLink(true); - } else { - setIsLink(false); - } + // Update links + const parent = node.getParent(); + if ($isLinkNode(parent) || $isLinkNode(node)) { + setIsLink(true); + } else { + setIsLink(false); + } - if ( - !$isCodeHighlightNode(selection.anchor.getNode()) && - selection.getTextContent() !== '' - ) { - setIsText($isTextNode(node)); - } else { - setIsText(false); - } - }); + if ( + !$isCodeHighlightNode(selection.anchor.getNode()) && + selection.getTextContent() !== '' + ) { + setIsText($isTextNode(node)); + } else { + setIsText(false); + } }); }, [editor]); + useEffect(() => { + document.addEventListener('selectionchange', updatePopup); + return () => { + document.removeEventListener('selectionchange', updatePopup); + }; + }, [updatePopup]); + + useEffect(() => { + return editor.registerUpdateListener(() => { + updatePopup(); + }); + }, [editor, updatePopup]); + if (!isText || isLink) { return null; } diff --git a/packages/lexical-playground/src/plugins/CommentPlugin.css b/packages/lexical-playground/src/plugins/CommentPlugin.css new file mode 100644 index 00000000000..f6b139726e1 --- /dev/null +++ b/packages/lexical-playground/src/plugins/CommentPlugin.css @@ -0,0 +1,433 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * + */ + +.CommentPlugin_AddCommentBox { + display: block; + position: fixed; + border-radius: 20px; + background-color: white; + width: 40px; + height: 60px; + box-shadow: 0 0 3px rgba(0, 0, 0, 0.2); + z-index: 10; +} + +.CommentPlugin_AddCommentBox_button { + border-radius: 20px; + border: 0; + background: none; + width: 40px; + height: 60px; + position: relative; + position: absolute; + top: 0; + left: 0; + cursor: pointer; +} + +.CommentPlugin_AddCommentBox_button:hover { + background-color: #f6f6f6; +} + +i.add-comment { + background-size: contain; + display: inline-block; + height: 20px; + width: 20px; + vertical-align: -10px; + background-image: url(../images/icons/chat-left-text.svg); +} + +.CommentPlugin_CommentInputBox { + display: block; + position: fixed; + width: 250px; + min-height: 80px; + background-color: #fff; + box-shadow: 0 0 5px 0 rgba(0, 0, 0, 0.1); + border-radius: 5px; + z-index: 24; + animation: show-input-box 0.4s ease; +} + +.CommentPlugin_CommentInputBox::before { + content: ''; + position: absolute; + width: 0; + height: 0; + margin-left: 0.5em; + right: -1em; + top: 0; + left: calc(50% + 0.25em); + box-sizing: border-box; + border: 0.5em solid black; + border-color: transparent transparent #fff #fff; + transform-origin: 0 0; + transform: rotate(135deg); + box-shadow: -3px 3px 3px 0 rgba(0, 0, 0, 0.05); +} + +@keyframes show-input-box { + 0% { + opacity: 0; + transform: translateY(50px); + } + 100% { + opacity: 1; + transform: translateY(0); + } +} + +.CommentPlugin_CommentInputBox_Buttons { + display: flex; + flex-direction: row; + padding: 0 10px 10px 10px; + gap: 10px; +} + +.CommentPlugin_CommentInputBox_Button { + flex: 1; +} + +.CommentPlugin_CommentInputBox_Button.primary { + background-color: rgb(66, 135, 245); + font-weight: bold; + color: #fff; +} + +.CommentPlugin_CommentInputBox_Button.primary:hover { + background-color: rgb(53, 114, 211); +} + +.CommentPlugin_CommentInputBox_Button[disabled] { + background-color: #eee; + opacity: 0.5; + cursor: not-allowed; + font-weight: normal; + color: #444; +} + +.CommentPlugin_CommentInputBox_Button[disabled]:hover { + opacity: 0.5; + background-color: #eee; +} + +.CommentPlugin_CommentInputBox_EditorContainer { + position: relative; + margin: 10px; + border-radius: 5px; +} + +.CommentPlugin_CommentInputBox_Editor { + position: relative; + border: 1px solid #ccc; + background-color: #fff; + border-radius: 5px; + font-size: 15px; + caret-color: rgb(5, 5, 5); + display: block; + padding: 9px 10px 10px 9px; + min-height: 80px; +} + +.CommentPlugin_CommentInputBox_Editor:focus { + outline: 1px solid rgb(66, 135, 245); +} + +.CommentPlugin_ShowCommentsButton { + position: fixed; + top: 10px; + right: 10px; + background-color: #ddd; + border-radius: 10px; +} + +i.comments { + background-size: contain; + display: inline-block; + height: 20px; + width: 20px; + vertical-align: -10px; + background-image: url(../images/icons/comments.svg); + opacity: 0.5; + transition: opacity 0.2s linear; +} + +.CommentPlugin_ShowCommentsButton:hover i.comments { + opacity: 1; +} + +.CommentPlugin_ShowCommentsButton.active { + background-color: #ccc; +} + +.CommentPlugin_CommentsPanel { + position: fixed; + right: 0; + width: 300px; + height: calc(100% - 88px); + top: 88px; + background-color: #fff; + border-top-left-radius: 10px; + box-shadow: 0 0 10px rgba(0, 0, 0, 0.1); + animation: show-comments 0.2s ease; + z-index: 25; +} + +@keyframes show-comments { + 0% { + opacity: 0; + transform: translateX(300px); + } + 100% { + opacity: 1; + transform: translateX(0); + } +} + +.CommentPlugin_CommentsPanel_Heading { + padding-left: 15px; + padding-top: 10px; + margin: 0; + height: 34px; + border-bottom: 1px solid #eee; + font-size: 20px; + display: block; + width: 100%; + color: #444; + overflow: hidden; +} + +.CommentPlugin_CommentsPanel_Footer { + border-top: 1px solid #eee; + position: absolute; + bottom: 0; + left: 0; + width: 100%; +} + +.CommentPlugin_CommentsPanel_Editor { + position: relative; + border: 1px solid #ccc; + background-color: #fff; + border-radius: 5px; + font-size: 15px; + caret-color: rgb(5, 5, 5); + display: block; + padding: 9px 10px 10px 9px; + min-height: 20px; +} + +.CommentPlugin_CommentsPanel_Editor::before { + content: ''; + width: 30px; + height: 20px; + float: right; +} + +.CommentPlugin_CommentsPanel_SendButton { + position: absolute; + right: 10px; + top: 8px; + background: none; +} + +.CommentPlugin_CommentsPanel_SendButton:hover { + background: none; +} + +i.send { + background-size: contain; + display: inline-block; + height: 20px; + width: 20px; + vertical-align: -10px; + background-image: url(../images/icons/send.svg); + opacity: 0.5; + transition: opacity 0.2s linear; +} + +.CommentPlugin_CommentsPanel_SendButton:hover i.send { + opacity: 1; + filter: invert(45%) sepia(98%) saturate(2299%) hue-rotate(201deg) + brightness(100%) contrast(92%); +} + +.CommentPlugin_CommentsPanel_SendButton[disabled] i.send { + opacity: 0.3; +} + +.CommentPlugin_CommentsPanel_SendButton:hover[disabled] i.send { + opacity: 0.3; + filter: none; +} + +.CommentPlugin_CommentsPanel_Empty { + color: #777; + font-size: 15px; + text-align: center; + position: absolute; + top: calc(50% - 15px); + margin: 0; + padding: 0; + width: 100%; +} + +.CommentPlugin_CommentsPanel_List { + padding: 0; + list-style-type: none; + margin: 0; + padding: 0; + width: 100%; + position: absolute; + top: 45px; + overflow-y: auto; +} + +.CommentPlugin_CommentsPanel_List_Comment { + padding: 15px 0 15px 15px; + margin: 0; + font-size: 14px; + position: relative; + transition: all 0.2s linear; +} + +.CommentPlugin_CommentsPanel_List_Thread.active + .CommentPlugin_CommentsPanel_List_Comment:hover { + background-color: inherit; +} + +.CommentPlugin_CommentsPanel_List_Comment p { + margin: 0; + color: #444; +} + +.CommentPlugin_CommentsPanel_List_Details { + color: #444; + padding-bottom: 5px; + vertical-align: top; +} + +.CommentPlugin_CommentsPanel_List_Comment_Author { + font-weight: bold; + padding-right: 5px; +} + +.CommentPlugin_CommentsPanel_List_Comment_Time { + color: #999; +} + +.CommentPlugin_CommentsPanel_List_Thread { + padding: 0 0 0 0; + margin: 0; + border-top: 1px solid #eee; + border-bottom: 1px solid #eee; + position: relative; + transition: all 0.2s linear; + border-left: 0 solid #eee; +} + +.CommentPlugin_CommentsPanel_List_Thread:first-child, +.CommentPlugin_CommentsPanel_List_Thread + + .CommentPlugin_CommentsPanel_List_Thread { + border-top: none; +} + +.CommentPlugin_CommentsPanel_List_Thread.interactive { + cursor: pointer; +} + +.CommentPlugin_CommentsPanel_List_Thread.interactive:hover { + background-color: #fafafa; +} + +.CommentPlugin_CommentsPanel_List_Thread.active { + background-color: #fafafa; + border-left: 15px solid #eee; + cursor: inherit; +} + +.CommentPlugin_CommentsPanel_List_Thread_Quote { + padding-top: 10px; + margin: 0px 10px 0 10px; + color: #ccc; + display: block; +} + +.CommentPlugin_CommentsPanel_List_Thread_Quote span { + color: #222; + background-color: rgba(255, 212, 0, 0.4); + padding: 1px; + line-height: 1.4; + display: inline; + font-weight: bold; +} + +.CommentPlugin_CommentsPanel_List_Thread_Comments { + padding-left: 10px; + list-style-type: none; +} + +.CommentPlugin_CommentsPanel_List_Thread_Comments + .CommentPlugin_CommentsPanel_List_Comment:first-child { + border: none; + margin-left: 0; + padding-left: 5px; +} + +.CommentPlugin_CommentsPanel_List_Thread_Comments + .CommentPlugin_CommentsPanel_List_Comment:first-child.CommentPlugin_CommentsPanel_List_Comment:last-child { + padding-bottom: 5px; +} + +.CommentPlugin_CommentsPanel_List_Thread_Comments + .CommentPlugin_CommentsPanel_List_Comment { + padding-left: 10px; + border-left: 5px solid #eee; + margin-left: 5px; +} + +.CommentPlugin_CommentsPanel_List_Thread_Editor { + position: relative; + padding-top: 1px; +} + +.CommentPlugin_CommentsPanel_List_DeleteButton { + position: absolute; + top: 10px; + right: 10px; + width: 30px; + height: 30px; + background-color: transparent; + opacity: 0; +} + +.CommentPlugin_CommentsPanel_List_Comment:hover + .CommentPlugin_CommentsPanel_List_DeleteButton { + opacity: 0.5; +} + +.CommentPlugin_CommentsPanel_List_DeleteButton:hover { + background-color: transparent; + opacity: 1; + filter: invert(45%) sepia(98%) saturate(2299%) hue-rotate(201deg) + brightness(100%) contrast(92%); +} + +.CommentPlugin_CommentsPanel_List_DeleteButton i.delete { + background-size: contain; + position: absolute; + left: 5px; + top: 5px; + height: 15px; + width: 15px; + vertical-align: -10px; + background-image: url(../images/icons/trash3.svg); + transition: opacity 0.2s linear; +} diff --git a/packages/lexical-playground/src/plugins/CommentPlugin.jsx b/packages/lexical-playground/src/plugins/CommentPlugin.jsx new file mode 100644 index 00000000000..db19c92ea8e --- /dev/null +++ b/packages/lexical-playground/src/plugins/CommentPlugin.jsx @@ -0,0 +1,1037 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict + */ + +import type { + EditorState, + LexicalEditor, + NodeKey, + RangeSelection, + TextNode, +} from 'lexical'; + +import './CommentPlugin.css'; + +import AutoFocusPlugin from '@lexical/react/LexicalAutoFocusPlugin'; +import LexicalClearEditorPlugin from '@lexical/react/LexicalClearEditorPlugin'; +import LexicalComposer from '@lexical/react/LexicalComposer'; +import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; +import {HistoryPlugin} from '@lexical/react/LexicalHistoryPlugin'; +import LexicalOnChangePlugin from '@lexical/react/LexicalOnChangePlugin'; +import PlainTextPlugin from '@lexical/react/LexicalPlainTextPlugin'; +import {createDOMRange, createRectsFromDOMRange} from '@lexical/selection'; +import {$isRootTextContentEmpty, $rootTextContentCurry} from '@lexical/text'; +import {mergeRegister} from '@lexical/utils'; +import { + $getNodeByKey, + $getSelection, + $isElementNode, + $isRangeSelection, + $isTextNode, + CLEAR_EDITOR_COMMAND, + KEY_ESCAPE_COMMAND, +} from 'lexical'; +import {useCallback, useEffect, useMemo, useRef, useState} from 'react'; +import * as React from 'react'; +// $FlowFixMe: Flow doesn't see react-dom module +import {createPortal} from 'react-dom'; +import useLayoutEffect from 'shared/useLayoutEffect'; + +import useModal from '../hooks/useModal'; +import {$createMarkNode, $isMarkNode, MarkNode} from '../nodes/MarkNode'; +import CommentEditorTheme from '../themes/CommentEditorTheme'; +import Button from '../ui/Button'; +import ContentEditable from '../ui/ContentEditable.jsx'; +import Placeholder from '../ui/Placeholder.jsx'; + +export type CommentContextType = { + isActive: boolean, + setActive: (val: boolean) => void, +}; + +export type Comment = { + author: string, + content: string, + id: string, + timeStamp: number, + type: 'comment', +}; + +export type Thread = { + comments: Array, + id: string, + quote: string, + type: 'thread', +}; + +export type Comments = Array; + +// $FlowFixMe: needs type +type RtfObject = Object; + +function AddCommentBox({ + anchorKey, + editor, + onAddComment, +}: { + anchorKey: NodeKey, + editor: LexicalEditor, + onAddComment: () => void, +}): React$Node { + const boxRef = useRef(null); + + useLayoutEffect(() => { + const boxElem = boxRef.current; + const rootElement = editor.getRootElement(); + const anchorElement = editor.getElementByKey(anchorKey); + + if (boxElem !== null && rootElement !== null && anchorElement !== null) { + const {right} = rootElement.getBoundingClientRect(); + const {top} = anchorElement.getBoundingClientRect(); + boxElem.style.left = `${right - 20}px`; + boxElem.style.top = `${top - 30}px`; + } + }, [anchorKey, editor]); + + return ( + + + + + + ); +} + +function EditorRefPlugin({ + editorRef, +}: { + editorRef: {current: null | LexicalEditor}, +}): null { + const [editor] = useLexicalComposerContext(); + + useLayoutEffect(() => { + editorRef.current = editor; + return () => { + editorRef.current = null; + }; + }, [editor, editorRef]); + + return null; +} + +function EscapeHandlerPlugin({ + onEscape, +}: { + onEscape: (KeyboardEvent) => boolean, +}): null { + const [editor] = useLexicalComposerContext(); + + useEffect(() => { + return editor.registerCommand( + KEY_ESCAPE_COMMAND, + (event: KeyboardEvent) => { + return onEscape(event); + }, + 2, + ); + }, [editor, onEscape]); + + return null; +} + +function PlainTextEditor({ + className, + autoFocus, + onEscape, + onChange, + editorRef, + placeholder = 'Type a comment...', +}: { + autoFocus?: boolean, + className?: string, + editorRef?: {current: null | LexicalEditor}, + onChange: (EditorState, LexicalEditor) => void, + onEscape: (KeyboardEvent) => boolean, + placeholder?: string, +}) { + const initialConfig = { + namespace: 'CommentEditor', + nodes: [], + onError: (error) => { + throw error; + }, + theme: CommentEditorTheme, + }; + + return ( + + + } + placeholder={{placeholder}} + /> + + + {autoFocus !== false && } + + + {editorRef !== undefined && } + + + ); +} + +function useOnChange(setContent, setCanSubmit) { + return useCallback( + (editorState: EditorState, _editor: LexicalEditor) => { + editorState.read(() => { + setContent($rootTextContentCurry()); + setCanSubmit(!$isRootTextContentEmpty(_editor.isComposing(), true)); + }); + }, + [setCanSubmit, setContent], + ); +} + +function CommentInputBox({ + editor, + cancelAddComment, + submitAddComment, +}: { + cancelAddComment: () => void, + editor: LexicalEditor, + submitAddComment: (Comment | Thread, boolean) => void, +}) { + const [content, setContent] = useState(''); + const [canSubmit, setCanSubmit] = useState(false); + const boxRef = useRef(null); + const selectionState = useMemo( + () => ({ + container: document.createElement('div'), + elements: [], + }), + [], + ); + + const updateLocation = useCallback(() => { + editor.getEditorState().read(() => { + const selection = $getSelection(); + + if ($isRangeSelection(selection)) { + const anchor = selection.anchor; + const focus = selection.focus; + const range = createDOMRange( + editor, + anchor.getNode(), + anchor.offset, + focus.getNode(), + focus.offset, + ); + const boxElem = boxRef.current; + if (range !== null && boxElem !== null) { + const {left, bottom, width} = range.getBoundingClientRect(); + const selectionRects = createRectsFromDOMRange(editor, range); + let correctedLeft = + selectionRects.length === 1 ? left + width / 2 - 125 : left - 125; + if (correctedLeft < 10) { + correctedLeft = 10; + } + boxElem.style.left = `${correctedLeft}px`; + boxElem.style.top = `${bottom + 20}px`; + const selectionRectsLength = selectionRects.length; + const {elements, container} = selectionState; + const elementsLength = elements.length; + + for (let i = 0; i < selectionRectsLength; i++) { + const selectionRect = selectionRects[i]; + let elem = elements[i]; + if (elem === undefined) { + elem = document.createElement('span'); + elements[i] = elem; + container.appendChild(elem); + } + const color = '255, 212, 0'; + const style = `position:absolute;top:${selectionRect.top}px;left:${selectionRect.left}px;height:${selectionRect.height}px;width:${selectionRect.width}px;background-color:rgba(${color}, 0.3);pointer-events:none;z-index:5;`; + elem.style.cssText = style; + } + for (let i = elementsLength - 1; i >= selectionRectsLength; i--) { + const elem = elements[i]; + container.removeChild(elem); + elements.pop(); + } + } + } + }); + }, [editor, selectionState]); + + useLayoutEffect(() => { + updateLocation(); + const container = selectionState.container; + const body = document.body; + if (body !== null) { + body.appendChild(container); + return () => { + body.removeChild(container); + }; + } + }, [selectionState.container, updateLocation]); + + useEffect(() => { + window.addEventListener('resize', updateLocation); + + return () => { + window.removeEventListener('resize', updateLocation); + }; + }, [updateLocation]); + + const onEscape = (event: KeyboardEvent): boolean => { + event.preventDefault(); + cancelAddComment(); + return true; + }; + + const submitComment = () => { + if (canSubmit) { + let quote = editor.getEditorState().read(() => { + const selection = $getSelection(); + return selection !== null ? selection.getTextContent() : ''; + }); + if (quote.length > 100) { + quote = quote.slice(0, 99) + '…'; + } + submitAddComment(createThread(quote, content), true); + } + }; + + const onChange = useOnChange(setContent, setCanSubmit); + + return ( + + + + + Cancel + + + Comment + + + + ); +} + +function CommentsComposer({ + submitAddComment, + thread, + placeholder, +}: { + placeholder?: string, + submitAddComment: (Comment, boolean, thread?: Thread) => void, + thread?: Thread, +}) { + const [content, setContent] = useState(''); + const [canSubmit, setCanSubmit] = useState(false); + const editorRef = useRef(null); + + const onChange = useOnChange(setContent, setCanSubmit); + + const submitComment = () => { + if (canSubmit) { + submitAddComment(createComment(content), false, thread); + const editor = editorRef.current; + if (editor !== null) { + editor.dispatchCommand(CLEAR_EDITOR_COMMAND); + } + } + }; + + return ( + <> + { + return true; + }} + onChange={onChange} + editorRef={editorRef} + placeholder={placeholder} + /> + + + + > + ); +} + +function CommentsPanelFooter({ + footerRef, + submitAddComment, +}: { + footerRef: {current: null | HTMLElement}, + submitAddComment: (Comment | Thread, boolean) => void, +}) { + return ( + + + + ); +} + +function ShowDeleteCommentDialog({ + comment, + deleteComment, + onClose, + thread, +}: { + comment: Comment, + deleteComment: (Comment, thread?: Thread) => void, + onClose: () => void, + thread?: Thread, +}): React$Node { + return ( + <> + Are you sure you want to delete this comment? + + { + deleteComment(comment, thread); + onClose(); + }}> + Delete + {' '} + { + onClose(); + }}> + Cancel + + + > + ); +} + +function CommentsPanelListComment({ + comment, + deleteComment, + thread, + rtf, +}: { + comment: Comment, + deleteComment: (Comment, thread?: Thread) => void, + rtf: RtfObject, + thread?: Thread, +}): React$Node { + const seconds = Math.round((comment.timeStamp - performance.now()) / 1000); + const minutes = Math.round(seconds / 60); + const [modal, showModal] = useModal(); + + return ( + + + + {comment.author} + + + · {seconds > -10 ? 'Just now' : rtf.format(minutes, 'minute')} + + + {comment.content} + { + showModal('Delete Comment', (onClose) => ( + + )); + }} + className="CommentPlugin_CommentsPanel_List_DeleteButton"> + + + {modal} + + ); +} + +function CommentsPanelList({ + activeIDs, + comments, + deleteComment, + listRef, + submitAddComment, + markNodeMap, + setActiveIDs, +}: { + activeIDs: Array, + comments: Comments, + deleteComment: (Comment, thread?: Thread) => void, + listRef: {current: null | HTMLElement}, + markNodeMap: Map>, + setActiveIDs: (((Array) => Array) | Array) => void, + submitAddComment: (Comment | Thread, boolean, thread?: Thread) => void, +}): React$Node { + const [counter, setCounter] = useState(0); + const rtf: RtfObject = useMemo( + () => + // $FlowFixMe: Flow hasn't got types yet + new Intl.RelativeTimeFormat('en', { + localeMatcher: 'best fit', + numeric: 'auto', + style: 'short', + }), + [], + ); + + useEffect(() => { + // Used to keep the time stamp up to date + const id = setTimeout(() => { + setCounter(counter + 1); + }, 10000); + + return () => { + clearTimeout(id); + }; + }, [counter]); + + return ( + + {comments.map((commentOrThread) => { + const id = commentOrThread.id; + if (commentOrThread.type === 'thread') { + const handleClickThread = () => { + if ( + markNodeMap.has(id) && + (activeIDs === null || activeIDs.indexOf(id) === -1) + ) { + setActiveIDs((_activeIDs) => [..._activeIDs, id]); + } + }; + + return ( + // eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions + + + > {commentOrThread.quote} + + + {commentOrThread.comments.map((comment) => ( + + ))} + + + + + + ); + } + return ( + + ); + })} + + ); +} + +function CommentsPanel({ + activeIDs, + deleteComment, + comments, + submitAddComment, + markNodeMap, + setActiveIDs, +}: { + activeIDs: Array, + comments: Comments, + deleteComment: (Comment, thread?: Thread) => void, + markNodeMap: Map>, + setActiveIDs: (((Array) => Array) | Array) => void, + submitAddComment: (Comment | Thread, boolean, thread?: Thread) => void, +}): React$Node { + const footerRef = useRef(null); + const listRef = useRef(null); + const isEmpty = comments.length === 0; + + useLayoutEffect(() => { + const footerElem = footerRef.current; + if (footerElem !== null) { + const resizeObserver = new ResizeObserver(() => { + const listElem = listRef.current; + if (listElem !== null) { + const rect = footerElem.getBoundingClientRect(); + listElem.style.height = window.innerHeight - rect.height - 133 + 'px'; + } + }); + + resizeObserver.observe(footerElem); + return () => { + resizeObserver.disconnect(); + }; + } + }, [isEmpty]); + + return ( + + Comments + {isEmpty ? ( + No Comments + ) : ( + + )} + + + ); +} + +function createUID(): string { + return Math.random() + .toString(36) + .replace(/[^a-z]+/g, '') + .substr(0, 5); +} + +function createComment(content: string): Comment { + return { + author: 'Playground User', + content, + id: createUID(), + timeStamp: performance.now(), + type: 'comment', + }; +} + +function createThread(quote: string, content: string): Thread { + return { + comments: [createComment(content)], + id: createUID(), + quote, + type: 'thread', + }; +} + +function cloneThread(thread: Thread): Thread { + return { + comments: Array.from(thread.comments), + id: thread.id, + quote: thread.quote, + type: 'thread', + }; +} + +function $unwrapMarkNode(node: MarkNode): void { + const children = node.getChildren(); + let target = null; + for (let i = 0; i < children.length; i++) { + const child = children[i]; + if (target === null) { + node.insertBefore(child); + target = child; + } else { + target.insertAfter(child); + } + } + node.remove(); +} + +function $wrapSelectionInMarkNode( + selection: RangeSelection, + isBackward: boolean, + id: string, +): void { + const nodes = selection.getNodes(); + const anchorOffset = selection.anchor.offset; + const focusOffset = selection.focus.offset; + const nodesLength = nodes.length; + const startOffset = isBackward ? focusOffset : anchorOffset; + const endOffset = isBackward ? anchorOffset : focusOffset; + let currentNodeParent; + let currentMarkNode; + + // We only want wrap adjacent text nodes, line break nodes + // and inline element nodes. For decorator nodes and block + // element nodes, we stop out their boundary and start again + // after, if there are more nodes. + for (let i = 0; i < nodesLength; i++) { + const node = nodes[i]; + if ($isElementNode(currentMarkNode) && currentMarkNode.isParentOf(node)) { + continue; + } + const isFirstNode = i === 0; + const isLastNode = i === nodesLength - 1; + let targetNode; + + if ($isTextNode(node)) { + const textContentSize = node.getTextContentSize(); + const startTextOffset = isFirstNode ? startOffset : 0; + const endTextOffset = isLastNode ? endOffset : textContentSize; + const splitNodes = node.splitText(startTextOffset, endTextOffset); + targetNode = + splitNodes.length > 1 && + (splitNodes.length === 3 || + (isFirstNode && !isLastNode) || + endTextOffset === textContentSize) + ? splitNodes[1] + : splitNodes[0]; + } else if ($isElementNode(node) && node.isInline()) { + targetNode = node; + } + if (targetNode !== undefined) { + const parentNode = targetNode.getParent(); + if (parentNode == null || !parentNode.is(currentNodeParent)) { + currentMarkNode = undefined; + } + currentNodeParent = parentNode; + if (currentMarkNode === undefined) { + currentMarkNode = $createMarkNode([id]); + targetNode.insertBefore(currentMarkNode); + } + currentMarkNode.append(targetNode); + } else { + currentNodeParent = undefined; + currentMarkNode = undefined; + } + } +} + +function $getCommentIDs(node: TextNode): null | Array { + let currentNode = node; + while (currentNode !== null) { + if ($isMarkNode(currentNode)) { + return currentNode.getIDs(); + } + currentNode = currentNode.getParent(); + } + return null; +} + +export default function CommentPlugin({ + initialComments, +}: { + initialComments?: Comments, +}): React$Node { + const [editor] = useLexicalComposerContext(); + const [comments, setComments] = useState(initialComments || []); + const markNodeMap = useMemo>>(() => { + return new Map(); + }, []); + const [activeAnchorKey, setActiveAnchorKey] = useState(null); + const [activeIDs, setActiveIDs] = useState>([]); + const [showCommentInput, setShowCommentInput] = useState(false); + const [showComments, setShowComments] = useState(false); + + const cancelAddComment = useCallback(() => { + editor.update(() => { + const selection = $getSelection(); + // Restore selection + if (selection !== null) { + selection.dirty = true; + } + }); + setShowCommentInput(false); + }, [editor]); + + const deleteComment = useCallback( + (comment: Comment, thread?: Thread) => { + setComments((_comments) => { + const nextComments = Array.from(_comments); + + if (thread !== undefined) { + for (let i = 0; i < nextComments.length; i++) { + const nextComment = nextComments[i]; + if (nextComment.type === 'thread' && nextComment.id === thread.id) { + const newThread = cloneThread(nextComment); + nextComments.splice(i, 1, newThread); + const threadComments = newThread.comments; + const index = threadComments.indexOf(comment); + threadComments.splice(index, 1); + if (threadComments.length === 0) { + const threadIndex = nextComments.indexOf(newThread); + nextComments.splice(threadIndex, 1); + // Remove ids from associated marks + const id = thread !== undefined ? thread.id : comment.id; + const markNodeKeys = markNodeMap.get(id); + if (markNodeKeys !== undefined) { + // Do async to avoid causing a React infinite loop + setTimeout(() => { + editor.update(() => { + for (const key of markNodeKeys) { + const node: null | MarkNode = $getNodeByKey(key); + if ($isMarkNode(node)) { + node.deleteID(id); + if (node.getIDs().length === 0) { + $unwrapMarkNode(node); + } + } + } + }); + }); + } + } + break; + } + } + } else { + const index = nextComments.indexOf(comment); + nextComments.splice(index, 1); + } + return nextComments; + }); + }, + [editor, markNodeMap], + ); + + const submitAddComment = useCallback( + ( + commentOrThread: Comment | Thread, + isInlineComment: boolean, + thread?: Thread, + ) => { + setComments((_comments) => { + const nextComments = Array.from(_comments); + if (thread !== undefined && commentOrThread.type === 'comment') { + for (let i = 0; i < nextComments.length; i++) { + const comment = nextComments[i]; + if (comment.type === 'thread' && comment.id === thread.id) { + const newThread = cloneThread(comment); + nextComments.splice(i, 1, newThread); + newThread.comments.push(commentOrThread); + break; + } + } + } else { + nextComments.push(commentOrThread); + } + return nextComments; + }); + if (isInlineComment) { + editor.update(() => { + const selection = $getSelection(); + if ($isRangeSelection(selection)) { + const focus = selection.focus; + const anchor = selection.anchor; + const isBackward = selection.isBackward(); + const id = commentOrThread.id; + + // Wrap content in a MarkNode + $wrapSelectionInMarkNode(selection, isBackward, id); + + // Make selection collapsed at the end + if (isBackward) { + focus.set(anchor.key, anchor.offset, anchor.type); + } else { + anchor.set(focus.key, focus.offset, focus.type); + } + } + }); + setShowCommentInput(false); + } + }, + [editor], + ); + + useEffect(() => { + const changedElems = []; + for (let i = 0; i < activeIDs.length; i++) { + const id = activeIDs[i]; + const keys = markNodeMap.get(id); + if (keys !== undefined) { + for (const key of keys) { + const elem = editor.getElementByKey(key); + if (elem !== null) { + elem.classList.add('selected'); + changedElems.push(elem); + } + } + } + } + return () => { + for (let i = 0; i < changedElems.length; i++) { + const changedElem = changedElems[i]; + changedElem.classList.remove('selected'); + } + }; + }, [activeIDs, editor, markNodeMap]); + + useEffect(() => { + const markNodeKeysToIDs: Map> = new Map(); + + return mergeRegister( + editor.registerMutationListener(MarkNode, (mutations) => { + for (const [key, mutation] of mutations) { + const node: null | MarkNode = $getNodeByKey(key); + let ids = []; + + if (mutation === 'destroyed') { + ids = markNodeKeysToIDs.get(key) || []; + } else if ($isMarkNode(node)) { + ids = node.getIDs(); + } + let hasChanged = false; + + for (let i = 0; i < ids.length; i++) { + const id = ids[i]; + let markNodeKeys = markNodeMap.get(id); + markNodeKeysToIDs.set(key, ids); + + if (mutation === 'destroyed') { + if (markNodeKeys !== undefined) { + markNodeKeys.delete(key); + hasChanged = true; + if (markNodeKeys.size === 0) { + markNodeMap.delete(id); + } + } + } else { + if (markNodeKeys === undefined) { + markNodeKeys = new Set(); + markNodeMap.set(id, markNodeKeys); + } + if (!markNodeKeys.has(key)) { + hasChanged = true; + markNodeKeys.add(key); + } + } + } + // This will try an update so the CommentList can update + // accordingly. + if (hasChanged) { + setActiveIDs((activeIds) => [...activeIds]); + } + } + }), + editor.registerUpdateListener(({editorState, tags}) => { + editorState.read(() => { + const selection = $getSelection(); + let hasActiveIds = false; + + if ($isRangeSelection(selection)) { + const anchorNode = selection.anchor.getNode(); + + if ($isTextNode(anchorNode)) { + const commentIDs = $getCommentIDs(anchorNode); + if (commentIDs !== null) { + setActiveIDs(commentIDs); + hasActiveIds = true; + } else if (!selection.isCollapsed()) { + setActiveAnchorKey(anchorNode.getKey()); + return; + } + } + } + if (!hasActiveIds) { + setActiveIDs([]); + } + setActiveAnchorKey(null); + }); + if (!tags.has('collaboration')) { + setShowCommentInput(false); + } + }), + ); + }, [editor, markNodeMap]); + + const onAddComment = () => { + const domSelection = window.getSelection(); + domSelection.removeAllRanges(); + setShowCommentInput(true); + }; + + return ( + <> + {showCommentInput && + createPortal( + , + document.body, + )} + {activeAnchorKey !== null && + !showCommentInput && + createPortal( + , + document.body, + )} + {createPortal( + setShowComments(!showComments)} + title={showComments ? 'Hide Comments' : 'Show Comments'}> + + , + document.body, + )} + {showComments && + createPortal( + , + document.body, + )} + > + ); +} diff --git a/packages/lexical-playground/src/plugins/TableCellResizer.css b/packages/lexical-playground/src/plugins/TableCellResizer.css index 77c42c5fcf7..e2408824ce1 100644 --- a/packages/lexical-playground/src/plugins/TableCellResizer.css +++ b/packages/lexical-playground/src/plugins/TableCellResizer.css @@ -6,6 +6,7 @@ * * */ + .TableCellResizer__resizer { position: absolute; } diff --git a/packages/lexical-playground/src/themes/CommentEditorTheme.css b/packages/lexical-playground/src/themes/CommentEditorTheme.css new file mode 100644 index 00000000000..e50318c3250 --- /dev/null +++ b/packages/lexical-playground/src/themes/CommentEditorTheme.css @@ -0,0 +1,13 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * + */ + +.CommentEditorTheme__paragraph { + margin: 0; + position: 'relative'; +} diff --git a/packages/lexical-playground/src/themes/CommentEditorTheme.js b/packages/lexical-playground/src/themes/CommentEditorTheme.js new file mode 100644 index 00000000000..8bcbd2676b7 --- /dev/null +++ b/packages/lexical-playground/src/themes/CommentEditorTheme.js @@ -0,0 +1,21 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict + */ + +import type {EditorThemeClasses} from 'lexical'; + +import './CommentEditorTheme.css'; + +import baseTheme from './PlaygroundEditorTheme'; + +const theme: EditorThemeClasses = { + ...baseTheme, + paragraph: 'CommentEditorTheme__paragraph', +}; + +export default theme; diff --git a/packages/lexical-playground/src/themes/PlaygroundEditorTheme.css b/packages/lexical-playground/src/themes/PlaygroundEditorTheme.css index 48dd9e8fbb8..949000d6bd4 100644 --- a/packages/lexical-playground/src/themes/PlaygroundEditorTheme.css +++ b/packages/lexical-playground/src/themes/PlaygroundEditorTheme.css @@ -213,3 +213,13 @@ .PlaygroundEditorTheme__tokenFunction { color: #dd4a68; } + +.PlaygroundEditorTheme__mark { + background: rgba(255, 212, 0, 0.14); + border-bottom: 1px solid rgb(255, 212, 0); + padding-bottom: 2px; +} + +.PlaygroundEditorTheme__mark.selected { + background: rgba(255, 212, 0, 0.5); +} diff --git a/packages/lexical-playground/src/themes/PlaygroundEditorTheme.js b/packages/lexical-playground/src/themes/PlaygroundEditorTheme.js index 0191e99ab0c..86f888f82c8 100644 --- a/packages/lexical-playground/src/themes/PlaygroundEditorTheme.js +++ b/packages/lexical-playground/src/themes/PlaygroundEditorTheme.js @@ -71,6 +71,7 @@ const theme: EditorThemeClasses = { ul: 'PlaygroundEditorTheme__ul', }, ltr: 'PlaygroundEditorTheme__ltr', + mark: 'PlaygroundEditorTheme__mark', paragraph: 'PlaygroundEditorTheme__paragraph', quote: 'PlaygroundEditorTheme__quote', rtl: 'PlaygroundEditorTheme__rtl', diff --git a/packages/lexical-playground/src/ui/Button.jsx b/packages/lexical-playground/src/ui/Button.jsx index d3a86e9672b..be906da979b 100644 --- a/packages/lexical-playground/src/ui/Button.jsx +++ b/packages/lexical-playground/src/ui/Button.jsx @@ -16,15 +16,19 @@ import joinClasses from '../utils/join-classes'; export default function Button({ 'data-test-id': dataTestId, children, + className, onClick, disabled, small, + title, }: { 'data-test-id'?: string, children: React$Node, + className?: string, disabled?: boolean, onClick: () => void, small?: boolean, + title?: string, }): React$Node { return ( {children} diff --git a/packages/lexical-selection/src/index.js b/packages/lexical-selection/src/index.js index b5e8aac36fd..c5c2d4463d8 100644 --- a/packages/lexical-selection/src/index.js +++ b/packages/lexical-selection/src/index.js @@ -10,6 +10,7 @@ import type { ElementNode, GridSelection, + LexicalEditor, LexicalNode, NodeKey, NodeSelection, @@ -673,3 +674,130 @@ export function $shouldOverrideDefaultCharacterSelection( const possibleNode = $getDecoratorNode(selection.focus, isBackward); return $isDecoratorNode(possibleNode) && !possibleNode.isIsolated(); } + +function getDOMTextNode(element: Node | null): Text | null { + let node = element; + while (node != null) { + if (node.nodeType === 3) { + // $FlowFixMe: this is a Text + return node; + } + node = node.firstChild; + } + return null; +} + +function getDOMIndexWithinParent(node: Node): [Node, number] { + const parent = node.parentNode; + if (parent == null) { + throw new Error('Should never happen'); + } + return [parent, Array.from(parent.childNodes).indexOf(node)]; +} + +export function createDOMRange( + editor: LexicalEditor, + anchorNode: LexicalNode, + _anchorOffset: number, + focusNode: LexicalNode, + _focusOffset: number, +): Range | null { + const anchorKey = anchorNode.getKey(); + const focusKey = focusNode.getKey(); + const range = document.createRange(); + let anchorDOM = editor.getElementByKey(anchorKey); + let focusDOM = editor.getElementByKey(focusKey); + let anchorOffset = _anchorOffset; + let focusOffset = _focusOffset; + + if ($isTextNode(anchorNode)) { + anchorDOM = getDOMTextNode(anchorDOM); + } + if ($isTextNode(focusNode)) { + focusDOM = getDOMTextNode(focusDOM); + } + if ( + anchorNode === undefined || + focusNode === undefined || + anchorDOM === null || + focusDOM === null + ) { + return null; + } + if (anchorDOM.nodeName === 'BR') { + [anchorDOM, anchorOffset] = getDOMIndexWithinParent(anchorDOM); + } + if (focusDOM.nodeName === 'BR') { + [focusDOM, focusOffset] = getDOMIndexWithinParent(focusDOM); + } + const firstChild = anchorDOM.firstChild; + if ( + anchorDOM === focusDOM && + firstChild != null && + firstChild.nodeName === 'BR' && + anchorOffset === 0 && + focusOffset === 0 + ) { + focusOffset = 1; + } + try { + range.setStart(anchorDOM, anchorOffset); + range.setEnd(focusDOM, focusOffset); + } catch (e) { + return null; + } + + if ( + range.collapsed && + (anchorOffset !== focusOffset || anchorKey !== focusKey) + ) { + // Range is backwards, we need to reverse it + range.setStart(focusDOM, focusOffset); + range.setEnd(anchorDOM, anchorOffset); + } + return range; +} + +export function createRectsFromDOMRange( + editor: LexicalEditor, + range: Range, +): Array { + const rootElement = editor.getRootElement(); + if (rootElement === null) { + return []; + } + const rootRect = rootElement.getBoundingClientRect(); + const computedStyle = getComputedStyle(rootElement); + const rootPadding = + parseFloat(computedStyle.paddingLeft) + + parseFloat(computedStyle.paddingRight); + const selectionRects = Array.from(range.getClientRects()); + let selectionRectsLength = selectionRects.length; + let prevRect; + + for (let i = 0; i < selectionRectsLength; i++) { + const selectionRect = selectionRects[i]; + // Exclude a rect that is the exact same as the last rect. getClientRects() can return + // the same rect twice for some elements. A more sophisticated thing to do here is to + // merge all the rects together into a set of rects that don't overlap, so we don't + // generate backgrounds that are too dark. + const isDuplicateRect = + prevRect && + prevRect.top === selectionRect.top && + prevRect.left === selectionRect.left && + prevRect.width === selectionRect.width && + prevRect.height === selectionRect.height; + + // Exclude selections that span the entire element + const selectionSpansElement = + selectionRect.width + rootPadding === rootRect.width; + + if (isDuplicateRect || selectionSpansElement) { + selectionRects.splice(i--, 1); + selectionRectsLength--; + continue; + } + prevRect = selectionRect; + } + return selectionRects; +} diff --git a/packages/lexical-yjs/src/SyncCursors.js b/packages/lexical-yjs/src/SyncCursors.js index c7d9ccaee16..4b0d2c7cb57 100644 --- a/packages/lexical-yjs/src/SyncCursors.js +++ b/packages/lexical-yjs/src/SyncCursors.js @@ -24,12 +24,12 @@ import type { XmlText, } from 'yjs'; +import {createDOMRange, createRectsFromDOMRange} from '@lexical/selection'; import { $getNodeByKey, $getSelection, $isElementNode, $isRangeSelection, - $isTextNode, } from 'lexical'; import { compareRelativePositions, @@ -55,7 +55,6 @@ export type CursorSelection = { offset: number, }, name: HTMLSpanElement, - range: Range, selections: Array, }; @@ -138,18 +137,6 @@ function destroyCursor(binding: Binding, cursor: Cursor) { } } -function getDOMTextNode(element: Node | null): Text | null { - let node = element; - while (node != null) { - if (node.nodeType === 3) { - // $FlowFixMe: this is a Text - return node; - } - node = node.firstChild; - } - return null; -} - function createCursorSelection( cursor: Cursor, anchorKey: NodeKey, @@ -176,19 +163,10 @@ function createCursorSelection( offset: focusOffset, }, name, - range: document.createRange(), selections: [], }; } -function getDOMIndexWithinParent(node: Node): [Node, number] { - const parent = node.parentNode; - if (parent == null) { - throw new Error('Should never happen'); - } - return [parent, Array.from(parent.childNodes).indexOf(node)]; -} - function updateCursor( binding: Binding, cursor: Cursor, @@ -213,7 +191,6 @@ function updateCursor( } else { cursor.selection = nextSelection; } - const range = nextSelection.range; const caret = nextSelection.caret; const color = nextSelection.color; const selections = nextSelection.selections; @@ -223,101 +200,32 @@ function updateCursor( const focusKey = focus.key; const anchorNode = nodeMap.get(anchorKey); const focusNode = nodeMap.get(focusKey); - let anchorDOM = editor.getElementByKey(anchorKey); - let focusDOM = editor.getElementByKey(focusKey); - let anchorOffset = anchor.offset; - let focusOffset = focus.offset; - - if ($isTextNode(anchorNode)) { - anchorDOM = getDOMTextNode(anchorDOM); - } - if ($isTextNode(focusNode)) { - focusDOM = getDOMTextNode(focusDOM); - } - if ( - anchorNode === undefined || - focusNode === undefined || - anchorDOM === null || - focusDOM === null - ) { + if (anchorNode == null || focusNode == null) { return; } - if (anchorDOM.nodeName === 'BR') { - [anchorDOM, anchorOffset] = getDOMIndexWithinParent(anchorDOM); - } - if (focusDOM.nodeName === 'BR') { - [focusDOM, focusOffset] = getDOMIndexWithinParent(focusDOM); - } - const firstChild = anchorDOM.firstChild; - if ( - anchorDOM === focusDOM && - firstChild != null && - firstChild.nodeName === 'BR' && - anchorOffset === 0 && - focusOffset === 0 - ) { - focusOffset = 1; - } - try { - range.setStart(anchorDOM, anchorOffset); - range.setEnd(focusDOM, focusOffset); - } catch (e) { + const range = createDOMRange( + editor, + anchorNode, + anchor.offset, + focusNode, + focus.offset, + ); + if (range === null) { return; } - - if ( - range.collapsed && - (anchorOffset !== focusOffset || anchorKey !== focusKey) - ) { - // Range is backwards, we need to reverse it - range.setStart(focusDOM, focusOffset); - range.setEnd(anchorDOM, anchorOffset); - } - // We need to - const rootRect = rootElement.getBoundingClientRect(); - const computedStyle = getComputedStyle(rootElement); - const rootPadding = - parseFloat(computedStyle.paddingLeft) + - parseFloat(computedStyle.paddingRight); - const selectionRects = Array.from(range.getClientRects()); - let selectionRectsLength = selectionRects.length; const selectionsLength = selections.length; - - let prevRect; + const selectionRects = createRectsFromDOMRange(editor, range); + const selectionRectsLength = selectionRects.length; for (let i = 0; i < selectionRectsLength; i++) { const selectionRect = selectionRects[i]; - - // Exclude a rect that is the exact same as the last rect. getClientRects() can return - // the same rect twice for some elements. A more sophisticated thing to do here is to - // merge all the rects together into a set of rects that don't overlap, so we don't - // generate backgrounds that are too dark. - const isDuplicateRect = - prevRect && - prevRect.top === selectionRect.top && - prevRect.left === selectionRect.left && - prevRect.width === selectionRect.width && - prevRect.height === selectionRect.height; - - // Exclude selections that span the entire element - const selectionSpansElement = - selectionRect.width + rootPadding === rootRect.width; - - if (isDuplicateRect || selectionSpansElement) { - selectionRects.splice(i--, 1); - selectionRectsLength--; - continue; - } - - prevRect = selectionRect; - let selection = selections[i]; if (selection === undefined) { selection = document.createElement('span'); selections[i] = selection; cursorsContainer.appendChild(selection); } - const style = `position:absolute;top:${selectionRect.top}px;left:${selectionRect.left}px;height:${selectionRect.height}px;width:${selectionRect.width}px;background-color:rgba(${color}, 0.3);pointer-events:none;z-index:10;`; + const style = `position:absolute;top:${selectionRect.top}px;left:${selectionRect.left}px;height:${selectionRect.height}px;width:${selectionRect.width}px;background-color:rgba(${color}, 0.3);pointer-events:none;z-index:5;`; selection.style.cssText = style; if (i === selectionRectsLength - 1) { if (caret.parentNode !== selection) { diff --git a/packages/lexical/Lexical.d.ts b/packages/lexical/Lexical.d.ts index e8420072f95..95d8909ce87 100644 --- a/packages/lexical/Lexical.d.ts +++ b/packages/lexical/Lexical.d.ts @@ -201,6 +201,7 @@ export type EditorThemeClasses = { tableRow?: EditorThemeClassName; tableCell?: EditorThemeClassName; tableCellHeader?: EditorThemeClassName; + mark?: EditorThemeClassName; link?: EditorThemeClassName; quote?: EditorThemeClassName; code?: EditorThemeClassName; diff --git a/packages/lexical/flow/Lexical.js.flow b/packages/lexical/flow/Lexical.js.flow index eddd036c071..db0af05f97b 100644 --- a/packages/lexical/flow/Lexical.js.flow +++ b/packages/lexical/flow/Lexical.js.flow @@ -208,6 +208,7 @@ export type EditorThemeClasses = { tableRow?: EditorThemeClassName, tableCell?: EditorThemeClassName, tableCellHeader?: EditorThemeClassName, + mark?: EditorThemeClassName, link?: EditorThemeClassName, quote?: EditorThemeClassName, code?: EditorThemeClassName, diff --git a/packages/lexical/src/LexicalEditor.js b/packages/lexical/src/LexicalEditor.js index 8a1f5e48ab8..bc10e1d3661 100644 --- a/packages/lexical/src/LexicalEditor.js +++ b/packages/lexical/src/LexicalEditor.js @@ -81,6 +81,7 @@ export type EditorThemeClasses = { ulDepth: Array, }, ltr?: EditorThemeClassName, + mark?: EditorThemeClassName, paragraph?: EditorThemeClassName, quote?: EditorThemeClassName, root?: EditorThemeClassName, diff --git a/packages/lexical/src/LexicalSelection.js b/packages/lexical/src/LexicalSelection.js index 72888332b9b..3a51f8e797a 100644 --- a/packages/lexical/src/LexicalSelection.js +++ b/packages/lexical/src/LexicalSelection.js @@ -1961,7 +1961,8 @@ function resolveSelectionPointOnBoundary( (isCollapsed || isBackward) && nextSibling === null && $isElementNode(parent) && - parent.isInline() + parent.isInline() && + !parent.canInsertTextAfter() ) { const parentSibling = parent.getNextSibling(); if ($isTextNode(parentSibling)) {
{comment.content}
+ > {commentOrThread.quote} +