codex-overleaf-link 1.8.5 → 1.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/README.md +45 -21
  2. package/bin/codex-overleaf-link.mjs +58 -0
  3. package/extension/assets/icons/codex-overleaf-dialog-icon.png +0 -0
  4. package/extension/assets/icons/codex-overleaf-icon.png +0 -0
  5. package/extension/assets/icons/icon128.png +0 -0
  6. package/extension/assets/icons/icon16.png +0 -0
  7. package/extension/assets/icons/icon32.png +0 -0
  8. package/extension/assets/icons/icon48.png +0 -0
  9. package/extension/bootstrap/background.js +404 -0
  10. package/extension/bootstrap/manifest.template.json +51 -0
  11. package/extension/bootstrap/popup.css +26 -0
  12. package/extension/bootstrap/popup.html +38 -0
  13. package/extension/bootstrap/popup.js +44 -0
  14. package/extension/bootstrap/runtimeContext.js +2 -0
  15. package/extension/runtime-manifest.json +60 -0
  16. package/extension/src/background.js +532 -0
  17. package/extension/src/content/applyResultFormatters.js +387 -0
  18. package/extension/src/content/composerAttachments.js +522 -0
  19. package/extension/src/content/composerPanel.js +254 -0
  20. package/extension/src/content/contentRuntime.js +8895 -0
  21. package/extension/src/content/contextTray.js +686 -0
  22. package/extension/src/content/diagnosticsController.js +659 -0
  23. package/extension/src/content/diagnosticsPanel.js +342 -0
  24. package/extension/src/content/diffReviewPanel.js +797 -0
  25. package/extension/src/content/localSkillsPanel.js +280 -0
  26. package/extension/src/content/markdownText.js +606 -0
  27. package/extension/src/content/mirrorHealth.js +92 -0
  28. package/extension/src/content/modelPicker.js +489 -0
  29. package/extension/src/content/nativeChannel.js +62 -0
  30. package/extension/src/content/otWarmMirror.js +964 -0
  31. package/extension/src/content/otWarmMirrorController.js +174 -0
  32. package/extension/src/content/panelMaintenance.js +367 -0
  33. package/extension/src/content/panelRenderer.js +253 -0
  34. package/extension/src/content/recentProjects.js +1063 -0
  35. package/extension/src/content/reviewHunks.js +247 -0
  36. package/extension/src/content/runController.js +242 -0
  37. package/extension/src/content/runTimelineView.js +1126 -0
  38. package/extension/src/content/sessionManager.js +487 -0
  39. package/extension/src/content/sessionPanel.js +263 -0
  40. package/extension/src/content/settingsPanel.js +527 -0
  41. package/extension/src/content/themeController.js +91 -0
  42. package/extension/src/content/updateIdle.js +67 -0
  43. package/extension/src/content/writebackController.js +300 -0
  44. package/extension/src/content/writebackOrchestrator.js +850 -0
  45. package/extension/src/contentScript.js +8 -0
  46. package/extension/src/page/compileBridge.js +460 -0
  47. package/extension/src/page/overleafCapabilities.js +100 -0
  48. package/extension/src/page/overleafEditor.js +282 -0
  49. package/extension/src/page/overleafProjectSnapshot.js +234 -0
  50. package/extension/src/page/overleafRealtimeObserver.js +491 -0
  51. package/extension/src/page/pageBridgeCapability.js +71 -0
  52. package/extension/src/page/snapshotRouter.js +948 -0
  53. package/extension/src/page/trackedChangesLifecycle.js +1506 -0
  54. package/extension/src/page/treeOperations.js +1584 -0
  55. package/extension/src/page/writeGuard.js +142 -0
  56. package/extension/src/page/writebackRouter.js +1934 -0
  57. package/extension/src/pageBridge.js +2224 -0
  58. package/extension/src/popup.js +247 -0
  59. package/extension/src/shared/compatibility.js +1 -1
  60. package/extension/styles/panel.css +4048 -0
  61. package/native-host/src/index.js +9 -2
  62. package/native-host/src/launcher.js +2 -0
  63. package/native-host/src/managedInstall.js +393 -0
  64. package/native-host/src/managedLauncherRuntime.js +71 -0
  65. package/native-host/src/nativeEnvironment.js +4 -0
  66. package/native-host/src/nativeHostPlatform.js +16 -0
  67. package/native-host/src/runtimeInstaller.js +6 -1
  68. package/native-host/src/taskRunnerRuntime.js +8 -0
  69. package/native-host/src/updateArchive.js +253 -0
  70. package/native-host/src/updateManager.js +548 -0
  71. package/native-host/src/updateTrust.js +156 -0
  72. package/package.json +11 -2
  73. package/scripts/install-managed.mjs +64 -0
  74. package/scripts/uninstall-managed.mjs +48 -0
