Skip to content
Snippets Groups Projects
Commit 7bd796d5 authored by Benedikt's avatar Benedikt
Browse files

added input widget

parent 93776da3
Branches
No related tags found
No related merge requests found
Pipeline #51705 failed
export default {
testEnvironment: "jsdom",
testEnvironmentOptions: {}
};
\ No newline at end of file
Source diff could not be displayed: it is too large. Options to address this: view the blob.
...@@ -9,6 +9,7 @@ ...@@ -9,6 +9,7 @@
"@babel/core": "^7.0.0", "@babel/core": "^7.0.0",
"@babel/preset-env": "^7.0.0", "@babel/preset-env": "^7.0.0",
"babel-jest": "^27.0.0", "babel-jest": "^27.0.0",
"jest": "^27.0.0" "jest": "^28.1.3",
"jest-environment-jsdom": "^28.1.3"
} }
} }
import { DSIUnit } from "./dsiUnit.js";
import { dsiPrefixesHTML, dsiUnitsHTML, dsiKeyWords } from "./unitStrings.js";
// Build a default suggestion list from prefixes, units, and keywords.
const defaultAllowedTokens = [
...Object.keys(dsiPrefixesHTML),
...Object.keys(dsiUnitsHTML),
"per",
"tothe"
].sort();
export class DSIUnitInput extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: "open" });
// Internal state: store raw value
this._rawValue = "";
// Live update flag: if true, update suggestions and rendering on each keystroke.
this.liveUpdate = true;
// Allowed tokens (may be overridden via attribute)
this.allowedTokens = defaultAllowedTokens;
// Create our elements.
this.input = document.createElement("input");
this.input.type = "text";
this.input.id = "dsiInput";
this.suggestions = document.createElement("div");
this.suggestions.className = "autocomplete-list";
this.suggestions.style.display = "none";
this.display = document.createElement("div"); // For rendered output on blur.
this.display.id = "dsiDisplay";
// Wrap in a container (position: relative for suggestions).
const container = document.createElement("div");
container.style.position = "relative";
container.appendChild(this.input);
container.appendChild(this.suggestions);
container.appendChild(this.display);
// Append styles into the shadow DOM.
const style = document.createElement("style");
style.textContent = `
#dsiInput {
width: 400px;
font-family: monospace;
}
.autocomplete-list {
position: absolute;
border: 1px solid #ccc;
background: #fff;
max-height: 150px;
overflow-y: auto;
width: 400px;
z-index: 100;
}
.autocomplete-item {
padding: 4px;
cursor: pointer;
}
.autocomplete-item:hover {
background-color: #eee;
}
#dsiDisplay {
margin-top: 5px;
}
`;
this.shadowRoot.append(style, container);
// Bind event handlers.
this.onInput = this.onInput.bind(this);
this.onBlur = this.onBlur.bind(this);
this.onFocus = this.onFocus.bind(this);
}
static get observedAttributes() {
return ["suggestions-list"];
}
attributeChangedCallback(name, oldValue, newValue) {
if (name === "suggestions-list") {
if (newValue) {
this.allowedTokens = newValue.split(",").map(s => s.trim());
} else {
this.allowedTokens = defaultAllowedTokens;
}
}
}
connectedCallback() {
// Check for external suggestions list.
const suggestionsAttr = this.getAttribute("suggestions-list");
if (suggestionsAttr) {
this.allowedTokens = suggestionsAttr.split(",").map(s => s.trim());
}
this.input.addEventListener("input", this.onInput);
this.input.addEventListener("blur", this.onBlur);
this.input.addEventListener("focus", this.onFocus);
}
disconnectedCallback() {
this.input.removeEventListener("input", this.onInput);
this.input.removeEventListener("blur", this.onBlur);
this.input.removeEventListener("focus", this.onFocus);
}
onInput(e) {
// Store the raw value.
this._rawValue = this.input.value;
this.updateSuggestions();
if (this.liveUpdate) {
this.renderOutput();
}
}
updateSuggestions() {
const cursorPos = this.input.selectionStart;
const value = this.input.value;
const substring = value.substring(0, cursorPos);
// Look for a pattern: a backslash followed by at least one letter at the end.
const regex = /\\([a-zA-Z]+)$/;
const match = substring.match(regex);
if (match) {
const currentToken = match[1];
const suggestions = this.allowedTokens.filter(token =>
token.startsWith(currentToken)
);
this.showSuggestions(suggestions, substring.length - currentToken.length);
} else {
this.suggestions.style.display = "none";
}
}
showSuggestions(list, tokenStartIndex) {
this.suggestions.innerHTML = "";
if (list.length === 0) {
this.suggestions.style.display = "none";
return;
}
list.forEach(token => {
const item = document.createElement("div");
item.textContent = "\\" + token;
item.className = "autocomplete-item";
item.addEventListener("mousedown", (e) => {
e.preventDefault();
const value = this.input.value;
const before = value.substring(0, tokenStartIndex);
const after = value.substring(this.input.selectionStart);
this.input.value = before + "\\" + token + after;
this._rawValue = this.input.value;
this.suggestions.style.display = "none";
this.input.focus();
this.renderOutput();
});
this.suggestions.appendChild(item);
});
this.suggestions.style.display = "block";
}
renderOutput() {
try {
const unit = new DSIUnit(this.input.value);
// Display the rendered output using the DSIUnit's toHTML().
this.display.innerHTML = unit.toHTML();
if (unit.warnings && unit.warnings.length > 0) {
this.display.title = unit.warnings.join("; ");
} else {
this.display.removeAttribute("title");
}
} catch (err) {
this.display.innerHTML = `<span style="color:red;">Error: ${err.message}</span>`;
}
}
onBlur(e) {
// On blur, hide the input and show the rendered output.
this.renderOutput();
this.input.style.display = "none";
this.display.style.display = "block";
this.suggestions.style.display = "none";
}
onFocus(e) {
// On focus, restore the raw value for editing.
this.input.style.display = "block";
this.display.style.display = "none";
this.input.value = this._rawValue;
}
// Expose the current raw value.
get value() {
return this._rawValue;
}
// Allow setting live update mode.
set live(value) {
this.liveUpdate = Boolean(value);
}
}
customElements.define("dsi-unit-input", DSIUnitInput);
/**
* @jest-environment jsdom
*/
import "../src/dsiUnitInput.js";
import { DSIUnit } from "../src/dsiUnit.js";
describe("DSIUnitInput custom element", () => {
let element;
beforeEach(() => {
document.body.innerHTML = `<dsi-unit-input></dsi-unit-input>`;
element = document.querySelector("dsi-unit-input");
});
test("initializes with an input field", () => {
const input = element.shadowRoot.querySelector("#dsiInput");
expect(input).not.toBeNull();
});
test("shows suggestions after typing backslash and letters", () => {
const input = element.shadowRoot.querySelector("#dsiInput");
input.value = "\\m";
input.dispatchEvent(new Event("input"));
const suggestions = element.shadowRoot.querySelectorAll(".autocomplete-item");
expect(suggestions.length).toBeGreaterThan(0);
suggestions.forEach(item => {
expect(item.textContent).toMatch(/^\\m/);
});
});
test("on blur, input is replaced by rendered output", () => {
const input = element.shadowRoot.querySelector("#dsiInput");
const display = element.shadowRoot.querySelector("#dsiDisplay");
input.value = "\\metre";
input.dispatchEvent(new Event("input"));
input.dispatchEvent(new Event("blur"));
expect(input.style.display).toBe("none");
expect(display.style.display).toBe("block");
const unit = new DSIUnit("\\metre");
expect(display.innerHTML).toBe(unit.toHTML());
});
test("on focus, raw value is restored", () => {
const input = element.shadowRoot.querySelector("#dsiInput");
input.value = "\\metre\\tothe{2}";
input.dispatchEvent(new Event("input"));
input.dispatchEvent(new Event("blur"));
input.dispatchEvent(new Event("focus"));
expect(input.style.display).toBe("block");
expect(input.value).toBe("\\metre\\tothe{2}");
});
test("external suggestions list overrides defaults", () => {
// Set the attribute "suggestions-list" to only allow "foo" and "bar"
element.setAttribute("suggestions-list", "foo,bar");
// Force re-read of the attribute by calling attributeChangedCallback manually if needed.
const input = element.shadowRoot.querySelector("#dsiInput");
input.value = "\\f";
input.dispatchEvent(new Event("input"));
const suggestions = element.shadowRoot.querySelectorAll(".autocomplete-item");
expect(suggestions.length).toBeGreaterThan(0);
suggestions.forEach(item => {
expect(item.textContent).toMatch(/^\\(foo|bar)$/);
});
});
});
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<title>D-SI Units Visual Test & Documentation</title> <title>D-SI Units Visual Test & Documentation</title>
<style> <style>
body { body {
font-family: sans-serif; font-family: sans-serif;
margin: 20px; margin: 20px;
line-height: 1.6; line-height: 1.6;
} }
h1, h2, h3 { h1, h2, h3 {
color: #333; color: #333;
} }
table { table {
border-collapse: collapse; border-collapse: collapse;
width: 100%; width: 100%;
margin-bottom: 40px; margin-bottom: 40px;
} }
th, td { th, td {
border: 1px solid #ccc; border: 1px solid #ccc;
padding: 8px; padding: 8px;
text-align: left; text-align: left;
vertical-align: top; vertical-align: top;
} }
th { th {
background-color: #eee; background-color: #eee;
} }
pre { pre {
margin: 0; margin: 0;
font-family: monospace; font-family: monospace;
background: #f8f8f8; background: #f8f8f8;
padding: 4px; padding: 4px;
} }
/* Invalid tokens in red */ .dsi-invalid {
.dsi-invalid { color: red;
color: red; font-weight: bold;
font-weight: bold; }
} .dsi-error {
/* Error wrapper (for overall errors) in orange */ color: orange;
.dsi-error { }
color: orange; .dsi-fraction {
} display: inline-block;
/* Fraction styling */ text-align: center;
.dsi-fraction { vertical-align: middle;
display: inline-block; }
text-align: center; .dsi-numerator {
vertical-align: middle; display: block;
} border-bottom: 1px solid #000;
.dsi-numerator { padding: 0 2px;
display: block; line-height: 1.2;
border-bottom: 1px solid #000; }
padding: 0 2px; .dsi-denom {
line-height: 1.2; display: block;
} padding: 0 2px;
.dsi-denom { line-height: 1.2;
display: block; }
padding: 0 2px; .dsi-root {
line-height: 1.2; display: inline-flex;
} align-items: flex-start;
/* Radical styling */ }
.dsi-root { .dsi-root-symbol {
display: inline-flex; font-size: 1.2em;
align-items: flex-start; padding-right: 2px;
} }
.dsi-root-symbol { .dsi-root-content {
font-size: 1.2em; border-top: 1px solid #000;
padding-right: 2px; display: inline-block;
} padding-top: 2px;
.dsi-root-content { }
border-top: 1px solid #000; .dsi-mul {
display: inline-block; padding: 0 2px;
padding-top: 2px; }
} .dsi-unit-wrapper {}
.dsi-mul { #documentation {
padding: 0 2px; margin-bottom: 40px;
} padding: 10px;
.dsi-unit-wrapper { border: 1px solid #ccc;
/* Wrapper for the final output; tooltip is handled via the title attribute */ background-color: #fafafa;
} }
/* Documentation explanation styling */ .doc-table {
#documentation { margin-bottom: 20px;
margin-bottom: 40px; }
padding: 10px; .doc-table th {
border: 1px solid #ccc; background-color: #ddd;
background-color: #fafafa; }
} /* Styles for our custom auto-complete input */
/* Styling for the allowed prefixes and units tables */ dsi-unit-input {
.doc-table { display: block;
margin-bottom: 20px; margin-bottom: 40px;
} }
.doc-table th { </style>
background-color: #ddd;
}
/* Styling for the interactive section */
#interactiveSection {
border: 1px solid #ccc;
padding: 10px;
margin-top: 40px;
}
#dsiInput {
width: 400px;
font-family: monospace;
}
#interactiveResult {
margin-top: 10px;
padding: 10px;
border: 1px solid #ccc;
min-height: 40px;
}
#warningText {
color: red;
margin-top: 5px;
}
#liveUpdateContainer {
margin-top: 10px;
}
</style>
</head> </head>
<body> <body>
<h1>D-SI Units Visual Test & Documentation</h1> <h1>D-SI Units Visual Test & Documentation</h1>
<div id="documentation"> <div id="documentation">
<h2>How D-SI Units Are Composed</h2> <h2>How D-SI Units Are Composed</h2>
<p> <p>
A D-SI unit string is composed of one or more components, each preceded by a backslash (<code>\</code>). D-SI units are represented as strings where each component is preceded by a backslash (<code>\</code>).
The components include: A basic D-SI unit string is composed of:
</p> </p>
<ul> <ul>
<li>An <strong>optional prefix</strong> (e.g. <code>\kilo</code> for 10³, <code>\milli</code> for 10⁻³),</li> <li>An <strong>optional prefix</strong> (e.g. <code>\kilo</code> for 10³, <code>\milli</code> for 10⁻³),</li>
<li>A <strong>base unit</strong> (e.g. <code>\metre</code>, <code>\second</code>),</li> <li>A <strong>base unit</strong> (e.g. <code>\metre</code>, <code>\second</code>),</li>
<li>An optional exponent specified with <code>\tothe{...}</code> (e.g. <code>\metre\tothe{2}</code> for square metres). Common fractional exponents such as 0.5, 0.333333, and 0.25 are rendered as square, cube, and fourth roots respectively.</li> <li>An optional exponent specified with <code>\tothe{...}</code> (e.g. <code>\metre\tothe{2}</code> for square metre),
<li>An optional <strong>fraction operator</strong> <code>\per</code> to separate numerator and denominator (e.g. <code>\metre\per\second</code> for metres per second).</li> with fractional exponents like 0.5, 0.333333, or 0.25 rendered as square, cube, or fourth roots respectively.</li>
</ul> <li>An optional fraction operator <code>\per</code> is used to combine units into a fraction (e.g. <code>\metre\per\second</code> for metres per second).</li>
<p> </ul>
The rendered output mimics LaTeX-like formatting with radicals (a horizontal line over the radicand) and fractions. <p>
</p> Use the auto-complete input below to type a D-SI string. After typing a backslash and at least one letter,
</div> suggestions will appear based on the allowed tokens. When the field loses focus, the rendered unit is shown (while
<!-- Interactive Section --> the raw string is preserved for further editing when focused again). External suggestion lists can also be provided.
<h2>Interactive D-SI Unit Renderer</h2> </p>
<div id="interactiveSection">
<label for="dsiInput">Enter D-SI string:</label>
<input type="text" id="dsiInput" placeholder="e.g. \metre\tothe{0.333333}">
<button id="renderBtn">Render</button>
<div id="liveUpdateContainer">
<label for="liveUpdate">Live Update: </label>
<input type="checkbox" id="liveUpdate" checked>
</div>
<div id="interactiveResult"></div>
</div> </div>
<!-- New auto-complete input component -->
<h2>Interactive D-SI Input</h2>
<dsi-unit-input suggestions-list="kilo,mega,milli,metre,second,per,tothe"></dsi-unit-input>
<h2>Allowed Prefixes</h2> <h2>Allowed Prefixes</h2>
<table id="prefixes-table" class="doc-table"> <table id="prefixes-table" class="doc-table">
<thead> <thead>
<tr> <tr>
<th>Prefix Name</th> <th>Prefix Name</th>
<th>Symbol</th> <th>Symbol</th>
</tr> </tr>
</thead> </thead>
<tbody></tbody> <tbody></tbody>
</table> </table>
<h2>Allowed Units</h2> <h2>Allowed Units</h2>
<table id="units-table" class="doc-table"> <table id="units-table" class="doc-table">
<thead> <thead>
<tr> <tr>
<th>Unit Name</th> <th>Unit Name</th>
<th>Symbol</th> <th>Symbol</th>
</tr> </tr>
</thead> </thead>
<tbody></tbody> <tbody></tbody>
</table> </table>
<h2>Example D-SI Strings</h2> <h2>Example D-SI Strings</h2>
<h3>Valid D-SI Strings</h3> <h3>Valid D-SI Strings</h3>
<table id="valid-table"> <table id="valid-table">
<thead> <thead>
<tr> <tr>
<th>Raw D-SI String</th> <th>Raw D-SI String</th>
<th>Rendered Output</th> <th>Rendered Output</th>
</tr> </tr>
</thead> </thead>
<tbody></tbody> <tbody></tbody>
</table> </table>
<h3>Invalid D-SI Strings</h3> <h3>Invalid D-SI Strings</h3>
<table id="invalid-table"> <table id="invalid-table">
<thead> <thead>
<tr> <tr>
<th>Raw D-SI String</th> <th>Raw D-SI String</th>
<th>Rendered Output</th> <th>Rendered Output</th>
<th>Warnings</th> <th>Warnings</th>
</tr> </tr>
</thead> </thead>
<tbody></tbody> <tbody></tbody>
</table> </table>
<script type="module"> <!-- Existing interactive section (button controlled) -->
// Import the DSIUnit class and unit mappings. <h2>Interactive Renderer (Button Controlled)</h2>
import { DSIUnit } from "../src/dsiUnit.js"; <div id="interactiveSection">
import { dsiPrefixesHTML, dsiUnitsHTML } from "../src/unitStrings.js"; <label for="dsiInput">Enter D-SI string:</label>
<input type="text" id="dsiInput" placeholder="e.g. \metre\tothe{0.333333}">
// Populate Allowed Prefixes table. <button id="renderBtn">Render</button>
const prefixesTbody = document.getElementById('prefixes-table').querySelector('tbody'); <div id="interactiveResult"></div>
Object.keys(dsiPrefixesHTML).sort().forEach(prefix => { </div>
const tr = document.createElement('tr');
const tdName = document.createElement('td');
tdName.textContent = prefix;
const tdSymbol = document.createElement('td');
tdSymbol.textContent = dsiPrefixesHTML[prefix];
tr.appendChild(tdName);
tr.appendChild(tdSymbol);
prefixesTbody.appendChild(tr);
});
// Populate Allowed Units table.
const unitsTbody = document.getElementById('units-table').querySelector('tbody');
Object.keys(dsiUnitsHTML).sort().forEach(unit => {
const tr = document.createElement('tr');
const tdName = document.createElement('td');
tdName.textContent = unit;
const tdSymbol = document.createElement('td');
tdSymbol.textContent = dsiUnitsHTML[unit];
tr.appendChild(tdName);
tr.appendChild(tdSymbol);
unitsTbody.appendChild(tr);
});
// Sample D-SI strings for the examples.
const validDSI = [
'\\metre',
'\\metre\\tothe{2}',
'\\metre\\tothe{0.5}',
'\\mega\\metre\\per\\second\\tothe{2}',
'\\kilo\\metre\\per\\hour',
'\\metre\\per\\second',
'\\metre\\tothe{0.333333}', // Cube root
'\\metre\\tothe{0.25}' // Fourth root
];
const invalidDSI = [ <script type="module">
'\\foo', // Unknown unit. import { dsiPrefixesHTML, dsiUnitsHTML } from "../src/unitStrings.js";
'\\milli\\tothe{2}', // Missing base unit after prefix. import { DSIUnit } from "../src/dsiUnit.js";
'\\metre\\per', // Fraction missing numerator or denominator. import "../src/dsiUnitInput.js";
'\\\\metre\\per\\second' // Double backslashes.
];
function renderDSI(rawStr) { // Populate Allowed Prefixes table.
try { const prefixesTbody = document.getElementById('prefixes-table').querySelector('tbody');
const dsi = new DSIUnit(rawStr); Object.keys(dsiPrefixesHTML).sort().forEach(prefix => {
return { const tr = document.createElement('tr');
html: dsi.toHTML(), const tdName = document.createElement('td');
warnings: dsi.warnings tdName.textContent = prefix;
}; const tdSymbol = document.createElement('td');
} catch (error) { tdSymbol.textContent = dsiPrefixesHTML[prefix];
return { tr.appendChild(tdName);
html: `<span style="color:red;">Error: ${error.message}</span>`, tr.appendChild(tdSymbol);
warnings: [error.message] prefixesTbody.appendChild(tr);
}; });
}
}
function populateTable(tableId, dsiStrings) { // Populate Allowed Units table.
const tbody = document.getElementById(tableId).querySelector('tbody'); const unitsTbody = document.getElementById('units-table').querySelector('tbody');
dsiStrings.forEach(rawStr => { Object.keys(dsiUnitsHTML).sort().forEach(unit => {
const result = renderDSI(rawStr); const tr = document.createElement('tr');
const tr = document.createElement('tr'); const tdName = document.createElement('td');
const tdRaw = document.createElement('td'); tdName.textContent = unit;
tdRaw.innerHTML = `<pre>${rawStr}</pre>`; const tdSymbol = document.createElement('td');
tr.appendChild(tdRaw); tdSymbol.textContent = dsiUnitsHTML[unit];
const tdRendered = document.createElement('td'); tr.appendChild(tdName);
tdRendered.innerHTML = result.html; tr.appendChild(tdSymbol);
tr.appendChild(tdRendered); unitsTbody.appendChild(tr);
if (tableId === 'invalid-table') {
const tdWarnings = document.createElement('td');
tdWarnings.innerHTML = result.warnings.join('<br>');
tr.appendChild(tdWarnings);
}
tbody.appendChild(tr);
}); });
}
// Populate the example tables. // Example D-SI strings.
populateTable('valid-table', validDSI); const validDSI = [
populateTable('invalid-table', invalidDSI); '\\metre',
'\\metre\\tothe{2}',
'\\metre\\tothe{0.5}',
'\\mega\\metre\\per\\second\\tothe{2}',
'\\kilo\\metre\\per\\hour',
'\\metre\\per\\second',
'\\metre\\tothe{0.333333}', // Cube root
'\\metre\\tothe{0.25}' // Fourth root
];
const invalidDSI = [
'\\foo',
'\\milli\\tothe{2}',
'\\metre\\per',
'\\\\metre\\per\\second'
];
// Interactive section: re-render on button click or live update if enabled. function renderDSI(rawStr) {
const renderBtn = document.getElementById('renderBtn'); try {
const dsiInput = document.getElementById('dsiInput'); const dsi = new DSIUnit(rawStr);
const liveUpdateCheckbox = document.getElementById('liveUpdate'); return {
const interactiveResult = document.getElementById('interactiveResult'); html: dsi.toHTML(),
warnings: dsi.warnings
};
} catch (error) {
return {
html: `<span style="color:red;">Error: ${error.message}</span>`,
warnings: [error.message]
};
}
}
function updateInteractiveResult() { function populateTable(tableId, dsiStrings) {
const inputStr = dsiInput.value; const tbody = document.getElementById(tableId).querySelector('tbody');
try { dsiStrings.forEach(rawStr => {
const unit = new DSIUnit(inputStr); const result = renderDSI(rawStr);
const renderedHTML = unit.toHTML(); const tr = document.createElement('tr');
let warningsHTML = ""; const tdRaw = document.createElement('td');
if (unit.warnings.length > 0) { tdRaw.innerHTML = `<pre>${rawStr}</pre>`;
warningsHTML = `<div id="warningText">Warnings: ${unit.warnings.join("; ")}</div>`; tr.appendChild(tdRaw);
} const tdRendered = document.createElement('td');
interactiveResult.innerHTML = renderedHTML + warningsHTML; tdRendered.innerHTML = result.html;
} catch (err) { tr.appendChild(tdRendered);
interactiveResult.innerHTML = `<span style="color:red;">Error: ${err.message}</span>`; if (tableId === 'invalid-table') {
const tdWarnings = document.createElement('td');
tdWarnings.innerHTML = result.warnings.join('<br>');
tr.appendChild(tdWarnings);
}
tbody.appendChild(tr);
});
} }
}
renderBtn.addEventListener('click', updateInteractiveResult); populateTable('valid-table', validDSI);
populateTable('invalid-table', invalidDSI);
dsiInput.addEventListener('input', () => { // Button-controlled interactive section.
if (liveUpdateCheckbox.checked) { const renderBtn = document.getElementById('renderBtn');
updateInteractiveResult(); renderBtn.addEventListener('click', () => {
} const inputStr = document.getElementById('dsiInput').value;
}); try {
const unit = new DSIUnit(inputStr);
const renderedHTML = unit.toHTML();
let warningsHTML = "";
if (unit.warnings.length > 0) {
warningsHTML = `<div id="warningText">Warnings: ${unit.warnings.join("; ")}</div>`;
}
document.getElementById('interactiveResult').innerHTML = renderedHTML + warningsHTML;
} catch (err) {
document.getElementById('interactiveResult').innerHTML = `<span style="color:red;">Error: ${err.message}</span>`;
}
});
</script> </script>
</body> </body>
</html> </html>
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Please register or to comment