feat: add toolbar styles and functionality for blog editor
All checks were successful
CI - Build and Push / Build and Push Docker Image (push) Successful in 18s
All checks were successful
CI - Build and Push / Build and Push Docker Image (push) Successful in 18s
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
This commit is contained in:
120
src/blog/BlogEditor.tsx
Normal file
120
src/blog/BlogEditor.tsx
Normal file
@@ -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 (
|
||||
<LexicalComposer initialConfig={editorConfig}>
|
||||
<div className="editor-container">
|
||||
<ToolbarPlugin />
|
||||
<div className="editor-inner">
|
||||
<RichTextPlugin
|
||||
contentEditable={
|
||||
<ContentEditable className="editor-input" />
|
||||
}
|
||||
placeholder={
|
||||
<div className="editor-placeholder">Start writing your blog post...</div>
|
||||
}
|
||||
ErrorBoundary={LexicalErrorBoundary}
|
||||
/>
|
||||
<HistoryPlugin />
|
||||
<ListPlugin />
|
||||
<CheckListPlugin />
|
||||
<LinkPlugin />
|
||||
<AutoLinkPlugin matchers={MATCHERS} />
|
||||
<TablePlugin />
|
||||
<ImagesPlugin />
|
||||
<DragDropPastePlugin />
|
||||
<HashtagPlugin />
|
||||
<MentionsPlugin />
|
||||
<MarkdownPlugin />
|
||||
</div>
|
||||
</div>
|
||||
</LexicalComposer>
|
||||
);
|
||||
}
|
||||
148
src/blog/README.md
Normal file
148
src/blog/README.md
Normal file
@@ -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 <BlogEditor />;
|
||||
}
|
||||
```
|
||||
|
||||
### 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
|
||||
128
src/blog/api.ts
Normal file
128
src/blog/api.ts
Normal file
@@ -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<UploadPresignedURLResponse> {
|
||||
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<DownloadPresignedURLResponse> {
|
||||
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<void> {
|
||||
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<CreatePostResponse> {
|
||||
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();
|
||||
}
|
||||
7
src/blog/index.ts
Normal file
7
src/blog/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* Blog module exports
|
||||
*/
|
||||
|
||||
export { default as BlogEditor } from './BlogEditor';
|
||||
export * from './types';
|
||||
export * from './api';
|
||||
335
src/blog/nodes/ImageComponent.tsx
Normal file
335
src/blog/nodes/ImageComponent.tsx
Normal file
@@ -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<string, Promise<ImageStatus> | 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<ImageStatus>((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 (
|
||||
<img
|
||||
src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='200' height='200'%3E%3Crect fill='%23f0f0f0' width='200' height='200'/%3E%3Ctext x='50%25' y='50%25' dominant-baseline='middle' text-anchor='middle' fill='%23999' font-family='sans-serif'%3EImage not found%3C/text%3E%3C/svg%3E"
|
||||
style={{
|
||||
height: 200,
|
||||
opacity: 0.3,
|
||||
width: 200,
|
||||
}}
|
||||
draggable="false"
|
||||
alt="Broken image"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<img
|
||||
className={className || undefined}
|
||||
src={src}
|
||||
alt={altText}
|
||||
ref={imageRef}
|
||||
style={{
|
||||
height: height === 'inherit' ? 'auto' : height,
|
||||
maxWidth,
|
||||
width: width === 'inherit' ? '100%' : width,
|
||||
}}
|
||||
onError={onError}
|
||||
draggable="false"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
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 | HTMLImageElement>(null);
|
||||
const [isSelected, setSelected, clearSelection] =
|
||||
useLexicalNodeSelection(nodeKey);
|
||||
const [isResizing, setIsResizing] = useState<boolean>(false);
|
||||
const [editor] = useLexicalComposerContext();
|
||||
const [isLoadError, setIsLoadError] = useState<boolean>(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<MouseEvent>(
|
||||
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 (
|
||||
<>
|
||||
<div draggable={draggable} style={{ position: 'relative', display: 'inline-block' }}>
|
||||
{isLoadError ? (
|
||||
<img
|
||||
src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='200' height='200'%3E%3Crect fill='%23f0f0f0' width='200' height='200'/%3E%3Ctext x='50%25' y='50%25' dominant-baseline='middle' text-anchor='middle' fill='%23999' font-family='sans-serif'%3EImage not found%3C/text%3E%3C/svg%3E"
|
||||
style={{
|
||||
height: 200,
|
||||
opacity: 0.3,
|
||||
width: 200,
|
||||
}}
|
||||
draggable="false"
|
||||
alt="Broken image"
|
||||
/>
|
||||
) : (
|
||||
<LazyImage
|
||||
className={isFocused ? 'focused' : null}
|
||||
src={src}
|
||||
altText={altText}
|
||||
imageRef={imageRef}
|
||||
width={width}
|
||||
height={height}
|
||||
maxWidth={maxWidth}
|
||||
onError={() => setIsLoadError(true)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Upload Progress Bar */}
|
||||
{isUploading && (
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
background: 'rgba(0, 0, 0, 0.7)',
|
||||
padding: '8px',
|
||||
}}>
|
||||
<div style={{
|
||||
background: 'rgba(255, 255, 255, 0.2)',
|
||||
borderRadius: '4px',
|
||||
overflow: 'hidden',
|
||||
height: '4px',
|
||||
}}>
|
||||
<div style={{
|
||||
background: 'var(--accent-color, #4CAF50)',
|
||||
height: '100%',
|
||||
width: `${uploadProgress || 0}%`,
|
||||
transition: 'width 0.3s ease',
|
||||
}} />
|
||||
</div>
|
||||
<div style={{
|
||||
color: 'white',
|
||||
fontSize: '12px',
|
||||
marginTop: '4px',
|
||||
textAlign: 'center',
|
||||
}}>
|
||||
Uploading... {Math.round(uploadProgress || 0)}%
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Upload Error */}
|
||||
{uploadError && (
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
background: 'rgba(244, 67, 54, 0.9)',
|
||||
padding: '8px',
|
||||
color: 'white',
|
||||
fontSize: '12px',
|
||||
textAlign: 'center',
|
||||
}}>
|
||||
❌ {uploadError}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{resizable && isSelected && isFocused && (
|
||||
<ImageResizer
|
||||
editor={editor}
|
||||
imageRef={imageRef}
|
||||
maxWidth={maxWidth}
|
||||
onResizeStart={onResizeStart}
|
||||
onResizeEnd={onResizeEnd}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
276
src/blog/nodes/ImageNode.tsx
Normal file
276
src/blog/nodes/ImageNode.tsx
Normal file
@@ -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<JSX.Element> {
|
||||
__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 (
|
||||
<Suspense fallback={null}>
|
||||
<ImageComponent
|
||||
src={this.__src}
|
||||
altText={this.__altText}
|
||||
width={this.__width}
|
||||
height={this.__height}
|
||||
maxWidth={this.__maxWidth}
|
||||
nodeKey={this.getKey()}
|
||||
resizable={true}
|
||||
fileKey={this.__fileKey}
|
||||
uploadProgress={this.__uploadProgress}
|
||||
uploadError={this.__uploadError}
|
||||
/>
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
292
src/blog/nodes/ImageResizer.tsx
Normal file
292
src/blog/nodes/ImageResizer.tsx
Normal file
@@ -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<HTMLDivElement>(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<HTMLDivElement>,
|
||||
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 (
|
||||
<div ref={controlWrapperRef}>
|
||||
<div
|
||||
className="image-resizer image-resizer-n"
|
||||
onPointerDown={(event) => {
|
||||
handlePointerDown(event, Direction.north);
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="image-resizer image-resizer-ne"
|
||||
onPointerDown={(event) => {
|
||||
handlePointerDown(event, Direction.north | Direction.east);
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="image-resizer image-resizer-e"
|
||||
onPointerDown={(event) => {
|
||||
handlePointerDown(event, Direction.east);
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="image-resizer image-resizer-se"
|
||||
onPointerDown={(event) => {
|
||||
handlePointerDown(event, Direction.south | Direction.east);
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="image-resizer image-resizer-s"
|
||||
onPointerDown={(event) => {
|
||||
handlePointerDown(event, Direction.south);
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="image-resizer image-resizer-sw"
|
||||
onPointerDown={(event) => {
|
||||
handlePointerDown(event, Direction.south | Direction.west);
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="image-resizer image-resizer-w"
|
||||
onPointerDown={(event) => {
|
||||
handlePointerDown(event, Direction.west);
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="image-resizer image-resizer-nw"
|
||||
onPointerDown={(event) => {
|
||||
handlePointerDown(event, Direction.north | Direction.west);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
130
src/blog/nodes/MentionNode.ts
Normal file
130
src/blog/nodes/MentionNode.ts
Normal file
@@ -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;
|
||||
}
|
||||
101
src/blog/plugins/DragDropPastePlugin.tsx
Normal file
101
src/blog/plugins/DragDropPastePlugin.tsx
Normal file
@@ -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;
|
||||
}
|
||||
18
src/blog/plugins/HashtagPlugin.tsx
Normal file
18
src/blog/plugins/HashtagPlugin.tsx
Normal file
@@ -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;
|
||||
}
|
||||
47
src/blog/plugins/ImagesPlugin.tsx
Normal file
47
src/blog/plugins/ImagesPlugin.tsx
Normal file
@@ -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<ImagePayload>;
|
||||
|
||||
export const INSERT_IMAGE_COMMAND: LexicalCommand<InsertImagePayload> =
|
||||
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<InsertImagePayload>(
|
||||
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;
|
||||
}
|
||||
7
src/blog/plugins/MarkdownShortcutPlugin.tsx
Normal file
7
src/blog/plugins/MarkdownShortcutPlugin.tsx
Normal file
@@ -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 <MarkdownShortcutPlugin transformers={EDITOR_TRANSFORMERS} />;
|
||||
}
|
||||
258
src/blog/plugins/MarkdownTransformers.ts
Normal file
258
src/blog/plugins/MarkdownTransformers.ts
Normal file
@@ -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 
|
||||
export const IMAGE: TextMatchTransformer = {
|
||||
dependencies: [],
|
||||
export: (node) => {
|
||||
if (!$isImageNode(node)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return `})`;
|
||||
},
|
||||
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<TableCellNode> | 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 ()
|
||||
|
||||
export const EDITOR_TRANSFORMERS: Array<Transformer> = [
|
||||
TABLE,
|
||||
HR,
|
||||
IMAGE,
|
||||
CHECK_LIST,
|
||||
...ELEMENT_TRANSFORMERS,
|
||||
...MULTILINE_ELEMENT_TRANSFORMERS,
|
||||
...TEXT_FORMAT_TRANSFORMERS,
|
||||
...TEXT_MATCH_TRANSFORMERS,
|
||||
];
|
||||
288
src/blog/plugins/MentionsPlugin.tsx
Normal file
288
src/blog/plugins/MentionsPlugin.tsx
Normal file
@@ -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<string>) => 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<Array<string>>([]);
|
||||
|
||||
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 (
|
||||
<li
|
||||
key={option.key}
|
||||
tabIndex={-1}
|
||||
className={className}
|
||||
ref={option.setRefElement}
|
||||
role="option"
|
||||
aria-selected={isSelected}
|
||||
id={'typeahead-item-' + index}
|
||||
onMouseEnter={onMouseEnter}
|
||||
onClick={onClick}>
|
||||
{option.picture}
|
||||
<span className="text">{option.name}</span>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
export default function MentionsPlugin(): JSX.Element | null {
|
||||
const [editor] = useLexicalComposerContext();
|
||||
|
||||
const [queryString, setQueryString] = useState<string | null>(null);
|
||||
|
||||
const results = useMentionLookupService(queryString);
|
||||
|
||||
const checkForSlashTriggerMatch = useBasicTypeaheadTriggerMatch('/', {
|
||||
minLength: 0,
|
||||
});
|
||||
|
||||
const options = useMemo(
|
||||
() =>
|
||||
results
|
||||
.map(
|
||||
(result) =>
|
||||
new MentionTypeaheadOption(result, <i className="icon user" />),
|
||||
)
|
||||
.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 (
|
||||
<LexicalTypeaheadMenuPlugin<MentionTypeaheadOption>
|
||||
onQueryChange={setQueryString}
|
||||
onSelectOption={onSelectOption}
|
||||
triggerFn={checkForMentionMatch}
|
||||
options={options}
|
||||
menuRenderFn={(
|
||||
anchorElementRef,
|
||||
{selectedIndex, selectOptionAndCleanUp, setHighlightedIndex},
|
||||
) =>
|
||||
anchorElementRef.current && results.length
|
||||
? ReactDOM.createPortal(
|
||||
<div className="typeahead-popover mentions-menu">
|
||||
<ul>
|
||||
{options.map((option, i: number) => (
|
||||
<MentionsTypeaheadMenuItem
|
||||
index={i}
|
||||
isSelected={selectedIndex === i}
|
||||
onClick={() => {
|
||||
setHighlightedIndex(i);
|
||||
selectOptionAndCleanUp(option);
|
||||
}}
|
||||
onMouseEnter={() => {
|
||||
setHighlightedIndex(i);
|
||||
}}
|
||||
key={option.key}
|
||||
option={option}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</div>,
|
||||
anchorElementRef.current,
|
||||
)
|
||||
: null
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
301
src/blog/plugins/ToolbarPlugin.tsx
Normal file
301
src/blog/plugins/ToolbarPlugin.tsx
Normal file
@@ -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<ElementFormatType>('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<HTMLSelectElement>) => {
|
||||
const value = e.target.value;
|
||||
editor.update(() => {
|
||||
const selection = $getSelection();
|
||||
if ($isRangeSelection(selection)) {
|
||||
$patchStyleText(selection, { 'font-size': value });
|
||||
}
|
||||
});
|
||||
},
|
||||
[editor]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="toolbar" ref={toolbarRef}>
|
||||
<button
|
||||
disabled={!canUndo}
|
||||
onClick={() => {
|
||||
editor.dispatchCommand(UNDO_COMMAND, undefined);
|
||||
}}
|
||||
className="toolbar-item spaced"
|
||||
aria-label="Undo">
|
||||
<i className="format undo" />
|
||||
</button>
|
||||
<button
|
||||
disabled={!canRedo}
|
||||
onClick={() => {
|
||||
editor.dispatchCommand(REDO_COMMAND, undefined);
|
||||
}}
|
||||
className="toolbar-item"
|
||||
aria-label="Redo">
|
||||
<i className="format redo" />
|
||||
</button>
|
||||
<div className="divider" />
|
||||
|
||||
<select
|
||||
className="toolbar-item block-controls"
|
||||
value={fontSize}
|
||||
onChange={onFontSizeChange}>
|
||||
<option value="10px">10px</option>
|
||||
<option value="11px">11px</option>
|
||||
<option value="12px">12px</option>
|
||||
<option value="13px">13px</option>
|
||||
<option value="14px">14px</option>
|
||||
<option value="15px">15px</option>
|
||||
<option value="16px">16px</option>
|
||||
<option value="18px">18px</option>
|
||||
<option value="20px">20px</option>
|
||||
<option value="24px">24px</option>
|
||||
<option value="30px">30px</option>
|
||||
<option value="36px">36px</option>
|
||||
</select>
|
||||
<div className="divider" />
|
||||
|
||||
<button
|
||||
onClick={() => formatText('bold')}
|
||||
className={'toolbar-item spaced ' + (isBold ? 'active' : '')}
|
||||
aria-label="Format Bold">
|
||||
<i className="format bold" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => formatText('italic')}
|
||||
className={'toolbar-item spaced ' + (isItalic ? 'active' : '')}
|
||||
aria-label="Format Italics">
|
||||
<i className="format italic" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => formatText('underline')}
|
||||
className={'toolbar-item spaced ' + (isUnderline ? 'active' : '')}
|
||||
aria-label="Format Underline">
|
||||
<i className="format underline" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => formatText('strikethrough')}
|
||||
className={'toolbar-item spaced ' + (isStrikethrough ? 'active' : '')}
|
||||
aria-label="Format Strikethrough">
|
||||
<i className="format strikethrough" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => formatText('code')}
|
||||
className={'toolbar-item spaced ' + (isCode ? 'active' : '')}
|
||||
aria-label="Insert Code">
|
||||
<i className="format code" />
|
||||
</button>
|
||||
<div className="divider" />
|
||||
|
||||
<DropdownColorPicker
|
||||
buttonClassName="toolbar-item color-picker"
|
||||
buttonAriaLabel="Formatting text color"
|
||||
buttonIconClassName="icon font-color"
|
||||
color={fontColor}
|
||||
onChange={onFontColorSelect}
|
||||
title="text color"
|
||||
/>
|
||||
<DropdownColorPicker
|
||||
buttonClassName="toolbar-item color-picker"
|
||||
buttonAriaLabel="Formatting background color"
|
||||
buttonIconClassName="icon bg-color"
|
||||
color={bgColor}
|
||||
onChange={onBgColorSelect}
|
||||
title="bg color"
|
||||
/>
|
||||
<div className="divider" />
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
editor.dispatchCommand(FORMAT_ELEMENT_COMMAND, 'left');
|
||||
}}
|
||||
className={'toolbar-item spaced ' + (elementFormat === 'left' ? 'active' : '')}
|
||||
aria-label="Left Align">
|
||||
<i className="format left-align" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
editor.dispatchCommand(FORMAT_ELEMENT_COMMAND, 'center');
|
||||
}}
|
||||
className={'toolbar-item spaced ' + (elementFormat === 'center' ? 'active' : '')}
|
||||
aria-label="Center Align">
|
||||
<i className="format center-align" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
editor.dispatchCommand(FORMAT_ELEMENT_COMMAND, 'right');
|
||||
}}
|
||||
className={'toolbar-item spaced ' + (elementFormat === 'right' ? 'active' : '')}
|
||||
aria-label="Right Align">
|
||||
<i className="format right-align" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
editor.dispatchCommand(FORMAT_ELEMENT_COMMAND, 'justify');
|
||||
}}
|
||||
className={'toolbar-item spaced ' + (elementFormat === 'justify' ? 'active' : '')}
|
||||
aria-label="Justify Align">
|
||||
<i className="format justify-align" />
|
||||
</button>
|
||||
<div className="divider" />
|
||||
|
||||
<button
|
||||
onClick={() => importFromJSON(editor)}
|
||||
className="toolbar-item"
|
||||
title="Import from JSON"
|
||||
aria-label="Import editor state from JSON file">
|
||||
<i className="format import" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => exportToJSON(editor)}
|
||||
className="toolbar-item"
|
||||
title="Export to JSON"
|
||||
aria-label="Export editor state to JSON file">
|
||||
<i className="format export" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
477
src/blog/styles/editor.css
Normal file
477
src/blog/styles/editor.css
Normal file
@@ -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");
|
||||
}
|
||||
155
src/blog/styles/toolbar.css
Normal file
155
src/blog/styles/toolbar.css
Normal file
@@ -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,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 7v6h6"/><path d="M21 17a9 9 0 00-9-9 9 9 0 00-6 2.3L3 13"/></svg>');
|
||||
}
|
||||
|
||||
i.format.redo {
|
||||
background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 7v6h-6"/><path d="M3 17a9 9 0 019-9 9 9 0 016 2.3l3 2.7"/></svg>');
|
||||
}
|
||||
|
||||
i.format.bold {
|
||||
background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 4h8a4 4 0 0 1 4 4 4 4 0 0 1-4 4H6z"/><path d="M6 12h9a4 4 0 0 1 4 4 4 4 0 0 1-4 4H6z"/></svg>');
|
||||
}
|
||||
|
||||
i.format.italic {
|
||||
background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="19" y1="4" x2="10" y2="4"/><line x1="14" y1="20" x2="5" y2="20"/><line x1="15" y1="4" x2="9" y2="20"/></svg>');
|
||||
}
|
||||
|
||||
i.format.underline {
|
||||
background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 3v7a6 6 0 0 0 6 6 6 6 0 0 0 6-6V3"/><line x1="4" y1="21" x2="20" y2="21"/></svg>');
|
||||
}
|
||||
|
||||
i.format.strikethrough {
|
||||
background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17.3 5.3c-.94-.94-2.2-1.3-3.3-1.3H10a4 4 0 0 0 0 8"/><path d="M14 12a4 4 0 0 1 0 8h-4"/><line x1="4" y1="12" x2="20" y2="12"/></svg>');
|
||||
}
|
||||
|
||||
i.format.code {
|
||||
background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg>');
|
||||
}
|
||||
|
||||
i.icon.font-color {
|
||||
background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 20h16"/><path d="m6 16 6-12 6 12"/><path d="M8 12h8"/></svg>');
|
||||
display: inline-block;
|
||||
height: 18px;
|
||||
width: 18px;
|
||||
background-size: contain;
|
||||
}
|
||||
|
||||
i.icon.bg-color {
|
||||
background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 14c0-2.5-2-4-4-4H8c-2 0-4 1.5-4 4v6h16v-6Z"/><path d="M4 14V6a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v8"/></svg>');
|
||||
display: inline-block;
|
||||
height: 18px;
|
||||
width: 18px;
|
||||
background-size: contain;
|
||||
}
|
||||
|
||||
i.format.left-align {
|
||||
background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="15" y2="12"/><line x1="3" y1="18" x2="18" y2="18"/></svg>');
|
||||
}
|
||||
|
||||
i.format.center-align {
|
||||
background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="3" y1="6" x2="21" y2="6"/><line x1="6" y1="12" x2="18" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></svg>');
|
||||
}
|
||||
|
||||
i.format.right-align {
|
||||
background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="3" y1="6" x2="21" y2="6"/><line x1="9" y1="12" x2="21" y2="12"/><line x1="6" y1="18" x2="21" y2="18"/></svg>');
|
||||
}
|
||||
|
||||
i.format.justify-align {
|
||||
background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></svg>');
|
||||
}
|
||||
|
||||
i.format.import {
|
||||
background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>');
|
||||
}
|
||||
|
||||
i.format.export {
|
||||
background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>');
|
||||
}
|
||||
|
||||
46
src/blog/themes/EditorTheme.ts
Normal file
46
src/blog/themes/EditorTheme.ts
Normal file
@@ -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;
|
||||
33
src/blog/types.ts
Normal file
33
src/blog/types.ts
Normal file
@@ -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;
|
||||
}
|
||||
50
src/blog/ui/DropdownColorPicker.css
Normal file
50
src/blog/ui/DropdownColorPicker.css
Normal file
@@ -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;
|
||||
}
|
||||
87
src/blog/ui/DropdownColorPicker.tsx
Normal file
87
src/blog/ui/DropdownColorPicker.tsx
Normal file
@@ -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<HTMLDivElement>(null);
|
||||
const buttonRef = useRef<HTMLButtonElement>(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 (
|
||||
<div className="color-picker-wrapper">
|
||||
<button
|
||||
ref={buttonRef}
|
||||
type="button"
|
||||
className={buttonClassName}
|
||||
onClick={() => setShowPicker(!showPicker)}
|
||||
aria-label={buttonAriaLabel || title}
|
||||
style={{ position: 'relative' }}>
|
||||
{buttonIconClassName && <i className={buttonIconClassName} />}
|
||||
<span
|
||||
className="color-preview"
|
||||
style={{ backgroundColor: color }}
|
||||
/>
|
||||
</button>
|
||||
{showPicker && (
|
||||
<div ref={dropdownRef} className="color-picker-dropdown">
|
||||
<div className="color-picker-basic-color">
|
||||
{basicColors.map((basicColor) => (
|
||||
<button
|
||||
key={basicColor}
|
||||
className={basicColor === color ? ' active' : ''}
|
||||
style={{ backgroundColor: basicColor }}
|
||||
onClick={() => {
|
||||
onChange?.(basicColor);
|
||||
setShowPicker(false);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
292
src/blog/ui/ImageResizer.tsx
Normal file
292
src/blog/ui/ImageResizer.tsx
Normal file
@@ -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<HTMLDivElement>(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<HTMLDivElement>,
|
||||
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 (
|
||||
<div ref={controlWrapperRef}>
|
||||
<div
|
||||
className="image-resizer image-resizer-n"
|
||||
onPointerDown={(event) => {
|
||||
handlePointerDown(event, Direction.north);
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="image-resizer image-resizer-ne"
|
||||
onPointerDown={(event) => {
|
||||
handlePointerDown(event, Direction.north | Direction.east);
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="image-resizer image-resizer-e"
|
||||
onPointerDown={(event) => {
|
||||
handlePointerDown(event, Direction.east);
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="image-resizer image-resizer-se"
|
||||
onPointerDown={(event) => {
|
||||
handlePointerDown(event, Direction.south | Direction.east);
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="image-resizer image-resizer-s"
|
||||
onPointerDown={(event) => {
|
||||
handlePointerDown(event, Direction.south);
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="image-resizer image-resizer-sw"
|
||||
onPointerDown={(event) => {
|
||||
handlePointerDown(event, Direction.south | Direction.west);
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="image-resizer image-resizer-w"
|
||||
onPointerDown={(event) => {
|
||||
handlePointerDown(event, Direction.west);
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="image-resizer image-resizer-nw"
|
||||
onPointerDown={(event) => {
|
||||
handlePointerDown(event, Direction.north | Direction.west);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
115
src/blog/utils/exportImport.ts
Normal file
115
src/blog/utils/exportImport.ts
Normal file
@@ -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();
|
||||
}
|
||||
@@ -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(
|
||||
<Route path="/" element={<App />} />
|
||||
<Route path="/friends" element={<Friends />} />
|
||||
<Route path="/blog" element={<Blog />} />
|
||||
<Route path="/blog/create" element={<CreatePost />} />
|
||||
<Route path="/servers" element={<Servers />} />
|
||||
<Route path="/forum" element={<Forum />} />
|
||||
<Route path="/auth/callback" element={<AuthCallback />} />
|
||||
|
||||
@@ -119,10 +119,36 @@ function Blog() {
|
||||
<p style={{
|
||||
fontSize: '1.3rem',
|
||||
color: 'var(--text-secondary)',
|
||||
lineHeight: '1.6'
|
||||
lineHeight: '1.6',
|
||||
marginBottom: '2rem'
|
||||
}}>
|
||||
{t('blog.subtitle')}
|
||||
</p>
|
||||
{/* Create Post Button */}
|
||||
<Link
|
||||
to="/blog/create"
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
background: 'var(--accent-color)',
|
||||
color: 'white',
|
||||
padding: '1rem 2rem',
|
||||
borderRadius: '8px',
|
||||
textDecoration: 'none',
|
||||
fontWeight: 'bold',
|
||||
fontSize: '1.1rem',
|
||||
transition: 'transform 0.3s ease, box-shadow 0.3s ease'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
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
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
355
src/pages/CreatePost.tsx
Normal file
355
src/pages/CreatePost.tsx
Normal file
@@ -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<string | null>(null);
|
||||
const [coverImageKey, setCoverImageKey] = useState<string>('');
|
||||
const [uploadProgress, setUploadProgress] = useState<number>(0);
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [uploadError, setUploadError] = useState<string>('');
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleCoverImageChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
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 (
|
||||
<Layout currentPage="blog">
|
||||
<div style={{
|
||||
maxWidth: '900px',
|
||||
margin: '0 auto',
|
||||
padding: '120px 2rem 80px'
|
||||
}}>
|
||||
{/* Header */}
|
||||
<div style={{ marginBottom: '3rem' }}>
|
||||
<h1 style={{
|
||||
fontSize: '2.5rem',
|
||||
fontWeight: 'bold',
|
||||
color: 'var(--text-primary)',
|
||||
marginBottom: '1rem'
|
||||
}}>
|
||||
Create New Post
|
||||
</h1>
|
||||
<p style={{
|
||||
fontSize: '1.1rem',
|
||||
color: 'var(--text-secondary)'
|
||||
}}>
|
||||
Share your thoughts with the community
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Title Input */}
|
||||
<div style={{ marginBottom: '2rem' }}>
|
||||
<label style={{
|
||||
display: 'block',
|
||||
fontSize: '1rem',
|
||||
fontWeight: '600',
|
||||
color: 'var(--text-primary)',
|
||||
marginBottom: '0.5rem'
|
||||
}}>
|
||||
Title *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={title}
|
||||
onChange={(e) => 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)';
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Cover Image Upload */}
|
||||
<div style={{ marginBottom: '2rem' }}>
|
||||
<label style={{
|
||||
display: 'block',
|
||||
fontSize: '1rem',
|
||||
fontWeight: '600',
|
||||
color: 'var(--text-primary)',
|
||||
marginBottom: '0.5rem'
|
||||
}}>
|
||||
Cover Image *
|
||||
</label>
|
||||
|
||||
<div style={{
|
||||
border: '2px dashed var(--border-color)',
|
||||
borderRadius: '8px',
|
||||
padding: '2rem',
|
||||
textAlign: 'center',
|
||||
background: 'var(--bg-card)',
|
||||
cursor: 'pointer',
|
||||
transition: 'border-color 0.3s ease'
|
||||
}}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.borderColor = 'var(--accent-color)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.borderColor = 'var(--border-color)';
|
||||
}}
|
||||
>
|
||||
{coverImage ? (
|
||||
<div style={{ position: 'relative' }}>
|
||||
<img
|
||||
src={coverImage}
|
||||
alt="Cover"
|
||||
style={{
|
||||
maxWidth: '100%',
|
||||
maxHeight: '400px',
|
||||
borderRadius: '8px'
|
||||
}}
|
||||
/>
|
||||
{isUploading && (
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
bottom: '10px',
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
background: 'rgba(0, 0, 0, 0.8)',
|
||||
padding: '10px 20px',
|
||||
borderRadius: '8px',
|
||||
color: 'white',
|
||||
minWidth: '200px'
|
||||
}}>
|
||||
<div style={{
|
||||
background: 'rgba(255, 255, 255, 0.2)',
|
||||
borderRadius: '4px',
|
||||
overflow: 'hidden',
|
||||
height: '6px',
|
||||
marginBottom: '5px'
|
||||
}}>
|
||||
<div style={{
|
||||
background: 'var(--accent-color, #4CAF50)',
|
||||
height: '100%',
|
||||
width: `${uploadProgress}%`,
|
||||
transition: 'width 0.3s ease'
|
||||
}} />
|
||||
</div>
|
||||
<div style={{ fontSize: '14px' }}>
|
||||
Uploading... {Math.round(uploadProgress)}%
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<div style={{
|
||||
fontSize: '3rem',
|
||||
marginBottom: '1rem'
|
||||
}}>
|
||||
📷
|
||||
</div>
|
||||
<p style={{
|
||||
color: 'var(--text-secondary)',
|
||||
fontSize: '1rem'
|
||||
}}>
|
||||
Click to upload cover image
|
||||
</p>
|
||||
<p style={{
|
||||
color: 'var(--text-secondary)',
|
||||
fontSize: '0.9rem',
|
||||
marginTop: '0.5rem'
|
||||
}}>
|
||||
PNG, JPG, GIF up to 10MB
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{uploadError && (
|
||||
<div style={{
|
||||
marginTop: '1rem',
|
||||
padding: '1rem',
|
||||
background: 'rgba(244, 67, 54, 0.1)',
|
||||
border: '1px solid rgba(244, 67, 54, 0.3)',
|
||||
borderRadius: '8px',
|
||||
color: '#F44336'
|
||||
}}>
|
||||
❌ {uploadError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={handleCoverImageChange}
|
||||
style={{ display: 'none' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Content Editor */}
|
||||
<div style={{ marginBottom: '2rem' }}>
|
||||
<label style={{
|
||||
display: 'block',
|
||||
fontSize: '1rem',
|
||||
fontWeight: '600',
|
||||
color: 'var(--text-primary)',
|
||||
marginBottom: '0.5rem'
|
||||
}}>
|
||||
Content *
|
||||
</label>
|
||||
<div style={{
|
||||
border: '2px solid var(--border-color)',
|
||||
borderRadius: '8px',
|
||||
overflow: 'hidden',
|
||||
background: 'var(--bg-card)'
|
||||
}}>
|
||||
<BlogEditor />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
gap: '1rem',
|
||||
justifyContent: 'flex-end'
|
||||
}}>
|
||||
<button
|
||||
onClick={() => navigate('/blog')}
|
||||
style={{
|
||||
padding: '1rem 2rem',
|
||||
fontSize: '1rem',
|
||||
fontWeight: '600',
|
||||
border: '2px solid var(--border-color)',
|
||||
borderRadius: '8px',
|
||||
background: 'transparent',
|
||||
color: 'var(--text-primary)',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.3s ease'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = 'var(--bg-secondary)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = 'transparent';
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={isSubmitting || isUploading}
|
||||
style={{
|
||||
padding: '1rem 2rem',
|
||||
fontSize: '1rem',
|
||||
fontWeight: '600',
|
||||
border: 'none',
|
||||
borderRadius: '8px',
|
||||
background: isSubmitting || isUploading ? 'var(--bg-secondary)' : 'var(--accent-color)',
|
||||
color: 'white',
|
||||
cursor: isSubmitting || isUploading ? 'not-allowed' : 'pointer',
|
||||
transition: 'all 0.3s ease',
|
||||
opacity: isSubmitting || isUploading ? 0.6 : 1
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!isSubmitting && !isUploading) {
|
||||
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';
|
||||
}}
|
||||
>
|
||||
{isSubmitting ? 'Publishing...' : 'Publish Post'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
export default CreatePost;
|
||||
Reference in New Issue
Block a user