Skip to content

Commit 8fdfb65

Browse files
committed
feat: add grid-based math item authoring tool with visual formula editor
- New authoring module (components/math/authoring/) with block-based grid editor - Body block: textarea + MathLive popup composer for WYSIWYG formula assembly - Choices block: multiple choice with per-option MathLive editing - Table, image, solution blocks with drag-and-drop reordering - Symbol palette with 8 categories (100+ symbols) and Korean tooltips - MathLive integration via dynamic import (SSR-safe) - Tab toggle on items/new: "저작 도구" (new) / "LaTeX 직접 입력" (classic) - Radix tooltips on all toolbar buttons and symbol palette for discoverability
1 parent 8b2384b commit 8fdfb65

17 files changed

Lines changed: 1775 additions & 9 deletions

apps/web/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737
"ioredis": "^5.10.1",
3838
"katex": "^0.16.45",
3939
"lucide-react": "^1.7.0",
40+
"mathlive": "^0.109.1",
4041
"meilisearch": "^0.57.0",
4142
"next": "^15.2.0",
4243
"next-auth": "5.0.0-beta.30",

apps/web/src/app/(dashboard)/items/new/page.tsx

Lines changed: 64 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
"use client";
22

3-
import React, { useState, useCallback, useEffect, Suspense, type FormEvent } from "react";
3+
import React, { useState, useCallback, useEffect, useRef, Suspense, type FormEvent } from "react";
44
import Link from "next/link";
5+
import dynamic from "next/dynamic";
56
import { useRouter, useSearchParams } from "next/navigation";
67
import { FormulaEditor } from "@/components/math/formula-editor";
78
import { Button } from "@/components/ui/button";
@@ -18,6 +19,15 @@ import {
1819
} from "@math-item-os/shared/constants/index";
1920
import { extractFormValues } from "./item-form-utils";
2021
import { AutoTagSuggestions } from "@/components/items/auto-tag-suggestions";
22+
import type { AuthoringOutput } from "@/components/math/authoring";
23+
24+
// MathLive는 SSR 불가 — 클라이언트 전용 dynamic import
25+
const ItemAuthoringGrid = dynamic(
26+
() => import("@/components/math/authoring").then((m) => ({ default: m.ItemAuthoringGrid })),
27+
{ ssr: false, loading: () => <div className="flex h-[200px] items-center justify-center text-sm text-slate-400">저작 도구 로딩 중...</div> },
28+
);
29+
30+
type EditorTab = "classic" | "authoring";
2131

2232
// --- 타입 정의 ---
2333

@@ -177,6 +187,8 @@ function ItemForm() {
177187
const [errors, setErrors] = useState<FormErrors>({});
178188
const [changeSummary, setChangeSummary] = useState("");
179189
const [initialized, setInitialized] = useState(false);
190+
const [editorTab, setEditorTab] = useState<EditorTab>("authoring");
191+
const authoringOutputRef = useRef<AuthoringOutput | null>(null);
180192

181193
// -- 편집 모드: 기존 문항 로드 --
182194
const { data: existingItem, isLoading: isLoadingItem } = trpc.item.getById.useQuery(
@@ -256,6 +268,14 @@ function ItemForm() {
256268
);
257269
}, []);
258270

