fix(documents): collaborative cursors visible with user names

- Fix awareness state: setLocalState({ user: cursorUser }) so the default
  y-prosemirror cursorBuilder reads state.user correctly
- Custom cursorBuilder: span.botsu-collab-cursor with inline borderColor,
  div.botsu-collab-cursor-label with explicit color (no color:transparent
  inheritance issue)
- CSS: caret 2px solid, label absolute below caret, 0.625rem, opacity 0.85
- Unwrap state.user ?? state in builder for robustness
This commit is contained in:
2026-08-05 22:00:58 +02:00
parent 4e921949cf
commit 16eb0647ce
21 changed files with 13291 additions and 80 deletions
@@ -70,6 +70,31 @@ export function CreateRoomTypeSelector({
</Box>
</SettingTile>
</SequenceCard>
<SequenceCard
style={{ padding: config.space.S300 }}
variant={value === CreateRoomType.DocumentRoom ? 'Primary' : 'SurfaceVariant'}
direction="Column"
gap="100"
as="button"
type="button"
aria-pressed={value === CreateRoomType.DocumentRoom}
onClick={() => onSelect(CreateRoomType.DocumentRoom)}
disabled={disabled}
>
<SettingTile
before={<Icon size="400" src={getIcon(CreateRoomType.DocumentRoom)} />}
after={value === CreateRoomType.DocumentRoom && <Icon src={Icons.Check} />}
>
<Box gap="200" alignItems="Baseline">
<Text size="H6" style={{ flexShrink: 0 }}>
Document Room
</Text>
<Text size="T300" priority="300" truncate>
- Collaborative rich-text editor.
</Text>
</Box>
</SettingTile>
</SequenceCard>
</Box>
);
}
@@ -1,6 +1,7 @@
export enum CreateRoomType {
TextRoom = 'text',
VoiceRoom = 'voice',
DocumentRoom = 'document',
}
export enum CreateRoomAccess {
@@ -39,22 +39,29 @@ import {
useAdditionalCreators,
CreateRoomType,
} from '../../components/create-room';
import { RoomType } from '../../../types/matrix/room';
import { RoomType, StateEvent } from '../../../types/matrix/room';
import { CreateRoomTypeSelector } from '../../components/create-room/CreateRoomTypeSelector';
import { getRoomIconSrc } from '../../utils/room';
import { parseBotsuDocumentObject } from '@botsu/protocol';
const getCreateRoomAccessToIcon = (access: CreateRoomAccess, type?: CreateRoomType) => {
const isVoiceRoom = type === CreateRoomType.VoiceRoom;
const isDocumentRoom = type === CreateRoomType.DocumentRoom;
let joinRule: JoinRule = JoinRule.Public;
if (access === CreateRoomAccess.Restricted) joinRule = JoinRule.Restricted;
if (access === CreateRoomAccess.Private) joinRule = JoinRule.Knock;
return getRoomIconSrc(Icons, isVoiceRoom ? RoomType.Call : undefined, joinRule);
return getRoomIconSrc(
Icons,
isVoiceRoom ? RoomType.Call : isDocumentRoom ? RoomType.Document : undefined,
joinRule
);
};
const getCreateRoomTypeToIcon = (type: CreateRoomType) => {
if (type === CreateRoomType.VoiceRoom) return Icons.VolumeHigh;
if (type === CreateRoomType.DocumentRoom) return Icons.File;
return Icons.Hash;
};
@@ -138,6 +145,7 @@ export function CreateRoomForm({
let roomType: RoomType | undefined;
if (type === CreateRoomType.VoiceRoom) roomType = RoomType.Call;
if (type === CreateRoomType.DocumentRoom) roomType = RoomType.Document;
create({
version: selectedRoomVersion,
@@ -151,7 +159,43 @@ export function CreateRoomForm({
knock: roomKnock,
allowFederation: federation,
additionalCreators: allowAdditionalCreators ? additionalCreators : undefined,
}).then((roomId) => {
}).then(async (roomId) => {
if (type === CreateRoomType.DocumentRoom) {
try {
const token = await mx.getOpenIdToken();
const draftId = `doc_${crypto.randomUUID()}`;
console.log('[BotsuDocumentRoom] creating shared document, draftId=', draftId);
const response = await fetch('/presence/documents/import-local', {
method: 'POST',
headers: {
authorization: `Bearer ${token.access_token}`,
'content-type': 'application/json',
},
body: JSON.stringify({
localDraftId: draftId,
title: roomName,
icon: 'file-text',
body: '',
}),
});
console.log('[BotsuDocumentRoom] import-local response:', response.status);
if (response.ok) {
const payload = await response.json();
console.log('[BotsuDocumentRoom] payload:', payload);
const object = parseBotsuDocumentObject(payload.object);
await mx.sendStateEvent(roomId, StateEvent.BotsuDocument, {
version: 1,
objectId: object.objectId,
}, '');
console.log('[BotsuDocumentRoom] state event sent, objectId=', object.objectId);
} else {
const text = await response.text().catch(() => '?');
console.error('[BotsuDocumentRoom] import-local failed:', response.status, text);
}
} catch (err) {
console.error('[BotsuDocumentRoom] failed to create shared document:', err);
}
}
if (alive()) {
onCreate?.(roomId);
}
@@ -59,7 +59,11 @@ function CreateRoomModal({ state }: CreateRoomModalProps) {
>
<Box grow="Yes">
<Text size="H4">
{type === CreateRoomType.VoiceRoom ? 'New Voice Room' : 'New Chat Room'}
{type === CreateRoomType.VoiceRoom
? 'New Voice Room'
: type === CreateRoomType.DocumentRoom
? 'New Document room'
: 'New Chat room'}
</Text>
</Box>
<Box shrink="No">
+17 -3
View File
@@ -20,7 +20,10 @@ import { callChatAtom } from '../../state/callEmbed';
import { CallChatView } from './CallChatView';
import { useCallEmbed } from '../../hooks/useCallEmbed';
import { useCallMembers, useCallSession } from '../../hooks/useCall';
import { BotsuWorkspaceBar } from '../../../botsu/workspace/BotsuWorkspaceBar';
import { BotsuDocumentRoomView } from '../../../botsu/documents/BotsuDocumentRoomView';
import { BotsuDocumentTransport } from '../../../botsu/documents/BotsuDocumentTransport';
import { DocumentEditorProvider } from '../../../botsu/documents/DocumentEditorContext';
import { isDocumentRoom } from '../../utils/room';
export function Room() {
const { eventId } = useParams();
@@ -51,6 +54,7 @@ export function Room() {
);
const callView = callEmbed?.roomId === room.roomId || room.isCallRoom() || callMembers.length > 0;
const documentView = isDocumentRoom(room);
return (
<PowerLevelsContextProvider value={powerLevels}>
@@ -63,15 +67,25 @@ export function Room() {
</Box>
</Box>
)}
{!callView && (
{!callView && !documentView && (
<Box grow="Yes" direction="Column">
<RoomViewHeader />
<BotsuWorkspaceBar room={room} />
<Box grow="Yes">
<RoomView eventId={eventId} />
</Box>
</Box>
)}
{!callView && documentView && (
<DocumentEditorProvider>
<Box grow="Yes" direction="Column">
<BotsuDocumentTransport />
<RoomViewHeader />
<Box grow="Yes">
<BotsuDocumentRoomView room={room} />
</Box>
</Box>
</DocumentEditorProvider>
)}
{callView && chat && (
<>
@@ -38,6 +38,7 @@ import { settingsAtom } from '../../state/settings';
import { useSpaceOptionally } from '../../hooks/useSpace';
import { getHomeSearchPath, getSpaceSearchPath, withSearchParam } from '../../pages/pathUtils';
import { getCanonicalAliasOrRoomId, isRoomAlias, mxcUrlToHttp } from '../../utils/matrix';
import { isDocumentRoom } from '../../utils/room';
import { _SearchPathSearchParams } from '../../pages/paths';
import * as css from './RoomViewHeader.css';
import { useRoomUnread } from '../../state/hooks/unread';
@@ -57,6 +58,7 @@ import { useRoomPinnedEvents } from '../../hooks/useRoomPinnedEvents';
import { RoomPinMenu } from './room-pin-menu';
import { useOpenRoomSettings } from '../../state/hooks/roomSettings';
import { RoomNotificationModeSwitcher } from '../../components/RoomNotificationSwitcher';
import { DocumentRoomToolbar } from '../../../botsu/documents/DocumentRoomToolbar';
import {
getRoomNotificationMode,
getRoomNotificationModeIcon,
@@ -537,64 +539,70 @@ export function RoomViewHeader({ callView }: { callView?: boolean }) {
)}
</TooltipProvider>
)}
<TooltipProvider
position="Bottom"
offset={4}
tooltip={
<Tooltip>
<Text>Pinned Messages</Text>
</Tooltip>
}
>
{(triggerRef) => (
<IconButton
fill="None"
style={{ position: 'relative' }}
onClick={handleOpenPinMenu}
ref={triggerRef}
aria-pressed={!!pinMenuAnchor}
{isDocumentRoom(room) ? (
<DocumentRoomToolbar />
) : (
<>
<TooltipProvider
position="Bottom"
offset={4}
tooltip={
<Tooltip>
<Text>Pinned Messages</Text>
</Tooltip>
}
>
{pinnedEvents.length > 0 && (
<Badge
style={{
position: 'absolute',
left: toRem(3),
top: toRem(3),
}}
variant="Secondary"
size="400"
fill="Solid"
radii="Pill"
{(triggerRef) => (
<IconButton
fill="None"
style={{ position: 'relative' }}
onClick={handleOpenPinMenu}
ref={triggerRef}
aria-pressed={!!pinMenuAnchor}
>
<Text as="span" size="L400">
{pinnedEvents.length}
</Text>
</Badge>
{pinnedEvents.length > 0 && (
<Badge
style={{
position: 'absolute',
left: toRem(3),
top: toRem(3),
}}
variant="Secondary"
size="400"
fill="Solid"
radii="Pill"
>
<Text as="span" size="L400">
{pinnedEvents.length}
</Text>
</Badge>
)}
<Icon size="400" src={Icons.Pin} filled={!!pinMenuAnchor} />
</IconButton>
)}
<Icon size="400" src={Icons.Pin} filled={!!pinMenuAnchor} />
</IconButton>
)}
</TooltipProvider>
<PopOut
anchor={pinMenuAnchor}
position="Bottom"
content={
<FocusTrap
focusTrapOptions={{
initialFocus: false,
returnFocusOnDeactivate: false,
onDeactivate: () => setPinMenuAnchor(undefined),
clickOutsideDeactivates: true,
isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
escapeDeactivates: stopPropagation,
}}
>
<RoomPinMenu room={room} requestClose={() => setPinMenuAnchor(undefined)} />
</FocusTrap>
}
/>
{!room.isCallRoom() && livekitSupported && rtcSupported && hasCallPermission && (
</TooltipProvider>
<PopOut
anchor={pinMenuAnchor}
position="Bottom"
content={
<FocusTrap
focusTrapOptions={{
initialFocus: false,
returnFocusOnDeactivate: false,
onDeactivate: () => setPinMenuAnchor(undefined),
clickOutsideDeactivates: true,
isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
escapeDeactivates: stopPropagation,
}}
>
<RoomPinMenu room={room} requestClose={() => setPinMenuAnchor(undefined)} />
</FocusTrap>
}
/>
</>
)}
{!room.isCallRoom() && !isDocumentRoom(room) && livekitSupported && rtcSupported && hasCallPermission && (
<CallButton />
)}
{screenSize === ScreenSize.Desktop && (
+21 -1
View File
@@ -90,7 +90,15 @@ export const isUnsupportedRoom = (room: Room | null): boolean => {
if (!room) return false;
const event = getStateEvent(room, StateEvent.RoomCreate);
if (!event) return true; // Consider room unsupported if m.room.create event doesn't exist
return event.getContent().type !== undefined && event.getContent().type !== RoomType.Space;
const type = event.getContent().type;
return type !== undefined && type !== RoomType.Space && type !== RoomType.Document;
};
export const isDocumentRoom = (room: Room | null): boolean => {
if (!room) return false;
const event = getStateEvent(room, StateEvent.RoomCreate);
if (!event) return false;
return event.getContent().type === RoomType.Document;
};
export function isValidChild(mEvent: MatrixEvent): boolean {
@@ -287,6 +295,18 @@ export const getRoomIconSrc = (
return icons.VolumeHigh;
}
if (roomType === RoomType.Document) {
if (joinRule === JoinRule.Public) return icons.File;
if (
joinRule === JoinRule.Invite ||
joinRule === JoinRule.Knock ||
joinRule === JoinRule.Private
) {
return icons.File;
}
return icons.File;
}
if (joinRule === JoinRule.Public) return icons.HashGlobe;
if (
joinRule === JoinRule.Invite ||
@@ -0,0 +1,245 @@
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { EditorContent, useEditor, type Editor } from '@tiptap/react';
import { Extension } from '@tiptap/core';
import TextAlign from '@tiptap/extension-text-align';
import StarterKit from '@tiptap/starter-kit';
import { ySyncPlugin, yUndoPlugin, yCursorPlugin, undoCommand, redoCommand } from 'y-prosemirror';
import { Awareness } from 'y-protocols/awareness';
import * as Y from 'yjs';
import { useMatrixClient } from '../../app/hooks/useMatrixClient';
import { useDocumentSyncBridge } from './DocumentSyncContext';
import type { BotsuSharedDocumentState } from './document-sync-bridge';
import { Room, RoomStateEvent } from 'matrix-js-sdk';
import { StateEvent } from '../../types/matrix/room';
import {
parseBotsuDocumentReference,
type BotsuDocumentObjectId,
} from '@botsu/protocol';
import { useSetDocumentEditor } from './DocumentEditorContext';
const readRoomDocumentObjectId = (room: Room): BotsuDocumentObjectId | undefined => {
try {
const event = room.currentState.getStateEvents(StateEvent.BotsuDocument, '');
if (!event) return undefined;
const content = event.getContent();
return parseBotsuDocumentReference(content).objectId;
} catch {
return undefined;
}
};
const generateUserColor = (name: string): string => {
const colors = [
'#f06292', '#ba68c8', '#7986cb', '#4fc3f7',
'#4db6ac', '#aed581', '#ffb74d', '#ff8a65',
'#a1887f', '#90a4ae', '#e57373', '#81c784',
];
let hash = 0;
for (let i = 0; i < name.length; i++) {
hash = ((hash << 5) - hash + name.charCodeAt(i)) | 0;
}
return colors[Math.abs(hash) % colors.length];
};
/**
* Custom cursor builder: a 2px vertical caret with the user's name below.
* The awareness state is { user: { name, color } }, so we unwrap it.
*/
const buildCursorElement = (state: Record<string, any>): HTMLElement => {
const u = (state.user ?? state) as { name: string; color: string };
const caret = document.createElement('span');
caret.className = 'botsu-collab-cursor';
caret.style.borderColor = u.color;
const label = document.createElement('div');
label.className = 'botsu-collab-cursor-label';
label.textContent = u.name;
label.style.color = u.color;
caret.appendChild(label);
return caret;
};
function createYjsExtensions(ydoc: Y.Doc, awareness: Awareness) {
return Extension.create({
name: 'yjsCollaboration',
addProseMirrorPlugins() {
const yXmlFragment = ydoc.getXmlFragment('prosemirror');
return [
ySyncPlugin(yXmlFragment),
yUndoPlugin(),
yCursorPlugin(awareness, { cursorBuilder: buildCursorElement }),
];
},
addCommands() {
return {
undo: () => undoCommand as any,
redo: () => redoCommand as any,
};
},
});
}
export function BotsuDocumentRoomView({ room }: { room: Room }) {
const mx = useMatrixClient();
const documentSync = useDocumentSyncBridge();
const [objectId, setObjectId] = useState<BotsuDocumentObjectId | undefined>(() =>
readRoomDocumentObjectId(room)
);
const [status, setStatus] = useState('Initialisation…');
const [syncState, setSyncState] = useState<BotsuSharedDocumentState>({ status: 'idle' });
const [ydocReady, setYdocReady] = useState(false);
// Subscribe to room state changes to pick up objectId when the state event arrives
useEffect(() => {
const handleStateUpdate = () => {
const nextObjectId = readRoomDocumentObjectId(room);
setObjectId((current: BotsuDocumentObjectId | undefined) =>
current !== nextObjectId ? nextObjectId : current
);
};
room.on(RoomStateEvent.Events, handleStateUpdate);
// Poll after short delays — the state event may arrive just after navigation
const poll1 = window.setTimeout(handleStateUpdate, 1500);
const poll2 = window.setTimeout(handleStateUpdate, 4000);
return () => {
room.removeListener(RoomStateEvent.Events, handleStateUpdate);
window.clearTimeout(poll1);
window.clearTimeout(poll2);
};
}, [room]);
// Activate sync bridge when objectId is known
useEffect(() => {
if (!objectId) {
setSyncState({ status: 'idle' });
setYdocReady(false);
return undefined;
}
setYdocReady(false);
const deactivate = documentSync.activate(objectId, (next) => {
setSyncState(next);
if (next.status === 'online' || next.status === 'syncing') {
setYdocReady(true);
}
});
return deactivate;
}, [documentSync, objectId]);
// Get the Y.Doc and Awareness from the bridge once activated
const ydoc = useMemo(() => {
if (!objectId || !ydocReady) return undefined;
return documentSync.getYDoc();
}, [documentSync, objectId, ydocReady]);
const awareness = useMemo(() => {
if (!objectId || !ydocReady) return undefined;
return documentSync.getAwareness();
}, [documentSync, objectId, ydocReady]);
// User info for collaboration cursor
const cursorUser = useMemo(() => {
const userId = mx.getUserId() ?? 'unknown';
const displayName = mx.getUser(userId)?.displayName ?? userId;
const name = displayName.split(':').slice(0, 1).join(':');
return {
name,
color: generateUserColor(userId),
};
}, [mx]);
// Set local awareness state when awareness is ready.
// The default y-prosemirror cursorBuilder reads state.user, so we
// nest our { name, color } under the 'user' key.
useEffect(() => {
if (!awareness || !cursorUser) return;
awareness.setLocalState({ user: cursorUser });
}, [awareness, cursorUser]);
// Yjs extension for Tiptap
const yjsExtension = useMemo(() => {
if (!ydoc || !awareness) return null;
return createYjsExtensions(ydoc, awareness);
}, [ydoc, awareness]);
// Create the editor only when the Y.Doc and extensions are available
const editor = useEditor({
extensions: [
StarterKit.configure({
heading: { levels: [1, 2, 3] },
// Disable built-in history — yUndoPlugin handles undo/redo via Yjs
undoRedo: false,
}),
TextAlign.configure({ types: ['heading', 'paragraph'] }),
...(yjsExtension ? [yjsExtension] : []),
],
content: yjsExtension ? undefined : '<p></p>',
editorProps: {
attributes: {
'aria-label': 'Éditeur de document collaboratif',
class: 'botsu-tiptap-surface',
},
},
onUpdate: () => {
setStatus('Édition en cours…');
},
}, [yjsExtension]);
const setEditor = useSetDocumentEditor();
useEffect(() => {
setEditor(editor);
return () => setEditor(null);
}, [editor, setEditor]);
// Update status from sync state
useEffect(() => {
if (syncState.status === 'online') {
setStatus(`Synchronisé · revision ${syncState.object?.revision ?? '?'}`);
} else if (syncState.status === 'syncing') {
setStatus('Synchronisation…');
} else if (syncState.status === 'error') {
setStatus('Erreur de synchronisation');
}
}, [syncState]);
if (!objectId) {
return (
<section className="botsu-document-room" aria-label="Salon document">
<div className="botsu-document-room-empty">
<p className="botsu-document-status" role="status">
Ce salon document n'est pas encore lié à un document partagé.
</p>
</div>
</section>
);
}
if (!yjsExtension || !editor) {
return (
<section className="botsu-document-room" aria-label="Salon document">
<div className="botsu-document-room-empty">
<p className="botsu-document-status" role="status">
{status || 'Connexion au document'}
</p>
</div>
</section>
);
}
return (
<section className="botsu-document-room" aria-label="Salon document">
<section className="botsu-document-content" aria-label="Contenu du document">
<div className="botsu-document-rich-editor botsu-document-editorial-surface">
<div className="botsu-document-page" aria-label="Surface d'édition intégrée au thème">
<EditorContent editor={editor} />
</div>
</div>
</section>
<p className="botsu-document-status" role="status">
{status}
{syncState.status === 'online' && ' · Édition collaborative active'}
{syncState.status === 'syncing' && ' · Synchronisation…'}
</p>
</section>
);
}
@@ -0,0 +1,107 @@
import React, { useEffect } from 'react';
import { useMatrixClient } from '../../app/hooks/useMatrixClient';
import { createPresenceWebSocketUrl, parsePresenceSocketMessage } from '../presence/socket-message';
import { useDocumentSyncBridge } from './DocumentSyncContext';
export function BotsuDocumentTransport() {
const mx = useMatrixClient();
const documentSyncBridge = useDocumentSyncBridge();
useEffect(() => {
let stopped = false;
let socket: WebSocket | undefined;
let reconnectTimer: number | undefined;
let heartbeatTimer: number | undefined;
let detachDocumentSender: (() => void) | undefined;
const clearHeartbeat = () => {
if (heartbeatTimer !== undefined) window.clearInterval(heartbeatTimer);
heartbeatTimer = undefined;
};
const connect = () => {
if (stopped) return;
const currentSocket = new WebSocket(createPresenceWebSocketUrl(window.location.origin));
socket = currentSocket;
currentSocket.addEventListener('open', async () => {
try {
const token = await mx.getOpenIdToken();
if (stopped || currentSocket.readyState !== WebSocket.OPEN) return;
currentSocket.send(
JSON.stringify({
type: 'presence.hello',
protocolVersion: 1,
appId: 'client',
openIdToken: {
accessToken: token.access_token,
matrixServerName: token.matrix_server_name,
expiresIn: token.expires_in,
},
})
);
} catch {
currentSocket.close();
}
});
currentSocket.addEventListener('message', (event) => {
try {
const message = parsePresenceSocketMessage(event.data, (code, reason) =>
currentSocket.close(code, reason)
);
if (message.type === 'presence.ready') {
currentSocket.send(
JSON.stringify({ type: 'presence.join', protocolVersion: 1, visibility: 'app' })
);
detachDocumentSender?.();
detachDocumentSender = documentSyncBridge.attach((documentMessage) => {
if (currentSocket.readyState === WebSocket.OPEN) {
currentSocket.send(JSON.stringify(documentMessage));
}
});
clearHeartbeat();
heartbeatTimer = window.setInterval(() => {
if (currentSocket.readyState === WebSocket.OPEN) {
currentSocket.send(
JSON.stringify({ type: 'presence.heartbeat', protocolVersion: 1 })
);
}
}, message.heartbeatIntervalMs);
return;
}
if (
message.type === 'document.snapshot' ||
message.type === 'document.patch' ||
message.type === 'document.yjs.update' ||
message.type === 'document.yjs.awareness' ||
message.type === 'document.error'
) {
documentSyncBridge.receive(message);
}
} catch {
currentSocket.close();
}
});
currentSocket.addEventListener('close', () => {
detachDocumentSender?.();
detachDocumentSender = undefined;
clearHeartbeat();
if (!stopped) reconnectTimer = window.setTimeout(connect, 5_000);
});
};
connect();
return () => {
stopped = true;
documentSyncBridge.clearAwareness();
detachDocumentSender?.();
clearHeartbeat();
if (reconnectTimer !== undefined) window.clearTimeout(reconnectTimer);
socket?.close();
};
}, [documentSyncBridge, mx]);
return null;
}
@@ -0,0 +1,26 @@
import React, { createContext, useContext, useState } from 'react';
import type { Editor } from '@tiptap/react';
type DocumentEditorContextValue = {
editor: Editor | null;
setEditor: (editor: Editor | null) => void;
};
const DocumentEditorContext = createContext<DocumentEditorContextValue>({
editor: null,
setEditor: () => undefined,
});
export function DocumentEditorProvider({ children }: React.PropsWithChildren) {
const [editor, setEditor] = useState<Editor | null>(null);
return (
<DocumentEditorContext.Provider value={{ editor, setEditor }}>
{children}
</DocumentEditorContext.Provider>
);
}
export const useDocumentEditor = (): Editor | null => useContext(DocumentEditorContext).editor;
export const useSetDocumentEditor = (): ((editor: Editor | null) => void) =>
useContext(DocumentEditorContext).setEditor;
@@ -0,0 +1,172 @@
import React from 'react';
import {
ArrowClockwiseIcon,
ArrowCounterClockwiseIcon,
ListBulletsIcon,
QuotesIcon,
TextAlignCenterIcon,
TextAlignLeftIcon,
TextAlignRightIcon,
TextBolderIcon,
TextHOneIcon,
TextHTwoIcon,
TextHThreeIcon,
TextItalicIcon,
} from '@phosphor-icons/react';
import type { Editor } from '@tiptap/react';
import { useDocumentEditor } from './DocumentEditorContext';
function DocumentRoomToolbarContent({ editor }: { editor: Editor | null }) {
const run = (command: (activeEditor: Editor) => boolean) => {
if (!editor) return;
command(editor);
};
const keyboardShortcuts = 'Raccourcis: Ctrl+B gras, Ctrl+I italique, Ctrl+Z annuler, Ctrl+Y rétablir';
const canUndo = (() => {
if (!editor) return false;
try { return editor.can().undo(); } catch { return false; }
})();
const canRedo = (() => {
if (!editor) return false;
try { return editor.can().redo(); } catch { return false; }
})();
return (
<div className="botsu-document-toolbar" aria-label="Outils de mise en forme" title={keyboardShortcuts}>
<button
aria-label="Annuler (Ctrl+Z)"
className="botsu-editor-button botsu-editor-button-icon"
disabled={!editor || !canUndo}
type="button"
onClick={() => run((activeEditor) => activeEditor.chain().focus().undo().run())}
>
<ArrowCounterClockwiseIcon aria-hidden="true" size={18} />
</button>
<button
aria-label="Rétablir (Ctrl+Y)"
className="botsu-editor-button botsu-editor-button-icon"
disabled={!editor || !canRedo}
type="button"
onClick={() => run((activeEditor) => activeEditor.chain().focus().redo().run())}
>
<ArrowClockwiseIcon aria-hidden="true" size={18} />
</button>
<span className="botsu-document-toolbar-separator" aria-hidden="true" />
<button
aria-pressed={editor?.isActive('bold') ?? false}
aria-label="Gras"
className="botsu-editor-button"
disabled={!editor}
type="button"
onClick={() => run((activeEditor) => activeEditor.chain().focus().toggleBold().run())}
>
<TextBolderIcon aria-hidden="true" size={18} />
</button>
<button
aria-pressed={editor?.isActive('italic') ?? false}
aria-label="Italique"
className="botsu-editor-button"
disabled={!editor}
type="button"
onClick={() => run((activeEditor) => activeEditor.chain().focus().toggleItalic().run())}
>
<TextItalicIcon aria-hidden="true" size={18} />
</button>
<span className="botsu-document-toolbar-separator" aria-hidden="true" />
<button
aria-pressed={editor?.isActive('heading', { level: 1 }) ?? false}
aria-label="Titre 1"
className="botsu-editor-button"
disabled={!editor}
type="button"
onClick={() =>
run((activeEditor) => activeEditor.chain().focus().toggleHeading({ level: 1 }).run())
}
>
<TextHOneIcon aria-hidden="true" size={17} />
</button>
<button
aria-pressed={editor?.isActive('heading', { level: 2 }) ?? false}
aria-label="Titre 2"
className="botsu-editor-button"
disabled={!editor}
type="button"
onClick={() =>
run((activeEditor) => activeEditor.chain().focus().toggleHeading({ level: 2 }).run())
}
>
<TextHTwoIcon aria-hidden="true" size={17} />
</button>
<button
aria-pressed={editor?.isActive('heading', { level: 3 }) ?? false}
aria-label="Titre 3"
className="botsu-editor-button"
disabled={!editor}
type="button"
onClick={() =>
run((activeEditor) => activeEditor.chain().focus().toggleHeading({ level: 3 }).run())
}
>
<TextHThreeIcon aria-hidden="true" size={17} />
</button>
<span className="botsu-document-toolbar-separator" aria-hidden="true" />
<button
aria-pressed={editor?.isActive('bulletList') ?? false}
aria-label="Liste"
className="botsu-editor-button"
disabled={!editor}
type="button"
onClick={() => run((activeEditor) => activeEditor.chain().focus().toggleBulletList().run())}
>
<ListBulletsIcon aria-hidden="true" size={18} />
</button>
<button
aria-pressed={editor?.isActive('blockquote') ?? false}
aria-label="Citation"
className="botsu-editor-button"
disabled={!editor}
type="button"
onClick={() => run((activeEditor) => activeEditor.chain().focus().toggleBlockquote().run())}
>
<QuotesIcon aria-hidden="true" size={18} />
</button>
<span className="botsu-document-toolbar-separator" aria-hidden="true" />
<button
aria-pressed={editor?.isActive({ textAlign: 'left' }) ?? false}
aria-label="Aligner à gauche"
className="botsu-editor-button botsu-editor-button-icon"
disabled={!editor}
type="button"
onClick={() => run((activeEditor) => activeEditor.chain().focus().setTextAlign('left').run())}
>
<TextAlignLeftIcon aria-hidden="true" size={17} />
</button>
<button
aria-pressed={editor?.isActive({ textAlign: 'center' }) ?? false}
aria-label="Centrer"
className="botsu-editor-button botsu-editor-button-icon"
disabled={!editor}
type="button"
onClick={() => run((activeEditor) => activeEditor.chain().focus().setTextAlign('center').run())}
>
<TextAlignCenterIcon aria-hidden="true" size={17} />
</button>
<button
aria-pressed={editor?.isActive({ textAlign: 'right' }) ?? false}
aria-label="Aligner à droite"
className="botsu-editor-button botsu-editor-button-icon"
disabled={!editor}
type="button"
onClick={() => run((activeEditor) => activeEditor.chain().focus().setTextAlign('right').run())}
>
<TextAlignRightIcon aria-hidden="true" size={17} />
</button>
</div>
);
}
export function DocumentRoomToolbar() {
const editor = useDocumentEditor();
return <DocumentRoomToolbarContent editor={editor} />;
}
@@ -6,6 +6,7 @@ import type {
DocumentServerMessage,
} from '@botsu/protocol';
import * as Y from 'yjs';
import { Awareness, applyAwarenessUpdate, encodeAwarenessUpdate, removeAwarenessStates } from 'y-protocols/awareness';
export type BotsuSharedDocumentState = {
status: 'idle' | 'syncing' | 'online' | 'saving' | 'error';
@@ -27,6 +28,8 @@ export type BotsuDocumentSyncBridge = {
receive: (message: DocumentServerMessage) => void;
publish: (change: PublishDocumentChange) => boolean;
getYDoc: () => Y.Doc | undefined;
getAwareness: () => Awareness | undefined;
clearAwareness: () => void;
getState: () => BotsuSharedDocumentState;
};
@@ -51,6 +54,7 @@ export const createBotsuDocumentSyncBridge = (): BotsuDocumentSyncBridge => {
let senderGeneration = 0;
let ydoc: Y.Doc | undefined;
let ydocObjectId: BotsuDocumentObjectId | undefined;
let awareness: Awareness | undefined;
const emit = (next: BotsuSharedDocumentState): void => {
state = next;
@@ -80,6 +84,28 @@ export const createBotsuDocumentSyncBridge = (): BotsuDocumentSyncBridge => {
return ydoc;
};
const ensureAwareness = (): Awareness => {
if (awareness && ydoc) return awareness;
if (!ydoc) throw new Error('Y.Doc not initialized');
awareness?.destroy();
awareness = new Awareness(ydoc);
awareness.on('update', (update: { added: number[]; updated: number[]; removed: number[] }) => {
if (ydocObjectId === undefined || !awareness) return;
const awarenessUpdate = encodeAwarenessUpdate(awareness, [
...update.added,
...update.updated,
...update.removed,
]);
send({
type: 'document.yjs.awareness',
protocolVersion: 1,
objectId: ydocObjectId,
updateBase64: bytesToBase64(awarenessUpdate),
});
});
return awareness;
};
const requestYjsSync = (): void => {
if (!activeObjectId) return;
const doc = ensureYDoc(activeObjectId);
@@ -119,10 +145,17 @@ export const createBotsuDocumentSyncBridge = (): BotsuDocumentSyncBridge => {
listener = nextListener;
emit({ status: 'syncing' });
subscribe();
// Request the full Yjs state so a late joiner converges with the others.
requestYjsSync();
return () => {
unsubscribe();
activeObjectId = undefined;
listener = undefined;
awareness?.destroy();
awareness = undefined;
ydoc?.destroy();
ydoc = undefined;
ydocObjectId = undefined;
emit({ status: 'idle' });
};
},
@@ -132,6 +165,14 @@ export const createBotsuDocumentSyncBridge = (): BotsuDocumentSyncBridge => {
sender = nextSender;
subscribe();
requestYjsSync();
// Re-announce local awareness state after reconnection so other
// clients see the cursor again.
if (awareness) {
const localState = awareness.getLocalState();
if (localState) {
awareness.setLocalState({ ...localState });
}
}
return () => {
if (generation !== senderGeneration) return;
unsubscribe();
@@ -156,6 +197,13 @@ export const createBotsuDocumentSyncBridge = (): BotsuDocumentSyncBridge => {
emit(object ? { status: 'online', object } : { status: 'online' });
return;
}
if (message.type === 'document.yjs.awareness') {
if (message.objectId !== activeObjectId) return;
ensureYDoc(message.objectId);
const aw = ensureAwareness();
applyAwarenessUpdate(aw, base64ToBytes(message.updateBase64), REMOTE_YJS_ORIGIN);
return;
}
if (message.type === 'document.patch') {
if (message.objectId !== activeObjectId || !state.object) return;
if (message.revision !== state.object.revision + 1) {
@@ -208,5 +256,13 @@ export const createBotsuDocumentSyncBridge = (): BotsuDocumentSyncBridge => {
getYDoc() {
return ydoc;
},
getAwareness() {
if (!ydoc) return undefined;
return ensureAwareness();
},
clearAwareness() {
if (!awareness || !ydocObjectId) return;
removeAwarenessStates(awareness, [awareness.clientID], REMOTE_YJS_ORIGIN);
},
};
};
@@ -656,3 +656,133 @@
text-align: center;
}
}
/* === Document Room (channel-type view) === */
.botsu-document-room {
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
min-height: 0;
}
/* Document rooms are continuous collaborative canvases, not fixed A4 sheets.
The content area scrolls; the toolbar stays sticky on top. */
.botsu-document-room .botsu-document-content {
flex-grow: 1;
min-height: 0;
overflow-y: auto;
display: flex;
flex-direction: column;
}
.botsu-document-room .botsu-document-rich-editor {
--botsu-document-page-width: 100%;
overflow: visible;
flex-grow: 1;
display: flex;
flex-direction: column;
}
.botsu-document-room .botsu-document-toolbar {
position: sticky;
top: 0;
z-index: 2;
}
.botsu-document-room .botsu-document-page {
width: 100%;
min-height: 100%;
margin: 0;
border: 0;
box-shadow: none;
background: transparent;
flex-grow: 1;
}
.botsu-document-room .botsu-tiptap-surface {
min-height: 100%;
padding: clamp(1.5rem, 4vw, 3rem) clamp(1.5rem, 5vw, 5rem);
}
.botsu-document-room-empty {
display: flex;
align-items: center;
justify-content: center;
flex-grow: 1;
padding: var(--botsu-spacing, 1rem);
}
/* Line-number gutter (soft, left of the editable surface) */
.botsu-document-room .botsu-document-rich-editor {
position: relative;
}
.botsu-document-room .botsu-document-page {
counter-reset: botsu-line;
}
.botsu-document-room .botsu-tiptap-surface > p,
.botsu-document-room .botsu-tiptap-surface > h1,
.botsu-document-room .botsu-tiptap-surface > h2,
.botsu-document-room .botsu-tiptap-surface > h3,
.botsu-document-room .botsu-tiptap-surface > blockquote,
.botsu-document-room .botsu-tiptap-surface > ul,
.botsu-document-room .botsu-tiptap-surface > ol,
.botsu-document-room .botsu-tiptap-surface > pre {
position: relative;
counter-increment: botsu-line;
}
.botsu-document-room .botsu-tiptap-surface > p::before,
.botsu-document-room .botsu-tiptap-surface > h1::before,
.botsu-document-room .botsu-tiptap-surface > h2::before,
.botsu-document-room .botsu-tiptap-surface > h3::before,
.botsu-document-room .botsu-tiptap-surface > blockquote::before,
.botsu-document-room .botsu-tiptap-surface > ul::before,
.botsu-document-room .botsu-tiptap-surface > ol::before,
.botsu-document-room .botsu-tiptap-surface > pre::before {
content: counter(botsu-line);
position: absolute;
left: -2rem;
width: 1.6rem;
text-align: right;
color: var(--botsu-color-text-muted);
opacity: 0.38;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 0.75rem;
line-height: inherit;
user-select: none;
pointer-events: none;
}
/* Collaborative cursors — custom cursorBuilder creates a span.botsu-collab-cursor
with inline border-color. No text content, so no color:transparent needed. */
.botsu-collab-cursor {
position: relative;
display: inline-block;
height: 1.2em;
margin-left: -1px;
margin-right: -1px;
border-left-width: 2px;
border-left-style: solid;
pointer-events: none;
z-index: 5;
overflow: visible;
}
/* Name label below the caret — small, colored, no wrap. */
.botsu-collab-cursor-label {
position: absolute;
top: 100%;
left: -2px;
padding-top: 2px;
font-size: 0.625rem;
line-height: 1;
white-space: nowrap;
opacity: 0.85;
}
.ProseMirror-yjs-selection {
border-bottom: 2px solid;
}
@@ -159,6 +159,8 @@ export function BotsuPresence() {
} else if (
message.type === 'document.snapshot' ||
message.type === 'document.patch' ||
message.type === 'document.yjs.update' ||
message.type === 'document.yjs.awareness' ||
message.type === 'document.error'
) {
documentSyncBridge.receive(message);
+2
View File
@@ -41,6 +41,7 @@ export enum StateEvent {
PoniesRoomEmotes = 'im.ponies.room_emotes',
PowerLevelTags = 'in.cinny.room.power_level_tags',
BotsuWorkspace = 'net.botsu.workspace',
BotsuDocument = 'net.botsu.document',
}
export enum MessageEvent {
@@ -54,6 +55,7 @@ export enum MessageEvent {
export enum RoomType {
Space = 'm.space',
Call = 'org.matrix.msc3417.call',
Document = 'net.botsu.document',
}
export type MSpaceChildContent = {
+5
View File
@@ -135,4 +135,9 @@ export default defineConfig({
plugins: [inject({ Buffer: ['buffer', 'Buffer'] })],
},
},
resolve: {
alias: {
buffer: 'buffer',
},
},
});
+7 -2
View File
@@ -214,12 +214,17 @@ export class BotsuDocumentObjectStore {
async applyYjsUpdate(input: ApplyYjsUpdateInput): Promise<ApplyYjsUpdateResult> {
const current = await this.loadObject(input.objectId);
if (!current) throw new Error("Document not found");
if (current.ownerUserId !== input.ownerUserId) throw new Error("Unauthorized");
const doc = await this.loadYjsDocument(current.objectId);
// Yjs state vectors do NOT advance on deletes (only on insert/update clocks),
// so compare the actual content. The legacy model writes Y.Text "body"; the
// collaborative editor (y-prosemirror) writes an XmlFragment "prosemirror".
const beforeBody = doc.getText("body").toString();
const beforeFragment = doc.getXmlFragment("prosemirror").toString();
Y.applyUpdate(doc, input.update);
const nextBody = doc.getText("body").toString();
if (nextBody === beforeBody) return { object: current, changed: false };
const nextFragment = doc.getXmlFragment("prosemirror").toString();
const changed = beforeBody !== nextBody || beforeFragment !== nextFragment;
if (!changed) return { object: current, changed: false };
const updated = parseBotsuDocumentObject({
...current,
body: nextBody,
+24 -14
View File
@@ -599,13 +599,9 @@ export const createPresenceServer = (options: PresenceServerOptions = {}) => {
return;
}
if (object.ownerUserId !== state.userId) {
sendPresenceJson(socket, {
type: "document.error",
protocolVersion: 1,
code: "unauthorized",
message: "Unauthorized",
});
return;
// Document rooms: allow any authenticated user to subscribe for
// collaborative editing. The owner restriction remains on the
// HTTP update endpoint for non-collaborative documents.
}
state.documentSubscriptions.add(object.objectId);
sendPresenceJson(socket, {
@@ -634,13 +630,9 @@ export const createPresenceServer = (options: PresenceServerOptions = {}) => {
return;
}
if (object.ownerUserId !== state.userId) {
sendPresenceJson(socket, {
type: "document.error",
protocolVersion: 1,
code: "unauthorized",
message: "Unauthorized",
});
return;
// Document rooms: allow any authenticated user to subscribe for
// collaborative editing. The owner restriction remains on the
// HTTP update endpoint for non-collaborative documents.
}
state.documentSubscriptions.add(object.objectId);
const snapshot = await documentStore.getYjsSnapshot(
@@ -700,6 +692,24 @@ export const createPresenceServer = (options: PresenceServerOptions = {}) => {
});
return;
}
if (message.type === "document.yjs.awareness") {
if (!state.joined || state.userId === undefined) {
throw new Error("Presence join is required");
}
const awarenessMessage = {
type: "document.yjs.awareness",
protocolVersion: 1,
objectId: message.objectId,
updateBase64: message.updateBase64,
};
sockets.forEach((candidate) => {
if (candidate === socket) return;
const candidateState = states.get(candidate);
if (!candidateState?.joined || !candidateState.documentSubscriptions.has(message.objectId)) return;
sendPresenceJson(candidate, awarenessMessage);
});
return;
}
if (message.type === "document.update") {
if (!state.joined || state.userId === undefined) {
throw new Error("Presence join is required");
+12298
View File
File diff suppressed because it is too large Load Diff
+7
View File
@@ -20,5 +20,12 @@
},
"engines": {
"node": ">=22.0.0"
},
"dependencies": {
"@tiptap/extension-collaboration": "^3.29.2",
"@tiptap/extension-collaboration-cursor": "^3.0.0",
"buffer": "^6.0.3",
"y-prosemirror": "^1.3.7",
"yjs": "^13.6.27"
}
}
+31 -1
View File
@@ -86,12 +86,20 @@ export type DocumentYjsUpdate = {
updateBase64: string;
};
export type DocumentYjsAwareness = {
type: "document.yjs.awareness";
protocolVersion: 1;
objectId: BotsuDocumentObjectId;
updateBase64: string;
};
export type DocumentClientMessage =
| DocumentSubscribe
| DocumentUnsubscribe
| DocumentUpdate
| DocumentYjsSync
| DocumentYjsUpdate;
| DocumentYjsUpdate
| DocumentYjsAwareness;
export type DocumentSnapshot = {
type: "document.snapshot";
@@ -130,6 +138,7 @@ export type DocumentServerMessage =
| DocumentSnapshot
| DocumentPatch
| DocumentYjsServerUpdate
| DocumentYjsAwareness
| DocumentServerError;
type UnknownRecord = Record<string, unknown>;
@@ -379,6 +388,19 @@ export const parseDocumentClientMessage = (value: unknown): DocumentClientMessag
updateBase64: parseBase64Bytes(exact.updateBase64, "document.yjs.update"),
};
}
if (base.type === "document.yjs.awareness") {
const exact = snapshotRecord(
value,
["type", "protocolVersion", "objectId", "updateBase64"],
"document.yjs.awareness"
);
return {
type: "document.yjs.awareness",
protocolVersion: 1,
objectId: parseBotsuDocumentObjectId(exact.objectId),
updateBase64: parseBase64Bytes(exact.updateBase64, "document.yjs.awareness"),
};
}
throw new TypeError("document client message type is unsupported");
};
@@ -431,6 +453,14 @@ export const parseDocumentServerMessage = (value: unknown): DocumentServerMessag
updatedAt: parseSafeInteger(base.updatedAt, "document.yjs.updatedAt"),
};
}
if (base.type === "document.yjs.awareness") {
return {
type: "document.yjs.awareness",
protocolVersion: 1,
objectId: parseBotsuDocumentObjectId(base.objectId),
updateBase64: parseBase64Bytes(base.updateBase64, "document.yjs.awareness"),
};
}
if (base.type === "document.error") {
if (
(base.code === "invalid_message" && base.message === "Invalid document message") ||