02회전과 수평 보정
각도 눈금을 밀면 사진이 기울고, 모서리에 빈 자리가 생기지 않도록 배율을 같이 계산해 키웁니다.
쓰임수평이 틀어진 여행 사진 바로잡기, 비뚤게 찍힌 영수증 각도 맞추기
rotateMath.cosaria-valuetext

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


