915 lines
32 KiB
HTML
915 lines
32 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="ko">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>바닐라 슬라이드 프레젠테이션</title>
|
|
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@400;700&family=Inter:wght@400;700;900&family=JetBrains+Mono:wght@400;700&display=swap" rel="stylesheet">
|
|
<style>
|
|
/* ===== CSS Custom Properties ===== */
|
|
:root {
|
|
--bg: #0a0a0f;
|
|
--bg-subtle: #111118;
|
|
--accent: #C6A55C;
|
|
--accent-dim: rgba(198, 165, 92, 0.15);
|
|
--accent-glow: rgba(198, 165, 92, 0.3);
|
|
--text-primary: #F0EBE0;
|
|
--text-secondary: #B8B0A0;
|
|
--text-dim: #706858;
|
|
--border: #1e1c18;
|
|
|
|
--font-kr: 'Noto Sans KR', sans-serif;
|
|
--font-en: 'Inter', sans-serif;
|
|
--font-mono: 'JetBrains Mono', monospace;
|
|
|
|
--ease-out-expo: cubic-bezier(0.16, 1, 0.3, 1);
|
|
}
|
|
|
|
/* ===== 리셋 ===== */
|
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
|
|
|
body {
|
|
font-family: var(--font-kr), var(--font-en), sans-serif;
|
|
overflow: hidden;
|
|
background: var(--bg);
|
|
color: var(--text-primary);
|
|
}
|
|
|
|
/* ===== 뷰포트: fixed 풀스크린 ===== */
|
|
.slide-viewport {
|
|
position: fixed;
|
|
inset: 0;
|
|
width: 100vw;
|
|
height: 100vh;
|
|
height: 100dvh;
|
|
background: var(--bg);
|
|
overflow: hidden;
|
|
}
|
|
|
|
.slide-viewport.animating { pointer-events: none; }
|
|
|
|
/* ===== 슬라이드: absolute 풀스크린 ===== */
|
|
.slide {
|
|
position: absolute;
|
|
inset: 0;
|
|
width: 100%;
|
|
height: 100%;
|
|
overflow: hidden;
|
|
display: flex;
|
|
flex-direction: column;
|
|
justify-content: center;
|
|
align-items: center;
|
|
padding: clamp(2rem, 5vw, 5rem);
|
|
|
|
opacity: 0;
|
|
transform: translateX(100%);
|
|
pointer-events: none;
|
|
transition:
|
|
opacity 400ms ease-in-out,
|
|
transform 450ms cubic-bezier(0.25, 0.46, 0.45, 0.94);
|
|
}
|
|
|
|
.slide.active {
|
|
opacity: 1;
|
|
transform: translateX(0);
|
|
pointer-events: auto;
|
|
z-index: 2;
|
|
}
|
|
|
|
.slide.stand-left { transform: translateX(-100%); }
|
|
|
|
/* ===== 배경 효과 ===== */
|
|
.slide::before {
|
|
content: '';
|
|
position: absolute;
|
|
inset: 0;
|
|
background: repeating-linear-gradient(0deg,
|
|
transparent, transparent 2px,
|
|
rgba(255, 255, 255, 0.005) 2px,
|
|
rgba(255, 255, 255, 0.005) 4px);
|
|
pointer-events: none;
|
|
z-index: 1;
|
|
}
|
|
|
|
.slide::after {
|
|
content: '';
|
|
position: absolute;
|
|
inset: 0;
|
|
background-image:
|
|
linear-gradient(rgba(198, 165, 92, 0.015) 1px, transparent 1px),
|
|
linear-gradient(90deg, rgba(198, 165, 92, 0.015) 1px, transparent 1px);
|
|
background-size: 40px 40px;
|
|
pointer-events: none;
|
|
}
|
|
|
|
.slide > * { position: relative; z-index: 2; }
|
|
|
|
/* ===== 콘텐츠 영역 ===== */
|
|
.slide__inner {
|
|
width: 100%;
|
|
max-width: 680px;
|
|
display: flex;
|
|
flex-direction: column;
|
|
justify-content: center;
|
|
flex: 1;
|
|
gap: clamp(0.8rem, 2vw, 1.5rem);
|
|
}
|
|
|
|
.slide__header {}
|
|
.slide__body { display: flex; flex-direction: column; justify-content: center; gap: 1rem; }
|
|
.slide__footer { font-size: clamp(0.6rem, 1vw, 0.8rem); color: var(--text-dim); text-align: right; margin-top: auto; }
|
|
|
|
/* ===== 타이포 ===== */
|
|
h1 {
|
|
font-family: var(--font-kr), var(--font-en), sans-serif;
|
|
font-size: clamp(2rem, 5vw, 4rem);
|
|
font-weight: 700;
|
|
line-height: 1.15;
|
|
color: var(--text-primary);
|
|
}
|
|
h2 {
|
|
font-family: var(--font-kr), var(--font-en), sans-serif;
|
|
font-size: clamp(1.4rem, 3vw, 2.4rem);
|
|
font-weight: 700;
|
|
line-height: 1.2;
|
|
margin-bottom: 0.8rem;
|
|
color: var(--text-primary);
|
|
}
|
|
h3 {
|
|
font-family: var(--font-kr), var(--font-en), sans-serif;
|
|
font-size: clamp(1.1rem, 2vw, 1.6rem);
|
|
font-weight: 700;
|
|
line-height: 1.3;
|
|
color: var(--text-primary);
|
|
}
|
|
p, li {
|
|
font-family: var(--font-kr), var(--font-en), sans-serif;
|
|
font-size: clamp(0.85rem, 1.5vw, 1.2rem);
|
|
line-height: 1.7;
|
|
color: var(--text-secondary);
|
|
}
|
|
ul { list-style: none; padding: 0; }
|
|
ul li {
|
|
padding: clamp(0.5rem, 0.8vw, 0.7rem) clamp(0.8rem, 1.2vw, 1rem);
|
|
background: rgba(255,255,255,0.02);
|
|
border: 1px solid var(--border);
|
|
border-radius: 4px;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.6rem;
|
|
}
|
|
ul li::before { content: '→ '; color: var(--accent); font-weight: bold; font-family: var(--font-mono); flex-shrink: 0; }
|
|
|
|
/* ===== 슬라이드 배경 변형 ===== */
|
|
.slide--intro { background: radial-gradient(ellipse at 30% 50%, rgba(198,165,92,0.06) 0%, var(--bg) 70%); }
|
|
.slide--content { background: radial-gradient(ellipse at 70% 40%, rgba(198,165,92,0.04) 0%, var(--bg) 70%); }
|
|
.slide--content-alt { background: radial-gradient(ellipse at 30% 60%, rgba(198,165,92,0.04) 0%, var(--bg) 70%); }
|
|
.slide--code { background: var(--bg); }
|
|
.slide--diagram { background: radial-gradient(ellipse at 50% 30%, rgba(198,165,92,0.05) 0%, var(--bg) 70%); }
|
|
.slide--end { background: radial-gradient(ellipse at 50% 50%, rgba(198,165,92,0.08) 0%, var(--bg) 60%); }
|
|
|
|
/* ===== reveal 애니메이션 (슬라이드 진입 시 순차 등장) ===== */
|
|
.reveal {
|
|
opacity: 0;
|
|
transform: translateY(20px);
|
|
transition: opacity 0.6s var(--ease-out-expo), transform 0.6s var(--ease-out-expo);
|
|
}
|
|
|
|
.slide.active .reveal {
|
|
opacity: 1;
|
|
transform: translateY(0);
|
|
}
|
|
|
|
.reveal:nth-child(1) { transition-delay: 0.08s; }
|
|
.reveal:nth-child(2) { transition-delay: 0.16s; }
|
|
.reveal:nth-child(3) { transition-delay: 0.24s; }
|
|
.reveal:nth-child(4) { transition-delay: 0.32s; }
|
|
.reveal:nth-child(5) { transition-delay: 0.40s; }
|
|
.reveal:nth-child(6) { transition-delay: 0.48s; }
|
|
.reveal:nth-child(7) { transition-delay: 0.56s; }
|
|
.reveal:nth-child(8) { transition-delay: 0.64s; }
|
|
|
|
/* .slide__body 내부 자식도 순차 등장 */
|
|
.slide.active .slide__body > .reveal:nth-child(1) { transition-delay: 0.15s; }
|
|
.slide.active .slide__body > .reveal:nth-child(2) { transition-delay: 0.25s; }
|
|
.slide.active .slide__body > .reveal:nth-child(3) { transition-delay: 0.35s; }
|
|
.slide.active .slide__body > .reveal:nth-child(4) { transition-delay: 0.45s; }
|
|
.slide.active .slide__body > .reveal:nth-child(5) { transition-delay: 0.55s; }
|
|
|
|
/* ul li 순차 등장 */
|
|
.slide.active ul li.reveal:nth-child(1) { transition-delay: 0.15s; }
|
|
.slide.active ul li.reveal:nth-child(2) { transition-delay: 0.25s; }
|
|
.slide.active ul li.reveal:nth-child(3) { transition-delay: 0.35s; }
|
|
.slide.active ul li.reveal:nth-child(4) { transition-delay: 0.45s; }
|
|
.slide.active ul li.reveal:nth-child(5) { transition-delay: 0.55s; }
|
|
|
|
/* ===== 코드 블록 ===== */
|
|
.code-block {
|
|
background: #111118;
|
|
border: 1px solid var(--border);
|
|
border-radius: 6px;
|
|
overflow: hidden;
|
|
font-family: var(--font-mono);
|
|
font-size: clamp(0.65rem, 1.1vw, 0.9rem);
|
|
line-height: 1.7;
|
|
counter-reset: line-number;
|
|
width: 100%;
|
|
}
|
|
.code-block::before {
|
|
content: attr(data-language);
|
|
display: block;
|
|
background: rgba(198,165,92,0.06);
|
|
color: var(--text-dim);
|
|
font-size: 0.75em;
|
|
padding: 0.4em 1em;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.1em;
|
|
font-family: var(--font-mono);
|
|
border-bottom: 1px solid var(--border);
|
|
}
|
|
.code-block code { display: block; padding: 1rem 1.2rem; overflow-x: auto; }
|
|
.code-block .line {
|
|
display: block;
|
|
padding: 2px 0 2px 3em;
|
|
position: relative;
|
|
counter-increment: line-number;
|
|
transition: background-color 0.3s ease, opacity 0.3s ease;
|
|
color: var(--text-secondary);
|
|
}
|
|
.code-block .line::before {
|
|
content: counter(line-number);
|
|
position: absolute;
|
|
left: 0;
|
|
width: 2.5em;
|
|
text-align: right;
|
|
color: #3a3830;
|
|
font-size: 0.85em;
|
|
user-select: none;
|
|
}
|
|
.code-block .line--highlight {
|
|
background: rgba(198, 165, 92, 0.08);
|
|
border-left: 3px solid var(--accent);
|
|
padding-left: calc(3em - 3px);
|
|
}
|
|
.code-block .line--highlight::before { color: var(--accent); }
|
|
.code-block.dim-others .line:not(.line--highlight) { opacity: 0.3; }
|
|
|
|
/* 신택스 컬러 */
|
|
.kw { color: #C6A55C; }
|
|
.fn { color: #D4B87A; }
|
|
.str { color: #A89070; }
|
|
.cmt { color: #555040; font-style: italic; }
|
|
.var { color: #E0D0B0; }
|
|
.op { color: #8A8070; }
|
|
.tag { color: #C6A55C; }
|
|
.attr { color: #D4B87A; }
|
|
.val { color: #A89070; }
|
|
|
|
/* ===== 컴포넌트 ===== */
|
|
.split { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; align-items: start; }
|
|
|
|
.split-card {
|
|
padding: clamp(0.8rem, 1.5vw, 1.2rem);
|
|
background: rgba(255,255,255,0.02);
|
|
border: 1px solid var(--border);
|
|
border-radius: 6px;
|
|
}
|
|
|
|
.info-card {
|
|
padding: clamp(1rem, 2vw, 1.5rem);
|
|
background: rgba(255,255,255,0.02);
|
|
border: 1px solid var(--border);
|
|
border-radius: 6px;
|
|
width: 100%;
|
|
}
|
|
|
|
.info-card .card-title {
|
|
font-family: var(--font-mono);
|
|
font-size: clamp(0.7rem, 1vw, 0.85rem);
|
|
color: var(--text-dim);
|
|
margin-bottom: 0.8rem;
|
|
letter-spacing: 0.05em;
|
|
}
|
|
|
|
.badge {
|
|
display: inline-block;
|
|
font-family: var(--font-mono);
|
|
font-size: clamp(0.65rem, 0.9vw, 0.8rem);
|
|
color: var(--accent);
|
|
padding: 0.2em 0.6em;
|
|
border: 1px solid var(--accent-dim);
|
|
border-radius: 100px;
|
|
letter-spacing: 0.05em;
|
|
}
|
|
|
|
.terminal-badge {
|
|
font-family: var(--font-mono);
|
|
font-size: clamp(0.75rem, 1.1vw, 0.95rem);
|
|
color: var(--accent);
|
|
letter-spacing: 0.1em;
|
|
text-transform: uppercase;
|
|
margin-bottom: 0.5rem;
|
|
}
|
|
|
|
.terminal-badge::before {
|
|
content: '> ';
|
|
animation: blink 1s step-end infinite;
|
|
}
|
|
|
|
@keyframes blink { 50% { opacity: 0; } }
|
|
|
|
.accent-line {
|
|
width: 50px;
|
|
height: 2px;
|
|
background: var(--accent);
|
|
margin: 0.5rem 0;
|
|
}
|
|
|
|
.number-label {
|
|
font-family: var(--font-mono);
|
|
font-size: clamp(0.7rem, 0.9vw, 0.8rem);
|
|
color: var(--accent);
|
|
flex-shrink: 0;
|
|
width: 2em;
|
|
}
|
|
|
|
/* ===== 네비게이션 UI ===== */
|
|
.progress-bar {
|
|
position: fixed;
|
|
top: 0;
|
|
left: 0;
|
|
width: 100%;
|
|
height: 2px;
|
|
background: rgba(198,165,92,0.1);
|
|
z-index: 101;
|
|
}
|
|
.progress-fill {
|
|
height: 100%;
|
|
width: 0%;
|
|
background: var(--accent);
|
|
transition: width 0.35s cubic-bezier(0.4, 0, 0.2, 1);
|
|
}
|
|
|
|
.slide-counter {
|
|
position: fixed;
|
|
bottom: 16px;
|
|
right: 24px;
|
|
font-size: 12px;
|
|
color: var(--text-dim);
|
|
font-family: var(--font-mono);
|
|
z-index: 102;
|
|
letter-spacing: 0.05em;
|
|
}
|
|
|
|
.nav-buttons {
|
|
position: fixed;
|
|
bottom: 16px;
|
|
left: 24px;
|
|
display: flex;
|
|
gap: 6px;
|
|
z-index: 102;
|
|
}
|
|
.nav-btn {
|
|
width: 32px;
|
|
height: 32px;
|
|
border: 1px solid rgba(198,165,92,0.2);
|
|
background: rgba(198,165,92,0.04);
|
|
color: var(--text-dim);
|
|
border-radius: 4px;
|
|
cursor: pointer;
|
|
font-size: 14px;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
transition: background 0.2s, color 0.2s, border-color 0.2s;
|
|
}
|
|
.nav-btn:hover { background: rgba(198,165,92,0.12); color: var(--accent); border-color: rgba(198,165,92,0.4); }
|
|
|
|
.speaker-notes { display: none; }
|
|
|
|
/* ===== 반응형 ===== */
|
|
@media (prefers-reduced-motion: reduce) {
|
|
.reveal, .slide { transition-duration: 0.1s !important; }
|
|
.reveal { transform: none !important; }
|
|
}
|
|
|
|
@media (hover: none) and (pointer: coarse) {
|
|
.nav-btn { width: 44px; height: 44px; }
|
|
}
|
|
|
|
@media (max-height: 600px) {
|
|
.slide { padding: clamp(1rem, 3vw, 2rem); }
|
|
h1 { font-size: clamp(1.5rem, 4vw, 2.5rem); }
|
|
h2 { font-size: clamp(1.2rem, 2.5vw, 1.8rem); }
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
|
|
<div class="slide-viewport" id="viewport">
|
|
|
|
<!-- ============ 슬라이드 01: 타이틀 ============ -->
|
|
<section class="slide slide--intro active" id="slide-1"
|
|
role="region" aria-roledescription="슬라이드" aria-label="슬라이드 1" aria-hidden="false">
|
|
<div class="slide__inner">
|
|
<div class="slide__header">
|
|
<div class="terminal-badge reveal">Presentation</div>
|
|
<h1 class="reveal">Vanilla Slides</h1>
|
|
</div>
|
|
<div class="slide__body">
|
|
<p class="reveal" style="font-size: clamp(0.95rem, 1.8vw, 1.3rem);">
|
|
순수 HTML, CSS, JavaScript로 만드는<br>웹 기반 프레젠테이션
|
|
</p>
|
|
<div class="accent-line reveal"></div>
|
|
<p class="reveal" style="font-size: clamp(0.7rem, 1vw, 0.85rem); color: var(--text-dim);">
|
|
← → 키 또는 스와이프로 이동
|
|
</p>
|
|
</div>
|
|
<div class="slide__footer">2026.03</div>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- ============ 슬라이드 02: 왜 바닐라인가 ============ -->
|
|
<section class="slide slide--content" id="slide-2"
|
|
role="region" aria-roledescription="슬라이드" aria-label="슬라이드 2" aria-hidden="true">
|
|
<div class="slide__inner">
|
|
<div class="slide__header">
|
|
<div class="terminal-badge reveal">Why vanilla?</div>
|
|
<h2 class="reveal">왜 바닐라 JS인가</h2>
|
|
</div>
|
|
<div class="slide__body">
|
|
<ul>
|
|
<li class="reveal">외부 의존성 제로 — 번들 크기 0KB</li>
|
|
<li class="reveal">브라우저 표준만으로 완전한 구현</li>
|
|
<li class="reveal">커스터마이즈의 자유도 극대화</li>
|
|
<li class="reveal">단일 HTML 파일로 배포 가능</li>
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- ============ 슬라이드 03: 핵심 기능 ============ -->
|
|
<section class="slide slide--content-alt" id="slide-3"
|
|
role="region" aria-roledescription="슬라이드" aria-label="슬라이드 3" aria-hidden="true">
|
|
<div class="slide__inner">
|
|
<div class="slide__header">
|
|
<div class="terminal-badge reveal">Features</div>
|
|
<h2 class="reveal">핵심 기능</h2>
|
|
</div>
|
|
<div class="slide__body">
|
|
<ul>
|
|
<li class="reveal">풀스크린 뷰포트 & 반응형 레이아웃</li>
|
|
<li class="reveal">키보드 / 터치 / 스와이프 네비게이션</li>
|
|
<li class="reveal">GPU 가속 슬라이드 전환 애니메이션</li>
|
|
<li class="reveal">순차 등장 reveal 효과</li>
|
|
<li class="reveal">코드 블록 라인 하이라이트</li>
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- ============ 슬라이드 04: 아키텍처 ============ -->
|
|
<section class="slide slide--diagram" id="slide-4"
|
|
role="region" aria-roledescription="슬라이드" aria-label="슬라이드 4" aria-hidden="true">
|
|
<div class="slide__inner">
|
|
<div class="slide__header">
|
|
<div class="terminal-badge reveal">Architecture</div>
|
|
<h2 class="reveal">아키텍처</h2>
|
|
</div>
|
|
<div class="slide__body">
|
|
<div class="split">
|
|
<div class="split-card reveal">
|
|
<p style="font-size: clamp(0.8rem, 1.3vw, 1.05rem); margin-bottom: 0.8rem;">
|
|
<strong style="color: var(--accent);">레이아웃</strong><br>
|
|
fixed 뷰포트 + absolute 슬라이드
|
|
</p>
|
|
<p style="font-size: clamp(0.8rem, 1.3vw, 1.05rem);">
|
|
<strong style="color: #D4B87A;">전환</strong><br>
|
|
CSS Transition + transform/opacity
|
|
</p>
|
|
</div>
|
|
<div class="split-card reveal">
|
|
<p style="font-size: clamp(0.8rem, 1.3vw, 1.05rem); margin-bottom: 0.8rem;">
|
|
<strong style="color: #B89860;">상태 관리</strong><br>
|
|
slideIndex 단일 상태 + URL hash
|
|
</p>
|
|
<p style="font-size: clamp(0.8rem, 1.3vw, 1.05rem);">
|
|
<strong style="color: #A08848;">등장 효과</strong><br>
|
|
.reveal 클래스 + transition-delay
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- ============ 슬라이드 05: CSS 레이아웃 ============ -->
|
|
<section class="slide slide--content" id="slide-5"
|
|
role="region" aria-roledescription="슬라이드" aria-label="슬라이드 5" aria-hidden="true">
|
|
<div class="slide__inner">
|
|
<div class="slide__header">
|
|
<div class="terminal-badge reveal">Layout</div>
|
|
<h2 class="reveal">CSS 레이아웃 전략</h2>
|
|
</div>
|
|
<div class="slide__body">
|
|
<div class="info-card reveal">
|
|
<div class="card-title">// 풀스크린 뷰포트</div>
|
|
<p style="font-size: clamp(0.8rem, 1.2vw, 1rem);">
|
|
<code style="color: var(--accent); font-family: var(--font-mono);">position: fixed</code> 뷰포트 안에<br>
|
|
<code style="color: var(--accent); font-family: var(--font-mono);">position: absolute</code> 슬라이드를 겹쳐<br>
|
|
<code style="color: var(--accent); font-family: var(--font-mono);">transform: translateX</code>로 전환합니다
|
|
</p>
|
|
</div>
|
|
<div class="info-card reveal">
|
|
<div class="card-title">// 콘텐츠 영역</div>
|
|
<p style="font-size: clamp(0.8rem, 1.2vw, 1rem);">
|
|
<code style="color: var(--accent); font-family: var(--font-mono);">max-width: 680px</code>로 읽기 편한 폭 유지<br>
|
|
<code style="color: var(--accent); font-family: var(--font-mono);">clamp()</code>로 반응형 타이포그래피 처리
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- ============ 슬라이드 06: 코드 예시 — HTML ============ -->
|
|
<section class="slide slide--code" id="slide-6"
|
|
role="region" aria-roledescription="슬라이드" aria-label="슬라이드 6" aria-hidden="true">
|
|
<div class="slide__inner">
|
|
<div class="slide__header">
|
|
<h2 class="reveal" style="color: var(--accent);">HTML 구조</h2>
|
|
</div>
|
|
<div class="slide__body">
|
|
<pre class="code-block reveal" data-language="html" data-highlight-lines="2,4-5"><code><span class="line"><span class="op"><</span><span class="tag">div</span> <span class="attr">class</span>=<span class="str">"slide-viewport"</span><span class="op">></span></span>
|
|
<span class="line"> <span class="op"><</span><span class="tag">section</span> <span class="attr">class</span>=<span class="str">"slide active"</span><span class="op">></span></span>
|
|
<span class="line"> <span class="op"><</span><span class="tag">div</span> <span class="attr">class</span>=<span class="str">"slide__inner"</span><span class="op">></span></span>
|
|
<span class="line"> <span class="op"><</span><span class="tag">h2</span> <span class="attr">class</span>=<span class="str">"reveal"</span><span class="op">></span><span class="var">제목</span><span class="op"></</span><span class="tag">h2</span><span class="op">></span></span>
|
|
<span class="line"> <span class="op"><</span><span class="tag">p</span> <span class="attr">class</span>=<span class="str">"reveal"</span><span class="op">></span><span class="var">내용</span><span class="op"></</span><span class="tag">p</span><span class="op">></span></span>
|
|
<span class="line"> <span class="op"></</span><span class="tag">div</span><span class="op">></span></span>
|
|
<span class="line"> <span class="op"></</span><span class="tag">section</span><span class="op">></span></span>
|
|
<span class="line"><span class="op"></</span><span class="tag">div</span><span class="op">></span></span></code></pre>
|
|
<p class="reveal" style="font-size: clamp(0.7rem, 1.1vw, 0.95rem); color: var(--accent); margin-top: 0.5rem;">
|
|
.reveal 클래스로 순차 등장 효과가 자동 적용됩니다
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- ============ 슬라이드 07: 코드 예시 — JS ============ -->
|
|
<section class="slide slide--code" id="slide-7"
|
|
role="region" aria-roledescription="슬라이드" aria-label="슬라이드 7" aria-hidden="true">
|
|
<div class="slide__inner">
|
|
<div class="slide__header">
|
|
<h2 class="reveal" style="color: var(--accent);">슬라이드 전환 로직</h2>
|
|
</div>
|
|
<div class="slide__body">
|
|
<pre class="code-block reveal" data-language="javascript" data-highlight-lines="3,6-7"><code><span class="line"><span class="fn">goNext</span>() {</span>
|
|
<span class="line"> <span class="kw">if</span> (<span class="kw">this</span>.<span class="var">isAnimating</span>) <span class="kw">return</span>;</span>
|
|
<span class="line"> <span class="kw">this</span>.<span class="fn">_goSlide</span>(<span class="kw">this</span>.<span class="var">current</span> + <span class="str">1</span>, <span class="str">'next'</span>);</span>
|
|
<span class="line">}</span>
|
|
<span class="line"></span>
|
|
<span class="line"><span class="cmt">// GPU 가속: transform + opacity만 사용</span></span>
|
|
<span class="line"><span class="var">to</span>.<span class="var">style</span>.<span class="var">transform</span> = <span class="str">'translateX(0)'</span>;</span>
|
|
<span class="line"><span class="var">from</span>.<span class="var">style</span>.<span class="var">transform</span> = <span class="str">'translateX(-100%)'</span>;</span></code></pre>
|
|
<p class="reveal" style="font-size: clamp(0.7rem, 1.1vw, 0.95rem); color: var(--text-dim); margin-top: 0.5rem;">
|
|
isAnimating 플래그 + transitionend로 이벤트 잠금
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- ============ 슬라이드 08: 네비게이션 ============ -->
|
|
<section class="slide slide--content-alt" id="slide-8"
|
|
role="region" aria-roledescription="슬라이드" aria-label="슬라이드 8" aria-hidden="true">
|
|
<div class="slide__inner">
|
|
<div class="slide__header">
|
|
<div class="terminal-badge reveal">Navigation</div>
|
|
<h2 class="reveal">네비게이션 지원</h2>
|
|
</div>
|
|
<div class="slide__body">
|
|
<div class="split">
|
|
<div class="split-card reveal">
|
|
<h3 style="font-size: clamp(0.85rem, 1.2vw, 1rem); color: var(--accent); margin-bottom: 0.5rem;">키보드</h3>
|
|
<p style="font-size: clamp(0.75rem, 1.1vw, 0.95rem);">
|
|
← → 이전/다음<br>
|
|
Space / PageDown 다음<br>
|
|
Home / End 처음/끝<br>
|
|
F 전체화면
|
|
</p>
|
|
</div>
|
|
<div class="split-card reveal">
|
|
<h3 style="font-size: clamp(0.85rem, 1.2vw, 1rem); color: var(--accent); margin-bottom: 0.5rem;">터치</h3>
|
|
<p style="font-size: clamp(0.75rem, 1.1vw, 0.95rem);">
|
|
좌 스와이프 → 다음<br>
|
|
우 스와이프 → 이전<br>
|
|
threshold: 50px<br>
|
|
touch-action: pan-y
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<div class="info-card reveal" style="margin-top: 0.8rem;">
|
|
<div class="card-title">// URL 동기화</div>
|
|
<p style="font-size: clamp(0.75rem, 1.1vw, 0.95rem);">
|
|
<code style="color: var(--accent); font-family: var(--font-mono);">#slide-N</code> 해시로 뒤로가기 및 직접 접근 지원
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- ============ 슬라이드 09: 주의사항 ============ -->
|
|
<section class="slide slide--content" id="slide-9"
|
|
role="region" aria-roledescription="슬라이드" aria-label="슬라이드 9" aria-hidden="true">
|
|
<div class="slide__inner">
|
|
<div class="slide__header">
|
|
<div class="terminal-badge reveal">Pitfalls</div>
|
|
<h2 class="reveal">주의사항</h2>
|
|
</div>
|
|
<div class="slide__body">
|
|
<ul>
|
|
<li class="reveal"><span style="color: var(--accent);">display:none</span> 사용 금지 — transition 불가</li>
|
|
<li class="reveal">모바일 높이는 <span style="color: var(--accent);">100dvh</span> 사용</li>
|
|
<li class="reveal"><span style="color: var(--accent);">will-change</span>는 동적 토글 (정적 적용 시 메모리 낭비)</li>
|
|
<li class="reveal"><span style="color: var(--accent);">transitionend</span> 미발생 대비 setTimeout fallback 필수</li>
|
|
<li class="reveal">터치 <span style="color: var(--accent);">passive:false</span>에서만 preventDefault 가능</li>
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- ============ 슬라이드 10: 마무리 ============ -->
|
|
<section class="slide slide--end" id="slide-10"
|
|
role="region" aria-roledescription="슬라이드" aria-label="슬라이드 10" aria-hidden="true">
|
|
<div class="slide__inner" style="text-align: center; align-items: center;">
|
|
<div class="slide__body" style="align-items: center;">
|
|
<h1 class="reveal">감사합니다</h1>
|
|
<div class="accent-line reveal" style="margin: 0.8rem auto;"></div>
|
|
<p class="reveal" style="font-size: clamp(0.85rem, 1.3vw, 1.1rem);">
|
|
외부 라이브러리 0개 · 단일 HTML 파일
|
|
</p>
|
|
<div class="reveal" style="display: flex; gap: 0.5rem; margin-top: 1rem;">
|
|
<span class="badge">HTML</span>
|
|
<span class="badge">CSS</span>
|
|
<span class="badge">JavaScript</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
</div>
|
|
|
|
<!-- 네비게이션 UI -->
|
|
<div class="progress-bar"><div class="progress-fill" id="progressFill"></div></div>
|
|
<div class="slide-counter" id="slideCounter">01 / 10</div>
|
|
<div class="nav-buttons">
|
|
<button class="nav-btn" id="btnPrev" aria-label="이전 슬라이드">‹</button>
|
|
<button class="nav-btn" id="btnNext" aria-label="다음 슬라이드">›</button>
|
|
</div>
|
|
|
|
<script>
|
|
class Presentation {
|
|
constructor() {
|
|
this.viewport = document.getElementById('viewport');
|
|
this.slides = Array.from(this.viewport.querySelectorAll('.slide'));
|
|
this.total = this.slides.length;
|
|
this.current = 0;
|
|
this.isAnimating = false;
|
|
this.DURATION = 450;
|
|
|
|
// 코드 하이라이트 적용
|
|
this.slides.forEach(s => this._applyCodeHighlights(s));
|
|
|
|
// URL hash에서 초기 슬라이드 복원
|
|
this._restoreFromHash();
|
|
|
|
// 이벤트 바인딩
|
|
this._bindKeyboard();
|
|
this._bindTouch();
|
|
this._bindButtons();
|
|
this._bindHash();
|
|
this._updateUI();
|
|
}
|
|
|
|
// ── 한 페이지 단위 이동 ─────────────────────
|
|
|
|
goNext() {
|
|
if (this.isAnimating || this.current >= this.total - 1) return;
|
|
this._goSlide(this.current + 1, 'next');
|
|
}
|
|
|
|
goPrev() {
|
|
if (this.isAnimating || this.current <= 0) return;
|
|
this._goSlide(this.current - 1, 'prev');
|
|
}
|
|
|
|
goTo(index) {
|
|
if (this.isAnimating || index === this.current) return;
|
|
if (index < 0 || index >= this.total) return;
|
|
this._goSlide(index, index > this.current ? 'next' : 'prev');
|
|
}
|
|
|
|
// ── 슬라이드 전환 ───────────────────────────
|
|
|
|
_goSlide(index, direction) {
|
|
const isNext = direction === 'next';
|
|
const from = this.slides[this.current];
|
|
const to = this.slides[index];
|
|
|
|
this.isAnimating = true;
|
|
this.viewport.classList.add('animating');
|
|
|
|
from.style.willChange = 'transform, opacity';
|
|
to.style.willChange = 'transform, opacity';
|
|
|
|
// 진입 슬라이드 초기 위치 (transition 없이)
|
|
to.style.transition = 'none';
|
|
to.classList.remove('stand-left');
|
|
to.style.transform = isNext ? 'translateX(100%)' : 'translateX(-100%)';
|
|
to.style.opacity = '0';
|
|
void to.offsetWidth;
|
|
|
|
// transition 복원 + 애니메이션 시작
|
|
to.style.transition = '';
|
|
to.style.transform = '';
|
|
to.style.opacity = '';
|
|
from.classList.remove('active');
|
|
to.classList.add('active');
|
|
|
|
from.style.transform = isNext ? 'translateX(-100%)' : 'translateX(100%)';
|
|
from.style.opacity = '0';
|
|
|
|
from.setAttribute('aria-hidden', 'true');
|
|
to.setAttribute('aria-hidden', 'false');
|
|
|
|
this.current = index;
|
|
this._syncHash();
|
|
this._updateUI();
|
|
|
|
// 완료 감지
|
|
let done = false;
|
|
const cleanup = () => {
|
|
if (done) return;
|
|
done = true;
|
|
clearTimeout(fallback);
|
|
to.removeEventListener('transitionend', onEnd);
|
|
|
|
from.style.transform = '';
|
|
from.style.opacity = '';
|
|
from.style.transition = 'none';
|
|
from.classList.add('stand-left');
|
|
void from.offsetWidth;
|
|
from.style.transition = '';
|
|
|
|
from.style.willChange = 'auto';
|
|
to.style.willChange = 'auto';
|
|
|
|
this.isAnimating = false;
|
|
this.viewport.classList.remove('animating');
|
|
};
|
|
|
|
const onEnd = (e) => {
|
|
if (e.propertyName !== 'transform' || e.target !== to) return;
|
|
cleanup();
|
|
};
|
|
to.addEventListener('transitionend', onEnd);
|
|
const fallback = setTimeout(cleanup, this.DURATION + 150);
|
|
}
|
|
|
|
// ── 유틸리티 ─────────────────────────────────
|
|
|
|
_applyCodeHighlights(slide) {
|
|
slide.querySelectorAll('.code-block[data-highlight-lines]').forEach(pre => {
|
|
const ranges = this._parseRanges(pre.dataset.highlightLines);
|
|
const lines = pre.querySelectorAll('.line');
|
|
lines.forEach((line, i) => {
|
|
if (ranges.has(i + 1)) line.classList.add('line--highlight');
|
|
});
|
|
if (ranges.size > 0) pre.classList.add('dim-others');
|
|
});
|
|
}
|
|
|
|
_parseRanges(str) {
|
|
const result = new Set();
|
|
str.split(',').forEach(part => {
|
|
part = part.trim();
|
|
if (part.includes('-')) {
|
|
const [a, b] = part.split('-').map(Number);
|
|
for (let i = a; i <= b; i++) result.add(i);
|
|
} else {
|
|
result.add(Number(part));
|
|
}
|
|
});
|
|
return result;
|
|
}
|
|
|
|
_updateUI() {
|
|
const progress = this.total <= 1 ? 100 : (this.current / (this.total - 1)) * 100;
|
|
document.getElementById('progressFill').style.width = `${progress}%`;
|
|
const num = String(this.current + 1).padStart(2, '0');
|
|
const tot = String(this.total).padStart(2, '0');
|
|
document.getElementById('slideCounter').textContent = `${num} / ${tot}`;
|
|
}
|
|
|
|
_syncHash() {
|
|
const hash = `#slide-${this.current + 1}`;
|
|
if (location.hash !== hash) history.pushState(null, '', hash);
|
|
}
|
|
|
|
_restoreFromHash() {
|
|
const match = location.hash.match(/^#slide-(\d+)$/);
|
|
if (match) {
|
|
const idx = parseInt(match[1], 10) - 1;
|
|
if (idx > 0 && idx < this.total) {
|
|
this.slides[0].classList.remove('active');
|
|
this.slides[0].setAttribute('aria-hidden', 'true');
|
|
this.slides[0].classList.add('stand-left');
|
|
this.slides[idx].classList.add('active');
|
|
this.slides[idx].setAttribute('aria-hidden', 'false');
|
|
this.current = idx;
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── 이벤트 바인딩 ────────────────────────────
|
|
|
|
_bindKeyboard() {
|
|
document.addEventListener('keydown', (e) => {
|
|
if (e.repeat) return;
|
|
const tag = document.activeElement.tagName;
|
|
if (tag === 'INPUT' || tag === 'TEXTAREA') return;
|
|
|
|
switch (e.key) {
|
|
case 'ArrowRight': case 'ArrowDown': case ' ': case 'PageDown':
|
|
e.preventDefault(); this.goNext(); break;
|
|
case 'ArrowLeft': case 'ArrowUp': case 'PageUp':
|
|
e.preventDefault(); this.goPrev(); break;
|
|
case 'Home':
|
|
e.preventDefault(); this.goTo(0); break;
|
|
case 'End':
|
|
e.preventDefault(); this.goTo(this.total - 1); break;
|
|
case 'f': case 'F':
|
|
this._toggleFullscreen(); break;
|
|
}
|
|
});
|
|
}
|
|
|
|
_bindTouch() {
|
|
let startX = 0, startY = 0, startTime = 0, swiping = false;
|
|
const el = this.viewport;
|
|
|
|
el.addEventListener('touchstart', (e) => {
|
|
const t = e.changedTouches[0];
|
|
startX = t.clientX; startY = t.clientY;
|
|
startTime = Date.now(); swiping = false;
|
|
}, { passive: true });
|
|
|
|
el.addEventListener('touchmove', (e) => {
|
|
const t = e.changedTouches[0];
|
|
const dx = Math.abs(t.clientX - startX);
|
|
const dy = Math.abs(t.clientY - startY);
|
|
if (dx > dy && dx > 10) { swiping = true; e.preventDefault(); }
|
|
}, { passive: false });
|
|
|
|
el.addEventListener('touchend', (e) => {
|
|
const t = e.changedTouches[0];
|
|
const dx = t.clientX - startX;
|
|
const dy = Math.abs(t.clientY - startY);
|
|
const elapsed = Date.now() - startTime;
|
|
if (swiping && Math.abs(dx) >= 50 && dy <= 75 && elapsed <= 400) {
|
|
dx < 0 ? this.goNext() : this.goPrev();
|
|
}
|
|
swiping = false;
|
|
}, { passive: true });
|
|
|
|
el.style.touchAction = 'pan-y';
|
|
el.style.userSelect = 'none';
|
|
}
|
|
|
|
_bindButtons() {
|
|
document.getElementById('btnPrev').addEventListener('click', () => this.goPrev());
|
|
document.getElementById('btnNext').addEventListener('click', () => this.goNext());
|
|
}
|
|
|
|
_bindHash() {
|
|
window.addEventListener('hashchange', () => {
|
|
const match = location.hash.match(/^#slide-(\d+)$/);
|
|
if (match) {
|
|
const idx = parseInt(match[1], 10) - 1;
|
|
if (idx !== this.current) this.goTo(idx);
|
|
}
|
|
});
|
|
}
|
|
|
|
_toggleFullscreen() {
|
|
if (!document.fullscreenElement) {
|
|
document.documentElement.requestFullscreen().catch(() => {});
|
|
} else {
|
|
document.exitFullscreen();
|
|
}
|
|
}
|
|
}
|
|
|
|
document.addEventListener('DOMContentLoaded', () => new Presentation());
|
|
</script>
|
|
</body>
|
|
</html>
|