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