feat: add audio support with AudioNode and AudioComponent, including drag-and-drop functionality
All checks were successful
CI - Build and Push / Build and Push Docker Image (push) Successful in 19s
All checks were successful
CI - Build and Push / Build and Push Docker Image (push) Successful in 19s
This commit is contained in:
@@ -17,6 +17,7 @@ import editorTheme from './themes/EditorTheme';
|
||||
import { ImageNode } from './nodes/ImageNode';
|
||||
import { ArchiveNode } from './nodes/ArchiveNode';
|
||||
import { VideoNode } from './nodes/VideoNode';
|
||||
import { AudioNode } from './nodes/AudioNode';
|
||||
import { MentionNode } from './nodes/MentionNode';
|
||||
import './styles/editor.css';
|
||||
|
||||
@@ -67,6 +68,7 @@ const initialConfig: InitialConfigType = {
|
||||
ImageNode,
|
||||
ArchiveNode,
|
||||
VideoNode,
|
||||
AudioNode,
|
||||
HashtagNode,
|
||||
MentionNode,
|
||||
],
|
||||
|
||||
@@ -22,11 +22,13 @@ import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext
|
||||
import { ImageNode } from './nodes/ImageNode';
|
||||
import { ArchiveNode } from './nodes/ArchiveNode';
|
||||
import { VideoNode } from './nodes/VideoNode';
|
||||
import { AudioNode } from './nodes/AudioNode';
|
||||
import { MentionNode } from './nodes/MentionNode';
|
||||
import ToolbarPlugin from './plugins/ToolbarPlugin';
|
||||
import MarkdownPlugin from './plugins/MarkdownShortcutPlugin';
|
||||
import ImagesPlugin from './plugins/ImagesPlugin';
|
||||
import VideosPlugin from './plugins/VideosPlugin';
|
||||
import AudiosPlugin from './plugins/AudiosPlugin';
|
||||
import DragDropPastePlugin, { setDragDropToastHandler } from './plugins/DragDropPastePlugin';
|
||||
import ArchivesPlugin from './plugins/ArchivesPlugin';
|
||||
import HashtagPlugin from './plugins/HashtagPlugin';
|
||||
@@ -93,6 +95,7 @@ const editorConfig: InitialConfigType = {
|
||||
ImageNode,
|
||||
ArchiveNode,
|
||||
VideoNode,
|
||||
AudioNode,
|
||||
HashtagNode,
|
||||
MentionNode,
|
||||
],
|
||||
@@ -180,6 +183,7 @@ const BlogEditor = forwardRef<BlogEditorRef, BlogEditorProps>(({ initialContent
|
||||
<TablePlugin />
|
||||
<ImagesPlugin />
|
||||
<VideosPlugin />
|
||||
<AudiosPlugin />
|
||||
<ArchivesPlugin />
|
||||
<DragDropPastePlugin />
|
||||
<HashtagPlugin />
|
||||
|
||||
148
src/blog/nodes/AudioComponent.tsx
Normal file
148
src/blog/nodes/AudioComponent.tsx
Normal file
@@ -0,0 +1,148 @@
|
||||
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 {
|
||||
$setSelection,
|
||||
CLICK_COMMAND,
|
||||
COMMAND_PRIORITY_LOW,
|
||||
KEY_ESCAPE_COMMAND,
|
||||
SELECTION_CHANGE_COMMAND,
|
||||
} from 'lexical';
|
||||
import { useCallback, useEffect, useMemo, useRef } from 'react';
|
||||
|
||||
import { getS3Url } from '../s3Config';
|
||||
|
||||
interface AudioComponentProps {
|
||||
nodeKey: NodeKey;
|
||||
src: string;
|
||||
fileKey?: string;
|
||||
uploadProgress?: number;
|
||||
uploadError?: string;
|
||||
}
|
||||
|
||||
export default function AudioComponent({
|
||||
nodeKey,
|
||||
src,
|
||||
fileKey,
|
||||
uploadProgress,
|
||||
uploadError,
|
||||
}: AudioComponentProps): JSX.Element {
|
||||
const [editor] = useLexicalComposerContext();
|
||||
const [isSelected, setSelected, clearSelection] = useLexicalNodeSelection(nodeKey);
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||
|
||||
const resolvedSrc = useMemo(() => {
|
||||
if (fileKey) {
|
||||
return getS3Url(fileKey);
|
||||
}
|
||||
return src;
|
||||
}, [fileKey, src]);
|
||||
|
||||
const isUploading = uploadProgress !== undefined && uploadProgress < 100 && !uploadError;
|
||||
|
||||
const handleEscape = useCallback(() => {
|
||||
if (!isSelected) {
|
||||
return false;
|
||||
}
|
||||
$setSelection(null);
|
||||
editor.update(() => {
|
||||
setSelected(true);
|
||||
editor.getRootElement()?.focus();
|
||||
});
|
||||
return true;
|
||||
}, [editor, isSelected, setSelected]);
|
||||
|
||||
const handleClick = useCallback(
|
||||
(event: MouseEvent) => {
|
||||
if (!containerRef.current) {
|
||||
return false;
|
||||
}
|
||||
if (containerRef.current.contains(event.target as Node)) {
|
||||
if (event.shiftKey) {
|
||||
setSelected(!isSelected);
|
||||
} else {
|
||||
clearSelection();
|
||||
setSelected(true);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
[clearSelection, isSelected, setSelected],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
return mergeRegister(
|
||||
editor.registerCommand(
|
||||
SELECTION_CHANGE_COMMAND,
|
||||
() => false,
|
||||
COMMAND_PRIORITY_LOW,
|
||||
),
|
||||
editor.registerCommand(
|
||||
CLICK_COMMAND,
|
||||
handleClick,
|
||||
COMMAND_PRIORITY_LOW,
|
||||
),
|
||||
editor.registerCommand(
|
||||
KEY_ESCAPE_COMMAND,
|
||||
handleEscape,
|
||||
COMMAND_PRIORITY_LOW,
|
||||
),
|
||||
);
|
||||
}, [editor, handleClick, handleEscape]);
|
||||
|
||||
const borderColor = uploadError
|
||||
? 'rgba(244, 67, 54, 0.6)'
|
||||
: isSelected
|
||||
? 'var(--accent-color, #4CAF50)'
|
||||
: 'var(--border-color, #ccc)';
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="editor-audio-card"
|
||||
style={{
|
||||
borderColor,
|
||||
}}
|
||||
tabIndex={0}
|
||||
onFocus={() => {
|
||||
clearSelection();
|
||||
setSelected(true);
|
||||
}}
|
||||
>
|
||||
<div className="editor-audio-wrapper">
|
||||
{resolvedSrc ? (
|
||||
<audio
|
||||
ref={audioRef}
|
||||
className="editor-audio-player"
|
||||
controls
|
||||
preload="metadata"
|
||||
src={resolvedSrc}
|
||||
/>
|
||||
) : (
|
||||
<div className="editor-audio-placeholder">Preparing audio...</div>
|
||||
)}
|
||||
{isUploading && (
|
||||
<div className="editor-audio-overlay">
|
||||
<div className="editor-audio-progress">
|
||||
<div
|
||||
className="editor-audio-progress-value"
|
||||
style={{ width: `${uploadProgress || 0}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="editor-audio-progress-label">
|
||||
Uploading... {Math.round(uploadProgress || 0)}%
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{uploadError && (
|
||||
<div className="editor-audio-error">❌ {uploadError}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
195
src/blog/nodes/AudioNode.tsx
Normal file
195
src/blog/nodes/AudioNode.tsx
Normal file
@@ -0,0 +1,195 @@
|
||||
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 AudioComponent = lazy(() => import('./AudioComponent'));
|
||||
|
||||
export interface AudioPayload {
|
||||
src: string;
|
||||
fileKey?: string;
|
||||
uploadProgress?: number;
|
||||
uploadError?: string;
|
||||
key?: NodeKey;
|
||||
}
|
||||
|
||||
function $convertAudioElement(domNode: Node): DOMConversionOutput | null {
|
||||
const element = domNode as HTMLElement;
|
||||
if (!element.hasAttribute('data-lexical-audio')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const src = element.getAttribute('src') || '';
|
||||
const fileKey = element.getAttribute('data-file-key') || undefined;
|
||||
|
||||
return {
|
||||
node: $createAudioNode({
|
||||
src,
|
||||
fileKey,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export type SerializedAudioNode = Spread<
|
||||
{
|
||||
src: string;
|
||||
fileKey?: string;
|
||||
},
|
||||
SerializedLexicalNode
|
||||
>;
|
||||
|
||||
export class AudioNode extends DecoratorNode<JSX.Element> {
|
||||
__src: string;
|
||||
__fileKey?: string;
|
||||
__uploadProgress?: number;
|
||||
__uploadError?: string;
|
||||
|
||||
static getType(): string {
|
||||
return 'audio';
|
||||
}
|
||||
|
||||
static clone(node: AudioNode): AudioNode {
|
||||
return new AudioNode(
|
||||
node.__src,
|
||||
node.__fileKey,
|
||||
node.__uploadProgress,
|
||||
node.__uploadError,
|
||||
node.__key,
|
||||
);
|
||||
}
|
||||
|
||||
static importJSON(serializedNode: SerializedAudioNode): AudioNode {
|
||||
const { src, fileKey } = serializedNode;
|
||||
return $createAudioNode({
|
||||
src: fileKey ? '' : src,
|
||||
fileKey,
|
||||
});
|
||||
}
|
||||
|
||||
exportJSON(): SerializedAudioNode {
|
||||
return {
|
||||
src: this.__fileKey ? '' : this.__src,
|
||||
fileKey: this.__fileKey,
|
||||
type: 'audio',
|
||||
version: 1,
|
||||
};
|
||||
}
|
||||
|
||||
exportDOM(): DOMExportOutput {
|
||||
const element = document.createElement('audio');
|
||||
element.setAttribute('data-lexical-audio', 'true');
|
||||
element.setAttribute('controls', '');
|
||||
element.setAttribute('src', this.__src);
|
||||
if (this.__fileKey) {
|
||||
element.setAttribute('data-file-key', this.__fileKey);
|
||||
}
|
||||
return { element };
|
||||
}
|
||||
|
||||
constructor(
|
||||
src: string,
|
||||
fileKey?: string,
|
||||
uploadProgress?: number,
|
||||
uploadError?: string,
|
||||
key?: NodeKey,
|
||||
) {
|
||||
super(key);
|
||||
this.__src = src;
|
||||
this.__fileKey = fileKey;
|
||||
this.__uploadProgress = uploadProgress;
|
||||
this.__uploadError = uploadError;
|
||||
}
|
||||
|
||||
static importDOM(): DOMConversionMap | null {
|
||||
return {
|
||||
audio: () => ({
|
||||
conversion: $convertAudioElement,
|
||||
priority: 1,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
createDOM(config: EditorConfig): HTMLElement {
|
||||
const div = document.createElement('div');
|
||||
const className = config.theme?.audio;
|
||||
if (className) {
|
||||
div.className = className;
|
||||
}
|
||||
return div;
|
||||
}
|
||||
|
||||
updateDOM(): false {
|
||||
return false;
|
||||
}
|
||||
|
||||
getSrc(): string {
|
||||
return this.__src;
|
||||
}
|
||||
|
||||
getFileKey(): string | undefined {
|
||||
return this.__fileKey;
|
||||
}
|
||||
|
||||
getUploadProgress(): number | undefined {
|
||||
return this.__uploadProgress;
|
||||
}
|
||||
|
||||
getUploadError(): string | undefined {
|
||||
return this.__uploadError;
|
||||
}
|
||||
|
||||
setSrc(src: string): void {
|
||||
const writable = this.getWritable();
|
||||
writable.__src = src;
|
||||
}
|
||||
|
||||
setFileKey(fileKey: string): void {
|
||||
const writable = this.getWritable();
|
||||
writable.__fileKey = fileKey;
|
||||
}
|
||||
|
||||
setUploadProgress(progress: number): void {
|
||||
const writable = this.getWritable();
|
||||
writable.__uploadProgress = progress;
|
||||
}
|
||||
|
||||
setUploadError(error: string | undefined): void {
|
||||
const writable = this.getWritable();
|
||||
writable.__uploadError = error;
|
||||
}
|
||||
|
||||
decorate(): JSX.Element {
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<AudioComponent
|
||||
nodeKey={this.getKey()}
|
||||
src={this.__src}
|
||||
fileKey={this.__fileKey}
|
||||
uploadProgress={this.__uploadProgress}
|
||||
uploadError={this.__uploadError}
|
||||
/>
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function $createAudioNode(payload: AudioPayload): AudioNode {
|
||||
const { src, fileKey, uploadProgress, uploadError, key } = payload;
|
||||
return $applyNodeReplacement(
|
||||
new AudioNode(src, fileKey, uploadProgress, uploadError, key),
|
||||
);
|
||||
}
|
||||
|
||||
export function $isAudioNode(node: LexicalNode | null | undefined): node is AudioNode {
|
||||
return node instanceof AudioNode;
|
||||
}
|
||||
48
src/blog/plugins/AudiosPlugin.tsx
Normal file
48
src/blog/plugins/AudiosPlugin.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
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 { $createAudioNode, AudioNode, type AudioPayload } from '../nodes/AudioNode';
|
||||
|
||||
export type InsertAudioPayload = Readonly<AudioPayload>;
|
||||
|
||||
export const INSERT_AUDIO_COMMAND: LexicalCommand<InsertAudioPayload> =
|
||||
createCommand('INSERT_AUDIO_COMMAND');
|
||||
|
||||
export default function AudiosPlugin(): null {
|
||||
const [editor] = useLexicalComposerContext();
|
||||
|
||||
useEffect(() => {
|
||||
if (!editor.hasNodes([AudioNode])) {
|
||||
throw new Error('AudiosPlugin: AudioNode not registered on editor');
|
||||
}
|
||||
|
||||
return mergeRegister(
|
||||
editor.registerCommand<InsertAudioPayload>(
|
||||
INSERT_AUDIO_COMMAND,
|
||||
(payload) => {
|
||||
const audioNode = $createAudioNode(payload);
|
||||
$insertNodes([audioNode]);
|
||||
|
||||
if ($isRootOrShadowRoot(audioNode.getParentOrThrow())) {
|
||||
$wrapNodeInElement(audioNode, $createParagraphNode).selectEnd();
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
COMMAND_PRIORITY_EDITOR,
|
||||
),
|
||||
);
|
||||
}, [editor]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -7,10 +7,12 @@ import { useEffect } from 'react';
|
||||
import { INSERT_IMAGE_COMMAND } from './ImagesPlugin';
|
||||
import { INSERT_ARCHIVE_COMMAND } from './ArchivesPlugin';
|
||||
import { INSERT_VIDEO_COMMAND } from './VideosPlugin';
|
||||
import { INSERT_AUDIO_COMMAND } from './AudiosPlugin';
|
||||
import { uploadImage, uploadBlogFile } from '../api';
|
||||
import { ImageNode } from '../nodes/ImageNode';
|
||||
import { ArchiveNode } from '../nodes/ArchiveNode';
|
||||
import { VideoNode } from '../nodes/VideoNode';
|
||||
import { AudioNode } from '../nodes/AudioNode';
|
||||
|
||||
// Toast notification helper (global)
|
||||
let showToastFn: ((message: string, type: 'success' | 'error') => void) | null = null;
|
||||
@@ -76,6 +78,18 @@ const ARCHIVE_EXTENSIONS = new Set([
|
||||
'xz',
|
||||
]);
|
||||
|
||||
const ACCEPTABLE_AUDIO_TYPES = [
|
||||
'audio/mpeg',
|
||||
'audio/mp3',
|
||||
'audio/mp4',
|
||||
'audio/aac',
|
||||
'audio/ogg',
|
||||
'audio/wav',
|
||||
'audio/x-wav',
|
||||
'audio/webm',
|
||||
'audio/flac',
|
||||
];
|
||||
|
||||
function isArchiveFile(file: File): boolean {
|
||||
const mimeMatches = isMimeType(file, ACCEPTABLE_ARCHIVE_TYPES);
|
||||
if (mimeMatches) {
|
||||
@@ -103,6 +117,7 @@ export default function DragDropPastePlugin(): null {
|
||||
const imageFiles: File[] = [];
|
||||
const archiveFiles: File[] = [];
|
||||
const videoFiles: File[] = [];
|
||||
const audioFiles: File[] = [];
|
||||
|
||||
for (const file of fileArray) {
|
||||
if (isMimeType(file, ACCEPTABLE_IMAGE_TYPES)) {
|
||||
@@ -111,6 +126,8 @@ export default function DragDropPastePlugin(): null {
|
||||
archiveFiles.push(file);
|
||||
} else if (isMimeType(file, ACCEPTABLE_VIDEO_TYPES)) {
|
||||
videoFiles.push(file);
|
||||
} else if (isMimeType(file, ACCEPTABLE_AUDIO_TYPES)) {
|
||||
audioFiles.push(file);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -231,6 +248,61 @@ export default function DragDropPastePlugin(): null {
|
||||
}
|
||||
}
|
||||
|
||||
for (const file of audioFiles) {
|
||||
const previewUrl = URL.createObjectURL(file);
|
||||
|
||||
editor.dispatchCommand(INSERT_AUDIO_COMMAND, {
|
||||
src: previewUrl,
|
||||
uploadProgress: 0,
|
||||
uploadError: undefined,
|
||||
});
|
||||
|
||||
try {
|
||||
const { fileKey, url } = await uploadBlogFile(file, (progress: number) => {
|
||||
editor.update(() => {
|
||||
const nodes = $nodesOfType(AudioNode);
|
||||
nodes.forEach((node: AudioNode) => {
|
||||
if (node.getSrc() === previewUrl) {
|
||||
node.setUploadProgress(progress);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
URL.revokeObjectURL(previewUrl);
|
||||
|
||||
editor.update(() => {
|
||||
const nodes = $nodesOfType(AudioNode);
|
||||
nodes.forEach((node: AudioNode) => {
|
||||
if (node.getSrc() === previewUrl || node.getFileKey() === fileKey) {
|
||||
node.setSrc(url);
|
||||
node.setFileKey(fileKey);
|
||||
node.setUploadProgress(100);
|
||||
node.setUploadError(undefined);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
showToast('Audio uploaded successfully!', 'success');
|
||||
} catch (error) {
|
||||
URL.revokeObjectURL(previewUrl);
|
||||
|
||||
editor.update(() => {
|
||||
const nodes = $nodesOfType(AudioNode);
|
||||
nodes.forEach((node: AudioNode) => {
|
||||
if (node.getSrc() === previewUrl) {
|
||||
node.setUploadError(
|
||||
error instanceof Error ? error.message : 'Upload failed',
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const errorMessage = error instanceof Error ? error.message : 'Upload failed';
|
||||
showToast(`Audio upload failed: ${errorMessage}`, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
for (const file of archiveFiles) {
|
||||
const uploadId = createUploadId();
|
||||
|
||||
|
||||
@@ -461,6 +461,91 @@
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
/* Audio */
|
||||
.editor-audio {
|
||||
display: block;
|
||||
margin: 16px 0;
|
||||
}
|
||||
|
||||
.editor-audio-card {
|
||||
position: relative;
|
||||
border: 1px solid var(--border-color, #d0d0d0);
|
||||
border-radius: 12px;
|
||||
background: var(--bg-card, #fafafa);
|
||||
overflow: hidden;
|
||||
padding: 12px 16px;
|
||||
transition: border-color 0.2s ease, box-shadow 0.2s ease;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.editor-audio-card:focus {
|
||||
border-color: var(--accent-color, #4caf50);
|
||||
box-shadow: 0 0 0 2px rgba(76, 175, 80, 0.15);
|
||||
}
|
||||
|
||||
.editor-audio-wrapper {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.editor-audio-player {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.editor-audio-placeholder {
|
||||
padding: 12px;
|
||||
text-align: center;
|
||||
color: var(--text-secondary, #777);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.editor-audio-overlay {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
padding: 10px 12px;
|
||||
background: linear-gradient(180deg, rgba(0, 0, 0, 0) 0%, rgba(0, 0, 0, 0.75) 100%);
|
||||
color: #fff;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.editor-audio-progress {
|
||||
height: 4px;
|
||||
background: rgba(255, 255, 255, 0.35);
|
||||
border-radius: 999px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.editor-audio-progress-value {
|
||||
height: 100%;
|
||||
background: var(--accent-color, #4caf50);
|
||||
width: 0;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.editor-audio-progress-label {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.editor-audio-error {
|
||||
position: absolute;
|
||||
bottom: 12px;
|
||||
left: 12px;
|
||||
right: 12px;
|
||||
padding: 10px;
|
||||
border-radius: 8px;
|
||||
background: rgba(244, 67, 54, 0.92);
|
||||
color: #fff;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
/* Image Resizer */
|
||||
.image-resizer {
|
||||
display: block;
|
||||
|
||||
@@ -42,6 +42,7 @@ const theme: EditorThemeClasses = {
|
||||
image: 'editor-image',
|
||||
archive: 'editor-archive',
|
||||
video: 'editor-video',
|
||||
audio: 'editor-audio',
|
||||
hashtag: 'editor-hashtag',
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user