01自由トリミング枠
角と辺にある八つのハンドルをドラッグして切る範囲を決め、切り抜いた結果を隣の枠にすぐ描き出します。
使いどころ商品写真をアップ前に整える、記事サムネイルの切り出し
setPointerCapturedrawImageclip-path

<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 オリジナル · SCSS と React は zip に同梱 · 画像 編集 javascript のトリミングUI 9選
このデモが属する記事
画像 編集 javascript のトリミングUI 9選画像 編集 javascript の入口は、写真から残す範囲を選んで切り抜く UI です。ここに並べた九つはアップロードなしでブラウザーの中だけで完結するように、canvas の 2D コンテキストと Pointer Events だけで組 この記事のデモをすべて見る


