@mindexec/cli 0.2.478 → 0.2.479

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 (146) hide show
  1. package/README.md +323 -323
  2. package/codex-model-catalog.js +202 -202
  3. package/codex-runtime.js +1608 -1608
  4. package/desktop-workspace-state.cjs +120 -120
  5. package/electron/main.cjs +517 -517
  6. package/electron/preload.cjs +17 -17
  7. package/electron/recurring-update-owner-smoke.mjs +55 -55
  8. package/electron/recurring-update-owner.cjs +97 -97
  9. package/electron/source-smoke.mjs +108 -108
  10. package/electron/update-feed-resolver.cjs +79 -79
  11. package/electron/update-feed.json +10 -10
  12. package/electron/update-manager-smoke.mjs +146 -146
  13. package/electron/update-manager.cjs +297 -297
  14. package/electron/windows-package-smoke.mjs +156 -156
  15. package/launch-bridge.cjs +236 -236
  16. package/lzo1x.js +110 -110
  17. package/mode4-atlas-worker.js +440 -440
  18. package/mode4-atlas.js +130 -130
  19. package/package.json +1 -1
  20. package/port-guard.cjs +285 -285
  21. package/remote-fast/osx-arm64/MacScreenCaptureKitH264.swift +339 -339
  22. package/remote-fast/osx-arm64/MacSystemAudioCapture.swift +214 -214
  23. package/remote-fast/osx-arm64/UnixRawCaptureHelper.mjs +80 -80
  24. package/remote-fast/osx-arm64/livedesk-raw-capture-resampler.mjs +73 -73
  25. package/remote-fast/osx-arm64/mindexec-remote-fast.dll +0 -0
  26. package/remote-fast/osx-x64/MacScreenCaptureKitH264.swift +339 -339
  27. package/remote-fast/osx-x64/MacSystemAudioCapture.swift +214 -214
  28. package/remote-fast/osx-x64/UnixRawCaptureHelper.mjs +80 -80
  29. package/remote-fast/osx-x64/livedesk-raw-capture-resampler.mjs +73 -73
  30. package/remote-fast/osx-x64/mindexec-remote-fast.dll +0 -0
  31. package/remote-fast/win-x64/mindexec-remote-fast.dll +0 -0
  32. package/remote-hub.js +3698 -3698
  33. package/scripts/auth-session-smoke.mjs +262 -262
  34. package/scripts/board-render-ownership-smoke.mjs +85 -85
  35. package/scripts/codex-model-catalog-smoke.mjs +150 -150
  36. package/scripts/codex-runtime-home-smoke.mjs +58 -58
  37. package/scripts/codex-sdk-image-live-smoke.mjs +43 -43
  38. package/scripts/codex-sdk-image-runtime-smoke.mjs +122 -122
  39. package/scripts/codex-sdk-runtime-smoke.mjs +89 -89
  40. package/scripts/desktop-update-publisher-smoke.mjs +89 -89
  41. package/scripts/desktop-update-worker-smoke.mjs +72 -72
  42. package/scripts/desktop-workspace-state-smoke.mjs +81 -81
  43. package/scripts/drive-installed-update-smoke.mjs +67 -67
  44. package/scripts/npm-fresh-install-smoke.mjs +216 -216
  45. package/scripts/publish-mindexec-desktop-updates.mjs +224 -224
  46. package/scripts/remote-agent-managed-smoke.mjs +318 -318
  47. package/scripts/remote-agent-package-smoke.mjs +64 -64
  48. package/scripts/remote-agent-ws-smoke.mjs +269 -269
  49. package/scripts/remote-fast-live-rate-smoke.mjs +343 -343
  50. package/scripts/remote-fast-mdm-browser-smoke.mjs +453 -453
  51. package/scripts/remote-fleet-render-smoke.mjs +1350 -1350
  52. package/scripts/remote-frame-ws-smoke.mjs +234 -234
  53. package/scripts/remote-http-smoke.mjs +592 -592
  54. package/scripts/remote-hub-identity-smoke.mjs +146 -146
  55. package/scripts/remote-hub-scale-smoke.mjs +124 -124
  56. package/scripts/remote-hub-smoke.mjs +699 -699
  57. package/scripts/remote-input-ws-smoke.mjs +263 -263
  58. package/scripts/remote-registry-follower-smoke.mjs +815 -815
  59. package/scripts/setup-tree-sitter-grammars.mjs +59 -59
  60. package/server.js +7510 -7510
  61. package/start-bridge.bat +32 -32
  62. package/start-bridge.sh +81 -81
  63. package/tree-sitter-grammars/README.md +18 -0
  64. package/tree-sitter-grammars/tree-sitter-c_sharp.wasm +0 -0
  65. package/tree-sitter-grammars/tree-sitter-go.wasm +0 -0
  66. package/tree-sitter-grammars/tree-sitter-java.wasm +0 -0
  67. package/tree-sitter-grammars/tree-sitter-javascript.wasm +0 -0
  68. package/tree-sitter-grammars/tree-sitter-python.wasm +0 -0
  69. package/tree-sitter-grammars/tree-sitter-rust.wasm +0 -0
  70. package/tree-sitter-grammars/tree-sitter-tsx.wasm +0 -0
  71. package/tree-sitter-grammars/tree-sitter-typescript.wasm +0 -0
  72. package/wwwroot/_content/MindExecution.Plugins.Admin/css/admin-dashboard.css +546 -546
  73. package/wwwroot/_content/MindExecution.Plugins.Directory/background.png +0 -0
  74. package/wwwroot/_content/MindExecution.Plugins.YouTube/css/youtube-search.css +251 -251
  75. package/wwwroot/_content/MindExecution.Shared/css/mind-map-overrides.css +3789 -3789
  76. package/wwwroot/_content/MindExecution.Shared/fonts/NotoSansKR-Bold.ttf +0 -0
  77. package/wwwroot/_content/MindExecution.Shared/fonts/NotoSansKR-Regular.ttf +0 -0
  78. package/wwwroot/_content/MindExecution.Shared/js/agent-visualization.js +27 -27
  79. package/wwwroot/_content/MindExecution.Shared/js/background-themes.js +1449 -1449
  80. package/wwwroot/_content/MindExecution.Shared/js/code-master.js +8316 -8316
  81. package/wwwroot/_content/MindExecution.Shared/js/file-system-helper.js +338 -338
  82. package/wwwroot/_content/MindExecution.Shared/js/marked.min.js +69 -69
  83. package/wwwroot/_content/MindExecution.Shared/js/mind-map-animated-image-preview.js +270 -270
  84. package/wwwroot/_content/MindExecution.Shared/js/mind-map-automation-edge-motion.js +170 -170
  85. package/wwwroot/_content/MindExecution.Shared/js/mind-map-board-render-ownership.js +81 -81
  86. package/wwwroot/_content/MindExecution.Shared/js/mind-map-core.js +10506 -10506
  87. package/wwwroot/_content/MindExecution.Shared/js/mind-map-core.js.backup +1059 -0
  88. package/wwwroot/_content/MindExecution.Shared/js/mind-map-css3d-hotpath.js +902 -902
  89. package/wwwroot/_content/MindExecution.Shared/js/mind-map-css3d-manager.js +24637 -24637
  90. package/wwwroot/_content/MindExecution.Shared/js/mind-map-dev-guards.js +325 -325
  91. package/wwwroot/_content/MindExecution.Shared/js/mind-map-dnd.js +1491 -1491
  92. package/wwwroot/_content/MindExecution.Shared/js/mind-map-dnd.js.bak +434 -0
  93. package/wwwroot/_content/MindExecution.Shared/js/mind-map-glow-shader.js +70 -70
  94. package/wwwroot/_content/MindExecution.Shared/js/mind-map-image-policy.js +218 -218
  95. package/wwwroot/_content/MindExecution.Shared/js/mind-map-interactions.js +7078 -7078
  96. package/wwwroot/_content/MindExecution.Shared/js/mind-map-lod-plan-worker.js +160 -160
  97. package/wwwroot/_content/MindExecution.Shared/js/mind-map-lod-renderer.js +9958 -9958
  98. package/wwwroot/_content/MindExecution.Shared/js/mind-map-lod-video-placeholder.js +107 -107
  99. package/wwwroot/_content/MindExecution.Shared/js/mind-map-logic-workers.js +1004 -1004
  100. package/wwwroot/_content/MindExecution.Shared/js/mind-map-menu-manager.js +1351 -1351
  101. package/wwwroot/_content/MindExecution.Shared/js/mind-map-multi-select.js +865 -865
  102. package/wwwroot/_content/MindExecution.Shared/js/mind-map-node-search-worker.js +554 -554
  103. package/wwwroot/_content/MindExecution.Shared/js/mind-map-nodes.js +4744 -4744
  104. package/wwwroot/_content/MindExecution.Shared/js/mind-map-normal-boundary-optimizer.js +624 -624
  105. package/wwwroot/_content/MindExecution.Shared/js/mind-map-object-manager.js +241 -241
  106. package/wwwroot/_content/MindExecution.Shared/js/mind-map-object-manager.js.backup +372 -0
  107. package/wwwroot/_content/MindExecution.Shared/js/mind-map-pipeline.js +2290 -2290
  108. package/wwwroot/_content/MindExecution.Shared/js/mind-map-remote-codecs.js +202 -202
  109. package/wwwroot/_content/MindExecution.Shared/js/mind-map-render-plan.js +1971 -1971
  110. package/wwwroot/_content/MindExecution.Shared/js/mind-map-text-lod-system.js +93 -93
  111. package/wwwroot/_content/MindExecution.Shared/js/mind-map-texture-factory.js +2391 -2391
  112. package/wwwroot/_content/MindExecution.Shared/js/mind-map-texture-factory.js.backup +1258 -0
  113. package/wwwroot/_content/MindExecution.Shared/js/mind-map-video-playback-controls.js +95 -95
  114. package/wwwroot/_content/MindExecution.Shared/js/mind-map-visibility-worker.js +895 -895
  115. package/wwwroot/_content/MindExecution.Shared/js/mindmap-toolbar.js +636 -636
  116. package/wwwroot/_content/MindExecution.Shared/js/native-drop-handler.js +45 -45
  117. package/wwwroot/_content/MindExecution.Shared/js/plan-master.js +117 -117
  118. package/wwwroot/_content/MindExecution.Shared/js/renderers/CSS3DRenderer.js +114 -114
  119. package/wwwroot/_content/MindExecution.Shared/js/texture-worker-manager.js +10 -10
  120. package/wwwroot/_content/MindExecution.Shared/js/three.min.js +6 -6
  121. package/wwwroot/_content/MindExecution.Shared/js/token-manager.js +7 -7
  122. package/wwwroot/_content/MindExecution.Shared/js/troika-bundle.js +6 -6
  123. package/wwwroot/_content/MindExecution.Shared/js/troika-bundle.js.map +2 -2
  124. package/wwwroot/_content/MindExecution.Shared/lib/font-awesome/css/all.min.css +8 -8
  125. package/wwwroot/_content/MindExecution.Shared/models/vocab.txt +30522 -30522
  126. package/wwwroot/_framework/{Google.Protobuf.33g2gb5is0.dll → Google.Protobuf.9h59ukbel7.dll} +0 -0
  127. package/wwwroot/_framework/{MindExecution.Core.z100xo4mg0.dll → MindExecution.Core.xxsj2s456b.dll} +0 -0
  128. package/wwwroot/_framework/{MindExecution.Kernel.i9cldrx8yc.dll → MindExecution.Kernel.5jofnc0wrd.dll} +0 -0
  129. package/wwwroot/_framework/{MindExecution.Plugins.Admin.48lzqddb0a.dll → MindExecution.Plugins.Admin.t7m4jnqjg3.dll} +0 -0
  130. package/wwwroot/_framework/{MindExecution.Plugins.Business.7m8u1q142p.dll → MindExecution.Plugins.Business.6yjlhhavap.dll} +0 -0
  131. package/wwwroot/_framework/{MindExecution.Plugins.Concept.cl5wmr53od.dll → MindExecution.Plugins.Concept.iikqq5zxu0.dll} +0 -0
  132. package/wwwroot/_framework/{MindExecution.Plugins.Directory.slipi39041.dll → MindExecution.Plugins.Directory.c5ribyne4m.dll} +0 -0
  133. package/wwwroot/_framework/{MindExecution.Plugins.PlanMaster.qfge05l671.dll → MindExecution.Plugins.PlanMaster.365bbuifr9.dll} +0 -0
  134. package/wwwroot/_framework/{MindExecution.Plugins.YouTube.tn7v29ut77.dll → MindExecution.Plugins.YouTube.ax415cilpk.dll} +0 -0
  135. package/wwwroot/_framework/MindExecution.Shared.21e2nvf86u.dll +0 -0
  136. package/wwwroot/_framework/{MindExecution.Web.rwawrxxk38.dll → MindExecution.Web.ppgm8rey59.dll} +0 -0
  137. package/wwwroot/_framework/{System.Collections.23yxgetbju.dll → System.Collections.zyvbaq6mpd.dll} +0 -0
  138. package/wwwroot/_framework/blazor.boot.json +25 -25
  139. package/wwwroot/_headers +40 -40
  140. package/wwwroot/_redirects +1 -1
  141. package/wwwroot/index.html +772 -772
  142. package/wwwroot/js/marketing-tool.js +180 -180
  143. package/wwwroot/manifest.webmanifest +23 -23
  144. package/wwwroot/service-worker-assets.js +109 -81
  145. package/wwwroot/service-worker.js +33 -33
  146. package/wwwroot/_framework/MindExecution.Shared.rjqlf0umjn.dll +0 -0
