@mindexec/cli 0.2.455 → 0.2.457

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 (23) hide show
  1. package/package.json +77 -77
  2. package/remote-fast/osx-arm64/mindexec-remote-fast.dll +0 -0
  3. package/remote-fast/osx-x64/mindexec-remote-fast.dll +0 -0
  4. package/remote-fast/win-x64/mindexec-remote-fast.dll +0 -0
  5. package/scripts/remote-fleet-render-smoke.mjs +24 -46
  6. package/wwwroot/_content/MindExecution.Shared/js/mind-map-core.js +4 -4
  7. package/wwwroot/_content/MindExecution.Shared/js/mind-map-css3d-manager.js +253 -261
  8. package/wwwroot/_content/MindExecution.Shared/js/mind-map-pipeline.js +1326 -1323
  9. package/wwwroot/_content/MindExecution.Shared/js/mind-map-render-plan.js +25 -6
  10. package/wwwroot/_framework/{MindExecution.Core.3y861y22o4.dll → MindExecution.Core.speado072l.dll} +0 -0
  11. package/wwwroot/_framework/{MindExecution.Kernel.182clfthnx.dll → MindExecution.Kernel.mjy31ssdac.dll} +0 -0
  12. package/wwwroot/_framework/{MindExecution.Plugins.Admin.ghxhrm0vf5.dll → MindExecution.Plugins.Admin.vbjjmdaao2.dll} +0 -0
  13. package/wwwroot/_framework/{MindExecution.Plugins.Business.doimlros51.dll → MindExecution.Plugins.Business.0cr5jc6wqe.dll} +0 -0
  14. package/wwwroot/_framework/{MindExecution.Plugins.Concept.hp54u7s3y5.dll → MindExecution.Plugins.Concept.m7p666e0jd.dll} +0 -0
  15. package/wwwroot/_framework/{MindExecution.Plugins.Directory.wjfnb1nk3b.dll → MindExecution.Plugins.Directory.kmdtirnljl.dll} +0 -0
  16. package/wwwroot/_framework/{MindExecution.Plugins.PlanMaster.447psj7gxo.dll → MindExecution.Plugins.PlanMaster.m1pfeozacj.dll} +0 -0
  17. package/wwwroot/_framework/{MindExecution.Plugins.YouTube.7f551zknhn.dll → MindExecution.Plugins.YouTube.nvsc9q4s6b.dll} +0 -0
  18. package/wwwroot/_framework/{MindExecution.Shared.aftyr3f4lo.dll → MindExecution.Shared.9jaznballd.dll} +0 -0
  19. package/wwwroot/_framework/{MindExecution.Web.tkj3z0fg8f.dll → MindExecution.Web.k5jxwawryl.dll} +0 -0
  20. package/wwwroot/_framework/blazor.boot.json +21 -21
  21. package/wwwroot/index.html +1 -1
  22. package/wwwroot/service-worker-assets.js +27 -27
  23. package/wwwroot/service-worker.js +1 -1
@@ -1,16 +1,16 @@
1
- // File name: X:\Dev@Work\MindExecution\Client\wwwroot\js\mind-map-pipeline.js
2
- // [Refactor] Owns the async rendering queue and node resizing logic.
1
+ // File name: X:\Dev@Work\MindExecution\Client\wwwroot\js\mind-map-pipeline.js
2
+ // [Refactor] Owns the async rendering queue and node resizing logic.
3
3
  (function () {
4
4
  'use strict';
5
5
 
6
6
  const CSS3D_WRAPPER_TRANSITION = 'box-shadow .22s ease, filter .22s ease';
7
7
  const CSS3D_WRAPPER_WEBKIT_TRANSITION = '-webkit-box-shadow .22s ease, -webkit-filter .22s ease';
8
-
9
- // ▼▼▼ [Changed] Add state variables to switch from debounce to throttle ▼▼▼
10
- const debounceTimers = new Map();
11
- const scrollUpdateThrottleTimers = new Map();
12
- const lastScrollCall = new Map();
13
- const resizeThrottleTimers = new Map(); // [New] throttle for texture updates during resize
8
+
9
+ // ▼▼▼ [Changed] Add state variables to switch from debounce to throttle ▼▼▼
10
+ const debounceTimers = new Map();
11
+ const scrollUpdateThrottleTimers = new Map();
12
+ const lastScrollCall = new Map();
13
+ const resizeThrottleTimers = new Map(); // [New] throttle for texture updates during resize
14
14
  const lastResizeUpdateCall = new Map(); // [New] last resize-update timestamp
15
15
  const THROTTLE_INTERVAL = 16; // 16ms -> ~60fps
16
16
  const RESIZE_HANDLE_SIZE = 16;
@@ -21,8 +21,9 @@
21
21
  const CSV_TABLE_MIN_WIDTH = 320;
22
22
  const CSV_TABLE_MIN_HEIGHT = 180;
23
23
  const REMOTE_FLEET_SEMANTIC_TYPE = 'RemoteFleetMonitor';
24
- const REMOTE_FLEET_DISPLAY_NAME = 'Multi Desktop Monitor';
25
- const REMOTE_FLEET_LEGACY_DISPLAY_NAME = 'Remote Fleet Monitor';
24
+ const REMOTE_FLEET_DISPLAY_NAME = 'Remote Work Console';
25
+ const REMOTE_FLEET_LEGACY_DISPLAY_NAME = 'Multi Desktop Monitor';
26
+ const REMOTE_FLEET_LEGACY_DISPLAY_NAME_V0 = 'Remote Fleet Monitor';
26
27
  const REMOTE_FLEET_MONITOR_MIN_WIDTH = 520;
27
28
  const REMOTE_FLEET_MONITOR_MIN_HEIGHT = 320;
28
29
 
@@ -44,7 +45,9 @@
44
45
  const contentType = getNodeContentType(nodeModel);
45
46
  const title = String(nodeModel?.prompt ?? nodeModel?.Prompt ?? '').trim();
46
47
  return contentType === 'templatelauncher'
47
- && (title === REMOTE_FLEET_DISPLAY_NAME || title === REMOTE_FLEET_LEGACY_DISPLAY_NAME);
48
+ && (title === REMOTE_FLEET_DISPLAY_NAME
49
+ || title === REMOTE_FLEET_LEGACY_DISPLAY_NAME
50
+ || title === REMOTE_FLEET_LEGACY_DISPLAY_NAME_V0);
48
51
  }
49
52
 
50
53
  function isCsvTableNodeModel(nodeModel) {
@@ -145,7 +148,7 @@
145
148
  }
146
149
 
147
150
  // ▼▼▼ [Perf] Render queue (time-sliced) ▼▼▼
148
- const renderQueue = [];
151
+ const renderQueue = [];
149
152
  const renderQueueSet = new Set();
150
153
  let renderQueueScheduled = false;
151
154
  let renderModuleRef = null;
@@ -154,52 +157,52 @@
154
157
  let renderSeq = 0;
155
158
  let renderQueueSortCount = 0;
156
159
  let renderQueueMaxLength = 0;
157
-
158
- function computeRenderPriority(module, nodeId) {
159
- const entry = module?.nodeObjectsById?.get(nodeId);
160
- if (!entry) return 1000000;
161
-
162
- const obj = entry.glObject || entry.cssObject;
163
- const visible = obj?.visible === true;
164
- const pos = obj?.position || { x: 0, y: 0 };
165
- const cam = module.camera?.position || { x: 0, y: 0 };
166
-
167
- const dx = pos.x - cam.x;
168
- const dy = pos.y - cam.y;
169
- const dist = Math.sqrt(dx * dx + dy * dy);
170
-
171
- const isSelected = module.multiSelectedNodeIds?.has(nodeId) || module.selectedNodeIdJs === nodeId;
172
-
173
- // 낮은 값이 우선: 보이는 노드 > 선택 노드 > 카메라 근접
174
- let priority = dist;
175
- if (!visible) priority += 100000;
176
- if (isSelected) priority -= 5000;
177
-
178
- return priority;
179
- }
180
-
181
- function enqueueRender(module, nodeId) {
182
- if (!nodeId) return;
183
- renderModuleRef = module;
160
+
161
+ function computeRenderPriority(module, nodeId) {
162
+ const entry = module?.nodeObjectsById?.get(nodeId);
163
+ if (!entry) return 1000000;
164
+
165
+ const obj = entry.glObject || entry.cssObject;
166
+ const visible = obj?.visible === true;
167
+ const pos = obj?.position || { x: 0, y: 0 };
168
+ const cam = module.camera?.position || { x: 0, y: 0 };
169
+
170
+ const dx = pos.x - cam.x;
171
+ const dy = pos.y - cam.y;
172
+ const dist = Math.sqrt(dx * dx + dy * dy);
173
+
174
+ const isSelected = module.multiSelectedNodeIds?.has(nodeId) || module.selectedNodeIdJs === nodeId;
175
+
176
+ // 낮은 값이 우선: 보이는 노드 > 선택 노드 > 카메라 근접
177
+ let priority = dist;
178
+ if (!visible) priority += 100000;
179
+ if (isSelected) priority -= 5000;
180
+
181
+ return priority;
182
+ }
183
+
184
+ function enqueueRender(module, nodeId) {
185
+ if (!nodeId) return;
186
+ renderModuleRef = module;
184
187
  if (!renderQueueSet.has(nodeId)) {
185
188
  renderQueue.push({ nodeId, priority: computeRenderPriority(module, nodeId), seq: renderSeq++ });
186
189
  renderQueueSet.add(nodeId);
187
190
  renderQueueDirty = true;
188
191
  renderQueueMaxLength = Math.max(renderQueueMaxLength, renderQueue.length);
189
- }
190
- scheduleRenderQueue();
191
- }
192
-
192
+ }
193
+ scheduleRenderQueue();
194
+ }
195
+
193
196
  function scheduleRenderQueue() {
194
197
  if (renderQueueScheduled) return;
195
198
  renderQueueScheduled = true;
196
199
  scheduleNonVisualFrame(() => processRenderQueueBatch());
197
200
  }
198
-
199
- async function processRenderQueueBatch() {
200
- const start = performance.now();
201
-
202
- // 우선순위 정렬 (가시/선택/거리)
201
+
202
+ async function processRenderQueueBatch() {
203
+ const start = performance.now();
204
+
205
+ // 우선순위 정렬 (가시/선택/거리)
203
206
  if (renderQueueDirty) {
204
207
  if (renderQueue.length > 1) {
205
208
  renderQueue.sort((a, b) => (b.priority - a.priority) || (b.seq - a.seq));
@@ -210,64 +213,64 @@
210
213
 
211
214
  while (renderQueue.length > 0) {
212
215
  const item = renderQueue.pop();
213
- const nodeId = item.nodeId;
214
- renderQueueSet.delete(nodeId);
215
- if (renderModuleRef) {
216
- await processRenderQueue(renderModuleRef, nodeId);
217
- }
218
- if ((performance.now() - start) >= RENDER_BUDGET_MS) {
219
- break;
220
- }
221
- }
222
-
223
- renderQueueScheduled = false;
224
- if (renderQueue.length > 0) {
225
- scheduleRenderQueue();
226
- }
227
- }
228
- // ▲▲▲ [Perf] ▲▲▲
229
-
230
- // ▼▼▼ [New] Chunked-node virtual scroll constants ▼▼▼
231
- const CHUNK_LINE_HEIGHT = 21; // 14px font * 1.5 line-height (matches plain text rendering)
232
- const CHUNK_SIZE = 100; // lines per chunk (matches C# TextChunkService)
233
- // ▲▲▲ [New] ▲▲▲
234
-
235
- // ▼▼▼ [Perf] Deferred disposal to avoid GC/driver spikes ▼▼▼
216
+ const nodeId = item.nodeId;
217
+ renderQueueSet.delete(nodeId);
218
+ if (renderModuleRef) {
219
+ await processRenderQueue(renderModuleRef, nodeId);
220
+ }
221
+ if ((performance.now() - start) >= RENDER_BUDGET_MS) {
222
+ break;
223
+ }
224
+ }
225
+
226
+ renderQueueScheduled = false;
227
+ if (renderQueue.length > 0) {
228
+ scheduleRenderQueue();
229
+ }
230
+ }
231
+ // ▲▲▲ [Perf] ▲▲▲
232
+
233
+ // ▼▼▼ [New] Chunked-node virtual scroll constants ▼▼▼
234
+ const CHUNK_LINE_HEIGHT = 21; // 14px font * 1.5 line-height (matches plain text rendering)
235
+ const CHUNK_SIZE = 100; // lines per chunk (matches C# TextChunkService)
236
+ // ▲▲▲ [New] ▲▲▲
237
+
238
+ // ▼▼▼ [Perf] Deferred disposal to avoid GC/driver spikes ▼▼▼
236
239
  const disposeQueue = [];
237
240
  let disposeQueueHead = 0;
238
- let disposeScheduled = false;
239
- const DISPOSE_BUDGET_MS = 6; // soft budget per frame
240
-
241
- function enqueueDispose(resource) {
242
- if (!resource || typeof resource.dispose !== 'function') return;
243
- disposeQueue.push(resource);
244
- scheduleDispose();
245
- }
246
-
247
- function scheduleDispose() {
248
- if (disposeScheduled) return;
249
- disposeScheduled = true;
241
+ let disposeScheduled = false;
242
+ const DISPOSE_BUDGET_MS = 6; // soft budget per frame
243
+
244
+ function enqueueDispose(resource) {
245
+ if (!resource || typeof resource.dispose !== 'function') return;
246
+ disposeQueue.push(resource);
247
+ scheduleDispose();
248
+ }
249
+
250
+ function scheduleDispose() {
251
+ if (disposeScheduled) return;
252
+ disposeScheduled = true;
250
253
  if (typeof requestIdleCallback === 'function') {
251
254
  requestIdleCallback(processDisposeQueue);
252
255
  } else {
253
256
  scheduleNonVisualFrame(() => processDisposeQueue());
254
257
  }
255
258
  }
256
-
257
- function processDisposeQueue(deadline) {
258
- const start = performance.now();
259
+
260
+ function processDisposeQueue(deadline) {
261
+ const start = performance.now();
259
262
  while (disposeQueueHead < disposeQueue.length) {
260
263
  const item = disposeQueue[disposeQueueHead++];
261
- try { item.dispose(); } catch { }
262
-
263
- if (deadline && typeof deadline.timeRemaining === 'function') {
264
- if (deadline.timeRemaining() < 1) break;
265
- } else if ((performance.now() - start) >= DISPOSE_BUDGET_MS) {
266
- break;
267
- }
268
- }
269
-
270
- disposeScheduled = false;
264
+ try { item.dispose(); } catch { }
265
+
266
+ if (deadline && typeof deadline.timeRemaining === 'function') {
267
+ if (deadline.timeRemaining() < 1) break;
268
+ } else if ((performance.now() - start) >= DISPOSE_BUDGET_MS) {
269
+ break;
270
+ }
271
+ }
272
+
273
+ disposeScheduled = false;
271
274
  if (disposeQueueHead >= disposeQueue.length) {
272
275
  disposeQueue.length = 0;
273
276
  disposeQueueHead = 0;
@@ -278,16 +281,16 @@
278
281
  if (disposeQueueHead < disposeQueue.length) {
279
282
  scheduleDispose();
280
283
  }
281
- }
282
- // ▲▲▲ [Perf] ▲▲▲
283
-
284
+ }
285
+ // ▲▲▲ [Perf] ▲▲▲
286
+
284
287
  function removeGlowMesh(group) {
285
288
  if (!group?.getObjectByName) return null;
286
289
  const glowMesh = group.getObjectByName('glow');
287
290
  if (!glowMesh) return null;
288
-
289
- group.remove(glowMesh);
290
- enqueueDispose(glowMesh.geometry);
291
+
292
+ group.remove(glowMesh);
293
+ enqueueDispose(glowMesh.geometry);
291
294
  enqueueDispose(glowMesh.material);
292
295
  return null;
293
296
  }
@@ -298,27 +301,27 @@
298
301
  window.MindMapNodes.applyImageCoverUv(bodyMesh, width, height);
299
302
  }
300
303
  }
301
-
302
- // ▼▼▼ [Changed] Geometry helper for rounded corners ▼▼▼
304
+
305
+ // ▼▼▼ [Changed] Geometry helper for rounded corners ▼▼▼
303
306
  function createRoundedRectGeometry(width, height, radius) {
304
307
  const shape = new THREE.Shape();
305
308
  const x = -width / 2;
306
309
  const y = -height / 2;
307
-
308
- // Start at bottom-left, go clockwise
309
- shape.moveTo(x, y + radius);
310
- shape.quadraticCurveTo(x, y, x + radius, y);
311
-
312
- shape.lineTo(x + width - radius, y);
313
- shape.quadraticCurveTo(x + width, y, x + width, y + radius);
314
-
315
- shape.lineTo(x + width, y + height - radius);
316
- shape.quadraticCurveTo(x + width, y + height, x + width - radius, y + height);
317
-
318
- shape.lineTo(x + radius, y + height);
319
- shape.quadraticCurveTo(x, y + height, x, y + height - radius);
320
-
321
- shape.lineTo(x, y + radius);
310
+
311
+ // Start at bottom-left, go clockwise
312
+ shape.moveTo(x, y + radius);
313
+ shape.quadraticCurveTo(x, y, x + radius, y);
314
+
315
+ shape.lineTo(x + width - radius, y);
316
+ shape.quadraticCurveTo(x + width, y, x + width, y + radius);
317
+
318
+ shape.lineTo(x + width, y + height - radius);
319
+ shape.quadraticCurveTo(x + width, y + height, x + width - radius, y + height);
320
+
321
+ shape.lineTo(x + radius, y + height);
322
+ shape.quadraticCurveTo(x, y + height, x, y + height - radius);
323
+
324
+ shape.lineTo(x, y + radius);
322
325
  const points = shape.getPoints();
323
326
  return new THREE.BufferGeometry().setFromPoints(points);
324
327
  }
@@ -404,102 +407,102 @@
404
407
  }
405
408
 
406
409
  // ▼▼▼ [New] Update visible content for virtual scrolling ▼▼▼
