TinyTyper+ — Auto Typing Snippets

Human-like typing snippets with GM storage, volatile "Hide" items, single-select radio, EN/JA UI, auto-typing toggle, import/export, and Tampermonkey menu. UI styles are fixed so they are not broken by site CSS or dark themes.

目前為 2025-11-15 提交的版本,檢視 最新版本

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==UserScript==
// @name         TinyTyper+ — Auto Typing Snippets
// @namespace    http://tampermonkey.net/
// @version      1.6.8
// @description  Human-like typing snippets with GM storage, volatile "Hide" items, single-select radio, EN/JA UI, auto-typing toggle, import/export, and Tampermonkey menu. UI styles are fixed so they are not broken by site CSS or dark themes.
// @license      MIT
// @match        *://*/*
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_deleteValue
// @grant        GM_registerMenuCommand
// @run-at       document-idle
// ==/UserScript==

(async function () {
  'use strict';

  /* ---------- Keys & defaults ---------- */
  const STORAGE_KEY = 'tinystyper_plus.snippets.v1';
  const LS_LEGACY_KEY = 'tinystyper_plus.snippets.v1.ls';
  const LANG_KEY = 'tinystyper_plus.lang';
  const PANEL_VISIBLE_KEY = 'tinystyper_plus.panelVisible';
  const AUTO_KEY = 'tinystyper_plus.autotype';
  const DEFAULT_DELAY_MS = 100;
  const RANDOMIZE_DEFAULT = true;
  const AUTO_DEFAULT = true;
  const AUTO_MIGRATE_FROM_LS = true;

  /* ---------- i18n ---------- */
  const LANGS = {
    ja: {
      title: 'TinyTyper+',
      clear: '解除',
      manage: '管理',
      close: '×',
      addPlaceholder: '新しいスニペットを追加...',
      hide: '隠す',
      add: '追加',
      delay: '遅延(ms)',
      random: 'ランダム',
      simkeys: 'キーイベントをシミュレート',
      simkeysNote: '(オンにすると keydown/keypress/input/keyup を発火)',
      howto: '使い方: 「隠す」にチェックしたものは保存されません(セッション限定)。管理ボタンで全データの一括削除ができます。',
      edit: '編集',
      del: '削除',
      pin: 'ピン/解除',
      maskTitle: '隠す(volatile): リロードで消えます',
      btnLang: 'EN/JA',
      menuToggle: 'TinyTyper+: 表示/非表示',
      menuErr: '表示切替でエラーが発生しました。コンソールを確認してください。',
      needText: 'テキストを入力してください',
      gmList: '全スニペット一覧(先頭60文字):',
      gmOps: '操作:\n1 = 全削除\n2 = エクスポート(JSONをコピー)\n3 = インポート(JSONを貼り付け)\n何もしないなら空でキャンセル',
      confirmDelAll: '本当に全スニペットを削除しますか?(復元不可)',
      delAllDone: '全削除しました',
      copied: 'JSON をクリップボードにコピーしました。',
      importPrompt: 'インポートする JSON を貼り付けてください:',
      importFail: 'インポート失敗: ',
      invalidOp: '無効な操作です',
      editPrompt: '編集:',
      autoOn: '自動入力 ON',
      autoOff: '自動入力 OFF',
      autoTitle: 'クリックすると自動入力のオン/オフを切り替えます'
    },
    en: {
      title: 'TinyTyper+',
      clear: 'Clear',
      manage: 'Manage',
      close: '×',
      addPlaceholder: 'Add a new snippet...',
      hide: 'Hide',
      add: 'Add',
      delay: 'Delay(ms)',
      random: 'Random',
      simkeys: 'Simulate key events',
      simkeysNote: '(fires keydown/keypress/input/keyup when on)',
      howto: "How to: Checked 'Hide' items are session-only. Use Manage to bulk delete/export/import.",
      edit: 'Edit',
      del: 'Delete',
      pin: 'Pin/Unpin',
      maskTitle: 'Hidden (volatile): will be cleared on reload',
      btnLang: 'EN/JA',
      menuToggle: 'TinyTyper+: Show/Hide',
      menuErr: 'Error while toggling. See console.',
      needText: 'Please enter text.',
      gmList: 'All snippets (first 60 chars):',
      gmOps: 'Actions:\n1 = Delete all\n2 = Export (copy JSON)\n3 = Import (paste JSON)\nLeave empty to cancel',
      confirmDelAll: 'Really delete ALL snippets? (irreversible)',
      delAllDone: 'Deleted all.',
      copied: 'Copied JSON to clipboard.',
      importPrompt: 'Paste JSON to import:',
      importFail: 'Import failed: ',
      invalidOp: 'Invalid action',
      editPrompt: 'Edit:',
      autoOn: 'Auto type ON',
      autoOff: 'Auto type OFF',
      autoTitle: 'Toggle auto typing on/off'
    }
  };

  let lang = (await GM_getValue(LANG_KEY, 'ja')) || 'ja';
  const t = (k) => (LANGS[lang] && LANGS[lang][k]) || LANGS.ja[k] || k;
  const uid = () => Date.now().toString(36) + Math.random().toString(36).slice(2, 8);

  let autoTypeEnabled = await GM_getValue(AUTO_KEY, AUTO_DEFAULT);

  /* ---------- storage helpers ---------- */
  async function loadSavedSnippets() {
    try {
      const raw = await GM_getValue(STORAGE_KEY, null);
      if (!raw) return [];
      return JSON.parse(raw);
    } catch (e) {
      console.error(e);
      return [];
    }
  }

  async function saveSavedSnippets(arr) {
    try {
      await GM_setValue(STORAGE_KEY, JSON.stringify(arr));
    } catch (e) {
      console.error(e);
    }
  }

  async function deleteAllSavedSnippets() {
    try {
      await GM_deleteValue(STORAGE_KEY);
    } catch (e) {
      console.error(e);
    }
  }

  async function migrateFromLocalStorageIfNeeded() {
    try {
      if (!AUTO_MIGRATE_FROM_LS) return;
      const current = await loadSavedSnippets();
      if (current.length > 0) return;
      const raw = localStorage.getItem(LS_LEGACY_KEY);
      if (!raw) return;
      const parsed = JSON.parse(raw);
      if (Array.isArray(parsed) && parsed.length > 0) {
        await saveSavedSnippets(parsed);
        localStorage.removeItem(LS_LEGACY_KEY);
      }
    } catch (e) {
      console.error('Migration error', e);
    }
  }

  function isTypeable(el) {
    if (!el) return false;
    if (el.isContentEditable) return true;
    if (el.tagName === 'TEXTAREA') return true;
    if (el.tagName === 'INPUT') {
      const tp = (el.type || '').toLowerCase();
      return ['text', 'search', 'email', 'url', 'tel', 'password', 'number'].includes(tp);
    }
    return false;
  }

  /* ---------- panel creation ---------- */
  const panel = document.createElement('div');
  panel.id = 'tinystyper-plus-panel';
  panel.style.cssText = `
    position: fixed; right: 18px; bottom: 18px; width: 420px; max-height: 72vh;
    background: rgba(255,255,255,0.97); border: 1px solid rgba(0,0,0,0.2);
    box-shadow: 0 10px 32px rgba(0,0,0,0.25);
    z-index: 2147483647; padding: 10px; font-family: system-ui,Segoe UI,Roboto,Helvetica,Arial;
    overflow:auto; border-radius:10px; font-size:13px; backdrop-filter: blur(2px);
  `;
  panel.innerHTML = `
    <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px;">
      <strong id="tsp-title" style="font-size:14px">${t('title')}</strong>
      <div style="display:flex;gap:6px;align-items:center">
        <button id="tsp-lang" style="font-size:12px;padding:4px 6px">${t('btnLang')}</button>
        <button id="tsp-auto" title="${t('autoTitle')}" style="font-size:12px;padding:4px 6px"></button>
        <button id="tsp-clearchecks" style="font-size:12px;padding:4px 6px">${t('clear')}</button>
        <button id="tsp-global-manage" style="font-size:12px;padding:4px 6px">${t('manage')}</button>
        <button id="tsp-close" style="font-size:12px;padding:4px 6px">${t('close')}</button>
      </div>
    </div>
    <div style="display:flex;gap:6px;align-items:center;margin-bottom:6px;">
      <input id="tsp-newtext" placeholder="${t('addPlaceholder')}" style="flex:1;padding:6px;font-size:13px" />
      <label style="display:flex;align-items:center;gap:6px;font-size:12px;">
        <input id="tsp-hide-new" type="checkbox"/> ${t('hide')}
      </label>
      <button id="tsp-add" style="padding:6px;font-size:13px">${t('add')}</button>
    </div>
    <div style="margin-bottom:6px;display:flex;gap:6px;align-items:center;flex-wrap:wrap;">
      <label style="font-size:12px">${t('delay')}</label>
      <input id="tsp-delay" type="number" min="0" value="${DEFAULT_DELAY_MS}" style="width:80px;padding:4px" />
      <label style="font-size:12px;margin-left:6px;"><input id="tsp-random" type="checkbox" ${RANDOMIZE_DEFAULT ? 'checked' : ''}/> ${t('random')}</label>
      <label style="font-size:12px;margin-left:10px;"><input id="tsp-simkeys" type="checkbox"/> ${t('simkeys')}</label>
      <span style="font-size:12px;color:#666;margin-left:6px;">${t('simkeysNote')}</span>
    </div>
    <div id="tsp-list" style="font-size:13px"></div>
    <div style="margin-top:8px;font-size:11px;color:#777">${t('howto')}</div>
  `;

  (document.body || document.documentElement).appendChild(panel);
  try { window.tinystyperPanel = panel; } catch (e) {}

  // ★ 固定スタイル(サイトのCSSやダークテーマに負けない)
  const tspStyle = document.createElement('style');
  tspStyle.textContent = `
    #tinystyper-plus-panel,
    #tinystyper-plus-panel * {
      box-sizing: border-box !important;
      font-family: system-ui,Segoe UI,Roboto,Helvetica,Arial !important;
      color: #000000 !important;
    }
    #tinystyper-plus-panel input,
    #tinystyper-plus-panel button,
    #tinystyper-plus-panel textarea,
    #tinystyper-plus-panel select {
      background: #ffffff !important;
      color: #000000 !important;
      border: 1px solid #cccccc !important;
    }
    /* カスタムチェックボックス/ラジオ(常に同じ見た目) */
    #tinystyper-plus-panel input[type="checkbox"],
    #tinystyper-plus-panel input[type="radio"] {
      appearance: none !important;
      -webkit-appearance: none !important;
      -moz-appearance: none !important;
      background: #ffffff !important;
      border: 1px solid #666666 !important;
      width: 14px !important;
      height: 14px !important;
      padding: 0 !important;
      margin: 0 3px 0 0 !important;
      border-radius: 3px !important;
      position: relative !important;
      box-shadow: none !important;
    }
    #tinystyper-plus-panel input[type="radio"] {
      border-radius: 50% !important;
    }
    #tinystyper-plus-panel input[type="checkbox"]:checked::after {
      content: "✔";
      position: absolute;
      top: -3px;
      left: 1px;
      font-size: 14px;
      line-height: 1;
      color: #000000;
    }
    #tinystyper-plus-panel input[type="radio"]:checked::after {
      content: "";
      position: absolute;
      top: 3px;
      left: 3px;
      right: 3px;
      bottom: 3px;
      border-radius: inherit;
      background: #333333;
    }
  `;
  document.head.appendChild(tspStyle);

  async function setPanelVisible(flag) {
    panel.style.display = flag ? 'block' : 'none';
    await GM_setValue(PANEL_VISIBLE_KEY, flag);
  }
  const initialVisible = await GM_getValue(PANEL_VISIBLE_KEY, true);
  panel.style.display = initialVisible ? 'block' : 'none';

  async function togglePanelDisplay() {
    const willShow = panel.style.display === 'none';
    await setPanelVisible(willShow);
  }

  if (typeof GM_registerMenuCommand === 'function') {
    GM_registerMenuCommand(t('menuToggle'), () => {
      togglePanelDisplay().catch(e => {
        console.error('TinyTyper menu toggle error', e);
        alert(t('menuErr'));
      });
    });
  }

  // ドラッグで移動
  (function makeDraggable(node) {
    let isDown = false, startX, startY, startLeft, startTop;
    node.addEventListener('mousedown', (e) => {
      if (['INPUT', 'BUTTON', 'TEXTAREA', 'LABEL', 'SELECT'].includes(e.target.tagName)) return;
      isDown = true;
      startX = e.clientX;
      startY = e.clientY;
      const rect = node.getBoundingClientRect();
      startLeft = rect.left;
      startTop = rect.top;
      e.preventDefault();
    }, true);
    window.addEventListener('mousemove', (e) => {
      if (!isDown) return;
      node.style.left = (startLeft + (e.clientX - startX)) + 'px';
      node.style.top = (startTop + (e.clientY - startY)) + 'px';
      node.style.right = 'auto';
      node.style.bottom = 'auto';
    }, true);
    window.addEventListener('mouseup', () => { isDown = false; }, true);
  })(panel);

  // UI 要素
  const listEl = document.getElementById('tsp-list');
  const inputNew = document.getElementById('tsp-newtext');
  const hideNewChk = document.getElementById('tsp-hide-new');
  const delayInput = document.getElementById('tsp-delay');
  const randomChk = document.getElementById('tsp-random');
  const simKeysChk = document.getElementById('tsp-simkeys');
  const btnAdd = document.getElementById('tsp-add');
  const btnClose = document.getElementById('tsp-close');
  const btnClear = document.getElementById('tsp-clearchecks');
  const btnManage = document.getElementById('tsp-global-manage');
  const btnLang = document.getElementById('tsp-lang');
  const btnAuto = document.getElementById('tsp-auto');

  function syncAutoButton() {
    btnAuto.textContent = autoTypeEnabled ? t('autoOn') : t('autoOff');
    btnAuto.style.background = autoTypeEnabled ? '#e0f0ff' : 'transparent';
    btnAuto.style.border = autoTypeEnabled ? '1px solid #88a' : '1px solid transparent';
  }
  syncAutoButton();

  btnAuto.addEventListener('click', async () => {
    autoTypeEnabled = !autoTypeEnabled;
    await GM_setValue(AUTO_KEY, autoTypeEnabled);
    syncAutoButton();
  });

  /* ---------- state ---------- */
  await migrateFromLocalStorageIfNeeded();
  let savedCache = await loadSavedSnippets();
  let volatileSnippets = [];   // 隠す=セッション限定

  const getAllSnippets = () => [
    ...volatileSnippets,
    ...savedCache.map(x => ({ ...x, volatile: false }))
  ];

  function escapeHtml(s) {
    return String(s).replace(/[&<>]/g, m => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;' }[m]));
  }

  function renderList() {
    listEl.innerHTML = '';
    const arr = getAllSnippets().sort((a, b) => {
      const pa = a.pinned ? 1 : 0;
      const pb = b.pinned ? 1 : 0;
      if (pa !== pb) return pb - pa;
      return (b.lastUsed || 0) - (a.lastUsed || 0);
    });

    for (const s of arr) {
      const wrap = document.createElement('div');
      wrap.style.cssText = 'display:flex;align-items:center;gap:8px;margin-bottom:6px;padding:4px;border-radius:6px;';
      if (s.volatile) {
        wrap.style.opacity = '0.95';
        wrap.title = t('maskTitle');
      } else if (s.pinned) {
        wrap.style.background = 'linear-gradient(90deg,#fffdf5,#fff)';
      }
      wrap.innerHTML = `
        <input class="tsp-chk" type="radio" name="tsp-selected" data-id="${s.id}" ${s.checked ? 'checked' : ''}/>
        <div class="tsp-text" data-id="${s.id}" title="${t('edit')}" style="flex:1;word-break:break-all;cursor:pointer;">
          ${s.volatile ? '♥♥♥♥♥' : escapeHtml(s.text)}
        </div>
        <label style="font-size:12px;display:flex;align-items:center;gap:4px;">
          <input class="tsp-hide" type="checkbox" data-id="${s.id}" ${s.volatile ? 'checked' : ''}/> ${t('hide')}
        </label>
        <button class="tsp-pin" data-id="${s.id}" style="font-size:12px">${t('pin')}</button>
        <button class="tsp-edit" data-id="${s.id}" style="font-size:12px">${t('edit')}</button>
        <button class="tsp-del" data-id="${s.id}" style="font-size:12px">${t('del')}</button>
      `;
      listEl.appendChild(wrap);
    }
  }

  // 言語切替
  btnLang.addEventListener('click', async () => {
    lang = (lang === 'ja' ? 'en' : 'ja');
    await GM_setValue(LANG_KEY, lang);
    location.reload();
  });

  // 追加
  btnAdd.addEventListener('click', async () => {
    const v = inputNew.value.trim();
    if (!v) {
      alert(t('needText'));
      return;
    }
    const now = Date.now();
    if (hideNewChk.checked) {
      volatileSnippets.unshift({
        id: uid(),
        text: v,
        checked: false,
        pinned: false,
        lastUsed: now,
        volatile: true
      });
    } else {
      savedCache.push({
        id: uid(),
        text: v,
        checked: false,
        pinned: false,
        lastUsed: now
      });
      await saveSavedSnippets(savedCache);
    }
    inputNew.value = '';
    hideNewChk.checked = false;
    renderList();
  });

  // リスト内操作
  listEl.addEventListener('click', async (e) => {
    const id = e.target.getAttribute('data-id');
    if (!id) return;
    const sIdx = savedCache.findIndex(x => x.id === id);
    const vIdx = volatileSnippets.findIndex(x => x.id === id);

    // 削除
    if (e.target.classList.contains('tsp-del')) {
      if (vIdx >= 0) volatileSnippets.splice(vIdx, 1);
      if (sIdx >= 0) savedCache.splice(sIdx, 1);
      await saveSavedSnippets(savedCache);
      renderList();
      return;
    }

    // ピン
    if (e.target.classList.contains('tsp-pin')) {
      const now = Date.now();
      if (vIdx >= 0) {
        volatileSnippets[vIdx].pinned = !volatileSnippets[vIdx].pinned;
        volatileSnippets[vIdx].lastUsed = now;
      } else if (sIdx >= 0) {
        savedCache[sIdx].pinned = !savedCache[sIdx].pinned;
        savedCache[sIdx].lastUsed = now;
        await saveSavedSnippets(savedCache);
      }
      renderList();
      return;
    }

    // 編集
    if (e.target.classList.contains('tsp-edit') || (e.target.classList.contains('tsp-text') && e.detail === 2)) {
      const target = (vIdx >= 0 ? volatileSnippets[vIdx] : savedCache[sIdx]);
      const nt = prompt(t('editPrompt'), target.text);
      if (nt == null) return;
      target.text = nt;
      target.lastUsed = Date.now();
      await saveSavedSnippets(savedCache);
      renderList();
      return;
    }

    // チェック
    if (e.target.classList.contains('tsp-chk') || e.target.classList.contains('tsp-text')) {
      savedCache.forEach(a => a.checked = false);
      volatileSnippets.forEach(a => a.checked = false);
      if (vIdx >= 0) {
        volatileSnippets[vIdx].checked = true;
        volatileSnippets[vIdx].lastUsed = Date.now();
      } else if (sIdx >= 0) {
        savedCache[sIdx].checked = true;
        savedCache[sIdx].lastUsed = Date.now();
        await saveSavedSnippets(savedCache);
      }
      renderList();
      return;
    }

    // 隠す
    if (e.target.classList.contains('tsp-hide')) {
      const hidden = e.target.checked;
      if (hidden && sIdx >= 0) {
        const item = savedCache.splice(sIdx, 1)[0];
        item.volatile = true;
        item.checked = false;
        volatileSnippets.unshift(item);
        await saveSavedSnippets(savedCache);
      } else if (!hidden && vIdx >= 0) {
        const item = volatileSnippets.splice(vIdx, 1)[0];
        delete item.volatile;
        savedCache.push(item);
        await saveSavedSnippets(savedCache);
      }
      renderList();
      return;
    }
  });

  // 選択解除
  btnClear.addEventListener('click', async () => {
    savedCache.forEach(a => a.checked = false);
    volatileSnippets.forEach(a => a.checked = false);
    await saveSavedSnippets(savedCache);
    renderList();
  });

  // パネル閉じる
  btnClose.addEventListener('click', () => {
    setPanelVisible(false).catch(e => console.error(e));
  });

  // グローバル管理
  btnManage.addEventListener('click', async () => {
    const items = savedCache.map(s => `• ${s.text.slice(0, 60).replace(/\n/g, ' ')} (${s.id})`).join('\n') || '(none)';
    const a = prompt(`${t('gmList')}\n\n${items}\n\n${t('gmOps')}`);
    if (!a) return;
    const v = a.trim();
    if (v === '1') {
      if (!confirm(t('confirmDelAll'))) return;
      await deleteAllSavedSnippets();
      savedCache = [];
      renderList();
      alert(t('delAllDone'));
      return;
    }
    if (v === '2') {
      const raw = JSON.stringify(savedCache, null, 2);
      try {
        await navigator.clipboard.writeText(raw);
        alert(t('copied'));
      } catch (e) {
        prompt('Copy JSON:', raw);
      }
      return;
    }
    if (v === '3') {
      const txt = prompt(t('importPrompt'));
      if (!txt) return;
      try {
        const parsed = JSON.parse(txt);
        if (!Array.isArray(parsed)) throw new Error('Array required');
        savedCache = parsed;
        await saveSavedSnippets(savedCache);
        renderList();
      } catch (err) {
        alert(t('importFail') + (err && err.message ? err.message : String(err)));
      }
      return;
    }
    alert(t('invalidOp'));
  });

  /* ---------- key events & typing ---------- */
  function inferKeyProps(ch) {
    if (ch === undefined || ch === null) return { key: 'Unidentified', code: 'Unidentified', keyCode: 0, charCode: 0 };
    if (ch === ' ') return { key: ' ', code: 'Space', keyCode: 32, charCode: 32 };
    if (ch === '\n' || ch === '\r') return { key: 'Enter', code: 'Enter', keyCode: 13, charCode: 13 };
    const c = ch.charCodeAt(0);
    if ((c >= 65 && c <= 90) || (c >= 97 && c <= 122)) {
      const upper = String.fromCharCode(c).toUpperCase();
      return { key: ch, code: 'Key' + upper, keyCode: upper.charCodeAt(0), charCode: upper.charCodeAt(0) };
    }
    if (c >= 48 && c <= 57) {
      const digit = String.fromCharCode(c);
      return { key: digit, code: 'Digit' + digit, keyCode: c, charCode: c };
    }
    return { key: ch, code: 'Unidentified', keyCode: c, charCode: c };
  }

  function dispatchKeyEvent(target, type, ch) {
    const props = inferKeyProps(ch);
    try {
      const ev = new KeyboardEvent(type, {
        key: props.key,
        code: props.code,
        location: 0,
        ctrlKey: false,
        shiftKey: false,
        altKey: false,
        metaKey: false,
        repeat: false,
        isComposing: false,
        bubbles: true,
        cancelable: true
      });
      try { Object.defineProperty(ev, 'keyCode', { get: () => props.keyCode }); } catch (e) {}
      try { Object.defineProperty(ev, 'which', { get: () => props.keyCode }); } catch (e) {}
      try { Object.defineProperty(ev, 'charCode', { get: () => props.charCode }); } catch (e) {}
      target.dispatchEvent(ev);
    } catch (e) {
      const ev = document.createEvent('Event');
      ev.initEvent(type, true, true);
      target.dispatchEvent(ev);
    }
  }

  let typingCancelToken = null;

  async function typeLikeHumanInto(el, text) {
    if (!text) return;
    if (typingCancelToken) typingCancelToken.cancel = true;
    const myToken = { cancel: false };
    typingCancelToken = myToken;

    const baseDelay = Number(delayInput.value) || DEFAULT_DELAY_MS;
    const randomize = randomChk.checked;
    const simulateKeys = simKeysChk.checked;

    el.focus();
    const isCE = el.isContentEditable;

    for (let i = 0; i < text.length; i++) {
      if (myToken.cancel) break;
      const ch = text[i];
      try {
        if (simulateKeys) {
          dispatchKeyEvent(el, 'keydown', ch);
          dispatchKeyEvent(el, 'keypress', ch);
        }

        if (isCE) {
          const sel = window.getSelection();
          if (sel && sel.rangeCount > 0) {
            const range = sel.getRangeAt(0);
            const node = document.createTextNode(ch);
            range.insertNode(node);
            range.setStartAfter(node);
            range.setEndAfter(node);
            sel.removeAllRanges();
            sel.addRange(range);
          } else {
            el.innerText = (el.innerText || '') + ch;
          }
        } else {
          const start = ('selectionStart' in el) ? el.selectionStart : el.value.length;
          const end = ('selectionEnd' in el) ? el.selectionEnd : start;
          const before = el.value.slice(0, start);
          const after = el.value.slice(end);
          el.value = before + ch + after;
          const pos = (before + ch).length;
          if (typeof el.setSelectionRange === 'function') el.setSelectionRange(pos, pos);
        }

        try {
          const inputEv = new InputEvent('input', { data: ch, bubbles: true, cancelable: true });
          el.dispatchEvent(inputEv);
        } catch (e) {
          el.dispatchEvent(new Event('input', { bubbles: true }));
        }

        if (simulateKeys) {
          dispatchKeyEvent(el, 'keyup', ch);
        }

      } catch (err) {
        console.error('TinyTyper error:', err);
      }

      const delay = randomize
        ? Math.max(0, Math.round(baseDelay * (0.7 + Math.random() * 0.6)))
        : baseDelay;
      await new Promise(r => setTimeout(r, delay));
    }

    try {
      el.dispatchEvent(new Event('change', { bubbles: true }));
    } catch (e) {}

    if (typingCancelToken === myToken) typingCancelToken = null;
  }

  // クリックで自動入力
  let lastClickTarget = null;

  document.addEventListener('click', (e) => {
    if (!autoTypeEnabled) return;
    const el = e.target;
    if (!isTypeable(el)) return;

    const all = getAllSnippets();
    const selected = all.find(a => a.checked);
    if (!selected) return;

    if (lastClickTarget === el) return;
    lastClickTarget = el;
    setTimeout(() => {
      typeLikeHumanInto(el, selected.text).finally(() => {
        lastClickTarget = null;
      });
    }, 50);
  }, true);

  // 初期描画
  renderList();

  // Ctrl+Alt+T でパネル表示切替
  window.addEventListener('keydown', (e) => {
    try {
      if (e.ctrlKey && e.altKey && e.key && e.key.toLowerCase() === 't') {
        togglePanelDisplay().catch(err => console.error(err));
      }
    } catch (err) {
      console.error(err);
    }
  }, true);

})();