Gradescope Comment Viewer

Groups annotation comments by rubric item on Gradescope

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

Advertisement:

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

Advertisement:

// ==UserScript==
// @name         Gradescope Comment Viewer
// @namespace    https://www.gradescope.com
// @version      1.2
// @description  Groups annotation comments by rubric item on Gradescope
// @author       Justin Lyon
// @license      MIT
// @match        https://www.gradescope.com/courses/*/questions/*/submissions/*
// @grant        none
// @run-at       document-idle
// ==/UserScript==

(function () {
  "use strict";

  const path = window.location.pathname;
  const match = path.match(
    /\/courses\/(\d+)\/questions\/(\d+)\/submissions\/(\d+)/
  );
  if (!match) return;

  const [, courseId, questionId] = match;
  const baseUrl = `https://www.gradescope.com/courses/${courseId}/questions/${questionId}`;

  // --- Constants ---
  const MIN_WIDTH = 280;
  const MIN_HEIGHT = 200;
  const RESIZE_EDGE = 12;

  // --- Extract rubric data from the page's embedded JSON ---
  function extractPageData() {
    const raw = document.documentElement.innerHTML;
    const tmp = document.createElement("textarea");
    tmp.innerHTML = raw;
    const html = tmp.value;
    const rubricItems = [];
    const rubricGroups = [];

    function extractArray(source, key) {
      const marker = '"' + key + '":[';
      const start = source.indexOf(marker);
      if (start === -1) return null;
      const arrStart = start + marker.length - 1;
      let depth = 0;
      for (let i = arrStart; i < source.length; i++) {
        if (source[i] === "[") depth++;
        else if (source[i] === "]") {
          depth--;
          if (depth === 0) return source.slice(arrStart, i + 1);
        }
      }
      return null;
    }

    try {
      const itemsJson = extractArray(html, "rubric_items");
      if (itemsJson) rubricItems.push(...JSON.parse(itemsJson));
    } catch (_) { }
    try {
      const groupsJson = extractArray(html, "rubric_item_groups");
      if (groupsJson) rubricGroups.push(...JSON.parse(groupsJson));
    } catch (_) { }

    return { rubricItems, rubricGroups };
  }

  // --- Fetch annotation comments ---
  async function fetchComments() {
    const csrfMeta = document.querySelector('meta[name="csrf-token"]');
    const headers = {
      Accept: "application/json",
      "X-Requested-With": "XMLHttpRequest",
    };
    if (csrfMeta) headers["X-CSRF-Token"] = csrfMeta.content;

    const resp = await fetch(`${baseUrl}/submissions/annotation_comments`, {
      headers,
      credentials: "same-origin",
    });
    if (!resp.ok) throw new Error(`Failed to fetch comments: ${resp.status}`);
    return resp.json();
  }

  // --- Build grouped data structure ---
  function groupComments(comments, rubricItems, rubricGroups) {
    const groupMap = new Map();
    for (const g of rubricGroups) {
      groupMap.set(g.id, { ...g, items: [] });
    }

    const itemMap = new Map();
    for (const item of rubricItems) {
      const entry = { ...item, comments: [] };
      itemMap.set(item.id, entry);
      const group = groupMap.get(item.group_id);
      if (group) group.items.push(entry);
    }

    const ungrouped = [];

    for (const value of Object.values(comments)) {
      const [text, linkableId, linkableType] = value;
      if (linkableType === "RubricItem" && itemMap.has(linkableId)) {
        itemMap.get(linkableId).comments.push(text);
      } else if (
        linkableType === "RubricItemGroup" &&
        groupMap.has(linkableId)
      ) {
        const group = groupMap.get(linkableId);
        if (!group.groupComments) group.groupComments = [];
        group.groupComments.push(text);
      } else {
        ungrouped.push(text);
      }
    }

    const sortedGroups = [...groupMap.values()].sort(
      (a, b) => a.position - b.position
    );
    for (const g of sortedGroups) {
      g.items.sort((a, b) => a.position - b.position);
    }

    return { groups: sortedGroups, ungrouped };
  }

  // --- Dragging ---
  function makeDraggable(panel, handle) {
    let dx = 0,
      dy = 0,
      startX = 0,
      startY = 0;
    handle.style.cursor = "grab";
    handle.addEventListener("mousedown", (e) => {
      if (e.target.closest("button, input")) return;
      e.preventDefault();
      startX = e.clientX;
      startY = e.clientY;
      handle.style.cursor = "grabbing";
      const onMove = (e2) => {
        dx = e2.clientX - startX;
        dy = e2.clientY - startY;
        startX = e2.clientX;
        startY = e2.clientY;
        panel.style.top = panel.offsetTop + dy + "px";
        panel.style.left = panel.offsetLeft + dx + "px";
      };
      const onUp = () => {
        handle.style.cursor = "grab";
        document.removeEventListener("mousemove", onMove);
        document.removeEventListener("mouseup", onUp);
      };
      document.addEventListener("mousemove", onMove);
      document.addEventListener("mouseup", onUp);
    });
  }

  // --- Resizing from any edge ---
  function makeResizable(panel) {
    const EDGE = RESIZE_EDGE;
    let resizing = null;

    function getEdge(e) {
      const r = panel.getBoundingClientRect();
      const x = e.clientX, y = e.clientY;
      const edges = {
        top: y - r.top < EDGE,
        bottom: r.bottom - y < EDGE,
        left: x - r.left < EDGE,
        right: r.right - x < EDGE,
      };
      return edges;
    }

    function getCursor(edges) {
      if ((edges.top && edges.left) || (edges.bottom && edges.right)) return "nwse-resize";
      if ((edges.top && edges.right) || (edges.bottom && edges.left)) return "nesw-resize";
      if (edges.top || edges.bottom) return "ns-resize";
      if (edges.left || edges.right) return "ew-resize";
      return "";
    }

    panel.addEventListener("mousemove", (e) => {
      if (resizing) return;
      const edges = getEdge(e);
      const cursor = getCursor(edges);
      panel.style.cursor = cursor || "";
    });

    panel.addEventListener("mousedown", (e) => {
      const edges = getEdge(e);
      if (!edges.top && !edges.bottom && !edges.left && !edges.right) return;
      e.preventDefault();
      e.stopPropagation();
      const startX = e.clientX, startY = e.clientY;
      const rect = panel.getBoundingClientRect();
      resizing = { edges, startX, startY, startW: rect.width, startH: rect.height, startTop: rect.top, startLeft: rect.left };

      const onMove = (e2) => {
        const dx = e2.clientX - resizing.startX;
        const dy = e2.clientY - resizing.startY;
        if (resizing.edges.right) {
          panel.style.width = Math.max(MIN_WIDTH, resizing.startW + dx) + "px";
        }
        if (resizing.edges.left) {
          const newW = Math.max(MIN_WIDTH, resizing.startW - dx);
          panel.style.width = newW + "px";
          panel.style.left = resizing.startLeft + (resizing.startW - newW) + "px";
        }
        if (resizing.edges.bottom) {
          panel.style.height = Math.max(MIN_HEIGHT, resizing.startH + dy) + "px";
          panel.style.maxHeight = "none";
        }
        if (resizing.edges.top) {
          const newH = Math.max(MIN_HEIGHT, resizing.startH - dy);
          panel.style.height = newH + "px";
          panel.style.maxHeight = "none";
          panel.style.top = resizing.startTop + (resizing.startH - newH) + "px";
        }
      };
      const onUp = () => {
        resizing = null;
        document.removeEventListener("mousemove", onMove);
        document.removeEventListener("mouseup", onUp);
      };
      document.addEventListener("mousemove", onMove);
      document.addEventListener("mouseup", onUp);
    });
  }

  // --- Search with auto-expand ---
  function setupSearch(panel, content) {
    const searchBox = document.createElement("div");
    searchBox.className = "gs-cv-search";
    searchBox.innerHTML =
      '<input type="text" class="gs-cv-search-input" placeholder="Search comments..."><button class="gs-cv-search-clear">&times;</button>';
    const input = searchBox.querySelector("input");
    const clearBtn = searchBox.querySelector(".gs-cv-search-clear");
    clearBtn.onclick = () => {
      input.value = "";
      input.dispatchEvent(new Event("input"));
      input.focus();
    };

    // Ctrl+F inside panel focuses search
    panel.addEventListener("keydown", (e) => {
      if ((e.ctrlKey || e.metaKey) && e.key === "f") {
        e.preventDefault();
        e.stopPropagation();
        input.focus();
        input.select();
      }
    });

    function highlightText(el, q) {
      // Store original text if not already stored
      if (!el.dataset.originalText) el.dataset.originalText = el.textContent;
      const text = el.dataset.originalText;
      if (!q) { el.textContent = text; return false; }
      const lower = text.toLowerCase();
      const idx = lower.indexOf(q);
      if (idx === -1) { el.textContent = text; return false; }
      el.innerHTML = '';
      let pos = 0;
      let searchFrom = 0;
      while (true) {
        const i = lower.indexOf(q, searchFrom);
        if (i === -1) break;
        if (i > pos) el.appendChild(document.createTextNode(text.slice(pos, i)));
        const mark = document.createElement('mark');
        mark.className = 'gs-cv-mark';
        mark.textContent = text.slice(i, i + q.length);
        el.appendChild(mark);
        pos = i + q.length;
        searchFrom = pos;
      }
      if (pos < text.length) el.appendChild(document.createTextNode(text.slice(pos)));
      return true;
    }

    let searchTimeout;
    input.addEventListener("input", () => {
      clearTimeout(searchTimeout);
      searchTimeout = setTimeout(doSearch, 120);
    });

    function doSearch() {
      const q = input.value.trim().toLowerCase();
      const allComments = content.querySelectorAll(".gs-cv-comment");
      const allItemHeaders = content.querySelectorAll(".gs-cv-item-header");
      const allGroupHeaders = content.querySelectorAll(".gs-cv-group-header");
      const allItems = content.querySelectorAll(".gs-cv-item");
      const allGroups = content.querySelectorAll(".gs-cv-group");

      if (!q) {
        // Clear: restore all text, show everything
        allComments.forEach((c) => { highlightText(c, ''); c.style.display = ""; });
        allItemHeaders.forEach((h) => highlightText(h, ''));
        allGroupHeaders.forEach((h) => highlightText(h, ''));
        allItems.forEach((i) => (i.style.display = ""));
        allGroups.forEach((g) => (g.style.display = ""));
        return;
      }

      // Highlight and track matches in comments
      allComments.forEach((c) => {
        const matched = highlightText(c, q);
        c.style.display = matched ? "" : "none";
      });

      // Highlight item headers and check if they or their comments match
      allItems.forEach((itemEl) => {
        const headerEl = itemEl.querySelector(".gs-cv-item-header");
        const headerMatch = headerEl ? highlightText(headerEl, q) : false;
        const hasVisibleComment = itemEl.querySelector('.gs-cv-comment:not([style*="display: none"])');

        if (headerMatch) {
          // Header matches: show all its comments too
          itemEl.style.display = "";
          itemEl.querySelectorAll(".gs-cv-comment").forEach((c) => { c.style.display = ""; highlightText(c, q); });
          const itemBody = itemEl.querySelector(".gs-cv-item-body");
          if (itemBody) itemBody.classList.remove("gs-cv-collapsed");
        } else if (hasVisibleComment) {
          itemEl.style.display = "";
          const itemBody = itemEl.querySelector(".gs-cv-item-body");
          if (itemBody) itemBody.classList.remove("gs-cv-collapsed");
        } else {
          itemEl.style.display = "none";
        }
      });

      // Highlight group headers and check if they or children match
      allGroups.forEach((groupEl) => {
        const headerEl = groupEl.querySelector(".gs-cv-group-header");
        const headerMatch = headerEl ? highlightText(headerEl, q) : false;
        const hasVisibleChild = groupEl.querySelector('.gs-cv-item:not([style*="display: none"]), .gs-cv-comment:not([style*="display: none"])');

        if (headerMatch) {
          // Group header matches: show group and all children
          groupEl.style.display = "";
          groupEl.querySelectorAll(".gs-cv-item").forEach((i) => (i.style.display = ""));
          groupEl.querySelectorAll(".gs-cv-comment").forEach((c) => { c.style.display = ""; highlightText(c, q); });
          groupEl.querySelectorAll(".gs-cv-item-header").forEach((h) => highlightText(h, q));
          const groupBody = groupEl.querySelector(".gs-cv-group-body");
          if (groupBody) groupBody.classList.remove("gs-cv-collapsed");
          groupEl.querySelectorAll(".gs-cv-item-body").forEach((b) => b.classList.remove("gs-cv-collapsed"));
        } else if (hasVisibleChild) {
          groupEl.style.display = "";
          const groupBody = groupEl.querySelector(".gs-cv-group-body");
          if (groupBody) groupBody.classList.remove("gs-cv-collapsed");
        } else {
          groupEl.style.display = "none";
        }
      });
    }

    return searchBox;
  }

  // --- Render the panel ---
  function renderPanel(grouped) {
    const panel = document.createElement("div");
    panel.id = "gs-comment-viewer";
    panel.tabIndex = 0; // so it can receive keyboard events

    // Header
    const header = document.createElement("div");
    header.className = "gs-cv-header";

    const titleRow = document.createElement("div");
    titleRow.className = "gs-cv-title-row";

    const title = document.createElement("span");
    title.className = "gs-cv-title";
    title.textContent = "Comments";
    titleRow.appendChild(title);

    const btnGroup = document.createElement("div");
    btnGroup.className = "gs-cv-btn-group";

    const refreshBtn = document.createElement("button");
    refreshBtn.className = "gs-cv-btn gs-cv-refresh";
    refreshBtn.textContent = "Refresh";
    refreshBtn.title = "Reload comments";
    btnGroup.appendChild(refreshBtn);

    const expandAllBtn = document.createElement("button");
    expandAllBtn.className = "gs-cv-btn";
    expandAllBtn.textContent = "Expand All";
    expandAllBtn.title = "Expand all sections";
    btnGroup.appendChild(expandAllBtn);

    const collapseAllBtn = document.createElement("button");
    collapseAllBtn.className = "gs-cv-btn";
    collapseAllBtn.textContent = "Collapse All";
    collapseAllBtn.title = "Collapse all sections";
    btnGroup.appendChild(collapseAllBtn);

    const minimizeBtn = document.createElement("button");
    minimizeBtn.className = "gs-cv-btn gs-cv-minimize";
    minimizeBtn.innerHTML = "&#8722;"; // minus sign
    minimizeBtn.title = "Minimize";
    btnGroup.appendChild(minimizeBtn);

    const closeBtn = document.createElement("button");
    closeBtn.className = "gs-cv-btn gs-cv-close";
    closeBtn.innerHTML = "&times;";
    closeBtn.title = "Close";
    btnGroup.appendChild(closeBtn);

    titleRow.appendChild(btnGroup);
    header.appendChild(titleRow);
    panel.appendChild(header);

    // Content wrapper (search + scrollable content)
    const contentWrap = document.createElement("div");
    contentWrap.className = "gs-cv-content-wrap";

    const content = document.createElement("div");
    content.className = "gs-cv-content";

    // Search bar
    const searchBox = setupSearch(panel, content);
    contentWrap.appendChild(searchBox);

    // Track collapse state: key -> boolean (true = collapsed)
    const collapseState = new Map();

    function saveCollapseState() {
      content.querySelectorAll(".gs-cv-group").forEach((g) => {
        const key = "g:" + g.querySelector(".gs-cv-group-header")?.dataset.key;
        const body = g.querySelector(".gs-cv-group-body");
        if (body) collapseState.set(key, body.classList.contains("gs-cv-collapsed"));
      });
      content.querySelectorAll(".gs-cv-item").forEach((i) => {
        const key = "i:" + i.querySelector(".gs-cv-item-header")?.dataset.key;
        const body = i.querySelector(".gs-cv-item-body");
        if (body) collapseState.set(key, body.classList.contains("gs-cv-collapsed"));
      });
    }

    function buildContent(grouped) {
      saveCollapseState();
      content.innerHTML = "";
      let total = 0;

      function buildGroup(description, commentsList, items) {
        const commentCount =
          (commentsList?.length || 0) +
          (items || []).reduce((s, i) => s + i.comments.length, 0);
        if (commentCount === 0) return null;

        const groupKey = "g:" + description;
        const groupEl = document.createElement("div");
        groupEl.className = "gs-cv-group";

        const groupHeader = document.createElement("div");
        groupHeader.className = "gs-cv-group-header";
        groupHeader.dataset.key = description;
        groupHeader.textContent = `${description} (${commentCount})`;
        groupHeader.onclick = () =>
          groupBody.classList.toggle("gs-cv-collapsed");
        groupEl.appendChild(groupHeader);

        const groupBody = document.createElement("div");
        groupBody.className = "gs-cv-group-body";
        // Restore state: if known, use saved; if new section, expand (not collapsed)
        if (collapseState.has(groupKey)) {
          if (collapseState.get(groupKey)) groupBody.classList.add("gs-cv-collapsed");
        }

        if (commentsList?.length) {
          for (const c of commentsList) {
            groupBody.appendChild(makeCommentEl(c));
            total++;
          }
        }

        if (items) {
          for (const item of items) {
            if (item.comments.length === 0) continue;
            const itemKey = "i:" + item.description;
            const itemEl = document.createElement("div");
            itemEl.className = "gs-cv-item";

            const itemHeader = document.createElement("div");
            itemHeader.className = "gs-cv-item-header";
            itemHeader.dataset.key = item.description;
            itemHeader.textContent = `${item.description} (${item.comments.length})`;
            itemHeader.onclick = (e) => {
              e.stopPropagation();
              itemBody.classList.toggle("gs-cv-collapsed");
            };
            itemEl.appendChild(itemHeader);

            const itemBody = document.createElement("div");
            itemBody.className = "gs-cv-item-body";
            if (collapseState.has(itemKey)) {
              if (collapseState.get(itemKey)) itemBody.classList.add("gs-cv-collapsed");
            }
            for (const c of item.comments) {
              itemBody.appendChild(makeCommentEl(c));
              total++;
            }
            itemEl.appendChild(itemBody);
            groupBody.appendChild(itemEl);
          }
        }

        groupEl.appendChild(groupBody);
        return groupEl;
      }

      for (const group of grouped.groups) {
        const el = buildGroup(
          group.description,
          group.groupComments,
          group.items
        );
        if (el) content.appendChild(el);
      }

      if (grouped.ungrouped.length > 0) {
        const el = buildGroup("Unlinked Comments", grouped.ungrouped, null);
        if (el) content.appendChild(el);
      }

      return total;
    }

    let totalComments = buildContent(grouped);

    contentWrap.appendChild(content);
    panel.appendChild(contentWrap);

    // --- Refresh logic ---
    let refreshing = false;
    async function refreshComments() {
      if (refreshing) return;
      refreshing = true;
      refreshBtn.textContent = "...";
      try {
        const comments = await fetchComments();
        const { rubricItems, rubricGroups } = extractPageData();
        const newGrouped = groupComments(comments, rubricItems, rubricGroups);
        totalComments = buildContent(newGrouped);
        toggleBtn.textContent = `Comments (${totalComments})`;
        // Re-trigger search if active
        const searchInput = panel.querySelector(".gs-cv-search-input");
        if (searchInput && searchInput.value.trim()) {
          searchInput.dispatchEvent(new Event("input"));
        }
        console.log("[GS-CV] Refreshed:", totalComments, "comments");
      } catch (err) {
        console.error("[GS-CV] Refresh error:", err);
      }
      refreshBtn.textContent = "Refresh";
      refreshing = false;
    }

    refreshBtn.onclick = refreshComments;

    // --- Expand/Collapse All ---
    expandAllBtn.onclick = () => {
      content
        .querySelectorAll(".gs-cv-collapsed")
        .forEach((el) => el.classList.remove("gs-cv-collapsed"));
    };
    collapseAllBtn.onclick = () => {
      content
        .querySelectorAll(".gs-cv-group-body, .gs-cv-item-body")
        .forEach((el) => el.classList.add("gs-cv-collapsed"));
    };

    // --- Minimize (collapse to just the header bar) ---
    let minimized = false;
    minimizeBtn.onclick = () => {
      minimized = !minimized;
      contentWrap.style.display = minimized ? "none" : "";
      panel.querySelector(".gs-cv-resize-grip").style.display = minimized
        ? "none"
        : "";
      minimizeBtn.innerHTML = minimized ? "&#43;" : "&#8722;";
      minimizeBtn.title = minimized ? "Restore" : "Minimize";
      if (minimized) {
        panel.style.height = "auto";
        panel.style.maxHeight = "none";
      } else {
        panel.style.height = "";
        panel.style.maxHeight = "";
      }
    };

    // --- Close ---
    const toggleBtn = document.createElement("button");
    toggleBtn.id = "gs-cv-toggle";
    toggleBtn.textContent = `Comments (${totalComments})`;
    toggleBtn.onclick = () => {
      panel.style.display = "flex";
      toggleBtn.style.display = "none";
    };

    closeBtn.onclick = () => {
      panel.style.display = "none";
      toggleBtn.style.display = "block";
    };

    // --- Styles ---
    if (!document.getElementById("gs-cv-styles")) {
    const style = document.createElement("style");
    style.id = "gs-cv-styles";
    style.textContent = `
      #gs-comment-viewer {
        position: fixed; top: 60px; left: 72px; width: 400px; height: calc(100vh - 200px);
        background: #fff; border: 1px solid #ccc; border-radius: 8px;
        box-shadow: 0 4px 20px rgba(0,0,0,0.15); z-index: 99999;
        display: flex; flex-direction: column; font-family: -apple-system, BlinkMacSystemFont, sans-serif;
        font-size: 14px; color: #333; overflow: hidden;
      }
      .gs-cv-header {
        padding: 8px 12px; border-bottom: 1px solid #eee; background: #f8f9fa;
        border-radius: 8px 8px 0 0; flex-shrink: 0;
      }
      .gs-cv-title-row {
        display: flex; justify-content: space-between; align-items: center;
      }
      .gs-cv-title { font-weight: 600; font-size: 15px; margin-right: 4px; }
      .gs-cv-btn-group { display: flex; gap: 4px; }
      .gs-cv-btn {
        background: none; border: 1px solid #ddd; border-radius: 4px;
        padding: 2px 8px; font-size: 12px; cursor: pointer; color: #555;
      }
      .gs-cv-btn:hover { background: #e8e8e8; color: #000; }
      .gs-cv-minimize, .gs-cv-close { font-size: 16px; padding: 0 6px; font-weight: bold; }
      .gs-cv-content-wrap { display: flex; flex-direction: column; flex: 1; min-height: 0; }
      .gs-cv-search { padding: 6px 8px; border-bottom: 1px solid #eee; flex-shrink: 0; position: relative; }
      .gs-cv-search-input {
        width: 100%; box-sizing: border-box; padding: 6px 28px 6px 10px;
        border: 1px solid #ddd; border-radius: 4px; font-size: 13px; outline: none;
      }
      .gs-cv-search-input:focus { border-color: #4a90d9; box-shadow: 0 0 0 2px rgba(74,144,217,0.2); }
      .gs-cv-search-clear {
        position: absolute; right: 14px; top: 50%; transform: translateY(-50%);
        background: none; border: none; font-size: 16px; color: #999; cursor: pointer;
        padding: 0 4px; line-height: 1;
      }
      .gs-cv-search-clear:hover { color: #333; }
      .gs-cv-content { overflow-y: auto; padding: 8px; flex: 1; }
      .gs-cv-group { margin-bottom: 6px; border: 1px solid #e8e8e8; border-radius: 6px; overflow: hidden; }
      .gs-cv-group-header {
        padding: 9px 12px; background: #f0f4f8; font-weight: 600; font-size: 14px;
        cursor: pointer; user-select: none;
      }
      .gs-cv-group-header:hover { background: #e4eaf0; }
      .gs-cv-group-body { padding: 4px 8px; }
      .gs-cv-group-body.gs-cv-collapsed { display: none; }
      .gs-cv-item { margin: 4px 0; }
      .gs-cv-item-header {
        padding: 6px 10px; background: #fafafa; font-weight: 500; font-size: 14px;
        color: #555; border-left: 3px solid #4a90d9; margin-bottom: 2px;
        cursor: pointer; user-select: none;
      }
      .gs-cv-item-header:hover { background: #f0f0f0; }
      .gs-cv-item-body { }
      .gs-cv-item-body.gs-cv-collapsed { display: none; }
      .gs-cv-comment {
        padding: 8px 12px; margin: 3px 0; background: #fffef5; border-radius: 4px;
        border: 1px solid #f0eedc; font-size: 14px; line-height: 1.5;
        cursor: pointer; transition: background 0.1s; white-space: pre-wrap;
      }
      .gs-cv-comment:hover { background: #fff9d4; }
      .gs-cv-comment.gs-cv-copied { background: #e6ffe6; border-color: #b3e6b3; }
      .gs-cv-mark { background: #ffe066; color: #000; padding: 0 1px; border-radius: 2px; }
      #gs-cv-toggle {
        position: fixed; top: 60px; left: 72px; z-index: 99998;
        background: #4a90d9; color: #fff; border: none; border-radius: 6px;
        padding: 8px 14px; font-size: 14px; font-weight: 600; cursor: pointer;
        box-shadow: 0 2px 8px rgba(0,0,0,0.15); display: none;
      }
      #gs-cv-toggle:hover { background: #3a7bc8; }
      .gs-cv-resize-grip {
        position: absolute; bottom: 0; right: 0; width: 16px; height: 16px;
        cursor: nwse-resize;
        background: linear-gradient(135deg, transparent 50%, #ccc 50%, transparent 55%, #ccc 65%, transparent 65%);
      }
    `;
    document.head.appendChild(style);
    }

    const grip = document.createElement("div");
    grip.className = "gs-cv-resize-grip";
    panel.appendChild(grip);

    makeDraggable(panel, header);
    makeResizable(panel);

    document.body.appendChild(panel);
    document.body.appendChild(toggleBtn);

    return { refresh: refreshComments };
  }

  function makeCommentEl(text) {
    const el = document.createElement("div");
    el.className = "gs-cv-comment";
    el.textContent = text;
    el.title = "Click to copy";
    el.addEventListener("click", () => {
      navigator.clipboard.writeText(text).then(() => {
        el.classList.add("gs-cv-copied");
        setTimeout(() => el.classList.remove("gs-cv-copied"), 600);
      });
    });
    return el;
  }

  // --- Intercept fetch to detect comment mutations ---
  function interceptFetch(onCommentChange) {
    const origFetch = window.fetch;
    window.fetch = function (url, opts) {
      const result = origFetch.apply(this, arguments);
      const method = (opts?.method || "GET").toUpperCase();
      if (
        method !== "GET" &&
        typeof url === "string" &&
        (url.includes("/annotation") || url.includes("/comments"))
      ) {
        result.then(() => {
          // Small delay to let the server process
          setTimeout(onCommentChange, 500);
        }).catch(() => { });
      }
      return result;
    };

    // Also intercept XMLHttpRequest for older code paths
    const origOpen = XMLHttpRequest.prototype.open;
    const origSend = XMLHttpRequest.prototype.send;
    XMLHttpRequest.prototype.open = function (method, url) {
      this._gscvMethod = method;
      this._gscvUrl = url;
      return origOpen.apply(this, arguments);
    };
    XMLHttpRequest.prototype.send = function () {
      if (
        this._gscvMethod &&
        this._gscvMethod.toUpperCase() !== "GET" &&
        typeof this._gscvUrl === "string" &&
        (this._gscvUrl.includes("/annotation") ||
          this._gscvUrl.includes("/comments"))
      ) {
        this.addEventListener("load", () => {
          setTimeout(onCommentChange, 500);
        });
      }
      return origSend.apply(this, arguments);
    };
  }

  // --- Main ---
  async function init() {
    console.log("[GS-CV] Script running on:", path);
    const { rubricItems, rubricGroups } = extractPageData();
    console.log(
      "[GS-CV] Found rubric items:",
      rubricItems.length,
      "groups:",
      rubricGroups.length
    );
    if (rubricItems.length === 0) return;

    try {
      const comments = await fetchComments();
      console.log("[GS-CV] Fetched comments:", Object.keys(comments).length);
      const grouped = groupComments(comments, rubricItems, rubricGroups);
      const { refresh } = renderPanel(grouped);
      console.log("[GS-CV] Panel rendered");

      // Auto-refresh when comments are created/updated/deleted
      interceptFetch(refresh);
    } catch (err) {
      console.error("[GS-CV] Error:", err);
      const errBtn = document.createElement("button");
      errBtn.id = "gs-cv-toggle";
      errBtn.textContent = "Comments (error loading)";
      errBtn.style.display = "block";
      errBtn.style.background = "#d9534f";
      errBtn.title = err.message;
      errBtn.onclick = () => { errBtn.remove(); init(); };
      document.body.appendChild(errBtn);
    }
  }

  init();
})();