diff --git a/packages/web/src/javascripts/Components/SuperEditor/BlocksEditor.tsx b/packages/web/src/javascripts/Components/SuperEditor/BlocksEditor.tsx index b2014c95790..c0674a935f2 100644 --- a/packages/web/src/javascripts/Components/SuperEditor/BlocksEditor.tsx +++ b/packages/web/src/javascripts/Components/SuperEditor/BlocksEditor.tsx @@ -27,7 +27,6 @@ import { RemoveBrokenTablesPlugin } from './Plugins/TablePlugin' import TableActionMenuPlugin from './Plugins/TableCellActionMenuPlugin' import ToolbarPlugin from './Plugins/ToolbarPlugin/ToolbarPlugin' import { useMediaQuery, MutuallyExclusiveMediaQueryBreakpoints } from '@/Hooks/useMediaQuery' -import { CheckListPlugin } from '@lexical/react/LexicalCheckListPlugin' import RemoteImagePlugin from './Plugins/RemoteImagePlugin/RemoteImagePlugin' import CodeOptionsPlugin from './Plugins/CodeOptionsPlugin/CodeOptions' import { SuperSearchContextProvider } from './Plugins/SearchPlugin/Context' @@ -35,6 +34,7 @@ import { SearchPlugin } from './Plugins/SearchPlugin/SearchPlugin' import AutoLinkPlugin from './Plugins/AutoLinkPlugin/AutoLinkPlugin' import DatetimePlugin from './Plugins/DateTimePlugin/DateTimePlugin' import PasswordPlugin from './Plugins/PasswordPlugin/PasswordPlugin' +import { CheckListPlugin } from './Plugins/CheckListPlugin' type BlocksEditorProps = { onChange?: (value: string, preview: string) => void diff --git a/packages/web/src/javascripts/Components/SuperEditor/Lexical/Theme/lists.scss b/packages/web/src/javascripts/Components/SuperEditor/Lexical/Theme/lists.scss index 188b504441d..813d881b580 100644 --- a/packages/web/src/javascripts/Components/SuperEditor/Lexical/Theme/lists.scss +++ b/packages/web/src/javascripts/Components/SuperEditor/Lexical/Theme/lists.scss @@ -57,6 +57,12 @@ list-style-type: none; outline: none; vertical-align: middle; + + &:focus, + &:focus-within { + outline: none; + box-shadow: none; + } } .Lexical__listItemChecked { text-decoration: line-through; @@ -80,11 +86,6 @@ left: auto; right: 0; } -.Lexical__listItemUnchecked:focus:before, -.Lexical__listItemChecked:focus:before { - box-shadow: 0 0 0 2px #a6cdfe; - border-radius: 2px; -} .Lexical__listItemUnchecked:before { border: 1px solid #999; border-radius: 2px; diff --git a/packages/web/src/javascripts/Components/SuperEditor/Plugins/CheckListPlugin.tsx b/packages/web/src/javascripts/Components/SuperEditor/Plugins/CheckListPlugin.tsx new file mode 100644 index 00000000000..f0b1bdf8384 --- /dev/null +++ b/packages/web/src/javascripts/Components/SuperEditor/Plugins/CheckListPlugin.tsx @@ -0,0 +1,158 @@ +import { $isListItemNode, $isListNode, INSERT_CHECK_LIST_COMMAND, insertList, ListNode } from '@lexical/list' +import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext' +import { calculateZoomLevel, isHTMLElement, mergeRegister } from '@lexical/utils' +import { + $getNearestNodeFromDOMNode, + $getSelection, + $isRangeSelection, + COMMAND_PRIORITY_LOW, + KEY_ENTER_COMMAND, +} from 'lexical' +import { useEffect } from 'react' +import { useApplication } from '../../ApplicationProvider' +import { getPrimaryModifier } from '@standardnotes/ui-services' + +export function CheckListPlugin(): null { + const application = useApplication() + const [editor] = useLexicalComposerContext() + + useEffect(() => { + const primaryModifier = getPrimaryModifier(application.platform) + + return mergeRegister( + editor.registerCommand( + INSERT_CHECK_LIST_COMMAND, + () => { + insertList(editor, 'check') + return true + }, + COMMAND_PRIORITY_LOW, + ), + editor.registerRootListener((rootElement, prevElement) => { + function handleCheckItemEvent(event: PointerEvent, callback: () => void) { + const target = event.target + + if (target === null || !isHTMLElement(target)) { + return + } + + // Ignore clicks on LI that have nested lists + const firstChild = target.firstChild + + if ( + firstChild != null && + isHTMLElement(firstChild) && + (firstChild.tagName === 'UL' || firstChild.tagName === 'OL') + ) { + return + } + + editor.update(() => { + const targetNode = $getNearestNodeFromDOMNode(target) + + const parentNode = targetNode?.getParent() + + if (!$isListNode(parentNode) || parentNode.getListType() !== 'check') { + return + } + + const rect = target.getBoundingClientRect() + + const listItemElementStyles = getComputedStyle(target) + const paddingLeft = parseFloat(listItemElementStyles.paddingLeft) || 0 + const paddingRight = parseFloat(listItemElementStyles.paddingRight) || 0 + const lineHeight = parseFloat(listItemElementStyles.lineHeight) || 0 + + const checkStyles = getComputedStyle(target, ':before') + const checkWidth = parseFloat(checkStyles.width) || 0 + + const pageX = event.pageX / calculateZoomLevel(target) + + const isWithinHorizontalThreshold = + target.dir === 'rtl' + ? pageX < rect.right && pageX > rect.right - paddingRight + : pageX > rect.left && pageX < rect.left + (checkWidth || paddingLeft) + + const isWithinVerticalThreshold = event.clientY > rect.top && event.clientY < rect.top + lineHeight + + if (isWithinHorizontalThreshold && isWithinVerticalThreshold) { + callback() + } + }) + } + + function handleClick(event: Event) { + handleCheckItemEvent(event as PointerEvent, () => { + if (!editor.isEditable()) { + return + } + + editor.update(() => { + const domNode = event.target as HTMLElement + + if (!event.target) { + return + } + + const node = $getNearestNodeFromDOMNode(domNode) + + if (!$isListItemNode(node)) { + return + } + + domNode.focus() + node.toggleChecked() + }) + }) + } + + function handlePointerDown(event: PointerEvent) { + handleCheckItemEvent(event, () => { + // Prevents caret moving when clicking on check mark + event.preventDefault() + }) + } + + if (rootElement !== null) { + rootElement.addEventListener('click', handleClick) + rootElement.addEventListener('pointerdown', handlePointerDown) + } + + if (prevElement !== null) { + prevElement.removeEventListener('click', handleClick) + prevElement.removeEventListener('pointerdown', handlePointerDown) + } + }), + editor.registerCommand( + KEY_ENTER_COMMAND, + () => { + if (!application.keyboardService.activeModifiers.has(primaryModifier)) { + return false + } + const selection = $getSelection() + if (!$isRangeSelection(selection) || !selection.isCollapsed()) { + return false + } + const focusNode = selection.focus.getNode() + const parent = focusNode.getParent() + const node = $isListItemNode(parent) ? parent : focusNode + if (!$isListItemNode(node) || node.getParent()?.getListType() !== 'check') { + return false + } + node.toggleChecked() + return true + }, + COMMAND_PRIORITY_LOW, + ), + application.keyboardService.registerExternalKeyboardShortcutHelpItem({ + platform: application.platform, + modifiers: [primaryModifier], + key: 'Enter', + category: 'Super notes', + description: 'Toggle checklist item', + }), + ) + }, [application.keyboardService, application.platform, editor]) + + return null +}