DevTools Emulator — Console & Elements

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

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==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);
    }

})();