09내보내기 패널
포맷과 품질을 고르면 예상 용량이 바로 갱신되고, 저장할 파일이 그 자리에서 만들어집니다.
쓰임업로드 용량 제한 맞추기, 같은 사진의 형식별 용량 비교
toBlobimage/webparia-valuetext

<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 포함 · 이미지 크롭 기능 9종 — 업로드 없이 브라우저에서
이 데모가 속한 글
이미지 크롭 기능 9종 — 업로드 없이 브라우저에서이미지 크롭 기능은 올리기 전 사진에서 남길 자리를 골라 잘라내는 편집 UI입니다. 여기 아홉 가지는 그 일을 업로드 없이 브라우저 안에서 끝내도록 캔버스 2D 와 포인터 이벤트만으로 짠 것이라, 자르기부터 회전·필 이 글의 데모 전부 보기


