@hyperbook/markdown 0.45.0 → 0.46.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 +631 -0
- package/dist/assets/directive-typst/style.css +467 -0
- package/dist/assets/prism/prism-typst.js +182 -0
- package/dist/assets/store.js +2 -1
- package/dist/assets/uzip/uzip.js +1538 -0
- package/dist/index.js +424 -2
- package/dist/index.js.map +4 -4
- package/dist/locales/de.json +21 -1
- package/dist/locales/en.json +21 -1
- package/dist/remarkDirectiveTypst.d.ts +5 -0
- package/package.json +3 -3
|
@@ -0,0 +1,631 @@
|
|
|
1
|
+
hyperbook.typst = (function () {
|
|
2
|
+
// Register code-input template for typst syntax highlighting
|
|
3
|
+
window.codeInput?.registerTemplate(
|
|
4
|
+
"typst-highlighted",
|
|
5
|
+
codeInput.templates.prism(window.Prism, [
|
|
6
|
+
new codeInput.plugins.AutoCloseBrackets(),
|
|
7
|
+
new codeInput.plugins.Indent(true, 2),
|
|
8
|
+
]),
|
|
9
|
+
);
|
|
10
|
+
|
|
11
|
+
const elems = document.getElementsByClassName("directive-typst");
|
|
12
|
+
|
|
13
|
+
// Typst WASM module URLs
|
|
14
|
+
const TYPST_COMPILER_URL = "https://cdn.jsdelivr.net/npm/@myriaddreamin/typst-ts-web-compiler/pkg/typst_ts_web_compiler_bg.wasm";
|
|
15
|
+
const TYPST_RENDERER_URL = "https://cdn.jsdelivr.net/npm/@myriaddreamin/typst-ts-renderer/pkg/typst_ts_renderer_bg.wasm";
|
|
16
|
+
|
|
17
|
+
// Load typst all-in-one bundle
|
|
18
|
+
let typstLoaded = false;
|
|
19
|
+
let typstLoadPromise = null;
|
|
20
|
+
|
|
21
|
+
// Rendering queue to ensure only one render at a time
|
|
22
|
+
let renderQueue = Promise.resolve();
|
|
23
|
+
|
|
24
|
+
const queueRender = (renderFn) => {
|
|
25
|
+
renderQueue = renderQueue.then(renderFn).catch((error) => {
|
|
26
|
+
console.error("Queued render error:", error);
|
|
27
|
+
});
|
|
28
|
+
return renderQueue;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const loadTypst = () => {
|
|
32
|
+
if (typstLoaded) {
|
|
33
|
+
return Promise.resolve();
|
|
34
|
+
}
|
|
35
|
+
if (typstLoadPromise) {
|
|
36
|
+
return typstLoadPromise;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
typstLoadPromise = new Promise((resolve, reject) => {
|
|
40
|
+
const script = document.createElement("script");
|
|
41
|
+
script.src = "https://cdn.jsdelivr.net/npm/@myriaddreamin/typst.ts/dist/esm/contrib/all-in-one-lite.bundle.js";
|
|
42
|
+
script.type = "module";
|
|
43
|
+
script.id = "typst-loader";
|
|
44
|
+
script.onload = () => {
|
|
45
|
+
// Wait a bit for the module to initialize
|
|
46
|
+
const checkTypst = () => {
|
|
47
|
+
if (typeof $typst !== "undefined") {
|
|
48
|
+
// Initialize the Typst compiler and renderer
|
|
49
|
+
$typst.setCompilerInitOptions({
|
|
50
|
+
getModule: () => TYPST_COMPILER_URL,
|
|
51
|
+
});
|
|
52
|
+
$typst.setRendererInitOptions({
|
|
53
|
+
getModule: () => TYPST_RENDERER_URL,
|
|
54
|
+
});
|
|
55
|
+
typstLoaded = true;
|
|
56
|
+
resolve();
|
|
57
|
+
} else {
|
|
58
|
+
setTimeout(checkTypst, 50);
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
checkTypst();
|
|
62
|
+
};
|
|
63
|
+
script.onerror = reject;
|
|
64
|
+
document.head.appendChild(script);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
return typstLoadPromise;
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
// Parse error message from SourceDiagnostic format
|
|
71
|
+
const parseTypstError = (errorMessage) => {
|
|
72
|
+
try {
|
|
73
|
+
// Try to extract message from SourceDiagnostic format
|
|
74
|
+
const match = errorMessage.match(/message:\s*"([^"]+)"/);
|
|
75
|
+
if (match) {
|
|
76
|
+
return match[1];
|
|
77
|
+
}
|
|
78
|
+
} catch (e) {
|
|
79
|
+
// Fallback to original message
|
|
80
|
+
}
|
|
81
|
+
return errorMessage;
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
// Render typst code to SVG
|
|
85
|
+
const renderTypst = async (code, container, loadingIndicator, sourceFiles, binaryFiles, id, previewContainer) => {
|
|
86
|
+
// Queue this render to ensure only one compilation runs at a time
|
|
87
|
+
return queueRender(async () => {
|
|
88
|
+
// Show loading indicator
|
|
89
|
+
if (loadingIndicator) {
|
|
90
|
+
loadingIndicator.style.display = "flex";
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
await loadTypst();
|
|
94
|
+
|
|
95
|
+
try {
|
|
96
|
+
// Reset shadow files for this render
|
|
97
|
+
$typst.resetShadow();
|
|
98
|
+
|
|
99
|
+
// Add source files
|
|
100
|
+
for (const { filename, content } of sourceFiles) {
|
|
101
|
+
const path = filename.startsWith('/') ? filename.substring(1) : filename;
|
|
102
|
+
await $typst.addSource(`/${path}`, content);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Add binary files
|
|
106
|
+
for (const { dest, url } of binaryFiles) {
|
|
107
|
+
try {
|
|
108
|
+
let arrayBuffer;
|
|
109
|
+
|
|
110
|
+
// Check if URL is a data URL (user-uploaded file)
|
|
111
|
+
if (url.startsWith('data:')) {
|
|
112
|
+
const response = await fetch(url);
|
|
113
|
+
arrayBuffer = await response.arrayBuffer();
|
|
114
|
+
} else {
|
|
115
|
+
// External URL
|
|
116
|
+
const response = await fetch(url);
|
|
117
|
+
if (!response.ok) {
|
|
118
|
+
console.warn(`Failed to load binary file: ${url}`);
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
arrayBuffer = await response.arrayBuffer();
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const path = dest.startsWith('/') ? dest.substring(1) : dest;
|
|
125
|
+
$typst.mapShadow(`/${path}`, new Uint8Array(arrayBuffer));
|
|
126
|
+
} catch (error) {
|
|
127
|
+
console.warn(`Error loading binary file ${url}:`, error);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const svg = await $typst.svg({ mainContent: code });
|
|
132
|
+
|
|
133
|
+
// Remove any existing error overlay from preview-container
|
|
134
|
+
if (previewContainer) {
|
|
135
|
+
const existingError = previewContainer.querySelector('.typst-error-overlay');
|
|
136
|
+
if (existingError) {
|
|
137
|
+
existingError.remove();
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
container.innerHTML = svg;
|
|
142
|
+
|
|
143
|
+
// Scale SVG to fit container
|
|
144
|
+
const svgElem = container.firstElementChild;
|
|
145
|
+
if (svgElem) {
|
|
146
|
+
const width = Number.parseFloat(svgElem.getAttribute("width"));
|
|
147
|
+
const height = Number.parseFloat(svgElem.getAttribute("height"));
|
|
148
|
+
const containerWidth = container.clientWidth - 20;
|
|
149
|
+
if (width > 0 && containerWidth > 0) {
|
|
150
|
+
svgElem.setAttribute("width", containerWidth);
|
|
151
|
+
svgElem.setAttribute("height", (height * containerWidth) / width);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
} catch (error) {
|
|
155
|
+
const errorText = parseTypstError(error || "Error rendering Typst");
|
|
156
|
+
|
|
157
|
+
// Check if we have existing content (previous successful render)
|
|
158
|
+
const hasExistingContent = container.querySelector('svg') !== null;
|
|
159
|
+
|
|
160
|
+
// Always use error overlay in preview-container if available
|
|
161
|
+
if (previewContainer) {
|
|
162
|
+
// Remove any existing error overlay
|
|
163
|
+
const existingError = previewContainer.querySelector('.typst-error-overlay');
|
|
164
|
+
if (existingError) {
|
|
165
|
+
existingError.remove();
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Clear preview if no existing content
|
|
169
|
+
if (!hasExistingContent) {
|
|
170
|
+
container.innerHTML = '';
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Create floating error overlay in preview-container
|
|
174
|
+
const errorOverlay = document.createElement('div');
|
|
175
|
+
errorOverlay.className = 'typst-error-overlay';
|
|
176
|
+
errorOverlay.innerHTML = `
|
|
177
|
+
<div class="typst-error-content">
|
|
178
|
+
<div class="typst-error-header">
|
|
179
|
+
<span class="typst-error-title">⚠️ Typst Error</span>
|
|
180
|
+
<button class="typst-error-close" title="Dismiss error">×</button>
|
|
181
|
+
</div>
|
|
182
|
+
<div class="typst-error-message">${errorText}</div>
|
|
183
|
+
</div>
|
|
184
|
+
`;
|
|
185
|
+
|
|
186
|
+
// Add close button functionality
|
|
187
|
+
const closeBtn = errorOverlay.querySelector('.typst-error-close');
|
|
188
|
+
closeBtn.addEventListener('click', () => {
|
|
189
|
+
errorOverlay.remove();
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
previewContainer.appendChild(errorOverlay);
|
|
193
|
+
} else {
|
|
194
|
+
// Fallback: show error in preview container directly
|
|
195
|
+
container.innerHTML = `<div class="typst-error">${errorText}</div>`;
|
|
196
|
+
}
|
|
197
|
+
} finally {
|
|
198
|
+
// Hide loading indicator
|
|
199
|
+
if (loadingIndicator) {
|
|
200
|
+
loadingIndicator.style.display = "none";
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
});
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
// Export to PDF
|
|
207
|
+
const exportPdf = async (code, id, sourceFiles, binaryFiles) => {
|
|
208
|
+
// Queue this export to ensure only one compilation runs at a time
|
|
209
|
+
return queueRender(async () => {
|
|
210
|
+
await loadTypst();
|
|
211
|
+
|
|
212
|
+
try {
|
|
213
|
+
// Reset shadow files for this export
|
|
214
|
+
$typst.resetShadow();
|
|
215
|
+
|
|
216
|
+
// Add source files
|
|
217
|
+
for (const { filename, content } of sourceFiles) {
|
|
218
|
+
const path = filename.startsWith('/') ? filename.substring(1) : filename;
|
|
219
|
+
await $typst.addSource(`/${path}`, content);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// Add binary files
|
|
223
|
+
for (const { dest, url } of binaryFiles) {
|
|
224
|
+
try {
|
|
225
|
+
let arrayBuffer;
|
|
226
|
+
|
|
227
|
+
// Check if URL is a data URL (user-uploaded file)
|
|
228
|
+
if (url.startsWith('data:')) {
|
|
229
|
+
const response = await fetch(url);
|
|
230
|
+
arrayBuffer = await response.arrayBuffer();
|
|
231
|
+
} else {
|
|
232
|
+
// External URL
|
|
233
|
+
const response = await fetch(url);
|
|
234
|
+
if (!response.ok) {
|
|
235
|
+
continue;
|
|
236
|
+
}
|
|
237
|
+
arrayBuffer = await response.arrayBuffer();
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const path = dest.startsWith('/') ? dest.substring(1) : dest;
|
|
241
|
+
$typst.mapShadow(`/${path}`, new Uint8Array(arrayBuffer));
|
|
242
|
+
} catch (error) {
|
|
243
|
+
console.warn(`Error loading binary file ${url}:`, error);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const pdfData = await $typst.pdf({ mainContent: code });
|
|
248
|
+
const pdfFile = new Blob([pdfData], { type: "application/pdf" });
|
|
249
|
+
const link = document.createElement("a");
|
|
250
|
+
link.href = URL.createObjectURL(pdfFile);
|
|
251
|
+
link.download = `typst-${id}.pdf`;
|
|
252
|
+
link.click();
|
|
253
|
+
URL.revokeObjectURL(link.href);
|
|
254
|
+
} catch (error) {
|
|
255
|
+
console.error("PDF export error:", error);
|
|
256
|
+
alert(i18n.get("typst-pdf-error") || "Error exporting PDF");
|
|
257
|
+
}
|
|
258
|
+
});
|
|
259
|
+
};
|
|
260
|
+
|
|
261
|
+
for (let elem of elems) {
|
|
262
|
+
const id = elem.getAttribute("data-id");
|
|
263
|
+
const previewContainer = elem.querySelector(".preview-container");
|
|
264
|
+
const preview = elem.querySelector(".typst-preview");
|
|
265
|
+
const loadingIndicator = elem.querySelector(".typst-loading");
|
|
266
|
+
const editor = elem.querySelector(".editor.typst");
|
|
267
|
+
const downloadBtn = elem.querySelector(".download-pdf");
|
|
268
|
+
const downloadProjectBtn = elem.querySelector(".download-project");
|
|
269
|
+
const resetBtn = elem.querySelector(".reset");
|
|
270
|
+
const sourceTextarea = elem.querySelector(".typst-source");
|
|
271
|
+
const tabsList = elem.querySelector(".tabs-list");
|
|
272
|
+
const binaryFilesList = elem.querySelector(".binary-files-list");
|
|
273
|
+
const addSourceFileBtn = elem.querySelector(".add-source-file");
|
|
274
|
+
const addBinaryFileBtn = elem.querySelector(".add-binary-file");
|
|
275
|
+
|
|
276
|
+
// Parse source files and binary files from data attributes
|
|
277
|
+
const sourceFilesData = elem.getAttribute("data-source-files");
|
|
278
|
+
const binaryFilesData = elem.getAttribute("data-binary-files");
|
|
279
|
+
|
|
280
|
+
let sourceFiles = sourceFilesData
|
|
281
|
+
? JSON.parse(atob(sourceFilesData))
|
|
282
|
+
: [];
|
|
283
|
+
let binaryFiles = binaryFilesData
|
|
284
|
+
? JSON.parse(atob(binaryFilesData))
|
|
285
|
+
: [];
|
|
286
|
+
|
|
287
|
+
// Track current active file
|
|
288
|
+
let currentFile = sourceFiles.find(f => f.filename === "main.typ" || f.filename === "main.typst") || sourceFiles[0];
|
|
289
|
+
|
|
290
|
+
// Store file contents in memory
|
|
291
|
+
const fileContents = new Map();
|
|
292
|
+
sourceFiles.forEach(f => fileContents.set(f.filename, f.content));
|
|
293
|
+
|
|
294
|
+
// Function to update tabs UI
|
|
295
|
+
const updateTabs = () => {
|
|
296
|
+
if (!tabsList) return;
|
|
297
|
+
|
|
298
|
+
tabsList.innerHTML = "";
|
|
299
|
+
|
|
300
|
+
// Add source file tabs
|
|
301
|
+
sourceFiles.forEach(file => {
|
|
302
|
+
const tab = document.createElement("div");
|
|
303
|
+
tab.className = "file-tab";
|
|
304
|
+
if (file.filename === currentFile.filename) {
|
|
305
|
+
tab.classList.add("active");
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
const tabName = document.createElement("span");
|
|
309
|
+
tabName.className = "tab-name";
|
|
310
|
+
tabName.textContent = file.filename;
|
|
311
|
+
tab.appendChild(tabName);
|
|
312
|
+
|
|
313
|
+
// Add delete button (except for main file)
|
|
314
|
+
if (file.filename !== "main.typ" && file.filename !== "main.typst") {
|
|
315
|
+
const deleteBtn = document.createElement("button");
|
|
316
|
+
deleteBtn.className = "tab-delete";
|
|
317
|
+
deleteBtn.textContent = "×";
|
|
318
|
+
deleteBtn.title = i18n.get("typst-delete-file") || "Delete file";
|
|
319
|
+
deleteBtn.addEventListener("click", (e) => {
|
|
320
|
+
e.stopPropagation();
|
|
321
|
+
if (confirm(`${i18n.get("typst-delete-confirm") || "Delete"} ${file.filename}?`)) {
|
|
322
|
+
sourceFiles = sourceFiles.filter(f => f.filename !== file.filename);
|
|
323
|
+
fileContents.delete(file.filename);
|
|
324
|
+
|
|
325
|
+
// Switch to main file if we deleted the current file
|
|
326
|
+
if (currentFile.filename === file.filename) {
|
|
327
|
+
currentFile = sourceFiles[0];
|
|
328
|
+
if (editor) {
|
|
329
|
+
editor.value = fileContents.get(currentFile.filename) || "";
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
updateTabs();
|
|
334
|
+
saveState();
|
|
335
|
+
rerenderTypst();
|
|
336
|
+
}
|
|
337
|
+
});
|
|
338
|
+
tab.appendChild(deleteBtn);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
tab.addEventListener("click", () => {
|
|
342
|
+
if (currentFile.filename !== file.filename) {
|
|
343
|
+
// Save current file content
|
|
344
|
+
if (editor) {
|
|
345
|
+
fileContents.set(currentFile.filename, editor.value);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// Switch to new file
|
|
349
|
+
currentFile = file;
|
|
350
|
+
if (editor) {
|
|
351
|
+
editor.value = fileContents.get(currentFile.filename) || "";
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
updateTabs();
|
|
355
|
+
saveState();
|
|
356
|
+
}
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
tabsList.appendChild(tab);
|
|
360
|
+
});
|
|
361
|
+
};
|
|
362
|
+
|
|
363
|
+
// Function to update binary files list
|
|
364
|
+
const updateBinaryFilesList = () => {
|
|
365
|
+
if (!binaryFilesList) return;
|
|
366
|
+
|
|
367
|
+
binaryFilesList.innerHTML = "";
|
|
368
|
+
|
|
369
|
+
if (binaryFiles.length === 0) {
|
|
370
|
+
const emptyMsg = document.createElement("div");
|
|
371
|
+
emptyMsg.className = "binary-files-empty";
|
|
372
|
+
emptyMsg.textContent = i18n.get("typst-no-binary-files") || "No binary files";
|
|
373
|
+
binaryFilesList.appendChild(emptyMsg);
|
|
374
|
+
return;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
binaryFiles.forEach(file => {
|
|
378
|
+
const item = document.createElement("div");
|
|
379
|
+
item.className = "binary-file-item";
|
|
380
|
+
|
|
381
|
+
const icon = document.createElement("span");
|
|
382
|
+
icon.className = "binary-file-icon";
|
|
383
|
+
icon.textContent = "📎";
|
|
384
|
+
item.appendChild(icon);
|
|
385
|
+
|
|
386
|
+
const name = document.createElement("span");
|
|
387
|
+
name.className = "binary-file-name";
|
|
388
|
+
name.textContent = file.dest;
|
|
389
|
+
item.appendChild(name);
|
|
390
|
+
|
|
391
|
+
const deleteBtn = document.createElement("button");
|
|
392
|
+
deleteBtn.className = "binary-file-delete";
|
|
393
|
+
deleteBtn.textContent = "×";
|
|
394
|
+
deleteBtn.title = i18n.get("typst-delete-file") || "Delete file";
|
|
395
|
+
deleteBtn.addEventListener("click", () => {
|
|
396
|
+
if (confirm(`${i18n.get("typst-delete-confirm") || "Delete"} ${file.dest}?`)) {
|
|
397
|
+
binaryFiles = binaryFiles.filter(f => f.dest !== file.dest);
|
|
398
|
+
updateBinaryFilesList();
|
|
399
|
+
saveState();
|
|
400
|
+
rerenderTypst();
|
|
401
|
+
}
|
|
402
|
+
});
|
|
403
|
+
item.appendChild(deleteBtn);
|
|
404
|
+
|
|
405
|
+
binaryFilesList.appendChild(item);
|
|
406
|
+
});
|
|
407
|
+
};
|
|
408
|
+
|
|
409
|
+
// Function to save state to store
|
|
410
|
+
const saveState = async () => {
|
|
411
|
+
if (!editor) return;
|
|
412
|
+
|
|
413
|
+
// Update current file content
|
|
414
|
+
fileContents.set(currentFile.filename, editor.value);
|
|
415
|
+
|
|
416
|
+
// Update sourceFiles array with latest content
|
|
417
|
+
sourceFiles = sourceFiles.map(f => ({
|
|
418
|
+
filename: f.filename,
|
|
419
|
+
content: fileContents.get(f.filename) || f.content
|
|
420
|
+
}));
|
|
421
|
+
|
|
422
|
+
await store.typst?.put({
|
|
423
|
+
id,
|
|
424
|
+
code: editor.value,
|
|
425
|
+
sourceFiles,
|
|
426
|
+
binaryFiles,
|
|
427
|
+
currentFile: currentFile.filename
|
|
428
|
+
});
|
|
429
|
+
};
|
|
430
|
+
|
|
431
|
+
// Function to rerender typst
|
|
432
|
+
const rerenderTypst = () => {
|
|
433
|
+
if (editor) {
|
|
434
|
+
// Update sourceFiles with current editor content
|
|
435
|
+
fileContents.set(currentFile.filename, editor.value);
|
|
436
|
+
sourceFiles = sourceFiles.map(f => ({
|
|
437
|
+
filename: f.filename,
|
|
438
|
+
content: fileContents.get(f.filename) || f.content
|
|
439
|
+
}));
|
|
440
|
+
|
|
441
|
+
const mainFile = sourceFiles.find(f => f.filename === "main.typ" || f.filename === "main.typst");
|
|
442
|
+
const mainCode = mainFile ? mainFile.content : "";
|
|
443
|
+
renderTypst(mainCode, preview, loadingIndicator, sourceFiles, binaryFiles, id, previewContainer);
|
|
444
|
+
}
|
|
445
|
+
};
|
|
446
|
+
|
|
447
|
+
// Add source file button
|
|
448
|
+
addSourceFileBtn?.addEventListener("click", () => {
|
|
449
|
+
const filename = prompt(i18n.get("typst-filename-prompt") || "Enter filename (e.g., helper.typ):");
|
|
450
|
+
if (filename) {
|
|
451
|
+
// Validate filename
|
|
452
|
+
if (!filename.endsWith(".typ") && !filename.endsWith(".typst")) {
|
|
453
|
+
alert(i18n.get("typst-filename-error") || "Filename must end with .typ or .typst");
|
|
454
|
+
return;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
if (sourceFiles.some(f => f.filename === filename)) {
|
|
458
|
+
alert(i18n.get("typst-filename-exists") || "File already exists");
|
|
459
|
+
return;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
// Add new file
|
|
463
|
+
const newFile = { filename, content: `// ${filename}\n` };
|
|
464
|
+
sourceFiles.push(newFile);
|
|
465
|
+
fileContents.set(filename, newFile.content);
|
|
466
|
+
|
|
467
|
+
// Switch to new file
|
|
468
|
+
if (editor) {
|
|
469
|
+
fileContents.set(currentFile.filename, editor.value);
|
|
470
|
+
}
|
|
471
|
+
currentFile = newFile;
|
|
472
|
+
if (editor) {
|
|
473
|
+
editor.value = newFile.content;
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
updateTabs();
|
|
477
|
+
saveState();
|
|
478
|
+
rerenderTypst();
|
|
479
|
+
}
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
// Add binary file button
|
|
483
|
+
addBinaryFileBtn?.addEventListener("click", (e) => {
|
|
484
|
+
e.preventDefault();
|
|
485
|
+
e.stopPropagation();
|
|
486
|
+
|
|
487
|
+
const input = document.createElement("input");
|
|
488
|
+
input.type = "file";
|
|
489
|
+
input.accept = "image/*,.pdf";
|
|
490
|
+
input.addEventListener("change", async (e) => {
|
|
491
|
+
const file = e.target.files[0];
|
|
492
|
+
if (file) {
|
|
493
|
+
const dest = `/${file.name}`;
|
|
494
|
+
|
|
495
|
+
// Check if file already exists
|
|
496
|
+
if (binaryFiles.some(f => f.dest === dest)) {
|
|
497
|
+
if (!confirm(i18n.get("typst-file-replace") || `Replace existing ${dest}?`)) {
|
|
498
|
+
return;
|
|
499
|
+
}
|
|
500
|
+
binaryFiles = binaryFiles.filter(f => f.dest !== dest);
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
// Read file as data URL
|
|
504
|
+
const reader = new FileReader();
|
|
505
|
+
reader.onload = async (e) => {
|
|
506
|
+
const url = e.target.result;
|
|
507
|
+
binaryFiles.push({ dest, url });
|
|
508
|
+
updateBinaryFilesList();
|
|
509
|
+
saveState();
|
|
510
|
+
rerenderTypst();
|
|
511
|
+
};
|
|
512
|
+
reader.readAsDataURL(file);
|
|
513
|
+
}
|
|
514
|
+
});
|
|
515
|
+
input.click();
|
|
516
|
+
});
|
|
517
|
+
|
|
518
|
+
// Get initial code
|
|
519
|
+
let initialCode = "";
|
|
520
|
+
if (editor) {
|
|
521
|
+
// Edit mode - code is in the editor
|
|
522
|
+
// Wait for code-input to load
|
|
523
|
+
editor.addEventListener("code-input_load", async () => {
|
|
524
|
+
// Check for stored code
|
|
525
|
+
const result = await store.typst?.get(id);
|
|
526
|
+
if (result) {
|
|
527
|
+
editor.value = result.code;
|
|
528
|
+
|
|
529
|
+
// Restore sourceFiles and binaryFiles if available
|
|
530
|
+
if (result.sourceFiles) {
|
|
531
|
+
sourceFiles = result.sourceFiles;
|
|
532
|
+
sourceFiles.forEach(f => fileContents.set(f.filename, f.content));
|
|
533
|
+
}
|
|
534
|
+
if (result.binaryFiles) {
|
|
535
|
+
binaryFiles = result.binaryFiles;
|
|
536
|
+
}
|
|
537
|
+
if (result.currentFile) {
|
|
538
|
+
currentFile = sourceFiles.find(f => f.filename === result.currentFile) || sourceFiles[0];
|
|
539
|
+
editor.value = fileContents.get(currentFile.filename) || "";
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
initialCode = editor.value;
|
|
543
|
+
|
|
544
|
+
updateTabs();
|
|
545
|
+
updateBinaryFilesList();
|
|
546
|
+
rerenderTypst();
|
|
547
|
+
|
|
548
|
+
// Listen for input changes
|
|
549
|
+
editor.addEventListener("input", () => {
|
|
550
|
+
saveState();
|
|
551
|
+
rerenderTypst();
|
|
552
|
+
});
|
|
553
|
+
});
|
|
554
|
+
} else if (sourceTextarea) {
|
|
555
|
+
// Preview mode - code is in hidden textarea
|
|
556
|
+
initialCode = sourceTextarea.value;
|
|
557
|
+
loadTypst().then(() => {
|
|
558
|
+
renderTypst(initialCode, preview, loadingIndicator, sourceFiles, binaryFiles, id, previewContainer);
|
|
559
|
+
});
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
// Download PDF button
|
|
563
|
+
downloadBtn?.addEventListener("click", async () => {
|
|
564
|
+
// Get the main file content
|
|
565
|
+
const mainFile = sourceFiles.find(f => f.filename === "main.typ" || f.filename === "main.typst");
|
|
566
|
+
const code = mainFile ? mainFile.content : (editor ? editor.value : initialCode);
|
|
567
|
+
await exportPdf(code, id, sourceFiles, binaryFiles);
|
|
568
|
+
});
|
|
569
|
+
|
|
570
|
+
// Download Project button (ZIP with all files)
|
|
571
|
+
downloadProjectBtn?.addEventListener("click", async () => {
|
|
572
|
+
// Get the main file content
|
|
573
|
+
const mainFile = sourceFiles.find(f => f.filename === "main.typ" || f.filename === "main.typst");
|
|
574
|
+
const code = mainFile ? mainFile.content : (editor ? editor.value : initialCode);
|
|
575
|
+
const encoder = new TextEncoder();
|
|
576
|
+
const zipFiles = {};
|
|
577
|
+
|
|
578
|
+
// Add all source files
|
|
579
|
+
for (const { filename, content } of sourceFiles) {
|
|
580
|
+
const path = filename.startsWith('/') ? filename.substring(1) : filename;
|
|
581
|
+
zipFiles[path] = encoder.encode(content);
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
// Add binary files
|
|
585
|
+
for (const { dest, url } of binaryFiles) {
|
|
586
|
+
try {
|
|
587
|
+
let arrayBuffer;
|
|
588
|
+
|
|
589
|
+
// Check if URL is a data URL (user-uploaded file)
|
|
590
|
+
if (url.startsWith('data:')) {
|
|
591
|
+
const response = await fetch(url);
|
|
592
|
+
arrayBuffer = await response.arrayBuffer();
|
|
593
|
+
} else {
|
|
594
|
+
// External URL
|
|
595
|
+
const response = await fetch(url);
|
|
596
|
+
if (response.ok) {
|
|
597
|
+
arrayBuffer = await response.arrayBuffer();
|
|
598
|
+
} else {
|
|
599
|
+
console.warn(`Failed to load binary file: ${url}`);
|
|
600
|
+
continue;
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
const path = dest.startsWith('/') ? dest.substring(1) : dest;
|
|
605
|
+
zipFiles[path] = new Uint8Array(arrayBuffer);
|
|
606
|
+
} catch (error) {
|
|
607
|
+
console.warn(`Error loading binary file ${url}:`, error);
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
// Create ZIP using UZIP
|
|
612
|
+
const zipData = UZIP.encode(zipFiles);
|
|
613
|
+
const zipBlob = new Blob([zipData], { type: "application/zip" });
|
|
614
|
+
const link = document.createElement("a");
|
|
615
|
+
link.href = URL.createObjectURL(zipBlob);
|
|
616
|
+
link.download = `typst-project-${id}.zip`;
|
|
617
|
+
link.click();
|
|
618
|
+
URL.revokeObjectURL(link.href);
|
|
619
|
+
});
|
|
620
|
+
|
|
621
|
+
// Reset button (edit mode only)
|
|
622
|
+
resetBtn?.addEventListener("click", async () => {
|
|
623
|
+
if (window.confirm(i18n.get("typst-reset-prompt") || "Are you sure you want to reset the code?")) {
|
|
624
|
+
store.typst?.delete(id);
|
|
625
|
+
window.location.reload();
|
|
626
|
+
}
|
|
627
|
+
});
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
return {};
|
|
631
|
+
})();
|