Skip to content
vast-cow's blog
Go back

A Bookmarklet for Jumping Between ChatGPT Conversation Articles

Edit page

Overview

The content describes a JavaScript bookmarklet designed to help users jump quickly to specific parts of a ChatGPT conversation. On pages where the conversation is rendered as multiple <article> elements, the bookmarklet detects those articles, presents them in a searchable interface, and scrolls smoothly to the selected target.

In practical terms, it adds a lightweight “navigation panel” on top of the current page so you can filter and select an article instead of manually scrolling.

What the Bookmarklet Does

Scans the Page for Conversation Segments

After execution, the script:

To keep labels readable, the script normalizes whitespace and truncates the preview to a fixed length.

Displays a Modal Overlay Navigator

The bookmarklet creates a full-screen overlay with a centered modal panel. This UI includes:

If a previous instance of the UI already exists, it is removed first so the bookmarklet can be run repeatedly without stacking multiple overlays.

User Interaction and Accessibility Features

Search and Filtering

As the user types in the search box, the list filters in real time by substring matching against each label. If there are no matches, the list shows a “No matches” message.

Keyboard Navigation

The UI is designed to be operable without a mouse:

The script also maintains accessibility attributes such as:

Mouse Interaction

Users can also:

How the Jump Works

When an article is selected, the script:

  1. Closes the overlay cleanly (removing event listeners and DOM nodes).
  2. Calls scrollIntoView with smooth scrolling to bring the chosen <article> to the top of the viewport.
  3. Temporarily adds a visible outline to the target article to make it easy to spot.
  4. Removes the outline after a short delay.

This combination provides both navigation and a brief visual confirmation of where the user landed.

Error Handling and Cleanup

The bookmarklet is wrapped in a try/catch block. If any unexpected error occurs during execution, it shows an alert with a descriptive message.

It also implements a dedicated cleanup routine that:

This prevents the UI from lingering or leaving behind handlers that could interfere with normal page behavior.

Summary

This bookmarklet provides a practical, accessible way to navigate long ChatGPT conversations by treating each <article> element as a jump destination. With a searchable list, keyboard controls, and smooth scrolling plus highlighting, it effectively turns a lengthy scroll experience into a fast “type-and-jump” workflow.

