返回 oh-my-ppt
template-builder.ts
根目录 / src / main / session / template-builder.ts
1 /** Session HTML template builders for the multi-page preview architecture. */
2 import { escapeHtml } from '../presentation/html/escape'
3 import * as cheerio from 'cheerio'
4 import { buildBasePageStyleTag, buildFitScript } from '../presentation/html/page-writer-core'
5 import { requireSlideSize, type SlideSizePreset } from '@shared/slide-size'
6 import { buildSessionAssetHeadTags } from './page-assets'
7 import { buildMasterStyleLink } from '../presentation/html/master-link'
8 import {
9 DEFAULT_INDEX_TRANSITION_CONFIG,
10 buildIndexTransitionConfigScript
11 } from './index-transition'
12
13 export interface DeckPageFile {
14 id?: string
15 pageNumber: number
16 pageId: string
17 title: string
18 htmlPath: string
19 }
20
21 export {
22 SESSION_ASSET_FILES,
23 SESSION_ASSET_FILE_NAMES,
24 SESSION_ASSET_SCRIPT_SRCS,
25 SESSION_ASSET_STYLE_HREFS,
26 buildSessionAssetHeadTags
27 } from './page-assets'
28
29 export const buildPageScaffoldHtml = (page: {
30 pageNumber: number
31 pageId: string
32 title: string
33 }, slideSizeInput: SlideSizePreset): string => {
34 const slideSize = requireSlideSize(slideSizeInput)
35 const safeTitle = escapeHtml(page.title || `第 ${page.pageNumber} 页`)
36 return `<!doctype html>
37 <html lang="zh-CN">
38 <head>
39 <meta charset="UTF-8" />
40 <meta name="viewport" content="width=device-width, initial-scale=1.0" />
41 <title>${safeTitle}</title>
42 ${buildSessionAssetHeadTags()}
43 ${buildBasePageStyleTag(slideSize)}
44 <style>
45 .scaffold-card {
46 width: 100%;
47 height: 100%;
48 border-radius: 24px;
49 border: 1px solid #e2e8f0;
50 background: #ffffff;
51 padding: 28px;
52 }
53 .scaffold-title {
54 margin: 0;
55 font-size: 48px;
56 line-height: 1.2;
57 color: #0f172a;
58 }
59 .scaffold-hint {
60 margin-top: 14px;
61 font-size: 16px;
62 color: #94a3b8;
63 }
64 </style>
65 ${buildMasterStyleLink()}
66 </head>
67 <body data-page-id="${page.pageId}" data-ppt-page-number="${page.pageNumber}">
68 <main class="ppt-page-root" data-ppt-guard-root="1" data-ppt-slide-size-id="${slideSize.id}" data-ppt-width="${slideSize.width}" data-ppt-height="${slideSize.height}" data-ppt-page-number="${page.pageNumber}">
69 <div class="ppt-page-fit-scope">
70 <div class="ppt-page-content">
71 <section class="scaffold-card" data-page-scaffold="1" data-placeholder-page="1">
72 <main data-block-id="content" data-role="content">
73 <h1 class="scaffold-title" data-block-id="title" data-role="title">${safeTitle}</h1>
74 <div class="scaffold-hint">等待模型填充这一页内容</div>
75 </main>
76 </section>
77 </div>
78 </div>
79 </main>
80 ${buildFitScript(slideSize)}
81 </body>
82 </html>`
83 }
84
85 export const buildProjectIndexHtml = (
86 title: string,
87 pages: DeckPageFile[],
88 slideSizeInput: SlideSizePreset
89 ): string => {
90 const slideSize = requireSlideSize(slideSizeInput)
91 const safeTitle = escapeHtml(title || 'OhMyPPT Preview')
92 const pagesData = JSON.stringify(
93 pages.map((page) => ({
94 id: page.id || undefined,
95 pageNumber: page.pageNumber,
96 pageId: page.pageId,
97 title: page.title,
98 htmlPath: page.htmlPath
99 }))
100 ).replace(/</g, '\\u003c')
101 const thumbButtons = pages
102 .map(
103 (page) => {
104 const pageKey = page.id || page.pageId
105 return `<button class="ppt-thumb-item" data-page-id="${pageKey}" data-legacy-page-id="${page.pageId}">
106 <div class="ppt-thumb-index">P${page.pageNumber}</div>
107 <div class="ppt-thumb-title">${escapeHtml(page.title)}</div>
108 </button>`
109 }
110 )
111 .join('\n')
112
113 const frameElements = pages
114 .map(
115 (page) => {
116 const pageKey = page.id || page.pageId
117 return `<iframe class="ppt-preview-frame" data-page-id="${pageKey}" data-legacy-page-id="${page.pageId}" title="${escapeHtml(page.title)}"></iframe>`
118 }
119 )
120 .join('\n')
121
122 return `<!doctype html>
123 <html lang="zh-CN">
124 <head>
125 <meta charset="UTF-8" />
126 <meta name="viewport" content="width=device-width, initial-scale=1.0" />
127 <title>${safeTitle} · Preview</title>
128 <style>
129 :root {
130 --ppt-slide-width: ${slideSize.width}px;
131 --ppt-slide-height: ${slideSize.height}px;
132 }
133 * { box-sizing: border-box; }
134 html, body {
135 width: 100%;
136 height: 100%;
137 }
138 body {
139 margin: 0;
140 font-family: "SF Pro Text", "PingFang SC", "Helvetica Neue", Arial, sans-serif;
141 background: linear-gradient(160deg, #eef6ff 0%, #f8fbff 55%, #eef2ff 100%);
142 color: #1e293b;
143 overflow: hidden;
144 }
145 .ppt-layout {
146 height: 100vh;
147 height: 100dvh;
148 padding: 0;
149 }
150 .ppt-stage {
151 background: #ffffff;
152 height: 100%;
153 min-height: 0;
154 border-radius: 0;
155 overflow: hidden;
156 padding: 0;
157 }
158 .ppt-preview-viewport {
159 position: relative;
160 width: 100%;
161 height: 100%;
162 border-radius: 0;
163 overflow: hidden;
164 background: #ffffff;
165 }
166 .ppt-preview-frame {
167 position: absolute;
168 left: 0;
169 top: 0;
170 width: var(--ppt-slide-width);
171 height: var(--ppt-slide-height);
172 transform-origin: top left;
173 border: none;
174 background: white;
175 display: none;
176 }
177 .ppt-preview-frame.active { display: block; }
178 .ppt-deck-switcher {
179 position: fixed;
180 right: 18px;
181 bottom: 82px;
182 width: min(320px, calc(100vw - 32px));
183 max-height: min(54vh, 520px);
184 overflow: auto;
185 display: none;
186 padding: 14px;
187 border-radius: 20px;
188 border: 1px solid rgba(148,163,184,0.24);
189 background: rgba(255,255,255,0.92);
190 box-shadow: 0 18px 48px rgba(15,23,42,0.16);
191 backdrop-filter: blur(14px);
192 }
193 .ppt-deck-switcher.open { display: block; }
194 .ppt-thumb-item {
195 width: 100%;
196 text-align: left;
197 border: 1px solid transparent;
198 border-radius: 14px;
199 background: rgba(248,250,252,0.9);
200 padding: 10px;
201 cursor: pointer;
202 }
203 .ppt-thumb-item + .ppt-thumb-item { margin-top: 8px; }
204 .ppt-thumb-item.active {
205 border-color: rgba(59,130,246,0.45);
206 background: rgba(219,234,254,0.7);
207 }
208 .ppt-thumb-index {
209 font-size: 11px;
210 color: #64748b;
211 }
212 .ppt-thumb-title {
213 margin-top: 4px;
214 font-size: 13px;
215 color: #0f172a;
216 font-weight: 600;
217 }
218 .ppt-controls {
219 position: fixed;
220 left: 50%;
221 bottom: 18px;
222 transform: translateX(-50%);
223 display: flex;
224 align-items: center;
225 gap: 8px;
226 padding: 8px;
227 border-radius: 14px;
228 border: 1px solid rgba(148,163,184,0.24);
229 background: rgba(255,255,255,0.92);
230 box-shadow: 0 10px 26px rgba(15,23,42,0.13);
231 backdrop-filter: blur(8px);
232 }
233 .ppt-control-btn {
234 border: 1px solid rgba(148,163,184,0.24);
235 background: rgba(248,250,252,0.9);
236 color: #334155;
237 border-radius: 10px;
238 padding: 8px 12px;
239 font-size: 13px;
240 font-weight: 600;
241 cursor: pointer;
242 }
243 .ppt-indicator {
244 min-width: 88px;
245 text-align: center;
246 color: #475569;
247 font-size: 13px;
248 font-weight: 600;
249 }
250 body.present { background: #000000; }
251 body.present .ppt-layout { padding: 0; background: #000000; }
252 body.present .ppt-stage { border-radius: 0; border: none; box-shadow: none; padding: 0; background: #000000; }
253 body.present .ppt-preview-viewport { border-radius: 0; background: #000000; }
254 body.present .ppt-controls, body.present .ppt-deck-switcher { display: none !important; }
255 body.embed .ppt-layout { padding: 0; }
256 body.embed .ppt-stage { border-radius: 0; border: none; box-shadow: none; padding: 0; }
257 body.embed .ppt-preview-viewport { border-radius: 0; }
258 body.embed .ppt-controls, body.embed .ppt-deck-switcher { display: none !important; }
259 .ppt-empty {
260 position: absolute;
261 inset: 0;
262 display: none;
263 align-items: center;
264 justify-content: center;
265 font-size: 16px;
266 color: #64748b;
267 background: linear-gradient(180deg, rgba(241,245,249,0.7) 0%, rgba(248,250,252,0.8) 100%);
268 }
269 body.empty .ppt-empty { display: flex; }
270 body.empty iframe { display: none; }
271 </style>
272 </head>
273 <body>
274 <div class="ppt-layout">
275 <section class="ppt-stage">
276 <div id="frameViewport" class="ppt-preview-viewport">
277 ${frameElements}
278 <div class="ppt-empty">暂无页面,请先生成 /&lt;pageId&gt;.html 内容</div>
279 </div>
280 </section>
281 </div>
282 <aside class="ppt-deck-switcher" id="deckSwitcher">
283 <div id="thumbs">${thumbButtons}</div>
284 </aside>
285 <div class="ppt-controls">
286 <button class="ppt-control-btn" id="prevBtn">上一页</button>
287 <div class="ppt-indicator" id="indicator"></div>
288 <button class="ppt-control-btn" id="nextBtn">下一页</button>
289 <button class="ppt-control-btn" id="tabsBtn">页面目录</button>
290 <button class="ppt-control-btn" id="presentBtn">演示模式(ESC退出)</button>
291 <button class="ppt-control-btn" id="fullscreenBtn">全屏</button>
292 </div>
293 <script type="application/json" id="pages-data">${pagesData}</script>
294 <script type="application/json" id="deck-metadata">${JSON.stringify({
295 slideSizeId: slideSize.id,
296 width: slideSize.width,
297 height: slideSize.height
298 })}</script>
299 ${buildIndexTransitionConfigScript(DEFAULT_INDEX_TRANSITION_CONFIG)}
300 <script src="./assets/anime.v4.js"></script>
301 <script src="./assets/index-runtime.js"></script>
302 </body>
303 </html>`
304 }
305
306 export const buildProjectIndexScaffold = (
307 title: string,
308 pages: Array<{ pageNumber: number; title: string; pageId: string }>,
309 slideSize: SlideSizePreset
310 ): string => {
311 return buildProjectIndexHtml(
312 title || 'OhMyPPT Preview',
313 pages.map((page) => ({
314 pageNumber: page.pageNumber,
315 pageId: page.pageId,
316 title: page.title,
317 htmlPath: `${page.pageId}.html`
318 })),
319 slideSize
320 )
321 }
322
323 export const extractPagesDataFromIndex = (
324 indexHtml: string
325 ): Array<{
326 id?: string
327 pageNumber: number
328 pageId: string
329 title: string
330 html: string
331 htmlPath?: string
332 }> => {
333 const $ = cheerio.load(indexHtml, { scriptingEnabled: false })
334 const pagesDataText = $('script#pages-data').text()
335 const metadata = (() => {
336 try {
337 const parsed = JSON.parse(pagesDataText)
338 return Array.isArray(parsed) ? parsed : []
339 } catch {
340 return []
341 }
342 })() as Array<{
343 pageNumber?: number
344 id?: string
345 pageId?: string
346 title?: string
347 htmlPath?: string
348 }>
349
350 if (metadata.length === 0) return []
351
352 return metadata.map((item, index) => {
353 const pageNumber = Number(item.pageNumber) || index + 1
354 const pageId = String(item.pageId || `page-${pageNumber}`)
355 const rawPath = typeof item.htmlPath === 'string' ? item.htmlPath.trim() : ''
356 return {
357 id: typeof item.id === 'string' ? item.id : undefined,
358 pageNumber,
359 pageId,
360 title: String(item.title || `Page ${pageNumber}`),
361 html: '',
362 htmlPath: rawPath.length > 0 ? rawPath : `${pageId}.html`
363 }
364 })
365 }
366
366 lines TYPESCRIPT