hyperbook 0.80.0 → 0.81.0

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