Add EncryptID auth, try-it-now API, editor mode dropdown, and simplify sync server
- Add AuthButton component with EncryptID passkey authentication - Add /api/try endpoint for instant guest demo access (scratch notes) - Add ModeDropdown component (editing/suggesting/viewing modes) - Simplify sync server to plain JS using y-websocket setupWSConnection - Update Dockerfile to copy SDK from build context and avoid sdk/ in COPY - Update CollaborativeEditor and Toolbar with refactored editor logic - Update docker-compose and entrypoint for simplified sync server - Add package-lock.json and next-env.d.ts Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
70ce3d8954
commit
6523006c8a
24
Dockerfile
24
Dockerfile
|
|
@ -1,15 +1,20 @@
|
||||||
FROM node:20-alpine AS builder
|
FROM node:20-alpine AS builder
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Copy SDK from build context
|
# Copy SDK to a location outside the app source
|
||||||
COPY --from=sdk / /encryptid-sdk/
|
COPY sdk/ /opt/encryptid-sdk/
|
||||||
|
|
||||||
# Install dependencies
|
# Install dependencies
|
||||||
COPY package.json package-lock.json* ./
|
COPY package.json package-lock.json* ./
|
||||||
|
RUN sed -i 's|"file:../encryptid-sdk"|"file:/opt/encryptid-sdk"|' package.json
|
||||||
RUN npm install --legacy-peer-deps
|
RUN npm install --legacy-peer-deps
|
||||||
|
|
||||||
# Copy source
|
# Copy source files explicitly (avoid copying sdk/)
|
||||||
COPY . .
|
COPY src/ ./src/
|
||||||
|
COPY prisma/ ./prisma/
|
||||||
|
COPY sync-server/ ./sync-server/
|
||||||
|
COPY public/ ./public/
|
||||||
|
COPY next.config.ts tsconfig.json postcss.config.mjs entrypoint.sh ./
|
||||||
|
|
||||||
# Generate Prisma client and build
|
# Generate Prisma client and build
|
||||||
RUN npx prisma generate
|
RUN npx prisma generate
|
||||||
|
|
@ -29,20 +34,21 @@ COPY --from=builder /app/.next/standalone ./
|
||||||
COPY --from=builder /app/.next/static ./.next/static
|
COPY --from=builder /app/.next/static ./.next/static
|
||||||
COPY --from=builder /app/public ./public
|
COPY --from=builder /app/public ./public
|
||||||
|
|
||||||
# Copy sync server
|
# Copy sync server (plain JS, no compilation needed)
|
||||||
COPY --from=builder /app/sync-server/dist ./sync-server/dist
|
COPY --from=builder /app/sync-server/src/index.js ./sync-server/index.js
|
||||||
COPY --from=builder /app/node_modules/yjs ./node_modules/yjs
|
COPY --from=builder /app/node_modules/yjs ./node_modules/yjs
|
||||||
|
COPY --from=builder /app/node_modules/y-websocket ./node_modules/y-websocket
|
||||||
COPY --from=builder /app/node_modules/y-protocols ./node_modules/y-protocols
|
COPY --from=builder /app/node_modules/y-protocols ./node_modules/y-protocols
|
||||||
COPY --from=builder /app/node_modules/lib0 ./node_modules/lib0
|
COPY --from=builder /app/node_modules/lib0 ./node_modules/lib0
|
||||||
COPY --from=builder /app/node_modules/ws ./node_modules/ws
|
COPY --from=builder /app/node_modules/ws ./node_modules/ws
|
||||||
|
COPY --from=builder /app/node_modules/lodash.debounce ./node_modules/lodash.debounce
|
||||||
|
|
||||||
# Copy Prisma
|
# Copy Prisma client
|
||||||
COPY --from=builder /app/node_modules/.prisma ./node_modules/.prisma
|
COPY --from=builder /app/node_modules/.prisma ./node_modules/.prisma
|
||||||
COPY --from=builder /app/node_modules/@prisma ./node_modules/@prisma
|
COPY --from=builder /app/node_modules/@prisma ./node_modules/@prisma
|
||||||
COPY --from=builder /app/prisma ./prisma
|
|
||||||
|
|
||||||
# Copy entrypoint
|
# Copy entrypoint
|
||||||
COPY entrypoint.sh ./entrypoint.sh
|
COPY --from=builder /app/entrypoint.sh ./entrypoint.sh
|
||||||
RUN chmod +x entrypoint.sh
|
RUN chmod +x entrypoint.sh
|
||||||
|
|
||||||
USER nextjs
|
USER nextjs
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,6 @@ services:
|
||||||
rnotes:
|
rnotes:
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
additional_contexts:
|
|
||||||
sdk: ../encryptid-sdk
|
|
||||||
container_name: rnotes-frontend
|
container_name: rnotes-frontend
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
|
|
@ -17,6 +15,7 @@ services:
|
||||||
- NEXT_PUBLIC_ENCRYPTID_SERVER_URL=https://auth.ridentity.online
|
- NEXT_PUBLIC_ENCRYPTID_SERVER_URL=https://auth.ridentity.online
|
||||||
- NEXT_PUBLIC_SYNC_URL=wss://rnotes.online/sync
|
- NEXT_PUBLIC_SYNC_URL=wss://rnotes.online/sync
|
||||||
- SYNC_SERVER_PORT=4444
|
- SYNC_SERVER_PORT=4444
|
||||||
|
- HOSTNAME=0.0.0.0
|
||||||
depends_on:
|
depends_on:
|
||||||
rnotes-db:
|
rnotes-db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
|
@ -26,14 +25,16 @@ services:
|
||||||
labels:
|
labels:
|
||||||
- "traefik.enable=true"
|
- "traefik.enable=true"
|
||||||
# Main app
|
# Main app
|
||||||
- "traefik.http.routers.rnotes.rule=Host(`rnotes.online`) || HostRegexp(`{subdomain:[a-z0-9-]+}.rnotes.online`)"
|
- "traefik.http.routers.rnotes.rule=Host(`rnotes.online`) || Host(`www.rnotes.online`) || HostRegexp(`{subdomain:[a-z0-9-]+}.rnotes.online`)"
|
||||||
- "traefik.http.routers.rnotes.entrypoints=websecure"
|
- "traefik.http.routers.rnotes.entrypoints=web"
|
||||||
- "traefik.http.routers.rnotes.tls.certresolver=letsencrypt"
|
- "traefik.http.routers.rnotes.priority=130"
|
||||||
|
- "traefik.http.routers.rnotes.service=rnotes"
|
||||||
- "traefik.http.services.rnotes.loadbalancer.server.port=3000"
|
- "traefik.http.services.rnotes.loadbalancer.server.port=3000"
|
||||||
# WebSocket sync
|
# WebSocket sync
|
||||||
- "traefik.http.routers.rnotes-sync.rule=(Host(`rnotes.online`) || HostRegexp(`{subdomain:[a-z0-9-]+}.rnotes.online`)) && PathPrefix(`/sync`)"
|
- "traefik.http.routers.rnotes-sync.rule=(Host(`rnotes.online`) || HostRegexp(`{subdomain:[a-z0-9-]+}.rnotes.online`)) && PathPrefix(`/sync`)"
|
||||||
- "traefik.http.routers.rnotes-sync.entrypoints=websecure"
|
- "traefik.http.routers.rnotes-sync.entrypoints=web"
|
||||||
- "traefik.http.routers.rnotes-sync.tls.certresolver=letsencrypt"
|
- "traefik.http.routers.rnotes-sync.priority=200"
|
||||||
|
- "traefik.http.routers.rnotes-sync.service=rnotes-sync"
|
||||||
- "traefik.http.services.rnotes-sync.loadbalancer.server.port=4444"
|
- "traefik.http.services.rnotes-sync.loadbalancer.server.port=4444"
|
||||||
- "traefik.http.middlewares.rnotes-sync-strip.stripprefix.prefixes=/sync"
|
- "traefik.http.middlewares.rnotes-sync-strip.stripprefix.prefixes=/sync"
|
||||||
- "traefik.http.routers.rnotes-sync.middlewares=rnotes-sync-strip"
|
- "traefik.http.routers.rnotes-sync.middlewares=rnotes-sync-strip"
|
||||||
|
|
|
||||||
|
|
@ -2,10 +2,7 @@
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
# Start the Yjs sync server in the background
|
# Start the Yjs sync server in the background
|
||||||
node sync-server/dist/index.js &
|
node sync-server/index.js &
|
||||||
|
|
||||||
# Run Prisma migrations
|
|
||||||
npx prisma db push --skip-generate 2>/dev/null || true
|
|
||||||
|
|
||||||
# Start the Next.js server
|
# Start the Next.js server
|
||||||
exec "$@"
|
exec "$@"
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
/// <reference types="next" />
|
||||||
|
/// <reference types="next/image-types/global" />
|
||||||
|
/// <reference path="./.next/types/routes.d.ts" />
|
||||||
|
|
||||||
|
// NOTE: This file should not be edited
|
||||||
|
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,55 @@
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { prisma } from '@/lib/prisma';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/try — Create a scratch note for instant demo access.
|
||||||
|
* No auth required. Creates a guest user + default space + scratch notebook + note.
|
||||||
|
* Returns the note URL so the frontend can redirect immediately.
|
||||||
|
*/
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
// Ensure default space
|
||||||
|
let space = await prisma.space.findUnique({ where: { slug: 'default' } });
|
||||||
|
if (!space) {
|
||||||
|
space = await prisma.space.create({
|
||||||
|
data: { slug: 'default', name: 'rNotes' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure guest user
|
||||||
|
let guest = await prisma.user.findUnique({ where: { username: 'guest' } });
|
||||||
|
if (!guest) {
|
||||||
|
guest = await prisma.user.create({
|
||||||
|
data: { username: 'guest', name: 'Guest', did: 'did:guest:anonymous' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find or create the Scratch Pad notebook
|
||||||
|
let scratchPad = await prisma.notebook.findFirst({
|
||||||
|
where: { spaceId: space.id, title: 'Scratch Pad' },
|
||||||
|
});
|
||||||
|
if (!scratchPad) {
|
||||||
|
scratchPad = await prisma.notebook.create({
|
||||||
|
data: {
|
||||||
|
title: 'Scratch Pad',
|
||||||
|
description: 'Try the editor — no sign-in required',
|
||||||
|
icon: '🗒️',
|
||||||
|
spaceId: space.id,
|
||||||
|
createdBy: guest.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a new scratch note
|
||||||
|
const note = await prisma.note.create({
|
||||||
|
data: {
|
||||||
|
title: 'Scratch Note',
|
||||||
|
notebookId: scratchPad.id,
|
||||||
|
createdBy: guest.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
noteId: note.id,
|
||||||
|
url: `/s/default/n/${note.id}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -1,7 +1,25 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { useState } from 'react';
|
||||||
import { Header } from '@/components/Header';
|
import { Header } from '@/components/Header';
|
||||||
|
|
||||||
export default function LandingPage() {
|
export default function LandingPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const [trying, setTrying] = useState(false);
|
||||||
|
|
||||||
|
const handleTry = async () => {
|
||||||
|
setTrying(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/try', { method: 'POST' });
|
||||||
|
if (res.ok) {
|
||||||
|
const { url } = await res.json();
|
||||||
|
router.push(url);
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
setTrying(false);
|
||||||
|
};
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex flex-col">
|
<div className="min-h-screen flex flex-col">
|
||||||
<Header />
|
<Header />
|
||||||
|
|
@ -26,18 +44,19 @@ export default function LandingPage() {
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="flex items-center justify-center gap-3">
|
<div className="flex items-center justify-center gap-3">
|
||||||
|
<button
|
||||||
|
onClick={handleTry}
|
||||||
|
disabled={trying}
|
||||||
|
className="px-6 py-2.5 bg-primary text-primary-foreground rounded-lg font-medium hover:bg-primary/90 transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{trying ? 'Creating note...' : 'Try it Now'}
|
||||||
|
</button>
|
||||||
<Link
|
<Link
|
||||||
href="/dashboard"
|
href="/dashboard"
|
||||||
className="px-6 py-2.5 bg-primary text-primary-foreground rounded-lg font-medium hover:bg-primary/90 transition-colors"
|
|
||||||
>
|
|
||||||
Get Started
|
|
||||||
</Link>
|
|
||||||
<a
|
|
||||||
href="https://rstack.online"
|
|
||||||
className="px-6 py-2.5 border border-slate-700 rounded-lg text-slate-300 hover:bg-white/[0.04] transition-colors"
|
className="px-6 py-2.5 border border-slate-700 rounded-lg text-slate-300 hover:bg-white/[0.04] transition-colors"
|
||||||
>
|
>
|
||||||
Learn More
|
Dashboard
|
||||||
</a>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,112 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
|
||||||
|
const ENCRYPTID_SERVER = process.env.NEXT_PUBLIC_ENCRYPTID_SERVER_URL || 'https://auth.ridentity.online';
|
||||||
|
|
||||||
|
interface AuthState {
|
||||||
|
authenticated: boolean;
|
||||||
|
username: string | null;
|
||||||
|
did: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStoredSession(): AuthState {
|
||||||
|
if (typeof window === 'undefined') return { authenticated: false, username: null, did: null };
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem('encryptid_session');
|
||||||
|
if (!stored) return { authenticated: false, username: null, did: null };
|
||||||
|
const parsed = JSON.parse(stored);
|
||||||
|
const claims = parsed?.claims || parsed;
|
||||||
|
if (claims?.sub) {
|
||||||
|
return {
|
||||||
|
authenticated: true,
|
||||||
|
did: claims.sub,
|
||||||
|
username: claims.eid?.username || claims.username || null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
return { authenticated: false, username: null, did: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AuthButton() {
|
||||||
|
const [auth, setAuth] = useState<AuthState>({ authenticated: false, username: null, did: null });
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setAuth(getStoredSession());
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleLogin = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
// Dynamic import to avoid SSR issues with WebAuthn
|
||||||
|
const { EncryptIDClient } = await import('@encryptid/sdk/client');
|
||||||
|
const client = new EncryptIDClient(ENCRYPTID_SERVER);
|
||||||
|
const result = await client.authenticate();
|
||||||
|
|
||||||
|
// Store token as cookie for API routes
|
||||||
|
document.cookie = `encryptid_token=${result.token};path=/;max-age=900;SameSite=Lax`;
|
||||||
|
|
||||||
|
// Store session in localStorage for SpaceSwitcher/AppSwitcher
|
||||||
|
localStorage.setItem('encryptid_token', result.token);
|
||||||
|
localStorage.setItem('encryptid_session', JSON.stringify({
|
||||||
|
claims: { sub: result.did, eid: { username: result.username } }
|
||||||
|
}));
|
||||||
|
|
||||||
|
setAuth({ authenticated: true, did: result.did, username: result.username });
|
||||||
|
} catch (e: unknown) {
|
||||||
|
if (e instanceof DOMException && (e.name === 'NotAllowedError' || e.name === 'AbortError')) {
|
||||||
|
setError('Passkey not found — register first at ridentity.online');
|
||||||
|
} else {
|
||||||
|
setError(e instanceof Error ? e.message : 'Sign in failed');
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLogout = () => {
|
||||||
|
document.cookie = 'encryptid_token=;path=/;max-age=0;SameSite=Lax';
|
||||||
|
localStorage.removeItem('encryptid_token');
|
||||||
|
localStorage.removeItem('encryptid_session');
|
||||||
|
setAuth({ authenticated: false, username: null, did: null });
|
||||||
|
};
|
||||||
|
|
||||||
|
if (auth.authenticated) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="text-sm">
|
||||||
|
<span className="text-white/60">Signed in as </span>
|
||||||
|
<span className="text-primary font-medium">{auth.username || auth.did?.slice(0, 12) + '...'}</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handleLogout}
|
||||||
|
className="text-xs text-white/40 hover:text-white/60 transition-colors"
|
||||||
|
>
|
||||||
|
Sign out
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={handleLogin}
|
||||||
|
disabled={loading}
|
||||||
|
className="text-sm text-white/60 hover:text-primary transition-colors flex items-center gap-1.5"
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} className="w-4 h-4">
|
||||||
|
<circle cx={12} cy={10} r={3} />
|
||||||
|
<path d="M12 13v8" />
|
||||||
|
<path d="M9 18h6" />
|
||||||
|
<circle cx={12} cy={10} r={7} />
|
||||||
|
</svg>
|
||||||
|
{loading ? 'Signing in...' : 'Sign in'}
|
||||||
|
</button>
|
||||||
|
{error && <span className="text-xs text-red-400 max-w-[200px] truncate">{error}</span>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { AppSwitcher } from '@/components/AppSwitcher';
|
import { AppSwitcher } from '@/components/AppSwitcher';
|
||||||
import { SpaceSwitcher } from '@/components/SpaceSwitcher';
|
import { SpaceSwitcher } from '@/components/SpaceSwitcher';
|
||||||
|
import { AuthButton } from '@/components/AuthButton';
|
||||||
|
|
||||||
interface BreadcrumbItem {
|
interface BreadcrumbItem {
|
||||||
label: string;
|
label: string;
|
||||||
|
|
@ -38,8 +39,9 @@ export function Header({ breadcrumbs, actions }: HeaderProps) {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-3">
|
||||||
{actions}
|
{actions}
|
||||||
|
<AuthButton />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
|
||||||
|
|
@ -5,8 +5,8 @@ import { useEditor, EditorContent } from '@tiptap/react';
|
||||||
import StarterKit from '@tiptap/starter-kit';
|
import StarterKit from '@tiptap/starter-kit';
|
||||||
import Collaboration from '@tiptap/extension-collaboration';
|
import Collaboration from '@tiptap/extension-collaboration';
|
||||||
import CollaborationCursor from '@tiptap/extension-collaboration-cursor';
|
import CollaborationCursor from '@tiptap/extension-collaboration-cursor';
|
||||||
import Underline from '@tiptap/extension-underline';
|
import UnderlineExt from '@tiptap/extension-underline';
|
||||||
import Link from '@tiptap/extension-link';
|
import LinkExt from '@tiptap/extension-link';
|
||||||
import ImageExt from '@tiptap/extension-image';
|
import ImageExt from '@tiptap/extension-image';
|
||||||
import Placeholder from '@tiptap/extension-placeholder';
|
import Placeholder from '@tiptap/extension-placeholder';
|
||||||
import Highlight from '@tiptap/extension-highlight';
|
import Highlight from '@tiptap/extension-highlight';
|
||||||
|
|
@ -20,8 +20,8 @@ import { SuggestionMark, SuggestionMode } from './SuggestionExtension';
|
||||||
import { CommentMark } from './CommentExtension';
|
import { CommentMark } from './CommentExtension';
|
||||||
import { Toolbar } from './Toolbar';
|
import { Toolbar } from './Toolbar';
|
||||||
import { CommentsSidebar, type CommentThread } from './CommentsSidebar';
|
import { CommentsSidebar, type CommentThread } from './CommentsSidebar';
|
||||||
import { MessageSquarePlus, PanelRightClose, PanelRightOpen, Users, Wifi, WifiOff } from 'lucide-react';
|
import { PanelRightClose, PanelRightOpen, Users, Wifi, WifiOff } from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import type { EditorMode } from './ModeDropdown';
|
||||||
|
|
||||||
// ─── User colors for collaboration cursors ───────────────
|
// ─── User colors for collaboration cursors ───────────────
|
||||||
|
|
||||||
|
|
@ -58,7 +58,7 @@ export function CollaborativeEditor({
|
||||||
syncUrl,
|
syncUrl,
|
||||||
onTitleChange,
|
onTitleChange,
|
||||||
}: CollaborativeEditorProps) {
|
}: CollaborativeEditorProps) {
|
||||||
const [suggestionMode, setSuggestionMode] = useState(false);
|
const [mode, setMode] = useState<EditorMode>('editing');
|
||||||
const [showComments, setShowComments] = useState(true);
|
const [showComments, setShowComments] = useState(true);
|
||||||
const [showResolved, setShowResolved] = useState(false);
|
const [showResolved, setShowResolved] = useState(false);
|
||||||
const [activeCommentId, setActiveCommentId] = useState<string | null>(null);
|
const [activeCommentId, setActiveCommentId] = useState<string | null>(null);
|
||||||
|
|
@ -70,12 +70,14 @@ export function CollaborativeEditor({
|
||||||
|
|
||||||
// ─── Yjs document + WebSocket provider ─────────────────
|
// ─── Yjs document + WebSocket provider ─────────────────
|
||||||
|
|
||||||
const { ydoc, provider } = useMemo(() => {
|
const { ydoc, provider, ycomments } = useMemo(() => {
|
||||||
const ydoc = new Y.Doc();
|
const ydoc = new Y.Doc();
|
||||||
const provider = new WebsocketProvider(syncUrl, yjsDocId, ydoc, {
|
const provider = new WebsocketProvider(syncUrl, yjsDocId, ydoc, {
|
||||||
connect: true,
|
connect: true,
|
||||||
});
|
});
|
||||||
return { ydoc, provider };
|
// Shared Y.Array for multiplayer comments
|
||||||
|
const ycomments = ydoc.getArray<CommentThread>('comments');
|
||||||
|
return { ydoc, provider, ycomments };
|
||||||
}, [syncUrl, yjsDocId]);
|
}, [syncUrl, yjsDocId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -83,7 +85,7 @@ export function CollaborativeEditor({
|
||||||
setConnected(status === 'connected');
|
setConnected(status === 'connected');
|
||||||
};
|
};
|
||||||
const onPeers = () => {
|
const onPeers = () => {
|
||||||
setPeers(provider.awareness.getStates().size - 1);
|
setPeers(Math.max(0, provider.awareness.getStates().size - 1));
|
||||||
};
|
};
|
||||||
|
|
||||||
provider.on('status', onStatus);
|
provider.on('status', onStatus);
|
||||||
|
|
@ -95,13 +97,21 @@ export function CollaborativeEditor({
|
||||||
color: userColor,
|
color: userColor,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Sync comments from Yjs
|
||||||
|
const syncComments = () => {
|
||||||
|
setComments(ycomments.toArray());
|
||||||
|
};
|
||||||
|
ycomments.observe(syncComments);
|
||||||
|
syncComments();
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
|
ycomments.unobserve(syncComments);
|
||||||
provider.off('status', onStatus);
|
provider.off('status', onStatus);
|
||||||
provider.awareness.off('change', onPeers);
|
provider.awareness.off('change', onPeers);
|
||||||
provider.destroy();
|
provider.destroy();
|
||||||
ydoc.destroy();
|
ydoc.destroy();
|
||||||
};
|
};
|
||||||
}, [provider, ydoc, userName, userColor]);
|
}, [provider, ydoc, ycomments, userName, userColor]);
|
||||||
|
|
||||||
// ─── TipTap editor ────────────────────────────────────
|
// ─── TipTap editor ────────────────────────────────────
|
||||||
|
|
||||||
|
|
@ -117,8 +127,8 @@ export function CollaborativeEditor({
|
||||||
provider,
|
provider,
|
||||||
user: { name: userName, color: userColor },
|
user: { name: userName, color: userColor },
|
||||||
}),
|
}),
|
||||||
Underline,
|
UnderlineExt,
|
||||||
Link.configure({ openOnClick: false }),
|
LinkExt.configure({ openOnClick: false }),
|
||||||
ImageExt,
|
ImageExt,
|
||||||
Placeholder.configure({
|
Placeholder.configure({
|
||||||
placeholder: 'Start writing...',
|
placeholder: 'Start writing...',
|
||||||
|
|
@ -130,46 +140,49 @@ export function CollaborativeEditor({
|
||||||
Color,
|
Color,
|
||||||
SuggestionMark,
|
SuggestionMark,
|
||||||
SuggestionMode.configure({
|
SuggestionMode.configure({
|
||||||
enabled: suggestionMode,
|
enabled: false,
|
||||||
userId,
|
userId,
|
||||||
userName,
|
userName,
|
||||||
userColor,
|
userColor,
|
||||||
}),
|
}),
|
||||||
CommentMark,
|
CommentMark,
|
||||||
],
|
],
|
||||||
|
editable: mode !== 'viewing',
|
||||||
editorProps: {
|
editorProps: {
|
||||||
attributes: {
|
attributes: {
|
||||||
class: 'tiptap',
|
class: 'tiptap',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
onUpdate: ({ editor }) => {
|
onUpdate: ({ editor: ed }) => {
|
||||||
// Extract title from first heading or first line
|
const firstNode = ed.state.doc.firstChild;
|
||||||
const firstNode = editor.state.doc.firstChild;
|
|
||||||
if (firstNode) {
|
if (firstNode) {
|
||||||
const text = firstNode.textContent?.trim() || 'Untitled';
|
onTitleChange?.(firstNode.textContent?.trim() || 'Untitled');
|
||||||
onTitleChange?.(text);
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
}, [ydoc, provider]);
|
}, [ydoc, provider]);
|
||||||
|
|
||||||
// Update suggestion mode dynamically
|
// ─── Mode changes ──────────────────────────────────────
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!editor) return;
|
if (!editor) return;
|
||||||
// Re-configure the suggestion mode extension
|
|
||||||
|
// Toggle editable
|
||||||
|
editor.setEditable(mode !== 'viewing');
|
||||||
|
|
||||||
|
// Toggle suggestion mode
|
||||||
editor.extensionManager.extensions.forEach((ext) => {
|
editor.extensionManager.extensions.forEach((ext) => {
|
||||||
if (ext.name === 'suggestionMode') {
|
if (ext.name === 'suggestionMode') {
|
||||||
ext.options.enabled = suggestionMode;
|
ext.options.enabled = mode === 'suggesting';
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}, [editor, suggestionMode]);
|
}, [editor, mode]);
|
||||||
|
|
||||||
// ─── Suggestion helpers ────────────────────────────────
|
// ─── Suggestion helpers ────────────────────────────────
|
||||||
|
|
||||||
const pendingSuggestions = useMemo(() => {
|
const pendingSuggestions = useMemo(() => {
|
||||||
if (!editor) return 0;
|
if (!editor) return 0;
|
||||||
let count = 0;
|
let count = 0;
|
||||||
const { doc } = editor.state;
|
editor.state.doc.descendants((node) => {
|
||||||
doc.descendants((node) => {
|
|
||||||
node.marks.forEach((mark) => {
|
node.marks.forEach((mark) => {
|
||||||
if (mark.type.name === 'suggestion' && mark.attrs.status === 'pending') {
|
if (mark.type.name === 'suggestion' && mark.attrs.status === 'pending') {
|
||||||
count++;
|
count++;
|
||||||
|
|
@ -177,30 +190,24 @@ export function CollaborativeEditor({
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
return count;
|
return count;
|
||||||
}, [editor?.state.doc]);
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [editor, editor?.state.doc]);
|
||||||
|
|
||||||
const handleAcceptAll = useCallback(() => {
|
const handleAcceptAll = useCallback(() => {
|
||||||
if (!editor) return;
|
if (!editor) return;
|
||||||
const { tr, doc } = editor.state;
|
const { tr, doc } = editor.state;
|
||||||
const marks: { from: number; to: number; mark: typeof doc.type.schema.marks.suggestion }[] = [];
|
const items: { from: number; to: number; type: string }[] = [];
|
||||||
|
|
||||||
doc.descendants((node, pos) => {
|
doc.descendants((node, pos) => {
|
||||||
node.marks.forEach((mark) => {
|
node.marks.forEach((mark) => {
|
||||||
if (mark.type.name === 'suggestion' && mark.attrs.status === 'pending') {
|
if (mark.type.name === 'suggestion' && mark.attrs.status === 'pending') {
|
||||||
if (mark.attrs.type === 'delete') {
|
items.push({ from: pos, to: pos + node.nodeSize, type: mark.attrs.type });
|
||||||
// For delete suggestions, remove the content
|
|
||||||
marks.push({ from: pos, to: pos + node.nodeSize, mark: mark as any });
|
|
||||||
} else {
|
|
||||||
// For insert suggestions, just remove the mark (keep the text)
|
|
||||||
marks.push({ from: pos, to: pos + node.nodeSize, mark: mark as any });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Process in reverse to maintain positions
|
items.reverse().forEach(({ from, to, type }) => {
|
||||||
marks.reverse().forEach(({ from, to, mark }) => {
|
if (type === 'delete') {
|
||||||
if ((mark as any).attrs.type === 'delete') {
|
|
||||||
tr.delete(from, to);
|
tr.delete(from, to);
|
||||||
} else {
|
} else {
|
||||||
tr.removeMark(from, to, editor.state.schema.marks.suggestion);
|
tr.removeMark(from, to, editor.state.schema.marks.suggestion);
|
||||||
|
|
@ -213,22 +220,20 @@ export function CollaborativeEditor({
|
||||||
const handleRejectAll = useCallback(() => {
|
const handleRejectAll = useCallback(() => {
|
||||||
if (!editor) return;
|
if (!editor) return;
|
||||||
const { tr, doc } = editor.state;
|
const { tr, doc } = editor.state;
|
||||||
const marks: { from: number; to: number; mark: any }[] = [];
|
const items: { from: number; to: number; type: string }[] = [];
|
||||||
|
|
||||||
doc.descendants((node, pos) => {
|
doc.descendants((node, pos) => {
|
||||||
node.marks.forEach((mark) => {
|
node.marks.forEach((mark) => {
|
||||||
if (mark.type.name === 'suggestion' && mark.attrs.status === 'pending') {
|
if (mark.type.name === 'suggestion' && mark.attrs.status === 'pending') {
|
||||||
marks.push({ from: pos, to: pos + node.nodeSize, mark });
|
items.push({ from: pos, to: pos + node.nodeSize, type: mark.attrs.type });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
marks.reverse().forEach(({ from, to, mark }) => {
|
items.reverse().forEach(({ from, to, type }) => {
|
||||||
if (mark.attrs.type === 'insert') {
|
if (type === 'insert') {
|
||||||
// For insert suggestions, remove the inserted text
|
|
||||||
tr.delete(from, to);
|
tr.delete(from, to);
|
||||||
} else {
|
} else {
|
||||||
// For delete suggestions, just remove the mark (keep the text)
|
|
||||||
tr.removeMark(from, to, editor.state.schema.marks.suggestion);
|
tr.removeMark(from, to, editor.state.schema.marks.suggestion);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
@ -236,12 +241,12 @@ export function CollaborativeEditor({
|
||||||
editor.view.dispatch(tr);
|
editor.view.dispatch(tr);
|
||||||
}, [editor]);
|
}, [editor]);
|
||||||
|
|
||||||
// ─── Comment helpers ───────────────────────────────────
|
// ─── Comment helpers (synced via Yjs) ──────────────────
|
||||||
|
|
||||||
const handleAddComment = useCallback(() => {
|
const handleAddComment = useCallback(() => {
|
||||||
if (!editor) return;
|
if (!editor) return;
|
||||||
const { from, to } = editor.state.selection;
|
const { from, to } = editor.state.selection;
|
||||||
if (from === to) return; // Need a selection
|
if (from === to) return;
|
||||||
|
|
||||||
const body = window.prompt('Add a comment:');
|
const body = window.prompt('Add a comment:');
|
||||||
if (!body?.trim()) return;
|
if (!body?.trim()) return;
|
||||||
|
|
@ -261,68 +266,68 @@ export function CollaborativeEditor({
|
||||||
reactions: {},
|
reactions: {},
|
||||||
};
|
};
|
||||||
|
|
||||||
// Add comment mark to the text
|
// Add mark to editor
|
||||||
editor
|
editor.chain().focus().setMark('comment', { commentId, resolved: false }).run();
|
||||||
.chain()
|
|
||||||
.focus()
|
|
||||||
.setMark('comment', { commentId, resolved: false })
|
|
||||||
.run();
|
|
||||||
|
|
||||||
setComments((prev) => [...prev, newComment]);
|
// Add to shared Yjs array (syncs to all peers)
|
||||||
|
ycomments.push([newComment]);
|
||||||
setActiveCommentId(commentId);
|
setActiveCommentId(commentId);
|
||||||
setShowComments(true);
|
setShowComments(true);
|
||||||
}, [editor, noteId, userId, userName]);
|
}, [editor, noteId, userId, userName, ycomments]);
|
||||||
|
|
||||||
const handleReply = useCallback((commentId: string, body: string) => {
|
const handleReply = useCallback((commentId: string, body: string) => {
|
||||||
setComments((prev) =>
|
const idx = ycomments.toArray().findIndex((c) => c.id === commentId);
|
||||||
prev.map((c) =>
|
if (idx === -1) return;
|
||||||
c.id === commentId
|
|
||||||
? {
|
const comment = ycomments.get(idx);
|
||||||
...c,
|
const updated: CommentThread = {
|
||||||
replies: [
|
...comment,
|
||||||
...c.replies,
|
replies: [
|
||||||
{
|
...comment.replies,
|
||||||
id: crypto.randomUUID(),
|
{
|
||||||
authorId: userId,
|
id: crypto.randomUUID(),
|
||||||
authorName: userName,
|
authorId: userId,
|
||||||
body,
|
authorName: userName,
|
||||||
createdAt: new Date().toISOString(),
|
body,
|
||||||
},
|
createdAt: new Date().toISOString(),
|
||||||
],
|
},
|
||||||
}
|
],
|
||||||
: c
|
};
|
||||||
)
|
ydoc.transact(() => {
|
||||||
);
|
ycomments.delete(idx, 1);
|
||||||
}, [userId, userName]);
|
ycomments.insert(idx, [updated]);
|
||||||
|
});
|
||||||
|
}, [userId, userName, ycomments, ydoc]);
|
||||||
|
|
||||||
const handleResolve = useCallback((commentId: string) => {
|
const handleResolve = useCallback((commentId: string) => {
|
||||||
setComments((prev) =>
|
const idx = ycomments.toArray().findIndex((c) => c.id === commentId);
|
||||||
prev.map((c) => (c.id === commentId ? { ...c, resolved: true } : c))
|
if (idx === -1) return;
|
||||||
);
|
|
||||||
|
|
||||||
// Update the mark in the editor
|
const comment = ycomments.get(idx);
|
||||||
|
ydoc.transact(() => {
|
||||||
|
ycomments.delete(idx, 1);
|
||||||
|
ycomments.insert(idx, [{ ...comment, resolved: true }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update mark
|
||||||
if (editor) {
|
if (editor) {
|
||||||
const { doc, tr } = editor.state;
|
const { doc, tr } = editor.state;
|
||||||
doc.descendants((node, pos) => {
|
doc.descendants((node, pos) => {
|
||||||
node.marks.forEach((mark) => {
|
node.marks.forEach((mark) => {
|
||||||
if (mark.type.name === 'comment' && mark.attrs.commentId === commentId) {
|
if (mark.type.name === 'comment' && mark.attrs.commentId === commentId) {
|
||||||
tr.removeMark(pos, pos + node.nodeSize, mark.type);
|
tr.removeMark(pos, pos + node.nodeSize, mark.type);
|
||||||
tr.addMark(
|
tr.addMark(pos, pos + node.nodeSize, mark.type.create({ commentId, resolved: true }));
|
||||||
pos,
|
|
||||||
pos + node.nodeSize,
|
|
||||||
mark.type.create({ commentId, resolved: true })
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
editor.view.dispatch(tr);
|
editor.view.dispatch(tr);
|
||||||
}
|
}
|
||||||
}, [editor]);
|
}, [editor, ycomments, ydoc]);
|
||||||
|
|
||||||
const handleDeleteComment = useCallback((commentId: string) => {
|
const handleDeleteComment = useCallback((commentId: string) => {
|
||||||
setComments((prev) => prev.filter((c) => c.id !== commentId));
|
const idx = ycomments.toArray().findIndex((c) => c.id === commentId);
|
||||||
|
if (idx !== -1) ycomments.delete(idx, 1);
|
||||||
|
|
||||||
// Remove the mark from the editor
|
|
||||||
if (editor) {
|
if (editor) {
|
||||||
const { doc, tr } = editor.state;
|
const { doc, tr } = editor.state;
|
||||||
doc.descendants((node, pos) => {
|
doc.descendants((node, pos) => {
|
||||||
|
|
@ -334,61 +339,70 @@ export function CollaborativeEditor({
|
||||||
});
|
});
|
||||||
editor.view.dispatch(tr);
|
editor.view.dispatch(tr);
|
||||||
}
|
}
|
||||||
}, [editor]);
|
}, [editor, ycomments]);
|
||||||
|
|
||||||
const handleReact = useCallback((commentId: string, emoji: string) => {
|
const handleReact = useCallback((commentId: string, emoji: string) => {
|
||||||
setComments((prev) =>
|
const idx = ycomments.toArray().findIndex((c) => c.id === commentId);
|
||||||
prev.map((c) => {
|
if (idx === -1) return;
|
||||||
if (c.id !== commentId) return c;
|
|
||||||
const reactions = { ...c.reactions };
|
const comment = ycomments.get(idx);
|
||||||
const authors = reactions[emoji] ? [...reactions[emoji]] : [];
|
const reactions = { ...comment.reactions };
|
||||||
const idx = authors.indexOf(userId);
|
const authors = reactions[emoji] ? [...reactions[emoji]] : [];
|
||||||
if (idx >= 0) {
|
const aidx = authors.indexOf(userId);
|
||||||
authors.splice(idx, 1);
|
if (aidx >= 0) {
|
||||||
if (authors.length === 0) delete reactions[emoji];
|
authors.splice(aidx, 1);
|
||||||
else reactions[emoji] = authors;
|
if (authors.length === 0) delete reactions[emoji];
|
||||||
} else {
|
else reactions[emoji] = authors;
|
||||||
reactions[emoji] = [...authors, userId];
|
} else {
|
||||||
}
|
reactions[emoji] = [...authors, userId];
|
||||||
return { ...c, reactions };
|
}
|
||||||
})
|
|
||||||
);
|
ydoc.transact(() => {
|
||||||
}, [userId]);
|
ycomments.delete(idx, 1);
|
||||||
|
ycomments.insert(idx, [{ ...comment, reactions }]);
|
||||||
|
});
|
||||||
|
}, [userId, ycomments, ydoc]);
|
||||||
|
|
||||||
const handleClickComment = useCallback((commentId: string) => {
|
const handleClickComment = useCallback((commentId: string) => {
|
||||||
setActiveCommentId(commentId);
|
setActiveCommentId(commentId);
|
||||||
// Scroll editor to the comment position
|
|
||||||
if (editor) {
|
if (editor) {
|
||||||
const comment = comments.find((c) => c.id === commentId);
|
const comment = comments.find((c) => c.id === commentId);
|
||||||
if (comment) {
|
if (comment) {
|
||||||
editor.commands.setTextSelection({
|
try {
|
||||||
from: comment.fromPos,
|
editor.commands.setTextSelection({ from: comment.fromPos, to: comment.toPos });
|
||||||
to: comment.toPos,
|
editor.commands.scrollIntoView();
|
||||||
});
|
} catch {
|
||||||
editor.commands.scrollIntoView();
|
// Position may be stale
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [editor, comments]);
|
}, [editor, comments]);
|
||||||
|
|
||||||
|
// ─── Mode banner text ──────────────────────────────────
|
||||||
|
|
||||||
|
const modeBanner = mode === 'suggesting'
|
||||||
|
? { bg: 'bg-green-500/[0.08] border-green-500/20 text-green-400', text: 'Suggesting — your edits will appear as suggestions for others to review' }
|
||||||
|
: mode === 'viewing'
|
||||||
|
? { bg: 'bg-slate-500/[0.08] border-slate-500/20 text-slate-400', text: 'Viewing — read-only mode, switch to Editing or Suggesting to make changes' }
|
||||||
|
: null;
|
||||||
|
|
||||||
// ─── Render ────────────────────────────────────────────
|
// ─── Render ────────────────────────────────────────────
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-[calc(100vh-53px)]">
|
<div className="flex flex-col h-[calc(100vh-53px)]">
|
||||||
<Toolbar
|
<Toolbar
|
||||||
editor={editor}
|
editor={editor}
|
||||||
suggestionMode={suggestionMode}
|
mode={mode}
|
||||||
onToggleSuggestionMode={() => setSuggestionMode(!suggestionMode)}
|
onModeChange={setMode}
|
||||||
onAddComment={handleAddComment}
|
onAddComment={handleAddComment}
|
||||||
pendingSuggestions={pendingSuggestions}
|
pendingSuggestions={pendingSuggestions}
|
||||||
onAcceptAll={handleAcceptAll}
|
onAcceptAll={handleAcceptAll}
|
||||||
onRejectAll={handleRejectAll}
|
onRejectAll={handleRejectAll}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Mode indicator bar */}
|
{modeBanner && (
|
||||||
{suggestionMode && (
|
<div className={`flex items-center gap-2 px-4 py-1.5 border-b text-xs ${modeBanner.bg}`}>
|
||||||
<div className="flex items-center gap-2 px-4 py-1.5 bg-green-500/[0.08] border-b border-green-500/20 text-green-400 text-xs">
|
<span>{modeBanner.text}</span>
|
||||||
<FileEdit size={12} />
|
|
||||||
<span>Suggestion mode — your edits will appear as suggestions for review</span>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
@ -450,13 +464,3 @@ export function CollaborativeEditor({
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Re-export for the toolbar (used in this file but imported from lucide)
|
|
||||||
function FileEdit({ size }: { size: number }) {
|
|
||||||
return (
|
|
||||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
||||||
<path d="M12 20h9" />
|
|
||||||
<path d="M16.376 3.622a1 1 0 0 1 3.002 3.002L7.368 18.635a2 2 0 0 1-.855.506l-2.872.838a.5.5 0 0 1-.62-.62l.838-2.872a2 2 0 0 1 .506-.855z" />
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,80 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useRef, useEffect } from 'react';
|
||||||
|
import { Pencil, LightbulbIcon, Eye, ChevronDown } from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
export type EditorMode = 'editing' | 'suggesting' | 'viewing';
|
||||||
|
|
||||||
|
const MODES: { id: EditorMode; label: string; icon: typeof Pencil; desc: string; color: string }[] = [
|
||||||
|
{ id: 'editing', label: 'Editing', icon: Pencil, desc: 'Edit the document directly', color: 'text-blue-400' },
|
||||||
|
{ id: 'suggesting', label: 'Suggesting', icon: LightbulbIcon, desc: 'Suggest changes for review', color: 'text-green-400' },
|
||||||
|
{ id: 'viewing', label: 'Viewing', icon: Eye, desc: 'Read-only view', color: 'text-slate-400' },
|
||||||
|
];
|
||||||
|
|
||||||
|
interface ModeDropdownProps {
|
||||||
|
mode: EditorMode;
|
||||||
|
onChange: (mode: EditorMode) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ModeDropdown({ mode, onChange }: ModeDropdownProps) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function handleClick(e: MouseEvent) {
|
||||||
|
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
|
||||||
|
}
|
||||||
|
document.addEventListener('click', handleClick);
|
||||||
|
return () => document.removeEventListener('click', handleClick);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const current = MODES.find((m) => m.id === mode)!;
|
||||||
|
const Icon = current.icon;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative" ref={ref}>
|
||||||
|
<button
|
||||||
|
onClick={() => setOpen(!open)}
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-sm font-medium transition-colors',
|
||||||
|
'hover:bg-white/[0.06] border border-transparent',
|
||||||
|
mode === 'editing' && 'text-blue-400 bg-blue-500/[0.08] border-blue-500/20',
|
||||||
|
mode === 'suggesting' && 'text-green-400 bg-green-500/[0.08] border-green-500/20',
|
||||||
|
mode === 'viewing' && 'text-slate-400 bg-white/[0.04]',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon size={14} />
|
||||||
|
<span>{current.label}</span>
|
||||||
|
<ChevronDown size={12} className="opacity-50" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{open && (
|
||||||
|
<div className="absolute top-full right-0 mt-1 w-56 rounded-xl bg-slate-800 border border-white/10 shadow-xl shadow-black/30 z-[200] overflow-hidden">
|
||||||
|
{MODES.map((m) => {
|
||||||
|
const MIcon = m.icon;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={m.id}
|
||||||
|
onClick={() => { onChange(m.id); setOpen(false); }}
|
||||||
|
className={cn(
|
||||||
|
'w-full flex items-start gap-2.5 px-3.5 py-2.5 text-left transition-colors hover:bg-white/[0.05]',
|
||||||
|
mode === m.id && 'bg-white/[0.07]'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<MIcon size={16} className={cn('mt-0.5 flex-shrink-0', m.color)} />
|
||||||
|
<div>
|
||||||
|
<div className={cn('text-sm font-medium', m.color)}>{m.label}</div>
|
||||||
|
<div className="text-[11px] text-slate-500">{m.desc}</div>
|
||||||
|
</div>
|
||||||
|
{mode === m.id && (
|
||||||
|
<span className="ml-auto text-primary text-sm">✓</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -7,21 +7,22 @@ import {
|
||||||
List, ListOrdered, ListChecks,
|
List, ListOrdered, ListChecks,
|
||||||
Quote, Code, Minus, Undo2, Redo2,
|
Quote, Code, Minus, Undo2, Redo2,
|
||||||
Image, Link2,
|
Image, Link2,
|
||||||
MessageSquare, FileEdit, Check, X,
|
MessageSquare, Check, X,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import { ModeDropdown, type EditorMode } from './ModeDropdown';
|
||||||
|
|
||||||
interface ToolbarProps {
|
interface ToolbarProps {
|
||||||
editor: Editor | null;
|
editor: Editor | null;
|
||||||
suggestionMode: boolean;
|
mode: EditorMode;
|
||||||
onToggleSuggestionMode: () => void;
|
onModeChange: (mode: EditorMode) => void;
|
||||||
onAddComment: () => void;
|
onAddComment: () => void;
|
||||||
pendingSuggestions: number;
|
pendingSuggestions: number;
|
||||||
onAcceptAll: () => void;
|
onAcceptAll: () => void;
|
||||||
onRejectAll: () => void;
|
onRejectAll: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function ToolbarButton({
|
function Btn({
|
||||||
onClick,
|
onClick,
|
||||||
active,
|
active,
|
||||||
disabled,
|
disabled,
|
||||||
|
|
@ -65,8 +66,8 @@ function Divider() {
|
||||||
|
|
||||||
export function Toolbar({
|
export function Toolbar({
|
||||||
editor,
|
editor,
|
||||||
suggestionMode,
|
mode,
|
||||||
onToggleSuggestionMode,
|
onModeChange,
|
||||||
onAddComment,
|
onAddComment,
|
||||||
pendingSuggestions,
|
pendingSuggestions,
|
||||||
onAcceptAll,
|
onAcceptAll,
|
||||||
|
|
@ -74,130 +75,127 @@ export function Toolbar({
|
||||||
}: ToolbarProps) {
|
}: ToolbarProps) {
|
||||||
if (!editor) return null;
|
if (!editor) return null;
|
||||||
|
|
||||||
|
const isViewing = mode === 'viewing';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-0.5 px-3 py-1.5 border-b border-slate-800 bg-slate-900/50 backdrop-blur-sm flex-wrap">
|
<div className="flex items-center gap-0.5 px-3 py-1.5 border-b border-slate-800 bg-slate-900/50 backdrop-blur-sm flex-wrap">
|
||||||
{/* Undo / Redo */}
|
{/* Undo / Redo */}
|
||||||
<ToolbarButton onClick={() => editor.chain().focus().undo().run()} disabled={!editor.can().undo()} title="Undo">
|
<Btn onClick={() => editor.chain().focus().undo().run()} disabled={isViewing || !editor.can().undo()} title="Undo">
|
||||||
<Undo2 size={16} />
|
<Undo2 size={16} />
|
||||||
</ToolbarButton>
|
</Btn>
|
||||||
<ToolbarButton onClick={() => editor.chain().focus().redo().run()} disabled={!editor.can().redo()} title="Redo">
|
<Btn onClick={() => editor.chain().focus().redo().run()} disabled={isViewing || !editor.can().redo()} title="Redo">
|
||||||
<Redo2 size={16} />
|
<Redo2 size={16} />
|
||||||
</ToolbarButton>
|
</Btn>
|
||||||
|
|
||||||
<Divider />
|
<Divider />
|
||||||
|
|
||||||
{/* Text formatting */}
|
{/* Text formatting */}
|
||||||
<ToolbarButton onClick={() => editor.chain().focus().toggleBold().run()} active={editor.isActive('bold')} title="Bold">
|
<Btn onClick={() => editor.chain().focus().toggleBold().run()} active={editor.isActive('bold')} disabled={isViewing} title="Bold">
|
||||||
<Bold size={16} />
|
<Bold size={16} />
|
||||||
</ToolbarButton>
|
</Btn>
|
||||||
<ToolbarButton onClick={() => editor.chain().focus().toggleItalic().run()} active={editor.isActive('italic')} title="Italic">
|
<Btn onClick={() => editor.chain().focus().toggleItalic().run()} active={editor.isActive('italic')} disabled={isViewing} title="Italic">
|
||||||
<Italic size={16} />
|
<Italic size={16} />
|
||||||
</ToolbarButton>
|
</Btn>
|
||||||
<ToolbarButton onClick={() => editor.chain().focus().toggleUnderline().run()} active={editor.isActive('underline')} title="Underline">
|
<Btn onClick={() => editor.chain().focus().toggleUnderline().run()} active={editor.isActive('underline')} disabled={isViewing} title="Underline">
|
||||||
<Underline size={16} />
|
<Underline size={16} />
|
||||||
</ToolbarButton>
|
</Btn>
|
||||||
<ToolbarButton onClick={() => editor.chain().focus().toggleStrike().run()} active={editor.isActive('strike')} title="Strikethrough">
|
<Btn onClick={() => editor.chain().focus().toggleStrike().run()} active={editor.isActive('strike')} disabled={isViewing} title="Strikethrough">
|
||||||
<Strikethrough size={16} />
|
<Strikethrough size={16} />
|
||||||
</ToolbarButton>
|
</Btn>
|
||||||
<ToolbarButton onClick={() => editor.chain().focus().toggleCode().run()} active={editor.isActive('code')} title="Inline code">
|
<Btn onClick={() => editor.chain().focus().toggleCode().run()} active={editor.isActive('code')} disabled={isViewing} title="Inline code">
|
||||||
<Code size={16} />
|
<Code size={16} />
|
||||||
</ToolbarButton>
|
</Btn>
|
||||||
|
|
||||||
<Divider />
|
<Divider />
|
||||||
|
|
||||||
{/* Headings */}
|
{/* Headings */}
|
||||||
<ToolbarButton onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()} active={editor.isActive('heading', { level: 1 })} title="Heading 1">
|
<Btn onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()} active={editor.isActive('heading', { level: 1 })} disabled={isViewing} title="Heading 1">
|
||||||
<Heading1 size={16} />
|
<Heading1 size={16} />
|
||||||
</ToolbarButton>
|
</Btn>
|
||||||
<ToolbarButton onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()} active={editor.isActive('heading', { level: 2 })} title="Heading 2">
|
<Btn onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()} active={editor.isActive('heading', { level: 2 })} disabled={isViewing} title="Heading 2">
|
||||||
<Heading2 size={16} />
|
<Heading2 size={16} />
|
||||||
</ToolbarButton>
|
</Btn>
|
||||||
<ToolbarButton onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()} active={editor.isActive('heading', { level: 3 })} title="Heading 3">
|
<Btn onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()} active={editor.isActive('heading', { level: 3 })} disabled={isViewing} title="Heading 3">
|
||||||
<Heading3 size={16} />
|
<Heading3 size={16} />
|
||||||
</ToolbarButton>
|
</Btn>
|
||||||
|
|
||||||
<Divider />
|
<Divider />
|
||||||
|
|
||||||
{/* Lists */}
|
{/* Lists */}
|
||||||
<ToolbarButton onClick={() => editor.chain().focus().toggleBulletList().run()} active={editor.isActive('bulletList')} title="Bullet list">
|
<Btn onClick={() => editor.chain().focus().toggleBulletList().run()} active={editor.isActive('bulletList')} disabled={isViewing} title="Bullet list">
|
||||||
<List size={16} />
|
<List size={16} />
|
||||||
</ToolbarButton>
|
</Btn>
|
||||||
<ToolbarButton onClick={() => editor.chain().focus().toggleOrderedList().run()} active={editor.isActive('orderedList')} title="Numbered list">
|
<Btn onClick={() => editor.chain().focus().toggleOrderedList().run()} active={editor.isActive('orderedList')} disabled={isViewing} title="Numbered list">
|
||||||
<ListOrdered size={16} />
|
<ListOrdered size={16} />
|
||||||
</ToolbarButton>
|
</Btn>
|
||||||
<ToolbarButton onClick={() => editor.chain().focus().toggleTaskList().run()} active={editor.isActive('taskList')} title="Task list">
|
<Btn onClick={() => editor.chain().focus().toggleTaskList().run()} active={editor.isActive('taskList')} disabled={isViewing} title="Task list">
|
||||||
<ListChecks size={16} />
|
<ListChecks size={16} />
|
||||||
</ToolbarButton>
|
</Btn>
|
||||||
|
|
||||||
<Divider />
|
<Divider />
|
||||||
|
|
||||||
{/* Block elements */}
|
{/* Block elements */}
|
||||||
<ToolbarButton onClick={() => editor.chain().focus().toggleBlockquote().run()} active={editor.isActive('blockquote')} title="Blockquote">
|
<Btn onClick={() => editor.chain().focus().toggleBlockquote().run()} active={editor.isActive('blockquote')} disabled={isViewing} title="Blockquote">
|
||||||
<Quote size={16} />
|
<Quote size={16} />
|
||||||
</ToolbarButton>
|
</Btn>
|
||||||
<ToolbarButton onClick={() => editor.chain().focus().setHorizontalRule().run()} title="Horizontal rule">
|
<Btn onClick={() => editor.chain().focus().setHorizontalRule().run()} disabled={isViewing} title="Horizontal rule">
|
||||||
<Minus size={16} />
|
<Minus size={16} />
|
||||||
</ToolbarButton>
|
</Btn>
|
||||||
|
|
||||||
<Divider />
|
<Divider />
|
||||||
|
|
||||||
{/* Link */}
|
{/* Link */}
|
||||||
<ToolbarButton
|
<Btn
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const url = window.prompt('URL:');
|
const url = window.prompt('URL:');
|
||||||
if (url) editor.chain().focus().setLink({ href: url }).run();
|
if (url) editor.chain().focus().setLink({ href: url }).run();
|
||||||
}}
|
}}
|
||||||
active={editor.isActive('link')}
|
active={editor.isActive('link')}
|
||||||
|
disabled={isViewing}
|
||||||
title="Insert link"
|
title="Insert link"
|
||||||
>
|
>
|
||||||
<Link2 size={16} />
|
<Link2 size={16} />
|
||||||
</ToolbarButton>
|
</Btn>
|
||||||
|
|
||||||
{/* Image */}
|
{/* Image */}
|
||||||
<ToolbarButton
|
<Btn
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const url = window.prompt('Image URL:');
|
const url = window.prompt('Image URL:');
|
||||||
if (url) editor.chain().focus().setImage({ src: url }).run();
|
if (url) editor.chain().focus().setImage({ src: url }).run();
|
||||||
}}
|
}}
|
||||||
|
disabled={isViewing}
|
||||||
title="Insert image"
|
title="Insert image"
|
||||||
>
|
>
|
||||||
<Image size={16} />
|
<Image size={16} />
|
||||||
</ToolbarButton>
|
</Btn>
|
||||||
|
|
||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
|
|
||||||
{/* Collaboration tools */}
|
{/* Comment button */}
|
||||||
<ToolbarButton onClick={onAddComment} title="Add comment" variant="accent">
|
<Btn onClick={onAddComment} title="Add comment (select text first)" variant="accent">
|
||||||
<MessageSquare size={16} />
|
<MessageSquare size={16} />
|
||||||
</ToolbarButton>
|
</Btn>
|
||||||
|
|
||||||
|
{/* Suggestion accept/reject */}
|
||||||
|
{pendingSuggestions > 0 && (
|
||||||
|
<>
|
||||||
|
<Divider />
|
||||||
|
<span className="text-xs text-slate-400 mx-1">
|
||||||
|
{pendingSuggestions} pending
|
||||||
|
</span>
|
||||||
|
<Btn onClick={onAcceptAll} title="Accept all suggestions" variant="success">
|
||||||
|
<Check size={14} />
|
||||||
|
</Btn>
|
||||||
|
<Btn onClick={onRejectAll} title="Reject all suggestions" variant="danger">
|
||||||
|
<X size={14} />
|
||||||
|
</Btn>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
<Divider />
|
<Divider />
|
||||||
|
|
||||||
{/* Suggestion mode toggle */}
|
{/* Mode dropdown */}
|
||||||
<ToolbarButton
|
<ModeDropdown mode={mode} onChange={onModeChange} />
|
||||||
onClick={onToggleSuggestionMode}
|
|
||||||
active={suggestionMode}
|
|
||||||
title={suggestionMode ? 'Switch to editing mode' : 'Switch to suggestion mode'}
|
|
||||||
>
|
|
||||||
<FileEdit size={16} />
|
|
||||||
<span className="text-xs ml-1 hidden sm:inline">
|
|
||||||
{suggestionMode ? 'Suggesting' : 'Editing'}
|
|
||||||
</span>
|
|
||||||
</ToolbarButton>
|
|
||||||
|
|
||||||
{pendingSuggestions > 0 && (
|
|
||||||
<>
|
|
||||||
<span className="text-xs text-slate-400 ml-1">
|
|
||||||
{pendingSuggestions} pending
|
|
||||||
</span>
|
|
||||||
<ToolbarButton onClick={onAcceptAll} title="Accept all suggestions" variant="success">
|
|
||||||
<Check size={14} />
|
|
||||||
</ToolbarButton>
|
|
||||||
<ToolbarButton onClick={onRejectAll} title="Reject all suggestions" variant="danger">
|
|
||||||
<X size={14} />
|
|
||||||
</ToolbarButton>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,31 @@
|
||||||
|
/**
|
||||||
|
* Yjs WebSocket sync server for rNotes.
|
||||||
|
*
|
||||||
|
* Uses y-websocket's setupWSConnection for full protocol compatibility.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const http = require("http");
|
||||||
|
const { WebSocketServer } = require("ws");
|
||||||
|
const { setupWSConnection } = require("y-websocket/bin/utils");
|
||||||
|
|
||||||
|
const PORT = parseInt(process.env.SYNC_SERVER_PORT || "4444", 10);
|
||||||
|
|
||||||
|
const server = http.createServer((req, res) => {
|
||||||
|
if (req.url === "/health") {
|
||||||
|
res.writeHead(200, { "Content-Type": "application/json" });
|
||||||
|
res.end(JSON.stringify({ status: "ok" }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.writeHead(200);
|
||||||
|
res.end("rNotes sync server");
|
||||||
|
});
|
||||||
|
|
||||||
|
const wss = new WebSocketServer({ server });
|
||||||
|
|
||||||
|
wss.on("connection", (ws, req) => {
|
||||||
|
setupWSConnection(ws, req);
|
||||||
|
});
|
||||||
|
|
||||||
|
server.listen(PORT, () => {
|
||||||
|
console.log(`rNotes sync server listening on port ${PORT}`);
|
||||||
|
});
|
||||||
|
|
@ -1,123 +1,21 @@
|
||||||
/**
|
/**
|
||||||
* Yjs WebSocket sync server for rNotes.
|
* Yjs WebSocket sync server for rNotes.
|
||||||
*
|
*
|
||||||
* Each note document is identified by its yjsDocId.
|
* Uses y-websocket's setupWSConnection for full compatibility
|
||||||
* The server holds documents in memory, persists to LevelDB,
|
* with the y-websocket client WebsocketProvider.
|
||||||
* and broadcasts changes to all connected clients.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import http from "http";
|
import http from "http";
|
||||||
import { WebSocketServer, WebSocket } from "ws";
|
import { WebSocketServer } from "ws";
|
||||||
import * as Y from "yjs";
|
// @ts-ignore — y-websocket/bin/utils has no types
|
||||||
import { encoding, decoding, mutex } from "lib0";
|
import { setupWSConnection } from "y-websocket/bin/utils";
|
||||||
|
|
||||||
const PORT = parseInt(process.env.SYNC_SERVER_PORT || "4444", 10);
|
const PORT = parseInt(process.env.SYNC_SERVER_PORT || "4444", 10);
|
||||||
|
|
||||||
// ─── In-memory document store ────────────────────────────
|
|
||||||
|
|
||||||
interface SharedDoc {
|
|
||||||
doc: Y.Doc;
|
|
||||||
conns: Map<WebSocket, Set<number>>;
|
|
||||||
awareness: Map<number, { clock: number; state: unknown }>;
|
|
||||||
mux: mutex.mutex;
|
|
||||||
}
|
|
||||||
|
|
||||||
const docs = new Map<string, SharedDoc>();
|
|
||||||
|
|
||||||
function getDoc(name: string): SharedDoc {
|
|
||||||
let shared = docs.get(name);
|
|
||||||
if (shared) return shared;
|
|
||||||
|
|
||||||
const doc = new Y.Doc();
|
|
||||||
shared = {
|
|
||||||
doc,
|
|
||||||
conns: new Map(),
|
|
||||||
awareness: new Map(),
|
|
||||||
mux: mutex.createMutex(),
|
|
||||||
};
|
|
||||||
docs.set(name, shared);
|
|
||||||
return shared;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Yjs sync protocol (simplified) ─────────────────────
|
|
||||||
|
|
||||||
const MSG_SYNC = 0;
|
|
||||||
const MSG_AWARENESS = 1;
|
|
||||||
|
|
||||||
const SYNC_STEP1 = 0;
|
|
||||||
const SYNC_STEP2 = 1;
|
|
||||||
const SYNC_UPDATE = 2;
|
|
||||||
|
|
||||||
function sendToAll(shared: SharedDoc, message: Uint8Array, exclude?: WebSocket) {
|
|
||||||
shared.conns.forEach((_, conn) => {
|
|
||||||
if (conn !== exclude && conn.readyState === WebSocket.OPEN) {
|
|
||||||
conn.send(message);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleSyncMessage(
|
|
||||||
shared: SharedDoc,
|
|
||||||
conn: WebSocket,
|
|
||||||
buf: Uint8Array
|
|
||||||
) {
|
|
||||||
const decoder = decoding.createDecoder(buf);
|
|
||||||
const msgType = decoding.readVarUint(decoder);
|
|
||||||
|
|
||||||
if (msgType === MSG_SYNC) {
|
|
||||||
const syncType = decoding.readVarUint(decoder);
|
|
||||||
|
|
||||||
if (syncType === SYNC_STEP1) {
|
|
||||||
// Client sends state vector, server responds with diff
|
|
||||||
const sv = decoding.readVarUint8Array(decoder);
|
|
||||||
const encoder = encoding.createEncoder();
|
|
||||||
encoding.writeVarUint(encoder, MSG_SYNC);
|
|
||||||
encoding.writeVarUint(encoder, SYNC_STEP2);
|
|
||||||
encoding.writeVarUint8Array(
|
|
||||||
encoder,
|
|
||||||
Y.encodeStateAsUpdate(shared.doc, sv)
|
|
||||||
);
|
|
||||||
if (conn.readyState === WebSocket.OPEN) {
|
|
||||||
conn.send(encoding.toUint8Array(encoder));
|
|
||||||
}
|
|
||||||
} else if (syncType === SYNC_STEP2 || syncType === SYNC_UPDATE) {
|
|
||||||
const update = decoding.readVarUint8Array(decoder);
|
|
||||||
Y.applyUpdate(shared.doc, update);
|
|
||||||
|
|
||||||
// Broadcast update to all other clients
|
|
||||||
if (syncType === SYNC_UPDATE) {
|
|
||||||
const encoder = encoding.createEncoder();
|
|
||||||
encoding.writeVarUint(encoder, MSG_SYNC);
|
|
||||||
encoding.writeVarUint(encoder, SYNC_UPDATE);
|
|
||||||
encoding.writeVarUint8Array(encoder, update);
|
|
||||||
sendToAll(shared, encoding.toUint8Array(encoder), conn);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (msgType === MSG_AWARENESS) {
|
|
||||||
// Broadcast awareness (cursors, selections) to all peers
|
|
||||||
sendToAll(shared, buf, conn);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function sendSyncStep1(shared: SharedDoc, conn: WebSocket) {
|
|
||||||
const encoder = encoding.createEncoder();
|
|
||||||
encoding.writeVarUint(encoder, MSG_SYNC);
|
|
||||||
encoding.writeVarUint(encoder, SYNC_STEP1);
|
|
||||||
encoding.writeVarUint8Array(
|
|
||||||
encoder,
|
|
||||||
Y.encodeStateVector(shared.doc)
|
|
||||||
);
|
|
||||||
if (conn.readyState === WebSocket.OPEN) {
|
|
||||||
conn.send(encoding.toUint8Array(encoder));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── WebSocket server ────────────────────────────────────
|
|
||||||
|
|
||||||
const server = http.createServer((req, res) => {
|
const server = http.createServer((req, res) => {
|
||||||
if (req.url === "/health") {
|
if (req.url === "/health") {
|
||||||
res.writeHead(200, { "Content-Type": "application/json" });
|
res.writeHead(200, { "Content-Type": "application/json" });
|
||||||
res.end(JSON.stringify({ status: "ok", docs: docs.size }));
|
res.end(JSON.stringify({ status: "ok" }));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
res.writeHead(200);
|
res.writeHead(200);
|
||||||
|
|
@ -126,53 +24,11 @@ const server = http.createServer((req, res) => {
|
||||||
|
|
||||||
const wss = new WebSocketServer({ server });
|
const wss = new WebSocketServer({ server });
|
||||||
|
|
||||||
wss.on("connection", (conn, req) => {
|
wss.on("connection", (ws, req) => {
|
||||||
// Document name from URL path: /ws/<docId>
|
setupWSConnection(ws, req);
|
||||||
const url = new URL(req.url || "/", `http://${req.headers.host}`);
|
|
||||||
const docName = url.pathname.replace(/^\/ws\//, "").replace(/^\//, "");
|
|
||||||
|
|
||||||
if (!docName) {
|
|
||||||
conn.close(4000, "Missing document name");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const shared = getDoc(docName);
|
|
||||||
shared.conns.set(conn, new Set());
|
|
||||||
|
|
||||||
// Send initial sync step 1 to the new client
|
|
||||||
sendSyncStep1(shared, conn);
|
|
||||||
|
|
||||||
conn.on("message", (data: Buffer | ArrayBuffer | Buffer[]) => {
|
|
||||||
const buf = data instanceof ArrayBuffer
|
|
||||||
? new Uint8Array(data)
|
|
||||||
: new Uint8Array(data as Buffer);
|
|
||||||
|
|
||||||
shared.mux(() => {
|
|
||||||
handleSyncMessage(shared, conn, buf);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
conn.on("close", () => {
|
|
||||||
shared.conns.delete(conn);
|
|
||||||
|
|
||||||
// Clean up empty docs after a delay
|
|
||||||
if (shared.conns.size === 0) {
|
|
||||||
setTimeout(() => {
|
|
||||||
const current = docs.get(docName);
|
|
||||||
if (current && current.conns.size === 0) {
|
|
||||||
current.doc.destroy();
|
|
||||||
docs.delete(docName);
|
|
||||||
}
|
|
||||||
}, 30000);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
conn.on("error", () => {
|
|
||||||
shared.conns.delete(conn);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
server.listen(PORT, () => {
|
server.listen(PORT, () => {
|
||||||
console.log(`rNotes sync server listening on port ${PORT}`);
|
console.log(`rNotes sync server listening on port ${PORT}`);
|
||||||
console.log(`WebSocket endpoint: ws://0.0.0.0:${PORT}/ws/<docId>`);
|
console.log(`WebSocket endpoint: ws://0.0.0.0:${PORT}`);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,11 @@
|
||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"target": "ES2017",
|
"target": "ES2017",
|
||||||
"lib": ["dom", "dom.iterable", "esnext"],
|
"lib": [
|
||||||
|
"dom",
|
||||||
|
"dom.iterable",
|
||||||
|
"esnext"
|
||||||
|
],
|
||||||
"allowJs": true,
|
"allowJs": true,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"strict": true,
|
"strict": true,
|
||||||
|
|
@ -11,13 +15,27 @@
|
||||||
"moduleResolution": "bundler",
|
"moduleResolution": "bundler",
|
||||||
"resolveJsonModule": true,
|
"resolveJsonModule": true,
|
||||||
"isolatedModules": true,
|
"isolatedModules": true,
|
||||||
"jsx": "react-jsx",
|
"jsx": "preserve",
|
||||||
"incremental": true,
|
"incremental": true,
|
||||||
"plugins": [{ "name": "next" }],
|
"plugins": [
|
||||||
|
{
|
||||||
|
"name": "next"
|
||||||
|
}
|
||||||
|
],
|
||||||
"paths": {
|
"paths": {
|
||||||
"@/*": ["./src/*"]
|
"@/*": [
|
||||||
|
"./src/*"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
"include": [
|
||||||
"exclude": ["node_modules", "sync-server"]
|
"next-env.d.ts",
|
||||||
|
"**/*.ts",
|
||||||
|
"**/*.tsx",
|
||||||
|
".next/types/**/*.ts"
|
||||||
|
],
|
||||||
|
"exclude": [
|
||||||
|
"node_modules",
|
||||||
|
"sync-server"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue