// Контент-скрипт: режим выбора элементов, подсветка, форма ввода правки,
// сохранение в 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(99, 102, 241, 0.25);
outline: 2px solid #818cf8; outline-offset: -1px;
border-radius: 2px;
}
.overlay-label {
position: fixed; pointer-events: none; display: none;
background: #6366f1; 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: #111827; color: #e2e8f0;
border: 1px solid #334155; border-radius: 10px;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.5);
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: #818cf8; 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: #a5b4fc; background: rgba(99, 102, 241, 0.14);
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: #e2e8f0;
background: #0b1120;
border: 1px solid #334155; border-radius: 7px;
padding: 7px 8px; margin: 0 0 8px;
}
textarea:focus { outline: 2px solid #6366f1; 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 #334155;
background: #1e293b; color: #cbd5e1;
}
button.save {
background: #6366f1; border-color: #6366f1; color: #fff;
font-weight: 600;
}
button.save:hover { background: #4f46e5; }
button.cancel:hover { background: #26334d; }
</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(() => {});
}
})();