Remove editor styles, toolbar styles, and related components
All checks were successful
CI - Build and Push / Build and Push Docker Image (push) Successful in 18s

- Deleted editor.css and toolbar.css stylesheets to streamline the editor's appearance.
- Removed EditorTheme.ts which defined theme classes for the editor.
- Eliminated DropdownColorPicker component and its associated styles for color selection.
- Removed ImageResizer component responsible for resizing images within the editor.
- Deleted exportImport.ts utility for handling JSON export/import functionality.
- Removed EditorDemo page and its references from the main application.
This commit is contained in:
2025-10-27 20:57:19 +08:00
parent fd0ec5a1d3
commit 06426eb781
23 changed files with 0 additions and 3344 deletions

View File

@@ -1,95 +0,0 @@
# Rich Text Editor
A basic rich text editor built with Lexical, ported from the lexical-playground project.
## Features
### Toolbar Controls
1. **Undo/Redo** - Navigate through editing history
2. **Text Size** - Select from multiple font sizes (10px - 36px)
3. **Bold** - Make text bold
4. **Italic** - Italicize text
5. **Underline** - Underline text
6. **Strikethrough** - Strike through text
7. **Code** - Format text as inline code
8. **Text Color** - Choose from 30 predefined colors
9. **Background Color** - Choose background color for text
### Additional Features
- Quote blocks
- Code blocks (without syntax highlighting)
- Lists (bullet and numbered)
- Headings (H1, H2, H3)
## Usage
### Basic Usage
```tsx
import RichTextEditor from './editor/RichTextEditor';
function MyComponent() {
return <RichTextEditor />;
}
```
### Accessing the Demo
Navigate to `/editor` to see the editor in action.
## Files Structure
```
src/editor/
├── RichTextEditor.tsx # Main editor component
├── index.ts # Export file
├── plugins/
│ └── ToolbarPlugin.tsx # Toolbar with formatting controls
├── ui/
│ ├── DropdownColorPicker.tsx # Color picker component
│ └── DropdownColorPicker.css # Color picker styles
├── themes/
│ └── EditorTheme.ts # Editor theme configuration
└── styles/
├── editor.css # Editor styles
└── toolbar.css # Toolbar styles
```
## Dependencies
- `lexical` - Core editor framework
- `@lexical/react` - React bindings
- `@lexical/code` - Code block support
- `@lexical/list` - List support
- `@lexical/rich-text` - Rich text features (headings, quotes)
- `@lexical/selection` - Selection utilities
- `@lexical/utils` - Utility functions
## Customization
### Theme
Edit `src/editor/themes/EditorTheme.ts` to customize the editor's appearance.
### Toolbar
Modify `src/editor/plugins/ToolbarPlugin.tsx` to add or remove toolbar buttons.
### Styles
- `src/editor/styles/editor.css` - Editor content styles
- `src/editor/styles/toolbar.css` - Toolbar styles
## Future Enhancements
Possible additions (not currently implemented):
- Syntax highlighting for code blocks
- Links
- Images
- Tables
- Alignment controls
- More list types (checklists)
- Markdown shortcuts

View File

@@ -1,120 +0,0 @@
import { LexicalComposer } from '@lexical/react/LexicalComposer';
import { RichTextPlugin } from '@lexical/react/LexicalRichTextPlugin';
import { ContentEditable } from '@lexical/react/LexicalContentEditable';
import { HistoryPlugin } from '@lexical/react/LexicalHistoryPlugin';
import { LexicalErrorBoundary } from '@lexical/react/LexicalErrorBoundary';
import { HeadingNode, QuoteNode } from '@lexical/rich-text';
import { CodeNode, CodeHighlightNode } from '@lexical/code';
import { ListItemNode, ListNode } from '@lexical/list';
import { ListPlugin } from '@lexical/react/LexicalListPlugin';
import { CheckListPlugin } from '@lexical/react/LexicalCheckListPlugin';
import { HorizontalRuleNode } from '@lexical/react/LexicalHorizontalRuleNode';
import { TableCellNode, TableNode, TableRowNode } from '@lexical/table';
import { TablePlugin } from '@lexical/react/LexicalTablePlugin';
import { LinkNode, AutoLinkNode } from '@lexical/link';
import { LinkPlugin } from '@lexical/react/LexicalLinkPlugin';
import { AutoLinkPlugin } from '@lexical/react/LexicalAutoLinkPlugin';
import { HashtagNode } from '@lexical/hashtag';
import { ImageNode } from './nodes/ImageNode';
import { MentionNode } from './nodes/MentionNode';
import ToolbarPlugin from './plugins/ToolbarPlugin';
import MarkdownPlugin from './plugins/MarkdownShortcutPlugin';
import ImagesPlugin from './plugins/ImagesPlugin';
import DragDropPastePlugin from './plugins/DragDropPastePlugin';
import HashtagPlugin from './plugins/HashtagPlugin';
import MentionsPlugin from './plugins/MentionsPlugin';
import editorTheme from './themes/EditorTheme';
import './styles/editor.css';
const URL_MATCHER =
/((https?:\/\/(www\.)?)|(www\.))[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&//=]*)/;
const EMAIL_MATCHER =
/(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))/;
const MATCHERS = [
(text: string) => {
const match = URL_MATCHER.exec(text);
if (match === null) {
return null;
}
const fullMatch = match[0];
return {
index: match.index,
length: fullMatch.length,
text: fullMatch,
url: fullMatch.startsWith('http') ? fullMatch : `https://${fullMatch}`,
};
},
(text: string) => {
const match = EMAIL_MATCHER.exec(text);
if (match === null) {
return null;
}
const fullMatch = match[0];
return {
index: match.index,
length: fullMatch.length,
text: fullMatch,
url: `mailto:${fullMatch}`,
};
},
];
const editorConfig = {
namespace: 'CiallooEditor',
theme: editorTheme,
onError(error: Error) {
console.error(error);
},
nodes: [
HeadingNode,
QuoteNode,
CodeNode,
CodeHighlightNode,
ListNode,
ListItemNode,
HorizontalRuleNode,
TableNode,
TableRowNode,
TableCellNode,
LinkNode,
AutoLinkNode,
ImageNode,
HashtagNode,
MentionNode,
],
};
export default function RichTextEditor() {
return (
<LexicalComposer initialConfig={editorConfig}>
<div className="editor-container">
<ToolbarPlugin />
<div className="editor-inner">
<RichTextPlugin
contentEditable={
<ContentEditable className="editor-input" />
}
placeholder={
<div className="editor-placeholder">Enter some text...</div>
}
ErrorBoundary={LexicalErrorBoundary}
/>
<HistoryPlugin />
<ListPlugin />
<CheckListPlugin />
<LinkPlugin />
<AutoLinkPlugin matchers={MATCHERS} />
<TablePlugin />
<ImagesPlugin />
<DragDropPastePlugin />
<HashtagPlugin />
<MentionsPlugin />
<MarkdownPlugin />
</div>
</div>
</LexicalComposer>
);
}

