Dev #7
No files matched your search
@@ -196,10 +196,12 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
|||||||
switch (evt.type) {
|
switch (evt.type) {
|
||||||
case 'doc_state': {
|
case 'doc_state': {
|
||||||
if (evt.title !== undefined) {
|
if (evt.title !== undefined) {
|
||||||
const isTitleFocused =
|
const isTitleActivelyEditing =
|
||||||
titleInputRef.current !== null &&
|
titleInputRef.current !== null &&
|
||||||
document.activeElement === titleInputRef.current;
|
document.activeElement === titleInputRef.current &&
|
||||||
if (!isTitleFocused) {
|
typeof document !== 'undefined' &&
|
||||||
|
document.hasFocus();
|
||||||
|
if (!isTitleActivelyEditing) {
|
||||||
setTitle(evt.title);
|
setTitle(evt.title);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -211,10 +213,12 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
|||||||
|
|
||||||
case 'title_updated': {
|
case 'title_updated': {
|
||||||
if (evt.title !== undefined) {
|
if (evt.title !== undefined) {
|
||||||
const isTitleFocused =
|
const isTitleActivelyEditing =
|
||||||
titleInputRef.current !== null &&
|
titleInputRef.current !== null &&
|
||||||
document.activeElement === titleInputRef.current;
|
document.activeElement === titleInputRef.current &&
|
||||||
if (!isTitleFocused) {
|
typeof document !== 'undefined' &&
|
||||||
|
document.hasFocus();
|
||||||
|
if (!isTitleActivelyEditing) {
|
||||||
setTitle(evt.title);
|
setTitle(evt.title);
|
||||||
}
|
}
|
||||||
if (docRef.current) {
|
if (docRef.current) {
|
||||||
@@ -407,6 +411,27 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
|||||||
}, 250);
|
}, 250);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleTitleBlur = () => {
|
||||||
|
if (titleDebounceRef.current !== null) {
|
||||||
|
window.clearTimeout(titleDebounceRef.current);
|
||||||
|
titleDebounceRef.current = null;
|
||||||
|
sendTitleUpdated(title);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Flush title debouncing on window blur so background window never holds stale pending title
|
||||||
|
useEffect(() => {
|
||||||
|
const handleWindowBlur = () => {
|
||||||
|
if (titleDebounceRef.current !== null) {
|
||||||
|
window.clearTimeout(titleDebounceRef.current);
|
||||||
|
titleDebounceRef.current = null;
|
||||||
|
sendTitleUpdated(title);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener('blur', handleWindowBlur);
|
||||||
|
return () => window.removeEventListener('blur', handleWindowBlur);
|
||||||
|
}, [title, sendTitleUpdated]);
|
||||||
|
|
||||||
const handleUpdateBlock = useCallback(
|
const handleUpdateBlock = useCallback(
|
||||||
(updatedBlock: DocumentBlock) => {
|
(updatedBlock: DocumentBlock) => {
|
||||||
setBlocks((prev) =>
|
setBlocks((prev) =>
|
||||||
@@ -920,6 +945,7 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
|||||||
value={title}
|
value={title}
|
||||||
readOnly={!canEdit}
|
readOnly={!canEdit}
|
||||||
onChange={handleTitleChange}
|
onChange={handleTitleChange}
|
||||||
|
onBlur={handleTitleBlur}
|
||||||
placeholder="Titel der Notiz…"
|
placeholder="Titel der Notiz…"
|
||||||
className="flex-1 min-w-0 text-xl sm:text-2xl font-bold text-main bg-transparent border-0 focus:outline-none focus:ring-0 tracking-tight"
|
className="flex-1 min-w-0 text-xl sm:text-2xl font-bold text-main bg-transparent border-0 focus:outline-none focus:ring-0 tracking-tight"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -102,15 +102,87 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
|||||||
const localContentRef = useRef<string>(localContent);
|
const localContentRef = useRef<string>(localContent);
|
||||||
localContentRef.current = localContent;
|
localContentRef.current = localContent;
|
||||||
|
|
||||||
// Sync incoming remote changes ONLY when this textarea is NOT active
|
// Callback refs to avoid stale closures in setTimeout, debounce flush, and event listeners
|
||||||
|
const onUpdateBlockRef = useRef(onUpdateBlock);
|
||||||
|
onUpdateBlockRef.current = onUpdateBlock;
|
||||||
|
const onFocusBlockRef = useRef(onFocusBlock);
|
||||||
|
onFocusBlockRef.current = onFocusBlock;
|
||||||
|
const onCreateBlockBelowRef = useRef(onCreateBlockBelow);
|
||||||
|
onCreateBlockBelowRef.current = onCreateBlockBelow;
|
||||||
|
const onDeleteBlockRef = useRef(onDeleteBlock);
|
||||||
|
onDeleteBlockRef.current = onDeleteBlock;
|
||||||
|
const onIndentBlockRef = useRef(onIndentBlock);
|
||||||
|
onIndentBlockRef.current = onIndentBlock;
|
||||||
|
const onNavigateFocusRef = useRef(onNavigateFocus);
|
||||||
|
onNavigateFocusRef.current = onNavigateFocus;
|
||||||
|
const sendCursorMovedRef = useRef(sendCursorMoved);
|
||||||
|
sendCursorMovedRef.current = sendCursorMoved;
|
||||||
|
|
||||||
|
// Track if this input currently has actual focus in an active browser window
|
||||||
|
const isFocusedRef = useRef<boolean>(false);
|
||||||
|
|
||||||
|
const flushDebounce = useCallback(
|
||||||
|
(contentToFlush: string, overrides?: Partial<DocumentBlock>) => {
|
||||||
|
if (debounceTimerRef.current !== null) {
|
||||||
|
window.clearTimeout(debounceTimerRef.current);
|
||||||
|
debounceTimerRef.current = null;
|
||||||
|
}
|
||||||
|
const nextBlock: DocumentBlock = {
|
||||||
|
...latestBlockRef.current,
|
||||||
|
content: contentToFlush,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
onUpdateBlockRef.current(nextBlock, true);
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Sync incoming remote changes ONLY when this textarea is NOT actively being edited
|
||||||
|
// Condition: isFocusedRef.current === true && document.hasFocus()
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const isActivelyFocused =
|
const isActivelyEditing =
|
||||||
textareaRef.current !== null && document.activeElement === textareaRef.current;
|
isFocusedRef.current === true &&
|
||||||
if (!isActivelyFocused && block.content !== localContentRef.current) {
|
typeof document !== 'undefined' &&
|
||||||
|
document.hasFocus() &&
|
||||||
|
textareaRef.current !== null &&
|
||||||
|
document.activeElement === textareaRef.current;
|
||||||
|
|
||||||
|
if (!isActivelyEditing && block.content !== localContentRef.current) {
|
||||||
setLocalContent(block.content || '');
|
setLocalContent(block.content || '');
|
||||||
|
localContentRef.current = block.content || '';
|
||||||
}
|
}
|
||||||
}, [block.content]);
|
}, [block.content]);
|
||||||
|
|
||||||
|
// Handle window blur / focus to instantly flush debounced input and clear zombie focus
|
||||||
|
useEffect(() => {
|
||||||
|
const handleWindowBlur = () => {
|
||||||
|
if (debounceTimerRef.current !== null) {
|
||||||
|
window.clearTimeout(debounceTimerRef.current);
|
||||||
|
debounceTimerRef.current = null;
|
||||||
|
flushDebounce(localContentRef.current);
|
||||||
|
} else if (localContentRef.current !== latestBlockRef.current.content) {
|
||||||
|
flushDebounce(localContentRef.current);
|
||||||
|
}
|
||||||
|
isFocusedRef.current = false;
|
||||||
|
sendCursorMovedRef.current?.(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleWindowFocus = () => {
|
||||||
|
const isThisActive =
|
||||||
|
textareaRef.current !== null &&
|
||||||
|
document.activeElement === textareaRef.current &&
|
||||||
|
document.hasFocus();
|
||||||
|
isFocusedRef.current = isThisActive;
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('blur', handleWindowBlur);
|
||||||
|
window.addEventListener('focus', handleWindowFocus);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('blur', handleWindowBlur);
|
||||||
|
window.removeEventListener('focus', handleWindowFocus);
|
||||||
|
};
|
||||||
|
}, [flushDebounce]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
if (debounceTimerRef.current !== null) {
|
if (debounceTimerRef.current !== null) {
|
||||||
@@ -136,22 +208,6 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
|||||||
return () => window.removeEventListener('resize', adjustHeight);
|
return () => window.removeEventListener('resize', adjustHeight);
|
||||||
}, [adjustHeight]);
|
}, [adjustHeight]);
|
||||||
|
|
||||||
const flushDebounce = useCallback(
|
|
||||||
(contentToFlush: string, overrides?: Partial<DocumentBlock>) => {
|
|
||||||
if (debounceTimerRef.current !== null) {
|
|
||||||
window.clearTimeout(debounceTimerRef.current);
|
|
||||||
debounceTimerRef.current = null;
|
|
||||||
}
|
|
||||||
const nextBlock: DocumentBlock = {
|
|
||||||
...latestBlockRef.current,
|
|
||||||
content: contentToFlush,
|
|
||||||
...overrides,
|
|
||||||
};
|
|
||||||
onUpdateBlock(nextBlock, true);
|
|
||||||
},
|
|
||||||
[onUpdateBlock]
|
|
||||||
);
|
|
||||||
|
|
||||||
const filteredSlashCommands = useMemo(() => {
|
const filteredSlashCommands = useMemo(() => {
|
||||||
if (slashSearch === null) return [];
|
if (slashSearch === null) return [];
|
||||||
const q = slashSearch.toLowerCase();
|
const q = slashSearch.toLowerCase();
|
||||||
@@ -237,7 +293,7 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
|||||||
|
|
||||||
debounceTimerRef.current = window.setTimeout(() => {
|
debounceTimerRef.current = window.setTimeout(() => {
|
||||||
debounceTimerRef.current = null;
|
debounceTimerRef.current = null;
|
||||||
onUpdateBlock({
|
onUpdateBlockRef.current({
|
||||||
...latestBlockRef.current,
|
...latestBlockRef.current,
|
||||||
content: val,
|
content: val,
|
||||||
});
|
});
|
||||||
@@ -245,28 +301,34 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleFocus = (e: React.FocusEvent<HTMLTextAreaElement>) => {
|
const handleFocus = (e: React.FocusEvent<HTMLTextAreaElement>) => {
|
||||||
onFocusBlock(block.id);
|
isFocusedRef.current = true;
|
||||||
sendCursorMoved?.(block.id, e.target.selectionStart, e.target.selectionEnd);
|
onFocusBlockRef.current(block.id);
|
||||||
|
sendCursorMovedRef.current?.(block.id, e.target.selectionStart, e.target.selectionEnd);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleBlur = () => {
|
const handleBlur = () => {
|
||||||
|
isFocusedRef.current = false;
|
||||||
if (debounceTimerRef.current !== null) {
|
if (debounceTimerRef.current !== null) {
|
||||||
flushDebounce(localContent);
|
window.clearTimeout(debounceTimerRef.current);
|
||||||
|
debounceTimerRef.current = null;
|
||||||
|
flushDebounce(localContentRef.current);
|
||||||
|
} else if (localContentRef.current !== latestBlockRef.current.content) {
|
||||||
|
flushDebounce(localContentRef.current);
|
||||||
}
|
}
|
||||||
sendCursorMoved?.(null);
|
sendCursorMovedRef.current?.(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleClick = (e: React.MouseEvent<HTMLTextAreaElement>) => {
|
const handleClick = (e: React.MouseEvent<HTMLTextAreaElement>) => {
|
||||||
sendCursorMoved?.(block.id, e.currentTarget.selectionStart, e.currentTarget.selectionEnd);
|
sendCursorMovedRef.current?.(block.id, e.currentTarget.selectionStart, e.currentTarget.selectionEnd);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleKeyUp = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
const handleKeyUp = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||||
sendCursorMoved?.(block.id, e.currentTarget.selectionStart, e.currentTarget.selectionEnd);
|
sendCursorMovedRef.current?.(block.id, e.currentTarget.selectionStart, e.currentTarget.selectionEnd);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSelect = (e: React.SyntheticEvent<HTMLTextAreaElement>) => {
|
const handleSelect = (e: React.SyntheticEvent<HTMLTextAreaElement>) => {
|
||||||
const target = e.currentTarget;
|
const target = e.currentTarget;
|
||||||
sendCursorMoved?.(block.id, target.selectionStart, target.selectionEnd);
|
sendCursorMovedRef.current?.(block.id, target.selectionStart, target.selectionEnd);
|
||||||
};
|
};
|
||||||
|
|
||||||
const primaryRemoteCursor = activeRemoteCursors.length > 0 ? activeRemoteCursors[0] : null;
|
const primaryRemoteCursor = activeRemoteCursors.length > 0 ? activeRemoteCursors[0] : null;
|
||||||
@@ -306,7 +368,7 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
flushDebounce(localContent);
|
flushDebounce(localContent);
|
||||||
onIndentBlock(block.id, e.shiftKey ? -1 : 1);
|
onIndentBlockRef.current(block.id, e.shiftKey ? -1 : 1);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -316,7 +378,7 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
|||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
setSlashSearch(null);
|
setSlashSearch(null);
|
||||||
flushDebounce(localContent);
|
flushDebounce(localContent);
|
||||||
onCreateBlockBelow(block.id, localContent);
|
onCreateBlockBelowRef.current(block.id, localContent);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -330,7 +392,7 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
|||||||
window.clearTimeout(debounceTimerRef.current);
|
window.clearTimeout(debounceTimerRef.current);
|
||||||
debounceTimerRef.current = null;
|
debounceTimerRef.current = null;
|
||||||
}
|
}
|
||||||
onDeleteBlock(block.id);
|
onDeleteBlockRef.current(block.id);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -340,7 +402,7 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
|||||||
if (el && el.selectionStart === 0 && el.selectionEnd === 0) {
|
if (el && el.selectionStart === 0 && el.selectionEnd === 0) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
flushDebounce(localContent);
|
flushDebounce(localContent);
|
||||||
onNavigateFocus(block.id, 'up');
|
onNavigateFocusRef.current(block.id, 'up');
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -350,7 +412,7 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
|||||||
if (el && el.selectionStart === localContent.length && el.selectionEnd === localContent.length) {
|
if (el && el.selectionStart === localContent.length && el.selectionEnd === localContent.length) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
flushDebounce(localContent);
|
flushDebounce(localContent);
|
||||||
onNavigateFocus(block.id, 'down');
|
onNavigateFocusRef.current(block.id, 'down');
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -742,7 +804,7 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
|||||||
{canEdit && (
|
{canEdit && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onDeleteBlock(block.id)}
|
onClick={() => onDeleteBlockRef.current(block.id)}
|
||||||
className="opacity-0 group-hover:opacity-100 focus:opacity-100 p-0.5 text-faint hover:text-status-err rounded-btn transition-opacity shrink-0"
|
className="opacity-0 group-hover:opacity-100 focus:opacity-100 p-0.5 text-faint hover:text-status-err rounded-btn transition-opacity shrink-0"
|
||||||
title="Block löschen"
|
title="Block löschen"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -84,6 +84,13 @@ export function useDocumentSync(
|
|||||||
const onRemoteEventRef = useRef(onRemoteEvent);
|
const onRemoteEventRef = useRef(onRemoteEvent);
|
||||||
onRemoteEventRef.current = onRemoteEvent;
|
onRemoteEventRef.current = onRemoteEvent;
|
||||||
|
|
||||||
|
const docIdRef = useRef(docId);
|
||||||
|
docIdRef.current = docId;
|
||||||
|
const canEditRef = useRef(canEdit);
|
||||||
|
canEditRef.current = canEdit;
|
||||||
|
const currentUserRef = useRef(currentUser);
|
||||||
|
currentUserRef.current = currentUser;
|
||||||
|
|
||||||
const reconnectTimerRef = useRef<number | null>(null);
|
const reconnectTimerRef = useRef<number | null>(null);
|
||||||
const fallbackTimerRef = useRef<number | null>(null);
|
const fallbackTimerRef = useRef<number | null>(null);
|
||||||
|
|
||||||
@@ -93,6 +100,12 @@ export function useDocumentSync(
|
|||||||
() => getUserColor(currentUser?.id || clientIdRef.current),
|
() => getUserColor(currentUser?.id || clientIdRef.current),
|
||||||
[currentUser?.id]
|
[currentUser?.id]
|
||||||
);
|
);
|
||||||
|
const localUserNameRef = useRef(localUserName);
|
||||||
|
localUserNameRef.current = localUserName;
|
||||||
|
const localUserColorRef = useRef(localUserColor);
|
||||||
|
localUserColorRef.current = localUserColor;
|
||||||
|
|
||||||
|
const pendingMessagesQueueRef = useRef<string[]>([]);
|
||||||
|
|
||||||
const lastCursorSendRef = useRef<number>(0);
|
const lastCursorSendRef = useRef<number>(0);
|
||||||
const pendingCursorTimerRef = useRef<number | null>(null);
|
const pendingCursorTimerRef = useRef<number | null>(null);
|
||||||
@@ -126,9 +139,11 @@ export function useDocumentSync(
|
|||||||
if (!docId) {
|
if (!docId) {
|
||||||
setStatus('offline');
|
setStatus('offline');
|
||||||
setRemoteCursors({});
|
setRemoteCursors({});
|
||||||
|
pendingMessagesQueueRef.current = [];
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pendingMessagesQueueRef.current = [];
|
||||||
let isDisposed = false;
|
let isDisposed = false;
|
||||||
let retryCount = 0;
|
let retryCount = 0;
|
||||||
const currentClientId = `cli_${Math.random().toString(36).slice(2, 10)}_${Date.now().toString(36)}`;
|
const currentClientId = `cli_${Math.random().toString(36).slice(2, 10)}_${Date.now().toString(36)}`;
|
||||||
@@ -171,6 +186,19 @@ export function useDocumentSync(
|
|||||||
}
|
}
|
||||||
retryCount = 0;
|
retryCount = 0;
|
||||||
setStatus('connected');
|
setStatus('connected');
|
||||||
|
|
||||||
|
// Flush any queued messages buffered while connecting or reconnecting
|
||||||
|
if (pendingMessagesQueueRef.current.length > 0) {
|
||||||
|
const queue = [...pendingMessagesQueueRef.current];
|
||||||
|
pendingMessagesQueueRef.current = [];
|
||||||
|
for (const msg of queue) {
|
||||||
|
try {
|
||||||
|
ws.send(msg);
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[DocSync] Failed to flush queued message:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
ws.onmessage = (event) => {
|
ws.onmessage = (event) => {
|
||||||
@@ -281,27 +309,32 @@ export function useDocumentSync(
|
|||||||
const executeSendCursor = useCallback(
|
const executeSendCursor = useCallback(
|
||||||
(blockId: string | null, selectionStart?: number, selectionEnd?: number) => {
|
(blockId: string | null, selectionStart?: number, selectionEnd?: number) => {
|
||||||
const ws = wsRef.current;
|
const ws = wsRef.current;
|
||||||
if (!docId || !ws || ws.readyState !== WebSocket.OPEN) return;
|
const activeDocId = docIdRef.current;
|
||||||
|
if (!activeDocId || !ws || ws.readyState !== WebSocket.OPEN) return;
|
||||||
const payload: DocSyncEvent = {
|
const payload: DocSyncEvent = {
|
||||||
type: 'cursor_moved',
|
type: 'cursor_moved',
|
||||||
docId,
|
docId: activeDocId,
|
||||||
clientId: clientIdRef.current,
|
clientId: clientIdRef.current,
|
||||||
userId: currentUser?.id,
|
userId: currentUserRef.current?.id,
|
||||||
userName: localUserName,
|
userName: localUserNameRef.current,
|
||||||
userColor: localUserColor,
|
userColor: localUserColorRef.current,
|
||||||
blockId: blockId || undefined,
|
blockId: blockId || undefined,
|
||||||
selectionStart,
|
selectionStart,
|
||||||
selectionEnd,
|
selectionEnd,
|
||||||
};
|
};
|
||||||
ws.send(JSON.stringify(payload));
|
try {
|
||||||
|
ws.send(JSON.stringify(payload));
|
||||||
|
} catch {
|
||||||
|
// Ephemeral cursor, ignore send failure
|
||||||
|
}
|
||||||
lastCursorSendRef.current = Date.now();
|
lastCursorSendRef.current = Date.now();
|
||||||
},
|
},
|
||||||
[docId, currentUser?.id, localUserName, localUserColor]
|
[]
|
||||||
);
|
);
|
||||||
|
|
||||||
const sendCursorMoved = useCallback(
|
const sendCursorMoved = useCallback(
|
||||||
(blockId: string | null, selectionStart?: number, selectionEnd?: number) => {
|
(blockId: string | null, selectionStart?: number, selectionEnd?: number) => {
|
||||||
if (!docId) return;
|
if (!docIdRef.current) return;
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const elapsed = now - lastCursorSendRef.current;
|
const elapsed = now - lastCursorSendRef.current;
|
||||||
|
|
||||||
@@ -340,30 +373,48 @@ export function useDocumentSync(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[docId, executeSendCursor]
|
[executeSendCursor]
|
||||||
);
|
);
|
||||||
|
|
||||||
const sendRawEvent = useCallback(
|
const sendRawEvent = useCallback(
|
||||||
(evt: DocSyncEvent, fallbackFn?: () => void) => {
|
(evt: DocSyncEvent, fallbackFn?: () => void) => {
|
||||||
if (!docId || !canEdit) return;
|
const activeDocId = docIdRef.current;
|
||||||
|
const activeCanEdit = canEditRef.current;
|
||||||
|
if (!activeDocId || !activeCanEdit) return;
|
||||||
|
|
||||||
const ws = wsRef.current;
|
const ws = wsRef.current;
|
||||||
const payload: DocSyncEvent = {
|
const payload: DocSyncEvent = {
|
||||||
...evt,
|
...evt,
|
||||||
docId,
|
docId: activeDocId,
|
||||||
clientId: clientIdRef.current,
|
clientId: clientIdRef.current,
|
||||||
};
|
};
|
||||||
|
const jsonStr = JSON.stringify(payload);
|
||||||
|
|
||||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||||
ws.send(JSON.stringify(payload));
|
try {
|
||||||
} else if (fallbackFn) {
|
ws.send(jsonStr);
|
||||||
if (fallbackTimerRef.current !== null) {
|
} catch (err) {
|
||||||
window.clearTimeout(fallbackTimerRef.current);
|
console.warn('[DocSync] Failed to send WebSocket message, buffering:', err);
|
||||||
|
pendingMessagesQueueRef.current.push(jsonStr);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.warn(
|
||||||
|
`[DocSync] WebSocket not OPEN (state=${ws ? ws.readyState : 'null'}). Buffering event:`,
|
||||||
|
evt.type
|
||||||
|
);
|
||||||
|
pendingMessagesQueueRef.current.push(jsonStr);
|
||||||
|
|
||||||
|
if (fallbackFn) {
|
||||||
|
if (fallbackTimerRef.current !== null) {
|
||||||
|
window.clearTimeout(fallbackTimerRef.current);
|
||||||
|
}
|
||||||
|
fallbackTimerRef.current = window.setTimeout(() => {
|
||||||
|
fallbackFn();
|
||||||
|
}, 600);
|
||||||
}
|
}
|
||||||
fallbackTimerRef.current = window.setTimeout(() => {
|
|
||||||
fallbackFn();
|
|
||||||
}, 600);
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[docId, canEdit]
|
[]
|
||||||
);
|
);
|
||||||
|
|
||||||
const sendBlockCreated = useCallback(
|
const sendBlockCreated = useCallback(
|
||||||
|
|||||||
@@ -320,8 +320,10 @@ func (r *DocRoom) HandleEvent(evt DocSyncEvent, sender *DocWSClient) {
|
|||||||
targetID = evt.Block.ID
|
targetID = evt.Block.ID
|
||||||
}
|
}
|
||||||
if targetID != "" {
|
if targetID != "" {
|
||||||
|
found := false
|
||||||
for i := range r.blocks {
|
for i := range r.blocks {
|
||||||
if r.blocks[i].ID == targetID {
|
if r.blocks[i].ID == targetID {
|
||||||
|
found = true
|
||||||
if evt.Block != nil {
|
if evt.Block != nil {
|
||||||
r.blocks[i].Content = evt.Block.Content
|
r.blocks[i].Content = evt.Block.Content
|
||||||
r.blocks[i].Level = evt.Block.Level
|
r.blocks[i].Level = evt.Block.Level
|
||||||
@@ -359,6 +361,22 @@ func (r *DocRoom) HandleEvent(evt DocSyncEvent, sender *DocWSClient) {
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if !found && evt.Block != nil {
|
||||||
|
newBlock := *evt.Block
|
||||||
|
newBlock.DocumentID = r.docID
|
||||||
|
if newBlock.Type == "" {
|
||||||
|
newBlock.Type = "text"
|
||||||
|
}
|
||||||
|
if newBlock.Level < 0 {
|
||||||
|
newBlock.Level = 0
|
||||||
|
}
|
||||||
|
r.blocks = append(r.blocks, newBlock)
|
||||||
|
r.blocks = normalizeRoomBlocks(r.docID, r.blocks)
|
||||||
|
bCopy := newBlock
|
||||||
|
evt.Block = &bCopy
|
||||||
|
evt.BlockID = targetID
|
||||||
|
mutated = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
case "block_deleted":
|
case "block_deleted":
|
||||||
@@ -441,6 +459,11 @@ func (r *DocRoom) HandleEvent(evt DocSyncEvent, sender *DocWSClient) {
|
|||||||
select {
|
select {
|
||||||
case client.send <- msgBytes:
|
case client.send <- msgBytes:
|
||||||
default:
|
default:
|
||||||
|
slog.Warn("Doc room WS client send buffer full, dropping message",
|
||||||
|
"doc_id", r.docID,
|
||||||
|
"client_id", client.clientID,
|
||||||
|
"user_id", client.userID,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user