hyperbook 0.79.1 → 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,760 +47,1361 @@ 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
- );
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
+ };
23
59
 
24
- const elems = document.getElementsByClassName("directive-typst");
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
+ };
25
73
 
26
- // Typst WASM module URLs
27
- const TYPST_COMPILER_URL = "https://cdn.jsdelivr.net/npm/@myriaddreamin/typst-ts-web-compiler/pkg/typst_ts_web_compiler_bg.wasm";
28
- const TYPST_RENDERER_URL = "https://cdn.jsdelivr.net/npm/@myriaddreamin/typst-ts-renderer/pkg/typst_ts_renderer_bg.wasm";
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;
82
+ };
29
83
 
30
- // Load typst all-in-one bundle
31
- let typstLoaded = false;
32
- let typstLoadPromise = null;
84
+ // ============================================================================
85
+ // INITIALIZATION
86
+ // ============================================================================
33
87
 
34
- // Rendering queue to ensure only one render at a time
35
- let renderQueue = Promise.resolve();
88
+ /**
89
+ * Initialize code-input template for Typst syntax highlighting
90
+ */
91
+ const initializeCodeInput = () => {
92
+ if (!window.codeInput) return;
36
93
 
37
- const queueRender = (renderFn) => {
38
- renderQueue = renderQueue.then(renderFn).catch((error) => {
39
- console.error("Queued render error:", error);
40
- });
41
- return renderQueue;
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
+ );
42
101
  };
43
102
 
44
- const loadTypst = () => {
45
- if (typstLoaded) {
46
- return Promise.resolve();
47
- }
48
- if (typstLoadPromise) {
49
- return typstLoadPromise;
50
- }
51
-
52
- typstLoadPromise = new Promise((resolve, reject) => {
53
- const script = document.createElement("script");
54
- script.src = "https://cdn.jsdelivr.net/npm/@myriaddreamin/typst.ts/dist/esm/contrib/all-in-one-lite.bundle.js";
55
- script.type = "module";
56
- script.id = "typst-loader";
57
- script.onload = () => {
58
- // Wait a bit for the module to initialize
59
- const checkTypst = () => {
60
- if (typeof $typst !== "undefined") {
61
- // Initialize the Typst compiler and renderer
62
- $typst.setCompilerInitOptions({
63
- getModule: () => TYPST_COMPILER_URL,
64
- });
65
- $typst.setRendererInitOptions({
66
- getModule: () => TYPST_RENDERER_URL,
67
- });
68
- typstLoaded = true;
69
- resolve();
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
+ }
70
166
  } else {
71
- setTimeout(checkTypst, 50);
167
+ setTimeout(checkTypst, CONFIG.TYPST_CHECK_INTERVAL);
72
168
  }
73
169
  };
74
170
  checkTypst();
75
- };
76
- script.onerror = reject;
77
- document.head.appendChild(script);
78
- });
171
+ });
172
+ }
79
173
 
80
- return typstLoadPromise;
81
- };
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
+ );
82
183
 
83
- // Asset cache for server-loaded images
84
- const assetCache = new Map(); // filepath -> Uint8Array
85
-
86
- // Extract relative image paths from typst source
87
- const extractRelImagePaths = (src) => {
88
- const paths = new Set();
89
- const re = /image\s*\(\s*(['"])([^'"]+)\1/gi;
90
- let m;
91
- while ((m = re.exec(src))) {
92
- const p = m[2];
93
- // Skip absolute URLs, data URLs, blob URLs, and paths starting with "/"
94
- if (/^(https?:|data:|blob:|\/)/i.test(p)) continue;
95
- paths.add(p);
96
- }
97
- return [...paths];
98
- };
184
+ window.$typst.setCompilerInitOptions({
185
+ beforeBuild: [fonts],
186
+ getModule: () => CONFIG.TYPST_COMPILER_URL,
187
+ });
99
188
 
100
- // Fetch assets from server using base path
101
- const fetchAssets = async (paths, basePath) => {
102
- const misses = paths.filter(p => !assetCache.has(p));
103
- await Promise.all(misses.map(async (p) => {
104
- try {
105
- // Construct URL using base path
106
- const url = basePath ? `${basePath}/${p}`.replace(/\/+/g, '/') : p;
107
- const res = await fetch(url);
108
- if (!res.ok) {
109
- console.warn(`Image not found: ${p} at ${url} (HTTP ${res.status})`);
110
- assetCache.set(p, null); // Mark as failed
111
- return;
189
+ window.$typst.setRendererInitOptions({
190
+ beforeBuild: [fonts],
191
+ getModule: () => CONFIG.TYPST_RENDERER_URL,
192
+ });
193
+ }
194
+ }
195
+
196
+ // ============================================================================
197
+ // RENDER QUEUE
198
+ // ============================================================================
199
+
200
+ class RenderQueue {
201
+ constructor() {
202
+ this.queue = Promise.resolve();
203
+ }
204
+
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
+ }
219
+
220
+ // ============================================================================
221
+ // ASSET MANAGEMENT
222
+ // ============================================================================
223
+
224
+ class AssetManager {
225
+ constructor() {
226
+ this.cache = new Map(); // filepath -> Uint8Array | null
227
+ }
228
+
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
+ );
239
+
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);
112
250
  }
113
- const buf = await res.arrayBuffer();
114
- assetCache.set(p, new Uint8Array(buf));
115
- } catch (error) {
116
- console.warn(`Error loading image ${p}:`, error);
117
- assetCache.set(p, null); // Mark as failed
118
251
  }
119
- }));
120
- };
121
252
 
