diff --git a/apps/web/components/common/rich-text-editor.css b/apps/web/components/common/rich-text-editor.css index 2eba86e9..faa3bb16 100644 --- a/apps/web/components/common/rich-text-editor.css +++ b/apps/web/components/common/rich-text-editor.css @@ -241,14 +241,14 @@ margin: 0.5rem 0; } -/* Uploading image placeholder (blob: URLs = in-flight uploads) */ -.rich-text-editor img[src^="blob:"] { +/* Uploading image placeholder — data-uploading attribute managed by ProseMirror schema */ +.rich-text-editor img[data-uploading] { opacity: 0.5; border-radius: var(--radius); - animation: rte-pulse 1.5s ease-in-out infinite; + animation: rte-upload-pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite; } -@keyframes rte-pulse { +@keyframes rte-upload-pulse { 0%, 100% { opacity: 0.5; } 50% { opacity: 0.3; } } diff --git a/apps/web/components/common/rich-text-editor.tsx b/apps/web/components/common/rich-text-editor.tsx index a5d4f8d8..a7a92247 100644 --- a/apps/web/components/common/rich-text-editor.tsx +++ b/apps/web/components/common/rich-text-editor.tsx @@ -150,23 +150,13 @@ function createFileUploadExtension( const isImage = file.type.startsWith("image/"); if (isImage) { - // Instant preview via blob URL, then replace with real URL after upload + // Instant preview via blob URL with uploading flag for CSS styling const blobUrl = URL.createObjectURL(file); + const imgAttrs = { src: blobUrl, alt: file.name, uploading: true }; if (pos !== undefined) { - editor - .chain() - .focus() - .insertContentAt(pos, { - type: "image", - attrs: { src: blobUrl, alt: file.name }, - }) - .run(); + editor.chain().focus().insertContentAt(pos, { type: "image", attrs: imgAttrs }).run(); } else { - editor - .chain() - .focus() - .setImage({ src: blobUrl, alt: file.name }) - .run(); + editor.chain().focus().setImage(imgAttrs).run(); } try { @@ -174,14 +164,12 @@ function createFileUploadExtension( if (result) { const { tr } = editor.state; editor.state.doc.descendants((node, nodePos) => { - if ( - node.type.name === "image" && - node.attrs.src === blobUrl - ) { + if (node.type.name === "image" && node.attrs.src === blobUrl) { tr.setNodeMarkup(nodePos, undefined, { ...node.attrs, src: result.link, alt: result.filename, + uploading: false, }); } }); @@ -330,7 +318,18 @@ const RichTextEditor = forwardRef( LinkExtension, Typography, MentionExtension, - Image.configure({ + Image.extend({ + addAttributes() { + return { + ...this.parent?.(), + uploading: { + default: false, + renderHTML: (attrs) => (attrs.uploading ? { "data-uploading": "" } : {}), + parseHTML: (el) => el.hasAttribute("data-uploading"), + }, + }; + }, + }).configure({ inline: false, allowBase64: false, HTMLAttributes: { style: "max-width: 100%; height: auto;" },