Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/base-styles/_mixins.scss
Original file line number Diff line number Diff line change
Expand Up @@ -689,6 +689,7 @@
outline-style: solid;
outline-width: calc(#{$widthRatio} * (var(--wp-admin-border-width-focus) / var(--wp-block-editor-iframe-zoom-out-scale, 1)));
outline-offset: calc(#{$widthRatio} * ((-1 * var(--wp-admin-border-width-focus) ) / var(--wp-block-editor-iframe-zoom-out-scale, 1)));
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;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@dabowman this change is quite aggressive for the default editing experience, especially in some darker contexts. Can you expand on the context for this change? I understand that it can be a constrast thing, but I would love for us to find something slightly more balanced/considered, as the hover-border is now about 3px around the whole block:

Image

If we need a halo, can it be 1px at most, on the inside or outside but not both? And can it be semi opaque?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, it's definitely bright on dark backgrounds.

We were getting feedback to make the users own presence clearer. The approach we decided on was to include the user in the list of collaborators and make their outline style match the collaborators' to reinforce the pattern and make presence indication consistent.

I think that's a good approach but ... contrast is an issue. I dug into this quite a bit earlier on and there are some potential solutions to make the style and other styles in the UI more responsive to their context so we're not using this belt and braces approach to contrast when we don't need to. But all of them were either super complex or kinda unreliable so I ended up going with the approach you see to make sure we covered our bases. But the contrast can be too much.

On collaborators, the contest is more needed for a11y. But I think that's less of a concern for the highlight style we're talking about here since the user has the block toolbar etc. to show their focus. The issue is just consistency and how we want to align it with the collaborators without introducing other issues.

I'm totally fine just rolling it back if you think that's the right call. I think we'll want to make it all consistent eventually but we might be able to cook up a more clever way of doing that across the UI.

I think we can make the case for doing it either way. Increasing the transparency on the outline might be the way to go. Or just ditching it. Whatever looks good to your eye. I was deep in the a11y requirements and the odd constraints of CSS and the editor canvas' odd way of applying styles when I made this.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm tempted to do without this as it is part of a piece of mixin code whose consequences are a bit hard to predict. But the motivation is nevertheless very good, so I took a stab at a refinement here: #76619

Let me know what you think!

}

@mixin selected-block-focus($widthRatio: 1) {
Expand Down
1 change: 1 addition & 0 deletions packages/edit-post/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ export function initializeEditor(
showListViewByDefault: false,
enableChoosePatternModal: true,
isPublishSidebarEnabled: true,
showCollaborationCursor: false,
} );

if ( window.__clientSideMediaProcessing ) {
Expand Down
1 change: 1 addition & 0 deletions packages/edit-site/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ export function initializeEditor( id, settings ) {
showBlockBreadcrumbs: true,
showListViewByDefault: false,
enableChoosePatternModal: true,
showCollaborationCursor: false,
} );

if ( window.__clientSideMediaProcessing ) {
Expand Down
4 changes: 4 additions & 0 deletions packages/editor/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

## Unreleased

### Bug Fixes

- Fixed avatar contrast ring not displaying over loaded images by using a `::after` pseudo-element instead of an inset `box-shadow`.

## 14.41.0 (2026-03-04)

## 14.40.0 (2026-02-18)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,6 @@ export const AVATAR_IFRAME_STYLES = `
display: inline-flex;
align-items: center;
border-radius: ${ RADIUS_FULL };
overflow: hidden;
overflow: clip;
flex-shrink: 0;
box-shadow: 0 0 0 var(--wp-admin-border-width-focus, 2px) ${ WHITE }, ${ ELEVATION_X_SMALL };
}
Expand All @@ -53,9 +51,17 @@ export const AVATAR_IFRAME_STYLES = `
}
.has-avatar-border-color > .editor-avatar__image {
border: var(--wp-admin-border-width-focus, 2px) solid var(--editor-avatar-outline-color);
box-shadow: inset 0 0 0 var(--wp-admin-border-width-focus, 2px) ${ WHITE };
background-clip: padding-box;
}
.has-avatar-border-color > .editor-avatar__image::after {
content: "";
position: absolute;
inset: 0;
border-radius: inherit;
box-shadow: inset 0 0 0 var(--wp-admin-border-width-focus, 2px) ${ WHITE };
pointer-events: none;
z-index: 1;
}
.editor-avatar__img {
position: absolute;
inset: 0;
Expand All @@ -75,9 +81,11 @@ export const AVATAR_IFRAME_STYLES = `
font-size: ${ FONT_SIZE_X_SMALL };
font-weight: ${ FONT_WEIGHT_MEDIUM };
border: 0;
box-shadow: none;
background-clip: border-box;
}
.editor-avatar:not(.has-src) > .editor-avatar__image::after {
content: none;
}
.editor-avatar:not(.has-src).has-avatar-border-color > .editor-avatar__image {
background-color: var(--editor-avatar-outline-color);
}
Expand Down
45 changes: 25 additions & 20 deletions packages/editor/src/components/collaborators-overlay/overlay.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { useResizeObserver, useMergeRefs } from '@wordpress/compose';
import { useCallback, useEffect, useState } from '@wordpress/element';
import { __ } from '@wordpress/i18n';

