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:
co-authored by
Claude Sonnet 4.6
parent
d838b67e31
commit
d993355a54
@@ -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
|
- `getAllFilesObject()` helper extracted; `sendFilesAdded()` and `GET /api/files` share it
|
||||||
- `sendLegacyInitialWsMessages` renamed to `sendAuthlessInitialMessages`
|
- `sendLegacyInitialWsMessages` renamed to `sendAuthlessInitialMessages`
|
||||||
- `.project-hooks/pre-commit` added to run vitest on every commit
|
- `.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)
|
||||||
|
|||||||
+67
-4
@@ -521,14 +521,67 @@ function App(): JSX.Element {
|
|||||||
case 'element_updated':
|
case 'element_updated':
|
||||||
if (data.element) {
|
if (data.element) {
|
||||||
const cleanedUpdatedElement = cleanElementForExcalidraw(data.element)
|
const cleanedUpdatedElement = cleanElementForExcalidraw(data.element)
|
||||||
const convertedUpdatedElement = convertToExcalidrawElements([cleanedUpdatedElement], { regenerateIds: false })[0]
|
const newLabelText = data.element.label?.text
|
||||||
const updatedElements = currentElements.map(el =>
|
const isLabeledContainer = newLabelText !== undefined &&
|
||||||
el.id === data.element!.id ? convertedUpdatedElement : el
|
['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({
|
api.updateScene({
|
||||||
elements: updatedElements,
|
elements: updatedElements,
|
||||||
captureUpdate: CaptureUpdateAction.NEVER
|
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)
|
sendAck(data.msgId, 'applied', 1, 1)
|
||||||
}
|
}
|
||||||
break
|
break
|
||||||
@@ -540,6 +593,10 @@ function App(): JSX.Element {
|
|||||||
elements: filteredElements,
|
elements: filteredElements,
|
||||||
captureUpdate: CaptureUpdateAction.NEVER
|
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)
|
sendAck(data.msgId, 'applied', 1, 1)
|
||||||
}
|
}
|
||||||
break
|
break
|
||||||
@@ -568,6 +625,12 @@ function App(): JSX.Element {
|
|||||||
const expectedIds = data.elements.map((e: ServerElement) => e.id)
|
const expectedIds = data.elements.map((e: ServerElement) => e.id)
|
||||||
const landedCount = expectedIds.filter(id => scene.some(s => s.id === id)).length
|
const landedCount = expectedIds.filter(id => scene.some(s => s.id === id)).length
|
||||||
const status = landedCount === expectedIds.length ? 'applied' : landedCount > 0 ? 'partial' : 'failed'
|
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)
|
sendAck(data.msgId, status, landedCount, expectedIds.length)
|
||||||
}
|
}
|
||||||
break
|
break
|
||||||
|
|||||||
+9
-12
@@ -1033,18 +1033,15 @@ const server = new Server(
|
|||||||
// Helper function to convert text property to label format for Excalidraw
|
// Helper function to convert text property to label format for Excalidraw
|
||||||
function convertTextToLabel(element: ServerElement): ServerElement {
|
function convertTextToLabel(element: ServerElement): ServerElement {
|
||||||
const { text, ...rest } = element;
|
const { text, ...rest } = element;
|
||||||
if (text) {
|
// text === undefined means the caller didn't touch the text field — leave as-is
|
||||||
// For standalone text elements, keep text as direct property
|
if (text === undefined) return element;
|
||||||
if (element.type === 'text') {
|
// Standalone text elements keep text as a direct property
|
||||||
return element; // Keep text as direct property
|
if (element.type === 'text') return element;
|
||||||
}
|
// All container/shape/arrow elements: map text → label.text (empty string clears it)
|
||||||
// For other elements (rectangle, ellipse, diamond), convert to label format
|
return {
|
||||||
return {
|
...rest,
|
||||||
...rest,
|
label: { text }
|
||||||
label: { text }
|
} as ServerElement;
|
||||||
} as ServerElement;
|
|
||||||
}
|
|
||||||
return element;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set up request handler for tool calls
|
// Set up request handler for tool calls
|
||||||
|
|||||||
Reference in New Issue
Block a user