Add remote cursors and a presence strip (#37)
All checks were successful
CD / Build and push images (push) Successful in 2m54s
CI / Lint, typecheck, test (push) Successful in 1m58s
CI / Auth e2e pack (push) Successful in 2m10s
CI / Build container images (push) Has been skipped
CD / Deploy to Test (push) Successful in 9s
CD / Smoke tests against Test (push) Successful in 1m15s
CD / Promote to Int (push) Successful in 11s

Seeing other participants live (ADR 0003/0004, realtime-collaboration.md
§Awareness):

- The collaboration-caret extension renders remote carets and selections
  with a name flag and a per-user colour. Colours come from a small,
  hand-picked palette hashed by user id (FNV-1a), so they are stable across
  sessions; a unit test asserts each palette colour clears WCAG AA contrast
  (4.5:1) against the white label text.
- A presence strip at the top of the page shows an avatar (initials) per
  connected participant, deduplicated by user id, with an overflow count.
  Read-only participants appear in the strip (with a marker) but broadcast
  no caret — the caret render suppresses read-only users — so the same
  awareness feed drives both cursors and presence. Own identity (id +
  display name) comes from the auth context into the awareness `user` field.
- Presence updates on every awareness change, so a disconnect drops the
  participant within seconds.

The collab e2e pack gains a test: two browsers see each other in the
presence strip, one participant's named caret appears in the other's editor,
and disconnecting removes them. Validated locally against the full stack.
de + en strings and cursor/presence styles added.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PGdhRiwU1WRL4XxJfZYipY
This commit is contained in:
Claude Opus 4.8 2026-07-08 18:22:41 +02:00
parent 7d04c0b594
commit 63fe6af6b0
12 changed files with 375 additions and 2 deletions

View File

@ -88,6 +88,39 @@ test('offline edits continue locally and sync on reconnect', async ({ browser })
await admin.close();
});
test('remote carets and the presence strip reflect participants (#37)', async ({ browser }) => {
const owner = await contextForUser(browser, BASE_URL, 'fixture-user');
const admin = await contextForUser(browser, BASE_URL, 'fixture-admin');
const ownerMe = (await (await owner.request.get('/api/v1/auth/me')).json()) as {
displayName: string;
};
const pond = await personalPond(owner);
const created = await owner.request.post(`/api/v1/ponds/${pond.id}/pages`, {
data: { title: `Collab Presence ${Date.now()}` },
});
const { slug } = await created.json();
const pageA = await openEditor(owner, pond.slug, slug);
const pageB = await openEditor(admin, pond.slug, slug);
// Both participants appear in each presence strip.
await expect(pageA.locator('.presence-avatar')).toHaveCount(2, { timeout: 10000 });
await expect(pageB.locator('.presence-avatar')).toHaveCount(2, { timeout: 10000 });
// The owner's cursor shows up as a named caret in the admin's editor.
await pageA.locator('.ProseMirror').click();
await pageA.keyboard.type('cursor is here');
await expect(pageB.locator('.collab-caret__label')).toHaveText(ownerMe.displayName, {
timeout: 10000,
});
// Disconnecting the owner drops them from the admin's presence strip.
await owner.close();
await expect(pageB.locator('.presence-avatar')).toHaveCount(1, { timeout: 15000 });
await admin.close();
});
// Read-only participants (live changes visible, typing blocked, reason shown)
// need a real read-only grant to obtain a `ro` collab token. Under the interim
// access model seeing and modifying coincide, so no user is issued a `ro` token

View File

@ -21,6 +21,7 @@
"@tiptap/core": "^3.27.1",
"@tiptap/extension-bubble-menu": "^3.27.1",
"@tiptap/extension-collaboration": "^3.27.1",
"@tiptap/extension-collaboration-caret": "^3.27.1",
"@tiptap/pm": "^3.27.1",
"@tiptap/react": "^3.27.1",
"i18next": "^26.3.4",

View File

