From 2bb3ab869935555915a913d97d4a7e8732fa29f3 Mon Sep 17 00:00:00 2001 From: wnhlee <2wheeh@gmail.com> Date: Sun, 21 Apr 2024 12:54:37 +0900 Subject: [PATCH] feat: implement useBlockTypeActions --- ui/src/hooks/editor/use-block-type-actions.ts | 115 ++++++++++++++++++ ui/src/lib/utils/editor/block-type-actions.ts | 45 +++++++ 2 files changed, 160 insertions(+) create mode 100644 ui/src/hooks/editor/use-block-type-actions.ts create mode 100644 ui/src/lib/utils/editor/block-type-actions.ts diff --git a/ui/src/hooks/editor/use-block-type-actions.ts b/ui/src/hooks/editor/use-block-type-actions.ts new file mode 100644 index 00000000..49825565 --- /dev/null +++ b/ui/src/hooks/editor/use-block-type-actions.ts @@ -0,0 +1,115 @@ +'use client'; + +import { INSERT_ORDERED_LIST_COMMAND, INSERT_UNORDERED_LIST_COMMAND } from '@lexical/list'; +import type { HeadingTagType } from '@lexical/rich-text'; +import { $createHeadingNode, $createQuoteNode } from '@lexical/rich-text'; +import { $setBlocksType } from '@lexical/selection'; +import type { LexicalEditor } from 'lexical'; +import { + $createParagraphNode, + $getSelection, + $isRangeSelection, + COMMAND_PRIORITY_CRITICAL, + SELECTION_CHANGE_COMMAND, +} from 'lexical'; +import { useCallback, useEffect, useState } from 'react'; + +import { + $getBlockAndAnchorFromSelection, + $getBlockType, +} from '@/lib/utils/editor/block-type-actions'; + +const blockTypeToBlockName = { + paragraph: 'Normal', + quote: 'Quote', + h1: 'Heading 1', + h2: 'Heading 2', + h3: 'Heading 3', + bullet: 'Bulleted List', + number: 'Numbered List', +}; + +export function useBlockTypeActions({ editor }: { editor: LexicalEditor | undefined | null }) { + const [blockType, setBlockType] = useState('paragraph'); + + const $updateBlockType = useCallback(() => { + const selection = $getSelection(); + const { blockNode, anchorNode } = $getBlockAndAnchorFromSelection(selection) ?? {}; + const type = $getBlockType(blockNode, anchorNode); + + if (type && type in blockTypeToBlockName) { + setBlockType(type as keyof typeof blockTypeToBlockName); + } + }, []); + + useEffect(() => { + return editor?.registerCommand( + SELECTION_CHANGE_COMMAND, + () => { + $updateBlockType(); + return false; + }, + COMMAND_PRIORITY_CRITICAL + ); + }, [editor, $updateBlockType]); + + useEffect(() => { + return editor?.registerUpdateListener(({ editorState }) => { + editorState.read(() => { + $updateBlockType(); + }); + }); + }, [editor, $updateBlockType]); + + const formatParagraph = () => { + editor?.update(() => { + const selection = $getSelection(); + if ($isRangeSelection(selection)) { + $setBlocksType(selection, () => $createParagraphNode()); + } + }); + }; + + const formatHeading = (headingType: HeadingTagType) => { + if (blockType !== headingType) { + editor?.update(() => { + const selection = $getSelection(); + $setBlocksType(selection, () => $createHeadingNode(headingType)); + }); + } + }; + + const formatBulletList = () => { + if (blockType !== 'bullet') { + editor?.dispatchCommand(INSERT_UNORDERED_LIST_COMMAND, undefined); + } else { + formatParagraph(); + } + }; + + const formatNumberedList = () => { + if (blockType !== 'number') { + editor?.dispatchCommand(INSERT_ORDERED_LIST_COMMAND, undefined); + } else { + formatParagraph(); + } + }; + + const formatQuote = () => { + if (blockType !== 'quote') { + editor?.update(() => { + const selection = $getSelection(); + $setBlocksType(selection, () => $createQuoteNode()); + }); + } + }; + + return { + blockType, + formatParagraph, + formatQuote, + formatHeading, + formatBulletList, + formatNumberedList, + }; +} diff --git a/ui/src/lib/utils/editor/block-type-actions.ts b/ui/src/lib/utils/editor/block-type-actions.ts new file mode 100644 index 00000000..cc6a5e91 --- /dev/null +++ b/ui/src/lib/utils/editor/block-type-actions.ts @@ -0,0 +1,45 @@ +import { $isListNode, ListNode } from '@lexical/list'; +import { $isHeadingNode } from '@lexical/rich-text'; +import { $findMatchingParent, $getNearestNodeOfType } from '@lexical/utils'; +import type { BaseSelection, LexicalNode } from 'lexical'; +import { $isRangeSelection, $isRootOrShadowRoot } from 'lexical'; + +export function $getBlockAndAnchorFromSelection(selection: BaseSelection | null) { + if (!$isRangeSelection(selection)) { + return null; + } + + const anchorNode = selection.anchor.getNode(); + let blockNode = + anchorNode.getKey() === 'root' + ? anchorNode + : $findMatchingParent(anchorNode, (e) => { + const parent = e.getParent(); + return parent !== null && $isRootOrShadowRoot(parent); + }); + + if (blockNode === null) { + blockNode = anchorNode.getTopLevelElementOrThrow(); + } + + return { blockNode, anchorNode }; +} + +export function $getBlockType(blockNode?: LexicalNode, anchorNode?: LexicalNode) { + if (!blockNode || !anchorNode) { + return null; + } + + if ($isListNode(blockNode)) { + /* + list <- blockNode + listitem + list <- nestedListNode + listitem <- anchorNode + */ + const nestedListNode = $getNearestNodeOfType(anchorNode, ListNode); + return nestedListNode ? nestedListNode.getListType() : blockNode.getListType(); + } else { + return $isHeadingNode(blockNode) ? blockNode.getTag() : blockNode.getType(); + } +}