View File

@@ -1 +0,0 @@
export { default } from './RichTextEditor';

View File

@@ -1,275 +0,0 @@
import type { NodeKey } from 'lexical';
import type { JSX } from 'react';
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
import { useLexicalNodeSelection } from '@lexical/react/useLexicalNodeSelection';
import { mergeRegister } from '@lexical/utils';
import {
$getNodeByKey,
$setSelection,
CLICK_COMMAND,
COMMAND_PRIORITY_LOW,
DRAGSTART_COMMAND,
KEY_ESCAPE_COMMAND,
SELECTION_CHANGE_COMMAND,
} from 'lexical';
import {
useCallback,
useEffect,
useRef,
useState,
} from 'react';
import ImageResizer from './ImageResizer';
import { $isImageNode } from './ImageNode';
type ImageStatus =
| { error: true }
| { error: false; width: number; height: number };
const imageCache = new Map<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,
}: {
altText: string;
height: 'inherit' | number;
maxWidth: number;
nodeKey: NodeKey;
resizable: boolean;
src: string;
width: 'inherit' | number;
}): 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;
return (
<>
<div draggable={draggable}>
{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)}
/>
)}
</div>
{resizable && isSelected && isFocused && (
<ImageResizer
editor={editor}
imageRef={imageRef}
maxWidth={maxWidth}
onResizeStart={onResizeStart}
onResizeEnd={onResizeEnd}
/>
)}
</>
);
}

View File

@@ -1,207 +0,0 @@
import type {
DOMConversionMap,
DOMConversionOutput,
DOMExportOutput,
EditorConfig,
LexicalNode,
NodeKey,
SerializedLexicalNode,
Spread,
} from 'lexical';
import type { JSX } from 'react';
import {
$applyNodeReplacement,
DecoratorNode,
} from 'lexical';
import { Suspense, lazy } from 'react';
const ImageComponent = lazy(() => import('./ImageComponent'));
export interface ImagePayload {
altText: string;
height?: number;
key?: NodeKey;
maxWidth?: number;
src: string;
width?: number;
}
function $convertImageElement(domNode: Node): null | DOMConversionOutput {
const img = domNode as HTMLImageElement;
const src = img.getAttribute('src');
if (!src || src.startsWith('file:///')) {
return null;
}
const { alt: altText, width, height } = img;
const node = $createImageNode({ altText, height, src, width });
return { node };
}
export type SerializedImageNode = Spread<
{
altText: string;
height?: number;
maxWidth: number;
src: string;
width?: number;
},
SerializedLexicalNode
>;
export class ImageNode extends DecoratorNode<JSX.Element> {
__src: string;
__altText: string;
__width: 'inherit' | number;
__height: 'inherit' | number;
__maxWidth: number;
static getType(): string {
return 'image';
}
static clone(node: ImageNode): ImageNode {
return new ImageNode(
node.__src,
node.__altText,
node.__maxWidth,
node.__width,
node.__height,
node.__key,
);
}
static importJSON(serializedNode: SerializedImageNode): ImageNode {
const { altText, height, width, maxWidth, src } = serializedNode;
return $createImageNode({
altText,
height,
maxWidth,
src,
width,
});
}
exportDOM(): DOMExportOutput {
const element = document.createElement('img');
element.setAttribute('src', this.__src);
element.setAttribute('alt', this.__altText);
if (this.__width !== 'inherit') {
element.setAttribute('width', this.__width.toString());
}
if (this.__height !== 'inherit') {
element.setAttribute('height', this.__height.toString());
}
return { element };
}
static importDOM(): DOMConversionMap | null {
return {
img: () => ({
conversion: $convertImageElement,
priority: 0,
}),
};
}
constructor(
src: string,
altText: string,
maxWidth: number,
width?: 'inherit' | number,
height?: 'inherit' | number,
key?: NodeKey,
) {
super(key);
this.__src = src;
this.__altText = altText;
this.__maxWidth = maxWidth;
this.__width = width || 'inherit';
this.__height = height || 'inherit';
}
exportJSON(): SerializedImageNode {
return {
altText: this.getAltText(),
height: this.__height === 'inherit' ? 0 : this.__height,
maxWidth: this.__maxWidth,
src: this.getSrc(),
type: 'image',
version: 1,
width: this.__width === 'inherit' ? 0 : this.__width,
};
}
setWidthAndHeight(
width: 'inherit' | number,
height: 'inherit' | number,
): void {
const writable = this.getWritable();
writable.__width = width;
writable.__height = height;
}
createDOM(config: EditorConfig): HTMLElement {
const span = document.createElement('span');
const theme = config.theme;
const className = theme.image;
if (className !== undefined) {
span.className = className;
}
return span;
}
updateDOM(): false {
return false;
}
getSrc(): string {
return this.__src;
}
getAltText(): string {
return this.__altText;
}
decorate(): JSX.Element {
return (
<Suspense fallback={null}>
<ImageComponent
src={this.__src}
altText={this.__altText}
width={this.__width}
height={this.__height}
maxWidth={this.__maxWidth}
nodeKey={this.getKey()}
resizable={true}
/>
</Suspense>
);
}
}
export function $createImageNode({
altText,
height,
maxWidth = 800,
src,
width,
key,
}: ImagePayload): ImageNode {
return $applyNodeReplacement(
new ImageNode(
src,
altText,
maxWidth,
width,
height,
key,
),
);
}
export function $isImageNode(
node: LexicalNode | null | undefined,
): node is ImageNode {
return node instanceof ImageNode;
}

