Cosense Toolbox
userscript 一覧テーマ作成使い方パーサーGitHub ↗
cosense-table-viewer
改造#テーブル#CSV#モジュール#検索

cosense-table-viewer

テーブルを画面いっぱいの大きなビューで表示してくれる本体です。表全体が落ち着いたダークテーマで広々と開き、検索ボックスで目的の行を絞り込んだり、見出しの列を固定したまま横スクロールしたりできます。列がたくさんある表でも、どの行が何の値かを見失わずに読めます。

行数・列数の多いデータをじっくり眺めたいときに力を発揮します。単体でも動きますが、ふだんは虫眼鏡ボタン(table-modal)から呼び出してセットで使うのがおすすめです。

デモ

デモ

インストール

設定ページの code:script.js に貼る
const { CSVTableModal } = await import("/api/code/cosense-toolbox/cosense-table-viewer/module.js");
ソースコード全文を見る(JS)
/**
 * CSVテーブルモーダル表示クラス
 * 指定したURLからCSVデータを取得し、ダークモードのテーブルをモーダルで表示
 */
export class CSVTableModal {
  constructor() {
    this.modal = null;
    this.tableContainer = null;
    this.csvData = [];
    this.headers = [];
    this.filteredData = [];
    this.searchInput = null;
    this.searchTimeout = null;
    this.lastSearchTime = 0;
    this.throttleDelay = 100;
    this.debounceDelay = 500;
    this.pinnedColumns = new Set();
  }

  /**
   * 指定したURLからCSVデータを取得してテーブルを表示
   * @param {string} url - CSV取得用のURL
   */
  async displayTable(url) {
    try {
      // CSVデータを取得
      await this.fetchCSVData(url);

      // モーダルを作成・表示
      this.createModal();
      this.setupSearchListener();
      this.renderTable();
      this.showModal();
    } catch (error) {
      console.error("テーブル表示エラー:", error);
      this.showError("データの取得に失敗しました: " + error.message);
    }
  }

  /**
   * 指定したURLからCSVデータを取得
   * @param {string} url - CSV取得用のURL
   */
  async fetchCSVData(url) {
    const response = await fetch(url);

    if (!response.ok) {
      throw new Error(`HTTP error! status: ${response.status}`);
    }

    const csvText = await response.text();
    this.parseCSV(csvText);
  }

  /**
   * CSV文字列をパースして配列に変換
   * @param {string} csvText - CSV文字列
   */
  parseCSV(csvText) {
    const lines = csvText.trim().split("\n");

    if (lines.length === 0) {
      throw new Error("CSVデータが空です");
    }

    // ヘッダー行を取得
    this.headers = this.parseCSVLine(lines[0]);

    // データ行を取得
    this.csvData = lines.slice(1).map((line) => this.parseCSVLine(line));
    this.filteredData = [...this.csvData];
  }

  /**
   * CSV行をパースして配列に変換(カンマ区切り、クォート対応)
   * @param {string} line - CSV行
   * @returns {Array<string>} パースされた値の配列
   */
  parseCSVLine(line) {
    const result = [];
    let current = "";
    let inQuotes = false;

    for (let i = 0; i < line.length; i++) {
      const char = line[i];

      if (char === '"') {
        if (inQuotes && line[i + 1] === '"') {
          // エスケープされたクォート
          current += '"';
          i++; // 次の文字をスキップ
        } else {
          // クォートの開始/終了
          inQuotes = !inQuotes;
        }
      } else if (char === "," && !inQuotes) {
        // 区切り文字(クォート外)
        result.push(current.trim());
        current = "";
      } else {
        current += char;
      }
    }

    result.push(current.trim());
    return result;
  }

