이미지 크롭 기능 9종 — 업로드 없이 브라우저에서
이미지 크롭 기능은 올리기 전 사진에서 남길 자리를 골라 잘라내는 편집 UI입니다. 여기 아홉 가지는 그 일을 업로드 없이 브라우저 안에서 끝내도록 캔버스 2D 와 포인터 이벤트만으로 짠 것이라, 자르기부터 회전·필터·워터마크·내보내기까지 파일을 열면 그대로 돕니다.
01 image-edit-free-crop-box
02 image-edit-rotate-straighten
03 image-edit-filter-presets
04 image-edit-adjust-sliders
05 image-edit-flip-mirror
06 image-edit-bg-pad-square
07 image-edit-text-watermark
08 image-edit-histogram-rgb
09 image-edit-export-panel
자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip
- 01 자유 크롭 박스
- 02 회전과 수평 보정
- 03 필터 프리셋 여섯 개
- 04 밝기 대비 채도 슬라이더
- 05 좌우와 상하 뒤집기
- 06 정사각 여백 채우기
- 07 텍스트 워터마크
- 08 RGB 히스토그램
- 09 내보내기 패널
아홉 개의 순서는 기능의 무게가 아니라 사진 한 장이 웹 이미지 편집기를 지나는 길입니다. 먼저 모양을 정하는 셋 — 남길 자리를 손잡이로 잡고(01), 삐뚤어진 수평을 각도로 펴고(02), 톤을 프리셋으로 한 번에 바꿉니다(03). 다음은 값을 다듬는 셋 — 밝기·대비·채도를 따로 밀고(04), 축을 뒤집고(05), 규격에 맞게 여백을 채웁니다(06). 마지막 셋은 내보내기 직전의 일 — 글자를 얹고(07), 픽셀 분포로 과했는지 확인하고(08), 형식과 품질을 골라 파일을 만듭니다(09). 사진은 외부 파일을 하나도 쓰지 않고 캔버스에 그라디언트와 도형으로 합성했습니다. 손으로 긋는 쪽 이야기는 서명 패드와 캔버스 드로잉 UI 9종에, 색을 고르는 쪽은 컬러 피커 직접 만들기 CSS+JS 9종에 있습니다. 같은 creative 기둥에서 사진 대신 무늬를 코드로 찍어 내는 CSS 배경 패턴, 이미지 대신 JSON 을 다루는 n8n 워크플로우 예제도 같은 "파일 없이 브라우저에서" 축에 서 있습니다.
01자유 크롭 박스
무엇이 보이나
모서리 넷과 변 넷, 손잡이 여덟 개로 남길 범위를 잡는 기본 판입니다. 바깥을 덮는 그늘은 겉 사각형을 돌고 안쪽 구멍을 거꾸로 도는 열 점짜리 clip-path 다각형 하나로 그렸고, 결과 칸에는 아홉 인자 drawImage 가 오려낸 사각형만 다시 그립니다.