122
- // Build typst preamble with inlined assets
123
- const buildAssetsPreamble = () => {
124
- if (assetCache.size === 0) return '';
125
- const entries = [...assetCache.entries()]
126
- .filter(([name, u8]) => u8 !== null) // Skip failed images
127
- .map(([name, u8]) => {
128
- const nums = Array.from(u8).join(',');
129
- return ` "${name}": bytes((${nums}))`;
130
- }).join(',\n');
131
- if (!entries) return '';
132
- return `#let __assets = (\n${entries}\n)\n\n`;
133
- };
253
+ return Array.from(paths);
254
+ }
255
+
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);
134
267
 
135
- // Rewrite image() calls to use inlined assets
136
- const rewriteImageCalls = (src) => {
137
- if (assetCache.size === 0) return src;
138
- return src.replace(/image\s*\(\s*(['"])([^'"]+)\1/g, (m, q, fname) => {
139
- if (assetCache.has(fname)) {
140
- const asset = assetCache.get(fname);
141
- if (asset === null) {
142
- // Image not found – replace with error text
143
- return `[Image not found: _${fname}_]`;
268
+ if (!response.ok) {
269
+ console.warn(`Asset not found: ${path} at ${url} (HTTP ${response.status})`);
270
+ return null;
144
271
  }
145
- return `image(__assets.at("${fname}")`;
272
+
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;
146
278
  }
147
- return m;
148
- });
149
- };
279
+ }
150
280
 
151
- // Prepare typst source with server-loaded assets
152
- const prepareTypstSourceWithAssets = async (src, basePath) => {
153
- const relPaths = extractRelImagePaths(src);
154
- if (relPaths.length > 0) {
155
- await fetchAssets(relPaths, basePath);
156
- const preamble = buildAssetsPreamble();
157
- return preamble + rewriteImageCalls(src);
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
+ );
158
297
  }
159
- return src;
160
- };
161
298
 
162
- // Parse error message from SourceDiagnostic format
163
- const parseTypstError = (errorMessage) => {
164
- try {
165
- // Try to extract message from SourceDiagnostic format
166
- const match = errorMessage.match(/message:\s*"([^"]+)"/);
167
- if (match) {
168
- return match[1];
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);
307
+ }
169
308
  }
170
- } catch (e) {
171
- // Fallback to original message
172
309
  }
173
- return errorMessage;
174
- };
175
310
 
176
- // Render typst code to SVG
177
- const renderTypst = async (code, container, loadingIndicator, sourceFiles, binaryFiles, id, previewContainer, basePath) => {
178
- // Queue this render to ensure only one compilation runs at a time
179
- return queueRender(async () => {
180
- // Show loading indicator
181
- if (loadingIndicator) {
182
- loadingIndicator.style.display = "flex";
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));
183
328
  }
184
329
 
185
- await loadTypst();
330
+ const paths = Array.from(allPaths);
186
331
 
187
- try {
188
- // Reset shadow files for this render
189
- $typst.resetShadow();
332
+ if (paths.length > 0) {
333
+ await this.fetchAssets(paths, basePath, pagePath);
334
+ this.mapToShadow();
335
+ }
336
+ }
337
+ }
190
338
 
191
- // Prepare code with server-loaded assets
192
- const preparedCode = await prepareTypstSourceWithAssets(code, basePath);
339
+ // ============================================================================
340
+ // ERROR HANDLING
341
+ // ============================================================================
193
342
 
194
- // Add source files
195
- for (const { filename, content } of sourceFiles) {
196
- const path = filename.startsWith('/') ? filename.substring(1) : filename;
197
- await $typst.addSource(`/${path}`, content);
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];
198
356
  }
357
+ } catch (e) {
358
+ // Fallback to original message
359
+ }
360
+
361
+ return errorMessage;
362
+ }
199
363
 
