@@ -22,6 +22,7 @@ interface ICollaborator {
2222 email ?: string ;
2323 color : string ;
2424 clientId : number ;
25+ avatar_url ?: string ;
2526}
2627
2728/**
@@ -66,6 +67,8 @@ class DocumentCollaboratorsWidget extends Widget {
6667 private _maxVisibleCollaborators = 3 ;
6768 private _awareness : Awareness | null = null ;
6869 private _sharedModel : any = null ;
70+ private _currentModal : HTMLDivElement | null = null ;
71+ private _hideModalTimeout : NodeJS . Timeout | null = null ;
6972
7073 constructor ( context ?: DocumentRegistry . IContext < any > ) {
7174 super ( ) ;
@@ -128,13 +131,15 @@ class DocumentCollaboratorsWidget extends Widget {
128131 const user = state . user || { } ;
129132 const name = user . name || user . displayName || `User ${ clientId } ` ;
130133 const email = user . email || '' ;
134+ const avatar_url = user . avatar_url || user . avatarUrl || '' ;
131135 const color = user . color || generateUserColor ( name ) ;
132136 const initials = generateInitials ( name ) ;
133137
134138 currentCollaborators . set ( clientId , {
135139 name,
136140 initials,
137141 email,
142+ avatar_url,
138143 color,
139144 clientId
140145 } ) ;
@@ -150,6 +155,7 @@ class DocumentCollaboratorsWidget extends Widget {
150155 name : 'Sarah Chen' ,
151156 initials : 'SC' ,
152157158+ avatar_url : 'https://api.dicebear.com/7.x/avataaars/svg?seed=Sarah' ,
153159 color : '#4CAF50' ,
154160 clientId : 1
155161 } ,
@@ -164,6 +170,7 @@ class DocumentCollaboratorsWidget extends Widget {
164170 name : 'Alice Smith' ,
165171 initials : 'AS' ,
166172173+ avatar_url : 'https://api.dicebear.com/7.x/avataaars/svg?seed=Alice' ,
167174 color : '#FF9800' ,
168175 clientId : 3
169176 }
@@ -209,29 +216,48 @@ class DocumentCollaboratorsWidget extends Widget {
209216 const userIcon = document . createElement ( 'div' ) ;
210217 userIcon . className = `jp-DocumentCollaborators-userIcon position-${ index } ` ;
211218
212- // Set dynamic background color (can't be in CSS)
213- userIcon . style . backgroundColor = collaborator . color ;
214-
215- // Add user initials
216- const initialsElement = document . createElement ( 'div' ) ;
217- initialsElement . className = 'jp-DocumentCollaborators-initials' ;
218- initialsElement . textContent = collaborator . initials ;
219- userIcon . appendChild ( initialsElement ) ;
220-
221- // Status indicator removed
219+ if ( collaborator . avatar_url ) {
220+ // Use avatar image
221+ userIcon . classList . add ( 'jp-DocumentCollaborators-userIcon-avatar' ) ;
222+ const avatarImage = document . createElement ( 'img' ) ;
223+ avatarImage . className = 'jp-DocumentCollaborators-avatar' ;
224+ avatarImage . src = collaborator . avatar_url ;
225+ avatarImage . alt = `${ collaborator . name } avatar` ;
226+
227+ // Handle image load errors by falling back to initials
228+ avatarImage . addEventListener ( 'error' , ( ) => {
229+ userIcon . removeChild ( avatarImage ) ;
230+ userIcon . classList . remove ( 'jp-DocumentCollaborators-userIcon-avatar' ) ;
231+ userIcon . style . backgroundColor = collaborator . color ;
232+
233+ const initialsElement = document . createElement ( 'div' ) ;
234+ initialsElement . className = 'jp-DocumentCollaborators-initials' ;
235+ initialsElement . textContent = collaborator . initials ;
236+ userIcon . appendChild ( initialsElement ) ;
237+ } ) ;
238+
239+ userIcon . appendChild ( avatarImage ) ;
240+ } else {
241+ // Fall back to initials
242+ userIcon . style . backgroundColor = collaborator . color ;
243+
244+ const initialsElement = document . createElement ( 'div' ) ;
245+ initialsElement . className = 'jp-DocumentCollaborators-initials' ;
246+ initialsElement . textContent = collaborator . initials ;
247+ userIcon . appendChild ( initialsElement ) ;
248+ }
222249
223- // Add hover effects with proper z-index management
224- userIcon . addEventListener ( 'mouseenter' , ( ) => {
225- // z-index is handled by CSS :hover rule
250+ // Add hover effects for modal display
251+ userIcon . addEventListener ( 'mouseenter' , ( event ) => {
252+ this . _showCollaboratorModal ( collaborator , event . target as HTMLElement ) ;
226253 } ) ;
227254
228255 userIcon . addEventListener ( 'mouseleave' , ( ) => {
229- // Reset handled by CSS
256+ this . _scheduleHideModal ( ) ;
230257 } ) ;
231258
232- // Add click handler
233- userIcon . addEventListener ( 'click' , ( ) => this . _onCollaboratorClicked ( collaborator ) ) ;
234- userIcon . title = collaborator . name ;
259+ // Remove the default title since we're using a custom modal
260+ userIcon . removeAttribute ( 'title' ) ;
235261
236262 return userIcon ;
237263 }
@@ -246,23 +272,218 @@ class DocumentCollaboratorsWidget extends Widget {
246272 textElement . textContent = `+${ remainingCount } ` ;
247273 moreIcon . appendChild ( textElement ) ;
248274
249- // Add click handler
250- moreIcon . addEventListener ( 'click' , ( ) => this . _onMoreClicked ( ) ) ;
251- moreIcon . title = `${ remainingCount } more collaborator${ remainingCount > 1 ? 's' : '' } ` ;
275+ // Add hover effects for more icon modal
276+ moreIcon . addEventListener ( 'mouseenter' , ( event ) => {
277+ this . _showMoreModal ( event . target as HTMLElement ) ;
278+ } ) ;
279+
280+ moreIcon . addEventListener ( 'mouseleave' , ( ) => {
281+ this . _scheduleHideModal ( ) ;
282+ } ) ;
283+
284+ // Remove the default title since we're using a custom modal
285+ moreIcon . removeAttribute ( 'title' ) ;
252286
253287 return moreIcon ;
254288 }
255289
256- private _onCollaboratorClicked ( collaborator : ICollaborator ) : void {
257- console . log ( 'Collaborator clicked:' , collaborator . name ) ;
258- const emailInfo = collaborator . email ? `\nEmail: ${ collaborator . email } ` : '' ;
259- alert ( `Collaborator: ${ collaborator . name } ${ emailInfo } \nClient ID: ${ collaborator . clientId } ` ) ;
290+ private _showCollaboratorModal ( collaborator : ICollaborator , targetElement : HTMLElement ) : void {
291+ this . _clearHideModalTimeout ( ) ;
292+ this . _hideCurrentModal ( ) ;
293+
294+ const modal = document . createElement ( 'div' ) ;
295+ modal . className = 'jp-DocumentCollaborators-modal' ;
296+
297+ // Create modal content
298+ const content = document . createElement ( 'div' ) ;
299+ content . className = 'jp-DocumentCollaborators-modal-content' ;
300+
301+ // Create header with user icon and info
302+ const headerElement = document . createElement ( 'div' ) ;
303+ headerElement . className = 'jp-DocumentCollaborators-modal-header' ;
304+
305+ // User icon
306+ const userIconElement = document . createElement ( 'div' ) ;
307+ userIconElement . className = 'jp-DocumentCollaborators-modal-userIcon' ;
308+
309+ if ( collaborator . avatar_url ) {
310+ // Use avatar image
311+ userIconElement . classList . add ( 'jp-DocumentCollaborators-modal-userIcon-avatar' ) ;
312+ const avatarImage = document . createElement ( 'img' ) ;
313+ avatarImage . className = 'jp-DocumentCollaborators-modal-avatar' ;
314+ avatarImage . src = collaborator . avatar_url ;
315+ avatarImage . alt = `${ collaborator . name } avatar` ;
316+
317+ // Handle image load errors by falling back to initials
318+ avatarImage . addEventListener ( 'error' , ( ) => {
319+ userIconElement . removeChild ( avatarImage ) ;
320+ userIconElement . classList . remove ( 'jp-DocumentCollaborators-modal-userIcon-avatar' ) ;
321+ userIconElement . style . backgroundColor = collaborator . color ;
322+
323+ const initialsElement = document . createElement ( 'div' ) ;
324+ initialsElement . className = 'jp-DocumentCollaborators-modal-initials' ;
325+ initialsElement . textContent = collaborator . initials ;
326+ userIconElement . appendChild ( initialsElement ) ;
327+ } ) ;
328+
329+ userIconElement . appendChild ( avatarImage ) ;
330+ } else {
331+ // Fall back to initials
332+ userIconElement . style . backgroundColor = collaborator . color ;
333+
334+ const initialsElement = document . createElement ( 'div' ) ;
335+ initialsElement . className = 'jp-DocumentCollaborators-modal-initials' ;
336+ initialsElement . textContent = collaborator . initials ;
337+ userIconElement . appendChild ( initialsElement ) ;
338+ }
339+
340+ headerElement . appendChild ( userIconElement ) ;
341+
342+ // User info container
343+ const userInfoElement = document . createElement ( 'div' ) ;
344+ userInfoElement . className = 'jp-DocumentCollaborators-modal-userInfo' ;
345+
346+ // User name
347+ const nameElement = document . createElement ( 'div' ) ;
348+ nameElement . className = 'jp-DocumentCollaborators-modal-name' ;
349+ nameElement . textContent = collaborator . name ;
350+ userInfoElement . appendChild ( nameElement ) ;
351+
352+ // User email (if available)
353+ if ( collaborator . email ) {
354+ const emailElement = document . createElement ( 'div' ) ;
355+ emailElement . className = 'jp-DocumentCollaborators-modal-email' ;
356+ emailElement . textContent = collaborator . email ;
357+ userInfoElement . appendChild ( emailElement ) ;
358+ }
359+
360+ headerElement . appendChild ( userInfoElement ) ;
361+ content . appendChild ( headerElement ) ;
362+
363+ modal . appendChild ( content ) ;
364+
365+ // Add hover handlers to keep modal visible
366+ modal . addEventListener ( 'mouseenter' , ( ) => {
367+ this . _clearHideModalTimeout ( ) ;
368+ } ) ;
369+
370+ modal . addEventListener ( 'mouseleave' , ( ) => {
371+ this . _scheduleHideModal ( ) ;
372+ } ) ;
373+
374+ // Position and show modal
375+ this . _positionAndShowModal ( modal , targetElement ) ;
260376 }
261-
262- private _onMoreClicked ( ) : void {
377+
378+ private _showMoreModal ( targetElement : HTMLElement ) : void {
379+ this . _clearHideModalTimeout ( ) ;
380+ this . _hideCurrentModal ( ) ;
381+
263382 const hiddenCollaborators = Array . from ( this . _collaborators . values ( ) ) . slice ( this . _maxVisibleCollaborators ) ;
264- const names = hiddenCollaborators . map ( c => c . name ) . join ( '\n' ) ;
265- alert ( `Additional Collaborators:\n\n${ names } ` ) ;
383+
384+ const modal = document . createElement ( 'div' ) ;
385+ modal . className = 'jp-DocumentCollaborators-modal jp-DocumentCollaborators-modal-more' ;
386+
387+ // Create modal content
388+ const content = document . createElement ( 'div' ) ;
389+ content . className = 'jp-DocumentCollaborators-modal-content' ;
390+
391+ // Title
392+ const titleElement = document . createElement ( 'div' ) ;
393+ titleElement . className = 'jp-DocumentCollaborators-modal-title' ;
394+ titleElement . textContent = 'Additional Collaborators' ;
395+ content . appendChild ( titleElement ) ;
396+
397+ // List of hidden collaborators
398+ hiddenCollaborators . forEach ( collaborator => {
399+ const collaboratorElement = document . createElement ( 'div' ) ;
400+ collaboratorElement . className = 'jp-DocumentCollaborators-modal-collaborator' ;
401+
402+ const nameElement = document . createElement ( 'div' ) ;
403+ nameElement . className = 'jp-DocumentCollaborators-modal-name' ;
404+ nameElement . textContent = collaborator . name ;
405+ collaboratorElement . appendChild ( nameElement ) ;
406+
407+ if ( collaborator . email ) {
408+ const emailElement = document . createElement ( 'div' ) ;
409+ emailElement . className = 'jp-DocumentCollaborators-modal-email' ;
410+ emailElement . textContent = collaborator . email ;
411+ collaboratorElement . appendChild ( emailElement ) ;
412+ }
413+
414+ content . appendChild ( collaboratorElement ) ;
415+ } ) ;
416+
417+ modal . appendChild ( content ) ;
418+
419+ // Add hover handlers to keep modal visible
420+ modal . addEventListener ( 'mouseenter' , ( ) => {
421+ this . _clearHideModalTimeout ( ) ;
422+ } ) ;
423+
424+ modal . addEventListener ( 'mouseleave' , ( ) => {
425+ this . _scheduleHideModal ( ) ;
426+ } ) ;
427+
428+ // Position and show modal
429+ this . _positionAndShowModal ( modal , targetElement ) ;
430+ }
431+
432+ private _positionAndShowModal ( modal : HTMLDivElement , targetElement : HTMLElement ) : void {
433+ // Add modal to document body
434+ document . body . appendChild ( modal ) ;
435+ this . _currentModal = modal ;
436+
437+ // Get target element position
438+ const targetRect = targetElement . getBoundingClientRect ( ) ;
439+ const modalRect = modal . getBoundingClientRect ( ) ;
440+
441+ // Position modal above the target element
442+ let left = targetRect . left + ( targetRect . width / 2 ) - ( modalRect . width / 2 ) ;
443+ let top = targetRect . top - modalRect . height - 8 ; // 8px gap
444+
445+ // Ensure modal stays within viewport
446+ const padding = 8 ;
447+ left = Math . max ( padding , Math . min ( left , window . innerWidth - modalRect . width - padding ) ) ;
448+
449+ // If modal would be cut off at the top, show it below the target
450+ if ( top < padding ) {
451+ top = targetRect . bottom + 8 ;
452+ }
453+
454+ modal . style . left = `${ left } px` ;
455+ modal . style . top = `${ top } px` ;
456+
457+ // Trigger animation
458+ requestAnimationFrame ( ( ) => {
459+ modal . classList . add ( 'jp-DocumentCollaborators-modal-visible' ) ;
460+ } ) ;
461+ }
462+
463+ private _scheduleHideModal ( ) : void {
464+ this . _clearHideModalTimeout ( ) ;
465+ this . _hideModalTimeout = setTimeout ( ( ) => {
466+ this . _hideCurrentModal ( ) ;
467+ } , 200 ) ; // Small delay to allow moving to modal
468+ }
469+
470+ private _clearHideModalTimeout ( ) : void {
471+ if ( this . _hideModalTimeout ) {
472+ clearTimeout ( this . _hideModalTimeout ) ;
473+ this . _hideModalTimeout = null ;
474+ }
475+ }
476+
477+ private _hideCurrentModal ( ) : void {
478+ if ( this . _currentModal ) {
479+ this . _currentModal . classList . remove ( 'jp-DocumentCollaborators-modal-visible' ) ;
480+ setTimeout ( ( ) => {
481+ if ( this . _currentModal && this . _currentModal . parentNode ) {
482+ this . _currentModal . parentNode . removeChild ( this . _currentModal ) ;
483+ }
484+ this . _currentModal = null ;
485+ } , 200 ) ; // Match CSS transition duration
486+ }
266487 }
267488
268489 /**
@@ -272,6 +493,8 @@ class DocumentCollaboratorsWidget extends Widget {
272493 if ( this . _awareness ) {
273494 this . _awareness . off ( 'change' , this . _onAwarenessChange . bind ( this ) ) ;
274495 }
496+ this . _clearHideModalTimeout ( ) ;
497+ this . _hideCurrentModal ( ) ;
275498 super . dispose ( ) ;
276499 }
277500}
0 commit comments