@ -0,0 +1,60 @@
import type { HocuspocusProvider } from '@hocuspocus/provider';
import { useTranslation } from 'react-i18next';
import { usePresence } from './use-presence';
const MAX_AVATARS = 5;
/** One or two letters standing in for an avatar image. */
function initials(name: string): string {
const parts = name.trim().split(/\s+/).filter(Boolean);
if (parts.length === 0) return '?';
if (parts.length === 1) return parts[0]!.slice(0, 2).toUpperCase();
return (parts[0]![0]! + parts[parts.length - 1]![0]!).toUpperCase();
}
/** Avatar strip of the participants currently connected to the page (#37). */
export function PresenceStrip({
provider,
}: {
provider: HocuspocusProvider | null;
}): React.JSX.Element | null {
const { t } = useTranslation('editor');
const participants = usePresence(provider);
if (participants.length === 0) return null;
const shown = participants.slice(0, MAX_AVATARS);
const overflow = participants.length - shown.length;
return (
<div
className="presence-strip"
aria-label={t('presence.label', { count: participants.length })}
>
{shown.map((participant) => (
<span
key={participant.userId}
className="presence-avatar"
style={{ backgroundColor: participant.color }}
title={
participant.readOnly
? t('presence.viewer', { name: participant.name })
: participant.name
}
>
{initials(participant.name)}
{participant.readOnly && (
<span className="presence-avatar__ro" aria-hidden="true">
</span>
)}
</span>
))}
{overflow > 0 && (
<span className="presence-avatar presence-avatar--overflow" aria-hidden="true">
+{overflow}
</span>
)}
</div>
);
}

View File

@ -0,0 +1,62 @@
import type { HocuspocusProvider } from '@hocuspocus/provider';
import { CollaborationCaret } from '@tiptap/extension-collaboration-caret';
import { colorForUser } from './user-color';
/** The awareness `user` payload each client broadcasts (issue #37). */
export interface CaretUser {
userId: string;
name: string;
color: string;
readOnly: boolean;
}
/** Remote caret with a name flag. Read-only participants broadcast presence
* but render no caret (they appear in the strip only). */
function renderCaret(user: Record<string, unknown>): HTMLElement {
const caret = document.createElement('span');
if (user.readOnly) {
caret.className = 'collab-caret collab-caret--hidden';
return caret;
}
const color = String(user.color);
caret.className = 'collab-caret';
caret.style.borderColor = color;
const label = document.createElement('span');
label.className = 'collab-caret__label';
label.style.backgroundColor = color;
label.textContent = String(user.name ?? '');
caret.appendChild(label);
return caret;
}
function renderSelection(user: Record<string, unknown>): Record<string, string> {
return {
nodeName: 'span',
class: 'collab-selection',
// 8-digit hex adds ~20% alpha so the selection tint stays subtle.
style: user.readOnly
? 'background-color: transparent'
: `background-color: ${String(user.color)}33`,
};
}
/** The collaboration-caret extension configured for the current user. */
export function collaborationCaretFor(
provider: HocuspocusProvider,
user: { id: string; displayName: string },
readOnly: boolean,
) {
const caretUser: CaretUser = {
userId: user.id,
name: user.displayName,
color: colorForUser(user.id),
readOnly,
};
return CollaborationCaret.configure({
provider,
user: caretUser,
render: renderCaret,
selectionRender: renderSelection,
});
}

View File

