@mindexec/cli 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (232) hide show
  1. package/README.md +275 -0
  2. package/codex-runtime.js +960 -0
  3. package/launch-bridge.cjs +162 -0
  4. package/package.json +61 -0
  5. package/port-guard.cjs +232 -0
  6. package/scripts/setup-tree-sitter-grammars.mjs +59 -0
  7. package/server.js +8422 -0
  8. package/start-bridge.bat +32 -0
  9. package/start-bridge.sh +81 -0
  10. package/tree-sitter-grammars/README.md +18 -0
  11. package/tree-sitter-grammars/tree-sitter-c_sharp.wasm +0 -0
  12. package/tree-sitter-grammars/tree-sitter-go.wasm +0 -0
  13. package/tree-sitter-grammars/tree-sitter-java.wasm +0 -0
  14. package/tree-sitter-grammars/tree-sitter-javascript.wasm +0 -0
  15. package/tree-sitter-grammars/tree-sitter-python.wasm +0 -0
  16. package/tree-sitter-grammars/tree-sitter-rust.wasm +0 -0
  17. package/tree-sitter-grammars/tree-sitter-tsx.wasm +0 -0
  18. package/tree-sitter-grammars/tree-sitter-typescript.wasm +0 -0
  19. package/wwwroot/MindExecution.Web.styles.css +3 -0
  20. package/wwwroot/_content/MindExecution.Plugins.Admin/css/admin-dashboard.css +546 -0
  21. package/wwwroot/_content/MindExecution.Plugins.Directory/MindExecution.Plugins.Directory.u7utcng611.bundle.scp.css +7 -0
  22. package/wwwroot/_content/MindExecution.Plugins.Directory/background.png +0 -0
  23. package/wwwroot/_content/MindExecution.Plugins.Directory/directory-manager.js +202 -0
  24. package/wwwroot/_content/MindExecution.Plugins.Directory/exampleJsInterop.js +6 -0
  25. package/wwwroot/_content/MindExecution.Plugins.YouTube/css/youtube-search.css +251 -0
  26. package/wwwroot/_content/MindExecution.Shared/MindExecution.Shared.wsano1j4wp.bundle.scp.css +4 -0
  27. package/wwwroot/_content/MindExecution.Shared/css/admin-dashboard.css +559 -0
  28. package/wwwroot/_content/MindExecution.Shared/css/app.css +1 -0
  29. package/wwwroot/_content/MindExecution.Shared/css/mind-map-overrides.css +2936 -0
  30. package/wwwroot/_content/MindExecution.Shared/fonts/NotoSansKR-Bold.ttf +0 -0
  31. package/wwwroot/_content/MindExecution.Shared/fonts/NotoSansKR-Regular.ttf +0 -0
  32. package/wwwroot/_content/MindExecution.Shared/js/agent-visualization.js +359 -0
  33. package/wwwroot/_content/MindExecution.Shared/js/background-themes.js +1721 -0
  34. package/wwwroot/_content/MindExecution.Shared/js/code-master.js +8316 -0
  35. package/wwwroot/_content/MindExecution.Shared/js/file-system-helper.js +639 -0
  36. package/wwwroot/_content/MindExecution.Shared/js/helpers/InfiniteGridHelper.js +109 -0
  37. package/wwwroot/_content/MindExecution.Shared/js/marked.min.js +69 -0
  38. package/wwwroot/_content/MindExecution.Shared/js/mind-map-core.js +7982 -0
  39. package/wwwroot/_content/MindExecution.Shared/js/mind-map-core.js.backup +1059 -0
  40. package/wwwroot/_content/MindExecution.Shared/js/mind-map-css3d-manager.js +15803 -0
  41. package/wwwroot/_content/MindExecution.Shared/js/mind-map-dev-guards.js +325 -0
  42. package/wwwroot/_content/MindExecution.Shared/js/mind-map-dnd.js +1430 -0
  43. package/wwwroot/_content/MindExecution.Shared/js/mind-map-dnd.js.bak +434 -0
  44. package/wwwroot/_content/MindExecution.Shared/js/mind-map-glow-shader.js +260 -0
  45. package/wwwroot/_content/MindExecution.Shared/js/mind-map-interactions.js +7640 -0
  46. package/wwwroot/_content/MindExecution.Shared/js/mind-map-lod-plan-worker.js +160 -0
  47. package/wwwroot/_content/MindExecution.Shared/js/mind-map-lod-renderer.js +9923 -0
  48. package/wwwroot/_content/MindExecution.Shared/js/mind-map-logic-workers.js +977 -0
  49. package/wwwroot/_content/MindExecution.Shared/js/mind-map-menu-manager.js +1431 -0
  50. package/wwwroot/_content/MindExecution.Shared/js/mind-map-multi-select.js +1716 -0
  51. package/wwwroot/_content/MindExecution.Shared/js/mind-map-node-search-worker.js +553 -0
  52. package/wwwroot/_content/MindExecution.Shared/js/mind-map-nodes.js +4541 -0
  53. package/wwwroot/_content/MindExecution.Shared/js/mind-map-object-manager.js +489 -0
  54. package/wwwroot/_content/MindExecution.Shared/js/mind-map-object-manager.js.backup +372 -0
  55. package/wwwroot/_content/MindExecution.Shared/js/mind-map-pipeline.js +2075 -0
  56. package/wwwroot/_content/MindExecution.Shared/js/mind-map-text-lod-system.js +646 -0
  57. package/wwwroot/_content/MindExecution.Shared/js/mind-map-text-overlay-v2.js +4323 -0
  58. package/wwwroot/_content/MindExecution.Shared/js/mind-map-texture-factory.js +2260 -0
  59. package/wwwroot/_content/MindExecution.Shared/js/mind-map-texture-factory.js.backup +1258 -0
  60. package/wwwroot/_content/MindExecution.Shared/js/mind-map-visibility-worker.js +890 -0
  61. package/wwwroot/_content/MindExecution.Shared/js/mindmap-toolbar.js +594 -0
  62. package/wwwroot/_content/MindExecution.Shared/js/native-drop-handler.js +170 -0
  63. package/wwwroot/_content/MindExecution.Shared/js/plan-master.js +788 -0
  64. package/wwwroot/_content/MindExecution.Shared/js/renderers/CSS3DRenderer.js +50 -0
  65. package/wwwroot/_content/MindExecution.Shared/js/texture-worker-manager.js +188 -0
  66. package/wwwroot/_content/MindExecution.Shared/js/texture-worker.js +208 -0
  67. package/wwwroot/_content/MindExecution.Shared/js/three.min.js +6 -0
  68. package/wwwroot/_content/MindExecution.Shared/js/titlebar-handler.js +191 -0
  69. package/wwwroot/_content/MindExecution.Shared/js/token-manager.js +37 -0
  70. package/wwwroot/_content/MindExecution.Shared/js/token-worker.js +28 -0
  71. package/wwwroot/_content/MindExecution.Shared/js/troika-bundle.js +5626 -0
  72. package/wwwroot/_content/MindExecution.Shared/js/troika-bundle.js.map +7 -0
  73. package/wwwroot/_content/MindExecution.Shared/lib/font-awesome/css/all.min.css +9 -0
  74. package/wwwroot/_content/MindExecution.Shared/lib/font-awesome/webfonts/fa-brands-400.ttf +0 -0
  75. package/wwwroot/_content/MindExecution.Shared/lib/font-awesome/webfonts/fa-brands-400.woff2 +0 -0
  76. package/wwwroot/_content/MindExecution.Shared/lib/font-awesome/webfonts/fa-regular-400.ttf +0 -0
  77. package/wwwroot/_content/MindExecution.Shared/lib/font-awesome/webfonts/fa-regular-400.woff2 +0 -0
  78. package/wwwroot/_content/MindExecution.Shared/lib/font-awesome/webfonts/fa-solid-900.ttf +0 -0
  79. package/wwwroot/_content/MindExecution.Shared/lib/font-awesome/webfonts/fa-solid-900.woff2 +0 -0
  80. package/wwwroot/_content/MindExecution.Shared/models/all-MiniLM-L6-v2-quantized.onnx +0 -0
  81. package/wwwroot/_content/MindExecution.Shared/models/vocab.txt +30522 -0
  82. package/wwwroot/_framework/Google.Protobuf.9h59ukbel7.dll +0 -0
  83. package/wwwroot/_framework/Markdig.d1j7v41cl1.dll +0 -0
  84. package/wwwroot/_framework/MessagePack.Annotations.l6qv48kgpt.dll +0 -0
  85. package/wwwroot/_framework/MessagePack.eqoptzx9d5.dll +0 -0
  86. package/wwwroot/_framework/Microsoft.AspNetCore.Authorization.k7dsih5y5g.dll +0 -0
  87. package/wwwroot/_framework/Microsoft.AspNetCore.Components.6nyje9sa0g.dll +0 -0
  88. package/wwwroot/_framework/Microsoft.AspNetCore.Components.Authorization.iycd6unprw.dll +0 -0
  89. package/wwwroot/_framework/Microsoft.AspNetCore.Components.Web.487u3twia4.dll +0 -0
  90. package/wwwroot/_framework/Microsoft.AspNetCore.Components.WebAssembly.d0gcnmlxxz.dll +0 -0
  91. package/wwwroot/_framework/Microsoft.AspNetCore.Metadata.h4yevl9adi.dll +0 -0
  92. package/wwwroot/_framework/Microsoft.CSharp.qrvp77qmhs.dll +0 -0
  93. package/wwwroot/_framework/Microsoft.Data.Sqlite.jdlxgv2jtg.dll +0 -0
  94. package/wwwroot/_framework/Microsoft.EntityFrameworkCore.4gjazp7kjf.dll +0 -0
  95. package/wwwroot/_framework/Microsoft.EntityFrameworkCore.Abstractions.gocudnvz7b.dll +0 -0
  96. package/wwwroot/_framework/Microsoft.EntityFrameworkCore.Relational.lt4rsvinuo.dll +0 -0
  97. package/wwwroot/_framework/Microsoft.EntityFrameworkCore.Sqlite.69luj0fa9r.dll +0 -0
  98. package/wwwroot/_framework/Microsoft.Extensions.Caching.Abstractions.364t4jh3zz.dll +0 -0
  99. package/wwwroot/_framework/Microsoft.Extensions.Caching.Memory.izlxhpzosu.dll +0 -0
  100. package/wwwroot/_framework/Microsoft.Extensions.Configuration.8zq7hh41o7.dll +0 -0
  101. package/wwwroot/_framework/Microsoft.Extensions.Configuration.Abstractions.8if74zs6ea.dll +0 -0
  102. package/wwwroot/_framework/Microsoft.Extensions.Configuration.Json.duvlngw8i0.dll +0 -0
  103. package/wwwroot/_framework/Microsoft.Extensions.DependencyInjection.Abstractions.t2hh9kvx0o.dll +0 -0
  104. package/wwwroot/_framework/Microsoft.Extensions.DependencyInjection.n4tg99oy8l.dll +0 -0
  105. package/wwwroot/_framework/Microsoft.Extensions.DependencyModel.h0d06ixk3e.dll +0 -0
  106. package/wwwroot/_framework/Microsoft.Extensions.Logging.Abstractions.rl32bkx2sd.dll +0 -0
  107. package/wwwroot/_framework/Microsoft.Extensions.Logging.dlht1xei0t.dll +0 -0
  108. package/wwwroot/_framework/Microsoft.Extensions.Options.qeunebioml.dll +0 -0
  109. package/wwwroot/_framework/Microsoft.Extensions.Primitives.18cr6vnuuz.dll +0 -0
  110. package/wwwroot/_framework/Microsoft.IO.RecyclableMemoryStream.r915vovvw4.dll +0 -0
  111. package/wwwroot/_framework/Microsoft.IdentityModel.Abstractions.1ejljk3erv.dll +0 -0
  112. package/wwwroot/_framework/Microsoft.IdentityModel.JsonWebTokens.1596zr8gne.dll +0 -0
  113. package/wwwroot/_framework/Microsoft.IdentityModel.Logging.229uyvpgio.dll +0 -0
  114. package/wwwroot/_framework/Microsoft.IdentityModel.Tokens.9sibtajc9f.dll +0 -0
  115. package/wwwroot/_framework/Microsoft.JSInterop.17lq4j1j7g.dll +0 -0
  116. package/wwwroot/_framework/Microsoft.JSInterop.WebAssembly.ryia5gxiad.dll +0 -0
  117. package/wwwroot/_framework/Microsoft.ML.OnnxRuntime.w9deo1m5ss.dll +0 -0
  118. package/wwwroot/_framework/Microsoft.ML.Tokenizers.cm2vuv2z61.dll +0 -0
  119. package/wwwroot/_framework/Microsoft.NET.StringTools.3qbrf4v2ki.dll +0 -0
  120. package/wwwroot/_framework/MimeMapping.og9ys58ylm.dll +0 -0
  121. package/wwwroot/_framework/MindExecution.Core.1q1trifbuu.dll +0 -0
  122. package/wwwroot/_framework/MindExecution.Kernel.gwwc40sc45.dll +0 -0
  123. package/wwwroot/_framework/MindExecution.Plugins.Admin.0jgrn1sckv.dll +0 -0
  124. package/wwwroot/_framework/MindExecution.Plugins.Business.13mme2qcag.dll +0 -0
  125. package/wwwroot/_framework/MindExecution.Plugins.Concept.dfp2mdt45q.dll +0 -0
  126. package/wwwroot/_framework/MindExecution.Plugins.Directory.3w4t6n3se0.dll +0 -0
  127. package/wwwroot/_framework/MindExecution.Plugins.PlanMaster.s0qpntz420.dll +0 -0
  128. package/wwwroot/_framework/MindExecution.Plugins.YouTube.iu11fq8d16.dll +0 -0
  129. package/wwwroot/_framework/MindExecution.Shared.7j27dcqnrc.dll +0 -0
  130. package/wwwroot/_framework/MindExecution.Web.pq1ty8ov2v.dll +0 -0
  131. package/wwwroot/_framework/Newtonsoft.Json.a56zs13vug.dll +0 -0
  132. package/wwwroot/_framework/SQLitePCLRaw.batteries_v2.rrd1nzawpp.dll +0 -0
  133. package/wwwroot/_framework/SQLitePCLRaw.core.1dxloztpfz.dll +0 -0
  134. package/wwwroot/_framework/SQLitePCLRaw.provider.e_sqlite3.oekyzl53i1.dll +0 -0
  135. package/wwwroot/_framework/Supabase.Core.s1pkj4aj0l.dll +0 -0
  136. package/wwwroot/_framework/Supabase.Functions.qz4nu782sg.dll +0 -0
  137. package/wwwroot/_framework/Supabase.Gotrue.twah27pkik.dll +0 -0
  138. package/wwwroot/_framework/Supabase.Postgrest.gmuuv369ih.dll +0 -0
  139. package/wwwroot/_framework/Supabase.Realtime.ox3kchdy3w.dll +0 -0
  140. package/wwwroot/_framework/Supabase.Storage.fnjnepaowr.dll +0 -0
  141. package/wwwroot/_framework/Supabase.azmaw5pgcz.dll +0 -0
  142. package/wwwroot/_framework/System.Collections.Concurrent.y1zmvuyipi.dll +0 -0
  143. package/wwwroot/_framework/System.Collections.Immutable.ug3j698qms.dll +0 -0
  144. package/wwwroot/_framework/System.Collections.NonGeneric.h66hj3863h.dll +0 -0
  145. package/wwwroot/_framework/System.Collections.Specialized.umr3y27ntj.dll +0 -0
  146. package/wwwroot/_framework/System.Collections.x53e19vfsj.dll +0 -0
  147. package/wwwroot/_framework/System.ComponentModel.Annotations.tz6gnt4ebt.dll +0 -0
  148. package/wwwroot/_framework/System.ComponentModel.Primitives.j7tiphu4rg.dll +0 -0
  149. package/wwwroot/_framework/System.ComponentModel.TypeConverter.ujlztox1gx.dll +0 -0
  150. package/wwwroot/_framework/System.ComponentModel.x9xz0ojfb6.dll +0 -0
  151. package/wwwroot/_framework/System.Console.ijzpqmj7ne.dll +0 -0
  152. package/wwwroot/_framework/System.Data.Common.1r0sqffq1p.dll +0 -0
  153. package/wwwroot/_framework/System.Diagnostics.DiagnosticSource.9upoqwq09o.dll +0 -0
  154. package/wwwroot/_framework/System.Diagnostics.Process.m99azzntjm.dll +0 -0
  155. package/wwwroot/_framework/System.Diagnostics.TraceSource.pl7wv26myr.dll +0 -0
  156. package/wwwroot/_framework/System.Diagnostics.Tracing.crlhfx6tut.dll +0 -0
  157. package/wwwroot/_framework/System.Drawing.Primitives.22e4y9ikq9.dll +0 -0
  158. package/wwwroot/_framework/System.Drawing.mi7d8hwowb.dll +0 -0
  159. package/wwwroot/_framework/System.Formats.Asn1.jx23sjiqnn.dll +0 -0
  160. package/wwwroot/_framework/System.IO.Compression.6fyoii3uej.dll +0 -0
  161. package/wwwroot/_framework/System.IO.Pipelines.vg77t4cd4d.dll +0 -0
  162. package/wwwroot/_framework/System.IdentityModel.Tokens.Jwt.t67es60z5b.dll +0 -0
  163. package/wwwroot/_framework/System.Linq.1bkoxlqgmq.dll +0 -0
  164. package/wwwroot/_framework/System.Linq.Expressions.24xqiypwdt.dll +0 -0
  165. package/wwwroot/_framework/System.Linq.Queryable.hvd01d6rsa.dll +0 -0
  166. package/wwwroot/_framework/System.Memory.8dx3lwgym4.dll +0 -0
  167. package/wwwroot/_framework/System.Net.Http.Json.3mhdm9l1rf.dll +0 -0
  168. package/wwwroot/_framework/System.Net.Http.eitrz660my.dll +0 -0
  169. package/wwwroot/_framework/System.Net.NetworkInformation.3pkuofcv9r.dll +0 -0
  170. package/wwwroot/_framework/System.Net.Ping.8clj5pklrp.dll +0 -0
  171. package/wwwroot/_framework/System.Net.Primitives.qrp4wcjz1p.dll +0 -0
  172. package/wwwroot/_framework/System.Net.WebSockets.Client.2u6pv01g69.dll +0 -0
  173. package/wwwroot/_framework/System.Net.WebSockets.qp6u31zvm5.dll +0 -0
  174. package/wwwroot/_framework/System.Numerics.Tensors.0c7z4mt3on.dll +0 -0
  175. package/wwwroot/_framework/System.Numerics.Vectors.kc7ufp2j4l.dll +0 -0
  176. package/wwwroot/_framework/System.ObjectModel.qv82fot1ib.dll +0 -0
  177. package/wwwroot/_framework/System.Private.CoreLib.rkafq04oma.dll +0 -0
  178. package/wwwroot/_framework/System.Private.Uri.t9542hmr6j.dll +0 -0
  179. package/wwwroot/_framework/System.Private.Xml.Linq.n8n3ptrbwu.dll +0 -0
  180. package/wwwroot/_framework/System.Private.Xml.rxd3tytisn.dll +0 -0
  181. package/wwwroot/_framework/System.Reactive.t3fuon548l.dll +0 -0
  182. package/wwwroot/_framework/System.Reflection.Emit.9tjhp6y0j3.dll +0 -0
  183. package/wwwroot/_framework/System.Reflection.Emit.ILGeneration.stxyk8zoo1.dll +0 -0
  184. package/wwwroot/_framework/System.Reflection.Emit.Lightweight.6xrd5v8vg0.dll +0 -0
  185. package/wwwroot/_framework/System.Reflection.Primitives.wgn8fpwwvv.dll +0 -0
  186. package/wwwroot/_framework/System.Runtime.InteropServices.JavaScript.sliym526xh.dll +0 -0
  187. package/wwwroot/_framework/System.Runtime.InteropServices.RuntimeInformation.oji7zut14z.dll +0 -0
  188. package/wwwroot/_framework/System.Runtime.InteropServices.te07xr2we9.dll +0 -0
  189. package/wwwroot/_framework/System.Runtime.Intrinsics.507y4h8nzq.dll +0 -0
  190. package/wwwroot/_framework/System.Runtime.Loader.v7gk4bse0k.dll +0 -0
  191. package/wwwroot/_framework/System.Runtime.Numerics.eqy5xjv3nd.dll +0 -0
  192. package/wwwroot/_framework/System.Runtime.Serialization.Formatters.zpkrub8lab.dll +0 -0
  193. package/wwwroot/_framework/System.Runtime.Serialization.Primitives.vhkpnbxjip.dll +0 -0
  194. package/wwwroot/_framework/System.Runtime.jn319d5nyg.dll +0 -0
  195. package/wwwroot/_framework/System.Security.Claims.0ztig1q9vo.dll +0 -0
  196. package/wwwroot/_framework/System.Security.Cryptography.vttizqc9ho.dll +0 -0
  197. package/wwwroot/_framework/System.Text.Encoding.Extensions.utdd47ny8f.dll +0 -0
  198. package/wwwroot/_framework/System.Text.Encodings.Web.wah8r1zoe0.dll +0 -0
  199. package/wwwroot/_framework/System.Text.Json.kxlfxj0wrs.dll +0 -0
  200. package/wwwroot/_framework/System.Text.RegularExpressions.dbqn58klox.dll +0 -0
  201. package/wwwroot/_framework/System.Threading.42ao9vi047.dll +0 -0
  202. package/wwwroot/_framework/System.Threading.Channels.hfa7j0uv2w.dll +0 -0
  203. package/wwwroot/_framework/System.Threading.Thread.caul0pdqul.dll +0 -0
  204. package/wwwroot/_framework/System.Transactions.Local.fimi2hamzo.dll +0 -0
  205. package/wwwroot/_framework/System.Web.HttpUtility.gq8yz50p2e.dll +0 -0
  206. package/wwwroot/_framework/System.Xml.Linq.kitin4zjoj.dll +0 -0
  207. package/wwwroot/_framework/System.Xml.ReaderWriter.kzvw3qgxb0.dll +0 -0
  208. package/wwwroot/_framework/System.Xml.XDocument.c539ki6cuq.dll +0 -0
  209. package/wwwroot/_framework/System.m05i39uvk9.dll +0 -0
  210. package/wwwroot/_framework/Websocket.Client.vapounvmnl.dll +0 -0
  211. package/wwwroot/_framework/blazor.boot.json +305 -0
  212. package/wwwroot/_framework/blazor.webassembly.js +1 -0
  213. package/wwwroot/_framework/dotnet.js +4 -0
  214. package/wwwroot/_framework/dotnet.native.vz0adxojrz.wasm +0 -0
  215. package/wwwroot/_framework/dotnet.native.xsn1d6x2kd.js +16 -0
  216. package/wwwroot/_framework/dotnet.runtime.dstopyvqzi.js +4 -0
  217. package/wwwroot/_framework/icudt_CJK.tjcz0u77k5.dat +0 -0
  218. package/wwwroot/_framework/icudt_EFIGS.tptq2av103.dat +0 -0
  219. package/wwwroot/_framework/icudt_no_CJK.lfu7j35m59.dat +0 -0
  220. package/wwwroot/_framework/netstandard.0xet7jg7ky.dll +0 -0
  221. package/wwwroot/_headers +40 -0
  222. package/wwwroot/_redirects +1 -0
  223. package/wwwroot/appsettings.json +71 -0
  224. package/wwwroot/icon-192.png +0 -0
  225. package/wwwroot/icon-512.png +0 -0
  226. package/wwwroot/index.html +710 -0
  227. package/wwwroot/js/marketing-tool.js +180 -0
  228. package/wwwroot/manifest.webmanifest +22 -0
  229. package/wwwroot/robots.txt +4 -0
  230. package/wwwroot/service-worker-assets.js +857 -0
  231. package/wwwroot/service-worker.js +33 -0
  232. package/wwwroot/sitemap.xml +27 -0
