diff --git a/packages/blocks-editor/src/Editor/BlocksEditor.tsx b/packages/blocks-editor/src/Editor/BlocksEditor.tsx index 78924d489..fae06450b 100644 --- a/packages/blocks-editor/src/Editor/BlocksEditor.tsx +++ b/packages/blocks-editor/src/Editor/BlocksEditor.tsx @@ -25,6 +25,9 @@ import YouTubePlugin from '../Lexical/Plugins/YouTubePlugin'; import AutoEmbedPlugin from '../Lexical/Plugins/AutoEmbedPlugin'; import CollapsiblePlugin from '../Lexical/Plugins/CollapsiblePlugin'; import DraggableBlockPlugin from '../Lexical/Plugins/DraggableBlockPlugin'; +import CodeHighlightPlugin from '../Lexical/Plugins/CodeHighlightPlugin'; +import FloatingTextFormatToolbarPlugin from '../Lexical/Plugins/FloatingTextFormatToolbarPlugin'; +import FloatingLinkEditorPlugin from '../Lexical/Plugins/FloatingLinkEditorPlugin'; const BlockDragEnabled = false; @@ -88,12 +91,20 @@ export const BlocksEditor: FunctionComponent = ({ + + + {floatingAnchorElem && ( + <> + + + + )} {floatingAnchorElem && BlockDragEnabled && ( <>{} )} diff --git a/packages/blocks-editor/src/Editor/Commands.ts b/packages/blocks-editor/src/Editor/Commands.ts deleted file mode 100644 index 6bcd54228..000000000 --- a/packages/blocks-editor/src/Editor/Commands.ts +++ /dev/null @@ -1,5 +0,0 @@ -import {createCommand, LexicalCommand} from 'lexical'; - -export const INSERT_FILE_COMMAND: LexicalCommand = createCommand( - 'INSERT_FILE_COMMAND', -); diff --git a/packages/blocks-editor/src/Lexical/Plugins/CodeHighlightPlugin/index.ts b/packages/blocks-editor/src/Lexical/Plugins/CodeHighlightPlugin/index.ts new file mode 100644 index 000000000..7813912ec --- /dev/null +++ b/packages/blocks-editor/src/Lexical/Plugins/CodeHighlightPlugin/index.ts @@ -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. + * + */ + +import {registerCodeHighlighting} from '@lexical/code'; +import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; +import {useEffect} from 'react'; + +export default function CodeHighlightPlugin(): JSX.Element | null { + const [editor] = useLexicalComposerContext(); + + useEffect(() => { + return registerCodeHighlighting(editor); + }, [editor]); + + return null; +} diff --git a/packages/blocks-editor/src/Lexical/Plugins/CollapsiblePlugin/Collapsible.css b/packages/blocks-editor/src/Lexical/Plugins/CollapsiblePlugin/Collapsible.css index 606da2808..f3e62e71c 100644 --- a/packages/blocks-editor/src/Lexical/Plugins/CollapsiblePlugin/Collapsible.css +++ b/packages/blocks-editor/src/Lexical/Plugins/CollapsiblePlugin/Collapsible.css @@ -8,8 +8,8 @@ */ .Collapsible__container { - background: #fcfcfc; - border: 1px solid #eee; + background: var(--sn-stylekit-contrast-background-color); + border: 1px solid var(--sn-stylekit-contrast-border-color); border-radius: 10px; margin-bottom: 8px; } @@ -44,7 +44,7 @@ .Collapsible__container[open] .Collapsible__title:before { border-color: transparent; border-width: 6px 4px 0 4px; - border-top-color: #000; + border-top-color: var(--sn-stylekit-contrast-color); } .Collapsible__content { diff --git a/packages/blocks-editor/src/Lexical/Plugins/FloatingLinkEditorPlugin/index.css b/packages/blocks-editor/src/Lexical/Plugins/FloatingLinkEditorPlugin/index.css new file mode 100644 index 000000000..55732191f --- /dev/null +++ b/packages/blocks-editor/src/Lexical/Plugins/FloatingLinkEditorPlugin/index.css @@ -0,0 +1,40 @@ +.link-editor { + position: absolute; + top: 0; + left: 0; + z-index: 10; + max-width: 400px; + width: 100%; + opacity: 0; + background-color: #fff; + box-shadow: 0 5px 10px rgba(0, 0, 0, 0.3); + border-radius: 8px; + transition: opacity 0.5s; + will-change: transform; +} + +.link-editor .button { + width: 20px; + height: 20px; + display: inline-block; + padding: 6px; + border-radius: 8px; + cursor: pointer; + margin: 0 2px; +} + +.link-editor .button.hovered { + width: 20px; + height: 20px; + display: inline-block; + background-color: #eee; +} + +.link-editor .button i, +.actions i { + background-size: contain; + display: inline-block; + height: 20px; + width: 20px; + vertical-align: -0.25em; +} diff --git a/packages/blocks-editor/src/Lexical/Plugins/FloatingLinkEditorPlugin/index.tsx b/packages/blocks-editor/src/Lexical/Plugins/FloatingLinkEditorPlugin/index.tsx new file mode 100644 index 000000000..58dd23eec --- /dev/null +++ b/packages/blocks-editor/src/Lexical/Plugins/FloatingLinkEditorPlugin/index.tsx @@ -0,0 +1,258 @@ +/** + * 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. + * + */ +import './index.css'; + +import {$isAutoLinkNode, $isLinkNode, TOGGLE_LINK_COMMAND} from '@lexical/link'; +import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; +import {$findMatchingParent, mergeRegister} from '@lexical/utils'; +import { + $getSelection, + $isRangeSelection, + COMMAND_PRIORITY_CRITICAL, + COMMAND_PRIORITY_LOW, + GridSelection, + LexicalEditor, + NodeSelection, + RangeSelection, + SELECTION_CHANGE_COMMAND, +} from 'lexical'; +import {useCallback, useEffect, useRef, useState} from 'react'; +import {createPortal} from 'react-dom'; + +import LinkPreview from '../../UI/LinkPreview'; +import {getSelectedNode} from '../../Utils/getSelectedNode'; +import {sanitizeUrl} from '../../Utils/sanitizeUrl'; +import {setFloatingElemPosition} from '../../Utils/setFloatingElemPosition'; + +function FloatingLinkEditor({ + editor, + anchorElem, +}: { + editor: LexicalEditor; + anchorElem: HTMLElement; +}): JSX.Element { + const editorRef = useRef(null); + const inputRef = useRef(null); + const [linkUrl, setLinkUrl] = useState(''); + const [isEditMode, setEditMode] = useState(false); + const [lastSelection, setLastSelection] = useState< + RangeSelection | GridSelection | NodeSelection | null + >(null); + + const updateLinkEditor = useCallback(() => { + const selection = $getSelection(); + if ($isRangeSelection(selection)) { + const node = getSelectedNode(selection); + const parent = node.getParent(); + if ($isLinkNode(parent)) { + setLinkUrl(parent.getURL()); + } else if ($isLinkNode(node)) { + setLinkUrl(node.getURL()); + } else { + setLinkUrl(''); + } + } + const editorElem = editorRef.current; + const nativeSelection = window.getSelection(); + const activeElement = document.activeElement; + + if (editorElem === null) { + return; + } + + const rootElement = editor.getRootElement(); + + if ( + selection !== null && + nativeSelection !== null && + rootElement !== null && + rootElement.contains(nativeSelection.anchorNode) + ) { + const domRange = nativeSelection.getRangeAt(0); + let rect; + if (nativeSelection.anchorNode === rootElement) { + let inner = rootElement; + while (inner.firstElementChild != null) { + inner = inner.firstElementChild as HTMLElement; + } + rect = inner.getBoundingClientRect(); + } else { + rect = domRange.getBoundingClientRect(); + } + + setFloatingElemPosition(rect, editorElem, anchorElem); + setLastSelection(selection); + } else if (!activeElement || activeElement.className !== 'link-input') { + if (rootElement !== null) { + setFloatingElemPosition(null, editorElem, anchorElem); + } + setLastSelection(null); + setEditMode(false); + setLinkUrl(''); + } + + return true; + }, [anchorElem, editor]); + + useEffect(() => { + const scrollerElem = anchorElem.parentElement; + + const update = () => { + editor.getEditorState().read(() => { + updateLinkEditor(); + }); + }; + + window.addEventListener('resize', update); + + if (scrollerElem) { + scrollerElem.addEventListener('scroll', update); + } + + return () => { + window.removeEventListener('resize', update); + + if (scrollerElem) { + scrollerElem.removeEventListener('scroll', update); + } + }; + }, [anchorElem.parentElement, editor, updateLinkEditor]); + + useEffect(() => { + return mergeRegister( + editor.registerUpdateListener(({editorState}) => { + editorState.read(() => { + updateLinkEditor(); + }); + }), + + editor.registerCommand( + SELECTION_CHANGE_COMMAND, + () => { + updateLinkEditor(); + return true; + }, + COMMAND_PRIORITY_LOW, + ), + ); + }, [editor, updateLinkEditor]); + + useEffect(() => { + editor.getEditorState().read(() => { + updateLinkEditor(); + }); + }, [editor, updateLinkEditor]); + + useEffect(() => { + if (isEditMode && inputRef.current) { + inputRef.current.focus(); + } + }, [isEditMode]); + + return ( +
+ {isEditMode ? ( + { + setLinkUrl(event.target.value); + }} + onKeyDown={(event) => { + if (event.key === 'Enter') { + event.preventDefault(); + if (lastSelection !== null) { + if (linkUrl !== '') { + editor.dispatchCommand( + TOGGLE_LINK_COMMAND, + sanitizeUrl(linkUrl), + ); + } + setEditMode(false); + } + } else if (event.key === 'Escape') { + event.preventDefault(); + setEditMode(false); + } + }} + /> + ) : ( + <> +
+ + {linkUrl} + +
event.preventDefault()} + onClick={() => { + setEditMode(true); + }} + /> +
+ + + )} +
+ ); +} + +function useFloatingLinkEditorToolbar( + editor: LexicalEditor, + anchorElem: HTMLElement, +): JSX.Element | null { + const [activeEditor, setActiveEditor] = useState(editor); + const [isLink, setIsLink] = useState(false); + + const updateToolbar = useCallback(() => { + const selection = $getSelection(); + if ($isRangeSelection(selection)) { + const node = getSelectedNode(selection); + const linkParent = $findMatchingParent(node, $isLinkNode); + const autoLinkParent = $findMatchingParent(node, $isAutoLinkNode); + + // We don't want this menu to open for auto links. + if (linkParent != null && autoLinkParent == null) { + setIsLink(true); + } else { + setIsLink(false); + } + } + }, []); + + useEffect(() => { + return editor.registerCommand( + SELECTION_CHANGE_COMMAND, + (_payload, newEditor) => { + updateToolbar(); + setActiveEditor(newEditor); + return false; + }, + COMMAND_PRIORITY_CRITICAL, + ); + }, [editor, updateToolbar]); + + return isLink + ? createPortal( + , + anchorElem, + ) + : null; +} + +export default function FloatingLinkEditorPlugin({ + anchorElem = document.body, +}: { + anchorElem?: HTMLElement; +}): JSX.Element | null { + const [editor] = useLexicalComposerContext(); + return useFloatingLinkEditorToolbar(editor, anchorElem); +} diff --git a/packages/blocks-editor/src/Lexical/Plugins/FloatingTextFormatToolbarPlugin/index.css b/packages/blocks-editor/src/Lexical/Plugins/FloatingTextFormatToolbarPlugin/index.css new file mode 100644 index 000000000..8a2a0cac0 --- /dev/null +++ b/packages/blocks-editor/src/Lexical/Plugins/FloatingTextFormatToolbarPlugin/index.css @@ -0,0 +1,129 @@ +.floating-text-format-popup { + display: flex; + vertical-align: middle; + position: absolute; + top: 0; + left: 0; + z-index: 10; + opacity: 0; + background-color: var(--sn-stylekit-contrast-background-color); + color: var(--sn-stylekit-contrast-color); + box-shadow: 0px 5px 10px rgba(0, 0, 0, 0.3); + border-radius: 8px; + transition: opacity 0.5s; + height: 35px; + will-change: transform; +} + +.floating-text-format-popup button.popup-item { + border: 0; + display: flex; + background: none; + border-radius: 10px; + padding: 8px; + cursor: pointer; + vertical-align: middle; +} + +.floating-text-format-popup button.popup-item:disabled { + cursor: not-allowed; +} + +.floating-text-format-popup button.popup-item.spaced { + margin-right: 2px; +} + +.floating-text-format-popup button.popup-item i.format { + background-size: contain; + display: inline-block; + height: 18px; + width: 18px; + margin-top: 2px; + vertical-align: -0.25em; + display: flex; + opacity: 0.6; +} + +.floating-text-format-popup button.popup-item:disabled i.format { + opacity: 0.2; +} + +.floating-text-format-popup button.popup-item.active { + background-color: rgba(223, 232, 250, 0.3); +} + +.floating-text-format-popup button.popup-item.active i { + opacity: 1; +} + +.floating-text-format-popup .popup-item:hover:not([disabled]) { + background-color: #eee; +} + +.floating-text-format-popup select.popup-item { + border: 0; + display: flex; + background: none; + border-radius: 10px; + padding: 8px; + vertical-align: middle; + -webkit-appearance: none; + -moz-appearance: none; + width: 70px; + font-size: 14px; + color: #777; + text-overflow: ellipsis; +} + +.floating-text-format-popup select.code-language { + text-transform: capitalize; + width: 130px; +} + +.floating-text-format-popup .popup-item .text { + display: flex; + line-height: 20px; + width: 200px; + vertical-align: middle; + font-size: 14px; + color: #777; + text-overflow: ellipsis; + width: 70px; + overflow: hidden; + height: 20px; + text-align: left; +} + +.floating-text-format-popup .popup-item .icon { + display: flex; + width: 20px; + height: 20px; + user-select: none; + margin-right: 8px; + line-height: 16px; + background-size: contain; +} + +.floating-text-format-popup i.chevron-down { + margin-top: 3px; + width: 16px; + height: 16px; + display: flex; + user-select: none; +} + +.floating-text-format-popup i.chevron-down.inside { + width: 16px; + height: 16px; + display: flex; + margin-left: -25px; + margin-top: 11px; + margin-right: 10px; + pointer-events: none; +} + +.floating-text-format-popup .divider { + width: 1px; + background-color: #eee; + margin: 0 4px; +} diff --git a/packages/blocks-editor/src/Lexical/Plugins/FloatingTextFormatToolbarPlugin/index.tsx b/packages/blocks-editor/src/Lexical/Plugins/FloatingTextFormatToolbarPlugin/index.tsx new file mode 100644 index 000000000..d2f462ef7 --- /dev/null +++ b/packages/blocks-editor/src/Lexical/Plugins/FloatingTextFormatToolbarPlugin/index.tsx @@ -0,0 +1,332 @@ +/** + * 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. + * + */ + +import './index.css'; + +import {$isCodeHighlightNode} from '@lexical/code'; +import {$isLinkNode, TOGGLE_LINK_COMMAND} from '@lexical/link'; +import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; +import {mergeRegister} from '@lexical/utils'; +import { + $getSelection, + $isRangeSelection, + $isTextNode, + COMMAND_PRIORITY_LOW, + FORMAT_TEXT_COMMAND, + LexicalEditor, + SELECTION_CHANGE_COMMAND, +} from 'lexical'; +import {useCallback, useEffect, useRef, useState} from 'react'; +import {createPortal} from 'react-dom'; + +import {getDOMRangeRect} from '../../Utils/getDOMRangeRect'; +import {getSelectedNode} from '../../Utils/getSelectedNode'; +import {setFloatingElemPosition} from '../../Utils/setFloatingElemPosition'; +import { + TypeItalic, + TypeStrikethrough, + TypeSubscript, + TypeSuperscript, + TypeUnderline, + TypeBold, + LexicalCode, + LexicalLink, +} from '@standardnotes/icons'; + +function TextFormatFloatingToolbar({ + editor, + anchorElem, + isLink, + isBold, + isItalic, + isUnderline, + isCode, + isStrikethrough, + isSubscript, + isSuperscript, +}: { + editor: LexicalEditor; + anchorElem: HTMLElement; + isBold: boolean; + isCode: boolean; + isItalic: boolean; + isLink: boolean; + isStrikethrough: boolean; + isSubscript: boolean; + isSuperscript: boolean; + isUnderline: boolean; +}): JSX.Element { + const popupCharStylesEditorRef = useRef(null); + + const insertLink = useCallback(() => { + if (!isLink) { + editor.dispatchCommand(TOGGLE_LINK_COMMAND, 'https://'); + } else { + editor.dispatchCommand(TOGGLE_LINK_COMMAND, null); + } + }, [editor, isLink]); + + const updateTextFormatFloatingToolbar = useCallback(() => { + const selection = $getSelection(); + + const popupCharStylesEditorElem = popupCharStylesEditorRef.current; + const nativeSelection = window.getSelection(); + + if (popupCharStylesEditorElem === null) { + return; + } + + const rootElement = editor.getRootElement(); + if ( + selection !== null && + nativeSelection !== null && + !nativeSelection.isCollapsed && + rootElement !== null && + rootElement.contains(nativeSelection.anchorNode) + ) { + const rangeRect = getDOMRangeRect(nativeSelection, rootElement); + + setFloatingElemPosition(rangeRect, popupCharStylesEditorElem, anchorElem); + } + }, [editor, anchorElem]); + + useEffect(() => { + const scrollerElem = anchorElem.parentElement; + + const update = () => { + editor.getEditorState().read(() => { + updateTextFormatFloatingToolbar(); + }); + }; + + window.addEventListener('resize', update); + if (scrollerElem) { + scrollerElem.addEventListener('scroll', update); + } + + return () => { + window.removeEventListener('resize', update); + if (scrollerElem) { + scrollerElem.removeEventListener('scroll', update); + } + }; + }, [editor, updateTextFormatFloatingToolbar, anchorElem]); + + useEffect(() => { + editor.getEditorState().read(() => { + updateTextFormatFloatingToolbar(); + }); + return mergeRegister( + editor.registerUpdateListener(({editorState}) => { + editorState.read(() => { + updateTextFormatFloatingToolbar(); + }); + }), + + editor.registerCommand( + SELECTION_CHANGE_COMMAND, + () => { + updateTextFormatFloatingToolbar(); + return false; + }, + COMMAND_PRIORITY_LOW, + ), + ); + }, [editor, updateTextFormatFloatingToolbar]); + + return ( +
+ {editor.isEditable() && ( + <> + + + + + + + + + + )} +
+ ); +} + +function useFloatingTextFormatToolbar( + editor: LexicalEditor, + anchorElem: HTMLElement, +): JSX.Element | null { + const [isText, setIsText] = useState(false); + const [isLink, setIsLink] = useState(false); + const [isBold, setIsBold] = useState(false); + const [isItalic, setIsItalic] = useState(false); + const [isUnderline, setIsUnderline] = useState(false); + const [isStrikethrough, setIsStrikethrough] = useState(false); + const [isSubscript, setIsSubscript] = useState(false); + const [isSuperscript, setIsSuperscript] = useState(false); + const [isCode, setIsCode] = useState(false); + + const updatePopup = useCallback(() => { + editor.getEditorState().read(() => { + // Should not to pop up the floating toolbar when using IME input + if (editor.isComposing()) { + return; + } + const selection = $getSelection(); + const nativeSelection = window.getSelection(); + const rootElement = editor.getRootElement(); + + if ( + nativeSelection !== null && + (!$isRangeSelection(selection) || + rootElement === null || + !rootElement.contains(nativeSelection.anchorNode)) + ) { + setIsText(false); + return; + } + + if (!$isRangeSelection(selection)) { + return; + } + + const node = getSelectedNode(selection); + + // Update text format + setIsBold(selection.hasFormat('bold')); + setIsItalic(selection.hasFormat('italic')); + setIsUnderline(selection.hasFormat('underline')); + setIsStrikethrough(selection.hasFormat('strikethrough')); + setIsSubscript(selection.hasFormat('subscript')); + setIsSuperscript(selection.hasFormat('superscript')); + setIsCode(selection.hasFormat('code')); + + // 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); + } + }); + }, [editor]); + + useEffect(() => { + document.addEventListener('selectionchange', updatePopup); + return () => { + document.removeEventListener('selectionchange', updatePopup); + }; + }, [updatePopup]); + + useEffect(() => { + return mergeRegister( + editor.registerUpdateListener(() => { + updatePopup(); + }), + editor.registerRootListener(() => { + if (editor.getRootElement() === null) { + setIsText(false); + } + }), + ); + }, [editor, updatePopup]); + + if (!isText || isLink) { + return null; + } + + return createPortal( + , + anchorElem, + ); +} + +export default function FloatingTextFormatToolbarPlugin({ + anchorElem = document.body, +}: { + anchorElem?: HTMLElement; +}): JSX.Element | null { + const [editor] = useLexicalComposerContext(); + return useFloatingTextFormatToolbar(editor, anchorElem); +} diff --git a/packages/blocks-editor/src/Lexical/Theme/base.scss b/packages/blocks-editor/src/Lexical/Theme/base.scss index e32a37efd..d56d232f9 100644 --- a/packages/blocks-editor/src/Lexical/Theme/base.scss +++ b/packages/blocks-editor/src/Lexical/Theme/base.scss @@ -1219,7 +1219,7 @@ body { content: ''; display: block; height: 2px; - background-color: #ccc; + background-color: var(--sn-stylekit-contrast-border-color); line-height: 2px; } diff --git a/packages/blocks-editor/src/Lexical/Theme/editor.scss b/packages/blocks-editor/src/Lexical/Theme/editor.scss index e0c065bac..adf6635f3 100644 --- a/packages/blocks-editor/src/Lexical/Theme/editor.scss +++ b/packages/blocks-editor/src/Lexical/Theme/editor.scss @@ -61,10 +61,12 @@ vertical-align: super; } .Lexical__textCode { - background-color: rgb(240, 242, 245); - padding: 1px 0.25rem; + background-color: var(--sn-stylekit-secondary-background-color); + color: var(--sn-stylekit-info-color); + padding: 5px; + border-radius: 5px; font-family: Menlo, Consolas, Monaco, monospace; - font-size: 94%; + font-size: 85%; } .Lexical__hashtag { background-color: rgba(88, 144, 255, 0.15); @@ -78,7 +80,7 @@ text-decoration: underline; } .Lexical__code { - background-color: rgb(240, 242, 245); + background-color: var(--sn-stylekit-contrast-background-color); font-family: Menlo, Consolas, Monaco, monospace; display: block; padding: 8px 8px 8px 52px; @@ -95,12 +97,12 @@ .Lexical__code:before { content: attr(data-gutter); position: absolute; - background-color: #eee; + background-color: var(--sn-stylekit-secondary-background-color); left: 0; top: 0; - border-right: 1px solid #ccc; + border-right: 1px solid var(--sn-stylekit-contrast-border-color); padding: 8px; - color: #777; + color: var(--sn-stylekit-info-color); white-space: pre-wrap; text-align: right; min-width: 25px; @@ -113,12 +115,13 @@ table-layout: fixed; width: calc(100% - 25px); margin: 30px 0; + color: var(--sn-stylekit-contrast-foreground-color); } .Lexical__tableSelected { outline: 2px solid rgb(60, 132, 244); } .Lexical__tableCell { - border: 1px solid #bbb; + border: 1px solid var(--sn-stylekit-border-color); min-width: 75px; vertical-align: top; text-align: start; @@ -147,7 +150,8 @@ top: 0; } .Lexical__tableCellHeader { - background-color: #f2f3f5; + background-color: var(--sn-stylekit-contrast-background-color); + border-color: var(--sn-stylekit-contrast-border-color); text-align: start; } .Lexical__tableCellSelected { @@ -306,7 +310,7 @@ list-style-position: inside; } .Lexical__listItem { - margin: 0 32px; + margin: 0 0px; } .Lexical__listItemChecked, .Lexical__listItemUnchecked { @@ -317,6 +321,7 @@ padding-right: 24px; list-style-type: none; outline: none; + vertical-align: middle; } .Lexical__listItemChecked { text-decoration: line-through; @@ -326,10 +331,9 @@ content: ''; width: 16px; height: 16px; - top: 5px; left: 0; + top: 5px; cursor: pointer; - display: block; background-size: cover; position: absolute; } diff --git a/packages/blocks-editor/src/Lexical/Theme/icons.scss b/packages/blocks-editor/src/Lexical/Theme/icons.scss index c1171f498..5513ddbe2 100644 --- a/packages/blocks-editor/src/Lexical/Theme/icons.scss +++ b/packages/blocks-editor/src/Lexical/Theme/icons.scss @@ -81,42 +81,10 @@ i.bucket { background-image: url(#{$blocks-editor-icons-path}/paint-bucket.svg); } -i.bold { - background-image: url(#{$blocks-editor-icons-path}/type-bold.svg); -} - -i.italic { - background-image: url(#{$blocks-editor-icons-path}/type-italic.svg); -} - i.clear { background-image: url(#{$blocks-editor-icons-path}/trash.svg); } -i.code { - background-image: url(#{$blocks-editor-icons-path}/code.svg); -} - -i.underline { - background-image: url(#{$blocks-editor-icons-path}/type-underline.svg); -} - -i.strikethrough { - background-image: url(#{$blocks-editor-icons-path}/type-strikethrough.svg); -} - -i.subscript { - background-image: url(#{$blocks-editor-icons-path}/type-subscript.svg); -} - -i.superscript { - background-image: url(#{$blocks-editor-icons-path}/type-superscript.svg); -} - -i.link { - background-image: url(#{$blocks-editor-icons-path}/link.svg); -} - i.horizontal-rule { background-image: url(#{$blocks-editor-icons-path}/horizontal-rule.svg); } diff --git a/packages/blocks-editor/src/Lexical/UI/LinkPreview.css b/packages/blocks-editor/src/Lexical/UI/LinkPreview.css new file mode 100644 index 000000000..4633a8ca2 --- /dev/null +++ b/packages/blocks-editor/src/Lexical/UI/LinkPreview.css @@ -0,0 +1,69 @@ +/** + * 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. + * + * + */ + +@keyframes glimmer-animation { + 0% { + background: #f9f9f9; + } + .50% { + background: #eeeeee; + } + .100% { + background: #f9f9f9; + } +} + +.LinkPreview__container { + padding-bottom: 12px; +} + +.LinkPreview__imageWrapper { + text-align: center; +} + +.LinkPreview__image { + max-width: 100%; + max-height: 250px; + margin: auto; +} + +.LinkPreview__title { + margin-left: 12px; + margin-right: 12px; + margin-top: 4px; +} + +.LinkPreview__description { + color: #999; + font-size: 90%; + margin-left: 12px; + margin-right: 12px; + margin-top: 4px; +} + +.LinkPreview__domain { + color: #999; + font-size: 90%; + margin-left: 12px; + margin-right: 12px; + margin-top: 4px; +} + +.LinkPreview__glimmer { + background: #f9f9f9; + border-radius: 8px; + height: 18px; + margin-bottom: 8px; + margin-left: 12px; + margin-right: 12px; + animation-duration: 3s; + animation-iteration-count: infinite; + animation-timing-function: linear; + animation-name: glimmer-animation; +} diff --git a/packages/blocks-editor/src/Lexical/UI/LinkPreview.tsx b/packages/blocks-editor/src/Lexical/UI/LinkPreview.tsx new file mode 100644 index 000000000..d52172283 --- /dev/null +++ b/packages/blocks-editor/src/Lexical/UI/LinkPreview.tsx @@ -0,0 +1,117 @@ +/** + * 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. + * + */ + +import './LinkPreview.css'; + +import {CSSProperties, Suspense} from 'react'; + +type Preview = { + title: string; + description: string; + img: string; + domain: string; +} | null; + +// Cached responses or running request promises +const PREVIEW_CACHE: Record | {preview: Preview}> = {}; + +const URL_MATCHER = + /((https?:\/\/(www\.)?)|(www\.))[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&//=]*)/; + +function useSuspenseRequest(url: string) { + let cached = PREVIEW_CACHE[url]; + + if (!url.match(URL_MATCHER)) { + return {preview: null}; + } + + if (!cached) { + cached = PREVIEW_CACHE[url] = fetch( + `/api/link-preview?url=${encodeURI(url)}`, + ) + .then((response) => response.json()) + .then((preview) => { + PREVIEW_CACHE[url] = preview; + return preview; + }) + .catch(() => { + PREVIEW_CACHE[url] = {preview: null}; + }); + } + + if (cached instanceof Promise) { + throw cached; + } + + return cached; +} + +function LinkPreviewContent({ + url, +}: Readonly<{ + url: string; +}>): JSX.Element | null { + const {preview} = useSuspenseRequest(url); + if (preview === null) { + return null; + } + return ( +
+ {preview.img && ( +
+ {preview.title} +
+ )} + {preview.domain && ( +
{preview.domain}
+ )} + {preview.title && ( +
{preview.title}
+ )} + {preview.description && ( +
{preview.description}
+ )} +
+ ); +} + +function Glimmer(props: {style: CSSProperties; index: number}): JSX.Element { + return ( +
+ ); +} + +export default function LinkPreview({ + url, +}: Readonly<{ + url: string; +}>): JSX.Element { + return ( + + + + + + }> + + + ); +} diff --git a/packages/blocks-editor/src/Lexical/Utils/getDOMRangeRect.ts b/packages/blocks-editor/src/Lexical/Utils/getDOMRangeRect.ts new file mode 100644 index 000000000..8b02ca3c9 --- /dev/null +++ b/packages/blocks-editor/src/Lexical/Utils/getDOMRangeRect.ts @@ -0,0 +1,27 @@ +/** + * 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. + * + */ +export function getDOMRangeRect( + nativeSelection: Selection, + rootElement: HTMLElement, +): DOMRect { + const domRange = nativeSelection.getRangeAt(0); + + let rect; + + if (nativeSelection.anchorNode === rootElement) { + let inner = rootElement; + while (inner.firstElementChild != null) { + inner = inner.firstElementChild as HTMLElement; + } + rect = inner.getBoundingClientRect(); + } else { + rect = domRange.getBoundingClientRect(); + } + + return rect; +} diff --git a/packages/blocks-editor/src/Lexical/Utils/getSelectedNode.ts b/packages/blocks-editor/src/Lexical/Utils/getSelectedNode.ts new file mode 100644 index 000000000..75101015b --- /dev/null +++ b/packages/blocks-editor/src/Lexical/Utils/getSelectedNode.ts @@ -0,0 +1,27 @@ +/** + * 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. + * + */ +import {$isAtNodeEnd} from '@lexical/selection'; +import {ElementNode, RangeSelection, TextNode} from 'lexical'; + +export function getSelectedNode( + selection: RangeSelection, +): TextNode | ElementNode { + const anchor = selection.anchor; + const focus = selection.focus; + const anchorNode = selection.anchor.getNode(); + const focusNode = selection.focus.getNode(); + if (anchorNode === focusNode) { + return anchorNode; + } + const isBackward = selection.isBackward(); + if (isBackward) { + return $isAtNodeEnd(focus) ? anchorNode : focusNode; + } else { + return $isAtNodeEnd(anchor) ? focusNode : anchorNode; + } +} diff --git a/packages/blocks-editor/src/Lexical/Utils/sanitizeUrl.ts b/packages/blocks-editor/src/Lexical/Utils/sanitizeUrl.ts new file mode 100644 index 000000000..ac6501aab --- /dev/null +++ b/packages/blocks-editor/src/Lexical/Utils/sanitizeUrl.ts @@ -0,0 +1,23 @@ +/** + * 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. + * + */ + +export const sanitizeUrl = (url: string): string => { + /** A pattern that matches safe URLs. */ + const SAFE_URL_PATTERN = + /^(?:(?:https?|mailto|ftp|tel|file|sms):|[^&:/?#]*(?:[/?#]|$))/gi; + + /** A pattern that matches safe data URLs. */ + const DATA_URL_PATTERN = + /^data:(?:image\/(?:bmp|gif|jpeg|jpg|png|tiff|webp)|video\/(?:mpeg|mp4|ogg|webm)|audio\/(?:mp3|oga|ogg|opus));base64,[a-z0-9+/]+=*$/i; + + url = String(url).trim(); + + if (url.match(SAFE_URL_PATTERN) || url.match(DATA_URL_PATTERN)) return url; + + return `https://`; +}; diff --git a/packages/blocks-editor/src/Lexical/Utils/setFloatingElemPosition.ts b/packages/blocks-editor/src/Lexical/Utils/setFloatingElemPosition.ts new file mode 100644 index 000000000..e1a5d1111 --- /dev/null +++ b/packages/blocks-editor/src/Lexical/Utils/setFloatingElemPosition.ts @@ -0,0 +1,46 @@ +/** + * 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. + * + */ +const VERTICAL_GAP = 10; +const HORIZONTAL_OFFSET = 5; + +export function setFloatingElemPosition( + targetRect: ClientRect | null, + floatingElem: HTMLElement, + anchorElem: HTMLElement, + verticalGap: number = VERTICAL_GAP, + horizontalOffset: number = HORIZONTAL_OFFSET, +): void { + const scrollerElem = anchorElem.parentElement; + + if (targetRect === null || !scrollerElem) { + floatingElem.style.opacity = '0'; + floatingElem.style.transform = 'translate(-10000px, -10000px)'; + return; + } + + const floatingElemRect = floatingElem.getBoundingClientRect(); + const anchorElementRect = anchorElem.getBoundingClientRect(); + const editorScrollerRect = scrollerElem.getBoundingClientRect(); + + let top = targetRect.top - floatingElemRect.height - verticalGap; + let left = targetRect.left - horizontalOffset; + + if (top < editorScrollerRect.top) { + top += floatingElemRect.height + targetRect.height + verticalGap * 2; + } + + if (left + floatingElemRect.width > editorScrollerRect.right) { + left = editorScrollerRect.right - floatingElemRect.width - horizontalOffset; + } + + top -= anchorElementRect.top; + left -= anchorElementRect.left; + + floatingElem.style.opacity = '1'; + floatingElem.style.transform = `translate(${left}px, ${top}px)`; +} diff --git a/packages/blocks-editor/src/index.ts b/packages/blocks-editor/src/index.ts index 7fedf67cc..2ff947f34 100644 --- a/packages/blocks-editor/src/index.ts +++ b/packages/blocks-editor/src/index.ts @@ -1,4 +1,2 @@ export * from './Editor/BlocksEditor'; export * from './Editor/BlocksEditorComposer'; -export * from './Editor/Commands'; -export * from './Editor/ClassNames'; diff --git a/packages/icons/src/Lexical/LICENSE.md b/packages/icons/src/Lexical/LICENSE.md new file mode 100644 index 000000000..ce74f6abe --- /dev/null +++ b/packages/icons/src/Lexical/LICENSE.md @@ -0,0 +1,5 @@ +Bootstrap Icons +https://icons.getbootstrap.com + +Licensed under MIT license +https://github.com/twbs/icons/blob/main/LICENSE.md diff --git a/packages/icons/src/Lexical/arrow-clockwise.svg b/packages/icons/src/Lexical/arrow-clockwise.svg new file mode 100644 index 000000000..80b3ad066 --- /dev/null +++ b/packages/icons/src/Lexical/arrow-clockwise.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/icons/src/Lexical/arrow-counterclockwise.svg b/packages/icons/src/Lexical/arrow-counterclockwise.svg new file mode 100644 index 000000000..46d3581d8 --- /dev/null +++ b/packages/icons/src/Lexical/arrow-counterclockwise.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/icons/src/Lexical/bg-color.svg b/packages/icons/src/Lexical/bg-color.svg new file mode 100644 index 000000000..ae08b2c1d --- /dev/null +++ b/packages/icons/src/Lexical/bg-color.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/icons/src/Lexical/camera.svg b/packages/icons/src/Lexical/camera.svg new file mode 100755 index 000000000..968ebf4eb --- /dev/null +++ b/packages/icons/src/Lexical/camera.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/icons/src/Lexical/card-checklist.svg b/packages/icons/src/Lexical/card-checklist.svg new file mode 100644 index 000000000..f81734be4 --- /dev/null +++ b/packages/icons/src/Lexical/card-checklist.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/icons/src/Lexical/caret-right-fill.svg b/packages/icons/src/Lexical/caret-right-fill.svg new file mode 100644 index 000000000..04c258e6d --- /dev/null +++ b/packages/icons/src/Lexical/caret-right-fill.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/icons/src/Lexical/chat-left-text.svg b/packages/icons/src/Lexical/chat-left-text.svg new file mode 100644 index 000000000..7c7acc239 --- /dev/null +++ b/packages/icons/src/Lexical/chat-left-text.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/icons/src/Lexical/chat-right-dots.svg b/packages/icons/src/Lexical/chat-right-dots.svg new file mode 100644 index 000000000..110925a12 --- /dev/null +++ b/packages/icons/src/Lexical/chat-right-dots.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/icons/src/Lexical/chat-right-text.svg b/packages/icons/src/Lexical/chat-right-text.svg new file mode 100644 index 000000000..08daa52bc --- /dev/null +++ b/packages/icons/src/Lexical/chat-right-text.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/icons/src/Lexical/chat-right.svg b/packages/icons/src/Lexical/chat-right.svg new file mode 100644 index 000000000..d9c2b110e --- /dev/null +++ b/packages/icons/src/Lexical/chat-right.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/icons/src/Lexical/chat-square-quote.svg b/packages/icons/src/Lexical/chat-square-quote.svg new file mode 100755 index 000000000..5501848a5 --- /dev/null +++ b/packages/icons/src/Lexical/chat-square-quote.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/icons/src/Lexical/chevron-down.svg b/packages/icons/src/Lexical/chevron-down.svg new file mode 100644 index 000000000..ef1a6ba3b --- /dev/null +++ b/packages/icons/src/Lexical/chevron-down.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/icons/src/Lexical/clipboard.svg b/packages/icons/src/Lexical/clipboard.svg new file mode 100755 index 000000000..f09e1a1c9 --- /dev/null +++ b/packages/icons/src/Lexical/clipboard.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/icons/src/Lexical/close.svg b/packages/icons/src/Lexical/close.svg new file mode 100644 index 000000000..4f5bb3938 --- /dev/null +++ b/packages/icons/src/Lexical/close.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/icons/src/Lexical/code.svg b/packages/icons/src/Lexical/code.svg new file mode 100755 index 000000000..c9070bf06 --- /dev/null +++ b/packages/icons/src/Lexical/code.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/icons/src/Lexical/comments.svg b/packages/icons/src/Lexical/comments.svg new file mode 100644 index 000000000..6a23ac546 --- /dev/null +++ b/packages/icons/src/Lexical/comments.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/icons/src/Lexical/copy.svg b/packages/icons/src/Lexical/copy.svg new file mode 100644 index 000000000..e757cdfe5 --- /dev/null +++ b/packages/icons/src/Lexical/copy.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/icons/src/Lexical/diagram-2.svg b/packages/icons/src/Lexical/diagram-2.svg new file mode 100644 index 000000000..7b7b696d0 --- /dev/null +++ b/packages/icons/src/Lexical/diagram-2.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/icons/src/Lexical/download.svg b/packages/icons/src/Lexical/download.svg new file mode 100755 index 000000000..cd27d96c1 --- /dev/null +++ b/packages/icons/src/Lexical/download.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/icons/src/Lexical/draggable-block-menu.svg b/packages/icons/src/Lexical/draggable-block-menu.svg new file mode 100644 index 000000000..7086d2990 --- /dev/null +++ b/packages/icons/src/Lexical/draggable-block-menu.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/icons/src/Lexical/dropdown-more.svg b/packages/icons/src/Lexical/dropdown-more.svg new file mode 100644 index 000000000..399ea8de5 --- /dev/null +++ b/packages/icons/src/Lexical/dropdown-more.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/icons/src/Lexical/figma.svg b/packages/icons/src/Lexical/figma.svg new file mode 100644 index 000000000..fa319e12b --- /dev/null +++ b/packages/icons/src/Lexical/figma.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/icons/src/Lexical/file-image.svg b/packages/icons/src/Lexical/file-image.svg new file mode 100644 index 000000000..73a9ff15f --- /dev/null +++ b/packages/icons/src/Lexical/file-image.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/icons/src/Lexical/filetype-gif.svg b/packages/icons/src/Lexical/filetype-gif.svg new file mode 100644 index 000000000..12acb80f3 --- /dev/null +++ b/packages/icons/src/Lexical/filetype-gif.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/icons/src/Lexical/font-color.svg b/packages/icons/src/Lexical/font-color.svg new file mode 100644 index 000000000..1ac53f7ac --- /dev/null +++ b/packages/icons/src/Lexical/font-color.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/icons/src/Lexical/font-family.svg b/packages/icons/src/Lexical/font-family.svg new file mode 100644 index 000000000..a13f5ad1e --- /dev/null +++ b/packages/icons/src/Lexical/font-family.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/icons/src/Lexical/gear.svg b/packages/icons/src/Lexical/gear.svg new file mode 100755 index 000000000..ee6efa044 --- /dev/null +++ b/packages/icons/src/Lexical/gear.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/icons/src/Lexical/horizontal-rule.svg b/packages/icons/src/Lexical/horizontal-rule.svg new file mode 100644 index 000000000..cb84970fb --- /dev/null +++ b/packages/icons/src/Lexical/horizontal-rule.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/icons/src/Lexical/indent.svg b/packages/icons/src/Lexical/indent.svg new file mode 100644 index 000000000..c9c5df7bf --- /dev/null +++ b/packages/icons/src/Lexical/indent.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/icons/src/Lexical/index.ts b/packages/icons/src/Lexical/index.ts new file mode 100644 index 000000000..f9c38fbcc --- /dev/null +++ b/packages/icons/src/Lexical/index.ts @@ -0,0 +1,37 @@ +import LexicalArrowClockwise from './arrow-clockwise.svg' +import LexicalArrowCounterClockwise from './arrow-counterclockwise.svg' +import LexicalCamera from './camera.svg' +import TypeItalic from './type-italic.svg' +import TypeStrikethrough from './type-strikethrough.svg' +import TypeSubscript from './type-subscript.svg' +import TypeSuperscript from './type-superscript.svg' +import TypeUnderline from './type-underline.svg' +import TypeBold from './type-bold.svg' +import TypeH1 from './type-h1.svg' +import TypeH2 from './type-h2.svg' +import TypeH3 from './type-h3.svg' +import TypeH4 from './type-h4.svg' +import TypeH5 from './type-h5.svg' +import TypeH6 from './type-h6.svg' +import LexicalCode from './code.svg' +import LexicalLink from './link.svg' + +export { + LexicalArrowClockwise, + LexicalArrowCounterClockwise, + LexicalCamera, + TypeItalic, + TypeStrikethrough, + TypeSubscript, + TypeSuperscript, + TypeUnderline, + TypeBold, + TypeH1, + TypeH2, + TypeH3, + TypeH4, + TypeH5, + TypeH6, + LexicalCode, + LexicalLink, +} diff --git a/packages/icons/src/Lexical/journal-code.svg b/packages/icons/src/Lexical/journal-code.svg new file mode 100755 index 000000000..9db6666a7 --- /dev/null +++ b/packages/icons/src/Lexical/journal-code.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/icons/src/Lexical/journal-text.svg b/packages/icons/src/Lexical/journal-text.svg new file mode 100755 index 000000000..9defed2c3 --- /dev/null +++ b/packages/icons/src/Lexical/journal-text.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/icons/src/Lexical/justify.svg b/packages/icons/src/Lexical/justify.svg new file mode 100644 index 000000000..6c5f8d0f7 --- /dev/null +++ b/packages/icons/src/Lexical/justify.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/icons/src/Lexical/link.svg b/packages/icons/src/Lexical/link.svg new file mode 100755 index 000000000..bc38ff5d4 --- /dev/null +++ b/packages/icons/src/Lexical/link.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/icons/src/Lexical/list-ol.svg b/packages/icons/src/Lexical/list-ol.svg new file mode 100755 index 000000000..ad288e8ea --- /dev/null +++ b/packages/icons/src/Lexical/list-ol.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/icons/src/Lexical/list-ul.svg b/packages/icons/src/Lexical/list-ul.svg new file mode 100755 index 000000000..6d7aae75d --- /dev/null +++ b/packages/icons/src/Lexical/list-ul.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/icons/src/Lexical/lock-fill.svg b/packages/icons/src/Lexical/lock-fill.svg new file mode 100644 index 000000000..466ca138f --- /dev/null +++ b/packages/icons/src/Lexical/lock-fill.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/icons/src/Lexical/lock.svg b/packages/icons/src/Lexical/lock.svg new file mode 100644 index 000000000..3e19e71b5 --- /dev/null +++ b/packages/icons/src/Lexical/lock.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/icons/src/Lexical/markdown.svg b/packages/icons/src/Lexical/markdown.svg new file mode 100644 index 000000000..310bff6d5 --- /dev/null +++ b/packages/icons/src/Lexical/markdown.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/icons/src/Lexical/mic.svg b/packages/icons/src/Lexical/mic.svg new file mode 100644 index 000000000..afdb58da9 --- /dev/null +++ b/packages/icons/src/Lexical/mic.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/icons/src/Lexical/outdent.svg b/packages/icons/src/Lexical/outdent.svg new file mode 100644 index 000000000..a98e0e192 --- /dev/null +++ b/packages/icons/src/Lexical/outdent.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/icons/src/Lexical/paint-bucket.svg b/packages/icons/src/Lexical/paint-bucket.svg new file mode 100644 index 000000000..baa02d3b3 --- /dev/null +++ b/packages/icons/src/Lexical/paint-bucket.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/icons/src/Lexical/palette.svg b/packages/icons/src/Lexical/palette.svg new file mode 100644 index 000000000..338222ec6 --- /dev/null +++ b/packages/icons/src/Lexical/palette.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/icons/src/Lexical/pencil-fill.svg b/packages/icons/src/Lexical/pencil-fill.svg new file mode 100755 index 000000000..eb01fb2a4 --- /dev/null +++ b/packages/icons/src/Lexical/pencil-fill.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/icons/src/Lexical/plug-fill.svg b/packages/icons/src/Lexical/plug-fill.svg new file mode 100644 index 000000000..3863ef840 --- /dev/null +++ b/packages/icons/src/Lexical/plug-fill.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/icons/src/Lexical/plug.svg b/packages/icons/src/Lexical/plug.svg new file mode 100644 index 000000000..de8d4c80b --- /dev/null +++ b/packages/icons/src/Lexical/plug.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/icons/src/Lexical/plus-slash-minus.svg b/packages/icons/src/Lexical/plus-slash-minus.svg new file mode 100644 index 000000000..40ff781e5 --- /dev/null +++ b/packages/icons/src/Lexical/plus-slash-minus.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/icons/src/Lexical/plus.svg b/packages/icons/src/Lexical/plus.svg new file mode 100644 index 000000000..1a26928a1 --- /dev/null +++ b/packages/icons/src/Lexical/plus.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/icons/src/Lexical/prettier-error.svg b/packages/icons/src/Lexical/prettier-error.svg new file mode 100644 index 000000000..8fc8450d0 --- /dev/null +++ b/packages/icons/src/Lexical/prettier-error.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/icons/src/Lexical/prettier.svg b/packages/icons/src/Lexical/prettier.svg new file mode 100644 index 000000000..b25a626c7 --- /dev/null +++ b/packages/icons/src/Lexical/prettier.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/icons/src/Lexical/send.svg b/packages/icons/src/Lexical/send.svg new file mode 100644 index 000000000..04e9f2983 --- /dev/null +++ b/packages/icons/src/Lexical/send.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/icons/src/Lexical/square-check.svg b/packages/icons/src/Lexical/square-check.svg new file mode 100644 index 000000000..352ba6158 --- /dev/null +++ b/packages/icons/src/Lexical/square-check.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/icons/src/Lexical/sticky.svg b/packages/icons/src/Lexical/sticky.svg new file mode 100644 index 000000000..2b14115cd --- /dev/null +++ b/packages/icons/src/Lexical/sticky.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/icons/src/Lexical/success.svg b/packages/icons/src/Lexical/success.svg new file mode 100644 index 000000000..8e11879e0 --- /dev/null +++ b/packages/icons/src/Lexical/success.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/icons/src/Lexical/table.svg b/packages/icons/src/Lexical/table.svg new file mode 100644 index 000000000..e514555c7 --- /dev/null +++ b/packages/icons/src/Lexical/table.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/icons/src/Lexical/text-center.svg b/packages/icons/src/Lexical/text-center.svg new file mode 100644 index 000000000..97ced49e6 --- /dev/null +++ b/packages/icons/src/Lexical/text-center.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/icons/src/Lexical/text-left.svg b/packages/icons/src/Lexical/text-left.svg new file mode 100644 index 000000000..5fe4cc445 --- /dev/null +++ b/packages/icons/src/Lexical/text-left.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/icons/src/Lexical/text-paragraph.svg b/packages/icons/src/Lexical/text-paragraph.svg new file mode 100755 index 000000000..1b943ab44 --- /dev/null +++ b/packages/icons/src/Lexical/text-paragraph.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/icons/src/Lexical/text-right.svg b/packages/icons/src/Lexical/text-right.svg new file mode 100644 index 000000000..de984517f --- /dev/null +++ b/packages/icons/src/Lexical/text-right.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/icons/src/Lexical/trash.svg b/packages/icons/src/Lexical/trash.svg new file mode 100644 index 000000000..75680bb7a --- /dev/null +++ b/packages/icons/src/Lexical/trash.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/icons/src/Lexical/trash3.svg b/packages/icons/src/Lexical/trash3.svg new file mode 100644 index 000000000..5c38b387e --- /dev/null +++ b/packages/icons/src/Lexical/trash3.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/icons/src/Lexical/tweet.svg b/packages/icons/src/Lexical/tweet.svg new file mode 100644 index 000000000..3304020e6 --- /dev/null +++ b/packages/icons/src/Lexical/tweet.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/icons/src/Lexical/type-bold.svg b/packages/icons/src/Lexical/type-bold.svg new file mode 100755 index 000000000..ec0dc2ec0 --- /dev/null +++ b/packages/icons/src/Lexical/type-bold.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/icons/src/Lexical/type-h1.svg b/packages/icons/src/Lexical/type-h1.svg new file mode 100755 index 000000000..379da930d --- /dev/null +++ b/packages/icons/src/Lexical/type-h1.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/icons/src/Lexical/type-h2.svg b/packages/icons/src/Lexical/type-h2.svg new file mode 100755 index 000000000..e724a0be3 --- /dev/null +++ b/packages/icons/src/Lexical/type-h2.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/icons/src/Lexical/type-h3.svg b/packages/icons/src/Lexical/type-h3.svg new file mode 100755 index 000000000..02d4a06c5 --- /dev/null +++ b/packages/icons/src/Lexical/type-h3.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/icons/src/Lexical/type-h4.svg b/packages/icons/src/Lexical/type-h4.svg new file mode 100755 index 000000000..eb950c9ed --- /dev/null +++ b/packages/icons/src/Lexical/type-h4.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/icons/src/Lexical/type-h5.svg b/packages/icons/src/Lexical/type-h5.svg new file mode 100755 index 000000000..5d565639c --- /dev/null +++ b/packages/icons/src/Lexical/type-h5.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/icons/src/Lexical/type-h6.svg b/packages/icons/src/Lexical/type-h6.svg new file mode 100755 index 000000000..8274acacd --- /dev/null +++ b/packages/icons/src/Lexical/type-h6.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/icons/src/Lexical/type-italic.svg b/packages/icons/src/Lexical/type-italic.svg new file mode 100755 index 000000000..ac139f3cc --- /dev/null +++ b/packages/icons/src/Lexical/type-italic.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/icons/src/Lexical/type-strikethrough.svg b/packages/icons/src/Lexical/type-strikethrough.svg new file mode 100755 index 000000000..a0d7e17e2 --- /dev/null +++ b/packages/icons/src/Lexical/type-strikethrough.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/icons/src/Lexical/type-subscript.svg b/packages/icons/src/Lexical/type-subscript.svg new file mode 100644 index 000000000..f6ebe4b6f --- /dev/null +++ b/packages/icons/src/Lexical/type-subscript.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/icons/src/Lexical/type-superscript.svg b/packages/icons/src/Lexical/type-superscript.svg new file mode 100644 index 000000000..bed98f9d8 --- /dev/null +++ b/packages/icons/src/Lexical/type-superscript.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/icons/src/Lexical/type-underline.svg b/packages/icons/src/Lexical/type-underline.svg new file mode 100755 index 000000000..d5c7046ee --- /dev/null +++ b/packages/icons/src/Lexical/type-underline.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/icons/src/Lexical/upload.svg b/packages/icons/src/Lexical/upload.svg new file mode 100644 index 000000000..81328ddbc --- /dev/null +++ b/packages/icons/src/Lexical/upload.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/icons/src/Lexical/user.svg b/packages/icons/src/Lexical/user.svg new file mode 100644 index 000000000..823b72d1e --- /dev/null +++ b/packages/icons/src/Lexical/user.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/icons/src/Lexical/youtube.svg b/packages/icons/src/Lexical/youtube.svg new file mode 100644 index 000000000..e7fb9faab --- /dev/null +++ b/packages/icons/src/Lexical/youtube.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/icons/src/index.ts b/packages/icons/src/index.ts index 6af6916a1..3f323c26f 100644 --- a/packages/icons/src/index.ts +++ b/packages/icons/src/index.ts @@ -1 +1,2 @@ export * from './Icons' +export * from './Lexical' diff --git a/packages/web/src/javascripts/Components/BlockEditor/BlockEditorComponent.tsx b/packages/web/src/javascripts/Components/BlockEditor/BlockEditorComponent.tsx index 5ea3790c2..367f6d037 100644 --- a/packages/web/src/javascripts/Components/BlockEditor/BlockEditorComponent.tsx +++ b/packages/web/src/javascripts/Components/BlockEditor/BlockEditorComponent.tsx @@ -8,6 +8,11 @@ import { FileNode } from './Plugins/EncryptedFilePlugin/Nodes/FileNode' import FilePlugin from './Plugins/EncryptedFilePlugin/FilePlugin' import BlockPickerMenuPlugin from './Plugins/BlockPickerPlugin/BlockPickerPlugin' import { ErrorBoundary } from '@/Utils/ErrorBoundary' +import { LinkingController } from '@/Controllers/LinkingController' +import LinkingControllerProvider from './Contexts/LinkingControllerProvider' +import { BubbleNode } from './Plugins/ItemBubblePlugin/Nodes/BubbleNode' +import ItemBubblePlugin from './Plugins/ItemBubblePlugin/ItemBubblePlugin' +import { NodeObserverPlugin } from './Plugins/NodeObserverPlugin/NodeObserverPlugin' const StringEllipses = '...' const NotePreviewCharLimit = 160 @@ -15,9 +20,10 @@ const NotePreviewCharLimit = 160 type Props = { application: WebApplication note: SNNote + linkingController: LinkingController } -export const BlockEditor: FunctionComponent = ({ note, application }) => { +export const BlockEditor: FunctionComponent = ({ note, application, linkingController }) => { const controller = useRef(new BlockEditorController(note, application)) const handleChange = useCallback( @@ -31,19 +37,34 @@ export const BlockEditor: FunctionComponent = ({ note, application }) => [controller], ) + const handleBubbleRemove = useCallback( + (itemUuid: string) => { + const item = application.items.findItem(itemUuid) + if (item) { + linkingController.unlinkItemFromSelectedItem(item).catch(console.error) + } + }, + [linkingController, application], + ) + return (
- - - - - - - + + + + + + + + + + + +
) diff --git a/packages/web/src/javascripts/Components/BlockEditor/Contexts/LinkingControllerProvider.tsx b/packages/web/src/javascripts/Components/BlockEditor/Contexts/LinkingControllerProvider.tsx new file mode 100644 index 000000000..8ee1f4222 --- /dev/null +++ b/packages/web/src/javascripts/Components/BlockEditor/Contexts/LinkingControllerProvider.tsx @@ -0,0 +1,36 @@ +import { ReactNode, createContext, useContext, memo } from 'react' + +import { observer } from 'mobx-react-lite' +import { LinkingController } from '@/Controllers/LinkingController' + +const LinkingControllerContext = createContext(undefined) + +export const useLinkingController = () => { + const value = useContext(LinkingControllerContext) + + if (!value) { + throw new Error('Component must be a child of ') + } + + return value +} + +type ChildrenProps = { + children: ReactNode +} + +type ProviderProps = { + controller: LinkingController +} & ChildrenProps + +const MemoizedChildren = memo(({ children }: ChildrenProps) => <>{children}) + +const LinkingControllerProvider = ({ controller, children }: ProviderProps) => { + return ( + + + + ) +} + +export default observer(LinkingControllerProvider) diff --git a/packages/web/src/javascripts/Components/BlockEditor/Plugins/BlockPickerPlugin/BlockPickerMenuItem.tsx b/packages/web/src/javascripts/Components/BlockEditor/Plugins/BlockPickerPlugin/BlockPickerMenuItem.tsx index 4b7a92f6e..3205226df 100644 --- a/packages/web/src/javascripts/Components/BlockEditor/Plugins/BlockPickerPlugin/BlockPickerMenuItem.tsx +++ b/packages/web/src/javascripts/Components/BlockEditor/Plugins/BlockPickerPlugin/BlockPickerMenuItem.tsx @@ -1,4 +1,4 @@ -import { PopoverItemClassNames, PopoverItemSelectedClassNames } from '@standardnotes/blocks-editor' +import { PopoverItemClassNames, PopoverItemSelectedClassNames } from '../ClassNames' import { BlockPickerOption } from './BlockPickerOption' export function BlockPickerMenuItem({ diff --git a/packages/web/src/javascripts/Components/BlockEditor/Plugins/BlockPickerPlugin/BlockPickerPlugin.tsx b/packages/web/src/javascripts/Components/BlockEditor/Plugins/BlockPickerPlugin/BlockPickerPlugin.tsx index 26997700c..aaab09bf6 100644 --- a/packages/web/src/javascripts/Components/BlockEditor/Plugins/BlockPickerPlugin/BlockPickerPlugin.tsx +++ b/packages/web/src/javascripts/Components/BlockEditor/Plugins/BlockPickerPlugin/BlockPickerPlugin.tsx @@ -2,7 +2,6 @@ import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext import { LexicalTypeaheadMenuPlugin, useBasicTypeaheadTriggerMatch } from '@lexical/react/LexicalTypeaheadMenuPlugin' import { TextNode } from 'lexical' import { useCallback, useMemo, useState } from 'react' -import { PopoverClassNames } from '@standardnotes/blocks-editor' import useModal from '@standardnotes/blocks-editor/src/Lexical/Hooks/useModal' import { InsertTableDialog } from '@standardnotes/blocks-editor/src/Lexical/Plugins/TablePlugin' import { BlockPickerOption } from './BlockPickerOption' @@ -20,6 +19,7 @@ import { GetCodeBlock } from './Blocks/Code' import { GetEmbedsBlocks } from './Blocks/Embeds' import { GetDynamicTableBlocks, GetTableBlock } from './Blocks/Table' import Popover from '@/Components/Popover/Popover' +import { PopoverClassNames } from '../ClassNames' export default function BlockPickerMenuPlugin(): JSX.Element { const [editor] = useLexicalComposerContext() @@ -109,7 +109,6 @@ export default function BlockPickerMenuPlugin(): JSX.Element { x: anchorElementRef.current.offsetLeft, y: anchorElementRef.current.offsetTop + anchorElementRef.current.offsetHeight, }} - className={'min-h-80 h-80'} open={popoverOpen} togglePopover={() => { setPopoverOpen((prevValue) => !prevValue) diff --git a/packages/blocks-editor/src/Editor/ClassNames.ts b/packages/web/src/javascripts/Components/BlockEditor/Plugins/ClassNames.ts similarity index 52% rename from packages/blocks-editor/src/Editor/ClassNames.ts rename to packages/web/src/javascripts/Components/BlockEditor/Plugins/ClassNames.ts index d1668161b..27aa6915c 100644 --- a/packages/blocks-editor/src/Editor/ClassNames.ts +++ b/packages/web/src/javascripts/Components/BlockEditor/Plugins/ClassNames.ts @@ -1,19 +1,13 @@ -const classNames = (...values: (string | boolean | undefined)[]): string => { - return values - .map((value) => (typeof value === 'string' ? value : null)) - .join(' '); -}; +import { classNames } from '@/Utils/ConcatenateClassNames' export const PopoverClassNames = classNames( - 'typeahead-popover file-picker-menu absolute z-dropdown-menu flex w-full min-w-80', + 'z-dropdown-menu w-full min-w-80', 'cursor-auto flex-col overflow-y-auto rounded bg-default md:h-auto md:max-w-xs h-auto overflow-y-scroll', -); +) export const PopoverItemClassNames = classNames( 'flex w-full items-center text-base gap-4 overflow-hidden py-2 px-3 hover:bg-contrast hover:text-foreground', 'focus:bg-info-backdrop cursor-pointer m-0 focus:bg-contrast focus:text-foreground', -); +) -export const PopoverItemSelectedClassNames = classNames( - 'bg-contrast text-foreground', -); +export const PopoverItemSelectedClassNames = classNames('bg-contrast text-foreground') diff --git a/packages/web/src/javascripts/Components/BlockEditor/Plugins/Commands.ts b/packages/web/src/javascripts/Components/BlockEditor/Plugins/Commands.ts new file mode 100644 index 000000000..689bfed85 --- /dev/null +++ b/packages/web/src/javascripts/Components/BlockEditor/Plugins/Commands.ts @@ -0,0 +1,4 @@ +import { createCommand, LexicalCommand } from 'lexical' + +export const INSERT_FILE_COMMAND: LexicalCommand = createCommand('INSERT_FILE_COMMAND') +export const INSERT_BUBBLE_COMMAND: LexicalCommand = createCommand('INSERT_BUBBLE_COMMAND') diff --git a/packages/web/src/javascripts/Components/BlockEditor/Plugins/EncryptedFilePlugin/FilePlugin.ts b/packages/web/src/javascripts/Components/BlockEditor/Plugins/EncryptedFilePlugin/FilePlugin.ts index f293c1497..60ce6e77d 100644 --- a/packages/web/src/javascripts/Components/BlockEditor/Plugins/EncryptedFilePlugin/FilePlugin.ts +++ b/packages/web/src/javascripts/Components/BlockEditor/Plugins/EncryptedFilePlugin/FilePlugin.ts @@ -1,10 +1,11 @@ +import { INSERT_FILE_COMMAND } from './../Commands' import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext' -import { $insertNodeToNearestRoot } from '@lexical/utils' -import { COMMAND_PRIORITY_EDITOR } from 'lexical' + import { useEffect } from 'react' import { FileNode } from './Nodes/FileNode' +import { $createParagraphNode, $insertNodes, $isRootOrShadowRoot, COMMAND_PRIORITY_EDITOR } from 'lexical' import { $createFileNode } from './Nodes/FileUtils' -import { INSERT_FILE_COMMAND } from '@standardnotes/blocks-editor' +import { $wrapNodeInElement } from '@lexical/utils' export default function FilePlugin(): JSX.Element | null { const [editor] = useLexicalComposerContext() @@ -18,7 +19,11 @@ export default function FilePlugin(): JSX.Element | null { INSERT_FILE_COMMAND, (payload) => { const fileNode = $createFileNode(payload) - $insertNodeToNearestRoot(fileNode) + // $insertNodeToNearestRoot(fileNode) + $insertNodes([fileNode]) + if ($isRootOrShadowRoot(fileNode.getParentOrThrow())) { + $wrapNodeInElement(fileNode, $createParagraphNode).selectEnd() + } return true }, diff --git a/packages/web/src/javascripts/Components/BlockEditor/Plugins/EncryptedFilePlugin/Nodes/FileNode.tsx b/packages/web/src/javascripts/Components/BlockEditor/Plugins/EncryptedFilePlugin/Nodes/FileNode.tsx index ee08b737c..7de15e462 100644 --- a/packages/web/src/javascripts/Components/BlockEditor/Plugins/EncryptedFilePlugin/Nodes/FileNode.tsx +++ b/packages/web/src/javascripts/Components/BlockEditor/Plugins/EncryptedFilePlugin/Nodes/FileNode.tsx @@ -3,8 +3,9 @@ import { DecoratorBlockNode } from '@lexical/react/LexicalDecoratorBlockNode' import { $createFileNode, convertToFileElement } from './FileUtils' import { FileComponent } from './FileComponent' import { SerializedFileNode } from './SerializedFileNode' +import { ItemNodeInterface } from '../../ItemNodeInterface' -export class FileNode extends DecoratorBlockNode { +export class FileNode extends DecoratorBlockNode implements ItemNodeInterface { __id: string static getType(): string { @@ -45,7 +46,7 @@ export class FileNode extends DecoratorBlockNode { } exportDOM(): DOMExportOutput { - const element = document.createElement('div') + const element = document.createElement('span') element.setAttribute('data-lexical-file-uuid', this.__id) const text = document.createTextNode(this.getTextContent()) element.append(text) @@ -74,8 +75,4 @@ export class FileNode extends DecoratorBlockNode { return } - - isInline(): false { - return false - } } diff --git a/packages/web/src/javascripts/Components/BlockEditor/Plugins/ItemBubblePlugin/ItemBubblePlugin.ts b/packages/web/src/javascripts/Components/BlockEditor/Plugins/ItemBubblePlugin/ItemBubblePlugin.ts new file mode 100644 index 000000000..5afdd8080 --- /dev/null +++ b/packages/web/src/javascripts/Components/BlockEditor/Plugins/ItemBubblePlugin/ItemBubblePlugin.ts @@ -0,0 +1,33 @@ +import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext' +import { $wrapNodeInElement } from '@lexical/utils' +import { COMMAND_PRIORITY_EDITOR, $createParagraphNode, $insertNodes, $isRootOrShadowRoot } from 'lexical' +import { useEffect } from 'react' +import { INSERT_BUBBLE_COMMAND } from '../Commands' +import { BubbleNode } from './Nodes/BubbleNode' +import { $createBubbleNode } from './Nodes/BubbleUtils' + +export default function ItemBubblePlugin(): JSX.Element | null { + const [editor] = useLexicalComposerContext() + + useEffect(() => { + if (!editor.hasNodes([BubbleNode])) { + throw new Error('ItemBubblePlugin: BubbleNode not registered on editor') + } + + return editor.registerCommand( + INSERT_BUBBLE_COMMAND, + (payload) => { + const bubbleNode = $createBubbleNode(payload) + $insertNodes([bubbleNode]) + if ($isRootOrShadowRoot(bubbleNode.getParentOrThrow())) { + $wrapNodeInElement(bubbleNode, $createParagraphNode).selectEnd() + } + + return true + }, + COMMAND_PRIORITY_EDITOR, + ) + }, [editor]) + + return null +} diff --git a/packages/web/src/javascripts/Components/BlockEditor/Plugins/ItemBubblePlugin/Nodes/BubbleComponent.tsx b/packages/web/src/javascripts/Components/BlockEditor/Plugins/ItemBubblePlugin/Nodes/BubbleComponent.tsx new file mode 100644 index 000000000..e820044c6 --- /dev/null +++ b/packages/web/src/javascripts/Components/BlockEditor/Plugins/ItemBubblePlugin/Nodes/BubbleComponent.tsx @@ -0,0 +1,60 @@ +import { useCallback, useMemo } from 'react' +import { useApplication } from '@/Components/ApplicationView/ApplicationProvider' +import LinkedItemBubble from '@/Components/LinkedItems/LinkedItemBubble' +import { createLinkFromItem } from '@/Utils/Items/Search/createLinkFromItem' +import { useLinkingController } from '@/Components/BlockEditor/Contexts/LinkingControllerProvider' +import { LinkableItem } from '@/Utils/Items/Search/LinkableItem' +import { useResponsiveAppPane } from '@/Components/ResponsivePane/ResponsivePaneProvider' +import { LexicalNode } from 'lexical' +import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext' + +export type BubbleComponentProps = Readonly<{ + itemUuid: string + node: LexicalNode +}> + +export function BubbleComponent({ itemUuid, node }: BubbleComponentProps) { + const application = useApplication() + const [editor] = useLexicalComposerContext() + const linkingController = useLinkingController() + const item = useMemo(() => application.items.findItem(itemUuid), [application, itemUuid]) + const { toggleAppPane } = useResponsiveAppPane() + + const activateItemAndTogglePane = useCallback( + async (item: LinkableItem) => { + const paneId = await linkingController.activateItem(item) + if (paneId) { + toggleAppPane(paneId) + } + }, + [toggleAppPane, linkingController], + ) + + const unlinkPressed = useCallback( + async (itemToUnlink: LinkableItem) => { + linkingController.unlinkItemFromSelectedItem(itemToUnlink).catch(console.error) + editor.update(() => { + node.remove() + }) + }, + [linkingController, node, editor], + ) + + if (!item) { + return
Unable to find item {itemUuid}
+ } + + const link = createLinkFromItem(item, 'linked') + + return ( + + ) +} diff --git a/packages/web/src/javascripts/Components/BlockEditor/Plugins/ItemBubblePlugin/Nodes/BubbleNode.tsx b/packages/web/src/javascripts/Components/BlockEditor/Plugins/ItemBubblePlugin/Nodes/BubbleNode.tsx new file mode 100644 index 000000000..5be07523d --- /dev/null +++ b/packages/web/src/javascripts/Components/BlockEditor/Plugins/ItemBubblePlugin/Nodes/BubbleNode.tsx @@ -0,0 +1,76 @@ +import { DOMConversionMap, DOMExportOutput, ElementFormatType, LexicalEditor, NodeKey } from 'lexical' +import { DecoratorBlockNode } from '@lexical/react/LexicalDecoratorBlockNode' +import { $createBubbleNode, convertToBubbleElement } from './BubbleUtils' +import { BubbleComponent } from './BubbleComponent' +import { SerializedBubbleNode } from './SerializedBubbleNode' +import { ItemNodeInterface } from '../../ItemNodeInterface' + +export class BubbleNode extends DecoratorBlockNode implements ItemNodeInterface { + __id: string + + static getType(): string { + return 'snbubble' + } + + static clone(node: BubbleNode): BubbleNode { + return new BubbleNode(node.__id, node.__format, node.__key) + } + + static importJSON(serializedNode: SerializedBubbleNode): BubbleNode { + const node = $createBubbleNode(serializedNode.itemUuid) + node.setFormat(serializedNode.format) + return node + } + + exportJSON(): SerializedBubbleNode { + return { + ...super.exportJSON(), + itemUuid: this.getId(), + version: 1, + type: 'snbubble', + } + } + + static importDOM(): DOMConversionMap | null { + return { + div: (domNode: HTMLDivElement) => { + if (!domNode.hasAttribute('data-lexical-item-uuid')) { + return null + } + return { + conversion: convertToBubbleElement, + priority: 2, + } + }, + } + } + + createDOM(): HTMLElement { + return document.createElement('span') + } + + exportDOM(): DOMExportOutput { + const element = document.createElement('span') + element.setAttribute('data-lexical-item-uuid', this.__id) + const text = document.createTextNode(this.getTextContent()) + element.append(text) + return { element } + } + + constructor(id: string, format?: ElementFormatType, key?: NodeKey) { + super(format, key) + this.__id = id + } + + getId(): string { + return this.__id + } + + getTextContent(_includeInert?: boolean | undefined, _includeDirectionless?: false | undefined): string { + return `[Item: ${this.__id}]` + } + + decorate(_editor: LexicalEditor): JSX.Element { + return + } +} diff --git a/packages/web/src/javascripts/Components/BlockEditor/Plugins/ItemBubblePlugin/Nodes/BubbleUtils.tsx b/packages/web/src/javascripts/Components/BlockEditor/Plugins/ItemBubblePlugin/Nodes/BubbleUtils.tsx new file mode 100644 index 000000000..b642230f3 --- /dev/null +++ b/packages/web/src/javascripts/Components/BlockEditor/Plugins/ItemBubblePlugin/Nodes/BubbleUtils.tsx @@ -0,0 +1,20 @@ +import type { DOMConversionOutput, LexicalNode } from 'lexical' + +import { BubbleNode } from './BubbleNode' + +export function convertToBubbleElement(domNode: HTMLDivElement): DOMConversionOutput | null { + const itemUuid = domNode.getAttribute('data-lexical-item-uuid') + if (itemUuid) { + const node = $createBubbleNode(itemUuid) + return { node } + } + return null +} + +export function $createBubbleNode(itemUuid: string): BubbleNode { + return new BubbleNode(itemUuid) +} + +export function $isBubbleNode(node: BubbleNode | LexicalNode | null | undefined): node is BubbleNode { + return node instanceof BubbleNode +} diff --git a/packages/web/src/javascripts/Components/BlockEditor/Plugins/ItemBubblePlugin/Nodes/SerializedBubbleNode.tsx b/packages/web/src/javascripts/Components/BlockEditor/Plugins/ItemBubblePlugin/Nodes/SerializedBubbleNode.tsx new file mode 100644 index 000000000..262798f4f --- /dev/null +++ b/packages/web/src/javascripts/Components/BlockEditor/Plugins/ItemBubblePlugin/Nodes/SerializedBubbleNode.tsx @@ -0,0 +1,11 @@ +import { Spread } from 'lexical' +import { SerializedDecoratorBlockNode } from '@lexical/react/LexicalDecoratorBlockNode' + +export type SerializedBubbleNode = Spread< + { + itemUuid: string + version: 1 + type: 'snbubble' + }, + SerializedDecoratorBlockNode +> diff --git a/packages/web/src/javascripts/Components/BlockEditor/Plugins/ItemNodeInterface.ts b/packages/web/src/javascripts/Components/BlockEditor/Plugins/ItemNodeInterface.ts new file mode 100644 index 000000000..1e824e78f --- /dev/null +++ b/packages/web/src/javascripts/Components/BlockEditor/Plugins/ItemNodeInterface.ts @@ -0,0 +1,3 @@ +export interface ItemNodeInterface { + getId(): string +} diff --git a/packages/web/src/javascripts/Components/BlockEditor/Plugins/ItemSelectionPlugin/ItemOption.ts b/packages/web/src/javascripts/Components/BlockEditor/Plugins/ItemSelectionPlugin/ItemOption.ts index 2ac1c544f..c536ee12f 100644 --- a/packages/web/src/javascripts/Components/BlockEditor/Plugins/ItemSelectionPlugin/ItemOption.ts +++ b/packages/web/src/javascripts/Components/BlockEditor/Plugins/ItemSelectionPlugin/ItemOption.ts @@ -1,18 +1,16 @@ -import { FileItem } from '@standardnotes/snjs' import { TypeaheadOption } from '@lexical/react/LexicalTypeaheadMenuPlugin' +import { LinkableItem } from '@/Utils/Items/Search/LinkableItem' export class ItemOption extends TypeaheadOption { - icon?: JSX.Element - constructor( - public item: FileItem, + public item: LinkableItem, public options: { keywords?: Array keyboardShortcut?: string onSelect: (queryString: string) => void }, ) { - super(item.title) + super(item.title || '') this.key = item.uuid } } diff --git a/packages/web/src/javascripts/Components/BlockEditor/Plugins/ItemSelectionPlugin/ItemSelectionItemComponent.tsx b/packages/web/src/javascripts/Components/BlockEditor/Plugins/ItemSelectionPlugin/ItemSelectionItemComponent.tsx index b84ef9e16..7222fcc98 100644 --- a/packages/web/src/javascripts/Components/BlockEditor/Plugins/ItemSelectionPlugin/ItemSelectionItemComponent.tsx +++ b/packages/web/src/javascripts/Components/BlockEditor/Plugins/ItemSelectionPlugin/ItemSelectionItemComponent.tsx @@ -1,5 +1,5 @@ import LinkedItemMeta from '@/Components/LinkedItems/LinkedItemMeta' -import { PopoverItemClassNames, PopoverItemSelectedClassNames } from '@standardnotes/blocks-editor' +import { PopoverItemClassNames, PopoverItemSelectedClassNames } from '../ClassNames' import { ItemOption } from './ItemOption' type Props = { diff --git a/packages/web/src/javascripts/Components/BlockEditor/Plugins/ItemSelectionPlugin/ItemSelectionPlugin.tsx b/packages/web/src/javascripts/Components/BlockEditor/Plugins/ItemSelectionPlugin/ItemSelectionPlugin.tsx index 3938b64db..d40974a56 100644 --- a/packages/web/src/javascripts/Components/BlockEditor/Plugins/ItemSelectionPlugin/ItemSelectionPlugin.tsx +++ b/packages/web/src/javascripts/Components/BlockEditor/Plugins/ItemSelectionPlugin/ItemSelectionPlugin.tsx @@ -1,14 +1,16 @@ import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext' import { LexicalTypeaheadMenuPlugin, useBasicTypeaheadTriggerMatch } from '@lexical/react/LexicalTypeaheadMenuPlugin' -import { INSERT_FILE_COMMAND, PopoverClassNames } from '@standardnotes/blocks-editor' import { TextNode } from 'lexical' import { FunctionComponent, useCallback, useMemo, useState } from 'react' import { ItemSelectionItemComponent } from './ItemSelectionItemComponent' import { ItemOption } from './ItemOption' import { useApplication } from '@/Components/ApplicationView/ApplicationProvider' -import { ContentType, FileItem, SNNote } from '@standardnotes/snjs' +import { ContentType, SNNote } from '@standardnotes/snjs' import { getLinkingSearchResults } from '@/Utils/Items/Search/getSearchResults' import Popover from '@/Components/Popover/Popover' +import { INSERT_BUBBLE_COMMAND, INSERT_FILE_COMMAND } from '../Commands' +import { useLinkingController } from '../../Contexts/LinkingControllerProvider' +import { PopoverClassNames } from '../ClassNames' type Props = { currentNote: SNNote @@ -19,6 +21,8 @@ export const ItemSelectionPlugin: FunctionComponent = ({ currentNote }) = const [editor] = useLexicalComposerContext() + const linkingController = useLinkingController() + const [queryString, setQueryString] = useState('') const checkForTriggerMatch = useBasicTypeaheadTriggerMatch('@', { @@ -43,18 +47,24 @@ export const ItemSelectionPlugin: FunctionComponent = ({ currentNote }) = const options = useMemo(() => { const results = getLinkingSearchResults(queryString || '', application, currentNote, { - contentType: ContentType.File, returnEmptyIfQueryEmpty: false, }) - const files = [...results.linkedItems, ...results.unlinkedItems] as FileItem[] - return files.map((file) => { - return new ItemOption(file, { + + const items = [...results.linkedItems, ...results.unlinkedItems] + + return items.map((item) => { + return new ItemOption(item, { onSelect: (_queryString: string) => { - editor.dispatchCommand(INSERT_FILE_COMMAND, file.uuid) + void linkingController.linkItems(currentNote, item) + if (item.content_type === ContentType.File) { + editor.dispatchCommand(INSERT_FILE_COMMAND, item.uuid) + } else { + editor.dispatchCommand(INSERT_BUBBLE_COMMAND, item.uuid) + } }, }) }) - }, [application, editor, currentNote, queryString]) + }, [application, editor, currentNote, queryString, linkingController]) return ( @@ -80,7 +90,6 @@ export const ItemSelectionPlugin: FunctionComponent = ({ currentNote }) = x: anchorElementRef.current.offsetLeft, y: anchorElementRef.current.offsetTop + anchorElementRef.current.offsetHeight, }} - className={'min-h-80 h-80'} open={popoverOpen} togglePopover={() => { setPopoverOpen((prevValue) => !prevValue) diff --git a/packages/web/src/javascripts/Components/BlockEditor/Plugins/NodeObserverPlugin/NodeObserverPlugin.tsx b/packages/web/src/javascripts/Components/BlockEditor/Plugins/NodeObserverPlugin/NodeObserverPlugin.tsx new file mode 100644 index 000000000..b5c7b0fe9 --- /dev/null +++ b/packages/web/src/javascripts/Components/BlockEditor/Plugins/NodeObserverPlugin/NodeObserverPlugin.tsx @@ -0,0 +1,45 @@ +import { useEffect, useRef } from 'react' +import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext' +import { $getNodeByKey, Klass, LexicalNode } from 'lexical' +import { ItemNodeInterface } from '../ItemNodeInterface' + +type NodeKey = string +type ItemUuid = string + +type ObserverProps = { + nodeType: Klass + onRemove: (itemUuid: string) => void +} + +export function NodeObserverPlugin({ nodeType, onRemove }: ObserverProps) { + const [editor] = useLexicalComposerContext() + const map = useRef>(new Map()) + + useEffect(() => { + const removeMutationListener = editor.registerMutationListener(nodeType, (mutatedNodes) => { + editor.getEditorState().read(() => { + for (const [nodeKey, mutation] of mutatedNodes) { + if (mutation === 'updated' || mutation === 'created') { + const node = $getNodeByKey(nodeKey) as unknown as ItemNodeInterface + + if (node) { + const uuid = node.getId() + map.current.set(nodeKey, uuid) + } + } else if (mutation === 'destroyed') { + const uuid = map.current.get(nodeKey) + if (uuid) { + onRemove(uuid) + } + } + } + }) + }) + + return () => { + removeMutationListener() + } + }) + + return null +} diff --git a/packages/web/src/javascripts/Components/LinkedItems/LinkedItemBubble.tsx b/packages/web/src/javascripts/Components/LinkedItems/LinkedItemBubble.tsx index da144e3cf..6a7c33573 100644 --- a/packages/web/src/javascripts/Components/LinkedItems/LinkedItemBubble.tsx +++ b/packages/web/src/javascripts/Components/LinkedItems/LinkedItemBubble.tsx @@ -15,11 +15,13 @@ type Props = { link: ItemLink activateItem: (item: LinkableItem) => Promise unlinkItem: LinkingController['unlinkItemFromSelectedItem'] - focusPreviousItem: () => void - focusNextItem: () => void - focusedId: string | undefined - setFocusedId: (id: string) => void + focusPreviousItem?: () => void + focusNextItem?: () => void + focusedId?: string | undefined + setFocusedId?: (id: string) => void isBidirectional: boolean + inlineFlex?: boolean + className?: string } const LinkedItemBubble = ({ @@ -31,6 +33,8 @@ const LinkedItemBubble = ({ focusedId, setFocusedId, isBidirectional, + inlineFlex, + className, }: Props) => { const ref = useRef(null) const application = useApplication() @@ -42,7 +46,7 @@ const LinkedItemBubble = ({ const handleFocus = () => { if (focusedId !== link.id) { - setFocusedId(link.id) + setFocusedId?.(link.id) } setShowUnlinkButton(true) } @@ -63,21 +67,21 @@ const LinkedItemBubble = ({ const onUnlinkClick: MouseEventHandler = (event) => { event.stopPropagation() - void unlinkItem(link) + void unlinkItem(link.item) } const onKeyDown: KeyboardEventHandler = (event) => { switch (event.key) { case KeyboardKey.Backspace: { - focusPreviousItem() - void unlinkItem(link) + focusPreviousItem?.() + void unlinkItem(link.item) break } case KeyboardKey.Left: - focusPreviousItem() + focusPreviousItem?.() break case KeyboardKey.Right: - focusNextItem() + focusNextItem?.() break } } @@ -94,7 +98,12 @@ const LinkedItemBubble = ({ return (
)}
- + {editorMode !== 'blocks' && ( + + )} )} @@ -1148,7 +1150,12 @@ class NoteView extends AbstractComponent { {editorMode === 'blocks' && (
- +
)} diff --git a/packages/web/src/javascripts/Constants/Constants.ts b/packages/web/src/javascripts/Constants/Constants.ts index 9c1f1a27e..20c9a5a62 100644 --- a/packages/web/src/javascripts/Constants/Constants.ts +++ b/packages/web/src/javascripts/Constants/Constants.ts @@ -22,7 +22,7 @@ export const TAG_FOLDERS_FEATURE_TOOLTIP = 'A Plus or Pro plan is required to en export const SMART_TAGS_FEATURE_NAME = 'Smart Tags' export const PLAIN_EDITOR_NAME = 'Plain Text' -export const BLOCKS_EDITOR_NAME = 'Blocks' +export const BLOCKS_EDITOR_NAME = 'Super Note' export const SYNC_TIMEOUT_DEBOUNCE = 350 export const SYNC_TIMEOUT_NO_DEBOUNCE = 100 diff --git a/packages/web/src/javascripts/Controllers/LinkingController.tsx b/packages/web/src/javascripts/Controllers/LinkingController.tsx index 2cabaa1e1..3ec067147 100644 --- a/packages/web/src/javascripts/Controllers/LinkingController.tsx +++ b/packages/web/src/javascripts/Controllers/LinkingController.tsx @@ -219,14 +219,14 @@ export class LinkingController extends AbstractViewController { return undefined } - unlinkItemFromSelectedItem = async (itemToUnlink: ItemLink) => { + unlinkItemFromSelectedItem = async (itemToUnlink: LinkableItem) => { const selectedItem = this.selectionController.firstSelectedItem if (!selectedItem) { return } - await this.application.items.unlinkItems(selectedItem, itemToUnlink.item) + await this.application.items.unlinkItems(selectedItem, itemToUnlink) void this.application.sync.sync() this.reloadAllLinks() diff --git a/packages/web/src/javascripts/Utils/createEditorMenuGroups.ts b/packages/web/src/javascripts/Utils/createEditorMenuGroups.ts index c9c7ca7f1..d8b014064 100644 --- a/packages/web/src/javascripts/Utils/createEditorMenuGroups.ts +++ b/packages/web/src/javascripts/Utils/createEditorMenuGroups.ts @@ -11,7 +11,7 @@ import { } from '@standardnotes/snjs' import { EditorMenuGroup } from '@/Components/NotesOptions/EditorMenuGroup' import { EditorMenuItem } from '@/Components/NotesOptions/EditorMenuItem' -import { PLAIN_EDITOR_NAME } from '@/Constants/Constants' +import { BLOCKS_EDITOR_NAME, PLAIN_EDITOR_NAME } from '@/Constants/Constants' type NoteTypeToEditorRowsMap = Record @@ -128,7 +128,7 @@ const createGroupsFromMap = (map: NoteTypeToEditorRowsMap): EditorMenuGroup[] => groups.splice(1, 0, { icon: 'dashboard', iconClassName: 'text-accessory-tint-1', - title: 'Blocks', + title: BLOCKS_EDITOR_NAME, items: map[NoteType.Blocks], }) } @@ -157,7 +157,7 @@ const createBaselineMap = (): NoteTypeToEditorRowsMap => { if (featureTrunkEnabled(FeatureTrunkName.Blocks)) { map[NoteType.Blocks].push({ - name: 'Blocks', + name: BLOCKS_EDITOR_NAME, isEntitled: true, noteType: NoteType.Blocks, })