09リッチテキストトースト — 書式の種類数だけラベル幅を計算し直す通知
コピーされた内容に混ざる書式の種類(太字・斜体・コードなど)を数え、その数だけトーストのラベル幅を計算し直して広げ、種類バッジが順に並びます。
使いどころエディタの書式コピー確認、メール下書きの持ち運び
querySelectorscrollWidthcreateElement

<main class="stage">
<section class="panel">
<p class="preview" id="preview">
この段落には<strong>太字</strong>と<em>斜体</em>と<code>コード</code>が混ざっています
</p>
<button class="copyBtn" id="copyBtn" type="button">書式ごとコピー</button>
<div class="toast" id="toast">
<span class="toastLabel" id="toastLabel">書式0種コピー済み</span>
<span class="badges" id="badges"></span>
</div>
</section>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-orange);
.panel {
box-sizing: border-box;
width: 320px;
display: flex;
flex-direction: column;
align-items: stretch;
gap: $sp-4;
background: $stage-paper;
border-radius: $r-card;
padding: $sp-5;
box-shadow: $shadow-raised;
}
.preview {
margin: 0;
color: $subject-ink;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-size: 13px;
line-height: 1.6;
strong { color: $subject-blue-deep; }
em { color: $subject-orange-deep; }
code {
font-family: "SFMono-Regular", Consolas, monospace;
background: rgba($subject-ink, .08);
border-radius: $r-xs;
padding: 4px 4px;
}
}
.copyBtn {
align-self: flex-start;
border: 0;
border-radius: $r-pill;
padding: $sp-2 $sp-4;
background: $subject-blue-deep;
color: $subject-cream;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-weight: 600;
font-size: 12px;
cursor: pointer;
}
.toast {
box-sizing: border-box;
display: flex;
align-items: center;
gap: $sp-2;
min-width: 140px;
width: fit-content;
max-width: 100%;
padding: $sp-2 $sp-3;
border-radius: $r-pill;
background: $subject-ink;
opacity: 0;
transition: min-width $dur-quick $ease-out, opacity $dur-quick $ease-out;
}
.toast.is-visible {
opacity: 1;
}
.toastLabel {
color: $stage-paper;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-size: 12px;
font-weight: 600;
white-space: nowrap;
}
.badges {
display: inline-flex;
gap: $sp-1;
}
.badge {
display: inline-flex;
align-items: center;
padding: 4px $sp-2;
border-radius: $r-xs;
background: $subject-blue-light;
color: $subject-ink;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-size: 10px;
font-weight: 700;
white-space: nowrap;
}
@media (max-width: 340px) {
.panel {
width: 260px;
padding: $sp-4;
}
}
@media (prefers-reduced-motion: reduce) {
.toast {
transition: none;
}
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.panel {
box-sizing: border-box;
width: 320px;
display: flex;
flex-direction: column;
align-items: stretch;
gap: 16px;
background: #ffffff;
border-radius: 16px;
padding: 20px;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.preview {
margin: 0;
color: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-size: 13px;
line-height: 1.6;
}
.preview strong {
color: #0a4fe8;
}
.preview em {
color: #c93000;
}
.preview code {
font-family: "SFMono-Regular", Consolas, monospace;
background: rgba(23, 20, 26, 0.08);
border-radius: 4px;
padding: 4px 4px;
}
.copyBtn {
align-self: flex-start;
border: 0;
border-radius: 999px;
padding: 8px 16px;
background: #0a4fe8;
color: #fff7e6;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-weight: 600;
font-size: 12px;
cursor: pointer;
}
.toast {
box-sizing: border-box;
display: flex;
align-items: center;
gap: 8px;
min-width: 140px;
width: fit-content;
max-width: 100%;
padding: 8px 12px;
border-radius: 999px;
background: #17141a;
opacity: 0;
transition: min-width 220ms ease-out, opacity 220ms ease-out;
}
.toast.is-visible {
opacity: 1;
}
.toastLabel {
color: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-size: 12px;
font-weight: 600;
white-space: nowrap;
}
.badges {
display: inline-flex;
gap: 4px;
}
.badge {
display: inline-flex;
align-items: center;
padding: 4px 8px;
border-radius: 4px;
background: #5b8cf8;
color: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-size: 10px;
font-weight: 700;
white-space: nowrap;
}
@media (max-width: 340px) {
.panel {
width: 260px;
padding: 16px;
}
}
@media (prefers-reduced-motion: reduce) {
.toast {
transition: none;
}
}// 書式 종류 토스트 — 복사된 내용에 서식이 몇 종류 섞였는지 세어(太字·斜体·コード)
// 그 수만큼 토스트의 라벨 폭을 다시 계산해 늘리고 종류 뱃지가 순서대로 붙는다.
// 계산 겹: DOM 태그 종류 카운트 → 라벨 실측 scrollWidth → 뱃지 폭 합산 → 토스트 min-width 재계산.
// 실사용자 리스너 있음(書式ごとコピー 버튼). 실 클립보드 API 호출 없음 — 상태는 시뮬레이션.
(function () {
var preview = document.getElementById('preview');
var copyBtn = document.getElementById('copyBtn');
var toast = document.getElementById('toast');
var toastLabel = document.getElementById('toastLabel');
var badgesEl = document.getElementById('badges');
if (!preview || !copyBtn || !toast || !toastLabel || !badgesEl) return;
var KIND_LABELS = { strong: '太字', em: '斜体', code: 'コード' };
var BADGE_STAGGER_MS = 160;
// 붙여넣는 문단이 바뀌는 상황을 흉내낸다 — 書式 종류 수가 1·2·3 종으로 달라진다
var SAMPLES = [
'この段落には<strong>太字</strong>だけがあります',
'この段落には<strong>太字</strong>と<em>斜体</em>が混ざっています',
'この段落には<strong>太字</strong>と<em>斜体</em>と<code>コード</code>が混ざっています'
];
var SAMPLE_MS = 660;
var CYCLE_MS = SAMPLE_MS * SAMPLES.length;
function countKinds() {
var found = [];
var tags = ['strong', 'em', 'code'];
for (var i = 0; i < tags.length; i++) {
if (preview.querySelector(tags[i])) found.push(tags[i]);
}
return found;
}
function measureAndSize(count) {
var labelWidth = toastLabel.scrollWidth;
var badgeWidth = count * 46;
var total = labelWidth + badgeWidth + 40;
toast.style.minWidth = total + 'px';
}
var badgeTimers = [];
function clearBadgeTimers() {
for (var i = 0; i < badgeTimers.length; i++) clearTimeout(badgeTimers[i]);
badgeTimers = [];
}
function showToast(sampleIndex) {
preview.innerHTML = SAMPLES[sampleIndex];
var kinds = countKinds();
clearBadgeTimers();
toastLabel.textContent = '書式 ' + kinds.length + '種コピー済み';
badgesEl.innerHTML = '';
measureAndSize(0);
toast.classList.add('is-visible');
for (var i = 0; i < kinds.length; i++) {
(function (kind, index) {
var timer = setTimeout(function () {
var b = document.createElement('span');
b.className = 'badge';
b.textContent = KIND_LABELS[kind];
badgesEl.appendChild(b);
measureAndSize(index + 1);
}, index * BADGE_STAGGER_MS);
badgeTimers.push(timer);
})(kinds[i], i);
}
}
showToast(0);
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce) return;
var start = null;
var lastSample = -1;
function autoLoop(ts) {
if (start === null) start = ts;
var elapsed = ts - start;
var t = elapsed % CYCLE_MS;
var sampleIndex = Math.floor(t / SAMPLE_MS) % SAMPLES.length;
if (sampleIndex !== lastSample) {
lastSample = sampleIndex;
showToast(sampleIndex);
}
requestAnimationFrame(autoLoop);
}
requestAnimationFrame(autoLoop);
copyBtn.addEventListener('click', function () {
lastSample = -1;
showToast(0);
});
})();import { useEffect, useRef } from "react";
import styles from "./CopyRichTextToast.module.scss";
// 서식 종류 토스트 — 복사된 내용에 서식이 몇 종류 섞였는지 세어(굵게·기울임·코드)
// 그 수만큼 토스트의 라벨 폭을 다시 계산해 늘리고 종류 뱃지가 순서대로 붙는다.
// 계산 겹: DOM 태그 종류 카운트 → 라벨 실측 scrollWidth → 뱃지 폭 합산 → 토스트 min-width 재계산.
// 실사용자 리스너 있음(서식째 복사 버튼). 실 클립보드 API 호출 없음 — 상태는 시뮬레이션.
const KIND_LABELS: Record<string, string> = { strong: "굵게", em: "기울임", code: "코드" };
const BADGE_STAGGER_MS = 160;
const SAMPLES = [
'이 문단은 <strong>굵게</strong>만 있어요',
'이 문단은 <strong>굵게</strong>와 <em>기울임</em>이 섞여 있어요',
'이 문단은 <strong>굵게</strong>와 <em>기울임</em>과 <code>코드</code>가 섞여 있어요',
];
const SAMPLE_MS = 660;
const CYCLE_MS = SAMPLE_MS * SAMPLES.length;
export default function CopyRichTextToast() {
const previewRef = useRef<HTMLParagraphElement>(null);
const copyBtnRef = useRef<HTMLButtonElement>(null);
const toastRef = useRef<HTMLDivElement>(null);
const toastLabelRef = useRef<HTMLSpanElement>(null);
const badgesRef = useRef<HTMLSpanElement>(null);
useEffect(() => {
const preview = previewRef.current;
const copyBtn = copyBtnRef.current;
const toast = toastRef.current;
const toastLabel = toastLabelRef.current;
const badgesEl = badgesRef.current;
if (!preview || !copyBtn || !toast || !toastLabel || !badgesEl) return;
let badgeTimers: number[] = [];
function clearBadgeTimers() {
badgeTimers.forEach((id) => clearTimeout(id));
badgeTimers = [];
}
function countKinds() {
const found: string[] = [];
["strong", "em", "code"].forEach((tag) => {
if (preview!.querySelector(tag)) found.push(tag);
});
return found;
}
function measureAndSize(count: number) {
const labelWidth = toastLabel!.scrollWidth;
const badgeWidth = count * 46;
const total = labelWidth + badgeWidth + 40;
toast!.style.minWidth = `${total}px`;
}
function showToast(sampleIndex: number) {
preview!.innerHTML = SAMPLES[sampleIndex];
const kinds = countKinds();
clearBadgeTimers();
toastLabel!.textContent = `서식 ${kinds.length}종 복사됨`;
badgesEl!.innerHTML = "";
measureAndSize(0);
toast!.classList.add(styles.isVisible);
kinds.forEach((kind, index) => {
const timer = window.setTimeout(() => {
const b = document.createElement("span");
b.className = styles.badge;
b.textContent = KIND_LABELS[kind];
badgesEl!.appendChild(b);
measureAndSize(index + 1);
}, index * BADGE_STAGGER_MS);
badgeTimers.push(timer);
});
}
showToast(0);
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let raf = 0;
if (!reduce) {
let start: number | null = null;
let lastSample = -1;
const autoLoop = (ts: number) => {
if (start === null) start = ts;
const elapsed = ts - start;
const t = elapsed % CYCLE_MS;
const sampleIndex = Math.floor(t / SAMPLE_MS) % SAMPLES.length;
if (sampleIndex !== lastSample) {
lastSample = sampleIndex;
showToast(sampleIndex);
}
raf = requestAnimationFrame(autoLoop);
};
raf = requestAnimationFrame(autoLoop);
}
function onCopy() {
showToast(0);
}
copyBtn.addEventListener("click", onCopy);
return () => {
if (raf) cancelAnimationFrame(raf);
clearBadgeTimers();
copyBtn.removeEventListener("click", onCopy);
};
}, []);
return (
<div className={styles.stage}>
<section className={styles.panel}>
<p ref={previewRef} className={styles.preview}>
이 문단은 <strong>굵게</strong>와 <em>기울임</em>과 <code>코드</code>가 섞여 있어요
</p>
<button ref={copyBtnRef} className={styles.copyBtn} type="button">서식째 복사</button>
<div ref={toastRef} className={styles.toast}>
<span ref={toastLabelRef} className={styles.toastLabel}>서식 0종 복사됨</span>
<span ref={badgesRef} className={styles.badges} />
</div>
</section>
</div>
);
}09 リッチテキストトースト — 書式の種類数だけラベル幅を計算し直す通知 — 計測 累積面積 10.36% · 動いたフレーム 10/23GODRICH オリジナル · SCSS と React は zip に同梱 · コピー ボタン html UI 9選 — コード・URL・テキストのフィードバックまで
このデモが属する記事
コピー ボタン html UI 9選 — コード・URL・テキストのフィードバックまで「コピー ボタン html」9選 — 9つのうち1つはコピー結果を2つのボタンが分けて持ち、1つはコピーした文字数だけ表示を埋め、もう1つは失敗したコピーの復帰時間を自ら計ります。 この記事のデモをすべて見る


