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.
- package/dist/assets/directive-typst/client.js +1282 -646
- package/dist/index.js +66 -28
- package/package.json +2 -2
|
@@ -1,5 +1,40 @@
|
|
|
1
1
|
hyperbook.typst = (function () {
|
|
2
|
-
|
|
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
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
|
|
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
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
//
|
|
31
|
-
|
|
32
|
-
|
|
84
|
+
// ============================================================================
|
|
85
|
+
// INITIALIZATION
|
|
86
|
+
// ============================================================================
|
|
33
87
|
|
|
34
|
-
|
|
35
|
-
|
|
88
|
+
/**
|
|
89
|
+
* Initialize code-input template for Typst syntax highlighting
|
|
90
|
+
*/
|
|
91
|
+
const initializeCodeInput = () => {
|
|
92
|
+
if (!window.codeInput) return;
|
|
36
93
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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,
|
|
167
|
+
setTimeout(checkTypst, CONFIG.TYPST_CHECK_INTERVAL);
|
|
72
168
|
}
|
|
73
169
|
};
|
|
74
170
|
checkTypst();
|
|
75
|
-
};
|
|
76
|
-
|
|
77
|
-
document.head.appendChild(script);
|
|
78
|
-
});
|
|
171
|
+
});
|
|
172
|
+
}
|
|
79
173
|
|
|
80
|
-
|
|
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
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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
|
-
|
|
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
|
-
|
|
148
|
-
});
|
|
149
|
-
};
|
|
279
|
+
}
|
|
150
280
|
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
const
|
|
167
|
-
|
|
168
|
-
|
|
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
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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
|
-
|
|
330
|
+
const paths = Array.from(allPaths);
|
|
186
331
|
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
332
|
+
if (paths.length > 0) {
|
|
333
|
+
await this.fetchAssets(paths, basePath, pagePath);
|
|
334
|
+
this.mapToShadow();
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
}
|
|
190
338
|
|
|
191
|
-
|
|
192
|
-
|
|
339
|
+
// ============================================================================
|
|
340
|
+
// ERROR HANDLING
|
|
341
|
+
// ============================================================================
|
|
193
342
|
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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
|
-
|
|
201
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
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
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
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
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
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
|
-
|
|
264
|
-
|
|
265
|
-
|
|
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
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
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
|
-
|
|
312
|
-
|
|
581
|
+
// Reset shadow files
|
|
582
|
+
window.$typst.resetShadow();
|
|
313
583
|
|
|
314
|
-
|
|
315
|
-
|
|
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
|
-
|
|
321
|
-
|
|
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
|
-
|
|
346
|
-
|
|
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
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
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
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
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
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
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
|
-
|
|
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
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
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
|
-
|
|
527
|
-
|
|
528
|
-
code: editor.value,
|
|
529
|
-
sourceFiles,
|
|
530
|
-
binaryFiles,
|
|
531
|
-
currentFile: currentFile.filename
|
|
532
|
-
});
|
|
533
|
-
};
|
|
682
|
+
return true;
|
|
683
|
+
}
|
|
534
684
|
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
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
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
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
|
-
|
|
590
|
-
|
|
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
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
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
|
-
|
|
670
|
-
|
|
671
|
-
|
|
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
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
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
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
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
|
|
736
|
-
if (
|
|
737
|
-
|
|
815
|
+
|
|
816
|
+
// Skip absolute URLs
|
|
817
|
+
if (REGEX_PATTERNS.ABSOLUTE_URL.test(relPath)) continue;
|
|
818
|
+
|
|
738
819
|
try {
|
|
739
|
-
|
|
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
|
|
827
|
+
console.warn(`Failed to load asset: ${relPath} at ${url}`);
|
|
747
828
|
}
|
|
748
829
|
} catch (error) {
|
|
749
|
-
console.warn(`Error loading
|
|
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
|
-
|
|
754
|
-
const
|
|
755
|
-
const
|
|
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
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
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
|
|