  /**
   * モーダルのDOM要素を作成
   */
  createModal() {
    // 既存のモーダルを削除
    if (this.modal) {
      this.modal.remove();
    }

    // モーダルオーバーレイを作成
    this.modal = document.createElement("div");
    this.modal.className = "csv-modal-overlay";
    this.modal.style.cssText = `
								position: fixed;
								top: 0;
								left: 0;
								width: 100vw;
								height: 100vh;
								background-color: rgba(0, 0, 0, 0.8);
								display: flex;
								justify-content: center;
								align-items: center;
								z-index: 10000;
								backdrop-filter: blur(4px);
						`;

    // モーダルコンテナを作成
    const modalContainer = document.createElement("div");
    modalContainer.className = "csv-modal-container";
    modalContainer.style.cssText = `
								width: 95vw;
								height: 95vh;
								background: linear-gradient(135deg, #1e293b 0%, #334155 100%);
								border-radius: 16px;
								box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5);
								display: flex;
								flex-direction: column;
								overflow: hidden;
								border: 1px solid #475569;
						`;

    // ヘッダー部分を作成
    const header = this.createModalHeader();
    modalContainer.appendChild(header);

    // テーブルコンテナを作成
    this.tableContainer = document.createElement("div");
    this.tableContainer.className = "csv-table-container";
    this.tableContainer.style.cssText = `
								flex: 1;
								overflow: auto;
								padding: 0;
								background: #0f172a;
								position: relative;
						`;

    modalContainer.appendChild(this.tableContainer);
    this.modal.appendChild(modalContainer);

    // 閉じるボタンのイベントリスナー
    this.modal.addEventListener("click", (e) => {
      if (e.target === this.modal) {
        this.closeModal();
      }
    });

    // ESCキーで閉じる
    document.addEventListener("keydown", (e) => {
      if (e.key === "Escape" && this.modal) {
        this.closeModal();
      }
    });
  }

  /**
   * モーダルヘッダーを作成
   * @returns {HTMLElement} ヘッダー要素
   */
  createModalHeader() {
    const header = document.createElement("div");
    header.className = "csv-modal-header";
    header.style.cssText = `
								padding: 20px 24px;
								background: linear-gradient(135deg, #374151 0%, #4b5563 100%);
								border-bottom: 1px solid #6b7280;
								display: flex;
								justify-content: space-between;
								align-items: center;
						`;

    // タイトルと検索コンテナ
    const leftSection = document.createElement("div");
    leftSection.style.cssText = `
								display: flex;
								align-items: center;
								gap: 20px;
						`;

    // タイトル
    const title = document.createElement("h2");
    title.textContent = "CSV Data Table";
    title.style.cssText = `
								color: #f8fafc;
								font-size: 20px;
								font-weight: 600;
								margin: 0;
								text-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
						`;

    // 検索入力フィールド
    this.searchInput = document.createElement("input");
    this.searchInput.type = "text";
    this.searchInput.placeholder = "Search...";
    this.searchInput.style.cssText = `
								padding: 8px 12px;
								border: 1px solid #475569;
								border-radius: 6px;
								background: #1e293b;
								color: #f1f5f9;
								font-size: 14px;
								width: 200px;
								outline: none;
								transition: all 0.2s ease;
						`;

    this.searchInput.addEventListener("focus", () => {
      this.searchInput.style.borderColor = "#3b82f6";
      this.searchInput.style.boxShadow = "0 0 0 3px rgba(59, 130, 246, 0.1)";
    });

    this.searchInput.addEventListener("blur", () => {
      this.searchInput.style.borderColor = "#475569";
      this.searchInput.style.boxShadow = "none";
    });

    leftSection.appendChild(title);
    leftSection.appendChild(this.searchInput);

    // 閉じるボタン
    const closeBtn = document.createElement("button");
    closeBtn.innerHTML = "×";
    closeBtn.className = "csv-modal-close";
    closeBtn.style.cssText = `
								background: none;
								border: none;
								color: #cbd5e1;
								font-size: 28px;
								cursor: pointer;
								padding: 0;
								width: 32px;
								height: 32px;
								border-radius: 50%;
								display: flex;
								align-items: center;
								justify-content: center;
								transition: all 0.2s ease;
						`;

    closeBtn.addEventListener("mouseenter", () => {
      closeBtn.style.background = "#ef4444";
      closeBtn.style.color = "white";
    });

    closeBtn.addEventListener("mouseleave", () => {
      closeBtn.style.background = "none";
      closeBtn.style.color = "#cbd5e1";
    });

    closeBtn.addEventListener("click", () => this.closeModal());

    header.appendChild(leftSection);
    header.appendChild(closeBtn);

    return header;
  }

  /**
   * 検索機能のイベントリスナーを設定
   */
  setupSearchListener() {
    if (!this.searchInput) return;

    this.searchInput.addEventListener("input", (e) => {
      const searchTerm = e.target.value;
      this.throttledSearch(searchTerm);
    });
  }

