feat: improve lecture slide viewer and bump version to 1.2.28
This commit is contained in:
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "lms",
|
"name": "lms",
|
||||||
"version": "1.2.26",
|
"version": "1.2.28",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "lms",
|
"name": "lms",
|
||||||
"version": "1.2.26",
|
"version": "1.2.28",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@electron-toolkit/preload": "^3.0.1",
|
"@electron-toolkit/preload": "^3.0.1",
|
||||||
"@electron-toolkit/utils": "^4.0.0",
|
"@electron-toolkit/utils": "^4.0.0",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "lms",
|
"name": "lms",
|
||||||
"version": "1.2.26",
|
"version": "1.2.28",
|
||||||
"description": "Autonomy Studio LMS Electron app",
|
"description": "Autonomy Studio LMS Electron app",
|
||||||
"main": "./out/main/index.js",
|
"main": "./out/main/index.js",
|
||||||
"author": {
|
"author": {
|
||||||
|
|||||||
@@ -31,3 +31,13 @@ test('lecture controls have English translations', () => {
|
|||||||
const en = require('../src/renderer/src/i18n/en.json');
|
const en = require('../src/renderer/src/i18n/en.json');
|
||||||
for (const key of ['슬라이드·퀴즈 열기', '강의 닫기', '슬라이드', '퀴즈', '답안 제출', '다시 풀기']) assert.ok(en[key], key);
|
for (const key of ['슬라이드·퀴즈 열기', '강의 닫기', '슬라이드', '퀴즈', '답안 제출', '다시 풀기']) assert.ok(en[key], key);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('presentation slides render key messages, examples and legacy body as safe text', () => {
|
||||||
|
const source = fs.readFileSync(require.resolve('../src/renderer/src/features/curriculum/LectureSlideCanvas.tsx'), 'utf8');
|
||||||
|
const exports = {};
|
||||||
|
const output = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, jsx: ts.JsxEmit.ReactJSX, esModuleInterop: true } }).outputText;
|
||||||
|
vm.runInNewContext(output, { exports, require: name => name === '../../i18n' ? { t: value => value } : require(name) });
|
||||||
|
const html = renderToStaticMarkup(React.createElement(exports.default, { slide: { id: 's1', type: 'content', title: '속도', subtitle: '핵심 메시지', body: '기존 본문', bullets: ['요점'], example: { label: '실습 예시', content: '<img src=x onerror=alert(1)>' } }, title: '제어 강의', index: 0, total: 4 }));
|
||||||
|
assert.match(html, /핵심 메시지/); assert.match(html, /기존 본문/); assert.match(html, /실습 예시/);
|
||||||
|
assert.doesNotMatch(html, /<img/); assert.match(html, /<img/);
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import { Code2, FlaskConical } from "lucide-react";
|
||||||
|
import { t } from "../../i18n";
|
||||||
|
|
||||||
|
export type LectureSlideData = {
|
||||||
|
id: string;
|
||||||
|
type: string;
|
||||||
|
title: string;
|
||||||
|
body: string;
|
||||||
|
bullets: string[];
|
||||||
|
subtitle?: string;
|
||||||
|
example?: { label: string; content: string };
|
||||||
|
code?: string;
|
||||||
|
language?: string;
|
||||||
|
};
|
||||||
|
export default function LectureSlideCanvas({
|
||||||
|
slide,
|
||||||
|
title,
|
||||||
|
index,
|
||||||
|
total,
|
||||||
|
}: {
|
||||||
|
slide: LectureSlideData;
|
||||||
|
title: string;
|
||||||
|
index: number;
|
||||||
|
total: number;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<article
|
||||||
|
aria-label={slide.title}
|
||||||
|
className="relative flex min-h-[420px] min-w-0 flex-col overflow-hidden rounded-lg border border-[#d4dfdc] bg-[#fcfdfc] text-[#192b29] shadow-lg md:aspect-video md:min-h-0"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`h-1.5 shrink-0 ${slide.type === "summary" ? "bg-[#b6504b]" : "bg-[#167866]"}`}
|
||||||
|
/>
|
||||||
|
<header className="flex shrink-0 items-center justify-between gap-4 px-5 pt-5 md:px-10 md:pt-7">
|
||||||
|
<p
|
||||||
|
className="min-w-0 truncate text-xs font-bold text-[#526a64]"
|
||||||
|
title={title}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</p>
|
||||||
|
<span className="shrink-0 text-xs tabular-nums text-[#526a64]">
|
||||||
|
{String(index + 1).padStart(2, "0")} /{" "}
|
||||||
|
{String(total).padStart(2, "0")}
|
||||||
|
</span>
|
||||||
|
</header>
|
||||||
|
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-5 pb-6 pt-5 md:px-10 md:pb-9 md:pt-6">
|
||||||
|
<h3 className="break-words text-[28px] font-bold leading-tight [overflow-wrap:anywhere] md:text-[40px]">
|
||||||
|
{slide.title}
|
||||||
|
</h3>
|
||||||
|
{slide.subtitle && (
|
||||||
|
<p className="mt-4 border-l-[3px] border-[#167866] pl-4 text-base font-semibold leading-relaxed text-[#226354] md:text-[22px]">
|
||||||
|
{slide.subtitle}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{slide.body && (
|
||||||
|
<p className="mt-5 whitespace-pre-wrap break-words text-sm leading-7 text-[#425a54] md:text-lg">
|
||||||
|
{slide.body}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{slide.bullets.length > 0 && (
|
||||||
|
<ul className="mt-6 space-y-3 text-sm leading-8 md:text-[20px]">
|
||||||
|
{slide.bullets.map((bullet, i) => (
|
||||||
|
<li key={i} className="flex min-w-0 gap-3">
|
||||||
|
<span className="mt-2.5 h-1.5 w-1.5 shrink-0 rounded-full bg-[#167866]" />
|
||||||
|
<span className="min-w-0 break-words [overflow-wrap:anywhere]">
|
||||||
|
{bullet}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
{slide.example?.content && (
|
||||||
|
<aside className="mt-6 border-l-[3px] border-[#b6504b] bg-[#f5efed] px-4 py-3">
|
||||||
|
<h4 className="flex items-center gap-2 text-xs font-bold text-[#8d3f3a]">
|
||||||
|
<FlaskConical size={15} />
|
||||||
|
{slide.example.label || t("예시·실습")}
|
||||||
|
</h4>
|
||||||
|
<p className="mt-2 whitespace-pre-wrap break-words text-sm leading-7 text-[#4e3936] md:text-base">
|
||||||
|
{slide.example.content}
|
||||||
|
</p>
|
||||||
|
</aside>
|
||||||
|
)}
|
||||||
|
{slide.code && (
|
||||||
|
<section className="mt-5 overflow-hidden rounded-md border border-[#c8d4d0] bg-[#202b29] text-[#eef7f3]">
|
||||||
|
<div className="flex items-center gap-2 border-b border-white/10 px-4 py-2 text-xs text-[#a8c6b9]">
|
||||||
|
<Code2 size={14} />
|
||||||
|
{slide.language || t("코드")}
|
||||||
|
</div>
|
||||||
|
<pre className="overflow-x-auto p-4 text-xs leading-6">
|
||||||
|
<code>{slide.code}</code>
|
||||||
|
</pre>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -9,26 +9,22 @@ import {
|
|||||||
RotateCcw,
|
RotateCcw,
|
||||||
Send,
|
Send,
|
||||||
X,
|
X,
|
||||||
|
Maximize2,
|
||||||
|
Minimize2,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
import LectureSlideCanvas, {
|
||||||
|
type LectureSlideData,
|
||||||
|
} from "./LectureSlideCanvas";
|
||||||
import { t, useLocaleRender } from "../../i18n";
|
import { t, useLocaleRender } from "../../i18n";
|
||||||
import { LMS_API_BASE_URL } from "../../config/appConfig";
|
import { LMS_API_BASE_URL } from "../../config/appConfig";
|
||||||
|
|
||||||
type Slide = {
|
|
||||||
id: string;
|
|
||||||
type: string;
|
|
||||||
title: string;
|
|
||||||
body: string;
|
|
||||||
bullets: string[];
|
|
||||||
code?: string;
|
|
||||||
language?: string;
|
|
||||||
};
|
|
||||||
type Question = { id: string; question: string; options: string[] };
|
type Question = { id: string; question: string; options: string[] };
|
||||||
type Lecture = {
|
type Lecture = {
|
||||||
id: string;
|
id: string;
|
||||||
version: number;
|
version: number;
|
||||||
title: string;
|
title: string;
|
||||||
objectives: string[];
|
objectives: string[];
|
||||||
slides: Slide[];
|
slides: LectureSlideData[];
|
||||||
quiz: Question[];
|
quiz: Question[];
|
||||||
progress: { lastSlide: number; completedAt: string | null } | null;
|
progress: { lastSlide: number; completedAt: string | null } | null;
|
||||||
};
|
};
|
||||||
@@ -68,6 +64,9 @@ export default function LectureViewerModal({
|
|||||||
const [grade, setGrade] = useState<Grade | null>(null);
|
const [grade, setGrade] = useState<Grade | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
const navigation = useRef({ count: 0, tab: "slides" });
|
||||||
|
navigation.current = { count: lecture?.slides.length ?? 0, tab };
|
||||||
const requestId = useRef<string | null>(null);
|
const requestId = useRef<string | null>(null);
|
||||||
const controller = useRef(new AbortController());
|
const controller = useRef(new AbortController());
|
||||||
const closeRef = useRef(onClose);
|
const closeRef = useRef(onClose);
|
||||||
@@ -127,11 +126,48 @@ export default function LectureViewerModal({
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
const keydown = (event: KeyboardEvent) => {
|
const keydown = (event: KeyboardEvent) => {
|
||||||
if (event.key === 'Tab') {
|
const editable =
|
||||||
const dialog = document.getElementById('lecture-viewer-title')?.closest('[role="dialog"]');
|
event.target instanceof HTMLElement &&
|
||||||
const nodes = Array.from(dialog?.querySelectorAll<HTMLElement>('button:not(:disabled), input:not(:disabled), select:not(:disabled), textarea:not(:disabled), [tabindex="0"]') ?? []).filter(e => e.getClientRects().length);
|
Boolean(
|
||||||
const first = nodes[0], last = nodes.at(-1);
|
event.target.closest(
|
||||||
if (first && last && ((event.shiftKey && document.activeElement === first) || (!event.shiftKey && document.activeElement === last))) { event.preventDefault(); (event.shiftKey ? last : first).focus(); }
|
'input, textarea, select, [contenteditable="true"]',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
!editable &&
|
||||||
|
navigation.current.tab === "slides" &&
|
||||||
|
["ArrowRight", "ArrowLeft", "PageDown", "PageUp"].includes(event.key)
|
||||||
|
) {
|
||||||
|
event.preventDefault();
|
||||||
|
const direction =
|
||||||
|
event.key === "ArrowRight" || event.key === "PageDown" ? 1 : -1;
|
||||||
|
setSlideIndex((current) =>
|
||||||
|
Math.min(
|
||||||
|
Math.max(0, current + direction),
|
||||||
|
Math.max(0, navigation.current.count - 1),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (event.key === "Tab") {
|
||||||
|
const dialog = document
|
||||||
|
.getElementById("lecture-viewer-title")
|
||||||
|
?.closest('[role="dialog"]');
|
||||||
|
const nodes = Array.from(
|
||||||
|
dialog?.querySelectorAll<HTMLElement>(
|
||||||
|
'button:not(:disabled), input:not(:disabled), select:not(:disabled), textarea:not(:disabled), [tabindex="0"]',
|
||||||
|
) ?? [],
|
||||||
|
).filter((e) => e.getClientRects().length);
|
||||||
|
const first = nodes[0],
|
||||||
|
last = nodes.at(-1);
|
||||||
|
if (
|
||||||
|
first &&
|
||||||
|
last &&
|
||||||
|
((event.shiftKey && document.activeElement === first) ||
|
||||||
|
(!event.shiftKey && document.activeElement === last))
|
||||||
|
) {
|
||||||
|
event.preventDefault();
|
||||||
|
(event.shiftKey ? last : first).focus();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (event.key === "Escape") {
|
if (event.key === "Escape") {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
@@ -193,7 +229,7 @@ export default function LectureViewerModal({
|
|||||||
aria-labelledby="lecture-viewer-title"
|
aria-labelledby="lecture-viewer-title"
|
||||||
>
|
>
|
||||||
<section
|
<section
|
||||||
className={`flex max-h-[94vh] w-full max-w-[1000px] min-w-0 flex-col overflow-hidden rounded-lg border shadow-2xl ${dark ? "border-white/15 bg-[#252525] text-white" : "border-[#cbd5e1] bg-white text-[#111827]"}`}
|
className={`flex w-full min-w-0 flex-col overflow-hidden rounded-lg border shadow-2xl ${expanded ? "h-[calc(100dvh-24px)] max-w-none" : "h-[94dvh] max-h-[1000px] max-w-[1440px]"} ${dark ? "border-white/15 bg-[#252525] text-white" : "border-[#cbd5e1] bg-white text-[#111827]"}`}
|
||||||
>
|
>
|
||||||
<header
|
<header
|
||||||
className={`flex items-start justify-between gap-3 border-b px-5 py-4 ${border}`}
|
className={`flex items-start justify-between gap-3 border-b px-5 py-4 ${border}`}
|
||||||
@@ -214,15 +250,26 @@ export default function LectureViewerModal({
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<button
|
<div className="flex shrink-0 items-center gap-1">
|
||||||
id="lecture-viewer-close"
|
<button
|
||||||
className="grid h-8 w-8 shrink-0 place-items-center rounded-md"
|
type="button"
|
||||||
aria-label={t("강의 닫기")}
|
className="grid h-8 w-8 place-items-center rounded-md"
|
||||||
title={t("닫기")}
|
title={expanded ? t("원래 크기") : t("전체 화면")}
|
||||||
onClick={onClose}
|
aria-label={expanded ? t("원래 크기") : t("전체 화면")}
|
||||||
>
|
onClick={() => setExpanded((value) => !value)}
|
||||||
<X size={18} />
|
>
|
||||||
</button>
|
{expanded ? <Minimize2 size={17} /> : <Maximize2 size={17} />}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
id="lecture-viewer-close"
|
||||||
|
className="grid h-8 w-8 shrink-0 place-items-center rounded-md"
|
||||||
|
aria-label={t("강의 닫기")}
|
||||||
|
title={t("닫기")}
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
<X size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
{lecture && (
|
{lecture && (
|
||||||
<nav
|
<nav
|
||||||
@@ -233,7 +280,7 @@ export default function LectureViewerModal({
|
|||||||
<button
|
<button
|
||||||
role="tab"
|
role="tab"
|
||||||
aria-selected={tab === "slides"}
|
aria-selected={tab === "slides"}
|
||||||
className={`inline-flex items-center gap-2 border-b-2 py-3 text-sm font-bold ${tab === "slides" ? dark ? "border-[#60a5fa] text-[#60a5fa]" : "border-[#2563eb] text-[#1d4ed8]" : "border-transparent"}`}
|
className={`inline-flex items-center gap-2 border-b-2 py-3 text-sm font-bold ${tab === "slides" ? (dark ? "border-[#60a5fa] text-[#60a5fa]" : "border-[#2563eb] text-[#1d4ed8]") : "border-transparent"}`}
|
||||||
onClick={() => setTab("slides")}
|
onClick={() => setTab("slides")}
|
||||||
>
|
>
|
||||||
<BookOpen size={15} />
|
<BookOpen size={15} />
|
||||||
@@ -242,7 +289,7 @@ export default function LectureViewerModal({
|
|||||||
<button
|
<button
|
||||||
role="tab"
|
role="tab"
|
||||||
aria-selected={tab === "quiz"}
|
aria-selected={tab === "quiz"}
|
||||||
className={`inline-flex items-center gap-2 border-b-2 py-3 text-sm font-bold ${tab === "quiz" ? dark ? "border-[#60a5fa] text-[#60a5fa]" : "border-[#2563eb] text-[#1d4ed8]" : "border-transparent"}`}
|
className={`inline-flex items-center gap-2 border-b-2 py-3 text-sm font-bold ${tab === "quiz" ? (dark ? "border-[#60a5fa] text-[#60a5fa]" : "border-[#2563eb] text-[#1d4ed8]") : "border-transparent"}`}
|
||||||
onClick={() => setTab("quiz")}
|
onClick={() => setTab("quiz")}
|
||||||
>
|
>
|
||||||
<ClipboardCheck size={15} />
|
<ClipboardCheck size={15} />
|
||||||
@@ -250,11 +297,11 @@ export default function LectureViewerModal({
|
|||||||
</button>
|
</button>
|
||||||
</nav>
|
</nav>
|
||||||
)}
|
)}
|
||||||
<div className="min-h-0 flex-1 overflow-y-auto px-5 py-5">
|
<div className="min-h-0 flex-1 overflow-y-auto px-3 py-4 md:px-5">
|
||||||
{error && (
|
{error && (
|
||||||
<p
|
<p
|
||||||
role="alert"
|
role="alert"
|
||||||
className={`mb-4 whitespace-pre-wrap text-sm ${dark ? 'text-[#f87171]' : 'text-[#b91c1c]'}`}
|
className={`mb-4 whitespace-pre-wrap text-sm ${dark ? "text-[#f87171]" : "text-[#b91c1c]"}`}
|
||||||
>
|
>
|
||||||
{error}
|
{error}
|
||||||
</p>
|
</p>
|
||||||
@@ -266,38 +313,41 @@ export default function LectureViewerModal({
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{lecture && tab === "slides" && slide && (
|
{lecture && tab === "slides" && slide && (
|
||||||
<article className="min-h-[340px] min-w-0">
|
<div className="grid min-w-0 gap-4 lg:grid-cols-[180px_minmax(0,1fr)]">
|
||||||
<div className={`mb-4 text-xs ${muted}`}>
|
<nav
|
||||||
{slideIndex + 1} / {lecture.slides.length}
|
aria-label={t("슬라이드 목록")}
|
||||||
</div>
|
className={`flex gap-2 overflow-x-auto border-b pb-3 lg:max-h-[620px] lg:flex-col lg:overflow-x-hidden lg:overflow-y-auto lg:border-b-0 lg:border-r lg:pb-0 lg:pr-3 ${border}`}
|
||||||
<h3 className="break-words text-2xl font-bold leading-snug">
|
|
||||||
{slide.title}
|
|
||||||
</h3>
|
|
||||||
<p
|
|
||||||
className={`mt-5 whitespace-pre-wrap break-words text-sm leading-7 ${muted}`}
|
|
||||||
>
|
>
|
||||||
{slide.body}
|
{lecture.slides.map((item, index) => (
|
||||||
</p>
|
<button
|
||||||
<ul className="mt-4 list-disc space-y-3 pl-5 text-sm leading-6">
|
type="button"
|
||||||
{slide.bullets.map((b, i) => (
|
key={item.id}
|
||||||
<li key={i} className="break-words">
|
aria-current={index === slideIndex ? "step" : undefined}
|
||||||
{b}
|
title={item.title}
|
||||||
</li>
|
onClick={() => setSlideIndex(index)}
|
||||||
|
className={`flex w-[145px] shrink-0 items-start gap-2 rounded-md border p-3 text-left text-xs leading-5 lg:w-full ${index === slideIndex ? (dark ? "border-[#61c3ab] bg-[#213a33]" : "border-[#167866] bg-[#e9f4ef] text-[#174f40]") : border}`}
|
||||||
|
>
|
||||||
|
<span className="shrink-0 font-bold tabular-nums">
|
||||||
|
{String(index + 1).padStart(2, "0")}
|
||||||
|
</span>
|
||||||
|
<span className="min-w-0 break-words">{item.title}</span>
|
||||||
|
</button>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</nav>
|
||||||
{slide.code && (
|
<LectureSlideCanvas
|
||||||
<pre
|
slide={slide}
|
||||||
className={`mt-5 overflow-x-auto rounded-md border p-4 text-xs leading-6 ${border} ${dark ? "bg-[#191919]" : "bg-[#f8fafc]"}`}
|
title={lecture.title}
|
||||||
>
|
index={slideIndex}
|
||||||
<code>{slide.code}</code>
|
total={lecture.slides.length}
|
||||||
</pre>
|
/>
|
||||||
)}
|
</div>
|
||||||
</article>
|
|
||||||
)}
|
)}
|
||||||
{lecture && tab === "quiz" && (
|
{lecture && tab === "quiz" && (
|
||||||
<div className="grid gap-6">
|
<div className="grid gap-6">
|
||||||
{grade && (
|
{grade && (
|
||||||
<p className={`text-lg font-bold ${dark ? 'text-[#34d399]' : 'text-[#15803d]'}`}>
|
<p
|
||||||
|
className={`text-lg font-bold ${dark ? "text-[#34d399]" : "text-[#15803d]"}`}
|
||||||
|
>
|
||||||
{grade.score} / {grade.total}
|
{grade.score} / {grade.total}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
@@ -318,7 +368,7 @@ export default function LectureViewerModal({
|
|||||||
{q.options.map((option, i) => (
|
{q.options.map((option, i) => (
|
||||||
<label
|
<label
|
||||||
key={i}
|
key={i}
|
||||||
className={`flex cursor-pointer items-start gap-3 rounded-md border p-3 text-sm ${result?.correctIndex === i ? dark ? "border-[#34d399]" : "border-[#15803d]" : border}`}
|
className={`flex cursor-pointer items-start gap-3 rounded-md border p-3 text-sm ${result?.correctIndex === i ? (dark ? "border-[#34d399]" : "border-[#15803d]") : border}`}
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
type="radio"
|
type="radio"
|
||||||
|
|||||||
@@ -775,5 +775,8 @@
|
|||||||
"일반 텍스트": "Plain text",
|
"일반 텍스트": "Plain text",
|
||||||
"코드": "Code",
|
"코드": "Code",
|
||||||
"표": "Table",
|
"표": "Table",
|
||||||
"강의": "Lecture"
|
"강의": "Lecture",
|
||||||
|
"전체 화면": "Expand",
|
||||||
|
"슬라이드 목록": "Slide list",
|
||||||
|
"예시·실습": "Example / Practice"
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user