From a02d15085abcd7da8f0327f4a261f75b87c1d8f1 Mon Sep 17 00:00:00 2001 From: sterdsterd Date: Thu, 21 Aug 2025 16:09:51 +0900 Subject: [PATCH 1/8] =?UTF-8?q?fix:=20=EC=88=98=EC=8B=9D=20=EB=A0=8C?= =?UTF-8?q?=EB=8D=94=EB=A7=81=20displaystyle=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../editor/text-block/FormulaModal.jsx | 3 +- .../editor/text-block/TextBlockEditor.jsx | 9 ++++-- .../libs/components/viewer/ProblemViewer.jsx | 32 +++++++++++++++++-- 3 files changed, 39 insertions(+), 5 deletions(-) diff --git a/packages/pointer-editor/libs/components/editor/text-block/FormulaModal.jsx b/packages/pointer-editor/libs/components/editor/text-block/FormulaModal.jsx index a0d67558..59dde1b1 100644 --- a/packages/pointer-editor/libs/components/editor/text-block/FormulaModal.jsx +++ b/packages/pointer-editor/libs/components/editor/text-block/FormulaModal.jsx @@ -16,7 +16,7 @@ const FormulaModal = ({ isOpen, onClose, onSave, initialValue = '' }) => { useEffect(() => { if (formula) { try { - const rendered = katex.renderToString(formula, { throwOnError: false }); + const rendered = katex.renderToString(formula, { throwOnError: false, displayMode: true }); setPreview(rendered); } catch { setPreview('수식 오류'); @@ -102,6 +102,7 @@ const FormulaModal = ({ isOpen, onClose, onSave, initialValue = '' }) => { border: '1px solid #ccc', borderRadius: '4px', fontSize: '14px', + fontFamily: 'monospace', }} autoFocus /> diff --git a/packages/pointer-editor/libs/components/editor/text-block/TextBlockEditor.jsx b/packages/pointer-editor/libs/components/editor/text-block/TextBlockEditor.jsx index 5632f6c2..ff43477b 100644 --- a/packages/pointer-editor/libs/components/editor/text-block/TextBlockEditor.jsx +++ b/packages/pointer-editor/libs/components/editor/text-block/TextBlockEditor.jsx @@ -304,11 +304,11 @@ function latexToQuillFormulaHtml(text) { html = html.replace(/\$([^\$]+)\$/g, (match, formula) => { let katexHtml = ''; try { - katexHtml = katex.renderToString(formula, { throwOnError: false }); + katexHtml = katex.renderToString(formula, { throwOnError: false, displayMode: true }); } catch { katexHtml = ''; } - return `\uFEFF${katexHtml}\uFEFF`; + return `\uFEFF${katexHtml}\uFEFF`; }); // 3. 연속 공백을  로 변환 (HTML 태그 내부는 제외) html = html.replace(/ +/g, (spaces) => ' '.repeat(spaces.length)); @@ -640,6 +640,11 @@ const TextBlockEditor = memo( return ( <> + {/* 스타일 옵션 영역 */} { @@ -92,7 +92,11 @@ const ProblemViewer = memo( if (match.index > lastIndex) { splitParts.push(part.substring(lastIndex, match.index)); } - splitParts.push(); + splitParts.push( + + + + ); lastIndex = match.index + match[0].length; } @@ -190,6 +194,18 @@ const ProblemViewer = memo( '& .katex-display': { margin: '1.5em 0', }, + '& .inline-display-math': { + display: 'inline-block', + verticalAlign: 'middle', + }, + '& .inline-display-math .katex-display': { + display: 'inline-block', + margin: 0, + textAlign: 'left', + }, + '& .inline-display-math .katex-display > .katex': { + display: 'inline-block', + }, '& img': { maxWidth: '100%', height: 'auto', @@ -282,6 +298,18 @@ const ProblemViewer = memo( '& .katex-display': { margin: '1.5em 0', }, + '& .inline-display-math': { + display: 'inline-block', + verticalAlign: 'middle', + }, + '& .inline-display-math .katex-display': { + display: 'inline-block', + margin: 0, + textAlign: 'left', + }, + '& .inline-display-math .katex-display > .katex': { + display: 'inline-block', + }, }}> {renderMathContent(problem.problem_content)} From 4b65ca8c03fcca802fc2691ceb5fbfe8200ec343 Mon Sep 17 00:00:00 2001 From: sterdsterd Date: Thu, 21 Aug 2025 16:14:54 +0900 Subject: [PATCH 2/8] =?UTF-8?q?fix:=20FormulaSymbolDropdown=20LaTeX=20?= =?UTF-8?q?=EB=AC=B8=EB=B2=95=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../editor/FormulaSymbolDropdown.jsx | 80 +++++++++---------- 1 file changed, 40 insertions(+), 40 deletions(-) diff --git a/packages/pointer-editor/libs/components/editor/FormulaSymbolDropdown.jsx b/packages/pointer-editor/libs/components/editor/FormulaSymbolDropdown.jsx index e5f0ac64..640da8e9 100644 --- a/packages/pointer-editor/libs/components/editor/FormulaSymbolDropdown.jsx +++ b/packages/pointer-editor/libs/components/editor/FormulaSymbolDropdown.jsx @@ -224,27 +224,27 @@ const categories = [ symbols: [ { icon: , - latex: 'α', + latex: '\\alpha', }, { icon: , - latex: 'β', + latex: '\\beta', }, { icon: , - latex: 'γ', + latex: '\\gamma', }, { icon: , - latex: 'θ', + latex: '\\theta', }, { icon: , - latex: 'π', + latex: '\\pi', }, { icon: , - latex: 'ω', + latex: '\\omega', }, ], }, @@ -253,67 +253,67 @@ const categories = [ symbols: [ { icon: , - latex: '∑', + latex: '\\Sigma', }, { icon: , - latex: '∏', + latex: '\\Pi', }, { icon: , - latex: '∩', + latex: '\\cap', }, { icon: , - latex: '∪', + latex: '\\cup', }, { icon: , - latex: '⊂', + latex: '\\subset', }, { icon: , - latex: '⊃', + latex: '\\supset', }, { icon: , - latex: '⊆', + latex: '\\subseteq', }, { icon: , - latex: '⊇', + latex: '\\supseteq', }, { icon: , - latex: '∈', + latex: '\\in', }, { icon: , - latex: '∋', + latex: '\\ni', }, { icon: , - latex: '≤', + latex: '\\leq', }, { icon: , - latex: '≥', + latex: '\\geq', }, { icon: , - latex: '≪', + latex: '\\ll', }, { icon: , - latex: '≫', + latex: '\\gg', }, { icon: , - latex: '<', + latex: '\\prec', }, { icon: , - latex: '>', + latex: '\\succ', }, ], }, @@ -322,51 +322,51 @@ const categories = [ symbols: [ { icon: , - latex: '±', + latex: '\\pm', }, { icon: , - latex: '∓', + latex: '\\mp', }, { icon: , - latex: '×', + latex: '\\times', }, { icon: , - latex: '÷', + latex: '\\div', }, { icon: , - latex: '∘', + latex: '\\circ', }, { icon: , - latex: '°', + latex: '\\degree', }, { icon: , - latex: '∴', + latex: '\\therefore', }, { icon: , - latex: '∵', + latex: '\\because', }, { icon: , - latex: '≠', + latex: '\\neq', }, { icon: , - latex: '∼', + latex: '\\sim', }, { icon: , - latex: '≃', + latex: '\\cong', }, { icon: , - latex: '∞', + latex: '\\infty', }, ], }, @@ -375,11 +375,11 @@ const categories = [ symbols: [ { icon: , - latex: '△', + latex: '\\triangle', }, { icon: , - latex: '∠', + latex: '\\angle', }, ], }, @@ -392,19 +392,19 @@ const categories = [ symbols: [ { icon: , - latex: 'lim _{ } { }', + latex: '\\lim_{ } { }', }, { icon: , - latex: 'lim _{ -> } { }', + latex: '\\lim_{ \\to } { }', }, { icon: , - latex: 'lim _{ ->0} { }', + latex: '\\lim_{ \\to 0} { }', }, { icon: , - latex: 'lim _{ ->inf} { }', + latex: '\\lim_{ \\to \\infty} { }', }, ], }, From 6208aa10cbf4cde9f260e5d429045e38ef4675f6 Mon Sep 17 00:00:00 2001 From: sterdsterd Date: Thu, 21 Aug 2025 18:04:28 +0900 Subject: [PATCH 3/8] feat: Add OCR image upload functionality to TextBlockEditor with drag-and-drop support --- .../libs/assets/CloudUploadIcon.jsx | 7 +- .../editor/text-block/TextBlockEditor.jsx | 289 ++++++++++++++++-- .../editor/text-block/hooks/useQuillEditor.js | 8 + 3 files changed, 275 insertions(+), 29 deletions(-) diff --git a/packages/pointer-editor/libs/assets/CloudUploadIcon.jsx b/packages/pointer-editor/libs/assets/CloudUploadIcon.jsx index ecf5681e..e3772d2e 100644 --- a/packages/pointer-editor/libs/assets/CloudUploadIcon.jsx +++ b/packages/pointer-editor/libs/assets/CloudUploadIcon.jsx @@ -1,7 +1,12 @@ import { memo } from 'react'; const ColorIcon = (props) => ( - + ${splitText[i]}

`; + html += `${splitText[i]}`; } html = html.replace(/\$([^\$]+)\$/g, (match, formula) => { @@ -510,7 +512,7 @@ const TextBlockEditor = memo( }, []); // Quill 에디터 초기화 - renderingData 사용 - const { getSelection, insertFormula, getContent, insertImage } = useQuillEditor({ + const { getSelection, insertFormula, getContent, insertImage, insertHtml } = useQuillEditor({ containerRef, initialContent: renderingData.content, // 렌더링용 데이터 사용 onTextChange: handleTextChange, @@ -579,6 +581,151 @@ const TextBlockEditor = memo( updateBlock, ]); + // OCR 이미지 업로드용 상태 및 핸들러 + const [isUploading, setIsUploading] = useState(false); + const [uploadError, setUploadError] = useState(''); + const [isDragging, setIsDragging] = useState(false); + const [ocrImageUrl, setOcrImageUrl] = useState(''); + const [isOcrProcessing, setIsOcrProcessing] = useState(false); + const [ocrError, setOcrError] = useState(''); + const fileInputRef = useRef(null); + + // Mathpix 텍스트의 \( .. \), \[ .. \] 구문을 $..$, $$..$$ 로 변환 + const convertMathpixToDollar = useCallback((text) => { + if (!text) return ''; + let output = text; + output = output.replace(/\\\[([\s\S]*?)\\\]/g, (_m, p1) => `$${p1}$`); + output = output.replace(/\\\(([\s\S]*?)\\\)/g, (_m, p1) => `$${p1}$`); + return output; + }, []); + + const getMathpixKeys = () => { + const g = typeof globalThis !== 'undefined' ? globalThis : window; + const viteEnv = + typeof import.meta !== 'undefined' && import.meta && import.meta.env + ? import.meta.env + : undefined; + const appId = + (g && g.__MATHPIX_APP_ID) || + (viteEnv && viteEnv.VITE_MATHPIX_APP_ID) || + (typeof process !== 'undefined' && process.env && process.env.NEXT_PUBLIC_MATHPIX_APP_ID) || + ''; + const appKey = + (g && g.__MATHPIX_API_KEY) || + (viteEnv && viteEnv.VITE_MATHPIX_API_KEY) || + (typeof process !== 'undefined' && + process.env && + process.env.NEXT_PUBLIC_MATHPIX_API_KEY) || + ''; + if (!appId || !appKey) throw new Error('Mathpix API 환경값이 설정되지 않았습니다.'); + return { appId, appKey }; + }; + + const runOcr = useCallback( + async (imageUrl) => { + setOcrError(''); + setIsOcrProcessing(true); + try { + const { appId, appKey } = getMathpixKeys(); + const res = await fetch('https://api.mathpix.com/v3/text', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + app_id: appId, + app_key: appKey, + }, + body: JSON.stringify({ + src: imageUrl, + formats: ['text', 'latex_styled'], + metadata: { improve_mathpix: false }, + }), + }); + if (!res.ok) { + const errText = await res.text().catch(() => ''); + throw new Error(`Mathpix 요청 실패: ${res.status} ${res.statusText} ${errText}`); + } + const json = await res.json(); + console.log(json); + const converted = convertMathpixToDollar(json.text || ''); + console.log(converted); + const html = latexToQuillFormulaHtml(converted); + insertHtml?.(html); + } catch (e) { + console.error(e); + setOcrError(e?.message || 'OCR 처리 중 오류가 발생했습니다.'); + } finally { + setIsOcrProcessing(false); + } + }, + [convertMathpixToDollar, insertHtml] + ); + + const startUpload = useCallback( + async (file) => { + if (!file) return; + if (!file.type.startsWith('image/')) { + setUploadError('이미지 파일만 업로드할 수 있어요.'); + return; + } + setUploadError(''); + setIsUploading(true); + try { + const result = await getFileUploadUrl({ fileName: file.name }); + await uploadFileToS3({ + uploadUrl: result.uploadUrl, + contentDisposition: result.contentDisposition, + file, + }); + setOcrImageUrl(result.file.url); + await runOcr(result.file.url); + } catch (error) { + console.error('이미지 업로드 실패:', error); + setUploadError(error?.message || '이미지 업로드에 실패했습니다.'); + } finally { + setIsUploading(false); + if (fileInputRef.current) fileInputRef.current.value = ''; + } + }, + [runOcr] + ); + + const handleFileSelect = useCallback( + (event) => { + const file = event.target.files?.[0]; + if (!file) return; + void startUpload(file); + }, + [startUpload] + ); + + const handleUploadClick = useCallback(() => { + fileInputRef.current?.click(); + }, []); + + const handleDragOver = useCallback((e) => { + e.preventDefault(); + e.stopPropagation(); + setIsDragging(true); + }, []); + + const handleDragLeave = useCallback((e) => { + e.preventDefault(); + e.stopPropagation(); + setIsDragging(false); + }, []); + + const handleDrop = useCallback( + (e) => { + e.preventDefault(); + e.stopPropagation(); + setIsDragging(false); + const file = e.dataTransfer?.files?.[0]; + if (!file) return; + void startUpload(file); + }, + [startUpload] + ); + const handleFormulaSave = (formula) => { if (formula) { const range = savedRangeRef.current; @@ -647,24 +794,85 @@ const TextBlockEditor = memo( `} {/* 스타일 옵션 영역 */} - - -
수식 삽입 (⌘+Shift+M / Ctrl+Shift+M) - + + + OCR + + {/* 파일 input (숨김) */} + + + {/* 드롭존 */} + + + 이미지를 여기에 드래그 앤 드롭하거나 클릭하여 업로드 + + + + {isUploading && ( + + 업로드 중... + + )} + {ocrImageUrl && !isUploading && ( + + 업로드 완료 + + )} + {isOcrProcessing && ( + + OCR 처리 중... + + )} + {uploadError && ( + + {uploadError} + + )} + {ocrError && ( + + {ocrError} + + )} + +
diff --git a/packages/pointer-editor/libs/components/editor/text-block/hooks/useQuillEditor.js b/packages/pointer-editor/libs/components/editor/text-block/hooks/useQuillEditor.js index 09d3d7ff..729c0985 100644 --- a/packages/pointer-editor/libs/components/editor/text-block/hooks/useQuillEditor.js +++ b/packages/pointer-editor/libs/components/editor/text-block/hooks/useQuillEditor.js @@ -206,6 +206,14 @@ const useQuillEditor = ({ quillRef.current.setSelection(length); } }, + insertHtml: (html) => { + if (!quillRef.current || !html) return; + const quill = quillRef.current; + const range = quill.getSelection(); + const index = range ? range.index : quill.getLength() - 1; + // Use Quill clipboard to paste sanitized HTML at the current cursor (or end) + quill.clipboard.dangerouslyPasteHTML(index, html); + }, insertImage: isInsertableImage ? (imageUrl) => { if (!quillRef.current) return; From 715e0f16aab9b5cafb5cdf7068bbea1d58fdb907 Mon Sep 17 00:00:00 2001 From: sterdsterd Date: Thu, 21 Aug 2025 19:43:05 +0900 Subject: [PATCH 4/8] refactor: extract ocr from TextBlockEditor --- packages/pointer-editor/libs/api/ocr.js | 66 +++++++++++++++++++ .../editor/text-block/TextBlockEditor.jsx | 54 +-------------- 2 files changed, 69 insertions(+), 51 deletions(-) create mode 100644 packages/pointer-editor/libs/api/ocr.js diff --git a/packages/pointer-editor/libs/api/ocr.js b/packages/pointer-editor/libs/api/ocr.js new file mode 100644 index 00000000..6748daa0 --- /dev/null +++ b/packages/pointer-editor/libs/api/ocr.js @@ -0,0 +1,66 @@ +export const getMathpixKeys = () => { + const g = typeof globalThis !== 'undefined' ? globalThis : window; + const viteEnv = + typeof import.meta !== 'undefined' && import.meta && import.meta.env + ? import.meta.env + : undefined; + + const appId = + (g && g.__MATHPIX_APP_ID) || + (viteEnv && viteEnv.VITE_MATHPIX_APP_ID) || + (typeof process !== 'undefined' && process.env && process.env.NEXT_PUBLIC_MATHPIX_APP_ID) || + ''; + + const appKey = + (g && g.__MATHPIX_API_KEY) || + (viteEnv && viteEnv.VITE_MATHPIX_API_KEY) || + (typeof process !== 'undefined' && process.env && process.env.NEXT_PUBLIC_MATHPIX_API_KEY) || + ''; + + if (!appId || !appKey) throw new Error('Mathpix API 환경값이 설정되지 않았습니다.'); + return { appId, appKey }; +}; + +export const recognizeImageWithMathpix = async (imageUrl) => { + const { appId, appKey } = getMathpixKeys(); + const res = await fetch('https://api.mathpix.com/v3/text', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + app_id: appId, + app_key: appKey, + }, + body: JSON.stringify({ + src: imageUrl, + formats: ['text', 'latex_styled'], + metadata: { improve_mathpix: false }, + }), + }); + if (!res.ok) { + const errText = await res.text().catch(() => ''); + throw new Error(`Mathpix 요청 실패: ${res.status} ${res.statusText} ${errText}`); + } + const json = await res.json(); + return json; +}; + +export const convertMathpixToDollar = (text) => { + if (!text) return ''; + let output = text; + output = output.replace(/\\\[([\s\S]*?)\\\]/g, (_m, p1) => `$${p1}$`); + output = output.replace(/\\\(([\s\S]*?)\\\)/g, (_m, p1) => `$${p1}$`); + return output; +}; + +export const recognizeAndConvertMathpixText = async (imageUrl) => { + const json = await recognizeImageWithMathpix(imageUrl); + const converted = convertMathpixToDollar(json.text || ''); + return converted; +}; + +export default { + getMathpixKeys, + recognizeImageWithMathpix, + convertMathpixToDollar, + recognizeAndConvertMathpixText, +}; diff --git a/packages/pointer-editor/libs/components/editor/text-block/TextBlockEditor.jsx b/packages/pointer-editor/libs/components/editor/text-block/TextBlockEditor.jsx index 9e01d9f7..e6dfd3bd 100644 --- a/packages/pointer-editor/libs/components/editor/text-block/TextBlockEditor.jsx +++ b/packages/pointer-editor/libs/components/editor/text-block/TextBlockEditor.jsx @@ -14,6 +14,8 @@ import { import { Functions } from '@mui/icons-material'; import katex from 'katex'; +import { recognizeImageWithMathpix, convertMathpixToDollar } from '../../../api/ocr'; +import { getFileUploadUrl, uploadFileToS3 } from '../../../api/fileUpload'; import { BoldIcon, ItalicIcon, @@ -22,7 +24,6 @@ import { BoxIcon, CloudUploadIcon, } from '../../../assets'; -import { getFileUploadUrl, uploadFileToS3 } from '../../../api/fileUpload'; import useQuillEditor from './hooks/useQuillEditor'; import FormulaModal from './FormulaModal'; @@ -590,61 +591,12 @@ const TextBlockEditor = memo( const [ocrError, setOcrError] = useState(''); const fileInputRef = useRef(null); - // Mathpix 텍스트의 \( .. \), \[ .. \] 구문을 $..$, $$..$$ 로 변환 - const convertMathpixToDollar = useCallback((text) => { - if (!text) return ''; - let output = text; - output = output.replace(/\\\[([\s\S]*?)\\\]/g, (_m, p1) => `$${p1}$`); - output = output.replace(/\\\(([\s\S]*?)\\\)/g, (_m, p1) => `$${p1}$`); - return output; - }, []); - - const getMathpixKeys = () => { - const g = typeof globalThis !== 'undefined' ? globalThis : window; - const viteEnv = - typeof import.meta !== 'undefined' && import.meta && import.meta.env - ? import.meta.env - : undefined; - const appId = - (g && g.__MATHPIX_APP_ID) || - (viteEnv && viteEnv.VITE_MATHPIX_APP_ID) || - (typeof process !== 'undefined' && process.env && process.env.NEXT_PUBLIC_MATHPIX_APP_ID) || - ''; - const appKey = - (g && g.__MATHPIX_API_KEY) || - (viteEnv && viteEnv.VITE_MATHPIX_API_KEY) || - (typeof process !== 'undefined' && - process.env && - process.env.NEXT_PUBLIC_MATHPIX_API_KEY) || - ''; - if (!appId || !appKey) throw new Error('Mathpix API 환경값이 설정되지 않았습니다.'); - return { appId, appKey }; - }; - const runOcr = useCallback( async (imageUrl) => { setOcrError(''); setIsOcrProcessing(true); try { - const { appId, appKey } = getMathpixKeys(); - const res = await fetch('https://api.mathpix.com/v3/text', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - app_id: appId, - app_key: appKey, - }, - body: JSON.stringify({ - src: imageUrl, - formats: ['text', 'latex_styled'], - metadata: { improve_mathpix: false }, - }), - }); - if (!res.ok) { - const errText = await res.text().catch(() => ''); - throw new Error(`Mathpix 요청 실패: ${res.status} ${res.statusText} ${errText}`); - } - const json = await res.json(); + const json = await recognizeImageWithMathpix(imageUrl); console.log(json); const converted = convertMathpixToDollar(json.text || ''); console.log(converted); From 100761f0fb9d01b9f78d8eda012659e3941fe789 Mon Sep 17 00:00:00 2001 From: sterdsterd Date: Thu, 21 Aug 2025 19:49:56 +0900 Subject: [PATCH 5/8] feat: Implement copy and paste functionality for formulas in Quill editor --- .../editor/text-block/hooks/useQuillEditor.js | 76 +++++++++++++++++++ 1 file changed, 76 insertions(+) diff --git a/packages/pointer-editor/libs/components/editor/text-block/hooks/useQuillEditor.js b/packages/pointer-editor/libs/components/editor/text-block/hooks/useQuillEditor.js index 729c0985..fc876733 100644 --- a/packages/pointer-editor/libs/components/editor/text-block/hooks/useQuillEditor.js +++ b/packages/pointer-editor/libs/components/editor/text-block/hooks/useQuillEditor.js @@ -160,6 +160,80 @@ const useQuillEditor = ({ quill.on(Quill.events.TEXT_CHANGE, handleTextChange); + const handleCopy = (e) => { + try { + const range = quill.getSelection(); + if (!range || range.length === 0) return; + const delta = quill.getContents(range.index, range.length); + let plain = ''; + (delta.ops || []).forEach((op) => { + const insert = op.insert; + if (insert == null) return; + if (typeof insert === 'string') { + plain += insert; + } else if (insert.formula) { + plain += `$${insert.formula}$`; + } + }); + + if (plain) { + e.preventDefault(); + let html = ''; + const sel = window.getSelection(); + if (sel && sel.rangeCount > 0) { + const container = document.createElement('div'); + for (let i = 0; i < sel.rangeCount; i++) { + container.appendChild(sel.getRangeAt(i).cloneContents()); + } + html = container.innerHTML; + } + e.clipboardData.setData('text/plain', plain); + if (html) e.clipboardData.setData('text/html', html); + } + } catch {} + }; + + const handlePaste = (e) => { + try { + const text = e.clipboardData?.getData('text/plain') || ''; + if (!text || !/\$\$[\s\S]*?\$\$/.test(text)) return; + + e.preventDefault(); + const range = quill.getSelection(); + if (range && range.length) { + quill.deleteText(range.index, range.length); + } + const insertIndex = range ? range.index : quill.getLength() - 1; + + let cursor = insertIndex; + const regex = /\$\$([\s\S]*?)\$\$/g; + let lastIndex = 0; + let match; + while ((match = regex.exec(text)) !== null) { + const before = text.slice(lastIndex, match.index); + if (before) { + quill.insertText(cursor, before); + cursor += before.length; + } + const formula = match[1]; + if (formula) { + quill.insertEmbed(cursor, 'formula', formula); + cursor += 1; + } + lastIndex = match.index + match[0].length; + } + const tail = text.slice(lastIndex); + if (tail) { + quill.insertText(cursor, tail); + cursor += tail.length; + } + quill.setSelection(cursor); + } catch {} + }; + + editorContainer.addEventListener('copy', handleCopy); + editorContainer.addEventListener('paste', handlePaste); + // 수식 클릭 이벤트 핸들러 const handleFormulaClick = (e) => { const formulaElement = e.target.closest('.ql-formula'); @@ -185,6 +259,8 @@ const useQuillEditor = ({ if (container && editorContainer.parentNode === container) { container.removeChild(editorContainer); } + editorContainer.removeEventListener('copy', handleCopy); + editorContainer.removeEventListener('paste', handlePaste); quillRef.current = null; }; }, []); // 의존성 배열을 비워서 한 번만 실행 From 572aeb4a74aa991979e28808dc1f96f5afa73182 Mon Sep 17 00:00:00 2001 From: sterdsterd Date: Thu, 21 Aug 2025 22:29:42 +0900 Subject: [PATCH 6/8] refactor: Improve LaTeX formula handling in TextBlockEditor by wrapping lines in

tags and enhancing formula insertion logic --- .../editor/text-block/TextBlockEditor.jsx | 38 +++++++++---------- .../editor/text-block/hooks/useQuillEditor.js | 21 +++++++--- 2 files changed, 33 insertions(+), 26 deletions(-) diff --git a/packages/pointer-editor/libs/components/editor/text-block/TextBlockEditor.jsx b/packages/pointer-editor/libs/components/editor/text-block/TextBlockEditor.jsx index e6dfd3bd..01459b35 100644 --- a/packages/pointer-editor/libs/components/editor/text-block/TextBlockEditor.jsx +++ b/packages/pointer-editor/libs/components/editor/text-block/TextBlockEditor.jsx @@ -294,29 +294,27 @@ const editorReducer = (state, action) => { // $...$를 로 변환 (내부에 KaTeX HTML 포함) function latexToQuillFormulaHtml(text) { if (!text) return ''; - // 1. $...$를 모두 변환 - // text를 개행 단위로 분리 (p태그 단위) - const splitText = text.split('\n'); - - let html = ''; - for (let i = 0; i < splitText.length; i++) { - html += `${splitText[i]}`; - } + // text를 개행 단위로 분리하여 각 라인을 p 태그로 감싸기 + const lines = text.split('\n'); + + const processedLines = lines.map((line) => { + // 각 라인에서 $...$를 수식으로 변환 + const processedLine = line.replace(/\$([^\$]+)\$/g, (match, formula) => { + let katexHtml = ''; + try { + katexHtml = katex.renderToString(formula, { throwOnError: false, displayMode: true }); + } catch { + katexHtml = ''; + } + return `\u200B${katexHtml}\u200B`; + }); - html = html.replace(/\$([^\$]+)\$/g, (match, formula) => { - let katexHtml = ''; - try { - katexHtml = katex.renderToString(formula, { throwOnError: false, displayMode: true }); - } catch { - katexHtml = ''; - } - return `\uFEFF${katexHtml}\uFEFF`; + return processedLine; }); - // 3. 연속 공백을  로 변환 (HTML 태그 내부는 제외) - html = html.replace(/ +/g, (spaces) => ' '.repeat(spaces.length)); - // 4. 전체를 하나의 p태그로 감싸기 - return html; + + // 각 라인을 p 태그로 감싸서 반환 + return processedLines.map((line) => `

${line}

`).join(''); } // Quill HTML을 $$...$$ LaTeX 텍스트로 변환하는 함수 diff --git a/packages/pointer-editor/libs/components/editor/text-block/hooks/useQuillEditor.js b/packages/pointer-editor/libs/components/editor/text-block/hooks/useQuillEditor.js index fc876733..26a027e2 100644 --- a/packages/pointer-editor/libs/components/editor/text-block/hooks/useQuillEditor.js +++ b/packages/pointer-editor/libs/components/editor/text-block/hooks/useQuillEditor.js @@ -272,14 +272,23 @@ const useQuillEditor = ({ insertFormula: (formula, range) => { if (!quillRef.current) return; + const quill = quillRef.current; + let katexHtml = ''; + try { + katexHtml = katex.renderToString(formula, { throwOnError: false, displayMode: true }); + } catch { + katexHtml = ''; + } + const html = `\u200B${katexHtml}\u200B`; + if (range) { - quillRef.current.deleteText(range.index, range.length); - quillRef.current.insertEmbed(range.index, 'formula', formula); - quillRef.current.setSelection(range.index + 1); + quill.deleteText(range.index, range.length); + quill.clipboard.dangerouslyPasteHTML(range.index, html); + quill.setSelection(range.index + 1); } else { - const length = quillRef.current.getLength(); - quillRef.current.insertEmbed(length - 1, 'formula', formula); - quillRef.current.setSelection(length); + const index = quill.getSelection()?.index ?? quill.getLength() - 1; + quill.clipboard.dangerouslyPasteHTML(index, html); + quill.setSelection(index + 1); } }, insertHtml: (html) => { From cf6024ef28abcf8cec788f51d951a72478e3bbe7 Mon Sep 17 00:00:00 2001 From: sterdsterd Date: Thu, 21 Aug 2025 22:39:19 +0900 Subject: [PATCH 7/8] refactor: Enhance LaTeX formula conversion by trimming whitespace in Mathpix output --- packages/pointer-editor/libs/api/ocr.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/pointer-editor/libs/api/ocr.js b/packages/pointer-editor/libs/api/ocr.js index 6748daa0..3ef30489 100644 --- a/packages/pointer-editor/libs/api/ocr.js +++ b/packages/pointer-editor/libs/api/ocr.js @@ -47,8 +47,8 @@ export const recognizeImageWithMathpix = async (imageUrl) => { export const convertMathpixToDollar = (text) => { if (!text) return ''; let output = text; - output = output.replace(/\\\[([\s\S]*?)\\\]/g, (_m, p1) => `$${p1}$`); - output = output.replace(/\\\(([\s\S]*?)\\\)/g, (_m, p1) => `$${p1}$`); + output = output.replace(/\\\[([\s\S]*?)\\\]/g, (_m, p1) => `$${p1.replace(/\s+/g, ' ').trim()}$`); + output = output.replace(/\\\(([\s\S]*?)\\\)/g, (_m, p1) => `$${p1.replace(/\s+/g, ' ').trim()}$`); return output; }; From cac626d0356d01ad311ad10557e7881ef2483ef5 Mon Sep 17 00:00:00 2001 From: sterdsterd Date: Thu, 21 Aug 2025 22:54:43 +0900 Subject: [PATCH 8/8] design: Update TextBlockEditor UI --- .../editor/text-block/TextBlockEditor.jsx | 94 ++++++++++--------- 1 file changed, 52 insertions(+), 42 deletions(-) diff --git a/packages/pointer-editor/libs/components/editor/text-block/TextBlockEditor.jsx b/packages/pointer-editor/libs/components/editor/text-block/TextBlockEditor.jsx index 01459b35..4b107e6c 100644 --- a/packages/pointer-editor/libs/components/editor/text-block/TextBlockEditor.jsx +++ b/packages/pointer-editor/libs/components/editor/text-block/TextBlockEditor.jsx @@ -11,7 +11,7 @@ import { TextField, Alert, } from '@mui/material'; -import { Functions } from '@mui/icons-material'; +import { Functions, CheckCircle } from '@mui/icons-material'; import katex from 'katex'; import { recognizeImageWithMathpix, convertMathpixToDollar } from '../../../api/ocr'; @@ -600,10 +600,14 @@ const TextBlockEditor = memo( console.log(converted); const html = latexToQuillFormulaHtml(converted); insertHtml?.(html); + + setUploadError(''); + setOcrError(''); + setOcrImageUrl(''); + setIsOcrProcessing(false); } catch (e) { console.error(e); setOcrError(e?.message || 'OCR 처리 중 오류가 발생했습니다.'); - } finally { setIsOcrProcessing(false); } }, @@ -776,53 +780,59 @@ const TextBlockEditor = memo( alignItems: 'center', justifyContent: 'space-between', gap: 1, - backgroundColor: '#F7F7F7', + backgroundColor: uploadError || ocrError ? '#FEE2E2' : '#F7F7F7', borderRadius: '10px', - border: isDragging ? '2px solid #1E1E21' : '2px dashed #C6CAD4', - transition: 'border-color 0.15s ease', + border: isDragging + ? '2px solid #1E1E21' + : uploadError || ocrError + ? '2px solid #FEE2E2' + : '2px dashed #C6CAD4', cursor: 'pointer', textAlign: 'center', padding: '12px 12px', + transition: 'all 0.2s ease', }} onClick={handleUploadClick}> - - 이미지를 여기에 드래그 앤 드롭하거나 클릭하여 업로드 - - + + {(isUploading || isOcrProcessing) && ( + + )} + + {isOcrProcessing + ? 'OCR 처리 중...' + : isUploading + ? '업로드 중...' + : uploadError + ? uploadError + : ocrError + ? ocrError + : '이미지를 여기에 드래그 앤 드롭하거나 클릭하여 업로드'} + + + {!isOcrProcessing && !isUploading && !ocrImageUrl && ( + + )} - - {isUploading && ( - - 업로드 중... - - )} - {ocrImageUrl && !isUploading && ( - - 업로드 완료 - - )} - {isOcrProcessing && ( - - OCR 처리 중... - - )} - {uploadError && ( - - {uploadError} - - )} - {ocrError && ( - - {ocrError} - - )}