主页面与 PDF 实时预览
主页面(src/routes/+page.svelte)是 endfield-docmaker 的核心 UI 入口,它将动态表单、Typst 浏览器编译器与多级 PDF 预览回退策略组合成一个"填写即出稿"的单页体验。本文深入剖析该页面的状态模型、预览模式判定逻辑与持久化机制。
目的与范围(Purpose and Scope)
本页覆盖 主页面组件(+page.svelte) 及其直接编排的 PDF 实时预览链路,包括:
- 页面的顶层状态模型(生成中、可下载、可分享等标志位)
- 预览模式的三级判定:内联
<object type="pdf">→ Typst 向量 SVG → 栅格化页面图片 - 运行时"PDF 查看器失效"探测与跨会话记忆机制
- 模板选择、按模板隔离的表单值状态(
valuesMap)与localStorage持久化 - 主页面引用的各协作模块的接线方式(
typst.svelte、utils/render、utils/svg、stores/docs等)
以下主题有意留给兄弟页面,本文只做入口级指引:
- 模板定义与注册机制(
TEMPLATES、getTemplate、template.defaults()的实现)——见模板系统页 - Typst 编译器的加载与封装——见 Typst 编译封装页
- 文档库与云端同步的完整实现(
stores/docs、stores/cloud)——见文档库页 - 分享链接 / 二维码 / 云剪贴板(
utils/link、utils/paste)——见分享与持久化页 - 动态表单渲染(
DynamicForm.svelte)——见动态表单页
概述(Overview)
endfield-docmaker 是一个纯前端(无后端)的文档生成器:用户在左侧表单中填写模板参数,页面调用浏览器内 WASM 版 Typst 编译器实时产出 PDF,并在右侧预览。README 中将其列为项目核心特性:
- 在浏览器中实时生成 PDF 文件,无需后端服务
- PDF 实时预览、在新标签页中打开、下载
主页面承担的职责可以概括为"编排者":它本身不实现编译、渲染或存储,而是把这些能力装配成一条从表单输入到可预览、可下载、可分享文档的流水线。它最复杂的部分是预览模式自适应——因为该应用预期运行在多种环境中(普通浏览器、移动端 WebView、Bilibili App 内嵌的沙盒 iframe),不同环境对内联 PDF 的支持差异极大,页面实现了一套确定性的判定 + 运行时探测 + 失败记忆的组合策略。
关键概念与术语:
| 术语 | 含义 |
|---|---|
| 内联预览 | 用 <object type="pdf"> 让浏览器原生 PDF 插件直接渲染 |
| 向量预览 | 调用 typst.svg() 直接从编译器排版模型产出整篇文档的 SVG,不经 pdf.js 往返 |
| 栅格预览 | 用 renderPdfPages 将 PDF 每页位图化(图片兜底) |
| Toy 路径 | Bilibili App 内嵌运行时的 /toy/<slug>/… URL 前缀 |
| 断定失效 | <object> 的 fallback 内容被布局 → 说明 PDF 插件未成功实例化,记为 pdfObjectBroken |
架构(Architecture)
主页面处于编排层中心:表单侧(DynamicForm + 模板注册表)产生状态,编译层(typst.svelte)消费状态产出 PDF 字节,预览侧根据模式判定选择 <object>、SVG 或位图;文档库与分享模块从同一份状态快照读取/写入。
依赖装配(imports 即接线图)
页面顶部的 import 列表直接反映了依赖边界——这是阅读该组件的第一入口:
1import typst, {
2 loadingState,
3 packageLoadingState,
4 downloadProgress,
5 waitForTypst
6} from '$lib/typst.svelte';
7import DynamicForm from '$lib/components/DynamicForm.svelte';
8import CompileError from '$lib/components/CompileError.svelte';
9import DocLibrary from '$lib/components/DocLibrary.svelte';
10import ShareQrModal from '$lib/components/ShareQrModal.svelte';
11import PageImageViewer from '$lib/components/PageImageViewer.svelte';
12import { TEMPLATES, getTemplate } from '$lib/templates';
13import {
14 getAllDocs, getDoc, putDoc,
15 deleteDoc as removeDocFromLibrary,
16 stripThumbnail, type LibraryDoc
17} from '$lib/stores/docs';
18import { pushDocToCloud, pullDocsFromCloud, deleteDocFromCloud } from '$lib/stores/cloud';
19import { initToy, supportsToy, toyEnv } from '$lib/toy.svelte';
20import {
21 decodeDocState, buildToyPath, buildShareUrl, resolveToyPath, type DocState
22} from '$lib/utils/link';
23import { uploadDocState, fetchPaste, externalStorageAvailable } from '$lib/utils/paste';
24import { renderPdfPages } from '$lib/utils/render';
25import { scopeTypstSvg } from '$lib/utils/svg';
26import { bytesToBase64Url } from '$lib/utils/base64';Source: +page.svelte
几点设计意图值得注意:
- 编译进度与文档生成解耦:从
typst.svelte只引入加载态信号(loadingState、packageLoadingState、downloadProgress、waitForTypst),说明编译器 WASM/字体包下载是首屏体验的一部分,由独立模块管理,主页面只消费。 - 本地/云端双通道存储:
stores/docs(本地)与stores/cloud(远端)同时被引入,页面在保存时通过快照比较决定是否需要入库。 bytesToBase64Url:用于把 PDF 字节编码进分享 URL 或数据 URI,是"无后端分享"链路的基础。
核心机制一:PDF 预览模式的三级判定
这是该页面最精巧的部分。pdfViewerEnabled 等 API 在移动 WebView 中会"说谎",页面因此实现了一条从静态判定到运行时探测、再到失败记忆的完整链条。
1. 内联 PDF 支持的静态判定 pdfInlineSupported
1/**
2 * Whether the environment can display PDFs inline (`<object type="pdf">`).
3 * Mobile webviews (e.g. inside the Bilibili app) typically cannot – in that
4 * case the preview falls back to rasterized page images. `?nopdf` forces
5 * the fallback locally for testing.
6 */
7const pdfInlineSupported = $derived.by(() => {
8 if (typeof navigator === 'undefined') return true;
9 if (new URLSearchParams(window.location.search).has('nopdf')) return false;
10 if ('pdfViewerEnabled' in navigator) return navigator.pdfViewerEnabled === true;
11 return true;
12});Source: +page.svelte
规则按顺序短路:
- SSR 阶段(
navigator未定义)默认乐观返回true,避免服务端/客户端首帧不一致; ?nopdf查询参数强制进入图片回退(本地测试图片路径用);- 存在
navigator.pdfViewerEnabled时以它为准; - 老浏览器没有该 API 时默认
true(信任<object>的 fallback 探测兜底)。
2. 运行时"断定失效"探测
静态判定不够——源码注释明确指出:"pdfViewerEnabled 可能在查看器仍实例化失败时报告 true(例如 Bilibili 的沙盒预览 iframe)。当 <object> 的 fallback 内容可见时,我们一次性检测到它,然后在整个会话(以及重载后)记住该结论,而不是在每次生成时重新探测这个坏掉的 object。"
1$effect(() => {
2 // A learned failure sticks, and a failed raster must not loop forever –
3 // `generatePDF` resets `previewFailed` so a fresh doc retries.
4 if (!pdf || previewFailed) return;
5
6 if (rasterRequired) {
7 if (!previewSvg && !isRenderingPreview) void refreshPreviewSvg();
8 return;
9 }
10
11 const marker = () => document.getElementById('pdf-object-fallback');
12 const PROBE_DEADLINE_MS = 8000;
13 let strikes = 0;
14 const started = Date.now();
15 const fail = () => {
16 clearInterval(interval);
17 if (pdfObjectBroken) return;
18 console.warn('Inline PDF viewer unavailable – switching to image preview.');
19 pdfObjectBroken = true;
20 try {
21 localStorage.setItem(PDF_OBJECT_BROKEN_KEY, '1');
22 } catch {
23 /* private mode etc. – the in-session verdict still applies */
24 }
25 };
26 const interval = setInterval(() => {
27 if (Date.now() - started > PROBE_DEADLINE_MS) {
28 clearInterval(interval);
29 return;
30 }
31 if (marker() && (marker()!.offsetHeight > 0 || marker()!.getClientRects().length > 0)) {
32 if (++strikes >= 2) fail();
33 } else {
34 strikes = 0;
35 }
36 }, 400);
37 return () => {
38 clearInterval(interval);
39 };
40});Source: +page.svelte
实现细节与设计意图:
- 探测原理:成功加载的 PDF
<object>的子元素永远不会被布局;若 id 为pdf-object-fallback的元素offsetHeight > 0或getClientRects().length > 0,说明 fallback 内容被渲染,即插件未实例化。 - 两次命中(strikes ≥ 2)防抖:加载瞬间的瞬时布局可能造成一次假阳性,连续两次命中才判死。
- 8 秒探测死线:沙盒环境可能既不加载 PDF 也不布局 fallback,死线防止无限轮询;死线内无失败信号即视为查看器健康。
- 探测贯穿对象生命周期:清理函数里
clearInterval(interval)保证$effect依赖变化时定时器被回收,且注释强调"失败可能较晚浮现,需要持续观察"。 - 记忆跨会话持久化:
PDF_OBJECT_BROKEN_KEY = 'pdf-object-broken'写入localStorage,重载后不再重复探测(见下文读取代码)。
3. 读回历史结论(防御式)
1// Read the persisted verdict defensively – sandboxed iframes may throw
2// `SecurityError` on `localStorage` access, which must not abort the page.
3try {
4 pdfObjectBroken =
5 typeof localStorage !== 'undefined' && localStorage.getItem(PDF_OBJECT_BROKEN_KEY) === '1';
6} catch {
7 /* storage unavailable – the runtime probe below decides instead */
8}Source: +page.svelte
沙盒 iframe 访问 localStorage 会抛 SecurityError,必须吞掉——页面在无痕模式/受限环境下依然要可用。
4. 最终决策 rasterRequired
1/**
2 * Whether the preview must fall back to rasterized page images.
3 *
4 * Deterministic on Bilibili Toy: the sandboxed iframe reports
5 * `pdfViewerEnabled` even though its PDF plugin never instantiates, so we
6 * never trust inline PDF there. `?pdf` forces the inline `<object>`
7 * (debugging) and `?nopdf` forces raster (testing), regardless of env.
8 */
9const rasterRequired = $derived.by(() => {
10 if (typeof window === 'undefined') return false;
11 const params = new URLSearchParams(window.location.search);
12 if (params.has('pdf')) return false;
13 if (!pdfInlineSupported) return true;
14 if (pdfObjectBroken) return true;
15 return isOnToyPath();
16});Source: +page.svelte
判定优先级(自上而下短路):
| 顺序 | 条件 | 结果 | 理由 |
|---|---|---|---|
| 1 | ?pdf 查询参数 | 强制内联 | 调试用途,覆盖一切环境判定 |
| 2 | pdfInlineSupported === false | 栅格回退 | 静态判定已否定(含 ?nopdf) |
| 3 | pdfObjectBroken === true | 栅格回退 | 学习到的失效结论(跨会话) |
| 4 | isOnToyPath() | 栅格回退 | Bilibili Toy 沙盒从不信任内联 PDF |
isOnToyPath 用正则 ^\/toy\/[^/]+(?:\/|$) 匹配路径名,识别 Bilibili App 内嵌运行时;源码注释称此路径上的判定是确定性的,因为其沙盒 iframe 会谎报 pdfViewerEnabled。
预览模式决策流程图
核心机制二:向量 SVG 预览(为何绕开 pdf.js)
页面为栅格回退分支准备了独立的向量预览状态:
1// Vector preview of the whole document (all pages, stacked vertically).
2// `typst.svg()` renders straight from the compiler's layout model – no
3// pdf.js round-trip, so it cannot hit the sandbox's per-page raster hang,
4// and it stays crisp at any zoom level.
5let previewSvg = $state<string | undefined>(undefined);
6let isRenderingPreview = $state(false);
7let previewFailed = $state(false);
8
9/** Raster scale (px per pt) for library thumbnails – ~446 px wide A4. */
10const THUMBNAIL_PPP = 0.75;Source: +page.svelte
设计意图写在注释里,值得逐条拆解:
- 直达排版模型:
typst.svg()直接从 Typst 编译器的布局模型渲染 SVG,绕开 pdf.js 的字节往返。 - 规避沙盒栅格挂起:沙盒 iframe 内 pdf.js 的按页栅格化可能挂死,SVG 路径天然不会触发。
- 任意缩放不失真:矢量输出在任何缩放级别都保持清晰,这对文档预览体验至关重要。
THUMBNAIL_PPP = 0.75:文档库缩略图专用的栅格比例(px/pt),对应 A4 约 446px 宽——缩略图与整页预览使用不同的渲染预算,缩略图刻意低清以省资源。
配套的 PageImageViewer.svelte 与 utils/render.renderPdfPages / utils/svg.scopeTypstSvg 共同构成图片查看器分支:renderPdfPages 产出逐页位图数组,scopeTypstSvg 负责将 Typst 原始 SVG 包裹上作用域化的样式(避免页面全局 CSS 与 SVG 内联样式互相污染)。
核心机制三:模板选择与按模板隔离的表单状态
主页面用一个双层键控的状态结构同时支持"多模板"与"每模板一份进行中文档":
1// Template selection
2const STORAGE_META_KEY = 'endfield-doc:meta';
3let templateId = $state(TEMPLATES[0].id);
4let template = $derived(getTemplate(templateId));
5
6/** Library entry ids keyed by template – one working document per template. */
7let docIds = $state<Record<string, string>>({});
8const currentDocId = () => docIds[templateId];
9
10// Form values (keyed by template id)
11let valuesMap = $state<Record<string, Record<string, unknown>>>({});Source: +page.svelte
valuesMap: Record<templateId, Record<fieldKey, unknown>>—— 表单值以模板 id 为第一层键,切换模板时无需重新加载,天然保留每个模板各自的填写进度。docIds: Record<templateId, docId>—— 每个模板对应文档库中的一条工作文档记录,"一个模板一份进行中文档"。- 模板元信息本身存于
endfield-doc:meta,与表单值分开存储,读写互不阻塞。
表单值的初始化是惰性的,通过 $effect 保证只在首次访问某模板时落默认值:
1// Ensure values are initialized for the current template
2$effect(() => {
3 if (!valuesMap[templateId]) {
4 valuesMap[templateId] = template.defaults();
5 }
6});Source: +page.svelte
这种"缺省即建"的模式避免了在顶层一次性为所有模板预生成默认值——模板可能很多,且大多数用户只会用到一两个。
核心机制四:localStorage 持久化与脏标记
1// Storage
2const storageKey = (tid: string) => `endfield-doc:${tid}`;
3
4const saveToStorage = () => {
5 try {
6 localStorage.setItem(STORAGE_META_KEY, JSON.stringify({ templateId, docIds }));
7 const tpl = getTemplate(templateId);
8 localStorage.setItem(
9 storageKey(templateId),
10 JSON.stringify({ version: tpl.storageVersion, values: valuesMap[templateId] })
11 );
12 } catch (e) {
13 console.error('Error saving to storage:', e);
14 }
15};Source: +page.svelte
设计要点:
- 键命名约定:
endfield-doc:meta(全局元信息)与endfield-doc:<templateId>(每模板表单值)分离,清理单模板数据不影响其它模板。 - 版本化存储:每条记录带
version: tpl.storageVersion,为将来模板字段演进预留迁移通道。 - 写失败容错:
try/catch包裹整个写入(配额满、隐私模式),失败只打console.error,绝不打断 UI 流程——持久化是增强项而非关键路径。 - 脏标记防抖:页面另维护
lastSavedSnapshot(上次自动保存时{ templateId, values }的快照字符串)与isDirty,用于判断"当前文档是否有值得自动保存的未保存编辑",避免高频无意义写入。
配合编译错误组件与自动保存,形成了"编辑 → 编译 → 预览/报错 → 脏则入库"的闭环。
核心流程(Core Flow)
端到端时序:从表单输入到可预览文档
页面生命周期状态机
使用示例(Usage Examples)
示例 1:控制生成与下载按钮的可用性标志
页面用一组布尔状态驱动工具栏(下载、新标签页打开、分享、保存图片)的可用性,canSaveToAlbum 专门服务 Bilibili App 内保存到相册的场景:
1let isReady = $state(false);
2let isGenerating = $state(false);
3let canDownload = $state(false);
4let canShare = $state(false);
5let isSavingImage = $state(false);
6/** True when the Bilibili App can save images to the album. */
7let canSaveToAlbum = $state(false);
8
9/**
10 * Whether saving images is possible in the current environment:
11 * in-app via the album, or in-browser via `<a download>`.
12 */
13const saveImageSupported = $derived(canSaveToAlbum || canDownload);Source: +page.svelte
saveImageSupported 是一个能力或运算:任一保存通道(App 相册、浏览器 <a download>)可用即启用保存图片入口,保证最差环境仍有出路。
示例 2:识别 Bilibili Toy 运行环境
/** True when running under a Bilibili Toy path (`/toy/<slug>/…`). */
const isOnToyPath = () =>
typeof window !== 'undefined' && /^\/toy\/[^/]+(?:\/|$)/.test(window.location.pathname);Source: +page.svelte
配合 initToy / supportsToy / toyEnv(来自 $lib/toy.svelte)与 buildToyPath / resolveToyPath(来自 utils/link),构成对 Bilibili 内嵌分发的完整适配:路径前缀判断是零成本、确定性的,因此放在 rasterRequired 的最后一环作为兜底。
示例 3:工具函数的选取
import { pick, randomId, triggerDownload } from '$lib/utils';Source: +page.svelte
triggerDownload:以编程方式触发浏览器下载(配合bytesToBase64Url可生成数据 URI 下载链接)。randomId:为新建文档库条目生成 id(配合docIds结构)。pick:从对象中挑选子集(典型用途是从表单值中提取入库/分享所需的字段)。
示例 4:编译期加载进度信号
1import typst, {
2 loadingState, // WASM 编译器加载状态
3 packageLoadingState, // Typst 包加载状态
4 downloadProgress, // 下载进度
5 waitForTypst // 等待编译器可用的 Promise
6} from '$lib/typst.svelte';Source: +page.svelte
页面把这些信号对接到 Spinner / 加载 UI(来自 $lib/components/ui/spinner),首屏在编译器下载完成前展示进度而非空白。相关构建配置见 vite.config.ts 中的注释——pdf.js 的 worker 是 ESM-only,默认 IIFE 打包会静默失败,因此 worker: { format: 'es' } 被显式设置:
// pdf.js's worker is ESM-only; the default IIFE bundle breaks it silently.
worker: { format: 'es' },Source: vite.config.ts
配置项(Configuration Options)
主页面本身没有独立的配置对象,其可配置面通过 URL 查询参数、存储键与常量暴露:
| 配置项 | 类型 | 默认值 | 说明 |
|---|---|---|---|
?pdf | URL 查询参数 | 不存在 | 强制内联 <object> PDF 预览(调试用),覆盖一切环境判定 |
?nopdf | URL 查询参数 | 不存在 | 强制栅格图片回退(测试图片路径用) |
endfield-doc:meta | localStorage 键 | — | 存 { templateId, docIds } 全局元信息 |
endfield-doc:<templateId> | localStorage 键 | — | 存 { version, values } 每模板表单值 |
pdf-object-broken | localStorage 键 | '0'/未设置 | PDF 内联查看器失效的跨会话结论,'1' 表示失效 |
THUMBNAIL_PPP | 常量 number | 0.75 | 缩略图栅格比例(px/pt),A4 约 446px 宽 |
PROBE_DEADLINE_MS | 常量 number | 8000 | fallback 探测死线(毫秒) |
| 探测轮询间隔 | 常量 number | 400 | 每次探测间隔(毫秒) |
| 探测命中阈值 | 常量 number | 2 | 连续命中次数达到即判死 |
pdfjs-dist | npm 依赖 | ^4.10.38 | 栅格回退所用的 PDF 渲染库 |
API 参考(组件内部关键状态与方法)
本节列出主页面内部值得关注的响应式状态与函数(非公开导出 API,而是该组件的"接缝面",供维护者定位)。
pdfInlineSupported: boolean($derived)
- 职责:判定当前环境能否以内联
<object type="pdf">展示 PDF。 - 求值顺序:SSR →
?nopdf→navigator.pdfViewerEnabled→ 默认true。 - 注意:返回
true不代表一定能用——还需通过pdfObjectBroken运行时探测。
rasterRequired: boolean($derived)
- 职责:最终决定预览是否必须走栅格图片回退。
- 短路顺序:
?pdf→pdfInlineSupported→pdfObjectBroken→isOnToyPath()。
pdfObjectBroken: boolean($state)
- 职责:运行时学习到的"内联 PDF 已失效"结论。
- 写入时机:fallback 探测连续两次命中;写入后同步持久化到
localStorage['pdf-object-broken'] = '1'。 - 读取时机:组件初始化时防御式读回,
localStorage抛异常则交给运行时探测决定。
saveToStorage(): void
- 参数:无。
- 行为:先写
endfield-doc:meta({ templateId, docIds }),再写endfield-doc:<templateId>({ version: template.storageVersion, values })。 - 异常:内部捕获所有写入错误,仅
console.error,不上抛。
setValues(v: Record<string, unknown>): void / getValues(): Record<string, unknown>
- 职责:以当前
templateId为键读写valuesMap,供DynamicForm双向绑定与编译提交复用。 - 缺省行为:
getValues在值未初始化时返回{}(初始化由惰性$effect负责)。
currentDocId(): string | undefined
- 职责:返回当前模板对应的文档库条目 id(
docIds[templateId])。
失败模式、边界情况与并发(Failure Modes & Concurrency)
| 场景 | 触发条件 | 处理方式 | 源码证据 |
|---|---|---|---|
| 浏览器不支持内联 PDF | navigator.pdfViewerEnabled === false | 静态判定直接进入栅格回退 | pdfInlineSupported |
| 沙盒 iframe 谎报支持 | <object> fallback 被布局 | 连续 2 次探测命中后记死并持久化 | 探测 $effect |
| 探测永不收敛 | 沙盒既不加载也不布局 fallback | 8 秒死线后停止轮询,视为健康 | PROBE_DEADLINE_MS |
| localStorage 访问被拒 | 隐私模式 / 沙盒 SecurityError | 捕获并降级为会话内记忆;保存失败仅打日志 | 读取/写入的 try/catch |
| 栅格渲染失败 | 沙盒栅格化挂起 | previewFailed 标志阻止无限重试;generatePDF 重置它以允许新文档重试 | $effect 顶部守卫注释 |
| 重复探测/重复渲染 | $effect 依赖翻转重跑 | if (!previewSvg && !isRenderingPreview) 守卫防止重启渲染 | rasterRequired 分支 |
| 定时器泄漏 | $effect 依赖变化 | 返回清理函数 clearInterval | $effect return |
| 模板切换 | 用户换模板 | 表单值按 templateId 隔离,无需重新加载 | valuesMap 双层键控 |
| 编译失败 | Typst 语法/参数错误 | 渲染 CompileError 组件展示错误,修正后重试 | 状态机 CompileError 态 |
| 存储配额满 | localStorage 写入抛异常 | 捕获 + console.error,不打断 UI | saveToStorage |
并发要点:
- 所有预览状态都是 Svelte 5 runes(
$state/$derived/$effect),由响应式系统调度,无手写订阅——但$effect内部的setInterval是命令式副作用,必须自清理(已实现)。 - 自动保存以快照字符串比较去重(
lastSavedSnapshot+isDirty),即便高频编辑也只在与上次快照不同才写库,天然避免了写风暴。
性能与运维要点(Performance & Operational Notes)
- 首屏关键路径是 Typst WASM 与字体包下载(
loadingState/downloadProgress),主页面把这部分交给typst.svelte管理,仅消费信号渲染进度——字体资产位于src/lib/assets/fonts/(含FZXIAOBIAOSONG-B05.TTF、NotoSansCJKsc-Regular.ttf、JetBrainsMono-VariableFont_wght.ttf等多字体),是包体主要构成。 - SVG 预览优先于 pdf.js 栅格化:整篇文档一次向量渲染(
typst.svg())避免逐页位图化的开销与沙盒挂起风险,同时缩放零成本。 - 缩略图与整页预览分离预算:
THUMBNAIL_PPP = 0.75让文档库缩略图保持约 446px 宽的 A4 低保真,避免为列表视图付出整页渲染成本。 - 探测成本可控:仅在非栅格分支运行,400ms 轮询、8 秒死线、一次判死后不再探测(跨会话记忆),最坏情况约 20 次轮询。
- worker 打包格式:pdf.js 的 worker 必须以 ESM 打包,否则静默失败——这是运维/构建层面最容易踩的坑,已在
vite.config.ts显式固化。
扩展点(Extension Points)
- 新增模板:模板系统通过
TEMPLATES注册,主页面零改动即可获得新表单与预览(每模板自动获得隔离的valuesMap槽位与存储键)。详见模板系统页。 - 新增预览模式:预览判定集中在
rasterRequired/pdfInlineSupported两个$derived与一个探测$effect中,新增模式(如分页 SVG)只需扩展该判定链与渲染分支。 - 新增分享通道:
utils/link(buildShareUrl)与utils/paste(uploadDocState)已把编码/上传抽象出来,主页面只负责调用与弹窗编排。 - 环境适配:新的内嵌宿主(类似 Bilibili Toy)只需:① 路径识别函数(参照
isOnToyPath);② 探测结论持久化键(参照PDF_OBJECT_BROKEN_KEY)。
相关链接(Related Links)
- 源文件:+page.svelte
- 构建配置:vite.config.ts
- 项目说明:README.md
- 依赖清单:package.json
- 兄弟主题(模板系统、Typst 编译封装、文档库与云同步、分享链接与持久化、动态表单)见各自目录页