  /**
   * スロットリング付きの検索処理
   * @param {string} searchTerm - 検索キーワード
   */
  throttledSearch(searchTerm) {
    const now = Date.now();

    if (now - this.lastSearchTime < this.throttleDelay) {
      clearTimeout(this.searchTimeout);
      this.searchTimeout = setTimeout(() => {
        this.performSearch(searchTerm);
      }, this.debounceDelay);
      return;
    }

    this.lastSearchTime = now;
    clearTimeout(this.searchTimeout);
    this.searchTimeout = setTimeout(() => {
      this.performSearch(searchTerm);
    }, this.debounceDelay);
  }

  /**
   * 実際の検索処理
   * @param {string} searchTerm - 検索キーワード
   */
  performSearch(searchTerm) {
    if (!searchTerm.trim()) {
      this.filteredData = [...this.csvData];
    } else {
      const lowerSearchTerm = searchTerm.toLowerCase();
      this.filteredData = this.csvData.filter((row) =>
        row.some((cell) => cell.toLowerCase().includes(lowerSearchTerm)),
      );
    }
    this.renderTable();
  }

  /**
   * テキスト内のキーワードをハイライト
   * @param {string} text - 対象テキスト
   * @param {string} searchTerm - ハイライトするキーワード
   * @returns {string} HTMLハイライト済みテキスト
   */
  highlightSearchTerm(text, searchTerm) {
    if (!searchTerm.trim()) return text;

    const regex = new RegExp(`(${searchTerm.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")})`, "gi");
    return text.replace(
      regex,
      '<mark style="background-color: #fbbf24; color: #000; padding: 2px 4px; border-radius: 2px;">$1</mark>',
    );
  }

  /**
   * カラムの固定状態を切り替え
   * @param {number} columnIndex - カラムのインデックス
   */
  toggleColumnPin(columnIndex) {
    if (this.pinnedColumns.has(columnIndex)) {
      this.pinnedColumns.delete(columnIndex);
    } else {
      this.pinnedColumns.add(columnIndex);
    }
    this.renderTable();
  }

  /**
   * テーブルをレンダリング
   */
  renderTable() {
    if (!this.tableContainer) return;

    // 既存のテーブルを削除
    this.tableContainer.innerHTML = "";

    // テーブル要素を作成
    const table = document.createElement("table");
    table.className = "csv-data-table";
    table.style.cssText = `
								width: 100%;
								border-collapse: collapse;
								background: #0f172a;
								font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
								font-size: 14px;
								position: relative;
						`;

    // ヘッダーを作成
    const thead = this.createTableHeader();
    table.appendChild(thead);

    // ボディを作成
    const tbody = this.createTableBody();
    table.appendChild(tbody);

    // テーブルコンテナにスタイルを適用
    this.tableContainer.style.cssText += `
								scrollbar-width: thin;
								scrollbar-color: #475569 #1e293b;
						`;

    // Webkitスクロールバーのスタイル
    const style = document.createElement("style");
    style.textContent = `
								.csv-table-container::-webkit-scrollbar {
										width: 8px;
										height: 8px;
								}
								.csv-table-container::-webkit-scrollbar-track {
										background: #1e293b;
										border-radius: 4px;
								}
								.csv-table-container::-webkit-scrollbar-thumb {
										background: #475569;
										border-radius: 4px;
								}
								.csv-table-container::-webkit-scrollbar-thumb:hover {
										background: #64748b;
								}
						`;

    if (!document.querySelector("#csv-modal-styles")) {
      style.id = "csv-modal-styles";
      document.head.appendChild(style);
    }

    this.tableContainer.appendChild(table);
  }

