Newer
Older
fixtape / src / content.js
// Контент-скрипт: режим выбора элементов, подсветка, форма ввода правки,
// сохранение в chrome.storage.local. UI живёт в закрытом Shadow DOM,
// чтобы стили страницы и экстеншена не мешали друг другу.
(() => {
  const STORAGE_KEY = 'ft_comments';
  const MAX_SELECTOR_DEPTH = 15;
  const MAX_TEXT_SNIPPET = 80;

  // ---------- Состояние ----------
  let active = false;
  let formOpen = false;
  let selected = []; // выбранные для текущей правки элементы

  // ---------- Shadow DOM ----------
  const host = document.createElement('div');
  host.id = 'ft-picker-host';
  host.style.cssText = 'all: initial; position: fixed; top: 0; left: 0; width: 0; height: 0; z-index: 2147483647; pointer-events: none;';
  const root = host.attachShadow({ mode: 'closed' });
  root.innerHTML = `
    <style>
      .overlay {
        position: fixed; pointer-events: none; display: none;
        background: rgba(79, 70, 229, 0.15);
        outline: 2px solid #4f46e5; outline-offset: -1px;
        border-radius: 2px;
      }
      .overlay-label {
        position: fixed; pointer-events: none; display: none;
        background: #4f46e5; color: #fff;
        font: 11px/1.4 ui-monospace, monospace;
        padding: 2px 6px; border-radius: 4px 4px 4px 0;
        white-space: nowrap; max-width: 320px;
        overflow: hidden; text-overflow: ellipsis;
      }
      .form {
        position: fixed; display: none; pointer-events: auto;
        width: 320px; box-sizing: border-box;
        background: #ffffff; color: #1e1b4b;
        border: 1px solid #c7d2fe; border-radius: 10px;
        box-shadow: 0 10px 30px rgba(30, 27, 75, 0.25);
        font: 13px/1.45 system-ui, sans-serif;
        padding: 10px 12px 12px;
      }
      .form * { box-sizing: border-box; }
      .form-head {
        display: flex; justify-content: space-between; gap: 8px;
        align-items: baseline; margin-bottom: 6px;
      }
      .form-count { font-weight: 600; }
      .form-hint { color: #6d28d9; font-size: 11px; }
      .targets {
        list-style: none; margin: 0 0 8px; padding: 0;
        max-height: 96px; overflow-y: auto;
      }
      .targets li {
        font: 11px/1.4 ui-monospace, monospace;
        color: #3730a3; background: #eef2ff;
        border-radius: 5px; padding: 3px 6px; margin: 2px 0;
        overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
      }
      textarea {
        width: 100%; min-height: 64px; resize: vertical;
        font: 13px/1.45 system-ui, sans-serif; color: #1e1b4b;
        border: 1px solid #c7d2fe; border-radius: 7px;
        padding: 7px 8px; margin: 0 0 8px;
      }
      textarea:focus { outline: 2px solid #a5b4fc; border-color: #6366f1; }
      .form-row { display: flex; gap: 8px; }
      button {
        font: 12px/1 system-ui, sans-serif; cursor: pointer;
        border-radius: 7px; padding: 7px 12px; border: 1px solid #c7d2fe;
        background: #fff; color: #3730a3;
      }
      button.save {
        background: #4f46e5; border-color: #4f46e5; color: #fff;
        font-weight: 600;
      }
      button.save:hover { background: #4338ca; }
      button.cancel:hover { background: #eef2ff; }
    </style>
    <div class="overlay"></div>
    <div class="overlay-label"></div>
    <div class="form">
      <div class="form-head">
        <span class="form-count">1 element</span>
        <span class="form-hint">Shift+click — add</span>
      </div>
      <ul class="targets"></ul>
      <textarea placeholder="What to change?"></textarea>
      <div class="form-row">
        <button class="save">Save · Ctrl+↵</button>
        <button class="cancel">Cancel</button>
      </div>
    </div>
  `;
  (document.documentElement || document).appendChild(host);

  const overlay = root.querySelector('.overlay');
  const overlayLabel = root.querySelector('.overlay-label');
  const form = root.querySelector('.form');
  const formCount = root.querySelector('.form-count');
  const targetsList = root.querySelector('.targets');
  const textarea = root.querySelector('textarea');
  const saveBtn = root.querySelector('.save');
  const cancelBtn = root.querySelector('.cancel');

  // ---------- Сообщения ----------
  chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => {
    if (msg.type === 'ft-toggle') {
      active ? deactivate() : activate();
      sendResponse({ active });
    } else if (msg.type === 'ft-get-state') {
      sendResponse({ active });
    }
  });

  // При загрузке страницы — обновить бейдж по уже сохранённым правкам.
  countForThisPage().then(sendBadge);

  // ---------- Вкл/выкл режима ----------
  let savedCursor = null;

  function activate() {
    active = true;
    savedCursor = document.documentElement.style.cursor;
    document.documentElement.style.cursor = 'crosshair';
    document.addEventListener('mousemove', onMove, true);
    document.addEventListener('mousedown', onMouseDown, true);
    document.addEventListener('click', onClick, true);
    document.addEventListener('keydown', onKeyDown, true);
    document.addEventListener('scroll', hideHighlight, true);
    window.addEventListener('resize', hideHighlight);
  }

  function deactivate() {
    active = false;
    closeForm();
    clearSelection();
    hideHighlight();
    document.documentElement.style.cursor = savedCursor ?? '';
    document.removeEventListener('mousemove', onMove, true);
    document.removeEventListener('mousedown', onMouseDown, true);
    document.removeEventListener('click', onClick, true);
    document.removeEventListener('keydown', onKeyDown, true);
    document.removeEventListener('scroll', hideHighlight, true);
    window.removeEventListener('resize', hideHighlight);
  }

  // ---------- Подсветка ----------
  function onMove(e) {
    const el = eventElement(e);
    if (!el) { hideHighlight(); return; }
    const rect = el.getBoundingClientRect();
    if (rect.width === 0 && rect.height === 0) { hideHighlight(); return; }
    overlay.style.display = 'block';
    overlay.style.top = rect.top + 'px';
    overlay.style.left = rect.left + 'px';
    overlay.style.width = rect.width + 'px';
    overlay.style.height = rect.height + 'px';

    const tag = el.tagName.toLowerCase();
    const cls = (typeof el.className === 'string' && el.className.trim())
      ? '.' + el.className.trim().split(/\s+/).slice(0, 2).join('.')
      : '';
    overlayLabel.style.display = 'block';
    overlayLabel.textContent = tag + cls;
    const labelBottom = rect.top - 4;
    if (labelBottom < 4) {
      overlayLabel.style.top = rect.bottom + 4 + 'px';
      overlayLabel.style.borderRadius = '0 4px 4px 4px';
    } else {
      overlayLabel.style.top = labelBottom + 'px';
      overlayLabel.style.borderRadius = '4px 4px 4px 0';
    }
    overlayLabel.style.left = rect.left + 'px';
  }

  function hideHighlight() {
    overlay.style.display = 'none';
    overlayLabel.style.display = 'none';
  }

  // ---------- Выбор ----------
  function eventElement(e) {
    const path = e.composedPath();
    if (!path || path.includes(host)) return null;
    const el = path[0];
    if (!(el instanceof Element)) return null;
    return el;
  }

  function onMouseDown(e) {
    if (!eventElement(e)) return;
    // Не даём странице ловить фокус/выделение, пока идёт выбор.
    e.preventDefault();
  }

  function onClick(e) {
    const el = eventElement(e);
    if (!el) return; // клик по нашему UI — пусть работает обычным образом
    e.preventDefault();
    e.stopImmediatePropagation();
    hideHighlight();
    if (formOpen && e.shiftKey) {
      if (!selected.includes(el)) selected.push(el);
      renderForm();
    } else {
      selected = [el];
      openForm();
    }
  }

  function onKeyDown(e) {
    if (e.key !== 'Escape') return;
    e.preventDefault();
    e.stopImmediatePropagation();
    if (formOpen) {
      closeForm();
      clearSelection();
    } else {
      deactivate();
    }
  }

  // ---------- Форма ----------
  function openForm() {
    formOpen = true;
    renderForm();
  }

  function renderForm() {
    formOpen = true;
    const n = selected.length;
    formCount.textContent = n === 1 ? '1 element' : `${n} elements`;
    targetsList.innerHTML = '';
    for (const el of selected) {
      const li = document.createElement('li');
      li.textContent = describeLine(el);
      li.title = li.textContent;
      targetsList.appendChild(li);
    }
    form.style.display = 'block';
    positionForm();
    textarea.focus();
  }

  function positionForm() {
    const anchor = selected[selected.length - 1];
    if (!anchor) { return; }
    const rect = anchor.getBoundingClientRect();
    const fw = form.offsetWidth || 320;
    const fh = form.offsetHeight || 200;
    const vw = document.documentElement.clientWidth;
    const vh = document.documentElement.clientHeight;
    let x = rect.right - fw; // прижимаем к правому краю элемента
    x = Math.min(Math.max(8, x), vw - fw - 8);
    let y = rect.bottom + 8;
    if (y + fh > vh - 8) {
      y = rect.top - fh - 8;
      if (y < 8) y = Math.min(Math.max(8, vh - fh - 8), rect.bottom + 8);
    }
    form.style.left = x + 'px';
    form.style.top = y + 'px';
  }

  function closeForm() {
    formOpen = false;
    form.style.display = 'none';
    textarea.value = '';
  }

  function clearSelection() {
    selected = [];
  }

  saveBtn.addEventListener('click', (e) => {
    e.stopPropagation();
    saveComment();
  });
  cancelBtn.addEventListener('click', (e) => {
    e.stopPropagation();
    closeForm();
    clearSelection();
  });
  textarea.addEventListener('keydown', (e) => {
    if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
      e.stopPropagation();
      saveComment();
    }
  });

  // ---------- Контекст элемента ----------
  function describeLine(el) {
    const tag = el.tagName.toLowerCase();
    const cls = el.getAttribute('class');
    const text = snippet(el);
    return `<${tag}${cls ? ' class="' + cls + '"' : ''}${text ? ' «' + text + '»' : ''}>`;
  }

  function snippet(el) {
    return (el.textContent || '').replace(/\s+/g, ' ').trim().slice(0, MAX_TEXT_SNIPPET);
  }

  function elementContext(el) {
    const tag = el.tagName.toLowerCase();
    const cls = (el.getAttribute('class') || '').trim().slice(0, 80);
    return { selector: buildSelector(el), tag, cls, text: snippet(el) };
  }

  // Путь до стабильного #id, иначе tag:nth-of-type(n), максимум 15 уровней.
  function buildSelector(el) {
    const parts = [];
    let cur = el;
    while (cur && cur.nodeType === 1 && parts.length < MAX_SELECTOR_DEPTH) {
      const tag = cur.tagName.toLowerCase();
      if (cur.id && document.querySelectorAll('#' + CSS.escape(cur.id)).length === 1) {
        parts.unshift('#' + CSS.escape(cur.id));
        break;
      }
      const parent = cur.parentElement;
      if (!parent) {
        parts.unshift(tag);
        break;
      }
      let nth = 1;
      for (let sib = cur.previousElementSibling; sib; sib = sib.previousElementSibling) {
        if (sib.tagName === cur.tagName) nth++;
      }
      parts.unshift(`${tag}:nth-of-type(${nth})`);
      cur = parent;
    }
    return parts.join(' > ');
  }

  // ---------- Сохранение ----------
  function normUrl() {
    return location.href.split('#')[0];
  }

  function saveComment() {
    const prompt = textarea.value.trim();
    if (!prompt || !selected.length) return;
    const contexts = selected.map(elementContext);
    closeForm();
    clearSelection();

    chrome.storage.local.get({ [STORAGE_KEY]: [] }, (data) => {
      const comments = data[STORAGE_KEY];
      comments.push({
        id: crypto.randomUUID(),
        url: normUrl(),
        prompt,
        elements: contexts,
        createdAt: Date.now(),
      });
      chrome.storage.local.set({ [STORAGE_KEY]: comments }, () => {
        sendBadge(comments.filter((c) => c.url === normUrl()).length);
      });
    });
  }

  function countForThisPage() {
    return new Promise((resolve) => {
      chrome.storage.local.get({ [STORAGE_KEY]: [] }, (data) => {
        resolve(data[STORAGE_KEY].filter((c) => c.url === normUrl()).length);
      });
    });
  }

  function sendBadge(count) {
    chrome.runtime.sendMessage({ type: 'ft-set-badge', count }).catch(() => {});
  }
})();