fune/toolkit/content/aboutwebrtc/aboutWebrtc.js
Dan Minor 9b31893b44 Bug 976669 - Estimate bitrate for local streams; r=ng
This would be improved by smoothing the values a bit, but I'm not sure that is
worth the extra complexity at this point. I left out the remote values because
they do not update regularly, but with smoothing, we might be able to
include them as well.

Differential Revision: https://phabricator.services.mozilla.com/D83318
2020-07-15 15:03:41 +00:00

978 lines
27 KiB
JavaScript

/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
"use strict";
const { XPCOMUtils } = ChromeUtils.import(
"resource://gre/modules/XPCOMUtils.jsm"
);
const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");
ChromeUtils.defineModuleGetter(
this,
"FileUtils",
"resource://gre/modules/FileUtils.jsm"
);
XPCOMUtils.defineLazyServiceGetter(
this,
"FilePicker",
"@mozilla.org/filepicker;1",
"nsIFilePicker"
);
XPCOMUtils.defineLazyGetter(this, "strings", () =>
Services.strings.createBundle("chrome://global/locale/aboutWebrtc.properties")
);
const string = strings.GetStringFromName;
const format = strings.formatStringFromName;
const WGI = WebrtcGlobalInformation;
const LOGFILE_NAME_DEFAULT = "aboutWebrtc.html";
const WEBRTC_TRACE_ALL = 65535;
async function getStats() {
const { reports } = await new Promise(r => WGI.getAllStats(r));
return [...reports].sort((a, b) => b.timestamp - a.timestamp);
}
const getLog = () => new Promise(r => WGI.getLogging("", r));
const renderElement = (name, options) =>
Object.assign(document.createElement(name), options);
const renderText = (name, textContent, options) =>
renderElement(name, Object.assign({ textContent }, options));
const renderElements = (name, options, list) => {
const element = renderElement(name, options);
element.append(...list);
return element;
};
// Button control classes
class Control {
label = null;
message = null;
messageHeader = null;
render() {
this.ctrl = renderElement("button", { onclick: () => this.onClick() });
this.msg = renderElement("p");
this.update();
return [this.ctrl, this.msg];
}
update() {
this.ctrl.textContent = this.label;
this.msg.textContent = "";
if (this.message) {
this.msg.append(
renderText("span", `${this.messageHeader}: `, {
className: "info-label",
}),
this.message
);
}
}
}
class SavePage extends Control {
constructor() {
super();
this.messageHeader = string("save_page_label");
this.label = string("save_page_label");
}
async onClick() {
FoldEffect.expandAll();
FilePicker.init(
window,
string("save_page_dialog_title"),
FilePicker.modeSave
);
FilePicker.defaultString = LOGFILE_NAME_DEFAULT;
const rv = await new Promise(r => FilePicker.open(r));
if (rv != FilePicker.returnOK && rv != FilePicker.returnReplace) {
return;
}
const fout = FileUtils.openAtomicFileOutputStream(
FilePicker.file,
FileUtils.MODE_WRONLY | FileUtils.MODE_CREATE
);
const content = document.querySelector("#content");
const noPrintList = [...content.querySelectorAll(".no-print")];
for (const node of noPrintList) {
node.style.setProperty("display", "none");
}
try {
fout.write(content.outerHTML, content.outerHTML.length);
} finally {
FileUtils.closeAtomicFileOutputStream(fout);
for (const node of noPrintList) {
node.style.removeProperty("display");
}
}
this.message = format("save_page_msg", [FilePicker.file.path]);
this.update();
}
}
class DebugMode extends Control {
constructor() {
super();
this.messageHeader = string("debug_mode_msg_label");
if (WGI.debugLevel > 0) {
this.setState(true);
} else {
this.label = string("debug_mode_off_state_label");
}
}
setState(state) {
const stateString = state ? "on" : "off";
this.label = string(`debug_mode_${stateString}_state_label`);
try {
const file = Services.prefs.getCharPref("media.webrtc.debug.log_file");
this.message = format(`debug_mode_${stateString}_state_msg`, [file]);
} catch (e) {
this.message = null;
}
return state;
}
onClick() {
this.setState((WGI.debugLevel = WGI.debugLevel ? 0 : WEBRTC_TRACE_ALL));
this.update();
}
}
class AecLogging extends Control {
constructor() {
super();
this.messageHeader = string("aec_logging_msg_label");
if (WGI.aecDebug) {
this.setState(true);
} else {
this.label = string("aec_logging_off_state_label");
this.message = null;
}
}
setState(state) {
this.label = string(`aec_logging_${state ? "on" : "off"}_state_label`);
try {
if (!state) {
const file = WGI.aecDebugLogDir;
this.message = format("aec_logging_off_state_msg", [file]);
} else {
this.message = string("aec_logging_on_state_msg");
}
} catch (e) {
this.message = null;
}
}
onClick() {
this.setState((WGI.aecDebug = !WGI.aecDebug));
this.update();
}
}
(async () => {
// Setup. Retrieve reports & log while page loads.
const haveReports = getStats();
const haveLog = getLog();
await new Promise(r => (window.onload = r));
document.title = string("document_title");
{
const ctrl = renderElement("div", { className: "control" });
const msg = renderElement("div", { className: "message" });
const add = ([control, message]) => {
ctrl.appendChild(control);
msg.appendChild(message);
};
add(new SavePage().render());
add(new DebugMode().render());
add(new AecLogging().render());
const ctrls = document.querySelector("#controls");
ctrls.append(renderElements("div", { className: "controls" }, [ctrl, msg]));
}
// Render pcs and log
let reports = await haveReports;
let log = await haveLog;
let peerConnections = renderElement("div");
let connectionLog = renderElement("div");
const content = document.querySelector("#content");
content.append(peerConnections, connectionLog);
function refresh() {
const pcDiv = renderElements("div", { className: "stats" }, [
renderElements("span", { className: "section-heading" }, [
renderText("h3", string("stats_heading")),
renderText("button", string("stats_clear"), {
className: "no-print",
onclick: async () => {
WGI.clearAllStats();
reports = await getStats();
refresh();
},
}),
]),
...reports.map(renderPeerConnection),
]);
const logDiv = renderElements("div", { className: "log" }, [
renderElement("span", { className: "section-heading" }, [
renderText("h3", string("log_heading")),
renderElement("button", {
textContent: string("log_clear"),
className: "no-print",
onclick: async () => {
WGI.clearLogging();
log = await getLog();
refresh();
},
}),
]),
]);
if (log.length) {
const div = renderFoldableSection(logDiv, {
showMsg: string("log_show_msg"),
hideMsg: string("log_hide_msg"),
});
div.append(...log.map(line => renderText("p", line)));
logDiv.append(div);
}
// Replace previous info
peerConnections.replaceWith(pcDiv);
connectionLog.replaceWith(logDiv);
peerConnections = pcDiv;
connectionLog = logDiv;
}
refresh();
window.setInterval(
async history => {
const reports = await getStats();
reports.forEach(report => {
const replace = (id, renderFunc) => {
const elem = document.getElementById(`${id}: ${report.pcid}`);
if (elem) {
elem.replaceWith(renderFunc(report, history));
}
};
replace("ice-stats", renderICEStats);
replace("rtp-stats", renderRTPStats);
replace("frame-stats", renderFrameRateStats);
});
},
500,
{}
);
})();
function renderPeerConnection(report) {
const { pcid, closed, timestamp, configuration } = report;
const pcDiv = renderElement("div", { className: "peer-connection" });
{
const id = pcid.match(/id=(\S+)/)[1];
const url = pcid.match(/url=([^)]+)/)[1];
const closedStr = closed ? `(${string("connection_closed")})` : "";
const now = new Date(timestamp).toString();
pcDiv.append(renderText("h3", `[ ${id} ] ${url} ${closedStr} ${now}`));
}
{
const section = renderFoldableSection(pcDiv);
section.append(
renderElements("div", {}, [
renderText("span", `${string("peer_connection_id_label")}: `, {
className: "info-label",
}),
renderText("span", pcid, { className: "info-body" }),
]),
renderConfiguration(configuration),
renderICEStats(report),
renderSDPStats(report),
renderFrameRateStats(report),
renderRTPStats(report)
);
pcDiv.append(section);
}
return pcDiv;
}
function renderSDPStats({ offerer, localSdp, remoteSdp, sdpHistory }) {
const trimNewlines = sdp => sdp.replaceAll("\r\n", "\n");
const statsDiv = renderElements("div", {}, [
renderText("h4", string("sdp_heading")),
renderText(
"h5",
`${string("local_sdp_heading")} (${string(offerer ? "offer" : "answer")})`
),
renderText("pre", trimNewlines(localSdp)),
renderText(
"h5",
`${string("remote_sdp_heading")} (${string(
offerer ? "answer" : "offer"
)})`
),
renderText("pre", trimNewlines(remoteSdp)),
renderText("h4", string("sdp_history_heading")),
]);
// All SDP in sequential order. Add onclick handler to scroll the associated
// SDP into view below.
for (const { isLocal, timestamp } of sdpHistory) {
const histDiv = renderElement("div", {});
const text = renderText(
"h5",
format("sdp_set_at_timestamp", [
string(`${isLocal ? "local" : "remote"}_sdp_heading`),
timestamp,
]),
{ className: "sdp-history-link" }
);
text.onclick = () => {
const elem = document.getElementById("sdp-history: " + timestamp);
if (elem) {
elem.scrollIntoView();
}
};
histDiv.append(text);
statsDiv.append(histDiv);
}
// Render the SDP into separate columns for local and remote.
const section = renderElement("div", { className: "sdp-history" });
const localDiv = renderElements("div", {}, [
renderText("h4", `${string("local_sdp_heading")}`),
]);
const remoteDiv = renderElements("div", {}, [
renderText("h4", `${string("remote_sdp_heading")}`),
]);
let first = NaN;
for (const { isLocal, timestamp, sdp, errors } of sdpHistory) {
if (isNaN(first)) {
first = timestamp;
}
const histDiv = isLocal ? localDiv : remoteDiv;
histDiv.append(
renderText(
"h5",
format("sdp_set_timestamp", [timestamp, timestamp - first]),
{ id: "sdp-history: " + timestamp }
)
);
if (errors.length) {
histDiv.append(renderElement("h5", string("sdp_parsing_errors_heading")));
}
for (const { lineNumber, error } of errors) {
histDiv.append(renderElement("br"), `${lineNumber}: ${error}`);
}
histDiv.append(renderText("pre", trimNewlines(sdp)));
}
section.append(localDiv, remoteDiv);
statsDiv.append(section);
return statsDiv;
}
function renderFrameRateStats(report) {
const statsDiv = renderElement("div", { id: "frame-stats: " + report.pcid });
report.videoFrameHistories.forEach(history => {
const stats = history.entries.map(stat => {
stat.elapsed = stat.lastFrameTimestamp - stat.firstFrameTimestamp;
if (stat.elapsed < 1) {
stat.elapsed = 0;
}
stat.elapsed = (stat.elapsed / 1_000).toFixed(3);
if (stat.elapsed && stat.consecutiveFrames) {
stat.avgFramerate = (stat.consecutiveFrames / stat.elapsed).toFixed(2);
} else {
stat.avgFramerate = string("n_a");
}
return stat;
});
const table = renderSimpleTable(
"",
[
"width_px",
"height_px",
"consecutive_frames",
"time_elapsed",
"estimated_framerate",
"rotation_degrees",
"first_frame_timestamp",
"last_frame_timestamp",
"local_receive_ssrc",
"remote_send_ssrc",
].map(columnName => string(columnName)),
stats.map(stat =>
[
stat.width,
stat.height,
stat.consecutiveFrames,
stat.elapsed,
stat.avgFramerate,
stat.rotationAngle,
stat.firstFrameTimestamp,
stat.lastFrameTimestamp,
stat.localSsrc,
stat.remoteSsrc || "?",
].map(entry => (Object.is(entry, undefined) ? "<<undefined>>" : entry))
)
);
statsDiv.append(
renderText(
"h4",
`${string("frame_stats_heading")} - MediaStreamTrack Id: ${
history.trackIdentifier
}`
),
table
);
});
return statsDiv;
}
function renderRTPStats(report, history) {
const rtpStats = [
...(report.inboundRtpStreamStats || []),
...(report.outboundRtpStreamStats || []),
];
const remoteRtpStats = [
...(report.remoteInboundRtpStreamStats || []),
...(report.remoteOutboundRtpStreamStats || []),
];
// Generate an id-to-streamStat index for each remote streamStat. This will
// be used next to link the remote to its local side.
const remoteRtpStatsMap = {};
for (const stat of remoteRtpStats) {
remoteRtpStatsMap[stat.id] = stat;
}
// If a streamStat has a remoteId attribute, create a remoteRtpStats
// attribute that references the remote streamStat entry directly.
// That is, the index generated above is merged into the returned list.
for (const stat of rtpStats.filter(s => "remoteId" in s)) {
stat.remoteRtpStats = remoteRtpStatsMap[stat.remoteId];
}
const stats = [...rtpStats, ...remoteRtpStats];
// Render stats set
return renderElements("div", { id: "rtp-stats: " + report.pcid }, [
renderText("h4", string("rtp_stats_heading")),
...stats.map(stat => {
const { id, remoteId, remoteRtpStats } = stat;
const div = renderElements("div", {}, [
renderText("h5", id),
renderCoderStats(stat),
renderTransportStats(stat, true, history),
]);
if (remoteId && remoteRtpStats) {
div.append(renderTransportStats(remoteRtpStats, false));
}
return div;
}),
]);
}
function renderCoderStats({
bitrateMean,
bitrateStdDev,
framerateMean,
framerateStdDev,
droppedFrames,
discardedPackets,
packetsReceived,
}) {
let s = "";
if (bitrateMean) {
s += ` ${string("avg_bitrate_label")}: ${(bitrateMean / 1000000).toFixed(
2
)} Mbps`;
if (bitrateStdDev) {
s += ` (${(bitrateStdDev / 1000000).toFixed(2)} SD)`;
}
}
if (framerateMean) {
s += ` ${string("avg_framerate_label")}: ${framerateMean.toFixed(2)} fps`;
if (framerateStdDev) {
s += ` (${framerateStdDev.toFixed(2)} SD)`;
}
}
if (droppedFrames) {
s += ` ${string("dropped_frames_label")}: ${droppedFrames}`;
}
if (discardedPackets) {
s += ` ${string("discarded_packets_label")}: ${discardedPackets}`;
}
if (s.length) {
s = ` ${string(`${packetsReceived ? "de" : "en"}coder_label`)}:${s}`;
}
return renderText("p", s);
}
function renderTransportStats(
{
id,
timestamp,
type,
ssrc,
packetsReceived,
bytesReceived,
packetsLost,
jitter,
roundTripTime,
packetsSent,
bytesSent,
},
local,
history
) {
const typeLabel = local ? string("typeLocal") : string("typeRemote");
if (history) {
if (history[id] === undefined) {
history[id] = {};
}
}
const estimateKbps = (timestamp, lastTimestamp, bytes, lastBytes) => {
if (!timestamp || !lastTimestamp || !bytes || !lastBytes) {
return string("n_a");
}
const elapsedTime = timestamp - lastTimestamp;
if (elapsedTime <= 0) {
return string("n_a");
}
return ((bytes - lastBytes) / elapsedTime).toFixed(1);
};
const time = new Date(timestamp).toTimeString();
let s = `${typeLabel}: ${time} ${type} SSRC: ${ssrc}`;
const packets = string("packets");
if (packetsReceived) {
s += ` ${string("received_label")}: ${packetsReceived} ${packets}`;
if (bytesReceived) {
s += ` (${(bytesReceived / 1024).toFixed(2)} Kb`;
if (local && history) {
s += ` , ~${estimateKbps(
timestamp,
history[id].lastTimestamp,
bytesReceived,
history[id].lastBytesReceived
)} Kbps`;
}
s += ")";
}
s += ` ${string("lost_label")}: ${packetsLost} ${string(
"jitter_label"
)}: ${jitter}`;
if (roundTripTime) {
s += ` RTT: ${roundTripTime * 1000} ms`;
}
} else if (packetsSent) {
s += ` ${string("sent_label")}: ${packetsSent} ${packets}`;
if (bytesSent) {
s += ` (${(bytesSent / 1024).toFixed(2)} Kb`;
if (local && history) {
s += `, ~${estimateKbps(
timestamp,
history[id].lastTimestamp,
bytesSent,
history[id].lastBytesSent
)} Kbps`;
}
s += ")";
}
}
// Update history
if (history) {
history[id].lastBytesReceived = bytesReceived;
history[id].lastBytesSent = bytesSent;
history[id].lastTimestamp = timestamp;
}
return renderText("p", s);
}
function renderRawIceTable(caption, candidates) {
const table = renderSimpleTable(
"",
[string(caption)],
[...new Set(candidates.sort())].filter(i => i).map(i => [i])
);
table.className = "raw-candidate";
return table;
}
function renderConfiguration(c) {
const provided = string("configuration_element_provided");
const notProvided = string("configuration_element_not_provided");
// Create the text for a configuration field
const cfg = (obj, key) => [
renderElement("br"),
`${key}: `,
key in obj ? obj[key] : renderText("i", notProvided),
];
// Create the text for a fooProvided configuration field
const pro = (obj, key) => [
renderElement("br"),
`${key}(`,
renderText("i", provided),
`/`,
renderText("i", notProvided),
`): `,
renderText("i", obj[`${key}Provided`] ? provided : notProvided),
];
return renderElements("div", { classList: "peer-connection-config" }, [
"RTCConfiguration",
...cfg(c, "bundlePolicy"),
...cfg(c, "iceTransportPolicy"),
...pro(c, "peerIdentity"),
...cfg(c, "sdpSemantics"),
renderElement("br"),
"iceServers: ",
...(!c.iceServers
? [renderText("i", notProvided)]
: c.iceServers.map(i =>
renderElements("div", {}, [
`urls: ${JSON.stringify(i.urls)}`,
...pro(i, "credential"),
...pro(i, "userName"),
])
)),
]);
}
function renderICEStats(report) {
const iceDiv = renderElements("div", { id: "ice-stats: " + report.pcid }, [
renderText("h4", string("ice_stats_heading")),
]);
// Render ICECandidate table
{
const caption = renderElement("caption", { className: "no-print" });
// This takes the caption message with the replacement token, breaks
// it around the token, and builds the spans for each portion of the
// caption. This is to allow localization to put the color name for
// the highlight wherever it is appropriate in the translated string
// while avoiding innerHTML warnings from eslint.
const [start, end] = string("trickle_caption_msg2").split(/%(?:1\$)?S/);
// only append span if non-whitespace chars present
if (/\S/.test(start)) {
caption.append(renderText("span", start));
}
caption.append(
renderText("span", string("trickle_highlight_color_name2"), {
className: "ice-trickled",
})
);
// only append span if non-whitespace chars present
if (/\S/.test(end)) {
caption.append(renderText("span", end));
}
// Generate ICE stats
const stats = [];
{
// Create an index based on candidate ID for each element in the
// iceCandidateStats array.
const candidates = {};
for (const candidate of report.iceCandidateStats) {
candidates[candidate.id] = candidate;
}
// a method to see if a given candidate id is in the array of tickled
// candidates.
const isTrickled = candidateId =>
report.trickledIceCandidateStats.some(({ id }) => id == candidateId);
// A component may have a remote or local candidate address or both.
// Combine those with both; these will be the peer candidates.
const matched = {};
for (const {
localCandidateId,
remoteCandidateId,
componentId,
state,
priority,
nominated,
selected,
bytesSent,
bytesReceived,
} of report.iceCandidatePairStats) {
const local = candidates[localCandidateId];
if (local) {
const stat = {
["local-candidate"]: candidateToString(local),
componentId,
state,
priority,
nominated,
selected,
bytesSent,
bytesReceived,
};
matched[local.id] = true;
if (isTrickled(local.id)) {
stat["local-trickled"] = true;
}
const remote = candidates[remoteCandidateId];
if (remote) {
stat["remote-candidate"] = candidateToString(remote);
matched[remote.id] = true;
if (isTrickled(remote.id)) {
stat["remote-trickled"] = true;
}
}
stats.push(stat);
}
}
// sort (group by) componentId first, then bytesSent if available, else by
// priority
stats.sort((a, b) => {
if (a.componentId != b.componentId) {
return a.componentId - b.componentId;
}
return b.bytesSent
? b.bytesSent - (a.bytesSent || 0)
: (b.priority || 0) - (a.priority || 0);
});
}
// Render ICE stats
// don't use |stat.x || ""| here because it hides 0 values
const statsTable = renderSimpleTable(
caption,
[
"ice_state",
"nominated",
"selected",
"local_candidate",
"remote_candidate",
"ice_component_id",
"priority",
"ice_pair_bytes_sent",
"ice_pair_bytes_received",
].map(columnName => string(columnName)),
stats.map(stat =>
[
stat.state,
stat.nominated,
stat.selected,
stat["local-candidate"],
stat["remote-candidate"],
stat.componentId,
stat.priority,
stat.bytesSent,
stat.bytesReceived,
].map(entry => (Object.is(entry, undefined) ? "" : entry))
)
);
// after rendering the table, we need to change the class name for each
// candidate pair's local or remote candidate if it was trickled.
let index = 0;
for (const {
state,
nominated,
selected,
"local-trickled": localTrickled,
"remote-trickled": remoteTrickled,
} of stats) {
// look at statsTable row index + 1 to skip column headers
const { cells } = statsTable.rows[++index];
cells[0].className = `ice-${state}`;
if (nominated) {
cells[1].className = "ice-succeeded";
}
if (selected) {
cells[2].className = "ice-succeeded";
}
if (localTrickled) {
cells[3].className = "ice-trickled";
}
if (remoteTrickled) {
cells[4].className = "ice-trickled";
}
}
// if the current row's component id changes, mark the bottom of the
// previous row with a thin, black border to differentiate the
// component id grouping.
let previousRow;
for (const row of statsTable.rows) {
if (previousRow) {
if (previousRow.cells[5].innerHTML != row.cells[5].innerHTML) {
previousRow.className = "bottom-border";
}
}
previousRow = row;
}
iceDiv.append(statsTable);
}
// add just a bit of vertical space between the restart/rollback
// counts and the ICE candidate pair table above.
iceDiv.append(
renderElement("br"),
renderIceMetric("ice_restart_count_label", report.iceRestarts),
renderIceMetric("ice_rollback_count_label", report.iceRollbacks)
);
// Render raw ICECandidate section
{
const section = renderElements("div", {}, [
renderText("h4", string("raw_candidates_heading")),
]);
const foldSection = renderFoldableSection(section, {
showMsg: string("raw_cand_show_msg"),
hideMsg: string("raw_cand_hide_msg"),
});
// render raw candidates
foldSection.append(
renderElements("div", {}, [
renderRawIceTable("raw_local_candidate", report.rawLocalCandidates),
renderRawIceTable("raw_remote_candidate", report.rawRemoteCandidates),
])
);
section.append(foldSection);
iceDiv.append(section);
}
return iceDiv;
}
function renderIceMetric(label, value) {
return renderElement("div", {}, [
renderText("span", `${string(label)}: `, { className: "info-label" }),
renderText("span", value, { className: "info-body" }),
]);
}
function candidateToString({
type,
address,
port,
protocol,
candidateType,
relayProtocol,
proxied,
} = {}) {
if (!type) {
return "*";
}
if (relayProtocol) {
candidateType = `${candidateType}-${relayProtocol}`;
}
proxied = type == "local-candidate" ? ` [${proxied}]` : "";
return `${address}:${port}/${protocol}(${candidateType})${proxied}`;
}
function renderFoldableSection(parent, options = {}) {
const section = renderElement("div");
if (parent) {
const ctrl = renderElements("div", { className: "section-ctrl no-print" }, [
new FoldEffect(section, options).render(),
]);
parent.append(ctrl);
}
return section;
}
function renderSimpleTable(caption, headings, data) {
const heads = headings.map(text => renderText("th", text));
const renderCell = text => renderText("td", text);
return renderElements("table", {}, [
caption,
renderElements("tr", {}, heads),
...data.map(line => renderElements("tr", {}, line.map(renderCell))),
]);
}
class FoldEffect {
static allSections = [];
constructor(
target,
{
showMsg = string("fold_show_msg"),
showHint = string("fold_show_hint"),
hideMsg = string("fold_hide_msg"),
hideHint = string("fold_hide_hint"),
} = {}
) {
showMsg = `\u25BC ${showMsg}`;
hideMsg = `\u25B2 ${hideMsg}`;
Object.assign(this, { target, showMsg, showHint, hideMsg, hideHint });
}
render() {
this.target.classList.add("fold-target");
this.trigger = renderElement("div", { className: "fold-trigger" });
this.collapse();
this.trigger.onclick = () => {
if (this.target.classList.contains("fold-closed")) {
this.expand();
} else {
this.collapse();
}
};
FoldEffect.allSections.push(this);
return this.trigger;
}
expand() {
this.target.classList.remove("fold-closed");
this.trigger.setAttribute("title", this.hideHint);
this.trigger.textContent = this.hideMsg;
}
collapse() {
this.target.classList.add("fold-closed");
this.trigger.setAttribute("title", this.showHint);
this.trigger.textContent = this.showMsg;
}
static expandAll() {
for (const section of FoldEffect.allSections) {
section.expand();
}
}
static collapseAll() {
for (const section of FoldEffect.allSections) {
section.collapse();
}
}
}