Skip to content

Commit 0efc15d

Browse files
committed
Add a hover UX to show a collaborator's information
1 parent 9c613ed commit 0efc15d

File tree

2 files changed

+401
-28
lines changed

2 files changed

+401
-28
lines changed

src/index.ts

Lines changed: 251 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -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',
152157
158+
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',
166172
173+
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

Comments
 (0)