/** A zero dependency web component that will display JavaScript console output on-page.
*
* @version See Class version property
*
*/
/** Copyright (c) 2024-2024 Julian Knight (Totally Information)
* https://it.knightnet.org.uk, https://github.com/TotallyInformation
*
* Licensed under the Apache License, Version 2.0 (the 'License');
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an 'AS IS' BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
**/
/** TODO
* - Add part transparent color backgrounds for at least warn and err
* - std parts
* - allow to be separate from console (via an attribute) for custom visual logging
* - Max entry limit
*/
import TiBaseComponent from '../libs/ti-base-component'
/** Only use a template if you want to isolate the code and CSS */
const template = document.createElement('template')
template.innerHTML = /** @type {HTMLTemplateElement} */ /*html*/`
<style>
:host {
display: block; /* default is inline */
contain: content; /* performance boost */
}
.wrapper {
display: grid;
grid-template-columns: minmax(6ch, 1fr) minmax(75%, 5fr);
column-gap: 1rem;
row-gap: .3rem;
align-content: start;
height: 22em;
overflow: auto;
resize: vertical;
font-family: Consolas, "ui-monospace", "Lucida Console", monospace;
font-size: smaller;
/* white-space: pre; */
background-color:black;
border: 1px solid silver;
}
.meta {
/* grid-area: meta; */
border-right: 1px solid silver;
font-size: larger;
/* background-color:black; */
/* white-space: pre; */
}
.data {
/* grid-area: content; */
/* color:white; */
/* display:block; */
/* background-color:black; */
/* padding:5px 10px; */
/* font-family: Consolas, "ui-monospace", "Lucida Console", monospace; */
/* font-size: smaller; */
/* white-space: pre; */
/* width: 99%; */
/* height: 22em; */
/* overflow: auto; */
/* resize: both; */
}
.key {color:#ffbf35}
.string {color:#5dff39;}
.number {color:#70aeff;}
.boolean {color:#b993ff;}
.null {color:#93ffe4;}
.undefined {color:#ff93c9;}
.log {color: hsl(120 100 77);}
.info {color: hsl(212 100 77);}
.warn {color: orange;}
.error {color: red;}
.data-info {background-color: hsl(192 100 42 /0.3);}
.data-warn {background-color: hsl(39 100 42 /0.3);}
.data-error {background-color: hsl(0 100 42 /0.3);}
</style>
<div class="wrapper"></div>
`
/**
* @namespace Beta
*/
/**
* @class
* @augments TiBaseComponent
* @description A zero dependency web component that will display JavaScript console output on-page.
*
* @element component-template
* @memberOf Beta
*
* @example
* <visible-console></visible-console>
* METHODS FROM BASE:
* @method config Update runtime configuration, return complete config
* @method createShadowSelectors Creates the jQuery-like $ and $$ methods
* @method deepAssign Object deep merger
* @method doInheritStyles If requested, add link to an external style sheet
* @method ensureId Adds a unique ID to the tag if no ID defined.
* @method _uibMsgHandler Not yet in use
* @method _event(name,data) Standardised custom event dispatcher
* OTHER METHODS:
* @method redirectConsole Capture console.xxxx and write to the div
* @method newLog Creates a new HTML log entry
* @method checkType Find out the input JavaScript var type
* @method createHTMLVisualizer Creates an HTML visualisation of the input
* @fires visible-console:connected - When an instance of the component is attached to the DOM. `evt.details` contains the details of the element.
* @fires component-template:ready - Alias for connected. The instance can handle property & attribute changes
* @fires visible-console:disconnected - When an instance of the component is removed from the DOM. `evt.details` contains the details of the element.
* @fires visible-console:attribChanged - When a watched attribute changes. `evt.details` contains the details of the change.
* NOTE that listeners can be attached either to the `document` or to the specific element instance.
* Standard watched attributes (common across all my components):
* @property {string|boolean} inherit-style - Optional. Load external styles into component (only useful if using template). If present but empty, will default to './index.css'. Optionally give a URL to load.
* @property {string} name - Optional. HTML name attribute. Included in output _meta prop.
*
* Other watched attributes:
* None
* Standard props (common across all my components):
* @property {number} _iCount Static. The component version string (date updated)
* @property {boolean} uib True if UIBUILDER for Node-RED is loaded
* @property {function(string): Element} $ jQuery-like shadow dom selector
* @property {function(string): NodeList} $$ jQuery-like shadow dom multi-selector
* @property {string} name Placeholder for the optional name attribute
* @property {object} opts This components controllable options - get/set using the `config()` method
*
* @property {string} version Static. The component version string (date updated). Also has a getter that returns component and base version strings.
*
* Other props:
* @property {object} colors
* @property {object} bgColors
* @property {object} icons
* @slot No slot
* See https://github.com/runem/web-component-analyzer?tab=readme-ov-file#-how-to-document-your-components-using-jsdoc
*/
class VisibleConsole extends TiBaseComponent {
/** Component version */
static componentVersion = '2024-10-06'
/** Makes HTML attribute change watched
* @returns {Array<string>} List of all of the html attribs (props) listened to
*/
static get observedAttributes() {
return [
// Standard watched attributes:
'inherit-style', 'name'
// Other watched attributes:
]
}
/** Runtime configuration settings */
opts = {}
colors = {
'log': 'green',
'error': 'red',
'warn': 'orange',
}
bgColors = {
'info': 'hsl(92, 100, 50, 0.3)',
'warn': 'hsl(39, 100, 50, 0.3)',
'error': 'hsl(0, 100, 50, 0.3)',
}
icons = {
'log': '> ',
'info': 'ℹ️ ',
'debug': '🪲 ',
'trace': '👓 ',
'warn': '⚠️ ',
'error': '⛔ ',
}
//#endregion --- Class Properties ---
/** NB: Attributes not available here - use connectedCallback to reference */
constructor() {
super()
this.attachShadow({ mode: 'open', delegatesFocus: true })
// Only append the template if code and style isolation is needed
.append(template.content.cloneNode(true))
// jQuery-like selectors but for the shadow. NB: Returns are STATIC not dynamic lists
this.createShadowSelectors() // in base class
// @ts-ignore
this.wrapper = this.shadowRoot.querySelector('.wrapper')
// Keep a COPY of the original console so we can still use it if we want
this.originalConsole = { ...console }
}
/** Runs when an instance is added to the DOM */
connectedCallback() {
// Make sure instance has an ID. Create an id from name or calculation if needed
this.ensureId() // in base class
// Apply parent styles from a stylesheet if required - only required if using an applied template
this.doInheritStyles() // in base class
this.redirectConsole()
// Keep at end. Let everyone know that a new instance of the component has been connected
this._event('connected')
this._event('ready')
}
/** Runs when an instance is removed from the DOM */
disconnectedCallback() {
// Keep at end. Let everyone know that an instance of the component has been disconnected
this._event('disconnected')
}
/** Handle watched attributes
* NOTE: On initial startup, this is called for each watched attrib set in HTML - BEFORE connectedCallback is called.
* Attribute values can only ever be strings
* @param {string} attrib The name of the attribute that is changing
* @param {string} newVal The new value of the attribute
* @param {string} oldVal The old value of the attribute
*/
attributeChangedCallback(attrib, oldVal, newVal) {
// Don't bother if the new value same as old
if ( oldVal === newVal ) return
// Create a property from the value - WARN: Be careful with name clashes
this[attrib] = newVal
// Add other dynamic attribute processing here.
// If attribute processing doesn't need to be dynamic, process in connectedCallback as that happens earlier in the lifecycle
// Keep at end. Let everyone know that an attribute has changed for this instance of the component
this._event('attribChanged', { attribute: attrib, newVal: newVal, oldVal: oldVal })
}
/** Capture console.xxxx and write to the div */
redirectConsole() {
Object.keys(this.icons).forEach( type => {
console[type] = (...args) => {
// Call the original console.log
this.originalConsole[type].apply(console[type], args)
this.newLog(type, args)
}
})
}
/** Creates a new HTML log entry
* @param {string} type The log type
* @param {*} args The arguments to log
*/
newLog(type, args) {
const icon = this.icons?.[type] || ''
// TODO Use template and clone
// Create a new line in the output div
const newMeta = document.createElement('div')
newMeta.className = `meta ${type}`
newMeta.innerHTML = `${icon} ${type}`
this.wrapper?.appendChild(newMeta)
// Convert args to a string
const message = args.map(arg => {
return this.createHTMLVisualizer(arg)
})
const newLog = document.createElement('div')
newLog.className = `data data-${type}`
message.forEach( el => {
newLog.appendChild(el)
} )
this.wrapper?.appendChild(newLog)
// Scroll to the bottom
// @ts-ignore
this.wrapper.scrollTop = this.wrapper?.scrollHeight
}
/** Find out the input JavaScript var type
* @param {*} input The JavaScript var to type
* @returns {string} The input type
*/
checkType(input) {
if (input === null) {
return 'null'
} else if (Array.isArray(input)) {
return 'array'
} else if (typeof input === 'object') {
return 'object'
} else {
return typeof input
}
}
/** Creates an HTML visualisation of the input
* @param {*} input
* @returns {HTMLDivElement}
*/
createHTMLVisualizer(input) {
const container = document.createElement('div')
// container.style.backgroundColor = 'black'
// container.style.color = 'white'
// container.style.fontFamily = 'monospace'
// container.style.padding = '10px'
// container.style.whiteSpace = 'pre-wrap'
const renderValue = (value, level = 0) => {
const wrapper = document.createElement('div')
wrapper.style.marginLeft = `${level * 10}px`
if (level > 10) formatPrimitive(value) // not ideal but it doesn't crash
else if (Array.isArray(value)) {
wrapper.appendChild(renderCollapsible('Array', value, level, '[', ']'))
} else if (value && typeof value === 'object') {
wrapper.appendChild(renderCollapsible('Object', value, level, '{', '}'))
} else {
wrapper.appendChild(formatPrimitive(value))
}
return wrapper
}
const renderCollapsible = (label, value, level, openSymbol, closeSymbol) => {
const container = document.createElement('div')
const header = document.createElement('span')
const content = document.createElement('div')
const icon = document.createElement('span')
icon.textContent = '▶' // Initial icon for collapsed state
header.textContent = ` ${openSymbol}${getCollapsedSummary(value)}${closeSymbol}`
header.style.cursor = 'pointer'
// header.style.color = 'lightblue'
icon.style.cursor = 'pointer'
// icon.style.color = 'lightblue'
icon.addEventListener('click', () => {
const isCollapsed = content.style.display === 'none'
content.style.display = isCollapsed ? 'block' : 'none'
icon.textContent = isCollapsed ? '▼' : '▶'
header.textContent = isCollapsed ? ` ${openSymbol}` : ` ${openSymbol}${getCollapsedSummary(value)}${closeSymbol}`
})
header.addEventListener('click', () => {
const isCollapsed = content.style.display === 'none'
content.style.display = isCollapsed ? 'block' : 'none'
icon.textContent = isCollapsed ? '▼' : '▶'
header.textContent = isCollapsed ? ` ${openSymbol}` : ` ${openSymbol}${getCollapsedSummary(value)}${closeSymbol}`
})
content.style.display = 'none'
content.style.marginLeft = '20px'
for (const key in value) {
const line = document.createElement('div')
line.style.marginLeft = `${level * 10}px`
const keySpan = document.createElement('span')
keySpan.style.color = 'orange'
keySpan.textContent = `${key}: `
const valueSpan = renderValue(value[key], level++)
line.appendChild(keySpan)
line.appendChild(valueSpan)
content.appendChild(line)
}
const closeSymbolSpan = document.createElement('span')
closeSymbolSpan.textContent = ` ${closeSymbol}`
closeSymbolSpan.style.color = 'lightblue'
container.appendChild(icon)
container.appendChild(header)
container.appendChild(content)
container.appendChild(closeSymbolSpan)
return container
}
const getCollapsedSummary = (value) => {
let summary = ''
if (Array.isArray(value)) {
summary = value.map(v => getPrimitiveSummary(v)).join(', ')
} else if (typeof value === 'object') {
summary = Object.keys(value).map(key => `${key}: ${getPrimitiveSummary(value[key])}`).join(', ')
}
if (summary.length > 30) {
summary = summary.slice(0, 30) + '...'
}
return summary
}
const getPrimitiveSummary = (value) => {
if (typeof value === 'string') {
return `"${value}"`
} else if (typeof value === 'number') {
return value
} else if (typeof value === 'boolean') {
return value
} else if (value === null) {
return 'null'
} else if (typeof value === 'undefined') {
return 'undefined'
} else if (typeof value === 'bigint') {
return `${value}n`
} else if (typeof value === 'function') {
return '[Function]'
} else if (Array.isArray(value)) {
return '[Array]'
} else if (typeof value === 'object') {
return '[Object]'
}
}
const formatPrimitive = (value) => {
const span = document.createElement('span')
if (typeof value === 'string') {
span.style.color = 'lightgreen'
span.textContent = `"${value}"`
} else if (typeof value === 'number') {
span.style.color = 'lightcoral'
// @ts-ignore
span.textContent = value
} else if (typeof value === 'boolean') {
span.style.color = 'lightyellow'
// @ts-ignore
span.textContent = value
} else if (value === null) {
span.style.color = 'gray'
span.textContent = 'null'
} else if (typeof value === 'undefined') {
span.style.color = 'gray'
span.textContent = 'undefined'
} else if (typeof value === 'bigint') {
span.style.color = 'lightcoral'
span.textContent = `${value}n`
} else if (typeof value === 'function') {
span.style.color = 'violet'
span.textContent = '[Function]'
}
return span
}
container.appendChild(renderValue(input))
return container
}
}
// Make the class the default export so it can be used elsewhere
export default VisibleConsole
/** Self register the class to global
* Enables new data to be dynamically added via JS
* and lets any static methods be called
*/
window['VisibleConsole'] = VisibleConsole
// Add the class as a new Custom Element to the window object
customElements.define('visible-console', VisibleConsole)