407
- function updateVisibleContent(model, lineHeight, chunkSize) {
408
- if (!model.isChunkedText || !model._chunks) {
409
- model._visibleContent = model.response || '';
410
- model._visibleScrollOffset = model.scrollOffset || 0;
411
- return;
412
- }
413
-
414
- const scrollOffset = model.scrollOffset || 0;
415
- const nodeHeight = model.height || 400;
416
-
417
- // Visible line range in the current viewport
418
- const visibleStartLine = Math.floor(scrollOffset / lineHeight);
419
- const visibleLineCount = Math.ceil(nodeHeight / lineHeight) + 5; // small buffer
420
-
421
- // Chunk indices needed for that range
422
- const startChunkIndex = Math.floor(visibleStartLine / chunkSize) * chunkSize;
423
- const endChunkIndex = Math.floor((visibleStartLine + visibleLineCount) / chunkSize) * chunkSize;
424
-
425
- // Merge only the chunks that intersect the current viewport
426
- let visibleContent = '';
427
- let firstChunkStart = -1;
428
- let hasContent = false;
429
-
430
- for (let chunkStart = startChunkIndex; chunkStart <= endChunkIndex; chunkStart += chunkSize) {
431
- if (model._chunks[chunkStart]) {
432
- if (firstChunkStart === -1) firstChunkStart = chunkStart;
433
- visibleContent += model._chunks[chunkStart] + '\n';
434
- hasContent = true;
435
- }
436
- }
437
-
438
- // ▼▼▼ [Changed] If no chunks exist in range, use the nearest loaded chunk ▼▼▼
439
- if (!hasContent) {
440
- // Find the nearest loaded chunk
441
- const loadedChunks = Object.keys(model._chunks).map(Number).sort((a, b) => a - b);
442
- if (loadedChunks.length > 0) {
443
- // Pick the chunk closest to the current position
444
- let closestChunk = loadedChunks[0];
445
- for (const chunkStart of loadedChunks) {
446
- if (chunkStart <= startChunkIndex) {
447
- closestChunk = chunkStart;
448
- } else {
449
- break;
450
- }
451
- }
452
- firstChunkStart = closestChunk;
453
- visibleContent = model._chunks[closestChunk] + '\n';
454
-
455
- // Add the next chunk as well if present
456
- const nextChunk = closestChunk + chunkSize;
457
- if (model._chunks[nextChunk]) {
458
- visibleContent += model._chunks[nextChunk] + '\n';
459
- }
460
- } else {
461
- // If no chunks exist at all, keep the previous response
462
- return;
463
- }
464
- }
465
- // ▲▲▲ [Changed] ▲▲▲
466
-
467
- if (firstChunkStart === -1) firstChunkStart = 0;
468
-
469
- // Compute relative scroll offset from the first chunk
470
- const firstChunkPixelOffset = firstChunkStart * lineHeight;
471
- const relativeScrollOffset = scrollOffset - firstChunkPixelOffset;
472
-
473
- model._visibleContent = visibleContent;
474
- model._visibleScrollOffset = Math.max(0, relativeScrollOffset);
475
-
476
- // ▼▼▼ [Changed] Do not overwrite response for chunk nodes (saved/restored via full file path) ▼▼▼
477
- // Chunk nodes are restored based on SourceFilePath, so avoid mutating response.
478
- // model.response = visibleContent; // removed
479
- // ▲▲▲ [Changed] ▲▲▲
480
- }
481
- // ▲▲▲ [New] ▲▲▲
482
- // ▲▲▲ [Changed] ▲▲▲
483
-
484
- async function processRenderQueue(module, nodeId) {
485
- // ▼▼▼ [Profiling] Start per-node render timing ▼▼▼
486
- // console.time(`[Pipeline] 6. Render Node ${nodeId}`);
487
- // ▲▲▲ [Profiling] ▲▲▲
488
-
489
- // ▼▼▼ [Changed] module.nodeRenderLocks -> window.MindMapNodes.nodeRenderLocks ▼▼▼
490
- if (window.MindMapNodes.nodeRenderLocks.get(nodeId)) {
491
- // console.log(`[Pipeline] [Log Detail] Node ${nodeId} skipped: Render lock active.`);
492
- return;
493
- }
494
-
495
- // ▼▼▼ [Changed] module.pendingNodeUpdates -> window.MindMapNodes.pendingNodeUpdates ▼▼▼
496
- const updateData = window.MindMapNodes.pendingNodeUpdates.get(nodeId);
497
- if (!updateData) {
498
- return;
499
- }
500
-
501
- // console.log(`[Pipeline] [Log Detail] Node ${nodeId} processing queue.`);
502
-
410
+ function updateVisibleContent(model, lineHeight, chunkSize) {
411
+ if (!model.isChunkedText || !model._chunks) {
412
+ model._visibleContent = model.response || '';
413
+ model._visibleScrollOffset = model.scrollOffset || 0;
414
+ return;
415
+ }
416
+
417
+ const scrollOffset = model.scrollOffset || 0;
418
+ const nodeHeight = model.height || 400;
419
+
420
+ // Visible line range in the current viewport
421
+ const visibleStartLine = Math.floor(scrollOffset / lineHeight);
422
+ const visibleLineCount = Math.ceil(nodeHeight / lineHeight) + 5; // small buffer
423
+
424
+ // Chunk indices needed for that range
425
+ const startChunkIndex = Math.floor(visibleStartLine / chunkSize) * chunkSize;
426
+ const endChunkIndex = Math.floor((visibleStartLine + visibleLineCount) / chunkSize) * chunkSize;
427
+
428
+ // Merge only the chunks that intersect the current viewport
429
+ let visibleContent = '';
430
+ let firstChunkStart = -1;
431
+ let hasContent = false;
432
+
433
+ for (let chunkStart = startChunkIndex; chunkStart <= endChunkIndex; chunkStart += chunkSize) {
434
+ if (model._chunks[chunkStart]) {
435
+ if (firstChunkStart === -1) firstChunkStart = chunkStart;
436
+ visibleContent += model._chunks[chunkStart] + '\n';
437
+ hasContent = true;
438
+ }
439
+ }
440
+
441
+ // ▼▼▼ [Changed] If no chunks exist in range, use the nearest loaded chunk ▼▼▼
442
+ if (!hasContent) {
443
+ // Find the nearest loaded chunk
444
+ const loadedChunks = Object.keys(model._chunks).map(Number).sort((a, b) => a - b);
445
+ if (loadedChunks.length > 0) {
446
+ // Pick the chunk closest to the current position
447
+ let closestChunk = loadedChunks[0];
448
+ for (const chunkStart of loadedChunks) {
449
+ if (chunkStart <= startChunkIndex) {
450
+ closestChunk = chunkStart;
451
+ } else {
452
+ break;
453
+ }
454
+ }
455
+ firstChunkStart = closestChunk;
456
+ visibleContent = model._chunks[closestChunk] + '\n';
457
+
458
+ // Add the next chunk as well if present
459
+ const nextChunk = closestChunk + chunkSize;
460
+ if (model._chunks[nextChunk]) {
461
+ visibleContent += model._chunks[nextChunk] + '\n';
462
+ }
463
+ } else {
464
+ // If no chunks exist at all, keep the previous response
465
+ return;
466
+ }
467
+ }
468
+ // ▲▲▲ [Changed] ▲▲▲
469
+
470
+ if (firstChunkStart === -1) firstChunkStart = 0;
471
+
472
+ // Compute relative scroll offset from the first chunk
473
+ const firstChunkPixelOffset = firstChunkStart * lineHeight;
474
+ const relativeScrollOffset = scrollOffset - firstChunkPixelOffset;
475
+
476
+ model._visibleContent = visibleContent;
477
+ model._visibleScrollOffset = Math.max(0, relativeScrollOffset);
478
+
479
+ // ▼▼▼ [Changed] Do not overwrite response for chunk nodes (saved/restored via full file path) ▼▼▼
480
+ // Chunk nodes are restored based on SourceFilePath, so avoid mutating response.
481
+ // model.response = visibleContent; // removed
482
+ // ▲▲▲ [Changed] ▲▲▲
483
+ }
484
+ // ▲▲▲ [New] ▲▲▲
485
+ // ▲▲▲ [Changed] ▲▲▲
486
+
487
+ async function processRenderQueue(module, nodeId) {
488
+ // ▼▼▼ [Profiling] Start per-node render timing ▼▼▼
489
+ // console.time(`[Pipeline] 6. Render Node ${nodeId}`);
490
+ // ▲▲▲ [Profiling] ▲▲▲
491
+
492
+ // ▼▼▼ [Changed] module.nodeRenderLocks -> window.MindMapNodes.nodeRenderLocks ▼▼▼
493
+ if (window.MindMapNodes.nodeRenderLocks.get(nodeId)) {
494
+ // console.log(`[Pipeline] [Log Detail] Node ${nodeId} skipped: Render lock active.`);
495
+ return;
496
+ }
497
+
498
+ // ▼▼▼ [Changed] module.pendingNodeUpdates -> window.MindMapNodes.pendingNodeUpdates ▼▼▼
499
+ const updateData = window.MindMapNodes.pendingNodeUpdates.get(nodeId);
500
+ if (!updateData) {
501
+ return;
502
+ }
503
+
504
+ // console.log(`[Pipeline] [Log Detail] Node ${nodeId} processing queue.`);
505
+
503
506
  const HANDLE_SIZE = RESIZE_HANDLE_SIZE;
504
507
  const nodeEntry = module.nodeObjectsById.get(nodeId);
505
508
  if (!nodeEntry) {
@@ -531,24 +534,24 @@
531
534
  model.IsLoading = !!updateData.isLoading;
532
535
  }
533
536
  const zoomLevel = 2;
534
-
535
- // ▼▼▼ [Perf] CSS3D 지원 노드는 거리와 무관하게 텍스처 생성 스킵 ▼▼▼
536
- const cameraZ = module.camera?.position?.z || 0;
537
- const threshold = 4000;
537
+
538
+ // ▼▼▼ [Perf] CSS3D 지원 노드는 거리와 무관하게 텍스처 생성 스킵 ▼▼▼
539
+ const cameraZ = module.camera?.position?.z || 0;
540
+ const threshold = 4000;
538
541
  const isHighMode = cameraZ < threshold;
539
542
  const supportsCss3d = model.contentType === 'text' ||
540
543
  model.contentType === 'note' ||
541
544
  model.contentType === 'memo' ||
542
545
  model.contentType === 'markdown' ||
543
546
  model.contentType === 'code';
544
-
545
- // ★ [Fix] currentType이 'CSS'이면 카메라 거리와 무관하게 텍스처 생성 스킵
546
- const isCssMode = nodeEntry.currentType === 'CSS';
547
- const shouldSkipForCss = supportsCss3d && (isHighMode || cameraZ >= threshold || isCssMode);
548
-
549
- if (shouldSkipForCss) {
550
- const prevWidth = model.width || 0;
551
- const prevHeight = model.height || 0;
547
+
548
+ // ★ [Fix] currentType이 'CSS'이면 카메라 거리와 무관하게 텍스처 생성 스킵
549
+ const isCssMode = nodeEntry.currentType === 'CSS';
550
+ const shouldSkipForCss = supportsCss3d && (isHighMode || cameraZ >= threshold || isCssMode);
551
+
552
+ if (shouldSkipForCss) {
553
+ const prevWidth = model.width || 0;
554
+ const prevHeight = model.height || 0;
552
555
  const hasExplicitSizeUpdate =
553
556
  (updateData.width !== null && updateData.width !== undefined) ||
554
557
  (updateData.height !== null && updateData.height !== undefined);
@@ -558,10 +561,10 @@
558
561
  model.Response = updateData.content;
559
562
  if (updateData.width !== null && updateData.width !== undefined) model.width = updateData.width;
560
563
  if (updateData.height !== null && updateData.height !== undefined) model.height = updateData.height;
561
-
562
- // ▼▼▼ [Fix] Sync CSS3D/GL userData with updated model dimensions ▼▼▼
563
- // Without this, syncCss3dContent() reads stale userData.worldWidth/Height
564
- // and reverts the DOM size, causing content/node size mismatch.
564
+
565
+ // ▼▼▼ [Fix] Sync CSS3D/GL userData with updated model dimensions ▼▼▼
566
+ // Without this, syncCss3dContent() reads stale userData.worldWidth/Height
567
+ // and reverts the DOM size, causing content/node size mismatch.
565
568
  if (nodeEntry.cssObject?.userData) {
566
569
  nodeEntry.cssObject.userData.worldWidth = model.width || 400;
567
570
  nodeEntry.cssObject.userData.worldHeight = model.height || 200;
@@ -619,43 +622,43 @@
619
622
  inner.style.setProperty('--css3d-node-resolution-zoom', String(Math.max(1, Number(resolutionScale || 1))));
620
623
  inner.style.setProperty('zoom', String(Math.max(1, Number(resolutionScale || 1))));
621
624
  }
622
- const textarea = wrapper.querySelector('textarea');
623
- if (textarea) {
624
- textarea.style.width = '100%';
625
- textarea.style.height = '100%';
626
- }
627
- }
628
-
629
- // Update glow size if needed (no texture work)
630
- const group = nodeEntry.glObject;
631
- if (group) {
632
- if (!ENABLE_WEBGL_GLOW) {
633
- removeGlowMesh(group);
634
- } else if (window.MindMapGlowShader) {
635
- const glowMesh = group.getObjectByName('glow');
636
- const w = model.width || 400;
637
- const h = model.height || 200;
638
- if (glowMesh) {
639
- window.MindMapGlowShader.updateGlowSize(glowMesh, w, h, model.contentType || 'text');
640
- glowMesh.position.set(w / 2, -h / 2, 0); // [Fix] Z=0
641
- }
642
- }
643
- }
644
-
645
- // Keep WebGL body/tail geometry aligned with CSS3D size (prevents split)
646
- if (nodeEntry.glObject) {
647
- const bodyMesh = nodeEntry.glObject.getObjectByName('body');
648
- const tailMesh = nodeEntry.glObject.getObjectByName('tail');
649
- const w = model.width || 400;
650
- const h = model.height || 200;
651
-
625
+ const textarea = wrapper.querySelector('textarea');
626
+ if (textarea) {
627
+ textarea.style.width = '100%';
628
+ textarea.style.height = '100%';
629
+ }
630
+ }
631
+
632
+ // Update glow size if needed (no texture work)
633
+ const group = nodeEntry.glObject;
634
+ if (group) {
635
+ if (!ENABLE_WEBGL_GLOW) {
636
+ removeGlowMesh(group);
637
+ } else if (window.MindMapGlowShader) {
638
+ const glowMesh = group.getObjectByName('glow');
639
+ const w = model.width || 400;
640
+ const h = model.height || 200;
641
+ if (glowMesh) {
642
+ window.MindMapGlowShader.updateGlowSize(glowMesh, w, h, model.contentType || 'text');
643
+ glowMesh.position.set(w / 2, -h / 2, 0); // [Fix] Z=0
644
+ }
645
+ }
646
+ }
647
+
648
+ // Keep WebGL body/tail geometry aligned with CSS3D size (prevents split)
649
+ if (nodeEntry.glObject) {
650
+ const bodyMesh = nodeEntry.glObject.getObjectByName('body');
651
+ const tailMesh = nodeEntry.glObject.getObjectByName('tail');
652
+ const w = model.width || 400;
653
+ const h = model.height || 200;
654
+
652
655
  if (bodyMesh) {
653
656
  const gp = bodyMesh.geometry && bodyMesh.geometry.parameters;
654
657
  if (!gp || gp.width !== w || gp.height !== h) {
655
658
  bodyMesh.geometry.dispose();
656
659
  bodyMesh.geometry = new THREE.PlaneGeometry(w, h);
657
- }
658
- bodyMesh.position.set(w / 2, -h / 2, 0);
660
+ }
661
+ bodyMesh.position.set(w / 2, -h / 2, 0);
659
662
  if (bodyMesh.material) {
660
663
  bodyMesh.material.color?.setHex?.(0xffffff);
661
664
  bodyMesh.material.needsUpdate = true;
@@ -665,79 +668,79 @@
665
668
  applyImageCoverUv(bodyMesh, w, h);
666
669
  }
667
670
  }
668
-
669
- if (tailMesh) {
670
- tailMesh.position.set(w / 2, -h - 6, 0);
671
- if (tailMesh.material) {
672
- tailMesh.material.color?.setHex?.(0xffffff);
673
- tailMesh.material.needsUpdate = true;
674
- }
675
- }
676
- }
677
-
678
- // Persist size changes for CSS-rendered nodes as well.
679
- // Without this, auto-grown AI note dimensions are not saved to the board.
680
- const sizeChanged = Math.abs((model.width || 0) - prevWidth) > 0.5
681
- || Math.abs((model.height || 0) - prevHeight) > 0.5;
671
+
672
+ if (tailMesh) {
673
+ tailMesh.position.set(w / 2, -h - 6, 0);
674
+ if (tailMesh.material) {
675
+ tailMesh.material.color?.setHex?.(0xffffff);
676
+ tailMesh.material.needsUpdate = true;
677
+ }
678
+ }
679
+ }
680
+
681
+ // Persist size changes for CSS-rendered nodes as well.
682
+ // Without this, auto-grown AI note dimensions are not saved to the board.
683
+ const sizeChanged = Math.abs((model.width || 0) - prevWidth) > 0.5
684
+ || Math.abs((model.height || 0) - prevHeight) > 0.5;
682
685
  if (!updateData.skipPersistDimensions && (sizeChanged || hasExplicitSizeUpdate) && module.dotNetHelper?.invokeMethodAsync) {
683
- try {
684
- await module.dotNetHelper.invokeMethodAsync(
685
- 'UpdateNodeDimensions',
686
- nodeId,
687
- Math.round(model.width || 0),
688
- Math.round(model.height || 0)
689
- );
690
- } catch (e) {
691
- console.warn('[MindMapPipeline] Failed to persist CSS node dimensions:', e);
692
- }
693
- }
694
-
695
- window.MindMapNodes.pendingNodeUpdates.delete(nodeId);
696
- window.MindMapNodes.nodeRenderLocks.set(nodeId, false);
697
- return;
698
- }
699
- // ▲▲▲ [Perf] ▲▲▲
700
-
701
- const width = (updateData.width !== null && updateData.width !== undefined) ?
702
- updateData.width : (model.width || (model.contentType === 'image' ? 300 : 400));
703
-
704
- // ▼▼▼ [Key change] Reduce height-change sensitivity (avoid jitter) ▼▼▼
705
- // ▼▼▼ [Changed] Chunk nodes already have fixed maxScroll; skip recalculation ▼▼▼
706
- // ▼▼▼ [Changed] Skip height recalculation for expanded nodes (they auto-grow in updateNodeContent) ▼▼▼
707
- if ((model.contentType === 'text' || model.contentType === 'note') && !model.isChunkedText && !model.isExpanded) {
708
- if (updateData.height === null || updateData.height === undefined) {
709
- const tempModel = { ...model, response: updateData.content };
710
- // When showFullAiResponse is true, do not cap height
711
- const defaultMaxHeight = module.aiNodeDefaultHeight || 200;
712
- const effectiveMaxHeight = module.showFullAiResponse ? 10000 : defaultMaxHeight;
713
- const { height: requiredHeight, maxScrollPx } = await window.MindMapTextureFactory.measureHtmlHeightAsync(tempModel, zoomLevel, width * zoomLevel, null, effectiveMaxHeight);
714
-
715
- // Keep AI/loading note nodes at least at the configured default height.
716
- // This prevents initial one-line collapse while streaming starts.
717
- const defaultPrompts = ['Note', 'Pasted Text', '에이전트 메모'];
718
- const promptText = model.prompt ?? model.Prompt ?? '';
719
- const isAiPrompt = !!promptText && !defaultPrompts.includes(promptText);
720
- const minPreferredHeight = (model.isLoading || isAiPrompt)
721
- ? (module.aiNodeDefaultHeight || 200)
722
- : 60;
723
- const normalizedRequiredHeight = Math.max(requiredHeight, minPreferredHeight);
724
-
725
- const currentHeight = (model.height || 0);
726
- // Update only if delta is >= 5px (was 2px -> 5px)
727
- if (Math.abs(currentHeight - normalizedRequiredHeight) > 5) {
728
- model.height = normalizedRequiredHeight;
729
- model.maxScroll = maxScrollPx / zoomLevel;
730
- model.scrollOffset = Math.min(model.scrollOffset || 0, model.maxScroll);
731
- updateData.height = normalizedRequiredHeight;
732
- try {
733
- module.dotNetHelper?.invokeMethodAsync('UpdateNodeDimensions', nodeId, Math.round(width), Math.round(normalizedRequiredHeight));
734
- } catch { }
735
- } else {
736
- // If the change is tiny, keep the existing height (avoid infinite loop)
737
- updateData.height = currentHeight;
738
- }
739
- }
740
- }
686
+ try {
687
+ await module.dotNetHelper.invokeMethodAsync(
688
+ 'UpdateNodeDimensions',
689
+ nodeId,
690
+ Math.round(model.width || 0),
691
+ Math.round(model.height || 0)
692
+ );
693
+ } catch (e) {
694
+ console.warn('[MindMapPipeline] Failed to persist CSS node dimensions:', e);
695
+ }
696
+ }
697
+
698
+ window.MindMapNodes.pendingNodeUpdates.delete(nodeId);
699
+ window.MindMapNodes.nodeRenderLocks.set(nodeId, false);
700
+ return;
701
+ }
702
+ // ▲▲▲ [Perf] ▲▲▲
703
+
704
+ const width = (updateData.width !== null && updateData.width !== undefined) ?
705
+ updateData.width : (model.width || (model.contentType === 'image' ? 300 : 400));
706
+
707
+ // ▼▼▼ [Key change] Reduce height-change sensitivity (avoid jitter) ▼▼▼
708
+ // ▼▼▼ [Changed] Chunk nodes already have fixed maxScroll; skip recalculation ▼▼▼
709
+ // ▼▼▼ [Changed] Skip height recalculation for expanded nodes (they auto-grow in updateNodeContent) ▼▼▼
710
+ if ((model.contentType === 'text' || model.contentType === 'note') && !model.isChunkedText && !model.isExpanded) {
711
+ if (updateData.height === null || updateData.height === undefined) {
712
+ const tempModel = { ...model, response: updateData.content };
713
+ // When showFullAiResponse is true, do not cap height
714
+ const defaultMaxHeight = module.aiNodeDefaultHeight || 200;
715
+ const effectiveMaxHeight = module.showFullAiResponse ? 10000 : defaultMaxHeight;
716
+ const { height: requiredHeight, maxScrollPx } = await window.MindMapTextureFactory.measureHtmlHeightAsync(tempModel, zoomLevel, width * zoomLevel, null, effectiveMaxHeight);
717
+
718
+ // Keep AI/loading note nodes at least at the configured default height.
719
+ // This prevents initial one-line collapse while streaming starts.
720
+ const defaultPrompts = ['Note', 'Pasted Text', '에이전트 메모'];
721
+ const promptText = model.prompt ?? model.Prompt ?? '';
722
+ const isAiPrompt = !!promptText && !defaultPrompts.includes(promptText);
723
+ const minPreferredHeight = (model.isLoading || isAiPrompt)
724
+ ? (module.aiNodeDefaultHeight || 200)
725
+ : 60;
726
+ const normalizedRequiredHeight = Math.max(requiredHeight, minPreferredHeight);
727
+
728
+ const currentHeight = (model.height || 0);
729
+ // Update only if delta is >= 5px (was 2px -> 5px)
730
+ if (Math.abs(currentHeight - normalizedRequiredHeight) > 5) {
731
+ model.height = normalizedRequiredHeight;
732
+ model.maxScroll = maxScrollPx / zoomLevel;
733
+ model.scrollOffset = Math.min(model.scrollOffset || 0, model.maxScroll);
734
+ updateData.height = normalizedRequiredHeight;
735
+ try {
736
+ module.dotNetHelper?.invokeMethodAsync('UpdateNodeDimensions', nodeId, Math.round(width), Math.round(normalizedRequiredHeight));
737
+ } catch { }
738
+ } else {
739
+ // If the change is tiny, keep the existing height (avoid infinite loop)
740
+ updateData.height = currentHeight;
741
+ }
742
+ }
743
+ }
741
744
  // ▲▲▲ [Changed] ▲▲▲
