Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
30 changes: 30 additions & 0 deletions src/app/components/DocEditor/DocEditor.scss
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ $swarm-text: #f8f8ff;
flex-direction: column;
height: 100%;
background: $swarm-dark;
position: relative;

&--loading {
padding: 24px;
Expand Down Expand Up @@ -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 {
Expand Down
112 changes: 96 additions & 16 deletions src/app/components/DocEditor/DocEditor.tsx
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -23,58 +21,117 @@ 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)
const deleteCount = oldValue.length - prefix - suffix
const insertText = newValue.slice(prefix, newValue.length - suffix)

if (deleteCount === 0 && insertText.length === 0) return

doc.transact(() => {
if (deleteCount > 0) yText.delete(prefix, deleteCount)

if (insertText.length > 0) yText.insert(prefix, insertText)
})
}

export const DocEditor: React.FC<DocEditorProps> = ({ doc, disabled = false }) => {
interface DocEditorProps {
doc: Y.Doc | null
disabled?: boolean
awareness?: Map<string, AwarenessState>
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<DocEditorProps> = ({ doc, disabled = false, awareness, onCursorChange }) => {
const yTextRef = useRef<Y.Text | null>(null)
const prevContentRef = useRef('')
const textareaRef = useRef<HTMLTextAreaElement>(null)
const [content, setContent] = useState('')
const [scrollTop, setScrollTop] = useState(0)
const [badges, setBadges] = useState<CursorBadge[]>([])

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 = () => {
const text = yText.toString()
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<HTMLTextAreaElement>) => {
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) {
Expand All @@ -84,13 +141,36 @@ export const DocEditor: React.FC<DocEditorProps> = ({ doc, disabled = false }) =
return (
<div className="doc-editor">
<textarea
ref={textareaRef}
className="doc-editor__textarea"
value={content}
onChange={handleChange}
onSelect={reportCursor}
onKeyUp={reportCursor}
onClick={reportCursor}
onScroll={e => setScrollTop((e.target as HTMLTextAreaElement).scrollTop)}
disabled={disabled}
placeholder="Start typing — changes sync across peers via Swarm…"
spellCheck={false}
/>
{badges.length > 0 && (
<div className="doc-editor__cursor-overlay" aria-hidden="true">
{badges.map(b => (
<React.Fragment key={b.address}>
<div
className="doc-editor__cursor-line"
style={{ top: b.top, left: b.left, height: b.lineHeight, background: b.color }}
/>
<div
className="doc-editor__cursor-badge"
style={{ top: Math.max(0, b.top - 20), left: b.left, background: b.color }}
>
{b.username || b.address.slice(0, 6)}
</div>
</React.Fragment>
))}
</div>
)}
</div>
)
}
20 changes: 9 additions & 11 deletions src/app/components/LoginView/LoginView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -290,7 +290,7 @@ export const LoginView: React.FC<LoginViewProps> = ({
</div>

<div className="login-view__field">
<label className="login-view__field-label">MUTABLE_STAMP</label>
<label className="login-view__field-label">Postage stamp</label>
<input
value={mutableStamp}
onChange={e => onMutableStampChange(e.target.value)}
Expand All @@ -306,16 +306,14 @@ export const LoginView: React.FC<LoginViewProps> = ({
)}
</div>

{transport !== Transport.SWARM_FEED_POLL && (
<label className="login-view__checkbox-label">
<input
type="checkbox"
checked={disableUntilConnected}
onChange={e => onDisableUntilConnectedChange(e.target.checked)}
/>
Disable editing until peer connected
</label>
)}
<label className="login-view__checkbox-label">
<input
type="checkbox"
checked={disableUntilConnected}
onChange={e => onDisableUntilConnectedChange(e.target.checked)}
/>
Disable editing until peer connected
</label>

{transport === Transport.SWARM_PUBSUB && (
<div className="login-view__field">
Expand Down
31 changes: 16 additions & 15 deletions src/app/components/SessionView/SessionView.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
import { PrivateKey } from '@ethersphere/bee-js'
import {
createBroadcastChannelTransport,
createSwarmFeedTransport,
createSwarmPubSubTransport,
createSwarmRtcTransport,
createWakuTransport,
Expand All @@ -21,6 +19,7 @@ import {
MUTABLE_STAMP_KEY,
TOPIC_KEY,
} from '../../utils/constants'
import { colorForAddress } from '../../utils/peers'
import { Session, Transport, TRANSPORT_LABELS } from '../../utils/types'
import { DocEditor } from '../DocEditor/DocEditor'

Expand Down Expand Up @@ -71,14 +70,6 @@ export const SessionView: React.FC<SessionViewProps> = ({

const docConfig: DocSettings = useMemo(() => {
const getTransport = () => {
if (session.transport === Transport.BROADCAST_CHANNEL) {
return createBroadcastChannelTransport()
}

if (session.transport === Transport.SWARM_FEED_POLL) {
return createSwarmFeedTransport(beeUrl, signer.toHex(), mutableStamp, topic)
}

if (session.transport === Transport.WAKU) {
let wakuAddress: string[] | undefined = undefined

Expand Down Expand Up @@ -131,7 +122,8 @@ export const SessionView: React.FC<SessionViewProps> = ({
mutableStamp,
])

const { doc, error, members, connected, refreshMemberList, dismissError } = useSwarmDoc(docConfig)
const { doc, error, members, connected, awareness, updateCursor, refreshMemberList, dismissError } =
useSwarmDoc(docConfig)

const transportLabel = TRANSPORT_LABELS[session.transport]

Expand All @@ -146,7 +138,12 @@ export const SessionView: React.FC<SessionViewProps> = ({
</button>
</div>
) : null}
<DocEditor doc={doc} disabled={disableUntilConnected && !connected} />
<DocEditor
doc={doc}
disabled={disableUntilConnected && !connected}
awareness={awareness}
onCursorChange={updateCursor}
/>
</div>
)
}
Expand All @@ -159,7 +156,11 @@ export const SessionView: React.FC<SessionViewProps> = ({
for (const [addr, username] of members) {
block.push(
<span key={addr} className="session-view__member-chip">
<span className="session-view__member-dot" aria-hidden="true" />
<span
className="session-view__member-dot"
aria-hidden="true"
style={{ background: colorForAddress(addr), boxShadow: `0 0 0 2px ${colorForAddress(addr)}33` }}
/>
<code className="session-view__member-code" title={addr}>
{username.length ? username : addr.slice(0, 8) + '…'}
</code>
Expand Down Expand Up @@ -244,15 +245,15 @@ export const SessionView: React.FC<SessionViewProps> = ({
onReset: () => setUrlDraft(DEFAULT_BEE_API_URL),
},
{
label: 'MUTABLE_STAMP',
label: 'Postage stamp',
value: mutableStampDraft,
onChange: setMutableStampDraft,
placeholder: PLACEHOLDER_STAMP,
mono: true,
onReset: () => setMutableStampDraft(PLACEHOLDER_STAMP),
},
{
label: 'TOPIC',
label: 'Topic',
value: topicDraft,
onChange: setTopicDraft,
placeholder: DEFAULT_TOPIC,
Expand Down
Loading
Loading