ふんわりしたペイントツール「FluffyPainter」
【更新履歴】
・2026/3/7 バージョン1.0公開。
・2026/3/8 バージョン1.1公開。
・2026/3/8 バージョン1.2公開。
・ダウンロードされる方はこちら。↓
《取扱説明書》
【ツールの概要】
・Fluffy Painterは、
ふんわりとした柔らかい質感の図形(スポット)を
直感的に描画・編集できる
Webブラウザ向けのペイントツールです。
・中心から輪郭にかけての美しいグラデーションと影を自動生成し、
ベクターデータとして後から自由に変形や調整を行うことができます。
【画面構成】
・画面は大きく分けて4つの部分から構成されています。
(1) 画面上部の「メニューバー」では、
ファイルの保存や読み込み、画像出力、
キャンバスの表示倍率の変更などを行います。
(2) メニューバーのすぐ下にある「ツールバー」では、
描画や編集を行うための各種ツールを切り替えます。
(3) 画面左側の「サイドバー」は、
レイヤーやスポットのリストによる管理、
および選択中のスポットの質感や色を調整する
プロパティパネルを備えています。
(4) 画面右側の「ワークスペース」は、
実際に描画を行うキャンバス領域です。
スクロールバーやマウスによるスクロール、
ズーム機能に対応しています。
【ツールバーの基本操作】
・ツールバーから目的のボタンをクリックして操作モードを切り替えます。
(1) 「描く」モードは、
キャンバス上をドラッグして
新しいスポットの形状を描き足します。
(2) 「消す」モードは、
描いたスポットの一部をドラッグして消去し、形を削ります。
(3) 「塗る」モードは、
線で囲まれた閉じた領域の中をクリックして塗りつぶします。
(4) 「選択」モードは、
キャンバス上のスポットをクリックして選択状態にします。
Ctrlキー(MacはCmdキー)を押しながらクリックすることで
複数選択が可能です。
(5) 「移動」「回転」「拡縮」モードは、
選択したスポット全体をドラッグ操作で直感的に変形・移動させます。
(6) 「調整」モードは、
スポットの輪郭を構成するベジェ曲線のポイント(点)や
ハンドルを直接ドラッグして、細かな形状調整を行います。
Ctrlキーを押しながらポイントをクリックすると複数選択が可能です。
また、パスやポイント上で右クリックすると、
隣接するポイントの中間への新規ポイント追加や、
選択中のポイントの削除を行うメニューが表示されます。
【プロパティと色の設定】
・サイドバーの「プロパティ」タブでは、
ドロー設定やスポットの質感を変更できます。
・設定内容は即座にキャンバスへ反映されます。
(1) 「ドロー設定」のブラシサイズスライダーで、
描画や消去時の線の太さを調整します。
(2) 「選択中スポットの質感」では、
中心色、中間色、輪郭色の3色を設定してグラデーションを作ります。
各色のカラーサンプル枠をクリックすると色選択ダイアログが開き、
色を変更できます。
(3) カラーサンプル枠をドラッグ&ドロップすることで、
設定した色を別の枠へ簡単にコピーできます。
(4) 「中間色を自動生成」ボタンを押すと、
現在の中心色と輪郭色の中間となる色合いを
自動的に計算して設定します。
(5) グラデーションパレットには、
あらかじめパステルカラーのセットが用意されています。
パレットのセルからドラッグして
上部のカラーサンプル枠にドロップすると、
3色セットをまとめて適用できます。
逆に、現在のカラーサンプル枠からパレットへ
ドラッグ&ドロップすることで、
お気に入りの色セットを保存できます。
(6) 「ふんわり感」と「影の深さ」のスライダーを動かすと、
スポットのぼかし具合や立体的なくっきり感を
リアルタイムに調整できます。
【レイヤーとスポットの管理】
・サイドバーの「レイヤー」および「スポット」タブで、
描画した要素の構成を管理します。
(1) リスト上の各項目をクリックすると選択状態になり、
左側のチェックボックスで表示と非表示を切り替えられます。
Ctrlキーを押しながらのクリックで複数選択が可能です。
(2) 項目がない余白部分で右クリックすると、
新規追加や貼り付けなどのメニューが表示されます。
(3) 既存の項目上で右クリックすると、
コピー、カット、貼り付け、名前の変更、透明度の変更、
削除などのメニューが表示されます。
複数の項目を選択した状態で「結合」を選ぶと、
それらを一つのレイヤーやスポットにまとめることができます。
【ファイル操作とショートカット】
(1) メニューバーの「ファイル」から、
「上書き保存 (JSON)」を選ぶと
現在のプロジェクトデータを保存でき、
「開く...」から保存したデータを読み込んで作業を再開できます。
「PNG出力」を選ぶと、
現在のキャンバスの見た目を、png画像として保存します。
(2) ワークスペース上で方向キーを押すか、
スポットがない空白の空間をドラッグすると、
キャンバスの可視範囲をスクロールできます。
PageUpキーやPageDownキー、
またはメニューバーの「表示」から
ズームインとズームアウトが可能です。
(3) 一般的なキーボードショートカットに対応しており、
Ctrl+Zで元に戻す、Ctrl+Yでやり直す、Ctrl+Cでコピー、
Ctrl+Vで貼り付け、Ctrl+Xでカット、Ctrl+Jで結合、
Deleteキーで削除などがすばやく実行できます。
・ソースコードはこちら。↓
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<title>Fluffy Painter 1.2</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; touch-action: none;}
/* 共通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); }
select { background-color: #222; color: #fff; border: 1px solid #555; padding: 4px; border-radius: 3px; outline: none;}
.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; touch-action: none;}
#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; }
#palette-dialog { display:none; position:fixed; top:0; left:0; width:100%; height:100%; background:rgba(0,0,0,0.8); z-index:2000; justify-content:center; align-items:center; }
</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('svg')">SVG出力</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>
<div style="display:flex; justify-content:space-between; align-items:center;">
<label style="font-size: 0.8em; color: var(--text-muted);">グラデーションパレット</label>
<button class="action-btn-outline" style="padding:2px 5px; font-size:0.7em; margin:0; width:auto;" onclick="openPaletteLibrary()">もっと見る...</button>
</div>
<div id="palette-grid"></div>
</div>
<div class="prop-group" style="margin-top: 10px;">
<label>光の方向 (角度): <span id="valLightAngle">0</span>°</label>
<input type="range" id="uiLightAngle" min="0" max="360" value="0" oninput="updateProps({target: this})" onchange="saveState()">
</div>
<div class="prop-group">
<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 class="prop-group">
<label>テクスチャ:</label>
<select id="uiTexture" onchange="updateProps({target: this}); saveState();">
<option value="none">なし</option>
<option value="dot">ドット (ノイズ風)</option>
<option value="stripe">ストライプ</option>
</select>
</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="sep_order"></div>
<div class="cm-item" data-action="bringFront" onclick="cmAction('bringFront')">最前面へ移動</div>
<div class="cm-item" data-action="bringForward" onclick="cmAction('bringForward')">前面へ移動</div>
<div class="cm-item" data-action="sendBackward" onclick="cmAction('sendBackward')">背面へ移動</div>
<div class="cm-item" data-action="sendBack" onclick="cmAction('sendBack')">最背面へ移動</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>
<div id="palette-dialog">
<div style="background:var(--bg-panel); padding:20px; border-radius:8px; width:80%; max-height:80%; display:flex; flex-direction:column; border:1px solid #555;">
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:15px;">
<h2 style="margin:0; font-size:1.2em;">パレットライブラリ</h2>
<button onclick="document.getElementById('palette-dialog').style.display='none'" class="action-btn-outline" style="padding:5px 10px; width:auto; margin:0;">閉じる</button>
</div>
<div style="color:var(--text-muted); font-size:0.9em; margin-bottom:10px;">クリックすると選択中のスポットのプロパティに適用されます。</div>
<div id="library-grid" style="display:grid; grid-template-columns:repeat(auto-fill, minmax(80px, 1fr)); gap:10px; overflow-y:auto; flex-grow:1; padding:5px;"></div>
</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 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;
ws.scrollTop = (wrap.offsetHeight - ws.clientHeight) / 2 + 40;
}
document.getElementById('zoom-text').innerText = Math.round(currentZoom * 100) + '%';
if(selectionRect) updateSelectionBounds();
updateVisuals();
}
// --- タッチ・ピンチズーム対応 ---
const workspaceElem = document.getElementById('workspace');
let initialPinchDist = null;
let initialZoom = null;
let lastTouchPoint = null;
workspaceElem.addEventListener('touchstart', (e) => {
if (e.touches.length === 2) {
e.preventDefault();
initialPinchDist = Math.hypot(e.touches[0].clientX - e.touches[1].clientX, e.touches[0].clientY - e.touches[1].clientY);
initialZoom = currentZoom;
} else if (e.touches.length === 1 && ['select','move','rotate','scale'].includes(mode) && !paper.project.hitTest(new paper.Point(e.touches[0].clientX - workspaceElem.getBoundingClientRect().left + workspaceElem.scrollLeft, e.touches[0].clientY - workspaceElem.getBoundingClientRect().top + workspaceElem.scrollTop), {fill:true, stroke:true, tolerance:4})) {
lastTouchPoint = { x: e.touches[0].clientX, y: e.touches[0].clientY };
}
}, {passive: false});
workspaceElem.addEventListener('touchmove', (e) => {
if (e.touches.length === 2 && initialPinchDist) {
e.preventDefault();
let currentDist = Math.hypot(e.touches[0].clientX - e.touches[1].clientX, e.touches[0].clientY - e.touches[1].clientY);
let ratio = currentDist / initialPinchDist;
let newZoom = initialZoom * ratio;
if (newZoom < 0.1) newZoom = 0.1;
if (newZoom > 10) newZoom = 10;
let factor = newZoom / currentZoom;
changeZoom(factor);
} else if (e.touches.length === 1 && lastTouchPoint) {
e.preventDefault();
workspaceElem.scrollLeft -= (e.touches[0].clientX - lastTouchPoint.x);
workspaceElem.scrollTop -= (e.touches[0].clientY - lastTouchPoint.y);
lastTouchPoint = { x: e.touches[0].clientX, y: e.touches[0].clientY };
}
}, {passive: false});
workspaceElem.addEventListener('touchend', (e) => {
if (e.touches.length < 2) initialPinchDist = null;
if (e.touches.length === 0) lastTouchPoint = null;
});
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'}
];
// パレットライブラリ用データ
const extendedPalettes = [
{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'},
{c: '#ffc0cb', m: '#ff1493', e: '#8b008b'}, {c: '#ffa07a', m: '#ff4500', e: '#b22222'},
{c: '#ffff00', m: '#ffd700', e: '#daa520'}, {c: '#00ff00', m: '#32cd32', e: '#006400'},
{c: '#00ffff', m: '#00ced1', e: '#008b8b'}, {c: '#0000ff', m: '#0000cd', e: '#000080'},
{c: '#ee82ee', m: '#9400d3', e: '#4b0082'}, {c: '#ffebcd', m: '#d2b48c', e: '#8b4513'},
{c: '#ffffff', m: '#aaaaaa', e: '#333333'}, {c: '#e0e0e0', m: '#757575', e: '#212121'},
{c: '#e0f7fa', m: '#00bcd4', e: '#006064'}, {c: '#ede7f6', m: '#673ab7', e: '#311b92'},
{c: '#fdfbf7', m: '#e0c38c', e: '#b07f35'}, {c: '#f2f0e6', m: '#a6a592', e: '#59584c'},
{c: '#fcf6f5', m: '#d9a6a9', e: '#a8494f'}, {c: '#f2faeb', m: '#86b05e', e: '#3c5922'}
];
window.openPaletteLibrary = function() {
const grid = document.getElementById('library-grid');
grid.innerHTML = '';
extendedPalettes.forEach(colors => {
let cell = document.createElement('div');
cell.style.height = '40px'; cell.style.borderRadius = '4px'; cell.style.border = '1px solid #111'; cell.style.cursor = 'pointer';
cell.style.background = `linear-gradient(to right, ${colors.c} 33%, ${colors.m} 33% 66%, ${colors.e} 66%)`;
cell.onclick = () => {
uiCenterCol.value = colors.c; uiMidCol.value = colors.m; uiEdgeCol.value = colors.e;
updateProps({target: uiCenterCol}); updateProps({target: uiMidCol}); updateProps({target: uiEdgeCol});
saveState(); document.getElementById('palette-dialog').style.display='none';
};
grid.appendChild(cell);
});
document.getElementById('palette-dialog').style.display='flex';
};
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) {
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 uiLightAngle = document.getElementById('uiLightAngle');
const uiTexture = document.getElementById('uiTexture');
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);
spot.data.lightAngle = parseInt(uiLightAngle.value) || 0; spot.data.texture = uiTexture.value || 'none';
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 if (type === 'layer' && ['bringFront', 'bringForward', 'sendBackward', 'sendBack', 'sep_order'].includes(action)) 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)を押しながら複数のスポットを選択してください。");
}
// 順序変更
else if(action === 'bringFront') { selectedSpots.forEach(s => s.bringToFront()); saveState(); }
else if(action === 'bringForward') {
selectedSpots.forEach(spot => { let parent = spot.parent; let idx = parent.children.indexOf(spot); if(idx < parent.children.length - 1) parent.insertChild(idx + 1, spot); }); saveState();
}
else if(action === 'sendBackward') {
selectedSpots.forEach(spot => { let parent = spot.parent; let idx = parent.children.indexOf(spot); if(idx > 0) parent.insertChild(idx - 1, spot); }); saveState();
}
else if(action === 'sendBack') {
selectedSpots.forEach(spot => { let parent = spot.parent; parent.insertChild(0, spot); }); saveState();
}
updateVisuals();
}
function createTextureGroup(path, type, color) {
let bounds = path.bounds;
let group = new paper.Group();
if (type === 'dot') {
let step = 15;
for (let x = bounds.left; x < bounds.right; x += step) {
for (let y = bounds.top; y < bounds.bottom; y += step) {
let dot = new paper.Path.Circle({ center: [x + (y%30===0?0:step/2), y], radius: 2.5, fillColor: color });
group.addChild(dot);
}
}
} else if (type === 'stripe') {
let step = 20; let ext = bounds.expand(200);
for (let i = -ext.height; i < ext.width + ext.height; i += step) {
let line = new paper.Path.Line({ from: [ext.left + i, ext.top], to: [ext.left + i - ext.height, ext.bottom], strokeColor: color, strokeWidth: 5 });
group.addChild(line);
}
}
return group;
}
// --- コアレンダリング (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 angleRad = (data.lightAngle || 0) * Math.PI / 180;
const shiftDistance = 10000;
const shadowVecX = -Math.cos(angleRad);
const shadowVecY = -Math.sin(angleRad);
const offsetX = shiftDistance * shadowVecX;
const offsetY = shiftDistance * shadowVecY;
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(-offsetX, -offsetY)); shadowWide.shadowOffset = new paper.Point(offsetX, offsetY);
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(-offsetX, -offsetY)); shadowDeep.shadowOffset = new paper.Point(offsetX, offsetY);
clipGroup.addChild(shadowWide); clipGroup.addChild(shadowDeep);
if (data.texture && data.texture !== 'none') {
let texColor = 'rgba(0, 0, 0, 0.15)';
let texGroup = createTextureGroup(basePath, data.texture, texColor);
if (texGroup.children.length > 0) {
texGroup.blendMode = 'overlay';
clipGroup.addChild(texGroup);
} else {
texGroup.remove();
}
}
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;
uiLightAngle.value = data.lightAngle || 0; document.getElementById('valLightAngle').innerText = uiLightAngle.value;
uiTexture.value = data.texture || 'none';
}
}
// --- 筆圧対応・動的太さのパス生成 ---
function createTaperedPath(pointsData, maxRadius) {
if (pointsData.length < 2) return new paper.Path.Circle({ center: pointsData[0].point, radius: Math.max(0.5, maxRadius / 2 * pointsData[0].pressure) });
let spinePoints = pointsData.map(p => p.point);
let spine = new paper.Path({ segments: spinePoints }); spine.simplify(2); let totalLength = spine.length;
if (totalLength < 1) { spine.remove(); return new paper.Path.Circle({ center: spinePoints[0], radius: Math.max(0.5, maxRadius / 2 * pointsData[0].pressure) }); }
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 ratio = t * (pointsData.length - 1);
let idx = Math.floor(ratio); let rem = ratio - idx;
let pressure = 1;
if (idx < pointsData.length - 1) { pressure = pointsData[idx].pressure * (1 - rem) + pointsData[idx+1].pressure * rem; }
else { pressure = pointsData[idx].pressure; }
let radius = Math.max(0.5, Math.sin(t * Math.PI) * maxRadius * pressure);
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; 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 bp = selectedSpots[0].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();
let press = event.event.pressure !== undefined && event.event.pressure > 0 ? event.event.pressure : 1;
strokePoints = [{ point: event.point, pressure: press }];
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 || 0;
ws.scrollTop -= event.event.movementY || 0;
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].point) > 2) {
let press = event.event.pressure !== undefined && event.event.pressure > 0 ? event.event.pressure : 1;
strokePoints.push({ point: event.point, pressure: press }); 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 newCurve = adjustTargetLocation.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), lightAngle: parseInt(uiLightAngle.value) || 0, texture: uiTexture.value || 'none' };
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' || action === 'svg') {
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(() => {
if (action === 'png') {
const a = document.createElement('a'); a.href = document.getElementById('myCanvas').toDataURL('image/png'); a.download = 'fluffy_export.png'; a.click();
} else if (action === 'svg') {
let svgStr = paper.project.exportSVG({asString: true});
let blob = new Blob([svgStr], {type: "image/svg+xml;charset=utf-8"});
let url = URL.createObjectURL(blob);
let a = document.createElement('a'); a.href = url; a.download = 'fluffy_export.svg'; a.click();
URL.revokeObjectURL(url);
}
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>


コメント