742
745
  // ▲▲▲ [Key change] ▲▲▲
743
746
 
@@ -815,87 +818,87 @@
815
818
 
816
819
  // ★ Invalidate LOD cache when content changes
817
820
  window.MindMapTextLOD?.invalidateNodeCache?.(nodeId);
818
-
819
- const updateDataWithScroll = {
820
- ...updateData,
821
- scrollDelta: updateData.scrollDelta || 0
822
- };
823
-
824
- // ★ Troika 모드 체크: text/note/code 노드면 배경 전용 텍스처 사용
825
- // ★ Code nodes always use Troika for text rendering
826
- const isTroikaMode = (model.contentType === 'code') || // Code nodes always use Troika
827
- (window.MindMapNodes?.getTextRenderMode?.() === 'troika'
828
- && (model.contentType === 'text' || model.contentType === 'note')
829
- && !model.isChunkedText);
830
-
831
- let textures, w, h;
832
- const existingTextures = window.MindMapNodes.textureCache.get(nodeId);
833
-
834
- if (isTroikaMode) {
835
- // ★ Troika 최적화: 이미 캐시된 텍스처가 있고, 크기와 내용이 동일하면 재사용
836
- const canReuseTexture = existingTextures?.default?.body
837
- && existingTextures._cachedWidth === model.width
838
- && existingTextures._cachedHeight === model.height
839
- && existingTextures._cachedContent === model.response; // ★ Content change check
840
-
841
- if (canReuseTexture) {
842
- // 캐시된 텍스처 재사용 (텍스처 재생성 스킵)
843
- textures = existingTextures;
844
- w = model.width;
845
- h = model.height;
846
- } else {
847
- // 배경 텍스처 생성
848
- const result = await window.MindMapTextureFactory.generateTroikaBackgroundTextures(module, model, model.width, model.height);
849
- textures = result.textures;
850
- // 캐시 메타데이터 저장
851
- textures._cachedWidth = model.width;
852
- textures._cachedHeight = model.height;
853
- textures._cachedContent = model.response; // ★ Track content for change detection
854
- w = result.width;
855
- h = result.height;
856
- }
857
- } else {
858
- // Canvas 모드: 기존 방식 (텍스트 포함)
859
- const isResizeUpdate = updateData.width !== undefined || updateData.height !== undefined;
860
- const isBatchExpand = updateData.skipSelected === true; // ★ Batch expand operation
861
-
862
- // 고정 품질 렌더링 (LOD 품질 스케일 제거)
863
- const qualityScale = 1.0;
864
- console.log(`[processRenderQueue] Canvas mode for ${nodeId}, qualityScale=${qualityScale}, isResize=${isResizeUpdate}, isBatch=${isBatchExpand}`);
865
- const result = await window.MindMapTextureFactory.generateAndCacheTextures(module, model, window.MindMapNodes.imageCache, updateDataWithScroll, qualityScale);
866
- textures = result.textures;
867
- w = result.width;
868
- h = result.height;
869
-
870
- // 캐시 메타데이터 (콘텐츠 변경 감지용)
871
- if (textures.default?.body) {
872
- textures.default.body._cachedContent = model.response ?? '';
873
- }
874
- }
875
-
876
- // ★ 기존 텍스처 dispose (Troika에서 재사용한 경우 제외)
877
- if (existingTextures && existingTextures !== textures) {
878
- enqueueDispose(existingTextures.default?.body);
879
- enqueueDispose(existingTextures.selected?.body);
880
- enqueueDispose(existingTextures.glow?.texture);
881
- }
882
- window.MindMapNodes.textureCache.set(nodeId, textures);
883
-
884
- const isSelected = module.multiSelectedNodeIds.has(nodeId);
885
- // ★ [Fix] If selected texture is null (skipSelected mode), use default texture
886
- const active = (isSelected && textures.selected?.body) ? textures.selected : textures.default;
887
- const group = nodeEntry.glObject;
888
-
889
- const CORNER_RADIUS = 20;
890
- const OUTLINE_SHRINK = 2;
891
- const OUTLINE_RADIUS = Math.max(1, CORNER_RADIUS - 1);
892
- const CHAMFER_SIZE = 16;
893
-
894
- if (group) {
895
- const bodyMesh = group.getObjectByName('body');
896
- const tailMesh = group.getObjectByName('tail');
897
- let glowMesh = group.getObjectByName('glow');
898
- const outline = group.getObjectByName('outline');
821
+
822
+ const updateDataWithScroll = {
823
+ ...updateData,
824
+ scrollDelta: updateData.scrollDelta || 0
825
+ };
826
+
827
+ // ★ Troika 모드 체크: text/note/code 노드면 배경 전용 텍스처 사용
828
+ // ★ Code nodes always use Troika for text rendering
829
+ const isTroikaMode = (model.contentType === 'code') || // Code nodes always use Troika
830
+ (window.MindMapNodes?.getTextRenderMode?.() === 'troika'
831
+ && (model.contentType === 'text' || model.contentType === 'note')
832
+ && !model.isChunkedText);
833
+
834
+ let textures, w, h;
835
+ const existingTextures = window.MindMapNodes.textureCache.get(nodeId);
836
+
837
+ if (isTroikaMode) {
838
+ // ★ Troika 최적화: 이미 캐시된 텍스처가 있고, 크기와 내용이 동일하면 재사용
839
+ const canReuseTexture = existingTextures?.default?.body
840
+ && existingTextures._cachedWidth === model.width
841
+ && existingTextures._cachedHeight === model.height
842
+ && existingTextures._cachedContent === model.response; // ★ Content change check
843
+
844
+ if (canReuseTexture) {
845
+ // 캐시된 텍스처 재사용 (텍스처 재생성 스킵)
846
+ textures = existingTextures;
847
+ w = model.width;
848
+ h = model.height;
849
+ } else {
850
+ // 배경 텍스처 생성
851
+ const result = await window.MindMapTextureFactory.generateTroikaBackgroundTextures(module, model, model.width, model.height);
852
+ textures = result.textures;
853
+ // 캐시 메타데이터 저장
854
+ textures._cachedWidth = model.width;
855
+ textures._cachedHeight = model.height;
856
+ textures._cachedContent = model.response; // ★ Track content for change detection
857
+ w = result.width;
858
+ h = result.height;
859
+ }
860
+ } else {
861
+ // Canvas 모드: 기존 방식 (텍스트 포함)
862
+ const isResizeUpdate = updateData.width !== undefined || updateData.height !== undefined;
863
+ const isBatchExpand = updateData.skipSelected === true; // ★ Batch expand operation
864
+
865
+ // 고정 품질 렌더링 (LOD 품질 스케일 제거)
866
+ const qualityScale = 1.0;
867
+ console.log(`[processRenderQueue] Canvas mode for ${nodeId}, qualityScale=${qualityScale}, isResize=${isResizeUpdate}, isBatch=${isBatchExpand}`);
868
+ const result = await window.MindMapTextureFactory.generateAndCacheTextures(module, model, window.MindMapNodes.imageCache, updateDataWithScroll, qualityScale);
869
+ textures = result.textures;
870
+ w = result.width;
871
+ h = result.height;
872
+
873
+ // 캐시 메타데이터 (콘텐츠 변경 감지용)
874
+ if (textures.default?.body) {
875
+ textures.default.body._cachedContent = model.response ?? '';
876
+ }
877
+ }
878
+
879
+ // ★ 기존 텍스처 dispose (Troika에서 재사용한 경우 제외)
880
+ if (existingTextures && existingTextures !== textures) {
881
+ enqueueDispose(existingTextures.default?.body);
882
+ enqueueDispose(existingTextures.selected?.body);
883
+ enqueueDispose(existingTextures.glow?.texture);
884
+ }
885
+ window.MindMapNodes.textureCache.set(nodeId, textures);
886
+
887
+ const isSelected = module.multiSelectedNodeIds.has(nodeId);
888
+ // ★ [Fix] If selected texture is null (skipSelected mode), use default texture
889
+ const active = (isSelected && textures.selected?.body) ? textures.selected : textures.default;
890
+ const group = nodeEntry.glObject;
891
+
892
+ const CORNER_RADIUS = 20;
893
+ const OUTLINE_SHRINK = 2;
894
+ const OUTLINE_RADIUS = Math.max(1, CORNER_RADIUS - 1);
895
+ const CHAMFER_SIZE = 16;
896
+
897
+ if (group) {
898
+ const bodyMesh = group.getObjectByName('body');
899
+ const tailMesh = group.getObjectByName('tail');
900
+ let glowMesh = group.getObjectByName('glow');
901
+ const outline = group.getObjectByName('outline');
899
902
  if (!ENABLE_WEBGL_GLOW) {
900
903
  glowMesh = removeGlowMesh(group);
901
904
  }
@@ -907,18 +910,18 @@
907
910
  if (!gp || gp.width !== w || gp.height !== h) {
908
911
  bodyMesh.geometry.dispose();
909
912
  bodyMesh.geometry = new THREE.PlaneGeometry(w, h);
910
-
911
- if (outline) {
912
- outline.geometry.dispose();
913
- outline.geometry = createRoundedRectGeometry(
914
- w - OUTLINE_SHRINK,
915
- h - OUTLINE_SHRINK,
916
- OUTLINE_RADIUS
917
- );
918
- }
919
- }
920
- if (active?.body) {
921
- bodyMesh.material.map = active.body;
913
+
914
+ if (outline) {
915
+ outline.geometry.dispose();
916
+ outline.geometry = createRoundedRectGeometry(
917
+ w - OUTLINE_SHRINK,
918
+ h - OUTLINE_SHRINK,
919
+ OUTLINE_RADIUS
920
+ );
921
+ }
922
+ }
923
+ if (active?.body) {
924
+ bodyMesh.material.map = active.body;
922
925
  }
923
926
  bodyMesh.material.color.setHex(0xffffff);
924
927
  bodyMesh.material.needsUpdate = true;
@@ -939,53 +942,53 @@
939
942
  outline.position.z = 0; // [Fix] Z=0
940
943
  outline.visible = false; // Disabled - using SDF glow instead
941
944
  }
942
- }
943
- if (tailMesh) {
944
- if (active?.tail) {
945
- tailMesh.material.map = active.tail;
946
- tailMesh.material.needsUpdate = true;
947
- }
948
- tailMesh.position.set(w / 2, -h - 6, 0);
949
- }
950
-
945
+ }
946
+ if (tailMesh) {
947
+ if (active?.tail) {
948
+ tailMesh.material.map = active.tail;
949
+ tailMesh.material.needsUpdate = true;
950
+ }
951
+ tailMesh.position.set(w / 2, -h - 6, 0);
952
+ }
953
+
951
954
  // ▼▼▼ [SDF Glow] Use shader-based glow (no textures!) ▼▼▼
952
- if (ENABLE_WEBGL_GLOW && window.MindMapGlowShader) {
953
- const contentType = model.contentType || 'text';
954
- if (glowMesh) {
955
- window.MindMapGlowShader.updateGlowSize(glowMesh, w, h, contentType);
956
- window.MindMapGlowShader.updateGlowSelection(glowMesh, isSelected);
957
- glowMesh.position.set(w / 2, -h / 2, 0); // [Fix] Z=0
958
- glowMesh.renderOrder = baseRenderOrder - 5;
959
- } else {
960
- const newGlow = window.MindMapGlowShader.createSDFGlowMesh(w, h, isSelected, contentType);
961
- newGlow.position.set(w / 2, -h / 2, 0); // [Fix] Z=0
962
- newGlow.renderOrder = baseRenderOrder - 5;
963
- // ★ 카메라 거리에 따라 초기 가시성 설정
964
- const cameraZ = module.camera?.position?.z ?? Infinity;
965
- const threshold = 4000;
966
- newGlow.visible = cameraZ < threshold;
967
- group.add(newGlow);
968
- }
969
- }
970
- // ▲▲▲ [SDF Glow] ▲▲▲
971
-
972
- // ▼▼▼ [Changed] Update resize handles (4 corners + optional 4 edges) ▼▼▼
973
- if (model.contentType !== 'pdf') {
974
- // [Fix] 이미지 노드 리사이즈 핸들 크기 통일 (Note와 동일한 16px)
975
- const actualHandleSize = HANDLE_SIZE;
976
-
977
- const edgeWidthLength = Math.max(10, w - actualHandleSize * 2);
978
- const edgeHeightLength = Math.max(10, h - actualHandleSize * 2);
979
-
980
- const handleConfigs = {
981
- // ▼▼▼ [Fix] Corners - Z값을 0으로 통일하여 시차(Parallax) 완전 제거 ▼▼▼
982
- 'resizeHandle_TL': { x: actualHandleSize / 2, y: -actualHandleSize / 2, w: actualHandleSize, h: actualHandleSize, z: 0 },
983
- 'resizeHandle_TR': { x: w - actualHandleSize / 2, y: -actualHandleSize / 2, w: actualHandleSize, h: actualHandleSize, z: 0 },
984
- 'resizeHandle_BL': { x: actualHandleSize / 2, y: -h + actualHandleSize / 2, w: actualHandleSize, h: actualHandleSize, z: 0 },
985
- 'resizeHandle_BR': { x: w - actualHandleSize / 2, y: -h + actualHandleSize / 2, w: actualHandleSize, h: actualHandleSize, z: 0 }
986
- // ▲▲▲ [Fix] ▲▲▲
987
- };
988
-
955
+ if (ENABLE_WEBGL_GLOW && window.MindMapGlowShader) {
956
+ const contentType = model.contentType || 'text';
957
+ if (glowMesh) {
958
+ window.MindMapGlowShader.updateGlowSize(glowMesh, w, h, contentType);
959
+ window.MindMapGlowShader.updateGlowSelection(glowMesh, isSelected);
960
+ glowMesh.position.set(w / 2, -h / 2, 0); // [Fix] Z=0
961
+ glowMesh.renderOrder = baseRenderOrder - 5;
962
+ } else {
963
+ const newGlow = window.MindMapGlowShader.createSDFGlowMesh(w, h, isSelected, contentType);
964
+ newGlow.position.set(w / 2, -h / 2, 0); // [Fix] Z=0
965
+ newGlow.renderOrder = baseRenderOrder - 5;
966
+ // ★ 카메라 거리에 따라 초기 가시성 설정
967
+ const cameraZ = module.camera?.position?.z ?? Infinity;
968
+ const threshold = 4000;
969
+ newGlow.visible = cameraZ < threshold;
970
+ group.add(newGlow);
971
+ }
972
+ }
973
+ // ▲▲▲ [SDF Glow] ▲▲▲
974
+
975
+ // ▼▼▼ [Changed] Update resize handles (4 corners + optional 4 edges) ▼▼▼
976
+ if (model.contentType !== 'pdf') {
977
+ // [Fix] 이미지 노드 리사이즈 핸들 크기 통일 (Note와 동일한 16px)
978
+ const actualHandleSize = HANDLE_SIZE;
979
+
980
+ const edgeWidthLength = Math.max(10, w - actualHandleSize * 2);
981
+ const edgeHeightLength = Math.max(10, h - actualHandleSize * 2);
982
+
983
+ const handleConfigs = {
984
+ // ▼▼▼ [Fix] Corners - Z값을 0으로 통일하여 시차(Parallax) 완전 제거 ▼▼▼
985
+ 'resizeHandle_TL': { x: actualHandleSize / 2, y: -actualHandleSize / 2, w: actualHandleSize, h: actualHandleSize, z: 0 },
986
+ 'resizeHandle_TR': { x: w - actualHandleSize / 2, y: -actualHandleSize / 2, w: actualHandleSize, h: actualHandleSize, z: 0 },
987
+ 'resizeHandle_BL': { x: actualHandleSize / 2, y: -h + actualHandleSize / 2, w: actualHandleSize, h: actualHandleSize, z: 0 },
988
+ 'resizeHandle_BR': { x: w - actualHandleSize / 2, y: -h + actualHandleSize / 2, w: actualHandleSize, h: actualHandleSize, z: 0 }
989
+ // ▲▲▲ [Fix] ▲▲▲
990
+ };
991
+
989
992
  if (model.contentType !== 'image') {
990
993
  Object.assign(handleConfigs, {
991
994
  // ▼▼▼ [Fix] Edges - Z값을 0으로 통일 ▼▼▼
@@ -996,17 +999,17 @@
996
999
  // ▲▲▲ [Fix] ▲▲▲
997
1000
  });
998
1001
  }
999
-
1000
- // Iterate all possible handles to update position OR hide if not needed
1001
- const allHandleNames = [
1002
- 'resizeHandle_TL', 'resizeHandle_TR', 'resizeHandle_BL', 'resizeHandle_BR',
1003
- 'resizeHandle_T', 'resizeHandle_B', 'resizeHandle_L', 'resizeHandle_R'
1004
- ];
1005
-
1006
- allHandleNames.forEach(name => {
1007
- const handle = group.getObjectByName(name);
1008
- const cfg = handleConfigs[name];
1009
-
1002
+
1003
+ // Iterate all possible handles to update position OR hide if not needed
1004
+ const allHandleNames = [
1005
+ 'resizeHandle_TL', 'resizeHandle_TR', 'resizeHandle_BL', 'resizeHandle_BR',
1006
+ 'resizeHandle_T', 'resizeHandle_B', 'resizeHandle_L', 'resizeHandle_R'
1007
+ ];
1008
+
1009
+ allHandleNames.forEach(name => {
1010
+ const handle = group.getObjectByName(name);
1011
+ const cfg = handleConfigs[name];
1012
+
1010
1013
  if (handle) {
1011
1014
  if (cfg) {
1012
1015
  // Active handle: update position/size and ensure visible
@@ -1027,7 +1030,7 @@
1027
1030
  handle.visible = false;
1028
1031
  }
1029
1032
  }
1030
- });
1033
+ });
1031
1034
  }
1032
1035
  // ▲▲▲ [Changed] ▲▲▲
1033
1036
 
@@ -1055,26 +1058,26 @@
1055
1058
  }
