diff --git a/packages/pointer-editor/libs/api/ocr.js b/packages/pointer-editor/libs/api/ocr.js new file mode 100644 index 00000000..3ef30489 --- /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.replace(/\s+/g, ' ').trim()}$`); + output = output.replace(/\\\(([\s\S]*?)\\\)/g, (_m, p1) => `$${p1.replace(/\s+/g, ' ').trim()}$`); + 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/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) => ( - + , - 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} { }', }, ], }, 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..4b107e6c 100644 --- a/packages/pointer-editor/libs/components/editor/text-block/TextBlockEditor.jsx +++ b/packages/pointer-editor/libs/components/editor/text-block/TextBlockEditor.jsx @@ -3,24 +3,27 @@ import { Box, Button, IconButton, - ToggleButton, - ToggleButtonGroup, Typography, Dialog, DialogTitle, DialogContent, DialogActions, TextField, + Alert, } from '@mui/material'; -import { - Functions, - FormatAlignLeft, - FormatAlignCenter, - FormatAlignRight, -} from '@mui/icons-material'; +import { Functions, CheckCircle } from '@mui/icons-material'; import katex from 'katex'; -import { BoldIcon, ItalicIcon, UnderlineIcon, ColorIcon, BoxIcon } from '../../../assets'; +import { recognizeImageWithMathpix, convertMathpixToDollar } from '../../../api/ocr'; +import { getFileUploadUrl, uploadFileToS3 } from '../../../api/fileUpload'; +import { + BoldIcon, + ItalicIcon, + UnderlineIcon, + ColorIcon, + BoxIcon, + CloudUploadIcon, +} from '../../../assets'; import useQuillEditor from './hooks/useQuillEditor'; import FormulaModal from './FormulaModal'; @@ -291,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 }); - } 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 텍스트로 변환하는 함수 @@ -510,7 +511,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 +580,106 @@ 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); + + const runOcr = useCallback( + async (imageUrl) => { + setOcrError(''); + setIsOcrProcessing(true); + try { + const json = await recognizeImageWithMathpix(imageUrl); + console.log(json); + const converted = convertMathpixToDollar(json.text || ''); + console.log(converted); + const html = latexToQuillFormulaHtml(converted); + insertHtml?.(html); + + setUploadError(''); + setOcrError(''); + setOcrImageUrl(''); + setIsOcrProcessing(false); + } catch (e) { + console.error(e); + setOcrError(e?.message || 'OCR 처리 중 오류가 발생했습니다.'); + 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; @@ -640,25 +741,97 @@ const TextBlockEditor = memo( return ( <> + {/* 스타일 옵션 영역 */} - - - + + + OCR + + {/* 파일 input (숨김) */} + + + {/* 드롭존 */} + + + {(isUploading || isOcrProcessing) && ( + + )} + + {isOcrProcessing + ? 'OCR 처리 중...' + : isUploading + ? '업로드 중...' + : uploadError + ? uploadError + : ocrError + ? ocrError + : '이미지를 여기에 드래그 앤 드롭하거나 클릭하여 업로드'} + + + {!isOcrProcessing && !isUploading && !ocrImageUrl && ( + + )} @@ -760,6 +933,31 @@ const TextBlockEditor = memo( onClick={handleColor}> + + 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..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 @@ -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; }; }, []); // 의존성 배열을 비워서 한 번만 실행 @@ -196,16 +272,33 @@ 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) => { + 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; diff --git a/packages/pointer-editor/libs/components/viewer/ProblemViewer.jsx b/packages/pointer-editor/libs/components/viewer/ProblemViewer.jsx index 78ad8ded..ed32a2f6 100644 --- a/packages/pointer-editor/libs/components/viewer/ProblemViewer.jsx +++ b/packages/pointer-editor/libs/components/viewer/ProblemViewer.jsx @@ -1,7 +1,7 @@ import React, { memo } from 'react'; import { Container, Paper, Typography, Box, CircularProgress } from '@mui/material'; import 'katex/dist/katex.min.css'; -import { BlockMath, InlineMath } from 'react-katex'; +import { BlockMath } from 'react-katex'; const ProblemViewer = memo( ({ problem, loading = false }) => { @@ -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)}