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) => (
-
`;
+ 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(
`}
{/* 스타일 옵션 영역 */}
-
-
-
+
+
+ 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}
-
- )}