1056
1059
  }
1057
1060
  } catch (err) {
1058
- console.error(`[Pipeline] Render Node ${nodeId} Failed:`, err);
1059
- } finally {
1060
- window.MindMapNodes.nodeRenderLocks.set(nodeId, false);
1061
- // console.timeEnd(`[Pipeline] 6. Render Node ${nodeId}`);
1062
-
1063
- // If another update arrived while processing, re-run (with a small delay)
1064
- if (window.MindMapNodes.pendingNodeUpdates.has(nodeId)) {
1065
- setTimeout(() => enqueueRender(module, nodeId), 50);
1066
- }
1067
- }
1068
- }
1069
-
1070
- // --- Resizing handlers (keep existing behavior) ---
1071
-
1072
- // ▼▼▼ [Changed] Add corner parameter for four-corner resizing ▼▼▼
1073
- function startResizing(module, nodeId, startX, startY, corner = 'BR') {
1074
- const nodeEntry = module.nodeObjectsById.get(nodeId);
1075
- if (!nodeEntry) return false;
1076
- if (nodeEntry.model.contentType === 'pdf') return false;
1077
-
1061
+ console.error(`[Pipeline] Render Node ${nodeId} Failed:`, err);
1062
+ } finally {
1063
+ window.MindMapNodes.nodeRenderLocks.set(nodeId, false);
1064
+ // console.timeEnd(`[Pipeline] 6. Render Node ${nodeId}`);
1065
+
1066
+ // If another update arrived while processing, re-run (with a small delay)
1067
+ if (window.MindMapNodes.pendingNodeUpdates.has(nodeId)) {
1068
+ setTimeout(() => enqueueRender(module, nodeId), 50);
1069
+ }
1070
+ }
1071
+ }
1072
+
1073
+ // --- Resizing handlers (keep existing behavior) ---
1074
+
1075
+ // ▼▼▼ [Changed] Add corner parameter for four-corner resizing ▼▼▼
1076
+ function startResizing(module, nodeId, startX, startY, corner = 'BR') {
1077
+ const nodeEntry = module.nodeObjectsById.get(nodeId);
1078
+ if (!nodeEntry) return false;
1079
+ if (nodeEntry.model.contentType === 'pdf') return false;
1080
+
1078
1081
  module.isResizing = true;
1079
1082
  module.resizingNodeId = nodeId;
1080
1083
  module.resizeCorner = corner; // TL, TR, BL, BR
@@ -1095,203 +1098,203 @@
1095
1098
  window.MindMapInteractions?.setBrowserSelectionLock?.(true, 'resize');
1096
1099
  document.body.classList.add('is-resizing');
1097
1100
  module.resizeStartMousePos = { x: startX, y: startY };
1098
- module.resizeStartDimensions = {
1099
- w: nodeEntry.model.width || 400,
1100
- h: nodeEntry.model.height || 200
1101
- };
1102
- // Store initial position from the active render object (CSS/GL) to avoid
1103
- // image-node jumps when CSS and GL are slightly out of sync.
1104
- const activePos = module.resizeNodeObject?.position
1105
- || nodeEntry.cssObject?.position
1106
- || nodeEntry.glObject?.position
1107
- || { x: 0, y: 0 };
1108
- module.resizeStartPosition = {
1109
- x: activePos.x,
1110
- y: activePos.y
1111
- };
1112
-
1113
- // Force immediate CSS/GL transform alignment at resize start.
1114
- if (nodeEntry.glObject) {
1115
- nodeEntry.glObject.position.x = activePos.x;
1116
- nodeEntry.glObject.position.y = activePos.y;
1117
- }
1118
- if (nodeEntry.cssObject) {
1119
- nodeEntry.cssObject.position.x = activePos.x;
1120
- nodeEntry.cssObject.position.y = activePos.y;
1121
- nodeEntry.cssObject.updateMatrixWorld(true);
1122
- }
1123
-
1124
- // ▼▼▼ [Fix] 리사이즈 중 CSS transition 비활성화하여 즉시 크기 변경 ▼▼▼
1125
- if (nodeEntry.cssObject && nodeEntry.cssObject.element) {
1126
- const wrapper = nodeEntry.cssObject.element;
1127
- wrapper.style.transition = 'none';
1128
- const inner = wrapper.firstElementChild;
1129
- if (inner) {
1130
- inner.style.transition = 'none';
1131
- }
1132
- }
1133
- // ▲▲▲ [Fix] ▲▲▲
1134
-
1135
- // ▼▼▼ [UndoRedo] Capture before-state for undo ▼▼▼
1136
- if (module.dotNetHelper) {
1137
- try { module.dotNetHelper.invokeMethodAsync('StartResizeCapture', nodeId); } catch { }
1138
- }
1139
- // ▲▲▲ [UndoRedo] ▲▲▲
1140
-
1141
- return true;
1142
- }
1143
- // ▲▲▲ [Changed] ▲▲▲
1144
-
1145
- function doResizing(module, currentX, currentY) {
1101
+ module.resizeStartDimensions = {
1102
+ w: nodeEntry.model.width || 400,
1103
+ h: nodeEntry.model.height || 200
1104
+ };
1105
+ // Store initial position from the active render object (CSS/GL) to avoid
1106
+ // image-node jumps when CSS and GL are slightly out of sync.
1107
+ const activePos = module.resizeNodeObject?.position
1108
+ || nodeEntry.cssObject?.position
1109
+ || nodeEntry.glObject?.position
1110
+ || { x: 0, y: 0 };
1111
+ module.resizeStartPosition = {
1112
+ x: activePos.x,
1113
+ y: activePos.y
1114
+ };
1115
+
1116
+ // Force immediate CSS/GL transform alignment at resize start.
1117
+ if (nodeEntry.glObject) {
1118
+ nodeEntry.glObject.position.x = activePos.x;
1119
+ nodeEntry.glObject.position.y = activePos.y;
1120
+ }
1121
+ if (nodeEntry.cssObject) {
1122
+ nodeEntry.cssObject.position.x = activePos.x;
1123
+ nodeEntry.cssObject.position.y = activePos.y;
1124
+ nodeEntry.cssObject.updateMatrixWorld(true);
1125
+ }
1126
+
1127
+ // ▼▼▼ [Fix] 리사이즈 중 CSS transition 비활성화하여 즉시 크기 변경 ▼▼▼
1128
+ if (nodeEntry.cssObject && nodeEntry.cssObject.element) {
1129
+ const wrapper = nodeEntry.cssObject.element;
1130
+ wrapper.style.transition = 'none';
1131
+ const inner = wrapper.firstElementChild;
1132
+ if (inner) {
1133
+ inner.style.transition = 'none';
1134
+ }
1135
+ }
1136
+ // ▲▲▲ [Fix] ▲▲▲
1137
+
1138
+ // ▼▼▼ [UndoRedo] Capture before-state for undo ▼▼▼
1139
+ if (module.dotNetHelper) {
1140
+ try { module.dotNetHelper.invokeMethodAsync('StartResizeCapture', nodeId); } catch { }
1141
+ }
1142
+ // ▲▲▲ [UndoRedo] ▲▲▲
1143
+
1144
+ return true;
1145
+ }
1146
+ // ▲▲▲ [Changed] ▲▲▲
1147
+
1148
+ function doResizing(module, currentX, currentY) {
1146
1149
  if (!module.isResizing || !module.resizeNodeObject) return;
1147
1150
  // ▼▼▼ [Changed] Cap max node size: X=2000, Y=4000 ▼▼▼
1148
1151
  const MAX_NODE_WIDTH = 2000;
1149
1152
  const MAX_NODE_HEIGHT = 4000;
1150
1153
  // ▲▲▲ [Changed] ▲▲▲
1151
-
1152
- const vfov = (module.camera.fov * Math.PI) / 180;
1153
- const viewHeight = 2 * Math.tan(vfov / 2) * module.camera.position.z;
1154
- const viewWidth = viewHeight * module.camera.aspect;
1155
- const screenToWorldFactorX = viewWidth / module.container.clientWidth;
1156
- const screenToWorldFactorY = viewHeight / module.container.clientHeight;
1157
- const worldDeltaX = (currentX - module.resizeStartMousePos.x) * screenToWorldFactorX;
1158
- // ▼▼▼ [Fix] Negate Y delta: screen Y increases downward, world Y increases upward ▼▼▼
1159
- const worldDeltaY = -(currentY - module.resizeStartMousePos.y) * screenToWorldFactorY;
1160
-
1161
- const HANDLE_SIZE = RESIZE_HANDLE_SIZE;
1162
- // ▼▼▼ [Changed] Use stored nodeId (CSS3D mode compatible) ▼▼▼
1163
- const nodeId = module.resizingNodeId || (module.resizeNodeObject.userData?.nodeId || module.resizeNodeObject.element?.dataset?.nodeId);
1164
- const nodeEntry = module.nodeObjectsById.get(nodeId);
1165
- if (!nodeEntry) {
1166
- console.warn('[Pipeline] doResizing: nodeEntry not found for', nodeId);
1167
- return;
1168
- }
1169
-
1170
- const corner = module.resizeCorner || 'BR';
1171
- const startW = module.resizeStartDimensions.w;
1172
- const startH = module.resizeStartDimensions.h;
1173
- const startX = module.resizeStartPosition.x;
1174
- const startY = module.resizeStartPosition.y;
1175
-
1176
- let finalWidth, finalHeight, newPosX, newPosY;
1177
-
1178
- // ▼▼▼ [Changed] Eight-direction resize logic (4 corners + 4 edges) ▼▼▼
1179
- // Node position is top-left corner in Three.js (Y increases upward)
1180
- // Depending on handle, we compute size and position differently
1181
- switch (corner) {
1182
- // === Corners (diagonal resize) ===
1183
- case 'TL': // Top-Left: anchor bottom-right, move top-left
1184
- finalWidth = startW - worldDeltaX;
1185
- finalHeight = startH + worldDeltaY;
1186
- newPosX = startX + worldDeltaX;
1187
- newPosY = startY + worldDeltaY;
1188
- break;
1189
- case 'TR': // Top-Right: anchor bottom-left, expand right/up
1190
- finalWidth = startW + worldDeltaX;
1191
- finalHeight = startH + worldDeltaY;
1192
- newPosX = startX;
1193
- newPosY = startY + worldDeltaY;
1194
- break;
1195
- case 'BL': // Bottom-Left: anchor top-right, expand left/down
1196
- finalWidth = startW - worldDeltaX;
1197
- finalHeight = startH - worldDeltaY;
1198
- newPosX = startX + worldDeltaX;
1199
- newPosY = startY;
1200
- break;
1201
- case 'BR': // Bottom-Right: anchor top-left (original behavior)
1202
- finalWidth = startW + worldDeltaX;
1203
- finalHeight = startH - worldDeltaY;
1204
- newPosX = startX;
1205
- newPosY = startY;
1206
- break;
1207
- // === Edges (single-direction resize) ===
1208
- case 'T': // Top edge: vertical only, anchor bottom
1209
- finalWidth = startW;
1210
- finalHeight = startH + worldDeltaY;
1211
- newPosX = startX;
1212
- newPosY = startY + worldDeltaY;
1213
- break;
1214
- case 'B': // Bottom edge: vertical only, anchor top
1215
- finalWidth = startW;
1216
- finalHeight = startH - worldDeltaY;
1217
- newPosX = startX;
1218
- newPosY = startY;
1219
- break;
1220
- case 'L': // Left edge: horizontal only, anchor right
1221
- finalWidth = startW - worldDeltaX;
1222
- finalHeight = startH;
1223
- newPosX = startX + worldDeltaX;
1224
- newPosY = startY;
1225
- break;
1226
- case 'R': // Right edge: horizontal only, anchor left
1227
- finalWidth = startW + worldDeltaX;
1228
- finalHeight = startH;
1229
- newPosX = startX;
1230
- newPosY = startY;
1231
- break;
1232
- default:
1233
- finalWidth = startW + worldDeltaX;
1234
- finalHeight = startH - worldDeltaY;
1235
- newPosX = startX;
1236
- newPosY = startY;
1237
- break;
1238
- }
1239
-
1154
+
1155
+ const vfov = (module.camera.fov * Math.PI) / 180;
1156
+ const viewHeight = 2 * Math.tan(vfov / 2) * module.camera.position.z;
1157
+ const viewWidth = viewHeight * module.camera.aspect;
1158
+ const screenToWorldFactorX = viewWidth / module.container.clientWidth;
1159
+ const screenToWorldFactorY = viewHeight / module.container.clientHeight;
1160
+ const worldDeltaX = (currentX - module.resizeStartMousePos.x) * screenToWorldFactorX;
1161
+ // ▼▼▼ [Fix] Negate Y delta: screen Y increases downward, world Y increases upward ▼▼▼
1162
+ const worldDeltaY = -(currentY - module.resizeStartMousePos.y) * screenToWorldFactorY;
1163
+
1164
+ const HANDLE_SIZE = RESIZE_HANDLE_SIZE;
1165
+ // ▼▼▼ [Changed] Use stored nodeId (CSS3D mode compatible) ▼▼▼
1166
+ const nodeId = module.resizingNodeId || (module.resizeNodeObject.userData?.nodeId || module.resizeNodeObject.element?.dataset?.nodeId);
1167
+ const nodeEntry = module.nodeObjectsById.get(nodeId);
1168
+ if (!nodeEntry) {
1169
+ console.warn('[Pipeline] doResizing: nodeEntry not found for', nodeId);
1170
+ return;
1171
+ }
1172
+
1173
+ const corner = module.resizeCorner || 'BR';
1174
+ const startW = module.resizeStartDimensions.w;
1175
+ const startH = module.resizeStartDimensions.h;
1176
+ const startX = module.resizeStartPosition.x;
1177
+ const startY = module.resizeStartPosition.y;
1178
+
1179
+ let finalWidth, finalHeight, newPosX, newPosY;
1180
+
1181
+ // ▼▼▼ [Changed] Eight-direction resize logic (4 corners + 4 edges) ▼▼▼
1182
+ // Node position is top-left corner in Three.js (Y increases upward)
1183
+ // Depending on handle, we compute size and position differently
1184
+ switch (corner) {
1185
+ // === Corners (diagonal resize) ===
1186
+ case 'TL': // Top-Left: anchor bottom-right, move top-left
1187
+ finalWidth = startW - worldDeltaX;
1188
+ finalHeight = startH + worldDeltaY;
1189
+ newPosX = startX + worldDeltaX;
1190
+ newPosY = startY + worldDeltaY;
1191
+ break;
1192
+ case 'TR': // Top-Right: anchor bottom-left, expand right/up
1193
+ finalWidth = startW + worldDeltaX;
1194
+ finalHeight = startH + worldDeltaY;
1195
+ newPosX = startX;
1196
+ newPosY = startY + worldDeltaY;
1197
+ break;
1198
+ case 'BL': // Bottom-Left: anchor top-right, expand left/down
1199
+ finalWidth = startW - worldDeltaX;
1200
+ finalHeight = startH - worldDeltaY;
1201
+ newPosX = startX + worldDeltaX;
1202
+ newPosY = startY;
1203
+ break;
1204
+ case 'BR': // Bottom-Right: anchor top-left (original behavior)
1205
+ finalWidth = startW + worldDeltaX;
1206
+ finalHeight = startH - worldDeltaY;
1207
+ newPosX = startX;
1208
+ newPosY = startY;
1209
+ break;
1210
+ // === Edges (single-direction resize) ===
1211
+ case 'T': // Top edge: vertical only, anchor bottom
1212
+ finalWidth = startW;
1213
+ finalHeight = startH + worldDeltaY;
1214
+ newPosX = startX;
1215
+ newPosY = startY + worldDeltaY;
1216
+ break;
1217
+ case 'B': // Bottom edge: vertical only, anchor top
1218
+ finalWidth = startW;
1219
+ finalHeight = startH - worldDeltaY;
1220
+ newPosX = startX;
1221
+ newPosY = startY;
1222
+ break;
1223
+ case 'L': // Left edge: horizontal only, anchor right
1224
+ finalWidth = startW - worldDeltaX;
1225
+ finalHeight = startH;
1226
+ newPosX = startX + worldDeltaX;
1227
+ newPosY = startY;
1228
+ break;
1229
+ case 'R': // Right edge: horizontal only, anchor left
1230
+ finalWidth = startW + worldDeltaX;
1231
+ finalHeight = startH;
1232
+ newPosX = startX;
1233
+ newPosY = startY;
1234
+ break;
1235
+ default:
1236
+ finalWidth = startW + worldDeltaX;
1237
+ finalHeight = startH - worldDeltaY;
1238
+ newPosX = startX;
1239
+ newPosY = startY;
1240
+ break;
1241
+ }
1242
+
1240
1243
  // Clamp size
1241
1244
  const minClampedSize = clampRemoteFleetResizeDimensions(nodeEntry.model, finalWidth, finalHeight);
1242
1245
  let clampedWidth = Math.min(MAX_NODE_WIDTH, minClampedSize.width);
1243
1246
  let clampedHeight = Math.min(MAX_NODE_HEIGHT, minClampedSize.height);
1244
-
1245
- // ▼▼▼ [Image Resize Fix] Remove specific Ratio Preservation for now (Treat as normal nodes) ▼▼▼
1246
- // Normal nodes Logic (apply to all)
1247
- // Adjust position if size was clamped (keep anchor point fixed)
1248
- if (corner === 'TL' || corner === 'BL' || corner === 'L') {
1249
- // Left handles: position adjusts with width changes
1250
- const widthDiff = finalWidth - clampedWidth;
1251
- newPosX += widthDiff;
1252
- }
1253
- if (corner === 'TL' || corner === 'TR' || corner === 'T') {
1254
- // Top handles: position adjusts with height changes
1255
- const heightDiff = finalHeight - clampedHeight;
1256
- newPosY -= heightDiff;
1257
- }
1258
- // ▲▲▲ [Image Resize Fix] ▲▲▲
1259
- // ▲▲▲ [Image Resize Fix] ▲▲▲
1260
-
1261
- finalWidth = clampedWidth;
1262
- finalHeight = clampedHeight;
1263
- // ▲▲▲ [Changed] ▲▲▲
1264
-
1265
- // ▼▼▼ [Refactor] 스냅 현상 제거: 실수형 크기/좌표 그대로 사용 ▼▼▼
1266
- nodeEntry.model.width = finalWidth;
1267
- nodeEntry.model.height = finalHeight;
1268
-
1269
- // ▼▼▼ [Changed] Update node position for corner resizing ▼▼▼
1270
- if (nodeEntry.glObject) {
1271
- nodeEntry.glObject.position.x = newPosX;
1272
- nodeEntry.glObject.position.y = newPosY;
1273
- }
1274
- if (nodeEntry.cssObject) {
1275
- nodeEntry.cssObject.position.x = newPosX;
1276
- nodeEntry.cssObject.position.y = newPosY;
1277
- // ▼▼▼ [Fix] CSS3D matrixWorld 즉시 갱신하여 CSS3DRenderer가 새 위치를 반영하도록 보장 ▼▼▼
1278
- nodeEntry.cssObject.updateMatrixWorld(true);
1279
- // ▲▲▲ [Fix] ▲▲▲
1280
- }
1281
- // ▼▼▼ [Fix] model position도 동기화하여 다른 sync 로직과의 일관성 보장 ▼▼▼
1282
- nodeEntry.model.positionX = newPosX;
1283
- nodeEntry.model.positionY = newPosY;
1284
- // ▲▲▲ [Fix] ▲▲▲
1285
- // ▲▲▲ [Changed] ▲▲▲
1286
-
1247
+
1248
+ // ▼▼▼ [Image Resize Fix] Remove specific Ratio Preservation for now (Treat as normal nodes) ▼▼▼
1249
+ // Normal nodes Logic (apply to all)
1250
+ // Adjust position if size was clamped (keep anchor point fixed)
1251
+ if (corner === 'TL' || corner === 'BL' || corner === 'L') {
1252
+ // Left handles: position adjusts with width changes
1253
+ const widthDiff = finalWidth - clampedWidth;
1254
+ newPosX += widthDiff;
1255
+ }
1256
+ if (corner === 'TL' || corner === 'TR' || corner === 'T') {
1257
+ // Top handles: position adjusts with height changes
1258
+ const heightDiff = finalHeight - clampedHeight;
1259
+ newPosY -= heightDiff;
1260
+ }
1261
+ // ▲▲▲ [Image Resize Fix] ▲▲▲
1262
+ // ▲▲▲ [Image Resize Fix] ▲▲▲
1263
+
1264
+ finalWidth = clampedWidth;
1265
+ finalHeight = clampedHeight;
1266
+ // ▲▲▲ [Changed] ▲▲▲
1267
+
1268
+ // ▼▼▼ [Refactor] 스냅 현상 제거: 실수형 크기/좌표 그대로 사용 ▼▼▼
1269
+ nodeEntry.model.width = finalWidth;
1270
+ nodeEntry.model.height = finalHeight;
1271
+
1272
+ // ▼▼▼ [Changed] Update node position for corner resizing ▼▼▼
1273
+ if (nodeEntry.glObject) {
1274
+ nodeEntry.glObject.position.x = newPosX;
1275
+ nodeEntry.glObject.position.y = newPosY;
1276
+ }
1277
+ if (nodeEntry.cssObject) {
1278
+ nodeEntry.cssObject.position.x = newPosX;
1279
+ nodeEntry.cssObject.position.y = newPosY;
1280
+ // ▼▼▼ [Fix] CSS3D matrixWorld 즉시 갱신하여 CSS3DRenderer가 새 위치를 반영하도록 보장 ▼▼▼
1281
+ nodeEntry.cssObject.updateMatrixWorld(true);
1282
+ // ▲▲▲ [Fix] ▲▲▲
1283
+ }
1284
+ // ▼▼▼ [Fix] model position도 동기화하여 다른 sync 로직과의 일관성 보장 ▼▼▼
1285
+ nodeEntry.model.positionX = newPosX;
1286
+ nodeEntry.model.positionY = newPosY;
1287
+ // ▲▲▲ [Fix] ▲▲▲
1288
+ // ▲▲▲ [Changed] ▲▲▲
1289
+
1287
1290
  if (nodeEntry.cssObject) {
1288
1291
  const wrapper = nodeEntry.cssObject.element;
1289
- // ▼▼▼ [Fix] CSS3D 노드는 resolutionScale을 고려하여 DOM 크기 설정 ▼▼▼
1290
- // CSS3D 객체의 scale은 1/resolutionScale이므로, DOM 크기는 worldSize * resolutionScale
1291
- const resolutionScale = nodeEntry.cssObject.userData?.resolutionScale || 1;
1292
- const domWidth = nodeEntry.model.width * resolutionScale;
1293
- const domHeight = nodeEntry.model.height * resolutionScale;
1294
-
1292
+ // ▼▼▼ [Fix] CSS3D 노드는 resolutionScale을 고려하여 DOM 크기 설정 ▼▼▼
1293
+ // CSS3D 객체의 scale은 1/resolutionScale이므로, DOM 크기는 worldSize * resolutionScale
1294
+ const resolutionScale = nodeEntry.cssObject.userData?.resolutionScale || 1;
1295
+ const domWidth = nodeEntry.model.width * resolutionScale;
1296
+ const domHeight = nodeEntry.model.height * resolutionScale;
1297
+
1295
1298
  wrapper.style.width = domWidth + 'px';
1296
1299
  wrapper.style.height = domHeight + 'px';
1297
1300
  wrapper.style.minWidth = domWidth + 'px';
@@ -1310,27 +1313,27 @@
1310
1313
  innerContent.style.maxHeight = nodeEntry.model.height + 'px';
1311
1314
 
1312
1315
  // ▼▼▼ [Fix] 이미지 노드의 경우 내부 img 요소 크기도 업데이트 ▼▼▼
1313
- if (nodeEntry.model.contentType === 'image') {
1314
- const imgEl = innerContent.querySelector('img');
1315
- if (imgEl) {
1316
- imgEl.style.width = '100%';
1317
- imgEl.style.height = '100%';
1318
- }
1319
- // contentWrapper와 responseDiv 크기도 업데이트
1320
- const contentWrapper = innerContent.querySelector('.node-content-wrapper');
1321
- if (contentWrapper) {
1322
- contentWrapper.style.width = '100%';
1323
- contentWrapper.style.height = '100%';
1324
- }
1325
- const responseDiv = innerContent.querySelector('.node-response');
1326
- if (responseDiv) {
1327
- responseDiv.style.width = '100%';
1328
- responseDiv.style.height = '100%';
1329
- }
1330
- }
1331
- // ▲▲▲ [Fix] ▲▲▲
1332
- }
1333
-
1316
+ if (nodeEntry.model.contentType === 'image') {
1317
+ const imgEl = innerContent.querySelector('img');
1318
+ if (imgEl) {
1319
+ imgEl.style.width = '100%';
1320
+ imgEl.style.height = '100%';
1321
+ }
1322
+ // contentWrapper와 responseDiv 크기도 업데이트
1323
+ const contentWrapper = innerContent.querySelector('.node-content-wrapper');
1324
+ if (contentWrapper) {
1325
+ contentWrapper.style.width = '100%';
1326
+ contentWrapper.style.height = '100%';
1327
+ }
1328
+ const responseDiv = innerContent.querySelector('.node-response');
1329
+ if (responseDiv) {
1330
+ responseDiv.style.width = '100%';
1331
+ responseDiv.style.height = '100%';
1332
+ }
1333
+ }
1334
+ // ▲▲▲ [Fix] ▲▲▲
1335
+ }
1336
+
1334
1337
  // ▼▼▼ [Fix] userData도 업데이트하여 syncCss3dContent에서 올바른 크기 사용 ▼▼▼
