Skip to content

Commit 21529a3

Browse files
dabowmandabowmanchriszaratemaxschmelingpbearne
authored andcommitted
Editor: Show own presence in collaborative editing sessions (#76413)
* Editor: Align user block selection highlight with collaborator highlight Add box-shadow to the selected-block-outline mixin to match the visual treatment used for collaborator block highlights — white inner/outer borders and a subtle elevation shadow. The user's selection retains the theme color while gaining the same polished appearance. Co-authored-by: Claude <noreply@anthropic.com> https://claude.ai/code/session_012mkKooPds1bE1YkRWVF982 * Editor: Add current user to collaborators-presence list When the showCollaborationCursor preference is enabled, the current user now appears in the collaborators-presence avatar group and popover list. The user's avatar is displayed first with the admin theme color border, and their name shows as "You" in the popover. Uses the Avatar component's name prop to preserve the full username in the tooltip. Disconnected collaborators are indicated via the dimmed prop. Co-authored-by: Claude <noreply@anthropic.com> https://claude.ai/code/session_012mkKooPds1bE1YkRWVF982 * Show own presence (as a preference) * Editor: Use theme color for own cursor in collaborative sessions When a user opts in to showing their own cursor, it was rendered using their assigned collaborator color. The cursor now uses `var(--wp-admin-theme-color)`, matching how the user's own presence is shown in the collaborators list and block highlighting. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Editor: Suppress blinking cursor line for own cursor in collaborative sessions When the user opts in to showing their own cursor, only the avatar label is rendered — not the blinking cursor line. This avoids conflicting with the browser's native caret. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Editor: Always show own avatar in toolbar and collaborators list The showCollaborationCursor preference previously gated the current user's avatar from appearing in the AvatarGroup and CollaboratorsList. Own presence in those two surfaces is now always visible — the preference only controls cursor visibility in the editor overlay. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Editor: Use theme color for own block highlight in collaborative sessions Applies the same isMe → theme color pattern already used for the cursor and presence list, so the current user's block highlight outline and avatar badge consistently reflect the admin theme color. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Editor: Remove own block highlight from collaborative sessions The block highlight overlay was applying to the current user's own selection when the showCollaborationCursor preference was enabled. Own block highlighting is now fully suppressed — the block editor's built-in selection UI handles it. The preference only controls whether the user's avatar label appears over their text cursor. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Editor: Fix avatar contrast ring using pseudo-element instead of inset box-shadow The inset box-shadow on .editor-avatar__image was unreliable because CSS paint order renders inset shadows below content — the absolutely- positioned <img> element covers it when the image loads. Replace with a ::after pseudo-element (z-index: 1) that always paints above the image. The initials fallback sets content: none since it doesn't need a contrast ring. Mirrored in avatar-iframe-styles.ts. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Update editor CHANGELOG for avatar contrast ring fix Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Editor: Suppress selection rects for own cursor in collaborative sessions The browser already renders the user's native selection highlight, so the overlay selection rects would double up. Skip them for the current user, same as we do for the cursor line. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Editor: Fix self-presence e2e test selectors to match component implementation The test used a non-existent `.list-item-you` class and `(you)` text, but the component renders all users with `.list-item` and displays `You`. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Editor: Update self-presence preference copy and fix e2e tests Rename preference label to "Show avatar in blocks" with clearer help text. Fix e2e tests to reflect that the "You" entry in the collaborators list is always visible regardless of the preference — the preference only controls the cursor/highlight overlay, not list membership. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Editor: Show "You" label on own cursor badge in collaborative overlay Pass `label="You"` to the Avatar badge for the current user's cursor, so the badge displays "You" instead of the username. The Avatar component automatically shows the real username in a tooltip when a custom label is set. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Editor: Remove unused list-item-you CSS rule from collaborators list Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: dabowman <davidabowman@git.wordpress.org> Co-authored-by: chriszarate <czarate@git.wordpress.org> Co-authored-by: maxschmeling <maxschmeling@git.wordpress.org> Co-authored-by: pbearne <pbearne@git.wordpress.org> Co-authored-by: smithjw1 <smithjw1@git.wordpress.org>
1 parent a23999b commit 21529a3

13 files changed

Lines changed: 228 additions & 57 deletions

File tree

‎packages/base-styles/_mixins.scss‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -672,6 +672,7 @@
672672
outline-style: solid;
673673
outline-width: calc(#{$widthRatio} * (var(--wp-admin-border-width-focus) / var(--wp-block-editor-iframe-zoom-out-scale, 1)));
674674
outline-offset: calc(#{$widthRatio} * ((-1 * var(--wp-admin-border-width-focus) ) / var(--wp-block-editor-iframe-zoom-out-scale, 1)));
675+
box-shadow: inset 0 0 0 calc(var(--wp-admin-border-width-focus, #{variables.$border-width-focus-fallback}) + #{variables.$border-width}) colors.$white, 0 0 0 variables.$border-width colors.$white, variables.$elevation-x-small;
675676
}
676677

677678
@mixin selected-block-focus($widthRatio: 1) {

‎packages/edit-post/src/index.js‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ export function initializeEditor(
7272
showListViewByDefault: false,
7373
enableChoosePatternModal: true,
7474
isPublishSidebarEnabled: true,
75+
showCollaborationCursor: false,
7576
} );
7677

7778
if ( window.__clientSideMediaProcessing ) {

‎packages/edit-site/src/index.js‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ export function initializeEditor( id, settings ) {
7373
showBlockBreadcrumbs: true,
7474
showListViewByDefault: false,
7575
enableChoosePatternModal: true,
76+
showCollaborationCursor: false,
7677
} );
7778

7879
if ( window.__clientSideMediaProcessing ) {

‎packages/editor/CHANGELOG.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,12 @@
22

33
## Unreleased
44

5+
### Bug Fixes
6+
7+
- Fixed avatar contrast ring not displaying over loaded images by using a `::after` pseudo-element instead of an inset `box-shadow`.
8+
9+
## 14.41.0 (2026-03-04)
10+
511
## 14.40.0 (2026-02-18)
612

713
## 14.39.0 (2026-01-29)

‎packages/editor/src/components/collaborators-overlay/avatar-iframe-styles.ts‎

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,6 @@ export const AVATAR_IFRAME_STYLES = `
2828
display: inline-flex;
2929
align-items: center;
3030
border-radius: ${ RADIUS_FULL };
31-
overflow: hidden;
32-
overflow: clip;
3331
flex-shrink: 0;
3432
box-shadow: 0 0 0 var(--wp-admin-border-width-focus, 2px) ${ WHITE }, ${ ELEVATION_X_SMALL };
3533
}
@@ -53,9 +51,17 @@ export const AVATAR_IFRAME_STYLES = `
5351
}
5452
.has-avatar-border-color > .editor-avatar__image {
5553
border: var(--wp-admin-border-width-focus, 2px) solid var(--editor-avatar-outline-color);
56-
box-shadow: inset 0 0 0 var(--wp-admin-border-width-focus, 2px) ${ WHITE };
5754
background-clip: padding-box;
5855
}
56+
.has-avatar-border-color > .editor-avatar__image::after {
57+
content: "";
58+
position: absolute;
59+
inset: 0;
60+
border-radius: inherit;
61+
box-shadow: inset 0 0 0 var(--wp-admin-border-width-focus, 2px) ${ WHITE };
62+
pointer-events: none;
63+
z-index: 1;
64+
}
5965
.editor-avatar__img {
6066
position: absolute;
6167
inset: 0;
@@ -75,9 +81,11 @@ export const AVATAR_IFRAME_STYLES = `
7581
font-size: ${ FONT_SIZE_X_SMALL };
7682
font-weight: ${ FONT_WEIGHT_MEDIUM };
7783
border: 0;
78-
box-shadow: none;
7984
background-clip: border-box;
8085
}
86+
.editor-avatar:not(.has-src) > .editor-avatar__image::after {
87+
content: none;
88+
}
8189
.editor-avatar:not(.has-src).has-avatar-border-color > .editor-avatar__image {
8290
background-color: var(--editor-avatar-outline-color);
8391
}

‎packages/editor/src/components/collaborators-overlay/overlay.tsx‎

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { useResizeObserver, useMergeRefs } from '@wordpress/compose';
22
import { useCallback, useEffect, useState } from '@wordpress/element';
3+
import { __ } from '@wordpress/i18n';
34

45
import Avatar from '../collaborators-presence/avatar';
56
import { AVATAR_IFRAME_STYLES } from './avatar-iframe-styles';
@@ -100,19 +101,22 @@ export function Overlay( {
100101
top: `${ cursor.y }px`,
101102
} }
102103
>
103-
<div
104-
className="collaborators-overlay-user-cursor"
105-
style={ {
106-
backgroundColor: cursor.color,
107-
height: `${ cursor.height }px`,
108-
} }
109-
/>
104+
{ ! cursor.isMe && (
105+
<div
106+
className="collaborators-overlay-user-cursor"
107+
style={ {
108+
backgroundColor: cursor.color,
109+
height: `${ cursor.height }px`,
110+
} }
111+
/>
112+
) }
110113
<Avatar
111114
className="collaborators-overlay-user-label"
112115
variant="badge"
113116
size="small"
114117
src={ cursor.avatarUrl }
115118
name={ cursor.userName }
119+
label={ cursor.isMe ? __( 'You' ) : undefined }
116120
borderColor={ cursor.color }
117121
/>
118122
</div>

‎packages/editor/src/components/collaborators-overlay/use-block-highlighting.ts‎

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
import {
55
privateApis as coreDataPrivateApis,
66
SelectionType,
7-
type PostEditorAwarenessState,
7+
type PostEditorAwarenessState as ActiveCollaborator,
88
} from '@wordpress/core-data';
99
import { useEffect, useRef, useState } from '@wordpress/element';
1010

@@ -50,7 +50,7 @@ export function useBlockHighlighting(
5050
rerenderHighlightsAfterDelay: () => () => void;
5151
} {
5252
const highlightedBlockIds = useRef< Set< string > >( new Set() );
53-
const userStates: PostEditorAwarenessState[] = useActiveCollaborators(
53+
const userStates: ActiveCollaborator[] = useActiveCollaborators(
5454
postId ?? null,
5555
postType ?? null
5656
);
@@ -82,12 +82,13 @@ export function useBlockHighlighting(
8282
// same block, only the first one gets the highlight and avatar label.
8383
const seen = new Set< string >();
8484
const blocksToHighlight = userStates
85-
.filter(
86-
( userState ) =>
87-
! userState.isMe &&
85+
.filter( ( userState: ActiveCollaborator ) => {
86+
const isWholeBlockSelected =
8887
userState.editorState?.selection?.type ===
89-
SelectionType.WholeBlock
90-
)
88+
SelectionType.WholeBlock;
89+
90+
return ! userState.isMe && isWholeBlockSelected;
91+
} )
9192
.map( ( userState ) => {
9293
let localClientId;
9394
try {
@@ -104,9 +105,9 @@ export function useBlockHighlighting(
104105

105106
return {
106107
blockId: localClientId,
107-
color: getAvatarBorderColor(
108-
userState.collaboratorInfo.id
109-
),
108+
color: userState.isMe
109+
? 'var(--wp-admin-theme-color)'
110+
: getAvatarBorderColor( userState.collaboratorInfo.id ),
110111
userName: userState.collaboratorInfo.name,
111112
avatarUrl: getAvatarUrl(
112113
userState.collaboratorInfo.avatar_urls

‎packages/editor/src/components/collaborators-overlay/use-render-cursors.ts‎

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
import {
22
privateApis as coreDataPrivateApis,
33
SelectionType,
4+
type PostEditorAwarenessState as ActiveCollaborator,
45
} from '@wordpress/core-data';
6+
import { useSelect } from '@wordpress/data';
57
import { useEffect, useState } from '@wordpress/element';
8+
import { store as preferencesStore } from '@wordpress/preferences';
69
import type { ResolvedSelection } from '@wordpress/core-data';
710

811
import { unlock } from '../../lock-unlock';
@@ -25,6 +28,7 @@ export interface CursorData {
2528
x: number;
2629
y: number;
2730
height: number;
31+
isMe?: boolean;
2832
selectionRects?: SelectionRect[];
2933
}
3034

@@ -54,6 +58,12 @@ export function useRenderCursors(
5458
postType ?? null
5559
);
5660

61+
const showOwnCursor = useSelect(
62+
( select ) =>
63+
select( preferencesStore ).get( 'core', 'showCollaborationCursor' ),
64+
[]
65+
);
66+
5767
const [ cursorPositions, setCursorPositions ] = useState< CursorData[] >(
5868
[]
5969
);
@@ -78,8 +88,12 @@ export function useRenderCursors(
7888

7989
const results: CursorData[] = [];
8090

81-
sortedUsers.forEach( ( user: any ) => {
82-
if ( user.isMe ) {
91+
const hasOtherCollaborators = sortedUsers.some(
92+
( u: ActiveCollaborator ) => ! u.isMe
93+
);
94+
95+
sortedUsers.forEach( ( user: ActiveCollaborator ) => {
96+
if ( user.isMe && ( ! showOwnCursor || ! hasOtherCollaborators ) ) {
8397
return;
8498
}
8599

@@ -122,7 +136,9 @@ export function useRenderCursors(
122136

123137
const userName = user.collaboratorInfo.name;
124138
const clientId = user.clientId;
125-
const color = getAvatarBorderColor( user.collaboratorInfo.id );
139+
const color = user.isMe
140+
? 'var(--wp-admin-theme-color)'
141+
: getAvatarBorderColor( user.collaboratorInfo.id );
126142
const avatarUrl = getAvatarUrl( user.collaboratorInfo.avatar_urls );
127143

128144
const selectionVisual = computeSelectionVisual(
@@ -138,6 +154,7 @@ export function useRenderCursors(
138154
clientId,
139155
color,
140156
avatarUrl,
157+
isMe: user.isMe,
141158
...selectionVisual.coords,
142159
};
143160

@@ -155,6 +172,7 @@ export function useRenderCursors(
155172
resolveSelection,
156173
overlayElement,
157174
sortedUsers,
175+
showOwnCursor,
158176
recomputeToken,
159177
] );
160178

‎packages/editor/src/components/collaborators-presence/avatar/styles.scss‎

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,14 @@
55
// fallback matching the default WordPress admin blue.
66
$-accent-color: var(--wp-admin-theme-color, #3858e9);
77

8+
// overflow: clip is intentionally omitted here — .editor-avatar__image handles
9+
// its own circular clip. Removing overflow from this container allows the inset
10+
// box-shadow on __image to render without being clipped by the parent.
811
.editor-avatar {
912
position: relative;
1013
display: inline-flex;
1114
align-items: center;
1215
border-radius: $radius-full;
13-
overflow: hidden;
14-
overflow: clip;
1516
flex-shrink: 0;
1617
box-shadow: 0 0 0 var(--wp-admin-border-width-focus) $white, $elevation-x-small;
1718
}
@@ -37,8 +38,20 @@ $-accent-color: var(--wp-admin-theme-color, #3858e9);
3738

3839
.has-avatar-border-color > & {
3940
border: var(--wp-admin-border-width-focus) solid var(--editor-avatar-outline-color);
40-
box-shadow: inset 0 0 0 var(--wp-admin-border-width-focus) $white;
4141
background-clip: padding-box;
42+
43+
// Inner contrast ring rendered as a pseudo-element so it paints above
44+
// the absolutely-positioned <img>. An inset box-shadow would be
45+
// covered by the image content layer.
46+
&::after {
47+
content: "";
48+
position: absolute;
49+
inset: 0;
50+
border-radius: inherit;
51+
box-shadow: inset 0 0 0 var(--wp-admin-border-width-focus, 2px) $white;
52+
pointer-events: none;
53+
z-index: 1;
54+
}
4255
}
4356
}
4457

@@ -66,8 +79,11 @@ $-accent-color: var(--wp-admin-theme-color, #3858e9);
6679
font-size: $font-size-x-small;
6780
font-weight: $font-weight-medium;
6881
border: 0;
69-
box-shadow: none;
7082
background-clip: border-box;
83+
84+
&::after {
85+
content: none;
86+
}
7187
}
7288

7389
.editor-avatar:not(.has-src).has-avatar-border-color > .editor-avatar__image {

‎packages/editor/src/components/collaborators-presence/index.tsx‎

Lines changed: 30 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { Button } from '@wordpress/components';
2-
import { useState } from '@wordpress/element';
2+
import { useMemo, useState } from '@wordpress/element';
33
import {
44
privateApis,
55
type PostEditorAwarenessState,
@@ -40,11 +40,23 @@ export function CollaboratorsPresence( {
4040
postType
4141
) as PostEditorAwarenessState[];
4242

43-
// Filter out current user - we never show ourselves in the list
4443
const otherActiveCollaborators = activeCollaborators.filter(
45-
( collaborator ) => ! collaborator.isMe
44+
( c ) => ! c.isMe
4645
);
4746

47+
// Always include self in the list sorted first.
48+
const collaboratorsForList = useMemo( () => {
49+
return [ ...activeCollaborators ].sort( ( a, b ) => {
50+
if ( a.isMe && ! b.isMe ) {
51+
return -1;
52+
}
53+
if ( ! a.isMe && b.isMe ) {
54+
return 1;
55+
}
56+
return 0;
57+
} );
58+
}, [ activeCollaborators ] );
59+
4860
const [ isPopoverVisible, setIsPopoverVisible ] = useState( false );
4961
const [ popoverAnchor, setPopoverAnchor ] = useState< HTMLElement | null >(
5062
null
@@ -57,6 +69,8 @@ export function CollaboratorsPresence( {
5769
return null;
5870
}
5971

72+
const me = activeCollaborators.find( ( c ) => c.isMe );
73+
6074
return (
6175
<>
6276
<div className="editor-collaborators-presence">
@@ -69,10 +83,21 @@ export function CollaboratorsPresence( {
6983
aria-label={ sprintf(
7084
// translators: %d: number of online collaborators.
7185
__( 'Collaborators list, %d online' ),
72-
otherActiveCollaborators.length
86+
collaboratorsForList.length
7387
) }
7488
>
7589
<AvatarGroup max={ 4 }>
90+
{ me && (
91+
<Avatar
92+
key={ me.clientId }
93+
src={ getAvatarUrl(
94+
me.collaboratorInfo.avatar_urls
95+
) }
96+
name={ me.collaboratorInfo.name }
97+
borderColor="var(--wp-admin-theme-color)"
98+
size="small"
99+
/>
100+
) }
76101
{ otherActiveCollaborators.map(
77102
( collaboratorState ) => (
78103
<Avatar
@@ -95,7 +120,7 @@ export function CollaboratorsPresence( {
95120
</Button>
96121
{ isPopoverVisible && (
97122
<CollaboratorsList
98-
activeCollaborators={ otherActiveCollaborators }
123+
activeCollaborators={ collaboratorsForList }
99124
popoverAnchor={ popoverAnchor }
100125
setIsPopoverVisible={ setIsPopoverVisible }
101126
/>

0 commit comments

Comments
 (0)