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:
@@ -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">
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -135,4 +135,9 @@ export default defineConfig({
|
||||
plugins: [inject({ Buffer: ['buffer', 'Buffer'] })],
|
||||
},
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
buffer: 'buffer',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
|
||||
Generated
+12298
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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") ||
|
||||
|
||||
Reference in New Issue
Block a user