Skip to content

Commit

Permalink
Tobi develop (#1316)
Browse files Browse the repository at this point in the history
* 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
tabiodun authored Dec 25, 2024
1 parent bf96c7d commit 156eb8d
Show file tree
Hide file tree
Showing 8 changed files with 359 additions and 174 deletions.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,7 @@
"puppeteer": "^23.0.0",
"qrcode": "^1.5.4",
"qrcode-svg": "^1.1.0",
"quill": "^2.0.0",
"quill": "^2.0.3",
"quill-image-resize-vue": "^1.0.4",
"raw-loader": "^4.0.2",
"rrule": "^2.8.1",
Expand Down
90 changes: 45 additions & 45 deletions pnpm-lock.yaml

Large diffs are not rendered by default.

344 changes: 240 additions & 104 deletions src/components/Editor.vue
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>
Loading

0 comments on commit 156eb8d

Please sign in to comment.