「小説家になろう」の小説をVSCodeで書く その2

## 文字数を気にする人が多いらしい
最近Xで、同じアマチュア作家さんやアマチュアから一歩抜け出して出版社からデビューした方などのお話をよく見かけるんですが、一部の方は大変文字数を気にされます。
気にし方にもいくつかあって

- 一話あたりの文字数を気にする方
- 一日に何文字書いたかを気にする方
- シリーズ連載している著作の合計文字数を気にする方

まあ、こんな感じです。もっといるかもしれませんが、私の網にはかかっていないようです。すいません。
今回は前回の[VSCode拡張機能]( https://qiita.com/ixige/items/819f9e6feb5adaac8a74 )に、文字数の表示機能をつけてやろうという企画です。
つまり、上の類系では「一話あたりの文字数を気にする方」が今回のお客様です。下の図で、ピンク色に囲まれた薄い数字が今回の開発ターゲットです。

![ScreenShot_VS.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/87287/c973c338-1709-400a-a3bd-8e29fefea242.png)

ちなみに、合計文字数がどうして気になるのかと言えば、最近のWeb小説賞は募集要項に「n万字以上」(n=3~8)と書かれていることが多いのでそのためかと思います。ラノベ一冊がだいたい8~10万字らしいので、募集作にもその当たりの要求をしているわけですね。

## 実装とコード
### 1. 変更のないファイル
`package.json` の方は特に**変更がありません**。ちゃんとしたい方はご自分なりの尺度でバージョンを変更しておいて下さい。
### 2. 変更するファイル
`extension.js` にはいくつかの変更ポイントがあります。
#### 2.1 ツールバーに字数表示用のspanを追加
getHtml内、ツールバーにこの1行を追加してください：
まずは表示領域で、
```
<span id="charCount" style="margin-left:auto;">0字</span>
```
#### 2.2 字数カウント＆表示用JavaScriptを追加
同じく`<script>`タグ内に、以下の関数と呼び出しを追加：
```
function updateCharCount() {
  // ルビやタグなどを除いてプレーンテキストとしてカウント
  let txt = ctn.innerText || ctn.textContent || '';
  // 空白・改行も含めてカウントしたい場合そのまま。除外したいなら .replace(/\s/g, '') など工夫
  document.getElementById('charCount').textContent = txt.length + '字';
}
```
字数を更新したいタイミングで`updateCharCount()`を呼ぶだけです。

#### 2.3 イベントにフックして字数更新
既存の`window.addEventListener('message', ...)` 内で、`ctn.innerHTML`が書き換わるタイミング（`rendered`受信時）に呼びます：
```
window.addEventListener('message', ev=>{
  const m = ev.data;
  if(m.command==='rendered'){
    ctn.innerHTML = m.content;
    fileEl.style.visibility='visible';
    updateCharCount(); // ← 追加
  }
  if(m.command==='cursorPos'){
    const el = ctn.querySelector(`.line[data-line="${m.line}"]`);
    if(el) el.scrollIntoView({block:'center'});
  }
});

```
また、最初の初期化時にも呼ぶと親切です。

## まとめ
- ツールバーに <span id="charCount"> を設置
- 字数カウント関数 updateCharCount() を作成
- テキスト更新イベントで呼ぶ
これだけで動きます！

### もっと高度にしたい場合
スペースや改行を除外、全角/半角カウント切替なども対応可能です。
私はあまり必要性を感じていないので、やるとしたらそのうちです。

例：`txt.replace(/\s/g,'').length` で空白をカウントから除外する、など


### めんどくさい人向け
以下は、extension.js の全コードです。

```extension.js
const vscode = require('vscode');
const fs     = require('fs');
const path   = require('path');

function activate(context) {
  context.subscriptions.push(
    vscode.commands.registerCommand('novelPreview.start', () => showPreview(context))
  );
}

function deactivate() {}

async function showPreview(context) {
  const ws = vscode.workspace.workspaceFolders;
  if (!ws || !ws.length) {
    vscode.window.showErrorMessage('まずワークスペースフォルダを開いてください');
    return;
  }
  const root = ws[0].uri.fsPath;
  const files = fs.readdirSync(root).filter(f => f.endsWith('.md') || f.endsWith('.txt'));
  if (!files.length) {
    vscode.window.showWarningMessage('.md/.txt ファイルが見つかりません');
  }

  const active = vscode.window.activeTextEditor;
  let initialFile = files[0] || '';
  let initialLine = 0;
  if (active && ['markdown','plaintext'].includes(active.document.languageId)) {
    const name = path.basename(active.document.fileName);
    if (files.includes(name)) {
      initialFile = name;
      initialLine = active.selection.active.line;
    }
  }

  let initialContent = '';
  try {
    const txt = await fs.promises.readFile(path.join(root, initialFile), 'utf8');
    initialContent = convertToHtml(txt);
  } catch (e) {
    console.error('初期ファイル読み込み失敗:', e);
  }

  const panel = vscode.window.createWebviewPanel(
    'novelPreview',
    'Novel Preview',
    vscode.ViewColumn.Beside,
    { enableScripts: true }
  );
  panel.webview.html = getHtml(files, initialContent, initialFile);
  panel.webview.postMessage({ command: 'cursorPos', line: initialLine });

  const disposables = [];

  disposables.push(
    panel.webview.onDidReceiveMessage(async msg => {
      if (msg.command === 'loadFile') {
        try {
          const txt = await fs.promises.readFile(path.join(root, msg.filename), 'utf8');
          panel.webview.postMessage({ command: 'rendered', content: convertToHtml(txt) });
        } catch (err) {
          console.error('ファイル読み込み失敗:', err);
        }
      }
    })
  );

  disposables.push(
    vscode.workspace.onDidSaveTextDocument(doc => {
      if (panel.visible && ['markdown','plaintext'].includes(doc.languageId)) {
        panel.webview.postMessage({ command:'loadFile', filename:path.basename(doc.fileName) });
      }
    })
  );

  disposables.push(
    vscode.workspace.onDidChangeTextDocument(evt => {
      const d = evt.document;
      if (panel.visible && ['markdown','plaintext'].includes(d.languageId)) {
        panel.webview.postMessage({ command:'rendered', content:convertToHtml(d.getText()) });
      }
    })
  );

  disposables.push(
    vscode.window.onDidChangeTextEditorSelection(evt => {
      const ed = evt.textEditor;
      if (panel.visible && ['markdown','plaintext'].includes(ed.document.languageId)) {
        panel.webview.postMessage({ command:'cursorPos', line:evt.selections[0].active.line });
      }
    })
  );

  panel.onDidDispose(() => disposables.forEach(d => d.dispose()), null, context.subscriptions);
}

function convertToHtml(text) {
  return text
    .split(/\r?\n/)
    .map((line, i) => {
      let html = line
        // 1) 文字列(読み) or 文字列（読み）形式のルビ
        .replace(
          /([一-龯々〆ヵヶ]+)[(（]([^()（）]+?)[)）]/g,
          (_, body, ruby) => `<ruby>${body}<rt>${ruby}</rt></ruby>`
        )
        // 2) 半角/全角パイプ｜|＋文字列《読み》形式のルビ
        .replace(
          /[｜|]([^《]+?)《([^》]+?)》/g,
          (_, body, ruby) => `<ruby>${body}<rt>${ruby}</rt></ruby>`
        );

      if (!html.trim()) html = '<br>';
      return `<div class="line" data-line="${i}">${html}</div>`;
    })
    .join('');
}

function getHtml(files, initialContent, initialFile) {
  const opts = files.map(f =>
    `<option value="${f}"${f===initialFile?' selected':''}>${f}</option>`
  ).join('\n');

  return `<!DOCTYPE html>
<html lang="ja"><head><meta charset="UTF-8"><style>
:root{--bg-cream:#fefae0;--bg-white:#ffffff;--bg-dark:#1e1e1e;
      --text-cream:#2c2c2c;--text-white:#000000;--text-dark:#d4d4d4;}
body{margin:0;padding:0;background:var(--bg-cream);}
.toolbar{position:fixed;top:0;left:0;right:0;
  background:rgba(255,255,255,0.9);padding:8px;
  display:flex;gap:1em;align-items:center;border-bottom:1px solid #ccc;z-index:10;}
#content-container{margin-top:48px;padding:1em;
  overflow:auto;height:calc(100vh-48px);padding-bottom:50vh;
  color:var(--text-cream);font-family:var(--font-family,Meiryo,sans-serif);
  font-size:var(--font-size,14pt);}
.line{max-width:37em;margin:0 auto;line-height:1.86em;
  white-space:pre-wrap;word-break:break-word;color:inherit;
  font-family:inherit;font-size:inherit;}
.heading{text-align:center;font-weight:bold;
  font-size:1.142em;margin:1em 0;}
#fileSelect{visibility:hidden;}
</style></head><body>
  <div class="toolbar">
    フォント:<select id="fontSelect">
      <option value="Meiryo, sans-serif" selected>メイリオ</option>
      <option value="'Yu Mincho', serif">明朝</option>
    </select>
    サイズ:<input id="fontSize" type="range" min="10" max="16" value="14">
    背景:<select id="themeSelect">
      <option value="cream" selected>クリーム</option>
      <option value="white">白</option>
      <option value="dark">黒</option>
    </select>
    📂 ファイル:<select id="fileSelect">${opts}</select>
    <span id="charCount" style="margin-left:auto;">0字</span>
  </div>
  <div id="content-container" style="
    --font-family:Meiryo,sans-serif;--font-size:14pt;">
    ${initialContent}
  </div>
  <script>
    const vscode   = acquireVsCodeApi();
    const ctn      = document.getElementById('content-container');
    const fileEl   = document.getElementById('fileSelect');
    const fontEl   = document.getElementById('fontSelect');
    const sizeEl   = document.getElementById('fontSize');
    const themeEl  = document.getElementById('themeSelect');
    const charEl   = document.getElementById('charCount');

    function updateCharCount() {
      // タグ等を無視して純粋なテキストのみをカウント
      let txt = ctn.innerText || ctn.textContent || '';
      charEl.textContent = txt.length + '字';
    }

    themeEl.addEventListener('change', e=>{
      const t = {cream:{bg:'var(--bg-cream)', text:'var(--text-cream)'},
                 white:{bg:'var(--bg-white)', text:'var(--text-white)'},
                 dark:{bg:'var(--bg-dark)', text:'var(--text-dark)'}}[e.target.value];
      document.body.style.background = t.bg;
      ctn.style.color = t.text;
    });

    fontEl.addEventListener('change', e=>
      ctn.style.setProperty('--font-family', e.target.value)
    );
    sizeEl.addEventListener('input', e=>
      ctn.style.setProperty('--font-size', e.target.value+'pt')
    );
    fileEl.addEventListener('change',e=>
      vscode.postMessage({command:'loadFile',filename:e.target.value})
    );

    window.addEventListener('message', ev=>{
      const m = ev.data;
      if(m.command==='rendered'){
        ctn.innerHTML = m.content;
        fileEl.style.visibility='visible';
        updateCharCount();
      }
      if(m.command==='cursorPos'){
        const el = ctn.querySelector(\`.line[data-line="\${m.line}"]\`);
        if(el) el.scrollIntoView({block:'center'});
      }
    });

    fileEl.style.visibility = 'visible';
    updateCharCount();
  </script>
</body></html>`;
}

module.exports = { activate, deactivate };
```

このまま **コピペで差し替えれば**、ツールバー右端に字数がリアルタイム表示されるようになります。

## 今後
禁則処理などを入れようかとも思ったんですが、思った以上に字数に対する感度が高いようなのでまずはこちらを実装してみました。
私自身はこのコードですでにすっかり満足しているので、改良ポイントや改善要求がある方はお知らせ下さい。

## 余談
この拡張機能を使って書いている拙作[「漂流ジャンクショップ」](https://ncode.syosetu.com/n2394kf/ )も、お時間ある時で構いませんのでお手にとっていただけると幸いです。

(2025/06/01)