200
- // Add binary files
201
- for (const { dest, url } of binaryFiles) {
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
+ }
375
+
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
+ }
395
+
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
+ }
405
+
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
+ }
425
+
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 }) => {
202
434
  try {
203
- let arrayBuffer;
204
-
205
- // Check if URL is a data URL (user-uploaded file)
206
- if (url.startsWith('data:')) {
207
- const response = await fetch(url);
208
- arrayBuffer = await response.arrayBuffer();
209
- } else {
210
- // External URL
211
- const response = await fetch(url);
212
- if (!response.ok) {
213
- console.warn(`Failed to load binary file: ${url}`);
214
- continue;
215
- }
216
- arrayBuffer = await response.arrayBuffer();
217
- }
218
-
435
+ const arrayBuffer = await BinaryFileHandler.load(url);
219
436
  const path = dest.startsWith('/') ? dest.substring(1) : dest;
220
- $typst.mapShadow(`/${path}`, new Uint8Array(arrayBuffer));
437
+ window.$typst.mapShadow(`/${path}`, new Uint8Array(arrayBuffer));
221
438
  } catch (error) {
222
439
  console.warn(`Error loading binary file ${url}:`, error);
223
440
  }
224
- }
441
+ })
442
+ );
443
+ }
444
+ }
225
445
 
226
- const svg = await $typst.svg({ mainContent: preparedCode });
227
-
228
- // Remove any existing error overlay from preview-container
229
- if (previewContainer) {
230
- const existingError = previewContainer.querySelector('.typst-error-overlay');
231
- if (existingError) {
232
- existingError.remove();
446
+ // ============================================================================
447
+ // TYPST RENDERER
448
+ // ============================================================================
449
+
450
+ class TypstRenderer {
451
+ constructor(loader, assetManager, renderQueue) {
452
+ this.loader = loader;
453
+ this.assetManager = assetManager;
454
+ this.renderQueue = renderQueue;
455
+ }
456
+
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
+ }
468
+
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
+ }
486
+
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';
233
509
  }
234
- }
235
-
236
- container.innerHTML = svg;
237
-
238
- // Scale SVG to fit container
239
- const svgElem = container.firstElementChild;
240
- if (svgElem) {
241
- const width = Number.parseFloat(svgElem.getAttribute("width"));
242
- const height = Number.parseFloat(svgElem.getAttribute("height"));
243
- const containerWidth = container.clientWidth - 20;
244
- if (width > 0 && containerWidth > 0) {
245
- svgElem.setAttribute("width", containerWidth);
246
- svgElem.setAttribute("height", (height * containerWidth) / width);
510
+
511
+ await this.loader.load({ fontFiles });
512
+
513
+ // Reset shadow files
514
+ window.$typst.resetShadow();
515
+
516
+ // Prepare assets
517
+ await this.assetManager.prepare(code, sourceFiles, basePath, pagePath);
518
+
519
+ // Add source files
520
+ await this.addSourceFiles(sourceFiles);
521
+
522
+ // Add binary files
523
+ await BinaryFileHandler.addToShadow(binaryFiles);
524
+
525
+ // Render to SVG
526
+ const svg = await window.$typst.svg({ mainContent: code });
527
+
528
+ // Clear any existing errors
529
+ if (previewContainer) {
530
+ const existingError = previewContainer.querySelector('.typst-error-overlay');
531
+ if (existingError) {
532
+ existingError.remove();
533
+ }
247
534
  }
248
- }
249
- } catch (error) {
250
- const errorText = parseTypstError(error || "Error rendering Typst");
251
-
252
- // Check if we have existing content (previous successful render)
253
- const hasExistingContent = container.querySelector('svg') !== null;
254
-
255
- // Always use error overlay in preview-container if available
256
- if (previewContainer) {
257
- // Remove any existing error overlay
258
- const existingError = previewContainer.querySelector('.typst-error-overlay');
259
- if (existingError) {
260
- existingError.remove();
535
+
536
+ // Update container with SVG
537
+ container.innerHTML = svg;
538
+ this.scaleSvg(container);
539
+
540
+ } catch (error) {
541
+ const errorText = ErrorHandler.parse(error);
542
+ const hasExistingContent = container.querySelector('svg') !== null;
543
+
544
+ if (previewContainer) {
545
+ // Don't clear existing content on error
546
+ if (!hasExistingContent) {
547
+ container.innerHTML = '';
548
+ }
549
+ ErrorHandler.showOverlay(previewContainer, errorText);
550
+ } else {
551
+ ErrorHandler.showInline(container, errorText);
261
552
  }
262
-
263
- // Clear preview if no existing content
264
- if (!hasExistingContent) {
265
- container.innerHTML = '';
553
+
554
+ } finally {
555
+ // Hide loading indicator
556
+ if (loadingIndicator) {
557
+ loadingIndicator.style.display = 'none';
266
558
  }
267
-
268
- // Create floating error overlay in preview-container
269
- const errorOverlay = document.createElement('div');
270
- errorOverlay.className = 'typst-error-overlay';
271
- errorOverlay.innerHTML = `
272
- <div class="typst-error-content">
273
- <div class="typst-error-header">
274
- <span class="typst-error-title">⚠️ Typst Error</span>
275
- <button class="typst-error-close" title="Dismiss error">×</button>
276
- </div>
277
- <div class="typst-error-message">${errorText}</div>
278
- </div>
279
- `;
280
-
281
- // Add close button functionality
282
- const closeBtn = errorOverlay.querySelector('.typst-error-close');
283
- closeBtn.addEventListener('click', () => {
284
- errorOverlay.remove();
285
- });
286
-
287
- previewContainer.appendChild(errorOverlay);
288
- } else {
289
- // Fallback: show error in preview container directly
290
- container.innerHTML = `<div class="typst-error">${errorText}</div>`;
291
- }
292
- } finally {
293
- // Hide loading indicator
294
- if (loadingIndicator) {
295
- loadingIndicator.style.display = "none";
296
559
  }
297
- }
298
- });
299
- };
560
+ });
561
+ }
300
562
 
