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
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:
parent
7d04c0b594
commit
63fe6af6b0
@ -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
|
||||
|
||||
@ -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",
|
||||
|
||||
60
apps/web/src/editor/PresenceStrip.tsx
Normal file
60
apps/web/src/editor/PresenceStrip.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
62
apps/web/src/editor/collaboration-caret.ts
Normal file
62
apps/web/src/editor/collaboration-caret.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
43
apps/web/src/editor/use-presence.ts
Normal file
43
apps/web/src/editor/use-presence.ts
Normal 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;
|
||||
}
|
||||
38
apps/web/src/editor/user-color.test.ts
Normal file
38
apps/web/src/editor/user-color.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
32
apps/web/src/editor/user-color.ts
Normal file
32
apps/web/src/editor/user-color.ts
Normal 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]!;
|
||||
}
|
||||
@ -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')}
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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
16
pnpm-lock.yaml
generated
@ -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)
|
||||
|
||||
Loading…
Reference in New Issue
Block a user