diff --git a/apps/web/e2e/collab.spec.ts b/apps/web/e2e/collab.spec.ts index d9e24b8..4f5d9e2 100644 --- a/apps/web/e2e/collab.spec.ts +++ b/apps/web/e2e/collab.spec.ts @@ -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 diff --git a/apps/web/package.json b/apps/web/package.json index b24a494..5a1dd90 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -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", diff --git a/apps/web/src/editor/PresenceStrip.tsx b/apps/web/src/editor/PresenceStrip.tsx new file mode 100644 index 0000000..3070ecc --- /dev/null +++ b/apps/web/src/editor/PresenceStrip.tsx @@ -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 ( +
+ {shown.map((participant) => ( + + {initials(participant.name)} + {participant.readOnly && ( + + )} + + ))} + {overflow > 0 && ( + + )} +
+ ); +} diff --git a/apps/web/src/editor/collaboration-caret.ts b/apps/web/src/editor/collaboration-caret.ts new file mode 100644 index 0000000..e5f4542 --- /dev/null +++ b/apps/web/src/editor/collaboration-caret.ts @@ -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): 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): Record { + 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, + }); +} diff --git a/apps/web/src/editor/use-presence.ts b/apps/web/src/editor/use-presence.ts new file mode 100644 index 0000000..61c33e4 --- /dev/null +++ b/apps/web/src/editor/use-presence.ts @@ -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([]); + + useEffect(() => { + const awareness = provider?.awareness; + if (!awareness) { + setParticipants([]); + return; + } + const update = (): void => { + const byUser = new Map(); + for (const state of awareness.getStates().values()) { + const user = (state as { user?: Partial }).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; +} diff --git a/apps/web/src/editor/user-color.test.ts b/apps/web/src/editor/user-color.test.ts new file mode 100644 index 0000000..9fea94f --- /dev/null +++ b/apps/web/src/editor/user-color.test.ts @@ -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); + }); +}); diff --git a/apps/web/src/editor/user-color.ts b/apps/web/src/editor/user-color.ts new file mode 100644 index 0000000..ad1288e --- /dev/null +++ b/apps/web/src/editor/user-color.ts @@ -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]!; +} diff --git a/apps/web/src/pages/PageEditorPage.tsx b/apps/web/src/pages/PageEditorPage.tsx index 9659ca0..f9f3712 100644 --- a/apps/web/src/pages/PageEditorPage.tsx +++ b/apps/web/src/pages/PageEditorPage.tsx @@ -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.
{t(`connection.${collab.status}`)}
+ {mode === 'edit' && readOnly && (
{t('readOnly.notice')} diff --git a/apps/web/src/styles/base.css b/apps/web/src/styles/base.css index 81a1ec2..5ace071 100644 --- a/apps/web/src/styles/base.css +++ b/apps/web/src/styles/base.css @@ -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); diff --git a/packages/shared/i18n/de/editor.json b/packages/shared/i18n/de/editor.json index 32a7d67..4e2a3d7 100644 --- a/packages/shared/i18n/de/editor.json +++ b/packages/shared/i18n/de/editor.json @@ -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", diff --git a/packages/shared/i18n/en/editor.json b/packages/shared/i18n/en/editor.json index a64bbb7..dd34544 100644 --- a/packages/shared/i18n/en/editor.json +++ b/packages/shared/i18n/en/editor.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4e264cc..d217073 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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)