1335
1338
  nodeEntry.cssObject.userData.worldWidth = nodeEntry.model.width;
1336
1339
  nodeEntry.cssObject.userData.worldHeight = nodeEntry.model.height;
@@ -1344,17 +1347,17 @@
1344
1347
 
1345
1348
  if (nodeEntry.glObject) {
1346
1349
  const group = nodeEntry.glObject;
1347
-
1348
- const bodyMesh = group.getObjectByName('body');
1349
- const outline = group.getObjectByName('outline');
1350
- let glowMesh = group.getObjectByName('glow');
1351
- if (!ENABLE_WEBGL_GLOW) {
1352
- glowMesh = removeGlowMesh(group);
1353
- }
1354
- const CORNER_RADIUS = 20;
1355
- const OUTLINE_SHRINK = 2;
1356
- const OUTLINE_RADIUS = Math.max(1, CORNER_RADIUS - 1);
1357
- const CHAMFER_SIZE = 16;
1350
+
1351
+ const bodyMesh = group.getObjectByName('body');
1352
+ const outline = group.getObjectByName('outline');
1353
+ let glowMesh = group.getObjectByName('glow');
1354
+ if (!ENABLE_WEBGL_GLOW) {
1355
+ glowMesh = removeGlowMesh(group);
1356
+ }
1357
+ const CORNER_RADIUS = 20;
1358
+ const OUTLINE_SHRINK = 2;
1359
+ const OUTLINE_RADIUS = Math.max(1, CORNER_RADIUS - 1);
1360
+ const CHAMFER_SIZE = 16;
1358
1361
  if (bodyMesh) {
1359
1362
  bodyMesh.geometry.dispose();
1360
1363
  bodyMesh.geometry = new THREE.PlaneGeometry(nodeEntry.model.width, nodeEntry.model.height);
@@ -1366,15 +1369,15 @@
1366
1369
  if (nodeEntry.currentResizeType === 'CSS') {
1367
1370
  bodyMesh.visible = false;
1368
1371
  }
1369
- // ▲▲▲ [Fix] ▲▲▲
1370
-
1371
- // ▼▼▼ [Image Resize Fix] Don't clear map during resize to prevent white flash/loss ▼▼▼
1372
- // if (bodyMesh.material.map) {
1373
- // bodyMesh.material.map = null;
1374
- // bodyMesh.material.color.setHex(0xffffff);
1375
- // bodyMesh.material.needsUpdate = true;
1376
- // }
1377
- // ▲▲▲ [Image Resize Fix] ▲▲▲
1372
+ // ▲▲▲ [Fix] ▲▲▲
1373
+
1374
+ // ▼▼▼ [Image Resize Fix] Don't clear map during resize to prevent white flash/loss ▼▼▼
1375
+ // if (bodyMesh.material.map) {
1376
+ // bodyMesh.material.map = null;
1377
+ // bodyMesh.material.color.setHex(0xffffff);
1378
+ // bodyMesh.material.needsUpdate = true;
1379
+ // }
1380
+ // ▲▲▲ [Image Resize Fix] ▲▲▲
1378
1381
  const isSelected = module.multiSelectedNodeIds.has(module.resizingNodeId) || module.selectedNodeIdJs === module.resizingNodeId;
1379
1382
  if (nodeEntry.model.contentType === 'image') {
1380
1383
  const usesCssViewDuringResize =
@@ -1412,87 +1415,87 @@
1412
1415
  outline.material.opacity = 1.0;
1413
1416
  }
1414
1417
  }
1415
- }
1416
-
1417
- // ▼▼▼ [Changed] Update glow position during resize (keep visible, no flickering) ▼▼▼
1418
- if (ENABLE_WEBGL_GLOW && glowMesh) {
1419
- // Update position to match bodyMesh
1420
- glowMesh.position.set(nodeEntry.model.width / 2, -nodeEntry.model.height / 2, 0); // [Fix] Z=0
1421
- // Don't hide - causes flickering
1422
- }
1423
- // ▲▲▲ [Changed] ▲▲▲
1424
- const tailMesh = group.getObjectByName('tail');
1425
- if (tailMesh) {
1426
- tailMesh.position.set(nodeEntry.model.width / 2, -nodeEntry.model.height - 6, 0);
1427
- }
1428
- // ▼▼▼ [Changed] Update eight resize handles (4 corners + 4 edges with geometry) ▼▼▼
1429
- const edgeWidthLength = Math.max(10, nodeEntry.model.width - HANDLE_SIZE * 2);
1430
- const edgeHeightLength = Math.max(10, nodeEntry.model.height - HANDLE_SIZE * 2);
1431
- const handleConfigs = {
1432
- // ▼▼▼ [Fix] Corners Z=0 for parallax elimination ▼▼▼
1433
- 'resizeHandle_TL': { x: HANDLE_SIZE / 2, y: -HANDLE_SIZE / 2, w: HANDLE_SIZE, h: HANDLE_SIZE, z: 0 },
1434
- 'resizeHandle_TR': { x: nodeEntry.model.width - HANDLE_SIZE / 2, y: -HANDLE_SIZE / 2, w: HANDLE_SIZE, h: HANDLE_SIZE, z: 0 },
1435
- 'resizeHandle_BL': { x: HANDLE_SIZE / 2, y: -nodeEntry.model.height + HANDLE_SIZE / 2, w: HANDLE_SIZE, h: HANDLE_SIZE, z: 0 },
1436
- 'resizeHandle_BR': { x: nodeEntry.model.width - HANDLE_SIZE / 2, y: -nodeEntry.model.height + HANDLE_SIZE / 2, w: HANDLE_SIZE, h: HANDLE_SIZE, z: 0 },
1437
- // Edges Z=0
1438
- 'resizeHandle_T': { x: nodeEntry.model.width / 2, y: -HANDLE_SIZE / 2, w: edgeWidthLength, h: HANDLE_SIZE, z: 0 },
1439
- 'resizeHandle_B': { x: nodeEntry.model.width / 2, y: -nodeEntry.model.height + HANDLE_SIZE / 2, w: edgeWidthLength, h: HANDLE_SIZE, z: 0 },
1440
- 'resizeHandle_L': { x: HANDLE_SIZE / 2, y: -nodeEntry.model.height / 2, w: HANDLE_SIZE, h: edgeHeightLength, z: 0 },
1441
- 'resizeHandle_R': { x: nodeEntry.model.width - HANDLE_SIZE / 2, y: -nodeEntry.model.height / 2, w: HANDLE_SIZE, h: edgeHeightLength, z: 0 }
1442
- // ▲▲▲ [Fix] ▲▲▲
1443
- };
1444
- Object.entries(handleConfigs).forEach(([name, cfg]) => {
1445
- const handle = group.getObjectByName(name);
1446
- if (handle) {
1447
- handle.position.set(cfg.x, cfg.y, cfg.z);
1448
- const gp = handle.geometry?.parameters;
1449
- if (gp && (gp.width !== cfg.w || gp.height !== cfg.h)) {
1450
- handle.geometry.dispose();
1451
- handle.geometry = new THREE.PlaneGeometry(cfg.w, cfg.h);
1452
- }
1453
- }
1454
- });
1455
- // ▲▲▲ [Changed] ▲▲▲
1456
-
1457
- // ▼▼▼ [New] Update Troika text clipRect on resize for code nodes ▼▼▼
1458
- if (nodeEntry.model.contentType === 'code') {
1459
- const troikaText = group.userData.troikaText;
1460
- if (troikaText && troikaText.userData) {
1461
- const padding = troikaText.userData.padding || 16;
1462
- const filenameHeight = troikaText.userData.filenameHeight || 0; // ★ Include filename height
1463
- const newWidth = nodeEntry.model.width;
1464
- const newHeight = nodeEntry.model.height;
1465
- const newTextWidth = newWidth - padding * 2;
1466
- const newVisibleHeight = newHeight - padding * 2 - filenameHeight; // ★ Subtract filename height
1467
-
1468
- // Update userData dimensions
1469
- troikaText.userData.nodeWidth = newWidth;
1470
- troikaText.userData.nodeHeight = newHeight;
1471
- troikaText.userData.textWidth = newTextWidth;
1472
- troikaText.userData.visibleHeight = newVisibleHeight;
1473
-
1474
- // ★ DO NOT update maxWidth - code nodes should keep maxWidth: Infinity (no word wrap)
1475
- // troikaText.maxWidth = newTextWidth; // REMOVED - this forces word wrap
1476
-
1477
- // Keep existing scroll and recalc maxScroll based on totalHeight
1478
- const totalHeight = troikaText.userData.totalHeight || newVisibleHeight;
1479
- const maxScroll = Math.max(0, totalHeight - newVisibleHeight);
1480
- nodeEntry.model.maxScroll = maxScroll;
1481
-
1482
- // Clamp scroll position
1483
- let scrollY = troikaText.userData.scrollY || 0;
1484
- if (scrollY > maxScroll) {
1485
- scrollY = maxScroll;
1486
- troikaText.userData.scrollY = scrollY;
1487
- }
1488
-
1489
- // Update clipRect - clip to visible area only
1490
- const clipTop = -scrollY;
1491
- const clipBottom = -scrollY - newVisibleHeight;
1492
- troikaText.clipRect = [0, clipBottom, newTextWidth, clipTop];
1493
- troikaText.sync();
1494
-
1495
- // Update scrollbar - show/hide based on whether scrolling is needed
1418
+ }
1419
+
1420
+ // ▼▼▼ [Changed] Update glow position during resize (keep visible, no flickering) ▼▼▼
1421
+ if (ENABLE_WEBGL_GLOW && glowMesh) {
1422
+ // Update position to match bodyMesh
1423
+ glowMesh.position.set(nodeEntry.model.width / 2, -nodeEntry.model.height / 2, 0); // [Fix] Z=0
1424
+ // Don't hide - causes flickering
1425
+ }
1426
+ // ▲▲▲ [Changed] ▲▲▲
1427
+ const tailMesh = group.getObjectByName('tail');
1428
+ if (tailMesh) {
1429
+ tailMesh.position.set(nodeEntry.model.width / 2, -nodeEntry.model.height - 6, 0);
1430
+ }
1431
+ // ▼▼▼ [Changed] Update eight resize handles (4 corners + 4 edges with geometry) ▼▼▼
1432
+ const edgeWidthLength = Math.max(10, nodeEntry.model.width - HANDLE_SIZE * 2);
1433
+ const edgeHeightLength = Math.max(10, nodeEntry.model.height - HANDLE_SIZE * 2);
1434
+ const handleConfigs = {
1435
+ // ▼▼▼ [Fix] Corners Z=0 for parallax elimination ▼▼▼
1436
+ 'resizeHandle_TL': { x: HANDLE_SIZE / 2, y: -HANDLE_SIZE / 2, w: HANDLE_SIZE, h: HANDLE_SIZE, z: 0 },
1437
+ 'resizeHandle_TR': { x: nodeEntry.model.width - HANDLE_SIZE / 2, y: -HANDLE_SIZE / 2, w: HANDLE_SIZE, h: HANDLE_SIZE, z: 0 },
1438
+ 'resizeHandle_BL': { x: HANDLE_SIZE / 2, y: -nodeEntry.model.height + HANDLE_SIZE / 2, w: HANDLE_SIZE, h: HANDLE_SIZE, z: 0 },
1439
+ 'resizeHandle_BR': { x: nodeEntry.model.width - HANDLE_SIZE / 2, y: -nodeEntry.model.height + HANDLE_SIZE / 2, w: HANDLE_SIZE, h: HANDLE_SIZE, z: 0 },
1440
+ // Edges Z=0
1441
+ 'resizeHandle_T': { x: nodeEntry.model.width / 2, y: -HANDLE_SIZE / 2, w: edgeWidthLength, h: HANDLE_SIZE, z: 0 },
1442
+ 'resizeHandle_B': { x: nodeEntry.model.width / 2, y: -nodeEntry.model.height + HANDLE_SIZE / 2, w: edgeWidthLength, h: HANDLE_SIZE, z: 0 },
1443
+ 'resizeHandle_L': { x: HANDLE_SIZE / 2, y: -nodeEntry.model.height / 2, w: HANDLE_SIZE, h: edgeHeightLength, z: 0 },
1444
+ 'resizeHandle_R': { x: nodeEntry.model.width - HANDLE_SIZE / 2, y: -nodeEntry.model.height / 2, w: HANDLE_SIZE, h: edgeHeightLength, z: 0 }
1445
+ // ▲▲▲ [Fix] ▲▲▲
1446
+ };
1447
+ Object.entries(handleConfigs).forEach(([name, cfg]) => {
1448
+ const handle = group.getObjectByName(name);
1449
+ if (handle) {
1450
+ handle.position.set(cfg.x, cfg.y, cfg.z);
1451
+ const gp = handle.geometry?.parameters;
1452
+ if (gp && (gp.width !== cfg.w || gp.height !== cfg.h)) {
1453
+ handle.geometry.dispose();
1454
+ handle.geometry = new THREE.PlaneGeometry(cfg.w, cfg.h);
1455
+ }
1456
+ }
1457
+ });
1458
+ // ▲▲▲ [Changed] ▲▲▲
1459
+
1460
+ // ▼▼▼ [New] Update Troika text clipRect on resize for code nodes ▼▼▼
1461
+ if (nodeEntry.model.contentType === 'code') {
1462
+ const troikaText = group.userData.troikaText;
1463
+ if (troikaText && troikaText.userData) {
1464
+ const padding = troikaText.userData.padding || 16;
1465
+ const filenameHeight = troikaText.userData.filenameHeight || 0; // ★ Include filename height
1466
+ const newWidth = nodeEntry.model.width;
1467
+ const newHeight = nodeEntry.model.height;
1468
+ const newTextWidth = newWidth - padding * 2;
1469
+ const newVisibleHeight = newHeight - padding * 2 - filenameHeight; // ★ Subtract filename height
1470
+
1471
+ // Update userData dimensions
1472
+ troikaText.userData.nodeWidth = newWidth;
1473
+ troikaText.userData.nodeHeight = newHeight;
1474
+ troikaText.userData.textWidth = newTextWidth;
1475
+ troikaText.userData.visibleHeight = newVisibleHeight;
1476
+
1477
+ // ★ DO NOT update maxWidth - code nodes should keep maxWidth: Infinity (no word wrap)
1478
+ // troikaText.maxWidth = newTextWidth; // REMOVED - this forces word wrap
1479
+
1480
+ // Keep existing scroll and recalc maxScroll based on totalHeight
1481
+ const totalHeight = troikaText.userData.totalHeight || newVisibleHeight;
1482
+ const maxScroll = Math.max(0, totalHeight - newVisibleHeight);
1483
+ nodeEntry.model.maxScroll = maxScroll;
1484
+
1485
+ // Clamp scroll position
1486
+ let scrollY = troikaText.userData.scrollY || 0;
1487
+ if (scrollY > maxScroll) {
1488
+ scrollY = maxScroll;
1489
+ troikaText.userData.scrollY = scrollY;
1490
+ }
1491
+
1492
+ // Update clipRect - clip to visible area only
1493
+ const clipTop = -scrollY;
1494
+ const clipBottom = -scrollY - newVisibleHeight;
1495
+ troikaText.clipRect = [0, clipBottom, newTextWidth, clipTop];
1496
+ troikaText.sync();
1497
+
1498
+ // Update scrollbar - show/hide based on whether scrolling is needed
1496
1499
  const scrollbarTrack = group.getObjectByName('scrollbarTrack');
1497
1500
  const scrollbarThumb = group.getObjectByName('scrollbarThumb');
1498
1501
  const isCssMode = nodeEntry.currentType === 'CSS';
@@ -1505,131 +1508,131 @@
1505
1508
  } else {
1506
1509
  scrollbarTrack.visible = true;
1507
1510
  scrollbarThumb.visible = true;
1508
-
1509
- const SCROLLBAR_WIDTH = 6;
1510
- const SCROLLBAR_PADDING = 4;
1511
- const SCROLLBAR_TOP_MARGIN = 16;
1512
- const SCROLLBAR_BOTTOM_MARGIN = 16;
1513
- const SCROLLBAR_RADIUS = 3;
1514
- const trackHeight = newHeight - SCROLLBAR_TOP_MARGIN - SCROLLBAR_BOTTOM_MARGIN - filenameHeight; // ★ Include filenameHeight
1515
- const trackX = newWidth - SCROLLBAR_WIDTH - SCROLLBAR_PADDING;
1516
- const trackY = -SCROLLBAR_TOP_MARGIN - filenameHeight; // ★ Include filenameHeight
1517
-
1518
- // Update track position and geometry
1519
- scrollbarTrack.position.set(trackX, trackY, 3);
1520
- if (scrollbarTrack.geometry) {
1521
- scrollbarTrack.geometry.dispose();
1522
- if (window.MindMapObjectManager?.createRoundedRectFilledGeometry) {
1523
- scrollbarTrack.geometry = window.MindMapObjectManager.createRoundedRectFilledGeometry(SCROLLBAR_WIDTH, trackHeight, SCROLLBAR_RADIUS);
1524
- } else {
1525
- scrollbarTrack.geometry = new THREE.PlaneGeometry(SCROLLBAR_WIDTH, trackHeight);
1526
- }
1527
- }
1528
-
1529
- // Update thumb
1530
- const thumbHeightRatio = Math.min(1, newVisibleHeight / (maxScroll + newVisibleHeight));
1531
- const thumbHeight = Math.max(24, trackHeight * thumbHeightRatio);
1532
- const scrollRatio = scrollY / maxScroll;
1533
- const thumbTravel = trackHeight - thumbHeight;
1534
- const thumbY = trackY - (scrollRatio * thumbTravel); // ★ Use trackY which includes filenameHeight
1535
-
1536
- scrollbarThumb.position.set(trackX, thumbY, 3.1);
1537
- if (scrollbarThumb.geometry) {
1538
- scrollbarThumb.geometry.dispose();
1539
- if (window.MindMapObjectManager?.createRoundedRectFilledGeometry) {
1540
- scrollbarThumb.geometry = window.MindMapObjectManager.createRoundedRectFilledGeometry(SCROLLBAR_WIDTH, thumbHeight, SCROLLBAR_RADIUS);
1541
- } else {
1542
- scrollbarThumb.geometry = new THREE.PlaneGeometry(SCROLLBAR_WIDTH, thumbHeight);
1543
- }
1544
- }
1545
- scrollbarThumb.userData.trackHeight = trackHeight;
1546
- scrollbarThumb.userData.thumbHeight = thumbHeight;
1547
- }
1548
- }
1549
- }
1550
- }
1551
- // ▲▲▲ [New] ▲▲▲
1552
-
1553
- // ▼▼▼ [New] Update glow shader dimensions on resize ▼▼▼
1554
- if (ENABLE_WEBGL_GLOW && glowMesh && window.MindMapGlowShader?.updateGlowSize) {
1555
- window.MindMapGlowShader.updateGlowSize(glowMesh, nodeEntry.model.width, nodeEntry.model.height, nodeEntry.model.contentType);
1556
- }
1557
- // ▲▲▲ [New] ▲▲▲
1558
-
1559
- group.userData.worldWidth = nodeEntry.model.width;
1560
- group.userData.worldHeight = nodeEntry.model.height;
1561
- }
1562
-
1511
+
1512
+ const SCROLLBAR_WIDTH = 6;
1513
+ const SCROLLBAR_PADDING = 4;
1514
+ const SCROLLBAR_TOP_MARGIN = 16;
1515
+ const SCROLLBAR_BOTTOM_MARGIN = 16;
1516
+ const SCROLLBAR_RADIUS = 3;
1517
+ const trackHeight = newHeight - SCROLLBAR_TOP_MARGIN - SCROLLBAR_BOTTOM_MARGIN - filenameHeight; // ★ Include filenameHeight
1518
+ const trackX = newWidth - SCROLLBAR_WIDTH - SCROLLBAR_PADDING;
1519
+ const trackY = -SCROLLBAR_TOP_MARGIN - filenameHeight; // ★ Include filenameHeight
1520
+
1521
+ // Update track position and geometry
1522
+ scrollbarTrack.position.set(trackX, trackY, 3);
1523
+ if (scrollbarTrack.geometry) {
1524
+ scrollbarTrack.geometry.dispose();
1525
+ if (window.MindMapObjectManager?.createRoundedRectFilledGeometry) {
1526
+ scrollbarTrack.geometry = window.MindMapObjectManager.createRoundedRectFilledGeometry(SCROLLBAR_WIDTH, trackHeight, SCROLLBAR_RADIUS);
1527
+ } else {
1528
+ scrollbarTrack.geometry = new THREE.PlaneGeometry(SCROLLBAR_WIDTH, trackHeight);
1529
+ }
1530
+ }
1531
+
1532
+ // Update thumb
1533
+ const thumbHeightRatio = Math.min(1, newVisibleHeight / (maxScroll + newVisibleHeight));
1534
+ const thumbHeight = Math.max(24, trackHeight * thumbHeightRatio);
1535
+ const scrollRatio = scrollY / maxScroll;
1536
+ const thumbTravel = trackHeight - thumbHeight;
1537
+ const thumbY = trackY - (scrollRatio * thumbTravel); // ★ Use trackY which includes filenameHeight
1538
+
1539
+ scrollbarThumb.position.set(trackX, thumbY, 3.1);
1540
+ if (scrollbarThumb.geometry) {
1541
+ scrollbarThumb.geometry.dispose();
1542
+ if (window.MindMapObjectManager?.createRoundedRectFilledGeometry) {
1543
+ scrollbarThumb.geometry = window.MindMapObjectManager.createRoundedRectFilledGeometry(SCROLLBAR_WIDTH, thumbHeight, SCROLLBAR_RADIUS);
1544
+ } else {
1545
+ scrollbarThumb.geometry = new THREE.PlaneGeometry(SCROLLBAR_WIDTH, thumbHeight);
1546
+ }
1547
+ }
1548
+ scrollbarThumb.userData.trackHeight = trackHeight;
1549
+ scrollbarThumb.userData.thumbHeight = thumbHeight;
1550
+ }
1551
+ }
1552
+ }
1553
+ }
1554
+ // ▲▲▲ [New] ▲▲▲
1555
+
1556
+ // ▼▼▼ [New] Update glow shader dimensions on resize ▼▼▼
1557
+ if (ENABLE_WEBGL_GLOW && glowMesh && window.MindMapGlowShader?.updateGlowSize) {
1558
+ window.MindMapGlowShader.updateGlowSize(glowMesh, nodeEntry.model.width, nodeEntry.model.height, nodeEntry.model.contentType);
1559
+ }
1560
+ // ▲▲▲ [New] ▲▲▲
1561
+
1562
+ group.userData.worldWidth = nodeEntry.model.width;
1563
+ group.userData.worldHeight = nodeEntry.model.height;
1564
+ }
1565
+
1563
1566
  // ▼▼▼ [Fix] LOD 모드에서 리사이즈 시 InstancedMesh 실시간 업데이트 ▼▼▼