301
- // Export to PDF
302
- const exportPdf = async (code, id, sourceFiles, binaryFiles, basePath) => {
303
- // Queue this export to ensure only one compilation runs at a time
304
- return queueRender(async () => {
305
- await loadTypst();
306
-
307
- try {
308
- // Reset shadow files for this export
309
- $typst.resetShadow();
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 });
310
580
 
311
- // Prepare code with server-loaded assets
312
- const preparedCode = await prepareTypstSourceWithAssets(code, basePath);
581
+ // Reset shadow files
582
+ window.$typst.resetShadow();
313
583
 
314
- // Add source files
315
- for (const { filename, content } of sourceFiles) {
316
- const path = filename.startsWith('/') ? filename.substring(1) : filename;
317
- await $typst.addSource(`/${path}`, content);
318
- }
584
+ // Prepare assets
585
+ await this.assetManager.prepare(code, sourceFiles, basePath, pagePath);
319
586
 
320
- // Add binary files
321
- for (const { dest, url } of binaryFiles) {
322
- try {
323
- let arrayBuffer;
324
-
325
- // Check if URL is a data URL (user-uploaded file)
326
- if (url.startsWith('data:')) {
327
- const response = await fetch(url);
328
- arrayBuffer = await response.arrayBuffer();
329
- } else {
330
- // External URL
331
- const response = await fetch(url);
332
- if (!response.ok) {
333
- continue;
334
- }
335
- arrayBuffer = await response.arrayBuffer();
336
- }
337
-
338
- const path = dest.startsWith('/') ? dest.substring(1) : dest;
339
- $typst.mapShadow(`/${path}`, new Uint8Array(arrayBuffer));
340
- } catch (error) {
341
- console.warn(`Error loading binary file ${url}:`, error);
342
- }
343
- }
587
+ // Add source files
588
+ await this.addSourceFiles(sourceFiles);
344
589
 
345
- const pdfData = await $typst.pdf({ mainContent: preparedCode });
346
- const pdfFile = new Blob([pdfData], { type: "application/pdf" });
347
- const link = document.createElement("a");
348
- link.href = URL.createObjectURL(pdfFile);
349
- link.download = `typst-${id}.pdf`;
350
- link.click();
351
- URL.revokeObjectURL(link.href);
352
- } catch (error) {
353
- console.error("PDF export error:", error);
354
- alert(i18n.get("typst-pdf-error") || "Error exporting PDF");
355
- }
356
- });
357
- };
590
+ // Add binary files
591
+ await BinaryFileHandler.addToShadow(binaryFiles);
358
592
 
