Spaces:
Running
Running
incognitolm commited on
Commit ·
8dfb3cd
1
Parent(s): c613a09
syncing on new block
Browse files
client/src/components/BlockEditor/BlockEditor.tsx
CHANGED
|
@@ -130,6 +130,21 @@ export default function BlockEditor() {
|
|
| 130 |
// Set when the user changes the workspace during a drag while a remote XML
|
| 131 |
// sync is deferred. Local edits must win over the older deferred snapshot.
|
| 132 |
const localChangeDuringDragRef = useRef(false);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 133 |
|
| 134 |
// Modal state for editing variable/function blocks
|
| 135 |
const [editingModal, setEditingModal] = useState<BlockEditorModalState | null>(null);
|
|
@@ -328,16 +343,23 @@ export default function BlockEditor() {
|
|
| 328 |
if (e.isStart) {
|
| 329 |
dragEndId++;
|
| 330 |
isDraggingRef.current = true;
|
| 331 |
-
|
|
|
|
|
|
|
|
|
|
| 332 |
localChangeDuringDragRef.current = false;
|
|
|
|
| 333 |
draggedBlockId = e.blockId;
|
| 334 |
emitBlockDragStart(e.blockId, xy.x, xy.y);
|
| 335 |
// Poll block position during drag (BLOCK_MOVE may not fire)
|
| 336 |
dragPoll = setInterval(() => {
|
| 337 |
-
if (
|
| 338 |
if (dragPoll) { clearInterval(dragPoll); dragPoll = null; }
|
| 339 |
return;
|
| 340 |
}
|
|
|
|
|
|
|
|
|
|
| 341 |
const b = ws.getBlockById(draggedBlockId!);
|
| 342 |
if (b) {
|
| 343 |
const pos = b.getRelativeToSurfaceXY();
|
|
@@ -355,41 +377,19 @@ export default function BlockEditor() {
|
|
| 355 |
requestAnimationFrame(() => {
|
| 356 |
if (thisId !== dragEndId) return;
|
| 357 |
isDraggingRef.current = false;
|
| 358 |
-
|
| 359 |
-
|
| 360 |
-
|
| 361 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 362 |
pendingSyncXmlRef.current = null;
|
| 363 |
-
|
| 364 |
-
if (ws2) {
|
| 365 |
-
// If the user changed anything locally during the drag, the
|
| 366 |
-
// deferred XML predates those edits. Keep the local state;
|
| 367 |
-
// saveBlocks() above already broadcast it to collaborators.
|
| 368 |
-
if (blocksCreatedDuringDragRef.current || localChangeDuringDragRef.current) {
|
| 369 |
-
console.log('[WS Reload] drag-end SKIPPING deferred XML: local edits during drag take priority');
|
| 370 |
-
return;
|
| 371 |
-
}
|
| 372 |
-
console.log('[WS Reload] applying deferred XML after drag end');
|
| 373 |
-
remoteUpdateRef.current = true;
|
| 374 |
-
try {
|
| 375 |
-
ws2.clear();
|
| 376 |
-
const dom = Blockly.utils.xml.textToDom(pendingXml);
|
| 377 |
-
Blockly.Xml.domToWorkspace(dom, ws2);
|
| 378 |
-
try { (ws2 as any).clearSelection(); } catch {}
|
| 379 |
-
try { ws2.setScale(useEditorStore.getState().zoom); } catch {}
|
| 380 |
-
// Regenerate the compiled code now that the workspace reflects
|
| 381 |
-
// the collaborator's XML (saveBlocks skips during remote loads).
|
| 382 |
-
const currentFileId = useEditorStore.getState().activeFileId;
|
| 383 |
-
if (currentFileId) refreshCodeForWorkspace(ws2, currentFileId);
|
| 384 |
-
} catch (e) {
|
| 385 |
-
console.error('[WS Reload] FAILED to apply deferred XML:', e);
|
| 386 |
-
}
|
| 387 |
-
requestAnimationFrame(() => { setTimeout(() => { remoteUpdateRef.current = false; }, 0); });
|
| 388 |
-
requestAnimationFrame(() => {
|
| 389 |
-
try { syncCustomFunctionBlocks(ws2); } catch {}
|
| 390 |
-
try { rebuildToolbox(ws2); } catch {}
|
| 391 |
-
});
|
| 392 |
-
}
|
| 393 |
}
|
| 394 |
});
|
| 395 |
}
|
|
@@ -418,30 +418,42 @@ export default function BlockEditor() {
|
|
| 418 |
if (e.type === Blockly.Events.BLOCK_CHANGE && e.element === 'field' && !remoteUpdateRef.current) {
|
| 419 |
emitBlockChange(e.blockId, e.name, e.newValue, e.element);
|
| 420 |
}
|
| 421 |
-
// Broadcast block creation (toolbox drag-out, paste, duplicate) so
|
| 422 |
-
// screens can create the block immediately instead of
|
|
|
|
|
|
|
|
|
|
|
|
|
| 423 |
if (e.type === Blockly.Events.BLOCK_CREATE && !remoteUpdateRef.current) {
|
|
|
|
| 424 |
if (isDraggingRef.current) {
|
| 425 |
blocksCreatedDuringDragRef.current = true;
|
| 426 |
}
|
| 427 |
-
const
|
| 428 |
-
|
| 429 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 430 |
try {
|
| 431 |
-
const dom = Blockly.Xml.
|
| 432 |
const xml = Blockly.Xml.domToText(dom);
|
| 433 |
-
|
| 434 |
-
if (activeFileId) emitBlockCreateEvent(e.blockId, activeFileId, xml);
|
| 435 |
} catch (err) {
|
| 436 |
console.warn('[BLOCK_CREATE] failed to emit block_create_event:', err);
|
| 437 |
}
|
| 438 |
-
}
|
| 439 |
-
console.log('[BLOCK_CREATE] skipping: block has parent');
|
| 440 |
-
}
|
| 441 |
}
|
| 442 |
});
|
| 443 |
|
| 444 |
// Restore saved blocks for this specific file
|
|
|
|
|
|
|
|
|
|
| 445 |
const savedXml = getBlocksXml(activeFileId);
|
| 446 |
if (savedXml) {
|
| 447 |
try {
|
|
@@ -453,9 +465,9 @@ export default function BlockEditor() {
|
|
| 453 |
// the restored blocks even if the persisted file content is older than
|
| 454 |
// the saved XML (e.g. leftover code from a previous session).
|
| 455 |
if (activeFileId) refreshCodeForWorkspace(ws, activeFileId);
|
| 456 |
-
requestAnimationFrame(() => { setTimeout(
|
| 457 |
} catch (e) {
|
| 458 |
-
requestAnimationFrame(() => { setTimeout(
|
| 459 |
// saved XML is invalid, ignore
|
| 460 |
}
|
| 461 |
}
|
|
@@ -465,22 +477,15 @@ export default function BlockEditor() {
|
|
| 465 |
});
|
| 466 |
|
| 467 |
// Auto-save blocks on change - per-file
|
| 468 |
-
const saveBlocks = () => {
|
| 469 |
if (!workspaceProjectId || projectIdRef.current !== workspaceProjectId) return;
|
| 470 |
-
if (remoteUpdateRef.current || isDraggingRef.current)
|
| 471 |
-
console.log('[saveBlocks] SKIPPING (remote:', remoteUpdateRef.current, 'dragging:', isDraggingRef.current, ') topBlocks:', ws.getTopBlocks(false).length);
|
| 472 |
-
return;
|
| 473 |
-
}
|
| 474 |
const dom = Blockly.Xml.workspaceToDom(ws);
|
| 475 |
const xml = Blockly.Xml.domToText(dom);
|
| 476 |
const currentFileId = useEditorStore.getState().activeFileId;
|
| 477 |
-
if (!currentFileId) return;
|
| 478 |
const savedXml = getBlocksXml(currentFileId);
|
| 479 |
-
if (savedXml === xml)
|
| 480 |
-
console.log('[saveBlocks] SKIPPING: XML unchanged for file:', currentFileId);
|
| 481 |
-
return;
|
| 482 |
-
}
|
| 483 |
-
console.log('[saveBlocks] SAVING blocks for file:', currentFileId, 'xml length:', xml.length, 'topBlocks:', ws.getTopBlocks(false).length, '(old:', savedXml?.length, ')');
|
| 484 |
setBlocksXml(currentFileId, xml);
|
| 485 |
emitBlockStateSync(currentFileId, xml);
|
| 486 |
const generatedCode = refreshCodeForWorkspace(ws, currentFileId);
|
|
@@ -493,7 +498,18 @@ export default function BlockEditor() {
|
|
| 493 |
s.emit('file_changed', { projectId: pid, fileId: currentFileId, content: generatedCode });
|
| 494 |
}
|
| 495 |
};
|
| 496 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 497 |
|
| 498 |
return () => {
|
| 499 |
resizeObserver.disconnect();
|
|
@@ -516,18 +532,25 @@ export default function BlockEditor() {
|
|
| 516 |
delete (div as any).__pointerSvg;
|
| 517 |
}
|
| 518 |
isDraggingRef.current = false;
|
| 519 |
-
ws.removeChangeListener(
|
| 520 |
ws.dispose();
|
| 521 |
workspaceRef.current = null;
|
| 522 |
};
|
| 523 |
-
}, [framework, activeFileId, currentProject?.id]);
|
| 524 |
|
| 525 |
// Apply remote block state changes (skip if workspace already matches)
|
| 526 |
const blocksXmlForFile = useEditorStore((s) => s.blocksXml[activeFileId || '']);
|
| 527 |
useEffect(() => {
|
| 528 |
-
if (!activeFileId || !workspaceRef.current
|
| 529 |
-
const ws = workspaceRef.current;
|
| 530 |
if (!blocksXmlForFile) return;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 531 |
// If the workspace already matches this XML, skip reload (avoid disrupting drag/edit)
|
| 532 |
try {
|
| 533 |
const currentDom = Blockly.Xml.workspaceToDom(ws);
|
|
@@ -537,7 +560,6 @@ export default function BlockEditor() {
|
|
| 537 |
// If the user is currently dragging, defer the reload to avoid disrupting the drag.
|
| 538 |
// Store the pending XML so the drag-end handler can apply it.
|
| 539 |
if (isDraggingRef.current) {
|
| 540 |
-
console.log('[WS Reload] DEFERRING: user is dragging');
|
| 541 |
pendingSyncXmlRef.current = blocksXmlForFile;
|
| 542 |
return;
|
| 543 |
}
|
|
@@ -545,7 +567,6 @@ export default function BlockEditor() {
|
|
| 545 |
// has fewer top-level blocks than the local workspace: otherwise deleting
|
| 546 |
// blocks remotely would never propagate. Concurrent local edits are sent
|
| 547 |
// back through the same channel and converge on the last update.
|
| 548 |
-
console.log('[WS Reload] reloading workspace from XML (top blocks:', ws.getTopBlocks(false).length, ')');
|
| 549 |
pendingSyncXmlRef.current = null;
|
| 550 |
remoteUpdateRef.current = true;
|
| 551 |
try {
|
|
@@ -562,17 +583,23 @@ export default function BlockEditor() {
|
|
| 562 |
}
|
| 563 |
// Blockly may fire events asynchronously (microtasks) after domToWorkspace.
|
| 564 |
// Defer resetting the flag so those events see remoteUpdateRef=true.
|
| 565 |
-
requestAnimationFrame(() => { setTimeout(
|
| 566 |
requestAnimationFrame(() => {
|
| 567 |
try { syncCustomFunctionBlocks(ws); } catch {}
|
| 568 |
try { rebuildToolbox(ws); } catch {}
|
| 569 |
});
|
| 570 |
-
}, [activeFileId, blocksXmlForFile]);
|
| 571 |
|
| 572 |
// Move remote collaborator blocks to absolute positions during drag
|
| 573 |
useEffect(() => {
|
| 574 |
const ws = workspaceRef.current;
|
| 575 |
-
if (!ws || !activeFileId
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 576 |
remoteUpdateRef.current = true;
|
| 577 |
for (const [userId, drag] of Object.entries(remoteBlockDrags)) {
|
| 578 |
const block = ws.getBlockById(drag.blockId);
|
|
@@ -588,12 +615,18 @@ export default function BlockEditor() {
|
|
| 588 |
block.moveBy(dx, dy);
|
| 589 |
}
|
| 590 |
}
|
| 591 |
-
|
| 592 |
-
}, [remoteBlockDrags, activeFileId]);
|
| 593 |
|
| 594 |
// Connect blocks on receiver when remote user snaps a block into a parent
|
| 595 |
useEffect(() => {
|
| 596 |
if (!pendingBlockConnects.length) return;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 597 |
remoteUpdateRef.current = true;
|
| 598 |
const ws = workspaceRef.current;
|
| 599 |
if (ws) {
|
|
@@ -641,12 +674,16 @@ export default function BlockEditor() {
|
|
| 641 |
// code explicitly after applying remote connections.
|
| 642 |
if (activeFileId) refreshCodeForWorkspace(ws, activeFileId);
|
| 643 |
}
|
| 644 |
-
|
| 645 |
-
}, [pendingBlockConnects, blocksXmlForFile]);
|
| 646 |
|
| 647 |
// Apply field value changes from remote collaborators (dropdowns, text fields, etc.)
|
| 648 |
useEffect(() => {
|
| 649 |
if (!pendingBlockChanges.length) return;
|
|
|
|
|
|
|
|
|
|
|
|
|
| 650 |
remoteUpdateRef.current = true;
|
| 651 |
const ws = workspaceRef.current;
|
| 652 |
if (ws) {
|
|
@@ -660,61 +697,82 @@ export default function BlockEditor() {
|
|
| 660 |
} catch {}
|
| 661 |
}
|
| 662 |
}
|
| 663 |
-
|
| 664 |
// saveBlocks skips while remoteUpdateRef is set, so refresh the compiled
|
| 665 |
// code explicitly after applying remote field value changes.
|
| 666 |
if (ws && activeFileId) refreshCodeForWorkspace(ws, activeFileId);
|
| 667 |
-
}, [pendingBlockChanges]);
|
| 668 |
|
| 669 |
// Create blocks on receiver when remote user drags a new block from the toolbox
|
| 670 |
useEffect(() => {
|
| 671 |
-
if (!pendingBlockCreates.length)
|
| 672 |
-
|
|
|
|
| 673 |
return;
|
| 674 |
}
|
| 675 |
-
console.log('[PendingCreate] processing', pendingBlockCreates.length, 'pending creates');
|
| 676 |
-
remoteUpdateRef.current = true;
|
| 677 |
const ws = workspaceRef.current;
|
| 678 |
-
if (ws
|
| 679 |
-
|
| 680 |
-
|
| 681 |
-
|
| 682 |
-
|
| 683 |
-
|
| 684 |
-
|
| 685 |
-
|
| 686 |
-
|
| 687 |
-
|
| 688 |
-
|
| 689 |
-
|
| 690 |
-
|
| 691 |
-
|
| 692 |
-
|
| 693 |
-
|
| 694 |
-
|
| 695 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 696 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
| 697 |
}
|
| 698 |
-
// Update blocksXml in the store so the blocksXmlForFile comparison
|
| 699 |
-
// in the workspace-reload effect matches the current workspace,
|
| 700 |
-
// preventing a destructive clear+reload that could conflict.
|
| 701 |
-
if (created) {
|
| 702 |
-
const dom = Blockly.Xml.workspaceToDom(ws);
|
| 703 |
-
const xml = Blockly.Xml.domToText(dom);
|
| 704 |
-
console.log('[PendingCreate] updating blocksXml after creating blocks');
|
| 705 |
-
useEditorStore.getState().setBlocksXml(activeFileId, xml);
|
| 706 |
-
// saveBlocks skips while remoteUpdateRef is set, so refresh the
|
| 707 |
-
// compiled code explicitly after creating collaborator blocks.
|
| 708 |
-
refreshCodeForWorkspace(ws, activeFileId);
|
| 709 |
-
requestAnimationFrame(() => {
|
| 710 |
-
try { syncCustomFunctionBlocks(ws); } catch {}
|
| 711 |
-
try { rebuildToolbox(ws); } catch {}
|
| 712 |
-
});
|
| 713 |
-
}
|
| 714 |
-
useCollaborationStore.setState({ pendingBlockCreates: [] });
|
| 715 |
}
|
| 716 |
-
|
| 717 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 718 |
|
| 719 |
// Collaborator block selection overlay
|
| 720 |
const collaborators = useCollaborationStore((s) => s.collaborators);
|
|
|
|
| 130 |
// Set when the user changes the workspace during a drag while a remote XML
|
| 131 |
// sync is deferred. Local edits must win over the older deferred snapshot.
|
| 132 |
const localChangeDuringDragRef = useRef(false);
|
| 133 |
+
// Recent block creation id. A toolbox drag emits BLOCK_CREATE before
|
| 134 |
+
// BLOCK_DRAG start, so this lets us tell that the drag created the block.
|
| 135 |
+
const recentCreateBlockIdRef = useRef<string | null>(null);
|
| 136 |
+
// Remote XML that arrived while another remote update was still in flight.
|
| 137 |
+
// It must be retried instead of silently dropped.
|
| 138 |
+
const deferredRemoteSyncRef = useRef(false);
|
| 139 |
+
const [remoteSyncTick, setRemoteSyncTick] = useState(0);
|
| 140 |
+
|
| 141 |
+
const finishRemoteUpdate = useCallback(() => {
|
| 142 |
+
remoteUpdateRef.current = false;
|
| 143 |
+
if (deferredRemoteSyncRef.current) {
|
| 144 |
+
deferredRemoteSyncRef.current = false;
|
| 145 |
+
setRemoteSyncTick((tick) => tick + 1);
|
| 146 |
+
}
|
| 147 |
+
}, []);
|
| 148 |
|
| 149 |
// Modal state for editing variable/function blocks
|
| 150 |
const [editingModal, setEditingModal] = useState<BlockEditorModalState | null>(null);
|
|
|
|
| 343 |
if (e.isStart) {
|
| 344 |
dragEndId++;
|
| 345 |
isDraggingRef.current = true;
|
| 346 |
+
// A toolbox drag emits BLOCK_CREATE just before BLOCK_DRAG start.
|
| 347 |
+
// Track that this drag introduced a new block, so its drop wins
|
| 348 |
+
// over any older XML that arrived while the drag was in progress.
|
| 349 |
+
blocksCreatedDuringDragRef.current = recentCreateBlockIdRef.current === e.blockId;
|
| 350 |
localChangeDuringDragRef.current = false;
|
| 351 |
+
recentCreateBlockIdRef.current = null;
|
| 352 |
draggedBlockId = e.blockId;
|
| 353 |
emitBlockDragStart(e.blockId, xy.x, xy.y);
|
| 354 |
// Poll block position during drag (BLOCK_MOVE may not fire)
|
| 355 |
dragPoll = setInterval(() => {
|
| 356 |
+
if (!isDraggingRef.current) {
|
| 357 |
if (dragPoll) { clearInterval(dragPoll); dragPoll = null; }
|
| 358 |
return;
|
| 359 |
}
|
| 360 |
+
// Skip this tick while a remote apply is settling, but keep
|
| 361 |
+
// polling so later drag positions still reach collaborators.
|
| 362 |
+
if (remoteUpdateRef.current) return;
|
| 363 |
const b = ws.getBlockById(draggedBlockId!);
|
| 364 |
if (b) {
|
| 365 |
const pos = b.getRelativeToSurfaceXY();
|
|
|
|
| 377 |
requestAnimationFrame(() => {
|
| 378 |
if (thisId !== dragEndId) return;
|
| 379 |
isDraggingRef.current = false;
|
| 380 |
+
const hadLocalChanges =
|
| 381 |
+
blocksCreatedDuringDragRef.current || localChangeDuringDragRef.current;
|
| 382 |
+
// The dropped block is a local edit. Force the save even when a
|
| 383 |
+
// remote update is being applied, otherwise a quick drag can be
|
| 384 |
+
// overwritten by the remote XML before it is broadcast.
|
| 385 |
+
saveBlocks(hadLocalChanges);
|
| 386 |
+
// Any XML that arrived while dragging was deferred. The store now
|
| 387 |
+
// holds either the forced local save above or the collaborator's
|
| 388 |
+
// latest XML, so let the reload effect reconcile the workspace
|
| 389 |
+
// with that single source of truth.
|
| 390 |
+
if (pendingSyncXmlRef.current) {
|
| 391 |
pendingSyncXmlRef.current = null;
|
| 392 |
+
setRemoteSyncTick((tick) => tick + 1);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 393 |
}
|
| 394 |
});
|
| 395 |
}
|
|
|
|
| 418 |
if (e.type === Blockly.Events.BLOCK_CHANGE && e.element === 'field' && !remoteUpdateRef.current) {
|
| 419 |
emitBlockChange(e.blockId, e.name, e.newValue, e.element);
|
| 420 |
}
|
| 421 |
+
// Broadcast block creation (toolbox drag-out, paste, duplicate) so
|
| 422 |
+
// collaborator screens can create the block immediately instead of
|
| 423 |
+
// waiting for the next full XML sync. BLOCK_CREATE fires from the block
|
| 424 |
+
// constructor, before Blockly has positioned a flyout block, so emit on
|
| 425 |
+
// a microtask to capture its real coordinates. blockToDomWithXY also
|
| 426 |
+
// preserves the block id that later drag/connect/change events use.
|
| 427 |
if (e.type === Blockly.Events.BLOCK_CREATE && !remoteUpdateRef.current) {
|
| 428 |
+
recentCreateBlockIdRef.current = e.blockId;
|
| 429 |
if (isDraggingRef.current) {
|
| 430 |
blocksCreatedDuringDragRef.current = true;
|
| 431 |
}
|
| 432 |
+
const createdBlockId = e.blockId;
|
| 433 |
+
const createdFileId = activeFileId;
|
| 434 |
+
queueMicrotask(() => {
|
| 435 |
+
if (!createdFileId || workspaceRef.current !== ws) return;
|
| 436 |
+
// Never emit a block from a workspace that has already been replaced
|
| 437 |
+
// by another file or project.
|
| 438 |
+
if (projectIdRef.current !== workspaceProjectId) return;
|
| 439 |
+
if (useEditorStore.getState().activeFileId !== createdFileId) return;
|
| 440 |
+
const block = ws.getBlockById(createdBlockId);
|
| 441 |
+
if (!block || block.isShadow() || block.isInsertionMarker()) return;
|
| 442 |
try {
|
| 443 |
+
const dom = Blockly.Xml.blockToDomWithXY(block);
|
| 444 |
const xml = Blockly.Xml.domToText(dom);
|
| 445 |
+
emitBlockCreateEvent(createdBlockId, createdFileId, xml);
|
|
|
|
| 446 |
} catch (err) {
|
| 447 |
console.warn('[BLOCK_CREATE] failed to emit block_create_event:', err);
|
| 448 |
}
|
| 449 |
+
});
|
|
|
|
|
|
|
| 450 |
}
|
| 451 |
});
|
| 452 |
|
| 453 |
// Restore saved blocks for this specific file
|
| 454 |
+
remoteUpdateRef.current = false;
|
| 455 |
+
deferredRemoteSyncRef.current = false;
|
| 456 |
+
pendingSyncXmlRef.current = null;
|
| 457 |
const savedXml = getBlocksXml(activeFileId);
|
| 458 |
if (savedXml) {
|
| 459 |
try {
|
|
|
|
| 465 |
// the restored blocks even if the persisted file content is older than
|
| 466 |
// the saved XML (e.g. leftover code from a previous session).
|
| 467 |
if (activeFileId) refreshCodeForWorkspace(ws, activeFileId);
|
| 468 |
+
requestAnimationFrame(() => { setTimeout(finishRemoteUpdate, 0); });
|
| 469 |
} catch (e) {
|
| 470 |
+
requestAnimationFrame(() => { setTimeout(finishRemoteUpdate, 0); });
|
| 471 |
// saved XML is invalid, ignore
|
| 472 |
}
|
| 473 |
}
|
|
|
|
| 477 |
});
|
| 478 |
|
| 479 |
// Auto-save blocks on change - per-file
|
| 480 |
+
const saveBlocks = (force = false) => {
|
| 481 |
if (!workspaceProjectId || projectIdRef.current !== workspaceProjectId) return;
|
| 482 |
+
if ((remoteUpdateRef.current && !force) || isDraggingRef.current) return;
|
|
|
|
|
|
|
|
|
|
| 483 |
const dom = Blockly.Xml.workspaceToDom(ws);
|
| 484 |
const xml = Blockly.Xml.domToText(dom);
|
| 485 |
const currentFileId = useEditorStore.getState().activeFileId;
|
| 486 |
+
if (!currentFileId || currentFileId !== activeFileId) return;
|
| 487 |
const savedXml = getBlocksXml(currentFileId);
|
| 488 |
+
if (savedXml === xml) return;
|
|
|
|
|
|
|
|
|
|
|
|
|
| 489 |
setBlocksXml(currentFileId, xml);
|
| 490 |
emitBlockStateSync(currentFileId, xml);
|
| 491 |
const generatedCode = refreshCodeForWorkspace(ws, currentFileId);
|
|
|
|
| 498 |
s.emit('file_changed', { projectId: pid, fileId: currentFileId, content: generatedCode });
|
| 499 |
}
|
| 500 |
};
|
| 501 |
+
// A toolbox drag fires BLOCK_CREATE from the block constructor, before
|
| 502 |
+
// Blockly has positioned the block. Saving there would broadcast a
|
| 503 |
+
// transient (0,0) position. Defer one microtask: if a drag starts, the
|
| 504 |
+
// save is suppressed until drop; paste/programmatic creates still save.
|
| 505 |
+
const handleWorkspaceChange = (e: any) => {
|
| 506 |
+
if (e?.type === Blockly.Events.BLOCK_CREATE && !remoteUpdateRef.current) {
|
| 507 |
+
queueMicrotask(() => saveBlocks());
|
| 508 |
+
return;
|
| 509 |
+
}
|
| 510 |
+
saveBlocks();
|
| 511 |
+
};
|
| 512 |
+
ws.addChangeListener(handleWorkspaceChange);
|
| 513 |
|
| 514 |
return () => {
|
| 515 |
resizeObserver.disconnect();
|
|
|
|
| 532 |
delete (div as any).__pointerSvg;
|
| 533 |
}
|
| 534 |
isDraggingRef.current = false;
|
| 535 |
+
ws.removeChangeListener(handleWorkspaceChange);
|
| 536 |
ws.dispose();
|
| 537 |
workspaceRef.current = null;
|
| 538 |
};
|
| 539 |
+
}, [framework, activeFileId, currentProject?.id, finishRemoteUpdate]);
|
| 540 |
|
| 541 |
// Apply remote block state changes (skip if workspace already matches)
|
| 542 |
const blocksXmlForFile = useEditorStore((s) => s.blocksXml[activeFileId || '']);
|
| 543 |
useEffect(() => {
|
| 544 |
+
if (!activeFileId || !workspaceRef.current) return;
|
|
|
|
| 545 |
if (!blocksXmlForFile) return;
|
| 546 |
+
const ws = workspaceRef.current;
|
| 547 |
+
if (remoteUpdateRef.current) {
|
| 548 |
+
// A different remote update is still settling. Remember it and retry;
|
| 549 |
+
// dropping it here is what made quick updates (e.g. a block created and
|
| 550 |
+
// dropped while a create was being applied) disappear for collaborators.
|
| 551 |
+
deferredRemoteSyncRef.current = true;
|
| 552 |
+
return;
|
| 553 |
+
}
|
| 554 |
// If the workspace already matches this XML, skip reload (avoid disrupting drag/edit)
|
| 555 |
try {
|
| 556 |
const currentDom = Blockly.Xml.workspaceToDom(ws);
|
|
|
|
| 560 |
// If the user is currently dragging, defer the reload to avoid disrupting the drag.
|
| 561 |
// Store the pending XML so the drag-end handler can apply it.
|
| 562 |
if (isDraggingRef.current) {
|
|
|
|
| 563 |
pendingSyncXmlRef.current = blocksXmlForFile;
|
| 564 |
return;
|
| 565 |
}
|
|
|
|
| 567 |
// has fewer top-level blocks than the local workspace: otherwise deleting
|
| 568 |
// blocks remotely would never propagate. Concurrent local edits are sent
|
| 569 |
// back through the same channel and converge on the last update.
|
|
|
|
| 570 |
pendingSyncXmlRef.current = null;
|
| 571 |
remoteUpdateRef.current = true;
|
| 572 |
try {
|
|
|
|
| 583 |
}
|
| 584 |
// Blockly may fire events asynchronously (microtasks) after domToWorkspace.
|
| 585 |
// Defer resetting the flag so those events see remoteUpdateRef=true.
|
| 586 |
+
requestAnimationFrame(() => { setTimeout(finishRemoteUpdate, 0); });
|
| 587 |
requestAnimationFrame(() => {
|
| 588 |
try { syncCustomFunctionBlocks(ws); } catch {}
|
| 589 |
try { rebuildToolbox(ws); } catch {}
|
| 590 |
});
|
| 591 |
+
}, [activeFileId, blocksXmlForFile, remoteSyncTick, finishRemoteUpdate]);
|
| 592 |
|
| 593 |
// Move remote collaborator blocks to absolute positions during drag
|
| 594 |
useEffect(() => {
|
| 595 |
const ws = workspaceRef.current;
|
| 596 |
+
if (!ws || !activeFileId) return;
|
| 597 |
+
if (remoteUpdateRef.current) {
|
| 598 |
+
// Retry once the in-flight apply settles so the latest drag position is
|
| 599 |
+
// not skipped.
|
| 600 |
+
deferredRemoteSyncRef.current = true;
|
| 601 |
+
return;
|
| 602 |
+
}
|
| 603 |
remoteUpdateRef.current = true;
|
| 604 |
for (const [userId, drag] of Object.entries(remoteBlockDrags)) {
|
| 605 |
const block = ws.getBlockById(drag.blockId);
|
|
|
|
| 615 |
block.moveBy(dx, dy);
|
| 616 |
}
|
| 617 |
}
|
| 618 |
+
finishRemoteUpdate();
|
| 619 |
+
}, [remoteBlockDrags, activeFileId, blocksXmlForFile, remoteSyncTick, finishRemoteUpdate]);
|
| 620 |
|
| 621 |
// Connect blocks on receiver when remote user snaps a block into a parent
|
| 622 |
useEffect(() => {
|
| 623 |
if (!pendingBlockConnects.length) return;
|
| 624 |
+
if (remoteUpdateRef.current) {
|
| 625 |
+
// Another apply is still settling; retry after it finishes so this
|
| 626 |
+
// connection is not applied against a half-updated workspace.
|
| 627 |
+
deferredRemoteSyncRef.current = true;
|
| 628 |
+
return;
|
| 629 |
+
}
|
| 630 |
remoteUpdateRef.current = true;
|
| 631 |
const ws = workspaceRef.current;
|
| 632 |
if (ws) {
|
|
|
|
| 674 |
// code explicitly after applying remote connections.
|
| 675 |
if (activeFileId) refreshCodeForWorkspace(ws, activeFileId);
|
| 676 |
}
|
| 677 |
+
finishRemoteUpdate();
|
| 678 |
+
}, [pendingBlockConnects, blocksXmlForFile, remoteSyncTick, finishRemoteUpdate]);
|
| 679 |
|
| 680 |
// Apply field value changes from remote collaborators (dropdowns, text fields, etc.)
|
| 681 |
useEffect(() => {
|
| 682 |
if (!pendingBlockChanges.length) return;
|
| 683 |
+
if (remoteUpdateRef.current) {
|
| 684 |
+
deferredRemoteSyncRef.current = true;
|
| 685 |
+
return;
|
| 686 |
+
}
|
| 687 |
remoteUpdateRef.current = true;
|
| 688 |
const ws = workspaceRef.current;
|
| 689 |
if (ws) {
|
|
|
|
| 697 |
} catch {}
|
| 698 |
}
|
| 699 |
}
|
| 700 |
+
finishRemoteUpdate();
|
| 701 |
// saveBlocks skips while remoteUpdateRef is set, so refresh the compiled
|
| 702 |
// code explicitly after applying remote field value changes.
|
| 703 |
if (ws && activeFileId) refreshCodeForWorkspace(ws, activeFileId);
|
| 704 |
+
}, [pendingBlockChanges, remoteSyncTick, finishRemoteUpdate]);
|
| 705 |
|
| 706 |
// Create blocks on receiver when remote user drags a new block from the toolbox
|
| 707 |
useEffect(() => {
|
| 708 |
+
if (!activeFileId || !pendingBlockCreates.length) return;
|
| 709 |
+
if (remoteUpdateRef.current) {
|
| 710 |
+
deferredRemoteSyncRef.current = true;
|
| 711 |
return;
|
| 712 |
}
|
|
|
|
|
|
|
| 713 |
const ws = workspaceRef.current;
|
| 714 |
+
if (!ws) return;
|
| 715 |
+
// Creates are queued together with their file id. Leave operations for
|
| 716 |
+
// other files untouched; they are recovered by the full XML sync (or
|
| 717 |
+
// applied when the user opens that file).
|
| 718 |
+
const forActiveFile = useCollaborationStore
|
| 719 |
+
.getState()
|
| 720 |
+
.pendingBlockCreates.filter((create) => create.fileId === activeFileId);
|
| 721 |
+
if (!forActiveFile.length) return;
|
| 722 |
+
|
| 723 |
+
remoteUpdateRef.current = true;
|
| 724 |
+
let created = false;
|
| 725 |
+
const processedBlockIds = new Set<string>();
|
| 726 |
+
for (const create of forActiveFile) {
|
| 727 |
+
processedBlockIds.add(create.blockId);
|
| 728 |
+
// Skip if block already exists (e.g. already created by XML sync)
|
| 729 |
+
if (ws.getBlockById(create.blockId)) continue;
|
| 730 |
+
try {
|
| 731 |
+
const dom = Blockly.utils.xml.textToDom(create.xml);
|
| 732 |
+
// `domToWorkspace` expects an <xml> wrapper; passing a bare <block>
|
| 733 |
+
// element silently creates nothing. Older payloads may also have lost
|
| 734 |
+
// the id (blockToDom's second argument is opt_noId), so restore it to
|
| 735 |
+
// keep later drag/connect/change events addressable.
|
| 736 |
+
const rootName = dom.nodeName.toLowerCase();
|
| 737 |
+
let wrapper: Element;
|
| 738 |
+
if (rootName === 'xml') {
|
| 739 |
+
wrapper = dom;
|
| 740 |
+
} else {
|
| 741 |
+
if (!dom.getAttribute('id')) dom.setAttribute('id', create.blockId);
|
| 742 |
+
wrapper = Blockly.utils.xml.createElement('xml');
|
| 743 |
+
wrapper.appendChild(dom);
|
| 744 |
}
|
| 745 |
+
Blockly.Xml.domToWorkspace(wrapper, ws);
|
| 746 |
+
created = true;
|
| 747 |
+
} catch (e) {
|
| 748 |
+
console.warn('[Collaboration] failed to apply remote block create:', create.blockId, e);
|
| 749 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 750 |
}
|
| 751 |
+
// Remove only the entries that were processed so newly queued creates are
|
| 752 |
+
// never dropped.
|
| 753 |
+
useCollaborationStore.setState((state) => ({
|
| 754 |
+
pendingBlockCreates: state.pendingBlockCreates.filter(
|
| 755 |
+
(create) =>
|
| 756 |
+
!(create.fileId === activeFileId && processedBlockIds.has(create.blockId))
|
| 757 |
+
),
|
| 758 |
+
}));
|
| 759 |
+
// Update blocksXml in the store so the blocksXmlForFile comparison in the
|
| 760 |
+
// workspace-reload effect matches the current workspace, preventing a
|
| 761 |
+
// destructive clear+reload that could conflict.
|
| 762 |
+
if (created) {
|
| 763 |
+
const dom = Blockly.Xml.workspaceToDom(ws);
|
| 764 |
+
const xml = Blockly.Xml.domToText(dom);
|
| 765 |
+
useEditorStore.getState().setBlocksXml(activeFileId, xml);
|
| 766 |
+
// saveBlocks skips while remoteUpdateRef is set, so refresh the compiled
|
| 767 |
+
// code explicitly after creating collaborator blocks.
|
| 768 |
+
refreshCodeForWorkspace(ws, activeFileId);
|
| 769 |
+
requestAnimationFrame(() => {
|
| 770 |
+
try { syncCustomFunctionBlocks(ws); } catch {}
|
| 771 |
+
try { rebuildToolbox(ws); } catch {}
|
| 772 |
+
});
|
| 773 |
+
}
|
| 774 |
+
requestAnimationFrame(() => { setTimeout(finishRemoteUpdate, 0); });
|
| 775 |
+
}, [pendingBlockCreates, blocksXmlForFile, activeFileId, remoteSyncTick, finishRemoteUpdate]);
|
| 776 |
|
| 777 |
// Collaborator block selection overlay
|
| 778 |
const collaborators = useCollaborationStore((s) => s.collaborators);
|
client/src/components/ProjectEditor/ProjectEditor.tsx
CHANGED
|
@@ -74,13 +74,15 @@ export default function ProjectEditor() {
|
|
| 74 |
// snapshot, silently discarding local and collaborator edits.
|
| 75 |
const hydratedProjectIdRef = useRef<string | null>(null);
|
| 76 |
const resetProjectState = useEditorStore((s) => s.resetProjectState);
|
|
|
|
| 77 |
|
| 78 |
// Declared before `useProjectSocket` so it runs before the new room's initial
|
| 79 |
// block state arrives. Per-file caches are keyed by id and default files
|
| 80 |
// repeat across projects, so stale entries must be cleared first.
|
| 81 |
useEffect(() => {
|
| 82 |
resetProjectState();
|
| 83 |
-
|
|
|
|
| 84 |
|
| 85 |
const { emitFileChanged, emitActiveFileChanged, emitSave, emitCursorMove } = useProjectSocket(id);
|
| 86 |
|
|
|
|
| 74 |
// snapshot, silently discarding local and collaborator edits.
|
| 75 |
const hydratedProjectIdRef = useRef<string | null>(null);
|
| 76 |
const resetProjectState = useEditorStore((s) => s.resetProjectState);
|
| 77 |
+
const resetCollaborationState = useCollaborationStore((s) => s.resetTransientState);
|
| 78 |
|
| 79 |
// Declared before `useProjectSocket` so it runs before the new room's initial
|
| 80 |
// block state arrives. Per-file caches are keyed by id and default files
|
| 81 |
// repeat across projects, so stale entries must be cleared first.
|
| 82 |
useEffect(() => {
|
| 83 |
resetProjectState();
|
| 84 |
+
resetCollaborationState();
|
| 85 |
+
}, [id, resetProjectState, resetCollaborationState]);
|
| 86 |
|
| 87 |
const { emitFileChanged, emitActiveFileChanged, emitSave, emitCursorMove } = useProjectSocket(id);
|
| 88 |
|
client/src/hooks/useWebSocket.ts
CHANGED
|
@@ -228,15 +228,11 @@ export function useWebSocket() {
|
|
| 228 |
s.on('collaborator_block_create_event', ({ projectId, blockId, fileId, xml }: { projectId?: string; blockId: string; fileId: string; xml: string }) => {
|
| 229 |
if (!isCurrentProject(projectId)) return;
|
| 230 |
const store = useEditorStore.getState();
|
| 231 |
-
console.log('[WS] collaborator_block_create_event received:', { blockId, fileId, activeFileId: store.activeFileId, xmlLength: xml?.length });
|
| 232 |
// Only apply to the currently active file; otherwise the block would appear
|
| 233 |
// on the wrong file's workspace. The full XML sync (block_state_synced) will
|
| 234 |
// handle it when the collaborator switches to that file.
|
| 235 |
if (store.activeFileId === fileId) {
|
| 236 |
-
|
| 237 |
-
useCollaborationStore.getState().pushPendingBlockCreate({ blockId, xml });
|
| 238 |
-
} else {
|
| 239 |
-
console.log('[WS] skipping block create - fileId mismatch:', { eventFileId: fileId, activeFileId: store.activeFileId });
|
| 240 |
}
|
| 241 |
});
|
| 242 |
|
|
|
|
| 228 |
s.on('collaborator_block_create_event', ({ projectId, blockId, fileId, xml }: { projectId?: string; blockId: string; fileId: string; xml: string }) => {
|
| 229 |
if (!isCurrentProject(projectId)) return;
|
| 230 |
const store = useEditorStore.getState();
|
|
|
|
| 231 |
// Only apply to the currently active file; otherwise the block would appear
|
| 232 |
// on the wrong file's workspace. The full XML sync (block_state_synced) will
|
| 233 |
// handle it when the collaborator switches to that file.
|
| 234 |
if (store.activeFileId === fileId) {
|
| 235 |
+
useCollaborationStore.getState().pushPendingBlockCreate({ blockId, fileId, xml });
|
|
|
|
|
|
|
|
|
|
| 236 |
}
|
| 237 |
});
|
| 238 |
|
client/src/store/collaborationStore.ts
CHANGED
|
@@ -40,7 +40,7 @@ interface CollaborationStore {
|
|
| 40 |
remoteBlockDrags: Record<string, RemoteBlockDrag>;
|
| 41 |
pendingBlockConnects: Array<{ childId: string; parentId: string; inputName?: string }>;
|
| 42 |
pendingBlockChanges: Array<{ blockId: string; name: string; value: any }>;
|
| 43 |
-
pendingBlockCreates: Array<{ blockId: string; xml: string }>;
|
| 44 |
setConnected: (connected: boolean) => void;
|
| 45 |
setCollaborators: (collaborators: Collaborator[]) => void;
|
| 46 |
setOwner: (owner: CollaboratorOwner) => void;
|
|
@@ -62,8 +62,8 @@ interface CollaborationStore {
|
|
| 62 |
shiftPendingBlockConnect: () => { childId: string; parentId: string; inputName?: string } | undefined;
|
| 63 |
pushPendingBlockChange: (data: { blockId: string; name: string; value: any }) => void;
|
| 64 |
shiftPendingBlockChange: () => { blockId: string; name: string; value: any } | undefined;
|
| 65 |
-
pushPendingBlockCreate: (data: { blockId: string; xml: string }) => void;
|
| 66 |
-
|
| 67 |
getCollaboratorsOnFile: (fileId: string) => Collaborator[];
|
| 68 |
getCollaboratorsOnBlock: (blockId: string) => Collaborator[];
|
| 69 |
getCollaboratorsOnElement: (elementId: string) => Collaborator[];
|
|
@@ -152,6 +152,18 @@ export const useCollaborationStore = create<CollaborationStore>((set, get) => ({
|
|
| 152 |
|
| 153 |
setSavedBy: (username) => set({ savedBy: username }),
|
| 154 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 155 |
getCollaboratorsOnFile: (fileId) => {
|
| 156 |
return get().collaborators.filter((c) => c.activeFileId === fileId);
|
| 157 |
},
|
|
@@ -234,12 +246,4 @@ export const useCollaborationStore = create<CollaborationStore>((set, get) => ({
|
|
| 234 |
set((state) => ({
|
| 235 |
pendingBlockCreates: [...state.pendingBlockCreates, data],
|
| 236 |
})),
|
| 237 |
-
shiftPendingBlockCreate: () => {
|
| 238 |
-
const state = get();
|
| 239 |
-
const [first, ...rest] = state.pendingBlockCreates;
|
| 240 |
-
if (first) {
|
| 241 |
-
set({ pendingBlockCreates: rest });
|
| 242 |
-
}
|
| 243 |
-
return first;
|
| 244 |
-
},
|
| 245 |
}));
|
|
|
|
| 40 |
remoteBlockDrags: Record<string, RemoteBlockDrag>;
|
| 41 |
pendingBlockConnects: Array<{ childId: string; parentId: string; inputName?: string }>;
|
| 42 |
pendingBlockChanges: Array<{ blockId: string; name: string; value: any }>;
|
| 43 |
+
pendingBlockCreates: Array<{ blockId: string; fileId: string; xml: string }>;
|
| 44 |
setConnected: (connected: boolean) => void;
|
| 45 |
setCollaborators: (collaborators: Collaborator[]) => void;
|
| 46 |
setOwner: (owner: CollaboratorOwner) => void;
|
|
|
|
| 62 |
shiftPendingBlockConnect: () => { childId: string; parentId: string; inputName?: string } | undefined;
|
| 63 |
pushPendingBlockChange: (data: { blockId: string; name: string; value: any }) => void;
|
| 64 |
shiftPendingBlockChange: () => { blockId: string; name: string; value: any } | undefined;
|
| 65 |
+
pushPendingBlockCreate: (data: { blockId: string; fileId: string; xml: string }) => void;
|
| 66 |
+
resetTransientState: () => void;
|
| 67 |
getCollaboratorsOnFile: (fileId: string) => Collaborator[];
|
| 68 |
getCollaboratorsOnBlock: (blockId: string) => Collaborator[];
|
| 69 |
getCollaboratorsOnElement: (elementId: string) => Collaborator[];
|
|
|
|
| 152 |
|
| 153 |
setSavedBy: (username) => set({ savedBy: username }),
|
| 154 |
|
| 155 |
+
// Queued block operations and remote drag markers are scoped to the project
|
| 156 |
+
// that produced them. Block ids and file ids are not globally unique enough
|
| 157 |
+
// to let them survive a project switch.
|
| 158 |
+
resetTransientState: () =>
|
| 159 |
+
set({
|
| 160 |
+
remotePointers: {},
|
| 161 |
+
remoteBlockDrags: {},
|
| 162 |
+
pendingBlockConnects: [],
|
| 163 |
+
pendingBlockChanges: [],
|
| 164 |
+
pendingBlockCreates: [],
|
| 165 |
+
}),
|
| 166 |
+
|
| 167 |
getCollaboratorsOnFile: (fileId) => {
|
| 168 |
return get().collaborators.filter((c) => c.activeFileId === fileId);
|
| 169 |
},
|
|
|
|
| 246 |
set((state) => ({
|
| 247 |
pendingBlockCreates: [...state.pendingBlockCreates, data],
|
| 248 |
})),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 249 |
}));
|