-
Notifications
You must be signed in to change notification settings - Fork 2
Live-presence spike: remote selections, mouse cursors, and editor carets #261
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
7c1aae9
4374601
d4d5afd
453f189
09af9d2
dcce806
aef73eb
75cc930
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,65 @@ | ||
| /** | ||
| * Publishes the LOCAL user's selection / focus / editor caret to the | ||
| * presence channel. Mounted once per panel via `panelMountsFacet` (so it | ||
| * gets the panel's UI-state `block`), but only the ACTIVE panel publishes — | ||
| * one client tracks one selection at a time, and the active panel is the one | ||
| * the user is actually driving. Renders nothing. | ||
| */ | ||
| import { useEffect } from 'react' | ||
| import type { Block } from '@/data/block.js' | ||
| import { useHandle } from '@/hooks/block.js' | ||
| import { useIsActivePanel } from '@/data/globalState.js' | ||
| import { | ||
| editorSelection, | ||
| focusedBlockLocationFromProperties, | ||
| isEditingProp, | ||
| selectionStateProp, | ||
| } from '@/data/properties.js' | ||
| import { presenceClient } from './presenceClient.js' | ||
| import type { LocalPresence } from './types.js' | ||
|
|
||
| const buildLocal = (properties: Record<string, unknown> | undefined): LocalPresence => { | ||
| const selRaw = properties?.[selectionStateProp.name] | ||
| const selection = selRaw === undefined | ||
| ? selectionStateProp.defaultValue | ||
| : selectionStateProp.codec.decode(selRaw) | ||
|
|
||
| const focusedBlockId = focusedBlockLocationFromProperties(properties)?.blockId ?? null | ||
|
|
||
| const editingRaw = properties?.[isEditingProp.name] | ||
| const editing = editingRaw === undefined | ||
| ? isEditingProp.defaultValue | ||
| : isEditingProp.codec.decode(editingRaw) | ||
|
|
||
| const edRaw = properties?.[editorSelection.name] | ||
| const ed = edRaw === undefined ? undefined : editorSelection.codec.decode(edRaw) | ||
| // Only surface the caret while the user is actually editing the focused | ||
| // block. Gating on edit mode (not just focus) clears the caret on | ||
| // Escape/blur — otherwise `editorSelection` lingers and peers see a ghost | ||
| // caret until focus moves to another block. | ||
| const editor = editing && ed && ed.blockId === focusedBlockId | ||
| ? { blockId: ed.blockId, start: ed.start ?? null, end: ed.end ?? ed.start ?? null } | ||
| : null | ||
|
|
||
| return { | ||
| selectedBlockIds: selection.selectedBlockIds, | ||
| anchorBlockId: selection.anchorBlockId, | ||
| focusedBlockId, | ||
| editor, | ||
| } | ||
| } | ||
|
|
||
| export function PresencePublisher({ block }: { block: Block }) { | ||
| const active = useIsActivePanel(block) | ||
| // `useHandle` structurally dedups the selected value, so `local`'s identity | ||
| // is stable until the published state actually changes — the effect below | ||
| // doesn't re-fire on unrelated UI-state churn. | ||
| const local = useHandle(block, { selector: doc => buildLocal(doc?.properties) }) | ||
|
|
||
| useEffect(() => { | ||
| if (!active) return | ||
| presenceClient.updateLocal(local) | ||
| }, [active, local]) | ||
|
|
||
| return null | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,117 @@ | ||
| /** | ||
| * Fixed, click-through layer that paints remote peers' mouse cursors. | ||
| * Mounted once at app root via `appMountsFacet`. | ||
| * | ||
| * Each cursor is anchored to a block id + fractional offset (see | ||
| * `presenceClient.handlePointerMove`), so we resolve it against the live DOM | ||
| * every paint: this makes the cursor land in the same logical spot across | ||
| * different scroll positions / window widths, and naturally hides cursors | ||
| * whose block is scrolled out of view. We re-paint on the cursor stream and | ||
| * on scroll/resize (rAF-coalesced). | ||
| */ | ||
| import { useEffect, useReducer } from 'react' | ||
| import { createPortal } from 'react-dom' | ||
| import { useRemoteCursors } from './hooks.js' | ||
| import type { RemoteCursor } from './types.js' | ||
|
|
||
| const resolveAnchor = (cursor: RemoteCursor): HTMLElement | null => { | ||
| if (!cursor.blockId) return null | ||
| // Match the publish side: only block shells (`.tm-block`), not the | ||
| // block-refs / property rows that also carry `data-block-id`. | ||
| const matches = document.querySelectorAll<HTMLElement>( | ||
| `.tm-block[data-block-id="${CSS.escape(cursor.blockId)}"]`, | ||
| ) | ||
| if (matches.length === 0) return null | ||
| if (cursor.renderScopeId) { | ||
| for (const el of matches) { | ||
| if (el.dataset.renderScopeId === cursor.renderScopeId) return el | ||
| } | ||
| // Scoped cursor but this client has no copy in that render scope (the | ||
| // peer is in an embed / backlink / other pane we don't show). Don't fall | ||
| // back to an unrelated copy of the same block — hide it. | ||
| return null | ||
| } | ||
| return matches[0] | ||
| } | ||
|
|
||
| function RemoteCursorView({ cursor }: { cursor: RemoteCursor }) { | ||
| const anchor = resolveAnchor(cursor) | ||
| if (!anchor) return null | ||
| const rect = anchor.getBoundingClientRect() | ||
| const x = rect.left + cursor.nx * rect.width | ||
| const y = rect.top + cursor.ny * rect.height | ||
| if (x < 0 || y < 0 || x > window.innerWidth || y > window.innerHeight) return null | ||
|
|
||
| return ( | ||
| <div | ||
| style={{ | ||
| position: 'absolute', | ||
| left: 0, | ||
| top: 0, | ||
| transform: `translate(${x}px, ${y}px)`, | ||
| willChange: 'transform', | ||
| }} | ||
| > | ||
| <svg width="16" height="22" viewBox="0 0 16 22" fill="none" style={{ display: 'block' }}> | ||
| <path | ||
| d="M1 1L1 16.5L5 12.5L7.5 18.5L10 17.5L7.5 11.5L13 11.5L1 1Z" | ||
| fill={cursor.color} | ||
| stroke="white" | ||
| strokeWidth="1" | ||
| strokeLinejoin="round" | ||
| /> | ||
| </svg> | ||
| <span | ||
| style={{ | ||
| position: 'absolute', | ||
| left: 12, | ||
| top: 12, | ||
| padding: '1px 6px', | ||
| borderRadius: 6, | ||
| background: cursor.color, | ||
| color: 'white', | ||
| fontSize: 11, | ||
| lineHeight: '16px', | ||
| fontWeight: 500, | ||
| whiteSpace: 'nowrap', | ||
| boxShadow: '0 1px 2px rgba(0,0,0,0.25)', | ||
| }} | ||
| > | ||
| {cursor.name} | ||
| </span> | ||
| </div> | ||
| ) | ||
| } | ||
|
|
||
| export function RemoteCursorsOverlay() { | ||
| const cursors = useRemoteCursors() | ||
| const [, repaint] = useReducer((n: number) => n + 1, 0) | ||
|
|
||
| // Cursor coordinates are derived from live DOM rects, so they drift on any | ||
| // scroll/resize even when no new cursor packet arrives — repaint then too. | ||
| useEffect(() => { | ||
| let raf = 0 | ||
| const schedule = () => { | ||
| cancelAnimationFrame(raf) | ||
| raf = requestAnimationFrame(repaint) | ||
| } | ||
| window.addEventListener('scroll', schedule, true) | ||
| window.addEventListener('resize', schedule) | ||
| return () => { | ||
| cancelAnimationFrame(raf) | ||
| window.removeEventListener('scroll', schedule, true) | ||
| window.removeEventListener('resize', schedule) | ||
| } | ||
| }, []) | ||
|
|
||
| if (typeof document === 'undefined' || cursors.length === 0) return null | ||
|
|
||
| return createPortal( | ||
| <div style={{ position: 'fixed', inset: 0, pointerEvents: 'none', zIndex: 60 }}> | ||
| {cursors.map(cursor => ( | ||
| <RemoteCursorView key={cursor.clientId} cursor={cursor} /> | ||
| ))} | ||
| </div>, | ||
| document.body, | ||
| ) | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,48 @@ | ||
| /** | ||
| * Paints a coloured ring around any block a remote peer has selected / | ||
| * focused / is editing. Contributed to `blockShellDecoratorsFacet`, so it | ||
| * composes with the local focus highlight rather than replacing it. | ||
| * | ||
| * The ring colour is per-peer and dynamic, which Tailwind classes can't | ||
| * express, so we set `box-shadow` directly on the shell node via `shellRef`. | ||
| * Stacked `inset` rings (2px, 4px, …) show multiple peers on one block. This | ||
| * is a property React never sets on the shell, so the write survives | ||
| * re-renders; the effect clears it on change/unmount. | ||
| */ | ||
| import { useLayoutEffect } from 'react' | ||
| import type { | ||
| BlockShellDecoratorContribution, | ||
| BlockShellDecoratorProps, | ||
| } from '@/extensions/blockInteraction.js' | ||
| import { useRemoteSelectionColorKey } from './hooks.js' | ||
|
|
||
| export function RemoteSelectionShellDecorator({ | ||
| resolveContext, | ||
| shellRef, | ||
| state, | ||
| children, | ||
| }: BlockShellDecoratorProps) { | ||
| const colorKey = useRemoteSelectionColorKey(resolveContext.block.id) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When a peer focuses or edits a block that is also visible in another render scope (split pane, embed, or backlink), the app's own focus state is scoped by Useful? React with 👍 / 👎. |
||
|
|
||
| useLayoutEffect(() => { | ||
| const el = shellRef.current | ||
| if (!el) return | ||
| if (!colorKey) { | ||
| el.style.boxShadow = '' | ||
| return | ||
| } | ||
| el.style.boxShadow = colorKey | ||
| .split(',') | ||
| .map((color, i) => `inset 0 0 0 ${2 * (i + 1)}px ${color}`) | ||
| .join(', ') | ||
| return () => { | ||
| const node = shellRef.current | ||
| if (node) node.style.boxShadow = '' | ||
| } | ||
| }, [colorKey, shellRef]) | ||
|
|
||
| return <>{children(state)}</> | ||
| } | ||
|
|
||
| export const remoteSelectionShellDecorator: BlockShellDecoratorContribution = () => | ||
| RemoteSelectionShellDecorator | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,22 @@ | ||
| /** | ||
| * Deterministic per-user colour. Same `userId` → same hue on every client, | ||
| * so a peer's selection ring / cursor / caret are all the same colour | ||
| * without any colour-assignment coordination over the wire. | ||
| */ | ||
|
|
||
| const hashString = (value: string): number => { | ||
| let hash = 0 | ||
| for (let i = 0; i < value.length; i++) { | ||
| hash = (hash << 5) - hash + value.charCodeAt(i) | ||
| hash |= 0 | ||
| } | ||
| return Math.abs(hash) | ||
| } | ||
|
|
||
| /** A saturated, mid-lightness HSL colour keyed off the user id. The fixed | ||
| * saturation/lightness keep every assigned colour legible against both the | ||
| * light and dark block backgrounds. */ | ||
| export const colorForUser = (userId: string): string => { | ||
| const hue = hashString(userId) % 360 | ||
| return `hsl(${hue} 70% 50%)` | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,23 @@ | ||
| /** React bindings over the presence store. */ | ||
| import { useSyncExternalStore } from 'react' | ||
| import { presenceClient } from './presenceClient.js' | ||
| import type { RemoteCursor } from './types.js' | ||
|
|
||
| const EMPTY_CURSORS: readonly RemoteCursor[] = [] | ||
|
|
||
| /** The `,`-joined peer colours occupying `blockId` (or `''`). Primitive, so | ||
| * only blocks whose occupancy changed re-render. */ | ||
| export const useRemoteSelectionColorKey = (blockId: string): string => | ||
| useSyncExternalStore( | ||
| presenceClient.subscribePresence, | ||
| () => presenceClient.selectionColorKey(blockId), | ||
| () => '', | ||
| ) | ||
|
|
||
| /** All remote mouse cursors (already excludes self). */ | ||
| export const useRemoteCursors = (): readonly RemoteCursor[] => | ||
| useSyncExternalStore( | ||
| presenceClient.subscribeCursors, | ||
| presenceClient.getCursors, | ||
| () => EMPTY_CURSORS, | ||
| ) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,56 @@ | ||
| /** | ||
| * Live-presence spike — shows other people in a shared workspace in real | ||
| * time: the blocks they have selected/focused (coloured rings), their mouse | ||
| * cursor, and their editor caret. Runs over Supabase Realtime (Presence for | ||
| * identity + selection + caret, Broadcast for the cursor) — entirely beside | ||
| * the kernel/PowerSync document path, so none of it is ever persisted. | ||
| * | ||
| * Seams used: | ||
| * - appEffectsFacet → owns the Realtime channel + cursor broadcast | ||
| * - panelMountsFacet → publishes the local user's selection/caret | ||
| * - blockShellDecoratorsFacet → remote-selection rings | ||
| * - appMountsFacet → the mouse-cursor overlay | ||
| * - codeMirrorExtensionsFacet → remote editor carets | ||
| * | ||
| * SPIKE NOTE: defaults OFF — opt in per workspace via Extensions settings. | ||
| * The channel is still PUBLIC, so enabling it globally must wait on Realtime | ||
| * Authorization (private channel + RLS on `realtime.messages`); until then an | ||
| * on-by-default public channel would let anyone with a workspace URL (ids | ||
| * appear in URL hashes) observe or forge presence. See the security/privacy | ||
| * caveats in `presenceClient.ts` (public channel → private + RLS; e2ee | ||
| * metadata leak). | ||
| */ | ||
| import { appEffectsFacet, appMountsFacet, panelMountsFacet } from '@/extensions/core.js' | ||
| import { blockShellDecoratorsFacet } from '@/extensions/blockInteraction.js' | ||
| import { codeMirrorExtensionsFacet } from '@/editor/codeMirrorExtensions.js' | ||
| import type { AppExtension } from '@/facets/facet.js' | ||
| import { systemToggle } from '@/facets/togglable.js' | ||
| import { presenceAppEffect } from './presenceEffect.js' | ||
| import { PresencePublisher } from './PresencePublisher.js' | ||
| import { RemoteCursorsOverlay } from './RemoteCursorsOverlay.js' | ||
| import { remoteSelectionShellDecorator } from './RemoteSelectionShellDecorator.js' | ||
| import { remoteCaretsCodeMirrorExtensions } from './remoteCaretsExtension.js' | ||
|
|
||
| const SOURCE = 'presence' | ||
|
|
||
| export const presencePlugin: AppExtension = systemToggle({ | ||
| id: 'system:presence', | ||
| name: 'Live presence', | ||
| description: | ||
| "Shows other people in the workspace — their selected blocks, mouse cursors, and editor carets — over Supabase Realtime.", | ||
| // Off by default: the channel is public for now, so this stays opt-in until | ||
| // it's private + RLS-gated (see the module header). | ||
| defaultEnabled: false, | ||
| }).of([ | ||
| appEffectsFacet.of(presenceAppEffect, { source: SOURCE }), | ||
| panelMountsFacet.of( | ||
| { id: 'presence.publisher', component: PresencePublisher }, | ||
| { source: SOURCE }, | ||
| ), | ||
| blockShellDecoratorsFacet.of(remoteSelectionShellDecorator, { source: SOURCE }), | ||
| appMountsFacet.of( | ||
| { id: 'presence.cursors', component: RemoteCursorsOverlay }, | ||
| { source: SOURCE }, | ||
| ), | ||
| codeMirrorExtensionsFacet.of(remoteCaretsCodeMirrorExtensions, { source: SOURCE }), | ||
| ]) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Even after shell scoping, a cursor that carries a
renderScopeIdfalls back to the first rendered copy of that block when this client does not have the peer's scope. In split panes, embeds, or backlink surfaces the same block can exist in multiple render scopes, so a peer moving in an embed can appear over an unrelated outline copy on another client; returnnullwhen a scoped cursor has no matching scope, and only use the fallback for unscoped payloads.Useful? React with 👍 / 👎.