Initial Commit

This commit is contained in:
2026-09-16 17:22:14 +09:00
commit 858ee9e9da
335 changed files with 123898 additions and 0 deletions
+213
View File
@@ -0,0 +1,213 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>CodeAssist Tauri · Rust 구조 안내서</title>
<style>
:root { --bg:#f4f6fb; --paper:#fff; --ink:#15203b; --muted:#65708a; --line:#dce2ef; --blue:#1428a0; --soft:#edf1ff; --code:#10182f; --accent:#ffb000; }
[data-theme="dark"] { --bg:#0d1220; --paper:#151d31; --ink:#edf1ff; --muted:#a7b1cb; --line:#2b3753; --soft:#202b4e; --code:#090e1b; --blue:#8ea0ff; --accent:#ffd166; }
* { box-sizing:border-box } html { scroll-behavior:smooth } body { margin:0; background:var(--bg); color:var(--ink); font-family:Pretendard,"Noto Sans KR","Segoe UI",sans-serif; line-height:1.72 }
#progress { position:fixed; inset:0 auto auto 0; z-index:20; height:3px; width:0; background:var(--blue) }
.tools { position:fixed; z-index:30; top:16px; right:18px; display:flex; gap:8px } button { border:1px solid var(--line); background:var(--paper); color:var(--ink); border-radius:999px; padding:8px 13px; cursor:pointer; font-weight:700 }
header { background:linear-gradient(135deg,#0c174a,#1428a0 64%,#314bd2); color:#fff; padding:76px max(24px,calc((100vw - 1160px)/2)) 58px }
header .eyebrow { letter-spacing:.18em; text-transform:uppercase; font-size:12px; opacity:.74 } h1 { font-size:clamp(36px,6vw,76px); line-height:1.05; margin:12px 0 18px; letter-spacing:-.045em } header p { max-width:760px; font-size:18px; opacity:.9 }
.layout { max-width:1160px; margin:0 auto; display:grid; grid-template-columns:230px minmax(0,1fr); gap:32px; padding:32px 24px 90px }
nav { position:sticky; top:22px; align-self:start; background:var(--paper); border:1px solid var(--line); border-radius:18px; padding:16px }
nav strong { display:block; margin:0 8px 8px } nav a { display:block; color:var(--muted); text-decoration:none; padding:7px 9px; border-radius:8px; font-size:14px } nav a:hover,nav a.active { background:var(--soft); color:var(--blue); font-weight:800 }
main { min-width:0 } section { scroll-margin-top:24px; background:var(--paper); border:1px solid var(--line); border-radius:24px; padding:clamp(24px,4vw,46px); margin-bottom:24px; box-shadow:0 14px 40px rgba(20,40,160,.055) }
h2 { font-size:clamp(25px,3vw,38px); letter-spacing:-.035em; margin:0 0 12px } h3 { margin:26px 0 8px; font-size:19px } p { margin:8px 0 16px } .tldr { font-size:20px; line-height:1.5; color:var(--blue); font-weight:800; margin-bottom:26px }
.badges { display:flex; flex-wrap:wrap; gap:8px; margin:20px 0 } .badge { border:1px solid var(--line); background:var(--soft); border-radius:999px; padding:6px 11px; font-size:13px; font-weight:750 }
.card-grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(210px,1fr)); gap:12px } .card { border:1px solid var(--line); border-radius:15px; padding:18px; background:color-mix(in srgb,var(--soft) 42%,var(--paper)) } .card h3 { margin:0 0 6px } .card p { color:var(--muted); margin:0; font-size:14px }
.diagram { display:grid; gap:10px; margin:24px 0 } .node { border:1px solid var(--line); border-left:5px solid var(--blue); border-radius:12px; padding:14px 16px; background:var(--soft) } .node b { display:block } .node small { color:var(--muted) } .arrow { text-align:center; color:var(--blue); font-weight:900 }
.flow-row { display:grid; grid-template-columns:1fr auto 1fr auto 1fr; align-items:stretch; gap:9px } .flow-row .node { display:flex; flex-direction:column; justify-content:center } .flow-row .arrow { align-self:center }
.tree { font-family:Consolas,monospace; white-space:pre-wrap; background:var(--code); color:#dce5ff; padding:22px; border-radius:14px; overflow:auto; line-height:1.65 }
pre { position:relative; background:var(--code); color:#dce5ff; border-radius:14px; padding:44px 20px 20px; overflow:auto; line-height:1.55; font-size:13px } pre::before { content:attr(data-lang); position:absolute; top:12px; left:16px; color:#8ea0c8; font:700 11px/1 sans-serif; text-transform:uppercase } .copy { position:absolute; top:8px; right:8px; border:0; color:#dce5ff; background:#263253; padding:6px 9px; font-size:11px }
table { width:100%; border-collapse:collapse; font-size:14px } th,td { border-bottom:1px solid var(--line); text-align:left; padding:11px 8px; vertical-align:top } th { color:var(--muted) } code:not(pre code) { background:var(--soft); color:var(--blue); border-radius:5px; padding:2px 5px }
.callout { border-left:4px solid var(--accent); background:color-mix(in srgb,var(--accent) 10%,var(--paper)); padding:12px 16px; border-radius:0 10px 10px 0 }
.slides .layout { display:block; max-width:none; padding:0 } .slides nav { display:none } .slides header,.slides section { display:none; width:100vw; height:100vh; border:0; border-radius:0; margin:0; padding:9vh 9vw; overflow:auto } .slides header.active,.slides section.active { display:block } .slides section h2 { font-size:18px; color:var(--muted) } .slides .tldr { font-size:clamp(28px,4.4vw,62px); line-height:1.22; max-width:1100px; margin:5vh 0 } .slide-no { display:none; position:fixed; bottom:20px; right:28px; z-index:50; color:var(--muted) } .slides .slide-no { display:block }
@media(max-width:800px){ .layout{display:block}.toc{position:relative;top:auto;margin-bottom:24px}.flow-row{grid-template-columns:1fr}.flow-row .arrow{transform:rotate(90deg)}.tools{top:8px;right:8px} }
</style>
</head>
<body>
<div id="progress"></div>
<div class="tools"><button id="theme">테마</button><button id="present">발표 모드</button></div>
<header id="cover">
<div class="eyebrow">Beginner's architecture guide</div>
<h1>CodeAssist<br>Tauri · Rust 입문 지도</h1>
<p>Rust와 Tauri를 처음 보는 사람을 위한 현재 코드 안내서임. 로그인 뒤 Snap과 Snippet을 열고, 전역 단축키·캡처·붙여넣기가 Windows에서 실행되는 과정을 실제 파일 순서대로 설명함.</p>
<p>생성일: 2026-09-16</p>
</header>
<div class="layout">
<nav class="toc"><strong>목차</strong>
<a href="#overview">한눈에</a><a href="#background">개발 배경</a><a href="#impact">기대효과</a><a href="#architecture">시스템 구조</a><a href="#features">핵심 기능</a><a href="#pages">전체 화면 지도</a><a href="#scenario">주요 흐름</a><a href="#structure">코드 구조</a><a href="#code-notes">핵심 코드</a><a href="#stack">기술 스택</a>
</nav>
<main>
<section id="overview"><h2>한눈에</h2>
<p class="tldr">React가 화면을 그리고, Rust가 데스크톱 앱을 운영하며, Tauri가 둘 사이의 다리가 됨.</p>
<p>화면 코드는 <code>2_frontend</code>, 데스크톱 실행부와 Windows 기능은 <code>4_rust_tauri</code>에 있음. 현재 화면은 로그인, Snap, Snippet만 남아 있음. Tauri가 React를 WebView 안에 띄우고 두 영역의 통신을 연결함.</p>
<div class="badges"><span class="badge">Tauri 2</span><span class="badge">Rust 2021</span><span class="badge">React + TypeScript</span><span class="badge">Windows API</span><span class="badge">SQLite</span><span class="badge">WebView</span></div>
<div class="callout"><strong>처음 볼 때 기준점</strong><br><code>main.rs</code>는 시작 버튼, <code>lib.rs</code>는 조립 설명서, <code>bridge/</code>는 프론트 통역사, <code>shell/</code>은 운영체제 작업자라고 보면 됨.</div>
</section>
<section id="background"><h2>개발 배경 / 문제정의</h2>
<p class="tldr">브라우저만으로 할 수 없는 데스크톱 기능을 React 화면에 붙이는 것이 이 Rust 영역의 목적임.</p>
<p>CodeAssist는 일반 웹페이지와 달리 앱이 화면 뒤에 있어도 전역 단축키로 나타나야 함. 시스템 트레이에 머물고, 다른 프로그램을 기억했다가 코드를 붙여넣으며, 화면 영역도 캡처해야 함. 이런 일은 브라우저 권한 밖이므로 Rust가 맡음.</p>
<p>대상 독자는 React 경험도 Rust 경험도 없어도 됨. 소유권 같은 깊은 문법보다 “어느 파일이 언제 불리고, 데이터가 어느 방향으로 이동하는가”를 먼저 잡는 데 초점을 둠.</p>
</section>
<section id="impact"><h2>기대효과 / 임팩트</h2>
<p class="tldr">이 지도를 읽고 나면 기능 이름을 보고 수정할 파일과 통신 방향을 먼저 짚을 수 있음.</p>
<ul><li><code>src-tauri</code> 아래 설정과 Rust 파일의 역할을 구분함.</li><li>React의 <code>invoke</code>가 어떤 Rust 함수로 연결되는지 추적함.</li><li>Rust 이벤트가 React 화면에 도착하는 반대 흐름도 이해함.</li><li>새 전역 단축키, 창 동작, 저장 기능을 어디에 추가할지 판단할 수 있음.</li></ul>
</section>
<section id="architecture"><h2>시스템 구조</h2>
<p class="tldr">한 프로세스 안에 WebView 화면과 Rust 호스트가 함께 있고, 명령과 이벤트가 경계를 넘음.</p>
<div class="flow-row"><div class="node"><b>React SPA</b><small>화면·라우팅·사용자 입력<br>2_frontend</small></div><div class="arrow"></div><div class="node"><b>Tauri bridge</b><small>invoke: JS → Rust<br>event: Rust → JS</small></div><div class="arrow"></div><div class="node"><b>Rust host</b><small>창·단축키·트레이·캡처<br>SQLite·Windows API</small></div></div>
<h3>부팅 순서</h3>
<div class="diagram"><div class="node"><b>1. main.rs</b><small>운영체제가 실행 파일의 main 함수를 호출함</small></div><div class="arrow"></div><div class="node"><b>2. lib.rs의 run()</b><small>Tauri Builder에 plugin, state, command, setup을 차례로 등록함</small></div><div class="arrow"></div><div class="node"><b>3. tauri.conf.json</b><small>개발 서버 또는 빌드된 React 파일을 main WebView에 띄움</small></div><div class="arrow"></div><div class="node"><b>4. setup</b><small>트레이를 만들고 전역 단축키를 등록한 뒤 이벤트 루프에 들어감</small></div></div>
</section>
<section id="features"><h2>핵심 기능</h2>
<p class="tldr">사용자 기능은 로그인·Snap·Snippet 세 축이고, Rust는 이 화면들이 데스크톱처럼 동작하게 만듦.</p>
<div class="card-grid"><div class="card"><h3>로그인과 세션</h3><p>보호된 도구에 들어가기 전 사용자를 확인하고 토큰 갱신과 만료 안내를 처리함.</p></div><div class="card"><h3>Snap</h3><p>대화 목록, 새 질문, 스트리밍 답변, 코드 탐색, 이미지 첨부를 한 흐름으로 제공함.</p></div><div class="card"><h3>Snippet</h3><p>SQLite의 코드 조각을 검색·미리보기·편집하고 원래 프로그램에 붙여넣음.</p></div><div class="card"><h3>전역 단축키</h3><p>Ctrl+Shift+8은 Snap, 7은 Snippet, 9는 영역 캡처를 실행함.</p></div><div class="card"><h3>화면 캡처</h3><p>Windows 오버레이에서 고른 영역을 PNG data URL로 바꿔 새 Snap 질문에 붙임.</p></div><div class="card"><h3>창과 트레이</h3><p>창 표시·숨김·크기 복원·항상 위·단일 실행을 Rust에서 관리함.</p></div><div class="card"><h3>대상 앱 붙여넣기</h3><p>CodeAssist를 열기 직전의 프로그램을 기억하고 선택한 스니펫을 돌려보냄.</p></div></div>
</section>
<section id="pages"><h2>전체 화면 지도</h2>
<p class="tldr">화면은 로그인과 Snap·Snippet에만 집중되며 캡처는 별도 route 없는 Rust 기능임.</p>
<p>Rust 쪽에서 직접 소환하는 중심 화면은 Snap과 Snippet임. 캡처는 독립 페이지가 아니라 Rust 오버레이 기능이며, 완료되면 <code>/snap/new</code> 화면으로 이미지를 보냄. 로그인은 보호된 두 도구로 들어가기 위한 입구임.</p>
<table><thead><tr><th>주소</th><th>화면</th><th>역할</th></tr></thead><tbody>
<tr><td><code>/login</code></td><td>로그인</td><td>인증되지 않은 사용자의 입구임</td></tr>
<tr><td><code>/snap</code></td><td>Snap 대화 목록</td><td>대화 목록과 검색 결과를 보여줌</td></tr>
<tr><td><code>/snap/new</code></td><td>새 Snap 대화</td><td>질문과 캡처·클립보드 이미지를 첨부해 새 세션을 시작함</td></tr>
<tr><td><code>/snap/s/:id</code></td><td>Snap 대화</td><td>특정 세션의 메시지와 스트리밍 답변을 보여줌</td></tr>
<tr><td><code>/snippet</code></td><td>Snippet Palette</td><td>코드 조각을 검색·미리보기·편집하고 원래 앱에 붙여넣음</td></tr>
<tr><td>별도 주소 없음</td><td>영역 캡처 오버레이</td><td>Ctrl+Shift+9로 Rust가 띄우며 완료 뒤 <code>/snap/new</code>로 이동함</td></tr>
</tbody></table>
<div class="callout"><strong>기본 이동</strong><br>로그인 성공 뒤와 알 수 없는 주소로 접근했을 때 모두 <code>/snap</code>으로 이동함.</div>
</section>
<section id="scenario"><h2>주요 시나리오 / 데이터 흐름</h2>
<p class="tldr">화면 캡처는 Windows 입력에서 시작해 Rust 이벤트를 거쳐 React 첨부 이미지로 끝남.</p>
<table><thead><tr><th>순서</th><th>담당</th><th>무슨 일이 생기는가</th></tr></thead><tbody><tr><td>1</td><td>hotkey.rs</td><td>Ctrl+Shift+9 입력을 감지하고 <code>on_hotkey</code>를 부름</td></tr><tr><td>2</td><td>lib.rs</td><td><code>capture::start</code>에 완료 콜백을 넘김</td></tr><tr><td>3</td><td>capture.rs</td><td>별도 thread에서 전체 화면 오버레이와 Windows 메시지 루프를 실행함</td></tr><tr><td>4</td><td>capture.rs</td><td>선택 영역 픽셀을 BGRA에서 RGBA로 바꾸고 PNG·Base64로 인코딩함</td></tr><tr><td>5</td><td>bridge/mod.rs</td><td><code>navigate</code><code>capture.image</code> 이벤트를 <code>bridge</code> 채널로 보냄</td></tr><tr><td>6</td><td>React bridge</td><td>이벤트를 받아 새 채팅으로 이동하고 Composer에 이미지를 붙임</td></tr></tbody></table>
<h3>반대 방향: 스니펫 목록 요청</h3>
<div class="flow-row"><div class="node"><b>React</b><small>invoke("snippets_list")</small></div><div class="arrow"></div><div class="node"><b>commands.rs</b><small>등록된 command 실행</small></div><div class="arrow"></div><div class="node"><b>snippets.rs</b><small>SQLite 조회 후 JSON 반환</small></div></div>
</section>
<section id="structure"><h2>코드 구조 지도</h2>
<p class="tldr">프론트는 화면별 feature로, Rust는 조립·통신·운영체제 기능으로 나뉨.</p>
<div class="tree">2_frontend/src/
├─ routes.tsx 로그인·Snap·Snippet route 트리
├─ features/
│ ├─ auth/ 로그인·세션 확인·자동 갱신
│ ├─ snap/ 대화 목록·새 대화·스트리밍·이미지 입력
│ └─ snippets/ 검색·편집·미리보기 UI
├─ lib/bridge/ invoke와 event를 감싸는 TypeScript 통신부
└─ shared/components/
├─ ProtectedRoute.tsx 로그인하지 않은 접근을 차단함
└─ PaletteShell.tsx Snap·Snippet 공통 수명과 세션을 관리함
4_rust_tauri/
├─ package.json Tauri CLI 실행 명령
├─ docs-lib/ 사용 중인 라이브러리 참고 문서
└─ src-tauri/
├─ Cargo.toml Rust 패키지와 dependency 목록
├─ tauri.conf.json 제품명·창·React 빌드 연결 설정
├─ capabilities/default.json WebView에 허용할 Tauri 권한
├─ build.rs Tauri 빌드 준비 코드
└─ src/
├─ main.rs 실행 파일 진입점
├─ lib.rs 앱 전체 조립과 기능별 동작 결정
├─ bridge/
│ ├─ mod.rs Rust → React 이벤트 타입
│ ├─ commands.rs React → Rust command
│ └─ snippets.rs SQLite 저장소
└─ shell/
├─ hotkey.rs 전역 단축키 등록
├─ tray.rs 시스템 트레이
├─ window.rs 메인 창 상태와 크기
├─ paste.rs 다른 앱·클립보드·키 입력
└─ capture.rs 영역 선택과 PNG 캡처</div>
<p>처음에는 <code>routes.tsx → PaletteShell → lib.rs → bridge 또는 shell</code> 순서로 읽는 편이 좋음. <code>bridge</code>는 “화면과 무엇을 주고받는가”, <code>shell</code>은 “Windows에서 실제로 어떻게 하는가”에 답함.</p>
</section>
<section id="code-notes"><h2>핵심 코드 해설 — 왜 이렇게 짰나</h2>
<p class="tldr">Rust 코드는 함수, 타입, 성공·실패 표현만 먼저 알아도 이 프로젝트의 큰 흐름을 읽을 수 있음.</p>
<p><code>fn</code>은 함수, <code>let</code>은 변수, <code>pub</code>은 다른 모듈에서도 쓸 수 있다는 뜻임. <code>&amp;AppHandle</code><code>&amp;</code>는 값을 넘겨주는 대신 잠깐 빌려 쓴다는 표시임. <code>match</code>는 값에 따라 갈라지는 분기이고, <code>Result&lt;T, E&gt;</code>는 성공 <code>Ok(T)</code> 또는 실패 <code>Err(E)</code>를 담음.</p>
<pre data-lang="rust"><code>fn show_palette(app: &amp;AppHandle) -&gt; tauri::Result&lt;()&gt; {
window::show(app)?;
Ok(())
}</code></pre>
<p class="tldr">main.rs를 얇게 둬서 실행과 앱 구성을 분리함.</p>
<p><code>main</code>은 Rust 실행 파일이 반드시 가지는 시작 함수임. 실제 구성은 library의 <code>run</code>에 맡겨 테스트와 플랫폼 진입점을 다루기 쉬움.</p>
<pre data-lang="rust"><code>fn main() {
codeassist_tauri_lib::run()
}</code></pre>
<p class="tldr">Builder 체인은 앱에 필요한 부품을 등록한 뒤 한 번에 실행함.</p>
<p><code>.plugin</code>은 Tauri 확장 기능, <code>.manage</code>는 여러 command가 함께 쓸 상태, <code>.invoke_handler</code>는 React가 부를 수 있는 함수 목록임. 마지막 <code>.build</code> 전까지는 설정을 모으는 단계임.</p>
<pre data-lang="rust"><code>tauri::Builder::default()
.plugin(tauri_plugin_global_shortcut::Builder::new().build())
.manage(commands::RouteState::default())
.manage(paste::PasteState::default())
.invoke_handler(tauri::generate_handler![
commands::window_hide,
commands::report_route,
commands::snippets_list,
])
.setup(|app| { /* 시작 직후 준비 작업 */ Ok(()) })
.build(tauri::generate_context!())
.expect("Tauri 앱 실행 실패");</code></pre>
<p class="tldr">#[tauri::command]가 붙은 함수만 React의 invoke 요청을 받을 수 있음.</p>
<p><code>AppHandle</code>은 앱 전체 조작 손잡이이고, <code>State</code>는 Builder에서 등록한 공유 상태를 꺼냄. <code>Result&lt;T, String&gt;</code>은 성공값 또는 화면에 전달할 오류를 표현함.</p>
<pre data-lang="rust"><code>#[tauri::command]
pub fn report_route(
app: AppHandle,
path: String,
state: State&lt;'_, RouteState&gt;,
) -&gt; Result&lt;(), String&gt; {
let mut routes = state.lock()
.map_err(|_| "현재 화면 상태를 읽지 못했어")?;
routes.report(&amp;path);
Ok(())
}</code></pre>
<p class="tldr">Rust enum 하나로 React에 보낼 이벤트 모양을 제한함.</p>
<p><code>serde</code>가 enum을 JSON으로 바꿈. <code>tag = "type"</code> 때문에 결과에 <code>type</code> 필드가 생기고, React는 이 값으로 이벤트 종류를 나눔.</p>
<pre data-lang="rust"><code>#[derive(Serialize, Clone)]
#[serde(tag = "type")]
pub enum Push {
#[serde(rename = "navigate")]
Navigate { path: String },
#[serde(rename = "capture.image")]
CaptureImage {
#[serde(rename = "dataUrl")]
data_url: String,
},
}</code></pre>
<p class="tldr">캡처는 UI를 멈추지 않도록 별도 thread에서 실행함.</p>
<p>Windows 메시지 루프는 선택이 끝날 때까지 기다림. 메인 thread에서 실행하면 React 창까지 얼 수 있으므로 <code>std::thread::spawn</code>으로 분리함. <code>AtomicBool</code>은 캡처 창이 두 개 생기는 것을 막음.</p>
<pre data-lang="rust"><code>if CAPTURING.swap(true, Ordering::AcqRel) {
return;
}
std::thread::spawn(move || {
let result = run_overlay()
.and_then(|rect| rect.map(capture_png_data_url).transpose());
CAPTURING.store(false, Ordering::Release);
// 성공 결과를 콜백으로 전달함
});</code></pre>
</section>
<section id="stack"><h2>기술 스택 &amp; 선택 이유</h2>
<p class="tldr">UI 생산성은 웹 스택에서 얻고, 시스템 접근은 Rust와 전용 crate가 담당함.</p>
<table><thead><tr><th>기술</th><th>현재 역할</th></tr></thead><tbody><tr><td>Tauri 2</td><td>WebView 창, command/event 통신, 앱 생명주기, bundle을 제공함</td></tr><tr><td>Rust</td><td>앱 조립, 상태, 오류 처리, Windows 기능을 안전한 함수 경계로 묶음</td></tr><tr><td>React + TypeScript</td><td>실제 화면, 라우팅, 채팅과 스니펫 사용자 경험을 담당함</td></tr><tr><td>windows crate</td><td>GDI 캡처, 클립보드, 다른 창 탐색, 키 입력 같은 Win32 API를 호출함</td></tr><tr><td>rusqlite</td><td>별도 DB 서버 없이 로컬 스니펫을 저장함</td></tr><tr><td>serde</td><td>Rust 구조체와 enum을 프론트가 읽는 JSON으로 변환함</td></tr></tbody></table>
</section>
</main></div>
<div class="slide-no" id="slideNo"></div>
<script>
const root=document.documentElement, body=document.body, sections=[document.querySelector('header'),...document.querySelectorAll('main section')];
const saved=localStorage.getItem('explainer-theme'); if(saved) root.dataset.theme=saved;
document.querySelector('#theme').onclick=()=>{const next=root.dataset.theme==='dark'?'light':'dark';root.dataset.theme=next;localStorage.setItem('explainer-theme',next)};
document.querySelectorAll('pre').forEach(pre=>{const b=document.createElement('button');b.className='copy';b.textContent='복사';b.onclick=async()=>{await navigator.clipboard.writeText(pre.querySelector('code').innerText);b.textContent='완료';setTimeout(()=>b.textContent='복사',1000)};pre.appendChild(b)});
const links=[...document.querySelectorAll('nav a')]; const observer=new IntersectionObserver(es=>es.forEach(e=>{if(e.isIntersecting){links.forEach(a=>a.classList.toggle('active',a.hash==='#'+e.target.id))}}),{rootMargin:'-25% 0px -65%'}); document.querySelectorAll('main section').forEach(s=>observer.observe(s));
addEventListener('scroll',()=>{const max=document.documentElement.scrollHeight-innerHeight;document.querySelector('#progress').style.width=(max?scrollY/max*100:0)+'%'});
let slide=0; function render(){sections.forEach((s,i)=>s.classList.toggle('active',i===slide));document.querySelector('#slideNo').textContent=`${slide+1} / ${sections.length}`}
function toggleSlides(){body.classList.toggle('slides');document.querySelector('#present').textContent=body.classList.contains('slides')?'읽기 모드':'발표 모드';slide=0;render()}
document.querySelector('#present').onclick=toggleSlides; addEventListener('keydown',e=>{if(!body.classList.contains('slides'))return;if(['ArrowRight',' '].includes(e.key)){e.preventDefault();slide=Math.min(sections.length-1,slide+1);render()}if(e.key==='ArrowLeft'){slide=Math.max(0,slide-1);render()}if(e.key==='Home'){slide=0;render()}if(e.key==='End'){slide=sections.length-1;render()}if(e.key==='Escape')toggleSlides()});
</script>
</body>
</html>