MagicalDraw - trace + AutoDraw

トレース機能の実装

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

// ==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) {}
});