359
- for (let elem of elems) {
360
- const id = elem.getAttribute("data-id");
361
- const previewContainer = elem.querySelector(".preview-container");
362
- const preview = elem.querySelector(".typst-preview");
363
- const loadingIndicator = elem.querySelector(".typst-loading");
364
- const editor = elem.querySelector(".editor.typst");
365
- const downloadBtn = elem.querySelector(".download-pdf");
366
- const downloadProjectBtn = elem.querySelector(".download-project");
367
- const resetBtn = elem.querySelector(".reset");
368
- const sourceTextarea = elem.querySelector(".typst-source");
369
- const tabsList = elem.querySelector(".tabs-list");
370
- const binaryFilesList = elem.querySelector(".binary-files-list");
371
- const addSourceFileBtn = elem.querySelector(".add-source-file");
372
- const addBinaryFileBtn = elem.querySelector(".add-binary-file");
373
-
374
- // Parse source files and binary files from data attributes
375
- const sourceFilesData = elem.getAttribute("data-source-files");
376
- const binaryFilesData = elem.getAttribute("data-binary-files");
377
- let basePath = elem.getAttribute("data-base-path") || "";
378
-
379
- // Ensure basePath starts with / for absolute paths
380
- if (basePath && !basePath.startsWith('/')) {
381
- basePath = '/' + basePath;
382
- }
383
-
384
- let sourceFiles = sourceFilesData
385
- ? JSON.parse(atob(sourceFilesData))
386
- : [];
387
- let binaryFiles = binaryFilesData
388
- ? JSON.parse(atob(binaryFilesData))
389
- : [];
390
-
391
- // Track current active file
392
- let currentFile = sourceFiles.find(f => f.filename === "main.typ" || f.filename === "main.typst") || sourceFiles[0];
393
-
394
- // Store file contents in memory
395
- const fileContents = new Map();
396
- sourceFiles.forEach(f => fileContents.set(f.filename, f.content));
397
-
398
- // Function to update tabs UI
399
- const updateTabs = () => {
400
- if (!tabsList) return;
401
-
402
- tabsList.innerHTML = "";
403
-
404
- // Add source file tabs
405
- sourceFiles.forEach(file => {
406
- const tab = document.createElement("div");
407
- tab.className = "file-tab";
408
- if (file.filename === currentFile.filename) {
409
- tab.classList.add("active");
410
- }
411
-
412
- const tabName = document.createElement("span");
413
- tabName.className = "tab-name";
414
- tabName.textContent = file.filename;
415
- tab.appendChild(tabName);
416
-
417
- // Add delete button (except for main file)
418
- if (file.filename !== "main.typ" && file.filename !== "main.typst") {
419
- const deleteBtn = document.createElement("button");
420
- deleteBtn.className = "tab-delete";
421
- deleteBtn.textContent = "×";
422
- deleteBtn.title = i18n.get("typst-delete-file") || "Delete file";
423
- deleteBtn.addEventListener("click", (e) => {
424
- e.stopPropagation();
425
- if (confirm(`${i18n.get("typst-delete-confirm") || "Delete"} ${file.filename}?`)) {
426
- sourceFiles = sourceFiles.filter(f => f.filename !== file.filename);
427
- fileContents.delete(file.filename);
428
-
429
- // Switch to main file if we deleted the current file
430
- if (currentFile.filename === file.filename) {
431
- currentFile = sourceFiles[0];
432
- if (editor) {
433
- editor.value = fileContents.get(currentFile.filename) || "";
434
- }
435
- }
436
-
437
- updateTabs();
438
- saveState();
439
- rerenderTypst();
440
- }
441
- });
442
- tab.appendChild(deleteBtn);
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'));
443
607
  }
444
-
445
- tab.addEventListener("click", () => {
446
- if (currentFile.filename !== file.filename) {
447
- // Save current file content
448
- if (editor) {
449
- fileContents.set(currentFile.filename, editor.value);
450
- }
451
-
452
- // Switch to new file
453
- currentFile = file;
454
- if (editor) {
455
- editor.value = fileContents.get(currentFile.filename) || "";
456
- }
457
-
458
- updateTabs();
459
- saveState();
460
- }
461
- });
462
-
463
- tabsList.appendChild(tab);
464
608
  });
465
- };
609
+ }
610
+ }
466
611
 
467
- // Function to update binary files list
468
- const updateBinaryFilesList = () => {
469
- if (!binaryFilesList) return;
470
-
471
- binaryFilesList.innerHTML = "";
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];
472
621
 
473
- if (binaryFiles.length === 0) {
474
- const emptyMsg = document.createElement("div");
475
- emptyMsg.className = "binary-files-empty";
476
- emptyMsg.textContent = i18n.get("typst-no-binary-files") || "No binary files";
477
- binaryFilesList.appendChild(emptyMsg);
478
- return;
622
+ // Initialize contents map
623
+ sourceFiles.forEach((f) => this.contents.set(f.filename, f.content));
624
+ }
625
+
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
+ }
635
+
636
+ /**
637
+ * Get current file content
638
+ * @returns {string} File content
639
+ */
640
+ getCurrentContent() {
641
+ return this.contents.get(this.currentFile.filename) || '';
642
+ }
643
+
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
+ }
651
+
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();
479
662
  }
