From 7722301ab42ee18d520cbabfe743be384ebfa046 Mon Sep 17 00:00:00 2001 From: Balint Ujvari Date: Thu, 7 May 2026 14:09:06 +0200 Subject: [PATCH 1/2] feat: display member cursors refactor: logging chore: removed unnecessary transprots --- README.md | 2 +- src/app/components/DocEditor/DocEditor.scss | 30 ++ src/app/components/DocEditor/DocEditor.tsx | 154 ++++++++++- src/app/components/LoginView/LoginView.tsx | 20 +- .../components/SessionView/SessionView.tsx | 31 ++- src/app/hooks/useSwarmDoc.tsx | 19 +- src/app/main.tsx | 6 +- src/app/pages/TestPage.tsx | 20 +- src/app/utils/peerColor.ts | 7 + src/app/utils/types.ts | 4 - src/lib/doc/doc.ts | 113 ++++++-- src/lib/doc/events.ts | 8 + src/lib/doc/members.ts | 10 +- src/lib/index.ts | 7 +- src/lib/interfaces/notification.ts | 54 +++- src/lib/notification/broadcastChannel.ts | 69 ----- src/lib/notification/swarmFeed.ts | 261 ------------------ src/lib/notification/swarmPubSubTransport.ts | 6 +- src/lib/notification/swarmRtcTransport.ts | 104 ++++--- src/lib/notification/swarmSignal.ts | 17 +- src/lib/notification/wakuTransport.ts | 18 +- src/lib/notification/yWebrtcTransport.ts | 6 +- src/lib/utils/constants.ts | 2 - 23 files changed, 468 insertions(+), 500 deletions(-) create mode 100644 src/app/utils/peerColor.ts delete mode 100644 src/lib/notification/broadcastChannel.ts delete mode 100644 src/lib/notification/swarmFeed.ts diff --git a/README.md b/README.md index 14e4f4a..a420e57 100644 --- a/README.md +++ b/README.md @@ -306,7 +306,7 @@ Enter a username, then configure: - _WebRTC_ — y-webrtc via a signaling server or Swarm-based SDP signaling (see Advanced Settings) - **Advanced Settings** (collapsible): - Bee API URL - - `MUTABLE_STAMP` postage batch ID + - Postage batch ID - Disable editing until a peer is connected (WebRTC / Waku only) - Broker Peer multiaddress (Swarm PubSub only) - Signaling Server URL or Swarm Signaling STUN URL (WebRTC only) diff --git a/src/app/components/DocEditor/DocEditor.scss b/src/app/components/DocEditor/DocEditor.scss index 472c2b4..287dd59 100644 --- a/src/app/components/DocEditor/DocEditor.scss +++ b/src/app/components/DocEditor/DocEditor.scss @@ -10,6 +10,7 @@ $swarm-text: #f8f8ff; flex-direction: column; height: 100%; background: $swarm-dark; + position: relative; &--loading { padding: 24px; @@ -68,6 +69,35 @@ $swarm-text: #f8f8ff; background: rgba(247, 104, 8, 0.25); } } + &__cursor-overlay { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + pointer-events: none; + overflow: hidden; + } + + &__cursor-line { + position: absolute; + width: 2px; + border-radius: 1px; + opacity: 0.85; + } + + &__cursor-badge { + position: absolute; + font-family: 'Inter', system-ui, sans-serif; + font-size: 10px; + font-weight: 600; + color: #fff; + padding: 1px 5px; + border-radius: 3px; + white-space: nowrap; + opacity: 0.92; + line-height: 16px; + } } @keyframes doc-editor-pulse { diff --git a/src/app/components/DocEditor/DocEditor.tsx b/src/app/components/DocEditor/DocEditor.tsx index 0b8e255..ce46738 100644 --- a/src/app/components/DocEditor/DocEditor.tsx +++ b/src/app/components/DocEditor/DocEditor.tsx @@ -1,11 +1,63 @@ -import React, { useEffect, useRef, useState } from 'react' +import React, { useEffect, useLayoutEffect, useRef, useState } from 'react' import * as Y from 'yjs' +import { AwarenessState } from '../../hooks/useSwarmDoc' +import { colorForAddress } from '../../utils/peerColor' + import './DocEditor.scss' -interface DocEditorProps { - doc: Y.Doc | null - disabled?: boolean +// Mirror-div technique: measure pixel coordinates of a character offset in a textarea. +// Returns coordinates relative to the textarea's top-left padding origin. +function getCaretXY(el: HTMLTextAreaElement, position: number): { top: number; left: number } { + const style = window.getComputedStyle(el) + const div = document.createElement('div') + + const props = [ + 'boxSizing', + 'width', + 'paddingTop', + 'paddingRight', + 'paddingBottom', + 'paddingLeft', + 'borderTopWidth', + 'borderRightWidth', + 'borderBottomWidth', + 'borderLeftWidth', + 'fontFamily', + 'fontSize', + 'fontWeight', + 'fontStyle', + 'fontVariant', + 'lineHeight', + 'letterSpacing', + 'wordSpacing', + 'textTransform', + 'textIndent', + 'whiteSpace', + 'wordBreak', + 'wordWrap', + 'tabSize', + ] as const + + div.style.position = 'absolute' + div.style.visibility = 'hidden' + div.style.top = '-9999px' + div.style.left = '-9999px' + div.style.overflow = 'hidden' + props.forEach(p => { + div.style[p as never] = style[p as never] + }) + + const text = el.value.slice(0, position) + div.textContent = text || ' ' + const span = document.createElement('span') + span.textContent = el.value[position] ?? ' ' + div.appendChild(span) + document.body.appendChild(div) + const coords = { top: span.offsetTop, left: span.offsetLeft } + document.body.removeChild(div) + + return coords } function commonPrefixLen(a: string, b: string): number { @@ -23,8 +75,6 @@ function commonSuffixLen(a: string, b: string, prefixLen: number): number { return i } -// Apply only the diff between oldValue and newValue to Y.Text. -// This preserves other users' items that are outside the changed range. function applyDiff(yText: Y.Text, doc: Y.Doc, oldValue: string, newValue: string): void { const prefix = commonPrefixLen(oldValue, newValue) const suffix = commonSuffixLen(oldValue, newValue, prefix) @@ -32,7 +82,6 @@ function applyDiff(yText: Y.Text, doc: Y.Doc, oldValue: string, newValue: string const insertText = newValue.slice(prefix, newValue.length - suffix) if (deleteCount === 0 && insertText.length === 0) return - doc.transact(() => { if (deleteCount > 0) yText.delete(prefix, deleteCount) @@ -40,41 +89,93 @@ function applyDiff(yText: Y.Text, doc: Y.Doc, oldValue: string, newValue: string }) } -export const DocEditor: React.FC = ({ doc, disabled = false }) => { +interface DocEditorProps { + doc: Y.Doc | null + disabled?: boolean + awareness?: Map + onCursorChange?: (cursor: { anchor: number; head: number } | null) => void +} + +interface CursorBadge { + address: string + username: string + color: string + top: number + left: number + lineHeight: number +} + +export const DocEditor: React.FC = ({ doc, disabled = false, awareness, onCursorChange }) => { const yTextRef = useRef(null) const prevContentRef = useRef('') + const textareaRef = useRef(null) const [content, setContent] = useState('') + const [scrollTop, setScrollTop] = useState(0) + const [badges, setBadges] = useState([]) useEffect(() => { if (!doc) return - const yText = doc.getText('content') yTextRef.current = yText - const initial = yText.toString() prevContentRef.current = initial setContent(initial) - const observer = () => { const text = yText.toString() prevContentRef.current = text setContent(text) } - yText.observe(observer) return () => yText.unobserve(observer) }, [doc]) + // Recompute badge positions whenever awareness or content changes + useLayoutEffect(() => { + const el = textareaRef.current + + if (!el || !awareness || awareness.size === 0) { + setBadges([]) + + return + } + + const style = window.getComputedStyle(el) + const lineHeight = parseFloat(style.lineHeight) + const next: CursorBadge[] = [] + + for (const [address, state] of awareness) { + if (state.cursor) { + const pos = Math.max(0, Math.min(state.cursor.anchor, el.value.length)) + const { top, left } = getCaretXY(el, pos) + next.push({ + address, + username: state.username, + color: colorForAddress(address), + top: top - scrollTop, + left, + lineHeight, + }) + } + } + + setBadges(next) + }, [awareness, content, scrollTop]) + + const reportCursor = () => { + const el = textareaRef.current + + if (!el || !onCursorChange) return + onCursorChange({ anchor: el.selectionStart, head: el.selectionEnd }) + } + const handleChange = (e: React.ChangeEvent) => { if (!yTextRef.current || !doc) return - const newValue = e.target.value const oldValue = prevContentRef.current - // Update prevContent immediately so rapid keystrokes diff against the right base prevContentRef.current = newValue - applyDiff(yTextRef.current, doc, oldValue, newValue) + reportCursor() } if (!doc) { @@ -84,13 +185,36 @@ export const DocEditor: React.FC = ({ doc, disabled = false }) = return (