@mindexec/cli 0.2.476 → 0.2.478

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