()
for (const el of result.elements) {
@@ -475,6 +618,12 @@ function App(): JSX.Element {
const cleanedElements = data.elements.map(cleanElementForExcalidraw)
const validatedElements = validateAndFixBindings(cleanedElements)
const convertedElements = convertElementsPreservingImageProps(validatedElements)
+ // Seed known containers before updateScene
+ for (const el of convertedElements) {
+ if (CONTAINER_TYPES.has((el as any).type)) {
+ knownContainerIdsRef.current.add((el as any).id)
+ }
+ }
api.updateScene({
elements: convertedElements,
captureUpdate: CaptureUpdateAction.NEVER
@@ -522,6 +671,11 @@ function App(): JSX.Element {
const cleanedElements = data.elements.map(cleanElementForExcalidraw)
const validatedElements = validateAndFixBindings(cleanedElements)
const convertedElements = convertElementsPreservingImageProps(validatedElements)
+ for (const el of convertedElements) {
+ if (CONTAINER_TYPES.has((el as any).type)) {
+ knownContainerIdsRef.current.add((el as any).id)
+ }
+ }
api.updateScene({
elements: convertedElements,
captureUpdate: CaptureUpdateAction.NEVER
@@ -952,18 +1106,14 @@ function App(): JSX.Element {
const result: ServerElement[] = []
for (const el of elements) {
- if (boundTextIds.has(el.id)) continue // skip bound text — merged into container
-
+ // Keep bound text elements as-is — store native Excalidraw format
+ // so x/y/width/height survive round-trips without recalculation
const out: any = { ...el }
- // If this container has bound text, put it back as label.text
- const merged = containerTextMap.get(el.id)
- if (merged && merged.text) {
- out.label = { text: merged.text }
- if (merged.fontSize) out.fontSize = merged.fontSize
- if (merged.fontFamily) out.fontFamily = merged.fontFamily
- // Clean up Excalidraw-internal binding metadata
- delete out.boundElements
+ // Strip label.text from containers that have native bound text,
+ // so the load path doesn't double-create text elements
+ if (containerTextMap.has(el.id)) {
+ delete out.label
}
// Normalize arrow bindings from Excalidraw format back to MCP format
@@ -1112,11 +1262,15 @@ function App(): JSX.Element {
merged = merged.filter(el => el.id !== sc.id)
} else if (sc.element) {
const cleaned = cleanElementForExcalidraw(sc.element)
- const converted = convertToExcalidrawElements([cleaned], { regenerateIds: false })
const idx = merged.findIndex(el => el.id === sc.id)
+
if (idx >= 0) {
- merged[idx] = converted[0]!
+ // Existing element: spread-merge to preserve geometry and
+ // Excalidraw internals (seed, version, versionNonce)
+ merged[idx] = { ...merged[idx], ...cleaned } as any
} else {
+ // New element from MCP/other tab: must convert to get proper internals
+ const converted = convertToExcalidrawElements([cleaned], { regenerateIds: false })
merged.push(...converted)
}
}
@@ -1333,6 +1487,53 @@ function App(): JSX.Element {
)}
+ {/* Draggable font size widget */}
+ {
+ // Don't drag when clicking input or button
+ if ((e.target as HTMLElement).tagName === 'INPUT' || (e.target as HTMLElement).tagName === 'BUTTON') return
+ setIsDragging(true)
+ dragOffset.current = { x: e.clientX - widgetPos.x, y: e.clientY - widgetPos.y }
+ const onMove = (ev: MouseEvent) => {
+ const newPos = { x: ev.clientX - dragOffset.current.x, y: ev.clientY - dragOffset.current.y }
+ setWidgetPos(newPos)
+ }
+ const onUp = () => {
+ setIsDragging(false)
+ setWidgetPos(prev => {
+ localStorage.setItem('font-widget-pos', JSON.stringify(prev))
+ return prev
+ })
+ window.removeEventListener('mousemove', onMove)
+ window.removeEventListener('mouseup', onUp)
+ }
+ window.addEventListener('mousemove', onMove)
+ window.addEventListener('mouseup', onUp)
+ }}
+ >
+
+ setCustomFontSize(e.target.value)}
+ onKeyDown={e => {
+ if (e.key === 'Enter') {
+ const size = parseInt(customFontSize, 10)
+ if (size > 0) applyCustomFontSize(size)
+ }
+ }}
+ />
+
+
+
{/* Canvas Container */}
{
it('401 with bad API key is still rate-limited', async () => {
const ip = '10.20.0.3';
- for (let i = 0; i < 100; i++) {
+ for (let i = 0; i < 500; i++) {
await request(app)
.get('/api/elements')
.set('X-API-Key', 'wrong-key')
diff --git a/tests/backend/rate-limit.test.ts b/tests/backend/rate-limit.test.ts
index d12b238..6928c5a 100644
--- a/tests/backend/rate-limit.test.ts
+++ b/tests/backend/rate-limit.test.ts
@@ -105,7 +105,7 @@ describe('Rate limiting — destructive endpoints', () => {
describe('Rate limiting — sync endpoints', () => {
it('returns 429 after exceeding /api/elements/sync write-burst limit', async () => {
const ip = '10.10.0.1';
- for (let i = 0; i < 10; i++) {
+ for (let i = 0; i < 30; i++) {
await request(app)
.post('/api/elements/sync')
.set('X-Forwarded-For', ip)
@@ -122,7 +122,7 @@ describe('Rate limiting — sync endpoints', () => {
it('returns 429 after exceeding /api/elements/sync/v2 write-burst limit', async () => {
const ip = '10.10.0.2';
- for (let i = 0; i < 10; i++) {
+ for (let i = 0; i < 30; i++) {
await request(app)
.post('/api/elements/sync/v2')
.set('X-Forwarded-For', ip)
@@ -139,7 +139,7 @@ describe('Rate limiting — sync endpoints', () => {
it('sync 429 responses include rate-limit headers', async () => {
const ip = '10.10.0.3';
- for (let i = 0; i < 10; i++) {
+ for (let i = 0; i < 30; i++) {
await request(app)
.post('/api/elements/sync')
.set('X-Forwarded-For', ip)