Skip to content

Commit cbda05f

Browse files
dabowmanclaudechriszaratemaxschmeling
authored
[Real-time collaboration] Refine collaborator overlay with Avatar component integration (#75595)
* Add CollaboratorsOverlay with cursor and block highlight rendering Initial implementation of the real-time collaboration overlay system: cursor rendering via CursorRegistry, block selection highlighting, overlay style injection into the editor iframe, and BlockCanvasCover SlotFill integration. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Render awareness cursors with JSX instead of manual DOM manipulation * big fix Bug: useBlockHighlighting crashes with TypeError: Cannot read properties of undefined (reading 'WholeBlock') when two users edit the same post. Cause: In use-block-highlighting.ts:15, SelectionType is destructured from unlock(coreDataPrivateApis), but it was never added to the private APIs in private-apis.js. It's undefined at runtime. Fix: Import SelectionType directly from @wordpress/core-data (it's already a public export), matching the pattern in use-render-cursors.ts. Remove it from the unlock() destructure. * update collaborator avatar styles * refining styles to pull from design system. * Add WCAG-compliant collaborator color derivation using OKLCH Replace the naive RGB lightness-shift color system with a contrast-aware pipeline that derives two purpose-specific colors per collaborator from seed colors in the palette: - strokeColor: meets 3.1:1 contrast against white (WCAG 2.1 SC 1.4.11 for UI components like borders and block highlights) - backgroundColor: meets 7:1 contrast against white (WCAG 2.1 SC 1.4.3 AA for white text on colored cursor labels) Colors are manipulated in OKLCH space (Björn Ottosson's OKLAB) for perceptually uniform darkening — only lightness is reduced while chroma and hue are preserved. This avoids the hue drift that CIE LCH exhibits when darkening blues. The implementation is ~60 lines of pure math with zero external dependencies. Also updates the seed palette to use more vibrant, distinct colors and replaces the RGB-shift overflow strategy with OKLCH hue rotation for generating new colors when all palette slots are taken. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Align collaborator block highlight with core selection outline Replace the inline box-shadow approach for RTC block highlighting with the same outline-based method core uses for `.is-highlighted` blocks. This ensures consistent outline style, width, offset, and zoom scaling across both the default selection and collaborator highlights. - Switch from inline `style.boxShadow` to a `.is-collaborator-selected` CSS class with a `--collaborator-outline-color` custom property - Reuse the `selected-block-focus()` mixin from `@wordpress/base-styles` - Add `:not(:focus)` guard so the user's own selection always wins - Extract shared `collaborator-contrast-shadow()` mixin used by both the block highlight and avatar components - Add compiled `COLLABORATOR_CONTRAST_SHADOW` constant for iframe styles Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Update collaborator cursor label to pill-shaped avatar badge Redesign the cursor label from a plain text tag to a pill-shaped badge with the collaborator's avatar. The label defaults to showing just the avatar circle and expands on hover to reveal the username. - Add `collaborator-avatar()` mixin for shared avatar styles between the presence list and cursor label, using `--avatar-url` CSS custom property - Restyle cursor label as a flex pill with avatar + name, white outline contrast ring, and hover expand transition - Add white outline + elevation shadow to cursor caret for contrast - Fix block highlight inset shadow: use a wider inset that extends past the outline so the white inner ring remains visible - Consolidate `collaborator-contrast-shadow()` to include inset ring, fixing the `--with-color-border` avatar variant losing its inner ring - Sync all changes to `overlay-iframe-styles.ts` for iframe injection Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Add avatar initials fallback and refine cursor overlay styles When a collaborator has no custom Gravatar, display their first two initials on their assigned background color instead of the default placeholder image. A shared gravatar-check utility detects real vs default avatars by preloading with `d=404` and caching the result for both the React presence avatar and the imperative cursor overlay. Also refines cursor overlay visuals: Safari-style blink animation, rounded caret edges, label centered on caret midpoint, and box-sizing fix for accurate avatar dimensions. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Add @ts-expect-error for untyped @wordpress/block-editor import Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Components: Use Avatar and AvatarGroup in RTC collaborator UI Replace custom avatar implementations across the collaborators-presence and collaborators-overlay features with the new Avatar and AvatarGroup private components from @wordpress/components. - Add Avatar and AvatarGroup components to the RTC branch (copied from add/avatar-component) and register them as private APIs - Replace hand-rolled avatar in presence toolbar with AvatarGroup - Replace custom avatar in collaborators list with Avatar component - Replace manual cursor label markup in overlay with Avatar badge mode - Inject compiled Avatar CSS into the iframe via useStyleOverride since wp-components styles are excluded from the editor canvas - Rename `has-border-color` modifier to `has-avatar-border-color` to avoid collision with the WordPress block support class of the same name - Add `width: max-content` to overlay Avatar so CSS Grid fr units resolve correctly in the absolutely-positioned context - Delete old avatar.tsx, avatar.scss, and _collaborator-mixins.scss - Remove unused COLLABORATOR_CONTRAST_SHADOW constant Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Add Gravatar default detection for Avatar initials fallback WordPress always returns a Gravatar URL even when the user has no custom image. Add a utility that rewrites the URL with `d=404` and preloads it to detect default placeholders. When a default is detected, `undefined` is passed as the Avatar `src`, triggering its built-in initials fallback. - Add `gravatar-check.ts` with async image preload, module-level cache, and callback queue for multiple consumers - Add `useResolveGravatars` hook to initiate checks and trigger re-renders when results arrive - Integrate into cursor overlay (use-render-cursors), presence toolbar, and collaborators list via `resolveGravatarUrl(getAvatarUrl(urls))` - Re-enable inner white ring box-shadow on bordered avatar images Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Move collaborator colors from core-data to editor package Replace the OKLCH color generation system in core-data with the simpler `AVATAR_BORDER_COLORS` array from `collab-sidebar/utils.js`. All editor consumers now use `getAvatarBorderColor(userId)` for a single color per collaborator (border, cursor, and block highlight), removing the need for separate stroke/background derivation and ~230 lines of color math. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Fix CSS collision with block supports `[style*=border-color]` selector Rename `--components-avatar-border-color` to `--components-avatar-outline-color` to avoid matching `html :where([style*="border-color"])` in block-library's common.scss, which forces `border-style: solid` on any element whose style attribute contains the substring "border-color". Also adds explicit type parameter to `useMergeRefs` to fix TS2322. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Components: Add CHANGELOG entry for Avatar and AvatarGroup Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Block Editor: Remove duplicate BlockCanvasCover slot The squash/rebase accidentally introduced a second BlockCanvasCover.Slot after the block content in both iframe and non-iframe paths. This extra Slot rendered a container element below .editor-styles-wrapper, causing the padding appender's click-to-append to fail — clicks near the bottom of the canvas would hit the Slot element instead of the editor's 40vh padding area, bypassing the target check. The existing Slot before the content is sufficient since the cover uses position: absolute. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Remove custom gravatar resolver --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: chriszarate <chris.zarate@automattic.com> Co-authored-by: Max Schmeling <max.schmeling@automattic.com>
1 parent a6cc8c2 commit cbda05f

28 files changed

Lines changed: 691 additions & 388 deletions

‎packages/components/CHANGELOG.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919

2020
### Internal
2121

22+
- Add `Avatar` and `AvatarGroup` components as private APIs ([#75595](https://github.com/WordPress/gutenberg/pull/75595)).
2223
- Remove `Picker` from private APIs ([#75394](https://github.com/WordPress/gutenberg/pull/75394)).
2324
- Expose `useDrag` from `@use-gesture/react` package via private API's ([#66735](https://github.com/WordPress/gutenberg/pull/66735)).
2425
- `Disabled`, `Modal`, `Popover`, `Tooltip`: Move context code to separate files to help docgen prop extraction ([#75316](https://github.com/WordPress/gutenberg/pull/75316)).
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
/**
2+
* External dependencies
3+
*/
4+
import clsx from 'clsx';
5+
6+
/**
7+
* WordPress dependencies
8+
*/
9+
import { Children } from '@wordpress/element';
10+
11+
/**
12+
* Internal dependencies
13+
*/
14+
import type { AvatarGroupProps } from './types';
15+
import type { WordPressComponentProps } from '../context';
16+
17+
function AvatarGroup( {
18+
className,
19+
max = 3,
20+
children,
21+
...props
22+
}: WordPressComponentProps< AvatarGroupProps, 'div', false > ) {
23+
const childArray = Children.toArray( children );
24+
const visible = childArray.slice( 0, max );
25+
const overflowCount = childArray.length - max;
26+
27+
return (
28+
<div
29+
role="group"
30+
className={ clsx( 'components-avatar-group', className ) }
31+
{ ...props }
32+
>
33+
{ visible }
34+
{ overflowCount > 0 && (
35+
<span
36+
className="components-avatar-group__overflow"
37+
aria-label={ `${ overflowCount } more` }
38+
>
39+
{ `+${ overflowCount }` }
40+
</span>
41+
) }
42+
</div>
43+
);
44+
}
45+
46+
export default AvatarGroup;
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export { default } from './component';
2+
export type { AvatarGroupProps } from './types';
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
@use "@wordpress/base-styles/colors" as *;
2+
@use "@wordpress/base-styles/variables" as *;
3+
4+
.components-avatar-group {
5+
display: flex;
6+
align-items: center;
7+
8+
// Overlap subsequent avatars.
9+
> .components-avatar + .components-avatar {
10+
margin-inline-start: -$grid-unit-10;
11+
}
12+
13+
// Stack earlier avatars on top of later ones so the overlap
14+
// is visually correct, and elevate on hover for badge expansion.
15+
> .components-avatar {
16+
position: relative;
17+
18+
@for $i from 1 through 10 {
19+
&:nth-child(#{$i}) {
20+
z-index: #{11 - $i};
21+
}
22+
}
23+
}
24+
}
25+
26+
.components-avatar-group__overflow {
27+
margin-inline-start: $grid-unit-05;
28+
font-size: $font-size-small;
29+
line-height: $font-line-height-small;
30+
color: $gray-900;
31+
white-space: nowrap;
32+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
export type AvatarGroupProps = {
2+
/**
3+
* Maximum number of avatars to display before showing an
4+
* overflow indicator.
5+
*
6+
* @default 3
7+
*/
8+
max?: number;
9+
/**
10+
* Avatar elements to display in the group.
11+
*/
12+
children: React.ReactNode;
13+
};
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
/**
2+
* External dependencies
3+
*/
4+
import clsx from 'clsx';
5+
6+
/**
7+
* Internal dependencies
8+
*/
9+
import Icon from '../icon';
10+
import Tooltip from '../tooltip';
11+
import type { AvatarProps } from './types';
12+
import type { WordPressComponentProps } from '../context';
13+
14+
function Avatar( {
15+
className,
16+
src,
17+
name,
18+
label,
19+
badge = false,
20+
size = 'default',
21+
borderColor,
22+
status,
23+
statusIndicator,
24+
style,
25+
...props
26+
}: WordPressComponentProps< AvatarProps, 'div', false > ) {
27+
const showBadge = badge && !! name;
28+
const initials = name
29+
? name
30+
.split( /\s+/ )
31+
.slice( 0, 2 )
32+
.map( ( word ) => word[ 0 ] )
33+
.join( '' )
34+
.toUpperCase()
35+
: undefined;
36+
const customProperties = {
37+
...style,
38+
...( src ? { '--components-avatar-url': `url(${ src })` } : {} ),
39+
...( borderColor
40+
? { '--components-avatar-outline-color': borderColor }
41+
: {} ),
42+
} as React.CSSProperties;
43+
44+
const avatar = (
45+
<div
46+
className={ clsx( 'components-avatar', className, {
47+
'has-avatar-border-color': !! borderColor,
48+
'has-src': !! src,
49+
'has-badge': showBadge,
50+
'is-small': size === 'small',
51+
'has-status': !! status,
52+
[ `is-${ status }` ]: !! status,
53+
} ) }
54+
style={ customProperties }
55+
role="img"
56+
aria-label={ name }
57+
{ ...props }
58+
>
59+
<span className="components-avatar__image">
60+
{ ! src && initials }
61+
{ !! status && !! statusIndicator && (
62+
<span className="components-avatar__status-indicator">
63+
<Icon icon={ statusIndicator } />
64+
</span>
65+
) }
66+
</span>
67+
{ showBadge && (
68+
<span className="components-avatar__name">
69+
{ label || name }
70+
</span>
71+
) }
72+
</div>
73+
);
74+
75+
if ( name && ( ! showBadge || label ) ) {
76+
return <Tooltip text={ name }>{ avatar }</Tooltip>;
77+
}
78+
79+
return avatar;
80+
}
81+
82+
export default Avatar;
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export { default } from './component';
2+
export type { AvatarProps } from './types';
Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
@use "@wordpress/base-styles/colors" as *;
2+
@use "@wordpress/base-styles/variables" as *;
3+
@use "../utils/theme-variables" as *;
4+
5+
.components-avatar {
6+
display: inline-flex;
7+
align-items: center;
8+
border-radius: $radius-full;
9+
overflow: clip;
10+
flex-shrink: 0;
11+
background-color: $components-color-accent;
12+
box-shadow: 0 0 0 var(--wp-admin-border-width-focus) $white, $elevation-x-small;
13+
}
14+
15+
.components-avatar__image {
16+
box-sizing: border-box;
17+
position: relative;
18+
width: $button-size-compact;
19+
height: $button-size-compact;
20+
border-radius: $radius-full;
21+
border: 0;
22+
background-color: $components-color-accent;
23+
overflow: clip;
24+
flex-shrink: 0;
25+
font-size: 0;
26+
color: $white;
27+
28+
.is-small > & {
29+
width: $button-size-small;
30+
height: $button-size-small;
31+
}
32+
33+
.has-src > & {
34+
background-image: var(--components-avatar-url);
35+
background-size: cover;
36+
background-position: center;
37+
}
38+
39+
.has-avatar-border-color > & {
40+
border: var(--wp-admin-border-width-focus) solid var(--components-avatar-outline-color);
41+
box-shadow: inset 0 0 0 var(--wp-admin-border-width-focus) $white;
42+
background-clip: padding-box;
43+
}
44+
}
45+
46+
// Initials fallback: show name characters when no image.
47+
.components-avatar:not(.has-src) > .components-avatar__image {
48+
display: flex;
49+
align-items: center;
50+
justify-content: center;
51+
font-size: $font-size-x-small;
52+
font-weight: $font-weight-medium;
53+
border: 0;
54+
box-shadow: none;
55+
background-clip: border-box;
56+
}
57+
58+
.components-avatar:not(.has-src).has-avatar-border-color > .components-avatar__image {
59+
background-color: var(--components-avatar-outline-color);
60+
}
61+
62+
.components-avatar__name {
63+
font-size: $font-size-medium;
64+
line-height: $font-line-height-small;
65+
color: $white;
66+
min-width: 0;
67+
padding-bottom: calc($grid-unit-05 / 2);
68+
overflow: hidden;
69+
opacity: 0;
70+
white-space: nowrap;
71+
transition: opacity 0.15s cubic-bezier(0.15, 0, 0.15, 1);
72+
}
73+
74+
// Badge mode: use grid so the name column animates from 0 to natural width.
75+
// Spacing is on the container (column-gap + padding-inline-end) so it
76+
// transitions alongside grid-template-columns instead of causing a bump.
77+
.components-avatar.has-badge {
78+
display: inline-grid;
79+
grid-template-columns: min-content 0fr;
80+
column-gap: 0;
81+
padding-inline-end: 0;
82+
transition:
83+
grid-template-columns 0.3s cubic-bezier(0.15, 0, 0.15, 1),
84+
column-gap 0.3s cubic-bezier(0.15, 0, 0.15, 1),
85+
padding-inline-end 0.3s cubic-bezier(0.15, 0, 0.15, 1);
86+
87+
&:hover {
88+
grid-template-columns: min-content 1fr;
89+
column-gap: $grid-unit-05;
90+
padding-inline-end: $grid-unit-10;
91+
transition-timing-function: cubic-bezier(0.85, 0, 0.85, 1);
92+
}
93+
94+
&:hover .components-avatar__name {
95+
opacity: 1;
96+
transition-timing-function: cubic-bezier(0.85, 0, 0.85, 1);
97+
}
98+
}
99+
100+
.components-avatar.has-badge.has-avatar-border-color {
101+
background-color: var(--components-avatar-outline-color);
102+
}
103+
104+
// Status: dim and desaturate content so the background color shows through.
105+
// Images: move to ::before with luminosity blend over the solid bg color.
106+
// Initials: reduce text opacity.
107+
.components-avatar.has-status.has-src > .components-avatar__image {
108+
background-image: none;
109+
110+
&::before {
111+
content: "";
112+
position: absolute;
113+
inset: 0;
114+
border-radius: inherit;
115+
background-image: var(--components-avatar-url);
116+
background-size: cover;
117+
background-position: center;
118+
mix-blend-mode: luminosity;
119+
opacity: 0.3;
120+
}
121+
}
122+
123+
.components-avatar.has-status:not(.has-src) > .components-avatar__image {
124+
color: rgba($white, 0.3);
125+
}
126+
127+
.components-avatar.has-status.has-avatar-border-color > .components-avatar__image {
128+
background-color: var(--components-avatar-outline-color);
129+
}
130+
131+
.components-avatar__status-indicator {
132+
position: absolute;
133+
inset: 0;
134+
display: flex;
135+
align-items: center;
136+
justify-content: center;
137+
z-index: 1;
138+
color: $white;
139+
fill: $white;
140+
141+
svg {
142+
width: 75%;
143+
height: 75%;
144+
}
145+
}
146+
147+
@media (prefers-reduced-motion: reduce) {
148+
.components-avatar.has-badge,
149+
.components-avatar__name {
150+
transition: none;
151+
}
152+
}

0 commit comments

Comments
 (0)