diff --git a/public/images/icons/paint-brush-line.svg b/public/images/icons/paint-brush-line.svg new file mode 100644 index 0000000..b0471c2 --- /dev/null +++ b/public/images/icons/paint-brush-line.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/icons/paint-brush-line.svg b/src/assets/icons/paint-brush-line.svg new file mode 100644 index 0000000..b0471c2 --- /dev/null +++ b/src/assets/icons/paint-brush-line.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/components/LexicalEditor/index.jsx b/src/components/LexicalEditor/index.jsx index 60434cf..0b14f8d 100644 --- a/src/components/LexicalEditor/index.jsx +++ b/src/components/LexicalEditor/index.jsx @@ -45,6 +45,8 @@ import TableCellResizer from './plugins/TableCellResizer'; // import {useLexicalEditable} from '@lexical/react/useLexicalEditable'; +import FormatPaintPlugin from './plugins/FormatPaint'; + import { TextNode, $getRoot, $getSelection, $createParagraphNode } from 'lexical'; import { $generateHtmlFromNodes, $generateNodesFromDOM, } from '@lexical/html'; @@ -203,6 +205,7 @@ export default function Editor({ isRichText, isDebug, editorRef, onChange, defau + diff --git a/src/components/LexicalEditor/plugins/FormatPaint/FormatPainterCommands.ts b/src/components/LexicalEditor/plugins/FormatPaint/FormatPainterCommands.ts new file mode 100644 index 0000000..0853a9e --- /dev/null +++ b/src/components/LexicalEditor/plugins/FormatPaint/FormatPainterCommands.ts @@ -0,0 +1,27 @@ +import { LexicalCommand, createCommand, TextFormatType } from 'lexical'; + +export interface CopiedFormat { + textFormatFlags: number; // 从node.getFormat() + style: string; // 从node.getStyle() + // todo: p 标签的样式 +} + +export interface ActivateFormatPainterPayload { + sticky: boolean; +} + +// activate the format painter and copy the current selection's format +export const ACTIVATE_FORMAT_PAINTER_COMMAND: LexicalCommand = + createCommand('ACTIVATE_FORMAT_PAINTER_COMMAND'); + +// deactivate the format painter +export const DEACTIVATE_FORMAT_PAINTER_COMMAND: LexicalCommand = + createCommand('DEACTIVATE_FORMAT_PAINTER_COMMAND'); + +// dispatched by the plugin to inform UI about state changes +export interface FormatPainterState { + isActive: boolean; + isSticky: boolean; +} +export const FORMAT_PAINTER_STATE_UPDATE_COMMAND: LexicalCommand = + createCommand('FORMAT_PAINTER_STATE_UPDATE_COMMAND'); diff --git a/src/components/LexicalEditor/plugins/FormatPaint/FormatPainterToolbarButton.tsx b/src/components/LexicalEditor/plugins/FormatPaint/FormatPainterToolbarButton.tsx new file mode 100644 index 0000000..fe3bc07 --- /dev/null +++ b/src/components/LexicalEditor/plugins/FormatPaint/FormatPainterToolbarButton.tsx @@ -0,0 +1,84 @@ +import React, { useState, useEffect, useCallback } from 'react'; +import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'; +import { $getSelection, $isRangeSelection, LexicalEditor, COMMAND_PRIORITY_NORMAL } from 'lexical'; +import { + ACTIVATE_FORMAT_PAINTER_COMMAND, + DEACTIVATE_FORMAT_PAINTER_COMMAND, + FORMAT_PAINTER_STATE_UPDATE_COMMAND, + FormatPainterState, +} from './FormatPainterCommands'; + +const PaintBrushIcon = () => ; + +export function FormatPainterToolbarButton() { + const [editor] = useLexicalComposerContext(); + const [isActive, setIsActive] = useState(false); + const [isSticky, setIsSticky] = useState(false); + const [canCopy, setCanCopy] = useState(false); + + // 插件状态 + useEffect(() => { + return editor.registerCommand( + FORMAT_PAINTER_STATE_UPDATE_COMMAND, + (payload) => { + setIsActive(payload.isActive); + setIsSticky(payload.isSticky); + return true; + }, + COMMAND_PRIORITY_NORMAL, + ); + }, [editor]); + + // 选区状态 + useEffect(() => { + return editor.registerUpdateListener(({ editorState }) => { + editorState.read(() => { + const selection = $getSelection(); + if ($isRangeSelection(selection) && !selection.isCollapsed()) { + setCanCopy(true); + } else { + setCanCopy(false); + } + }); + }); + }, [editor]); + + const handleClick = () => { + if (isActive) { + editor.dispatchCommand(DEACTIVATE_FORMAT_PAINTER_COMMAND, undefined); + } else if (canCopy) { + editor.dispatchCommand(ACTIVATE_FORMAT_PAINTER_COMMAND, { sticky: false }); + } + // * !isActive and !canCopy 什么也不做 + }; + + // 双击 保持激活 + const handleDoubleClick = () => { + if (isActive && isSticky) { + editor.dispatchCommand(DEACTIVATE_FORMAT_PAINTER_COMMAND, undefined); + } else if (canCopy) { + editor.dispatchCommand(ACTIVATE_FORMAT_PAINTER_COMMAND, { sticky: true }); + } + }; + + return ( + + ); +} + +{/* */} +export default FormatPainterToolbarButton; diff --git a/src/components/LexicalEditor/plugins/FormatPaint/index.tsx b/src/components/LexicalEditor/plugins/FormatPaint/index.tsx new file mode 100644 index 0000000..d9d373d --- /dev/null +++ b/src/components/LexicalEditor/plugins/FormatPaint/index.tsx @@ -0,0 +1,267 @@ +import React, { useEffect, useState, useCallback, useRef } from 'react'; +import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'; +import { + $getSelection, + $isRangeSelection, + $isTextNode, + TextFormatType, + LexicalEditor, + COMMAND_PRIORITY_NORMAL, + COMMAND_PRIORITY_LOW, +} from 'lexical'; +import { $patchStyleText, } from '@lexical/selection'; +// $patchStyleText is more efficient for merging styles. + +import { + CopiedFormat, + ACTIVATE_FORMAT_PAINTER_COMMAND, + DEACTIVATE_FORMAT_PAINTER_COMMAND, + FORMAT_PAINTER_STATE_UPDATE_COMMAND, + ActivateFormatPainterPayload, + FormatPainterState, +} from './FormatPainterCommands'; + +// parse style string to object for $patchStyleText +function parseStyleText(style: string): Record { + const styleObj: Record = {}; + style.split(';').forEach((rule) => { + const [key, value] = rule.split(':'); + if (key && value && key.trim() && value.trim()) { + styleObj[key.trim()] = value.trim(); + } + }); + return styleObj; +} + +// map format flags to TextFormatType +const textFormatTypeMap: { flag: number; type: TextFormatType }[] = [ + { flag: 1, type: 'bold' }, + { flag: 2, type: 'italic' }, + { flag: 4, type: 'strikethrough' }, + { flag: 8, type: 'underline' }, + { flag: 16, type: 'code' }, + { flag: 32, type: 'subscript' }, + { flag: 64, type: 'superscript' }, +]; + +export function FormatPainterPlugin(): null { + const [editor] = useLexicalComposerContext(); + + const [copiedFormat, setCopiedFormat] = useState(null); + const [isActive, setIsActive] = useState(false); + const [isSticky, setIsSticky] = useState(false); + + // 避免多次复制 + const isPickingUpRef = useRef(false); + + const broadcastState = useCallback(() => { + editor.dispatchCommand(FORMAT_PAINTER_STATE_UPDATE_COMMAND, { + isActive, + isSticky, + }); + }, [editor, isActive, isSticky]); + + // Update broadcast whenever state changes + useEffect(() => { + broadcastState(); + }, [isActive, isSticky, broadcastState]); + + + // Activate Format Painter (Copy Format) + useEffect(() => { + return editor.registerCommand( + ACTIVATE_FORMAT_PAINTER_COMMAND, + (payload) => { + isPickingUpRef.current = true; + editor.update(() => { + const selection = $getSelection(); + if ($isRangeSelection(selection) && !selection.isCollapsed()) { + const anchorNode = selection.anchor.getNode(); + let formatToCopy: CopiedFormat | null = null; + + if ($isTextNode(anchorNode)) { + formatToCopy = { + textFormatFlags: anchorNode.getFormat(), + style: anchorNode.getStyle(), + }; + } else { + // ? todo: 从第一个字符获取格式 + const nodes = selection.getNodes(); + for (const node of nodes) { + if ($isTextNode(node)) { + formatToCopy = { + textFormatFlags: node.getFormat(), + style: node.getStyle(), + }; + break; + } + } + } + + if (formatToCopy) { + setCopiedFormat(formatToCopy); + setIsActive(true); + setIsSticky(payload.sticky); + // console.log('Format Painter Activated. Sticky:', payload.sticky, 'Format:', formatToCopy); + } + } + }); + // 鼠标抬起 + setTimeout(() => { isPickingUpRef.current = false; }, 50); + return true; + }, + COMMAND_PRIORITY_NORMAL, + ); + }, [editor]); + + // Deactivate Format Painter + useEffect(() => { + return editor.registerCommand( + DEACTIVATE_FORMAT_PAINTER_COMMAND, + () => { + if (!isActive) return false; + setIsActive(false); + setIsSticky(false); + // 不保留 + setCopiedFormat(null); + // console.log('Format Painter Deactivated.'); + return true; + }, + COMMAND_PRIORITY_NORMAL, + ); + }, [editor, isActive]); + + + // 应用复制的格式 + const applyFormat = useCallback(() => { + if (!isActive || !copiedFormat || isPickingUpRef.current) { + return false; + } + + editor.update(() => { + const selection = $getSelection(); + if ($isRangeSelection(selection) && copiedFormat) { + // console.log('copiedFormat:', copiedFormat, '\ntextFormatTypeMap:', textFormatTypeMap); + // TextNode (bold, italic, ...) + textFormatTypeMap.forEach(fmt => { + if (copiedFormat.textFormatFlags & fmt.flag) { + selection.formatText(fmt.type); + } else { + const currentSelection = $getSelection(); + if ($isRangeSelection(currentSelection)) { + textFormatTypeMap.forEach(fmt => { + const shouldHaveFormat = (copiedFormat.textFormatFlags & fmt.flag) > 0; + if (currentSelection.hasFormat(fmt.type) !== shouldHaveFormat) { + currentSelection.formatText(fmt.type); + } + }); + } + } + }); + // ensure applied + let newSelection = $getSelection(); + if ($isRangeSelection(newSelection)) { + textFormatTypeMap.forEach(fmt => { + if (copiedFormat.textFormatFlags & fmt.flag) { + if (!newSelection!.hasFormat(fmt.type)) newSelection!.formatText(fmt.type); + } else { + if (newSelection!.hasFormat(fmt.type)) newSelection!.formatText(fmt.type); + } + }); + } + + // inline styles (font-family, color, font-size, ...) + const stylesToApply = parseStyleText(copiedFormat.style); + // console.log('inline style', stylesToApply); + if (Object.keys(stylesToApply).length > 0) { + newSelection = $getSelection(); + if ($isRangeSelection(newSelection)) { + $patchStyleText(newSelection as any, stylesToApply); + } + } else { + // 清除格式 + const selectedNodes = newSelection.getNodes(); + selectedNodes.forEach(node => { + if ($isTextNode(node)) { + if (node.getStyle() !== "") { + node.setStyle(""); // 清除 + } + } + // todo:

node + }); + } + + // console.log('Format Applied. Sticky:', isSticky); + + if (!isSticky) { + setIsActive(false); + } + } + }); + return true; + }, [editor, isActive, isSticky, copiedFormat]); + + + // 鼠标抬起 + useEffect(() => { + if (!isActive || !copiedFormat) return; + + const editorElement = editor.getRootElement(); + if (!editorElement) return; + + const handleMouseUp = (event: MouseEvent) => { + if (isPickingUpRef.current) return; + + if (editorElement.contains(event.target as Node)) { + // todo: 改为在下一帧更新 + setTimeout(() => { + const selection = editor.getEditorState().read($getSelection); + if ($isRangeSelection(selection) && !selection.isCollapsed()) { + applyFormat(); + } else if ($isRangeSelection(selection) && selection.isCollapsed()) { + // 折叠的选区, 也应用 + // applyFormat(); + } + }, 0); + } + }; + + document.addEventListener('mouseup', handleMouseUp); + return () => { + document.removeEventListener('mouseup', handleMouseUp); + }; + }, [editor, isActive, copiedFormat, applyFormat]); + + + // 按 esc 键取消格式 + useEffect(() => { + if (!isActive) return; + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + editor.dispatchCommand(DEACTIVATE_FORMAT_PAINTER_COMMAND, undefined); + } + }; + + document.addEventListener('keydown', handleKeyDown); + return () => { + document.removeEventListener('keydown', handleKeyDown); + }; + }, [editor, isActive]); + + // 鼠标样式 + useEffect(() => { + const editorElement = editor.getRootElement(); + if (editorElement) { + editorElement.style.cursor = isActive ? 'copy' : 'auto'; + } + return () => { + if (editorElement) { + editorElement.style.cursor = 'auto'; + } + }; + }, [editor, isActive]); + + return null; +} +export default FormatPainterPlugin; diff --git a/src/components/LexicalEditor/plugins/ToolbarPlugin.jsx b/src/components/LexicalEditor/plugins/ToolbarPlugin.jsx index a258eff..ae436b0 100644 --- a/src/components/LexicalEditor/plugins/ToolbarPlugin.jsx +++ b/src/components/LexicalEditor/plugins/ToolbarPlugin.jsx @@ -39,6 +39,7 @@ import { // InsertImagePayload, } from './ImagesPlugin'; import {InsertInlineImageDialog} from './InlineImagePlugin'; +import FormatPainterToolbarButton from './FormatPaint/FormatPainterToolbarButton'; import useModal from './../hooks/useModal'; @@ -793,6 +794,7 @@ export default function ToolbarPlugin() { aria-label='Redo'> + {supportedBlockTypes.has(blockType) && ( <> diff --git a/src/components/LexicalEditor/styles.css b/src/components/LexicalEditor/styles.css index 173bd1a..f065d4d 100644 --- a/src/components/LexicalEditor/styles.css +++ b/src/components/LexicalEditor/styles.css @@ -430,7 +430,8 @@ pre::-webkit-scrollbar-thumb { } .toolbar button.toolbar-item.active { - background-color: rgba(223, 232, 250, 0.3); + /* background-color: rgba(223, 232, 250, 0.8); */ + background-color: #eef2ff; } .toolbar button.toolbar-item.active i { @@ -604,7 +605,8 @@ i.chevron-down { background-size: contain; } button.item.dropdown-item-active { - background-color: #dfe8fa4d; + /* background-color: #dfe8fa4d; */ + background-color: #eef2ff; } .dropdown .item:first-child { @@ -836,6 +838,10 @@ i.outdent { background-image: url("data:image/svg+xml,%3csvg%20xmlns='http://www.w3.org/2000/svg'%20width='16'%20height='16'%20fill='currentColor'%20class='bi%20bi-text-indent-right'%3e%3cpath%20d='M2%203.5a.5.5%200%200%201%20.5-.5h11a.5.5%200%200%201%200%201h-11a.5.5%200%200%201-.5-.5zm10.646%202.146a.5.5%200%200%201%20.708.708L11.707%208l1.647%201.646a.5.5%200%200%201-.708.708l-2-2a.5.5%200%200%201%200-.708l2-2zM2%206.5a.5.5%200%200%201%20.5-.5h6a.5.5%200%200%201%200%201h-6a.5.5%200%200%201-.5-.5zm0%203a.5.5%200%200%201%20.5-.5h6a.5.5%200%200%201%200%201h-6a.5.5%200%200%201-.5-.5zm0%203a.5.5%200%200%201%20.5-.5h11a.5.5%200%200%201%200%201h-11a.5.5%200%200%201-.5-.5z'/%3e%3c/svg%3e") } +i.painter { + background-image: url(/images/icons/paint-brush-line.svg); +} + i.bold { background-image: url(/images/icons/type-bold.svg); }