絵文字で絵を描く「Emoji Map」
【更新履歴】
・2026/2/17バージョン1.0公開。
・2026/2/17バージョン1.1公開。
・ダウンロードされる方はこちら。↓
・ソースコードはこちら。↓
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>Emoji Map 1.1</title>
<style>
:root {
--bg-app: #e0e0e0;
--bg-panel: #f5f5f5;
--border: #ccc;
--accent: #4a90e2;
--accent-hover: #357abd;
--text: #333;
--menu-bg: #333;
--menu-text: #fff;
--menu-hover: #555;
}
body {
margin: 0;
padding: 0;
font-family: "Segoe UI", sans-serif;
background: var(--bg-app);
height: 100vh;
display: flex;
flex-direction: column;
overflow: hidden;
user-select: none;
-webkit-user-select: none; /* iOS Safari */
}
/* --- メニューバー (ドロップダウン) --- */
#menubar {
background: var(--menu-bg);
color: var(--menu-text);
display: flex;
height: 30px;
align-items: center;
font-size: 13px;
position: relative;
z-index: 100;
}
.menu-group { position: relative; }
.menu-title { padding: 0 15px; line-height: 30px; cursor: pointer; }
.menu-title:hover { background: var(--menu-hover); }
.dropdown {
display: none;
position: absolute;
top: 30px; left: 0;
background: #fff; color: #000;
border: 1px solid #ccc;
box-shadow: 2px 2px 5px rgba(0,0,0,0.2);
min-width: 200px;
flex-direction: column;
}
.menu-group:hover .dropdown { display: flex; }
.menu-item { padding: 8px 15px; cursor: pointer; display: flex; justify-content: space-between; }
.menu-item:hover { background: #eee; }
.shortcut { color: #888; font-size: 0.9em; margin-left: 10px; }
.menu-sep { height: 1px; background: #ddd; margin: 2px 0; }
/* --- ツールバー --- */
#toolbar {
background: var(--bg-panel);
border-bottom: 1px solid var(--border);
padding: 5px;
display: flex;
gap: 5px;
align-items: center;
flex-wrap: wrap;
min-height: 44px;
}
.tool-group {
display: flex; gap: 2px; align-items: center;
border-right: 1px solid #ccc; padding-right: 5px; margin-right: 5px;
}
.tool-group:last-child { border: none; }
.tool-btn {
width: 40px; height: 40px;
border: 1px solid #aaa; background: white; border-radius: 4px;
cursor: pointer; display: flex; flex-direction: column;
justify-content: center; align-items: center;
font-size: 18px; position: relative; transition: background 0.1s;
}
.tool-btn span { font-size: 9px; margin-top: -2px; }
.tool-btn:active { transform: translateY(1px); }
.tool-btn.active {
background: #d0e8ff; border-color: var(--accent);
box-shadow: inset 0 0 3px rgba(0,0,0,0.3);
}
#current-icon-wrapper {
display: flex; align-items: center; gap: 5px; padding: 0 10px;
background: #fff; border: 1px solid #ccc; border-radius: 4px; height: 40px;
}
#current-emoji-display { font-size: 32px; line-height: 1; width: 40px; text-align: center; }
/* --- メインレイアウト --- */
#main-area { display: flex; flex: 1; overflow: hidden; }
/* --- 左パネル(パレット) --- */
#left-panel {
width: 260px; background: var(--bg-panel);
border-right: 1px solid var(--border);
display: flex; flex-direction: column; z-index: 5;
}
#palette-controls {
padding: 8px; background: #eee; border-bottom: 1px solid #ccc;
display: flex; flex-direction: column; gap: 5px;
}
select#category-select {
width: 100%; padding: 5px; font-size: 14px;
border-radius: 4px; border: 1px solid #999;
}
#palette-grid {
flex: 1; padding: 8px; overflow-y: auto;
display: grid; grid-template-columns: repeat(auto-fill, minmax(40px, 1fr));
gap: 4px; align-content: start;
}
.emoji-cell {
width: 40px; height: 40px; font-size: 28px;
background: white; border: 1px solid #ddd; border-radius: 4px;
display: flex; justify-content: center; align-items: center;
cursor: pointer; user-select: none; touch-action: none;
}
.emoji-cell:hover { background: #f0f0f0; }
.emoji-cell.selected { border: 2px solid var(--accent); background: #eef6ff; }
.emoji-cell.dragging { opacity: 0.5; border: 2px dashed #333; }
/* --- キャンバスエリア --- */
#canvas-wrapper {
flex: 1; position: relative; background: #888; overflow: hidden;
}
#scroll-container {
width: 100%; height: 100%; overflow: auto;
display: flex;
background-image:
linear-gradient(45deg, #ccc 25%, transparent 25%),
linear-gradient(-45deg, #ccc 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, #ccc 75%),
linear-gradient(-45deg, transparent 75%, #ccc 75%);
background-size: 20px 20px;
background-color: #fff;
}
canvas {
margin: auto;
background: white;
box-shadow: 0 0 10px rgba(0,0,0,0.5);
cursor: crosshair;
image-rendering: pixelated;
display: block;
}
/* --- モーダル --- */
#overlay {
position: fixed; top:0; left:0; right:0; bottom:0;
background: rgba(0,0,0,0.5);
display: none;
z-index: 2000; justify-content: center; align-items: center;
}
.dialog {
background: white; padding: 20px; border-radius: 6px;
width: 320px; box-shadow: 0 10px 25px rgba(0,0,0,0.5);
display: flex; flex-direction: column; gap: 15px;
}
.dialog h3 { margin: 0 0 5px 0; border-bottom: 1px solid #eee; padding-bottom: 5px;}
.dialog-buttons { text-align: right; margin-top: 10px; }
.dialog-buttons button { padding: 6px 12px; cursor: pointer; }
.resize-grid {
display: grid; grid-template-columns: 1fr 1fr; gap: 10px; align-items: center;
}
.resize-inputs label { display: block; margin-top: 5px; }
.resize-inputs input { width: 50px; padding: 4px; }
@media (max-width: 600px) {
#left-panel { width: 80px; }
#palette-grid { grid-template-columns: 1fr; }
.emoji-cell { width: 100%; height: 50px; font-size: 30px; }
.tool-btn { width: 44px; height: 44px; }
#menubar { font-size: 11px; }
.menu-title { padding: 0 8px; }
#current-icon-wrapper { display: none; }
}
</style>
</head>
<body>
<input type="file" id="file-input" accept=".txt,.json" style="display:none;">
<div id="menubar">
<div class="menu-group">
<div class="menu-title">ファイル</div>
<div class="dropdown">
<div class="menu-item" onclick="app.newFile()">新規作成</div>
<div class="menu-item" onclick="app.openFileAction()">開く... <span class="shortcut">Ctrl+O</span></div>
<div class="menu-item" onclick="app.saveFileAction()">上書き保存 <span class="shortcut">Ctrl+S</span></div>
<div class="menu-item" onclick="app.saveAsFileAction()">名前を付けて保存... <span class="shortcut">Ctrl+Shift+S</span></div>
<div class="menu-sep"></div>
<div class="menu-item" onclick="app.showResizeDialog()">サイズ変更...</div>
</div>
</div>
<div class="menu-group">
<div class="menu-title">編集</div>
<div class="dropdown">
<div class="menu-item" onclick="app.undo()">元に戻す <span class="shortcut">Ctrl+Z</span></div>
<div class="menu-item" onclick="app.redo()">やり直す <span class="shortcut">Ctrl+Y</span></div>
<div class="menu-sep"></div>
<div class="menu-item" onclick="app.editAction('cut')">カット</div>
<div class="menu-item" onclick="app.editAction('copy')">コピー</div>
<div class="menu-item" onclick="app.editAction('paste')">ペースト</div>
<div class="menu-sep"></div>
<div class="menu-item" onclick="app.editAction('selectAll')">すべて選択</div>
<div class="menu-item" onclick="app.editAction('deselect')">選択解除</div>
</div>
</div>
<div class="menu-group">
<div class="menu-title">表示</div>
<div class="dropdown">
<div class="menu-item" onclick="app.toggleGrid()">グリッド表示切替</div>
</div>
</div>
</div>
<div id="toolbar">
<div class="tool-group">
<button class="tool-btn active" id="btn-pen" onclick="app.setTool('pen')">✏️<span>ペン</span></button>
<button class="tool-btn" id="btn-eraser" onclick="app.setTool('eraser')">🧽<span>消しゴム</span></button>
<button class="tool-btn" id="btn-fill" onclick="app.setTool('fill')">🎨<span>塗り</span></button>
<button class="tool-btn" id="btn-dropper" onclick="app.setTool('dropper')">🧪<span>スポイト</span></button>
</div>
<div class="tool-group">
<button class="tool-btn" id="btn-select" onclick="app.setTool('select')">⛶<span>選択</span></button>
</div>
<div class="tool-group">
<button class="tool-btn" onclick="app.zoomIn()">➕<span>拡大</span></button>
<button class="tool-btn" onclick="app.zoomOut()">➖<span>縮小</span></button>
</div>
<div id="current-icon-wrapper">
<div id="current-emoji-display">⬛</div>
<div style="font-size: 10px; color: #666; display:flex; flex-direction:column; line-height:1.2;">
<span>Current</span>
<span>Color</span>
</div>
</div>
</div>
<div id="main-area">
<div id="left-panel">
<div id="palette-controls">
<select id="category-select" onchange="app.changeCategory(this.value)"></select>
<div style="display:flex; justify-content:space-between;">
<button onclick="app.addEmojiToPalette()" style="font-size:11px;">+追加</button>
<button onclick="app.resetPalette()" style="font-size:11px;">初期化</button>
</div>
</div>
<div id="palette-grid"></div>
</div>
<div id="canvas-wrapper">
<div id="scroll-container">
<canvas id="editor-canvas"></canvas>
</div>
</div>
</div>
<div id="overlay">
<div id="resize-dialog" class="dialog" style="display:none;">
<h3>サイズ変更</h3>
<div class="resize-grid">
<div class="resize-inputs">
<label>上に追加: <input type="number" id="rs-top" value="0"></label>
<label>下に追加: <input type="number" id="rs-bottom" value="0"></label>
</div>
<div class="resize-inputs">
<label>左に追加: <input type="number" id="rs-left" value="0"></label>
<label>右に追加: <input type="number" id="rs-right" value="0"></label>
</div>
</div>
<p style="font-size:12px; color:#666;">※マイナス値でトリミング</p>
<div class="dialog-buttons">
<button onclick="app.closeDialog()">キャンセル</button>
<button onclick="app.applyResize()">適用</button>
</div>
</div>
</div>
<script>
/**
* Emoji Pixel Studio Pro v3
*/
const SPACE = ' ';
const PALETTE_KEY = 'palette.json';
class EmojiApp {
constructor() {
this.grid = [];
this.width = 16;
this.height = 16;
this.zoom = 1.0;
this.tool = 'pen';
this.currentEmoji = '⬛';
this.showGrid = true;
this.isDrawing = false;
this.history = [];
this.historyIndex = -1;
this.selection = null;
this.fileHandle = null; // File System Access API用
// パレット初期データ
this.categories = {
"基本": ["⬛","⬜","🟥","🟦","🟧","🟨","🟩","🟪","🟫"],
"顔": ["😀","😂","😊","🥰","😎","🤔","😭","😡"],
"自然": ["🌲","🌷","🌻","🔥","💧","⭐","🌙"],
"記号": ["❤","💔","💯","💢","💤","💨","💥"],
"RPG": ["⚔️","🛡️","🏰","👾","🐉","🧙♂️","🧚"]
};
this.currentCat = "基本";
this.canvas = document.getElementById('editor-canvas');
this.ctx = this.canvas.getContext('2d');
this.paletteGrid = document.getElementById('palette-grid');
this.overlay = document.getElementById('overlay');
this.cellSize = 32;
this.init();
}
init() {
this.loadPalette();
this.initGrid(this.width, this.height);
this.renderCategorySelect();
this.renderPalette();
this.updateCanvasSize();
this.saveHistory();
this.setupEvents();
this.setupLegacyFileIO();
window.addEventListener('beforeunload', () => {
this.savePalette();
});
// キーボードショートカット
window.addEventListener('keydown', (e) => {
if ((e.ctrlKey || e.metaKey)) {
if(e.key === 'z') { e.preventDefault(); this.undo(); }
if(e.key === 'y') { e.preventDefault(); this.redo(); }
if(e.key === 's') {
e.preventDefault();
if(e.shiftKey) this.saveAsFileAction();
else this.saveFileAction();
}
if(e.key === 'o') { e.preventDefault(); this.openFileAction(); }
}
});
}
setupEvents() {
const startHandler = (e) => this.handlePointerDown(e);
const moveHandler = (e) => this.handlePointerMove(e);
const endHandler = (e) => this.handlePointerUp(e);
this.canvas.addEventListener('mousedown', startHandler);
window.addEventListener('mousemove', moveHandler);
window.addEventListener('mouseup', endHandler);
this.canvas.addEventListener('touchstart', startHandler, {passive: false});
window.addEventListener('touchmove', moveHandler, {passive: false});
window.addEventListener('touchend', endHandler);
this.canvas.addEventListener('contextmenu', (e) => e.preventDefault());
}
// --- Grid & Data ---
initGrid(w, h, fill = SPACE) {
this.width = w;
this.height = h;
this.grid = [];
for(let y=0; y<h; y++) {
this.grid.push(new Array(w).fill(fill));
}
this.fileHandle = null; // リセット
}
// --- Palette System ---
loadPalette() {
const saved = localStorage.getItem(PALETTE_KEY);
if (saved) {
try { this.categories = JSON.parse(saved); } catch(e){}
}
}
savePalette() {
localStorage.setItem(PALETTE_KEY, JSON.stringify(this.categories));
}
resetPalette() {
if(confirm("パレットを初期設定に戻しますか?")) {
localStorage.removeItem(PALETTE_KEY);
location.reload();
}
}
renderCategorySelect() {
const sel = document.getElementById('category-select');
sel.innerHTML = '';
for(let cat in this.categories) {
let op = document.createElement('option');
op.value = cat;
op.innerText = cat;
if(cat === this.currentCat) op.selected = true;
sel.appendChild(op);
}
let addOp = document.createElement('option');
addOp.value = "__ADD__";
addOp.innerText = "➕ 新規カテゴリ...";
sel.appendChild(addOp);
}
changeCategory(val) {
if(val === "__ADD__") {
const name = prompt("新規カテゴリ名:");
if(name) {
if(!this.categories[name]) this.categories[name] = [];
this.currentCat = name;
this.savePalette();
this.renderCategorySelect();
} else {
document.getElementById('category-select').value = this.currentCat;
}
} else {
this.currentCat = val;
}
this.renderPalette();
}
renderPalette() {
this.paletteGrid.innerHTML = '';
const emojis = this.categories[this.currentCat];
emojis.forEach((emoji, index) => {
const el = document.createElement('div');
el.className = 'emoji-cell';
if(emoji === this.currentEmoji) el.classList.add('selected');
el.innerText = emoji;
// ダブルクリックで編集
el.ondblclick = () => {
let newE = prompt("絵文字を入力 (1文字のみ):", emoji);
if(newE && newE.length > 0) {
// サロゲートペア対応
newE = Array.from(newE)[0];
this.categories[this.currentCat][index] = newE;
this.savePalette();
// ★修正: 編集した文字を即座にカレントに設定する
this.setCurrentEmoji(newE);
this.renderPalette();
}
};
this.setupPaletteInteraction(el, index);
this.paletteGrid.appendChild(el);
});
}
// カレント絵文字セット用ヘルパー
setCurrentEmoji(emoji) {
this.currentEmoji = emoji;
document.getElementById('current-emoji-display').innerText = emoji;
this.setTool('pen');
}
setupPaletteInteraction(el, index) {
let startX, startY;
let isDragStarted = false;
const DRAG_THRESHOLD = 5;
const down = (e) => {
if(e.button !== 0 && e.type === 'mousedown') return;
startX = e.touches ? e.touches[0].clientX : e.clientX;
startY = e.touches ? e.touches[0].clientY : e.clientY;
isDragStarted = false;
};
const move = (e) => {
if(startX === undefined) return;
const cx = e.touches ? e.touches[0].clientX : e.clientX;
const cy = e.touches ? e.touches[0].clientY : e.clientY;
if (!isDragStarted && (Math.abs(cx - startX) > DRAG_THRESHOLD || Math.abs(cy - startY) > DRAG_THRESHOLD)) {
isDragStarted = true;
el.classList.add('dragging');
}
};
const up = (e) => {
if(startX === undefined) return;
el.classList.remove('dragging');
if (!isDragStarted) {
// --- クリック処理 (選択) ---
const selectedEmoji = this.categories[this.currentCat][index];
this.setCurrentEmoji(selectedEmoji);
document.querySelectorAll('.emoji-cell').forEach(c => c.classList.remove('selected'));
el.classList.add('selected');
} else {
// --- ドラッグ終了処理 (並べ替え) ---
const touch = e.changedTouches ? e.changedTouches[0] : e;
const target = document.elementFromPoint(touch.clientX, touch.clientY);
const targetCell = target ? target.closest('.emoji-cell') : null;
if (targetCell) {
const children = Array.from(this.paletteGrid.children);
const targetIndex = children.indexOf(targetCell);
if (targetIndex !== -1 && targetIndex !== index) {
const list = this.categories[this.currentCat];
const item = list.splice(index, 1)[0];
list.splice(targetIndex, 0, item);
this.savePalette();
this.renderPalette();
}
}
}
startX = undefined;
};
el.addEventListener('mousedown', down);
window.addEventListener('mousemove', move);
window.addEventListener('mouseup', up);
el.addEventListener('touchstart', down, {passive:false});
window.addEventListener('touchmove', move, {passive:false});
window.addEventListener('touchend', up);
}
addEmojiToPalette() {
let val = prompt("追加する絵文字:");
if(val && val.length > 0) {
val = Array.from(val)[0];
this.categories[this.currentCat].push(val);
this.savePalette();
this.renderPalette();
}
}
// --- Drawing Logic ---
setTool(name) {
if(this.tool === 'select' && name !== 'select') this.commitSelection();
this.tool = name;
document.querySelectorAll('.tool-btn').forEach(b => b.classList.remove('active'));
document.getElementById('btn-'+name).classList.add('active');
}
handlePointerDown(e) {
if(e.cancelable) e.preventDefault();
const pos = this.getGridPos(e);
if(!pos) return;
this.isDrawing = true;
this.drawStartPos = pos;
if (this.tool === 'select') {
if (this.selection && this.isInSelection(pos.x, pos.y)) {
this.isDraggingSelection = true;
this.selectionStart = { x: pos.x, y: pos.y, selX: this.selection.x, selY: this.selection.y };
if(!this.selection.buffer) this.floatSelection();
} else {
this.commitSelection();
this.selection = {x: pos.x, y: pos.y, w:1, h:1, buffer: null};
this.draw();
}
return;
}
if (this.tool === 'dropper') {
const char = this.grid[pos.y][pos.x];
if (char !== SPACE) this.setCurrentEmoji(char);
return;
}
this.useTool(pos.x, pos.y);
this.draw();
}
handlePointerMove(e) {
if(!this.isDrawing) return;
if(e.cancelable) e.preventDefault();
if (this.tool === 'dropper') return;
const pos = this.getGridPos(e);
if (this.tool === 'select') {
if (this.isDraggingSelection && pos) {
const dx = pos.x - this.selectionStart.x;
const dy = pos.y - this.selectionStart.y;
this.selection.x = this.selectionStart.selX + dx;
this.selection.y = this.selectionStart.selY + dy;
this.draw();
} else if (pos) {
const sx = this.drawStartPos.x;
const sy = this.drawStartPos.y;
this.selection.x = Math.min(sx, pos.x);
this.selection.y = Math.min(sy, pos.y);
this.selection.w = Math.abs(pos.x - sx) + 1;
this.selection.h = Math.abs(pos.y - sy) + 1;
this.draw();
}
return;
}
if(pos) {
this.useTool(pos.x, pos.y);
this.draw();
}
}
handlePointerUp(e) {
if(!this.isDrawing) return;
this.isDrawing = false;
this.isDraggingSelection = false;
if (this.tool === 'dropper') {
const touch = e.changedTouches ? e.changedTouches[0] : e;
const target = document.elementFromPoint(touch.clientX, touch.clientY);
const cell = target ? target.closest('.emoji-cell') : null;
if (cell) {
const children = Array.from(this.paletteGrid.children);
const index = children.indexOf(cell);
if(index !== -1) {
this.categories[this.currentCat][index] = this.currentEmoji;
this.savePalette();
this.renderPalette();
}
}
return;
}
if(this.tool !== 'select') {
this.saveHistory();
}
}
getGridPos(e) {
const rect = this.canvas.getBoundingClientRect();
const clientX = e.touches ? e.touches[0].clientX : e.clientX;
const clientY = e.touches ? e.touches[0].clientY : e.clientY;
const x = Math.floor((clientX - rect.left) / (this.cellSize * this.zoom));
const y = Math.floor((clientY - rect.top) / (this.cellSize * this.zoom));
if (this.isDraggingSelection) return {x, y};
if(x < 0 || x >= this.width || y < 0 || y >= this.height) return null;
return {x, y};
}
useTool(x, y) {
if(x < 0 || x >= this.width || y < 0 || y >= this.height) return;
if (this.tool === 'pen') {
this.grid[y][x] = this.currentEmoji;
} else if (this.tool === 'eraser') {
this.grid[y][x] = SPACE;
} else if (this.tool === 'fill') {
this.floodFill(x, y, this.grid[y][x], this.currentEmoji);
}
}
floodFill(x, y, target, replace) {
if(target === replace) return;
if(this.grid[y][x] !== target) return;
const stack = [[x,y]];
while(stack.length) {
const [cx, cy] = stack.pop();
if(cx < 0 || cx >= this.width || cy < 0 || cy >= this.height) continue;
if(this.grid[cy][cx] === target) {
this.grid[cy][cx] = replace;
stack.push([cx+1, cy]); stack.push([cx-1, cy]);
stack.push([cx, cy+1]); stack.push([cx, cy-1]);
}
}
}
// --- History ---
saveHistory() {
if (this.historyIndex < this.history.length - 1) {
this.history = this.history.slice(0, this.historyIndex + 1);
}
const snapshot = {
grid: JSON.parse(JSON.stringify(this.grid)),
w: this.width,
h: this.height
};
this.history.push(snapshot);
if(this.history.length > 30) this.history.shift();
else this.historyIndex++;
}
undo() {
if (this.historyIndex > 0) {
this.historyIndex--;
this.restoreHistory();
}
}
redo() {
if (this.historyIndex < this.history.length - 1) {
this.historyIndex++;
this.restoreHistory();
}
}
restoreHistory() {
const state = this.history[this.historyIndex];
this.grid = JSON.parse(JSON.stringify(state.grid));
this.width = state.w;
this.height = state.h;
this.selection = null;
this.updateCanvasSize();
this.draw();
}
// --- Selection ---
isInSelection(x, y) {
if(!this.selection) return false;
return x >= this.selection.x && x < this.selection.x + this.selection.w &&
y >= this.selection.y && y < this.selection.y + this.selection.h;
}
floatSelection() {
if(!this.selection) return;
const buf = [];
for(let iy=0; iy<this.selection.h; iy++) {
let row = [];
for(let ix=0; ix<this.selection.w; ix++) {
const gx = this.selection.x + ix;
const gy = this.selection.y + iy;
if(gx>=0 && gx<this.width && gy>=0 && gy<this.height) {
row.push(this.grid[gy][gx]);
this.grid[gy][gx] = SPACE;
} else {
row.push(SPACE);
}
}
buf.push(row);
}
this.selection.buffer = buf;
this.draw();
}
commitSelection() {
if(!this.selection || !this.selection.buffer) {
this.selection = null; return;
}
const buf = this.selection.buffer;
for(let iy=0; iy<this.selection.h; iy++) {
for(let ix=0; ix<this.selection.w; ix++) {
const gx = this.selection.x + ix;
const gy = this.selection.y + iy;
if(gx>=0 && gx<this.width && gy>=0 && gy<this.height) {
if(buf[iy][ix] !== SPACE) {
this.grid[gy][gx] = buf[iy][ix];
}
}
}
}
this.selection = null;
this.saveHistory();
this.draw();
}
// --- Editing ---
editAction(type) {
if(type === 'selectAll') {
this.commitSelection();
this.selection = {x:0, y:0, w:this.width, h:this.height, buffer: null};
this.setTool('select');
this.floatSelection();
} else if (type === 'deselect') {
this.commitSelection();
} else if (type === 'cut') {
if(this.selection) {
if(!this.selection.buffer) this.floatSelection();
this.clipboard = JSON.parse(JSON.stringify(this.selection.buffer));
this.selection = null;
this.saveHistory();
this.draw();
}
} else if (type === 'copy') {
if(this.selection) {
const buf = this.selection.buffer || this.extractBufferFromGrid(this.selection);
this.clipboard = JSON.parse(JSON.stringify(buf));
alert("コピーしました");
}
} else if (type === 'paste') {
if(this.clipboard) {
this.commitSelection();
const h = this.clipboard.length;
const w = this.clipboard[0].length;
this.selection = {x:0, y:0, w:w, h:h, buffer: JSON.parse(JSON.stringify(this.clipboard))};
this.setTool('select');
this.draw();
}
}
}
extractBufferFromGrid(sel) {
const buf = [];
for(let y=0; y<sel.h; y++) {
let row = [];
for(let x=0; x<sel.w; x++) {
row.push(this.grid[sel.y+y][sel.x+x]);
}
buf.push(row);
}
return buf;
}
// --- File I/O (File System Access API & Legacy) ---
setupLegacyFileIO() {
const fileInput = document.getElementById('file-input');
fileInput.addEventListener('change', (e) => {
const file = e.target.files[0];
if (!file) return;
this.readFileContent(file);
e.target.value = '';
this.fileHandle = null; // レガシー読み込み時はハンドルなし
});
}
async readFileContent(file) {
const reader = new FileReader();
reader.onload = (ev) => {
try {
const text = ev.target.result;
const lines = text.replace(/\r\n/g, '\n').split('\n').filter(line => line.length > 0);
const newGrid = lines.map(line => Array.from(line));
const h = newGrid.length;
const w = newGrid[0] ? newGrid[0].length : 0;
if (h === 0 || w === 0) throw new Error("無効なデータです");
const maxW = Math.max(...newGrid.map(row => row.length));
for(let y=0; y<h; y++) {
while(newGrid[y].length < maxW) newGrid[y].push(SPACE);
}
this.grid = newGrid;
this.width = maxW;
this.height = h;
this.updateCanvasSize();
this.saveHistory();
} catch(err) {
alert("読み込みエラー: " + err.message);
}
};
reader.readAsText(file);
}
newFile() {
if(confirm("新規作成しますか?")) {
this.initGrid(16, 16);
this.saveHistory();
this.updateCanvasSize();
}
}
// 開く (API対応ならハンドル取得)
async openFileAction() {
if (window.showOpenFilePicker) {
try {
const [handle] = await window.showOpenFilePicker({
types: [{ description: 'Text Files', accept: {'text/plain': ['.txt']} }]
});
const file = await handle.getFile();
await this.readFileContent(file);
this.fileHandle = handle; // 保存用にハンドル保持
} catch (err) {
// キャンセル時は何もしない
}
} else {
document.getElementById('file-input').click();
}
}
// 上書き保存 (ハンドルがあれば上書き、なければ別名保存へ)
async saveFileAction() {
if (this.fileHandle) {
try {
const writable = await this.fileHandle.createWritable();
const text = this.grid.map(row => row.join("")).join("\n");
await writable.write(text);
await writable.close();
alert("保存しました");
} catch (err) {
alert("保存失敗: " + err.message);
}
} else {
await this.saveAsFileAction();
}
}
// 別名で保存 (ダイアログ表示)
async saveAsFileAction() {
const text = this.grid.map(row => row.join("")).join("\n");
if (window.showSaveFilePicker) {
try {
const handle = await window.showSaveFilePicker({
suggestedName: `pixel_art.txt`,
types: [{ description: 'Text Files', accept: {'text/plain': ['.txt']} }]
});
const writable = await handle.createWritable();
await writable.write(text);
await writable.close();
this.fileHandle = handle; // 新しいハンドルを保持
} catch (err) {
// キャンセル
}
} else {
// レガシーダウンロード
const blob = new Blob([text], {type: "text/plain"});
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `pixel_art_${Date.now()}.txt`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
}
// --- Resize ---
showResizeDialog() {
this.overlay.style.display = 'flex';
document.getElementById('resize-dialog').style.display = 'block';
}
closeDialog() {
this.overlay.style.display = 'none';
}
applyResize() {
const t = parseInt(document.getElementById('rs-top').value) || 0;
const b = parseInt(document.getElementById('rs-bottom').value) || 0;
const l = parseInt(document.getElementById('rs-left').value) || 0;
const r = parseInt(document.getElementById('rs-right').value) || 0;
const newW = this.width + l + r;
const newH = this.height + t + b;
if(newW < 1 || newH < 1) return alert("サイズが小さすぎます");
const newGrid = [];
for(let y=0; y<newH; y++) newGrid.push(new Array(newW).fill(SPACE));
for(let y=0; y<this.height; y++) {
for(let x=0; x<this.width; x++) {
const ny = y + t;
const nx = x + l;
if(ny >= 0 && ny < newH && nx >= 0 && nx < newW) {
newGrid[ny][nx] = this.grid[y][x];
}
}
}
this.grid = newGrid;
this.width = newW;
this.height = newH;
this.updateCanvasSize();
this.saveHistory();
this.closeDialog();
}
// --- Rendering ---
updateCanvasSize() {
this.canvas.width = this.width * this.cellSize * this.zoom;
this.canvas.height = this.height * this.cellSize * this.zoom;
this.canvas.style.width = `${this.width * this.cellSize * this.zoom}px`;
this.canvas.style.height = `${this.height * this.cellSize * this.zoom}px`;
this.draw();
}
zoomIn() { this.zoom = Math.min(3, this.zoom + 0.2); this.updateCanvasSize(); }
zoomOut() { this.zoom = Math.max(0.5, this.zoom - 0.2); this.updateCanvasSize(); }
toggleGrid() { this.showGrid = !this.showGrid; this.draw(); }
draw() {
this.ctx.fillStyle = '#fff';
this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height);
this.ctx.save();
this.ctx.scale(this.zoom, this.zoom);
this.ctx.font = `${Math.floor(this.cellSize * 0.8)}px "Segoe UI Emoji", "Apple Color Emoji", sans-serif`;
this.ctx.textAlign = 'center';
this.ctx.textBaseline = 'middle';
for(let y=0; y<this.height; y++) {
for(let x=0; x<this.width; x++) {
if(this.selection && this.selection.buffer && this.isInSelection(x, y)) {
// buffer move (skip)
} else {
const char = this.grid[y][x];
if(char !== SPACE) {
this.ctx.fillText(char, x*this.cellSize + this.cellSize/2, y*this.cellSize + this.cellSize/2 + 2);
}
}
}
}
if(this.selection && this.selection.buffer) {
const buf = this.selection.buffer;
this.ctx.strokeStyle = '#007bff';
this.ctx.lineWidth = 2;
this.ctx.setLineDash([4, 2]);
this.ctx.strokeRect(this.selection.x * this.cellSize, this.selection.y * this.cellSize, this.selection.w * this.cellSize, this.selection.h * this.cellSize);
this.ctx.setLineDash([]);
for(let iy=0; iy<this.selection.h; iy++) {
for(let ix=0; ix<this.selection.w; ix++) {
const char = buf[iy][ix];
if(char !== SPACE) {
const dx = (this.selection.x + ix) * this.cellSize;
const dy = (this.selection.y + iy) * this.cellSize;
this.ctx.fillText(char, dx + this.cellSize/2, dy + this.cellSize/2 + 2);
}
}
}
}
if(this.showGrid) {
this.ctx.strokeStyle = '#e0e0e0';
this.ctx.lineWidth = 1;
this.ctx.beginPath();
for(let x=0; x<=this.width; x++) this.ctx.moveTo(x*this.cellSize, 0), this.ctx.lineTo(x*this.cellSize, this.height*this.cellSize);
for(let y=0; y<=this.height; y++) this.ctx.moveTo(0, y*this.cellSize), this.ctx.lineTo(this.width*this.cellSize, y*this.cellSize);
this.ctx.stroke();
}
this.ctx.restore();
}
}
const app = new EmojiApp();
</script>
</body>
</html>

コメント