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