diff --git a/README.md b/README.md new file mode 100644 index 0000000..b52ba79 --- /dev/null +++ b/README.md @@ -0,0 +1,58 @@ +# Fixtape + +Браузерный экстеншн (Chrome, MV3), упрощающий постановку правок ИИ-агенту при +разработке фронтенда: выбираете элемент на странице, пишете правку — к ней +автоматически прикрепляется контекст элемента (CSS-селектор, тег, текст). +Все правки копятся в «микстейп» и копируются одним структурированным запросом. + +## Установка + +1. Открыть `chrome://extensions` +2. Включить «Режим разработчика» +3. «Загрузить распакованное расширение» → выбрать папку проекта + +Для страниц `file://` дополнительно включите «Разрешить доступ к URL файлов» +на карточке экстеншена. + +## Использование + +1. **Включить режим выбора**: `Alt+Shift+Q` или кнопка «Pick mode» в popup + (клик по иконке экстеншена). Комбинацию можно сменить в + `chrome://extensions/shortcuts`. +2. Наведите курсор — элемент подсвечивается, рядом метка с тегом. +3. **Клик** по элементу открывает форму — опишите правку, сохраните + (`Ctrl+Enter` или кнопка). +4. **Shift+клик** по следующему элементу добавляет его к текущей правке + (один текст → несколько элементов). +5. `Escape` — закрыть форму / выйти из режима. +6. Правки сохраняются в локальное хранилище (переживают перезагрузку + страницы), число видно на бейдже иконки. +7. Откройте popup → «Copy all» — получите один запрос вида: + +``` +Общий контекст: страница http://localhost:5173/. Ниже набор отдельных правок; селектор указывает на целевой элемент. + +--- Правка 1 --- +Элемент: `body > main > section > button:nth-of-type(1)` ( + + + + `; + (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(() => {}); + } +})(); \ No newline at end of file diff --git a/src/popup.css b/src/popup.css new file mode 100644 index 0000000..a855187 --- /dev/null +++ b/src/popup.css @@ -0,0 +1,166 @@ +:root { + --indigo: #4f46e5; + --indigo-dark: #3730a3; + --bg: #eef2ff; + color-scheme: light; +} + +* { box-sizing: border-box; } + +body { + width: 360px; + max-height: 560px; + display: flex; + flex-direction: column; + margin: 0; + padding: 12px; + font: 13px/1.45 system-ui, sans-serif; + color: #1e1b4b; + background: #fff; +} + +.head h1 { + margin: 0; + font-size: 14px; + font-weight: 700; +} +.page-url { + color: #6d28d9; + font-size: 11px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.toggle { + display: flex; + justify-content: center; + align-items: center; + gap: 8px; + margin: 10px 0; + padding: 7px 10px; + border: 1px solid #c7d2fe; + border-radius: 8px; + background: var(--bg); + color: var(--indigo-dark); + font-weight: 600; + cursor: pointer; +} +.toggle.on { + background: var(--indigo); + border-color: var(--indigo); + color: #fff; +} +.toggle:disabled { opacity: 0.5; cursor: default; } +.toggle kbd { + font: 10px/1 ui-monospace, monospace; + padding: 3px 5px; + border: 1px solid #c7d2fe; + border-radius: 4px; + background: #fff; + color: #6366f1; +} +.toggle.on kbd { + border-color: rgba(255, 255, 255, 0.5); + background: transparent; + color: #fff; +} + +.list { + flex: 1; + overflow-y: auto; + margin: 0 0 10px; +} + +.item { + border: 1px solid #e0e7ff; + border-radius: 9px; + padding: 8px 10px; + margin-bottom: 8px; + background: var(--bg); +} +.item textarea { + width: 100%; + min-height: 40px; + resize: vertical; + font: 13px/1.45 system-ui, sans-serif; + color: #1e1b4b; + border: 1px solid #c7d2fe; + border-radius: 7px; + padding: 6px 8px; +} +.item .targets { + margin: 6px 0 4px; + padding: 0; + list-style: none; +} +.item .targets li { + font: 11px/1.4 ui-monospace, monospace; + color: var(--indigo-dark); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.item .del { + float: right; + border: none; + background: none; + color: #b91c1c; + cursor: pointer; + font-size: 14px; + padding: 0 2px; +} +.item .del:hover { color: #ef4444; } + +.empty { + color: #64748b; + padding: 12px 4px; +} + +.foot { + display: flex; + gap: 8px; +} +.foot button { + flex: 1; + padding: 8px 10px; + border-radius: 8px; + border: 1px solid #c7d2fe; + cursor: pointer; + font-weight: 600; +} +.primary { + background: var(--indigo); + border-color: var(--indigo); + color: #fff; +} +.primary:hover { background: #4338ca; } +.primary:disabled { opacity: 0.45; cursor: default; } +.danger { + background: #fff; + color: #b91c1c; +} +.danger:hover { background: #fef2f2; } +.danger.confirming { + background: #b91c1c; + border-color: #b91c1c; + color: #fff; +} + +.others { + margin-top: 8px; + font-size: 11px; + color: #64748b; +} + +.toast { + position: fixed; + bottom: 10px; + left: 50%; + transform: translateX(-50%); + background: var(--indigo-dark); + color: #fff; + padding: 5px 12px; + border-radius: 20px; + font-size: 12px; +} \ No newline at end of file diff --git a/src/popup.html b/src/popup.html new file mode 100644 index 0000000..98a7ff0 --- /dev/null +++ b/src/popup.html @@ -0,0 +1,35 @@ + + + + + + Fixtape + + +
+
+

