05下書きとインクの2レイヤー
下書きの板とインクの板を重ね、下書きだけ薄くして上からなぞります。
使いどころなぞり書き学習ツール、図案の上での清書
globalAlphadrawImageopacity

<main class="stage">
<div class="li is-demo" id="li">
<div class="li__card">
<div class="li__layers">
<div class="li__row">
<button type="button" class="li__layer" id="sketchBtn" aria-pressed="false"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor" class="li__ico" aria-hidden="true"><path d="M224,128a96,96,0,1,1-96-96A96,96,0,0,1,224,128Z" opacity="0.2"/><path d="M96.26,37A8,8,0,0,1,102,27.29a104.11,104.11,0,0,1,52,0,8,8,0,0,1-2,15.75,8.15,8.15,0,0,1-2-.26,88.09,88.09,0,0,0-44,0A8,8,0,0,1,96.26,37ZM53.79,55.14a104,104,0,0,0-26,45,8,8,0,1,0,15.42,4.26,87.91,87.91,0,0,1,22-38.08A8,8,0,0,0,53.79,55.14ZM43.21,151.55a8,8,0,1,0-15.42,4.28,104,104,0,0,0,26,45,8,8,0,1,0,11.41-11.21A88.14,88.14,0,0,1,43.21,151.55ZM150,213.22a88,88,0,0,1-44,0,8,8,0,1,0-4,15.49,104.11,104.11,0,0,0,52,0,8,8,0,0,0-4-15.49ZM222.65,146a8,8,0,0,0-9.85,5.57,87.88,87.88,0,0,1-22,38.09,8,8,0,1,0,11.42,11.21,104.05,104.05,0,0,0,26-45A8,8,0,0,0,222.65,146Zm-9.86-41.54a8,8,0,0,0,15.42-4.28,104,104,0,0,0-26-45,8,8,0,1,0-11.41,11.21A88.14,88.14,0,0,1,212.79,104.45Z"/></svg><span>下書き</span></button>
<span class="li__slot">
<input class="li__range" id="fade" type="range" min="0" max="100" value="34" aria-label="下書きの不透明度">
<span class="li__fake" aria-hidden="true"><span class="li__fakeFill"></span><span class="li__fakeKnob"></span></span>
</span>
</div>
<div class="li__row">
<button type="button" class="li__layer" id="inkBtn" aria-pressed="true"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor" class="li__ico" aria-hidden="true"><path d="M221.66,90.34,192,120,136,64l29.66-29.66a8,8,0,0,1,11.31,0L221.66,79A8,8,0,0,1,221.66,90.34Z" opacity="0.2"/><path d="M227.32,73.37,182.63,28.69a16,16,0,0,0-22.63,0L36.69,152A15.86,15.86,0,0,0,32,163.31V208a16,16,0,0,0,16,16H92.69A15.86,15.86,0,0,0,104,219.31l83.67-83.66,3.48,13.9-36.8,36.79a8,8,0,0,0,11.31,11.32l40-40a8,8,0,0,0,2.11-7.6l-6.9-27.61L227.32,96A16,16,0,0,0,227.32,73.37ZM48,208V179.31L76.69,208Zm48-3.31L51.31,160,136,75.31,180.69,120Zm96-96L147.32,64l24-24L216,84.69Z"/></svg><span>インク</span></button>
<button type="button" class="li__merge" id="mergeBtn"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor" class="li__ico" aria-hidden="true"><path d="M224,80l-96,56L32,80l96-56Z" opacity="0.2"/><path d="M230.91,172A8,8,0,0,1,228,182.91l-96,56a8,8,0,0,1-8.06,0l-96-56A8,8,0,0,1,36,169.09l92,53.65,92-53.65A8,8,0,0,1,230.91,172ZM220,121.09l-92,53.65L36,121.09A8,8,0,0,0,28,134.91l96,56a8,8,0,0,0,8.06,0l96-56A8,8,0,1,0,220,121.09ZM24,80a8,8,0,0,1,4-6.91l96-56a8,8,0,0,1,8.06,0l96,56a8,8,0,0,1,0,13.82l-96,56a8,8,0,0,1-8.06,0l-96-56A8,8,0,0,1,24,80Zm23.88,0L128,126.74,208.12,80,128,33.26Z"/></svg><span>2枚を重ねる</span></button>
</div>
</div>
<div class="li__board" id="board">
<canvas class="li__sketch" id="sketch" role="img" aria-label="下書きレイヤー"></canvas>
<canvas class="li__ink" id="ink" role="img" aria-label="インクレイヤー"></canvas>
<svg class="li__ghost" viewBox="0 0 300 88" preserveAspectRatio="none" aria-hidden="true">
<g class="li__guide">
<path class="li__guideCurve" d="M40,70 C60,18 110,14 130,46 C150,78 190,80 210,54 C226,33 250,26 268,36"/>
<circle class="li__guideRing" cx="130" cy="46" r="26"/>
<line class="li__guideBase" x1="36" y1="78" x2="264" y2="78"/>
</g>
<path class="li__trace" pathLength="100" d="M40,70 C60,18 110,14 130,46 C150,78 190,80 210,54 C226,33 250,26 268,36"/>
<line class="li__finish" pathLength="100" x1="36" y1="78" x2="264" y2="78"/>
</svg>
</div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 밑그림·잉크 2레이어 — 실제 동작은 canvas 두 장이다. 밑그림 판은 globalAlpha 를 낮춘 안내선을 미리 깔고,
// 슬라이더는 그 판의 CSS opacity 만 바꾸며, 「두 장 합치기」는 drawImage 로 밑그림을 잉크 판에 눌러 담는다.
// 격자 미리보기(.is-demo)는 두 판을 감추고 같은 그림을 SVG 두 겹(연한 안내선 · 잉크 획)으로 재현한다 —
// demo.py 는 CSS 애니메이션 시계로 프레임을 뽑으므로 canvas 로 그린 그림은 24장이 전부 같다.
$duration: $dur-loop;
$easing: $ease-out;
$color: $subject-ink;
@include stage($stage-ink);
.li { display: flex; justify-content: center; width: 100%; }
.li__card {
width: min(100%, 340px); background: $stage-paper; border-radius: $r-card;
box-shadow: $shadow-raised; overflow: hidden;
}
.li__layers {
display: flex; flex-direction: column; gap: $sp-1;
background: $subject-ink; padding: $sp-2 $sp-3;
}
.li__row { display: flex; align-items: center; gap: $sp-2; }
.li__layer, .li__merge {
display: flex; align-items: center; gap: $sp-1; cursor: pointer; flex: 0 0 auto;
border: 1px solid rgba(255, 255, 255, .34); background: transparent; color: $stage-paper;
border-radius: $r-pill; font-family: inherit; font-weight: 700;
font-size: clamp(9px, 2.4vw, 10px); padding: $sp-1 $sp-2; line-height: 1.3;
}
.li__layer[aria-pressed="true"] { background: $subject-blue; border-color: $subject-blue; }
.li__layer:focus-visible, .li__merge:focus-visible { outline: 3px solid $subject-blue; outline-offset: 2px; }
.li__ico { width: 14px; height: 14px; flex: 0 0 auto; }
.li__slot { position: relative; display: flex; align-items: center; flex: 1 1 auto; min-width: 0; }
.li__range { width: 100%; height: 12px; margin: 0; cursor: pointer; accent-color: $subject-blue; }
.li__range:focus-visible { outline: 3px solid $subject-blue; outline-offset: 2px; }
// 미리보기용 가짜 슬라이더 — 진짜 range 는 JS 없이 못 움직이므로 .is-demo 동안만 자리를 바꿔 보여 준다
.li__fake {
display: none; position: absolute; left: 0; right: 0; top: 50%; height: 4px;
transform: translateY(-50%); border-radius: $r-pill; background: rgba(255, 255, 255, .28);
}
.li__fakeFill {
position: absolute; inset: 0; border-radius: $r-pill; background: $subject-blue;
// 채움 막대는 시작 변에서 자란다 — 규칙 8 이 허용하는 비중심 origin
transform-origin: left center; transform: scaleX(.34);
}
.li__fakeKnob {
position: absolute; top: 0; bottom: 0; left: 0; width: 100%;
// translateX 의 %는 자기 폭 기준이라, 손잡이를 트랙 폭의 %로 옮기려면 감싼 요소가 트랙과 같은 폭이어야 한다
transform: translateX(34%);
}
.li__fakeKnob::after {
content: ""; position: absolute; left: -4px; top: -2px; width: 8px; height: 8px;
border-radius: 50%; background: $stage-paper;
}
.li__board { position: relative; background: $subject-cream; aspect-ratio: 300 / 88; touch-action: none; }
.li__sketch, .li__ink, .li__ghost { position: absolute; inset: 0; width: 100%; height: 100%; }
.li__sketch { opacity: .34; } // 슬라이더가 바꾸는 값 — 밑그림 판만 옅어진다
.li__ghost { display: none; fill: none; }
.li__guide { opacity: .34; } // 미리보기의 밑그림 겹 — 진짜 판의 opacity 와 같은 값에서 시작한다
.li__guideCurve { stroke: rgba(23, 20, 26, .55); stroke-width: 5; stroke-linecap: round; }
.li__guideRing { stroke: rgba(23, 20, 26, .55); stroke-width: 3; stroke-dasharray: 6 5; }
.li__guideBase { stroke: rgba(23, 20, 26, .55); stroke-width: 3; stroke-dasharray: 6 5; }
.li__trace {
stroke: $color; stroke-width: 3.5; stroke-linecap: round; stroke-linejoin: round;
stroke-dasharray: 100; stroke-dashoffset: 100.5; // 100 이면 round 뚜껑이 길이 0에서도 점으로 찍힌다 — 반 밖으로 밀어 둔다
}
.li__finish {
stroke: $color; stroke-width: 9; stroke-linecap: round;
stroke-dasharray: 100; stroke-dashoffset: 100.5;
}
// ── 미리보기 루프 (2s)
// 0~8% 밑그림이 연하게 깔려 있고 · 8~44% 잉크가 그 위를 따라 그어진다
// 46~50% 마지막 굵은 마무리 획을 빠르게 그어 poster 가 이 장이 되게 한다
// 54~68% 밑그림 겹의 opacity 가 여러 프레임에 걸쳐 0.34 → 0 으로 내려가고 슬라이더 손잡이도 왼쪽으로 간다
// 66~88% 마무리 획 → 잉크 획 순으로 되감기고 밑그림이 돌아와 0% 와 같은 상태가 된다
.li.is-demo .li__sketch, .li.is-demo .li__ink { visibility: hidden; }
.li.is-demo .li__ghost { display: block; }
.li.is-demo .li__range { visibility: hidden; } // display:none 은 자리까지 없앤다 — 칸은 그대로 두고 감춘다
.li.is-demo .li__fake { display: block; }
.li.is-demo .li__trace {
animation-name: liTrace;
animation-duration: $duration;
animation-timing-function: linear; // 펜이 일정한 속도로 밑그림을 따라간다
animation-iteration-count: infinite;
}
@keyframes liTrace {
0%, 8% { stroke-dashoffset: 100.5; }
44%, 72% { stroke-dashoffset: 0; }
88%, 100% { stroke-dashoffset: 100.5; }
}
.li.is-demo .li__finish {
animation-name: liFinish;
animation-duration: $duration;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
@keyframes liFinish {
0%, 46% { stroke-dashoffset: 100.5; }
50%, 66% { stroke-dashoffset: 0; }
80%, 100% { stroke-dashoffset: 100.5; }
}
// 밑그림 겹은 여러 프레임에 걸쳐 옅어진다 — 한 프레임에 사라지면 그 장이 poster 로 뽑힌다
.li.is-demo .li__guide {
animation-name: liFade;
animation-duration: $duration;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
@keyframes liFade {
0%, 54% { opacity: .34; }
68%, 74% { opacity: 0; }
88%, 100% { opacity: .34; }
}
.li.is-demo .li__fakeKnob {
animation-name: liKnob;
animation-duration: $duration;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
@keyframes liKnob {
0%, 54% { transform: translateX(34%); }
68%, 74% { transform: translateX(0); }
88%, 100% { transform: translateX(34%); }
}
.li.is-demo .li__fakeFill {
animation-name: liFill;
animation-duration: $duration;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
@keyframes liFill {
0%, 54% { transform: scaleX(.34); }
68%, 74% { transform: scaleX(0); }
88%, 100% { transform: scaleX(.34); }
}
@media (max-width: 340px) {
.li__layers { padding: $sp-1 $sp-2; }
.li__row { gap: $sp-1; }
}
@media (prefers-reduced-motion: reduce) {
.li.is-demo .li__trace,
.li.is-demo .li__finish,
.li.is-demo .li__guide,
.li.is-demo .li__fakeKnob,
.li.is-demo .li__fakeFill { animation: none !important; }
// 움직임 없이 잉크만 남은 최종 상태로 세운다
.li.is-demo .li__trace,
.li.is-demo .li__finish { stroke-dashoffset: 0 !important; }
.li.is-demo .li__guide { opacity: 0 !important; }
.li.is-demo .li__fakeKnob { transform: translateX(0) !important; }
.li.is-demo .li__fakeFill { transform: scaleX(0) !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);
}
.li {
display: flex;
justify-content: center;
width: 100%;
}
.li__card {
width: min(100%, 340px);
background: #ffffff;
border-radius: 16px;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
overflow: hidden;
}
.li__layers {
display: flex;
flex-direction: column;
gap: 4px;
background: #17141a;
padding: 8px 12px;
}
.li__row {
display: flex;
align-items: center;
gap: 8px;
}
.li__layer, .li__merge {
display: flex;
align-items: center;
gap: 4px;
cursor: pointer;
flex: 0 0 auto;
border: 1px solid rgba(255, 255, 255, 0.34);
background: transparent;
color: #ffffff;
border-radius: 999px;
font-family: inherit;
font-weight: 700;
font-size: clamp(9px, 2.4vw, 10px);
padding: 4px 8px;
line-height: 1.3;
}
.li__layer[aria-pressed=true] {
background: #2f6df6;
border-color: #2f6df6;
}
.li__layer:focus-visible, .li__merge:focus-visible {
outline: 3px solid #2f6df6;
outline-offset: 2px;
}
.li__ico {
width: 14px;
height: 14px;
flex: 0 0 auto;
}
.li__slot {
position: relative;
display: flex;
align-items: center;
flex: 1 1 auto;
min-width: 0;
}
.li__range {
width: 100%;
height: 12px;
margin: 0;
cursor: pointer;
accent-color: #2f6df6;
}
.li__range:focus-visible {
outline: 3px solid #2f6df6;
outline-offset: 2px;
}
.li__fake {
display: none;
position: absolute;
left: 0;
right: 0;
top: 50%;
height: 4px;
transform: translateY(-50%);
border-radius: 999px;
background: rgba(255, 255, 255, 0.28);
}
.li__fakeFill {
position: absolute;
inset: 0;
border-radius: 999px;
background: #2f6df6;
transform-origin: left center;
transform: scaleX(0.34);
}
.li__fakeKnob {
position: absolute;
top: 0;
bottom: 0;
left: 0;
width: 100%;
transform: translateX(34%);
}
.li__fakeKnob::after {
content: "";
position: absolute;
left: -4px;
top: -2px;
width: 8px;
height: 8px;
border-radius: 50%;
background: #ffffff;
}
.li__board {
position: relative;
background: #fff7e6;
aspect-ratio: 300/88;
touch-action: none;
}
.li__sketch, .li__ink, .li__ghost {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
}
.li__sketch {
opacity: 0.34;
}
.li__ghost {
display: none;
fill: none;
}
.li__guide {
opacity: 0.34;
}
.li__guideCurve {
stroke: rgba(23, 20, 26, 0.55);
stroke-width: 5;
stroke-linecap: round;
}
.li__guideRing {
stroke: rgba(23, 20, 26, 0.55);
stroke-width: 3;
stroke-dasharray: 6 5;
}
.li__guideBase {
stroke: rgba(23, 20, 26, 0.55);
stroke-width: 3;
stroke-dasharray: 6 5;
}
.li__trace {
stroke: #17141a;
stroke-width: 3.5;
stroke-linecap: round;
stroke-linejoin: round;
stroke-dasharray: 100;
stroke-dashoffset: 100.5;
}
.li__finish {
stroke: #17141a;
stroke-width: 9;
stroke-linecap: round;
stroke-dasharray: 100;
stroke-dashoffset: 100.5;
}
.li.is-demo .li__sketch, .li.is-demo .li__ink {
visibility: hidden;
}
.li.is-demo .li__ghost {
display: block;
}
.li.is-demo .li__range {
visibility: hidden;
}
.li.is-demo .li__fake {
display: block;
}
.li.is-demo .li__trace {
animation-name: liTrace;
animation-duration: 2s;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
@keyframes liTrace {
0%, 8% {
stroke-dashoffset: 100.5;
}
44%, 72% {
stroke-dashoffset: 0;
}
88%, 100% {
stroke-dashoffset: 100.5;
}
}
.li.is-demo .li__finish {
animation-name: liFinish;
animation-duration: 2s;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
@keyframes liFinish {
0%, 46% {
stroke-dashoffset: 100.5;
}
50%, 66% {
stroke-dashoffset: 0;
}
80%, 100% {
stroke-dashoffset: 100.5;
}
}
.li.is-demo .li__guide {
animation-name: liFade;
animation-duration: 2s;
animation-timing-function: ease-out;
animation-iteration-count: infinite;
}
@keyframes liFade {
0%, 54% {
opacity: 0.34;
}
68%, 74% {
opacity: 0;
}
88%, 100% {
opacity: 0.34;
}
}
.li.is-demo .li__fakeKnob {
animation-name: liKnob;
animation-duration: 2s;
animation-timing-function: ease-out;
animation-iteration-count: infinite;
}
@keyframes liKnob {
0%, 54% {
transform: translateX(34%);
}
68%, 74% {
transform: translateX(0);
}
88%, 100% {
transform: translateX(34%);
}
}
.li.is-demo .li__fakeFill {
animation-name: liFill;
animation-duration: 2s;
animation-timing-function: ease-out;
animation-iteration-count: infinite;
}
@keyframes liFill {
0%, 54% {
transform: scaleX(0.34);
}
68%, 74% {
transform: scaleX(0);
}
88%, 100% {
transform: scaleX(0.34);
}
}
@media (max-width: 340px) {
.li__layers {
padding: 4px 8px;
}
.li__row {
gap: 4px;
}
}
@media (prefers-reduced-motion: reduce) {
.li.is-demo .li__trace,
.li.is-demo .li__finish,
.li.is-demo .li__guide,
.li.is-demo .li__fakeKnob,
.li.is-demo .li__fakeFill {
animation: none !important;
}
.li.is-demo .li__trace,
.li.is-demo .li__finish {
stroke-dashoffset: 0 !important;
}
.li.is-demo .li__guide {
opacity: 0 !important;
}
.li.is-demo .li__fakeKnob {
transform: translateX(0) !important;
}
.li.is-demo .li__fakeFill {
transform: scaleX(0) !important;
}
}var li = document.getElementById('li');
var board = document.getElementById('board');
var sketch = document.getElementById('sketch');
var ink = document.getElementById('ink');
var range = document.getElementById('fade');
var sketchBtn = document.getElementById('sketchBtn');
var inkBtn = document.getElementById('inkBtn');
var sketchCtx = sketch.getContext('2d');
var inkCtx = ink.getContext('2d');
var active = 'ink'; // 기본 활성 레이어는 インクレイヤー
var drawing = false;
// 고DPI 대응 — 두 판 다 배킹 스토어를 devicePixelRatio 배로 키우고 좌표계는 CSS 픽셀 그대로 쓴다
function resize() {
var r = board.getBoundingClientRect(), dpr = window.devicePixelRatio || 1;
[sketch, ink].forEach(function (c) {
c.width = Math.round(r.width * dpr);
c.height = Math.round(r.height * dpr);
c.getContext('2d').setTransform(dpr, 0, 0, dpr, 0, 0);
});
drawGuide(r.width);
}
// 下書き 판은 마운트 때 연한 안내선을 깔아 둔다 — globalAlpha 로 낮춘 잉크라 판 자체의 opacity 와 겹쳐 작동한다
function drawGuide(width) {
var s = width / 300; // プレビュー SVG 와 같은 300×88 좌표계로 그린다
sketchCtx.clearRect(0, 0, sketch.width, sketch.height);
sketchCtx.save();
sketchCtx.scale(s, s);
sketchCtx.globalAlpha = 0.55;
sketchCtx.strokeStyle = '#17141a';
sketchCtx.lineCap = 'round';
sketchCtx.lineWidth = 5;
sketchCtx.beginPath();
sketchCtx.moveTo(40, 70);
sketchCtx.bezierCurveTo(60, 18, 110, 14, 130, 46);
sketchCtx.bezierCurveTo(150, 78, 190, 80, 210, 54);
sketchCtx.bezierCurveTo(226, 33, 250, 26, 268, 36);
sketchCtx.stroke();
sketchCtx.lineWidth = 3;
sketchCtx.setLineDash([6, 5]);
sketchCtx.beginPath();
sketchCtx.arc(130, 46, 26, 0, Math.PI * 2);
sketchCtx.stroke();
sketchCtx.beginPath();
sketchCtx.moveTo(36, 78);
sketchCtx.lineTo(264, 78);
sketchCtx.stroke();
sketchCtx.restore();
}
function activeCtx() { return active === 'sketch' ? sketchCtx : inkCtx; }
function setActive(name) {
active = name;
sketchBtn.setAttribute('aria-pressed', name === 'sketch' ? 'true' : 'false');
inkBtn.setAttribute('aria-pressed', name === 'ink' ? 'true' : 'false');
}
function toLocal(e) {
var r = ink.getBoundingClientRect();
return { x: e.clientX - r.left, y: e.clientY - r.top };
}
board.addEventListener('pointerdown', function (e) {
li.classList.remove('is-demo');
var c = activeCtx(), p = toLocal(e);
drawing = true;
c.lineCap = 'round';
c.lineJoin = 'round';
c.globalAlpha = active === 'sketch' ? 0.55 : 1;
c.strokeStyle = '#17141a';
c.lineWidth = active === 'sketch' ? 4 : 3;
c.beginPath();
c.moveTo(p.x, p.y);
board.setPointerCapture(e.pointerId);
});
board.addEventListener('pointermove', function (e) {
if (!drawing) { return; }
var p = toLocal(e);
activeCtx().lineTo(p.x, p.y);
activeCtx().stroke();
});
window.addEventListener('pointerup', function () { drawing = false; });
// 슬라이더는 下書き 판의 CSS opacity 만 바꾼다 — 그린 것은 그대로 있고 보이기만 옅어진다
range.addEventListener('input', function () {
li.classList.remove('is-demo');
sketch.style.opacity = String(range.value / 100);
});
// 2枚を重ねる — 下書き 판을 지금 보이는 진하기 그대로 インク 판에 눌러 담고 下書き 판을 비운다
document.getElementById('mergeBtn').addEventListener('click', function () {
li.classList.remove('is-demo');
inkCtx.save();
inkCtx.setTransform(1, 0, 0, 1, 0, 0);
inkCtx.globalAlpha = Number(sketch.style.opacity || 0.34);
inkCtx.drawImage(sketch, 0, 0);
inkCtx.restore();
sketchCtx.clearRect(0, 0, sketch.width, sketch.height);
});
sketchBtn.addEventListener('click', function () { li.classList.remove('is-demo'); setActive('sketch'); });
inkBtn.addEventListener('click', function () { li.classList.remove('is-demo'); setActive('ink'); });
window.addEventListener('resize', resize);
resize();// SketchInkLayers — vanilla 판(run/287/demo/05-canvas-layer-ink/index.html) 의 React 포트.
// canvas 두 장을 겹친다 — 밑그림 판은 globalAlpha 를 낮춘 안내선을 미리 깔고, 활성 레이어에만 포인터 궤적을 그린다.
// 슬라이더는 밑그림 판의 CSS opacity 만 바꾸고, 「두 장 합치기」는 drawImage 로 밑그림을 잉크 판에 눌러 담는다.
import { useCallback, useEffect, useRef, useState } from "react";
import type { CSSProperties, PointerEvent as ReactPointerEvent } from "react";
import styles from "./SketchInkLayers.module.scss";
export type SketchInkLayersProps = {
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
const GUIDE_ALPHA = 0.55; // 밑그림 잉크 자체의 진하기 — canvas 안에서 한 번, CSS opacity 로 또 한 번 겹쳐 옅어진다
const DEFAULT_OPACITY = 0.34;
function applyGuide(ctx: CanvasRenderingContext2D, width: number, height: number): void {
// 미리보기 SVG 와 같은 300×88 좌표계로 그린다 — 판 폭이 변해도 모양 비율이 유지된다
const s = width / 300;
ctx.clearRect(0, 0, width, height);
ctx.save();
ctx.scale(s, s);
ctx.globalAlpha = GUIDE_ALPHA;
ctx.strokeStyle = "#17141a";
ctx.lineCap = "round";
ctx.lineWidth = 5;
ctx.beginPath();
ctx.moveTo(40, 70);
ctx.bezierCurveTo(60, 18, 110, 14, 130, 46);
ctx.bezierCurveTo(150, 78, 190, 80, 210, 54);
ctx.bezierCurveTo(226, 33, 250, 26, 268, 36);
ctx.stroke();
ctx.lineWidth = 3;
ctx.setLineDash([6, 5]);
ctx.beginPath();
ctx.arc(130, 46, 26, 0, Math.PI * 2);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(36, 78);
ctx.lineTo(264, 78);
ctx.stroke();
ctx.restore();
}
export default function SketchInkLayers({
duration = 2000,
easing = "ease-out",
color = "#17141a",
}: SketchInkLayersProps) {
const boardRef = useRef<HTMLDivElement | null>(null);
const sketchRef = useRef<HTMLCanvasElement | null>(null);
const inkRef = useRef<HTMLCanvasElement | null>(null);
const drawingRef = useRef(false);
const [demo, setDemo] = useState(true);
const [active, setActive] = useState("ink");
const [fade, setFade] = useState(34);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
const activeCtx = useCallback((): CanvasRenderingContext2D | null => {
const canvas = active === "sketch" ? sketchRef.current : inkRef.current;
if (!canvas) return null;
return canvas.getContext("2d");
}, [active]);
// 고DPI 대응 — 배킹 스토어를 devicePixelRatio 배로 키우고 좌표계는 CSS 픽셀 그대로 쓴다
const resize = useCallback(() => {
const board = boardRef.current;
if (!board) return;
const r = board.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
for (const canvas of [sketchRef.current, inkRef.current]) {
if (!canvas) continue;
canvas.width = Math.round(r.width * dpr);
canvas.height = Math.round(r.height * dpr);
const ctx = canvas.getContext("2d");
if (!ctx) continue;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
const sketch = sketchRef.current;
const sketchCtx = sketch ? sketch.getContext("2d") : null;
if (sketchCtx) applyGuide(sketchCtx, r.width, r.height);
}, []);
useEffect(() => {
resize();
window.addEventListener("resize", resize);
return () => window.removeEventListener("resize", resize);
}, [resize]);
useEffect(() => {
function onUp(): void {
drawingRef.current = false;
}
window.addEventListener("pointerup", onUp);
return () => window.removeEventListener("pointerup", onUp);
}, []);
function toLocal(event: ReactPointerEvent<HTMLDivElement>): { x: number; y: number } | null {
const canvas = inkRef.current;
if (!canvas) return null;
const r = canvas.getBoundingClientRect();
return { x: event.clientX - r.left, y: event.clientY - r.top };
}
function onPointerDown(event: ReactPointerEvent<HTMLDivElement>): void {
setDemo(false);
const board = boardRef.current;
const ctx = activeCtx();
const p = toLocal(event);
if (!board || !ctx || !p) return;
drawingRef.current = true;
ctx.lineCap = "round";
ctx.lineJoin = "round";
ctx.globalAlpha = active === "sketch" ? GUIDE_ALPHA : 1;
ctx.strokeStyle = color;
ctx.lineWidth = active === "sketch" ? 4 : 3;
ctx.beginPath();
ctx.moveTo(p.x, p.y);
board.setPointerCapture(event.pointerId);
}
function onPointerMove(event: ReactPointerEvent<HTMLDivElement>): void {
if (!drawingRef.current) return;
const ctx = activeCtx();
const p = toLocal(event);
if (!ctx || !p) return;
ctx.lineTo(p.x, p.y);
ctx.stroke();
}
function onFade(event: { target: EventTarget | null }): void {
setDemo(false);
const input = event.target as HTMLInputElement | null;
if (!input) return;
const value = Number(input.value);
setFade(value);
const sketch = sketchRef.current;
if (sketch) sketch.style.opacity = String(value / 100);
}
// 두 장 합치기 — 밑그림 판을 지금 보이는 진하기 그대로 잉크 판에 눌러 담고 밑그림 판을 비운다
function onMerge(): void {
setDemo(false);
const sketch = sketchRef.current;
const ink = inkRef.current;
if (!sketch || !ink) return;
const inkCtx = ink.getContext("2d");
if (!inkCtx) return;
inkCtx.save();
inkCtx.setTransform(1, 0, 0, 1, 0, 0);
inkCtx.globalAlpha = sketch.style.opacity ? Number(sketch.style.opacity) : DEFAULT_OPACITY;
inkCtx.drawImage(sketch, 0, 0);
inkCtx.restore();
const sketchCtx = sketch.getContext("2d");
if (sketchCtx) sketchCtx.clearRect(0, 0, sketch.width, sketch.height);
}
function onLayer(name: string): void {
setDemo(false);
setActive(name);
}
const cls = [styles.root, demo ? styles.isDemo : ""].filter(Boolean).join(" ");
return (
<main className={styles.stage}>
<div className={cls} style={vars}>
<div className={styles.card}>
<div className={styles.layers}>
<div className={styles.row}>
<button type="button" className={styles.layer} aria-pressed={active === "sketch"} onClick={() => onLayer("sketch")}>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor" className={styles.ico} aria-hidden="true"><path d="M224,128a96,96,0,1,1-96-96A96,96,0,0,1,224,128Z" opacity="0.2"/><path d="M96.26,37A8,8,0,0,1,102,27.29a104.11,104.11,0,0,1,52,0,8,8,0,0,1-2,15.75,8.15,8.15,0,0,1-2-.26,88.09,88.09,0,0,0-44,0A8,8,0,0,1,96.26,37ZM53.79,55.14a104,104,0,0,0-26,45,8,8,0,1,0,15.42,4.26,87.91,87.91,0,0,1,22-38.08A8,8,0,0,0,53.79,55.14ZM43.21,151.55a8,8,0,1,0-15.42,4.28,104,104,0,0,0,26,45,8,8,0,1,0,11.41-11.21A88.14,88.14,0,0,1,43.21,151.55ZM150,213.22a88,88,0,0,1-44,0,8,8,0,1,0-4,15.49,104.11,104.11,0,0,0,52,0,8,8,0,0,0-4-15.49ZM222.65,146a8,8,0,0,0-9.85,5.57,87.88,87.88,0,0,1-22,38.09,8,8,0,1,0,11.42,11.21,104.05,104.05,0,0,0,26-45A8,8,0,0,0,222.65,146Zm-9.86-41.54a8,8,0,0,0,15.42-4.28,104,104,0,0,0-26-45,8,8,0,1,0-11.41,11.21A88.14,88.14,0,0,1,212.79,104.45Z"/></svg>
<span>밑그림</span>
</button>
<span className={styles.slot}>
<input className={styles.range} type="range" min="0" max="100" value={fade} aria-label="밑그림 불투명도" onChange={onFade}></input>
<span className={styles.fake} aria-hidden="true"><span className={styles.fakeFill}></span><span className={styles.fakeKnob}></span></span>
</span>
</div>
<div className={styles.row}>
<button type="button" className={styles.layer} aria-pressed={active === "ink"} onClick={() => onLayer("ink")}>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor" className={styles.ico} aria-hidden="true"><path d="M221.66,90.34,192,120,136,64l29.66-29.66a8,8,0,0,1,11.31,0L221.66,79A8,8,0,0,1,221.66,90.34Z" opacity="0.2"/><path d="M227.32,73.37,182.63,28.69a16,16,0,0,0-22.63,0L36.69,152A15.86,15.86,0,0,0,32,163.31V208a16,16,0,0,0,16,16H92.69A15.86,15.86,0,0,0,104,219.31l83.67-83.66,3.48,13.9-36.8,36.79a8,8,0,0,0,11.31,11.32l40-40a8,8,0,0,0,2.11-7.6l-6.9-27.61L227.32,96A16,16,0,0,0,227.32,73.37ZM48,208V179.31L76.69,208Zm48-3.31L51.31,160,136,75.31,180.69,120Zm96-96L147.32,64l24-24L216,84.69Z"/></svg>
<span>잉크</span>
</button>
<button type="button" className={styles.merge} onClick={onMerge}>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor" className={styles.ico} aria-hidden="true"><path d="M224,80l-96,56L32,80l96-56Z" opacity="0.2"/><path d="M230.91,172A8,8,0,0,1,228,182.91l-96,56a8,8,0,0,1-8.06,0l-96-56A8,8,0,0,1,36,169.09l92,53.65,92-53.65A8,8,0,0,1,230.91,172ZM220,121.09l-92,53.65L36,121.09A8,8,0,0,0,28,134.91l96,56a8,8,0,0,0,8.06,0l96-56A8,8,0,1,0,220,121.09ZM24,80a8,8,0,0,1,4-6.91l96-56a8,8,0,0,1,8.06,0l96,56a8,8,0,0,1,0,13.82l-96,56a8,8,0,0,1-8.06,0l-96-56A8,8,0,0,1,24,80Zm23.88,0L128,126.74,208.12,80,128,33.26Z"/></svg>
<span>두 장 합치기</span>
</button>
</div>
</div>
<div className={styles.board} ref={boardRef} onPointerDown={onPointerDown} onPointerMove={onPointerMove}>
<canvas className={styles.sketch} ref={sketchRef} role="img" aria-label="밑그림 판" style={{ opacity: fade / 100 }}></canvas>
<canvas className={styles.ink} ref={inkRef} role="img" aria-label="잉크 판"></canvas>
<svg className={styles.ghost} viewBox="0 0 300 88" preserveAspectRatio="none" aria-hidden="true">
<g className={styles.guide}>
<path className={styles.guideCurve} d="M40,70 C60,18 110,14 130,46 C150,78 190,80 210,54 C226,33 250,26 268,36"/>
<circle className={styles.guideRing} cx="130" cy="46" r="26"/>
<line className={styles.guideBase} x1="36" y1="78" x2="264" y2="78"/>
</g>
<path className={styles.trace} pathLength="100" d="M40,70 C60,18 110,14 130,46 C150,78 190,80 210,54 C226,33 250,26 268,36"/>
<line className={styles.finish} pathLength="100" x1="36" y1="78" x2="264" y2="78"/>
</svg>
</div>
</div>
</div>
</main>
);
}05 下書きとインクの2レイヤー — 計測 累積面積 4.01% · 動いたフレーム 21/23GODRICH オリジナル · SCSS と React は zip に同梱 · canvas 手書きサインと描画UI 9選 — ライブラリなし
このデモが属する記事
canvas 手書きサインと描画UI 9選 — ライブラリなしcanvas 手書きサインとは、契約書や同意書で手書きの署名を受け取る入力欄のことです。この9つはライブラリなしで、ブラウザ標準のcanvasとPointer Eventsだけを使って作ってあります。ファイルを貼り付ければ、筆圧による太さ・ この記事のデモをすべて見る


