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 @@ - +