@ -0,0 +1,43 @@
import type { HocuspocusProvider } from '@hocuspocus/provider';
import { useEffect, useState } from 'react';
import type { CaretUser } from './collaboration-caret';
export type Participant = CaretUser;
/**
* The current participants on a page, derived from the collab awareness states
* (issue #37). Deduplicated by user id (a user with two tabs is one avatar) and
* updated whenever awareness changes, so a disconnect drops the participant
* within seconds.
*/
export function usePresence(provider: HocuspocusProvider | null): Participant[] {
const [participants, setParticipants] = useState<Participant[]>([]);
useEffect(() => {
const awareness = provider?.awareness;
if (!awareness) {
setParticipants([]);
return;
}
const update = (): void => {
const byUser = new Map<string, Participant>();
for (const state of awareness.getStates().values()) {
const user = (state as { user?: Partial<CaretUser> }).user;
if (!user?.userId || byUser.has(user.userId)) continue;
byUser.set(user.userId, {
userId: user.userId,
name: user.name ?? '',
color: user.color ?? '#495057',
readOnly: Boolean(user.readOnly),
});
}
setParticipants([...byUser.values()]);
};
update();
awareness.on('change', update);
return () => awareness.off('change', update);
}, [provider]);
return participants;
}

View File

@ -0,0 +1,38 @@
import { describe, expect, it } from 'vitest';
import { colorForUser, USER_COLORS } from './user-color';
/** WCAG relative luminance of an sRGB hex colour. */
function luminance(hex: string): number {
const channels = [1, 3, 5].map((i) => {
const c = parseInt(hex.slice(i, i + 2), 16) / 255;
return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
});
return 0.2126 * channels[0]! + 0.7152 * channels[1]! + 0.0722 * channels[2]!;
}
/** Contrast ratio between two colours (WCAG 2.x). */
function contrast(a: string, b: string): number {
const [hi, lo] = [luminance(a), luminance(b)].sort((x, y) => y - x);
return (hi! + 0.05) / (lo! + 0.05);
}
describe('user colours', () => {
it('assigns a stable colour for the same user id', () => {
expect(colorForUser('user-abc')).toBe(colorForUser('user-abc'));
expect(colorForUser('11111111-2222-3333-4444-555555555555')).toBe(
colorForUser('11111111-2222-3333-4444-555555555555'),
);
});
it('every palette colour clears AA contrast against white label text', () => {
for (const color of USER_COLORS) {
expect(contrast('#ffffff', color), `contrast for ${color}`).toBeGreaterThanOrEqual(4.5);
}
});
it('spreads ids across more than one colour', () => {
const used = new Set(Array.from({ length: 50 }, (_, i) => colorForUser(`user-${i}`)));
expect(used.size).toBeGreaterThan(1);
});
});

View File

@ -0,0 +1,32 @@
/**
* Stable, distinguishable colours for collaboration cursors and presence
* avatars (issue #37). Each colour is dark enough that white label text on it
* clears the WCAG AA contrast ratio (4.5:1) asserted in `user-color.test.ts`.
* The palette is deliberately small and hand-picked rather than generated, so
* every colour is legible and the set stays visually balanced.
*/
export const USER_COLORS = [
'#1a5fb4', // blue
'#26734d', // green
'#6c3fb5', // purple
'#b02a37', // red
'#9a4a00', // brown
'#a03672', // magenta
'#0e6b7d', // teal
'#495057', // slate
] as const;
/** A small, stable string hash (FNV-1a, 32-bit) — deterministic across sessions. */
function hashString(value: string): number {
let hash = 0x811c9dc5;
for (let i = 0; i < value.length; i += 1) {
hash ^= value.charCodeAt(i);
hash = Math.imul(hash, 0x01000193);
}
return hash >>> 0;
}
/** The palette colour assigned to a user id — stable for the same id. */
export function colorForUser(userId: string): string {
return USER_COLORS[hashString(userId) % USER_COLORS.length]!;
}

View File

