Click Modules in the toolbar to open the module manager. Modules are plain JavaScript objects that can inject buttons, styles, and logic into the viewer at runtime.
| Module | Description |
|---|---|
| Show Levels | Adds a levels input to the toolbar. Enter depth levels (e.g. 0-2,4) and press Enter or click the button to show only nodes at those depths; ancestors are dimmed. |
| Schema Folding | Adds an Apply Folding ▶ button to the Schema panel header. Clicking it copies the Schema panel’s expand/fold state into the Data panel. |
| Protobuf Converter | Adds a Protobuf button to the toolbar. Opens a two-pane editor: paste text proto on the left, click Convert (or Ctrl/⌘+Enter) to see the JSON on the right, then Copy JSON and paste it into the viewer. |
Instead of hand-writing a module, you can ask an AI coding agent (e.g. Claude Code) to do it. This repo ships a skill that teaches the agent the module API, conventions, and constraints:
Add it to your agent as a skill — for example by pointing the agent at the repo file, or fetching the raw URL into your local skills folder — then describe the module you want. The agent will follow the skill and produce a copy-pasteable module object you can drop into the Modules popup.
A module is a JS object literal returned from a self-contained expression:
({
name: 'My Module',
description: 'What it does',
// Functions exported to moduleShared — callable by other modules
functions: {
myHelper() { /* ... */ }
},
// CSS injected while the module is enabled
styles: `
.my-button { color: var(--accent); }
`,
// Buttons injected into slots
// Slots: toolbar, schema.header.left, schema.header.right, data.header.left, data.header.right
buttons: {
'data.header.right': `<button id="myBtn">Do thing</button>`
},
// Called when the module is enabled; wire up event listeners here
init(schemaDom, dataDom, shared) {
this._handler = () => shared.myHelper();
document.getElementById('myBtn')?.addEventListener('click', this._handler);
},
// Called when the module is disabled; clean up listeners here
destroy() {
document.getElementById('myBtn')?.removeEventListener('click', this._handler);
}
})
The shared object (moduleShared) provides:
shared.schemaNodes — Map<path, node> for the Schema treeshared.dataNodes — Map<path, node> for the Data treeshared.activeLevels — Set<number> | null — the currently active depth levels (read/write)shared.setAllExpanded(nodes, expanded) — expand or collapse all nodes in a tree and clears activeLevelsshared.dataPathToSchemaPath(path) — converts a data path (e.g. $[0].name) to its schema path ($[].name)shared.isAncestorPath(parent, child) — path ancestry checkshared.postLoad — array of callbacks invoked at the start of each loadJson callfunctions exported by other registered modules| Action | Effect |
|---|---|
| Add | Creates a blank module in disabled state; its inline editor opens automatically |
| Edit / Apply | Hot-reloads the module code without a page refresh |
| Enable / Disable | Toggle via the switch; injected buttons and styles are added/removed accordingly |
| Remove | Destroys the module and removes all its contributions |
Schema Folding adds an Apply Folding ▶ button to the Schema panel header. Clicking it copies the Schema panel’s expand/fold state into the Data panel.
({
name: 'Schema Folding',
description: 'Apply Folding copies the Schema panel expand/fold state into the Data panel',
functions: {
applySchemaFoldingToData() {
this.dataNodes.forEach(function(dn, dataPath) {
if (!dn.childrenEl) return;
var sn = this.schemaNodes.get(this.dataPathToSchemaPath(dataPath));
if (!sn?.childrenEl) return;
var folded = sn.childrenEl.classList.contains('hidden');
this.setFolded(dn, folded);
}.bind(this));
},
},
styles: `
.btn-apply-schema-folding {
padding: 3px 9px;
font-size: 11px;
letter-spacing: 0.3px;
text-transform: none;
border-color: var(--accent-border);
color: var(--accent);
background: var(--accent-dim);
}
.btn-apply-schema-folding:hover { background: var(--accent-hover); }
`,
buttons: {
'schema.header.left': `
<button class="btn-apply-schema-folding" id="btnApplySchemaFolding"
title="Copy the Schema panel's expand/fold state into the Data panel">Apply Folding ▶</button>
`
},
init(schemaDom, dataDom, shared) {
this._handler = () => shared.applySchemaFoldingToData();
document.getElementById('btnApplySchemaFolding')?.addEventListener('click', this._handler);
},
destroy() {
document.getElementById('btnApplySchemaFolding')?.removeEventListener('click', this._handler);
}
})
Show Levels adds a levels input to the toolbar. Enter depth levels (e.g. 0-2,4) and press Enter or click levels to show only nodes at those depths; ancestor nodes are dimmed.
({
name: 'Show Levels',
description: 'Filter both panels to show only nodes at specified depth levels (e.g. 0-2,4)',
functions: {
parseLevels(input) {
var levels = new Set();
input.split(',').forEach(function(part) {
var trimmed = part.trim();
if (!trimmed) return;
var range = trimmed.split('-').map(function(s) { return parseInt(s.trim(), 10); });
if (range.length === 1 && !isNaN(range[0])) {
levels.add(range[0]);
} else if (range.length === 2 && !isNaN(range[0]) && !isNaN(range[1])) {
for (var i = range[0]; i <= range[1]; i++) levels.add(i);
}
});
return levels;
},
applyLevels(nodes, levels) {
this.activeLevels = levels;
var activePaths = new Set();
nodes.forEach(function(n, path) { if (levels.has(n.depth)) activePaths.add(path); });
var ancestorPaths = new Set();
nodes.forEach(function(n, path) {
if (!n.childrenEl) return;
for (var ap of activePaths) {
if (this.isAncestorPath(path, ap)) { ancestorPaths.add(path); break; }
}
}.bind(this));
nodes.forEach(function(n, path) {
var isActive = activePaths.has(path);
var isAncestor = ancestorPaths.has(path);
this.setDimmed(n, isAncestor && !isActive);
if (n.childrenEl) this.setFolded(n, !isAncestor);
var wrapper = n.el.parentElement;
if (wrapper?.classList.contains('node')) wrapper.style.display = (isActive || isAncestor) ? '' : 'none';
}.bind(this));
},
},
styles: `
.level-group {
display: flex;
align-items: stretch;
}
.level-input {
font-family: var(--mono);
font-size: 12px;
width: 100px;
padding: 6px 10px;
border: 1px solid var(--border);
border-right: none;
border-radius: var(--radius) 0 0 var(--radius);
background: var(--surface2);
color: var(--text);
outline: none;
transition: border-color 0.15s;
}
.level-input:focus { border-color: var(--accent-border); }
.level-input::placeholder { color: var(--text-dim); }
.btn-levels {
border-radius: 0 var(--radius) var(--radius) 0;
border-left: none;
}
.level-group:focus-within .level-input,
.level-group:focus-within .btn-levels { border-color: var(--accent-border); }
`,
buttons: {
'toolbar': `
<div class="level-group">
<input class="level-input" id="levelInput" placeholder="e.g. 0-2,4">
<button id="btnLevels" class="btn-levels">levels</button>
</div>
`
},
init(schemaDom, dataDom, shared) {
this._shared = shared;
this._input = document.getElementById('levelInput');
this._btn = document.getElementById('btnLevels');
this._click = () => {
var levels = shared.parseLevels(this._input.value);
if (levels.size === 0) return;
shared.applyLevels(shared.schemaNodes, levels);
shared.applyLevels(shared.dataNodes, levels);
};
this._keydown = (e) => { if (e.key === 'Enter') this._btn.click(); };
this._btn.addEventListener('click', this._click);
this._input.addEventListener('keydown', this._keydown);
this._postLoadFn = () => { this._input.value = ''; };
shared.postLoad.push(this._postLoadFn);
},
destroy() {
this._btn?.removeEventListener('click', this._click);
this._input?.removeEventListener('keydown', this._keydown);
var arr = this._shared?.postLoad;
if (arr) { var i = arr.indexOf(this._postLoadFn); if (i !== -1) arr.splice(i, 1); }
delete this._shared; delete this._input; delete this._btn;
},
})
Protobuf Converter adds a Protobuf button to the toolbar. Clicking it opens a two-pane editor:
The built-in parser handles nested messages ({} and <> syntax), repeated fields (converted to arrays), quoted strings (including concatenation), booleans, numbers, null, Inf/NaN, extension fields ([package.Field]), and # comments.
({
name: 'Protobuf Converter',
description: 'Edit text proto, convert to JSON, and copy it to load into the viewer.',
functions: {},
styles: `
#pb-overlay {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.55);
z-index: 9999;
display: flex;
align-items: center;
justify-content: center;
}
#pb-modal {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
width: min(820px, 94vw);
height: min(600px, 90vh);
display: flex;
flex-direction: column;
box-shadow: 0 8px 40px rgba(0,0,0,0.45);
overflow: hidden;
}
#pb-titlebar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 9px 14px;
border-bottom: 1px solid var(--border);
background: var(--surface2);
flex-shrink: 0;
}
#pb-titlebar span {
font-family: var(--sans);
font-size: 13px;
font-weight: 600;
color: var(--text);
}
#pb-close {
background: none;
border: none;
cursor: pointer;
color: var(--text-dim);
font-size: 18px;
line-height: 1;
padding: 2px 6px;
border-radius: var(--radius);
}
#pb-close:hover { background: var(--bg); color: var(--text); }
#pb-body {
display: flex;
flex: 1;
min-height: 0;
}
.pb-pane {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
}
.pb-pane + .pb-pane {
border-left: 1px solid var(--border);
}
.pb-pane-label {
font-family: var(--sans);
font-size: 11px;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--text-dim);
padding: 6px 12px 5px;
border-bottom: 1px solid var(--border);
background: var(--surface2);
flex-shrink: 0;
}
.pb-pane textarea {
flex: 1;
resize: none;
border: none;
outline: none;
padding: 12px 14px;
font-family: var(--mono);
font-size: 12px;
background: var(--bg);
color: var(--text);
line-height: 1.65;
tab-size: 2;
min-height: 0;
}
#pb-json-out {
color: var(--type-obj);
}
#pb-json-out.pb-has-error {
color: var(--type-bool);
}
#pb-footer {
display: flex;
align-items: center;
gap: 8px;
padding: 9px 14px;
border-top: 1px solid var(--border);
background: var(--surface2);
flex-shrink: 0;
}
#pb-footer-hint {
flex: 1;
font-family: var(--sans);
font-size: 11.5px;
color: var(--text-dim);
}
.pb-btn {
font-family: var(--sans);
font-size: 12px;
font-weight: 600;
border-radius: var(--radius);
border: 1px solid var(--border);
cursor: pointer;
padding: 5px 14px;
background: var(--surface);
color: var(--text);
transition: background 0.12s;
white-space: nowrap;
}
.pb-btn:hover { background: var(--bg); }
.pb-btn:disabled { opacity: 0.4; cursor: default; }
.pb-btn--accent {
background: var(--accent);
border-color: var(--accent-border);
color: #fff;
}
.pb-btn--accent:hover { background: var(--accent-hover); }
.pb-btn--accent:disabled { background: var(--accent-dim); }
#pb-copy-flash {
font-family: var(--sans);
font-size: 11.5px;
color: var(--type-obj);
opacity: 0;
transition: opacity 0.2s;
pointer-events: none;
}
#pb-copy-flash.visible { opacity: 1; }
.pb-editor-wrap {
flex: 1;
display: flex;
overflow: hidden;
min-height: 0;
}
.pb-line-nums {
padding: 12px 6px 12px 0;
width: 40px;
flex-shrink: 0;
text-align: right;
font-family: var(--mono);
font-size: 12px;
line-height: 1.65;
color: var(--text-dim);
background: var(--surface2);
border-right: 1px solid var(--border);
overflow: hidden;
user-select: none;
white-space: pre;
}
`,
buttons: {
toolbar: `<button id="pb-open-btn" class="btn" title="Open Protobuf → JSON converter">Protobuf</button>`
},
init(schemaDom, dataDom, shared) {
this._shared = shared;
this._openBtn = document.getElementById('pb-open-btn');
this._onOpen = () => this._openModal();
this._openBtn?.addEventListener('click', this._onOpen);
},
destroy() {
this._openBtn?.removeEventListener('click', this._onOpen);
this._closeModal();
},
_openModal() {
if (document.getElementById('pb-overlay')) return;
const overlay = document.createElement('div');
overlay.id = 'pb-overlay';
overlay.innerHTML = `
<div id="pb-modal" role="dialog" aria-modal="true" aria-label="Protobuf converter">
<div id="pb-titlebar">
<span>Protobuf → JSON Converter</span>
<button id="pb-close" title="Close (Esc)">✕</button>
</div>
<div id="pb-body">
<div class="pb-pane">
<div class="pb-pane-label">Text Proto</div>
<div class="pb-editor-wrap">
<div class="pb-line-nums" id="pb-proto-lines">1</div>
<textarea id="pb-proto-in" spellcheck="false" placeholder="Paste your text proto here…"></textarea>
</div>
</div>
<div class="pb-pane">
<div class="pb-pane-label">JSON Output</div>
<div class="pb-editor-wrap">
<div class="pb-line-nums" id="pb-json-lines">1</div>
<textarea id="pb-json-out" spellcheck="false" readonly placeholder="Converted JSON will appear here…"></textarea>
</div>
</div>
</div>
<div id="pb-footer">
<span id="pb-footer-hint">Edit proto on the left, then click Convert (or Ctrl/⌘+Enter).</span>
<span id="pb-copy-flash">Copied!</span>
<button class="pb-btn" id="pb-close-btn">Close</button>
<button class="pb-btn" id="pb-copy-btn" disabled>Copy JSON</button>
<button class="pb-btn pb-btn--accent" id="pb-convert-btn">Convert</button>
</div>
</div>
`;
document.body.appendChild(overlay);
this._overlay = overlay;
const protoIn = document.getElementById('pb-proto-in');
const jsonOut = document.getElementById('pb-json-out');
const copyBtn = document.getElementById('pb-copy-btn');
const flash = document.getElementById('pb-copy-flash');
const hint = document.getElementById('pb-footer-hint');
const protoLines = document.getElementById('pb-proto-lines');
const jsonLines = document.getElementById('pb-json-lines');
const updateLines = (ta, el) => {
const n = (ta.value.match(/\n/g)?.length ?? 0) + 1;
el.textContent = Array.from({length: n}, (_, i) => i + 1).join('\n');
};
if (this._savedProto) { protoIn.value = this._savedProto; updateLines(protoIn, protoLines); }
protoIn.focus();
protoIn.addEventListener('input', () => updateLines(protoIn, protoLines));
protoIn.addEventListener('scroll', () => { protoLines.scrollTop = protoIn.scrollTop; });
jsonOut.addEventListener('scroll', () => { jsonLines.scrollTop = jsonOut.scrollTop; });
const doConvert = () => {
this._savedProto = protoIn.value;
jsonOut.classList.remove('pb-has-error');
try {
const obj = this._parseTextProto(protoIn.value);
const json = JSON.stringify(obj, null, 2);
jsonOut.value = json;
updateLines(jsonOut, jsonLines);
copyBtn.disabled = false;
hint.textContent = 'Conversion successful. Copy JSON and paste into the viewer.';
} catch (e) {
jsonOut.classList.add('pb-has-error');
jsonOut.value = 'Error: ' + (e.message ?? String(e));
updateLines(jsonOut, jsonLines);
copyBtn.disabled = true;
hint.textContent = 'Fix the error on the left and try again.';
}
};
const doCopy = () => {
const text = jsonOut.value;
if (!text || copyBtn.disabled) return;
navigator.clipboard?.writeText(text).catch(() => {
jsonOut.select();
document.execCommand('copy');
});
flash.classList.add('visible');
if (this._flashTimer) clearTimeout(this._flashTimer);
this._flashTimer = setTimeout(() => flash.classList.remove('visible'), 1800);
};
const doClose = () => this._closeModal();
this._onConvert = doConvert;
this._onCopy = doCopy;
this._onClose = doClose;
this._onOverlay = (e) => { if (e.target === overlay) doClose(); };
this._onKeyDown = (e) => {
if (e.key === 'Escape') { doClose(); return; }
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') { doConvert(); }
};
document.getElementById('pb-convert-btn').addEventListener('click', this._onConvert);
copyBtn.addEventListener('click', this._onCopy);
document.getElementById('pb-close-btn').addEventListener('click', this._onClose);
document.getElementById('pb-close').addEventListener('click', this._onClose);
overlay.addEventListener('click', this._onOverlay);
document.addEventListener('keydown', this._onKeyDown);
},
_closeModal() {
const protoIn = document.getElementById('pb-proto-in');
if (protoIn) this._savedProto = protoIn.value;
if (this._flashTimer) { clearTimeout(this._flashTimer); this._flashTimer = null; }
if (this._onKeyDown) { document.removeEventListener('keydown', this._onKeyDown); this._onKeyDown = null; }
this._overlay?.remove();
this._overlay = null;
this._onConvert = this._onCopy = this._onClose = this._onOverlay = null;
},
// ── Text Proto parser ─────────────────────────────────────────────────
_parseTextProto(src) {
const { tokens, lines } = this._tokenize(src);
const p = { tokens, lines, pos: 0 };
const obj = this._parseBody(p, true);
if (p.pos < p.tokens.length) {
throw this._err(p, p.pos,
'Unexpected token: ' + p.tokens.slice(p.pos, p.pos + 3).join(' '));
}
return obj;
},
_tokenize(src) {
const DOUBLE_QUOTED_STRING = /"(?:[^"\\]|\\.)*"/;
const SINGLE_QUOTED_STRING = /'(?:[^'\\]|\\.)*'/;
const EXTENSION_NAME = /\[[\w.\s/]+\]/;
const PUNCTUATION = /[{}:<>\[\],;]/;
const WORD = /[^\s{}:<>\[\],;"']+/;
const COMMENT = /#[^\n]*/g;
const TOKEN = new RegExp(
[
DOUBLE_QUOTED_STRING.source,
SINGLE_QUOTED_STRING.source,
EXTENSION_NAME.source,
PUNCTUATION.source,
WORD.source,
].join('|'),
'g'
);
const stripped = src.replace(COMMENT, '');
const tokens = [];
const lines = [];
let scanned = 0;
let line = 1;
let m;
while ((m = TOKEN.exec(stripped)) !== null) {
const gap = stripped.slice(scanned, m.index);
// If the lexer skips non-whitespace characters, it means invalid syntax (e.g. unclosed string)
if (gap.trim().length > 0) {
const badCharIndex = gap.search(/\S/);
const beforeBad = gap.slice(0, badCharIndex);
const nl = beforeBad.match(/\n/g);
if (nl) line += nl.length;
throw new Error('[line ' + line + '] Invalid syntax or unclosed string near "' + gap.trim()[0] + '"');
}
// Count newlines in the whitespace gap before this token
const gapNl = gap.match(/\n/g);
if (gapNl) line += gapNl.length;
tokens.push(m[0]);
lines.push(line);
// Advance line for newlines INSIDE the matched token, so the next token's line is correct
const tokNl = m[0].match(/\n/g);
if (tokNl) line += tokNl.length;
scanned = m.index + m[0].length;
}
// Check for trailing invalid characters after the last valid token
const tail = stripped.slice(scanned);
if (tail.trim().length > 0) {
const badCharIndex = tail.search(/\S/);
const beforeBad = tail.slice(0, badCharIndex);
const nl = beforeBad.match(/\n/g);
if (nl) line += nl.length;
throw new Error('[line ' + line + '] Invalid syntax near "' + tail.trim()[0] + '"');
}
return { tokens, lines };
},
_lineAt(p, pos) {
if (pos < p.lines.length) return p.lines[pos];
return p.lines[p.lines.length - 1] ?? 1;
},
_err(p, pos, msg) {
return new Error('[line ' + this._lineAt(p, pos) + '] ' + msg);
},
_parseBody(p, topLevel) {
const obj = {};
while (p.pos < p.tokens.length) {
const t = p.tokens[p.pos];
if (!topLevel && (t === '}' || t === '>')) break;
const keyPos = p.pos;
const raw = p.tokens[p.pos++];
let key = raw;
// Handle extension fields or reject stray punctuation
if (raw[0] === '[' && raw[raw.length - 1] === ']') {
key = raw.slice(1, -1).trim();
} else if (/[{}:<>\[\],;]/.test(raw)) {
throw this._err(p, keyPos, 'Expected field name but got "' + raw + '"');
}
if (p.tokens[p.pos] === ':') p.pos++;
if (p.pos >= p.tokens.length) {
throw this._err(p, keyPos, 'Unexpected end of input after field "' + key + '"');
}
const val = this._parseValue(p);
if (Object.prototype.hasOwnProperty.call(obj, key)) {
if (!Array.isArray(obj[key])) obj[key] = [obj[key]];
obj[key].push(val);
} else {
obj[key] = val;
}
if (p.tokens[p.pos] === ',' || p.tokens[p.pos] === ';') p.pos++;
}
return obj;
},
_parseValue(p) {
const t = p.tokens[p.pos];
if (t === '{' || t === '<') {
p.pos++;
const close = t === '{' ? '}' : '>';
const msg = this._parseBody(p, false);
if (p.tokens[p.pos] !== close) {
throw this._err(p, p.pos,
'Expected "' + close + '" but got "' + (p.tokens[p.pos] ?? '<eof>') + '"');
}
p.pos++;
return msg;
}
if (t === '[') {
p.pos++;
const arr = [];
while (p.pos < p.tokens.length && p.tokens[p.pos] !== ']') {
arr.push(this._parseValue(p));
if (p.tokens[p.pos] === ',') p.pos++;
}
if (p.tokens[p.pos] !== ']') {
throw this._err(p, p.pos, 'Unterminated array');
}
p.pos++;
return arr;
}
return this._parseScalar(p);
},
_parseScalar(p) {
const startPos = p.pos;
const t = p.tokens[p.pos++];
if (t === undefined) throw this._err(p, startPos, 'Unexpected end of input');
if (this._isQuoted(t)) {
let s = this._unescape(t.slice(1, -1));
while (p.pos < p.tokens.length && this._isQuoted(p.tokens[p.pos])) {
s += this._unescape(p.tokens[p.pos].slice(1, -1));
p.pos++;
}
return s;
}
const lower = t.toLowerCase();
if (lower === 'true' || t === 't') return true;
if (lower === 'false' || t === 'f') return false;
if (lower === 'null') return null;
if (lower === 'inf' || lower === 'infinity') return Infinity;
if (lower === '-inf' || lower === '-infinity') return -Infinity;
if (lower === '+inf' || lower === '+infinity') return Infinity;
if (lower === 'nan') return NaN;
if (/^[-+]?(?:\d|\.\d)/.test(t)) {
const n = Number(t);
if (!isNaN(n)) return n;
const m = t.match(/^([-+]?(?:\d+\.?\d*|\.\d+)(?:[eE][-+]?\d+)?)/);
if (m) {
const n2 = Number(m[1]);
if (!isNaN(n2)) return n2;
}
}
return t;
},
_isQuoted(t) {
if (!t || t.length < 2) return false;
const a = t[0], b = t[t.length - 1];
return (a === '"' && b === '"') || (a === "'" && b === "'");
},
_unescape(s) {
return s.replace(/\\(x[0-9a-fA-F]{1,2}|u[0-9a-fA-F]{4}|[0-7]{1,3}|[^])/g, (_, c) => {
if (c[0] === 'x') return String.fromCharCode(parseInt(c.slice(1), 16));
if (c[0] === 'u') return String.fromCharCode(parseInt(c.slice(1), 16));
if (/^[0-7]+$/.test(c)) return String.fromCharCode(parseInt(c, 8));
switch (c) {
case 'n': return '\n';
case 't': return '\t';
case 'r': return '\r';
case 'a': return '\x07';
case 'b': return '\b';
case 'f': return '\f';
case 'v': return '\v';
case '\\': return '\\';
case "'": return "'";
case '"': return '"';
default: return c;
}
});
}
})
Highlight module lets users to highlight by clicking.
Buttons:
Highlights are automatically cleared on every new loadJson call.
({
name: 'Highlight',
description: 'Highlight schema nodes (purple) and data nodes (yellow), with cross-panel mirroring and expand helpers',
styles: `
:root {
--clear-hl-color: rgba(252,165,165,0.3);
--clear-hl-dim: rgba(252,165,165,0.08);
--clear-hl-hover: rgba(252,165,165,0.18);
--hl-schema: #a78bfa;
--hl-schema-dim: rgba(167,139,250,0.1);
--hl-schema-hover: rgba(167,139,250,0.18);
--hl-schema-border: rgba(167,139,250,0.35);
--highlight: #fbbf24;
--highlight-dim: rgba(251,191,36,0.08);
--highlight-hover: rgba(251,191,36,0.18);
--highlight-border: rgba(251,191,36,0.3);
}
.btn-clear-hl {
padding: 3px 7px;
font-size: 10px;
letter-spacing: 0.3px;
text-transform: none;
border-color: var(--clear-hl-color);
color: var(--type-bool);
background: var(--clear-hl-dim);
}
.btn-clear-hl:hover { background: var(--clear-hl-hover); }
.hl-label {
font-family: var(--mono);
font-size: 10px;
font-weight: 600;
letter-spacing: 0.5px;
user-select: none;
padding: 0 2px;
}
.btn-hl-icon {
padding: 3px 7px;
font-size: 10px;
letter-spacing: 0.3px;
text-transform: none;
border-color: var(--hl-schema-border);
color: var(--hl-schema);
background: var(--hl-schema-dim);
}
.btn-hl-icon:hover { background: var(--hl-schema-hover); }
.hl-label-schema { color: var(--hl-schema); }
.node-row.highlighted-schema {
background: var(--hl-schema-dim);
outline: 1px solid var(--hl-schema-border);
outline-offset: -1px;
}
.node-row.highlighted.highlighted-schema {
background: linear-gradient(to right, var(--hl-schema-dim) 50%, var(--highlight-dim) 50%);
outline: 1px solid var(--hl-schema-border);
outline-offset: -1px;
}
.btn-hl-icon-data {
padding: 3px 7px;
font-size: 10px;
letter-spacing: 0.3px;
text-transform: none;
border-color: var(--highlight-border);
color: var(--highlight);
background: var(--highlight-dim);
}
.btn-hl-icon-data:hover { background: var(--highlight-hover); }
.hl-label-data { color: var(--highlight); }
.node-row.highlighted {
background: var(--highlight-dim);
outline: 1px solid var(--highlight-border);
outline-offset: -1px;
}
`,
buttons: {
'schema.header.right': `
<button class="btn-clear-hl" id="btnSchemaClearHl" title="Clear schema highlights">✕</button>
<span class="hl-label hl-label-schema">Highlights</span>
<button class="btn-hl-icon" id="btnSchemaHlToSchema" title="Expand highlighted in Schema panel">▼</button>
<button class="btn-hl-icon" id="btnSchemaHlToData" title="Expand highlighted in Data panel">▶</button>
`,
'data.header.left': `
<button class="btn-hl-icon-data" id="btnDataHlToSchema" title="Expand highlighted in Schema panel">◄</button>
<button class="btn-hl-icon-data" id="btnDataHlToData" title="Expand highlighted in Data panel">▼</button>
<span class="hl-label hl-label-data">Highlights</span>
<button class="btn-clear-hl" id="btnDataClearHl" title="Clear data highlights">✕</button>
`
},
functions: {
schemaHighlights: new Set(),
dataHighlights: new Set(),
expandToHighlights(nodes, targetPaths) {
if (targetPaths.size === 0) return;
var ancestorPaths = new Set();
nodes.forEach(function(n, p) {
if (!n.childrenEl) return;
for (var tp of targetPaths) {
if (this.isAncestorPath(p, tp)) { ancestorPaths.add(p); break; }
}
}.bind(this));
nodes.forEach(function(n) {
if (!n.childrenEl) return;
this.setFolded(n, !ancestorPaths.has(n.path));
}.bind(this));
},
clearSchemaHighlights(schemaDom, dataDom) {
schemaDom = schemaDom || document.getElementById('schemaTree');
dataDom = dataDom || document.getElementById('dataTree');
this.schemaHighlights.clear();
schemaDom.querySelectorAll('.highlighted-schema').forEach(function(el) { el.classList.remove('highlighted-schema'); });
dataDom.querySelectorAll('.highlighted-schema').forEach(function(el) { el.classList.remove('highlighted-schema'); });
},
clearDataHighlights(schemaDom, dataDom) {
schemaDom = schemaDom || document.getElementById('schemaTree');
dataDom = dataDom || document.getElementById('dataTree');
this.dataHighlights.clear();
schemaDom.querySelectorAll('.highlighted').forEach(function(el) { el.classList.remove('highlighted'); });
dataDom.querySelectorAll('.highlighted').forEach(function(el) { el.classList.remove('highlighted'); });
},
toggleSchemaHighlight(schemaPath, row, dataDom) {
dataDom = dataDom || document.getElementById('dataTree');
var safePath = schemaPath.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
if (this.schemaHighlights.has(schemaPath)) {
this.schemaHighlights.delete(schemaPath);
row.classList.remove('highlighted-schema');
dataDom.querySelectorAll('[data-schema-path="' + safePath + '"]')
.forEach(function(el) { el.classList.remove('highlighted-schema'); });
} else {
this.schemaHighlights.add(schemaPath);
row.classList.add('highlighted-schema');
var first = null;
dataDom.querySelectorAll('[data-schema-path="' + safePath + '"]').forEach(function(el) {
el.classList.add('highlighted-schema');
if (!first) first = el;
});
first?.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
}
},
toggleHighlight(dataPath, schemaPath, row, schemaDom) {
schemaDom = schemaDom || document.getElementById('schemaTree');
var safePath = schemaPath.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
var dpath = this.dataPathToSchemaPath;
if (this.dataHighlights.has(dataPath)) {
this.dataHighlights.delete(dataPath);
row.classList.remove('highlighted');
var stillLinked = [...this.dataHighlights].some(function(p) { return dpath(p) === schemaPath; });
if (!stillLinked) schemaDom.querySelector('[data-schema-path="' + safePath + '"]')?.classList.remove('highlighted');
} else {
this.dataHighlights.add(dataPath);
row.classList.add('highlighted');
var sn = schemaDom.querySelector('[data-schema-path="' + safePath + '"]');
if (sn) { sn.classList.add('highlighted'); sn.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); }
}
},
applySchemaHlToSchema() {
this.expandToHighlights(this.schemaNodes, this.schemaHighlights);
},
applySchemaHlToData() {
var targets = new Set();
var dpath = this.dataPathToSchemaPath;
var hl = this.schemaHighlights;
this.dataNodes.forEach(function(_, dp) { if (hl.has(dpath(dp))) targets.add(dp); });
this.expandToHighlights(this.dataNodes, targets);
},
applyDataHlToSchema() {
var targets = new Set([...this.dataHighlights].map(function(p) { return this.dataPathToSchemaPath(p); }.bind(this)));
this.expandToHighlights(this.schemaNodes, targets);
},
applyDataHlToData() {
this.expandToHighlights(this.dataNodes, this.dataHighlights);
},
},
init(schemaDom, dataDom, shared) {
this._schemaDom = schemaDom;
this._dataDom = dataDom;
this._shared = shared;
this._postLoadFn = function() {
shared.schemaHighlights.clear();
shared.dataHighlights.clear();
};
shared.postLoad.push(this._postLoadFn);
this._schemaClick = (e) => {
const row = e.target.closest('.node-row');
if (!row || !schemaDom.contains(row) || !row.dataset.schemaPath) return;
const isContainer = row.dataset.container === '1';
if (e.ctrlKey || e.metaKey || !isContainer) {
e.stopPropagation();
shared.toggleSchemaHighlight(row.dataset.schemaPath, row, dataDom);
}
};
schemaDom.addEventListener('click', this._schemaClick);
this._dataClick = (e) => {
const row = e.target.closest('.node-row');
if (!row || !dataDom.contains(row) || !row.dataset.path) return;
const isContainer = row.dataset.container === '1';
if (e.ctrlKey || e.metaKey || !isContainer) {
e.stopPropagation();
shared.toggleHighlight(row.dataset.path, row.dataset.schemaPath, row, schemaDom);
}
};
dataDom.addEventListener('click', this._dataClick);
const bindBtn = (id, fn) => document.getElementById(id)?.addEventListener('click', fn);
bindBtn('btnSchemaClearHl', () => shared.clearSchemaHighlights(schemaDom, dataDom));
bindBtn('btnSchemaHlToSchema', () => shared.applySchemaHlToSchema());
bindBtn('btnSchemaHlToData', () => shared.applySchemaHlToData());
bindBtn('btnDataClearHl', () => shared.clearDataHighlights(schemaDom, dataDom));
bindBtn('btnDataHlToSchema', () => shared.applyDataHlToSchema());
bindBtn('btnDataHlToData', () => shared.applyDataHlToData());
},
destroy() {
if (this._shared) {
this._shared.clearSchemaHighlights(this._schemaDom, this._dataDom);
this._shared.clearDataHighlights(this._schemaDom, this._dataDom);
var arr = this._shared.postLoad;
var idx = arr.indexOf(this._postLoadFn);
if (idx !== -1) arr.splice(idx, 1);
}
this._schemaDom?.removeEventListener('click', this._schemaClick);
this._dataDom?.removeEventListener('click', this._dataClick);
delete this._schemaDom;
delete this._dataDom;
delete this._shared;
delete this._postLoadFn;
},
})
Regex Filter module adds a live text-filter input to each panel header.
array, string)..* toggle button switches between plain-text and regexp matching per panel. Invalid regexps fall back to plain-text (input border turns red as a hint).× clear button appears at the right of the input when it is non-empty.loadJson call.({
name: 'Regex Filter',
description: 'Live text/regexp filter for schema and data panels; ancestors of matches are dimmed, non-matches are hidden',
styles: `
.filter-bar {
display: flex;
align-items: center;
gap: 4px;
}
.filter-wrap {
position: relative;
display: flex;
align-items: stretch;
}
.filter-wrap:focus-within .filter-kv-btn,
.filter-wrap:focus-within .filter-input { border-color: var(--accent-border); }
.filter-input {
font-family: var(--mono);
font-size: 11px;
width: 130px;
padding: 3px 22px 3px 7px;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--surface2);
color: var(--text);
outline: none;
transition: border-color 0.15s;
}
.filter-input:focus { border-color: var(--accent-border); }
.filter-input::placeholder { color: var(--text-dim); }
.filter-input.filter-invalid { border-color: rgba(252,165,165,0.6); }
.filter-clear {
position: absolute;
right: 4px;
padding: 1px 4px;
font-size: 10px;
border: none;
background: transparent;
color: var(--text-dim);
cursor: pointer;
line-height: 1;
visibility: hidden;
}
.filter-clear:hover { color: var(--text); background: transparent; border-color: transparent; }
.filter-clear.visible { visibility: visible; }
.filter-re-btn {
padding: 3px 6px;
font-size: 10px;
letter-spacing: 0;
border-color: var(--border);
color: var(--text-dim);
}
.filter-re-btn.active {
border-color: var(--accent-border);
color: var(--accent);
background: var(--accent-dim);
}
.filter-kv-btn {
padding: 3px 6px;
font-size: 10px;
letter-spacing: 0;
border-color: var(--border);
border-right: none;
border-radius: var(--radius) 0 0 var(--radius);
color: var(--text-dim);
min-width: 28px;
text-align: center;
}
.filter-kv-btn.active {
color: var(--accent);
background: var(--accent-dim);
}
.filter-input-kv {
border-radius: 0 var(--radius) var(--radius) 0;
}
`,
buttons: {
'schema.header.right': `
<div class="filter-bar" id="schemaFilterBar">
<button class="filter-re-btn" id="schemaFilterRe" title="Toggle regexp mode">.*</button>
<div class="filter-wrap">
<button class="filter-kv-btn" id="schemaFilterKT" title="KT: match key or type K: match key only T: match type only">KT</button>
<input class="filter-input filter-input-kv" id="schemaFilterInput" placeholder="filter schema…" />
<button class="filter-clear" id="schemaFilterClear" title="Clear filter">✕</button>
</div>
</div>
`,
'data.header.right': `
<div class="filter-bar" id="dataFilterBar">
<button class="filter-re-btn" id="dataFilterRe" title="Toggle regexp mode">.*</button>
<div class="filter-wrap">
<button class="filter-kv-btn" id="dataFilterKV" title="KV: match key or value K: match key only V: match value only">KV</button>
<input class="filter-input filter-input-kv" id="dataFilterInput" placeholder="filter data…" />
<button class="filter-clear" id="dataFilterClear" title="Clear filter">✕</button>
</div>
</div>
`,
},
functions: {
applyFilter(nodes, matcher, isSchema, shared, matchTarget) {
if (!matcher) {
nodes.forEach(function(n) {
n.el.classList.remove('dimmed');
var wrapper = n.el.parentElement;
if (wrapper && wrapper.classList.contains('node')) wrapper.style.display = '';
});
return;
}
var mt = matchTarget || 'kv';
function getRowText(row) {
var key = row.querySelector('.key');
var value = row.querySelector('.value');
var type = row.querySelector('[class*="t-"]');
var parts = [];
if (isSchema) {
if (mt !== 't' && key) parts.push(key.textContent);
if (mt !== 'k' && type) parts.push(type.textContent);
} else {
if (mt !== 'v' && key) parts.push(key.textContent);
if (mt !== 'k' && value) parts.push(value.textContent);
}
return parts.join(' ');
}
var matched = new Set();
nodes.forEach(function(n, path) {
if (matcher.test(getRowText(n.el))) matched.add(path);
});
var ancestors = new Set();
nodes.forEach(function(n, path) {
if (!n.isContainer) return;
for (var mp of matched) {
if (shared.isAncestorPath(path, mp)) { ancestors.add(path); break; }
}
});
ancestors.forEach(function(path) {
var n = nodes.get(path);
if (n) shared.setFolded(n, false);
});
nodes.forEach(function(n, path) {
var isMatch = matched.has(path);
var isAncestor = ancestors.has(path);
var visible = isMatch || isAncestor;
shared.setDimmed(n, isAncestor && !isMatch);
var wrapper = n.el.parentElement;
if (wrapper && wrapper.classList.contains('node')) wrapper.style.display = visible ? '' : 'none';
});
},
},
init(schemaDom, dataDom, shared) {
this._shared = shared;
this._schemaRe = false;
this._schemaKT = 'kt'; // 'kt' | 'k' | 't'
this._dataRe = false;
this._dataKV = 'kv'; // 'kv' | 'k' | 'v'
var self = this;
function buildMatcher(pattern, useRe) {
if (!pattern) return null;
if (useRe) {
try { return new RegExp(pattern, 'i'); }
catch(_) { return null; }
}
var lower = pattern.toLowerCase();
return { test: function(s) { return s.toLowerCase().indexOf(lower) !== -1; } };
}
function isValidRe(pattern) {
try { new RegExp(pattern); return true; } catch(_) { return false; }
}
// ── schema filter ──────────────────────────────────
var schemaInput = document.getElementById('schemaFilterInput');
var schemaClear = document.getElementById('schemaFilterClear');
var schemaReBtn = document.getElementById('schemaFilterRe');
var schemaKTBtn = document.getElementById('schemaFilterKT');
var KT_CYCLE = { kt: 'k', k: 't', t: 'kt' };
var KT_TITLE = {
kt: 'KT: match key or type\nK: match key only\nT: match type only',
k: 'K: match key only\nKT: match key or type\nT: match type only',
t: 'T: match type only\nKT: match key or type\nK: match key only',
};
function runSchemaFilter() {
shared.setAllExpanded(shared.schemaNodes, true);
var pat = schemaInput.value;
var useRe = self._schemaRe;
var invalid = useRe && pat && !isValidRe(pat);
schemaInput.classList.toggle('filter-invalid', invalid);
schemaClear.classList.toggle('visible', pat.length > 0);
shared.applyFilter(shared.schemaNodes, buildMatcher(pat, useRe), true, shared, self._schemaKT);
}
this._schemaInput = function() { runSchemaFilter(); };
this._schemaEnter = function(e) { if (e.key === 'Enter') runSchemaFilter(); };
this._schemaClear = function() { schemaInput.value = ''; runSchemaFilter(); };
this._schemaReClick = function() {
self._schemaRe = !self._schemaRe;
schemaReBtn.classList.toggle('active', self._schemaRe);
runSchemaFilter();
};
this._schemaKTClick = function() {
self._schemaKT = KT_CYCLE[self._schemaKT];
schemaKTBtn.textContent = self._schemaKT.toUpperCase();
schemaKTBtn.title = KT_TITLE[self._schemaKT];
schemaKTBtn.classList.toggle('active', self._schemaKT !== 'kt');
runSchemaFilter();
};
schemaInput.addEventListener('input', this._schemaInput);
schemaInput.addEventListener('keydown', this._schemaEnter);
schemaClear.addEventListener('click', this._schemaClear);
schemaReBtn.addEventListener('click', this._schemaReClick);
schemaKTBtn.addEventListener('click', this._schemaKTClick);
// ── data filter ────────────────────────────────────
var dataInput = document.getElementById('dataFilterInput');
var dataClear = document.getElementById('dataFilterClear');
var dataReBtn = document.getElementById('dataFilterRe');
var dataKVBtn = document.getElementById('dataFilterKV');
var KV_CYCLE = { kv: 'k', k: 'v', v: 'kv' };
var KV_TITLE = {
kv: 'KV: match key or value\nK: match key only\nV: match value only',
k: 'K: match key only\nKV: match key or value\nV: match value only',
v: 'V: match value only\nKV: match key or value\nK: match key only',
};
function runDataFilter() {
shared.setAllExpanded(shared.dataNodes, true);
var pat = dataInput.value;
var useRe = self._dataRe;
var invalid = useRe && pat && !isValidRe(pat);
dataInput.classList.toggle('filter-invalid', invalid);
dataClear.classList.toggle('visible', pat.length > 0);
shared.applyFilter(shared.dataNodes, buildMatcher(pat, useRe), false, shared, self._dataKV);
}
this._dataInput = function() { runDataFilter(); };
this._dataEnter = function(e) { if (e.key === 'Enter') runDataFilter(); };
this._dataClear = function() { dataInput.value = ''; runDataFilter(); };
this._dataReClick = function() {
self._dataRe = !self._dataRe;
dataReBtn.classList.toggle('active', self._dataRe);
runDataFilter();
};
this._dataKVClick = function() {
self._dataKV = KV_CYCLE[self._dataKV];
dataKVBtn.textContent = self._dataKV.toUpperCase();
dataKVBtn.title = KV_TITLE[self._dataKV];
dataKVBtn.classList.toggle('active', self._dataKV !== 'kv');
runDataFilter();
};
dataInput.addEventListener('input', this._dataInput);
dataInput.addEventListener('keydown', this._dataEnter);
dataClear.addEventListener('click', this._dataClear);
dataReBtn.addEventListener('click', this._dataReClick);
dataKVBtn.addEventListener('click', this._dataKVClick);
// ── postLoad: reset both inputs on new JSON load ───
this._postLoadFn = function() {
schemaInput.value = '';
dataInput.value = '';
schemaInput.classList.remove('filter-invalid');
dataInput.classList.remove('filter-invalid');
schemaClear.classList.remove('visible');
dataClear.classList.remove('visible');
self._schemaKT = 'kt';
schemaKTBtn.textContent = 'KT';
schemaKTBtn.classList.remove('active');
self._dataKV = 'kv';
dataKVBtn.textContent = 'KV';
dataKVBtn.classList.remove('active');
shared.applyFilter(shared.schemaNodes, null, true, shared);
shared.applyFilter(shared.dataNodes, null, false, shared);
};
shared.postLoad.push(this._postLoadFn);
},
destroy() {
if (this._shared) {
this._shared.applyFilter(this._shared.schemaNodes, null, true, this._shared);
this._shared.applyFilter(this._shared.dataNodes, null, false, this._shared);
var arr = this._shared.postLoad;
var idx = arr.indexOf(this._postLoadFn);
if (idx !== -1) arr.splice(idx, 1);
}
[
{ id: 'schemaFilterInput', event: 'input', handler: this._schemaInput },
{ id: 'schemaFilterInput', event: 'keydown', handler: this._schemaEnter },
{ id: 'schemaFilterClear', event: 'click', handler: this._schemaClear },
{ id: 'schemaFilterRe', event: 'click', handler: this._schemaReClick },
{ id: 'schemaFilterKT', event: 'click', handler: this._schemaKTClick },
{ id: 'dataFilterInput', event: 'input', handler: this._dataInput },
{ id: 'dataFilterInput', event: 'keydown', handler: this._dataEnter },
{ id: 'dataFilterClear', event: 'click', handler: this._dataClear },
{ id: 'dataFilterRe', event: 'click', handler: this._dataReClick },
{ id: 'dataFilterKV', event: 'click', handler: this._dataKVClick }
].forEach(({ id, event, handler }) => {
document.getElementById(id)?.removeEventListener(event, handler);
});
delete this._shared;
delete this._postLoadFn;
},
})