MagicalDraw - trace + AutoDraw

トレース機能の実装

Bu betiği kurabilmeniz için Tampermonkey, Greasemonkey ya da Violentmonkey gibi bir kullanıcı betiği eklentisini kurmanız gerekmektedir.

You will need to install an extension such as Tampermonkey to install this script.

Bu betiği kurabilmeniz için Tampermonkey ya da Violentmonkey gibi bir kullanıcı betiği eklentisini kurmanız gerekmektedir.

Bu betiği kurabilmeniz için Tampermonkey ya da Userscripts gibi bir kullanıcı betiği eklentisini kurmanız gerekmektedir.

Bu betiği indirebilmeniz için ayrıca Tampermonkey gibi bir eklenti kurmanız gerekmektedir.

Bu komut dosyasını yüklemek için bir kullanıcı komut dosyası yöneticisi uzantısı yüklemeniz gerekecek.

(Zaten bir kullanıcı komut dosyası yöneticim var, kurmama izin verin!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(Zateb bir user-style yöneticim var, yükleyeyim!)

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