(() => {
  try {
    const KEY = '__article_jump_ui__';
    const old = document.getElementById(KEY);
    if (old) old.remove();

    const arts = Array.from(document.querySelectorAll('article'));
    if (!arts.length) {
      alert('No <article> elements found');
      return;
    }

    const normalize = (s) =>
      String(s ?? '')
        .replace(/\s+/g, ' ')
        .trim();

    const head = (t) => {
      const s = normalize(t);
      return s.length ? s.slice(0, 180) : '(no text)';
    };

    const items = arts.map((a, i) => ({
      i,
      el: a,
      label: `${i + 1}. ${head(a.textContent)}`,
    }));

    // Root overlay
    const overlay = document.createElement('div');
    overlay.id = KEY;
    overlay.style.cssText =
      'position:fixed;inset:0;z-index:2147483647;background:rgba(0,0,0,.55);display:flex;align-items:center;justify-content:center;padding:18px;';

    // Modal panel (wider)
    const panel = document.createElement('div');
    panel.setAttribute('role', 'dialog');
    panel.setAttribute('aria-modal', 'true');
    panel.setAttribute('aria-label', 'Article Jump');
    panel.style.cssText =
      'width:min(1280px,calc(100vw - 36px));height:min(86vh,920px);background:rgba(255,255,255,.98);border:1px solid rgba(0,0,0,.25);border-radius:14px;box-shadow:0 20px 60px rgba(0,0,0,.35);display:flex;flex-direction:column;overflow:hidden;font:14px/1.45 -apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica,Arial,sans-serif;color:#111;';

    // Header
    const header = document.createElement('div');
    header.style.cssText =
      'padding:14px 16px;border-bottom:1px solid rgba(0,0,0,.12);display:flex;align-items:center;gap:10px;';
    const title = document.createElement('div');
    title.style.cssText = 'font-weight:700;';
    title.textContent = `Article Jump (${items.length})`;
    const spacer = document.createElement('div');
    spacer.style.cssText = 'flex:1;';
    const close = document.createElement('button');
    close.type = 'button';
    close.textContent = 'Close';
    close.setAttribute('aria-label', 'Close');
    close.style.cssText =
      'border:1px solid rgba(0,0,0,.25);background:#fff;border-radius:10px;padding:8px 10px;cursor:pointer;';
    header.appendChild(title);
    header.appendChild(spacer);
    header.appendChild(close);

    // Body
    const body = document.createElement('div');
    body.style.cssText =
      'padding:14px 16px;display:flex;flex-direction:column;gap:12px;overflow:hidden;';

    // Combobox row
    const row = document.createElement('div');
    row.style.cssText = 'display:flex;gap:10px;align-items:center;';

    const comboWrap = document.createElement('div');
    comboWrap.style.cssText = 'position:relative;flex:1;min-width:0;';

    const input = document.createElement('input');
    input.type = 'text';
    input.placeholder = 'Search target article…';
    input.autocomplete = 'off';
    input.spellcheck = false;
    input.style.cssText =
      'width:100%;box-sizing:border-box;padding:10px 12px;border-radius:12px;border:1px solid rgba(0,0,0,.25);background:#fff;font-size:14px;';

    const listId = KEY + '_listbox';
    input.setAttribute('role', 'combobox');
    input.setAttribute('aria-autocomplete', 'list');
    input.setAttribute('aria-expanded', 'false');
    input.setAttribute('aria-controls', listId);
    input.setAttribute('aria-haspopup', 'listbox');

    // Listbox: make it a LARGE, always-within-panel area instead of a small dropdown
    const list = document.createElement('div');
    list.id = listId;
    list.setAttribute('role', 'listbox');
    list.style.cssText =
      'margin-top:12px;flex:1;min-height:240px;overflow:auto;border:1px solid rgba(0,0,0,.18);border-radius:14px;background:#fff;box-shadow:0 12px 32px rgba(0,0,0,.12);';

    const go = document.createElement('button');
    go.type = 'button';
    go.textContent = 'Go';
    go.style.cssText =
      'padding:10px 14px;border-radius:12px;border:1px solid rgba(0,0,0,.25);background:#f6f6f6;cursor:pointer;white-space:nowrap;font-size:14px;';

    const hint = document.createElement('div');
    hint.style.cssText = 'color:rgba(0,0,0,.65);font-size:12.5px;';
    hint.textContent =
      'Type to filter. Use ↑/↓ to navigate, Enter to jump, Esc to close. Click outside the panel to close.';

    // Footer
    const footer = document.createElement('div');
    footer.style.cssText =
      'padding:12px 16px;border-top:1px solid rgba(0,0,0,.12);display:flex;gap:10px;align-items:center;justify-content:space-between;color:rgba(0,0,0,.7);font-size:12.5px;';
    const leftInfo = document.createElement('div');
    leftInfo.textContent = 'Enter: jump';
    const rightInfo = document.createElement('div');
    rightInfo.textContent = 'Esc: close';
    footer.appendChild(leftInfo);
    footer.appendChild(rightInfo);

    // Compose body
    row.appendChild(comboWrap);
    row.appendChild(go);
    comboWrap.appendChild(input);
    body.appendChild(row);
    body.appendChild(hint);
    body.appendChild(list);

    panel.appendChild(header);
    panel.appendChild(body);
    panel.appendChild(footer);
    overlay.appendChild(panel);
    document.documentElement.appendChild(overlay);

    // State
    let filtered = [...items];
    let activeIndex = -1;

    const optionId = (idx) => `${KEY}_opt_${idx}`;

    const highlight = (idx, scroll = true) => {
      const max = filtered.length - 1;
      const n = Math.max(-1, Math.min(idx, max));
      activeIndex = n;

      const opts = list.querySelectorAll('[role="option"]');
      opts.forEach((el, j) => {
        const active = j === activeIndex;
        el.setAttribute('aria-selected', active ? 'true' : 'false');
        el.style.background = active ? 'rgba(0,0,0,.06)' : 'transparent';
      });

      if (activeIndex >= 0) {
        const id = optionId(activeIndex);
        input.setAttribute('aria-activedescendant', id);
        if (scroll) {
          const el = document.getElementById(id);
          if (el) el.scrollIntoView({ block: 'nearest' });
        }
      } else {
        input.removeAttribute('aria-activedescendant');
      }
    };

    const jump = (item) => {
      if (!item) return;
      const a = item.el;

      cleanup();

      a.scrollIntoView({ behavior: 'smooth', block: 'start' });
      a.style.outline = '3px solid rgba(0,120,255,.55)';
      a.style.outlineOffset = '4px';
      setTimeout(() => {
        a.style.outline = '';
        a.style.outlineOffset = '';
      }, 1200);
    };

    const selectActive = () => {
      if (activeIndex < 0 || activeIndex >= filtered.length) return;
      const it = filtered[activeIndex];
      input.value = it.label;
      jump(it);
    };

    const render = () => {
      list.innerHTML = '';
      if (!filtered.length) {
        const empty = document.createElement('div');
        empty.style.cssText = 'padding:12px 12px;color:rgba(0,0,0,.6);';
        empty.textContent = 'No matches';
        list.appendChild(empty);
        highlight(-1, false);
        return;
      }

      filtered.forEach((it, j) => {
        const opt = document.createElement('div');
        opt.id = optionId(j);
        opt.setAttribute('role', 'option');
        opt.setAttribute('aria-selected', 'false');
        opt.style.cssText =
          'padding:10px 12px;cursor:pointer;user-select:none;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;';
        opt.textContent = it.label;

        opt.addEventListener('mousemove', () => highlight(j, false));
        opt.addEventListener('mousedown', (e) => e.preventDefault());
        opt.addEventListener('click', () => {
          highlight(j, false);
          selectActive();
        });

        list.appendChild(opt);
      });

      if (activeIndex >= filtered.length) activeIndex = -1;
      if (activeIndex === -1) highlight(0, false);
      else highlight(activeIndex, false);
    };

    const filterNow = () => {
      const q = normalize(input.value).toLowerCase();
      filtered = q
        ? items.filter((it) => it.label.toLowerCase().includes(q))
        : items.slice();
      activeIndex = -1;
      render();
    };

    const onOverlayDown = (e) => {
      if (e.target === overlay) cleanup();
    };

    const onKey = (e) => {
      if (e.key === 'Escape') {
        e.preventDefault();
        cleanup();
      }
    };

    const cleanup = () => {
      overlay.removeEventListener('mousedown', onOverlayDown, true);
      document.removeEventListener('keydown', onKey, true);
      overlay.remove();
    };

    close.onclick = cleanup;

    overlay.addEventListener('mousedown', onOverlayDown, true);
    document.addEventListener('keydown', onKey, true);

    input.addEventListener('input', () => {
      filterNow();
    });

    input.addEventListener('keydown', (e) => {
      if (e.key === 'ArrowDown') {
        e.preventDefault();
        if (!filtered.length) return;
        highlight(activeIndex + 1);
      } else if (e.key === 'ArrowUp') {
        e.preventDefault();
        if (!filtered.length) return;
        highlight(activeIndex - 1);
      } else if (e.key === 'Enter') {
        e.preventDefault();
        selectActive();
      } else if (e.key === 'Tab') {
        // keep modal open; do nothing special
      } else if (e.key === 'Escape') {
        e.preventDefault();
        cleanup();
      }
    });

    go.addEventListener('click', () => {
      if (activeIndex === -1 && filtered.length) highlight(0, false);
      selectActive();
    });

    // Initial render
    filterNow();
    input.focus();
  } catch (e) {
    alert('Bookmarklet error: ' + (e && e.message ? e.message : e));
  }
})();

Edit page
Share this post:

Comments


Previous Post
A Simple Bookmarklet Selector UI
Next Post
Fonts to Use for English Posters: Practical Picks That Always Work