半角スペースとタブを可視化するUserScript
半角スペースとタブを可視化するUserScript
https://gyazo.com/7fb2d3e0e0dd26fe3e58e1f8fbec7498
code:テスト
//半角スペースが2つ入っているのがわかる
//半角スペースが4つ入っているのがわかる
//タブ文字が1つあるのがわかる
//タブ文字が2つあるのがわかる
実装
code:script.js
// Scrapbox / Cosense: 半角スペースとタブ文字を可視化するUserScript
//
// 半角スペース: ·
// タブ文字: →
//
// 特徴:
// - 1つの半角スペースにつき1つの丸ぽちを表示
// - タブ文字も → で表示
// - スクロール時に全マーカーを再計算しない
// - コードブロック行の内部的な先頭タブ1文字は無視
// - ホーム(ページ一覧)など「ページ閲覧以外のレイアウト」では可視化しない
(() => {
"use strict";
const NS = "__visibleAsciiWhitespace__";
// 再実行時に前回のobserver/listener/markerを掃除
if (windowNS?.cleanup) {
windowNS.cleanup();
}
const STYLE_ID = "visible-ascii-whitespace-style";
const LAYER_ID = "visible-ascii-whitespace-layer";
const STORAGE_KEY = "visible-ascii-whitespace-enabled";
const config = {
rootSelector: ".page-lines, .page, #page",
lineSelector: ".line",
// 表示記号
spaceMark: "·",
tabMark: "→",
// Scrapboxのコードブロック行は内部的に先頭タブ1文字を持つ。
// このタブはコードブロック記法用なので可視化しない。
skipFirstTabInLine: true,
// 巨大ページ対策
maxMarkers: 50000,
// MutationObserver等からの更新をまとめる
updateDelayMs: 30,
};
let enabled = localStorage.getItem(STORAGE_KEY) !== "false";
let rafId = 0;
let timerId = 0;
let observer = null;
const disposers = [];
// ページ閲覧レイアウトのときだけ可視化する。
// ホーム(ページ一覧 = 'list')や検索結果などでは描画しない。
function isPageLayout() {
const layout = window.scrapbox?.layout;
if (typeof layout === "string") {
return layout === "page";
}
// scrapbox APIが取れない場合のフォールバック:
// /projectName/pageTitle のように2セグメント以上ある時だけページとみなす
const segments = location.pathname.split("/").filter(Boolean);
return segments.length >= 2;
}
function ensureStyle() {
if (document.getElementById(STYLE_ID)) return;
const style = document.createElement("style");
style.id = STYLE_ID;
style.textContent = `
#${LAYER_ID} {
position: absolute;
left: 0;
top: 0;
width: 0;
height: 0;
pointer-events: none;
z-index: 2147483647;
}
#${LAYER_ID} .vw-marker {
position: absolute;
pointer-events: none;
user-select: none;
line-height: 1;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
color: rgba(128, 128, 128, 0.62);
opacity: 0.9;
white-space: pre;
}
#${LAYER_ID} .vw-space-marker {
font-size: 0.82em;
transform: translate(-50%, -50%);
}
#${LAYER_ID} .vw-tab-marker {
font-size: 1.08em;
color: rgba(128, 128, 128, 0.72);
transform: translate(1px, -50%);
}
`;
document.head.appendChild(style);
}
function ensureLayer() {
let layer = document.getElementById(LAYER_ID);
if (!layer) {
layer = document.createElement("div");
layer.id = LAYER_ID;
document.body.appendChild(layer);
}
layer.style.display = enabled ? "block" : "none";
return layer;
}
function clearMarkers() {
const layer = document.getElementById(LAYER_ID);
if (layer) {
layer.textContent = "";
}
}
function rootElement() {
return document.querySelector(config.rootSelector) || document.body;
}
function isIgnoredTextNode(node) {
const parent = node.parentElement;
if (!parent) return true;
return Boolean(
parent.closest(`
#${LAYER_ID},
script,
style,
textarea,
input,
select,
option,
button,
.dropdown-menu,
.modal
`)
);
}
function lineElements(root) {
const lines = [];
if (root.matches?.(config.lineSelector)) {
lines.push(root);
}
lines.push(...root.querySelectorAll(config.lineSelector));
// Scrapboxでは .line を対象にする。
// 取れない場合だけroot全体を処理する。
return lines.length > 0 ? lines : root;
}
function firstUsableRectForRange(range) {
const rects = ...range.getClientRects();
const rect = rects.find((r) => r.width >= 0 && r.height > 0);
if (rect) return rect;
const fallback = range.getBoundingClientRect();
return fallback.height > 0 ? fallback : null;
}
function appendSpaceMarker(fragment, rect) {
const marker = document.createElement("span");
marker.className = "vw-marker vw-space-marker";
marker.textContent = config.spaceMark;
marker.style.left = `${Math.round(
rect.left + window.scrollX + rect.width / 2
)}px`;
marker.style.top = `${Math.round(
rect.top + window.scrollY + rect.height / 2
)}px`;
fragment.appendChild(marker);
}
function appendTabMarker(fragment, rect) {
const marker = document.createElement("span");
marker.className = "vw-marker vw-tab-marker";
marker.textContent = config.tabMark;
marker.style.left = ${Math.round(rect.left + window.scrollX)}px;
marker.style.top = `${Math.round(
rect.top + window.scrollY + rect.height / 2
)}px`;
fragment.appendChild(marker);
}
function addMarkerForChar(fragment, node, index, ch) {
const range = document.createRange();
range.setStart(node, index);
range.setEnd(node, index + 1);
const rect = firstUsableRectForRange(range);
range.detach?.();
if (!rect) return false;
if (ch === " ") {
appendSpaceMarker(fragment, rect);
return true;
}
if (ch === "\t") {
appendTabMarker(fragment, rect);
return true;
}
return false;
}
function collectFromLine(line, state, fragment) {
let skipFirstTab =
config.skipFirstTabInLine && line.textContent.startsWith("\t");
const walker = document.createTreeWalker(line, NodeFilter.SHOW_TEXT, {
acceptNode(node) {
const text = node.nodeValue;
if (!text || (!text.includes(" ") && !text.includes("\t"))) {
return NodeFilter.FILTER_REJECT;
}
return isIgnoredTextNode(node)
? NodeFilter.FILTER_REJECT
: NodeFilter.FILTER_ACCEPT;
},
});
for (let node; (node = walker.nextNode()); ) {
const text = node.nodeValue;
for (let i = 0; i < text.length; i++) {
const ch = texti;
if (ch !== " " && ch !== "\t") continue;
// コードブロック行の内部的な先頭タブ1文字だけ無視する
if (ch === "\t" && skipFirstTab) {
skipFirstTab = false;
continue;
}
if (state.count >= config.maxMarkers) {
return;
}
if (addMarkerForChar(fragment, node, i, ch)) {
state.count += 1;
}
}
}
}
function updateNow() {
rafId = 0;
timerId = 0;
clearMarkers();
if (!enabled) return;
// ホーム(ページ一覧)などページ閲覧以外のレイアウトでは描画しない
if (!isPageLayout()) return;
const layer = ensureLayer();
const fragment = document.createDocumentFragment();
const state = {
count: 0,
};
const root = rootElement();
for (const line of lineElements(root)) {
collectFromLine(line, state, fragment);
if (state.count >= config.maxMarkers) {
break;
}
}
layer.appendChild(fragment);
}
function scheduleUpdate() {
if (!enabled) return;
clearTimeout(timerId);
timerId = window.setTimeout(() => {
if (rafId) return;
rafId = requestAnimationFrame(updateNow);
}, config.updateDelayMs);
}
function setEnabled(next) {
enabled = Boolean(next);
localStorage.setItem(STORAGE_KEY, String(enabled));
const layer = ensureLayer();
layer.style.display = enabled ? "block" : "none";
if (enabled) {
scheduleUpdate();
} else {
clearMarkers();
}
console.log([visible-whitespace] ${enabled ? "enabled" : "disabled"});
}
function addEvent(target, type, listener, options) {
target.addEventListener(type, listener, options);
disposers.push(() => target.removeEventListener(type, listener, options));
}
function isOnlyLayerMutation(mutations) {
return mutations.every((m) => {
const target =
m.target.nodeType === Node.ELEMENT_NODE
? m.target
: m.target.parentElement;
if (target?.closest?.(#${LAYER_ID})) return true;
for (const node of m.addedNodes) {
if (
node.nodeType === Node.ELEMENT_NODE &&
node.closest?.(#${LAYER_ID})
) {
return true;
}
}
return false;
});
}
function boot() {
ensureStyle();
ensureLayer();
updateNow();
observer = new MutationObserver((mutations) => {
if (isOnlyLayerMutation(mutations)) return;
scheduleUpdate();
});
observer.observe(document.body, {
childList: true,
subtree: true,
characterData: true,
});
// スクロールでは更新しない。
// マーカーは文書座標に置いているため、通常のスクロールには自然追従する。
addEvent(window, "resize", scheduleUpdate, { passive: true });
addEvent(document, "input", scheduleUpdate, true);
addEvent(document, "compositionend", scheduleUpdate, true);
if (window.scrapbox?.addListener) {
scrapbox.addListener("page:changed", scheduleUpdate);
scrapbox.addListener("lines:changed", scheduleUpdate);
// レイアウト切り替え(ページ⇔一覧など)でも再評価する
scrapbox.addListener("layout:changed", scheduleUpdate);
if (window.scrapbox?.removeListener) {
disposers.push(() =>
scrapbox.removeListener("page:changed", scheduleUpdate)
);
disposers.push(() =>
scrapbox.removeListener("lines:changed", scheduleUpdate)
);
disposers.push(() =>
scrapbox.removeListener("layout:changed", scheduleUpdate)
);
}
}
window.showVisibleWhitespace = () => setEnabled(true);
window.hideVisibleWhitespace = () => setEnabled(false);
window.toggleVisibleWhitespace = () => setEnabled(!enabled);
window.updateVisibleWhitespace = scheduleUpdate;
windowNS = {
cleanup() {
clearTimeout(timerId);
if (rafId) {
cancelAnimationFrame(rafId);
rafId = 0;
}
observer?.disconnect();
observer = null;
for (const dispose of disposers.splice(0)) {
try {
dispose();
} catch (_) {
// ignore
}
}
clearMarkers();
document.getElementById(LAYER_ID)?.remove();
},
};
}
if (document.body) {
boot();
} else {
window.addEventListener("DOMContentLoaded", boot, { once: true });
}
})();
hr.icon
based on ${TITLE}するUserScript