Edits on this page

+
+
+
+ + + +
+ + + + +
+ + + + \ No newline at end of file diff --git a/src/popup.js b/src/popup.js new file mode 100644 index 0000000..76d428c --- /dev/null +++ b/src/popup.js @@ -0,0 +1,224 @@ +// Popup: список правок текущей страницы, редактирование/удаление, +// копирование всех правок одним запросом, очистка, toggle режима выбора. +(() => { + const STORAGE_KEY = 'ft_comments'; + const COPY_HEADER = (url) => + `Общий контекст: страница ${url}. Ниже набор отдельных правок; селектор указывает на целевой элемент.`; + + let tab = null; + let tabUrl = ''; + let comments = []; // правки текущей страницы + let pickActive = false; + + const listEl = document.getElementById('list'); + const emptyEl = document.getElementById('empty'); + const othersEl = document.getElementById('others'); + const copyBtn = document.getElementById('copy-all'); + const clearBtn = document.getElementById('clear-page'); + const toggleBtn = document.getElementById('toggle-mode'); + const toggleLabel = document.getElementById('toggle-label'); + const hotkeyEl = document.getElementById('hotkey'); + const CLEAR_LABEL = 'Clear'; + const CLEAR_CONFIRM = 'Confirm clear?'; + + init(); + + async function init() { + [tab] = await chrome.tabs.query({ active: true, currentWindow: true }); + tabUrl = tab ? tab.url.split('#')[0] : ''; + document.getElementById('page-url').textContent = tabUrl; + + try { + const res = await chrome.tabs.sendMessage(tab.id, { type: 'ft-get-state' }); + pickActive = !!res.active; + updateToggleLabel(); + } catch { + toggleBtn.disabled = true; // нет контент-скрипта (chrome:// и т.п.) + } + + const cmds = await chrome.commands.getAll(); + const cmd = cmds.find((c) => c.name === 'toggle-pick'); + if (cmd && cmd.shortcut) hotkeyEl.textContent = cmd.shortcut; + else hotkeyEl.remove(); + + toggleBtn.addEventListener('click', onToggleMode); + copyBtn.addEventListener('click', onCopyAll); + clearBtn.addEventListener('click', onClearPage); + document.addEventListener('keydown', (e) => { + if (e.key === 'Escape') resetClearConfirm(); + }); + await render(); + } + + function loadAll() { + return chrome.storage.local.get({ [STORAGE_KEY]: [] }).then((d) => d[STORAGE_KEY]); + } + + function saveAll(all) { + return chrome.storage.local.set({ [STORAGE_KEY]: all }); + } + + async function render() { + resetClearConfirm(); + const all = await loadAll(); + comments = all.filter((c) => c.url === tabUrl); + copyBtn.disabled = comments.length === 0; + clearBtn.disabled = comments.length === 0; + + listEl.innerHTML = ''; + emptyEl.hidden = comments.length > 0; + + comments.forEach((c) => listEl.appendChild(renderItem(c))); + + const others = all.length - comments.length; + othersEl.textContent = others > 0 + ? `${others} more edits on other pages` + : ''; + + updateBadge(comments.length); + } + + function renderItem(c) { + const item = document.createElement('div'); + item.className = 'item'; + + const del = document.createElement('button'); + del.className = 'del'; + del.title = 'Удалить правку'; + del.textContent = '✕'; + del.addEventListener('click', () => removeComment(c.id)); + item.appendChild(del); + + const ta = document.createElement('textarea'); + ta.value = c.prompt; + ta.addEventListener('change', () => updatePrompt(c.id, ta.value.trim())); + item.appendChild(ta); + + const targets = document.createElement('ul'); + targets.className = 'targets'; + for (const el of c.elements) { + const li = document.createElement('li'); + li.textContent = elementLine(el); + li.title = li.textContent; + targets.appendChild(li); + } + item.appendChild(targets); + + return item; + } + + function elementLine(el) { + const parts = [`<${el.tag}`]; + if (el.cls) parts.push(` class="${el.cls}"`); + if (el.text) parts.push(` «${el.text}»`); + return `\`${el.selector}\` ${parts.join('')}>`; + } + + async function updatePrompt(id, prompt) { + if (!prompt) { await removeComment(id); return; } + const all = await loadAll(); + const c = all.find((x) => x.id === id); + if (!c) return; + c.prompt = prompt; + await saveAll(all); + } + + async function removeComment(id) { + const all = await loadAll(); + await saveAll(all.filter((x) => x.id !== id)); + await render(); + } + + let confirmTimer = null; + + function onClearPage() { + if (!comments.length) return; + if (!clearBtn.classList.contains('confirming')) { + clearBtn.classList.add('confirming'); + clearBtn.textContent = CLEAR_CONFIRM; + confirmTimer = setTimeout(resetClearConfirm, 3000); + return; + } + resetClearConfirm(); + clearPage(); + } + + function resetClearConfirm() { + clearTimeout(confirmTimer); + confirmTimer = null; + clearBtn.classList.remove('confirming'); + clearBtn.textContent = CLEAR_LABEL; + } + + async function clearPage() { + const all = await loadAll(); + await saveAll(all.filter((x) => x.url !== tabUrl)); + await render(); + } + + function buildCopyText() { + const lines = [COPY_HEADER(tabUrl), '']; + comments.forEach((c, i) => { + lines.push(`--- Правка ${i + 1} ---`); + c.elements.forEach((el, j) => { + const label = c.elements.length > 1 ? `Элемент ${j + 1}` : 'Элемент'; + lines.push(`${label}: ${elementLine(el)}`); + }); + lines.push(c.prompt, ''); + }); + return lines.join('\n').trimEnd() + '\n'; + } + + async function onCopyAll() { + if (!comments.length) return; + const text = buildCopyText(); + try { + await navigator.clipboard.writeText(text); + toast('Copied ✓'); + } catch { + fallbackCopy(text); + toast('Copied ✓'); + } + } + + function fallbackCopy(text) { + const ta = document.createElement('textarea'); + ta.value = text; + ta.style.position = 'fixed'; + ta.style.opacity = '0'; + document.body.appendChild(ta); + ta.select(); + document.execCommand('copy'); + ta.remove(); + } + + function toast(msg) { + const el = document.createElement('div'); + el.className = 'toast'; + el.textContent = msg; + document.body.appendChild(el); + setTimeout(() => el.remove(), 1400); + } + + async function onToggleMode() { + try { + const res = await chrome.tabs.sendMessage(tab.id, { type: 'ft-toggle' }); + pickActive = !!res.active; + updateToggleLabel(); + } catch { + // нет контент-скрипта + } + } + + function updateToggleLabel() { + toggleLabel.textContent = pickActive ? '🎯 Pick mode: ON' : '🎯 Pick mode'; + toggleBtn.classList.toggle('on', pickActive); + } + + function updateBadge(count) { + if (tab && tab.id != null) { + chrome.action.setBadgeBackgroundColor({ tabId: tab.id, color: '#4f46e5' }); + chrome.action.setBadgeText({ tabId: tab.id, text: count > 0 ? String(count) : '' }); + } + } +})(); \ No newline at end of file diff --git a/test/page.html b/test/page.html new file mode 100644 index 0000000..364e758 --- /dev/null +++ b/test/page.html @@ -0,0 +1,123 @@ + + + + + Тестовая страница — Fixtape + + + +
+ +
+
+

Тестовая страница для экстеншена

+

Наведите курсор на любой элемент и кликните, чтобы открыть форму правки. Shift+клик добавляет элемент к текущей группе.

+ + +
+ +
+
+

Быстро

+

Правки собираются за несколько кликов.

+
+
+

Точно

+

Селектор указывает агенту точно на элемент.

+
+
+

Удобно

+

Копируете всё одним запросом.

+
+
+ +
+

Подписка на новости

+ + +
+ +

Загрузите экстеншн через chrome://extensions → Load unpacked.

+
+ + \ No newline at end of file