トレース機能の実装
// ==UserScript==
// @name MagicalDraw - trace + AutoDraw
// @namespace https://greasyfork.org/ja/users/941284-ぐらんぴ
// @version 2026-02-21-3
// @description トレース機能の実装
// @author ぐらんぴ
// @match *://*/*
// @icon https://www.google.com/s2/favicons?sz=64&domain=kuku.lu
// @grant none
// @license MIT
// ==/UserScript==
//Utilities
const $S = sel => document.querySelector(sel);
const $SA = sel => Array.from(document.querySelectorAll(sel));
const $C = (tag, props = {}, styles = {}) => {
const el = document.createElement(tag);
Object.assign(el, props);
Object.assign(el.style, styles);
return el;
};
const log = (...a) => console.log('[ぐらんぴ]', ...a);
/* ------------------------- WebSocket capture (Tampermonkey: unsafeWindow) ------------------------- */
let ws = null;
const origWb = unsafeWindow.WebSocket;
unsafeWindow.WebSocket = function (...args) {
if(!isTargetPage()) return;
ws = new origWb(...args);
ws.addEventListener('open', () => { console.log('WebSocket接続確立'); });
return ws;
};
/* ------------------------- Messaging helpers (optional) ------------------------- */
const BC_NAME = 'trace-image-channel';
let bc = null;
try { bc = new BroadcastChannel(BC_NAME); } catch (e) { bc = null; }
function broadcastDataURL(dataURL) {
const msg = { type: 'trace-image', dataURL };
try { if (bc) bc.postMessage(msg); } catch (e) {}
try {
const targets = [window.opener, window.parent, window.top];
for (const t of targets) {
if (t && t !== window) {
try { t.postMessage(msg, '*'); } catch (e) {}
}
}
} catch (e) {}
}
/* ------------------------- Target detection ------------------------- */
const TARGET_PREFIX = 'https://draw.kuku.lu/room/?hash=';
function isTargetPage() {
try { return location.href.startsWith(TARGET_PREFIX); } catch (e) { return false; }
}
/* ------------------------- Core state & helpers ------------------------- */
let insertedWrapper = null;
let originalTracePreviewClone = null;
let previewBaseScale = 1;
let previewImg = null;
let canvasImg = null;
let state = { tx: 0, ty: 0, scale: 1, rot: 0, opacity: 1 };
function applyTransformToPreview() {
if (!previewImg) return;
const base = previewBaseScale || 1;
const s = (state.scale || 1) * base;
const r = state.rot || 0;
previewImg.style.transform = `translate(-50%,-50%) rotate(${r}deg) scale(${s})`;
previewImg.style.transformOrigin = '50% 50%';
previewImg.style.opacity = String(state.opacity == null ? 1 : state.opacity);
}
function applyTransformToCanvas() {
if (!canvasImg) return;
const canvasContainer = $S('#cvws_pend')//$S('#area-canvas') || document.body;
const guideEl = $S('#cvws_pend')//$S('#cvws_guide') || $S('#cvws_pend') || canvasContainer;
const guideRect = guideEl.getBoundingClientRect();
const containerRect = canvasContainer.getBoundingClientRect();
const guideCenterX = guideRect.left + guideRect.width / 2;
const guideCenterY = guideRect.top + guideRect.height / 2;
const left = guideCenterX - containerRect.left;
const top = guideCenterY - containerRect.top;
const natW = canvasImg.naturalWidth || canvasImg.width || 1;
const natH = canvasImg.naturalHeight || canvasImg.height || 1;
const scaleToGuideX = guideRect.width / natW;
const scaleToGuideY = guideRect.height / natH;
const fitScale = Math.min(scaleToGuideX, scaleToGuideY);
const safeFitScale = (isFinite(fitScale) && fitScale > 0) ? fitScale : 1;
const userScale = state.scale || 1;
const totalScale = Math.max(0.0001, Math.min(100, safeFitScale * userScale));
const offsetX = Number(state.tx) || 0;
const offsetY = Number(state.ty) || 0;
const finalLeft = left + offsetX * safeFitScale * userScale;
const finalTop = top + offsetY * safeFitScale * userScale;
canvasImg.style.position = 'absolute';
canvasImg.style.left = finalLeft + 'px';
canvasImg.style.top = finalTop + 'px';
canvasImg.style.transformOrigin = '50% 50%';
canvasImg.style.transform = `translate(-50%,-50%) rotate(${(state.rot || 0)}deg) scale(${totalScale})`;
canvasImg.style.opacity = String(Number(state.opacity) || 0);
canvasImg.style.willChange = 'opacity, transform';
}
/* ------------------------- Image pixel helper ------------------------- */
function getImagePixels(img) {
const w = img.naturalWidth || img.width || 1;
const h = img.naturalHeight || img.height || 1;
const canvas = document.createElement('canvas');
canvas.width = w;
canvas.height = h;
const ctx = canvas.getContext('2d');
ctx.clearRect(0, 0, w, h);
ctx.drawImage(img, 0, 0, w, h);
return ctx.getImageData(0, 0, w, h);
}
/* ------------------------- Process DataURL into preview + canvas image ------------------------- */
function processDataURL(dataURL) {
if (!dataURL || typeof dataURL !== 'string') return;
ensureTraceUI();
const preview = $S('#trace-preview');
const canvasContainer = $S('#area-canvas')//$S('#area-canvas') || document.body;
if (previewImg && previewImg.parentElement) previewImg.parentElement.removeChild(previewImg);
if (canvasImg && canvasImg.parentElement) canvasImg.parentElement.removeChild(canvasImg);
previewImg = null;
canvasImg = null;
previewImg = $C('img', { src: dataURL, draggable: false }, {
position: 'absolute', left: '50%', top: '50%', transform: 'translate(-50%,-50%)',
cursor: 'grab', maxWidth: '100%', maxHeight: '100%', width: 'auto', height: 'auto',
boxSizing: 'border-box', userSelect: 'none', touchAction: 'none', pointerEvents: 'none',
transition: 'none', transformOrigin: '50% 50%'
});
preview.appendChild(previewImg);
if (canvasContainer) {
canvasImg = $C('img', { src: dataURL, draggable: false }, {
position: 'absolute', left: '50%', top: '50%', transform: 'translate(-50%,-50%)',
pointerEvents: 'auto', userSelect: 'none', width: 'auto', height: 'auto',
maxWidth: 'none', maxHeight: 'none', transformOrigin: '50% 50%'
});
canvasContainer.appendChild(canvasImg);
}
state = { tx: 0, ty: 0, scale: 1, rot: 0, opacity: 1 };
previewImg.onload = () => {
try {
const availableW = Math.max(1, preview.clientWidth);
const availableH = Math.max(1, preview.clientHeight);
let fitScale = Math.min(availableW / previewImg.naturalWidth, availableH / previewImg.naturalHeight);
if (!isFinite(fitScale) || fitScale <= 0) fitScale = 1;
previewBaseScale = fitScale;
} catch (e) { previewBaseScale = 1; }
if (canvasImg) {
canvasImg.style.width = previewImg.naturalWidth + 'px';
canvasImg.style.height = previewImg.naturalHeight + 'px';
}
applyTransformToPreview();
applyTransformToCanvas();
const scaleInput = $S('#trace-scale-input');
if (scaleInput) {
const visiblePercent = Math.round(previewBaseScale * (state.scale || 1) * 100);
scaleInput.value = visiblePercent;
$S('#trace-scale-value').innerText = Math.round(visiblePercent);
}
const rotInput = $S('#trace-rot-input');
if (rotInput) {
rotInput.value = Math.round(state.rot || 0);
$S('#trace-rot-value').innerText = rotInput.value;
}
const opInput = $S('#trace-op-input');
if (opInput) {
opInput.value = Number(state.opacity).toFixed(2);
$S('#trace-op-value').innerText = Number(state.opacity).toFixed(2);
}
};
}
/* ------------------------- UI builder (insert only on target pages) ------------------------- */
function ensureTraceUI() {
if (!isTargetPage()) return;
if ($S('#trace-preview') && insertedWrapper) return;
const existing = $S('#trace-preview');
if (existing && !originalTracePreviewClone) {
try {
originalTracePreviewClone = existing.cloneNode(true);
existing.parentElement && existing.parentElement.removeChild(existing);
} catch (e) { originalTracePreviewClone = null; }
}
const attachAnchor = $S('#area-palette-colorpicker');
const parentEl = attachAnchor ? attachAnchor.parentElement : document.body;
const tr = $C('tr', {}, {});
const td = $C('td', { valign: 'center', onselectstart: 'return false;' }, { borderTop: '1px solid #eee', fontSize: '12px',});
tr.appendChild(td);
const header = $C('div', { innerText: '+トレース', className: 'palette_menu' }, { marginBottom: '6px', cursor: 'pointer' });
td.appendChild(header);
const fileRow = $C('div', {}, { marginBottom: '6px' });
const fileInput = $C('input', { type: 'file', accept: 'image/*', id: 'trace-file-input', className: 'grmp-display' }, {});
fileRow.appendChild(fileInput);
td.appendChild(fileRow);
const preview = $C('div', { id: 'trace-preview', className: 'grmp-display' }, {
position: 'relative', width: '220px', height: '160px', border: '1px solid #ddd', overflow: 'hidden',
background: '#fff', marginBottom: '8px', touchAction: 'none'
});
td.appendChild(preview);
function makeControl(labelText, idInput, inputProps, valueId) {
const row = $C('div', { className: 'grmp-display' }, { marginBottom: '6px' });
const label = $C('div', { innerText: labelText, className: 'grmp-display' }, { fontSize: '11px', color: '#666', marginBottom: '4px' });
const container = $C('div', { className: 'grmp-display' }, { display: 'flex', gap: '8px', alignItems: 'center' });
const input = $C('input', Object.assign({ id: idInput }, inputProps), { width: '140px' });
const valueView = $C('div', { id: valueId, innerText: inputProps.value, className: 'grmp-display' }, { minWidth: '36px', textAlign: 'right', fontSize: '11px', color: '#333' });
container.appendChild(input);
container.appendChild(valueView);
row.appendChild(label);
row.appendChild(container);
return { row, input, valueView };
}
const scaleCtl = makeControl('大きさ (%)', 'trace-scale-input', { type: 'range', min: 1, max: 800, step: 1, value: 100 }, 'trace-scale-value');
const rotateCtl = makeControl('角度 (°)', 'trace-rot-input', { type: 'range', min: -180, max: 180, step: 1, value: 0 }, 'trace-rot-value');
const opacityCtl = makeControl('不透明度', 'trace-op-input', { type: 'range', min: 0, max: 1, step: 0.01, value: 1 }, 'trace-op-value');
td.appendChild(scaleCtl.row);
td.appendChild(rotateCtl.row);
td.appendChild(opacityCtl.row);
const btnRow = $C('div', { className: 'grmp-display' }, { display: 'flex', gap: '8px', alignItems: 'center' });
const removeBtn = $C('button', { innerText: '削除', className: 'grmp-display' }, { padding: '4px 8px', fontSize: '12px' });
btnRow.appendChild(removeBtn);
const spoitBtn = $C('button', { id: 'trace-spoit-btn', title: 'スポイト (Eyedropper)' }, { padding: '4px', display: 'inline-flex', alignItems: 'center', justifyContent: 'center', border: '1px solid #ccc', background: '#fff', cursor: 'pointer' });
const spoitImg = $C('img', { src: 'img/spoit.png', alt: 'spoit', draggable: false }, { width: '18px', height: '18px', display: 'block' });
spoitBtn.appendChild(spoitImg);
btnRow.appendChild(spoitBtn);
const drawBtn = $C('button', { id: 'trace-draw-btn', innerText: '描写', title: '選択画像を自動描写' }, { padding: '4px 8px', fontSize: '12px' });
btnRow.appendChild(drawBtn);
td.appendChild(btnRow);
parentEl.appendChild(tr);
insertedWrapper = tr;
/* ---------- events ---------- */
fileInput.addEventListener('change', e => {
const f = e.target.files && e.target.files[0];
if (!f) return;
const reader = new FileReader();
reader.onload = ev => processDataURL(ev.target.result);
reader.readAsDataURL(f);
});
scaleCtl.input.addEventListener('input', ev => {
const inputPercent = Number(scaleCtl.input.value) || 100;
state.scale = (inputPercent / 100) / (previewBaseScale || 1);
scaleCtl.valueView.innerText = Math.round(inputPercent);
applyTransformToPreview();
applyTransformToCanvas();
});
rotateCtl.input.addEventListener('input', ev => {
const v = Number(rotateCtl.input.value) || 0;
rotateCtl.valueView.innerText = v;
state.rot = v;
applyTransformToPreview();
applyTransformToCanvas();
ev.stopPropagation();
});
opacityCtl.input.addEventListener('input', ev => {
const v = Number(opacityCtl.input.value);
opacityCtl.valueView.innerText = v.toFixed(2);
state.opacity = v;
applyTransformToPreview();
applyTransformToCanvas();
ev.stopPropagation();
});
removeBtn.addEventListener('click', () => {
if (previewImg && previewImg.parentElement) previewImg.parentElement.removeChild(previewImg);
if (canvasImg && canvasImg.parentElement) canvasImg.parentElement.removeChild(canvasImg);
previewImg = null;
canvasImg = null;
});
spoitBtn.addEventListener('click', async () => {
if (window.EyeDropper) {
try {
const eye = new EyeDropper();
const { sRGBHex } = await eye.open();
const hex = sRGBHex.toUpperCase();
if (!hex) {
alert('色の取得失敗しました。: ' + hex);
return;
}
await navigator.clipboard.writeText(`${hex}`);
} catch (err) {
console.warn('EyeDropper failed or cancelled', err);
}
} else {
alert('このブラウザは EyeDropper API をサポートしていません。');
}
});
// pointer drag on preview to move (updates state.tx/state.ty in natural image units)
let pDown = false;
let last = { x: 0, y: 0 };
preview.addEventListener('pointerdown', ev => {
if (!canvasImg) return;
if (ev.target !== preview && ev.target !== previewImg) return;
pDown = true;
last.x = ev.clientX;
last.y = ev.clientY;
try { preview.setPointerCapture(ev.pointerId); } catch (e) {}
preview.style.cursor = 'grabbing';
ev.preventDefault();
});
preview.addEventListener('pointermove', ev => {
if (!pDown || !canvasImg) return;
const dx = ev.clientX - last.x;
const dy = ev.clientY - last.y;
last.x = ev.clientX;
last.y = ev.clientY;
const basePreview = (previewBaseScale && previewBaseScale > 0) ? previewBaseScale : 1;
const previewUserScale = state.scale || 1;
const previewDisplayScale = basePreview * previewUserScale;
const dxNatural = dx / previewDisplayScale;
const dyNatural = dy / previewDisplayScale;
state.tx += dxNatural;
state.ty += dyNatural;
applyTransformToCanvas();
});
function endPointer(ev) {
if (!pDown) return;
pDown = false;
try { preview.releasePointerCapture(ev && ev.pointerId); } catch (e) {}
preview.style.cursor = 'default';
}
preview.addEventListener('pointerup', endPointer);
preview.addEventListener('pointercancel', endPointer);
document.body.addEventListener('pointerup', () => { pDown = false; preview.style.cursor = 'default'; });
['dragenter', 'dragover'].forEach(evName => {
preview.addEventListener(evName, ev => {
ev.preventDefault();
try { ev.dataTransfer.dropEffect = 'copy'; } catch (e) {}
});
});
preview.addEventListener('drop', async ev => {
ev.preventDefault();
const dt = ev.dataTransfer;
if (!dt) return;
try {
const custom = dt.getData && (dt.getData('text/trace-image') || dt.getData('text/plain'));
if (custom && custom.startsWith('data:')) { processDataURL(custom); return; }
} catch (e) {}
if (dt.files && dt.files.length) {
const f = dt.files[0];
if (f.type && f.type.startsWith('image/')) {
const r = new FileReader();
r.onload = e => processDataURL(e.target.result);
r.readAsDataURL(f);
return;
}
}
if (dt.items && dt.items.length) {
for (let i = 0; i < dt.items.length; i++) {
const it = dt.items[i];
if (it.kind === 'file') {
const file = it.getAsFile();
if (file) {
const r = new FileReader();
r.onload = e => processDataURL(e.target.result);
r.readAsDataURL(file);
return;
}
}
}
}
let url = null;
try { url = dt.getData && (dt.getData('text/uri-list') || dt.getData('text/plain') || dt.getData('text')); } catch (e) {}
if (url && (url.startsWith('http://') || url.startsWith('https://'))) {
try {
const resp = await fetch(url, { mode: 'cors' });
if (!resp.ok) throw new Error('http status ' + resp.status);
const blob = await resp.blob();
if (!blob.type.startsWith('image/')) throw new Error('not image');
const reader = new FileReader();
reader.onload = e => processDataURL(e.target.result);
reader.readAsDataURL(blob);
return;
} catch (err) { log('failed to fetch dropped URL', err); showFetchFailDialog(url); return; }
}
log('drop: no usable image data found');
});
// draw button: auto-draw via ws, mapping preview pixels to canvas coordinates
drawBtn.addEventListener('click', async () => {
if (!previewImg || !canvasImg) { alert('描写する画像がありません。'); return; }
if (!ws || ws.readyState !== 1) { alert('WebSocket が取得されていないか接続されていません。ページで WebSocket を開いてください。'); return; }
let my_id = $S('#member-list > .member-list-item').id.replace('member-list-', '')
const canvasContainer = $S('#cvws_pend')//$S('#area-canvas') || document.body;
const guideEl = $S('#cvws_guide')//$S('#cvws_guide') || $S('#cvws_pend') || canvasContainer;
const guideRect = guideEl.getBoundingClientRect();
const containerRect = canvasContainer.getBoundingClientRect();
const guideCenterX = guideRect.left + guideRect.width / 2;
const guideCenterY = guideRect.top + guideRect.height / 2;
const left = guideCenterX - containerRect.left;
const top = guideCenterY - containerRect.top;
const natW = canvasImg.naturalWidth || canvasImg.width || 1;
const natH = canvasImg.naturalHeight || canvasImg.height || 1;
const scaleToGuideX = guideRect.width / natW;
const scaleToGuideY = guideRect.height / natH;
const fitScale = Math.min(scaleToGuideX, scaleToGuideY);
const safeFitScale = (isFinite(fitScale) && fitScale > 0) ? fitScale : 1;
const userScale = state.scale || 1;
const totalScale = Math.max(0.0001, Math.min(100, safeFitScale * userScale));
const offsetX = Number(state.tx) || 0;
const offsetY = Number(state.ty) || 0;
const finalLeft = left + offsetX * safeFitScale * userScale;
const finalTop = top + offsetY * safeFitScale * userScale;
const rotDeg = (state.rot || 0);
const rotRad = rotDeg * Math.PI / 180;
// get pixel data from natural image
let data;
try { data = getImagePixels(previewImg); } catch (e) { alert('画像のピクセル取得に失敗しました'); return; }
const w = data.width, h = data.height, pixels = data.data;
const toHex = v => v.toString(16).padStart(2, "0");
// Build drawing commands: for each horizontal run of same color, compute start/end coordinates in canvas container space
const values = [];
// helper to map natural pixel coords to container coords
const makeMapper = (natW, natH, totalScale, finalLeft, finalTop, rotRad) => (px, py) => {
const cx = px - natW / 2;
const cy = py - natH / 2;
const sx = cx * totalScale;
const sy = cy * totalScale;
const rx = sx * Math.cos(rotRad) - sy * Math.sin(rotRad);
const ry = sx * Math.sin(rotRad) + sy * Math.cos(rotRad);
return { px: finalLeft + rx, py: finalTop + ry };
};
const map = makeMapper(natW, natH, totalScale, finalLeft, finalTop, rotRad);
for (let y = 0; y < h; y++) {
let runColor = null;
let runStartX = null;
let runStartAlpha = null; // store alpha of run (we'll use first pixel's alpha)
for (let x = 0; x < w; x++) {
const idx = (y * w + x) * 4;
const r = pixels[idx], g = pixels[idx + 1], b = pixels[idx + 2], a = pixels[idx + 3];
// treat fully transparent only as empty; do NOT skip near-white — white should be drawn
if (a === 0) {
if (runColor) {
// finalize previous run
const x1 = runStartX, x2 = x - 1, y0 = y;
const p1 = map(x1, y0);
const p2 = map(x2, y0);
// compute combined opacity: slider * pixel alpha
const sliderOpacity = (state.opacity == null) ? 1 : Number(state.opacity);
const combinedOpacity = Math.max(0, Math.min(1, sliderOpacity * (runStartAlpha / 255)));
const opacityPercent = Math.round(combinedOpacity * 100);
const value = `1/${my_id}/${p1.px.toFixed(1)}/${p1.py.toFixed(1)}/${p2.px.toFixed(1)}/${p2.py.toFixed(1)}/${runColor}/3.0/${opacityPercent}/${(unsafeWindow && unsafeWindow.mylayer) ? unsafeWindow.mylayer : 0}/0/`;
values.push(value);
runColor = null;
runStartX = null;
runStartAlpha = null;
}
continue;
}
const color = `#${toHex(r)}${toHex(g)}${toHex(b)}`;
if (runColor === color) {
// continue run
continue;
} else {
// finalize previous run if any
if (runColor) {
const x1 = runStartX, x2 = x - 1, y0 = y;
const p1 = map(x1, y0);
const p2 = map(x2, y0);
const sliderOpacity = (state.opacity == null) ? 1 : Number(state.opacity);
const combinedOpacity = Math.max(0, Math.min(1, sliderOpacity * (runStartAlpha / 255)));
const opacityPercent = Math.round(combinedOpacity * 100);
const value = `1/${my_id}/${p1.px.toFixed(1)}/${p1.py.toFixed(1)}/${p2.px.toFixed(1)}/${p2.py.toFixed(1)}/${runColor}/3.0/${opacityPercent}/${(unsafeWindow && unsafeWindow.mylayer) ? unsafeWindow.mylayer : 0}/0/`;
values.push(value);
}
// start new run
runColor = color;
runStartX = x;
runStartAlpha = a;
}
}
// finalize at row end if run active
if (runColor) {
const x1 = runStartX, x2 = w - 1, y0 = y;
const p1 = map(x1, y0);
const p2 = map(x2, y0);
const sliderOpacity = (state.opacity == null) ? 1 : Number(state.opacity);
const combinedOpacity = Math.max(0, Math.min(1, sliderOpacity * (runStartAlpha / 255)));
const opacityPercent = Math.round(combinedOpacity * 100);
const value = `1/${my_id}/${p1.px.toFixed(1)}/${p1.py.toFixed(1)}/${p2.px.toFixed(1)}/${p2.py.toFixed(1)}/${runColor}/3.0/${opacityPercent}/${(unsafeWindow && unsafeWindow.mylayer) ? unsafeWindow.mylayer : 0}/0/`;
values.push(value);
}
}
if (!values.length) { alert('描写する画像が見つかりませんでした'); return; }
values.forEach((val, idx) => {
setTimeout(() => {
try { ws.send(val); } catch (e) { console.error('ws.send failed', e); }
}, 20);
});
alert('描写コマンドを送信しました: ' + values.length + ' 本');
});
// expose processDataURL globally
window.__trace_processDataURL = processDataURL;
// observe guide changes to reapply transform
const guideEl = $S('#cvws_guide') || $S('#cvws_pend');
if (guideEl) {
try { const ro = new ResizeObserver(() => applyTransformToCanvas()); ro.observe(guideEl); } catch (e) {}
try {
const mo = new MutationObserver(muts => {
for (const m of muts) {
if (m.type === 'attributes' && m.attributeName === 'style') { applyTransformToCanvas(); break; }
}
});
mo.observe(guideEl, { attributes: true, attributeFilter: ['style'] });
} catch (e) {}
window.addEventListener('resize', () => applyTransformToCanvas());
}
}
/* ------------------------- Fallback dialog when URL fetch fails ------------------------- */
function showFetchFailDialog(url) {
const old = $S('#trace-fetch-fail');
if (old && old.parentElement) old.parentElement.removeChild(old);
const dlg = $C('div', { id: 'trace-fetch-fail' }, { position: 'fixed', left: '50%', top: '50%', transform: 'translate(-50%,-50%)', zIndex: 1000000, background: '#fff', border: '1px solid #333', padding: '12px', boxShadow: '0 4px 12px rgba(0,0,0,0.3)' });
const p = $C('div', { innerText: 'この画像は直接取得できませんでした。送信元ページを新しいウィンドウで開き、そこで画像をドラッグしてください。' }, { marginBottom: '8px', maxWidth: '360px' });
const openBtn = $C('button', { innerText: '送信元を新しいウィンドウで開く' }, { marginRight: '8px' });
openBtn.addEventListener('click', () => { window.open(url, '_blank'); dlg.parentElement && dlg.parentElement.removeChild(dlg); });
const closeBtn = $C('button', { innerText: '閉じる' }, {});
closeBtn.addEventListener('click', () => { dlg.parentElement && dlg.parentElement.removeChild(dlg); });
dlg.appendChild(p); dlg.appendChild(openBtn); dlg.appendChild(closeBtn);
document.body.appendChild(dlg);
}
/* ------------------------- Sender: attach dragstart to images (set DataURL) ------------------------- */
function installImageSender() {
function attach(img) {
if (!img || img.__trace_sender_installed) return;
img.__trace_sender_installed = true;
img.draggable = true;
img.addEventListener('dragstart', async ev => {
try {
const dataURL = await imgToDataURL(img, 2048);
try { ev.dataTransfer.setData('text/trace-image', dataURL); } catch (e) {}
try { ev.dataTransfer.setData('text/plain', dataURL); } catch (e) {}
broadcastDataURL(dataURL);
try { ev.dataTransfer.setDragImage(img, 10, 10); } catch (e) {}
} catch (err) { log('send failed', err); }
});
}
document.querySelectorAll('img').forEach(attach);
const mo = new MutationObserver(muts => {
for (const m of muts) {
if (m.addedNodes && m.addedNodes.length) {
m.addedNodes.forEach(n => {
if (n.nodeType === 1) {
if (n.tagName === 'IMG') attach(n);
n.querySelectorAll && n.querySelectorAll('img').forEach(attach);
}
});
}
}
});
try { mo.observe(document.body, { childList: true, subtree: true }); } catch (e) {}
}
function imgToDataURL(img, maxSize = 2048) {
return new Promise((resolve, reject) => {
try {
const w = img.naturalWidth || img.width || 1;
const h = img.naturalHeight || img.height || 1;
let scale = 1;
if (Math.max(w, h) > maxSize) scale = maxSize / Math.max(w, h);
const canvas = document.createElement('canvas');
canvas.width = Math.round(w * scale);
canvas.height = Math.round(h * scale);
const ctx = canvas.getContext('2d');
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
resolve(canvas.toDataURL('image/png'));
} catch (err) { reject(err); }
});
}
/* ------------------------- Receivers: BroadcastChannel & postMessage ------------------------- */
function installReceivers() {
if (bc) {
try {
bc.onmessage = ev => {
const msg = ev.data;
if (msg && msg.type === 'trace-image' && typeof msg.dataURL === 'string') {
try { processDataURL(msg.dataURL); } catch (e) {}
}
};
} catch (e) {}
}
window.addEventListener('message', ev => {
try {
const msg = ev.data;
if (msg && msg.type === 'trace-image' && typeof msg.dataURL === 'string') {
processDataURL(msg.dataURL);
}
} catch (e) {}
}, false);
}
/* ------------------------- Init ------------------------- */
window.addEventListener('load', () => {
try {
if (isTargetPage()) ensureTraceUI();
installImageSender();
installReceivers();
window.__trace_processDataURL = processDataURL;
window.__trace_sendDataURL = broadcastDataURL;
log('initialized');
} catch (e) { console.error(e); }
});
// SPA navigation handling
window.addEventListener('popstate', () => { try { if (isTargetPage()) ensureTraceUI(); } catch (e) {} });
window.addEventListener('hashchange', () => { try { if (isTargetPage()) ensureTraceUI(); } catch (e) {} });
// restore original preview on unload if we replaced it
window.addEventListener('beforeunload', () => {
try {
if (originalTracePreviewClone) {
if (insertedWrapper && insertedWrapper.parentElement) insertedWrapper.parentElement.removeChild(insertedWrapper);
const attachAnchor = $S('#area-palette-colorpicker');
const parentEl = attachAnchor ? attachAnchor.parentElement : document.body;
try { parentEl.appendChild(originalTracePreviewClone); } catch (e) { document.body.appendChild(originalTracePreviewClone); }
} else {
if (insertedWrapper && insertedWrapper.parentElement) insertedWrapper.parentElement.removeChild(insertedWrapper);
}
} catch (e) {}
});