@@ -1,1491 +1,1491 @@
1
- // File: mind-map-dnd.js
2
- // MindMap drag-and-drop handling (preprocess-then-create approach to reduce flicker)
3
- window.MindMapDnD = (function () {
4
-
5
- // ▼▼▼ [New] IndexedDB helper for directory handles ▼▼▼
6
- const IDBHelper = {
7
- dbName: 'MindMapDB',
8
- storeName: 'handles',
9
- async getDB() {
10
- return new Promise((resolve, reject) => {
11
- const request = indexedDB.open(this.dbName, 1);
12
- request.onupgradeneeded = (e) => {
13
- if (!e.target.result.objectStoreNames.contains(this.storeName)) {
14
- e.target.result.createObjectStore(this.storeName);
15
- }
16
- };
17
- request.onsuccess = () => resolve(request.result);
18
- request.onerror = () => reject(request.error);
19
- });
20
- },
21
- async set(key, val) {
22
- const db = await this.getDB();
23
- return new Promise((resolve, reject) => {
24
- const tx = db.transaction(this.storeName, 'readwrite');
25
- tx.objectStore(this.storeName).put(val, key);
26
- tx.oncomplete = () => resolve();
27
- tx.onerror = () => reject(tx.error);
28
- });
29
- },
30
- async get(key) {
31
- const db = await this.getDB();
32
- return new Promise((resolve, reject) => {
33
- const tx = db.transaction(this.storeName, 'readonly');
34
- const req = tx.objectStore(this.storeName).get(key);
35
- req.onsuccess = () => resolve(req.result);
36
- req.onerror = () => reject(req.error);
37
- });
38
- }
39
- };
40
- // ▲▲▲ [New] ▲▲▲
41
-
42
- if (!window.MindMapFileRegistry) {
43
- window.MindMapFileRegistry = [];
44
- }
45
-
46
- function registerCoreHandlers() {
47
- // 1. PDF handler
48
- if (!window.MindMapFileRegistry.some(h => h.name === 'CorePDF')) {
49
- window.MindMapFileRegistry.push({
50
- name: 'CorePDF',
51
- priority: 5,
52
- check: (file) => file.type === 'application/pdf',
53
- getType: () => 'pdf'
54
- });
55
- }
56
-
57
- // 2. Image handler (updated)
58
- if (!window.MindMapFileRegistry.some(h => h.name === 'CoreImage')) {
59
- window.MindMapFileRegistry.push({
60
- name: 'CoreImage',
61
- priority: 5,
62
- // [Fix] If MIME type is empty or octet-stream, fall back to extension check
63
- check: (file) => {
64
- if (file.type && file.type.startsWith('image/')) return true;
65
-
66
- // Extension-based check (Windows/WebView2 compatibility)
67
- const name = file.name ? file.name.toLowerCase() : '';
68
- return /\.(jpg|jpeg|png|gif|webp|bmp|svg)$/.test(name);
69
- },
70
- getType: () => 'image'
71
- });
72
- }
73
-
74
- // 3. CSV table handler
75
- if (!window.MindMapFileRegistry.some(h => h.name === 'CoreCSV')) {
76
- window.MindMapFileRegistry.push({
77
- name: 'CoreCSV',
78
- priority: 6,
79
- check: isCsvFile,
80
- getType: () => CSV_TABLE_CONTENT_TYPE
81
- });
82
- }
83
-
84
- // 4. Video handler
85
- if (!window.MindMapFileRegistry.some(h => h.name === 'CoreVideo')) {
86
- window.MindMapFileRegistry.push({
87
- name: 'CoreVideo',
88
- priority: 5,
89
- check: (file) => {
90
- if (file.type && file.type.startsWith('video/')) return true;
91
- const name = file.name ? file.name.toLowerCase() : '';
92
- return /\.(mp4|webm|mov|ogv|m4v)$/.test(name);
93
- },
94
- getType: () => 'video'
95
- });
96
- }
97
-
98
- // ▼▼▼ [New] 4. Text handler (basic .txt support) ▼▼▼
99
- if (!window.MindMapFileRegistry.some(h => h.name === 'CoreText')) {
100
- window.MindMapFileRegistry.push({
101
- name: 'CoreText',
102
- priority: 5,
103
- check: (file) => {
104
- // If MIME type is text/plain or extension is .txt or .md
105
- const name = file.name ? file.name.toLowerCase() : '';
106
- return file.type === 'text/plain' ||
107
- name.endsWith('.txt') ||
108
- name.endsWith('.md');
109
- },
110
- getType: () => 'text'
111
- });
112
- }
113
- // ▲▲▲ [New] ▲▲▲
114
-
115
- // ▼▼▼ [New] 5. Code handler (source code files - Troika rendered) ▼▼▼
116
- if (!window.MindMapFileRegistry.some(h => h.name === 'CoreCode')) {
117
- const CODE_EXTENSIONS = [
118
- '.js', '.jsx', '.ts', '.tsx', '.py', '.cs', '.java',
119
- '.cpp', '.c', '.h', '.go', '.rs', '.rb', '.php',
120
- '.swift', '.kt', '.json', '.xml', '.yaml', '.yml',
121
- '.sql', '.html', '.css', '.scss', '.less', '.sh',
122
- '.bat', '.ps1', '.razor' // Added .razor for Blazor
123
- ];
124
- window.MindMapFileRegistry.push({
125
- name: 'CoreCode',
126
- priority: 4, // Higher priority than text
127
- check: (file) => {
128
- const name = file.name ? file.name.toLowerCase() : '';
129
- return CODE_EXTENSIONS.some(ext => name.endsWith(ext));
130
- },
131
- getType: () => 'code'
132
- });
133
- }
134
- // ▲▲▲ [New] ▲▲▲
135
-
136
- console.log('[MindMapDnD] Core file handlers (PDF, Image, Video, CSV, Text, Code) registered');
137
- }
138
- registerCoreHandlers();
139
-
140
- const eventHandlers = new WeakMap();
141
- const GRID_SNAP = 10;
142
- const CSV_TABLE_CONTENT_TYPE = 'csv-table';
143
-
144
- function isCsvFile(file) {
145
- const name = String(file?.name || '').toLowerCase();
146
- const mime = String(file?.type || '').toLowerCase();
147
- return mime === 'text/csv' ||
148
- mime === 'application/csv' ||
149
- name.endsWith('.csv');
150
- }
151
-
152
- function computeDroppedImageNodeSize(originalWidth, originalHeight) {
153
- if (window.mindMap?.computeImageNodeSize) {
154
- return window.mindMap.computeImageNodeSize(originalWidth, originalHeight, {
155
- gridSnap: GRID_SNAP,
156
- minWidth: 100,
157
- minHeight: 100,
158
- maxWidth: 4000,
159
- maxHeight: 4000,
160
- fallbackWidth: 470,
161
- fallbackHeight: 350
162
- });
163
- }
164
-
165
- const width = Math.max(100, Math.round(Number(originalWidth || 470) / GRID_SNAP) * GRID_SNAP);
166
- const height = Math.max(100, Math.round(Number(originalHeight || 350) / GRID_SNAP) * GRID_SNAP);
167
- return { width, height };
168
- }
169
-
170
- async function readCsvFileText(file) {
171
- if (file && typeof file.text === 'function') {
172
- return await file.text();
173
- }
174
-
175
- return await new Promise((resolve, reject) => {
176
- const reader = new FileReader();
177
- reader.onload = () => resolve(String(reader.result || ''));
178
- reader.onerror = () => reject(reader.error || new Error('CSV read failed'));
179
- reader.readAsText(file);
180
- });
181
- }
182
-
183
- function summarizeCsvText(csvText) {
184
- if (window.MindMapCsvTable?.summarizeCsv) {
185
- return window.MindMapCsvTable.summarizeCsv(csvText);
186
- }
187
-
188
- const firstLine = String(csvText || '').split(/\r\n|\n|\r/, 1)[0] || '';
189
- const columnCount = firstLine ? Math.max(1, firstLine.split(',').length) : 0;
190
- const rowCount = Math.max(0, String(csvText || '').split(/\r\n|\n|\r/).filter(Boolean).length - 1);
191
- return { rowCount, columnCount, delimiter: ',' };
192
- }
193
-
194
- // ▼▼▼ [Profiling] Drag statistics tracking ▼▼▼
195
- let _dragStats = {
196
- startTime: 0,
197
- dragOverCount: 0
198
- };
199
- // ▲▲▲ [Profiling] ▲▲▲
200
-
201
- function getBoundHandlers(module) {
202
- if (!eventHandlers.has(module)) {
203
- eventHandlers.set(module, {
204
- boundOnDragEnter: onDragEnter.bind(null, module), // [New]
205
- boundOnDragOver: onDragOver.bind(null, module),
206
- boundOnDragLeave: onDragLeave.bind(null, module),
207
- boundOnDrop: onDrop.bind(null, module)
208
- });
209
- }
210
- return eventHandlers.get(module);
211
- }
212
-
213
- // [New] dragenter handler
214
- function onDragEnter(module, e) {
215
- // Ignore movements between child elements inside the container
216
- if (module.container.contains(e.relatedTarget)) return;
217
-
218
- e.preventDefault();
219
- e.stopPropagation();
220
- if (e.dataTransfer) {
221
- e.dataTransfer.dropEffect = 'copy';
222
- }
223
-
224
- // ▼▼▼ [Profiling] Record drag start time ▼▼▼
225
- _dragStats.startTime = performance.now();
226
- _dragStats.dragOverCount = 0;
227
- console.log('[DnD] 🟢 Drag entered. Tracking started.');
228
- // ▲▲▲ [Profiling] ▲▲▲
229
-
230
- return false;
231
- }
232
-
233
- function onDragOver(module, e) {
234
- // ▼▼▼ [Optimization] Do the minimum work - avoid IPC overload ▼▼▼
235
- // preventDefault() is required to allow drop
236
- e.preventDefault();
237
-
238
- // Profiling counter (very lightweight)
239
- _dragStats.dragOverCount++;
240
-
241
- // Remove all extra work (like setting dropEffect)
242
- // In WebView2, each call can trigger IPC traffic
243
- return false;
244
- // ▲▲▲ [Optimization] ▲▲▲
245
- }
246
-
247
- function onDragLeave(module, e) {
248
- // Do not treat entering a child element as a leave
249
- if (module.container.contains(e.relatedTarget)) return;
250
-
251
- e.preventDefault();
252
- e.stopPropagation();
253
- }
254
-
255
- function extractImageUrl(dataTransfer) {
256
- if (!dataTransfer) return null;
257
- const html = dataTransfer.getData('text/html');
258
- if (html) {
259
- const parser = new DOMParser();
260
- const doc = parser.parseFromString(html, 'text/html');
261
- const img = doc.querySelector('img');
262
- if (img && img.src) return img.src;
263
- }
264
- const text = (dataTransfer.getData('text/plain') || '').trim();
265
- if (text && (text.startsWith('data:image') || isImageUrl(text))) return text;
266
- return null;
267
- }
268
-
269
- function extractDroppedUrl(dataTransfer) {
270
- if (!dataTransfer) return null;
271
- const uri = dataTransfer.getData('text/uri-list') || dataTransfer.getData('URL');
272
- if (uri) return uri.split(/\r?\n/).find(line => line && !line.startsWith('#')) || uri;
273
- const text = (dataTransfer.getData('text/plain') || '').trim();
274
- if (text && /^https?:\/\//i.test(text)) return text;
275
- return null;
276
- }
277
-
278
- function isImageUrl(url) {
279
- return /^https?:\/\/.+\.(png|jpe?g|gif|webp|bmp|svg)(\?|#|$)/i.test(String(url || '').trim());
280
- }
281
-
282
- async function fetchImageAsBlobUrl(url) {
283
- if (!url) return null;
284
- if (url.startsWith('data:') || url.startsWith('blob:')) {
285
- return url;
286
- }
287
-
288
- try {
289
- const response = await fetch(url, { mode: 'cors', credentials: 'omit' });
290
- if (!response.ok) throw new Error(`Network response was not ok (${response.status})`);
291
- const blob = await response.blob();
292
- return URL.createObjectURL(blob);
293
- } catch (e) {
294
- console.warn(`[MindMapDnD] CORS fetch failed (${e?.message || 'unknown'}). Image drop cancelled.`, e);
295
- window.MindMapFeedback?.toast?.('The server blocked copying.', { kind: 'error' });
296
- return null;
297
- }
298
- }
299
-
300
- function measureImageSize(src) {
301
- return new Promise((resolve) => {
302
- const img = new Image();
303
- img.onload = () => {
304
- resolve(computeDroppedImageNodeSize(img.naturalWidth, img.naturalHeight));
305
- };
306
- img.onerror = () => {
307
- console.warn('[MindMapDnD] Failed to measure image size. Using default.');
308
- resolve(computeDroppedImageNodeSize(470, 350));
309
- };
310
- img.src = src;
311
- });
312
- }
313
-
314
- const HOSTED_LOCAL_BRIDGE_STORAGE_KEY = 'MindExec.AllowHostedLocalBridge';
315
- const CLOUD_VIDEO_DND_BLOCKED_MESSAGE = 'Local video files are not available in cloud mode. Use Local Bridge for large video workflows, or use YouTube, a direct video URL, or AI video.';
316
-
317
- function isLoopbackHost(hostname) {
318
- return hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1';
319
- }
320
-
321
- function getCurrentLoopbackBridgeUrl() {
322
- const location = window.location;
323
- if (!location || !/^https?:$/i.test(location.protocol || '') || !isLoopbackHost(location.hostname || '')) {
324
- return null;
325
- }
326
-
327
- return location.origin;
328
- }
329
-
330
- function preferCurrentLoopbackBridgeOrigin(bridgeUrl) {
331
- const currentOrigin = getCurrentLoopbackBridgeUrl();
332
- if (!bridgeUrl || !currentOrigin) {
333
- return bridgeUrl;
334
- }
335
-
336
- try {
337
- const bridgeUri = new URL(bridgeUrl);
338
- const currentUri = new URL(currentOrigin);
339
- if (isLoopbackHost(bridgeUri.hostname)
340
- && isLoopbackHost(currentUri.hostname)
341
- && bridgeUri.protocol === currentUri.protocol
342
- && bridgeUri.port === currentUri.port) {
343
- return currentOrigin;
344
- }
345
- } catch {
346
- return bridgeUrl;
347
- }
348
-
349
- return bridgeUrl;
350
- }
351
-
352
- // Direct upload to Bridge server (faster than C# route)
353
- const BRIDGE_URLS = [getCurrentLoopbackBridgeUrl(), 'http://127.0.0.1:5147', 'http://localhost:5147']
354
- .filter((url, index, urls) => !!url && urls.indexOf(url) === index);
355
- let bridgeAuthPromise = null;
356
- let bridgeConnectedPromise = null;
357
- let activeBridgeUrl = preferCurrentLoopbackBridgeOrigin(window.MindExecLocalBridge?.activeBridgeUrl || BRIDGE_URLS[0]);
358
-
359
- function isHostedLocalBridgeExplicitlyEnabled() {
360
- if (window.MindExecLocalBridge?.allowHostedAutoProbe === true) {
361
- return true;
362
- }
363
-
364
- try {
365
- return window.localStorage?.getItem(HOSTED_LOCAL_BRIDGE_STORAGE_KEY) === 'true';
366
- } catch {
367
- return false;
368
- }
369
- }
370
-
371
- function resetLocalBridgeProbeCache() {
372
- bridgeAuthPromise = null;
373
- bridgeConnectedPromise = null;
374
- window.__mindExecutionBridgeAuthPromise = null;
375
- }
376
-
377
- function setActiveBridgeUrl(bridgeUrl) {
378
- activeBridgeUrl = preferCurrentLoopbackBridgeOrigin(bridgeUrl || BRIDGE_URLS[0]);
379
- window.MindExecLocalBridge = {
380
- ...(window.MindExecLocalBridge || {}),
381
- activeBridgeUrl
382
- };
383
- }
384
-
385
- function setHostedLocalBridgeExplicitlyEnabled(enabled) {
386
- const nextEnabled = enabled === true;
387
- try {
388
- if (nextEnabled) {
389
- window.localStorage?.setItem(HOSTED_LOCAL_BRIDGE_STORAGE_KEY, 'true');
390
- } else {
391
- window.localStorage?.removeItem(HOSTED_LOCAL_BRIDGE_STORAGE_KEY);
392
- }
393
- } catch { }
394
-
395
- window.MindExecLocalBridge = {
396
- ...(window.MindExecLocalBridge || {}),
397
- allowHostedAutoProbe: nextEnabled,
398
- hostedAccessEnabled: nextEnabled
399
- };
400
- resetLocalBridgeProbeCache();
401
- window.dispatchEvent?.(new CustomEvent('mindexec-local-bridge-access-changed', {
402
- detail: { enabled: nextEnabled }
403
- }));
404
- }
405
-
406
- function isLocalBridgeAutoProbeAllowed() {
407
- if (window.__isNativeApp) {
408
- return false;
409
- }
410
-
411
- const hostname = window.location?.hostname || '';
412
- return isLoopbackHost(hostname) || isHostedLocalBridgeExplicitlyEnabled();
413
- }
414
-
415
- function getBridgeUrlCandidates() {
416
- const seen = new Set();
417
- return [getCurrentLoopbackBridgeUrl(), activeBridgeUrl, ...BRIDGE_URLS].filter(url => {
418
- if (!url || seen.has(url)) {
419
- return false;
420
- }
421
-
422
- seen.add(url);
423
- return true;
424
- });
425
- }
426
-
427
- function buildBridgeStatusUrl(bridgeUrl) {
428
- const nonce = `${Date.now()}-${Math.random().toString(16).slice(2)}`;
429
- return `${bridgeUrl}/api/status?mindexecOrigin=pages&cb=${encodeURIComponent(nonce)}`;
430
- }
431
-
432
- function rewriteBridgeAssetUrl(url, bridgeUrl) {
433
- if (!url || !bridgeUrl) {
434
- return url;
435
- }
436
-
437
- try {
438
- const parsed = new URL(url);
439
- if (!isLoopbackHost(parsed.hostname) || !parsed.pathname.startsWith('/assets/')) {
440
- return url;
441
- }
442
-
443
- return `${bridgeUrl}${parsed.pathname}${parsed.search}${parsed.hash}`;
444
- } catch {
445
- return url;
446
- }
447
- }
448
-
449
- async function getBridgeAuthHeaders() {
450
- if (!isLocalBridgeAutoProbeAllowed()) {
451
- return {};
452
- }
453
-
454
- bridgeAuthPromise ??= (async () => {
455
- for (const bridgeUrl of getBridgeUrlCandidates()) {
456
- try {
457
- const response = await fetch(buildBridgeStatusUrl(bridgeUrl), {
458
- cache: 'no-store',
459
- targetAddressSpace: 'loopback'
460
- });
461
- if (!response.ok) {
462
- continue;
463
- }
464
-
465
- const status = await response.json();
466
- setActiveBridgeUrl(bridgeUrl);
467
- const token = status?.bridgeToken;
468
- const header = status?.bridgeTokenHeader || 'X-Bridge-Token';
469
- return token ? { [header]: token } : {};
470
- } catch {
471
- // Try next loopback spelling.
472
- }
473
- }
474
-
475
- return {};
476
- })();
477
-
478
- return bridgeAuthPromise;
479
- }
480
-
481
- async function isBridgeConnected() {
482
- if (!isLocalBridgeAutoProbeAllowed()) {
483
- return false;
484
- }
485
-
486
- bridgeConnectedPromise ??= (async () => {
487
- const controller = typeof AbortController !== 'undefined' ? new AbortController() : null;
488
- const timeoutId = controller ? window.setTimeout(() => controller.abort(), 900) : null;
489
-
490
- try {
491
- for (const bridgeUrl of getBridgeUrlCandidates()) {
492
- try {
493
- const response = await fetch(buildBridgeStatusUrl(bridgeUrl), {
494
- cache: 'no-store',
495
- signal: controller?.signal,
496
- targetAddressSpace: 'loopback'
497
- });
498
- if (response.ok) {
499
- setActiveBridgeUrl(bridgeUrl);
500
- return true;
501
- }
502
- } catch {
503
- // Try next loopback spelling.
504
- }
505
- }
506
-
507
- return false;
508
- } finally {
509
- if (timeoutId !== null) {
510
- window.clearTimeout(timeoutId);
511
- }
512
- }
513
- })();
514
-
515
- return bridgeConnectedPromise;
516
- }
517
-
518
- async function enableHostedLocalBridgeAccessAndProbe() {
519
- setHostedLocalBridgeExplicitlyEnabled(true);
520
- return await isBridgeConnected();
521
- }
522
-
523
- window.MindExecLocalBridge = {
524
- ...(window.MindExecLocalBridge || {}),
525
- storageKey: HOSTED_LOCAL_BRIDGE_STORAGE_KEY,
526
- allowHostedAutoProbe: isHostedLocalBridgeExplicitlyEnabled(),
527
- hostedAccessEnabled: isHostedLocalBridgeExplicitlyEnabled(),
528
- isHostedAccessEnabled: isHostedLocalBridgeExplicitlyEnabled,
529
- enableHostedAccess: () => setHostedLocalBridgeExplicitlyEnabled(true),
530
- disableHostedAccess: () => setHostedLocalBridgeExplicitlyEnabled(false),
531
- enableHostedAccessAndProbe: enableHostedLocalBridgeAccessAndProbe,
532
- activeBridgeUrl
533
- };
534
-
535
- async function uploadAssetToBridge(file) {
536
- if (!isLocalBridgeAutoProbeAllowed()) {
537
- return { success: false };
538
- }
539
-
540
- try {
541
- const headers = await getBridgeAuthHeaders();
542
- for (const bridgeUrl of getBridgeUrlCandidates()) {
543
- const formData = new FormData();
544
- formData.append('file', file, file.name);
545
-
546
- const response = await fetch(`${bridgeUrl}/api/assets/upload`, {
547
- method: 'POST',
548
- headers,
549
- body: formData,
550
- targetAddressSpace: 'loopback'
551
- });
552
-
553
- if (!response.ok) {
554
- continue;
555
- }
556
-
557
- const result = await response.json();
558
- if (result.success) {
559
- setActiveBridgeUrl(bridgeUrl);
560
- return {
561
- success: true,
562
- url: rewriteBridgeAssetUrl(result.url, bridgeUrl), // Full HTTP URL for display
563
- relativeUrl: result.relativeUrl, // Relative path for document storage
564
- thumbnailUrl: rewriteBridgeAssetUrl(result.thumbnailUrl, bridgeUrl),
565
- width: result.width,
566
- height: result.height
567
- };
568
- }
569
- }
570
- return { success: false };
571
- } catch (err) {
572
- console.warn(`[DnD] Bridge upload failed: ${err.message}`);
573
- return { success: false };
574
- }
575
- }
576
-
577
- async function uploadImageToBridge(file) {
578
- return uploadAssetToBridge(file);
579
- }
580
-
581
- function getDroppedItemForFile(droppedItems, file, index) {
582
- const direct = droppedItems?.[index];
583
- if (direct?.kind === 'file') {
584
- const directFile = typeof direct.getAsFile === 'function' ? direct.getAsFile() : null;
585
- if (!directFile
586
- || directFile === file
587
- || (directFile.name === file.name && directFile.size === file.size && directFile.lastModified === file.lastModified)) {
588
- return direct;
589
- }
590
- }
591
-
592
- for (const item of droppedItems || []) {
593
- if (!item || item.kind !== 'file' || typeof item.getAsFile !== 'function') {
594
- continue;
595
- }
596
-
597
- const itemFile = item.getAsFile();
598
- if (itemFile
599
- && itemFile.name === file.name
600
- && itemFile.size === file.size
601
- && itemFile.lastModified === file.lastModified) {
602
- return item;
603
- }
604
- }
605
-
606
- return null;
607
- }
608
-
609
- function getBrowserExposedLocalPath(file) {
610
- const candidates = [
611
- file?.path,
612
- file?.fullPath,
613
- file?.mozFullPath
614
- ];
615
-
616
- for (const candidate of candidates) {
617
- const value = String(candidate || '').trim();
618
- if (/^[a-z]:[\\/]/i.test(value) || value.startsWith('\\\\') || value.startsWith('/')) {
619
- return value;
620
- }
621
- }
622
-
623
- return '';
624
- }
625
-
626
- function rewriteBridgeLocalFileUrl(url, bridgeUrl) {
627
- if (!url || !bridgeUrl) {
628
- return url;
629
- }
630
-
631
- try {
632
- const parsed = new URL(url);
633
- if (!isLoopbackHost(parsed.hostname) || !parsed.pathname.startsWith('/api/local-files/')) {
634
- return url;
635
- }
636
-
637
- return `${bridgeUrl}${parsed.pathname}${parsed.search}${parsed.hash}`;
638
- } catch {
639
- return url;
640
- }
641
- }
642
-
643
- async function registerLocalFilePathWithBridge(file, localPath) {
644
- const normalizedPath = String(localPath || '').trim();
645
- if (!normalizedPath || !isLocalBridgeAutoProbeAllowed()) {
646
- return { success: false };
647
- }
648
-
649
- try {
650
- const headers = {
651
- ...(await getBridgeAuthHeaders()),
652
- 'Content-Type': 'application/json'
653
- };
654
-
655
- for (const bridgeUrl of getBridgeUrlCandidates()) {
656
- const response = await fetch(`${bridgeUrl}/api/local-files/register`, {
657
- method: 'POST',
658
- headers,
659
- body: JSON.stringify({
660
- path: normalizedPath,
661
- fileName: file?.name || '',
662
- size: Number(file?.size || 0),
663
- mime: file?.type || ''
664
- }),
665
- targetAddressSpace: 'loopback'
666
- });
667
-
668
- if (!response.ok) {
669
- continue;
670
- }
671
-
672
- const result = await response.json();
673
- if (result?.success && result?.url) {
674
- setActiveBridgeUrl(bridgeUrl);
675
- return {
676
- success: true,
677
- id: result.id || '',
678
- url: rewriteBridgeLocalFileUrl(result.url, bridgeUrl),
679
- fileName: result.fileName || file?.name || '',
680
- size: Number(result.size || file?.size || 0),
681
- mime: result.mime || file?.type || '',
682
- lastModified: Number(result.lastModified || file?.lastModified || 0)
683
- };
684
- }
685
- }
686
- } catch (error) {
687
- console.warn('[DnD] Bridge local file registration failed:', error);
688
- }
689
-
690
- return { success: false };
691
- }
692
-
693
- async function createLocalVideoReference(file, item, bridgeConnected) {
694
- const localPath = getBrowserExposedLocalPath(file);
695
- if (bridgeConnected && localPath) {
696
- const bridgeResult = await registerLocalFilePathWithBridge(file, localPath);
697
- if (bridgeResult.success) {
698
- return {
699
- response: bridgeResult.url,
700
- displayUrl: bridgeResult.url,
701
- metadata: {
702
- assetKind: 'video',
703
- sourceKind: 'local-path',
704
- videoMode: 'local_bridge_stream',
705
- storageScope: 'original_file',
706
- persistPolicy: 'reference_only',
707
- localBridgeFileId: bridgeResult.id,
708
- localFilePath: localPath,
709
- fileName: bridgeResult.fileName || file.name || '',
710
- fileSize: String(bridgeResult.size || file.size || 0),
711
- fileMime: bridgeResult.mime || file.type || '',
712
- fileLastModified: String(bridgeResult.lastModified || file.lastModified || 0)
713
- },
714
- saved: true
715
- };
716
- }
717
- }
718
-
719
- const registry = window.MindMapLocalFiles;
720
- const entry = registry?.registerDroppedFile
721
- ? await registry.registerDroppedFile(file, item)
722
- : null;
723
- const objectUrl = entry?.objectUrl || URL.createObjectURL(file);
724
- const reference = entry?.reference || objectUrl;
725
- const sourceKind = entry?.sourceKind || 'session-file';
726
- const durableHandle = entry?.durableHandle === true;
727
-
728
- return {
729
- response: reference,
730
- displayUrl: objectUrl,
731
- metadata: {
732
- assetKind: 'video',
733
- sourceKind,
734
- localFileId: entry?.id || '',
735
- localFileReference: reference,
736
- fileName: file.name || '',
737
- fileSize: String(file.size || 0),
738
- fileMime: file.type || '',
739
- fileLastModified: String(file.lastModified || 0),
740
- videoMode: durableHandle ? 'local_handle' : 'session_blob',
741
- storageScope: durableHandle ? 'original_file' : 'session',
742
- persistPolicy: durableHandle ? 'reference_only' : 'session_only'
743
- },
744
- saved: true
745
- };
746
- }
747
-
748
- function showCloudVideoDndBlockedToast(count = 1) {
749
- const suffix = count > 1 ? ` (${count} files skipped)` : '';
750
- window.MindMapFeedback?.toast?.(`${CLOUD_VIDEO_DND_BLOCKED_MESSAGE}${suffix}`, {
751
- kind: 'error',
752
- duration: 6800
753
- });
754
- }
755
- // ▲▲▲ [New] ▲▲▲
756
-
757
- function fileToDataUrl(file) {
758
- return new Promise((resolve, reject) => {
759
- const reader = new FileReader();
760
- reader.onload = () => resolve(reader.result);
761
- reader.onerror = () => reject(reader.error);
762
- reader.readAsDataURL(file);
763
- });
764
- }
765
-
766
- async function handleBrowserImageDrop(module, e, imageUrl) {
767
- const finalUrl = await fetchImageAsBlobUrl(imageUrl);
768
- if (!finalUrl) {
769
- console.log('[MindMapDnD] Image drop cancelled due to CORS/Fetch error.');
770
- return;
771
- }
772
-
773
- const mouse = {
774
- x: (e.clientX / module.container.clientWidth) * 2 - 1,
775
- y: -(e.clientY / module.container.clientHeight) * 2 + 1
776
- };
777
- const vfov = (module.camera.fov * Math.PI) / 180;
778
- const viewHeight = 2 * Math.tan(vfov / 2) * module.camera.position.z;
779
- const viewWidth = viewHeight * module.camera.aspect;
780
- const worldX = module.camera.position.x + (mouse.x * viewWidth) / 2;
781
- const worldY = module.camera.position.y + (mouse.y * viewHeight) / 2;
782
-
783
- const size = await measureImageSize(finalUrl);
784
-
785
- const centeredX = worldX - (size.width / 2);
786
- const centeredY = worldY + (size.height / 2);
787
-
788
- const finalNode = {
789
- TempId: `temp_browser_${Date.now()}`,
790
- ContentType: 'image',
791
- FileName: 'Web Image',
792
- Response: finalUrl,
793
- X: module.snapToGrid(centeredX),
794
- Y: module.snapToGridY(centeredY),
795
- Width: size.width,
796
- Height: size.height,
797
- IsLoading: false
798
- };
799
-
800
- const truncatedUrl = finalUrl.length > 50 ? `${finalUrl.substring(0, 50)}...` : finalUrl;
801
-
802
- try {
803
- const actualNodeIdsMap = await module.dotNetHelper.invokeMethodAsync('AddFileNodesFromJs', [finalNode]);
804
- const actualId = actualNodeIdsMap?.[finalNode.TempId];
805
- if (actualId) {
806
- const batchDetails = [{
807
- nodeId: actualId,
808
- fileUrl: finalUrl,
809
- width: size.width,
810
- height: size.height
811
- }];
812
- await module.dotNetHelper.invokeMethodAsync('UpdateMultipleFileNodeDetails', batchDetails);
813
- }
814
- } catch (err) {
815
- console.error('[MindMapDnD] Failed to add browser image:', err);
816
- }
817
- }
818
-
819
- async function handleDirectoryDrop(module, handle, e) {
820
- console.log('[MindMapDnD] Directory dropped:', handle.name);
821
-
822
- const mouse = {
823
- x: (e.clientX / module.container.clientWidth) * 2 - 1,
824
- y: -(e.clientY / module.container.clientHeight) * 2 + 1
825
- };
826
- const vfov = (module.camera.fov * Math.PI) / 180;
827
- const viewHeight = 2 * Math.tan(vfov / 2) * module.camera.position.z;
828
- const viewWidth = viewHeight * module.camera.aspect;
829
- const worldX = module.camera.position.x + (mouse.x * viewWidth) / 2;
830
- const worldY = module.camera.position.y + (mouse.y * viewHeight) / 2;
831
-
832
- const centeredX = module.snapToGrid(worldX);
833
- const centeredY = module.snapToGridY(worldY);
834
-
835
- try {
836
- const nodeId = await module.dotNetHelper.invokeMethodAsync('AddDirectoryNodeFromJs', handle.name, centeredX, centeredY);
837
- if (nodeId) {
838
- await IDBHelper.set(nodeId, handle);
839
- console.log(`[MindMapDnD] Directory handle stored for node ${nodeId}`);
840
- }
841
- } catch (err) {
842
- console.error('[MindMapDnD] Failed to add directory node:', err);
843
- }
844
- }
845
-
846
- async function onDrop(module, e) {
847
- // ▼▼▼ [Profiling] Log drag statistics ▼▼▼
848
- const dropTime = performance.now();
849
- const totalDragTime = _dragStats.startTime > 0 ? (dropTime - _dragStats.startTime).toFixed(0) : '?';
850
- console.log(`[DnD] 🔴 Drop received! dragTime=${totalDragTime}ms, dragOverCount=${_dragStats.dragOverCount}`);
851
- // ▲▲▲ [Profiling] ▲▲▲
852
-
853
- // ▼▼▼ [Changed] Prevent duplicate processing ▼▼▼
854
- if (e._dndProcessed) {
855
- console.log('[MindMapDnD] Drop event already processed, skipping.');
856
- return false;
857
- }
858
- e._dndProcessed = true;
859
- // ▲▲▲ [Changed] ▲▲▲
860
-
861
- console.log('[MindMapDnD] Drop event received!');
862
-
863
- e.preventDefault();
864
- e.stopPropagation();
865
- e.stopImmediatePropagation(); // Also block other handlers on the same element
866
-
867
- // ▼▼▼ [Critical fix] Capture file lists before starting async work ▼▼▼
868
- // e.dataTransfer.files can be cleared by the browser once async work (await) begins.
869
- const droppedFiles = e.dataTransfer.files ? Array.from(e.dataTransfer.files) : [];
870
- const droppedItems = e.dataTransfer.items ? Array.from(e.dataTransfer.items) : [];
871
- // ▲▲▲ [Critical fix] ▲▲▲
872
-
873
- // 1. Directory handling (FileSystem API)
874
- // (Note: droppedFiles is already copied into memory, so it's safe even with await.)
875
- // [Optimized] If all files are already supported (image, PDF, etc.), skip directory checks
876
- // (async handle requests) to avoid delay.
877
- let shouldCheckDirectory = false;
878
- if (droppedFiles.length === 0) {
879
- // If the file list is empty (pure folder drop, etc.), we need to check
880
- shouldCheckDirectory = true;
881
- } else {
882
- // If any file is not recognized (no extension, etc. => possibly a folder), check
883
- const sortedRegistry = window.MindMapFileRegistry; // Registry is global usage
884
- for (let droppedFileIndex = 0; droppedFileIndex < droppedFiles.length; droppedFileIndex++) {
885
- const file = droppedFiles[droppedFileIndex];
886
- let isKnown = false;
887
- for (const handler of sortedRegistry) {
888
- if (handler.check(file)) {
889
- isKnown = true;
890
- break;
891
- }
892
- }
893
- if (!isKnown) {
894
- shouldCheckDirectory = true;
895
- break;
896
- }
897
- }
898
- }
899
-
900
- // ▼▼▼ [Profiling] Measure directory-check time ▼▼▼
901
- const dirCheckStart = performance.now();
902
-
903
- if (shouldCheckDirectory && droppedItems.length > 0) {
904
- console.log('[DnD] 📁 Checking for directory drop...');
905
- for (const item of droppedItems) {
906
- // webkitGetAsEntry (sync / non-standard) vs getAsFileSystemHandle (async / standard)
907
- // getAsFileSystemHandle can be slow, so apply a timeout
908
- if (item.kind === 'file' && typeof item.getAsFileSystemHandle === 'function') {
909
- try {
910
- // ▼▼▼ [Optimization] 500ms timeout to avoid blocking directory checks ▼▼▼
911
- const handlePromise = item.getAsFileSystemHandle();
912
- const timeoutPromise = new Promise((_, reject) =>
913
- setTimeout(() => reject(new Error('Timeout')), 500)
914
- );
915
-
916
- const handle = await Promise.race([handlePromise, timeoutPromise]);
917
- // ▲▲▲ [Optimization] ▲▲▲
918
-
919
- if (handle && handle.kind === 'directory') {
920
- console.log(`[DnD] 📁 Directory detected in ${(performance.now() - dirCheckStart).toFixed(0)}ms`);
921
- await handleDirectoryDrop(module, handle, e);
922
- return false;
923
- }
924
- } catch (err) {
925
- // If timeout/failure, skip quietly and continue as file handling
926
- console.warn(`[DnD] Directory check skipped: ${err.message}`);
927
- }
928
- }
929
- }
930
- }
931
- console.log(`[DnD] 📁 Directory check completed in ${(performance.now() - dirCheckStart).toFixed(0)}ms`);
932
- // ▲▲▲ [Profiling] ▲▲▲
933
-
934
- // 2. Regular file handling (use captured droppedFiles)
935
- if (droppedFiles.length > 0) {
936
- const timeLabel = `[DnD] Process_${Date.now()}`;
937
- console.time(timeLabel);
938
-
939
- // Debug: Log all dropped files
940
- console.log(`[DnD] 📂 Dropped ${droppedFiles.length} files:`);
941
- droppedFiles.forEach((f, i) => console.log(` [${i}] ${f.name} (type: ${f.type || 'unknown'}, size: ${f.size})`));
942
-
943
- const matchedFiles = [];
944
- const filesToProcess = [];
945
- const sortedRegistry = [...window.MindMapFileRegistry].sort((a, b) => (a.priority ?? 50) - (b.priority ?? 50));
946
-
947
- for (let droppedFileIndex = 0; droppedFileIndex < droppedFiles.length; droppedFileIndex++) {
948
- const file = droppedFiles[droppedFileIndex];
949
- let matchedType = null;
950
- for (const handler of sortedRegistry) {
951
- if (handler.check(file)) {
952
- matchedType = handler.getType(file);
953
- break;
954
- }
955
- }
956
- if (matchedType) {
957
- matchedFiles.push({
958
- file,
959
- type: matchedType,
960
- item: getDroppedItemForFile(droppedItems, file, droppedFileIndex)
961
- });
962
- } else {
963
- console.warn(`[DnD] ⚠️ No handler matched for: ${file.name} (type: ${file.type})`);
964
- }
965
- }
966
-
967
- const bridgeConnected = !window.__isNativeApp && await isBridgeConnected();
968
- let blockedVideoCount = 0;
969
- for (const entry of matchedFiles) {
970
- if (entry.type === 'video' && !window.__isNativeApp && !bridgeConnected) {
971
- blockedVideoCount++;
972
- console.warn(`[DnD] Blocked local video DnD in cloud mode: ${entry.file.name}`);
973
- continue;
974
- }
975
-
976
- filesToProcess.push(entry);
977
- }
978
-
979
- if (blockedVideoCount > 0) {
980
- showCloudVideoDndBlockedToast(blockedVideoCount);
981
- }
982
-
983
- if (filesToProcess.length === 0) {
984
- console.timeEnd(timeLabel);
985
- console.log('[MindMapDnD] No matching file handlers found. Checking for web images...');
986
- } else {
987
- const mouse = {
988
- x: (e.clientX / module.container.clientWidth) * 2 - 1,
989
- y: -(e.clientY / module.container.clientHeight) * 2 + 1
990
- };
991
- const vfov = (module.camera.fov * Math.PI) / 180;
992
- const viewHeight = 2 * Math.tan(vfov / 2) * module.camera.position.z;
993
- const viewWidth = viewHeight * module.camera.aspect;
994
- const worldX = module.camera.position.x + (mouse.x * viewWidth) / 2;
995
- const worldY = module.camera.position.y + (mouse.y * viewHeight) / 2;
996
-
997
- // ▼▼▼ [Optimization] Batch processing to prevent UI blocking ▼▼▼
998
- const BATCH_SIZE = 5; // Process 5 files at a time
999
- const preparedNodes = [];
1000
-
1001
- // Helper to yield to main thread
1002
- const yieldToMain = () => new Promise(resolve => setTimeout(resolve, 0));
1003
-
1004
- // ▼▼▼ [Fix] Track cumulative X offset for proper spacing ▼▼▼
1005
- const NODE_GAP = 30;
1006
- // ▲▲▲ [Fix] ▲▲▲
1007
-
1008
- // Process files in batches
1009
- for (let batchStart = 0; batchStart < filesToProcess.length; batchStart += BATCH_SIZE) {
1010
- const batchEnd = Math.min(batchStart + BATCH_SIZE, filesToProcess.length);
1011
- const batch = filesToProcess.slice(batchStart, batchEnd);
1012
-
1013
- const batchResults = await Promise.all(batch.map(async ({ file, type, item }, batchIndex) => {
1014
- const index = batchStart + batchIndex;
1015
- const tempId = `temp_${Date.now()}_${index}`;
1016
-
1017
- let finalResponse = '';
1018
- let displayUrl = ''; // For JS image loading
1019
- let finalWidth = module.aiNodeDefaultWidth || 470;
1020
- let finalHeight = module.aiNodeDefaultHeight || 350;
1021
- let isLoading = false;
1022
- const fileIndex = index;
1023
- let imageSaved = false; // Track if image was already saved
1024
- let videoSaved = false;
1025
- let metadata = null;
1026
-
1027
- if (type === 'image') {
1028
- const isAnimatedGif = file.type === 'image/gif' || /\.gif$/i.test(file.name || '');
1029
- if (isAnimatedGif) {
1030
- metadata = {
1031
- assetKind: 'image',
1032
- fileName: file.name || '',
1033
- fileSize: String(file.size || 0),
1034
- fileMime: file.type || 'image/gif',
1035
- fileLastModified: String(file.lastModified || ''),
1036
- isAnimatedGif: 'true'
1037
- };
1038
- }
1039
- // ▼▼▼ [Optimization] Direct Bridge upload (fastest path) ▼▼▼
1040
- console.log(`[DnD] Processing image ${index + 1}: ${file.name}`);
1041
-
1042
- // Try Bridge direct upload first (bypasses C# entirely)
1043
- if (bridgeConnected) {
1044
- const localSizingUrl = URL.createObjectURL(file);
1045
- const measuredSizePromise = measureImageSize(localSizingUrl).catch((err) => {
1046
- console.warn(`[DnD] Image ${index + 1} local measure failed: ${err?.message || err}`);
1047
- return null;
1048
- });
1049
-
1050
- const bridgeResult = await uploadImageToBridge(file);
1051
- const measuredSize = await measuredSizePromise;
1052
- if (bridgeResult.success) {
1053
- displayUrl = bridgeResult.url; // HTTP URL for display
1054
- finalResponse = bridgeResult.relativeUrl; // Relative path for storage
1055
- if (isAnimatedGif && metadata) {
1056
- metadata.OriginalPath = bridgeResult.relativeUrl || '';
1057
- }
1058
-
1059
- const imageNodeSize = measuredSize || computeDroppedImageNodeSize(
1060
- bridgeResult.width || 0,
1061
- bridgeResult.height || 0
1062
- );
1063
- finalWidth = imageNodeSize.width;
1064
- finalHeight = imageNodeSize.height;
1065
-
1066
- URL.revokeObjectURL(localSizingUrl);
1067
- imageSaved = true; // Already saved by Bridge
1068
- console.log(`[DnD] ✓ Bridge saved: ${file.name} -> ${bridgeResult.relativeUrl} (${finalWidth}x${finalHeight})`);
1069
- } else {
1070
- // Fallback to Blob URL + background save
1071
- const size = measuredSize || computeDroppedImageNodeSize(470, 350);
1072
- displayUrl = localSizingUrl;
1073
- finalResponse = localSizingUrl;
1074
- finalWidth = size.width;
1075
- finalHeight = size.height;
1076
- imageSaved = false;
1077
- }
1078
- } else {
1079
- // MAUI/Native: use Blob URL + background save via C#
1080
- try {
1081
- const sizingUrl = URL.createObjectURL(file);
1082
- const size = await measureImageSize(sizingUrl);
1083
- displayUrl = sizingUrl;
1084
- finalResponse = sizingUrl;
1085
- finalWidth = size.width;
1086
- finalHeight = size.height;
1087
- imageSaved = false;
1088
- } catch (err) {
1089
- console.warn(`[DnD] Image ${index + 1} measure failed: ${err.message}`);
1090
- displayUrl = URL.createObjectURL(file);
1091
- finalResponse = displayUrl;
1092
- const fallbackSize = computeDroppedImageNodeSize(470, 350);
1093
- finalWidth = fallbackSize.width;
1094
- finalHeight = fallbackSize.height;
1095
- imageSaved = false;
1096
- }
1097
- }
1098
- // ▲▲▲ [Optimization] ▲▲▲
1099
- isLoading = false;
1100
- } else if (type === 'video') {
1101
- console.log(`[DnD] Processing video ${index + 1}: ${file.name}`);
1102
- finalWidth = 470;
1103
- finalHeight = 264;
1104
- const localReference = await createLocalVideoReference(file, item, bridgeConnected);
1105
- displayUrl = localReference.displayUrl;
1106
- finalResponse = localReference.response;
1107
- metadata = localReference.metadata;
1108
- videoSaved = localReference.saved === true;
1109
-
1110
- // Legacy asset-copy path is intentionally unreachable for videos.
1111
- if (false && bridgeConnected) {
1112
- const bridgeResult = await uploadAssetToBridge(file);
1113
- if (bridgeResult.success) {
1114
- displayUrl = bridgeResult.url;
1115
- finalResponse = bridgeResult.relativeUrl;
1116
- videoSaved = true;
1117
- console.log(`[DnD] ✓ Bridge saved video: ${file.name} -> ${bridgeResult.relativeUrl}`);
1118
- }
1119
- }
1120
-
1121
- if (!videoSaved) {
1122
- displayUrl = window.__isNativeApp ? await fileToDataUrl(file) : URL.createObjectURL(file);
1123
- finalResponse = displayUrl;
1124
- }
1125
-
1126
- isLoading = false;
1127
- } else if (type === CSV_TABLE_CONTENT_TYPE) {
1128
- console.log(`[DnD] Processing CSV ${index + 1}: ${file.name}`);
1129
- finalResponse = await readCsvFileText(file);
1130
- displayUrl = finalResponse;
1131
- finalWidth = 720;
1132
- finalHeight = 420;
1133
- isLoading = false;
1134
- const csvSummary = summarizeCsvText(finalResponse);
1135
- metadata = {
1136
- assetKind: 'csv',
1137
- fileName: file.name || '',
1138
- fileSize: String(file.size || 0),
1139
- fileMime: file.type || 'text/csv',
1140
- fileLastModified: String(file.lastModified || ''),
1141
- CsvDelimiter: csvSummary.delimiter || ',',
1142
- CsvRowCount: String(csvSummary.rowCount ?? 0),
1143
- CsvColumnCount: String(csvSummary.columnCount ?? 0)
1144
- };
1145
- } else if (type === 'text') {
1146
- finalResponse = '⏳ Analyzing file...';
1147
- displayUrl = finalResponse;
1148
- isLoading = true;
1149
- } else if (type === 'code') {
1150
- // ▼▼▼ [New] Code files: Troika-rendered, load content immediately ▼▼▼
1151
- finalResponse = '⏳ Loading source code...';
1152
- displayUrl = finalResponse;
1153
- finalWidth = module.aiNodeDefaultWidth || 470;
1154
- finalHeight = 400; // Taller default for code
1155
- isLoading = true;
1156
- // ▲▲▲ [New] ▲▲▲
1157
- } else if (type === 'pdf') {
1158
- finalResponse = window.__isNativeApp ? await fileToDataUrl(file) : URL.createObjectURL(file);
1159
- displayUrl = finalResponse;
1160
- finalWidth = module.aiNodeDefaultWidth || 470;
1161
- finalHeight = 56;
1162
- }
1163
-
1164
- // X, Y will be recalculated after all dimensions are known
1165
- const tempX = 0;
1166
- const tempY = 0;
1167
-
1168
- return {
1169
- TempId: tempId,
1170
- ContentType: type,
1171
- FileName: file.name,
1172
- Response: finalResponse, // For C# storage
1173
- DisplayUrl: displayUrl, // For JS display
1174
- FileIndex: fileIndex,
1175
- X: tempX,
1176
- Y: tempY,
1177
- Width: finalWidth,
1178
- Height: finalHeight,
1179
- IsLoading: isLoading,
1180
- ImageSaved: imageSaved, // Flag to skip background save
1181
- VideoSaved: videoSaved,
1182
- Metadata: metadata
1183
- };
1184
- }));
1185
-
1186
- preparedNodes.push(...batchResults.filter(n => n !== null));
1187
-
1188
- // Yield to main thread after each batch to prevent UI freeze
1189
- if (batchEnd < filesToProcess.length) {
1190
- await yieldToMain();
1191
- }
1192
-
1193
- console.log(`[DnD] Prepared batch ${Math.floor(batchStart / BATCH_SIZE) + 1}/${Math.ceil(filesToProcess.length / BATCH_SIZE)}`);
1194
- }
1195
- // ▲▲▲ [Optimization] ▲▲▲
1196
-
1197
- const nodesToSend = preparedNodes.filter(n => n !== null);
1198
-
1199
- // ▼▼▼ [Fix] Recalculate X positions based on node widths + gap ▼▼▼
1200
- if (nodesToSend.length > 0) {
1201
- let nextLeftX = worldX - (nodesToSend[0].Width / 2);
1202
- for (const node of nodesToSend) {
1203
- const centeredY = worldY + (node.Height / 2);
1204
- node.X = module.snapToGrid(nextLeftX);
1205
- node.Y = module.snapToGridY(centeredY);
1206
- nextLeftX = node.X + node.Width + NODE_GAP;
1207
- }
1208
- }
1209
- // ▲▲▲ [Fix] ▲▲▲
1210
-
1211
- if (nodesToSend.length > 0) {
1212
- // 1. Get ID mapping from C# (batch call)
1213
- const t4 = performance.now();
1214
- const actualNodeIdsMap = await module.dotNetHelper.invokeMethodAsync('AddFileNodesFromJs', nodesToSend);
1215
- const t5 = performance.now();
1216
- console.log(`[DnD] AddFileNodesFromJs: ${(t5 - t4).toFixed(1)}ms for ${nodesToSend.length} nodes`);
1217
-
1218
- // ▼▼▼ [Optimization] Add nodes to JS in batches to prevent UI blocking ▼▼▼
1219
- const nodesToAdd = nodesToSend.map(dto => ({
1220
- id: actualNodeIdsMap[dto.TempId],
1221
- contentType: dto.ContentType,
1222
- prompt: dto.FileName,
1223
- response: dto.DisplayUrl || dto.Response, // Use DisplayUrl for images (HTTP URL)
1224
- positionX: dto.X,
1225
- positionY: dto.Y,
1226
- positionZ: 0,
1227
- width: dto.Width,
1228
- height: dto.Height,
1229
- isLoading: false,
1230
- metadata: dto.Metadata || {}
1231
- }));
1232
-
1233
- const JS_BATCH_SIZE = 10;
1234
- const t5b = performance.now();
1235
- for (let i = 0; i < nodesToAdd.length; i += JS_BATCH_SIZE) {
1236
- const batch = nodesToAdd.slice(i, i + JS_BATCH_SIZE);
1237
- await window.mindMap.addNodes(batch);
1238
-
1239
- // Yield to main thread between batches
1240
- if (i + JS_BATCH_SIZE < nodesToAdd.length) {
1241
- await yieldToMain();
1242
- }
1243
- }
1244
- const t5c = performance.now();
1245
- console.log(`[DnD] JS addNodes: ${(t5c - t5b).toFixed(1)}ms for ${nodesToAdd.length} nodes`);
1246
- // ▲▲▲ [Optimization] ▲▲▲
1247
-
1248
- // 2. Stream text files (BATCH PROCESSING to prevent Render Storm)
1249
- // ▼▼▼ [Optimization] Batch text file processing ▼▼▼
1250
- const textNodesToProcess = nodesToSend.filter(n => n.IsLoading && n.ContentType !== 'image');
1251
-
1252
- if (textNodesToProcess.length > 0) {
1253
- console.log(`[DnD] 📄 Batch processing ${textNodesToProcess.length} text files...`);
1254
-
1255
- // Prepare all stream references at once
1256
- const textFileStreams = [];
1257
- for (const nodeDto of textNodesToProcess) {
1258
- const realNodeId = actualNodeIdsMap?.[nodeDto.TempId];
1259
- if (!realNodeId) continue;
1260
-
1261
- const originalEntry = filesToProcess[nodeDto.FileIndex];
1262
- if (!originalEntry) continue;
1263
-
1264
- try {
1265
- const streamRef = DotNet.createJSStreamReference(originalEntry.file);
1266
- textFileStreams.push({
1267
- nodeId: realNodeId,
1268
- fileName: nodeDto.FileName,
1269
- streamRef: streamRef
1270
- });
1271
- } catch (err) {
1272
- console.error(`[DnD] Failed to create stream ref for ${nodeDto.FileName}:`, err);
1273
- }
1274
- }
1275
-
1276
- // Single batch call to C# (prevents N individual StateHasChanged calls)
1277
- if (textFileStreams.length > 0) {
1278
- try {
1279
- const t6a = performance.now();
1280
- await module.dotNetHelper.invokeMethodAsync(
1281
- 'IngestMultipleTextFilesDirectly',
1282
- textFileStreams
1283
- );
1284
- const t6b = performance.now();
1285
- console.log(`[DnD] ✅ Batch text processing: ${(t6b - t6a).toFixed(1)}ms for ${textFileStreams.length} files`);
1286
- } catch (err) {
1287
- console.error('[DnD] Batch text processing failed, falling back to individual:', err);
1288
- // Fallback to individual processing
1289
- for (const stream of textFileStreams) {
1290
- try {
1291
- await module.dotNetHelper.invokeMethodAsync(
1292
- 'IngestFileStreamDirectly',
1293
- stream.streamRef,
1294
- stream.nodeId,
1295
- stream.fileName
1296
- );
1297
- } catch (e) {
1298
- console.error(`[DnD] Individual stream error for ${stream.fileName}:`, e);
1299
- }
1300
- }
1301
- }
1302
- }
1303
- }
1304
- // ▲▲▲ [Optimization] ▲▲▲
1305
-
1306
- // ▼▼▼ [Optimization] Save images in background (Blob URL displayed immediately) ▼▼▼
1307
- const imageNodesToSave = nodesToSend.filter(n => n.ContentType === 'image' && !n.ImageSaved);
1308
- if (imageNodesToSave.length > 0) {
1309
- console.log(`[DnD] 🔄 Starting background save for ${imageNodesToSave.length} images...`);
1310
- // Fire-and-forget: start sequential image saving in background
1311
- (async () => {
1312
- for (let i = 0; i < imageNodesToSave.length; i++) {
1313
- const nodeDto = imageNodesToSave[i];
1314
- const realNodeId = actualNodeIdsMap?.[nodeDto.TempId];
1315
- if (!realNodeId) continue;
1316
-
1317
- const originalEntry = filesToProcess[nodeDto.FileIndex];
1318
- if (!originalEntry) continue;
1319
-
1320
- try {
1321
- const streamRef = DotNet.createJSStreamReference(originalEntry.file);
1322
- await module.dotNetHelper.invokeMethodAsync(
1323
- 'SaveImageStreamBackground',
1324
- streamRef,
1325
- realNodeId,
1326
- nodeDto.FileName
1327
- );
1328
-
1329
- if ((i + 1) % 10 === 0) {
1330
- console.log(`[DnD] Fallback saved ${i + 1}/${imageNodesToSave.length} images`);
1331
- }
1332
- } catch (err) {
1333
- console.warn('[DnD] Image save error:', err);
1334
- }
1335
- }
1336
- console.log(`[DnD] ✅ Background saved ${imageNodesToSave.length} images to assets`);
1337
- })();
1338
- } else {
1339
- const totalImages = nodesToSend.filter(n => n.ContentType === 'image').length;
1340
- if (totalImages > 0) {
1341
- console.log(`[DnD] ✅ All ${totalImages} images already saved via SaveImageAndGetInfo`);
1342
- }
1343
- }
1344
- // ▲▲▲ [Optimization] ▲▲▲
1345
-
1346
- const videoNodesToSave = nodesToSend.filter(n => n.ContentType === 'video' && !n.VideoSaved);
1347
- if (videoNodesToSave.length > 0) {
1348
- console.log(`[DnD] 🔄 Starting background save for ${videoNodesToSave.length} videos...`);
1349
- (async () => {
1350
- for (let i = 0; i < videoNodesToSave.length; i++) {
1351
- const nodeDto = videoNodesToSave[i];
1352
- const realNodeId = actualNodeIdsMap?.[nodeDto.TempId];
1353
- if (!realNodeId) continue;
1354
-
1355
- const originalEntry = filesToProcess[nodeDto.FileIndex];
1356
- if (!originalEntry) continue;
1357
-
1358
- try {
1359
- const streamRef = DotNet.createJSStreamReference(originalEntry.file);
1360
- await module.dotNetHelper.invokeMethodAsync(
1361
- 'SaveVideoStreamBackground',
1362
- streamRef,
1363
- realNodeId,
1364
- nodeDto.FileName
1365
- );
1366
- } catch (err) {
1367
- console.warn('[DnD] Video save error:', err);
1368
- }
1369
- }
1370
- console.log(`[DnD] ✅ Background saved ${videoNodesToSave.length} videos to assets`);
1371
- })();
1372
- }
1373
-
1374
- // 3. Update PDF URL only (images were already added in JS)
1375
- // ▼▼▼ [Changed] Images already handled in JS addNodes; update PDFs only ▼▼▼
1376
- const batchDetails = nodesToSend
1377
- .map(node => {
1378
- const nodeId = actualNodeIdsMap?.[node.TempId];
1379
- if (!nodeId) return null;
1380
- // Images and text have already been handled
1381
- if (node.ContentType === 'image') return null;
1382
- if (node.ContentType === 'video') return null;
1383
- if (node.IsLoading) return null; // text still loading
1384
-
1385
- return {
1386
- nodeId: nodeId,
1387
- fileUrl: node.Response,
1388
- width: node.Width,
1389
- height: node.Height
1390
- };
1391
- })
1392
- .filter(detail => detail !== null);
1393
-
1394
- // Execute all work in parallel
1395
- // ▼▼▼ [Profiling] Post-processing time ▼▼▼
1396
- const t6 = performance.now();
1397
- await Promise.all([
1398
- batchDetails.length > 0 ? module.dotNetHelper.invokeMethodAsync('UpdateMultipleFileNodeDetails', batchDetails) : Promise.resolve()
1399
- ]);
1400
- const t7 = performance.now();
1401
- console.log(`[DnD] UpdateMultipleFileNodeDetails: ${(t7 - t6).toFixed(1)}ms`);
1402
- // ▲▲▲ [Profiling] ▲▲▲
1403
- }
1404
-
1405
- console.timeEnd(timeLabel);
1406
- return false;
1407
- }
1408
- }
1409
-
1410
- // 3. Web resource (URL) handling
1411
- const imageUrl = extractImageUrl(e.dataTransfer);
1412
- console.log(`[DnD] extractImageUrl result: ${imageUrl ? imageUrl.substring(0, 50) + '...' : 'null'}`);
1413
- const isWebResource = imageUrl && (imageUrl.startsWith('http') || imageUrl.startsWith('data:'));
1414
- if (isWebResource) {
1415
- console.log('[DnD] Processing as web resource (should NOT happen if file was processed)');
1416
- await handleBrowserImageDrop(module, e, imageUrl);
1417
- return false;
1418
- }
1419
-
1420
- const droppedUrl = extractDroppedUrl(e.dataTransfer);
1421
- if (droppedUrl && /^https?:\/\//i.test(droppedUrl)) {
1422
- const mouse = {
1423
- x: (e.clientX / module.container.clientWidth) * 2 - 1,
1424
- y: -(e.clientY / module.container.clientHeight) * 2 + 1
1425
- };
1426
- const vfov = (module.camera.fov * Math.PI) / 180;
1427
- const viewHeight = 2 * Math.tan(vfov / 2) * module.camera.position.z;
1428
- const viewWidth = viewHeight * module.camera.aspect;
1429
- const worldX = module.camera.position.x + (mouse.x * viewWidth) / 2;
1430
- const worldY = module.camera.position.y + (mouse.y * viewHeight) / 2;
1431
-
1432
- await module.dotNetHelper?.invokeMethodAsync(
1433
- 'AddUrlNodeFromJs',
1434
- droppedUrl,
1435
- module.snapToGrid(worldX - 230),
1436
- module.snapToGridY(worldY + 130)
1437
- );
1438
- return false;
1439
- }
1440
-
1441
- // ▼▼▼ [Profiling] Drop handling complete ▼▼▼
1442
- const totalProcessTime = performance.now() - dropTime;
1443
- console.log(`[DnD] ✅ Drop processing completed in ${totalProcessTime.toFixed(0)}ms`);
1444
- // ▲▲▲ [Profiling] ▲▲▲
1445
-
1446
- return false;
1447
- }
1448
-
1449
- return {
1450
- addEventListeners: function (moduleInstance) {
1451
- // ▼▼▼ [Changed] Prevent duplicate registration ▼▼▼
1452
- if (moduleInstance._dndListenersAttached) {
1453
- console.log('[MindMapDnD] Event listeners already attached, skipping.');
1454
- return;
1455
- }
1456
- // ▲▲▲ [Changed] ▲▲▲
1457
-
1458
- // ▼▼▼ [New] Disable JS drag listeners in MAUI native app ▼▼▼
1459
- // C# NativeDropService handles it to avoid IPC bottlenecks and duplicated UI.
1460
- if (window.__isNativeApp === true) {
1461
- console.log('[MindMapDnD] Native app detected - JS drag handlers DISABLED (C# handles drop).');
1462
- moduleInstance._dndListenersAttached = true;
1463
- return;
1464
- }
1465
- // ▲▲▲ [New] ▲▲▲
1466
-
1467
- const handlers = getBoundHandlers(moduleInstance);
1468
- if (moduleInstance.container) {
1469
- moduleInstance.container.addEventListener('dragenter', handlers.boundOnDragEnter); // [New]
1470
- moduleInstance.container.addEventListener('dragover', handlers.boundOnDragOver);
1471
- moduleInstance.container.addEventListener('dragleave', handlers.boundOnDragLeave);
1472
- moduleInstance.container.addEventListener('drop', handlers.boundOnDrop);
1473
- moduleInstance._dndListenersAttached = true;
1474
- console.log('[MindMapDnD] Event listeners attached to container.');
1475
- }
1476
- },
1477
- removeEventListeners: function (moduleInstance) {
1478
- const handlers = getBoundHandlers(moduleInstance);
1479
- if (moduleInstance.container) {
1480
- moduleInstance.container.removeEventListener('dragenter', handlers.boundOnDragEnter); // [New]
1481
- moduleInstance.container.removeEventListener('dragover', handlers.boundOnDragOver);
1482
- moduleInstance.container.removeEventListener('dragleave', handlers.boundOnDragLeave);
1483
- moduleInstance.container.removeEventListener('drop', handlers.boundOnDrop);
1484
- }
1485
- moduleInstance._dndListenersAttached = false;
1486
- eventHandlers.delete(moduleInstance);
1487
- },
1488
- IDBHelper: IDBHelper
1489
- };
1490
- })();
1491
- console.log('✓ mind-map-dnd.js loaded (Fixed: Async DataTransfer Safety + Text Support).');
1
+ // File: mind-map-dnd.js
2
+ // MindMap drag-and-drop handling (preprocess-then-create approach to reduce flicker)
3
+ window.MindMapDnD = (function () {
4
+
5
+ // ▼▼▼ [New] IndexedDB helper for directory handles ▼▼▼
6
+ const IDBHelper = {
7
+ dbName: 'MindMapDB',
8
+ storeName: 'handles',
9
+ async getDB() {
10
+ return new Promise((resolve, reject) => {
11
+ const request = indexedDB.open(this.dbName, 1);
12
+ request.onupgradeneeded = (e) => {
13
+ if (!e.target.result.objectStoreNames.contains(this.storeName)) {
14
+ e.target.result.createObjectStore(this.storeName);
15
+ }
16
+ };
17
+ request.onsuccess = () => resolve(request.result);
18
+ request.onerror = () => reject(request.error);
19
+ });
20
+ },
21
+ async set(key, val) {
22
+ const db = await this.getDB();
23
+ return new Promise((resolve, reject) => {
24
+ const tx = db.transaction(this.storeName, 'readwrite');
25
+ tx.objectStore(this.storeName).put(val, key);
26
+ tx.oncomplete = () => resolve();
27
+ tx.onerror = () => reject(tx.error);
28
+ });
29
+ },
30
+ async get(key) {
31
+ const db = await this.getDB();
32
+ return new Promise((resolve, reject) => {
33
+ const tx = db.transaction(this.storeName, 'readonly');
34
+ const req = tx.objectStore(this.storeName).get(key);
35
+ req.onsuccess = () => resolve(req.result);
36
+ req.onerror = () => reject(req.error);
37
+ });
38
+ }
39
+ };
40
+ // ▲▲▲ [New] ▲▲▲
41
+
42
+ if (!window.MindMapFileRegistry) {
43
+ window.MindMapFileRegistry = [];
44
+ }
45
+
46
+ function registerCoreHandlers() {
47
+ // 1. PDF handler
48
+ if (!window.MindMapFileRegistry.some(h => h.name === 'CorePDF')) {
49
+ window.MindMapFileRegistry.push({
50
+ name: 'CorePDF',
51
+ priority: 5,
52
+ check: (file) => file.type === 'application/pdf',
53
+ getType: () => 'pdf'
54
+ });
55
+ }
56
+
57
+ // 2. Image handler (updated)
58
+ if (!window.MindMapFileRegistry.some(h => h.name === 'CoreImage')) {
59
+ window.MindMapFileRegistry.push({
60
+ name: 'CoreImage',
61
+ priority: 5,
62
+ // [Fix] If MIME type is empty or octet-stream, fall back to extension check
63
+ check: (file) => {
64
+ if (file.type && file.type.startsWith('image/')) return true;
65
+
66
+ // Extension-based check (Windows/WebView2 compatibility)
67
+ const name = file.name ? file.name.toLowerCase() : '';
68
+ return /\.(jpg|jpeg|png|gif|webp|bmp|svg)$/.test(name);
69
+ },
70
+ getType: () => 'image'
71
+ });
72
+ }
73
+
74
+ // 3. CSV table handler
75
+ if (!window.MindMapFileRegistry.some(h => h.name === 'CoreCSV')) {
76
+ window.MindMapFileRegistry.push({
77
+ name: 'CoreCSV',
78
+ priority: 6,
79
+ check: isCsvFile,
80
+ getType: () => CSV_TABLE_CONTENT_TYPE
81
+ });
82
+ }
83
+
84
+ // 4. Video handler
85
+ if (!window.MindMapFileRegistry.some(h => h.name === 'CoreVideo')) {
86
+ window.MindMapFileRegistry.push({
87
+ name: 'CoreVideo',
88
+ priority: 5,
89
+ check: (file) => {
90
+ if (file.type && file.type.startsWith('video/')) return true;
91
+ const name = file.name ? file.name.toLowerCase() : '';
92
+ return /\.(mp4|webm|mov|ogv|m4v)$/.test(name);
93
+ },
94
+ getType: () => 'video'
95
+ });
96
+ }
97
+
98
+ // ▼▼▼ [New] 4. Text handler (basic .txt support) ▼▼▼
99
+ if (!window.MindMapFileRegistry.some(h => h.name === 'CoreText')) {
100
+ window.MindMapFileRegistry.push({
101
+ name: 'CoreText',
102
+ priority: 5,
103
+ check: (file) => {
104
+ // If MIME type is text/plain or extension is .txt or .md
105
+ const name = file.name ? file.name.toLowerCase() : '';
106
+ return file.type === 'text/plain' ||
107
+ name.endsWith('.txt') ||
108
+ name.endsWith('.md');
109
+ },
110
+ getType: () => 'text'
111
+ });
112
+ }
113
+ // ▲▲▲ [New] ▲▲▲
114
+
115
+ // ▼▼▼ [New] 5. Code handler (source code files - Troika rendered) ▼▼▼
116
+ if (!window.MindMapFileRegistry.some(h => h.name === 'CoreCode')) {
117
+ const CODE_EXTENSIONS = [
118
+ '.js', '.jsx', '.ts', '.tsx', '.py', '.cs', '.java',
119
+ '.cpp', '.c', '.h', '.go', '.rs', '.rb', '.php',
120
+ '.swift', '.kt', '.json', '.xml', '.yaml', '.yml',
121
+ '.sql', '.html', '.css', '.scss', '.less', '.sh',
122
+ '.bat', '.ps1', '.razor' // Added .razor for Blazor
123
+ ];
124
+ window.MindMapFileRegistry.push({
125
+ name: 'CoreCode',
126
+ priority: 4, // Higher priority than text
127
+ check: (file) => {
128
+ const name = file.name ? file.name.toLowerCase() : '';
129
+ return CODE_EXTENSIONS.some(ext => name.endsWith(ext));
130
+ },
131
+ getType: () => 'code'
132
+ });
133
+ }
134
+ // ▲▲▲ [New] ▲▲▲
135
+
136
+ console.log('[MindMapDnD] Core file handlers (PDF, Image, Video, CSV, Text, Code) registered');
137
+ }
138
+ registerCoreHandlers();
139
+
140
+ const eventHandlers = new WeakMap();
141
+ const GRID_SNAP = 10;
142
+ const CSV_TABLE_CONTENT_TYPE = 'csv-table';
143
+
144
+ function isCsvFile(file) {
145
+ const name = String(file?.name || '').toLowerCase();
146
+ const mime = String(file?.type || '').toLowerCase();
147
+ return mime === 'text/csv' ||
148
+ mime === 'application/csv' ||
149
+ name.endsWith('.csv');
150
+ }
151
+
152
+ function computeDroppedImageNodeSize(originalWidth, originalHeight) {
153
+ if (window.mindMap?.computeImageNodeSize) {
154
+ return window.mindMap.computeImageNodeSize(originalWidth, originalHeight, {
155
+ gridSnap: GRID_SNAP,
156
+ minWidth: 100,
157
+ minHeight: 100,
158
+ maxWidth: 4000,
159
+ maxHeight: 4000,
160
+ fallbackWidth: 470,
161
+ fallbackHeight: 350
162
+ });
163
+ }
164
+
165
+ const width = Math.max(100, Math.round(Number(originalWidth || 470) / GRID_SNAP) * GRID_SNAP);
166
+ const height = Math.max(100, Math.round(Number(originalHeight || 350) / GRID_SNAP) * GRID_SNAP);
167
+ return { width, height };
168
+ }
169
+
170
+ async function readCsvFileText(file) {
171
+ if (file && typeof file.text === 'function') {
172
+ return await file.text();
173
+ }
174
+
175
+ return await new Promise((resolve, reject) => {
176
+ const reader = new FileReader();
177
+ reader.onload = () => resolve(String(reader.result || ''));
178
+ reader.onerror = () => reject(reader.error || new Error('CSV read failed'));
179
+ reader.readAsText(file);
180
+ });
181
+ }
182
+
183
+ function summarizeCsvText(csvText) {
184
+ if (window.MindMapCsvTable?.summarizeCsv) {
185
+ return window.MindMapCsvTable.summarizeCsv(csvText);
186
+ }
187
+
188
+ const firstLine = String(csvText || '').split(/\r\n|\n|\r/, 1)[0] || '';
189
+ const columnCount = firstLine ? Math.max(1, firstLine.split(',').length) : 0;
190
+ const rowCount = Math.max(0, String(csvText || '').split(/\r\n|\n|\r/).filter(Boolean).length - 1);
191
+ return { rowCount, columnCount, delimiter: ',' };
192
+ }
193
+
194
+ // ▼▼▼ [Profiling] Drag statistics tracking ▼▼▼
195
+ let _dragStats = {
196
+ startTime: 0,
197
+ dragOverCount: 0
198
+ };
199
+ // ▲▲▲ [Profiling] ▲▲▲
200
+
201
+ function getBoundHandlers(module) {
202
+ if (!eventHandlers.has(module)) {
203
+ eventHandlers.set(module, {
204
+ boundOnDragEnter: onDragEnter.bind(null, module), // [New]
205
+ boundOnDragOver: onDragOver.bind(null, module),
206
+ boundOnDragLeave: onDragLeave.bind(null, module),
207
+ boundOnDrop: onDrop.bind(null, module)
208
+ });
209
+ }
210
+ return eventHandlers.get(module);
211
+ }
212
+
213
+ // [New] dragenter handler
214
+ function onDragEnter(module, e) {
215
+ // Ignore movements between child elements inside the container
216
+ if (module.container.contains(e.relatedTarget)) return;
217
+
218
+ e.preventDefault();
219
+ e.stopPropagation();
220
+ if (e.dataTransfer) {
221
+ e.dataTransfer.dropEffect = 'copy';
222
+ }
223
+
224
+ // ▼▼▼ [Profiling] Record drag start time ▼▼▼
225
+ _dragStats.startTime = performance.now();
226
+ _dragStats.dragOverCount = 0;
227
+ console.log('[DnD] 🟢 Drag entered. Tracking started.');
228
+ // ▲▲▲ [Profiling] ▲▲▲
229
+
230
+ return false;
231
+ }
232
+
233
+ function onDragOver(module, e) {
234
+ // ▼▼▼ [Optimization] Do the minimum work - avoid IPC overload ▼▼▼
235
+ // preventDefault() is required to allow drop
236
+ e.preventDefault();
237
+
238
+ // Profiling counter (very lightweight)
239
+ _dragStats.dragOverCount++;
240
+
241
+ // Remove all extra work (like setting dropEffect)
242
+ // In WebView2, each call can trigger IPC traffic
243
+ return false;
244
+ // ▲▲▲ [Optimization] ▲▲▲
245
+ }
246
+
247
+ function onDragLeave(module, e) {
248
+ // Do not treat entering a child element as a leave
249
+ if (module.container.contains(e.relatedTarget)) return;
250
+
251
+ e.preventDefault();
252
+ e.stopPropagation();
253
+ }
254
+
255
+ function extractImageUrl(dataTransfer) {
256
+ if (!dataTransfer) return null;
257
+ const html = dataTransfer.getData('text/html');
258
+ if (html) {
259
+ const parser = new DOMParser();
260
+ const doc = parser.parseFromString(html, 'text/html');
261
+ const img = doc.querySelector('img');
262
+ if (img && img.src) return img.src;
263
+ }
264
+ const text = (dataTransfer.getData('text/plain') || '').trim();
265
+ if (text && (text.startsWith('data:image') || isImageUrl(text))) return text;
266
+ return null;
267
+ }
268
+
269
+ function extractDroppedUrl(dataTransfer) {
270
+ if (!dataTransfer) return null;
271
+ const uri = dataTransfer.getData('text/uri-list') || dataTransfer.getData('URL');
272
+ if (uri) return uri.split(/\r?\n/).find(line => line && !line.startsWith('#')) || uri;
273
+ const text = (dataTransfer.getData('text/plain') || '').trim();
274
+ if (text && /^https?:\/\//i.test(text)) return text;
275
+ return null;
276
+ }
277
+
278
+ function isImageUrl(url) {
279
+ return /^https?:\/\/.+\.(png|jpe?g|gif|webp|bmp|svg)(\?|#|$)/i.test(String(url || '').trim());
280
+ }
281
+
282
+ async function fetchImageAsBlobUrl(url) {
283
+ if (!url) return null;
284
+ if (url.startsWith('data:') || url.startsWith('blob:')) {
285
+ return url;
286
+ }
287
+
288
+ try {
289
+ const response = await fetch(url, { mode: 'cors', credentials: 'omit' });
290
+ if (!response.ok) throw new Error(`Network response was not ok (${response.status})`);
291
+ const blob = await response.blob();
292
+ return URL.createObjectURL(blob);
293
+ } catch (e) {
294
+ console.warn(`[MindMapDnD] CORS fetch failed (${e?.message || 'unknown'}). Image drop cancelled.`, e);
295
+ window.MindMapFeedback?.toast?.('The server blocked copying.', { kind: 'error' });
296
+ return null;
297
+ }
298
+ }
299
+
300
+ function measureImageSize(src) {
301
+ return new Promise((resolve) => {
302
+ const img = new Image();
303
+ img.onload = () => {
304
+ resolve(computeDroppedImageNodeSize(img.naturalWidth, img.naturalHeight));
305
+ };
306
+ img.onerror = () => {
307
+ console.warn('[MindMapDnD] Failed to measure image size. Using default.');
308
+ resolve(computeDroppedImageNodeSize(470, 350));
309
+ };
310
+ img.src = src;
311
+ });
312
+ }
313
+
314
+ const HOSTED_LOCAL_BRIDGE_STORAGE_KEY = 'MindExec.AllowHostedLocalBridge';
315
+ const CLOUD_VIDEO_DND_BLOCKED_MESSAGE = 'Local video files are not available in cloud mode. Use Local Bridge for large video workflows, or use YouTube, a direct video URL, or AI video.';
316
+
317
+ function isLoopbackHost(hostname) {
318
+ return hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1';
319
+ }
320
+
321
+ function getCurrentLoopbackBridgeUrl() {
322
+ const location = window.location;
323
+ if (!location || !/^https?:$/i.test(location.protocol || '') || !isLoopbackHost(location.hostname || '')) {
324
+ return null;
325
+ }
326
+
327
+ return location.origin;
328
+ }
329
+
330
+ function preferCurrentLoopbackBridgeOrigin(bridgeUrl) {
331
+ const currentOrigin = getCurrentLoopbackBridgeUrl();
332
+ if (!bridgeUrl || !currentOrigin) {
333
+ return bridgeUrl;
334
+ }
335
+
336
+ try {
337
+ const bridgeUri = new URL(bridgeUrl);
338
+ const currentUri = new URL(currentOrigin);
339
+ if (isLoopbackHost(bridgeUri.hostname)
340
+ && isLoopbackHost(currentUri.hostname)
341
+ && bridgeUri.protocol === currentUri.protocol
342
+ && bridgeUri.port === currentUri.port) {
343
+ return currentOrigin;
344
+ }
345
+ } catch {
346
+ return bridgeUrl;
347
+ }
348
+
349
+ return bridgeUrl;
350
+ }
351
+
352
+ // Direct upload to Bridge server (faster than C# route)
353
+ const BRIDGE_URLS = [getCurrentLoopbackBridgeUrl(), 'http://127.0.0.1:5147', 'http://localhost:5147']
354
+ .filter((url, index, urls) => !!url && urls.indexOf(url) === index);
355
+ let bridgeAuthPromise = null;
356
+ let bridgeConnectedPromise = null;
357
+ let activeBridgeUrl = preferCurrentLoopbackBridgeOrigin(window.MindExecLocalBridge?.activeBridgeUrl || BRIDGE_URLS[0]);
358
+
359
+ function isHostedLocalBridgeExplicitlyEnabled() {
360
+ if (window.MindExecLocalBridge?.allowHostedAutoProbe === true) {
361
+ return true;
362
+ }
363
+
364
+ try {
365
+ return window.localStorage?.getItem(HOSTED_LOCAL_BRIDGE_STORAGE_KEY) === 'true';
366
+ } catch {
367
+ return false;
368
+ }
369
+ }
370
+
371
+ function resetLocalBridgeProbeCache() {
372
+ bridgeAuthPromise = null;
373
+ bridgeConnectedPromise = null;
374
+ window.__mindExecutionBridgeAuthPromise = null;
375
+ }
376
+
377
+ function setActiveBridgeUrl(bridgeUrl) {
378
+ activeBridgeUrl = preferCurrentLoopbackBridgeOrigin(bridgeUrl || BRIDGE_URLS[0]);
379
+ window.MindExecLocalBridge = {
380
+ ...(window.MindExecLocalBridge || {}),
381
+ activeBridgeUrl
382
+ };
383
+ }
384
+
385
+ function setHostedLocalBridgeExplicitlyEnabled(enabled) {
386
+ const nextEnabled = enabled === true;
387
+ try {
388
+ if (nextEnabled) {
389
+ window.localStorage?.setItem(HOSTED_LOCAL_BRIDGE_STORAGE_KEY, 'true');
390
+ } else {
391
+ window.localStorage?.removeItem(HOSTED_LOCAL_BRIDGE_STORAGE_KEY);
392
+ }
393
+ } catch { }
394
+
395
+ window.MindExecLocalBridge = {
396
+ ...(window.MindExecLocalBridge || {}),
397
+ allowHostedAutoProbe: nextEnabled,
398
+ hostedAccessEnabled: nextEnabled
399
+ };
400
+ resetLocalBridgeProbeCache();
401
+ window.dispatchEvent?.(new CustomEvent('mindexec-local-bridge-access-changed', {
402
+ detail: { enabled: nextEnabled }
403
+ }));
404
+ }
405
+
406
+ function isLocalBridgeAutoProbeAllowed() {
407
+ if (window.__isNativeApp) {
408
+ return false;
409
+ }
410
+
411
+ const hostname = window.location?.hostname || '';
412
+ return isLoopbackHost(hostname) || isHostedLocalBridgeExplicitlyEnabled();
413
+ }
414
+
415
+ function getBridgeUrlCandidates() {
416
+ const seen = new Set();
417
+ return [getCurrentLoopbackBridgeUrl(), activeBridgeUrl, ...BRIDGE_URLS].filter(url => {
418
+ if (!url || seen.has(url)) {
419
+ return false;
420
+ }
421
+
422
+ seen.add(url);
423
+ return true;
424
+ });
425
+ }
426
+
427
+ function buildBridgeStatusUrl(bridgeUrl) {
428
+ const nonce = `${Date.now()}-${Math.random().toString(16).slice(2)}`;
429
+ return `${bridgeUrl}/api/status?mindexecOrigin=pages&cb=${encodeURIComponent(nonce)}`;
430
+ }
431
+
432
+ function rewriteBridgeAssetUrl(url, bridgeUrl) {
433
+ if (!url || !bridgeUrl) {
434
+ return url;
435
+ }
436
+
437
+ try {
438
+ const parsed = new URL(url);
439
+ if (!isLoopbackHost(parsed.hostname) || !parsed.pathname.startsWith('/assets/')) {
440
+ return url;
441
+ }
442
+
443
+ return `${bridgeUrl}${parsed.pathname}${parsed.search}${parsed.hash}`;
444
+ } catch {
445
+ return url;
446
+ }
447
+ }
448
+
449
+ async function getBridgeAuthHeaders() {
450
+ if (!isLocalBridgeAutoProbeAllowed()) {
451
+ return {};
452
+ }
453
+
454
+ bridgeAuthPromise ??= (async () => {
455
+ for (const bridgeUrl of getBridgeUrlCandidates()) {
456
+ try {
457
+ const response = await fetch(buildBridgeStatusUrl(bridgeUrl), {
458
+ cache: 'no-store',
459
+ targetAddressSpace: 'loopback'
460
+ });
461
+ if (!response.ok) {
462
+ continue;
463
+ }
464
+
465
+ const status = await response.json();
466
+ setActiveBridgeUrl(bridgeUrl);
467
+ const token = status?.bridgeToken;
468
+ const header = status?.bridgeTokenHeader || 'X-Bridge-Token';
469
+ return token ? { [header]: token } : {};
470
+ } catch {
471
+ // Try next loopback spelling.
472
+ }
473
+ }
474
+
475
+ return {};
476
+ })();
477
+
478
+ return bridgeAuthPromise;
479
+ }
480
+
481
+ async function isBridgeConnected() {
482
+ if (!isLocalBridgeAutoProbeAllowed()) {
483
+ return false;
484
+ }
485
+
486
+ bridgeConnectedPromise ??= (async () => {
487
+ const controller = typeof AbortController !== 'undefined' ? new AbortController() : null;
488
+ const timeoutId = controller ? window.setTimeout(() => controller.abort(), 900) : null;
489
+
490
+ try {
491
+ for (const bridgeUrl of getBridgeUrlCandidates()) {
492
+ try {
493
+ const response = await fetch(buildBridgeStatusUrl(bridgeUrl), {
494
+ cache: 'no-store',
495
+ signal: controller?.signal,
496
+ targetAddressSpace: 'loopback'
497
+ });
498
+ if (response.ok) {
499
+ setActiveBridgeUrl(bridgeUrl);
500
+ return true;
501
+ }
502
+ } catch {
503
+ // Try next loopback spelling.
504
+ }
505
+ }
506
+
507
+ return false;
508
+ } finally {
509
+ if (timeoutId !== null) {
510
+ window.clearTimeout(timeoutId);
511
+ }
512
+ }
513
+ })();
514
+
515
+ return bridgeConnectedPromise;
516
+ }
517
+
518
+ async function enableHostedLocalBridgeAccessAndProbe() {
519
+ setHostedLocalBridgeExplicitlyEnabled(true);
520
+ return await isBridgeConnected();
521
+ }
522
+
523
+ window.MindExecLocalBridge = {
524
+ ...(window.MindExecLocalBridge || {}),
525
+ storageKey: HOSTED_LOCAL_BRIDGE_STORAGE_KEY,
526
+ allowHostedAutoProbe: isHostedLocalBridgeExplicitlyEnabled(),
527
+ hostedAccessEnabled: isHostedLocalBridgeExplicitlyEnabled(),
528
+ isHostedAccessEnabled: isHostedLocalBridgeExplicitlyEnabled,
529
+ enableHostedAccess: () => setHostedLocalBridgeExplicitlyEnabled(true),
530
+ disableHostedAccess: () => setHostedLocalBridgeExplicitlyEnabled(false),
531
+ enableHostedAccessAndProbe: enableHostedLocalBridgeAccessAndProbe,
532
+ activeBridgeUrl
533
+ };
534
+
535
+ async function uploadAssetToBridge(file) {
536
+ if (!isLocalBridgeAutoProbeAllowed()) {
537
+ return { success: false };
538
+ }
539
+
540
+ try {
541
+ const headers = await getBridgeAuthHeaders();
542
+ for (const bridgeUrl of getBridgeUrlCandidates()) {
543
+ const formData = new FormData();
544
+ formData.append('file', file, file.name);
545
+
546
+ const response = await fetch(`${bridgeUrl}/api/assets/upload`, {
547
+ method: 'POST',
548
+ headers,
549
+ body: formData,
550
+ targetAddressSpace: 'loopback'
551
+ });
552
+
553
+ if (!response.ok) {
554
+ continue;
555
+ }
556
+
557
+ const result = await response.json();
558
+ if (result.success) {
559
+ setActiveBridgeUrl(bridgeUrl);
560
+ return {
561
+ success: true,
562
+ url: rewriteBridgeAssetUrl(result.url, bridgeUrl), // Full HTTP URL for display
563
+ relativeUrl: result.relativeUrl, // Relative path for document storage
564
+ thumbnailUrl: rewriteBridgeAssetUrl(result.thumbnailUrl, bridgeUrl),
565
+ width: result.width,
566
+ height: result.height
567
+ };
568
+ }
569
+ }
570
+ return { success: false };
571
+ } catch (err) {
572
+ console.warn(`[DnD] Bridge upload failed: ${err.message}`);
573
+ return { success: false };
574
+ }
575
+ }
576
+
577
+ async function uploadImageToBridge(file) {
578
+ return uploadAssetToBridge(file);
579
+ }
580
+
581
+ function getDroppedItemForFile(droppedItems, file, index) {
582
+ const direct = droppedItems?.[index];
583
+ if (direct?.kind === 'file') {
584
+ const directFile = typeof direct.getAsFile === 'function' ? direct.getAsFile() : null;
585
+ if (!directFile
586
+ || directFile === file
587
+ || (directFile.name === file.name && directFile.size === file.size && directFile.lastModified === file.lastModified)) {
588
+ return direct;
589
+ }
590
+ }
591
+
592
+ for (const item of droppedItems || []) {
593
+ if (!item || item.kind !== 'file' || typeof item.getAsFile !== 'function') {
594
+ continue;
595
+ }
596
+
597
+ const itemFile = item.getAsFile();
598
+ if (itemFile
599
+ && itemFile.name === file.name
600
+ && itemFile.size === file.size
601
+ && itemFile.lastModified === file.lastModified) {
602
+ return item;
603
+ }
604
+ }
605
+
606
+ return null;
607
+ }
608
+
609
+ function getBrowserExposedLocalPath(file) {
610
+ const candidates = [
611
+ file?.path,
612
+ file?.fullPath,
613
+ file?.mozFullPath
614
+ ];
615
+
616
+ for (const candidate of candidates) {
617
+ const value = String(candidate || '').trim();
618
+ if (/^[a-z]:[\\/]/i.test(value) || value.startsWith('\\\\') || value.startsWith('/')) {
619
+ return value;
620
+ }
621
+ }
622
+
623
+ return '';
624
+ }
625
+
626
+ function rewriteBridgeLocalFileUrl(url, bridgeUrl) {
627
+ if (!url || !bridgeUrl) {
628
+ return url;
629
+ }
630
+
631
+ try {
632
+ const parsed = new URL(url);
633
+ if (!isLoopbackHost(parsed.hostname) || !parsed.pathname.startsWith('/api/local-files/')) {
634
+ return url;
635
+ }
636
+
637
+ return `${bridgeUrl}${parsed.pathname}${parsed.search}${parsed.hash}`;
638
+ } catch {
639
+ return url;
640
+ }
641
+ }
642
+
643
+ async function registerLocalFilePathWithBridge(file, localPath) {
644
+ const normalizedPath = String(localPath || '').trim();
645
+ if (!normalizedPath || !isLocalBridgeAutoProbeAllowed()) {
646
+ return { success: false };
647
+ }
648
+
649
+ try {
650
+ const headers = {
651
+ ...(await getBridgeAuthHeaders()),
652
+ 'Content-Type': 'application/json'
653
+ };
654
+
655
+ for (const bridgeUrl of getBridgeUrlCandidates()) {
656
+ const response = await fetch(`${bridgeUrl}/api/local-files/register`, {
657
+ method: 'POST',
658
+ headers,
659
+ body: JSON.stringify({
660
+ path: normalizedPath,
661
+ fileName: file?.name || '',
662
+ size: Number(file?.size || 0),
663
+ mime: file?.type || ''
664
+ }),
665
+ targetAddressSpace: 'loopback'
666
+ });
667
+
668
+ if (!response.ok) {
669
+ continue;
670
+ }
671
+
672
+ const result = await response.json();
673
+ if (result?.success && result?.url) {
674
+ setActiveBridgeUrl(bridgeUrl);
675
+ return {
676
+ success: true,
677
+ id: result.id || '',
678
+ url: rewriteBridgeLocalFileUrl(result.url, bridgeUrl),
679
+ fileName: result.fileName || file?.name || '',
680
+ size: Number(result.size || file?.size || 0),
681
+ mime: result.mime || file?.type || '',
682
+ lastModified: Number(result.lastModified || file?.lastModified || 0)
683
+ };
684
+ }
685
+ }
686
+ } catch (error) {
687
+ console.warn('[DnD] Bridge local file registration failed:', error);
688
+ }
689
+
690
+ return { success: false };
691
+ }
692
+
693
+ async function createLocalVideoReference(file, item, bridgeConnected) {
694
+ const localPath = getBrowserExposedLocalPath(file);
695
+ if (bridgeConnected && localPath) {
696
+ const bridgeResult = await registerLocalFilePathWithBridge(file, localPath);
697
+ if (bridgeResult.success) {
698
+ return {
699
+ response: bridgeResult.url,
700
+ displayUrl: bridgeResult.url,
701
+ metadata: {
702
+ assetKind: 'video',
703
+ sourceKind: 'local-path',
704
+ videoMode: 'local_bridge_stream',
705
+ storageScope: 'original_file',
706
+ persistPolicy: 'reference_only',
707
+ localBridgeFileId: bridgeResult.id,
708
+ localFilePath: localPath,
709
+ fileName: bridgeResult.fileName || file.name || '',
710
+ fileSize: String(bridgeResult.size || file.size || 0),
711
+ fileMime: bridgeResult.mime || file.type || '',
712
+ fileLastModified: String(bridgeResult.lastModified || file.lastModified || 0)
713
+ },
714
+ saved: true
715
+ };
716
+ }
717
+ }
718
+
719
+ const registry = window.MindMapLocalFiles;
720
+ const entry = registry?.registerDroppedFile
721
+ ? await registry.registerDroppedFile(file, item)
722
+ : null;
723
+ const objectUrl = entry?.objectUrl || URL.createObjectURL(file);
724
+ const reference = entry?.reference || objectUrl;
725
+ const sourceKind = entry?.sourceKind || 'session-file';
726
+ const durableHandle = entry?.durableHandle === true;
727
+
728
+ return {
729
+ response: reference,
730
+ displayUrl: objectUrl,
731
+ metadata: {
732
+ assetKind: 'video',
733
+ sourceKind,
734
+ localFileId: entry?.id || '',
735
+ localFileReference: reference,
736
+ fileName: file.name || '',
737
+ fileSize: String(file.size || 0),
738
+ fileMime: file.type || '',
739
+ fileLastModified: String(file.lastModified || 0),
740
+ videoMode: durableHandle ? 'local_handle' : 'session_blob',
741
+ storageScope: durableHandle ? 'original_file' : 'session',
742
+ persistPolicy: durableHandle ? 'reference_only' : 'session_only'
743
+ },
744
+ saved: true
745
+ };
746
+ }
747
+
748
+ function showCloudVideoDndBlockedToast(count = 1) {
749
+ const suffix = count > 1 ? ` (${count} files skipped)` : '';
750
+ window.MindMapFeedback?.toast?.(`${CLOUD_VIDEO_DND_BLOCKED_MESSAGE}${suffix}`, {
751
+ kind: 'error',
752
+ duration: 6800
753
+ });
754
+ }
755
+ // ▲▲▲ [New] ▲▲▲
756
+
757
+ function fileToDataUrl(file) {
758
+ return new Promise((resolve, reject) => {
759
+ const reader = new FileReader();
760
+ reader.onload = () => resolve(reader.result);
761
+ reader.onerror = () => reject(reader.error);
762
+ reader.readAsDataURL(file);
763
+ });
764
+ }
765
+
766
+ async function handleBrowserImageDrop(module, e, imageUrl) {
767
+ const finalUrl = await fetchImageAsBlobUrl(imageUrl);
768
+ if (!finalUrl) {
769
+ console.log('[MindMapDnD] Image drop cancelled due to CORS/Fetch error.');
770
+ return;
771
+ }
772
+
773
+ const mouse = {
774
+ x: (e.clientX / module.container.clientWidth) * 2 - 1,
775
+ y: -(e.clientY / module.container.clientHeight) * 2 + 1
776
+ };
777
+ const vfov = (module.camera.fov * Math.PI) / 180;
778
+ const viewHeight = 2 * Math.tan(vfov / 2) * module.camera.position.z;
779
+ const viewWidth = viewHeight * module.camera.aspect;
780
+ const worldX = module.camera.position.x + (mouse.x * viewWidth) / 2;
781
+ const worldY = module.camera.position.y + (mouse.y * viewHeight) / 2;
782
+
783
+ const size = await measureImageSize(finalUrl);
784
+
785
+ const centeredX = worldX - (size.width / 2);
786
+ const centeredY = worldY + (size.height / 2);
787
+
788
+ const finalNode = {
789
+ TempId: `temp_browser_${Date.now()}`,
790
+ ContentType: 'image',
791
+ FileName: 'Web Image',
792
+ Response: finalUrl,
793
+ X: module.snapToGrid(centeredX),
794
+ Y: module.snapToGridY(centeredY),
795
+ Width: size.width,
796
+ Height: size.height,
797
+ IsLoading: false
798
+ };
799
+
800
+ const truncatedUrl = finalUrl.length > 50 ? `${finalUrl.substring(0, 50)}...` : finalUrl;
801
+
802
+ try {
803
+ const actualNodeIdsMap = await module.dotNetHelper.invokeMethodAsync('AddFileNodesFromJs', [finalNode]);
804
+ const actualId = actualNodeIdsMap?.[finalNode.TempId];
805
+ if (actualId) {
806
+ const batchDetails = [{
807
+ nodeId: actualId,
808
+ fileUrl: finalUrl,
809
+ width: size.width,
810
+ height: size.height
811
+ }];
812
+ await module.dotNetHelper.invokeMethodAsync('UpdateMultipleFileNodeDetails', batchDetails);
813
+ }
814
+ } catch (err) {
815
+ console.error('[MindMapDnD] Failed to add browser image:', err);
816
+ }
817
+ }
818
+
819
+ async function handleDirectoryDrop(module, handle, e) {
820
+ console.log('[MindMapDnD] Directory dropped:', handle.name);
821
+
822
+ const mouse = {
823
+ x: (e.clientX / module.container.clientWidth) * 2 - 1,
824
+ y: -(e.clientY / module.container.clientHeight) * 2 + 1
825
+ };
826
+ const vfov = (module.camera.fov * Math.PI) / 180;
827
+ const viewHeight = 2 * Math.tan(vfov / 2) * module.camera.position.z;
828
+ const viewWidth = viewHeight * module.camera.aspect;
829
+ const worldX = module.camera.position.x + (mouse.x * viewWidth) / 2;
830
+ const worldY = module.camera.position.y + (mouse.y * viewHeight) / 2;
831
+
832
+ const centeredX = module.snapToGrid(worldX);
833
+ const centeredY = module.snapToGridY(worldY);
834
+
835
+ try {
836
+ const nodeId = await module.dotNetHelper.invokeMethodAsync('AddDirectoryNodeFromJs', handle.name, centeredX, centeredY);
837
+ if (nodeId) {
838
+ await IDBHelper.set(nodeId, handle);
839
+ console.log(`[MindMapDnD] Directory handle stored for node ${nodeId}`);
840
+ }
841
+ } catch (err) {
842
+ console.error('[MindMapDnD] Failed to add directory node:', err);
843
+ }
844
+ }
845
+
846
+ async function onDrop(module, e) {
847
+ // ▼▼▼ [Profiling] Log drag statistics ▼▼▼
848
+ const dropTime = performance.now();
849
+ const totalDragTime = _dragStats.startTime > 0 ? (dropTime - _dragStats.startTime).toFixed(0) : '?';
850
+ console.log(`[DnD] 🔴 Drop received! dragTime=${totalDragTime}ms, dragOverCount=${_dragStats.dragOverCount}`);
851
+ // ▲▲▲ [Profiling] ▲▲▲
852
+
853
+ // ▼▼▼ [Changed] Prevent duplicate processing ▼▼▼
854
+ if (e._dndProcessed) {
855
+ console.log('[MindMapDnD] Drop event already processed, skipping.');
856
+ return false;
857
+ }
858
+ e._dndProcessed = true;
859
+ // ▲▲▲ [Changed] ▲▲▲
860
+
861
+ console.log('[MindMapDnD] Drop event received!');
862
+
863
+ e.preventDefault();
864
+ e.stopPropagation();
865
+ e.stopImmediatePropagation(); // Also block other handlers on the same element
866
+
867
+ // ▼▼▼ [Critical fix] Capture file lists before starting async work ▼▼▼
868
+ // e.dataTransfer.files can be cleared by the browser once async work (await) begins.
869
+ const droppedFiles = e.dataTransfer.files ? Array.from(e.dataTransfer.files) : [];
870
+ const droppedItems = e.dataTransfer.items ? Array.from(e.dataTransfer.items) : [];
871
+ // ▲▲▲ [Critical fix] ▲▲▲
872
+
873
+ // 1. Directory handling (FileSystem API)
874
+ // (Note: droppedFiles is already copied into memory, so it's safe even with await.)
875
+ // [Optimized] If all files are already supported (image, PDF, etc.), skip directory checks
876
+ // (async handle requests) to avoid delay.
877
+ let shouldCheckDirectory = false;
878
+ if (droppedFiles.length === 0) {
879
+ // If the file list is empty (pure folder drop, etc.), we need to check
880
+ shouldCheckDirectory = true;
881
+ } else {
882
+ // If any file is not recognized (no extension, etc. => possibly a folder), check
883
+ const sortedRegistry = window.MindMapFileRegistry; // Registry is global usage
884
+ for (let droppedFileIndex = 0; droppedFileIndex < droppedFiles.length; droppedFileIndex++) {
885
+ const file = droppedFiles[droppedFileIndex];
886
+ let isKnown = false;
887
+ for (const handler of sortedRegistry) {
888
+ if (handler.check(file)) {
889
+ isKnown = true;
890
+ break;
891
+ }
892
+ }
893
+ if (!isKnown) {
894
+ shouldCheckDirectory = true;
895
+ break;
896
+ }
897
+ }
898
+ }
899
+
900
+ // ▼▼▼ [Profiling] Measure directory-check time ▼▼▼
901
+ const dirCheckStart = performance.now();
902
+
903
+ if (shouldCheckDirectory && droppedItems.length > 0) {
904
+ console.log('[DnD] 📁 Checking for directory drop...');
905
+ for (const item of droppedItems) {
906
+ // webkitGetAsEntry (sync / non-standard) vs getAsFileSystemHandle (async / standard)
907
+ // getAsFileSystemHandle can be slow, so apply a timeout
908
+ if (item.kind === 'file' && typeof item.getAsFileSystemHandle === 'function') {
909
+ try {
910
+ // ▼▼▼ [Optimization] 500ms timeout to avoid blocking directory checks ▼▼▼
911
+ const handlePromise = item.getAsFileSystemHandle();
912
+ const timeoutPromise = new Promise((_, reject) =>
913
+ setTimeout(() => reject(new Error('Timeout')), 500)
914
+ );
915
+
916
+ const handle = await Promise.race([handlePromise, timeoutPromise]);
917
+ // ▲▲▲ [Optimization] ▲▲▲
918
+
919
+ if (handle && handle.kind === 'directory') {
920
+ console.log(`[DnD] 📁 Directory detected in ${(performance.now() - dirCheckStart).toFixed(0)}ms`);
921
+ await handleDirectoryDrop(module, handle, e);
922
+ return false;
923
+ }
924
+ } catch (err) {
925
+ // If timeout/failure, skip quietly and continue as file handling
926
+ console.warn(`[DnD] Directory check skipped: ${err.message}`);
927
+ }
928
+ }
929
+ }
930
+ }
931
+ console.log(`[DnD] 📁 Directory check completed in ${(performance.now() - dirCheckStart).toFixed(0)}ms`);
932
+ // ▲▲▲ [Profiling] ▲▲▲
933
+
934
+ // 2. Regular file handling (use captured droppedFiles)
935
+ if (droppedFiles.length > 0) {
936
+ const timeLabel = `[DnD] Process_${Date.now()}`;
937
+ console.time(timeLabel);
938
+
939
+ // Debug: Log all dropped files
940
+ console.log(`[DnD] 📂 Dropped ${droppedFiles.length} files:`);
941
+ droppedFiles.forEach((f, i) => console.log(` [${i}] ${f.name} (type: ${f.type || 'unknown'}, size: ${f.size})`));
942
+
943
+ const matchedFiles = [];
944
+ const filesToProcess = [];
945
+ const sortedRegistry = [...window.MindMapFileRegistry].sort((a, b) => (a.priority ?? 50) - (b.priority ?? 50));
946
+
947
+ for (let droppedFileIndex = 0; droppedFileIndex < droppedFiles.length; droppedFileIndex++) {
948
+ const file = droppedFiles[droppedFileIndex];
949
+ let matchedType = null;
950
+ for (const handler of sortedRegistry) {
951
+ if (handler.check(file)) {
952
+ matchedType = handler.getType(file);
953
+ break;
954
+ }
955
+ }
956
+ if (matchedType) {
957
+ matchedFiles.push({
958
+ file,
959
+ type: matchedType,
960
+ item: getDroppedItemForFile(droppedItems, file, droppedFileIndex)
961
+ });
962
+ } else {
963
+ console.warn(`[DnD] ⚠️ No handler matched for: ${file.name} (type: ${file.type})`);
964
+ }
965
+ }
966
+
967
+ const bridgeConnected = !window.__isNativeApp && await isBridgeConnected();
968
+ let blockedVideoCount = 0;
969
+ for (const entry of matchedFiles) {
970
+ if (entry.type === 'video' && !window.__isNativeApp && !bridgeConnected) {
971
+ blockedVideoCount++;
972
+ console.warn(`[DnD] Blocked local video DnD in cloud mode: ${entry.file.name}`);
973
+ continue;
974
+ }
975
+
976
+ filesToProcess.push(entry);
977
+ }
978
+
979
+ if (blockedVideoCount > 0) {
980
+ showCloudVideoDndBlockedToast(blockedVideoCount);
981
+ }
982
+
983
+ if (filesToProcess.length === 0) {
984
+ console.timeEnd(timeLabel);
985
+ console.log('[MindMapDnD] No matching file handlers found. Checking for web images...');
986
+ } else {
987
+ const mouse = {
988
+ x: (e.clientX / module.container.clientWidth) * 2 - 1,
989
+ y: -(e.clientY / module.container.clientHeight) * 2 + 1
990
+ };
991
+ const vfov = (module.camera.fov * Math.PI) / 180;
992
+ const viewHeight = 2 * Math.tan(vfov / 2) * module.camera.position.z;
993
+ const viewWidth = viewHeight * module.camera.aspect;
994
+ const worldX = module.camera.position.x + (mouse.x * viewWidth) / 2;
995
+ const worldY = module.camera.position.y + (mouse.y * viewHeight) / 2;
996
+
997
+ // ▼▼▼ [Optimization] Batch processing to prevent UI blocking ▼▼▼
998
+ const BATCH_SIZE = 5; // Process 5 files at a time
999
+ const preparedNodes = [];
1000
+
1001
+ // Helper to yield to main thread
1002
+ const yieldToMain = () => new Promise(resolve => setTimeout(resolve, 0));
1003
+
1004
+ // ▼▼▼ [Fix] Track cumulative X offset for proper spacing ▼▼▼
1005
+ const NODE_GAP = 30;
1006
+ // ▲▲▲ [Fix] ▲▲▲
1007
+
1008
+ // Process files in batches
1009
+ for (let batchStart = 0; batchStart < filesToProcess.length; batchStart += BATCH_SIZE) {
1010
+ const batchEnd = Math.min(batchStart + BATCH_SIZE, filesToProcess.length);
1011
+ const batch = filesToProcess.slice(batchStart, batchEnd);
1012
+
1013
+ const batchResults = await Promise.all(batch.map(async ({ file, type, item }, batchIndex) => {
1014
+ const index = batchStart + batchIndex;
1015
+ const tempId = `temp_${Date.now()}_${index}`;
1016
+
1017
+ let finalResponse = '';
1018
+ let displayUrl = ''; // For JS image loading
1019
+ let finalWidth = module.aiNodeDefaultWidth || 470;
1020
+ let finalHeight = module.aiNodeDefaultHeight || 350;
1021
+ let isLoading = false;
1022
+ const fileIndex = index;
1023
+ let imageSaved = false; // Track if image was already saved
1024
+ let videoSaved = false;
1025
+ let metadata = null;
1026
+
1027
+ if (type === 'image') {
1028
+ const isAnimatedGif = file.type === 'image/gif' || /\.gif$/i.test(file.name || '');
1029
+ if (isAnimatedGif) {
1030
+ metadata = {
1031
+ assetKind: 'image',
1032
+ fileName: file.name || '',
1033
+ fileSize: String(file.size || 0),
1034
+ fileMime: file.type || 'image/gif',
1035
+ fileLastModified: String(file.lastModified || ''),
1036
+ isAnimatedGif: 'true'
1037
+ };
1038
+ }
1039
+ // ▼▼▼ [Optimization] Direct Bridge upload (fastest path) ▼▼▼
1040
+ console.log(`[DnD] Processing image ${index + 1}: ${file.name}`);
1041
+
1042
+ // Try Bridge direct upload first (bypasses C# entirely)
1043
+ if (bridgeConnected) {
1044
+ const localSizingUrl = URL.createObjectURL(file);
1045
+ const measuredSizePromise = measureImageSize(localSizingUrl).catch((err) => {
1046
+ console.warn(`[DnD] Image ${index + 1} local measure failed: ${err?.message || err}`);
1047
+ return null;
1048
+ });
1049
+
1050
+ const bridgeResult = await uploadImageToBridge(file);
1051
+ const measuredSize = await measuredSizePromise;
1052
+ if (bridgeResult.success) {
1053
+ displayUrl = bridgeResult.url; // HTTP URL for display
1054
+ finalResponse = bridgeResult.relativeUrl; // Relative path for storage
1055
+ if (isAnimatedGif && metadata) {
1056
+ metadata.OriginalPath = bridgeResult.relativeUrl || '';
1057
+ }
1058
+
1059
+ const imageNodeSize = measuredSize || computeDroppedImageNodeSize(
1060
+ bridgeResult.width || 0,
1061
+ bridgeResult.height || 0
1062
+ );
1063
+ finalWidth = imageNodeSize.width;
1064
+ finalHeight = imageNodeSize.height;
1065
+
1066
+ URL.revokeObjectURL(localSizingUrl);
1067
+ imageSaved = true; // Already saved by Bridge
1068
+ console.log(`[DnD] ✓ Bridge saved: ${file.name} -> ${bridgeResult.relativeUrl} (${finalWidth}x${finalHeight})`);
1069
+ } else {
1070
+ // Fallback to Blob URL + background save
1071
+ const size = measuredSize || computeDroppedImageNodeSize(470, 350);
1072
+ displayUrl = localSizingUrl;
1073
+ finalResponse = localSizingUrl;
1074
+ finalWidth = size.width;
1075
+ finalHeight = size.height;
1076
+ imageSaved = false;
1077
+ }
1078
+ } else {
1079
+ // MAUI/Native: use Blob URL + background save via C#
1080
+ try {
1081
+ const sizingUrl = URL.createObjectURL(file);
1082
+ const size = await measureImageSize(sizingUrl);
1083
+ displayUrl = sizingUrl;
1084
+ finalResponse = sizingUrl;
1085
+ finalWidth = size.width;
1086
+ finalHeight = size.height;
1087
+ imageSaved = false;
1088
+ } catch (err) {
1089
+ console.warn(`[DnD] Image ${index + 1} measure failed: ${err.message}`);
1090
+ displayUrl = URL.createObjectURL(file);
1091
+ finalResponse = displayUrl;
1092
+ const fallbackSize = computeDroppedImageNodeSize(470, 350);
1093
+ finalWidth = fallbackSize.width;
1094
+ finalHeight = fallbackSize.height;
1095
+ imageSaved = false;
1096
+ }
1097
+ }
1098
+ // ▲▲▲ [Optimization] ▲▲▲
1099
+ isLoading = false;
1100
+ } else if (type === 'video') {
1101
+ console.log(`[DnD] Processing video ${index + 1}: ${file.name}`);
1102
+ finalWidth = 470;
1103
+ finalHeight = 264;
1104
+ const localReference = await createLocalVideoReference(file, item, bridgeConnected);
1105
+ displayUrl = localReference.displayUrl;
1106
+ finalResponse = localReference.response;
1107
+ metadata = localReference.metadata;
1108
+ videoSaved = localReference.saved === true;
1109
+
1110
+ // Legacy asset-copy path is intentionally unreachable for videos.
1111
+ if (false && bridgeConnected) {
1112
+ const bridgeResult = await uploadAssetToBridge(file);
1113
+ if (bridgeResult.success) {
1114
+ displayUrl = bridgeResult.url;
1115
+ finalResponse = bridgeResult.relativeUrl;
1116
+ videoSaved = true;
1117
+ console.log(`[DnD] ✓ Bridge saved video: ${file.name} -> ${bridgeResult.relativeUrl}`);
1118
+ }
1119
+ }
1120
+
1121
+ if (!videoSaved) {
1122
+ displayUrl = window.__isNativeApp ? await fileToDataUrl(file) : URL.createObjectURL(file);
1123
+ finalResponse = displayUrl;
1124
+ }
1125
+
1126
+ isLoading = false;
1127
+ } else if (type === CSV_TABLE_CONTENT_TYPE) {
1128
+ console.log(`[DnD] Processing CSV ${index + 1}: ${file.name}`);
1129
+ finalResponse = await readCsvFileText(file);
1130
+ displayUrl = finalResponse;
1131
+ finalWidth = 720;
1132
+ finalHeight = 420;
1133
+ isLoading = false;
1134
+ const csvSummary = summarizeCsvText(finalResponse);
1135
+ metadata = {
1136
+ assetKind: 'csv',
1137
+ fileName: file.name || '',
1138
+ fileSize: String(file.size || 0),
1139
+ fileMime: file.type || 'text/csv',
1140
+ fileLastModified: String(file.lastModified || ''),
1141
+ CsvDelimiter: csvSummary.delimiter || ',',
1142
+ CsvRowCount: String(csvSummary.rowCount ?? 0),
1143
+ CsvColumnCount: String(csvSummary.columnCount ?? 0)
1144
+ };
1145
+ } else if (type === 'text') {
1146
+ finalResponse = '⏳ Analyzing file...';
1147
+ displayUrl = finalResponse;
1148
+ isLoading = true;
1149
+ } else if (type === 'code') {
1150
+ // ▼▼▼ [New] Code files: Troika-rendered, load content immediately ▼▼▼
1151
+ finalResponse = '⏳ Loading source code...';
1152
+ displayUrl = finalResponse;
1153
+ finalWidth = module.aiNodeDefaultWidth || 470;
1154
+ finalHeight = 400; // Taller default for code
1155
+ isLoading = true;
1156
+ // ▲▲▲ [New] ▲▲▲
1157
+ } else if (type === 'pdf') {
1158
+ finalResponse = window.__isNativeApp ? await fileToDataUrl(file) : URL.createObjectURL(file);
1159
+ displayUrl = finalResponse;
1160
+ finalWidth = module.aiNodeDefaultWidth || 470;
1161
+ finalHeight = 56;
1162
+ }
1163
+
1164
+ // X, Y will be recalculated after all dimensions are known
1165
+ const tempX = 0;
1166
+ const tempY = 0;
1167
+
1168
+ return {
1169
+ TempId: tempId,
1170
+ ContentType: type,
1171
+ FileName: file.name,
1172
+ Response: finalResponse, // For C# storage
1173
+ DisplayUrl: displayUrl, // For JS display
1174
+ FileIndex: fileIndex,
1175
+ X: tempX,
1176
+ Y: tempY,
1177
+ Width: finalWidth,
1178
+ Height: finalHeight,
1179
+ IsLoading: isLoading,
1180
+ ImageSaved: imageSaved, // Flag to skip background save
1181
+ VideoSaved: videoSaved,
1182
+ Metadata: metadata
1183
+ };
1184
+ }));
1185
+
1186
+ preparedNodes.push(...batchResults.filter(n => n !== null));
1187
+
1188
+ // Yield to main thread after each batch to prevent UI freeze
1189
+ if (batchEnd < filesToProcess.length) {
1190
+ await yieldToMain();
1191
+ }
1192
+
1193
+ console.log(`[DnD] Prepared batch ${Math.floor(batchStart / BATCH_SIZE) + 1}/${Math.ceil(filesToProcess.length / BATCH_SIZE)}`);
1194
+ }
1195
+ // ▲▲▲ [Optimization] ▲▲▲
1196
+
1197
+ const nodesToSend = preparedNodes.filter(n => n !== null);
1198
+
1199
+ // ▼▼▼ [Fix] Recalculate X positions based on node widths + gap ▼▼▼
1200
+ if (nodesToSend.length > 0) {
1201
+ let nextLeftX = worldX - (nodesToSend[0].Width / 2);
1202
+ for (const node of nodesToSend) {
1203
+ const centeredY = worldY + (node.Height / 2);
1204
+ node.X = module.snapToGrid(nextLeftX);
1205
+ node.Y = module.snapToGridY(centeredY);
1206
+ nextLeftX = node.X + node.Width + NODE_GAP;
1207
+ }
1208
+ }
1209
+ // ▲▲▲ [Fix] ▲▲▲
1210
+
1211
+ if (nodesToSend.length > 0) {
1212
+ // 1. Get ID mapping from C# (batch call)
1213
+ const t4 = performance.now();
1214
+ const actualNodeIdsMap = await module.dotNetHelper.invokeMethodAsync('AddFileNodesFromJs', nodesToSend);
1215
+ const t5 = performance.now();
1216
+ console.log(`[DnD] AddFileNodesFromJs: ${(t5 - t4).toFixed(1)}ms for ${nodesToSend.length} nodes`);
1217
+
1218
+ // ▼▼▼ [Optimization] Add nodes to JS in batches to prevent UI blocking ▼▼▼
1219
+ const nodesToAdd = nodesToSend.map(dto => ({
1220
+ id: actualNodeIdsMap[dto.TempId],
1221
+ contentType: dto.ContentType,
1222
+ prompt: dto.FileName,
1223
+ response: dto.DisplayUrl || dto.Response, // Use DisplayUrl for images (HTTP URL)
1224
+ positionX: dto.X,
1225
+ positionY: dto.Y,
1226
+ positionZ: 0,
1227
+ width: dto.Width,
1228
+ height: dto.Height,
1229
+ isLoading: false,
1230
+ metadata: dto.Metadata || {}
1231
+ }));
1232
+
1233
+ const JS_BATCH_SIZE = 10;
1234
+ const t5b = performance.now();
1235
+ for (let i = 0; i < nodesToAdd.length; i += JS_BATCH_SIZE) {
1236
+ const batch = nodesToAdd.slice(i, i + JS_BATCH_SIZE);
1237
+ await window.mindMap.addNodes(batch);
1238
+
1239
+ // Yield to main thread between batches
1240
+ if (i + JS_BATCH_SIZE < nodesToAdd.length) {
1241
+ await yieldToMain();
1242
+ }
1243
+ }
1244
+ const t5c = performance.now();
1245
+ console.log(`[DnD] JS addNodes: ${(t5c - t5b).toFixed(1)}ms for ${nodesToAdd.length} nodes`);
1246
+ // ▲▲▲ [Optimization] ▲▲▲
1247
+
1248
+ // 2. Stream text files (BATCH PROCESSING to prevent Render Storm)
1249
+ // ▼▼▼ [Optimization] Batch text file processing ▼▼▼
1250
+ const textNodesToProcess = nodesToSend.filter(n => n.IsLoading && n.ContentType !== 'image');
1251
+
1252
+ if (textNodesToProcess.length > 0) {
1253
+ console.log(`[DnD] 📄 Batch processing ${textNodesToProcess.length} text files...`);
1254
+
1255
+ // Prepare all stream references at once
1256
+ const textFileStreams = [];
1257
+ for (const nodeDto of textNodesToProcess) {
1258
+ const realNodeId = actualNodeIdsMap?.[nodeDto.TempId];
1259
+ if (!realNodeId) continue;
1260
+
1261
+ const originalEntry = filesToProcess[nodeDto.FileIndex];
1262
+ if (!originalEntry) continue;
1263
+
1264
+ try {
1265
+ const streamRef = DotNet.createJSStreamReference(originalEntry.file);
1266
+ textFileStreams.push({
1267
+ nodeId: realNodeId,
1268
+ fileName: nodeDto.FileName,
1269
+ streamRef: streamRef
1270
+ });
1271
+ } catch (err) {
1272
+ console.error(`[DnD] Failed to create stream ref for ${nodeDto.FileName}:`, err);
1273
+ }
1274
+ }
1275
+
1276
+ // Single batch call to C# (prevents N individual StateHasChanged calls)
1277
+ if (textFileStreams.length > 0) {
1278
+ try {
1279
+ const t6a = performance.now();
1280
+ await module.dotNetHelper.invokeMethodAsync(
1281
+ 'IngestMultipleTextFilesDirectly',
1282
+ textFileStreams
1283
+ );
1284
+ const t6b = performance.now();
1285
+ console.log(`[DnD] ✅ Batch text processing: ${(t6b - t6a).toFixed(1)}ms for ${textFileStreams.length} files`);
1286
+ } catch (err) {
1287
+ console.error('[DnD] Batch text processing failed, falling back to individual:', err);
1288
+ // Fallback to individual processing
1289
+ for (const stream of textFileStreams) {
1290
+ try {
1291
+ await module.dotNetHelper.invokeMethodAsync(
1292
+ 'IngestFileStreamDirectly',
1293
+ stream.streamRef,
1294
+ stream.nodeId,
1295
+ stream.fileName
1296
+ );
1297
+ } catch (e) {
1298
+ console.error(`[DnD] Individual stream error for ${stream.fileName}:`, e);
1299
+ }
1300
+ }
1301
+ }
1302
+ }
1303
+ }
1304
+ // ▲▲▲ [Optimization] ▲▲▲
1305
+
1306
+ // ▼▼▼ [Optimization] Save images in background (Blob URL displayed immediately) ▼▼▼
1307
+ const imageNodesToSave = nodesToSend.filter(n => n.ContentType === 'image' && !n.ImageSaved);
1308
+ if (imageNodesToSave.length > 0) {
1309
+ console.log(`[DnD] 🔄 Starting background save for ${imageNodesToSave.length} images...`);
1310
+ // Fire-and-forget: start sequential image saving in background
1311
+ (async () => {
1312
+ for (let i = 0; i < imageNodesToSave.length; i++) {
1313
+ const nodeDto = imageNodesToSave[i];
1314
+ const realNodeId = actualNodeIdsMap?.[nodeDto.TempId];
1315
+ if (!realNodeId) continue;
1316
+
1317
+ const originalEntry = filesToProcess[nodeDto.FileIndex];
1318
+ if (!originalEntry) continue;
1319
+
1320
+ try {
1321
+ const streamRef = DotNet.createJSStreamReference(originalEntry.file);
1322
+ await module.dotNetHelper.invokeMethodAsync(
1323
+ 'SaveImageStreamBackground',
1324
+ streamRef,
1325
+ realNodeId,
1326
+ nodeDto.FileName
1327
+ );
1328
+
1329
+ if ((i + 1) % 10 === 0) {
1330
+ console.log(`[DnD] Fallback saved ${i + 1}/${imageNodesToSave.length} images`);
1331
+ }
1332
+ } catch (err) {
1333
+ console.warn('[DnD] Image save error:', err);
1334
+ }
1335
+ }
1336
+ console.log(`[DnD] ✅ Background saved ${imageNodesToSave.length} images to assets`);
1337
+ })();
1338
+ } else {
1339
+ const totalImages = nodesToSend.filter(n => n.ContentType === 'image').length;
1340
+ if (totalImages > 0) {
1341
+ console.log(`[DnD] ✅ All ${totalImages} images already saved via SaveImageAndGetInfo`);
1342
+ }
1343
+ }
1344
+ // ▲▲▲ [Optimization] ▲▲▲
1345
+
1346
+ const videoNodesToSave = nodesToSend.filter(n => n.ContentType === 'video' && !n.VideoSaved);
1347
+ if (videoNodesToSave.length > 0) {
1348
+ console.log(`[DnD] 🔄 Starting background save for ${videoNodesToSave.length} videos...`);
1349
+ (async () => {
1350
+ for (let i = 0; i < videoNodesToSave.length; i++) {
1351
+ const nodeDto = videoNodesToSave[i];
1352
+ const realNodeId = actualNodeIdsMap?.[nodeDto.TempId];
1353
+ if (!realNodeId) continue;
1354
+
1355
+ const originalEntry = filesToProcess[nodeDto.FileIndex];
1356
+ if (!originalEntry) continue;
1357
+
1358
+ try {
1359
+ const streamRef = DotNet.createJSStreamReference(originalEntry.file);
1360
+ await module.dotNetHelper.invokeMethodAsync(
1361
+ 'SaveVideoStreamBackground',
1362
+ streamRef,
1363
+ realNodeId,
1364
+ nodeDto.FileName
1365
+ );
1366
+ } catch (err) {
1367
+ console.warn('[DnD] Video save error:', err);
1368
+ }
1369
+ }
1370
+ console.log(`[DnD] ✅ Background saved ${videoNodesToSave.length} videos to assets`);
1371
+ })();
1372
+ }
1373
+
1374
+ // 3. Update PDF URL only (images were already added in JS)
1375
+ // ▼▼▼ [Changed] Images already handled in JS addNodes; update PDFs only ▼▼▼
1376
+ const batchDetails = nodesToSend
1377
+ .map(node => {
1378
+ const nodeId = actualNodeIdsMap?.[node.TempId];
1379
+ if (!nodeId) return null;
1380
+ // Images and text have already been handled
1381
+ if (node.ContentType === 'image') return null;
1382
+ if (node.ContentType === 'video') return null;
1383
+ if (node.IsLoading) return null; // text still loading
1384
+
1385
+ return {
1386
+ nodeId: nodeId,
1387
+ fileUrl: node.Response,
1388
+ width: node.Width,
1389
+ height: node.Height
1390
+ };
1391
+ })
1392
+ .filter(detail => detail !== null);
1393
+
1394
+ // Execute all work in parallel
1395
+ // ▼▼▼ [Profiling] Post-processing time ▼▼▼
1396
+ const t6 = performance.now();
1397
+ await Promise.all([
1398
+ batchDetails.length > 0 ? module.dotNetHelper.invokeMethodAsync('UpdateMultipleFileNodeDetails', batchDetails) : Promise.resolve()
1399
+ ]);
1400
+ const t7 = performance.now();
1401
+ console.log(`[DnD] UpdateMultipleFileNodeDetails: ${(t7 - t6).toFixed(1)}ms`);
1402
+ // ▲▲▲ [Profiling] ▲▲▲
1403
+ }
1404
+
1405
+ console.timeEnd(timeLabel);
1406
+ return false;
1407
+ }
1408
+ }
1409
+
1410
+ // 3. Web resource (URL) handling
1411
+ const imageUrl = extractImageUrl(e.dataTransfer);
1412
+ console.log(`[DnD] extractImageUrl result: ${imageUrl ? imageUrl.substring(0, 50) + '...' : 'null'}`);
1413
+ const isWebResource = imageUrl && (imageUrl.startsWith('http') || imageUrl.startsWith('data:'));
1414
+ if (isWebResource) {
1415
+ console.log('[DnD] Processing as web resource (should NOT happen if file was processed)');
1416
+ await handleBrowserImageDrop(module, e, imageUrl);
1417
+ return false;
1418
+ }
1419
+
1420
+ const droppedUrl = extractDroppedUrl(e.dataTransfer);
1421
+ if (droppedUrl && /^https?:\/\//i.test(droppedUrl)) {
1422
+ const mouse = {
1423
+ x: (e.clientX / module.container.clientWidth) * 2 - 1,
1424
+ y: -(e.clientY / module.container.clientHeight) * 2 + 1
1425
+ };
1426
+ const vfov = (module.camera.fov * Math.PI) / 180;
1427
+ const viewHeight = 2 * Math.tan(vfov / 2) * module.camera.position.z;
1428
+ const viewWidth = viewHeight * module.camera.aspect;
1429
+ const worldX = module.camera.position.x + (mouse.x * viewWidth) / 2;
1430
+ const worldY = module.camera.position.y + (mouse.y * viewHeight) / 2;
1431
+
1432
+ await module.dotNetHelper?.invokeMethodAsync(
1433
+ 'AddUrlNodeFromJs',
1434
+ droppedUrl,
1435
+ module.snapToGrid(worldX - 230),
1436
+ module.snapToGridY(worldY + 130)
1437
+ );
1438
+ return false;
1439
+ }
1440
+
1441
+ // ▼▼▼ [Profiling] Drop handling complete ▼▼▼
1442
+ const totalProcessTime = performance.now() - dropTime;
1443
+ console.log(`[DnD] ✅ Drop processing completed in ${totalProcessTime.toFixed(0)}ms`);
1444
+ // ▲▲▲ [Profiling] ▲▲▲
1445
+
1446
+ return false;
1447
+ }
1448
+
1449
+ return {
1450
+ addEventListeners: function (moduleInstance) {
1451
+ // ▼▼▼ [Changed] Prevent duplicate registration ▼▼▼
1452
+ if (moduleInstance._dndListenersAttached) {
1453
+ console.log('[MindMapDnD] Event listeners already attached, skipping.');
1454
+ return;
1455
+ }
1456
+ // ▲▲▲ [Changed] ▲▲▲
1457
+
1458
+ // ▼▼▼ [New] Disable JS drag listeners in MAUI native app ▼▼▼
1459
+ // C# NativeDropService handles it to avoid IPC bottlenecks and duplicated UI.
1460
+ if (window.__isNativeApp === true) {
1461
+ console.log('[MindMapDnD] Native app detected - JS drag handlers DISABLED (C# handles drop).');
1462
+ moduleInstance._dndListenersAttached = true;
1463
+ return;
1464
+ }
1465
+ // ▲▲▲ [New] ▲▲▲
1466
+
1467
+ const handlers = getBoundHandlers(moduleInstance);
1468
+ if (moduleInstance.container) {
1469
+ moduleInstance.container.addEventListener('dragenter', handlers.boundOnDragEnter); // [New]
1470
+ moduleInstance.container.addEventListener('dragover', handlers.boundOnDragOver);
1471
+ moduleInstance.container.addEventListener('dragleave', handlers.boundOnDragLeave);
1472
+ moduleInstance.container.addEventListener('drop', handlers.boundOnDrop);
1473
+ moduleInstance._dndListenersAttached = true;
1474
+ console.log('[MindMapDnD] Event listeners attached to container.');
1475
+ }
1476
+ },
1477
+ removeEventListeners: function (moduleInstance) {
1478
+ const handlers = getBoundHandlers(moduleInstance);
1479
+ if (moduleInstance.container) {
1480
+ moduleInstance.container.removeEventListener('dragenter', handlers.boundOnDragEnter); // [New]
1481
+ moduleInstance.container.removeEventListener('dragover', handlers.boundOnDragOver);
1482
+ moduleInstance.container.removeEventListener('dragleave', handlers.boundOnDragLeave);
1483
+ moduleInstance.container.removeEventListener('drop', handlers.boundOnDrop);
1484
+ }
1485
+ moduleInstance._dndListenersAttached = false;
1486
+ eventHandlers.delete(moduleInstance);
1487
+ },
1488
+ IDBHelper: IDBHelper
1489
+ };
1490
+ })();
1491
+ console.log('✓ mind-map-dnd.js loaded (Fixed: Async DataTransfer Safety + Text Support).');