  /**
   * テーブルヘッダーを作成
   * @returns {HTMLElement} thead要素
   */
  createTableHeader() {
    const thead = document.createElement("thead");
    thead.style.cssText = `
								position: sticky;
								top: 0;
								z-index: 100;
								background: linear-gradient(135deg, #1e293b 0%, #334155 100%);
								box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
						`;

    const tr = document.createElement("tr");

    this.headers.forEach((header, index) => {
      const th = document.createElement("th");

      // ヘッダーコンテナ
      const headerContainer = document.createElement("div");
      headerContainer.style.cssText = `
										display: flex;
										align-items: center;
										gap: 8px;
										justify-content: space-between;
								`;

      // ヘッダーテキスト
      const headerText = document.createElement("span");
      headerText.textContent = header;

      // ピン留めアイコン
      const pinIcon = document.createElement("div");
      pinIcon.style.cssText = `
										cursor: pointer;
										opacity: 0.7;
										transition: opacity 0.2s ease;
										flex-shrink: 0;
										width: 16px;
										height: 16px;
										display: flex;
										align-items: center;
										justify-content: center;
								`;

      const isPinned = this.pinnedColumns.has(index);

      if (isPinned) {
        pinIcon.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="currentColor" class="icon icon-tabler icons-tabler-filled icon-tabler-pin">
												<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
												<path d="M15.113 3.21l.094 .083l5.5 5.5a1 1 0 0 1 -1.175 1.59l-3.172 3.171l-1.424 3.797a1 1 0 0 1 -.158 .277l-.07 .08l-1.5 1.5a1 1 0 0 1 -1.32 .082l-.095 -.083l-2.793 -2.792l-3.793 3.792a1 1 0 0 1 -1.497 -1.32l.083 -.094l3.792 -3.793l-2.792 -2.793a1 1 0 0 1 -.083 -1.32l.083 -.094l1.5 -1.5a1 1 0 0 1 .258 -.187l.098 -.042l3.796 -1.425l3.171 -3.17a1 1 0 0 1 1.497 -1.26z" />
										</svg>`;
      } else {
        pinIcon.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-pin">
												<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
												<path d="M15 4.5l-4 4l-4 1.5l-1.5 1.5l7 7l1.5 -1.5l1.5 -4l4 -4" />
												<path d="M9 15l-4.5 4.5" />
												<path d="M14.5 4l5.5 5.5" />
										</svg>`;
      }

      pinIcon.addEventListener("mouseenter", () => {
        pinIcon.style.opacity = "1";
      });

      pinIcon.addEventListener("mouseleave", () => {
        pinIcon.style.opacity = "0.7";
      });

      pinIcon.addEventListener("click", (e) => {
        e.stopPropagation();
        this.toggleColumnPin(index);
      });

      headerContainer.appendChild(headerText);
      headerContainer.appendChild(pinIcon);
      th.appendChild(headerContainer);

      const isColumnPinned = this.pinnedColumns.has(index);
      const sortedPinnedColumns = Array.from(this.pinnedColumns).sort((a, b) => a - b);
      const pinnedPosition = isColumnPinned ? sortedPinnedColumns.indexOf(index) : -1;

      th.style.cssText = `
										padding: 16px 12px;
										text-align: left;
										font-weight: 600;
										color: #f1f5f9;
										background: inherit;
										border-bottom: 2px solid #475569;
										border-right: 1px solid #374151;
										white-space: nowrap;
										min-width: 120px;
										text-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
										position: sticky;
										top: 0;
										z-index: ${isColumnPinned ? 200 + pinnedPosition : 100};
										${isColumnPinned ? `left: ${pinnedPosition * 144}px; background: linear-gradient(135deg, #1e293b 0%, #334155 100%);` : ""}
								`;

      if (index === this.headers.length - 1) {
        th.style.borderRight = "none";
      }

      tr.appendChild(th);
    });

    thead.appendChild(tr);
    return thead;
  }

  /**
   * テーブルボディを作成
   * @returns {HTMLElement} tbody要素
   */
  createTableBody() {
    const tbody = document.createElement("tbody");
    const searchTerm = this.searchInput ? this.searchInput.value : "";

    this.filteredData.forEach((row, rowIndex) => {
      const tr = document.createElement("tr");
      tr.style.cssText = `
										transition: background-color 0.2s ease;
										${rowIndex % 2 === 0 ? "background: #0f172a;" : "background: #1e293b;"}
								`;

      // ホバー効果
      tr.addEventListener("mouseenter", () => {
        tr.style.background = "#2563eb";
        tr.style.transform = "scale(1.001)";
        // 固定カラムのホバー背景色も更新
        Array.from(tr.children).forEach((td, index) => {
          if (this.pinnedColumns.has(index)) {
            td.style.background = "#2563eb";
          }
        });
      });

      tr.addEventListener("mouseleave", () => {
        tr.style.background = rowIndex % 2 === 0 ? "#0f172a" : "#1e293b";
        tr.style.transform = "scale(1)";
        // 固定カラムの背景色を元に戻す
        Array.from(tr.children).forEach((td, index) => {
          if (this.pinnedColumns.has(index)) {
            td.style.background = rowIndex % 2 === 0 ? "#0f172a" : "#1e293b";
          }
        });
      });

      row.forEach((cell, cellIndex) => {
        const td = document.createElement("td");

        if (searchTerm.trim()) {
          td.innerHTML = this.highlightSearchTerm(cell, searchTerm);
        } else {
          td.textContent = cell;
        }

        const isColumnPinned = this.pinnedColumns.has(cellIndex);
        const sortedPinnedColumns = Array.from(this.pinnedColumns).sort((a, b) => a - b);
        const pinnedPosition = isColumnPinned ? sortedPinnedColumns.indexOf(cellIndex) : -1;

        td.style.cssText = `
												padding: 12px;
												color: #e2e8f0;
												border-bottom: 1px solid #334155;
												border-right: 1px solid #374151;
												white-space: nowrap;
												min-width: 120px;
												${isColumnPinned ? `position: sticky; left: ${pinnedPosition * 144}px; z-index: ${150 + pinnedPosition}; background: ${rowIndex % 2 === 0 ? "#0f172a" : "#1e293b"};` : ""}
										`;

        if (cellIndex === row.length - 1) {
          td.style.borderRight = "none";
        }

        tr.appendChild(td);
      });

      tbody.appendChild(tr);
    });

    return tbody;
  }

  /**
   * モーダルを表示
   */
  showModal() {
    if (this.modal) {
      document.body.appendChild(this.modal);

      // アニメーション効果
      this.modal.style.opacity = "0";
      this.modal.style.transform = "scale(0.9)";

      requestAnimationFrame(() => {
        this.modal.style.transition = "opacity 0.3s ease, transform 0.3s ease";
        this.modal.style.opacity = "1";
        this.modal.style.transform = "scale(1)";
      });
    }
  }

  /**
   * モーダルを閉じる
   */
  closeModal() {
    if (this.modal) {
      this.modal.style.transition = "opacity 0.3s ease, transform 0.3s ease";
      this.modal.style.opacity = "0";
      this.modal.style.transform = "scale(0.9)";

      setTimeout(() => {
        if (this.modal && this.modal.parentNode) {
          this.modal.parentNode.removeChild(this.modal);
        }
        this.modal = null;
      }, 300);
    }
  }

  /**
   * エラーメッセージを表示
   * @param {string} message - エラーメッセージ
   */
  showError(message) {
    const errorModal = document.createElement("div");
    errorModal.style.cssText = `
								position: fixed;
								top: 0;
								left: 0;
								width: 100vw;
								height: 100vh;
								background-color: rgba(0, 0, 0, 0.8);
								display: flex;
								justify-content: center;
								align-items: center;
								z-index: 10001;
						`;

    const errorContent = document.createElement("div");
    errorContent.style.cssText = `
								background: linear-gradient(135deg, #dc2626 0%, #b91c1c 100%);
								color: white;
								padding: 24px;
								border-radius: 12px;
								box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5);
								text-align: center;
								max-width: 400px;
								border: 1px solid #ef4444;
						`;

    errorContent.innerHTML = `
								<h3 style="margin: 0 0 12px 0; font-size: 18px;">エラー</h3>
								<p style="margin: 0 0 20px 0; font-size: 14px;">${message}</p>
								<button onclick="this.parentElement.parentElement.remove()"
																style="background: #fef2f2; color: #dc2626; border: none; padding: 8px 16px;
																							border-radius: 6px; cursor: pointer; font-weight: 500;">
										閉じる
								</button>
						`;

    errorModal.appendChild(errorContent);
    document.body.appendChild(errorModal);

    // 3秒後に自動で閉じる
    setTimeout(() => {
      if (errorModal.parentNode) {
        errorModal.parentNode.removeChild(errorModal);
      }
    }, 3000);
  }
}
元のCosenseページを見る ↗

Cosense Toolbox / Cosense をもっと面白く、もっと強力に使うためのツール群

GitHub ↗

マイ・ツールボックス

    積んだ import を1つにまとめてコピー → 自分の設定ページ(ユーザー名ページ)に貼るだけ。