1564
1567
  if (module.lodRenderer?.isInLODMode && module.lodRenderer?.requestResidentPatch) {
1565
1568
  module.lodRenderer.requestResidentPatch('update-node', nodeId);
1566
1569
  }
1567
- // ▲▲▲ [Fix] ▲▲▲
1568
- }
1569
-
1570
- function debouncedProcessRenderQueue(module, nodeId, updateData) {
1571
- if (debounceTimers.has(nodeId)) {
1572
- clearTimeout(debounceTimers.get(nodeId));
1573
- }
1574
- window.MindMapNodes.pendingNodeUpdates.set(nodeId, updateData);
1575
- const timer = setTimeout(() => {
1576
- enqueueRender(module, nodeId);
1577
- debounceTimers.delete(nodeId);
1578
- }, 100);
1579
- debounceTimers.set(nodeId, timer);
1580
- }
1581
-
1582
- function throttledProcessRenderQueueDuringResize(module, nodeId, updateData) {
1583
- const now = performance.now();
1584
- const lastCallTime = lastResizeUpdateCall.get(nodeId) || 0;
1585
-
1586
- if (now - lastCallTime < THROTTLE_INTERVAL) {
1587
- window.MindMapNodes.pendingNodeUpdates.set(nodeId, updateData);
1588
- if (resizeThrottleTimers.has(nodeId)) return;
1589
- const timer = setTimeout(() => {
1590
- const queuedUpdateData = window.MindMapNodes.pendingNodeUpdates.get(nodeId);
1591
- if (queuedUpdateData) {
1592
- enqueueRender(module, nodeId);
1593
- }
1594
- resizeThrottleTimers.delete(nodeId);
1595
- }, THROTTLE_INTERVAL);
1596
- resizeThrottleTimers.set(nodeId, timer);
1597
- return;
1598
- }
1599
-
1600
- lastResizeUpdateCall.set(nodeId, now);
1601
- window.MindMapNodes.pendingNodeUpdates.set(nodeId, updateData);
1602
- enqueueRender(module, nodeId);
1603
-
1604
- // ▼▼▼ [Fix] Maintain appropriate resize cursor during doResizing ▼▼▼
1605
- let cursor = 'nwse-resize';
1606
- switch (corner) {
1607
- case 'TL': case 'BR': cursor = 'nwse-resize'; break;
1608
- case 'TR': case 'BL': cursor = 'nesw-resize'; break;
1609
- case 'T': case 'B': cursor = 'ns-resize'; break;
1610
- case 'L': case 'R': cursor = 'ew-resize'; break;
1611
- }
1612
- document.body.style.cursor = cursor;
1613
- // ▲▲▲ [Fix] ▲▲▲
1614
- }
1615
-
1616
- function throttledScrollUpdate(module, nodeId, scrollDelta) {
1617
- const now = performance.now();
1618
- const lastCallTime = lastScrollCall.get(nodeId) || 0;
1619
-
1620
- if (now - lastCallTime < THROTTLE_INTERVAL) {
1621
- return;
1622
- }
1623
- lastScrollCall.set(nodeId, now);
1624
-
1625
- const nodeEntry = module.nodeObjectsById.get(nodeId);
1626
- if (!nodeEntry) return;
1627
-
1628
- const model = nodeEntry.model;
1629
- const oldOffset = model.scrollOffset || 0;
1630
- const maxScroll = model.maxScroll || 0;
1631
- const newOffset = Math.max(0, Math.min(oldOffset + scrollDelta, maxScroll));
1632
-
1570
+ // ▲▲▲ [Fix] ▲▲▲
1571
+ }
1572
+
1573
+ function debouncedProcessRenderQueue(module, nodeId, updateData) {
1574
+ if (debounceTimers.has(nodeId)) {
1575
+ clearTimeout(debounceTimers.get(nodeId));
1576
+ }
1577
+ window.MindMapNodes.pendingNodeUpdates.set(nodeId, updateData);
1578
+ const timer = setTimeout(() => {
1579
+ enqueueRender(module, nodeId);
1580
+ debounceTimers.delete(nodeId);
1581
+ }, 100);
1582
+ debounceTimers.set(nodeId, timer);
1583
+ }
1584
+
1585
+ function throttledProcessRenderQueueDuringResize(module, nodeId, updateData) {
1586
+ const now = performance.now();
1587
+ const lastCallTime = lastResizeUpdateCall.get(nodeId) || 0;
1588
+
1589
+ if (now - lastCallTime < THROTTLE_INTERVAL) {
1590
+ window.MindMapNodes.pendingNodeUpdates.set(nodeId, updateData);
1591
+ if (resizeThrottleTimers.has(nodeId)) return;
1592
+ const timer = setTimeout(() => {
1593
+ const queuedUpdateData = window.MindMapNodes.pendingNodeUpdates.get(nodeId);
1594
+ if (queuedUpdateData) {
1595
+ enqueueRender(module, nodeId);
1596
+ }
1597
+ resizeThrottleTimers.delete(nodeId);
1598
+ }, THROTTLE_INTERVAL);
1599
+ resizeThrottleTimers.set(nodeId, timer);
1600
+ return;
1601
+ }
1602
+
1603
+ lastResizeUpdateCall.set(nodeId, now);
1604
+ window.MindMapNodes.pendingNodeUpdates.set(nodeId, updateData);
1605
+ enqueueRender(module, nodeId);
1606
+
1607
+ // ▼▼▼ [Fix] Maintain appropriate resize cursor during doResizing ▼▼▼
1608
+ let cursor = 'nwse-resize';
1609
+ switch (corner) {
1610
+ case 'TL': case 'BR': cursor = 'nwse-resize'; break;
1611
+ case 'TR': case 'BL': cursor = 'nesw-resize'; break;
1612
+ case 'T': case 'B': cursor = 'ns-resize'; break;
1613
+ case 'L': case 'R': cursor = 'ew-resize'; break;
1614
+ }
1615
+ document.body.style.cursor = cursor;
1616
+ // ▲▲▲ [Fix] ▲▲▲
1617
+ }
1618
+
1619
+ function throttledScrollUpdate(module, nodeId, scrollDelta) {
1620
+ const now = performance.now();
1621
+ const lastCallTime = lastScrollCall.get(nodeId) || 0;
1622
+
1623
+ if (now - lastCallTime < THROTTLE_INTERVAL) {
1624
+ return;
1625
+ }
1626
+ lastScrollCall.set(nodeId, now);
1627
+
1628
+ const nodeEntry = module.nodeObjectsById.get(nodeId);
1629
+ if (!nodeEntry) return;
1630
+
1631
+ const model = nodeEntry.model;
1632
+ const oldOffset = model.scrollOffset || 0;
1633
+ const maxScroll = model.maxScroll || 0;
1634
+ const newOffset = Math.max(0, Math.min(oldOffset + scrollDelta, maxScroll));
1635
+
1633
1636
  if (Math.abs(newOffset - oldOffset) < 0.1) return;
1634
1637
 
1635
1638
  model.scrollOffset = newOffset;
@@ -1637,213 +1640,213 @@
1637
1640
 
1638
1641
  // ▼▼▼ [New] Code nodes: update Troika text via scrollCodeText ▼▼▼
1639
1642
  if (model.contentType === 'code' && nodeEntry.glObject) {
1640
- const troikaText = nodeEntry.glObject.userData.troikaText;
1641
- if (troikaText && window.MindMapTroikaText?.scrollCodeText) {
1642
- // Use scrollCodeText which handles both position and clipRect
1643
- const scrollResult = window.MindMapTroikaText.scrollCodeText(troikaText, scrollDelta);
1644
- if (scrollResult) {
1645
- model.scrollOffset = scrollResult.scrollY;
1646
-
1647
- // ★ Update scrollbar thumb position (ShapeGeometry anchor is top-left, not center)
1648
- const scrollbarThumb = nodeEntry.glObject.getObjectByName('scrollbarThumb');
1649
- if (scrollbarThumb && scrollResult.maxScroll > 0) {
1650
- const thumbData = scrollbarThumb.userData;
1651
- const thumbTravel = thumbData.trackHeight - thumbData.thumbHeight;
1652
- const thumbY = -thumbData.topMargin - (scrollResult.scrollRatio * thumbTravel);
1653
- scrollbarThumb.position.y = thumbY;
1654
- }
1655
- }
1656
-
1657
- // Persist scroll position to C#
1658
- throttledUpdateScrollInCSharp(module, nodeId, model.scrollOffset);
1659
- return;
1660
- }
1661
- }
1662
- // ▲▲▲ [New] ▲▲▲
1663
-
1664
- // ▼▼▼ [Refactor] Fetch content directly from C# (no cache) ▼▼▼
1665
- if (model.isChunkedText && module.dotNetHelper) {
1666
- // Cancel the previous request (process only the latest)
1667
- if (model._scrollFetchController) {
1668
- model._scrollFetchController.abort = true;
1669
- }
1670
- const controller = { abort: false };
1671
- model._scrollFetchController = controller;
1672
-
1673
- // Debounce: render after scrolling stops
1674
- if (model._scrollDebounceTimer) {
1675
- clearTimeout(model._scrollDebounceTimer);
1676
- }
1677
- model._scrollDebounceTimer = setTimeout(async () => {
1678
- if (controller.abort) return;
1679
-
1680
- try {
1681
- const result = await module.dotNetHelper.invokeMethodAsync(
1682
- 'GetTextByScrollPosition',
1683
- nodeId,
1684
- model.scrollOffset,
1685
- model.height || 400,
1686
- CHUNK_LINE_HEIGHT
1687
- );
1688
-
1689
- if (controller.abort || !result) return;
1690
-
1691
- // Set content used for rendering
1692
- model._visibleContent = result.content;
1693
- model._visibleScrollOffset = result.relativeScrollOffset;
1694
-
1695
- window.MindMapNodes.pendingNodeUpdates.set(nodeId, {
1696
- content: result.content,
1697
- width: model.width,
1698
- height: model.height,
1699
- scrollDelta: 0
1700
- });
1701
- enqueueRender(module, nodeId);
1702
- } catch (e) {
1703
- console.error(`[Pipeline] GetTextByScrollPosition error:`, e);
1704
- }
1705
- }, 16); // 16ms - 60fps throttle
1706
-
1707
- // Persist scroll position to C#
1708
- throttledUpdateScrollInCSharp(module, nodeId, model.scrollOffset);
1709
- return;
1710
- }
1711
- // ▲▲▲ [Refactor] ▲▲▲
1712
-
1713
- // Regular nodes (non-chunked)
1714
- const updateData = {
1715
- content: model.response,
1716
- width: model.width,
1717
- height: model.height,
1718
- scrollDelta: 0
1719
- };
1720
- window.MindMapNodes.pendingNodeUpdates.set(nodeId, updateData);
1721
- enqueueRender(module, nodeId);
1722
-
1723
- // ▼▼▼ [New] Persist scroll position for regular nodes too ▼▼▼
1724
- throttledUpdateScrollInCSharp(module, nodeId, model.scrollOffset);
1725
- // ▲▲▲ [New] ▲▲▲
1726
- }
1727
-
1728
- // ▼▼▼ [New] Set absolute scroll position (for scrollbar drag) ▼▼▼
1729
- function setScrollPosition(module, nodeId, absoluteScrollPosition) {
1730
- const now = performance.now();
1731
- const lastCallTime = lastScrollCall.get(nodeId) || 0;
1732
-
1733
- // Throttling (ignore calls that arrive too quickly)
1734
- if (now - lastCallTime < THROTTLE_INTERVAL) {
1735
- return;
1736
- }
1737
- lastScrollCall.set(nodeId, now);
1738
-
1739
- const nodeEntry = module.nodeObjectsById.get(nodeId);
1740
- if (!nodeEntry) return;
1741
-
1742
- const model = nodeEntry.model;
1743
- const oldOffset = model.scrollOffset || 0;
1744
- const maxScroll = model.maxScroll || 0;
1745
- const newOffset = Math.max(0, Math.min(absoluteScrollPosition, maxScroll));
1746
-
1643
+ const troikaText = nodeEntry.glObject.userData.troikaText;
1644
+ if (troikaText && window.MindMapTroikaText?.scrollCodeText) {
1645
+ // Use scrollCodeText which handles both position and clipRect
1646
+ const scrollResult = window.MindMapTroikaText.scrollCodeText(troikaText, scrollDelta);
1647
+ if (scrollResult) {
1648
+ model.scrollOffset = scrollResult.scrollY;
1649
+
1650
+ // ★ Update scrollbar thumb position (ShapeGeometry anchor is top-left, not center)
1651
+ const scrollbarThumb = nodeEntry.glObject.getObjectByName('scrollbarThumb');
1652
+ if (scrollbarThumb && scrollResult.maxScroll > 0) {
1653
+ const thumbData = scrollbarThumb.userData;
1654
+ const thumbTravel = thumbData.trackHeight - thumbData.thumbHeight;
1655
+ const thumbY = -thumbData.topMargin - (scrollResult.scrollRatio * thumbTravel);
1656
+ scrollbarThumb.position.y = thumbY;
1657
+ }
1658
+ }
1659
+
1660
+ // Persist scroll position to C#
1661
+ throttledUpdateScrollInCSharp(module, nodeId, model.scrollOffset);
1662
+ return;
1663
+ }
1664
+ }
1665
+ // ▲▲▲ [New] ▲▲▲
1666
+
1667
+ // ▼▼▼ [Refactor] Fetch content directly from C# (no cache) ▼▼▼
1668
+ if (model.isChunkedText && module.dotNetHelper) {
1669
+ // Cancel the previous request (process only the latest)
1670
+ if (model._scrollFetchController) {
1671
+ model._scrollFetchController.abort = true;
1672
+ }
1673
+ const controller = { abort: false };
1674
+ model._scrollFetchController = controller;
1675
+
1676
+ // Debounce: render after scrolling stops
1677
+ if (model._scrollDebounceTimer) {
1678
+ clearTimeout(model._scrollDebounceTimer);
1679
+ }
1680
+ model._scrollDebounceTimer = setTimeout(async () => {
1681
+ if (controller.abort) return;
1682
+
1683
+ try {
1684
+ const result = await module.dotNetHelper.invokeMethodAsync(
1685
+ 'GetTextByScrollPosition',
1686
+ nodeId,
1687
+ model.scrollOffset,
1688
+ model.height || 400,
1689
+ CHUNK_LINE_HEIGHT
1690
+ );
1691
+
1692
+ if (controller.abort || !result) return;
1693
+
1694
+ // Set content used for rendering
1695
+ model._visibleContent = result.content;
1696
+ model._visibleScrollOffset = result.relativeScrollOffset;
1697
+
1698
+ window.MindMapNodes.pendingNodeUpdates.set(nodeId, {
1699
+ content: result.content,
1700
+ width: model.width,
1701
+ height: model.height,
1702
+ scrollDelta: 0
1703
+ });
1704
+ enqueueRender(module, nodeId);
1705
+ } catch (e) {
1706
+ console.error(`[Pipeline] GetTextByScrollPosition error:`, e);
1707
+ }
1708
+ }, 16); // 16ms - 60fps throttle
1709
+
1710
+ // Persist scroll position to C#
1711
+ throttledUpdateScrollInCSharp(module, nodeId, model.scrollOffset);
1712
+ return;
1713
+ }
1714
+ // ▲▲▲ [Refactor] ▲▲▲
1715
+
1716
+ // Regular nodes (non-chunked)
1717
+ const updateData = {
1718
+ content: model.response,
1719
+ width: model.width,
1720
+ height: model.height,
1721
+ scrollDelta: 0
1722
+ };
1723
+ window.MindMapNodes.pendingNodeUpdates.set(nodeId, updateData);
1724
+ enqueueRender(module, nodeId);
1725
+
1726
+ // ▼▼▼ [New] Persist scroll position for regular nodes too ▼▼▼
1727
+ throttledUpdateScrollInCSharp(module, nodeId, model.scrollOffset);
1728
+ // ▲▲▲ [New] ▲▲▲
1729
+ }
1730
+
1731
+ // ▼▼▼ [New] Set absolute scroll position (for scrollbar drag) ▼▼▼
1732
+ function setScrollPosition(module, nodeId, absoluteScrollPosition) {
1733
+ const now = performance.now();
1734
+ const lastCallTime = lastScrollCall.get(nodeId) || 0;
1735
+
1736
+ // Throttling (ignore calls that arrive too quickly)
1737
+ if (now - lastCallTime < THROTTLE_INTERVAL) {
1738
+ return;
1739
+ }
1740
+ lastScrollCall.set(nodeId, now);
1741
+
1742
+ const nodeEntry = module.nodeObjectsById.get(nodeId);
1743
+ if (!nodeEntry) return;
1744
+
1745
+ const model = nodeEntry.model;
1746
+ const oldOffset = model.scrollOffset || 0;
1747
+ const maxScroll = model.maxScroll || 0;
1748
+ const newOffset = Math.max(0, Math.min(absoluteScrollPosition, maxScroll));
1749
+
1747
1750
  if (Math.abs(newOffset - oldOffset) < 0.1) return;
1748
1751
 
1749
1752
  model.scrollOffset = newOffset;
1750
1753
  window.MindMapCss3DManager?.syncCss3dScrollFromModel?.(module, nodeId);
1751
1754
 
1752
1755
  // ▼▼▼ [Refactor] Fetch content directly from C# (no cache) ▼▼▼
1753
- if (model.isChunkedText && module.dotNetHelper) {
1754
- // Cancel the previous request (process only the latest)
1755
- if (model._scrollFetchController) {
1756
- model._scrollFetchController.abort = true;
1757
- }
1758
- const controller = { abort: false };
1759
- model._scrollFetchController = controller;
1760
-
1761
- // Throttle: update every 16ms even during fast scrolling (~60fps)
1762
- if (model._scrollDebounceTimer) {
1763
- clearTimeout(model._scrollDebounceTimer);
1764
- }
1765
- model._scrollDebounceTimer = setTimeout(async () => {
1766
- if (controller.abort) return;
1767
-
1768
- try {
1769
- const result = await module.dotNetHelper.invokeMethodAsync(
1770
- 'GetTextByScrollPosition',
1771
- nodeId,
1772
- model.scrollOffset,
1773
- model.height || 400,
1774
- CHUNK_LINE_HEIGHT
1775
- );
1776
-
1777
- if (controller.abort || !result) return;
1778
-
1779
- // Set content used for rendering
1780
- model._visibleContent = result.content;
1781
- model._visibleScrollOffset = result.relativeScrollOffset;
1782
-
1783
- window.MindMapNodes.pendingNodeUpdates.set(nodeId, {
1784
- content: result.content,
1785
- width: model.width,
1786
- height: model.height,
1787
- scrollDelta: 0
1788
- });
1789
- enqueueRender(module, nodeId);
1790
- } catch (e) {
1791
- console.error(`[Pipeline] GetTextByScrollPosition error:`, e);
1792
- }
1793
- }, 16); // 16ms - 60fps throttle
1794
-
1795
- // Persist scroll position to C#
1796
- throttledUpdateScrollInCSharp(module, nodeId, model.scrollOffset);
1797
- return;
1798
- }
1799
- // ▲▲▲ [Refactor] ▲▲▲
1800
-
1801
- // Regular nodes (non-chunked)
1802
- const updateData = {
1803
- content: model.response,
1804
- width: model.width,
1805
- height: model.height,
1806
- scrollDelta: 0
1807
- };
1808
- window.MindMapNodes.pendingNodeUpdates.set(nodeId, updateData);
1809
- enqueueRender(module, nodeId);
1810
-
1811
- // ▼▼▼ [New] Persist scroll position for regular nodes too ▼▼▼
1812
- throttledUpdateScrollInCSharp(module, nodeId, model.scrollOffset);
1813
- // ▲▲▲ [New] ▲▲▲
1814
- }
1815
- // ▲▲▲ [New] Set absolute scroll position (for scrollbar drag) ▲▲▲
1816
-
1817
- function throttledUpdateScrollInCSharp(module, nodeId, scrollOffset) {
1818
- if (scrollUpdateThrottleTimers.has(nodeId)) {
1819
- clearTimeout(scrollUpdateThrottleTimers.get(nodeId));
1820
- }
1821
- const timer = setTimeout(() => {
1822
- try {
1823
- module.dotNetHelper?.invokeMethodAsync('UpdateNodeScroll', nodeId, scrollOffset);
1824
- } catch (e) {
1825
- console.error(`[Pipeline] Failed to update scroll in C# for node ${nodeId}:`, e);
1826
- }
1827
- scrollUpdateThrottleTimers.delete(nodeId);
1828
- }, 100);
1829
- scrollUpdateThrottleTimers.set(nodeId, timer);
1830
- }
1831
-
1832
- async function stopResizing(module) {
1833
- if (!module.isResizing || !module.resizeNodeObject) return;
1834
-
1835
- // ▼▼▼ [Changed] Use stored nodeId (CSS3D mode compatible) ▼▼▼
1836
- const nodeId = module.resizingNodeId || (module.resizeNodeObject.userData?.nodeId || module.resizeNodeObject.element?.dataset?.nodeId);
1837
- const nodeEntry = module.nodeObjectsById.get(nodeId);
1838
-
1839
- if (nodeEntry && module.dotNetHelper) {
1840
- const currentPosX = nodeEntry.glObject?.position?.x ?? nodeEntry.cssObject?.position?.x ?? 0;
1841
- const currentPosY = nodeEntry.glObject?.position?.y ?? nodeEntry.cssObject?.position?.y ?? 0;
1842
- let finalPosX = currentPosX;
1843
- let finalPosY = currentPosY;
1844
-
1845
- // Respect global snap toggle. Resize itself stays free-form while dragging;
1846
- // snapping is applied only at the end when enabled.
1756
+ if (model.isChunkedText && module.dotNetHelper) {
1757
+ // Cancel the previous request (process only the latest)
1758
+ if (model._scrollFetchController) {
1759
+ model._scrollFetchController.abort = true;
1760
+ }
1761
+ const controller = { abort: false };
1762
+ model._scrollFetchController = controller;
1763
+
1764
+ // Throttle: update every 16ms even during fast scrolling (~60fps)
1765
+ if (model._scrollDebounceTimer) {
1766
+ clearTimeout(model._scrollDebounceTimer);
1767
+ }
1768
+ model._scrollDebounceTimer = setTimeout(async () => {
1769
+ if (controller.abort) return;
1770
+
1771
+ try {
1772
+ const result = await module.dotNetHelper.invokeMethodAsync(
1773
+ 'GetTextByScrollPosition',
1774
+ nodeId,
1775
+ model.scrollOffset,
1776
+ model.height || 400,
1777
+ CHUNK_LINE_HEIGHT
1778
+ );
1779
+
1780
+ if (controller.abort || !result) return;
1781
+
1782
+ // Set content used for rendering
1783
+ model._visibleContent = result.content;
1784
+ model._visibleScrollOffset = result.relativeScrollOffset;
1785
+
1786
+ window.MindMapNodes.pendingNodeUpdates.set(nodeId, {
1787
+ content: result.content,
1788
+ width: model.width,
1789
+ height: model.height,
1790
+ scrollDelta: 0
1791
+ });
1792
+ enqueueRender(module, nodeId);
1793
+ } catch (e) {
1794
+ console.error(`[Pipeline] GetTextByScrollPosition error:`, e);
1795
+ }
1796
+ }, 16); // 16ms - 60fps throttle
1797
+
1798
+ // Persist scroll position to C#
1799
+ throttledUpdateScrollInCSharp(module, nodeId, model.scrollOffset);
1800
+ return;
1801
+ }
1802
+ // ▲▲▲ [Refactor] ▲▲▲
1803
+
1804
+ // Regular nodes (non-chunked)
1805
+ const updateData = {
1806
+ content: model.response,
1807
+ width: model.width,
1808
+ height: model.height,
1809
+ scrollDelta: 0
1810
+ };
1811
+ window.MindMapNodes.pendingNodeUpdates.set(nodeId, updateData);
1812
+ enqueueRender(module, nodeId);
1813
+
1814
+ // ▼▼▼ [New] Persist scroll position for regular nodes too ▼▼▼
1815
+ throttledUpdateScrollInCSharp(module, nodeId, model.scrollOffset);
1816
+ // ▲▲▲ [New] ▲▲▲
1817
+ }
1818
+ // ▲▲▲ [New] Set absolute scroll position (for scrollbar drag) ▲▲▲
1819
+
1820
+ function throttledUpdateScrollInCSharp(module, nodeId, scrollOffset) {
1821
+ if (scrollUpdateThrottleTimers.has(nodeId)) {
1822
+ clearTimeout(scrollUpdateThrottleTimers.get(nodeId));
1823
+ }
1824
+ const timer = setTimeout(() => {
1825
+ try {
1826
+ module.dotNetHelper?.invokeMethodAsync('UpdateNodeScroll', nodeId, scrollOffset);
1827
+ } catch (e) {
1828
+ console.error(`[Pipeline] Failed to update scroll in C# for node ${nodeId}:`, e);
1829
+ }
1830
+ scrollUpdateThrottleTimers.delete(nodeId);
1831
+ }, 100);
1832
+ scrollUpdateThrottleTimers.set(nodeId, timer);
1833
+ }
1834
+
1835
+ async function stopResizing(module) {
1836
+ if (!module.isResizing || !module.resizeNodeObject) return;
1837
+
1838
+ // ▼▼▼ [Changed] Use stored nodeId (CSS3D mode compatible) ▼▼▼
1839
+ const nodeId = module.resizingNodeId || (module.resizeNodeObject.userData?.nodeId || module.resizeNodeObject.element?.dataset?.nodeId);
1840
+ const nodeEntry = module.nodeObjectsById.get(nodeId);
1841
+
1842
+ if (nodeEntry && module.dotNetHelper) {
1843
+ const currentPosX = nodeEntry.glObject?.position?.x ?? nodeEntry.cssObject?.position?.x ?? 0;
1844
+ const currentPosY = nodeEntry.glObject?.position?.y ?? nodeEntry.cssObject?.position?.y ?? 0;
1845
+ let finalPosX = currentPosX;
1846
+ let finalPosY = currentPosY;
1847
+
1848
+ // Respect global snap toggle. Resize itself stays free-form while dragging;
1849
+ // snapping is applied only at the end when enabled.
1847
1850
  if (module.gridSnapEnabled !== false) {
1848
1851
  const GRID_SIZE = module.GRID_SIZE || 10;
1849
1852
  nodeEntry.model.width = Math.round(nodeEntry.model.width / GRID_SIZE) * GRID_SIZE;
@@ -1857,20 +1860,20 @@
1857
1860
  nodeEntry.model.height);
1858
1861
  nodeEntry.model.width = finalClampedSize.width;
1859
1862
  nodeEntry.model.height = finalClampedSize.height;
1860
-
1861
- if (nodeEntry.glObject) {
1862
- nodeEntry.glObject.position.x = finalPosX;
1863
- nodeEntry.glObject.position.y = finalPosY;
1864
- }
1865
- if (nodeEntry.cssObject) {
1866
- nodeEntry.cssObject.position.x = finalPosX;
1867
- nodeEntry.cssObject.position.y = finalPosY;
1868
- nodeEntry.cssObject.updateMatrixWorld(true);
1869
-
1870
- // ▼▼▼ [Fix] 그리드 스냅 후 CSS3D DOM 크기도 업데이트 ▼▼▼
1871
- const wrapper = nodeEntry.cssObject.element;
1872
- if (wrapper) {
1873
- const resolutionScale = nodeEntry.cssObject.userData?.resolutionScale || 1;
1863
+
1864
+ if (nodeEntry.glObject) {
1865
+ nodeEntry.glObject.position.x = finalPosX;
1866
+ nodeEntry.glObject.position.y = finalPosY;
1867
+ }
1868
+ if (nodeEntry.cssObject) {
1869
+ nodeEntry.cssObject.position.x = finalPosX;
1870
+ nodeEntry.cssObject.position.y = finalPosY;
1871
+ nodeEntry.cssObject.updateMatrixWorld(true);
1872
+
1873
+ // ▼▼▼ [Fix] 그리드 스냅 후 CSS3D DOM 크기도 업데이트 ▼▼▼
1874
+ const wrapper = nodeEntry.cssObject.element;
1875
+ if (wrapper) {
1876
+ const resolutionScale = nodeEntry.cssObject.userData?.resolutionScale || 1;
1874
1877
  const domWidth = nodeEntry.model.width * resolutionScale;
1875
1878
  const domHeight = nodeEntry.model.height * resolutionScale;
1876
1879
 
@@ -1892,25 +1895,25 @@
1892
1895
 
1893
1896
  // ▼▼▼ [Fix] 이미지 노드의 경우 내부 img 요소 크기도 업데이트 ▼▼▼
1894
1897
  if (nodeEntry.model.contentType === 'image') {
1895
- const imgEl = innerContent.querySelector('img');
1896
- if (imgEl) {
1897
- imgEl.style.width = '100%';
1898
- imgEl.style.height = '100%';
1899
- }
1900
- const contentWrapper = innerContent.querySelector('.node-content-wrapper');
1901
- if (contentWrapper) {
1902
- contentWrapper.style.width = '100%';
1903
- contentWrapper.style.height = '100%';
1904
- }
1905
- const responseDiv = innerContent.querySelector('.node-response');
1906
- if (responseDiv) {
1907
- responseDiv.style.width = '100%';
1908
- responseDiv.style.height = '100%';
1909
- }
1910
- }
1911
- // ▲▲▲ [Fix] ▲▲▲
1912
- }
1913
-
1898
+ const imgEl = innerContent.querySelector('img');
1899
+ if (imgEl) {
1900
+ imgEl.style.width = '100%';
1901
+ imgEl.style.height = '100%';
1902
+ }
1903
+ const contentWrapper = innerContent.querySelector('.node-content-wrapper');
1904
+ if (contentWrapper) {
1905
+ contentWrapper.style.width = '100%';
1906
+ contentWrapper.style.height = '100%';
1907
+ }
1908
+ const responseDiv = innerContent.querySelector('.node-response');
1909
+ if (responseDiv) {
1910
+ responseDiv.style.width = '100%';
1911
+ responseDiv.style.height = '100%';
1912
+ }
1913
+ }
1914
+ // ▲▲▲ [Fix] ▲▲▲
1915
+ }
1916
+
1914
1917
  // userData 업데이트
1915
1918
  nodeEntry.cssObject.userData.worldWidth = nodeEntry.model.width;
1916
1919
  nodeEntry.cssObject.userData.worldHeight = nodeEntry.model.height;
@@ -1918,21 +1921,21 @@
1918
1921
  }
1919
1922
  // ▲▲▲ [Fix] ▲▲▲
1920
1923
  }
1921
- try {
1922
- await module.dotNetHelper.invokeMethodAsync(
1923
- 'UpdateNodeDimensions',
1924
- nodeId,
1925
- Math.round(nodeEntry.model.width),
1926
- Math.round(nodeEntry.model.height)
1927
- );
1928
- // ▼▼▼ [Changed] Also update position in C# ▼▼▼
1929
- await module.dotNetHelper.invokeMethodAsync(
1930
- 'UpdateNodePosition',
1931
- nodeId,
1932
- Math.round(finalPosX),
1933
- Math.round(finalPosY)
1934
- );
1935
- // ▲▲▲ [Changed] ▲▲▲
1924
+ try {
1925
+ await module.dotNetHelper.invokeMethodAsync(
1926
+ 'UpdateNodeDimensions',
1927
+ nodeId,
1928
+ Math.round(nodeEntry.model.width),
1929
+ Math.round(nodeEntry.model.height)
1930
+ );
1931
+ // ▼▼▼ [Changed] Also update position in C# ▼▼▼
1932
+ await module.dotNetHelper.invokeMethodAsync(
1933
+ 'UpdateNodePosition',
1934
+ nodeId,
1935
+ Math.round(finalPosX),
1936
+ Math.round(finalPosY)
1937
+ );
1938
+ // ▲▲▲ [Changed] ▲▲▲
1936
1939
  } catch (e) {
1937
1940
  console.error('[MindMapPipeline] Error updating dimensions/position:', e);
1938
1941
  }
@@ -1947,18 +1950,18 @@
1947
1950
 
1948
1951
  // ▼▼▼ [UndoRedo] Record resize command after dimensions/position are persisted ▼▼▼
1949
1952
  try { await module.dotNetHelper.invokeMethodAsync('EndResizeWithUndo'); } catch { }
1950
- // ▲▲▲ [UndoRedo] ▲▲▲
1951
-
1952
- if (nodeEntry.glObject) {
1953
- const outline = nodeEntry.glObject.getObjectByName('outline');
1954
- let glowMesh = nodeEntry.glObject.getObjectByName('glow');
1955
- if (!ENABLE_WEBGL_GLOW) {
1956
- glowMesh = removeGlowMesh(nodeEntry.glObject);
1957
- }
1958
- const bodyMesh = nodeEntry.glObject.getObjectByName('body');
1959
- const isSelected = module.multiSelectedNodeIds.has(nodeId) || module.selectedNodeIdJs === nodeId;
1960
-
1961
- // ▼▼▼ [Fix] 그리드 스냅 후 body mesh 크기/위치 업데이트 ▼▼▼
1953
+ // ▲▲▲ [UndoRedo] ▲▲▲
1954
+
1955
+ if (nodeEntry.glObject) {
1956
+ const outline = nodeEntry.glObject.getObjectByName('outline');
1957
+ let glowMesh = nodeEntry.glObject.getObjectByName('glow');
1958
+ if (!ENABLE_WEBGL_GLOW) {
1959
+ glowMesh = removeGlowMesh(nodeEntry.glObject);
1960
+ }
1961
+ const bodyMesh = nodeEntry.glObject.getObjectByName('body');
1962
+ const isSelected = module.multiSelectedNodeIds.has(nodeId) || module.selectedNodeIdJs === nodeId;
1963
+
1964
+ // ▼▼▼ [Fix] 그리드 스냅 후 body mesh 크기/위치 업데이트 ▼▼▼
1962
1965
  if (bodyMesh) {
1963
1966
  bodyMesh.geometry.dispose();
1964
1967
  bodyMesh.geometry = new THREE.PlaneGeometry(nodeEntry.model.width, nodeEntry.model.height);
@@ -1970,10 +1973,10 @@
1970
1973
  if (nodeEntry.currentResizeType === 'CSS') {
1971
1974
  bodyMesh.visible = false;
1972
1975
  }
1973
- // ▲▲▲ [Fix] ▲▲▲
1974
- }
1975
- // ▲▲▲ [Fix] ▲▲▲
1976
-
1976
+ // ▲▲▲ [Fix] ▲▲▲
1977
+ }
1978
+ // ▲▲▲ [Fix] ▲▲▲
1979
+
1977
1980
  if (nodeEntry.model.contentType === 'image') {
1978
1981
  const usesCssViewAfterResize =
1979
1982
  nodeEntry.currentResizeType === 'CSS' ||
@@ -1999,29 +2002,29 @@
1999
2002
  outline.material.color.setHex(isSelected ? 0x7b7ff2 : 0x374151);
2000
2003
  outline.visible = false; // Disabled - using SDF glow instead
2001
2004
  }
2002
-
2003
- // ▼▼▼ [Changed] After resizing, update glow using SDF shader ▼▼▼
2004
- if (ENABLE_WEBGL_GLOW && glowMesh && window.MindMapGlowShader?.updateGlowSize) {
2005
- const contentType = nodeEntry.model.contentType || 'text';
2006
- window.MindMapGlowShader.updateGlowSize(glowMesh, nodeEntry.model.width, nodeEntry.model.height, contentType);
2007
- glowMesh.position.set(
2008
- nodeEntry.model.width / 2,
2009
- -nodeEntry.model.height / 2,
2010
- 0
2011
- );
2012
- // ★ 카메라 거리에 따라 가시성 설정
2013
- const cameraZ = module.camera?.position?.z ?? Infinity;
2014
- const threshold = 4000;
2015
- glowMesh.visible = cameraZ < threshold;
2016
- }
2017
- // ▲▲▲ [Changed] ▲▲▲
2018
-
2019
- // ▼▼▼ [Fix] 리사이즈 종료 후 8방향 핸들 위치 업데이트 ▼▼▼
2020
- const HANDLE_SIZE = RESIZE_HANDLE_SIZE;
2021
- const w = nodeEntry.model.width;
2022
- const h = nodeEntry.model.height;
2023
- const edgeWidthLength = Math.max(10, w - HANDLE_SIZE * 2);
2024
- const edgeHeightLength = Math.max(10, h - HANDLE_SIZE * 2);
2005
+
2006
+ // ▼▼▼ [Changed] After resizing, update glow using SDF shader ▼▼▼
2007
+ if (ENABLE_WEBGL_GLOW && glowMesh && window.MindMapGlowShader?.updateGlowSize) {
2008
+ const contentType = nodeEntry.model.contentType || 'text';
2009
+ window.MindMapGlowShader.updateGlowSize(glowMesh, nodeEntry.model.width, nodeEntry.model.height, contentType);
2010
+ glowMesh.position.set(
2011
+ nodeEntry.model.width / 2,
2012
+ -nodeEntry.model.height / 2,
2013
+ 0
2014
+ );
2015
+ // ★ 카메라 거리에 따라 가시성 설정
2016
+ const cameraZ = module.camera?.position?.z ?? Infinity;
2017
+ const threshold = 4000;
2018
+ glowMesh.visible = cameraZ < threshold;
2019
+ }
2020
+ // ▲▲▲ [Changed] ▲▲▲
2021
+
2022
+ // ▼▼▼ [Fix] 리사이즈 종료 후 8방향 핸들 위치 업데이트 ▼▼▼
2023
+ const HANDLE_SIZE = RESIZE_HANDLE_SIZE;
2024
+ const w = nodeEntry.model.width;
2025
+ const h = nodeEntry.model.height;
2026
+ const edgeWidthLength = Math.max(10, w - HANDLE_SIZE * 2);
2027
+ const edgeHeightLength = Math.max(10, h - HANDLE_SIZE * 2);
2025
2028
  const handleConfigs = {
2026
2029
  // Corners
2027
2030
  'resizeHandle_TL': { x: HANDLE_SIZE / 2, y: -HANDLE_SIZE / 2, w: HANDLE_SIZE, h: HANDLE_SIZE, z: 0 },
@@ -2059,25 +2062,25 @@
2059
2062
  handle.material.needsUpdate = true;
2060
2063
  }
2061
2064
  });
2062
- // ▲▲▲ [Fix] ▲▲▲
2063
- }
2064
-
2065
- if (debounceTimers.has(nodeId)) {
2066
- clearTimeout(debounceTimers.get(nodeId));
2067
- debounceTimers.delete(nodeId);
2068
- }
2069
- if (resizeThrottleTimers.has(nodeId)) {
2070
- clearTimeout(resizeThrottleTimers.get(nodeId));
2071
- resizeThrottleTimers.delete(nodeId);
2072
- }
2073
-
2065
+ // ▲▲▲ [Fix] ▲▲▲
2066
+ }
2067
+
2068
+ if (debounceTimers.has(nodeId)) {
2069
+ clearTimeout(debounceTimers.get(nodeId));
2070
+ debounceTimers.delete(nodeId);
2071
+ }
2072
+ if (resizeThrottleTimers.has(nodeId)) {
2073
+ clearTimeout(resizeThrottleTimers.get(nodeId));
2074
+ resizeThrottleTimers.delete(nodeId);
2075
+ }
2076
+
2074
2077
  if (nodeEntry.currentResizeType === 'CSS') {
2075
2078
  console.log(`[stopResizing] CSS type - queuing texture update for ${nodeId}`);
2076
2079
 
2077
2080
  // ▼▼▼ [Fix] CSS3D 노드의 최종 크기를 resolutionScale 고려하여 설정 ▼▼▼
2078
2081
  if (nodeEntry.cssObject && nodeEntry.cssObject.element) {
2079
- const wrapper = nodeEntry.cssObject.element;
2080
- const resolutionScale = nodeEntry.cssObject.userData?.resolutionScale || 1;
2082
+ const wrapper = nodeEntry.cssObject.element;
2083
+ const resolutionScale = nodeEntry.cssObject.userData?.resolutionScale || 1;
2081
2084
  const domWidth = nodeEntry.model.width * resolutionScale;
2082
2085
  const domHeight = nodeEntry.model.height * resolutionScale;
2083
2086
 
@@ -2097,7 +2100,7 @@
2097
2100
  innerContent.style.maxWidth = nodeEntry.model.width + 'px';
2098
2101
  innerContent.style.maxHeight = nodeEntry.model.height + 'px';
2099
2102
  }
2100
-
2103
+
2101
2104
  // userData 업데이트
2102
2105
  nodeEntry.cssObject.userData.worldWidth = nodeEntry.model.width;
2103
2106
  nodeEntry.cssObject.userData.worldHeight = nodeEntry.model.height;
@@ -2121,72 +2124,72 @@
2121
2124
  if (textarea) {
2122
2125
  window.MindMapNodes.pendingNodeUpdates.set(nodeId, {
2123
2126
  content: textarea.value,
2124
- width: nodeEntry.model.width,
2125
- height: nodeEntry.model.height
2126
- });
2127
- enqueueRender(module, nodeId);
2128
- }
2129
- } else if (nodeEntry.currentResizeType === 'GL') {
2130
- // ▼▼▼ [Refactor] After resizing a chunk node, fetch content directly from C# ▼▼▼
2131
- if (nodeEntry.model.isChunkedText && module.dotNetHelper) {
2132
- try {
2133
- const result = await module.dotNetHelper.invokeMethodAsync(
2134
- 'GetTextByScrollPosition',
2135
- nodeId,
2136
- nodeEntry.model.scrollOffset || 0,
2137
- nodeEntry.model.height || 400,
2138
- CHUNK_LINE_HEIGHT
2139
- );
2140
-
2141
- if (result) {
2142
- nodeEntry.model._visibleContent = result.content;
2143
- nodeEntry.model._visibleScrollOffset = result.relativeScrollOffset;
2144
-
2145
- window.MindMapNodes.pendingNodeUpdates.set(nodeId, {
2146
- content: result.content,
2147
- width: nodeEntry.model.width,
2148
- height: nodeEntry.model.height,
2149
- scrollDelta: 0
2150
- });
2151
- enqueueRender(module, nodeId);
2152
- }
2153
- } catch (e) {
2154
- console.error(`[Pipeline] GetTextByScrollPosition error on resize:`, e);
2155
- }
2156
- } else {
2157
- // ▲▲▲ [Refactor] ▲▲▲
2158
- // Regular nodes (non-chunked) + IMAGE NODES
2159
-
2160
- // ▼▼▼ [Fix] 이미지 노드 리사이즈 시 기존 텍스처 유지 ▼▼▼
2161
- if (nodeEntry.model.contentType === 'image') {
2162
- // 이미지 노드는 URL이 변하지 않으므로 텍스처 재생성 불필요
2163
- // geometry만 업데이트하고 기존 텍스처를 유지
2164
- const bodyMesh = nodeEntry.glObject?.getObjectByName('body');
2165
- if (bodyMesh && bodyMesh.material.map) {
2166
- // 기존 텍스처 유지 - 아무것도 하지 않음
2167
- console.log(`[stopResizing] Image node ${nodeId} - keeping existing texture`);
2168
- } else {
2169
- console.warn(`[stopResizing] Image node ${nodeId} - no texture found, may need reload`);
2170
- }
2171
- } else {
2172
- // [Fix] Ensure regular nodes have content for texture generation
2173
- const content = nodeEntry.model.response;
2174
-
2175
- console.log(`[stopResizing] GL type - queuing texture update for ${nodeId}, size ${nodeEntry.model.width}x${nodeEntry.model.height}`);
2176
- window.MindMapNodes.pendingNodeUpdates.set(nodeId, {
2177
- content: content,
2178
- width: nodeEntry.model.width,
2179
- height: nodeEntry.model.height
2180
- });
2181
- enqueueRender(module, nodeId);
2182
- }
2183
- // ▲▲▲ [Fix] ▲▲▲
2184
- }
2185
- }
2186
-
2187
- window.MindMapNodes.updateNodeSpatialGrid(module, nodeId);
2188
-
2189
- // ▼▼▼ [Fix] 리사이즈 종료 후 CSS transition 복원 ▼▼▼
2127
+ width: nodeEntry.model.width,
2128
+ height: nodeEntry.model.height
2129
+ });
2130
+ enqueueRender(module, nodeId);
2131
+ }
2132
+ } else if (nodeEntry.currentResizeType === 'GL') {
2133
+ // ▼▼▼ [Refactor] After resizing a chunk node, fetch content directly from C# ▼▼▼
2134
+ if (nodeEntry.model.isChunkedText && module.dotNetHelper) {
2135
+ try {
2136
+ const result = await module.dotNetHelper.invokeMethodAsync(
2137
+ 'GetTextByScrollPosition',
2138
+ nodeId,
2139
+ nodeEntry.model.scrollOffset || 0,
2140
+ nodeEntry.model.height || 400,
2141
+ CHUNK_LINE_HEIGHT
2142
+ );
2143
+
2144
+ if (result) {
2145
+ nodeEntry.model._visibleContent = result.content;
2146
+ nodeEntry.model._visibleScrollOffset = result.relativeScrollOffset;
2147
+
2148
+ window.MindMapNodes.pendingNodeUpdates.set(nodeId, {
2149
+ content: result.content,
2150
+ width: nodeEntry.model.width,
2151
+ height: nodeEntry.model.height,
2152
+ scrollDelta: 0
2153
+ });
2154
+ enqueueRender(module, nodeId);
2155
+ }
2156
+ } catch (e) {
2157
+ console.error(`[Pipeline] GetTextByScrollPosition error on resize:`, e);
2158
+ }
2159
+ } else {
2160
+ // ▲▲▲ [Refactor] ▲▲▲
2161
+ // Regular nodes (non-chunked) + IMAGE NODES
2162
+
2163
+ // ▼▼▼ [Fix] 이미지 노드 리사이즈 시 기존 텍스처 유지 ▼▼▼
2164
+ if (nodeEntry.model.contentType === 'image') {
2165
+ // 이미지 노드는 URL이 변하지 않으므로 텍스처 재생성 불필요
2166
+ // geometry만 업데이트하고 기존 텍스처를 유지
2167
+ const bodyMesh = nodeEntry.glObject?.getObjectByName('body');
2168
+ if (bodyMesh && bodyMesh.material.map) {
2169
+ // 기존 텍스처 유지 - 아무것도 하지 않음
2170
+ console.log(`[stopResizing] Image node ${nodeId} - keeping existing texture`);
2171
+ } else {
2172
+ console.warn(`[stopResizing] Image node ${nodeId} - no texture found, may need reload`);
2173
+ }
2174
+ } else {
2175
+ // [Fix] Ensure regular nodes have content for texture generation
2176
+ const content = nodeEntry.model.response;
2177
+
2178
+ console.log(`[stopResizing] GL type - queuing texture update for ${nodeId}, size ${nodeEntry.model.width}x${nodeEntry.model.height}`);
2179
+ window.MindMapNodes.pendingNodeUpdates.set(nodeId, {
2180
+ content: content,
2181
+ width: nodeEntry.model.width,
2182
+ height: nodeEntry.model.height
2183
+ });
2184
+ enqueueRender(module, nodeId);
2185
+ }
2186
+ // ▲▲▲ [Fix] ▲▲▲
2187
+ }
2188
+ }
2189
+
2190
+ window.MindMapNodes.updateNodeSpatialGrid(module, nodeId);
2191
+
2192
+ // ▼▼▼ [Fix] 리사이즈 종료 후 CSS transition 복원 ▼▼▼
2190
2193
  if (nodeEntry.cssObject && nodeEntry.cssObject.element) {
2191
2194
  const wrapper = nodeEntry.cssObject.element;
2192
2195
  wrapper.style.transition = CSS3D_WRAPPER_TRANSITION;
@@ -2197,9 +2200,9 @@
2197
2200
  inner.style.webkitTransition = 'none';
2198
2201
  }
