Files
donetick/src/views/components/RichTextEditor.jsx

293 lines
9.4 KiB
JavaScript

import imageCompression from 'browser-image-compression'
import Quill from 'quill'
import 'quill/dist/quill.snow.css'
import QuillMarkdown from 'quilljs-markdown'
// Extend the built-in Image blot to preserve dt-data-path
const ImageBlot = Quill.import('formats/image')
class DtImageBlot extends ImageBlot {
static create(value) {
const node = super.create(typeof value === 'string' ? value : value.src)
if (value?.path) node.setAttribute('dt-data-path', value.path)
return node
}
static value(node) {
return { src: node.getAttribute('src'), path: node.getAttribute('dt-data-path') }
}
static formats(node) {
return { 'dt-data-path': node.getAttribute('dt-data-path') }
}
format(name, value) {
if (name === 'dt-data-path') {
if (value) this.domNode.setAttribute('dt-data-path', value)
else this.domNode.removeAttribute('dt-data-path')
} else {
super.format(name, value)
}
}
}
DtImageBlot.blotName = 'image'
DtImageBlot.tagName = 'img'
Quill.register(DtImageBlot, true)
import {
forwardRef,
useCallback,
useEffect,
useImperativeHandle,
useRef,
} from 'react'
import { useUserProfile } from '../../queries/UserQueries'
import { useNotification } from '../../service/NotificationProvider'
import { apiClient } from '../../utils/ApiClient'
import {
isPlusAccount,
refreshSignedUrlsInHtml,
resolvePhotoURL,
} from '../../utils/Helpers'
import './RichTextEditor.css'
const RichTextEditor = forwardRef(
(
{
value = '',
onChange,
isEditable = true,
placeholder = 'Enter description...',
variant = 'outlined',
entityId,
entityType,
},
ref,
) => {
const { showError } = useNotification()
const { data: userProfile } = useUserProfile()
const quillRef = useRef(null)
const editorRef = useRef(null)
const initialContentSet = useRef(false)
// Expose focus method to parent components
useImperativeHandle(
ref,
() => ({
focus: () => {
if (editorRef.current) {
editorRef.current.focus()
}
},
blur: () => {
if (editorRef.current) {
editorRef.current.blur()
}
},
}),
[],
)
// Image upload handler - wrapped in useCallback to avoid recreating on every render
const handleImageUpload = useCallback(() => {
// Check if user has plus account
if (!isPlusAccount(userProfile)) {
showError({
title: 'Plus Feature',
message:
'Image uploads are not available in the Basic plan. Upgrade to Plus to add images to your content.',
})
return
}
const input = document.createElement('input')
input.setAttribute('type', 'file')
input.setAttribute('accept', 'image/*')
input.click()
input.onchange = async () => {
const file = input.files[0]
if (!file) return
try {
// Define compression options based on entity type ( this need a revist later)
const compressionOptions = {
maxSizeMB: entityType === 'profile' ? 0.5 : 1, // Smaller size for profile images
maxWidthOrHeight: entityType === 'profile' ? 320 : 1200, // Smaller dimensions for profile images
useWebWorker: true,
fileType: 'image/jpeg',
}
// Compress the image
const compressedFile = await imageCompression(
file,
compressionOptions,
)
// Create new file with .jpg extension to ensure it's treated as JPEG
const compressedJpegFile = new File(
[compressedFile],
`${file.name.split('.')[0]}.jpg`,
{ type: 'image/jpeg' },
)
console.log(
`Original size: ${(file.size / 1024 / 1024).toFixed(2)} MB`,
)
console.log(
`Compressed size: ${(compressedJpegFile.size / 1024 / 1024).toFixed(2)} MB`,
)
// Upload compressed image to backend
const formData = new FormData()
formData.append('file', compressedJpegFile)
formData.append('entityId', entityId)
formData.append('entityType', entityType)
const response = await apiClient.upload('/assets/chore', formData)
if (response.status === 507) {
showError({
title: 'Storage Quota Exceeded',
message: 'You have exceeded your quota for uploading files.',
})
return
} else if (response.status === 413) {
showError({
title: 'File Too Large',
message: 'The file you are trying to upload is too large.',
})
return
} else if (response.status === 403 && !isPlusAccount()) {
showError({
title: 'Upgrade Required',
message:
'Image uploads are only available for Plus accounts. Please ',
})
return
} else if (response.status === 403) {
showError({
title: 'Permission Denied',
message: 'You do not have permission to upload files.',
})
return
} else if (!response.ok) {
showError({
title: 'Upload Failed',
message: 'Failed to upload image.',
})
return
}
const data = await response.json()
// Prefer the backend-proxied path (data.sign) over the direct cloud
// signed URL (data.url) — the proxy re-signs on every request so the
// embedded src never expires.
const path = data.path
const url = resolvePhotoURL(data.sign || data.url)
// Insert image into Quill with dt-data-path tracked by the custom blot
const quill = editorRef.current
const range = quill.getSelection()
const insertIndex = range ? range.index : 0
quill.insertEmbed(insertIndex, 'image', { src: url, path })
} catch (error) {
console.error('Error during image processing or upload:', error)
showError({
title: 'Upload Failed',
message: 'An error occurred while processing the image.',
})
}
}
}, [entityId, entityType, showError, userProfile]) // Dependencies for useCallback
useEffect(() => {
if (!quillRef.current) return
if (!editorRef.current && isEditable) {
editorRef.current = new Quill(quillRef.current, {
theme: variant === 'bubble' ? 'bubble' : 'snow',
modules: {
toolbar: {
container: [
[{ header: [1, 2, 3, 4, false] }],
['bold', 'italic', 'underline', 'strike'],
['blockquote', 'code-block'],
[{ list: 'ordered' }, { list: 'bullet' }],
['link', 'image'],
['clean'],
],
handlers: {
image: handleImageUpload,
},
},
},
placeholder: placeholder,
})
new QuillMarkdown(editorRef.current, {})
editorRef.current.root.innerHTML = value
editorRef.current.on('text-change', () => {
if (onChange) {
onChange(editorRef.current.root.innerHTML)
}
})
}
// If switching to read-only mode, disable Quill instance
if (editorRef.current && !isEditable) {
// editorRef.current.disable()
editorRef.current.readOnly = true
// If switching back to editable, enable Quill
if (editorRef.current && isEditable) {
// editorRef.current.enable()
editorRef.current.readOnly = false
}
}
}, [onChange, value, isEditable, variant, handleImageUpload, userProfile]) // Added handleImageUpload and userProfile to dependency array
useEffect(() => {
if (editorRef.current && isEditable) {
if (editorRef.current.root.innerHTML !== value) {
const html = !initialContentSet.current
? refreshSignedUrlsInHtml(value || '')
: value || ''
initialContentSet.current = true
editorRef.current.root.innerHTML = html
}
}
}, [value, isEditable])
if (!isEditable) {
// Display-only mode: render HTML
return (
<div
className='editor-view-mode'
style={{
minHeight: 120,
overflow: 'scroll',
// border:
// '1px solid var(--joy-palette-neutral-outlinedBorder, #DDE7EE)',
borderRadius: 8,
padding: 16,
background: 'var(--joy-palette-background-surface, #fff)',
color: 'var(--joy-palette-text-primary, #1A2027)',
fontFamily:
'var(--joy-fontFamily-body, Inter, system-ui, Avenir, Helvetica, Arial, sans-serif)',
fontSize: 16,
boxShadow:
'var(--joy-shadow-xs, 0px 1px 2px 0px rgba(16, 24, 40, 0.05))',
}}
dangerouslySetInnerHTML={{ __html: refreshSignedUrlsInHtml(value) }}
/>
)
}
return (
<div className={`quill-root quill-variant-${variant}`}>
<div
ref={quillRef}
style={{
minHeight: 120,
background: 'var(--joy-palette-background-surface, #fff)',
}}
/>
</div>
)
},
)
RichTextEditor.displayName = 'RichTextEditor'
export default RichTextEditor