X/Twitter 検索プリセット起動 (Alt+S) — 完全版 v2.3(バージョン管理・最新5件保持)

Alt+Sで検索プリセット。日本語除外は自動で"引用"。グループ・空グループ作成・カードD&D、カード右端の編集⇄更新トグル、URL全幅+クエリ/除外同列、バックアップ▼(エクスポート/インポート)、テーマ追従。ストレージは searchPresets.vN を使用し、最新のみ読み込み・保存時は新規vN作成、古い版は5件残して削除。

当前为 2025-08-20 提交的版本,查看 最新版本

// ==UserScript==
// @name         X/Twitter 検索プリセット起動 (Alt+S) — 完全版 v2.3(バージョン管理・最新5件保持)
// @namespace    preset-search-launcher
// @version      2.3.0
// @author       Dondoco
// @license      MIT
// @description  Alt+Sで検索プリセット。日本語除外は自動で"引用"。グループ・空グループ作成・カードD&D、カード右端の編集⇄更新トグル、URL全幅+クエリ/除外同列、バックアップ▼(エクスポート/インポート)、テーマ追従。ストレージは searchPresets.vN を使用し、最新のみ読み込み・保存時は新規vN作成、古い版は5件残して削除。
// @match        https://x.com/*
// @match        https://twitter.com/*
// @run-at       document-idle
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_addStyle
// @grant        GM_listValues
// @grant        GM_deleteValue
// ==/UserScript==
(() => {
  'use strict';

  /** ============== 設定 ============== */
  const KEEP_VERSIONS = 5;                            // 何世代残すか
  const EXPANDED_KEY = 'searchPresets.expGroups.v1';  // 開閉状態
  const GROUPS_KEY   = 'searchPresets.customGroups.v1'; // カスタムグループ
  const OPEN_IN_NEW_TAB = false;                      // true で新タブ
  /** ================================== */

  // 初期値(初回のみ)
  const DEFAULTS = [
    {
      title: 'Twitter 落ちた',
      group: '未分類',
      q: ['Twitter', '落ちた'],
      exclude: ['エックスくん', 'Twix'],
      lang: 'ja',
      live: true
    },
  ];

  /* ================= バージョン管理 ================= */
  function listPresetVersions(){
    const keys = (typeof GM_listValues === 'function') ? GM_listValues() : [];
    return keys.map(k => {
      const m = /^searchPresets\.v(\d+)$/.exec(k);
      return m ? { key: k, ver: parseInt(m[1], 10) } : null;
    }).filter(Boolean).sort((a,b)=> b.ver - a.ver); // 降順
  }
  function loadLatestPresets(){
    const vers = listPresetVersions();
    if (vers.length === 0) {
      // 初回保存 v1
      GM_setValue('searchPresets.v1', JSON.stringify(DEFAULTS));
      return DEFAULTS.slice();
    }
    const latestKey = vers[0].key;
    try {
      const raw = GM_getValue(latestKey, '[]');
      const data = typeof raw === 'string' ? JSON.parse(raw) : raw;
      return Array.isArray(data) ? data : DEFAULTS.slice();
    } catch(e){
      console.warn('[PSL] preset parse failed for', latestKey, e);
      return DEFAULTS.slice();
    }
  }
  function saveNewVersion(presets){
    const vers = listPresetVersions();
    const nextVer = vers.length ? (vers[0].ver + 1) : 1;
    const key = `searchPresets.v${nextVer}`;
    GM_setValue(key, JSON.stringify(presets));
    pruneOldVersions();
  }
  function pruneOldVersions(){
    const vers = listPresetVersions(); // 降順
    const toDelete = vers.slice(KEEP_VERSIONS); // KEEP_VERSIONS より古いものを削除
    toDelete.forEach(v => GM_deleteValue(v.key));
  }

  // 便利関数
  const esc = (s)=> (s||'').replace(/[&<>"']/g, c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
  const enc = (s)=> encodeURIComponent(s);
  const loadExpanded = ()=> { try { return new Set(JSON.parse(GM_getValue(EXPANDED_KEY,'[]'))); } catch { return new Set(); } };
  const saveExpanded = (set)=> GM_setValue(EXPANDED_KEY, JSON.stringify(Array.from(set)));
  const loadGroups = ()=> { try { return new Set(JSON.parse(GM_getValue(GROUPS_KEY,'[]'))); } catch { return new Set(); } };
  const saveGroups = (set)=> GM_setValue(GROUPS_KEY, JSON.stringify(Array.from(set)));

  // 日本語判定
  function isJapanese(str=''){ return /[\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Han}ー-]/u.test(str); }

  // 検索URL合成
  function buildSearchUrl(p){
    if (p.url) return p.url;
    const parts=[];
    if (Array.isArray(p.q)&&p.q.length){
      const hh=p.q.map(line=>{
        line=(line||'').trim(); if(!line) return '';
        if(isJapanese(line)) return `"${line}"`;
        // 英語等:スペースはAND扱い。演算子含む場合は丸括弧で保護
        return /\s|OR|AND|NOT|-|:|\(|\)/i.test(line)?`(${line})`:line;
      }).filter(Boolean).join(' OR ');
      if(hh) parts.push(hh);
    }
    if (Array.isArray(p.exclude)&&p.exclude.length){
      const ex=p.exclude.map(w=>(w||'').trim()).filter(Boolean).map(w=>isJapanese(w)?`-"${w}"`:`-${w}`).join(' ');
      if(ex) parts.push(ex);
    }
    if (p.lang) parts.push(`lang:${p.lang}`);
    const qStr = parts.join(' ').trim();
    const params=['q='+enc(qStr),'src=typed_query']; if(p.live) params.push('f=live');
    return `https://x.com/search?${params.join('&')}`;
  }

  // 表示用クエリ
  function buildQueryText(p){
    if ((p.q&&p.q.length)||(p.exclude&&p.exclude.length)||p.lang||p.live){
      const parts=[];
      if (Array.isArray(p.q)&&p.q.length){
        const hh=p.q.map(line=>{
          line=(line||'').trim(); if(!line) return '';
          if(isJapanese(line)) return `"${line}"`;
          return /\s|OR|AND|NOT|-|:|\(|\)/i.test(line)?`(${line})`:line;
        }).filter(Boolean).join(' OR ');
        if(hh) parts.push(hh);
      }
      if (Array.isArray(p.exclude)&&p.exclude.length){
        const ex=p.exclude.map(w=>(w||'').trim()).filter(Boolean).map(w=>isJapanese(w)?`-"${w}"`:`-${w}`).join(' ');
        if(ex) parts.push(ex);
      }
      if (p.lang) parts.push(`lang:${p.lang}`);
      if (p.live) parts.push('[最新]');
      return parts.join(' ');
    }
    if (p.url) {
      try {
        const u=new URL(p.url);
        let t = u.searchParams.get('q') ? decodeURIComponent(u.searchParams.get('q')) : p.url;
        if (u.searchParams.get('f')==='live') t+=' [最新]';
        return t;
      } catch { return p.url; }
    }
    return '';
  }

  // テーマ
  function detectTheme(){
    const bg=getComputedStyle(document.body).backgroundColor||'rgb(255,255,255)';
    const m=bg.match(/\d+/g); if(!m) return 'dim';
    const [r,g,b]=m.map(Number); const max=Math.max(r,g,b), min=Math.min(r,g,b); const l=(max+min)/510;
    if(l>0.82) return 'light'; if(l<0.10) return 'lightsout'; return 'dim';
  }
  const palettes={
    light:{bg:'#fff',card:'#fff',border:'#eff3f4',text:'#0f1419',subtle:'#536471',hover:'rgba(15,20,25,.06)'},
    dim:{bg:'#15202b',card:'#192734',border:'#22303c',text:'#e7e9ea',subtle:'#8899a6',hover:'rgba(29,155,240,.12)'},
    lightsout:{bg:'#000',card:'#0a0a0a',border:'#2f3336',text:'#e7e9ea',subtle:'#8b98a5',hover:'rgba(29,155,240,.15)'}
  };
  const pal=palettes[detectTheme()];

  GM_addStyle(`
  .psl-backdrop{position:fixed;inset:0;background:rgba(0,0,0,.35);z-index:2147483646;display:none}
  .psl-modal{position:fixed;left:50%;top:10%;transform:translateX(-50%);
    width:min(900px,94vw);background:${pal.card};border:1px solid ${pal.border};border-radius:12px;
    box-shadow:0 8px 28px rgba(0,0,0,.45);z-index:2147483647;color:${pal.text};
    font:14px/1.5 system-ui,-apple-system,"Segoe UI",Roboto,Helvetica,Arial,"Apple Color Emoji","Segoe UI Emoji"}
  .psl-modal, .psl-modal * { box-sizing:border-box; font:inherit; color:inherit; }

  .psl-head{display:flex;align-items:center;justify-content:space-between;padding:10px 14px;border-bottom:1px solid ${pal.border};font-weight:700}
  .psl-small{opacity:.7;font-size:12px;color:${pal.subtle}}
  .psl-kbd{background:${pal.border};border-radius:6px;padding:0 6px;margin-left:8px;font-size:12px}

  .psl-list{max-height:48vh;overflow:auto;padding:6px 8px}
  .psl-group{border:1px solid ${pal.border};border-radius:10px;margin:8px 0;overflow:hidden;background:${pal.bg}}
  .psl-group-header{display:flex;align-items:center;gap:8px;padding:10px 12px;cursor:pointer;user-select:none}
  .psl-caret{transition:transform .15s}
  .psl-group-header:hover{background:${pal.hover}}
  .psl-group-title{font-weight:700}
  .psl-group-body{display:none;padding:6px;min-height:36px}
  .psl-group.open .psl-group-body{display:block}
  .psl-group.open .psl-caret{transform:rotate(90deg)}
  .psl-group.empty .psl-group-body{display:block}
  .psl-group.empty .psl-group-body::after{content:'(空のグループ)ここにドラッグで追加';display:block;color:${pal.subtle};font-size:12px;padding:8px}

  .psl-card{display:flex;align-items:center;gap:10px;padding:10px;border:1px solid ${pal.border};border-radius:10px;background:${pal.card};margin:6px 4px;cursor:pointer}
  .psl-card[aria-selected="true"], .psl-card:hover{background:${pal.hover}}
  .psl-handle{flex:0 0 18px;display:flex;align-items:center;justify-content:center;cursor:grab;user-select:none;opacity:.6}
  .psl-handle::before{content:"⋮⋮";line-height:1}
  .psl-card.dragging{opacity:.6}
  .psl-card-main{display:flex;flex-direction:column;flex:1;min-width:0}
  .psl-title{font-weight:700;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
  .psl-sub{opacity:.8;font-size:12px;color:${pal.subtle};white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
  .psl-card-actions{display:flex;gap:6px}
  .psl-mini{padding:6px 8px;border:1px solid ${pal.border};border-radius:8px;background:${pal.bg};color:${pal.text};cursor:pointer;font-size:12px}
  .psl-mini:hover{filter:brightness(1.06)}

  .psl-actions{display:flex;gap:8px;padding:10px 14px;justify-content:flex-end;border-top:1px solid ${pal.border};flex-wrap:wrap;position:relative}
  .psl-btn{padding:8px 10px;border:1px solid ${pal.border};border-radius:8px;background:${pal.bg};color:${pal.text};cursor:pointer}
  .psl-btn[disabled]{opacity:.5;cursor:not-allowed}
  .psl-btn:hover:not([disabled]){filter:brightness(1.06)}

  /* バックアップ▼のメニュー */
  .psl-menu{position:absolute;right:14px;bottom:46px;background:${pal.card};border:1px solid ${pal.border};border-radius:8px;box-shadow:0 6px 20px rgba(0,0,0,.35);display:none;min-width:180px;z-index:10}
  .psl-menu.open{display:block}
  .psl-menu button{display:block;width:100%;text-align:left;border:0;border-bottom:1px solid ${pal.border};background:transparent;padding:10px}
  .psl-menu button:last-child{border-bottom:0}
  .psl-menu button:hover{background:${pal.hover}}

  /* 入力域:2カラム固定。URLは全幅(2カラム跨ぎ)。クエリと除外は同列 */
  .psl-inputs{display:grid;grid-template-columns:1fr 1fr;gap:10px 12px;padding:10px 14px}
  .psl-row{display:flex;flex-direction:column;gap:6px}
  .psl-label{font-size:12px;color:${pal.subtle}}
  .psl-span2{grid-column:1 / -1;}
  .psl-inputs input,.psl-inputs textarea,.psl-inputs select{
    padding:8px 10px;border:1px solid ${pal.border};border-radius:8px;background:${pal.bg};color:${pal.text};width:100%;
  }
  .psl-inputs textarea{min-height:74px;resize:vertical}
  .psl-inputs input::placeholder, .psl-inputs textarea::placeholder { color:${pal.subtle}; opacity:.8; }
  `);

  // DOM
  const backdrop=document.createElement('div'); backdrop.className='psl-backdrop';
  const modal=document.createElement('div'); modal.className='psl-modal';
  backdrop.appendChild(modal); document.body.appendChild(backdrop);

  // 状態
  let presets=loadLatestPresets();
  let selectedGlobalIndex=0; // ↑↓操作は実装しない(クリック選択のみ)
  let editIndex=-1;
  let expandedGroups=loadExpanded();
  let customGroups=loadGroups();

  const groupOf=(p)=> (p.group||'未分類');

  function labelWrap(text, control, span2=false){
    const wrap=document.createElement('div'); wrap.className='psl-row' + (span2 ? ' psl-span2' : '');
    const lab=document.createElement('div'); lab.className='psl-label'; lab.textContent=text;
    wrap.appendChild(lab); wrap.appendChild(control); return wrap;
  }
  function btn(label,onClick){ const b=document.createElement('button'); b.className='psl-btn'; b.textContent=label; b.addEventListener('click',onClick); return b; }
  function mini(label,onClick){ const b=document.createElement('button'); b.className='psl-mini'; b.textContent=label; b.addEventListener('click',(e)=>{ e.stopPropagation(); onClick(e); }); return b; }

  function rebuildPresetsFromDOM(listEl){
    const used=new Set(); const newArr=[];
    const groups=[...listEl.querySelectorAll('.psl-group')];
    groups.forEach(gEl=>{
      const gname=gEl.getAttribute('data-group-name')||'未分類';
      const cards=[...gEl.querySelectorAll('.psl-card')];
      cards.forEach(card=>{
        const idx=Number(card.getAttribute('data-index'));
        if (Number.isInteger(idx) && presets[idx]){
          const obj=Object.assign({}, presets[idx]);
          obj.group=gname; newArr.push(obj); used.add(idx);
        }
      });
    });
    presets.forEach((p,i)=>{ if(!used.has(i)) newArr.push(p); });
    presets=newArr; saveNewVersion(presets);
  }

  function render(){
    modal.innerHTML='';

    const head=document.createElement('div');
    head.className='psl-head';
    head.innerHTML=`<div>検索プリセット</div>
      <div class="psl-small">Alt+S<span class="psl-kbd">Alt+S</span> / Esc</div>`;
    modal.appendChild(head);

    // グループ集合
    const setNames=new Set(['未分類']);
    customGroups.forEach(n=>setNames.add(n));
    presets.forEach(p=> setNames.add(groupOf(p)));
    const groupNames=[...setNames];

    const list=document.createElement('div'); list.className='psl-list'; modal.appendChild(list);

    groupNames.forEach(gname=>{
      const groupEl=document.createElement('div'); groupEl.className='psl-group'; groupEl.setAttribute('data-group-name', gname);
      const header=document.createElement('div'); header.className='psl-group-header';
      header.innerHTML=`<span class="psl-caret">▶</span><span class="psl-group-title">${esc(gname)}</span>`;
      header.addEventListener('click',()=>{
        if (groupEl.classList.toggle('open')) expandedGroups.add(gname); else expandedGroups.delete(gname);
        saveExpanded(expandedGroups);
      });
      if (expandedGroups.has(gname)) groupEl.classList.add('open');
      list.appendChild(groupEl); groupEl.appendChild(header);

      const body=document.createElement('div'); body.className='psl-group-body'; groupEl.appendChild(body);

      // ドロップ受け入れ(空でもOK)
      body.addEventListener('dragover',(e)=>{
        e.preventDefault();
        const dragging=list.querySelector('.psl-card.dragging');
        if (dragging){
          const after=getDragAfterElement(body, e.clientY);
          if (after==null) body.appendChild(dragging); else body.insertBefore(dragging, after);
        }
      });
      body.addEventListener('drop',(e)=>{ e.preventDefault(); rebuildPresetsFromDOM(list); render(); });

      const items=presets.map((p,i)=>({p,i})).filter(x=> groupOf(x.p)===gname);
      if (items.length===0) groupEl.classList.add('empty'); else groupEl.classList.remove('empty');

      items.forEach(({p,i})=>{
        const card=document.createElement('div');
        card.className='psl-card'; card.setAttribute('data-index', String(i));
        card.setAttribute('data-group', gname);
        card.setAttribute('draggable','true');

        const handle=document.createElement('div'); handle.className='psl-handle'; card.appendChild(handle);

        let dragEnabled=false;
        handle.addEventListener('mousedown',()=>{ dragEnabled=true; });
        handle.addEventListener('mouseup',()=>{ dragEnabled=false; });

        card.addEventListener('dragstart',(e)=>{
          if(!dragEnabled){ e.preventDefault(); return; }
          card.classList.add('dragging'); e.dataTransfer.effectAllowed='move';
        });
        card.addEventListener('dragend',()=>{ card.classList.remove('dragging'); });

        const main=document.createElement('div'); main.className='psl-card-main';
        const title=p.title?.trim()||'(no title)'; const sub=buildQueryText(p);
        main.innerHTML=`<div class="psl-title">${esc(title)}</div><div class="psl-sub">${esc(sub)}</div>`;
        card.appendChild(main);

        const right=document.createElement('div'); right.className='psl-card-actions';
        const editLabel = (editIndex===i) ? '更新' : '編集';
        const editB=mini(editLabel,()=>{
          if (editIndex===i){
            // 更新保存
            const pNew=formToPreset();
            if(!pNew.url && (!pNew.q||pNew.q.length===0)) return;
            presets[i]=pNew; saveNewVersion(presets);
            customGroups.add(groupOf(pNew)); saveGroups(customGroups);
            editIndex=-1; render();
          } else {
            // 編集開始
            editIndex=i; render();
          }
        });
        const delB=mini('削除',()=>{
          presets.splice(i,1);
          if(selectedGlobalIndex>=presets.length) selectedGlobalIndex=presets.length-1;
          saveNewVersion(presets); render();
        });
        right.append(editB, delB);
        card.appendChild(right);

        if (i===selectedGlobalIndex) card.setAttribute('aria-selected','true');
        card.addEventListener('click',(e)=>{ if(e.target===handle) return; openPreset(i); });
        card.addEventListener('mouseenter',()=>{ selectedGlobalIndex=i; updateSelection(); });

        body.appendChild(card);
      });
    });

    // 入力フォーム(URL全幅、クエリと除外は同列)
    const inputs=document.createElement('div'); inputs.className='psl-inputs';
    const t=document.createElement('input'), g=document.createElement('input'), u=document.createElement('input'),
          q=document.createElement('textarea'), ex=document.createElement('textarea'),
          lang=document.createElement('input'), live=document.createElement('select');
    t.placeholder='タイトル(例: Twitter 落ちた)';
    g.placeholder='グループ(例: 障害情報)';
    u.placeholder='検索URL(https://x.com/search?...) ※URL保存ならこちらに記入';
    q.placeholder='#タグかキーワードを行区切りで。日本語は自動で""囲まれます(URLを使うなら空でOK)';
    ex.placeholder='除外ワード(行区切り)';
    lang.placeholder='言語コード(例: ja)';
    live.innerHTML=`<option value="">並べ替え既定</option><option value="1">最新 (f=live)</option>`;

    if (editIndex>=0){
      const p=presets[editIndex];
      t.value=p.title||''; g.value=p.group||''; u.value=p.url||'';
      // 改行を実際の改行文字にして代入
      q.value=Array.isArray(p.q)?p.q.join("\n"):'';
      ex.value=Array.isArray(p.exclude)?p.exclude.join("\n"):'';
      lang.value=p.lang||''; live.value=p.live?'1':'';
    }

    inputs.append(
      labelWrap('タイトル',t,false),
      labelWrap('グループ',g,false),
      labelWrap('検索URL',u,true),
      labelWrap('クエリ(行区切り / OR 連結)',q,false),
      labelWrap('除外(行区切り / 日本語は自動で引用)',ex,false),
      labelWrap('言語',lang,false),
      labelWrap('最新タブ',live,false),
    );
    modal.appendChild(inputs);

    // アクション(追加 / 編集(更新) / 削除 / バックアップ▼ / グループ追加)
    const actions=document.createElement('div'); actions.className='psl-actions';

    const addBtn=btn('追加',()=>{
      const p=formToPreset();
      if(!p.url && (!p.q||p.q.length===0)) return;
      presets.push(p); saveNewVersion(presets); selectedGlobalIndex=presets.length-1; editIndex=-1;
      customGroups.add(groupOf(p)); saveGroups(customGroups); render();
    });

    const editToggleBtn=btn(editIndex>=0?'更新':'編集',()=>{
      if (editIndex<0){
        if(presets.length===0) return;
        editIndex=selectedGlobalIndex; render();
      } else {
        const p=formToPreset();
        if(!p.url && (!p.q||p.q.length===0)) return;
        presets[editIndex]=p; saveNewVersion(presets);
        customGroups.add(groupOf(p)); saveGroups(customGroups);
        editIndex=-1; render();
      }
    });

    const delBtn=btn('削除',()=>{
      if(presets.length===0) return;
      presets.splice(selectedGlobalIndex,1);
      if(selectedGlobalIndex>=presets.length) selectedGlobalIndex=presets.length-1;
      saveNewVersion(presets); render();
    });

    const backupBtn=btn('バックアップ▼',()=>{ menu.classList.toggle('open'); });
    const menu=document.createElement('div'); menu.className='psl-menu';
    const exportItem=document.createElement('button'); exportItem.textContent='エクスポート (.json)';
    exportItem.addEventListener('click',()=>{
      const blob=new Blob([JSON.stringify(presets,null,2)],{type:'application/json'});
      const a=Object.assign(document.createElement('a'),{href:URL.createObjectURL(blob),download:'x-search-presets.json'});
      document.body.appendChild(a); a.click(); a.remove(); menu.classList.remove('open');
    });
    const importItem=document.createElement('button'); importItem.textContent='インポート (.json)';
    importItem.addEventListener('click',()=>{
      const input=Object.assign(document.createElement('input'),{type:'file',accept:'.json,application/json'});
      input.onchange=async()=>{ try{ const text=await input.files[0].text(); presets=JSON.parse(text); saveNewVersion(presets); render(); }catch{} };
      input.click(); menu.classList.remove('open');
    });
    menu.append(exportItem, importItem);
    actions.append(menu);
    document.addEventListener('click',(e)=>{ if (!actions.contains(e.target)) menu.classList.remove('open'); });

    const newGroupBtn=btn('グループ追加',()=>{
      const name=prompt('新しいグループ名を入力してください','新規グループ');
      if(!name) return;
      customGroups.add(name); saveGroups(customGroups); expandedGroups.add(name); saveExpanded(expandedGroups); render();
    });

    actions.append(addBtn, editToggleBtn, delBtn, backupBtn, newGroupBtn);
    modal.appendChild(actions);

    function formToPreset(){
      const obj={ title:(t.value||'').trim(), group:(g.value||'').trim()||'未分類' };
      const urlVal=(u.value||'').trim(); if(urlVal) obj.url=urlVal;
      // 改行で配列化(空行は除外)
      const qLines=(q.value||'').split(/\r?\n/).map(s=>s.trim()).filter(Boolean);
      const exLines=(ex.value||'').split(/\r?\n/).map(s=>s.trim()).filter(Boolean);
      if(qLines.length) obj.q=qLines;
      if(exLines.length) obj.exclude=exLines;
      const langVal=(lang.value||'').trim(); if(langVal) obj.lang=langVal;
      obj.live=!!live.value; return obj;
    }
  }

  function updateSelection(){
    modal.querySelectorAll('.psl-card').forEach(el=>{
      const idx=Number(el.getAttribute('data-index'));
      if(idx===selectedGlobalIndex) el.setAttribute('aria-selected','true'); else el.removeAttribute('aria-selected');
    });
  }

  function openPreset(i){
    const p=presets[i]; if(!p) return;
    const url=buildSearchUrl(p); if(OPEN_IN_NEW_TAB) window.open(url,'_blank'); else location.href=url; close();
  }

  function show(){ selectedGlobalIndex=Math.min(selectedGlobalIndex, Math.max(0, presets.length-1)); render(); backdrop.style.display='block'; document.documentElement.style.overflow='hidden'; }
  function close(){ backdrop.style.display='none'; editIndex=-1; document.documentElement.style.overflow=''; }

  function getDragAfterElement(container,y){
    const els=[...container.querySelectorAll('.psl-card:not(.dragging)')];
    return els.reduce((closest,child)=>{
      const box=child.getBoundingClientRect(); const offset=y - box.top - box.height/2;
      if(offset<0 && offset>closest.offset) return {offset, element: child}; else return closest;
    }, {offset:Number.NEGATIVE_INFINITY}).element;
  }

  // キーイベント:モーダル中はXショートカット抑止、Alt+Sトグル/ESCのみ通す
  const isModalOpen=()=> backdrop.style.display==='block';
  ['keydown','keypress','keyup'].forEach((type)=>{
    window.addEventListener(type,(e)=>{
      const isAltS = e.altKey && !e.shiftKey && !e.ctrlKey && !e.metaKey && e.code==='KeyS';
      const isEsc  = e.key==='Escape';
      if (type==='keydown'){
        if (!isModalOpen() && isAltS){ e.preventDefault(); e.stopImmediatePropagation(); show(); return; }
        if (isModalOpen() && isAltS){  e.preventDefault(); e.stopImmediatePropagation(); close(); return; }
        if (isModalOpen() && isEsc){   e.preventDefault(); e.stopImmediatePropagation(); close(); return; }
      }
      if (isModalOpen()){
        if (!modal.contains(e.target)) e.preventDefault();
        e.stopPropagation(); e.stopImmediatePropagation();
      }
    }, true);
  });

  // 背景クリックで閉じる
  backdrop.addEventListener('click',(e)=>{ if(e.target===backdrop) close(); });

})();

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址