From e299694f221392ab09c8012eefe142c5e642ec39 Mon Sep 17 00:00:00 2001 From: cialloo Date: Sat, 25 Oct 2025 21:03:58 +0800 Subject: [PATCH] feat: add toolbar styles and functionality for blog editor feat: implement EditorTheme for consistent styling across editor components feat: define types for blog-related operations including image uploads and post creation feat: create DropdownColorPicker component for color selection in blog editor feat: implement ImageResizer component for resizing images in the blog editor feat: add export and import functionality for blog posts in JSON format feat: update main application routes to include CreatePost page feat: enhance Blog page with a button to navigate to CreatePost feat: implement CreatePost page with title, cover image upload, and content editor --- src/blog/BlogEditor.tsx | 120 +++++ src/blog/README.md | 148 ++++++ src/blog/api.ts | 128 ++++++ src/blog/index.ts | 7 + src/blog/nodes/ImageComponent.tsx | 335 ++++++++++++++ src/blog/nodes/ImageNode.tsx | 276 +++++++++++ src/blog/nodes/ImageResizer.tsx | 292 ++++++++++++ src/blog/nodes/MentionNode.ts | 130 ++++++ src/blog/plugins/DragDropPastePlugin.tsx | 101 +++++ src/blog/plugins/HashtagPlugin.tsx | 18 + src/blog/plugins/ImagesPlugin.tsx | 47 ++ src/blog/plugins/MarkdownShortcutPlugin.tsx | 7 + src/blog/plugins/MarkdownTransformers.ts | 258 +++++++++++ src/blog/plugins/MentionsPlugin.tsx | 288 ++++++++++++ src/blog/plugins/ToolbarPlugin.tsx | 301 ++++++++++++ src/blog/styles/editor.css | 477 ++++++++++++++++++++ src/blog/styles/toolbar.css | 155 +++++++ src/blog/themes/EditorTheme.ts | 46 ++ src/blog/types.ts | 33 ++ src/blog/ui/DropdownColorPicker.css | 50 ++ src/blog/ui/DropdownColorPicker.tsx | 87 ++++ src/blog/ui/ImageResizer.tsx | 292 ++++++++++++ src/blog/utils/exportImport.ts | 115 +++++ src/main.tsx | 2 + src/pages/Blog.tsx | 28 +- src/pages/CreatePost.tsx | 355 +++++++++++++++ 26 files changed, 4095 insertions(+), 1 deletion(-) create mode 100644 src/blog/BlogEditor.tsx create mode 100644 src/blog/README.md create mode 100644 src/blog/api.ts create mode 100644 src/blog/index.ts create mode 100644 src/blog/nodes/ImageComponent.tsx create mode 100644 src/blog/nodes/ImageNode.tsx create mode 100644 src/blog/nodes/ImageResizer.tsx create mode 100644 src/blog/nodes/MentionNode.ts create mode 100644 src/blog/plugins/DragDropPastePlugin.tsx create mode 100644 src/blog/plugins/HashtagPlugin.tsx create mode 100644 src/blog/plugins/ImagesPlugin.tsx create mode 100644 src/blog/plugins/MarkdownShortcutPlugin.tsx create mode 100644 src/blog/plugins/MarkdownTransformers.ts create mode 100644 src/blog/plugins/MentionsPlugin.tsx create mode 100644 src/blog/plugins/ToolbarPlugin.tsx create mode 100644 src/blog/styles/editor.css create mode 100644 src/blog/styles/toolbar.css create mode 100644 src/blog/themes/EditorTheme.ts create mode 100644 src/blog/types.ts create mode 100644 src/blog/ui/DropdownColorPicker.css create mode 100644 src/blog/ui/DropdownColorPicker.tsx create mode 100644 src/blog/ui/ImageResizer.tsx create mode 100644 src/blog/utils/exportImport.ts create mode 100644 src/pages/CreatePost.tsx diff --git a/src/blog/BlogEditor.tsx b/src/blog/BlogEditor.tsx new file mode 100644 index 0000000..228e7bf --- /dev/null +++ b/src/blog/BlogEditor.tsx @@ -0,0 +1,120 @@ +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: 'BlogEditor', + 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 BlogEditor() { + return ( + +
+ +
+ + } + placeholder={ +
Start writing your blog post...
+ } + ErrorBoundary={LexicalErrorBoundary} + /> + + + + + + + + + + + +
+
+
+ ); +} diff --git a/src/blog/README.md b/src/blog/README.md new file mode 100644 index 0000000..df9c87c --- /dev/null +++ b/src/blog/README.md @@ -0,0 +1,148 @@ +# Blog Module + +This module contains all the blog-related functionality for creating and managing blog posts. + +## Features + +### ✍️ Rich Text Editor +- Custom Lexical-based editor with full formatting support +- Markdown shortcuts +- Image upload with progress tracking +- Hashtags and mentions +- Tables, lists, code blocks, and more + +### 📷 Image Upload +- Direct upload to S3 using presigned URLs +- Real-time upload progress indicator +- Error handling with user notifications +- Automatic image key storage for reference + +### 📝 Post Creation +- Title and cover image +- Rich text content editing +- Draft and publish workflow +- Validation before submission + +## Structure + +``` +src/blog/ +├── BlogEditor.tsx # Main editor component +├── api.ts # API functions for blog operations +├── types.ts # TypeScript type definitions +├── index.ts # Module exports +├── nodes/ # Custom Lexical nodes +│ ├── ImageNode.tsx # Image node with upload support +│ ├── ImageComponent.tsx # Image component with progress UI +│ ├── ImageResizer.tsx # Image resizing functionality +│ └── MentionNode.ts # Mention node +├── plugins/ # Lexical editor plugins +│ ├── ImagesPlugin.tsx # Image insertion plugin +│ ├── DragDropPastePlugin.tsx # Image upload on drag/drop +│ ├── ToolbarPlugin.tsx # Editor toolbar +│ ├── MarkdownShortcutPlugin.tsx # Markdown shortcuts +│ ├── HashtagPlugin.tsx # Hashtag support +│ └── MentionsPlugin.tsx # Mentions support +├── styles/ # Editor styles +│ ├── editor.css +│ └── toolbar.css +├── themes/ # Editor themes +│ └── EditorTheme.ts +├── ui/ # UI components +│ ├── DropdownColorPicker.tsx +│ └── DropdownColorPicker.css +└── utils/ # Utility functions + └── exportImport.ts +``` + +## Usage + +### Creating a New Post + +```tsx +import { CreatePost } from './pages/CreatePost'; + +// The CreatePost component provides a complete UI for: +// 1. Entering post title +// 2. Uploading cover image +// 3. Writing content with rich text editor +// 4. Publishing the post +``` + +### Using the Blog Editor + +```tsx +import { BlogEditor } from './blog'; + +function MyComponent() { + return ; +} +``` + +### API Functions + +```typescript +import { uploadImage, createBlogPost } from './blog/api'; + +// Upload an image +const { fileKey, url } = await uploadImage(file, (progress) => { + console.log(`Upload progress: ${progress}%`); +}); + +// Create a blog post +const { postId } = await createBlogPost(title, content, coverImageKey); +``` + +## Image Upload Flow + +1. **User Action**: User drops/pastes image or uploads cover image +2. **Preview**: Image preview shown immediately with local data URL +3. **Get Upload URL**: Request presigned URL from backend API +4. **Upload to S3**: Upload file directly to S3 with progress tracking +5. **Get Download URL**: Request presigned download URL +6. **Update Node**: Update image node with final URL and fileKey +7. **Notifications**: Show success/error messages to user + +## API Endpoints + +### File Upload +``` +POST /api/blog/file/upload +Body: { fileName: string } +Response: { url: string, fileKey: string, expireAt: number } +``` + +### File Download +``` +POST /api/blog/file/download +Body: { fileKey: string } +Response: { url: string, expireAt: number } +``` + +### Create Post +``` +POST /api/blog/post/create +Body: { title: string, content: string, coverImageKey: string } +Response: { postId: string } +``` + +## Features to Implement + +- [ ] List all blog posts +- [ ] View single blog post +- [ ] Edit blog post +- [ ] Delete blog post +- [ ] Add tags/categories +- [ ] Comments system +- [ ] Like/reaction system +- [ ] Search functionality +- [ ] Pagination +- [ ] Draft management +- [ ] Scheduled publishing + +## Notes + +- All images are stored with their S3 file keys in the editor state +- Upload progress is tracked in real-time and displayed to users +- Error handling includes both API errors and upload failures +- The editor state can be exported/imported for draft saving diff --git a/src/blog/api.ts b/src/blog/api.ts new file mode 100644 index 0000000..0fd99fb --- /dev/null +++ b/src/blog/api.ts @@ -0,0 +1,128 @@ +/** + * Blog API functions + */ + +import { apiRequest } from '../utils/api'; +import type { + UploadPresignedURLResponse, + DownloadPresignedURLResponse, + CreatePostResponse, +} from './types'; + +const API_BASE = '/api/blog'; + +/** + * Get presigned URL for file upload + */ +export async function getUploadPresignedURL(fileName: string): Promise { + const response = await apiRequest(`${API_BASE}/file/upload`, { + method: 'POST', + body: JSON.stringify({ fileName }), + }); + + if (!response.ok) { + throw new Error(`Failed to get upload URL: ${response.statusText}`); + } + + return response.json(); +} + +/** + * Get presigned URL for file download + */ +export async function getDownloadPresignedURL(fileKey: string): Promise { + const response = await apiRequest(`${API_BASE}/file/download`, { + method: 'POST', + body: JSON.stringify({ fileKey }), + }); + + if (!response.ok) { + throw new Error(`Failed to get download URL: ${response.statusText}`); + } + + return response.json(); +} + +/** + * Upload file to S3 using presigned URL + */ +export async function uploadFileToS3( + url: string, + file: File, + onProgress?: (progress: number) => void +): Promise { + return new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest(); + + // Track upload progress + xhr.upload.addEventListener('progress', (event) => { + if (event.lengthComputable && onProgress) { + const progress = (event.loaded / event.total) * 100; + onProgress(progress); + } + }); + + xhr.addEventListener('load', () => { + if (xhr.status >= 200 && xhr.status < 300) { + resolve(); + } else { + reject(new Error(`Upload failed with status ${xhr.status}`)); + } + }); + + xhr.addEventListener('error', () => { + reject(new Error('Upload failed')); + }); + + xhr.addEventListener('abort', () => { + reject(new Error('Upload aborted')); + }); + + xhr.open('PUT', url); + xhr.setRequestHeader('Content-Type', file.type); + xhr.send(file); + }); +} + +/** + * Complete image upload workflow + */ +export async function uploadImage( + file: File, + onProgress?: (progress: number) => void +): Promise<{ fileKey: string; url: string }> { + // Step 1: Get presigned URL + const { url: uploadUrl, fileKey } = await getUploadPresignedURL(file.name); + + // Step 2: Upload file to S3 + await uploadFileToS3(uploadUrl, file, onProgress); + + // Step 3: Get download URL + const { url: downloadUrl } = await getDownloadPresignedURL(fileKey); + + return { fileKey, url: downloadUrl }; +} + +/** + * Create a new blog post + */ +export async function createBlogPost( + title: string, + content: string, + coverImageKey: string +): Promise { + const response = await apiRequest(`${API_BASE}/post/create`, { + method: 'POST', + body: JSON.stringify({ + title, + content, + coverImageKey, + }), + }); + + if (!response.ok) { + throw new Error(`Failed to create post: ${response.statusText}`); + } + + return response.json(); +} diff --git a/src/blog/index.ts b/src/blog/index.ts new file mode 100644 index 0000000..c0db0fd --- /dev/null +++ b/src/blog/index.ts @@ -0,0 +1,7 @@ +/** + * Blog module exports + */ + +export { default as BlogEditor } from './BlogEditor'; +export * from './types'; +export * from './api'; diff --git a/src/blog/nodes/ImageComponent.tsx b/src/blog/nodes/ImageComponent.tsx new file mode 100644 index 0000000..491c76e --- /dev/null +++ b/src/blog/nodes/ImageComponent.tsx @@ -0,0 +1,335 @@ +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, + fileKey: _fileKey, // Stored but not used in component + uploadProgress, + uploadError, +}: { + altText: string; + height: 'inherit' | number; + maxWidth: number; + nodeKey: NodeKey; + resizable: boolean; + src: string; + width: 'inherit' | number; + fileKey?: string; + uploadProgress?: number; + uploadError?: string; +}): 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; + + // Calculate if upload is in progress + const isUploading = uploadProgress !== undefined && uploadProgress < 100 && !uploadError; + + return ( + <> +
+ {isLoadError ? ( + Broken image + ) : ( + setIsLoadError(true)} + /> + )} + + {/* Upload Progress Bar */} + {isUploading && ( +
+
+
+
+
+ Uploading... {Math.round(uploadProgress || 0)}% +
+
+ )} + + {/* Upload Error */} + {uploadError && ( +
+ ❌ {uploadError} +
+ )} +
+ + {resizable && isSelected && isFocused && ( + + )} + + ); +} diff --git a/src/blog/nodes/ImageNode.tsx b/src/blog/nodes/ImageNode.tsx new file mode 100644 index 0000000..986cce3 --- /dev/null +++ b/src/blog/nodes/ImageNode.tsx @@ -0,0 +1,276 @@ +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; + fileKey?: string; // S3 file key + uploadProgress?: number; // Upload progress (0-100) + uploadError?: string; // Upload error message +} + +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 fileKey = img.getAttribute('data-file-key') || undefined; + const node = $createImageNode({ altText, height, src, width, fileKey }); + return { node }; +} + +export type SerializedImageNode = Spread< + { + altText: string; + height?: number; + maxWidth: number; + src: string; + width?: number; + fileKey?: string; + uploadProgress?: number; + uploadError?: string; + }, + SerializedLexicalNode +>; + +export class ImageNode extends DecoratorNode { + __src: string; + __altText: string; + __width: 'inherit' | number; + __height: 'inherit' | number; + __maxWidth: number; + __fileKey?: string; + __uploadProgress?: number; + __uploadError?: string; + + static getType(): string { + return 'image'; + } + + static clone(node: ImageNode): ImageNode { + return new ImageNode( + node.__src, + node.__altText, + node.__maxWidth, + node.__width, + node.__height, + node.__fileKey, + node.__uploadProgress, + node.__uploadError, + node.__key, + ); + } + + static importJSON(serializedNode: SerializedImageNode): ImageNode { + const { altText, height, width, maxWidth, src, fileKey, uploadProgress, uploadError } = serializedNode; + return $createImageNode({ + altText, + height, + maxWidth, + src, + width, + fileKey, + uploadProgress, + uploadError, + }); + } + + exportDOM(): DOMExportOutput { + const element = document.createElement('img'); + element.setAttribute('src', this.__src); + element.setAttribute('alt', this.__altText); + if (this.__fileKey) { + element.setAttribute('data-file-key', this.__fileKey); + } + 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, + fileKey?: string, + uploadProgress?: number, + uploadError?: string, + key?: NodeKey, + ) { + super(key); + this.__src = src; + this.__altText = altText; + this.__maxWidth = maxWidth; + this.__width = width || 'inherit'; + this.__height = height || 'inherit'; + this.__fileKey = fileKey; + this.__uploadProgress = uploadProgress; + this.__uploadError = uploadError; + } + + 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, + fileKey: this.__fileKey, + uploadProgress: this.__uploadProgress, + uploadError: this.__uploadError, + }; + } + + setWidthAndHeight( + width: 'inherit' | number, + height: 'inherit' | number, + ): void { + const writable = this.getWritable(); + writable.__width = width; + writable.__height = height; + } + + setUploadProgress(progress: number): void { + const writable = this.getWritable(); + writable.__uploadProgress = progress; + } + + setUploadError(error: string | undefined): void { + const writable = this.getWritable(); + writable.__uploadError = error; + } + + setFileKey(fileKey: string): void { + const writable = this.getWritable(); + writable.__fileKey = fileKey; + } + + setSrc(src: string): void { + const writable = this.getWritable(); + writable.__src = src; + } + + 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; + } + + getFileKey(): string | undefined { + return this.__fileKey; + } + + getUploadProgress(): number | undefined { + return this.__uploadProgress; + } + + getUploadError(): string | undefined { + return this.__uploadError; + } + + decorate(): JSX.Element { + return ( + + + + ); + } +} + +export function $createImageNode({ + altText, + height, + maxWidth = 800, + src, + width, + key, + fileKey, + uploadProgress, + uploadError, +}: ImagePayload): ImageNode { + return $applyNodeReplacement( + new ImageNode( + src, + altText, + maxWidth, + width, + height, + fileKey, + uploadProgress, + uploadError, + key, + ), + ); +} + +export function $isImageNode( + node: LexicalNode | null | undefined, +): node is ImageNode { + return node instanceof ImageNode; +} diff --git a/src/blog/nodes/ImageResizer.tsx b/src/blog/nodes/ImageResizer.tsx new file mode 100644 index 0000000..b8ca26f --- /dev/null +++ b/src/blog/nodes/ImageResizer.tsx @@ -0,0 +1,292 @@ +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/blog/nodes/MentionNode.ts b/src/blog/nodes/MentionNode.ts new file mode 100644 index 0000000..575ce0b --- /dev/null +++ b/src/blog/nodes/MentionNode.ts @@ -0,0 +1,130 @@ +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/blog/plugins/DragDropPastePlugin.tsx b/src/blog/plugins/DragDropPastePlugin.tsx new file mode 100644 index 0000000..91e44dc --- /dev/null +++ b/src/blog/plugins/DragDropPastePlugin.tsx @@ -0,0 +1,101 @@ +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'; +import { uploadImage } from '../api'; + +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)) { + // Insert image with preview first + const imagePayload = { + altText: file.name, + src: result, // data URL for preview + uploadProgress: 0, + }; + + editor.dispatchCommand(INSERT_IMAGE_COMMAND, imagePayload); + + // Upload to S3 in background + try { + const { fileKey, url } = await uploadImage(file, (progress) => { + // Update progress + editor.update(() => { + const nodes = editor._editorState._nodeMap; + nodes.forEach((node: any) => { + if (node.__type === 'image' && node.__src === result) { + node.setUploadProgress(progress); + } + }); + }); + }); + + // Update with final URL and fileKey + editor.update(() => { + const nodes = editor._editorState._nodeMap; + nodes.forEach((node: any) => { + if (node.__type === 'image' && node.__src === result) { + node.setSrc(url); + node.setFileKey(fileKey); + node.setUploadProgress(100); + node.setUploadError(undefined); + } + }); + }); + + // Show success notification + console.log('✅ Image uploaded successfully'); + } catch (error) { + // Update with error + editor.update(() => { + const nodes = editor._editorState._nodeMap; + nodes.forEach((node: any) => { + if (node.__type === 'image' && node.__src === result) { + node.setUploadError( + error instanceof Error ? error.message : 'Upload failed' + ); + } + }); + }); + + // Show error notification + console.error('❌ Image upload failed:', error); + } + } + } + })(); + return true; + }, + COMMAND_PRIORITY_LOW, + ); + }, [editor]); + + return null; +} diff --git a/src/blog/plugins/HashtagPlugin.tsx b/src/blog/plugins/HashtagPlugin.tsx new file mode 100644 index 0000000..6d5ba75 --- /dev/null +++ b/src/blog/plugins/HashtagPlugin.tsx @@ -0,0 +1,18 @@ +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/blog/plugins/ImagesPlugin.tsx b/src/blog/plugins/ImagesPlugin.tsx new file mode 100644 index 0000000..d15afc3 --- /dev/null +++ b/src/blog/plugins/ImagesPlugin.tsx @@ -0,0 +1,47 @@ +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/blog/plugins/MarkdownShortcutPlugin.tsx b/src/blog/plugins/MarkdownShortcutPlugin.tsx new file mode 100644 index 0000000..b31c26a --- /dev/null +++ b/src/blog/plugins/MarkdownShortcutPlugin.tsx @@ -0,0 +1,7 @@ +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/blog/plugins/MarkdownTransformers.ts b/src/blog/plugins/MarkdownTransformers.ts new file mode 100644 index 0000000..c08b1b1 --- /dev/null +++ b/src/blog/plugins/MarkdownTransformers.ts @@ -0,0 +1,258 @@ +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/blog/plugins/MentionsPlugin.tsx b/src/blog/plugins/MentionsPlugin.tsx new file mode 100644 index 0000000..8ffdc5c --- /dev/null +++ b/src/blog/plugins/MentionsPlugin.tsx @@ -0,0 +1,288 @@ +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/blog/plugins/ToolbarPlugin.tsx b/src/blog/plugins/ToolbarPlugin.tsx new file mode 100644 index 0000000..ee63995 --- /dev/null +++ b/src/blog/plugins/ToolbarPlugin.tsx @@ -0,0 +1,301 @@ +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/blog/styles/editor.css b/src/blog/styles/editor.css new file mode 100644 index 0000000..e6a62a6 --- /dev/null +++ b/src/blog/styles/editor.css @@ -0,0 +1,477 @@ +.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/blog/styles/toolbar.css b/src/blog/styles/toolbar.css new file mode 100644 index 0000000..4b54899 --- /dev/null +++ b/src/blog/styles/toolbar.css @@ -0,0 +1,155 @@ +.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/blog/themes/EditorTheme.ts b/src/blog/themes/EditorTheme.ts new file mode 100644 index 0000000..94b1a80 --- /dev/null +++ b/src/blog/themes/EditorTheme.ts @@ -0,0 +1,46 @@ +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/blog/types.ts b/src/blog/types.ts new file mode 100644 index 0000000..1b3e465 --- /dev/null +++ b/src/blog/types.ts @@ -0,0 +1,33 @@ +/** + * Blog-related type definitions + */ + +export interface UploadPresignedURLResponse { + url: string; + fileKey: string; + expireAt: number; +} + +export interface DownloadPresignedURLResponse { + url: string; + expireAt: number; +} + +export interface CreatePostResponse { + postId: string; +} + +export interface ImageUploadPayload { + file: File; + onProgress?: (progress: number) => void; + onSuccess?: (fileKey: string, url: string) => void; + onError?: (error: Error) => void; +} + +export interface ImageUploadStatus { + isUploading: boolean; + progress: number; + error?: string; + fileKey?: string; + url?: string; +} diff --git a/src/blog/ui/DropdownColorPicker.css b/src/blog/ui/DropdownColorPicker.css new file mode 100644 index 0000000..3ee739d --- /dev/null +++ b/src/blog/ui/DropdownColorPicker.css @@ -0,0 +1,50 @@ +.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/blog/ui/DropdownColorPicker.tsx b/src/blog/ui/DropdownColorPicker.tsx new file mode 100644 index 0000000..1e5fea0 --- /dev/null +++ b/src/blog/ui/DropdownColorPicker.tsx @@ -0,0 +1,87 @@ +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/blog/ui/ImageResizer.tsx b/src/blog/ui/ImageResizer.tsx new file mode 100644 index 0000000..b8ca26f --- /dev/null +++ b/src/blog/ui/ImageResizer.tsx @@ -0,0 +1,292 @@ +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/blog/utils/exportImport.ts b/src/blog/utils/exportImport.ts new file mode 100644 index 0000000..6fcff53 --- /dev/null +++ b/src/blog/utils/exportImport.ts @@ -0,0 +1,115 @@ +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 646d682..cc7f2df 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -11,6 +11,7 @@ import ScrollToTop from './components/ScrollToTop' import App from './App.tsx' import Friends from './pages/Friends.tsx' import Blog from './pages/Blog.tsx' +import CreatePost from './pages/CreatePost.tsx' import Servers from './pages/Servers.tsx' import Forum from './pages/Forum.tsx' import AuthCallback from './pages/AuthCallback.tsx' @@ -28,6 +29,7 @@ createRoot(document.getElementById('root')!).render( } /> } /> } /> + } /> } /> } /> } /> diff --git a/src/pages/Blog.tsx b/src/pages/Blog.tsx index 3d1a39e..df90d87 100644 --- a/src/pages/Blog.tsx +++ b/src/pages/Blog.tsx @@ -119,10 +119,36 @@ function Blog() {

    {t('blog.subtitle')}

    + {/* Create Post Button */} + { + e.currentTarget.style.transform = 'translateY(-2px)'; + e.currentTarget.style.boxShadow = '0 4px 12px var(--accent-shadow)'; + }} + onMouseLeave={(e) => { + e.currentTarget.style.transform = 'translateY(0)'; + e.currentTarget.style.boxShadow = 'none'; + }} + > + ✍️ Create New Post +
    diff --git a/src/pages/CreatePost.tsx b/src/pages/CreatePost.tsx new file mode 100644 index 0000000..35c6def --- /dev/null +++ b/src/pages/CreatePost.tsx @@ -0,0 +1,355 @@ +import { useState, useRef } from 'react'; +import { useNavigate } from 'react-router-dom'; +import Layout from '../components/Layout'; +import BlogEditor from '../blog/BlogEditor'; +import { uploadImage, createBlogPost } from '../blog/api'; +import '../App.css'; + +function CreatePost() { + const navigate = useNavigate(); + const [title, setTitle] = useState(''); + const [coverImage, setCoverImage] = useState(null); + const [coverImageKey, setCoverImageKey] = useState(''); + const [uploadProgress, setUploadProgress] = useState(0); + const [isUploading, setIsUploading] = useState(false); + const [uploadError, setUploadError] = useState(''); + const [isSubmitting, setIsSubmitting] = useState(false); + const fileInputRef = useRef(null); + + const handleCoverImageChange = async (event: React.ChangeEvent) => { + const file = event.target.files?.[0]; + if (!file) return; + + // Validate file type + if (!file.type.startsWith('image/')) { + setUploadError('Please select an image file'); + return; + } + + // Show preview immediately + const reader = new FileReader(); + reader.onload = (e) => { + setCoverImage(e.target?.result as string); + }; + reader.readAsDataURL(file); + + // Upload to S3 + setIsUploading(true); + setUploadError(''); + setUploadProgress(0); + + try { + const { fileKey, url } = await uploadImage(file, (progress) => { + setUploadProgress(progress); + }); + + setCoverImageKey(fileKey); + setCoverImage(url); + setUploadProgress(100); + console.log('✅ Cover image uploaded successfully'); + } catch (error) { + setUploadError(error instanceof Error ? error.message : 'Upload failed'); + console.error('❌ Cover image upload failed:', error); + } finally { + setIsUploading(false); + } + }; + + const handleSubmit = async () => { + if (!title.trim()) { + alert('Please enter a title'); + return; + } + + if (!coverImageKey) { + alert('Please upload a cover image'); + return; + } + + setIsSubmitting(true); + + try { + // Get editor content + // Note: You'll need to export the editor content from the BlogEditor component + // For now, we'll use a placeholder + const content = JSON.stringify({ editorState: 'placeholder' }); + + const response = await createBlogPost(title, content, coverImageKey); + + console.log('✅ Blog post created successfully:', response.postId); + alert('Blog post created successfully!'); + + // Navigate to the blog page or the created post + navigate('/blog'); + } catch (error) { + console.error('❌ Failed to create blog post:', error); + alert(error instanceof Error ? error.message : 'Failed to create blog post'); + } finally { + setIsSubmitting(false); + } + }; + + return ( + +
    + {/* Header */} +
    +

    + Create New Post +

    +

    + Share your thoughts with the community +

    +
    + + {/* Title Input */} +
    + + setTitle(e.target.value)} + placeholder="Enter your blog post title..." + style={{ + width: '100%', + padding: '1rem', + fontSize: '1.2rem', + border: '2px solid var(--border-color)', + borderRadius: '8px', + background: 'var(--bg-card)', + color: 'var(--text-primary)', + outline: 'none', + transition: 'border-color 0.3s ease' + }} + onFocus={(e) => { + e.currentTarget.style.borderColor = 'var(--accent-color)'; + }} + onBlur={(e) => { + e.currentTarget.style.borderColor = 'var(--border-color)'; + }} + /> +
    + + {/* Cover Image Upload */} +
    + + +
    fileInputRef.current?.click()} + onMouseEnter={(e) => { + e.currentTarget.style.borderColor = 'var(--accent-color)'; + }} + onMouseLeave={(e) => { + e.currentTarget.style.borderColor = 'var(--border-color)'; + }} + > + {coverImage ? ( +
    + Cover + {isUploading && ( +
    +
    +
    +
    +
    + Uploading... {Math.round(uploadProgress)}% +
    +
    + )} +
    + ) : ( +
    +
    + 📷 +
    +

    + Click to upload cover image +

    +

    + PNG, JPG, GIF up to 10MB +

    +
    + )} +
    + + {uploadError && ( +
    + ❌ {uploadError} +
    + )} + + +
    + + {/* Content Editor */} +
    + +
    + +
    +
    + + {/* Action Buttons */} +
    + + +
    +
    + + ); +} + +export default CreatePost;