@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,2260 @@
1
+ (function () {
2
+ 'use strict';
3
+
4
+ // ▼▼▼ [Perf] Debug logging - set to false in production ▼▼▼
5
+ const DEBUG = false;
6
+ const log = DEBUG ? console.log.bind(console) : () => { };
7
+ const warn = DEBUG ? console.warn.bind(console) : () => { };
8
+ // ▲▲▲ [Perf] ▲▲▲
9
+
10
+ // ★ [메모리 보호] 텍스처 최대 높이 제한
11
+ // 4000px 높이 노드 = 64MB+ 메모리 → 2048px로 제한 = 최대 ~8MB
12
+ const MAX_TEXTURE_HEIGHT = 2048;
13
+
14
+ const tailTextureCache = new Map();
15
+ const renderers = new Map();
16
+ const failedImageAssetCache = new Map();
17
+ const pendingImageAssetFetches = new Map();
18
+ let sharedMeasurer = null;
19
+
20
+ function getSharedMeasurer() {
21
+ if (sharedMeasurer) return sharedMeasurer;
22
+ sharedMeasurer = document.createElement('div');
23
+ Object.assign(sharedMeasurer.style, {
24
+ position: 'absolute',
25
+ visibility: 'hidden',
26
+ left: '-9999px',
27
+ top: '-9999px',
28
+ zIndex: '-1000',
29
+ pointerEvents: 'none',
30
+ margin: '0',
31
+ padding: '0',
32
+ boxSizing: 'border-box',
33
+ width: 'auto'
34
+ });
35
+ document.body.appendChild(sharedMeasurer);
36
+ return sharedMeasurer;
37
+ }
38
+
39
+ function isNonRefreshableLocalUrl(url) {
40
+ if (!url || typeof url !== 'string') return true;
41
+ const normalized = url.trim().toLowerCase();
42
+ return normalized.startsWith('blob:')
43
+ || normalized.startsWith('data:')
44
+ || normalized.startsWith('file:');
45
+ }
46
+
47
+ async function requestFreshNodeAssetUrl(nodeId, failedUrl, options = {}) {
48
+ if (!nodeId || options.skipAssetRefresh === true || isNonRefreshableLocalUrl(failedUrl)) {
49
+ return null;
50
+ }
51
+
52
+ const helper = window.dotNetHelper;
53
+ if (!helper || typeof helper.invokeMethodAsync !== 'function') {
54
+ return null;
55
+ }
56
+
57
+ try {
58
+ const resolveMethodName = options.preferOriginal === true
59
+ ? 'ResolveNodeAssetUrl'
60
+ : 'ResolveNodePreviewAssetUrl';
61
+ const refreshedUrl = await helper.invokeMethodAsync(resolveMethodName, String(nodeId));
62
+ const normalized = typeof refreshedUrl === 'string' ? refreshedUrl.trim() : '';
63
+ if (!normalized || normalized === String(failedUrl || '').trim()) {
64
+ return null;
65
+ }
66
+
67
+ return normalized;
68
+ } catch (error) {
69
+ warn('[TextureFactory] Failed to refresh node asset URL', nodeId, error);
70
+ return null;
71
+ }
72
+ }
73
+
74
+ function normalizeImageAssetUrl(url) {
75
+ if (typeof url !== 'string') {
76
+ return '';
77
+ }
78
+
79
+ const trimmed = url.trim();
80
+ if (!trimmed) {
81
+ return '';
82
+ }
83
+
84
+ return trimmed
85
+ .replace(/^http:\/\/localhost(?=:\d+\/assets\/)/i, 'http://127.0.0.1')
86
+ .replace(/^http:\/\/\[::1\](?=:\d+\/assets\/)/i, 'http://127.0.0.1');
87
+ }
88
+
89
+ function isLoopbackImageAssetUrl(url) {
90
+ return /^http:\/\/(?:127(?:\.\d{1,3}){3}|localhost|\[::1\])(?::\d+)?\/assets\//i
91
+ .test(String(url || '').trim());
92
+ }
93
+
94
+ function getCanonicalImageAssetKey(url) {
95
+ const normalized = normalizeImageAssetUrl(url);
96
+ if (!normalized) {
97
+ return '';
98
+ }
99
+
100
+ const withoutQuery = normalized.split(/[?#]/)[0];
101
+ const lower = withoutQuery.toLowerCase();
102
+ const thumbsMarker = '/assets/thumbs/';
103
+ const assetsMarker = '/assets/';
104
+
105
+ if (lower.includes(thumbsMarker)) {
106
+ const assetName = withoutQuery.slice(lower.lastIndexOf(thumbsMarker) + thumbsMarker.length).split('/').pop() || '';
107
+ const dotIndex = assetName.lastIndexOf('.');
108
+ return (dotIndex > 0 ? assetName.slice(0, dotIndex) : assetName).toLowerCase();
109
+ }
110
+
111
+ if (lower.includes(assetsMarker)) {
112
+ const assetName = withoutQuery.slice(lower.lastIndexOf(assetsMarker) + assetsMarker.length).split('/').pop() || '';
113
+ const dotIndex = assetName.lastIndexOf('.');
114
+ return (dotIndex > 0 ? assetName.slice(0, dotIndex) : assetName).toLowerCase();
115
+ }
116
+
117
+ return withoutQuery.toLowerCase();
118
+ }
119
+
120
+ function areImageAssetUrlsEquivalent(leftUrl, rightUrl) {
121
+ const leftKey = getCanonicalImageAssetKey(leftUrl);
122
+ const rightKey = getCanonicalImageAssetKey(rightUrl);
123
+ if (leftKey && rightKey) {
124
+ return leftKey === rightKey;
125
+ }
126
+
127
+ return normalizeImageAssetUrl(leftUrl) === normalizeImageAssetUrl(rightUrl);
128
+ }
129
+
130
+ function isThumbnailAssetUrl(url) {
131
+ return normalizeImageAssetUrl(url).toLowerCase().includes('/assets/thumbs/');
132
+ }
133
+
134
+ function getImageSourceKind(url) {
135
+ return isThumbnailAssetUrl(url) ? 'thumbnail' : 'original';
136
+ }
137
+
138
+ function looksLikeExtensionlessAssetUrl(url) {
139
+ const normalized = normalizeImageAssetUrl(url);
140
+ if (!normalized || isNonRefreshableLocalUrl(normalized) || isThumbnailAssetUrl(normalized)) {
141
+ return false;
142
+ }
143
+
144
+ const withoutQuery = normalized.split(/[?#]/)[0];
145
+ const lower = withoutQuery.toLowerCase();
146
+ if (!lower.includes('/assets/')) {
147
+ return false;
148
+ }
149
+
150
+ const lastSegment = withoutQuery.split('/').pop() || '';
151
+ return !!lastSegment && !lastSegment.includes('.');
152
+ }
153
+
154
+ function looksLikeRelativeAssetUrl(url) {
155
+ const normalized = normalizeImageAssetUrl(url);
156
+ if (!normalized || isNonRefreshableLocalUrl(normalized)) {
157
+ return false;
158
+ }
159
+
160
+ const lower = normalized.toLowerCase();
161
+ if (lower.startsWith('/assets/')) {
162
+ return true;
163
+ }
164
+
165
+ return lower.startsWith('assets/');
166
+ }
167
+
168
+ function shouldResolveNodeAssetUrlBeforeFetch(url, options = {}) {
169
+ const normalized = normalizeImageAssetUrl(url);
170
+ if (!normalized || isNonRefreshableLocalUrl(normalized)) {
171
+ return false;
172
+ }
173
+
174
+ if (looksLikeExtensionlessAssetUrl(normalized) || looksLikeRelativeAssetUrl(normalized)) {
175
+ return true;
176
+ }
177
+
178
+ return false;
179
+ }
180
+
181
+ function getImageCacheNow() {
182
+ return typeof performance !== 'undefined' ? performance.now() : Date.now();
183
+ }
184
+
185
+ function getImageErrorStatus(error) {
186
+ const status = Number(error?.status || error?.errorStatus || error?.response?.status || 0);
187
+ return Number.isFinite(status) && status > 0 ? status : 0;
188
+ }
189
+
190
+ function getImageErrorRetryDelayMs(errorStatus) {
191
+ if (errorStatus === 404) {
192
+ return 60 * 1000;
193
+ }
194
+
195
+ return 15 * 1000;
196
+ }
197
+
198
+ function isImageContentType(value) {
199
+ const normalized = String(value || '').trim().toLowerCase();
200
+ return !normalized || normalized.startsWith('image/');
201
+ }
202
+
203
+ function createUnexpectedImageContentTypeError(targetUrl, contentType, blob = null) {
204
+ const error = new Error(`Unexpected image content-type: ${contentType || blob?.type || 'unknown'}`);
205
+ error.status = 415;
206
+ error.url = targetUrl;
207
+ error.contentType = contentType || '';
208
+ error.blobType = blob?.type || '';
209
+ error.blobSize = Number(blob?.size || 0);
210
+ return error;
211
+ }
212
+
213
+ async function readImageBlobFromResponse(response, targetUrl) {
214
+ const contentType = response.headers?.get?.('content-type') || '';
215
+ if (!isImageContentType(contentType)) {
216
+ throw createUnexpectedImageContentTypeError(targetUrl, contentType);
217
+ }
218
+
219
+ const blob = await response.blob();
220
+ if (!isImageContentType(blob?.type || contentType)) {
221
+ throw createUnexpectedImageContentTypeError(targetUrl, contentType, blob);
222
+ }
223
+
224
+ return blob;
225
+ }
226
+
227
+ function createCachedImageErrorEntry(url, resizeWidth, errorStatus, overrides = {}) {
228
+ const overrideFailedAt = Number(overrides?.failedAt || 0);
229
+ const now = Number.isFinite(overrideFailedAt) && overrideFailedAt > 0
230
+ ? overrideFailedAt
231
+ : getImageCacheNow();
232
+ const overrideRetryAfter = Number(overrides?.retryAfter || 0);
233
+ const retryAfter = Number.isFinite(overrideRetryAfter) && overrideRetryAfter > 0
234
+ ? overrideRetryAfter
235
+ : now + getImageErrorRetryDelayMs(errorStatus);
236
+ const errorCacheKey = String(overrides?.errorCacheKey || getImageAssetErrorCacheKey(url) || '');
237
+ return {
238
+ image: null,
239
+ url,
240
+ width: 0,
241
+ height: 0,
242
+ requestedWidth: resizeWidth,
243
+ sourceKind: getImageSourceKind(url),
244
+ assetKey: String(overrides?.assetKey || getCanonicalImageAssetKey(url) || ''),
245
+ errorCacheKey,
246
+ isError: true,
247
+ errorStatus,
248
+ failedAt: now,
249
+ retryAfter
250
+ };
251
+ }
252
+
253
+ function getImageAssetErrorCacheKey(url) {
254
+ const normalized = normalizeImageAssetUrl(url);
255
+ if (!normalized) {
256
+ return '';
257
+ }
258
+
259
+ const sourceKind = getImageSourceKind(normalized);
260
+ const assetKey = getCanonicalImageAssetKey(normalized) || normalized;
261
+ return `${sourceKind}:${assetKey}`;
262
+ }
263
+
264
+ function getFailedImageAssetEntry(url) {
265
+ const cacheKey = getImageAssetErrorCacheKey(url);
266
+ if (!cacheKey) {
267
+ return null;
268
+ }
269
+
270
+ const failedEntry = failedImageAssetCache.get(cacheKey) || null;
271
+ if (!failedEntry) {
272
+ return null;
273
+ }
274
+
275
+ const retryAfter = Number(failedEntry.retryAfter || 0);
276
+ if (retryAfter <= getImageCacheNow()) {
277
+ failedImageAssetCache.delete(cacheKey);
278
+ return null;
279
+ }
280
+
281
+ return failedEntry;
282
+ }
283
+
284
+ function storeFailedImageAssetEntry(errorEntry) {
285
+ const cacheKey = String(errorEntry?.errorCacheKey || getImageAssetErrorCacheKey(errorEntry?.url || '') || '');
286
+ if (!cacheKey) {
287
+ return errorEntry;
288
+ }
289
+
290
+ failedImageAssetCache.set(cacheKey, {
291
+ url: String(errorEntry?.url || ''),
292
+ assetKey: String(errorEntry?.assetKey || ''),
293
+ sourceKind: String(errorEntry?.sourceKind || ''),
294
+ errorStatus: Number(errorEntry?.errorStatus || 0),
295
+ failedAt: Number(errorEntry?.failedAt || 0),
296
+ retryAfter: Number(errorEntry?.retryAfter || 0),
297
+ errorCacheKey: cacheKey
298
+ });
299
+ return errorEntry;
300
+ }
301
+
302
+ function clearFailedImageAssetEntry(url) {
303
+ const cacheKey = getImageAssetErrorCacheKey(url);
304
+ if (!cacheKey) {
305
+ return;
306
+ }
307
+
308
+ failedImageAssetCache.delete(cacheKey);
309
+ }
310
+
311
+ function createSyntheticImageFetchError(failedEntry, url) {
312
+ const status = Number(failedEntry?.errorStatus || 0);
313
+ const error = new Error(status > 0 ? `HTTP ${status}` : 'Cached image fetch failure');
314
+ error.status = status;
315
+ error.url = url;
316
+ error.retryAfter = Number(failedEntry?.retryAfter || 0);
317
+ error.failedAt = Number(failedEntry?.failedAt || 0);
318
+ error.assetKey = String(failedEntry?.assetKey || '');
319
+ error.errorCacheKey = String(failedEntry?.errorCacheKey || '');
320
+ return error;
321
+ }
322
+
323
+ function getRequestedResizeWidth(options = {}) {
324
+ const parsed = Number(options.resizeWidth);
325
+ if (Number.isFinite(parsed) && parsed > 0) {
326
+ return Math.max(1, Math.round(parsed));
327
+ }
328
+
329
+ return 512;
330
+ }
331
+
332
+ const IMAGE_CACHE_RECORD_FLAG = '__mindMapImageRecord';
333
+
334
+ function isImageCacheRecord(entry) {
335
+ return !!entry && entry[IMAGE_CACHE_RECORD_FLAG] === true;
336
+ }
337
+
338
+ function normalizeImageCacheVariantKind(value) {
339
+ if (String(value || '').toLowerCase() === 'thumbnail' || isThumbnailAssetUrl(value || '')) {
340
+ return 'thumbnail';
341
+ }
342
+ return 'original';
343
+ }
344
+
345
+ function createImageCacheRecord(existing = null) {
346
+ if (isImageCacheRecord(existing)) {
347
+ return existing;
348
+ }
349
+
350
+ const record = {
351
+ [IMAGE_CACHE_RECORD_FLAG]: true,
352
+ thumbnail: null,
353
+ original: null,
354
+ fullResError: null,
355
+ lastRequestedKind: '',
356
+ lastAccessAt: 0
357
+ };
358
+
359
+ if (existing && typeof existing === 'object') {
360
+ const variantKind = normalizeImageCacheVariantKind(
361
+ existing.sourceKind || existing.url || existing.src || existing.image?.src || ''
362
+ );
363
+ record[variantKind] = existing;
364
+ if (existing.fullResError) {
365
+ record.fullResError = { ...existing.fullResError };
366
+ }
367
+ }
368
+
369
+ return record;
370
+ }
371
+
372
+ function getImageCacheOriginalError(existing) {
373
+ if (!existing) return null;
374
+ if (isImageCacheRecord(existing)) {
375
+ return existing.fullResError
376
+ || (existing.original?.isError === true ? existing.original : null)
377
+ || existing.thumbnail?.fullResError
378
+ || existing.original?.fullResError
379
+ || null;
380
+ }
381
+ return existing.fullResError
382
+ || ((existing.isError === true && normalizeImageCacheVariantKind(existing.sourceKind || existing.url || '') === 'original') ? existing : null)
383
+ || null;
384
+ }
385
+
386
+ function setImageCacheOriginalError(existing, errorEntry) {
387
+ if (!existing || !errorEntry) return null;
388
+
389
+ const nextError = {
390
+ url: errorEntry.url,
391
+ assetKey: errorEntry.assetKey,
392
+ errorCacheKey: errorEntry.errorCacheKey,
393
+ errorStatus: errorEntry.errorStatus,
394
+ failedAt: errorEntry.failedAt,
395
+ retryAfter: errorEntry.retryAfter
396
+ };
397
+
398
+ if (isImageCacheRecord(existing)) {
399
+ existing.fullResError = nextError;
400
+ if (existing.thumbnail && existing.thumbnail.isError !== true) {
401
+ existing.thumbnail.fullResError = nextError;
402
+ }
403
+ if (existing.original && existing.original.isError !== true) {
404
+ existing.original.fullResError = nextError;
405
+ }
406
+ return existing;
407
+ }
408
+
409
+ existing.fullResError = nextError;
410
+ return existing;
411
+ }
412
+
413
+ function clearImageCacheOriginalError(existing) {
414
+ if (!existing) return;
415
+ if (isImageCacheRecord(existing)) {
416
+ existing.fullResError = null;
417
+ if (existing.thumbnail) existing.thumbnail.fullResError = null;
418
+ if (existing.original) existing.original.fullResError = null;
419
+ return;
420
+ }
421
+ existing.fullResError = null;
422
+ }
423
+
424
+ function getUsableCachedImageEntry(existing) {
425
+ if (!existing) return null;
426
+ if (isImageCacheRecord(existing)) {
427
+ if (existing.thumbnail?.isError !== true && existing.thumbnail?.image) return existing.thumbnail;
428
+ if (existing.original?.isError !== true && existing.original?.image) return existing.original;
429
+ return null;
430
+ }
431
+ return existing.isError !== true && existing.image ? existing : null;
432
+ }
433
+
434
+ function canReuseSingleCachedImageEntry(existing, requestedUrl, options = {}) {
435
+ if (!existing) {
436
+ return false;
437
+ }
438
+
439
+ const normalizedRequestedUrl = normalizeImageAssetUrl(requestedUrl);
440
+ if (!normalizedRequestedUrl) {
441
+ return false;
442
+ }
443
+
444
+ const normalizedOriginalUrl = normalizeImageAssetUrl(options.originalUrl);
445
+ const preferOriginal = options.preferOriginal === true;
446
+ const effectiveRequestedUrl = preferOriginal && normalizedOriginalUrl
447
+ ? normalizedOriginalUrl
448
+ : normalizedRequestedUrl;
449
+ const existingUrl = normalizeImageAssetUrl(existing.url || existing.src || existing.image?.src);
450
+ if (!existingUrl) {
451
+ return false;
452
+ }
453
+
454
+ if (existing.isError === true) {
455
+ const retryAfter = Number(existing.retryAfter || 0);
456
+ return areImageAssetUrlsEquivalent(existingUrl, effectiveRequestedUrl) && retryAfter > getImageCacheNow();
457
+ }
458
+
459
+ const existingSourceKind = existing.sourceKind || getImageSourceKind(existingUrl);
460
+ const requestedWidth = getRequestedResizeWidth(options);
461
+ const existingRequestedWidth = Number(existing.requestedWidth || 0);
462
+ const existingWidth = Number(existing.width || existing.image?.naturalWidth || existing.image?.width || 0);
463
+
464
+ const matchesRequestedUrl = existingUrl === normalizedRequestedUrl;
465
+ const matchesOriginalUrl = !!normalizedOriginalUrl && existingUrl === normalizedOriginalUrl;
466
+ const canUseOriginalForPreview =
467
+ options.allowOriginalReuseForPreview !== false &&
468
+ preferOriginal !== true &&
469
+ existingSourceKind === 'original' &&
470
+ (matchesOriginalUrl || isThumbnailAssetUrl(normalizedRequestedUrl));
471
+
472
+ if (!(matchesRequestedUrl || matchesOriginalUrl || canUseOriginalForPreview)) {
473
+ return false;
474
+ }
475
+
476
+ if (preferOriginal && existingSourceKind !== 'original') {
477
+ return false;
478
+ }
479
+
480
+ if (existingSourceKind === 'thumbnail') {
481
+ return true;
482
+ }
483
+
484
+ if (!(requestedWidth > 0)) {
485
+ return true;
486
+ }
487
+
488
+ return existingRequestedWidth >= requestedWidth || existingWidth >= (requestedWidth * 0.95);
489
+ }
490
+
491
+ function resolveCachedImageEntry(existing, requestedUrl, options = {}) {
492
+ if (!existing) {
493
+ return null;
494
+ }
495
+
496
+ if (!isImageCacheRecord(existing)) {
497
+ return canReuseSingleCachedImageEntry(existing, requestedUrl, options) ? existing : null;
498
+ }
499
+
500
+ const preferOriginal = options.preferOriginal === true;
501
+ const candidates = preferOriginal
502
+ ? [existing.original, existing.thumbnail]
503
+ : [existing.thumbnail, existing.original];
504
+
505
+ for (let i = 0; i < candidates.length; i++) {
506
+ const candidate = candidates[i];
507
+ if (canReuseSingleCachedImageEntry(candidate, requestedUrl, options)) {
508
+ existing.lastRequestedKind = normalizeImageCacheVariantKind(candidate?.sourceKind || candidate?.url || '');
509
+ existing.lastAccessAt = getImageCacheNow();
510
+ return candidate;
511
+ }
512
+ }
513
+
514
+ return null;
515
+ }
516
+
517
+ function storeImageCacheEntry(imageCache, nodeId, cachedEntry) {
518
+ const existing = imageCache.get(nodeId) || null;
519
+ const record = createImageCacheRecord(existing);
520
+ const variantKind = normalizeImageCacheVariantKind(cachedEntry?.sourceKind || cachedEntry?.url || '');
521
+ record.lastRequestedKind = variantKind;
522
+ record.lastAccessAt = getImageCacheNow();
523
+ record[variantKind] = cachedEntry;
524
+
525
+ if (variantKind === 'thumbnail') {
526
+ const originalError = getImageCacheOriginalError(record);
527
+ if (originalError && cachedEntry && cachedEntry.isError !== true) {
528
+ cachedEntry.fullResError = originalError;
529
+ }
530
+ } else if (cachedEntry?.isError === true) {
531
+ setImageCacheOriginalError(record, cachedEntry);
532
+ } else {
533
+ clearImageCacheOriginalError(record);
534
+ }
535
+
536
+ imageCache.set(nodeId, record);
537
+ return cachedEntry;
538
+ }
539
+
540
+ function getDeferredOriginalImageError(existing, requestedUrl, resizeWidth, options = {}) {
541
+ if (!existing || options.preferOriginal !== true) {
542
+ return null;
543
+ }
544
+
545
+ const fallbackEntry = getUsableCachedImageEntry(existing);
546
+ if (!fallbackEntry) {
547
+ return null;
548
+ }
549
+
550
+ const originalError = getImageCacheOriginalError(existing);
551
+ const retryAfter = Number(originalError?.retryAfter || 0);
552
+ if (!(retryAfter > getImageCacheNow())) {
553
+ return null;
554
+ }
555
+
556
+ const normalizedRequestedUrl = normalizeImageAssetUrl(requestedUrl);
557
+ const normalizedOriginalUrl = normalizeImageAssetUrl(options.originalUrl);
558
+ const effectiveRequestedUrl = normalizedOriginalUrl || normalizedRequestedUrl;
559
+ if (!effectiveRequestedUrl) {
560
+ return null;
561
+ }
562
+
563
+ if (!areImageAssetUrlsEquivalent(originalError?.url || '', effectiveRequestedUrl)) {
564
+ return null;
565
+ }
566
+
567
+ return createCachedImageErrorEntry(
568
+ effectiveRequestedUrl,
569
+ resizeWidth,
570
+ getImageErrorStatus(originalError),
571
+ {
572
+ failedAt: Number(originalError?.failedAt || 0),
573
+ retryAfter,
574
+ assetKey: String(originalError?.assetKey || getCanonicalImageAssetKey(effectiveRequestedUrl) || '')
575
+ }
576
+ );
577
+ }
578
+
579
+ function rememberOriginalImageError(existing, requestedUrl, resizeWidth, errorStatus, options = {}) {
580
+ if (!existing || options.preferOriginal !== true) {
581
+ return null;
582
+ }
583
+
584
+ const fallbackEntry = getUsableCachedImageEntry(existing);
585
+ if (!fallbackEntry) {
586
+ return null;
587
+ }
588
+
589
+ const errorEntry = createCachedImageErrorEntry(requestedUrl, resizeWidth, errorStatus);
590
+ setImageCacheOriginalError(existing, errorEntry);
591
+ return storeFailedImageAssetEntry(errorEntry);
592
+ }
593
+
594
+ function canReuseCachedImageEntry(existing, requestedUrl, options = {}) {
595
+ return resolveCachedImageEntry(existing, requestedUrl, options);
596
+ }
597
+
598
+ function preprocessMarkdown(text) {
599
+ if (!text) return '';
600
+ let processed = text.trim();
601
+ processed = processed.replace(/([).]*)(\*\*)(?=[가-힣])/g, '$1 $2');
602
+ processed = processed.replace(/([^\n])\n([\*]{1,2}[\d]+\.|[\d]+\.)/g, '$1\n\n$2');
603
+ processed = processed.replace(/([^\n])\n([-*] )/g, '$1\n\n$2');
604
+ return processed;
605
+ }
606
+
607
+ function getFileExtensionLower(pathOrName) {
608
+ if (!pathOrName || typeof pathOrName !== 'string') return '';
609
+ const normalized = pathOrName.split(/[?#]/)[0].trim();
610
+ const fileName = normalized.split(/[\\/]/).pop() || '';
611
+ const dot = fileName.lastIndexOf('.');
612
+ if (dot < 0 || dot === fileName.length - 1) return '';
613
+ return fileName.slice(dot).toLowerCase();
614
+ }
615
+
616
+ function withTypePrefix(label, prefix) {
617
+ if (!label) return '';
618
+ const trimmed = String(label).trim();
619
+ if (!trimmed) return '';
620
+ if (trimmed.startsWith(prefix)) return trimmed;
621
+ return `${prefix} ${trimmed}`;
622
+ }
623
+
624
+ function getDisplayPrompt(nodeModel, rawPrompt) {
625
+ const prompt = String(rawPrompt || '').trim();
626
+ if (!prompt) return '';
627
+
628
+ const contentType = String(nodeModel?.contentType ?? nodeModel?.ContentType ?? '').toLowerCase();
629
+ const sourceFilePath = String(nodeModel?.sourceFilePath ?? nodeModel?.SourceFilePath ?? '');
630
+ const ext = getFileExtensionLower(sourceFilePath) || getFileExtensionLower(prompt);
631
+ const defaultNotePrompts = ['Note', 'Pasted Text', '에이전트 메모'];
632
+
633
+ // AI 노드는 현재 note 타입으로 생성되므로, 기본 노트명이 아닌 note 프롬프트는 ✨ 접두어로 구분
634
+ if (contentType === 'note' && !defaultNotePrompts.includes(prompt)) {
635
+ return withTypePrefix(prompt, '✨');
636
+ }
637
+
638
+ if (contentType === 'directory' || contentType === 'folder') {
639
+ return withTypePrefix(prompt, '📂');
640
+ }
641
+ if (contentType === 'code') {
642
+ return withTypePrefix(prompt, '</>');
643
+ }
644
+ if ((contentType === 'text' || contentType === 'markdown') && (ext === '.txt' || ext === '.md')) {
645
+ return withTypePrefix(prompt, '📄');
646
+ }
647
+ return prompt;
648
+ }
649
+
650
+ const ICON_SPINNER_SVG = `
651
+ <svg viewBox="0 0 512 512" style="width:1em; height:1em; fill:currentColor; vertical-align:-0.125em;">
652
+ <path d="M304 48a48 48 0 1 0 -96 0 48 48 0 1 0 96 0zm0 416a48 48 0 1 0 -96 0 48 48 0 1 0 96 0zM48 304a48 48 0 1 0 0-96 48 48 0 1 0 0 96zm464-48a48 48 0 1 0 -96 0 48 48 0 1 0 96 0zM142.9 437A48 48 0 1 0 75 369.1 48 48 0 1 0 142.9 437zm0-294.2A48 48 0 1 0 75 75a48 48 0 1 0 67.9 67.9zM369.1 437A48 48 0 1 0 437 369.1 48 48 0 1 0 369.1 437z"/>
653
+ </svg>`;
654
+
655
+ const BROKEN_IMAGE_ICON_SVG = `
656
+ <svg viewBox="0 0 24 24" style="width:100%; height:100%; fill:none; stroke:#9ca3af; stroke-width:2;">
657
+ <path stroke-linecap="round" stroke-linejoin="round" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
658
+ </svg>`;
659
+ const BROKEN_IMAGE_ICON_DATA_URL = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(BROKEN_IMAGE_ICON_SVG)}`;
660
+ const brokenImageIcon = new Image();
661
+ brokenImageIcon.src = BROKEN_IMAGE_ICON_DATA_URL;
662
+
663
+ const MEMO_TEXTURE_THEMES = {
664
+ coral: { fill: '#fff1eb', border: '#f7b08d', accent: '#ff8052' },
665
+ amber: { fill: '#fff7df', border: '#f4d06f', accent: '#f59e0b' },
666
+ sun: { fill: '#fff9d7', border: '#e7d668', accent: '#ca8a04' },
667
+ sage: { fill: '#eef6e7', border: '#bed79f', accent: '#65a30d' },
668
+ mint: { fill: '#e8faf4', border: '#8fd8c1', accent: '#10b981' },
669
+ sky: { fill: '#e8f4ff', border: '#9dc7f7', accent: '#3b82f6' },
670
+ indigo: { fill: '#eef0ff', border: '#bcc2ff', accent: '#6366f1' },
671
+ violet: { fill: '#f5ecff', border: '#d6b8ff', accent: '#8b5cf6' },
672
+ rose: { fill: '#ffeef2', border: '#f5b2c2', accent: '#f43f5e' },
673
+ gray: { fill: '#f3f4f6', border: '#d1d5db', accent: '#6b7280' },
674
+ slate: { fill: '#f3f5f7', border: '#cbd5e1', accent: '#64748b' }
675
+ };
676
+
677
+ function getNodeMetadata(nodeModel) {
678
+ return nodeModel?.metadata || nodeModel?.Metadata || null;
679
+ }
680
+
681
+ function isAgentStyledMemoNode(nodeModel) {
682
+ const metadata = getNodeMetadata(nodeModel);
683
+ const semanticType = String(metadata?.SemanticType || metadata?.semanticType || '').trim();
684
+ return semanticType === 'MindCanvasAgent' || semanticType === 'AgentCommand';
685
+ }
686
+
687
+ function getMemoTextureTheme(nodeModel) {
688
+ const fallbackKey = 'coral';
689
+ const key = String(getNodeMetadata(nodeModel)?.memoColor || fallbackKey).toLowerCase();
690
+ return MEMO_TEXTURE_THEMES[key] || MEMO_TEXTURE_THEMES[fallbackKey];
691
+ }
692
+
693
+ function getTailColors(nodeModel, isSelected) {
694
+ const contentType = String(nodeModel?.contentType ?? nodeModel?.ContentType ?? '').toLowerCase();
695
+ if (contentType !== 'memo') {
696
+ const neutralSelectedStroke = ['text', 'note', 'code', 'markdown'].includes(contentType)
697
+ ? '#7b7ff2'
698
+ : '#7b7ff2';
699
+ const defaultStroke =
700
+ contentType === 'note' || contentType === 'image' || contentType === 'video'
701
+ ? 'transparent'
702
+ : 'rgba(55, 65, 81, 0.9)';
703
+ return {
704
+ fill: '#fefefe',
705
+ stroke: isSelected ? neutralSelectedStroke : defaultStroke
706
+ };
707
+ }
708
+
709
+ const theme = getMemoTextureTheme(nodeModel);
710
+ return {
711
+ fill: theme.fill,
712
+ stroke: isSelected ? theme.accent : theme.border
713
+ };
714
+ }
715
+
716
+ function createTailTexture(color, borderColor, zoomLevel, tailSize, isSelected) {
717
+ const key = `${color}-${borderColor}-${zoomLevel}-${isSelected}`;
718
+ if (tailTextureCache.has(key)) return tailTextureCache.get(key);
719
+
720
+ const borderWidth = (isSelected ? 4 : 3) * zoomLevel;
721
+ const tailHeight = tailSize * zoomLevel;
722
+ const tailWidth = tailHeight * 2;
723
+ const canvas = document.createElement('canvas');
724
+ canvas.width = tailWidth + borderWidth * 2;
725
+ canvas.height = tailHeight + borderWidth;
726
+ const ctx = canvas.getContext('2d');
727
+
728
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
729
+ ctx.fillStyle = color;
730
+ ctx.beginPath();
731
+ ctx.moveTo(borderWidth, borderWidth / 2);
732
+ ctx.lineTo(canvas.width / 2, canvas.height - borderWidth / 2);
733
+ ctx.lineTo(canvas.width - borderWidth, borderWidth / 2);
734
+ ctx.closePath();
735
+ ctx.fill();
736
+
737
+ const hasVisibleBorder = !!borderColor && borderColor !== 'transparent' && borderColor !== 'rgba(0, 0, 0, 0)' && borderColor !== 'rgba(0,0,0,0)';
738
+ if (hasVisibleBorder) {
739
+ ctx.strokeStyle = borderColor;
740
+ ctx.lineWidth = borderWidth;
741
+ ctx.beginPath();
742
+ ctx.moveTo(borderWidth, borderWidth / 2);
743
+ ctx.lineTo(canvas.width / 2, canvas.height - borderWidth / 2);
744
+ ctx.lineTo(canvas.width - borderWidth, borderWidth / 2);
745
+ ctx.stroke();
746
+ }
747
+
748
+ const texture = new THREE.CanvasTexture(canvas);
749
+ texture.colorSpace = THREE.SRGBColorSpace;
750
+ texture.generateMipmaps = true;
751
+ texture.minFilter = THREE.LinearMipmapLinearFilter;
752
+ texture.magFilter = THREE.LinearFilter;
753
+ texture._isShared = true; // Shared across nodes via cache
754
+ tailTextureCache.set(key, texture);
755
+ return texture;
756
+ }
757
+
758
+
759
+ function drawRoundedRect(ctx, x, y, width, height, radius) {
760
+ ctx.beginPath();
761
+ ctx.moveTo(x + radius, y);
762
+ ctx.lineTo(x + width - radius, y);
763
+ ctx.arcTo(x + width, y, x + width, y + radius, radius);
764
+ ctx.lineTo(x + width, y + height - radius);
765
+ ctx.arcTo(x + width, y + height, x + width - radius, y + height, radius);
766
+ ctx.lineTo(x + radius, y + height);
767
+ ctx.arcTo(x, y + height, x, y + height - radius, radius); // Bottom-left corner (now rounded)
768
+ ctx.lineTo(x, y + radius);
769
+ ctx.arcTo(x, y, x + radius, y, radius);
770
+ ctx.closePath();
771
+ }
772
+
773
+ // ▼▼▼ [New] Fully rounded rect for image nodes (all corners rounded) ▼▼▼
774
+ function drawFullyRoundedRect(ctx, x, y, width, height, radius) {
775
+ ctx.beginPath();
776
+ ctx.moveTo(x + radius, y);
777
+ ctx.lineTo(x + width - radius, y);
778
+ ctx.arcTo(x + width, y, x + width, y + radius, radius);
779
+ ctx.lineTo(x + width, y + height - radius);
780
+ ctx.arcTo(x + width, y + height, x + width - radius, y + height, radius);
781
+ ctx.lineTo(x + radius, y + height);
782
+ ctx.arcTo(x, y + height, x, y + height - radius, radius); // Bottom-left corner (now rounded)
783
+ ctx.lineTo(x, y + radius);
784
+ ctx.arcTo(x, y, x + radius, y, radius);
785
+ ctx.closePath();
786
+ }
787
+ // ▲▲▲ [New] ▲▲▲
788
+
789
+ function drawScrollbar(ctx, nodeModel, styleCanvasWidth, styleCanvasHeight, zoomLevel, isHovered = false) {
790
+ if (!nodeModel.maxScroll || nodeModel.maxScroll <= 1) return;
791
+
792
+ // ▼▼▼ [FIX] 슬림 스크롤바 - 너비와 패딩 축소 ▼▼▼
793
+ const scrollbarWidth = 4 * zoomLevel; // 6 → 4 (더 슬림하게)
794
+ const scrollbarPadding = 2 * zoomLevel; // 4 → 2 (오른쪽 여백 최소화)
795
+ // ▲▲▲ [FIX] ▲▲▲
796
+
797
+ const trackColor = 'rgba(0, 0, 0, 0.05)';
798
+
799
+ const thumbColor = isHovered ? 'rgba(0, 0, 0, 0.5)' : 'rgba(0, 0, 0, 0.25)';
800
+ const thumbRadius = 2 * zoomLevel; // 3 → 2 (슬림하게)
801
+ const minThumbHeight = 24 * zoomLevel;
802
+
803
+ const topMargin = (nodeModel.contentType === 'note' ? 20 : 16) * zoomLevel;
804
+ const bottomMargin = 16 * zoomLevel;
805
+
806
+ const trackX = styleCanvasWidth - scrollbarWidth - scrollbarPadding;
807
+ const trackY = topMargin;
808
+ const trackHeight = styleCanvasHeight - topMargin - bottomMargin;
809
+
810
+ if (trackHeight <= minThumbHeight) return;
811
+
812
+ ctx.fillStyle = trackColor;
813
+ drawRoundedRect(ctx, trackX, trackY, scrollbarWidth, trackHeight, thumbRadius);
814
+ ctx.fill();
815
+
816
+ const topPaddingContent = (nodeModel.contentType === 'note' ? 16 : 12) * zoomLevel;
817
+ const bottomPaddingContent = 16 * zoomLevel;
818
+ const borderWidth = 3 * zoomLevel;
819
+ const visibleContentHeight = styleCanvasHeight - (topPaddingContent + borderWidth) - (bottomPaddingContent + borderWidth);
820
+
821
+ let totalContentHeight;
822
+ if (nodeModel.isChunkedText && nodeModel.totalContentHeight > 0) {
823
+ totalContentHeight = nodeModel.totalContentHeight * zoomLevel;
824
+ } else {
825
+ totalContentHeight = visibleContentHeight + (nodeModel.maxScroll * zoomLevel);
826
+ }
827
+
828
+ let thumbHeight = (visibleContentHeight / totalContentHeight) * trackHeight;
829
+ thumbHeight = Math.max(minThumbHeight, Math.min(trackHeight, thumbHeight));
830
+
831
+ const currentScroll = (nodeModel.scrollOffset || 0) * zoomLevel;
832
+ const maxScrollWorld = nodeModel.maxScroll * zoomLevel;
833
+
834
+ let scrollRatio = currentScroll / maxScrollWorld;
835
+ scrollRatio = Math.max(0, Math.min(1, isNaN(scrollRatio) ? 0 : scrollRatio));
836
+
837
+ const availableTrackSpace = trackHeight - thumbHeight;
838
+ const thumbY = trackY + (availableTrackSpace * scrollRatio);
839
+
840
+ ctx.fillStyle = thumbColor;
841
+ drawRoundedRect(ctx, trackX, thumbY, scrollbarWidth, thumbHeight, thumbRadius);
842
+ ctx.fill();
843
+ }
844
+
845
+ function getBodyColors(contentType, isSelected, nodeModel = null) {
846
+ const contentTypeLower = String(contentType || '').toLowerCase();
847
+ if (contentTypeLower === 'memo') {
848
+ const theme = getMemoTextureTheme(nodeModel);
849
+ return {
850
+ fill: theme.fill,
851
+ stroke: isSelected ? theme.accent : theme.border
852
+ };
853
+ }
854
+
855
+ const neutralSelectedStroke = ['text', 'note', 'code', 'markdown'].includes(contentTypeLower)
856
+ ? '#7b7ff2'
857
+ : '#7b7ff2';
858
+
859
+ return {
860
+ fill: '#fefefe',
861
+ stroke: isSelected ? neutralSelectedStroke : 'rgba(55, 65, 81, 0.9)'
862
+ };
863
+ }
864
+
865
+ function shouldDrawBodyStroke(contentType, isSelected, options = {}) {
866
+ if (options.skipBorder) {
867
+ return false;
868
+ }
869
+
870
+ const contentTypeLower = String(contentType || '').toLowerCase();
871
+ if (contentTypeLower === 'image') {
872
+ return false;
873
+ }
874
+
875
+ if (contentTypeLower === 'note') {
876
+ return false;
877
+ }
878
+
879
+ return true;
880
+ }
881
+
882
+ function drawNodeBodyBackground(ctx, canvasWidth, canvasHeight, zoomLevel, isSelected, contentType = 'text', nodeModel = null, options = {}) {
883
+ const radius = 0; // No rounded corners
884
+ const borderWidth = 3 * zoomLevel;
885
+ const halfBorder = borderWidth / 2;
886
+
887
+ const x = halfBorder;
888
+ const y = halfBorder;
889
+ const w = canvasWidth - borderWidth;
890
+ const h = canvasHeight - borderWidth;
891
+ const right = x + w;
892
+ const bottom = y + h;
893
+
894
+ ctx.clearRect(0, 0, canvasWidth, canvasHeight);
895
+ ctx.beginPath();
896
+ ctx.moveTo(x + radius, y);
897
+ ctx.lineTo(right - radius, y);
898
+ ctx.quadraticCurveTo(right, y, right, y + radius);
899
+ ctx.lineTo(right, bottom - radius);
900
+ ctx.quadraticCurveTo(right, bottom, right - radius, bottom);
901
+ ctx.lineTo(x + radius, bottom);
902
+ ctx.quadraticCurveTo(x, bottom, x, bottom - radius);
903
+ ctx.lineTo(x, y + radius);
904
+ ctx.quadraticCurveTo(x, y, x + radius, y);
905
+ ctx.closePath();
906
+
907
+ const { fill, stroke } = getBodyColors(contentType, isSelected, nodeModel);
908
+ ctx.fillStyle = fill;
909
+ ctx.fill();
910
+
911
+ if (shouldDrawBodyStroke(contentType, isSelected, options)) {
912
+ ctx.strokeStyle = stroke;
913
+ ctx.lineWidth = borderWidth;
914
+ ctx.stroke();
915
+ }
916
+ }
917
+
918
+ function drawNodeBodyOnCanvas(ctx, nodeModel, canvasWidth, canvasHeight, zoomLevel, isSelected, options = {}) {
919
+ const { skipText = false, skipBorder = false } = options; // ★ Troika 사용 시 텍스트 렌더링 건너뛰기
920
+
921
+ drawNodeBodyBackground(ctx, canvasWidth, canvasHeight, zoomLevel, isSelected, nodeModel.contentType, nodeModel, { skipBorder });
922
+
923
+ // ★ skipText가 true면 배경만 그리고 텍스트는 건너뛰기 (Troika가 처리)
924
+ if (skipText) {
925
+ return;
926
+ }
927
+
928
+ ctx.save();
929
+
930
+ const BORDER_WIDTH = 3 * zoomLevel;
931
+ const CSS_PADDING = 16 * zoomLevel;
932
+ const totalPadding = CSS_PADDING + BORDER_WIDTH;
933
+ const topPadding = ((nodeModel.contentType === 'note' ? 16 : 12) * zoomLevel) + BORDER_WIDTH;
934
+ const paddingBottom = 16 * zoomLevel;
935
+ const textWidth = canvasWidth - totalPadding * 2;
936
+ const lineHeight = 14 * zoomLevel * 1.5;
937
+ let currentY = topPadding;
938
+
939
+ ctx.textBaseline = 'top';
940
+ // 기본 Prompt 값들은 표시하지 않음
941
+ const defaultPrompts = ['Note', 'Pasted Text', '에이전트 메모'];
942
+ const rawPrompt = nodeModel.prompt ?? nodeModel.Prompt ?? '';
943
+ const nodePrompt = getDisplayPrompt(nodeModel, rawPrompt);
944
+ const nodeResponse = nodeModel.response ?? nodeModel.Response ?? '';
945
+ const shouldShowPrompt = rawPrompt && !defaultPrompts.includes(rawPrompt);
946
+ if (shouldShowPrompt) {
947
+ ctx.fillStyle = '#111827';
948
+ ctx.font = `bold ${14 * zoomLevel}px sans-serif`;
949
+ ctx.fillText(nodePrompt, totalPadding, currentY, textWidth);
950
+ currentY += 24 * zoomLevel;
951
+ }
952
+
953
+ ctx.fillStyle = '#1f2937';
954
+ ctx.font = `${14 * zoomLevel}px sans-serif`;
955
+ const lines = (nodeResponse || '').split('\n');
956
+ for (const line of lines) {
957
+ ctx.fillText(line, totalPadding, currentY, textWidth);
958
+ currentY += lineHeight;
959
+ }
960
+
961
+ ctx.restore();
962
+ }
963
+
964
+ function normalizeCachedImage(cachedImage) {
965
+ if (!cachedImage) return { image: null, width: 0, height: 0, isError: false };
966
+
967
+ const isError = cachedImage.isError === true;
968
+
969
+ if (cachedImage.image) {
970
+ const img = cachedImage.image;
971
+ const width = cachedImage.width || img.naturalWidth || img.width || 0;
972
+ const height = cachedImage.height || img.naturalHeight || img.height || 0;
973
+ return { image: img, width, height, isError };
974
+ }
975
+
976
+ const img = cachedImage;
977
+ const width = img.naturalWidth || img.width || 0;
978
+ const height = img.naturalHeight || img.height || 0;
979
+ return { image: img, width, height, isError };
980
+ }
981
+
982
+ function drawCachedImageBody(cachedImage, ctx, nodeModel, canvasWidth, canvasHeight, zoomLevel, isSelected) {
983
+ ctx.save();
984
+ // Use simple rect for sharp corners (no arcTo issues with radius=0)
985
+ ctx.beginPath();
986
+ ctx.rect(0, 0, canvasWidth, canvasHeight);
987
+ ctx.clip();
988
+ ctx.fillStyle = '#fefefe';
989
+ ctx.fillRect(0, 0, canvasWidth, canvasHeight);
990
+
991
+ const { image, width: imgWidth, height: imgHeight, isError } = normalizeCachedImage(cachedImage);
992
+
993
+ if (image && imgWidth > 0 && imgHeight > 0) {
994
+ const canvasAspect = canvasWidth / canvasHeight;
995
+ const imgAspect = imgWidth / imgHeight;
996
+ let sx = 0, sy = 0, sWidth = imgWidth, sHeight = imgHeight;
997
+ if (imgAspect > canvasAspect) {
998
+ sWidth = imgHeight * canvasAspect;
999
+ sx = (imgWidth - sWidth) / 2;
1000
+ } else {
1001
+ sHeight = imgWidth / canvasAspect;
1002
+ sy = (imgHeight - sHeight) / 2;
1003
+ }
1004
+ ctx.drawImage(image, sx, sy, sWidth, sHeight, 0, 0, canvasWidth, canvasHeight);
1005
+ }
1006
+ else if (isError) {
1007
+ ctx.fillStyle = '#f3f4f6';
1008
+ ctx.fillRect(0, 0, canvasWidth, canvasHeight);
1009
+ ctx.fillStyle = '#9ca3af';
1010
+ ctx.font = `bold ${16 * zoomLevel}px sans-serif`;
1011
+ ctx.textAlign = 'center';
1012
+ ctx.textBaseline = 'middle';
1013
+ const textY = canvasHeight * 0.55;
1014
+ ctx.fillText('Image Not Found', canvasWidth / 2, textY);
1015
+
1016
+ if (brokenImageIcon.complete) {
1017
+ const iconSize = Math.min(canvasWidth, canvasHeight) * 0.4;
1018
+ ctx.drawImage(brokenImageIcon, (canvasWidth - iconSize) / 2, (canvasHeight - iconSize) / 2 - (8 * zoomLevel), iconSize, iconSize);
1019
+ } else {
1020
+ ctx.strokeStyle = '#9ca3af';
1021
+ ctx.lineWidth = 2 * zoomLevel;
1022
+ ctx.beginPath();
1023
+ ctx.moveTo(canvasWidth * 0.3, canvasHeight * 0.3);
1024
+ ctx.lineTo(canvasWidth * 0.7, canvasHeight * 0.7);
1025
+ ctx.moveTo(canvasWidth * 0.7, canvasHeight * 0.3);
1026
+ ctx.lineTo(canvasWidth * 0.3, canvasHeight * 0.7);
1027
+ ctx.stroke();
1028
+ }
1029
+
1030
+ ctx.strokeStyle = '#ef4444';
1031
+ ctx.setLineDash([5 * zoomLevel, 5 * zoomLevel]);
1032
+ ctx.lineWidth = 2 * zoomLevel;
1033
+ ctx.strokeRect(0, 0, canvasWidth, canvasHeight);
1034
+ ctx.setLineDash([]);
1035
+ }
1036
+ else {
1037
+ ctx.fillStyle = '#f9fafb';
1038
+ ctx.fillRect(0, 0, canvasWidth, canvasHeight);
1039
+ ctx.fillStyle = '#6b7280';
1040
+ ctx.font = `${14 * zoomLevel}px sans-serif`;
1041
+ ctx.textAlign = 'center';
1042
+ ctx.textBaseline = 'middle';
1043
+ ctx.fillText('Loading...', canvasWidth / 2, canvasHeight / 2);
1044
+ }
1045
+
1046
+ const borderWidth = 3 * zoomLevel;
1047
+ ctx.strokeStyle = 'transparent'; // Image nodes have no visible border
1048
+ ctx.lineWidth = borderWidth;
1049
+ // Sharp corners - use strokeRect instead of drawFullyRoundedRect
1050
+ ctx.strokeRect(borderWidth / 2, borderWidth / 2, canvasWidth - borderWidth, canvasHeight - borderWidth);
1051
+ ctx.restore();
1052
+ }
1053
+
1054
+ function getMarkdownStyles(zoomLevel, contentType = 'text', options = {}) {
1055
+ const z = zoomLevel;
1056
+ const fontStack = "ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif";
1057
+ const baseFontSize = 14 * z;
1058
+ const baseLineHeight = 1.6;
1059
+
1060
+ // NOTE:
1061
+ // Markdown is rasterized via SVG <foreignObject> -> Image(). In that path, native scrollbars are
1062
+ // problematic (styling may be ignored, and interaction isn't meaningful on a texture anyway).
1063
+ // We therefore always wrap long lines and avoid native horizontal scrollbars.
1064
+ const preOverflowX = 'hidden';
1065
+ const preWhiteSpace = 'pre-wrap';
1066
+ const preOverflowWrap = 'anywhere';
1067
+ const styles = `
1068
+ div { color: #1f2937; line-height: ${baseLineHeight}; font-family: ${fontStack}; font-size: ${baseFontSize}px; margin: 0; padding: 0; text-align: left; }
1069
+ p { margin: 0 0 ${11 * z}px 0; white-space: normal; }
1070
+ h1 { font-size: ${Math.round(baseFontSize * 1.8)}px; font-weight: 700; margin: ${16 * z}px 0 ${8 * z}px 0; color: #111827; border-bottom: ${1 * z}px solid #eaecef; padding-bottom: ${5 * z}px; }
1071
+ h2 { font-size: ${Math.round(baseFontSize * 1.4)}px; font-weight: 700; margin: ${14 * z}px 0 ${8 * z}px 0; color: #111827; border-bottom: ${1 * z}px solid #eaecef; padding-bottom: ${5 * z}px; }
1072
+ h3, h4 { font-size: ${Math.round(baseFontSize * 1.2)}px; font-weight: 700; margin: ${12 * z}px 0 ${8 * z}px 0; color: #111827; }
1073
+ ul, ol { margin: ${6 * z}px 0 ${14 * z}px ${20 * z}px; padding-left: ${8 * z}px; }
1074
+ li { margin-bottom: ${4 * z}px; }
1075
+ .response > *:first-child { margin-top: 0 !important; }
1076
+ .response > *:last-child { margin-bottom: 0 !important; }
1077
+ li:last-child { margin-bottom: 0 !important; }
1078
+ strong, b { font-weight: 700; color: #111827; }
1079
+ .prompt { font-weight: 700; color: #111827; margin: 0 0 ${8 * z}px 0; padding-bottom: 0; display: flex; align-items: center; font-size: ${baseFontSize}px; }
1080
+ .icon-wrapper { display: inline-flex; align-items: center; justify-content: center; width: ${16 * z}px; height: ${16 * z}px; color: #4b5563; }
1081
+ hr.node-separator { border: 0; border-top: ${1 * z}px solid #e5e7eb; margin: ${8 * z}px 0 ${12 * z}px 0; }
1082
+ .thinking { color: #6b7280; font-style: italic; display: flex; align-items: center; gap: ${6 * z}px; }
1083
+ .response { margin: 0; }
1084
+ code { background-color: rgba(27,31,35,0.05); padding: ${2 * z}px ${4 * z}px; border-radius: ${3 * z}px; font-family: Consolas, 'Courier New', monospace; font-size: ${Math.round(baseFontSize * 0.85)}px; }
1085
+ pre { background-color: #f6f8fa; padding: ${12 * z}px; border-radius: ${3 * z}px; overflow-x: ${preOverflowX}; margin: ${8 * z}px 0 ${12 * z}px 0; font-family: Consolas, 'Courier New', monospace; white-space: ${preWhiteSpace}; overflow-wrap: ${preOverflowWrap}; }
1086
+ pre code { background-color: transparent; padding: 0; border-radius: 0; }
1087
+
1088
+ /* Scrollbar styling (best-effort)
1089
+ - Some browsers ignore ::-webkit-scrollbar during SVG foreignObject rasterization.
1090
+ - LOD path avoids native scrollbars entirely via overflow-x:hidden above.
1091
+ */
1092
+ pre { scrollbar-width: thin; scrollbar-color: rgba(0,0,0,0.28) transparent; }
1093
+ pre::-webkit-scrollbar { height: ${Math.max(2, 6 * z)}px; width: ${Math.max(2, 6 * z)}px; }
1094
+ pre::-webkit-scrollbar-track { background: transparent; }
1095
+ pre::-webkit-scrollbar-thumb { background-color: rgba(0,0,0,0.28); border-radius: ${Math.max(2, 6 * z)}px; }
1096
+ pre::-webkit-scrollbar-thumb:hover { background-color: rgba(0,0,0,0.42); }
1097
+
1098
+ blockquote { border-left: ${4 * z}px solid #dfe2e5; padding: 0 ${12 * z}px; margin: 0 0 ${12 * z}px 0; color: #6a737d; }
1099
+ table { border-collapse: collapse; width: 100%; margin: ${8 * z}px 0 ${12 * z}px 0; font-size: ${13 * z}px; }
1100
+ th, td { border: ${1 * z}px solid #d1d5db; padding: ${6 * z}px ${8 * z}px; text-align: left; }
1101
+ th { background-color: #f3f4f6; font-weight: 600; color: #111827; }
1102
+ tr:nth-child(even) { background-color: #f9fafb; }
1103
+ `;
1104
+ return { styles, fontStack };
1105
+ }
1106
+
1107
+ function scopeCss(styles, scopeSelector) {
1108
+ if (!scopeSelector) return styles;
1109
+ return styles.split('\n').map(line => {
1110
+ const trimmed = line.trim();
1111
+ if (!trimmed || trimmed.startsWith('@') || !trimmed.includes('{')) return line;
1112
+ const parts = line.split('{');
1113
+ const selectors = parts[0].trim();
1114
+ const rest = parts.slice(1).join('{');
1115
+ const scopedSelectors = selectors
1116
+ .split(',')
1117
+ .map(s => `${scopeSelector} ${s.trim()}`)
1118
+ .join(', ');
1119
+ return line.replace(selectors, scopedSelectors);
1120
+ }).join('\n');
1121
+ }
1122
+
1123
+ function escapeHtml(value) {
1124
+ return (value || '').replace(/[&<>\"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
1125
+ }
1126
+
1127
+ const ALLOWED_HTML_TAGS = new Set([
1128
+ 'div', 'span', 'p', 'br', 'hr', 'a', 'b', 'i', 'u', 's', 'em', 'strong', 'mark',
1129
+ 'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
1130
+ 'ul', 'ol', 'li', 'dl', 'dt', 'dd',
1131
+ 'table', 'thead', 'tbody', 'tfoot', 'tr', 'th', 'td', 'caption', 'colgroup', 'col',
1132
+ 'pre', 'code', 'blockquote', 'q', 'cite',
1133
+ 'sub', 'sup', 'small', 'big', 'abbr', 'acronym', 'ins', 'del',
1134
+ 'input', 'label', 'button', 'select', 'option', 'textarea',
1135
+ 'figure', 'figcaption', 'article', 'section', 'aside', 'header', 'footer', 'nav', 'main',
1136
+ 'details', 'summary', 'time', 'address', 'wbr', 'kbd', 'samp', 'var', 'dfn'
1137
+ ]);
1138
+
1139
+ function sanitizeHtml(html) {
1140
+ if (!html) return '';
1141
+
1142
+ let sanitized = html
1143
+ .replace(/<(script|iframe|object|embed|meta|link|style)[^>]*>[\s\S]*?<\/\1>/gi, '')
1144
+ .replace(/<(script|iframe|object|embed|meta|link)[^>]*\/?>/gi, '');
1145
+
1146
+ sanitized = sanitized.replace(/<img[^>]*\/?>/gi, '');
1147
+
1148
+ sanitized = sanitized.replace(/<([a-zA-Z][a-zA-Z0-9]*)\s*([^>]*)>/g, (match, tagName, attrs) => {
1149
+ if (ALLOWED_HTML_TAGS.has(tagName.toLowerCase())) {
1150
+ return match;
1151
+ }
1152
+ return `&lt;${tagName}${attrs ? ' ' + attrs : ''}&gt;`;
1153
+ });
1154
+
1155
+ sanitized = sanitized.replace(/<\/([a-zA-Z][a-zA-Z0-9]*)>/g, (match, tagName) => {
1156
+ if (ALLOWED_HTML_TAGS.has(tagName.toLowerCase())) {
1157
+ return match;
1158
+ }
1159
+ return `&lt;/${tagName}&gt;`;
1160
+ });
1161
+
1162
+ sanitized = sanitized
1163
+ .replace(/<br\s*\/?>/gi, '<br/>')
1164
+ .replace(/<hr\s*\/?>/gi, '<hr/>')
1165
+ .replace(/<input([^>]*[^\/])>/gi, '<input$1 />');
1166
+
1167
+ sanitized = sanitized.replace(/\]\]>/g, ']]&gt;');
1168
+
1169
+ sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F]/g, '');
1170
+
1171
+ return sanitized;
1172
+ }
1173
+
1174
+ function renderMarkdownToCanvasAsync(ctx, texture, nodeModel, canvasWidth, canvasHeight, zoomLevel, isSelected, onComplete = null, options = {}) {
1175
+ const hasMarked = typeof window !== 'undefined' && window.marked && typeof window.marked.parse === 'function';
1176
+ const promptValue = nodeModel.prompt ?? nodeModel.Prompt ?? '';
1177
+ const displayPromptValue = getDisplayPrompt(nodeModel, promptValue);
1178
+ // 기본 Prompt 값들은 표시하지 않음
1179
+ const defaultPrompts = ['Note', 'Pasted Text', '에이전트 메모'];
1180
+ const shouldShowPrompt = promptValue && !defaultPrompts.includes(promptValue);
1181
+ // C#에서 PascalCase (IsLoading)로 전달될 수 있으므로 둘 다 확인
1182
+ const nodeIsLoading = nodeModel.isLoading ?? nodeModel.IsLoading ?? false;
1183
+ const nodeResponse = nodeModel.response ?? nodeModel.Response ?? '';
1184
+ const isLoadingState = nodeIsLoading && !nodeResponse;
1185
+ // note 타입에서도 프롬프트 표시 (CSS3D와 동일하게)
1186
+ const showPromptNow = shouldShowPrompt;
1187
+ let htmlContent = '';
1188
+
1189
+ if (showPromptNow) {
1190
+ htmlContent += `<div class="prompt">${escapeHtml(displayPromptValue)}</div>`;
1191
+ }
1192
+
1193
+ if (isLoadingState) {
1194
+ if (showPromptNow) {
1195
+ htmlContent += '<hr class="node-separator" />';
1196
+ }
1197
+ htmlContent += `<div class="thinking">AI is generating a response... <span class="icon-wrapper">${ICON_SPINNER_SVG}</span></div>`;
1198
+ } else if (nodeResponse) {
1199
+ // Separator between prompt and response (for both text and note types)
1200
+ if (shouldShowPrompt) {
1201
+ htmlContent += '<hr class="node-separator" />';
1202
+ }
1203
+
1204
+ if (nodeModel.isChunkedText) {
1205
+ const contentToRender = nodeModel._visibleContent || nodeModel.response || '';
1206
+ const escapedText = escapeHtml(contentToRender);
1207
+
1208
+ const chunkWhiteSpace = 'pre-wrap';
1209
+ const chunkOverflowWrap = 'anywhere';
1210
+ htmlContent += `<div class="response" style="font-family: 'Consolas', 'Monaco', 'Courier New', monospace; white-space: ${chunkWhiteSpace}; overflow-wrap: ${chunkOverflowWrap}; line-height: 1.5; font-size: ${14 * zoomLevel}px;">${escapedText}</div>`;
1211
+
1212
+ if (nodeModel.totalLineCount > 0 && (nodeModel.scrollOffset || 0) === 0) {
1213
+ htmlContent += `<div style="margin-top:12px; padding:8px; background:rgba(59,130,246,0.1); border-radius:4px; font-size:0.85em; color:#3b82f6; text-align:center;">
1214
+ Large file (${nodeModel.totalLineCount.toLocaleString()} lines) - Scroll to view more
1215
+ </div>`;
1216
+ }
1217
+ } else {
1218
+ const processedResponse = preprocessMarkdown(nodeModel.response);
1219
+
1220
+ let parsed;
1221
+ if (hasMarked) {
1222
+ try {
1223
+ const renderer = new window.marked.Renderer();
1224
+
1225
+ renderer.code = function (codeOrObj, language, isEscaped) {
1226
+ const code = typeof codeOrObj === 'object' ? codeOrObj.text : codeOrObj;
1227
+ const lang = typeof codeOrObj === 'object' ? codeOrObj.lang : language;
1228
+
1229
+ const escaped = escapeHtml(code || '');
1230
+ const langClass = lang ? ` class="language-${lang}"` : '';
1231
+ return `<pre><code${langClass}>${escaped}</code></pre>`;
1232
+ };
1233
+
1234
+ renderer.codespan = function (codeOrObj) {
1235
+ const code = typeof codeOrObj === 'object' ? codeOrObj.text : codeOrObj;
1236
+ return `<code>${escapeHtml(code || '')}</code>`;
1237
+ };
1238
+
1239
+ parsed = window.marked.parse(processedResponse, {
1240
+ breaks: true,
1241
+ xhtml: true,
1242
+ renderer: renderer
1243
+ });
1244
+ } catch (e) {
1245
+ console.warn('[TextureFactory] marked.parse with custom renderer failed, using default:', e);
1246
+ parsed = window.marked.parse(processedResponse, { breaks: true, xhtml: true });
1247
+ }
1248
+ } else {
1249
+ parsed = `<div style="white-space: pre-wrap;">${escapeHtml(processedResponse)}</div>`;
1250
+ }
1251
+
1252
+ parsed = parsed.replace(/<br\s*\/?>/gi, '<br/>').replace(/<hr\s*\/?>/gi, '<hr/>');
1253
+ parsed = sanitizeHtml(parsed);
1254
+ htmlContent += `<div class="response">${parsed}</div>`;
1255
+ }
1256
+ } else if (nodeModel.isChunkedText && !nodeModel.response) {
1257
+ htmlContent += `<div style="padding:16px; color:#6b7280; text-align:center;">
1258
+ <span style="animation: pulse 1.5s infinite;">⏳</span> Loading...
1259
+ </div>`;
1260
+ }
1261
+
1262
+ const { styles, fontStack } = getMarkdownStyles(zoomLevel, nodeModel.contentType, options);
1263
+ const BORDER_WIDTH = 3 * zoomLevel;
1264
+ const CSS_PADDING = 16 * zoomLevel;
1265
+ const totalPaddingX = CSS_PADDING + BORDER_WIDTH;
1266
+ const cssTopPadding = (nodeModel.contentType === 'note' ? 16 : 12) * zoomLevel;
1267
+ const totalPaddingY = cssTopPadding + BORDER_WIDTH;
1268
+ const paddingBottom = 16 * zoomLevel;
1269
+ const textWidth = Math.max(1, canvasWidth - totalPaddingX * 2);
1270
+ const textHeight = Math.max(1, canvasHeight - totalPaddingY - paddingBottom);
1271
+
1272
+ drawNodeBodyBackground(ctx, canvasWidth, canvasHeight, zoomLevel, isSelected, nodeModel.contentType, nodeModel, options);
1273
+
1274
+ const effectiveScrollOffset = nodeModel.isChunkedText && nodeModel._visibleScrollOffset !== undefined
1275
+ ? nodeModel._visibleScrollOffset
1276
+ : (nodeModel.scrollOffset || 0);
1277
+
1278
+ let svgString;
1279
+
1280
+ if (nodeModel.isChunkedText && promptValue) {
1281
+ const headerHeight = 30 * zoomLevel;
1282
+ const bodyHeight = Math.max(1, textHeight - headerHeight);
1283
+
1284
+ const headerHtml = `
1285
+ <div xmlns="http://www.w3.org/1999/xhtml" style="width:100%; font-family:${fontStack}; font-size:${14 * zoomLevel}px; line-height:1.5; color:#1f2937;">
1286
+ <div class="prompt">${escapeHtml(displayPromptValue)}</div>
1287
+ <hr class="node-separator" />
1288
+ </div>`;
1289
+
1290
+ const visibleText = nodeModel._visibleContent || nodeModel.response || '';
1291
+ const bodyContent = visibleText ? escapeHtml(visibleText) : '<span style="color:#9ca3af;">Loading...</span>';
1292
+ const bodyWhiteSpace = 'pre-wrap';
1293
+ const bodyOverflowWrap = 'anywhere';
1294
+ const bodyHtml = `
1295
+ <div xmlns="http://www.w3.org/1999/xhtml" style="width:100%; height:auto; font-family:'Consolas', 'Monaco', 'Courier New', monospace; font-size:${14 * zoomLevel}px; line-height:1.5; color:#1f2937; white-space:${bodyWhiteSpace}; overflow-wrap:${bodyOverflowWrap}; transform:translateY(-${effectiveScrollOffset * zoomLevel}px);">
1296
+ ${bodyContent}
1297
+ </div>`;
1298
+
1299
+ svgString = `
1300
+ <svg xmlns="http://www.w3.org/2000/svg" width="${canvasWidth}" height="${canvasHeight}">
1301
+ <defs>
1302
+ <style type="text/css"><![CDATA[
1303
+ ${styles}
1304
+ ]]></style>
1305
+ </defs>
1306
+ <!-- Fixed header -->
1307
+ <foreignObject x="${totalPaddingX}" y="${totalPaddingY}" width="${textWidth}" height="${headerHeight}">
1308
+ ${headerHtml}
1309
+ </foreignObject>
1310
+ <!-- Scrollable body -->
1311
+ <foreignObject x="${totalPaddingX}" y="${totalPaddingY + headerHeight}" width="${textWidth}" height="${bodyHeight}">
1312
+ ${bodyHtml}
1313
+ </foreignObject>
1314
+ </svg>`;
1315
+ } else {
1316
+ const htmlBlock = `
1317
+ <div xmlns="http://www.w3.org/1999/xhtml" style="width:100%; height:auto; font-family:${fontStack}; font-size:${14 * zoomLevel}px; line-height:1.5; color:#1f2937; overflow-wrap:break-word; transform:translateY(-${effectiveScrollOffset * zoomLevel}px);">
1318
+ ${htmlContent}
1319
+ </div>`;
1320
+
1321
+ svgString = `
1322
+ <svg xmlns="http://www.w3.org/2000/svg" width="${canvasWidth}" height="${canvasHeight}">
1323
+ <defs>
1324
+ <style type="text/css"><![CDATA[
1325
+ ${styles}
1326
+ ]]></style>
1327
+ </defs>
1328
+ <foreignObject x="${totalPaddingX}" y="${totalPaddingY}" width="${textWidth}" height="${textHeight}">
1329
+ ${htmlBlock}
1330
+ </foreignObject>
1331
+ </svg>`;
1332
+ }
1333
+
1334
+ const dataUrl = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svgString)}`;
1335
+ const img = new Image();
1336
+
1337
+ img.onload = () => {
1338
+ ctx.drawImage(img, 0, 0);
1339
+ // ★ skipScrollbar 옵션이 true이면 스크롤바를 여기서 그리지 않음 (외부에서 별도 호출)
1340
+ if (!options.skipScrollbar) {
1341
+ drawScrollbar(ctx, nodeModel, canvasWidth, canvasHeight, zoomLevel, nodeModel.isScrollbarHovered);
1342
+ }
1343
+ texture.needsUpdate = true;
1344
+ if (onComplete) onComplete(true);
1345
+ };
1346
+
1347
+ img.onerror = (err) => {
1348
+ console.error('[TextureFactory] SVG render failed for node:', nodeModel.id);
1349
+
1350
+ try {
1351
+ const parser = new DOMParser();
1352
+ const doc = parser.parseFromString(svgString, 'image/svg+xml');
1353
+ const parseError = doc.querySelector('parsererror');
1354
+
1355
+ if (parseError) {
1356
+ console.error('[TextureFactory] SVG Parse Error:', parseError.textContent);
1357
+ } else {
1358
+ console.error('[TextureFactory] SVG parsed OK but Image failed to load');
1359
+ }
1360
+ } catch (e) {
1361
+ console.error('[TextureFactory] DOMParser error:', e);
1362
+ }
1363
+
1364
+ console.error('[TextureFactory] HTML content (first 500 chars):', htmlContent.substring(0, 500));
1365
+ console.error('[TextureFactory] HTML content (last 200 chars):', htmlContent.substring(Math.max(0, htmlContent.length - 200)));
1366
+
1367
+ drawNodeBodyOnCanvas(ctx, nodeModel, canvasWidth, canvasHeight, zoomLevel, isSelected, options);
1368
+ // ★ skipScrollbar 옵션 체크 (img.onload와 동일하게)
1369
+ if (!options.skipScrollbar) {
1370
+ drawScrollbar(ctx, nodeModel, canvasWidth, canvasHeight, zoomLevel, nodeModel.isScrollbarHovered);
1371
+ }
1372
+ texture.needsUpdate = true;
1373
+ if (onComplete) onComplete(false);
1374
+ };
1375
+
1376
+ img.src = dataUrl;
1377
+ }
1378
+
1379
+ // [LOD] Simplified Texture generation: Only Image nodes use textures now.
1380
+ // Text/Note/Code nodes use CSS3D for near view and InstancedMesh(Lines) for far view.
1381
+ async function createRenderedTextureAsync(module, nodeModel, isSelected, imageCache, qualityScale = 1.0) {
1382
+ if (nodeModel.contentType === 'image') {
1383
+ const zoomLevel = 2;
1384
+ const styleW = (nodeModel.width || 400) * zoomLevel;
1385
+ const styleH = (nodeModel.height || 200) * zoomLevel;
1386
+
1387
+ const canvas = document.createElement('canvas');
1388
+ canvas.width = styleW;
1389
+ canvas.height = styleH;
1390
+ const ctx = canvas.getContext('2d');
1391
+
1392
+ // Draw image texture
1393
+ // TODO: Ensure drawCachedImageBody is available or inline it
1394
+ const cachedImage = imageCache ? imageCache.get(nodeModel.response) : null;
1395
+ drawCachedImageBody(cachedImage, ctx, nodeModel, styleW, styleH, zoomLevel, isSelected);
1396
+
1397
+ const texture = new THREE.CanvasTexture(canvas);
1398
+ texture.colorSpace = THREE.SRGBColorSpace;
1399
+ texture.generateMipmaps = true;
1400
+ texture.minFilter = THREE.LinearMipmapLinearFilter;
1401
+ texture.magFilter = THREE.LinearFilter;
1402
+ texture.needsUpdate = true;
1403
+ return texture;
1404
+ }
1405
+
1406
+ // For other types, we don't generate textures anymore (CSS3D / InstancedMesh handles them)
1407
+ return null;
1408
+ }
1409
+
1410
+ async function measureHtmlHeightAsync(nodeModel, zoomLevel, canvasWidth, forcedHeight = null, maxHeight = 800) {
1411
+ log(`[measureHtmlHeightAsync] ENTER - nodeId: ${nodeModel.id}, responseLen: ${nodeModel.response?.length || 0}`);
1412
+
1413
+ const hasMarked = typeof window !== 'undefined' && window.marked && typeof window.marked.parse === 'function';
1414
+ const { styles, fontStack } = getMarkdownStyles(zoomLevel, nodeModel.contentType);
1415
+ let htmlContent = '';
1416
+
1417
+ const promptValue = nodeModel.prompt ?? nodeModel.Prompt ?? '';
1418
+ const displayPromptValue = getDisplayPrompt(nodeModel, promptValue);
1419
+ // 기본 Prompt 값들은 표시하지 않음
1420
+ const defaultPrompts = ['Note', 'Pasted Text', '에이전트 메모'];
1421
+ const shouldShowPrompt = promptValue && !defaultPrompts.includes(promptValue);
1422
+ // C#에서 PascalCase (IsLoading)로 전달될 수 있으므로 둘 다 확인
1423
+ const nodeIsLoading = nodeModel.isLoading ?? nodeModel.IsLoading ?? false;
1424
+ const nodeResponse = nodeModel.response ?? nodeModel.Response ?? '';
1425
+ const nodeContentType = nodeModel.contentType ?? nodeModel.ContentType ?? 'text';
1426
+ const isLoadingState = nodeIsLoading && !nodeResponse;
1427
+ // note 타입에서도 프롬프트 표시 (CSS3D와 동일하게)
1428
+ const showPromptNow = shouldShowPrompt;
1429
+ if (showPromptNow) {
1430
+ htmlContent += `<div class="prompt">${escapeHtml(displayPromptValue)}</div>`;
1431
+ }
1432
+
1433
+ // Separator between prompt and response (for both text and note types)
1434
+ if (showPromptNow && (nodeResponse || isLoadingState)) {
1435
+ htmlContent += '<hr class="node-separator" />';
1436
+ }
1437
+
1438
+ if (nodeResponse) {
1439
+ log(`[measureHtmlHeightAsync] Step 1: Preprocessing markdown...`);
1440
+ const processedResponse = preprocessMarkdown(nodeModel.response);
1441
+ log(`[measureHtmlHeightAsync] Step 1: Done. Preprocessed length: ${processedResponse.length}`);
1442
+
1443
+ let parsed;
1444
+ if (hasMarked) {
1445
+ try {
1446
+ log(`[measureHtmlHeightAsync] Step 2: Parsing with marked...`);
1447
+
1448
+ const renderer = new window.marked.Renderer();
1449
+ renderer.code = function (codeOrObj, language, isEscaped) {
1450
+ const code = typeof codeOrObj === 'object' ? codeOrObj.text : codeOrObj;
1451
+ const lang = typeof codeOrObj === 'object' ? codeOrObj.lang : language;
1452
+ const escaped = escapeHtml(code || '');
1453
+ const langClass = lang ? ` class="language-${lang}"` : '';
1454
+ return `<pre><code${langClass}>${escaped}</code></pre>`;
1455
+ };
1456
+ renderer.codespan = function (codeOrObj) {
1457
+ const code = typeof codeOrObj === 'object' ? codeOrObj.text : codeOrObj;
1458
+ return `<code>${escapeHtml(code || '')}</code>`;
1459
+ };
1460
+ parsed = window.marked.parse(processedResponse, {
1461
+ breaks: true,
1462
+ xhtml: true,
1463
+ renderer: renderer
1464
+ });
1465
+ log(`[measureHtmlHeightAsync] Step 2: Marked parsing done. Length: ${parsed.length}`);
1466
+ } catch (e) {
1467
+ console.warn(`[measureHtmlHeightAsync] Marked parse failed, using fallback`, e);
1468
+ parsed = window.marked.parse(processedResponse, { breaks: true, xhtml: true });
1469
+ }
1470
+ } else {
1471
+ parsed = `<div style="white-space: pre-wrap;">${escapeHtml(processedResponse)}</div>`;
1472
+ }
1473
+
1474
+ log(`[measureHtmlHeightAsync] Step 3: Sanitizing HTML...`);
1475
+ parsed = sanitizeHtml(parsed);
1476
+ log(`[measureHtmlHeightAsync] Step 3: Sanitizing done. Length: ${parsed.length}`);
1477
+
1478
+ htmlContent += `<div class="response">${parsed}</div>`;
1479
+ } else if (nodeModel.isLoading) {
1480
+ if (promptValue) {
1481
+ htmlContent += '<hr class="node-separator" />';
1482
+ }
1483
+ htmlContent += `<div class=\"thinking\">AI is generating a response... <span class=\"icon-wrapper\">${ICON_SPINNER_SVG}</span></div>`;
1484
+ } else {
1485
+ htmlContent += '<div class="response">&nbsp;</div>';
1486
+ }
1487
+
1488
+ log(`[measureHtmlHeightAsync] Step 4: Setting measurer innerHTML...`);
1489
+ const measurer = getSharedMeasurer();
1490
+ const scopeClass = 'mm-measure-root';
1491
+ const scopedStyles = scopeCss(styles, `.${scopeClass}`);
1492
+ const BORDER_WIDTH = 3 * zoomLevel;
1493
+ const PADDING_X = 16 * zoomLevel;
1494
+ const PADDING_BOTTOM = 16 * zoomLevel;
1495
+ const PADDING_TOP = (nodeModel.contentType === 'note' ? 16 : 12) * zoomLevel;
1496
+ const totalHorizontalPadding = (PADDING_X + BORDER_WIDTH) * 2;
1497
+
1498
+ const totalVerticalPadding = PADDING_TOP + PADDING_BOTTOM + (BORDER_WIDTH * 2);
1499
+ const contentWidth = Math.max(1, canvasWidth - totalHorizontalPadding);
1500
+ measurer.style.width = `${contentWidth}px`;
1501
+ measurer.style.fontFamily = fontStack;
1502
+ measurer.style.fontSize = `${14 * zoomLevel}px`;
1503
+ measurer.style.lineHeight = '1.5';
1504
+ measurer.style.overflowWrap = 'break-word';
1505
+ measurer.style.whiteSpace = 'normal';
1506
+ measurer.style.overflow = 'hidden';
1507
+ measurer.innerHTML = `<style>${scopedStyles}</style><div class="${scopeClass}">${htmlContent}</div>`;
1508
+ log(`[measureHtmlHeightAsync] Step 4: innerHTML set. Total length: ${measurer.innerHTML.length}`);
1509
+
1510
+ log(`[measureHtmlHeightAsync] Step 5: Calling getBoundingClientRect...`);
1511
+ const rect = measurer.getBoundingClientRect();
1512
+ const contentHeight = Math.ceil(rect.height);
1513
+ log(`[measureHtmlHeightAsync] Step 5: Done. contentHeight: ${contentHeight}`);
1514
+
1515
+ const totalRequiredHeight = contentHeight + totalVerticalPadding;
1516
+
1517
+ const MAX_HEIGHT = maxHeight * zoomLevel;
1518
+ const MIN_HEIGHT = 60 * zoomLevel;
1519
+
1520
+ let visibleHeight;
1521
+ if (forcedHeight !== null && forcedHeight > 0) {
1522
+ visibleHeight = Math.max(forcedHeight, MIN_HEIGHT);
1523
+ } else {
1524
+ visibleHeight = Math.min(Math.max(totalRequiredHeight, MIN_HEIGHT), MAX_HEIGHT);
1525
+ }
1526
+
1527
+ // ▼▼▼ [수정] 스크롤 끝에서 마지막 줄이 잘리지 않도록 여분 추가 ▼▼▼
1528
+ // 콘텐츠 영역의 하단 패딩만큼 추가 스크롤 허용
1529
+ const extraScrollMargin = PADDING_BOTTOM;
1530
+ const maxScrollPx = Math.max(0, totalRequiredHeight - visibleHeight + extraScrollMargin);
1531
+ // ▲▲▲ [수정] ▲▲▲
1532
+
1533
+ return {
1534
+ height: Math.ceil(Math.max(60, visibleHeight / zoomLevel)),
1535
+ maxScrollPx
1536
+ };
1537
+ }
1538
+
1539
+ /**
1540
+ * [Optimization] Batch measure HTML heights to prevent DOM Layout Thrashing.
1541
+ * Instead of N individual Write→Read cycles, this does 1 Write→1 Read cycle.
1542
+ * @param {Array} contentsWithIds - Array of { id, content, maxWidth, zoomLevel, contentType }
1543
+ * @returns {Object} Map of nodeId -> { height, maxScrollPx }
1544
+ */
1545
+ async function measureBatchHtmlHeights(contentsWithIds) {
1546
+ if (!contentsWithIds || contentsWithIds.length === 0) return {};
1547
+
1548
+ const startTime = performance.now();
1549
+ const measurer = getSharedMeasurer();
1550
+ if (!measurer) return {};
1551
+
1552
+ const hasMarked = typeof window !== 'undefined' && window.marked && typeof window.marked.parse === 'function';
1553
+ const results = {};
1554
+
1555
+ // 1. Prepare all HTML contents (CPU work, no DOM)
1556
+ const preparedItems = contentsWithIds.map(item => {
1557
+ const zoomLevel = item.zoomLevel || 2;
1558
+ const contentType = item.contentType ?? item.ContentType ?? 'text';
1559
+ const maxWidth = item.maxWidth || 800;
1560
+ const { styles, fontStack } = getMarkdownStyles(zoomLevel, contentType);
1561
+ const scopeClass = `mm-measure-root-${item.id}`;
1562
+ const scopedStyles = scopeCss(styles, `.${scopeClass}`);
1563
+
1564
+ let htmlContent = '';
1565
+ const promptValue = item.prompt ?? item.Prompt ?? '';
1566
+ const sourceFilePath = item.sourceFilePath ?? item.SourceFilePath ?? '';
1567
+ const displayPromptValue = getDisplayPrompt({ contentType, sourceFilePath }, promptValue);
1568
+ const defaultPrompts = ['Note', 'Pasted Text', '에이전트 메모'];
1569
+ const shouldShowPrompt = promptValue && !defaultPrompts.includes(promptValue);
1570
+
1571
+ if (shouldShowPrompt) {
1572
+ htmlContent += `<div class="prompt">${escapeHtml(displayPromptValue)}</div>`;
1573
+ }
1574
+
1575
+ if (item.content) {
1576
+ if (shouldShowPrompt) {
1577
+ htmlContent += '<hr class="node-separator" />';
1578
+ }
1579
+
1580
+ const processedResponse = preprocessMarkdown(item.content);
1581
+ let parsed;
1582
+
1583
+ if (hasMarked) {
1584
+ try {
1585
+ const renderer = new window.marked.Renderer();
1586
+ renderer.code = function (codeOrObj, language) {
1587
+ const code = typeof codeOrObj === 'object' ? codeOrObj.text : codeOrObj;
1588
+ const lang = typeof codeOrObj === 'object' ? codeOrObj.lang : language;
1589
+ const escaped = escapeHtml(code || '');
1590
+ const langClass = lang ? ` class="language-${lang}"` : '';
1591
+ return `<pre><code${langClass}>${escaped}</code></pre>`;
1592
+ };
1593
+ renderer.codespan = function (codeOrObj) {
1594
+ const code = typeof codeOrObj === 'object' ? codeOrObj.text : codeOrObj;
1595
+ return `<code>${escapeHtml(code || '')}</code>`;
1596
+ };
1597
+ parsed = window.marked.parse(processedResponse, { breaks: true, xhtml: true, renderer });
1598
+ } catch (e) {
1599
+ parsed = window.marked.parse(processedResponse, { breaks: true, xhtml: true });
1600
+ }
1601
+ } else {
1602
+ parsed = `<div style="white-space: pre-wrap;">${escapeHtml(processedResponse)}</div>`;
1603
+ }
1604
+
1605
+ parsed = sanitizeHtml(parsed);
1606
+ htmlContent += `<div class="response">${parsed}</div>`;
1607
+ }
1608
+
1609
+ // Calculate dimensions
1610
+ const BORDER_WIDTH = 3 * zoomLevel;
1611
+ const PADDING_X = 16 * zoomLevel;
1612
+ const PADDING_BOTTOM = 16 * zoomLevel;
1613
+ const PADDING_TOP = (contentType === 'note' ? 16 : 12) * zoomLevel;
1614
+ const totalHorizontalPadding = (PADDING_X + BORDER_WIDTH) * 2;
1615
+ const totalVerticalPadding = PADDING_TOP + PADDING_BOTTOM + (BORDER_WIDTH * 2);
1616
+ const contentWidth = Math.max(1, maxWidth - totalHorizontalPadding);
1617
+
1618
+ return {
1619
+ id: item.id,
1620
+ htmlContent,
1621
+ styles: scopedStyles,
1622
+ fontStack,
1623
+ scopeClass,
1624
+ contentWidth,
1625
+ totalVerticalPadding,
1626
+ zoomLevel,
1627
+ maxHeight: (item.maxHeight || 800) * zoomLevel,
1628
+ PADDING_BOTTOM
1629
+ };
1630
+ });
1631
+
1632
+ // 2. WRITE: Inject ALL HTML at once (single DOM write)
1633
+ const combinedHtml = preparedItems.map((item, idx) => {
1634
+ return `<div id="batch-measure-${idx}"
1635
+ style="width:${item.contentWidth}px;
1636
+ font-family:${item.fontStack};
1637
+ font-size:${14 * item.zoomLevel}px;
1638
+ line-height:1.5;
1639
+ overflow-wrap:break-word;
1640
+ white-space:normal;
1641
+ overflow:hidden;
1642
+ position:absolute;
1643
+ left:0;
1644
+ top:${idx * 10000}px;">
1645
+ <style>${item.styles}</style>
1646
+ <div class="${item.scopeClass}">${item.htmlContent}</div>
1647
+ </div>`;
1648
+ }).join('');
1649
+
1650
+ measurer.innerHTML = combinedHtml;
1651
+
1652
+ // 3. Yield to browser for layout calculation (single reflow)
1653
+ await new Promise(resolve => setTimeout(resolve, 0));
1654
+
1655
+ // 4. READ: Get ALL heights at once (single DOM read after reflow)
1656
+ for (let i = 0; i < preparedItems.length; i++) {
1657
+ const item = preparedItems[i];
1658
+ const el = document.getElementById(`batch-measure-${i}`);
1659
+
1660
+ if (el) {
1661
+ const rect = el.getBoundingClientRect();
1662
+ const contentHeight = Math.ceil(rect.height);
1663
+ const totalRequiredHeight = contentHeight + item.totalVerticalPadding;
1664
+
1665
+ const MIN_HEIGHT = 60 * item.zoomLevel;
1666
+ let visibleHeight = Math.min(Math.max(totalRequiredHeight, MIN_HEIGHT), item.maxHeight);
1667
+
1668
+ const extraScrollMargin = item.PADDING_BOTTOM;
1669
+ const maxScrollPx = Math.max(0, totalRequiredHeight - visibleHeight + extraScrollMargin);
1670
+
1671
+ results[item.id] = {
1672
+ height: Math.ceil(Math.max(60, visibleHeight / item.zoomLevel)),
1673
+ maxScrollPx
1674
+ };
1675
+ }
1676
+ }
1677
+
1678
+ // 5. Cleanup
1679
+ measurer.innerHTML = '';
1680
+
1681
+ const elapsed = (performance.now() - startTime).toFixed(1);
1682
+ log(`[measureBatchHtmlHeights] ✅ Measured ${contentsWithIds.length} nodes in ${elapsed}ms (${(elapsed / contentsWithIds.length).toFixed(1)}ms/node avg)`);
1683
+
1684
+ return results;
1685
+ }
1686
+
1687
+
1688
+
1689
+
1690
+ // ▼▼▼ [Memory Optimization] Shared 1x1 placeholder texture ▼▼▼
1691
+ let sharedPlaceholderTexture = null;
1692
+ function getSharedPlaceholderTexture() {
1693
+ if (!sharedPlaceholderTexture) {
1694
+ const canvas = document.createElement('canvas');
1695
+ canvas.width = 1;
1696
+ canvas.height = 1;
1697
+ const ctx = canvas.getContext('2d');
1698
+ ctx.fillStyle = '#ffffff';
1699
+ ctx.fillRect(0, 0, 1, 1);
1700
+
1701
+ const tex = new THREE.CanvasTexture(canvas);
1702
+ tex.colorSpace = THREE.SRGBColorSpace;
1703
+ tex.minFilter = THREE.NearestFilter;
1704
+ tex.magFilter = THREE.NearestFilter;
1705
+ tex.generateMipmaps = false;
1706
+ tex._isShared = true; // Flag to prevent disposal via dispose()
1707
+
1708
+ sharedPlaceholderTexture = tex;
1709
+ }
1710
+ return sharedPlaceholderTexture;
1711
+ }
1712
+ // ▲▲▲ [Memory Optimization] ▲▲▲
1713
+
1714
+ async function generatePlaceholderTextures(module, nodeModel, width, height) {
1715
+ const zoomLevel = 2;
1716
+ // Skip canvas creation entirely!
1717
+
1718
+ const sharedTex = getSharedPlaceholderTexture();
1719
+
1720
+ const tailDefault = createTailTexture('#fefefe', 'rgba(55, 65, 81, 0.9)', zoomLevel, 12, false);
1721
+ const tailSelected = createTailTexture('#fefefe', '#7b7ff2', zoomLevel, 12, true);
1722
+
1723
+ // ▼▼▼ [SDF Glow] Glow is now rendered via SDF shader - no texture needed! ▼▼▼
1724
+ const glowData = null;
1725
+ // ▲▲▲ [SDF Glow] ▲▲▲
1726
+
1727
+ const result = {
1728
+ textures: {
1729
+ default: {
1730
+ body: sharedTex,
1731
+ tail: tailDefault,
1732
+ // Attach metadata to the wrapper object, NOT the shared texture
1733
+ _qualityScale: 0.1,
1734
+ _cachedContent: nodeModel.response ?? ''
1735
+ },
1736
+ selected: {
1737
+ body: sharedTex,
1738
+ tail: tailSelected,
1739
+ _qualityScale: 0.1
1740
+ },
1741
+ glow: glowData
1742
+ },
1743
+ width,
1744
+ height
1745
+ };
1746
+
1747
+ return result;
1748
+ }
1749
+
1750
+ async function ensureImageCached(nodeId, url, imageCache, options = {}) {
1751
+ if (!url) return null;
1752
+
1753
+ if (url.startsWith('⏳') || url.includes('Uploading')) {
1754
+ return null;
1755
+ }
1756
+
1757
+ const preferOriginal = options.preferOriginal === true;
1758
+ const allowOriginalFallback = options.allowOriginalFallback !== false;
1759
+ const resizeWidth = getRequestedResizeWidth(options);
1760
+ const existing = imageCache.get(nodeId) || null;
1761
+ const deferredOriginalError = getDeferredOriginalImageError(existing, url, resizeWidth, options);
1762
+ if (deferredOriginalError) {
1763
+ return deferredOriginalError;
1764
+ }
1765
+
1766
+ const reusableEntry = canReuseCachedImageEntry(existing, url, options);
1767
+ if (reusableEntry) {
1768
+ return reusableEntry;
1769
+ }
1770
+
1771
+ const normalizedUrl = normalizeImageAssetUrl(url);
1772
+ const normalizedOriginalUrl = normalizeImageAssetUrl(options.originalUrl);
1773
+ const requestedUrl = preferOriginal && normalizedOriginalUrl
1774
+ ? normalizedOriginalUrl
1775
+ : normalizedUrl;
1776
+ let blob = null;
1777
+ let actualUrl = requestedUrl; // Track which URL was actually loaded
1778
+ let candidateUrl = requestedUrl;
1779
+ const authToken = typeof options.authToken === 'string' ? options.authToken.trim() : '';
1780
+ const notifyMissingAsset = (missingUrl, errorStatus) => {
1781
+ if (Number(errorStatus || 0) !== 404) {
1782
+ return;
1783
+ }
1784
+
1785
+ const normalizedMissingUrl =
1786
+ normalizeImageAssetUrl(missingUrl)
1787
+ || normalizeImageAssetUrl(options.originalUrl)
1788
+ || normalizeImageAssetUrl(requestedUrl)
1789
+ || normalizeImageAssetUrl(url);
1790
+ if (!normalizedMissingUrl) {
1791
+ return;
1792
+ }
1793
+
1794
+ try { options.onMissingAsset?.(normalizedMissingUrl); } catch { }
1795
+ };
1796
+
1797
+ const getFetchOptions = (targetUrl) => {
1798
+ const normalized = (targetUrl || '').toLowerCase();
1799
+ if (normalized.startsWith('blob:') || normalized.startsWith('data:') || normalized.startsWith('file:')) {
1800
+ return undefined;
1801
+ }
1802
+
1803
+ const requestOptions = {};
1804
+ if (isLoopbackImageAssetUrl(targetUrl)) {
1805
+ requestOptions.targetAddressSpace = 'loopback';
1806
+ }
1807
+
1808
+ if (authToken) {
1809
+ requestOptions.headers = {
1810
+ Authorization: `Bearer ${authToken}`
1811
+ };
1812
+ }
1813
+
1814
+ return Object.keys(requestOptions).length > 0 ? requestOptions : undefined;
1815
+ };
1816
+
1817
+ const fetchWithAuth = (targetUrl) => fetch(targetUrl, getFetchOptions(targetUrl));
1818
+ const fetchBlobWithAuth = async (targetUrl) => {
1819
+ const requestKey = getImageAssetErrorCacheKey(targetUrl) || normalizeImageAssetUrl(targetUrl);
1820
+ if (!requestKey) {
1821
+ const response = await fetchWithAuth(targetUrl);
1822
+ if (!response.ok) {
1823
+ const httpError = new Error(`HTTP ${response.status}`);
1824
+ httpError.status = response.status;
1825
+ httpError.url = targetUrl;
1826
+ storeFailedImageAssetEntry(createCachedImageErrorEntry(targetUrl, resizeWidth, response.status));
1827
+ throw httpError;
1828
+ }
1829
+ return await readImageBlobFromResponse(response, targetUrl);
1830
+ }
1831
+
1832
+ const existingPendingFetch = pendingImageAssetFetches.get(requestKey);
1833
+ if (existingPendingFetch) {
1834
+ return await existingPendingFetch;
1835
+ }
1836
+
1837
+ const pendingFetch = (async () => {
1838
+ const failedAssetEntry = getFailedImageAssetEntry(targetUrl);
1839
+ if (failedAssetEntry) {
1840
+ throw createSyntheticImageFetchError(failedAssetEntry, targetUrl);
1841
+ }
1842
+
1843
+ const response = await fetchWithAuth(targetUrl);
1844
+ if (!response.ok) {
1845
+ const httpError = new Error(`HTTP ${response.status}`);
1846
+ httpError.status = response.status;
1847
+ httpError.url = targetUrl;
1848
+ storeFailedImageAssetEntry(createCachedImageErrorEntry(targetUrl, resizeWidth, response.status));
1849
+ throw httpError;
1850
+ }
1851
+ return await readImageBlobFromResponse(response, targetUrl);
1852
+ })();
1853
+
1854
+ pendingImageAssetFetches.set(requestKey, pendingFetch);
1855
+ try {
1856
+ return await pendingFetch;
1857
+ } finally {
1858
+ if (pendingImageAssetFetches.get(requestKey) === pendingFetch) {
1859
+ pendingImageAssetFetches.delete(requestKey);
1860
+ }
1861
+ }
1862
+ };
1863
+
1864
+ if (shouldResolveNodeAssetUrlBeforeFetch(candidateUrl, options)) {
1865
+ const refreshedUrl = await requestFreshNodeAssetUrl(nodeId, candidateUrl, options);
1866
+ const normalizedRefreshedUrl = normalizeImageAssetUrl(refreshedUrl);
1867
+ if (normalizedRefreshedUrl) {
1868
+ candidateUrl = normalizedRefreshedUrl;
1869
+ actualUrl = normalizedRefreshedUrl;
1870
+ try { options.onRefreshedUrl?.(normalizedRefreshedUrl); } catch { }
1871
+ }
1872
+ }
1873
+
1874
+ // ▼▼▼ [Fallback] Preview requests may opt into original fallback, but startup preview paths keep this disabled. ▼▼▼
1875
+ try {
1876
+ blob = await fetchBlobWithAuth(candidateUrl);
1877
+ actualUrl = candidateUrl;
1878
+ } catch (error) {
1879
+ // Only escalate a thumbnail miss to the original asset when the caller explicitly allows it.
1880
+ const isThumbnail = isThumbnailAssetUrl(candidateUrl);
1881
+ if (isThumbnail && allowOriginalFallback) {
1882
+ // Convert thumbnail URL to original URL
1883
+ const originalUrl = candidateUrl.replace('/assets/thumbs/', '/assets/').replace(/\.jpg$/, '');
1884
+ // Try common extensions
1885
+ const extensions = ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp'];
1886
+ let fallbackSuccess = false;
1887
+ let lastFallbackError = error;
1888
+
1889
+ for (const ext of extensions) {
1890
+ const tryUrl = originalUrl.includes('.') ? originalUrl : originalUrl + ext;
1891
+ try {
1892
+ blob = await fetchBlobWithAuth(tryUrl);
1893
+ actualUrl = tryUrl;
1894
+ fallbackSuccess = true;
1895
+ log(`[TextureFactory] Thumbnail not found, using original: ${tryUrl}`);
1896
+ break;
1897
+ } catch (fallbackError) {
1898
+ lastFallbackError = fallbackError;
1899
+ // Try next extension
1900
+ }
1901
+ }
1902
+
1903
+ // Also try the original URL from options if provided
1904
+ if (!fallbackSuccess && normalizedOriginalUrl) {
1905
+ try {
1906
+ blob = await fetchBlobWithAuth(normalizedOriginalUrl);
1907
+ actualUrl = normalizedOriginalUrl;
1908
+ fallbackSuccess = true;
1909
+ log(`[TextureFactory] Using provided originalUrl: ${normalizedOriginalUrl}`);
1910
+ } catch (originalError) {
1911
+ lastFallbackError = originalError;
1912
+ // Ignore
1913
+ }
1914
+ }
1915
+
1916
+ if (!fallbackSuccess) {
1917
+ const refreshedUrl = await requestFreshNodeAssetUrl(nodeId, actualUrl || candidateUrl, options);
1918
+ if (refreshedUrl) {
1919
+ try { options.onRefreshedUrl?.(refreshedUrl); } catch { }
1920
+ return ensureImageCached(nodeId, refreshedUrl, imageCache, {
1921
+ ...options,
1922
+ originalUrl: refreshedUrl,
1923
+ skipAssetRefresh: true
1924
+ });
1925
+ }
1926
+
1927
+ console.warn(`[TextureFactory] Thumbnail fetch failed and no usable fallback remained for ${nodeId}`);
1928
+ const errorStatus = getImageErrorStatus(lastFallbackError);
1929
+ notifyMissingAsset(actualUrl || candidateUrl || requestedUrl, errorStatus);
1930
+ const preservedOriginalError = rememberOriginalImageError(
1931
+ existing,
1932
+ actualUrl || candidateUrl || requestedUrl,
1933
+ resizeWidth,
1934
+ errorStatus,
1935
+ options
1936
+ );
1937
+ if (preservedOriginalError) {
1938
+ return preservedOriginalError;
1939
+ }
1940
+ const errorEntry = createCachedImageErrorEntry(
1941
+ candidateUrl,
1942
+ resizeWidth,
1943
+ errorStatus
1944
+ );
1945
+ storeFailedImageAssetEntry(errorEntry);
1946
+ return storeImageCacheEntry(imageCache, nodeId, errorEntry);
1947
+ }
1948
+ } else {
1949
+ const refreshedUrl = await requestFreshNodeAssetUrl(nodeId, actualUrl || candidateUrl, options);
1950
+ if (refreshedUrl) {
1951
+ try { options.onRefreshedUrl?.(refreshedUrl); } catch { }
1952
+ return ensureImageCached(nodeId, refreshedUrl, imageCache, {
1953
+ ...options,
1954
+ originalUrl: refreshedUrl,
1955
+ skipAssetRefresh: true
1956
+ });
1957
+ }
1958
+
1959
+ console.warn(`[TextureFactory] Image fetch failed for ${nodeId}:`, error);
1960
+ const errorStatus = getImageErrorStatus(error);
1961
+ notifyMissingAsset(actualUrl || candidateUrl || requestedUrl, errorStatus);
1962
+ const preservedOriginalError = rememberOriginalImageError(
1963
+ existing,
1964
+ actualUrl || candidateUrl || requestedUrl,
1965
+ resizeWidth,
1966
+ errorStatus,
1967
+ options
1968
+ );
1969
+ if (preservedOriginalError) {
1970
+ return preservedOriginalError;
1971
+ }
1972
+ const errorEntry = createCachedImageErrorEntry(
1973
+ candidateUrl,
1974
+ resizeWidth,
1975
+ errorStatus
1976
+ );
1977
+ storeFailedImageAssetEntry(errorEntry);
1978
+ return storeImageCacheEntry(imageCache, nodeId, errorEntry);
1979
+ }
1980
+ }
1981
+ // ▲▲▲ [Fallback] ▲▲▲
1982
+
1983
+ if (blob && typeof createImageBitmap === 'function') {
1984
+ try {
1985
+ // ▼▼▼ [Optimization] Skip resize for thumbnails (already 512px or smaller) ▼▼▼
1986
+ const isThumbnail = isThumbnailAssetUrl(actualUrl);
1987
+ const sourceKind = getImageSourceKind(actualUrl);
1988
+ const resizeQuality = preferOriginal ? 'high' : 'medium';
1989
+
1990
+ if (isThumbnail) {
1991
+ // Thumbnail: decode directly without resize (faster - single decode)
1992
+ const bitmap = await createImageBitmap(blob);
1993
+ const cached = {
1994
+ image: bitmap,
1995
+ url: actualUrl,
1996
+ width: bitmap.width,
1997
+ height: bitmap.height,
1998
+ requestedWidth: bitmap.width,
1999
+ sourceKind,
2000
+ assetKey: getCanonicalImageAssetKey(actualUrl),
2001
+ type: 'bitmap',
2002
+ isError: false,
2003
+ fullResError: null
2004
+ };
2005
+ clearFailedImageAssetEntry(actualUrl);
2006
+ return storeImageCacheEntry(imageCache, nodeId, cached);
2007
+ }
2008
+
2009
+ // Non-thumbnail (original image): keep a larger, DPR-aware cached bitmap for visible image meshes.
2010
+ const bitmap = await createImageBitmap(blob, { resizeWidth, resizeQuality });
2011
+ const cached = {
2012
+ image: bitmap,
2013
+ url: actualUrl,
2014
+ width: bitmap.width,
2015
+ height: bitmap.height,
2016
+ requestedWidth: resizeWidth,
2017
+ sourceKind,
2018
+ assetKey: getCanonicalImageAssetKey(actualUrl),
2019
+ type: 'bitmap',
2020
+ isError: false,
2021
+ fullResError: null
2022
+ };
2023
+ clearFailedImageAssetEntry(actualUrl);
2024
+ return storeImageCacheEntry(imageCache, nodeId, cached);
2025
+ // ▲▲▲ [Optimization] ▲▲▲
2026
+ } catch (error) {
2027
+ console.warn('[TextureFactory] createImageBitmap failed, fallback to Image()', {
2028
+ nodeId,
2029
+ actualUrl,
2030
+ blobType: blob?.type || '',
2031
+ blobSize: Number(blob?.size || 0)
2032
+ }, error);
2033
+ }
2034
+ }
2035
+
2036
+ return new Promise((resolve) => {
2037
+ const img = new Image();
2038
+ if (!actualUrl.startsWith('blob:') && !actualUrl.startsWith('data:') && !actualUrl.startsWith('file:')) {
2039
+ img.crossOrigin = 'anonymous';
2040
+ }
2041
+
2042
+ let objectUrl = null;
2043
+ if (blob) {
2044
+ objectUrl = URL.createObjectURL(blob);
2045
+ }
2046
+
2047
+ img.onload = () => {
2048
+ if (objectUrl) URL.revokeObjectURL(objectUrl);
2049
+ const sourceKind = getImageSourceKind(actualUrl);
2050
+ const cached = {
2051
+ image: img,
2052
+ url: actualUrl,
2053
+ width: img.naturalWidth || img.width || 0,
2054
+ height: img.naturalHeight || img.height || 0,
2055
+ requestedWidth: resizeWidth,
2056
+ sourceKind,
2057
+ assetKey: getCanonicalImageAssetKey(actualUrl),
2058
+ type: 'image',
2059
+ isError: false,
2060
+ fullResError: null
2061
+ };
2062
+ clearFailedImageAssetEntry(actualUrl);
2063
+ resolve(storeImageCacheEntry(imageCache, nodeId, cached));
2064
+ };
2065
+
2066
+ img.onerror = () => {
2067
+ if (objectUrl) URL.revokeObjectURL(objectUrl);
2068
+ console.warn(`[TextureFactory] Image load error for ${nodeId}`, {
2069
+ actualUrl,
2070
+ blobType: blob?.type || '',
2071
+ blobSize: Number(blob?.size || 0)
2072
+ });
2073
+ const errorEntry = createCachedImageErrorEntry(actualUrl, resizeWidth, 0);
2074
+ storeFailedImageAssetEntry(errorEntry);
2075
+ resolve(storeImageCacheEntry(imageCache, nodeId, errorEntry));
2076
+ };
2077
+
2078
+ img.src = objectUrl || actualUrl;
2079
+ });
2080
+ }
2081
+
2082
+ /**
2083
+ * 텍스처 생성 및 캐싱 (LOD 품질 지원)
2084
+ * @param {number} qualityScale - 품질 스케일 (1.0=고해상도, 0.25=저해상도)
2085
+ */
2086
+ // Simplified Texture Factory for Image Nodes & Measurement Only
2087
+
2088
+ function getMarkdownStyles(zoomLevel, contentType = 'text', options = {}) {
2089
+ // Kept for measurement purposes only
2090
+ const z = zoomLevel;
2091
+ const fontStack = "ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif";
2092
+ const baseFontSize = 14 * z;
2093
+ const baseLineHeight = 1.5;
2094
+
2095
+ // Simplified styles just for height measurement
2096
+ const styles = `
2097
+ div { line-height: ${baseLineHeight}; font-family: ${fontStack}; font-size: ${baseFontSize}px; margin: 0; padding: 0; }
2098
+ p { margin: 0 0 ${8 * z}px 0; }
2099
+ h1, h2, h3, h4 { font-size: ${16 * z}px; font-weight: 700; margin: ${12 * z}px 0 ${6 * z}px 0; }
2100
+ ul, ol { margin: ${6 * z}px 0 ${10 * z}px ${20 * z}px; padding: 0; }
2101
+ li { margin-bottom: ${4 * z}px; }
2102
+ pre { margin: ${8 * z}px 0; white-space: pre-wrap; }
2103
+ `;
2104
+ return { styles, fontStack };
2105
+ }
2106
+
2107
+ // [Deleted] renderMarkdownToCanvasAsync - No longer needed (CSS3D handles rendering)
2108
+ // [Deleted] drawNodeBodyOnCanvas - No longer needed
2109
+
2110
+ // ... (measureHtmlHeightAsync and helper functions kept for auto-sizing logic) ...
2111
+
2112
+ /**
2113
+ * 텍스처 생성 및 캐싱 (Simplified)
2114
+ */
2115
+ async function generateAndCacheTextures(module, nodeModel, imageCache, updateData = {}, qualityScale = 1.0) {
2116
+ // Image Node: Full processing
2117
+ if (nodeModel.contentType === 'image') {
2118
+ // ... existing image logic ...
2119
+ const defaultWidth = module.aiNodeDefaultWidth || 400;
2120
+ const defaultHeight = module.aiNodeDefaultHeight || 200;
2121
+ const width = nodeModel.width || 300;
2122
+ const height = nodeModel.height || 200;
2123
+
2124
+ const bodyDefault = await createRenderedTextureAsync(module, nodeModel, false, imageCache, qualityScale);
2125
+ // Skip selected texture for optimization if requested, or create it
2126
+ const skipSelected = updateData.skipSelected === true;
2127
+ const bodySelected = skipSelected ? null : await createRenderedTextureAsync(module, nodeModel, true, imageCache, qualityScale);
2128
+
2129
+ // Tail textures
2130
+ const zoomLevel = 2;
2131
+ const tailDefaultColors = getTailColors(nodeModel, false);
2132
+ const tailSelectedColors = getTailColors(nodeModel, true);
2133
+ const tailDefault = createTailTexture(tailDefaultColors.fill, tailDefaultColors.stroke, zoomLevel, 12, false);
2134
+ const tailSelected = createTailTexture(tailSelectedColors.fill, tailSelectedColors.stroke, zoomLevel, 12, true);
2135
+
2136
+ return {
2137
+ textures: {
2138
+ default: { body: bodyDefault, tail: tailDefault },
2139
+ selected: { body: bodySelected, tail: tailSelected },
2140
+ glow: null
2141
+ },
2142
+ width,
2143
+ height,
2144
+ sourceCanvas: bodyDefault?.image
2145
+ };
2146
+ }
2147
+
2148
+ // Text/Note/Code Nodes: Measurement ONLY, No Textures
2149
+ const isTextLike = nodeModel.contentType === 'text' || nodeModel.contentType === 'note' || nodeModel.contentType === 'code' || nodeModel.contentType === 'markdown';
2150
+
2151
+ // 1. Measure Height (Crucial for Layout)
2152
+ if (isTextLike && !nodeModel.isChunkedText) { // Code nodes now measured like text
2153
+ const skipMeasure = updateData.skipMeasure === true;
2154
+ if (!skipMeasure) {
2155
+ const zoomLevel = 2;
2156
+ const defaultWidth = module.aiNodeDefaultWidth || 400;
2157
+ const width = nodeModel.width || defaultWidth;
2158
+ const defaultHeight = module.aiNodeDefaultHeight || 200;
2159
+ const effectiveMaxHeight = module.showFullAiResponse ? 10000 : defaultHeight;
2160
+
2161
+ // Reuse existing measurement logic
2162
+ try {
2163
+ const forcedHeight = (nodeModel.height && nodeModel.height > 0) ? (nodeModel.height * zoomLevel) : null;
2164
+ const { height: measuredHeight, maxScrollPx } = await measureHtmlHeightAsync(nodeModel, zoomLevel, width * zoomLevel, forcedHeight, effectiveMaxHeight);
2165
+
2166
+ if (!nodeModel.height || nodeModel.height <= 0) {
2167
+ nodeModel.height = measuredHeight;
2168
+ }
2169
+ nodeModel.maxScroll = maxScrollPx / zoomLevel;
2170
+ } catch (e) {
2171
+ console.warn('[TextureFactory] Height measure failed', e);
2172
+ if (!nodeModel.height) nodeModel.height = 200;
2173
+ }
2174
+ }
2175
+ }
2176
+
2177
+ // 2. Return Placeholder/Null Textures (CSS3D will handle visibility)
2178
+ // We technically don't need *any* texture for the content, just the tail.
2179
+ // But mind-map-nodes.js expects a structure. We will return minimized data.
2180
+
2181
+ const zoomLevel = 2;
2182
+ const tailDefaultColors = getTailColors(nodeModel, false);
2183
+ const tailSelectedColors = getTailColors(nodeModel, true);
2184
+ const tailDefault = createTailTexture(tailDefaultColors.fill, tailDefaultColors.stroke, zoomLevel, 12, false);
2185
+ const tailSelected = createTailTexture(tailSelectedColors.fill, tailSelectedColors.stroke, zoomLevel, 12, true);
2186
+
2187
+ return {
2188
+ textures: {
2189
+ // No body textures! mind-map-nodes.js should be updated to handle null/undefined body texture
2190
+ default: { body: null, tail: tailDefault },
2191
+ selected: { body: null, tail: tailSelected },
2192
+ glow: null
2193
+ },
2194
+ width: nodeModel.width || 400,
2195
+ height: nodeModel.height || 200
2196
+ };
2197
+ }
2198
+
2199
+ // Placeholder generation optimized for CSS3D adoption
2200
+ async function generatePlaceholderTextures(module, nodeModel, width, height) {
2201
+ // ▼▼▼ [Optimization] Skip texture generation for CSS3D nodes ▼▼▼
2202
+ const isCss3dType = nodeModel.contentType === 'text' || nodeModel.contentType === 'note' || nodeModel.contentType === 'memo' || nodeModel.contentType === 'markdown' || nodeModel.contentType === 'code';
2203
+
2204
+ // For CSS3D types, we return dummy/null textures because they are rendered via CSS3D or InstancedMesh (lines).
2205
+ // Exception: Glow might still need a texture, but usually that comes from a shared sprite or simple mesh.
2206
+ // We still need the 'glow' texture if the interaction model expects it on the glObject.
2207
+
2208
+ const glowTextureInfo = null;
2209
+
2210
+ // Image nodes stay on the WebGL path and manage their own body texture directly.
2211
+
2212
+ if (isCss3dType) {
2213
+ const zoomLevel = 2;
2214
+ const tailDefaultColors = getTailColors(nodeModel, false);
2215
+ const tailSelectedColors = getTailColors(nodeModel, true);
2216
+ const tailDefault = createTailTexture(tailDefaultColors.fill, tailDefaultColors.stroke, zoomLevel, 12, false);
2217
+ const tailSelected = createTailTexture(tailSelectedColors.fill, tailSelectedColors.stroke, zoomLevel, 12, true);
2218
+ return {
2219
+ textures: {
2220
+ default: { body: null, tail: tailDefault },
2221
+ selected: { body: null, tail: tailSelected },
2222
+ glow: glowTextureInfo
2223
+ }
2224
+ };
2225
+ }
2226
+ // ▲▲▲ [Optimization] ▲▲▲
2227
+
2228
+ // Legacy behavior for other types (e.g. PDF, custom)
2229
+ const zoomLevel = 2; // Fixed resolution scale
2230
+ // ... (Original logic for creating canvas textures for non-CSS types)
2231
+
2232
+ // Return placeholder
2233
+ return {
2234
+ textures: {
2235
+ default: { body: null, tail: null },
2236
+ selected: { body: null, tail: null },
2237
+ glow: glowTextureInfo
2238
+ }
2239
+ };
2240
+ }
2241
+
2242
+
2243
+ window.MindMapTextureFactory = {
2244
+ generateAndCacheTextures,
2245
+ generatePlaceholderTextures,
2246
+ measureHtmlHeightAsync,
2247
+ measureBatchHtmlHeights, // [Optimization] Batch measure for Layout Thrashing prevention
2248
+ ensureImageCached,
2249
+ resolveCachedImageEntry,
2250
+ createTailTexture, // ★ Export for code node tail creation
2251
+ clearCache: () => {
2252
+ tailTextureCache.clear();
2253
+ failedImageAssetCache.clear();
2254
+ pendingImageAssetFetches.clear();
2255
+ },
2256
+ registerRenderer: (type, renderer) => renderers.set(type, renderer)
2257
+ };
2258
+
2259
+ log('mind-map-texture-factory.js initialized (folded corners, spinner)');
2260
+ })();