import { nextTick, markRaw } from "vue";
import { createTimers, createListeners } from "./state.js";
import { slotRedactions } from "./app.js";
// Used by the reviewer page and the author's preview with pageView.htm.
export const setupFileViewer = function (state, events = state) {
const REDACTION_RE = /\bXXXX-\d+\b/g;
events.on("dark-mode", (event, on) => {
if (state.aceOption) state.aceOption.theme = on ? "nord_dark" : "chrome";
});
const timers = createTimers();
const listen = createListeners();
state.isMac = /Mac|iPhone|iPad|iPod/.test(navigator.platform || navigator.userAgent);
const extensionModes = {
yml: "yaml",
txt: "text",
py: "python",
js: "javascript",
ts: "typescript",
};
const textFiles = ["license", "txt"];
const imageFiles = [
"png",
"jpg",
"jpeg",
"gif",
"svg",
"ico",
"bmp",
"tiff",
"tif",
"webp",
"avif",
"heif",
"heic",
];
const audioFiles = ["wav", "mp3", "ogg", "wma", "flac", "aac", "m4a"];
const mediaFiles = [
"mp4",
"avi",
"webm",
"mov",
"mpg",
"mpeg",
"mkv",
"flv",
"wmv",
"3gp",
"3g2",
"m4v",
"f4v",
"f4p",
"f4a",
"f4b",
];
// Defined as methods rather than inline `showSource = !showSource` in the
// template: the toolbar and the file view sit in different child scopes,
// so an inline assignment would shadow the value on one of them instead
// of updating the controller's.
state.toggleSource = function () {
state.showSource = !state.showSource;
};
state.toggleAllowScripts = function () {
state.allowScripts = !state.allowScripts;
};
// Wrap on wide screens; on phones code scrolls sideways so tokens stay intact.
const storedWrap = (() => { try { return window.localStorage?.getItem("wrapLines"); } catch { return null; } })();
state.wrapLines = (storedWrap ?? (window.matchMedia?.("(max-width: 767px)").matches ? "false" : "true")) !== "false";
state.toggleWrap = function () {
state.wrapLines = !state.wrapLines;
try { window.localStorage?.setItem("wrapLines", String(state.wrapLines)); } catch { /* storage unavailable */ }
state.codeEditor?.session.setUseWrapMode(state.wrapLines);
};
// Find opens as an overlay at the top right of the code, like an editor.
// Find on rendered Markdown pages: highlight matches inside the reading column.
function mdFindRoot() { return window.document.querySelector(".reading-layout .markdown-body"); }
function clearMdFind() {
const root = mdFindRoot();
if (!root) return;
root.querySelectorAll("mark.md-find").forEach((m) => { m.replaceWith(window.document.createTextNode(m.textContent)); });
root.normalize();
}
state.runMdFind = function () {
clearMdFind();
state.mdFindTotal = 0;
state.mdFindIndex = 0;
const q = (state.mdFindQuery || "").trim();
const root = mdFindRoot();
if (!q || !root) return;
const cs = !!state.mdFindCase;
const needle = cs ? q : q.toLowerCase();
const walker = window.document.createTreeWalker(root, 4);
const nodes = [];
while (walker.nextNode()) if ((cs ? walker.currentNode.nodeValue : walker.currentNode.nodeValue.toLowerCase()).includes(needle)) nodes.push(walker.currentNode);
let total = 0;
for (const node of nodes) {
const text = node.nodeValue, lower = cs ? text : text.toLowerCase();
const frag = window.document.createDocumentFragment();
let last = 0, i;
while ((i = lower.indexOf(needle, last)) !== -1) {
frag.appendChild(window.document.createTextNode(text.slice(last, i)));
const m = window.document.createElement("mark");
m.className = "md-find";
m.textContent = text.slice(i, i + q.length);
frag.appendChild(m);
total++;
last = i + q.length;
}
frag.appendChild(window.document.createTextNode(text.slice(last)));
node.parentNode.replaceChild(frag, node);
}
state.mdFindTotal = total;
if (total) state.stepMdFind(0);
};
state.stepMdFind = function (delta) {
const root = mdFindRoot();
if (!root || !state.mdFindTotal) return;
const marks = root.querySelectorAll("mark.md-find");
state.mdFindIndex = (state.mdFindIndex + delta + marks.length) % marks.length;
marks.forEach((m, i) => m.classList.toggle("is-current", i === state.mdFindIndex));
marks[state.mdFindIndex]?.scrollIntoView({ block: "center", behavior: "smooth" });
};
state.toggleMdFind = function (open) {
state.mdFindOpen = open === undefined ? !state.mdFindOpen : open;
if (!state.mdFindOpen) { clearMdFind(); state.mdFindTotal = 0; return; }
nextTick(() => window.document.querySelector(".md-find-input")?.focus());
};
state.findInFile = function () {
const editor = state.codeEditor;
if (!editor) return;
editor.focus();
editor.execCommand("find");
// Docked under the toolbar: push the code down while the find bar is open.
const dock = () => {
const box = editor.searchBox?.element;
if (!box) return;
editor.renderer.setScrollMargin(52, 12, 0, 0);
if (box._dockObserver) return;
box._dockObserver = new MutationObserver(() => {
editor.renderer.setScrollMargin(box.style.display !== "none" ? 52 : 12, 12, 0, 0);
});
box._dockObserver.observe(box, { attributes: true, attributeFilter: ["style"] });
};
dock();
setTimeout(dock, 150);
};
// Lightweight outline: top-level classes, functions and exported constants.
const symbolPatterns = [
[/^\s*(?:export\s+)?(?:default\s+)?(?:abstract\s+)?class\s+([A-Za-z_$][\w$]*)/, "class"],
[/^\s*(?:export\s+)?(?:default\s+)?(?:async\s+)?function\s*\*?\s*([A-Za-z_$][\w$]*)/, "function"],
[/^\s*(?:export\s+)?(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*(?:async\s*)?(?:function|\([^)]*\)\s*=>|[A-Za-z_$][\w$]*\s*=>)/, "function"],
[/^\s*(?:async\s+)?def\s+([A-Za-z_]\w*)/, "function"],
[/^\s*class\s+([A-Za-z_]\w*)/, "class"],
[/^\s*func\s+(?:\([^)]*\)\s*)?([A-Za-z_]\w*)/, "function"],
[/^\s*(?:pub\s+)?fn\s+([A-Za-z_]\w*)/, "function"],
[/^\s*(?:public|private|protected|static|\s)+[\w<>\[\],\s]+\s+([A-Za-z_]\w*)\s*\([^;]*\)\s*\{?\s*$/, "function"],
];
let symbolCache = { content: null, list: [] };
state.symbolsOpen = false;
state.symbols = function () {
if (typeof state.content !== "string" || state.content.length > 2_000_000) return [];
if (symbolCache.content === state.content) return symbolCache.list;
const list = [];
state.content.split("\n").forEach((text, index) => {
for (const [re, kind] of symbolPatterns) {
const m = re.exec(text);
if (m && !/^(if|for|while|switch|return|catch)$/.test(m[1])) { list.push({ name: m[1], kind, line: index + 1 }); break; }
}
});
symbolCache = { content: state.content, list: list.slice(0, 300) };
return symbolCache.list;
};
state.goToSymbol = function (line) {
state.symbolsOpen = false;
const editor = state.codeEditor;
if (!editor) return;
editor.scrollToLine(line - 1, true, true, () => {});
editor.gotoLine(line, 0, true);
window.history.replaceState(null, "", window.location.pathname + window.location.search + "#L" + line);
window.dispatchEvent(new HashChangeEvent("hashchange"));
};
// Go to line: a small popover in the file header (Ace's own prompt ignores the site theme).
state.gotoLine = function () {
if (!state.codeEditor) return;
state.moreOpen = false;
state.gotoError = "";
state.gotoValue = String(state.codeEditor.getCursorPosition().row + 1);
state.gotoOpen = true;
setTimeout(() => { const el = window.document.getElementById("goto-input"); if (el) { el.focus(); el.select(); } }, 0);
};
state.closeGoto = function () {
state.gotoOpen = false;
state.gotoError = "";
state.codeEditor?.focus();
};
state.submitGoto = function () {
const editor = state.codeEditor;
if (!editor) return;
const m = /^\s*:?\s*(\d+)\s*(?:[:,]\s*(\d+))?\s*$/.exec(state.gotoValue || "");
const lines = editor.session.getLength();
if (!m || +m[1] < 1 || +m[1] > lines) { state.gotoError = "Enter a line from 1 to " + lines; return; }
editor.gotoLine(+m[1], m[2] ? Math.max(0, +m[2] - 1) : 0, true);
state.gotoOpen = false;
state.gotoError = "";
editor.focus();
};
// Root listing above the README: folders first, then files.
state.rootEntries = function () {
return [...(state.files || [])].sort((a, b) => {
const af = a.size == null, bf = b.size == null;
if (af !== bf) return af ? -1 : 1;
return a.name.localeCompare(b.name);
});
};
state.formatSize = function (bytes) {
if (bytes == null) return "";
if (bytes < 1024) return bytes + " B";
const kb = bytes / 1024;
const one = (n) => n.toFixed(1).replace(/\.0$/, "");
return kb < 1024 ? one(kb) + " KB" : kb < 1024 * 1024 ? one(kb / 1024) + " MB" : one(kb / 1024 / 1024) + " GB";
};
state.fileSize = function (path) {
if (!path) return null;
const clean = path.replace(/^\/+/, "");
const entry = (state.files || []).find((f) => (f.path ? f.path + "/" : "") + f.name === clean);
return entry && entry.size != null ? entry.size : null;
};
state.revealFolder = function (name) {
state.sidebarHidden = false;
state.sidebarCollapsed = false;
nextTick(() => {
const link = window.document.querySelector('.files a[data-path="/' + (window.CSS?.escape ? window.CSS.escape(name) : name) + '"]');
if (!link) return;
if (!link.closest("li")?.classList.contains("open")) link.click();
link.scrollIntoView({ block: "nearest" });
link.focus();
});
};
// Notebooks: count the same markers in cell sources and text outputs (not in the JSON keys or image data).
let nbCount = { content: null, n: 0 };
const notebookRedactions = (content) => {
if (nbCount.content === content) return nbCount.n;
let nb = content;
if (typeof nb === "string") { try { nb = JSON.parse(nb); } catch { nb = null; } }
const texts = [];
const add = (v) => { if (Array.isArray(v)) texts.push(v.join("")); else if (typeof v === "string") texts.push(v); };
((nb && (nb.cells || (nb.worksheets || []).flatMap((w) => w.cells || []))) || []).forEach((cell) => {
add(cell?.source ?? cell?.input);
(cell?.outputs || []).forEach((o) => {
add(o?.text);
if (o?.data) { add(o.data["text/plain"]); add(o.data["text/markdown"]); add(o.data["text/html"]); }
add(o?.traceback);
});
});
const n = texts.reduce((sum, t) => sum + (t.match(REDACTION_RE) || []).length, 0);
nbCount = { content, n };
return n;
};
state.redactionCount = function () {
if (state.type === "IPython") return notebookRedactions(state.content);
if (typeof state.content !== "string") return 0;
const text = state.type === "html" ? state.content.replace(/<[^>]*>/g, " ") : state.content;
return (text.match(REDACTION_RE) || []).length;
};
state.renderRedacted = slotRedactions;
// Reading mode: slugged heading ids + an "On this page" outline (like Mintlify/Stripe docs).
const slug = (t) => t.toLowerCase().trim().replace(/[^a-z0-9\s-]/g, "").replace(/\s+/g, "-").slice(0, 60) || "section";
state.renderReadme = function (html) {
const out = state.renderRedacted(html);
if (!out || typeof window.DOMParser !== "function") return out;
const doc = new window.DOMParser().parseFromString("
" + out + "
", "text/html");
const seen = {};
doc.querySelectorAll("h1, h2, h3").forEach((h) => {
let id = h.id || slug(h.textContent);
if (seen[id]) id += "-" + (++seen[id]); else seen[id] = 1;
h.id = id;
});
return doc.body.firstChild.innerHTML;
};
let tocCache = { content: null, list: [] };
state.readmeToc = function () {
if (state.type !== "html" || typeof state.content !== "string") return [];
if (tocCache.content === state.content) return tocCache.list;
const doc = new window.DOMParser().parseFromString("" + state.renderReadme(state.sanitize ? state.sanitize(state.content) : state.content) + "
", "text/html");
const list = [...doc.querySelectorAll("h2, h3")].map((h) => ({ id: h.id, text: h.textContent.trim(), level: h.tagName === "H3" ? 3 : 2 })).slice(0, 40);
tocCache = { content: state.content, list };
return list;
};
state.activeHeading = null;
listen(window, "scroll", () => {
const toc = state.readmeToc ? state.readmeToc() : [];
if (!toc.length) return;
// Active = the first heading visible below the sticky chrome; if none is on screen,
// the last heading already scrolled past (the reader is inside that section).
const chrome = 150;
let current = "", firstVisible = "";
for (const h of toc) {
const el = window.document.getElementById(h.id);
if (!el) continue;
const top = el.getBoundingClientRect().top;
if (top < chrome) current = h.id;
else if (!firstVisible && top < window.innerHeight - 80) firstVisible = h.id;
}
if (firstVisible) current = firstVisible;
if (state.activeHeading !== current) state.activeHeading = current;
}, { capture: true, passive: true });
state.scrollToHeading = function (id) {
if (!id) {
const sc = window.document.querySelector(".explorer-content, .file-card-body, .main-pane");
(sc || window).scrollTo({ top: 0, behavior: "smooth" });
state.activeHeading = "";
return;
}
const el = window.document.getElementById(id);
if (el) el.scrollIntoView({ behavior: "smooth", block: "start" });
state.activeHeading = id;
};
// CSV/TSV files up to 1 MB render as a table (sticky header, rules, tabular
// figures) with a Raw toggle back to the code view.
state.csvView = "table";
let csvCache = { content: null, rows: [] };
const parseDelimited = (text, sep) => {
const rows = [];
let row = [], cell = "", quoted = false;
for (let i = 0; i < text.length; i++) {
const ch = text[i];
if (quoted) {
if (ch === '"') { if (text[i + 1] === '"') { cell += '"'; i++; } else quoted = false; }
else cell += ch;
} else if (ch === '"' && cell === "") quoted = true;
else if (ch === sep) { row.push(cell); cell = ""; }
else if (ch === "\n" || ch === "\r") {
if (ch === "\r" && text[i + 1] === "\n") i++;
row.push(cell); rows.push(row); row = []; cell = "";
} else cell += ch;
}
if (cell !== "" || row.length) { row.push(cell); rows.push(row); }
return rows.filter((r) => r.length > 1 || r[0] !== "");
};
state.isCsv = () => typeof state.content === "string" && state.type !== "html"
&& /\.(csv|tsv)$/i.test(state.filePath || "") && state.content.length <= 1024 * 1024;
state.csvRows = () => {
if (csvCache.content !== state.content) {
const sep = /\.tsv$/i.test(state.filePath || "") ? "\t" : ",";
csvCache = { content: state.content, rows: parseDelimited(state.content, sep) };
}
return csvCache.rows;
};
state.csvNumCol = (ci) => {
const rows = state.csvRows().slice(1).map((r) => r[ci]).filter((v) => v != null && String(v).trim() !== "");
return rows.length > 0 && rows.every((v) => state.csvNumeric(v));
};
state.csvNumeric = (value) => /^\s*[-+]?(\d[\d,]*)?(\.\d+)?(e[-+]?\d+)?%?\s*$/i.test(value) && /\d/.test(value);
state.redactParts = function (text) {
const out = []; let last = 0; let m; const re = /\bXXXX-(\d+)\b/g;
text = String(text || "");
while ((m = re.exec(text))) { if (m.index > last) out.push({ t: text.slice(last, m.index) }); out.push({ n: m[1] }); last = m.index + m[0].length; }
if (last < text.length) out.push({ t: text.slice(last) });
return out;
};
state.pathCopied = false;
state.copyPath = async function () {
if (!window.navigator.clipboard?.writeText) return;
await window.navigator.clipboard.writeText(state.filePath || "");
state.pathCopied = true;
setTimeout(() => { state.pathCopied = false; }, 1600);
};
state.contentCopied = false;
state.copyContent = async function () {
if (typeof state.content !== "string" || !window.navigator.clipboard?.writeText) return;
// Rendered Markdown: copy the source, not the HTML.
let text = state.content;
if (state.type === "html" && state.url) {
if (state.preview) text = state.sourceContent;
else try { text = await (await window.fetch(state.url, { credentials: "same-origin" })).text(); } catch (_) { return; }
}
await window.navigator.clipboard.writeText(text);
state.contentCopied = true;
setTimeout(() => { state.contentCopied = false; }, 1600);
};
function getMode(extension) {
if (extensionModes[extension]) {
return extensionModes[extension];
}
return extension;
}
function getType(extension) {
if (extension == "pdf") {
return "pdf";
}
// Rendered as a document in a sandboxed frame rather than as source —
// see html-doc.js. "html" is reserved for markup we generated
// ourselves (rendered markdown/org) and inject directly.
if (extension == "html" || extension == "htm") {
return "html-doc";
}
if (["md", "markdown", "mdown", "mkdn", "mdx"].includes(extension)) {
return "md";
}
if (extension == "org") {
return "org";
}
if (extension == "ipynb") {
return "IPython";
}
if (textFiles.indexOf(extension) > -1) {
return "text";
}
if (imageFiles.indexOf(extension) > -1) {
return "image";
}
if (mediaFiles.indexOf(extension) > -1) {
return "media";
}
if (audioFiles.indexOf(extension) > -1) {
return "audio";
}
return "code";
}
state.downloadUrl = () => state.preview ? state.url : state.url + "&download=true";
state.prepareFile = function (path) {
state.showSource = false;
state.allowScripts = false;
state.imageSize = "";
state.filePath = path;
state.paths = path.split("/").filter(Boolean);
let extension = state.filePath.toLowerCase();
const extensionIndex = extension.lastIndexOf(".");
if (extensionIndex > -1) {
extension = extension.substring(extensionIndex + 1);
}
const languageNames = { javascript: "JavaScript", typescript: "TypeScript", tsx: "TSX", jsx: "JSX", python: "Python", java: "Java", c_cpp: "C/C++", csharp: "C#", golang: "Go", rust: "Rust", ruby: "Ruby", php: "PHP", html: "HTML", css: "CSS", scss: "SCSS", json: "JSON", yaml: "YAML", markdown: "Markdown", sh: "Shell", dockerfile: "Dockerfile", xml: "XML", sql: "SQL", kotlin: "Kotlin", swift: "Swift", scala: "Scala", r: "R", julia: "Julia", latex: "LaTeX", toml: "TOML", csv: "CSV", tsv: "TSV", text: "" };
const detectedMode = getMode(extension);
state.languageName = languageNames[detectedMode] ?? (detectedMode ? detectedMode.charAt(0).toUpperCase() + detectedMode.slice(1) : "");
state.aceOption = {
readOnly: true,
useWrapMode: true,
showGutter: true,
theme: "chrome",
useSoftTab: true,
tabSize: 2,
fontSize: window.matchMedia?.("(max-width: 767px)").matches ? 12 : 13,
keyBinding: "vscode",
fullLineSelection: true,
highlightActiveLine: false,
highlightGutterLine: false,
cursor: "hide",
showInvisibles: false,
showIndentGuides: false,
showPrintMargin: false,
highlightSelectedWord: false,
enableBehaviours: true,
fadeFoldWidgets: false,
mode: getMode(extension),
onLoad: function (_editor) {
state.codeEditor = markRaw(_editor);
{
// Render each XXXX-n as its own "redacted" token so the text layer
// draws it exactly like the Markdown chip (teal text on a teal tint).
const session = _editor.session;
const origGetTokens = session.getTokens.bind(session);
const re = /\bXXXX-\d+\b/g;
session.getTokens = function (row) {
const tokens = origGetTokens(row);
if (!tokens.some((t) => t.value.indexOf("XXXX-") !== -1)) return tokens;
const out = [];
for (const t of tokens) {
if (t.value.indexOf("XXXX-") === -1) { out.push(t); continue; }
let last = 0;
re.lastIndex = 0;
let m;
while ((m = re.exec(t.value))) {
if (m.index > last) out.push({ type: t.type, value: t.value.slice(last, m.index) });
out.push({ type: "redacted.n" + m[0].slice(5), value: m[0] });
last = m.index + m[0].length;
}
if (last < t.value.length) out.push({ type: t.type, value: t.value.slice(last) });
}
return out;
};
// Each redacted token shows its term number inside the slot.
if (!window.document.getElementById("redact-index-rules")) {
const st = window.document.createElement("style");
st.id = "redact-index-rules";
st.textContent = Array.from({ length: 99 }, (_, i) => `.ace_redacted.ace_n${i + 1}::after{content:"${i + 1}"}`).join("");
window.document.head.appendChild(st);
}
_editor.renderer.updateFull(true);
}
_editor.session.setUseWrapMode(state.wrapLines !== false);
// The right-edge fade is a cue only while there is more code to the right.
const card = _editor.container.closest?.(".file-card");
const syncEdge = () => {
if (!card) return;
const r = _editor.renderer;
const max = (r.layerConfig?.width || 0) - (r.$size?.scrollerWidth || 0);
card.classList.toggle("at-right-edge", max <= 2 || r.getScrollLeft() >= max - 2);
};
_editor.session.on("changeScrollLeft", syncEdge);
_editor.renderer.on("afterRender", syncEdge);
// On phones code scrolls sideways; keep the scrollbar visible as a cue.
if (window.matchMedia?.("(max-width: 767px)").matches) _editor.setOption("hScrollBarAlwaysVisible", true);
const Range = ace.require("ace/range").Range;
let activeLineMarker = null;
function highlightLines(from, to) {
if (activeLineMarker !== null) {
_editor.session.removeMarker(activeLineMarker);
activeLineMarker = null;
}
if (from === null || from === undefined) return;
activeLineMarker = _editor.session.addMarker(
new Range(from, 0, to, 1),
"highlighted-line",
"fullLine"
);
}
function applyHashFromUrl(scroll) {
const m = !state.preview && window.location.hash.match(/^#L(\d+)(?:-L(\d+))?/);
if (!m) {
highlightLines(null);
return;
}
const from = parseInt(m[1]) - 1;
const to = m[2] ? parseInt(m[2]) - 1 : from;
highlightLines(from, to);
if (scroll) {
timers.timeout(() => {
_editor.scrollToLine(from, true, true, function () {});
}, 100);
}
}
applyHashFromUrl(true);
// #392 — clicking a gutter line updates the URL to #L and
// shift-clicking extends to #L-L so the user can copy
// a stable link to a specific line. Use replaceState to avoid
// polluting history with every click.
let anchorRow = null;
_editor.on("guttermousedown", function (e) {
const row = e.getDocumentPosition().row;
const shift = e.domEvent && e.domEvent.shiftKey;
let from = row;
let to = row;
if (shift && anchorRow !== null) {
from = Math.min(anchorRow, row);
to = Math.max(anchorRow, row);
} else {
anchorRow = row;
}
const hash =
from === to
? `#L${from + 1}`
: `#L${from + 1}-L${to + 1}`;
const url =
window.location.pathname + window.location.search + hash;
// The preview highlights lines without touching the form's URL.
if (!state.preview) window.history.replaceState(null, "", url);
highlightLines(from, to);
e.stop();
});
const removeHashListener = listen(window, "hashchange", () => applyHashFromUrl(false));
_editor.setFontSize(state.aceOption.fontSize);
_editor.setOptions({
fontFamily: "'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, monospace",
});
_editor.container.style.lineHeight = "20px";
_editor.renderer.setPadding(16);
_editor.renderer.setScrollMargin(12, 12, 0, 0);
_editor.renderer.updateFontSize();
_editor.setReadOnly(state.aceOption.readOnly);
_editor.setKeyboardHandler(state.aceOption.keyBinding);
_editor.setSelectionStyle(
state.aceOption.fullLineSelection ? "line" : "text"
);
_editor.setOption("displayIndentGuides", false);
_editor.setHighlightActiveLine(
state.aceOption.highlightActiveLine
);
if (state.aceOption.cursor == "hide") {
_editor.renderer.$cursorLayer.element.style.display = "none";
}
_editor.setHighlightGutterLine(
state.aceOption.highlightGutterLine
);
_editor.setShowInvisibles(state.aceOption.showInvisibles);
_editor.setDisplayIndentGuides(state.aceOption.showIndentGuides);
_editor.renderer.setShowPrintMargin(
state.aceOption.showPrintMargin
);
_editor.setHighlightSelectedWord(
state.aceOption.highlightSelectedWord
);
_editor.session.setUseSoftTabs(state.aceOption.useSoftTab);
_editor.session.setTabSize(state.aceOption.tabSize);
_editor.setBehavioursEnabled(state.aceOption.enableBehaviours);
_editor.setFadeFoldWidgets(state.aceOption.fadeFoldWidgets);
// Ctrl/Cmd+F searches inside the file (Ace searchbox), like GitHub's blob view.
// In the preview the shortcuts apply only while the code has focus, so
// the form keeps the browser's own Find.
const ownsShortcuts = () => !state.preview || _editor.isFocused();
const removeFindListener = listen(window, "keydown", (event) => {
if ((event.ctrlKey || event.metaKey) && !event.altKey && event.key.toLowerCase() === "g" && document.body.contains(_editor.container) && ownsShortcuts()) {
event.preventDefault();
state.gotoLine();
return;
}
if (event.altKey && !event.ctrlKey && !event.metaKey && event.key.toLowerCase() === "z" && document.body.contains(_editor.container) && ownsShortcuts()) {
event.preventDefault();
state.toggleWrap();
return;
}
if ((event.ctrlKey || event.metaKey) && !event.altKey && event.key.toLowerCase() === "f" && document.body.contains(_editor.container) && ownsShortcuts()) {
event.preventDefault();
state.findInFile();
}
});
return () => { removeHashListener(); removeFindListener(); };
},
};
if (state.isDarkMode) {
state.aceOption.theme = "nord_dark";
}
state.type = getType(extension);
};
state.renderFileContent = function (content, type, baseUrl, contentType) {
state.type = type;
state.content = content;
state.sourceContent = content;
if (state.content == "") {
state.content = null;
}
if (state.type == "md") {
state.content = renderMD(content, baseUrl);
state.type = "html";
}
if (state.type == "org") {
const orgSource = contentAbs2Relative(content);
const orgParser = new Org.Parser();
const orgDocument = orgParser.parse(orgSource);
var orgHTMLDocument = orgDocument.convert(Org.ConverterHTML, {
headerOffset: 1,
exportFromLineNumber: false,
suppressSubScriptHandling: true,
suppressAutoLink: false,
});
state.content = DOMPurify.sanitize(orgHTMLDocument.toString());
state.type = "html";
}
if (
state.type == "code" &&
contentType == "application/octet-stream"
) {
state.type = "binary";
state.content = "binary";
}
timers.timeout(() => { Prism.highlightAll(); }, 50);
};
// Files without a text body (media, notebooks, binaries, empty files and
// per-file errors) share the code view's framed card and header.
const FRAMED_TYPES = ["image", "media", "audio", "pdf", "IPython", "binary"];
const FILE_ERRORS = ["file_too_big", "file_not_found", "file_not_accessible", "file_not_supported", "folder_not_supported"];
state.fileFrame = () => {
if (!state.filePath) return false;
if (FRAMED_TYPES.includes(state.type)) return state.content != null;
if (state.type === "error") return FILE_ERRORS.includes(state.content);
return state.content == null && ["code", "text"].includes(state.type);
};
state.fileKind = () => {
const name = (state.paths || [])[(state.paths || []).length - 1] || "";
const dot = name.lastIndexOf(".");
const ext = dot > 0 ? name.slice(dot + 1).toUpperCase() : "";
if (state.type === "image") return (ext === "JPG" ? "JPEG" : ext) + " image";
const MEDIA_NAMES = { WEBM: "WebM", OGG: "Ogg", OGV: "Ogg", MOV: "QuickTime", M4V: "M4V", MKV: "Matroska" };
if (state.type === "media") return (MEDIA_NAMES[ext] || ext) + " video";
if (state.type === "audio") return ext + " audio";
if (state.type === "pdf") return "PDF";
if (state.type === "IPython") return "Jupyter notebook";
if (state.type === "binary") return "Binary file";
return state.languageName || (ext ? ext + " file" : "File");
};
state.imageSize = "";
state.onImageLoad = (event) => {
const img = event.target;
if (img?.naturalWidth) state.imageSize = `${img.naturalWidth} × ${img.naturalHeight}`;
};
// The reviewer page closes its menus in exploreController; this is the preview's.
listen(document, "click", e => {
if (!state.preview) return;
if (!e.target.closest?.(".symbols")) state.symbolsOpen = false;
if (!e.target.closest?.(".tb-more")) state.moreOpen = false;
if (!e.target.closest?.(".tb-toc-m")) state.tocOpen = false;
if (e.target.isConnected && !e.target.closest?.(".goto-pop, .btn-goto, .tb-more")) state.gotoOpen = false;
});
};