| 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">暂无页面,请先生成 /<pageId>.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 |