@@ -0,0 +1,522 @@
1
+ (function initComposerAttachments(global) {
2
+ 'use strict';
3
+
4
+ function createComposerAttachmentController(options = {}) {
5
+ const getPanel = typeof options.getPanel === 'function' ? options.getPanel : () => null;
6
+ const tx = typeof options.tx === 'function' ? options.tx : (en) => en;
7
+ const tr = typeof options.tr === 'function' ? options.tr : (key) => key;
8
+ const appendPlainLog = typeof options.appendPlainLog === 'function' ? options.appendPlainLog : () => {};
9
+ const limits = options.limits || {};
10
+ const maxAttachments = Number(limits.maxAttachments) || 8;
11
+ const maxAttachmentBytes = Number(limits.maxAttachmentBytes) || 12 * 1024 * 1024;
12
+ const maxAttachmentTotalBytes = Number(limits.maxAttachmentTotalBytes) || 32 * 1024 * 1024;
13
+ const maxPreviewDataUrlChars = Number(limits.maxPreviewDataUrlChars) || 768 * 1024;
14
+
15
+ let attachments = [];
16
+ const pendingAttachmentKeys = new Set();
17
+ let pendingAttachmentBytes = 0;
18
+
19
+ function handlePaste(event) {
20
+ const files = collectFilesFromDataTransfer(event.clipboardData);
21
+ if (!files.length) {
22
+ return;
23
+ }
24
+ event.preventDefault();
25
+ addFiles(files).catch(error => {
26
+ appendPlainLog(tx(`Attachment was not added: ${error.message}`, `附件没有添加:${error.message}`));
27
+ });
28
+ }
29
+
30
+ function handleDragOver(event) {
31
+ if (!hasFileDataTransfer(event.dataTransfer)) {
32
+ return;
33
+ }
34
+ event.preventDefault();
35
+ if (event.dataTransfer) {
36
+ event.dataTransfer.dropEffect = 'copy';
37
+ }
38
+ getPanel()?.querySelector('[data-composer-form]')?.setAttribute('data-dragging-attachment', 'true');
39
+ }
40
+
41
+ function handleDragLeave(event) {
42
+ const composer = getPanel()?.querySelector('[data-composer-form]');
43
+ if (!composer || (event.relatedTarget && composer.contains(event.relatedTarget))) {
44
+ return;
45
+ }
46
+ composer.removeAttribute('data-dragging-attachment');
47
+ }
48
+
49
+ function handleDrop(event) {
50
+ const files = collectFilesFromDataTransfer(event.dataTransfer);
51
+ getPanel()?.querySelector('[data-composer-form]')?.removeAttribute('data-dragging-attachment');
52
+ if (!files.length) {
53
+ return;
54
+ }
55
+ event.preventDefault();
56
+ addFiles(files).catch(error => {
57
+ appendPlainLog(tx(`Attachment was not added: ${error.message}`, `附件没有添加:${error.message}`));
58
+ });
59
+ }
60
+
61
+ async function addFiles(files) {
62
+ for (const file of Array.from(files || [])) {
63
+ const dedupeKey = buildAttachmentDedupeKey(file);
64
+ if (isDuplicateAttachmentFile(dedupeKey)) {
65
+ continue;
66
+ }
67
+ if (attachments.length + pendingAttachmentKeys.size >= maxAttachments) {
68
+ appendPlainLog(tx(
69
+ `Attachment limit reached (${maxAttachments}).`,
70
+ `附件数量已达上限(${maxAttachments} 个)。`
71
+ ));
72
+ break;
73
+ }
74
+ validateAttachmentFile(file);
75
+ const fileSize = Number(file.size) || 0;
76
+ if (!canReserveAttachmentBytes(fileSize)) {
77
+ appendPlainLog(tx(
78
+ `Attachment total limit reached (${formatFileSize(maxAttachmentTotalBytes)}). Remove attachments or use smaller files.`,
79
+ `附件总大小已达上限(${formatFileSize(maxAttachmentTotalBytes)})。请移除部分附件或使用更小的文件。`
80
+ ));
81
+ continue;
82
+ }
83
+ pendingAttachmentKeys.add(dedupeKey);
84
+ pendingAttachmentBytes += fileSize;
85
+ try {
86
+ const dataUrl = await readFileAsDataUrl(file);
87
+ const preview = await buildAttachmentPreviewData(file, dataUrl);
88
+ const attachment = {
89
+ id: `${Date.now()}-${Math.random().toString(36).slice(2)}`,
90
+ name: normalizeAttachmentName(file.name),
91
+ mimeType: String(file.type || ''),
92
+ size: Number(file.size) || 0,
93
+ kind: preview.kind,
94
+ previewDataUrl: preview.previewDataUrl,
95
+ contentBase64: extractBase64FromDataUrl(dataUrl),
96
+ dedupeKey
97
+ };
98
+ if (!attachments.some(existing => buildAttachmentDedupeKey(existing) === dedupeKey)) {
99
+ attachments.push(attachment);
100
+ }
101
+ } finally {
102
+ pendingAttachmentBytes = Math.max(0, pendingAttachmentBytes - fileSize);
103
+ pendingAttachmentKeys.delete(dedupeKey);
104
+ }
105
+ }
106
+ renderComposerAttachments();
107
+ }
108
+
109
+ function getAttachmentsForRun() {
110
+ return attachments.map(attachment => ({
111
+ name: attachment.name,
112
+ mimeType: attachment.mimeType,
113
+ size: attachment.size,
114
+ kind: attachment.kind,
115
+ previewDataUrl: attachment.previewDataUrl,
116
+ contentBase64: attachment.contentBase64
117
+ }));
118
+ }
119
+
120
+ function createRunAttachmentSnapshots(value = []) {
121
+ return Array.from(value || []).map(attachment => ({
122
+ name: normalizeAttachmentName(attachment.name),
123
+ mimeType: String(attachment.mimeType || '').trim(),
124
+ size: Number(attachment.size) || 0,
125
+ kind: attachment.kind === 'image' ? 'image' : 'file',
126
+ previewDataUrl: attachment.kind === 'image' && /^data:image\//i.test(String(attachment.previewDataUrl || ''))
127
+ ? String(attachment.previewDataUrl || '').slice(0, maxPreviewDataUrlChars)
128
+ : ''
129
+ })).filter(attachment => attachment.name).slice(0, maxAttachments);
130
+ }
131
+
132
+ function renderComposerAttachments() {
133
+ const strip = getPanel()?.querySelector('[data-attachment-strip]');
134
+ if (!strip) {
135
+ return;
136
+ }
137
+ strip.hidden = attachments.length === 0;
138
+ renderAttachmentPreviewList(attachments, strip, {
139
+ root: getPanel() || document.body,
140
+ tr,
141
+ tx,
142
+ removable: true,
143
+ onRemove: removeAttachment
144
+ });
145
+ }
146
+
147
+ function clear() {
148
+ attachments = [];
149
+ pendingAttachmentKeys.clear();
150
+ renderComposerAttachments();
151
+ }
152
+
153
+ function removeAttachment(id) {
154
+ attachments = attachments.filter(attachment => attachment.id !== id);
155
+ renderComposerAttachments();
156
+ }
157
+
158
+ function isDuplicateAttachmentFile(dedupeKey) {
159
+ const key = String(dedupeKey || '');
160
+ return pendingAttachmentKeys.has(key) ||
161
+ attachments.some(attachment => buildAttachmentDedupeKey(attachment) === key);
162
+ }
163
+
164
+ function canReserveAttachmentBytes(size) {
165
+ return getCommittedAttachmentBytes() + pendingAttachmentBytes + size <= maxAttachmentTotalBytes;
166
+ }
167
+
168
+ function getCommittedAttachmentBytes() {
169
+ return attachments.reduce((total, attachment) => total + (Number(attachment.size) || 0), 0);
170
+ }
171
+
172
+ function validateAttachmentFile(file) {
173
+ if (!file || !normalizeAttachmentName(file.name)) {
174
+ throw new Error(tx('The selected attachment has no readable file name.', '选择的附件没有可读取的文件名。'));
175
+ }
176
+ if (Number(file.size) <= 0) {
177
+ throw new Error(tx('Empty attachments are not supported.', '不支持空附件。'));
178
+ }
179
+ if (Number(file.size) > maxAttachmentBytes) {
180
+ throw new Error(tx('Selected attachment is too large.', '选择的附件太大。'));
181
+ }
182
+ }
183
+
184
+ function buildAttachmentPreviewData(file, dataUrl) {
185
+ const mimeType = String(file?.type || '');
186
+ const name = normalizeAttachmentName(file?.name);
187
+ if (!isImageAttachment({ name, mimeType })) {
188
+ return Promise.resolve({ kind: 'file', previewDataUrl: '' });
189
+ }
190
+ return createScaledImagePreviewDataUrl(dataUrl).catch(() => '').then(scaled => {
191
+ const fallback = String(dataUrl || '').length <= maxPreviewDataUrlChars ? String(dataUrl || '') : '';
192
+ return {
193
+ kind: 'image',
194
+ previewDataUrl: scaled || fallback
195
+ };
196
+ });
197
+ }
198
+
199
+ function createScaledImagePreviewDataUrl(dataUrl) {
200
+ return new Promise(resolve => {
201
+ if (typeof Image === 'undefined' || typeof document === 'undefined' || !/^data:image\//i.test(String(dataUrl || ''))) {
202
+ resolve('');
203
+ return;
204
+ }
205
+ const image = new Image();
206
+ image.onload = () => {
207
+ const width = Number(image.naturalWidth || image.width);
208
+ const height = Number(image.naturalHeight || image.height);
209
+ if (!width || !height) {
210
+ resolve('');
211
+ return;
212
+ }
213
+ const maxEdge = 960;
214
+ const scale = Math.min(1, maxEdge / Math.max(width, height));
215
+ const canvas = document.createElement('canvas');
216
+ canvas.width = Math.max(1, Math.round(width * scale));
217
+ canvas.height = Math.max(1, Math.round(height * scale));
218
+ const context = canvas.getContext('2d');
219
+ if (!context) {
220
+ resolve('');
221
+ return;
222
+ }
223
+ context.drawImage(image, 0, 0, canvas.width, canvas.height);
224
+ const value = canvas.toDataURL('image/png');
225
+ resolve(value.length <= maxPreviewDataUrlChars ? value : '');
226
+ };
227
+ image.onerror = () => resolve('');
228
+ image.src = dataUrl;
229
+ });
230
+ }
231
+
232
+ // v1.8.0 C4: survive a page refresh. Only small attachments persist
233
+ // (single base64 <= 1MB, max 3) — oversized ones are dropped on restore
234
+ // rather than bloating chrome.storage and every debounced saveState.
235
+ const PERSIST_MAX_CONTENT_CHARS = 1024 * 1024;
236
+ const PERSIST_MAX_COUNT = 3;
237
+
238
+ function serializePersistableAttachments() {
239
+ return attachments
240
+ .filter(attachment => (attachment.contentBase64 || '').length <= PERSIST_MAX_CONTENT_CHARS)
241
+ .slice(0, PERSIST_MAX_COUNT)
242
+ .map(attachment => ({
243
+ id: attachment.id,
244
+ name: attachment.name,
245
+ mimeType: attachment.mimeType,
246
+ size: attachment.size,
247
+ kind: attachment.kind,
248
+ previewDataUrl: attachment.previewDataUrl,
249
+ contentBase64: attachment.contentBase64,
250
+ dedupeKey: attachment.dedupeKey
251
+ }));
252
+ }
253
+
254
+ function restorePersistedAttachments(persisted) {
255
+ const usable = (Array.isArray(persisted) ? persisted : [])
256
+ .filter(item => item && item.name && typeof item.contentBase64 === 'string'
257
+ && item.contentBase64.length > 0
258
+ && item.contentBase64.length <= PERSIST_MAX_CONTENT_CHARS)
259
+ .slice(0, PERSIST_MAX_COUNT);
260
+ if (!usable.length) {
261
+ return 0;
262
+ }
263
+ attachments = usable.map(item => ({ ...item }));
264
+ renderComposerAttachments();
265
+ return attachments.length;
266
+ }
267
+
268
+ return {
269
+ serializePersistableAttachments,
270
+ restorePersistedAttachments,
271
+ handlePaste,
272
+ handleDragOver,
273
+ handleDragLeave,
274
+ handleDrop,
275
+ addFiles,
276
+ getAttachmentByteUsage: () => ({
277
+ committed: getCommittedAttachmentBytes(),
278
+ pending: pendingAttachmentBytes,
279
+ limit: maxAttachmentTotalBytes
280
+ }),
281
+ getAttachmentsForRun,
282
+ createRunAttachmentSnapshots,
283
+ renderComposerAttachments,
284
+ renderAttachmentPreviewList: (value, container, options = {}) => renderAttachmentPreviewList(value, container, {
285
+ root: getPanel() || document.body,
286
+ tr,
287
+ tx,
288
+ ...options
289
+ }),
290
+ clear,
291
+ _private: {
292
+ collectFilesFromDataTransfer,
293
+ normalizeAttachmentName,
294
+ buildAttachmentDedupeKey,
295
+ isDuplicateAttachmentFile,
296
+ validateAttachmentFile
297
+ }
298
+ };
299
+ }
300
+
301
+ function collectFilesFromDataTransfer(dataTransfer) {
302
+ const addFile = (target, seen, file) => {
303
+ if (!file) {
304
+ return;
305
+ }
306
+ const key = [
307
+ normalizeAttachmentName(file.name).toLowerCase(),
308
+ String(file.type || '').trim().toLowerCase(),
309
+ Number(file.size) || 0,
310
+ Number(file.lastModified) || 0
311
+ ].join('\n');
312
+ if (seen.has(key)) {
313
+ return;
314
+ }
315
+ seen.add(key);
316
+ target.push(file);
317
+ };
318
+ const itemFiles = [];
319
+ const seenItemFiles = new Set();
320
+ for (const item of Array.from(dataTransfer?.items || [])) {
321
+ if (item?.kind !== 'file' || typeof item.getAsFile !== 'function') {
322
+ continue;
323
+ }
324
+ addFile(itemFiles, seenItemFiles, item.getAsFile());
325
+ }
326
+ if (itemFiles.length) {
327
+ return itemFiles;
328
+ }
329
+ const files = [];
330
+ const seenFiles = new Set();
331
+ for (const file of Array.from(dataTransfer?.files || [])) {
332
+ addFile(files, seenFiles, file);
333
+ }
334
+ return files;
335
+ }
336
+
337
+ function hasFileDataTransfer(dataTransfer) {
338
+ return Array.from(dataTransfer?.items || []).some(item => item?.kind === 'file') ||
339
+ Array.from(dataTransfer?.types || []).includes('Files') ||
340
+ Array.from(dataTransfer?.files || []).length > 0;
341
+ }
342
+
343
+ function buildAttachmentDedupeKey(file) {
344
+ const input = file || {};
345
+ return [
346
+ normalizeAttachmentName(input.name).toLowerCase(),
347
+ String(input.type || input.mimeType || '').trim().toLowerCase(),
348
+ Number(input.size) || 0
349
+ ].join('\n');
350
+ }
351
+
352
+ function normalizeAttachmentName(name) {
353
+ return String(name || '')
354
+ .replace(/\0/g, '')
355
+ .replace(/\\/g, '/')
356
+ .split('/')
357
+ .filter(Boolean)
358
+ .pop()
359
+ ?.trim()
360
+ .slice(0, 180) || '';
361
+ }
362
+
363
+ function renderAttachmentPreviewList(attachments = [], container, options = {}) {
364
+ if (!container) {
365
+ return;
366
+ }
367
+ container.replaceChildren();
368
+ const items = Array.isArray(attachments) ? attachments : [];
369
+ container.hidden = items.length === 0;
370
+ for (const attachment of items) {
371
+ const isImage = attachment.kind === 'image' && attachment.previewDataUrl;
372
+ const card = document.createElement(isImage ? 'button' : 'div');
373
+ card.className = 'codex-attachment-preview-card';
374
+ card.dataset.kind = isImage ? 'image' : 'file';
375
+ if (isImage) {
376
+ card.type = 'button';
377
+ card.title = options.tx
378
+ ? options.tx(`Open ${attachment.name}`, `打开 ${attachment.name}`)
379
+ : `Open ${attachment.name}`;
380
+ card.addEventListener('click', () => showAttachmentPreviewDialog(attachment, options));
381
+ const image = document.createElement('img');
382
+ image.src = attachment.previewDataUrl;
383
+ image.alt = attachment.name || (options.tx ? options.tx('Attached image', '已附加图片') : 'Attached image');
384
+ card.append(image);
385
+ } else {
386
+ const icon = document.createElement('span');
387
+ icon.className = 'codex-attachment-file-icon';
388
+ icon.textContent = getAttachmentIconLabel(attachment);
389
+ card.append(icon);
390
+ }
391
+
392
+ const meta = document.createElement('span');
393
+ meta.className = 'codex-attachment-preview-meta';
394
+ const name = document.createElement('span');
395
+ name.className = 'codex-attachment-preview-name';
396
+ name.textContent = attachment.name || (options.tx ? options.tx('Attachment', '附件') : 'Attachment');
397
+ const size = document.createElement('span');
398
+ size.className = 'codex-attachment-preview-size';
399
+ size.textContent = formatFileSize(attachment.size);
400
+ meta.append(name, size);
401
+ card.append(meta);
402
+
403
+ if (options.removable) {
404
+ const remove = document.createElement('button');
405
+ remove.type = 'button';
406
+ remove.className = 'codex-attachment-preview-remove';
407
+ remove.textContent = '×';
408
+ remove.title = options.tx ? options.tx('Remove attachment', '移除附件') : 'Remove attachment';
409
+ remove.setAttribute('aria-label', options.tx
410
+ ? options.tx(`Remove ${attachment.name}`, `移除 ${attachment.name}`)
411
+ : `Remove ${attachment.name}`);
412
+ remove.addEventListener('click', event => {
413
+ event.preventDefault();
414
+ event.stopPropagation();
415
+ options.onRemove?.(attachment.id);
416
+ });
417
+ card.append(remove);
418
+ }
419
+ container.append(card);
420
+ }
421
+ }
422
+
423
+ function showAttachmentPreviewDialog(attachment, options = {}) {
424
+ if (!attachment?.previewDataUrl) {
425
+ return;
426
+ }
427
+ const overlay = document.createElement('div');
428
+ overlay.className = 'codex-attachment-preview-dialog';
429
+ overlay.setAttribute('data-attachment-preview-dialog', 'true');
430
+ overlay.setAttribute('role', 'dialog');
431
+ overlay.setAttribute('aria-modal', 'true');
432
+ overlay.setAttribute('aria-label', attachment.name || (options.tx ? options.tx('Attachment preview', '附件预览') : 'Attachment preview'));
433
+
434
+ const card = document.createElement('section');
435
+ card.className = 'codex-attachment-preview-dialog-card';
436
+ const close = document.createElement('button');
437
+ close.type = 'button';
438
+ close.className = 'codex-attachment-preview-dialog-close';
439
+ close.textContent = '×';
440
+ close.title = options.tr ? options.tr('close') : 'Close';
441
+ close.setAttribute('aria-label', options.tr ? options.tr('close') : 'Close');
442
+ const image = document.createElement('img');
443
+ image.src = attachment.previewDataUrl;
444
+ image.alt = attachment.name || (options.tx ? options.tx('Attached image', '已附加图片') : 'Attached image');
445
+ const caption = document.createElement('div');
446
+ caption.className = 'codex-attachment-preview-dialog-caption';
447
+ caption.textContent = attachment.name || '';
448
+ card.append(close, image, caption);
449
+ overlay.append(card);
450
+
451
+ const cleanup = () => {
452
+ document.removeEventListener('keydown', onKeydown, true);
453
+ overlay.remove();
454
+ };
455
+ const onKeydown = event => {
456
+ if (event.key === 'Escape') {
457
+ event.preventDefault();
458
+ cleanup();
459
+ }
460
+ };
461
+ overlay.addEventListener('click', event => {
462
+ if (event.target === overlay) {
463
+ cleanup();
464
+ }
465
+ });
466
+ close.addEventListener('click', cleanup);
467
+ document.addEventListener('keydown', onKeydown, true);
468
+ (options.root || document.body).append(overlay);
469
+ close.focus();
470
+ }
471
+
472
+ function getAttachmentIconLabel(attachment = {}) {
473
+ const name = String(attachment.name || '');
474
+ const extension = name.includes('.') ? name.split('.').pop().slice(0, 4).toUpperCase() : '';
475
+ if (/pdf/i.test(attachment.mimeType || '') || /\.pdf$/i.test(name)) {
476
+ return 'PDF';
477
+ }
478
+ return extension || 'FILE';
479
+ }
480
+
481
+ function formatFileSize(size) {
482
+ const bytes = Number(size) || 0;
483
+ if (bytes >= 1024 * 1024) {
484
+ return `${(bytes / (1024 * 1024)).toFixed(bytes >= 10 * 1024 * 1024 ? 0 : 1)} MB`;
485
+ }
486
+ if (bytes >= 1024) {
487
+ return `${Math.round(bytes / 1024)} KB`;
488
+ }
489
+ return `${bytes} B`;
490
+ }
491
+
492
+ function isImageAttachment(attachment = {}) {
493
+ return /^image\//i.test(String(attachment.mimeType || '')) ||
494
+ /\.(?:png|jpe?g|gif|webp|bmp|svg)$/i.test(String(attachment.name || ''));
495
+ }
496
+
497
+ function readFileAsDataUrl(file) {
498
+ return new Promise((resolve, reject) => {
499
+ const reader = new FileReader();
500
+ reader.onload = () => {
501
+ resolve(String(reader.result || ''));
502
+ };
503
+ reader.onerror = () => reject(reader.error || new Error('Could not read selected file'));
504
+ reader.readAsDataURL(file);
505
+ });
506
+ }
507
+
508
+ function extractBase64FromDataUrl(value) {
509
+ const text = String(value || '');
510
+ return text.includes(',') ? text.slice(text.indexOf(',') + 1) : text;
511
+ }
512
+
513
+ global.CodexOverleafComposerAttachments = {
514
+ createComposerAttachmentController,
515
+ _private: {
516
+ collectFilesFromDataTransfer,
517
+ normalizeAttachmentName,
518
+ buildAttachmentDedupeKey,
519
+ renderAttachmentPreviewList
520
+ }
521
+ };
522
+ })(window);