ふんわりしたペイントツール「FluffyPainter」


【更新履歴】

 ・2026/3/7 バージョン1.0公開。
 ・2026/3/8 バージョン1.1公開。

画像


・ダウンロードされる方はこちら。↓


・ソースコードはこちら。↓

<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <title>Fluffy Painter 1.1</title>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/paper.js/0.12.17/paper-full.min.js"></script>
    <style>
        :root { --bg-dark: #202225; --bg-panel: #2f3136; --bg-hover: #393c43; --text-main: #dcddde; --text-muted: #8e9297; --accent: #7289da; --border: #202225; }
        body { margin: 0; padding: 0; display: flex; flex-direction: column; height: 100vh; background-color: var(--bg-dark); color: var(--text-main); font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; font-size: 14px; overflow: hidden; }
        
        /* 共通UI */
        #menubar { display: flex; background-color: var(--bg-dark); padding: 5px 10px; border-bottom: 1px solid #111; user-select: none; }
        .menu-item { position: relative; padding: 5px 10px; cursor: pointer; border-radius: 3px; }
        .menu-item:hover { background-color: var(--bg-hover); }
        .dropdown { display: none; position: absolute; top: 100%; left: 0; background-color: var(--bg-panel); border: 1px solid #111; box-shadow: 0 4px 6px rgba(0,0,0,0.3); z-index: 100; min-width: 180px; }
        .menu-item:hover .dropdown { display: block; }
        .dropdown-item { padding: 8px 15px; cursor: pointer; display: flex; justify-content: space-between; }
        .dropdown-item:hover { background-color: var(--accent); color: white; }

        /* ツールバー */
        #toolbar { display: flex; background-color: var(--bg-panel); padding: 5px 10px; border-bottom: 1px solid var(--border); gap: 5px; align-items: center; }
        .tool-btn { background: transparent; border: 1px solid transparent; color: var(--text-main); font-size: 1.2em; padding: 5px 10px; border-radius: 4px; cursor: pointer; display: flex; align-items: center; gap: 5px; }
        .tool-btn:hover { background-color: rgba(255,255,255,0.1); border-color: rgba(255,255,255,0.2); }
        .tool-btn.active { background-color: var(--accent); color: white; border-color: var(--accent); }
        .toolbar-sep { width: 1px; height: 24px; background-color: #555; margin: 0 10px; }

        #main { display: flex; flex-grow: 1; overflow: hidden; }

        /* サイドバーとタブ */
        #sidebar { width: 320px; flex-shrink: 0; background-color: var(--bg-panel); display: flex; flex-direction: column; border-right: 1px solid var(--border); z-index: 20;}
        #tabs { display: flex; border-bottom: 1px solid var(--border); background-color: var(--bg-dark); }
        .tab-btn { flex: 1; padding: 10px 0; text-align: center; cursor: pointer; border-bottom: 2px solid transparent; color: var(--text-muted); font-size: 0.9em; }
        .tab-btn.active { color: var(--text-main); border-bottom-color: var(--accent); background-color: var(--bg-panel); font-weight: bold;}
        .tab-content { display: none; flex-grow: 1; padding: 15px; overflow-y: auto; }
        .tab-content.active { display: flex; flex-direction: column; gap: 15px; }

        /* プロパティパネル */
        .prop-group { display: flex; flex-direction: column; gap: 5px; background: rgba(0,0,0,0.1); padding: 10px; border-radius: 5px; border: 1px solid rgba(255,255,255,0.05); }
        .prop-group label { font-size: 0.9em; display: flex; justify-content: space-between; }
        input[type="range"] { width: 100%; accent-color: var(--accent); }
        
        .color-picker-wrap { display: flex; align-items: center; gap: 10px; width: 100%; }
        .color-drag-handle { width: 100%; height: 30px; border-radius: 4px; border: 1px solid #555; cursor: pointer; display: flex; justify-content: center; align-items: center; box-shadow: inset 0 0 4px rgba(0,0,0,0.3); transition: transform 0.1s;}
        .color-drag-handle:hover { transform: scale(1.02); border-color: #aaa; }

        #palette-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 4px; margin-top: 5px; }
        .palette-cell { height: 25px; border-radius: 3px; border: 1px solid #111; cursor: pointer; box-sizing: border-box; }
        .palette-cell:hover { border-color: #fff; transform: scale(1.05); }

        button.action-btn { background-color: var(--accent); color: white; border: none; padding: 10px; border-radius: 4px; cursor: pointer; width: 100%; margin-top: 5px; font-weight: bold; }
        button.action-btn:hover { filter: brightness(1.2); }
        button.action-btn-green { background-color: #43b581; }
        button.action-btn-outline { background-color: transparent; border: 1px solid var(--text-muted); color: var(--text-main); }
        button.action-btn-outline:hover { background-color: rgba(255,255,255,0.1); }

        /* リスト項目 */
        .list-item { padding: 6px 8px; border-radius: 4px; cursor: pointer; margin-bottom: 4px; border: 1px solid transparent; background-color: rgba(0,0,0,0.2); display: flex; align-items: center; gap: 10px; }
        .list-item:hover { background-color: rgba(255,255,255,0.05); }
        .list-item.selected { background-color: rgba(114, 137, 218, 0.4); border-color: var(--accent); color: white;}
        .list-item input[type="checkbox"] { cursor: pointer; width: 16px; height: 16px; accent-color: var(--accent); }
        .list-item .item-name { flex-grow: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; pointer-events: none;}

        /* キャンバス領域 */
        #workspace { flex-grow: 1; background-color: #1e1e1e; position: relative; display: flex; overflow: auto; padding: 40px; box-sizing: border-box; align-items: flex-start; justify-content: flex-start;}
        #canvas-wrap { position: relative; box-shadow: 0 0 20px rgba(0,0,0,0.8); flex-shrink: 0; margin: auto; background-color: transparent;}
        #checkerboard { position: absolute; top: 0; left: 0; width: 100%; height: 100%; background-image: linear-gradient(45deg, #2a2a2a 25%, transparent 25%, transparent 75%, #2a2a2a 75%, #2a2a2a), linear-gradient(45deg, #2a2a2a 25%, transparent 25%, transparent 75%, #2a2a2a 75%, #2a2a2a); background-size: 20px 20px; background-position: 0 0, 10px 10px; z-index: 0; }
        canvas { display: block; background-color: transparent; z-index: 1; position: absolute; top: 0; left: 0;}

        #status-bar { position: fixed; bottom: 10px; left: 340px; background: rgba(0,0,0,0.7); padding: 5px 10px; border-radius: 5px; z-index: 10; font-size: 0.9em; pointer-events: none;}
        
        /* ポップアップメニュー群 */
        .popup-menu { position: fixed; background-color: var(--bg-panel); border: 1px solid #111; box-shadow: 0 4px 6px rgba(0,0,0,0.5); z-index: 1000; border-radius: 4px; padding: 5px 0; display: none; min-width: 150px;}
        .cm-item { padding: 8px 15px; cursor: pointer; font-size: 0.9em; }
        .cm-item:hover { background-color: var(--accent); color: white; }
        .cm-sep { height: 1px; background-color: #444; margin: 4px 0; }
    </style>
</head>
<body>

    <div id="menubar">
        <div class="menu-item">ファイル
            <div class="dropdown">
                <div class="dropdown-item" onclick="fileAction('new')">新規作成</div>
                <div class="dropdown-item" onclick="fileAction('open')">開く... (JSON)</div>
                <div class="dropdown-item" onclick="fileAction('save')">上書き保存 (JSON) <span style="color:#888;">Ctrl+S</span></div>
                <div class="dropdown-item" onclick="fileAction('png')">PNG出力</div>
                <div class="dropdown-item" onclick="fileAction('resize')">キャンバスサイズ変更</div>
            </div>
        </div>
        <div class="menu-item">表示
            <div class="dropdown">
                <div class="dropdown-item" onclick="changeZoom(1.2)">ズームイン <span style="color:#888;">PageUp</span></div>
                <div class="dropdown-item" onclick="changeZoom(1 / 1.2)">ズームアウト <span style="color:#888;">PageDown</span></div>
                <div class="dropdown-item" onclick="changeZoom(null)">100%表示</div>
            </div>
        </div>
        <div class="menu-item">設定</div>
    </div>

    <div id="toolbar">
        <button class="tool-btn active" onclick="setMode('draw')" id="btn-draw" title="スポット領域をドローしていく">🖊️ 描く</button>
        <button class="tool-btn" onclick="setMode('erase')" id="btn-erase" title="スポット領域を消していく">🧽 消す</button>
        <button class="tool-btn" onclick="setMode('fill')" id="btn-fill" title="囲まれた領域を塗りつぶす">🎨 塗る</button>
        <div class="toolbar-sep"></div>
        <button class="tool-btn" onclick="setMode('select')" id="btn-select" title="スポットを選択・複数選択">👆 選択</button>
        <button class="tool-btn" onclick="setMode('move')" id="btn-move" title="ドラッグで移動">🖐️ 移動</button>
        <button class="tool-btn" onclick="setMode('rotate')" id="btn-rotate" title="ドラッグで回転">🔄 回転</button>
        <button class="tool-btn" onclick="setMode('scale')" id="btn-scale" title="ドラッグで拡縮">📐 拡縮</button>
        <div class="toolbar-sep"></div>
        <button class="tool-btn" onclick="setMode('adjust')" id="btn-adjust" title="ベジェ曲線で調整">✒️ 調整</button>
        <div class="toolbar-sep"></div>
        <button class="tool-btn" onclick="execAction('undo')" id="btn-undo" title="元に戻す (Ctrl+Z)">⏪️</button>
        <button class="tool-btn" onclick="execAction('redo')" id="btn-redo" title="やり直す (Ctrl+Y)">⏩️</button>
    </div>

    <div id="main">
        <div id="sidebar">
            <div id="tabs">
                <div class="tab-btn active" onclick="switchTab('tab-layers')">レイヤー</div>
                <div class="tab-btn" onclick="switchTab('tab-spots')">スポット</div>
                <div class="tab-btn" onclick="switchTab('tab-prop')">プロパティ</div>
            </div>

            <div id="tab-layers" class="tab-content active" style="position: relative;">
                <p style="color:#888; font-size:12px; margin:0;">※右クリックでメニューを表示</p>
                <div id="layer-list-container" style="flex-grow: 1;"></div>
            </div>

            <div id="tab-spots" class="tab-content" style="position: relative;">
                <p style="color:#888; font-size:12px; margin:0;">※右クリックでメニューを表示</p>
                <div id="spot-list-container" style="flex-grow: 1;"></div>
            </div>

            <div id="tab-prop" class="tab-content">
                <div class="prop-group">
                    <strong>ドロー設定</strong>
                    <label>ブラシサイズ: <span id="valBrush">10</span>px</label>
                    <input type="range" id="uiBrushSize" min="1" max="50" value="10">
                </div>
                
                <hr style="border-color: #444; width: 100%; margin: 5px 0;">

                <div id="spot-properties" style="opacity: 0.5; pointer-events: none;">
                    <strong>選択中スポットの質感</strong>
                    <div class="prop-group">
                        <label>中心色 (ハイライト)</label>
                        <div class="color-picker-wrap">
                            <input type="color" id="uiCenterCol" value="#fff0f5" style="display:none;" oninput="updateProps({target: this})" onchange="saveState()">
                            <div id="dragHandleCenter" class="color-drag-handle" draggable="true" ondragstart="handleColorDragStart(event, 'uiCenterCol')" ondrop="handleColorDrop(event, 'uiCenterCol')" ondragover="event.preventDefault()" onclick="document.getElementById('uiCenterCol').click()" title="クリックで色選択 / ドラッグ&ドロップで色を移動" style="background-color: #fff0f5;"></div>
                        </div>
                    </div>
                    
                    <div class="prop-group">
                        <label>中間色 (グラデーション)</label>
                        <div class="color-picker-wrap">
                            <input type="color" id="uiMidCol" value="#ffadd5" style="display:none;" oninput="updateProps({target: this})" onchange="saveState()">
                            <div id="dragHandleMid" class="color-drag-handle" draggable="true" ondragstart="handleColorDragStart(event, 'uiMidCol')" ondrop="handleColorDrop(event, 'uiMidCol')" ondragover="event.preventDefault()" onclick="document.getElementById('uiMidCol').click()" title="クリックで色選択 / ドラッグ&ドロップで色を移動" style="background-color: #ffadd5;"></div>
                        </div>
                        <button class="action-btn action-btn-outline" style="padding: 4px; font-size: 0.8em; margin-top: 2px;" onclick="generateMidColor()">中間色を自動生成</button>
                    </div>

                    <div class="prop-group">
                        <label>輪郭色 (影・エッジ)</label>
                        <div class="color-picker-wrap">
                            <input type="color" id="uiEdgeCol" value="#ff69b4" style="display:none;" oninput="updateProps({target: this})" onchange="saveState()">
                            <div id="dragHandleEdge" class="color-drag-handle" draggable="true" ondragstart="handleColorDragStart(event, 'uiEdgeCol')" ondrop="handleColorDrop(event, 'uiEdgeCol')" ondragover="event.preventDefault()" onclick="document.getElementById('uiEdgeCol').click()" title="クリックで色選択 / ドラッグ&ドロップで色を移動" style="background-color: #ff69b4;"></div>
                        </div>
                    </div>
                    
                    <div>
                        <label style="font-size: 0.8em; color: var(--text-muted);">グラデーションパレット (ドラッグ&ドロップ)</label>
                        <div id="palette-grid"></div>
                    </div>

                    <div class="prop-group" style="margin-top: 10px;">
                        <label>ふんわり感: <span id="valBlur">30</span></label>
                        <input type="range" id="uiBlur" min="5" max="80" value="30" oninput="updateProps({target: this})" onchange="saveState()">
                    </div>
                    <div class="prop-group">
                        <label>影の深さ (きつさ): <span id="valDepth">15</span></label>
                        <input type="range" id="uiDepth" min="5" max="50" value="15" oninput="updateProps({target: this})" onchange="saveState()">
                    </div>

                    <div style="display: flex; gap: 5px; margin-top: 10px;">
                        <button class="action-btn action-btn-outline" style="flex: 1; padding: 8px 5px;" onclick="execAction('vectorize')">■ ベクター化</button>
                        <button class="action-btn action-btn-green" style="flex: 1.5; padding: 8px 5px; margin-top: 0;" onclick="execAction('render')">▶ 3D化(立体化)</button>
                    </div>
                </div>
            </div>
        </div>

        <div id="workspace" oncontextmenu="return false;">
            <div id="canvas-wrap">
                <div id="checkerboard"></div>
                <canvas id="myCanvas"></canvas>
            </div>
            <div id="status-bar">モード: <span id="mode-text" style="color:var(--accent); font-weight:bold;">描く</span> | ズーム: <span id="zoom-text">100%</span> | 空白ドラッグ/方向キー: スクロール</div>
        </div>
    </div>

    <div id="context-menu" class="popup-menu">
        <div class="cm-item" data-action="add" onclick="cmAction('add')">追加</div>
        <div class="cm-sep" data-action="sep1"></div>
        <div class="cm-item" data-action="cut" onclick="cmAction('cut')">カット (Ctrl+X)</div>
        <div class="cm-item" data-action="copy" onclick="cmAction('copy')">コピー (Ctrl+C)</div>
        <div class="cm-item" data-action="paste" onclick="cmAction('paste')">貼り付け (Ctrl+V)</div>
        <div class="cm-sep" data-action="sep2"></div>
        <div class="cm-item" data-action="rename" onclick="cmAction('rename')">名前の変更</div>
        <div class="cm-item" data-action="opacity" onclick="cmAction('opacity')">透明度の変更</div>
        <div class="cm-sep" data-action="sep3"></div>
        <div class="cm-item" data-action="merge" onclick="cmAction('merge')">結合 (選択項目のみ)</div>
        <div class="cm-sep" data-action="sep4"></div>
        <div class="cm-item" data-action="delete" onclick="cmAction('delete')" style="color: #ff4444;">削除 (Del)</div>
    </div>

    <div id="adjust-menu" class="popup-menu">
        <div class="cm-item" id="am-add" onclick="adjustMenuAction('add')">隣接するポイントの中間に追加</div>
        <div class="cm-item" id="am-delete" onclick="adjustMenuAction('delete')" style="color: #ff4444;">選択中のポイントを削除</div>
    </div>

    <input type="file" id="fileLoader" style="display:none;" accept=".json">

    <script>
        paper.settings.handleSize = 8;
        paper.setup('myCanvas');
        
        let baseWidth = 1000;
        let baseHeight = 800;
        let currentZoom = 1;

        let mode = 'draw'; 
        let brushRadius = 10;
        
        let appLayers = []; 
        let activeLayer = null;
        let selectedLayers = []; 
        let layerCounter = 0;

        let selectedSpots = []; 
        let spotCounter = 0;
        let draftQueue = [];

        let clipboardData = null;
        let clipboardType = ''; 

        let selectionRect = null;
        let isPanning = false;
        let lastPanPoint = null;
        let cmTargetType = ''; 
        let cmTargetItem = null;

        let undoStack = [];
        let redoStack = [];
        
        let selectedSegments = [];

        // ズームとスクロールエリアの連動
        window.changeZoom = function(factor) {
            let ws = document.getElementById('workspace');
            let scrollCenterX = ws.scrollLeft + ws.clientWidth / 2;
            let scrollCenterY = ws.scrollTop + ws.clientHeight / 2;
            
            let oldZoom = currentZoom;
            if (factor === null) {
                currentZoom = 1;
            } else {
                currentZoom *= factor;
            }
            
            let wrap = document.getElementById('canvas-wrap');
            wrap.style.width = (baseWidth * currentZoom) + 'px';
            wrap.style.height = (baseHeight * currentZoom) + 'px';
            
            paper.view.viewSize = new paper.Size(baseWidth * currentZoom, baseHeight * currentZoom);
            paper.view.zoom = currentZoom;
            paper.view.center = new paper.Point(baseWidth / 2, baseHeight / 2); // 中心の論理座標を固定
            
            if (factor !== null) {
                let ratio = currentZoom / oldZoom;
                ws.scrollLeft = scrollCenterX * ratio - ws.clientWidth / 2;
                ws.scrollTop = scrollCenterY * ratio - ws.clientHeight / 2;
            } else {
                ws.scrollLeft = (wrap.offsetWidth - ws.clientWidth) / 2 + 40; // padding考慮
                ws.scrollTop = (wrap.offsetHeight - ws.clientHeight) / 2 + 40;
            }

            document.getElementById('zoom-text').innerText = Math.round(currentZoom * 100) + '%';
            if(selectionRect) updateSelectionBounds(); 
            updateVisuals();
        }

        function saveState() {
            let state = {
                layers: appLayers.map(l => l.exportJSON()),
                layerCounter: layerCounter,
                spotCounter: spotCounter
            };
            undoStack.push(state);
            if(undoStack.length > 30) undoStack.shift();
            redoStack = []; 
        }

        function restoreState(stateJSON) {
            appLayers.forEach(l => l.remove()); appLayers = [];
            draftLayer.removeChildren();
            
            layerCounter = stateJSON.layerCounter;
            spotCounter = stateJSON.spotCounter;
            
            stateJSON.layers.forEach(lData => {
                let newL = new paper.Layer();
                newL.importJSON(lData);
                appLayers.push(newL);
                newL.children.forEach(spot => {
                    if(spot.name && spot.name.startsWith('Spot_') && spot.data.isRendered) renderFluffySpot(spot);
                });
            });
            
            if(appLayers.length > 0) { activeLayer = appLayers[0]; selectedLayers = [activeLayer]; } 
            else { activeLayer = null; selectedLayers = []; }
            
            selectedSpots = [];
            activeSegment = null;
            selectedSegments = [];
            clearSelection(); 
        }

        let gradientPalette = JSON.parse(localStorage.getItem('fluffy_grad_palette_v2')) || [
            {c: '#fff0f5', m: '#ffadd5', e: '#ff69b4'}, // ピンク
            {c: '#fff5e6', m: '#ffc173', e: '#ff8c00'}, // オレンジ
            {c: '#ffffe0', m: '#ffeb70', e: '#ffd700'}, // レモン
            {c: '#f5fffa', m: '#94e696', e: '#32cd32'}, // 黄緑
            {c: '#e8f5e9', m: '#9ad29d', e: '#4caf50'}, // 草色
            {c: '#f0ffff', m: '#78dfff', e: '#00bfff'}, // 水色
            {c: '#f8f8ff', m: '#c6b4ed', e: '#9370db'}, // 青紫
            {c: '#fdf5e6', m: '#e8d5b9', e: '#d2b48c'}  // ベージュ
        ];

        function renderPalette() {
            const grid = document.getElementById('palette-grid');
            grid.innerHTML = '';
            gradientPalette.forEach((colors, i) => {
                let cell = document.createElement('div');
                cell.className = 'palette-cell';
                cell.style.background = `linear-gradient(to right, ${colors.c} 33%, ${colors.m} 33% 66%, ${colors.e} 66%)`;
                cell.draggable = true;
                cell.title = "ドラッグ&ドロップで3色セットを適用・保存";
                
                cell.ondragstart = (e) => { 
                    e.dataTransfer.setData('application/json', JSON.stringify(colors)); 
                };
                cell.ondragover = (e) => e.preventDefault();
                cell.ondrop = (e) => {
                    e.preventDefault(); 
                    let jsonData = e.dataTransfer.getData('application/json');
                    let source = e.dataTransfer.getData('source_id');
                    
                    if (jsonData && source === 'current_colors') {
                        try {
                            let parsed = JSON.parse(jsonData);
                            if (parsed.c && parsed.m && parsed.e) {
                                gradientPalette[i] = parsed;
                                localStorage.setItem('fluffy_grad_palette_v2', JSON.stringify(gradientPalette));
                                renderPalette();
                            }
                        } catch(err) {}
                    } else if (jsonData) {
                        try {
                            let parsed = JSON.parse(jsonData);
                            if (parsed.c && parsed.m && parsed.e) {
                                gradientPalette[i] = parsed;
                                localStorage.setItem('fluffy_grad_palette_v2', JSON.stringify(gradientPalette));
                                renderPalette();
                            }
                        } catch(err) {}
                    }
                };
                grid.appendChild(cell);
            });
        }
        renderPalette();

        window.handleColorDragStart = function(e, inputId) { 
            e.dataTransfer.setData('text/plain', document.getElementById(inputId).value); 
            e.dataTransfer.setData('source_id', 'current_colors');
            let colors = { c: uiCenterCol.value, m: uiMidCol.value, e: uiEdgeCol.value };
            e.dataTransfer.setData('application/json', JSON.stringify(colors));
        }
        
        window.handleColorDrop = function(e, inputId) {
            e.preventDefault(); 
            let jsonData = e.dataTransfer.getData('application/json');
            if (jsonData) {
                try {
                    let parsed = JSON.parse(jsonData);
                    if (parsed.c && parsed.m && parsed.e) {
                        uiCenterCol.value = parsed.c;
                        uiMidCol.value = parsed.m;
                        uiEdgeCol.value = parsed.e;
                        updateProps({target: uiCenterCol});
                        updateProps({target: uiMidCol});
                        updateProps({target: uiEdgeCol});
                        saveState();
                        return;
                    }
                } catch(err) {}
            }
            let color = e.dataTransfer.getData('text/plain');
            if (color && color.startsWith('#')) { 
                let input = document.getElementById(inputId); 
                input.value = color; 
                updateProps({target: input}); 
                saveState(); 
            }
        };

        const draftLayer = new paper.Layer({ name: 'draftLayer' }); 
        const uiLayer = new paper.Layer({ name: 'uiLayer' }); 
        
        let adjustMarkers = new paper.Group({name: 'adjustMarkers'});
        uiLayer.addChild(adjustMarkers);

        function createNewLayer() {
            layerCounter++; let newLayer = new paper.Layer({ name: 'Layer_' + Date.now() });
            newLayer.data = { displayName: 'レイヤー ' + layerCounter, isVisible: true };
            appLayers.unshift(newLayer); draftLayer.bringToFront(); uiLayer.bringToFront(); return newLayer;
        }
        activeLayer = createNewLayer(); selectedLayers = [activeLayer]; saveState(); 

        const uiBrushSize = document.getElementById('uiBrushSize');
        const uiCenterCol = document.getElementById('uiCenterCol');
        const uiMidCol = document.getElementById('uiMidCol');
        const uiEdgeCol = document.getElementById('uiEdgeCol');
        const uiBlur = document.getElementById('uiBlur');
        const uiDepth = document.getElementById('uiDepth');
        const propPanel = document.getElementById('spot-properties');
        
        uiBrushSize.oninput = (e) => { brushRadius = parseInt(e.target.value); document.getElementById('valBrush').innerText = brushRadius; };

        window.generateMidColor = function() {
            let c = uiCenterCol.value; let e = uiEdgeCol.value;
            let r = Math.round((parseInt(c.substring(1,3), 16) + parseInt(e.substring(1,3), 16)) / 2).toString(16).padStart(2, '0');
            let g = Math.round((parseInt(c.substring(3,5), 16) + parseInt(e.substring(3,5), 16)) / 2).toString(16).padStart(2, '0');
            let b = Math.round((parseInt(c.substring(5,7), 16) + parseInt(e.substring(5,7), 16)) / 2).toString(16).padStart(2, '0');
            uiMidCol.value = '#' + r + g + b; updateProps({target: uiMidCol}); saveState();
        };

        window.updateProps = (e) => {
            if (e && e.target) {
                const valElem = document.getElementById(e.target.id.replace('ui', 'val'));
                if (valElem) valElem.innerText = e.target.value;
                if(e.target.type === 'color') {
                    if(e.target.id === 'uiCenterCol') { document.getElementById('dragHandleCenter').style.backgroundColor = e.target.value; }
                    if(e.target.id === 'uiMidCol') { document.getElementById('dragHandleMid').style.backgroundColor = e.target.value; }
                    if(e.target.id === 'uiEdgeCol') { document.getElementById('dragHandleEdge').style.backgroundColor = e.target.value; }
                }
            }
            if (selectedSpots.length > 0) {
                selectedSpots.forEach(spot => {
                    spot.data.centerCol = uiCenterCol.value; spot.data.midCol = uiMidCol.value; spot.data.edgeCol = uiEdgeCol.value;
                    spot.data.blurAmt = parseInt(uiBlur.value); spot.data.depthAmt = parseInt(uiDepth.value);
                    if(spot.data.isRendered) renderFluffySpot(spot);
                });
            }
        };

        window.switchTab = function(tabId) {
            document.querySelectorAll('.tab-btn').forEach(btn => btn.classList.remove('active'));
            document.querySelectorAll('.tab-content').forEach(content => content.classList.remove('active'));
            event.target.classList.add('active'); document.getElementById(tabId).classList.add('active');
        };

        function commitVector() {
            if (draftQueue.length === 0 || selectedSpots.length === 0) return;
            let targetSpot = selectedSpots[0]; let basePath = targetSpot.children['basePath'];
            if (!basePath) return;

            let parentLayer = targetSpot.parent; if(parentLayer) parentLayer.activate();
            let newBasePath = basePath.clone(); newBasePath.visible = true;
            
            for (let item of draftQueue) {
                if (item.mode === 'draw') {
                    let temp = newBasePath.isEmpty() ? item.path.clone() : newBasePath.unite(item.path);
                    newBasePath.remove(); newBasePath = temp;
                } else if (item.mode === 'erase' && !newBasePath.isEmpty()) {
                    let temp = newBasePath.subtract(item.path); newBasePath.remove(); newBasePath = temp;
                }
                item.path.remove(); 
            }
            draftQueue = []; 
            targetSpot.children['basePath'].remove(); newBasePath.name = 'basePath'; targetSpot.addChild(newBasePath);
            if (targetSpot.data.isRendered) renderFluffySpot(targetSpot);
            updateVisuals(); saveState(); 
        }

        function getPathSegments(path) {
            let segs = [];
            if (path instanceof paper.CompoundPath) {
                path.children.forEach(child => {
                    if (child.segments) segs = segs.concat(child.segments);
                });
            } else if (path.segments) {
                segs = path.segments;
            }
            return segs;
        }

        function updateVisuals() {
            const layerContainer = document.getElementById('layer-list-container');
            layerContainer.innerHTML = '';
            appLayers.forEach(layer => {
                let div = document.createElement('div'); div.className = 'list-item' + (selectedLayers.includes(layer) ? ' selected' : '');
                let chk = document.createElement('input'); chk.type = 'checkbox'; chk.checked = layer.data.isVisible !== false;
                chk.onclick = (e) => { e.stopPropagation(); layer.visible = chk.checked; layer.data.isVisible = chk.checked; updateSelectionBounds(); };
                let span = document.createElement('span'); span.className = 'item-name'; span.innerText = layer.data.displayName + (layer.opacity < 1 ? ` (${Math.round(layer.opacity*100)}%)` : '');
                div.appendChild(chk); div.appendChild(span);
                
                div.onclick = (e) => {
                    commitVector();
                    if(e.ctrlKey || e.metaKey) {
                        if(selectedLayers.includes(layer)) selectedLayers = selectedLayers.filter(l => l!==layer); else selectedLayers.push(layer);
                    } else selectedLayers = [layer];
                    activeLayer = selectedLayers.length > 0 ? selectedLayers[0] : appLayers[0];
                    clearSelection(); 
                };
                div.oncontextmenu = (e) => {
                    e.preventDefault(); e.stopPropagation();
                    if(!selectedLayers.includes(layer)) { selectedLayers = [layer]; activeLayer = layer; updateVisuals(); }
                    showContextMenu(e, 'layer', layer, false);
                };
                layerContainer.appendChild(div);
            });

            const spotContainer = document.getElementById('spot-list-container');
            spotContainer.innerHTML = '';
            if (activeLayer) {
                let spots = activeLayer.children.filter(i => i.name && i.name.startsWith('Spot_'));
                for (let i = spots.length - 1; i >= 0; i--) {
                    let spot = spots[i];
                    let div = document.createElement('div'); div.className = 'list-item' + (selectedSpots.includes(spot) ? ' selected' : '');
                    let chk = document.createElement('input'); chk.type = 'checkbox'; chk.checked = spot.data.isVisible !== false;
                    chk.onclick = (e) => { e.stopPropagation(); spot.visible = chk.checked; spot.data.isVisible = chk.checked; updateSelectionBounds(); };
                    let span = document.createElement('span'); span.className = 'item-name'; span.innerText = spot.data.displayName + (spot.opacity < 1 ? ` (${Math.round(spot.opacity*100)}%)` : '');
                    div.appendChild(chk); div.appendChild(span);

                    div.onclick = (e) => {
                        commitVector(); 
                        if (['draw','erase','fill','move','rotate','scale','adjust'].indexOf(mode) === -1) setMode('select');
                        
                        if(e.ctrlKey || e.metaKey) {
                            if(selectedSpots.includes(spot)) selectedSpots = selectedSpots.filter(s => s!==spot); else selectedSpots.push(spot);
                        } else selectedSpots = [spot];
                        
                        propPanel.style.opacity = selectedSpots.length > 0 ? '1' : '0.5';
                        propPanel.style.pointerEvents = selectedSpots.length > 0 ? 'auto' : 'none';
                        updateVisuals();
                    };
                    div.oncontextmenu = (e) => {
                        e.preventDefault(); e.stopPropagation();
                        if(!selectedSpots.includes(spot)) { selectedSpots = [spot]; updateVisuals(); }
                        showContextMenu(e, 'spot', spot, false);
                    };
                    spotContainer.appendChild(div);
                }
            }

            paper.project.deselectAll();
            adjustMarkers.removeChildren();

            appLayers.forEach(layer => {
                layer.children.forEach(spot => {
                    if (!spot.name || !spot.name.startsWith('Spot_')) return;
                    let base = spot.children['basePath'];
                    let renderGrp = spot.children['renderGroup'];
                    
                    if (spot.data.isVisible === false || layer.data.isVisible === false) {
                        base.visible = false; base.selected = false;
                        if(renderGrp) renderGrp.visible = false; return;
                    }

                    base.selected = false;
                    base.strokeColor = null; base.strokeWidth = 0; base.fillColor = null;

                    if (mode === 'adjust' && selectedSpots.includes(spot)) {
                        base.visible = true; 
                        base.selected = false; 
                        base.strokeColor = '#00aaff'; base.strokeWidth = 1.5; base.fillColor = 'rgba(0,170,255,0.05)';
                        base.bringToFront(); 
                        
                        let segs = getPathSegments(base);
                        segs.forEach(seg => {
                            let isSel = selectedSegments.includes(seg);
                            seg.selected = isSel; 
                            if (!isSel) {
                                let dot = new paper.Path.Circle({center: seg.point, radius: 3, fillColor: '#ffffff', strokeColor: '#555555', strokeWidth: 1});
                                adjustMarkers.addChild(dot);
                            }
                        });

                        if (renderGrp) { renderGrp.visible = true; renderGrp.opacity = 0.5; }
                    } else if ((mode === 'draw' || mode === 'erase' || mode === 'fill') && selectedSpots.includes(spot)) {
                        base.visible = true; base.fillColor = 'rgba(255, 255, 255, 0.01)'; 
                        if (renderGrp) { renderGrp.visible = true; renderGrp.opacity = 0.8; }
                    } else {
                        base.visible = false; 
                        if (renderGrp) { renderGrp.visible = true; renderGrp.opacity = 1.0; }
                    }

                    if (!spot.data.isRendered && mode !== 'adjust') {
                        base.visible = true; base.fillColor = 'rgba(200, 200, 200, 0.5)'; 
                        if (renderGrp) renderGrp.visible = false;
                    }
                });
            });

            if (['select','move','rotate','scale'].includes(mode)) updateSelectionBounds();
            else if (selectionRect) { selectionRect.remove(); selectionRect = null; }
            
            paper.view.update(); 
        }

        window.setMode = function(newMode) {
            commitVector(); 
            mode = newMode;
            
            if (mode !== 'adjust') {
                activeSegment = null; activeHandle = null; adjustTargetLocation = null;
                selectedSegments = [];
                paper.project.deselectAll(); 
            }
            
            document.querySelectorAll('.tool-btn').forEach(btn => btn.classList.remove('active'));
            let btn = document.getElementById('btn-' + mode);
            if(btn) btn.classList.add('active');

            const texts = { 'draw': '描く', 'erase': '消す', 'fill':'塗る', 'select': '選択・複数選択', 'move': '移動', 'rotate':'回転', 'scale':'拡縮', 'adjust': 'ベジェ調整' };
            document.getElementById('mode-text').innerText = texts[mode] || mode;
            document.getElementById('myCanvas').style.cursor = (['select','move','rotate','scale'].includes(mode)) ? 'default' : 'crosshair';
            updateVisuals();
        };

        // --- コンテキストメニュー(リスト領域) ---
        document.getElementById('tab-layers').addEventListener('contextmenu', (e) => {
            e.preventDefault(); if (e.target.closest('.list-item')) return;
            showContextMenu(e, 'layer', null, true);
        });
        document.getElementById('tab-spots').addEventListener('contextmenu', (e) => {
            e.preventDefault(); if (e.target.closest('.list-item')) return;
            showContextMenu(e, 'spot', null, true);
        });

        function showContextMenu(e, type, item, isBackground = false) {
            cmTargetType = type; cmTargetItem = item;
            const menu = document.getElementById('context-menu');
            const items = menu.children;
            for(let i=0; i<items.length; i++) {
                let action = items[i].getAttribute('data-action');
                if(!action) continue;
                if (isBackground) {
                    if (action === 'add' || action === 'paste') items[i].style.display = 'block'; else items[i].style.display = 'none';
                } else {
                    if (action === 'add' || action === 'sep1') items[i].style.display = 'none'; else items[i].style.display = 'block';
                }
            }
            menu.style.display = 'block';
            let x = e.clientX; let y = e.clientY;
            if(x + menu.offsetWidth > window.innerWidth) x -= menu.offsetWidth;
            if(y + menu.offsetHeight > window.innerHeight) y -= menu.offsetHeight;
            menu.style.left = x + 'px'; menu.style.top = y + 'px';
        }

        window.addEventListener('click', (e) => { document.querySelectorAll('.popup-menu').forEach(m => m.style.display = 'none'); });

        window.cmAction = function(action) {
            if(cmTargetType === 'layer') handleLayerAction(action); else if(cmTargetType === 'spot') handleSpotAction(action);
        }

        function handleLayerAction(action) {
            if(action === 'add') { activeLayer = createNewLayer(); selectedLayers = [activeLayer]; clearSelection(); saveState(); } 
            else if(action === 'cut' || action === 'copy') {
                if(selectedLayers.length > 0) { clipboardData = selectedLayers[0].exportJSON(); clipboardType = 'layer'; if(action === 'cut') { selectedLayers[0].remove(); appLayers = appLayers.filter(l => l !== selectedLayers[0]); saveState(); } }
            } else if(action === 'paste') {
                if(clipboardType === 'layer' && clipboardData) {
                    let newL = new paper.Layer(); newL.importJSON(clipboardData); layerCounter++; newL.data.displayName = 'ペーストされたレイヤー ' + layerCounter;
                    appLayers.unshift(newL); activeLayer = newL; selectedLayers = [newL];
                    newL.children.forEach(spot => { if(spot.name && spot.data.isRendered) renderFluffySpot(spot); }); saveState();
                }
            } else if(action === 'rename') {
                if(selectedLayers.length > 0) { let newName = prompt('新しい名前を入力:', selectedLayers[0].data.displayName); if(newName) { selectedLayers[0].data.displayName = newName; saveState(); } }
            } else if(action === 'delete') {
                selectedLayers.forEach(l => { l.remove(); appLayers = appLayers.filter(al => al !== l); });
                selectedLayers = []; activeLayer = appLayers.length > 0 ? appLayers[0] : createNewLayer(); saveState();
            } else if(action === 'opacity') {
                if(selectedLayers.length > 0) { let op = prompt('透明度を 0.0 ~ 1.0 の間で入力:', selectedLayers[0].opacity); if(op !== null && !isNaN(parseFloat(op))) { selectedLayers.forEach(l => l.opacity = parseFloat(op)); saveState(); } }
            } else if(action === 'merge') {
                if(selectedLayers.length > 1) {
                    let mergedLayer = createNewLayer(); mergedLayer.data.displayName = '結合レイヤー ' + layerCounter;
                    selectedLayers.slice().reverse().forEach(l => { let spots = l.children.filter(i => i.name && i.name.startsWith('Spot_')).slice(); spots.forEach(s => mergedLayer.addChild(s)); l.remove(); appLayers = appLayers.filter(al => al !== l); });
                    selectedLayers = [mergedLayer]; activeLayer = mergedLayer; saveState();
                } else alert("結合するにはCtrlキー(MacはCmd)を押しながら複数のレイヤーを選択してください。");
            }
            updateVisuals();
        }

        function handleSpotAction(action) {
            commitVector();
            if(action === 'add') { execAction('addSpot'); } 
            else if(action === 'cut' || action === 'copy') {
                if(selectedSpots.length > 0) { clipboardData = selectedSpots[0].exportJSON(); clipboardType = 'spot'; if(action === 'cut') execAction('delete'); }
            } else if(action === 'paste') {
                if(clipboardType === 'spot' && clipboardData && activeLayer) {
                    activeLayer.activate(); let pasted = new paper.Group(); pasted.importJSON(clipboardData); pasted.name = 'Spot_' + Date.now();
                    pasted.data.displayName = pasted.data.displayName + ' (コピー)'; pasted.position.x += 20; pasted.position.y += 20; 
                    activeLayer.addChild(pasted); if (pasted.data.isRendered) renderFluffySpot(pasted);
                    selectedSpots = [pasted]; saveState();
                }
            } else if(action === 'rename') {
                if(selectedSpots.length > 0) { let newName = prompt('新しい名前を入力:', selectedSpots[0].data.displayName); if(newName) { selectedSpots[0].data.displayName = newName; saveState(); } }
            } else if(action === 'delete') { execAction('delete'); } 
            else if(action === 'opacity') {
                if(selectedSpots.length > 0) { let op = prompt('透明度を 0.0 ~ 1.0 の間で入力:', selectedSpots[0].opacity); if(op !== null && !isNaN(parseFloat(op))) { selectedSpots.forEach(s => s.opacity = parseFloat(op)); saveState(); } }
            } else if(action === 'merge') {
                if(selectedSpots.length > 1) execAction('unite'); else alert("結合するにはCtrlキー(MacはCmd)を押しながら複数のスポットを選択してください。");
            }
            updateVisuals();
        }

        // --- コアレンダリング (3層グラデーション) ---
        function renderFluffySpot(spotGroup) {
            const oldRender = spotGroup.children['renderGroup']; if (oldRender) oldRender.remove();
            spotGroup.data.isRendered = true; const data = spotGroup.data; const basePath = spotGroup.children['basePath'];
            if (!basePath || basePath.isEmpty()) return;

            const renderGroup = new paper.Group({ name: 'renderGroup' });
            const baseFill = basePath.clone(); baseFill.visible = true; baseFill.fillColor = data.centerCol; 
            baseFill.strokeColor = null; baseFill.strokeWidth = 0; 
            renderGroup.addChild(baseFill);
            
            const clipGroup = new paper.Group(); 
            const clipMask = basePath.clone(); clipMask.visible = true; 
            clipMask.strokeColor = null; clipMask.strokeWidth = 0; clipMask.fillColor = null;
            clipGroup.addChild(clipMask); clipGroup.clipped = true;

            const shiftOffset = 10000;
            const shadowWide = basePath.clone(); shadowWide.visible = true; shadowWide.fillColor = null; shadowWide.strokeColor = data.midCol || data.edgeCol; 
            shadowWide.strokeWidth = data.blurAmt; shadowWide.shadowColor = data.midCol || data.edgeCol; shadowWide.shadowBlur = data.blurAmt * 1.5; 
            shadowWide.translate(new paper.Point(shiftOffset, 0)); shadowWide.shadowOffset = new paper.Point(-shiftOffset, 0);
            
            const shadowDeep = basePath.clone(); shadowDeep.visible = true; shadowDeep.fillColor = null; shadowDeep.strokeColor = data.edgeCol;
            shadowDeep.strokeWidth = data.depthAmt; shadowDeep.shadowColor = data.edgeCol; shadowDeep.shadowBlur = data.depthAmt; 
            shadowDeep.translate(new paper.Point(shiftOffset, 0)); shadowDeep.shadowOffset = new paper.Point(-shiftOffset, 0); 

            clipGroup.addChild(shadowWide); clipGroup.addChild(shadowDeep); renderGroup.addChild(clipGroup); spotGroup.addChild(renderGroup);
            
            if (selectedSpots.length === 1 && selectedSpots[0] === spotGroup) {
                uiCenterCol.value = data.centerCol; uiMidCol.value = data.midCol || data.edgeCol; uiEdgeCol.value = data.edgeCol;
                document.getElementById('dragHandleCenter').style.backgroundColor = data.centerCol;
                document.getElementById('dragHandleMid').style.backgroundColor = data.midCol || data.edgeCol;
                document.getElementById('dragHandleEdge').style.backgroundColor = data.edgeCol;
                uiBlur.value = data.blurAmt; uiDepth.value = data.depthAmt; document.getElementById('valBlur').innerText = data.blurAmt; document.getElementById('valDepth').innerText = data.depthAmt;
            }
        }

        function createTaperedPath(points, maxRadius) {
            if (points.length < 2) return new paper.Path.Circle({ center: points[0] || new paper.Point(0,0), radius: maxRadius / 2 });
            let spine = new paper.Path({ segments: points }); spine.simplify(2); let totalLength = spine.length;
            if (totalLength < 1) { spine.remove(); return new paper.Path.Circle({ center: points[0], radius: maxRadius / 2 }); }
            let outline = new paper.Path(); let samples = Math.max(10, Math.floor(totalLength / 3)); 
            let topPoints = []; let bottomPoints = [];
            for (let i = 0; i <= samples; i++) {
                let t = i / samples; let offset = t * totalLength;
                let point = spine.getPointAt(offset); let normal = spine.getNormalAt(offset);
                if (!point || !normal) continue;
                let radius = Math.sin(t * Math.PI) * maxRadius;
                topPoints.push(point.add(normal.multiply(radius))); bottomPoints.unshift(point.subtract(normal.multiply(radius)));
            }
            outline.addSegments(topPoints); outline.addSegments(bottomPoints); outline.closed = true; outline.simplify(2);
            spine.remove(); return outline;
        }

        const tool = new paper.Tool();
        let strokePoints = []; let currentDraftPath = null;
        let activeSegment = null; let activeHandle = null;
        let actionCenter = null; let startAngle = 0; let startDist = 0;
        let adjustTargetLocation = null;

        tool.onMouseDown = function(event) {
            if (event.event.button === 2) { 
                if (mode === 'adjust' && selectedSpots.length > 0) {
                    let targetSpot = selectedSpots[0];
                    let hitResultPath = targetSpot.children['basePath'].hitTest(event.point, { segments: true, curve: true, stroke: true, tolerance: 8 });
                    if (hitResultPath) {
                        if (hitResultPath.type === 'segment') { activeSegment = hitResultPath.segment; adjustTargetLocation = null; }
                        else if (hitResultPath.type === 'curve' || hitResultPath.type === 'stroke') { adjustTargetLocation = hitResultPath.location; activeSegment = null; }
                    } else { adjustTargetLocation = null; }
                    
                    const menu = document.getElementById('adjust-menu');
                    const itemAdd = document.getElementById('am-add');
                    const itemDel = document.getElementById('am-delete');
                    
                    itemAdd.style.display = adjustTargetLocation ? 'block' : 'none';
                    itemDel.style.display = activeSegment ? 'block' : 'none';
                    
                    if (adjustTargetLocation || activeSegment) {
                        menu.style.display = 'block';
                        let x = event.event.clientX; let y = event.event.clientY;
                        if(x + menu.offsetWidth > window.innerWidth) x -= menu.offsetWidth;
                        if(y + menu.offsetHeight > window.innerHeight) y -= menu.offsetHeight;
                        menu.style.left = x + 'px'; menu.style.top = y + 'px';
                    }
                }
                return;
            }

            const hitResult = paper.project.hitTest(event.point, { fill: true, stroke: true, tolerance: 4 });
            
            if (!hitResult && ['select', 'move', 'rotate', 'scale'].includes(mode)) {
                isPanning = true; lastPanPoint = event.point; return;
            }
            isPanning = false;

            if (mode === 'select') {
                if (hitResult) {
                    let item = hitResult.item; let spotItem = null;
                    while(item) { 
                        if(item.name && item.name.startsWith('Spot_')) { spotItem = item; break; }
                        item = item.parent; 
                    } 
                    if(spotItem) selectSpot(spotItem, event.modifiers.control || event.modifiers.command);
                    else clearSelection();
                } else clearSelection();
                return;
            }

            if (mode === 'adjust') {
                if (selectedSpots.length === 0) return;
                let targetSpot = selectedSpots[0];
                let bp = targetSpot.children['basePath'];
                
                let hitResultPath = bp.hitTest(event.point, { segments: true, handles: true, tolerance: 8 });

                if (hitResultPath) {
                    if (hitResultPath.type === 'segment') {
                        if (event.modifiers.control || event.modifiers.command) {
                            let idx = selectedSegments.indexOf(hitResultPath.segment);
                            if (idx >= 0) selectedSegments.splice(idx, 1);
                            else selectedSegments.push(hitResultPath.segment);
                        } else {
                            if (!selectedSegments.includes(hitResultPath.segment)) {
                                selectedSegments = [hitResultPath.segment];
                            }
                        }
                        activeSegment = hitResultPath.segment;
                        activeHandle = null;
                    }
                    else if (hitResultPath.type === 'handle-in' || hitResultPath.type === 'handle-out') {
                        activeHandle = hitResultPath.type; activeSegment = hitResultPath.segment;
                        if (!selectedSegments.includes(activeSegment)) {
                            selectedSegments = [activeSegment];
                        }
                    }
                } else {
                    let curveHit = bp.hitTest(event.point, { curve: true, stroke: true, tolerance: 8 });
                    if (!curveHit || !(event.modifiers.control || event.modifiers.command)) {
                        selectedSegments = [];
                        activeSegment = null;
                        activeHandle = null;
                    }
                }
                updateVisuals();
                return;
            }

            if (mode === 'fill') {
                if (selectedSpots.length === 0) return;
                commitVector(); 
                let targetSpot = selectedSpots[0];
                let basePath = targetSpot.children['basePath'];
                
                let obstaclePath = new paper.Path();
                appLayers.forEach(layer => {
                    if (layer.data.isVisible !== false) {
                        layer.children.forEach(spot => {
                            if (spot.name && spot.name.startsWith('Spot_') && spot.data.isVisible !== false) {
                                let b = spot.children['basePath'];
                                if (b && !b.isEmpty()) {
                                    let temp = obstaclePath.isEmpty() ? b.clone() : obstaclePath.unite(b);
                                    obstaclePath.remove(); obstaclePath = temp;
                                }
                            }
                        });
                    }
                });

                let bgRect = new paper.Path.Rectangle(paper.view.bounds.expand(2000));
                let inverted = bgRect.subtract(obstaclePath);
                obstaclePath.remove();

                let targetRegion = null;
                if (inverted instanceof paper.CompoundPath) {
                    let candidates = [];
                    for (let i = 0; i < inverted.children.length; i++) {
                        if (inverted.children[i].contains(event.point)) candidates.push(inverted.children[i]);
                    }
                    if (candidates.length > 0) {
                        candidates.sort((a, b) => Math.abs(a.area) - Math.abs(b.area));
                        targetRegion = candidates[0];
                    }
                } else if (inverted.contains(event.point)) {
                    targetRegion = inverted;
                }

                if (targetRegion) {
                    let united = basePath.isEmpty() ? targetRegion.clone() : basePath.unite(targetRegion);
                    targetSpot.children['basePath'].remove(); united.name = 'basePath'; targetSpot.addChild(united);
                    if (targetSpot.data.isRendered) renderFluffySpot(targetSpot);
                    updateVisuals(); saveState();
                }
                inverted.remove(); bgRect.remove();
                return;
            }

            if (mode === 'move' || mode === 'rotate' || mode === 'scale') {
                if (selectedSpots.length === 0) return;
                let bounds = selectedSpots[0].bounds;
                for(let i=1; i<selectedSpots.length; i++) bounds = bounds.unite(selectedSpots[i].bounds);
                actionCenter = bounds.center;
                startAngle = (event.point.subtract(actionCenter)).angle; startDist = event.point.getDistance(actionCenter);
                return;
            }

            if (selectedSpots.length === 0) { alert("ドローするスポットを追加するか選択してください。"); setMode('select'); return; }
            draftLayer.activate(); strokePoints = [event.point];
            currentDraftPath = new paper.Path.Circle({ center: event.point, radius: 1, fillColor: mode === 'draw' ? 'rgba(200, 200, 200, 0.6)' : 'rgba(255, 68, 68, 0.6)' });
        };

        tool.onMouseDrag = function(event) {
            if (isPanning) { 
                let ws = document.getElementById('workspace');
                ws.scrollLeft -= event.event.movementX;
                ws.scrollTop -= event.event.movementY;
                return; 
            }
            if (mode === 'select' || mode === 'move') { selectedSpots.forEach(spot => spot.position = spot.position.add(event.delta)); updateSelectionBounds(); return; }
            if (mode === 'rotate' && actionCenter) {
                let currentAngle = (event.point.subtract(actionCenter)).angle; let deltaAngle = currentAngle - startAngle;
                selectedSpots.forEach(spot => spot.rotate(deltaAngle, actionCenter)); startAngle = currentAngle; updateSelectionBounds(); return;
            }
            if (mode === 'scale' && actionCenter && startDist > 0) {
                let currentDist = event.point.getDistance(actionCenter); let scaleFactor = currentDist / startDist;
                selectedSpots.forEach(spot => spot.scale(scaleFactor, actionCenter)); startDist = currentDist; updateSelectionBounds(); return;
            }
            if (mode === 'adjust') {
                if (activeHandle === 'handle-in' && activeSegment) {
                    activeSegment.handleIn = activeSegment.handleIn.add(event.delta);
                }
                else if (activeHandle === 'handle-out' && activeSegment) {
                    activeSegment.handleOut = activeSegment.handleOut.add(event.delta);
                }
                else if (selectedSegments.length > 0) {
                    selectedSegments.forEach(seg => {
                        seg.point = seg.point.add(event.delta);
                    });
                }
                return;
            }
            if ((mode === 'draw' || mode === 'erase') && currentDraftPath) {
                if (event.point.getDistance(strokePoints[strokePoints.length - 1]) > 2) {
                    strokePoints.push(event.point); currentDraftPath.remove();
                    currentDraftPath = createTaperedPath(strokePoints, brushRadius);
                    currentDraftPath.fillColor = mode === 'draw' ? 'rgba(200, 200, 200, 0.6)' : 'rgba(255, 68, 68, 0.6)';
                }
            }
        };

        tool.onMouseUp = function(event) {
            if (isPanning) { isPanning = false; return; }
            if (mode === 'adjust') {
                if (selectedSpots[0] && selectedSpots[0].data.isRendered) renderFluffySpot(selectedSpots[0]);
                updateSelectionBounds(); activeHandle = null; 
                updateVisuals(); saveState(); return;
            }
            if ((mode === 'move' || mode === 'rotate' || mode === 'scale') && selectedSpots.length > 0 && actionCenter) {
                selectedSpots.forEach(spot => { if (spot.data.isRendered) renderFluffySpot(spot); }); actionCenter = null; saveState();
            }
            if ((mode === 'draw' || mode === 'erase') && currentDraftPath) {
                currentDraftPath.simplify(2); draftQueue.push({ path: currentDraftPath, mode: mode });
                currentDraftPath = null; strokePoints = [];
            }
        };

        window.adjustMenuAction = function(action) {
            document.getElementById('adjust-menu').style.display = 'none';
            if(action === 'add' && adjustTargetLocation) {
                let curve = adjustTargetLocation.curve;
                let newCurve = curve.divideAt(0.5); 
                if(newCurve) { 
                    selectedSegments = [newCurve.segment1];
                    activeSegment = newCurve.segment1; 
                }
                if(selectedSpots[0] && selectedSpots[0].data.isRendered) renderFluffySpot(selectedSpots[0]);
                updateVisuals(); saveState();
            } else if(action === 'delete' && activeSegment) {
                activeSegment.remove(); 
                activeSegment = null;
                selectedSegments = [];
                if(selectedSpots[0] && selectedSpots[0].data.isRendered) renderFluffySpot(selectedSpots[0]);
                updateVisuals(); saveState();
            }
            adjustTargetLocation = null;
        };

        function selectSpot(spot, isMulti) {
            commitVector(); 
            if (mode === 'adjust') { activeSegment = null; activeHandle = null; adjustTargetLocation = null; selectedSegments = []; }

            if (!isMulti) selectedSpots = [];
            if (!selectedSpots.includes(spot)) selectedSpots.push(spot);
            else if (isMulti) selectedSpots = selectedSpots.filter(s => s !== spot);

            propPanel.style.opacity = selectedSpots.length > 0 ? '1' : '0.5';
            propPanel.style.pointerEvents = selectedSpots.length > 0 ? 'auto' : 'none';
            
            if (selectedSpots.length > 0 && selectedSpots[0].parent && selectedSpots[0].parent.name && selectedSpots[0].parent.name.startsWith('Layer_')) {
                activeLayer = selectedSpots[0].parent;
                selectedLayers = [activeLayer];
            }
            updateVisuals(); 
        }

        function clearSelection() {
            commitVector();
            if (mode === 'adjust') { activeSegment = null; activeHandle = null; adjustTargetLocation = null; selectedSegments = []; }
            selectedSpots = []; propPanel.style.opacity = '0.5'; propPanel.style.pointerEvents = 'none'; updateVisuals();
        }

        function updateSelectionBounds() {
            if (selectionRect) { selectionRect.remove(); selectionRect = null; }
            if (selectedSpots.length === 0 || mode === 'adjust') return; 

            uiLayer.activate();
            let bounds = selectedSpots[0].bounds;
            for(let i=1; i<selectedSpots.length; i++) bounds = bounds.unite(selectedSpots[i].bounds);
            selectionRect = new paper.Path.Rectangle(bounds);
            selectionRect.strokeColor = '#7289da'; 
            selectionRect.strokeWidth = 2 / paper.view.zoom; 
            selectionRect.dashArray = [4 / paper.view.zoom, 4 / paper.view.zoom];
        }

        window.execAction = function(action) {
            if (action === 'undo') { if (undoStack.length > 1) { redoStack.push(undoStack.pop()); restoreState(undoStack[undoStack.length - 1]); } return; }
            if (action === 'redo') { if (redoStack.length > 0) { let state = redoStack.pop(); undoStack.push(state); restoreState(state); } return; }
            commitVector(); 
            if (action === 'addSpot') {
                if(!activeLayer) { alert('レイヤーがありません。'); return; }
                spotCounter++; const spotGroup = new paper.Group({ name: 'Spot_' + Date.now() });
                spotGroup.data = { isRendered: false, isVisible: true, displayName: 'スポット ' + spotCounter, centerCol: uiCenterCol.value, midCol: uiMidCol.value, edgeCol: uiEdgeCol.value, blurAmt: parseInt(uiBlur.value), depthAmt: parseInt(uiDepth.value) };
                const basePath = new paper.Path({ name: 'basePath', visible: false });
                spotGroup.addChild(basePath); activeLayer.addChild(spotGroup);
                selectSpot(spotGroup, false); setMode('draw'); saveState();
            }
            else if (action === 'vectorize') { updateVisuals(); }
            else if (action === 'render') { if (selectedSpots.length > 0) { selectedSpots.forEach(spot => renderFluffySpot(spot)); setMode('select'); saveState(); } }
            else if (action === 'delete') { selectedSpots.forEach(s => s.remove()); clearSelection(); saveState(); }
            else if (action === 'unite') {
                if (selectedSpots.length < 2) return;
                let combinedPath = selectedSpots[0].children['basePath'].clone();
                for (let i = 1; i < selectedSpots.length; i++) {
                    let path2 = selectedSpots[i].children['basePath'].clone();
                    if (!path2.isEmpty()) { let temp = combinedPath.isEmpty() ? path2.clone() : combinedPath.unite(path2); combinedPath.remove(); path2.remove(); combinedPath = temp;
                    } else { path2.remove(); }
                }
                let newData = Object.assign({}, selectedSpots[0].data); let targetLayer = selectedSpots[0].parent;
                selectedSpots.forEach(s => s.remove()); clearSelection(); spotCounter++;
                let newSpot = new paper.Group({ name: 'Spot_' + Date.now() }); newSpot.data = newData; newSpot.data.displayName = '統合スポット ' + spotCounter;
                combinedPath.name = 'basePath'; newSpot.addChild(combinedPath);
                if (newData.isRendered) renderFluffySpot(newSpot); targetLayer.addChild(newSpot); selectSpot(newSpot, false); saveState();
            }
        };

        window.fileAction = function(action) {
            commitVector();
            if (action === 'new') {
                if(confirm('現在の作業内容は失われます。新規作成しますか?')) {
                    appLayers.forEach(l => l.remove()); appLayers = []; draftLayer.removeChildren();
                    spotCounter = 0; layerCounter = 0; clearSelection(); activeLayer = createNewLayer(); execAction('addSpot');
                }
            } else if (action === 'save') {
                let exportData = { layers: appLayers.map(l => l.exportJSON()) }; const dataStr = "data:text/json;charset=utf-8," + encodeURIComponent(JSON.stringify(exportData));
                const a = document.createElement('a'); a.setAttribute("href", dataStr); a.setAttribute("download", "fluffy_project.json"); a.click();
            } else if (action === 'open') { document.getElementById('fileLoader').click(); } 
            else if (action === 'png') {
                const oldMode = mode; setMode('select'); clearSelection(); 
                paper.project.deselectAll(); 
                
                let previousZoom = currentZoom;
                changeZoom(null); 
                
                appLayers.forEach(layer => {
                    layer.children.forEach(spot => {
                        if (spot.name && spot.name.startsWith('Spot_') && spot.data.isVisible !== false) {
                            if (!spot.data.isRendered) renderFluffySpot(spot);
                            let base = spot.children['basePath'];
                            if (base) { base.visible = false; base.selected = false; base.strokeColor = null; base.strokeWidth = 0; }
                            let renderGrp = spot.children['renderGroup'];
                            if (renderGrp) { renderGrp.visible = true; renderGrp.opacity = spot.opacity; }
                        }
                    });
                });
                uiLayer.visible = false; draftLayer.visible = false; paper.view.update(); 
                setTimeout(() => {
                    const a = document.createElement('a'); a.href = document.getElementById('myCanvas').toDataURL('image/png'); a.download = 'fluffy_export.png'; a.click();
                    uiLayer.visible = true; draftLayer.visible = true; 
                    changeZoom(previousZoom); 
                    setMode(oldMode); 
                }, 100);
            } else if (action === 'resize') {
                const w = prompt('新しい幅を入力', baseWidth); 
                const h = prompt('新しい高さを入力', baseHeight);
                if (w && h) { 
                    baseWidth = parseInt(w); 
                    baseHeight = parseInt(h); 
                    changeZoom(1); 
                }
            }
        };

        document.getElementById('fileLoader').addEventListener('change', function(e) {
            const file = e.target.files[0]; if (!file) return; const reader = new FileReader();
            reader.onload = function(evt) {
                try {
                    let parsed = JSON.parse(evt.target.result); appLayers.forEach(l => l.remove()); appLayers = [];
                    if(parsed.layers) {
                        parsed.layers.forEach(lData => { let newL = new paper.Layer(); newL.importJSON(lData); appLayers.push(newL);
                            newL.children.forEach(spot => { if(spot.name && spot.name.startsWith('Spot_') && spot.data.isRendered) renderFluffySpot(spot); });
                        });
                    }
                    activeLayer = appLayers[0]; selectedLayers = [activeLayer]; clearSelection(); saveState();
                } catch(err) { alert("ファイルの読み込みに失敗しました。"); }
            }; reader.readAsText(file); this.value = ''; 
        });

        document.addEventListener('keydown', (e) => {
            if (['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(e.key)) {
                e.preventDefault(); const panStep = 30;
                let ws = document.getElementById('workspace');
                if (e.key === 'ArrowUp') ws.scrollTop -= panStep; 
                if (e.key === 'ArrowDown') ws.scrollTop += panStep;
                if (e.key === 'ArrowLeft') ws.scrollLeft -= panStep; 
                if (e.key === 'ArrowRight') ws.scrollLeft += panStep;
                return;
            }
            if (e.key === 'PageUp') { e.preventDefault(); changeZoom(1.2); }
            if (e.key === 'PageDown') { e.preventDefault(); changeZoom(1 / 1.2); }
            if (e.key === 'Delete') { if(selectedSpots.length > 0) cmAction('delete'); }
            if (e.ctrlKey || e.metaKey) {
                if (e.key.toLowerCase() === 'c') { e.preventDefault(); cmAction('copy'); }
                if (e.key.toLowerCase() === 'v') { e.preventDefault(); cmAction('paste'); }
                if (e.key.toLowerCase() === 'x') { e.preventDefault(); cmAction('cut'); }
                if (e.key.toLowerCase() === 'j') { e.preventDefault(); cmAction('merge'); }
                if (e.key.toLowerCase() === 's') { e.preventDefault(); fileAction('save'); }
                if (e.key.toLowerCase() === 'z') { e.preventDefault(); execAction('undo'); }
                if (e.key.toLowerCase() === 'y') { e.preventDefault(); execAction('redo'); }
            }
        });
        
        changeZoom(null); // 初期サイズと中央スクロールの適用
        execAction('addSpot');

    </script>
</body>
</html>

いいなと思ったら応援しよう!

コメント

コメントするには、 ログイン または 会員登録 をお願いします。
小ぶりなプログラムを試しに作っているんですが、 ここではその説明書きをしていこうと思います。 こういう機能をつけてみてほしいだとかいった要望があれば コメント欄に書いてみて下さい。 ひまをみて対応します。
ふんわりしたペイントツール「FluffyPainter」|古井和雄
word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word

mmMwWLliI0fiflO&1
mmMwWLliI0fiflO&1
mmMwWLliI0fiflO&1
mmMwWLliI0fiflO&1
mmMwWLliI0fiflO&1
mmMwWLliI0fiflO&1
mmMwWLliI0fiflO&1