@@ -0,0 +1,2075 @@
1
+ // File name: X:\Dev@Work\MindExecution\Client\wwwroot\js\mind-map-pipeline.js
2
+ // [Refactor] Owns the async rendering queue and node resizing logic.
3
+ (function () {
4
+ 'use strict';
5
+
6
+ const CSS3D_WRAPPER_TRANSITION = 'box-shadow .22s ease, filter .22s ease';
7
+ const CSS3D_WRAPPER_WEBKIT_TRANSITION = '-webkit-box-shadow .22s ease, -webkit-filter .22s ease';
8
+
9
+ // ▼▼▼ [Changed] Add state variables to switch from debounce to throttle ▼▼▼
10
+ const debounceTimers = new Map();
11
+ const scrollUpdateThrottleTimers = new Map();
12
+ const lastScrollCall = new Map();
13
+ const resizeThrottleTimers = new Map(); // [New] throttle for texture updates during resize
14
+ const lastResizeUpdateCall = new Map(); // [New] last resize-update timestamp
15
+ const THROTTLE_INTERVAL = 16; // 16ms -> ~60fps
16
+ const RESIZE_HANDLE_SIZE = 16;
17
+ const ENABLE_WEBGL_GLOW = false;
18
+ function isDocumentHidden() {
19
+ return typeof document !== 'undefined' && document.visibilityState === 'hidden';
20
+ }
21
+
22
+ function scheduleNonVisualFrame(callback, hiddenDelay = 16) {
23
+ if (typeof requestAnimationFrame === 'function' && !isDocumentHidden()) {
24
+ return requestAnimationFrame(callback);
25
+ }
26
+
27
+ return setTimeout(() => callback(typeof performance !== 'undefined' ? performance.now() : Date.now()), hiddenDelay);
28
+ }
29
+
30
+ // ▼▼▼ [Perf] Render queue (time-sliced) ▼▼▼
31
+ const renderQueue = [];
32
+ const renderQueueSet = new Set();
33
+ let renderQueueScheduled = false;
34
+ let renderModuleRef = null;
35
+ const RENDER_BUDGET_MS = 8;
36
+ let renderSeq = 0;
37
+
38
+ function computeRenderPriority(module, nodeId) {
39
+ const entry = module?.nodeObjectsById?.get(nodeId);
40
+ if (!entry) return 1000000;
41
+
42
+ const obj = entry.glObject || entry.cssObject;
43
+ const visible = obj?.visible === true;
44
+ const pos = obj?.position || { x: 0, y: 0 };
45
+ const cam = module.camera?.position || { x: 0, y: 0 };
46
+
47
+ const dx = pos.x - cam.x;
48
+ const dy = pos.y - cam.y;
49
+ const dist = Math.sqrt(dx * dx + dy * dy);
50
+
51
+ const isSelected = module.multiSelectedNodeIds?.has(nodeId) || module.selectedNodeIdJs === nodeId;
52
+
53
+ // 낮은 값이 우선: 보이는 노드 > 선택 노드 > 카메라 근접
54
+ let priority = dist;
55
+ if (!visible) priority += 100000;
56
+ if (isSelected) priority -= 5000;
57
+
58
+ return priority;
59
+ }
60
+
61
+ function enqueueRender(module, nodeId) {
62
+ if (!nodeId) return;
63
+ renderModuleRef = module;
64
+ if (!renderQueueSet.has(nodeId)) {
65
+ renderQueue.push({ nodeId, priority: computeRenderPriority(module, nodeId), seq: renderSeq++ });
66
+ renderQueueSet.add(nodeId);
67
+ }
68
+ scheduleRenderQueue();
69
+ }
70
+
71
+ function scheduleRenderQueue() {
72
+ if (renderQueueScheduled) return;
73
+ renderQueueScheduled = true;
74
+ scheduleNonVisualFrame(() => processRenderQueueBatch());
75
+ }
76
+
77
+ async function processRenderQueueBatch() {
78
+ const start = performance.now();
79
+
80
+ // 우선순위 정렬 (가시/선택/거리)
81
+ if (renderQueue.length > 1) {
82
+ renderQueue.sort((a, b) => (a.priority - b.priority) || (a.seq - b.seq));
83
+ }
84
+
85
+ while (renderQueue.length > 0) {
86
+ const item = renderQueue.shift();
87
+ const nodeId = item.nodeId;
88
+ renderQueueSet.delete(nodeId);
89
+ if (renderModuleRef) {
90
+ await processRenderQueue(renderModuleRef, nodeId);
91
+ }
92
+ if ((performance.now() - start) >= RENDER_BUDGET_MS) {
93
+ break;
94
+ }
95
+ }
96
+
97
+ renderQueueScheduled = false;
98
+ if (renderQueue.length > 0) {
99
+ scheduleRenderQueue();
100
+ }
101
+ }
102
+ // ▲▲▲ [Perf] ▲▲▲
103
+
104
+ // ▼▼▼ [New] Chunked-node virtual scroll constants ▼▼▼
105
+ const CHUNK_LINE_HEIGHT = 21; // 14px font * 1.5 line-height (matches plain text rendering)
106
+ const CHUNK_SIZE = 100; // lines per chunk (matches C# TextChunkService)
107
+ // ▲▲▲ [New] ▲▲▲
108
+
109
+ // ▼▼▼ [Perf] Deferred disposal to avoid GC/driver spikes ▼▼▼
110
+ const disposeQueue = [];
111
+ let disposeScheduled = false;
112
+ const DISPOSE_BUDGET_MS = 6; // soft budget per frame
113
+
114
+ function enqueueDispose(resource) {
115
+ if (!resource || typeof resource.dispose !== 'function') return;
116
+ disposeQueue.push(resource);
117
+ scheduleDispose();
118
+ }
119
+
120
+ function scheduleDispose() {
121
+ if (disposeScheduled) return;
122
+ disposeScheduled = true;
123
+ if (typeof requestIdleCallback === 'function') {
124
+ requestIdleCallback(processDisposeQueue);
125
+ } else {
126
+ scheduleNonVisualFrame(() => processDisposeQueue());
127
+ }
128
+ }
129
+
130
+ function processDisposeQueue(deadline) {
131
+ const start = performance.now();
132
+ while (disposeQueue.length > 0) {
133
+ const item = disposeQueue.shift();
134
+ try { item.dispose(); } catch { }
135
+
136
+ if (deadline && typeof deadline.timeRemaining === 'function') {
137
+ if (deadline.timeRemaining() < 1) break;
138
+ } else if ((performance.now() - start) >= DISPOSE_BUDGET_MS) {
139
+ break;
140
+ }
141
+ }
142
+
143
+ disposeScheduled = false;
144
+ if (disposeQueue.length > 0) {
145
+ scheduleDispose();
146
+ }
147
+ }
148
+ // ▲▲▲ [Perf] ▲▲▲
149
+
150
+ function removeGlowMesh(group) {
151
+ if (!group?.getObjectByName) return null;
152
+ const glowMesh = group.getObjectByName('glow');
153
+ if (!glowMesh) return null;
154
+
155
+ group.remove(glowMesh);
156
+ enqueueDispose(glowMesh.geometry);
157
+ enqueueDispose(glowMesh.material);
158
+ return null;
159
+ }
160
+
161
+ function applyImageCoverUv(bodyMesh, width, height) {
162
+ if (!bodyMesh) return;
163
+ if (window.MindMapNodes?.applyImageCoverUv) {
164
+ window.MindMapNodes.applyImageCoverUv(bodyMesh, width, height);
165
+ }
166
+ }
167
+
168
+ // ▼▼▼ [Changed] Geometry helper for rounded corners ▼▼▼
169
+ function createRoundedRectGeometry(width, height, radius) {
170
+ const shape = new THREE.Shape();
171
+ const x = -width / 2;
172
+ const y = -height / 2;
173
+
174
+ // Start at bottom-left, go clockwise
175
+ shape.moveTo(x, y + radius);
176
+ shape.quadraticCurveTo(x, y, x + radius, y);
177
+
178
+ shape.lineTo(x + width - radius, y);
179
+ shape.quadraticCurveTo(x + width, y, x + width, y + radius);
180
+
181
+ shape.lineTo(x + width, y + height - radius);
182
+ shape.quadraticCurveTo(x + width, y + height, x + width - radius, y + height);
183
+
184
+ shape.lineTo(x + radius, y + height);
185
+ shape.quadraticCurveTo(x, y + height, x, y + height - radius);
186
+
187
+ shape.lineTo(x, y + radius);
188
+ const points = shape.getPoints();
189
+ return new THREE.BufferGeometry().setFromPoints(points);
190
+ }
191
+ // ▲▲▲ [Changed] ▲▲▲
192
+
193
+ function createRoundedRectFilledGeometry(width, height, radius) {
194
+ const shape = new THREE.Shape();
195
+ const x = 0;
196
+ const y = 0;
197
+ const r = Math.min(radius, width / 2, height / 2);
198
+ const h = -height;
199
+
200
+ shape.moveTo(x + r, y);
201
+ shape.lineTo(x + width - r, y);
202
+ shape.quadraticCurveTo(x + width, y, x + width, y - r);
203
+ shape.lineTo(x + width, y + h + r);
204
+ shape.quadraticCurveTo(x + width, y + h, x + width - r, y + h);
205
+ shape.lineTo(x + r, y + h);
206
+ shape.quadraticCurveTo(x, y + h, x, y + h + r);
207
+ shape.lineTo(x, y - r);
208
+ shape.quadraticCurveTo(x, y, x + r, y);
209
+
210
+ return new THREE.ShapeGeometry(shape);
211
+ }
212
+
213
+ const IMAGE_SELECTION_GLOW_PAD = 18;
214
+ const IMAGE_SELECTION_GLOW_COLOR = 0x1e3a8a;
215
+ const IMAGE_SELECTION_GLOW_OPACITY = 0.18;
216
+
217
+ function syncImageSelectionGlow(group, width, height, baseRenderOrder, isSelected) {
218
+ if (!group) return null;
219
+
220
+ let outline = group.getObjectByName('outline');
221
+ if (!outline) {
222
+ outline = new THREE.Mesh(
223
+ createRoundedRectFilledGeometry(
224
+ width + IMAGE_SELECTION_GLOW_PAD,
225
+ height + IMAGE_SELECTION_GLOW_PAD,
226
+ 20 + (IMAGE_SELECTION_GLOW_PAD * 0.5)
227
+ ),
228
+ new THREE.MeshBasicMaterial({
229
+ color: IMAGE_SELECTION_GLOW_COLOR,
230
+ transparent: true,
231
+ opacity: 0,
232
+ depthWrite: false,
233
+ depthTest: false
234
+ })
235
+ );
236
+ outline.name = 'outline';
237
+ outline.userData = { ...(outline.userData || {}), isImageSelectionGlow: true };
238
+ group.add(outline);
239
+ }
240
+
241
+ outline.geometry?.dispose?.();
242
+ outline.geometry = createRoundedRectFilledGeometry(
243
+ width + IMAGE_SELECTION_GLOW_PAD,
244
+ height + IMAGE_SELECTION_GLOW_PAD,
245
+ 20 + (IMAGE_SELECTION_GLOW_PAD * 0.5)
246
+ );
247
+ outline.position.set(-(IMAGE_SELECTION_GLOW_PAD / 2), IMAGE_SELECTION_GLOW_PAD / 2, 0);
248
+ outline.renderOrder = baseRenderOrder - 1;
249
+ outline.visible = !!isSelected;
250
+
251
+ if (outline.material) {
252
+ outline.material.color.setHex(IMAGE_SELECTION_GLOW_COLOR);
253
+ outline.material.opacity = isSelected ? IMAGE_SELECTION_GLOW_OPACITY : 0;
254
+ outline.material.transparent = true;
255
+ outline.material.depthWrite = false;
256
+ outline.material.depthTest = false;
257
+ outline.material.needsUpdate = true;
258
+ }
259
+
260
+ return outline;
261
+ }
262
+
263
+ // ▼▼▼ [New] Update visible content for virtual scrolling ▼▼▼
264
+ function updateVisibleContent(model, lineHeight, chunkSize) {
265
+ if (!model.isChunkedText || !model._chunks) {
266
+ model._visibleContent = model.response || '';
267
+ model._visibleScrollOffset = model.scrollOffset || 0;
268
+ return;
269
+ }
270
+
271
+ const scrollOffset = model.scrollOffset || 0;
272
+ const nodeHeight = model.height || 400;
273
+
274
+ // Visible line range in the current viewport
275
+ const visibleStartLine = Math.floor(scrollOffset / lineHeight);
276
+ const visibleLineCount = Math.ceil(nodeHeight / lineHeight) + 5; // small buffer
277
+
278
+ // Chunk indices needed for that range
279
+ const startChunkIndex = Math.floor(visibleStartLine / chunkSize) * chunkSize;
280
+ const endChunkIndex = Math.floor((visibleStartLine + visibleLineCount) / chunkSize) * chunkSize;
281
+
282
+ // Merge only the chunks that intersect the current viewport
283
+ let visibleContent = '';
284
+ let firstChunkStart = -1;
285
+ let hasContent = false;
286
+
287
+ for (let chunkStart = startChunkIndex; chunkStart <= endChunkIndex; chunkStart += chunkSize) {
288
+ if (model._chunks[chunkStart]) {
289
+ if (firstChunkStart === -1) firstChunkStart = chunkStart;
290
+ visibleContent += model._chunks[chunkStart] + '\n';
291
+ hasContent = true;
292
+ }
293
+ }
294
+
295
+ // ▼▼▼ [Changed] If no chunks exist in range, use the nearest loaded chunk ▼▼▼
296
+ if (!hasContent) {
297
+ // Find the nearest loaded chunk
298
+ const loadedChunks = Object.keys(model._chunks).map(Number).sort((a, b) => a - b);
299
+ if (loadedChunks.length > 0) {
300
+ // Pick the chunk closest to the current position
301
+ let closestChunk = loadedChunks[0];
302
+ for (const chunkStart of loadedChunks) {
303
+ if (chunkStart <= startChunkIndex) {
304
+ closestChunk = chunkStart;
305
+ } else {
306
+ break;
307
+ }
308
+ }
309
+ firstChunkStart = closestChunk;
310
+ visibleContent = model._chunks[closestChunk] + '\n';
311
+
312
+ // Add the next chunk as well if present
313
+ const nextChunk = closestChunk + chunkSize;
314
+ if (model._chunks[nextChunk]) {
315
+ visibleContent += model._chunks[nextChunk] + '\n';
316
+ }
317
+ } else {
318
+ // If no chunks exist at all, keep the previous response
319
+ return;
320
+ }
321
+ }
322
+ // ▲▲▲ [Changed] ▲▲▲
323
+
324
+ if (firstChunkStart === -1) firstChunkStart = 0;
325
+
326
+ // Compute relative scroll offset from the first chunk
327
+ const firstChunkPixelOffset = firstChunkStart * lineHeight;
328
+ const relativeScrollOffset = scrollOffset - firstChunkPixelOffset;
329
+
330
+ model._visibleContent = visibleContent;
331
+ model._visibleScrollOffset = Math.max(0, relativeScrollOffset);
332
+
333
+ // ▼▼▼ [Changed] Do not overwrite response for chunk nodes (saved/restored via full file path) ▼▼▼
334
+ // Chunk nodes are restored based on SourceFilePath, so avoid mutating response.
335
+ // model.response = visibleContent; // removed
336
+ // ▲▲▲ [Changed] ▲▲▲
337
+ }
338
+ // ▲▲▲ [New] ▲▲▲
339
+ // ▲▲▲ [Changed] ▲▲▲
340
+
341
+ async function processRenderQueue(module, nodeId) {
342
+ // ▼▼▼ [Profiling] Start per-node render timing ▼▼▼
343
+ // console.time(`[Pipeline] 6. Render Node ${nodeId}`);
344
+ // ▲▲▲ [Profiling] ▲▲▲
345
+
346
+ // ▼▼▼ [Changed] module.nodeRenderLocks -> window.MindMapNodes.nodeRenderLocks ▼▼▼
347
+ if (window.MindMapNodes.nodeRenderLocks.get(nodeId)) {
348
+ // console.log(`[Pipeline] [Log Detail] Node ${nodeId} skipped: Render lock active.`);
349
+ return;
350
+ }
351
+
352
+ // ▼▼▼ [Changed] module.pendingNodeUpdates -> window.MindMapNodes.pendingNodeUpdates ▼▼▼
353
+ const updateData = window.MindMapNodes.pendingNodeUpdates.get(nodeId);
354
+ if (!updateData) {
355
+ return;
356
+ }
357
+
358
+ // console.log(`[Pipeline] [Log Detail] Node ${nodeId} processing queue.`);
359
+
360
+ const HANDLE_SIZE = RESIZE_HANDLE_SIZE;
361
+ const nodeEntry = module.nodeObjectsById.get(nodeId);
362
+ if (!nodeEntry) {
363
+ const retryCount = Math.max(0, Number(updateData.retryCount || 0)) + 1;
364
+ if (retryCount <= 80) {
365
+ updateData.retryCount = retryCount;
366
+ window.MindMapNodes.pendingNodeUpdates.set(nodeId, updateData);
367
+ const delayMs = Math.min(1000, 50 + retryCount * 50);
368
+ setTimeout(() => {
369
+ if (window.MindMapNodes?.pendingNodeUpdates?.has?.(nodeId)) {
370
+ window.MindMapPipeline?.processRenderQueue?.(module, nodeId);
371
+ }
372
+ }, delayMs);
373
+ } else {
374
+ window.MindMapNodes.pendingNodeUpdates.delete(nodeId);
375
+ console.warn(`[MindMapPipeline] Dropped pending node update after waiting for missing node: ${nodeId}`);
376
+ }
377
+ return;
378
+ }
379
+
380
+ window.MindMapNodes.nodeRenderLocks.set(nodeId, true);
381
+
382
+ try {
383
+ const model = nodeEntry.model;
384
+ if (Object.prototype.hasOwnProperty.call(updateData, 'isLoading')
385
+ && updateData.isLoading !== null
386
+ && updateData.isLoading !== undefined) {
387
+ model.isLoading = !!updateData.isLoading;
388
+ model.IsLoading = !!updateData.isLoading;
389
+ }
390
+ const zoomLevel = 2;
391
+
392
+ // ▼▼▼ [Perf] CSS3D 지원 노드는 거리와 무관하게 텍스처 생성 스킵 ▼▼▼
393
+ const cameraZ = module.camera?.position?.z || 0;
394
+ const threshold = 4000;
395
+ const isHighMode = cameraZ < threshold;
396
+ const supportsCss3d = model.contentType === 'text' ||
397
+ model.contentType === 'note' ||
398
+ model.contentType === 'memo' ||
399
+ model.contentType === 'markdown' ||
400
+ model.contentType === 'code';
401
+
402
+ // ★ [Fix] currentType이 'CSS'이면 카메라 거리와 무관하게 텍스처 생성 스킵
403
+ const isCssMode = nodeEntry.currentType === 'CSS';
404
+ const shouldSkipForCss = supportsCss3d && (isHighMode || cameraZ >= threshold || isCssMode);
405
+
406
+ if (shouldSkipForCss) {
407
+ const prevWidth = model.width || 0;
408
+ const prevHeight = model.height || 0;
409
+ const hasExplicitSizeUpdate =
410
+ (updateData.width !== null && updateData.width !== undefined) ||
411
+ (updateData.height !== null && updateData.height !== undefined);
412
+
413
+ // Update model state
414
+ model.response = updateData.content;
415
+ model.Response = updateData.content;
416
+ if (updateData.width !== null && updateData.width !== undefined) model.width = updateData.width;
417
+ if (updateData.height !== null && updateData.height !== undefined) model.height = updateData.height;
418
+
419
+ // ▼▼▼ [Fix] Sync CSS3D/GL userData with updated model dimensions ▼▼▼
420
+ // Without this, syncCss3dContent() reads stale userData.worldWidth/Height
421
+ // and reverts the DOM size, causing content/node size mismatch.
422
+ if (nodeEntry.cssObject?.userData) {
423
+ nodeEntry.cssObject.userData.worldWidth = model.width || 400;
424
+ nodeEntry.cssObject.userData.worldHeight = model.height || 200;
425
+ }
426
+ if (nodeEntry.glObject?.userData) {
427
+ nodeEntry.glObject.userData.worldWidth = model.width || 400;
428
+ nodeEntry.glObject.userData.worldHeight = model.height || 200;
429
+ }
430
+ // ▲▲▲ [Fix] ▲▲▲
431
+
432
+ // CSS3D-capable text/note nodes intentionally skip WebGL texture rebuilds.
433
+ // Keep the live DOM/overlay in sync here so async AI completions do not
434
+ // require a full board reload before the final answer becomes visible.
435
+ if (nodeEntry.cssObject && window.MindMapCss3DManager?.syncCss3dContent) {
436
+ nodeEntry.isCssDirty = true;
437
+ window.MindMapCss3DManager.syncCss3dContent(model, nodeEntry.cssObject);
438
+ nodeEntry.isCssDirty = false;
439
+ } else {
440
+ nodeEntry.isCssDirty = true;
441
+ }
442
+
443
+ window.MindMapTextOverlayV2?.invalidateNode?.(module, nodeId, 'content');
444
+ module.lodRenderer?.requestResidentPatch?.('update-node', nodeId);
445
+ module._overlayDirty = true;
446
+ module._lastOverlayKey = '';
447
+ module._forceUpdateFrames = Math.max(Number(module._forceUpdateFrames || 0), 3);
448
+
449
+ if (isCssMode) {
450
+ window.MindMapNodes?.setNodeScrollbarVisibility?.(nodeEntry, false);
451
+ }
452
+
453
+ // Sync CSS3D element size (if present)
454
+ const wrapper = nodeEntry.cssObject?.element;
455
+ if (wrapper) {
456
+ const w = model.width || 400;
457
+ const h = model.height || 200;
458
+ const resolutionScale = nodeEntry.cssObject?.userData?.resolutionScale || 1;
459
+ wrapper.style.width = (w * resolutionScale) + 'px';
460
+ wrapper.style.height = (h * resolutionScale) + 'px';
461
+ wrapper.style.minWidth = (w * resolutionScale) + 'px';
462
+ wrapper.style.minHeight = (h * resolutionScale) + 'px';
463
+ wrapper.style.maxWidth = (w * resolutionScale) + 'px';
464
+ wrapper.style.maxHeight = (h * resolutionScale) + 'px';
465
+ const inner = wrapper.firstElementChild;
466
+ if (inner) {
467
+ inner.style.width = w + 'px';
468
+ inner.style.height = h + 'px';
469
+ inner.style.minWidth = w + 'px';
470
+ inner.style.minHeight = h + 'px';
471
+ inner.style.maxWidth = w + 'px';
472
+ inner.style.maxHeight = h + 'px';
473
+ inner.style.transform = `scale(${resolutionScale})`;
474
+ inner.style.transformOrigin = '0 0';
475
+ }
476
+ const textarea = wrapper.querySelector('textarea');
477
+ if (textarea) {
478
+ textarea.style.width = '100%';
479
+ textarea.style.height = '100%';
480
+ }
481
+ }
482
+
483
+ // Update glow size if needed (no texture work)
484
+ const group = nodeEntry.glObject;
485
+ if (group) {
486
+ if (!ENABLE_WEBGL_GLOW) {
487
+ removeGlowMesh(group);
488
+ } else if (window.MindMapGlowShader) {
489
+ const glowMesh = group.getObjectByName('glow');
490
+ const w = model.width || 400;
491
+ const h = model.height || 200;
492
+ if (glowMesh) {
493
+ window.MindMapGlowShader.updateGlowSize(glowMesh, w, h, model.contentType || 'text');
494
+ glowMesh.position.set(w / 2, -h / 2, 0); // [Fix] Z=0
495
+ }
496
+ }
497
+ }
498
+
499
+ // Keep WebGL body/tail geometry aligned with CSS3D size (prevents split)
500
+ if (nodeEntry.glObject) {
501
+ const bodyMesh = nodeEntry.glObject.getObjectByName('body');
502
+ const tailMesh = nodeEntry.glObject.getObjectByName('tail');
503
+ const w = model.width || 400;
504
+ const h = model.height || 200;
505
+
506
+ if (bodyMesh) {
507
+ const gp = bodyMesh.geometry && bodyMesh.geometry.parameters;
508
+ if (!gp || gp.width !== w || gp.height !== h) {
509
+ bodyMesh.geometry.dispose();
510
+ bodyMesh.geometry = new THREE.PlaneGeometry(w, h);
511
+ }
512
+ bodyMesh.position.set(w / 2, -h / 2, 0);
513
+ if (bodyMesh.material) {
514
+ bodyMesh.material.color?.setHex?.(0xffffff);
515
+ bodyMesh.material.needsUpdate = true;
516
+ }
517
+ if (model.contentType === 'image') {
518
+ window.MindMapNodes?.syncImageNodeBodyTexture?.(bodyMesh, model);
519
+ applyImageCoverUv(bodyMesh, w, h);
520
+ }
521
+ }
522
+
523
+ if (tailMesh) {
524
+ tailMesh.position.set(w / 2, -h - 6, 0);
525
+ if (tailMesh.material) {
526
+ tailMesh.material.color?.setHex?.(0xffffff);
527
+ tailMesh.material.needsUpdate = true;
528
+ }
529
+ }
530
+ }
531
+
532
+ // Persist size changes for CSS-rendered nodes as well.
533
+ // Without this, auto-grown AI note dimensions are not saved to the board.
534
+ const sizeChanged = Math.abs((model.width || 0) - prevWidth) > 0.5
535
+ || Math.abs((model.height || 0) - prevHeight) > 0.5;
536
+ if (!updateData.skipPersistDimensions && (sizeChanged || hasExplicitSizeUpdate) && module.dotNetHelper?.invokeMethodAsync) {
537
+ try {
538
+ await module.dotNetHelper.invokeMethodAsync(
539
+ 'UpdateNodeDimensions',
540
+ nodeId,
541
+ Math.round(model.width || 0),
542
+ Math.round(model.height || 0)
543
+ );
544
+ } catch (e) {
545
+ console.warn('[MindMapPipeline] Failed to persist CSS node dimensions:', e);
546
+ }
547
+ }
548
+
549
+ window.MindMapNodes.pendingNodeUpdates.delete(nodeId);
550
+ window.MindMapNodes.nodeRenderLocks.set(nodeId, false);
551
+ return;
552
+ }
553
+ // ▲▲▲ [Perf] ▲▲▲
554
+
555
+ const width = (updateData.width !== null && updateData.width !== undefined) ?
556
+ updateData.width : (model.width || (model.contentType === 'image' ? 300 : 400));
557
+
558
+ // ▼▼▼ [Key change] Reduce height-change sensitivity (avoid jitter) ▼▼▼
559
+ // ▼▼▼ [Changed] Chunk nodes already have fixed maxScroll; skip recalculation ▼▼▼
560
+ // ▼▼▼ [Changed] Skip height recalculation for expanded nodes (they auto-grow in updateNodeContent) ▼▼▼
561
+ if ((model.contentType === 'text' || model.contentType === 'note') && !model.isChunkedText && !model.isExpanded) {
562
+ if (updateData.height === null || updateData.height === undefined) {
563
+ const tempModel = { ...model, response: updateData.content };
564
+ // When showFullAiResponse is true, do not cap height
565
+ const defaultMaxHeight = module.aiNodeDefaultHeight || 200;
566
+ const effectiveMaxHeight = module.showFullAiResponse ? 10000 : defaultMaxHeight;
567
+ const { height: requiredHeight, maxScrollPx } = await window.MindMapTextureFactory.measureHtmlHeightAsync(tempModel, zoomLevel, width * zoomLevel, null, effectiveMaxHeight);
568
+
569
+ // Keep AI/loading note nodes at least at the configured default height.
570
+ // This prevents initial one-line collapse while streaming starts.
571
+ const defaultPrompts = ['Note', 'Pasted Text', '에이전트 메모'];
572
+ const promptText = model.prompt ?? model.Prompt ?? '';
573
+ const isAiPrompt = !!promptText && !defaultPrompts.includes(promptText);
574
+ const minPreferredHeight = (model.isLoading || isAiPrompt)
575
+ ? (module.aiNodeDefaultHeight || 200)
576
+ : 60;
577
+ const normalizedRequiredHeight = Math.max(requiredHeight, minPreferredHeight);
578
+
579
+ const currentHeight = (model.height || 0);
580
+ // Update only if delta is >= 5px (was 2px -> 5px)
581
+ if (Math.abs(currentHeight - normalizedRequiredHeight) > 5) {
582
+ model.height = normalizedRequiredHeight;
583
+ model.maxScroll = maxScrollPx / zoomLevel;
584
+ model.scrollOffset = Math.min(model.scrollOffset || 0, model.maxScroll);
585
+ updateData.height = normalizedRequiredHeight;
586
+ try {
587
+ module.dotNetHelper?.invokeMethodAsync('UpdateNodeDimensions', nodeId, Math.round(width), Math.round(normalizedRequiredHeight));
588
+ } catch { }
589
+ } else {
590
+ // If the change is tiny, keep the existing height (avoid infinite loop)
591
+ updateData.height = currentHeight;
592
+ }
593
+ }
594
+ }
595
+ // ▲▲▲ [Changed] ▲▲▲
596
+ // ▲▲▲ [Key change] ▲▲▲
597
+
598
+ const previousContent = String(model.response ?? model.Response ?? '');
599
+ const hasContentUpdate = updateData.content !== null && updateData.content !== undefined;
600
+ let nextContent = hasContentUpdate ? String(updateData.content ?? '') : previousContent;
601
+
602
+ if (model.contentType === 'image' && hasContentUpdate) {
603
+ nextContent = window.MindMapNodes?.applyCanonicalNodeResponse?.(model, nextContent) ?? nextContent;
604
+ updateData.content = nextContent;
605
+ }
606
+
607
+ if (model.contentType === 'image' && nextContent) {
608
+ // ▼▼▼ [Changed] module.imageCache -> window.MindMapNodes.imageCache ▼▼▼
609
+ const assetUrls = window.MindMapNodes?.getNodeImageAssetUrls?.(model) || {};
610
+ const previewUrl = String(assetUrls.previewUrl || nextContent).trim();
611
+ const originalUrl = String(assetUrls.fullResUrl || assetUrls.originalUrl || '').trim();
612
+ await window.MindMapTextureFactory.ensureImageCached(nodeId, previewUrl, window.MindMapNodes.imageCache, {
613
+ authToken: module.authToken,
614
+ allowOriginalFallback: false,
615
+ allowOriginalReuseForPreview: false,
616
+ originalUrl,
617
+ onRefreshedUrl: (refreshedUrl) => {
618
+ const normalized = String(refreshedUrl || '').trim();
619
+ if (!normalized) {
620
+ return;
621
+ }
622
+
623
+ window.MindMapNodes?.setNodeImageOriginalUrl?.(model, normalized);
624
+ const refreshedAssetUrls = window.MindMapNodes?.getNodeImageAssetUrls?.(model) || {};
625
+ if (!refreshedAssetUrls.thumbnailUrl && !String(model.response ?? model.Response ?? '').trim()) {
626
+ updateData.content = normalized;
627
+ model.response = normalized;
628
+ model.Response = normalized;
629
+ }
630
+ }
631
+ });
632
+ }
633
+
634
+ window.MindMapNodes.pendingNodeUpdates.delete(nodeId);
635
+ const contentChanged = previousContent !== nextContent;
636
+ const hasExplicitSizeUpdate =
637
+ (updateData.width !== null && updateData.width !== undefined) ||
638
+ (updateData.height !== null && updateData.height !== undefined);
639
+ if (hasContentUpdate) {
640
+ model.response = nextContent;
641
+ model.Response = nextContent;
642
+ }
643
+ if (updateData.width !== null && updateData.width !== undefined) model.width = updateData.width;
644
+ if (updateData.height !== null && updateData.height !== undefined) model.height = updateData.height;
645
+
646
+ if (nodeEntry.cssObject?.userData) {
647
+ nodeEntry.cssObject.userData.worldWidth = model.width || 400;
648
+ nodeEntry.cssObject.userData.worldHeight = model.height || 200;
649
+ }
650
+ if (nodeEntry.glObject?.userData) {
651
+ nodeEntry.glObject.userData.worldWidth = model.width || 400;
652
+ nodeEntry.glObject.userData.worldHeight = model.height || 200;
653
+ }
654
+
655
+ if (model.contentType === 'image' && (contentChanged || hasExplicitSizeUpdate)) {
656
+ nodeEntry.isCssDirty = true;
657
+ nodeEntry._imageLodVisualState = null;
658
+ const bodyMeshForImage = nodeEntry.bodyMesh || nodeEntry.glObject?.getObjectByName?.('body') || null;
659
+ if (bodyMeshForImage?.userData && contentChanged) {
660
+ bodyMeshForImage.userData.imageRenderKey = null;
661
+ bodyMeshForImage.userData.pendingImageUrl = '';
662
+ }
663
+ if (nodeEntry.cssObject && window.MindMapCss3DManager?.syncCss3dContent) {
664
+ window.MindMapCss3DManager.syncCss3dContent(model, nodeEntry.cssObject);
665
+ nodeEntry.isCssDirty = false;
666
+ }
667
+ module.lodRenderer?.requestResidentPatch?.('update-node', nodeId);
668
+ }
669
+
670
+ // ★ Invalidate LOD cache when content changes
671
+ window.MindMapTextLOD?.invalidateNodeCache?.(nodeId);
672
+
673
+ const updateDataWithScroll = {
674
+ ...updateData,
675
+ scrollDelta: updateData.scrollDelta || 0
676
+ };
677
+
678
+ // ★ Troika 모드 체크: text/note/code 노드면 배경 전용 텍스처 사용
679
+ // ★ Code nodes always use Troika for text rendering
680
+ const isTroikaMode = (model.contentType === 'code') || // Code nodes always use Troika
681
+ (window.MindMapNodes?.getTextRenderMode?.() === 'troika'
682
+ && (model.contentType === 'text' || model.contentType === 'note')
683
+ && !model.isChunkedText);
684
+
685
+ let textures, w, h;
686
+ const existingTextures = window.MindMapNodes.textureCache.get(nodeId);
687
+
688
+ if (isTroikaMode) {
689
+ // ★ Troika 최적화: 이미 캐시된 텍스처가 있고, 크기와 내용이 동일하면 재사용
690
+ const canReuseTexture = existingTextures?.default?.body
691
+ && existingTextures._cachedWidth === model.width
692
+ && existingTextures._cachedHeight === model.height
693
+ && existingTextures._cachedContent === model.response; // ★ Content change check
694
+
695
+ if (canReuseTexture) {
696
+ // 캐시된 텍스처 재사용 (텍스처 재생성 스킵)
697
+ textures = existingTextures;
698
+ w = model.width;
699
+ h = model.height;
700
+ } else {
701
+ // 배경 텍스처 생성
702
+ const result = await window.MindMapTextureFactory.generateTroikaBackgroundTextures(module, model, model.width, model.height);
703
+ textures = result.textures;
704
+ // 캐시 메타데이터 저장
705
+ textures._cachedWidth = model.width;
706
+ textures._cachedHeight = model.height;
707
+ textures._cachedContent = model.response; // ★ Track content for change detection
708
+ w = result.width;
709
+ h = result.height;
710
+ }
711
+ } else {
712
+ // Canvas 모드: 기존 방식 (텍스트 포함)
713
+ const isResizeUpdate = updateData.width !== undefined || updateData.height !== undefined;
714
+ const isBatchExpand = updateData.skipSelected === true; // ★ Batch expand operation
715
+
716
+ // 고정 품질 렌더링 (LOD 품질 스케일 제거)
717
+ const qualityScale = 1.0;
718
+ console.log(`[processRenderQueue] Canvas mode for ${nodeId}, qualityScale=${qualityScale}, isResize=${isResizeUpdate}, isBatch=${isBatchExpand}`);
719
+ const result = await window.MindMapTextureFactory.generateAndCacheTextures(module, model, window.MindMapNodes.imageCache, updateDataWithScroll, qualityScale);
720
+ textures = result.textures;
721
+ w = result.width;
722
+ h = result.height;
723
+
724
+ // 캐시 메타데이터 (콘텐츠 변경 감지용)
725
+ if (textures.default?.body) {
726
+ textures.default.body._cachedContent = model.response ?? '';
727
+ }
728
+ }
729
+
730
+ // ★ 기존 텍스처 dispose (Troika에서 재사용한 경우 제외)
731
+ if (existingTextures && existingTextures !== textures) {
732
+ enqueueDispose(existingTextures.default?.body);
733
+ enqueueDispose(existingTextures.selected?.body);
734
+ enqueueDispose(existingTextures.glow?.texture);
735
+ }
736
+ window.MindMapNodes.textureCache.set(nodeId, textures);
737
+
738
+ const isSelected = module.multiSelectedNodeIds.has(nodeId);
739
+ // ★ [Fix] If selected texture is null (skipSelected mode), use default texture
740
+ const active = (isSelected && textures.selected?.body) ? textures.selected : textures.default;
741
+ const group = nodeEntry.glObject;
742
+
743
+ const CORNER_RADIUS = 20;
744
+ const OUTLINE_SHRINK = 2;
745
+ const OUTLINE_RADIUS = Math.max(1, CORNER_RADIUS - 1);
746
+ const CHAMFER_SIZE = 16;
747
+
748
+ if (group) {
749
+ const bodyMesh = group.getObjectByName('body');
750
+ const tailMesh = group.getObjectByName('tail');
751
+ let glowMesh = group.getObjectByName('glow');
752
+ const outline = group.getObjectByName('outline');
753
+ if (!ENABLE_WEBGL_GLOW) {
754
+ glowMesh = removeGlowMesh(group);
755
+ }
756
+ const baseRenderOrder = group.userData?.baseRenderOrder ?? bodyMesh?.renderOrder ?? module.nodeZCounter++;
757
+ group.userData = { ...(group.userData || {}), baseRenderOrder };
758
+
759
+ if (bodyMesh) {
760
+ const gp = bodyMesh.geometry && bodyMesh.geometry.parameters;
761
+ if (!gp || gp.width !== w || gp.height !== h) {
762
+ bodyMesh.geometry.dispose();
763
+ bodyMesh.geometry = new THREE.PlaneGeometry(w, h);
764
+
765
+ if (outline) {
766
+ outline.geometry.dispose();
767
+ outline.geometry = createRoundedRectGeometry(
768
+ w - OUTLINE_SHRINK,
769
+ h - OUTLINE_SHRINK,
770
+ OUTLINE_RADIUS
771
+ );
772
+ }
773
+ }
774
+ if (active?.body) {
775
+ bodyMesh.material.map = active.body;
776
+ }
777
+ bodyMesh.material.color.setHex(0xffffff);
778
+ bodyMesh.material.needsUpdate = true;
779
+ bodyMesh.position.set(w / 2, -h / 2, 0);
780
+ if (model.contentType === 'image') {
781
+ if (contentChanged) {
782
+ bodyMesh.userData ??= {};
783
+ bodyMesh.userData.imageRenderKey = null;
784
+ bodyMesh.userData.pendingImageUrl = '';
785
+ }
786
+ applyImageCoverUv(bodyMesh, w, h);
787
+ }
788
+
789
+ if (model.contentType === 'image') {
790
+ syncImageSelectionGlow(group, w, h, baseRenderOrder, isSelected);
791
+ } else if (outline) {
792
+ outline.position.copy(bodyMesh.position);
793
+ outline.position.z = 0; // [Fix] Z=0
794
+ outline.visible = false; // Disabled - using SDF glow instead
795
+ }
796
+ }
797
+ if (tailMesh) {
798
+ if (active?.tail) {
799
+ tailMesh.material.map = active.tail;
800
+ tailMesh.material.needsUpdate = true;
801
+ }
802
+ tailMesh.position.set(w / 2, -h - 6, 0);
803
+ }
804
+
805
+ // ▼▼▼ [SDF Glow] Use shader-based glow (no textures!) ▼▼▼
806
+ if (ENABLE_WEBGL_GLOW && window.MindMapGlowShader) {
807
+ const contentType = model.contentType || 'text';
808
+ if (glowMesh) {
809
+ window.MindMapGlowShader.updateGlowSize(glowMesh, w, h, contentType);
810
+ window.MindMapGlowShader.updateGlowSelection(glowMesh, isSelected);
811
+ glowMesh.position.set(w / 2, -h / 2, 0); // [Fix] Z=0
812
+ glowMesh.renderOrder = baseRenderOrder - 5;
813
+ } else {
814
+ const newGlow = window.MindMapGlowShader.createSDFGlowMesh(w, h, isSelected, contentType);
815
+ newGlow.position.set(w / 2, -h / 2, 0); // [Fix] Z=0
816
+ newGlow.renderOrder = baseRenderOrder - 5;
817
+ // ★ 카메라 거리에 따라 초기 가시성 설정
818
+ const cameraZ = module.camera?.position?.z ?? Infinity;
819
+ const threshold = 4000;
820
+ newGlow.visible = cameraZ < threshold;
821
+ group.add(newGlow);
822
+ }
823
+ }
824
+ // ▲▲▲ [SDF Glow] ▲▲▲
825
+
826
+ // ▼▼▼ [Changed] Update resize handles (4 corners + optional 4 edges) ▼▼▼
827
+ if (model.contentType !== 'pdf') {
828
+ // [Fix] 이미지 노드 리사이즈 핸들 크기 통일 (Note와 동일한 16px)
829
+ const actualHandleSize = HANDLE_SIZE;
830
+
831
+ const edgeWidthLength = Math.max(10, w - actualHandleSize * 2);
832
+ const edgeHeightLength = Math.max(10, h - actualHandleSize * 2);
833
+
834
+ const handleConfigs = {
835
+ // ▼▼▼ [Fix] Corners - Z값을 0으로 통일하여 시차(Parallax) 완전 제거 ▼▼▼
836
+ 'resizeHandle_TL': { x: actualHandleSize / 2, y: -actualHandleSize / 2, w: actualHandleSize, h: actualHandleSize, z: 0 },
837
+ 'resizeHandle_TR': { x: w - actualHandleSize / 2, y: -actualHandleSize / 2, w: actualHandleSize, h: actualHandleSize, z: 0 },
838
+ 'resizeHandle_BL': { x: actualHandleSize / 2, y: -h + actualHandleSize / 2, w: actualHandleSize, h: actualHandleSize, z: 0 },
839
+ 'resizeHandle_BR': { x: w - actualHandleSize / 2, y: -h + actualHandleSize / 2, w: actualHandleSize, h: actualHandleSize, z: 0 }
840
+ // ▲▲▲ [Fix] ▲▲▲
841
+ };
842
+
843
+ if (model.contentType !== 'image') {
844
+ Object.assign(handleConfigs, {
845
+ // ▼▼▼ [Fix] Edges - Z값을 0으로 통일 ▼▼▼
846
+ 'resizeHandle_T': { x: w / 2, y: -actualHandleSize / 2, w: edgeWidthLength, h: actualHandleSize, z: 0 },
847
+ 'resizeHandle_B': { x: w / 2, y: -h + actualHandleSize / 2, w: edgeWidthLength, h: actualHandleSize, z: 0 },
848
+ 'resizeHandle_L': { x: actualHandleSize / 2, y: -h / 2, w: actualHandleSize, h: edgeHeightLength, z: 0 },
849
+ 'resizeHandle_R': { x: w - actualHandleSize / 2, y: -h / 2, w: actualHandleSize, h: edgeHeightLength, z: 0 }
850
+ // ▲▲▲ [Fix] ▲▲▲
851
+ });
852
+ }
853
+
854
+ // Iterate all possible handles to update position OR hide if not needed
855
+ const allHandleNames = [
856
+ 'resizeHandle_TL', 'resizeHandle_TR', 'resizeHandle_BL', 'resizeHandle_BR',
857
+ 'resizeHandle_T', 'resizeHandle_B', 'resizeHandle_L', 'resizeHandle_R'
858
+ ];
859
+
860
+ allHandleNames.forEach(name => {
861
+ const handle = group.getObjectByName(name);
862
+ const cfg = handleConfigs[name];
863
+
864
+ if (handle) {
865
+ if (cfg) {
866
+ // Active handle: update position/size and ensure visible
867
+ handle.visible = true; // Ensure visible (might have been hidden)
868
+ handle.position.set(cfg.x, cfg.y, cfg.z);
869
+ const gp = handle.geometry?.parameters;
870
+ if (gp && (gp.width !== cfg.w || gp.height !== cfg.h)) {
871
+ handle.geometry.dispose();
872
+ handle.geometry = new THREE.PlaneGeometry(cfg.w, cfg.h);
873
+ }
874
+ if (model.contentType === 'image' && handle.material) {
875
+ handle.material.opacity = 0.0;
876
+ handle.material.colorWrite = false;
877
+ handle.material.needsUpdate = true;
878
+ }
879
+ } else {
880
+ // Inactive handle (e.g. edge on image node): hide it
881
+ handle.visible = false;
882
+ }
883
+ }
884
+ });
885
+ }
886
+ // ▲▲▲ [Changed] ▲▲▲
887
+
888
+ group.userData.worldWidth = w;
889
+ group.userData.worldHeight = h;
890
+
891
+ const isCssMediaNode =
892
+ (model.contentType === 'image' || model.contentType === 'video' || model.contentType === 'embed')
893
+ && !!nodeEntry.cssObject;
894
+ const mediaFrameResynced =
895
+ isCssMediaNode &&
896
+ window.MindMapCss3DManager?.syncCss3dMediaNodeFrame?.(model, nodeEntry.cssObject) === true;
897
+
898
+ if (group.visible === false && nodeEntry.currentType === 'GL') {
899
+ group.visible = true;
900
+ if (nodeEntry.cssObject) {
901
+ nodeEntry.cssObject.visible = false;
902
+ }
903
+ }
904
+
905
+ if (!mediaFrameResynced &&
906
+ window.MindMapNodes &&
907
+ typeof window.MindMapNodes.updateNodeSpatialGrid === 'function') {
908
+ window.MindMapNodes.updateNodeSpatialGrid(module, nodeId);
909
+ }
910
+ }
911
+ } catch (err) {
912
+ console.error(`[Pipeline] Render Node ${nodeId} Failed:`, err);
913
+ } finally {
914
+ window.MindMapNodes.nodeRenderLocks.set(nodeId, false);
915
+ // console.timeEnd(`[Pipeline] 6. Render Node ${nodeId}`);
916
+
917
+ // If another update arrived while processing, re-run (with a small delay)
918
+ if (window.MindMapNodes.pendingNodeUpdates.has(nodeId)) {
919
+ setTimeout(() => enqueueRender(module, nodeId), 50);
920
+ }
921
+ }
922
+ }
923
+
924
+ // --- Resizing handlers (keep existing behavior) ---
925
+
926
+ // ▼▼▼ [Changed] Add corner parameter for four-corner resizing ▼▼▼
927
+ function startResizing(module, nodeId, startX, startY, corner = 'BR') {
928
+ const nodeEntry = module.nodeObjectsById.get(nodeId);
929
+ if (!nodeEntry) return false;
930
+ if (nodeEntry.model.contentType === 'pdf') return false;
931
+
932
+ module.isResizing = true;
933
+ module.resizingNodeId = nodeId;
934
+ module.resizeCorner = corner; // TL, TR, BL, BR
935
+ const cssRendererEnabled = module?.renderDebugFlags?.enableCss3d !== false;
936
+ const shouldResizeCssObject = !!nodeEntry.cssObject &&
937
+ cssRendererEnabled &&
938
+ (nodeEntry.currentType === 'CSS' || nodeEntry.cssObject.visible === true);
939
+ const activeResizeType = shouldResizeCssObject ? 'CSS' : 'GL';
940
+ module.resizeNodeObject = activeResizeType === 'CSS' ? nodeEntry.cssObject : nodeEntry.glObject;
941
+ if (!module.resizeNodeObject && nodeEntry.cssObject) {
942
+ module.resizeNodeObject = nodeEntry.cssObject;
943
+ nodeEntry.currentResizeType = 'CSS';
944
+ } else {
945
+ nodeEntry.currentResizeType = activeResizeType;
946
+ }
947
+ window.MindMapNodes?.bringNodeToFront?.(module, nodeId);
948
+ window.MindMapInteractions?.setBrowserSelectionLock?.(true, 'resize');
949
+ document.body.classList.add('is-resizing');
950
+ module.resizeStartMousePos = { x: startX, y: startY };
951
+ module.resizeStartDimensions = {
952
+ w: nodeEntry.model.width || 400,
953
+ h: nodeEntry.model.height || 200
954
+ };
955
+ // Store initial position from the active render object (CSS/GL) to avoid
956
+ // image-node jumps when CSS and GL are slightly out of sync.
957
+ const activePos = module.resizeNodeObject?.position
958
+ || nodeEntry.cssObject?.position
959
+ || nodeEntry.glObject?.position
960
+ || { x: 0, y: 0 };
961
+ module.resizeStartPosition = {
962
+ x: activePos.x,
963
+ y: activePos.y
964
+ };
965
+
966
+ // Force immediate CSS/GL transform alignment at resize start.
967
+ if (nodeEntry.glObject) {
968
+ nodeEntry.glObject.position.x = activePos.x;
969
+ nodeEntry.glObject.position.y = activePos.y;
970
+ }
971
+ if (nodeEntry.cssObject) {
972
+ nodeEntry.cssObject.position.x = activePos.x;
973
+ nodeEntry.cssObject.position.y = activePos.y;
974
+ nodeEntry.cssObject.updateMatrixWorld(true);
975
+ }
976
+
977
+ // ▼▼▼ [Fix] 리사이즈 중 CSS transition 비활성화하여 즉시 크기 변경 ▼▼▼
978
+ if (nodeEntry.cssObject && nodeEntry.cssObject.element) {
979
+ const wrapper = nodeEntry.cssObject.element;
980
+ wrapper.style.transition = 'none';
981
+ const inner = wrapper.firstElementChild;
982
+ if (inner) {
983
+ inner.style.transition = 'none';
984
+ }
985
+ }
986
+ // ▲▲▲ [Fix] ▲▲▲
987
+
988
+ // ▼▼▼ [UndoRedo] Capture before-state for undo ▼▼▼
989
+ if (module.dotNetHelper) {
990
+ try { module.dotNetHelper.invokeMethodAsync('StartResizeCapture', nodeId); } catch { }
991
+ }
992
+ // ▲▲▲ [UndoRedo] ▲▲▲
993
+
994
+ return true;
995
+ }
996
+ // ▲▲▲ [Changed] ▲▲▲
997
+
998
+ function doResizing(module, currentX, currentY) {
999
+ if (!module.isResizing || !module.resizeNodeObject) return;
1000
+ // ▼▼▼ [Changed] Cap max node size: X=2000, Y=4000 ▼▼▼
1001
+ const MAX_NODE_WIDTH = 2000;
1002
+ const MAX_NODE_HEIGHT = 4000;
1003
+ const MIN_WIDTH = 200;
1004
+ const MIN_HEIGHT = 100;
1005
+ // ▲▲▲ [Changed] ▲▲▲
1006
+
1007
+ const vfov = (module.camera.fov * Math.PI) / 180;
1008
+ const viewHeight = 2 * Math.tan(vfov / 2) * module.camera.position.z;
1009
+ const viewWidth = viewHeight * module.camera.aspect;
1010
+ const screenToWorldFactorX = viewWidth / module.container.clientWidth;
1011
+ const screenToWorldFactorY = viewHeight / module.container.clientHeight;
1012
+ const worldDeltaX = (currentX - module.resizeStartMousePos.x) * screenToWorldFactorX;
1013
+ // ▼▼▼ [Fix] Negate Y delta: screen Y increases downward, world Y increases upward ▼▼▼
1014
+ const worldDeltaY = -(currentY - module.resizeStartMousePos.y) * screenToWorldFactorY;
1015
+
1016
+ const HANDLE_SIZE = RESIZE_HANDLE_SIZE;
1017
+ // ▼▼▼ [Changed] Use stored nodeId (CSS3D mode compatible) ▼▼▼
1018
+ const nodeId = module.resizingNodeId || (module.resizeNodeObject.userData?.nodeId || module.resizeNodeObject.element?.dataset?.nodeId);
1019
+ const nodeEntry = module.nodeObjectsById.get(nodeId);
1020
+ if (!nodeEntry) {
1021
+ console.warn('[Pipeline] doResizing: nodeEntry not found for', nodeId);
1022
+ return;
1023
+ }
1024
+
1025
+ const corner = module.resizeCorner || 'BR';
1026
+ const startW = module.resizeStartDimensions.w;
1027
+ const startH = module.resizeStartDimensions.h;
1028
+ const startX = module.resizeStartPosition.x;
1029
+ const startY = module.resizeStartPosition.y;
1030
+
1031
+ let finalWidth, finalHeight, newPosX, newPosY;
1032
+
1033
+ // ▼▼▼ [Changed] Eight-direction resize logic (4 corners + 4 edges) ▼▼▼
1034
+ // Node position is top-left corner in Three.js (Y increases upward)
1035
+ // Depending on handle, we compute size and position differently
1036
+ switch (corner) {
1037
+ // === Corners (diagonal resize) ===
1038
+ case 'TL': // Top-Left: anchor bottom-right, move top-left
1039
+ finalWidth = startW - worldDeltaX;
1040
+ finalHeight = startH + worldDeltaY;
1041
+ newPosX = startX + worldDeltaX;
1042
+ newPosY = startY + worldDeltaY;
1043
+ break;
1044
+ case 'TR': // Top-Right: anchor bottom-left, expand right/up
1045
+ finalWidth = startW + worldDeltaX;
1046
+ finalHeight = startH + worldDeltaY;
1047
+ newPosX = startX;
1048
+ newPosY = startY + worldDeltaY;
1049
+ break;
1050
+ case 'BL': // Bottom-Left: anchor top-right, expand left/down
1051
+ finalWidth = startW - worldDeltaX;
1052
+ finalHeight = startH - worldDeltaY;
1053
+ newPosX = startX + worldDeltaX;
1054
+ newPosY = startY;
1055
+ break;
1056
+ case 'BR': // Bottom-Right: anchor top-left (original behavior)
1057
+ finalWidth = startW + worldDeltaX;
1058
+ finalHeight = startH - worldDeltaY;
1059
+ newPosX = startX;
1060
+ newPosY = startY;
1061
+ break;
1062
+ // === Edges (single-direction resize) ===
1063
+ case 'T': // Top edge: vertical only, anchor bottom
1064
+ finalWidth = startW;
1065
+ finalHeight = startH + worldDeltaY;
1066
+ newPosX = startX;
1067
+ newPosY = startY + worldDeltaY;
1068
+ break;
1069
+ case 'B': // Bottom edge: vertical only, anchor top
1070
+ finalWidth = startW;
1071
+ finalHeight = startH - worldDeltaY;
1072
+ newPosX = startX;
1073
+ newPosY = startY;
1074
+ break;
1075
+ case 'L': // Left edge: horizontal only, anchor right
1076
+ finalWidth = startW - worldDeltaX;
1077
+ finalHeight = startH;
1078
+ newPosX = startX + worldDeltaX;
1079
+ newPosY = startY;
1080
+ break;
1081
+ case 'R': // Right edge: horizontal only, anchor left
1082
+ finalWidth = startW + worldDeltaX;
1083
+ finalHeight = startH;
1084
+ newPosX = startX;
1085
+ newPosY = startY;
1086
+ break;
1087
+ default:
1088
+ finalWidth = startW + worldDeltaX;
1089
+ finalHeight = startH - worldDeltaY;
1090
+ newPosX = startX;
1091
+ newPosY = startY;
1092
+ break;
1093
+ }
1094
+
1095
+ // Clamp size
1096
+ let clampedWidth = Math.min(MAX_NODE_WIDTH, Math.max(MIN_WIDTH, finalWidth));
1097
+ let clampedHeight = Math.min(MAX_NODE_HEIGHT, Math.max(MIN_HEIGHT, finalHeight));
1098
+
1099
+ // ▼▼▼ [Image Resize Fix] Remove specific Ratio Preservation for now (Treat as normal nodes) ▼▼▼
1100
+ // Normal nodes Logic (apply to all)
1101
+ // Adjust position if size was clamped (keep anchor point fixed)
1102
+ if (corner === 'TL' || corner === 'BL' || corner === 'L') {
1103
+ // Left handles: position adjusts with width changes
1104
+ const widthDiff = finalWidth - clampedWidth;
1105
+ newPosX += widthDiff;
1106
+ }
1107
+ if (corner === 'TL' || corner === 'TR' || corner === 'T') {
1108
+ // Top handles: position adjusts with height changes
1109
+ const heightDiff = finalHeight - clampedHeight;
1110
+ newPosY -= heightDiff;
1111
+ }
1112
+ // ▲▲▲ [Image Resize Fix] ▲▲▲
1113
+ // ▲▲▲ [Image Resize Fix] ▲▲▲
1114
+
1115
+ finalWidth = clampedWidth;
1116
+ finalHeight = clampedHeight;
1117
+ // ▲▲▲ [Changed] ▲▲▲
1118
+
1119
+ // ▼▼▼ [Refactor] 스냅 현상 제거: 실수형 크기/좌표 그대로 사용 ▼▼▼
1120
+ nodeEntry.model.width = finalWidth;
1121
+ nodeEntry.model.height = finalHeight;
1122
+
1123
+ // ▼▼▼ [Changed] Update node position for corner resizing ▼▼▼
1124
+ if (nodeEntry.glObject) {
1125
+ nodeEntry.glObject.position.x = newPosX;
1126
+ nodeEntry.glObject.position.y = newPosY;
1127
+ }
1128
+ if (nodeEntry.cssObject) {
1129
+ nodeEntry.cssObject.position.x = newPosX;
1130
+ nodeEntry.cssObject.position.y = newPosY;
1131
+ // ▼▼▼ [Fix] CSS3D matrixWorld 즉시 갱신하여 CSS3DRenderer가 새 위치를 반영하도록 보장 ▼▼▼
1132
+ nodeEntry.cssObject.updateMatrixWorld(true);
1133
+ // ▲▲▲ [Fix] ▲▲▲
1134
+ }
1135
+ // ▼▼▼ [Fix] model position도 동기화하여 다른 sync 로직과의 일관성 보장 ▼▼▼
1136
+ nodeEntry.model.positionX = newPosX;
1137
+ nodeEntry.model.positionY = newPosY;
1138
+ // ▲▲▲ [Fix] ▲▲▲
1139
+ // ▲▲▲ [Changed] ▲▲▲
1140
+
1141
+ if (nodeEntry.cssObject) {
1142
+ const wrapper = nodeEntry.cssObject.element;
1143
+ // ▼▼▼ [Fix] CSS3D 노드는 resolutionScale을 고려하여 DOM 크기 설정 ▼▼▼
1144
+ // CSS3D 객체의 scale은 1/resolutionScale이므로, DOM 크기는 worldSize * resolutionScale
1145
+ const resolutionScale = nodeEntry.cssObject.userData?.resolutionScale || 1;
1146
+ const domWidth = nodeEntry.model.width * resolutionScale;
1147
+ const domHeight = nodeEntry.model.height * resolutionScale;
1148
+
1149
+ wrapper.style.width = domWidth + 'px';
1150
+ wrapper.style.height = domHeight + 'px';
1151
+ wrapper.style.minWidth = domWidth + 'px';
1152
+ wrapper.style.minHeight = domHeight + 'px';
1153
+ wrapper.style.maxWidth = domWidth + 'px';
1154
+ wrapper.style.maxHeight = domHeight + 'px';
1155
+
1156
+ const innerContent = wrapper.firstElementChild;
1157
+ if (innerContent) {
1158
+ // innerContent는 transform: scale(resolutionScale)이 적용되므로 원본 크기 사용
1159
+ innerContent.style.width = nodeEntry.model.width + 'px';
1160
+ innerContent.style.height = nodeEntry.model.height + 'px';
1161
+ innerContent.style.minWidth = nodeEntry.model.width + 'px';
1162
+ innerContent.style.minHeight = nodeEntry.model.height + 'px';
1163
+ innerContent.style.maxWidth = nodeEntry.model.width + 'px';
1164
+ innerContent.style.maxHeight = nodeEntry.model.height + 'px';
1165
+
1166
+ // ▼▼▼ [Fix] 이미지 노드의 경우 내부 img 요소 크기도 업데이트 ▼▼▼
1167
+ if (nodeEntry.model.contentType === 'image') {
1168
+ const imgEl = innerContent.querySelector('img');
1169
+ if (imgEl) {
1170
+ imgEl.style.width = '100%';
1171
+ imgEl.style.height = '100%';
1172
+ }
1173
+ // contentWrapper와 responseDiv 크기도 업데이트
1174
+ const contentWrapper = innerContent.querySelector('.node-content-wrapper');
1175
+ if (contentWrapper) {
1176
+ contentWrapper.style.width = '100%';
1177
+ contentWrapper.style.height = '100%';
1178
+ }
1179
+ const responseDiv = innerContent.querySelector('.node-response');
1180
+ if (responseDiv) {
1181
+ responseDiv.style.width = '100%';
1182
+ responseDiv.style.height = '100%';
1183
+ }
1184
+ }
1185
+ // ▲▲▲ [Fix] ▲▲▲
1186
+ }
1187
+
1188
+ // ▼▼▼ [Fix] userData도 업데이트하여 syncCss3dContent에서 올바른 크기 사용 ▼▼▼
1189
+ nodeEntry.cssObject.userData.worldWidth = nodeEntry.model.width;
1190
+ nodeEntry.cssObject.userData.worldHeight = nodeEntry.model.height;
1191
+ // ▲▲▲ [Fix] ▲▲▲
1192
+ }
1193
+
1194
+ if (window.MindMapCss3DManager?.syncAgentPlanStack) {
1195
+ window.MindMapCss3DManager.syncAgentPlanStack(module, nodeId, { persist: false });
1196
+ }
1197
+
1198
+ if (nodeEntry.glObject) {
1199
+ const group = nodeEntry.glObject;
1200
+
1201
+ const bodyMesh = group.getObjectByName('body');
1202
+ const outline = group.getObjectByName('outline');
1203
+ let glowMesh = group.getObjectByName('glow');
1204
+ if (!ENABLE_WEBGL_GLOW) {
1205
+ glowMesh = removeGlowMesh(group);
1206
+ }
1207
+ const CORNER_RADIUS = 20;
1208
+ const OUTLINE_SHRINK = 2;
1209
+ const OUTLINE_RADIUS = Math.max(1, CORNER_RADIUS - 1);
1210
+ const CHAMFER_SIZE = 16;
1211
+ if (bodyMesh) {
1212
+ bodyMesh.geometry.dispose();
1213
+ bodyMesh.geometry = new THREE.PlaneGeometry(nodeEntry.model.width, nodeEntry.model.height);
1214
+ bodyMesh.position.set(nodeEntry.model.width / 2, -nodeEntry.model.height / 2, 0);
1215
+ if (nodeEntry.model.contentType === 'image') {
1216
+ applyImageCoverUv(bodyMesh, nodeEntry.model.width, nodeEntry.model.height);
1217
+ }
1218
+ // ▼▼▼ [Fix] CSS 모드에서 리사이즈 중 body mesh 숨김 유지 (이중 렌더링 방지) ▼▼▼
1219
+ if (nodeEntry.currentResizeType === 'CSS') {
1220
+ bodyMesh.visible = false;
1221
+ }
1222
+ // ▲▲▲ [Fix] ▲▲▲
1223
+
1224
+ // ▼▼▼ [Image Resize Fix] Don't clear map during resize to prevent white flash/loss ▼▼▼
1225
+ // if (bodyMesh.material.map) {
1226
+ // bodyMesh.material.map = null;
1227
+ // bodyMesh.material.color.setHex(0xffffff);
1228
+ // bodyMesh.material.needsUpdate = true;
1229
+ // }
1230
+ // ▲▲▲ [Image Resize Fix] ▲▲▲
1231
+ const isSelected = module.multiSelectedNodeIds.has(module.resizingNodeId) || module.selectedNodeIdJs === module.resizingNodeId;
1232
+ if (nodeEntry.model.contentType === 'image') {
1233
+ const usesCssViewDuringResize =
1234
+ nodeEntry.currentResizeType === 'CSS' ||
1235
+ (nodeEntry.currentType === 'CSS' && !!nodeEntry.cssObject?.visible);
1236
+ syncImageSelectionGlow(
1237
+ group,
1238
+ nodeEntry.model.width,
1239
+ nodeEntry.model.height,
1240
+ group.userData?.baseRenderOrder ?? bodyMesh?.renderOrder ?? 0,
1241
+ isSelected && !usesCssViewDuringResize
1242
+ );
1243
+ window.MindMapNodes?.syncImageMidLodDecorations?.(
1244
+ group,
1245
+ nodeEntry.model.width,
1246
+ nodeEntry.model.height,
1247
+ group.userData?.baseRenderOrder ?? bodyMesh?.renderOrder ?? 0,
1248
+ isSelected,
1249
+ usesCssViewDuringResize
1250
+ ? { edgeVisible: false, glowVisible: false }
1251
+ : undefined
1252
+ );
1253
+ } else if (outline) {
1254
+ outline.geometry.dispose();
1255
+ outline.geometry = createRoundedRectGeometry(
1256
+ nodeEntry.model.width - OUTLINE_SHRINK,
1257
+ nodeEntry.model.height - OUTLINE_SHRINK,
1258
+ OUTLINE_RADIUS
1259
+ );
1260
+ outline.position.copy(bodyMesh.position);
1261
+ outline.position.z = 0; // [Fix] Z=0
1262
+ outline.visible = false; // Disabled - using SDF glow instead
1263
+ if (outline.material) {
1264
+ outline.material.color.setHex(isSelected ? 0x7b7ff2 : 0x9ca3af);
1265
+ outline.material.opacity = 1.0;
1266
+ }
1267
+ }
1268
+ }
1269
+
1270
+ // ▼▼▼ [Changed] Update glow position during resize (keep visible, no flickering) ▼▼▼
1271
+ if (ENABLE_WEBGL_GLOW && glowMesh) {
1272
+ // Update position to match bodyMesh
1273
+ glowMesh.position.set(nodeEntry.model.width / 2, -nodeEntry.model.height / 2, 0); // [Fix] Z=0
1274
+ // Don't hide - causes flickering
1275
+ }
1276
+ // ▲▲▲ [Changed] ▲▲▲
1277
+ const tailMesh = group.getObjectByName('tail');
1278
+ if (tailMesh) {
1279
+ tailMesh.position.set(nodeEntry.model.width / 2, -nodeEntry.model.height - 6, 0);
1280
+ }
1281
+ // ▼▼▼ [Changed] Update eight resize handles (4 corners + 4 edges with geometry) ▼▼▼
1282
+ const edgeWidthLength = Math.max(10, nodeEntry.model.width - HANDLE_SIZE * 2);
1283
+ const edgeHeightLength = Math.max(10, nodeEntry.model.height - HANDLE_SIZE * 2);
1284
+ const handleConfigs = {
1285
+ // ▼▼▼ [Fix] Corners Z=0 for parallax elimination ▼▼▼
1286
+ 'resizeHandle_TL': { x: HANDLE_SIZE / 2, y: -HANDLE_SIZE / 2, w: HANDLE_SIZE, h: HANDLE_SIZE, z: 0 },
1287
+ 'resizeHandle_TR': { x: nodeEntry.model.width - HANDLE_SIZE / 2, y: -HANDLE_SIZE / 2, w: HANDLE_SIZE, h: HANDLE_SIZE, z: 0 },
1288
+ 'resizeHandle_BL': { x: HANDLE_SIZE / 2, y: -nodeEntry.model.height + HANDLE_SIZE / 2, w: HANDLE_SIZE, h: HANDLE_SIZE, z: 0 },
1289
+ 'resizeHandle_BR': { x: nodeEntry.model.width - HANDLE_SIZE / 2, y: -nodeEntry.model.height + HANDLE_SIZE / 2, w: HANDLE_SIZE, h: HANDLE_SIZE, z: 0 },
1290
+ // Edges Z=0
1291
+ 'resizeHandle_T': { x: nodeEntry.model.width / 2, y: -HANDLE_SIZE / 2, w: edgeWidthLength, h: HANDLE_SIZE, z: 0 },
1292
+ 'resizeHandle_B': { x: nodeEntry.model.width / 2, y: -nodeEntry.model.height + HANDLE_SIZE / 2, w: edgeWidthLength, h: HANDLE_SIZE, z: 0 },
1293
+ 'resizeHandle_L': { x: HANDLE_SIZE / 2, y: -nodeEntry.model.height / 2, w: HANDLE_SIZE, h: edgeHeightLength, z: 0 },
1294
+ 'resizeHandle_R': { x: nodeEntry.model.width - HANDLE_SIZE / 2, y: -nodeEntry.model.height / 2, w: HANDLE_SIZE, h: edgeHeightLength, z: 0 }
1295
+ // ▲▲▲ [Fix] ▲▲▲
1296
+ };
1297
+ Object.entries(handleConfigs).forEach(([name, cfg]) => {
1298
+ const handle = group.getObjectByName(name);
1299
+ if (handle) {
1300
+ handle.position.set(cfg.x, cfg.y, cfg.z);
1301
+ const gp = handle.geometry?.parameters;
1302
+ if (gp && (gp.width !== cfg.w || gp.height !== cfg.h)) {
1303
+ handle.geometry.dispose();
1304
+ handle.geometry = new THREE.PlaneGeometry(cfg.w, cfg.h);
1305
+ }
1306
+ }
1307
+ });
1308
+ // ▲▲▲ [Changed] ▲▲▲
1309
+
1310
+ // ▼▼▼ [New] Update Troika text clipRect on resize for code nodes ▼▼▼
1311
+ if (nodeEntry.model.contentType === 'code') {
1312
+ const troikaText = group.userData.troikaText;
1313
+ if (troikaText && troikaText.userData) {
1314
+ const padding = troikaText.userData.padding || 16;
1315
+ const filenameHeight = troikaText.userData.filenameHeight || 0; // ★ Include filename height
1316
+ const newWidth = nodeEntry.model.width;
1317
+ const newHeight = nodeEntry.model.height;
1318
+ const newTextWidth = newWidth - padding * 2;
1319
+ const newVisibleHeight = newHeight - padding * 2 - filenameHeight; // ★ Subtract filename height
1320
+
1321
+ // Update userData dimensions
1322
+ troikaText.userData.nodeWidth = newWidth;
1323
+ troikaText.userData.nodeHeight = newHeight;
1324
+ troikaText.userData.textWidth = newTextWidth;
1325
+ troikaText.userData.visibleHeight = newVisibleHeight;
1326
+
1327
+ // ★ DO NOT update maxWidth - code nodes should keep maxWidth: Infinity (no word wrap)
1328
+ // troikaText.maxWidth = newTextWidth; // REMOVED - this forces word wrap
1329
+
1330
+ // Keep existing scroll and recalc maxScroll based on totalHeight
1331
+ const totalHeight = troikaText.userData.totalHeight || newVisibleHeight;
1332
+ const maxScroll = Math.max(0, totalHeight - newVisibleHeight);
1333
+ nodeEntry.model.maxScroll = maxScroll;
1334
+
1335
+ // Clamp scroll position
1336
+ let scrollY = troikaText.userData.scrollY || 0;
1337
+ if (scrollY > maxScroll) {
1338
+ scrollY = maxScroll;
1339
+ troikaText.userData.scrollY = scrollY;
1340
+ }
1341
+
1342
+ // Update clipRect - clip to visible area only
1343
+ const clipTop = -scrollY;
1344
+ const clipBottom = -scrollY - newVisibleHeight;
1345
+ troikaText.clipRect = [0, clipBottom, newTextWidth, clipTop];
1346
+ troikaText.sync();
1347
+
1348
+ // Update scrollbar - show/hide based on whether scrolling is needed
1349
+ const scrollbarTrack = group.getObjectByName('scrollbarTrack');
1350
+ const scrollbarThumb = group.getObjectByName('scrollbarThumb');
1351
+ const isCssMode = nodeEntry.currentType === 'CSS';
1352
+
1353
+ if (scrollbarTrack && scrollbarThumb) {
1354
+ // Keep native CSS scrollbar as the single source of truth in CSS mode.
1355
+ if (isCssMode || maxScroll <= 0) {
1356
+ scrollbarTrack.visible = false;
1357
+ scrollbarThumb.visible = false;
1358
+ } else {
1359
+ scrollbarTrack.visible = true;
1360
+ scrollbarThumb.visible = true;
1361
+
1362
+ const SCROLLBAR_WIDTH = 6;
1363
+ const SCROLLBAR_PADDING = 4;
1364
+ const SCROLLBAR_TOP_MARGIN = 16;
1365
+ const SCROLLBAR_BOTTOM_MARGIN = 16;
1366
+ const SCROLLBAR_RADIUS = 3;
1367
+ const trackHeight = newHeight - SCROLLBAR_TOP_MARGIN - SCROLLBAR_BOTTOM_MARGIN - filenameHeight; // ★ Include filenameHeight
1368
+ const trackX = newWidth - SCROLLBAR_WIDTH - SCROLLBAR_PADDING;
1369
+ const trackY = -SCROLLBAR_TOP_MARGIN - filenameHeight; // ★ Include filenameHeight
1370
+
1371
+ // Update track position and geometry
1372
+ scrollbarTrack.position.set(trackX, trackY, 3);
1373
+ if (scrollbarTrack.geometry) {
1374
+ scrollbarTrack.geometry.dispose();
1375
+ if (window.MindMapObjectManager?.createRoundedRectFilledGeometry) {
1376
+ scrollbarTrack.geometry = window.MindMapObjectManager.createRoundedRectFilledGeometry(SCROLLBAR_WIDTH, trackHeight, SCROLLBAR_RADIUS);
1377
+ } else {
1378
+ scrollbarTrack.geometry = new THREE.PlaneGeometry(SCROLLBAR_WIDTH, trackHeight);
1379
+ }
1380
+ }
1381
+
1382
+ // Update thumb
1383
+ const thumbHeightRatio = Math.min(1, newVisibleHeight / (maxScroll + newVisibleHeight));
1384
+ const thumbHeight = Math.max(24, trackHeight * thumbHeightRatio);
1385
+ const scrollRatio = scrollY / maxScroll;
1386
+ const thumbTravel = trackHeight - thumbHeight;
1387
+ const thumbY = trackY - (scrollRatio * thumbTravel); // ★ Use trackY which includes filenameHeight
1388
+
1389
+ scrollbarThumb.position.set(trackX, thumbY, 3.1);
1390
+ if (scrollbarThumb.geometry) {
1391
+ scrollbarThumb.geometry.dispose();
1392
+ if (window.MindMapObjectManager?.createRoundedRectFilledGeometry) {
1393
+ scrollbarThumb.geometry = window.MindMapObjectManager.createRoundedRectFilledGeometry(SCROLLBAR_WIDTH, thumbHeight, SCROLLBAR_RADIUS);
1394
+ } else {
1395
+ scrollbarThumb.geometry = new THREE.PlaneGeometry(SCROLLBAR_WIDTH, thumbHeight);
1396
+ }
1397
+ }
1398
+ scrollbarThumb.userData.trackHeight = trackHeight;
1399
+ scrollbarThumb.userData.thumbHeight = thumbHeight;
1400
+ }
1401
+ }
1402
+ }
1403
+ }
1404
+ // ▲▲▲ [New] ▲▲▲
1405
+
1406
+ // ▼▼▼ [New] Update glow shader dimensions on resize ▼▼▼
1407
+ if (ENABLE_WEBGL_GLOW && glowMesh && window.MindMapGlowShader?.updateGlowSize) {
1408
+ window.MindMapGlowShader.updateGlowSize(glowMesh, nodeEntry.model.width, nodeEntry.model.height, nodeEntry.model.contentType);
1409
+ }
1410
+ // ▲▲▲ [New] ▲▲▲
1411
+
1412
+ group.userData.worldWidth = nodeEntry.model.width;
1413
+ group.userData.worldHeight = nodeEntry.model.height;
1414
+ }
1415
+
1416
+ // ▼▼▼ [Fix] LOD 모드에서 리사이즈 시 InstancedMesh 실시간 업데이트 ▼▼▼
1417
+ if (module.lodRenderer?.isInLODMode && module.lodRenderer?.requestResidentPatch) {
1418
+ module.lodRenderer.requestResidentPatch('update-node', nodeId);
1419
+ }
1420
+ // ▲▲▲ [Fix] ▲▲▲
1421
+ }
1422
+
1423
+ function debouncedProcessRenderQueue(module, nodeId, updateData) {
1424
+ if (debounceTimers.has(nodeId)) {
1425
+ clearTimeout(debounceTimers.get(nodeId));
1426
+ }
1427
+ window.MindMapNodes.pendingNodeUpdates.set(nodeId, updateData);
1428
+ const timer = setTimeout(() => {
1429
+ enqueueRender(module, nodeId);
1430
+ debounceTimers.delete(nodeId);
1431
+ }, 100);
1432
+ debounceTimers.set(nodeId, timer);
1433
+ }
1434
+
1435
+ function throttledProcessRenderQueueDuringResize(module, nodeId, updateData) {
1436
+ const now = performance.now();
1437
+ const lastCallTime = lastResizeUpdateCall.get(nodeId) || 0;
1438
+
1439
+ if (now - lastCallTime < THROTTLE_INTERVAL) {
1440
+ window.MindMapNodes.pendingNodeUpdates.set(nodeId, updateData);
1441
+ if (resizeThrottleTimers.has(nodeId)) return;
1442
+ const timer = setTimeout(() => {
1443
+ const queuedUpdateData = window.MindMapNodes.pendingNodeUpdates.get(nodeId);
1444
+ if (queuedUpdateData) {
1445
+ enqueueRender(module, nodeId);
1446
+ }
1447
+ resizeThrottleTimers.delete(nodeId);
1448
+ }, THROTTLE_INTERVAL);
1449
+ resizeThrottleTimers.set(nodeId, timer);
1450
+ return;
1451
+ }
1452
+
1453
+ lastResizeUpdateCall.set(nodeId, now);
1454
+ window.MindMapNodes.pendingNodeUpdates.set(nodeId, updateData);
1455
+ enqueueRender(module, nodeId);
1456
+
1457
+ // ▼▼▼ [Fix] Maintain appropriate resize cursor during doResizing ▼▼▼
1458
+ let cursor = 'nwse-resize';
1459
+ switch (corner) {
1460
+ case 'TL': case 'BR': cursor = 'nwse-resize'; break;
1461
+ case 'TR': case 'BL': cursor = 'nesw-resize'; break;
1462
+ case 'T': case 'B': cursor = 'ns-resize'; break;
1463
+ case 'L': case 'R': cursor = 'ew-resize'; break;
1464
+ }
1465
+ document.body.style.cursor = cursor;
1466
+ // ▲▲▲ [Fix] ▲▲▲
1467
+ }
1468
+
1469
+ function throttledScrollUpdate(module, nodeId, scrollDelta) {
1470
+ const now = performance.now();
1471
+ const lastCallTime = lastScrollCall.get(nodeId) || 0;
1472
+
1473
+ if (now - lastCallTime < THROTTLE_INTERVAL) {
1474
+ return;
1475
+ }
1476
+ lastScrollCall.set(nodeId, now);
1477
+
1478
+ const nodeEntry = module.nodeObjectsById.get(nodeId);
1479
+ if (!nodeEntry) return;
1480
+
1481
+ const model = nodeEntry.model;
1482
+ const oldOffset = model.scrollOffset || 0;
1483
+ const maxScroll = model.maxScroll || 0;
1484
+ const newOffset = Math.max(0, Math.min(oldOffset + scrollDelta, maxScroll));
1485
+
1486
+ if (Math.abs(newOffset - oldOffset) < 0.1) return;
1487
+
1488
+ model.scrollOffset = newOffset;
1489
+ window.MindMapCss3DManager?.syncCss3dScrollFromModel?.(module, nodeId);
1490
+
1491
+ // ▼▼▼ [New] Code nodes: update Troika text via scrollCodeText ▼▼▼
1492
+ if (model.contentType === 'code' && nodeEntry.glObject) {
1493
+ const troikaText = nodeEntry.glObject.userData.troikaText;
1494
+ if (troikaText && window.MindMapTroikaText?.scrollCodeText) {
1495
+ // Use scrollCodeText which handles both position and clipRect
1496
+ const scrollResult = window.MindMapTroikaText.scrollCodeText(troikaText, scrollDelta);
1497
+ if (scrollResult) {
1498
+ model.scrollOffset = scrollResult.scrollY;
1499
+
1500
+ // ★ Update scrollbar thumb position (ShapeGeometry anchor is top-left, not center)
1501
+ const scrollbarThumb = nodeEntry.glObject.getObjectByName('scrollbarThumb');
1502
+ if (scrollbarThumb && scrollResult.maxScroll > 0) {
1503
+ const thumbData = scrollbarThumb.userData;
1504
+ const thumbTravel = thumbData.trackHeight - thumbData.thumbHeight;
1505
+ const thumbY = -thumbData.topMargin - (scrollResult.scrollRatio * thumbTravel);
1506
+ scrollbarThumb.position.y = thumbY;
1507
+ }
1508
+ }
1509
+
1510
+ // Persist scroll position to C#
1511
+ throttledUpdateScrollInCSharp(module, nodeId, model.scrollOffset);
1512
+ return;
1513
+ }
1514
+ }
1515
+ // ▲▲▲ [New] ▲▲▲
1516
+
1517
+ // ▼▼▼ [Refactor] Fetch content directly from C# (no cache) ▼▼▼
1518
+ if (model.isChunkedText && module.dotNetHelper) {
1519
+ // Cancel the previous request (process only the latest)
1520
+ if (model._scrollFetchController) {
1521
+ model._scrollFetchController.abort = true;
1522
+ }
1523
+ const controller = { abort: false };
1524
+ model._scrollFetchController = controller;
1525
+
1526
+ // Debounce: render after scrolling stops
1527
+ if (model._scrollDebounceTimer) {
1528
+ clearTimeout(model._scrollDebounceTimer);
1529
+ }
1530
+ model._scrollDebounceTimer = setTimeout(async () => {
1531
+ if (controller.abort) return;
1532
+
1533
+ try {
1534
+ const result = await module.dotNetHelper.invokeMethodAsync(
1535
+ 'GetTextByScrollPosition',
1536
+ nodeId,
1537
+ model.scrollOffset,
1538
+ model.height || 400,
1539
+ CHUNK_LINE_HEIGHT
1540
+ );
1541
+
1542
+ if (controller.abort || !result) return;
1543
+
1544
+ // Set content used for rendering
1545
+ model._visibleContent = result.content;
1546
+ model._visibleScrollOffset = result.relativeScrollOffset;
1547
+
1548
+ window.MindMapNodes.pendingNodeUpdates.set(nodeId, {
1549
+ content: result.content,
1550
+ width: model.width,
1551
+ height: model.height,
1552
+ scrollDelta: 0
1553
+ });
1554
+ enqueueRender(module, nodeId);
1555
+ } catch (e) {
1556
+ console.error(`[Pipeline] GetTextByScrollPosition error:`, e);
1557
+ }
1558
+ }, 16); // 16ms - 60fps throttle
1559
+
1560
+ // Persist scroll position to C#
1561
+ throttledUpdateScrollInCSharp(module, nodeId, model.scrollOffset);
1562
+ return;
1563
+ }
1564
+ // ▲▲▲ [Refactor] ▲▲▲
1565
+
1566
+ // Regular nodes (non-chunked)
1567
+ const updateData = {
1568
+ content: model.response,
1569
+ width: model.width,
1570
+ height: model.height,
1571
+ scrollDelta: 0
1572
+ };
1573
+ window.MindMapNodes.pendingNodeUpdates.set(nodeId, updateData);
1574
+ enqueueRender(module, nodeId);
1575
+
1576
+ // ▼▼▼ [New] Persist scroll position for regular nodes too ▼▼▼
1577
+ throttledUpdateScrollInCSharp(module, nodeId, model.scrollOffset);
1578
+ // ▲▲▲ [New] ▲▲▲
1579
+ }
1580
+
1581
+ // ▼▼▼ [New] Set absolute scroll position (for scrollbar drag) ▼▼▼
1582
+ function setScrollPosition(module, nodeId, absoluteScrollPosition) {
1583
+ const now = performance.now();
1584
+ const lastCallTime = lastScrollCall.get(nodeId) || 0;
1585
+
1586
+ // Throttling (ignore calls that arrive too quickly)
1587
+ if (now - lastCallTime < THROTTLE_INTERVAL) {
1588
+ return;
1589
+ }
1590
+ lastScrollCall.set(nodeId, now);
1591
+
1592
+ const nodeEntry = module.nodeObjectsById.get(nodeId);
1593
+ if (!nodeEntry) return;
1594
+
1595
+ const model = nodeEntry.model;
1596
+ const oldOffset = model.scrollOffset || 0;
1597
+ const maxScroll = model.maxScroll || 0;
1598
+ const newOffset = Math.max(0, Math.min(absoluteScrollPosition, maxScroll));
1599
+
1600
+ if (Math.abs(newOffset - oldOffset) < 0.1) return;
1601
+
1602
+ model.scrollOffset = newOffset;
1603
+ window.MindMapCss3DManager?.syncCss3dScrollFromModel?.(module, nodeId);
1604
+
1605
+ // ▼▼▼ [Refactor] Fetch content directly from C# (no cache) ▼▼▼
1606
+ if (model.isChunkedText && module.dotNetHelper) {
1607
+ // Cancel the previous request (process only the latest)
1608
+ if (model._scrollFetchController) {
1609
+ model._scrollFetchController.abort = true;
1610
+ }
1611
+ const controller = { abort: false };
1612
+ model._scrollFetchController = controller;
1613
+
1614
+ // Throttle: update every 16ms even during fast scrolling (~60fps)
1615
+ if (model._scrollDebounceTimer) {
1616
+ clearTimeout(model._scrollDebounceTimer);
1617
+ }
1618
+ model._scrollDebounceTimer = setTimeout(async () => {
1619
+ if (controller.abort) return;
1620
+
1621
+ try {
1622
+ const result = await module.dotNetHelper.invokeMethodAsync(
1623
+ 'GetTextByScrollPosition',
1624
+ nodeId,
1625
+ model.scrollOffset,
1626
+ model.height || 400,
1627
+ CHUNK_LINE_HEIGHT
1628
+ );
1629
+
1630
+ if (controller.abort || !result) return;
1631
+
1632
+ // Set content used for rendering
1633
+ model._visibleContent = result.content;
1634
+ model._visibleScrollOffset = result.relativeScrollOffset;
1635
+
1636
+ window.MindMapNodes.pendingNodeUpdates.set(nodeId, {
1637
+ content: result.content,
1638
+ width: model.width,
1639
+ height: model.height,
1640
+ scrollDelta: 0
1641
+ });
1642
+ enqueueRender(module, nodeId);
1643
+ } catch (e) {
1644
+ console.error(`[Pipeline] GetTextByScrollPosition error:`, e);
1645
+ }
1646
+ }, 16); // 16ms - 60fps throttle
1647
+
1648
+ // Persist scroll position to C#
1649
+ throttledUpdateScrollInCSharp(module, nodeId, model.scrollOffset);
1650
+ return;
1651
+ }
1652
+ // ▲▲▲ [Refactor] ▲▲▲
1653
+
1654
+ // Regular nodes (non-chunked)
1655
+ const updateData = {
1656
+ content: model.response,
1657
+ width: model.width,
1658
+ height: model.height,
1659
+ scrollDelta: 0
1660
+ };
1661
+ window.MindMapNodes.pendingNodeUpdates.set(nodeId, updateData);
1662
+ enqueueRender(module, nodeId);
1663
+
1664
+ // ▼▼▼ [New] Persist scroll position for regular nodes too ▼▼▼
1665
+ throttledUpdateScrollInCSharp(module, nodeId, model.scrollOffset);
1666
+ // ▲▲▲ [New] ▲▲▲
1667
+ }
1668
+ // ▲▲▲ [New] Set absolute scroll position (for scrollbar drag) ▲▲▲
1669
+
1670
+ function throttledUpdateScrollInCSharp(module, nodeId, scrollOffset) {
1671
+ if (scrollUpdateThrottleTimers.has(nodeId)) {
1672
+ clearTimeout(scrollUpdateThrottleTimers.get(nodeId));
1673
+ }
1674
+ const timer = setTimeout(() => {
1675
+ try {
1676
+ module.dotNetHelper?.invokeMethodAsync('UpdateNodeScroll', nodeId, scrollOffset);
1677
+ } catch (e) {
1678
+ console.error(`[Pipeline] Failed to update scroll in C# for node ${nodeId}:`, e);
1679
+ }
1680
+ scrollUpdateThrottleTimers.delete(nodeId);
1681
+ }, 100);
1682
+ scrollUpdateThrottleTimers.set(nodeId, timer);
1683
+ }
1684
+
1685
+ async function stopResizing(module) {
1686
+ if (!module.isResizing || !module.resizeNodeObject) return;
1687
+
1688
+ // ▼▼▼ [Changed] Use stored nodeId (CSS3D mode compatible) ▼▼▼
1689
+ const nodeId = module.resizingNodeId || (module.resizeNodeObject.userData?.nodeId || module.resizeNodeObject.element?.dataset?.nodeId);
1690
+ const nodeEntry = module.nodeObjectsById.get(nodeId);
1691
+
1692
+ if (nodeEntry && module.dotNetHelper) {
1693
+ const currentPosX = nodeEntry.glObject?.position?.x ?? nodeEntry.cssObject?.position?.x ?? 0;
1694
+ const currentPosY = nodeEntry.glObject?.position?.y ?? nodeEntry.cssObject?.position?.y ?? 0;
1695
+ let finalPosX = currentPosX;
1696
+ let finalPosY = currentPosY;
1697
+
1698
+ // Respect global snap toggle. Resize itself stays free-form while dragging;
1699
+ // snapping is applied only at the end when enabled.
1700
+ if (module.gridSnapEnabled !== false) {
1701
+ const GRID_SIZE = module.GRID_SIZE || 10;
1702
+ nodeEntry.model.width = Math.round(nodeEntry.model.width / GRID_SIZE) * GRID_SIZE;
1703
+ nodeEntry.model.height = Math.round(nodeEntry.model.height / GRID_SIZE) * GRID_SIZE;
1704
+ finalPosX = Math.round(currentPosX / GRID_SIZE) * GRID_SIZE;
1705
+ finalPosY = Math.round(currentPosY / GRID_SIZE) * GRID_SIZE;
1706
+ }
1707
+
1708
+ if (nodeEntry.glObject) {
1709
+ nodeEntry.glObject.position.x = finalPosX;
1710
+ nodeEntry.glObject.position.y = finalPosY;
1711
+ }
1712
+ if (nodeEntry.cssObject) {
1713
+ nodeEntry.cssObject.position.x = finalPosX;
1714
+ nodeEntry.cssObject.position.y = finalPosY;
1715
+ nodeEntry.cssObject.updateMatrixWorld(true);
1716
+
1717
+ // ▼▼▼ [Fix] 그리드 스냅 후 CSS3D DOM 크기도 업데이트 ▼▼▼
1718
+ const wrapper = nodeEntry.cssObject.element;
1719
+ if (wrapper) {
1720
+ const resolutionScale = nodeEntry.cssObject.userData?.resolutionScale || 1;
1721
+ const domWidth = nodeEntry.model.width * resolutionScale;
1722
+ const domHeight = nodeEntry.model.height * resolutionScale;
1723
+
1724
+ wrapper.style.width = domWidth + 'px';
1725
+ wrapper.style.height = domHeight + 'px';
1726
+ wrapper.style.minWidth = domWidth + 'px';
1727
+ wrapper.style.minHeight = domHeight + 'px';
1728
+ wrapper.style.maxWidth = domWidth + 'px';
1729
+ wrapper.style.maxHeight = domHeight + 'px';
1730
+
1731
+ const innerContent = wrapper.firstElementChild;
1732
+ if (innerContent) {
1733
+ innerContent.style.width = nodeEntry.model.width + 'px';
1734
+ innerContent.style.height = nodeEntry.model.height + 'px';
1735
+ innerContent.style.minWidth = nodeEntry.model.width + 'px';
1736
+ innerContent.style.minHeight = nodeEntry.model.height + 'px';
1737
+ innerContent.style.maxWidth = nodeEntry.model.width + 'px';
1738
+ innerContent.style.maxHeight = nodeEntry.model.height + 'px';
1739
+
1740
+ // ▼▼▼ [Fix] 이미지 노드의 경우 내부 img 요소 크기도 업데이트 ▼▼▼
1741
+ if (nodeEntry.model.contentType === 'image') {
1742
+ const imgEl = innerContent.querySelector('img');
1743
+ if (imgEl) {
1744
+ imgEl.style.width = '100%';
1745
+ imgEl.style.height = '100%';
1746
+ }
1747
+ const contentWrapper = innerContent.querySelector('.node-content-wrapper');
1748
+ if (contentWrapper) {
1749
+ contentWrapper.style.width = '100%';
1750
+ contentWrapper.style.height = '100%';
1751
+ }
1752
+ const responseDiv = innerContent.querySelector('.node-response');
1753
+ if (responseDiv) {
1754
+ responseDiv.style.width = '100%';
1755
+ responseDiv.style.height = '100%';
1756
+ }
1757
+ }
1758
+ // ▲▲▲ [Fix] ▲▲▲
1759
+ }
1760
+
1761
+ // userData 업데이트
1762
+ nodeEntry.cssObject.userData.worldWidth = nodeEntry.model.width;
1763
+ nodeEntry.cssObject.userData.worldHeight = nodeEntry.model.height;
1764
+ }
1765
+ // ▲▲▲ [Fix] ▲▲▲
1766
+ }
1767
+ try {
1768
+ await module.dotNetHelper.invokeMethodAsync(
1769
+ 'UpdateNodeDimensions',
1770
+ nodeId,
1771
+ Math.round(nodeEntry.model.width),
1772
+ Math.round(nodeEntry.model.height)
1773
+ );
1774
+ // ▼▼▼ [Changed] Also update position in C# ▼▼▼
1775
+ await module.dotNetHelper.invokeMethodAsync(
1776
+ 'UpdateNodePosition',
1777
+ nodeId,
1778
+ Math.round(finalPosX),
1779
+ Math.round(finalPosY)
1780
+ );
1781
+ // ▲▲▲ [Changed] ▲▲▲
1782
+ } catch (e) {
1783
+ console.error('[MindMapPipeline] Error updating dimensions/position:', e);
1784
+ }
1785
+
1786
+ if (window.MindMapCss3DManager?.syncAgentPlanStack) {
1787
+ try {
1788
+ await window.MindMapCss3DManager.syncAgentPlanStack(module, nodeId, { persist: true });
1789
+ } catch (error) {
1790
+ console.warn('[MindMapPipeline] Failed to sync agent plan stack after resize:', error);
1791
+ }
1792
+ }
1793
+
1794
+ // ▼▼▼ [UndoRedo] Record resize command after dimensions/position are persisted ▼▼▼
1795
+ try { await module.dotNetHelper.invokeMethodAsync('EndResizeWithUndo'); } catch { }
1796
+ // ▲▲▲ [UndoRedo] ▲▲▲
1797
+
1798
+ if (nodeEntry.glObject) {
1799
+ const outline = nodeEntry.glObject.getObjectByName('outline');
1800
+ let glowMesh = nodeEntry.glObject.getObjectByName('glow');
1801
+ if (!ENABLE_WEBGL_GLOW) {
1802
+ glowMesh = removeGlowMesh(nodeEntry.glObject);
1803
+ }
1804
+ const bodyMesh = nodeEntry.glObject.getObjectByName('body');
1805
+ const isSelected = module.multiSelectedNodeIds.has(nodeId) || module.selectedNodeIdJs === nodeId;
1806
+
1807
+ // ▼▼▼ [Fix] 그리드 스냅 후 body mesh 크기/위치 업데이트 ▼▼▼
1808
+ if (bodyMesh) {
1809
+ bodyMesh.geometry.dispose();
1810
+ bodyMesh.geometry = new THREE.PlaneGeometry(nodeEntry.model.width, nodeEntry.model.height);
1811
+ bodyMesh.position.set(nodeEntry.model.width / 2, -nodeEntry.model.height / 2, 0);
1812
+ if (nodeEntry.model.contentType === 'image') {
1813
+ applyImageCoverUv(bodyMesh, nodeEntry.model.width, nodeEntry.model.height);
1814
+ }
1815
+ // ▼▼▼ [Fix] CSS 모드에서 body mesh 숨김 유지 (이중 렌더링 방지) ▼▼▼
1816
+ if (nodeEntry.currentResizeType === 'CSS') {
1817
+ bodyMesh.visible = false;
1818
+ }
1819
+ // ▲▲▲ [Fix] ▲▲▲
1820
+ }
1821
+ // ▲▲▲ [Fix] ▲▲▲
1822
+
1823
+ if (nodeEntry.model.contentType === 'image') {
1824
+ const usesCssViewAfterResize =
1825
+ nodeEntry.currentResizeType === 'CSS' ||
1826
+ (nodeEntry.currentType === 'CSS' && !!nodeEntry.cssObject?.visible);
1827
+ syncImageSelectionGlow(
1828
+ nodeEntry.glObject,
1829
+ nodeEntry.model.width,
1830
+ nodeEntry.model.height,
1831
+ nodeEntry.glObject.userData?.baseRenderOrder ?? bodyMesh?.renderOrder ?? 0,
1832
+ isSelected && !usesCssViewAfterResize
1833
+ );
1834
+ window.MindMapNodes?.syncImageMidLodDecorations?.(
1835
+ nodeEntry.glObject,
1836
+ nodeEntry.model.width,
1837
+ nodeEntry.model.height,
1838
+ nodeEntry.glObject.userData?.baseRenderOrder ?? bodyMesh?.renderOrder ?? 0,
1839
+ isSelected,
1840
+ usesCssViewAfterResize
1841
+ ? { edgeVisible: false, glowVisible: false }
1842
+ : undefined
1843
+ );
1844
+ } else if (outline && outline.material) {
1845
+ outline.material.color.setHex(isSelected ? 0x7b7ff2 : 0x374151);
1846
+ outline.visible = false; // Disabled - using SDF glow instead
1847
+ }
1848
+
1849
+ // ▼▼▼ [Changed] After resizing, update glow using SDF shader ▼▼▼
1850
+ if (ENABLE_WEBGL_GLOW && glowMesh && window.MindMapGlowShader?.updateGlowSize) {
1851
+ const contentType = nodeEntry.model.contentType || 'text';
1852
+ window.MindMapGlowShader.updateGlowSize(glowMesh, nodeEntry.model.width, nodeEntry.model.height, contentType);
1853
+ glowMesh.position.set(
1854
+ nodeEntry.model.width / 2,
1855
+ -nodeEntry.model.height / 2,
1856
+ 0
1857
+ );
1858
+ // ★ 카메라 거리에 따라 가시성 설정
1859
+ const cameraZ = module.camera?.position?.z ?? Infinity;
1860
+ const threshold = 4000;
1861
+ glowMesh.visible = cameraZ < threshold;
1862
+ }
1863
+ // ▲▲▲ [Changed] ▲▲▲
1864
+
1865
+ // ▼▼▼ [Fix] 리사이즈 종료 후 8방향 핸들 위치 업데이트 ▼▼▼
1866
+ const HANDLE_SIZE = RESIZE_HANDLE_SIZE;
1867
+ const w = nodeEntry.model.width;
1868
+ const h = nodeEntry.model.height;
1869
+ const edgeWidthLength = Math.max(10, w - HANDLE_SIZE * 2);
1870
+ const edgeHeightLength = Math.max(10, h - HANDLE_SIZE * 2);
1871
+ const handleConfigs = {
1872
+ // Corners
1873
+ 'resizeHandle_TL': { x: HANDLE_SIZE / 2, y: -HANDLE_SIZE / 2, w: HANDLE_SIZE, h: HANDLE_SIZE, z: 0 },
1874
+ 'resizeHandle_TR': { x: w - HANDLE_SIZE / 2, y: -HANDLE_SIZE / 2, w: HANDLE_SIZE, h: HANDLE_SIZE, z: 0 },
1875
+ 'resizeHandle_BL': { x: HANDLE_SIZE / 2, y: -h + HANDLE_SIZE / 2, w: HANDLE_SIZE, h: HANDLE_SIZE, z: 0 },
1876
+ 'resizeHandle_BR': { x: w - HANDLE_SIZE / 2, y: -h + HANDLE_SIZE / 2, w: HANDLE_SIZE, h: HANDLE_SIZE, z: 0 }
1877
+ };
1878
+ if (nodeEntry.model.contentType !== 'image') {
1879
+ Object.assign(handleConfigs, {
1880
+ 'resizeHandle_T': { x: w / 2, y: -HANDLE_SIZE / 2, w: edgeWidthLength, h: HANDLE_SIZE, z: 0 },
1881
+ 'resizeHandle_B': { x: w / 2, y: -h + HANDLE_SIZE / 2, w: edgeWidthLength, h: HANDLE_SIZE, z: 0 },
1882
+ 'resizeHandle_L': { x: HANDLE_SIZE / 2, y: -h / 2, w: HANDLE_SIZE, h: edgeHeightLength, z: 0 },
1883
+ 'resizeHandle_R': { x: w - HANDLE_SIZE / 2, y: -h / 2, w: HANDLE_SIZE, h: edgeHeightLength, z: 0 }
1884
+ });
1885
+ }
1886
+ ['resizeHandle_TL', 'resizeHandle_TR', 'resizeHandle_BL', 'resizeHandle_BR', 'resizeHandle_T', 'resizeHandle_B', 'resizeHandle_L', 'resizeHandle_R'].forEach(name => {
1887
+ const handle = nodeEntry.glObject.getObjectByName(name);
1888
+ const cfg = handleConfigs[name];
1889
+ if (!handle) {
1890
+ return;
1891
+ }
1892
+ if (!cfg) {
1893
+ handle.visible = false;
1894
+ return;
1895
+ }
1896
+ handle.position.set(cfg.x, cfg.y, cfg.z);
1897
+ const gp = handle.geometry?.parameters;
1898
+ if (gp && (gp.width !== cfg.w || gp.height !== cfg.h)) {
1899
+ handle.geometry.dispose();
1900
+ handle.geometry = new THREE.PlaneGeometry(cfg.w, cfg.h);
1901
+ }
1902
+ if (nodeEntry.model.contentType === 'image' && handle.material) {
1903
+ handle.material.opacity = 0.0;
1904
+ handle.material.colorWrite = false;
1905
+ handle.material.needsUpdate = true;
1906
+ }
1907
+ });
1908
+ // ▲▲▲ [Fix] ▲▲▲
1909
+ }
1910
+
1911
+ if (debounceTimers.has(nodeId)) {
1912
+ clearTimeout(debounceTimers.get(nodeId));
1913
+ debounceTimers.delete(nodeId);
1914
+ }
1915
+ if (resizeThrottleTimers.has(nodeId)) {
1916
+ clearTimeout(resizeThrottleTimers.get(nodeId));
1917
+ resizeThrottleTimers.delete(nodeId);
1918
+ }
1919
+
1920
+ if (nodeEntry.currentResizeType === 'CSS') {
1921
+ console.log(`[stopResizing] CSS type - queuing texture update for ${nodeId}`);
1922
+
1923
+ // ▼▼▼ [Fix] CSS3D 노드의 최종 크기를 resolutionScale 고려하여 설정 ▼▼▼
1924
+ if (nodeEntry.cssObject && nodeEntry.cssObject.element) {
1925
+ const wrapper = nodeEntry.cssObject.element;
1926
+ const resolutionScale = nodeEntry.cssObject.userData?.resolutionScale || 1;
1927
+ const domWidth = nodeEntry.model.width * resolutionScale;
1928
+ const domHeight = nodeEntry.model.height * resolutionScale;
1929
+
1930
+ wrapper.style.width = domWidth + 'px';
1931
+ wrapper.style.height = domHeight + 'px';
1932
+ wrapper.style.minWidth = domWidth + 'px';
1933
+ wrapper.style.minHeight = domHeight + 'px';
1934
+ wrapper.style.maxWidth = domWidth + 'px';
1935
+ wrapper.style.maxHeight = domHeight + 'px';
1936
+
1937
+ const innerContent = wrapper.firstElementChild;
1938
+ if (innerContent) {
1939
+ innerContent.style.width = nodeEntry.model.width + 'px';
1940
+ innerContent.style.height = nodeEntry.model.height + 'px';
1941
+ innerContent.style.minWidth = nodeEntry.model.width + 'px';
1942
+ innerContent.style.minHeight = nodeEntry.model.height + 'px';
1943
+ innerContent.style.maxWidth = nodeEntry.model.width + 'px';
1944
+ innerContent.style.maxHeight = nodeEntry.model.height + 'px';
1945
+ }
1946
+
1947
+ // userData 업데이트
1948
+ nodeEntry.cssObject.userData.worldWidth = nodeEntry.model.width;
1949
+ nodeEntry.cssObject.userData.worldHeight = nodeEntry.model.height;
1950
+ }
1951
+ // ▲▲▲ [Fix] ▲▲▲
1952
+
1953
+ if (window.MindMapCss3DManager?.syncCss3dContent && nodeEntry.cssObject) {
1954
+ try {
1955
+ window.MindMapCss3DManager.syncCss3dContent(nodeEntry.model, nodeEntry.cssObject);
1956
+ } catch (error) {
1957
+ console.warn(`[stopResizing] Failed to sync CSS3D content after resize for ${nodeId}`, error);
1958
+ }
1959
+ }
1960
+
1961
+ window.MindMapTextOverlayV2?.updateNodePlacement?.(module, nodeId);
1962
+ window.MindMapTextOverlayV2?.invalidateNode?.(module, nodeId, 'layout');
1963
+ module._overlayDirty = true;
1964
+ module._lastOverlayKey = '';
1965
+
1966
+ const textarea = nodeEntry.cssObject.element.querySelector('textarea');
1967
+ if (textarea) {
1968
+ window.MindMapNodes.pendingNodeUpdates.set(nodeId, {
1969
+ content: textarea.value,
1970
+ width: nodeEntry.model.width,
1971
+ height: nodeEntry.model.height
1972
+ });
1973
+ enqueueRender(module, nodeId);
1974
+ }
1975
+ } else if (nodeEntry.currentResizeType === 'GL') {
1976
+ // ▼▼▼ [Refactor] After resizing a chunk node, fetch content directly from C# ▼▼▼
1977
+ if (nodeEntry.model.isChunkedText && module.dotNetHelper) {
1978
+ try {
1979
+ const result = await module.dotNetHelper.invokeMethodAsync(
1980
+ 'GetTextByScrollPosition',
1981
+ nodeId,
1982
+ nodeEntry.model.scrollOffset || 0,
1983
+ nodeEntry.model.height || 400,
1984
+ CHUNK_LINE_HEIGHT
1985
+ );
1986
+
1987
+ if (result) {
1988
+ nodeEntry.model._visibleContent = result.content;
1989
+ nodeEntry.model._visibleScrollOffset = result.relativeScrollOffset;
1990
+
1991
+ window.MindMapNodes.pendingNodeUpdates.set(nodeId, {
1992
+ content: result.content,
1993
+ width: nodeEntry.model.width,
1994
+ height: nodeEntry.model.height,
1995
+ scrollDelta: 0
1996
+ });
1997
+ enqueueRender(module, nodeId);
1998
+ }
1999
+ } catch (e) {
2000
+ console.error(`[Pipeline] GetTextByScrollPosition error on resize:`, e);
2001
+ }
2002
+ } else {
2003
+ // ▲▲▲ [Refactor] ▲▲▲
2004
+ // Regular nodes (non-chunked) + IMAGE NODES
2005
+
2006
+ // ▼▼▼ [Fix] 이미지 노드 리사이즈 시 기존 텍스처 유지 ▼▼▼
2007
+ if (nodeEntry.model.contentType === 'image') {
2008
+ // 이미지 노드는 URL이 변하지 않으므로 텍스처 재생성 불필요
2009
+ // geometry만 업데이트하고 기존 텍스처를 유지
2010
+ const bodyMesh = nodeEntry.glObject?.getObjectByName('body');
2011
+ if (bodyMesh && bodyMesh.material.map) {
2012
+ // 기존 텍스처 유지 - 아무것도 하지 않음
2013
+ console.log(`[stopResizing] Image node ${nodeId} - keeping existing texture`);
2014
+ } else {
2015
+ console.warn(`[stopResizing] Image node ${nodeId} - no texture found, may need reload`);
2016
+ }
2017
+ } else {
2018
+ // [Fix] Ensure regular nodes have content for texture generation
2019
+ const content = nodeEntry.model.response;
2020
+
2021
+ console.log(`[stopResizing] GL type - queuing texture update for ${nodeId}, size ${nodeEntry.model.width}x${nodeEntry.model.height}`);
2022
+ window.MindMapNodes.pendingNodeUpdates.set(nodeId, {
2023
+ content: content,
2024
+ width: nodeEntry.model.width,
2025
+ height: nodeEntry.model.height
2026
+ });
2027
+ enqueueRender(module, nodeId);
2028
+ }
2029
+ // ▲▲▲ [Fix] ▲▲▲
2030
+ }
2031
+ }
2032
+
2033
+ window.MindMapNodes.updateNodeSpatialGrid(module, nodeId);
2034
+
2035
+ // ▼▼▼ [Fix] 리사이즈 종료 후 CSS transition 복원 ▼▼▼
2036
+ if (nodeEntry.cssObject && nodeEntry.cssObject.element) {
2037
+ const wrapper = nodeEntry.cssObject.element;
2038
+ wrapper.style.transition = CSS3D_WRAPPER_TRANSITION;
2039
+ wrapper.style.webkitTransition = CSS3D_WRAPPER_WEBKIT_TRANSITION;
2040
+ const inner = wrapper.firstElementChild;
2041
+ if (inner) {
2042
+ inner.style.transition = 'none';
2043
+ inner.style.webkitTransition = 'none';
2044
+ }
2045
+ }
2046
+ // ▲▲▲ [Fix] ▲▲▲
2047
+ }
2048
+
2049
+ module.isResizing = false;
2050
+ module.resizeNodeObject = null;
2051
+ module.resizingNodeId = null; // ▼▼▼ [Changed] clear resizingNodeId ▼▼▼
2052
+ window.MindMapInteractions?.setBrowserSelectionLock?.(false, 'resize');
2053
+ document.body.classList.remove('is-resizing');
2054
+
2055
+ // ▼▼▼ [Fix] Reset cursor after resizing ▼▼▼
2056
+ document.body.style.cursor = '';
2057
+ // ▲▲▲ [Fix] ▲▲▲
2058
+ }
2059
+
2060
+ // Expose MindMapPipeline globally
2061
+ window.MindMapPipeline = {
2062
+ processRenderQueue: processRenderQueue,
2063
+ debouncedProcessRenderQueue: debouncedProcessRenderQueue,
2064
+ throttledScrollUpdate: throttledScrollUpdate,
2065
+ setScrollPosition: setScrollPosition, // [New] for scrollbar dragging
2066
+ throttledProcessRenderQueueDuringResize: throttledProcessRenderQueueDuringResize,
2067
+ startResizing: startResizing,
2068
+ doResizing: doResizing,
2069
+ stopResizing: stopResizing,
2070
+ throttledUpdateScrollInCSharp: throttledUpdateScrollInCSharp
2071
+ };
2072
+
2073
+ console.log('? mind-map-pipeline.js loaded');
2074
+ })();
2075
+