feat:添加画图

This commit is contained in:
shixiaohua 2024-02-29 10:47:59 +08:00
parent 8dd57ec5b4
commit 4f6c3915ee
12 changed files with 759 additions and 22 deletions

11
package-lock.json generated
View File

@ -25,6 +25,7 @@
"@electron-forge/maker-rpm": "^6.0.4", "@electron-forge/maker-rpm": "^6.0.4",
"@electron-forge/maker-squirrel": "^6.0.4", "@electron-forge/maker-squirrel": "^6.0.4",
"@electron-forge/maker-zip": "^6.0.4", "@electron-forge/maker-zip": "^6.0.4",
"@excalidraw/excalidraw": "^0.17.0",
"@lexical/react": "^0.12.6", "@lexical/react": "^0.12.6",
"@reduxjs/toolkit": "^1.9.3", "@reduxjs/toolkit": "^1.9.3",
"@testing-library/jest-dom": "^5.16.5", "@testing-library/jest-dom": "^5.16.5",
@ -4071,6 +4072,16 @@
"node": "^12.22.0 || ^14.17.0 || >=16.0.0" "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
} }
}, },
"node_modules/@excalidraw/excalidraw": {
"version": "0.17.3",
"resolved": "https://registry.npmmirror.com/@excalidraw/excalidraw/-/excalidraw-0.17.3.tgz",
"integrity": "sha512-t+0sR30AboKcINt0WUJmSAC1cJy6npO37j/zONvuWvSh6XDOGoL1E0L+WYKJMBzp4wnOQhRIghQJmdfktQlO8w==",
"dev": true,
"peerDependencies": {
"react": "^17.0.2 || ^18.2.0",
"react-dom": "^17.0.2 || ^18.2.0"
}
},
"node_modules/@gar/promisify": { "node_modules/@gar/promisify": {
"version": "1.1.3", "version": "1.1.3",
"resolved": "https://registry.npmmirror.com/@gar/promisify/-/promisify-1.1.3.tgz", "resolved": "https://registry.npmmirror.com/@gar/promisify/-/promisify-1.1.3.tgz",

View File

@ -8,6 +8,7 @@
"devDependencies": { "devDependencies": {
"@ant-design/pro-components": "^2.3.57", "@ant-design/pro-components": "^2.3.57",
"@craco/craco": "^6.0.0", "@craco/craco": "^6.0.0",
"@excalidraw/excalidraw": "^0.17.0",
"@electron-forge/cli": "^6.0.4", "@electron-forge/cli": "^6.0.4",
"@electron-forge/maker-deb": "^6.0.4", "@electron-forge/maker-deb": "^6.0.4",
"@electron-forge/maker-rpm": "^6.0.4", "@electron-forge/maker-rpm": "^6.0.4",

View File

@ -30,6 +30,7 @@ import {HorizontalRulePlugin} from "@lexical/react/LexicalHorizontalRulePlugin"
import InlineImagePlugin from "./plugins/InlineImagePlugin"; import InlineImagePlugin from "./plugins/InlineImagePlugin";
import {TablePlugin} from "@lexical/react/LexicalTablePlugin"; import {TablePlugin} from "@lexical/react/LexicalTablePlugin";
import TableCellActionMenuPlugin from './plugins/TableActionMenuPlugin'; import TableCellActionMenuPlugin from './plugins/TableActionMenuPlugin';
import ExcalidrawPlugin from "./plugins/ExcalidrawPlugin";
function Placeholder() { function Placeholder() {
return <div className="editor-placeholder">Enter some rich text...</div>; return <div className="editor-placeholder">Enter some rich text...</div>;
} }
@ -80,8 +81,6 @@ export default function Hlexical(props) {
/> />
{/* 表格单元格操作 */} {/* 表格单元格操作 */}
<TableCellActionMenuPlugin/> <TableCellActionMenuPlugin/>
<TabIndentationPlugin /> <TabIndentationPlugin />
{/*markdown 快捷键*/} {/*markdown 快捷键*/}
<MarkdownShortcutPlugin transformers={TRANSFORMERS}/> <MarkdownShortcutPlugin transformers={TRANSFORMERS}/>
@ -94,13 +93,9 @@ export default function Hlexical(props) {
{/*页分割线*/} {/*页分割线*/}
{/*目录加载*/} {/*目录加载*/}
{/* 表格加载 */}
{/*<TableOfContentsPlugin />*/} {/* 画图 */}
{/*<LexicalTableOfContents>*/} <ExcalidrawPlugin />
{/* {(tableOfContentsArray) => {*/}
{/* return <MyCustomTableOfContetsPlugin tableOfContents={tableOfContentsArray} />;*/}
{/* }}*/}
{/*</LexicalTableOfContents>*/}
<ImportFilePlugin filePath={props.filePath}/> <ImportFilePlugin filePath={props.filePath}/>
<SaveFilePlugin filePath={props.filePath}/> <SaveFilePlugin filePath={props.filePath}/>

View File

@ -0,0 +1,222 @@
import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext';
import {useLexicalNodeSelection} from '@lexical/react/useLexicalNodeSelection';
import {mergeRegister} from '@lexical/utils';
import {
$getNodeByKey,
$getSelection,
$isNodeSelection,
CLICK_COMMAND,
COMMAND_PRIORITY_LOW,
KEY_BACKSPACE_COMMAND,
KEY_DELETE_COMMAND,
} from 'lexical';
import {useCallback, useEffect, useMemo, useRef, useState} from 'react';
import * as React from 'react';
import ImageResizer from '../../plugins/Input/ImageResizer';
import {$isExcalidrawNode} from '.';
import ExcalidrawImage from './ExcalidrawImage';
import ExcalidrawModal from './ExcalidrawModal';
export default function ExcalidrawComponent({
nodeKey,
data,
}) {
const [editor] = useLexicalComposerContext();
const [isModalOpen, setModalOpen] = useState (
data === '[]' && editor.isEditable(),
);
const imageContainerRef = useRef(null);
const buttonRef = useRef(null);
const captionButtonRef = useRef(null);
const [isSelected, setSelected, clearSelection] =
useLexicalNodeSelection(nodeKey);
const [isResizing, setIsResizing] = useState(false);
const onDelete = useCallback(
(event) => {
if (isSelected && $isNodeSelection($getSelection())) {
event.preventDefault();
editor.update(() => {
const node = $getNodeByKey(nodeKey);
if ($isExcalidrawNode(node)) {
node.remove();
}
});
}
return false;
},
[editor, isSelected, nodeKey],
);
// Set editor to readOnly if excalidraw is open to prevent unwanted changes
useEffect(() => {
if (isModalOpen) {
editor.setEditable(false);
} else {
editor.setEditable(true);
}
}, [isModalOpen, editor]);
useEffect(() => {
return mergeRegister(
editor.registerCommand(
CLICK_COMMAND,
(event) => {
const buttonElem = buttonRef.current;
const eventTarget = event.target;
if (isResizing) {
return true;
}
if (buttonElem !== null && buttonElem.contains(eventTarget)) {
if (!event.shiftKey) {
clearSelection();
}
setSelected(!isSelected);
if (event.detail > 1) {
setModalOpen(true);
}
return true;
}
return false;
},
COMMAND_PRIORITY_LOW,
),
editor.registerCommand(
KEY_DELETE_COMMAND,
onDelete,
COMMAND_PRIORITY_LOW,
),
editor.registerCommand(
KEY_BACKSPACE_COMMAND,
onDelete,
COMMAND_PRIORITY_LOW,
),
);
}, [clearSelection, editor, isSelected, isResizing, onDelete, setSelected]);
const deleteNode = useCallback(() => {
setModalOpen(false);
return editor.update(() => {
const node = $getNodeByKey(nodeKey);
if ($isExcalidrawNode(node)) {
node.remove();
}
});
}, [editor, nodeKey]);
const setData = (
els,
aps,
fls,
) => {
if (!editor.isEditable()) {
return;
}
return editor.update(() => {
const node = $getNodeByKey(nodeKey);
if ($isExcalidrawNode(node)) {
if (els.length > 0 || Object.keys(fls).length > 0) {
node.setData(
JSON.stringify({
appState: aps,
elements: els,
files: fls,
}),
);
} else {
node.remove();
}
}
});
};
const onResizeStart = () => {
setIsResizing(true);
};
const onResizeEnd = (
nextWidth,
nextHeight,
) => {
// Delay hiding the resize bars for click case
setTimeout(() => {
setIsResizing(false);
}, 200);
editor.update(() => {
const node = $getNodeByKey(nodeKey);
if ($isExcalidrawNode(node)) {
node.setWidth(nextWidth);
node.setHeight(nextHeight);
}
});
};
const openModal = useCallback(() => {
setModalOpen(true);
}, []);
const {
elements = [],
files = {},
appState = {},
} = useMemo(() => JSON.parse(data), [data]);
return (
<>
<ExcalidrawModal
initialElements={elements}
initialFiles={files}
initialAppState={appState}
isShown={isModalOpen}
onDelete={deleteNode}
onClose={() => setModalOpen(false)}
onSave={(els, aps, fls) => {
editor.setEditable(true);
setData(els, aps, fls);
setModalOpen(false);
}}
closeOnClickOutside={false}
/>
{elements.length > 0 && (
<button
ref={buttonRef}
className={`excalidraw-button ${isSelected ? 'selected' : ''}`}>
<ExcalidrawImage
imageContainerRef={imageContainerRef}
className="image"
elements={elements}
files={files}
appState={appState}
/>
{isSelected && (
<div
className="image-edit-button"
role="button"
tabIndex={0}
onMouseDown={(event) => event.preventDefault()}
onClick={openModal}
/>
)}
{(isSelected || isResizing) && (
<ImageResizer
buttonRef={captionButtonRef}
showCaption={true}
setShowCaption={() => null}
imageRef={imageContainerRef}
editor={editor}
onResizeStart={onResizeStart}
onResizeEnd={onResizeEnd}
captionsEnabled={true}
/>
)}
</button>
)}
</>
);
}

View File

@ -0,0 +1,60 @@
import {exportToSvg} from '@excalidraw/excalidraw';
import * as React from 'react';
import {useEffect, useState} from 'react';
const removeStyleFromSvg_HACK = (svg) => {
const styleTag = svg?.firstElementChild?.firstElementChild;
// Generated SVG is getting double-sized by height and width attributes
// We want to match the real size of the SVG element
const viewBox = svg.getAttribute('viewBox');
if (viewBox != null) {
const viewBoxDimensions = viewBox.split(' ');
svg.setAttribute('width', viewBoxDimensions[2]);
svg.setAttribute('height', viewBoxDimensions[3]);
}
if (styleTag && styleTag.tagName === 'style') {
styleTag.remove();
}
};
/**
* @explorer-desc
* A component for rendering Excalidraw elements as a static image
*/
export default function ExcalidrawImage({
elements,
files,
imageContainerRef,
appState,
rootClassName = null,
}) {
const [Svg, setSvg] = useState(null);
useEffect(() => {
const setContent = async () => {
const svg = await exportToSvg({
appState,
elements,
files,
});
removeStyleFromSvg_HACK(svg);
svg.setAttribute('width', '100%');
svg.setAttribute('height', '100%');
svg.setAttribute('display', 'block');
setSvg(svg);
};
setContent();
}, [elements, files, appState]);
return (
<div
ref={imageContainerRef}
className={rootClassName ?? ''}
dangerouslySetInnerHTML={{__html: Svg?.outerHTML ?? ''}}
/>
);
}

View File

@ -0,0 +1,196 @@
import './ExcalidrawModal.less';
import {Excalidraw} from '@excalidraw/excalidraw';
import * as React from 'react';
import {ReactPortal, useEffect, useLayoutEffect, useRef, useState} from 'react';
import {createPortal} from 'react-dom';
import Button from '../../plugins/Input/Button';
import Modal from '../../plugins/Input/Modal';
export const useCallbackRefState = () => {
const [refValue, setRefValue] =
React.useState(null);
const refCallback = React.useCallback(
(value) => setRefValue(value),
[],
);
return [refValue, refCallback];
};
export default function ExcalidrawModal({
closeOnClickOutside = false,
onSave,
initialElements,
initialAppState,
initialFiles,
isShown = false,
onDelete,
onClose,
}) {
const excaliDrawModelRef = useRef(null);
const [excalidrawAPI, excalidrawAPIRefCallback] = useCallbackRefState();
const [discardModalOpen, setDiscardModalOpen] = useState(false);
const [elements, setElements] =
useState(initialElements);
const [files, setFiles] = useState(initialFiles);
useEffect(() => {
if (excaliDrawModelRef.current !== null) {
excaliDrawModelRef.current.focus();
}
}, []);
useEffect(() => {
let modalOverlayElement= null;
const clickOutsideHandler = (event) => {
const target = event.target;
if (
excaliDrawModelRef.current !== null &&
!excaliDrawModelRef.current.contains(target) &&
closeOnClickOutside
) {
onDelete();
}
};
if (excaliDrawModelRef.current !== null) {
modalOverlayElement = excaliDrawModelRef.current?.parentElement;
if (modalOverlayElement !== null) {
modalOverlayElement?.addEventListener('click', clickOutsideHandler);
}
}
return () => {
if (modalOverlayElement !== null) {
modalOverlayElement?.removeEventListener('click', clickOutsideHandler);
}
};
}, [closeOnClickOutside, onDelete]);
useLayoutEffect(() => {
const currentModalRef = excaliDrawModelRef.current;
const onKeyDown = (event) => {
if (event.key === 'Escape') {
onDelete();
}
};
if (currentModalRef !== null) {
currentModalRef.addEventListener('keydown', onKeyDown);
}
return () => {
if (currentModalRef !== null) {
currentModalRef.removeEventListener('keydown', onKeyDown);
}
};
}, [elements, files, onDelete]);
const save = () => {
if (elements.filter((el) => !el.isDeleted).length > 0) {
const appState = excalidrawAPI?.getAppState();
// We only need a subset of the state
const partialState = {
exportBackground: appState.exportBackground,
exportScale: appState.exportScale,
exportWithDarkMode: appState.theme === 'dark',
isBindingEnabled: appState.isBindingEnabled,
isLoading: appState.isLoading,
name: appState.name,
theme: appState.theme,
viewBackgroundColor: appState.viewBackgroundColor,
viewModeEnabled: appState.viewModeEnabled,
zenModeEnabled: appState.zenModeEnabled,
zoom: appState.zoom,
};
onSave(elements, partialState, files);
} else {
// delete node if the scene is clear
onDelete();
}
};
const discard = () => {
if (elements.filter((el) => !el.isDeleted).length === 0) {
// delete node if the scene is clear
onDelete();
} else {
//Otherwise, show confirmation dialog before closing
setDiscardModalOpen(true);
}
};
function ShowDiscardDialog() {
return (
<Modal
title="Discard"
onClose={() => {
setDiscardModalOpen(false);
}}
closeOnClickOutside={false}>
Are you sure you want to discard the changes?
<div className="ExcalidrawModal__discardModal">
<Button
onClick={() => {
setDiscardModalOpen(false);
onClose();
}}>
Discard
</Button>{' '}
<Button
onClick={() => {
setDiscardModalOpen(false);
}}>
Cancel
</Button>
</div>
</Modal>
);
}
if (isShown === false) {
return null;
}
const onChange = (
els,
_,
fls,
) => {
setElements(els);
setFiles(fls);
};
return createPortal(
<div className="ExcalidrawModal__overlay" role="dialog">
<div
className="ExcalidrawModal__modal"
ref={excaliDrawModelRef}
tabIndex={-1}>
<div className="ExcalidrawModal__row">
{discardModalOpen && <ShowDiscardDialog />}
<Excalidraw
onChange={onChange}
excalidrawAPI={excalidrawAPIRefCallback}
initialData={{
appState: initialAppState || {isLoading: false},
elements: initialElements,
files: initialFiles,
}}
/>
<div className="ExcalidrawModal__actions">
<button className="action-button" onClick={discard}>
Discard
</button>
<button className="action-button" onClick={save}>
Save
</button>
</div>
</div>
</div>
</div>,
document.body,
);
}

View File

@ -0,0 +1,62 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
*
*/
.ExcalidrawModal__overlay {
display: flex;
align-items: center;
position: fixed;
flex-direction: column;
top: 0px;
bottom: 0px;
left: 0px;
right: 0px;
flex-grow: 0px;
flex-shrink: 1px;
z-index: 100;
background-color: rgba(40, 40, 40, 0.6);
}
.ExcalidrawModal__actions {
text-align: end;
position: absolute;
right: 5px;
top: 5px;
z-index: 1;
}
.ExcalidrawModal__actions button {
background-color: #fff;
border-radius: 5px;
}
.ExcalidrawModal__row {
position: relative;
padding: 40px 5px 5px;
width: 70vw;
height: 70vh;
border-radius: 8px;
box-shadow: 0 12px 28px 0 rgba(0, 0, 0, 0.2), 0 2px 4px 0 rgba(0, 0, 0, 0.1),
inset 0 0 0 1px rgba(255, 255, 255, 0.5);
}
.ExcalidrawModal__row > div {
border-radius: 5px;
}
.ExcalidrawModal__modal {
position: relative;
z-index: 10;
top: 50px;
width: auto;
left: 0;
display: flex;
justify-content: center;
align-items: center;
border-radius: 8px;
background-color: #eee;
}
.ExcalidrawModal__discardModal {
margin-top: 60px;
text-align: center;
}

View File

@ -0,0 +1,145 @@
import {DecoratorNode} from 'lexical';
import * as React from 'react';
import {Suspense} from 'react';
const ExcalidrawComponent = React.lazy(() => import('./ExcalidrawComponent'));
function convertExcalidrawElement(
domNode,
){
const excalidrawData = domNode.getAttribute('data-lexical-excalidraw-json');
if (excalidrawData) {
const node = $createExcalidrawNode();
node.__data = excalidrawData;
return {
node,
};
}
return null;
}
export class ExcalidrawNode extends DecoratorNode {
__data;
__width;
__height;
static getType() {
return 'excalidraw';
}
static clone(node) {
return new ExcalidrawNode(node.__data, node.__key);
}
static importJSON(serializedNode) {
return new ExcalidrawNode(serializedNode.data);
}
exportJSON() {
return {
data: this.__data,
height: this.__height,
type: 'excalidraw',
version: 1,
width: this.__width,
};
}
constructor(data = '[]', key) {
super(key);
this.__data = data;
this.__width = 'inherit';
this.__height = 'inherit';
}
// View
createDOM(config) {
const span = document.createElement('span');
const theme = config.theme;
const className = theme.image;
span.style.width =
this.__width === 'inherit' ? 'inherit' : `${this.__width}px`;
span.style.height =
this.__height === 'inherit' ? 'inherit' : `${this.__height}px`;
if (className !== undefined) {
span.className = className;
}
return span;
}
updateDOM() {
return false;
}
static importDOM() {
return {
span: (domNode ) => {
if (!domNode.hasAttribute('data-lexical-excalidraw-json')) {
return null;
}
return {
conversion: convertExcalidrawElement,
priority: 1,
};
},
};
}
exportDOM(editor) {
const element = document.createElement('span');
element.style.display = 'inline-block';
const content = editor.getElementByKey(this.getKey());
if (content !== null) {
const svg = content.querySelector('svg');
if (svg !== null) {
element.innerHTML = svg.outerHTML;
}
}
element.style.width =
this.__width === 'inherit' ? 'inherit' : `${this.__width}px`;
element.style.height =
this.__height === 'inherit' ? 'inherit' : `${this.__height}px`;
element.setAttribute('data-lexical-excalidraw-json', this.__data);
return {element};
}
setData(data) {
const self = this.getWritable();
self.__data = data;
}
setWidth(width) {
const self = this.getWritable();
self.__width = width;
}
setHeight(height) {
const self = this.getWritable();
self.__height = height;
}
decorate(editor, config) {
return (
<Suspense fallback={null}>
<ExcalidrawComponent nodeKey={this.getKey()} data={this.__data} />
</Suspense>
);
}
}
export function $createExcalidrawNode() {
return new ExcalidrawNode();
}
export function $isExcalidrawNode(
node,
) {
return node instanceof ExcalidrawNode;
}

View File

@ -251,7 +251,7 @@ export default function ImageComponent({
</Placeholder>} </Placeholder>}
ErrorBoundary={LexicalErrorBoundary} ErrorBoundary={LexicalErrorBoundary}
/> />
{showNestedEditorTreeView === true ? <TreeViewPlugin/> : null} { <TreeViewPlugin/>}
</LexicalNestedComposer> </LexicalNestedComposer>
</div>)} </div>)}
{resizable && $isNodeSelection(selection) && isFocused && (<ImageResizer {resizable && $isNodeSelection(selection) && isFocused && (<ImageResizer

View File

@ -6,7 +6,10 @@ import {ImageNode} from "./ImageNode";
import {HorizontalRuleNode} from "@lexical/react/LexicalHorizontalRuleNode"; import {HorizontalRuleNode} from "@lexical/react/LexicalHorizontalRuleNode";
import {InlineImageNode} from "./InlineImageNode"; import {InlineImageNode} from "./InlineImageNode";
import {TableNode,TableCellNode, TableRowNode} from "@lexical/table"; import {TableNode,TableCellNode, TableRowNode} from "@lexical/table";
import {ExcalidrawNode} from "./ExcalidrawNode";
import {EmojiNode} from "./EmojiNode";
import {HashtagNode} from '@lexical/hashtag';
import {KeywordNode} from "./KeywordNode";
const UsefulNodes=[ const UsefulNodes=[
HeadingNode, HeadingNode,
ListNode, ListNode,
@ -22,5 +25,9 @@ const UsefulNodes=[
InlineImageNode, InlineImageNode,
LinkNode, LinkNode,
HorizontalRuleNode, HorizontalRuleNode,
ExcalidrawNode,
EmojiNode,
HashtagNode,
KeywordNode
] ]
export default UsefulNodes; export default UsefulNodes;

View File

@ -0,0 +1,48 @@
import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext';
import {$wrapNodeInElement} from '@lexical/utils';
import {
$createParagraphNode,
$insertNodes,
$isRootOrShadowRoot,
COMMAND_PRIORITY_EDITOR,
createCommand,
LexicalCommand,
} from 'lexical';
import {useEffect} from 'react';
import {
$createExcalidrawNode,
ExcalidrawNode,
} from '../../nodes/ExcalidrawNode';
export const INSERT_EXCALIDRAW_COMMAND = createCommand(
'INSERT_EXCALIDRAW_COMMAND',
);
export default function ExcalidrawPlugin() {
const [editor] = useLexicalComposerContext();
useEffect(() => {
if (!editor.hasNodes([ExcalidrawNode])) {
throw new Error(
'ExcalidrawPlugin: ExcalidrawNode not registered on editor',
);
}
return editor.registerCommand(
INSERT_EXCALIDRAW_COMMAND,
() => {
const excalidrawNode = $createExcalidrawNode();
$insertNodes([excalidrawNode]);
if ($isRootOrShadowRoot(excalidrawNode.getParentOrThrow())) {
$wrapNodeInElement(excalidrawNode, $createParagraphNode).selectEnd();
}
return true;
},
COMMAND_PRIORITY_EDITOR,
);
}, [editor]);
return null;
}

View File

@ -46,6 +46,7 @@ import useModal from "../hook/userModal";
import {InsertTableDialog} from "./TablePlugin"; import {InsertTableDialog} from "./TablePlugin";
import {INSERT_HORIZONTAL_RULE_COMMAND} from "@lexical/react/LexicalHorizontalRuleNode"; import {INSERT_HORIZONTAL_RULE_COMMAND} from "@lexical/react/LexicalHorizontalRuleNode";
import {InsertInlineImageDialog} from "./InlineImagePlugin"; import {InsertInlineImageDialog} from "./InlineImagePlugin";
import {INSERT_EXCALIDRAW_COMMAND} from "./ExcalidrawPlugin";
const LowPriority = 1; const LowPriority = 1;
@ -753,17 +754,6 @@ export default function ToolbarPlugin() {
<i className="icon image"/> <i className="icon image"/>
<span className="text">行内图片</span> <span className="text">行内图片</span>
</DropDownItem> </DropDownItem>
<DropDownItem
onClick={() =>
insertGifOnClick({
altText: 'Cat typing on a laptop',
src: catTypingGif,
})
}
className="item">
<i className="icon gif"/>
<span className="text">动态图片</span>
</DropDownItem>
<DropDownItem <DropDownItem
onClick={() => { onClick={() => {
activeEditor.dispatchCommand( activeEditor.dispatchCommand(