View File

@@ -1,292 +0,0 @@
import type { LexicalEditor } from 'lexical';
import type { JSX } from 'react';
import { calculateZoomLevel } from '@lexical/utils';
import { useRef } from 'react';
function clamp(value: number, min: number, max: number) {
return Math.min(Math.max(value, min), max);
}
const Direction = {
east: 1 << 0,
north: 1 << 3,
south: 1 << 1,
west: 1 << 2,
};
export default function ImageResizer({
onResizeStart,
onResizeEnd,
imageRef,
maxWidth,
editor,
}: {
editor: LexicalEditor;
imageRef: { current: null | HTMLElement };
maxWidth?: number;
onResizeEnd: (width: 'inherit' | number, height: 'inherit' | number) => void;
onResizeStart: () => void;
}): JSX.Element {
const controlWrapperRef = useRef<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>
);
}

View File

@@ -1,130 +0,0 @@
import {
$applyNodeReplacement,
type DOMConversionMap,
type DOMConversionOutput,
type DOMExportOutput,
type EditorConfig,
type LexicalNode,
type NodeKey,
type SerializedTextNode,
type Spread,
TextNode,
} from 'lexical';
export type SerializedMentionNode = Spread<
{
mentionName: string;
},
SerializedTextNode
>;
function $convertMentionElement(
domNode: HTMLElement,
): DOMConversionOutput | null {
const textContent = domNode.textContent;
const mentionName = domNode.getAttribute('data-lexical-mention-name');
if (textContent !== null) {
const node = $createMentionNode(
typeof mentionName === 'string' ? mentionName : textContent,
textContent,
);
return {
node,
};
}
return null;
}
const mentionStyle = 'background-color: rgba(24, 119, 232, 0.2)';
export class MentionNode extends TextNode {
__mention: string;
static getType(): string {
return 'mention';
}
static clone(node: MentionNode): MentionNode {
return new MentionNode(node.__mention, node.__text, node.__key);
}
static importJSON(serializedNode: SerializedMentionNode): MentionNode {
return $createMentionNode(serializedNode.mentionName).updateFromJSON(
serializedNode,
);
}
constructor(mentionName: string, text?: string, key?: NodeKey) {
super(text ?? mentionName, key);
this.__mention = mentionName;
}
exportJSON(): SerializedMentionNode {
return {
...super.exportJSON(),
mentionName: this.__mention,
};
}
createDOM(config: EditorConfig): HTMLElement {
const dom = super.createDOM(config);
dom.style.cssText = mentionStyle;
dom.className = 'mention';
dom.spellcheck = false;
return dom;
}
exportDOM(): DOMExportOutput {
const element = document.createElement('span');
element.setAttribute('data-lexical-mention', 'true');
if (this.__text !== this.__mention) {
element.setAttribute('data-lexical-mention-name', this.__mention);
}
element.textContent = this.__text;
return {element};
}
static importDOM(): DOMConversionMap | null {
return {
span: (domNode: HTMLElement) => {
if (!domNode.hasAttribute('data-lexical-mention')) {
return null;
}
return {
conversion: $convertMentionElement,
priority: 1,
};
},
};
}
isTextEntity(): true {
return true;
}
canInsertTextBefore(): boolean {
return false;
}
canInsertTextAfter(): boolean {
return false;
}
}
export function $createMentionNode(
mentionName: string,
textContent?: string,
): MentionNode {
const mentionNode = new MentionNode(mentionName, textContent ?? mentionName);
mentionNode.setMode('segmented').toggleDirectionless();
return $applyNodeReplacement(mentionNode);
}
export function $isMentionNode(
node: LexicalNode | null | undefined,
): node is MentionNode {
return node instanceof MentionNode;
}

View File

@@ -1,49 +0,0 @@
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
import { DRAG_DROP_PASTE } from '@lexical/rich-text';
import { isMimeType, mediaFileReader } from '@lexical/utils';
import { COMMAND_PRIORITY_LOW } from 'lexical';
import { useEffect } from 'react';
import { INSERT_IMAGE_COMMAND } from './ImagesPlugin';
const ACCEPTABLE_IMAGE_TYPES = [
'image/',
'image/heic',
'image/heif',
'image/gif',
'image/webp',
'image/png',
'image/jpeg',
'image/jpg',
'image/svg+xml',
];
export default function DragDropPastePlugin(): null {
const [editor] = useLexicalComposerContext();
useEffect(() => {
return editor.registerCommand(
DRAG_DROP_PASTE,
(files) => {
(async () => {
const filesResult = await mediaFileReader(
files,
[ACCEPTABLE_IMAGE_TYPES].flatMap((x) => x),
);
for (const { file, result } of filesResult) {
if (isMimeType(file, ACCEPTABLE_IMAGE_TYPES)) {
editor.dispatchCommand(INSERT_IMAGE_COMMAND, {
altText: file.name,
src: result,
});
}
}
})();
return true;
},
COMMAND_PRIORITY_LOW,
);
}, [editor]);
return null;
}

