first commit
This commit is contained in:
32
20250916/ppt/js/engine.js
Normal file
32
20250916/ppt/js/engine.js
Normal file
@@ -0,0 +1,32 @@
|
||||
function escapeHtml(str) {
|
||||
if (str == null) return "";
|
||||
return String(str)
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
export function renderTemplate(template, data = {}, opts = { allowRaw: true }) {
|
||||
// 支持 nested keys (a.b.c)
|
||||
const get = (path) =>
|
||||
path
|
||||
.split(".")
|
||||
.reduce((acc, k) => (acc == null ? undefined : acc[k]), data);
|
||||
|
||||
return template.replace(
|
||||
/\{\{\{\s*([\w$.]+)\s*\}\}\}|\{\{\s*([\w$.]+)\s*\}\}/g,
|
||||
(m, rawKey, escKey) => {
|
||||
const key = rawKey || escKey;
|
||||
const val = get(key);
|
||||
if (rawKey)
|
||||
return opts.allowRaw
|
||||
? val == null
|
||||
? ""
|
||||
: String(val)
|
||||
: escapeHtml(val);
|
||||
return val == null ? "" : escapeHtml(val);
|
||||
}
|
||||
);
|
||||
}
|
||||
201
20250916/ppt/js/loader.js
Normal file
201
20250916/ppt/js/loader.js
Normal file
@@ -0,0 +1,201 @@
|
||||
// js/loader.js
|
||||
// 负责:加载 manifest、加载模板 HTML(并注入模板 CSS)、渲染接口、加载 override CSS
|
||||
// 依赖:./engine.js 导出的 renderTemplate(template, data, opts)
|
||||
|
||||
import { renderTemplate } from "./engine.js";
|
||||
|
||||
const manifestCache = new Map(); // manifestPath -> manifest object
|
||||
const templateCache = new Map(); // cacheKey -> { html, meta, baseUrl }
|
||||
|
||||
/**
|
||||
* Resolve a possibly-relative URL against a base (template HTML path or manifest path).
|
||||
* Returns an absolute-ish href (as string) that can be used in link[href="..."] matching.
|
||||
*/
|
||||
function resolveUrl(href, base) {
|
||||
try {
|
||||
// new URL(href, base) will resolve relative href against base
|
||||
return new URL(href, base).toString();
|
||||
} catch (e) {
|
||||
// fallback: return original
|
||||
return href;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load manifest JSON (模板注册表). 支持缓存。
|
||||
* manifestPath: string (绝对或相对 URL)
|
||||
*/
|
||||
export async function loadManifest(manifestPath = "templates/manifest.json") {
|
||||
if (manifestCache.has(manifestPath)) return manifestCache.get(manifestPath);
|
||||
|
||||
const res = await fetch(manifestPath);
|
||||
if (!res.ok)
|
||||
throw new Error(`无法加载 manifest: ${manifestPath} (${res.status})`);
|
||||
const manifest = await res.json();
|
||||
|
||||
// store base for resolving any relative css/html in manifest entries
|
||||
const base = new URL(location.origin + location.pathname + manifestPath).toString();
|
||||
manifestCache.set(manifestPath, { data: manifest, base });
|
||||
return manifestCache.get(manifestPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load template by name using manifest.
|
||||
* manifestPath: path to manifest (from config) — loader will call loadManifest(manifestPath)
|
||||
* templateName: key inside manifest (e.g., "speech")
|
||||
*
|
||||
* Returns an object:
|
||||
* {
|
||||
* html: '<div...>...</div>', // body innerHTML of template
|
||||
* meta: { html: '/templates/speech.html', css: '/templates/speech.css' },
|
||||
* baseUrl: 'https://yourhost/templates/' // used to resolve relative hrefs in template
|
||||
* }
|
||||
*/
|
||||
export async function loadTemplateByName(manifestPath, templateName) {
|
||||
const manifestObj = await loadManifest(manifestPath);
|
||||
const manifest = manifestObj.data;
|
||||
const manifestBase = manifestObj.base;
|
||||
|
||||
const meta = manifest[templateName];
|
||||
if (!meta)
|
||||
throw new Error(`模板未注册: ${templateName} (manifest: ${manifestPath})`);
|
||||
|
||||
// determine absolute paths for template html and template css (if provided)
|
||||
const htmlPath = resolveUrl(meta.html, manifestBase);
|
||||
const cssPath = meta.css ? resolveUrl(meta.css, manifestBase) : null;
|
||||
|
||||
// cacheKey should include both htmlPath and cssPath
|
||||
const cacheKey = `${htmlPath}::${cssPath || ""}`;
|
||||
if (templateCache.has(cacheKey)) return templateCache.get(cacheKey);
|
||||
|
||||
// fetch template HTML
|
||||
const res = await fetch(htmlPath);
|
||||
if (!res.ok)
|
||||
throw new Error(`无法加载模板 HTML: ${htmlPath} (${res.status})`);
|
||||
const rawHtml = await res.text();
|
||||
|
||||
// inject manifest-declared css (meta.css) into head if present (and not already injected)
|
||||
if (cssPath) {
|
||||
injectCssIfNeeded(cssPath);
|
||||
}
|
||||
|
||||
// parse template HTML to capture any <link rel="stylesheet"> declared inside template,
|
||||
// and inject those into head too (resolving relative hrefs against htmlPath)
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(rawHtml, "text/html");
|
||||
const linkEls = Array.from(doc.querySelectorAll('link[rel="stylesheet"]'));
|
||||
|
||||
linkEls.forEach((linkEl) => {
|
||||
const href = linkEl.getAttribute("href");
|
||||
if (!href) return;
|
||||
const resolved = resolveUrl(href, htmlPath);
|
||||
injectCssIfNeeded(resolved);
|
||||
});
|
||||
|
||||
// optionally: you might want to extract and move inline <style> out,
|
||||
// but keeping inline <style> inside template HTML is acceptable.
|
||||
const bodyHtml = doc.body.innerHTML.trim();
|
||||
|
||||
const tpl = {
|
||||
html: bodyHtml,
|
||||
meta: { ...meta, html: htmlPath, css: cssPath },
|
||||
baseUrl: new URL(".", htmlPath).toString(),
|
||||
};
|
||||
templateCache.set(cacheKey, tpl);
|
||||
|
||||
return tpl;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper: inject a stylesheet link element into document.head if not already present.
|
||||
* Performs de-dup by checking existing link[href="..."] after resolving href.
|
||||
*/
|
||||
function injectCssIfNeeded(href) {
|
||||
const absHref = href; // already resolved by resolveUrl
|
||||
// dedupe: check both full absolute href and possibly same-origin relative forms by comparing full URL
|
||||
const existing = Array.from(
|
||||
document.querySelectorAll('link[rel="stylesheet"]')
|
||||
).some((l) => {
|
||||
// some browsers return absolute hrefs, so compare URL()
|
||||
try {
|
||||
return (
|
||||
new URL(l.href, location.href).toString() ===
|
||||
new URL(absHref, location.href).toString()
|
||||
);
|
||||
} catch (e) {
|
||||
return l.getAttribute("href") === absHref;
|
||||
}
|
||||
});
|
||||
if (!existing) {
|
||||
const link = document.createElement("link");
|
||||
link.rel = "stylesheet";
|
||||
link.href = absHref;
|
||||
document.head.appendChild(link);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render wrapper — 调用 engine.renderTemplate
|
||||
* tplHtml: string (template HTML body)
|
||||
* data: object
|
||||
* opts: { allowRaw: boolean }
|
||||
*/
|
||||
export function render(tplHtml, data = {}, opts = { allowRaw: true }) {
|
||||
return renderTemplate(tplHtml, data, opts);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load override CSS for an instance (per-slide override), with dedupe.
|
||||
* href can be absolute or relative; if relative, resolve against current location.
|
||||
*/
|
||||
export function loadOverrideCss(href) {
|
||||
if (!href) return null;
|
||||
const resolved = resolveUrl(href, location.href);
|
||||
|
||||
const link = document.createElement("link");
|
||||
link.rel = "stylesheet";
|
||||
link.href = resolved;
|
||||
link.disabled = true; // 默认禁用
|
||||
document.head.appendChild(link);
|
||||
return link;
|
||||
}
|
||||
|
||||
/**
|
||||
* Optional utility: clear caches (for dev hot-reload)
|
||||
*/
|
||||
export function clearCaches() {
|
||||
manifestCache.clear();
|
||||
templateCache.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Optional utility: prefetch templates listed in manifest (improves UX)
|
||||
* manifestPath: path to manifest
|
||||
* templateNames: array of template names to prefetch; if omitted, prefetch all in manifest.
|
||||
*/
|
||||
export async function prefetchTemplates(
|
||||
manifestPath = "/templates/manifest.json",
|
||||
templateNames = null
|
||||
) {
|
||||
const manifestObj = await loadManifest(manifestPath);
|
||||
const manifest = manifestObj.data;
|
||||
const names = templateNames || Object.keys(manifest);
|
||||
await Promise.all(
|
||||
names.map((name) => {
|
||||
const meta = manifest[name];
|
||||
if (!meta) return Promise.resolve();
|
||||
const htmlPath = resolveUrl(meta.html, manifestObj.base);
|
||||
const cssPath = meta.css ? resolveUrl(meta.css, manifestObj.base) : null;
|
||||
// warm up by fetching html (and cache behavior in loadTemplateByName will handle injection)
|
||||
return fetch(htmlPath)
|
||||
.then((r) =>
|
||||
r.ok
|
||||
? r.text().then(() => {
|
||||
if (cssPath) injectCssIfNeeded(cssPath);
|
||||
})
|
||||
: Promise.resolve()
|
||||
)
|
||||
.catch(() => {});
|
||||
})
|
||||
);
|
||||
}
|
||||
96
20250916/ppt/js/ppt.js
Normal file
96
20250916/ppt/js/ppt.js
Normal file
@@ -0,0 +1,96 @@
|
||||
// js/ppt.js
|
||||
// Banquet PPT 入口:读取 config,加载模板,渲染幻灯片到 DOM
|
||||
// 依赖:loader.js 提供的 loadTemplateByName/render/loadOverrideCss
|
||||
|
||||
import { loadTemplateByName, render, loadOverrideCss } from "./loader.js";
|
||||
|
||||
/**
|
||||
* 渲染整个宴会演示文稿
|
||||
* @param {string} configPath - 配置文件路径 (例如 '/config/banquet.json')
|
||||
* @param {string} manifestPath - 模板 manifest 路径 (例如 '/templates/manifest.json')
|
||||
* @param {string} mountSelector - 容器选择器,渲染幻灯片的 DOM 节点
|
||||
*/
|
||||
export async function renderBanquet(
|
||||
configPath = "config/banquet.json",
|
||||
manifestPath = "templates/manifest.json",
|
||||
mountSelector = "#app"
|
||||
) {
|
||||
// 读取配置
|
||||
const res = await fetch(configPath);
|
||||
if (!res.ok)
|
||||
throw new Error(`无法加载配置文件: ${configPath} (${res.status})`);
|
||||
const config = await res.json();
|
||||
|
||||
const container = document.querySelector(mountSelector);
|
||||
if (!container) throw new Error(`容器未找到: ${mountSelector}`);
|
||||
container.innerHTML = "";
|
||||
|
||||
// 存储每个 slide 对应的 CSS link
|
||||
const overrideLinks = [];
|
||||
// 遍历 config.slides 渲染
|
||||
for (const [idx, slide] of config.slides.entries()) {
|
||||
const { template, data = {}, overrideCss } = slide;
|
||||
|
||||
// 加载模板
|
||||
const tpl = await loadTemplateByName(manifestPath, template);
|
||||
|
||||
// 注入实例级 override CSS(如果配置了)
|
||||
let cssLink = null;
|
||||
if (overrideCss) {
|
||||
cssLink = loadOverrideCss(overrideCss); // 获取 link 元素
|
||||
}
|
||||
|
||||
// 渲染 HTML
|
||||
const html = render(tpl.html, data, { allowRaw: true });
|
||||
|
||||
// 创建 slide 容器
|
||||
const slideEl = document.createElement("section");
|
||||
slideEl.classList.add("banquet-slide");
|
||||
slideEl.dataset.index = idx;
|
||||
slideEl.innerHTML = html;
|
||||
|
||||
// 如果配置了背景,应用背景图
|
||||
if (slide.background) {
|
||||
slideEl.style.backgroundImage = `url('${slide.background}')`;
|
||||
slideEl.style.backgroundSize = "cover";
|
||||
slideEl.style.backgroundPosition = "center";
|
||||
}
|
||||
|
||||
container.appendChild(slideEl);
|
||||
overrideLinks[idx] = cssLink; // 保存起来
|
||||
}
|
||||
|
||||
// 简单导航(可扩展)
|
||||
initNavigation(container, overrideLinks);
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化键盘翻页逻辑
|
||||
*/
|
||||
function initNavigation(container, overrideLinks) {
|
||||
let current = 0;
|
||||
const slides = container.querySelectorAll(".banquet-slide");
|
||||
|
||||
function showSlide(i) {
|
||||
slides.forEach((s, idx) => {
|
||||
s.style.display = idx === i ? "block" : "none";
|
||||
});
|
||||
|
||||
// 动态切换 CSS:只让当前 slide 的 override 生效
|
||||
overrideLinks.forEach((link, idx) => {
|
||||
if (link) link.disabled = idx !== i;
|
||||
});
|
||||
|
||||
current = i;
|
||||
}
|
||||
|
||||
showSlide(current);
|
||||
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if (e.key === "ArrowRight" || e.key === "PageDown") {
|
||||
if (current < slides.length - 1) showSlide(current + 1);
|
||||
} else if (e.key === "ArrowLeft" || e.key === "PageUp") {
|
||||
if (current > 0) showSlide(current - 1);
|
||||
}
|
||||
});
|
||||
}
|
||||
11
20250916/ppt/js/utils.js
Normal file
11
20250916/ppt/js/utils.js
Normal file
@@ -0,0 +1,11 @@
|
||||
/** 简单工具 */
|
||||
export function el(selector, root = document) {
|
||||
return root.querySelector(selector);
|
||||
}
|
||||
export function els(selector, root = document) {
|
||||
return Array.from(root.querySelectorAll(selector));
|
||||
}
|
||||
|
||||
export function safeAssign(target, src) {
|
||||
return Object.assign({}, target, src);
|
||||
}
|
||||
Reference in New Issue
Block a user