어떤 긴 텍스트가 입력되었을 때 그걸 특정 character 수 단위로 쪼개서 복사할 수 있게 해줄 수 있어?

좋죠! “원하는 글자 수로 잘라서 복사”할 수 있는 초간단 웹페이지를 바로 드릴게요. 아래 코드를 splitter.html로 저장해서 브라우저로 열면 됩니다.

<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>텍스트 분할 & 복사</title>
<style>
  body { font-family: system-ui, -apple-system, "Segoe UI", Roboto, "Noto Sans KR", Arial, sans-serif; margin: 24px; }
  h1 { font-size: 20px; margin: 0 0 16px; }
  textarea { width: 100%; min-height: 180px; box-sizing: border-box; padding: 12px; }
  .row { display: flex; gap: 12px; align-items: center; margin: 12px 0; flex-wrap: wrap; }
  input[type="number"] { width: 120px; padding: 8px; }
  button { padding: 8px 12px; cursor: pointer; }
  .stats { margin: 8px 0 16px; color: #333; }
  .chunk { border: 1px solid #ddd; padding: 10px; margin: 12px 0; border-radius: 8px; background: #fafafa; }
  .chunk header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; }
  .chunk pre { white-space: pre-wrap; word-break: break-word; margin: 0; }
  .muted { color: #666; font-size: 12px; }
</style>
</head>
<body>
  <h1>텍스트 분할 & 복사</h1>

  <label for="input">원본 텍스트</label>
  <textarea id="input" placeholder="여기에 긴 텍스트를 붙여넣으세요"></textarea>

  <div class="row">
    <label for="size">조각 글자 수</label>
    <input id="size" type="number" min="1" step="1" value="500" />
    <button id="splitBtn">분할하기</button>
    <button id="copyAllBtn" title="모든 조각을 이어붙여서 복사">모두 복사</button>
  </div>

  <div class="muted">
    * 한국어/이모지 등 묶음 문자가 깨지지 않도록 <code>Intl.Segmenter</code>를 이용해 “문자(그래프림)” 단위로 자릅니다.
    (매우 오래된 브라우저에서는 일반 문자 길이로 대체)
  </div>

  <div id="stats" class="stats"></div>
  <div id="chunks"></div>

<script>
(function(){
  const $ = (sel) => document.querySelector(sel);
  const $$ = (sel) => Array.from(document.querySelectorAll(sel));
  const input = $('#input');
  const sizeEl = $('#size');
  const splitBtn = $('#splitBtn');
  const copyAllBtn = $('#copyAllBtn');
  const stats = $('#stats');
  const container = $('#chunks');

  // 안전한 "문자(그래프림)" 단위 분할을 위한 세그멘터
  function getGraphemes(str) {
    try {
      if ('Segmenter' in Intl) {
        const seg = new Intl.Segmenter('ko', { granularity: 'grapheme' });
        return Array.from(seg.segment(str), s => s.segment);
      }
    } catch (e) { /* fallback below */ }
    // Fallback: code unit 기준 (이모지 결합문자에 완벽하진 않지만 동작은 함)
    return Array.from(str);
  }

  function chunkArray(arr, n) {
    const out = [];
    for (let i = 0; i < arr.length; i += n) out.push(arr.slice(i, i + n).join(''));
    return out;
  }

  async function copyText(text) {
    try {
      await navigator.clipboard.writeText(text);
      return true;
    } catch (e) {
      // Fallback: 임시 텍스트영역 이용
      const ta = document.createElement('textarea');
      ta.value = text;
      document.body.appendChild(ta);
      ta.select();
      try { document.execCommand('copy'); document.body.removeChild(ta); return true; }
      catch (e2) { document.body.removeChild(ta); return false; }
    }
  }

  function renderChunks(chunks) {
    container.innerHTML = '';
    chunks.forEach((text, idx) => {
      const div = document.createElement('div');
      div.className = 'chunk';
      const header = document.createElement('header');
      const title = document.createElement('strong');
      title.textContent = `조각 ${idx + 1} / ${chunks.length}  (${text.length}자)`;
      const btn = document.createElement('button');
      btn.textContent = '이 조각 복사';
      btn.addEventListener('click', async () => {
        const ok = await copyText(text);
        btn.textContent = ok ? '복사됨!' : '복사 실패';
        setTimeout(() => btn.textContent = '이 조각 복사', 1200);
      });
      header.appendChild(title);
      header.appendChild(btn);
      const pre = document.createElement('pre');
      pre.textContent = text;
      div.appendChild(header);
      div.appendChild(pre);
      container.appendChild(div);
    });
  }

  function splitNow() {
    const raw = input.value ?? '';
    const size = Math.max(1, parseInt(sizeEl.value || '0', 10));
    const grams = getGraphemes(raw);
    const chunks = chunkArray(grams, size);
    renderChunks(chunks);
    stats.textContent = `전체 길이: ${grams.length}자 · 조각 개수: ${chunks.length} · 조각당 최대 ${size}자`;
  }

  splitBtn.addEventListener('click', splitNow);

  copyAllBtn.addEventListener('click', async () => {
    const pieces = $$('.chunk pre').map(p => p.textContent);
    const joined = pieces.join('');
    const ok = await copyText(joined);
    copyAllBtn.textContent = ok ? '모두 복사됨!' : '복사 실패';
    setTimeout(() => copyAllBtn.textContent = '모두 복사', 1200);
  });
})();
</script>
</body>
</html>

사용 방법 (step-by-step)

  1. 위 코드를 splitter.html로 저장하고 더블클릭해 브라우저에서 엽니다.

  2. 긴 텍스트를 상단 입력창에 붙여넣습니다.

  3. “조각 글자 수”에 원하는 숫자(예: 500)를 입력합니다.

  4. “분할하기”를 누르면 아래에 조각들이 생성됩니다.

  5. 각 조각의 “이 조각 복사” 버튼으로 개별 복사하거나, “모두 복사”로 다시 이어붙인 전체를 복사할 수 있어요.

참고

  • 기본적으로 한국어, 이모지 등 결합 문자를 안전하게 처리하도록 “문자(그래프림)” 단위로 자릅니다. 아주 오래된 브라우저에선 일반 문자 길이(.length)로 동작할 수 있습니다.

  • 필요하면 “조각당 N자” 외에도 “겹침(오버랩)”, “문단 단위로 자르되 최대 N자” 같은 옵션도 붙여드릴 수 있어요. 원하는 규칙이 있으면 알려주세요!

좋아 잘 작동해. 전체적인 구조를 유지하되 두 가지 개선해야할 사항이 있어. 1. 버튼이 “복사됨!”으로 바뀐 뒤 다시 일정 시간 후 “이 조각 복사” 버튼으로 돌아가는데, 내가 어디까지 복사했는지 헷갈려. 내가 1번이라도 누른 것은 버튼 색을 바꾸거나 해서 내가 어디까지 했는지 알 수 있으면 좋겠어. 2. 각 조각을 8000자 정도로 하고 있는데 스크롤 내려야할 게 너무 길어. 각 미리보기 블럭을 접어두고 내가 원할때만 펴도 좋을 거 같아. 두 가지는 내가 생각하는 방향인데 너한테 더 좋은 아이디어가 있다면 반영해줘.

좋아요! 말씀해주신 두 가지를 기준으로, 구조는 유지하면서 이렇게 개선했어요.

  • #1 “내가 복사한 조각을 한눈에”: 어떤 조각이든 한 번이라도 복사에 성공하면 그 카드가 영구 표시(세션 내) 됩니다.

    • 상단 요약줄에 ✅ 배지가 붙고, 카드 테두리가 강조됩니다(초록색).

    • 버튼 문구는 예전처럼 잠깐 “복사됨!”으로 바뀌지만, 배지는 그대로 남아 “어디까지 했는지” 헷갈리지 않게 해요.

  • #2 “스크롤 지옥 해방”: 각 조각을 접을 수 있는 미리보기(accordion) 로 바꿨습니다.

    • 기본은 접힘 상태로 렌더링되고, 요약줄엔 조각 번호/총개수, 길이, 앞부분 미리보기 가 보여요.

    • 상단에 “모두 펼치기 / 모두 접기 ” 토글도 추가했습니다.

거기에 제안 하나 더!

  • “다음 조각 열기” : 어떤 조각에서 복사 성공하면, 다음 조각을 자동으로 펼쳐 작업 흐름이 매끄럽습니다(선택 가능 토글).

아래 전체 코드만 splitter.html로 저장해 열면 바로 됩니다.

<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>텍스트 분할 & 복사 (개선판)</title>
<style>
  :root {
    --accent: #2e7d32; /* 복사 완료 강조색 */
    --muted: #666;
    --border: #ddd;
    --bg: #fafafa;
  }

  body { font-family: system-ui, -apple-system, "Segoe UI", Roboto, "Noto Sans KR", Arial, sans-serif; margin: 24px; }
  h1 { font-size: 20px; margin: 0 0 16px; }
  textarea { width: 100%; min-height: 180px; box-sizing: border-box; padding: 12px; }
  .row { display: flex; gap: 12px; align-items: center; margin: 12px 0; flex-wrap: wrap; }
  input[type="number"] { width: 120px; padding: 8px; }
  button { padding: 8px 12px; cursor: pointer; }
  .stats { margin: 8px 0 16px; color: #333; }
  .muted { color: var(--muted); font-size: 12px; }

  /* 조각(접힘 카드) */
  details.chunk {
    border: 1px solid var(--border);
    border-radius: 10px;
    background: var(--bg);
    margin: 12px 0;
    transition: border-color .2s, box-shadow .2s;
  }
  details.chunk[open] {
    box-shadow: 0 1px 10px rgba(0,0,0,.04);
  }
  details.chunk.copied-once {
    border-color: var(--accent);
  }

  /* 요약줄 */
  summary {
    list-style: none;
    display: flex;
    gap: 8px;
    align-items: center;
    padding: 10px 12px;
    cursor: pointer;
    user-select: none;
  }
  summary::-webkit-details-marker { display: none; }
  .summary-left { display: flex; align-items: center; gap: 8px; flex: 1; }
  .badge {
    font-size: 11px;
    padding: 2px 6px;
    border-radius: 999px;
    border: 1px solid var(--border);
    color: #333;
    background: #fff;
  }
  .badge.ok {
    border-color: var(--accent);
    color: var(--accent);
    font-weight: 600;
  }
  .snippet {
    color: var(--muted);
    font-size: 12px;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
    max-width: 40ch;
  }

  /* 본문 */
  .chunk-body { padding: 0 12px 12px 12px; }
  .actions { display: flex; gap: 8px; align-items: center; margin: 8px 0; flex-wrap: wrap; }
  .chunk pre { white-space: pre-wrap; word-break: break-word; margin: 0; padding: 10px; border: 1px solid var(--border); border-radius: 8px; background: #fff; max-height: 40vh; overflow: auto; }

  /* 상단 컨트롤 */
  .toolbar { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; margin: 8px 0; }
  .toolbar .spacer { flex: 1; }

  /* 작은 토글 스위치 */
  .toggle {
    display: inline-flex; align-items: center; gap: 6px; font-size: 12px; color: #333;
  }
  .toggle input { width: 1.1rem; height: 1.1rem; }
</style>
</head>
<body>
  <h1>텍스트 분할 & 복사</h1>

  <label for="input">원본 텍스트</label>
  <textarea id="input" placeholder="여기에 긴 텍스트를 붙여넣으세요"></textarea>

  <div class="row">
    <label for="size">조각 글자 수</label>
    <input id="size" type="number" min="1" step="1" value="8000" />
    <button id="splitBtn">분할하기</button>
    <button id="copyAllBtn" title="모든 조각을 이어붙여서 복사">모두 복사</button>
  </div>

  <div class="toolbar">
    <button id="expandAllBtn">모두 펼치기</button>
    <button id="collapseAllBtn">모두 접기</button>
    <span class="spacer"></span>
    <label class="toggle" title="복사 성공 시 자동으로 다음 조각을 열어 작업 흐름을 빠르게 합니다.">
      <input id="autoOpenNext" type="checkbox" checked />
      복사 후 다음 조각 자동 열기
    </label>
  </div>

  <div class="muted">
    * 한국어/이모지 등 결합 문자가 깨지지 않도록 <code>Intl.Segmenter</code>로 “문자(그래프림)” 단위로 자릅니다. (오래된 브라우저는 일반 길이로 대체)
  </div>

  <div id="stats" class="stats"></div>
  <div id="chunks"></div>

<script>
(function(){
  const $ = (sel) => document.querySelector(sel);
  const $$ = (sel) => Array.from(document.querySelectorAll(sel));
  const input = $('#input');
  const sizeEl = $('#size');
  const splitBtn = $('#splitBtn');
  const copyAllBtn = $('#copyAllBtn');
  const stats = $('#stats');
  const container = $('#chunks');
  const expandAllBtn = $('#expandAllBtn');
  const collapseAllBtn = $('#collapseAllBtn');
  const autoOpenNextEl = $('#autoOpenNext');

  function getGraphemes(str) {
    try {
      if ('Segmenter' in Intl) {
        const seg = new Intl.Segmenter('ko', { granularity: 'grapheme' });
        return Array.from(seg.segment(str), s => s.segment);
      }
    } catch (e) { /* fallback below */ }
    return Array.from(str);
  }

  function chunkArray(arr, n) {
    const out = [];
    for (let i = 0; i < arr.length; i += n) out.push(arr.slice(i, i + n).join(''));
    return out;
  }

  async function copyText(text) {
    try {
      await navigator.clipboard.writeText(text);
      return true;
    } catch (e) {
      const ta = document.createElement('textarea');
      ta.value = text;
      document.body.appendChild(ta);
      ta.select();
      try {
        document.execCommand('copy');
        document.body.removeChild(ta);
        return true;
      } catch (e2) {
        document.body.removeChild(ta);
        return false;
      }
    }
  }

  function makeSummaryLine(idx, total, text) {
    const len = text.length;
    const snippet = text.replace(/\s+/g, ' ').slice(0, 60);
    return {
      title: `조각 ${idx + 1} / ${total}`,
      meta: `(${len}자)`,
      snippet
    };
  }

  function renderChunks(chunks) {
    container.innerHTML = '';
    chunks.forEach((text, idx) => {
      const details = document.createElement('details');
      details.className = 'chunk';
      // 기본: 접힘
      details.open = false;

      const summary = document.createElement('summary');
      const left = document.createElement('div');
      left.className = 'summary-left';

      const b1 = document.createElement('span'); b1.className = 'badge';
      const b2 = document.createElement('span'); b2.className = 'badge';
      const snip = document.createElement('span'); snip.className = 'snippet';

      const meta = makeSummaryLine(idx, chunks.length, text);
      b1.textContent = meta.title;
      b2.textContent = meta.meta;
      snip.textContent = meta.snippet;

      left.appendChild(b1); left.appendChild(b2); left.appendChild(snip);

      const copiedBadge = document.createElement('span');
      copiedBadge.className = 'badge';
      copiedBadge.textContent = '미복사';

      summary.appendChild(left);
      summary.appendChild(copiedBadge);
      details.appendChild(summary);

      const body = document.createElement('div');
      body.className = 'chunk-body';

      const actions = document.createElement('div');
      actions.className = 'actions';

      const copyBtn = document.createElement('button');
      copyBtn.textContent = '이 조각 복사';

      const copyAndNextBtn = document.createElement('button');
      copyAndNextBtn.textContent = '복사하고 다음 펼치기';

      const markBtn = document.createElement('button');
      markBtn.textContent = '표시 해제';

      actions.appendChild(copyBtn);
      actions.appendChild(copyAndNextBtn);
      actions.appendChild(markBtn);

      const pre = document.createElement('pre');
      pre.textContent = text;

      body.appendChild(actions);
      body.appendChild(pre);
      details.appendChild(body);
      container.appendChild(details);

      // 상태 마킹 함수
      function markCopiedOnce() {
        details.classList.add('copied-once');
        copiedBadge.classList.add('ok');
        copiedBadge.textContent = '✅ 복사함';
        markBtn.textContent = '표시 해제';
      }
      function unmarkCopied() {
        details.classList.remove('copied-once');
        copiedBadge.classList.remove('ok');
        copiedBadge.textContent = '미복사';
        markBtn.textContent = '표시하기';
      }

      // 수동 토글 (원하실 때 직접 표시/해제)
      markBtn.addEventListener('click', (e) => {
        e.preventDefault(); // summary 열림 방지
        if (details.classList.contains('copied-once')) unmarkCopied();
        else markCopiedOnce();
      });

      // 복사 버튼
      copyBtn.addEventListener('click', async (e) => {
        e.preventDefault(); // summary 토글 방지
        const ok = await copyText(text);
        copyBtn.textContent = ok ? '복사됨!' : '복사 실패';
        if (ok) markCopiedOnce();
        setTimeout(() => copyBtn.textContent = '이 조각 복사', 1200);
      });

      // 복사 후 다음 펼치기(옵션)
      copyAndNextBtn.addEventListener('click', async (e) => {
        e.preventDefault();
        const ok = await copyText(text);
        copyAndNextBtn.textContent = ok ? '복사됨!' : '복사 실패';
        if (ok) {
          markCopiedOnce();
          if (autoOpenNextEl.checked) {
            const next = details.nextElementSibling;
            if (next && next.tagName === 'DETAILS') {
              next.open = true;
              next.scrollIntoView({ behavior: 'smooth', block: 'start' });
            }
          }
        }
        setTimeout(() => copyAndNextBtn.textContent = '복사하고 다음 펼치기', 1200);
      });
    });
  }

  function splitNow() {
    const raw = input.value ?? '';
    const size = Math.max(1, parseInt(sizeEl.value || '0', 10));
    const grams = getGraphemes(raw);
    const chunks = chunkArray(grams, size);
    renderChunks(chunks);
    stats.textContent = `전체 길이: ${grams.length}자 · 조각 개수: ${chunks.length} · 조각당 최대 ${size}자`;
  }

  splitBtn.addEventListener('click', splitNow);

  copyAllBtn.addEventListener('click', async () => {
    const pieces = $$('.chunk pre').map(p => p.textContent);
    const joined = pieces.join('');
    const ok = await copyText(joined);
    copyAllBtn.textContent = ok ? '모두 복사됨!' : '복사 실패';
    setTimeout(() => copyAllBtn.textContent = '모두 복사', 1200);
  });

  expandAllBtn.addEventListener('click', () => {
    $$('#chunks details.chunk').forEach(d => d.open = true);
  });
  collapseAllBtn.addEventListener('click', () => {
    $$('#chunks details.chunk').forEach(d => d.open = false);
  });
})();
</script>
</body>
</html>

바뀐 점 요약 (step-by-step)

  1. 복사 진행 상황 표시 : 한 번이라도 복사 성공하면 해당 카드가 초록 테두리 + ✅ 복사함 배지로 영구 표시(세션 내) 됩니다.

  2. 접힘 UI : 각 조각은 details/summary 기반 접힘 카드 로 렌더링됩니다. 상단의 “모두 펼치기/모두 접기”로 일괄 제어 가능.

  3. 미리보기 : 요약줄에 앞부분 60자 미리보기와 길이 표기가 들어가 스크롤 없이 훑어보기 가 쉬워졌습니다.

  4. 작업 흐름 가속 : “복사하고 다음 펼치기” + (옵션) “복사 후 다음 조각 자동 열기” 토글로 연속 작업 이 부드럽습니다.

  5. 수동 표시/해제 버튼: 필요하면 복사 없이도 “표시하기/표시 해제”로 체크리스트처럼 쓸 수 있어요.

원하시면

  • 오버랩 분할(예: 8000자, 오버랩 200자) ,

  • 문단/문장 단위 최대 N자(자연어 경계 유지),

  • 세션 간 진행상황 저장(localStorage)
    같은 옵션도 바로 붙여드릴게요.

Categories:

Updated: