mirror of
https://github.com/mozilla/pdf.js.git
synced 2026-04-09 23:04:02 +02:00
Instead of having all the code for the new debugger in a single file, split it into multiple files. This makes it easier to navigate and maintain the codebase. It'll be make hacking and fixing bugs in the debugger easier.
518 lines
16 KiB
JavaScript
518 lines
16 KiB
JavaScript
/* Copyright 2026 Mozilla Foundation
|
||
*
|
||
* 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.
|
||
*/
|
||
|
||
// Number of rows rendered per batch (IntersectionObserver batching).
|
||
const BATCH_SIZE = 500;
|
||
// Maximum rows kept in the DOM at once (two batches).
|
||
const MAX_RENDERED = BATCH_SIZE * 2;
|
||
|
||
let _idCounter = 0;
|
||
|
||
/**
|
||
* A scrollable multi-line panel combining:
|
||
* – a frozen line-number column on the left,
|
||
* – a scrollable content column on the right,
|
||
* – a search / go-to-line toolbar at the top,
|
||
* – IntersectionObserver-based virtual scroll.
|
||
*
|
||
* Usage:
|
||
* const mc = new MultilineView({ total, getText, makeLineEl });
|
||
* container.append(mc.element);
|
||
*/
|
||
class MultilineView {
|
||
// -- DOM elements --
|
||
#element;
|
||
|
||
#numCol;
|
||
|
||
#innerEl;
|
||
|
||
#pre;
|
||
|
||
#topSentinel;
|
||
|
||
#bottomSentinel;
|
||
|
||
#observer = null;
|
||
|
||
#onScroll = null;
|
||
|
||
#total;
|
||
|
||
#getText;
|
||
|
||
#makeLineEl;
|
||
|
||
#startIndex = 0;
|
||
|
||
#endIndex = 0;
|
||
|
||
#highlightedIndex = -1;
|
||
|
||
#searchMatches = [];
|
||
|
||
#currentMatchIdx = -1;
|
||
|
||
#searchInput;
|
||
|
||
#searchError;
|
||
|
||
#prevButton;
|
||
|
||
#nextButton;
|
||
|
||
#matchInfo;
|
||
|
||
#ignoreCaseCb;
|
||
|
||
#regexCb;
|
||
|
||
/**
|
||
* @param {object} opts
|
||
* @param {number} opts.total Total number of lines.
|
||
* @param {Function} opts.getText (i) => string used for search.
|
||
* @param {Function} opts.makeLineEl (i, isHighlighted) => HTMLElement.
|
||
* @param {string} [opts.lineClass] CSS class for the lines container.
|
||
* @param {HTMLElement} [opts.actions] Element prepended in the toolbar.
|
||
*/
|
||
constructor({ total, getText, makeLineEl, lineClass = "", actions = null }) {
|
||
this.#total = total;
|
||
this.#getText = getText;
|
||
this.#makeLineEl = makeLineEl;
|
||
|
||
// Root element.
|
||
this.#element = document.createElement("div");
|
||
this.#element.className = "mlc-scroll";
|
||
|
||
// Line-number column (frozen; scrollTop synced with the scroll pane).
|
||
this.#numCol = document.createElement("div");
|
||
this.#numCol.className = "mlc-line-nums-col";
|
||
this.#numCol.style.setProperty(
|
||
"--line-num-width",
|
||
`${String(total).length}ch`
|
||
);
|
||
|
||
// Scrollable content column.
|
||
this.#innerEl = document.createElement("div");
|
||
this.#innerEl.className = "mlc-inner";
|
||
this.#onScroll = () => {
|
||
this.#numCol.scrollTop = this.#innerEl.scrollTop;
|
||
};
|
||
this.#innerEl.addEventListener("scroll", this.#onScroll);
|
||
|
||
// Item container inside the scroll column.
|
||
this.#pre = document.createElement("div");
|
||
if (lineClass) {
|
||
this.#pre.className = lineClass;
|
||
}
|
||
this.#innerEl.append(this.#pre);
|
||
|
||
const body = document.createElement("div");
|
||
body.className = "mlc-body";
|
||
body.append(this.#numCol, this.#innerEl);
|
||
|
||
this.#element.append(this.#buildToolbar(actions), body);
|
||
|
||
// Sentinels bracket the rendered window inside #pre:
|
||
// topSentinel [startIndex .. endIndex) bottomSentinel
|
||
this.#topSentinel = document.createElement("div");
|
||
this.#topSentinel.className = "mlc-load-sentinel";
|
||
this.#bottomSentinel = document.createElement("div");
|
||
this.#bottomSentinel.className = "mlc-load-sentinel";
|
||
|
||
this.#endIndex = Math.min(BATCH_SIZE, total);
|
||
this.#pre.append(
|
||
this.#topSentinel,
|
||
this.#renderRange(0, this.#endIndex),
|
||
this.#bottomSentinel
|
||
);
|
||
this.#numCol.append(this.#renderNumRange(0, this.#endIndex));
|
||
|
||
if (total > BATCH_SIZE) {
|
||
this.#setupObserver();
|
||
}
|
||
}
|
||
|
||
/** The root element — append to the DOM to display the component. */
|
||
get element() {
|
||
return this.#element;
|
||
}
|
||
|
||
/** The inner content container (between the sentinels). Useful for setting
|
||
* ARIA attributes and attaching keyboard listeners. */
|
||
get inner() {
|
||
return this.#pre;
|
||
}
|
||
|
||
/**
|
||
* Scroll to ensure line i (0-based) is visible without changing the current
|
||
* search highlight. Useful for programmatic navigation (e.g. a debugger).
|
||
*/
|
||
scrollToLine(i) {
|
||
if (i < 0 || i >= this.#total) {
|
||
return;
|
||
}
|
||
if (i >= this.#startIndex && i < this.#endIndex) {
|
||
this.#scrollRenderedTargetIntoView(i);
|
||
} else {
|
||
this.#jumpToTarget(i);
|
||
}
|
||
}
|
||
|
||
destroy() {
|
||
this.#observer?.disconnect();
|
||
this.#observer = null;
|
||
|
||
if (this.#onScroll) {
|
||
this.#innerEl.removeEventListener("scroll", this.#onScroll);
|
||
this.#onScroll = null;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Scroll to line i (0-based) and mark it as the current search highlight.
|
||
* Pass i = -1 to clear the highlight.
|
||
*/
|
||
jumpToLine(i) {
|
||
this.#pre.querySelector(".mlc-match")?.classList.remove("mlc-match");
|
||
this.#numCol.querySelector(".mlc-match")?.classList.remove("mlc-match");
|
||
if (i < 0) {
|
||
this.#highlightedIndex = -1;
|
||
return;
|
||
}
|
||
if (i >= this.#total) {
|
||
return;
|
||
}
|
||
this.#highlightedIndex = i;
|
||
if (i >= this.#startIndex && i < this.#endIndex) {
|
||
this.#scrollRenderedTargetIntoView(i);
|
||
} else {
|
||
this.#jumpToTarget(i);
|
||
}
|
||
this.#pre.children[i - this.#startIndex + 1]?.classList.add("mlc-match");
|
||
this.#numCol.children[i - this.#startIndex]?.classList.add("mlc-match");
|
||
}
|
||
|
||
#renderRange(from, to) {
|
||
const frag = document.createDocumentFragment();
|
||
for (let i = from; i < to; i++) {
|
||
frag.append(this.#makeLineEl(i, i === this.#highlightedIndex));
|
||
}
|
||
return frag;
|
||
}
|
||
|
||
#renderNumRange(from, to) {
|
||
const frag = document.createDocumentFragment();
|
||
for (let i = from; i < to; i++) {
|
||
const item = document.createElement("div");
|
||
item.className = "mlc-num-item";
|
||
if (i === this.#highlightedIndex) {
|
||
item.classList.add("mlc-match");
|
||
}
|
||
item.textContent = String(i + 1);
|
||
frag.append(item);
|
||
}
|
||
return frag;
|
||
}
|
||
|
||
// Re-render a window centred on targetIndex and scroll to it.
|
||
#jumpToTarget(targetIndex) {
|
||
// Remove all rendered rows between the sentinels.
|
||
const firstRow = this.#topSentinel.nextSibling;
|
||
const lastRow = this.#bottomSentinel.previousSibling;
|
||
if (firstRow && lastRow && firstRow !== this.#bottomSentinel) {
|
||
const range = document.createRange();
|
||
range.setStartBefore(firstRow);
|
||
range.setEndAfter(lastRow);
|
||
range.deleteContents();
|
||
}
|
||
|
||
const half = Math.floor(MAX_RENDERED / 2);
|
||
this.#startIndex = Math.max(0, targetIndex - half);
|
||
this.#endIndex = Math.min(this.#total, this.#startIndex + MAX_RENDERED);
|
||
this.#startIndex = Math.max(0, this.#endIndex - MAX_RENDERED);
|
||
|
||
this.#topSentinel.after(
|
||
this.#renderRange(this.#startIndex, this.#endIndex)
|
||
);
|
||
this.#numCol.replaceChildren(
|
||
this.#renderNumRange(this.#startIndex, this.#endIndex)
|
||
);
|
||
|
||
this.#scrollRenderedTargetIntoView(targetIndex);
|
||
}
|
||
|
||
#scrollRenderedTargetIntoView(targetIndex) {
|
||
// #pre.children: [0]=topSentinel, [1..n]=rows, [n+1]=bottomSentinel
|
||
const targetEl = this.#pre.children[targetIndex - this.#startIndex + 1];
|
||
if (!targetEl) {
|
||
return;
|
||
}
|
||
const targetRect = targetEl.getBoundingClientRect();
|
||
const innerRect = this.#innerEl.getBoundingClientRect();
|
||
this.#innerEl.scrollTop +=
|
||
targetRect.top -
|
||
innerRect.top -
|
||
this.#innerEl.clientHeight / 2 +
|
||
targetEl.clientHeight / 2;
|
||
}
|
||
|
||
#setupObserver() {
|
||
const observer = (this.#observer = new IntersectionObserver(
|
||
entries => {
|
||
for (const entry of entries) {
|
||
if (!entry.isIntersecting) {
|
||
continue;
|
||
}
|
||
if (entry.target === this.#bottomSentinel) {
|
||
this.#loadBottom();
|
||
} else {
|
||
this.#loadTop();
|
||
}
|
||
}
|
||
},
|
||
{ root: this.#innerEl, rootMargin: "200px" }
|
||
));
|
||
observer.observe(this.#topSentinel);
|
||
observer.observe(this.#bottomSentinel);
|
||
}
|
||
|
||
#loadBottom() {
|
||
const newEnd = Math.min(this.#endIndex + BATCH_SIZE, this.#total);
|
||
if (newEnd === this.#endIndex) {
|
||
return;
|
||
}
|
||
this.#bottomSentinel.before(this.#renderRange(this.#endIndex, newEnd));
|
||
this.#numCol.append(this.#renderNumRange(this.#endIndex, newEnd));
|
||
this.#endIndex = newEnd;
|
||
|
||
// Trim from top if the window exceeds MAX_RENDERED.
|
||
if (this.#endIndex - this.#startIndex > MAX_RENDERED) {
|
||
const removeCount = this.#endIndex - this.#startIndex - MAX_RENDERED;
|
||
const heightBefore = this.#pre.scrollHeight;
|
||
for (let i = 0; i < removeCount; i++) {
|
||
this.#topSentinel.nextElementSibling?.remove();
|
||
this.#numCol.firstElementChild?.remove();
|
||
}
|
||
this.#startIndex += removeCount;
|
||
// Compensate so visible content doesn't jump upward.
|
||
this.#innerEl.scrollTop -= heightBefore - this.#pre.scrollHeight;
|
||
}
|
||
}
|
||
|
||
#loadTop() {
|
||
if (this.#startIndex === 0) {
|
||
return;
|
||
}
|
||
const newStart = Math.max(0, this.#startIndex - BATCH_SIZE);
|
||
const scrollBefore = this.#innerEl.scrollTop;
|
||
const heightBefore = this.#pre.scrollHeight;
|
||
this.#topSentinel.after(this.#renderRange(newStart, this.#startIndex));
|
||
this.#numCol.prepend(this.#renderNumRange(newStart, this.#startIndex));
|
||
// Compensate so visible content doesn't jump downward.
|
||
this.#innerEl.scrollTop =
|
||
scrollBefore + (this.#pre.scrollHeight - heightBefore);
|
||
this.#startIndex = newStart;
|
||
|
||
// Trim from bottom if the window exceeds MAX_RENDERED.
|
||
if (this.#endIndex - this.#startIndex > MAX_RENDERED) {
|
||
const removeCount = this.#endIndex - this.#startIndex - MAX_RENDERED;
|
||
for (let i = 0; i < removeCount; i++) {
|
||
this.#bottomSentinel.previousElementSibling?.remove();
|
||
this.#numCol.lastElementChild?.remove();
|
||
}
|
||
this.#endIndex -= removeCount;
|
||
}
|
||
}
|
||
|
||
#buildToolbar(actions) {
|
||
const id = ++_idCounter;
|
||
const bar = document.createElement("div");
|
||
bar.className = "mlc-goto-bar";
|
||
|
||
const searchGroup = document.createElement("div");
|
||
searchGroup.className = "mlc-search-group";
|
||
|
||
const searchErrorId = `mlc-err-${id}`;
|
||
|
||
const searchInput = (this.#searchInput = document.createElement("input"));
|
||
searchInput.type = "search";
|
||
searchInput.className = "mlc-search-input";
|
||
searchInput.placeholder = "Search for\u2026";
|
||
searchInput.ariaLabel = "Search";
|
||
searchInput.setAttribute("aria-describedby", searchErrorId);
|
||
|
||
const searchError = (this.#searchError = document.createElement("span"));
|
||
searchError.id = searchErrorId;
|
||
searchError.className = "sr-only";
|
||
searchError.role = "alert";
|
||
|
||
const prevButton = (this.#prevButton = document.createElement("button"));
|
||
prevButton.className = "mlc-nav-button";
|
||
prevButton.textContent = "↑";
|
||
prevButton.ariaLabel = "Previous match";
|
||
prevButton.disabled = true;
|
||
|
||
const nextButton = (this.#nextButton = document.createElement("button"));
|
||
nextButton.className = "mlc-nav-button";
|
||
nextButton.textContent = "↓";
|
||
nextButton.ariaLabel = "Next match";
|
||
nextButton.disabled = true;
|
||
|
||
const matchInfo = (this.#matchInfo = document.createElement("span"));
|
||
matchInfo.className = "mlc-match-info";
|
||
|
||
const { label: ignoreCaseLabel, cb: ignoreCaseCb } =
|
||
this.#makeCheckboxLabel("Ignore case");
|
||
const { label: regexLabel, cb: regexCb } = this.#makeCheckboxLabel("Regex");
|
||
this.#ignoreCaseCb = ignoreCaseCb;
|
||
this.#regexCb = regexCb;
|
||
|
||
searchGroup.append(
|
||
searchInput,
|
||
searchError,
|
||
prevButton,
|
||
nextButton,
|
||
matchInfo,
|
||
ignoreCaseLabel,
|
||
regexLabel
|
||
);
|
||
|
||
const gotoInput = document.createElement("input");
|
||
gotoInput.type = "number";
|
||
gotoInput.className = "mlc-goto";
|
||
gotoInput.placeholder = "Go to line\u2026";
|
||
gotoInput.min = "1";
|
||
gotoInput.max = String(this.#total);
|
||
gotoInput.step = "1";
|
||
gotoInput.ariaLabel = "Go to line";
|
||
|
||
if (actions) {
|
||
bar.append(actions);
|
||
}
|
||
bar.append(searchGroup, gotoInput);
|
||
|
||
searchInput.addEventListener("input", () => this.#runSearch());
|
||
searchInput.addEventListener("keydown", ({ key, shiftKey }) => {
|
||
if (key === "Enter") {
|
||
this.#navigateMatch(shiftKey ? -1 : 1);
|
||
}
|
||
});
|
||
prevButton.addEventListener("click", () => this.#navigateMatch(-1));
|
||
nextButton.addEventListener("click", () => this.#navigateMatch(1));
|
||
this.#ignoreCaseCb.addEventListener("change", () => this.#runSearch());
|
||
this.#regexCb.addEventListener("change", () => this.#runSearch());
|
||
|
||
gotoInput.addEventListener("keydown", ({ key }) => {
|
||
if (key !== "Enter") {
|
||
return;
|
||
}
|
||
const value = gotoInput.value.trim();
|
||
const n = Number(value);
|
||
if (!value || !Number.isInteger(n) || n < 1 || n > this.#total) {
|
||
gotoInput.setAttribute("aria-invalid", "true");
|
||
return;
|
||
}
|
||
gotoInput.removeAttribute("aria-invalid");
|
||
this.jumpToLine(n - 1);
|
||
});
|
||
|
||
return bar;
|
||
}
|
||
|
||
#makeCheckboxLabel(text) {
|
||
const label = document.createElement("label");
|
||
label.className = "mlc-check-label";
|
||
const cb = document.createElement("input");
|
||
cb.type = "checkbox";
|
||
label.append(cb, ` ${text}`);
|
||
return { label, cb };
|
||
}
|
||
|
||
#updateMatchInfo() {
|
||
if (!this.#searchInput.value) {
|
||
this.#matchInfo.textContent = "";
|
||
this.#prevButton.disabled = this.#nextButton.disabled = true;
|
||
} else if (this.#searchMatches.length === 0) {
|
||
this.#matchInfo.textContent = "No results";
|
||
this.#prevButton.disabled = this.#nextButton.disabled = true;
|
||
} else {
|
||
this.#matchInfo.textContent = `${this.#currentMatchIdx + 1} / ${this.#searchMatches.length}`;
|
||
this.#prevButton.disabled = this.#nextButton.disabled = false;
|
||
}
|
||
}
|
||
|
||
#computeMatches() {
|
||
this.jumpToLine(-1);
|
||
this.#searchMatches = [];
|
||
this.#currentMatchIdx = -1;
|
||
|
||
const query = this.#searchInput.value;
|
||
if (!query) {
|
||
this.#updateMatchInfo();
|
||
return false;
|
||
}
|
||
|
||
let test;
|
||
if (this.#regexCb.checked) {
|
||
try {
|
||
const re = new RegExp(query, this.#ignoreCaseCb.checked ? "i" : "");
|
||
test = str => re.test(str);
|
||
this.#searchInput.removeAttribute("aria-invalid");
|
||
this.#searchError.textContent = "";
|
||
} catch {
|
||
this.#searchInput.setAttribute("aria-invalid", "true");
|
||
this.#searchError.textContent = "Invalid regular expression";
|
||
this.#updateMatchInfo();
|
||
return false;
|
||
}
|
||
} else {
|
||
const ignoreCase = this.#ignoreCaseCb.checked;
|
||
const needle = ignoreCase ? query.toLowerCase() : query;
|
||
test = str => (ignoreCase ? str.toLowerCase() : str).includes(needle);
|
||
}
|
||
this.#searchInput.removeAttribute("aria-invalid");
|
||
this.#searchError.textContent = "";
|
||
|
||
for (let i = 0, ii = this.#total; i < ii; i++) {
|
||
if (test(this.#getText(i))) {
|
||
this.#searchMatches.push(i);
|
||
}
|
||
}
|
||
return this.#searchMatches.length > 0;
|
||
}
|
||
|
||
#navigateMatch(delta) {
|
||
if (!this.#searchMatches.length) {
|
||
return;
|
||
}
|
||
this.#currentMatchIdx =
|
||
(this.#currentMatchIdx + delta + this.#searchMatches.length) %
|
||
this.#searchMatches.length;
|
||
this.jumpToLine(this.#searchMatches[this.#currentMatchIdx]);
|
||
this.#updateMatchInfo();
|
||
}
|
||
|
||
#runSearch() {
|
||
if (this.#computeMatches() && this.#searchMatches.length) {
|
||
this.#currentMatchIdx = 0;
|
||
this.jumpToLine(this.#searchMatches[0]);
|
||
}
|
||
this.#updateMatchInfo();
|
||
}
|
||
}
|
||
|
||
export { MultilineView };
|