@@ -415,6 +415,116 @@ app.delete('/api/elements/:id', (req: Request, res: Response) => {
415415 res . json ( { success : true , message : `Element ${ id } deleted` } satisfies ApiResponse )
416416} )
417417
418+ // ─── REST: Grouping ──────────────────────────────────────────────────────────
419+
420+ /** POST /api/elements/groups — group elements together */
421+ app . post ( '/api/elements/groups' , ( req : Request , res : Response ) => {
422+ const { groupId, elementIds } = req . body as { groupId ?: string ; elementIds ?: string [ ] }
423+
424+ if ( ! groupId ) {
425+ res . status ( 400 ) . json ( { success : false , error : '`groupId` is required' } satisfies ApiResponse )
426+ return
427+ }
428+ if ( ! Array . isArray ( elementIds ) || elementIds . length < 2 ) {
429+ res . status ( 400 ) . json ( { success : false , error : '`elementIds` must be an array of at least 2 IDs' } satisfies ApiResponse )
430+ return
431+ }
432+
433+ const missing = elementIds . find ( ( id ) => ! elements . has ( id ) )
434+ if ( missing ) {
435+ res . status ( 404 ) . json ( { success : false , error : `Element ${ missing } not found` } satisfies ApiResponse )
436+ return
437+ }
438+
439+ if ( elements . has ( groupId ) ) {
440+ res . status ( 409 ) . json ( { success : false , error : `Element ${ groupId } already exists` } satisfies ApiResponse )
441+ return
442+ }
443+
444+ const now = new Date ( ) . toISOString ( )
445+
446+ // Compute bounding box of members
447+ let minX = Infinity , minY = Infinity , maxX = - Infinity , maxY = - Infinity
448+ for ( const id of elementIds ) {
449+ const el = elements . get ( id ) !
450+ minX = Math . min ( minX , el . x )
451+ minY = Math . min ( minY , el . y )
452+ maxX = Math . max ( maxX , el . x + ( el . width ?? 160 ) )
453+ maxY = Math . max ( maxY , el . y + ( el . height ?? 80 ) )
454+ }
455+
456+ // Create a group element to represent the container
457+ const groupElement : CanvasElement = {
458+ id : groupId ,
459+ type : 'group' ,
460+ x : minX ,
461+ y : minY ,
462+ width : maxX - minX ,
463+ height : maxY - minY ,
464+ childIds : elementIds ,
465+ color : 'black' ,
466+ fill : 'none' ,
467+ dash : 'draw' ,
468+ size : 'm' ,
469+ font : 'draw' ,
470+ opacity : 100 ,
471+ createdAt : now ,
472+ updatedAt : now ,
473+ version : 1 ,
474+ } as CanvasElement
475+
476+ elements . set ( groupId , groupElement )
477+
478+ // Update each child with parentId
479+ const updatedChildren : CanvasElement [ ] = elementIds . map ( ( id ) => {
480+ const el = elements . get ( id ) !
481+ const updated : CanvasElement = { ...el , parentId : groupId , updatedAt : now , version : el . version + 1 }
482+ elements . set ( id , updated )
483+ return updated
484+ } )
485+
486+ broadcast ( { type : 'element_created' , element : groupElement } )
487+ broadcast ( { type : 'elements_batch_updated' , elements : updatedChildren } )
488+ res . status ( 201 ) . json ( { success : true , element : groupElement , childIds : elementIds } satisfies ApiResponse )
489+ } )
490+
491+ /** DELETE /api/elements/groups/:groupId — dissolve a group */
492+ app . delete ( '/api/elements/groups/:groupId' , ( req : Request , res : Response ) => {
493+ const groupId = req . params . groupId as string
494+ const groupEl = elements . get ( groupId )
495+
496+ if ( ! groupEl ) {
497+ res . status ( 404 ) . json ( { success : false , error : `Group ${ groupId } not found` } satisfies ApiResponse )
498+ return
499+ }
500+ if ( groupEl . type !== 'group' ) {
501+ res . status ( 400 ) . json ( { success : false , error : `Element ${ groupId } is not a group` } satisfies ApiResponse )
502+ return
503+ }
504+
505+ const childIds = groupEl . childIds ?? [ ]
506+ const now = new Date ( ) . toISOString ( )
507+
508+ // Clear parentId from all children
509+ const releasedChildren : CanvasElement [ ] = childIds . map ( ( id ) => {
510+ const el = elements . get ( id )
511+ if ( ! el ) return null
512+ const updated : CanvasElement = { ...el , parentId : undefined , updatedAt : now , version : el . version + 1 }
513+ elements . set ( id , updated )
514+ return updated
515+ } ) . filter ( Boolean ) as CanvasElement [ ]
516+
517+ // Remove the group element
518+ elements . delete ( groupId )
519+
520+ broadcast ( { type : 'element_deleted' , id : groupId } )
521+ if ( releasedChildren . length > 0 ) {
522+ broadcast ( { type : 'elements_batch_updated' , elements : releasedChildren } )
523+ }
524+
525+ res . json ( { success : true , message : `Group ${ groupId } dissolved` , childIds } satisfies ApiResponse )
526+ } )
527+
418528// ─── REST: Viewport ───────────────────────────────────────────────────────────
419529
420530app . post ( '/api/viewport' , ( req : Request , res : Response ) => {
0 commit comments