diff --git a/demo/styles/viewer.html b/demo/styles/viewer.html
index 8f89798d..ba076c78 100644
--- a/demo/styles/viewer.html
+++ b/demo/styles/viewer.html
@@ -1,4 +1,4 @@
-
+
)|(<\?[\s\S]*?\?>)|(<\/?)([\w:.-]+)((?:\s+[\w:.-]+\s*=\s*(?:"[^"]*"|'[^']*'))*)(\s*\/?>)|([^<]+)|(<)/g
+const XML_ATTRS = /(\s+)([\w:.-]+)(\s*=\s*)("[^"]*"|'[^']*')/g
+const JSON_TOKENS =
+ /("(?:\\.|[^"\\])*")(\s*:)?|\b(true|false|null)\b|-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?|[{}[\],:]/g
+
+function tok(parent, cls, text) {
+ if (!text) return
+ if (!cls) return parent.append(text)
+ const span = document.createElement('span')
+ span.className = 'tok-' + cls
+ span.textContent = text
+ parent.append(span)
+}
+
+function highlightXml(text) {
+ const out = document.createDocumentFragment()
+ let last = 0
+ for (const m of text.matchAll(XML_TOKENS)) {
+ tok(out, null, text.slice(last, m.index))
+ last = m.index + m[0].length
+ if (m[1]) tok(out, 'comment', m[1])
+ else if (m[2]) tok(out, 'decl', m[2])
+ else if (m[4]) {
+ tok(out, 'punct', m[3])
+ tok(out, 'tag', m[4])
+ let end = 0
+ for (const a of m[5].matchAll(XML_ATTRS)) {
+ tok(out, null, a[1])
+ tok(out, 'attr', a[2])
+ tok(out, 'punct', a[3])
+ tok(out, 'str', a[4])
+ end = a.index + a[0].length
+ }
+ tok(out, null, m[5].slice(end))
+ tok(out, 'punct', m[6])
+ } else if (m[7]) tok(out, m[7].trim() ? 'text' : null, m[7])
+ else tok(out, 'punct', m[8])
+ }
+ tok(out, null, text.slice(last))
+ return out
+}
+
+function highlightJson(text) {
+ const out = document.createDocumentFragment()
+ let last = 0
+ for (const m of text.matchAll(JSON_TOKENS)) {
+ tok(out, null, text.slice(last, m.index))
+ last = m.index + m[0].length
+ if (m[1]) {
+ tok(out, m[2] ? 'key' : 'str', m[1])
+ tok(out, 'punct', m[2])
+ } else if (m[3]) tok(out, 'lit', m[3])
+ else if (/^[-\d]/.test(m[0])) tok(out, 'num', m[0])
+ else tok(out, 'punct', m[0])
+ }
+ tok(out, null, text.slice(last))
+ return out
+}
+
+// How far a wrapped line continues past its own indent, in characters
+const HANG = 4
+
+// Tokens can span lines, so each one is split at its line breaks into one block per line
+function intoLines(tokens) {
+ const out = document.createDocumentFragment()
+ let line = null
+ const startLine = () => {
+ line = document.createElement('div')
+ line.className = 'mtc-line'
+ out.append(line)
+ }
+ startLine()
+ for (const node of [...tokens.childNodes]) {
+ node.textContent.split('\n').forEach((part, i) => {
+ if (i > 0) startLine()
+ if (!part) return
+ const piece = node.cloneNode(false)
+ piece.textContent = part
+ line.append(piece)
+ })
+ }
+ for (const block of out.children) {
+ const indent = block.textContent.length - block.textContent.trimStart().length
+ block.style.setProperty('--hang', indent + HANG + 'ch')
+ }
+ return out
+}
+
+function showRaw(pre, text, format) {
+ const trimmed = text.trimStart()
+ if (format === 'json' || trimmed.startsWith('{') || trimmed.startsWith('[')) {
+ pre.replaceChildren(intoLines(highlightJson(text)))
+ } else if (trimmed.startsWith('<')) {
+ pre.replaceChildren(intoLines(highlightXml(text)))
+ } else {
+ pre.textContent = text
+ }
+}
+
function setupDialogs() {
$('help').addEventListener('click', () => $('help-dialog').showModal())
$('raw').addEventListener('click', async () => {
@@ -149,10 +275,9 @@ function setupDialogs() {
pre.textContent = 'Loading...'
$('raw-dialog').showModal()
try {
- pre.textContent = await fetchDocument(
- window.location.pathname + window.location.search,
- $('format').value
- )
+ const format = $('format').value
+ const text = await fetchDocument(window.location.pathname + window.location.search, format)
+ showRaw(pre, text, format)
} catch (err) {
pre.textContent = err.message
}
diff --git a/styles/mtc-views.js b/styles/mtc-views.js
index 8a43e95c..2da4b741 100644
--- a/styles/mtc-views.js
+++ b/styles/mtc-views.js
@@ -24,6 +24,29 @@ const INDENT_EM = 1.1
// survives the re-render that autorefresh does.
const collapsed = new Set()
+// Values longer than this show a few lines until clicked. Same keys, for the same reason.
+const LONG_VALUE = 120
+const expanded = new Set()
+
+function valueContent(text, key) {
+ if (!text || text.length <= LONG_VALUE) return text
+ const button = h(
+ 'button',
+ {
+ type: 'button',
+ class: 'mtc-long',
+ title: 'Show or hide the whole value',
+ 'aria-expanded': String(expanded.has(key)),
+ },
+ h('span', {}, text)
+ )
+ button.addEventListener('click', () => {
+ if (!expanded.delete(key)) expanded.add(key)
+ button.setAttribute('aria-expanded', String(expanded.has(key)))
+ })
+ return button
+}
+
// Pre-order walk that reports the depth and a stable key for each node, like the stylesheet's `//*`
function* walk(node, depth = 0, key = node.name) {
yield { node, depth, key }
@@ -37,6 +60,14 @@ function* walk(node, depth = 0, key = node.name) {
}
}
+// Lets a name wrap only between its words: after an underscore or before a capital. adds no
+// characters, so copying the name still gives the exact name.
+function nameCell(text) {
+ if (!text) return h('td', { class: 'mtc-name' })
+ const parts = text.replace(/(_)|([a-z0-9])(?=[A-Z])/g, '$1$2\0').split('\0')
+ return h('td', { class: 'mtc-name' }, parts.flatMap((part, i) => (i ? [h('wbr', {}), part] : [part])))
+}
+
// Rows carry their depth, so hiding a branch hides the rows below it until one is as shallow
function rowAttrs(depth, key, cls) {
return { class: cls || false, 'data-depth': depth, 'data-key': key }
@@ -149,12 +180,12 @@ function probeRows(root) {
'tr',
rowAttrs(depth, key, PROBE_BOLD.has(node.name) && 'mtc-bold'),
elementCell(node.name, depth, node.children.length > 0),
- h('td', {}, a.id),
- h('td', {}, a.name),
- h('td', {}, a.category),
- h('td', {}, a.type),
- h('td', {}, a.subType),
- h('td', {}, value),
+ nameCell(a.id),
+ nameCell(a.name),
+ nameCell(a.category),
+ nameCell(a.type),
+ nameCell(a.subType),
+ h('td', {}, valueContent(value, key)),
h('td', {}, a.units)
)
)
@@ -209,11 +240,11 @@ function streamRows(root) {
'tr',
rowAttrs(depth, key, STREAM_BOLD.has(label) && 'mtc-bold'),
elementCell(label, depth, node.children.length > 0),
- h('td', {}, node.attrs.dataItemId),
- h('td', {}, node.attrs.name),
+ nameCell(node.attrs.dataItemId),
+ nameCell(node.attrs.name),
h('td', {}, timestamp && h('span', { title: timestamp }, timestamp.substring(11, 21))),
h('td', { class: node.name === 'Entry' ? 'mtc-right' : false }, sequence),
- h('td', { class: 'mtc-value-' + value.replace(/[^A-Za-z]/g, '') }, streamDisplay(node, value))
+ h('td', { class: 'mtc-value-' + value.replace(/[^A-Za-z]/g, '') }, valueContent(streamDisplay(node, value), key))
)
)
if (node.name === 'Header') rows.push(detail(depth, attrTable(6, node)))
@@ -232,11 +263,11 @@ function assetRows(root) {
'tr',
rowAttrs(depth, key, node.name === 'Header' && 'mtc-bold'),
elementCell(node.name, depth, node.children.length > 0),
- h('td', {}, a.assetId || a.id),
- h('td', {}, a.name),
- h('td', {}, a.type),
+ nameCell(a.assetId || a.id),
+ nameCell(a.name),
+ nameCell(a.type),
h('td', {}, a.timestamp),
- h('td', {}, node.text)
+ h('td', {}, valueContent(node.text, key))
)
)
if (node.name === 'Header') rows.push(detail(depth, attrTable(6, node)))
diff --git a/styles/viewer.css b/styles/viewer.css
index 3c07b8b9..8d077975 100644
--- a/styles/viewer.css
+++ b/styles/viewer.css
@@ -137,26 +137,37 @@ dialog {
max-width: 60em;
border: 1px solid #ccc;
border-radius: 6px;
+ position: fixed;
+ padding: 3em 2em 1.5em;
}
+/* Raw dialog: sized to its content, close button above the text, long lines wrap */
dialog.mtc-wide {
width: 90vw;
- height: 80vh;
+ max-width: 90em;
+ max-height: 85vh;
+ /* Equal border on all sides; the close button fits inside the top edge */
+ padding: 2em;
}
-dialog pre {
- max-height: 65vh;
- overflow: auto;
+dialog.mtc-wide .mtc-close {
+ top: 0.15em;
+ right: 0.4em;
+ font-size: 1.5em;
}
-dialog {
- position: fixed;
- padding: 1.5em 2em 1em;
+dialog.mtc-wide[open] {
+ display: flex;
+ flex-direction: column;
+}
+
+dialog::backdrop {
+ background: rgba(0, 0, 0, 0.55);
}
.mtc-close {
position: absolute;
- top: 0.25em;
+ top: 0.3em;
right: 0.5em;
border: none;
background: none;
@@ -171,13 +182,52 @@ dialog {
color: #000;
}
-dialog.mtc-wide pre {
- margin-top: 0.5em;
+dialog pre {
+ margin: 0;
+ overflow: auto;
+ white-space: pre-wrap;
+ /* Bootstrap sets break-all on pre, which splits names mid-word */
+ word-break: normal;
+ overflow-wrap: break-word;
+ tab-size: 2;
+}
+
+/* A wrapped Raw line continues just past its own indent, so the nesting stays easy to follow.
+ The limit keeps deeply nested lines readable in a narrow window. */
+.mtc-line {
+ min-height: 1.5em;
+ padding-left: min(var(--hang), 40%);
+ text-indent: calc(-1 * min(var(--hang), 40%));
}
+dialog.mtc-wide pre {
+ flex: 1 1 auto;
+ min-height: 0;
+ padding: 1em;
+ border: 1px solid #d0d7de;
+ border-radius: 4px;
+ background: #f6f8fa;
+ color: #24292f;
+ font-size: 13px;
+ line-height: 1.5;
+}
+
+/* Raw output colors */
+.tok-punct { color: #57606a; }
+.tok-tag { color: #116329; }
+.tok-attr { color: #0550ae; }
+.tok-str { color: #a40e26; }
+.tok-num { color: #953800; }
+.tok-lit { color: #8250df; }
+.tok-key { color: #0550ae; }
+.tok-text { color: #24292f; }
+.tok-comment { color: #57606a; font-style: italic; }
+.tok-decl { color: #8250df; }
+
/* Header and detail subtables scroll inside their row instead of widening the page */
.mtc-scroll {
overflow-x: auto;
+ contain: inline-size;
}
.mtc-scroll > .subtable {
@@ -194,6 +244,130 @@ dialog.mtc-wide pre {
padding: 1px 8px;
}
+/* Long values wrap so the Value column stays on screen. Names stay whole: the Element column because
+ its indent already takes the width, and ids and names because they only break between words. */
+.table > tbody > tr > td {
+ overflow-wrap: anywhere;
+}
+
+.table > tbody > tr > td:first-child,
+.table > tbody > tr > td.mtc-name {
+ overflow-wrap: normal;
+}
+
+/* Long values show a few lines until clicked. The width limit stops one long value claiming the
+ width of the whole table. */
+.mtc-long {
+ display: block;
+ max-width: 24em;
+ padding: 0;
+ border: 0;
+ background: none;
+ font: inherit;
+ color: inherit;
+ text-align: left;
+ overflow-wrap: anywhere;
+ cursor: pointer;
+}
+
+.mtc-long > span {
+ display: -webkit-box;
+ -webkit-box-orient: vertical;
+ -webkit-line-clamp: 4;
+ overflow: hidden;
+}
+
+.mtc-long[aria-expanded='true'] > span {
+ display: block;
+}
+
+/* Phones: logo and tabs share a row, and the query form packs into rows instead of a stack */
+@media (max-width: 767px) {
+ nav > .container-fluid {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ }
+
+ nav > .container-fluid::before,
+ nav > .container-fluid::after {
+ display: none;
+ }
+
+ /* Bootstrap pulls the header out by 15px here, which clips the logo and lets the tabs cover its
+ registered trademark mark. The whole logo always shows. */
+ .container-fluid > .navbar-header {
+ flex: none;
+ margin: 0;
+ }
+
+ .navbar-brand {
+ padding: 5px 6px;
+ }
+
+ .navbar-nav {
+ display: flex;
+ flex: 1 1 0;
+ margin: 0;
+ }
+
+ .navbar-nav.mtc-links {
+ flex: 1 1 100%;
+ margin: 0;
+ }
+
+ .navbar-nav > li {
+ flex: 1 1 auto;
+ text-align: center;
+ }
+
+ .navbar-nav > li > a {
+ padding: 8px 4px;
+ }
+
+ .navbar-form {
+ flex: 1 1 100%;
+ margin: 0;
+ padding: 6px;
+ border: 0;
+ box-shadow: none;
+ }
+
+ .navbar-form .form-group {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 6px;
+ margin: 0;
+ }
+
+ .navbar-form .form-control,
+ .navbar-form .btn {
+ width: auto;
+ margin: 0;
+ }
+
+ .navbar-form .mtc-path {
+ flex: 1 1 100%;
+ width: auto;
+ margin: 0;
+ }
+
+ .navbar-form .mtc-small {
+ flex: 1 1 5em;
+ width: 5em;
+ margin: 0;
+ }
+
+ .navbar-form .mtc-format {
+ margin: 0;
+ }
+
+ /* Six columns do not fit a phone, so the table scrolls sideways instead of crushing them */
+ .table {
+ min-width: 56em;
+ }
+}
+
/* The copyright line stays visible below the scrolling table */
:root {
--footer-height: 26px;
diff --git a/styles/viewer.html b/styles/viewer.html
index 1c2731b9..9e079167 100644
--- a/styles/viewer.html
+++ b/styles/viewer.html
@@ -1,4 +1,4 @@
-
+