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..9755f94 100644 --- a/src/app/components/DocEditor/DocEditor.tsx +++ b/src/app/components/DocEditor/DocEditor.tsx @@ -1,12 +1,10 @@ -import React, { useEffect, useRef, useState } from 'react' +import React, { useEffect, useLayoutEffect, useRef, useState } from 'react' import * as Y from 'yjs' -import './DocEditor.scss' +import { AwarenessState } from '../../hooks/useSwarmDoc' +import { colorForAddress, getCaretXY } from '../../utils/peers' -interface DocEditorProps { - doc: Y.Doc | null - disabled?: boolean -} +import './DocEditor.scss' function commonPrefixLen(a: string, b: string): number { let i = 0 @@ -23,8 +21,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 +28,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,19 +35,40 @@ 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 + if (!doc) { + return + } const yText = doc.getText('content') yTextRef.current = yText - const initial = yText.toString() prevContentRef.current = initial + setContent(initial) const observer = () => { @@ -60,21 +76,62 @@ export const DocEditor: React.FC = ({ doc, disabled = false }) = prevContentRef.current = text setContent(text) } - yText.observe(observer) return () => yText.unobserve(observer) }, [doc]) + 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 + 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 +141,36 @@ export const DocEditor: React.FC = ({ doc, disabled = false }) = return (