From 06426eb7815d8cd06a84feaf0544d257329d9df0 Mon Sep 17 00:00:00 2001 From: cialloo Date: Mon, 27 Oct 2025 20:57:19 +0800 Subject: [PATCH] Remove editor styles, toolbar styles, and related components - Deleted editor.css and toolbar.css stylesheets to streamline the editor's appearance. - Removed EditorTheme.ts which defined theme classes for the editor. - Eliminated DropdownColorPicker component and its associated styles for color selection. - Removed ImageResizer component responsible for resizing images within the editor. - Deleted exportImport.ts utility for handling JSON export/import functionality. - Removed EditorDemo page and its references from the main application. --- src/editor/README.md | 95 ---- src/editor/RichTextEditor.tsx | 120 ----- src/editor/index.ts | 1 - src/editor/nodes/ImageComponent.tsx | 275 ---------- src/editor/nodes/ImageNode.tsx | 207 -------- src/editor/nodes/ImageResizer.tsx | 292 ----------- src/editor/nodes/MentionNode.ts | 130 ----- src/editor/plugins/DragDropPastePlugin.tsx | 49 -- src/editor/plugins/HashtagPlugin.tsx | 18 - src/editor/plugins/ImagesPlugin.tsx | 47 -- src/editor/plugins/MarkdownShortcutPlugin.tsx | 7 - src/editor/plugins/MarkdownTransformers.ts | 258 ---------- src/editor/plugins/MentionsPlugin.tsx | 288 ----------- src/editor/plugins/ToolbarPlugin.tsx | 301 ----------- src/editor/styles/editor.css | 477 ------------------ src/editor/styles/toolbar.css | 155 ------ src/editor/themes/EditorTheme.ts | 46 -- src/editor/ui/DropdownColorPicker.css | 50 -- src/editor/ui/DropdownColorPicker.tsx | 87 ---- src/editor/ui/ImageResizer.tsx | 292 ----------- src/editor/utils/exportImport.ts | 115 ----- src/main.tsx | 2 - src/pages/EditorDemo.tsx | 32 -- 23 files changed, 3344 deletions(-) delete mode 100644 src/editor/README.md delete mode 100644 src/editor/RichTextEditor.tsx delete mode 100644 src/editor/index.ts delete mode 100644 src/editor/nodes/ImageComponent.tsx delete mode 100644 src/editor/nodes/ImageNode.tsx delete mode 100644 src/editor/nodes/ImageResizer.tsx delete mode 100644 src/editor/nodes/MentionNode.ts delete mode 100644 src/editor/plugins/DragDropPastePlugin.tsx delete mode 100644 src/editor/plugins/HashtagPlugin.tsx delete mode 100644 src/editor/plugins/ImagesPlugin.tsx delete mode 100644 src/editor/plugins/MarkdownShortcutPlugin.tsx delete mode 100644 src/editor/plugins/MarkdownTransformers.ts delete mode 100644 src/editor/plugins/MentionsPlugin.tsx delete mode 100644 src/editor/plugins/ToolbarPlugin.tsx delete mode 100644 src/editor/styles/editor.css delete mode 100644 src/editor/styles/toolbar.css delete mode 100644 src/editor/themes/EditorTheme.ts delete mode 100644 src/editor/ui/DropdownColorPicker.css delete mode 100644 src/editor/ui/DropdownColorPicker.tsx delete mode 100644 src/editor/ui/ImageResizer.tsx delete mode 100644 src/editor/utils/exportImport.ts delete mode 100644 src/pages/EditorDemo.tsx diff --git a/src/editor/README.md b/src/editor/README.md deleted file mode 100644 index 73de55e..0000000 --- a/src/editor/README.md +++ /dev/null @@ -1,95 +0,0 @@ -# Rich Text Editor - -A basic rich text editor built with Lexical, ported from the lexical-playground project. - -## Features - -### Toolbar Controls - -1. **Undo/Redo** - Navigate through editing history -2. **Text Size** - Select from multiple font sizes (10px - 36px) -3. **Bold** - Make text bold -4. **Italic** - Italicize text -5. **Underline** - Underline text -6. **Strikethrough** - Strike through text -7. **Code** - Format text as inline code -8. **Text Color** - Choose from 30 predefined colors -9. **Background Color** - Choose background color for text - -### Additional Features - -- Quote blocks -- Code blocks (without syntax highlighting) -- Lists (bullet and numbered) -- Headings (H1, H2, H3) - -## Usage - -### Basic Usage - -```tsx -import RichTextEditor from './editor/RichTextEditor'; - -function MyComponent() { - return ; -} -``` - -### Accessing the Demo - -Navigate to `/editor` to see the editor in action. - -## Files Structure - -``` -src/editor/ -├── RichTextEditor.tsx # Main editor component -├── index.ts # Export file -├── plugins/ -│ └── ToolbarPlugin.tsx # Toolbar with formatting controls -├── ui/ -│ ├── DropdownColorPicker.tsx # Color picker component -│ └── DropdownColorPicker.css # Color picker styles -├── themes/ -│ └── EditorTheme.ts # Editor theme configuration -└── styles/ - ├── editor.css # Editor styles - └── toolbar.css # Toolbar styles -``` - -## Dependencies - -- `lexical` - Core editor framework -- `@lexical/react` - React bindings -- `@lexical/code` - Code block support -- `@lexical/list` - List support -- `@lexical/rich-text` - Rich text features (headings, quotes) -- `@lexical/selection` - Selection utilities -- `@lexical/utils` - Utility functions - -## Customization - -### Theme - -Edit `src/editor/themes/EditorTheme.ts` to customize the editor's appearance. - -### Toolbar - -Modify `src/editor/plugins/ToolbarPlugin.tsx` to add or remove toolbar buttons. - -### Styles - -- `src/editor/styles/editor.css` - Editor content styles -- `src/editor/styles/toolbar.css` - Toolbar styles - -## Future Enhancements - -Possible additions (not currently implemented): - -- Syntax highlighting for code blocks -- Links -- Images -- Tables -- Alignment controls -- More list types (checklists) -- Markdown shortcuts diff --git a/src/editor/RichTextEditor.tsx b/src/editor/RichTextEditor.tsx deleted file mode 100644 index 9e97114..0000000 --- a/src/editor/RichTextEditor.tsx +++ /dev/null @@ -1,120 +0,0 @@ -import { LexicalComposer } from '@lexical/react/LexicalComposer'; -import { RichTextPlugin } from '@lexical/react/LexicalRichTextPlugin'; -import { ContentEditable } from '@lexical/react/LexicalContentEditable'; -import { HistoryPlugin } from '@lexical/react/LexicalHistoryPlugin'; -import { LexicalErrorBoundary } from '@lexical/react/LexicalErrorBoundary'; -import { HeadingNode, QuoteNode } from '@lexical/rich-text'; -import { CodeNode, CodeHighlightNode } from '@lexical/code'; -import { ListItemNode, ListNode } from '@lexical/list'; -import { ListPlugin } from '@lexical/react/LexicalListPlugin'; -import { CheckListPlugin } from '@lexical/react/LexicalCheckListPlugin'; -import { HorizontalRuleNode } from '@lexical/react/LexicalHorizontalRuleNode'; -import { TableCellNode, TableNode, TableRowNode } from '@lexical/table'; -import { TablePlugin } from '@lexical/react/LexicalTablePlugin'; -import { LinkNode, AutoLinkNode } from '@lexical/link'; -import { LinkPlugin } from '@lexical/react/LexicalLinkPlugin'; -import { AutoLinkPlugin } from '@lexical/react/LexicalAutoLinkPlugin'; -import { HashtagNode } from '@lexical/hashtag'; - -import { ImageNode } from './nodes/ImageNode'; -import { MentionNode } from './nodes/MentionNode'; -import ToolbarPlugin from './plugins/ToolbarPlugin'; -import MarkdownPlugin from './plugins/MarkdownShortcutPlugin'; -import ImagesPlugin from './plugins/ImagesPlugin'; -import DragDropPastePlugin from './plugins/DragDropPastePlugin'; -import HashtagPlugin from './plugins/HashtagPlugin'; -import MentionsPlugin from './plugins/MentionsPlugin'; -import editorTheme from './themes/EditorTheme'; -import './styles/editor.css'; - -const URL_MATCHER = - /((https?:\/\/(www\.)?)|(www\.))[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&//=]*)/; - -const EMAIL_MATCHER = - /(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))/; - -const MATCHERS = [ - (text: string) => { - const match = URL_MATCHER.exec(text); - if (match === null) { - return null; - } - const fullMatch = match[0]; - return { - index: match.index, - length: fullMatch.length, - text: fullMatch, - url: fullMatch.startsWith('http') ? fullMatch : `https://${fullMatch}`, - }; - }, - (text: string) => { - const match = EMAIL_MATCHER.exec(text); - if (match === null) { - return null; - } - const fullMatch = match[0]; - return { - index: match.index, - length: fullMatch.length, - text: fullMatch, - url: `mailto:${fullMatch}`, - }; - }, -]; - -const editorConfig = { - namespace: 'CiallooEditor', - theme: editorTheme, - onError(error: Error) { - console.error(error); - }, - nodes: [ - HeadingNode, - QuoteNode, - CodeNode, - CodeHighlightNode, - ListNode, - ListItemNode, - HorizontalRuleNode, - TableNode, - TableRowNode, - TableCellNode, - LinkNode, - AutoLinkNode, - ImageNode, - HashtagNode, - MentionNode, - ], -}; - -export default function RichTextEditor() { - return ( - -
- -
- - } - placeholder={ -
Enter some text...
- } - ErrorBoundary={LexicalErrorBoundary} - /> - - - - - - - - - - - -
-
-
- ); -} diff --git a/src/editor/index.ts b/src/editor/index.ts deleted file mode 100644 index 934a7bb..0000000 --- a/src/editor/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { default } from './RichTextEditor'; diff --git a/src/editor/nodes/ImageComponent.tsx b/src/editor/nodes/ImageComponent.tsx deleted file mode 100644 index fe7d7c6..0000000 --- a/src/editor/nodes/ImageComponent.tsx +++ /dev/null @@ -1,275 +0,0 @@ -import type { NodeKey } from 'lexical'; -import type { JSX } from 'react'; - -import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'; -import { useLexicalNodeSelection } from '@lexical/react/useLexicalNodeSelection'; -import { mergeRegister } from '@lexical/utils'; -import { - $getNodeByKey, - $setSelection, - CLICK_COMMAND, - COMMAND_PRIORITY_LOW, - DRAGSTART_COMMAND, - KEY_ESCAPE_COMMAND, - SELECTION_CHANGE_COMMAND, -} from 'lexical'; -import { - useCallback, - useEffect, - useRef, - useState, -} from 'react'; - -import ImageResizer from './ImageResizer'; -import { $isImageNode } from './ImageNode'; - -type ImageStatus = - | { error: true } - | { error: false; width: number; height: number }; - -const imageCache = new Map | ImageStatus>(); - -function useSuspenseImage(src: string): ImageStatus { - let cached = imageCache.get(src); - if (cached && 'error' in cached && typeof cached.error === 'boolean') { - return cached; - } else if (!cached) { - cached = new Promise((resolve) => { - const img = new Image(); - img.src = src; - img.onload = () => - resolve({ - error: false, - height: img.naturalHeight, - width: img.naturalWidth, - }); - img.onerror = () => resolve({ error: true }); - }).then((rval) => { - imageCache.set(src, rval); - return rval; - }); - imageCache.set(src, cached); - throw cached; - } - throw cached; -} - -function LazyImage({ - altText, - className, - imageRef, - src, - width, - height, - maxWidth, - onError, -}: { - altText: string; - className: string | null; - height: 'inherit' | number; - imageRef: { current: null | HTMLImageElement }; - maxWidth: number; - src: string; - width: 'inherit' | number; - onError: () => void; -}): JSX.Element { - const status = useSuspenseImage(src); - - useEffect(() => { - if (status.error) { - onError(); - } - }, [status.error, onError]); - - if (status.error) { - return ( - Broken image - ); - } - - return ( - {altText} - ); -} - -export default function ImageComponent({ - src, - altText, - nodeKey, - width, - height, - maxWidth, - resizable, -}: { - altText: string; - height: 'inherit' | number; - maxWidth: number; - nodeKey: NodeKey; - resizable: boolean; - src: string; - width: 'inherit' | number; -}): JSX.Element { - const imageRef = useRef(null); - const [isSelected, setSelected, clearSelection] = - useLexicalNodeSelection(nodeKey); - const [isResizing, setIsResizing] = useState(false); - const [editor] = useLexicalComposerContext(); - const [isLoadError, setIsLoadError] = useState(false); - - const $onEscape = useCallback( - () => { - if (isSelected) { - $setSelection(null); - editor.update(() => { - setSelected(true); - const parentRootElement = editor.getRootElement(); - if (parentRootElement !== null) { - parentRootElement.focus(); - } - }); - return true; - } - return false; - }, - [editor, isSelected, setSelected], - ); - - const onClick = useCallback( - (payload: MouseEvent) => { - const event = payload; - - if (isResizing) { - return true; - } - if (event.target === imageRef.current) { - if (event.shiftKey) { - setSelected(!isSelected); - } else { - clearSelection(); - setSelected(true); - } - return true; - } - - return false; - }, - [isResizing, isSelected, setSelected, clearSelection], - ); - - useEffect(() => { - return mergeRegister( - editor.registerCommand( - SELECTION_CHANGE_COMMAND, - () => { - return false; - }, - COMMAND_PRIORITY_LOW, - ), - editor.registerCommand( - DRAGSTART_COMMAND, - (event) => { - if (event.target === imageRef.current) { - event.preventDefault(); - return true; - } - return false; - }, - COMMAND_PRIORITY_LOW, - ), - editor.registerCommand( - CLICK_COMMAND, - onClick, - COMMAND_PRIORITY_LOW, - ), - editor.registerCommand( - KEY_ESCAPE_COMMAND, - $onEscape, - COMMAND_PRIORITY_LOW, - ), - ); - }, [clearSelection, editor, isResizing, isSelected, onClick, $onEscape]); - - const onResizeEnd = ( - nextWidth: 'inherit' | number, - nextHeight: 'inherit' | number, - ) => { - setTimeout(() => { - setIsResizing(false); - }, 200); - - editor.update(() => { - const node = $getNodeByKey(nodeKey); - if ($isImageNode(node)) { - node.setWidthAndHeight(nextWidth, nextHeight); - } - }); - }; - - const onResizeStart = () => { - setIsResizing(true); - }; - - const draggable = isSelected && !isResizing; - const isFocused = isSelected || isResizing; - - return ( - <> -
- {isLoadError ? ( - Broken image - ) : ( - setIsLoadError(true)} - /> - )} -
- - {resizable && isSelected && isFocused && ( - - )} - - ); -} diff --git a/src/editor/nodes/ImageNode.tsx b/src/editor/nodes/ImageNode.tsx deleted file mode 100644 index 898c9fc..0000000 --- a/src/editor/nodes/ImageNode.tsx +++ /dev/null @@ -1,207 +0,0 @@ -import type { - DOMConversionMap, - DOMConversionOutput, - DOMExportOutput, - EditorConfig, - LexicalNode, - NodeKey, - SerializedLexicalNode, - Spread, -} from 'lexical'; -import type { JSX } from 'react'; - -import { - $applyNodeReplacement, - DecoratorNode, -} from 'lexical'; -import { Suspense, lazy } from 'react'; - -const ImageComponent = lazy(() => import('./ImageComponent')); - -export interface ImagePayload { - altText: string; - height?: number; - key?: NodeKey; - maxWidth?: number; - src: string; - width?: number; -} - -function $convertImageElement(domNode: Node): null | DOMConversionOutput { - const img = domNode as HTMLImageElement; - const src = img.getAttribute('src'); - if (!src || src.startsWith('file:///')) { - return null; - } - const { alt: altText, width, height } = img; - const node = $createImageNode({ altText, height, src, width }); - return { node }; -} - -export type SerializedImageNode = Spread< - { - altText: string; - height?: number; - maxWidth: number; - src: string; - width?: number; - }, - SerializedLexicalNode ->; - -export class ImageNode extends DecoratorNode { - __src: string; - __altText: string; - __width: 'inherit' | number; - __height: 'inherit' | number; - __maxWidth: number; - - static getType(): string { - return 'image'; - } - - static clone(node: ImageNode): ImageNode { - return new ImageNode( - node.__src, - node.__altText, - node.__maxWidth, - node.__width, - node.__height, - node.__key, - ); - } - - static importJSON(serializedNode: SerializedImageNode): ImageNode { - const { altText, height, width, maxWidth, src } = serializedNode; - return $createImageNode({ - altText, - height, - maxWidth, - src, - width, - }); - } - - exportDOM(): DOMExportOutput { - const element = document.createElement('img'); - element.setAttribute('src', this.__src); - element.setAttribute('alt', this.__altText); - if (this.__width !== 'inherit') { - element.setAttribute('width', this.__width.toString()); - } - if (this.__height !== 'inherit') { - element.setAttribute('height', this.__height.toString()); - } - return { element }; - } - - static importDOM(): DOMConversionMap | null { - return { - img: () => ({ - conversion: $convertImageElement, - priority: 0, - }), - }; - } - - constructor( - src: string, - altText: string, - maxWidth: number, - width?: 'inherit' | number, - height?: 'inherit' | number, - key?: NodeKey, - ) { - super(key); - this.__src = src; - this.__altText = altText; - this.__maxWidth = maxWidth; - this.__width = width || 'inherit'; - this.__height = height || 'inherit'; - } - - exportJSON(): SerializedImageNode { - return { - altText: this.getAltText(), - height: this.__height === 'inherit' ? 0 : this.__height, - maxWidth: this.__maxWidth, - src: this.getSrc(), - type: 'image', - version: 1, - width: this.__width === 'inherit' ? 0 : this.__width, - }; - } - - setWidthAndHeight( - width: 'inherit' | number, - height: 'inherit' | number, - ): void { - const writable = this.getWritable(); - writable.__width = width; - writable.__height = height; - } - - createDOM(config: EditorConfig): HTMLElement { - const span = document.createElement('span'); - const theme = config.theme; - const className = theme.image; - if (className !== undefined) { - span.className = className; - } - return span; - } - - updateDOM(): false { - return false; - } - - getSrc(): string { - return this.__src; - } - - getAltText(): string { - return this.__altText; - } - - decorate(): JSX.Element { - return ( - - - - ); - } -} - -export function $createImageNode({ - altText, - height, - maxWidth = 800, - src, - width, - key, -}: ImagePayload): ImageNode { - return $applyNodeReplacement( - new ImageNode( - src, - altText, - maxWidth, - width, - height, - key, - ), - ); -} - -export function $isImageNode( - node: LexicalNode | null | undefined, -): node is ImageNode { - return node instanceof ImageNode; -} diff --git a/src/editor/nodes/ImageResizer.tsx b/src/editor/nodes/ImageResizer.tsx deleted file mode 100644 index b8ca26f..0000000 --- a/src/editor/nodes/ImageResizer.tsx +++ /dev/null @@ -1,292 +0,0 @@ -import type { LexicalEditor } from 'lexical'; -import type { JSX } from 'react'; - -import { calculateZoomLevel } from '@lexical/utils'; -import { useRef } from 'react'; - -function clamp(value: number, min: number, max: number) { - return Math.min(Math.max(value, min), max); -} - -const Direction = { - east: 1 << 0, - north: 1 << 3, - south: 1 << 1, - west: 1 << 2, -}; - -export default function ImageResizer({ - onResizeStart, - onResizeEnd, - imageRef, - maxWidth, - editor, -}: { - editor: LexicalEditor; - imageRef: { current: null | HTMLElement }; - maxWidth?: number; - onResizeEnd: (width: 'inherit' | number, height: 'inherit' | number) => void; - onResizeStart: () => void; -}): JSX.Element { - const controlWrapperRef = useRef(null); - const userSelect = useRef({ - priority: '', - value: 'default', - }); - const positioningRef = useRef<{ - currentHeight: 'inherit' | number; - currentWidth: 'inherit' | number; - direction: number; - isResizing: boolean; - ratio: number; - startHeight: number; - startWidth: number; - startX: number; - startY: number; - }>({ - currentHeight: 0, - currentWidth: 0, - direction: 0, - isResizing: false, - ratio: 0, - startHeight: 0, - startWidth: 0, - startX: 0, - startY: 0, - }); - - const editorRootElement = editor.getRootElement(); - const maxWidthContainer = maxWidth - ? maxWidth - : editorRootElement !== null - ? editorRootElement.getBoundingClientRect().width - 20 - : 100; - const maxHeightContainer = - editorRootElement !== null - ? editorRootElement.getBoundingClientRect().height - 20 - : 100; - - const minWidth = 100; - const minHeight = 100; - - const setStartCursor = (direction: number) => { - const ew = direction === Direction.east || direction === Direction.west; - const ns = direction === Direction.north || direction === Direction.south; - const nwse = - (direction & Direction.north && direction & Direction.west) || - (direction & Direction.south && direction & Direction.east); - - const cursorDir = ew ? 'ew' : ns ? 'ns' : nwse ? 'nwse' : 'nesw'; - - if (editorRootElement !== null) { - editorRootElement.style.setProperty( - 'cursor', - `${cursorDir}-resize`, - 'important', - ); - } - if (document.body !== null) { - document.body.style.setProperty( - 'cursor', - `${cursorDir}-resize`, - 'important', - ); - userSelect.current.value = document.body.style.getPropertyValue( - '-webkit-user-select', - ); - userSelect.current.priority = document.body.style.getPropertyPriority( - '-webkit-user-select', - ); - document.body.style.setProperty( - '-webkit-user-select', - `none`, - 'important', - ); - } - }; - - const setEndCursor = () => { - if (editorRootElement !== null) { - editorRootElement.style.setProperty('cursor', 'text'); - } - if (document.body !== null) { - document.body.style.setProperty('cursor', 'default'); - document.body.style.setProperty( - '-webkit-user-select', - userSelect.current.value, - userSelect.current.priority, - ); - } - }; - - const handlePointerDown = ( - event: React.PointerEvent, - direction: number, - ) => { - if (!editor.isEditable()) { - return; - } - - const image = imageRef.current; - const controlWrapper = controlWrapperRef.current; - - if (image !== null && controlWrapper !== null) { - event.preventDefault(); - const { width, height } = image.getBoundingClientRect(); - const zoom = calculateZoomLevel(image); - const positioning = positioningRef.current; - positioning.startWidth = width; - positioning.startHeight = height; - positioning.ratio = width / height; - positioning.currentWidth = width; - positioning.currentHeight = height; - positioning.startX = event.clientX / zoom; - positioning.startY = event.clientY / zoom; - positioning.isResizing = true; - positioning.direction = direction; - - setStartCursor(direction); - onResizeStart(); - - controlWrapper.classList.add('image-control-wrapper--resizing'); - image.style.height = `${height}px`; - image.style.width = `${width}px`; - - document.addEventListener('pointermove', handlePointerMove); - document.addEventListener('pointerup', handlePointerUp); - } - }; - - const handlePointerMove = (event: PointerEvent) => { - const image = imageRef.current; - const positioning = positioningRef.current; - - const isHorizontal = - positioning.direction & (Direction.east | Direction.west); - const isVertical = - positioning.direction & (Direction.south | Direction.north); - - if (image !== null && positioning.isResizing) { - const zoom = calculateZoomLevel(image); - if (isHorizontal && isVertical) { - let diff = Math.floor(positioning.startX - event.clientX / zoom); - diff = positioning.direction & Direction.east ? -diff : diff; - - const width = clamp( - positioning.startWidth + diff, - minWidth, - maxWidthContainer, - ); - - const height = width / positioning.ratio; - image.style.width = `${width}px`; - image.style.height = `${height}px`; - positioning.currentHeight = height; - positioning.currentWidth = width; - } else if (isVertical) { - let diff = Math.floor(positioning.startY - event.clientY / zoom); - diff = positioning.direction & Direction.south ? -diff : diff; - - const height = clamp( - positioning.startHeight + diff, - minHeight, - maxHeightContainer, - ); - - image.style.height = `${height}px`; - positioning.currentHeight = height; - } else { - let diff = Math.floor(positioning.startX - event.clientX / zoom); - diff = positioning.direction & Direction.east ? -diff : diff; - - const width = clamp( - positioning.startWidth + diff, - minWidth, - maxWidthContainer, - ); - - image.style.width = `${width}px`; - positioning.currentWidth = width; - } - } - }; - - const handlePointerUp = () => { - const image = imageRef.current; - const positioning = positioningRef.current; - const controlWrapper = controlWrapperRef.current; - if (image !== null && controlWrapper !== null && positioning.isResizing) { - const width = positioning.currentWidth; - const height = positioning.currentHeight; - positioning.startWidth = 0; - positioning.startHeight = 0; - positioning.ratio = 0; - positioning.startX = 0; - positioning.startY = 0; - positioning.currentWidth = 0; - positioning.currentHeight = 0; - positioning.isResizing = false; - - controlWrapper.classList.remove('image-control-wrapper--resizing'); - - setEndCursor(); - onResizeEnd(width, height); - - document.removeEventListener('pointermove', handlePointerMove); - document.removeEventListener('pointerup', handlePointerUp); - } - }; - - return ( -
-
{ - handlePointerDown(event, Direction.north); - }} - /> -
{ - handlePointerDown(event, Direction.north | Direction.east); - }} - /> -
{ - handlePointerDown(event, Direction.east); - }} - /> -
{ - handlePointerDown(event, Direction.south | Direction.east); - }} - /> -
{ - handlePointerDown(event, Direction.south); - }} - /> -
{ - handlePointerDown(event, Direction.south | Direction.west); - }} - /> -
{ - handlePointerDown(event, Direction.west); - }} - /> -
{ - handlePointerDown(event, Direction.north | Direction.west); - }} - /> -
- ); -} diff --git a/src/editor/nodes/MentionNode.ts b/src/editor/nodes/MentionNode.ts deleted file mode 100644 index 575ce0b..0000000 --- a/src/editor/nodes/MentionNode.ts +++ /dev/null @@ -1,130 +0,0 @@ -import { - $applyNodeReplacement, - type DOMConversionMap, - type DOMConversionOutput, - type DOMExportOutput, - type EditorConfig, - type LexicalNode, - type NodeKey, - type SerializedTextNode, - type Spread, - TextNode, -} from 'lexical'; - -export type SerializedMentionNode = Spread< - { - mentionName: string; - }, - SerializedTextNode ->; - -function $convertMentionElement( - domNode: HTMLElement, -): DOMConversionOutput | null { - const textContent = domNode.textContent; - const mentionName = domNode.getAttribute('data-lexical-mention-name'); - - if (textContent !== null) { - const node = $createMentionNode( - typeof mentionName === 'string' ? mentionName : textContent, - textContent, - ); - return { - node, - }; - } - - return null; -} - -const mentionStyle = 'background-color: rgba(24, 119, 232, 0.2)'; - -export class MentionNode extends TextNode { - __mention: string; - - static getType(): string { - return 'mention'; - } - - static clone(node: MentionNode): MentionNode { - return new MentionNode(node.__mention, node.__text, node.__key); - } - - static importJSON(serializedNode: SerializedMentionNode): MentionNode { - return $createMentionNode(serializedNode.mentionName).updateFromJSON( - serializedNode, - ); - } - - constructor(mentionName: string, text?: string, key?: NodeKey) { - super(text ?? mentionName, key); - this.__mention = mentionName; - } - - exportJSON(): SerializedMentionNode { - return { - ...super.exportJSON(), - mentionName: this.__mention, - }; - } - - createDOM(config: EditorConfig): HTMLElement { - const dom = super.createDOM(config); - dom.style.cssText = mentionStyle; - dom.className = 'mention'; - dom.spellcheck = false; - - return dom; - } - - exportDOM(): DOMExportOutput { - const element = document.createElement('span'); - element.setAttribute('data-lexical-mention', 'true'); - if (this.__text !== this.__mention) { - element.setAttribute('data-lexical-mention-name', this.__mention); - } - element.textContent = this.__text; - return {element}; - } - - static importDOM(): DOMConversionMap | null { - return { - span: (domNode: HTMLElement) => { - if (!domNode.hasAttribute('data-lexical-mention')) { - return null; - } - return { - conversion: $convertMentionElement, - priority: 1, - }; - }, - }; - } - - isTextEntity(): true { - return true; - } - - canInsertTextBefore(): boolean { - return false; - } - - canInsertTextAfter(): boolean { - return false; - } -} - -export function $createMentionNode( - mentionName: string, - textContent?: string, -): MentionNode { - const mentionNode = new MentionNode(mentionName, textContent ?? mentionName); - mentionNode.setMode('segmented').toggleDirectionless(); - return $applyNodeReplacement(mentionNode); -} - -export function $isMentionNode( - node: LexicalNode | null | undefined, -): node is MentionNode { - return node instanceof MentionNode; -} diff --git a/src/editor/plugins/DragDropPastePlugin.tsx b/src/editor/plugins/DragDropPastePlugin.tsx deleted file mode 100644 index cceb6d4..0000000 --- a/src/editor/plugins/DragDropPastePlugin.tsx +++ /dev/null @@ -1,49 +0,0 @@ -import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'; -import { DRAG_DROP_PASTE } from '@lexical/rich-text'; -import { isMimeType, mediaFileReader } from '@lexical/utils'; -import { COMMAND_PRIORITY_LOW } from 'lexical'; -import { useEffect } from 'react'; - -import { INSERT_IMAGE_COMMAND } from './ImagesPlugin'; - -const ACCEPTABLE_IMAGE_TYPES = [ - 'image/', - 'image/heic', - 'image/heif', - 'image/gif', - 'image/webp', - 'image/png', - 'image/jpeg', - 'image/jpg', - 'image/svg+xml', -]; - -export default function DragDropPastePlugin(): null { - const [editor] = useLexicalComposerContext(); - - useEffect(() => { - return editor.registerCommand( - DRAG_DROP_PASTE, - (files) => { - (async () => { - const filesResult = await mediaFileReader( - files, - [ACCEPTABLE_IMAGE_TYPES].flatMap((x) => x), - ); - for (const { file, result } of filesResult) { - if (isMimeType(file, ACCEPTABLE_IMAGE_TYPES)) { - editor.dispatchCommand(INSERT_IMAGE_COMMAND, { - altText: file.name, - src: result, - }); - } - } - })(); - return true; - }, - COMMAND_PRIORITY_LOW, - ); - }, [editor]); - - return null; -} diff --git a/src/editor/plugins/HashtagPlugin.tsx b/src/editor/plugins/HashtagPlugin.tsx deleted file mode 100644 index 6d5ba75..0000000 --- a/src/editor/plugins/HashtagPlugin.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import type {JSX} from 'react'; - -import {HashtagNode, registerLexicalHashtag} from '@lexical/hashtag'; -import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; -import {useEffect} from 'react'; - -export default function HashtagPlugin(): JSX.Element | null { - const [editor] = useLexicalComposerContext(); - - useEffect(() => { - if (!editor.hasNodes([HashtagNode])) { - throw new Error('HashtagPlugin: HashtagNode not registered on editor'); - } - return registerLexicalHashtag(editor); - }, [editor]); - - return null; -} diff --git a/src/editor/plugins/ImagesPlugin.tsx b/src/editor/plugins/ImagesPlugin.tsx deleted file mode 100644 index d15afc3..0000000 --- a/src/editor/plugins/ImagesPlugin.tsx +++ /dev/null @@ -1,47 +0,0 @@ -import type { LexicalCommand } from 'lexical'; - -import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'; -import { $wrapNodeInElement, mergeRegister } from '@lexical/utils'; -import { - $createParagraphNode, - $insertNodes, - $isRootOrShadowRoot, - COMMAND_PRIORITY_EDITOR, - createCommand, -} from 'lexical'; -import { useEffect } from 'react'; - -import { $createImageNode, ImageNode, type ImagePayload } from '../nodes/ImageNode'; - -export type InsertImagePayload = Readonly; - -export const INSERT_IMAGE_COMMAND: LexicalCommand = - createCommand('INSERT_IMAGE_COMMAND'); - -export default function ImagesPlugin(): null { - const [editor] = useLexicalComposerContext(); - - useEffect(() => { - if (!editor.hasNodes([ImageNode])) { - throw new Error('ImagesPlugin: ImageNode not registered on editor'); - } - - return mergeRegister( - editor.registerCommand( - INSERT_IMAGE_COMMAND, - (payload) => { - const imageNode = $createImageNode(payload); - $insertNodes([imageNode]); - if ($isRootOrShadowRoot(imageNode.getParentOrThrow())) { - $wrapNodeInElement(imageNode, $createParagraphNode).selectEnd(); - } - - return true; - }, - COMMAND_PRIORITY_EDITOR, - ), - ); - }, [editor]); - - return null; -} diff --git a/src/editor/plugins/MarkdownShortcutPlugin.tsx b/src/editor/plugins/MarkdownShortcutPlugin.tsx deleted file mode 100644 index b31c26a..0000000 --- a/src/editor/plugins/MarkdownShortcutPlugin.tsx +++ /dev/null @@ -1,7 +0,0 @@ -import type { JSX } from 'react'; -import { MarkdownShortcutPlugin } from '@lexical/react/LexicalMarkdownShortcutPlugin'; -import { EDITOR_TRANSFORMERS } from './MarkdownTransformers'; - -export default function MarkdownPlugin(): JSX.Element { - return ; -} diff --git a/src/editor/plugins/MarkdownTransformers.ts b/src/editor/plugins/MarkdownTransformers.ts deleted file mode 100644 index c08b1b1..0000000 --- a/src/editor/plugins/MarkdownTransformers.ts +++ /dev/null @@ -1,258 +0,0 @@ -import type { ElementTransformer, TextMatchTransformer, Transformer } from '@lexical/markdown'; -import { - CHECK_LIST, - ELEMENT_TRANSFORMERS, - MULTILINE_ELEMENT_TRANSFORMERS, - TEXT_FORMAT_TRANSFORMERS, - TEXT_MATCH_TRANSFORMERS, -} from '@lexical/markdown'; -import { - $createHorizontalRuleNode, - $isHorizontalRuleNode, -} from '@lexical/react/LexicalHorizontalRuleNode'; -import { - $createTableCellNode, - $createTableNode, - $createTableRowNode, - $isTableCellNode, - $isTableNode, - $isTableRowNode, - TableCellHeaderStates, - type TableCellNode, - type TableNode, -} from '@lexical/table'; -import { - $createTextNode, - $isParagraphNode, - $isTextNode, - type LexicalNode, -} from 'lexical'; - -import { $createImageNode, $isImageNode } from '../nodes/ImageNode'; - -// Horizontal Rule transformer (---, ***, ___) -export const HR: ElementTransformer = { - dependencies: [], - export: (node: LexicalNode) => { - return $isHorizontalRuleNode(node) ? '***' : null; - }, - regExp: /^(---|\*\*\*|___)\s?$/, - replace: (parentNode, _1, _2, isImport) => { - const line = $createHorizontalRuleNode(); - - if (isImport || parentNode.getNextSibling() != null) { - parentNode.replace(line); - } else { - parentNode.insertBefore(line); - } - - line.selectNext(); - }, - type: 'element', -}; - -// Image transformer ![alt](url) -export const IMAGE: TextMatchTransformer = { - dependencies: [], - export: (node) => { - if (!$isImageNode(node)) { - return null; - } - - return `![${node.getAltText()}](${node.getSrc()})`; - }, - importRegExp: /!(?:\[([^[]*)\])(?:\(([^(]+)\))/, - regExp: /!(?:\[([^[]*)\])(?:\(([^(]+)\))$/, - replace: (textNode, match) => { - const [, altText, src] = match; - const imageNode = $createImageNode({ - altText, - maxWidth: 800, - src, - }); - textNode.replace(imageNode); - }, - trigger: ')', - type: 'text-match', -}; - -// Table transformer -const TABLE_ROW_REG_EXP = /^(?:\|)(.+)(?:\|)\s?$/; -const TABLE_ROW_DIVIDER_REG_EXP = /^(\| ?:?-*:? ?)+\|\s?$/; - -const $createTableCell = (textContent: string): TableCellNode => { - const cell = $createTableCellNode(TableCellHeaderStates.NO_STATUS); - const text = $createTextNode(textContent.trim()); - cell.append(text); - return cell; -}; - -const mapToTableCells = (textContent: string): Array | null => { - const match = textContent.match(TABLE_ROW_REG_EXP); - if (!match || !match[1]) { - return null; - } - return match[1].split('|').map((text) => $createTableCell(text)); -}; - -function getTableColumnsSize(table: TableNode) { - const row = table.getFirstChild(); - return $isTableRowNode(row) ? row.getChildrenSize() : 0; -} - -export const TABLE: ElementTransformer = { - dependencies: [], - export: (node: LexicalNode) => { - if (!$isTableNode(node)) { - return null; - } - - const output: string[] = []; - - for (const row of node.getChildren()) { - const rowOutput = []; - if (!$isTableRowNode(row)) { - continue; - } - - let isHeaderRow = false; - for (const cell of row.getChildren()) { - if ($isTableCellNode(cell)) { - const textContent = cell.getTextContent().replace(/\n/g, '\\n').trim(); - rowOutput.push(textContent); - if (cell.__headerState === TableCellHeaderStates.ROW) { - isHeaderRow = true; - } - } - } - - output.push(`| ${rowOutput.join(' | ')} |`); - if (isHeaderRow) { - output.push(`| ${rowOutput.map((_) => '---').join(' | ')} |`); - } - } - - return output.join('\n'); - }, - regExp: TABLE_ROW_REG_EXP, - replace: (parentNode, _1, match) => { - // Header row divider - if (TABLE_ROW_DIVIDER_REG_EXP.test(match[0])) { - const table = parentNode.getPreviousSibling(); - if (!table || !$isTableNode(table)) { - return; - } - - const rows = table.getChildren(); - const lastRow = rows[rows.length - 1]; - if (!lastRow || !$isTableRowNode(lastRow)) { - return; - } - - // Add header state to row cells - lastRow.getChildren().forEach((cell) => { - if (!$isTableCellNode(cell)) { - return; - } - cell.setHeaderStyles( - TableCellHeaderStates.ROW, - TableCellHeaderStates.ROW, - ); - }); - - // Remove divider line - parentNode.remove(); - return; - } - - const matchCells = mapToTableCells(match[0]); - - if (matchCells == null) { - return; - } - - const rows = [matchCells]; - let sibling = parentNode.getPreviousSibling(); - let maxCells = matchCells.length; - - while (sibling) { - if (!$isParagraphNode(sibling)) { - break; - } - - if (sibling.getChildrenSize() !== 1) { - break; - } - - const firstChild = sibling.getFirstChild(); - - if (!$isTextNode(firstChild)) { - break; - } - - const cells = mapToTableCells(firstChild.getTextContent()); - - if (cells == null) { - break; - } - - maxCells = Math.max(maxCells, cells.length); - rows.unshift(cells); - const previousSibling = sibling.getPreviousSibling(); - sibling.remove(); - sibling = previousSibling; - } - - const table = $createTableNode(); - - for (const cells of rows) { - const tableRow = $createTableRowNode(); - table.append(tableRow); - - for (let i = 0; i < maxCells; i++) { - tableRow.append(i < cells.length ? cells[i] : $createTableCell('')); - } - } - - const previousSibling = parentNode.getPreviousSibling(); - if ( - $isTableNode(previousSibling) && - getTableColumnsSize(previousSibling) === maxCells - ) { - previousSibling.append(...table.getChildren()); - parentNode.remove(); - } else { - parentNode.replace(table); - } - - table.selectEnd(); - }, - type: 'element', -}; - -// Export all transformers for full markdown support -// Includes support for: -// - Headings (# ## ###) -// - Bold (**text** or __text__) -// - Italic (*text* or _text_) -// - Strikethrough (~~text~~) -// - Code (`code`) -// - Links ([text](url)) -// - Lists (ordered and unordered) -// - Checkboxes (- [ ] or - [x]) -// - Blockquotes (>) -// - Code blocks (```) -// - Horizontal rules (---, ***, ___) -// - Tables (| col1 | col2 |) -// - Images (![alt](url)) - -export const EDITOR_TRANSFORMERS: Array = [ - TABLE, - HR, - IMAGE, - CHECK_LIST, - ...ELEMENT_TRANSFORMERS, - ...MULTILINE_ELEMENT_TRANSFORMERS, - ...TEXT_FORMAT_TRANSFORMERS, - ...TEXT_MATCH_TRANSFORMERS, -]; diff --git a/src/editor/plugins/MentionsPlugin.tsx b/src/editor/plugins/MentionsPlugin.tsx deleted file mode 100644 index 8ffdc5c..0000000 --- a/src/editor/plugins/MentionsPlugin.tsx +++ /dev/null @@ -1,288 +0,0 @@ -import type {JSX} from 'react'; -import type {MenuTextMatch} from '@lexical/react/LexicalTypeaheadMenuPlugin'; - -import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; -import { - LexicalTypeaheadMenuPlugin, - MenuOption, - useBasicTypeaheadTriggerMatch, -} from '@lexical/react/LexicalTypeaheadMenuPlugin'; -import {TextNode} from 'lexical'; -import {useCallback, useEffect, useMemo, useState} from 'react'; -import * as ReactDOM from 'react-dom'; - -import {$createMentionNode} from '../nodes/MentionNode'; - -const PUNCTUATION = - '\\.,\\+\\*\\?\\$\\@\\|#{}\\(\\)\\^\\-\\[\\]\\\\/!%\'"~=<>_:;'; - -const TRIGGERS = ['@'].join(''); - -// Chars we expect to see in a mention (non-space, non-punctuation). -const VALID_CHARS = '[^' + TRIGGERS + PUNCTUATION + '\\s]'; - -// Non-standard series of chars. Each series must be preceded and followed by -// a valid char. -const VALID_JOINS = - '(?:' + - '\\.[ |$]|' + // E.g. "r. " in "Mr. Smith" - ' |' + // E.g. " " in "Josh Duck" - '[' + - PUNCTUATION + - ']|' + // E.g. "-' in "Salier-Hellendag" - ')'; - -const LENGTH_LIMIT = 75; - -const AtSignMentionsRegex = new RegExp( - '(^|\\s|\\()(' + - '[' + - TRIGGERS + - ']' + - '((?:' + - VALID_CHARS + - VALID_JOINS + - '){0,' + - LENGTH_LIMIT + - '})' + - ')$', -); - -// 50 is the longest alias length limit. -const ALIAS_LENGTH_LIMIT = 50; - -// Regex used to match alias. -const AtSignMentionsRegexAliasRegex = new RegExp( - '(^|\\s|\\()(' + - '[' + - TRIGGERS + - ']' + - '((?:' + - VALID_CHARS + - '){0,' + - ALIAS_LENGTH_LIMIT + - '})' + - ')$', -); - -// At most, 5 suggestions are shown in the popup. -const SUGGESTION_LIST_LENGTH_LIMIT = 5; - -const mentionsCache = new Map(); - -const dummyMentionsData = [ - 'Alice Johnson', - 'Bob Smith', - 'Charlie Brown', - 'David Wilson', - 'Emma Davis', - 'Frank Miller', - 'Grace Lee', - 'Henry Taylor', - 'Isabella Martinez', - 'Jack Anderson', -]; - -const dummyLookupService = { - search(string: string, callback: (results: Array) => void): void { - setTimeout(() => { - const results = dummyMentionsData.filter((mention) => - mention.toLowerCase().includes(string.toLowerCase()), - ); - callback(results); - }, 100); - }, -}; - -function useMentionLookupService(mentionString: string | null) { - const [results, setResults] = useState>([]); - - useEffect(() => { - const cachedResults = mentionsCache.get(mentionString); - - if (mentionString == null) { - setResults([]); - return; - } - - if (cachedResults === null) { - return; - } else if (cachedResults !== undefined) { - setResults(cachedResults); - return; - } - - mentionsCache.set(mentionString, null); - dummyLookupService.search(mentionString, (newResults) => { - mentionsCache.set(mentionString, newResults); - setResults(newResults); - }); - }, [mentionString]); - - return results; -} - -function checkForAtSignMentions( - text: string, - minMatchLength: number, -): MenuTextMatch | null { - let match = AtSignMentionsRegex.exec(text); - - if (match === null) { - match = AtSignMentionsRegexAliasRegex.exec(text); - } - if (match !== null) { - // The strategy ignores leading whitespace but we need to know it's - // length to add it to the leadOffset - const maybeLeadingWhitespace = match[1]; - - const matchingString = match[3]; - if (matchingString.length >= minMatchLength) { - return { - leadOffset: match.index + maybeLeadingWhitespace.length, - matchingString, - replaceableString: match[2], - }; - } - } - return null; -} - -function getPossibleQueryMatch(text: string): MenuTextMatch | null { - return checkForAtSignMentions(text, 1); -} - -class MentionTypeaheadOption extends MenuOption { - name: string; - picture: JSX.Element; - - constructor(name: string, picture: JSX.Element) { - super(name); - this.name = name; - this.picture = picture; - } -} - -function MentionsTypeaheadMenuItem({ - index, - isSelected, - onClick, - onMouseEnter, - option, -}: { - index: number; - isSelected: boolean; - onClick: () => void; - onMouseEnter: () => void; - option: MentionTypeaheadOption; -}) { - let className = 'item'; - if (isSelected) { - className += ' selected'; - } - return ( -
  • - {option.picture} - {option.name} -
  • - ); -} - -export default function MentionsPlugin(): JSX.Element | null { - const [editor] = useLexicalComposerContext(); - - const [queryString, setQueryString] = useState(null); - - const results = useMentionLookupService(queryString); - - const checkForSlashTriggerMatch = useBasicTypeaheadTriggerMatch('/', { - minLength: 0, - }); - - const options = useMemo( - () => - results - .map( - (result) => - new MentionTypeaheadOption(result, ), - ) - .slice(0, SUGGESTION_LIST_LENGTH_LIMIT), - [results], - ); - - const onSelectOption = useCallback( - ( - selectedOption: MentionTypeaheadOption, - nodeToReplace: TextNode | null, - closeMenu: () => void, - ) => { - editor.update(() => { - const mentionNode = $createMentionNode(selectedOption.name); - if (nodeToReplace) { - nodeToReplace.replace(mentionNode); - } - mentionNode.select(); - closeMenu(); - }); - }, - [editor], - ); - - const checkForMentionMatch = useCallback( - (text: string) => { - const slashMatch = checkForSlashTriggerMatch(text, editor); - if (slashMatch !== null) { - return null; - } - return getPossibleQueryMatch(text); - }, - [checkForSlashTriggerMatch, editor], - ); - - return ( - - onQueryChange={setQueryString} - onSelectOption={onSelectOption} - triggerFn={checkForMentionMatch} - options={options} - menuRenderFn={( - anchorElementRef, - {selectedIndex, selectOptionAndCleanUp, setHighlightedIndex}, - ) => - anchorElementRef.current && results.length - ? ReactDOM.createPortal( -
    -
      - {options.map((option, i: number) => ( - { - setHighlightedIndex(i); - selectOptionAndCleanUp(option); - }} - onMouseEnter={() => { - setHighlightedIndex(i); - }} - key={option.key} - option={option} - /> - ))} -
    -
    , - anchorElementRef.current, - ) - : null - } - /> - ); -} diff --git a/src/editor/plugins/ToolbarPlugin.tsx b/src/editor/plugins/ToolbarPlugin.tsx deleted file mode 100644 index ee63995..0000000 --- a/src/editor/plugins/ToolbarPlugin.tsx +++ /dev/null @@ -1,301 +0,0 @@ -import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'; -import { useCallback, useEffect, useRef, useState } from 'react'; -import { - CAN_REDO_COMMAND, - CAN_UNDO_COMMAND, - REDO_COMMAND, - UNDO_COMMAND, - SELECTION_CHANGE_COMMAND, - FORMAT_TEXT_COMMAND, - FORMAT_ELEMENT_COMMAND, - COMMAND_PRIORITY_CRITICAL, - $getSelection, - $isRangeSelection, - $isRootOrShadowRoot, - $isElementNode, -} from 'lexical'; -import type { ElementFormatType, TextFormatType } from 'lexical'; -import { $getSelectionStyleValueForProperty, $patchStyleText } from '@lexical/selection'; -import { $findMatchingParent } from '@lexical/utils'; - -import DropdownColorPicker from '../ui/DropdownColorPicker'; -import { exportToJSON, importFromJSON } from '../utils/exportImport'; -import '../styles/toolbar.css'; - -export default function ToolbarPlugin() { - const [editor] = useLexicalComposerContext(); - const toolbarRef = useRef(null); - const [canUndo, setCanUndo] = useState(false); - const [canRedo, setCanRedo] = useState(false); - const [isBold, setIsBold] = useState(false); - const [isItalic, setIsItalic] = useState(false); - const [isUnderline, setIsUnderline] = useState(false); - const [isStrikethrough, setIsStrikethrough] = useState(false); - const [isCode, setIsCode] = useState(false); - const [fontColor, setFontColor] = useState('#000'); - const [bgColor, setBgColor] = useState('#fff'); - const [fontSize, setFontSize] = useState('15px'); - const [elementFormat, setElementFormat] = useState('left'); - - const updateToolbar = useCallback(() => { - const selection = $getSelection(); - if ($isRangeSelection(selection)) { - // Update text format - setIsBold(selection.hasFormat('bold')); - setIsItalic(selection.hasFormat('italic')); - setIsUnderline(selection.hasFormat('underline')); - setIsStrikethrough(selection.hasFormat('strikethrough')); - setIsCode(selection.hasFormat('code')); - - // Update color - setFontColor( - $getSelectionStyleValueForProperty(selection, 'color', '#000') - ); - setBgColor( - $getSelectionStyleValueForProperty(selection, 'background-color', '#fff') - ); - setFontSize( - $getSelectionStyleValueForProperty(selection, 'font-size', '15px') - ); - - // Update element format (alignment) - const node = selection.anchor.getNode(); - const element = - node.getKey() === 'root' - ? node - : $findMatchingParent(node, (e) => { - const parent = e.getParent(); - return parent !== null && $isRootOrShadowRoot(parent); - }); - - if (element !== null && $isElementNode(element)) { - const formatType = element.getFormatType(); - setElementFormat(formatType || 'left'); - } - } - }, [editor]); - - useEffect(() => { - return editor.registerCommand( - SELECTION_CHANGE_COMMAND, - () => { - updateToolbar(); - return false; - }, - COMMAND_PRIORITY_CRITICAL - ); - }, [editor, updateToolbar]); - - useEffect(() => { - return editor.registerUpdateListener(({ editorState }) => { - editorState.read(() => { - updateToolbar(); - }); - }); - }, [editor, updateToolbar]); - - useEffect(() => { - return editor.registerCommand( - CAN_UNDO_COMMAND, - (payload) => { - setCanUndo(payload); - return false; - }, - COMMAND_PRIORITY_CRITICAL - ); - }, [editor]); - - useEffect(() => { - return editor.registerCommand( - CAN_REDO_COMMAND, - (payload) => { - setCanRedo(payload); - return false; - }, - COMMAND_PRIORITY_CRITICAL - ); - }, [editor]); - - const formatText = (format: TextFormatType) => { - editor.dispatchCommand(FORMAT_TEXT_COMMAND, format); - }; - - const onFontColorSelect = useCallback( - (value: string) => { - editor.update(() => { - const selection = $getSelection(); - if ($isRangeSelection(selection)) { - $patchStyleText(selection, { color: value }); - } - }); - }, - [editor] - ); - - const onBgColorSelect = useCallback( - (value: string) => { - editor.update(() => { - const selection = $getSelection(); - if ($isRangeSelection(selection)) { - $patchStyleText(selection, { 'background-color': value }); - } - }); - }, - [editor] - ); - - const onFontSizeChange = useCallback( - (e: React.ChangeEvent) => { - const value = e.target.value; - editor.update(() => { - const selection = $getSelection(); - if ($isRangeSelection(selection)) { - $patchStyleText(selection, { 'font-size': value }); - } - }); - }, - [editor] - ); - - return ( -
    - - -
    - - -
    - - - - - - -
    - - - -
    - - - - - -
    - - - -
    - ); -} diff --git a/src/editor/styles/editor.css b/src/editor/styles/editor.css deleted file mode 100644 index e6a62a6..0000000 --- a/src/editor/styles/editor.css +++ /dev/null @@ -1,477 +0,0 @@ -.editor-container { - margin: 20px auto; - border-radius: 8px; - max-width: 1100px; - color: #000; - position: relative; - line-height: 1.7; - font-weight: 400; - text-align: left; - border: 1px solid #e0e0e0; - background: #fff; -} - -.editor-inner { - background: #fff; - position: relative; - border-bottom-left-radius: 8px; - border-bottom-right-radius: 8px; -} - -.editor-input { - min-height: 300px; - resize: vertical; - font-size: 15px; - caret-color: rgb(5, 5, 5); - position: relative; - tab-size: 1; - outline: 0; - padding: 15px 20px; - caret-color: #444; -} - -.editor-placeholder { - color: #999; - overflow: hidden; - position: absolute; - text-overflow: ellipsis; - top: 15px; - left: 20px; - font-size: 15px; - user-select: none; - display: inline-block; - pointer-events: none; -} - -.editor-paragraph { - margin: 0; - margin-bottom: 8px; - position: relative; -} - -.editor-paragraph:last-child { - margin-bottom: 0; -} - -.editor-heading-h1 { - font-size: 2em; - font-weight: 700; - margin: 0; - margin-bottom: 12px; - padding: 0; -} - -.editor-heading-h2 { - font-size: 1.5em; - font-weight: 700; - margin: 0; - margin-bottom: 10px; - padding: 0; -} - -.editor-heading-h3 { - font-size: 1.25em; - font-weight: 700; - margin: 0; - margin-bottom: 8px; - padding: 0; -} - -.editor-quote { - margin: 0; - margin-left: 20px; - margin-bottom: 10px; - font-size: 15px; - color: #666; - border-left: 4px solid #ccc; - padding-left: 16px; -} - -.editor-code { - background-color: #f4f4f4; - font-family: Menlo, Consolas, Monaco, monospace; - display: block; - padding: 8px 12px; - line-height: 1.53; - font-size: 13px; - margin: 0; - margin-top: 8px; - margin-bottom: 8px; - overflow-x: auto; - position: relative; - tab-size: 2; - border-radius: 4px; -} - -.editor-text-bold { - font-weight: bold; -} - -.editor-text-italic { - font-style: italic; -} - -.editor-text-underline { - text-decoration: underline; -} - -.editor-text-strikethrough { - text-decoration: line-through; -} - -.editor-text-code { - background-color: #f4f4f4; - padding: 1px 4px; - font-family: Menlo, Consolas, Monaco, monospace; - font-size: 90%; - border-radius: 3px; -} - -.editor-list-ol { - padding: 0; - margin: 0; - margin-left: 20px; -} - -.editor-list-ul { - padding: 0; - margin: 0; - margin-left: 20px; -} - -.editor-listitem { - margin: 4px 0; -} - -.editor-nested-listitem { - list-style-type: none; -} - -/* Horizontal Rule */ -.editor-hr { - border: none; - border-top: 2px solid #ccc; - margin: 15px 0; -} - -/* Link */ -.editor-link { - color: #0066cc; - text-decoration: none; - cursor: pointer; -} - -.editor-link:hover { - text-decoration: underline; -} - -/* Table */ -.editor-table { - border-collapse: collapse; - border-spacing: 0; - overflow-y: scroll; - overflow-x: scroll; - table-layout: fixed; - width: max-content; - margin: 15px 0; -} - -.editor-table-cell { - border: 1px solid #ccc; - min-width: 75px; - vertical-align: top; - text-align: start; - padding: 6px 8px; - position: relative; - outline: none; -} - -.editor-table-cell-header { - background-color: #f4f4f4; - font-weight: bold; - text-align: left; -} - -/* Checklist */ -.editor-listitem-checked, -.editor-listitem-unchecked { - position: relative; - margin-left: 8px; - margin-right: 8px; - padding-left: 24px; - padding-right: 24px; - list-style-type: none; - outline: none; -} - -.editor-listitem-checked { - text-decoration: line-through; -} - -.editor-listitem-unchecked:before, -.editor-listitem-checked:before { - content: ''; - width: 16px; - height: 16px; - top: 2px; - left: 0; - cursor: pointer; - display: block; - background-size: cover; - position: absolute; -} - -.editor-listitem-unchecked:before { - border: 1px solid #999; - border-radius: 2px; -} - -.editor-listitem-checked:before { - border: 1px solid #0066cc; - border-radius: 2px; - background-color: #0066cc; - background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 16 16'%3E%3Cpath fill='white' d='M13.5 2l-7.5 7.5-3.5-3.5-1.5 1.5 5 5 9-9z'/%3E%3C/svg%3E"); - background-repeat: no-repeat; - background-position: center; -} - -.editor-listitem-checked[dir='rtl']:before, -.editor-listitem-unchecked[dir='rtl']:before { - left: auto; - right: 0; -} - -.editor-listitem-checked[dir='rtl'], -.editor-listitem-unchecked[dir='rtl'] { - padding-left: 24px; - padding-right: 24px; -} - -/* Image */ -.editor-image { - cursor: default; - display: inline-block; - position: relative; - user-select: none; -} - -.editor-image img { - max-width: 100%; - cursor: default; -} - -.editor-image img.focused { - outline: 2px solid rgb(60, 132, 244); - user-select: none; -} - -.editor-image img.focused.draggable { - cursor: grab; -} - -.editor-image img.focused.draggable:active { - cursor: grabbing; -} - -.image-control-wrapper--resizing { - touch-action: none; -} - -/* Image Resizer */ -.image-resizer { - display: block; - width: 7px; - height: 7px; - position: absolute; - background-color: rgb(60, 132, 244); - border: 1px solid #fff; -} - -.image-resizer.image-resizer-n { - top: -6px; - left: 48%; - cursor: n-resize; -} - -.image-resizer.image-resizer-ne { - top: -6px; - right: -6px; - cursor: ne-resize; -} - -.image-resizer.image-resizer-e { - bottom: 48%; - right: -6px; - cursor: e-resize; -} - -.image-resizer.image-resizer-se { - bottom: -2px; - right: -6px; - cursor: nwse-resize; -} - -.image-resizer.image-resizer-s { - bottom: -2px; - left: 48%; - cursor: s-resize; -} - -.image-resizer.image-resizer-sw { - bottom: -2px; - left: -6px; - cursor: sw-resize; -} - -.image-resizer.image-resizer-w { - bottom: 48%; - left: -6px; - cursor: w-resize; -} - -.image-resizer.image-resizer-nw { - top: -6px; - left: -6px; - cursor: nw-resize; -} - -/* Text Alignment */ -.editor-text-left { - text-align: left; -} - -.editor-text-center { - text-align: center; -} - -.editor-text-right { - text-align: right; -} - -.editor-text-justify { - text-align: justify; -} - -/* Hashtag styles */ -.editor-hashtag { - background-color: rgba(88, 144, 255, 0.15); - border-bottom: 1px solid rgba(88, 144, 255, 0.3); - font-weight: 500; -} - -/* Mention styles */ -.mention { - background-color: rgba(24, 119, 232, 0.2); - color: #1877e8; - border-radius: 4px; - padding: 1px 3px; - font-weight: 500; - cursor: pointer; -} - -.mention:focus { - box-shadow: rgb(180 213 255) 0px 0px 0px 2px; - outline: none; -} - -/* Typeahead popover styles */ -.typeahead-popover { - background: #fff; - box-shadow: 0px 5px 10px rgba(0, 0, 0, 0.3); - border-radius: 8px; - position: relative; - z-index: 5; -} - -.typeahead-popover ul { - padding: 0; - list-style: none; - margin: 0; - border-radius: 8px; - max-height: 200px; - overflow-y: scroll; -} - -.typeahead-popover ul::-webkit-scrollbar { - display: none; -} - -.typeahead-popover ul { - -ms-overflow-style: none; - scrollbar-width: none; -} - -.typeahead-popover ul li { - margin: 0; - min-width: 180px; - font-size: 14px; - outline: none; - cursor: pointer; - border-radius: 8px; -} - -.typeahead-popover ul li.selected { - background: #eee; -} - -.typeahead-popover li { - margin: 0 8px 0 8px; - padding: 8px; - color: #050505; - cursor: pointer; - line-height: 16px; - font-size: 15px; - display: flex; - align-content: center; - flex-direction: row; - flex-shrink: 0; - background-color: #fff; - border-radius: 8px; - border: 0; -} - -.typeahead-popover li.active { - display: flex; - width: 20px; - height: 20px; - background-size: contain; -} - -.typeahead-popover li:first-child { - border-radius: 8px 8px 0px 0px; -} - -.typeahead-popover li:last-child { - border-radius: 0px 0px 8px 8px; -} - -.typeahead-popover li:hover { - background-color: #eee; -} - -.typeahead-popover li .text { - display: flex; - line-height: 20px; - flex-grow: 1; - min-width: 150px; -} - -.typeahead-popover li .icon { - display: flex; - width: 20px; - height: 20px; - user-select: none; - margin-right: 8px; - line-height: 16px; - background-size: contain; - background-repeat: no-repeat; - background-position: center; -} - -.mentions-menu { - width: 250px; -} - -.typeahead-popover li .icon.user { - background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='%23666'%3E%3Cpath d='M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z'/%3E%3C/svg%3E"); -} diff --git a/src/editor/styles/toolbar.css b/src/editor/styles/toolbar.css deleted file mode 100644 index 4b54899..0000000 --- a/src/editor/styles/toolbar.css +++ /dev/null @@ -1,155 +0,0 @@ -.toolbar { - display: flex; - background: #fff; - padding: 8px; - border-top-left-radius: 8px; - border-top-right-radius: 8px; - vertical-align: middle; - border-bottom: 1px solid #e0e0e0; - gap: 4px; - flex-wrap: wrap; - align-items: center; -} - -.toolbar button { - border: 0; - display: flex; - background: none; - border-radius: 4px; - padding: 6px; - cursor: pointer; - vertical-align: middle; - align-items: center; - justify-content: center; - transition: background-color 0.2s; -} - -.toolbar button:hover:not([disabled]) { - background-color: #f0f0f0; -} - -.toolbar button:disabled { - cursor: not-allowed; - opacity: 0.3; -} - -.toolbar button.active { - background-color: rgba(24, 144, 255, 0.1); -} - -.toolbar-item { - border: 0; - display: flex; - background: none; - border-radius: 4px; - padding: 6px; - cursor: pointer; - vertical-align: middle; - flex-shrink: 0; - align-items: center; - justify-content: space-between; -} - -.toolbar-item.spaced { - margin-right: 2px; -} - -.toolbar-item.block-controls { - background: none; - border: 1px solid #d0d0d0; - border-radius: 4px; - padding: 4px 8px; - font-size: 14px; - cursor: pointer; - min-width: 80px; -} - -.toolbar-item i.format { - background-size: contain; - display: inline-block; - height: 18px; - width: 18px; - vertical-align: -0.25em; - opacity: 0.7; -} - -.toolbar-item.active i.format, -.toolbar-item:hover:not([disabled]) i.format { - opacity: 1; -} - -.divider { - width: 1px; - background-color: #e0e0e0; - margin: 0 4px; - height: 24px; -} - -i.format.undo { - background-image: url('data:image/svg+xml;utf8,'); -} - -i.format.redo { - background-image: url('data:image/svg+xml;utf8,'); -} - -i.format.bold { - background-image: url('data:image/svg+xml;utf8,'); -} - -i.format.italic { - background-image: url('data:image/svg+xml;utf8,'); -} - -i.format.underline { - background-image: url('data:image/svg+xml;utf8,'); -} - -i.format.strikethrough { - background-image: url('data:image/svg+xml;utf8,'); -} - -i.format.code { - background-image: url('data:image/svg+xml;utf8,'); -} - -i.icon.font-color { - background-image: url('data:image/svg+xml;utf8,'); - display: inline-block; - height: 18px; - width: 18px; - background-size: contain; -} - -i.icon.bg-color { - background-image: url('data:image/svg+xml;utf8,'); - display: inline-block; - height: 18px; - width: 18px; - background-size: contain; -} - -i.format.left-align { - background-image: url('data:image/svg+xml;utf8,'); -} - -i.format.center-align { - background-image: url('data:image/svg+xml;utf8,'); -} - -i.format.right-align { - background-image: url('data:image/svg+xml;utf8,'); -} - -i.format.justify-align { - background-image: url('data:image/svg+xml;utf8,'); -} - -i.format.import { - background-image: url('data:image/svg+xml;utf8,'); -} - -i.format.export { - background-image: url('data:image/svg+xml;utf8,'); -} - diff --git a/src/editor/themes/EditorTheme.ts b/src/editor/themes/EditorTheme.ts deleted file mode 100644 index 94b1a80..0000000 --- a/src/editor/themes/EditorTheme.ts +++ /dev/null @@ -1,46 +0,0 @@ -import type { EditorThemeClasses } from 'lexical'; - -const theme: EditorThemeClasses = { - paragraph: 'editor-paragraph', - quote: 'editor-quote', - heading: { - h1: 'editor-heading-h1', - h2: 'editor-heading-h2', - h3: 'editor-heading-h3', - h4: 'editor-heading-h4', - h5: 'editor-heading-h5', - h6: 'editor-heading-h6', - }, - list: { - nested: { - listitem: 'editor-nested-listitem', - }, - ol: 'editor-list-ol', - ul: 'editor-list-ul', - listitem: 'editor-listitem', - listitemChecked: 'editor-listitem-checked', - listitemUnchecked: 'editor-listitem-unchecked', - }, - text: { - bold: 'editor-text-bold', - italic: 'editor-text-italic', - underline: 'editor-text-underline', - strikethrough: 'editor-text-strikethrough', - code: 'editor-text-code', - left: 'editor-text-left', - center: 'editor-text-center', - right: 'editor-text-right', - justify: 'editor-text-justify', - }, - code: 'editor-code', - codeHighlight: {}, - link: 'editor-link', - table: 'editor-table', - tableCell: 'editor-table-cell', - tableCellHeader: 'editor-table-cell-header', - hr: 'editor-hr', - image: 'editor-image', - hashtag: 'editor-hashtag', -}; - -export default theme; diff --git a/src/editor/ui/DropdownColorPicker.css b/src/editor/ui/DropdownColorPicker.css deleted file mode 100644 index 3ee739d..0000000 --- a/src/editor/ui/DropdownColorPicker.css +++ /dev/null @@ -1,50 +0,0 @@ -.color-picker-wrapper { - position: relative; -} - -.color-preview { - display: inline-block; - width: 16px; - height: 16px; - border-radius: 2px; - border: 1px solid #ccc; - margin-left: 4px; - vertical-align: middle; -} - -.color-picker-dropdown { - position: absolute; - top: 100%; - left: 0; - margin-top: 4px; - background: white; - border: 1px solid #ccc; - border-radius: 4px; - padding: 8px; - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); - z-index: 1000; -} - -.color-picker-basic-color { - display: grid; - grid-template-columns: repeat(6, 1fr); - gap: 4px; -} - -.color-picker-basic-color button { - width: 24px; - height: 24px; - border: 1px solid #ccc; - border-radius: 2px; - cursor: pointer; - padding: 0; -} - -.color-picker-basic-color button:hover { - border-color: #333; -} - -.color-picker-basic-color button.active { - border: 2px solid #1890ff; - border-radius: 4px; -} diff --git a/src/editor/ui/DropdownColorPicker.tsx b/src/editor/ui/DropdownColorPicker.tsx deleted file mode 100644 index 1e5fea0..0000000 --- a/src/editor/ui/DropdownColorPicker.tsx +++ /dev/null @@ -1,87 +0,0 @@ -import { useEffect, useRef, useState } from 'react'; -import './DropdownColorPicker.css'; - -const basicColors = [ - '#000000', '#ffffff', '#888888', '#ff0000', '#00ff00', '#0000ff', - '#ffff00', '#00ffff', '#ff00ff', '#c0c0c0', '#808080', '#800000', - '#808000', '#008000', '#800080', '#008080', '#000080', '#ffa500', - '#a52a2a', '#dc143c', '#ff1493', '#ff69b4', '#ffd700', '#adff2f', - '#00fa9a', '#00ced1', '#1e90ff', '#9370db', '#ff6347', '#40e0d0', -]; - -type Props = { - buttonClassName: string; - buttonAriaLabel?: string; - buttonIconClassName?: string; - color: string; - onChange?: (color: string) => void; - title?: string; -}; - -export default function DropdownColorPicker({ - buttonClassName, - buttonAriaLabel, - buttonIconClassName, - color, - onChange, - title, -}: Props) { - const [showPicker, setShowPicker] = useState(false); - const dropdownRef = useRef(null); - const buttonRef = useRef(null); - - useEffect(() => { - const handleClickOutside = (event: MouseEvent) => { - if ( - dropdownRef.current && - !dropdownRef.current.contains(event.target as Node) && - buttonRef.current && - !buttonRef.current.contains(event.target as Node) - ) { - setShowPicker(false); - } - }; - - if (showPicker) { - document.addEventListener('mousedown', handleClickOutside); - return () => { - document.removeEventListener('mousedown', handleClickOutside); - }; - } - }, [showPicker]); - - return ( -
    - - {showPicker && ( -
    -
    - {basicColors.map((basicColor) => ( -
    -
    - )} -
    - ); -} diff --git a/src/editor/ui/ImageResizer.tsx b/src/editor/ui/ImageResizer.tsx deleted file mode 100644 index b8ca26f..0000000 --- a/src/editor/ui/ImageResizer.tsx +++ /dev/null @@ -1,292 +0,0 @@ -import type { LexicalEditor } from 'lexical'; -import type { JSX } from 'react'; - -import { calculateZoomLevel } from '@lexical/utils'; -import { useRef } from 'react'; - -function clamp(value: number, min: number, max: number) { - return Math.min(Math.max(value, min), max); -} - -const Direction = { - east: 1 << 0, - north: 1 << 3, - south: 1 << 1, - west: 1 << 2, -}; - -export default function ImageResizer({ - onResizeStart, - onResizeEnd, - imageRef, - maxWidth, - editor, -}: { - editor: LexicalEditor; - imageRef: { current: null | HTMLElement }; - maxWidth?: number; - onResizeEnd: (width: 'inherit' | number, height: 'inherit' | number) => void; - onResizeStart: () => void; -}): JSX.Element { - const controlWrapperRef = useRef(null); - const userSelect = useRef({ - priority: '', - value: 'default', - }); - const positioningRef = useRef<{ - currentHeight: 'inherit' | number; - currentWidth: 'inherit' | number; - direction: number; - isResizing: boolean; - ratio: number; - startHeight: number; - startWidth: number; - startX: number; - startY: number; - }>({ - currentHeight: 0, - currentWidth: 0, - direction: 0, - isResizing: false, - ratio: 0, - startHeight: 0, - startWidth: 0, - startX: 0, - startY: 0, - }); - - const editorRootElement = editor.getRootElement(); - const maxWidthContainer = maxWidth - ? maxWidth - : editorRootElement !== null - ? editorRootElement.getBoundingClientRect().width - 20 - : 100; - const maxHeightContainer = - editorRootElement !== null - ? editorRootElement.getBoundingClientRect().height - 20 - : 100; - - const minWidth = 100; - const minHeight = 100; - - const setStartCursor = (direction: number) => { - const ew = direction === Direction.east || direction === Direction.west; - const ns = direction === Direction.north || direction === Direction.south; - const nwse = - (direction & Direction.north && direction & Direction.west) || - (direction & Direction.south && direction & Direction.east); - - const cursorDir = ew ? 'ew' : ns ? 'ns' : nwse ? 'nwse' : 'nesw'; - - if (editorRootElement !== null) { - editorRootElement.style.setProperty( - 'cursor', - `${cursorDir}-resize`, - 'important', - ); - } - if (document.body !== null) { - document.body.style.setProperty( - 'cursor', - `${cursorDir}-resize`, - 'important', - ); - userSelect.current.value = document.body.style.getPropertyValue( - '-webkit-user-select', - ); - userSelect.current.priority = document.body.style.getPropertyPriority( - '-webkit-user-select', - ); - document.body.style.setProperty( - '-webkit-user-select', - `none`, - 'important', - ); - } - }; - - const setEndCursor = () => { - if (editorRootElement !== null) { - editorRootElement.style.setProperty('cursor', 'text'); - } - if (document.body !== null) { - document.body.style.setProperty('cursor', 'default'); - document.body.style.setProperty( - '-webkit-user-select', - userSelect.current.value, - userSelect.current.priority, - ); - } - }; - - const handlePointerDown = ( - event: React.PointerEvent, - direction: number, - ) => { - if (!editor.isEditable()) { - return; - } - - const image = imageRef.current; - const controlWrapper = controlWrapperRef.current; - - if (image !== null && controlWrapper !== null) { - event.preventDefault(); - const { width, height } = image.getBoundingClientRect(); - const zoom = calculateZoomLevel(image); - const positioning = positioningRef.current; - positioning.startWidth = width; - positioning.startHeight = height; - positioning.ratio = width / height; - positioning.currentWidth = width; - positioning.currentHeight = height; - positioning.startX = event.clientX / zoom; - positioning.startY = event.clientY / zoom; - positioning.isResizing = true; - positioning.direction = direction; - - setStartCursor(direction); - onResizeStart(); - - controlWrapper.classList.add('image-control-wrapper--resizing'); - image.style.height = `${height}px`; - image.style.width = `${width}px`; - - document.addEventListener('pointermove', handlePointerMove); - document.addEventListener('pointerup', handlePointerUp); - } - }; - - const handlePointerMove = (event: PointerEvent) => { - const image = imageRef.current; - const positioning = positioningRef.current; - - const isHorizontal = - positioning.direction & (Direction.east | Direction.west); - const isVertical = - positioning.direction & (Direction.south | Direction.north); - - if (image !== null && positioning.isResizing) { - const zoom = calculateZoomLevel(image); - if (isHorizontal && isVertical) { - let diff = Math.floor(positioning.startX - event.clientX / zoom); - diff = positioning.direction & Direction.east ? -diff : diff; - - const width = clamp( - positioning.startWidth + diff, - minWidth, - maxWidthContainer, - ); - - const height = width / positioning.ratio; - image.style.width = `${width}px`; - image.style.height = `${height}px`; - positioning.currentHeight = height; - positioning.currentWidth = width; - } else if (isVertical) { - let diff = Math.floor(positioning.startY - event.clientY / zoom); - diff = positioning.direction & Direction.south ? -diff : diff; - - const height = clamp( - positioning.startHeight + diff, - minHeight, - maxHeightContainer, - ); - - image.style.height = `${height}px`; - positioning.currentHeight = height; - } else { - let diff = Math.floor(positioning.startX - event.clientX / zoom); - diff = positioning.direction & Direction.east ? -diff : diff; - - const width = clamp( - positioning.startWidth + diff, - minWidth, - maxWidthContainer, - ); - - image.style.width = `${width}px`; - positioning.currentWidth = width; - } - } - }; - - const handlePointerUp = () => { - const image = imageRef.current; - const positioning = positioningRef.current; - const controlWrapper = controlWrapperRef.current; - if (image !== null && controlWrapper !== null && positioning.isResizing) { - const width = positioning.currentWidth; - const height = positioning.currentHeight; - positioning.startWidth = 0; - positioning.startHeight = 0; - positioning.ratio = 0; - positioning.startX = 0; - positioning.startY = 0; - positioning.currentWidth = 0; - positioning.currentHeight = 0; - positioning.isResizing = false; - - controlWrapper.classList.remove('image-control-wrapper--resizing'); - - setEndCursor(); - onResizeEnd(width, height); - - document.removeEventListener('pointermove', handlePointerMove); - document.removeEventListener('pointerup', handlePointerUp); - } - }; - - return ( -
    -
    { - handlePointerDown(event, Direction.north); - }} - /> -
    { - handlePointerDown(event, Direction.north | Direction.east); - }} - /> -
    { - handlePointerDown(event, Direction.east); - }} - /> -
    { - handlePointerDown(event, Direction.south | Direction.east); - }} - /> -
    { - handlePointerDown(event, Direction.south); - }} - /> -
    { - handlePointerDown(event, Direction.south | Direction.west); - }} - /> -
    { - handlePointerDown(event, Direction.west); - }} - /> -
    { - handlePointerDown(event, Direction.north | Direction.west); - }} - /> -
    - ); -} diff --git a/src/editor/utils/exportImport.ts b/src/editor/utils/exportImport.ts deleted file mode 100644 index 6fcff53..0000000 --- a/src/editor/utils/exportImport.ts +++ /dev/null @@ -1,115 +0,0 @@ -import type { LexicalEditor, SerializedEditorState } from 'lexical'; -import { CLEAR_HISTORY_COMMAND } from 'lexical'; - -export interface SerializedDocument { - /** The serialized editorState produced by editorState.toJSON() */ - editorState: SerializedEditorState; - /** The time this document was created in epoch milliseconds (Date.now()) */ - lastSaved: number; - /** The source of the document, defaults to Cialloo */ - source: string; -} - -/** - * Generates a SerializedDocument from the current editor state - */ -function serializedDocumentFromEditorState( - editor: LexicalEditor, - config: Readonly<{ - source?: string; - lastSaved?: number; - }> = Object.freeze({}), -): SerializedDocument { - return { - editorState: editor.getEditorState().toJSON(), - lastSaved: config.lastSaved || Date.now(), - source: config.source || 'Cialloo Editor', - }; -} - -/** - * Exports the editor content as a JSON file - */ -export function exportToJSON( - editor: LexicalEditor, - config: Readonly<{ - fileName?: string; - source?: string; - }> = Object.freeze({}), -) { - const now = new Date(); - const serializedDocument = serializedDocumentFromEditorState(editor, { - ...config, - lastSaved: now.getTime(), - }); - const fileName = config.fileName || `document-${now.toISOString()}`; - exportBlob(serializedDocument, `${fileName}.json`); -} - -/** - * Creates a downloadable blob and triggers download - */ -function exportBlob(data: SerializedDocument, fileName: string) { - const a = document.createElement('a'); - const body = document.body; - - if (body === null) { - return; - } - - body.appendChild(a); - a.style.display = 'none'; - const json = JSON.stringify(data, null, 2); // Pretty print with 2 spaces - const blob = new Blob([json], { - type: 'application/json', - }); - const url = window.URL.createObjectURL(blob); - a.href = url; - a.download = fileName; - a.click(); - window.URL.revokeObjectURL(url); - a.remove(); -} - -/** - * Imports editor content from a JSON file - */ -export function importFromJSON(editor: LexicalEditor) { - readJSONFileFromSystem((text) => { - try { - const json = JSON.parse(text) as SerializedDocument; - const editorState = editor.parseEditorState(json.editorState); - editor.setEditorState(editorState); - editor.dispatchCommand(CLEAR_HISTORY_COMMAND, undefined); - } catch (error) { - console.error('Failed to import JSON:', error); - alert('Failed to import file. Please make sure it\'s a valid JSON export.'); - } - }); -} - -/** - * Reads a JSON file from the user's system - */ -function readJSONFileFromSystem(callback: (text: string) => void) { - const input = document.createElement('input'); - input.type = 'file'; - input.accept = '.json'; - input.addEventListener('change', (event: Event) => { - const target = event.target as HTMLInputElement; - - if (target.files) { - const file = target.files[0]; - const reader = new FileReader(); - reader.readAsText(file, 'UTF-8'); - - reader.onload = (readerEvent) => { - if (readerEvent.target) { - const content = readerEvent.target.result; - callback(content as string); - } - }; - } - }); - input.click(); -} diff --git a/src/main.tsx b/src/main.tsx index 6c9b52c..f4ca2de 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -16,7 +16,6 @@ import CreatePost from './pages/CreatePost.tsx' import Servers from './pages/Servers.tsx' import Forum from './pages/Forum.tsx' import AuthCallback from './pages/AuthCallback.tsx' -import EditorDemo from './pages/EditorDemo.tsx' import BlogPost from './pages/BlogPost.tsx' import EditPost from './pages/EditPost.tsx' @@ -39,7 +38,6 @@ createRoot(document.getElementById('root')!).render( } /> } /> } /> - } /> diff --git a/src/pages/EditorDemo.tsx b/src/pages/EditorDemo.tsx deleted file mode 100644 index 8e01495..0000000 --- a/src/pages/EditorDemo.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import RichTextEditor from '../editor/RichTextEditor'; -import '../editor/styles/editor.css'; -import '../editor/styles/toolbar.css'; - -export default function EditorDemo() { - return ( -
    -

    - Rich Text Editor Demo -

    -

    - A basic rich text editor with formatting toolbar featuring: - undo/redo, text size, bold, italic, underline, strikethrough, - code blocks, text color, and background color. -

    - -
    - ); -}