diff --git a/package-lock.json b/package-lock.json index 46c3439..518ad1b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -25,6 +25,7 @@ "@electron-forge/maker-rpm": "^6.0.4", "@electron-forge/maker-squirrel": "^6.0.4", "@electron-forge/maker-zip": "^6.0.4", + "@excalidraw/excalidraw": "^0.17.0", "@lexical/react": "^0.12.6", "@reduxjs/toolkit": "^1.9.3", "@testing-library/jest-dom": "^5.16.5", @@ -4071,6 +4072,16 @@ "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": { "version": "1.1.3", "resolved": "https://registry.npmmirror.com/@gar/promisify/-/promisify-1.1.3.tgz", diff --git a/package.json b/package.json index 8bdd4f5..11c6f81 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ "devDependencies": { "@ant-design/pro-components": "^2.3.57", "@craco/craco": "^6.0.0", + "@excalidraw/excalidraw": "^0.17.0", "@electron-forge/cli": "^6.0.4", "@electron-forge/maker-deb": "^6.0.4", "@electron-forge/maker-rpm": "^6.0.4", diff --git a/src/pages/Note/Hlexical/index.jsx b/src/pages/Note/Hlexical/index.jsx index 048f450..56da82e 100644 --- a/src/pages/Note/Hlexical/index.jsx +++ b/src/pages/Note/Hlexical/index.jsx @@ -30,6 +30,7 @@ import {HorizontalRulePlugin} from "@lexical/react/LexicalHorizontalRulePlugin" import InlineImagePlugin from "./plugins/InlineImagePlugin"; import {TablePlugin} from "@lexical/react/LexicalTablePlugin"; import TableCellActionMenuPlugin from './plugins/TableActionMenuPlugin'; +import ExcalidrawPlugin from "./plugins/ExcalidrawPlugin"; function Placeholder() { return
Enter some rich text...
; } @@ -80,8 +81,6 @@ export default function Hlexical(props) { /> {/* 表格单元格操作 */} - - {/*markdown 快捷键*/} @@ -94,13 +93,9 @@ export default function Hlexical(props) { {/*页分割线*/} {/*目录加载*/} - {/* 表格加载 */} - {/**/} - {/**/} - {/* {(tableOfContentsArray) => {*/} - {/* return ;*/} - {/* }}*/} - {/**/} + + {/* 画图 */} + diff --git a/src/pages/Note/Hlexical/nodes/ExcalidrawNode/ExcalidrawComponent.jsx b/src/pages/Note/Hlexical/nodes/ExcalidrawNode/ExcalidrawComponent.jsx new file mode 100644 index 0000000..4d4cadd --- /dev/null +++ b/src/pages/Note/Hlexical/nodes/ExcalidrawNode/ExcalidrawComponent.jsx @@ -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 ( + <> + setModalOpen(false)} + onSave={(els, aps, fls) => { + editor.setEditable(true); + setData(els, aps, fls); + setModalOpen(false); + }} + closeOnClickOutside={false} + /> + {elements.length > 0 && ( + + )} + + ); +} diff --git a/src/pages/Note/Hlexical/nodes/ExcalidrawNode/ExcalidrawImage.jsx b/src/pages/Note/Hlexical/nodes/ExcalidrawNode/ExcalidrawImage.jsx new file mode 100644 index 0000000..9d8dfcd --- /dev/null +++ b/src/pages/Note/Hlexical/nodes/ExcalidrawNode/ExcalidrawImage.jsx @@ -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 ( +
+ ); +} diff --git a/src/pages/Note/Hlexical/nodes/ExcalidrawNode/ExcalidrawModal.jsx b/src/pages/Note/Hlexical/nodes/ExcalidrawNode/ExcalidrawModal.jsx new file mode 100644 index 0000000..47e7335 --- /dev/null +++ b/src/pages/Note/Hlexical/nodes/ExcalidrawNode/ExcalidrawModal.jsx @@ -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 ( + { + setDiscardModalOpen(false); + }} + closeOnClickOutside={false}> + Are you sure you want to discard the changes? +
+ {' '} + +
+
+ ); + } + + if (isShown === false) { + return null; + } + + const onChange = ( + els, + _, + fls, + ) => { + setElements(els); + setFiles(fls); + }; + + return createPortal( +
+
+
+ {discardModalOpen && } + +
+ + +
+
+
+
, + document.body, + ); +} diff --git a/src/pages/Note/Hlexical/nodes/ExcalidrawNode/ExcalidrawModal.less b/src/pages/Note/Hlexical/nodes/ExcalidrawNode/ExcalidrawModal.less new file mode 100644 index 0000000..7438d60 --- /dev/null +++ b/src/pages/Note/Hlexical/nodes/ExcalidrawNode/ExcalidrawModal.less @@ -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; +} diff --git a/src/pages/Note/Hlexical/nodes/ExcalidrawNode/index.jsx b/src/pages/Note/Hlexical/nodes/ExcalidrawNode/index.jsx new file mode 100644 index 0000000..d3f1d7b --- /dev/null +++ b/src/pages/Note/Hlexical/nodes/ExcalidrawNode/index.jsx @@ -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 ( + + + + ); + } +} + +export function $createExcalidrawNode() { + return new ExcalidrawNode(); +} + +export function $isExcalidrawNode( + node, +) { + return node instanceof ExcalidrawNode; +} diff --git a/src/pages/Note/Hlexical/nodes/ImageNode/ImageComponent/index.jsx b/src/pages/Note/Hlexical/nodes/ImageNode/ImageComponent/index.jsx index 1d2787e..a15337f 100644 --- a/src/pages/Note/Hlexical/nodes/ImageNode/ImageComponent/index.jsx +++ b/src/pages/Note/Hlexical/nodes/ImageNode/ImageComponent/index.jsx @@ -251,7 +251,7 @@ export default function ImageComponent({ } ErrorBoundary={LexicalErrorBoundary} /> - {showNestedEditorTreeView === true ? : null} + { }
)} {resizable && $isNodeSelection(selection) && isFocused && ( { + 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; +} diff --git a/src/pages/Note/Hlexical/plugins/ToolbarPlugin.js b/src/pages/Note/Hlexical/plugins/ToolbarPlugin.js index 3e81d9f..f0f812f 100644 --- a/src/pages/Note/Hlexical/plugins/ToolbarPlugin.js +++ b/src/pages/Note/Hlexical/plugins/ToolbarPlugin.js @@ -46,6 +46,7 @@ import useModal from "../hook/userModal"; import {InsertTableDialog} from "./TablePlugin"; import {INSERT_HORIZONTAL_RULE_COMMAND} from "@lexical/react/LexicalHorizontalRuleNode"; import {InsertInlineImageDialog} from "./InlineImagePlugin"; +import {INSERT_EXCALIDRAW_COMMAND} from "./ExcalidrawPlugin"; const LowPriority = 1; @@ -753,17 +754,6 @@ export default function ToolbarPlugin() { 行内图片 - - insertGifOnClick({ - altText: 'Cat typing on a laptop', - src: catTypingGif, - }) - } - className="item"> - - 动态图片 - { activeEditor.dispatchCommand(