feat: 编辑器: 格式刷

dev/ckeditor
Lei OT 4 months ago
parent 3e8cda6700
commit 0d9dd3ad8c

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M5 4.9967V7.9967H19V4.9967H5ZM4 2.9967H20C20.5523 2.9967 21 3.44442 21 3.9967V8.9967C21 9.54899 20.5523 9.9967 20 9.9967H4C3.44772 9.9967 3 9.54899 3 8.9967V3.9967C3 3.44442 3.44772 2.9967 4 2.9967ZM6 11.9967H12C12.5523 11.9967 13 12.4444 13 12.9967V15.9967H14V21.9967H10V15.9967H11V13.9967H5C4.44772 13.9967 4 13.549 4 12.9967V10.9967H6V11.9967ZM17.7322 13.7289L19.5 11.9612L21.2678 13.7289C22.2441 14.7052 22.2441 16.2882 21.2678 17.2645C20.2915 18.2408 18.7085 18.2408 17.7322 17.2645C16.7559 16.2882 16.7559 14.7052 17.7322 13.7289Z"></path></svg>

After

Width:  |  Height:  |  Size: 640 B

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M5 4.9967V7.9967H19V4.9967H5ZM4 2.9967H20C20.5523 2.9967 21 3.44442 21 3.9967V8.9967C21 9.54899 20.5523 9.9967 20 9.9967H4C3.44772 9.9967 3 9.54899 3 8.9967V3.9967C3 3.44442 3.44772 2.9967 4 2.9967ZM6 11.9967H12C12.5523 11.9967 13 12.4444 13 12.9967V15.9967H14V21.9967H10V15.9967H11V13.9967H5C4.44772 13.9967 4 13.549 4 12.9967V10.9967H6V11.9967ZM17.7322 13.7289L19.5 11.9612L21.2678 13.7289C22.2441 14.7052 22.2441 16.2882 21.2678 17.2645C20.2915 18.2408 18.7085 18.2408 17.7322 17.2645C16.7559 16.2882 16.7559 14.7052 17.7322 13.7289Z"></path></svg>

After

Width:  |  Height:  |  Size: 640 B

@ -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
<EditorRefPlugin editorRef={editorRef} />
<ImagesPlugin />
<InlineImagePlugin />
<FormatPaintPlugin />
<MyOnChangePlugin onChange={onChange}/>
</div>
</div>

@ -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<ActivateFormatPainterPayload> =
createCommand('ACTIVATE_FORMAT_PAINTER_COMMAND');
// deactivate the format painter
export const DEACTIVATE_FORMAT_PAINTER_COMMAND: LexicalCommand<void> =
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<FormatPainterState> =
createCommand('FORMAT_PAINTER_STATE_UPDATE_COMMAND');

@ -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 = () => <i className='format painter' />;
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<FormatPainterState>(
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 (
<button
type="button"
onClick={handleClick}
onDoubleClick={handleDoubleClick}
className={`toolbar-item spaced ${isActive ? 'active' : ''}`}
title={isActive ? (isSticky ? 'Format Painter (Sticky)' : 'Format Painter (Active)') : 'Format Painter'}
aria-label={isActive ? (isSticky ? 'Deactivate Format Painter (Sticky)' : 'Deactivate Format Painter (Active)') : 'Activate Format Painter'}
disabled={!isActive && !canCopy}
>
<PaintBrushIcon />
</button>
);
}
{/* <button type='button'
className={'toolbar-item spaced ' + (isActive ? 'active' : '')}
aria-label='Format Painter'>
<i className='format painter' />
</button> */}
export default FormatPainterToolbarButton;

@ -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<string, string> {
const styleObj: Record<string, string> = {};
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<CopiedFormat | null>(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<ActivateFormatPainterPayload>(
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<void>(
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: <p> 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;

@ -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'>
<i className='format redo' />
</button>
<FormatPainterToolbarButton />
<Divider />
{supportedBlockTypes.has(blockType) && (
<>

@ -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);
}

Loading…
Cancel
Save