接入 LinuxCNC TP 运行反馈
This commit is contained in:
@@ -32,6 +32,9 @@ export function mountGmoccapyShell(root, store) {
|
||||
);
|
||||
|
||||
store.subscribe((state) => render(regions, state, store.dispatch));
|
||||
root.addEventListener("profile-change", (event) => {
|
||||
store.dispatch({ type: "SET_PROFILE", profileId: event.detail.profileId });
|
||||
});
|
||||
|
||||
return {
|
||||
getRegions() {
|
||||
@@ -46,7 +49,7 @@ function render(regions, state, dispatch) {
|
||||
renderTitlebar(regions.titlebar, state);
|
||||
renderPreview(regions.preview, state, dispatch);
|
||||
renderDro(regions.dro, state);
|
||||
renderGcode(regions.gcode, state);
|
||||
renderGcode(regions.gcode, state, dispatch);
|
||||
renderSidebar(regions["status-sidebar"], state, dispatch);
|
||||
renderInfoTabs(regions["info-tabs"], state);
|
||||
renderOverride(regions.override, state, dispatch);
|
||||
@@ -61,8 +64,22 @@ function renderTitlebar(element, state) {
|
||||
<strong>gmoccapy Web 5 Axis for LinuxCNC RTCP Simulation</strong>
|
||||
<span>${state.machineProfile} | ${state.sessionName} | ${state.sourceMode} | ${state.machine.mode}</span>
|
||||
</div>
|
||||
<label class="profile-select-label">
|
||||
Profile
|
||||
<select data-action="select-profile">
|
||||
${state.availableProfiles.map((profile) => `
|
||||
<option value="${profile.id}" ${profile.id === state.machineProfile ? "selected" : ""}>${profile.coordinates} ${profile.id}</option>
|
||||
`).join("")}
|
||||
</select>
|
||||
</label>
|
||||
<div class="run-state" data-run-state="${state.runState}">${state.runState}</div>
|
||||
`;
|
||||
element.querySelector('[data-action="select-profile"]').addEventListener("change", (event) => {
|
||||
element.dispatchEvent(new CustomEvent("profile-change", {
|
||||
bubbles: true,
|
||||
detail: { profileId: event.target.value },
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
function renderPreview(element, state, dispatch) {
|
||||
@@ -137,7 +154,7 @@ function droRow(axis, value, dtg) {
|
||||
`;
|
||||
}
|
||||
|
||||
function renderGcode(element, state) {
|
||||
function renderGcode(element, state, dispatch) {
|
||||
const rows = state.programLines
|
||||
.map((line, index) => {
|
||||
const lineNumber = state.programStartLine + index;
|
||||
@@ -158,12 +175,101 @@ function renderGcode(element, state) {
|
||||
<span data-program-source="${state.programSource}">${state.programSource}</span>
|
||||
<span data-active-program-line="${state.activeLine}">Current line ${state.activeLine}</span>
|
||||
</div>
|
||||
<div class="linuxcnc-source-row" data-linuxcnc-gcode-source="row">
|
||||
<label>
|
||||
LinuxCNC 5-axis source
|
||||
<select data-action="select-linuxcnc-gcode-source" ${state.machineFileStaging.gcodeSources?.length ? "" : "disabled"}>
|
||||
${renderLinuxCncGcodeSourceOptions(state)}
|
||||
</select>
|
||||
</label>
|
||||
<button type="button" data-action="stage-linuxcnc-sources">Stage</button>
|
||||
<span data-linuxcnc-gcode-source="status">${formatLinuxCncGcodeSourceStatus(state)}</span>
|
||||
</div>
|
||||
<ol class="gcode-list" start="${state.programStartLine}">${rows}</ol>
|
||||
<div class="gcode-progress">
|
||||
<span>${state.activeLine} / ${programEndLine}</span>
|
||||
<div><i style="width: ${progress}%"></i></div>
|
||||
</div>
|
||||
<section class="mdi-panel" data-mdi-mode="${state.machine.mode === "mdi"}">
|
||||
<form class="mdi-command-row" data-action="mdi-form">
|
||||
<strong>MDI</strong>
|
||||
<input
|
||||
type="text"
|
||||
data-action="mdi-command"
|
||||
value="${escapeHtml(state.machine.mdiCommand)}"
|
||||
spellcheck="false"
|
||||
autocomplete="off"
|
||||
aria-label="MDI command"
|
||||
/>
|
||||
<button type="submit" data-action="mdi-submit">Run</button>
|
||||
</form>
|
||||
<div class="mdi-history">
|
||||
${mdiQuickCommands(state).map((command) => `
|
||||
<button type="button" data-action="mdi-history" data-command="${escapeHtml(command)}">${escapeHtml(command)}</button>
|
||||
`).join("")}
|
||||
</div>
|
||||
</section>
|
||||
`;
|
||||
|
||||
const form = element.querySelector('[data-action="mdi-form"]');
|
||||
const input = element.querySelector('[data-action="mdi-command"]');
|
||||
form.addEventListener("submit", (event) => {
|
||||
event.preventDefault();
|
||||
dispatch({ type: "RUN_MDI", command: input.value });
|
||||
});
|
||||
input.addEventListener("change", () => {
|
||||
dispatch({ type: "SET_MDI_COMMAND", command: input.value });
|
||||
});
|
||||
for (const button of element.querySelectorAll('[data-action="mdi-history"]')) {
|
||||
button.addEventListener("click", () => {
|
||||
dispatch({ type: "RUN_MDI", command: button.dataset.command });
|
||||
});
|
||||
}
|
||||
element.querySelector('[data-action="stage-linuxcnc-sources"]').addEventListener("click", () => {
|
||||
dispatch({ type: "STAGE_MACHINE_FILES_REQUEST" });
|
||||
});
|
||||
const linuxCncSourceSelect = element.querySelector('[data-action="select-linuxcnc-gcode-source"]');
|
||||
linuxCncSourceSelect.addEventListener("change", () => {
|
||||
if (!linuxCncSourceSelect.value) return;
|
||||
dispatch({ type: "LOAD_LINUXCNC_GCODE_SOURCE", sourceRel: linuxCncSourceSelect.value });
|
||||
});
|
||||
}
|
||||
|
||||
function renderLinuxCncGcodeSourceOptions(state) {
|
||||
const sources = state.machineFileStaging.gcodeSources || [];
|
||||
if (sources.length === 0) {
|
||||
return `<option value="">stage machine files first</option>`;
|
||||
}
|
||||
const selected = state.machineFileStaging.selectedGcodeSourceRel || "";
|
||||
return [
|
||||
`<option value="">select source</option>`,
|
||||
...sources.map((source) => `
|
||||
<option value="${escapeHtml(source.sourceRel)}" ${source.sourceRel === selected ? "selected" : ""}>
|
||||
${escapeHtml(source.filename)}
|
||||
</option>
|
||||
`),
|
||||
].join("");
|
||||
}
|
||||
|
||||
function formatLinuxCncGcodeSourceStatus(state) {
|
||||
const sources = state.machineFileStaging.gcodeSources || [];
|
||||
if (sources.length === 0) return `${state.machineFileStaging.status} / no staged LinuxCNC G-code sources`;
|
||||
const selected = state.machineFileStaging.selectedGcodeSourceRel || "-";
|
||||
return `${sources.length} staged / ${selected}`;
|
||||
}
|
||||
|
||||
function mdiQuickCommands(state) {
|
||||
const profileCommands = state.profile.kinematicsParameters.switchkinsTypes
|
||||
.map((type) => type.mdiCommand)
|
||||
.filter(Boolean);
|
||||
return [
|
||||
...(state.mdiHistory || []),
|
||||
state.machine.mdiCommand,
|
||||
"G0 X0 Y0 Z0",
|
||||
"G91 X1",
|
||||
"G90",
|
||||
...profileCommands,
|
||||
].filter((command, index, commands) => command && commands.indexOf(command) === index).slice(0, 7);
|
||||
}
|
||||
|
||||
function renderSidebar(element, state, dispatch) {
|
||||
@@ -173,10 +279,10 @@ function renderSidebar(element, state, dispatch) {
|
||||
<button type="button" class="sidebar-button" data-action="reset">RESET</button>
|
||||
<button type="button" class="sidebar-button" data-action="mode-auto" data-active="${state.machine.mode === "auto"}">AUTO</button>
|
||||
<button type="button" class="sidebar-button" data-action="mode-manual" data-active="${state.machine.mode === "manual"}">MANUAL</button>
|
||||
<button type="button" class="sidebar-button" data-action="mode-jog" data-active="${state.machine.mode === "jog"}">JOG</button>
|
||||
<button type="button" class="sidebar-button" data-action="mode-jog" data-active="${state.machine.mode === "manual"}">JOG</button>
|
||||
<button type="button" class="sidebar-button" data-action="mode-mdi" data-active="${state.machine.mode === "mdi"}">MDI</button>
|
||||
<button type="button" class="sidebar-button" data-action="kins-identity" data-active="${state.kinsType === "identity"}">IDENTITY</button>
|
||||
<button type="button" class="sidebar-button" data-action="kins-tcp" data-active="${state.kinsType === "tcp-xyzac"}">TCP</button>
|
||||
<button type="button" class="sidebar-button" data-action="kins-tcp" data-active="${state.kinsType.startsWith("tcp-")}">TCP</button>
|
||||
<time>13:30:31<br />20.06.2026</time>
|
||||
`;
|
||||
|
||||
@@ -191,12 +297,14 @@ function renderSidebar(element, state, dispatch) {
|
||||
dispatch({ type: "SET_KINS_TYPE", kinsType: "identity" });
|
||||
});
|
||||
element.querySelector('[data-action="kins-tcp"]').addEventListener("click", () => {
|
||||
dispatch({ type: "SET_KINS_TYPE", kinsType: "tcp-xyzac" });
|
||||
const tcpKinsType = state.profile.kinematicsParameters.switchkinsTypes.find((type) => type.value === 1)?.webKinsType || "tcp-xyzac";
|
||||
dispatch({ type: "SET_KINS_TYPE", kinsType: tcpKinsType });
|
||||
});
|
||||
}
|
||||
|
||||
function renderInfoTabs(element, state) {
|
||||
const frame = state.rtcpFrame;
|
||||
const taskPolicy = state.linuxCncTaskPolicy;
|
||||
element.innerHTML = `
|
||||
<nav class="tabs">
|
||||
<button type="button" class="active">Tool info and G-codes</button>
|
||||
@@ -207,8 +315,21 @@ function renderInfoTabs(element, state) {
|
||||
<dt>Size:</dt><dd>${state.fileSizeBytes} bytes</dd>
|
||||
<dt>Lines:</dt><dd>${state.lineCount} gcode lines</dd>
|
||||
<dt>Machine:</dt><dd data-machine-state="summary">${state.machine.powerOn ? "power on" : "power off"} / ${state.machine.estopActive ? "estop" : "clear"} / ${state.machine.mode}</dd>
|
||||
<dt>Task policy:</dt><dd data-linuxcnc-task-policy="boundary">${taskPolicy.semanticBoundary}</dd>
|
||||
<dt>Task state:</dt><dd data-linuxcnc-task-policy="state">${taskPolicy.taskState} / ${taskPolicy.taskMode} / ${taskPolicy.interpState}</dd>
|
||||
<dt>Task gates:</dt><dd data-linuxcnc-task-policy="gates">${formatLinuxCncTaskGates(taskPolicy)}</dd>
|
||||
<dt>Task source:</dt><dd data-linuxcnc-task-policy="source">${formatLinuxCncTaskSources(taskPolicy)}</dd>
|
||||
<dt>Current line:</dt><dd data-program-current-line="${state.activeLine}">${state.activeLine}</dd>
|
||||
<dt>Program source:</dt><dd data-program-execution-source="${state.programExecutionSourceMode}">${state.programExecutionSourceMode}</dd>
|
||||
<dt>Canonical:</dt><dd data-program-execution-summary="${state.programExecution?.summary?.motionEventCount ?? 0}">${state.programExecution?.summary?.motionEventCount ?? 0} motion / ${state.programExecution?.summary?.canonicalEventCount ?? 0} events</dd>
|
||||
<dt>Switchkins:</dt><dd data-program-switchkins-summary="${state.programExecution?.summary?.switchkinsEventCount ?? 0}">${formatSwitchkinsSummary(state.programExecution)}</dd>
|
||||
<dt>Machine run:</dt><dd data-machine-file-execution="status">${formatMachineFileExecution(state.machineFileExecution)}</dd>
|
||||
<dt>Session:</dt><dd data-session-persistence="status">${state.sessionPersistence.status} / ${state.sessionPersistence.path ?? "-"}</dd>
|
||||
<dt>Tool preview:</dt><dd data-tool-preview="detail">T${state.toolPreview.toolNumber} D${formatNumber(state.toolPreview.diameter, 2)} L${formatNumber(state.toolPreview.length, 3)} ${state.toolPreview.units}</dd>
|
||||
<dt>Program time:</dt><dd data-program-timing="summary">${formatProgramTiming(state)}</dd>
|
||||
<dt>Segment time:</dt><dd data-program-timing="segment">${formatProgramTimingSegment(state)}</dd>
|
||||
<dt>Runtime feedback:</dt><dd data-program-runtime-feedback="source">${formatProgramRuntimeFeedback(state)}</dd>
|
||||
<dt>Runtime DTG:</dt><dd data-program-runtime-feedback="dtg">${formatProgramRuntimeDtg(state)}</dd>
|
||||
<dt>Rapid distance:</dt><dd>37.634 mm</dd>
|
||||
<dt>Feed distance:</dt><dd>5814.069 mm</dd>
|
||||
<dt>X bounds:</dt><dd>8.000 to 113.000 = 105.000 mm</dd>
|
||||
@@ -216,7 +337,19 @@ function renderInfoTabs(element, state) {
|
||||
<dt>Z bounds:</dt><dd>-90.500 to -50.000 = 40.500 mm</dd>
|
||||
<dt>RTCP frame:</dt><dd data-rtcp-diagnostic="frame">${frame.apiName} ${frame.rtcpState}</dd>
|
||||
<dt>Boundary:</dt><dd data-rtcp-diagnostic="boundary">${frame.semanticBoundary}</dd>
|
||||
<dt>INI:</dt><dd data-linuxcnc-ini="status">${state.iniConfigReadiness.loaded ? "loaded" : "pending"} / ${state.iniConfigReadiness.path ?? "-"}</dd>
|
||||
<dt>INI kins:</dt><dd data-linuxcnc-ini="kins">${state.iniConfigReadiness.kinematics ?? "-"} / ${state.iniConfigReadiness.coordinates ?? "-"}</dd>
|
||||
<dt>INI limits:</dt><dd data-linuxcnc-ini="limits">${formatAxisLimitSummary(state.profile.axisLimits)}</dd>
|
||||
<dt>INI joints:</dt><dd data-linuxcnc-ini="joints">${state.iniConfigReadiness.jointCount ?? 0} joints / ${state.iniConfigReadiness.axisCount ?? 0} axes</dd>
|
||||
<dt>Machine files:</dt><dd data-machine-file-staging="status">${formatMachineFileStaging(state.machineFileStaging)}</dd>
|
||||
<dt>LinuxCNC G-code:</dt><dd data-linuxcnc-gcode-source="selected">${state.machineFileStaging.selectedGcodeSourceRel || state.programSourceRel || "-"}</dd>
|
||||
<dt>Full boundary:</dt><dd data-full-execution-boundary="status">${formatFullExecutionBoundary(state.fullExecutionBoundary)}</dd>
|
||||
<dt>Planner/task:</dt><dd data-full-execution-boundary="blockers">${formatFullExecutionBlockers(state.fullExecutionBoundary)}</dd>
|
||||
<dt>Boundary evidence:</dt><dd data-full-execution-boundary="evidence">${formatFullExecutionEvidence(state.fullExecutionBoundary)}</dd>
|
||||
<dt>LinuxCNC kins:</dt><dd data-rtcp-diagnostic="kinematics-ready">${frame.readiness.linuxCncKinematicsReady ? "ready" : "pending"}</dd>
|
||||
<dt>Kins context:</dt><dd data-rtcp-diagnostic="execution-context">${state.kinematicsExecutionContext}</dd>
|
||||
<dt>Interpreter:</dt><dd data-linuxcnc-boundary="interpreter">${state.interpreterRuntimeReadiness?.loaded ? state.interpreterRuntimeReadiness.semanticBoundary : "pending"}</dd>
|
||||
<dt>Interp context:</dt><dd data-linuxcnc-boundary="interpreter-context">${state.interpreterRuntimeReadiness?.executionContext ?? "none"}</dd>
|
||||
<dt>Profile refs:</dt><dd>${state.profile.sourceReferences.length} source references</dd>
|
||||
<dt>Adapter:</dt><dd data-linuxcnc-boundary="adapter">${state.linuxCncBoundaryAdapter.apiName}</dd>
|
||||
<dt>Panel schema:</dt><dd data-linuxcnc-boundary="panel">${state.linuxCncBoundaryAdapter.panelSummary.schemaId} / ${state.linuxCncBoundaryAdapter.panelSummary.buttonCount} buttons</dd>
|
||||
@@ -227,6 +360,97 @@ function renderInfoTabs(element, state) {
|
||||
`;
|
||||
}
|
||||
|
||||
function formatLinuxCncTaskGates(taskPolicy) {
|
||||
if (!taskPolicy) return "pending";
|
||||
return [
|
||||
taskPolicy.canJog ? "jog" : "jog blocked",
|
||||
taskPolicy.canHome ? "home" : "home blocked",
|
||||
taskPolicy.canRunAuto ? "auto" : "auto blocked",
|
||||
taskPolicy.canExecuteMdi ? "mdi" : "mdi blocked",
|
||||
taskPolicy.canPause ? "pause" : "pause blocked",
|
||||
taskPolicy.canResume ? "resume" : "resume blocked",
|
||||
].join(" / ");
|
||||
}
|
||||
|
||||
function formatLinuxCncTaskSources(taskPolicy) {
|
||||
if (!taskPolicy?.sourceReferences?.length) return "pending";
|
||||
return taskPolicy.sourceReferences
|
||||
.map((reference) => reference.path)
|
||||
.join(" | ");
|
||||
}
|
||||
|
||||
function formatProgramTiming(state) {
|
||||
const timing = state.programExecutionTiming;
|
||||
if (!timing) return "pending";
|
||||
return `${formatDuration(state.programElapsedSeconds)} / ${formatDuration(timing.totalSeconds)} (${formatDuration(state.programRemainingSeconds)} left)`;
|
||||
}
|
||||
|
||||
function formatProgramTimingSegment(state) {
|
||||
const segment = state.programExecutionTiming?.segments?.[state.programExecutionMotionIndex || 0];
|
||||
if (!segment) return "pending";
|
||||
return `${segment.motionClass} line ${segment.line ?? "-"} ${formatNumber(segment.linearDistanceMm, 3)} mm ${formatDuration(segment.durationSeconds)} @ ${formatNumber(segment.velocityMmPerMin, 1)} mm/min`;
|
||||
}
|
||||
|
||||
function formatProgramRuntimeFeedback(state) {
|
||||
const feedback = state.programRuntimeFeedback;
|
||||
if (!feedback) return "pending";
|
||||
return `${feedback.sourceMode} sample ${feedback.sampleIndex ?? 0} line ${feedback.line ?? "-"} queue ${feedback.queueDepth ?? 0}/${feedback.activeDepth ?? 0} @ ${formatNumber(feedback.currentVelocityMmPerMin, 1)} mm/min`;
|
||||
}
|
||||
|
||||
function formatProgramRuntimeDtg(state) {
|
||||
const feedback = state.programRuntimeFeedback;
|
||||
if (!feedback) return "pending";
|
||||
const dtg = feedback.dtg || {};
|
||||
return `DTG ${formatNumber(dtg.x, 3)} / ${formatNumber(dtg.y, 3)} / ${formatNumber(dtg.z, 3)} distance ${formatNumber(feedback.distanceToGo, 3)}`;
|
||||
}
|
||||
|
||||
function formatDuration(seconds) {
|
||||
const safeSeconds = Math.max(Number(seconds) || 0, 0);
|
||||
const minutes = Math.floor(safeSeconds / 60);
|
||||
const remainder = safeSeconds - minutes * 60;
|
||||
return `${minutes}:${remainder.toFixed(1).padStart(4, "0")}`;
|
||||
}
|
||||
|
||||
function formatSwitchkinsSummary(programExecution) {
|
||||
const count = programExecution?.summary?.switchkinsEventCount ?? 0;
|
||||
if (count === 0) return "0 events";
|
||||
const codes = programExecution.summary.switchkinsCodes?.join("/") || "-";
|
||||
return `${count} events ${codes}`;
|
||||
}
|
||||
|
||||
function formatMachineFileStaging(machineFileStaging) {
|
||||
if (!machineFileStaging || machineFileStaging.status === "not-staged") {
|
||||
return "not staged";
|
||||
}
|
||||
if (machineFileStaging.status === "error") {
|
||||
return `error ${machineFileStaging.lastError || "-"}`;
|
||||
}
|
||||
return `${machineFileStaging.status} ${machineFileStaging.fileCount || 0} files ${machineFileStaging.opfsRoot || "-"}`;
|
||||
}
|
||||
|
||||
function formatMachineFileExecution(machineFileExecution) {
|
||||
if (!machineFileExecution?.machineFilePlan) return "not run";
|
||||
const summary = machineFileExecution.summary || {};
|
||||
return `${summary.machineFileExecutionReady ? "ready" : "ran"} ${summary.motionEventCount || 0} motion ${machineFileExecution.machineFilePlan.profileId}`;
|
||||
}
|
||||
|
||||
function formatFullExecutionBoundary(boundary) {
|
||||
if (!boundary) return "pending";
|
||||
const remap = boundary.machineFileBackedRemapReady ? "remap ready" : "remap pending";
|
||||
const full = boundary.fullLinuxCncProgramExecutionReady ? "full ready" : "full blocked";
|
||||
return `${boundary.phase} / ${remap} / ${full}`;
|
||||
}
|
||||
|
||||
function formatFullExecutionBlockers(boundary) {
|
||||
if (!boundary) return "pending";
|
||||
return boundary.blockers.slice(0, 2).join("; ");
|
||||
}
|
||||
|
||||
function formatFullExecutionEvidence(boundary) {
|
||||
if (!boundary) return "pending";
|
||||
return `${boundary.satisfied.length} satisfied / ${boundary.missing.length} missing / ${boundary.semanticBoundary}`;
|
||||
}
|
||||
|
||||
function renderOverride(element, state, dispatch) {
|
||||
element.innerHTML = `
|
||||
<section class="meter-card">
|
||||
@@ -301,6 +525,7 @@ function renderBottomControls(element, state, dispatch) {
|
||||
["Run", "RUN", () => dispatch({ type: "RUN" })],
|
||||
["Stop", "STOP", () => dispatch({ type: "STOP" })],
|
||||
["Pause", "PAUSE", () => dispatch({ type: "PAUSE" })],
|
||||
["Resume", "RESUME", () => dispatch({ type: "RESUME" })],
|
||||
["Step", "STEP", () => dispatch({ type: "STEP" })],
|
||||
["Home", "HOME", () => dispatch({ type: "HOME" })],
|
||||
["X-", "JOG_X_NEG", () => dispatch({ type: "JOG", axis: "x", direction: -1 })],
|
||||
@@ -308,6 +533,9 @@ function renderBottomControls(element, state, dispatch) {
|
||||
["Y-", "JOG_Y_NEG", () => dispatch({ type: "JOG", axis: "y", direction: -1 })],
|
||||
["Y+", "JOG_Y_POS", () => dispatch({ type: "JOG", axis: "y", direction: 1 })],
|
||||
["MDI", "MDI_RUN", () => dispatch({ type: "RUN_MDI" })],
|
||||
["Save Session", "SAVE_SESSION", () => dispatch({ type: "SAVE_SESSION_REQUEST" })],
|
||||
["Restore Session", "RESTORE_SESSION", () => dispatch({ type: "RESTORE_SESSION_REQUEST" })],
|
||||
["Audit", "AUDIT_FULL_BOUNDARY", () => dispatch({ type: "RUN_FULL_BOUNDARY_AUDIT_REQUEST" })],
|
||||
["Full", "FULL", () => dispatch({ type: "TOGGLE_FULLSCREEN" })],
|
||||
];
|
||||
|
||||
@@ -345,6 +573,12 @@ function formatNumber(value, digits = 3) {
|
||||
return Number(value).toFixed(digits);
|
||||
}
|
||||
|
||||
function formatAxisLimitSummary(axisLimits) {
|
||||
return Object.entries(axisLimits || {})
|
||||
.map(([axis, limit]) => `${axis}[${formatNumber(limit.min, 0)},${formatNumber(limit.max, 0)}]`)
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value)
|
||||
.replaceAll("&", "&")
|
||||
|
||||
Reference in New Issue
Block a user