Greasy Fork is available in English.

MagicalDraw - trace + AutoDraw

トレース機能の実装

スクリプトをインストールするには、Tampermonkey, GreasemonkeyViolentmonkey のような拡張機能のインストールが必要です。

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

スクリプトをインストールするには、TampermonkeyViolentmonkey のような拡張機能のインストールが必要です。

スクリプトをインストールするには、TampermonkeyUserscripts のような拡張機能のインストールが必要です。

このスクリプトをインストールするには、Tampermonkeyなどの拡張機能をインストールする必要があります。

このスクリプトをインストールするには、ユーザースクリプト管理ツールの拡張機能をインストールする必要があります。

(ユーザースクリプト管理ツールは設定済みなのでインストール!)

このスタイルをインストールするには、Stylusなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus などの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus tなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

(ユーザースタイル管理ツールは設定済みなのでインストール!)

このスクリプトの質問や評価の投稿はこちら通報はこちらへお寄せください
// ==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) { }
});