2199
2202
  }
2200
- // ▲▲▲ [Fix] ▲▲▲
2201
- }
2202
-
2203
+ // ▲▲▲ [Fix] ▲▲▲
2204
+ }
2205
+
2203
2206
  module.isResizing = false;
2204
2207
  module.resizeNodeObject = null;
2205
2208
  module.resizingNodeId = null; // ▼▼▼ [Changed] clear resizingNodeId ▼▼▼
@@ -2208,18 +2211,18 @@
2208
2211
 
2209
2212
  // ▼▼▼ [Fix] Reset cursor after resizing ▼▼▼
2210
2213
  document.body.style.cursor = '';
2211
- // ▲▲▲ [Fix] ▲▲▲
2212
- }
2213
-
2214
- // Expose MindMapPipeline globally
2214
+ // ▲▲▲ [Fix] ▲▲▲
2215
+ }
2216
+
2217
+ // Expose MindMapPipeline globally
2215
2218
  window.MindMapPipeline = {
2216
- processRenderQueue: processRenderQueue,
2217
- debouncedProcessRenderQueue: debouncedProcessRenderQueue,
2218
- throttledScrollUpdate: throttledScrollUpdate,
2219
- setScrollPosition: setScrollPosition, // [New] for scrollbar dragging
2220
- throttledProcessRenderQueueDuringResize: throttledProcessRenderQueueDuringResize,
2221
- startResizing: startResizing,
2222
- doResizing: doResizing,
2219
+ processRenderQueue: processRenderQueue,
2220
+ debouncedProcessRenderQueue: debouncedProcessRenderQueue,
2221
+ throttledScrollUpdate: throttledScrollUpdate,
2222
+ setScrollPosition: setScrollPosition, // [New] for scrollbar dragging
2223
+ throttledProcessRenderQueueDuringResize: throttledProcessRenderQueueDuringResize,
2224
+ startResizing: startResizing,
2225
+ doResizing: doResizing,
2223
2226
  stopResizing: stopResizing,
2224
2227
  getQueueDebugMetrics: () => ({
2225
2228
  sortCount: renderQueueSortCount,
@@ -2232,8 +2235,8 @@
2232
2235
  renderQueueMaxLength = renderQueue.length;
2233
2236
  },
2234
2237
  throttledUpdateScrollInCSharp: throttledUpdateScrollInCSharp
2235
- };
2236
-
2237
- console.log('? mind-map-pipeline.js loaded');
2238
- })();
2239
-
2238
+ };
2239
+
2240
+ console.log('? mind-map-pipeline.js loaded');
2241
+ })();
2242
+