480
-
481
- binaryFiles.forEach(file => {
482
- const item = document.createElement("div");
483
- item.className = "binary-file-item";
484
-
485
- const icon = document.createElement("span");
486
- icon.className = "binary-file-icon";
487
- icon.textContent = "📎";
488
- item.appendChild(icon);
489
-
490
- const name = document.createElement("span");
491
- name.className = "binary-file-name";
492
- name.textContent = file.dest;
493
- item.appendChild(name);
494
-
495
- const deleteBtn = document.createElement("button");
496
- deleteBtn.className = "binary-file-delete";
497
- deleteBtn.textContent = "×";
498
- deleteBtn.title = i18n.get("typst-delete-file") || "Delete file";
499
- deleteBtn.addEventListener("click", () => {
500
- if (confirm(`${i18n.get("typst-delete-confirm") || "Delete"} ${file.dest}?`)) {
501
- binaryFiles = binaryFiles.filter(f => f.dest !== file.dest);
502
- updateBinaryFilesList();
503
- saveState();
504
- rerenderTypst();
505
- }
506
- });
507
- item.appendChild(deleteBtn);
508
-
509
- binaryFilesList.appendChild(item);
510
- });
511
- };
663
+ return '';
664
+ }
512
665
 
513
- // Function to save state to store
514
- const saveState = async () => {
515
- if (!editor) return;
516
-
517
- // Update current file content
518
- fileContents.set(currentFile.filename, editor.value);
519
-
520
- // Update sourceFiles array with latest content
521
- sourceFiles = sourceFiles.map(f => ({
522
- filename: f.filename,
523
- content: fileContents.get(f.filename) || f.content
524
- }));
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
+ }
676
+
677
+ const newFile = { filename, content };
678
+ this.sourceFiles.push(newFile);
679
+ this.contents.set(filename, content);
680
+ this.currentFile = newFile;
525
681
 
526
- await store.typst?.put({
527
- id,
528
- code: editor.value,
529
- sourceFiles,
530
- binaryFiles,
531
- currentFile: currentFile.filename
532
- });
533
- };
682
+ return true;
683
+ }
534
684
 
535
- // Function to rerender typst
536
- const rerenderTypst = () => {
537
- if (editor) {
538
- // Update sourceFiles with current editor content
539
- fileContents.set(currentFile.filename, editor.value);
540
- sourceFiles = sourceFiles.map(f => ({
541
- filename: f.filename,
542
- content: fileContents.get(f.filename) || f.content
543
- }));
544
-
545
- const mainFile = sourceFiles.find(f => f.filename === "main.typ" || f.filename === "main.typst");
546
- const mainCode = mainFile ? mainFile.content : "";
547
- renderTypst(mainCode, preview, loadingIndicator, sourceFiles, binaryFiles, id, previewContainer, basePath);
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;
548
694
  }
549
- };
550
695
 
551
- // Create debounced version of rerenderTypst for input events (500ms delay)
552
- const debouncedRerenderTypst = debounce(rerenderTypst, 500);
553
-
554
- // Add source file button
555
- addSourceFileBtn?.addEventListener("click", () => {
556
- const filename = prompt(i18n.get("typst-filename-prompt") || "Enter filename (e.g., helper.typ):");
557
- if (filename) {
558
- // Validate filename
559
- if (!filename.endsWith(".typ") && !filename.endsWith(".typst")) {
560
- alert(i18n.get("typst-filename-error") || "Filename must end with .typ or .typst");
561
- return;
562
- }
563
-
564
- if (sourceFiles.some(f => f.filename === filename)) {
565
- alert(i18n.get("typst-filename-exists") || "File already exists");
566
- return;
567
- }
568
-
569
- // Add new file
570
- const newFile = { filename, content: `// ${filename}\n` };
571
- sourceFiles.push(newFile);
572
- fileContents.set(filename, newFile.content);
573
-
574
- // Switch to new file
575
- if (editor) {
576
- fileContents.set(currentFile.filename, editor.value);
577
- }
578
- currentFile = newFile;
579
- if (editor) {
580
- editor.value = newFile.content;
581
- }
582
-
583
- updateTabs();
584
- saveState();
585
- rerenderTypst();
696
+ this.sourceFiles = this.sourceFiles.filter((f) => f.filename !== filename);
697
+ this.contents.delete(filename);
698
+
699
+ // Switch to main file if we deleted current file
700
+ if (this.currentFile.filename === filename) {
701
+ this.currentFile = this.sourceFiles[0];
586
702
  }
587
- });
588
703
 
589
- // Add binary file button
590
- addBinaryFileBtn?.addEventListener("click", (e) => {
591
- e.preventDefault();
592
- e.stopPropagation();
593
-
594
- const input = document.createElement("input");
595
- input.type = "file";
596
- input.accept = "image/*,.pdf";
597
- input.addEventListener("change", async (e) => {
598
- const file = e.target.files[0];
599
- if (file) {
600
- const dest = `/${file.name}`;
601
-
602
- // Check if file already exists
603
- if (binaryFiles.some(f => f.dest === dest)) {
604
- if (!confirm(i18n.get("typst-file-replace") || `Replace existing ${dest}?`)) {
605
- return;
606
- }
607
- binaryFiles = binaryFiles.filter(f => f.dest !== dest);
608
- }
609
-
610
- // Read file as data URL
611
- const reader = new FileReader();
612
- reader.onload = async (e) => {
613
- const url = e.target.result;
614
- binaryFiles.push({ dest, url });
615
- updateBinaryFilesList();
616
- saveState();
617
- rerenderTypst();
618
- };
619
- reader.readAsDataURL(file);
620
- }
621
- });
622
- input.click();
623
- });
704
+ return true;
705
+ }
624
706
 
