From 2aedb1b6c04465dbcd45ac6e0f0b1542d3633f7d Mon Sep 17 00:00:00 2001 From: Lei OT Date: Wed, 23 Oct 2024 10:49:38 +0800 Subject: [PATCH] feat: Image Node; Resizer --- src/components/LexicalEditor/Index.jsx | 14 +- .../LexicalEditor/hooks/useFlashMessage.tsx | 16 + .../LexicalEditor/hooks/useModal.tsx | 60 +++ .../LexicalEditor/hooks/useReport.ts | 67 +++ .../InlineImageNode/InlineImageComponent.tsx | 410 ++++++++++++++++++ .../nodes/InlineImageNode/InlineImageNode.css | 94 ++++ .../nodes/InlineImageNode/InlineImageNode.tsx | 294 +++++++++++++ .../plugins/ImagesPlugin/index.tsx | 10 +- .../plugins/InlineImagePlugin/index.tsx | 346 +++++++++++++++ .../LexicalEditor/plugins/ToolbarPlugin.jsx | 85 ++-- src/components/LexicalEditor/styles.css | 226 +++++++++- .../LexicalEditor/ui/ImageResizer.tsx | 4 +- src/components/LexicalEditor/ui/Modal.css | 62 +++ src/components/LexicalEditor/ui/Modal.tsx | 106 +++++ src/components/LexicalEditor/ui/Select.css | 42 ++ src/components/LexicalEditor/ui/Select.tsx | 34 ++ 16 files changed, 1820 insertions(+), 50 deletions(-) create mode 100644 src/components/LexicalEditor/hooks/useFlashMessage.tsx create mode 100644 src/components/LexicalEditor/hooks/useModal.tsx create mode 100644 src/components/LexicalEditor/hooks/useReport.ts create mode 100644 src/components/LexicalEditor/nodes/InlineImageNode/InlineImageComponent.tsx create mode 100644 src/components/LexicalEditor/nodes/InlineImageNode/InlineImageNode.css create mode 100644 src/components/LexicalEditor/nodes/InlineImageNode/InlineImageNode.tsx create mode 100644 src/components/LexicalEditor/plugins/InlineImagePlugin/index.tsx create mode 100644 src/components/LexicalEditor/ui/Modal.css create mode 100644 src/components/LexicalEditor/ui/Modal.tsx create mode 100644 src/components/LexicalEditor/ui/Select.css create mode 100644 src/components/LexicalEditor/ui/Select.tsx diff --git a/src/components/LexicalEditor/Index.jsx b/src/components/LexicalEditor/Index.jsx index e32aeae..fb83883 100644 --- a/src/components/LexicalEditor/Index.jsx +++ b/src/components/LexicalEditor/Index.jsx @@ -27,8 +27,11 @@ import ListMaxIndentLevelPlugin from "./plugins/ListMaxIndentLevelPlugin"; import CodeHighlightPlugin from "./plugins/CodeHighlightPlugin"; import AutoLinkPlugin from "./plugins/AutoLinkPlugin"; import TabFocusPlugin from './plugins/TabFocusPlugin'; -// import ImagesPlugin from './plugins/ImagesPlugin'; +import ImagesPlugin from './plugins/ImagesPlugin'; +import InlineImagePlugin from './plugins/InlineImagePlugin'; import { ImageNode } from './nodes/ImageNode'; +import {InlineImageNode} from './nodes/InlineImageNode/InlineImageNode'; + import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'; // import { useLexicalEditable } from '@lexical/react/useLexicalEditable'; @@ -68,7 +71,7 @@ const editorConfig = { AutoLinkNode, LinkNode, HorizontalRuleNode, - ImageNode, + ImageNode,InlineImageNode, ] }; @@ -131,7 +134,6 @@ function MyOnChangePlugin({ onChange }) { return null; } export default function Editor({ isRichText, onChange, initialValue, ...props }) { - // const isEditable = useLexicalEditable(); return (
@@ -150,14 +152,14 @@ export default function Editor({ isRichText, onChange, initialValue, ...props }) - + - {/* */} - {/* */} + +
diff --git a/src/components/LexicalEditor/hooks/useFlashMessage.tsx b/src/components/LexicalEditor/hooks/useFlashMessage.tsx new file mode 100644 index 0000000..9a24fa6 --- /dev/null +++ b/src/components/LexicalEditor/hooks/useFlashMessage.tsx @@ -0,0 +1,16 @@ +/** + * 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. + * + */ + +import { + type ShowFlashMessage, + useFlashMessageContext, +} from '../context/FlashMessageContext'; + +export default function useFlashMessage(): ShowFlashMessage { + return useFlashMessageContext(); +} diff --git a/src/components/LexicalEditor/hooks/useModal.tsx b/src/components/LexicalEditor/hooks/useModal.tsx new file mode 100644 index 0000000..81f61b9 --- /dev/null +++ b/src/components/LexicalEditor/hooks/useModal.tsx @@ -0,0 +1,60 @@ +/** + * 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. + * + */ + +import {useCallback, useMemo, useState} from 'react'; +import * as React from 'react'; + +import Modal from './../ui/Modal'; + +export default function useModal(): [ + JSX.Element | null, + (title: string, showModal: (onClose: () => void) => JSX.Element) => void, +] { + const [modalContent, setModalContent] = useState(null); + + const onClose = useCallback(() => { + setModalContent(null); + }, []); + + const modal = useMemo(() => { + if (modalContent === null) { + return null; + } + const {title, content, closeOnClickOutside} = modalContent; + return ( + + {content} + + ); + }, [modalContent, onClose]); + + const showModal = useCallback( + ( + title: string, + // eslint-disable-next-line no-shadow + getContent: (onClose: () => void) => JSX.Element, + closeOnClickOutside = false, + ) => { + setModalContent({ + closeOnClickOutside, + content: getContent(onClose), + title, + }); + }, + [onClose], + ); + + return [modal, showModal]; +} diff --git a/src/components/LexicalEditor/hooks/useReport.ts b/src/components/LexicalEditor/hooks/useReport.ts new file mode 100644 index 0000000..6f89d9f --- /dev/null +++ b/src/components/LexicalEditor/hooks/useReport.ts @@ -0,0 +1,67 @@ +/** + * 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. + * + */ + +import {useCallback, useEffect, useRef} from 'react'; + +const getElement = (): HTMLElement => { + let element = document.getElementById('report-container'); + + if (element === null) { + element = document.createElement('div'); + element.id = 'report-container'; + element.style.position = 'fixed'; + element.style.top = '50%'; + element.style.left = '50%'; + element.style.fontSize = '32px'; + element.style.transform = 'translate(-50%, -50px)'; + element.style.padding = '20px'; + element.style.background = 'rgba(240, 240, 240, 0.4)'; + element.style.borderRadius = '20px'; + + if (document.body) { + document.body.appendChild(element); + } + } + + return element; +}; + +export default function useReport(): ( + arg0: string, +) => ReturnType { + const timer = useRef | null>(null); + const cleanup = useCallback(() => { + if (timer.current !== null) { + clearTimeout(timer.current); + timer.current = null; + } + + if (document.body) { + document.body.removeChild(getElement()); + } + }, []); + + useEffect(() => { + return cleanup; + }, [cleanup]); + + return useCallback( + (content) => { + // eslint-disable-next-line no-console + console.log(content); + const element = getElement(); + if (timer.current !== null) { + clearTimeout(timer.current); + } + element.innerHTML = content; + timer.current = setTimeout(cleanup, 1000); + return timer.current; + }, + [cleanup], + ); +} diff --git a/src/components/LexicalEditor/nodes/InlineImageNode/InlineImageComponent.tsx b/src/components/LexicalEditor/nodes/InlineImageNode/InlineImageComponent.tsx new file mode 100644 index 0000000..7cc53ed --- /dev/null +++ b/src/components/LexicalEditor/nodes/InlineImageNode/InlineImageComponent.tsx @@ -0,0 +1,410 @@ +/** + * 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. + * + */ +import type {Position} from './InlineImageNode'; +import type {BaseSelection, LexicalEditor, NodeKey} from 'lexical'; + +import './InlineImageNode.css'; + +import {AutoFocusPlugin} from '@lexical/react/LexicalAutoFocusPlugin'; +import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; +import {LexicalErrorBoundary} from '@lexical/react/LexicalErrorBoundary'; +import {LexicalNestedComposer} from '@lexical/react/LexicalNestedComposer'; +import {RichTextPlugin} from '@lexical/react/LexicalRichTextPlugin'; +import {useLexicalNodeSelection} from '@lexical/react/useLexicalNodeSelection'; +import {mergeRegister} from '@lexical/utils'; +import { + $getNodeByKey, + $getSelection, + $isNodeSelection, + $setSelection, + CLICK_COMMAND, + COMMAND_PRIORITY_LOW, + DRAGSTART_COMMAND, + KEY_BACKSPACE_COMMAND, + KEY_DELETE_COMMAND, + KEY_ENTER_COMMAND, + KEY_ESCAPE_COMMAND, + SELECTION_CHANGE_COMMAND, +} from 'lexical'; +import * as React from 'react'; +import {Suspense, useCallback, useEffect, useRef, useState} from 'react'; + +import useModal from '../../hooks/useModal'; +import LinkPlugin from '../../plugins/LinkPlugin'; +import Button from '../../ui/Button'; +import ContentEditable from '../../ui/ContentEditable'; +import {DialogActions} from '../../ui/Dialog'; +import Select from '../../ui/Select'; +import TextInput from '../../ui/TextInput'; +import {$isInlineImageNode, InlineImageNode} from './InlineImageNode'; + +const imageCache = new Set(); + +function useSuspenseImage(src: string) { + if (!imageCache.has(src)) { + throw new Promise((resolve) => { + const img = new Image(); + img.src = src; + img.onload = () => { + imageCache.add(src); + resolve(null); + }; + }); + } +} + +function LazyImage({ + altText, + className, + imageRef, + src, + width, + height, + position, +}: { + altText: string; + className: string | null; + height: 'inherit' | number; + imageRef: {current: null | HTMLImageElement}; + src: string; + width: 'inherit' | number; + position: Position; +}): JSX.Element { + useSuspenseImage(src); + return ( + {altText} + ); +} + +export function UpdateInlineImageDialog({ + activeEditor, + nodeKey, + onClose, +}: { + activeEditor: LexicalEditor; + nodeKey: NodeKey; + onClose: () => void; +}): JSX.Element { + const editorState = activeEditor.getEditorState(); + const node = editorState.read( + () => $getNodeByKey(nodeKey) as InlineImageNode, + ); + const [altText, setAltText] = useState(node.getAltText()); + const [showCaption, setShowCaption] = useState(node.getShowCaption()); + const [position, setPosition] = useState(node.getPosition()); + + const handleShowCaptionChange = (e: React.ChangeEvent) => { + setShowCaption(e.target.checked); + }; + + const handlePositionChange = (e: React.ChangeEvent) => { + setPosition(e.target.value as Position); + }; + + const handleOnConfirm = () => { + const payload = {altText, position, showCaption}; + if (node) { + activeEditor.update(() => { + node.update(payload); + }); + } + onClose(); + }; + + return ( + <> +
+ +
+ + + +
+ + +
+ + + + + + ); +} + +export default function InlineImageComponent({ + src, + altText, + nodeKey, + width, + height, + showCaption, + caption, + position, +}: { + altText: string; + caption: LexicalEditor; + height: 'inherit' | number; + nodeKey: NodeKey; + showCaption: boolean; + src: string; + width: 'inherit' | number; + position: Position; +}): JSX.Element { + const [modal, showModal] = useModal(); + const imageRef = useRef(null); + const buttonRef = useRef(null); + const [isSelected, setSelected, clearSelection] = + useLexicalNodeSelection(nodeKey); + const [editor] = useLexicalComposerContext(); + const [selection, setSelection] = useState(null); + const activeEditorRef = useRef(null); + + const $onDelete = useCallback( + (payload: KeyboardEvent) => { + const deleteSelection = $getSelection(); + if (isSelected && $isNodeSelection(deleteSelection)) { + const event: KeyboardEvent = payload; + event.preventDefault(); + if (isSelected && $isNodeSelection(deleteSelection)) { + editor.update(() => { + deleteSelection.getNodes().forEach((node) => { + if ($isInlineImageNode(node)) { + node.remove(); + } + }); + }); + } + } + return false; + }, + [editor, isSelected], + ); + + const $onEnter = useCallback( + (event: KeyboardEvent) => { + const latestSelection = $getSelection(); + const buttonElem = buttonRef.current; + if ( + isSelected && + $isNodeSelection(latestSelection) && + latestSelection.getNodes().length === 1 + ) { + if (showCaption) { + // Move focus into nested editor + $setSelection(null); + event.preventDefault(); + caption.focus(); + return true; + } else if ( + buttonElem !== null && + buttonElem !== document.activeElement + ) { + event.preventDefault(); + buttonElem.focus(); + return true; + } + } + return false; + }, + [caption, isSelected, showCaption], + ); + + const $onEscape = useCallback( + (event: KeyboardEvent) => { + if ( + activeEditorRef.current === caption || + buttonRef.current === event.target + ) { + $setSelection(null); + editor.update(() => { + setSelected(true); + const parentRootElement = editor.getRootElement(); + if (parentRootElement !== null) { + parentRootElement.focus(); + } + }); + return true; + } + return false; + }, + [caption, editor, setSelected], + ); + + useEffect(() => { + let isMounted = true; + const unregister = mergeRegister( + editor.registerUpdateListener(({editorState}) => { + if (isMounted) { + setSelection(editorState.read(() => $getSelection())); + } + }), + editor.registerCommand( + SELECTION_CHANGE_COMMAND, + (_, activeEditor) => { + activeEditorRef.current = activeEditor; + return false; + }, + COMMAND_PRIORITY_LOW, + ), + editor.registerCommand( + CLICK_COMMAND, + (payload) => { + const event = payload; + if (event.target === imageRef.current) { + if (event.shiftKey) { + setSelected(!isSelected); + } else { + clearSelection(); + setSelected(true); + } + return true; + } + + return false; + }, + COMMAND_PRIORITY_LOW, + ), + editor.registerCommand( + DRAGSTART_COMMAND, + (event) => { + if (event.target === imageRef.current) { + // TODO This is just a temporary workaround for FF to behave like other browsers. + // Ideally, this handles drag & drop too (and all browsers). + event.preventDefault(); + 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, + ), + editor.registerCommand(KEY_ENTER_COMMAND, $onEnter, COMMAND_PRIORITY_LOW), + editor.registerCommand( + KEY_ESCAPE_COMMAND, + $onEscape, + COMMAND_PRIORITY_LOW, + ), + ); + return () => { + isMounted = false; + unregister(); + }; + }, [ + clearSelection, + editor, + isSelected, + nodeKey, + $onDelete, + $onEnter, + $onEscape, + setSelected, + ]); + + const draggable = isSelected && $isNodeSelection(selection); + const isFocused = isSelected; + return ( + + <> + + {/* */} + + + {showCaption && ( + + + + + + } + ErrorBoundary={LexicalErrorBoundary} + /> + + + )} + + {modal} + + ); +} diff --git a/src/components/LexicalEditor/nodes/InlineImageNode/InlineImageNode.css b/src/components/LexicalEditor/nodes/InlineImageNode/InlineImageNode.css new file mode 100644 index 0000000..38ec6ca --- /dev/null +++ b/src/components/LexicalEditor/nodes/InlineImageNode/InlineImageNode.css @@ -0,0 +1,94 @@ +/** + * 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. + * + * + */ + +.InlineImageNode__contentEditable { + min-height: 20px; + border: 0px; + resize: none; + cursor: text; + caret-color: rgb(5, 5, 5); + display: block; + position: relative; + tab-size: 1; + outline: 0px; + padding: 10px; + user-select: text; + font-size: 14px; + line-height: 1.4em; + width: calc(100% - 20px); + white-space: pre-wrap; + word-break: break-word; +} + +.InlineImageNode__placeholder { + font-size: 12px; + color: #888; + overflow: hidden; + position: absolute; + text-overflow: ellipsis; + bottom: 10px; + left: 10px; + user-select: none; + white-space: nowrap; + display: inline-block; + pointer-events: none; +} + +.InlineImageNode_Checkbox:checked, +.InlineImageNode_Checkbox:not(:checked) { + position: absolute; + left: -9999px; +} + +.InlineImageNode_Checkbox:checked + label, +.InlineImageNode_Checkbox:not(:checked) + label { + position: absolute; + padding-right: 55px; + cursor: pointer; + line-height: 20px; + display: inline-block; + color: #666; +} + +.InlineImageNode_Checkbox:checked + label:before, +.InlineImageNode_Checkbox:not(:checked) + label:before { + content: ''; + position: absolute; + right: 0; + top: 0; + width: 18px; + height: 18px; + border: 1px solid #666; + background: #fff; +} + +.InlineImageNode_Checkbox:checked + label:after, +.InlineImageNode_Checkbox:not(:checked) + label:after { + content: ''; + width: 8px; + height: 8px; + background: #222222; + position: absolute; + top: 6px; + right: 6px; + -webkit-transition: all 0.2s ease; + transition: all 0.2s ease; +} + +.InlineImageNode_Checkbox:not(:checked) + label:after { + opacity: 0; + -webkit-transform: scale(0); + transform: scale(0); +} + +.InlineImageNode_Checkbox:checked + label:after { + opacity: 1; + -webkit-transform: scale(1); + transform: scale(1); +} diff --git a/src/components/LexicalEditor/nodes/InlineImageNode/InlineImageNode.tsx b/src/components/LexicalEditor/nodes/InlineImageNode/InlineImageNode.tsx new file mode 100644 index 0000000..3ed9eca --- /dev/null +++ b/src/components/LexicalEditor/nodes/InlineImageNode/InlineImageNode.tsx @@ -0,0 +1,294 @@ +/** + * 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. + * + */ + +import type { + DOMConversionMap, + DOMConversionOutput, + DOMExportOutput, + EditorConfig, + LexicalEditor, + LexicalNode, + NodeKey, + SerializedEditor, + SerializedLexicalNode, + Spread, +} from 'lexical'; + +import {$applyNodeReplacement, createEditor, DecoratorNode} from 'lexical'; +import * as React from 'react'; +import {Suspense} from 'react'; + +const InlineImageComponent = React.lazy(() => import('./InlineImageComponent')); + +export type Position = 'left' | 'right' | 'full' | undefined; + +export interface InlineImagePayload { + altText: string; + caption?: LexicalEditor; + height?: number; + key?: NodeKey; + showCaption?: boolean; + src: string; + width?: number; + position?: Position; +} + +export interface UpdateInlineImagePayload { + altText?: string; + showCaption?: boolean; + position?: Position; +} + +function $convertInlineImageElement(domNode: Node): null | DOMConversionOutput { + if (domNode instanceof HTMLImageElement) { + const {alt: altText, src, width, height} = domNode; + const node = $createInlineImageNode({altText, height, src, width}); + return {node}; + } + return null; +} + +export type SerializedInlineImageNode = Spread< + { + altText: string; + caption: SerializedEditor; + height?: number; + showCaption: boolean; + src: string; + width?: number; + position?: Position; + }, + SerializedLexicalNode +>; + +export class InlineImageNode extends DecoratorNode { + __src: string; + __altText: string; + __width: 'inherit' | number; + __height: 'inherit' | number; + __showCaption: boolean; + __caption: LexicalEditor; + __position: Position; + + static getType(): string { + return 'inline-image'; + } + + static clone(node: InlineImageNode): InlineImageNode { + return new InlineImageNode( + node.__src, + node.__altText, + node.__position, + node.__width, + node.__height, + node.__showCaption, + node.__caption, + node.__key, + ); + } + + static importJSON( + serializedNode: SerializedInlineImageNode, + ): InlineImageNode { + const {altText, height, width, caption, src, showCaption, position} = + serializedNode; + const node = $createInlineImageNode({ + altText, + height, + position, + showCaption, + src, + width, + }); + const nestedEditor = node.__caption; + const editorState = nestedEditor.parseEditorState(caption.editorState); + if (!editorState.isEmpty()) { + nestedEditor.setEditorState(editorState); + } + return node; + } + + static importDOM(): DOMConversionMap | null { + return { + img: (node: Node) => ({ + conversion: $convertInlineImageElement, + priority: 0, + }), + }; + } + + constructor( + src: string, + altText: string, + position: Position, + width?: 'inherit' | number, + height?: 'inherit' | number, + showCaption?: boolean, + caption?: LexicalEditor, + key?: NodeKey, + ) { + super(key); + this.__src = src; + this.__altText = altText; + this.__width = width || 'inherit'; + this.__height = height || 'inherit'; + this.__showCaption = showCaption || false; + this.__caption = caption || createEditor(); + this.__position = position; + } + + exportDOM(): DOMExportOutput { + const element = document.createElement('img'); + element.setAttribute('src', this.__src); + element.setAttribute('alt', this.__altText); + element.setAttribute('width', this.__width.toString()); + element.setAttribute('height', this.__height.toString()); + return {element}; + } + + exportJSON(): SerializedInlineImageNode { + return { + altText: this.getAltText(), + caption: this.__caption.toJSON(), + height: this.__height === 'inherit' ? 0 : this.__height, + position: this.__position, + showCaption: this.__showCaption, + src: this.getSrc(), + type: 'inline-image', + version: 1, + width: this.__width === 'inherit' ? 0 : this.__width, + }; + } + + getSrc(): string { + return this.__src; + } + + getAltText(): string { + return this.__altText; + } + + setAltText(altText: string): void { + const writable = this.getWritable(); + writable.__altText = altText; + } + + setWidthAndHeight( + width: 'inherit' | number, + height: 'inherit' | number, + ): void { + const writable = this.getWritable(); + writable.__width = width; + writable.__height = height; + } + + getShowCaption(): boolean { + return this.__showCaption; + } + + setShowCaption(showCaption: boolean): void { + const writable = this.getWritable(); + writable.__showCaption = showCaption; + } + + getPosition(): Position { + return this.__position; + } + + setPosition(position: Position): void { + const writable = this.getWritable(); + writable.__position = position; + } + + update(payload: UpdateInlineImagePayload): void { + const writable = this.getWritable(); + const {altText, showCaption, position} = payload; + if (altText !== undefined) { + writable.__altText = altText; + } + if (showCaption !== undefined) { + writable.__showCaption = showCaption; + } + if (position !== undefined) { + writable.__position = position; + } + } + + // View + + createDOM(config: EditorConfig): HTMLElement { + const span = document.createElement('span'); + const className = `${config.theme.inlineImage} position-${this.__position}`; + if (className !== undefined) { + span.className = className; + } + return span; + } + + updateDOM( + prevNode: InlineImageNode, + dom: HTMLElement, + config: EditorConfig, + ): false { + const position = this.__position; + if (position !== prevNode.__position) { + const className = `${config.theme.inlineImage} position-${position}`; + if (className !== undefined) { + dom.className = className; + } + } + return false; + } + + decorate(): JSX.Element { + return ( + + + + ); + } +} + +export function $createInlineImageNode({ + altText, + position, + height, + src, + width, + showCaption, + caption, + key, +}: InlineImagePayload): InlineImageNode { + return $applyNodeReplacement( + new InlineImageNode( + src, + altText, + position, + width, + height, + showCaption, + caption, + key, + ), + ); +} + +export function $isInlineImageNode( + node: LexicalNode | null | undefined, +): node is InlineImageNode { + return node instanceof InlineImageNode; +} diff --git a/src/components/LexicalEditor/plugins/ImagesPlugin/index.tsx b/src/components/LexicalEditor/plugins/ImagesPlugin/index.tsx index b9b2c0d..f1f3243 100644 --- a/src/components/LexicalEditor/plugins/ImagesPlugin/index.tsx +++ b/src/components/LexicalEditor/plugins/ImagesPlugin/index.tsx @@ -71,13 +71,13 @@ export function InsertImageUriDialogBody({ value={src} data-test-id="image-modal-url-input" /> - + /> */} + + + ); +} + +export default function InlineImagePlugin(): JSX.Element | null { + const [editor] = useLexicalComposerContext(); + + useEffect(() => { + if (!editor.hasNodes([InlineImageNode])) { + throw new Error('ImagesPlugin: ImageNode not registered on editor'); + } + + return mergeRegister( + editor.registerCommand( + INSERT_INLINE_IMAGE_COMMAND, + (payload) => { + const imageNode = $createInlineImageNode(payload); + $insertNodes([imageNode]); + if ($isRootOrShadowRoot(imageNode.getParentOrThrow())) { + $wrapNodeInElement(imageNode, $createParagraphNode).selectEnd(); + } + + return true; + }, + COMMAND_PRIORITY_EDITOR, + ), + editor.registerCommand( + DRAGSTART_COMMAND, + (event) => { + return $onDragStart(event); + }, + COMMAND_PRIORITY_HIGH, + ), + editor.registerCommand( + DRAGOVER_COMMAND, + (event) => { + return $onDragover(event); + }, + COMMAND_PRIORITY_LOW, + ), + editor.registerCommand( + DROP_COMMAND, + (event) => { + return $onDrop(event, editor); + }, + COMMAND_PRIORITY_HIGH, + ), + ); + }, [editor]); + + return null; +} + +const TRANSPARENT_IMAGE = + 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7'; +const img = document.createElement('img'); +img.src = TRANSPARENT_IMAGE; + +function $onDragStart(event: DragEvent): boolean { + const node = $getImageNodeInSelection(); + if (!node) { + return false; + } + const dataTransfer = event.dataTransfer; + if (!dataTransfer) { + return false; + } + dataTransfer.setData('text/plain', '_'); + dataTransfer.setDragImage(img, 0, 0); + dataTransfer.setData( + 'application/x-lexical-drag', + JSON.stringify({ + data: { + altText: node.__altText, + caption: node.__caption, + height: node.__height, + key: node.getKey(), + showCaption: node.__showCaption, + src: node.__src, + width: node.__width, + }, + type: 'image', + }), + ); + + return true; +} + +function $onDragover(event: DragEvent): boolean { + const node = $getImageNodeInSelection(); + if (!node) { + return false; + } + if (!canDropImage(event)) { + event.preventDefault(); + } + return true; +} + +function $onDrop(event: DragEvent, editor: LexicalEditor): boolean { + const node = $getImageNodeInSelection(); + if (!node) { + return false; + } + const data = getDragImageData(event); + if (!data) { + return false; + } + event.preventDefault(); + if (canDropImage(event)) { + const range = getDragSelection(event); + node.remove(); + const rangeSelection = $createRangeSelection(); + if (range !== null && range !== undefined) { + rangeSelection.applyDOMRange(range); + } + $setSelection(rangeSelection); + editor.dispatchCommand(INSERT_INLINE_IMAGE_COMMAND, data); + } + return true; +} + +function $getImageNodeInSelection(): InlineImageNode | null { + const selection = $getSelection(); + if (!$isNodeSelection(selection)) { + return null; + } + const nodes = selection.getNodes(); + const node = nodes[0]; + return $isInlineImageNode(node) ? node : null; +} + +function getDragImageData(event: DragEvent): null | InsertInlineImagePayload { + const dragData = event.dataTransfer?.getData('application/x-lexical-drag'); + if (!dragData) { + return null; + } + const {type, data} = JSON.parse(dragData); + if (type !== 'image') { + return null; + } + + return data; +} + +declare global { + interface DragEvent { + rangeOffset?: number; + rangeParent?: Node; + } +} + +function canDropImage(event: DragEvent): boolean { + const target = event.target; + return !!( + target && + target instanceof HTMLElement && + !target.closest('code, span.editor-image') && + target.parentElement && + target.parentElement.closest('div.ContentEditable__root') + ); +} + +function getDragSelection(event: DragEvent): Range | null | undefined { + let range; + const target = event.target as null | Element | Document; + const targetWindow = + target == null + ? null + : target.nodeType === 9 + ? (target as Document).defaultView + : (target as Element).ownerDocument.defaultView; + const domSelection = getDOMSelection(targetWindow); + if (document.caretRangeFromPoint) { + range = document.caretRangeFromPoint(event.clientX, event.clientY); + } else if (event.rangeParent && domSelection !== null) { + domSelection.collapse(event.rangeParent, event.rangeOffset || 0); + range = domSelection.getRangeAt(0); + } else { + throw Error('Cannot get the selection when dragging'); + } + + return range; +} diff --git a/src/components/LexicalEditor/plugins/ToolbarPlugin.jsx b/src/components/LexicalEditor/plugins/ToolbarPlugin.jsx index a5cbea4..33569a3 100644 --- a/src/components/LexicalEditor/plugins/ToolbarPlugin.jsx +++ b/src/components/LexicalEditor/plugins/ToolbarPlugin.jsx @@ -33,6 +33,14 @@ import { $createCodeNode, $isCodeNode, getDefaultCodeLanguage, getCodeLanguages import { INSERT_HORIZONTAL_RULE_COMMAND } from '@lexical/react/LexicalHorizontalRuleNode'; import DropDown, { DropDownItem } from './../ui/DropDown'; import DropdownColorPicker from '../ui/DropdownColorPicker'; +import { + // INSERT_IMAGE_COMMAND, + InsertImageDialog, + // InsertImagePayload, +} from './ImagesPlugin'; +import {InsertInlineImageDialog} from './InlineImagePlugin'; + +import useModal from './../hooks/useModal'; const LowPriority = 1; @@ -590,6 +598,7 @@ function ElementFormatDropdown({ editor, value, isRTL, disabled = false }) { export default function ToolbarPlugin() { const [editor] = useLexicalComposerContext(); + const [activeEditor, setActiveEditor] = useState(editor); const toolbarRef = useRef(null); const [isEditable, setIsEditable] = useState(() => editor.isEditable()); const [canUndo, setCanUndo] = useState(false); @@ -613,6 +622,8 @@ export default function ToolbarPlugin() { const [floatingAnchorElem, setFloatingAnchorElem] = useState(null); const [isSmallWidthViewport, setIsSmallWidthViewport] = useState(false); + const [modal, showModal] = useModal(); + const applyStyleText = useCallback( (styles, skipHistoryStack = null) => { editor.update( @@ -711,10 +722,12 @@ export default function ToolbarPlugin() { editor.registerCommand( SELECTION_CHANGE_COMMAND, (_payload, newEditor) => { + setActiveEditor(newEditor); updateToolbar(); return false; }, LowPriority + // COMMAND_PRIORITY_CRITICAL, ), editor.registerCommand( CAN_UNDO_COMMAND, @@ -886,44 +899,44 @@ export default function ToolbarPlugin() { /> - {/* - - - */} + + + { + showModal('Insert Image', (onClose) => ( + + )); + }} + className="item"> + + Image + + { + showModal('Insert Inline Image', (onClose) => ( + + )); + }} + className="item"> + + Inline Image + + + )} + {modal} ); } diff --git a/src/components/LexicalEditor/styles.css b/src/components/LexicalEditor/styles.css index 478cc49..b06cd5b 100644 --- a/src/components/LexicalEditor/styles.css +++ b/src/components/LexicalEditor/styles.css @@ -796,7 +796,6 @@ i.redo { .icon.code { background-image: url(/images/icons/code.svg); } - .icon.font-family { background-image: url("data:image/svg+xml,%3csvg%20xmlns='http://www.w3.org/2000/svg'%20width='16'%20height='16'%20fill='currentColor'%20class='bi%20bi-fonts'%3e%3cpath%20d='M12.258%203h-8.51l-.083%202.46h.479c.26-1.544.758-1.783%202.693-1.845l.424-.013v7.827c0%20.663-.144.82-1.3.923v.52h4.082v-.52c-1.162-.103-1.306-.26-1.306-.923V3.602l.431.013c1.934.062%202.434.301%202.693%201.846h.479L12.258%203z'/%3e%3c/svg%3e") } @@ -824,7 +823,12 @@ i.redo { .icon.justify-align,i.justify-align { background-image: url("data:image/svg+xml,%3csvg%20xmlns='http://www.w3.org/2000/svg'%20width='16'%20height='16'%20fill='currentColor'%20class='bi%20bi-justify'%3e%3cpath%20fill-rule='evenodd'%20d='M2%2012.5a.5.5%200%200%201%20.5-.5h11a.5.5%200%200%201%200%201h-11a.5.5%200%200%201-.5-.5zm0-3a.5.5%200%200%201%20.5-.5h11a.5.5%200%200%201%200%201h-11a.5.5%200%200%201-.5-.5zm0-3a.5.5%200%200%201%20.5-.5h11a.5.5%200%200%201%200%201h-11a.5.5%200%200%201-.5-.5zm0-3a.5.5%200%200%201%20.5-.5h11a.5.5%200%200%201%200%201h-11a.5.5%200%200%201-.5-.5z'/%3e%3c/svg%3e") } - +.icon.plus { + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='currentColor'%3E%3Cpath d='M11 11V5H13V11H19V13H13V19H11V13H5V11H11Z'%3E%3C/path%3E%3C/svg%3E"); +} +.icon.image{ + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='currentColor'%3E%3Cpath d='M20 5H4V19L13.2923 9.70649C13.6828 9.31595 14.3159 9.31591 14.7065 9.70641L20 15.0104V5ZM2 3.9934C2 3.44476 2.45531 3 2.9918 3H21.0082C21.556 3 22 3.44495 22 3.9934V20.0066C22 20.5552 21.5447 21 21.0082 21H2.9918C2.44405 21 2 20.5551 2 20.0066V3.9934ZM8 11C6.89543 11 6 10.1046 6 9C6 7.89543 6.89543 7 8 7C9.10457 7 10 7.89543 10 9C10 10.1046 9.10457 11 8 11Z'%3E%3C/path%3E%3C/svg%3E"); +} i.indent { background-image: url("data:image/svg+xml,%3csvg%20xmlns='http://www.w3.org/2000/svg'%20width='16'%20height='16'%20fill='currentColor'%20class='bi%20bi-text-indent-left'%3e%3cpath%20d='M2%203.5a.5.5%200%200%201%20.5-.5h11a.5.5%200%200%201%200%201h-11a.5.5%200%200%201-.5-.5zm.646%202.146a.5.5%200%200%201%20.708%200l2%202a.5.5%200%200%201%200%20.708l-2%202a.5.5%200%200%201-.708-.708L4.293%208%202.646%206.354a.5.5%200%200%201%200-.708zM7%206.5a.5.5%200%200%201%20.5-.5h6a.5.5%200%200%201%200%201h-6a.5.5%200%200%201-.5-.5zm0%203a.5.5%200%200%201%20.5-.5h6a.5.5%200%200%201%200%201h-6a.5.5%200%200%201-.5-.5zm-5%203a.5.5%200%200%201%20.5-.5h11a.5.5%200%200%201%200%201h-11a.5.5%200%200%201-.5-.5z'/%3e%3c/svg%3e") } @@ -874,3 +878,221 @@ i.right-align { i.justify-align { background-image: url(/images/icons/justify.svg); } + +.editor-container span.editor-image { + cursor: default; + display: inline-block; + position: relative; + user-select: none; +} + +.editor-container .editor-image img { +max-width: 100%; +cursor: default; +} + +.editor-container .editor-image img.focused { +outline: 2px solid rgb(60, 132, 244); +user-select: none; +} + +.editor-container .editor-image img.focused.draggable { +cursor: grab; +} + +.editor-container .editor-image img.focused.draggable:active { +cursor: grabbing; +} + +.editor-container .editor-image .image-caption-container .tree-view-output { +margin: 0; +border-radius: 0; +} + +.editor-container .editor-image .image-caption-container { +display: block; +position: absolute; +bottom: 4px; +left: 0; +right: 0; +padding: 0; +margin: 0; +border-top: 1px solid #fff; +background-color: rgba(255, 255, 255, 0.9); +min-width: 100px; +color: #000; +overflow: hidden; +} + +.editor-container .editor-image .image-caption-button { +display: block; +position: absolute; +bottom: 20px; +left: 0; +right: 0; +width: 30%; +padding: 10px; +margin: 0 auto; +border: 1px solid rgba(255, 255, 255, 0.3); +border-radius: 5px; +background-color: rgba(0, 0, 0, 0.5); +min-width: 100px; +color: #fff; +cursor: pointer; +user-select: none; +} + +.editor-container .editor-image .image-caption-button:hover { +background-color: rgba(60, 132, 244, 0.5); +} + +.editor-container .editor-image .image-edit-button { +border: 1px solid rgba(0, 0, 0, 0.3); +border-radius: 5px; +background-image: url(/src/images/icons/pencil-fill.svg); +background-size: 16px; +background-position: center; +background-repeat: no-repeat; +width: 35px; +height: 35px; +vertical-align: -0.25em; +position: absolute; +right: 4px; +top: 4px; +cursor: pointer; +user-select: none; +} + +.editor-container .editor-image .image-edit-button:hover { +background-color: rgba(60, 132, 244, 0.1); +} + +.editor-container .editor-image .image-resizer { +display: block; +width: 7px; +height: 7px; +position: absolute; +background-color: rgb(60, 132, 244); +border: 1px solid #fff; +} + +.editor-container .editor-image .image-resizer.image-resizer-n { +top: -6px; +left: 48%; +cursor: n-resize; +} + +.editor-container .editor-image .image-resizer.image-resizer-ne { +top: -6px; +right: -6px; +cursor: ne-resize; +} + +.editor-container .editor-image .image-resizer.image-resizer-e { +bottom: 48%; +right: -6px; +cursor: e-resize; +} + +.editor-container .editor-image .image-resizer.image-resizer-se { +bottom: -2px; +right: -6px; +cursor: nwse-resize; +} + +.editor-container .editor-image .image-resizer.image-resizer-s { +bottom: -2px; +left: 48%; +cursor: s-resize; +} + +.editor-container .editor-image .image-resizer.image-resizer-sw { +bottom: -2px; +left: -6px; +cursor: sw-resize; +} + +.editor-container .editor-image .image-resizer.image-resizer-w { +bottom: 48%; +left: -6px; +cursor: w-resize; +} + +.editor-container .editor-image .image-resizer.image-resizer-nw { +top: -6px; +left: -6px; +cursor: nw-resize; +} + +.editor-container span.inline-editor-image { +cursor: default; +display: inline-block; +position: relative; +z-index: 1; +} + +.editor-container .inline-editor-image img { +max-width: 100%; +cursor: default; +} + +.editor-container .inline-editor-image img.focused { +outline: 2px solid rgb(60, 132, 244); +} + +.editor-container .inline-editor-image img.focused.draggable { +cursor: grab; +} + +.editor-container .inline-editor-image img.focused.draggable:active { +cursor: grabbing; +} + +.editor-container .inline-editor-image .image-caption-container .tree-view-output { +margin: 0; +border-radius: 0; +} + +.editor-container .inline-editor-image.position-full { +margin: 1em 0 1em 0; +} + +.editor-container .inline-editor-image.position-left { +float: left; +width: 50%; +margin: 1em 1em 0 0; +} + +.editor-container .inline-editor-image.position-right { +float: right; +width: 50%; +margin: 1em 0 0 1em; +} + +.editor-container .inline-editor-image .image-edit-button { +display: block; +position: absolute; +top: 12px; +right: 12px; +padding: 6px 8px; +margin: 0 auto; +border: 1px solid rgba(255, 255, 255, 0.3); +border-radius: 5px; +background-color: rgba(0, 0, 0, 0.5); +min-width: 60px; +color: #fff; +cursor: pointer; +user-select: none; +} + +.editor-container .inline-editor-image .image-edit-button:hover { +background-color: rgba(60, 132, 244, 0.5); +} + +.editor-container .inline-editor-image .image-caption-container { +display: block; +background-color: #f4f4f4; +min-width: 100%; +color: #000; +overflow: hidden; +} diff --git a/src/components/LexicalEditor/ui/ImageResizer.tsx b/src/components/LexicalEditor/ui/ImageResizer.tsx index 13e9f48..091f646 100644 --- a/src/components/LexicalEditor/ui/ImageResizer.tsx +++ b/src/components/LexicalEditor/ui/ImageResizer.tsx @@ -253,7 +253,7 @@ export default function ImageResizer({ }; return (
- {!showCaption && captionsEnabled && ( + {/* {!showCaption && captionsEnabled && ( - )} + )} */}
{ diff --git a/src/components/LexicalEditor/ui/Modal.css b/src/components/LexicalEditor/ui/Modal.css new file mode 100644 index 0000000..908500b --- /dev/null +++ b/src/components/LexicalEditor/ui/Modal.css @@ -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. + * + * + */ + +.Modal__overlay { + display: flex; + justify-content: center; + align-items: center; + position: fixed; + flex-direction: column; + top: 0px; + bottom: 0px; + left: 0px; + right: 0px; + background-color: rgba(40, 40, 40, 0.6); + flex-grow: 0px; + flex-shrink: 1px; + z-index: 100; +} +.Modal__modal { + padding: 20px; + min-height: 100px; + min-width: 300px; + display: flex; + flex-grow: 0px; + background-color: #fff; + flex-direction: column; + position: relative; + box-shadow: 0 0 20px 0 #444; + border-radius: 10px; +} +.Modal__title { + color: #444; + margin: 0px; + padding-bottom: 10px; + border-bottom: 1px solid #ccc; +} +.Modal__closeButton { + border: 0px; + position: absolute; + right: 20px; + border-radius: 20px; + justify-content: center; + align-items: center; + display: flex; + width: 30px; + height: 30px; + text-align: center; + cursor: pointer; + background-color: #eee; +} +.Modal__closeButton:hover { + background-color: #ddd; +} +.Modal__content { + padding-top: 20px; +} diff --git a/src/components/LexicalEditor/ui/Modal.tsx b/src/components/LexicalEditor/ui/Modal.tsx new file mode 100644 index 0000000..f69fd92 --- /dev/null +++ b/src/components/LexicalEditor/ui/Modal.tsx @@ -0,0 +1,106 @@ +/** + * 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. + * + */ + +import './Modal.css'; + +import * as React from 'react'; +import {ReactNode, useEffect, useRef} from 'react'; +import {createPortal} from 'react-dom'; + +function PortalImpl({ + onClose, + children, + title, + closeOnClickOutside, +}: { + children: ReactNode; + closeOnClickOutside: boolean; + onClose: () => void; + title: string; +}) { + const modalRef = useRef(null); + + useEffect(() => { + if (modalRef.current !== null) { + modalRef.current.focus(); + } + }, []); + + useEffect(() => { + let modalOverlayElement: HTMLElement | null = null; + const handler = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + onClose(); + } + }; + const clickOutsideHandler = (event: MouseEvent) => { + const target = event.target; + if ( + modalRef.current !== null && + !modalRef.current.contains(target as Node) && + closeOnClickOutside + ) { + onClose(); + } + }; + const modelElement = modalRef.current; + if (modelElement !== null) { + modalOverlayElement = modelElement.parentElement; + if (modalOverlayElement !== null) { + modalOverlayElement.addEventListener('click', clickOutsideHandler); + } + } + + window.addEventListener('keydown', handler); + + return () => { + window.removeEventListener('keydown', handler); + if (modalOverlayElement !== null) { + modalOverlayElement?.removeEventListener('click', clickOutsideHandler); + } + }; + }, [closeOnClickOutside, onClose]); + + return ( +
+
+

{title}

+ +
{children}
+
+
+ ); +} + +export default function Modal({ + onClose, + children, + title, + closeOnClickOutside = false, +}: { + children: ReactNode; + closeOnClickOutside?: boolean; + onClose: () => void; + title: string; +}): JSX.Element { + return createPortal( + + {children} + , + document.body, + ); +} diff --git a/src/components/LexicalEditor/ui/Select.css b/src/components/LexicalEditor/ui/Select.css new file mode 100644 index 0000000..9a66a9e --- /dev/null +++ b/src/components/LexicalEditor/ui/Select.css @@ -0,0 +1,42 @@ +/** + * 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. + * + */ + +select { + appearance: none; + -webkit-appearance: none; + -moz-appearance: none; + background-color: transparent; + border: none; + padding: 0 1em 0 0; + margin: 0; + font-family: inherit; + font-size: inherit; + cursor: inherit; + line-height: inherit; + + z-index: 1; + outline: none; +} + +:root { + --select-border: #393939; + --select-focus: #101484; + --select-arrow: var(--select-border); +} + +.select { + min-width: 160px; + max-width: 290px; + border: 1px solid var(--select-border); + border-radius: 0.25em; + padding: 0.25em 0.5em; + font-size: 1rem; + cursor: pointer; + line-height: 1.4; + background: linear-gradient(to bottom, #ffffff 0%, #e5e5e5 100%); +} diff --git a/src/components/LexicalEditor/ui/Select.tsx b/src/components/LexicalEditor/ui/Select.tsx new file mode 100644 index 0000000..59e5ccc --- /dev/null +++ b/src/components/LexicalEditor/ui/Select.tsx @@ -0,0 +1,34 @@ +/** + * 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. + * + */ + +import './Select.css'; + +import * as React from 'react'; + +type SelectIntrinsicProps = JSX.IntrinsicElements['select']; +interface SelectProps extends SelectIntrinsicProps { + label: string; +} + +export default function Select({ + children, + label, + className, + ...other +}: SelectProps): JSX.Element { + return ( +
+ + +
+ ); +}