05左右と上下の反転
片方の軸をマイナスに反転すると、画面の絵と保存される絵が一致します。
使いどころ自撮りの左右反転を戻す、図案のミラー印刷プレビュー
scaleXsetTransformtranslate

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


