From eb5edf8d4b162a20ad35bde1865c6c662426d7f8 Mon Sep 17 00:00:00 2001 From: Wendy Yuchen Sun Date: Fri, 24 Apr 2026 17:03:00 +0800 Subject: [PATCH 1/3] feat: update insert image modal --- public/locales/en/upload-image-modal.json | 35 +- public/locales/zh-TW/upload-image-modal.json | 36 +- src/components/edit-icons-tab.js | 18 +- src/components/image-upload-modal.js | 561 ++++++++++++------- src/lib/url.js | 7 + 5 files changed, 448 insertions(+), 209 deletions(-) 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..d96f0c3 100644 --- a/src/components/image-upload-modal.js +++ b/src/components/image-upload-modal.js @@ -1,226 +1,409 @@ -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 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 = ''; + }; - 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 })); + return; + } - const handleClose = useCallback(() => { - resetForm(); - onClose(); - }, [resetForm, onClose]); + const objectUrl = URL.createObjectURL(file); + blobUrlRef.current = objectUrl; + + setUploadFile(file); + setPreviewUrl(objectUrl); - const handleConfirm = useCallback(() => { - if (selectedFile && altText.trim()) { - onConfirm(selectedFile, altText); - handleClose(); + const img = new Image(); + img.onload = () => + 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; + + resetSourceState({ resetFileInput: true }); + + if (!isValidUrl(trimmed)) { + setEmbedError(true); + setStatusMessage(t('announceEmbedFailed')); + return; } - }, [selectedFile, altText, onConfirm, handleClose]); - const handleRemoveImage = useCallback(() => { - resetImage(); - }, [resetImage]); + setPreviewUrl(trimmed); + }; - return ( - -
- + const handleRemoveImage = () => { + resetSourceState({ resetFileInput: true }); + }; -
- - {t('title')} - + useEffect(() => { + if (uploadError || embedError || !previewUrl) { + onChange(null); + return; + } + onChange({ + file: uploadFile, + sourceUrl: uploadFile ? null : previewUrl, + }); + }, [uploadError, embedError, previewUrl, onChange, uploadFile]); -
- {t('modalDescription')} -
+ useEffect(() => revokeBlobUrl, [revokeBlobUrl]); -
!previewUrl && fileInputRef.current?.click()} - onDrop={handleDrop} - onDragOver={handleDragOver} - onKeyPress={(e) => { - if (e.key === 'Enter' || e.key === ' ') { - !previewUrl && fileInputRef.current?.click(); - } - }} - role="button" - tabIndex={0} - aria-label={previewUrl ? t('replaceImage') : t('chooseFile')} - > - { + if (uploadError) { + return ( +
+
+ ); + } + if (embedError) { + return ( +
+
+ ); + } + if (previewUrl) { + return ( +
+
+ {t('imagePreview')} { + if (!uploadFile) { + setEmbedError(true); + setPreviewUrl(null); + setStatusMessage(t('announceEmbedFailed')); + } + }} /> - {!previewUrl ? ( -
- - {t('chooseFile')} -
+ +
+

+ {uploadFile ? ( + <> + {uploadFile.name} + + ) : ( -

- - {t('imagePreview')} -
+ <> + {previewUrl} + + )} -
- - {errorMessage && ( - - )} - +

{imageInfo && ( -
-

- {t('dimensions')}: {imageInfo.width} x {imageInfo.height}px -

-

- {t('fileSize')}: {imageInfo.size} -

-
+

+ {t('fileSize')}:{imageInfo.size} {t('dimensions')}:{imageInfo.width} x{' '} + {imageInfo.height} px +

)} +
+ ); + } + 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)}`; +}; From fed1269d187d0b0a710e037751840948763e9bca Mon Sep 17 00:00:00 2001 From: Wendy Yuchen Sun Date: Mon, 18 May 2026 21:08:39 +0800 Subject: [PATCH 2/3] refactor: call onChange directly in image source handlers --- src/components/image-upload-modal.js | 17 ++++++----------- 1 file changed, 6 insertions(+), 11 deletions(-) diff --git a/src/components/image-upload-modal.js b/src/components/image-upload-modal.js index d96f0c3..2b4fe57 100644 --- a/src/components/image-upload-modal.js +++ b/src/components/image-upload-modal.js @@ -50,6 +50,7 @@ const ImageSource = ({ onChange }) => { if (file.size > MAX_FILE_SIZE_MB * 1024 * 1024) { setUploadError(true); setStatusMessage(t('announceUploadFailed', { maxSize: MAX_FILE_SIZE_MB })); + onChange(null); return; } @@ -58,6 +59,7 @@ const ImageSource = ({ onChange }) => { setUploadFile(file); setPreviewUrl(objectUrl); + onChange({ file, sourceUrl: null }); const img = new Image(); img.onload = () => @@ -86,27 +88,19 @@ const ImageSource = ({ onChange }) => { if (!isValidUrl(trimmed)) { setEmbedError(true); setStatusMessage(t('announceEmbedFailed')); + onChange(null); return; } setPreviewUrl(trimmed); + onChange({ file: null, sourceUrl: trimmed }); }; const handleRemoveImage = () => { resetSourceState({ resetFileInput: true }); + onChange(null); }; - useEffect(() => { - if (uploadError || embedError || !previewUrl) { - onChange(null); - return; - } - onChange({ - file: uploadFile, - sourceUrl: uploadFile ? null : previewUrl, - }); - }, [uploadError, embedError, previewUrl, onChange, uploadFile]); - useEffect(() => revokeBlobUrl, [revokeBlobUrl]); const renderPreviewBody = () => { @@ -151,6 +145,7 @@ const ImageSource = ({ onChange }) => { setEmbedError(true); setPreviewUrl(null); setStatusMessage(t('announceEmbedFailed')); + onChange(null); } }} /> From c755377285e2cb8f67f04108857e51dd7a9c6a36 Mon Sep 17 00:00:00 2001 From: Wendy Yuchen Sun Date: Mon, 18 May 2026 21:14:12 +0800 Subject: [PATCH 3/3] fix: ignore stale image loads in image source picker --- src/components/image-upload-modal.js | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/components/image-upload-modal.js b/src/components/image-upload-modal.js index 2b4fe57..779b869 100644 --- a/src/components/image-upload-modal.js +++ b/src/components/image-upload-modal.js @@ -21,6 +21,7 @@ const ImageSource = ({ onChange }) => { const fileInputRef = useRef(null); const blobUrlRef = useRef(null); + const loadRequestIdRef = useRef(0); const t = useTranslation('upload-image-modal'); @@ -40,6 +41,7 @@ const ImageSource = ({ onChange }) => { setStatusMessage(''); revokeBlobUrl(); if (resetFileInput && fileInputRef.current) fileInputRef.current.value = ''; + loadRequestIdRef.current++; }; const processFile = (file) => { @@ -61,13 +63,18 @@ const ImageSource = ({ onChange }) => { setPreviewUrl(objectUrl); onChange({ file, sourceUrl: null }); + const requestId = loadRequestIdRef.current; const img = new Image(); - img.onload = () => + 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; };