271+
// -- 저작 도구 출력 → bodyLatex 동기화 --
272+
const handleAuthoringOutput = useCallback((output: AuthoringOutput) => {
273+
authoringOutputRef.current = output;
274+
if (editorTab === "authoring") {
275+
setBodyLatex(output.bodyLatex);
276+
}
277+
}, [editorTab]);
278+
259279
// -- 활용 목적 토글 --
260280
const handleUsagePurposeToggle = useCallback((purpose: string) => {
261281
setUsagePurposes((prev) =>
@@ -377,7 +397,7 @@ function ItemForm() {
377397
}
378398

379399
return (
380-
<div className="mx-auto max-w-3xl">
400+
<div className="mx-auto max-w-5xl">
381401
{/* 페이지 헤더 */}
382402
<div className="mb-6 flex items-center justify-between">
383403
<h1 className="text-2xl font-bold text-slate-900 dark:text-slate-100">
@@ -393,14 +413,49 @@ function ItemForm() {
393413
</div>
394414

395415
<form onSubmit={handleSubmit} className="flex flex-col gap-6">
396-
{/* 수식 영역 */}
416+
{/* 수식 영역 — 탭 토글 */}
397417
<FormSection title="수식 영역">
398-
<FormulaEditor
399-
value={bodyLatex}
400-
onChange={setBodyLatex}
401-
label="수식"
402-
error={errors.bodyLatex}
403-
/>
418+
{/* 탭 헤더 */}
419+
<div className="flex gap-1 rounded-md bg-slate-100 p-0.5 dark:bg-slate-800">
420+
<button
421+
type="button"
422+
onClick={() => setEditorTab("authoring")}
423+
className={`flex-1 rounded-md px-3 py-1.5 text-sm font-medium transition-colors ${
424+
editorTab === "authoring"
425+
? "bg-white text-slate-900 shadow-sm dark:bg-slate-700 dark:text-slate-100"
426+
: "text-slate-500 hover:text-slate-700 dark:text-slate-400"
427+
}`}
428+
>
429+
저작 도구
430+
</button>
431+
<button
432+
type="button"
433+
onClick={() => setEditorTab("classic")}
434+
className={`flex-1 rounded-md px-3 py-1.5 text-sm font-medium transition-colors ${
435+
editorTab === "classic"
436+
? "bg-white text-slate-900 shadow-sm dark:bg-slate-700 dark:text-slate-100"
437+
: "text-slate-500 hover:text-slate-700 dark:text-slate-400"
438+
}`}
439+
>
440+
LaTeX 직접 입력
441+
</button>
442+
</div>
443+
444+
{/* 탭 콘텐츠 */}
445+
{editorTab === "authoring" ? (
446+
<ItemAuthoringGrid onOutputChange={handleAuthoringOutput} />
447+
) : (
448+
<FormulaEditor
449+
value={bodyLatex}
450+
onChange={setBodyLatex}
451+
label="수식"
452+
error={errors.bodyLatex}
453+
/>
454+
)}
455+
456+
{errors.bodyLatex && editorTab === "authoring" && (
457+
<p className="text-sm text-red-500" role="alert">{errors.bodyLatex}</p>
458+
)}
404459
</FormSection>
405460

406461
{/* 기본 정보 */}
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
"use client";
2+
3+
import { memo } from "react";
4+
import { Button } from "@/components/ui/button";
5+
import {
6+
Tooltip,
7+
TooltipContent,
8+
TooltipProvider,
9+
TooltipTrigger,
10+
} from "@/components/ui/tooltip";
11+
import type { AuthoringBlockType } from "./types";
12+
13+
interface AuthoringToolbarProps {
14+
readonly editorMode: "visual" | "latex";
15+
readonly onAddBlock: (type: AuthoringBlockType) => void;
16+
readonly onToggleMode: () => void;
17+
}
18+
19+
const BLOCK_BUTTONS: ReadonlyArray<{
20+
type: AuthoringBlockType;
21+
label: string;
22+
icon: string;
23+
tooltip: string;
24+
}> = [
25+
{ type: "body", label: "본문", icon: "T", tooltip: "한국어 텍스트 + 수식을 혼합 입력하는 문제 본문을 추가합니다" },
26+
{ type: "choices", label: "선택지", icon: "①", tooltip: "객관식 보기(①②③④⑤)를 추가합니다. 정답 표시 가능" },
27+
{ type: "table", label: "표", icon: "⊞", tooltip: "행/열 조절 가능한 표를 추가합니다" },
28+
{ type: "image", label: "이미지", icon: "🖼", tooltip: "그래프, 도형 등 이미지를 추가합니다" },
29+
{ type: "solution", label: "풀이", icon: "✎", tooltip: "풀이 과정을 수식과 함께 작성합니다" },
30+
];
31+
32+
const AuthoringToolbar = memo(function AuthoringToolbar({
33+
editorMode,
34+
onAddBlock,
35+
onToggleMode,
36+
}: AuthoringToolbarProps) {
37+
return (
38+
<TooltipProvider delayDuration={300}>
39+
<div className="flex items-center justify-between rounded-lg border border-slate-200 bg-slate-50 px-3 py-2 dark:border-slate-700 dark:bg-slate-800">
40+
{/* 블록 추가 버튼들 */}
41+
<div className="flex items-center gap-1">
42+
<span className="mr-2 text-xs font-medium text-slate-500 dark:text-slate-400">블록 추가:</span>
43+
{BLOCK_BUTTONS.map(({ type, label, icon, tooltip }) => (
44+
<Tooltip key={type}>
45+
<TooltipTrigger asChild>
46+
<Button
47+
type="button"
48+
variant="ghost"
49+
size="sm"
50+
onClick={() => onAddBlock(type)}
51+
className="h-7 gap-1 px-2 text-xs"
52+
>
53+
<span>{icon}</span>
54+
<span className="hidden sm:inline">{label}</span>
55+
</Button>
56+
</TooltipTrigger>
57+
<TooltipContent side="bottom">
58+
<p>{tooltip}</p>
59+
</TooltipContent>
60+
</Tooltip>
61+
))}
62+
</div>
63+
64+
{/* 수식 입력 모드 토글 */}
65+
<Tooltip>
66+
<TooltipTrigger asChild>
67+
<Button
68+
type="button"
69+
variant="outline"
70+
size="sm"
71+
onClick={onToggleMode}
72+
className="h-7 gap-1.5 px-3 text-xs"
73+
>
74+
{editorMode === "visual" ? (
75+
<>
76+
<span className="font-mono">Tx</span>
77+
<span>LaTeX 모드</span>
78+
</>
79+
) : (
80+
<>
81+
<span>fx</span>
82+
<span>시각 모드</span>
83+
</>
84+
)}
85+
</Button>
86+
</TooltipTrigger>
87+
<TooltipContent side="bottom">
88+
<p>{editorMode === "visual"
89+
? "수식을 LaTeX 코드로 직접 편집합니다 (고급)"
90+
: "기호 팔레트와 수식 조립기를 사용합니다 (권장)"
91+
}</p>
92+
</TooltipContent>
93+
</Tooltip>
94+
</div>
95+
</TooltipProvider>
96+
);
97+
});
98+
99+
export { AuthoringToolbar };

0 commit comments

Comments
 (0)