@mindexec/cli 0.2.384 → 0.2.385

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