👻

テンプレを簡単に作成、編集できるソフトを公開

に公開

https://zenn.dev/kemii/articles/ad330e1711485f

このソフトで動くテキスト編集ソフトを作成しました。


<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>テンプレート挿入エディタ</title>
    <script src="https://cdn.tailwindcss.com"></script>
    <link rel="preconnect" href="https://fonts.googleapis.com">
    <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
    <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
    <style>
        /* 基本的なスタイル */
        body {
            font-family: 'Inter', sans-serif; /* フォント指定 */
            display: flex;
            height: 95vh; /* 画面全体の高さ */
            margin: 0;
            background-color: #f3f4f6; /* 背景色 */
            overflow: hidden; /* body自体のスクロールを禁止 */
        }
        /* 左側のテンプレートリスト用コンテナ */
        #template-list-container {
            width: 250px; /* 少し幅を広げる */
            flex-shrink: 0; /* 幅が縮まないように */
            padding: 1rem;
            background-color: #e5e7eb; /* 背景色 */
            border-right: 1px solid #d1d5db; /* 境界線 */
            display: flex;
            flex-direction: column;
            overflow-y: auto; /* コンテナ自体がスクロール */
        }
        #template-list-container h2 {
             flex-shrink: 0; /* タイトルは縮まない */
        }
        #template-list {
            flex-grow: 1; /* 利用可能なスペースを埋める */
            overflow-y: auto; /* リストが長くなった場合にスクロール */
            margin-bottom: 1rem; /* 下部の設定との間にマージン */
            list-style: none;
            padding: 0;
        }
        /* テンプレートリストの各項目 */
        #template-list li {
            cursor: pointer;
            padding: 0.6rem 0.8rem; /* パディング調整 */
            margin-bottom: 0.5rem;
            background-color: #fff;
            border-radius: 0.375rem; /* 角丸 */
            transition: background-color 0.2s, box-shadow 0.2s; /* ホバー効果 */
            box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05); /* 軽い影 */
            font-size: 0.875rem; /* 文字サイズ調整 */
        }
        #template-list li:hover {
            background-color: #f0f9ff; /* ホバー時の背景色 (水色系) */
            box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px -1px rgba(0, 0, 0, 0.1);
        }
         #template-list li.text-gray-500 { /* 読み込み中/見つからない場合 */
             background-color: transparent;
             box-shadow: none;
             cursor: default;
         }

        /* 右側のエディタエリア用コンテナ */
        #editor-container {
            flex-grow: 1; /* 残りの幅をすべて使う */
            display: flex;
            flex-direction: column;
            /* 上左右: 1.5rem, 下: 2rem のパディング */
            padding: 1.5rem 1.5rem 2rem 1.5rem;
            overflow-y: hidden; /* コンテナ自体のスクロールを禁止 */
        }
        #editor-container h1 {
             flex-shrink: 0; /* タイトルは縮まない */
        }
        .input-group { /* ファイルパス入力欄のグループ */
             margin-bottom: 1rem;
             flex-shrink: 0; /* 縮まない */
        }
        /* テキストエリア */
        #main-textarea {
            flex-grow: 1; /* 高さを可能な限り伸ばす */
            width: 100%;
            padding: 0.75rem;
            border: 1px solid #d1d5db;
            border-radius: 0.375rem; /* 角丸 */
            resize: none; /* リサイズ不可 */
            font-family: 'Menlo', 'Monaco', 'Consolas', monospace; /* 等幅フォント指定 */
            margin-bottom: 1rem; /* 下の要素との間にマージン */
            overflow-y: auto; /* テキストエリア自体がスクロール */
            line-height: 1.6; /* 行間を少し広げる */
        }
         #main-textarea:focus {
             outline: none;
             border-color: #3b82f6;
             box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.3);
         }
        /* メッセージ表示エリア */
        #message-area {
            margin-bottom: 1rem; /* ボタンとのマージンを少し増やす */
            padding: 0.6rem 0.8rem;
            border-radius: 0.375rem;
            min-height: 2.8rem; /* 高さを確保 */
            background-color: #f9fafb;
            color: #1f2937;
            border: 1px solid #e5e7eb;
            font-size: 0.875rem;
            line-height: 1.4;
            flex-shrink: 0; /* 縮まない */
            transition: background-color 0.3s, color 0.3s, border-color 0.3s;
        }
        .message-success {
            background-color: #ecfdf5; /* より薄い緑 */
            color: #065f46;
            border-color: #a7f3d0;
        }
        .message-error {
            background-color: #fef2f2; /* より薄い赤 */
            color: #991b1b;
            border-color: #fecaca;
        }
        /* ボタンコンテナ */
        #button-container {
             flex-shrink: 0; /* 縮まない */
             text-align: right; /* ボタンを右寄せ */
        }
        /* ボタンのスタイル */
        .action-button {
            padding: 0.6rem 1.2rem; /* パディング調整 */
            margin-left: 0.5rem; /* ボタン間のマージン (右寄せなので左側) */
            margin-top: 0; /* 上マージンは不要に */
            background-color: #3b82f6; /* 青色 */
            color: white;
            border: none;
            border-radius: 0.375rem; /* 角丸 */
            cursor: pointer;
            font-weight: 500; /* 少し太字に */
            font-size: 0.875rem;
            transition: background-color 0.2s, box-shadow 0.2s; /* ホバー/フォーカス効果 */
        }
        .action-button:hover {
            background-color: #2563eb; /* ホバー時の色 */
        }
         .action-button:focus {
             outline: none;
             box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.4); /* フォーカスリング */
         }
        .delete-button {
             background-color: #ef4444; /* 赤色 */
        }
         .delete-button:hover {
             background-color: #dc2626; /* ホバー時の色 */
         }
         .delete-button:focus {
             box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.4); /* フォーカスリング (赤) */
         }
         /* 設定ファイル入力欄 */
         #settings-path {
              font-size: 0.8rem; /* 少し小さく */
         }
         #settings-path:focus {
              outline: none;
              border-color: #9ca3af;
              box-shadow: 0 0 0 2px rgba(156, 163, 175, 0.3);
         }
         /* テンプレート再読込ボタン */
         #template-list-container .action-button {
              width: 100%;
              margin-left: 0; /* 左マージンリセット */
              margin-top: 0.5rem; /* 上マージン追加 */
              font-size: 0.8rem;
              padding: 0.5rem 1rem;
         }
    </style>