<main class="stage">
<div class="cb is-demo" id="cb">
<div class="cb__work" id="work">
<canvas class="cb__photo" id="photo" width="300" height="200" role="img" aria-label="샘플 사진"></canvas>
<div class="cb__shade" id="shade" aria-hidden="true"></div>
<div class="cb__box" id="box">
<span class="cb__grid" aria-hidden="true"></span>
<button type="button" class="cb__h cb__h--nw" data-h="nw" aria-label="왼쪽 위 손잡이"></button>
<button type="button" class="cb__h cb__h--n" data-h="n" aria-label="위쪽 손잡이"></button>
<button type="button" class="cb__h cb__h--ne" data-h="ne" aria-label="오른쪽 위 손잡이"></button>
<button type="button" class="cb__h cb__h--w" data-h="w" aria-label="왼쪽 손잡이"></button>
<button type="button" class="cb__h cb__h--e" data-h="e" aria-label="오른쪽 손잡이"></button>
<button type="button" class="cb__h cb__h--sw" data-h="sw" aria-label="왼쪽 아래 손잡이"></button>
<button type="button" class="cb__h cb__h--s" data-h="s" aria-label="아래쪽 손잡이"></button>
<button type="button" class="cb__h cb__h--se" data-h="se" aria-label="오른쪽 아래 손잡이"></button>
</div>
</div>
<div class="cb__side">
<p class="cb__cap"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" class="cb__ico" aria-hidden="true"><g fill="currentColor"><path d="M192 64v128H64V64Z" opacity=".2"/><path d="M240 192a8 8 0 0 1-8 8h-32v32a8 8 0 0 1-16 0v-32H64a8 8 0 0 1-8-8V72H24a8 8 0 0 1 0-16h32V24a8 8 0 0 1 16 0v160h160a8 8 0 0 1 8 8M96 72h88v88a8 8 0 0 0 16 0V64a8 8 0 0 0-8-8H96a8 8 0 0 0 0 16"/></g></svg><span>결과 칸</span></p>
<div class="cb__frame">
<canvas class="cb__out" id="out" width="120" height="120" role="img" aria-label="잘린 결과 미리보기"></canvas>
<span class="cb__ghost" aria-hidden="true"></span>
</div>
<p class="cb__size" id="size">162 × 112</p>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 자유 크롭 박스 — 실제 자르기는 canvas 2D 의 drawImage 아홉 인자가 한다(오려낼 사각형 + 그려 넣을 사각형).
// 격자 미리보기(.is-demo)는 캔버스를 건드리지 않고 그늘의 clip-path 와 틀의 transform 만 움직인다.
// demo.py 는 CSS 애니메이션 시계로 프레임을 뽑으므로 캔버스에 그린 결과는 24장이 전부 같기 때문이다.
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-blue;
@include stage($stage-paper);
.cb { display: flex; align-items: center; gap: $sp-3; width: 100%; max-width: 400px; }
.cb__work {
position: relative; flex: 1 1 auto; aspect-ratio: 3 / 2;
border-radius: $r-sm; overflow: hidden; box-shadow: $shadow-raised; touch-action: none;
}
.cb__photo { display: block; width: 100%; height: 100%; }
.cb__shade { position: absolute; inset: 0; background: rgba(23, 20, 26, .58); pointer-events: none; }
.cb__box { position: absolute; border: 2px solid $stage-paper; cursor: move; }
.cb__grid {
position: absolute; inset: 0;
background-image:
linear-gradient(to right, rgba(255, 255, 255, .5) 1px, transparent 1px),
linear-gradient(to bottom, rgba(255, 255, 255, .5) 1px, transparent 1px);
background-size: 33.34% 33.34%;
background-position: 33.33% 33.33%;
}
.cb__h {
position: absolute; width: 11px; height: 11px; padding: 0; cursor: pointer;
border: 2px solid $subject-ink; border-radius: 50%; background: $stage-paper;
}
.cb__h:focus-visible { outline: 3px solid $color; outline-offset: 2px; }
.cb__h--nw { left: -6px; top: -6px; }
.cb__h--n { left: calc(50% - 6px); top: -6px; }
.cb__h--ne { right: -6px; top: -6px; }
.cb__h--w { left: -6px; top: calc(50% - 6px); }
.cb__h--e { right: -6px; top: calc(50% - 6px); }
.cb__h--sw { left: -6px; bottom: -6px; }
.cb__h--s { left: calc(50% - 6px); bottom: -6px; }
.cb__h--se { right: -6px; bottom: -6px; }
.cb__side { flex: 0 0 auto; width: 96px; }
.cb__cap {
display: flex; align-items: center; gap: $sp-1; margin: 0 0 $sp-1;
font-weight: 800; font-size: clamp(9px, 2.4vw, 11px); color: $subject-ink;
}
.cb__ico { width: 13px; height: 13px; flex: 0 0 auto; }
.cb__frame {
position: relative; width: 100%; aspect-ratio: 3 / 2; display: grid; place-items: center;
border-radius: $r-xs; overflow: hidden; background: $subject-cream;
}
.cb__out { display: block; max-width: 100%; max-height: 100%; }
.cb__ghost {
display: none; position: absolute; inset: 0;
background-image: var(--photo); background-size: 185% 179%; background-position: 39% 36%;
}
.cb__size {
margin: $sp-1 0 0; font-weight: 700; font-size: clamp(9px, 2.4vw, 11px);
color: rgba(23, 20, 26, .72);
}
// ── 미리보기 루프: 크롭 틀을 오른쪽 아래로 끌었다가 제자리로 돌려놓는다.
// 틀은 transform 으로만 움직이고, 그늘의 구멍과 결과 칸은 같은 시각표로 따라간다.
// 복귀는 86% 에 끝난다 — 24장 표본의 마지막은 2초의 95.8% 지점이라 그 뒤는 안 찍힌다.
.cb.is-demo .cb__box {
animation-name: cbMove; animation-duration: $duration;
animation-timing-function: $easing; animation-iteration-count: infinite;
}
@keyframes cbMove {
0%, 8% { transform: translate(0, 0); }
40%, 56% { transform: translate(25.9%, 14.3%); }
86%, 100% { transform: translate(0, 0); }
}
.cb.is-demo .cb__shade {
animation-name: cbShade; animation-duration: $duration;
animation-timing-function: $easing; animation-iteration-count: infinite;
}
@keyframes cbShade {
0%, 8% { clip-path: polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%, 0% 0%, 18% 16%, 18% 72%, 72% 72%, 72% 16%, 18% 16%); }
40%, 56% { clip-path: polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%, 0% 0%, 32% 24%, 32% 80%, 86% 80%, 86% 24%, 32% 24%); }
86%, 100% { clip-path: polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%, 0% 0%, 18% 16%, 18% 72%, 72% 72%, 72% 16%, 18% 16%); }
}
.cb.is-demo .cb__out { visibility: hidden; }
.cb.is-demo .cb__ghost {
display: block;
animation-name: cbGhost; animation-duration: $duration;
animation-timing-function: $easing; animation-iteration-count: infinite;
}
@keyframes cbGhost {
0%, 8% { background-position: 39% 36%; }
40%, 56% { background-position: 70% 54%; }
86%, 100% { background-position: 39% 36%; }
}
// 손잡이는 자기 중심에서 커진다 — 모서리에 원점을 두면 커지는 게 아니라 옆으로 밀려 보인다
.cb.is-demo .cb__h--se {
animation-name: cbPop; animation-duration: $duration;
animation-timing-function: $ease-pop; animation-iteration-count: infinite;
}
@keyframes cbPop {
0%, 6% { transform: scale(1); }
14%, 46% { transform: scale(1.7); }
60%, 100% { transform: scale(1); }
}
@media (max-width: 340px) {
.cb { gap: $sp-2; }
.cb__side { width: 76px; }
}
@media (prefers-reduced-motion: reduce) {
.cb.is-demo .cb__box,
.cb.is-demo .cb__shade,
.cb.is-demo .cb__ghost,
.cb.is-demo .cb__h--se { animation: none !important; }
// 움직임 없이 끌어다 놓은 자리에 세워 둔다
.cb.is-demo .cb__box { transform: translate(25.9%, 14.3%) !important; }
.cb.is-demo .cb__shade { clip-path: polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%, 0% 0%, 32% 24%, 32% 80%, 86% 80%, 86% 24%, 32% 24%) !important; }
.cb.is-demo .cb__ghost { background-position: 70% 54% !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.cb {
display: flex;
align-items: center;
gap: 12px;
width: 100%;
max-width: 400px;
}
.cb__work {
position: relative;
flex: 1 1 auto;
aspect-ratio: 3/2;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
touch-action: none;
}
.cb__photo {
display: block;
width: 100%;
height: 100%;
}
.cb__shade {
position: absolute;
inset: 0;
background: rgba(23, 20, 26, 0.58);
pointer-events: none;
}
.cb__box {
position: absolute;
border: 2px solid #ffffff;
cursor: move;
}
.cb__grid {
position: absolute;
inset: 0;
background-image: linear-gradient(to right, rgba(255, 255, 255, 0.5) 1px, transparent 1px), linear-gradient(to bottom, rgba(255, 255, 255, 0.5) 1px, transparent 1px);
background-size: 33.34% 33.34%;
background-position: 33.33% 33.33%;
}
.cb__h {
position: absolute;
width: 11px;
height: 11px;
padding: 0;
cursor: pointer;
border: 2px solid #17141a;
border-radius: 50%;
background: #ffffff;
}
.cb__h:focus-visible {
outline: 3px solid #2f6df6;
outline-offset: 2px;
}
.cb__h--nw {
left: -6px;
top: -6px;
}
.cb__h--n {
left: calc(50% - 6px);
top: -6px;
}
.cb__h--ne {
right: -6px;
top: -6px;
}
.cb__h--w {
left: -6px;
top: calc(50% - 6px);
}
.cb__h--e {
right: -6px;
top: calc(50% - 6px);
}
.cb__h--sw {
left: -6px;
bottom: -6px;
}
.cb__h--s {
left: calc(50% - 6px);
bottom: -6px;
}
.cb__h--se {
right: -6px;
bottom: -6px;
}
.cb__side {
flex: 0 0 auto;
width: 96px;
}
.cb__cap {
display: flex;
align-items: center;
gap: 4px;
margin: 0 0 4px;
font-weight: 800;
font-size: clamp(9px, 2.4vw, 11px);
color: #17141a;
}
.cb__ico {
width: 13px;
height: 13px;
flex: 0 0 auto;
}
.cb__frame {
position: relative;
width: 100%;
aspect-ratio: 3/2;
display: grid;
place-items: center;
border-radius: 4px;
overflow: hidden;
background: #fff7e6;
}
.cb__out {
display: block;
max-width: 100%;
max-height: 100%;
}
.cb__ghost {
display: none;
position: absolute;
inset: 0;
background-image: var(--photo);
background-size: 185% 179%;
background-position: 39% 36%;
}
.cb__size {
margin: 4px 0 0;
font-weight: 700;
font-size: clamp(9px, 2.4vw, 11px);
color: rgba(23, 20, 26, 0.72);
}
.cb.is-demo .cb__box {
animation-name: cbMove;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
@keyframes cbMove {
0%, 8% {
transform: translate(0, 0);
}
40%, 56% {
transform: translate(25.9%, 14.3%);
}
86%, 100% {
transform: translate(0, 0);
}
}
.cb.is-demo .cb__shade {
animation-name: cbShade;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
@keyframes cbShade {
0%, 8% {
clip-path: polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%, 0% 0%, 18% 16%, 18% 72%, 72% 72%, 72% 16%, 18% 16%);
}
40%, 56% {
clip-path: polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%, 0% 0%, 32% 24%, 32% 80%, 86% 80%, 86% 24%, 32% 24%);
}
86%, 100% {
clip-path: polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%, 0% 0%, 18% 16%, 18% 72%, 72% 72%, 72% 16%, 18% 16%);
}
}
.cb.is-demo .cb__out {
visibility: hidden;
}
.cb.is-demo .cb__ghost {
display: block;
animation-name: cbGhost;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
@keyframes cbGhost {
0%, 8% {
background-position: 39% 36%;
}
40%, 56% {
background-position: 70% 54%;
}
86%, 100% {
background-position: 39% 36%;
}
}
.cb.is-demo .cb__h--se {
animation-name: cbPop;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 1.4, 0.4, 1);
animation-iteration-count: infinite;
}
@keyframes cbPop {
0%, 6% {
transform: scale(1);
}
14%, 46% {
transform: scale(1.7);
}
60%, 100% {
transform: scale(1);
}
}
@media (max-width: 340px) {
.cb {
gap: 8px;
}
.cb__side {
width: 76px;
}
}
@media (prefers-reduced-motion: reduce) {
.cb.is-demo .cb__box,
.cb.is-demo .cb__shade,
.cb.is-demo .cb__ghost,
.cb.is-demo .cb__h--se {
animation: none !important;
}
.cb.is-demo .cb__box {
transform: translate(25.9%, 14.3%) !important;
}
.cb.is-demo .cb__shade {
clip-path: polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%, 0% 0%, 32% 24%, 32% 80%, 86% 80%, 86% 24%, 32% 24%) !important;
}
.cb.is-demo .cb__ghost {
background-position: 70% 54% !important;
}
}var cb = document.getElementById('cb');
var work = document.getElementById('work');
var photo = document.getElementById('photo');
var shade = document.getElementById('shade');
var boxEl = document.getElementById('box');
var out = document.getElementById('out');
var sizeEl = document.getElementById('size');
var ctx = photo.getContext('2d');
var octx = out.getContext('2d');
var box = { x: 18, y: 16, w: 54, h: 56 };
var SIZE_LABEL = '%w × %h';
var OUT_LABEL = '잘라낸 범위 %w × %h 픽셀';
// 가짜 사진 — 외부 파일 없이 그라디언트와 도형으로 합성한다. 필터·히스토그램이 보이도록 대비를 세게 준다
function paintPhoto(c, w, h) {
var sky = c.createLinearGradient(0, 0, 0, h);
sky.addColorStop(0, '#2f6df6'); sky.addColorStop(0.55, '#ff9ebb'); sky.addColorStop(1, '#ffd23f');
c.fillStyle = sky; c.fillRect(0, 0, w, h);
c.fillStyle = '#fff7e6';
c.beginPath(); c.arc(w * 0.74, h * 0.28, h * 0.13, 0, Math.PI * 2); c.fill();
// 곧은 수평선 하나 — 기울기 보정이 눈에 보이려면 비교할 직선이 있어야 한다
c.fillStyle = 'rgba(23, 20, 26, 0.5)'; c.fillRect(0, Math.round(h * 0.66), w, 2);
c.fillStyle = '#17141a';
c.beginPath(); c.moveTo(0, h); c.lineTo(w * 0.34, h * 0.36); c.lineTo(w * 0.62, h); c.closePath(); c.fill();
c.fillStyle = '#c2361a';
c.beginPath(); c.moveTo(w * 0.46, h); c.lineTo(w * 0.78, h * 0.52); c.lineTo(w, h); c.closePath(); c.fill();
for (var i = 0; i < 6; i++) { c.fillStyle = i % 2 ? '#fff7e6' : '#17141a'; c.fillRect(w * (0.08 + i * 0.07), h * (0.14 + (i % 3) * 0.06), 7, 7); }
}
function apply() {
var x = box.x, y = box.y, w = box.w, h = box.h;
boxEl.style.left = x + '%'; boxEl.style.top = y + '%';
boxEl.style.width = w + '%'; boxEl.style.height = h + '%';
// 바깥을 어둡게 덮는 그늘. 겉 사각형을 한 바퀴 돌고 안쪽 구멍을 거꾸로 도는 열 점짜리 다각형이다
shade.style.clipPath = 'polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%, 0% 0%, ' +
x + '% ' + y + '%, ' + x + '% ' + (y + h) + '%, ' + (x + w) + '% ' + (y + h) + '%, ' +
(x + w) + '% ' + y + '%, ' + x + '% ' + y + '%)';
var sx = photo.width * x / 100, sy = photo.height * y / 100;
var sw = photo.width * w / 100, sh = photo.height * h / 100;
var pw = Math.round(sw), ph = Math.round(sh);
// 결과 칸은 잘린 크기 그대로 만든다 — 고정 정사각에 그리면 비율이 눌린다
out.width = pw; out.height = ph;
// 아홉 인자 drawImage — 원본에서 오려낼 사각형과 그려 넣을 사각형을 따로 준다
octx.drawImage(photo, sx, sy, sw, sh, 0, 0, pw, ph);
sizeEl.textContent = SIZE_LABEL.replace('%w', pw).replace('%h', ph);
out.setAttribute('aria-label', OUT_LABEL.replace('%w', pw).replace('%h', ph));
}
function clampBox() {
box.w = Math.max(18, Math.min(100, box.w)); box.h = Math.max(18, Math.min(100, box.h));
box.x = Math.max(0, Math.min(100 - box.w, box.x)); box.y = Math.max(0, Math.min(100 - box.h, box.y));
}
function nudge(dir, dx, dy) {
if (dir.indexOf('w') > -1) { box.x += dx; box.w -= dx; }
if (dir.indexOf('e') > -1) { box.w += dx; }
if (dir.indexOf('n') > -1) { box.y += dy; box.h -= dy; }
if (dir.indexOf('s') > -1) { box.h += dy; }
clampBox(); apply();
}
function unit() { var r = work.getBoundingClientRect(); return { x: 100 / r.width, y: 100 / r.height }; }
function startDrag(e, dir) {
cb.classList.remove('is-demo');
var u = unit(), px = e.clientX, py = e.clientY;
e.target.setPointerCapture(e.pointerId);
function move(ev) {
var dx = (ev.clientX - px) * u.x, dy = (ev.clientY - py) * u.y;
px = ev.clientX; py = ev.clientY;
if (dir) { nudge(dir, dx, dy); } else { box.x += dx; box.y += dy; clampBox(); apply(); }
}
function stop() { e.target.removeEventListener('pointermove', move); e.target.removeEventListener('pointerup', stop); }
e.target.addEventListener('pointermove', move);
e.target.addEventListener('pointerup', stop);
}
boxEl.addEventListener('pointerdown', function (e) { if (e.target === boxEl) { startDrag(e, ''); } });
var handles = boxEl.querySelectorAll('.cb__h');
for (var i = 0; i < handles.length; i++) {
(function (el) {
var dir = el.getAttribute('data-h');
el.addEventListener('pointerdown', function (e) { e.stopPropagation(); startDrag(e, dir); });
// 화살표 1px, Shift 와 함께면 10px — 손이 아니라 키보드로도 같은 조작이 된다
el.addEventListener('keydown', function (e) {
var step = e.shiftKey ? 10 : 1, u = unit(), dx = 0, dy = 0;
if (e.key === 'ArrowLeft') { dx = -step * u.x; } else if (e.key === 'ArrowRight') { dx = step * u.x; }
else if (e.key === 'ArrowUp') { dy = -step * u.y; } else if (e.key === 'ArrowDown') { dy = step * u.y; }
else { return; }
e.preventDefault(); cb.classList.remove('is-demo'); nudge(dir, dx, dy);
});
})(handles[i]);
}
paintPhoto(ctx, photo.width, photo.height);
document.documentElement.style.setProperty('--photo', 'url(' + photo.toDataURL() + ')');
apply();// FreeCropBox — vanilla 판(run/295/demo/01-image-edit-free-crop-box/index.html) 의 React 포트.
// 크롭 틀은 퍼센트로만 들고 있다가(x·y·w·h), 자를 때 원본 픽셀로 환산해 drawImage 아홉 인자에 넘긴다.
// 손잡이는 포인터(setPointerCapture)와 키보드(화살표 1px · Shift 10px) 둘 다로 끌 수 있다. 값·수치는 vanilla 와 같다.
import { useCallback, useEffect, useRef, useState } from "react";
import type { CSSProperties, PointerEvent as ReactPointerEvent } from "react";
import styles from "./FreeCropBox.module.scss";
export type FreeCropBoxProps = {
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & {
"--duration"?: string;
"--easing"?: string;
"--color"?: string;
"--photo"?: string;
};
type Rect = { x: number; y: number; w: number; h: number };
// useRef 는 T | null 을 돌려준다. 처음부터 값이 있고 절대 비지 않는 상자는 이렇게 좁혀 쓴다
type Box<T> = { current: T };
// 끌기 한 번의 상태 — 방향(빈 문자열이면 틀 전체 이동)과 직전 포인터 자리, 픽셀→퍼센트 환산 비율
type Drag = { dir: string; px: number; py: number; ux: number; uy: number };
// ambient 타입에는 KeyboardEvent 가 없다 — 키 핸들러가 실제로 쓰는 것만 지역 타입으로 적는다
type KeyEvt = { key: string; shiftKey: boolean; preventDefault(): void };
const SIZE_LABEL = "%w × %h";
const OUT_LABEL = "잘라낸 범위 %w × %h 픽셀";
const PHOTO_W = 300;
const PHOTO_H = 200;
const HANDLES: { dir: string; cls: string; label: string }[] = [
{ dir: "nw", cls: "handleNw", label: "왼쪽 위 손잡이" },
{ dir: "n", cls: "handleN", label: "위쪽 손잡이" },
{ dir: "ne", cls: "handleNe", label: "오른쪽 위 손잡이" },
{ dir: "w", cls: "handleW", label: "왼쪽 손잡이" },
{ dir: "e", cls: "handleE", label: "오른쪽 손잡이" },
{ dir: "sw", cls: "handleSw", label: "왼쪽 아래 손잡이" },
{ dir: "s", cls: "handleS", label: "아래쪽 손잡이" },
{ dir: "se", cls: "handleSe", label: "오른쪽 아래 손잡이" },
];
// 가짜 사진 — 외부 파일 없이 그라디언트와 도형으로 합성한다. 필터·히스토그램이 보이도록 대비를 세게 준다
function paintPhoto(c: CanvasRenderingContext2D, w: number, h: number): void {
const sky = c.createLinearGradient(0, 0, 0, h);
sky.addColorStop(0, "#2f6df6"); sky.addColorStop(0.55, "#ff9ebb"); sky.addColorStop(1, "#ffd23f");
c.fillStyle = sky; c.fillRect(0, 0, w, h);
c.fillStyle = "#fff7e6";
c.beginPath(); c.arc(w * 0.74, h * 0.28, h * 0.13, 0, Math.PI * 2); c.fill();
// 곧은 수평선 하나 — 기울기 보정이 눈에 보이려면 비교할 직선이 있어야 한다
c.fillStyle = "rgba(23, 20, 26, 0.5)"; c.fillRect(0, Math.round(h * 0.66), w, 2);
c.fillStyle = "#17141a";
c.beginPath(); c.moveTo(0, h); c.lineTo(w * 0.34, h * 0.36); c.lineTo(w * 0.62, h); c.closePath(); c.fill();
c.fillStyle = "#c2361a";
c.beginPath(); c.moveTo(w * 0.46, h); c.lineTo(w * 0.78, h * 0.52); c.lineTo(w, h); c.closePath(); c.fill();
for (let i = 0; i < 6; i++) { c.fillStyle = i % 2 ? "#fff7e6" : "#17141a"; c.fillRect(w * (0.08 + i * 0.07), h * (0.14 + (i % 3) * 0.06), 7, 7); }
}
function clampBox(box: Rect): void {
box.w = Math.max(18, Math.min(100, box.w)); box.h = Math.max(18, Math.min(100, box.h));
box.x = Math.max(0, Math.min(100 - box.w, box.x)); box.y = Math.max(0, Math.min(100 - box.h, box.y));
}
// 바깥을 어둡게 덮는 그늘. 겉 사각형을 한 바퀴 돌고 안쪽 구멍을 거꾸로 도는 열 점짜리 다각형이다
function shadeOf(box: Rect): string {
const x = box.x, y = box.y, w = box.w, h = box.h;
return "polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%, 0% 0%, " +
x + "% " + y + "%, " + x + "% " + (y + h) + "%, " + (x + w) + "% " + (y + h) + "%, " +
(x + w) + "% " + y + "%, " + x + "% " + y + "%)";
}
export default function FreeCropBox({
duration = 2000,
easing = "cubic-bezier(.2, .8, .2, 1)",
color = "#2f6df6",
}: FreeCropBoxProps) {
const workRef = useRef<HTMLDivElement | null>(null);
const photoRef = useRef<HTMLCanvasElement | null>(null);
const outRef = useRef<HTMLCanvasElement | null>(null);
const rectRef = useRef<Rect>({ x: 18, y: 16, w: 54, h: 56 }) as Box<Rect>;
const dragRef = useRef<Drag | null>(null);
const [box, setBox] = useState<Rect>({ x: 18, y: 16, w: 54, h: 56 });
const [demo, setDemo] = useState(true);
const [photo, setPhoto] = useState("");
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
if (photo) vars["--photo"] = `url(${photo})`;
// 결과 칸은 잘린 크기 그대로 만든다 — 고정 정사각에 그리면 비율이 눌린다
const pw = Math.round(PHOTO_W * box.w / 100);
const ph = Math.round(PHOTO_H * box.h / 100);
const commit = useCallback(() => {
clampBox(rectRef.current);
setBox({ x: rectRef.current.x, y: rectRef.current.y, w: rectRef.current.w, h: rectRef.current.h });
}, []);
useEffect(() => {
const canvas = photoRef.current;
if (!canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
paintPhoto(ctx, canvas.width, canvas.height);
setPhoto(canvas.toDataURL());
}, []);
// 실제 자르기 — 원본에서 오려낼 사각형과 그려 넣을 사각형을 따로 주는 아홉 인자 drawImage
useEffect(() => {
const source = photoRef.current;
const out = outRef.current;
if (!source || !out) return;
const octx = out.getContext("2d");
if (!octx) return;
const sx = source.width * box.x / 100, sy = source.height * box.y / 100;
const sw = source.width * box.w / 100, sh = source.height * box.h / 100;
out.width = Math.round(sw); out.height = Math.round(sh);
octx.drawImage(source, sx, sy, sw, sh, 0, 0, out.width, out.height);
}, [box, photo]);
function unit(): { x: number; y: number } {
const work = workRef.current;
if (!work) return { x: 0, y: 0 };
const r = work.getBoundingClientRect();
return { x: 100 / r.width, y: 100 / r.height };
}
function nudge(dir: string, dx: number, dy: number): void {
const rect = rectRef.current;
if (dir.indexOf("w") > -1) { rect.x += dx; rect.w -= dx; }
if (dir.indexOf("e") > -1) { rect.w += dx; }
if (dir.indexOf("n") > -1) { rect.y += dy; rect.h -= dy; }
if (dir.indexOf("s") > -1) { rect.h += dy; }
commit();
}
function startDrag(event: ReactPointerEvent<HTMLElement>, dir: string): void {
setDemo(false);
const u = unit();
dragRef.current = { dir: dir, px: event.clientX, py: event.clientY, ux: u.x, uy: u.y };
event.currentTarget.setPointerCapture(event.pointerId);
}
function onMove(event: ReactPointerEvent<HTMLElement>): void {
const drag = dragRef.current;
if (!drag) return;
const dx = (event.clientX - drag.px) * drag.ux, dy = (event.clientY - drag.py) * drag.uy;
drag.px = event.clientX; drag.py = event.clientY;
if (drag.dir) {
nudge(drag.dir, dx, dy);
} else {
rectRef.current.x += dx; rectRef.current.y += dy; commit();
}
}
function onStop(): void {
dragRef.current = null;
}
function onBoxDown(event: ReactPointerEvent<HTMLDivElement>): void {
if (event.target !== event.currentTarget) return;
startDrag(event, "");
}
function onHandleDown(event: ReactPointerEvent<HTMLButtonElement>, dir: string): void {
event.stopPropagation();
startDrag(event, dir);
}
// 화살표 1px, Shift 와 함께면 10px — 손이 아니라 키보드로도 같은 조작이 된다
function onHandleKey(event: KeyEvt, dir: string): void {
const step = event.shiftKey ? 10 : 1, u = unit();
let dx = 0, dy = 0;
if (event.key === "ArrowLeft") { dx = -step * u.x; } else if (event.key === "ArrowRight") { dx = step * u.x; }
else if (event.key === "ArrowUp") { dy = -step * u.y; } else if (event.key === "ArrowDown") { dy = step * u.y; }
else { return; }
event.preventDefault(); setDemo(false); nudge(dir, dx, dy);
}
const cls = [styles.root, demo ? styles.isDemo : ""].filter(Boolean).join(" ");
return (
<main className={styles.stage}>
<div className={cls} style={vars}>
<div className={styles.work} ref={workRef}>
<canvas className={styles.photo} ref={photoRef} width={PHOTO_W} height={PHOTO_H} role="img" aria-label="샘플 사진"></canvas>
<div className={styles.shade} style={{ clipPath: shadeOf(box) }} aria-hidden="true"></div>
<div
className={styles.box}
style={{ left: `${box.x}%`, top: `${box.y}%`, width: `${box.w}%`, height: `${box.h}%` }}
onPointerDown={onBoxDown}
onPointerMove={onMove}
onPointerUp={onStop}
>
<span className={styles.grid} aria-hidden="true"></span>
{HANDLES.map((handle) => (
<button
key={handle.dir}
type="button"
className={`${styles.handle} ${styles[handle.cls]}`}
aria-label={handle.label}
onPointerDown={(event: ReactPointerEvent<HTMLButtonElement>) => onHandleDown(event, handle.dir)}
onPointerMove={onMove}
onPointerUp={onStop}
onKeyDown={(event: KeyEvt) => onHandleKey(event, handle.dir)}
></button>
))}
</div>
</div>
<div className={styles.side}>
<p className={styles.cap}>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" className={styles.ico} aria-hidden="true"><g fill="currentColor"><path d="M192 64v128H64V64Z" opacity=".2"/><path d="M240 192a8 8 0 0 1-8 8h-32v32a8 8 0 0 1-16 0v-32H64a8 8 0 0 1-8-8V72H24a8 8 0 0 1 0-16h32V24a8 8 0 0 1 16 0v160h160a8 8 0 0 1 8 8M96 72h88v88a8 8 0 0 0 16 0V64a8 8 0 0 0-8-8H96a8 8 0 0 0 0 16"/></g></svg>
<span>결과 칸</span>
</p>
<div className={styles.frame}>
<canvas className={styles.out} ref={outRef} width={120} height={120} role="img" aria-label={OUT_LABEL.replace("%w", String(pw)).replace("%h", String(ph))}></canvas>
<span className={styles.ghost} aria-hidden="true"></span>
</div>
<p className={styles.size}>{SIZE_LABEL.replace("%w", String(pw)).replace("%h", String(ph))}</p>
</div>
</div>
</main>
);
}01 · 자유 크롭 박스 — 측정 누적면적 18.917% · 움직인 프레임 19/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드에서 볼 곳
오른쪽 아래 손잡이를 끌면 162 × 112 였던 범위가 111 × 72 로 줄어드는 것을 Playwright 로 확인했고, 미리보기에서 누적면적 18.917%·움직인 프레임 19/23 을 기록했습니다.
// 바깥을 어둡게 덮는 그늘. 겉 사각형을 한 바퀴 돌고 안쪽 구멍을 거꾸로 도는 열 점짜리 다각형이다
shade.style.clipPath = 'polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%, 0% 0%, ' +
x + '% ' + y + '%, ' + x + '% ' + (y + h) + '%, ' + (x + w) + '% ' + (y + h) + '%, ' +
(x + w) + '% ' + y + '%, ' + x + '% ' + y + '%)';
var sx = photo.width * x / 100, sy = photo.height * y / 100;
var sw = photo.width * w / 100, sh = photo.height * h / 100;
var pw = Math.round(sw), ph = Math.round(sh);
// 결과 칸은 잘린 크기 그대로 만든다 — 고정 정사각에 그리면 비율이 눌린다
out.width = pw; out.height = ph;
// 아홉 인자 drawImage — 원본에서 오려낼 사각형과 그려 넣을 사각형을 따로 준다
octx.drawImage(photo, sx, sy, sw, sh, 0, 0, pw, ph);
02회전과 수평 보정
이미지 크롭 작업에서 각도는 어디서 잡나
각도를 밀면 사진이 기울고, 모서리에 빈 자리가 생기지 않도록 필요한 배율을 삼각함수로 같이 계산합니다. 12도에서 배율은 1.31배로 나오는데 이 값은 감이 아니라 기운 사각형의 가로변과 세로변이 얼마나 길어지는지에서 곧바로 나옵니다.

<main class="stage">
<div class="rs is-demo" id="rs">
<div class="rs__frame">
<canvas class="rs__view" id="view" width="320" height="200" role="img" aria-label="샘플 사진"></canvas>
<span class="rs__ghost" aria-hidden="true"></span>
<span class="rs__grid" aria-hidden="true"></span>
</div>
<div class="rs__ctl">
<span class="rs__lab"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" class="rs__ico" aria-hidden="true"><g fill="currentColor"><path d="M216 128a88 88 0 1 1-88-88a88 88 0 0 1 88 88" opacity=".2"/><path d="M224 48v48a8 8 0 0 1-8 8h-48a8 8 0 0 1 0-16h28.69l-14.63-14.63a79.56 79.56 0 0 0-56.13-23.43h-.45a79.52 79.52 0 0 0-55.89 22.77a8 8 0 0 1-11.18-11.44a96 96 0 0 1 135 .79L208 76.69V48a8 8 0 0 1 16 0m-37.59 135.29a80 80 0 0 1-112.47-.66L59.31 168H88a8 8 0 0 0 0-16H40a8 8 0 0 0-8 8v48a8 8 0 0 0 16 0v-28.69l14.63 14.63A95.43 95.43 0 0 0 130 222.06h.53a95.36 95.36 0 0 0 67.07-27.33a8 8 0 0 0-11.18-11.44Z"/></g></svg><span>수평 보정</span></span>
<div class="rs__track" id="track" role="slider" tabindex="0" aria-label="기운 각도 조절" aria-valuemin="-15" aria-valuemax="15" aria-valuenow="0">
<span class="rs__thumb" id="thumb"></span>
</div>
<span class="rs__vals">
<span class="rs__real" id="val">0.0° · 1.00×</span>
<span class="rs__demo rs__demo--a" aria-hidden="true">-12.0° · 1.31×</span>
<span class="rs__demo rs__demo--b" aria-hidden="true">0.0° · 1.00×</span>
</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 회전과 수평 보정 — 실제 보정은 canvas 2D 가 한다(translate → rotate → scale → drawImage).
// 격자 미리보기(.is-demo)는 캔버스를 감추고 같은 사진을 배경으로 깐 유령 판을 CSS 로 돌린다.
// demo.py 는 CSS 애니메이션 시계로 프레임을 뽑으므로 캔버스에 그린 그림은 24장이 전부 같기 때문이다.
$duration: $dur-loop;
$easing: $ease-out;
$color: $subject-cream;
@include stage($stage-ink);
.rs { display: flex; flex-direction: column; align-items: center; gap: $sp-2; width: 100%; }
.rs__frame {
position: relative; height: clamp(110px, 60vh, 196px); aspect-ratio: 16 / 10; max-width: 100%;
border-radius: $r-sm; overflow: hidden; box-shadow: $shadow-raised;
}
.rs__view { display: block; width: 100%; height: 100%; }
.rs__ghost {
display: none; position: absolute; inset: 0;
background-image: var(--photo); background-size: cover;
}
.rs__grid {
position: absolute; inset: 0;
background-image:
linear-gradient(to right, rgba(255, 255, 255, .42) 1px, transparent 1px),
linear-gradient(to bottom, rgba(255, 255, 255, .42) 1px, transparent 1px);
background-size: 33.34% 33.34%;
background-position: 33.33% 33.33%;
}
.rs__ctl { display: flex; align-items: center; gap: $sp-2; color: $color; }
.rs__lab {
display: flex; align-items: center; gap: $sp-1;
font-weight: 800; font-size: clamp(9px, 2.4vw, 11px);
}
.rs__ico { width: 13px; height: 13px; flex: 0 0 auto; }
.rs__track {
position: relative; flex: 0 0 auto; width: 120px; height: 6px; cursor: pointer;
border-radius: $r-pill; background: rgba(255, 247, 230, .28);
}
.rs__track:focus-visible { outline: 3px solid $subject-blue; outline-offset: 4px; }
.rs__thumb {
position: absolute; top: 50%; left: 50%; width: 14px; height: 14px;
border-radius: 50%; background: $stage-paper; box-shadow: $shadow-press;
transform: translate(-50%, -50%);
}
.rs__vals {
position: relative; flex: 0 0 auto; display: inline-block; min-width: 84px;
font-weight: 700; font-size: clamp(9px, 2.4vw, 11px);
font-family: ui-monospace, "Consolas", monospace; color: rgba(255, 247, 230, .82);
}
.rs__demo { display: none; position: absolute; left: 0; top: 0; white-space: nowrap; }
// ── 미리보기 루프: 기울어진 사진에서 시작해 천천히 수평을 맞추고, 76~78% 에 한 번에 다시 기운다.
// poster 는 앞 장과 가장 크게 달라진 장이 뽑히므로, 가장 빠른 구간 바로 뒤가 대표 이미지가 된다 —
// 그래서 되돌아가는 쪽을 빠르게 두어 「기울어진 사진」이 카드에 걸리게 했다 (실측: 반대로 두면 수평 장면이 뽑혔다).
// 배율은 zoomFor 로 미리 계산한 값 그대로다 — 12도는 1.31배, 0도는 1.00배
.rs.is-demo .rs__view { visibility: hidden; }
.rs.is-demo .rs__ghost {
display: block;
animation-name: rsTilt; animation-duration: $duration;
animation-timing-function: ease-in-out; animation-iteration-count: infinite;
}
@keyframes rsTilt {
0%, 26% { transform: rotate(-12deg) scale(1.31); }
56%, 76% { transform: rotate(0deg) scale(1); }
78%, 100% { transform: rotate(-12deg) scale(1.31); }
}
.rs.is-demo .rs__thumb {
animation-name: rsSlide; animation-duration: $duration;
animation-timing-function: ease-in-out; animation-iteration-count: infinite;
}
@keyframes rsSlide {
0%, 26% { transform: translate(-50%, -50%) translateX(-48px); }
56%, 76% { transform: translate(-50%, -50%); }
78%, 100% { transform: translate(-50%, -50%) translateX(-48px); }
}
// 값은 보간할 수 없으니 두 칸을 steps(1, end) 로 갈아 끼운다. 천천히 수평을 맞추는 구간의
// 한가운데(40%)에서 바꿔 앞뒤 어긋남을 반으로 줄였다 — 가장 크게 변하는 장면은 값과 기울기가 맞는 자리다
.rs.is-demo .rs__real { color: transparent; }
.rs.is-demo .rs__demo {
display: block;
animation-duration: $duration;
animation-timing-function: steps(1, end); animation-iteration-count: infinite;
}
.rs.is-demo .rs__demo--a { animation-name: rsValA; }
.rs.is-demo .rs__demo--b { animation-name: rsValB; }
@keyframes rsValA { 0%, 40% { opacity: 1; } 41%, 76% { opacity: 0; } 77%, 100% { opacity: 1; } }
@keyframes rsValB { 0%, 40% { opacity: 0; } 41%, 76% { opacity: 1; } 77%, 100% { opacity: 0; } }
@media (max-width: 340px) {
.rs__track { width: 84px; }
.rs__ctl { gap: $sp-1; }
}
@media (prefers-reduced-motion: reduce) {
.rs.is-demo .rs__ghost,
.rs.is-demo .rs__thumb,
.rs.is-demo .rs__demo { animation: none !important; }
// 움직임 없이 왼쪽으로 기울여 둔 상태로 세운다
.rs.is-demo .rs__ghost { transform: rotate(-12deg) scale(1.31) !important; }
.rs.is-demo .rs__thumb { transform: translate(-50%, -50%) translateX(-48px) !important; }
.rs.is-demo .rs__demo--b { opacity: 0 !important; }
.rs.is-demo .rs__demo--a { opacity: 1 !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.rs {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
width: 100%;
}
.rs__frame {
position: relative;
height: clamp(110px, 60vh, 196px);
aspect-ratio: 16/10;
max-width: 100%;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.rs__view {
display: block;
width: 100%;
height: 100%;
}
.rs__ghost {
display: none;
position: absolute;
inset: 0;
background-image: var(--photo);
background-size: cover;
}
.rs__grid {
position: absolute;
inset: 0;
background-image: linear-gradient(to right, rgba(255, 255, 255, 0.42) 1px, transparent 1px), linear-gradient(to bottom, rgba(255, 255, 255, 0.42) 1px, transparent 1px);
background-size: 33.34% 33.34%;
background-position: 33.33% 33.33%;
}
.rs__ctl {
display: flex;
align-items: center;
gap: 8px;
color: #fff7e6;
}
.rs__lab {
display: flex;
align-items: center;
gap: 4px;
font-weight: 800;
font-size: clamp(9px, 2.4vw, 11px);
}
.rs__ico {
width: 13px;
height: 13px;
flex: 0 0 auto;
}
.rs__track {
position: relative;
flex: 0 0 auto;
width: 120px;
height: 6px;
cursor: pointer;
border-radius: 999px;
background: rgba(255, 247, 230, 0.28);
}
.rs__track:focus-visible {
outline: 3px solid #2f6df6;
outline-offset: 4px;
}
.rs__thumb {
position: absolute;
top: 50%;
left: 50%;
width: 14px;
height: 14px;
border-radius: 50%;
background: #ffffff;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
transform: translate(-50%, -50%);
}
.rs__vals {
position: relative;
flex: 0 0 auto;
display: inline-block;
min-width: 84px;
font-weight: 700;
font-size: clamp(9px, 2.4vw, 11px);
font-family: ui-monospace, "Consolas", monospace;
color: rgba(255, 247, 230, 0.82);
}
.rs__demo {
display: none;
position: absolute;
left: 0;
top: 0;
white-space: nowrap;
}
.rs.is-demo .rs__view {
visibility: hidden;
}
.rs.is-demo .rs__ghost {
display: block;
animation-name: rsTilt;
animation-duration: 2s;
animation-timing-function: ease-in-out;
animation-iteration-count: infinite;
}
@keyframes rsTilt {
0%, 26% {
transform: rotate(-12deg) scale(1.31);
}
56%, 76% {
transform: rotate(0deg) scale(1);
}
78%, 100% {
transform: rotate(-12deg) scale(1.31);
}
}
.rs.is-demo .rs__thumb {
animation-name: rsSlide;
animation-duration: 2s;
animation-timing-function: ease-in-out;
animation-iteration-count: infinite;
}
@keyframes rsSlide {
0%, 26% {
transform: translate(-50%, -50%) translateX(-48px);
}
56%, 76% {
transform: translate(-50%, -50%);
}
78%, 100% {
transform: translate(-50%, -50%) translateX(-48px);
}
}
.rs.is-demo .rs__real {
color: transparent;
}
.rs.is-demo .rs__demo {
display: block;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.rs.is-demo .rs__demo--a {
animation-name: rsValA;
}
.rs.is-demo .rs__demo--b {
animation-name: rsValB;
}
@keyframes rsValA {
0%, 40% {
opacity: 1;
}
41%, 76% {
opacity: 0;
}
77%, 100% {
opacity: 1;
}
}
@keyframes rsValB {
0%, 40% {
opacity: 0;
}
41%, 76% {
opacity: 1;
}
77%, 100% {
opacity: 0;
}
}
@media (max-width: 340px) {
.rs__track {
width: 84px;
}
.rs__ctl {
gap: 4px;
}
}
@media (prefers-reduced-motion: reduce) {
.rs.is-demo .rs__ghost,
.rs.is-demo .rs__thumb,
.rs.is-demo .rs__demo {
animation: none !important;
}
.rs.is-demo .rs__ghost {
transform: rotate(-12deg) scale(1.31) !important;
}
.rs.is-demo .rs__thumb {
transform: translate(-50%, -50%) translateX(-48px) !important;
}
.rs.is-demo .rs__demo--b {
opacity: 0 !important;
}
.rs.is-demo .rs__demo--a {
opacity: 1 !important;
}
}var rs = document.getElementById('rs');
var view = document.getElementById('view');
var track = document.getElementById('track');
var thumb = document.getElementById('thumb');
var val = document.getElementById('val');
var vctx = view.getContext('2d');
var src = document.createElement('canvas');
var angle = 0;
var VOICE = '기운 각도 %a도, 배율 %k배';
// 가짜 사진 — 외부 파일 없이 그라디언트와 도형으로 합성한다. 필터·히스토그램이 보이도록 대비를 세게 준다
function paintPhoto(c, w, h) {
var sky = c.createLinearGradient(0, 0, 0, h);
sky.addColorStop(0, '#2f6df6'); sky.addColorStop(0.55, '#ff9ebb'); sky.addColorStop(1, '#ffd23f');
c.fillStyle = sky; c.fillRect(0, 0, w, h);
c.fillStyle = '#fff7e6';
c.beginPath(); c.arc(w * 0.74, h * 0.28, h * 0.13, 0, Math.PI * 2); c.fill();
// 곧은 수평선 하나 — 기울기 보정이 눈에 보이려면 비교할 직선이 있어야 한다
c.fillStyle = 'rgba(23, 20, 26, 0.5)'; c.fillRect(0, Math.round(h * 0.66), w, 2);
c.fillStyle = '#17141a';
c.beginPath(); c.moveTo(0, h); c.lineTo(w * 0.34, h * 0.36); c.lineTo(w * 0.62, h); c.closePath(); c.fill();
c.fillStyle = '#c2361a';
c.beginPath(); c.moveTo(w * 0.46, h); c.lineTo(w * 0.78, h * 0.52); c.lineTo(w, h); c.closePath(); c.fill();
for (var i = 0; i < 6; i++) { c.fillStyle = i % 2 ? '#fff7e6' : '#17141a'; c.fillRect(w * (0.08 + i * 0.07), h * (0.14 + (i % 3) * 0.06), 7, 7); }
}
// 기운 사각형이 원래 칸을 끝까지 덮으려면 얼마나 키워야 하는가.
// 가로변은 w·|cos| + h·|sin| 로 늘어나고 세로변은 w·|sin| + h·|cos| 로 늘어난다 — 둘 중 큰 쪽이 배율이다
function zoomFor(rad, w, h) {
var c = Math.abs(Math.cos(rad)), s = Math.abs(Math.sin(rad));
return Math.max((w * c + h * s) / w, (w * s + h * c) / h);
}
function render() {
var w = view.width, h = view.height, rad = angle * Math.PI / 180, k = zoomFor(rad, w, h);
vctx.setTransform(1, 0, 0, 1, 0, 0);
vctx.clearRect(0, 0, w, h);
vctx.translate(w / 2, h / 2);
vctx.rotate(rad);
vctx.scale(k, k);
vctx.drawImage(src, -w / 2, -h / 2, w, h);
thumb.style.left = ((angle + 15) / 30 * 100) + '%';
val.textContent = angle.toFixed(1) + '° · ' + k.toFixed(2) + '×';
track.setAttribute('aria-valuenow', angle.toFixed(1));
track.setAttribute('aria-valuetext', VOICE.replace('%a', angle.toFixed(1)).replace('%k', k.toFixed(2)));
}
function setAngle(next) {
angle = Math.max(-15, Math.min(15, next));
rs.classList.remove('is-demo');
render();
}
function fromPointer(e) {
var r = track.getBoundingClientRect();
setAngle(Math.round(((e.clientX - r.left) / r.width * 30 - 15) * 10) / 10);
}
track.addEventListener('pointerdown', function (e) {
track.setPointerCapture(e.pointerId);
fromPointer(e);
track.addEventListener('pointermove', drag);
});
function drag(e) { if (e.buttons) { fromPointer(e); } }
track.addEventListener('pointerup', function () { track.removeEventListener('pointermove', drag); });
// 화살표 0.5도, Shift 와 함께면 5도 — 미세 보정은 손보다 키보드가 정확하다
track.addEventListener('keydown', function (e) {
var step = e.shiftKey ? 5 : 0.5;
if (e.key === 'ArrowLeft' || e.key === 'ArrowDown') { e.preventDefault(); setAngle(angle - step); }
else if (e.key === 'ArrowRight' || e.key === 'ArrowUp') { e.preventDefault(); setAngle(angle + step); }
else if (e.key === 'Home') { e.preventDefault(); setAngle(0); }
});
src.width = 320; src.height = 200;
paintPhoto(src.getContext('2d'), src.width, src.height);
document.documentElement.style.setProperty('--photo', 'url(' + src.toDataURL() + ')');
render();// AngleStraighten — vanilla 판(run/295/demo/02-image-edit-rotate-straighten/index.html) 의 React 포트.
// 보정은 canvas 2D 한 번에 한다 — 가운데로 옮기고(translate) 돌리고(rotate) 빈 귀퉁이가 안 생길 만큼 키운다(scale).
// 얼마나 키워야 하는지는 zoomFor 가 삼각함수로 계산한다. 값·수치는 vanilla 와 같다.
import { useEffect, useRef, useState } from "react";
import type { CSSProperties, PointerEvent as ReactPointerEvent } from "react";
import styles from "./AngleStraighten.module.scss";
export type AngleStraightenProps = {
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & {
"--duration"?: string;
"--easing"?: string;
"--color"?: string;
"--photo"?: string;
};
// ambient 타입에는 KeyboardEvent 가 없다 — 키 핸들러가 실제로 쓰는 것만 지역 타입으로 적는다
type KeyEvt = { key: string; shiftKey: boolean; preventDefault(): void };
const VOICE = "기운 각도 %a도, 배율 %k배";
const VIEW_W = 320;
const VIEW_H = 200;
// 가짜 사진 — 외부 파일 없이 그라디언트와 도형으로 합성한다. 필터·히스토그램이 보이도록 대비를 세게 준다
function paintPhoto(c: CanvasRenderingContext2D, w: number, h: number): void {
const sky = c.createLinearGradient(0, 0, 0, h);
sky.addColorStop(0, "#2f6df6"); sky.addColorStop(0.55, "#ff9ebb"); sky.addColorStop(1, "#ffd23f");
c.fillStyle = sky; c.fillRect(0, 0, w, h);
c.fillStyle = "#fff7e6";
c.beginPath(); c.arc(w * 0.74, h * 0.28, h * 0.13, 0, Math.PI * 2); c.fill();
// 곧은 수평선 하나 — 기울기 보정이 눈에 보이려면 비교할 직선이 있어야 한다
c.fillStyle = "rgba(23, 20, 26, 0.5)"; c.fillRect(0, Math.round(h * 0.66), w, 2);
c.fillStyle = "#17141a";
c.beginPath(); c.moveTo(0, h); c.lineTo(w * 0.34, h * 0.36); c.lineTo(w * 0.62, h); c.closePath(); c.fill();
c.fillStyle = "#c2361a";
c.beginPath(); c.moveTo(w * 0.46, h); c.lineTo(w * 0.78, h * 0.52); c.lineTo(w, h); c.closePath(); c.fill();
for (let i = 0; i < 6; i++) { c.fillStyle = i % 2 ? "#fff7e6" : "#17141a"; c.fillRect(w * (0.08 + i * 0.07), h * (0.14 + (i % 3) * 0.06), 7, 7); }
}
// 기운 사각형이 원래 칸을 끝까지 덮으려면 얼마나 키워야 하는가.
// 가로변은 w·|cos| + h·|sin| 로 늘어나고 세로변은 w·|sin| + h·|cos| 로 늘어난다 — 둘 중 큰 쪽이 배율이다
function zoomFor(rad: number, w: number, h: number): number {
const c = Math.abs(Math.cos(rad)), s = Math.abs(Math.sin(rad));
return Math.max((w * c + h * s) / w, (w * s + h * c) / h);
}
export default function AngleStraighten({
duration = 2000,
easing = "ease-in-out",
color = "#fff7e6",
}: AngleStraightenProps) {
const viewRef = useRef<HTMLCanvasElement | null>(null);
const srcRef = useRef<HTMLCanvasElement | null>(null);
const trackRef = useRef<HTMLDivElement | null>(null);
const dragRef = useRef<boolean | null>(false);
const [angle, setAngle] = useState(0);
const [demo, setDemo] = useState(true);
const [photo, setPhoto] = useState("");
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
if (photo) vars["--photo"] = `url(${photo})`;
const rad = angle * Math.PI / 180;
const zoom = zoomFor(rad, VIEW_W, VIEW_H);
const shown = `${angle.toFixed(1)}° · ${zoom.toFixed(2)}×`;
// 원본은 화면에 없는 캔버스에 한 번만 그려 두고, 보정할 때마다 거기서 가져다 쓴다
useEffect(() => {
const src = document.createElement("canvas");
src.width = VIEW_W; src.height = VIEW_H;
const sctx = src.getContext("2d");
if (!sctx) return;
paintPhoto(sctx, src.width, src.height);
srcRef.current = src;
setPhoto(src.toDataURL());
}, []);
useEffect(() => {
const view = viewRef.current, src = srcRef.current;
if (!view || !src) return;
const vctx = view.getContext("2d");
if (!vctx) return;
const w = view.width, h = view.height, k = zoomFor(angle * Math.PI / 180, w, h);
vctx.setTransform(1, 0, 0, 1, 0, 0);
vctx.clearRect(0, 0, w, h);
vctx.translate(w / 2, h / 2);
vctx.rotate(angle * Math.PI / 180);
vctx.scale(k, k);
vctx.drawImage(src, -w / 2, -h / 2, w, h);
}, [angle, photo]);
function applyAngle(next: number): void {
setAngle(Math.max(-15, Math.min(15, next)));
setDemo(false);
}
function fromPointer(event: ReactPointerEvent<HTMLDivElement>): void {
const track = trackRef.current;
if (!track) return;
const r = track.getBoundingClientRect();
applyAngle(Math.round(((event.clientX - r.left) / r.width * 30 - 15) * 10) / 10);
}
function onPointerDown(event: ReactPointerEvent<HTMLDivElement>): void {
event.currentTarget.setPointerCapture(event.pointerId);
dragRef.current = true;
fromPointer(event);
}
function onPointerMove(event: ReactPointerEvent<HTMLDivElement>): void {
if (!dragRef.current || !event.buttons) return;
fromPointer(event);
}
function onPointerUp(): void {
dragRef.current = false;
}
// 화살표 0.5도, Shift 와 함께면 5도 — 미세 보정은 손보다 키보드가 정확하다
function onKeyDown(event: KeyEvt): void {
const step = event.shiftKey ? 5 : 0.5;
if (event.key === "ArrowLeft" || event.key === "ArrowDown") { event.preventDefault(); applyAngle(angle - step); }
else if (event.key === "ArrowRight" || event.key === "ArrowUp") { event.preventDefault(); applyAngle(angle + step); }
else if (event.key === "Home") { event.preventDefault(); applyAngle(0); }
}
const cls = [styles.root, demo ? styles.isDemo : ""].filter(Boolean).join(" ");
return (
<main className={styles.stage}>
<div className={cls} style={vars}>
<div className={styles.frame}>
<canvas className={styles.view} ref={viewRef} width={VIEW_W} height={VIEW_H} role="img" aria-label="샘플 사진"></canvas>
<span className={styles.ghost} aria-hidden="true"></span>
<span className={styles.grid} aria-hidden="true"></span>
</div>
<div className={styles.ctl}>
<span className={styles.lab}>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" className={styles.ico} aria-hidden="true"><g fill="currentColor"><path d="M216 128a88 88 0 1 1-88-88a88 88 0 0 1 88 88" opacity=".2"/><path d="M224 48v48a8 8 0 0 1-8 8h-48a8 8 0 0 1 0-16h28.69l-14.63-14.63a79.56 79.56 0 0 0-56.13-23.43h-.45a79.52 79.52 0 0 0-55.89 22.77a8 8 0 0 1-11.18-11.44a96 96 0 0 1 135 .79L208 76.69V48a8 8 0 0 1 16 0m-37.59 135.29a80 80 0 0 1-112.47-.66L59.31 168H88a8 8 0 0 0 0-16H40a8 8 0 0 0-8 8v48a8 8 0 0 0 16 0v-28.69l14.63 14.63A95.43 95.43 0 0 0 130 222.06h.53a95.36 95.36 0 0 0 67.07-27.33a8 8 0 0 0-11.18-11.44Z"/></g></svg>
<span>수평 보정</span>
</span>
<div
className={styles.track}
ref={trackRef}
role="slider"
tabIndex={0}
aria-label="기운 각도 조절"
aria-valuemin={-15}
aria-valuemax={15}
aria-valuenow={angle.toFixed(1)}
aria-valuetext={VOICE.replace("%a", angle.toFixed(1)).replace("%k", zoom.toFixed(2))}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onKeyDown={onKeyDown}
>
<span className={styles.thumb} style={{ left: `${(angle + 15) / 30 * 100}%` }}></span>
</div>
<span className={styles.vals}>
<span className={styles.real}>{shown}</span>
<span className={`${styles.valDemo} ${styles.valA}`} aria-hidden="true">-12.0° · 1.31×</span>
<span className={`${styles.valDemo} ${styles.valB}`} aria-hidden="true">0.0° · 1.00×</span>
</span>
</div>
</div>
</main>
);
}02 · 회전과 수평 보정 — 측정 누적면적 22.342% · 움직인 프레임 9/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드 읽는 법
격자 미리보기는 기울어진 상태에서 시작해 수평으로 폈다가 다시 기울며 누적면적 22.342%·강도 65.5 를 기록했습니다.
// 기운 사각형이 원래 칸을 끝까지 덮으려면 얼마나 키워야 하는가.
// 가로변은 w·|cos| + h·|sin| 로 늘어나고 세로변은 w·|sin| + h·|cos| 로 늘어난다 — 둘 중 큰 쪽이 배율이다
function zoomFor(rad, w, h) {
var c = Math.abs(Math.cos(rad)), s = Math.abs(Math.sin(rad));
return Math.max((w * c + h * s) / w, (w * s + h * c) / h);
}
vctx.setTransform(1, 0, 0, 1, 0, 0);
vctx.clearRect(0, 0, w, h);
vctx.translate(w / 2, h / 2);
vctx.rotate(rad);
vctx.scale(k, k);
vctx.drawImage(src, -w / 2, -h / 2, w, h);
03필터 프리셋 여섯 개
어떤 움직임인가
원본·흑백·세피아·선명·차분·빈티지 여섯 개가 각자 filter 문자열 하나로만 정의돼 있고, 누르면 큰 사진과 아래 글자가 동시에 바뀝니다. 격자 미리보기에서만 여섯 상태를 이어 돌리는데, 이때는 함수 목록을 여섯 개로 통일해 적습니다.

<main class="stage">
<div class="fp is-demo" id="fp">
<div class="fp__frame">
<canvas class="fp__view" id="view" width="320" height="200" role="img" aria-label="미리보기 사진, 필터 없음"></canvas>
<span class="fp__ghost" aria-hidden="true"></span>
</div>
<div class="fp__row" id="row">
<span class="fp__ring" aria-hidden="true"></span>
<button type="button" class="fp__p" data-f="none" aria-pressed="true"><span class="fp__sw fp__sw--a" aria-hidden="true"></span><span class="fp__name">원본</span></button>
<button type="button" class="fp__p" data-f="grayscale(1) contrast(1.08)" aria-pressed="false"><span class="fp__sw fp__sw--b" aria-hidden="true"></span><span class="fp__name">흑백</span></button>
<button type="button" class="fp__p" data-f="sepia(0.75) saturate(1.2)" aria-pressed="false"><span class="fp__sw fp__sw--c" aria-hidden="true"></span><span class="fp__name">세피아</span></button>
<button type="button" class="fp__p" data-f="contrast(1.3) saturate(1.35)" aria-pressed="false"><span class="fp__sw fp__sw--d" aria-hidden="true"></span><span class="fp__name">선명</span></button>
<button type="button" class="fp__p" data-f="saturate(0.55) brightness(1.08)" aria-pressed="false"><span class="fp__sw fp__sw--e" aria-hidden="true"></span><span class="fp__name">차분</span></button>
<button type="button" class="fp__p" data-f="sepia(0.4) contrast(0.9) hue-rotate(-14deg)" aria-pressed="false"><span class="fp__sw fp__sw--f" aria-hidden="true"></span><span class="fp__name">빈티지</span></button>
</div>
<p class="fp__code">
<code class="fp__real" id="code">filter: none;</code>
<code class="fp__demo fp__demo--a" aria-hidden="true">filter: none;</code>
<code class="fp__demo fp__demo--b" aria-hidden="true">filter: grayscale(1) contrast(1.08);</code>
<code class="fp__demo fp__demo--c" aria-hidden="true">filter: sepia(0.75) saturate(1.2);</code>
<code class="fp__demo fp__demo--d" aria-hidden="true">filter: contrast(1.3) saturate(1.35);</code>
<code class="fp__demo fp__demo--e" aria-hidden="true">filter: saturate(0.55) brightness(1.08);</code>
<code class="fp__demo fp__demo--f" aria-hidden="true">filter: sepia(0.4) contrast(0.9) hue-rotate(-14deg);</code>
</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 필터 프리셋 여섯 개 — 누르면 큰 사진과 아래 글자가 같이 바뀐다. 프리셋 값은 index.html 의 data-f 하나뿐이다.
// 격자 미리보기(.is-demo)는 유령 판의 filter 를 여섯 상태로 이어 돌린다. 이때만 함수 목록을
// grayscale sepia saturate contrast brightness hue-rotate 여섯 개로 통일해 적는다 —
// 함수 목록이 서로 다르면 브라우저가 보간을 포기하고 툭 끊기기 때문이다. 계산 결과는 프리셋과 같다.
$duration: $dur-loop;
$easing: $ease-out;
$color: $subject-ink;
@include stage($stage-yellow);
.fp { display: flex; flex-direction: column; align-items: center; gap: $sp-2; width: 100%; }
.fp__frame {
position: relative; height: clamp(84px, 42vh, 148px); aspect-ratio: 16 / 10; max-width: 100%;
border-radius: $r-sm; overflow: hidden; box-shadow: $shadow-raised;
}
.fp__view { display: block; width: 100%; height: 100%; }
.fp__ghost {
display: none; position: absolute; inset: 0;
background-image: var(--photo); background-size: cover;
}
.fp__row {
position: relative; display: grid; grid-template-columns: repeat(6, 1fr);
width: min(100%, 300px);
}
.fp__ring {
position: absolute; top: 0; left: 0; width: calc(100% / 6); height: 100%;
border: 2px solid $color; border-radius: $r-sm; pointer-events: none;
}
.fp__p {
display: grid; justify-items: center; gap: $sp-1; width: 100%; cursor: pointer;
padding: $sp-1; border: 0; background: transparent; color: $color;
font-family: inherit; font-weight: 700;
}
.fp__p:focus-visible { outline: 3px solid $subject-blue; outline-offset: 2px; }
.fp__sw {
width: 100%; height: 20px; border-radius: $r-xs;
background-image: var(--photo); background-size: cover; background-position: center;
}
.fp__sw--b { filter: grayscale(1) contrast(1.08); }
.fp__sw--c { filter: sepia(0.75) saturate(1.2); }
.fp__sw--d { filter: contrast(1.3) saturate(1.35); }
.fp__sw--e { filter: saturate(0.55) brightness(1.08); }
.fp__sw--f { filter: sepia(0.4) contrast(0.9) hue-rotate(-14deg); }
.fp__name { font-size: clamp(8px, 2.2vw, 10px); line-height: 1.2; }
.fp__code {
position: relative; margin: 0; width: min(100%, 300px); height: 14px;
font-family: ui-monospace, "Consolas", monospace; font-size: clamp(8px, 2.1vw, 10px);
color: rgba(23, 20, 26, .78); white-space: nowrap; overflow: hidden;
}
.fp__demo { display: none; position: absolute; left: 0; top: 0; }
// ── 미리보기 루프: 원본 → 흑백 → 세피아 → 선명 → 차분 → 빈티지 → 원본.
// 복귀는 88% 에 끝난다. 24장 표본의 마지막 장이 2초의 95.8% 지점이라 그 뒤는 안 찍힌다
.fp.is-demo .fp__view { visibility: hidden; }
.fp.is-demo .fp__ghost {
display: block;
animation-name: fpChain; animation-duration: $duration;
animation-timing-function: $easing; animation-iteration-count: infinite;
}
@keyframes fpChain {
0%, 10% { filter: grayscale(0) sepia(0) saturate(1) contrast(1) brightness(1) hue-rotate(0deg); }
14%, 24% { filter: grayscale(1) sepia(0) saturate(1) contrast(1.08) brightness(1) hue-rotate(0deg); }
28%, 38% { filter: grayscale(0) sepia(0.75) saturate(1.2) contrast(1) brightness(1) hue-rotate(0deg); }
42%, 52% { filter: grayscale(0) sepia(0) saturate(1.35) contrast(1.3) brightness(1) hue-rotate(0deg); }
56%, 66% { filter: grayscale(0) sepia(0) saturate(0.55) contrast(1) brightness(1.08) hue-rotate(0deg); }
70%, 85% { filter: grayscale(0) sepia(0.4) saturate(1) contrast(0.9) brightness(1) hue-rotate(-14deg); }
89%, 100% { filter: grayscale(0) sepia(0) saturate(1) contrast(1) brightness(1) hue-rotate(0deg); }
}
// 테두리는 글자와 **같은 순간에** 끊어야 한다 — 부드럽게 밀면 테두리가 먼저 도착한 프레임이
// poster 로 뽑혀 「선명 칸에 테두리, 아래는 세피아 값」 같은 어긋난 대표 이미지가 나온다 (실측)
.fp.is-demo .fp__ring {
animation-name: fpRing; animation-duration: $duration;
animation-timing-function: steps(1, end); animation-iteration-count: infinite;
}
@keyframes fpRing {
0%, 11% { transform: translateX(0); }
12%, 25% { transform: translateX(100%); }
26%, 39% { transform: translateX(200%); }
40%, 53% { transform: translateX(300%); }
54%, 67% { transform: translateX(400%); }
68%, 86% { transform: translateX(500%); }
87%, 100% { transform: translateX(0); }
}
// 글자는 보간할 수 없으니 여섯 칸을 steps(1, end) 로 갈아 끼운다 — 반투명하게 겹치는 장면이 없다
.fp.is-demo .fp__real { color: transparent; }
.fp.is-demo .fp__demo {
display: block;
animation-duration: $duration;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.fp.is-demo .fp__demo--a { animation-name: fpTextA; }
.fp.is-demo .fp__demo--b { animation-name: fpTextB; }
.fp.is-demo .fp__demo--c { animation-name: fpTextC; }
.fp.is-demo .fp__demo--d { animation-name: fpTextD; }
.fp.is-demo .fp__demo--e { animation-name: fpTextE; }
.fp.is-demo .fp__demo--f { animation-name: fpTextF; }
@keyframes fpTextA { 0%, 11% { opacity: 1; } 12%, 86% { opacity: 0; } 87%, 100% { opacity: 1; } }
@keyframes fpTextB { 0%, 11% { opacity: 0; } 12%, 25% { opacity: 1; } 26%, 100% { opacity: 0; } }
@keyframes fpTextC { 0%, 25% { opacity: 0; } 26%, 39% { opacity: 1; } 40%, 100% { opacity: 0; } }
@keyframes fpTextD { 0%, 39% { opacity: 0; } 40%, 53% { opacity: 1; } 54%, 100% { opacity: 0; } }
@keyframes fpTextE { 0%, 53% { opacity: 0; } 54%, 67% { opacity: 1; } 68%, 100% { opacity: 0; } }
@keyframes fpTextF { 0%, 67% { opacity: 0; } 68%, 86% { opacity: 1; } 87%, 100% { opacity: 0; } }
@media (max-width: 340px) {
.fp { gap: $sp-1; }
.fp__sw { height: 16px; }
}
@media (prefers-reduced-motion: reduce) {
.fp.is-demo .fp__ghost,
.fp.is-demo .fp__ring,
.fp.is-demo .fp__demo { animation: none !important; }
// 움직임 없이 세피아를 고른 상태로 세운다
.fp.is-demo .fp__ghost { filter: grayscale(0) sepia(0.75) saturate(1.2) contrast(1) brightness(1) hue-rotate(0deg) !important; }
.fp.is-demo .fp__ring { transform: translateX(200%) !important; }
.fp.is-demo .fp__demo--a,
.fp.is-demo .fp__demo--b,
.fp.is-demo .fp__demo--d,
.fp.is-demo .fp__demo--e,
.fp.is-demo .fp__demo--f { opacity: 0 !important; }
.fp.is-demo .fp__demo--c { opacity: 1 !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.fp {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
width: 100%;
}
.fp__frame {
position: relative;
height: clamp(84px, 42vh, 148px);
aspect-ratio: 16/10;
max-width: 100%;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.fp__view {
display: block;
width: 100%;
height: 100%;
}
.fp__ghost {
display: none;
position: absolute;
inset: 0;
background-image: var(--photo);
background-size: cover;
}
.fp__row {
position: relative;
display: grid;
grid-template-columns: repeat(6, 1fr);
width: min(100%, 300px);
}
.fp__ring {
position: absolute;
top: 0;
left: 0;
width: 16.6666666667%;
height: 100%;
border: 2px solid #17141a;
border-radius: 8px;
pointer-events: none;
}
.fp__p {
display: grid;
justify-items: center;
gap: 4px;
width: 100%;
cursor: pointer;
padding: 4px;
border: 0;
background: transparent;
color: #17141a;
font-family: inherit;
font-weight: 700;
}
.fp__p:focus-visible {
outline: 3px solid #2f6df6;
outline-offset: 2px;
}
.fp__sw {
width: 100%;
height: 20px;
border-radius: 4px;
background-image: var(--photo);
background-size: cover;
background-position: center;
}
.fp__sw--b {
filter: grayscale(1) contrast(1.08);
}
.fp__sw--c {
filter: sepia(0.75) saturate(1.2);
}
.fp__sw--d {
filter: contrast(1.3) saturate(1.35);
}
.fp__sw--e {
filter: saturate(0.55) brightness(1.08);
}
.fp__sw--f {
filter: sepia(0.4) contrast(0.9) hue-rotate(-14deg);
}
.fp__name {
font-size: clamp(8px, 2.2vw, 10px);
line-height: 1.2;
}
.fp__code {
position: relative;
margin: 0;
width: min(100%, 300px);
height: 14px;
font-family: ui-monospace, "Consolas", monospace;
font-size: clamp(8px, 2.1vw, 10px);
color: rgba(23, 20, 26, 0.78);
white-space: nowrap;
overflow: hidden;
}
.fp__demo {
display: none;
position: absolute;
left: 0;
top: 0;
}
.fp.is-demo .fp__view {
visibility: hidden;
}
.fp.is-demo .fp__ghost {
display: block;
animation-name: fpChain;
animation-duration: 2s;
animation-timing-function: ease-out;
animation-iteration-count: infinite;
}
@keyframes fpChain {
0%, 10% {
filter: grayscale(0) sepia(0) saturate(1) contrast(1) brightness(1) hue-rotate(0deg);
}
14%, 24% {
filter: grayscale(1) sepia(0) saturate(1) contrast(1.08) brightness(1) hue-rotate(0deg);
}
28%, 38% {
filter: grayscale(0) sepia(0.75) saturate(1.2) contrast(1) brightness(1) hue-rotate(0deg);
}
42%, 52% {
filter: grayscale(0) sepia(0) saturate(1.35) contrast(1.3) brightness(1) hue-rotate(0deg);
}
56%, 66% {
filter: grayscale(0) sepia(0) saturate(0.55) contrast(1) brightness(1.08) hue-rotate(0deg);
}
70%, 85% {
filter: grayscale(0) sepia(0.4) saturate(1) contrast(0.9) brightness(1) hue-rotate(-14deg);
}
89%, 100% {
filter: grayscale(0) sepia(0) saturate(1) contrast(1) brightness(1) hue-rotate(0deg);
}
}
.fp.is-demo .fp__ring {
animation-name: fpRing;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes fpRing {
0%, 11% {
transform: translateX(0);
}
12%, 25% {
transform: translateX(100%);
}
26%, 39% {
transform: translateX(200%);
}
40%, 53% {
transform: translateX(300%);
}
54%, 67% {
transform: translateX(400%);
}
68%, 86% {
transform: translateX(500%);
}
87%, 100% {
transform: translateX(0);
}
}
.fp.is-demo .fp__real {
color: transparent;
}
.fp.is-demo .fp__demo {
display: block;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.fp.is-demo .fp__demo--a {
animation-name: fpTextA;
}
.fp.is-demo .fp__demo--b {
animation-name: fpTextB;
}
.fp.is-demo .fp__demo--c {
animation-name: fpTextC;
}
.fp.is-demo .fp__demo--d {
animation-name: fpTextD;
}
.fp.is-demo .fp__demo--e {
animation-name: fpTextE;
}
.fp.is-demo .fp__demo--f {
animation-name: fpTextF;
}
@keyframes fpTextA {
0%, 11% {
opacity: 1;
}
12%, 86% {
opacity: 0;
}
87%, 100% {
opacity: 1;
}
}
@keyframes fpTextB {
0%, 11% {
opacity: 0;
}
12%, 25% {
opacity: 1;
}
26%, 100% {
opacity: 0;
}
}
@keyframes fpTextC {
0%, 25% {
opacity: 0;
}
26%, 39% {
opacity: 1;
}
40%, 100% {
opacity: 0;
}
}
@keyframes fpTextD {
0%, 39% {
opacity: 0;
}
40%, 53% {
opacity: 1;
}
54%, 100% {
opacity: 0;
}
}
@keyframes fpTextE {
0%, 53% {
opacity: 0;
}
54%, 67% {
opacity: 1;
}
68%, 100% {
opacity: 0;
}
}
@keyframes fpTextF {
0%, 67% {
opacity: 0;
}
68%, 86% {
opacity: 1;
}
87%, 100% {
opacity: 0;
}
}
@media (max-width: 340px) {
.fp {
gap: 4px;
}
.fp__sw {
height: 16px;
}
}
@media (prefers-reduced-motion: reduce) {
.fp.is-demo .fp__ghost,
.fp.is-demo .fp__ring,
.fp.is-demo .fp__demo {
animation: none !important;
}
.fp.is-demo .fp__ghost {
filter: grayscale(0) sepia(0.75) saturate(1.2) contrast(1) brightness(1) hue-rotate(0deg) !important;
}
.fp.is-demo .fp__ring {
transform: translateX(200%) !important;
}
.fp.is-demo .fp__demo--a,
.fp.is-demo .fp__demo--b,
.fp.is-demo .fp__demo--d,
.fp.is-demo .fp__demo--e,
.fp.is-demo .fp__demo--f {
opacity: 0 !important;
}
.fp.is-demo .fp__demo--c {
opacity: 1 !important;
}
}var fp = document.getElementById('fp');
var view = document.getElementById('view');
var code = document.getElementById('code');
var ring = document.querySelector('.fp__ring');
var presets = document.querySelectorAll('.fp__p');
var VIEW_LABEL = '미리보기 사진, 필터 %n';
// 가짜 사진 — 외부 파일 없이 그라디언트와 도형으로 합성한다. 필터·히스토그램이 보이도록 대비를 세게 준다
function paintPhoto(c, w, h) {
var sky = c.createLinearGradient(0, 0, 0, h);
sky.addColorStop(0, '#2f6df6'); sky.addColorStop(0.55, '#ff9ebb'); sky.addColorStop(1, '#ffd23f');
c.fillStyle = sky; c.fillRect(0, 0, w, h);
c.fillStyle = '#fff7e6';
c.beginPath(); c.arc(w * 0.74, h * 0.28, h * 0.13, 0, Math.PI * 2); c.fill();
// 곧은 수평선 하나 — 기울기 보정이 눈에 보이려면 비교할 직선이 있어야 한다
c.fillStyle = 'rgba(23, 20, 26, 0.5)'; c.fillRect(0, Math.round(h * 0.66), w, 2);
c.fillStyle = '#17141a';
c.beginPath(); c.moveTo(0, h); c.lineTo(w * 0.34, h * 0.36); c.lineTo(w * 0.62, h); c.closePath(); c.fill();
c.fillStyle = '#c2361a';
c.beginPath(); c.moveTo(w * 0.46, h); c.lineTo(w * 0.78, h * 0.52); c.lineTo(w, h); c.closePath(); c.fill();
for (var i = 0; i < 6; i++) { c.fillStyle = i % 2 ? '#fff7e6' : '#17141a'; c.fillRect(w * (0.08 + i * 0.07), h * (0.14 + (i % 3) * 0.06), 7, 7); }
}
function pick(index) {
var el = presets[index], chain = el.getAttribute('data-f');
fp.classList.remove('is-demo');
for (var i = 0; i < presets.length; i++) { presets[i].setAttribute('aria-pressed', i === index ? 'true' : 'false'); }
// 사진에는 CSS filter 를 그대로 걸고, 같은 문자열을 아래 칸에 글자로 내보낸다
view.style.filter = chain;
code.textContent = 'filter: ' + chain + ';';
ring.style.transform = 'translateX(' + index * 100 + '%)';
view.setAttribute('aria-label', VIEW_LABEL.replace('%n', el.querySelector('.fp__name').textContent));
}
for (var i = 0; i < presets.length; i++) {
(function (index) { presets[index].addEventListener('click', function () { pick(index); }); })(i);
}
paintPhoto(view.getContext('2d'), view.width, view.height);
document.documentElement.style.setProperty('--photo', 'url(' + view.toDataURL() + ')');// FilterPresets — vanilla 판(run/295/demo/03-image-edit-filter-presets/index.html) 의 React 포트.
// 프리셋 여섯 개의 값은 PRESETS 배열 하나뿐이다 — 사진에 거는 CSS filter 와 아래 칸에 찍는 글자가 같은 문자열이다.
// 누른 칸은 aria-pressed 로 알리고, 테두리는 인덱스 × 100% 만큼 옆으로 민다. 값·수치는 vanilla 와 같다.
import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./FilterPresets.module.scss";
export type FilterPresetsProps = {
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & {
"--duration"?: string;
"--easing"?: string;
"--color"?: string;
"--photo"?: string;
};
type Preset = { name: string; chain: string; sw: string };
const VIEW_LABEL = "미리보기 사진, 필터 %n";
// 첫 화면의 대체텍스트는 통째로 사전에 든 문자열이다 — 「없음」만 따로 떼면 언어팩이 그 조각을 못 바꾼다
const VIEW_LABEL_NONE = "미리보기 사진, 필터 없음";
const VIEW_W = 320;
const VIEW_H = 200;
const PRESETS: Preset[] = [
{ name: "원본", chain: "none", sw: "swA" },
{ name: "흑백", chain: "grayscale(1) contrast(1.08)", sw: "swB" },
{ name: "세피아", chain: "sepia(0.75) saturate(1.2)", sw: "swC" },
{ name: "선명", chain: "contrast(1.3) saturate(1.35)", sw: "swD" },
{ name: "차분", chain: "saturate(0.55) brightness(1.08)", sw: "swE" },
{ name: "빈티지", chain: "sepia(0.4) contrast(0.9) hue-rotate(-14deg)", sw: "swF" },
];
// 가짜 사진 — 외부 파일 없이 그라디언트와 도형으로 합성한다. 필터·히스토그램이 보이도록 대비를 세게 준다
function paintPhoto(c: CanvasRenderingContext2D, w: number, h: number): void {
const sky = c.createLinearGradient(0, 0, 0, h);
sky.addColorStop(0, "#2f6df6"); sky.addColorStop(0.55, "#ff9ebb"); sky.addColorStop(1, "#ffd23f");
c.fillStyle = sky; c.fillRect(0, 0, w, h);
c.fillStyle = "#fff7e6";
c.beginPath(); c.arc(w * 0.74, h * 0.28, h * 0.13, 0, Math.PI * 2); c.fill();
// 곧은 수평선 하나 — 기울기 보정이 눈에 보이려면 비교할 직선이 있어야 한다
c.fillStyle = "rgba(23, 20, 26, 0.5)"; c.fillRect(0, Math.round(h * 0.66), w, 2);
c.fillStyle = "#17141a";
c.beginPath(); c.moveTo(0, h); c.lineTo(w * 0.34, h * 0.36); c.lineTo(w * 0.62, h); c.closePath(); c.fill();
c.fillStyle = "#c2361a";
c.beginPath(); c.moveTo(w * 0.46, h); c.lineTo(w * 0.78, h * 0.52); c.lineTo(w, h); c.closePath(); c.fill();
for (let i = 0; i < 6; i++) { c.fillStyle = i % 2 ? "#fff7e6" : "#17141a"; c.fillRect(w * (0.08 + i * 0.07), h * (0.14 + (i % 3) * 0.06), 7, 7); }
}
export default function FilterPresets({
duration = 2000,
easing = "ease-out",
color = "#17141a",
}: FilterPresetsProps) {
const viewRef = useRef<HTMLCanvasElement | null>(null);
const [picked, setPicked] = useState(-1);
const [demo, setDemo] = useState(true);
const [photo, setPhoto] = useState("");
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
if (photo) vars["--photo"] = `url(${photo})`;
// 아직 아무것도 안 누른 첫 화면은 원본 그대로다 — 그때의 대체텍스트만 「필터 없음」이다
const index = picked < 0 ? 0 : picked;
const chain = PRESETS[index].chain;
const label = picked < 0 ? VIEW_LABEL_NONE : VIEW_LABEL.replace("%n", PRESETS[index].name);
useEffect(() => {
const view = viewRef.current;
if (!view) return;
const ctx = view.getContext("2d");
if (!ctx) return;
paintPhoto(ctx, view.width, view.height);
setPhoto(view.toDataURL());
}, []);
// 사진에는 CSS filter 를 그대로 걸고, 같은 문자열을 아래 칸에 글자로 내보낸다
function pick(next: number): void {
setDemo(false);
setPicked(next);
}
const cls = [styles.root, demo ? styles.isDemo : ""].filter(Boolean).join(" ");
return (
<main className={styles.stage}>
<div className={cls} style={vars}>
<div className={styles.frame}>
<canvas className={styles.view} ref={viewRef} width={VIEW_W} height={VIEW_H} role="img" aria-label={label} style={{ filter: chain }}></canvas>
<span className={styles.ghost} aria-hidden="true"></span>
</div>
<div className={styles.row}>
<span className={styles.ring} style={{ transform: `translateX(${index * 100}%)` }} aria-hidden="true"></span>
{PRESETS.map((preset, i) => (
<button
key={preset.name}
type="button"
className={styles.preset}
aria-pressed={picked < 0 ? i === 0 : i === picked}
onClick={() => pick(i)}
>
<span className={[styles.sw, styles[preset.sw]].filter(Boolean).join(" ")} aria-hidden="true"></span>
<span className={styles.name}>{preset.name}</span>
</button>
))}
</div>
<p className={styles.code}>
<code className={styles.real}>{`filter: ${chain};`}</code>
<code className={`${styles.codeDemo} ${styles.codeA}`} aria-hidden="true">filter: none;</code>
<code className={`${styles.codeDemo} ${styles.codeB}`} aria-hidden="true">filter: grayscale(1) contrast(1.08);</code>
<code className={`${styles.codeDemo} ${styles.codeC}`} aria-hidden="true">filter: sepia(0.75) saturate(1.2);</code>
<code className={`${styles.codeDemo} ${styles.codeD}`} aria-hidden="true">filter: contrast(1.3) saturate(1.35);</code>
<code className={`${styles.codeDemo} ${styles.codeE}`} aria-hidden="true">filter: saturate(0.55) brightness(1.08);</code>
<code className={`${styles.codeDemo} ${styles.codeF}`} aria-hidden="true">filter: sepia(0.4) contrast(0.9) hue-rotate(-14deg);</code>
</p>
</div>
</main>
);
}03 · 필터 프리셋 여섯 개 — 측정 누적면적 19.97% · 움직인 프레임 10/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
어디를 바꾸면 되나
값 계산은 프리셋과 같지만 목록을 맞추지 않으면 보간이 아예 일어나지 않아 톤이 툭툭 끊깁니다(누적면적 19.97%·움직인 프레임 10/23).
// 함수 목록이 서로 다르면 브라우저가 보간을 포기한다. 없는 함수는 항등값으로 채워 길이를 맞춘다
@keyframes fpChain {
0%, 10% { filter: grayscale(0) sepia(0) saturate(1) contrast(1) brightness(1) hue-rotate(0deg); }
14%, 24% { filter: grayscale(1) sepia(0) saturate(1) contrast(1.08) brightness(1) hue-rotate(0deg); }
28%, 38% { filter: grayscale(0) sepia(0.75) saturate(1.2) contrast(1) brightness(1) hue-rotate(0deg); }
42%, 52% { filter: grayscale(0) sepia(0) saturate(1.35) contrast(1.3) brightness(1) hue-rotate(0deg); }
56%, 66% { filter: grayscale(0) sepia(0) saturate(0.55) contrast(1) brightness(1.08) hue-rotate(0deg); }
70%, 85% { filter: grayscale(0) sepia(0.4) saturate(1) contrast(0.9) brightness(1) hue-rotate(-14deg); }
89%, 100% { filter: grayscale(0) sepia(0) saturate(1) contrast(1) brightness(1) hue-rotate(0deg); }
}
04밝기 대비 채도 슬라이더
무엇이 보이나
세 값이 따로 놀지 않고 filter 문자열 하나로 합쳐지므로 적는 순서가 곧 적용 순서입니다. 비교 버튼은 그 문자열을 none 으로 갈아 끼워 같은 자리에서 원본을 보여주고, 꼬리표 글자도 함께 바뀝니다.

<main class="stage">
<div class="ad is-demo" id="ad">
<div class="ad__frame">
<canvas class="ad__view" id="view" width="320" height="200" role="img" aria-label="샘플 사진"></canvas>
<span class="ad__ghost" aria-hidden="true"></span>
<span class="ad__orig" aria-hidden="true"></span>
<span class="ad__tag">
<span class="ad__real" id="tag">보정 후</span>
<span class="ad__tagDemo ad__tagDemo--edit" aria-hidden="true">보정 후</span>
<span class="ad__tagDemo ad__tagDemo--orig" aria-hidden="true">원본</span>
</span>
</div>
<div class="ad__ctl">
<div class="ad__s">
<span class="ad__lab" id="labB">밝기</span>
<div class="ad__track" data-k="b" role="slider" tabindex="0" aria-labelledby="labB" aria-valuemin="0.5" aria-valuemax="1.5" aria-valuenow="1"><span class="ad__thumb" id="thB"></span></div>
</div>
<div class="ad__s">
<span class="ad__lab" id="labC">대비</span>
<div class="ad__track" data-k="c" role="slider" tabindex="0" aria-labelledby="labC" aria-valuemin="0.5" aria-valuemax="1.5" aria-valuenow="1"><span class="ad__thumb" id="thC"></span></div>
</div>
<div class="ad__s">
<span class="ad__lab" id="labS">채도</span>
<div class="ad__track" data-k="s" role="slider" tabindex="0" aria-labelledby="labS" aria-valuemin="0.5" aria-valuemax="1.5" aria-valuenow="1"><span class="ad__thumb" id="thS"></span></div>
</div>
</div>
<div class="ad__foot">
<button type="button" class="ad__btn" id="resetBtn">초기화</button>
<button type="button" class="ad__btn ad__btn--ab" id="abBtn" aria-pressed="false"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" class="ad__ico" aria-hidden="true"><g fill="currentColor"><path d="M80 136a24 24 0 1 1-24-24a24 24 0 0 1 24 24m48-72a24 24 0 1 0 24 24a24 24 0 0 0-24-24m72 80a24 24 0 1 0 24 24a24 24 0 0 0-24-24" opacity=".2"/><path d="M64 105V40a8 8 0 0 0-16 0v65a32 32 0 0 0 0 62v49a8 8 0 0 0 16 0v-49a32 32 0 0 0 0-62m-8 47a16 16 0 1 1 16-16a16 16 0 0 1-16 16m80-95V40a8 8 0 0 0-16 0v17a32 32 0 0 0 0 62v97a8 8 0 0 0 16 0v-97a32 32 0 0 0 0-62m-8 47a16 16 0 1 1 16-16a16 16 0 0 1-16 16m104 64a32.06 32.06 0 0 0-24-31V40a8 8 0 0 0-16 0v97a32 32 0 0 0 0 62v17a8 8 0 0 0 16 0v-17a32.06 32.06 0 0 0 24-31m-32 16a16 16 0 1 1 16-16a16 16 0 0 1-16 16"/></g></svg><span>비교</span></button>
<span class="ad__codes">
<code class="ad__realCode" id="code">brightness(1.00) contrast(1.00) saturate(1.00)</code>
<code class="ad__demoCode ad__demoCode--a" aria-hidden="true">brightness(1.00) contrast(1.00) saturate(1.00)</code>
<code class="ad__demoCode ad__demoCode--b" aria-hidden="true">brightness(1.18) contrast(1.22) saturate(1.30)</code>
</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 밝기 대비 채도 슬라이더 — 값 셋이 filter 문자열 하나로 합쳐지고, 비교 버튼이 원본과 번갈아 보여준다.
// 격자 미리보기(.is-demo)는 유령 판 두 장을 겹쳐 쓴다. 아래 판은 filter 를 부드럽게 보간하고,
// 위의 원본 판은 steps(1, end) 로 한 프레임에 덮는다 — 교차 페이드로 하면 두 장이 반투명하게 겹친다.
$duration: $dur-loop;
$easing: $ease-out;
$color: $subject-ink;
@include stage($stage-paper);
.ad { display: flex; flex-direction: column; align-items: center; gap: $sp-2; width: 100%; }
.ad__frame {
position: relative; height: clamp(76px, 38vh, 132px); aspect-ratio: 16 / 10; max-width: 100%;
border-radius: $r-sm; overflow: hidden; box-shadow: $shadow-raised;
}
.ad__view { display: block; width: 100%; height: 100%; }
.ad__ghost, .ad__orig {
display: none; position: absolute; inset: 0;
background-image: var(--photo); background-size: cover;
}
.ad__tag {
position: absolute; left: $sp-1; top: $sp-1; padding: 0 $sp-1;
border-radius: $r-xs; background: rgba(23, 20, 26, .72); color: $subject-cream;
// 세로 여백은 4px 격자에 안 맞는 2px 이었다 — 0 으로 내리고 행간으로 높이를 만든다
font-weight: 800; font-size: clamp(8px, 2.1vw, 10px); line-height: 2;
}
.ad__tagDemo { display: none; position: absolute; left: $sp-1; top: 0; }
.ad__ctl { display: flex; justify-content: center; gap: $sp-2; }
.ad__s { display: grid; justify-items: center; gap: $sp-1; width: 88px; }
.ad__lab { font-weight: 800; font-size: clamp(8px, 2.1vw, 10px); color: $color; }
.ad__track {
position: relative; width: 100%; height: 6px; cursor: pointer;
border-radius: $r-pill; background: rgba(23, 20, 26, .18);
}
.ad__track:focus-visible { outline: 3px solid $subject-blue; outline-offset: 4px; }
.ad__thumb {
position: absolute; top: 50%; left: 50%; width: 13px; height: 13px;
border-radius: 50%; background: $subject-blue; box-shadow: $shadow-press;
transform: translate(-50%, -50%);
}
.ad__foot { display: flex; align-items: center; gap: $sp-2; flex-wrap: wrap; justify-content: center; }
.ad__btn {
display: inline-flex; align-items: center; gap: $sp-1; cursor: pointer;
border: 1px solid rgba(23, 20, 26, .24); background: $stage-paper; color: $color;
border-radius: $r-pill; font-family: inherit; font-weight: 700; line-height: 1.3;
font-size: clamp(8px, 2.2vw, 10px); padding: $sp-1 $sp-2;
}
.ad__btn--ab { background: $subject-blue; border-color: $subject-blue; color: $stage-paper; }
.ad__btn:focus-visible { outline: 3px solid $subject-blue; outline-offset: 2px; }
.ad__ico { width: 12px; height: 12px; flex: 0 0 auto; }
.ad__codes {
position: relative; display: inline-block; min-width: 200px; height: 14px;
font-family: ui-monospace, "Consolas", monospace; font-size: clamp(8px, 2.1vw, 10px);
color: rgba(23, 20, 26, .7);
}
.ad__demoCode { display: none; position: absolute; left: 0; top: 0; white-space: nowrap; }
// ── 미리보기 루프: 슬라이더 셋이 8~12% 에 올라가고, 47~66% 에 원본이 한 프레임 덮었다 물러나고,
// 82~86% 에 처음 값으로 내려온다. 24장 표본의 마지막 장이 2초의 95.8% 지점이라 복귀는 그 앞에 끝낸다
.ad.is-demo .ad__view { visibility: hidden; }
.ad.is-demo .ad__ghost {
display: block;
animation-name: adTone; animation-duration: $duration;
animation-timing-function: $easing; animation-iteration-count: infinite;
}
@keyframes adTone {
0%, 8% { filter: brightness(1) contrast(1) saturate(1); }
12%, 82% { filter: brightness(1.18) contrast(1.22) saturate(1.3); }
86%, 100% { filter: brightness(1) contrast(1) saturate(1); }
}
.ad.is-demo .ad__orig {
display: block;
animation-name: adCompare; animation-duration: $duration;
animation-timing-function: steps(1, end); animation-iteration-count: infinite;
}
@keyframes adCompare {
0%, 46% { opacity: 0; }
47%, 66% { opacity: 1; }
67%, 100% { opacity: 0; }
}
.ad.is-demo .ad__real { color: transparent; }
.ad.is-demo .ad__tagDemo {
display: block;
animation-duration: $duration;
animation-timing-function: steps(1, end); animation-iteration-count: infinite;
}
.ad.is-demo .ad__tagDemo--edit { animation-name: adTagEdit; }
.ad.is-demo .ad__tagDemo--orig { animation-name: adTagOrig; }
@keyframes adTagEdit { 0%, 46% { opacity: 1; } 47%, 66% { opacity: 0; } 67%, 100% { opacity: 1; } }
@keyframes adTagOrig { 0%, 46% { opacity: 0; } 47%, 66% { opacity: 1; } 67%, 100% { opacity: 0; } }
.ad.is-demo .ad__thumb {
animation-duration: $duration;
animation-timing-function: $easing; animation-iteration-count: infinite;
}
.ad.is-demo #thB { animation-name: adThumbB; }
.ad.is-demo #thC { animation-name: adThumbC; }
.ad.is-demo #thS { animation-name: adThumbS; }
@keyframes adThumbB {
0%, 8% { transform: translate(-50%, -50%); }
12%, 82% { transform: translate(-50%, -50%) translateX(16px); }
86%, 100% { transform: translate(-50%, -50%); }
}
@keyframes adThumbC {
0%, 8% { transform: translate(-50%, -50%); }
12%, 82% { transform: translate(-50%, -50%) translateX(19px); }
86%, 100% { transform: translate(-50%, -50%); }
}
@keyframes adThumbS {
0%, 8% { transform: translate(-50%, -50%); }
12%, 82% { transform: translate(-50%, -50%) translateX(26px); }
86%, 100% { transform: translate(-50%, -50%); }
}
// 비교 버튼은 자기 중심에서 눌린다 — 원점을 모서리에 두면 눌리는 게 아니라 옆으로 밀려 보인다
.ad.is-demo .ad__btn--ab {
animation-name: adPress; animation-duration: $duration;
animation-timing-function: $ease-pop; animation-iteration-count: infinite;
}
@keyframes adPress {
0%, 42% { transform: scale(1); }
47% { transform: scale(0.88); }
54%, 62% { transform: scale(1); }
67% { transform: scale(0.88); }
74%, 100% { transform: scale(1); }
}
// 값 글자는 보간할 수 없으니 두 칸을 steps(1, end) 로 갈아 끼우고, 슬라이더가 옮겨 가는 구간을
// 한 프레임 안(8~12%)으로 끊는다 — 그래야 어느 장면을 잘라도 글자와 사진이 같은 값을 말한다
.ad.is-demo .ad__realCode { color: transparent; }
.ad.is-demo .ad__demoCode {
display: block;
animation-duration: $duration;
animation-timing-function: steps(1, end); animation-iteration-count: infinite;
}
.ad.is-demo .ad__demoCode--a { animation-name: adCodeA; }
.ad.is-demo .ad__demoCode--b { animation-name: adCodeB; }
@keyframes adCodeA { 0%, 9% { opacity: 1; } 10%, 83% { opacity: 0; } 84%, 100% { opacity: 1; } }
@keyframes adCodeB { 0%, 9% { opacity: 0; } 10%, 83% { opacity: 1; } 84%, 100% { opacity: 0; } }
@media (max-width: 340px) {
.ad { gap: $sp-1; }
.ad__ctl { gap: $sp-1; }
.ad__s { width: 88px; }
}
@media (prefers-reduced-motion: reduce) {
.ad.is-demo .ad__ghost,
.ad.is-demo .ad__orig,
.ad.is-demo .ad__tagDemo,
.ad.is-demo .ad__thumb,
.ad.is-demo .ad__btn--ab,
.ad.is-demo .ad__demoCode { animation: none !important; }
// 움직임 없이 보정을 올린 상태로 세운다
.ad.is-demo .ad__ghost { filter: brightness(1.18) contrast(1.22) saturate(1.3) !important; }
.ad.is-demo .ad__orig { opacity: 0 !important; }
.ad.is-demo .ad__tagDemo--edit { opacity: 1 !important; }
.ad.is-demo .ad__tagDemo--orig { opacity: 0 !important; }
.ad.is-demo #thB { transform: translate(-50%, -50%) translateX(16px) !important; }
.ad.is-demo #thC { transform: translate(-50%, -50%) translateX(19px) !important; }
.ad.is-demo #thS { transform: translate(-50%, -50%) translateX(26px) !important; }
.ad.is-demo .ad__demoCode--a { opacity: 0 !important; }
.ad.is-demo .ad__demoCode--b { opacity: 1 !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.ad {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
width: 100%;
}
.ad__frame {
position: relative;
height: clamp(76px, 38vh, 132px);
aspect-ratio: 16/10;
max-width: 100%;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.ad__view {
display: block;
width: 100%;
height: 100%;
}
.ad__ghost, .ad__orig {
display: none;
position: absolute;
inset: 0;
background-image: var(--photo);
background-size: cover;
}
.ad__tag {
position: absolute;
left: 4px;
top: 4px;
padding: 0 4px;
border-radius: 4px;
background: rgba(23, 20, 26, 0.72);
color: #fff7e6;
font-weight: 800;
font-size: clamp(8px, 2.1vw, 10px);
line-height: 2;
}
.ad__tagDemo {
display: none;
position: absolute;
left: 4px;
top: 0;
}
.ad__ctl {
display: flex;
justify-content: center;
gap: 8px;
}
.ad__s {
display: grid;
justify-items: center;
gap: 4px;
width: 88px;
}
.ad__lab {
font-weight: 800;
font-size: clamp(8px, 2.1vw, 10px);
color: #17141a;
}
.ad__track {
position: relative;
width: 100%;
height: 6px;
cursor: pointer;
border-radius: 999px;
background: rgba(23, 20, 26, 0.18);
}
.ad__track:focus-visible {
outline: 3px solid #2f6df6;
outline-offset: 4px;
}
.ad__thumb {
position: absolute;
top: 50%;
left: 50%;
width: 13px;
height: 13px;
border-radius: 50%;
background: #2f6df6;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
transform: translate(-50%, -50%);
}
.ad__foot {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
justify-content: center;
}
.ad__btn {
display: inline-flex;
align-items: center;
gap: 4px;
cursor: pointer;
border: 1px solid rgba(23, 20, 26, 0.24);
background: #ffffff;
color: #17141a;
border-radius: 999px;
font-family: inherit;
font-weight: 700;
line-height: 1.3;
font-size: clamp(8px, 2.2vw, 10px);
padding: 4px 8px;
}
.ad__btn--ab {
background: #2f6df6;
border-color: #2f6df6;
color: #ffffff;
}
.ad__btn:focus-visible {
outline: 3px solid #2f6df6;
outline-offset: 2px;
}
.ad__ico {
width: 12px;
height: 12px;
flex: 0 0 auto;
}
.ad__codes {
position: relative;
display: inline-block;
min-width: 200px;
height: 14px;
font-family: ui-monospace, "Consolas", monospace;
font-size: clamp(8px, 2.1vw, 10px);
color: rgba(23, 20, 26, 0.7);
}
.ad__demoCode {
display: none;
position: absolute;
left: 0;
top: 0;
white-space: nowrap;
}
.ad.is-demo .ad__view {
visibility: hidden;
}
.ad.is-demo .ad__ghost {
display: block;
animation-name: adTone;
animation-duration: 2s;
animation-timing-function: ease-out;
animation-iteration-count: infinite;
}
@keyframes adTone {
0%, 8% {
filter: brightness(1) contrast(1) saturate(1);
}
12%, 82% {
filter: brightness(1.18) contrast(1.22) saturate(1.3);
}
86%, 100% {
filter: brightness(1) contrast(1) saturate(1);
}
}
.ad.is-demo .ad__orig {
display: block;
animation-name: adCompare;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes adCompare {
0%, 46% {
opacity: 0;
}
47%, 66% {
opacity: 1;
}
67%, 100% {
opacity: 0;
}
}
.ad.is-demo .ad__real {
color: transparent;
}
.ad.is-demo .ad__tagDemo {
display: block;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.ad.is-demo .ad__tagDemo--edit {
animation-name: adTagEdit;
}
.ad.is-demo .ad__tagDemo--orig {
animation-name: adTagOrig;
}
@keyframes adTagEdit {
0%, 46% {
opacity: 1;
}
47%, 66% {
opacity: 0;
}
67%, 100% {
opacity: 1;
}
}
@keyframes adTagOrig {
0%, 46% {
opacity: 0;
}
47%, 66% {
opacity: 1;
}
67%, 100% {
opacity: 0;
}
}
.ad.is-demo .ad__thumb {
animation-duration: 2s;
animation-timing-function: ease-out;
animation-iteration-count: infinite;
}
.ad.is-demo #thB {
animation-name: adThumbB;
}
.ad.is-demo #thC {
animation-name: adThumbC;
}
.ad.is-demo #thS {
animation-name: adThumbS;
}
@keyframes adThumbB {
0%, 8% {
transform: translate(-50%, -50%);
}
12%, 82% {
transform: translate(-50%, -50%) translateX(16px);
}
86%, 100% {
transform: translate(-50%, -50%);
}
}
@keyframes adThumbC {
0%, 8% {
transform: translate(-50%, -50%);
}
12%, 82% {
transform: translate(-50%, -50%) translateX(19px);
}
86%, 100% {
transform: translate(-50%, -50%);
}
}
@keyframes adThumbS {
0%, 8% {
transform: translate(-50%, -50%);
}
12%, 82% {
transform: translate(-50%, -50%) translateX(26px);
}
86%, 100% {
transform: translate(-50%, -50%);
}
}
.ad.is-demo .ad__btn--ab {
animation-name: adPress;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 1.4, 0.4, 1);
animation-iteration-count: infinite;
}
@keyframes adPress {
0%, 42% {
transform: scale(1);
}
47% {
transform: scale(0.88);
}
54%, 62% {
transform: scale(1);
}
67% {
transform: scale(0.88);
}
74%, 100% {
transform: scale(1);
}
}
.ad.is-demo .ad__realCode {
color: transparent;
}
.ad.is-demo .ad__demoCode {
display: block;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.ad.is-demo .ad__demoCode--a {
animation-name: adCodeA;
}
.ad.is-demo .ad__demoCode--b {
animation-name: adCodeB;
}
@keyframes adCodeA {
0%, 9% {
opacity: 1;
}
10%, 83% {
opacity: 0;
}
84%, 100% {
opacity: 1;
}
}
@keyframes adCodeB {
0%, 9% {
opacity: 0;
}
10%, 83% {
opacity: 1;
}
84%, 100% {
opacity: 0;
}
}
@media (max-width: 340px) {
.ad {
gap: 4px;
}
.ad__ctl {
gap: 4px;
}
.ad__s {
width: 88px;
}
}
@media (prefers-reduced-motion: reduce) {
.ad.is-demo .ad__ghost,
.ad.is-demo .ad__orig,
.ad.is-demo .ad__tagDemo,
.ad.is-demo .ad__thumb,
.ad.is-demo .ad__btn--ab,
.ad.is-demo .ad__demoCode {
animation: none !important;
}
.ad.is-demo .ad__ghost {
filter: brightness(1.18) contrast(1.22) saturate(1.3) !important;
}
.ad.is-demo .ad__orig {
opacity: 0 !important;
}
.ad.is-demo .ad__tagDemo--edit {
opacity: 1 !important;
}
.ad.is-demo .ad__tagDemo--orig {
opacity: 0 !important;
}
.ad.is-demo #thB {
transform: translate(-50%, -50%) translateX(16px) !important;
}
.ad.is-demo #thC {
transform: translate(-50%, -50%) translateX(19px) !important;
}
.ad.is-demo #thS {
transform: translate(-50%, -50%) translateX(26px) !important;
}
.ad.is-demo .ad__demoCode--a {
opacity: 0 !important;
}
.ad.is-demo .ad__demoCode--b {
opacity: 1 !important;
}
}var ad = document.getElementById('ad');
var view = document.getElementById('view');
var tag = document.getElementById('tag');
var code = document.getElementById('code');
var abBtn = document.getElementById('abBtn');
var tracks = document.querySelectorAll('.ad__track');
var thumbs = { b: document.getElementById('thB'), c: document.getElementById('thC'), s: document.getElementById('thS') };
var value = { b: 1, c: 1, s: 1 };
var showOriginal = false;
var VOICE = '%v배';
var TAG_EDIT = '보정 후';
var TAG_ORIG = '원본';
// 가짜 사진 — 외부 파일 없이 그라디언트와 도형으로 합성한다. 필터·히스토그램이 보이도록 대비를 세게 준다
function paintPhoto(c, w, h) {
var sky = c.createLinearGradient(0, 0, 0, h);
sky.addColorStop(0, '#2f6df6'); sky.addColorStop(0.55, '#ff9ebb'); sky.addColorStop(1, '#ffd23f');
c.fillStyle = sky; c.fillRect(0, 0, w, h);
c.fillStyle = '#fff7e6';
c.beginPath(); c.arc(w * 0.74, h * 0.28, h * 0.13, 0, Math.PI * 2); c.fill();
// 곧은 수평선 하나 — 기울기 보정이 눈에 보이려면 비교할 직선이 있어야 한다
c.fillStyle = 'rgba(23, 20, 26, 0.5)'; c.fillRect(0, Math.round(h * 0.66), w, 2);
c.fillStyle = '#17141a';
c.beginPath(); c.moveTo(0, h); c.lineTo(w * 0.34, h * 0.36); c.lineTo(w * 0.62, h); c.closePath(); c.fill();
c.fillStyle = '#c2361a';
c.beginPath(); c.moveTo(w * 0.46, h); c.lineTo(w * 0.78, h * 0.52); c.lineTo(w, h); c.closePath(); c.fill();
for (var i = 0; i < 6; i++) { c.fillStyle = i % 2 ? '#fff7e6' : '#17141a'; c.fillRect(w * (0.08 + i * 0.07), h * (0.14 + (i % 3) * 0.06), 7, 7); }
}
function render() {
// 슬라이더 셋이 filter 문자열 하나로 합쳐진다 — 순서가 곧 적용 순서다
var chain = 'brightness(' + value.b.toFixed(2) + ') contrast(' + value.c.toFixed(2) + ') saturate(' + value.s.toFixed(2) + ')';
view.style.filter = showOriginal ? 'none' : chain;
code.textContent = chain;
tag.textContent = showOriginal ? TAG_ORIG : TAG_EDIT;
for (var i = 0; i < tracks.length; i++) {
var key = tracks[i].getAttribute('data-k'), v = value[key];
thumbs[key].style.left = ((v - 0.5) * 100) + '%';
tracks[i].setAttribute('aria-valuenow', v.toFixed(2));
tracks[i].setAttribute('aria-valuetext', VOICE.replace('%v', v.toFixed(2)));
}
}
function setValue(key, next) {
value[key] = Math.max(0.5, Math.min(1.5, Math.round(next * 100) / 100));
ad.classList.remove('is-demo');
render();
}
for (var i = 0; i < tracks.length; i++) {
(function (el) {
var key = el.getAttribute('data-k');
function fromPointer(e) {
var r = el.getBoundingClientRect();
setValue(key, (e.clientX - r.left) / r.width + 0.5);
}
el.addEventListener('pointerdown', function (e) { el.setPointerCapture(e.pointerId); fromPointer(e); });
el.addEventListener('pointermove', function (e) { if (e.buttons) { fromPointer(e); } });
// 화살표 0.02, Shift 와 함께면 0.1 — 눈으로 맞추기 어려운 미세 보정은 키보드가 낫다
el.addEventListener('keydown', function (e) {
var step = e.shiftKey ? 0.1 : 0.02;
if (e.key === 'ArrowLeft' || e.key === 'ArrowDown') { e.preventDefault(); setValue(key, value[key] - step); }
else if (e.key === 'ArrowRight' || e.key === 'ArrowUp') { e.preventDefault(); setValue(key, value[key] + step); }
});
})(tracks[i]);
}
document.getElementById('resetBtn').addEventListener('click', function () {
value.b = 1; value.c = 1; value.s = 1; showOriginal = false;
abBtn.setAttribute('aria-pressed', 'false');
ad.classList.remove('is-demo'); render();
});
abBtn.addEventListener('click', function () {
showOriginal = !showOriginal;
abBtn.setAttribute('aria-pressed', showOriginal ? 'true' : 'false');
ad.classList.remove('is-demo'); render();
});
paintPhoto(view.getContext('2d'), view.width, view.height);
document.documentElement.style.setProperty('--photo', 'url(' + view.toDataURL() + ')');
render();// ToneSliders — vanilla 판(run/295/demo/04-image-edit-adjust-sliders/index.html) 의 React 포트.
// 밝기·대비·채도 세 값이 filter 문자열 하나로 합쳐져 canvas 에 걸리고, 비교 버튼은 filter 를 none 으로
// 돌려 원본을 보여준다. 가짜 사진은 paintPhoto 가 그려 넣고, 그 toDataURL 을 --photo 로 내려
// 미리보기 유령 판 두 장이 같은 그림을 쓴다. 값·수치·키프레임은 vanilla 와 같다.
import { useEffect, useRef, useState } from "react";
import type { CSSProperties, PointerEvent as ReactPointerEvent } from "react";
import styles from "./ToneSliders.module.scss";
export type ToneSlidersProps = {
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & {
"--duration"?: string;
"--easing"?: string;
"--color"?: string;
"--photo"?: string;
};
type ToneKey = "b" | "c" | "s";
type Tone = { b: number; c: number; s: number };
// react 앰비언트에 KeyboardEvent 가 없다 — 키 핸들러가 실제로 쓰는 것만 지역 타입으로 선언한다
type KeyEvt = { key: string; shiftKey: boolean; preventDefault(): void };
const VOICE = "%v배";
const TAG_EDIT = "보정 후";
const TAG_ORIG = "원본";
const SLIDERS: { key: ToneKey; label: string; labelId: string; thumb: string }[] = [
{ key: "b", label: "밝기", labelId: "labB", thumb: styles.thumbB },
{ key: "c", label: "대비", labelId: "labC", thumb: styles.thumbC },
{ key: "s", label: "채도", labelId: "labS", thumb: styles.thumbS },
];
// 가짜 사진 — 외부 파일 없이 그라디언트와 도형으로 합성한다. 필터·히스토그램이 보이도록 대비를 세게 준다
function paintPhoto(c: CanvasRenderingContext2D, w: number, h: number): void {
const sky = c.createLinearGradient(0, 0, 0, h);
sky.addColorStop(0, "#2f6df6"); sky.addColorStop(0.55, "#ff9ebb"); sky.addColorStop(1, "#ffd23f");
c.fillStyle = sky; c.fillRect(0, 0, w, h);
c.fillStyle = "#fff7e6";
c.beginPath(); c.arc(w * 0.74, h * 0.28, h * 0.13, 0, Math.PI * 2); c.fill();
// 곧은 수평선 하나 — 기울기 보정이 눈에 보이려면 비교할 직선이 있어야 한다
c.fillStyle = "rgba(23, 20, 26, 0.5)"; c.fillRect(0, Math.round(h * 0.66), w, 2);
c.fillStyle = "#17141a";
c.beginPath(); c.moveTo(0, h); c.lineTo(w * 0.34, h * 0.36); c.lineTo(w * 0.62, h); c.closePath(); c.fill();
c.fillStyle = "#c2361a";
c.beginPath(); c.moveTo(w * 0.46, h); c.lineTo(w * 0.78, h * 0.52); c.lineTo(w, h); c.closePath(); c.fill();
for (let i = 0; i < 6; i++) { c.fillStyle = i % 2 ? "#fff7e6" : "#17141a"; c.fillRect(w * (0.08 + i * 0.07), h * (0.14 + (i % 3) * 0.06), 7, 7); }
}
export default function ToneSliders({
duration = 2000,
easing = "ease-out",
color = "#17141a",
}: ToneSlidersProps) {
const viewRef = useRef<HTMLCanvasElement | null>(null);
const [tone, setTone] = useState<Tone>({ b: 1, c: 1, s: 1 });
const [showOriginal, setShowOriginal] = useState(false);
const [demo, setDemo] = useState(true);
const [photo, setPhoto] = useState("");
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
if (photo) vars["--photo"] = `url(${photo})`;
useEffect(() => {
const canvas = viewRef.current;
if (!canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
paintPhoto(ctx, canvas.width, canvas.height);
setPhoto(canvas.toDataURL());
}, []);
// 슬라이더 셋이 filter 문자열 하나로 합쳐진다 — 순서가 곧 적용 순서다
const chain = `brightness(${tone.b.toFixed(2)}) contrast(${tone.c.toFixed(2)}) saturate(${tone.s.toFixed(2)})`;
function setValue(key: ToneKey, nextRaw: number): void {
const v = Math.max(0.5, Math.min(1.5, Math.round(nextRaw * 100) / 100));
setTone((prev) => {
const next: Tone = { b: prev.b, c: prev.c, s: prev.s };
next[key] = v;
return next;
});
setDemo(false);
}
function fromPointer(key: ToneKey, event: ReactPointerEvent<HTMLDivElement>): void {
const r = event.currentTarget.getBoundingClientRect();
setValue(key, (event.clientX - r.left) / r.width + 0.5);
}
function onPointerDown(key: ToneKey, event: ReactPointerEvent<HTMLDivElement>): void {
event.currentTarget.setPointerCapture(event.pointerId);
fromPointer(key, event);
}
function onPointerMove(key: ToneKey, event: ReactPointerEvent<HTMLDivElement>): void {
if (event.buttons) { fromPointer(key, event); }
}
// 화살표 0.02, Shift 와 함께면 0.1 — 눈으로 맞추기 어려운 미세 보정은 키보드가 낫다
function onKeyDown(key: ToneKey, event: KeyEvt): void {
const step = event.shiftKey ? 0.1 : 0.02;
if (event.key === "ArrowLeft" || event.key === "ArrowDown") { event.preventDefault(); setValue(key, tone[key] - step); }
else if (event.key === "ArrowRight" || event.key === "ArrowUp") { event.preventDefault(); setValue(key, tone[key] + step); }
}
function onReset(): void {
setTone({ b: 1, c: 1, s: 1 });
setShowOriginal(false);
setDemo(false);
}
function onCompare(): void {
setShowOriginal(!showOriginal);
setDemo(false);
}
const cls = [styles.root, demo ? styles.isDemo : ""].filter(Boolean).join(" ");
return (
<main className={styles.stage}>
<div className={cls} style={vars}>
<div className={styles.frame}>
<canvas className={styles.view} ref={viewRef} width={320} height={200} role="img" aria-label="샘플 사진" style={{ filter: showOriginal ? "none" : chain }}></canvas>
<span className={styles.ghost} aria-hidden="true"></span>
<span className={styles.orig} aria-hidden="true"></span>
<span className={styles.tag}>
<span className={styles.real}>{showOriginal ? TAG_ORIG : TAG_EDIT}</span>
<span className={`${styles.tagDemo} ${styles.tagDemoEdit}`} aria-hidden="true">보정 후</span>
<span className={`${styles.tagDemo} ${styles.tagDemoOrig}`} aria-hidden="true">원본</span>
</span>
</div>
<div className={styles.ctl}>
{SLIDERS.map((s) => (
<div className={styles.slider} key={s.key}>
<span className={styles.lab} id={s.labelId}>{s.label}</span>
<div
className={styles.track}
role="slider"
tabIndex={0}
aria-labelledby={s.labelId}
aria-valuemin={0.5}
aria-valuemax={1.5}
aria-valuenow={Number(tone[s.key].toFixed(2))}
aria-valuetext={VOICE.replace("%v", tone[s.key].toFixed(2))}
onPointerDown={(e: ReactPointerEvent<HTMLDivElement>) => onPointerDown(s.key, e)}
onPointerMove={(e: ReactPointerEvent<HTMLDivElement>) => onPointerMove(s.key, e)}
onKeyDown={(e: KeyEvt) => onKeyDown(s.key, e)}
>
<span className={`${styles.thumb} ${s.thumb}`} style={{ left: `${(tone[s.key] - 0.5) * 100}%` }}></span>
</div>
</div>
))}
</div>
<div className={styles.foot}>
<button type="button" className={styles.btn} onClick={onReset}>초기화</button>
<button type="button" className={`${styles.btn} ${styles.btnAb}`} aria-pressed={showOriginal} onClick={onCompare}><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" className={styles.ico} aria-hidden="true"><g fill="currentColor"><path d="M80 136a24 24 0 1 1-24-24a24 24 0 0 1 24 24m48-72a24 24 0 1 0 24 24a24 24 0 0 0-24-24m72 80a24 24 0 1 0 24 24a24 24 0 0 0-24-24" opacity=".2"/><path d="M64 105V40a8 8 0 0 0-16 0v65a32 32 0 0 0 0 62v49a8 8 0 0 0 16 0v-49a32 32 0 0 0 0-62m-8 47a16 16 0 1 1 16-16a16 16 0 0 1-16 16m80-95V40a8 8 0 0 0-16 0v17a32 32 0 0 0 0 62v97a8 8 0 0 0 16 0v-97a32 32 0 0 0 0-62m-8 47a16 16 0 1 1 16-16a16 16 0 0 1-16 16m104 64a32.06 32.06 0 0 0-24-31V40a8 8 0 0 0-16 0v97a32 32 0 0 0 0 62v17a8 8 0 0 0 16 0v-17a32.06 32.06 0 0 0 24-31m-32 16a16 16 0 1 1 16-16a16 16 0 0 1-16 16"/></g></svg><span>비교</span></button>
<span className={styles.codes}>
<code className={styles.realCode}>{chain}</code>
<code className={`${styles.demoCode} ${styles.demoCodeA}`} aria-hidden="true">brightness(1.00) contrast(1.00) saturate(1.00)</code>
<code className={`${styles.demoCode} ${styles.demoCodeB}`} aria-hidden="true">brightness(1.18) contrast(1.22) saturate(1.30)</code>
</span>
</div>
</div>
</main>
);
}04 · 밝기 대비 채도 슬라이더 — 측정 누적면적 13.291% · 움직인 프레임 11/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드에서 볼 곳
밝기 트랙을 오른쪽으로 끌었을 때 brightness(1.35) 와 낭독 문구 「1.35배」가 같이 갱신되는 것을 실측했습니다(누적면적 15.922%·강도 56.2).
// 슬라이더 셋이 filter 문자열 하나로 합쳐진다 — 순서가 곧 적용 순서다
var chain = 'brightness(' + value.b.toFixed(2) + ') contrast(' + value.c.toFixed(2) + ') saturate(' + value.s.toFixed(2) + ')';
view.style.filter = showOriginal ? 'none' : chain;
code.textContent = chain;
tag.textContent = showOriginal ? TAG_ORIG : TAG_EDIT;
for (var i = 0; i < tracks.length; i++) {
var key = tracks[i].getAttribute('data-k'), v = value[key];
thumbs[key].style.left = ((v - 0.5) * 100) + '%';
tracks[i].setAttribute('aria-valuenow', v.toFixed(2));
tracks[i].setAttribute('aria-valuetext', VOICE.replace('%v', v.toFixed(2)));
}
05좌우와 상하 뒤집기
화면에서 일어나는 일
축 하나에 음수 배율을 주는 일인데, 그냥 뒤집으면 그림이 캔버스 밖으로 나가므로 그 변의 길이만큼 먼저 밀어 놓아야 합니다. 미리보기는 네 상태를 steps(1, end) 로 끊는데, 1 에서 -1 로 이어서 보간하면 도중에 폭이 0 이 되는 장면이 생겨 대표 이미지가 선 한 줄로 남기 때문입니다.

<main class="stage">
<div class="fm is-demo" id="fm">
<div class="fm__frame">
<canvas class="fm__view" id="view" width="320" height="200" role="img" aria-label="샘플 사진"></canvas>
<span class="fm__ghost" aria-hidden="true"></span>
<span class="fm__axis" aria-hidden="true"></span>
<span class="fm__sweep" aria-hidden="true"></span>
</div>
<div class="fm__foot">
<button type="button" class="fm__btn fm__btn--x" id="xBtn" aria-pressed="false"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" class="fm__ico" aria-hidden="true"><g fill="currentColor"><path d="M112 40v160a8 8 0 0 1-8 8H40a8 8 0 0 1-7.37-11.12l64-160C100 28.86 112 31.29 112 40" opacity=".2"/><path d="M107.18 24.33a15.86 15.86 0 0 0-17.92 9.45l-.06.14l-64 159.93A16 16 0 0 0 40 216h64a16 16 0 0 0 16-16V40a15.85 15.85 0 0 0-12.82-15.67M104 200H40l.06-.15L104 40Zm126.77-6.15l-64-159.93l-.06-.14A16 16 0 0 0 136 40v160a16 16 0 0 0 16 16h64a16 16 0 0 0 14.78-22.15ZM152 200V40l63.93 159.84l.06.15Z"/></g></svg><span>좌우 뒤집기</span></button>
<button type="button" class="fm__btn fm__btn--y" id="yBtn" aria-pressed="false"><span>상하 뒤집기</span></button>
<span class="fm__codes">
<code class="fm__real" id="code">scale(1, 1)</code>
<code class="fm__demo fm__demo--a" aria-hidden="true">scale(1, 1)</code>
<code class="fm__demo fm__demo--b" aria-hidden="true">scale(-1, 1)</code>
<code class="fm__demo fm__demo--c" aria-hidden="true">scale(-1, -1)</code>
<code class="fm__demo fm__demo--d" aria-hidden="true">scale(1, -1)</code>
</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 좌우와 상하 뒤집기 — 실제 뒤집기는 canvas 2D 가 한다(translate 로 밀고 scale 을 음수로).
// 격자 미리보기(.is-demo)는 유령 판을 steps(1, end) 로 한 프레임에 뒤집는다 —
// scale 을 1 에서 -1 로 이어서 보간하면 중간에 폭이 0 이 되는 장면이 생겨 대표 이미지가 선 한 줄이 된다.
// 프레임 수를 채우는 몫은 따로 흐르는 빛줄기가 맡는다.
$duration: $dur-loop;
$easing: $ease-out;
$color: $subject-cream;
@include stage($stage-ink);
.fm { display: flex; flex-direction: column; align-items: center; gap: $sp-2; width: 100%; }
.fm__frame {
position: relative; height: clamp(100px, 52vh, 176px); aspect-ratio: 16 / 10; max-width: 100%;
border-radius: $r-sm; overflow: hidden; box-shadow: $shadow-raised;
}
.fm__view { display: block; width: 100%; height: 100%; }
.fm__ghost {
display: none; position: absolute; inset: 0;
background-image: var(--photo); background-size: cover;
}
.fm__axis {
position: absolute; left: 50%; top: 0; bottom: 0; width: 0;
border-left: 2px dashed rgba(255, 247, 230, .72);
}
.fm__sweep {
position: absolute; top: 0; bottom: 0; left: 0; width: 22%;
background: linear-gradient(90deg, rgba(255, 247, 230, 0) 0%, rgba(255, 247, 230, .34) 50%, rgba(255, 247, 230, 0) 100%);
transform: translateX(-120%);
}
.fm__foot { display: flex; align-items: center; gap: $sp-2; flex-wrap: wrap; justify-content: center; }
.fm__btn {
display: inline-flex; align-items: center; gap: $sp-1; cursor: pointer;
border: 1px solid rgba(255, 247, 230, .34); background: transparent; color: $color;
border-radius: $r-pill; font-family: inherit; font-weight: 700; line-height: 1.3;
font-size: clamp(8px, 2.2vw, 10px); padding: $sp-1 $sp-2;
}
.fm__btn:focus-visible { outline: 3px solid $subject-blue; outline-offset: 2px; }
// 눌린 알약의 글자는 크림이 아니라 흰색이다 — 파랑 위 크림은 4.25:1 로 AA 미달이다
.fm__btn[aria-pressed="true"] { background: $subject-blue; border-color: $subject-blue; color: $stage-paper; }
.fm__ico { width: 12px; height: 12px; flex: 0 0 auto; }
.fm__codes {
position: relative; display: inline-block; min-width: 72px;
font-family: ui-monospace, "Consolas", monospace; font-size: clamp(8px, 2.1vw, 10px);
color: rgba(255, 247, 230, .78);
}
.fm__demo { display: none; position: absolute; left: 0; top: 0; white-space: nowrap; }
// ── 미리보기 루프: 원본 → 좌우 → 좌우와 상하 → 상하 → 원본. 네 번 다 한 프레임에 끊는다.
// 복귀는 89% 에 끝난다 — 24장 표본의 마지막 장이 2초의 95.8% 지점이라 그 뒤는 안 찍힌다
.fm.is-demo .fm__view { visibility: hidden; }
.fm.is-demo .fm__ghost {
display: block;
animation-name: fmFlip; animation-duration: $duration;
animation-timing-function: steps(1, end); animation-iteration-count: infinite;
}
@keyframes fmFlip {
0%, 24% { transform: scaleX(1) scaleY(1); }
25%, 49% { transform: scaleX(-1) scaleY(1); }
50%, 74% { transform: scaleX(-1) scaleY(-1); }
75%, 88% { transform: scaleX(1) scaleY(-1); }
89%, 100% { transform: scaleX(1) scaleY(1); }
}
// 빛줄기는 한 번에 다 건너간다 — 끊기는 뒤집기 사이를 이 움직임이 메운다
.fm.is-demo .fm__sweep {
animation-name: fmSweep; animation-duration: $duration;
animation-timing-function: linear; animation-iteration-count: infinite;
}
@keyframes fmSweep {
0% { transform: translateX(-120%); }
100% { transform: translateX(560%); }
}
.fm.is-demo .fm__btn--x {
animation-name: fmOnX; animation-duration: $duration;
animation-timing-function: steps(1, end); animation-iteration-count: infinite;
}
.fm.is-demo .fm__btn--y {
animation-name: fmOnY; animation-duration: $duration;
animation-timing-function: steps(1, end); animation-iteration-count: infinite;
}
@keyframes fmOnX {
0%, 24% { background: transparent; border-color: rgba(255, 247, 230, .34); color: $color; }
25%, 74% { background: $subject-blue; border-color: $subject-blue; color: $stage-paper; }
75%, 100% { background: transparent; border-color: rgba(255, 247, 230, .34); color: $color; }
}
@keyframes fmOnY {
0%, 49% { background: transparent; border-color: rgba(255, 247, 230, .34); color: $color; }
50%, 88% { background: $subject-blue; border-color: $subject-blue; color: $stage-paper; }
89%, 100% { background: transparent; border-color: rgba(255, 247, 230, .34); color: $color; }
}
// 글자는 보간할 수 없으니 네 칸을 steps(1, end) 로 갈아 끼운다 — 뒤집기와 같은 순간에 바뀐다
.fm.is-demo .fm__real { color: transparent; }
.fm.is-demo .fm__demo {
display: block;
animation-duration: $duration;
animation-timing-function: steps(1, end); animation-iteration-count: infinite;
}
.fm.is-demo .fm__demo--a { animation-name: fmCodeA; }
.fm.is-demo .fm__demo--b { animation-name: fmCodeB; }
.fm.is-demo .fm__demo--c { animation-name: fmCodeC; }
.fm.is-demo .fm__demo--d { animation-name: fmCodeD; }
@keyframes fmCodeA { 0%, 24% { opacity: 1; } 25%, 88% { opacity: 0; } 89%, 100% { opacity: 1; } }
@keyframes fmCodeB { 0%, 24% { opacity: 0; } 25%, 49% { opacity: 1; } 50%, 100% { opacity: 0; } }
@keyframes fmCodeC { 0%, 49% { opacity: 0; } 50%, 74% { opacity: 1; } 75%, 100% { opacity: 0; } }
@keyframes fmCodeD { 0%, 74% { opacity: 0; } 75%, 88% { opacity: 1; } 89%, 100% { opacity: 0; } }
@media (max-width: 340px) {
.fm__foot { gap: $sp-1; }
}
@media (prefers-reduced-motion: reduce) {
.fm.is-demo .fm__ghost,
.fm.is-demo .fm__sweep,
.fm.is-demo .fm__btn--x,
.fm.is-demo .fm__btn--y,
.fm.is-demo .fm__demo { animation: none !important; }
// 움직임 없이 좌우를 뒤집어 둔 상태로 세운다
.fm.is-demo .fm__ghost { transform: scaleX(-1) scaleY(1) !important; }
.fm.is-demo .fm__sweep { opacity: 0 !important; }
.fm.is-demo .fm__demo--a,
.fm.is-demo .fm__demo--c,
.fm.is-demo .fm__demo--d { opacity: 0 !important; }
.fm.is-demo .fm__demo--b { opacity: 1 !important; }
.fm.is-demo .fm__btn--x { background: $subject-blue !important; border-color: $subject-blue !important; color: $stage-paper !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.fm {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
width: 100%;
}
.fm__frame {
position: relative;
height: clamp(100px, 52vh, 176px);
aspect-ratio: 16/10;
max-width: 100%;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.fm__view {
display: block;
width: 100%;
height: 100%;
}
.fm__ghost {
display: none;
position: absolute;
inset: 0;
background-image: var(--photo);
background-size: cover;
}
.fm__axis {
position: absolute;
left: 50%;
top: 0;
bottom: 0;
width: 0;
border-left: 2px dashed rgba(255, 247, 230, 0.72);
}
.fm__sweep {
position: absolute;
top: 0;
bottom: 0;
left: 0;
width: 22%;
background: linear-gradient(90deg, rgba(255, 247, 230, 0) 0%, rgba(255, 247, 230, 0.34) 50%, rgba(255, 247, 230, 0) 100%);
transform: translateX(-120%);
}
.fm__foot {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
justify-content: center;
}
.fm__btn {
display: inline-flex;
align-items: center;
gap: 4px;
cursor: pointer;
border: 1px solid rgba(255, 247, 230, 0.34);
background: transparent;
color: #fff7e6;
border-radius: 999px;
font-family: inherit;
font-weight: 700;
line-height: 1.3;
font-size: clamp(8px, 2.2vw, 10px);
padding: 4px 8px;
}
.fm__btn:focus-visible {
outline: 3px solid #2f6df6;
outline-offset: 2px;
}
.fm__btn[aria-pressed=true] {
background: #2f6df6;
border-color: #2f6df6;
color: #ffffff;
}
.fm__ico {
width: 12px;
height: 12px;
flex: 0 0 auto;
}
.fm__codes {
position: relative;
display: inline-block;
min-width: 72px;
font-family: ui-monospace, "Consolas", monospace;
font-size: clamp(8px, 2.1vw, 10px);
color: rgba(255, 247, 230, 0.78);
}
.fm__demo {
display: none;
position: absolute;
left: 0;
top: 0;
white-space: nowrap;
}
.fm.is-demo .fm__view {
visibility: hidden;
}
.fm.is-demo .fm__ghost {
display: block;
animation-name: fmFlip;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes fmFlip {
0%, 24% {
transform: scaleX(1) scaleY(1);
}
25%, 49% {
transform: scaleX(-1) scaleY(1);
}
50%, 74% {
transform: scaleX(-1) scaleY(-1);
}
75%, 88% {
transform: scaleX(1) scaleY(-1);
}
89%, 100% {
transform: scaleX(1) scaleY(1);
}
}
.fm.is-demo .fm__sweep {
animation-name: fmSweep;
animation-duration: 2s;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
@keyframes fmSweep {
0% {
transform: translateX(-120%);
}
100% {
transform: translateX(560%);
}
}
.fm.is-demo .fm__btn--x {
animation-name: fmOnX;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.fm.is-demo .fm__btn--y {
animation-name: fmOnY;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes fmOnX {
0%, 24% {
background: transparent;
border-color: rgba(255, 247, 230, 0.34);
color: #fff7e6;
}
25%, 74% {
background: #2f6df6;
border-color: #2f6df6;
color: #ffffff;
}
75%, 100% {
background: transparent;
border-color: rgba(255, 247, 230, 0.34);
color: #fff7e6;
}
}
@keyframes fmOnY {
0%, 49% {
background: transparent;
border-color: rgba(255, 247, 230, 0.34);
color: #fff7e6;
}
50%, 88% {
background: #2f6df6;
border-color: #2f6df6;
color: #ffffff;
}
89%, 100% {
background: transparent;
border-color: rgba(255, 247, 230, 0.34);
color: #fff7e6;
}
}
.fm.is-demo .fm__real {
color: transparent;
}
.fm.is-demo .fm__demo {
display: block;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.fm.is-demo .fm__demo--a {
animation-name: fmCodeA;
}
.fm.is-demo .fm__demo--b {
animation-name: fmCodeB;
}
.fm.is-demo .fm__demo--c {
animation-name: fmCodeC;
}
.fm.is-demo .fm__demo--d {
animation-name: fmCodeD;
}
@keyframes fmCodeA {
0%, 24% {
opacity: 1;
}
25%, 88% {
opacity: 0;
}
89%, 100% {
opacity: 1;
}
}
@keyframes fmCodeB {
0%, 24% {
opacity: 0;
}
25%, 49% {
opacity: 1;
}
50%, 100% {
opacity: 0;
}
}
@keyframes fmCodeC {
0%, 49% {
opacity: 0;
}
50%, 74% {
opacity: 1;
}
75%, 100% {
opacity: 0;
}
}
@keyframes fmCodeD {
0%, 74% {
opacity: 0;
}
75%, 88% {
opacity: 1;
}
89%, 100% {
opacity: 0;
}
}
@media (max-width: 340px) {
.fm__foot {
gap: 4px;
}
}
@media (prefers-reduced-motion: reduce) {
.fm.is-demo .fm__ghost,
.fm.is-demo .fm__sweep,
.fm.is-demo .fm__btn--x,
.fm.is-demo .fm__btn--y,
.fm.is-demo .fm__demo {
animation: none !important;
}
.fm.is-demo .fm__ghost {
transform: scaleX(-1) scaleY(1) !important;
}
.fm.is-demo .fm__sweep {
opacity: 0 !important;
}
.fm.is-demo .fm__demo--a,
.fm.is-demo .fm__demo--c,
.fm.is-demo .fm__demo--d {
opacity: 0 !important;
}
.fm.is-demo .fm__demo--b {
opacity: 1 !important;
}
.fm.is-demo .fm__btn--x {
background: #2f6df6 !important;
border-color: #2f6df6 !important;
color: #ffffff !important;
}
}var fm = document.getElementById('fm');
var view = document.getElementById('view');
var code = document.getElementById('code');
var xBtn = document.getElementById('xBtn');
var yBtn = document.getElementById('yBtn');
var vctx = view.getContext('2d');
var src = document.createElement('canvas');
var flipX = false, flipY = false;
var VIEW_LABEL = '미리보기, 가로 %x 세로 %y';
var ON = '뒤집음';
var OFF = '그대로';
// 가짜 사진 — 외부 파일 없이 그라디언트와 도형으로 합성한다. 필터·히스토그램이 보이도록 대비를 세게 준다
function paintPhoto(c, w, h) {
var sky = c.createLinearGradient(0, 0, 0, h);
sky.addColorStop(0, '#2f6df6'); sky.addColorStop(0.55, '#ff9ebb'); sky.addColorStop(1, '#ffd23f');
c.fillStyle = sky; c.fillRect(0, 0, w, h);
c.fillStyle = '#fff7e6';
c.beginPath(); c.arc(w * 0.74, h * 0.28, h * 0.13, 0, Math.PI * 2); c.fill();
// 곧은 수평선 하나 — 기울기 보정이 눈에 보이려면 비교할 직선이 있어야 한다
c.fillStyle = 'rgba(23, 20, 26, 0.5)'; c.fillRect(0, Math.round(h * 0.66), w, 2);
c.fillStyle = '#17141a';
c.beginPath(); c.moveTo(0, h); c.lineTo(w * 0.34, h * 0.36); c.lineTo(w * 0.62, h); c.closePath(); c.fill();
c.fillStyle = '#c2361a';
c.beginPath(); c.moveTo(w * 0.46, h); c.lineTo(w * 0.78, h * 0.52); c.lineTo(w, h); c.closePath(); c.fill();
for (var i = 0; i < 6; i++) { c.fillStyle = i % 2 ? '#fff7e6' : '#17141a'; c.fillRect(w * (0.08 + i * 0.07), h * (0.14 + (i % 3) * 0.06), 7, 7); }
}
function render() {
var w = view.width, h = view.height;
vctx.setTransform(1, 0, 0, 1, 0, 0);
vctx.clearRect(0, 0, w, h);
// 축을 음수로 뒤집으면 그림이 화면 밖으로 나가므로 그만큼 먼저 밀어 놓는다
vctx.translate(flipX ? w : 0, flipY ? h : 0);
vctx.scale(flipX ? -1 : 1, flipY ? -1 : 1);
vctx.drawImage(src, 0, 0, w, h);
code.textContent = 'scale(' + (flipX ? -1 : 1) + ', ' + (flipY ? -1 : 1) + ')';
xBtn.setAttribute('aria-pressed', flipX ? 'true' : 'false');
yBtn.setAttribute('aria-pressed', flipY ? 'true' : 'false');
view.setAttribute('aria-label', VIEW_LABEL.replace('%x', flipX ? ON : OFF).replace('%y', flipY ? ON : OFF));
}
xBtn.addEventListener('click', function () { flipX = !flipX; fm.classList.remove('is-demo'); render(); });
yBtn.addEventListener('click', function () { flipY = !flipY; fm.classList.remove('is-demo'); render(); });
src.width = 320; src.height = 200;
paintPhoto(src.getContext('2d'), src.width, src.height);
document.documentElement.style.setProperty('--photo', 'url(' + src.toDataURL() + ')');
render();// ImageFlip — vanilla 판(run/295/demo/05-image-edit-flip-mirror/index.html) 의 React 포트.
// 실제 뒤집기는 canvas 2D 가 한다 — 축을 음수로 세우면 그림이 화면 밖으로 나가므로 translate 로
// 그만큼 먼저 밀어 놓고 scale 을 음수로 준다. 원본 그림은 화면에 없는 보조 canvas 에 한 번만 그려
// 두고(src), 뒤집을 때마다 그것을 다시 drawImage 한다. 값·수치·키프레임은 vanilla 와 같다.
import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./ImageFlip.module.scss";
export type ImageFlipProps = {
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & {
"--duration"?: string;
"--easing"?: string;
"--color"?: string;
"--photo"?: string;
};
const VIEW_LABEL = "미리보기, 가로 %x 세로 %y";
const ON = "뒤집음";
const OFF = "그대로";
// 가짜 사진 — 외부 파일 없이 그라디언트와 도형으로 합성한다. 필터·히스토그램이 보이도록 대비를 세게 준다
function paintPhoto(c: CanvasRenderingContext2D, w: number, h: number): void {
const sky = c.createLinearGradient(0, 0, 0, h);
sky.addColorStop(0, "#2f6df6"); sky.addColorStop(0.55, "#ff9ebb"); sky.addColorStop(1, "#ffd23f");
c.fillStyle = sky; c.fillRect(0, 0, w, h);
c.fillStyle = "#fff7e6";
c.beginPath(); c.arc(w * 0.74, h * 0.28, h * 0.13, 0, Math.PI * 2); c.fill();
// 곧은 수평선 하나 — 기울기 보정이 눈에 보이려면 비교할 직선이 있어야 한다
c.fillStyle = "rgba(23, 20, 26, 0.5)"; c.fillRect(0, Math.round(h * 0.66), w, 2);
c.fillStyle = "#17141a";
c.beginPath(); c.moveTo(0, h); c.lineTo(w * 0.34, h * 0.36); c.lineTo(w * 0.62, h); c.closePath(); c.fill();
c.fillStyle = "#c2361a";
c.beginPath(); c.moveTo(w * 0.46, h); c.lineTo(w * 0.78, h * 0.52); c.lineTo(w, h); c.closePath(); c.fill();
for (let i = 0; i < 6; i++) { c.fillStyle = i % 2 ? "#fff7e6" : "#17141a"; c.fillRect(w * (0.08 + i * 0.07), h * (0.14 + (i % 3) * 0.06), 7, 7); }
}
export default function ImageFlip({
duration = 2000,
easing = "ease-out",
color = "#fff7e6",
}: ImageFlipProps) {
const viewRef = useRef<HTMLCanvasElement | null>(null);
const srcRef = useRef<HTMLCanvasElement | null>(null);
const [flipX, setFlipX] = useState(false);
const [flipY, setFlipY] = useState(false);
const [demo, setDemo] = useState(true);
const [photo, setPhoto] = useState("");
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
if (photo) vars["--photo"] = `url(${photo})`;
useEffect(() => {
const src = document.createElement("canvas");
src.width = 320; src.height = 200;
const ctx = src.getContext("2d");
if (!ctx) return;
paintPhoto(ctx, src.width, src.height);
srcRef.current = src;
setPhoto(src.toDataURL());
}, []);
useEffect(() => {
const view = viewRef.current;
const src = srcRef.current;
if (!view || !src) return;
const ctx = view.getContext("2d");
if (!ctx) return;
const w = view.width, h = view.height;
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.clearRect(0, 0, w, h);
// 축을 음수로 뒤집으면 그림이 화면 밖으로 나가므로 그만큼 먼저 밀어 놓는다
ctx.translate(flipX ? w : 0, flipY ? h : 0);
ctx.scale(flipX ? -1 : 1, flipY ? -1 : 1);
ctx.drawImage(src, 0, 0, w, h);
}, [flipX, flipY, photo]);
function onFlipX(): void {
setFlipX(!flipX);
setDemo(false);
}
function onFlipY(): void {
setFlipY(!flipY);
setDemo(false);
}
const code = `scale(${flipX ? -1 : 1}, ${flipY ? -1 : 1})`;
const label = VIEW_LABEL.replace("%x", flipX ? ON : OFF).replace("%y", flipY ? ON : OFF);
const cls = [styles.root, demo ? styles.isDemo : ""].filter(Boolean).join(" ");
return (
<main className={styles.stage}>
<div className={cls} style={vars}>
<div className={styles.frame}>
<canvas className={styles.view} ref={viewRef} width={320} height={200} role="img" aria-label={label}></canvas>
<span className={styles.ghost} aria-hidden="true"></span>
<span className={styles.axis} aria-hidden="true"></span>
<span className={styles.sweep} aria-hidden="true"></span>
</div>
<div className={styles.foot}>
<button type="button" className={`${styles.btn} ${styles.btnX}`} aria-pressed={flipX} onClick={onFlipX}><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" className={styles.ico} aria-hidden="true"><g fill="currentColor"><path d="M112 40v160a8 8 0 0 1-8 8H40a8 8 0 0 1-7.37-11.12l64-160C100 28.86 112 31.29 112 40" opacity=".2"/><path d="M107.18 24.33a15.86 15.86 0 0 0-17.92 9.45l-.06.14l-64 159.93A16 16 0 0 0 40 216h64a16 16 0 0 0 16-16V40a15.85 15.85 0 0 0-12.82-15.67M104 200H40l.06-.15L104 40Zm126.77-6.15l-64-159.93l-.06-.14A16 16 0 0 0 136 40v160a16 16 0 0 0 16 16h64a16 16 0 0 0 14.78-22.15ZM152 200V40l63.93 159.84l.06.15Z"/></g></svg><span>좌우 뒤집기</span></button>
<button type="button" className={`${styles.btn} ${styles.btnY}`} aria-pressed={flipY} onClick={onFlipY}><span>상하 뒤집기</span></button>
<span className={styles.codes}>
<code className={styles.real}>{code}</code>
<code className={`${styles.demoCode} ${styles.demoCodeA}`} aria-hidden="true">scale(1, 1)</code>
<code className={`${styles.demoCode} ${styles.demoCodeB}`} aria-hidden="true">scale(-1, 1)</code>
<code className={`${styles.demoCode} ${styles.demoCodeC}`} aria-hidden="true">scale(-1, -1)</code>
<code className={`${styles.demoCode} ${styles.demoCodeD}`} aria-hidden="true">scale(1, -1)</code>
</span>
</div>
</div>
</main>
);
}05 · 좌우와 상하 뒤집기 — 측정 누적면적 29.441% · 움직인 프레임 20/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드 읽는 법
컷만으로는 프레임이 모자라 빛줄기 하나를 따로 흘려 20/23 프레임을 채웠습니다(누적면적 29.441%).
var w = view.width, h = view.height;
vctx.setTransform(1, 0, 0, 1, 0, 0);
vctx.clearRect(0, 0, w, h);
// 축을 음수로 뒤집으면 그림이 화면 밖으로 나가므로 그만큼 먼저 밀어 놓는다
vctx.translate(flipX ? w : 0, flipY ? h : 0);
vctx.scale(flipX ? -1 : 1, flipY ? -1 : 1);
vctx.drawImage(src, 0, 0, w, h);
06정사각 여백 채우기
어떤 움직임인가
가로로 긴 사진을 정사각 규격에 넣을 때 잘라내는 대신 남는 자리를 칠하는 방식입니다. 배율은 안쪽 크기를 사진의 가로·세로로 각각 나눈 값 중 작은 쪽이라 긴 변이 먼저 닿고 잘리지 않습니다.

<main class="stage">
<div class="sq is-demo" id="sq">
<p class="sq__cap"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" class="sq__ico" aria-hidden="true"><g fill="currentColor"><path d="M224 56v144a8 8 0 0 1-8 8H40a8 8 0 0 1-8-8V56a8 8 0 0 1 8-8h176a8 8 0 0 1 8 8" opacity=".2"/><path d="M200 80v32a8 8 0 0 1-16 0V88h-24a8 8 0 0 1 0-16h32a8 8 0 0 1 8 8M96 168H72v-24a8 8 0 0 0-16 0v32a8 8 0 0 0 8 8h32a8 8 0 0 0 0-16M232 56v144a16 16 0 0 1-16 16H40a16 16 0 0 1-16-16V56a16 16 0 0 1 16-16h176a16 16 0 0 1 16 16m-16 144V56H40v144z"/></g></svg><span>정사각 맞춤</span></p>
<div class="sq__frame">
<canvas class="sq__out" id="out" width="240" height="240" role="img" aria-label="정사각 결과"></canvas>
<span class="sq__ghost" aria-hidden="true"><span class="sq__ghostImg"></span></span>
</div>
<div class="sq__row">
<button type="button" class="sq__c sq__c--paper" data-bg="#ffffff" aria-label="배경 흰색" aria-pressed="true"></button>
<button type="button" class="sq__c sq__c--ink" data-bg="#17141a" aria-label="배경 먹색" aria-pressed="false"></button>
<button type="button" class="sq__c sq__c--cream" data-bg="#fff7e6" aria-label="배경 크림" aria-pressed="false"></button>
<button type="button" class="sq__c sq__c--blue" data-bg="#2f6df6" aria-label="배경 파랑" aria-pressed="false"></button>
<span class="sq__pad">
<span class="sq__lab" id="padLab">여백</span>
<span class="sq__track" id="track" role="slider" tabindex="0" aria-labelledby="padLab" aria-valuemin="0" aria-valuemax="20" aria-valuenow="8"><span class="sq__thumb" id="thumb"></span></span>
</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 정사각 여백 채우기 — 실제 결과는 canvas 2D 가 만든다(fillRect 로 바탕을 칠하고 drawImage 로 가운데 앉힌다).
// 격자 미리보기(.is-demo)는 유령 판의 배경색을 steps(1, end) 로 갈아 끼우고 사진 판만 계속 줄였다 키운다.
// 색은 한 프레임에 끊어야 두 색이 반투명하게 겹친 장면이 대표 이미지로 뽑히지 않는다.
$duration: $dur-loop;
$easing: $ease-out;
// 오렌지 무대 위 크림 글자는 3.11:1 로 AA 미달이었다 — 잉크로 바꿔 5.50:1 (run/295/_probe_contrast)
$color: $subject-ink;
@include stage($stage-orange);
.sq { display: flex; flex-direction: column; align-items: center; gap: $sp-2; width: 100%; }
.sq__cap {
display: flex; align-items: center; gap: $sp-1; margin: 0;
font-weight: 800; font-size: clamp(9px, 2.4vw, 11px); color: $color;
}
.sq__ico { width: 13px; height: 13px; flex: 0 0 auto; }
.sq__frame {
position: relative; height: clamp(80px, 38vh, 130px); aspect-ratio: 1;
border-radius: $r-sm; overflow: hidden; box-shadow: $shadow-raised;
}
.sq__out { display: block; width: 100%; height: 100%; }
.sq__ghost { display: none; position: absolute; inset: 0; background: $stage-paper; }
.sq__ghostImg {
position: absolute; left: 8%; right: 8%; top: 50%; aspect-ratio: 16 / 10;
background-image: var(--photo); background-size: cover;
transform: translateY(-50%);
}
.sq__row { display: flex; align-items: center; gap: $sp-2; flex-wrap: wrap; justify-content: center; }
.sq__c {
width: 18px; height: 18px; padding: 0; cursor: pointer;
border: 2px solid rgba(255, 247, 230, .7); border-radius: 50%;
}
.sq__c:focus-visible { outline: 3px solid $subject-blue; outline-offset: 2px; }
.sq__c[aria-pressed="true"] { border-color: $subject-ink; }
.sq__c--paper { background: $stage-paper; }
.sq__c--ink { background: $subject-ink; }
.sq__c--cream { background: $subject-cream; }
.sq__c--blue { background: $subject-blue; }
.sq__pad { display: inline-flex; align-items: center; gap: $sp-1; }
.sq__lab { font-weight: 800; font-size: clamp(8px, 2.2vw, 10px); color: $color; background: $subject-cream; padding: 0 $sp-1; border-radius: $r-xs; } // 액자 그림자가 깔린 주황 위에선 잉크도 4.09:1 — 크림 알약 면 위 17.11:1
.sq__track {
position: relative; display: block; width: 72px; height: 6px; cursor: pointer;
border-radius: $r-pill; background: rgba(255, 247, 230, .4);
}
.sq__track:focus-visible { outline: 3px solid $subject-blue; outline-offset: 4px; }
.sq__thumb {
position: absolute; top: 50%; left: 40%; width: 13px; height: 13px;
border-radius: 50%; background: $stage-paper; box-shadow: $shadow-press;
transform: translate(-50%, -50%);
}
// ── 미리보기 루프: 바탕색 넷을 차례로 끊어 보여주고, 사진은 여백 8퍼센트와 18퍼센트 사이를 오간다.
// 복귀는 88% 에 끝난다 — 24장 표본의 마지막 장이 2초의 95.8% 지점이라 그 뒤는 안 찍힌다
.sq.is-demo .sq__out { visibility: hidden; }
.sq.is-demo .sq__ghost {
display: block;
animation-name: sqBg; animation-duration: $duration;
animation-timing-function: steps(1, end); animation-iteration-count: infinite;
}
@keyframes sqBg {
0%, 18% { background: $stage-paper; }
20%, 40% { background: $subject-ink; }
42%, 62% { background: $subject-cream; }
64%, 86% { background: $subject-blue; }
88%, 100% { background: $stage-paper; }
}
.sq.is-demo .sq__ghostImg {
animation-name: sqPad; animation-duration: $duration;
animation-timing-function: $easing; animation-iteration-count: infinite;
}
@keyframes sqPad {
0% { transform: translateY(-50%) scale(1); }
44% { transform: translateY(-50%) scale(0.76); }
88%, 100% { transform: translateY(-50%) scale(1); }
}
.sq.is-demo .sq__thumb {
animation-name: sqThumb; animation-duration: $duration;
animation-timing-function: $easing; animation-iteration-count: infinite;
}
@keyframes sqThumb {
0% { transform: translate(-50%, -50%); }
44% { transform: translate(-50%, -50%) translateX(36px); }
88%, 100% { transform: translate(-50%, -50%); }
}
// 고른 색 표시도 바탕색과 **같은 순간에** 옮긴다 — 정지한 채 두면 「흰색이 눌린 상태인데 바탕은 크림」
// 같은 어긋난 장면이 대표 이미지로 뽑힌다 (실측)
.sq.is-demo .sq__c {
animation-duration: $duration;
animation-timing-function: steps(1, end); animation-iteration-count: infinite;
}
.sq.is-demo .sq__c--paper { animation-name: sqPickA; }
.sq.is-demo .sq__c--ink { animation-name: sqPickB; }
.sq.is-demo .sq__c--cream { animation-name: sqPickC; }
.sq.is-demo .sq__c--blue { animation-name: sqPickD; }
@keyframes sqPickA { 0%, 19% { border-color: $subject-ink; } 20%, 87% { border-color: rgba(255, 247, 230, .7); } 88%, 100% { border-color: $subject-ink; } }
@keyframes sqPickB { 0%, 19% { border-color: rgba(255, 247, 230, .7); } 20%, 41% { border-color: $subject-ink; } 42%, 100% { border-color: rgba(255, 247, 230, .7); } }
@keyframes sqPickC { 0%, 41% { border-color: rgba(255, 247, 230, .7); } 42%, 63% { border-color: $subject-ink; } 64%, 100% { border-color: rgba(255, 247, 230, .7); } }
@keyframes sqPickD { 0%, 63% { border-color: rgba(255, 247, 230, .7); } 64%, 87% { border-color: $subject-ink; } 88%, 100% { border-color: rgba(255, 247, 230, .7); } }
@media (max-width: 340px) {
.sq__row { gap: $sp-1; }
}
@media (prefers-reduced-motion: reduce) {
.sq.is-demo .sq__ghost,
.sq.is-demo .sq__ghostImg,
.sq.is-demo .sq__thumb,
.sq.is-demo .sq__c { animation: none !important; }
// 움직임 없이 먹색 바탕에 여백을 넓힌 상태로 세운다
.sq.is-demo .sq__ghost { background: $subject-ink !important; }
.sq.is-demo .sq__ghostImg { transform: translateY(-50%) scale(0.76) !important; }
.sq.is-demo .sq__thumb { transform: translate(-50%, -50%) translateX(36px) !important; }
.sq.is-demo .sq__c--ink { border-color: $subject-ink !important; }
.sq.is-demo .sq__c--paper { border-color: rgba(255, 247, 230, .7) !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.sq {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
width: 100%;
}
.sq__cap {
display: flex;
align-items: center;
gap: 4px;
margin: 0;
font-weight: 800;
font-size: clamp(9px, 2.4vw, 11px);
color: #17141a;
}
.sq__ico {
width: 13px;
height: 13px;
flex: 0 0 auto;
}
.sq__frame {
position: relative;
height: clamp(80px, 38vh, 130px);
aspect-ratio: 1;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.sq__out {
display: block;
width: 100%;
height: 100%;
}
.sq__ghost {
display: none;
position: absolute;
inset: 0;
background: #ffffff;
}
.sq__ghostImg {
position: absolute;
left: 8%;
right: 8%;
top: 50%;
aspect-ratio: 16/10;
background-image: var(--photo);
background-size: cover;
transform: translateY(-50%);
}
.sq__row {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
justify-content: center;
}
.sq__c {
width: 18px;
height: 18px;
padding: 0;
cursor: pointer;
border: 2px solid rgba(255, 247, 230, 0.7);
border-radius: 50%;
}
.sq__c:focus-visible {
outline: 3px solid #2f6df6;
outline-offset: 2px;
}
.sq__c[aria-pressed=true] {
border-color: #17141a;
}
.sq__c--paper {
background: #ffffff;
}
.sq__c--ink {
background: #17141a;
}
.sq__c--cream {
background: #fff7e6;
}
.sq__c--blue {
background: #2f6df6;
}
.sq__pad {
display: inline-flex;
align-items: center;
gap: 4px;
}
.sq__lab {
font-weight: 800;
font-size: clamp(8px, 2.2vw, 10px);
color: #17141a;
background: #fff7e6;
padding: 0 4px;
border-radius: 4px;
}
.sq__track {
position: relative;
display: block;
width: 72px;
height: 6px;
cursor: pointer;
border-radius: 999px;
background: rgba(255, 247, 230, 0.4);
}
.sq__track:focus-visible {
outline: 3px solid #2f6df6;
outline-offset: 4px;
}
.sq__thumb {
position: absolute;
top: 50%;
left: 40%;
width: 13px;
height: 13px;
border-radius: 50%;
background: #ffffff;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
transform: translate(-50%, -50%);
}
.sq.is-demo .sq__out {
visibility: hidden;
}
.sq.is-demo .sq__ghost {
display: block;
animation-name: sqBg;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes sqBg {
0%, 18% {
background: #ffffff;
}
20%, 40% {
background: #17141a;
}
42%, 62% {
background: #fff7e6;
}
64%, 86% {
background: #2f6df6;
}
88%, 100% {
background: #ffffff;
}
}
.sq.is-demo .sq__ghostImg {
animation-name: sqPad;
animation-duration: 2s;
animation-timing-function: ease-out;
animation-iteration-count: infinite;
}
@keyframes sqPad {
0% {
transform: translateY(-50%) scale(1);
}
44% {
transform: translateY(-50%) scale(0.76);
}
88%, 100% {
transform: translateY(-50%) scale(1);
}
}
.sq.is-demo .sq__thumb {
animation-name: sqThumb;
animation-duration: 2s;
animation-timing-function: ease-out;
animation-iteration-count: infinite;
}
@keyframes sqThumb {
0% {
transform: translate(-50%, -50%);
}
44% {
transform: translate(-50%, -50%) translateX(36px);
}
88%, 100% {
transform: translate(-50%, -50%);
}
}
.sq.is-demo .sq__c {
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.sq.is-demo .sq__c--paper {
animation-name: sqPickA;
}
.sq.is-demo .sq__c--ink {
animation-name: sqPickB;
}
.sq.is-demo .sq__c--cream {
animation-name: sqPickC;
}
.sq.is-demo .sq__c--blue {
animation-name: sqPickD;
}
@keyframes sqPickA {
0%, 19% {
border-color: #17141a;
}
20%, 87% {
border-color: rgba(255, 247, 230, 0.7);
}
88%, 100% {
border-color: #17141a;
}
}
@keyframes sqPickB {
0%, 19% {
border-color: rgba(255, 247, 230, 0.7);
}
20%, 41% {
border-color: #17141a;
}
42%, 100% {
border-color: rgba(255, 247, 230, 0.7);
}
}
@keyframes sqPickC {
0%, 41% {
border-color: rgba(255, 247, 230, 0.7);
}
42%, 63% {
border-color: #17141a;
}
64%, 100% {
border-color: rgba(255, 247, 230, 0.7);
}
}
@keyframes sqPickD {
0%, 63% {
border-color: rgba(255, 247, 230, 0.7);
}
64%, 87% {
border-color: #17141a;
}
88%, 100% {
border-color: rgba(255, 247, 230, 0.7);
}
}
@media (max-width: 340px) {
.sq__row {
gap: 4px;
}
}
@media (prefers-reduced-motion: reduce) {
.sq.is-demo .sq__ghost,
.sq.is-demo .sq__ghostImg,
.sq.is-demo .sq__thumb,
.sq.is-demo .sq__c {
animation: none !important;
}
.sq.is-demo .sq__ghost {
background: #17141a !important;
}
.sq.is-demo .sq__ghostImg {
transform: translateY(-50%) scale(0.76) !important;
}
.sq.is-demo .sq__thumb {
transform: translate(-50%, -50%) translateX(36px) !important;
}
.sq.is-demo .sq__c--ink {
border-color: #17141a !important;
}
.sq.is-demo .sq__c--paper {
border-color: rgba(255, 247, 230, 0.7) !important;
}
}var sq = document.getElementById('sq');
var out = document.getElementById('out');
var track = document.getElementById('track');
var thumb = document.getElementById('thumb');
var octx = out.getContext('2d');
var src = document.createElement('canvas');
var swatches = document.querySelectorAll('.sq__c');
var bg = '#ffffff', pad = 0.08;
var VOICE = '%p 퍼센트';
// 가짜 사진 — 외부 파일 없이 그라디언트와 도형으로 합성한다. 필터·히스토그램이 보이도록 대비를 세게 준다
function paintPhoto(c, w, h) {
var sky = c.createLinearGradient(0, 0, 0, h);
sky.addColorStop(0, '#2f6df6'); sky.addColorStop(0.55, '#ff9ebb'); sky.addColorStop(1, '#ffd23f');
c.fillStyle = sky; c.fillRect(0, 0, w, h);
c.fillStyle = '#fff7e6';
c.beginPath(); c.arc(w * 0.74, h * 0.28, h * 0.13, 0, Math.PI * 2); c.fill();
// 곧은 수평선 하나 — 기울기 보정이 눈에 보이려면 비교할 직선이 있어야 한다
c.fillStyle = 'rgba(23, 20, 26, 0.5)'; c.fillRect(0, Math.round(h * 0.66), w, 2);
c.fillStyle = '#17141a';
c.beginPath(); c.moveTo(0, h); c.lineTo(w * 0.34, h * 0.36); c.lineTo(w * 0.62, h); c.closePath(); c.fill();
c.fillStyle = '#c2361a';
c.beginPath(); c.moveTo(w * 0.46, h); c.lineTo(w * 0.78, h * 0.52); c.lineTo(w, h); c.closePath(); c.fill();
for (var i = 0; i < 6; i++) { c.fillStyle = i % 2 ? '#fff7e6' : '#17141a'; c.fillRect(w * (0.08 + i * 0.07), h * (0.14 + (i % 3) * 0.06), 7, 7); }
}
function render() {
var s = out.width;
// 먼저 정사각 전체를 고른 색으로 칠하고, 그 위에 사진을 가운데로 앉힌다
octx.fillStyle = bg;
octx.fillRect(0, 0, s, s);
var inner = s * (1 - pad * 2);
var k = Math.min(inner / src.width, inner / src.height);
var dw = src.width * k, dh = src.height * k;
octx.drawImage(src, (s - dw) / 2, (s - dh) / 2, dw, dh);
thumb.style.left = (pad / 0.2 * 100) + '%';
track.setAttribute('aria-valuenow', Math.round(pad * 100));
track.setAttribute('aria-valuetext', VOICE.replace('%p', Math.round(pad * 100)));
}
function setPad(next) {
pad = Math.max(0, Math.min(0.2, next));
sq.classList.remove('is-demo');
render();
}
for (var i = 0; i < swatches.length; i++) {
(function (el) {
el.addEventListener('click', function () {
bg = el.getAttribute('data-bg');
for (var j = 0; j < swatches.length; j++) { swatches[j].setAttribute('aria-pressed', swatches[j] === el ? 'true' : 'false'); }
sq.classList.remove('is-demo'); render();
});
})(swatches[i]);
}
function fromPointer(e) {
var r = track.getBoundingClientRect();
setPad(Math.round((e.clientX - r.left) / r.width * 20) / 100);
}
track.addEventListener('pointerdown', function (e) { track.setPointerCapture(e.pointerId); fromPointer(e); });
track.addEventListener('pointermove', function (e) { if (e.buttons) { fromPointer(e); } });
// 화살표 1퍼센트, Shift 와 함께면 5퍼센트
track.addEventListener('keydown', function (e) {
var step = e.shiftKey ? 0.05 : 0.01;
if (e.key === 'ArrowLeft' || e.key === 'ArrowDown') { e.preventDefault(); setPad(pad - step); }
else if (e.key === 'ArrowRight' || e.key === 'ArrowUp') { e.preventDefault(); setPad(pad + step); }
});
src.width = 320; src.height = 200;
paintPhoto(src.getContext('2d'), src.width, src.height);
document.documentElement.style.setProperty('--photo', 'url(' + src.toDataURL() + ')');
render();// SquarePad — vanilla 판(run/295/demo/06-image-edit-bg-pad-square/index.html) 의 React 포트.
// 정사각 캔버스를 고른 색으로 먼저 fillRect 한 뒤, 여백을 뺀 안쪽에 맞춰 줄인 사진을 drawImage 로
// 가운데 앉힌다 — 가로세로 어느 쪽도 넘지 않도록 배율은 두 비율 중 작은 쪽을 쓴다.
// 원본 그림은 화면에 없는 보조 canvas(src)에 한 번만 그려 두고 다시 쓴다. 값·수치·키프레임은 vanilla 와 같다.
import { useEffect, useRef, useState } from "react";
import type { CSSProperties, PointerEvent as ReactPointerEvent } from "react";
import styles from "./SquarePad.module.scss";
export type SquarePadProps = {
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & {
"--duration"?: string;
"--easing"?: string;
"--color"?: string;
"--photo"?: string;
};
// react 앰비언트에 KeyboardEvent 가 없다 — 키 핸들러가 실제로 쓰는 것만 지역 타입으로 선언한다
type KeyEvt = { key: string; shiftKey: boolean; preventDefault(): void };
const VOICE = "%p 퍼센트";
const SWATCHES: { bg: string; label: string; cls: string }[] = [
{ bg: "#ffffff", label: "배경 흰색", cls: styles.swatchPaper },
{ bg: "#17141a", label: "배경 먹색", cls: styles.swatchInk },
{ bg: "#fff7e6", label: "배경 크림", cls: styles.swatchCream },
{ bg: "#2f6df6", label: "배경 파랑", cls: styles.swatchBlue },
];
// 가짜 사진 — 외부 파일 없이 그라디언트와 도형으로 합성한다. 필터·히스토그램이 보이도록 대비를 세게 준다
function paintPhoto(c: CanvasRenderingContext2D, w: number, h: number): void {
const sky = c.createLinearGradient(0, 0, 0, h);
sky.addColorStop(0, "#2f6df6"); sky.addColorStop(0.55, "#ff9ebb"); sky.addColorStop(1, "#ffd23f");
c.fillStyle = sky; c.fillRect(0, 0, w, h);
c.fillStyle = "#fff7e6";
c.beginPath(); c.arc(w * 0.74, h * 0.28, h * 0.13, 0, Math.PI * 2); c.fill();
// 곧은 수평선 하나 — 기울기 보정이 눈에 보이려면 비교할 직선이 있어야 한다
c.fillStyle = "rgba(23, 20, 26, 0.5)"; c.fillRect(0, Math.round(h * 0.66), w, 2);
c.fillStyle = "#17141a";
c.beginPath(); c.moveTo(0, h); c.lineTo(w * 0.34, h * 0.36); c.lineTo(w * 0.62, h); c.closePath(); c.fill();
c.fillStyle = "#c2361a";
c.beginPath(); c.moveTo(w * 0.46, h); c.lineTo(w * 0.78, h * 0.52); c.lineTo(w, h); c.closePath(); c.fill();
for (let i = 0; i < 6; i++) { c.fillStyle = i % 2 ? "#fff7e6" : "#17141a"; c.fillRect(w * (0.08 + i * 0.07), h * (0.14 + (i % 3) * 0.06), 7, 7); }
}
export default function SquarePad({
duration = 2000,
easing = "ease-out",
color = "#17141a",
}: SquarePadProps) {
const outRef = useRef<HTMLCanvasElement | null>(null);
const srcRef = useRef<HTMLCanvasElement | null>(null);
const [bg, setBg] = useState("#ffffff");
const [pad, setPad] = useState(0.08);
const [demo, setDemo] = useState(true);
const [photo, setPhoto] = useState("");
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
if (photo) vars["--photo"] = `url(${photo})`;
useEffect(() => {
const src = document.createElement("canvas");
src.width = 320; src.height = 200;
const ctx = src.getContext("2d");
if (!ctx) return;
paintPhoto(ctx, src.width, src.height);
srcRef.current = src;
setPhoto(src.toDataURL());
}, []);
useEffect(() => {
const out = outRef.current;
const src = srcRef.current;
if (!out || !src) return;
const ctx = out.getContext("2d");
if (!ctx) return;
const s = out.width;
// 먼저 정사각 전체를 고른 색으로 칠하고, 그 위에 사진을 가운데로 앉힌다
ctx.fillStyle = bg;
ctx.fillRect(0, 0, s, s);
const inner = s * (1 - pad * 2);
const k = Math.min(inner / src.width, inner / src.height);
const dw = src.width * k, dh = src.height * k;
ctx.drawImage(src, (s - dw) / 2, (s - dh) / 2, dw, dh);
}, [bg, pad, photo]);
function applyPad(next: number): void {
setPad(Math.max(0, Math.min(0.2, next)));
setDemo(false);
}
function onPick(next: string): void {
setBg(next);
setDemo(false);
}
function fromPointer(event: ReactPointerEvent<HTMLSpanElement>): void {
const r = event.currentTarget.getBoundingClientRect();
applyPad(Math.round((event.clientX - r.left) / r.width * 20) / 100);
}
function onPointerDown(event: ReactPointerEvent<HTMLSpanElement>): void {
event.currentTarget.setPointerCapture(event.pointerId);
fromPointer(event);
}
function onPointerMove(event: ReactPointerEvent<HTMLSpanElement>): void {
if (event.buttons) { fromPointer(event); }
}
// 화살표 1퍼센트, Shift 와 함께면 5퍼센트
function onKeyDown(event: KeyEvt): void {
const step = event.shiftKey ? 0.05 : 0.01;
if (event.key === "ArrowLeft" || event.key === "ArrowDown") { event.preventDefault(); applyPad(pad - step); }
else if (event.key === "ArrowRight" || event.key === "ArrowUp") { event.preventDefault(); applyPad(pad + step); }
}
const percent = Math.round(pad * 100);
const cls = [styles.root, demo ? styles.isDemo : ""].filter(Boolean).join(" ");
return (
<main className={styles.stage}>
<div className={cls} style={vars}>
<p className={styles.cap}><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" className={styles.ico} aria-hidden="true"><g fill="currentColor"><path d="M224 56v144a8 8 0 0 1-8 8H40a8 8 0 0 1-8-8V56a8 8 0 0 1 8-8h176a8 8 0 0 1 8 8" opacity=".2"/><path d="M200 80v32a8 8 0 0 1-16 0V88h-24a8 8 0 0 1 0-16h32a8 8 0 0 1 8 8M96 168H72v-24a8 8 0 0 0-16 0v32a8 8 0 0 0 8 8h32a8 8 0 0 0 0-16M232 56v144a16 16 0 0 1-16 16H40a16 16 0 0 1-16-16V56a16 16 0 0 1 16-16h176a16 16 0 0 1 16 16m-16 144V56H40v144z"/></g></svg><span>정사각 맞춤</span></p>
<div className={styles.frame}>
<canvas className={styles.out} ref={outRef} width={240} height={240} role="img" aria-label="정사각 결과"></canvas>
<span className={styles.ghost} aria-hidden="true"><span className={styles.ghostImg}></span></span>
</div>
<div className={styles.row}>
{SWATCHES.map((s) => (
<button
type="button"
key={s.bg}
className={`${styles.swatch} ${s.cls}`}
aria-label={s.label}
aria-pressed={bg === s.bg}
onClick={() => onPick(s.bg)}
></button>
))}
<span className={styles.pad}>
<span className={styles.lab} id="padLab">여백</span>
<span
className={styles.track}
role="slider"
tabIndex={0}
aria-labelledby="padLab"
aria-valuemin={0}
aria-valuemax={20}
aria-valuenow={percent}
aria-valuetext={VOICE.replace("%p", String(percent))}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onKeyDown={onKeyDown}
>
<span className={styles.thumb} style={{ left: `${pad / 0.2 * 100}%` }}></span>
</span>
</span>
</div>
</div>
</main>
);
}06 · 정사각 여백 채우기 — 측정 누적면적 9.137% · 움직인 프레임 22/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
어디를 바꾸면 되나
바탕색 넷을 한 프레임에 갈아 끼우고 여백만 계속 움직여 22/23 프레임·강도 99.5 를 기록했습니다.
var s = out.width;
// 먼저 정사각 전체를 고른 색으로 칠하고, 그 위에 사진을 가운데로 앉힌다
octx.fillStyle = bg;
octx.fillRect(0, 0, s, s);
var inner = s * (1 - pad * 2);
var k = Math.min(inner / src.width, inner / src.height);
var dw = src.width * k, dh = src.height * k;
octx.drawImage(src, (s - dw) / 2, (s - dh) / 2, dw, dh);
07텍스트 워터마크
무엇이 보이나
글자를 끌어 자리를 정하고 크기와 그림자를 조절해 캔버스에 굽습니다. 밝은 하늘 위에서는 흰 글자가 묻히므로 shadowBlur 와 세로 오프셋을 함께 걸어야 읽히고, 토글을 끄면 둘 다 0 으로 내려갑니다.

<main class="stage">
<div class="wm is-demo" id="wm">
<div class="wm__frame" id="frame">
<canvas class="wm__view" id="view" width="320" height="200" role="img" aria-label="워터마크를 얹은 사진"></canvas>
<span class="wm__ghost" aria-hidden="true"><span class="wm__mark">© GODRICH</span></span>
</div>
<div class="wm__foot">
<span class="wm__lab" id="sizeLab"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" class="wm__ico" aria-hidden="true"><g fill="currentColor"><path d="M232 164c0 15.46-14.33 28-32 28s-32-12.54-32-28s14.33-28 32-28s32 12.54 32 28M34.82 152h90.36L80 56Z" opacity=".2"/><path d="M87.24 52.59a8 8 0 0 0-14.48 0l-64 136a8 8 0 1 0 14.48 6.81L39.9 160h80.2l16.66 35.4a8 8 0 1 0 14.48-6.81ZM47.43 144L80 74.79L112.57 144ZM200 96c-12.76 0-22.73 3.47-29.63 10.32a8 8 0 0 0 11.26 11.36c3.8-3.77 10-5.68 18.37-5.68c13.23 0 24 9 24 20v3.22a42.76 42.76 0 0 0-24-7.22c-22.06 0-40 16.15-40 36s17.94 36 40 36a42.73 42.73 0 0 0 24-7.25a8 8 0 0 0 16-.75v-60c0-19.85-17.94-36-40-36m0 88c-13.23 0-24-9-24-20s10.77-20 24-20s24 9 24 20s-10.77 20-24 20"/></g></svg><span>글자 크기</span></span>
<span class="wm__track" id="track" role="slider" tabindex="0" aria-labelledby="sizeLab" aria-valuemin="14" aria-valuemax="42" aria-valuenow="24"><span class="wm__thumb" id="thumb"></span></span>
<button type="button" class="wm__btn" id="shadowBtn" aria-pressed="true">그림자</button>
<span class="wm__hint">끌어서 옮기기</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 텍스트 워터마크 — 실제 글자는 canvas 2D 의 fillText 가 굽는다(shadowBlur 로 밝은 하늘 위에서도 읽히게).
// 격자 미리보기(.is-demo)에서는 캔버스가 사진만 그리고 글자 몫을 유령 판이 맡는다 —
// 한 번 구운 캔버스 글자는 CSS 로 움직일 수 없어 끌어 옮기는 장면을 보여줄 수가 없기 때문이다.
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-cream;
@include stage($stage-ink);
.wm { display: flex; flex-direction: column; align-items: center; gap: $sp-2; width: 100%; }
.wm__frame {
position: relative; height: clamp(96px, 50vh, 170px); aspect-ratio: 16 / 10; max-width: 100%;
border-radius: $r-sm; overflow: hidden; box-shadow: $shadow-raised;
cursor: grab; touch-action: none;
}
.wm__view { display: block; width: 100%; height: 100%; }
// 유령 판은 무대 전체를 덮는 층이다 — 층을 퍼센트로 옮기면 그 퍼센트가 사진 크기 기준이 되어
// 폭이 달라져도 글자가 같은 자리에 떨어진다. 글자 자체는 제자리에서 커지기만 한다
.wm__ghost { display: none; position: absolute; inset: 0; }
.wm__mark {
position: absolute; left: 50%; top: 78%;
font-weight: 800; font-size: 24px; line-height: 1;
color: rgba(255, 247, 230, .94); text-shadow: 0 2px 9px rgba(23, 20, 26, .62);
white-space: nowrap; transform: translate(-50%, -50%);
}
.wm__foot { display: flex; align-items: center; gap: $sp-2; flex-wrap: wrap; justify-content: center; color: $color; }
.wm__lab {
display: inline-flex; align-items: center; gap: $sp-1;
font-weight: 800; font-size: clamp(8px, 2.2vw, 10px);
}
.wm__ico { width: 12px; height: 12px; flex: 0 0 auto; }
.wm__track {
position: relative; display: block; width: 76px; height: 6px; cursor: pointer;
border-radius: $r-pill; background: rgba(255, 247, 230, .32);
}
.wm__track:focus-visible { outline: 3px solid $subject-blue; outline-offset: 4px; }
.wm__thumb {
position: absolute; top: 50%; left: 36%; width: 13px; height: 13px;
border-radius: 50%; background: $stage-paper; box-shadow: $shadow-press;
transform: translate(-50%, -50%);
}
.wm__btn {
cursor: pointer; border: 1px solid rgba(255, 247, 230, .34); background: transparent; color: $color;
border-radius: $r-pill; font-family: inherit; font-weight: 700; line-height: 1.3;
font-size: clamp(8px, 2.2vw, 10px); padding: $sp-1 $sp-2;
}
// 눌린 알약의 글자는 크림이 아니라 흰색이다 — 파랑 위 크림은 4.25:1 로 AA 미달이다
.wm__btn[aria-pressed="true"] { background: $subject-blue; border-color: $subject-blue; color: $stage-paper; }
.wm__btn:focus-visible { outline: 3px solid $subject-blue; outline-offset: 2px; }
.wm__hint { font-weight: 700; font-size: clamp(8px, 2.1vw, 10px); color: rgba(255, 247, 230, .64); }
// ── 미리보기 루프: 워터마크를 오른쪽 아래 모서리로 끌어다 놓고 글자를 키웠다가 제자리로 돌아온다.
// 글자는 늘 화면 안에 남는다 — 사라지는 장면이 poster 로 뽑히면 대표 이미지가 빈 사진이 된다.
// 복귀는 88% 에 끝난다. 24장 표본의 마지막 장이 2초의 95.8% 지점이라 그 뒤는 안 찍힌다
.wm.is-demo .wm__ghost {
display: block;
animation-name: wmMove; animation-duration: $duration;
animation-timing-function: $easing; animation-iteration-count: infinite;
}
@keyframes wmMove {
0%, 6% { transform: translate(0, 0); }
36%, 60% { transform: translate(18%, -16%); }
88%, 100% { transform: translate(0, 0); }
}
.wm.is-demo .wm__mark {
animation-name: wmGrow; animation-duration: $duration;
animation-timing-function: $easing; animation-iteration-count: infinite;
}
@keyframes wmGrow {
0%, 6% { transform: translate(-50%, -50%) scale(1); }
36%, 60% { transform: translate(-50%, -50%) scale(1.42); }
88%, 100% { transform: translate(-50%, -50%) scale(1); }
}
.wm.is-demo .wm__thumb {
animation-name: wmThumb; animation-duration: $duration;
animation-timing-function: $easing; animation-iteration-count: infinite;
}
@keyframes wmThumb {
0%, 6% { transform: translate(-50%, -50%); }
36%, 60% { transform: translate(-50%, -50%) translateX(24px); }
88%, 100% { transform: translate(-50%, -50%); }
}
@media (max-width: 340px) {
.wm__foot { gap: $sp-1; }
.wm__hint { display: none; }
}
@media (prefers-reduced-motion: reduce) {
.wm.is-demo .wm__ghost,
.wm.is-demo .wm__mark,
.wm.is-demo .wm__thumb { animation: none !important; }
// 움직임 없이 오른쪽 위로 옮겨 키운 상태로 세운다
.wm.is-demo .wm__ghost { transform: translate(18%, -16%) !important; }
.wm.is-demo .wm__mark { transform: translate(-50%, -50%) scale(1.42) !important; }
.wm.is-demo .wm__thumb { transform: translate(-50%, -50%) translateX(24px) !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.wm {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
width: 100%;
}
.wm__frame {
position: relative;
height: clamp(96px, 50vh, 170px);
aspect-ratio: 16/10;
max-width: 100%;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
cursor: grab;
touch-action: none;
}
.wm__view {
display: block;
width: 100%;
height: 100%;
}
.wm__ghost {
display: none;
position: absolute;
inset: 0;
}
.wm__mark {
position: absolute;
left: 50%;
top: 78%;
font-weight: 800;
font-size: 24px;
line-height: 1;
color: rgba(255, 247, 230, 0.94);
text-shadow: 0 2px 9px rgba(23, 20, 26, 0.62);
white-space: nowrap;
transform: translate(-50%, -50%);
}
.wm__foot {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
justify-content: center;
color: #fff7e6;
}
.wm__lab {
display: inline-flex;
align-items: center;
gap: 4px;
font-weight: 800;
font-size: clamp(8px, 2.2vw, 10px);
}
.wm__ico {
width: 12px;
height: 12px;
flex: 0 0 auto;
}
.wm__track {
position: relative;
display: block;
width: 76px;
height: 6px;
cursor: pointer;
border-radius: 999px;
background: rgba(255, 247, 230, 0.32);
}
.wm__track:focus-visible {
outline: 3px solid #2f6df6;
outline-offset: 4px;
}
.wm__thumb {
position: absolute;
top: 50%;
left: 36%;
width: 13px;
height: 13px;
border-radius: 50%;
background: #ffffff;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
transform: translate(-50%, -50%);
}
.wm__btn {
cursor: pointer;
border: 1px solid rgba(255, 247, 230, 0.34);
background: transparent;
color: #fff7e6;
border-radius: 999px;
font-family: inherit;
font-weight: 700;
line-height: 1.3;
font-size: clamp(8px, 2.2vw, 10px);
padding: 4px 8px;
}
.wm__btn[aria-pressed=true] {
background: #2f6df6;
border-color: #2f6df6;
color: #ffffff;
}
.wm__btn:focus-visible {
outline: 3px solid #2f6df6;
outline-offset: 2px;
}
.wm__hint {
font-weight: 700;
font-size: clamp(8px, 2.1vw, 10px);
color: rgba(255, 247, 230, 0.64);
}
.wm.is-demo .wm__ghost {
display: block;
animation-name: wmMove;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
@keyframes wmMove {
0%, 6% {
transform: translate(0, 0);
}
36%, 60% {
transform: translate(18%, -16%);
}
88%, 100% {
transform: translate(0, 0);
}
}
.wm.is-demo .wm__mark {
animation-name: wmGrow;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
@keyframes wmGrow {
0%, 6% {
transform: translate(-50%, -50%) scale(1);
}
36%, 60% {
transform: translate(-50%, -50%) scale(1.42);
}
88%, 100% {
transform: translate(-50%, -50%) scale(1);
}
}
.wm.is-demo .wm__thumb {
animation-name: wmThumb;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
@keyframes wmThumb {
0%, 6% {
transform: translate(-50%, -50%);
}
36%, 60% {
transform: translate(-50%, -50%) translateX(24px);
}
88%, 100% {
transform: translate(-50%, -50%);
}
}
@media (max-width: 340px) {
.wm__foot {
gap: 4px;
}
.wm__hint {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
.wm.is-demo .wm__ghost,
.wm.is-demo .wm__mark,
.wm.is-demo .wm__thumb {
animation: none !important;
}
.wm.is-demo .wm__ghost {
transform: translate(18%, -16%) !important;
}
.wm.is-demo .wm__mark {
transform: translate(-50%, -50%) scale(1.42) !important;
}
.wm.is-demo .wm__thumb {
transform: translate(-50%, -50%) translateX(24px) !important;
}
}var wm = document.getElementById('wm');
var frame = document.getElementById('frame');
var view = document.getElementById('view');
var track = document.getElementById('track');
var thumb = document.getElementById('thumb');
var shadowBtn = document.getElementById('shadowBtn');
var vctx = view.getContext('2d');
var src = document.createElement('canvas');
var mark = '© GODRICH';
var spot = { x: 0.5, y: 0.78 };
var size = 24, shadowOn = true;
var VOICE = '%s 픽셀';
// 가짜 사진 — 외부 파일 없이 그라디언트와 도형으로 합성한다. 필터·히스토그램이 보이도록 대비를 세게 준다
function paintPhoto(c, w, h) {
var sky = c.createLinearGradient(0, 0, 0, h);
sky.addColorStop(0, '#2f6df6'); sky.addColorStop(0.55, '#ff9ebb'); sky.addColorStop(1, '#ffd23f');
c.fillStyle = sky; c.fillRect(0, 0, w, h);
c.fillStyle = '#fff7e6';
c.beginPath(); c.arc(w * 0.74, h * 0.28, h * 0.13, 0, Math.PI * 2); c.fill();
// 곧은 수평선 하나 — 기울기 보정이 눈에 보이려면 비교할 직선이 있어야 한다
c.fillStyle = 'rgba(23, 20, 26, 0.5)'; c.fillRect(0, Math.round(h * 0.66), w, 2);
c.fillStyle = '#17141a';
c.beginPath(); c.moveTo(0, h); c.lineTo(w * 0.34, h * 0.36); c.lineTo(w * 0.62, h); c.closePath(); c.fill();
c.fillStyle = '#c2361a';
c.beginPath(); c.moveTo(w * 0.46, h); c.lineTo(w * 0.78, h * 0.52); c.lineTo(w, h); c.closePath(); c.fill();
for (var i = 0; i < 6; i++) { c.fillStyle = i % 2 ? '#fff7e6' : '#17141a'; c.fillRect(w * (0.08 + i * 0.07), h * (0.14 + (i % 3) * 0.06), 7, 7); }
}
function render() {
var w = view.width, h = view.height;
vctx.setTransform(1, 0, 0, 1, 0, 0);
vctx.clearRect(0, 0, w, h);
vctx.drawImage(src, 0, 0, w, h);
thumb.style.left = ((size - 14) / 28 * 100) + '%';
track.setAttribute('aria-valuenow', size);
track.setAttribute('aria-valuetext', VOICE.replace('%s', size));
// 격자 미리보기 동안에는 글자를 유령 판이 맡는다 — 캔버스에 구운 글자는 CSS 로 못 움직인다
if (wm.classList.contains('is-demo')) { return; }
vctx.textAlign = 'center';
vctx.textBaseline = 'middle';
vctx.fillStyle = 'rgba(255, 247, 230, 0.94)';
vctx.shadowColor = 'rgba(23, 20, 26, 0.62)';
// 그림자는 글자 아래 번지는 정도만 — 0 이면 밝은 하늘 위에서 글자가 묻힌다
vctx.shadowBlur = shadowOn ? 9 : 0;
vctx.shadowOffsetY = shadowOn ? 2 : 0;
vctx.fillText(mark, spot.x * w, spot.y * h);
}
function setFont(px) {
size = Math.max(14, Math.min(42, Math.round(px)));
vctx.font = '800 ' + size + 'px system-ui, sans-serif';
wm.classList.remove('is-demo');
render();
}
frame.addEventListener('pointerdown', function (e) {
frame.setPointerCapture(e.pointerId);
wm.classList.remove('is-demo');
moveMark(e);
});
frame.addEventListener('pointermove', function (e) { if (e.buttons) { moveMark(e); } });
function moveMark(e) {
var r = frame.getBoundingClientRect();
spot.x = Math.max(0.12, Math.min(0.88, (e.clientX - r.left) / r.width));
spot.y = Math.max(0.12, Math.min(0.9, (e.clientY - r.top) / r.height));
render();
}
function fromPointer(e) {
var r = track.getBoundingClientRect();
setFont((e.clientX - r.left) / r.width * 28 + 14);
}
track.addEventListener('pointerdown', function (e) { track.setPointerCapture(e.pointerId); fromPointer(e); });
track.addEventListener('pointermove', function (e) { if (e.buttons) { fromPointer(e); } });
// 화살표 1픽셀, Shift 와 함께면 6픽셀
track.addEventListener('keydown', function (e) {
var step = e.shiftKey ? 6 : 1;
if (e.key === 'ArrowLeft' || e.key === 'ArrowDown') { e.preventDefault(); setFont(size - step); }
else if (e.key === 'ArrowRight' || e.key === 'ArrowUp') { e.preventDefault(); setFont(size + step); }
});
shadowBtn.addEventListener('click', function () {
shadowOn = !shadowOn;
shadowBtn.setAttribute('aria-pressed', shadowOn ? 'true' : 'false');
wm.classList.remove('is-demo'); render();
});
// 미리보기 클래스가 어떤 경로로 떨어지든 캔버스를 다시 그린다 —
// 손을 대지 않고 클래스만 벗겨진 상태에서 글자가 없는 사진만 남는 일이 없게
new MutationObserver(render).observe(wm, { attributes: true, attributeFilter: ['class'] });
src.width = 320; src.height = 200;
paintPhoto(src.getContext('2d'), src.width, src.height);
vctx.font = '800 ' + size + 'px system-ui, sans-serif';
render();// TextWatermark — vanilla 판(run/295/demo/07-image-edit-text-watermark/index.html) 의 React 포트.
// 실제 워터마크는 canvas 2D 의 fillText 가 굽는다(shadowBlur 로 밝은 하늘 위에서도 읽히게).
// 격자 미리보기(.isDemo) 동안에는 캔버스가 사진만 그리고 글자 몫을 유령 판이 맡는다 —
// 한 번 구운 캔버스 글자는 CSS 로 움직일 수 없어 끌어 옮기는 장면을 보여줄 수가 없기 때문이다.
// 값·수치는 vanilla 와 같다.
import { useCallback, useEffect, useRef, useState } from "react";
import type { CSSProperties, PointerEvent as ReactPointerEvent } from "react";
import styles from "./TextWatermark.module.scss";
export type TextWatermarkProps = {
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Spot = { x: number; y: number };
// ambient 타입에 KeyboardEvent 가 없다 — 화살표 조작에 쓰는 것만 지역 타입으로 선언한다
type KeyEvt = { key: string; shiftKey: boolean; preventDefault(): void };
const MARK = "© GODRICH";
const VOICE = "%s 픽셀";
// 가짜 사진 — 외부 파일 없이 그라디언트와 도형으로 합성한다. 필터·히스토그램이 보이도록 대비를 세게 준다
function paintPhoto(c: CanvasRenderingContext2D, w: number, h: number): void {
const sky = c.createLinearGradient(0, 0, 0, h);
sky.addColorStop(0, "#2f6df6");
sky.addColorStop(0.55, "#ff9ebb");
sky.addColorStop(1, "#ffd23f");
c.fillStyle = sky;
c.fillRect(0, 0, w, h);
c.fillStyle = "#fff7e6";
c.beginPath();
c.arc(w * 0.74, h * 0.28, h * 0.13, 0, Math.PI * 2);
c.fill();
// 곧은 수평선 하나 — 기울기 보정이 눈에 보이려면 비교할 직선이 있어야 한다
c.fillStyle = "rgba(23, 20, 26, 0.5)";
c.fillRect(0, Math.round(h * 0.66), w, 2);
c.fillStyle = "#17141a";
c.beginPath();
c.moveTo(0, h);
c.lineTo(w * 0.34, h * 0.36);
c.lineTo(w * 0.62, h);
c.closePath();
c.fill();
c.fillStyle = "#c2361a";
c.beginPath();
c.moveTo(w * 0.46, h);
c.lineTo(w * 0.78, h * 0.52);
c.lineTo(w, h);
c.closePath();
c.fill();
for (let i = 0; i < 6; i++) {
c.fillStyle = i % 2 ? "#fff7e6" : "#17141a";
c.fillRect(w * (0.08 + i * 0.07), h * (0.14 + (i % 3) * 0.06), 7, 7);
}
}
export default function TextWatermark({
duration = 2000,
easing = "cubic-bezier(.2, .8, .2, 1)",
color = "#fff7e6",
}: TextWatermarkProps) {
const frameRef = useRef<HTMLDivElement | null>(null);
const viewRef = useRef<HTMLCanvasElement | null>(null);
const trackRef = useRef<HTMLSpanElement | null>(null);
const srcRef = useRef<HTMLCanvasElement | null>(null);
const [demo, setDemo] = useState(true);
const [size, setSize] = useState(24);
const [shadowOn, setShadowOn] = useState(true);
const [spot, setSpot] = useState<Spot>({ x: 0.5, y: 0.78 });
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
// 사진은 한 번만 굽는다 — 보이는 캔버스는 이 판을 그대로 옮겨 그린 위에 글자를 얹는다
const ensureSrc = useCallback((): HTMLCanvasElement | null => {
if (!srcRef.current) {
const src = document.createElement("canvas");
src.width = 320;
src.height = 200;
const sctx = src.getContext("2d");
if (!sctx) return null;
paintPhoto(sctx, src.width, src.height);
srcRef.current = src;
}
return srcRef.current;
}, []);
const render = useCallback(() => {
const view = viewRef.current;
const src = ensureSrc();
if (!view || !src) return;
const vctx = view.getContext("2d");
if (!vctx) return;
const w = view.width;
const h = view.height;
vctx.setTransform(1, 0, 0, 1, 0, 0);
vctx.clearRect(0, 0, w, h);
vctx.drawImage(src, 0, 0, w, h);
// 격자 미리보기 동안에는 글자를 유령 판이 맡는다 — 캔버스에 구운 글자는 CSS 로 못 움직인다
if (demo) return;
vctx.font = "800 " + size + "px system-ui, sans-serif";
vctx.textAlign = "center";
vctx.textBaseline = "middle";
vctx.fillStyle = "rgba(255, 247, 230, 0.94)";
vctx.shadowColor = "rgba(23, 20, 26, 0.62)";
// 그림자는 글자 아래 번지는 정도만 — 0 이면 밝은 하늘 위에서 글자가 묻힌다
vctx.shadowBlur = shadowOn ? 9 : 0;
vctx.shadowOffsetY = shadowOn ? 2 : 0;
vctx.fillText(MARK, spot.x * w, spot.y * h);
}, [demo, ensureSrc, shadowOn, size, spot]);
useEffect(() => {
render();
}, [render]);
const setFont = useCallback((px: number) => {
setSize(Math.max(14, Math.min(42, Math.round(px))));
setDemo(false);
}, []);
function moveMark(event: ReactPointerEvent<HTMLDivElement>): void {
const frame = frameRef.current;
if (!frame) return;
const r = frame.getBoundingClientRect();
setSpot({
x: Math.max(0.12, Math.min(0.88, (event.clientX - r.left) / r.width)),
y: Math.max(0.12, Math.min(0.9, (event.clientY - r.top) / r.height)),
});
}
function onFrameDown(event: ReactPointerEvent<HTMLDivElement>): void {
const frame = frameRef.current;
if (!frame) return;
frame.setPointerCapture(event.pointerId);
setDemo(false);
moveMark(event);
}
function onFrameMove(event: ReactPointerEvent<HTMLDivElement>): void {
if (event.buttons) moveMark(event);
}
function fromPointer(event: ReactPointerEvent<HTMLSpanElement>): void {
const track = trackRef.current;
if (!track) return;
const r = track.getBoundingClientRect();
setFont(((event.clientX - r.left) / r.width) * 28 + 14);
}
function onTrackDown(event: ReactPointerEvent<HTMLSpanElement>): void {
const track = trackRef.current;
if (!track) return;
track.setPointerCapture(event.pointerId);
fromPointer(event);
}
function onTrackMove(event: ReactPointerEvent<HTMLSpanElement>): void {
if (event.buttons) fromPointer(event);
}
// 화살표 1픽셀, Shift 와 함께면 6픽셀
function onTrackKey(event: KeyEvt): void {
const step = event.shiftKey ? 6 : 1;
if (event.key === "ArrowLeft" || event.key === "ArrowDown") {
event.preventDefault();
setFont(size - step);
} else if (event.key === "ArrowRight" || event.key === "ArrowUp") {
event.preventDefault();
setFont(size + step);
}
}
function onShadow(): void {
setShadowOn(!shadowOn);
setDemo(false);
}
const cls = [styles.root, demo ? styles.isDemo : ""].filter(Boolean).join(" ");
return (
<main className={styles.stage}>
<div className={cls} style={vars}>
<div className={styles.frame} ref={frameRef} onPointerDown={onFrameDown} onPointerMove={onFrameMove}>
<canvas className={styles.view} ref={viewRef} width={320} height={200} role="img" aria-label="워터마크를 얹은 사진"></canvas>
<span className={styles.ghost} aria-hidden="true"><span className={styles.mark}>{MARK}</span></span>
</div>
<div className={styles.foot}>
<span className={styles.lab} id="sizeLab">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" className={styles.ico} aria-hidden="true"><g fill="currentColor"><path d="M232 164c0 15.46-14.33 28-32 28s-32-12.54-32-28s14.33-28 32-28s32 12.54 32 28M34.82 152h90.36L80 56Z" opacity=".2"/><path d="M87.24 52.59a8 8 0 0 0-14.48 0l-64 136a8 8 0 1 0 14.48 6.81L39.9 160h80.2l16.66 35.4a8 8 0 1 0 14.48-6.81ZM47.43 144L80 74.79L112.57 144ZM200 96c-12.76 0-22.73 3.47-29.63 10.32a8 8 0 0 0 11.26 11.36c3.8-3.77 10-5.68 18.37-5.68c13.23 0 24 9 24 20v3.22a42.76 42.76 0 0 0-24-7.22c-22.06 0-40 16.15-40 36s17.94 36 40 36a42.73 42.73 0 0 0 24-7.25a8 8 0 0 0 16-.75v-60c0-19.85-17.94-36-40-36m0 88c-13.23 0-24-9-24-20s10.77-20 24-20s24 9 24 20s-10.77 20-24 20"/></g></svg>
<span>글자 크기</span>
</span>
<span
className={styles.track}
ref={trackRef}
role="slider"
tabIndex={0}
aria-labelledby="sizeLab"
aria-valuemin={14}
aria-valuemax={42}
aria-valuenow={size}
aria-valuetext={VOICE.replace("%s", String(size))}
onPointerDown={onTrackDown}
onPointerMove={onTrackMove}
onKeyDown={onTrackKey}
>
<span className={styles.thumb} style={{ left: `${((size - 14) / 28) * 100}%` }}></span>
</span>
<button type="button" className={styles.btn} aria-pressed={shadowOn} onClick={onShadow}>그림자</button>
<span className={styles.hint}>끌어서 옮기기</span>
</div>
</div>
</main>
);
}07 · 텍스트 워터마크 — 측정 누적면적 6.188% · 움직인 프레임 14/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드에서 볼 곳
미리보기 동안에는 캔버스가 사진만 그리고 글자는 DOM 유령 판이 대신 움직이는데, 한 번 구운 캔버스 글자는 CSS 로 옮길 수 없기 때문입니다(누적면적 6.148%·강도 77.7).
vctx.textAlign = 'center';
vctx.textBaseline = 'middle';
vctx.fillStyle = 'rgba(255, 247, 230, 0.94)';
vctx.shadowColor = 'rgba(23, 20, 26, 0.62)';
// 그림자는 글자 아래 번지는 정도만 — 0 이면 밝은 하늘 위에서 글자가 묻힌다
vctx.shadowBlur = shadowOn ? 9 : 0;
vctx.shadowOffsetY = shadowOn ? 2 : 0;
vctx.fillText(mark, spot.x * w, spot.y * h);
08RGB 히스토그램
화면에서 일어나는 일
보정을 입힌 사진을 작은 판에 다시 그린 다음 getImageData 로 픽셀을 통째로 읽어 채널마다 열여섯 칸으로 셉니다. 바이트를 네 비트 오른쪽으로 밀면 그대로 칸 번호가 되고, 256 단계를 다 세면 막대가 잘아 모양이 안 읽힙니다.

<main class="stage">
<div class="hg is-demo" id="hg">
<div class="hg__top">
<div class="hg__frame">
<canvas class="hg__view" id="view" width="320" height="200" role="img" aria-label="샘플 사진"></canvas>
</div>
<div class="hg__chart">
<p class="hg__cap"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" class="hg__ico" aria-hidden="true"><g fill="currentColor"><path d="M208 40v168h-56V40Z" opacity=".2"/><path d="M224 200h-8V40a8 8 0 0 0-8-8h-56a8 8 0 0 0-8 8v40H96a8 8 0 0 0-8 8v40H48a8 8 0 0 0-8 8v64h-8a8 8 0 0 0 0 16h192a8 8 0 0 0 0-16M160 48h40v152h-40Zm-56 48h40v104h-40Zm-48 48h32v56H56Z"/></g></svg><span>히스토그램</span></p>
<div class="hg__plot">
<canvas class="hg__hist" id="hist" width="240" height="120" role="img" aria-label="채널별 분포 그래프"></canvas>
<span class="hg__bars" aria-hidden="true">
<span class="hg__gb" style="--i:0; --h:.18"></span><span class="hg__gb" style="--i:1; --h:.3"></span><span class="hg__gb" style="--i:2; --h:.46"></span><span class="hg__gb" style="--i:3; --h:.62"></span>
<span class="hg__gb" style="--i:4; --h:.8"></span><span class="hg__gb" style="--i:5; --h:.95"></span><span class="hg__gb" style="--i:6; --h:.72"></span><span class="hg__gb" style="--i:7; --h:.5"></span>
<span class="hg__gb" style="--i:8; --h:.38"></span><span class="hg__gb" style="--i:9; --h:.44"></span><span class="hg__gb" style="--i:10; --h:.58"></span><span class="hg__gb" style="--i:11; --h:.7"></span>
<span class="hg__gb" style="--i:12; --h:.85"></span><span class="hg__gb" style="--i:13; --h:.62"></span><span class="hg__gb" style="--i:14; --h:.4"></span><span class="hg__gb" style="--i:15; --h:.24"></span>
</span>
</div>
</div>
</div>
<div class="hg__ctl">
<span class="hg__lab" id="lab">밝기</span>
<span class="hg__track" id="track" role="slider" tabindex="0" aria-labelledby="lab" aria-valuemin="0.6" aria-valuemax="1.6" aria-valuenow="1"><span class="hg__thumb" id="thumb"></span></span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// RGB 히스토그램 — 실제 막대는 canvas 2D 가 그린다(보정을 먹인 사진을 다시 그린 뒤 getImageData 로 픽셀을 센다).
// 격자 미리보기(.is-demo)는 그 캔버스를 감추고 DOM 막대 열여섯 개를 scaleY 로 물결처럼 오르내리게 한다.
// 사진 쪽은 유령 판이 필요 없다 — 캔버스 자체에 걸린 filter 를 CSS 애니메이션이 그대로 덮어쓴다.
$duration: $dur-loop;
$easing: $ease-out;
$color: $subject-ink;
@include stage($stage-yellow);
.hg { display: flex; flex-direction: column; align-items: center; gap: $sp-2; width: 100%; max-width: 420px; }
.hg__top { display: flex; align-items: stretch; gap: $sp-2; width: 100%; justify-content: center; }
.hg__frame {
position: relative; flex: 0 0 auto; height: clamp(76px, 36vh, 122px); aspect-ratio: 16 / 10;
border-radius: $r-sm; overflow: hidden; box-shadow: $shadow-raised;
}
.hg__view { display: block; width: 100%; height: 100%; }
.hg__chart { flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; gap: $sp-1; }
.hg__cap {
display: flex; align-items: center; gap: $sp-1; margin: 0;
font-weight: 800; font-size: clamp(8px, 2.2vw, 10px); color: $color;
}
.hg__ico { width: 12px; height: 12px; flex: 0 0 auto; }
.hg__plot {
position: relative; flex: 1 1 auto; min-height: 52px;
border-radius: $r-xs; background: rgba(255, 255, 255, .5);
}
.hg__hist { display: block; width: 100%; height: 100%; }
.hg__bars {
display: none; position: absolute; inset: 0;
grid-template-columns: repeat(16, 1fr); align-items: end;
}
.hg__gb {
width: 72%; height: 100%; justify-self: center;
// 실제 캔버스는 칸마다 R·G·B 막대 셋을 나란히 그린다 — 유령 막대도 같은 색 차례로 세 쪽을 낸다
background: linear-gradient(90deg, $chart-1 0 33.33%, $chart-3 33.33% 66.66%, $chart-2 66.66% 100%);
border-radius: 0;
transform-origin: bottom; transform: scaleY(var(--h));
}
.hg__ctl { display: flex; align-items: center; gap: $sp-2; }
.hg__lab { font-weight: 800; font-size: clamp(8px, 2.2vw, 10px); color: $color; }
.hg__track {
position: relative; display: block; width: 100px; height: 6px; cursor: pointer;
border-radius: $r-pill; background: rgba(23, 20, 26, .2);
}
.hg__track:focus-visible { outline: 3px solid $subject-blue; outline-offset: 4px; }
.hg__thumb {
position: absolute; top: 50%; left: 40%; width: 13px; height: 13px;
border-radius: 50%; background: $subject-ink; box-shadow: $shadow-press;
transform: translate(-50%, -50%);
}
// ── 미리보기 루프: 밝기를 1.42배까지 올렸다가 되돌린다. 막대는 음의 지연으로 한 칸씩 어긋나
// 물결처럼 오르내린다 — 지연을 음수로 주면 주기가 깨지지 않아 루프가 이어붙는 지점이 튀지 않는다.
// 복귀는 88% 에 끝난다. 24장 표본의 마지막 장이 2초의 95.8% 지점이라 그 뒤는 안 찍힌다
.hg.is-demo .hg__hist { visibility: hidden; }
.hg.is-demo .hg__bars { display: grid; }
.hg.is-demo .hg__gb {
animation-name: hgPulse; animation-duration: $duration;
animation-timing-function: ease-in-out; animation-iteration-count: infinite;
animation-delay: calc(var(--i) * -125ms);
}
@keyframes hgPulse {
0%, 100% { transform: scaleY(var(--h)); }
50% { transform: scaleY(calc(var(--h) * 0.4)); }
}
.hg.is-demo .hg__view {
animation-name: hgLift; animation-duration: $duration;
animation-timing-function: $easing; animation-iteration-count: infinite;
}
@keyframes hgLift {
0%, 6% { filter: brightness(1); }
40%, 74% { filter: brightness(1.42); }
88%, 100% { filter: brightness(1); }
}
.hg.is-demo .hg__thumb {
animation-name: hgThumb; animation-duration: $duration;
animation-timing-function: $easing; animation-iteration-count: infinite;
}
@keyframes hgThumb {
0%, 6% { transform: translate(-50%, -50%); }
40%, 74% { transform: translate(-50%, -50%) translateX(42px); }
88%, 100% { transform: translate(-50%, -50%); }
}
@media (max-width: 340px) {
.hg__top { gap: $sp-1; }
.hg__track { width: 84px; }
}
@media (prefers-reduced-motion: reduce) {
.hg.is-demo .hg__gb,
.hg.is-demo .hg__view,
.hg.is-demo .hg__thumb { animation: none !important; }
// 움직임 없이 밝기를 올린 상태로 세운다
.hg.is-demo .hg__view { filter: brightness(1.42) !important; }
.hg.is-demo .hg__thumb { transform: translate(-50%, -50%) translateX(42px) !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.hg {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
width: 100%;
max-width: 420px;
}
.hg__top {
display: flex;
align-items: stretch;
gap: 8px;
width: 100%;
justify-content: center;
}
.hg__frame {
position: relative;
flex: 0 0 auto;
height: clamp(76px, 36vh, 122px);
aspect-ratio: 16/10;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.hg__view {
display: block;
width: 100%;
height: 100%;
}
.hg__chart {
flex: 1 1 auto;
min-width: 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.hg__cap {
display: flex;
align-items: center;
gap: 4px;
margin: 0;
font-weight: 800;
font-size: clamp(8px, 2.2vw, 10px);
color: #17141a;
}
.hg__ico {
width: 12px;
height: 12px;
flex: 0 0 auto;
}
.hg__plot {
position: relative;
flex: 1 1 auto;
min-height: 52px;
border-radius: 4px;
background: rgba(255, 255, 255, 0.5);
}
.hg__hist {
display: block;
width: 100%;
height: 100%;
}
.hg__bars {
display: none;
position: absolute;
inset: 0;
grid-template-columns: repeat(16, 1fr);
align-items: end;
}
.hg__gb {
width: 72%;
height: 100%;
justify-self: center;
background: linear-gradient(90deg, #2f6df6 0 33.33%, #2b9d74 33.33% 66.66%, #c2361a 66.66% 100%);
border-radius: 0;
transform-origin: bottom;
transform: scaleY(var(--h));
}
.hg__ctl {
display: flex;
align-items: center;
gap: 8px;
}
.hg__lab {
font-weight: 800;
font-size: clamp(8px, 2.2vw, 10px);
color: #17141a;
}
.hg__track {
position: relative;
display: block;
width: 100px;
height: 6px;
cursor: pointer;
border-radius: 999px;
background: rgba(23, 20, 26, 0.2);
}
.hg__track:focus-visible {
outline: 3px solid #2f6df6;
outline-offset: 4px;
}
.hg__thumb {
position: absolute;
top: 50%;
left: 40%;
width: 13px;
height: 13px;
border-radius: 50%;
background: #17141a;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
transform: translate(-50%, -50%);
}
.hg.is-demo .hg__hist {
visibility: hidden;
}
.hg.is-demo .hg__bars {
display: grid;
}
.hg.is-demo .hg__gb {
animation-name: hgPulse;
animation-duration: 2s;
animation-timing-function: ease-in-out;
animation-iteration-count: infinite;
animation-delay: calc(var(--i) * -125ms);
}
@keyframes hgPulse {
0%, 100% {
transform: scaleY(var(--h));
}
50% {
transform: scaleY(calc(var(--h) * 0.4));
}
}
.hg.is-demo .hg__view {
animation-name: hgLift;
animation-duration: 2s;
animation-timing-function: ease-out;
animation-iteration-count: infinite;
}
@keyframes hgLift {
0%, 6% {
filter: brightness(1);
}
40%, 74% {
filter: brightness(1.42);
}
88%, 100% {
filter: brightness(1);
}
}
.hg.is-demo .hg__thumb {
animation-name: hgThumb;
animation-duration: 2s;
animation-timing-function: ease-out;
animation-iteration-count: infinite;
}
@keyframes hgThumb {
0%, 6% {
transform: translate(-50%, -50%);
}
40%, 74% {
transform: translate(-50%, -50%) translateX(42px);
}
88%, 100% {
transform: translate(-50%, -50%);
}
}
@media (max-width: 340px) {
.hg__top {
gap: 4px;
}
.hg__track {
width: 84px;
}
}
@media (prefers-reduced-motion: reduce) {
.hg.is-demo .hg__gb,
.hg.is-demo .hg__view,
.hg.is-demo .hg__thumb {
animation: none !important;
}
.hg.is-demo .hg__view {
filter: brightness(1.42) !important;
}
.hg.is-demo .hg__thumb {
transform: translate(-50%, -50%) translateX(42px) !important;
}
}var hg = document.getElementById('hg');
var view = document.getElementById('view');
var hist = document.getElementById('hist');
var track = document.getElementById('track');
var thumb = document.getElementById('thumb');
var vctx = view.getContext('2d');
var hctx = hist.getContext('2d');
var work = document.createElement('canvas');
var wctx = work.getContext('2d', { willReadFrequently: true });
var BINS = 16;
var CHANNEL = ['rgba(47, 109, 246, .85)', 'rgba(43, 157, 116, .85)', 'rgba(194, 54, 26, .85)'];
var level = 1, queued = 0;
var VOICE = '%v배';
// 가짜 사진 — 외부 파일 없이 그라디언트와 도형으로 합성한다. 필터·히스토그램이 보이도록 대비를 세게 준다
function paintPhoto(c, w, h) {
var sky = c.createLinearGradient(0, 0, 0, h);
sky.addColorStop(0, '#2f6df6'); sky.addColorStop(0.55, '#ff9ebb'); sky.addColorStop(1, '#ffd23f');
c.fillStyle = sky; c.fillRect(0, 0, w, h);
c.fillStyle = '#fff7e6';
c.beginPath(); c.arc(w * 0.74, h * 0.28, h * 0.13, 0, Math.PI * 2); c.fill();
// 곧은 수평선 하나 — 기울기 보정이 눈에 보이려면 비교할 직선이 있어야 한다
c.fillStyle = 'rgba(23, 20, 26, 0.5)'; c.fillRect(0, Math.round(h * 0.66), w, 2);
c.fillStyle = '#17141a';
c.beginPath(); c.moveTo(0, h); c.lineTo(w * 0.34, h * 0.36); c.lineTo(w * 0.62, h); c.closePath(); c.fill();
c.fillStyle = '#c2361a';
c.beginPath(); c.moveTo(w * 0.46, h); c.lineTo(w * 0.78, h * 0.52); c.lineTo(w, h); c.closePath(); c.fill();
for (var i = 0; i < 6; i++) { c.fillStyle = i % 2 ? '#fff7e6' : '#17141a'; c.fillRect(w * (0.08 + i * 0.07), h * (0.14 + (i % 3) * 0.06), 7, 7); }
}
// 보정을 먹인 사진을 작은 판에 다시 그린 다음 픽셀을 통째로 읽어 칸마다 센다.
// 한 칸은 밝기 16단계 — 256 단계를 그대로 세면 막대가 너무 잘아서 모양이 안 읽힌다.
// ctx.filter 는 Safari 에 아직 없다(caniuse 81.08%). 없으면 읽은 값에 직접 곱해 같은 결과를 만든다
var CAN_FILTER = typeof wctx.filter === 'string';
function countBins() {
if (CAN_FILTER) { wctx.filter = 'brightness(' + level.toFixed(2) + ')'; }
wctx.clearRect(0, 0, work.width, work.height);
wctx.drawImage(view, 0, 0, work.width, work.height);
var data = wctx.getImageData(0, 0, work.width, work.height).data;
var bins = [], i;
for (i = 0; i < BINS * 3; i++) { bins.push(0); }
for (var p = 0; p < data.length; p += 4) {
for (var ch = 0; ch < 3; ch++) {
var v = CAN_FILTER ? data[p + ch] : Math.min(255, Math.round(data[p + ch] * level));
bins[Math.min(BINS - 1, v >> 4) * 3 + ch] += 1;
}
}
return bins;
}
function drawHist(bins) {
var w = hist.width, h = hist.height, top = 1, i;
for (i = 0; i < bins.length; i++) { if (bins[i] > top) { top = bins[i]; } }
hctx.clearRect(0, 0, w, h);
var slot = w / BINS, bar = slot / 3;
for (i = 0; i < BINS; i++) {
for (var ch = 0; ch < 3; ch++) {
var tall = bins[i * 3 + ch] / top * h;
hctx.fillStyle = CHANNEL[ch];
hctx.fillRect(i * slot + ch * bar, h - tall, bar - 1, tall);
}
}
}
function refresh() {
view.style.filter = 'brightness(' + level.toFixed(2) + ')';
thumb.style.left = ((level - 0.6) * 100) + '%';
track.setAttribute('aria-valuenow', level.toFixed(2));
track.setAttribute('aria-valuetext', VOICE.replace('%v', level.toFixed(2)));
// 픽셀 세기는 무거우니 프레임마다 한 번으로 묶는다 — 슬라이더를 빠르게 끌어도 밀리지 않는다
if (queued) { return; }
queued = requestAnimationFrame(function () { queued = 0; drawHist(countBins()); });
}
function setLevel(next) {
level = Math.max(0.6, Math.min(1.6, Math.round(next * 100) / 100));
hg.classList.remove('is-demo');
refresh();
}
function fromPointer(e) {
var r = track.getBoundingClientRect();
setLevel((e.clientX - r.left) / r.width + 0.6);
}
track.addEventListener('pointerdown', function (e) { track.setPointerCapture(e.pointerId); fromPointer(e); });
track.addEventListener('pointermove', function (e) { if (e.buttons) { fromPointer(e); } });
// 화살표 0.02, Shift 와 함께면 0.1
track.addEventListener('keydown', function (e) {
var step = e.shiftKey ? 0.1 : 0.02;
if (e.key === 'ArrowLeft' || e.key === 'ArrowDown') { e.preventDefault(); setLevel(level - step); }
else if (e.key === 'ArrowRight' || e.key === 'ArrowUp') { e.preventDefault(); setLevel(level + step); }
});
work.width = 80; work.height = 50;
paintPhoto(vctx, view.width, view.height);
refresh();// RgbHistogram — vanilla 판(run/295/demo/08-image-edit-histogram-rgb/index.html) 의 React 포트.
// 보정을 먹인 사진을 작은 판에 다시 그린 뒤 getImageData 로 픽셀을 통째로 읽어 16칸 3채널을 센다.
// 격자 미리보기(.isDemo)는 그 캔버스를 감추고 DOM 막대 열여섯 개를 scaleY 로 오르내리게 한다.
// 값·수치는 vanilla 와 같다.
import { useCallback, useEffect, useRef, useState } from "react";
import type { CSSProperties, PointerEvent as ReactPointerEvent } from "react";
import styles from "./RgbHistogram.module.scss";
export type RgbHistogramProps = {
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type BarVars = CSSProperties & { "--i"?: string; "--h"?: string };
// ambient 타입에 KeyboardEvent 가 없다 — 화살표 조작에 쓰는 것만 지역 타입으로 선언한다
type KeyEvt = { key: string; shiftKey: boolean; preventDefault(): void };
// useRef 는 T | null 을 돌려준다. 처음부터 값이 있고 절대 비지 않는 상자는 이렇게 좁혀 쓴다
type Box<T> = { current: T };
const BINS = 16;
const CHANNEL = ["rgba(47, 109, 246, .85)", "rgba(43, 157, 116, .85)", "rgba(194, 54, 26, .85)"];
const VOICE = "%v배";
// 미리보기 막대의 키 — 실제로 센 값이 아니라 모양만 보여주는 유령 판이다
const GHOST = [0.18, 0.3, 0.46, 0.62, 0.8, 0.95, 0.72, 0.5, 0.38, 0.44, 0.58, 0.7, 0.85, 0.62, 0.4, 0.24];
// 가짜 사진 — 외부 파일 없이 그라디언트와 도형으로 합성한다. 필터·히스토그램이 보이도록 대비를 세게 준다
function paintPhoto(c: CanvasRenderingContext2D, w: number, h: number): void {
const sky = c.createLinearGradient(0, 0, 0, h);
sky.addColorStop(0, "#2f6df6");
sky.addColorStop(0.55, "#ff9ebb");
sky.addColorStop(1, "#ffd23f");
c.fillStyle = sky;
c.fillRect(0, 0, w, h);
c.fillStyle = "#fff7e6";
c.beginPath();
c.arc(w * 0.74, h * 0.28, h * 0.13, 0, Math.PI * 2);
c.fill();
// 곧은 수평선 하나 — 기울기 보정이 눈에 보이려면 비교할 직선이 있어야 한다
c.fillStyle = "rgba(23, 20, 26, 0.5)";
c.fillRect(0, Math.round(h * 0.66), w, 2);
c.fillStyle = "#17141a";
c.beginPath();
c.moveTo(0, h);
c.lineTo(w * 0.34, h * 0.36);
c.lineTo(w * 0.62, h);
c.closePath();
c.fill();
c.fillStyle = "#c2361a";
c.beginPath();
c.moveTo(w * 0.46, h);
c.lineTo(w * 0.78, h * 0.52);
c.lineTo(w, h);
c.closePath();
c.fill();
for (let i = 0; i < 6; i++) {
c.fillStyle = i % 2 ? "#fff7e6" : "#17141a";
c.fillRect(w * (0.08 + i * 0.07), h * (0.14 + (i % 3) * 0.06), 7, 7);
}
}
// 보정을 먹인 사진을 작은 판에 다시 그린 다음 픽셀을 통째로 읽어 칸마다 센다.
// 한 칸은 밝기 16단계 — 256 단계를 그대로 세면 막대가 너무 잘아서 모양이 안 읽힌다
// ctx.filter 는 Safari 에 아직 없다(caniuse 81.08%). 없으면 읽은 값에 직접 곱해 같은 결과를 만든다
function countBins(wctx: CanvasRenderingContext2D, work: HTMLCanvasElement, view: HTMLCanvasElement, level: number): number[] {
const canFilter = typeof wctx.filter === "string";
if (canFilter) wctx.filter = "brightness(" + level.toFixed(2) + ")";
wctx.clearRect(0, 0, work.width, work.height);
wctx.drawImage(view, 0, 0, work.width, work.height);
const data = wctx.getImageData(0, 0, work.width, work.height).data;
const bins: number[] = [];
for (let i = 0; i < BINS * 3; i++) bins.push(0);
for (let p = 0; p < data.length; p += 4) {
for (let ch = 0; ch < 3; ch++) {
const v = canFilter ? data[p + ch] : Math.min(255, Math.round(data[p + ch] * level));
bins[Math.min(BINS - 1, v >> 4) * 3 + ch] += 1;
}
}
return bins;
}
function drawHist(hctx: CanvasRenderingContext2D, hist: HTMLCanvasElement, bins: number[]): void {
const w = hist.width;
const h = hist.height;
let top = 1;
for (let i = 0; i < bins.length; i++) {
if (bins[i] > top) top = bins[i];
}
hctx.clearRect(0, 0, w, h);
const slot = w / BINS;
const bar = slot / 3;
for (let i = 0; i < BINS; i++) {
for (let ch = 0; ch < 3; ch++) {
const tall = (bins[i * 3 + ch] / top) * h;
hctx.fillStyle = CHANNEL[ch];
hctx.fillRect(i * slot + ch * bar, h - tall, bar - 1, tall);
}
}
}
export default function RgbHistogram({
duration = 2000,
easing = "ease-out",
color = "#17141a",
}: RgbHistogramProps) {
const viewRef = useRef<HTMLCanvasElement | null>(null);
const histRef = useRef<HTMLCanvasElement | null>(null);
const trackRef = useRef<HTMLSpanElement | null>(null);
const workRef = useRef<HTMLCanvasElement | null>(null);
const wctxRef = useRef<CanvasRenderingContext2D | null>(null);
const queuedRef = useRef<number>(0) as Box<number>;
const levelRef = useRef<number>(1) as Box<number>;
const [demo, setDemo] = useState(true);
const [level, setLevel] = useState(1);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
// 픽셀을 읽는 판은 한 번만 만든다 — willReadFrequently 는 만들 때 한 번만 먹는 옵션이다
const ensureWork = useCallback((): HTMLCanvasElement | null => {
if (!workRef.current) {
const work = document.createElement("canvas");
work.width = 80;
work.height = 50;
const wctx = work.getContext("2d", { willReadFrequently: true });
if (!wctx) return null;
workRef.current = work;
wctxRef.current = wctx;
}
return workRef.current;
}, []);
useEffect(() => {
const view = viewRef.current;
if (!view) return;
const vctx = view.getContext("2d");
if (vctx) paintPhoto(vctx, view.width, view.height);
}, []);
// 픽셀 세기는 무거우니 프레임마다 한 번으로 묶는다 — 슬라이더를 빠르게 끌어도 밀리지 않는다
useEffect(() => {
if (queuedRef.current) return;
queuedRef.current = requestAnimationFrame(() => {
queuedRef.current = 0;
const view = viewRef.current;
const hist = histRef.current;
const work = ensureWork();
const wctx = wctxRef.current;
if (!view || !hist || !work || !wctx) return;
const hctx = hist.getContext("2d");
if (!hctx) return;
drawHist(hctx, hist, countBins(wctx, work, view, levelRef.current));
});
}, [level, ensureWork]);
useEffect(() => {
return () => {
if (queuedRef.current) cancelAnimationFrame(queuedRef.current);
};
}, []);
const applyLevel = useCallback((next: number) => {
const value = Math.max(0.6, Math.min(1.6, Math.round(next * 100) / 100));
levelRef.current = value;
setLevel(value);
setDemo(false);
}, []);
function fromPointer(event: ReactPointerEvent<HTMLSpanElement>): void {
const track = trackRef.current;
if (!track) return;
const r = track.getBoundingClientRect();
applyLevel((event.clientX - r.left) / r.width + 0.6);
}
function onTrackDown(event: ReactPointerEvent<HTMLSpanElement>): void {
const track = trackRef.current;
if (!track) return;
track.setPointerCapture(event.pointerId);
fromPointer(event);
}
function onTrackMove(event: ReactPointerEvent<HTMLSpanElement>): void {
if (event.buttons) fromPointer(event);
}
// 화살표 0.02, Shift 와 함께면 0.1
function onTrackKey(event: KeyEvt): void {
const step = event.shiftKey ? 0.1 : 0.02;
if (event.key === "ArrowLeft" || event.key === "ArrowDown") {
event.preventDefault();
applyLevel(level - step);
} else if (event.key === "ArrowRight" || event.key === "ArrowUp") {
event.preventDefault();
applyLevel(level + step);
}
}
const cls = [styles.root, demo ? styles.isDemo : ""].filter(Boolean).join(" ");
return (
<main className={styles.stage}>
<div className={cls} style={vars}>
<div className={styles.top}>
<div className={styles.frame}>
<canvas
className={styles.view}
ref={viewRef}
width={320}
height={200}
role="img"
aria-label="샘플 사진"
style={{ filter: `brightness(${level.toFixed(2)})` }}
></canvas>
</div>
<div className={styles.chart}>
<p className={styles.cap}>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" className={styles.ico} aria-hidden="true"><g fill="currentColor"><path d="M208 40v168h-56V40Z" opacity=".2"/><path d="M224 200h-8V40a8 8 0 0 0-8-8h-56a8 8 0 0 0-8 8v40H96a8 8 0 0 0-8 8v40H48a8 8 0 0 0-8 8v64h-8a8 8 0 0 0 0 16h192a8 8 0 0 0 0-16M160 48h40v152h-40Zm-56 48h40v104h-40Zm-48 48h32v56H56Z"/></g></svg>
<span>히스토그램</span>
</p>
<div className={styles.plot}>
<canvas className={styles.hist} ref={histRef} width={240} height={120} role="img" aria-label="채널별 분포 그래프"></canvas>
<span className={styles.bars} aria-hidden="true">
{GHOST.map((h, i) => {
const barVars: BarVars = { "--i": String(i), "--h": String(h) };
return <span key={i} className={styles.gb} style={barVars}></span>;
})}
</span>
</div>
</div>
</div>
<div className={styles.ctl}>
<span className={styles.lab} id="levelLab">밝기</span>
<span
className={styles.track}
ref={trackRef}
role="slider"
tabIndex={0}
aria-labelledby="levelLab"
aria-valuemin={0.6}
aria-valuemax={1.6}
aria-valuenow={level.toFixed(2)}
aria-valuetext={VOICE.replace("%v", level.toFixed(2))}
onPointerDown={onTrackDown}
onPointerMove={onTrackMove}
onKeyDown={onTrackKey}
>
<span className={styles.thumb} style={{ left: `${(level - 0.6) * 100}%` }}></span>
</span>
</div>
</div>
</main>
);
}08 · RGB 히스토그램 — 측정 누적면적 16.865% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드 읽는 법
밝기를 1.55배로 올렸을 때 막대가 칠한 넓이가 16.44%에서 8.65%로 줄어드는 것을 실측했습니다(누적면적 16.857%·움직인 프레임 23/23).
// ctx.filter 는 Safari 에 아직 없다(caniuse 81.08%). 없으면 읽은 값에 직접 곱해 같은 결과를 만든다
var CAN_FILTER = typeof wctx.filter === 'string';
function countBins() {
if (CAN_FILTER) { wctx.filter = 'brightness(' + level.toFixed(2) + ')'; }
wctx.clearRect(0, 0, work.width, work.height);
wctx.drawImage(view, 0, 0, work.width, work.height);
var data = wctx.getImageData(0, 0, work.width, work.height).data;
var bins = [], i;
for (i = 0; i < BINS * 3; i++) { bins.push(0); }
for (var p = 0; p < data.length; p += 4) {
for (var ch = 0; ch < 3; ch++) {
var v = CAN_FILTER ? data[p + ch] : Math.min(255, Math.round(data[p + ch] * level));
bins[Math.min(BINS - 1, v >> 4) * 3 + ch] += 1;
}
}
return bins;
}
09내보내기 패널
어떤 움직임인가
용량을 공식으로 짐작하지 않고 toBlob 으로 파일을 실제로 만들어 길이를 읽습니다. 같은 사진 320 × 200 을 기준으로 PNG 는 12.4 KB, JPG 는 품질 0.8 에서 5.8 KB, WEBP 는 품질 0.8 에서 3.3 KB, 품질을 0.5 로 내리면 2.4 KB 가 나왔습니다.

<main class="stage">
<div class="ep is-demo" id="ep">
<div class="ep__frame">
<canvas class="ep__view" id="view" width="320" height="200" role="img" aria-label="내보낼 사진"></canvas>
</div>
<div class="ep__panel">
<div class="ep__fmts" id="fmts">
<span class="ep__ring" aria-hidden="true"></span>
<button type="button" class="ep__f" data-m="image/jpeg" aria-pressed="true">JPG</button>
<button type="button" class="ep__f" data-m="image/png" aria-pressed="false">PNG</button>
<button type="button" class="ep__f" data-m="image/webp" aria-pressed="false">WEBP</button>
</div>
<div class="ep__q">
<span class="ep__lab" id="qLab">품질</span>
<span class="ep__track" id="track" role="slider" tabindex="0" aria-labelledby="qLab" aria-valuemin="0.3" aria-valuemax="1" aria-valuenow="0.8"><span class="ep__thumb" id="thumb"></span></span>
</div>
<p class="ep__size">
<span class="ep__lab">예상 용량</span>
<span class="ep__num">
<span class="ep__real" id="size">— KB</span>
<span class="ep__demo ep__demo--a" aria-hidden="true">5.8 KB</span>
<span class="ep__demo ep__demo--b" aria-hidden="true">12.4 KB</span>
<span class="ep__demo ep__demo--c" aria-hidden="true">3.3 KB</span>
<span class="ep__demo ep__demo--d" aria-hidden="true">2.4 KB</span>
</span>
</p>
<button type="button" class="ep__go" id="goBtn"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" class="ep__ico" aria-hidden="true"><g fill="currentColor"><path d="M216 48v160H40V48a16 16 0 0 1 16-16h144a16 16 0 0 1 16 16" opacity=".2"/><path d="M224 144v64a8 8 0 0 1-8 8H40a8 8 0 0 1-8-8v-64a8 8 0 0 1 16 0v56h160v-56a8 8 0 0 1 16 0m-101.66 5.66a8 8 0 0 0 11.32 0l40-40a8 8 0 0 0-11.32-11.32L136 124.69V32a8 8 0 0 0-16 0v92.69L93.66 98.34a8 8 0 0 0-11.32 11.32Z"/></g></svg><span>내보내기</span></button>
<p class="ep__done" id="done">파일 준비 완료</p>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 내보내기 패널 — 용량은 canvas 2D 의 toBlob 이 실제로 만든 파일 길이다(짐작한 값이 아니다).
// 격자 미리보기(.is-demo)는 형식 테두리를 밀고 용량 칸 넷을 steps(1, end) 로 갈아 끼운다 —
// 글자는 보간할 수 없어 교차 페이드로 두면 두 숫자가 반투명하게 겹친 장면이 대표 이미지로 뽑힌다.
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-ink;
@include stage($stage-paper);
.ep { display: flex; align-items: center; gap: $sp-3; width: 100%; max-width: 400px; justify-content: center; }
.ep__frame {
flex: 0 0 auto; height: clamp(72px, 34vh, 116px); aspect-ratio: 16 / 10;
border-radius: $r-sm; overflow: hidden; box-shadow: $shadow-raised;
}
.ep__view { display: block; width: 100%; height: 100%; }
.ep__panel { flex: 0 0 auto; display: flex; flex-direction: column; gap: $sp-2; width: 152px; }
.ep__fmts { position: relative; display: grid; grid-template-columns: repeat(3, 1fr); }
.ep__ring {
position: absolute; top: 0; left: 0; width: calc(100% / 3); height: 100%;
border: 2px solid $subject-blue; border-radius: $r-sm; pointer-events: none;
}
.ep__f {
width: 100%; cursor: pointer; padding: $sp-1 0; border: 0; background: transparent; color: $color;
font-family: ui-monospace, "Consolas", monospace; font-weight: 700;
font-size: clamp(8px, 2.2vw, 10px); line-height: 1.3;
}
.ep__f:focus-visible { outline: 3px solid $subject-blue; outline-offset: 2px; }
.ep__q { display: flex; align-items: center; gap: $sp-2; }
.ep__lab { font-weight: 800; font-size: clamp(8px, 2.2vw, 10px); color: rgba(23, 20, 26, .72); }
.ep__track {
position: relative; display: block; flex: 0 0 auto; width: 76px; height: 6px; cursor: pointer;
border-radius: $r-pill; background: rgba(23, 20, 26, .18);
}
.ep__track[aria-disabled="true"] { opacity: .42; }
.ep__track:focus-visible { outline: 3px solid $subject-blue; outline-offset: 4px; }
.ep__thumb {
position: absolute; top: 50%; left: 71.4%; width: 13px; height: 13px;
border-radius: 50%; background: $subject-blue; box-shadow: $shadow-press;
transform: translate(-50%, -50%);
}
.ep__size { display: flex; align-items: baseline; gap: $sp-2; margin: 0; }
.ep__num { position: relative; font-family: ui-monospace, "Consolas", monospace; font-weight: 700;
font-size: clamp(9px, 2.4vw, 11px); color: $color; }
.ep__demo { display: none; position: absolute; left: 0; top: 0; white-space: nowrap; }
.ep__go {
display: inline-flex; align-items: center; justify-content: center; gap: $sp-1; width: 100%;
cursor: pointer; border: 0; background: $subject-ink; color: $subject-cream;
border-radius: $r-control; font-family: inherit; font-weight: 800; line-height: 1.3;
font-size: clamp(8px, 2.2vw, 10px); padding: $sp-1 $sp-2;
}
.ep__go:focus-visible { outline: 3px solid $subject-blue; outline-offset: 2px; }
.ep__ico { width: 12px; height: 12px; flex: 0 0 auto; }
.ep__done { margin: 0; font-weight: 700; font-size: clamp(8px, 2.1vw, 10px); color: $subject-blue; }
// ── 미리보기 루프: JPG → PNG → WEBP 순으로 형식을 돌고, WEBP 에서 품질을 0.8 에서 0.5 로 내린다.
// 복귀는 86% 에 끝난다. 24장 표본의 마지막 장이 2초의 95.8% 지점이라 그 뒤는 안 찍힌다
.ep.is-demo .ep__ring {
animation-name: epRing; animation-duration: $duration;
animation-timing-function: $easing; animation-iteration-count: infinite;
}
@keyframes epRing {
0%, 24% { transform: translateX(0); }
26%, 48% { transform: translateX(100%); }
50%, 84% { transform: translateX(200%); }
86%, 100% { transform: translateX(0); }
}
.ep.is-demo .ep__thumb {
animation-name: epThumb; animation-duration: $duration;
animation-timing-function: $easing; animation-iteration-count: infinite;
}
@keyframes epThumb {
0%, 66% { transform: translate(-50%, -50%); }
70%, 84% { transform: translate(-50%, -50%) translateX(-33px); }
86%, 100% { transform: translate(-50%, -50%); }
}
.ep.is-demo .ep__real { color: transparent; }
.ep.is-demo .ep__demo {
display: block;
animation-duration: $duration;
animation-timing-function: steps(1, end); animation-iteration-count: infinite;
}
.ep.is-demo .ep__demo--a { animation-name: epSizeA; }
.ep.is-demo .ep__demo--b { animation-name: epSizeB; }
.ep.is-demo .ep__demo--c { animation-name: epSizeC; }
.ep.is-demo .ep__demo--d { animation-name: epSizeD; }
@keyframes epSizeA { 0%, 25% { opacity: 1; } 26%, 85% { opacity: 0; } 86%, 100% { opacity: 1; } }
@keyframes epSizeB { 0%, 25% { opacity: 0; } 26%, 49% { opacity: 1; } 50%, 100% { opacity: 0; } }
@keyframes epSizeC { 0%, 49% { opacity: 0; } 50%, 69% { opacity: 1; } 70%, 100% { opacity: 0; } }
@keyframes epSizeD { 0%, 69% { opacity: 0; } 70%, 85% { opacity: 1; } 86%, 100% { opacity: 0; } }
// 내보내기 단추는 자기 중심에서 눌린다 — 원점을 모서리에 두면 눌리는 게 아니라 옆으로 밀려 보인다
.ep.is-demo .ep__go {
animation-name: epPress; animation-duration: $duration;
animation-timing-function: $ease-pop; animation-iteration-count: infinite;
}
@keyframes epPress {
0%, 20% { transform: scale(1); }
24% { transform: scale(0.93); }
30%, 44% { transform: scale(1); }
48% { transform: scale(0.93); }
54%, 100% { transform: scale(1); }
}
@media (max-width: 340px) {
.ep { gap: $sp-2; }
.ep__panel { width: 132px; }
.ep__track { width: 60px; }
}
@media (prefers-reduced-motion: reduce) {
.ep.is-demo .ep__ring,
.ep.is-demo .ep__thumb,
.ep.is-demo .ep__demo,
.ep.is-demo .ep__go { animation: none !important; }
// 움직임 없이 WEBP 를 고른 상태로 세운다
.ep.is-demo .ep__ring { transform: translateX(200%) !important; }
.ep.is-demo .ep__demo--a,
.ep.is-demo .ep__demo--b,
.ep.is-demo .ep__demo--d { opacity: 0 !important; }
.ep.is-demo .ep__demo--c { opacity: 1 !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.ep {
display: flex;
align-items: center;
gap: 12px;
width: 100%;
max-width: 400px;
justify-content: center;
}
.ep__frame {
flex: 0 0 auto;
height: clamp(72px, 34vh, 116px);
aspect-ratio: 16/10;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.ep__view {
display: block;
width: 100%;
height: 100%;
}
.ep__panel {
flex: 0 0 auto;
display: flex;
flex-direction: column;
gap: 8px;
width: 152px;
}
.ep__fmts {
position: relative;
display: grid;
grid-template-columns: repeat(3, 1fr);
}
.ep__ring {
position: absolute;
top: 0;
left: 0;
width: 33.3333333333%;
height: 100%;
border: 2px solid #2f6df6;
border-radius: 8px;
pointer-events: none;
}
.ep__f {
width: 100%;
cursor: pointer;
padding: 4px 0;
border: 0;
background: transparent;
color: #17141a;
font-family: ui-monospace, "Consolas", monospace;
font-weight: 700;
font-size: clamp(8px, 2.2vw, 10px);
line-height: 1.3;
}
.ep__f:focus-visible {
outline: 3px solid #2f6df6;
outline-offset: 2px;
}
.ep__q {
display: flex;
align-items: center;
gap: 8px;
}
.ep__lab {
font-weight: 800;
font-size: clamp(8px, 2.2vw, 10px);
color: rgba(23, 20, 26, 0.72);
}
.ep__track {
position: relative;
display: block;
flex: 0 0 auto;
width: 76px;
height: 6px;
cursor: pointer;
border-radius: 999px;
background: rgba(23, 20, 26, 0.18);
}
.ep__track[aria-disabled=true] {
opacity: 0.42;
}
.ep__track:focus-visible {
outline: 3px solid #2f6df6;
outline-offset: 4px;
}
.ep__thumb {
position: absolute;
top: 50%;
left: 71.4%;
width: 13px;
height: 13px;
border-radius: 50%;
background: #2f6df6;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
transform: translate(-50%, -50%);
}
.ep__size {
display: flex;
align-items: baseline;
gap: 8px;
margin: 0;
}
.ep__num {
position: relative;
font-family: ui-monospace, "Consolas", monospace;
font-weight: 700;
font-size: clamp(9px, 2.4vw, 11px);
color: #17141a;
}
.ep__demo {
display: none;
position: absolute;
left: 0;
top: 0;
white-space: nowrap;
}
.ep__go {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 4px;
width: 100%;
cursor: pointer;
border: 0;
background: #17141a;
color: #fff7e6;
border-radius: 12px;
font-family: inherit;
font-weight: 800;
line-height: 1.3;
font-size: clamp(8px, 2.2vw, 10px);
padding: 4px 8px;
}
.ep__go:focus-visible {
outline: 3px solid #2f6df6;
outline-offset: 2px;
}
.ep__ico {
width: 12px;
height: 12px;
flex: 0 0 auto;
}
.ep__done {
margin: 0;
font-weight: 700;
font-size: clamp(8px, 2.1vw, 10px);
color: #2f6df6;
}
.ep.is-demo .ep__ring {
animation-name: epRing;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
@keyframes epRing {
0%, 24% {
transform: translateX(0);
}
26%, 48% {
transform: translateX(100%);
}
50%, 84% {
transform: translateX(200%);
}
86%, 100% {
transform: translateX(0);
}
}
.ep.is-demo .ep__thumb {
animation-name: epThumb;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
@keyframes epThumb {
0%, 66% {
transform: translate(-50%, -50%);
}
70%, 84% {
transform: translate(-50%, -50%) translateX(-33px);
}
86%, 100% {
transform: translate(-50%, -50%);
}
}
.ep.is-demo .ep__real {
color: transparent;
}
.ep.is-demo .ep__demo {
display: block;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.ep.is-demo .ep__demo--a {
animation-name: epSizeA;
}
.ep.is-demo .ep__demo--b {
animation-name: epSizeB;
}
.ep.is-demo .ep__demo--c {
animation-name: epSizeC;
}
.ep.is-demo .ep__demo--d {
animation-name: epSizeD;
}
@keyframes epSizeA {
0%, 25% {
opacity: 1;
}
26%, 85% {
opacity: 0;
}
86%, 100% {
opacity: 1;
}
}
@keyframes epSizeB {
0%, 25% {
opacity: 0;
}
26%, 49% {
opacity: 1;
}
50%, 100% {
opacity: 0;
}
}
@keyframes epSizeC {
0%, 49% {
opacity: 0;
}
50%, 69% {
opacity: 1;
}
70%, 100% {
opacity: 0;
}
}
@keyframes epSizeD {
0%, 69% {
opacity: 0;
}
70%, 85% {
opacity: 1;
}
86%, 100% {
opacity: 0;
}
}
.ep.is-demo .ep__go {
animation-name: epPress;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 1.4, 0.4, 1);
animation-iteration-count: infinite;
}
@keyframes epPress {
0%, 20% {
transform: scale(1);
}
24% {
transform: scale(0.93);
}
30%, 44% {
transform: scale(1);
}
48% {
transform: scale(0.93);
}
54%, 100% {
transform: scale(1);
}
}
@media (max-width: 340px) {
.ep {
gap: 8px;
}
.ep__panel {
width: 132px;
}
.ep__track {
width: 60px;
}
}
@media (prefers-reduced-motion: reduce) {
.ep.is-demo .ep__ring,
.ep.is-demo .ep__thumb,
.ep.is-demo .ep__demo,
.ep.is-demo .ep__go {
animation: none !important;
}
.ep.is-demo .ep__ring {
transform: translateX(200%) !important;
}
.ep.is-demo .ep__demo--a,
.ep.is-demo .ep__demo--b,
.ep.is-demo .ep__demo--d {
opacity: 0 !important;
}
.ep.is-demo .ep__demo--c {
opacity: 1 !important;
}
}var ep = document.getElementById('ep');
var view = document.getElementById('view');
var track = document.getElementById('track');
var thumb = document.getElementById('thumb');
var sizeEl = document.getElementById('size');
var doneEl = document.getElementById('done');
var ring = document.querySelector('.ep__ring');
var formats = document.querySelectorAll('.ep__f');
var mime = 'image/jpeg', quality = 0.8;
// 가짜 사진 — 외부 파일 없이 그라디언트와 도형으로 합성한다. 필터·히스토그램이 보이도록 대비를 세게 준다
function paintPhoto(c, w, h) {
var sky = c.createLinearGradient(0, 0, 0, h);
sky.addColorStop(0, '#2f6df6'); sky.addColorStop(0.55, '#ff9ebb'); sky.addColorStop(1, '#ffd23f');
c.fillStyle = sky; c.fillRect(0, 0, w, h);
c.fillStyle = '#fff7e6';
c.beginPath(); c.arc(w * 0.74, h * 0.28, h * 0.13, 0, Math.PI * 2); c.fill();
// 곧은 수평선 하나 — 기울기 보정이 눈에 보이려면 비교할 직선이 있어야 한다
c.fillStyle = 'rgba(23, 20, 26, 0.5)'; c.fillRect(0, Math.round(h * 0.66), w, 2);
c.fillStyle = '#17141a';
c.beginPath(); c.moveTo(0, h); c.lineTo(w * 0.34, h * 0.36); c.lineTo(w * 0.62, h); c.closePath(); c.fill();
c.fillStyle = '#c2361a';
c.beginPath(); c.moveTo(w * 0.46, h); c.lineTo(w * 0.78, h * 0.52); c.lineTo(w, h); c.closePath(); c.fill();
for (var i = 0; i < 6; i++) { c.fillStyle = i % 2 ? '#fff7e6' : '#17141a'; c.fillRect(w * (0.08 + i * 0.07), h * (0.14 + (i % 3) * 0.06), 7, 7); }
}
// 용량은 짐작하지 않는다 — 실제로 파일을 만들어 보고 그 길이를 읽는다.
// PNG 는 무손실이라 quality 인자를 아예 무시한다. 이 사진 320x200 기준 실측:
// JPG 0.8 은 5.8 KB, PNG 는 12.4 KB, WEBP 0.8 은 3.3 KB, WEBP 0.5 는 2.4 KB
// 고른 형식을 브라우저가 모르면 규격상 조용히 PNG 가 나온다 — image/webp 는 Safari 에 아직 없다
// (caniuse 81.11%). blob.type 을 견줘 다르면 실제로 나온 형식을 같이 적는다
function label(blob) {
if (!blob) { return '— KB'; }
return (blob.size / 1024).toFixed(1) + ' KB' + (blob.type === mime ? '' : ' · ' + blob.type);
}
function estimate() {
view.toBlob(function (blob) { sizeEl.textContent = label(blob); }, mime, quality);
}
function refresh() {
thumb.style.left = ((quality - 0.3) / 0.7 * 100) + '%';
track.setAttribute('aria-valuenow', quality.toFixed(2));
track.setAttribute('aria-valuetext', quality.toFixed(2));
track.setAttribute('aria-disabled', mime === 'image/png' ? 'true' : 'false');
estimate();
}
for (var i = 0; i < formats.length; i++) {
(function (index) {
formats[index].addEventListener('click', function () {
mime = formats[index].getAttribute('data-m');
for (var j = 0; j < formats.length; j++) { formats[j].setAttribute('aria-pressed', j === index ? 'true' : 'false'); }
ring.style.transform = 'translateX(' + index * 100 + '%)';
ep.classList.remove('is-demo');
doneEl.hidden = true;
refresh();
});
})(i);
}
function setQuality(next) {
quality = Math.max(0.3, Math.min(1, Math.round(next * 100) / 100));
ep.classList.remove('is-demo');
doneEl.hidden = true;
refresh();
}
function fromPointer(e) {
var r = track.getBoundingClientRect();
setQuality((e.clientX - r.left) / r.width * 0.7 + 0.3);
}
track.addEventListener('pointerdown', function (e) { track.setPointerCapture(e.pointerId); fromPointer(e); });
track.addEventListener('pointermove', function (e) { if (e.buttons) { fromPointer(e); } });
// 화살표 0.05, Shift 와 함께면 0.2
track.addEventListener('keydown', function (e) {
var step = e.shiftKey ? 0.2 : 0.05;
if (e.key === 'ArrowLeft' || e.key === 'ArrowDown') { e.preventDefault(); setQuality(quality - step); }
else if (e.key === 'ArrowRight' || e.key === 'ArrowUp') { e.preventDefault(); setQuality(quality + step); }
});
// 데모에서는 내려받기 대신 만들어진 파일의 길이만 보여준다
document.getElementById('goBtn').addEventListener('click', function () {
ep.classList.remove('is-demo');
view.toBlob(function (blob) {
sizeEl.textContent = label(blob);
doneEl.hidden = false;
}, mime, quality);
});
doneEl.hidden = true;
paintPhoto(view.getContext('2d'), view.width, view.height);
refresh();// ExportPanel — vanilla 판(run/295/demo/09-image-edit-export-panel/index.html) 의 React 포트.
// 용량은 짐작하지 않는다 — canvas 2D 의 toBlob 이 실제로 만든 파일 길이를 KB 로 읽는다(내려받지는 않는다).
// 격자 미리보기(.isDemo)는 형식 테두리를 밀고 용량 칸 넷을 steps(1, end) 로 갈아 끼운다.
// 값·수치는 vanilla 와 같다.
import { useCallback, useEffect, useRef, useState } from "react";
import type { CSSProperties, PointerEvent as ReactPointerEvent } from "react";
import styles from "./ExportPanel.module.scss";
export type ExportPanelProps = {
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Format = { mime: string; name: string };
// ambient 타입에 KeyboardEvent 가 없다 — 화살표 조작에 쓰는 것만 지역 타입으로 선언한다
type KeyEvt = { key: string; shiftKey: boolean; preventDefault(): void };
const FORMATS: Format[] = [
{ mime: "image/jpeg", name: "JPG" },
{ mime: "image/png", name: "PNG" },
{ mime: "image/webp", name: "WEBP" },
];
const EMPTY = "— KB";
// 가짜 사진 — 외부 파일 없이 그라디언트와 도형으로 합성한다. 필터·히스토그램이 보이도록 대비를 세게 준다
function paintPhoto(c: CanvasRenderingContext2D, w: number, h: number): void {
const sky = c.createLinearGradient(0, 0, 0, h);
sky.addColorStop(0, "#2f6df6");
sky.addColorStop(0.55, "#ff9ebb");
sky.addColorStop(1, "#ffd23f");
c.fillStyle = sky;
c.fillRect(0, 0, w, h);
c.fillStyle = "#fff7e6";
c.beginPath();
c.arc(w * 0.74, h * 0.28, h * 0.13, 0, Math.PI * 2);
c.fill();
// 곧은 수평선 하나 — 기울기 보정이 눈에 보이려면 비교할 직선이 있어야 한다
c.fillStyle = "rgba(23, 20, 26, 0.5)";
c.fillRect(0, Math.round(h * 0.66), w, 2);
c.fillStyle = "#17141a";
c.beginPath();
c.moveTo(0, h);
c.lineTo(w * 0.34, h * 0.36);
c.lineTo(w * 0.62, h);
c.closePath();
c.fill();
c.fillStyle = "#c2361a";
c.beginPath();
c.moveTo(w * 0.46, h);
c.lineTo(w * 0.78, h * 0.52);
c.lineTo(w, h);
c.closePath();
c.fill();
for (let i = 0; i < 6; i++) {
c.fillStyle = i % 2 ? "#fff7e6" : "#17141a";
c.fillRect(w * (0.08 + i * 0.07), h * (0.14 + (i % 3) * 0.06), 7, 7);
}
}
export default function ExportPanel({
duration = 2000,
easing = "cubic-bezier(.2, .8, .2, 1)",
color = "#17141a",
}: ExportPanelProps) {
const viewRef = useRef<HTMLCanvasElement | null>(null);
const trackRef = useRef<HTMLSpanElement | null>(null);
const [demo, setDemo] = useState(true);
const [picked, setPicked] = useState(0);
const [quality, setQuality] = useState(0.8);
const [sizeText, setSizeText] = useState(EMPTY);
const [done, setDone] = useState(false);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
useEffect(() => {
const view = viewRef.current;
if (!view) return;
const vctx = view.getContext("2d");
if (vctx) paintPhoto(vctx, view.width, view.height);
}, []);
// 용량은 짐작하지 않는다 — 실제로 파일을 만들어 보고 그 길이를 읽는다.
// PNG 는 무손실이라 quality 인자를 아예 무시한다. 이 사진 320x200 기준 실측:
// JPG 0.8 은 5.8 KB, PNG 는 12.4 KB, WEBP 0.8 은 3.3 KB, WEBP 0.5 는 2.4 KB
const estimate = useCallback((finish: boolean) => {
const view = viewRef.current;
if (!view) return;
// 고른 형식을 브라우저가 모르면 규격상 조용히 PNG 가 나온다 — image/webp 는 Safari 에 아직 없다
// (caniuse 81.11%). blob.type 을 견줘 다르면 실제로 나온 형식을 같이 적는다
view.toBlob(
(blob: Blob | null) => {
const mime = FORMATS[picked].mime;
setSizeText(blob
? (blob.size / 1024).toFixed(1) + " KB" + (blob.type === mime ? "" : " · " + blob.type)
: EMPTY);
if (finish) setDone(true);
},
FORMATS[picked].mime,
quality
);
}, [picked, quality]);
useEffect(() => {
estimate(false);
}, [estimate]);
function onFormat(index: number): void {
setPicked(index);
setDemo(false);
setDone(false);
}
const applyQuality = useCallback((next: number) => {
setQuality(Math.max(0.3, Math.min(1, Math.round(next * 100) / 100)));
setDemo(false);
setDone(false);
}, []);
function fromPointer(event: ReactPointerEvent<HTMLSpanElement>): void {
const track = trackRef.current;
if (!track) return;
const r = track.getBoundingClientRect();
applyQuality(((event.clientX - r.left) / r.width) * 0.7 + 0.3);
}
function onTrackDown(event: ReactPointerEvent<HTMLSpanElement>): void {
const track = trackRef.current;
if (!track) return;
track.setPointerCapture(event.pointerId);
fromPointer(event);
}
function onTrackMove(event: ReactPointerEvent<HTMLSpanElement>): void {
if (event.buttons) fromPointer(event);
}
// 화살표 0.05, Shift 와 함께면 0.2
function onTrackKey(event: KeyEvt): void {
const step = event.shiftKey ? 0.2 : 0.05;
if (event.key === "ArrowLeft" || event.key === "ArrowDown") {
event.preventDefault();
applyQuality(quality - step);
} else if (event.key === "ArrowRight" || event.key === "ArrowUp") {
event.preventDefault();
applyQuality(quality + step);
}
}
// 데모에서는 내려받기 대신 만들어진 파일의 길이만 보여준다
function onExport(): void {
setDemo(false);
estimate(true);
}
const cls = [styles.root, demo ? styles.isDemo : ""].filter(Boolean).join(" ");
return (
<main className={styles.stage}>
<div className={cls} style={vars}>
<div className={styles.frame}>
<canvas className={styles.view} ref={viewRef} width={320} height={200} role="img" aria-label="내보낼 사진"></canvas>
</div>
<div className={styles.panel}>
<div className={styles.fmts}>
<span className={styles.ring} aria-hidden="true" style={{ transform: `translateX(${picked * 100}%)` }}></span>
{FORMATS.map((format, index) => (
<button
key={format.mime}
type="button"
className={styles.fmt}
aria-pressed={index === picked}
onClick={() => onFormat(index)}
>
{format.name}
</button>
))}
</div>
<div className={styles.q}>
<span className={styles.lab} id="qLab">품질</span>
<span
className={styles.track}
ref={trackRef}
role="slider"
tabIndex={0}
aria-labelledby="qLab"
aria-valuemin={0.3}
aria-valuemax={1}
aria-valuenow={quality.toFixed(2)}
aria-valuetext={quality.toFixed(2)}
aria-disabled={FORMATS[picked].mime === "image/png"}
onPointerDown={onTrackDown}
onPointerMove={onTrackMove}
onKeyDown={onTrackKey}
>
<span className={styles.thumb} style={{ left: `${((quality - 0.3) / 0.7) * 100}%` }}></span>
</span>
</div>
<p className={styles.size}>
<span className={styles.lab}>예상 용량</span>
<span className={styles.num}>
<span className={styles.real}>{sizeText}</span>
<span className={`${styles.demo} ${styles.demoA}`} aria-hidden="true">5.8 KB</span>
<span className={`${styles.demo} ${styles.demoB}`} aria-hidden="true">12.4 KB</span>
<span className={`${styles.demo} ${styles.demoC}`} aria-hidden="true">3.3 KB</span>
<span className={`${styles.demo} ${styles.demoD}`} aria-hidden="true">2.4 KB</span>
</span>
</p>
<button type="button" className={styles.go} onClick={onExport}>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" className={styles.ico} aria-hidden="true"><g fill="currentColor"><path d="M216 48v160H40V48a16 16 0 0 1 16-16h144a16 16 0 0 1 16 16" opacity=".2"/><path d="M224 144v64a8 8 0 0 1-8 8H40a8 8 0 0 1-8-8v-64a8 8 0 0 1 16 0v56h160v-56a8 8 0 0 1 16 0m-101.66 5.66a8 8 0 0 0 11.32 0l40-40a8 8 0 0 0-11.32-11.32L136 124.69V32a8 8 0 0 0-16 0v92.69L93.66 98.34a8 8 0 0 0-11.32 11.32Z"/></g></svg>
<span>내보내기</span>
</button>
<p className={styles.done} hidden={!done}>파일 준비 완료</p>
</div>
</div>
</main>
);
}09 · 내보내기 패널 — 측정 누적면적 2.169% · 움직인 프레임 9/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
어디를 바꾸면 되나
PNG 는 무손실이라 품질 인자를 아예 무시하므로 그때는 슬라이더를 흐리게 두고 aria-disabled 를 켭니다(누적면적 2.169%·강도 108.0).
// 고른 형식을 브라우저가 모르면 규격상 조용히 PNG 가 나온다 — image/webp 는 Safari 에 아직 없다
// (caniuse 81.11%). blob.type 을 견줘 다르면 실제로 나온 형식을 같이 적는다
function label(blob) {
if (!blob) { return '— KB'; }
return (blob.size / 1024).toFixed(1) + ' KB' + (blob.type === mime ? '' : ' · ' + blob.type);
}
function estimate() {
view.toBlob(function (blob) { sizeEl.textContent = label(blob); }, mime, quality);
}
어디서 깨지는가 — 함정
이 편에서 가장 조용히 깨지는 것은 캔버스 쪽 두 기능이 Safari 에만 없다는 점입니다. CanvasRenderingContext2D.filter 는 전역 81.08%, toBlob 의 image/webp 는 81.11% 인데 둘 다 Safari 에서 빠져 있고, 하필 둘 다 오류를 던지지 않습니다. 08 은 필터가 안 먹은 원본을 세어 막대가 안 움직이고, 09 는 WEBP 를 골랐는데 규격대로 PNG 가 나와 용량만 두 배 넘게 찍힙니다. 그래서 08 은 typeof wctx.filter === 'string' 으로 지원 여부를 보고 없으면 읽은 바이트에 직접 곱하도록, 09 는 blob.type 을 고른 형식과 견줘 다르면 실제로 나온 형식을 같이 적도록 고쳤습니다. 반면 CSS 쪽 filter 는 97.02% 라 03·04 는 그대로 써도 됩니다.
| 기능 | 전역 지원 | 빠지는 곳 | 이 편의 대응 |
|---|---|---|---|
CSS filter |
97.02% | 없음 | 03·04 가 그대로 사용 |
ctx.filter |
81.08% | Safari (18부터도 기본 꺼짐) | 08 이 픽셀에 직접 곱함 |
toBlob 의 image/webp |
81.11% | Safari 전 버전 | 09 가 blob.type 을 표시 |
두 번째 함정은 미리보기와 화면 글자가 어긋나는 것이었습니다. 03 에서 선택 테두리만 부드럽게 밀었더니 테두리가 먼저 도착한 프레임이 대표 이미지로 뽑혀 「선명 칸에 테두리, 아래 글자는 세피아 값」이 됐고, 테두리와 글자를 같은 순간에 끊고 나서야 둘이 맞아떨어졌습니다. 세 번째는 대비입니다. 06 의 오렌지 무대 위 크림 글자가 #ff4d1f 배경에서 3.11:1 로 AA 미달이라 잉크로 바꿨고, 그 잉크 글자도 사진 그림자가 깔린 주황 위에서는 4.09:1 에 그쳐 크림 알약 면을 깔아 17.11:1 로 올랐고, 05·07 의 눌린 알약도 #2f6df6 위 크림이 4.25:1 이라 흰색으로 올려 4.53:1 을 만들었습니다. 단계가 길어질수록 현재 위치를 잃기 쉬운 것은 편집기만의 일이 아니어서, 브레드크럼 UI도 경로가 깊어지는 순간 같은 문제를 만납니다(근거: run/295/_수리전실측.json·run/295/_probe_contrast/_대비실측.json). 편집기 아홉 벌을 한 묶음으로 받는 zip 의 잠금은 z3y6mq2c 이며, 열면 항목 번호마다 바닐라 판과 React 판이 같은 이름으로 나란히 놓여 있습니다. 출처는 MDN drawImage 와 caniuse 입니다.
접근성
캔버스는 화면 낭독기에 그림일 뿐이라 아홉 개 전부 role="img" 와 aria-label 을 붙였고, 01·03·05 는 값이 바뀔 때마다 그 라벨을 다시 씁니다(01 은 「잘라낸 범위 111 × 72 픽셀」). 슬라이더 여덟 개는 role="slider" 에 aria-labelledby(02 만 aria-label)로 이름을 잇고 aria-valuenow·aria-valuetext 를 갱신하며, 토글은 진짜 버튼에 aria-pressed 를 씁니다. 키보드만으로도 같은 조작이 됩니다 — 01 의 손잡이에 포커스를 두고 오른쪽 화살표를 열 번 누르면 범위가 162 × 112 에서 152 × 112 로 정확히 10px 줄고, Shift 와 함께 왼쪽 화살표를 한 번 누르면 162 × 112 로 돌아옵니다. prefers-reduced-motion 에서는 아홉 개의 미리보기 루프를 !important 로 끄고 편집이 끝난 상태를 세워 두므로, 02 는 기울어진 사진과 -12.0도 값이, 06 은 먹색 바탕에 여백을 넓힌 정사각이 그대로 남습니다.
FAQ
사진 파일을 서버로 올리지 않고도 자를 수 있나요?
가능합니다. 아홉 개 전부 브라우저 안에서만 돌아가고 네트워크 요청이 하나도 없습니다. 사용자가 고른 파일을 URL.createObjectURL 로 <img> 에 물린 뒤 그 이미지를 drawImage 의 첫 인자로 넘기면 되고, 이 편의 데모는 그 자리에 캔버스로 합성한 가짜 사진을 넣어 파일 없이도 열리게 했습니다.
잘라낸 결과를 원본 해상도로 저장하려면 어떻게 하나요?
화면에 보이는 크기가 아니라 원본 픽셀 기준으로 계산하면 됩니다. 01 은 크롭 범위를 퍼센트로만 들고 있다가 그릴 때 photo.width 에 곱하므로, 원본이 4000px짜리라도 같은 퍼센트를 곱해 4000px 기준 사각형을 오려낼 수 있습니다. 결과 캔버스의 width·height 를 그 픽셀 크기로 잡아 주는 것이 요령입니다. 자르지 않고 원본을 크게 들여다보는 쪽은 이미지 확대 보기 UI에서 아홉 가지로 다뤘습니다.
이미지 편집 ui 에서 편집 전후를 나란히 비교하는 화면은 어떻게 만드나요?
04 는 같은 자리에서 원본과 보정본을 번갈아 보여주는 A/B 토글입니다. 두 장을 좌우로 문질러 비교하는 방식이 필요하면 이미지 비교 슬라이더 UI 9종이 그쪽을 다루고, 두 방식은 목적이 달라 함께 써도 부딪히지 않습니다.









