@mindexec/cli 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (232) hide show
  1. package/README.md +275 -0
  2. package/codex-runtime.js +960 -0
  3. package/launch-bridge.cjs +162 -0
  4. package/package.json +61 -0
  5. package/port-guard.cjs +232 -0
  6. package/scripts/setup-tree-sitter-grammars.mjs +59 -0
  7. package/server.js +8422 -0
  8. package/start-bridge.bat +32 -0
  9. package/start-bridge.sh +81 -0
  10. package/tree-sitter-grammars/README.md +18 -0
  11. package/tree-sitter-grammars/tree-sitter-c_sharp.wasm +0 -0
  12. package/tree-sitter-grammars/tree-sitter-go.wasm +0 -0
  13. package/tree-sitter-grammars/tree-sitter-java.wasm +0 -0
  14. package/tree-sitter-grammars/tree-sitter-javascript.wasm +0 -0
  15. package/tree-sitter-grammars/tree-sitter-python.wasm +0 -0
  16. package/tree-sitter-grammars/tree-sitter-rust.wasm +0 -0
  17. package/tree-sitter-grammars/tree-sitter-tsx.wasm +0 -0
  18. package/tree-sitter-grammars/tree-sitter-typescript.wasm +0 -0
  19. package/wwwroot/MindExecution.Web.styles.css +3 -0
  20. package/wwwroot/_content/MindExecution.Plugins.Admin/css/admin-dashboard.css +546 -0
  21. package/wwwroot/_content/MindExecution.Plugins.Directory/MindExecution.Plugins.Directory.u7utcng611.bundle.scp.css +7 -0
  22. package/wwwroot/_content/MindExecution.Plugins.Directory/background.png +0 -0
  23. package/wwwroot/_content/MindExecution.Plugins.Directory/directory-manager.js +202 -0
  24. package/wwwroot/_content/MindExecution.Plugins.Directory/exampleJsInterop.js +6 -0
  25. package/wwwroot/_content/MindExecution.Plugins.YouTube/css/youtube-search.css +251 -0
  26. package/wwwroot/_content/MindExecution.Shared/MindExecution.Shared.wsano1j4wp.bundle.scp.css +4 -0
  27. package/wwwroot/_content/MindExecution.Shared/css/admin-dashboard.css +559 -0
  28. package/wwwroot/_content/MindExecution.Shared/css/app.css +1 -0
  29. package/wwwroot/_content/MindExecution.Shared/css/mind-map-overrides.css +2936 -0
  30. package/wwwroot/_content/MindExecution.Shared/fonts/NotoSansKR-Bold.ttf +0 -0
  31. package/wwwroot/_content/MindExecution.Shared/fonts/NotoSansKR-Regular.ttf +0 -0
  32. package/wwwroot/_content/MindExecution.Shared/js/agent-visualization.js +359 -0
  33. package/wwwroot/_content/MindExecution.Shared/js/background-themes.js +1721 -0
  34. package/wwwroot/_content/MindExecution.Shared/js/code-master.js +8316 -0
  35. package/wwwroot/_content/MindExecution.Shared/js/file-system-helper.js +639 -0
  36. package/wwwroot/_content/MindExecution.Shared/js/helpers/InfiniteGridHelper.js +109 -0
  37. package/wwwroot/_content/MindExecution.Shared/js/marked.min.js +69 -0
  38. package/wwwroot/_content/MindExecution.Shared/js/mind-map-core.js +7982 -0
  39. package/wwwroot/_content/MindExecution.Shared/js/mind-map-core.js.backup +1059 -0
  40. package/wwwroot/_content/MindExecution.Shared/js/mind-map-css3d-manager.js +15803 -0
  41. package/wwwroot/_content/MindExecution.Shared/js/mind-map-dev-guards.js +325 -0
  42. package/wwwroot/_content/MindExecution.Shared/js/mind-map-dnd.js +1430 -0
  43. package/wwwroot/_content/MindExecution.Shared/js/mind-map-dnd.js.bak +434 -0
  44. package/wwwroot/_content/MindExecution.Shared/js/mind-map-glow-shader.js +260 -0
  45. package/wwwroot/_content/MindExecution.Shared/js/mind-map-interactions.js +7640 -0
  46. package/wwwroot/_content/MindExecution.Shared/js/mind-map-lod-plan-worker.js +160 -0
  47. package/wwwroot/_content/MindExecution.Shared/js/mind-map-lod-renderer.js +9923 -0
  48. package/wwwroot/_content/MindExecution.Shared/js/mind-map-logic-workers.js +977 -0
  49. package/wwwroot/_content/MindExecution.Shared/js/mind-map-menu-manager.js +1431 -0
  50. package/wwwroot/_content/MindExecution.Shared/js/mind-map-multi-select.js +1716 -0
  51. package/wwwroot/_content/MindExecution.Shared/js/mind-map-node-search-worker.js +553 -0
  52. package/wwwroot/_content/MindExecution.Shared/js/mind-map-nodes.js +4541 -0
  53. package/wwwroot/_content/MindExecution.Shared/js/mind-map-object-manager.js +489 -0
  54. package/wwwroot/_content/MindExecution.Shared/js/mind-map-object-manager.js.backup +372 -0
  55. package/wwwroot/_content/MindExecution.Shared/js/mind-map-pipeline.js +2075 -0
  56. package/wwwroot/_content/MindExecution.Shared/js/mind-map-text-lod-system.js +646 -0
  57. package/wwwroot/_content/MindExecution.Shared/js/mind-map-text-overlay-v2.js +4323 -0
  58. package/wwwroot/_content/MindExecution.Shared/js/mind-map-texture-factory.js +2260 -0
  59. package/wwwroot/_content/MindExecution.Shared/js/mind-map-texture-factory.js.backup +1258 -0
  60. package/wwwroot/_content/MindExecution.Shared/js/mind-map-visibility-worker.js +890 -0
  61. package/wwwroot/_content/MindExecution.Shared/js/mindmap-toolbar.js +594 -0
  62. package/wwwroot/_content/MindExecution.Shared/js/native-drop-handler.js +170 -0
  63. package/wwwroot/_content/MindExecution.Shared/js/plan-master.js +788 -0
  64. package/wwwroot/_content/MindExecution.Shared/js/renderers/CSS3DRenderer.js +50 -0
  65. package/wwwroot/_content/MindExecution.Shared/js/texture-worker-manager.js +188 -0
  66. package/wwwroot/_content/MindExecution.Shared/js/texture-worker.js +208 -0
  67. package/wwwroot/_content/MindExecution.Shared/js/three.min.js +6 -0
  68. package/wwwroot/_content/MindExecution.Shared/js/titlebar-handler.js +191 -0
  69. package/wwwroot/_content/MindExecution.Shared/js/token-manager.js +37 -0
  70. package/wwwroot/_content/MindExecution.Shared/js/token-worker.js +28 -0
  71. package/wwwroot/_content/MindExecution.Shared/js/troika-bundle.js +5626 -0
  72. package/wwwroot/_content/MindExecution.Shared/js/troika-bundle.js.map +7 -0
  73. package/wwwroot/_content/MindExecution.Shared/lib/font-awesome/css/all.min.css +9 -0
  74. package/wwwroot/_content/MindExecution.Shared/lib/font-awesome/webfonts/fa-brands-400.ttf +0 -0
  75. package/wwwroot/_content/MindExecution.Shared/lib/font-awesome/webfonts/fa-brands-400.woff2 +0 -0
  76. package/wwwroot/_content/MindExecution.Shared/lib/font-awesome/webfonts/fa-regular-400.ttf +0 -0
  77. package/wwwroot/_content/MindExecution.Shared/lib/font-awesome/webfonts/fa-regular-400.woff2 +0 -0
  78. package/wwwroot/_content/MindExecution.Shared/lib/font-awesome/webfonts/fa-solid-900.ttf +0 -0
  79. package/wwwroot/_content/MindExecution.Shared/lib/font-awesome/webfonts/fa-solid-900.woff2 +0 -0
  80. package/wwwroot/_content/MindExecution.Shared/models/all-MiniLM-L6-v2-quantized.onnx +0 -0
  81. package/wwwroot/_content/MindExecution.Shared/models/vocab.txt +30522 -0
  82. package/wwwroot/_framework/Google.Protobuf.9h59ukbel7.dll +0 -0
  83. package/wwwroot/_framework/Markdig.d1j7v41cl1.dll +0 -0
  84. package/wwwroot/_framework/MessagePack.Annotations.l6qv48kgpt.dll +0 -0
  85. package/wwwroot/_framework/MessagePack.eqoptzx9d5.dll +0 -0
  86. package/wwwroot/_framework/Microsoft.AspNetCore.Authorization.k7dsih5y5g.dll +0 -0
  87. package/wwwroot/_framework/Microsoft.AspNetCore.Components.6nyje9sa0g.dll +0 -0
  88. package/wwwroot/_framework/Microsoft.AspNetCore.Components.Authorization.iycd6unprw.dll +0 -0
  89. package/wwwroot/_framework/Microsoft.AspNetCore.Components.Web.487u3twia4.dll +0 -0
  90. package/wwwroot/_framework/Microsoft.AspNetCore.Components.WebAssembly.d0gcnmlxxz.dll +0 -0
  91. package/wwwroot/_framework/Microsoft.AspNetCore.Metadata.h4yevl9adi.dll +0 -0
  92. package/wwwroot/_framework/Microsoft.CSharp.qrvp77qmhs.dll +0 -0
  93. package/wwwroot/_framework/Microsoft.Data.Sqlite.jdlxgv2jtg.dll +0 -0
  94. package/wwwroot/_framework/Microsoft.EntityFrameworkCore.4gjazp7kjf.dll +0 -0
  95. package/wwwroot/_framework/Microsoft.EntityFrameworkCore.Abstractions.gocudnvz7b.dll +0 -0
  96. package/wwwroot/_framework/Microsoft.EntityFrameworkCore.Relational.lt4rsvinuo.dll +0 -0
  97. package/wwwroot/_framework/Microsoft.EntityFrameworkCore.Sqlite.69luj0fa9r.dll +0 -0
  98. package/wwwroot/_framework/Microsoft.Extensions.Caching.Abstractions.364t4jh3zz.dll +0 -0
  99. package/wwwroot/_framework/Microsoft.Extensions.Caching.Memory.izlxhpzosu.dll +0 -0
  100. package/wwwroot/_framework/Microsoft.Extensions.Configuration.8zq7hh41o7.dll +0 -0
  101. package/wwwroot/_framework/Microsoft.Extensions.Configuration.Abstractions.8if74zs6ea.dll +0 -0
  102. package/wwwroot/_framework/Microsoft.Extensions.Configuration.Json.duvlngw8i0.dll +0 -0
  103. package/wwwroot/_framework/Microsoft.Extensions.DependencyInjection.Abstractions.t2hh9kvx0o.dll +0 -0
  104. package/wwwroot/_framework/Microsoft.Extensions.DependencyInjection.n4tg99oy8l.dll +0 -0
  105. package/wwwroot/_framework/Microsoft.Extensions.DependencyModel.h0d06ixk3e.dll +0 -0
  106. package/wwwroot/_framework/Microsoft.Extensions.Logging.Abstractions.rl32bkx2sd.dll +0 -0
  107. package/wwwroot/_framework/Microsoft.Extensions.Logging.dlht1xei0t.dll +0 -0
  108. package/wwwroot/_framework/Microsoft.Extensions.Options.qeunebioml.dll +0 -0
  109. package/wwwroot/_framework/Microsoft.Extensions.Primitives.18cr6vnuuz.dll +0 -0
  110. package/wwwroot/_framework/Microsoft.IO.RecyclableMemoryStream.r915vovvw4.dll +0 -0
  111. package/wwwroot/_framework/Microsoft.IdentityModel.Abstractions.1ejljk3erv.dll +0 -0
  112. package/wwwroot/_framework/Microsoft.IdentityModel.JsonWebTokens.1596zr8gne.dll +0 -0
  113. package/wwwroot/_framework/Microsoft.IdentityModel.Logging.229uyvpgio.dll +0 -0
  114. package/wwwroot/_framework/Microsoft.IdentityModel.Tokens.9sibtajc9f.dll +0 -0
  115. package/wwwroot/_framework/Microsoft.JSInterop.17lq4j1j7g.dll +0 -0
  116. package/wwwroot/_framework/Microsoft.JSInterop.WebAssembly.ryia5gxiad.dll +0 -0
  117. package/wwwroot/_framework/Microsoft.ML.OnnxRuntime.w9deo1m5ss.dll +0 -0
  118. package/wwwroot/_framework/Microsoft.ML.Tokenizers.cm2vuv2z61.dll +0 -0
  119. package/wwwroot/_framework/Microsoft.NET.StringTools.3qbrf4v2ki.dll +0 -0
  120. package/wwwroot/_framework/MimeMapping.og9ys58ylm.dll +0 -0
  121. package/wwwroot/_framework/MindExecution.Core.1q1trifbuu.dll +0 -0
  122. package/wwwroot/_framework/MindExecution.Kernel.gwwc40sc45.dll +0 -0
  123. package/wwwroot/_framework/MindExecution.Plugins.Admin.0jgrn1sckv.dll +0 -0
  124. package/wwwroot/_framework/MindExecution.Plugins.Business.13mme2qcag.dll +0 -0
  125. package/wwwroot/_framework/MindExecution.Plugins.Concept.dfp2mdt45q.dll +0 -0
  126. package/wwwroot/_framework/MindExecution.Plugins.Directory.3w4t6n3se0.dll +0 -0
  127. package/wwwroot/_framework/MindExecution.Plugins.PlanMaster.s0qpntz420.dll +0 -0
  128. package/wwwroot/_framework/MindExecution.Plugins.YouTube.iu11fq8d16.dll +0 -0
  129. package/wwwroot/_framework/MindExecution.Shared.7j27dcqnrc.dll +0 -0
  130. package/wwwroot/_framework/MindExecution.Web.pq1ty8ov2v.dll +0 -0
  131. package/wwwroot/_framework/Newtonsoft.Json.a56zs13vug.dll +0 -0
  132. package/wwwroot/_framework/SQLitePCLRaw.batteries_v2.rrd1nzawpp.dll +0 -0
  133. package/wwwroot/_framework/SQLitePCLRaw.core.1dxloztpfz.dll +0 -0
  134. package/wwwroot/_framework/SQLitePCLRaw.provider.e_sqlite3.oekyzl53i1.dll +0 -0
  135. package/wwwroot/_framework/Supabase.Core.s1pkj4aj0l.dll +0 -0
  136. package/wwwroot/_framework/Supabase.Functions.qz4nu782sg.dll +0 -0
  137. package/wwwroot/_framework/Supabase.Gotrue.twah27pkik.dll +0 -0
  138. package/wwwroot/_framework/Supabase.Postgrest.gmuuv369ih.dll +0 -0
  139. package/wwwroot/_framework/Supabase.Realtime.ox3kchdy3w.dll +0 -0
  140. package/wwwroot/_framework/Supabase.Storage.fnjnepaowr.dll +0 -0
  141. package/wwwroot/_framework/Supabase.azmaw5pgcz.dll +0 -0
  142. package/wwwroot/_framework/System.Collections.Concurrent.y1zmvuyipi.dll +0 -0
  143. package/wwwroot/_framework/System.Collections.Immutable.ug3j698qms.dll +0 -0
  144. package/wwwroot/_framework/System.Collections.NonGeneric.h66hj3863h.dll +0 -0
  145. package/wwwroot/_framework/System.Collections.Specialized.umr3y27ntj.dll +0 -0
  146. package/wwwroot/_framework/System.Collections.x53e19vfsj.dll +0 -0
  147. package/wwwroot/_framework/System.ComponentModel.Annotations.tz6gnt4ebt.dll +0 -0
  148. package/wwwroot/_framework/System.ComponentModel.Primitives.j7tiphu4rg.dll +0 -0
  149. package/wwwroot/_framework/System.ComponentModel.TypeConverter.ujlztox1gx.dll +0 -0
  150. package/wwwroot/_framework/System.ComponentModel.x9xz0ojfb6.dll +0 -0
  151. package/wwwroot/_framework/System.Console.ijzpqmj7ne.dll +0 -0
  152. package/wwwroot/_framework/System.Data.Common.1r0sqffq1p.dll +0 -0
  153. package/wwwroot/_framework/System.Diagnostics.DiagnosticSource.9upoqwq09o.dll +0 -0
  154. package/wwwroot/_framework/System.Diagnostics.Process.m99azzntjm.dll +0 -0
  155. package/wwwroot/_framework/System.Diagnostics.TraceSource.pl7wv26myr.dll +0 -0
  156. package/wwwroot/_framework/System.Diagnostics.Tracing.crlhfx6tut.dll +0 -0
  157. package/wwwroot/_framework/System.Drawing.Primitives.22e4y9ikq9.dll +0 -0
  158. package/wwwroot/_framework/System.Drawing.mi7d8hwowb.dll +0 -0
  159. package/wwwroot/_framework/System.Formats.Asn1.jx23sjiqnn.dll +0 -0
  160. package/wwwroot/_framework/System.IO.Compression.6fyoii3uej.dll +0 -0
  161. package/wwwroot/_framework/System.IO.Pipelines.vg77t4cd4d.dll +0 -0
  162. package/wwwroot/_framework/System.IdentityModel.Tokens.Jwt.t67es60z5b.dll +0 -0
  163. package/wwwroot/_framework/System.Linq.1bkoxlqgmq.dll +0 -0
  164. package/wwwroot/_framework/System.Linq.Expressions.24xqiypwdt.dll +0 -0
  165. package/wwwroot/_framework/System.Linq.Queryable.hvd01d6rsa.dll +0 -0
  166. package/wwwroot/_framework/System.Memory.8dx3lwgym4.dll +0 -0
  167. package/wwwroot/_framework/System.Net.Http.Json.3mhdm9l1rf.dll +0 -0
  168. package/wwwroot/_framework/System.Net.Http.eitrz660my.dll +0 -0
  169. package/wwwroot/_framework/System.Net.NetworkInformation.3pkuofcv9r.dll +0 -0
  170. package/wwwroot/_framework/System.Net.Ping.8clj5pklrp.dll +0 -0
  171. package/wwwroot/_framework/System.Net.Primitives.qrp4wcjz1p.dll +0 -0
  172. package/wwwroot/_framework/System.Net.WebSockets.Client.2u6pv01g69.dll +0 -0
  173. package/wwwroot/_framework/System.Net.WebSockets.qp6u31zvm5.dll +0 -0
  174. package/wwwroot/_framework/System.Numerics.Tensors.0c7z4mt3on.dll +0 -0
  175. package/wwwroot/_framework/System.Numerics.Vectors.kc7ufp2j4l.dll +0 -0
  176. package/wwwroot/_framework/System.ObjectModel.qv82fot1ib.dll +0 -0
  177. package/wwwroot/_framework/System.Private.CoreLib.rkafq04oma.dll +0 -0
  178. package/wwwroot/_framework/System.Private.Uri.t9542hmr6j.dll +0 -0
  179. package/wwwroot/_framework/System.Private.Xml.Linq.n8n3ptrbwu.dll +0 -0
  180. package/wwwroot/_framework/System.Private.Xml.rxd3tytisn.dll +0 -0
  181. package/wwwroot/_framework/System.Reactive.t3fuon548l.dll +0 -0
  182. package/wwwroot/_framework/System.Reflection.Emit.9tjhp6y0j3.dll +0 -0
  183. package/wwwroot/_framework/System.Reflection.Emit.ILGeneration.stxyk8zoo1.dll +0 -0
  184. package/wwwroot/_framework/System.Reflection.Emit.Lightweight.6xrd5v8vg0.dll +0 -0
  185. package/wwwroot/_framework/System.Reflection.Primitives.wgn8fpwwvv.dll +0 -0
  186. package/wwwroot/_framework/System.Runtime.InteropServices.JavaScript.sliym526xh.dll +0 -0
  187. package/wwwroot/_framework/System.Runtime.InteropServices.RuntimeInformation.oji7zut14z.dll +0 -0
  188. package/wwwroot/_framework/System.Runtime.InteropServices.te07xr2we9.dll +0 -0
  189. package/wwwroot/_framework/System.Runtime.Intrinsics.507y4h8nzq.dll +0 -0
  190. package/wwwroot/_framework/System.Runtime.Loader.v7gk4bse0k.dll +0 -0
  191. package/wwwroot/_framework/System.Runtime.Numerics.eqy5xjv3nd.dll +0 -0
  192. package/wwwroot/_framework/System.Runtime.Serialization.Formatters.zpkrub8lab.dll +0 -0
  193. package/wwwroot/_framework/System.Runtime.Serialization.Primitives.vhkpnbxjip.dll +0 -0
  194. package/wwwroot/_framework/System.Runtime.jn319d5nyg.dll +0 -0
  195. package/wwwroot/_framework/System.Security.Claims.0ztig1q9vo.dll +0 -0
  196. package/wwwroot/_framework/System.Security.Cryptography.vttizqc9ho.dll +0 -0
  197. package/wwwroot/_framework/System.Text.Encoding.Extensions.utdd47ny8f.dll +0 -0
  198. package/wwwroot/_framework/System.Text.Encodings.Web.wah8r1zoe0.dll +0 -0
  199. package/wwwroot/_framework/System.Text.Json.kxlfxj0wrs.dll +0 -0
  200. package/wwwroot/_framework/System.Text.RegularExpressions.dbqn58klox.dll +0 -0
  201. package/wwwroot/_framework/System.Threading.42ao9vi047.dll +0 -0
  202. package/wwwroot/_framework/System.Threading.Channels.hfa7j0uv2w.dll +0 -0
  203. package/wwwroot/_framework/System.Threading.Thread.caul0pdqul.dll +0 -0
  204. package/wwwroot/_framework/System.Transactions.Local.fimi2hamzo.dll +0 -0
  205. package/wwwroot/_framework/System.Web.HttpUtility.gq8yz50p2e.dll +0 -0
  206. package/wwwroot/_framework/System.Xml.Linq.kitin4zjoj.dll +0 -0
  207. package/wwwroot/_framework/System.Xml.ReaderWriter.kzvw3qgxb0.dll +0 -0
  208. package/wwwroot/_framework/System.Xml.XDocument.c539ki6cuq.dll +0 -0
  209. package/wwwroot/_framework/System.m05i39uvk9.dll +0 -0
  210. package/wwwroot/_framework/Websocket.Client.vapounvmnl.dll +0 -0
  211. package/wwwroot/_framework/blazor.boot.json +305 -0
  212. package/wwwroot/_framework/blazor.webassembly.js +1 -0
  213. package/wwwroot/_framework/dotnet.js +4 -0
  214. package/wwwroot/_framework/dotnet.native.vz0adxojrz.wasm +0 -0
  215. package/wwwroot/_framework/dotnet.native.xsn1d6x2kd.js +16 -0
  216. package/wwwroot/_framework/dotnet.runtime.dstopyvqzi.js +4 -0
  217. package/wwwroot/_framework/icudt_CJK.tjcz0u77k5.dat +0 -0
  218. package/wwwroot/_framework/icudt_EFIGS.tptq2av103.dat +0 -0
  219. package/wwwroot/_framework/icudt_no_CJK.lfu7j35m59.dat +0 -0
  220. package/wwwroot/_framework/netstandard.0xet7jg7ky.dll +0 -0
  221. package/wwwroot/_headers +40 -0
  222. package/wwwroot/_redirects +1 -0
  223. package/wwwroot/appsettings.json +71 -0
  224. package/wwwroot/icon-192.png +0 -0
  225. package/wwwroot/icon-512.png +0 -0
  226. package/wwwroot/index.html +710 -0
  227. package/wwwroot/js/marketing-tool.js +180 -0
  228. package/wwwroot/manifest.webmanifest +22 -0
  229. package/wwwroot/robots.txt +4 -0
  230. package/wwwroot/service-worker-assets.js +857 -0
  231. package/wwwroot/service-worker.js +33 -0
  232. package/wwwroot/sitemap.xml +27 -0
