feat(theme): add style switcher and centralize diff logic

Extract duplicated diff rendering logic into shared/diff-page.js
Implement theme switcher component across all templates
This commit is contained in:
2026-04-08 14:39:39 +08:00
parent 90792b276e
commit e9fcd411a7
9 changed files with 642 additions and 1271 deletions

293
shared/diff-page.js Normal file
View File

@@ -0,0 +1,293 @@
(function (global) {
"use strict";
const DEFAULT_THEMES = [
{ id: "light", label: "轻量", path: "index.html" },
{ id: "cyberpunk", label: "赛博", path: "cyberpunk/index.html" },
{ id: "macos", label: "macOS", path: "macos/index.html" },
{ id: "matrix", label: "Matrix", path: "matrix/index.html" },
{ id: "notion", label: "Notion", path: "notion/index.html" },
{ id: "synthwave", label: "蒸汽波", path: "synthwave/index.html" },
{ id: "exe", label: "EXE", path: "exe/index.html" },
];
function resolveThemeUrl(targetPath, currentPath) {
try {
const url = new URL(global.location.href);
if (url.pathname.endsWith(currentPath)) {
url.pathname =
url.pathname.slice(0, -currentPath.length) + targetPath;
return url.toString();
}
return new URL(targetPath, global.location.href).toString();
} catch (error) {
return targetPath;
}
}
function initStyleSwitcher(config) {
const host = document.querySelector(config.hostSelector || "[data-style-switcher]");
if (!host) {
return;
}
const themes = config.themes || DEFAULT_THEMES;
const wrapper = document.createElement("div");
wrapper.className = "style-switcher";
const label = document.createElement("label");
label.className = "style-switcher-label";
label.textContent = config.switcherLabel || "风格";
const select = document.createElement("select");
select.className = "style-switcher-select";
select.setAttribute(
"aria-label",
config.switcherAriaLabel || "切换界面风格",
);
themes.forEach((theme) => {
const option = document.createElement("option");
option.value = resolveThemeUrl(theme.path, config.currentThemePath);
option.textContent = theme.label;
option.selected = theme.id === config.themeId;
select.appendChild(option);
});
select.addEventListener("change", () => {
global.location.href = select.value;
});
wrapper.appendChild(label);
wrapper.appendChild(select);
host.replaceChildren(wrapper);
}
function getElement(id) {
return document.getElementById(id);
}
function initDiffPage(config) {
const leftTextarea = getElement(config.ids?.leftTextarea || "leftTextarea");
const rightTextarea = getElement(
config.ids?.rightTextarea || "rightTextarea",
);
const compareBtn = getElement(config.ids?.compareBtn || "compareBtn");
const swapBtn = getElement(config.ids?.swapBtn || "swapBtn");
const exampleBtn = getElement(config.ids?.exampleBtn || "exampleBtn");
const clearLeftBtn = getElement(config.ids?.clearLeftBtn || "clearLeftBtn");
const clearRightBtn = getElement(
config.ids?.clearRightBtn || "clearRightBtn",
);
const languageSelect = getElement(
config.ids?.languageSelect || "languageSelect",
);
const diffOutput = getElement(config.ids?.diffOutput || "diff-output");
const diffStats = getElement(config.ids?.diffStats || "diffStats");
const viewOptions = document.querySelectorAll(
config.selectors?.viewOptions || ".view-option",
);
if (
!leftTextarea ||
!rightTextarea ||
!compareBtn ||
!swapBtn ||
!exampleBtn ||
!clearLeftBtn ||
!clearRightBtn ||
!languageSelect ||
!diffOutput ||
!diffStats ||
viewOptions.length === 0
) {
throw new Error("Diff page initialization failed: missing required DOM nodes.");
}
const messages = config.messages || {};
const example = config.example || {};
const normalizeLanguage =
config.normalizeLanguage || ((language) => language);
const formatStats =
config.formatStats ||
((result) => {
if (result.identical) {
return "No changes";
}
return `+${result.added} / -${result.deleted}`;
});
let currentView =
config.initialView ||
Array.from(viewOptions).find((button) => button.classList.contains("active"))
?.getAttribute("data-view") ||
"side-by-side";
function setOutput(html, statsHtml) {
diffOutput.innerHTML = html;
diffStats.innerHTML = statsHtml || "";
}
function getSelectedLanguage() {
return normalizeLanguage(languageSelect.value);
}
function getHighlightConfig() {
const language = getSelectedLanguage();
if (language === "plaintext") {
return false;
}
return { enabled: true, language: language };
}
function generateUnifiedDiff(original, modified) {
return Diff.createTwoFilesPatch(
config.fileLabels?.left || "Original",
config.fileLabels?.right || "Modified",
original || "",
modified || "",
"",
"",
{ context: config.contextLines || 4 },
);
}
function applyHighlighting() {
const selectedLanguage = getSelectedLanguage();
if (selectedLanguage === "plaintext" || !global.hljs) {
return;
}
const codeBlocks = diffOutput.querySelectorAll("code");
codeBlocks.forEach((block) => {
block.classList.forEach((className) => {
if (className.startsWith("language-")) {
block.classList.remove(className);
}
});
block.classList.add("language-" + selectedLanguage);
delete block.dataset.highlighted;
try {
global.hljs.highlightElement(block);
} catch (error) {}
});
}
function renderDiff() {
let diffString = "";
try {
diffString = generateUnifiedDiff(leftTextarea.value, rightTextarea.value);
} catch (error) {
setOutput(
typeof messages.generateError === "function"
? messages.generateError(error)
: "<div>Diff generation failed.</div>",
messages.generateErrorStats || "",
);
return;
}
let diffHtml = "";
try {
diffHtml = Diff2Html.html(diffString, {
drawFileList: false,
matching: "lines",
outputFormat: currentView,
highlight: getHighlightConfig(),
renderNothingWhenEmpty: false,
});
} catch (error) {
setOutput(
typeof messages.renderError === "function"
? messages.renderError(error)
: "<div>Diff rendering failed.</div>",
messages.renderErrorStats || "",
);
return;
}
diffOutput.innerHTML = diffHtml;
applyHighlighting();
const added = diffOutput.querySelectorAll(".d2h-ins").length;
const deleted = diffOutput.querySelectorAll(".d2h-del").length;
const identical = added === 0 && deleted === 0;
diffStats.innerHTML = formatStats({
added: added,
deleted: deleted,
identical: identical,
});
if (!diffHtml.trim() || diffOutput.innerText.trim() === "") {
setOutput(messages.blankResult || "<div>Files are identical.</div>", messages.blankStats || "");
}
}
function setActiveView(view, shouldRender) {
currentView = view;
viewOptions.forEach((button) => {
button.classList.toggle(
"active",
button.getAttribute("data-view") === view,
);
});
if (shouldRender !== false) {
renderDiff();
}
}
function loadExample() {
leftTextarea.value = example.left || "";
rightTextarea.value = example.right || "";
if (example.language) {
languageSelect.value = example.language;
}
renderDiff();
}
function swapTexts() {
const currentLeft = leftTextarea.value;
leftTextarea.value = rightTextarea.value;
rightTextarea.value = currentLeft;
renderDiff();
}
function clearLeft() {
leftTextarea.value = "";
renderDiff();
}
function clearRight() {
rightTextarea.value = "";
renderDiff();
}
compareBtn.addEventListener("click", renderDiff);
swapBtn.addEventListener("click", swapTexts);
exampleBtn.addEventListener("click", loadExample);
clearLeftBtn.addEventListener("click", clearLeft);
clearRightBtn.addEventListener("click", clearRight);
languageSelect.addEventListener("change", renderDiff);
viewOptions.forEach((button) => {
button.addEventListener("click", (event) => {
setActiveView(event.currentTarget.getAttribute("data-view"));
});
});
document.addEventListener("keydown", (event) => {
if ((event.ctrlKey || event.metaKey) && event.key === "Enter") {
event.preventDefault();
renderDiff();
}
});
initStyleSwitcher(config);
setActiveView(currentView, false);
renderDiff();
}
global.initDiffPage = initDiffPage;
})(window);