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



<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <title>Fluffy Painter 1.0</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; }
        
        #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; }
        .shortcut-hint { color: var(--text-muted); font-size: 0.85em; }
        .dropdown-item:hover .shortcut-hint { color: #ddd; }

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

        #sidebar { width: 300px; background-color: var(--bg-panel); display: flex; flex-direction: column; border-right: 1px solid var(--border); }
        
        #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); }
        .tab-btn.active { color: var(--text-main); border-bottom-color: var(--accent); background-color: var(--bg-panel); }
        
        .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); }
        input[type="color"] { width: 100%; height: 30px; border: none; border-radius: 4px; cursor: pointer; padding: 0; background: none; }
        
        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); }

        #workspace { flex-grow: 1; background-color: #1e1e1e; position: relative; display: flex; justify-content: center; align-items: center; overflow: hidden; }
        #checkerboard { position: absolute; top: 0; left: 0; right: 0; bottom: 0; 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 { background-color: transparent; z-index: 1; box-shadow: 0 0 20px rgba(0,0,0,0.8); }

        #status-bar { position: absolute; bottom: 10px; left: 10px; background: rgba(0,0,0,0.7); padding: 5px 10px; border-radius: 5px; z-index: 10; font-size: 0.9em; pointer-events: none;}
        
        .list-item { padding: 8px; border-radius: 4px; cursor: pointer; margin-bottom: 2px; border: 1px solid transparent; background-color: rgba(0,0,0,0.2); }
        .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); font-weight: bold; color: white;}
    </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 class="shortcut-hint">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="execAction('addSpot')">スポットの追加 <span class="shortcut-hint">新規領域</span></div>
                <div class="dropdown-item" onclick="execAction('delete')">削除 <span class="shortcut-hint">Del</span></div>
                <div class="dropdown-item" onclick="execAction('copy')">コピー <span class="shortcut-hint">Ctrl+C</span></div>
                <div class="dropdown-item" onclick="execAction('paste')">ペースト <span class="shortcut-hint">Ctrl+V</span></div>
                <div class="dropdown-item" onclick="execAction('unite')">スポット結合 <span class="shortcut-hint">Ctrl+J</span></div>
            </div>
        </div>
        <div class="menu-item">操作
            <div class="dropdown">
                <div class="dropdown-item" onclick="setMode('select')">選択・移動</div>
                <div class="dropdown-item" onclick="setMode('draw')">ドロー (領域の追加)</div>
                <div class="dropdown-item" onclick="setMode('erase')">ドロー (領域を削る)</div>
                <div class="dropdown-item" onclick="setMode('adjust')">ベジェ曲線の調整</div>
            </div>
        </div>
        <div class="menu-item">表示
            <div class="dropdown">
                <div class="dropdown-item" onclick="changeZoom(1.2)">ズームイン <span class="shortcut-hint">PageUp</span></div>
                <div class="dropdown-item" onclick="changeZoom(0.8)">ズームアウト <span class="shortcut-hint">PageDown</span></div>
                <div class="dropdown-item" onclick="paper.view.zoom = 1">100%表示</div>
            </div>
        </div>
        <div class="menu-item">設定</div>
    </div>

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

            <div id="tab-prop" class="tab-content active">
                <div class="prop-group">
                    <strong>現在のドロー設定</strong>
                    <label>ブラシサイズ: <span id="valBrush">40</span>px</label>
                    <input type="range" id="uiBrushSize" min="10" max="150" value="40">
                </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>
                        <input type="color" id="uiCenterCol" value="#8ab87a">
                    </div>
                    <div class="prop-group">
                        <label>輪郭色 (影・エッジ)</label>
                        <input type="color" id="uiEdgeCol" value="#2d4a22">
                    </div>
                    <div class="prop-group">
                        <label>ふんわり感: <span id="valBlur">25</span></label>
                        <input type="range" id="uiBlur" min="0" max="80" value="25">
                    </div>
                    <div class="prop-group">
                        <label>影の深さ (きつさ): <span id="valDepth">15</span></label>
                        <input type="range" id="uiDepth" min="0" max="50" value="15">
                    </div>

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

            <div id="tab-layers" class="tab-content">
                <div class="list-item selected">レイヤー 1 (統合)</div>
            </div>
            <div id="tab-spots" class="tab-content">
                <p style="color:#888; font-size:12px; margin:0 0 10px 0;">※編集メニューから追加してください</p>
                <div id="spot-list-container"></div>
            </div>
        </div>

        <div id="workspace">
            <div id="checkerboard"></div>
            <canvas id="myCanvas" width="1000" height="800"></canvas>
            <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>

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

    <script>
        paper.setup('myCanvas');
        
        let mode = 'select'; 
        let brushRadius = 40;
        let selectedSpots = [];
        let clipboard = null;
        let selectionRect = null;
        let spotCounter = 0;
        let draftQueue = [];

        const spotLayer = new paper.Layer({ name: 'spotLayer' }); 
        const draftLayer = new paper.Layer({ name: 'draftLayer' }); 
        const uiLayer = new paper.Layer({ name: 'uiLayer' }); 

        const uiBrushSize = document.getElementById('uiBrushSize');
        const uiCenterCol = document.getElementById('uiCenterCol');
        const uiEdgeCol = document.getElementById('uiEdgeCol');
        const uiBlur = document.getElementById('uiBlur');
        const uiDepth = document.getElementById('uiDepth');
        const propPanel = document.getElementById('spot-properties');
        const spotListContainer = document.getElementById('spot-list-container');

        uiBrushSize.oninput = (e) => { brushRadius = parseInt(e.target.value); document.getElementById('valBrush').innerText = brushRadius; };

        const updateProps = (e) => {
            if (e && e.target) document.getElementById(e.target.id.replace('ui', 'val')).innerText = e.target.value;
            if (selectedSpots.length > 0) {
                selectedSpots.forEach(spot => {
                    spot.data.centerCol = uiCenterCol.value; spot.data.edgeCol = uiEdgeCol.value;
                    spot.data.blurAmt = parseInt(uiBlur.value); spot.data.depthAmt = parseInt(uiDepth.value);
                    if(spot.data.isRendered) renderFluffySpot(spot);
                });
            }
        };
        uiCenterCol.oninput = updateProps; uiEdgeCol.oninput = updateProps; uiBlur.oninput = updateProps; uiDepth.oninput = updateProps;

        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;

            spotLayer.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();
        }

        function updateVisuals() {
            spotListContainer.innerHTML = '';
            let spots = spotLayer.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' : '');
                div.innerText = spot.data.displayName || spot.name;
                div.onclick = (e) => {
                    commitVector(); 
                    if (mode !== 'adjust' && mode !== 'draw' && mode !== 'erase') setMode('select');
                    selectSpot(spot, e.ctrlKey || e.metaKey);
                };
                spotListContainer.appendChild(div);
            }

            spotLayer.children.forEach(spot => {
                if (!spot.name || !spot.name.startsWith('Spot_')) return;
                let base = spot.children['basePath'];
                let renderGrp = spot.children['renderGroup'];

                if (mode === 'adjust' && selectedSpots.includes(spot)) {
                    base.visible = true; base.fullySelected = true;
                    base.strokeColor = '#00aaff'; base.strokeWidth = 1.5; base.fillColor = null;
                    if (renderGrp) { renderGrp.visible = true; renderGrp.opacity = 0.5; }
                } else if ((mode === 'draw' || mode === 'erase') && selectedSpots.includes(spot)) {
                    base.visible = true; base.fullySelected = false;
                    base.strokeColor = '#00ff00'; base.strokeWidth = 2 / paper.view.zoom;
                    base.fillColor = 'rgba(255,255,255,0.01)'; 
                    if (renderGrp) { renderGrp.visible = true; renderGrp.opacity = 0.8; }
                } else {
                    base.visible = false; base.fullySelected = false;
                    if (renderGrp) { renderGrp.visible = true; renderGrp.opacity = 1.0; }
                }

                if (!spot.data.isRendered) {
                    base.visible = true; base.fullySelected = false;
                    base.strokeColor = selectedSpots.includes(spot) ? '#00ff00' : '#888888';
                    base.strokeWidth = 2 / paper.view.zoom;
                    base.fillColor = 'rgba(255,255,255,0.01)'; 
                    if (renderGrp) renderGrp.visible = false;
                }
            });

            if (mode === 'select' || mode === 'adjust') updateSelectionBounds();
            else if (selectionRect) { selectionRect.remove(); selectionRect = null; }
        }

        window.setMode = function(newMode) {
            commitVector(); 
            mode = newMode;
            const texts = { 'draw': 'ドロー (追加)', 'erase': 'ドロー (削る)', 'select': '選択・移動', 'adjust': 'ベジェ調整' };
            document.getElementById('mode-text').innerText = texts[mode];
            document.getElementById('myCanvas').style.cursor = mode === 'select' ? 'default' : 'crosshair';
            updateVisuals();
        };

        // --- コアレンダリング ---
        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' });
            
            // ★修正: 元のbasePathが不可視状態でも、複製先は必ず可視化する
            const baseFill = basePath.clone();
            baseFill.visible = true;
            baseFill.fillColor = data.centerCol;
            renderGroup.addChild(baseFill);

            const clipGroup = new paper.Group();
            const clipMask = basePath.clone();
            clipMask.visible = true;
            clipGroup.addChild(clipMask);
            clipGroup.clipped = true;

            const shadowWide = basePath.clone();
            shadowWide.visible = true;
            shadowWide.fillColor = null;
            shadowWide.strokeColor = data.edgeCol;
            shadowWide.strokeWidth = 2; 
            shadowWide.shadowColor = data.edgeCol;
            shadowWide.shadowBlur = data.blurAmt;
            shadowWide.shadowOffset = new paper.Point(0, 0);
            
            const shadowDeep = basePath.clone();
            shadowDeep.visible = true;
            shadowDeep.fillColor = null;
            shadowDeep.strokeColor = data.edgeCol;
            shadowDeep.strokeWidth = 2; 
            shadowDeep.shadowColor = data.edgeCol;
            shadowDeep.shadowBlur = data.depthAmt / 4; 
            shadowDeep.shadowOffset = new paper.Point(0, 0); 

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

        const tool = new paper.Tool();
        let currentDraftPath = null;
        let startPoint = null;
        let activeSegment = null; let activeHandle = null;

        tool.onMouseDown = function(event) {
            if (mode === 'select') {
                const hitResult = spotLayer.hitTest(event.point, { fill: true, stroke: true, tolerance: 2 });
                if (hitResult) {
                    let item = hitResult.item;
                    while(item.parent && item.parent !== spotLayer) { item = item.parent; } 
                    selectSpot(item, event.modifiers.control || event.modifiers.command);
                } else {
                    clearSelection();
                }
                return;
            }

            if (mode === 'adjust') {
                if (selectedSpots.length === 0) return;
                let targetSpot = selectedSpots[0];
                let hitResult = targetSpot.children['basePath'].hitTest(event.point, { segments: true, handles: true, tolerance: 8 });
                if (hitResult) {
                    if (hitResult.type === 'segment') activeSegment = hitResult.segment;
                    else if (hitResult.type === 'handle-in' || hitResult.type === 'handle-out') {
                        activeHandle = hitResult.type; activeSegment = hitResult.segment;
                    }
                }
                return;
            }

            if (selectedSpots.length === 0) {
                alert("ドローするスポットをリストから選択するか、「編集」メニューからスポットを追加してください。");
                setMode('select'); return;
            }

            startPoint = event.point;
            draftLayer.activate();
            currentDraftPath = new paper.Path.Circle({
                center: event.point, radius: brushRadius,
                fillColor: mode === 'draw' ? 'rgba(200, 200, 200, 0.4)' : 'rgba(255, 68, 68, 0.4)',
                strokeColor: mode === 'draw' ? '#ffffff' : '#ff0000', strokeWidth: 1
            });
        };

        tool.onMouseDrag = function(event) {
            if (mode === 'select') {
                selectedSpots.forEach(spot => spot.position = spot.position.add(event.delta));
                updateSelectionBounds(); return;
            }

            if (mode === 'adjust' && activeSegment) {
                if (activeHandle === 'handle-in') activeSegment.handleIn = activeSegment.handleIn.add(event.delta);
                else if (activeHandle === 'handle-out') activeSegment.handleOut = activeSegment.handleOut.add(event.delta);
                else activeSegment.point = activeSegment.point.add(event.delta);
                return;
            }

            if ((mode === 'draw' || mode === 'erase') && currentDraftPath) {
                if (event.point.getDistance(startPoint) > brushRadius / 4) {
                    let circle = new paper.Path.Circle({ center: event.point, radius: brushRadius });
                    let newPath = currentDraftPath.unite(circle);
                    currentDraftPath.remove(); circle.remove();
                    currentDraftPath = newPath;
                    currentDraftPath.fillColor = mode === 'draw' ? 'rgba(200, 200, 200, 0.4)' : 'rgba(255, 68, 68, 0.4)';
                    currentDraftPath.strokeColor = mode === 'draw' ? '#ffffff' : '#ff0000';
                    currentDraftPath.strokeWidth = 1;
                    startPoint = event.point;
                }
            }
        };

        tool.onMouseUp = function(event) {
            if (mode === 'adjust' && activeSegment) {
                if (selectedSpots[0].data.isRendered) renderFluffySpot(selectedSpots[0]);
                updateSelectionBounds(); activeSegment = null; activeHandle = null; return;
            }

            if ((mode === 'draw' || mode === 'erase') && currentDraftPath) {
                currentDraftPath.simplify(2); 
                draftQueue.push({ path: currentDraftPath, mode: mode });
                currentDraftPath = null;
            }
        };

        function selectSpot(spot, isMulti) {
            commitVector(); 

            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';
            updateVisuals(); 
        }

        function clearSelection() {
            commitVector();
            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, 4];
        }

        window.changeZoom = function(factor) {
            paper.view.zoom *= factor;
            document.getElementById('zoom-text').innerText = Math.round(paper.view.zoom * 100) + '%';
            if(selectionRect) updateSelectionBounds();
            updateVisuals();
        }

        window.execAction = function(action) {
            commitVector(); 

            if (action === 'addSpot') {
                spotCounter++;
                const spotGroup = new paper.Group({ name: 'Spot_' + Date.now() });
                spotGroup.data = { isRendered: false, displayName: 'スポット ' + spotCounter, centerCol: uiCenterCol.value, edgeCol: uiEdgeCol.value, blurAmt: parseInt(uiBlur.value), depthAmt: parseInt(uiDepth.value) };
                
                const basePath = new paper.Path({ name: 'basePath', visible: false });
                spotGroup.addChild(basePath);
                spotLayer.addChild(spotGroup);
                
                selectSpot(spotGroup, false);
                setMode('draw'); 
            }
            else if (action === 'vectorize') {
                updateVisuals(); 
            }
            else if (action === 'render') {
                if (selectedSpots.length > 0) {
                    selectedSpots.forEach(spot => renderFluffySpot(spot));
                    setMode('select'); 
                }
            }
            else if (action === 'delete') {
                selectedSpots.forEach(s => s.remove());
                clearSelection();
            } else if (action === 'copy' && selectedSpots.length > 0) {
                clipboard = selectedSpots[0].exportJSON();
            } else if (action === 'paste' && clipboard) {
                spotLayer.activate();
                let pasted = new paper.Group();
                pasted.importJSON(clipboard);
                pasted.name = 'Spot_' + Date.now();
                pasted.data.displayName = pasted.data.displayName + ' (コピー)';
                pasted.position.x += 20; pasted.position.y += 20; 
                spotLayer.addChild(pasted);
                if (pasted.data.isRendered) renderFluffySpot(pasted);
                selectSpot(pasted, false);
            } else if (action === 'unite' && selectedSpots.length > 1) {
                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);
                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);
                spotLayer.addChild(newSpot);
                selectSpot(newSpot, false);
            }
        };

        window.fileAction = function(action) {
            commitVector();

            if (action === 'new') {
                if(confirm('現在の作業内容は失われます。新規作成しますか?')) {
                    spotLayer.removeChildren(); draftLayer.removeChildren();
                    spotCounter = 0; clearSelection();
                    execAction('addSpot');
                }
            } else if (action === 'save') {
                const dataStr = "data:text/json;charset=utf-8," + encodeURIComponent(spotLayer.exportJSON());
                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(); 
                
                spotLayer.children.forEach(spot => {
                    if (spot.name && spot.name.startsWith('Spot_')) {
                        if (!spot.data.isRendered) renderFluffySpot(spot);
                        let base = spot.children['basePath'];
                        if (base) base.visible = false;
                        let renderGrp = spot.children['renderGroup'];
                        if (renderGrp) { renderGrp.visible = true; renderGrp.opacity = 1.0; }
                    }
                });

                paper.view.update(); 
                
                setTimeout(() => {
                    const a = document.createElement('a');
                    a.href = document.getElementById('myCanvas').toDataURL('image/png');
                    a.download = 'fluffy_export.png';
                    a.click();
                    setMode(oldMode); 
                }, 100);
            } else if (action === 'resize') {
                const w = prompt('新しい幅を入力', paper.view.viewSize.width);
                const h = prompt('新しい高さを入力', paper.view.viewSize.height);
                if (w && h) { paper.view.viewSize = new paper.Size(parseInt(w), parseInt(h)); }
            }
        };

        document.getElementById('fileLoader').addEventListener('change', function(e) {
            const file = e.target.files[0];
            if (!file) return;
            const reader = new FileReader();
            reader.onload = function(evt) {
                spotLayer.removeChildren();
                spotLayer.importJSON(evt.target.result);
                spotLayer.children.forEach(spot => {
                    if(spot.name && spot.name.startsWith('Spot_') && spot.data.isRendered) renderFluffySpot(spot);
                });
                clearSelection();
            };
            reader.readAsText(file);
            this.value = ''; 
        });

        document.addEventListener('keydown', (e) => {
            if (['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(e.key)) {
                e.preventDefault();
                const panStep = 30 / paper.view.zoom;
                if (e.key === 'ArrowUp') paper.view.center.y -= panStep;
                if (e.key === 'ArrowDown') paper.view.center.y += panStep;
                if (e.key === 'ArrowLeft') paper.view.center.x -= panStep;
                if (e.key === 'ArrowRight') paper.view.center.x += panStep;
                if (selectionRect) updateSelectionBounds();
                return;
            }

            if (e.key === 'PageUp') { e.preventDefault(); changeZoom(1.2); }
            if (e.key === 'PageDown') { e.preventDefault(); changeZoom(1.1 / 1.2); }
            if (e.key === 'Delete') execAction('delete');

            if (e.ctrlKey || e.metaKey) {
                if (e.key.toLowerCase() === 'c') execAction('copy');
                if (e.key.toLowerCase() === 'v') execAction('paste');
                if (e.key.toLowerCase() === 'j') { e.preventDefault(); execAction('unite'); }
                if (e.key.toLowerCase() === 's') { e.preventDefault(); fileAction('save'); }
            }
        });
        
        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