RGBの表記を見つけて色プレビューを挿入削除するUserScript
https://gyazo.com/d7d39a4e38a61e201b72a7d1fda87856
概要
「"#FF0000"」のように文章中の""で囲われたRGBの表記にプレビューを表示させます
"#F00"のように3桁表記でも"#FF0000"のように6桁表記でもOK
""で囲まれていないと発動しません→#FF00FF,#FFF
コードブロックの中のものにも適用される.
code:hoge.c
printf("#FF00FF");
こんな感じで使える
code:script.js
(() => {
'use strict';
// ========= 設定 =========
const STYLE_ID = 'sbx-hexswatch-style';
const LINE_SELECTOR = LINE_CANDIDATES.join(',');
const POLL_MS = 250; // 軽量ポーリング間隔
// ========= スタイル =========
const ensureStyle = () => {
if (document.getElementById(STYLE_ID)) return;
const s = document.createElement('style');
s.id = STYLE_ID;
s.textContent = `
.sbx-swatch{
display:inline-block;
width:0.9em; height:0.9em;
margin-right:0.1em; vertical-align:-0.1em;
border:1px solid rgba(0,0,0,.28); border-radius:2px;
background:var(--sbx-color,#000); box-sizing:border-box;
}
`;
document.head.appendChild(s);
};
// ========= ユーティリティ =========
const expand3 = (hex) => hex.length === 4
? '#'+hex1+hex1+hex2+hex2+hex3+hex3 : hex;
// TextNode連結 & offset→(node,off) 解決(resolveは二分探索)
const buildTextAndResolver = (container) => {
const w = document.createTreeWalker(container, NodeFilter.SHOW_TEXT, {
acceptNode: (n) => n.textContent ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_REJECT
});
const nodes = [], lens = [], parts = [];
for (let n; (n = w.nextNode()); ) {
const t = n.textContent;
if (!t) continue;
nodes.push(n); lens.push(t.length); parts.push(t);
}
const text = parts.join('');
const total = lens.reduce((a,b)=>a+b,0);
if (!nodes.length) return { text, total, resolve: () => ({ node:null, offset:0 }) };
// 累積和
const cumul = new Array(lens.length);
let acc = 0;
for (let i=0; i<lens.length; i++) { acc += lensi; cumuli = acc; } const resolve = (offset) => {
if (offset <= 0) return { node:nodes0, offset:0 }; // 二分探索で cumulidx > offset となる最小idxを探す let lo = 0, hi = cumul.length - 1, idx = hi;
while (lo <= hi) {
const mid = (lo + hi) >> 1;
if (cumulmid > offset) { idx = mid; hi = mid - 1; } else { lo = mid + 1; }
}
const prev = idx > 0 ? cumulidx-1 : 0; return { node:nodesidx, offset: offset - prev }; };
return { text, total, resolve };
};
// ========= 1行処理(全スウォッチ削除 → 再検出 → 先頭位置にだけ挿入) =========
const processLine = (lineEl) => {
if (!(lineEl instanceof Element)) return;
// 既存スウォッチ全削除(直下/子孫ともにqSAで十分)
lineEl.querySelectorAll('.sbx-swatch').forEach(s => s.remove());
const { text, total, resolve } = buildTextAndResolver(lineEl);
if (!text || total === 0) return;
if (text.indexOf('"#') === -1) return;
let m; RE.lastIndex = 0;
while ((m = RE.exec(text)) !== null) {
const start = m.index;
const quoted = m0; // "\"#FFAABB\"" const rawHex = quoted.slice(1, -1); // -> #FFAABB const hex = expand3(rawHex).toUpperCase();
const a = resolve(start);
if (!a.node) continue;
try {
const r = document.createRange();
r.setStart(a.node, a.offset);
r.setEnd(a.node, a.offset);
const sw = document.createElement('span');
sw.className = 'sbx-swatch';
sw.style.setProperty('--sbx-color', hex);
sw.title = rawHex;
r.insertNode(sw);
} catch { /* ignore */ }
}
};
// ========= 差分監視(イベント + 軽量ポーリング) =========
let currentPage = null;
let mo = null;
let pollId = null;
const lastTextCache = new WeakMap(); // lineEl -> lastText
const processIfChanged = (line) => {
const { text } = buildTextAndResolver(line);
const prev = lastTextCache.get(line);
if (prev !== text) {
lastTextCache.set(line, text);
processLine(line);
}
};
const addLineListeners = (el) => {
const h = () => processIfChanged(el);
for (const ev of LINE_EVENTS) el.addEventListener?.(ev, h);
// 初期キャッシュ & 初期処理
const { text } = buildTextAndResolver(el);
lastTextCache.set(el, text);
processLine(el);
};
const startPolling = () => {
if (pollId) return;
pollId = setInterval(() => {
try {
currentPage?.querySelectorAll?.(LINE_SELECTOR)?.forEach(processIfChanged);
} catch { /* ignore */ }
}, POLL_MS);
};
const stopPolling = () => {
if (pollId) { clearInterval(pollId); pollId = null; }
};
const attach = () => {
const page = document.querySelector('.page');
if (!page || page === currentPage) return;
// 既存監視をクリア
if (mo) { mo.disconnect(); mo = null; }
stopPolling();
lastTextCache.clear?.();
currentPage = page;
// 既存行:初期処理 + 主要イベント
page.querySelectorAll(LINE_SELECTOR).forEach(addLineListeners);
// MutationObserver(追加/文字差し替え)
const root = page.querySelector('.lines') || page;
mo = new MutationObserver((muts) => {
for (const m of muts) {
if (m.type === 'characterData') {
const line = m.target.parentElement?.closest?.(LINE_SELECTOR);
if (line) processIfChanged(line);
}
if (m.addedNodes && m.addedNodes.length) {
m.addedNodes.forEach(n => {
if (n.nodeType !== 1) return;
if (n.matches?.(LINE_SELECTOR)) addLineListeners(n);
n.querySelectorAll?.(LINE_SELECTOR)?.forEach(addLineListeners);
});
}
}
});
mo.observe(root, { characterData:true, childList:true, subtree:true });
// 軽量ポーリング(取りこぼしの安全網)
if (document.visibilityState === 'visible') startPolling();
};
// ========= PJAX 追随(History API + 保険) =========
const installNavHooks = () => {
const fire = () => window.dispatchEvent(new Event('sbx:navigate'));
const wrap = (t) => {
return function(...args){ const ret = orig.apply(this, args); fire(); return ret; };
};
history.pushState = wrap('pushState');
history.replaceState = wrap('replaceState');
window.addEventListener('popstate', fire);
window.addEventListener('sbx:navigate', () => {
let tries = 0;
const retry = () => {
const pageNow = document.querySelector('.page');
if (pageNow && pageNow !== currentPage) { attach(); return; }
if (tries++ < 20) requestAnimationFrame(retry);
};
requestAnimationFrame(retry);
});
new MutationObserver(() => attach()).observe(document.body, { childList:true, subtree:true });
// タブの可視状態でポーリングを抑制
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') startPolling();
else stopPolling();
});
window.addEventListener('pageshow', () => attach());
};
// ========= 起動 =========
const boot = () => { ensureStyle(); installNavHooks(); attach(); };
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', boot);
else boot();
})();