您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
TOKIMEKIの「メディア」スタイルで投稿の本文や引用元をクリックした際に、その投稿の個別ページに移動できるようにします。また、キーボードショートカットでリアクション操作ができるようになります。
// ==UserScript== // @name TOKIMEKI メディアスタイル修復 // @namespace https://bsky.app/profile/neon-ai.art // @homepage https://bsky.app/profile/neon-ai.art // @icon data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🌈</text></svg> // @version 2.2 // @description TOKIMEKIの「メディア」スタイルで投稿の本文や引用元をクリックした際に、その投稿の個別ページに移動できるようにします。また、キーボードショートカットでリアクション操作ができるようになります。 // @author ねおん // @match https://tokimeki.blue/* // @grant GM_getValue // @grant GM_setValue // @grant GM_registerMenuCommand // @grant GM_unregisterMenuCommand // @license CC BY-NC 4.0 // ==/UserScript== (function() { 'use strict'; const VERSION = 'v2.2'; const STORE_KEY = 'tokimeki_media_fix_shortcuts'; // ========= 設定 ========= let shortcuts = GM_getValue(STORE_KEY, { reply: 'Numpad1', repost: 'Numpad2', like: 'Numpad3', quote: 'Numpad4', bookmark: 'Numpad5' }); // ========= クリックでポストを開く処理 (v1.4ベース) ========= document.body.addEventListener('click', function(e) { // キー操作の設定画面が開いているときは何もしない if (document.querySelector('.tmf-overlay')) return; const dialog = e.target.closest('dialog.media-content-wrap'); const selection = window.getSelection(); // 以下の条件のどれか1つでも当てはまったら、何もしないで処理を終わります。 // 1. メディアビューのダイアログの外がクリックされた // 2. リアクションボタンやリンクなどがクリックされた // 3. テキストが選択されている if (!dialog || e.target.closest('.timeline-reaction, button, a') || (selection && !selection.isCollapsed)) { return; } // クリックされた要素から一番近い[data-aturi]を持つ親要素を探します。 // これで、投稿の本文でも引用部分でも、どっちでもいけます! const postElement = e.target.closest('[data-aturi]'); if (postElement) { const atUri = postElement.dataset.aturi; // atUriがちゃんと投稿のものかチェックします。 if (atUri && atUri.startsWith('at://') && atUri.includes('/app.bsky.feed.post/')) { // atUriをtokimeki.blueのURLに変換します。 const parts = atUri.replace('at://', '').split('/'); const did = parts[0]; const rkey = parts[2]; const postUrl = `https://tokimeki.blue/profile/${did}/post/${rkey}`; // 本来のクリックイベントを止めて、同じタブでURLを書き換えます。 // これでメディアビューを閉じて、投稿のページに移動します🐾 e.preventDefault(); e.stopPropagation(); window.location.href = postUrl; } } }, true); // ========= キーボード操作 ========= document.body.addEventListener('keydown', function(e) { // メディアビューが開いていない、または入力欄にフォーカスがある、設定画面が開いている場合は何もしない const dialog = document.querySelector('dialog.media-content-wrap[open]'); const activeEl = document.activeElement; if (!dialog || (activeEl && (activeEl.tagName === 'INPUT' || activeEl.tagName === 'TEXTAREA' || activeEl.isContentEditable)) || document.querySelector('.tmf-overlay')) { return; } // 押されたキーに一致するアクションを探す const action = Object.keys(shortcuts).find(key => e.code === shortcuts[key]); if (!action) return; // イベントのデフォルト動作をキャンセル e.preventDefault(); e.stopPropagation(); // ダイアログ内の対応するボタンを探してクリック! const contentArea = dialog.querySelector('.media-content__content'); if (!contentArea) return; const reactionAreas = contentArea.querySelectorAll('.timeline-reaction'); if (reactionAreas.length === 0) return; const reactionArea = reactionAreas[reactionAreas.length - 1]; // 一番最後の要素がターゲットです✨ let button; switch (action) { case 'reply': button = reactionArea.querySelector('.timeline-reaction__item--reply'); break; case 'repost': button = reactionArea.querySelector('.timeline-reaction__item--repost'); break; case 'like': button = reactionArea.querySelector('.timeline-reaction__item--like'); break; case 'quote': button = reactionArea.querySelector('.timeline-reaction__item--quote'); break; case 'bookmark': // ブックマークはボタンが入れ子になってるので注意です! button = reactionArea.querySelector('.timeline-reaction__item--bookmark'); break; } if (button) { button.click(); // いいねボタンとかは色がすぐ変わらないので、見た目でわかるようにちょっとエフェクトをつけます✨ button.style.transform = 'scale(1.2)'; setTimeout(() => { button.style.transform = ''; }, 150); } }, true); // ========= 設定UI ========= function ensureStyle() { if (document.getElementById('tmf-style')) return; const style = document.createElement('style'); style.id = 'tmf-style'; style.textContent = ` :root { --tmf-bg-color: #1a1a1a; --tmf-text-color: #f0f0f0; --tmf-border-color: #333; --tmf-primary-color: #00a8ff; --tmf-primary-hover: #007bff; --tmf-secondary-color: #343a40; --tmf-modal-bg: #212529; --tmf-shadow: 0 8px 16px rgba(0, 0, 0, 0.5); --tmf-border-radius: 12px; } .tmf-overlay { position: fixed; top: 0; left: 0; width: 100%; height: 100%; background-color: rgba(0, 0, 0, 0.7); z-index: 100000; display: flex; justify-content: center; align-items: center; } .tmf-panel { background-color: var(--tmf-modal-bg); color: var(--tmf-text-color); width: 90%; max-width: 480px; border-radius: var(--tmf-border-radius); box-shadow: var(--tmf-shadow); border: 1px solid var(--tmf-border-color); font-family: 'Inter', sans-serif; overflow: hidden; } .tmf-title { padding: 15px 20px; display: flex; align-items: center; justify-content: space-between; border-bottom: 1px solid var(--tmf-border-color); font-size: 1.25rem; font-weight: 600; margin: 0; } .tmf-close { background: none; border: none; cursor: pointer; font-size: 24px; color: var(--tmf-text-color); opacity: 0.7; padding: 0; } .tmf-close:hover { opacity: 1; } .tmf-section { padding: 20px; } .tmf-shortcut-grid { display: grid; grid-template-columns: 120px 1fr; gap: 15px; align-items: center; } .tmf-label { font-size: 1rem; font-weight: 500; color: #e0e0e0; display: flex; align-items: center; gap: 8px; } .tmf-label svg { width: 20px; height: 20px; stroke: var(--tmf-text-color); } .tmf-input { width: 100%; padding: 8px 12px; background-color: var(--tmf-secondary-color); color: var(--tmf-text-color); border: 1px solid var(--tmf-border-color); border-radius: 6px; cursor: text; box-sizing: border-box; text-align: center; } .tmf-input.recording { border-color: var(--tmf-primary-color); box-shadow: 0 0 5px var(--tmf-primary-color); } .tmf-input.error { border-color: #e34959; } .tmf-bottom { padding: 15px 20px; border-top: 1px solid var(--tmf-border-color); display: flex; justify-content: space-between; align-items: center; } .tmf-bottom .tmf-version { font-size: 0.8rem; font-weight: 400; color: #aaa; } .tmf-button { padding: 10px 20px; border: none; border-radius: 6px; font-weight: bold; cursor: pointer; transition: all 0.2s ease; background-color: var(--tmf-primary-color); color: white; } .tmf-button:hover { background-color: var(--tmf-primary-hover); } `; document.head.appendChild(style); } function showToast(msg, isError = false) { const toast = document.createElement('div'); toast.textContent = msg; toast.style.cssText = ` position: fixed; bottom: 20px; left: 50%; transform: translateX(-50%); background: ${isError ? '#e34959' : 'var(--tmf-primary-color)'}; color: white; padding: 10px 20px; border-radius: 6px; z-index: 100001; font-size: 14px; box-shadow: var(--tmf-shadow); `; document.body.appendChild(toast); setTimeout(() => toast.remove(), 3000); } function openSettings() { ensureStyle(); if (document.querySelector('.tmf-overlay')) return; const overlay = document.createElement('div'); overlay.className = 'tmf-overlay'; overlay.addEventListener('click', e => { if (e.target === overlay) overlay.remove(); }); const panel = document.createElement('div'); panel.className = 'tmf-panel'; panel.innerHTML = ` <div class="tmf-title"><span>キー設定</span><button class="tmf-close">×</button></div> <div class="tmf-section"><div class="tmf-shortcut-grid"> <label class="tmf-label" for="tmf-reply"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke-width="2.25" stroke-linecap="round" stroke-linejoin="round"><path d="M22 17a2 2 0 0 1-2 2H6.828a2 2 0 0 0-1.414.586l-2.202 2.202A.71.71 0 0 1 2 21.286V5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2z"></path></svg><span>コメント</span></label><input type="text" id="tmf-reply" class="tmf-input" readonly> <label class="tmf-label" for="tmf-repost"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke-width="2.25" stroke-linecap="round" stroke-linejoin="round"><path d="m17 2 4 4-4 4"></path><path d="M3 11v-1a4 4 0 0 1 4-4h14"></path><path d="m7 22-4-4 4-4"></path><path d="M21 13v1a4 4 0 0 1-4 4H3"></path></svg><span>リポスト</span></label><input type="text" id="tmf-repost" class="tmf-input" readonly> <label class="tmf-label" for="tmf-like"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke-width="2.25" stroke-linecap="round" stroke-linejoin="round"><path d="M2 9.5a5.5 5.5 0 0 1 9.591-3.676.56.56 0 0 0 .818 0A5.49 5.49 0 0 1 22 9.5c0 2.29-1.5 4-3 5.5l-5.492 5.313a2 2 0 0 1-3 .019L5 15c-1.5-1.5-3-3.2-3-5.5"></path></svg><span>いいね</span></label><input type="text" id="tmf-like" class="tmf-input" readonly> <label class="tmf-label" for="tmf-quote"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke-width="2.25" stroke-linecap="round" stroke-linejoin="round"><path d="M16 3a2 2 0 0 0-2 2v6a2 2 0 0 0 2 2 1 1 0 0 1 1 1v1a2 2 0 0 1-2 2 1 1 0 0 0-1 1v2a1 1 0 0 0 1 1 6 6 0 0 0 6-6V5a2 2 0 0 0-2-2z"></path><path d="M5 3a2 2 0 0 0-2 2v6a2 2 0 0 0 2 2 1 1 0 0 1 1 1v1a2 2 0 0 1-2 2 1 1 0 0 0-1 1v2a1 1 0 0 0 1 1 6 6 0 0 0 6-6V5a2 2 0 0 0-2-2z"></path></svg><span>引用</span></label><input type="text" id="tmf-quote" class="tmf-input" readonly> <label class="tmf-label" for="tmf-bookmark"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke-width="2.25" stroke-linecap="round" stroke-linejoin="round"><path d="m19 21-7-4-7 4V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v16z"></path></svg><span>ブックマーク</span></label><input type="text" id="tmf-bookmark" class="tmf-input" readonly> </div></div> <div class="tmf-bottom"><span class="tmf-version">(${VERSION})</span><button class="tmf-button">保存</button></div> `; overlay.appendChild(panel); document.body.appendChild(overlay); const inputs = Object.fromEntries( Array.from(panel.querySelectorAll('.tmf-input')).map(input => [input.id.replace('tmf-', ''), input]) ); // 現在の設定値を表示 for (const action in inputs) { inputs[action].value = shortcuts[action] || ''; } let activeInput = null; const recordKey = (e, action) => { e.preventDefault(); e.stopPropagation(); // 全角入力チェック if (e.key.length > 1 && e.key !== 'Backspace' && e.key !== 'Delete' && !/F\d+/.test(e.key) ) { if (/[^ -~]/.test(e.key)) { // 半角ASCII文字以外 showToast('全角や修飾キーのみの入力はできません!', true); return; } } // 他で使われてるキーかチェック const newCode = e.code; const otherInputs = Object.entries(inputs).filter(([act,]) => act !== action); if (otherInputs.some(([, inp]) => inp.value === newCode && !inp.classList.contains('recording'))) { inputs[action].classList.add('error'); showToast('そのキーは他のアクションで使われています!', true); return; } inputs[action].value = newCode; inputs[action].classList.remove('recording', 'error'); activeInput = null; }; for (const action in inputs) { const input = inputs[action]; input.addEventListener('click', () => { if (activeInput === input) { input.classList.remove('recording'); activeInput = null; return; } if(activeInput) activeInput.classList.remove('recording', 'error'); activeInput = input; input.value = 'キーを押してください...'; input.classList.add('recording'); input.classList.remove('error'); }); input.addEventListener('keydown', (e) => recordKey(e, action)); } panel.querySelector('.tmf-close').addEventListener('click', () => overlay.remove()); panel.querySelector('.tmf-button').addEventListener('click', () => { const newShortcuts = {}; let hasError = false; for (const action in inputs) { const val = inputs[action].value; if (!val || val.includes('...')) { showToast(`${action} のキーが設定されていません!`, true); hasError = true; break; } newShortcuts[action] = val; } if (!hasError) { shortcuts = newShortcuts; GM_setValue(STORE_KEY, shortcuts); showToast('設定を保存しました!'); overlay.remove(); } }); } GM_registerMenuCommand('TOKIMEKI修復設定', openSettings); })();