diff --git a/public/editor/collab.ts b/public/editor/collab.ts index c535058..b55f9fa 100644 --- a/public/editor/collab.ts +++ b/public/editor/collab.ts @@ -43,6 +43,17 @@ interface CollabUser { displayName: string; } +/** One connected editor, surfaced for the presence avatar cluster. */ +export interface PresenceUser { + did: string; + handle: string; + name: string; + /** Cursor color assigned to this editor — reused for the avatar chip. */ + color: string; + /** True for the local editor. */ + self: boolean; +} + export interface CollabHandle { extension: Extension; /** Current merged document text (source of truth for the save). */ @@ -63,6 +74,8 @@ interface ConnectOptions { onReload: () => void; /** Fired once, when the shared doc has first synced from the server. */ onSynced?: () => void; + /** Fired whenever the set of connected editors changes (for the avatar cluster). */ + onPresence?: (users: PresenceUser[]) => void; onStatus?: (status: "connecting" | "connected" | "closed") => void; } @@ -115,6 +128,40 @@ export function connectCollab(opts: ConnectOptions): CollabHandle { }; awareness.on("update", onAwarenessUpdate); + // Snapshot the connected editors (self + peers) for the presence cluster. + const emitPresence = (): void => { + if (!opts.onPresence) return; + const users: PresenceUser[] = []; + for (const [clientId, state] of awareness.getStates()) { + const user = ( + state as { + user?: { + name?: string; + handle?: string; + did?: string; + color?: string; + }; + } + ).user; + if (!user?.handle) continue; + users.push({ + did: user.did ?? "", + handle: user.handle, + name: user.name || user.handle, + color: user.color ?? "var(--accent)", + self: clientId === ydoc.clientID, + }); + } + // Stable order: self first, then alphabetical by handle. + users.sort((a, b) => + a.self === b.self ? a.handle.localeCompare(b.handle) : a.self ? -1 : 1, + ); + opts.onPresence(users); + }; + // "change" fires on add/update/remove of any peer's state, regardless of origin. + awareness.on("change", emitPresence); + emitPresence(); // render the local editor immediately + function handleFrame(data: Uint8Array): void { const { type, decoder } = readFrameType(data); if (type === MSG_SYNC) { @@ -181,6 +228,7 @@ export function connectCollab(opts: ConnectOptions): CollabHandle { if (reconnectTimer) clearTimeout(reconnectTimer); ydoc.off("update", onDocUpdate); awareness.off("update", onAwarenessUpdate); + awareness.off("change", emitPresence); try { ws?.close(); } catch {} diff --git a/public/editor/editor.ts b/public/editor/editor.ts index aa86968..b117306 100644 --- a/public/editor/editor.ts +++ b/public/editor/editor.ts @@ -16,7 +16,11 @@ import { } from "@codemirror/view"; import { yUndoManagerKeymap } from "y-codemirror.next"; -import { type CollabHandle, connectCollab } from "./collab.ts"; +import { + type CollabHandle, + connectCollab, + type PresenceUser, +} from "./collab.ts"; import { renderPreview } from "./preview.ts"; import { createToolbar } from "./toolbar.ts"; import { showToast, syncBlobMetadata, uploadImage } from "./upload.ts"; @@ -29,6 +33,14 @@ let activeCollab: CollabHandle | null = null; // editor (e.g. when WebSockets are blocked). Keeps "no editor at all" off the table. const COLLAB_CONNECT_TIMEOUT_MS = 5000; +/** Up-to-two-letter initials for an avatar chip, from a display name or handle. */ +function initials(name: string): string { + const parts = name.trim().split(/\s+/).filter(Boolean); + if (parts.length === 0) return "?"; + if (parts.length === 1) return (parts[0] ?? "").slice(0, 2); + return (parts[0]?.[0] ?? "") + (parts[parts.length - 1]?.[0] ?? ""); +} + /** * Save flow for live-collaboration mode. Reuses the normal edit route — the * shared Y.Doc text is posted through the same `POST .../edit` endpoint as the @@ -166,6 +178,32 @@ function initEditor(root: Document | Element = document): void { wrapper.appendChild(preview); textarea.parentElement?.appendChild(wrapper); + // Responsive split: side by side on desktop, but on phones the form is no longer + // pinned to the viewport height (see edit-note.ts), so stack the preview *under* + // the editor and give each a usable height while the page scrolls naturally. + const mobileQuery = window.matchMedia("(max-width: 767px)"); + function applyResponsiveLayout(): void { + if (mobileQuery.matches) { + wrapper.style.flexDirection = "column"; + wrapper.style.height = "auto"; + editorPane.style.flex = "0 0 auto"; + editorPane.style.height = "60vh"; + preview.style.flex = "0 0 auto"; + preview.style.height = "auto"; + preview.style.overflow = "visible"; + } else { + wrapper.style.flexDirection = "row"; + wrapper.style.height = "100%"; + editorPane.style.flex = "1 1 0%"; + editorPane.style.height = ""; + preview.style.flex = "1 1 0%"; + preview.style.height = ""; + preview.style.overflow = "auto"; + } + } + applyResponsiveLayout(); + mobileQuery.addEventListener("change", applyResponsiveLayout); + // --- CRITICAL: Force scrollable editor with CSS --- const styleId = "lichen-editor-scroll-fix"; if (!document.getElementById(styleId)) { @@ -217,6 +255,36 @@ function initEditor(root: Document | Element = document): void { const userHandle = textarea.dataset["handle"]; const displayName = textarea.dataset["displayName"] ?? ""; const noteHref = textarea.dataset["noteUrl"] ?? ""; + const youLabel = textarea.dataset["collabYou"] ?? "you"; + + // Paint one avatar chip per connected editor into the avatar strip beside the + // title. Chips reuse each editor's cursor color so presence and cursors match. + function renderPresence(users: PresenceUser[]): void { + const container = root.querySelector("[data-collab-avatars]"); + if (!container) return; + container.replaceChildren(); + users.forEach((u, i) => { + const chip = document.createElement("span"); + chip.style.display = "inline-flex"; + chip.style.alignItems = "center"; + chip.style.justifyContent = "center"; + chip.style.width = "1.75rem"; + chip.style.height = "1.75rem"; + chip.style.marginLeft = i === 0 ? "0" : "-0.4rem"; + chip.style.borderRadius = "9999px"; + chip.style.backgroundColor = u.color; + chip.style.color = "#fff"; + chip.style.fontSize = "0.7rem"; + chip.style.fontWeight = "600"; + chip.style.lineHeight = "1"; + chip.style.textTransform = "uppercase"; + chip.style.userSelect = "none"; + chip.style.border = "2px solid var(--bg)"; + chip.title = u.self ? `${u.name} (${youLabel})` : `@${u.handle}`; + chip.textContent = initials(u.name); + container.appendChild(chip); + }); + } // Builds the CodeMirror view and wires the toolbar, uploads and save flow. // Called once: either bound to a synced collab doc, or in plain mode with the @@ -230,7 +298,7 @@ function initEditor(root: Document | Element = document): void { // Plain editor: there is no live session, so drop the collab-only chrome. if (!collabHandle) { - root.querySelector("[data-collab-banner]")?.remove(); + root.querySelector("[data-collab-presence]")?.remove(); root.querySelector("[data-collab-discard]")?.remove(); } @@ -407,6 +475,7 @@ function initEditor(root: Document | Element = document): void { if (noteHref) location.assign(noteHref); }, onSynced: () => finish(true), + onPresence: renderPresence, }); fallbackTimer = setTimeout(() => finish(false), COLLAB_CONNECT_TIMEOUT_MS); diff --git a/src/lib/i18n/en.ts b/src/lib/i18n/en.ts index 5820dab..cf0f936 100644 --- a/src/lib/i18n/en.ts +++ b/src/lib/i18n/en.ts @@ -96,6 +96,8 @@ export const en: Messages = { importFileBrowse: "Browse", collabConnecting: "Connecting…", collabActive: "Live collaboration", + collabHint: "People currently editing this page", + collabYou: "you", discardDraft: "Discard draft", confirmDiscardDraft: "Discard the shared draft? Everyone's unsaved changes will be lost.", diff --git a/src/lib/i18n/fr.ts b/src/lib/i18n/fr.ts index 1bb06ec..3f58d59 100644 --- a/src/lib/i18n/fr.ts +++ b/src/lib/i18n/fr.ts @@ -98,6 +98,8 @@ export const fr: PartialMessages = { importFileBrowse: "Parcourir", collabConnecting: "Connexion…", collabActive: "Collaboration en direct", + collabHint: "Personnes en train de modifier cette page", + collabYou: "vous", discardDraft: "Abandonner le brouillon", confirmDiscardDraft: "Abandonner le brouillon partagé ? Les modifications non enregistrées de chacun seront perdues.", diff --git a/src/lib/i18n/index.ts b/src/lib/i18n/index.ts index 6374b4a..fb15332 100644 --- a/src/lib/i18n/index.ts +++ b/src/lib/i18n/index.ts @@ -96,6 +96,8 @@ export interface Messages { importFileBrowse: string; collabConnecting: string; collabActive: string; + collabHint: string; + collabYou: string; discardDraft: string; confirmDiscardDraft: string; }; diff --git a/src/views/edit-note.ts b/src/views/edit-note.ts index 4a323cf..4a6c810 100644 --- a/src/views/edit-note.ts +++ b/src/views/edit-note.ts @@ -33,17 +33,28 @@ export function editNotePage( data-collab="1" data-collab-ws="${escapeHtml(collab.wsPath)}" data-collab-connecting="${escapeHtml(msg.editor.collabConnecting)}" + data-collab-you="${escapeHtml(msg.editor.collabYou)}" data-did="${escapeHtml(collab.did)}" data-handle="${escapeHtml(collab.handle)}" data-display-name="${escapeHtml(collab.displayName)}" data-note-url="${escapeHtml(collab.noteUrl)}"` : ""; - const collabBanner = collab?.enabled - ? `
- - ${msg.editor.collabActive} -
` + // Presence cluster: occupies the right half of the title row so it sits above + // the preview pane, mirroring the editor|preview split below. A "live" dot + + // label explains what the avatars are, then the avatar strip (filled by the + // client, one chip per connected editor, self included). Empty until the socket + // syncs; the whole cluster is removed if collaboration falls back to plain mode. + const collabPresence = collab?.enabled + ? `
+ + ${escapeHtml(msg.editor.collabActive)} +
+
` : ""; const discardButton = collab?.enabled @@ -55,20 +66,25 @@ export function editNotePage( >${msg.editor.discardDraft}` : ""; - // CSS fix: ensures the edit form has a fixed height on all viewport widths - // so that the CodeMirror editor can constrain its scroller and show a scrollbar. + // CSS fix: on desktop (md+) the edit form is pinned to the viewport height so the + // editor/preview sit side by side and the CodeMirror scroller shows a scrollbar. + // Below md the form flows naturally and the editor stacks above the preview + // (handled in editor.ts), so the constraints are scoped to md+. const scrollFixStyle = `