ふんわりしたペイントツール「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>

コメント