625
- // Get initial code
626
- let initialCode = "";
627
- if (editor) {
628
- // Edit mode - code is in the editor
629
- // Wait for code-input to load
630
- editor.addEventListener("code-input_load", async () => {
631
- // Check for stored code
632
- const result = await store.typst?.get(id);
633
- if (result) {
634
- editor.value = result.code;
635
-
636
- // Restore sourceFiles and binaryFiles if available
637
- if (result.sourceFiles) {
638
- sourceFiles = result.sourceFiles;
639
- sourceFiles.forEach(f => fileContents.set(f.filename, f.content));
640
- }
641
- if (result.binaryFiles) {
642
- binaryFiles = result.binaryFiles;
643
- }
644
- if (result.currentFile) {
645
- currentFile = sourceFiles.find(f => f.filename === result.currentFile) || sourceFiles[0];
646
- editor.value = fileContents.get(currentFile.filename) || "";
647
- }
648
- }
649
- initialCode = editor.value;
650
-
651
- updateTabs();
652
- updateBinaryFilesList();
653
- rerenderTypst();
654
-
655
- // Listen for input changes
656
- editor.addEventListener("input", () => {
657
- saveState();
658
- debouncedRerenderTypst();
659
- });
660
- });
661
- } else if (sourceTextarea) {
662
- // Preview mode - code is in hidden textarea
663
- initialCode = sourceTextarea.value;
664
- loadTypst().then(() => {
665
- renderTypst(initialCode, preview, loadingIndicator, sourceFiles, binaryFiles, id, previewContainer, basePath);
666
- });
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
+ }));
667
716
  }
717
+ }
668
718
 
669
- // Download PDF button
670
- downloadBtn?.addEventListener("click", async () => {
671
- // Get the main file content
672
- const mainFile = sourceFiles.find(f => f.filename === "main.typ" || f.filename === "main.typst");
673
- const code = mainFile ? mainFile.content : (editor ? editor.value : initialCode);
674
- await exportPdf(code, id, sourceFiles, binaryFiles, basePath);
675
- });
719
+ // ============================================================================
720
+ // PROJECT EXPORTER
721
+ // ============================================================================
676
722
 
677
- // Download Project button (ZIP with all files)
678
- downloadProjectBtn?.addEventListener("click", async () => {
679
- // Get the main file content
680
- const mainFile = sourceFiles.find(f => f.filename === "main.typ" || f.filename === "main.typst");
681
- const code = mainFile ? mainFile.content : (editor ? editor.value : initialCode);
682
- const encoder = new TextEncoder();
683
- const zipFiles = {};
723
+ class ProjectExporter {
724
+ constructor(assetManager) {
725
+ this.assetManager = assetManager;
726
+ }
684
727
 
685
- // Add all source files
686
- for (const { filename, content } of sourceFiles) {
687
- const path = filename.startsWith('/') ? filename.substring(1) : filename;
688
- zipFiles[path] = encoder.encode(content);
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);
742
+ }
743
+
744
+ // Add binary files
745
+ await this.addBinaryFiles(zipFiles, binaryFiles, basePath, pagePath);
746
+
747
+ // Add referenced assets
748
+ await this.addAssets(zipFiles, code, basePath, pagePath);
749
+
750
+ // Create and download ZIP
751
+ this.downloadZip(zipFiles, id);
752
+
753
+ } catch (error) {
754
+ console.error('Project export error:', error);
755
+ alert(i18nGet('typst-export-error', 'Error exporting project'));
689
756
  }
757
+ }
690
758
 