@ -7,9 +7,12 @@ import { useTranslation } from 'react-i18next';
import { Link, useNavigate, useParams } from 'react-router-dom';
import * as Y from 'yjs';
import { useAuth } from '../auth/auth-context';
import { FormError } from '../components/forms';
import { collaborationCaretFor } from '../editor/collaboration-caret';
import { documentExtensions } from '../editor/document-extensions';
import { ImageUpload } from '../editor/image-upload';
import { PresenceStrip } from '../editor/PresenceStrip';
import { Toolbar } from '../editor/Toolbar';
import { useCollabProvider } from '../editor/use-collab-provider';
import { useForceSidebarHidden } from '../layout/sidebar-chrome';
@ -19,6 +22,7 @@ type Mode = 'view' | 'edit';
function PageEditor({ page, mode }: { page: PageStateView; mode: Mode }): React.JSX.Element {
const { t } = useTranslation('editor');
const { user } = useAuth();
// Created and destroyed within the same effect (not `useMemo` + a separate
// cleanup effect): React StrictMode's dev-only mount→cleanup→remount would
@ -45,18 +49,23 @@ function PageEditor({ page, mode }: { page: PageStateView; mode: Mode }): React.
{
// `documentExtensions` alone is a valid (uncollaborated) schema, so the
// editor never gets built without its 'doc'/'paragraph'/'text' nodes
// while `ydoc` is still being created (see the effect above).
// while `ydoc` is still being created (see the effect above). The
// collaboration-caret extension is added once the provider exists, so
// remote carets and presence share the same awareness (#37).
extensions: ydoc
? [
...documentExtensions,
ImageUpload.configure({ pondId: page.pondId }),
Collaboration.configure({ document: ydoc, field: 'default' }),
...(collab.provider && user
? [collaborationCaretFor(collab.provider, user, readOnly)]
: []),
]
: documentExtensions,
editable: canEdit,
immediatelyRender: false,
},
[ydoc],
[ydoc, collab.provider, readOnly, user?.id],
);
useLayoutEffect(() => {
@ -71,6 +80,7 @@ function PageEditor({ page, mode }: { page: PageStateView; mode: Mode }): React.
<div className="editor-connection" role="status" data-status={collab.status}>
{t(`connection.${collab.status}`)}
</div>
<PresenceStrip provider={collab.provider} />
{mode === 'edit' && readOnly && (
<div className="editor-banner editor-banner--info" role="note">
{t('readOnly.notice')}

View File

@ -553,6 +553,76 @@ button {
color: var(--color-danger);
}
.presence-strip {
display: flex;
gap: calc(-1 * var(--space-1));
align-items: center;
padding: var(--space-1) var(--space-3);
}
.presence-avatar {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.75rem;
height: 1.75rem;
margin-left: -0.4rem;
border: 2px solid var(--color-bg);
border-radius: 50%;
color: #ffffff;
font-size: 0.7rem;
font-weight: 600;
}
.presence-avatar:first-child {
margin-left: 0;
}
.presence-avatar--overflow {
background: var(--color-text-muted);
}
.presence-avatar__ro {
position: absolute;
right: -0.15rem;
bottom: -0.15rem;
font-size: 0.75rem;
line-height: 1;
}
/* Remote collaboration carets and selections (issue #37). */
.collab-caret {
position: relative;
margin-left: -1px;
margin-right: -1px;
border-left: 1px solid;
border-right: 1px solid;
word-break: normal;
pointer-events: none;
}
.collab-caret--hidden {
display: none;
}
.collab-caret__label {
position: absolute;
top: -1.4em;
left: -1px;
padding: 0.05rem 0.3rem;
border-radius: 3px 3px 3px 0;
color: #ffffff;
font-size: 0.7rem;
font-weight: 600;
white-space: nowrap;
user-select: none;
}
.collab-selection {
border-radius: 2px;
}
.editor-banner {
margin: var(--space-1) var(--space-3);
padding: var(--space-2) var(--space-3);

View File

@ -20,6 +20,10 @@
"tooLarge": {
"notice": "Diese Seite hat ihre maximale Größe erreicht, daher wurde deine letzte Änderung nicht gespeichert. Bitte entferne etwas Inhalt."
},
"presence": {
"label": "{{count}} Personen auf dieser Seite",
"viewer": "{{name}} (nur Lesen)"
},
"toolbar": {
"paragraph": "Absatz",
"heading1": "Überschrift 1",

View File

@ -20,6 +20,10 @@
"tooLarge": {
"notice": "This page has reached its maximum size, so your latest change wasn't saved. Please remove some content."
},
"presence": {
"label": "{{count}} people on this page",
"viewer": "{{name}} (read-only)"
},
"toolbar": {
"paragraph": "Paragraph",
"heading1": "Heading 1",

16
pnpm-lock.yaml generated
View File

@ -208,6 +208,9 @@ importers:
'@tiptap/extension-collaboration':
specifier: ^3.27.1
version: 3.27.1(@tiptap/core@3.27.1(@tiptap/pm@3.27.1))(@tiptap/pm@3.27.1)(@tiptap/y-tiptap@3.0.6(prosemirror-model@1.25.9)(prosemirror-state@1.4.4)(prosemirror-view@1.42.0)(y-protocols@1.0.7(yjs@13.6.31))(yjs@13.6.31))(yjs@13.6.31)
'@tiptap/extension-collaboration-caret':
specifier: ^3.27.1
version: 3.27.1(@tiptap/core@3.27.1(@tiptap/pm@3.27.1))(@tiptap/pm@3.27.1)(@tiptap/y-tiptap@3.0.6(prosemirror-model@1.25.9)(prosemirror-state@1.4.4)(prosemirror-view@1.42.0)(y-protocols@1.0.7(yjs@13.6.31))(yjs@13.6.31))
'@tiptap/pm':
specifier: ^3.27.1
version: 3.27.1
@ -1585,6 +1588,13 @@ packages:
'@tiptap/core': 3.27.1
'@tiptap/pm': 3.27.1
'@tiptap/extension-collaboration-caret@3.27.1':
resolution: {integrity: sha512-mKyYMBkgQGWrlZLGKxQvPP28WaTeZOMUfQg0a6KBnexM4vhX7q2xqAGqCsCFaczQaTPsaJ+/6Bela9Ueh0sPdQ==}
peerDependencies:
'@tiptap/core': 3.27.1
'@tiptap/pm': 3.27.1
'@tiptap/y-tiptap': ^3.0.5
'@tiptap/extension-collaboration@3.27.1':
resolution: {integrity: sha512-Da7WeKNIaLsbcHBWlgexMgm5ygoA1mhRroFND1vweLNsWIPxvyjci7jrq/uDN1tSnpqMlJsdyW0tXzbVGYTpMw==}
peerDependencies:
@ -5146,6 +5156,12 @@ snapshots:
'@tiptap/core': 3.27.1(@tiptap/pm@3.27.1)
'@tiptap/pm': 3.27.1
'@tiptap/extension-collaboration-caret@3.27.1(@tiptap/core@3.27.1(@tiptap/pm@3.27.1))(@tiptap/pm@3.27.1)(@tiptap/y-tiptap@3.0.6(prosemirror-model@1.25.9)(prosemirror-state@1.4.4)(prosemirror-view@1.42.0)(y-protocols@1.0.7(yjs@13.6.31))(yjs@13.6.31))':
dependencies:
'@tiptap/core': 3.27.1(@tiptap/pm@3.27.1)
'@tiptap/pm': 3.27.1
'@tiptap/y-tiptap': 3.0.6(prosemirror-model@1.25.9)(prosemirror-state@1.4.4)(prosemirror-view@1.42.0)(y-protocols@1.0.7(yjs@13.6.31))(yjs@13.6.31)
'@tiptap/extension-collaboration@3.27.1(@tiptap/core@3.27.1(@tiptap/pm@3.27.1))(@tiptap/pm@3.27.1)(@tiptap/y-tiptap@3.0.6(prosemirror-model@1.25.9)(prosemirror-state@1.4.4)(prosemirror-view@1.42.0)(y-protocols@1.0.7(yjs@13.6.31))(yjs@13.6.31))(yjs@13.6.31)':
dependencies:
'@tiptap/core': 3.27.1(@tiptap/pm@3.27.1)