Greasy Fork is available in English.
トレース機能の実装
// ==UserScript==
// @name MagicalDraw - trace + AutoDraw
// @namespace https://greasyfork.org/ja/users/941284-ぐらんぴ
// @version 2026-02-21
// @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('[trace-autodraw]', ...a);
/* -------------------------
WebSocket capture (Tampermonkey: unsafeWindow)
------------------------- */
let ws = null;
try {
const origWb = unsafeWindow.WebSocket;
unsafeWindow.WebSocket = function (...args) {
ws = new origWb(...args);
try { console.log('取得したURL:', ws.url); } catch (e) { }
ws.addEventListener('open', () => { console.log('WebSocket接続確立'); });
return ws;
};
} catch (e) {
console.warn('WebSocket override failed (unsafeWindow unavailable?)', e);
}
/* -------------------------
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('#area-canvas') || document.body;
const guideEl = $S('#cvws_guide') || $S('#cvws_pend') || canvasContainer;
if (!guideEl) return;
// compute fit scale between image natural size and guide size
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;
// apply transform (centered)
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(); // ensure UI exists
const preview = $S('#trace-preview');
const canvasContainer = $S('#area-canvas') || document.body;
// remove existing
if (previewImg && previewImg.parentElement) previewImg.parentElement.removeChild(previewImg);
if (canvasImg && canvasImg.parentElement) canvasImg.parentElement.removeChild(canvasImg);
previewImg = null; canvasImg = null;
// preview image
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);
// canvas image (placed into page)
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);
}
// reset state and compute previewBaseScale
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();
// update controls if present
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; // already inserted
// backup existing #trace-preview if present
const existing = $S('#trace-preview');
if (existing && !originalTracePreviewClone) {
try { originalTracePreviewClone = existing.cloneNode(true); existing.parentElement && existing.parentElement.removeChild(existing); } catch (e) { originalTracePreviewClone = null; }
}
// attach near palette if possible
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', padding: '6px' });
tr.appendChild(td);
const header = $C('div', { innerText: '+トレース', className: 'palette_menu' }, { marginBottom: '6px', cursor: 'pointer' });
td.appendChild(header);
// file input
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);
// preview box
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);
// controls
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);
// buttons: delete, spoit, draw
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 ---------- */
// file input
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);
});
// slider handlers (ensure they update state and apply transforms)
scaleCtl.input.addEventListener('input', ev => {
const inputPercent = Number(scaleCtl.input.value) || 100;
// state.scale is relative to previewBaseScale so that visible percent = previewBaseScale * state.scale * 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();
});
// delete
removeBtn.addEventListener('click', () => {
if (previewImg && previewImg.parentElement) previewImg.parentElement.removeChild(previewImg);
if (canvasImg && canvasImg.parentElement) canvasImg.parentElement.removeChild(canvasImg);
previewImg = null; canvasImg = null;
});
// spoit
spoitBtn.addEventListener('click', async () => {
if (window.EyeDropper) {
try {
const eye = new EyeDropper();
const result = await eye.open();
const color = result && result.sRGBHex ? result.sRGBHex : null;
if (color) {
header.style.backgroundColor = color;
insertedWrapper.style.outline = `3px solid ${color}`;
setTimeout(() => { header.style.backgroundColor = ''; insertedWrapper.style.outline = ''; }, 800);
window.__trace_lastPickedColor = color;
log('picked color', color);
}
} catch (err) { console.warn('EyeDropper cancelled or failed', 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;
// dxNatural/dyNatural are in natural image pixels
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'; });
// drag/drop support on preview
['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;
// custom DataURL
try {
const custom = dt.getData && (dt.getData('text/trace-image') || dt.getData('text/plain'));
if (custom && custom.startsWith('data:')) { processDataURL(custom); return; }
} catch (e) { }
// files
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;
}
}
// items
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;
}
}
}
}
// fallback: URL
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; }
// get my_id
let my_id = null;
try {
const el = $S('#member-list > .member-list-item');
if (el && el.id) my_id = el.id.replace('member-list-', '');
} catch (e) { }
if (!my_id) {
try { my_id = unsafeWindow && unsafeWindow.myid ? String(unsafeWindow.myid) : null; } catch (e) { }
}
if (!my_id) { alert('my_id を取得できませんでした。#member-list の要素を確認してください。'); return; }
// compute mapping parameters used in applyTransformToCanvas
const canvasContainer = $S('#area-canvas') || document.body;
const guideEl = $S('#cvws_guide') || $S('#cvws_pend') || canvasContainer;
if (!guideEl) { alert('ガイド要素が見つかりません。'); return; }
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 = [];
for (let y = 0; y < h; y++) {
let runColor = null;
let runStartX = null;
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 or near-white as empty
if (a === 0 || (r > 250 && g > 250 && b > 250)) {
if (runColor) {
// finalize previous run: runStartX .. x-1
// compute canvas coordinates for runStartX and x-1
const x1 = runStartX, x2 = x - 1, y0 = y;
// map natural pixel coords (x1,y0) to container coords (px,py)
const map = (px, py) => {
// center-based coordinates in natural pixels
const cx = px - natW / 2;
const cy = py - natH / 2;
// scale
const sx = cx * totalScale;
const sy = cy * totalScale;
// rotate
const rx = sx * Math.cos(rotRad) - sy * Math.sin(rotRad);
const ry = sx * Math.sin(rotRad) + sy * Math.cos(rotRad);
// translate to final center
return { px: finalLeft + rx, py: finalTop + ry };
};
const p1 = map(x1, y0);
const p2 = map(x2, y0);
const value = `1/${my_id}/${p1.px.toFixed(1)}/${p1.py.toFixed(1)}/${p2.px.toFixed(1)}/${p2.py.toFixed(1)}/${runColor}/3.0/100/${(unsafeWindow && unsafeWindow.mylayer) ? unsafeWindow.mylayer : 0}/0/`;
values.push(value);
runColor = null;
runStartX = null;
}
continue;
}
const color = `#${toHex(r)}${toHex(g)}${toHex(b)}`;
if (runColor === color) {
// continue run
continue;
} else {
// finalize previous run
if (runColor) {
const x1 = runStartX, x2 = x - 1, y0 = y;
const map = (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 p1 = map(x1, y0);
const p2 = map(x2, y0);
const value = `1/${my_id}/${p1.px.toFixed(1)}/${p1.py.toFixed(1)}/${p2.px.toFixed(1)}/${p2.py.toFixed(1)}/${runColor}/3.0/100/${(unsafeWindow && unsafeWindow.mylayer) ? unsafeWindow.mylayer : 0}/0/`;
values.push(value);
}
// start new run
runColor = color;
runStartX = x;
}
}
// finalize at row end
if (runColor) {
const x1 = runStartX, x2 = w - 1, y0 = y;
const map = (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 p1 = map(x1, y0);
const p2 = map(x2, y0);
const value = `1/${my_id}/${p1.px.toFixed(1)}/${p1.py.toFixed(1)}/${p2.px.toFixed(1)}/${p2.py.toFixed(1)}/${runColor}/3.0/100/${(unsafeWindow && unsafeWindow.mylayer) ? unsafeWindow.mylayer : 0}/0/`;
values.push(value);
}
} // end rows
if (!values.length) { alert('描写するピクセルが見つかりませんでした'); return; }
// send values via ws with small interval
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());
}
} // end ensureTraceUI
/* -------------------------
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) { }
}
// helper used by sender
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) { }
});