profview: View source code of functions with samples inline.
If profiling is done with --log-source-code profview will now display a "View source" link for each function in the tree view. Clicking this will show a new source viewer, with sampled lines highlighted. See the associated bug for screenshots. This patch also fixes a bug in the profiler where the source info of only the first code object for each function would be logged, and includes some refactoring. Bug: v8:6240 Change-Id: Ib96a9cfc54543d0dc9bef4657cdeb96ce28b223c Reviewed-on: https://chromium-review.googlesource.com/1194231 Commit-Queue: Bret Sepulveda <bsep@chromium.org> Reviewed-by: Camillo Bruni <cbruni@chromium.org> Cr-Commit-Position: refs/heads/master@{#55542}
This commit is contained in:
parent
33f2012efd
commit
b9cb78a705
@ -1323,7 +1323,7 @@ void Logger::CodeCreateEvent(CodeEventListener::LogEventsAndTags tag,
|
||||
// <script-offset> is the position within the script
|
||||
// <inlining-id> is the offset in the <inlining> table
|
||||
// <inlining> table is a sequence of strings of the form
|
||||
// F<function-id>O<script-offset>[I<inlining-id>
|
||||
// F<function-id>O<script-offset>[I<inlining-id>]
|
||||
// where
|
||||
// <function-id> is an index into the <fns> function table
|
||||
// <fns> is the function table encoded as a sequence of strings
|
||||
@ -1335,12 +1335,8 @@ void Logger::CodeCreateEvent(CodeEventListener::LogEventsAndTags tag,
|
||||
<< shared->EndPosition() << kNext;
|
||||
|
||||
SourcePositionTableIterator iterator(code->source_position_table());
|
||||
bool is_first = true;
|
||||
bool hasInlined = false;
|
||||
for (; !iterator.done(); iterator.Advance()) {
|
||||
if (is_first) {
|
||||
is_first = false;
|
||||
}
|
||||
SourcePosition pos = iterator.source_position();
|
||||
msg << "C" << iterator.code_offset() << "O" << pos.ScriptOffset();
|
||||
if (pos.isInlined()) {
|
||||
@ -1604,7 +1600,7 @@ bool Logger::EnsureLogScriptSource(Script* script) {
|
||||
// Make sure the script is written to the log file.
|
||||
int script_id = script->id();
|
||||
if (logged_source_code_.find(script_id) != logged_source_code_.end()) {
|
||||
return false;
|
||||
return true;
|
||||
}
|
||||
// This script has not been logged yet.
|
||||
logged_source_code_.insert(script_id);
|
||||
|
@ -975,7 +975,7 @@ JsonProfile.prototype.addSourcePositions = function(
|
||||
if (!entry) return;
|
||||
var codeId = entry.codeId;
|
||||
|
||||
// Resolve the inlined fucntions list.
|
||||
// Resolve the inlined functions list.
|
||||
if (inlinedFunctions.length > 0) {
|
||||
inlinedFunctions = inlinedFunctions.substring(1).split("S");
|
||||
for (var i = 0; i < inlinedFunctions.length; i++) {
|
||||
|
@ -22,7 +22,7 @@ found in the LICENSE file. -->
|
||||
Chrome V8 profiling log processor
|
||||
</h3>
|
||||
|
||||
<input type="file" id="fileinput" />
|
||||
<input type="file" id="fileinput" /><div id="source-status"></div>
|
||||
<br>
|
||||
<hr>
|
||||
|
||||
@ -59,6 +59,10 @@ found in the LICENSE file. -->
|
||||
</table>
|
||||
<div>
|
||||
Current code object: <span id="timeline-currentCode"></span>
|
||||
<button id="source-viewer-hide-button">Hide source</button>
|
||||
</div>
|
||||
<div>
|
||||
<table id="source-viewer"> </table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
@ -93,9 +93,10 @@ function codeEquals(code1, code2, allowDifferentKinds = false) {
|
||||
|
||||
function createNodeFromStackEntry(code, codeId, vmState) {
|
||||
let name = code ? code.name : "UNKNOWN";
|
||||
|
||||
return { name, codeId, type : resolveCodeKindAndVmState(code, vmState),
|
||||
children : [], ownTicks : 0, ticks : 0 };
|
||||
let node = createEmptyNode(name);
|
||||
node.codeId = codeId;
|
||||
node.type = resolveCodeKindAndVmState(code, vmState);
|
||||
return node;
|
||||
}
|
||||
|
||||
function childIdFromCode(codeId, code) {
|
||||
@ -148,29 +149,30 @@ function findNextFrame(file, stack, stackPos, step, filter) {
|
||||
}
|
||||
|
||||
function addOrUpdateChildNode(parent, file, stackIndex, stackPos, ascending) {
|
||||
let stack = file.ticks[stackIndex].s;
|
||||
let vmState = file.ticks[stackIndex].vm;
|
||||
let codeId = stack[stackPos];
|
||||
let code = codeId >= 0 ? file.code[codeId] : undefined;
|
||||
if (stackPos === -1) {
|
||||
// We reached the end without finding the next step.
|
||||
// If we are doing top-down call tree, update own ticks.
|
||||
if (!ascending) {
|
||||
parent.ownTicks++;
|
||||
}
|
||||
} else {
|
||||
console.assert(stackPos >= 0 && stackPos < stack.length);
|
||||
// We found a child node.
|
||||
let childId = childIdFromCode(codeId, code);
|
||||
let child = parent.children[childId];
|
||||
if (!child) {
|
||||
child = createNodeFromStackEntry(code, codeId, vmState);
|
||||
child.delayedExpansion = { frameList : [], ascending };
|
||||
parent.children[childId] = child;
|
||||
}
|
||||
child.ticks++;
|
||||
addFrameToFrameList(child.delayedExpansion.frameList, stackIndex, stackPos);
|
||||
return;
|
||||
}
|
||||
|
||||
let stack = file.ticks[stackIndex].s;
|
||||
console.assert(stackPos >= 0 && stackPos < stack.length);
|
||||
let codeId = stack[stackPos];
|
||||
let code = codeId >= 0 ? file.code[codeId] : undefined;
|
||||
// We found a child node.
|
||||
let childId = childIdFromCode(codeId, code);
|
||||
let child = parent.children[childId];
|
||||
if (!child) {
|
||||
let vmState = file.ticks[stackIndex].vm;
|
||||
child = createNodeFromStackEntry(code, codeId, vmState);
|
||||
child.delayedExpansion = { frameList : [], ascending };
|
||||
parent.children[childId] = child;
|
||||
}
|
||||
child.ticks++;
|
||||
addFrameToFrameList(child.delayedExpansion.frameList, stackIndex, stackPos);
|
||||
}
|
||||
|
||||
// This expands a tree node (direct children only).
|
||||
@ -314,13 +316,7 @@ class FunctionListTree {
|
||||
this.tree = root;
|
||||
this.categories = categories;
|
||||
} else {
|
||||
this.tree = {
|
||||
name : "root",
|
||||
codeId: -1,
|
||||
children : [],
|
||||
ownTicks : 0,
|
||||
ticks : 0
|
||||
};
|
||||
this.tree = createEmptyNode("root");
|
||||
this.categories = null;
|
||||
}
|
||||
|
||||
@ -339,7 +335,7 @@ class FunctionListTree {
|
||||
let codeId = stack[i];
|
||||
if (codeId < 0 || this.codeVisited[codeId]) continue;
|
||||
|
||||
let code = codeId >= 0 ? file.code[codeId] : undefined;
|
||||
let code = file.code[codeId];
|
||||
if (this.filter) {
|
||||
let type = code ? code.type : undefined;
|
||||
let kind = code ? code.kind : undefined;
|
||||
@ -601,3 +597,15 @@ function computeOptimizationStats(file,
|
||||
softDeoptimizations,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeLeadingWhitespace(lines) {
|
||||
let regex = /^\s*/;
|
||||
let minimumLeadingWhitespaceChars = Infinity;
|
||||
for (let line of lines) {
|
||||
minimumLeadingWhitespaceChars =
|
||||
Math.min(minimumLeadingWhitespaceChars, regex.exec(line)[0].length);
|
||||
}
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
lines[i] = lines[i].substring(minimumLeadingWhitespaceChars);
|
||||
}
|
||||
}
|
||||
|
@ -19,6 +19,10 @@ body {
|
||||
font-family: 'Roboto', sans-serif;
|
||||
}
|
||||
|
||||
#source-status {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.tree-row-arrow {
|
||||
margin-right: 0.2em;
|
||||
text-align: right;
|
||||
@ -35,6 +39,7 @@ body {
|
||||
|
||||
.tree-row-name {
|
||||
margin-left: 0.2em;
|
||||
margin-right: 0.2em;
|
||||
}
|
||||
|
||||
.codeid-link {
|
||||
@ -42,6 +47,54 @@ body {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.view-source-link {
|
||||
text-decoration: underline;
|
||||
cursor: pointer;
|
||||
font-size: 10pt;
|
||||
margin-left: 0.6em;
|
||||
color: #555555;
|
||||
}
|
||||
|
||||
#source-viewer {
|
||||
border: 1px solid black;
|
||||
padding: 0.2em;
|
||||
font-family: 'Roboto Mono', monospace;
|
||||
white-space: pre;
|
||||
margin-top: 1em;
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
|
||||
#source-viewer td.line-none {
|
||||
background-color: white;
|
||||
}
|
||||
|
||||
#source-viewer td.line-cold {
|
||||
background-color: #e1f5fe;
|
||||
}
|
||||
|
||||
#source-viewer td.line-mediumcold {
|
||||
background-color: #b2ebf2;
|
||||
}
|
||||
|
||||
#source-viewer td.line-mediumhot {
|
||||
background-color: #c5e1a5;
|
||||
}
|
||||
|
||||
#source-viewer td.line-hot {
|
||||
background-color: #dce775;
|
||||
}
|
||||
|
||||
#source-viewer td.line-superhot {
|
||||
background-color: #ffee58;
|
||||
}
|
||||
|
||||
#source-viewer .source-line-number {
|
||||
padding-left: 0.2em;
|
||||
padding-right: 0.2em;
|
||||
color: #003c8f;
|
||||
background-color: #eceff1;
|
||||
}
|
||||
|
||||
div.mode-button {
|
||||
padding: 1em 3em;
|
||||
display: inline-block;
|
||||
|
@ -8,6 +8,12 @@ function $(id) {
|
||||
return document.getElementById(id);
|
||||
}
|
||||
|
||||
function removeAllChildren(element) {
|
||||
while (element.firstChild) {
|
||||
element.removeChild(element.firstChild);
|
||||
}
|
||||
}
|
||||
|
||||
let components;
|
||||
function createViews() {
|
||||
components = [
|
||||
@ -16,6 +22,7 @@ function createViews() {
|
||||
new HelpView(),
|
||||
new SummaryView(),
|
||||
new ModeBarView(),
|
||||
new ScriptSourceView(),
|
||||
];
|
||||
}
|
||||
|
||||
@ -24,6 +31,7 @@ function emptyState() {
|
||||
file : null,
|
||||
mode : null,
|
||||
currentCodeId : null,
|
||||
viewingSource: false,
|
||||
start : 0,
|
||||
end : Infinity,
|
||||
timelineSize : {
|
||||
@ -34,7 +42,8 @@ function emptyState() {
|
||||
attribution : "js-exclude-bc",
|
||||
categories : "code-type",
|
||||
sort : "time"
|
||||
}
|
||||
},
|
||||
sourceData: null
|
||||
};
|
||||
}
|
||||
|
||||
@ -119,11 +128,27 @@ let main = {
|
||||
}
|
||||
},
|
||||
|
||||
updateSources(file) {
|
||||
let statusDiv = $("source-status");
|
||||
if (!file) {
|
||||
statusDiv.textContent = "";
|
||||
return;
|
||||
}
|
||||
if (!file.scripts || file.scripts.length === 0) {
|
||||
statusDiv.textContent =
|
||||
"Script source not available. Run profiler with --log-source-code.";
|
||||
return;
|
||||
}
|
||||
statusDiv.textContent = "Script source is available.";
|
||||
main.currentState.sourceData = new SourceData(file);
|
||||
},
|
||||
|
||||
setFile(file) {
|
||||
if (file !== main.currentState.file) {
|
||||
let lastMode = main.currentState.mode || "summary";
|
||||
main.currentState = emptyState();
|
||||
main.currentState.file = file;
|
||||
main.updateSources(file);
|
||||
main.setMode(lastMode);
|
||||
main.delayRender();
|
||||
}
|
||||
@ -137,6 +162,14 @@ let main = {
|
||||
}
|
||||
},
|
||||
|
||||
setViewingSource(value) {
|
||||
if (main.currentState.viewingSource !== value) {
|
||||
main.currentState = Object.assign({}, main.currentState);
|
||||
main.currentState.viewingSource = value;
|
||||
main.delayRender();
|
||||
}
|
||||
},
|
||||
|
||||
onResize() {
|
||||
main.delayRender();
|
||||
},
|
||||
@ -328,6 +361,20 @@ function createFunctionNode(name, codeId) {
|
||||
return nameElement;
|
||||
}
|
||||
|
||||
function createViewSourceNode(codeId) {
|
||||
let linkElement = document.createElement("span");
|
||||
linkElement.appendChild(document.createTextNode("View source"));
|
||||
linkElement.classList.add("view-source-link");
|
||||
linkElement.onclick = (event) => {
|
||||
main.setCurrentCode(codeId);
|
||||
main.setViewingSource(true);
|
||||
// Prevent the click from bubbling to the row and causing it to
|
||||
// collapse/expand.
|
||||
event.stopPropagation();
|
||||
};
|
||||
return linkElement;
|
||||
}
|
||||
|
||||
const COLLAPSED_ARROW = "\u25B6";
|
||||
const EXPANDED_ARROW = "\u25BC";
|
||||
|
||||
@ -448,6 +495,10 @@ class CallTreeView {
|
||||
nameCell.appendChild(arrow);
|
||||
nameCell.appendChild(createTypeNode(node.type));
|
||||
nameCell.appendChild(createFunctionNode(node.name, node.codeId));
|
||||
if (main.currentState.sourceData &&
|
||||
main.currentState.sourceData.hasSource(node.name)) {
|
||||
nameCell.appendChild(createViewSourceNode(node.codeId));
|
||||
}
|
||||
|
||||
// Inclusive ticks cell.
|
||||
c = row.insertCell();
|
||||
@ -793,8 +844,8 @@ class TimelineView {
|
||||
return;
|
||||
}
|
||||
|
||||
let width = Math.round(window.innerWidth - 20);
|
||||
let height = Math.round(window.innerHeight / 5);
|
||||
let width = Math.round(document.documentElement.clientWidth - 20);
|
||||
let height = Math.round(document.documentElement.clientHeight / 5);
|
||||
|
||||
if (oldState) {
|
||||
if (width === oldState.timelineSize.width &&
|
||||
@ -1010,9 +1061,7 @@ class TimelineView {
|
||||
cell.appendChild(document.createTextNode(" " + desc.text));
|
||||
}
|
||||
|
||||
while (this.currentCode.firstChild) {
|
||||
this.currentCode.removeChild(this.currentCode.firstChild);
|
||||
}
|
||||
removeAllChildren(this.currentCode);
|
||||
if (currentCodeId) {
|
||||
let currentCode = file.code[currentCodeId];
|
||||
this.currentCode.appendChild(document.createTextNode(currentCode.name));
|
||||
@ -1083,10 +1132,7 @@ class SummaryView {
|
||||
}
|
||||
|
||||
this.element.style.display = "inherit";
|
||||
|
||||
while (this.element.firstChild) {
|
||||
this.element.removeChild(this.element.firstChild);
|
||||
}
|
||||
removeAllChildren(this.element);
|
||||
|
||||
let stats = computeOptimizationStats(
|
||||
this.currentState.file, newState.start, newState.end);
|
||||
@ -1237,6 +1283,217 @@ class SummaryView {
|
||||
}
|
||||
}
|
||||
|
||||
class ScriptSourceView {
|
||||
constructor() {
|
||||
this.table = $("source-viewer");
|
||||
this.hideButton = $("source-viewer-hide-button");
|
||||
this.hideButton.onclick = () => {
|
||||
main.setViewingSource(false);
|
||||
};
|
||||
}
|
||||
|
||||
render(newState) {
|
||||
let oldState = this.currentState;
|
||||
if (!newState.file || !newState.viewingSource) {
|
||||
this.table.style.display = "none";
|
||||
this.hideButton.style.display = "none";
|
||||
this.currentState = null;
|
||||
return;
|
||||
}
|
||||
if (oldState) {
|
||||
if (newState.file === oldState.file &&
|
||||
newState.currentCodeId === oldState.currentCodeId &&
|
||||
newState.viewingSource === oldState.viewingSource) {
|
||||
// No change, nothing to do.
|
||||
return;
|
||||
}
|
||||
}
|
||||
this.currentState = newState;
|
||||
|
||||
this.table.style.display = "inline-block";
|
||||
this.hideButton.style.display = "inline";
|
||||
removeAllChildren(this.table);
|
||||
|
||||
let functionName =
|
||||
this.currentState.file.code[this.currentState.currentCodeId].name;
|
||||
let sourceView =
|
||||
this.currentState.sourceData.generateSourceView(functionName);
|
||||
for (let i = 0; i < sourceView.source.length; i++) {
|
||||
let sampleCount = sourceView.lineSampleCounts[i] || 0;
|
||||
let sampleProportion = sourceView.samplesTotal > 0 ?
|
||||
sampleCount / sourceView.samplesTotal : 0;
|
||||
let heatBucket;
|
||||
if (sampleProportion === 0) {
|
||||
heatBucket = "line-none";
|
||||
} else if (sampleProportion < 0.2) {
|
||||
heatBucket = "line-cold";
|
||||
} else if (sampleProportion < 0.4) {
|
||||
heatBucket = "line-mediumcold";
|
||||
} else if (sampleProportion < 0.6) {
|
||||
heatBucket = "line-mediumhot";
|
||||
} else if (sampleProportion < 0.8) {
|
||||
heatBucket = "line-hot";
|
||||
} else {
|
||||
heatBucket = "line-superhot";
|
||||
}
|
||||
|
||||
let row = this.table.insertRow(-1);
|
||||
|
||||
let lineNumberCell = row.insertCell(-1);
|
||||
lineNumberCell.classList.add("source-line-number");
|
||||
lineNumberCell.textContent = i + sourceView.firstLineNumber;
|
||||
|
||||
let sampleCountCell = row.insertCell(-1);
|
||||
sampleCountCell.classList.add(heatBucket);
|
||||
sampleCountCell.textContent = sampleCount;
|
||||
|
||||
let sourceLineCell = row.insertCell(-1);
|
||||
sourceLineCell.classList.add(heatBucket);
|
||||
sourceLineCell.textContent = sourceView.source[i];
|
||||
}
|
||||
|
||||
$("timeline-currentCode").scrollIntoView();
|
||||
}
|
||||
}
|
||||
|
||||
class SourceData {
|
||||
constructor(file) {
|
||||
this.scripts = new Map();
|
||||
for (let scriptBlock of file.scripts) {
|
||||
if (scriptBlock === null) continue; // Array may be sparse.
|
||||
let source = scriptBlock.source.split("\n");
|
||||
this.scripts.set(scriptBlock.name, source);
|
||||
}
|
||||
|
||||
this.functions = new Map();
|
||||
for (let codeId = 0; codeId < file.code.length; ++codeId) {
|
||||
let codeBlock = file.code[codeId];
|
||||
if (codeBlock.source) {
|
||||
let data = this.functions.get(codeBlock.name);
|
||||
if (!data) {
|
||||
data = new FunctionSourceData(codeBlock.source.start,
|
||||
codeBlock.source.end);
|
||||
this.functions.set(codeBlock.name, data);
|
||||
}
|
||||
data.addSourceBlock(codeId, codeBlock.source);
|
||||
}
|
||||
}
|
||||
|
||||
for (let tick of file.ticks) {
|
||||
let stack = tick.s;
|
||||
for (let i = 0; i < stack.length; i += 2) {
|
||||
let codeId = stack[i];
|
||||
if (codeId < 0) continue;
|
||||
let name = file.code[codeId].name;
|
||||
if (this.functions.has(name)) {
|
||||
let codeOffset = stack[i + 1];
|
||||
this.functions.get(name).addOffsetSample(codeId, codeOffset);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getScript(name) {
|
||||
let nameAndSource = name.split(" ")
|
||||
console.assert(nameAndSource.length >= 2);
|
||||
let sourceAndLine = nameAndSource[1].split(":");
|
||||
return this.scripts.get(sourceAndLine[0]);
|
||||
}
|
||||
|
||||
getLineForScriptOffset(name, scriptOffset) {
|
||||
let script = this.getScript(name);
|
||||
let line = 0;
|
||||
let charsConsumed = 0;
|
||||
for (; line < script.length; ++line) {
|
||||
charsConsumed += script[line].length + 1; // Add 1 for newline.
|
||||
if (charsConsumed > scriptOffset) break;
|
||||
}
|
||||
return line;
|
||||
}
|
||||
|
||||
hasSource(name) {
|
||||
return this.functions.has(name);
|
||||
}
|
||||
|
||||
generateSourceView(name) {
|
||||
console.assert(this.hasSource(name));
|
||||
let data = this.functions.get(name);
|
||||
let firstLineNumber =
|
||||
this.getLineForScriptOffset(name, data.startScriptOffset);
|
||||
let lastLineNumber =
|
||||
this.getLineForScriptOffset(name, data.endScriptOffset);
|
||||
let script = this.getScript(name);
|
||||
let lines = script.slice(firstLineNumber, lastLineNumber + 1);
|
||||
normalizeLeadingWhitespace(lines);
|
||||
|
||||
let samplesTotal = 0;
|
||||
let lineSampleCounts = [];
|
||||
for (let [codeId, block] of data.codes) {
|
||||
block.offsets.forEach((sampleCount, codeOffset) => {
|
||||
let sourceOffset = block.positionTable.getScriptOffset(codeOffset);
|
||||
let lineNumber =
|
||||
this.getLineForScriptOffset(name, sourceOffset) - firstLineNumber;
|
||||
samplesTotal += sampleCount;
|
||||
lineSampleCounts[lineNumber] =
|
||||
(lineSampleCounts[lineNumber] || 0) + sampleCount;
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
source: lines,
|
||||
lineSampleCounts: lineSampleCounts,
|
||||
samplesTotal: samplesTotal,
|
||||
firstLineNumber: firstLineNumber + 1 // Source code is 1-indexed.
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
class FunctionSourceData {
|
||||
constructor(startScriptOffset, endScriptOffset) {
|
||||
this.startScriptOffset = startScriptOffset;
|
||||
this.endScriptOffset = endScriptOffset;
|
||||
|
||||
this.codes = new Map();
|
||||
}
|
||||
|
||||
addSourceBlock(codeId, source) {
|
||||
this.codes.set(codeId, {
|
||||
positionTable: new SourcePositionTable(source.positions),
|
||||
offsets: []
|
||||
});
|
||||
}
|
||||
|
||||
addOffsetSample(codeId, codeOffset) {
|
||||
let codeIdOffsets = this.codes.get(codeId).offsets;
|
||||
codeIdOffsets[codeOffset] = (codeIdOffsets[codeOffset] || 0) + 1;
|
||||
}
|
||||
}
|
||||
|
||||
class SourcePositionTable {
|
||||
constructor(encodedTable) {
|
||||
this.offsetTable = [];
|
||||
let offsetPairRegex = /C([0-9]+)O([0-9]+)/g;
|
||||
while (true) {
|
||||
let regexResult = offsetPairRegex.exec(encodedTable);
|
||||
if (!regexResult) break;
|
||||
let codeOffset = parseInt(regexResult[1]);
|
||||
let scriptOffset = parseInt(regexResult[2]);
|
||||
if (isNaN(codeOffset) || isNaN(scriptOffset)) continue;
|
||||
this.offsetTable.push(codeOffset, scriptOffset);
|
||||
}
|
||||
}
|
||||
|
||||
getScriptOffset(codeOffset) {
|
||||
console.assert(codeOffset >= 0);
|
||||
for (let i = this.offsetTable.length - 2; i >= 0; i -= 2) {
|
||||
if (this.offsetTable[i] <= codeOffset) {
|
||||
return this.offsetTable[i + 1];
|
||||
}
|
||||
}
|
||||
return this.offsetTable[1];
|
||||
}
|
||||
}
|
||||
|
||||
class HelpView {
|
||||
constructor() {
|
||||
this.element = $("help");
|
||||
|
Loading…
Reference in New Issue
Block a user