絵文字で絵を描く「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>

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

コメント

コメントするには、 ログイン または 会員登録 をお願いします。
絵文字で絵を描く「Emoji Map」|古井和雄
word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word

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