diff --git a/public/locales/en/upload-image-modal.json b/public/locales/en/upload-image-modal.json index d64cc16..e0f9059 100644 --- a/public/locales/en/upload-image-modal.json +++ b/public/locales/en/upload-image-modal.json @@ -1,16 +1,37 @@ { - "title": "Upload Image", + "title": "Insert Image", + "imageSource": "Image source — upload a file or embed from URL", "dimensions": "Dimensions", "fileSize": "File Size", "altText": "Alt Text", - "altTextPlaceholder": "Enter the alt text for the image", + "altTextPlaceholder": "Enter a description of the image content", "cancel": "Cancel", "confirm": "Confirm", - "chooseFile": "Choose File", + "chooseFile": "Choose File to Upload", "imagePreview": "Image Preview", - "replaceImage": "Replace Image", - "required": "Required", + "removeImage": "Remove Image", + "optional": "(Optional)", "altTextRequired": "Alt text is required", - "modalDescription": "Upload an image to add it to your document. You can replace an existing image by uploading a new one.", - "fileSizeExceeds": "File size exceeds {{maxSize}} MB" + "dropTitle": "Drag & drop to upload", + "dropMeta": "Max size: {{maxSize}}MB Recommended: 1200 x 800 px Formats: jpg/jpeg, png", + "uploadFailed": "Image upload failed", + "uploadFailedHint": "Drop another file or embed from a URL", + "announceEmbedded": "Image embedded: {{url}}", + "announceUploadFailed": "Image upload failed. File size limit: {{maxSize}}MB. Supported formats: jpg/jpeg, png", + "announceEmbedFailed": "Image embed failed. Please check the URL", + "embedFromUrl": "Embed Image from URL", + "embedHint": "No file size limit. Recommended for large images.", + "embedUrlPlaceholder": "Enter image URL", + "embed": "Embed", + "embedLoadFailed": "Image failed to load", + "embedLoadFailedHint": "Check the URL or upload a file instead", + "caption": "Image Caption", + "captionPlaceholder": "Enter a caption to display below the image", + "externalLink": "External Link", + "noLink": "No Link", + "withLink": "With Link", + "targetUrl": "Link URL", + "targetUrlPlaceholder": "Enter the URL to open when clicking the image", + "targetUrlRequired": "Link URL is required", + "targetUrlInvalid": "Please enter a valid URL (e.g. https://example.com)" } diff --git a/public/locales/zh-TW/upload-image-modal.json b/public/locales/zh-TW/upload-image-modal.json index a561699..ec99bda 100644 --- a/public/locales/zh-TW/upload-image-modal.json +++ b/public/locales/zh-TW/upload-image-modal.json @@ -1,17 +1,37 @@ { - "title": "上傳圖片", - "dimensions": "尺寸", + "title": "插入圖片", + "imageSource": "圖片來源(選擇上傳檔案或嵌入連結)", + "dimensions": "檔案尺寸", "fileSize": "檔案大小", "altText": "替代文字", - "altTextPlaceholder": "輸入圖片的替代文字", + "altTextPlaceholder": "請輸入描述圖片內容的文字", "cancel": "取消", "confirm": "確認", - "chooseFile": "選擇檔案", + "chooseFile": "選擇檔案上傳", "imagePreview": "圖片預覽", "removeImage": "移除圖片", - "modalDescription": "在此上傳圖片並添加替代文字描述", - "replaceImage": "替換圖片", - "required": "必填", + "optional": "(選填)", "altTextRequired": "請輸入替代文字", - "fileSizeExceeds": "檔案大小超過 {{maxSize}} MB" + "dropTitle": "拖曳檔案上傳", + "dropMeta": "大小限制:{{maxSize}}MB 建議尺寸:1200 x 800 px 支援格式:jpg/jpeg, png", + "uploadFailed": "圖片上傳失敗", + "uploadFailedHint": "請重新拖曳上傳或改以連結嵌入", + "announceEmbedded": "已嵌入圖片:{{url}}", + "announceUploadFailed": "圖片上傳失敗。檔案大小限制:{{maxSize}}MB,支援格式:jpg/jpeg, png", + "announceEmbedFailed": "圖片嵌入失敗,請檢查嵌入連結", + "embedFromUrl": "從連結嵌入圖片", + "embedHint": "無檔案大小限制,如有大型圖檔建議使用連結嵌入", + "embedUrlPlaceholder": "請輸入圖片連結網址", + "embed": "嵌入", + "embedLoadFailed": "圖片嵌入失敗", + "embedLoadFailedHint": "請檢查連結或改以檔案上傳", + "caption": "圖片說明", + "captionPlaceholder": "請輸入顯示於圖片下方的上下文說明", + "externalLink": "圖片外連連結", + "noLink": "不含連結", + "withLink": "含連結", + "targetUrl": "外連網址", + "targetUrlPlaceholder": "請輸入點擊圖片後開啟的網址", + "targetUrlRequired": "外連網址為必填", + "targetUrlInvalid": "請輸入有效的網址(例如:https://example.com)" } diff --git a/src/components/edit-icons-tab.js b/src/components/edit-icons-tab.js index 8dcc9a8..3746ac1 100644 --- a/src/components/edit-icons-tab.js +++ b/src/components/edit-icons-tab.js @@ -52,15 +52,23 @@ const EditIconsTab = ({ insertLatex, addImageToExport }) => { ); const handleImageConfirm = useCallback( - (file, altText) => { - const fileID = generateUniqueId(); + ({ file, sourceUrl, altText, display, targetUrl }) => { + let source; + if (file) { + const fileID = generateUniqueId(); + source = fileID; + const fileType = file.type.split('/')[1]; + addImageToExport(fileID, fileType, file); + } else { + source = sourceUrl; + } + const displayPart = display ? `[[${display}]]` : ''; + const targetPart = targetUrl ? `((${targetUrl}))` : ''; insertLatex({ id: 'insert_image_file', - latex: `![${altText}](${fileID})`, + latex: `![${altText}]${displayPart}(${source})${targetPart}`, offset: -1, }); - const fileType = file.type.split('/')[1]; - addImageToExport(fileID, fileType, file); }, [insertLatex, addImageToExport] ); diff --git a/src/components/image-upload-modal.js b/src/components/image-upload-modal.js index f2b499e..779b869 100644 --- a/src/components/image-upload-modal.js +++ b/src/components/image-upload-modal.js @@ -1,226 +1,411 @@ -import React, { useState, useRef, useCallback } from 'react'; +import React, { useState, useRef, useEffect, useCallback } from 'react'; import PropTypes from 'prop-types'; -import { Dialog } from '@headlessui/react'; +import { IconAlertTriangle, IconPhoto, IconPlus, IconX } from '@tabler/icons-react'; import { useTranslation } from '@/lib/i18n'; -import { PlusCircleIcon, XMarkIcon } from '@heroicons/react/24/outline'; -import SecondaryButton from '@/components/core/button/secondary-button'; +import { ellipsizeMiddle, isValidUrl } from '@/lib/url'; +import BasicModal from '@/components/core/modal/basic-modal'; import PrimaryButton from '@/components/core/button/primary-button'; +import TextInput from '@/components/core/text-input'; +import RadioGroup from '@/components/core/radio-group'; const MAX_FILE_SIZE_MB = 10; -const ImageUploadModal = ({ isOpen, onClose, onConfirm }) => { - const [selectedFile, setSelectedFile] = useState(null); +const ImageSource = ({ onChange }) => { + const [uploadFile, setUploadFile] = useState(null); + const [uploadError, setUploadError] = useState(false); + const [embedUrl, setEmbedUrl] = useState(''); + const [embedError, setEmbedError] = useState(false); const [previewUrl, setPreviewUrl] = useState(null); - const [altText, setAltText] = useState(''); const [imageInfo, setImageInfo] = useState(null); - const [errorMessage, setErrorMessage] = useState(''); + const [statusMessage, setStatusMessage] = useState(''); + const fileInputRef = useRef(null); + const blobUrlRef = useRef(null); + const loadRequestIdRef = useRef(0); + const t = useTranslation('upload-image-modal'); - const resetImage = useCallback(() => { - setSelectedFile(null); + const revokeBlobUrl = useCallback(() => { + if (blobUrlRef.current) { + URL.revokeObjectURL(blobUrlRef.current); + blobUrlRef.current = null; + } + }, []); + + const resetSourceState = ({ resetFileInput = false } = {}) => { + setUploadFile(null); + setUploadError(false); + setEmbedError(false); setPreviewUrl(null); setImageInfo(null); - setErrorMessage(''); - }, []); + setStatusMessage(''); + revokeBlobUrl(); + if (resetFileInput && fileInputRef.current) fileInputRef.current.value = ''; + loadRequestIdRef.current++; + }; - const resetForm = useCallback(() => { - resetImage(); - setAltText(''); - }, [resetImage]); - - const handleFileSelect = useCallback( - (event) => { - const file = event.target.files[0]; - if (file) { - if (file.size > MAX_FILE_SIZE_MB * 1024 * 1024) { - setErrorMessage(t('fileSizeExceeds', { maxSize: MAX_FILE_SIZE_MB })); - return; - } - setSelectedFile(file); - setPreviewUrl(URL.createObjectURL(file)); - setErrorMessage(''); - const img = new Image(); - img.onload = () => { - setImageInfo({ - width: img.width, - height: img.height, - size: (file.size / 1024).toFixed(2) + ' KB', - }); - }; - img.src = URL.createObjectURL(file); - } - }, - [t] - ); + const processFile = (file) => { + if (!file) return; - const handleDrop = useCallback( - (event) => { - event.preventDefault(); - const file = event.dataTransfer.files[0]; - if (file && file.type.startsWith('image/')) { - handleFileSelect({ target: { files: [file] } }); - } - }, - [handleFileSelect] - ); + resetSourceState(); - const handleDragOver = useCallback((event) => { - event.preventDefault(); - }, []); + if (file.size > MAX_FILE_SIZE_MB * 1024 * 1024) { + setUploadError(true); + setStatusMessage(t('announceUploadFailed', { maxSize: MAX_FILE_SIZE_MB })); + onChange(null); + return; + } - const handleClose = useCallback(() => { - resetForm(); - onClose(); - }, [resetForm, onClose]); + const objectUrl = URL.createObjectURL(file); + blobUrlRef.current = objectUrl; + + setUploadFile(file); + setPreviewUrl(objectUrl); + onChange({ file, sourceUrl: null }); + + const requestId = loadRequestIdRef.current; + const img = new Image(); + img.onload = () => { + // Drop late loads from a superseded source so stale dimensions don't overwrite the current preview. + if (requestId !== loadRequestIdRef.current) return; + + setImageInfo({ + width: img.width, + height: img.height, + size: (file.size / 1024).toFixed(0) + 'KB', + }); + }; + img.src = objectUrl; + }; + + const handleFileSelect = (e) => processFile(e.target.files[0]); + + const handleDrop = (e) => { + e.preventDefault(); + const file = e.dataTransfer.files[0]; + if (file?.type.startsWith('image/')) processFile(file); + }; + + const handleEmbed = () => { + const trimmed = embedUrl.trim(); + if (!trimmed) return; - const handleConfirm = useCallback(() => { - if (selectedFile && altText.trim()) { - onConfirm(selectedFile, altText); - handleClose(); + resetSourceState({ resetFileInput: true }); + + if (!isValidUrl(trimmed)) { + setEmbedError(true); + setStatusMessage(t('announceEmbedFailed')); + onChange(null); + return; } - }, [selectedFile, altText, onConfirm, handleClose]); - const handleRemoveImage = useCallback(() => { - resetImage(); - }, [resetImage]); + setPreviewUrl(trimmed); + onChange({ file: null, sourceUrl: trimmed }); + }; - return ( - -
- + const handleRemoveImage = () => { + resetSourceState({ resetFileInput: true }); + onChange(null); + }; -
- - {t('title')} - + useEffect(() => revokeBlobUrl, [revokeBlobUrl]); -
- {t('modalDescription')} + const renderPreviewBody = () => { + if (uploadError) { + return ( +
+
+ ); + } + return ( +
+
+ ); + }; -
- - setAltText(e.target.value)} - className="w-full border rounded-md p-2" - placeholder={t('altTextPlaceholder')} - aria-required="true" - aria-invalid={!altText.trim()} - aria-describedby="alt-text-error" - /> - {!altText.trim() && ( - - )} -
+ return ( +
+ {t('imageSource')} -
- - {t('cancel')} - - - {t('confirm')} - + {/* Status message for screen readers */} +
+ {statusMessage} +
+ + {/* Preview box */} +
e.preventDefault()} + > + {renderPreviewBody()} +
+ + {/* File select button */} +
+ + +
+ + {/* Divider */} +
+
+ or +
+
+ + {/* URL embed */} +
+ +

+ {t('embedHint')} +

+
+
+ setEmbedUrl(val)} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); + handleEmbed(); + } + }} + placeholder={t('embedUrlPlaceholder')} + />
+ + {t('embed')} +
-
+ + ); +}; + +ImageSource.propTypes = { + onChange: PropTypes.func.isRequired, +}; + +const ImageUploadModal = ({ isOpen, onClose, onConfirm }) => { + const [imageSource, setImageSource] = useState(null); + const [altText, setAltText] = useState(''); + const [altTextError, setAltTextError] = useState(''); + const [caption, setCaption] = useState(''); + const [linkOption, setLinkOption] = useState('no-link'); + const [targetUrl, setTargetUrl] = useState(''); + const [targetUrlError, setTargetUrlError] = useState(''); + + const t = useTranslation('upload-image-modal'); + + const resetForm = () => { + setImageSource(null); + setAltText(''); + setAltTextError(''); + setCaption(''); + setLinkOption('no-link'); + setTargetUrl(''); + setTargetUrlError(''); + }; + + const handleClose = () => { + resetForm(); + onClose(); + }; + + const handleConfirm = () => { + if (!imageSource) return; // TODO: ask designer where to show image validation error + + let valid = true; + if (!altText.trim()) { + setAltTextError(t('altTextRequired')); + valid = false; + } + if (linkOption === 'with-link') { + if (!targetUrl.trim()) { + setTargetUrlError(t('targetUrlRequired')); + valid = false; + } else if (!isValidUrl(targetUrl)) { + setTargetUrlError(t('targetUrlInvalid')); + valid = false; + } + } + if (!valid) return; + + onConfirm({ + file: imageSource.file, + sourceUrl: imageSource.sourceUrl, + altText: altText.trim(), + display: caption.trim(), + targetUrl: linkOption === 'with-link' ? targetUrl.trim() : '', + }); + handleClose(); + }; + + return ( + +
+ {/* Image source */} + + + {/* Alt text */} + { + setAltText(val); + setAltTextError(''); + }} + placeholder={t('altTextPlaceholder')} + error={altTextError} + required + /> + + {/* Display / caption */} + setCaption(val)} + placeholder={t('captionPlaceholder')} + /> + + {/* External link */} + { + setLinkOption(val); + setTargetUrlError(''); + }} + /> + {linkOption === 'with-link' && ( + { + setTargetUrl(val); + setTargetUrlError(''); + }} + placeholder={t('targetUrlPlaceholder')} + error={targetUrlError} + required + /> + )} +
+
); }; diff --git a/src/lib/url.js b/src/lib/url.js index dd91543..3fc5a2f 100644 --- a/src/lib/url.js +++ b/src/lib/url.js @@ -6,3 +6,10 @@ export const isValidUrl = (value) => { return false; } }; + +export const ellipsizeMiddle = (text, maxLen = 60) => { + if (text.length <= maxLen) return text; + const head = Math.ceil((maxLen - 1) / 2); + const tail = maxLen - 1 - head; + return `${text.slice(0, head)}…${text.slice(-tail)}`; +};