02줄 번호 거터와 현재 줄
왼쪽 거터의 번호가 입력 줄 수에 맞춰 늘고 줄며, 커서가 있는 줄만 배경이 켜집니다. 줄이 바뀔 때마다 거터도 같은 만큼 스크롤합니다.
쓰임오류 줄을 짚어야 하는 편집기
counter-resetselectionStarttranslateY

<main class="stage">
<div class="gt is-demo" id="gt">
<div class="gt__bar">
<span class="gt__ico" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 256 256"><g fill="currentColor"><path d="M216 64v128H104V64Z" opacity=".2"/><path d="M224 128a8 8 0 0 1-8 8H104a8 8 0 0 1 0-16h112a8 8 0 0 1 8 8M104 72h112a8 8 0 0 0 0-16H104a8 8 0 0 0 0 16m112 112H104a8 8 0 0 0 0 16h112a8 8 0 0 0 0-16M43.58 55.16L48 52.94V104a8 8 0 0 0 16 0V40a8 8 0 0 0-11.58-7.16l-16 8a8 8 0 0 0 7.16 14.32m36.19 101.56a23.73 23.73 0 0 0-9.6-15.95a24.86 24.86 0 0 0-34.11 4.7a23.6 23.6 0 0 0-3.57 6.46a8 8 0 1 0 15 5.47a7.8 7.8 0 0 1 1.18-2.13a8.76 8.76 0 0 1 12-1.59a7.9 7.9 0 0 1 3.26 5.32a7.64 7.64 0 0 1-1.57 5.78a1 1 0 0 0-.08.11l-28.69 38.32A8 8 0 0 0 40 216h32a8 8 0 0 0 0-16H56l19.08-25.53a23.47 23.47 0 0 0 4.69-17.75"/></g></svg></span>
<span class="gt__name">editor.scss</span>
<span class="gt__pill">
<span class="gt__count" id="count">6줄</span>
<span class="gt__cv gt__cv--1" aria-hidden="true">3줄</span>
<span class="gt__cv gt__cv--2" aria-hidden="true">4줄</span>
<span class="gt__cv gt__cv--3" aria-hidden="true">5줄</span>
<span class="gt__cv gt__cv--4" aria-hidden="true">6줄</span>
<span class="gt__cv gt__cv--5" aria-hidden="true">5줄</span>
</span>
</div>
<div class="gt__body">
<div class="gt__gut" aria-hidden="true">
<div class="gt__gtrack" id="gtrack"></div>
</div>
<div class="gt__wrap" id="wrap">
<div class="gt__track" id="track">
<span class="gt__cur" id="cur" aria-hidden="true"></span>
<pre class="gt__hl" id="hl" aria-hidden="true"></pre>
</div>
<textarea class="gt__ta" id="ta" spellcheck="false" autocomplete="off" wrap="off" aria-label="코드 입력칸">/* gutter demo */
.panel {
margin: 8px;
line-height: 20px;
transform: translateY(4px);
}</textarea>
</div>
</div>
<p class="gt__note">Enter 를 치면 번호가 늘고, 커서가 있는 줄만 밝아집니다</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 줄 번호 거터와 현재 줄 — 왼쪽 거터의 번호가 입력 줄 수에 맞춰 늘고 줄며,
// selectionStart 로 계산한 커서 줄만 배경이 켜진다. 본문(textarea)이 스크롤하면
// 거터 트랙도 같은 값으로 translateY 해 붙어 다닌다.
// 번호 자체는 CSS 카운터(counter-reset)가 매긴다 — JS 는 줄이 바뀔 때 빈 칸만 다시 그린다.
// 격자 미리보기(.is-demo)는 줄과 번호를 steps(1, end) 컷으로 하나씩 켜고,
// 현재 줄 밴드는 매끈하게 미끄러지며, 뒤에서 한 줄 스크롤 동기가 같이 움직인다.
$duration: $dur-quick;
$easing: $ease-spring;
$color: $stage-yellow;
$mono: ui-monospace, SFMono-Regular, "SF Mono", Consolas, "Liberation Mono", monospace;
$line: rgba(255, 255, 255, .16);
$dim: #beb7ad;
$fs: clamp(11px, 3vw, 13px);
$lh: 20px;
@include stage($stage-ink);
.gt {
display: flex; flex-direction: column; gap: $sp-2;
box-sizing: border-box; width: min(100%, 400px);
padding: $sp-3; border: 1px solid $line; border-radius: $r-card;
background: rgba(255, 255, 255, .05);
}
// ── 파일 이름 줄
.gt__bar { display: flex; align-items: center; gap: $sp-2; }
.gt__ico { display: grid; place-items: center; color: $color; font-size: 15px; }
.gt__name {
flex: 1 1 auto; color: $subject-cream;
font-family: $mono; font-weight: 700; font-size: 12px; line-height: 1.4;
}
// 줄 수 알약 — 실제 값은 JS 가 채우고, 미리보기에서는 겹켜진 값 장이 차례로 켜진다
.gt__pill { display: grid; justify-items: center; }
.gt__pill > * { grid-area: 1 / 1; }
.gt__count, .gt__cv {
padding: $sp-1 $sp-2; border-radius: $r-pill;
background: rgba($color, .14); color: $color;
font-family: $mono; font-weight: 700; font-size: 11px; line-height: 1.4;
white-space: nowrap;
}
.gt__cv { opacity: 0; }
// ── 거터 + 본문 몸통
.gt__body {
display: grid; grid-template-columns: 36px 1fr;
background: rgba(0, 0, 0, .28); border-radius: $r-sm; overflow: hidden;
}
.gt__gut { background: rgba(0, 0, 0, .35); border-right: 1px solid $line; overflow: hidden; }
.gt__gtrack {
position: relative; box-sizing: border-box; padding: $sp-2 0 $sp-2 $sp-2;
counter-reset: gtline;
font-family: $mono; font-weight: 500; font-size: $fs; line-height: $lh;
}
.gt__num {
position: relative; z-index: 1; display: block; height: $lh;
padding-right: $sp-2; text-align: right; color: $dim;
}
.gt__num::before { counter-increment: gtline; content: counter(gtline); }
// 거터 쪽 현재 줄 밴드 — 본문 밴드와 같은 translateY 값을 받아 같이 움직인다
.gt__gcur {
position: absolute; top: $sp-2; left: 0; right: 0; height: $lh;
background: rgba($color, .18); border-radius: 0 $r-xs $r-xs 0;
transition: transform $duration $easing;
}
// ── 본문. 글자는 투명한 textarea 아래 pre 가 그린다 — 서체·행간·패딩이 글자 단위로 같아야 한다
.gt__wrap {
position: relative; display: grid; overflow: hidden;
height: 5 * $lh + 2 * $sp-2; // 5줄이 온전히 보이고 마지막 줄이 조금 엿보인다 — 스크롤 여유 표시
}
.gt__wrap > * { grid-area: 1 / 1; min-height: 0; }
// textarea 는 격자 칸 높이에 **묶어 둔다**. 내용만큼 늘어나게 두면 스크롤하는 것은 textarea 가 아니라
// 바깥 상자가 되고, scrollTop 을 읽어 거터를 미는 동기 코드가 영영 0 만 읽는다 (실측 2026-09-13:
// 12줄을 넣어도 ta.scrollHeight == ta.clientHeight 였다)
.gt__ta { height: 100%; }
.gt__track { position: relative; align-self: start; box-sizing: border-box; padding: $sp-2; }
.gt__cur {
position: absolute; top: $sp-2; left: 0; right: 0; height: $lh;
background: rgba($color, .18); border-radius: 0 $r-xs $r-xs 0;
transition: transform $duration $easing;
}
.gt__hl {
position: relative; z-index: 1; margin: 0;
font-family: $mono; font-weight: 500; font-size: $fs; line-height: $lh;
letter-spacing: 0; tab-size: 2; white-space: pre; overflow: hidden;
color: $subject-cream;
}
.gt__ln { display: block; }
// 글자는 투명하고 커서만 남는다 — 색은 아래 pre 가 그린다
.gt__ta {
box-sizing: border-box; margin: 0; padding: $sp-2; border: 0; background: transparent;
color: transparent; caret-color: $color; resize: none;
font-family: $mono; font-weight: 500; font-size: $fs; line-height: $lh;
letter-spacing: 0; tab-size: 2; white-space: pre; overflow: auto;
scrollbar-width: none;
}
.gt__ta::-webkit-scrollbar { display: none; }
.gt__ta::selection { background: rgba($color, .32); }
.gt__ta:focus-visible { outline: 2px solid $subject-blue; outline-offset: -2px; }
.gt__note { margin: 0; color: $dim; font-weight: 500; font-size: 11px; line-height: 1.4; }
// ── 격자 미리보기 루프 (2초)
// 1~3줄은 곧바로, 4·5줄은 타이핑하듯 늦게 켜지고 6줄이 더해졌다가 줄어든다.
// 켜짐·꺼짐은 전부 steps(1, end) 컷 — 점프 지점(3%)을 따로 둬야 첫 장에 값이 보인다.
.gt.is-demo .gt__ln, .gt.is-demo .gt__num {
animation-name: gtLine;
animation-duration: $dur-loop; animation-timing-function: steps(1, end);
animation-iteration-count: infinite; animation-fill-mode: backwards;
}
.gt.is-demo .gt__ln:nth-child(1), .gt.is-demo .gt__num:nth-child(1) { animation-delay: 0s; }
.gt.is-demo .gt__ln:nth-child(2), .gt.is-demo .gt__num:nth-child(2) { animation-delay: .04s; }
.gt.is-demo .gt__ln:nth-child(3), .gt.is-demo .gt__num:nth-child(3) { animation-delay: .08s; }
.gt.is-demo .gt__ln:nth-child(4), .gt.is-demo .gt__num:nth-child(4) { animation-delay: .3s; }
.gt.is-demo .gt__ln:nth-child(5), .gt.is-demo .gt__num:nth-child(5) { animation-delay: .42s; }
// 6줄과 그 번호는 한 바퀴 안에서 늘었다가 줄어든다 — 지연 없이 절대 시점 키프레임으로
.gt.is-demo .gt__ln:nth-child(6), .gt.is-demo .gt__num:nth-child(6) { animation-name: gtLineCut; }
// 줄 수 알약 — 실제 값은 감추고 겹켜진 값 장을 컷으로 갈아 끼운다
.gt.is-demo .gt__count { color: transparent; }
.gt.is-demo .gt__cv {
animation-duration: $dur-loop; animation-timing-function: steps(1, end);
animation-iteration-count: infinite; animation-fill-mode: backwards;
}
.gt.is-demo .gt__cv--1 { animation-name: gtCv1; }
.gt.is-demo .gt__cv--2 { animation-name: gtCv2; }
.gt.is-demo .gt__cv--3 { animation-name: gtCv3; }
.gt.is-demo .gt__cv--4 { animation-name: gtCv4; }
.gt.is-demo .gt__cv--5 { animation-name: gtCv5; }
// 현재 줄 밴드 — 나타남은 컷, 줄 옮김은 매끈한 transform 이동
.gt.is-demo .gt__cur, .gt.is-demo .gt__gcur {
animation-name: gtCurO, gtCurT;
animation-duration: $dur-loop, $dur-loop;
animation-timing-function: steps(1, end), $easing;
animation-iteration-count: infinite, infinite;
animation-fill-mode: backwards, backwards;
transition: none;
}
// 스크롤 동기 — 본문 트랙과 거터 트랙이 같은 값으로 한 줄 올라갔다 내려온다
.gt.is-demo .gt__track, .gt.is-demo .gt__gtrack {
animation-name: gtScroll;
animation-duration: $dur-loop; animation-timing-function: $ease-out;
animation-iteration-count: infinite; animation-fill-mode: backwards;
}
@keyframes gtLine {
0% { opacity: 0; }
3%, 100% { opacity: 1; }
}
@keyframes gtLineCut {
0%, 29% { opacity: 0; }
30%, 79% { opacity: 1; }
80%, 100% { opacity: 0; }
}
@keyframes gtCv1 { 0%, 17% { opacity: 1; } 18%, 100% { opacity: 0; } }
@keyframes gtCv2 { 0%, 17% { opacity: 0; } 18%, 23% { opacity: 1; } 24%, 100% { opacity: 0; } }
@keyframes gtCv3 { 0%, 23% { opacity: 0; } 24%, 29% { opacity: 1; } 30%, 100% { opacity: 0; } }
@keyframes gtCv4 { 0%, 29% { opacity: 0; } 30%, 79% { opacity: 1; } 80%, 100% { opacity: 0; } }
@keyframes gtCv5 { 0%, 79% { opacity: 0; } 80%, 100% { opacity: 1; } }
@keyframes gtCurO {
0% { opacity: 0; }
12%, 100% { opacity: 1; }
}
// 셋째 줄에서 타이핑을 따라 넷째·다섯째·여섯째 줄로 내려간 뒤, 클릭해 셋째 줄로 돌아온다
@keyframes gtCurT {
0%, 12% { transform: translateY(40px); }
21%, 24% { transform: translateY(60px); }
27%, 30% { transform: translateY(80px); }
33%, 44% { transform: translateY(100px); }
50%, 100% { transform: translateY(40px); }
}
@keyframes gtScroll {
0%, 65% { transform: translateY(0); }
70%, 74% { transform: translateY(-20px); }
79%, 100% { transform: translateY(0); }
}
// ── 좁은 화면: 여백과 보이는 줄 수를 줄여 무대 안에 들어온다 (줄 높이 20px 는 그대로)
@media (max-width: 340px) {
.gt { gap: $sp-1; padding: $sp-2; }
.gt__note { display: none; } // 좁은 화면에서는 안내문을 접어 무대(200px) 안에 들어오게 한다
.gt__body { grid-template-columns: 28px 1fr; }
.gt__wrap { height: 3 * $lh + 2 * $sp-1; }
.gt__gtrack { padding: $sp-1 0 $sp-1 $sp-1; font-size: 11px; }
.gt__num { padding-right: $sp-1; }
.gt__track { padding: $sp-1; }
.gt__cur, .gt__gcur { top: $sp-1; }
.gt__hl { font-size: 11px; }
.gt__ta { padding: $sp-1; font-size: 11px; }
.gt__note { font-size: 10px; }
}
@media (prefers-reduced-motion: reduce) {
.gt.is-demo .gt__ln, .gt.is-demo .gt__num, .gt.is-demo .gt__cv,
.gt.is-demo .gt__cur, .gt.is-demo .gt__gcur, .gt.is-demo .gt__track, .gt.is-demo .gt__gtrack {
animation: none !important;
}
.gt.is-demo .gt__ln, .gt.is-demo .gt__num { opacity: 1 !important; }
.gt.is-demo .gt__cv { opacity: 0 !important; }
.gt.is-demo .gt__count { color: $color !important; }
.gt.is-demo .gt__cur, .gt.is-demo .gt__gcur { 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);
}
.gt {
display: flex;
flex-direction: column;
gap: 8px;
box-sizing: border-box;
width: min(100%, 400px);
padding: 12px;
border: 1px solid rgba(255, 255, 255, 0.16);
border-radius: 16px;
background: rgba(255, 255, 255, 0.05);
}
.gt__bar {
display: flex;
align-items: center;
gap: 8px;
}
.gt__ico {
display: grid;
place-items: center;
color: #ffd23f;
font-size: 15px;
}
.gt__name {
flex: 1 1 auto;
color: #fff7e6;
font-family: ui-monospace, SFMono-Regular, "SF Mono", Consolas, "Liberation Mono", monospace;
font-weight: 700;
font-size: 12px;
line-height: 1.4;
}
.gt__pill {
display: grid;
justify-items: center;
}
.gt__pill > * {
grid-area: 1/1;
}
.gt__count, .gt__cv {
padding: 4px 8px;
border-radius: 999px;
background: rgba(255, 210, 63, 0.14);
color: #ffd23f;
font-family: ui-monospace, SFMono-Regular, "SF Mono", Consolas, "Liberation Mono", monospace;
font-weight: 700;
font-size: 11px;
line-height: 1.4;
white-space: nowrap;
}
.gt__cv {
opacity: 0;
}
.gt__body {
display: grid;
grid-template-columns: 36px 1fr;
background: rgba(0, 0, 0, 0.28);
border-radius: 8px;
overflow: hidden;
}
.gt__gut {
background: rgba(0, 0, 0, 0.35);
border-right: 1px solid rgba(255, 255, 255, 0.16);
overflow: hidden;
}
.gt__gtrack {
position: relative;
box-sizing: border-box;
padding: 8px 0 8px 8px;
counter-reset: gtline;
font-family: ui-monospace, SFMono-Regular, "SF Mono", Consolas, "Liberation Mono", monospace;
font-weight: 500;
font-size: clamp(11px, 3vw, 13px);
line-height: 20px;
}
.gt__num {
position: relative;
z-index: 1;
display: block;
height: 20px;
padding-right: 8px;
text-align: right;
color: #beb7ad;
}
.gt__num::before {
counter-increment: gtline;
content: counter(gtline);
}
.gt__gcur {
position: absolute;
top: 8px;
left: 0;
right: 0;
height: 20px;
background: rgba(255, 210, 63, 0.18);
border-radius: 0 4px 4px 0;
transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.gt__wrap {
position: relative;
display: grid;
overflow: hidden;
height: 116px;
}
.gt__wrap > * {
grid-area: 1/1;
min-height: 0;
}
.gt__ta {
height: 100%;
}
.gt__track {
position: relative;
align-self: start;
box-sizing: border-box;
padding: 8px;
}
.gt__cur {
position: absolute;
top: 8px;
left: 0;
right: 0;
height: 20px;
background: rgba(255, 210, 63, 0.18);
border-radius: 0 4px 4px 0;
transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.gt__hl {
position: relative;
z-index: 1;
margin: 0;
font-family: ui-monospace, SFMono-Regular, "SF Mono", Consolas, "Liberation Mono", monospace;
font-weight: 500;
font-size: clamp(11px, 3vw, 13px);
line-height: 20px;
letter-spacing: 0;
tab-size: 2;
white-space: pre;
overflow: hidden;
color: #fff7e6;
}
.gt__ln {
display: block;
}
.gt__ta {
box-sizing: border-box;
margin: 0;
padding: 8px;
border: 0;
background: transparent;
color: transparent;
caret-color: #ffd23f;
resize: none;
font-family: ui-monospace, SFMono-Regular, "SF Mono", Consolas, "Liberation Mono", monospace;
font-weight: 500;
font-size: clamp(11px, 3vw, 13px);
line-height: 20px;
letter-spacing: 0;
tab-size: 2;
white-space: pre;
overflow: auto;
scrollbar-width: none;
}
.gt__ta::-webkit-scrollbar {
display: none;
}
.gt__ta::selection {
background: rgba(255, 210, 63, 0.32);
}
.gt__ta:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: -2px;
}
.gt__note {
margin: 0;
color: #beb7ad;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
}
.gt.is-demo .gt__ln, .gt.is-demo .gt__num {
animation-name: gtLine;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.gt.is-demo .gt__ln:nth-child(1), .gt.is-demo .gt__num:nth-child(1) {
animation-delay: 0s;
}
.gt.is-demo .gt__ln:nth-child(2), .gt.is-demo .gt__num:nth-child(2) {
animation-delay: 0.04s;
}
.gt.is-demo .gt__ln:nth-child(3), .gt.is-demo .gt__num:nth-child(3) {
animation-delay: 0.08s;
}
.gt.is-demo .gt__ln:nth-child(4), .gt.is-demo .gt__num:nth-child(4) {
animation-delay: 0.3s;
}
.gt.is-demo .gt__ln:nth-child(5), .gt.is-demo .gt__num:nth-child(5) {
animation-delay: 0.42s;
}
.gt.is-demo .gt__ln:nth-child(6), .gt.is-demo .gt__num:nth-child(6) {
animation-name: gtLineCut;
}
.gt.is-demo .gt__count {
color: transparent;
}
.gt.is-demo .gt__cv {
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.gt.is-demo .gt__cv--1 {
animation-name: gtCv1;
}
.gt.is-demo .gt__cv--2 {
animation-name: gtCv2;
}
.gt.is-demo .gt__cv--3 {
animation-name: gtCv3;
}
.gt.is-demo .gt__cv--4 {
animation-name: gtCv4;
}
.gt.is-demo .gt__cv--5 {
animation-name: gtCv5;
}
.gt.is-demo .gt__cur, .gt.is-demo .gt__gcur {
animation-name: gtCurO, gtCurT;
animation-duration: 2s, 2s;
animation-timing-function: steps(1, end), cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite, infinite;
animation-fill-mode: backwards, backwards;
transition: none;
}
.gt.is-demo .gt__track, .gt.is-demo .gt__gtrack {
animation-name: gtScroll;
animation-duration: 2s;
animation-timing-function: ease-out;
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
@keyframes gtLine {
0% {
opacity: 0;
}
3%, 100% {
opacity: 1;
}
}
@keyframes gtLineCut {
0%, 29% {
opacity: 0;
}
30%, 79% {
opacity: 1;
}
80%, 100% {
opacity: 0;
}
}
@keyframes gtCv1 {
0%, 17% {
opacity: 1;
}
18%, 100% {
opacity: 0;
}
}
@keyframes gtCv2 {
0%, 17% {
opacity: 0;
}
18%, 23% {
opacity: 1;
}
24%, 100% {
opacity: 0;
}
}
@keyframes gtCv3 {
0%, 23% {
opacity: 0;
}
24%, 29% {
opacity: 1;
}
30%, 100% {
opacity: 0;
}
}
@keyframes gtCv4 {
0%, 29% {
opacity: 0;
}
30%, 79% {
opacity: 1;
}
80%, 100% {
opacity: 0;
}
}
@keyframes gtCv5 {
0%, 79% {
opacity: 0;
}
80%, 100% {
opacity: 1;
}
}
@keyframes gtCurO {
0% {
opacity: 0;
}
12%, 100% {
opacity: 1;
}
}
@keyframes gtCurT {
0%, 12% {
transform: translateY(40px);
}
21%, 24% {
transform: translateY(60px);
}
27%, 30% {
transform: translateY(80px);
}
33%, 44% {
transform: translateY(100px);
}
50%, 100% {
transform: translateY(40px);
}
}
@keyframes gtScroll {
0%, 65% {
transform: translateY(0);
}
70%, 74% {
transform: translateY(-20px);
}
79%, 100% {
transform: translateY(0);
}
}
@media (max-width: 340px) {
.gt {
gap: 4px;
padding: 8px;
}
.gt__note {
display: none;
}
.gt__body {
grid-template-columns: 28px 1fr;
}
.gt__wrap {
height: 68px;
}
.gt__gtrack {
padding: 4px 0 4px 4px;
font-size: 11px;
}
.gt__num {
padding-right: 4px;
}
.gt__track {
padding: 4px;
}
.gt__cur, .gt__gcur {
top: 4px;
}
.gt__hl {
font-size: 11px;
}
.gt__ta {
padding: 4px;
font-size: 11px;
}
.gt__note {
font-size: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.gt.is-demo .gt__ln, .gt.is-demo .gt__num, .gt.is-demo .gt__cv,
.gt.is-demo .gt__cur, .gt.is-demo .gt__gcur, .gt.is-demo .gt__track, .gt.is-demo .gt__gtrack {
animation: none !important;
}
.gt.is-demo .gt__ln, .gt.is-demo .gt__num {
opacity: 1 !important;
}
.gt.is-demo .gt__cv {
opacity: 0 !important;
}
.gt.is-demo .gt__count {
color: #ffd23f !important;
}
.gt.is-demo .gt__cur, .gt.is-demo .gt__gcur {
opacity: 1 !important;
}
}var gt = document.getElementById('gt');
var ta = document.getElementById('ta');
var hl = document.getElementById('hl');
var wrap = document.getElementById('wrap');
var track = document.getElementById('track');
var gtrack = document.getElementById('gtrack');
var countEl = document.getElementById('count');
var curEl = document.getElementById('cur');
var gcurEl = null;
var LBL_COUNT = '{n}줄';
function esc(t) {
return t.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
}
function lineH() {
var v = parseFloat(getComputedStyle(ta).lineHeight);
return v > 0 ? v : 20;
}
// 줄 수가 바뀔 때마다 거터를 다시 그린다 — 번호 매김은 scss 의 counter-reset 카운터가 한다
function render() {
var lines = ta.value.split('\n');
var nums = '';
lines.forEach(function () { nums += '<span class="gt__num"></span>'; });
gtrack.innerHTML = nums + '<span class="gt__gcur"></span>';
gcurEl = gtrack.querySelector('.gt__gcur');
hl.innerHTML = lines.map(function (l) {
return '<span class="gt__ln">' + (esc(l) || ' ') + '</span>';
}).join('');
countEl.textContent = LBL_COUNT.replace('{n}', lines.length);
update();
}
// selectionStart 까지의 글자에서 몇째 줄인지 계산해 두 밴드를 같은 값으로 옮긴다
function update() {
var idx = ta.value.slice(0, ta.selectionStart).split('\n').length - 1;
var y = idx * lineH();
curEl.style.transform = 'translateY(' + y + 'px)';
if (gcurEl) gcurEl.style.transform = 'translateY(' + y + 'px)';
sync();
}
// 본문이 스크롤하면 거터 트랙도 같은 값으로 translateY — 두 겹이 어긋나지 않는 핵심
function sync() {
var y = -ta.scrollTop;
track.style.transform = 'translateY(' + y + 'px)';
gtrack.style.transform = 'translateY(' + y + 'px)';
}
ta.addEventListener('input', function () { gt.classList.remove('is-demo'); render(); });
ta.addEventListener('keyup', function () { gt.classList.remove('is-demo'); update(); });
ta.addEventListener('click', function () { gt.classList.remove('is-demo'); update(); });
ta.addEventListener('scroll', sync);
ta.addEventListener('focus', function () { gt.classList.remove('is-demo'); update(); });
wrap.addEventListener('pointerdown', function () { gt.classList.remove('is-demo'); });
render();
// 시작 커서는 셋째 줄 끝 — 손을 대지 않은 첫 화면부터 현재 줄 밴드가 보인다
var p = ta.value.split('\n').slice(0, 3).join('\n').length;
ta.setSelectionRange(p, p);
update();// LineNumberGutter — vanilla 판(run/299/demo/02-editor-line-number-gutter/index.html) 의 React 포트.
// 왼쪽 거터의 번호가 입력 줄 수를 따라 늘고 줄며, selectionStart 로 잰 커서 줄만 배경이 켜진다.
// 본문이 스크롤하면 거터 트랙도 같은 translateY 값을 받아 붙어 다닌다.
// 번호 자체는 scss 의 counter-reset 이 매긴다 — 여기서는 빈 칸만 줄 수만큼 그린다.
import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./LineNumberGutter.module.scss";
export type LineNumberGutterProps = {
duration?: number;
easing?: string;
color?: string;
initialCode?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
// useRef<T> 는 T | null 을 돌려준다 — ambient 타입에 맞춰 상자 타입을 따로 둔다
type Box<T> = { current: T | null };
const LABEL = "코드 입력칸";
const NOTE = "Enter 를 치면 번호가 늘고, 커서가 있는 줄만 밝아집니다";
const FILE_NAME = "editor.scss";
const LBL_COUNT = "{n}줄";
// 미리보기에서 겹켜놓고 컷으로 갈아 끼우는 줄 수 값 장 — vanilla 와 같은 글자다
const COUNT_VIEWS = ["3줄", "4줄", "5줄", "6줄", "5줄"];
const CODE = [
"/* gutter demo */",
".panel {",
" margin: 8px;",
" line-height: 20px;",
" transform: translateY(4px);",
"}",
].join("\n");
export default function LineNumberGutter(props: LineNumberGutterProps) {
const duration = props.duration ?? 220;
const easing = props.easing ?? "cubic-bezier(.2, .8, .2, 1)";
const color = props.color ?? "#ffd23f";
const [code, setCode] = useState<string>(props.initialCode ?? CODE);
const [preview, setPreview] = useState<boolean>(true);
const [caretY, setCaretY] = useState<number>(0);
const [scrollY, setScrollY] = useState<number>(0);
const taRef = useRef<HTMLTextAreaElement>(null) as Box<HTMLTextAreaElement>;
const lines = code.split("\n");
// 본문이 스크롤하면 두 트랙을 같은 값으로 옮긴다 — 두 겹이 어긋나지 않는 핵심
const sync = () => {
const ta = taRef.current;
if (ta) setScrollY(-ta.scrollTop);
};
// selectionStart 까지의 글자에서 몇째 줄인지 계산해 두 밴드를 같은 값으로 옮긴다
const update = () => {
const ta = taRef.current;
if (!ta) return;
const raw = parseFloat(getComputedStyle(ta).lineHeight);
const lh = raw > 0 ? raw : 20;
const idx = ta.value.slice(0, ta.selectionStart).split("\n").length - 1;
setCaretY(idx * lh);
setScrollY(-ta.scrollTop);
};
// 시작 커서는 셋째 줄 끝 — 손을 대지 않은 첫 화면부터 현재 줄 밴드가 보인다
useEffect(() => {
const ta = taRef.current;
if (!ta) return;
const p = ta.value.split("\n").slice(0, 3).join("\n").length;
ta.setSelectionRange(p, p);
update();
}, []);
const leave = () => setPreview(false);
const vars: Vars = { "--duration": duration + "ms", "--easing": easing, "--color": color };
const bandStyle: CSSProperties = { transform: "translateY(" + caretY + "px)" };
const trackStyle: CSSProperties = { transform: "translateY(" + scrollY + "px)" };
return (
<main className={styles.stage}>
<div className={styles.root + (preview ? " " + styles.isDemo : "")} style={vars}>
<div className={styles.bar}>
<span className={styles.ico} aria-hidden="true">
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 256 256">
<g fill="currentColor">
<path d="M216 64v128H104V64Z" opacity=".2" />
<path d="M224 128a8 8 0 0 1-8 8H104a8 8 0 0 1 0-16h112a8 8 0 0 1 8 8M104 72h112a8 8 0 0 0 0-16H104a8 8 0 0 0 0 16m112 112H104a8 8 0 0 0 0 16h112a8 8 0 0 0 0-16M43.58 55.16L48 52.94V104a8 8 0 0 0 16 0V40a8 8 0 0 0-11.58-7.16l-16 8a8 8 0 0 0 7.16 14.32m36.19 101.56a23.73 23.73 0 0 0-9.6-15.95a24.86 24.86 0 0 0-34.11 4.7a23.6 23.6 0 0 0-3.57 6.46a8 8 0 1 0 15 5.47a7.8 7.8 0 0 1 1.18-2.13a8.76 8.76 0 0 1 12-1.59a7.9 7.9 0 0 1 3.26 5.32a7.64 7.64 0 0 1-1.57 5.78a1 1 0 0 0-.08.11l-28.69 38.32A8 8 0 0 0 40 216h32a8 8 0 0 0 0-16H56l19.08-25.53a23.47 23.47 0 0 0 4.69-17.75" />
</g>
</svg>
</span>
<span className={styles.name}>{FILE_NAME}</span>
<span className={styles.pill}>
<span className={styles.count}>{LBL_COUNT.replace("{n}", String(lines.length))}</span>
{COUNT_VIEWS.map((text, i) => (
<span key={i} className={styles.cv + " " + styles["cv" + (i + 1)]} aria-hidden="true">
{text}
</span>
))}
</span>
</div>
<div className={styles.body}>
<div className={styles.gut} aria-hidden="true">
<div className={styles.gtrack} style={trackStyle}>
{lines.map((_line, i) => (
<span key={i} className={styles.num} />
))}
<span className={styles.gcur} style={bandStyle} />
</div>
</div>
<div className={styles.wrap} onPointerDown={leave}>
<div className={styles.track} style={trackStyle}>
<span className={styles.cur} style={bandStyle} aria-hidden="true" />
<pre className={styles.hl} aria-hidden="true">
{lines.map((line, i) => (
<span key={i} className={styles.ln}>
{line || " "}
</span>
))}
</pre>
</div>
<textarea
className={styles.ta}
ref={taRef}
value={code}
spellCheck={false}
autoComplete="off"
wrap="off"
aria-label={LABEL}
onChange={(e: { target: { value: string } }) => {
leave();
setCode(e.target.value);
update();
}}
onKeyUp={() => {
leave();
update();
}}
onClick={() => {
leave();
update();
}}
onFocus={() => {
leave();
update();
}}
onScroll={sync}
/>
</div>
</div>
<p className={styles.note}>{NOTE}</p>
</div>
</main>
);
}02 줄 번호 거터와 현재 줄 — 측정 누적면적 24.353% · 움직인 프레임 14/23GODRICH 원작 · zip 에 SCSS·React 포함 · 코드 에디터 만들기 9종 — 온라인 코드 편집기 핵심 기법
이 데모가 속한 글
코드 에디터 만들기 9종 — 온라인 코드 편집기 핵심 기법코드 에디터 만들기는 웹 페이지 안에서 코드를 치면 색이 입고 줄 번호가 붙고 괄호가 저절로 닫히는 입력칸을 짜는 일입니다. 아홉 개를 라이브러리 없이 textarea 하나로 만들었고, 아래 격자에서 지금 돌아갑니다 이 글의 데모 전부 보기