</head>
<body>
    <div id="template-list-container">
        <h2 class="text-lg font-semibold mb-4 text-gray-700">テンプレート一覧</h2>
        <ul id="template-list">
            <li class="text-gray-500">読み込み中...</li>
        </ul>
        <hr class="my-4 border-gray-300">
        <div>
            <label for="settings-path" class="block text-sm font-medium text-gray-700 mb-1">設定ファイル:</label>
            <input type="text" id="settings-path" value="templates/settings.json" class="w-full p-1 border border-gray-300 rounded-md text-sm bg-white shadow-sm">
            <button onclick="loadTemplates()" class="action-button text-sm">テンプレート再読込</button>
        </div>
    </div>

    <div id="editor-container">
        <h1 class="text-xl font-bold mb-4 text-gray-800">テキストエディタ</h1>

        <div class="input-group">
            <label for="file-path" class="block text-sm font-medium text-gray-700 mb-1">ファイルパス:</label>
            <input type="text" id="file-path" placeholder="保存・読み込み先のファイルパス (例: work/document.txt)" class="w-full p-2 border border-gray-300 rounded-md shadow-sm focus:border-blue-500 focus:ring focus:ring-blue-200 focus:ring-opacity-50">
        </div>

        <textarea id="main-textarea" placeholder="ここにテキストを入力してください..."></textarea>

        <div id="message-area"></div>

        <div id="button-container">
            <button onclick="saveContent()" class="action-button">内容を保存</button>
            <button onclick="loadContent()" class="action-button">内容を読み込み</button>
            <button onclick="deleteFile()" class="action-button delete-button">ファイルを削除</button>
        </div>
    </div>

    <script>
        // --- グローバル変数 ---
        let templates = []; // 読み込んだテンプレート情報を保持する配列
        const mainTextarea = document.getElementById('main-textarea');
        const templateList = document.getElementById('template-list');
        const filePathInput = document.getElementById('file-path');
        const settingsPathInput = document.getElementById('settings-path');
        const messageArea = document.getElementById('message-area');

        // --- 初期化 ---
        document.addEventListener('DOMContentLoaded', () => {
            // ページ読み込み完了から0.1秒後(100ms)にテンプレートを読み込む
            setTimeout(loadTemplates, 100);
        });

        // --- メッセージ表示 ---
        function showMessage(text, type = 'info') {
            messageArea.textContent = text;
            // タイプに基づいてクラスを切り替え
            messageArea.className = 'message-area'; // 基本クラスを維持
            if (type === 'success') {
                messageArea.classList.add('message-success');
            } else if (type === 'error') {
                messageArea.classList.add('message-error');
            } else {
                // 'info' または指定なしの場合はデフォルトスタイル (クラス追加なし)
                 messageArea.style.backgroundColor = '#f9fafb'; // Ensure default background for info
                 messageArea.style.color = '#1f2937';
                 messageArea.style.borderColor = '#e5e7eb';
            }

             // メッセージを一定時間後に消す (任意)
             setTimeout(() => {
                 if (messageArea.textContent === text) { // 他のメッセージで上書きされていないか確認
                     messageArea.textContent = '';
                     messageArea.className = 'message-area'; // スタイルをリセット
                     messageArea.style.backgroundColor = ''; // Clear inline styles if any
                     messageArea.style.color = '';
                     messageArea.style.borderColor = '';
                 }
             }, 5000); // 5秒後に消える
        }

        // --- C#連携ラッパー ---
        // エラーハンドリングを共通化
        async function callHostObject(method, ...args) {
            // WebView2のホストオブジェクトが利用可能かチェック
            if (!window.chrome || !window.chrome.webview || !window.chrome.webview.hostObjects || !window.chrome.webview.hostObjects.class) {
                 showMessage('WebView2の連携機能が見つかりません。C#側の設定を確認してください。', 'error');
                 console.error('chrome.webview.hostObjects.class is not available.');
                 return { success: false, error: 'WebView2の連携機能が見つかりません。' };
            }
             // C#側のメソッドが存在するかチェック (より安全にするため)
             if (typeof window.chrome.webview.hostObjects.class[method] !== 'function') {
                 showMessage(`C#側のメソッド '${method}' が見つかりません。`, 'error');
                 console.error(`Host object method 'class.${method}' is not found or not a function.`);
                 return { success: false, error: `C#側のメソッド '${method}' が見つかりません。` };
             }

            try {
                console.log(`Calling C# method: ${method} with args:`, args);
                const result = await window.chrome.webview.hostObjects.class[method](...args);
                console.log(`C# method ${method} returned:`, result);
                // txt_read以外は明確な戻り値がない場合があるため、成功として扱う
                // txt_readは読み込んだ文字列、それ以外はnullやundefinedでもエラーでなければ成功
                return { success: true, data: result };
            } catch (error) {
                console.error(`Error calling C# method '${method}':`, error);
                // エラーオブジェクトに詳細が含まれているか確認
                const errorMessage = error && error.message ? error.message : String(error);
                showMessage(`C#連携エラー (${method}): ${errorMessage}`, 'error');
                return { success: false, error: errorMessage };
            }
        }

        // --- テンプレート関連 ---
        async function loadTemplates() {
            const settingsPath = settingsPathInput.value;
            if (!settingsPath) {
                showMessage('設定ファイルのパスを入力してください。', 'error');
                return;
            }
            showMessage('テンプレート設定を読み込み中...', 'info');
            templateList.innerHTML = '<li class="text-gray-500">読み込み中...</li>'; // 表示を更新

            const result = await callHostObject('txt_read', settingsPath);

            if (result.success && typeof result.data === 'string') { // Check if data is string before parsing
                try {
                    // BOM (Byte Order Mark) が含まれている場合を考慮して除去
                    const cleanedData = result.data.charCodeAt(0) === 0xFEFF ? result.data.substring(1) : result.data;
                    const settings = JSON.parse(cleanedData);
                    if (settings && Array.isArray(settings.templates)) {
                        templates = settings.templates; // グローバル変数に保存
                        renderTemplateList();
                        showMessage('テンプレートを読み込みました。', 'success');
                    } else {
                        templates = [];
                        renderTemplateList(); // 空リスト表示
                        showMessage('設定ファイルの形式が正しくありません。(最上位に "templates" 配列が必要です)', 'error');
                    }
                } catch (e) {
                    templates = [];
                    renderTemplateList(); // 空リスト表示
                    showMessage(`設定ファイルのJSON解析エラー: ${e.message}`, 'error');
                    console.error("JSON Parse Error:", e, "Data received:", result.data); // Log received data on parse error
                }
            } else if (!result.success) {
                templates = [];
                renderTemplateList(); // 空リスト表示
                // callHostObject内でエラーメッセージ表示済み
            } else {
                 // Handle cases where C# returns non-string data unexpectedly for txt_read
                 templates = [];
                 renderTemplateList(); // 空リスト表示
                 showMessage('設定ファイルの読み込みに成功しましたが、内容がテキスト形式ではありません。', 'error');
                 console.error("Received non-string data for settings file:", result.data);
            }
        }

        function renderTemplateList() {
            templateList.innerHTML = ''; // リストをクリア
            if (templates.length === 0) {
                templateList.innerHTML = '<li class="text-gray-500">テンプレートが見つかりません。</li>';
                return;
            }
            templates.forEach((template, index) => {
                const li = document.createElement('li');
                li.textContent = template.name || `テンプレート ${index + 1}`; // 名前がなければデフォルト表示
                // filePath が存在しない、または空文字の場合にエラーを防ぐ
                if (template.filePath && typeof template.filePath === 'string' && template.filePath.trim() !== '') {
                    li.dataset.filePath = template.filePath; // data属性にファイルパスを保持
                    li.onclick = () => insertTemplate(index); // クリックイベントを設定
                } else {
                     li.textContent += ' (パス無効)';
                     li.style.cursor = 'not-allowed';
                     li.style.opacity = '0.6';
                     li.style.backgroundColor = '#f8f9fa'; // 無効な項目は少しグレーに
                     li.onclick = null; // クリックイベントを削除
                     console.warn(`Template "${template.name || index + 1}" has invalid or missing filePath.`);
                }
                templateList.appendChild(li);
            });
        }

        async function insertTemplate(index) {
            const template = templates[index];
            // filePathの存在と有効性を再度チェック
            if (!template || !template.filePath || typeof template.filePath !== 'string' || template.filePath.trim() === '') {
                showMessage('テンプレート情報またはファイルパスが無効です。', 'error');
                return;
            }

            showMessage(`テンプレート「${template.name}」を読み込み中...`, 'info');
            const result = await callHostObject('txt_read', template.filePath);

            if (result.success && typeof result.data === 'string') {
                const templateContent = result.data;
                const cursorPos = mainTextarea.selectionStart; // カーソル位置取得
                const currentText = mainTextarea.value;
                const textBefore = currentText.substring(0, cursorPos);
                const textAfter = currentText.substring(mainTextarea.selectionEnd); // 選択範囲がある場合も考慮

                // カーソル位置にテンプレート内容を挿入
                mainTextarea.value = textBefore + templateContent + textAfter;

                // 挿入後、カーソル位置を更新 (挿入したテキストの末尾に移動)
                mainTextarea.focus();
                const newCursorPos = textBefore.length + templateContent.length;
                mainTextarea.selectionStart = mainTextarea.selectionEnd = newCursorPos;

                // スクロールしてカーソル位置を表示 (必要な場合)
                // Note: textareaのscrollIntoViewは直接使えないため、scrollTopを調整
                const lineHeight = parseFloat(getComputedStyle(mainTextarea).lineHeight);
                const lines = mainTextarea.value.substring(0, newCursorPos).split('\n').length;
                mainTextarea.scrollTop = Math.max(0, (lines - 1) * lineHeight - mainTextarea.clientHeight / 2);


                showMessage(`テンプレート「${template.name}」を挿入しました。`, 'success');
            } else if (!result.success) {
                 // callHostObject内でエラーメッセージ表示済み
                 showMessage(`テンプレート「${template.name}」の読み込みに失敗しました。`, 'error');
            } else {
                 // Handle case where C# returns non-string for template content
                 showMessage(`テンプレート「${template.name}」の内容が空またはテキスト形式ではありません。`, 'error');
                 console.error(`Received non-string data for template file "${template.filePath}":`, result.data);
            }
        }

        // --- ファイル操作 (メインコンテンツ) ---
        async function saveContent() {
            const filePath = filePathInput.value.trim(); // 前後の空白を除去
            const content = mainTextarea.value;

            if (!filePath) {
                showMessage('保存先のファイルパスを入力してください。', 'error');
                filePathInput.focus(); // 入力欄にフォーカス
                return;
            }

            showMessage(`ファイル「${filePath}」に保存中...`, 'info');
            const result = await callHostObject('txt_write', filePath, content);

            if (result.success) {
                showMessage(`ファイル「${filePath}」に内容を保存しました。`, 'success');
            } else {
                 // callHostObject内でエラーメッセージ表示済み
                 showMessage(`ファイル「${filePath}」への保存に失敗しました。`, 'error');
            }
        }

        async function loadContent() {
            const filePath = filePathInput.value.trim(); // 前後の空白を除去

            if (!filePath) {
                showMessage('読み込むファイルのパスを入力してください。', 'error');
                filePathInput.focus(); // 入力欄にフォーカス
                return;
            }

            showMessage(`ファイル「${filePath}」を読み込み中...`, 'info');
            const result = await callHostObject('txt_read', filePath);

            if (result.success && typeof result.data === 'string') {
                 // BOM (Byte Order Mark) が含まれている場合を考慮して除去
                mainTextarea.value = result.data.charCodeAt(0) === 0xFEFF ? result.data.substring(1) : result.data;
                showMessage(`ファイル「${filePath}」を読み込みました。`, 'success');
            } else if (!result.success) {
                 // callHostObject内でエラーメッセージ表示済み
                 showMessage(`ファイル「${filePath}」の読み込みに失敗しました。`, 'error');
            } else {
                 // Handle case where C# returns non-string for content file
                 showMessage(`ファイル「${filePath}」の内容が空またはテキスト形式ではありません。`, 'error');
                 mainTextarea.value = ''; // エラー時はクリアする
                 console.error(`Received non-string data for content file "${filePath}":`, result.data);
            }
        }

         async function deleteFile() {
             const filePath = filePathInput.value.trim(); // 前後の空白を除去

             if (!filePath) {
                 showMessage('削除するファイルのパスを入力してください。', 'error');
                 filePathInput.focus(); // 入力欄にフォーカス
                 return;
             }

             // 削除確認
             if (!confirm(`本当にファイル「${filePath}」を削除しますか?\nこの操作は元に戻せません。`)) {
                 showMessage('ファイル削除をキャンセルしました。', 'info');
                 return;
             }

             showMessage(`ファイル「${filePath}」を削除中...`, 'info');
             // file_delete 関数の戻り値は期待せず、成功/失敗のみ確認
             const result = await callHostObject('file_delete', filePath);

             if (result.success) {
                 showMessage(`ファイル「${filePath}」を削除しました。`, 'success');
                 // 削除成功したらファイルパス入力欄とテキストエリアをクリアする
                 filePathInput.value = '';
                 mainTextarea.value = '';
             } else {
                  // callHostObject内でエラーメッセージ表示済み
                  showMessage(`ファイル「${filePath}」の削除に失敗しました。`, 'error');
             }
         }

    </script>
</body>
</html>



Discussion