@@ -0,0 +1,4541 @@
1
+ // File: mind-map-nodes.js
2
+ // [Refactor] Core API + state orchestrator for the MindMap node module
3
+ // (Implementation details are delegated to MindMapTextureFactory, MindMapObjectManager, MindMapCss3DManager, MindMapPipeline)
4
+ (function () {
5
+ // ▼▼▼ [Perf] Debug logging - set to false in production ▼▼▼
6
+ const DEBUG = false;
7
+ const log = DEBUG ? console.log.bind(console) : () => { };
8
+ const warn = DEBUG ? console.warn.bind(console) : () => { };
9
+ // Keep halo/glow in CSS selected styles; do not render WebGL glow meshes.
10
+ const ENABLE_WEBGL_GLOW = false;
11
+ function waitForNonVisualFrame(hiddenDelay = 16) {
12
+ return new Promise(resolve => {
13
+ if (typeof requestAnimationFrame === 'function'
14
+ && !(typeof document !== 'undefined' && document.visibilityState === 'hidden')) {
15
+ requestAnimationFrame(resolve);
16
+ return;
17
+ }
18
+
19
+ setTimeout(resolve, hiddenDelay);
20
+ });
21
+ }
22
+ // ▲▲▲ [Perf] ▲▲▲
23
+
24
+ const MEMO_SELECTION_THEMES = {
25
+ coral: { border: 0xf7b08d, accent: 0xff8052 },
26
+ amber: { border: 0xf4d06f, accent: 0xf59e0b },
27
+ sun: { border: 0xe7d668, accent: 0xca8a04 },
28
+ sage: { border: 0xbed79f, accent: 0x65a30d },
29
+ mint: { border: 0x8fd8c1, accent: 0x10b981 },
30
+ sky: { border: 0x9dc7f7, accent: 0x3b82f6 },
31
+ indigo: { border: 0xbcc2ff, accent: 0x6366f1 },
32
+ violet: { border: 0xd6b8ff, accent: 0x8b5cf6 },
33
+ rose: { border: 0xf5b2c2, accent: 0xf43f5e },
34
+ gray: { border: 0xd1d5db, accent: 0x6b7280 },
35
+ slate: { border: 0xcbd5e1, accent: 0x64748b }
36
+ };
37
+ const NODE_TEXT_SELECTION_SELECTORS = '.node-response, .code-body, .text-content, .markdown-body, textarea, .map-node-memo__title, .map-node-memo__body, .map-node-memo__body-view, .map-node-memo__body-view *';
38
+ const NODE_INTERACTIVE_CONTENT_SELECTORS = `${NODE_TEXT_SELECTION_SELECTORS}, .embed-content, .embed-card, .embed-card *, .embed-action, .embed-play-button, iframe, .map-node-memo__icon-button, .map-node-memo__icon-option, .map-node-memo__icon-popover, .map-node-memo__agent-action, .map-node-memo__agent-result-link, .map-node-memo__agent-plan-panel, .map-node-memo__agent-plan-body, .map-node-memo__agent-plan-body *, .map-node-memo__agent-console-panel, .map-node-memo__agent-console-body, .map-node-memo__agent-console-body *, .map-node-memo__agent-console-resize, .map-node-memo__agent-console-resize *`;
39
+ const MEMO_EDIT_ONLY_SELECTORS = '.map-node-memo__title, .map-node-memo__body, .map-node-memo__icon-button, .map-node-memo__icon-option, .map-node-memo__icon-popover';
40
+
41
+ function getNodeMetadata(nodeModel) {
42
+ return nodeModel?.metadata || nodeModel?.Metadata || null;
43
+ }
44
+
45
+ function getNodeSemanticType(nodeModel) {
46
+ const metadata = getNodeMetadata(nodeModel);
47
+ return String(metadata?.SemanticType || metadata?.semanticType || '').trim();
48
+ }
49
+
50
+ const BUSINESS_AUTOMATION_SEMANTIC_TYPE = 'BusinessAutomationNode';
51
+ const AUTOMATION_INPUT_PINS_METADATA_KEY = 'AutomationInputPins';
52
+ const AUTOMATION_OUTPUT_PINS_METADATA_KEY = 'AutomationOutputPins';
53
+ const AUTOMATION_PIN_RAIL_WIDTH = 118;
54
+ const AUTOMATION_EDGE_GUTTER_X = 80;
55
+
56
+ function parseAutomationPinsFromMetadata(metadata, key) {
57
+ const raw = metadata?.[key] ?? metadata?.[key.charAt(0).toLowerCase() + key.slice(1)];
58
+ if (Array.isArray(raw)) {
59
+ return raw;
60
+ }
61
+
62
+ if (typeof raw !== 'string' || !raw.trim()) {
63
+ return [];
64
+ }
65
+
66
+ try {
67
+ const parsed = JSON.parse(raw);
68
+ return Array.isArray(parsed) ? parsed : [];
69
+ } catch {
70
+ return [];
71
+ }
72
+ }
73
+
74
+ function getAutomationPinnedNodeCursorExtraPadding(nodeModel, direction) {
75
+ if (getNodeSemanticType(nodeModel) !== BUSINESS_AUTOMATION_SEMANTIC_TYPE
76
+ || String(direction || '').toLowerCase() === 'vertical') {
77
+ return 0;
78
+ }
79
+
80
+ const metadata = getNodeMetadata(nodeModel);
81
+ const hasInputPins = parseAutomationPinsFromMetadata(metadata, AUTOMATION_INPUT_PINS_METADATA_KEY).length > 0;
82
+ const hasOutputPins = parseAutomationPinsFromMetadata(metadata, AUTOMATION_OUTPUT_PINS_METADATA_KEY).length > 0;
83
+ if (!hasInputPins && !hasOutputPins) {
84
+ return 0;
85
+ }
86
+
87
+ return (hasInputPins ? AUTOMATION_PIN_RAIL_WIDTH : 0)
88
+ + (hasOutputPins ? AUTOMATION_PIN_RAIL_WIDTH : 0)
89
+ + AUTOMATION_EDGE_GUTTER_X;
90
+ }
91
+
92
+ function isAgentStyledMemoNode(nodeModel) {
93
+ const semanticType = getNodeSemanticType(nodeModel);
94
+ return semanticType === 'MindCanvasAgent'
95
+ || semanticType === 'AgentCommand'
96
+ || semanticType === BUSINESS_AUTOMATION_SEMANTIC_TYPE;
97
+ }
98
+
99
+ function isAgentNodeModel(nodeModel) {
100
+ const nodeType = String(nodeModel?.nodeType ?? nodeModel?.NodeType ?? '').toLowerCase();
101
+ if (nodeType.startsWith('agent_')) {
102
+ return true;
103
+ }
104
+
105
+ const metadata = getNodeMetadata(nodeModel);
106
+ const agentId = metadata?.agentId ?? metadata?.AgentId;
107
+ return agentId !== undefined && agentId !== null && String(agentId) !== '';
108
+ }
109
+
110
+ function isInlineEditableNodeModel(nodeModel) {
111
+ const contentType = String(nodeModel?.contentType ?? nodeModel?.ContentType ?? '').toLowerCase();
112
+ return contentType === 'note' || contentType === 'memo' || contentType === 'code' || isAgentNodeModel(nodeModel);
113
+ }
114
+
115
+ function isReadOnlyTextSelectionNodeModel(nodeModel) {
116
+ const contentType = String(nodeModel?.contentType ?? nodeModel?.ContentType ?? '').toLowerCase();
117
+ return (contentType === 'text' || contentType === 'markdown') && !isAgentNodeModel(nodeModel);
118
+ }
119
+
120
+ function getSelectionBorderColorHex(nodeModel, isSelected) {
121
+ if (String(nodeModel?.contentType ?? nodeModel?.ContentType ?? '').toLowerCase() !== 'memo') {
122
+ return isSelected ? 0x7b7ff2 : 0x374151;
123
+ }
124
+
125
+ const fallbackKey = 'coral';
126
+ const key = String(getNodeMetadata(nodeModel)?.memoColor || fallbackKey).toLowerCase();
127
+ const theme = MEMO_SELECTION_THEMES[key] || MEMO_SELECTION_THEMES[fallbackKey];
128
+ return isSelected ? theme.accent : theme.border;
129
+ }
130
+
131
+ function cloneNodeMetadata(nodeModel) {
132
+ const source = getNodeMetadata(nodeModel);
133
+ return source && typeof source === 'object' ? { ...source } : {};
134
+ }
135
+
136
+ function captureDeleteSnapshot(module, nodeId) {
137
+ const fallback = {
138
+ nodeId: String(nodeId || ''),
139
+ currentContent: '',
140
+ currentPrompt: '',
141
+ metadata: {}
142
+ };
143
+
144
+ const entry = module?.nodeObjectsById?.get(nodeId);
145
+ if (!entry?.model) {
146
+ return fallback;
147
+ }
148
+
149
+ const model = entry.model;
150
+ const contentType = String(model.contentType ?? model.ContentType ?? '').toLowerCase();
151
+ const metadata = cloneNodeMetadata(model);
152
+ let currentPrompt = model.prompt ?? model.Prompt ?? '';
153
+ let currentContent = model.response ?? model.Response ?? '';
154
+
155
+ const root = entry.cssObject?.element || null;
156
+ if (root) {
157
+ if (contentType === 'memo') {
158
+ const titleInput = root.querySelector('.map-node-memo__title');
159
+ const bodyTextarea = root.querySelector('.map-node-memo__body');
160
+ if (titleInput) {
161
+ currentPrompt = titleInput.value ?? '';
162
+ }
163
+ if (bodyTextarea) {
164
+ currentContent = bodyTextarea.value ?? '';
165
+ }
166
+ } else if (contentType === 'note') {
167
+ const textarea = root.querySelector('textarea, [id^="node-textarea-"]');
168
+ if (textarea) {
169
+ currentContent = textarea.value ?? '';
170
+ }
171
+ }
172
+ }
173
+
174
+ model.prompt = currentPrompt;
175
+ model.Prompt = currentPrompt;
176
+ model.response = currentContent;
177
+ model.Response = currentContent;
178
+ model.metadata = metadata;
179
+ model.Metadata = metadata;
180
+
181
+ return {
182
+ nodeId: String(nodeId),
183
+ currentContent,
184
+ currentPrompt,
185
+ metadata
186
+ };
187
+ }
188
+
189
+
190
+ // ▲▲▲ [Config] ▲▲▲
191
+
192
+ // --- Module state (caches, locks, etc.) ---
193
+ let textureCache = new Map();
194
+ let imageCache = new Map();
195
+ const failedImageUrlCache = new Map();
196
+ let nodeRenderLocks = new Map();
197
+ let pendingNodeUpdates = new Map();
198
+
199
+ // ▼▼▼ [New] Text Render Mode State ('canvas' | 'troika') ▼▼▼
200
+ let TEXT_RENDER_MODE = 'canvas'; // Default to Canvas 2D
201
+ // ▲▲▲ [New] ▲▲▲
202
+
203
+ // ★ 텍스처 메모리 추적
204
+ let totalTextureMemoryBytes = 0;
205
+
206
+ function calculateTextureMemory(textures, nodeId = 'unknown') {
207
+ if (!textures) return 0;
208
+ let bytes = 0;
209
+
210
+ // Canvas 텍스처 메모리 = width * height * 4 (RGBA)
211
+ const calcTexSize = (tex, name) => {
212
+ if (!tex || !tex.image) return 0;
213
+ if (tex._isShared) return 0;
214
+ const img = tex.image;
215
+ const w = img.width || img.naturalWidth || 0;
216
+ const h = img.height || img.naturalHeight || 0;
217
+ const size = w * h * 4; // RGBA
218
+ if (size > 1024 * 1024) { // 1MB 이상만 로그
219
+ console.log(` [TexMem] ${name}: ${w}x${h} = ${(size / 1024 / 1024).toFixed(2)}MB`);
220
+ }
221
+ return size;
222
+ };
223
+
224
+ // default body & tail
225
+ bytes += calcTexSize(textures.default?.body, `${nodeId}.default.body`);
226
+ bytes += calcTexSize(textures.default?.tail, `${nodeId}.default.tail`);
227
+ // selected body & tail
228
+ bytes += calcTexSize(textures.selected?.body, `${nodeId}.selected.body`);
229
+ bytes += calcTexSize(textures.selected?.tail, `${nodeId}.selected.tail`);
230
+ // glow
231
+ bytes += calcTexSize(textures.glow?.texture, `${nodeId}.glow`);
232
+
233
+ return bytes;
234
+ }
235
+
236
+ function logTextureMemory(action, nodeId, deltaBytes = 0) {
237
+ if (!deltaBytes) return;
238
+ totalTextureMemoryBytes += deltaBytes;
239
+ const totalMB = (totalTextureMemoryBytes / (1024 * 1024)).toFixed(2);
240
+ const deltaMB = (Math.abs(deltaBytes) / (1024 * 1024)).toFixed(2);
241
+ const sign = deltaBytes >= 0 ? '+' : '-';
242
+ console.log(`[📊 Texture Memory] ${action} ${nodeId}: ${sign}${deltaMB}MB | Total: ${totalMB}MB (${textureCache.size} nodes)`);
243
+ }
244
+
245
+ function estimateTextureCacheMemoryBytes(cache = textureCache) {
246
+ if (!(cache instanceof Map) || cache.size === 0) {
247
+ return 0;
248
+ }
249
+
250
+ let bytes = 0;
251
+ cache.forEach((textures, nodeId) => {
252
+ bytes += calculateTextureMemory(textures, nodeId);
253
+ });
254
+ return bytes;
255
+ }
256
+
257
+ function syncExportedRuntimeCaches() {
258
+ if (!window.MindMapNodes) return;
259
+ window.MindMapNodes.textureCache = textureCache;
260
+ window.MindMapNodes.imageCache = imageCache;
261
+ window.MindMapNodes.nodeRenderLocks = nodeRenderLocks;
262
+ window.MindMapNodes.pendingNodeUpdates = pendingNodeUpdates;
263
+ }
264
+
265
+ function replaceRuntimeCaches(nextState = {}) {
266
+ if (nextState.textureCache instanceof Map) {
267
+ textureCache = nextState.textureCache;
268
+ }
269
+ if (nextState.imageCache instanceof Map) {
270
+ imageCache = nextState.imageCache;
271
+ }
272
+ if (nextState.nodeRenderLocks instanceof Map) {
273
+ nodeRenderLocks = nextState.nodeRenderLocks;
274
+ }
275
+ if (nextState.pendingNodeUpdates instanceof Map) {
276
+ pendingNodeUpdates = nextState.pendingNodeUpdates;
277
+ }
278
+
279
+ const nextTextureBytes = Number(nextState.totalTextureMemoryBytes);
280
+ totalTextureMemoryBytes = Number.isFinite(nextTextureBytes)
281
+ ? Math.max(0, nextTextureBytes)
282
+ : estimateTextureCacheMemoryBytes(textureCache);
283
+
284
+ syncExportedRuntimeCaches();
285
+ }
286
+
287
+ function getRuntimeCacheStats() {
288
+ return {
289
+ textureEntryCount: textureCache.size,
290
+ imageEntryCount: imageCache.size,
291
+ nodeRenderLockCount: nodeRenderLocks.size,
292
+ pendingNodeUpdateCount: pendingNodeUpdates.size,
293
+ textureBytesEstimate: Math.max(0, Number(totalTextureMemoryBytes) || 0)
294
+ };
295
+ }
296
+
297
+ // ▼▼▼ [Restored] Generate background texture with border for code nodes ▼▼▼
298
+
299
+ // ▲▲▲ [Restored] ▲▲▲
300
+
301
+ // ▼▼▼ [New] Scrollbar Style Injection ▼▼▼
302
+ function injectScrollbarStyles() {
303
+ const styleId = 'mindmap-thin-scrollbar-style';
304
+ if (document.getElementById(styleId)) return;
305
+
306
+ const style = document.createElement('style');
307
+ style.id = styleId;
308
+ style.innerHTML = `
309
+ /* Narrow scrollbar width/height (approx 6-8px) */
310
+ .thin-scrollbar::-webkit-scrollbar {
311
+ width: 8px;
312
+ height: 8px;
313
+ }
314
+ /* Scrollbar Thumb */
315
+ .thin-scrollbar::-webkit-scrollbar-thumb {
316
+ background-color: rgba(0, 0, 0, 0.3);
317
+ border-radius: 4px;
318
+ }
319
+ /* Darken on hover */
320
+ .thin-scrollbar:hover {
321
+ scrollbar-color: rgba(0, 0, 0, 0.45) transparent;
322
+ }
323
+ .thin-scrollbar::-webkit-scrollbar-thumb:hover {
324
+ background-color: rgba(0, 0, 0, 0.45);
325
+ }
326
+ .thin-scrollbar:hover::-webkit-scrollbar-thumb,
327
+ .thin-scrollbar::-webkit-scrollbar-thumb:active {
328
+ background-color: rgba(0, 0, 0, 0.45);
329
+ }
330
+ /* Transparent Track */
331
+ .thin-scrollbar::-webkit-scrollbar-track {
332
+ background: transparent;
333
+ }
334
+ /* Firefox Compatibility */
335
+ .thin-scrollbar {
336
+ box-sizing: border-box;
337
+ scrollbar-gutter: stable;
338
+ padding-right: 14px;
339
+ scrollbar-width: thin;
340
+ scrollbar-color: rgba(0, 0, 0, 0.3) transparent;
341
+ }
342
+ `;
343
+ document.head.appendChild(style);
344
+ }
345
+ // ▲▲▲ [New] ▲▲▲
346
+
347
+ // --- Public API functions ---
348
+
349
+ // ▼▼▼ [New] Helper to update the node's spatial-grid registration ▼▼▼
350
+ function getNodeSpatialGridKeys(nodeEntry) {
351
+ if (nodeEntry?._spatialGridKeys instanceof Set) {
352
+ return nodeEntry._spatialGridKeys;
353
+ }
354
+ if (nodeEntry?.glObject?.userData?.spatialGridKeys instanceof Set) {
355
+ return nodeEntry.glObject.userData.spatialGridKeys;
356
+ }
357
+ if (nodeEntry?.cssObject?.userData?.spatialGridKeys instanceof Set) {
358
+ return nodeEntry.cssObject.userData.spatialGridKeys;
359
+ }
360
+
361
+ return new Set();
362
+ }
363
+
364
+ function setNodeSpatialGridKeys(nodeEntry, keys) {
365
+ if (!nodeEntry) return;
366
+ nodeEntry._spatialGridKeys = keys;
367
+ if (nodeEntry.glObject) {
368
+ nodeEntry.glObject.userData ??= {};
369
+ nodeEntry.glObject.userData.spatialGridKeys = keys;
370
+ }
371
+ if (nodeEntry.cssObject) {
372
+ nodeEntry.cssObject.userData ??= {};
373
+ nodeEntry.cssObject.userData.spatialGridKeys = keys;
374
+ }
375
+ }
376
+
377
+ function getNodeWorldBounds(nodeEntry) {
378
+ const sharedHelper = window.MindMapNodeBounds?.getNodeWorldBounds;
379
+ if (typeof sharedHelper === 'function') {
380
+ return sharedHelper(nodeEntry);
381
+ }
382
+
383
+ const model = nodeEntry?.model || null;
384
+ const obj = nodeEntry?.glObject || nodeEntry?.cssObject || null;
385
+ if (!model && !obj) {
386
+ return null;
387
+ }
388
+
389
+ const width = Math.max(1, Number(
390
+ nodeEntry?.glObject?.userData?.worldWidth
391
+ ?? nodeEntry?.cssObject?.userData?.worldWidth
392
+ ?? model?.width
393
+ ?? 400
394
+ ));
395
+ const height = Math.max(1, Number(
396
+ nodeEntry?.glObject?.userData?.worldHeight
397
+ ?? nodeEntry?.cssObject?.userData?.worldHeight
398
+ ?? model?.height
399
+ ?? 200
400
+ ));
401
+ const x = Number(obj?.position?.x ?? model?.positionX ?? model?.x ?? 0);
402
+ const y = Number(obj?.position?.y ?? model?.positionY ?? model?.y ?? 0);
403
+ const z = Number(obj?.position?.z ?? model?.positionZ ?? model?.z ?? 0);
404
+
405
+ return {
406
+ minX: x,
407
+ maxX: x + width,
408
+ minY: y - height,
409
+ maxY: y,
410
+ x,
411
+ y,
412
+ z,
413
+ width,
414
+ height
415
+ };
416
+ }
417
+
418
+ function updateNodeSpatialGrid(module, nodeId) {
419
+ const nodeEntry = module.nodeObjectsById.get(nodeId);
420
+ if (!nodeEntry) return;
421
+
422
+ const bounds = getNodeWorldBounds(nodeEntry);
423
+ if (!bounds) return;
424
+
425
+ const oldKeys = getNodeSpatialGridKeys(nodeEntry);
426
+ const newKeys = new Set();
427
+ const startCellX = Math.floor(bounds.minX / module.GRID_CELL_SIZE);
428
+ const endCellX = Math.floor(bounds.maxX / module.GRID_CELL_SIZE);
429
+ const startCellY = Math.floor(bounds.minY / module.GRID_CELL_SIZE);
430
+ const endCellY = Math.floor(bounds.maxY / module.GRID_CELL_SIZE);
431
+
432
+ for (let x = startCellX; x <= endCellX; x++) {
433
+ for (let y = startCellY; y <= endCellY; y++) {
434
+ newKeys.add(`${x}:${y}`);
435
+ }
436
+ }
437
+
438
+ // Update only the changed keys
439
+ oldKeys.forEach(key => {
440
+ if (!newKeys.has(key)) {
441
+ const cell = module.spatialGrid.get(key);
442
+ if (cell) cell.delete(nodeId);
443
+ if (cell && cell.size === 0) module.spatialGrid.delete(key);
444
+ }
445
+ });
446
+ newKeys.forEach(key => {
447
+ if (!oldKeys.has(key)) {
448
+ if (!module.spatialGrid.has(key)) module.spatialGrid.set(key, new Set());
449
+ module.spatialGrid.get(key).add(nodeId);
450
+ }
451
+ });
452
+
453
+ setNodeSpatialGridKeys(nodeEntry, newKeys);
454
+ module.logicWorkerBridge?.upsertNodeEntry?.(nodeEntry, module, {
455
+ nodeId,
456
+ bounds,
457
+ cellKeys: newKeys
458
+ });
459
+ }
460
+
461
+ function snapNodeFrameDimension(module, value, fallback = null) {
462
+ const grid = Math.max(1, Number(module?.GRID_SIZE || 10));
463
+ const raw = Number(value);
464
+ if (Number.isFinite(raw) && raw > 0) {
465
+ return Math.max(grid, Math.round(raw / grid) * grid);
466
+ }
467
+
468
+ const fallbackValue = Number(fallback);
469
+ if (Number.isFinite(fallbackValue) && fallbackValue > 0) {
470
+ return Math.max(grid, Math.round(fallbackValue / grid) * grid);
471
+ }
472
+
473
+ return grid;
474
+ }
475
+
476
+ function applyNodeWorldPosition(module, nodeEntry, x, y, z = null) {
477
+ if (!module || !nodeEntry) return;
478
+
479
+ const nextX = Number(x || 0);
480
+ const nextY = Number(y || 0);
481
+ const nextZ = Number.isFinite(Number(z))
482
+ ? Number(z)
483
+ : Number(nodeEntry.model?.positionZ ?? nodeEntry.glObject?.position?.z ?? nodeEntry.cssObject?.position?.z ?? 0);
484
+
485
+ if (nodeEntry.model) {
486
+ nodeEntry.model.positionX = nextX;
487
+ nodeEntry.model.positionY = nextY;
488
+ nodeEntry.model.positionZ = nextZ;
489
+ nodeEntry.model.x = nextX;
490
+ nodeEntry.model.y = nextY;
491
+ nodeEntry.model.z = nextZ;
492
+ }
493
+
494
+ if (nodeEntry.glObject) {
495
+ nodeEntry.glObject.position.set(nextX, nextY, nextZ);
496
+ }
497
+
498
+ if (nodeEntry.cssObject) {
499
+ const cssParentScene = module.cssScene || module.scene;
500
+ if (cssParentScene && nodeEntry.cssObject.parent !== cssParentScene) {
501
+ cssParentScene.add(nodeEntry.cssObject);
502
+ window.MindMapCss3DManager?.markCssParentRepairNeeded?.(module, nodeEntry.model?.id || nodeEntry.glObject?.userData?.nodeId);
503
+ }
504
+ nodeEntry.cssObject.position.set(nextX, nextY, nextZ);
505
+ nodeEntry.cssObject.updateMatrixWorld(true);
506
+ window.MindMapCss3DManager?.markNodeTransformDirty?.(module, nodeEntry.model?.id || nodeEntry.glObject?.userData?.nodeId);
507
+ }
508
+
509
+ const nodeId = nodeEntry.model?.id || nodeEntry.glObject?.userData?.nodeId;
510
+ updateNodeSpatialGrid(module, nodeId);
511
+ module.lodRenderer?.markPositionsDirty?.(nodeId);
512
+ module._forceUpdateFrames = Math.max(Number(module._forceUpdateFrames || 0), 2);
513
+ }
514
+
515
+ function moveCursorAfterNodePlacement(module, nodeModel, width, height, options = {}) {
516
+ if (!module || !nodeModel || typeof module.updateCursorPosition !== 'function') {
517
+ return;
518
+ }
519
+
520
+ const moveCamera = options.moveCamera === true;
521
+ const persistViewState = options.persistViewState !== false;
522
+ const direction = String(options.direction || module.cursorDirection || 'horizontal').toLowerCase();
523
+ const basePadding = Number.isFinite(Number(options.padding)) ? Number(options.padding) : 30;
524
+ const padding = basePadding + getAutomationPinnedNodeCursorExtraPadding(nodeModel, direction);
525
+ const nodeX = Number(nodeModel.positionX || 0);
526
+ const nodeY = Number(nodeModel.positionY || 0);
527
+ const nodeWidth = Math.max(1, Number(width || nodeModel.width || 1));
528
+ const nodeHeight = Math.max(1, Number(height || nodeModel.height || 1));
529
+
530
+ let nextCursorX = nodeX;
531
+ let nextCursorY = nodeY;
532
+ if (direction === 'vertical') {
533
+ nextCursorY = nodeY - nodeHeight - padding;
534
+ } else {
535
+ nextCursorX = nodeX + nodeWidth + padding;
536
+ }
537
+
538
+ const cameraTarget = {
539
+ x: nodeX + (nodeWidth / 2),
540
+ y: nodeY - (nodeHeight / 2)
541
+ };
542
+
543
+ module.updateCursorPosition(nextCursorX, nextCursorY, moveCamera, cameraTarget, persistViewState);
544
+ }
545
+
546
+ function prepareGeneratedImageLayoutUpdate(module, nodeEntry, requestedWidth, requestedHeight, previousResponse, previousIsLoading, nextIsLoading) {
547
+ const model = nodeEntry?.model;
548
+ if (!module || !model) {
549
+ return null;
550
+ }
551
+
552
+ const contentType = String(model.contentType ?? model.ContentType ?? '').toLowerCase();
553
+ if (contentType !== 'image' || previousIsLoading !== true || nextIsLoading !== false) {
554
+ return null;
555
+ }
556
+
557
+ const metadata = getNodeMetadata(model);
558
+ const isNewNode = String(metadata?.IsNew ?? metadata?.isNew ?? '').trim().toLowerCase() === 'true';
559
+ const hadPlaceholderResponse = !String(previousResponse || '').trim();
560
+ if (!isNewNode && !hadPlaceholderResponse) {
561
+ return null;
562
+ }
563
+
564
+ const previousWidth = Math.max(1, Number(model.width || model.Width || 1));
565
+ const previousHeight = Math.max(1, Number(model.height || model.Height || 1));
566
+ const nextWidth = snapNodeFrameDimension(module, requestedWidth, previousWidth);
567
+ const nextHeight = snapNodeFrameDimension(module, requestedHeight, previousHeight);
568
+ const previousX = Number(model.positionX ?? model.x ?? 0);
569
+ const previousY = Number(model.positionY ?? model.y ?? 0);
570
+ const rightEdge = previousX + previousWidth;
571
+ const bottomEdge = previousY - previousHeight;
572
+ const snapAxis = typeof module.snapNodeToGrid === 'function'
573
+ ? module.snapNodeToGrid.bind(module)
574
+ : (value) => {
575
+ const grid = Math.max(1, Number(module?.GRID_SIZE || 10));
576
+ return Math.round(Number(value || 0) / grid) * grid;
577
+ };
578
+ const snappedRightEdge = snapAxis(rightEdge);
579
+ const snappedBottomEdge = snapAxis(bottomEdge);
580
+ const nextX = snappedRightEdge - nextWidth;
581
+ const nextY = snappedBottomEdge + nextHeight;
582
+
583
+ return {
584
+ x: nextX,
585
+ y: nextY,
586
+ width: nextWidth,
587
+ height: nextHeight,
588
+ positionChanged: Math.abs(nextX - previousX) > 0.1 || Math.abs(nextY - previousY) > 0.1,
589
+ sizeChanged: Math.abs(nextWidth - previousWidth) > 0.1 || Math.abs(nextHeight - previousHeight) > 0.1,
590
+ shouldMoveCursor: isNewNode
591
+ };
592
+ }
593
+
594
+ function supportsDeferredShellNode(nodeModel) {
595
+ const contentType = String(nodeModel?.contentType ?? nodeModel?.ContentType ?? '').toLowerCase();
596
+ return contentType === 'text' ||
597
+ contentType === 'note' ||
598
+ contentType === 'memo' ||
599
+ contentType === 'markdown' ||
600
+ contentType === 'code' ||
601
+ contentType === 'embed';
602
+ }
603
+
604
+ function shouldCreateDeferredShellNode(module, nodeModel) {
605
+ return module?.isLoading === true && supportsDeferredShellNode(nodeModel);
606
+ }
607
+
608
+ async function ensureDeferredNodeMaterialized(module, nodeEntry, options = {}) {
609
+ if (!module || !nodeEntry || nodeEntry.isDeferredShell !== true) {
610
+ return nodeEntry?.glObject || null;
611
+ }
612
+
613
+ const objectManager = window.MindMapObjectManager;
614
+ const textureFactory = window.MindMapTextureFactory;
615
+ if (!objectManager?.createOrUpdateCanvasTextureNode || !textureFactory?.generatePlaceholderTextures) {
616
+ return nodeEntry.glObject || null;
617
+ }
618
+
619
+ const model = nodeEntry.model || {};
620
+ const oldGroup = nodeEntry.glObject || null;
621
+ const preservedX = Number(oldGroup?.position?.x ?? model.positionX ?? 0);
622
+ const preservedY = Number(oldGroup?.position?.y ?? model.positionY ?? 0);
623
+ const preservedZ = Number(oldGroup?.position?.z ?? model.positionZ ?? 0);
624
+ const preservedVisible = oldGroup?.visible !== false;
625
+ const preservedSpatialKeys = new Set(getNodeSpatialGridKeys(nodeEntry));
626
+ const width = Number(model.width || 400);
627
+ const height = Number(model.height || 200);
628
+
629
+ model.positionX = preservedX;
630
+ model.positionY = preservedY;
631
+ model.positionZ = preservedZ;
632
+
633
+ let textures = textureCache.get(model.id) || null;
634
+ if (!textures) {
635
+ const generated = await textureFactory.generatePlaceholderTextures(module, model, width, height);
636
+ textures = generated?.textures || null;
637
+ if (textures) {
638
+ textureCache.set(model.id, textures);
639
+ }
640
+ }
641
+
642
+ const materializedGroup = await objectManager.createOrUpdateCanvasTextureNode(
643
+ module,
644
+ model,
645
+ null,
646
+ preservedZ,
647
+ textures,
648
+ width,
649
+ height
650
+ );
651
+ if (!materializedGroup) {
652
+ return oldGroup;
653
+ }
654
+
655
+ materializedGroup.position.set(preservedX, preservedY, preservedZ);
656
+ materializedGroup.visible = preservedVisible;
657
+ materializedGroup.userData ??= {};
658
+ materializedGroup.userData.spatialGridKeys = preservedSpatialKeys;
659
+ materializedGroup.userData.isDeferredShell = false;
660
+
661
+ if (oldGroup?.parent) {
662
+ oldGroup.parent.remove(oldGroup);
663
+ }
664
+
665
+ if (options.attachToScene !== false && module.scene && !materializedGroup.parent) {
666
+ module.scene.add(materializedGroup);
667
+ }
668
+
669
+ nodeEntry.glObject = materializedGroup;
670
+ nodeEntry.bodyMesh = materializedGroup.getObjectByName?.('body') || null;
671
+ nodeEntry.tailMesh = materializedGroup.getObjectByName?.('tail') || null;
672
+ nodeEntry.glowMesh = materializedGroup.getObjectByName?.('glow') || null;
673
+ nodeEntry.isDeferredShell = false;
674
+
675
+ updateNodeSpatialGrid(module, model.id);
676
+ return materializedGroup;
677
+ }
678
+
679
+ function removeNodeFromSpatialGrid(module, nodeId, nodeEntry = null) {
680
+ if (!module?.spatialGrid || !nodeId) return;
681
+
682
+ const keys = getNodeSpatialGridKeys(nodeEntry);
683
+ if (keys && typeof keys.forEach === 'function') {
684
+ keys.forEach(key => {
685
+ const cell = module.spatialGrid.get(key);
686
+ if (cell) cell.delete(nodeId);
687
+ if (cell && cell.size === 0) module.spatialGrid.delete(key);
688
+ });
689
+ setNodeSpatialGridKeys(nodeEntry, new Set());
690
+ return;
691
+ }
692
+
693
+ module.spatialGrid.forEach((cell, key) => {
694
+ if (!cell?.delete?.(nodeId)) return;
695
+ if (cell.size === 0) module.spatialGrid.delete(key);
696
+ });
697
+ }
698
+ // ▲▲▲ [New] ▲▲▲
699
+
700
+ function ensureNodeMeshCache(entry) {
701
+ if (!entry?.glObject) return;
702
+ if (!entry.bodyMesh) entry.bodyMesh = entry.glObject.getObjectByName('body');
703
+ if (!entry.tailMesh) entry.tailMesh = entry.glObject.getObjectByName('tail');
704
+ if (!entry.glowMesh) entry.glowMesh = entry.glObject.getObjectByName('glow');
705
+ }
706
+
707
+ function setNodeScrollbarVisibility(entry, visible) {
708
+ if (!entry?.glObject?.getObjectByName) return;
709
+ const track = entry.glObject.getObjectByName('scrollbarTrack');
710
+ const thumb = entry.glObject.getObjectByName('scrollbarThumb');
711
+ if (track) track.visible = !!visible;
712
+ if (thumb) thumb.visible = !!visible;
713
+ }
714
+
715
+ const IMAGE_SELECTION_GLOW_PAD = 24;
716
+ const IMAGE_SELECTION_GLOW_COLOR = 0x1e3a8a;
717
+ const IMAGE_SELECTION_GLOW_OPACITY = 0.24;
718
+ const IMAGE_SELECTION_SDF_GLOW_COLOR = 0x3b82f6;
719
+ const IMAGE_SELECTION_SDF_GLOW_OPACITY = 0.46;
720
+ const IMAGE_MID_GLOW_COLOR = 0x0f172a;
721
+ const IMAGE_MID_GLOW_OPACITY = 0.34;
722
+
723
+ function getImageLodThresholds() {
724
+ const thresholds = window.LODRenderer?.LOD_THRESHOLDS || null;
725
+ const mid = Number.isFinite(thresholds?.MID) ? thresholds.MID : 6000;
726
+ const far = Number.isFinite(thresholds?.FAR) ? thresholds.FAR : 25000;
727
+ return { mid, far };
728
+ }
729
+
730
+ function createRoundedRectFilledGeometry(width, height, radius) {
731
+ const shape = new THREE.Shape();
732
+ const x = 0;
733
+ const y = 0;
734
+ const r = Math.min(radius, width / 2, height / 2);
735
+ const h = -height;
736
+
737
+ shape.moveTo(x + r, y);
738
+ shape.lineTo(x + width - r, y);
739
+ shape.quadraticCurveTo(x + width, y, x + width, y - r);
740
+ shape.lineTo(x + width, y + h + r);
741
+ shape.quadraticCurveTo(x + width, y + h, x + width - r, y + h);
742
+ shape.lineTo(x + r, y + h);
743
+ shape.quadraticCurveTo(x, y + h, x, y + h + r);
744
+ shape.lineTo(x, y - r);
745
+ shape.quadraticCurveTo(x, y, x + r, y);
746
+
747
+ return new THREE.ShapeGeometry(shape);
748
+ }
749
+
750
+ function syncImageSelectionGlow(group, width, height, baseRenderOrder, isSelected) {
751
+ if (!group) return null;
752
+
753
+ let outline = group.getObjectByName('outline');
754
+ if (!outline) {
755
+ outline = new THREE.Mesh(
756
+ createRoundedRectFilledGeometry(
757
+ width + IMAGE_SELECTION_GLOW_PAD,
758
+ height + IMAGE_SELECTION_GLOW_PAD,
759
+ 20 + (IMAGE_SELECTION_GLOW_PAD * 0.5)
760
+ ),
761
+ new THREE.MeshBasicMaterial({
762
+ color: IMAGE_SELECTION_GLOW_COLOR,
763
+ transparent: true,
764
+ opacity: 0,
765
+ depthWrite: false,
766
+ depthTest: false
767
+ })
768
+ );
769
+ outline.name = 'outline';
770
+ group.add(outline);
771
+ }
772
+
773
+ outline.geometry?.dispose?.();
774
+ outline.geometry = createRoundedRectFilledGeometry(
775
+ width + IMAGE_SELECTION_GLOW_PAD,
776
+ height + IMAGE_SELECTION_GLOW_PAD,
777
+ 20 + (IMAGE_SELECTION_GLOW_PAD * 0.5)
778
+ );
779
+ outline.position.set(-(IMAGE_SELECTION_GLOW_PAD / 2), IMAGE_SELECTION_GLOW_PAD / 2, 0);
780
+ outline.renderOrder = Math.max(0, (baseRenderOrder || 1) - 1);
781
+ outline.visible = !!isSelected;
782
+
783
+ if (outline.material) {
784
+ outline.material.color.setHex(IMAGE_SELECTION_GLOW_COLOR);
785
+ outline.material.opacity = isSelected ? IMAGE_SELECTION_GLOW_OPACITY : 0;
786
+ outline.material.transparent = true;
787
+ outline.material.depthWrite = false;
788
+ outline.material.depthTest = false;
789
+ outline.material.needsUpdate = true;
790
+ }
791
+
792
+ return outline;
793
+ }
794
+
795
+ function syncImageMidLodDecorations(group, width, height, baseRenderOrder, isSelected, visible) {
796
+ if (!group) return null;
797
+
798
+ const existingEdge = group.getObjectByName('imageMidEdge');
799
+ if (existingEdge) {
800
+ existingEdge.parent?.remove?.(existingEdge);
801
+ existingEdge.geometry?.dispose?.();
802
+ existingEdge.material?.dispose?.();
803
+ }
804
+ const existingGlow = group.getObjectByName('imageMidGlow');
805
+ const visibilityConfig =
806
+ visible && typeof visible === 'object'
807
+ ? visible
808
+ : null;
809
+ const edgeVisible = false;
810
+ const glowVisible = visibilityConfig
811
+ ? !!visibilityConfig.glowVisible
812
+ : (visible === undefined ? (existingGlow?.visible ?? false) : !!visible);
813
+ const previousState = group.userData?.imageMidLodDecorState || null;
814
+
815
+ if (
816
+ previousState &&
817
+ previousState.width === width &&
818
+ previousState.height === height &&
819
+ previousState.baseRenderOrder === baseRenderOrder &&
820
+ previousState.isSelected === !!isSelected &&
821
+ previousState.edgeVisible === edgeVisible &&
822
+ previousState.glowVisible === glowVisible &&
823
+ ((existingGlow && existingGlow.visible === glowVisible) || (!existingGlow && glowVisible === false))
824
+ ) {
825
+ return { edge: null, glow: existingGlow || null };
826
+ }
827
+
828
+ group.userData.imageMidLodDecorState = {
829
+ width,
830
+ height,
831
+ baseRenderOrder,
832
+ isSelected: !!isSelected,
833
+ edgeVisible,
834
+ glowVisible
835
+ };
836
+
837
+ let glow = existingGlow;
838
+ if (window.MindMapGlowShader?.createSDFGlowMesh) {
839
+ glow = window.MindMapGlowShader.createSDFGlowMesh(width, height, false, 'image', glow || null);
840
+ glow.name = 'imageMidGlow';
841
+ glow.raycast = () => { };
842
+ if (!glow.parent) {
843
+ group.add(glow);
844
+ }
845
+ } else {
846
+ const glowWidth = Math.max(1, width + 24);
847
+ const glowHeight = Math.max(1, height + 24);
848
+ if (!glow) {
849
+ glow = new THREE.Mesh(
850
+ new THREE.PlaneGeometry(glowWidth, glowHeight),
851
+ new THREE.MeshBasicMaterial({
852
+ color: IMAGE_MID_GLOW_COLOR,
853
+ transparent: true,
854
+ opacity: IMAGE_MID_GLOW_OPACITY,
855
+ depthWrite: false,
856
+ depthTest: false,
857
+ side: THREE.DoubleSide
858
+ })
859
+ );
860
+ glow.name = 'imageMidGlow';
861
+ glow.raycast = () => { };
862
+ group.add(glow);
863
+ }
864
+
865
+ const glowGeometry = glow.geometry?.parameters;
866
+ if (!glowGeometry || glowGeometry.width !== glowWidth || glowGeometry.height !== glowHeight) {
867
+ glow.geometry?.dispose?.();
868
+ glow.geometry = new THREE.PlaneGeometry(glowWidth, glowHeight);
869
+ }
870
+ }
871
+
872
+ if (glow) {
873
+ const glowColor = isSelected ? IMAGE_SELECTION_SDF_GLOW_COLOR : IMAGE_MID_GLOW_COLOR;
874
+ const glowOpacity = isSelected ? IMAGE_SELECTION_SDF_GLOW_OPACITY : IMAGE_MID_GLOW_OPACITY;
875
+ glow.position.set(width / 2, -height / 2, -0.02);
876
+ glow.renderOrder = Math.max(0, (baseRenderOrder || 1) - 3);
877
+ glow.visible = glowVisible;
878
+
879
+ if (glow.material?.uniforms) {
880
+ glow.material.uniforms.color.value.setHex(glowColor);
881
+ glow.material.uniforms.opacity.value = glowOpacity;
882
+ glow.material.depthWrite = false;
883
+ glow.material.depthTest = false;
884
+ glow.material.transparent = true;
885
+ glow.material.needsUpdate = true;
886
+ } else if (glow.material) {
887
+ glow.material.color.setHex(glowColor);
888
+ glow.material.opacity = glowOpacity;
889
+ glow.material.transparent = true;
890
+ glow.material.depthWrite = false;
891
+ glow.material.depthTest = false;
892
+ glow.material.needsUpdate = true;
893
+ }
894
+ }
895
+
896
+ return { edge: null, glow };
897
+ }
898
+
899
+ function applyImageCoverUv(bodyMesh, nodeWidth, nodeHeight) {
900
+ const material = bodyMesh?.material;
901
+ const texture = material?.map;
902
+ if (!texture) return;
903
+
904
+ const image = texture.image;
905
+ const imageWidth = Number(image?.videoWidth || image?.naturalWidth || image?.width || 0);
906
+ const imageHeight = Number(image?.videoHeight || image?.naturalHeight || image?.height || 0);
907
+ const targetWidth = Number(nodeWidth || bodyMesh?.geometry?.parameters?.width || 0);
908
+ const targetHeight = Number(nodeHeight || bodyMesh?.geometry?.parameters?.height || 0);
909
+
910
+ if (!(imageWidth > 0 && imageHeight > 0 && targetWidth > 0 && targetHeight > 0)) {
911
+ return;
912
+ }
913
+
914
+ const imageAspect = imageWidth / imageHeight;
915
+ const targetAspect = targetWidth / targetHeight;
916
+
917
+ let repeatX = 1;
918
+ let repeatY = 1;
919
+ let offsetX = 0;
920
+ let offsetY = 0;
921
+
922
+ // Match CSS `object-fit: cover` behavior in WebGL mode.
923
+ if (imageAspect > targetAspect) {
924
+ repeatX = targetAspect / imageAspect;
925
+ offsetX = (1 - repeatX) * 0.5;
926
+ } else if (imageAspect < targetAspect) {
927
+ repeatY = imageAspect / targetAspect;
928
+ offsetY = (1 - repeatY) * 0.5;
929
+ }
930
+
931
+ const requiresManualVFlip = texture.userData?.mindMapManualVFlip === true;
932
+ if (requiresManualVFlip) {
933
+ repeatY = -repeatY;
934
+ offsetY = 1 - offsetY;
935
+ }
936
+
937
+ const previousWrapS = texture.wrapS;
938
+ const previousWrapT = texture.wrapT;
939
+ const previousCenterX = Number(texture.center?.x ?? 0);
940
+ const previousCenterY = Number(texture.center?.y ?? 0);
941
+ const previousRepeatX = Number(texture.repeat?.x ?? 1);
942
+ const previousRepeatY = Number(texture.repeat?.y ?? 1);
943
+ const previousOffsetX = Number(texture.offset?.x ?? 0);
944
+ const previousOffsetY = Number(texture.offset?.y ?? 0);
945
+ const hasWrapChange =
946
+ previousWrapS !== THREE.ClampToEdgeWrapping ||
947
+ previousWrapT !== THREE.ClampToEdgeWrapping;
948
+ const hasCenterChange =
949
+ Math.abs(previousCenterX) > 1e-6 ||
950
+ Math.abs(previousCenterY) > 1e-6;
951
+ const hasRepeatChange =
952
+ Math.abs(previousRepeatX - repeatX) > 1e-6 ||
953
+ Math.abs(previousRepeatY - repeatY) > 1e-6;
954
+ const hasOffsetChange =
955
+ Math.abs(previousOffsetX - offsetX) > 1e-6 ||
956
+ Math.abs(previousOffsetY - offsetY) > 1e-6;
957
+
958
+ if (!hasWrapChange && !hasCenterChange && !hasRepeatChange && !hasOffsetChange) {
959
+ return;
960
+ }
961
+
962
+ texture.wrapS = THREE.ClampToEdgeWrapping;
963
+ texture.wrapT = THREE.ClampToEdgeWrapping;
964
+ // Keep center at the origin because offset already describes the centered crop window.
965
+ // Combining a 0.5 center with the offset shifts the sampled image in MID/FAR WebGL mode.
966
+ if (texture.center?.set) texture.center.set(0, 0);
967
+ if (texture.repeat?.set) texture.repeat.set(repeatX, repeatY);
968
+ if (texture.offset?.set) texture.offset.set(offsetX, offsetY);
969
+ if (hasWrapChange) {
970
+ texture.needsUpdate = true;
971
+ }
972
+ }
973
+
974
+ const IMAGE_STATUS_SPINNER_CYCLE_MS = 900;
975
+ const IMAGE_STATUS_ANIMATION_FRAME_MS = 140;
976
+ const IMAGE_STATUS_TEXTURE_MIN_SCALE = 2;
977
+ const IMAGE_STATUS_TEXTURE_MAX_SCALE = 4;
978
+ const IMAGE_STATUS_TEXTURE_MIN_BACKING = 160;
979
+ const IMAGE_STATUS_LOADING_TEXTURE_MIN_SCALE = 1;
980
+ const IMAGE_STATUS_LOADING_TEXTURE_MAX_SCALE = 2;
981
+ const IMAGE_STATUS_LOADING_TEXTURE_MIN_BACKING = 128;
982
+
983
+ function clampImageStatusMetric(value, min, max) {
984
+ return Math.max(min, Math.min(max, value));
985
+ }
986
+
987
+ function resolveImageStatusTextureScale(isLoading = false) {
988
+ const windowDpr = Math.max(1, Number(window.devicePixelRatio || 1));
989
+ const rendererDpr = Math.max(1, Number(window.mindMap?.renderer?.getPixelRatio?.() || 1));
990
+ const baseDpr = Math.max(windowDpr, rendererDpr);
991
+ const boostedScale = Math.ceil(baseDpr * (isLoading ? 1.0 : 1.35));
992
+ return clampImageStatusMetric(
993
+ boostedScale,
994
+ isLoading ? IMAGE_STATUS_LOADING_TEXTURE_MIN_SCALE : IMAGE_STATUS_TEXTURE_MIN_SCALE,
995
+ isLoading ? IMAGE_STATUS_LOADING_TEXTURE_MAX_SCALE : IMAGE_STATUS_TEXTURE_MAX_SCALE
996
+ );
997
+ }
998
+
999
+ function getImageStatusTypography(width, height) {
1000
+ const safeWidth = Math.max(160, Number(width || 0));
1001
+ const safeHeight = Math.max(120, Number(height || 0));
1002
+ const base = Math.min(safeWidth, safeHeight);
1003
+ return {
1004
+ titleSize: clampImageStatusMetric(Math.round(base * 0.095), 16, 28),
1005
+ subtitleSize: clampImageStatusMetric(Math.round(base * 0.07), 13, 22),
1006
+ radius: clampImageStatusMetric(base * 0.12, 16, 28),
1007
+ strokeWidth: clampImageStatusMetric(base * 0.018, 3, 5),
1008
+ titleGap: clampImageStatusMetric(base * 0.15, 24, 40),
1009
+ subtitleGap: clampImageStatusMetric(base * 0.28, 44, 68)
1010
+ };
1011
+ }
1012
+
1013
+ function getImageStatusState(nodeModel) {
1014
+ const isLoading = !!(nodeModel?.isLoading ?? nodeModel?.IsLoading ?? false);
1015
+ const contentType = String(nodeModel?.contentType ?? nodeModel?.ContentType ?? '').trim().toLowerCase();
1016
+ const metadata = nodeModel?.metadata ?? nodeModel?.Metadata ?? {};
1017
+ const hasError = !!String(metadata?.ImageGenerationError || '').trim();
1018
+ const readyTitle = contentType === 'video'
1019
+ ? 'Video'
1020
+ : (contentType === 'embed' ? 'Embed' : 'Image');
1021
+ const loadingTitle = contentType === 'video'
1022
+ ? 'Loading video'
1023
+ : (contentType === 'embed' ? 'Loading embed' : 'Generating image');
1024
+
1025
+ return {
1026
+ isLoading,
1027
+ hasError,
1028
+ title: hasError
1029
+ ? 'Image failed'
1030
+ : (isLoading ? loadingTitle : readyTitle),
1031
+ subtitle: hasError
1032
+ ? 'Check console and retry'
1033
+ : (isLoading ? 'Please wait' : 'Preparing preview')
1034
+ };
1035
+ }
1036
+
1037
+ function hasNodeResponseContent(nodeModel) {
1038
+ return !!String(nodeModel?.response ?? nodeModel?.Response ?? '').trim();
1039
+ }
1040
+
1041
+ function shouldUseCssImageLoadingOverlay(nodeModel) {
1042
+ const contentType = String(nodeModel?.contentType ?? nodeModel?.ContentType ?? '').trim().toLowerCase();
1043
+ if (contentType !== 'image') {
1044
+ return false;
1045
+ }
1046
+
1047
+ const isLoading = !!(nodeModel?.isLoading ?? nodeModel?.IsLoading ?? false);
1048
+ return isLoading && !hasNodeResponseContent(nodeModel);
1049
+ }
1050
+
1051
+ function shouldShowImageBodyInCssMode(nodeModel) {
1052
+ const contentType = String(nodeModel?.contentType ?? nodeModel?.ContentType ?? '').trim().toLowerCase();
1053
+ if (contentType !== 'image') {
1054
+ return false;
1055
+ }
1056
+ return nodeModel?._showImageBodyInCssMode === true;
1057
+ }
1058
+
1059
+ function drawImageStatusTextureFrame(ctx, width, height, statusState, rotationRadians = 0) {
1060
+ if (!ctx) {
1061
+ return;
1062
+ }
1063
+
1064
+ const isLoading = statusState?.isLoading === true;
1065
+ const hasError = statusState?.hasError === true;
1066
+ const title = String(statusState?.title || '');
1067
+ const subtitle = String(statusState?.subtitle || '');
1068
+
1069
+ ctx.clearRect(0, 0, width, height);
1070
+ ctx.fillStyle = '#f8fafc';
1071
+ ctx.fillRect(0, 0, width, height);
1072
+
1073
+ ctx.strokeStyle = '#e2e8f0';
1074
+ ctx.lineWidth = 1;
1075
+ ctx.strokeRect(0.5, 0.5, width - 1, height - 1);
1076
+
1077
+ const centerX = width / 2;
1078
+ const centerY = Math.max(52, (height / 2) - 12);
1079
+ const typography = getImageStatusTypography(width, height);
1080
+ const radius = typography.radius;
1081
+
1082
+ ctx.lineCap = 'round';
1083
+ ctx.lineWidth = typography.strokeWidth;
1084
+ if (hasError) {
1085
+ ctx.strokeStyle = '#f59e0b';
1086
+ ctx.beginPath();
1087
+ ctx.moveTo(centerX - radius * 0.6, centerY - radius * 0.6);
1088
+ ctx.lineTo(centerX + radius * 0.6, centerY + radius * 0.6);
1089
+ ctx.moveTo(centerX + radius * 0.6, centerY - radius * 0.6);
1090
+ ctx.lineTo(centerX - radius * 0.6, centerY + radius * 0.6);
1091
+ ctx.stroke();
1092
+ } else {
1093
+ ctx.strokeStyle = 'rgba(148, 163, 184, 0.3)';
1094
+ ctx.beginPath();
1095
+ ctx.arc(centerX, centerY, radius, 0, Math.PI * 2);
1096
+ ctx.stroke();
1097
+
1098
+ const arcLength = isLoading ? Math.PI * 1.2 : Math.PI * 1.3;
1099
+ const startAngle = isLoading
1100
+ ? (rotationRadians - (Math.PI * 0.5))
1101
+ : (-Math.PI * 0.45);
1102
+ ctx.strokeStyle = isLoading ? '#3b82f6' : '#94a3b8';
1103
+ ctx.beginPath();
1104
+ ctx.arc(centerX, centerY, radius, startAngle, startAngle + arcLength);
1105
+ ctx.stroke();
1106
+ }
1107
+
1108
+ ctx.textAlign = 'center';
1109
+ ctx.fillStyle = '#0f172a';
1110
+ ctx.textBaseline = 'middle';
1111
+ ctx.font = `600 ${typography.titleSize}px "Segoe UI", sans-serif`;
1112
+ ctx.fillText(title, centerX, centerY + radius + typography.titleGap);
1113
+
1114
+ ctx.fillStyle = '#64748b';
1115
+ ctx.font = `500 ${typography.subtitleSize}px "Segoe UI", sans-serif`;
1116
+ ctx.fillText(subtitle, centerX, centerY + radius + typography.subtitleGap);
1117
+ }
1118
+
1119
+ function createImageStatusTexture(nodeModel, nodeWidth, nodeHeight) {
1120
+ const statusState = getImageStatusState(nodeModel);
1121
+ const isLoading = statusState.isLoading === true;
1122
+ const scale = resolveImageStatusTextureScale(isLoading);
1123
+ const canvas = document.createElement('canvas');
1124
+ const minBacking = isLoading
1125
+ ? IMAGE_STATUS_LOADING_TEXTURE_MIN_BACKING
1126
+ : IMAGE_STATUS_TEXTURE_MIN_BACKING;
1127
+ canvas.width = Math.max(minBacking, Math.round((nodeWidth || 300) * scale));
1128
+ canvas.height = Math.max(minBacking, Math.round((nodeHeight || 200) * scale));
1129
+
1130
+ const ctx = canvas.getContext('2d');
1131
+ if (!ctx) {
1132
+ return null;
1133
+ }
1134
+
1135
+ ctx.scale(scale, scale);
1136
+
1137
+ const width = canvas.width / scale;
1138
+ const height = canvas.height / scale;
1139
+ drawImageStatusTextureFrame(ctx, width, height, statusState, 0);
1140
+
1141
+ const texture = new THREE.CanvasTexture(canvas);
1142
+ texture.colorSpace = THREE.SRGBColorSpace;
1143
+ texture.minFilter = THREE.LinearFilter;
1144
+ texture.magFilter = THREE.LinearFilter;
1145
+ texture.generateMipmaps = false;
1146
+ const maxAnisotropy = Number(window.mindMap?.renderer?.capabilities?.getMaxAnisotropy?.() || 1);
1147
+ texture.anisotropy = Math.max(1, Math.min(4, maxAnisotropy));
1148
+ texture.userData = texture.userData || {};
1149
+ texture.userData.mindMapImageStatus = {
1150
+ ctx,
1151
+ logicalWidth: width,
1152
+ logicalHeight: height,
1153
+ state: statusState,
1154
+ lastAnimationBucket: statusState.isLoading ? 0 : -1
1155
+ };
1156
+ texture.needsUpdate = true;
1157
+ return texture;
1158
+ }
1159
+
1160
+ function updateAnimatedImageStatusTexture(texture, now = performance.now()) {
1161
+ const statusInfo = texture?.userData?.mindMapImageStatus;
1162
+ if (!statusInfo || statusInfo.state?.isLoading !== true || !statusInfo.ctx) {
1163
+ return false;
1164
+ }
1165
+
1166
+ const animationBucket = Math.floor(now / IMAGE_STATUS_ANIMATION_FRAME_MS);
1167
+ if (statusInfo.lastAnimationBucket === animationBucket) {
1168
+ return false;
1169
+ }
1170
+
1171
+ statusInfo.lastAnimationBucket = animationBucket;
1172
+ const progress = (now % IMAGE_STATUS_SPINNER_CYCLE_MS) / IMAGE_STATUS_SPINNER_CYCLE_MS;
1173
+ drawImageStatusTextureFrame(
1174
+ statusInfo.ctx,
1175
+ statusInfo.logicalWidth,
1176
+ statusInfo.logicalHeight,
1177
+ statusInfo.state,
1178
+ progress * Math.PI * 2
1179
+ );
1180
+ texture.needsUpdate = true;
1181
+ return true;
1182
+ }
1183
+
1184
+ function updateAnimatedImageStatusTextures(module, now = performance.now(), options = {}) {
1185
+ if (options?.allowAnimation === false) {
1186
+ return;
1187
+ }
1188
+
1189
+ if (!module?.nodeObjectsById?.forEach) {
1190
+ return;
1191
+ }
1192
+
1193
+ module.nodeObjectsById.forEach(nodeEntry => {
1194
+ if (nodeEntry?.model?.contentType !== 'image' || nodeEntry?.glObject?.visible === false) {
1195
+ return;
1196
+ }
1197
+
1198
+ const texture = nodeEntry?.bodyMesh?.material?.map;
1199
+ updateAnimatedImageStatusTexture(texture, now);
1200
+ });
1201
+ }
1202
+
1203
+ function swapImageBodyTexture(bodyMesh, nextTexture) {
1204
+ const material = bodyMesh?.material;
1205
+ if (!material || !nextTexture) {
1206
+ return;
1207
+ }
1208
+
1209
+ const previousTexture = material.map;
1210
+ material.map = nextTexture;
1211
+ material.color?.setHex?.(0xffffff);
1212
+ material.needsUpdate = true;
1213
+ const nodeId = bodyMesh?.userData?.nodeId;
1214
+ if (nodeId) {
1215
+ window.mindMap?.invalidateImageLodTexture?.(nodeId);
1216
+ }
1217
+
1218
+ if (previousTexture && previousTexture !== nextTexture) {
1219
+ try { previousTexture.dispose?.(); } catch { }
1220
+ }
1221
+ }
1222
+
1223
+ function clearImageBodyTexture(bodyMesh, colorHex = 0xffffff) {
1224
+ const material = bodyMesh?.material;
1225
+ if (!material) {
1226
+ return;
1227
+ }
1228
+
1229
+ const previousTexture = material.map;
1230
+ material.map = null;
1231
+ material.color?.setHex?.(colorHex);
1232
+ material.transparent = false;
1233
+ material.opacity = 1;
1234
+ material.needsUpdate = true;
1235
+
1236
+ if (previousTexture) {
1237
+ try { previousTexture.dispose?.(); } catch { }
1238
+ }
1239
+ }
1240
+
1241
+ function normalizeImageAssetUrl(url) {
1242
+ if (typeof url !== 'string') {
1243
+ return '';
1244
+ }
1245
+
1246
+ const trimmed = url.trim();
1247
+ if (!trimmed) {
1248
+ return '';
1249
+ }
1250
+
1251
+ return trimmed
1252
+ .replace(/^http:\/\/localhost(?=:\d+\/assets\/)/i, 'http://127.0.0.1')
1253
+ .replace(/^http:\/\/\[::1\](?=:\d+\/assets\/)/i, 'http://127.0.0.1');
1254
+ }
1255
+
1256
+ function isThumbnailImageUrl(url) {
1257
+ return normalizeImageAssetUrl(url).toLowerCase().includes('/assets/thumbs/');
1258
+ }
1259
+
1260
+ function isAnimatedGifImageUrl(url) {
1261
+ const normalized = normalizeImageAssetUrl(url);
1262
+ if (!normalized) return false;
1263
+ return normalized.split(/[?#]/)[0].toLowerCase().endsWith('.gif');
1264
+ }
1265
+
1266
+ function getMetadataValueCaseInsensitive(metadata, ...keys) {
1267
+ if (!metadata || typeof metadata !== 'object') return '';
1268
+
1269
+ for (const key of keys) {
1270
+ const direct = metadata[key];
1271
+ if (direct !== undefined && direct !== null && String(direct).trim()) {
1272
+ return String(direct).trim();
1273
+ }
1274
+ }
1275
+
1276
+ const wantedKeys = new Set(keys.map(key => String(key || '').toLowerCase()));
1277
+ for (const key of Object.keys(metadata)) {
1278
+ if (wantedKeys.has(String(key || '').toLowerCase())) {
1279
+ const value = metadata[key];
1280
+ if (value !== undefined && value !== null && String(value).trim()) {
1281
+ return String(value).trim();
1282
+ }
1283
+ }
1284
+ }
1285
+
1286
+ return '';
1287
+ }
1288
+
1289
+ function isAnimatedGifImageNodeModel(nodeModel) {
1290
+ if (!nodeModel || getNodeContentTypeLower(nodeModel) !== 'image') {
1291
+ return false;
1292
+ }
1293
+
1294
+ const metadata = getNodeMetadata(nodeModel) || {};
1295
+ const mimeType = getMetadataValueCaseInsensitive(
1296
+ metadata,
1297
+ 'fileMime',
1298
+ 'FileMime',
1299
+ 'mimeType',
1300
+ 'MimeType'
1301
+ ).toLowerCase();
1302
+ if (mimeType === 'image/gif') {
1303
+ return true;
1304
+ }
1305
+
1306
+ return [
1307
+ nodeModel?.response,
1308
+ nodeModel?.Response,
1309
+ metadata?.OriginalPath,
1310
+ metadata?.OriginalUrl,
1311
+ metadata?.originalUrl,
1312
+ metadata?.ImageUrl,
1313
+ metadata?.imageUrl
1314
+ ].some(isAnimatedGifImageUrl);
1315
+ }
1316
+
1317
+ function getAnimatedGifOriginalReference(nodeModel, fallback = '') {
1318
+ const metadata = getNodeMetadata(nodeModel) || {};
1319
+ const candidates = [
1320
+ metadata?.OriginalPath,
1321
+ metadata?.OriginalUrl,
1322
+ metadata?.originalUrl,
1323
+ nodeModel?.response,
1324
+ nodeModel?.Response,
1325
+ fallback
1326
+ ];
1327
+
1328
+ for (const value of candidates) {
1329
+ const normalized = normalizeImageAssetUrl(value);
1330
+ if (normalized && !isThumbnailImageUrl(normalized)) {
1331
+ return normalized;
1332
+ }
1333
+ }
1334
+
1335
+ return '';
1336
+ }
1337
+
1338
+ function getFailedImageUrlCacheKey(url) {
1339
+ const normalized = normalizeImageAssetUrl(url);
1340
+ if (!normalized) {
1341
+ return '';
1342
+ }
1343
+
1344
+ const withoutQuery = normalized.split(/[?#]/)[0];
1345
+ const lower = withoutQuery.toLowerCase();
1346
+ const thumbsMarker = '/assets/thumbs/';
1347
+ const assetsMarker = '/assets/';
1348
+
1349
+ if (lower.includes(thumbsMarker)) {
1350
+ const assetName = withoutQuery.slice(lower.lastIndexOf(thumbsMarker) + thumbsMarker.length).split('/').pop() || '';
1351
+ const dotIndex = assetName.lastIndexOf('.');
1352
+ const assetKey = dotIndex > 0 ? assetName.slice(0, dotIndex) : assetName;
1353
+ return `thumbnail:${assetKey.toLowerCase()}`;
1354
+ }
1355
+
1356
+ if (lower.includes(assetsMarker)) {
1357
+ const assetName = withoutQuery.slice(lower.lastIndexOf(assetsMarker) + assetsMarker.length).split('/').pop() || '';
1358
+ const dotIndex = assetName.lastIndexOf('.');
1359
+ const assetKey = dotIndex > 0 ? assetName.slice(0, dotIndex) : assetName;
1360
+ return `original:${assetKey.toLowerCase()}`;
1361
+ }
1362
+
1363
+ return withoutQuery.toLowerCase();
1364
+ }
1365
+
1366
+ function getFailedImageRetryDelayMs(errorStatus) {
1367
+ return Number(errorStatus || 0) === 404 ? 60 * 1000 : 15 * 1000;
1368
+ }
1369
+
1370
+ function getFailedImageUrlEntry(url) {
1371
+ const cacheKey = getFailedImageUrlCacheKey(url);
1372
+ if (!cacheKey) {
1373
+ return null;
1374
+ }
1375
+
1376
+ const entry = failedImageUrlCache.get(cacheKey) || null;
1377
+ if (!entry) {
1378
+ return null;
1379
+ }
1380
+
1381
+ const now = performance.now();
1382
+ if (Number(entry.retryAfter || 0) <= now) {
1383
+ failedImageUrlCache.delete(cacheKey);
1384
+ return null;
1385
+ }
1386
+
1387
+ return entry;
1388
+ }
1389
+
1390
+ function rememberFailedImageUrl(url, errorStatus, retryAfter = 0) {
1391
+ const cacheKey = getFailedImageUrlCacheKey(url);
1392
+ if (!cacheKey) {
1393
+ return 0;
1394
+ }
1395
+
1396
+ const now = performance.now();
1397
+ const status = Number(errorStatus || 0);
1398
+ const nextRetryAfter = Number(retryAfter || 0) > now
1399
+ ? Number(retryAfter || 0)
1400
+ : now + getFailedImageRetryDelayMs(status);
1401
+
1402
+ failedImageUrlCache.set(cacheKey, {
1403
+ url: normalizeImageAssetUrl(url),
1404
+ errorStatus: status,
1405
+ retryAfter: nextRetryAfter,
1406
+ failedAt: now
1407
+ });
1408
+
1409
+ return nextRetryAfter;
1410
+ }
1411
+
1412
+ function clearFailedImageUrl(url) {
1413
+ const cacheKey = getFailedImageUrlCacheKey(url);
1414
+ if (!cacheKey) {
1415
+ return;
1416
+ }
1417
+
1418
+ failedImageUrlCache.delete(cacheKey);
1419
+ }
1420
+
1421
+ function ensureNodeMetadataObject(nodeModel) {
1422
+ if (!nodeModel) return {};
1423
+
1424
+ const existing = getNodeMetadata(nodeModel);
1425
+ if (existing && typeof existing === 'object') {
1426
+ nodeModel.metadata = existing;
1427
+ nodeModel.Metadata = existing;
1428
+ return existing;
1429
+ }
1430
+
1431
+ const metadata = {};
1432
+ nodeModel.metadata = metadata;
1433
+ nodeModel.Metadata = metadata;
1434
+ return metadata;
1435
+ }
1436
+
1437
+ function getNodeContentTypeLower(nodeModel) {
1438
+ return String(nodeModel?.contentType ?? nodeModel?.ContentType ?? '').toLowerCase();
1439
+ }
1440
+
1441
+ function isMediaNodeModel(nodeModel) {
1442
+ const contentTypeLower = getNodeContentTypeLower(nodeModel);
1443
+ return contentTypeLower === 'image' || contentTypeLower === 'video' || contentTypeLower === 'embed';
1444
+ }
1445
+
1446
+ function isGeneratedImageNodeModel(nodeModel) {
1447
+ if (!nodeModel || getNodeContentTypeLower(nodeModel) !== 'image') {
1448
+ return false;
1449
+ }
1450
+
1451
+ const metadata = getNodeMetadata(nodeModel) || {};
1452
+ const provider = String(metadata.ImageGenerationProvider || metadata.imageGenerationProvider || '').trim();
1453
+ const operation = String(metadata.RequestedMediaOperation || metadata.requestedMediaOperation || '').trim().toLowerCase();
1454
+ return !!provider ||
1455
+ !!(metadata.GeneratedImageSourceWidth || metadata.generatedImageSourceWidth) ||
1456
+ operation === 'text2image' ||
1457
+ operation === 'image2image' ||
1458
+ operation === 'inpaint';
1459
+ }
1460
+
1461
+ function isCss3dRendererEnabledForModule(module) {
1462
+ return module?.renderDebugFlags?.enableCss3d !== false;
1463
+ }
1464
+
1465
+ function syncCss3dMediaFrameForEntry(module, nodeId, entry, options = {}) {
1466
+ if (!entry?.model || !entry?.cssObject || !isMediaNodeModel(entry.model)) {
1467
+ return false;
1468
+ }
1469
+
1470
+ if (window.MindMapCss3DManager?.syncCss3dMediaNodeFrame) {
1471
+ return window.MindMapCss3DManager.syncCss3dMediaNodeFrame(entry.model, entry.cssObject, options) === true;
1472
+ }
1473
+
1474
+ const width = Math.max(1, Math.round(Number(entry.model.width ?? entry.model.Width ?? entry.cssObject.userData?.worldWidth ?? 1)));
1475
+ const height = Math.max(1, Math.round(Number(entry.model.height ?? entry.model.Height ?? entry.cssObject.userData?.worldHeight ?? 1)));
1476
+ const resolutionScale = Math.max(1, Number(entry.cssObject.userData?.resolutionScale || 1));
1477
+ const wrapper = entry.cssObject.element;
1478
+ const inner = wrapper?.firstElementChild;
1479
+ if (wrapper?.style) {
1480
+ wrapper.style.width = `${width * resolutionScale}px`;
1481
+ wrapper.style.height = `${height * resolutionScale}px`;
1482
+ wrapper.style.minWidth = `${width * resolutionScale}px`;
1483
+ wrapper.style.minHeight = `${height * resolutionScale}px`;
1484
+ wrapper.style.maxWidth = `${width * resolutionScale}px`;
1485
+ wrapper.style.maxHeight = `${height * resolutionScale}px`;
1486
+ wrapper.style.overflow = 'hidden';
1487
+ }
1488
+ if (inner?.style) {
1489
+ inner.style.width = `${width}px`;
1490
+ inner.style.height = `${height}px`;
1491
+ inner.style.minWidth = `${width}px`;
1492
+ inner.style.minHeight = `${height}px`;
1493
+ inner.style.maxWidth = `${width}px`;
1494
+ inner.style.maxHeight = `${height}px`;
1495
+ inner.style.transform = `scale(${resolutionScale})`;
1496
+ inner.style.transformOrigin = '0 0';
1497
+ inner.style.overflow = 'hidden';
1498
+ }
1499
+ if (module && nodeId) {
1500
+ updateNodeSpatialGrid(module, nodeId);
1501
+ }
1502
+ return true;
1503
+ }
1504
+
1505
+ function setNodeResponseValue(nodeModel, value) {
1506
+ if (!nodeModel) {
1507
+ return value == null ? '' : String(value);
1508
+ }
1509
+
1510
+ const normalized = value == null ? '' : String(value);
1511
+ nodeModel.response = normalized;
1512
+ nodeModel.Response = normalized;
1513
+ return normalized;
1514
+ }
1515
+
1516
+ function applyCanonicalNodeResponse(nodeModel, nextContent) {
1517
+ const contentTypeLower = getNodeContentTypeLower(nodeModel);
1518
+ if (contentTypeLower !== 'image') {
1519
+ return setNodeResponseValue(nodeModel, nextContent);
1520
+ }
1521
+
1522
+ const metadata = ensureNodeMetadataObject(nodeModel);
1523
+ const normalizedContent = normalizeImageAssetUrl(nextContent);
1524
+ const thumbnailUrl = normalizeImageAssetUrl(metadata?.ThumbnailUrl || metadata?.thumbnailUrl || '');
1525
+ const isAnimatedGif = isAnimatedGifImageNodeModel(nodeModel);
1526
+
1527
+ if (isAnimatedGif) {
1528
+ const originalReference = getAnimatedGifOriginalReference(nodeModel, normalizedContent);
1529
+ if (originalReference) {
1530
+ setNodeImageOriginalReference(nodeModel, originalReference);
1531
+ return setNodeResponseValue(nodeModel, originalReference);
1532
+ }
1533
+ }
1534
+
1535
+ if (normalizedContent && !isThumbnailImageUrl(normalizedContent)) {
1536
+ setNodeImageOriginalReference(nodeModel, normalizedContent);
1537
+ }
1538
+
1539
+ if (thumbnailUrl && !isAnimatedGif) {
1540
+ setNodeImagePreviewUrl(nodeModel, thumbnailUrl);
1541
+ return normalizeImageAssetUrl(nodeModel.response ?? nodeModel.Response ?? '');
1542
+ }
1543
+
1544
+ if (normalizedContent) {
1545
+ if (isThumbnailImageUrl(normalizedContent)) {
1546
+ setNodeImagePreviewUrl(nodeModel, normalizedContent);
1547
+ return normalizeImageAssetUrl(nodeModel.response ?? nodeModel.Response ?? '');
1548
+ }
1549
+
1550
+ return setNodeResponseValue(nodeModel, normalizedContent);
1551
+ }
1552
+
1553
+ return setNodeResponseValue(nodeModel, '');
1554
+ }
1555
+
1556
+ function setNodeImageOriginalUrl(nodeModel, url) {
1557
+ setNodeImageOriginalReference(nodeModel, url);
1558
+ }
1559
+
1560
+ function setNodeImagePreviewUrl(nodeModel, url) {
1561
+ const normalized = normalizeImageAssetUrl(url);
1562
+ if (!normalized || !nodeModel) return;
1563
+
1564
+ setNodeResponseValue(nodeModel, normalized);
1565
+
1566
+ const metadata = ensureNodeMetadataObject(nodeModel);
1567
+ if (isThumbnailImageUrl(normalized)) {
1568
+ metadata.ThumbnailUrl = normalized;
1569
+ metadata.thumbnailUrl = normalized;
1570
+ }
1571
+ }
1572
+
1573
+ function ensureNodeImageUsesPreviewResponse(nodeModel) {
1574
+ if (!nodeModel) return false;
1575
+ const previousResponse = normalizeImageAssetUrl(nodeModel.response ?? nodeModel.Response ?? '');
1576
+ const nextResponse = applyCanonicalNodeResponse(nodeModel, previousResponse);
1577
+ return previousResponse !== nextResponse;
1578
+ }
1579
+
1580
+ function isDirectImageUrl(url) {
1581
+ const normalized = normalizeImageAssetUrl(url).toLowerCase();
1582
+ return normalized.startsWith('http://')
1583
+ || normalized.startsWith('https://')
1584
+ || normalized.startsWith('blob:')
1585
+ || normalized.startsWith('data:')
1586
+ || normalized.startsWith('file:');
1587
+ }
1588
+
1589
+ function setNodeImageOriginalReference(nodeModel, url) {
1590
+ const normalized = normalizeImageAssetUrl(url);
1591
+ if (!normalized || !nodeModel) return;
1592
+
1593
+ const metadata = ensureNodeMetadataObject(nodeModel);
1594
+ if (isDirectImageUrl(normalized)) {
1595
+ metadata.OriginalUrl = normalized;
1596
+ metadata.originalUrl = normalized;
1597
+ } else {
1598
+ metadata.OriginalPath = normalized;
1599
+ }
1600
+ }
1601
+
1602
+ function canUseAsFullResImageUrl(url) {
1603
+ const normalized = normalizeImageAssetUrl(url);
1604
+ return !!normalized && !isThumbnailImageUrl(normalized);
1605
+ }
1606
+
1607
+ function markNodeImageAssetMissing(nodeModel, missingUrl) {
1608
+ if (!nodeModel) return;
1609
+
1610
+ const metadata = ensureNodeMetadataObject(nodeModel);
1611
+ const normalizedMissingUrl = normalizeImageAssetUrl(missingUrl);
1612
+ const responseUrl = normalizeImageAssetUrl(nodeModel.response ?? nodeModel.Response ?? '');
1613
+ const originalPath = normalizeImageAssetUrl(metadata.OriginalPath || '');
1614
+ const originalUrl = normalizeImageAssetUrl(metadata.OriginalUrl || metadata.originalUrl || '');
1615
+ const recoverableFullResUrl = canUseAsFullResImageUrl(originalPath)
1616
+ ? originalPath
1617
+ : (canUseAsFullResImageUrl(originalUrl)
1618
+ ? originalUrl
1619
+ : (canUseAsFullResImageUrl(responseUrl) ? responseUrl : ''));
1620
+
1621
+ if (isThumbnailImageUrl(normalizedMissingUrl)) {
1622
+ delete metadata.ThumbnailUrl;
1623
+ delete metadata.thumbnailUrl;
1624
+ }
1625
+
1626
+ if (normalizedMissingUrl) {
1627
+ metadata.MissingAssetUrl = normalizedMissingUrl;
1628
+ }
1629
+
1630
+ if (recoverableFullResUrl) {
1631
+ if (!responseUrl || isThumbnailImageUrl(responseUrl)) {
1632
+ nodeModel.response = recoverableFullResUrl;
1633
+ nodeModel.Response = recoverableFullResUrl;
1634
+ }
1635
+
1636
+ setNodeImageOriginalReference(nodeModel, recoverableFullResUrl);
1637
+ delete metadata.ImageGenerationError;
1638
+ return;
1639
+ }
1640
+
1641
+ if (normalizedMissingUrl) {
1642
+ delete metadata.ImageGenerationError;
1643
+ return;
1644
+ }
1645
+
1646
+ metadata.ImageGenerationError = 'Image failed';
1647
+ }
1648
+
1649
+ function getNodeImageAssetUrls(nodeModel) {
1650
+ const metadata = getNodeMetadata(nodeModel) || {};
1651
+ const contentTypeLower = getNodeContentTypeLower(nodeModel);
1652
+ const isImageContent = contentTypeLower === 'image';
1653
+ const responseUrl = normalizeImageAssetUrl(nodeModel?.response ?? nodeModel?.Response ?? '');
1654
+ const originalPath = normalizeImageAssetUrl(metadata?.OriginalPath || '');
1655
+ const thumbnailUrl = normalizeImageAssetUrl(metadata?.ThumbnailUrl || metadata?.thumbnailUrl || '');
1656
+ const originalUrl = normalizeImageAssetUrl(metadata?.OriginalUrl || metadata?.originalUrl || '');
1657
+ const snapshotUrl = normalizeImageAssetUrl(metadata?.SnapshotUrl || metadata?.snapshotUrl || '');
1658
+ const previewImageUrl = normalizeImageAssetUrl(
1659
+ metadata?.PreviewUrl ||
1660
+ metadata?.previewUrl ||
1661
+ metadata?.ImageUrl ||
1662
+ metadata?.imageUrl ||
1663
+ ''
1664
+ );
1665
+ const missingAssetUrl = normalizeImageAssetUrl(metadata?.MissingAssetUrl || '');
1666
+ const isGeneratedImageNode = isGeneratedImageNodeModel(nodeModel);
1667
+ const isAnimatedGif = isAnimatedGifImageNodeModel(nodeModel);
1668
+ const responseFullResUrl = isImageContent && canUseAsFullResImageUrl(responseUrl) ? responseUrl : '';
1669
+ const missingFullResUrl = canUseAsFullResImageUrl(missingAssetUrl) ? missingAssetUrl : '';
1670
+ const mediaPreviewUrl = isAnimatedGif ? '' : (thumbnailUrl || snapshotUrl || previewImageUrl);
1671
+ const responsePreviewUrl = isImageContent && !isAnimatedGif ? responseUrl : '';
1672
+ const fullResUrl = isImageContent
1673
+ ? (isGeneratedImageNode
1674
+ ? (originalUrl || responseFullResUrl || originalPath || missingFullResUrl)
1675
+ : (originalUrl || originalPath || responseFullResUrl || missingFullResUrl))
1676
+ : (originalUrl || originalPath || snapshotUrl || previewImageUrl || thumbnailUrl || missingFullResUrl);
1677
+ const previewUrl = isAnimatedGif
1678
+ ? (fullResUrl || responseUrl || originalPath || originalUrl || missingAssetUrl)
1679
+ : (mediaPreviewUrl || responsePreviewUrl || originalPath || originalUrl || missingAssetUrl);
1680
+
1681
+ return {
1682
+ responseUrl,
1683
+ originalPath,
1684
+ thumbnailUrl,
1685
+ originalUrl,
1686
+ snapshotUrl,
1687
+ previewImageUrl,
1688
+ missingAssetUrl,
1689
+ previewUrl,
1690
+ fullResUrl,
1691
+ animatedGif: isAnimatedGif
1692
+ };
1693
+ }
1694
+
1695
+ function hasNodeImagePreviewSource(assetUrls) {
1696
+ if (!assetUrls || typeof assetUrls !== 'object') return false;
1697
+ if (assetUrls.animatedGif === true) return false;
1698
+ return !!(
1699
+ assetUrls.thumbnailUrl ||
1700
+ (assetUrls.responseUrl && isThumbnailImageUrl(assetUrls.responseUrl))
1701
+ );
1702
+ }
1703
+
1704
+ function prewarmImageNodePreviewCache(nodeModel, module) {
1705
+ if (!nodeModel || getNodeContentTypeLower(nodeModel) !== 'image') {
1706
+ return;
1707
+ }
1708
+
1709
+ const textureFactory = window.MindMapTextureFactory;
1710
+ const activeImageCache = window.MindMapNodes?.imageCache || imageCache;
1711
+ if (!textureFactory?.ensureImageCached || !activeImageCache) {
1712
+ return;
1713
+ }
1714
+
1715
+ const nodeId = String(nodeModel.id ?? nodeModel.Id ?? '').trim();
1716
+ if (!nodeId) {
1717
+ return;
1718
+ }
1719
+
1720
+ const assetUrls = getNodeImageAssetUrls(nodeModel);
1721
+ if (!assetUrls.previewUrl) {
1722
+ return;
1723
+ }
1724
+ if (assetUrls.animatedGif === true) {
1725
+ return;
1726
+ }
1727
+
1728
+ const warmKey = `${assetUrls.previewUrl}||${assetUrls.fullResUrl || assetUrls.originalUrl || ''}`;
1729
+ if (nodeModel._previewWarmKey === warmKey) {
1730
+ return;
1731
+ }
1732
+
1733
+ nodeModel._previewWarmKey = warmKey;
1734
+ if (module) {
1735
+ module._boardLoadImagePreviewWarmQueued = Number(module._boardLoadImagePreviewWarmQueued || 0) + 1;
1736
+ }
1737
+
1738
+ Promise.resolve(
1739
+ textureFactory.ensureImageCached(nodeId, assetUrls.previewUrl, activeImageCache, {
1740
+ authToken: module?.authToken || window.mindMap?.authToken || '',
1741
+ resizeWidth: 256,
1742
+ preferOriginal: false,
1743
+ allowOriginalFallback: false,
1744
+ allowOriginalReuseForPreview: false,
1745
+ originalUrl: assetUrls.fullResUrl || assetUrls.originalUrl || '',
1746
+ onMissingAsset: (missingUrl) => {
1747
+ const normalized = normalizeImageAssetUrl(missingUrl);
1748
+ if (!normalized) return;
1749
+ markNodeImageAssetMissing(nodeModel, normalized);
1750
+ try { window.dotNetHelper?.invokeMethodAsync('ReportMissingNodeAsset', nodeId, normalized); } catch { }
1751
+ },
1752
+ onRefreshedUrl: (refreshedUrl) => {
1753
+ const normalized = normalizeImageAssetUrl(refreshedUrl);
1754
+ if (!normalized) return;
1755
+
1756
+ setNodeImageOriginalUrl(nodeModel, normalized);
1757
+ if (!hasNodeImagePreviewSource(assetUrls)) {
1758
+ setNodeImagePreviewUrl(nodeModel, normalized);
1759
+ }
1760
+ }
1761
+ })
1762
+ ).then((cachedImage) => {
1763
+ if (!module) {
1764
+ return;
1765
+ }
1766
+
1767
+ if (cachedImage?.image && cachedImage?.isError !== true) {
1768
+ module._boardLoadImagePreviewWarmReady = Number(module._boardLoadImagePreviewWarmReady || 0) + 1;
1769
+ return;
1770
+ }
1771
+
1772
+ module._boardLoadImagePreviewWarmFailed = Number(module._boardLoadImagePreviewWarmFailed || 0) + 1;
1773
+ }).catch(() => {
1774
+ if (!module) {
1775
+ return;
1776
+ }
1777
+
1778
+ module._boardLoadImagePreviewWarmFailed = Number(module._boardLoadImagePreviewWarmFailed || 0) + 1;
1779
+ });
1780
+ }
1781
+
1782
+ function getImageTextureResizeWidth(nodeWidth, nodeHeight, preferOriginal, explicitResizeWidth) {
1783
+ const parsedExplicit = Number(explicitResizeWidth);
1784
+ if (Number.isFinite(parsedExplicit) && parsedExplicit > 0) {
1785
+ return Math.max(1, Math.round(parsedExplicit));
1786
+ }
1787
+
1788
+ const maxDimension = Math.max(1, Number(nodeWidth || 0), Number(nodeHeight || 0));
1789
+ const dpr = Math.min(2.5, Math.max(1, window.devicePixelRatio || 1));
1790
+ if (preferOriginal) {
1791
+ return Math.min(3072, Math.max(1024, Math.round(maxDimension * dpr * 2)));
1792
+ }
1793
+
1794
+ return Math.min(768, Math.max(512, Math.round(maxDimension * Math.min(1.5, dpr))));
1795
+ }
1796
+
1797
+ function createImageBodyTextureFromCache(cachedImage) {
1798
+ const image = cachedImage?.image || cachedImage;
1799
+ if (!image) return null;
1800
+
1801
+ const texture = new THREE.Texture(image);
1802
+ texture.userData ??= {};
1803
+ const requiresManualVFlip = cachedImage?.type === 'bitmap';
1804
+ texture.userData.mindMapManualVFlip = requiresManualVFlip;
1805
+ if (requiresManualVFlip) {
1806
+ // ImageBitmap uploads do not honor Texture.flipY consistently, so
1807
+ // NEAR/NORMAL body meshes compensate in UV space instead.
1808
+ texture.flipY = false;
1809
+ }
1810
+ texture.colorSpace = THREE.SRGBColorSpace;
1811
+ texture.wrapS = THREE.ClampToEdgeWrapping;
1812
+ texture.wrapT = THREE.ClampToEdgeWrapping;
1813
+ texture.minFilter = THREE.LinearMipmapLinearFilter;
1814
+ texture.magFilter = THREE.LinearFilter;
1815
+ texture.generateMipmaps = true;
1816
+ texture.needsUpdate = true;
1817
+ return texture;
1818
+ }
1819
+
1820
+ function syncImageNodeBodyTexture(bodyMesh, nodeModel, options = {}) {
1821
+ if (!bodyMesh || !nodeModel) {
1822
+ return;
1823
+ }
1824
+
1825
+ const material = bodyMesh.material;
1826
+ if (!material) {
1827
+ return;
1828
+ }
1829
+
1830
+ const isGeneratedImageNode = isGeneratedImageNodeModel(nodeModel);
1831
+ if (options.preferOriginal !== true && !isGeneratedImageNode) {
1832
+ ensureNodeImageUsesPreviewResponse(nodeModel);
1833
+ }
1834
+
1835
+ const width = Number(bodyMesh.geometry?.parameters?.width || nodeModel.width || 300);
1836
+ const height = Number(bodyMesh.geometry?.parameters?.height || nodeModel.height || 200);
1837
+ const preferOriginal = options.preferOriginal === true || isGeneratedImageNode;
1838
+ const assetUrls = getNodeImageAssetUrls(nodeModel);
1839
+ bodyMesh.userData ??= {};
1840
+ const now = performance.now();
1841
+ const pendingImageRenderKey = String(bodyMesh.userData.pendingImageRenderKey || '');
1842
+ const resolvedImageRenderKey = String(bodyMesh.userData.resolvedImageRenderKey || '');
1843
+ const fullImageRetryAfter = Number(bodyMesh.userData.fullImageRetryAfter || 0);
1844
+ const failedImageRenderKey = String(bodyMesh.userData.failedImageRenderKey || '');
1845
+ const failedImageRetryAfter = Number(bodyMesh.userData.failedImageRetryAfter || 0);
1846
+ const hasDistinctFullResUrl =
1847
+ !!assetUrls.fullResUrl &&
1848
+ assetUrls.fullResUrl !== assetUrls.previewUrl;
1849
+ const shouldHoldPreview =
1850
+ preferOriginal &&
1851
+ hasDistinctFullResUrl &&
1852
+ (
1853
+ fullImageRetryAfter > now ||
1854
+ pendingImageRenderKey.startsWith('image:preview:') ||
1855
+ (!resolvedImageRenderKey.startsWith('image:preview:') &&
1856
+ !resolvedImageRenderKey.startsWith('image:full:'))
1857
+ );
1858
+ const useOriginalForThisPass = preferOriginal && !shouldHoldPreview;
1859
+ const imageUrl = useOriginalForThisPass
1860
+ ? (assetUrls.fullResUrl || assetUrls.previewUrl)
1861
+ : assetUrls.previewUrl;
1862
+ const resizeWidth = getImageTextureResizeWidth(width, height, useOriginalForThisPass, options.resizeWidth);
1863
+ const isLoading = !!(nodeModel.isLoading ?? nodeModel.IsLoading ?? false);
1864
+ const metadata = getNodeMetadata(nodeModel) || {};
1865
+ const nodeId = String(nodeModel.id ?? nodeModel.Id ?? '').trim();
1866
+ const imageError = String(metadata?.ImageGenerationError || '').trim();
1867
+ const renderKey = imageUrl
1868
+ ? `image:${useOriginalForThisPass ? 'full' : 'preview'}:${resizeWidth}:${imageUrl}`
1869
+ : `placeholder:${isLoading ? 'loading' : (imageError ? 'error' : 'empty')}:${Math.round(width)}x${Math.round(height)}`;
1870
+ if (failedImageRenderKey === renderKey && failedImageRetryAfter > now) {
1871
+ if (imageUrl) {
1872
+ applyImageCoverUv(bodyMesh, width, height);
1873
+ }
1874
+ return;
1875
+ }
1876
+ if (bodyMesh.userData.imageRenderKey === renderKey) {
1877
+ if (imageUrl) {
1878
+ applyImageCoverUv(bodyMesh, width, height);
1879
+ }
1880
+ return;
1881
+ }
1882
+
1883
+ bodyMesh.userData.imageRenderKey = renderKey;
1884
+
1885
+ const preserveResolvedPreviewTexture = (retryAfter = 0) => {
1886
+ bodyMesh.userData.pendingImageUrl = '';
1887
+ bodyMesh.userData.pendingImageRenderKey = '';
1888
+ bodyMesh.userData.imageRenderKey = resolvedImageRenderKey;
1889
+ bodyMesh.userData.failedImageRenderKey = renderKey;
1890
+ const retryAt = Number(retryAfter || 0);
1891
+ bodyMesh.userData.failedImageRetryAfter = retryAt > performance.now()
1892
+ ? retryAt
1893
+ : performance.now() + 1500;
1894
+ bodyMesh.userData.fullImageRetryAfter = bodyMesh.userData.failedImageRetryAfter;
1895
+ };
1896
+
1897
+ const applyImageFallbackTexture = (retryAfter = 0) => {
1898
+ bodyMesh.userData.pendingImageUrl = '';
1899
+ bodyMesh.userData.pendingImageRenderKey = '';
1900
+ bodyMesh.userData.resolvedImageRenderKey = '';
1901
+ bodyMesh.userData.failedImageRenderKey = renderKey;
1902
+ const retryAt = Number(retryAfter || 0);
1903
+ bodyMesh.userData.failedImageRetryAfter = retryAt > performance.now()
1904
+ ? retryAt
1905
+ : performance.now() + 1500;
1906
+ bodyMesh.userData.fullImageRetryAfter = bodyMesh.userData.failedImageRetryAfter;
1907
+ const fallbackTexture = createImageStatusTexture(
1908
+ {
1909
+ ...nodeModel,
1910
+ isLoading: false,
1911
+ metadata: {
1912
+ ...(nodeModel.metadata ?? nodeModel.Metadata ?? {}),
1913
+ ImageGenerationError: imageError || 'Image failed'
1914
+ }
1915
+ },
1916
+ width,
1917
+ height);
1918
+
1919
+ if (fallbackTexture) {
1920
+ swapImageBodyTexture(bodyMesh, fallbackTexture);
1921
+ }
1922
+ };
1923
+
1924
+ if (!imageUrl) {
1925
+ bodyMesh.userData.pendingImageUrl = '';
1926
+ bodyMesh.userData.pendingImageRenderKey = '';
1927
+ bodyMesh.userData.resolvedImageRenderKey = '';
1928
+ bodyMesh.userData.fullImageRetryAfter = 0;
1929
+ bodyMesh.userData.failedImageRenderKey = '';
1930
+ bodyMesh.userData.failedImageRetryAfter = 0;
1931
+ clearImageBodyTexture(bodyMesh, 0xf8fafc);
1932
+ return;
1933
+ }
1934
+
1935
+ const textureFactory = window.MindMapTextureFactory;
1936
+ if (!textureFactory?.ensureImageCached) {
1937
+ applyImageFallbackTexture();
1938
+ return;
1939
+ }
1940
+
1941
+ const stickyFailedImage = getFailedImageUrlEntry(imageUrl);
1942
+ if (stickyFailedImage) {
1943
+ if (useOriginalForThisPass && resolvedImageRenderKey.startsWith('image:preview:')) {
1944
+ preserveResolvedPreviewTexture(stickyFailedImage.retryAfter);
1945
+ applyImageCoverUv(bodyMesh, width, height);
1946
+ return;
1947
+ }
1948
+
1949
+ applyImageFallbackTexture(stickyFailedImage.retryAfter);
1950
+ return;
1951
+ }
1952
+
1953
+ const activeImageCache = window.MindMapNodes?.imageCache || imageCache;
1954
+
1955
+ bodyMesh.userData.pendingImageUrl = imageUrl;
1956
+ bodyMesh.userData.pendingImageRenderKey = renderKey;
1957
+
1958
+ textureFactory.ensureImageCached(nodeId, imageUrl, activeImageCache, {
1959
+ authToken: options.authToken || window.mindMap?.authToken || '',
1960
+ resizeWidth,
1961
+ preferOriginal: useOriginalForThisPass,
1962
+ allowOriginalFallback: useOriginalForThisPass,
1963
+ allowOriginalReuseForPreview: useOriginalForThisPass,
1964
+ originalUrl: assetUrls.fullResUrl || assetUrls.originalUrl || '',
1965
+ onMissingAsset: (missingUrl) => {
1966
+ const normalized = normalizeImageAssetUrl(missingUrl);
1967
+ if (!normalized) return;
1968
+ markNodeImageAssetMissing(nodeModel, normalized);
1969
+ try { window.dotNetHelper?.invokeMethodAsync('ReportMissingNodeAsset', nodeId, normalized); } catch { }
1970
+ },
1971
+ onRefreshedUrl: (refreshedUrl) => {
1972
+ const normalized = normalizeImageAssetUrl(refreshedUrl);
1973
+ if (!normalized) return;
1974
+
1975
+ setNodeImageOriginalUrl(nodeModel, normalized);
1976
+ if (!hasNodeImagePreviewSource(assetUrls)) {
1977
+ setNodeImagePreviewUrl(nodeModel, normalized);
1978
+ }
1979
+ }
1980
+ })
1981
+ .then((cachedImage) => {
1982
+ if (bodyMesh.userData?.pendingImageRenderKey !== renderKey) {
1983
+ return;
1984
+ }
1985
+
1986
+ if (!cachedImage || cachedImage.isError === true || !cachedImage.image) {
1987
+ const retryAfter = rememberFailedImageUrl(
1988
+ cachedImage?.url || imageUrl,
1989
+ cachedImage?.errorStatus || 0,
1990
+ cachedImage?.retryAfter || 0
1991
+ );
1992
+ if (useOriginalForThisPass && resolvedImageRenderKey.startsWith('image:preview:')) {
1993
+ preserveResolvedPreviewTexture(retryAfter);
1994
+ return;
1995
+ }
1996
+ applyImageFallbackTexture(retryAfter);
1997
+ return;
1998
+ }
1999
+
2000
+ const nextTexture = createImageBodyTextureFromCache(cachedImage);
2001
+ if (!nextTexture) {
2002
+ const retryAfter = rememberFailedImageUrl(
2003
+ cachedImage?.url || imageUrl,
2004
+ cachedImage?.errorStatus || 0,
2005
+ cachedImage?.retryAfter || 0
2006
+ );
2007
+ if (useOriginalForThisPass && resolvedImageRenderKey.startsWith('image:preview:')) {
2008
+ preserveResolvedPreviewTexture(retryAfter);
2009
+ return;
2010
+ }
2011
+ applyImageFallbackTexture(retryAfter);
2012
+ return;
2013
+ }
2014
+
2015
+ bodyMesh.userData.assetRefreshAttemptedFor = '';
2016
+ bodyMesh.userData.pendingImageUrl = '';
2017
+ bodyMesh.userData.pendingImageRenderKey = '';
2018
+ bodyMesh.userData.resolvedImageRenderKey = renderKey;
2019
+ bodyMesh.userData.fullImageRetryAfter = 0;
2020
+ bodyMesh.userData.failedImageRenderKey = '';
2021
+ bodyMesh.userData.failedImageRetryAfter = 0;
2022
+ clearFailedImageUrl(cachedImage.url || imageUrl);
2023
+ swapImageBodyTexture(bodyMesh, nextTexture);
2024
+ applyImageCoverUv(bodyMesh, width, height);
2025
+ })
2026
+ .catch(() => {
2027
+ if (bodyMesh.userData?.pendingImageRenderKey !== renderKey) {
2028
+ return;
2029
+ }
2030
+ bodyMesh.userData.pendingImageUrl = '';
2031
+ bodyMesh.userData.pendingImageRenderKey = '';
2032
+
2033
+ if (useOriginalForThisPass && resolvedImageRenderKey.startsWith('image:preview:')) {
2034
+ preserveResolvedPreviewTexture(rememberFailedImageUrl(imageUrl, 0));
2035
+ return;
2036
+ }
2037
+
2038
+ applyImageFallbackTexture(rememberFailedImageUrl(imageUrl, 0));
2039
+ });
2040
+ }
2041
+
2042
+ async function addNode(module, nodeModel) {
2043
+ if (!nodeModel) return;
2044
+
2045
+ // Normalize model shape from both JS (camelCase) and C# interop (PascalCase).
2046
+ const normalizedContentType = nodeModel.contentType ?? nodeModel.ContentType ?? 'text';
2047
+ const fallbackWidth = normalizedContentType === 'video'
2048
+ ? 470
2049
+ : (normalizedContentType === 'image' ? 300 : (module.aiNodeDefaultWidth || 400));
2050
+ const fallbackHeight = normalizedContentType === 'video'
2051
+ ? 264
2052
+ : (normalizedContentType === 'image'
2053
+ ? 300
2054
+ : (normalizedContentType === 'pdf' ? 56 : (module.aiNodeDefaultHeight || 200)));
2055
+
2056
+ const parsedWidth = Number(nodeModel.width ?? nodeModel.Width ?? fallbackWidth);
2057
+ const parsedHeight = Number(nodeModel.height ?? nodeModel.Height ?? fallbackHeight);
2058
+
2059
+ nodeModel = {
2060
+ ...nodeModel,
2061
+ id: String(nodeModel.id ?? nodeModel.Id ?? ''),
2062
+ contentType: normalizedContentType,
2063
+ prompt: nodeModel.prompt ?? nodeModel.Prompt ?? '',
2064
+ response: nodeModel.response ?? nodeModel.Response ?? '',
2065
+ positionX: Number(nodeModel.positionX ?? nodeModel.PositionX ?? 0),
2066
+ positionY: Number(nodeModel.positionY ?? nodeModel.PositionY ?? 0),
2067
+ positionZ: Number(nodeModel.positionZ ?? nodeModel.PositionZ ?? 0),
2068
+ isLoading: !!(nodeModel.isLoading ?? nodeModel.IsLoading ?? false),
2069
+ width: (Number.isFinite(parsedWidth) && parsedWidth > 0) ? parsedWidth : fallbackWidth,
2070
+ height: (Number.isFinite(parsedHeight) && parsedHeight > 0) ? parsedHeight : fallbackHeight,
2071
+ isChunkedText: !!(nodeModel.isChunkedText ?? nodeModel.IsChunkedText ?? false),
2072
+ totalLineCount: Number(nodeModel.totalLineCount ?? nodeModel.TotalLineCount ?? 0),
2073
+ sourceFilePath: nodeModel.sourceFilePath ?? nodeModel.SourceFilePath ?? null,
2074
+ scrollOffset: Number(nodeModel.scrollOffset ?? nodeModel.ScrollOffset ?? 0),
2075
+ maxScroll: Number(nodeModel.maxScroll ?? nodeModel.MaxScroll ?? 0),
2076
+ isExpanded: !!(nodeModel.isExpanded ?? nodeModel.IsExpanded ?? false),
2077
+ originalHeight: Number(nodeModel.originalHeight ?? nodeModel.OriginalHeight ?? 0),
2078
+ metadata: nodeModel.metadata ?? nodeModel.Metadata ?? {}
2079
+ };
2080
+
2081
+ if (normalizedContentType === 'image') {
2082
+ ensureNodeImageUsesPreviewResponse(nodeModel);
2083
+ }
2084
+
2085
+ if (!nodeModel.id) {
2086
+ console.warn('[MindMapNodes] addNode skipped: missing node id');
2087
+ return;
2088
+ }
2089
+
2090
+ const nodeStartTime = performance.now();
2091
+ const timings = {}; // 세부 시간 측정용
2092
+ log(`[addNode] ENTER - Node ID: ${nodeModel.id}, Type: ${nodeModel.contentType}`);
2093
+
2094
+ // ▼▼▼ [Fix] Capture active board ID to detect stale async work after tab switch ▼▼▼
2095
+ const startBoardId = window.mindMap?.activeBoardId || null;
2096
+ const _isBoardStale = () => {
2097
+ const current = window.mindMap?.activeBoardId;
2098
+ return current != null && startBoardId != null && current !== startBoardId;
2099
+ };
2100
+ // ▲▲▲ [Fix] ▲▲▲
2101
+
2102
+ // ▼▼▼ [New] Ensure scrollbar styles are injected ▼▼▼
2103
+ injectScrollbarStyles();
2104
+ // ▲▲▲ [New] ▲▲▲
2105
+
2106
+ // ★ [Add] Safeguard: abort if factory is not loaded yet (avoid crash)
2107
+ // ★ [Add] Safeguard: abort if factory is not loaded yet (avoid crash)
2108
+ // ★ [수정] 텍스처가 필요 없는 CSS3D 타입(text, note, code, markdown)은 팩토리가 없어도 통과시킵니다.
2109
+ const isCss3dType = nodeModel.contentType === 'text' || nodeModel.contentType === 'note' || nodeModel.contentType === 'memo' || nodeModel.contentType === 'code' || nodeModel.contentType === 'markdown' || nodeModel.contentType === 'embed';
2110
+
2111
+ if (!isCss3dType && typeof window.MindMapTextureFactory === 'undefined') {
2112
+ console.error(`[MindMapNodes] Critical: MindMapTextureFactory is missing! Cannot add node ${nodeModel.id}. Retrying in 100ms...`);
2113
+ // Retry after 100ms (addresses a load race condition)
2114
+ setTimeout(() => {
2115
+ if (_isBoardStale()) { log(`[addNode] Board switched, aborting retry for ${nodeModel.id}`); return; }
2116
+ addNode(module, nodeModel);
2117
+ }, 100);
2118
+ return;
2119
+ }
2120
+
2121
+ if (module.nodeObjectsById.has(nodeModel.id)) {
2122
+ log(`[MindMapNodes] Node already exists: ${nodeModel.id}. Skipping (expected during board reload).`);
2123
+ const existingEntry = module.nodeObjectsById.get(nodeModel.id);
2124
+ if (existingEntry) {
2125
+ if (nodeModel.contentType === 'image') {
2126
+ const imageAssetUrls = getNodeImageAssetUrls(nodeModel);
2127
+ if (imageAssetUrls.previewUrl) {
2128
+ await window.MindMapTextureFactory.ensureImageCached(nodeModel.id, imageAssetUrls.previewUrl, imageCache, {
2129
+ authToken: module.authToken,
2130
+ allowOriginalFallback: false,
2131
+ allowOriginalReuseForPreview: false,
2132
+ originalUrl: imageAssetUrls.fullResUrl || imageAssetUrls.originalUrl || '',
2133
+ onRefreshedUrl: (refreshedUrl) => {
2134
+ setNodeImageOriginalUrl(nodeModel, refreshedUrl);
2135
+ if (!hasNodeImagePreviewSource(imageAssetUrls)) {
2136
+ setNodeImagePreviewUrl(nodeModel, refreshedUrl);
2137
+ }
2138
+ if (existingEntry.model) {
2139
+ setNodeImageOriginalUrl(existingEntry.model, refreshedUrl);
2140
+ const existingAssetUrls = getNodeImageAssetUrls(existingEntry.model);
2141
+ if (!hasNodeImagePreviewSource(existingAssetUrls)) {
2142
+ setNodeImagePreviewUrl(existingEntry.model, refreshedUrl);
2143
+ }
2144
+ }
2145
+ }
2146
+ });
2147
+ }
2148
+ }
2149
+ if (existingEntry.glObject) {
2150
+ // [Fix] Force Z=0 to eliminate parallax
2151
+ existingEntry.glObject.position.set(nodeModel.positionX, nodeModel.positionY, 0);
2152
+ }
2153
+ if (existingEntry.cssObject) {
2154
+ // [Fix] Force Z=0 to eliminate parallax
2155
+ const cssParentScene = module.cssScene || module.scene;
2156
+ if (cssParentScene && existingEntry.cssObject.parent !== cssParentScene) {
2157
+ cssParentScene.add(existingEntry.cssObject);
2158
+ window.MindMapCss3DManager?.markCssParentRepairNeeded?.(module, nodeModel.id);
2159
+ }
2160
+ existingEntry.cssObject.position.set(nodeModel.positionX, nodeModel.positionY, 0);
2161
+ existingEntry.cssObject.updateMatrixWorld(true);
2162
+ window.MindMapCss3DManager?.markNodeTransformDirty?.(module, nodeModel.id);
2163
+ }
2164
+ }
2165
+ return;
2166
+ }
2167
+
2168
+ timings.t1_init = performance.now();
2169
+ log(`[addNode] Step 1: Preparing position for ${nodeModel.id}`);
2170
+ let autoPositioned = false;
2171
+ const isNewNode = nodeModel.metadata && nodeModel.metadata['IsNew'] === 'true';
2172
+
2173
+ if (isNewNode && (nodeModel.contentType === 'note' || nodeModel.contentType === 'text' || nodeModel.contentType === 'memo' || nodeModel.contentType === 'image' || nodeModel.contentType === 'video' || nodeModel.contentType === 'embed') && nodeModel.positionX === 0 && nodeModel.positionY === 0) {
2174
+ if (module.cursorPosition) {
2175
+ nodeModel.positionX = module.cursorPosition.x;
2176
+ nodeModel.positionY = module.cursorPosition.y;
2177
+ autoPositioned = true;
2178
+ } else {
2179
+ const targetPos = new THREE.Vector3(0, 0, 0);
2180
+ module.raycaster.setFromCamera({ x: 0, y: 0.4 }, module.camera);
2181
+ module.raycaster.ray.intersectPlane(module.plane, targetPos);
2182
+ nodeModel.positionX = module.snapToGrid(targetPos.x - (nodeModel.width || 400) / 2);
2183
+ nodeModel.positionY = module.snapToGridY(targetPos.y + (nodeModel.height || 200) / 2);
2184
+ autoPositioned = true;
2185
+ }
2186
+ }
2187
+
2188
+ const nodeZ = 0;
2189
+
2190
+ const MAX_SIZE = 4096; // hard limit
2191
+
2192
+ let width = (nodeModel.width ?? nodeModel.Width) || (nodeModel.contentType === 'embed' ? 460 : (nodeModel.contentType === 'video' ? 470 : (nodeModel.contentType === 'image' ? 300 : (module.aiNodeDefaultWidth || 400))));
2193
+ let height = (nodeModel.height ?? nodeModel.Height) || (nodeModel.contentType === 'embed' ? 260 : (nodeModel.contentType === 'video' ? 264 : (nodeModel.contentType === 'image' ? 300 : (nodeModel.contentType === 'pdf' ? 56 : (module.aiNodeDefaultHeight || 200)))));
2194
+
2195
+ // ▼▼▼ [Fix] Initial size cap ▼▼▼
2196
+ width = Math.min(width, MAX_SIZE);
2197
+ height = Math.min(height, MAX_SIZE);
2198
+ nodeModel.width = width;
2199
+ nodeModel.Width = width;
2200
+ nodeModel.height = height;
2201
+ nodeModel.Height = height;
2202
+ // ▲▲▲ [Fix] ▲▲▲
2203
+
2204
+ const zoomLevel = 2;
2205
+
2206
+ log(`[addNode] Step 2: Measuring height for ${nodeModel.id} (type: ${nodeModel.contentType})`);
2207
+ const isTextLikeNode = nodeModel.contentType === 'text' || nodeModel.contentType === 'note';
2208
+ const hasResponseText = !!(`${nodeModel.response ?? nodeModel.Response ?? ''}`.trim());
2209
+ const isLoadingWithoutContent = !!nodeModel.isLoading && !hasResponseText;
2210
+
2211
+ // Keep newly-created AI nodes at configured default height while loading.
2212
+ // This prevents one-line collapse before the first streamed tokens arrive.
2213
+ if (isTextLikeNode && isLoadingWithoutContent) {
2214
+ const minLoadingHeight = module.aiNodeDefaultHeight || 200;
2215
+ height = Math.max(height, minLoadingHeight);
2216
+ nodeModel.height = height;
2217
+ nodeModel.maxScroll = 0;
2218
+ nodeModel.scrollOffset = 0;
2219
+ } else if (isTextLikeNode) {
2220
+ try {
2221
+ // ▼▼▼ [Refactor] Chunk-loading node handling - fetch content directly from C# (no cache) ▼▼▼
2222
+ if (nodeModel.isChunkedText && nodeModel.totalLineCount > 0) {
2223
+ // For chunk-loading nodes, compute virtual scroll height from totalLineCount
2224
+ const LINE_HEIGHT = 21; // 14px font * 1.5 line-height
2225
+ const totalContentHeight = nodeModel.totalLineCount * LINE_HEIGHT;
2226
+ nodeModel.totalContentHeight = totalContentHeight;
2227
+
2228
+ // Compute virtual scroll height
2229
+ nodeModel.maxScroll = Math.max(0, totalContentHeight - height);
2230
+ nodeModel.scrollOffset = Math.min(nodeModel.scrollOffset || 0, nodeModel.maxScroll);
2231
+
2232
+ // Fetch content from C# for the current scroll position
2233
+ if (module.dotNetHelper) {
2234
+ // ▼▼▼ [Fix] Fast initial retries; self-heal in the background ▼▼▼
2235
+ const MAX_RETRIES = 2; // 5 → 2 (background retries also run)
2236
+ const RETRY_DELAY = 200; // 500 → 200ms
2237
+
2238
+ // ▼▼▼ [Fix] If it fails at the saved scroll position, retry at scrollOffset=0 ▼▼▼
2239
+ const savedScrollOffset = nodeModel.scrollOffset || 0;
2240
+ let loadAttemptScrollOffsets = [savedScrollOffset];
2241
+
2242
+ // If the saved scroll position is not 0, also try 0
2243
+ if (savedScrollOffset > 0) {
2244
+ loadAttemptScrollOffsets.push(0);
2245
+ }
2246
+
2247
+ for (const tryScrollOffset of loadAttemptScrollOffsets) {
2248
+ for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
2249
+ try {
2250
+ log(`[addNode] Chunked node ${nodeModel.id}: fetching content for scroll position ${tryScrollOffset}px (attempt ${attempt}/${MAX_RETRIES})...`);
2251
+ const result = await module.dotNetHelper.invokeMethodAsync(
2252
+ 'GetTextByScrollPosition',
2253
+ nodeModel.id,
2254
+ tryScrollOffset,
2255
+ height,
2256
+ LINE_HEIGHT
2257
+ );
2258
+
2259
+ if (result && result.content) {
2260
+ nodeModel.response = result.content;
2261
+ nodeModel._visibleContent = result.content;
2262
+ nodeModel._visibleScrollOffset = result.relativeScrollOffset;
2263
+
2264
+ // If loaded at 0, also set scroll position to 0
2265
+ if (tryScrollOffset === 0 && savedScrollOffset > 0) {
2266
+ nodeModel.scrollOffset = 0;
2267
+ log(`[addNode] Content loaded from scroll=0 (saved=${savedScrollOffset}px). Will scroll later after file is registered.`);
2268
+ }
2269
+
2270
+ log(`[addNode] Content loaded for ${nodeModel.id}: ${result.content.length} chars, startLine=${result.startLine}`);
2271
+ break;
2272
+ } else {
2273
+ if (attempt < MAX_RETRIES) {
2274
+ log(`[addNode] Result is null, retrying in ${RETRY_DELAY}ms...`);
2275
+ await new Promise(r => setTimeout(r, RETRY_DELAY));
2276
+ }
2277
+ }
2278
+ } catch (err) {
2279
+ console.warn(`[addNode] Failed to load content for ${nodeModel.id} (attempt ${attempt}):`, err);
2280
+ if (attempt < MAX_RETRIES) {
2281
+ await new Promise(r => setTimeout(r, RETRY_DELAY));
2282
+ }
2283
+ }
2284
+ }
2285
+
2286
+ // If content was loaded, exit loops
2287
+ if (nodeModel._visibleContent) break;
2288
+
2289
+ // Log before trying the next scroll offset
2290
+ if (tryScrollOffset !== 0) {
2291
+ log(`[addNode] Failed at scroll=${tryScrollOffset}px, trying scroll=0...`);
2292
+ }
2293
+ }
2294
+ // ▲▲▲ [Fix] ▲▲▲
2295
+ }
2296
+
2297
+ // If initial load fails, show a fallback message
2298
+ if (!nodeModel._visibleContent) {
2299
+ nodeModel._visibleContent = `Loading... (${nodeModel.totalLineCount} lines)`;
2300
+ nodeModel.response = nodeModel._visibleContent;
2301
+ log(`[addNode] ⚠️ Using fallback message for ${nodeModel.id}: "${nodeModel._visibleContent}"`);
2302
+
2303
+ // ▼▼▼ [New] Periodic retry logic - the node retries content loading by itself ▼▼▼
2304
+ const RETRY_INTERVAL = 2000; // retry every 2 seconds
2305
+ const MAX_BACKGROUND_RETRIES = 30; // max 30 attempts (~1 minute)
2306
+ let backgroundRetryCount = 0;
2307
+ const savedScrollOffset = nodeModel.scrollOffset || 0; // preserve saved scroll position
2308
+
2309
+ const retryLoadContent = async () => {
2310
+ // ▼▼▼ [Fix] Abort if board switched since addNode started ▼▼▼
2311
+ if (_isBoardStale()) {
2312
+ log(`[addNode] Board switched, aborting background retry for ${nodeModel.id}`);
2313
+ return;
2314
+ }
2315
+ // ▲▲▲ [Fix] ▲▲▲
2316
+ backgroundRetryCount++;
2317
+ log(`[addNode] 🔄 Background retry ${backgroundRetryCount}/${MAX_BACKGROUND_RETRIES} for ${nodeModel.id}...`);
2318
+
2319
+ try {
2320
+ // Start at 0 first to check whether the file is registered
2321
+ const result = await module.dotNetHelper.invokeMethodAsync(
2322
+ 'GetTextByScrollPosition',
2323
+ nodeModel.id,
2324
+ 0,
2325
+ height,
2326
+ LINE_HEIGHT
2327
+ );
2328
+ if (_isBoardStale()) {
2329
+ log(`[addNode] Board switched after background fetch, aborting retry for ${nodeModel.id}`);
2330
+ return;
2331
+ }
2332
+
2333
+ if (result && result.content) {
2334
+ log(`[addNode] ✅ Background load SUCCESS for ${nodeModel.id}: ${result.content.length} chars`);
2335
+
2336
+ // If a saved scroll position exists, jump to that position
2337
+ if (savedScrollOffset > 0) {
2338
+ log(`[addNode] 📍 Restoring saved scroll position: ${savedScrollOffset}px`);
2339
+ try {
2340
+ const savedResult = await module.dotNetHelper.invokeMethodAsync(
2341
+ 'GetTextByScrollPosition',
2342
+ nodeModel.id,
2343
+ savedScrollOffset,
2344
+ height,
2345
+ LINE_HEIGHT
2346
+ );
2347
+ if (_isBoardStale()) {
2348
+ log(`[addNode] Board switched after saved-position fetch, aborting retry for ${nodeModel.id}`);
2349
+ return;
2350
+ }
2351
+
2352
+ if (savedResult && savedResult.content) {
2353
+ nodeModel.response = savedResult.content;
2354
+ nodeModel._visibleContent = savedResult.content;
2355
+ nodeModel._visibleScrollOffset = savedResult.relativeScrollOffset;
2356
+ nodeModel.scrollOffset = savedScrollOffset;
2357
+ log(`[addNode] ✅ Restored to saved position: ${savedScrollOffset}px`);
2358
+ } else {
2359
+ // If restoring the saved position fails, fall back to scroll=0
2360
+ nodeModel.response = result.content;
2361
+ nodeModel._visibleContent = result.content;
2362
+ nodeModel._visibleScrollOffset = result.relativeScrollOffset;
2363
+ nodeModel.scrollOffset = 0;
2364
+ }
2365
+ } catch (err) {
2366
+ console.warn(`[addNode] Failed to restore saved position, using scroll=0:`, err);
2367
+ nodeModel.response = result.content;
2368
+ nodeModel._visibleContent = result.content;
2369
+ nodeModel._visibleScrollOffset = result.relativeScrollOffset;
2370
+ nodeModel.scrollOffset = 0;
2371
+ }
2372
+ } else {
2373
+ nodeModel.response = result.content;
2374
+ nodeModel._visibleContent = result.content;
2375
+ nodeModel._visibleScrollOffset = result.relativeScrollOffset;
2376
+ nodeModel.scrollOffset = 0;
2377
+ }
2378
+
2379
+ if (_isBoardStale()) {
2380
+ log(`[addNode] Board switched before applying retry result for ${nodeModel.id}`);
2381
+ return;
2382
+ }
2383
+
2384
+ // Update texture and re-render
2385
+ window.MindMapNodes.pendingNodeUpdates.set(nodeModel.id, {
2386
+ content: nodeModel._visibleContent,
2387
+ width: nodeModel.width,
2388
+ height: nodeModel.height,
2389
+ scrollDelta: 0
2390
+ });
2391
+ await window.MindMapPipeline.processRenderQueue(module, nodeModel.id);
2392
+ return; // stop retrying on success
2393
+ }
2394
+ } catch (err) {
2395
+ console.warn(`[addNode] Background retry failed for ${nodeModel.id}:`, err);
2396
+ }
2397
+
2398
+ // Check if max retry count is exceeded
2399
+ if (backgroundRetryCount < MAX_BACKGROUND_RETRIES) {
2400
+ setTimeout(retryLoadContent, RETRY_INTERVAL);
2401
+ } else {
2402
+ log(`[addNode] ⚠️ Giving up background retries for ${nodeModel.id} after ${MAX_BACKGROUND_RETRIES} attempts.`);
2403
+ }
2404
+ };
2405
+
2406
+ // Kick off the first background retry
2407
+ setTimeout(retryLoadContent, RETRY_INTERVAL);
2408
+ // ▲▲▲ [New] ▲▲▲
2409
+ }
2410
+
2411
+ log(`[MindMapNodes] Chunked text node ${nodeModel.id}: ${nodeModel.totalLineCount} lines, totalContentHeight=${totalContentHeight}, maxScroll=${nodeModel.maxScroll}, hasContent=${!!nodeModel._visibleContent}`);
2412
+ } else {
2413
+ // ▲▲▲ [Refactor] ▲▲▲
2414
+ // Normal text node
2415
+
2416
+ // ▼▼▼ [Optimization] Skip measurement if height was pre-calculated OR already set (loaded from saved board) ▼▼▼
2417
+ const existingHeight = nodeModel.height ?? nodeModel.Height ?? 0;
2418
+ const hasPreCalculatedHeight = nodeModel._heightPreCalculated && existingHeight > 0;
2419
+ const hasSavedHeight = existingHeight > 0 && !nodeModel._heightPreCalculated; // From saved board
2420
+
2421
+ if (hasPreCalculatedHeight) {
2422
+ log(`[addNode] Step 2.1: SKIPPED measureHtmlHeightAsync (pre-calculated height=${existingHeight}, maxScroll=${nodeModel.maxScroll})`);
2423
+ height = existingHeight;
2424
+ nodeModel.scrollOffset = Math.min(nodeModel.scrollOffset || 0, nodeModel.maxScroll || 0);
2425
+ } else if (hasSavedHeight) {
2426
+ // Height was saved from previous session - skip measurement
2427
+ log(`[addNode] Step 2.1: SKIPPED measureHtmlHeightAsync (saved height=${existingHeight})`);
2428
+ height = existingHeight;
2429
+ nodeModel.height = height;
2430
+ // maxScroll may not be set - calculate it now or leave as is
2431
+ if (nodeModel.maxScroll === undefined) {
2432
+ nodeModel.maxScroll = 0; // Will be recalculated if user scrolls
2433
+ }
2434
+ nodeModel.scrollOffset = Math.min(nodeModel.scrollOffset || 0, nodeModel.maxScroll || 0);
2435
+ } else {
2436
+ // If showFullAiResponse is true, do not constrain height
2437
+ const defaultMaxHeight = module.aiNodeDefaultHeight || 200;
2438
+ const effectiveMaxHeight = module.showFullAiResponse ? 10000 : defaultMaxHeight;
2439
+ // ▼▼▼ [Fix] Pass forcedHeight if height is already set (for loaded nodes) ▼▼▼
2440
+ const forcedHeight = (existingHeight > 0) ? (existingHeight * zoomLevel) : null;
2441
+ log(`[addNode] Step 2.1: Calling measureHtmlHeightAsync for ${nodeModel.id}... (forcedHeight=${forcedHeight ? forcedHeight / zoomLevel : 'auto'})`);
2442
+ const { height: autoHeight, maxScrollPx } = await window.MindMapTextureFactory.measureHtmlHeightAsync(nodeModel, zoomLevel, width * zoomLevel, forcedHeight, effectiveMaxHeight);
2443
+ if (_isBoardStale()) { log(`[addNode] Board switched after measureHeight, aborting ${nodeModel.id}`); return; }
2444
+ log(`[addNode] Step 2.2: measureHtmlHeightAsync completed for ${nodeModel.id}. autoHeight=${autoHeight}, maxScrollPx=${maxScrollPx}`);
2445
+ // ▲▲▲ [Fix] ▲▲▲
2446
+ if (!existingHeight || existingHeight <= 0) {
2447
+ height = autoHeight;
2448
+ nodeModel.height = height;
2449
+ } else {
2450
+ // Fixed height should still be capped
2451
+ height = Math.min(existingHeight, MAX_SIZE);
2452
+ nodeModel.height = height;
2453
+ }
2454
+ nodeModel.maxScroll = maxScrollPx / zoomLevel;
2455
+ nodeModel.scrollOffset = Math.min(nodeModel.scrollOffset || 0, nodeModel.maxScroll);
2456
+ log(`[addNode] Step 2.3: maxScroll set to ${nodeModel.maxScroll} for ${nodeModel.id}`);
2457
+ }
2458
+ // ▲▲▲ [Optimization] ▲▲▲
2459
+ }
2460
+ } catch (heightError) {
2461
+ console.error(`🔧 [MindMapNodes] Failed to auto-measure height for ${nodeModel.id}, using defaults.`, heightError);
2462
+ }
2463
+ }
2464
+
2465
+ nodeModel.width = width;
2466
+ nodeModel.height = height;
2467
+
2468
+ const shouldUseDeferredShell = shouldCreateDeferredShellNode(module, nodeModel);
2469
+
2470
+ timings.t2_afterMeasure = performance.now();
2471
+ log(`[addNode] Step 3: Generating textures for ${nodeModel.id}`);
2472
+ // ▼▼▼ [Key Fix] For text/note/code, generate hi-res textures immediately when response exists ▼▼▼
2473
+ // ▼▼▼ [Fix] Chunk nodes prefer _visibleContent when available ▼▼▼
2474
+ const contentForTexture = nodeModel.isChunkedText
2475
+ ? (nodeModel._visibleContent || nodeModel.response)
2476
+ : nodeModel.response;
2477
+
2478
+ let textures;
2479
+
2480
+ const isCodeNode = nodeModel.contentType === 'code';
2481
+
2482
+ // ▼▼▼ [Lazy Loading] Always start with placeholder. TextLOD will upgrade visible nodes sequentially.
2483
+ // This prevents main thread freeze during bulk load.
2484
+ // ▼▼▼ [Optimization] Skip expensive WebGL texture generation for CSS3D nodes ▼▼▼
2485
+ // Nodes that primarily use CSS3D (Text, Note, Code, Markdown) only need a lightweight placeholder
2486
+ // for the initial WebGL plane (used for Raycasting and far-distance LOD).
2487
+ // Actual content rendering happens via CSS3D or deferred TextureWorker.
2488
+
2489
+ let glObject;
2490
+ if (shouldUseDeferredShell) {
2491
+ timings.t3_afterTextures = performance.now();
2492
+ timings.t4_beforeWebGL = timings.t3_afterTextures;
2493
+ log(`[addNode] Step 5: Creating deferred shell for ${nodeModel.id}`);
2494
+ glObject = window.MindMapObjectManager?.createDeferredNodeShell?.(
2495
+ nodeModel,
2496
+ width,
2497
+ height,
2498
+ nodeZ,
2499
+ module.nodeZCounter++
2500
+ );
2501
+ } else {
2502
+ log(`[addNode] Step 3.1: Generating placeholder texture for ${nodeModel.id}...`);
2503
+ const result = await window.MindMapTextureFactory.generatePlaceholderTextures(module, nodeModel, width, height);
2504
+ if (_isBoardStale()) { log(`[addNode] Board switched after texture gen, aborting ${nodeModel.id}`); return; }
2505
+ textures = result.textures;
2506
+ // ▲▲▲ [Optimization] ▲▲▲
2507
+ // ▲▲▲ [Key Fix] ▲▲▲
2508
+
2509
+ timings.t3_afterTextures = performance.now();
2510
+ log(`[addNode] Step 4: Caching textures for ${nodeModel.id}`);
2511
+ const previous = textureCache.get(nodeModel.id);
2512
+
2513
+ // ★ 이전 텍스처 메모리 감산
2514
+ if (previous) {
2515
+ const prevMemory = calculateTextureMemory(previous, nodeModel.id);
2516
+ totalTextureMemoryBytes -= prevMemory;
2517
+ try { if (!previous.default.body?._isShared) previous.default.body?.dispose(); } catch { }
2518
+ try { if (!previous.selected.body?._isShared) previous.selected.body?.dispose(); } catch { }
2519
+ try { previous.glow?.texture?.dispose(); } catch { }
2520
+ }
2521
+
2522
+ // ★ 새 텍스처 메모리 가산 및 로그
2523
+ textureCache.set(nodeModel.id, textures);
2524
+ const newMemory = calculateTextureMemory(textures, nodeModel.id);
2525
+ logTextureMemory('ADD', nodeModel.id, newMemory);
2526
+
2527
+ timings.t4_beforeWebGL = performance.now();
2528
+ log(`[addNode] Step 5: Creating WebGL object for ${nodeModel.id}`);
2529
+
2530
+ // [Modified] CSS3D media/embed node implementation (image/video/embed)
2531
+ if (nodeModel.contentType === 'image' || nodeModel.contentType === 'video' || nodeModel.contentType === 'embed') {
2532
+ const node = nodeModel; // Alias for snippet compatibility
2533
+ const width = (node.width ?? node.Width) || (nodeModel.contentType === 'embed' ? 460 : (nodeModel.contentType === 'video' ? 470 : 300));
2534
+ const height = (node.height ?? node.Height) || (nodeModel.contentType === 'embed' ? 260 : (nodeModel.contentType === 'video' ? 264 : 300));
2535
+
2536
+ // 1. WebGL Mesh 생성 (LOD 원거리 표시 및 Raycaster 감지용)
2537
+ const hitGeometry = new THREE.PlaneGeometry(width, height);
2538
+ const initialBodyMap = nodeModel.contentType === 'image'
2539
+ ? null
2540
+ : createImageStatusTexture(node, width, height);
2541
+ const hitMaterial = new THREE.MeshBasicMaterial({
2542
+ map: initialBodyMap,
2543
+ color: 0xffffff,
2544
+ transparent: false,
2545
+ depthWrite: true,
2546
+ depthTest: true,
2547
+ side: THREE.DoubleSide,
2548
+ polygonOffset: true,
2549
+ polygonOffsetFactor: -1,
2550
+ polygonOffsetUnits: -1
2551
+ });
2552
+
2553
+ const hitBody = new THREE.Mesh(hitGeometry, hitMaterial);
2554
+ hitBody.name = 'body'; // 중요: 시스템이 'body' 이름을 통해 노드를 식별함
2555
+ hitBody.castShadow = false;
2556
+ hitBody.receiveShadow = false;
2557
+ hitBody.userData = { nodeId: node.id }; // Ensure nodeId is present for Raycaster
2558
+ const shouldDeferImageBodyTexture =
2559
+ module?.isLoading === true ||
2560
+ module?._bulkAddingNodes === true;
2561
+ if (nodeModel.contentType === 'image' && !shouldDeferImageBodyTexture) {
2562
+ syncImageNodeBodyTexture(hitBody, node);
2563
+ } else if (nodeModel.contentType === 'image') {
2564
+ prewarmImageNodePreviewCache(node, module);
2565
+ }
2566
+
2567
+ // [FIX] Align HitBody Top-Left to Group Origin (Standard behavior)
2568
+ // PlaneGeometry is centered (0,0), so move Right(+w/2) and Down(-h/2)
2569
+ // ▼▼▼ [FIX] Move hitBody back slightly to avoid fighting with CSS3D at z=0 ▼▼▼
2570
+ // parallax 최소화를 위해 z=0으로 설정 (Grid z=0, CSS3D z=0)
2571
+ hitBody.position.set(width / 2, -height / 2, 0.0); // ★ z=0.0
2572
+ // ▲▲▲ [Changed] ▲▲▲
2573
+
2574
+ // Create a Group to hold both the hit mesh and the CSS object
2575
+ glObject = new THREE.Group();
2576
+ glObject.add(hitBody);
2577
+
2578
+ const isSelected = module.selectedNodeIdJs === nodeModel.id || module.multiSelectedNodeIds.has(nodeModel.id);
2579
+ const baseRenderOrder = module.nodeZCounter++;
2580
+ hitBody.renderOrder = baseRenderOrder;
2581
+ glObject.userData.baseRenderOrder = baseRenderOrder;
2582
+
2583
+ syncImageSelectionGlow(glObject, width, height, baseRenderOrder, false);
2584
+ syncImageMidLodDecorations(
2585
+ glObject,
2586
+ width,
2587
+ height,
2588
+ baseRenderOrder,
2589
+ isSelected,
2590
+ {
2591
+ edgeVisible: false,
2592
+ glowVisible: true
2593
+ }
2594
+ );
2595
+
2596
+ // Optional WebGL glow (disabled by default)
2597
+ if (ENABLE_WEBGL_GLOW && window.MindMapGlowShader) {
2598
+ const glowMesh = window.MindMapGlowShader.createSDFGlowMesh(width, height, isSelected, nodeModel.contentType === 'video' || nodeModel.contentType === 'embed' ? 'text' : 'image');
2599
+ glowMesh.position.set(width / 2, -height / 2, 0.0); // body(1.0)보다 뒤, 그리드(-10)보다 앞
2600
+ glowMesh.renderOrder = baseRenderOrder - 5;
2601
+ const cameraZ = module.camera?.position?.z ?? Infinity;
2602
+ const threshold = 4000;
2603
+ glowMesh.visible = cameraZ < threshold;
2604
+ glObject.add(glowMesh);
2605
+ }
2606
+
2607
+ // ▼▼▼ [FIX] Add corner resize handles to image nodes ▼▼▼
2608
+ // Image nodes use only corner hit-handles in WebGL; the visible affordance comes from the selection overlay.
2609
+ const HANDLE_SIZE = 16;
2610
+
2611
+ const handleConfigs = [
2612
+ { name: 'resizeHandle_TL', corner: 'TL', x: HANDLE_SIZE / 2, y: -HANDLE_SIZE / 2, w: HANDLE_SIZE, h: HANDLE_SIZE },
2613
+ { name: 'resizeHandle_TR', corner: 'TR', x: width - HANDLE_SIZE / 2, y: -HANDLE_SIZE / 2, w: HANDLE_SIZE, h: HANDLE_SIZE },
2614
+ { name: 'resizeHandle_BL', corner: 'BL', x: HANDLE_SIZE / 2, y: -height + HANDLE_SIZE / 2, w: HANDLE_SIZE, h: HANDLE_SIZE },
2615
+ { name: 'resizeHandle_BR', corner: 'BR', x: width - HANDLE_SIZE / 2, y: -height + HANDLE_SIZE / 2, w: HANDLE_SIZE, h: HANDLE_SIZE }
2616
+ ];
2617
+
2618
+ handleConfigs.forEach(c => {
2619
+ const handleGeom = new THREE.PlaneGeometry(c.w, c.h);
2620
+ // 투명하지만 Raycast 감지 가능하도록 opacity 0.01 설정
2621
+ const handleMat = new THREE.MeshBasicMaterial({
2622
+ color: 0xff0000,
2623
+ transparent: true,
2624
+ opacity: 0.0,
2625
+ depthWrite: false,
2626
+ depthTest: false
2627
+ });
2628
+ handleMat.colorWrite = false;
2629
+ const handleMesh = new THREE.Mesh(handleGeom, handleMat);
2630
+ handleMesh.name = c.name;
2631
+ handleMesh.userData = { nodeId: nodeModel.id, isResizeHandle: true, corner: c.corner };
2632
+ handleMesh.renderOrder = baseRenderOrder + 10; // 높은 우선순위
2633
+ handleMesh.position.set(c.x, c.y, 0.0); // ★ Parallax 방지: 노드 평면(Z=0)에 완전 밀착 (renderOrder로 가시성 확보)
2634
+ glObject.add(handleMesh);
2635
+ });
2636
+ // ▲▲▲ [FIX] ▲▲▲
2637
+
2638
+ // 2. CSS3D creation delegated to MindMapCss3DManager (standardized)
2639
+ // Previously manual creation here is removed to avoid duplication.
2640
+
2641
+ // Set Group Position
2642
+ // [Fix] Force Z=0 to eliminate parallax - ignore nodeModel.positionZ completely
2643
+ glObject.position.set(nodeModel.positionX, nodeModel.positionY, 0);
2644
+
2645
+ // Essential Metadata
2646
+ glObject.userData.nodeId = nodeModel.id;
2647
+ glObject.userData.worldWidth = width;
2648
+ glObject.userData.worldHeight = height;
2649
+
2650
+ } else {
2651
+ // [Fix] Force zPos=0 to eliminate parallax
2652
+ glObject = await window.MindMapObjectManager.createOrUpdateCanvasTextureNode(module, nodeModel, null, 0, textures, width, height);
2653
+ if (_isBoardStale()) { log(`[addNode] Board switched after WebGL create, aborting ${nodeModel.id}`); return; }
2654
+ }
2655
+ }
2656
+
2657
+ if (!glObject) {
2658
+ console.error(`[MindMapNodes] Node ${nodeModel.id} failed to create WebGL object. Aborting addNode.`);
2659
+ if (!shouldUseDeferredShell) {
2660
+ textureCache.delete(nodeModel.id);
2661
+ }
2662
+ return;
2663
+ }
2664
+ glObject.userData.nodeId = nodeModel.id;
2665
+
2666
+
2667
+
2668
+ // ★ [LOD] 초기 LOD 레벨 저장 (첫 TextLOD 업데이트에서 품질 재결정)
2669
+ glObject.userData.lodLevel = 'NEEDS_UPDATE';
2670
+
2671
+ const nodeEntry = {
2672
+ glObject: glObject,
2673
+ // [Fix] For image nodes, we stored cssObject in userData. Assign it here for consistency.
2674
+ cssObject: glObject.userData?.css3dObject || null,
2675
+ currentType: 'GL', // Initially GL because (Image=Group, Others=Mesh)
2676
+ model: nodeModel,
2677
+ isDeferredShell: shouldUseDeferredShell,
2678
+ // ▼▼▼ [Optimization] Dirty flag for smart CSS3D sync (캐시 최적화) ▼▼▼
2679
+ isCssDirty: true, // 초기 생성 시 true (첫 표시 시 sync 필요)
2680
+ glowMesh: glObject?.getObjectByName?.('glow') || null,
2681
+ bodyMesh: glObject?.getObjectByName?.('body') || null,
2682
+ tailMesh: glObject?.getObjectByName?.('tail') || null
2683
+ // ▲▲▲ [Optimization] ▲▲▲
2684
+ };
2685
+ const isNearMode = module.lodRenderer ? !module.lodRenderer.isInLODMode : true;
2686
+
2687
+ // Rich cards use CSS3D in NEAR/NORMAL so wrapper glow/selection stays consistent.
2688
+ if (nodeModel.contentType === 'note' || nodeModel.contentType === 'memo' || nodeModel.contentType === 'text' || nodeModel.contentType === 'markdown' || nodeModel.contentType === 'code' || nodeModel.contentType === 'image' || nodeModel.contentType === 'video' || nodeModel.contentType === 'embed') {
2689
+ log(`[MindMapNodes] Creating cssObject for ${nodeModel.contentType} node ${nodeModel.id}`);
2690
+ const shouldDeferCssObject = module.isLoading && (
2691
+ nodeModel.contentType === 'text' ||
2692
+ nodeModel.contentType === 'note' ||
2693
+ nodeModel.contentType === 'memo' ||
2694
+ nodeModel.contentType === 'markdown' ||
2695
+ nodeModel.contentType === 'code' ||
2696
+ nodeModel.contentType === 'image' ||
2697
+ nodeModel.contentType === 'embed'
2698
+ );
2699
+
2700
+ if (!nodeEntry.cssObject && !shouldDeferCssObject) {
2701
+ nodeEntry.cssObject = window.MindMapCss3DManager.createCss3dObject(module, nodeModel);
2702
+ }
2703
+ if (nodeEntry.cssObject) {
2704
+ const glPos = glObject.position;
2705
+ // [FIX] Parallax issue fix: Set Z to match glObject (was + 0.2)
2706
+ nodeEntry.cssObject.position.set(glPos.x, glPos.y, glPos.z);
2707
+ const shouldAttachCss = isNearMode;
2708
+ if (shouldAttachCss) {
2709
+ (module.cssScene || module.scene).add(nodeEntry.cssObject);
2710
+ window.MindMapCss3DManager?.markCssParentRepairNeeded?.(module, nodeModel.id);
2711
+ } else if (nodeEntry.cssObject.parent) {
2712
+ nodeEntry.cssObject.parent.remove(nodeEntry.cssObject);
2713
+ }
2714
+ nodeEntry.cssObject.updateMatrixWorld(true);
2715
+ window.MindMapCss3DManager?.markNodeTransformDirty?.(module, nodeModel.id);
2716
+
2717
+ // ▼▼▼ [Fix] Apply thin scrollbar class to view mode element ▼▼▼
2718
+ const scrollableEl = nodeEntry.cssObject.element.querySelector('.node-response');
2719
+ if (scrollableEl) {
2720
+ scrollableEl.classList.add('thin-scrollbar');
2721
+ }
2722
+ // ▲▲▲ [Fix] ▲▲▲
2723
+
2724
+ // ▼▼▼ [Fix] Sync content immediately after CSS3D object creation ▼▼▼
2725
+ if (window.MindMapCss3DManager?.syncCss3dContent) {
2726
+ window.MindMapCss3DManager.syncCss3dContent(nodeModel, nodeEntry.cssObject);
2727
+ // ▼▼▼ [Optimization] 초기 sync 완료 후 clean 상태로 설정 ▼▼▼
2728
+ nodeEntry.isCssDirty = false;
2729
+ // ▲▲▲ [Optimization] ▲▲▲
2730
+ }
2731
+ // ▲▲▲ [Fix] ▲▲▲
2732
+
2733
+ // ▼▼▼ [Fix] NEAR 모드에서 새 노드 즉시 표시 ▼▼▼
2734
+ // CSS3D 객체는 기본적으로 visible=false로 생성됨
2735
+ // NEAR 모드(isInLODMode=false)일 때 즉시 표시해야 함
2736
+ if (shouldAttachCss) {
2737
+ nodeEntry.cssObject.visible = true;
2738
+ if (nodeEntry.cssObject.element) {
2739
+ nodeEntry.cssObject.element.style.removeProperty('display');
2740
+ nodeEntry.cssObject.element.style.removeProperty('visibility');
2741
+ nodeEntry.cssObject.element.style.removeProperty('opacity');
2742
+ nodeEntry.cssObject.element.style.setProperty('display', 'block', 'important');
2743
+ nodeEntry.cssObject.element.style.setProperty('visibility', 'visible', 'important');
2744
+ nodeEntry.cssObject.element.style.setProperty('opacity', '1', 'important');
2745
+ }
2746
+ nodeEntry.currentType = 'CSS';
2747
+ // WebGL body 숨기기 (이중 렌더링 방지)
2748
+ if (glObject) {
2749
+ ensureNodeMeshCache(nodeEntry);
2750
+ glObject.visible = true; // Group은 visible (glow 등 표시 위해)
2751
+ const body = nodeEntry.bodyMesh;
2752
+ const tail = nodeEntry.tailMesh;
2753
+ if (body) body.visible = false;
2754
+ if (tail) tail.visible = false;
2755
+ setNodeScrollbarVisibility(nodeEntry, false);
2756
+ }
2757
+ log(`[MindMapNodes] ✅ CSS3D visible=true for new node ${nodeModel.id} (NEAR mode)`);
2758
+ }
2759
+ // ▲▲▲ [Fix] ▲▲▲
2760
+ }
2761
+ }
2762
+ // ▲▲▲ [Changed] ▲▲▲
2763
+
2764
+ // ▼▼▼ [UX] Hide nodes while board is loading ▼▼▼
2765
+ if (module.isLoading) {
2766
+ if (glObject) glObject.visible = false;
2767
+ if (nodeEntry.cssObject) nodeEntry.cssObject.visible = false;
2768
+ }
2769
+ // ▲▲▲ [UX] ▲▲▲
2770
+
2771
+ // ▼▼▼ [Fix] Only enqueue when it was not rendered immediately ▼▼▼
2772
+ const alreadyRendered = (nodeModel.contentType === 'text' || nodeModel.contentType === 'note' || nodeModel.contentType === 'code') && nodeModel.response;
2773
+ if (!shouldUseDeferredShell && !alreadyRendered && !pendingNodeUpdates.has(nodeModel.id) && (nodeModel.contentType === 'text' || nodeModel.contentType === 'note' || nodeModel.contentType === 'code')) {
2774
+ pendingNodeUpdates.set(nodeModel.id, {
2775
+ content: nodeModel.response,
2776
+ width: width,
2777
+ height: height
2778
+ });
2779
+ setTimeout(() => window.MindMapPipeline.processRenderQueue(module, nodeModel.id), 0);
2780
+ }
2781
+ // ▲▲▲ [Fix] ▲▲▲
2782
+
2783
+ // ▼▼▼ [Fix] Trigger an immediate render update after image load completes ▼▼▼
2784
+ // Previously: cache only and wait for a C# call -> Now: enqueue rendering when caching succeeds
2785
+ // ▼▼▼ [Modified] Skip async texture load for CSS3D-based Image nodes ▼▼▼
2786
+ // Image nodes now use <img> tags via CSS3D, so we don't need to load the texture into WebGL.
2787
+ if (nodeModel.contentType === 'image' && nodeModel.response) {
2788
+ // WebGL texture is loaded directly in the image block above.
2789
+ }
2790
+ // ▲▲▲ [Modified] ▲▲▲
2791
+
2792
+ if (module.nodeObjectsById.has(nodeModel.id)) {
2793
+ console.warn(`[MindMapNodes] Race condition: Node was added by another call: ${nodeModel.id}`);
2794
+ if (glObject && glObject.geometry) try { glObject.geometry.dispose(); } catch { }
2795
+ return;
2796
+ }
2797
+
2798
+ module.nodeObjectsById.set(nodeModel.id, nodeEntry);
2799
+ const queuedUpdate = pendingNodeUpdates.get(nodeModel.id);
2800
+ if (queuedUpdate) {
2801
+ queuedUpdate.retryCount = 0;
2802
+ setTimeout(() => window.MindMapPipeline?.processRenderQueue?.(module, nodeModel.id), 0);
2803
+ }
2804
+ module.markAgentConsoleVisibilityTrackingDirty?.();
2805
+ scheduleMindCanvasAgentReportAutoFit(module, nodeModel.id);
2806
+
2807
+ if (module.GRID_CELL_SIZE && glObject && glObject.position) {
2808
+ // ▼▼▼ [Fix] Register the node in all occupied cells using the improved helper ▼▼▼
2809
+ updateNodeSpatialGrid(module, nodeModel.id);
2810
+ // ▲▲▲ [Fix] ▲▲▲
2811
+ }
2812
+
2813
+ if (!shouldUseDeferredShell) {
2814
+ module.scene.add(glObject);
2815
+ }
2816
+
2817
+ // Newly added nodes can already be selected programmatically (for example after paste).
2818
+ // Re-apply the current selection state now that the live entry exists so pointer events,
2819
+ // resize affordances, and click-cycle behavior start from a consistent base.
2820
+ const isSelectedOnAdd =
2821
+ module.selectedNodeIdJs === nodeModel.id ||
2822
+ module.multiSelectedNodeIds?.has?.(nodeModel.id) === true;
2823
+ updateNodeSelectionStyle(module, nodeModel.id, isSelectedOnAdd, false, true);
2824
+
2825
+ // Persist initial auto-position/size to C# state so board save captures them.
2826
+ if (module.dotNetHelper?.invokeMethodAsync) {
2827
+ if (autoPositioned) {
2828
+ try {
2829
+ await module.dotNetHelper.invokeMethodAsync(
2830
+ 'UpdateNodePosition',
2831
+ nodeModel.id,
2832
+ Math.round(nodeModel.positionX || 0),
2833
+ Math.round(nodeModel.positionY || 0)
2834
+ );
2835
+ } catch (e) {
2836
+ console.warn('[MindMapNodes] Failed to persist auto-position:', e);
2837
+ }
2838
+ }
2839
+
2840
+ if (isNewNode) {
2841
+ try {
2842
+ await module.dotNetHelper.invokeMethodAsync(
2843
+ 'UpdateNodeDimensions',
2844
+ nodeModel.id,
2845
+ Math.round(width || 0),
2846
+ Math.round(height || 0)
2847
+ );
2848
+ } catch (e) {
2849
+ console.warn('[MindMapNodes] Failed to persist initial dimensions:', e);
2850
+ }
2851
+ }
2852
+ }
2853
+
2854
+ if (autoPositioned) {
2855
+ moveCursorAfterNodePlacement(module, nodeModel, width, height, {
2856
+ padding: 30,
2857
+ moveCamera: true
2858
+ });
2859
+ }
2860
+ const nodeEndTime = performance.now();
2861
+ timings.t5_end = nodeEndTime;
2862
+
2863
+ // ▼▼▼ [Timing] 세부 시간 출력 ▼▼▼
2864
+ const t_init = timings.t1_init - nodeStartTime;
2865
+ const t_measure = (timings.t2_afterMeasure || timings.t1_init) - timings.t1_init;
2866
+ const t_textures = (timings.t3_afterTextures || timings.t2_afterMeasure || timings.t1_init) - (timings.t2_afterMeasure || timings.t1_init);
2867
+ const t_webgl = (timings.t5_end) - (timings.t4_beforeWebGL || timings.t3_afterTextures || timings.t1_init);
2868
+ const t_total = nodeEndTime - nodeStartTime;
2869
+
2870
+ if (!module.isLoading) {
2871
+ log(`[⏱️ Timing] addNode for ${nodeModel.id} (${nodeModel.contentType}):
2872
+ • Init: ${t_init.toFixed(1)}ms
2873
+ • MeasureHeight: ${t_measure.toFixed(1)}ms
2874
+ • Textures: ${t_textures.toFixed(1)}ms
2875
+ • WebGL+Finish: ${t_webgl.toFixed(1)}ms
2876
+ • TOTAL: ${t_total.toFixed(1)}ms`);
2877
+ }
2878
+ // ▲▲▲ [Timing] ▲▲▲
2879
+
2880
+ // ▼▼▼ [Fix] 노드 추가 후 LOD 렌더러 및 frustum culling 즉시 업데이트 ▼▼▼
2881
+ if (!module.isLoading) {
2882
+ if (module.lodRenderer?.requestResidentPatch) {
2883
+ module.lodRenderer.requestResidentPatch('add-node', nodeModel.id || nodeModel.Id);
2884
+ }
2885
+ // Frustum culling 강제 업데이트 (새 노드가 화면에 보이도록)
2886
+ if (module.updateVisibility && module.camera) {
2887
+ module.updateVisibility(module.camera);
2888
+ }
2889
+ }
2890
+ // ▲▲▲ [Fix] ▲▲▲
2891
+
2892
+ if (!module.isLoading) {
2893
+ log(`[MindMapNodes] Node added successfully: ${nodeModel.id} (${t_total.toFixed(1)}ms)`);
2894
+ }
2895
+ }
2896
+
2897
+ function removeNode(module, nodeId, options = null) {
2898
+ const id = String(nodeId || '').trim();
2899
+ if (!module || !id) return false;
2900
+
2901
+ const entry = module.nodeObjectsById.get(id);
2902
+ if (!entry) return false;
2903
+
2904
+ removeNodeFromSpatialGrid(module, id, entry);
2905
+
2906
+ if (entry.cssObject && entry.cssObject.element) {
2907
+ const domElement = entry.cssObject.element;
2908
+ if (domElement.parentElement) {
2909
+ domElement.parentElement.removeChild(domElement);
2910
+ }
2911
+ }
2912
+
2913
+ if (entry.glObject && module.scene) {
2914
+
2915
+
2916
+ try { module.scene.remove(entry.glObject); } catch { }
2917
+ if (entry.glObject.geometry) try { entry.glObject.geometry.dispose(); } catch { }
2918
+ try {
2919
+ entry.glObject.traverse(child => {
2920
+ if (child.geometry) try { child.geometry.dispose(); } catch { }
2921
+ if (child.material) {
2922
+ if (Array.isArray(child.material)) child.material.forEach(m => { try { m.dispose(); } catch { } });
2923
+ else try { child.material.dispose(); } catch { }
2924
+ }
2925
+ });
2926
+ } catch { }
2927
+ }
2928
+
2929
+ if (entry.cssObject) {
2930
+ try { (module.cssScene || module.scene).remove(entry.cssObject); } catch { }
2931
+ }
2932
+
2933
+ const cached = textureCache.get(id);
2934
+ if (cached) {
2935
+ // ★ 삭제되는 텍스처 메모리 감산 및 로그
2936
+ const freedMemory = calculateTextureMemory(cached);
2937
+ logTextureMemory('REMOVE', id, -freedMemory);
2938
+
2939
+ if (!cached.default.body?._isShared) try { cached.default.body?.dispose(); } catch { }
2940
+ if (!cached.selected.body?._isShared) try { cached.selected.body?.dispose(); } catch { }
2941
+ try { cached.glow?.texture?.dispose(); } catch { }
2942
+ textureCache.delete(id);
2943
+ }
2944
+ imageCache.delete(id);
2945
+ pendingNodeUpdates.delete(id);
2946
+ nodeRenderLocks.delete(id);
2947
+
2948
+ module.multiSelectedNodeIds?.delete?.(id);
2949
+ module.pendingSelectedActivationNodeIds?.delete?.(id);
2950
+ if (module.selectedNodeIdJs === id) module.selectedNodeIdJs = null;
2951
+ module.setAgentConsoleVisibilityState?.(id, false);
2952
+ module.logicWorkerBridge?.removeNode?.(id, module);
2953
+ module.nodeObjectsById.delete(id);
2954
+
2955
+ if (options?.deferRenderSync !== true) {
2956
+ finalizeRemovedNodes(module, [id]);
2957
+ }
2958
+
2959
+ return true;
2960
+ }
2961
+
2962
+ function removeNodes(module, nodeIds) {
2963
+ if (!module || !Array.isArray(nodeIds) || nodeIds.length === 0) return 0;
2964
+
2965
+ const uniqueIds = Array.from(new Set(
2966
+ nodeIds.map(id => String(id || '').trim()).filter(Boolean)
2967
+ ));
2968
+ const removedIds = [];
2969
+
2970
+ uniqueIds.forEach(id => {
2971
+ if (removeNode(module, id, { deferRenderSync: true })) {
2972
+ removedIds.push(id);
2973
+ }
2974
+ });
2975
+
2976
+ finalizeRemovedNodes(module, removedIds);
2977
+ return removedIds.length;
2978
+ }
2979
+
2980
+ function finalizeRemovedNodes(module, nodeIds) {
2981
+ if (!module || !Array.isArray(nodeIds) || nodeIds.length === 0) return;
2982
+
2983
+ const lodRenderer = module.lodRenderer;
2984
+ if (lodRenderer) {
2985
+ if (typeof lodRenderer.removeNodes === 'function') {
2986
+ lodRenderer.removeNodes(nodeIds);
2987
+ } else if (typeof lodRenderer.removeNode === 'function') {
2988
+ nodeIds.forEach(id => lodRenderer.removeNode(id));
2989
+ } else if (typeof lodRenderer.requestResidentPatch === 'function') {
2990
+ lodRenderer.requestResidentPatch('remove-node');
2991
+ }
2992
+ }
2993
+
2994
+ window.MindMapTextOverlayV2?.invalidateView?.(module);
2995
+
2996
+ if (module.updateVisibility && module.camera) {
2997
+ module.updateVisibility(module.camera);
2998
+ }
2999
+ module._forceUpdateFrames = Math.max(module._forceUpdateFrames || 0, 4);
3000
+ }
3001
+
3002
+ function isMindCanvasAgentReportNode(model) {
3003
+ const metadata = model?.metadata || model?.Metadata || {};
3004
+ return String(metadata?.source || metadata?.Source || '').trim() === 'mind-canvas-agent-report';
3005
+ }
3006
+
3007
+ function measureMindCanvasAgentReportNaturalHeight(entry) {
3008
+ const sourceRoot = entry?.cssObject?.element;
3009
+ if (!sourceRoot) return null;
3010
+
3011
+ const probe = sourceRoot.cloneNode(true);
3012
+ probe.removeAttribute('id');
3013
+ probe.querySelectorAll?.('[id]')?.forEach(el => el.removeAttribute('id'));
3014
+ Object.assign(probe.style, {
3015
+ position: 'absolute',
3016
+ left: '-100000px',
3017
+ top: '-100000px',
3018
+ display: 'block',
3019
+ visibility: 'hidden',
3020
+ pointerEvents: 'none',
3021
+ overflow: 'visible',
3022
+ height: 'auto',
3023
+ zIndex: '-1000'
3024
+ });
3025
+
3026
+ const inner = probe.firstElementChild;
3027
+ if (!inner) return null;
3028
+
3029
+ inner.style.height = 'auto';
3030
+ inner.style.minHeight = '0';
3031
+ inner.style.overflow = 'visible';
3032
+
3033
+ const contentWrapper = inner.querySelector('.node-content-wrapper');
3034
+ if (contentWrapper) {
3035
+ contentWrapper.style.height = 'auto';
3036
+ contentWrapper.style.overflow = 'visible';
3037
+ }
3038
+
3039
+ const responseEl = inner.querySelector(`[id^="node-response-"]`) || inner.querySelector('.node-response');
3040
+ if (responseEl) {
3041
+ responseEl.style.height = 'auto';
3042
+ responseEl.style.flex = '0 0 auto';
3043
+ responseEl.style.overflowY = 'visible';
3044
+ responseEl.style.overflow = 'visible';
3045
+ }
3046
+
3047
+ document.body.appendChild(probe);
3048
+ try {
3049
+ const measured = Math.ceil(Math.max(
3050
+ inner.getBoundingClientRect?.().height || 0,
3051
+ inner.scrollHeight || 0,
3052
+ probe.getBoundingClientRect?.().height || 0
3053
+ ));
3054
+ return Number.isFinite(measured) && measured > 0 ? measured : null;
3055
+ } finally {
3056
+ probe.remove();
3057
+ }
3058
+ }
3059
+
3060
+ function scheduleMindCanvasAgentReportAutoFit(module, nodeId) {
3061
+ const entry = module?.nodeObjectsById?.get?.(nodeId);
3062
+ if (!entry || !isMindCanvasAgentReportNode(entry.model) || entry.model?._agentReportAutoFitScheduled) return;
3063
+
3064
+ entry.model._agentReportAutoFitScheduled = true;
3065
+ setTimeout(() => {
3066
+ const latest = module?.nodeObjectsById?.get?.(nodeId);
3067
+ if (!latest || !isMindCanvasAgentReportNode(latest.model)) return;
3068
+
3069
+ const measured = measureMindCanvasAgentReportNaturalHeight(latest);
3070
+ if (!measured) return;
3071
+
3072
+ const currentHeight = Number(latest.model?.height || latest.model?.Height || latest.cssObject?.userData?.worldHeight || 0);
3073
+ const nextHeight = Math.max(100, Math.min(4000, measured + 2));
3074
+ if (Math.abs(nextHeight - currentHeight) <= 3) return;
3075
+
3076
+ const model = latest.model;
3077
+ const width = Number(model.width || model.Width || latest.cssObject?.userData?.worldWidth || 400);
3078
+ model.height = nextHeight;
3079
+ model.Height = nextHeight;
3080
+
3081
+ pendingNodeUpdates.set(nodeId, {
3082
+ content: model.response ?? model.Response ?? '',
3083
+ width,
3084
+ height: nextHeight,
3085
+ skipMeasure: true
3086
+ });
3087
+
3088
+ window.MindMapPipeline?.processRenderQueue?.(module, nodeId)
3089
+ ?.catch?.(err => console.warn('[MindMapNodes] Failed to auto-fit agent report node:', err));
3090
+ }, 0);
3091
+ }
3092
+
3093
+ async function updateNodeContent(module, nodeId, content, w = null, h = null, isLoading = null) {
3094
+ if (!module.nodeObjectsById.has(nodeId)) {
3095
+ pendingNodeUpdates.set(nodeId, {
3096
+ content,
3097
+ width: w,
3098
+ height: h,
3099
+ isLoading,
3100
+ retryCount: 0,
3101
+ source: 'MindMapNodes.updateNodeContent-missing'
3102
+ });
3103
+ console.warn(`[MindMapNodes] updateNodeContent queued: Node ID not found yet: ${nodeId}`);
3104
+ return;
3105
+ }
3106
+
3107
+ const nodeEntry = module.nodeObjectsById.get(nodeId);
3108
+ const contentType = getNodeContentTypeLower(nodeEntry?.model) || 'unknown';
3109
+ const widthLabel = w ?? 'auto';
3110
+ const heightLabel = h ?? 'auto';
3111
+ const previousResponse = String(nodeEntry?.model?.response ?? nodeEntry?.model?.Response ?? '');
3112
+ const previousIsLoading = !!(nodeEntry?.model?.isLoading ?? nodeEntry?.model?.IsLoading);
3113
+ let nextContent = content;
3114
+ const hasExplicitContent = nextContent !== null && nextContent !== undefined;
3115
+ const model = nodeEntry?.model;
3116
+ if (model && hasExplicitContent) {
3117
+ nextContent = applyCanonicalNodeResponse(model, nextContent);
3118
+ }
3119
+
3120
+ const effectiveContent = hasExplicitContent ? nextContent : previousResponse;
3121
+ const contentLen = effectiveContent ? String(effectiveContent).length : 0;
3122
+ log(`[MindMapNodes] updateNodeContent for ${nodeId}: ContentType=${contentType}, New Size=${widthLabel}x${heightLabel}, ContentLen=${contentLen}, isLoading=${isLoading}`);
3123
+
3124
+ // ▼▼▼ [New] Update isLoading state in JS model ▼▼▼
3125
+ if (model && isLoading !== null) {
3126
+ model.isLoading = isLoading;
3127
+ model.IsLoading = isLoading;
3128
+ }
3129
+ // ▲▲▲ [New] ▲▲▲
3130
+
3131
+ let generatedImageLayoutUpdate = null;
3132
+ if (model) {
3133
+ generatedImageLayoutUpdate = prepareGeneratedImageLayoutUpdate(
3134
+ module,
3135
+ nodeEntry,
3136
+ w,
3137
+ h,
3138
+ previousResponse,
3139
+ previousIsLoading,
3140
+ isLoading
3141
+ );
3142
+
3143
+ if (generatedImageLayoutUpdate) {
3144
+ w = generatedImageLayoutUpdate.width;
3145
+ h = generatedImageLayoutUpdate.height;
3146
+ applyNodeWorldPosition(module, nodeEntry, generatedImageLayoutUpdate.x, generatedImageLayoutUpdate.y);
3147
+ log(
3148
+ `[MindMapNodes] Generated image frame aligned for ${nodeId}: ` +
3149
+ `${generatedImageLayoutUpdate.width}x${generatedImageLayoutUpdate.height} @ ` +
3150
+ `(${generatedImageLayoutUpdate.x}, ${generatedImageLayoutUpdate.y})`
3151
+ );
3152
+ }
3153
+ }
3154
+
3155
+ // ▼▼▼ [New] Auto-grow height during streaming for expanded nodes or full-response mode ▼▼▼
3156
+ const shouldAutoGrow = model && (contentType === 'text' || contentType === 'note') &&
3157
+ (model.isExpanded || !!module.showFullAiResponse);
3158
+
3159
+ // Check whether CSS3D DOM is available for direct measurement (most accurate)
3160
+ const isCss3dVisible = !!(nodeEntry?.cssObject?.visible && window.MindMapCss3DManager?.syncCss3dContent);
3161
+
3162
+ // [Fallback] When CSS3D is NOT visible, use measureHtmlHeightAsync (different CSS, 2x scale)
3163
+ if (shouldAutoGrow && !isCss3dVisible) {
3164
+ try {
3165
+ const zoomLevel = 2;
3166
+ const currentWidth = model.width || 400;
3167
+ const effectiveMaxHeight = 10000;
3168
+ const { height: measuredHeight } = await window.MindMapTextureFactory.measureHtmlHeightAsync(
3169
+ { ...model, response: nextContent },
3170
+ zoomLevel,
3171
+ currentWidth * zoomLevel,
3172
+ null,
3173
+ effectiveMaxHeight
3174
+ );
3175
+
3176
+ const SAFETY_BUFFER = 4;
3177
+ const requiredHeight = measuredHeight + SAFETY_BUFFER;
3178
+ const MAX_HEIGHT = 4000;
3179
+ const newHeight = Math.min(requiredHeight, MAX_HEIGHT);
3180
+ const isFinalPass = isLoading === false;
3181
+
3182
+ if ((!isFinalPass && newHeight > model.height) ||
3183
+ (isFinalPass && Math.abs(newHeight - model.height) > 1)) {
3184
+ log(`[MindMapNodes] Auto-fit (fallback) node ${nodeId}: ${model.height} -> ${newHeight}px`);
3185
+ model.height = newHeight;
3186
+ h = newHeight;
3187
+ if (nodeEntry.cssObject?.userData) nodeEntry.cssObject.userData.worldHeight = newHeight;
3188
+ if (nodeEntry.glObject?.userData) nodeEntry.glObject.userData.worldHeight = newHeight;
3189
+ }
3190
+ } catch (err) {
3191
+ console.warn(`[MindMapNodes] Failed to auto-grow expanded node ${nodeId}:`, err);
3192
+ }
3193
+ }
3194
+ // ▲▲▲ [New] ▲▲▲
3195
+
3196
+ // ▼▼▼ [Optimization] 내용 변경 시 dirty 플래그 설정 ▼▼▼
3197
+ if (model && hasExplicitContent && previousResponse !== String(nextContent ?? '')) {
3198
+ nodeEntry.isCssDirty = true;
3199
+ }
3200
+ // ▲▲▲ [Optimization] ▲▲▲
3201
+
3202
+ // ▼▼▼ [Refactor] Update Model & Delegate CSS3D DOM updates to Manager ▼▼▼
3203
+ if (isCss3dVisible && nodeEntry.model) {
3204
+ // 1. Update content in DOM first
3205
+ if (hasExplicitContent) {
3206
+ setNodeResponseValue(nodeEntry.model, nextContent);
3207
+ }
3208
+ window.MindMapCss3DManager.syncCss3dContent(nodeEntry.model, nodeEntry.cssObject);
3209
+
3210
+ // 2. [Primary] Scrollbar-based precise height correction
3211
+ // After syncCss3dContent, response div has the content rendered.
3212
+ // If scrollHeight > clientHeight, the content overflows: grow by exactly that amount.
3213
+ // On final pass (isLoading=false), also allow shrink using auto-height measurement.
3214
+ if (shouldAutoGrow) {
3215
+ try {
3216
+ const cssRoot = nodeEntry.cssObject.element;
3217
+ const innerEl = cssRoot?.firstElementChild;
3218
+ const responseEl = innerEl?.querySelector(`[id^="node-response-"]`) || innerEl?.querySelector('.node-response');
3219
+
3220
+ if (responseEl) {
3221
+ const MAX_HEIGHT = 4000;
3222
+ const MIN_HEIGHT = module.aiNodeDefaultHeight || 60;
3223
+ const currentHeight = model.height || 0;
3224
+ const isFinalPass = isLoading === false;
3225
+
3226
+ const scrollH = responseEl.scrollHeight;
3227
+ const clientH = responseEl.clientHeight;
3228
+ const overflow = scrollH - clientH;
3229
+
3230
+ if (overflow > 1) {
3231
+ // Content overflows — grow by overflow + small buffer for sub-pixel rounding
3232
+ const SCROLL_BUFFER = 5;
3233
+ const GRID = module.GRID_SIZE || 10;
3234
+ const raw = currentHeight + Math.ceil(overflow) + SCROLL_BUFFER;
3235
+ const newHeight = Math.min(MAX_HEIGHT, Math.max(MIN_HEIGHT, Math.ceil(raw / GRID) * GRID));
3236
+ log(`[MindMapNodes] DOM-fit(scroll) node ${nodeId}: ${currentHeight} -> ${newHeight}px (overflow=${overflow})`);
3237
+ model.height = newHeight;
3238
+ h = newHeight;
3239
+ if (nodeEntry.cssObject?.userData) nodeEntry.cssObject.userData.worldHeight = newHeight;
3240
+ if (nodeEntry.glObject?.userData) nodeEntry.glObject.userData.worldHeight = newHeight;
3241
+ } else if (isFinalPass && overflow < -10) {
3242
+ // Final pass: node is too tall (excess whitespace).
3243
+ // Use auto-height trick to find exact needed height for shrinking.
3244
+ const wrapperEl = innerEl?.querySelector('.node-content-wrapper');
3245
+ if (wrapperEl) {
3246
+ const saved = {
3247
+ rootH: cssRoot.style.height, rootOv: cssRoot.style.overflow,
3248
+ innerH: innerEl.style.height, innerOv: innerEl.style.overflow,
3249
+ wrapperH: wrapperEl.style.height, wrapperOv: wrapperEl.style.overflow,
3250
+ responseH: responseEl.style.height, responseFlex: responseEl.style.flex,
3251
+ responseOv: responseEl.style.overflowY || responseEl.style.overflow,
3252
+ };
3253
+ cssRoot.style.height = 'auto'; cssRoot.style.overflow = 'visible';
3254
+ innerEl.style.height = 'auto'; innerEl.style.overflow = 'visible';
3255
+ wrapperEl.style.height = 'auto'; wrapperEl.style.overflow = 'visible';
3256
+ responseEl.style.height = 'auto'; responseEl.style.flex = '0 0 auto'; responseEl.style.overflowY = 'visible';
3257
+
3258
+ const naturalHeight = innerEl.scrollHeight;
3259
+
3260
+ cssRoot.style.height = saved.rootH; cssRoot.style.overflow = saved.rootOv;
3261
+ innerEl.style.height = saved.innerH; innerEl.style.overflow = saved.innerOv;
3262
+ wrapperEl.style.height = saved.wrapperH; wrapperEl.style.overflow = saved.wrapperOv;
3263
+ responseEl.style.height = saved.responseH; responseEl.style.flex = saved.responseFlex;
3264
+ responseEl.style.overflowY = saved.responseOv;
3265
+
3266
+ const shrunkHeight = Math.min(MAX_HEIGHT, Math.max(MIN_HEIGHT, Math.ceil(naturalHeight)));
3267
+ if (shrunkHeight < currentHeight - 1) {
3268
+ log(`[MindMapNodes] DOM-fit(shrink) node ${nodeId}: ${currentHeight} -> ${shrunkHeight}px`);
3269
+ model.height = shrunkHeight;
3270
+ h = shrunkHeight;
3271
+ if (nodeEntry.cssObject?.userData) nodeEntry.cssObject.userData.worldHeight = shrunkHeight;
3272
+ if (nodeEntry.glObject?.userData) nodeEntry.glObject.userData.worldHeight = shrunkHeight;
3273
+ }
3274
+ }
3275
+ }
3276
+ }
3277
+ } catch (err) {
3278
+ console.warn(`[MindMapNodes] DOM height fit failed for ${nodeId}:`, err);
3279
+ }
3280
+ }
3281
+
3282
+ nodeEntry.isCssDirty = false;
3283
+ }
3284
+ // ▲▲▲ [Refactor] ▲▲▲
3285
+
3286
+ if (window.MindMapTextOverlayV2?.invalidateNode) {
3287
+ const invalidateReason = hasExplicitContent ? 'content' : 'layout';
3288
+ window.MindMapTextOverlayV2.invalidateNode(module, nodeId, invalidateReason);
3289
+ }
3290
+
3291
+ pendingNodeUpdates.set(nodeId, { content: effectiveContent, width: w, height: h, isLoading });
3292
+ await window.MindMapPipeline.processRenderQueue(module, nodeId);
3293
+
3294
+ if (generatedImageLayoutUpdate?.shouldMoveCursor) {
3295
+ moveCursorAfterNodePlacement(module, model, model.width, model.height, {
3296
+ padding: 30,
3297
+ moveCamera: false
3298
+ });
3299
+ }
3300
+
3301
+ if ((generatedImageLayoutUpdate?.positionChanged || generatedImageLayoutUpdate?.sizeChanged) && module.dotNetHelper?.invokeMethodAsync) {
3302
+ try {
3303
+ if (generatedImageLayoutUpdate.sizeChanged) {
3304
+ await module.dotNetHelper.invokeMethodAsync(
3305
+ 'UpdateNodeDimensions',
3306
+ nodeId,
3307
+ Math.round(Number(model?.width || generatedImageLayoutUpdate.width || 0)),
3308
+ Math.round(Number(model?.height || generatedImageLayoutUpdate.height || 0))
3309
+ );
3310
+ }
3311
+
3312
+ if (generatedImageLayoutUpdate.positionChanged) {
3313
+ await module.dotNetHelper.invokeMethodAsync(
3314
+ 'UpdateNodePosition',
3315
+ nodeId,
3316
+ Math.round(Number(model?.positionX || generatedImageLayoutUpdate.x || 0)),
3317
+ Math.round(Number(model?.positionY || generatedImageLayoutUpdate.y || 0))
3318
+ );
3319
+ }
3320
+ } catch (error) {
3321
+ console.warn('[MindMapNodes] Failed to persist generated image layout update:', error);
3322
+ }
3323
+ }
3324
+ }
3325
+
3326
+ function markOverlayStackOrderChanged(module, forceFrames = 3) {
3327
+ if (!module) return;
3328
+
3329
+ module._deferPassiveOverlayRefresh = false;
3330
+ module._overlayDirty = true;
3331
+ module._forceUpdateFrames = Math.max(Number(module._forceUpdateFrames || 0), forceFrames);
3332
+ window.MindMapTextOverlayV2?.invalidateView?.(module);
3333
+ }
3334
+
3335
+ function bringNodeToFront(module, nodeId) {
3336
+ if (!module || !nodeId) return;
3337
+
3338
+ const entry = module.nodeObjectsById?.get(nodeId);
3339
+ if (!entry) return;
3340
+ const contentTypeLower = getNodeContentTypeLower(entry.model);
3341
+ const usesSafeMediaSelectionChrome = contentTypeLower === 'video' || contentTypeLower === 'embed';
3342
+
3343
+ // Keep relative renderOrder offsets (e.g., resize handles above body) and move whole node to top.
3344
+ if (entry.glObject) {
3345
+ let minOrder = Infinity;
3346
+ let maxOrder = -Infinity;
3347
+
3348
+ entry.glObject.traverse(child => {
3349
+ if (child.isMesh || child.isLine) {
3350
+ const order = child.renderOrder || 0;
3351
+ if (order < minOrder) minOrder = order;
3352
+ if (order > maxOrder) maxOrder = order;
3353
+ }
3354
+ });
3355
+
3356
+ if (!Number.isFinite(minOrder)) minOrder = 0;
3357
+ if (!Number.isFinite(maxOrder)) maxOrder = minOrder;
3358
+
3359
+ const targetBaseOrder = module.nodeZCounter++;
3360
+ const delta = targetBaseOrder - minOrder;
3361
+
3362
+ entry.glObject.traverse(child => {
3363
+ if (child.isMesh || child.isLine) {
3364
+ child.renderOrder = (child.renderOrder || 0) + delta;
3365
+ }
3366
+ });
3367
+
3368
+ const nextTop = maxOrder + delta + 1;
3369
+ if (nextTop > module.nodeZCounter) {
3370
+ module.nodeZCounter = nextTop;
3371
+ }
3372
+ }
3373
+
3374
+ // CSS3DObject re-parenting can trigger a one-frame detach flicker.
3375
+ // Keep DOM attached and raise stacking via z-index instead.
3376
+ if (entry.cssObject) {
3377
+ syncCss3dMediaFrameForEntry(module, nodeId, entry);
3378
+ const cssElement = entry.cssObject.element;
3379
+ if (cssElement) {
3380
+ const z = Math.max(1, module.nodeZCounter - 1);
3381
+ cssElement.style.zIndex = String(z);
3382
+ const hasLiveEmbedIframe =
3383
+ contentTypeLower === 'embed' &&
3384
+ window.MindMapCss3DManager?.hasMountedEmbedIframe?.(entry.cssObject) === true;
3385
+ if (usesSafeMediaSelectionChrome && !hasLiveEmbedIframe && cssElement.parentElement) {
3386
+ cssElement.parentElement.appendChild(cssElement);
3387
+ }
3388
+ }
3389
+ }
3390
+
3391
+ markOverlayStackOrderChanged(module);
3392
+ }
3393
+
3394
+ async function updateNodeSelectionStyle(module, nodeId, isSelected, bringToFront = false, asyncSchedule = false) {
3395
+ if (asyncSchedule) {
3396
+ setTimeout(() => updateNodeSelectionStyle(module, nodeId, isSelected, bringToFront, false), 0);
3397
+ return;
3398
+ }
3399
+
3400
+ if (!isSelected) {
3401
+ window.MindMapCss3DManager?.hideBusinessAutomationFloatingTooltipForNode?.(nodeId);
3402
+ }
3403
+
3404
+ const entry = module.nodeObjectsById.get(nodeId);
3405
+ if (!entry || !entry.glObject) return;
3406
+
3407
+ if (isMediaNodeModel(entry.model)) {
3408
+ syncCss3dMediaFrameForEntry(module, nodeId, entry);
3409
+ }
3410
+
3411
+ // ▼▼▼ [Fix] Keep image nodes visible on selection (avoid LOD/CSS conflicts) ▼▼▼
3412
+ if (entry.model?.contentType === 'image') {
3413
+ ensureNodeMeshCache(entry);
3414
+ entry.glObject.visible = true;
3415
+ const body = entry.bodyMesh;
3416
+ const tail = entry.tailMesh;
3417
+ const usesCssView =
3418
+ entry.currentType === 'CSS' &&
3419
+ !!entry.cssObject?.visible &&
3420
+ isCss3dRendererEnabledForModule(module);
3421
+ const baseRenderOrder = entry.glObject.userData?.baseRenderOrder ?? body?.renderOrder ?? 1;
3422
+ const nodeWidth = entry.model.width || body?.geometry?.parameters?.width || 300;
3423
+ const nodeHeight = entry.model.height || body?.geometry?.parameters?.height || 300;
3424
+ const showImageLodGlow = !usesCssView;
3425
+ syncImageSelectionGlow(entry.glObject, nodeWidth, nodeHeight, baseRenderOrder, false);
3426
+ syncImageMidLodDecorations(entry.glObject, nodeWidth, nodeHeight, baseRenderOrder, !!isSelected, {
3427
+ edgeVisible: false,
3428
+ glowVisible: showImageLodGlow
3429
+ });
3430
+ const outline = entry.glObject.getObjectByName('outline');
3431
+ if (body) body.visible = !usesCssView || shouldShowImageBodyInCssMode(entry.model);
3432
+ if (tail) tail.visible = false;
3433
+ if (outline) {
3434
+ outline.visible = false;
3435
+ if (outline.material) {
3436
+ outline.material.color.setHex(IMAGE_SELECTION_GLOW_COLOR);
3437
+ outline.material.opacity = 0;
3438
+ outline.material.needsUpdate = true;
3439
+ }
3440
+ }
3441
+ if (entry.glObject?.children) {
3442
+ entry.glObject.children.forEach(child => {
3443
+ if (child?.userData?.isResizeHandle) {
3444
+ child.visible = !usesCssView;
3445
+ if (usesCssView) {
3446
+ child.scale?.setScalar?.(1.0);
3447
+ }
3448
+ }
3449
+ });
3450
+ }
3451
+
3452
+ // Keep CSS-based glow in sync for image nodes as well.
3453
+ const imageCssElement = entry.cssObject?.element;
3454
+ const imageInner =
3455
+ imageCssElement?.querySelector('.map-node-image-container') ||
3456
+ imageCssElement?.querySelector('.map-node') ||
3457
+ null;
3458
+ if (imageCssElement?.classList) {
3459
+ imageCssElement.classList.remove('image-selected');
3460
+ imageCssElement.classList.toggle('selected', !!isSelected);
3461
+ }
3462
+ if (imageInner?.classList) {
3463
+ imageInner.classList.toggle('selected', !!isSelected);
3464
+ }
3465
+
3466
+ // ▼▼▼ [Fix] Glow update for image nodes ▼▼▼
3467
+ const glowMesh = entry.glowMesh;
3468
+ if (ENABLE_WEBGL_GLOW && glowMesh && window.MindMapGlowShader) {
3469
+ window.MindMapGlowShader.updateGlowSelection(glowMesh, isSelected);
3470
+ }
3471
+ // ▲▲▲ [Fix] ▲▲▲
3472
+
3473
+ if (bringToFront) {
3474
+ bringNodeToFront(module, nodeId);
3475
+ }
3476
+
3477
+ // Image nodes do not use textureCache/canvas generation logic below.
3478
+ // They rely on the static TextureLoader map assigned at creation.
3479
+ // So we return early to avoid overwriting the texture with a blank canvas.
3480
+ return;
3481
+ }
3482
+ // ▲▲▲ [Fix] ▲▲▲
3483
+
3484
+ // ▼▼▼ [Fix] Sync CSS3D selection glow/border with the node's active theme ▼▼▼
3485
+ const cssElement = entry.cssObject?.element;
3486
+ if (cssElement) {
3487
+ const isEditing = !!(module.isEditingNote && module.selectedNodeIdJs === nodeId);
3488
+ const shouldHighlight = !!(isSelected || isEditing);
3489
+ const bubbleElement =
3490
+ cssElement.querySelector('.map-node-note') ||
3491
+ cssElement.querySelector('.map-node-memo') ||
3492
+ cssElement.querySelector('.map-node-code') ||
3493
+ cssElement.querySelector('.map-node-agent') ||
3494
+ cssElement.querySelector('.map-node-bubble') ||
3495
+ cssElement.querySelector('.map-node') ||
3496
+ cssElement;
3497
+
3498
+ // Keep class-based styling in sync for CSS rules
3499
+ if (bubbleElement?.classList) {
3500
+ bubbleElement.classList.toggle('selected', !!shouldHighlight);
3501
+ }
3502
+
3503
+ // Keep wrapper glow/pointer state in sync for all CSS3D nodes.
3504
+ const contentType = String(entry.model?.contentType ?? entry.model?.ContentType ?? '').toLowerCase();
3505
+ const isMemoNode = contentType === 'memo';
3506
+ const isAgentLikeNode = isAgentNodeModel(entry.model);
3507
+ const usesSafeMediaSelectionChrome = contentType === 'video' || contentType === 'embed';
3508
+
3509
+ if (cssElement.classList) {
3510
+ cssElement.classList.toggle('selected', !usesSafeMediaSelectionChrome && !!shouldHighlight);
3511
+ cssElement.classList.toggle('selected-media-safe', usesSafeMediaSelectionChrome && !!shouldHighlight);
3512
+ }
3513
+
3514
+ if ((isMemoNode || isAgentStyledMemoNode(entry.model) || isAgentLikeNode) && window.MindMapCss3DManager?.applyMemoWrapperTheme) {
3515
+ const themedRoot =
3516
+ cssElement.querySelector('.map-node-memo') ||
3517
+ cssElement.querySelector('.map-node-agent') ||
3518
+ cssElement;
3519
+ window.MindMapCss3DManager.applyMemoWrapperTheme(
3520
+ themedRoot,
3521
+ String(entry.model?.metadata?.memoColor || entry.model?.Metadata?.memoColor || 'coral').toLowerCase()
3522
+ );
3523
+ }
3524
+
3525
+ // ▼▼▼ [NEW] 텍스트 기반 노드 선택 시 pointer-events 및 user-select 활성화 ▼▼▼
3526
+ // - note/memo/code/agent 노드: 편집 모드(isEditing)에서만 텍스트 선택 가능
3527
+ // - 일반 text/markdown 노드: 선택 시 텍스트 복사 가능
3528
+ const isNoteNode = contentType === 'note';
3529
+ const isEmbedNode = contentType === 'embed';
3530
+ const isInlineEditableNode = isInlineEditableNodeModel(entry.model);
3531
+ const isReadOnlyTextNode = isReadOnlyTextSelectionNodeModel(entry.model);
3532
+ const isTextBased = isInlineEditableNode || isReadOnlyTextNode;
3533
+
3534
+ if (isEmbedNode && window.MindMapCss3DManager?.syncCss3dEmbedInteractivity) {
3535
+ window.MindMapCss3DManager.syncCss3dEmbedInteractivity(entry.cssObject, {
3536
+ isInteractive: !!shouldHighlight
3537
+ });
3538
+ }
3539
+
3540
+ if (isTextBased) {
3541
+ // 선택 시 래퍼는 클릭 가능해야 하고, 실제 입력 컨트롤은 편집 중에만 연다.
3542
+ const shouldBeInteractive = isSelected || isEditing;
3543
+
3544
+ if (shouldBeInteractive) {
3545
+ cssElement.style.pointerEvents = 'auto';
3546
+
3547
+ // 내부 콘텐츠 영역 pointer-events 활성화
3548
+ const contentEls = cssElement.querySelectorAll(NODE_INTERACTIVE_CONTENT_SELECTORS);
3549
+ contentEls.forEach(el => {
3550
+ const isMemoControl = isMemoNode && el.matches(MEMO_EDIT_ONLY_SELECTORS);
3551
+ if (isMemoControl && !isEditing) {
3552
+ el.style.pointerEvents = 'none';
3553
+ el.style.userSelect = 'none';
3554
+ el.style.webkitUserSelect = 'none';
3555
+ el.style.cursor = 'grab';
3556
+ return;
3557
+ }
3558
+
3559
+ el.style.pointerEvents = 'auto';
3560
+
3561
+ if (el.matches('.map-node-memo__icon-button, .map-node-memo__icon-option')) {
3562
+ el.style.userSelect = 'none';
3563
+ el.style.webkitUserSelect = 'none';
3564
+ el.style.cursor = 'pointer';
3565
+ return;
3566
+ }
3567
+
3568
+ // ▼▼▼ [Fix] 노드 타입에 따라 텍스트 선택 허용 조건 분리 ▼▼▼
3569
+ // note/memo/code/agent 노드: 편집 모드에서만 텍스트 선택 허용
3570
+ // 일반 text/markdown 노드: 선택되면 텍스트 선택 허용 (복사를 위해)
3571
+ const allowTextSelection = isInlineEditableNode ? isEditing : isSelected;
3572
+
3573
+ if (allowTextSelection) {
3574
+ el.style.userSelect = 'text';
3575
+ el.style.webkitUserSelect = 'text';
3576
+ el.style.cursor = 'text';
3577
+ } else {
3578
+ el.style.userSelect = 'none';
3579
+ el.style.webkitUserSelect = 'none';
3580
+ el.style.cursor = 'grab';
3581
+ }
3582
+ // ▲▲▲ [Fix] ▲▲▲
3583
+ });
3584
+ } else {
3585
+ // 선택 해제 시: interaction 차단 + 텍스트 선택 비활성화
3586
+ cssElement.style.pointerEvents = 'none';
3587
+ const contentEls = cssElement.querySelectorAll(NODE_INTERACTIVE_CONTENT_SELECTORS);
3588
+ contentEls.forEach(el => {
3589
+ el.style.pointerEvents = 'none';
3590
+ el.style.userSelect = 'none';
3591
+ el.style.webkitUserSelect = 'none';
3592
+ });
3593
+ if (isMemoNode) {
3594
+ cssElement.querySelectorAll('.map-node-memo__icon-popover.is-open').forEach(popover => {
3595
+ popover.classList.remove('is-open');
3596
+ });
3597
+ }
3598
+ }
3599
+ }
3600
+ // ▲▲▲ [NEW] ▲▲▲
3601
+ }
3602
+ // ▲▲▲ [Fix] ▲▲▲
3603
+
3604
+ if (entry.isDeferredShell === true) {
3605
+ if (bringToFront && entry.cssObject) {
3606
+ bringNodeToFront(module, nodeId);
3607
+ }
3608
+
3609
+ if (module.lodRenderer && typeof module.lodRenderer.updateInstanceSelection === 'function') {
3610
+ module.lodRenderer.updateInstanceSelection(nodeId, isSelected);
3611
+ }
3612
+
3613
+ return;
3614
+ }
3615
+
3616
+ // ▼▼▼ [Fix] CSS3D 모드(HIGH)일 때 WebGL 겹침 방지 및 텍스처 생성 차단 ▼▼▼
3617
+ if (entry.currentType === 'CSS') {
3618
+ ensureNodeMeshCache(entry);
3619
+ const isCssRendererActive = isCss3dRendererEnabledForModule(module);
3620
+ // Image nodes should keep their WebGL body visible to prevent disappearance
3621
+ if (entry.model?.contentType !== 'image') {
3622
+ const bodyMesh = entry.bodyMesh;
3623
+ const tailMesh = entry.tailMesh;
3624
+ if (bodyMesh) {
3625
+ bodyMesh.visible = !isCssRendererActive && isMediaNodeModel(entry.model);
3626
+ if (!isCssRendererActive && isMediaNodeModel(entry.model) && bodyMesh.material) {
3627
+ if (entry.model?.contentType !== 'image' && bodyMesh.material.map) {
3628
+ bodyMesh.material.map = null;
3629
+ }
3630
+ const fallbackBodyHex = entry.model?.contentType === 'video'
3631
+ ? 0x0f172a
3632
+ : 0xf8fafc;
3633
+ bodyMesh.material.color?.setHex?.(fallbackBodyHex);
3634
+ bodyMesh.material.needsUpdate = true;
3635
+ }
3636
+ }
3637
+ if (tailMesh) tailMesh.visible = false;
3638
+ }
3639
+
3640
+ // Glow는 업데이트 (선택 표시용) - WebGL Glow는 CSS3D 뒤에 깔려서 보임
3641
+ const glowMesh = entry.glowMesh;
3642
+ if (ENABLE_WEBGL_GLOW && glowMesh && window.MindMapGlowShader) {
3643
+ window.MindMapGlowShader.updateGlowSelection(glowMesh, isSelected);
3644
+ }
3645
+
3646
+ // Note 내용 동기화 (CSS3D가 활성이므로 여기서 처리)
3647
+ if (!isSelected && entry.model.contentType === 'note' && entry.cssObject) {
3648
+ const textarea = entry.cssObject.element?.querySelector('textarea');
3649
+ if (textarea && textarea.value !== entry.model.response) {
3650
+ entry.model.response = textarea.value;
3651
+ window.MindMapTextLOD?.invalidateNodeCache?.(nodeId);
3652
+ }
3653
+ }
3654
+
3655
+ // ▼▼▼ [Optimization] CSS3D 모드에서 dirty면 sync ▼▼▼
3656
+ if (entry.isCssDirty && window.MindMapCss3DManager?.syncCss3dContent) {
3657
+ window.MindMapCss3DManager.syncCss3dContent(entry.model, entry.cssObject);
3658
+ entry.isCssDirty = false;
3659
+ if (isMediaNodeModel(entry.model)) {
3660
+ syncCss3dMediaFrameForEntry(module, nodeId, entry);
3661
+ }
3662
+ }
3663
+ // ▲▲▲ [Optimization] ▲▲▲
3664
+
3665
+ // Z-Order 업데이트
3666
+ if (bringToFront) {
3667
+ bringNodeToFront(module, nodeId);
3668
+ }
3669
+
3670
+ if (module.lodRenderer && typeof module.lodRenderer.updateInstanceSelection === 'function') {
3671
+ module.lodRenderer.updateInstanceSelection(nodeId, isSelected);
3672
+ }
3673
+
3674
+ return; // WebGL 텍스처 업데이트 중단
3675
+ }
3676
+ // ▲▲▲ [Fix] ▲▲▲
3677
+
3678
+ // ▼▼▼ [Fix] Sync CSS3D textarea content to model before deselection ▼▼▼
3679
+ // When deselecting a note node, the textarea may have newer content than model.response.
3680
+ // This can happen if switchToWebGL set currentType='GL' but hasn't completed updateNodeContent yet.
3681
+ // Sync from textarea regardless of currentType as long as cssObject exists and has a textarea.
3682
+ if (!isSelected && entry.model.contentType === 'note' && entry.cssObject) {
3683
+ const textarea = entry.cssObject.element?.querySelector('textarea');
3684
+ if (textarea && textarea.value !== entry.model.response) {
3685
+ log(`[updateNodeSelectionStyle] Syncing CSS3D textarea content for ${nodeId} (${textarea.value.length} chars)`);
3686
+ entry.model.response = textarea.value;
3687
+ // ★ Invalidate LOD cache so zoom-in will regenerate with new content
3688
+ window.MindMapTextLOD?.invalidateNodeCache?.(nodeId);
3689
+ }
3690
+ // Ensure focus-within is cleared so selected styling doesn't stick
3691
+ try { textarea?.blur(); } catch { }
3692
+ }
3693
+ // ▲▲▲ [Fix] ▲▲▲
3694
+
3695
+ if (!textureCache.has(nodeId)) {
3696
+ // [Refactor] Troika support removed. Using standard textures.
3697
+ textureCache.set(nodeId, textures);
3698
+ }
3699
+
3700
+ let textures = textureCache.get(nodeId);
3701
+
3702
+ // ▼▼▼ [Optimization B] On-demand selected texture creation ▼▼▼
3703
+ if (isSelected && (!textures.selected.body || !textures.selected.tail)) {
3704
+ log(`[updateNodeSelectionStyle] Creating selected textures on-demand for ${nodeId}`);
3705
+ const t0 = performance.now();
3706
+
3707
+
3708
+
3709
+ // Canvas 모드: 기존 방식
3710
+ const { textures: newTextures } = await window.MindMapTextureFactory.generateAndCacheTextures(
3711
+ module,
3712
+ entry.model,
3713
+ imageCache,
3714
+ { skipMeasure: true, skipSelected: false }
3715
+ );
3716
+ textures.selected = newTextures.selected;
3717
+ textureCache.set(nodeId, textures);
3718
+
3719
+ const t1 = performance.now();
3720
+ log(`[⏱️ Timing] On-demand selected texture for ${nodeId}: ${(t1 - t0).toFixed(1)}ms`);
3721
+ }
3722
+ // ▲▲▲ [Optimization B] ▲▲▲
3723
+
3724
+ // ▼▼▼ [Fix] Regenerate default texture only when content changed ▼▼▼
3725
+ if (!isSelected && textures.default?.body) {
3726
+ const cachedContent = textures.default._cachedContent ?? '';
3727
+ const currentContent = entry.model.response ?? '';
3728
+
3729
+ const needsContentUpdate = cachedContent !== currentContent;
3730
+
3731
+ if (needsContentUpdate) {
3732
+ log(`[updateNodeSelectionStyle] Regenerating default textures for ${nodeId} (content changed)`);
3733
+ const t0 = performance.now();
3734
+
3735
+ const { textures: newTextures } = await window.MindMapTextureFactory.generateAndCacheTextures(
3736
+ module,
3737
+ entry.model,
3738
+ imageCache,
3739
+ { skipMeasure: true, skipSelected: true }
3740
+ );
3741
+ // Dispose old default textures
3742
+ try { if (!textures.default.body?._isShared) textures.default.body?.dispose(); } catch { }
3743
+ try { if (!textures.default.tail?._isShared) textures.default.tail?.dispose(); } catch { }
3744
+ textures.default = newTextures.default;
3745
+ textures.default._cachedContent = currentContent;
3746
+ textureCache.set(nodeId, textures);
3747
+
3748
+ const t1 = performance.now();
3749
+ log(`[⏱️ Timing] On-demand default texture regeneration for ${nodeId}: ${(t1 - t0).toFixed(1)}ms`);
3750
+ }
3751
+ }
3752
+ // ▲▲▲ [Fix] ▲▲▲
3753
+
3754
+ const active = isSelected ? textures.selected : textures.default;
3755
+
3756
+ ensureNodeMeshCache(entry);
3757
+ const bodyMesh = entry.bodyMesh;
3758
+ const tailMesh = entry.tailMesh;
3759
+ const outline = entry.glObject.getObjectByName('outline');
3760
+
3761
+ if (outline) {
3762
+ outline.visible = false; // Disabled - using SDF glow instead
3763
+ }
3764
+ if (bodyMesh && bodyMesh.material) {
3765
+ bodyMesh.material.map = active.body;
3766
+ bodyMesh.material.needsUpdate = true;
3767
+ }
3768
+ if (tailMesh && tailMesh.material) {
3769
+ tailMesh.material.map = active.tail;
3770
+ tailMesh.material.needsUpdate = true;
3771
+ }
3772
+
3773
+ const glowMesh = entry.glowMesh;
3774
+ if (ENABLE_WEBGL_GLOW && glowMesh && window.MindMapGlowShader) {
3775
+ window.MindMapGlowShader.updateGlowSelection(glowMesh, isSelected);
3776
+ }
3777
+
3778
+ // ▼▼▼ [Fix] Update SDF body borderColor for Troika code nodes ▼▼▼
3779
+ if (bodyMesh && bodyMesh.material && bodyMesh.material.uniforms && bodyMesh.material.uniforms.borderColor) {
3780
+ bodyMesh.material.uniforms.borderColor.value.setHex(getSelectionBorderColorHex(entry.model, isSelected));
3781
+ }
3782
+ // ▲▲▲ [Fix] ▲▲▲
3783
+
3784
+ // ▼▼▼ [FIX] Update LOD InstancedMesh selection state immediately ▼▼▼
3785
+ if (module.lodRenderer && typeof module.lodRenderer.updateInstanceSelection === 'function') {
3786
+ module.lodRenderer.updateInstanceSelection(nodeId, isSelected);
3787
+ }
3788
+ // ▲▲▲ [FIX] ▲▲▲
3789
+
3790
+ if (bringToFront) {
3791
+ bringNodeToFront(module, nodeId);
3792
+ // ▼▼▼ [Fix] Keep Z position stable to align with grid (avoid perspective lift) ▼▼▼
3793
+ // renderOrder is sufficient for draw order without shifting world Z.
3794
+ // ▲▲▲ [Fix] ▲▲▲
3795
+ }
3796
+ }
3797
+
3798
+ function refreshLodSelectionState(module, changedIds = null) {
3799
+ const lodRenderer = module?.lodRenderer;
3800
+ if (!lodRenderer || lodRenderer.isInLODMode !== true) {
3801
+ if (module) {
3802
+ const currentIds = new Set();
3803
+ if (module.selectedNodeIdJs) {
3804
+ currentIds.add(module.selectedNodeIdJs);
3805
+ }
3806
+ module.multiSelectedNodeIds?.forEach?.((id) => {
3807
+ if (id) {
3808
+ currentIds.add(id);
3809
+ }
3810
+ });
3811
+ module._lastLodSelectionIds = currentIds;
3812
+ }
3813
+ return;
3814
+ }
3815
+
3816
+ const currentIds = new Set();
3817
+ if (module.selectedNodeIdJs) {
3818
+ currentIds.add(module.selectedNodeIdJs);
3819
+ }
3820
+ module.multiSelectedNodeIds?.forEach?.((id) => {
3821
+ if (id) {
3822
+ currentIds.add(id);
3823
+ }
3824
+ });
3825
+
3826
+ let idsToRefresh = [];
3827
+ if (Array.isArray(changedIds) && changedIds.length > 0) {
3828
+ idsToRefresh = changedIds
3829
+ .map(id => String(id || '').trim())
3830
+ .filter(Boolean);
3831
+ } else {
3832
+ const previousIds = module._lastLodSelectionIds instanceof Set
3833
+ ? module._lastLodSelectionIds
3834
+ : new Set();
3835
+ idsToRefresh = Array.from(new Set([...previousIds, ...currentIds]));
3836
+ }
3837
+
3838
+ for (let i = 0; i < idsToRefresh.length; i++) {
3839
+ const id = idsToRefresh[i];
3840
+ lodRenderer.updateInstanceSelection?.(id, currentIds.has(id));
3841
+ }
3842
+
3843
+ module._lastLodSelectionIds = currentIds;
3844
+ const primarySelectionId = module.selectedNodeIdJs || '';
3845
+ const multiSelectedIds = module.multiSelectedNodeIds;
3846
+ if (!multiSelectedIds || multiSelectedIds.size === 0) {
3847
+ lodRenderer._lastAppliedLodSelectionKey = primarySelectionId;
3848
+ } else if (multiSelectedIds.size === 1) {
3849
+ const onlyId = Array.from(multiSelectedIds)[0] || '';
3850
+ lodRenderer._lastAppliedLodSelectionKey = `${primarySelectionId}|${onlyId}`;
3851
+ } else {
3852
+ lodRenderer._lastAppliedLodSelectionKey = `${primarySelectionId}|${Array.from(multiSelectedIds).sort().join(',')}`;
3853
+ }
3854
+ module._forceUpdateFrames = Math.max(module._forceUpdateFrames || 0, 1);
3855
+ }
3856
+
3857
+ function refreshSelectionOverlayState(module, forceFrames = 2) {
3858
+ if (!module) return;
3859
+
3860
+ module._deferPassiveOverlayRefresh = false;
3861
+ module._overlayDirty = true;
3862
+ module._forceUpdateFrames = Math.max(Number(module._forceUpdateFrames || 0), forceFrames);
3863
+
3864
+ const isMotionInteraction =
3865
+ module.isZooming === true ||
3866
+ module.isPanning === true ||
3867
+ module.isResizing === true ||
3868
+ module.isDraggingNode === true ||
3869
+ module.isDraggingMultipleNodes === true ||
3870
+ module.isWindowResizing === true;
3871
+ if (!isMotionInteraction) {
3872
+ module._cameraNavigationOverlayInputTime = 0;
3873
+ module._passiveOverlayResumeAt = 0;
3874
+ window.MindMapTextOverlayV2?.setPassiveOverlaySuppressed?.(module, false);
3875
+ }
3876
+
3877
+ window.MindMapTextOverlayV2?.invalidateView?.(module);
3878
+ }
3879
+
3880
+ // ▼▼▼ [New] Single selection unified path ▼▼▼
3881
+ function applySingleSelection(module, newNodeId, options = {}) {
3882
+ if (!module) return;
3883
+
3884
+ const { notifyBlazor = true, bringToFront = true } = options;
3885
+ const oldNodeId = module.selectedNodeIdJs || null;
3886
+
3887
+ if (oldNodeId && oldNodeId !== newNodeId) {
3888
+ updateNodeSelectionStyle(module, oldNodeId, false, false, true);
3889
+ }
3890
+
3891
+ // ▼▼▼ [Fix] 다중 선택된 노드들의 스타일도 함께 초기화 ▼▼▼
3892
+ if (typeof clearMultiSelection === 'function') {
3893
+ clearMultiSelection(module, newNodeId); // Clears others and updates styles
3894
+ } else {
3895
+ module.multiSelectedNodeIds?.clear?.();
3896
+ }
3897
+ // ▲▲▲ [Fix] ▲▲▲
3898
+ if (newNodeId) {
3899
+ module.multiSelectedNodeIds?.add?.(newNodeId);
3900
+ module.selectedNodeIdJs = newNodeId;
3901
+ if (bringToFront) {
3902
+ // Selection styling is scheduled, but stack order must update before
3903
+ // overlay/CSS3D overlap filtering runs on the next frame.
3904
+ bringNodeToFront(module, newNodeId);
3905
+ }
3906
+ updateNodeSelectionStyle(module, newNodeId, true, false, true);
3907
+ } else {
3908
+ module.selectedNodeIdJs = null;
3909
+ }
3910
+
3911
+ if (notifyBlazor && module.dotNetHelper) {
3912
+ try { module.dotNetHelper.invokeMethodAsync('SelectNodeInBlazor', newNodeId); } catch { }
3913
+ }
3914
+
3915
+ const changedIds = [];
3916
+ if (oldNodeId && oldNodeId !== newNodeId) {
3917
+ changedIds.push(oldNodeId);
3918
+ }
3919
+ if (newNodeId) {
3920
+ changedIds.push(newNodeId);
3921
+ }
3922
+ refreshLodSelectionState(module, changedIds);
3923
+ refreshSelectionOverlayState(module);
3924
+ }
3925
+ // ▲▲▲ [New] ▲▲▲
3926
+
3927
+ function clearMultiSelection(module, keepId = null) {
3928
+ const toDeselect = Array.from(module.multiSelectedNodeIds).filter(id => id !== keepId);
3929
+ toDeselect.forEach(id => updateNodeSelectionStyle(module, id, false, false, true));
3930
+ if (keepId) {
3931
+ module.multiSelectedNodeIds = new Set([keepId]);
3932
+ module.selectedNodeIdJs = keepId;
3933
+ } else {
3934
+ module.multiSelectedNodeIds.clear();
3935
+ module.selectedNodeIdJs = null;
3936
+ }
3937
+
3938
+ // ▼▼▼ [Fix] Force LOD renderer to rebuild instances so selection borders update in MID/FAR ▼▼▼
3939
+ refreshLodSelectionState(module);
3940
+ // ▲▲▲ [Fix] ▲▲▲
3941
+ refreshSelectionOverlayState(module);
3942
+ }
3943
+
3944
+ function resetState(module) {
3945
+ try {
3946
+ textureCache.forEach(t => {
3947
+ try { if (!t.default.body?._isShared) t.default.body?.dispose(); } catch { }
3948
+ try { if (!t.selected.body?._isShared) t.selected.body?.dispose(); } catch { }
3949
+ try { t.glow?.texture?.dispose(); } catch { }
3950
+ });
3951
+ } catch { }
3952
+ textureCache.clear();
3953
+ imageCache.clear();
3954
+ failedImageUrlCache.clear();
3955
+ window.MindMapTextureFactory.clearCache();
3956
+ nodeRenderLocks.clear();
3957
+ pendingNodeUpdates.clear();
3958
+ totalTextureMemoryBytes = 0;
3959
+
3960
+ if (module) {
3961
+ try { module.nodeObjectsById.clear(); } catch { }
3962
+ try { module.spatialGrid.clear(); } catch { }
3963
+ try { module.multiSelectedNodeIds.clear(); } catch { }
3964
+ try { module.pendingSelectedActivationNodeIds?.clear?.(); } catch { }
3965
+ module.selectedNodeIdJs = null;
3966
+ module.lodUpdateQueue = [];
3967
+ }
3968
+ }
3969
+
3970
+ async function switchToCss3D(module, nodeId) {
3971
+ const nodeEntry = module.nodeObjectsById.get(nodeId);
3972
+ // ▼▼▼ [Log] Reason for skipping switch (SwitchToCss3D) ▼▼▼
3973
+ const supportedTypes = ['note', 'memo', 'code', 'text', 'markdown', 'image', 'video', 'embed'];
3974
+ if (!nodeEntry || !supportedTypes.includes(nodeEntry.model.contentType) || !nodeEntry.cssObject) {
3975
+ log(`[MindMapNodes] SwitchToCss3D(${nodeId}) SKIPPED (Not a valid, initialized text-based node).`);
3976
+ return;
3977
+ }
3978
+ // ▼▼▼ [New] Loading 상태에서는 CSS3D로 전환하지 않음 (WebGL 모드 유지) ▼▼▼
3979
+ const modelIsLoading = nodeEntry.model.isLoading ?? nodeEntry.model.IsLoading ?? false;
3980
+ const isMediaNode = nodeEntry.model.contentType === 'image' || nodeEntry.model.contentType === 'video' || nodeEntry.model.contentType === 'embed';
3981
+ if (modelIsLoading && !isMediaNode) {
3982
+ log(`[MindMapNodes] SwitchToCss3D(${nodeId}) SKIPPED (Node is still loading).`);
3983
+ return;
3984
+ }
3985
+ // ▲▲▲ [New] ▲▲▲
3986
+ if (nodeEntry.currentType === 'CSS') {
3987
+ log(`[MindMapNodes] SwitchToCss3D(${nodeId}) SKIPPED (Already in CSS mode).`);
3988
+ return;
3989
+ }
3990
+ log(`[MindMapNodes] Switching node ${nodeId} to CSS3D mode`);
3991
+ // ▲▲▲ [Log] ▲▲▲
3992
+
3993
+ const glPos = nodeEntry.glObject.position;
3994
+ nodeEntry.cssObject.position.copy(glPos);
3995
+
3996
+ // ▼▼▼ [Fix] Sync CSS3D element size with current model dimensions ▼▼▼
3997
+ const model = nodeEntry.model;
3998
+ const wrapper = nodeEntry.cssObject.element;
3999
+ const resolutionScale = nodeEntry.cssObject.userData?.resolutionScale || 1;
4000
+ const modelWidth = Number(model.width ?? model.Width ?? 400);
4001
+ const modelHeight = Number(model.height ?? model.Height ?? 200);
4002
+ if (isMediaNode) {
4003
+ syncCss3dMediaFrameForEntry(module, nodeId, nodeEntry);
4004
+ } else if (wrapper) {
4005
+ wrapper.style.width = `${modelWidth * resolutionScale}px`;
4006
+ wrapper.style.height = `${modelHeight * resolutionScale}px`;
4007
+
4008
+ // Also update inner content element if exists
4009
+ const innerContent = wrapper.firstElementChild;
4010
+ if (innerContent) {
4011
+ innerContent.style.width = `${modelWidth}px`;
4012
+ innerContent.style.height = `${modelHeight}px`;
4013
+ innerContent.style.transform = `scale(${resolutionScale})`;
4014
+ innerContent.style.transformOrigin = '0 0';
4015
+ }
4016
+
4017
+ // Update textarea size as well
4018
+ const textarea = wrapper.querySelector('textarea');
4019
+ if (textarea) {
4020
+ textarea.style.width = '100%';
4021
+ textarea.style.height = '100%';
4022
+ }
4023
+
4024
+ log(`[MindMapNodes] CSS3D size synced: ${modelWidth}x${modelHeight}px (scale=${resolutionScale})`);
4025
+ }
4026
+ // ▲▲▲ [Fix] ▲▲▲
4027
+
4028
+ if (nodeEntry.glObject) {
4029
+ ensureNodeMeshCache(nodeEntry);
4030
+ nodeEntry.glObject.visible = true;
4031
+ const body = nodeEntry.bodyMesh;
4032
+ const tail = nodeEntry.tailMesh;
4033
+ if (body) body.visible = shouldShowImageBodyInCssMode(nodeEntry.model);
4034
+ if (tail) tail.visible = false;
4035
+ nodeEntry.glObject.children?.forEach?.(child => {
4036
+ if (child?.userData?.isResizeHandle) {
4037
+ child.visible = false;
4038
+ child.scale?.setScalar?.(1.0);
4039
+ }
4040
+ });
4041
+ if (isMediaNode) {
4042
+ ['outline', 'imageMidGlow', 'imageMidEdge', 'glow'].forEach(name => {
4043
+ const decoration = nodeEntry.glObject.getObjectByName?.(name);
4044
+ if (!decoration) return;
4045
+ decoration.visible = false;
4046
+ if (decoration.material) {
4047
+ decoration.material.opacity = 0;
4048
+ decoration.material.needsUpdate = true;
4049
+ }
4050
+ });
4051
+ }
4052
+ }
4053
+
4054
+ nodeEntry.cssObject.visible = true;
4055
+
4056
+ // ▼▼▼ [Optimization] dirty 체크 후 필요시에만 sync ▼▼▼
4057
+ if (nodeEntry.isCssDirty && window.MindMapCss3DManager?.syncCss3dContent) {
4058
+ window.MindMapCss3DManager.syncCss3dContent(nodeEntry.model, nodeEntry.cssObject);
4059
+ nodeEntry.isCssDirty = false;
4060
+ if (isMediaNode) {
4061
+ syncCss3dMediaFrameForEntry(module, nodeId, nodeEntry);
4062
+ }
4063
+ }
4064
+ // ▲▲▲ [Optimization] ▲▲▲
4065
+
4066
+ // ▼▼▼ [Fix] 다른 노드들의 텍스트 선택을 비활성화하여 선택 번짐 방지 ▼▼▼
4067
+ module.nodeObjectsById.forEach((otherEntry, otherId) => {
4068
+ if (otherId === nodeId) return; // 현재 노드는 건너뛰기
4069
+ if (!otherEntry.cssObject?.element) return;
4070
+
4071
+ const otherWrapper = otherEntry.cssObject.element;
4072
+ // 다른 노드들의 텍스트 선택 비활성화
4073
+ const contentEls = otherWrapper.querySelectorAll(NODE_TEXT_SELECTION_SELECTORS);
4074
+ contentEls.forEach(el => {
4075
+ el.style.userSelect = 'none';
4076
+ el.style.webkitUserSelect = 'none';
4077
+ });
4078
+ });
4079
+ // ▲▲▲ [Fix] ▲▲▲
4080
+
4081
+ // ▼▼▼ [Fix] 현재 노드의 텍스트 선택 활성화 ▼▼▼
4082
+ const currentWrapper = nodeEntry.cssObject.element;
4083
+ if (currentWrapper) {
4084
+ const contentEls = currentWrapper.querySelectorAll(NODE_TEXT_SELECTION_SELECTORS);
4085
+ contentEls.forEach(el => {
4086
+ el.style.userSelect = 'text';
4087
+ el.style.webkitUserSelect = 'text';
4088
+ });
4089
+ }
4090
+ // ▲▲▲ [Fix] ▲▲▲
4091
+
4092
+ // Sync CSS3D scroll position with WebGL/model scrollOffset (ratio-based).
4093
+ const textareaEl = nodeEntry.cssObject.element.querySelector('textarea');
4094
+ if (nodeEntry.cssObject?.element) {
4095
+ setTimeout(() => {
4096
+ if (textareaEl) {
4097
+ textareaEl.focus({ preventScroll: true });
4098
+ }
4099
+
4100
+ const synced = window.MindMapCss3DManager?.syncCss3dScrollFromModel?.(module, nodeId, {
4101
+ source: 'switchToCss3D'
4102
+ }) === true;
4103
+ if (synced || !textareaEl) {
4104
+ return;
4105
+ }
4106
+
4107
+ // Fallback for older note editing surfaces.
4108
+ const maxScroll = nodeEntry.model.maxScroll || 1;
4109
+ const scrollOffset = nodeEntry.model.scrollOffset || 0;
4110
+ const scrollRatio = maxScroll > 0 ? (scrollOffset / maxScroll) : 0;
4111
+
4112
+ const maxCssScroll = textareaEl.scrollHeight - textareaEl.clientHeight;
4113
+ const targetScrollTop = Math.round(scrollRatio * maxCssScroll);
4114
+
4115
+ textareaEl.scrollTop = targetScrollTop;
4116
+ log(`[MindMapNodes] CSS3D scroll synced: ratio=${scrollRatio.toFixed(2)}, scrollTop=${targetScrollTop}px`);
4117
+ }, 50);
4118
+ }
4119
+
4120
+ nodeEntry.currentType = 'CSS';
4121
+ setNodeScrollbarVisibility(nodeEntry, false);
4122
+ log(`[MindMapNodes] ✓ Node ${nodeId} is now in CSS3D mode`);
4123
+ }
4124
+
4125
+ async function switchToWebGL(module, nodeId) {
4126
+ const nodeEntry = module.nodeObjectsById.get(nodeId);
4127
+ // ▼▼▼ [Log] Reason for skipping switch (SwitchToWebGL) ▼▼▼
4128
+ if (!nodeEntry || !nodeEntry.cssObject) {
4129
+ // Note: contentType check removed here to allow generic handling if needed,
4130
+ // but usually this is for text/note/markdown.
4131
+ return;
4132
+ }
4133
+
4134
+ // ▼▼▼ [Changed] HIGH 모드(CSS3D 유효 구간)에서는 WebGL로 전환 금지 ▼▼▼
4135
+ const cameraZ = module.camera?.position?.z || 0;
4136
+ const threshold = 4000;
4137
+ if (cameraZ < threshold) {
4138
+ const supportsCss3d = nodeEntry.model.contentType === 'text' ||
4139
+ nodeEntry.model.contentType === 'note' ||
4140
+ nodeEntry.model.contentType === 'memo' ||
4141
+ nodeEntry.model.contentType === 'markdown' ||
4142
+ nodeEntry.model.contentType === 'code' ||
4143
+ nodeEntry.model.contentType === 'image' ||
4144
+ nodeEntry.model.contentType === 'video' ||
4145
+ nodeEntry.model.contentType === 'embed';
4146
+ if (supportsCss3d) {
4147
+ console.log(`[MindMapNodes] SwitchToWebGL(${nodeId}) SKIPPED (HIGH Mode - Keeping CSS3D).`);
4148
+ return;
4149
+ }
4150
+ }
4151
+ // ▲▲▲ [Changed] ▲▲▲
4152
+
4153
+ if (nodeEntry.currentType === 'GL') {
4154
+ log(`[MindMapNodes] SwitchToWebGL(${nodeId}) SKIPPED (Already in GL mode).`);
4155
+ return;
4156
+ }
4157
+ log(`[MindMapNodes] Switching node ${nodeId} to WebGL mode`);
4158
+ // ▲▲▲ [Log] ▲▲▲
4159
+
4160
+ if (nodeEntry.isDeferredShell === true) {
4161
+ await ensureDeferredNodeMaterialized(module, nodeEntry, { attachToScene: true });
4162
+ }
4163
+
4164
+ // Store CSS3D textarea scroll ratio into WebGL scrollOffset
4165
+ const textarea = nodeEntry.cssObject.element.querySelector('textarea');
4166
+ if (textarea) {
4167
+ // Compute CSS3D scroll ratio
4168
+ const maxCssScroll = textarea.scrollHeight - textarea.clientHeight;
4169
+ const cssScrollTop = textarea.scrollTop || 0;
4170
+ const scrollRatio = maxCssScroll > 0 ? (cssScrollTop / maxCssScroll) : 0;
4171
+
4172
+ // Apply ratio to WebGL maxScroll to compute scrollOffset
4173
+ const maxScroll = nodeEntry.model.maxScroll || 0;
4174
+ const newScrollOffset = Math.round(scrollRatio * maxScroll);
4175
+
4176
+ if (nodeEntry.model.scrollOffset !== newScrollOffset) {
4177
+ nodeEntry.model.scrollOffset = newScrollOffset;
4178
+ log(`[MindMapNodes] WebGL scroll synced: ratio=${scrollRatio.toFixed(2)}, scrollOffset=${newScrollOffset}px`);
4179
+ }
4180
+
4181
+ if (nodeEntry.model.response !== textarea.value) {
4182
+ // ▼▼▼ [Log] Data sync log ▼▼▼
4183
+ log(`[MindMapNodes] -> Content updated from textarea (Length: ${textarea.value.length})`);
4184
+ // ▲▲▲ [Log] ▲▲▲
4185
+ nodeEntry.model.response = textarea.value;
4186
+ }
4187
+ }
4188
+
4189
+ const resolutionScale = nodeEntry.cssObject.userData?.resolutionScale || 1;
4190
+ const rawWidth = parseFloat(nodeEntry.cssObject.element.style.width) || nodeEntry.model.width;
4191
+ const rawHeight = parseFloat(nodeEntry.cssObject.element.style.height) || nodeEntry.model.height;
4192
+ const newWidth = rawWidth / resolutionScale;
4193
+ const newHeight = rawHeight / resolutionScale;
4194
+
4195
+ // ▼▼▼ [Log] Data sync log ▼▼▼
4196
+ if (newWidth !== nodeEntry.model.width || newHeight !== nodeEntry.model.height) {
4197
+ log(`[MindMapNodes] -> Dimensions updated from CSS (${newWidth}x${newHeight})`);
4198
+ }
4199
+ // ▲▲▲ [Log] ▲▲▲
4200
+ nodeEntry.model.width = newWidth;
4201
+ nodeEntry.model.height = newHeight;
4202
+ if (nodeEntry.cssObject?.userData) {
4203
+ nodeEntry.cssObject.userData.worldWidth = newWidth;
4204
+ nodeEntry.cssObject.userData.worldHeight = newHeight;
4205
+ }
4206
+
4207
+ const cssPos = nodeEntry.cssObject.position;
4208
+ // [Fix] Force Z=0 to eliminate parallax
4209
+ nodeEntry.glObject.position.set(cssPos.x, cssPos.y, 0);
4210
+ if (nodeEntry.glObject) {
4211
+ ensureNodeMeshCache(nodeEntry);
4212
+ nodeEntry.glObject.visible = true;
4213
+ const body = nodeEntry.bodyMesh;
4214
+ const tail = nodeEntry.tailMesh;
4215
+ if (body) body.visible = true;
4216
+ if (tail) tail.visible = true;
4217
+ setNodeScrollbarVisibility(nodeEntry, Number(nodeEntry.model?.maxScroll || 0) > 0);
4218
+ }
4219
+
4220
+ nodeEntry.currentType = 'GL';
4221
+ log(`[MindMapNodes] ✓ Node ${nodeId} is now in WebGL mode`);
4222
+
4223
+ await updateNodeContent(module, nodeId, nodeEntry.model.response, nodeEntry.model.width, nodeEntry.model.height);
4224
+ }
4225
+
4226
+ // ▼▼▼ [New] Update node content type (used for converting text to note) ▼▼▼
4227
+ async function updateNodeContentType(module, nodeId, newContentType) {
4228
+ const nodeEntry = module.nodeObjectsById.get(nodeId);
4229
+ if (!nodeEntry) {
4230
+ console.warn(`[MindMapNodes] updateNodeContentType: Node not found: ${nodeId}`);
4231
+ return;
4232
+ }
4233
+
4234
+ const model = nodeEntry.model;
4235
+ const oldContentType = model.contentType;
4236
+
4237
+ log(`[MindMapNodes] Updating node ${nodeId} content type: ${oldContentType} -> ${newContentType}`);
4238
+
4239
+ // Store current position and data
4240
+ const position = nodeEntry.glObject.position.clone();
4241
+ const nodeData = {
4242
+ id: model.id,
4243
+ contentType: newContentType,
4244
+ prompt: model.prompt,
4245
+ response: model.response,
4246
+ positionX: position.x,
4247
+ positionY: position.y,
4248
+ positionZ: position.z,
4249
+ width: model.width ?? model.Width,
4250
+ height: model.height ?? model.Height,
4251
+ isLoading: false,
4252
+ isAiEnabled: model.isAiEnabled,
4253
+ scrollOffset: model.scrollOffset || 0,
4254
+ maxScroll: model.maxScroll || 0,
4255
+ isChunkedText: model.isChunkedText,
4256
+ totalLineCount: model.totalLineCount,
4257
+ sourceFilePath: model.sourceFilePath,
4258
+ metadata: model.metadata ? { ...model.metadata } : {}
4259
+ };
4260
+
4261
+ // Remove old node
4262
+ removeNode(module, nodeId);
4263
+
4264
+ // Ensure node is fully removed from the map
4265
+ if (module.nodeObjectsById.has(nodeId)) {
4266
+ console.warn(`[MindMapNodes] Node ${nodeId} still in map after removeNode, force deleting...`);
4267
+ module.nodeObjectsById.delete(nodeId);
4268
+ }
4269
+
4270
+ // Wait for a frame to ensure cleanup is complete
4271
+ await waitForNonVisualFrame();
4272
+
4273
+ // Add new node with updated content type
4274
+ log(`[MindMapNodes] Re-adding node ${nodeId} with type ${newContentType}`);
4275
+ await addNode(module, nodeData);
4276
+
4277
+ // If converting to note, switch to CSS3D mode for editing
4278
+ if (newContentType === 'note') {
4279
+ // Wait for node to be fully created
4280
+ await new Promise(resolve => setTimeout(resolve, 150));
4281
+
4282
+ const newEntry = module.nodeObjectsById.get(nodeId);
4283
+ log(`[MindMapNodes] Checking for CSS3D object: exists=${!!newEntry}, hasCssObject=${!!newEntry?.cssObject}`);
4284
+
4285
+ if (newEntry && newEntry.cssObject) {
4286
+ await switchToCss3D(module, nodeId);
4287
+ // Focus the textarea
4288
+ const textarea = newEntry.cssObject.element?.querySelector('textarea');
4289
+ if (textarea) {
4290
+ textarea.focus();
4291
+ log(`[MindMapNodes] Focused textarea for node ${nodeId}`);
4292
+ }
4293
+ } else {
4294
+ console.warn(`[MindMapNodes] CSS3D object not ready for node ${nodeId}`);
4295
+ }
4296
+ }
4297
+
4298
+ log(`[MindMapNodes] Node ${nodeId} content type updated successfully`);
4299
+ }
4300
+ // ▲▲▲ [New] ▲▲▲
4301
+
4302
+ // ▼▼▼ [New] Update Troika scroll for code nodes ▼▼▼
4303
+ /**
4304
+ * Update scroll position for Troika code nodes
4305
+ * @param {Object} module - MindMap module
4306
+ * @param {Object} nodeEntry - Node entry from nodeObjectsById
4307
+ * @param {number} scrollOffset - New scroll offset
4308
+ */
4309
+ function updateTroikaScroll(module, nodeEntry, scrollOffset) {
4310
+ if (!nodeEntry || !nodeEntry.glObject) return;
4311
+
4312
+ // Find Troika text mesh in glObject
4313
+ const troikaText = nodeEntry.glObject.userData?.troikaText;
4314
+ if (!troikaText || !troikaText.userData) return;
4315
+
4316
+ const ud = troikaText.userData;
4317
+ const padding = ud.padding || 16;
4318
+ const visibleHeight = ud.visibleHeight || (nodeEntry.model.height - padding * 2);
4319
+ const textWidth = ud.textWidth || (nodeEntry.model.width - padding * 2);
4320
+ const initialY = ud.initialY || -padding;
4321
+
4322
+ // Update scroll state
4323
+ ud.scrollY = Math.max(0, scrollOffset);
4324
+
4325
+ // 1. Move text position UP by scrollY
4326
+ troikaText.position.y = initialY + ud.scrollY;
4327
+
4328
+ // 2. Shift clipRect DOWN to compensate (keep visible window at node top)
4329
+ const clipTop = -ud.scrollY;
4330
+ const clipBottom = -ud.scrollY - visibleHeight;
4331
+ troikaText.clipRect = [0, clipBottom, textWidth, clipTop];
4332
+
4333
+ troikaText.sync();
4334
+
4335
+ // 3. Update scrollbar thumb position (ShapeGeometry anchor is top-left, not center)
4336
+ const scrollbarThumb = nodeEntry.glObject.getObjectByName('scrollbarThumb');
4337
+ if (scrollbarThumb && nodeEntry.model.maxScroll > 0) {
4338
+ const thumbData = scrollbarThumb.userData;
4339
+ const scrollRatio = ud.scrollY / nodeEntry.model.maxScroll;
4340
+ const thumbTravel = thumbData.trackHeight - thumbData.thumbHeight;
4341
+ const thumbY = -thumbData.topMargin - (scrollRatio * thumbTravel);
4342
+ scrollbarThumb.position.y = thumbY;
4343
+ }
4344
+
4345
+ log(`[updateTroikaScroll] Node ${nodeEntry.model.id}: scrollY=${ud.scrollY}, clipRect=[0, ${clipBottom.toFixed(0)}, ${textWidth}, ${clipTop.toFixed(0)}]`);
4346
+ }
4347
+ // ▲▲▲ [New] ▲▲▲
4348
+
4349
+ // Expose MindMapNodes globally
4350
+ window.MindMapNodes = {
4351
+ // Module state (helper modules need access)
4352
+ get textureCache() { return textureCache; },
4353
+ set textureCache(value) {
4354
+ textureCache = value instanceof Map ? value : new Map();
4355
+ totalTextureMemoryBytes = estimateTextureCacheMemoryBytes(textureCache);
4356
+ },
4357
+ get imageCache() { return imageCache; },
4358
+ set imageCache(value) {
4359
+ imageCache = value instanceof Map ? value : new Map();
4360
+ },
4361
+ get nodeRenderLocks() { return nodeRenderLocks; },
4362
+ set nodeRenderLocks(value) {
4363
+ nodeRenderLocks = value instanceof Map ? value : new Map();
4364
+ },
4365
+ get pendingNodeUpdates() { return pendingNodeUpdates; },
4366
+ set pendingNodeUpdates(value) {
4367
+ pendingNodeUpdates = value instanceof Map ? value : new Map();
4368
+ },
4369
+ get totalTextureMemoryBytes() { return totalTextureMemoryBytes; },
4370
+ set totalTextureMemoryBytes(value) {
4371
+ const nextValue = Number(value);
4372
+ totalTextureMemoryBytes = Number.isFinite(nextValue)
4373
+ ? Math.max(0, nextValue)
4374
+ : estimateTextureCacheMemoryBytes(textureCache);
4375
+ },
4376
+
4377
+ // Public API
4378
+ addNode: addNode,
4379
+ removeNode: removeNode,
4380
+ removeNodes: removeNodes,
4381
+ updateNodeContent: updateNodeContent,
4382
+ clearMultiSelection: clearMultiSelection,
4383
+ refreshLodSelectionState: refreshLodSelectionState,
4384
+ bringNodeToFront: bringNodeToFront,
4385
+ updateNodeSelectionStyle: updateNodeSelectionStyle,
4386
+ applySingleSelection: applySingleSelection,
4387
+ resetState: resetState,
4388
+ captureDeleteSnapshot: captureDeleteSnapshot,
4389
+ // ▼▼▼ [New] Expose helper function for external callers ▼▼▼
4390
+ getNodeWorldBounds: getNodeWorldBounds,
4391
+ updateNodeSpatialGrid: updateNodeSpatialGrid,
4392
+ applyImageCoverUv: applyImageCoverUv,
4393
+ syncImageNodeBodyTexture: syncImageNodeBodyTexture,
4394
+ updateAnimatedImageStatusTextures: updateAnimatedImageStatusTextures,
4395
+ applyCanonicalNodeResponse: applyCanonicalNodeResponse,
4396
+ isGeneratedImageNodeModel: isGeneratedImageNodeModel,
4397
+ isAnimatedGifImageNodeModel: isAnimatedGifImageNodeModel,
4398
+ getNodeImageAssetUrls: getNodeImageAssetUrls,
4399
+ setNodeImageOriginalUrl: setNodeImageOriginalUrl,
4400
+ setNodeImagePreviewUrl: setNodeImagePreviewUrl,
4401
+ clearImageBodyTexture: clearImageBodyTexture,
4402
+ shouldUseCssImageLoadingOverlay: shouldUseCssImageLoadingOverlay,
4403
+ shouldShowImageBodyInCssMode: shouldShowImageBodyInCssMode,
4404
+ ensureNodeImageUsesPreviewResponse: ensureNodeImageUsesPreviewResponse,
4405
+ markNodeImageAssetMissing: markNodeImageAssetMissing,
4406
+ syncImageMidLodDecorations: syncImageMidLodDecorations,
4407
+ setNodeScrollbarVisibility: setNodeScrollbarVisibility,
4408
+ estimateTextureCacheMemoryBytes: estimateTextureCacheMemoryBytes,
4409
+ getCacheStats: getRuntimeCacheStats,
4410
+ replaceRuntimeCaches: replaceRuntimeCaches,
4411
+ // ▲▲▲ [New] ▲▲▲
4412
+ // ▼▼▼ [New] Troika scroll update ▼▼▼
4413
+ updateTroikaScroll: updateTroikaScroll,
4414
+ // ▲▲▲ [New] ▲▲▲
4415
+ switchToCss3D: switchToCss3D,
4416
+ switchToWebGL: switchToWebGL,
4417
+ // ▼▼▼ [New] Convert node content type ▼▼▼
4418
+ updateNodeContentType: updateNodeContentType,
4419
+ // ▲▲▲ [New] ▲▲▲
4420
+
4421
+ // ▼▼▼ [New] Text Render Mode API ▼▼▼
4422
+ /**
4423
+ * Get current text rendering mode
4424
+ * @returns {'canvas'|'troika'}
4425
+ */
4426
+ getTextRenderMode: () => TEXT_RENDER_MODE,
4427
+
4428
+ /**
4429
+ * Set text rendering mode
4430
+ * @param {'canvas'|'troika'} mode
4431
+ */
4432
+ setTextRenderMode: (mode) => {
4433
+ if (mode === 'canvas' || mode === 'troika') {
4434
+ TEXT_RENDER_MODE = mode;
4435
+ console.log(`[MindMapNodes] Text render mode changed to: ${mode}`);
4436
+ } else {
4437
+ console.warn(`[MindMapNodes] Invalid render mode: ${mode}. Use 'canvas' or 'troika'.`);
4438
+ }
4439
+ },
4440
+
4441
+ /**
4442
+ * Check if Troika rendering is available
4443
+ * @returns {boolean}
4444
+ */
4445
+ isTroikaAvailable: () => window.MindMapTroikaText?.isTroikaReady() || false,
4446
+ // ▲▲▲ [New] ▲▲▲
4447
+
4448
+ // ▼▼▼ [New] Word wrap toggle for code nodes ▼▼▼
4449
+ /**
4450
+ * Update word wrap setting for a code node (Troika text)
4451
+ * @param {Object} module - MindMap module
4452
+ * @param {string} nodeId - Node ID
4453
+ * @param {boolean} isWordWrap - Whether to wrap text
4454
+ */
4455
+ updateNodeWordWrap: (module, nodeId, isWordWrap) => {
4456
+ const nodeEntry = module?.nodeObjectsById?.get(nodeId);
4457
+ if (!nodeEntry) {
4458
+ console.warn(`[MindMapNodes] updateNodeWordWrap: Node ${nodeId} not found`);
4459
+ return;
4460
+ }
4461
+
4462
+ // ▼▼▼ [FIX] CSS3D Mode Handling (Priority) ▼▼▼
4463
+ if (nodeEntry.cssObject && nodeEntry.cssObject.element) {
4464
+ // Try to find the content element (code-body for code nodes, or generic response)
4465
+ const textElement = nodeEntry.cssObject.element.querySelector('.code-body') ||
4466
+ nodeEntry.cssObject.element.querySelector('.node-response') ||
4467
+ nodeEntry.cssObject.element.querySelector('textarea');
4468
+
4469
+ if (textElement) {
4470
+ console.log(`[MindMapNodes] Updating CSS3D word wrap for ${nodeId}: ${isWordWrap}`);
4471
+ if (isWordWrap) {
4472
+ textElement.style.whiteSpace = 'pre-wrap';
4473
+ textElement.style.wordWrap = 'break-word'; // legacy support
4474
+ textElement.style.overflowWrap = 'break-word';
4475
+ textElement.style.overflowX = 'hidden'; // No horizontal scroll needed
4476
+ } else {
4477
+ textElement.style.whiteSpace = 'pre';
4478
+ textElement.style.wordWrap = 'normal';
4479
+ textElement.style.overflowWrap = 'normal';
4480
+ textElement.style.overflowX = 'auto'; // Enable horizontal scroll
4481
+ }
4482
+ // Continue to Troika check in case it's a hybrid node, or return?
4483
+ // Usually returning here is safe if we are purely in CSS3D mode for Code nodes.
4484
+ // But if fallback WebGL text exists, we might want to update that too (though it's likely hidden).
4485
+ // Let's allow fall-through but don't warn if Troika is missing.
4486
+ }
4487
+ }
4488
+ // ▲▲▲ [FIX] ▲▲▲
4489
+
4490
+ // Find Troika text mesh in the GL object
4491
+ const glObject = nodeEntry.glObject;
4492
+ if (!glObject) return;
4493
+
4494
+ let troikaText = null;
4495
+ glObject.traverse((child) => {
4496
+ if (child.isTroikaText) {
4497
+ troikaText = child;
4498
+ }
4499
+ });
4500
+
4501
+ if (!troikaText) {
4502
+ // If we already handled CSS3D, don't warn about missing Troika
4503
+ if (!nodeEntry.cssObject) {
4504
+ console.warn(`[MindMapNodes] updateNodeWordWrap: No Troika text found for ${nodeId}`);
4505
+ }
4506
+ if (window.MindMapTextOverlayV2?.invalidateNode) {
4507
+ window.MindMapTextOverlayV2.invalidateNode(module, nodeId, 'layout');
4508
+ }
4509
+ return;
4510
+ }
4511
+
4512
+ // Update Troika text properties
4513
+ const padding = 16;
4514
+ const nodeWidth = nodeEntry.model.width || 470;
4515
+
4516
+ if (isWordWrap) {
4517
+ // Enable word wrap
4518
+ troikaText.whiteSpace = 'pre-wrap';
4519
+ troikaText.overflowWrap = 'break-word';
4520
+ troikaText.maxWidth = nodeWidth - (padding * 2);
4521
+ } else {
4522
+ // Disable word wrap - allow horizontal overflow
4523
+ troikaText.whiteSpace = 'pre';
4524
+ troikaText.overflowWrap = 'normal';
4525
+ troikaText.maxWidth = Infinity;
4526
+ }
4527
+
4528
+ // Trigger re-render
4529
+ troikaText.sync();
4530
+
4531
+ if (window.MindMapTextOverlayV2?.invalidateNode) {
4532
+ window.MindMapTextOverlayV2.invalidateNode(module, nodeId, 'layout');
4533
+ }
4534
+
4535
+ console.log(`[MindMapNodes] Troika Word wrap updated for ${nodeId}: ${isWordWrap}, maxWidth: ${troikaText.maxWidth}`);
4536
+ }
4537
+ // ▲▲▲ [New] ▲▲▲
4538
+ };
4539
+
4540
+ log('✅ mind-map-nodes.js loaded (Refactored Core/Orchestrator)');
4541
+ })();