hyperbook 0.80.0 → 0.81.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,43 @@
1
1
  hyperbook.typst = (function () {
2
- // Debounce utility function
2
+ 'use strict';
3
+
4
+ // ============================================================================
5
+ // CONSTANTS AND CONFIGURATION
6
+ // ============================================================================
7
+
8
+ const CONFIG = {
9
+ TYPST_COMPILER_URL: "https://cdn.jsdelivr.net/npm/@myriaddreamin/typst-ts-web-compiler/pkg/typst_ts_web_compiler_bg.wasm",
10
+ TYPST_RENDERER_URL: "https://cdn.jsdelivr.net/npm/@myriaddreamin/typst-ts-renderer/pkg/typst_ts_renderer_bg.wasm",
11
+ TYPST_BUNDLE_URL: "https://cdn.jsdelivr.net/npm/@myriaddreamin/typst.ts/dist/esm/contrib/all-in-one-lite.bundle.js",
12
+ DEBOUNCE_DELAY: 500,
13
+ TYPST_CHECK_INTERVAL: 50,
14
+ CONTAINER_PADDING: 20,
15
+ };
16
+
17
+ const REGEX_PATTERNS = {
18
+ READ: /read\s*\(\s*(['"])([^'"]+)\1[^)]*\)/gi,
19
+ CSV: /csv\s*\(\s*(['"])([^'"]+)\1[^)]*\)/gi,
20
+ JSON: /json\s*\(\s*(['"])([^'"]+)\1[^)]*\)/gi,
21
+ YAML: /yaml\s*\(\s*(['"])([^'"]+)\1[^)]*\)/gi,
22
+ XML: /xml\s*\(\s*(['"])([^'"]+)\1[^)]*\)/gi,
23
+ IMAGE: /image\s*\(\s*(['"])([^'"]+)\1[^)]*\)/gi,
24
+ ABSOLUTE_URL: /^(https?:|data:|blob:)/i,
25
+ ERROR_MESSAGE: /message:\s*"([^"]+)"/,
26
+ };
27
+
28
+ // Text file patterns that need UTF-8 encoding
29
+ const TEXT_PATTERNS = ['READ', 'CSV', 'JSON', 'YAML', 'XML'];
30
+
31
+ // ============================================================================
32
+ // UTILITY FUNCTIONS
33
+ // ============================================================================
34
+
35
+ /**
36
+ * Debounce utility function
37
+ * @param {Function} func - Function to debounce
38
+ * @param {number} wait - Wait time in milliseconds
39
+ * @returns {Function} Debounced function
40
+ */
3
41
  const debounce = (func, wait) => {
4
42
  let timeout;
5
43
  return function executedFunction(...args) {
@@ -12,886 +50,904 @@ hyperbook.typst = (function () {
12
50
  };
13
51
  };
14
52
 
15
- // Register code-input template for typst syntax highlighting
16
- window.codeInput?.registerTemplate(
17
- "typst-highlighted",
18
- codeInput.templates.prism(window.Prism, [
19
- new codeInput.plugins.AutoCloseBrackets(),
20
- new codeInput.plugins.Indent(true, 2),
21
- ]),
22
- );
23
-
24
- const elems = document.getElementsByClassName("directive-typst");
25
-
26
- // Typst WASM module URLs
27
- const TYPST_COMPILER_URL =
28
- "https://cdn.jsdelivr.net/npm/@myriaddreamin/typst-ts-web-compiler/pkg/typst_ts_web_compiler_bg.wasm";
29
- const TYPST_RENDERER_URL =
30
- "https://cdn.jsdelivr.net/npm/@myriaddreamin/typst-ts-renderer/pkg/typst_ts_renderer_bg.wasm";
31
-
32
- // Load typst all-in-one bundle
33
- let typstLoaded = false;
34
- let typstLoadPromise = null;
35
-
36
- // Rendering queue to ensure only one render at a time
37
- let renderQueue = Promise.resolve();
38
-
39
- const queueRender = (renderFn) => {
40
- renderQueue = renderQueue.then(renderFn).catch((error) => {
41
- console.error("Queued render error:", error);
42
- });
43
- return renderQueue;
53
+ /**
54
+ * Normalize path to ensure it starts with /
55
+ * @param {string} path - Path to normalize
56
+ * @returns {string} Normalized path
57
+ */
58
+ const normalizePath = (path) => {
59
+ if (!path) return '/';
60
+ return path.startsWith('/') ? path : `/${path}`;
44
61
  };
45
62
 
46
- const loadTypst = () => {
47
- if (typstLoaded) {
48
- return Promise.resolve();
49
- }
50
- if (typstLoadPromise) {
51
- return typstLoadPromise;
52
- }
53
-
54
- typstLoadPromise = new Promise((resolve, reject) => {
55
- const script = document.createElement("script");
56
- script.src =
57
- "https://cdn.jsdelivr.net/npm/@myriaddreamin/typst.ts/dist/esm/contrib/all-in-one-lite.bundle.js";
58
- script.type = "module";
59
- script.id = "typst-loader";
60
- script.onload = () => {
61
- // Wait a bit for the module to initialize
62
- const checkTypst = () => {
63
- if (typeof $typst !== "undefined") {
64
- // Initialize the Typst compiler and renderer
65
- $typst.setCompilerInitOptions({
66
- getModule: () => TYPST_COMPILER_URL,
67
- });
68
- $typst.setRendererInitOptions({
69
- getModule: () => TYPST_RENDERER_URL,
70
- });
71
- typstLoaded = true;
72
- resolve();
73
- } else {
74
- setTimeout(checkTypst, 50);
75
- }
76
- };
77
- checkTypst();
78
- };
79
- script.onerror = reject;
80
- document.head.appendChild(script);
81
- });
63
+ /**
64
+ * Construct full URL from base path and relative path
65
+ * @param {string} path - Relative or absolute path
66
+ * @param {string} basePath - Base path for absolute paths
67
+ * @param {string} pagePath - Page path for relative paths
68
+ * @returns {string} Full URL
69
+ */
70
+ const constructUrl = (path, basePath, pagePath) => {
71
+ if (path.startsWith('/')) {
72
+ return basePath ? `${basePath}${path}`.replace(/\/+/g, '/') : path;
73
+ }
74
+ return pagePath ? `${pagePath}/${path}`.replace(/\/+/g, '/') : path;
75
+ };
82
76
 
83
- return typstLoadPromise;
77
+ /**
78
+ * Get localized string with fallback
79
+ * @param {string} key - Translation key
80
+ * @param {string} fallback - Fallback text
81
+ * @returns {string} Localized or fallback text
82
+ */
83
+ const i18nGet = (key, fallback = '') => {
84
+ return window.i18n?.get(key) || fallback;
84
85
  };
85
86
 
86
- // Asset cache for server-loaded images
87
- const assetCache = new Map(); // filepath -> Uint8Array
87
+ // ============================================================================
88
+ // INITIALIZATION
89
+ // ============================================================================
90
+
91
+ /**
92
+ * Initialize code-input template for Typst syntax highlighting
93
+ */
94
+ const initializeCodeInput = () => {
95
+ if (!window.codeInput) return;
96
+
97
+ window.codeInput.registerTemplate(
98
+ "typst-highlighted",
99
+ window.codeInput.templates.prism(window.Prism, [
100
+ new window.codeInput.plugins.AutoCloseBrackets(),
101
+ new window.codeInput.plugins.Indent(true, 2),
102
+ ])
103
+ );
104
+ };
88
105
 
89
- const extractRelFilePaths = (src) => {
90
- const paths = new Set();
106
+ // ============================================================================
107
+ // TYPST LOADER
108
+ // ============================================================================
91
109
 
92
- // Pattern for read() function
93
- // Matches: read("path"), read('path'), read("path", encoding: ...)
94
- const readRe = /#read\s*\(\s*(['"])([^'"]+)\1/gi;
110
+ class TypstLoader {
111
+ constructor() {
112
+ this.loaded = false;
113
+ this.loadPromise = null;
114
+ this.loadedFonts = new Set();
115
+ }
95
116
 
96
- // Pattern for csv() function
97
- // Matches: csv("path"), csv('path'), csv("path", delimiter: ...)
98
- const csvRe = /csv\s*\(\s*(['"])([^'"]+)\1/gi;
117
+ /**
118
+ * Load Typst compiler and renderer
119
+ * @param {Array} fontFiles - Array of font file objects
120
+ * @returns {Promise<void>}
121
+ */
122
+ async load({ fontFiles = [] } = {}) {
123
+ if (this.loaded) {
124
+ return Promise.resolve();
125
+ }
99
126
 
100
- // Pattern for json() function
101
- // Matches: json("path"), json('path')
102
- const jsonRe = /json\s*\(\s*(['"])([^'"]+)\1/gi;
127
+ if (this.loadPromise) {
128
+ return this.loadPromise;
129
+ }
103
130
 
104
- // Pattern for yaml() function
105
- const yamlRe = /yaml\s*\(\s*(['"])([^'"]+)\1/gi;
106
- //
107
- // Pattern for xml() function
108
- const xmlRe = /xml\s*\(\s*(['"])([^'"]+)\1/gi;
131
+ this.loadPromise = new Promise((resolve, reject) => {
132
+ const script = document.createElement('script');
133
+ script.src = CONFIG.TYPST_BUNDLE_URL;
134
+ script.type = 'module';
135
+ script.id = 'typst-loader';
109
136
 
110
- const imageRe = /image\s*\(\s*(['"])([^'"]+)\1/gi;
137
+ script.onload = () => {
138
+ this.waitForTypst(fontFiles)
139
+ .then(resolve)
140
+ .catch(reject);
141
+ };
111
142
 
112
- // Process all patterns
113
- const patterns = [imageRe, readRe, csvRe, jsonRe, yamlRe, xmlRe];
143
+ script.onerror = () => {
144
+ reject(new Error('Failed to load Typst bundle'));
145
+ };
114
146
 
115
- for (const re of patterns) {
116
- let m;
117
- while ((m = re.exec(src))) {
118
- const p = m[2];
119
- // Skip absolute URLs, data URLs, blob URLs, and paths starting with "/"
120
- if (/^(https?:|data:|blob:|\/)/i.test(p)) continue;
121
- paths.add(p);
122
- }
123
- }
147
+ document.head.appendChild(script);
148
+ });
124
149
 
125
- return [...paths];
126
- };
150
+ return this.loadPromise;
151
+ }
127
152
 
128
- // Fetch assets from server using base path
129
- const fetchAssets = async (paths, basePath) => {
130
- const misses = paths.filter((p) => !assetCache.has(p));
131
- await Promise.all(
132
- misses.map(async (p) => {
133
- try {
134
- // Construct URL using base path
135
- const url = basePath ? `${basePath}/${p}`.replace(/\/+/g, "/") : p;
136
- const res = await fetch(url);
137
- if (!res.ok) {
138
- console.warn(
139
- `Asset not found: ${p} at ${url} (HTTP ${res.status})`,
140
- );
141
- assetCache.set(p, null); // Mark as failed
142
- return;
153
+ /**
154
+ * Wait for Typst module to initialize
155
+ * @param {Array} fontFiles - Array of font file objects
156
+ * @returns {Promise<void>}
157
+ */
158
+ async waitForTypst(fontFiles) {
159
+ return new Promise((resolve, reject) => {
160
+ const checkTypst = async () => {
161
+ if (typeof window.$typst !== 'undefined') {
162
+ try {
163
+ await this.initializeTypst(fontFiles);
164
+ this.loaded = true;
165
+ resolve();
166
+ } catch (error) {
167
+ reject(error);
168
+ }
169
+ } else {
170
+ setTimeout(checkTypst, CONFIG.TYPST_CHECK_INTERVAL);
143
171
  }
144
- const buf = await res.arrayBuffer();
145
- assetCache.set(p, new Uint8Array(buf));
146
- } catch (error) {
147
- console.warn(`Error loading asset ${p}:`, error);
148
- assetCache.set(p, null); // Mark as failed
149
- }
150
- }),
151
- );
152
- };
172
+ };
173
+ checkTypst();
174
+ });
175
+ }
153
176
 
154
- // Build typst preamble with inlined assets
155
- const buildAssetsPreamble = () => {
156
- if (assetCache.size === 0) return "";
157
- const entries = [...assetCache.entries()]
158
- .filter(([name, u8]) => u8 !== null) // Skip failed images
159
- .map(([name, u8]) => {
160
- const nums = Array.from(u8).join(",");
161
- return ` "${name}": bytes((${nums}))`;
162
- })
163
- .join(",\n");
164
- if (!entries) return "";
165
- return `#let __assets = (\n${entries}\n)\n\n`;
166
- };
177
+ /**
178
+ * Initialize Typst compiler and renderer with fonts
179
+ * @param {Array} fontFiles - Array of font file objects
180
+ * @returns {Promise<void>}
181
+ */
182
+ async initializeTypst(fontFiles) {
183
+ const fonts = window.TypstCompileModule.loadFonts(
184
+ fontFiles.map((f) => f.url)
185
+ );
167
186
 
168
- // Rewrite file calls (image, read, csv, json) to use inlined assets
169
- const rewriteAssetCalls = (src) => {
170
- if (assetCache.size === 0) return src;
171
-
172
- // Rewrite image() calls
173
- src = src.replace(/image\s*\(\s*(['"])([^'"]+)\1/g, (m, q, fname) => {
174
- if (assetCache.has(fname)) {
175
- const asset = assetCache.get(fname);
176
- if (asset === null) {
177
- return `[File not found: _${fname}_]`;
178
- }
179
- return `image(__assets.at("${fname}")`;
180
- }
181
- return m;
182
- });
183
-
184
- // Rewrite read() calls
185
- src = src.replace(/#read\s*\(\s*(['"])([^'"]+)\1/g, (m, q, fname) => {
186
- if (assetCache.has(fname)) {
187
- const asset = assetCache.get(fname);
188
- if (asset === null) {
189
- return `[File not found: _${fname}_]`;
190
- }
191
- return `#read(__assets.at("${fname}")`;
192
- }
193
- return m;
194
- });
195
-
196
- // Rewrite csv() calls
197
- src = src.replace(/csv\s*\(\s*(['"])([^'"]+)\1/g, (m, q, fname) => {
198
- if (assetCache.has(fname)) {
199
- const asset = assetCache.get(fname);
200
- if (asset === null) {
201
- return `[File not found: _${fname}_]`;
202
- }
203
- return `csv(__assets.at("${fname}")`;
204
- }
205
- return m;
206
- });
207
-
208
- // Rewrite json() calls
209
- src = src.replace(/json\s*\(\s*(['"])([^'"]+)\1/g, (m, q, fname) => {
210
- if (assetCache.has(fname)) {
211
- const asset = assetCache.get(fname);
212
- if (asset === null) {
213
- return `[File not found: _${fname}_]`;
214
- }
215
- return `json(__assets.at("${fname}")`;
216
- }
217
- return m;
218
- });
219
-
220
- return src;
221
- };
187
+ window.$typst.setCompilerInitOptions({
188
+ beforeBuild: [fonts],
189
+ getModule: () => CONFIG.TYPST_COMPILER_URL,
190
+ });
222
191
 
223
- // Prepare typst source with server-loaded assets
224
- const prepareTypstSourceWithAssets = async (src, basePath) => {
225
- const relPaths = extractRelFilePaths(src);
226
- if (relPaths.length > 0) {
227
- await fetchAssets(relPaths, basePath);
228
- const preamble = buildAssetsPreamble();
229
- return preamble + rewriteAssetCalls(src);
192
+ window.$typst.setRendererInitOptions({
193
+ beforeBuild: [fonts],
194
+ getModule: () => CONFIG.TYPST_RENDERER_URL,
195
+ });
230
196
  }
231
- return src;
232
- };
197
+ }
233
198
 
234
- // Parse error message from SourceDiagnostic format
235
- const parseTypstError = (errorMessage) => {
236
- try {
237
- // Try to extract message from SourceDiagnostic format
238
- const match = errorMessage.match(/message:\s*"([^"]+)"/);
239
- if (match) {
240
- return match[1];
241
- }
242
- } catch (e) {
243
- // Fallback to original message
199
+ // ============================================================================
200
+ // RENDER QUEUE
201
+ // ============================================================================
202
+
203
+ class RenderQueue {
204
+ constructor() {
205
+ this.queue = Promise.resolve();
244
206
  }
245
- return errorMessage;
246
- };
247
207
 
248
- // Render typst code to SVG
249
- const renderTypst = async (
250
- code,
251
- container,
252
- loadingIndicator,
253
- sourceFiles,
254
- binaryFiles,
255
- id,
256
- previewContainer,
257
- basePath,
258
- ) => {
259
- // Queue this render to ensure only one compilation runs at a time
260
- return queueRender(async () => {
261
- // Show loading indicator
262
- if (loadingIndicator) {
263
- loadingIndicator.style.display = "flex";
264
- }
265
-
266
- await loadTypst();
208
+ /**
209
+ * Add render function to queue
210
+ * @param {Function} renderFn - Async render function
211
+ * @returns {Promise}
212
+ */
213
+ add(renderFn) {
214
+ this.queue = this.queue
215
+ .then(renderFn)
216
+ .catch((error) => {
217
+ console.error('Queued render error:', error);
218
+ });
219
+ return this.queue;
220
+ }
221
+ }
222
+
223
+ // ============================================================================
224
+ // ASSET MANAGEMENT
225
+ // ============================================================================
226
+
227
+ class AssetManager {
228
+ constructor() {
229
+ this.cache = new Map(); // filepath -> Uint8Array | null
230
+ }
231
+
232
+ /**
233
+ * Extract relative file paths from Typst source code
234
+ * @param {string} src - Typst source code
235
+ * @returns {Array<{path: string, isText: boolean}>} Array of file paths with type info
236
+ */
237
+ extractFilePaths(src) {
238
+ const paths = new Map(); // path -> isText
239
+
240
+ for (const [name, pattern] of Object.entries(REGEX_PATTERNS)) {
241
+ if (name === 'ABSOLUTE_URL' || name === 'ERROR_MESSAGE') continue;
242
+
243
+ const isText = TEXT_PATTERNS.includes(name);
244
+ let match;
245
+ // Reset regex lastIndex
246
+ pattern.lastIndex = 0;
247
+
248
+ while ((match = pattern.exec(src)) !== null) {
249
+ const path = match[2];
250
+ // Skip absolute URLs, data URLs, blob URLs
251
+ if (REGEX_PATTERNS.ABSOLUTE_URL.test(path)) continue;
252
+ paths.set(path, isText);
253
+ }
254
+ }
255
+
256
+ return Array.from(paths.entries()).map(([path, isText]) => ({ path, isText }));
257
+ }
267
258
 
259
+ /**
260
+ * Fetch single asset from server
261
+ * @param {string} path - Asset path
262
+ * @param {string} basePath - Base path
263
+ * @param {string} pagePath - Page path
264
+ * @param {boolean} isText - Whether this is a text file
265
+ * @returns {Promise<Uint8Array|null>}
266
+ */
267
+ async fetchAsset(path, basePath, pagePath, isText = false) {
268
268
  try {
269
- // Reset shadow files for this render
270
- $typst.resetShadow();
269
+ const url = constructUrl(path, basePath, pagePath);
270
+ const response = await fetch(url);
271
271
 
272
- // Prepare code with server-loaded assets
273
- const preparedCode = await prepareTypstSourceWithAssets(code, basePath);
272
+ if (!response.ok) {
273
+ console.warn(`Asset not found: ${path} at ${url} (HTTP ${response.status})`);
274
+ return null;
275
+ }
274
276
 
275
- // Add source files
276
- for (const { filename, content } of sourceFiles) {
277
- const path = filename.startsWith("/")
278
- ? filename.substring(1)
279
- : filename;
280
- await $typst.addSource(`/${path}`, content);
277
+ if (isText) {
278
+ // For text files, decode as text and re-encode as UTF-8
279
+ const text = await response.text();
280
+ return new TextEncoder().encode(text);
281
+ } else {
282
+ // For binary files, use arrayBuffer directly
283
+ const arrayBuffer = await response.arrayBuffer();
284
+ return new Uint8Array(arrayBuffer);
281
285
  }
286
+ } catch (error) {
287
+ console.warn(`Error loading asset ${path}:`, error);
288
+ return null;
289
+ }
290
+ }
282
291
 
283
- // Add binary files
284
- for (const { dest, url } of binaryFiles) {
285
- try {
286
- let arrayBuffer;
292
+ /**
293
+ * Fetch multiple assets and cache them
294
+ * @param {Array<{path: string, isText: boolean}>} pathInfos - Array of path info objects
295
+ * @param {string} basePath - Base path
296
+ * @param {string} pagePath - Page path
297
+ * @returns {Promise<void>}
298
+ */
299
+ async fetchAssets(pathInfos, basePath, pagePath) {
300
+ const missingPaths = pathInfos.filter(({ path }) => !this.cache.has(path));
301
+
302
+ await Promise.all(
303
+ missingPaths.map(async ({ path, isText }) => {
304
+ const data = await this.fetchAsset(path, basePath, pagePath, isText);
305
+ this.cache.set(path, data);
306
+ })
307
+ );
308
+ }
287
309
 
288
- // Check if URL is a data URL (user-uploaded file)
289
- if (url.startsWith("data:")) {
290
- const response = await fetch(url);
291
- arrayBuffer = await response.arrayBuffer();
292
- } else {
293
- // External URL
294
- const response = await fetch(url);
295
- if (!response.ok) {
296
- console.warn(`Failed to load binary file: ${url}`);
297
- continue;
298
- }
299
- arrayBuffer = await response.arrayBuffer();
300
- }
310
+ /**
311
+ * Build Typst preamble with inlined assets as bytes
312
+ * @returns {string} Typst preamble code
313
+ */
314
+ buildAssetsPreamble() {
315
+ if (this.cache.size === 0) return "";
316
+ const entries = [...this.cache.entries()]
317
+ .filter(([name, u8]) => u8 !== null)
318
+ .map(([name, u8]) => {
319
+ const nums = Array.from(u8).join(",");
320
+ return ` "${name}": bytes((${nums}))`;
321
+ })
322
+ .join(",\n");
323
+ if (!entries) return "";
324
+ return `#let __assets = (\n${entries}\n)\n\n`;
325
+ }
301
326
 
302
- const path = dest.startsWith("/") ? dest.substring(1) : dest;
303
- $typst.mapShadow(`/${path}`, new Uint8Array(arrayBuffer));
304
- } catch (error) {
305
- console.warn(`Error loading binary file ${url}:`, error);
327
+ /**
328
+ * Rewrite file calls (image, read, csv, json, yaml, xml) to use inlined assets
329
+ * @param {string} src - Typst source code
330
+ * @returns {string} Rewritten source code
331
+ */
332
+ rewriteAssetCalls(src) {
333
+ if (this.cache.size === 0) return src;
334
+
335
+ // Rewrite image() calls
336
+ src = src.replace(/image\s*\(\s*(['"])([^'"]+)\1/g, (m, q, fname) => {
337
+ if (this.cache.has(fname)) {
338
+ const asset = this.cache.get(fname);
339
+ if (asset === null) {
340
+ return `[File not found: _${fname}_]`;
306
341
  }
342
+ return `image(__assets.at("${fname}")`;
307
343
  }
344
+ return m;
345
+ });
308
346
 
309
- const svg = await $typst.svg({ mainContent: preparedCode });
310
-
311
- // Remove any existing error overlay from preview-container
312
- if (previewContainer) {
313
- const existingError = previewContainer.querySelector(
314
- ".typst-error-overlay",
315
- );
316
- if (existingError) {
317
- existingError.remove();
347
+ // Rewrite read() calls
348
+ src = src.replace(/read\s*\(\s*(['"])([^'"]+)\1/g, (m, q, fname) => {
349
+ if (this.cache.has(fname)) {
350
+ const asset = this.cache.get(fname);
351
+ if (asset === null) {
352
+ return `[File not found: _${fname}_]`;
318
353
  }
354
+ return `read(__assets.at("${fname}")`;
319
355
  }
356
+ return m;
357
+ });
320
358
 
321
- container.innerHTML = svg;
322
-
323
- // Scale SVG to fit container
324
- const svgElem = container.firstElementChild;
325
- if (svgElem) {
326
- const width = Number.parseFloat(svgElem.getAttribute("width"));
327
- const height = Number.parseFloat(svgElem.getAttribute("height"));
328
- const containerWidth = container.clientWidth - 20;
329
- if (width > 0 && containerWidth > 0) {
330
- svgElem.setAttribute("width", containerWidth);
331
- svgElem.setAttribute("height", (height * containerWidth) / width);
359
+ // Rewrite csv() calls
360
+ src = src.replace(/csv\s*\(\s*(['"])([^'"]+)\1/g, (m, q, fname) => {
361
+ if (this.cache.has(fname)) {
362
+ const asset = this.cache.get(fname);
363
+ if (asset === null) {
364
+ return `[File not found: _${fname}_]`;
332
365
  }
366
+ return `csv(__assets.at("${fname}")`;
333
367
  }
334
- } catch (error) {
335
- const errorText = parseTypstError(error || "Error rendering Typst");
368
+ return m;
369
+ });
336
370
 
337
- // Check if we have existing content (previous successful render)
338
- const hasExistingContent = container.querySelector("svg") !== null;
371
+ // Rewrite json() calls
372
+ src = src.replace(/json\s*\(\s*(['"])([^'"]+)\1/g, (m, q, fname) => {
373
+ if (this.cache.has(fname)) {
374
+ const asset = this.cache.get(fname);
375
+ if (asset === null) {
376
+ return `[File not found: _${fname}_]`;
377
+ }
378
+ return `json(__assets.at("${fname}")`;
379
+ }
380
+ return m;
381
+ });
339
382
 
340
- // Always use error overlay in preview-container if available
341
- if (previewContainer) {
342
- // Remove any existing error overlay
343
- const existingError = previewContainer.querySelector(
344
- ".typst-error-overlay",
345
- );
346
- if (existingError) {
347
- existingError.remove();
383
+ // Rewrite yaml() calls
384
+ src = src.replace(/yaml\s*\(\s*(['"])([^'"]+)\1/g, (m, q, fname) => {
385
+ if (this.cache.has(fname)) {
386
+ const asset = this.cache.get(fname);
387
+ if (asset === null) {
388
+ return `[File not found: _${fname}_]`;
348
389
  }
390
+ return `yaml(__assets.at("${fname}")`;
391
+ }
392
+ return m;
393
+ });
349
394
 
350
- // Clear preview if no existing content
351
- if (!hasExistingContent) {
352
- container.innerHTML = "";
395
+ // Rewrite xml() calls
396
+ src = src.replace(/xml\s*\(\s*(['"])([^'"]+)\1/g, (m, q, fname) => {
397
+ if (this.cache.has(fname)) {
398
+ const asset = this.cache.get(fname);
399
+ if (asset === null) {
400
+ return `[File not found: _${fname}_]`;
353
401
  }
402
+ return `xml(__assets.at("${fname}")`;
403
+ }
404
+ return m;
405
+ });
354
406
 
355
- // Create floating error overlay in preview-container
356
- const errorOverlay = document.createElement("div");
357
- errorOverlay.className = "typst-error-overlay";
358
- errorOverlay.innerHTML = `
359
- <div class="typst-error-content">
360
- <div class="typst-error-header">
361
- <span class="typst-error-title">⚠️ Typst Error</span>
362
- <button class="typst-error-close" title="Dismiss error">×</button>
363
- </div>
364
- <div class="typst-error-message">${errorText}</div>
365
- </div>
366
- `;
367
-
368
- // Add close button functionality
369
- const closeBtn = errorOverlay.querySelector(".typst-error-close");
370
- closeBtn.addEventListener("click", () => {
371
- errorOverlay.remove();
372
- });
407
+ return src;
408
+ }
373
409
 
374
- previewContainer.appendChild(errorOverlay);
375
- } else {
376
- // Fallback: show error in preview container directly
377
- container.innerHTML = `<div class="typst-error">${errorText}</div>`;
378
- }
379
- } finally {
380
- // Hide loading indicator
381
- if (loadingIndicator) {
382
- loadingIndicator.style.display = "none";
410
+ /**
411
+ * Prepare assets for rendering (extract and fetch)
412
+ * @param {string} mainSrc - Main Typst source
413
+ * @param {Array} sourceFiles - Source file objects
414
+ * @param {string} basePath - Base path
415
+ * @param {string} pagePath - Page path
416
+ * @returns {Promise<void>}
417
+ */
418
+ async prepare(mainSrc, sourceFiles, basePath, pagePath) {
419
+ const allPaths = new Map(); // path -> isText
420
+
421
+ // Extract from main source
422
+ for (const { path, isText } of this.extractFilePaths(mainSrc)) {
423
+ allPaths.set(path, isText);
424
+ }
425
+
426
+ // Extract from all source files
427
+ for (const { content } of sourceFiles) {
428
+ for (const { path, isText } of this.extractFilePaths(content)) {
429
+ allPaths.set(path, isText);
383
430
  }
384
431
  }
385
- });
386
- };
387
432
 
388
- // Export to PDF
389
- const exportPdf = async (code, id, sourceFiles, binaryFiles, basePath) => {
390
- // Queue this export to ensure only one compilation runs at a time
391
- return queueRender(async () => {
392
- await loadTypst();
433
+ const pathInfos = Array.from(allPaths.entries()).map(([path, isText]) => ({ path, isText }));
434
+
435
+ if (pathInfos.length > 0) {
436
+ await this.fetchAssets(pathInfos, basePath, pagePath);
437
+ }
438
+ }
439
+ }
393
440
 
441
+ // ============================================================================
442
+ // ERROR HANDLING
443
+ // ============================================================================
444
+
445
+ class ErrorHandler {
446
+ /**
447
+ * Parse error message from SourceDiagnostic format
448
+ * @param {string|Error} error - Error object or message
449
+ * @returns {string} Parsed error message
450
+ */
451
+ static parse(error) {
452
+ const errorMessage = error?.toString() || 'Unknown error';
453
+
394
454
  try {
395
- // Reset shadow files for this export
396
- $typst.resetShadow();
455
+ const match = errorMessage.match(REGEX_PATTERNS.ERROR_MESSAGE);
456
+ if (match) {
457
+ return match[1];
458
+ }
459
+ } catch (e) {
460
+ // Fallback to original message
461
+ }
462
+
463
+ return errorMessage;
464
+ }
397
465
 
398
- // Prepare code with server-loaded assets
399
- const preparedCode = await prepareTypstSourceWithAssets(code, basePath);
466
+ /**
467
+ * Display error overlay in preview container
468
+ * @param {HTMLElement} previewContainer - Preview container element
469
+ * @param {string} errorText - Error message
470
+ */
471
+ static showOverlay(previewContainer, errorText) {
472
+ // Remove any existing error overlay
473
+ const existingError = previewContainer.querySelector('.typst-error-overlay');
474
+ if (existingError) {
475
+ existingError.remove();
476
+ }
400
477
 
401
- // Add source files
402
- for (const { filename, content } of sourceFiles) {
403
- const path = filename.startsWith("/")
404
- ? filename.substring(1)
405
- : filename;
406
- await $typst.addSource(`/${path}`, content);
407
- }
478
+ // Create floating error overlay
479
+ const errorOverlay = document.createElement('div');
480
+ errorOverlay.className = 'typst-error-overlay';
481
+ errorOverlay.innerHTML = `
482
+ <div class="typst-error-content">
483
+ <div class="typst-error-header">
484
+ <span class="typst-error-title">⚠️ Typst Error</span>
485
+ <button class="typst-error-close" title="Dismiss error">×</button>
486
+ </div>
487
+ <div class="typst-error-message">${errorText}</div>
488
+ </div>
489
+ `;
490
+
491
+ // Add close button functionality
492
+ const closeBtn = errorOverlay.querySelector('.typst-error-close');
493
+ closeBtn.addEventListener('click', () => errorOverlay.remove());
494
+
495
+ previewContainer.appendChild(errorOverlay);
496
+ }
408
497
 
409
- // Add binary files
410
- for (const { dest, url } of binaryFiles) {
411
- try {
412
- let arrayBuffer;
498
+ /**
499
+ * Display error inline
500
+ * @param {HTMLElement} container - Container element
501
+ * @param {string} errorText - Error message
502
+ */
503
+ static showInline(container, errorText) {
504
+ container.innerHTML = `<div class="typst-error">${errorText}</div>`;
505
+ }
506
+ }
413
507
 
414
- // Check if URL is a data URL (user-uploaded file)
415
- if (url.startsWith("data:")) {
416
- const response = await fetch(url);
417
- arrayBuffer = await response.arrayBuffer();
418
- } else {
419
- // External URL
420
- const response = await fetch(url);
421
- if (!response.ok) {
422
- continue;
423
- }
424
- arrayBuffer = await response.arrayBuffer();
425
- }
508
+ // ============================================================================
509
+ // BINARY FILE HANDLER
510
+ // ============================================================================
511
+
512
+ class BinaryFileHandler {
513
+ /**
514
+ * Load binary file from URL
515
+ * @param {string} url - File URL (data URL or HTTP URL)
516
+ * @returns {Promise<ArrayBuffer>}
517
+ */
518
+ static async load(url) {
519
+ const response = await fetch(url);
520
+
521
+ if (!response.ok && !url.startsWith('data:')) {
522
+ throw new Error(`Failed to load binary file: ${url}`);
523
+ }
524
+
525
+ return response.arrayBuffer();
526
+ }
426
527
 
427
- const path = dest.startsWith("/") ? dest.substring(1) : dest;
428
- $typst.mapShadow(`/${path}`, new Uint8Array(arrayBuffer));
528
+ /**
529
+ * Add binary files to Typst shadow filesystem
530
+ * @param {Array} binaryFiles - Array of binary file objects
531
+ * @returns {Promise<void>}
532
+ */
533
+ static async addToShadow(binaryFiles) {
534
+ await Promise.all(
535
+ binaryFiles.map(async ({ dest, url }) => {
536
+ try {
537
+ const arrayBuffer = await BinaryFileHandler.load(url);
538
+ const path = dest.startsWith('/') ? dest.substring(1) : dest;
539
+ window.$typst.mapShadow(`/${path}`, new Uint8Array(arrayBuffer));
429
540
  } catch (error) {
430
541
  console.warn(`Error loading binary file ${url}:`, error);
431
542
  }
432
- }
543
+ })
544
+ );
545
+ }
546
+ }
433
547
 
434
- const pdfData = await $typst.pdf({ mainContent: preparedCode });
435
- const pdfFile = new Blob([pdfData], { type: "application/pdf" });
436
- const link = document.createElement("a");
437
- link.href = URL.createObjectURL(pdfFile);
438
- link.download = `typst-${id}.pdf`;
439
- link.click();
440
- URL.revokeObjectURL(link.href);
441
- } catch (error) {
442
- console.error("PDF export error:", error);
443
- alert(i18n.get("typst-pdf-error") || "Error exporting PDF");
548
+ // ============================================================================
549
+ // TYPST RENDERER
550
+ // ============================================================================
551
+
552
+ class TypstRenderer {
553
+ constructor(loader, assetManager, renderQueue) {
554
+ this.loader = loader;
555
+ this.assetManager = assetManager;
556
+ this.renderQueue = renderQueue;
557
+ }
558
+
559
+ /**
560
+ * Add source files to Typst
561
+ * @param {Array} sourceFiles - Source file objects
562
+ * @returns {Promise<void>}
563
+ */
564
+ async addSourceFiles(sourceFiles) {
565
+ for (const { filename, content } of sourceFiles) {
566
+ const path = filename.startsWith('/') ? filename.substring(1) : filename;
567
+ await window.$typst.addSource(`/${path}`, content);
444
568
  }
445
- });
446
- };
569
+ }
447
570
 
448
- for (let elem of elems) {
449
- const id = elem.getAttribute("data-id");
450
- const previewContainer = elem.querySelector(".preview-container");
451
- const preview = elem.querySelector(".typst-preview");
452
- const loadingIndicator = elem.querySelector(".typst-loading");
453
- const editor = elem.querySelector(".editor.typst");
454
- const downloadBtn = elem.querySelector(".download-pdf");
455
- const downloadProjectBtn = elem.querySelector(".download-project");
456
- const resetBtn = elem.querySelector(".reset");
457
- const sourceTextarea = elem.querySelector(".typst-source");
458
- const tabsList = elem.querySelector(".tabs-list");
459
- const binaryFilesList = elem.querySelector(".binary-files-list");
460
- const addSourceFileBtn = elem.querySelector(".add-source-file");
461
- const addBinaryFileBtn = elem.querySelector(".add-binary-file");
462
-
463
- // Parse source files and binary files from data attributes
464
- const sourceFilesData = elem.getAttribute("data-source-files");
465
- const binaryFilesData = elem.getAttribute("data-binary-files");
466
- let basePath = elem.getAttribute("data-base-path") || "";
467
-
468
- // Ensure basePath starts with / for absolute paths
469
- if (basePath && !basePath.startsWith("/")) {
470
- basePath = "/" + basePath;
471
- }
472
-
473
- let sourceFiles = sourceFilesData ? JSON.parse(atob(sourceFilesData)) : [];
474
- let binaryFiles = binaryFilesData ? JSON.parse(atob(binaryFilesData)) : [];
475
-
476
- // Track current active file
477
- let currentFile =
478
- sourceFiles.find(
479
- (f) => f.filename === "main.typ" || f.filename === "main.typst",
480
- ) || sourceFiles[0];
481
-
482
- // Store file contents in memory
483
- const fileContents = new Map();
484
- sourceFiles.forEach((f) => fileContents.set(f.filename, f.content));
485
-
486
- // Function to update tabs UI
487
- const updateTabs = () => {
488
- if (!tabsList) return;
489
-
490
- tabsList.innerHTML = "";
491
-
492
- // Add source file tabs
493
- sourceFiles.forEach((file) => {
494
- const tab = document.createElement("div");
495
- tab.className = "file-tab";
496
- if (file.filename === currentFile.filename) {
497
- tab.classList.add("active");
498
- }
571
+ /**
572
+ * Scale SVG to fit container
573
+ * @param {HTMLElement} container - Container element
574
+ */
575
+ scaleSvg(container) {
576
+ const svgElem = container.firstElementChild;
577
+ if (!svgElem) return;
578
+
579
+ const width = Number.parseFloat(svgElem.getAttribute('width'));
580
+ const height = Number.parseFloat(svgElem.getAttribute('height'));
581
+ const containerWidth = container.clientWidth - CONFIG.CONTAINER_PADDING;
582
+
583
+ if (width > 0 && containerWidth > 0) {
584
+ svgElem.setAttribute('width', containerWidth);
585
+ svgElem.setAttribute('height', (height * containerWidth) / width);
586
+ }
587
+ }
499
588
 
500
- const tabName = document.createElement("span");
501
- tabName.className = "tab-name";
502
- tabName.textContent = file.filename;
503
- tab.appendChild(tabName);
504
-
505
- // Add delete button (except for main file)
506
- if (file.filename !== "main.typ" && file.filename !== "main.typst") {
507
- const deleteBtn = document.createElement("button");
508
- deleteBtn.className = "tab-delete";
509
- deleteBtn.textContent = "×";
510
- deleteBtn.title = i18n.get("typst-delete-file") || "Delete file";
511
- deleteBtn.addEventListener("click", (e) => {
512
- e.stopPropagation();
513
- if (
514
- confirm(
515
- `${i18n.get("typst-delete-confirm") || "Delete"} ${file.filename}?`,
516
- )
517
- ) {
518
- sourceFiles = sourceFiles.filter(
519
- (f) => f.filename !== file.filename,
520
- );
521
- fileContents.delete(file.filename);
522
-
523
- // Switch to main file if we deleted the current file
524
- if (currentFile.filename === file.filename) {
525
- currentFile = sourceFiles[0];
526
- if (editor) {
527
- editor.value = fileContents.get(currentFile.filename) || "";
528
- }
529
- }
530
-
531
- updateTabs();
532
- saveState();
533
- rerenderTypst();
534
- }
535
- });
536
- tab.appendChild(deleteBtn);
537
- }
589
+ /**
590
+ * Render Typst code to SVG
591
+ * @param {Object} params - Render parameters
592
+ * @returns {Promise<void>}
593
+ */
594
+ async render({
595
+ code,
596
+ container,
597
+ loadingIndicator,
598
+ sourceFiles,
599
+ binaryFiles,
600
+ fontFiles,
601
+ id,
602
+ previewContainer,
603
+ basePath,
604
+ pagePath,
605
+ }) {
606
+ return this.renderQueue.add(async () => {
607
+ try {
608
+ // Show loading indicator
609
+ if (loadingIndicator) {
610
+ loadingIndicator.style.display = 'flex';
611
+ }
538
612
 
539
- tab.addEventListener("click", () => {
540
- if (currentFile.filename !== file.filename) {
541
- // Save current file content
542
- if (editor) {
543
- fileContents.set(currentFile.filename, editor.value);
544
- }
613
+ await this.loader.load({ fontFiles });
545
614
 
546
- // Switch to new file
547
- currentFile = file;
548
- if (editor) {
549
- editor.value = fileContents.get(currentFile.filename) || "";
550
- }
615
+ // Reset shadow files
616
+ window.$typst.resetShadow();
551
617
 
552
- updateTabs();
553
- saveState();
554
- }
555
- });
618
+ // Prepare assets
619
+ await this.assetManager.prepare(code, sourceFiles, basePath, pagePath);
556
620
 
557
- tabsList.appendChild(tab);
558
- });
559
- };
621
+ // Build assets preamble and rewrite source files
622
+ const assetsPreamble = this.assetManager.buildAssetsPreamble();
623
+ const rewrittenCode = this.assetManager.rewriteAssetCalls(code);
624
+ const rewrittenSourceFiles = sourceFiles.map(({ filename, content }) => ({
625
+ filename,
626
+ content: assetsPreamble + this.assetManager.rewriteAssetCalls(content),
627
+ }));
560
628
 
561
- // Function to update binary files list
562
- const updateBinaryFilesList = () => {
563
- if (!binaryFilesList) return;
629
+ // Add source files with rewritten content (includes preamble)
630
+ await this.addSourceFiles(rewrittenSourceFiles);
564
631
 
565
- binaryFilesList.innerHTML = "";
632
+ // Add binary files
633
+ await BinaryFileHandler.addToShadow(binaryFiles);
566
634
 
567
- if (binaryFiles.length === 0) {
568
- const emptyMsg = document.createElement("div");
569
- emptyMsg.className = "binary-files-empty";
570
- emptyMsg.textContent =
571
- i18n.get("typst-no-binary-files") || "No binary files";
572
- binaryFilesList.appendChild(emptyMsg);
573
- return;
574
- }
635
+ // Render to SVG with preamble prepended
636
+ const mainContent = assetsPreamble + rewrittenCode;
637
+ const svg = await window.$typst.svg({ mainContent });
575
638
 
576
- binaryFiles.forEach((file) => {
577
- const item = document.createElement("div");
578
- item.className = "binary-file-item";
579
-
580
- const icon = document.createElement("span");
581
- icon.className = "binary-file-icon";
582
- icon.textContent = "📎";
583
- item.appendChild(icon);
584
-
585
- const name = document.createElement("span");
586
- name.className = "binary-file-name";
587
- name.textContent = file.dest;
588
- item.appendChild(name);
589
-
590
- const deleteBtn = document.createElement("button");
591
- deleteBtn.className = "binary-file-delete";
592
- deleteBtn.textContent = "×";
593
- deleteBtn.title = i18n.get("typst-delete-file") || "Delete file";
594
- deleteBtn.addEventListener("click", () => {
595
- if (
596
- confirm(
597
- `${i18n.get("typst-delete-confirm") || "Delete"} ${file.dest}?`,
598
- )
599
- ) {
600
- binaryFiles = binaryFiles.filter((f) => f.dest !== file.dest);
601
- updateBinaryFilesList();
602
- saveState();
603
- rerenderTypst();
639
+ // Clear any existing errors
640
+ if (previewContainer) {
641
+ const existingError = previewContainer.querySelector('.typst-error-overlay');
642
+ if (existingError) {
643
+ existingError.remove();
644
+ }
604
645
  }
605
- });
606
- item.appendChild(deleteBtn);
607
646
 
608
- binaryFilesList.appendChild(item);
609
- });
610
- };
611
-
612
- // Function to save state to store
613
- const saveState = async () => {
614
- if (!editor) return;
647
+ // Update container with SVG
648
+ container.innerHTML = svg;
649
+ this.scaleSvg(container);
615
650
 
616
- // Update current file content
617
- fileContents.set(currentFile.filename, editor.value);
651
+ } catch (error) {
652
+ const errorText = ErrorHandler.parse(error);
653
+ const hasExistingContent = container.querySelector('svg') !== null;
618
654
 
619
- // Update sourceFiles array with latest content
620
- sourceFiles = sourceFiles.map((f) => ({
621
- filename: f.filename,
622
- content: fileContents.get(f.filename) || f.content,
623
- }));
655
+ if (previewContainer) {
656
+ // Don't clear existing content on error
657
+ if (!hasExistingContent) {
658
+ container.innerHTML = '';
659
+ }
660
+ ErrorHandler.showOverlay(previewContainer, errorText);
661
+ } else {
662
+ ErrorHandler.showInline(container, errorText);
663
+ }
624
664
 
625
- await store.typst?.put({
626
- id,
627
- code: editor.value,
628
- sourceFiles,
629
- binaryFiles,
630
- currentFile: currentFile.filename,
665
+ } finally {
666
+ // Hide loading indicator
667
+ if (loadingIndicator) {
668
+ loadingIndicator.style.display = 'none';
669
+ }
670
+ }
631
671
  });
632
- };
672
+ }
633
673
 
634
- // Function to rerender typst
635
- const rerenderTypst = () => {
636
- if (editor) {
637
- // Update sourceFiles with current editor content
638
- fileContents.set(currentFile.filename, editor.value);
639
- sourceFiles = sourceFiles.map((f) => ({
640
- filename: f.filename,
641
- content: fileContents.get(f.filename) || f.content,
642
- }));
643
-
644
- const mainFile = sourceFiles.find(
645
- (f) => f.filename === "main.typ" || f.filename === "main.typst",
646
- );
647
- const mainCode = mainFile ? mainFile.content : "";
648
- renderTypst(
649
- mainCode,
650
- preview,
651
- loadingIndicator,
652
- sourceFiles,
653
- binaryFiles,
654
- id,
655
- previewContainer,
656
- basePath,
657
- );
658
- }
659
- };
674
+ /**
675
+ * Export Typst code to PDF
676
+ * @param {Object} params - Export parameters
677
+ * @returns {Promise<void>}
678
+ */
679
+ async exportPdf({
680
+ code,
681
+ id,
682
+ sourceFiles,
683
+ binaryFiles,
684
+ fontFiles,
685
+ basePath,
686
+ pagePath,
687
+ }) {
688
+ return this.renderQueue.add(async () => {
689
+ try {
690
+ await this.loader.load({ fontFiles });
691
+
692
+ // Reset shadow files
693
+ window.$typst.resetShadow();
694
+
695
+ // Prepare assets
696
+ await this.assetManager.prepare(code, sourceFiles, basePath, pagePath);
697
+
698
+ // Build assets preamble and rewrite source files
699
+ const assetsPreamble = this.assetManager.buildAssetsPreamble();
700
+ const rewrittenCode = this.assetManager.rewriteAssetCalls(code);
701
+ const rewrittenSourceFiles = sourceFiles.map(({ filename, content }) => ({
702
+ filename,
703
+ content: assetsPreamble + this.assetManager.rewriteAssetCalls(content),
704
+ }));
705
+
706
+ // Add source files with rewritten content (includes preamble)
707
+ await this.addSourceFiles(rewrittenSourceFiles);
708
+
709
+ // Add binary files
710
+ await BinaryFileHandler.addToShadow(binaryFiles);
711
+
712
+ // Generate PDF with preamble prepended
713
+ const mainContent = assetsPreamble + rewrittenCode;
714
+ const pdfData = await window.$typst.pdf({ mainContent });
715
+ const pdfBlob = new Blob([pdfData], { type: 'application/pdf' });
716
+
717
+ // Download PDF
718
+ const link = document.createElement('a');
719
+ link.href = URL.createObjectURL(pdfBlob);
720
+ link.download = `typst-${id}.pdf`;
721
+ link.click();
722
+ URL.revokeObjectURL(link.href);
660
723
 
661
- // Create debounced version of rerenderTypst for input events (500ms delay)
662
- const debouncedRerenderTypst = debounce(rerenderTypst, 500);
724
+ } catch (error) {
725
+ console.error('PDF export error:', error);
726
+ alert(i18nGet('typst-pdf-error', 'Error exporting PDF'));
727
+ }
728
+ });
729
+ }
730
+ }
663
731
 
664
- // Add source file button
665
- addSourceFileBtn?.addEventListener("click", () => {
666
- const filename = prompt(
667
- i18n.get("typst-filename-prompt") ||
668
- "Enter filename (e.g., helper.typ):",
732
+ // ============================================================================
733
+ // FILE MANAGER
734
+ // ============================================================================
735
+
736
+ class FileManager {
737
+ constructor(sourceFiles) {
738
+ this.sourceFiles = sourceFiles;
739
+ this.contents = new Map();
740
+ this.currentFile = this.findMainFile() || sourceFiles[0];
741
+
742
+ // Initialize contents map
743
+ sourceFiles.forEach((f) => this.contents.set(f.filename, f.content));
744
+ }
745
+
746
+ /**
747
+ * Find main Typst file
748
+ * @returns {Object|null} Main file object
749
+ */
750
+ findMainFile() {
751
+ return this.sourceFiles.find(
752
+ (f) => f.filename === 'main.typ' || f.filename === 'main.typst'
669
753
  );
670
- if (filename) {
671
- // Validate filename
672
- if (!filename.endsWith(".typ") && !filename.endsWith(".typst")) {
673
- alert(
674
- i18n.get("typst-filename-error") ||
675
- "Filename must end with .typ or .typst",
676
- );
677
- return;
678
- }
754
+ }
679
755
 
680
- if (sourceFiles.some((f) => f.filename === filename)) {
681
- alert(i18n.get("typst-filename-exists") || "File already exists");
682
- return;
683
- }
756
+ /**
757
+ * Get current file content
758
+ * @returns {string} File content
759
+ */
760
+ getCurrentContent() {
761
+ return this.contents.get(this.currentFile.filename) || '';
762
+ }
684
763
 
685
- // Add new file
686
- const newFile = { filename, content: `// ${filename}\n` };
687
- sourceFiles.push(newFile);
688
- fileContents.set(filename, newFile.content);
764
+ /**
765
+ * Update current file content
766
+ * @param {string} content - New content
767
+ */
768
+ updateCurrentContent(content) {
769
+ this.contents.set(this.currentFile.filename, content);
770
+ }
689
771
 
690
- // Switch to new file
691
- if (editor) {
692
- fileContents.set(currentFile.filename, editor.value);
693
- }
694
- currentFile = newFile;
695
- if (editor) {
696
- editor.value = newFile.content;
697
- }
772
+ /**
773
+ * Switch to different file
774
+ * @param {string} filename - Target filename
775
+ * @returns {string} New file content
776
+ */
777
+ switchTo(filename) {
778
+ const file = this.sourceFiles.find((f) => f.filename === filename);
779
+ if (file) {
780
+ this.currentFile = file;
781
+ return this.getCurrentContent();
782
+ }
783
+ return '';
784
+ }
698
785
 
699
- updateTabs();
700
- saveState();
701
- rerenderTypst();
786
+ /**
787
+ * Add new source file
788
+ * @param {string} filename - New filename
789
+ * @param {string} content - File content
790
+ * @returns {boolean} Success status
791
+ */
792
+ addFile(filename, content = `// ${filename}\n`) {
793
+ if (this.sourceFiles.some((f) => f.filename === filename)) {
794
+ return false;
702
795
  }
703
- });
704
796
 
705
- // Add binary file button
706
- addBinaryFileBtn?.addEventListener("click", (e) => {
707
- e.preventDefault();
708
- e.stopPropagation();
797
+ const newFile = { filename, content };
798
+ this.sourceFiles.push(newFile);
799
+ this.contents.set(filename, content);
800
+ this.currentFile = newFile;
801
+
802
+ return true;
803
+ }
709
804
 
710
- const input = document.createElement("input");
711
- input.type = "file";
712
- input.accept = "image/*,.pdf";
713
- input.addEventListener("change", async (e) => {
714
- const file = e.target.files[0];
715
- if (file) {
716
- const dest = `/${file.name}`;
717
-
718
- // Check if file already exists
719
- if (binaryFiles.some((f) => f.dest === dest)) {
720
- if (
721
- !confirm(
722
- i18n.get("typst-file-replace") || `Replace existing ${dest}?`,
723
- )
724
- ) {
725
- return;
726
- }
727
- binaryFiles = binaryFiles.filter((f) => f.dest !== dest);
728
- }
805
+ /**
806
+ * Delete source file
807
+ * @param {string} filename - Filename to delete
808
+ * @returns {boolean} Success status
809
+ */
810
+ deleteFile(filename) {
811
+ // Don't delete main file
812
+ if (filename === 'main.typ' || filename === 'main.typst') {
813
+ return false;
814
+ }
729
815
 
730
- // Read file as data URL
731
- const reader = new FileReader();
732
- reader.onload = async (e) => {
733
- const url = e.target.result;
734
- binaryFiles.push({ dest, url });
735
- updateBinaryFilesList();
736
- saveState();
737
- rerenderTypst();
738
- };
739
- reader.readAsDataURL(file);
740
- }
741
- });
742
- input.click();
743
- });
816
+ this.sourceFiles = this.sourceFiles.filter((f) => f.filename !== filename);
817
+ this.contents.delete(filename);
744
818
 
745
- // Get initial code
746
- let initialCode = "";
747
- if (editor) {
748
- // Edit mode - code is in the editor
749
- // Wait for code-input to load
750
- editor.addEventListener("code-input_load", async () => {
751
- // Check for stored code
752
- const result = await store.typst?.get(id);
753
- if (result) {
754
- editor.value = result.code;
755
-
756
- // Restore sourceFiles and binaryFiles if available
757
- if (result.sourceFiles) {
758
- sourceFiles = result.sourceFiles;
759
- sourceFiles.forEach((f) => fileContents.set(f.filename, f.content));
760
- }
761
- if (result.binaryFiles) {
762
- binaryFiles = result.binaryFiles;
763
- }
764
- if (result.currentFile) {
765
- currentFile =
766
- sourceFiles.find((f) => f.filename === result.currentFile) ||
767
- sourceFiles[0];
768
- editor.value = fileContents.get(currentFile.filename) || "";
769
- }
770
- }
771
- initialCode = editor.value;
819
+ // Switch to main file if we deleted current file
820
+ if (this.currentFile.filename === filename) {
821
+ this.currentFile = this.sourceFiles[0];
822
+ }
772
823
 
773
- updateTabs();
774
- updateBinaryFilesList();
775
- rerenderTypst();
824
+ return true;
825
+ }
776
826
 
777
- // Listen for input changes
778
- editor.addEventListener("input", () => {
779
- saveState();
780
- debouncedRerenderTypst();
781
- });
782
- });
783
- } else if (sourceTextarea) {
784
- // Preview mode - code is in hidden textarea
785
- initialCode = sourceTextarea.value;
786
- loadTypst().then(() => {
787
- renderTypst(
788
- initialCode,
789
- preview,
790
- loadingIndicator,
791
- sourceFiles,
792
- binaryFiles,
793
- id,
794
- previewContainer,
795
- basePath,
796
- );
797
- });
827
+ /**
828
+ * Get all source files with current content
829
+ * @returns {Array} Updated source files
830
+ */
831
+ getSourceFiles() {
832
+ return this.sourceFiles.map((f) => ({
833
+ filename: f.filename,
834
+ content: this.contents.get(f.filename) || f.content,
835
+ }));
798
836
  }
837
+ }
799
838
 
800
- // Download PDF button
801
- downloadBtn?.addEventListener("click", async () => {
802
- // Get the main file content
803
- const mainFile = sourceFiles.find(
804
- (f) => f.filename === "main.typ" || f.filename === "main.typst",
805
- );
806
- const code = mainFile
807
- ? mainFile.content
808
- : editor
809
- ? editor.value
810
- : initialCode;
811
- await exportPdf(code, id, sourceFiles, binaryFiles, basePath);
812
- });
839
+ // ============================================================================
840
+ // PROJECT EXPORTER
841
+ // ============================================================================
813
842
 
814
- // Download Project button (ZIP with all files)
815
- downloadProjectBtn?.addEventListener("click", async () => {
816
- // Get the main file content
817
- const mainFile = sourceFiles.find(
818
- (f) => f.filename === "main.typ" || f.filename === "main.typst",
819
- );
820
- const code = mainFile
821
- ? mainFile.content
822
- : editor
823
- ? editor.value
824
- : initialCode;
825
- const encoder = new TextEncoder();
826
- const zipFiles = {};
827
-
828
- // Add all source files
829
- for (const { filename, content } of sourceFiles) {
830
- const path = filename.startsWith("/")
831
- ? filename.substring(1)
832
- : filename;
833
- zipFiles[path] = encoder.encode(content);
843
+ class ProjectExporter {
844
+ constructor(assetManager) {
845
+ this.assetManager = assetManager;
846
+ }
847
+
848
+ /**
849
+ * Export project as ZIP file
850
+ * @param {Object} params - Export parameters
851
+ * @returns {Promise<void>}
852
+ */
853
+ async export({ code, id, sourceFiles, binaryFiles, basePath, pagePath }) {
854
+ try {
855
+ const encoder = new TextEncoder();
856
+ const zipFiles = {};
857
+
858
+ // Add all source files
859
+ for (const { filename, content } of sourceFiles) {
860
+ const path = filename.startsWith('/') ? filename.substring(1) : filename;
861
+ zipFiles[path] = encoder.encode(content);
862
+ }
863
+
864
+ // Add binary files
865
+ await this.addBinaryFiles(zipFiles, binaryFiles, basePath, pagePath);
866
+
867
+ // Add referenced assets
868
+ await this.addAssets(zipFiles, code, basePath, pagePath);
869
+
870
+ // Create and download ZIP
871
+ this.downloadZip(zipFiles, id);
872
+
873
+ } catch (error) {
874
+ console.error('Project export error:', error);
875
+ alert(i18nGet('typst-export-error', 'Error exporting project'));
834
876
  }
877
+ }
835
878
 
836
- // Add binary files
879
+ /**
880
+ * Add binary files to ZIP
881
+ * @param {Object} zipFiles - ZIP files object
882
+ * @param {Array} binaryFiles - Binary files array
883
+ * @param {string} basePath - Base path
884
+ * @param {string} pagePath - Page path
885
+ * @returns {Promise<void>}
886
+ */
887
+ async addBinaryFiles(zipFiles, binaryFiles, basePath, pagePath) {
837
888
  for (const { dest, url } of binaryFiles) {
838
889
  try {
839
890
  let arrayBuffer;
840
891
 
841
- // Check if URL is a data URL (user-uploaded file)
842
- if (url.startsWith("data:")) {
892
+ if (url.startsWith('data:')) {
843
893
  const response = await fetch(url);
844
894
  arrayBuffer = await response.arrayBuffer();
845
- } else if (url.startsWith("http://") || url.startsWith("https://")) {
846
- // External URL
895
+ } else if (url.startsWith('http://') || url.startsWith('https://')) {
847
896
  const response = await fetch(url);
848
- if (response.ok) {
849
- arrayBuffer = await response.arrayBuffer();
850
- } else {
851
- console.warn(`Failed to load binary file: ${url}`);
852
- continue;
853
- }
897
+ if (!response.ok) continue;
898
+ arrayBuffer = await response.arrayBuffer();
854
899
  } else {
855
- // Relative URL - use basePath to construct full URL
856
- const fullUrl = basePath
857
- ? `${basePath}/${url}`.replace(/\/+/g, "/")
858
- : url;
900
+ const fullUrl = constructUrl(url, basePath, pagePath);
859
901
  const response = await fetch(fullUrl);
860
- if (response.ok) {
861
- arrayBuffer = await response.arrayBuffer();
862
- } else {
902
+ if (!response.ok) {
863
903
  console.warn(`Failed to load binary file: ${url} at ${fullUrl}`);
864
904
  continue;
865
905
  }
906
+ arrayBuffer = await response.arrayBuffer();
866
907
  }
867
908
 
868
- const path = dest.startsWith("/") ? dest.substring(1) : dest;
909
+ const path = dest.startsWith('/') ? dest.substring(1) : dest;
869
910
  zipFiles[path] = new Uint8Array(arrayBuffer);
870
911
  } catch (error) {
871
912
  console.warn(`Error loading binary file ${url}:`, error);
872
913
  }
873
914
  }
915
+ }
874
916
 
875
- const relPaths = extractRelFilePaths(code);
876
- for (const relPath of relPaths) {
877
- // Skip if already in zipFiles or already handled as binary file
878
- const normalizedPath = relPath.startsWith("/")
879
- ? relPath.substring(1)
917
+ /**
918
+ * Add referenced assets to ZIP
919
+ * @param {Object} zipFiles - ZIP files object
920
+ * @param {string} code - Typst source code
921
+ * @param {string} basePath - Base path
922
+ * @param {string} pagePath - Page path
923
+ * @returns {Promise<void>}
924
+ */
925
+ async addAssets(zipFiles, code, basePath, pagePath) {
926
+ const pathInfos = this.assetManager.extractFilePaths(code);
927
+
928
+ for (const { path: relPath, isText } of pathInfos) {
929
+ const normalizedPath = relPath.startsWith('/')
930
+ ? relPath.substring(1)
880
931
  : relPath;
932
+
933
+ // Skip if already in zipFiles
881
934
  if (zipFiles[normalizedPath]) continue;
882
935
 
883
- // Skip absolute URLs, data URLs, and blob URLs
884
- if (/^(https?:|data:|blob:)/i.test(relPath)) continue;
936
+ // Skip absolute URLs
937
+ if (REGEX_PATTERNS.ABSOLUTE_URL.test(relPath)) continue;
885
938
 
886
939
  try {
887
- // Construct URL using basePath
888
- const url = basePath
889
- ? `${basePath}/${relPath}`.replace(/\/+/g, "/")
890
- : imagePath;
940
+ const url = constructUrl(relPath, basePath, pagePath);
891
941
  const response = await fetch(url);
942
+
892
943
  if (response.ok) {
893
- const arrayBuffer = await response.arrayBuffer();
894
- zipFiles[normalizedPath] = new Uint8Array(arrayBuffer);
944
+ if (isText) {
945
+ const text = await response.text();
946
+ zipFiles[normalizedPath] = new TextEncoder().encode(text);
947
+ } else {
948
+ const arrayBuffer = await response.arrayBuffer();
949
+ zipFiles[normalizedPath] = new Uint8Array(arrayBuffer);
950
+ }
895
951
  } else {
896
952
  console.warn(`Failed to load asset: ${relPath} at ${url}`);
897
953
  }
@@ -899,28 +955,587 @@ hyperbook.typst = (function () {
899
955
  console.warn(`Error loading asset ${relPath}:`, error);
900
956
  }
901
957
  }
958
+ }
902
959
 
903
- // Create ZIP using UZIP
904
- const zipData = UZIP.encode(zipFiles);
905
- const zipBlob = new Blob([zipData], { type: "application/zip" });
906
- const link = document.createElement("a");
960
+ /**
961
+ * Create and download ZIP file
962
+ * @param {Object} zipFiles - ZIP files object
963
+ * @param {string} id - Project ID
964
+ */
965
+ downloadZip(zipFiles, id) {
966
+ if (typeof window.UZIP === 'undefined') {
967
+ throw new Error('UZIP library not loaded');
968
+ }
969
+
970
+ const zipData = window.UZIP.encode(zipFiles);
971
+ const zipBlob = new Blob([zipData], { type: 'application/zip' });
972
+ const link = document.createElement('a');
907
973
  link.href = URL.createObjectURL(zipBlob);
908
974
  link.download = `typst-project-${id}.zip`;
909
975
  link.click();
910
976
  URL.revokeObjectURL(link.href);
911
- });
977
+ }
978
+ }
979
+
980
+ // ============================================================================
981
+ // UI MANAGER
982
+ // ============================================================================
983
+
984
+ class UIManager {
985
+ constructor(elem, fileManager, binaryFiles) {
986
+ this.elem = elem;
987
+ this.fileManager = fileManager;
988
+ this.binaryFiles = binaryFiles;
989
+ this.tabsList = elem.querySelector('.tabs-list');
990
+ this.binaryFilesList = elem.querySelector('.binary-files-list');
991
+ }
992
+
993
+ /**
994
+ * Update tabs UI
995
+ */
996
+ updateTabs() {
997
+ if (!this.tabsList) return;
998
+
999
+ this.tabsList.innerHTML = '';
1000
+
1001
+ this.fileManager.sourceFiles.forEach((file) => {
1002
+ const tab = this.createTab(file);
1003
+ this.tabsList.appendChild(tab);
1004
+ });
1005
+ }
1006
+
1007
+ /**
1008
+ * Create tab element for file
1009
+ * @param {Object} file - File object
1010
+ * @returns {HTMLElement} Tab element
1011
+ */
1012
+ createTab(file) {
1013
+ const tab = document.createElement('div');
1014
+ tab.className = 'file-tab';
1015
+
1016
+ if (file.filename === this.fileManager.currentFile.filename) {
1017
+ tab.classList.add('active');
1018
+ }
1019
+
1020
+ // Tab name
1021
+ const tabName = document.createElement('span');
1022
+ tabName.className = 'tab-name';
1023
+ tabName.textContent = file.filename;
1024
+ tab.appendChild(tabName);
1025
+
1026
+ // Delete button (except for main file)
1027
+ if (file.filename !== 'main.typ' && file.filename !== 'main.typst') {
1028
+ const deleteBtn = this.createDeleteButton(file);
1029
+ tab.appendChild(deleteBtn);
1030
+ }
1031
+
1032
+ // Click handler
1033
+ tab.addEventListener('click', () => this.handleTabClick(file));
1034
+
1035
+ return tab;
1036
+ }
1037
+
1038
+ /**
1039
+ * Create delete button for tab
1040
+ * @param {Object} file - File object
1041
+ * @returns {HTMLElement} Delete button
1042
+ */
1043
+ createDeleteButton(file) {
1044
+ const deleteBtn = document.createElement('button');
1045
+ deleteBtn.className = 'tab-delete';
1046
+ deleteBtn.textContent = '×';
1047
+ deleteBtn.title = i18nGet('typst-delete-file', 'Delete file');
1048
+
1049
+ deleteBtn.addEventListener('click', (e) => {
1050
+ e.stopPropagation();
1051
+ this.handleDeleteFile(file.filename);
1052
+ });
1053
+
1054
+ return deleteBtn;
1055
+ }
1056
+
1057
+ /**
1058
+ * Handle tab click
1059
+ * @param {Object} file - File object
1060
+ */
1061
+ handleTabClick(file) {
1062
+ if (this.fileManager.currentFile.filename !== file.filename) {
1063
+ this.onFileSwitch?.(file.filename);
1064
+ }
1065
+ }
1066
+
1067
+ /**
1068
+ * Handle file deletion
1069
+ * @param {string} filename - Filename to delete
1070
+ */
1071
+ handleDeleteFile(filename) {
1072
+ const confirmMsg = `${i18nGet('typst-delete-confirm', 'Delete')} ${filename}?`;
1073
+
1074
+ if (confirm(confirmMsg)) {
1075
+ this.fileManager.deleteFile(filename);
1076
+ this.updateTabs();
1077
+ this.onFilesChange?.();
1078
+ }
1079
+ }
1080
+
1081
+ /**
1082
+ * Update binary files list UI
1083
+ */
1084
+ updateBinaryFilesList() {
1085
+ if (!this.binaryFilesList) return;
1086
+
1087
+ this.binaryFilesList.innerHTML = '';
1088
+
1089
+ if (this.binaryFiles.length === 0) {
1090
+ const emptyMsg = document.createElement('div');
1091
+ emptyMsg.className = 'binary-files-empty';
1092
+ emptyMsg.textContent = i18nGet('typst-no-binary-files', 'No binary files');
1093
+ this.binaryFilesList.appendChild(emptyMsg);
1094
+ return;
1095
+ }
912
1096
 
913
- // Reset button (edit mode only)
914
- resetBtn?.addEventListener("click", async () => {
915
- if (
916
- window.confirm(
917
- i18n.get("typst-reset-prompt") ||
918
- "Are you sure you want to reset the code?",
919
- )
920
- ) {
921
- store.typst?.delete(id);
1097
+ this.binaryFiles.forEach((file) => {
1098
+ const item = this.createBinaryFileItem(file);
1099
+ this.binaryFilesList.appendChild(item);
1100
+ });
1101
+ }
1102
+
1103
+ /**
1104
+ * Create binary file list item
1105
+ * @param {Object} file - Binary file object
1106
+ * @returns {HTMLElement} List item
1107
+ */
1108
+ createBinaryFileItem(file) {
1109
+ const item = document.createElement('div');
1110
+ item.className = 'binary-file-item';
1111
+
1112
+ const icon = document.createElement('span');
1113
+ icon.className = 'binary-file-icon';
1114
+ icon.textContent = '📎';
1115
+ item.appendChild(icon);
1116
+
1117
+ const name = document.createElement('span');
1118
+ name.className = 'binary-file-name';
1119
+ name.textContent = file.dest;
1120
+ item.appendChild(name);
1121
+
1122
+ const deleteBtn = document.createElement('button');
1123
+ deleteBtn.className = 'binary-file-delete';
1124
+ deleteBtn.textContent = '×';
1125
+ deleteBtn.title = i18nGet('typst-delete-file', 'Delete file');
1126
+ deleteBtn.addEventListener('click', () => this.handleDeleteBinaryFile(file.dest));
1127
+ item.appendChild(deleteBtn);
1128
+
1129
+ return item;
1130
+ }
1131
+
1132
+ /**
1133
+ * Handle binary file deletion
1134
+ * @param {string} dest - File destination path
1135
+ */
1136
+ handleDeleteBinaryFile(dest) {
1137
+ const confirmMsg = `${i18nGet('typst-delete-confirm', 'Delete')} ${dest}?`;
1138
+
1139
+ if (confirm(confirmMsg)) {
1140
+ this.binaryFiles = this.binaryFiles.filter((f) => f.dest !== dest);
1141
+ this.updateBinaryFilesList();
1142
+ this.onBinaryFilesChange?.(this.binaryFiles);
1143
+ }
1144
+ }
1145
+ }
1146
+
1147
+ // ============================================================================
1148
+ // TYPST EDITOR
1149
+ // ============================================================================
1150
+
1151
+ class TypstEditor {
1152
+ constructor({
1153
+ elem,
1154
+ id,
1155
+ sourceFiles,
1156
+ binaryFiles,
1157
+ fontFiles,
1158
+ basePath,
1159
+ pagePath,
1160
+ renderer,
1161
+ exporter,
1162
+ }) {
1163
+ this.elem = elem;
1164
+ this.id = id;
1165
+ this.fontFiles = fontFiles;
1166
+ this.basePath = normalizePath(basePath);
1167
+ this.pagePath = normalizePath(pagePath);
1168
+ this.renderer = renderer;
1169
+ this.exporter = exporter;
1170
+
1171
+ // Initialize managers
1172
+ this.fileManager = new FileManager(sourceFiles);
1173
+ this.binaryFiles = binaryFiles;
1174
+ this.uiManager = new UIManager(elem, this.fileManager, this.binaryFiles);
1175
+
1176
+ // Get DOM elements
1177
+ this.previewContainer = elem.querySelector('.preview-container');
1178
+ this.preview = elem.querySelector('.typst-preview');
1179
+ this.loadingIndicator = elem.querySelector('.typst-loading');
1180
+ this.editor = elem.querySelector('.editor.typst');
1181
+ this.sourceTextarea = elem.querySelector('.typst-source');
1182
+
1183
+ // Setup UI callbacks
1184
+ this.setupUICallbacks();
1185
+
1186
+ // Setup event handlers
1187
+ this.setupEventHandlers();
1188
+
1189
+ // Initialize
1190
+ this.initialize();
1191
+ }
1192
+
1193
+ /**
1194
+ * Setup UI manager callbacks
1195
+ */
1196
+ setupUICallbacks() {
1197
+ this.uiManager.onFileSwitch = (filename) => this.handleFileSwitch(filename);
1198
+ this.uiManager.onFilesChange = () => this.handleFilesChange();
1199
+ this.uiManager.onBinaryFilesChange = (binaryFiles) => this.handleBinaryFilesChange(binaryFiles);
1200
+ }
1201
+
1202
+ /**
1203
+ * Setup event handlers
1204
+ */
1205
+ setupEventHandlers() {
1206
+ const downloadBtn = this.elem.querySelector('.download-pdf');
1207
+ const downloadProjectBtn = this.elem.querySelector('.download-project');
1208
+ const resetBtn = this.elem.querySelector('.reset');
1209
+ const addSourceFileBtn = this.elem.querySelector('.add-source-file');
1210
+ const addBinaryFileBtn = this.elem.querySelector('.add-binary-file');
1211
+
1212
+ downloadBtn?.addEventListener('click', () => this.handleExportPdf());
1213
+ downloadProjectBtn?.addEventListener('click', () => this.handleExportProject());
1214
+ resetBtn?.addEventListener('click', () => this.handleReset());
1215
+ addSourceFileBtn?.addEventListener('click', () => this.handleAddSourceFile());
1216
+ addBinaryFileBtn?.addEventListener('click', (e) => this.handleAddBinaryFile(e));
1217
+ }
1218
+
1219
+ /**
1220
+ * Initialize editor
1221
+ */
1222
+ async initialize() {
1223
+ if (this.editor) {
1224
+ // Edit mode - wait for code-input to load
1225
+ this.editor.addEventListener('code-input_load', async () => {
1226
+ await this.restoreState();
1227
+ this.uiManager.updateTabs();
1228
+ this.uiManager.updateBinaryFilesList();
1229
+ this.rerender();
1230
+
1231
+ // Create debounced rerender for input events
1232
+ const debouncedRerender = debounce(() => this.rerender(), CONFIG.DEBOUNCE_DELAY);
1233
+
1234
+ this.editor.addEventListener('input', () => {
1235
+ this.saveState();
1236
+ debouncedRerender();
1237
+ });
1238
+ });
1239
+ } else if (this.sourceTextarea) {
1240
+ // Preview mode
1241
+ const initialCode = this.sourceTextarea.value;
1242
+ await this.renderer.render({
1243
+ code: initialCode,
1244
+ container: this.preview,
1245
+ loadingIndicator: this.loadingIndicator,
1246
+ sourceFiles: this.fileManager.sourceFiles,
1247
+ binaryFiles: this.binaryFiles,
1248
+ fontFiles: this.fontFiles,
1249
+ id: this.id,
1250
+ previewContainer: this.previewContainer,
1251
+ basePath: this.basePath,
1252
+ pagePath: this.pagePath,
1253
+ });
1254
+ }
1255
+ }
1256
+
1257
+ /**
1258
+ * Restore state from storage
1259
+ */
1260
+ async restoreState() {
1261
+ const result = await window.store?.typst?.get(this.id);
1262
+
1263
+ if (result) {
1264
+ this.editor.value = result.code;
1265
+
1266
+ if (result.sourceFiles) {
1267
+ this.fileManager.sourceFiles = result.sourceFiles;
1268
+ result.sourceFiles.forEach((f) =>
1269
+ this.fileManager.contents.set(f.filename, f.content)
1270
+ );
1271
+ }
1272
+
1273
+ if (result.binaryFiles) {
1274
+ this.binaryFiles = result.binaryFiles;
1275
+ }
1276
+
1277
+ if (result.currentFile) {
1278
+ const file = this.fileManager.sourceFiles.find(
1279
+ (f) => f.filename === result.currentFile
1280
+ );
1281
+ if (file) {
1282
+ this.fileManager.currentFile = file;
1283
+ this.editor.value = this.fileManager.getCurrentContent();
1284
+ }
1285
+ }
1286
+ } else {
1287
+ this.editor.value = this.fileManager.getCurrentContent();
1288
+ }
1289
+ }
1290
+
1291
+ /**
1292
+ * Save state to storage
1293
+ */
1294
+ async saveState() {
1295
+ if (!this.editor) return;
1296
+
1297
+ this.fileManager.updateCurrentContent(this.editor.value);
1298
+
1299
+ await window.store?.typst?.put({
1300
+ id: this.id,
1301
+ code: this.editor.value,
1302
+ sourceFiles: this.fileManager.getSourceFiles(),
1303
+ binaryFiles: this.binaryFiles,
1304
+ currentFile: this.fileManager.currentFile.filename,
1305
+ });
1306
+ }
1307
+
1308
+ /**
1309
+ * Rerender Typst preview
1310
+ */
1311
+ rerender() {
1312
+ if (!this.editor) return;
1313
+
1314
+ this.fileManager.updateCurrentContent(this.editor.value);
1315
+
1316
+ const mainFile = this.fileManager.findMainFile();
1317
+ const mainCode = mainFile
1318
+ ? this.fileManager.contents.get(mainFile.filename) || mainFile.content
1319
+ : '';
1320
+
1321
+ this.renderer.render({
1322
+ code: mainCode,
1323
+ container: this.preview,
1324
+ loadingIndicator: this.loadingIndicator,
1325
+ sourceFiles: this.fileManager.getSourceFiles(),
1326
+ binaryFiles: this.binaryFiles,
1327
+ fontFiles: this.fontFiles,
1328
+ id: this.id,
1329
+ previewContainer: this.previewContainer,
1330
+ basePath: this.basePath,
1331
+ pagePath: this.pagePath,
1332
+ });
1333
+ }
1334
+
1335
+ /**
1336
+ * Handle file switch
1337
+ * @param {string} filename - Target filename
1338
+ */
1339
+ handleFileSwitch(filename) {
1340
+ if (this.editor) {
1341
+ this.fileManager.updateCurrentContent(this.editor.value);
1342
+ const content = this.fileManager.switchTo(filename);
1343
+ this.editor.value = content;
1344
+ this.uiManager.updateTabs();
1345
+ this.saveState();
1346
+ }
1347
+ }
1348
+
1349
+ /**
1350
+ * Handle files change
1351
+ */
1352
+ handleFilesChange() {
1353
+ if (this.editor) {
1354
+ this.editor.value = this.fileManager.getCurrentContent();
1355
+ }
1356
+ this.saveState();
1357
+ this.rerender();
1358
+ }
1359
+
1360
+ /**
1361
+ * Handle binary files change
1362
+ * @param {Array} binaryFiles - Updated binary files array
1363
+ */
1364
+ handleBinaryFilesChange(binaryFiles) {
1365
+ this.binaryFiles = binaryFiles;
1366
+ this.saveState();
1367
+ this.rerender();
1368
+ }
1369
+
1370
+ /**
1371
+ * Handle add source file
1372
+ */
1373
+ handleAddSourceFile() {
1374
+ const filename = prompt(
1375
+ i18nGet('typst-filename-prompt', 'Enter filename (e.g., helper.typ):')
1376
+ );
1377
+
1378
+ if (!filename) return;
1379
+
1380
+ // Validate filename
1381
+ if (!filename.endsWith('.typ') && !filename.endsWith('.typst')) {
1382
+ alert(i18nGet('typst-filename-error', 'Filename must end with .typ or .typst'));
1383
+ return;
1384
+ }
1385
+
1386
+ if (!this.fileManager.addFile(filename)) {
1387
+ alert(i18nGet('typst-filename-exists', 'File already exists'));
1388
+ return;
1389
+ }
1390
+
1391
+ if (this.editor) {
1392
+ this.editor.value = this.fileManager.getCurrentContent();
1393
+ }
1394
+
1395
+ this.uiManager.updateTabs();
1396
+ this.saveState();
1397
+ this.rerender();
1398
+ }
1399
+
1400
+ /**
1401
+ * Handle add binary file
1402
+ * @param {Event} e - Click event
1403
+ */
1404
+ handleAddBinaryFile(e) {
1405
+ e.preventDefault();
1406
+ e.stopPropagation();
1407
+
1408
+ const input = document.createElement('input');
1409
+ input.type = 'file';
1410
+ input.accept = 'image/*,.pdf';
1411
+
1412
+ input.addEventListener('change', async (e) => {
1413
+ const file = e.target.files[0];
1414
+ if (!file) return;
1415
+
1416
+ const dest = `/${file.name}`;
1417
+
1418
+ // Check if file already exists
1419
+ if (this.binaryFiles.some((f) => f.dest === dest)) {
1420
+ if (!confirm(i18nGet('typst-file-replace', `Replace existing ${dest}?`))) {
1421
+ return;
1422
+ }
1423
+ this.binaryFiles = this.binaryFiles.filter((f) => f.dest !== dest);
1424
+ }
1425
+
1426
+ // Read file as data URL
1427
+ const reader = new FileReader();
1428
+ reader.onload = async (e) => {
1429
+ const url = e.target.result;
1430
+ this.binaryFiles.push({ dest, url });
1431
+ this.uiManager.updateBinaryFilesList();
1432
+ this.saveState();
1433
+ this.rerender();
1434
+ };
1435
+ reader.readAsDataURL(file);
1436
+ });
1437
+
1438
+ input.click();
1439
+ }
1440
+
1441
+ /**
1442
+ * Handle PDF export
1443
+ */
1444
+ async handleExportPdf() {
1445
+ const mainFile = this.fileManager.findMainFile();
1446
+ const code = mainFile ? mainFile.content : (this.editor ? this.editor.value : '');
1447
+
1448
+ await this.renderer.exportPdf({
1449
+ code,
1450
+ id: this.id,
1451
+ sourceFiles: this.fileManager.getSourceFiles(),
1452
+ binaryFiles: this.binaryFiles,
1453
+ fontFiles: this.fontFiles,
1454
+ basePath: this.basePath,
1455
+ pagePath: this.pagePath,
1456
+ });
1457
+ }
1458
+
1459
+ /**
1460
+ * Handle project export
1461
+ */
1462
+ async handleExportProject() {
1463
+ const mainFile = this.fileManager.findMainFile();
1464
+ const code = mainFile ? mainFile.content : (this.editor ? this.editor.value : '');
1465
+
1466
+ await this.exporter.export({
1467
+ code,
1468
+ id: this.id,
1469
+ sourceFiles: this.fileManager.getSourceFiles(),
1470
+ binaryFiles: this.binaryFiles,
1471
+ basePath: this.basePath,
1472
+ pagePath: this.pagePath,
1473
+ });
1474
+ }
1475
+
1476
+ /**
1477
+ * Handle reset
1478
+ */
1479
+ async handleReset() {
1480
+ const confirmMsg = i18nGet(
1481
+ 'typst-reset-prompt',
1482
+ 'Are you sure you want to reset the code?'
1483
+ );
1484
+
1485
+ if (confirm(confirmMsg)) {
1486
+ await window.store?.typst?.delete(this.id);
922
1487
  window.location.reload();
923
1488
  }
1489
+ }
1490
+ }
1491
+
1492
+ // ============================================================================
1493
+ // MAIN INITIALIZATION
1494
+ // ============================================================================
1495
+
1496
+ // Initialize code-input
1497
+ initializeCodeInput();
1498
+
1499
+ // Get all Typst directive elements
1500
+ const elements = document.getElementsByClassName('directive-typst');
1501
+
1502
+ // Create shared instances
1503
+ const typstLoader = new TypstLoader();
1504
+ const renderQueue = new RenderQueue();
1505
+ const assetManager = new AssetManager();
1506
+ const renderer = new TypstRenderer(typstLoader, assetManager, renderQueue);
1507
+ const exporter = new ProjectExporter(assetManager);
1508
+
1509
+ // Initialize each Typst element
1510
+ for (const elem of elements) {
1511
+ const id = elem.getAttribute('data-id');
1512
+ const sourceFilesData = elem.getAttribute('data-source-files');
1513
+ const binaryFilesData = elem.getAttribute('data-binary-files');
1514
+ const fontFilesData = elem.getAttribute('data-font-files');
1515
+ const basePath = elem.getAttribute('data-base-path') || '';
1516
+ const pagePath = elem.getAttribute('data-page-path') || '';
1517
+
1518
+ // Decode base64 with proper UTF-8 handling
1519
+ const decodeBase64 = (str) => {
1520
+ const binaryStr = atob(str);
1521
+ const bytes = Uint8Array.from(binaryStr, (c) => c.charCodeAt(0));
1522
+ return new TextDecoder('utf-8').decode(bytes);
1523
+ };
1524
+
1525
+ const sourceFiles = sourceFilesData ? JSON.parse(decodeBase64(sourceFilesData)) : [];
1526
+ const binaryFiles = binaryFilesData ? JSON.parse(decodeBase64(binaryFilesData)) : [];
1527
+ const fontFiles = fontFilesData ? JSON.parse(decodeBase64(fontFilesData)) : [];
1528
+
1529
+ new TypstEditor({
1530
+ elem,
1531
+ id,
1532
+ sourceFiles,
1533
+ binaryFiles,
1534
+ fontFiles,
1535
+ basePath,
1536
+ pagePath,
1537
+ renderer,
1538
+ exporter,
924
1539
  });
925
1540
  }
926
1541