View File

@@ -1,18 +0,0 @@
import type {JSX} from 'react';
import {HashtagNode, registerLexicalHashtag} from '@lexical/hashtag';
import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext';
import {useEffect} from 'react';
export default function HashtagPlugin(): JSX.Element | null {
const [editor] = useLexicalComposerContext();
useEffect(() => {
if (!editor.hasNodes([HashtagNode])) {
throw new Error('HashtagPlugin: HashtagNode not registered on editor');
}
return registerLexicalHashtag(editor);
}, [editor]);
return null;
}

View File

@@ -1,47 +0,0 @@
import type { LexicalCommand } from 'lexical';
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
import { $wrapNodeInElement, mergeRegister } from '@lexical/utils';
import {
$createParagraphNode,
$insertNodes,
$isRootOrShadowRoot,
COMMAND_PRIORITY_EDITOR,
createCommand,
} from 'lexical';
import { useEffect } from 'react';
import { $createImageNode, ImageNode, type ImagePayload } from '../nodes/ImageNode';
export type InsertImagePayload = Readonly<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;
}

View File

@@ -1,7 +0,0 @@
import type { JSX } from 'react';
import { MarkdownShortcutPlugin } from '@lexical/react/LexicalMarkdownShortcutPlugin';
import { EDITOR_TRANSFORMERS } from './MarkdownTransformers';
export default function MarkdownPlugin(): JSX.Element {
return <MarkdownShortcutPlugin transformers={EDITOR_TRANSFORMERS} />;
}

View File