691
- // 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) {
692
768
  for (const { dest, url } of binaryFiles) {
693
769
  try {
694
770
  let arrayBuffer;
695
-
696
- // Check if URL is a data URL (user-uploaded file)
771
+
697
772
  if (url.startsWith('data:')) {
698
773
  const response = await fetch(url);
699
774
  arrayBuffer = await response.arrayBuffer();
700
775
  } else if (url.startsWith('http://') || url.startsWith('https://')) {
701
- // External URL
702
776
  const response = await fetch(url);
703
- if (response.ok) {
704
- arrayBuffer = await response.arrayBuffer();
705
- } else {
706
- console.warn(`Failed to load binary file: ${url}`);
707
- continue;
708
- }
777
+ if (!response.ok) continue;
778
+ arrayBuffer = await response.arrayBuffer();
709
779
  } else {
710
- // Relative URL - use basePath to construct full URL
711
- const fullUrl = basePath ? `${basePath}/${url}`.replace(/\/+/g, '/') : url;
780
+ const fullUrl = constructUrl(url, basePath, pagePath);
712
781
  const response = await fetch(fullUrl);
713
- if (response.ok) {
714
- arrayBuffer = await response.arrayBuffer();
715
- } else {
782
+ if (!response.ok) {
716
783
  console.warn(`Failed to load binary file: ${url} at ${fullUrl}`);
717
784
  continue;
718
785
  }
786
+ arrayBuffer = await response.arrayBuffer();
719
787
  }
720
-
788
+
721
789
  const path = dest.startsWith('/') ? dest.substring(1) : dest;
722
790
  zipFiles[path] = new Uint8Array(arrayBuffer);
723
791
  } catch (error) {
724
792
  console.warn(`Error loading binary file ${url}:`, error);
725
793
  }
726
794
  }
795
+ }
727
796
 
728
- // Also include assets loaded from image() calls in the code
729
- const relImagePaths = extractRelImagePaths(code);
730
- for (const imagePath of relImagePaths) {
731
- // Skip if already in zipFiles or already handled as binary file
732
- const normalizedPath = imagePath.startsWith('/') ? imagePath.substring(1) : imagePath;
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);
807
+
808
+ for (const relPath of relPaths) {
809
+ const normalizedPath = relPath.startsWith('/')
810
+ ? relPath.substring(1)
811
+ : relPath;
812
+
813
+ // Skip if already in zipFiles
733
814
  if (zipFiles[normalizedPath]) continue;
734
-
735
- // Skip absolute URLs, data URLs, and blob URLs
736
- if (/^(https?:|data:|blob:)/i.test(imagePath)) continue;
737
-
815
+
816
+ // Skip absolute URLs
817
+ if (REGEX_PATTERNS.ABSOLUTE_URL.test(relPath)) continue;
818
+
738
819
  try {
739
- // Construct URL using basePath
740
- const url = basePath ? `${basePath}/${imagePath}`.replace(/\/+/g, '/') : imagePath;
820
+ const url = constructUrl(relPath, basePath, pagePath);
741
821
  const response = await fetch(url);
822
+
742
823
  if (response.ok) {
743
824
  const arrayBuffer = await response.arrayBuffer();
744
825
  zipFiles[normalizedPath] = new Uint8Array(arrayBuffer);
745
826
  } else {
746
- console.warn(`Failed to load image asset: ${imagePath} at ${url}`);
827
+ console.warn(`Failed to load asset: ${relPath} at ${url}`);
747
828
  }
748
829
  } catch (error) {
749
- console.warn(`Error loading image asset ${imagePath}:`, error);
830
+ console.warn(`Error loading asset ${relPath}:`, error);
750
831
  }
751
832
  }
833
+ }
834
+
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
+ }
752
844
 
753
- // Create ZIP using UZIP
754
- const zipData = UZIP.encode(zipFiles);
755
- const zipBlob = new Blob([zipData], { type: "application/zip" });
756
- const link = document.createElement("a");
845
+ const zipData = window.UZIP.encode(zipFiles);
846
+ const zipBlob = new Blob([zipData], { type: 'application/zip' });
847
+ const link = document.createElement('a');
757
848
  link.href = URL.createObjectURL(zipBlob);
758
849
  link.download = `typst-project-${id}.zip`;
759
850
  link.click();
760
851
  URL.revokeObjectURL(link.href);
761
- });
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
+ }
762
977
 
763
- // Reset button (edit mode only)
764
- resetBtn?.addEventListener("click", async () => {
765
- if (window.confirm(i18n.get("typst-reset-prompt") || "Are you sure you want to reset the code?")) {
766
- 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);
767
1360
  window.location.reload();
768
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,
769
1405
  });
770
1406
  }
771
1407