You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
268 lines
8.5 KiB
TypeScript
268 lines
8.5 KiB
TypeScript
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;
|