@@ -1,258 +0,0 @@
import type { ElementTransformer, TextMatchTransformer, Transformer } from '@lexical/markdown';
import {
CHECK_LIST,
ELEMENT_TRANSFORMERS,
MULTILINE_ELEMENT_TRANSFORMERS,
TEXT_FORMAT_TRANSFORMERS,
TEXT_MATCH_TRANSFORMERS,
} from '@lexical/markdown';
import {
$createHorizontalRuleNode,
$isHorizontalRuleNode,
} from '@lexical/react/LexicalHorizontalRuleNode';
import {
$createTableCellNode,
$createTableNode,
$createTableRowNode,
$isTableCellNode,
$isTableNode,
$isTableRowNode,
TableCellHeaderStates,
type TableCellNode,
type TableNode,
} from '@lexical/table';
import {
$createTextNode,
$isParagraphNode,
$isTextNode,
type LexicalNode,
} from 'lexical';
import { $createImageNode, $isImageNode } from '../nodes/ImageNode';
// Horizontal Rule transformer (---, ***, ___)
export const HR: ElementTransformer = {
dependencies: [],
export: (node: LexicalNode) => {
return $isHorizontalRuleNode(node) ? '***' : null;
},
regExp: /^(---|\*\*\*|___)\s?$/,
replace: (parentNode, _1, _2, isImport) => {
const line = $createHorizontalRuleNode();
if (isImport || parentNode.getNextSibling() != null) {
parentNode.replace(line);
} else {
parentNode.insertBefore(line);
}
line.selectNext();
},
type: 'element',
};
// Image transformer ![alt](url)
export const IMAGE: TextMatchTransformer = {
dependencies: [],
export: (node) => {
if (!$isImageNode(node)) {
return null;
}
return `![${node.getAltText()}](${node.getSrc()})`;
},
importRegExp: /!(?:\[([^[]*)\])(?:\(([^(]+)\))/,
regExp: /!(?:\[([^[]*)\])(?:\(([^(]+)\))$/,
replace: (textNode, match) => {
const [, altText, src] = match;
const imageNode = $createImageNode({
altText,
maxWidth: 800,
src,
});
textNode.replace(imageNode);
},
trigger: ')',
type: 'text-match',
};
// Table transformer
const TABLE_ROW_REG_EXP = /^(?:\|)(.+)(?:\|)\s?$/;
const TABLE_ROW_DIVIDER_REG_EXP = /^(\| ?:?-*:? ?)+\|\s?$/;
const $createTableCell = (textContent: string): TableCellNode => {
const cell = $createTableCellNode(TableCellHeaderStates.NO_STATUS);
const text = $createTextNode(textContent.trim());
cell.append(text);
return cell;
};
const mapToTableCells = (textContent: string): Array<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 (![alt](url))
export const EDITOR_TRANSFORMERS: Array<Transformer> = [
TABLE,
HR,
IMAGE,
CHECK_LIST,
...ELEMENT_TRANSFORMERS,
...MULTILINE_ELEMENT_TRANSFORMERS,
...TEXT_FORMAT_TRANSFORMERS,
...TEXT_MATCH_TRANSFORMERS,
];

View File

@@ -1,288 +0,0 @@
import type {JSX} from 'react';
import type {MenuTextMatch} from '@lexical/react/LexicalTypeaheadMenuPlugin';
import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext';
import {
LexicalTypeaheadMenuPlugin,
MenuOption,
useBasicTypeaheadTriggerMatch,
} from '@lexical/react/LexicalTypeaheadMenuPlugin';
import {TextNode} from 'lexical';
import {useCallback, useEffect, useMemo, useState} from 'react';
import * as ReactDOM from 'react-dom';
import {$createMentionNode} from '../nodes/MentionNode';
const PUNCTUATION =
'\\.,\\+\\*\\?\\$\\@\\|#{}\\(\\)\\^\\-\\[\\]\\\\/!%\'"~=<>_:;';
const TRIGGERS = ['@'].join('');
// Chars we expect to see in a mention (non-space, non-punctuation).
const VALID_CHARS = '[^' + TRIGGERS + PUNCTUATION + '\\s]';
// Non-standard series of chars. Each series must be preceded and followed by
// a valid char.
const VALID_JOINS =
'(?:' +
'\\.[ |$]|' + // E.g. "r. " in "Mr. Smith"
' |' + // E.g. " " in "Josh Duck"
'[' +
PUNCTUATION +
']|' + // E.g. "-' in "Salier-Hellendag"
')';
const LENGTH_LIMIT = 75;
const AtSignMentionsRegex = new RegExp(
'(^|\\s|\\()(' +
'[' +
TRIGGERS +
']' +
'((?:' +
VALID_CHARS +
VALID_JOINS +
'){0,' +
LENGTH_LIMIT +
'})' +
')$',
);
// 50 is the longest alias length limit.
const ALIAS_LENGTH_LIMIT = 50;
// Regex used to match alias.
const AtSignMentionsRegexAliasRegex = new RegExp(
'(^|\\s|\\()(' +
'[' +
TRIGGERS +
']' +
'((?:' +
VALID_CHARS +
'){0,' +
ALIAS_LENGTH_LIMIT +
'})' +
')$',
);
// At most, 5 suggestions are shown in the popup.
const SUGGESTION_LIST_LENGTH_LIMIT = 5;
const mentionsCache = new Map();
const dummyMentionsData = [
'Alice Johnson',
'Bob Smith',
'Charlie Brown',
'David Wilson',
'Emma Davis',
'Frank Miller',
'Grace Lee',
'Henry Taylor',
'Isabella Martinez',
'Jack Anderson',
];
const dummyLookupService = {
search(string: string, callback: (results: Array<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
}
/>
);
}

View File

@@ -1,301 +0,0 @@
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
import { useCallback, useEffect, useRef, useState } from 'react';
import {
CAN_REDO_COMMAND,
CAN_UNDO_COMMAND,
REDO_COMMAND,
UNDO_COMMAND,
SELECTION_CHANGE_COMMAND,
FORMAT_TEXT_COMMAND,
FORMAT_ELEMENT_COMMAND,
COMMAND_PRIORITY_CRITICAL,
$getSelection,
$isRangeSelection,
$isRootOrShadowRoot,
$isElementNode,
} from 'lexical';
import type { ElementFormatType, TextFormatType } from 'lexical';
import { $getSelectionStyleValueForProperty, $patchStyleText } from '@lexical/selection';
import { $findMatchingParent } from '@lexical/utils';
import DropdownColorPicker from '../ui/DropdownColorPicker';
import { exportToJSON, importFromJSON } from '../utils/exportImport';
import '../styles/toolbar.css';
export default function ToolbarPlugin() {
const [editor] = useLexicalComposerContext();
const toolbarRef = useRef(null);
const [canUndo, setCanUndo] = useState(false);
const [canRedo, setCanRedo] = useState(false);
const [isBold, setIsBold] = useState(false);
const [isItalic, setIsItalic] = useState(false);
const [isUnderline, setIsUnderline] = useState(false);
const [isStrikethrough, setIsStrikethrough] = useState(false);
const [isCode, setIsCode] = useState(false);
const [fontColor, setFontColor] = useState('#000');
const [bgColor, setBgColor] = useState('#fff');
const [fontSize, setFontSize] = useState('15px');
const [elementFormat, setElementFormat] = useState<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>
);
}

View File

@@ -1,477 +0,0 @@
.editor-container {
margin: 20px auto;
border-radius: 8px;
max-width: 1100px;
color: #000;
position: relative;
line-height: 1.7;
font-weight: 400;
text-align: left;
border: 1px solid #e0e0e0;
background: #fff;
}
.editor-inner {
background: #fff;
position: relative;
border-bottom-left-radius: 8px;
border-bottom-right-radius: 8px;
}
.editor-input {
min-height: 300px;
resize: vertical;
font-size: 15px;
caret-color: rgb(5, 5, 5);
position: relative;
tab-size: 1;
outline: 0;
padding: 15px 20px;
caret-color: #444;
}
.editor-placeholder {
color: #999;
overflow: hidden;
position: absolute;
text-overflow: ellipsis;
top: 15px;
left: 20px;
font-size: 15px;
user-select: none;
display: inline-block;
pointer-events: none;
}
.editor-paragraph {
margin: 0;
margin-bottom: 8px;
position: relative;
}
.editor-paragraph:last-child {
margin-bottom: 0;
}
.editor-heading-h1 {
font-size: 2em;
font-weight: 700;
margin: 0;
margin-bottom: 12px;
padding: 0;
}
.editor-heading-h2 {
font-size: 1.5em;
font-weight: 700;
margin: 0;
margin-bottom: 10px;
padding: 0;
}
.editor-heading-h3 {
font-size: 1.25em;
font-weight: 700;
margin: 0;
margin-bottom: 8px;
padding: 0;
}
.editor-quote {
margin: 0;
margin-left: 20px;
margin-bottom: 10px;
font-size: 15px;
color: #666;
border-left: 4px solid #ccc;
padding-left: 16px;
}
.editor-code {
background-color: #f4f4f4;
font-family: Menlo, Consolas, Monaco, monospace;
display: block;
padding: 8px 12px;
line-height: 1.53;
font-size: 13px;
margin: 0;
margin-top: 8px;
margin-bottom: 8px;
overflow-x: auto;
position: relative;
tab-size: 2;
border-radius: 4px;
}
.editor-text-bold {
font-weight: bold;
}
.editor-text-italic {
font-style: italic;
}
.editor-text-underline {
text-decoration: underline;
}
.editor-text-strikethrough {
text-decoration: line-through;
}
.editor-text-code {
background-color: #f4f4f4;
padding: 1px 4px;
font-family: Menlo, Consolas, Monaco, monospace;
font-size: 90%;
border-radius: 3px;
}
.editor-list-ol {
padding: 0;
margin: 0;
margin-left: 20px;
}
.editor-list-ul {
padding: 0;
margin: 0;
margin-left: 20px;
}
.editor-listitem {
margin: 4px 0;
}
.editor-nested-listitem {
list-style-type: none;
}
/* Horizontal Rule */
.editor-hr {
border: none;
border-top: 2px solid #ccc;
margin: 15px 0;
}
/* Link */
.editor-link {
color: #0066cc;
text-decoration: none;
cursor: pointer;
}
.editor-link:hover {
text-decoration: underline;
}
/* Table */
.editor-table {
border-collapse: collapse;
border-spacing: 0;
overflow-y: scroll;
overflow-x: scroll;
table-layout: fixed;
width: max-content;
margin: 15px 0;
}
.editor-table-cell {
border: 1px solid #ccc;
min-width: 75px;
vertical-align: top;
text-align: start;
padding: 6px 8px;
position: relative;
outline: none;
}
.editor-table-cell-header {
background-color: #f4f4f4;
font-weight: bold;
text-align: left;
}
/* Checklist */
.editor-listitem-checked,
.editor-listitem-unchecked {
position: relative;
margin-left: 8px;
margin-right: 8px;
padding-left: 24px;
padding-right: 24px;
list-style-type: none;
outline: none;
}
.editor-listitem-checked {
text-decoration: line-through;
}
.editor-listitem-unchecked:before,
.editor-listitem-checked:before {
content: '';
width: 16px;
height: 16px;
top: 2px;
left: 0;
cursor: pointer;
display: block;
background-size: cover;
position: absolute;
}
.editor-listitem-unchecked:before {
border: 1px solid #999;
border-radius: 2px;
}
.editor-listitem-checked:before {
border: 1px solid #0066cc;
border-radius: 2px;
background-color: #0066cc;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 16 16'%3E%3Cpath fill='white' d='M13.5 2l-7.5 7.5-3.5-3.5-1.5 1.5 5 5 9-9z'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: center;
}
.editor-listitem-checked[dir='rtl']:before,
.editor-listitem-unchecked[dir='rtl']:before {
left: auto;
right: 0;
}
.editor-listitem-checked[dir='rtl'],
.editor-listitem-unchecked[dir='rtl'] {
padding-left: 24px;
padding-right: 24px;
}
/* Image */
.editor-image {
cursor: default;
display: inline-block;
position: relative;
user-select: none;
}
.editor-image img {
max-width: 100%;
cursor: default;
}
.editor-image img.focused {
outline: 2px solid rgb(60, 132, 244);
user-select: none;
}
.editor-image img.focused.draggable {
cursor: grab;
}
.editor-image img.focused.draggable:active {
cursor: grabbing;
}
.image-control-wrapper--resizing {
touch-action: none;
}
/* Image Resizer */
.image-resizer {
display: block;
width: 7px;
height: 7px;
position: absolute;
background-color: rgb(60, 132, 244);
border: 1px solid #fff;
}
.image-resizer.image-resizer-n {
top: -6px;
left: 48%;
cursor: n-resize;
}
.image-resizer.image-resizer-ne {
top: -6px;
right: -6px;
cursor: ne-resize;
}
.image-resizer.image-resizer-e {
bottom: 48%;
right: -6px;
cursor: e-resize;
}
.image-resizer.image-resizer-se {
bottom: -2px;
right: -6px;
cursor: nwse-resize;
}
.image-resizer.image-resizer-s {
bottom: -2px;
left: 48%;
cursor: s-resize;
}
.image-resizer.image-resizer-sw {
bottom: -2px;
left: -6px;
cursor: sw-resize;
}
.image-resizer.image-resizer-w {
bottom: 48%;
left: -6px;
cursor: w-resize;
}
.image-resizer.image-resizer-nw {
top: -6px;
left: -6px;
cursor: nw-resize;
}
/* Text Alignment */
.editor-text-left {
text-align: left;
}
.editor-text-center {
text-align: center;
}
.editor-text-right {
text-align: right;
}
.editor-text-justify {
text-align: justify;
}
/* Hashtag styles */
.editor-hashtag {
background-color: rgba(88, 144, 255, 0.15);
border-bottom: 1px solid rgba(88, 144, 255, 0.3);
font-weight: 500;
}
/* Mention styles */
.mention {
background-color: rgba(24, 119, 232, 0.2);
color: #1877e8;
border-radius: 4px;
padding: 1px 3px;
font-weight: 500;
cursor: pointer;
}
.mention:focus {
box-shadow: rgb(180 213 255) 0px 0px 0px 2px;
outline: none;
}
/* Typeahead popover styles */
.typeahead-popover {
background: #fff;
box-shadow: 0px 5px 10px rgba(0, 0, 0, 0.3);
border-radius: 8px;
position: relative;
z-index: 5;
}
.typeahead-popover ul {
padding: 0;
list-style: none;
margin: 0;
border-radius: 8px;
max-height: 200px;
overflow-y: scroll;
}
.typeahead-popover ul::-webkit-scrollbar {
display: none;
}
.typeahead-popover ul {
-ms-overflow-style: none;
scrollbar-width: none;
}
.typeahead-popover ul li {
margin: 0;
min-width: 180px;
font-size: 14px;
outline: none;
cursor: pointer;
border-radius: 8px;
}
.typeahead-popover ul li.selected {
background: #eee;
}
.typeahead-popover li {
margin: 0 8px 0 8px;
padding: 8px;
color: #050505;
cursor: pointer;
line-height: 16px;
font-size: 15px;
display: flex;
align-content: center;
flex-direction: row;
flex-shrink: 0;
background-color: #fff;
border-radius: 8px;
border: 0;
}
.typeahead-popover li.active {
display: flex;
width: 20px;
height: 20px;
background-size: contain;
}
.typeahead-popover li:first-child {
border-radius: 8px 8px 0px 0px;
}
.typeahead-popover li:last-child {
border-radius: 0px 0px 8px 8px;
}
.typeahead-popover li:hover {
background-color: #eee;
}
.typeahead-popover li .text {
display: flex;
line-height: 20px;
flex-grow: 1;
min-width: 150px;
}
.typeahead-popover li .icon {
display: flex;
width: 20px;
height: 20px;
user-select: none;
margin-right: 8px;
line-height: 16px;
background-size: contain;
background-repeat: no-repeat;
background-position: center;
}
.mentions-menu {
width: 250px;
}
.typeahead-popover li .icon.user {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='%23666'%3E%3Cpath d='M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z'/%3E%3C/svg%3E");
}

View File

@@ -1,155 +0,0 @@
.toolbar {
display: flex;
background: #fff;
padding: 8px;
border-top-left-radius: 8px;
border-top-right-radius: 8px;
vertical-align: middle;
border-bottom: 1px solid #e0e0e0;
gap: 4px;
flex-wrap: wrap;
align-items: center;
}
.toolbar button {
border: 0;
display: flex;
background: none;
border-radius: 4px;
padding: 6px;
cursor: pointer;
vertical-align: middle;
align-items: center;
justify-content: center;
transition: background-color 0.2s;
}
.toolbar button:hover:not([disabled]) {
background-color: #f0f0f0;
}
.toolbar button:disabled {
cursor: not-allowed;
opacity: 0.3;
}
.toolbar button.active {
background-color: rgba(24, 144, 255, 0.1);
}
.toolbar-item {
border: 0;
display: flex;
background: none;
border-radius: 4px;
padding: 6px;
cursor: pointer;
vertical-align: middle;
flex-shrink: 0;
align-items: center;
justify-content: space-between;
}
.toolbar-item.spaced {
margin-right: 2px;
}
.toolbar-item.block-controls {
background: none;
border: 1px solid #d0d0d0;
border-radius: 4px;
padding: 4px 8px;
font-size: 14px;
cursor: pointer;
min-width: 80px;
}
.toolbar-item i.format {
background-size: contain;
display: inline-block;
height: 18px;
width: 18px;
vertical-align: -0.25em;
opacity: 0.7;
}
.toolbar-item.active i.format,
.toolbar-item:hover:not([disabled]) i.format {
opacity: 1;
}
.divider {
width: 1px;
background-color: #e0e0e0;
margin: 0 4px;
height: 24px;
}
i.format.undo {
background-image: url('data:image/svg+xml;utf8,<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>');
}

View File

@@ -1,46 +0,0 @@
import type { EditorThemeClasses } from 'lexical';
const theme: EditorThemeClasses = {
paragraph: 'editor-paragraph',
quote: 'editor-quote',
heading: {
h1: 'editor-heading-h1',
h2: 'editor-heading-h2',
h3: 'editor-heading-h3',
h4: 'editor-heading-h4',
h5: 'editor-heading-h5',
h6: 'editor-heading-h6',
},
list: {
nested: {
listitem: 'editor-nested-listitem',
},
ol: 'editor-list-ol',
ul: 'editor-list-ul',
listitem: 'editor-listitem',
listitemChecked: 'editor-listitem-checked',
listitemUnchecked: 'editor-listitem-unchecked',
},
text: {
bold: 'editor-text-bold',
italic: 'editor-text-italic',
underline: 'editor-text-underline',
strikethrough: 'editor-text-strikethrough',
code: 'editor-text-code',
left: 'editor-text-left',
center: 'editor-text-center',
right: 'editor-text-right',
justify: 'editor-text-justify',
},
code: 'editor-code',
codeHighlight: {},
link: 'editor-link',
table: 'editor-table',
tableCell: 'editor-table-cell',
tableCellHeader: 'editor-table-cell-header',
hr: 'editor-hr',
image: 'editor-image',
hashtag: 'editor-hashtag',
};
export default theme;

View File

@@ -1,50 +0,0 @@
.color-picker-wrapper {
position: relative;
}
.color-preview {
display: inline-block;
width: 16px;
height: 16px;
border-radius: 2px;
border: 1px solid #ccc;
margin-left: 4px;
vertical-align: middle;
}
.color-picker-dropdown {
position: absolute;
top: 100%;
left: 0;
margin-top: 4px;
background: white;
border: 1px solid #ccc;
border-radius: 4px;
padding: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
z-index: 1000;
}
.color-picker-basic-color {
display: grid;
grid-template-columns: repeat(6, 1fr);
gap: 4px;
}
.color-picker-basic-color button {
width: 24px;
height: 24px;
border: 1px solid #ccc;
border-radius: 2px;
cursor: pointer;
padding: 0;
}
.color-picker-basic-color button:hover {
border-color: #333;
}
.color-picker-basic-color button.active {
border: 2px solid #1890ff;
border-radius: 4px;
}

View File

@@ -1,87 +0,0 @@
import { useEffect, useRef, useState } from 'react';
import './DropdownColorPicker.css';
const basicColors = [
'#000000', '#ffffff', '#888888', '#ff0000', '#00ff00', '#0000ff',
'#ffff00', '#00ffff', '#ff00ff', '#c0c0c0', '#808080', '#800000',
'#808000', '#008000', '#800080', '#008080', '#000080', '#ffa500',
'#a52a2a', '#dc143c', '#ff1493', '#ff69b4', '#ffd700', '#adff2f',
'#00fa9a', '#00ced1', '#1e90ff', '#9370db', '#ff6347', '#40e0d0',
];
type Props = {
buttonClassName: string;
buttonAriaLabel?: string;
buttonIconClassName?: string;
color: string;
onChange?: (color: string) => void;
title?: string;
};
export default function DropdownColorPicker({
buttonClassName,
buttonAriaLabel,
buttonIconClassName,
color,
onChange,
title,
}: Props) {
const [showPicker, setShowPicker] = useState(false);
const dropdownRef = useRef<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>
);
}

View File

@@ -1,292 +0,0 @@
import type { LexicalEditor } from 'lexical';
import type { JSX } from 'react';
import { calculateZoomLevel } from '@lexical/utils';
import { useRef } from 'react';
function clamp(value: number, min: number, max: number) {
return Math.min(Math.max(value, min), max);
}
const Direction = {
east: 1 << 0,
north: 1 << 3,
south: 1 << 1,
west: 1 << 2,
};
export default function ImageResizer({
onResizeStart,
onResizeEnd,
imageRef,
maxWidth,
editor,
}: {
editor: LexicalEditor;
imageRef: { current: null | HTMLElement };
maxWidth?: number;
onResizeEnd: (width: 'inherit' | number, height: 'inherit' | number) => void;
onResizeStart: () => void;
}): JSX.Element {
const controlWrapperRef = useRef<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>
);
}

View File

@@ -1,115 +0,0 @@
import type { LexicalEditor, SerializedEditorState } from 'lexical';
import { CLEAR_HISTORY_COMMAND } from 'lexical';
export interface SerializedDocument {
/** The serialized editorState produced by editorState.toJSON() */
editorState: SerializedEditorState;
/** The time this document was created in epoch milliseconds (Date.now()) */
lastSaved: number;
/** The source of the document, defaults to Cialloo */
source: string;
}
/**
* Generates a SerializedDocument from the current editor state
*/
function serializedDocumentFromEditorState(
editor: LexicalEditor,
config: Readonly<{
source?: string;
lastSaved?: number;
}> = Object.freeze({}),
): SerializedDocument {
return {
editorState: editor.getEditorState().toJSON(),
lastSaved: config.lastSaved || Date.now(),
source: config.source || 'Cialloo Editor',
};
}
/**
* Exports the editor content as a JSON file
*/
export function exportToJSON(
editor: LexicalEditor,
config: Readonly<{
fileName?: string;
source?: string;
}> = Object.freeze({}),
) {
const now = new Date();
const serializedDocument = serializedDocumentFromEditorState(editor, {
...config,
lastSaved: now.getTime(),
});
const fileName = config.fileName || `document-${now.toISOString()}`;
exportBlob(serializedDocument, `${fileName}.json`);
}
/**
* Creates a downloadable blob and triggers download
*/
function exportBlob(data: SerializedDocument, fileName: string) {
const a = document.createElement('a');
const body = document.body;
if (body === null) {
return;
}
body.appendChild(a);
a.style.display = 'none';
const json = JSON.stringify(data, null, 2); // Pretty print with 2 spaces
const blob = new Blob([json], {
type: 'application/json',
});
const url = window.URL.createObjectURL(blob);
a.href = url;
a.download = fileName;
a.click();
window.URL.revokeObjectURL(url);
a.remove();
}
/**
* Imports editor content from a JSON file
*/
export function importFromJSON(editor: LexicalEditor) {
readJSONFileFromSystem((text) => {
try {
const json = JSON.parse(text) as SerializedDocument;
const editorState = editor.parseEditorState(json.editorState);
editor.setEditorState(editorState);
editor.dispatchCommand(CLEAR_HISTORY_COMMAND, undefined);
} catch (error) {
console.error('Failed to import JSON:', error);
alert('Failed to import file. Please make sure it\'s a valid JSON export.');
}
});
}
/**
* Reads a JSON file from the user's system
*/
function readJSONFileFromSystem(callback: (text: string) => void) {
const input = document.createElement('input');
input.type = 'file';
input.accept = '.json';
input.addEventListener('change', (event: Event) => {
const target = event.target as HTMLInputElement;
if (target.files) {
const file = target.files[0];
const reader = new FileReader();
reader.readAsText(file, 'UTF-8');
reader.onload = (readerEvent) => {
if (readerEvent.target) {
const content = readerEvent.target.result;
callback(content as string);
}
};
}
});
input.click();
}

View File

@@ -16,7 +16,6 @@ import CreatePost from './pages/CreatePost.tsx'
import Servers from './pages/Servers.tsx'
import Forum from './pages/Forum.tsx'
import AuthCallback from './pages/AuthCallback.tsx'
import EditorDemo from './pages/EditorDemo.tsx'
import BlogPost from './pages/BlogPost.tsx'
import EditPost from './pages/EditPost.tsx'
@@ -39,7 +38,6 @@ createRoot(document.getElementById('root')!).render(
<Route path="/servers" element={<Servers />} />
<Route path="/forum" element={<Forum />} />
<Route path="/auth/callback" element={<AuthCallback />} />
<Route path="/editor" element={<EditorDemo />} />
</Routes>
</Router>
</BlogIndexProvider>

View File

@@ -1,32 +0,0 @@
import RichTextEditor from '../editor/RichTextEditor';
import '../editor/styles/editor.css';
import '../editor/styles/toolbar.css';
export default function EditorDemo() {
return (
<div style={{
padding: '40px 20px',
maxWidth: '1200px',
margin: '0 auto',
minHeight: '100vh'
}}>
<h1 style={{
marginBottom: '20px',
fontSize: '2em',
fontWeight: 'bold'
}}>
Rich Text Editor Demo
</h1>
<p style={{
marginBottom: '30px',
color: '#666',
fontSize: '1.1em'
}}>
A basic rich text editor with formatting toolbar featuring:
undo/redo, text size, bold, italic, underline, strikethrough,
code blocks, text color, and background color.
</p>
<RichTextEditor />
</div>
);
}