fix: bidirectional sync conflict and labeled element update rendering

- Update lastSyncedElementsRef on every WS-applied scene change so
  auto-sync does not revert MCP writes back to stale browser state
- Fix labeled container updates (rect/ellipse/diamond/arrow) to use
  convertToExcalidrawElements with bound-text ID transplant, preventing
  text clipping and empty labels after update
- Fix standalone text element updates to write into text/originalText
  so Excalidraw renders the new value immediately
- Fix convertTextToLabel to handle arrows and empty string text values

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
newblacc
2026-03-30 00:23:41 +02:00
co-authored by Claude Sonnet 4.6
parent d838b67e31
commit d993355a54
3 changed files with 84 additions and 16 deletions
+8
View File
@@ -53,3 +53,11 @@ Format: [Keep a Changelog](https://keepachangelog.com/en/1.0.0/)
- `getAllFilesObject()` helper extracted; `sendFilesAdded()` and `GET /api/files` share it
- `sendLegacyInitialWsMessages` renamed to `sendAuthlessInitialMessages`
- `.project-hooks/pre-commit` added to run vitest on every commit
## [Unreleased] - 2026-03-30
### Fixed
- Bidirectional sync conflict: WS-applied updates no longer reverted by browser auto-sync (lastSyncedElementsRef now updated on element_updated, element_deleted, elements_batch_created)
- Labeled container updates (rectangle, ellipse, diamond, arrow) now use convertToExcalidrawElements with ID transplant for correct text layout instead of in-place text patch that caused clipping
- Standalone text element updates now write label.text into text/originalText fields so Excalidraw renders the new value
- convertTextToLabel now maps text→label for arrows and empty strings (previously skipped falsy text)
+66 -3
View File
@@ -521,14 +521,67 @@ function App(): JSX.Element {
case 'element_updated':
if (data.element) {
const cleanedUpdatedElement = cleanElementForExcalidraw(data.element)
const convertedUpdatedElement = convertToExcalidrawElements([cleanedUpdatedElement], { regenerateIds: false })[0]
const updatedElements = currentElements.map(el =>
el.id === data.element!.id ? convertedUpdatedElement : el
const newLabelText = data.element.label?.text
const isLabeledContainer = newLabelText !== undefined &&
['rectangle', 'ellipse', 'diamond', 'arrow'].includes(data.element.type)
const isTextElement = data.element.type === 'text'
let updatedElements: any[]
if (isLabeledContainer) {
// Use convertToExcalidrawElements for correct text layout/metrics, but
// transplant the existing bound text element's ID so Excalidraw's internal
// state stays coherent (avoids orphan references and text clipping).
const existingBoundText = currentElements.find(
el => (el as any).containerId === data.element!.id
)
const convertedAll = convertToExcalidrawElements([cleanedUpdatedElement], { regenerateIds: false })
const convertedContainer = convertedAll[0] as any
const convertedBoundText = (convertedAll[1] ?? null) as any
if (existingBoundText && convertedBoundText) {
// Transplant existing bound text ID so container → text link is stable
const patchedBoundText = { ...convertedBoundText, id: (existingBoundText as any).id }
const patchedContainer = {
...convertedContainer,
boundElements: [{ id: (existingBoundText as any).id, type: 'text' }]
}
updatedElements = [
...currentElements.filter(el => el.id !== data.element!.id && el.id !== (existingBoundText as any).id),
patchedContainer,
patchedBoundText
]
} else {
// No existing bound text — use converted result as-is
updatedElements = [
...currentElements.filter(el => el.id !== data.element!.id && (el as any).containerId !== data.element!.id),
...(convertedBoundText ? [convertedContainer, convertedBoundText] : [convertedContainer])
]
}
} else if (isTextElement) {
// For standalone text elements: write label.text into the text field
const textValue = newLabelText ?? (data.element as any).text ?? ''
updatedElements = currentElements.map(el =>
el.id === data.element!.id
? { ...(el as any), ...cleanedUpdatedElement, text: textValue, originalText: textValue }
: el
)
} else {
// Generic element (arrows, etc.)
const convertedAll = convertToExcalidrawElements([cleanedUpdatedElement], { regenerateIds: false })
updatedElements = currentElements
.filter(el => (el as any).containerId !== data.element!.id)
.map(el => el.id === data.element!.id ? convertedAll[0] : el)
}
api.updateScene({
elements: updatedElements,
captureUpdate: CaptureUpdateAction.NEVER
})
// Update sync baseline so auto-sync doesn't overwrite this WS-applied change
const wsUpdatedBaseline = new Map(lastSyncedElementsRef.current)
wsUpdatedBaseline.set(data.element.id, data.element)
lastSyncedElementsRef.current = wsUpdatedBaseline
sendAck(data.msgId, 'applied', 1, 1)
}
break
@@ -540,6 +593,10 @@ function App(): JSX.Element {
elements: filteredElements,
captureUpdate: CaptureUpdateAction.NEVER
})
// Remove from sync baseline so auto-sync doesn't re-create it
const wsDeletedBaseline = new Map(lastSyncedElementsRef.current)
wsDeletedBaseline.delete(data.elementId)
lastSyncedElementsRef.current = wsDeletedBaseline
sendAck(data.msgId, 'applied', 1, 1)
}
break
@@ -568,6 +625,12 @@ function App(): JSX.Element {
const expectedIds = data.elements.map((e: ServerElement) => e.id)
const landedCount = expectedIds.filter(id => scene.some(s => s.id === id)).length
const status = landedCount === expectedIds.length ? 'applied' : landedCount > 0 ? 'partial' : 'failed'
// Update sync baseline so auto-sync doesn't treat these as new local changes
const wsBatchBaseline = new Map(lastSyncedElementsRef.current)
for (const el of data.elements) {
wsBatchBaseline.set(el.id, el)
}
lastSyncedElementsRef.current = wsBatchBaseline
sendAck(data.msgId, status, landedCount, expectedIds.length)
}
break
+5 -8
View File
@@ -1033,18 +1033,15 @@ const server = new Server(
// Helper function to convert text property to label format for Excalidraw
function convertTextToLabel(element: ServerElement): ServerElement {
const { text, ...rest } = element;
if (text) {
// For standalone text elements, keep text as direct property
if (element.type === 'text') {
return element; // Keep text as direct property
}
// For other elements (rectangle, ellipse, diamond), convert to label format
// text === undefined means the caller didn't touch the text field — leave as-is
if (text === undefined) return element;
// Standalone text elements keep text as a direct property
if (element.type === 'text') return element;
// All container/shape/arrow elements: map text → label.text (empty string clears it)
return {
...rest,
label: { text }
} as ServerElement;
}
return element;
}
// Set up request handler for tool calls