diff --git a/package-lock.json b/package-lock.json index 9a92e01..089b86d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "lms", - "version": "1.2.26", + "version": "1.2.28", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "lms", - "version": "1.2.26", + "version": "1.2.28", "dependencies": { "@electron-toolkit/preload": "^3.0.1", "@electron-toolkit/utils": "^4.0.0", diff --git a/package.json b/package.json index 195dea5..cf12625 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "lms", - "version": "1.2.26", + "version": "1.2.28", "description": "Autonomy Studio LMS Electron app", "main": "./out/main/index.js", "author": { diff --git a/scripts/test-lecture-viewer.cjs b/scripts/test-lecture-viewer.cjs index 81d8ba9..efe9add 100644 --- a/scripts/test-lecture-viewer.cjs +++ b/scripts/test-lecture-viewer.cjs @@ -31,3 +31,13 @@ test('lecture controls have English translations', () => { const en = require('../src/renderer/src/i18n/en.json'); 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: '' } }, title: '제어 강의', index: 0, total: 4 })); + assert.match(html, /핵심 메시지/); assert.match(html, /기존 본문/); assert.match(html, /실습 예시/); + assert.doesNotMatch(html, / +
+
+

+ {title} +

+ + {String(index + 1).padStart(2, "0")} /{" "} + {String(total).padStart(2, "0")} + +
+
+

+ {slide.title} +

+ {slide.subtitle && ( +

+ {slide.subtitle} +

+ )} + {slide.body && ( +

+ {slide.body} +

+ )} + {slide.bullets.length > 0 && ( + + )} + {slide.example?.content && ( + + )} + {slide.code && ( +
+
+ + {slide.language || t("코드")} +
+
+              {slide.code}
+            
+
+ )} +
+ + ); +} diff --git a/src/renderer/src/features/curriculum/LectureViewerModal.tsx b/src/renderer/src/features/curriculum/LectureViewerModal.tsx index edffda7..c502254 100644 --- a/src/renderer/src/features/curriculum/LectureViewerModal.tsx +++ b/src/renderer/src/features/curriculum/LectureViewerModal.tsx @@ -9,26 +9,22 @@ import { RotateCcw, Send, X, + Maximize2, + Minimize2, } from "lucide-react"; +import LectureSlideCanvas, { + type LectureSlideData, +} from "./LectureSlideCanvas"; import { t, useLocaleRender } from "../../i18n"; 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 Lecture = { id: string; version: number; title: string; objectives: string[]; - slides: Slide[]; + slides: LectureSlideData[]; quiz: Question[]; progress: { lastSlide: number; completedAt: string | null } | null; }; @@ -68,6 +64,9 @@ export default function LectureViewerModal({ const [grade, setGrade] = useState(null); const [error, setError] = useState(null); 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(null); const controller = useRef(new AbortController()); const closeRef = useRef(onClose); @@ -127,11 +126,48 @@ export default function LectureViewerModal({ ); }); const keydown = (event: KeyboardEvent) => { - if (event.key === 'Tab') { - const dialog = document.getElementById('lecture-viewer-title')?.closest('[role="dialog"]'); - const nodes = Array.from(dialog?.querySelectorAll('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(); } + const editable = + event.target instanceof HTMLElement && + Boolean( + event.target.closest( + '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( + '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") { event.stopPropagation(); @@ -193,7 +229,7 @@ export default function LectureViewerModal({ aria-labelledby="lecture-viewer-title" >
)}
- +
+ + +
{lecture && ( )} -
+
{error && (

{error}

@@ -266,38 +313,41 @@ export default function LectureViewerModal({

)} {lecture && tab === "slides" && slide && ( -
-
- {slideIndex + 1} / {lecture.slides.length} -
-

- {slide.title} -

-

+

+ + +
)} {lecture && tab === "quiz" && (
{grade && ( -

+

{grade.score} / {grade.total}

)} @@ -318,7 +368,7 @@ export default function LectureViewerModal({ {q.options.map((option, i) => (