feat: auto title/subtitle, Nunito default, position-preserving sync, draggable font widget
- Default font changed to Nunito (id 6) - Containers auto-inject "Title" + "Text here" subtitle on draw (grouped) - MCP create_element defaults to title/subtitle card layout for containers - Sync preserves element geometry (x/y/width/height) across refreshes - normalizeForBackend preserves native bound text instead of collapsing to label.text - Rate limits raised to 500 req/15min general, 30 req/min sync writes - Draggable "Font px" widget with localStorage position persistence - Left panel widened for full Opacity visibility - Updated rate-limit tests to match new limits (30 write, 500 general) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
1e535a5e31
commit
a605a15282
@@ -108,6 +108,63 @@
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Widen Excalidraw left properties panel */
|
||||
.App-menu_left .Island {
|
||||
min-width: 260px;
|
||||
}
|
||||
|
||||
/* Draggable font size widget */
|
||||
.custom-font-size-widget {
|
||||
position: fixed;
|
||||
z-index: 100;
|
||||
background: var(--island-bg-color, #232329);
|
||||
border-radius: 8px;
|
||||
padding: 6px 10px;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.4);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
cursor: grab;
|
||||
user-select: none;
|
||||
}
|
||||
.custom-font-size-widget.dragging {
|
||||
cursor: grabbing;
|
||||
opacity: 0.9;
|
||||
}
|
||||
.custom-font-size-widget label {
|
||||
font-size: 12px;
|
||||
color: #aaa;
|
||||
white-space: nowrap;
|
||||
cursor: grab;
|
||||
}
|
||||
.custom-font-size-widget input[type="number"] {
|
||||
width: 52px;
|
||||
padding: 4px 6px;
|
||||
font-size: 13px;
|
||||
border: 1px solid #555;
|
||||
border-radius: 4px;
|
||||
background: #1a1a2e;
|
||||
color: #fff;
|
||||
text-align: center;
|
||||
cursor: text;
|
||||
}
|
||||
.custom-font-size-widget input[type="number"]:focus {
|
||||
outline: none;
|
||||
border-color: #4a6cf7;
|
||||
}
|
||||
.custom-font-size-widget button {
|
||||
padding: 4px 10px;
|
||||
font-size: 12px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
background: #4a6cf7;
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
}
|
||||
.custom-font-size-widget button:hover {
|
||||
background: #3a5ce5;
|
||||
}
|
||||
|
||||
|
||||
.api-panel {
|
||||
position: fixed;
|
||||
|
||||
+222
-21
@@ -87,6 +87,21 @@ function App(): JSX.Element {
|
||||
|
||||
const DEBOUNCE_MS = 3000
|
||||
|
||||
// Track known container IDs to auto-inject title on new shapes
|
||||
const knownContainerIdsRef = useRef<Set<string>>(new Set())
|
||||
const CONTAINER_TYPES = new Set(['rectangle', 'ellipse', 'diamond'])
|
||||
|
||||
// Custom font size input state
|
||||
const [customFontSize, setCustomFontSize] = useState<string>('')
|
||||
|
||||
// Draggable widget state — default near top menu
|
||||
const [widgetPos, setWidgetPos] = useState<{x: number, y: number}>(() => {
|
||||
const saved = localStorage.getItem('font-widget-pos')
|
||||
return saved ? JSON.parse(saved) : { x: window.innerWidth * 0.55, y: 90 }
|
||||
})
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
const dragOffset = useRef<{x: number, y: number}>({x: 0, y: 0})
|
||||
|
||||
// Tenant state
|
||||
const [activeTenant, setActiveTenant] = useState<TenantInfo | null>(null)
|
||||
const activeTenantIdRef = useRef<string | null>(null)
|
||||
@@ -157,9 +172,117 @@ function App(): JSX.Element {
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Apply custom font size to selected elements
|
||||
const applyCustomFontSize = (size: number): void => {
|
||||
const api = excalidrawAPIRef.current
|
||||
if (!api || !size || size < 1) return
|
||||
|
||||
const appState = api.getAppState()
|
||||
const selectedIds = appState.selectedElementIds || {}
|
||||
const scene = api.getSceneElements()
|
||||
const updated = scene.map((el: any) => {
|
||||
if (selectedIds[el.id] && (el.type === 'text' || (el as any).fontSize !== undefined)) {
|
||||
return { ...el, fontSize: size }
|
||||
}
|
||||
return el
|
||||
})
|
||||
api.updateScene({ elements: updated, captureUpdate: CaptureUpdateAction.IMMEDIATELY })
|
||||
}
|
||||
|
||||
// Pending title injection — deferred to avoid updateScene inside onChange
|
||||
const pendingTitleTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
// Trailing debounce: resets on every change, fires after user is idle.
|
||||
// Only active when auto-save is on.
|
||||
const handleCanvasChange = (): void => {
|
||||
// Auto-inject title into new containers (rectangle, ellipse, diamond)
|
||||
// Deferred: collect candidates, inject after onChange completes
|
||||
if (pendingTitleTimerRef.current) clearTimeout(pendingTitleTimerRef.current)
|
||||
pendingTitleTimerRef.current = setTimeout(() => {
|
||||
const api = excalidrawAPIRef.current
|
||||
if (!api) return
|
||||
|
||||
const elements = api.getSceneElements()
|
||||
const newContainers: typeof elements[number][] = []
|
||||
|
||||
for (const el of elements) {
|
||||
if (
|
||||
CONTAINER_TYPES.has(el.type) &&
|
||||
!el.isDeleted &&
|
||||
!knownContainerIdsRef.current.has(el.id) &&
|
||||
el.width > 30 && el.height > 30
|
||||
) {
|
||||
const hasBoundText = (el as any).boundElements?.some((b: any) => b.type === 'text')
|
||||
if (!hasBoundText) {
|
||||
newContainers.push(el)
|
||||
}
|
||||
knownContainerIdsRef.current.add(el.id)
|
||||
}
|
||||
}
|
||||
|
||||
if (newContainers.length > 0) {
|
||||
const scene = api.getSceneElements()
|
||||
const updated = [...scene] as any[]
|
||||
|
||||
for (const container of newContainers) {
|
||||
const groupId = `${container.id}_group`
|
||||
const textId = `${container.id}_title`
|
||||
const subtitleId = `${container.id}_subtitle`
|
||||
|
||||
// Center-based positioning — works for all shapes
|
||||
const cx = container.x + container.width / 2
|
||||
const cy = container.y + container.height / 2
|
||||
|
||||
// Title — 15% above center
|
||||
const titleConverted = convertToExcalidrawElements([{
|
||||
type: 'text' as const,
|
||||
id: textId,
|
||||
x: cx,
|
||||
y: cy - container.height * 0.15,
|
||||
text: 'Title',
|
||||
fontSize: 24,
|
||||
fontFamily: 6,
|
||||
textAlign: 'center' as const,
|
||||
strokeColor: '#1e1e1e',
|
||||
}], { regenerateIds: false })
|
||||
const titleText = titleConverted.map((el: any) => ({
|
||||
...el,
|
||||
groupIds: [groupId],
|
||||
}))
|
||||
|
||||
// Subtitle — 10% below center
|
||||
const subtitleConverted = convertToExcalidrawElements([{
|
||||
type: 'text' as const,
|
||||
id: subtitleId,
|
||||
x: cx,
|
||||
y: cy + container.height * 0.10,
|
||||
text: 'Text here',
|
||||
fontSize: 16,
|
||||
fontFamily: 6,
|
||||
textAlign: 'center' as const,
|
||||
strokeColor: '#868e96',
|
||||
}], { regenerateIds: false })
|
||||
const subtitleText = subtitleConverted.map((el: any) => ({
|
||||
...el,
|
||||
groupIds: [groupId],
|
||||
}))
|
||||
|
||||
// Add group to container
|
||||
const idx = updated.findIndex((e: any) => e.id === container.id)
|
||||
if (idx >= 0) {
|
||||
const existingGroups = (updated[idx] as any).groupIds || []
|
||||
updated[idx] = {
|
||||
...updated[idx],
|
||||
groupIds: [...existingGroups, groupId]
|
||||
}
|
||||
}
|
||||
updated.push(...titleText, ...subtitleText)
|
||||
}
|
||||
|
||||
api.updateScene({ elements: updated, captureUpdate: CaptureUpdateAction.IMMEDIATELY })
|
||||
}
|
||||
}, 300) // 300ms delay — fires after drawing finishes
|
||||
|
||||
if (!autoSave) return
|
||||
|
||||
if (debounceTimerRef.current) clearTimeout(debounceTimerRef.current)
|
||||
@@ -167,8 +290,8 @@ function App(): JSX.Element {
|
||||
debounceTimerRef.current = setTimeout(() => {
|
||||
if (!excalidrawAPI || isSyncingRef.current) return
|
||||
|
||||
const elements = excalidrawAPI.getSceneElements()
|
||||
const hash = computeElementHash(elements)
|
||||
const currentElements = excalidrawAPI.getSceneElements()
|
||||
const hash = computeElementHash(currentElements)
|
||||
if (hash === lastSyncedHashRef.current) return
|
||||
|
||||
syncToBackend()
|
||||
@@ -243,18 +366,38 @@ function App(): JSX.Element {
|
||||
return
|
||||
}
|
||||
const cleanedElements = result.elements.map(cleanElementForExcalidraw)
|
||||
// Save original geometry for all DB elements — convertToExcalidrawElements
|
||||
// recalculates metrics and shifts positions for text, containers, and arrows
|
||||
const originalGeometry = new Map<string, { x: number; y: number; width: number; height: number }>()
|
||||
for (const el of cleanedElements) {
|
||||
if (el.x != null && el.y != null) {
|
||||
originalGeometry.set(el.id, { x: el.x, y: el.y, width: (el as any).width ?? 0, height: (el as any).height ?? 0 })
|
||||
}
|
||||
}
|
||||
// Expand server-format label.text into native Excalidraw bound text
|
||||
// elements so labels survive round-trips through the DB.
|
||||
const expandedElements = expandLabelsToNative(cleanedElements)
|
||||
const hasNativeFormat = expandedElements.some((el: any) => el.containerId)
|
||||
if (hasNativeFormat) {
|
||||
const validated = validateAndFixBindings(expandedElements)
|
||||
excalidrawAPI?.updateScene({ elements: validated as any })
|
||||
} else {
|
||||
const convertedElements = convertElementsPreservingImageProps(expandedElements)
|
||||
excalidrawAPI?.updateScene({ elements: convertedElements })
|
||||
// Convert through Excalidraw to get proper element objects
|
||||
// (with seed, version, versionNonce, etc.)
|
||||
const convertedElements = convertElementsPreservingImageProps(expandedElements)
|
||||
// Restore original geometry for elements that existed in the DB
|
||||
// (skip synthetic elements created by expandLabelsToNative)
|
||||
const finalElements = convertedElements.map((el: any) => {
|
||||
const orig = originalGeometry.get(el.id)
|
||||
if (orig) {
|
||||
return { ...el, x: orig.x, y: orig.y, width: orig.width, height: orig.height }
|
||||
}
|
||||
return el
|
||||
})
|
||||
// Seed known containers BEFORE updateScene so onChange doesn't re-inject titles
|
||||
for (const el of finalElements) {
|
||||
if (CONTAINER_TYPES.has((el as any).type)) {
|
||||
knownContainerIdsRef.current.add((el as any).id)
|
||||
}
|
||||
}
|
||||
|
||||
excalidrawAPI?.updateScene({ elements: finalElements })
|
||||
|
||||
// Populate sync baseline so deletions are detected on next sync
|
||||
const baselineMap = new Map<string, ServerElement>()
|
||||
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 {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Draggable font size widget */}
|
||||
<div
|
||||
className={`custom-font-size-widget${isDragging ? ' dragging' : ''}`}
|
||||
style={{ left: widgetPos.x, top: widgetPos.y }}
|
||||
onMouseDown={e => {
|
||||
// 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)
|
||||
}}
|
||||
>
|
||||
<label>Font px</label>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max="200"
|
||||
placeholder="size"
|
||||
value={customFontSize}
|
||||
onChange={e => setCustomFontSize(e.target.value)}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter') {
|
||||
const size = parseInt(customFontSize, 10)
|
||||
if (size > 0) applyCustomFontSize(size)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button onClick={() => {
|
||||
const size = parseInt(customFontSize, 10)
|
||||
if (size > 0) applyCustomFontSize(size)
|
||||
}}>Set</button>
|
||||
</div>
|
||||
|
||||
{/* Canvas Container */}
|
||||
<div className="canvas-container">
|
||||
<Excalidraw
|
||||
|
||||
@@ -9,5 +9,5 @@
|
||||
{ "id": 9, "name": "Liberation Sans", "label": "Liberation Sans", "aliases": ["liberation sans"], "legacy": true },
|
||||
{ "id": 1, "name": "Virgil", "label": "Virgil (legacy)", "aliases": ["virgil"], "legacy": true }
|
||||
],
|
||||
"defaultFontFamily": 5
|
||||
"defaultFontFamily": 6
|
||||
}
|
||||
|
||||
+94
-10
@@ -335,6 +335,14 @@ const ElementSchema = z.object({
|
||||
elbowed: z.boolean().optional(),
|
||||
startElementId: z.string().optional(),
|
||||
endElementId: z.string().optional(),
|
||||
textAlign: z.string().optional(),
|
||||
verticalAlign: z.string().optional(),
|
||||
title: z.string().optional(),
|
||||
titleFontSize: z.number().optional(),
|
||||
titleFontFamily: z.union([z.string(), z.number()]).optional(),
|
||||
subtitle: z.string().optional(),
|
||||
subtitleFontSize: z.number().optional(),
|
||||
subtitleFontFamily: z.union([z.string(), z.number()]).optional(),
|
||||
endArrowhead: z.string().optional(),
|
||||
startArrowhead: z.string().optional(),
|
||||
fileId: z.string().optional(),
|
||||
@@ -470,7 +478,7 @@ const DIAGRAM_DESIGN_GUIDE = `# Excalidraw Diagram Design Guide
|
||||
const tools: Tool[] = [
|
||||
{
|
||||
name: 'create_element',
|
||||
description: 'Create a new Excalidraw element. For arrows, use startElementId/endElementId to bind to shapes (auto-routes to edges).',
|
||||
description: 'Create a new Excalidraw element. For arrows, use startElementId/endElementId to bind to shapes (auto-routes to edges). For containers (rectangle, ellipse, diamond): use title+subtitle for a card layout with independent font styling — both are grouped so they move together.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
@@ -489,9 +497,17 @@ const tools: Tool[] = [
|
||||
strokeStyle: { type: 'string', description: 'Stroke style: solid, dashed, dotted' },
|
||||
roughness: { type: 'number' },
|
||||
opacity: { type: 'number' },
|
||||
text: { type: 'string' },
|
||||
text: { type: 'string', description: 'Simple label text (use title+subtitle instead for card layout)' },
|
||||
fontSize: { type: 'number' },
|
||||
fontFamily: { type: ['string', 'number'], description: FONT_FAMILY_DESCRIPTION },
|
||||
textAlign: { type: 'string', description: 'Text horizontal alignment: left, center, right (default: center)' },
|
||||
verticalAlign: { type: 'string', description: 'Text vertical alignment: top, middle (default: top for containers)' },
|
||||
title: { type: 'string', description: 'Title text for card layout (bound to container, moves with it)' },
|
||||
titleFontSize: { type: 'number', description: 'Title font size (default: 24)' },
|
||||
titleFontFamily: { type: ['string', 'number'], description: 'Title font family (default: Nunito). ' + FONT_FAMILY_DESCRIPTION },
|
||||
subtitle: { type: 'string', description: 'Subtitle/paragraph text (grouped with container, moves together)' },
|
||||
subtitleFontSize: { type: 'number', description: 'Subtitle font size (default: 16)' },
|
||||
subtitleFontFamily: { type: ['string', 'number'], description: 'Subtitle font family (default: Nunito). ' + FONT_FAMILY_DESCRIPTION },
|
||||
startElementId: { type: 'string', description: 'For arrows: ID of the element to bind the arrow start to. Arrow auto-routes to element edge.' },
|
||||
endElementId: { type: 'string', description: 'For arrows: ID of the element to bind the arrow end to. Arrow auto-routes to element edge.' },
|
||||
endArrowhead: { type: 'string', description: 'Arrowhead style at end: arrow, bar, dot, triangle, or null' },
|
||||
@@ -1038,8 +1054,11 @@ function convertTextToLabel(element: ServerElement): ServerElement {
|
||||
// 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)
|
||||
// Default containers to top-center alignment for title/subtitle layout
|
||||
const isArrow = element.type === 'arrow' || element.type === 'line';
|
||||
return {
|
||||
...rest,
|
||||
verticalAlign: (rest as any).verticalAlign ?? (isArrow ? 'middle' : 'top'),
|
||||
label: { text }
|
||||
} as ServerElement;
|
||||
}
|
||||
@@ -1055,15 +1074,35 @@ server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest)
|
||||
const params = ElementSchema.parse(args);
|
||||
logger.info('Creating element via MCP', { type: params.type });
|
||||
|
||||
const { startElementId, endElementId, id: customId, ...elementProps } = params;
|
||||
const {
|
||||
startElementId, endElementId, id: customId,
|
||||
title, titleFontSize, titleFontFamily,
|
||||
subtitle, subtitleFontSize, subtitleFontFamily,
|
||||
...elementProps
|
||||
} = params;
|
||||
const id = customId || generateId();
|
||||
const normalizedFont = normalizeFontFamily(elementProps.fontFamily);
|
||||
|
||||
// Auto-populate title+subtitle for container types unless text is explicitly set
|
||||
const CONTAINER_TYPES = new Set(['rectangle', 'ellipse', 'diamond']);
|
||||
const isContainer = CONTAINER_TYPES.has(params.type);
|
||||
const hasExplicitText = elementProps.text !== undefined;
|
||||
const effectiveTitle = title ?? (isContainer && !hasExplicitText ? 'Title' : undefined);
|
||||
const effectiveSubtitle = subtitle ?? (isContainer && !hasExplicitText && effectiveTitle ? 'Description' : undefined);
|
||||
|
||||
const effectiveText = effectiveTitle ?? elementProps.text;
|
||||
const effectiveFontSize = effectiveTitle ? (titleFontSize ?? 24) : (elementProps.fontSize ?? USER_PREFS.fontSize);
|
||||
const effectiveFontFamily = effectiveTitle
|
||||
? (normalizeFontFamily(titleFontFamily) ?? USER_PREFS.fontFamily)
|
||||
: (normalizedFont ?? USER_PREFS.fontFamily);
|
||||
|
||||
const element: ServerElement = {
|
||||
id,
|
||||
...elementProps,
|
||||
fontFamily: normalizedFont ?? USER_PREFS.fontFamily,
|
||||
text: effectiveText,
|
||||
fontFamily: effectiveFontFamily,
|
||||
roughness: elementProps.roughness ?? USER_PREFS.roughness,
|
||||
fontSize: elementProps.fontSize ?? USER_PREFS.fontSize,
|
||||
fontSize: effectiveFontSize,
|
||||
strokeWidth: elementProps.strokeWidth ?? USER_PREFS.strokeWidth,
|
||||
points: elementProps.points ? normalizePoints(elementProps.points) : undefined,
|
||||
...(startElementId ? { start: { id: startElementId } } : {}),
|
||||
@@ -1081,18 +1120,59 @@ server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest)
|
||||
// Convert text to label format for Excalidraw
|
||||
const excalidrawElement = convertTextToLabel(element);
|
||||
|
||||
// Create element directly on HTTP server (no local storage)
|
||||
// Card layout: title (bound) + subtitle (grouped standalone text)
|
||||
const groupId = (effectiveTitle && effectiveSubtitle && isContainer) ? generateId() : undefined;
|
||||
|
||||
// Add groupId to container if using card layout
|
||||
if (groupId) {
|
||||
(excalidrawElement as any).groupIds = [groupId];
|
||||
}
|
||||
|
||||
// Create the container element
|
||||
const canvasResponse = await createElementOnCanvas(excalidrawElement);
|
||||
|
||||
if (!canvasResponse) {
|
||||
throw new Error('Failed to create element: HTTP server unavailable');
|
||||
}
|
||||
|
||||
let subtitleResponse: any = null;
|
||||
|
||||
// Create subtitle as a grouped standalone text element
|
||||
if (effectiveSubtitle && isContainer && groupId) {
|
||||
const containerWidth = elementProps.width ?? 200;
|
||||
const containerHeight = elementProps.height ?? 100;
|
||||
const subtitleId = generateId();
|
||||
const resolvedSubtitleFont = normalizeFontFamily(subtitleFontFamily) ?? USER_PREFS.fontFamily;
|
||||
const resolvedSubtitleSize = subtitleFontSize ?? 16;
|
||||
|
||||
const subtitleElement: ServerElement = {
|
||||
id: subtitleId,
|
||||
type: 'text' as any,
|
||||
x: element.x + 10,
|
||||
y: element.y + (containerHeight * 0.45),
|
||||
width: containerWidth - 20,
|
||||
height: containerHeight * 0.5,
|
||||
text: effectiveSubtitle,
|
||||
fontSize: resolvedSubtitleSize,
|
||||
fontFamily: resolvedSubtitleFont,
|
||||
strokeColor: elementProps.strokeColor ?? '#1e1e1e',
|
||||
opacity: elementProps.opacity ?? 100,
|
||||
roughness: elementProps.roughness ?? USER_PREFS.roughness,
|
||||
groupIds: [groupId],
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
version: 1
|
||||
} as any;
|
||||
|
||||
subtitleResponse = await createElementOnCanvas(subtitleElement);
|
||||
}
|
||||
|
||||
const synced = canvasResponse.syncedToCanvas ?? false;
|
||||
logger.info('Element created via MCP', {
|
||||
id: excalidrawElement.id,
|
||||
type: excalidrawElement.type,
|
||||
synced,
|
||||
hasSubtitle: !!subtitleResponse,
|
||||
canvasStatus: canvasResponse.canvasStatus
|
||||
});
|
||||
|
||||
@@ -1101,10 +1181,14 @@ server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest)
|
||||
? 'Synced to canvas and confirmed by browser'
|
||||
: `Canvas sync not confirmed (${canvasResponse.canvasStatus?.reason ?? 'unknown'})`;
|
||||
|
||||
const subtitleInfo = subtitleResponse
|
||||
? `\n\nSubtitle element: ${subtitleResponse.element?.id ?? 'created'} (grouped)`
|
||||
: '';
|
||||
|
||||
return {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: `Element created successfully!\n\n${JSON.stringify(canvasResponse.element ?? excalidrawElement, null, 2)}\n\n${statusEmoji} ${statusText}`
|
||||
text: `Element created successfully!\n\n${JSON.stringify(canvasResponse.element ?? excalidrawElement, null, 2)}${subtitleInfo}\n\n${statusEmoji} ${statusText}`
|
||||
}]
|
||||
};
|
||||
}
|
||||
@@ -2366,7 +2450,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest)
|
||||
base.fontSize = rest.fontSize ?? USER_PREFS.fontSize;
|
||||
base.fontFamily = rest.fontFamily ?? USER_PREFS.fontFamily;
|
||||
base.textAlign = rest.textAlign ?? 'center';
|
||||
base.verticalAlign = rest.verticalAlign ?? 'middle';
|
||||
base.verticalAlign = rest.verticalAlign ?? (rest.containerId ? 'top' : 'middle');
|
||||
base.autoResize = rest.autoResize ?? true;
|
||||
base.lineHeight = rest.lineHeight ?? 1.25;
|
||||
base.containerId = rest.containerId ?? null;
|
||||
@@ -2460,8 +2544,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest)
|
||||
originalText: labelText,
|
||||
fontSize: isArrow ? 14 : (rest.fontSize ?? USER_PREFS.fontSize),
|
||||
fontFamily: rest.fontFamily ?? USER_PREFS.fontFamily,
|
||||
textAlign: 'center',
|
||||
verticalAlign: 'middle',
|
||||
textAlign: rest.textAlign ?? 'center',
|
||||
verticalAlign: rest.verticalAlign ?? (isArrow ? 'middle' : 'top'),
|
||||
autoResize: true,
|
||||
lineHeight: 1.25,
|
||||
containerId: base.id
|
||||
|
||||
+2
-2
@@ -137,7 +137,7 @@ export function validateMermaidInput(req: Request, res: Response, next: NextFunc
|
||||
// General limit for all /api routes.
|
||||
export const generalRateLimit = rateLimit({
|
||||
windowMs: 15 * 60 * 1000, // 15 minutes
|
||||
max: getEnvInt('EXCALIDRAW_RATE_LIMIT_GENERAL_MAX', 100),
|
||||
max: getEnvInt('EXCALIDRAW_RATE_LIMIT_GENERAL_MAX', 500),
|
||||
standardHeaders: 'draft-7',
|
||||
legacyHeaders: false,
|
||||
message: { success: false, error: 'Too many requests, please try again later.' },
|
||||
@@ -155,7 +155,7 @@ export const destructiveRateLimit = rateLimit({
|
||||
// Stricter limit for write-heavy sync operations.
|
||||
export const writeBurstLimit = rateLimit({
|
||||
windowMs: 60 * 1000, // 1 minute
|
||||
max: getEnvInt('EXCALIDRAW_RATE_LIMIT_WRITE_BURST_MAX', 10),
|
||||
max: getEnvInt('EXCALIDRAW_RATE_LIMIT_WRITE_BURST_MAX', 30),
|
||||
standardHeaders: 'draft-7',
|
||||
legacyHeaders: false,
|
||||
message: { success: false, error: 'Too many sync operations, please slow down.' },
|
||||
|
||||
@@ -51,7 +51,7 @@ describe('Middleware order', () => {
|
||||
|
||||
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')
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user