feat: 编辑器: 格式刷
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 |
@ -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;
|
Loading…
Reference in New Issue