-
Notifications
You must be signed in to change notification settings - Fork 2
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
* feat: enhance conditional rendering and enable case images in development mode - Update `WorksiteForm` to conditionally render photo sections only if `worksite.id` exists - Modify `Work.vue` to enable case images loading in `development_mode` alongside `beta_feature.enable_feed` * feat: integrate custom `Editor` component with enhanced capabilities - Replaced `BaseInput` with a custom `Editor` in `Chat` and `ChatMessage` components, enabling rich-text editing - Added support for inline image handling and server uploads using `quill` configurations - Implemented image resizing with configurable max dimensions and file size validation - Enhanced image handling in messages, displaying full-sized previews via `v-viewer` - Upgraded `quill` dependency to `2.0.3` for compatibility with added editor features * test: stub `Editor` component in Chat tests - Add `Editor` component stub with `<textarea />` template for unit tests in Chat component - Ensure consistent test isolation by mocking the `Editor` dependency
- Loading branch information
Showing
8 changed files
with
359 additions
and
174 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Large diffs are not rendered by default.
Oops, something went wrong.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,133 +1,269 @@ | ||
<template> | ||
<div> | ||
<div ref="editor"></div> | ||
<input | ||
id="getFile" | ||
type="file" | ||
class="hidden" | ||
@change="handleFileUpload($event)" | ||
/> | ||
<div class="quill-editor-wrapper"> | ||
<div ref="editorRef"></div> | ||
</div> | ||
</template> | ||
|
||
<script lang="ts"> | ||
// eslint-disable-next-line import/default | ||
<script setup lang="ts"> | ||
import { ref, onMounted, watch } from 'vue'; | ||
import Quill from 'quill'; | ||
import 'quill/dist/quill.core.css'; | ||
import 'quill/dist/quill.snow.css'; | ||
import 'quill/dist/quill.bubble.css'; | ||
import { ref, defineComponent, onMounted } from 'vue'; | ||
import { useI18n } from 'vue-i18n'; | ||
import { useToast } from 'vue-toastification'; | ||
import axios from 'axios'; | ||
import { getErrorMessage } from '../utils/errors'; | ||
// eslint-disable-next-line import/default | ||
import ImageResize from 'quill-image-resize-vue'; | ||
// Register the image resize module | ||
Quill.register('modules/imageResize', ImageResize); | ||
export default defineComponent({ | ||
name: 'Editor', | ||
props: { | ||
modelValue: { | ||
type: String, | ||
default: '', | ||
}, | ||
}, | ||
// Define component props | ||
const props = defineProps<{ | ||
modelValue?: string; | ||
quillOptions?: any; | ||
imageHandlerType?: 'server' | 'inline'; | ||
maxFileSize?: number; | ||
maxWidth?: number; // Updated prop for maximum width | ||
maxHeight?: number; // Updated prop for maximum height | ||
}>(); | ||
setup(props, context) { | ||
const { t } = useI18n(); | ||
const $toasted = useToast(); | ||
const emit = defineEmits(['update:modelValue']); | ||
const uploading = ref(false); | ||
const quillEditor = ref<Quill | null>(null); | ||
const editor = ref(null); | ||
// Initialize variables with default values | ||
const editorContent = props.modelValue ?? ''; | ||
const quillOpts = props.quillOptions; | ||
const imageHandlerType = props.imageHandlerType ?? 'server'; | ||
const maxFileSize = props.maxFileSize ?? 1_000_000; // 1MB | ||
const maxWidth = props.maxWidth ?? 200; // Default max width | ||
const maxHeight = props.maxHeight ?? 150; // Default max height | ||
async function handleFileUpload(e) { | ||
const fileList = e.target.files; | ||
if (fileList.length === 0) { | ||
uploading.value = false; | ||
return; | ||
} | ||
// Reference to the editor DOM element | ||
const editorRef = ref<HTMLElement | null>(null); | ||
const formData = new FormData(); | ||
formData.append('upload', fileList[0]); | ||
formData.append('type_t', 'fileTypes.other_file'); | ||
uploading.value = true; | ||
try { | ||
const result = await axios.post( | ||
`${import.meta.env.VITE_APP_API_BASE_URL}/files`, | ||
formData, | ||
{ | ||
headers: { | ||
'Content-Type': 'multipart/form-data', | ||
Accept: 'application/json', | ||
}, | ||
}, | ||
); | ||
await $toasted.success(t('info.upload_file_successful')); | ||
document.querySelectorAll('.ql-editor')[0].innerHTML += | ||
`<img src="${result.data.blog_url}" alt="${result.data.filename}"/>`; | ||
} catch (error) { | ||
await $toasted.error(getErrorMessage(error)); | ||
} finally { | ||
uploading.value = false; | ||
} | ||
// Quill instance | ||
let quill: Quill | null = null; | ||
/** | ||
* Prompts user to select an image file. | ||
* @returns The selected File or null. | ||
*/ | ||
async function selectFile(): Promise<File | null> { | ||
return new Promise((resolve) => { | ||
const input = document.createElement('input'); | ||
input.type = 'file'; | ||
input.accept = 'image/*'; | ||
input.addEventListener('change', () => { | ||
resolve(input.files?.[0] || null); | ||
}); | ||
input.click(); | ||
}); | ||
} | ||
/** | ||
* Uploads the selected image to the server and inserts its URL. | ||
*/ | ||
async function customImageUploadHandler() { | ||
const file = await selectFile(); | ||
if (!file) return; | ||
try { | ||
const formData = new FormData(); | ||
formData.append('upload', file); | ||
formData.append('type_t', 'fileTypes.other_file'); | ||
const response = await axios.post(`/files`, formData, { | ||
headers: { | ||
'Content-Type': 'multipart/form-data', | ||
Accept: 'application/json', | ||
}, | ||
}); | ||
const range = quill?.getSelection(true); | ||
if (range && quill) { | ||
quill.insertEmbed(range.index, 'image', response.data.blog_url, 'user'); | ||
quill.setSelection(range.index + 1, 0); | ||
} | ||
} catch (error) { | ||
console.error('Image upload failed:', error); | ||
} | ||
} | ||
/** | ||
* Resizes an image while maintaining its aspect ratio. | ||
* @param file The image file to resize. | ||
* @param maxWidth Maximum width. | ||
* @param maxHeight Maximum height. | ||
* @returns Resized image as a base64 string. | ||
*/ | ||
async function resizeImage( | ||
file: File, | ||
maxWidth: number, | ||
maxHeight: number, | ||
): Promise<string> { | ||
return new Promise((resolve, reject) => { | ||
const reader = new FileReader(); | ||
function update() { | ||
if (quillEditor.value) { | ||
context.emit( | ||
'update:modelValue', | ||
quillEditor.value.getText() ? quillEditor.value.root.innerHTML : '', | ||
); | ||
reader.addEventListener('load', () => { | ||
if (typeof reader.result !== 'string') { | ||
return reject(new Error('Invalid file data.')); | ||
} | ||
} | ||
onMounted(() => { | ||
quillEditor.value = new Quill(editor.value, { | ||
modules: { | ||
imageResize: {}, | ||
toolbar: { | ||
container: [ | ||
['bold', 'italic', 'underline', 'strike'], | ||
['blockquote', 'code-block'], | ||
[{ header: 1 }, { header: 2 }], | ||
[{ list: 'ordered' }, { list: 'bullet' }], | ||
[{ script: 'sub' }, { script: 'super' }], | ||
[{ indent: '-1' }, { indent: '+1' }], | ||
[{ direction: 'rtl' }], | ||
[{ size: ['small', !1, 'large', 'huge'] }], | ||
[{ header: [1, 2, 3, 4, 5, 6, !1] }], | ||
[{ color: [] }, { background: [] }], | ||
[{ font: [] }], | ||
[{ align: [] }], | ||
['clean'], | ||
['link', 'image', 'video'], | ||
], | ||
}, | ||
}, | ||
theme: 'snow', | ||
formats: ['bold', 'underline', 'header', 'italic'], | ||
placeholder: 'Type something in here!', | ||
const img = new Image(); | ||
img.addEventListener('load', () => { | ||
let { width, height } = img; | ||
// Calculate aspect ratio | ||
const aspectRatio = width / height; | ||
// Adjust dimensions to maintain aspect ratio | ||
if (width > maxWidth) { | ||
width = maxWidth; | ||
height = width / aspectRatio; | ||
} | ||
if (height > maxHeight) { | ||
height = maxHeight; | ||
width = height * aspectRatio; | ||
} | ||
const canvas = document.createElement('canvas'); | ||
canvas.width = width; | ||
canvas.height = height; | ||
const ctx = canvas.getContext('2d'); | ||
if (!ctx) return reject(new Error('Canvas context unavailable.')); | ||
ctx.drawImage(img, 0, 0, width, height); | ||
resolve(canvas.toDataURL(file.type)); | ||
}); | ||
quillEditor.value.root.innerHTML = props.modelValue; | ||
quillEditor.value.getModule('toolbar').addHandler('image', () => { | ||
document?.getElementById('getFile')?.click(); | ||
img.addEventListener('error', reject); | ||
img.src = reader.result; | ||
}); | ||
reader.addEventListener('error', reject); | ||
reader.readAsDataURL(file); | ||
}); | ||
} | ||
/** | ||
* Inserts an inline (base64) image with optional resizing and size validation. | ||
*/ | ||
async function customInlineImageHandler() { | ||
const file = await selectFile(); | ||
if (!file) return; | ||
if (file.size > maxFileSize) { | ||
alert(`Image exceeds the maximum size of ${maxFileSize} bytes.`); | ||
return; | ||
} | ||
try { | ||
const resizedBase64 = await resizeImage(file, maxWidth, maxHeight); | ||
const originalBase64 = await new Promise<string>((resolve, reject) => { | ||
const reader = new FileReader(); | ||
reader.addEventListener('load', () => { | ||
if (typeof reader.result === 'string') { | ||
resolve(reader.result); | ||
} else { | ||
reject(new Error('Failed to read file.')); | ||
} | ||
}); | ||
quillEditor.value.on('text-change', () => update()); | ||
reader.addEventListener('error', reject); | ||
reader.readAsDataURL(file); | ||
}); | ||
return { | ||
uploading, | ||
quillEditor, | ||
editor, | ||
handleFileUpload, | ||
update, | ||
}; | ||
}, | ||
const range = quill?.getSelection(true); | ||
if (range && quill) { | ||
quill.insertEmbed(range.index, 'image', resizedBase64, 'user'); | ||
// Store original image data | ||
setTimeout(() => { | ||
const images = document.querySelectorAll('.ql-editor img'); | ||
for (const img of images) { | ||
if (img.src === resizedBase64) { | ||
img.dataset.originalSrc = originalBase64; | ||
} | ||
} | ||
emit('update:modelValue', quill.root.innerHTML); | ||
}, 0); | ||
quill.setSelection(range.index + 1, 0); | ||
} | ||
} catch (error) { | ||
console.error('Inline image insertion failed:', error); | ||
} | ||
} | ||
/** | ||
* Chooses the image handler based on the prop. | ||
*/ | ||
function customImageHandler() { | ||
if (imageHandlerType === 'server') { | ||
customImageUploadHandler(); | ||
} else { | ||
customInlineImageHandler(); | ||
} | ||
} | ||
// Initialize Quill on component mount | ||
onMounted(() => { | ||
const options: Quill.QuillOptionsStatic = { | ||
theme: 'snow', | ||
modules: { | ||
imageResize: { displaySize: true }, | ||
toolbar: { | ||
container: quillOpts || [ | ||
['bold', 'italic', 'underline', 'strike'], | ||
['blockquote', 'code-block'], | ||
['link', 'image', 'video', 'formula'], | ||
[{ header: 1 }, { header: 2 }], | ||
[{ list: 'ordered' }, { list: 'bullet' }, { list: 'check' }], | ||
[{ script: 'sub' }, { script: 'super' }], | ||
[{ indent: '-1' }, { indent: '+1' }], | ||
[{ direction: 'rtl' }], | ||
[{ size: ['small', false, 'large', 'huge'] }], | ||
[{ header: [1, 2, 3, 4, 5, 6, false] }], | ||
[{ color: [] }, { background: [] }], | ||
[{ font: [] }], | ||
[{ align: [] }], | ||
['clean'], | ||
], | ||
handlers: { image: customImageHandler }, | ||
}, | ||
}, | ||
}; | ||
if (editorRef.value) { | ||
quill = new Quill(editorRef.value, options); | ||
// Set initial content | ||
if (editorContent) { | ||
quill.root.innerHTML = editorContent; | ||
} | ||
// Emit content changes | ||
quill.on('text-change', () => { | ||
emit('update:modelValue', quill?.root.innerHTML || ''); | ||
}); | ||
} | ||
}); | ||
// Update editor content when prop changes | ||
watch( | ||
() => props.modelValue, | ||
(newValue) => { | ||
if (quill && newValue !== quill.root.innerHTML) { | ||
quill.root.innerHTML = newValue || ''; | ||
} | ||
}, | ||
); | ||
</script> | ||
|
||
<style scoped></style> | ||
<style scoped> | ||
.quill-editor-wrapper { | ||
/* Add your editor styles here */ | ||
} | ||
</style> |
Oops, something went wrong.