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.

От 15.11.2025. Виж последната версия.

За да инсталирате този скрипт, трябва да имате инсталирано разширение като Tampermonkey, Greasemonkey или Violentmonkey.

За да инсталирате този скрипт, трябва да имате инсталирано разширение като Tampermonkey или Violentmonkey.

За да инсталирате този скрипт, трябва да имате инсталирано разширение като Tampermonkey или Violentmonkey.

За да инсталирате този скрипт, трябва да имате инсталирано разширение като Tampermonkey или Userscripts.

За да инсталирате скрипта, трябва да инсталирате разширение като 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);

})();