iPhone/iPad 用 DevTools エミュレータ。Console パネル(ログ表示)と Elements パネル(DOM ツリー表示・要素選択・CSS スタイル編集)を提供します。
此脚本不应直接安装。它是供其他脚本使用的外部库,要使用该库请加入元指令 // @require https://update.greasyfork.org/scripts/577436/1822650/DevTools%20Emulator%20%E2%80%94%20Console%20%20Elements.js
// ==UserScript==
// @name DevTools Emulator — Console & Elements
// @namespace http://tampermonkey.net/
// @version 3.1
// @description iPhone/iPad 用 DevTools エミュレータ。Console パネル(ログ表示)と Elements パネル(DOM ツリー表示・要素選択・CSS スタイル編集)を提供します。
// @author Professional Debugger
// @match *://*/*
// @grant none
// @run-at document-start
// ==/UserScript==
(function () {
'use strict';
// ================================================================
// 定数
// ================================================================
/** DevTools コンテナの ID */
const TOOL_ID = 'tm-devtools';
/** 選択要素ハイライトオーバーレイの ID */
const OVERLAY_ID = 'tm-highlight-overlay';
/** Console ログの最大保持件数(メモリ対策) */
const MAX_LOGS = 100;
/** DOM ツリーの最大再帰深度 */
const MAX_DOM_DEPTH = 50;
/** タグラベルに表示する属性の最大数 */
const MAX_ATTR_SHOW = 20;
/** 文字列の最大表示長 */
const TRUNCATE_LEN = 100;
// ================================================================
// ユーティリティ関数
// ================================================================
/**
* テキストと CSS クラスから <span> 要素を生成する
* @param {string} text
* @param {string} [className]
* @returns {HTMLSpanElement}
*/
function createSpan(text, className) {
const el = document.createElement('span');
if (className) el.className = className;
el.textContent = text;
return el;
}
/**
* 値を文字列化して指定の最大長に切り詰める
* @param {*} value
* @param {number} [max]
* @returns {string}
*/
function truncate(value, max = TRUNCATE_LEN) {
const s = String(value);
return s.length > max ? `${s.slice(0, max)}…` : s;
}
/**
* 要素の短い CSS セレクタ風識別子を返す
* @param {HTMLElement} element
* @returns {string}
*/
function getElementSelector(element) {
const tag = element.tagName.toLowerCase();
const id = element.id ? `#${element.id}` : '';
const cls = typeof element.className === 'string' && element.className.trim()
? '.' + element.className.trim().split(/\s+/).slice(0, 2).join('.')
: '';
return `${tag}${id}${cls}`;
}
// ================================================================
// SVG アイコンファクトリ
// ================================================================
/**
* SVG 名前空間で要素を生成し、属性を一括設定する
* @param {string} tag
* @param {Object<string,string>} attrs
* @returns {SVGElement}
*/
function createSvgEl(tag, attrs = {}) {
const el = document.createElementNS('http://www.w3.org/2000/svg', tag);
Object.entries(attrs).forEach(([k, v]) => el.setAttribute(k, v));
return el;
}
/**
* 共通の SVG ルート要素を生成する
* @param {string} [size='12']
* @returns {SVGSVGElement}
*/
function makeSvgRoot(size = '12') {
return createSvgEl('svg', {
width: size, height: size,
viewBox: '0 0 16 16',
fill: 'none',
stroke: 'currentColor',
'stroke-width': '2',
'stroke-linecap': 'round',
'stroke-linejoin': 'round',
});
}
/** 検索(虫眼鏡)アイコンを生成する */
function makeIconSearch() {
const svg = makeSvgRoot();
svg.appendChild(createSvgEl('circle', { cx: '6.5', cy: '6.5', r: '4' }));
svg.appendChild(createSvgEl('line', { x1: '9.5', y1: '9.5', x2: '14', y2: '14' }));
return svg;
}
/** 再描画(回転矢印)アイコンを生成する */
function makeIconRefresh() {
const svg = makeSvgRoot();
// 円弧(3/4 周)
svg.appendChild(createSvgEl('path', { d: 'M14 8A6 6 0 1 1 10.2 3' }));
// 矢じり
svg.appendChild(createSvgEl('polyline', { points: '10,1 10,4 13,4' }));
return svg;
}
/** 下向きシェブロン(最小化)アイコンを生成する */
function makeIconChevronDown() {
const svg = makeSvgRoot();
svg.setAttribute('stroke-width', '2.5');
svg.appendChild(createSvgEl('polyline', { points: '3,5 8,11 13,5' }));
return svg;
}
/** 上向きシェブロン(最大化復帰)アイコンを生成する */
function makeIconChevronUp() {
const svg = makeSvgRoot();
svg.setAttribute('stroke-width', '2.5');
svg.appendChild(createSvgEl('polyline', { points: '3,11 8,5 13,11' }));
return svg;
}
// ================================================================
// CSS スタイル定義
// ================================================================
const CSS = `
/* ===== コンテナ ===== */
#${TOOL_ID} {
position: fixed; bottom: 0; right: 0; width: 100%; max-height: 45vh;
color: #ddd; font-size: 10px;
font-family: 'SF Mono', Menlo, Monaco, Consolas, monospace;
z-index: 2147483647; border-top: 2px solid #555;
display: flex; flex-direction: column;
background: #1e1e1e; pointer-events: none;
transition: max-height 0.2s;
}
#${TOOL_ID}.minimized { max-height: 32px; overflow: hidden; }
#${TOOL_ID} * { pointer-events: auto; box-sizing: border-box; }
/* SVG アイコンが行ボックスに浮かないようにする */
#${TOOL_ID} .tm-ctrl-btn svg { display: block; }
/* ===== ヘッダー ===== */
.tm-header {
background: #2d2d2d; padding: 0 8px;
display: flex; align-items: center; flex-shrink: 0;
border-bottom: 1px solid #444; min-height: 32px; gap: 8px;
}
.tm-tabs { display: flex; }
.tm-tab-btn {
background: transparent; color: #999; border: none;
padding: 6px 12px; font-size: 11px; cursor: pointer;
border-bottom: 2px solid transparent;
-webkit-tap-highlight-color: transparent;
}
.tm-tab-btn.active { color: #fff; border-bottom-color: #4a9eff; }
.tm-controls { display: flex; gap: 4px; margin-left: auto; }
.tm-ctrl-btn {
background: #3c3c3c; color: #ccc; border: none;
padding: 4px 9px; border-radius: 3px; font-size: 10px;
cursor: pointer; white-space: nowrap;
display: flex; align-items: center; justify-content: center;
-webkit-tap-highlight-color: transparent;
}
.tm-ctrl-btn:active { background: #505050; }
/* アクティブ状態(Inspect ON / Styles ON など) */
.tm-ctrl-btn.on { background: #0d62d1; color: #fff; }
/* ===== パネル共通 ===== */
.tm-panel { flex: 1; overflow: hidden; display: none; flex-direction: column; min-height: 0; }
.tm-panel.active { display: flex; }
/* ===== Console パネル ===== */
.tm-log-area {
flex: 1; overflow-y: auto; padding: 4px;
-webkit-overflow-scrolling: touch;
}
.tm-log-line {
border-bottom: 1px solid #2a2a2a; padding: 3px 0;
white-space: pre-wrap; word-break: break-all; line-height: 1.5;
}
.tm-log-line.type-log { color: #33cc33; }
.tm-log-line.type-warn { color: #ffaa00; background: rgba(255,170,0,0.2); }
.tm-log-line.type-error { color: #ff5555; background: rgba(255,85,85,0.2); }
.tm-log-line.type-info { color: #55aaff; }
.tm-log-time { color: #555; font-size: 8px; margin-right: 6px; }
/* Console オブジェクト / DOM ツリー */
.tm-ct-indent { margin-left: 14px; border-left: 1px dotted #383838; padding-left: 4px; }
summary.tm-ct-summary {
list-style: none; cursor: pointer; outline: none;
display: flex; align-items: flex-start;
}
summary.tm-ct-summary::-webkit-details-marker { display: none; }
summary.tm-ct-summary::before {
content: '▶'; min-width: 12px; font-size: 8px; color: #777;
transition: transform 0.1s; margin-right: 2px;
padding-top: 2px; flex-shrink: 0; display: inline-block;
}
details[open] > summary.tm-ct-summary::before { transform: rotate(90deg); }
.tm-ct-key { color: #9cdcfe; }
.tm-ct-val { color: #ce9178; }
.tm-ct-tag { color: #569cd6; }
.tm-ct-attr { color: #9cdcfe; }
.tm-ct-attrval { color: #ce9178; }
/* ===== Elements パネル ===== */
.tm-el-toolbar {
background: #252526; padding: 4px 6px;
display: flex; align-items: center; gap: 5px;
flex-shrink: 0; border-bottom: 1px solid #333;
overflow-x: auto; -webkit-overflow-scrolling: touch;
}
.tm-el-status {
color: #777; font-size: 9px; flex: 1; overflow: hidden;
text-overflow: ellipsis; white-space: nowrap; min-width: 0;
}
/* デフォルト: DOM ツリーとスタイルパネルを左右に並べる(iPad 等) */
.tm-el-body { flex: 1; display: flex; flex-direction: row; overflow: hidden; min-height: 0; }
/* 狭い画面(iPhone 等)では上下に積む */
@media (max-width: 600px) {
.tm-el-body { flex-direction: column; }
.tm-styles-pane {
width: 100% !important;
max-width: none !important;
border-left: none !important;
border-top: 1px solid #383838;
max-height: 40%;
}
}
/* DOM ツリー */
.tm-dom-tree {
flex: 1; overflow: auto; padding: 4px 2px;
-webkit-overflow-scrolling: touch; min-width: 0;
}
/* ノード行: [展開ボタン(20px)] [タグラベル] */
.tm-dn-row { display: flex; align-items: center; line-height: 1.8; }
/* タップしやすいよう 20×20px を確保した展開ボタン */
.tm-dn-expand {
flex-shrink: 0; width: 20px; height: 20px; cursor: pointer;
display: flex; align-items: center; justify-content: center;
font-size: 8px; color: #666; user-select: none;
-webkit-tap-highlight-color: transparent;
}
.tm-dn-expand.has-ch::after { content: '▶'; display: inline-block; }
.tm-dn-expand.open::after { transform: rotate(90deg); }
/* タグラベル(クリックで要素を選択する) */
.tm-dn-label {
display: inline-flex; align-items: baseline; flex-wrap: wrap;
cursor: pointer; padding: 1px 4px; border-radius: 3px;
-webkit-tap-highlight-color: transparent; user-select: none;
}
.tm-dn-label:active { background: rgba(255,255,255,0.07); }
.tm-dn-label.selected { background: #264f78; }
/* 子コンテナ・閉じタグ・テキストノード・コメント */
.tm-dn-children { margin-left: 20px; border-left: 1px dotted #383838; padding-left: 2px; }
.tm-dn-close { color: #569cd6; padding-left: 22px; line-height: 1.8; display: block; white-space: nowrap; }
.tm-dn-textnode { color: #a8c4a2; font-style: italic; padding-left: 22px; display: block; white-space: pre-wrap; word-break: break-all; }
.tm-dn-comment { color: #6a9955; padding-left: 22px; display: block; }
/* タグ構成要素の色分け */
.tm-el-tagname { color: #569cd6; }
.tm-el-attrname { color: #9cdcfe; }
.tm-el-attrval { color: #ce9178; }
.tm-el-punct { color: #888; }
.tm-el-more { color: #6a9955; font-size: 9px; }
/* ===== スタイルパネル ===== */
.tm-styles-pane {
flex-shrink: 0; width: 44%; max-width: 200px;
border-left: 1px solid #383838;
overflow-y: auto; padding: 0;
-webkit-overflow-scrolling: touch;
display: none;
}
.tm-styles-pane.visible { display: block; }
.tm-sp-section-title {
color: #666; font-size: 9px; padding: 3px 6px;
border-bottom: 1px solid #333; background: #252526;
position: sticky; top: 0;
}
.tm-sp-row { display: flex; align-items: center; padding: 1px 4px; gap: 3px; }
.tm-sp-prop {
color: #9cdcfe; font-size: 9px; white-space: nowrap;
flex-shrink: 0; min-width: 70px; max-width: 90px;
overflow: hidden; text-overflow: ellipsis;
}
/* 編集可能な値インプット */
.tm-sp-input {
background: #2a2a2a; color: #ce9178; border: 1px solid #3c3c3c;
padding: 1px 3px; font-size: 9px; border-radius: 2px;
flex: 1; min-width: 0;
font-family: 'SF Mono', Menlo, Monaco, Consolas, monospace;
}
.tm-sp-input:focus { border-color: #4a9eff; outline: none; }
/* 読み取り専用の値ラベル */
.tm-sp-readonly {
color: #777; font-size: 9px; flex: 1;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
/* 新規プロパティ追加フォーム */
.tm-sp-add-row { display: flex; gap: 3px; padding: 3px 4px; }
.tm-sp-add-row input {
background: #2a2a2a; color: #ccc; border: 1px solid #3c3c3c;
padding: 2px 4px; font-size: 9px; border-radius: 2px;
min-width: 0; flex: 1;
font-family: 'SF Mono', Menlo, Monaco, Consolas, monospace;
}
.tm-sp-add-row input:focus { border-color: #4a9eff; outline: none; }
.tm-sp-add-row input::placeholder { color: #555; }
/* ===== 選択要素ハイライトオーバーレイ ===== */
#${OVERLAY_ID} {
position: fixed; pointer-events: none; z-index: 2147483646;
border: 2px solid #4a9eff; background: rgba(74,158,255,0.1);
display: none;
}
#tm-overlay-badge {
position: absolute; top: -18px; left: 0;
background: #4a9eff; color: #fff; font-size: 9px;
padding: 1px 5px; border-radius: 2px 2px 0 0; white-space: nowrap;
font-family: 'SF Mono', Menlo, Monaco, Consolas, monospace;
}
`;
// ================================================================
// HighlightOverlay — 選択要素の上に重ねて表示するオーバーレイ
// ================================================================
class HighlightOverlay {
constructor() {
this._el = null;
this._badge = null;
this._setup();
}
_setup() {
this._el = document.createElement('div');
this._el.id = OVERLAY_ID;
this._badge = document.createElement('div');
this._badge.id = 'tm-overlay-badge';
this._el.appendChild(this._badge);
document.documentElement.appendChild(this._el);
}
/**
* 指定要素の位置にオーバーレイを表示する
* @param {HTMLElement} element
*/
show(element) {
if (!(element instanceof HTMLElement)) { this.hide(); return; }
const r = element.getBoundingClientRect();
this._badge.textContent = getElementSelector(element);
Object.assign(this._el.style, {
display: 'block',
top: `${r.top}px`,
left: `${r.left}px`,
width: `${r.width}px`,
height: `${r.height}px`,
});
}
/**
* 選択要素が変形した際などにオーバーレイ位置を再計算する
* @param {HTMLElement} element
*/
update(element) {
if (this._el.style.display !== 'none') this.show(element);
}
/** オーバーレイを非表示にする */
hide() {
this._el.style.display = 'none';
}
}
// ================================================================
// ConsolePanel — console.log 等をパネルに表示する
// ================================================================
class ConsolePanel {
constructor() {
this._logArea = null;
}
/**
* パネルの <div> 要素を組み立てて返す
* @returns {HTMLDivElement}
*/
buildElement() {
const panel = document.createElement('div');
panel.className = 'tm-panel';
panel.dataset.panel = 'console';
this._logArea = document.createElement('div');
this._logArea.className = 'tm-log-area';
panel.appendChild(this._logArea);
this._interceptConsole();
this._interceptGlobalErrors();
return panel;
}
/** ログエリアをクリアする */
clear() {
this._logArea?.replaceChildren();
}
/**
* ログ行を追加する
* @param {'log'|'warn'|'error'|'info'} type
* @param {IArguments} args
*/
print(type, args) {
if (!this._logArea) return;
const SNAP_THRESHOLD = 30;
const wasAtBottom = (this._logArea.scrollHeight - this._logArea.scrollTop - this._logArea.clientHeight) <= SNAP_THRESHOLD;
const line = document.createElement('div');
line.className = `tm-log-line type-${type}`;
const now = new Date().toLocaleTimeString([], { hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit' });
line.appendChild(createSpan(now, 'tm-log-time'));
Array.from(args).forEach(arg => {
const wrapper = document.createElement('span');
wrapper.style.cssText = 'display:inline-block; vertical-align:top; margin-right:8px;';
wrapper.appendChild(this._renderValue(arg));
line.appendChild(wrapper);
});
this._logArea.appendChild(line);
// 最大件数超過時は最古の行を削除する
if (this._logArea.children.length > MAX_LOGS) {
this._logArea.removeChild(this._logArea.firstChild);
}
// ユーザーが末尾を閲覧中なら自動スクロールする
if (wasAtBottom) {
this._logArea.scrollTop = this._logArea.scrollHeight;
}
}
// ------ プライベートメソッド ------
/**
* 任意の値を再帰的に DOM 要素としてレンダリングする
* @param {*} value
* @returns {HTMLElement}
*/
_renderValue(value) {
if (value === null) return createSpan('null', 'tm-ct-val');
if (value === undefined) return createSpan('undefined', 'tm-ct-val');
if (typeof value !== 'object') return createSpan(String(value), 'tm-ct-val');
if (value instanceof HTMLElement) return this._renderDomNode(value);
return this._renderObject(value);
}
/**
* プレーンオブジェクト / 配列を折り畳み可能なツリーとしてレンダリングする
* @param {object|Array} obj
* @returns {HTMLElement}
*/
_renderObject(obj) {
const isArray = Array.isArray(obj);
const keys = Object.keys(obj);
if (keys.length === 0) return createSpan(isArray ? '[]' : '{}', 'tm-ct-val');
const summary = document.createElement('summary');
summary.className = 'tm-ct-summary';
summary.textContent = isArray
? `Array(${obj.length})`
: `Object { ${keys.slice(0, 2).join(', ')}${keys.length > 2 ? '…' : ''} }`;
const content = document.createElement('div');
content.className = 'tm-ct-indent';
keys.forEach(key => {
const row = document.createElement('div');
row.appendChild(createSpan(`${key}: `, 'tm-ct-key'));
row.appendChild(this._renderValue(obj[key]));
content.appendChild(row);
});
const details = document.createElement('details');
details.appendChild(summary);
details.appendChild(content);
return details;
}
/**
* HTML 要素を折り畳み可能なタグツリーとしてレンダリングする
* @param {HTMLElement} el
* @returns {HTMLElement}
*/
_renderDomNode(el) {
const children = Array.from(el.childNodes).filter(n =>
n.nodeType !== Node.TEXT_NODE || n.textContent.trim()
);
// 子なし要素はタグ表示のみ
if (children.length === 0) return this._buildDomTagSpan(el);
const summary = document.createElement('summary');
summary.className = 'tm-ct-summary';
summary.appendChild(this._buildDomTagSpan(el));
const content = document.createElement('div');
content.className = 'tm-ct-indent';
children.forEach(child => {
const row = document.createElement('div');
if (child.nodeType === Node.TEXT_NODE) {
row.textContent = truncate(child.textContent.trim(), 60);
} else {
row.appendChild(this._renderValue(child));
}
content.appendChild(row);
});
const details = document.createElement('details');
details.appendChild(summary);
details.appendChild(content);
return details;
}
/**
* 要素の開きタグを <span> として組み立てる
* @param {HTMLElement} el
* @returns {HTMLSpanElement}
*/
_buildDomTagSpan(el) {
const wrap = document.createElement('span');
wrap.appendChild(createSpan('<', 'tm-ct-tag'));
wrap.appendChild(createSpan(el.tagName.toLowerCase(), 'tm-ct-tag'));
Array.from(el.attributes).forEach(a => {
wrap.appendChild(createSpan(` ${a.name}`, 'tm-ct-attr'));
if (a.value) {
wrap.appendChild(createSpan(`="${truncate(a.value, 20)}"`, 'tm-ct-attrval'));
}
});
wrap.appendChild(createSpan('>', 'tm-ct-tag'));
return wrap;
}
/** console.log / warn / error / info をフックして画面に表示する */
_interceptConsole() {
['log', 'warn', 'error', 'info'].forEach(method => {
const orig = console[method];
console[method] = (...args) => {
this.print(method, args);
orig.apply(console, args);
};
});
}
/** window.onerror / onunhandledrejection をフックする */
_interceptGlobalErrors() {
window.onerror = (msg, url, line, col) => {
this.print('error', [`Runtime Error: ${msg}\nAt: ${url}:${line}:${col}`]);
};
window.onunhandledrejection = (e) => {
this.print('error', [`Unhandled Promise Rejection: ${e.reason}`]);
};
}
}
// ================================================================
// ElementsPanel — DOM ツリー検査・要素選択・CSS スタイル編集
// ================================================================
class ElementsPanel {
/**
* @param {HighlightOverlay} highlight
*/
constructor(highlight) {
this._highlight = highlight;
this._selectedEl = null; // 現在選択中の DOM 要素
this._inspectMode = false;
this._touchStartPos = null; // Inspect タップ判定用の開始座標
// Inspect モードのイベントハンドラ参照(removeEventListener 用)
this._inspectHandlers = { touchstart: null, touchend: null, click: null };
// DOM 参照
this._treeContainer = null;
this._stylesPane = null;
this._statusEl = null;
this._inspectBtn = null;
this._stylesBtn = null;
}
/**
* パネルの <div> 要素を組み立てて返す
* @returns {HTMLDivElement}
*/
buildElement() {
const panel = document.createElement('div');
panel.className = 'tm-panel';
panel.dataset.panel = 'elements';
panel.appendChild(this._buildToolbar());
const body = document.createElement('div');
body.className = 'tm-el-body';
this._treeContainer = document.createElement('div');
this._treeContainer.className = 'tm-dom-tree';
this._stylesPane = document.createElement('div');
this._stylesPane.className = 'tm-styles-pane';
body.appendChild(this._treeContainer);
body.appendChild(this._stylesPane);
panel.appendChild(body);
return panel;
}
/** DOM ツリーを(再)描画する */
renderDomTree() {
this._treeContainer.replaceChildren();
const rootNode = this._buildNode(document.documentElement, 0);
if (rootNode) this._treeContainer.appendChild(rootNode);
this._scrollToSelected();
}
// ---------- ツールバー ----------
/**
* ツールバーを組み立てて返す
* @returns {HTMLDivElement}
*/
_buildToolbar() {
const bar = document.createElement('div');
bar.className = 'tm-el-toolbar';
this._inspectBtn = this._makeCtrlBtn(
makeIconSearch(),
'ページ上の要素をタップして選択',
() => this._toggleInspect()
);
bar.appendChild(this._inspectBtn);
bar.appendChild(this._makeCtrlBtn(
makeIconRefresh(),
'DOM ツリーを再描画',
() => this.renderDomTree()
));
this._stylesBtn = this._makeCtrlBtn(
'Styles',
'スタイルパネルの表示切替',
() => this._toggleStylesPane()
);
bar.appendChild(this._stylesBtn);
this._statusEl = document.createElement('span');
this._statusEl.className = 'tm-el-status';
this._statusEl.textContent = '要素を選択してください';
bar.appendChild(this._statusEl);
return bar;
}
/**
* コントロールボタンを生成する
* content には文字列または SVG 要素を渡す
* @param {string|SVGElement} content
* @param {string} title
* @param {Function} handler
* @returns {HTMLButtonElement}
*/
_makeCtrlBtn(content, title, handler) {
const btn = document.createElement('button');
btn.className = 'tm-ctrl-btn';
btn.title = title;
btn.onclick = handler;
if (typeof content === 'string') {
btn.textContent = content;
} else {
btn.appendChild(content);
}
return btn;
}
// ---------- DOM ノード構築 ----------
/**
* Node を再帰的に UI 要素として構築する
* @param {Node} node
* @param {number} depth
* @returns {HTMLElement|null}
*/
_buildNode(node, depth) {
if (depth > MAX_DOM_DEPTH) return null;
switch (node.nodeType) {
case Node.TEXT_NODE: return this._buildTextNode(node);
case Node.COMMENT_NODE: return this._buildCommentNode(node);
case Node.ELEMENT_NODE: return this._buildElementNode(node, depth);
default: return null;
}
}
/**
* テキストノードの表示行を生成する
* @param {Text} node
* @returns {HTMLDivElement|null}
*/
_buildTextNode(node) {
const text = node.textContent.trim();
if (!text) return null;
const div = document.createElement('div');
div.className = 'tm-dn-textnode';
div.textContent = `"${truncate(text, 80)}"`;
return div;
}
/**
* コメントノードの表示行を生成する
* @param {Comment} node
* @returns {HTMLDivElement}
*/
_buildCommentNode(node) {
const div = document.createElement('div');
div.className = 'tm-dn-comment';
div.textContent = `<!-- ${truncate(node.textContent.trim(), 60)} -->`;
return div;
}
/**
* 要素ノードの表示ブロックを生成する(展開 / 折り畳み対応)
* @param {HTMLElement} el
* @param {number} depth
* @returns {HTMLDivElement|null}
*/
_buildElementNode(el, depth) {
// このツール自体の要素はスキップして循環を防ぐ
if (el.id === TOOL_ID || el.id === OVERLAY_ID) return null;
const significantChildren = Array.from(el.childNodes).filter(n => {
if (n.id === TOOL_ID || n.id === OVERLAY_ID) return false;
if (n.nodeType === Node.TEXT_NODE) return n.textContent.trim().length > 0;
if (n.nodeType === Node.ELEMENT_NODE) return true;
return n.nodeType === Node.COMMENT_NODE;
});
const hasChildren = significantChildren.length > 0;
// ---- 行レイアウト: [展開ボタン(20px)] [タグラベル] ----
const row = document.createElement('div');
row.className = 'tm-dn-row';
const expandBtn = document.createElement('div');
expandBtn.className = `tm-dn-expand${hasChildren ? ' has-ch' : ''}`;
const label = this._buildTagLabel(el);
row.appendChild(expandBtn);
row.appendChild(label);
const wrapper = document.createElement('div');
wrapper.appendChild(row);
if (hasChildren) {
const childContainer = document.createElement('div');
childContainer.className = 'tm-dn-children';
childContainer.style.display = 'none'; // 初期状態は折り畳み
significantChildren.forEach(child => {
const childEl = this._buildNode(child, depth + 1);
if (childEl) childContainer.appendChild(childEl);
});
// 閉じタグ
const closeTag = document.createElement('span');
closeTag.className = 'tm-dn-close';
closeTag.appendChild(createSpan('</', 'tm-el-tagname'));
closeTag.appendChild(createSpan(el.tagName.toLowerCase(), 'tm-el-tagname'));
closeTag.appendChild(createSpan('>', 'tm-el-punct'));
childContainer.appendChild(closeTag);
wrapper.appendChild(childContainer);
// 展開ボタンは DOM の現在状態を読んで開閉を判断する(状態が外から変わっても正確に動く)
expandBtn.onclick = (e) => {
e.stopPropagation();
const isOpen = childContainer.style.display !== 'none';
childContainer.style.display = isOpen ? 'none' : 'block';
expandBtn.classList.toggle('open', !isOpen);
};
}
return wrapper;
}
/**
* 開きタグのクリッカブルラベルを生成する
* @param {HTMLElement} el
* @returns {HTMLDivElement}
*/
_buildTagLabel(el) {
const label = document.createElement('div');
label.className = 'tm-dn-label';
// 選択中の要素なら選択スタイルを適用する
if (el === this._selectedEl) label.classList.add('selected');
label.appendChild(createSpan('<', 'tm-el-punct'));
label.appendChild(createSpan(el.tagName.toLowerCase(), 'tm-el-tagname'));
const attrs = Array.from(el.attributes);
attrs.slice(0, MAX_ATTR_SHOW).forEach(a => {
label.appendChild(createSpan(` ${a.name}`, 'tm-el-attrname'));
if (a.value) {
label.appendChild(createSpan('="', 'tm-el-punct'));
label.appendChild(createSpan(truncate(a.value, 25), 'tm-el-attrval'));
label.appendChild(createSpan('"', 'tm-el-punct'));
}
});
if (attrs.length > MAX_ATTR_SHOW) {
label.appendChild(createSpan(` …+${attrs.length - MAX_ATTR_SHOW}`, 'tm-el-more'));
}
label.appendChild(createSpan('>', 'tm-el-punct'));
// クリックで要素を選択する
label.addEventListener('click', (e) => {
e.stopPropagation();
this._selectFromTree(el, label);
});
return label;
}
// ---------- 要素選択 ----------
/**
* DOM ツリー上のラベルクリックで要素を選択する
* @param {HTMLElement} el
* @param {HTMLDivElement} labelEl
*/
_selectFromTree(el, labelEl) {
this._clearCurrentSelection();
this._selectedEl = el;
labelEl.classList.add('selected');
this._highlight.show(el);
this._updateStatus(el);
if (this._stylesPane.classList.contains('visible')) {
this._renderStyles(el);
}
}
/**
* Inspect モード(ページタップ)で要素を選択する。
* 選択状態を反映するために DOM ツリーを再描画する。
* @param {HTMLElement} el
*/
_selectFromInspect(el) {
this._selectedEl = el;
this._highlight.show(el);
this._updateStatus(el);
this.renderDomTree(); // 選択ラベルを反映するために再描画
if (this._stylesPane.classList.contains('visible')) {
this._renderStyles(el);
}
}
/** 現在の選択状態を解除する */
_clearCurrentSelection() {
const prev = this._treeContainer.querySelector('.tm-dn-label.selected');
if (prev) prev.classList.remove('selected');
}
/** ステータスバーに要素の識別子を表示する */
_updateStatus(el) {
this._statusEl.textContent = getElementSelector(el);
}
// ---------- Inspect モード ----------
_toggleInspect() {
this._inspectMode ? this._exitInspect() : this._enterInspect();
}
/** Inspect モードを開始し、ページへのタップを待ち受ける */
_enterInspect() {
this._inspectMode = true;
this._inspectBtn.classList.add('on');
this._statusEl.textContent = '要素をタップ…';
// タップ開始座標を記録する(スクロール操作と区別するため)
this._inspectHandlers.touchstart = (e) => {
const t = e.touches[0];
this._touchStartPos = { x: t.clientX, y: t.clientY };
};
// タップ終了時に要素を選択する
this._inspectHandlers.touchend = (e) => {
const t = e.changedTouches[0];
const pos = this._touchStartPos;
if (!pos) return;
// 指が大きく動いた場合はスクロールとみなして無視する
const hasMoved = Math.abs(t.clientX - pos.x) > 10 || Math.abs(t.clientY - pos.y) > 10;
if (hasMoved) return;
// タップ座標からページ上の要素を取得する(オーバーレイは pointer-events:none なので除外される)
const target = document.elementFromPoint(t.clientX, t.clientY);
if (!target || document.getElementById(TOOL_ID)?.contains(target)) return;
e.preventDefault();
e.stopPropagation();
this._selectFromInspect(target);
this._exitInspect();
};
// タッチ非対応デバイス(PC でのプレビューなど)向けフォールバック
this._inspectHandlers.click = (e) => {
if (document.getElementById(TOOL_ID)?.contains(e.target)) return;
e.preventDefault();
e.stopPropagation();
this._selectFromInspect(e.target);
this._exitInspect();
};
document.addEventListener('touchstart', this._inspectHandlers.touchstart, { capture: true, passive: true });
document.addEventListener('touchend', this._inspectHandlers.touchend, { capture: true, passive: false });
document.addEventListener('click', this._inspectHandlers.click, { capture: true });
}
/** Inspect モードを終了してイベントリスナーを解除する */
_exitInspect() {
this._inspectMode = false;
this._inspectBtn.classList.remove('on');
document.removeEventListener('touchstart', this._inspectHandlers.touchstart, { capture: true });
document.removeEventListener('touchend', this._inspectHandlers.touchend, { capture: true });
document.removeEventListener('click', this._inspectHandlers.click, { capture: true });
this._inspectHandlers = { touchstart: null, touchend: null, click: null };
this._touchStartPos = null;
}
// ---------- スタイルパネル ----------
/** スタイルパネルの表示 / 非表示を切り替える */
_toggleStylesPane() {
const visible = this._stylesPane.classList.toggle('visible');
this._stylesBtn.classList.toggle('on', visible);
if (visible && this._selectedEl) this._renderStyles(this._selectedEl);
}
/**
* 選択要素のスタイルをスタイルパネルに描画する
* @param {HTMLElement} el
*/
_renderStyles(el) {
this._stylesPane.replaceChildren();
this._appendInlineStyleSection(el);
this._appendComputedStyleSection(el);
}
/**
* 編集可能なインラインスタイルセクションを追加する
* @param {HTMLElement} el
*/
_appendInlineStyleSection(el) {
this._stylesPane.appendChild(this._makeSectionTitle('element.style'));
const entries = this._getInlineStyleEntries(el);
if (entries.length === 0) {
this._stylesPane.appendChild(this._makeEmptyLabel());
} else {
entries.forEach(([prop, val]) => {
this._stylesPane.appendChild(this._makeEditableRow(prop, val, el));
});
}
// 新規プロパティ追加フォームを末尾に配置する
this._stylesPane.appendChild(this._makeAddStyleRow(el));
}
/**
* 読み取り専用の Computed スタイルセクションを追加する
* @param {HTMLElement} el
*/
_appendComputedStyleSection(el) {
this._stylesPane.appendChild(this._makeSectionTitle('Computed'));
const IMPORTANT_PROPS = [
'display', 'position', 'width', 'height', 'margin', 'padding',
'color', 'background-color', 'font-size', 'font-weight', 'line-height',
'border', 'border-radius', 'opacity', 'overflow',
'flex', 'z-index', 'top', 'left', 'right', 'bottom',
'transform', 'box-shadow', 'text-align', 'cursor',
];
const computed = window.getComputedStyle(el);
const entries = IMPORTANT_PROPS
.map(p => [p, computed.getPropertyValue(p)])
.filter(([, v]) => v !== '');
if (entries.length === 0) {
this._stylesPane.appendChild(this._makeEmptyLabel());
} else {
entries.forEach(([prop, val]) => {
this._stylesPane.appendChild(this._makeReadonlyRow(prop, val));
});
}
}
/**
* 要素のインラインスタイルを [prop, val] のペア配列として返す
* @param {HTMLElement} el
* @returns {Array<[string, string]>}
*/
_getInlineStyleEntries(el) {
const entries = [];
for (let i = 0; i < el.style.length; i++) {
const p = el.style[i];
entries.push([p, el.style.getPropertyValue(p)]);
}
return entries;
}
/**
* セクションタイトル要素を生成する
* @param {string} text
* @returns {HTMLDivElement}
*/
_makeSectionTitle(text) {
const el = document.createElement('div');
el.className = 'tm-sp-section-title';
el.textContent = text;
return el;
}
/** 「(なし)」ラベルを生成する */
_makeEmptyLabel() {
const el = document.createElement('div');
el.style.cssText = 'color:#555; font-size:9px; padding:2px 6px;';
el.textContent = '(なし)';
return el;
}
/**
* 編集可能なスタイル行を生成する(インラインスタイル用)
* @param {string} prop
* @param {string} val
* @param {HTMLElement} targetEl
* @returns {HTMLDivElement}
*/
_makeEditableRow(prop, val, targetEl) {
const row = document.createElement('div');
row.className = 'tm-sp-row';
const propEl = document.createElement('div');
propEl.className = 'tm-sp-prop';
propEl.textContent = prop;
propEl.title = prop;
row.appendChild(propEl);
const input = document.createElement('input');
input.type = 'text';
input.className = 'tm-sp-input';
input.value = val;
input.addEventListener('change', () => {
try {
targetEl.style.setProperty(prop, input.value);
this._highlight.update(targetEl);
} catch {
// 無効な値は静かに無視する
}
});
row.appendChild(input);
return row;
}
/**
* 読み取り専用のスタイル行を生成する(Computed スタイル用)
* @param {string} prop
* @param {string} val
* @returns {HTMLDivElement}
*/
_makeReadonlyRow(prop, val) {
const row = document.createElement('div');
row.className = 'tm-sp-row';
const propEl = document.createElement('div');
propEl.className = 'tm-sp-prop';
propEl.textContent = prop;
propEl.title = prop;
const valEl = document.createElement('div');
valEl.className = 'tm-sp-readonly';
valEl.textContent = val;
valEl.title = val;
row.appendChild(propEl);
row.appendChild(valEl);
return row;
}
/**
* 新規インラインスタイルを追加するフォーム行を生成する。
* 追加したプロパティには自動で !important を付与する。
* @param {HTMLElement} targetEl
* @returns {HTMLDivElement}
*/
_makeAddStyleRow(targetEl) {
const row = document.createElement('div');
row.className = 'tm-sp-add-row';
const propInput = document.createElement('input');
propInput.type = 'text';
propInput.placeholder = 'property';
const valInput = document.createElement('input');
valInput.type = 'text';
valInput.placeholder = 'value';
const addBtn = document.createElement('button');
addBtn.className = 'tm-ctrl-btn';
addBtn.textContent = '+';
addBtn.title = '追加(!important を自動付与)';
addBtn.onclick = () => {
const p = propInput.value.trim();
const v = valInput.value.trim();
if (!p) return;
try {
// 第 3 引数 'important' が CSS !important に相当する
targetEl.style.setProperty(p, v, 'important');
this._highlight.update(targetEl);
this._renderStyles(targetEl); // 追加後に再描画して反映する
} catch {
// 無効な値は静かに無視する
}
};
row.appendChild(propInput);
row.appendChild(valInput);
row.appendChild(addBtn);
return row;
}
// ---------- ユーティリティ ----------
/**
* 選択中のノードが見えるよう祖先を全て展開してスクロールする
*/
_scrollToSelected() {
const selected = this._treeContainer.querySelector('.tm-dn-label.selected');
if (!selected) return;
// 選択ノードの祖先にある .tm-dn-children を全て展開する
let el = selected.parentElement;
while (el && el !== this._treeContainer) {
if (el.classList.contains('tm-dn-children')) {
el.style.display = 'block';
// 対応する展開ボタンを open 状態にする
const expandBtn = el.previousElementSibling?.querySelector('.tm-dn-expand');
if (expandBtn) expandBtn.classList.add('open');
}
el = el.parentElement;
}
// DOM 更新後にスクロールする
requestAnimationFrame(() => {
selected.scrollIntoView({ block: 'center', behavior: 'smooth' });
});
}
}
// ================================================================
// DevToolsUI — 全体の UI オーケストレーション
// ================================================================
class DevToolsUI {
constructor() {
this._isMinimized = false;
this._activePanel = 'elements';
this._console = null;
this._elements = null;
this._highlight = null;
this._container = null;
this._minBtn = null; // 最小化ボタンへの参照(アイコン更新用)
this._init();
}
_init() {
// 二重起動を防ぐ
if (document.getElementById(TOOL_ID)) return;
this._injectStyles();
this._highlight = new HighlightOverlay();
this._buildUI();
}
/** CSS をページに注入する */
_injectStyles() {
const style = document.createElement('style');
style.textContent = CSS;
document.head.appendChild(style);
}
/** ルートコンテナを組み立てて <html> 直下に追加する */
_buildUI() {
this._container = document.createElement('div');
this._container.id = TOOL_ID;
this._container.appendChild(this._buildHeader());
this._console = new ConsolePanel();
this._elements = new ElementsPanel(this._highlight);
this._container.appendChild(this._console.buildElement());
this._container.appendChild(this._elements.buildElement());
document.documentElement.appendChild(this._container);
// 初期パネルは Elements
this._activatePanel('elements');
}
/**
* ヘッダー(タブボタン + コントロールボタン)を組み立てて返す
* @returns {HTMLDivElement}
*/
_buildHeader() {
const header = document.createElement('div');
header.className = 'tm-header';
// タブボタン群
const tabs = document.createElement('div');
tabs.className = 'tm-tabs';
[
{ label: 'Elements', panel: 'elements' },
{ label: 'Console', panel: 'console' },
].forEach(({ label, panel }) => {
const btn = document.createElement('button');
btn.className = 'tm-tab-btn';
btn.dataset.tab = panel;
btn.textContent = label;
btn.onclick = () => this._activatePanel(panel);
tabs.appendChild(btn);
});
header.appendChild(tabs);
// コントロールボタン群
const controls = document.createElement('div');
controls.className = 'tm-controls';
const clearBtn = document.createElement('button');
clearBtn.className = 'tm-ctrl-btn';
clearBtn.textContent = 'Clear';
clearBtn.onclick = () => this._handleClear();
controls.appendChild(clearBtn);
// 最小化ボタン: 初期は下向きシェブロン(パネルが開いている状態)
this._minBtn = document.createElement('button');
this._minBtn.className = 'tm-ctrl-btn';
this._minBtn.title = '最小化 / 最大化';
this._minBtn.appendChild(makeIconChevronDown());
this._minBtn.onclick = () => this._toggleMinimize();
controls.appendChild(this._minBtn);
header.appendChild(controls);
return header;
}
/**
* 指定パネルをアクティブにして表示を切り替える
* @param {'console'|'elements'} panelName
*/
_activatePanel(panelName) {
this._activePanel = panelName;
// タブボタンのアクティブ状態を更新する
this._container.querySelectorAll('.tm-tab-btn').forEach(btn => {
btn.classList.toggle('active', btn.dataset.tab === panelName);
});
// パネルの表示 / 非表示を切り替える
this._container.querySelectorAll('.tm-panel').forEach(panel => {
panel.classList.toggle('active', panel.dataset.panel === panelName);
});
// Elements パネルに切り替えた際に DOM ツリーを描画する
if (panelName === 'elements') {
this._elements.renderDomTree();
}
}
/** アクティブなパネルに応じた Clear 処理を実行する */
_handleClear() {
if (this._activePanel === 'console') {
this._console.clear();
} else {
this._elements.renderDomTree();
}
}
/**
* パネルを最小化 / 最大化し、ボタンアイコンを切り替える
*/
_toggleMinimize() {
this._isMinimized = !this._isMinimized;
this._container.classList.toggle('minimized', this._isMinimized);
// 状態に応じてアイコンを入れ替える
this._minBtn.replaceChildren(
this._isMinimized ? makeIconChevronUp() : makeIconChevronDown()
);
if (this._isMinimized) this._highlight.hide();
}
}
// ================================================================
// エントリーポイント
// ================================================================
function bootstrap() {
new DevToolsUI();
}
if (document.readyState === 'complete' || document.readyState === 'interactive') {
bootstrap();
} else {
window.addEventListener('DOMContentLoaded', bootstrap);
}
})();