import Avatar from '../collaborators-presence/avatar';
import { AVATAR_IFRAME_STYLES } from './avatar-iframe-styles';
Expand Down Expand Up @@ -80,39 +81,43 @@ export function Overlay( {
<style>{ AVATAR_IFRAME_STYLES + OVERLAY_IFRAME_STYLES }</style>
{ cursors.map( ( cursor ) => (
<div key={ cursor.clientId }>
{ cursor.selectionRects?.map( ( rect, index ) => (
<div
key={ `${ cursor.clientId }-sel-${ index }` }
className="collaborators-overlay-selection-rect"
style={ {
left: `${ rect.x }px`,
top: `${ rect.y }px`,
width: `${ rect.width }px`,
height: `${ rect.height }px`,
backgroundColor: cursor.color,
} }
/>
) ) }
{ ! cursor.isMe &&
cursor.selectionRects?.map( ( rect, index ) => (
<div
key={ `${ cursor.clientId }-sel-${ index }` }
className="collaborators-overlay-selection-rect"
style={ {
left: `${ rect.x }px`,
top: `${ rect.y }px`,
width: `${ rect.width }px`,
height: `${ rect.height }px`,
backgroundColor: cursor.color,
} }
/>
) ) }
<div
className="collaborators-overlay-user"
style={ {
left: `${ cursor.x }px`,
top: `${ cursor.y }px`,
} }
>
<div
className="collaborators-overlay-user-cursor"
style={ {
backgroundColor: cursor.color,
height: `${ cursor.height }px`,
} }
/>
{ ! cursor.isMe && (
<div
className="collaborators-overlay-user-cursor"
style={ {
backgroundColor: cursor.color,
height: `${ cursor.height }px`,
} }
/>
) }
<Avatar
className="collaborators-overlay-user-label"
variant="badge"
size="small"
src={ cursor.avatarUrl }
name={ cursor.userName }
label={ cursor.isMe ? __( 'You' ) : undefined }
borderColor={ cursor.color }
/>
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
import {
privateApis as coreDataPrivateApis,
SelectionType,
type PostEditorAwarenessState,
type PostEditorAwarenessState as ActiveCollaborator,
} from '@wordpress/core-data';
import { useEffect, useRef, useState } from '@wordpress/element';

Expand Down Expand Up @@ -50,7 +50,7 @@ export function useBlockHighlighting(
rerenderHighlightsAfterDelay: () => () => void;
} {
const highlightedBlockIds = useRef< Set< string > >( new Set() );
const userStates: PostEditorAwarenessState[] = useActiveCollaborators(
const userStates: ActiveCollaborator[] = useActiveCollaborators(
postId ?? null,
postType ?? null
);
Expand Down Expand Up @@ -82,12 +82,13 @@ export function useBlockHighlighting(
// same block, only the first one gets the highlight and avatar label.
const seen = new Set< string >();
const blocksToHighlight = userStates
.filter(
( userState ) =>
! userState.isMe &&
.filter( ( userState: ActiveCollaborator ) => {
const isWholeBlockSelected =
userState.editorState?.selection?.type ===
SelectionType.WholeBlock
)
SelectionType.WholeBlock;

return ! userState.isMe && isWholeBlockSelected;
} )
.map( ( userState ) => {
let localClientId;
try {
Expand All @@ -104,9 +105,9 @@ export function useBlockHighlighting(

return {
blockId: localClientId,
color: getAvatarBorderColor(
userState.collaboratorInfo.id
),
color: userState.isMe
? 'var(--wp-admin-theme-color)'
: getAvatarBorderColor( userState.collaboratorInfo.id ),
userName: userState.collaboratorInfo.name,
avatarUrl: getAvatarUrl(
userState.collaboratorInfo.avatar_urls
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import {
privateApis as coreDataPrivateApis,
SelectionType,
type PostEditorAwarenessState as ActiveCollaborator,
} from '@wordpress/core-data';
import { useSelect } from '@wordpress/data';
import { useEffect, useState } from '@wordpress/element';
import { store as preferencesStore } from '@wordpress/preferences';
import type { ResolvedSelection } from '@wordpress/core-data';

import { unlock } from '../../lock-unlock';
Expand All @@ -25,6 +28,7 @@ export interface CursorData {
x: number;
y: number;
height: number;
isMe?: boolean;
selectionRects?: SelectionRect[];
}

Expand Down Expand Up @@ -54,6 +58,12 @@ export function useRenderCursors(
postType ?? null
);

const showOwnCursor = useSelect(
( select ) =>
select( preferencesStore ).get( 'core', 'showCollaborationCursor' ),
[]
);

const [ cursorPositions, setCursorPositions ] = useState< CursorData[] >(
[]
);
Expand All @@ -78,8 +88,12 @@ export function useRenderCursors(

const results: CursorData[] = [];

sortedUsers.forEach( ( user: any ) => {
if ( user.isMe ) {
const hasOtherCollaborators = sortedUsers.some(
( u: ActiveCollaborator ) => ! u.isMe
);

sortedUsers.forEach( ( user: ActiveCollaborator ) => {
if ( user.isMe && ( ! showOwnCursor || ! hasOtherCollaborators ) ) {
return;
}

Expand Down Expand Up @@ -122,7 +136,9 @@ export function useRenderCursors(

const userName = user.collaboratorInfo.name;
const clientId = user.clientId;
const color = getAvatarBorderColor( user.collaboratorInfo.id );
const color = user.isMe
? 'var(--wp-admin-theme-color)'
: getAvatarBorderColor( user.collaboratorInfo.id );
const avatarUrl = getAvatarUrl( user.collaboratorInfo.avatar_urls );

const selectionVisual = computeSelectionVisual(
Expand All @@ -138,6 +154,7 @@ export function useRenderCursors(
clientId,
color,
avatarUrl,
isMe: user.isMe,
...selectionVisual.coords,
};

Expand All @@ -155,6 +172,7 @@ export function useRenderCursors(
resolveSelection,
overlayElement,
sortedUsers,
showOwnCursor,
recomputeToken,
] );

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,14 @@
// fallback matching the default WordPress admin blue.
$-accent-color: var(--wp-admin-theme-color, #3858e9);

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

.has-avatar-border-color > & {
border: var(--wp-admin-border-width-focus) solid var(--editor-avatar-outline-color);
box-shadow: inset 0 0 0 var(--wp-admin-border-width-focus) $white;
background-clip: padding-box;

// Inner contrast ring rendered as a pseudo-element so it paints above
// the absolutely-positioned <img>. An inset box-shadow would be
// covered by the image content layer.
&::after {
content: "";
position: absolute;
inset: 0;
border-radius: inherit;
box-shadow: inset 0 0 0 var(--wp-admin-border-width-focus, 2px) $white;
pointer-events: none;
z-index: 1;
}
}
}

Expand Down Expand Up @@ -66,8 +79,11 @@ $-accent-color: var(--wp-admin-theme-color, #3858e9);
font-size: $font-size-x-small;
font-weight: $font-weight-medium;
border: 0;
box-shadow: none;
background-clip: border-box;

&::after {
content: none;
}
}

.editor-avatar:not(.has-src).has-avatar-border-color > .editor-avatar__image {
Expand Down
35 changes: 30 additions & 5 deletions packages/editor/src/components/collaborators-presence/index.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Button } from '@wordpress/components';
import { useState } from '@wordpress/element';
import { useMemo, useState } from '@wordpress/element';
import {
privateApis,
type PostEditorAwarenessState,
Expand Down Expand Up @@ -40,11 +40,23 @@ export function CollaboratorsPresence( {
postType
) as PostEditorAwarenessState[];

// Filter out current user - we never show ourselves in the list
const otherActiveCollaborators = activeCollaborators.filter(
( collaborator ) => ! collaborator.isMe
( c ) => ! c.isMe
);

// Always include self in the list sorted first.
const collaboratorsForList = useMemo( () => {
return [ ...activeCollaborators ].sort( ( a, b ) => {
if ( a.isMe && ! b.isMe ) {
return -1;
}
if ( ! a.isMe && b.isMe ) {
return 1;
}
return 0;
} );
}, [ activeCollaborators ] );

const [ isPopoverVisible, setIsPopoverVisible ] = useState( false );
const [ popoverAnchor, setPopoverAnchor ] = useState< HTMLElement | null >(
null
Expand All @@ -57,6 +69,8 @@ export function CollaboratorsPresence( {
return null;
}

const me = activeCollaborators.find( ( c ) => c.isMe );

return (
<>
<div className="editor-collaborators-presence">
Expand All @@ -69,10 +83,21 @@ export function CollaboratorsPresence( {
aria-label={ sprintf(
// translators: %d: number of online collaborators.
__( 'Collaborators list, %d online' ),
otherActiveCollaborators.length
collaboratorsForList.length
) }
>
<AvatarGroup max={ 4 }>
{ me && (
<Avatar
key={ me.clientId }
src={ getAvatarUrl(
me.collaboratorInfo.avatar_urls
) }
name={ me.collaboratorInfo.name }
borderColor="var(--wp-admin-theme-color)"
size="small"
/>
) }
{ otherActiveCollaborators.map(
( collaboratorState ) => (
<Avatar
Expand All @@ -95,7 +120,7 @@ export function CollaboratorsPresence( {
</Button>
{ isPopoverVisible && (
<CollaboratorsList
activeCollaborators={ otherActiveCollaborators }
activeCollaborators={ collaboratorsForList }
popoverAnchor={ popoverAnchor }
setIsPopoverVisible={ setIsPopoverVisible }
/>
Expand Down
Loading
Loading