fix(sync): eliminate zombie focus, flush debounced state on window blur, remove stale closures and buffer ws messages
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 57s
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 57s
This commit is contained in:
1 parent
06f5f7043c
commit
cedf5aebff
4 files changed
+221
-59
No files matched your search
@@ -196,10 +196,12 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
||||
switch (evt.type) {
|
||||
case 'doc_state': {
|
||||
if (evt.title !== undefined) {
|
||||
const isTitleFocused =
|
||||
const isTitleActivelyEditing =
|
||||
titleInputRef.current !== null &&
|
||||
document.activeElement === titleInputRef.current;
|
||||
if (!isTitleFocused) {
|
||||
document.activeElement === titleInputRef.current &&
|
||||
typeof document !== 'undefined' &&
|
||||
document.hasFocus();
|
||||
if (!isTitleActivelyEditing) {
|
||||
setTitle(evt.title);
|
||||
}
|
||||
}
|
||||
@@ -211,10 +213,12 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
||||
|
||||
case 'title_updated': {
|
||||
if (evt.title !== undefined) {
|
||||
const isTitleFocused =
|
||||
const isTitleActivelyEditing =
|
||||
titleInputRef.current !== null &&
|
||||
document.activeElement === titleInputRef.current;
|
||||
if (!isTitleFocused) {
|
||||
document.activeElement === titleInputRef.current &&
|
||||
typeof document !== 'undefined' &&
|
||||
document.hasFocus();
|
||||
if (!isTitleActivelyEditing) {
|
||||
setTitle(evt.title);
|
||||
}
|
||||
if (docRef.current) {
|
||||
@@ -407,6 +411,27 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
||||
}, 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(
|
||||
(updatedBlock: DocumentBlock) => {
|
||||
setBlocks((prev) =>
|
||||
@@ -920,6 +945,7 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
||||
value={title}
|
||||
readOnly={!canEdit}
|
||||
onChange={handleTitleChange}
|
||||
onBlur={handleTitleBlur}
|
||||
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"
|
||||
/>
|
||||
|
||||
@@ -102,15 +102,87 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
||||
const localContentRef = useRef<string>(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(() => {
|
||||
const isActivelyFocused =
|
||||
textareaRef.current !== null && document.activeElement === textareaRef.current;
|
||||
if (!isActivelyFocused && block.content !== localContentRef.current) {
|
||||
const isActivelyEditing =
|
||||
isFocusedRef.current === true &&
|
||||
typeof document !== 'undefined' &&
|
||||
document.hasFocus() &&
|
||||
textareaRef.current !== null &&
|
||||
document.activeElement === textareaRef.current;
|
||||
|
||||
if (!isActivelyEditing && block.content !== localContentRef.current) {
|
||||
setLocalContent(block.content || '');
|
||||
localContentRef.current = 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(() => {
|
||||
return () => {
|
||||
if (debounceTimerRef.current !== null) {
|
||||
@@ -136,22 +208,6 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
||||
return () => window.removeEventListener('resize', 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(() => {
|
||||
if (slashSearch === null) return [];
|
||||
const q = slashSearch.toLowerCase();
|
||||
@@ -237,7 +293,7 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
||||
|
||||
debounceTimerRef.current = window.setTimeout(() => {
|
||||
debounceTimerRef.current = null;
|
||||
onUpdateBlock({
|
||||
onUpdateBlockRef.current({
|
||||
...latestBlockRef.current,
|
||||
content: val,
|
||||
});
|
||||
@@ -245,28 +301,34 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
||||
};
|
||||
|
||||
const handleFocus = (e: React.FocusEvent<HTMLTextAreaElement>) => {
|
||||
onFocusBlock(block.id);
|
||||
sendCursorMoved?.(block.id, e.target.selectionStart, e.target.selectionEnd);
|
||||
isFocusedRef.current = true;
|
||||
onFocusBlockRef.current(block.id);
|
||||
sendCursorMovedRef.current?.(block.id, e.target.selectionStart, e.target.selectionEnd);
|
||||
};
|
||||
|
||||
const handleBlur = () => {
|
||||
isFocusedRef.current = false;
|
||||
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>) => {
|
||||
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>) => {
|
||||
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 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;
|
||||
@@ -306,7 +368,7 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
flushDebounce(localContent);
|
||||
onIndentBlock(block.id, e.shiftKey ? -1 : 1);
|
||||
onIndentBlockRef.current(block.id, e.shiftKey ? -1 : 1);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -316,7 +378,7 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
||||
e.stopPropagation();
|
||||
setSlashSearch(null);
|
||||
flushDebounce(localContent);
|
||||
onCreateBlockBelow(block.id, localContent);
|
||||
onCreateBlockBelowRef.current(block.id, localContent);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -330,7 +392,7 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
||||
window.clearTimeout(debounceTimerRef.current);
|
||||
debounceTimerRef.current = null;
|
||||
}
|
||||
onDeleteBlock(block.id);
|
||||
onDeleteBlockRef.current(block.id);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -340,7 +402,7 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
||||
if (el && el.selectionStart === 0 && el.selectionEnd === 0) {
|
||||
e.preventDefault();
|
||||
flushDebounce(localContent);
|
||||
onNavigateFocus(block.id, 'up');
|
||||
onNavigateFocusRef.current(block.id, 'up');
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -350,7 +412,7 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
||||
if (el && el.selectionStart === localContent.length && el.selectionEnd === localContent.length) {
|
||||
e.preventDefault();
|
||||
flushDebounce(localContent);
|
||||
onNavigateFocus(block.id, 'down');
|
||||
onNavigateFocusRef.current(block.id, 'down');
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -742,7 +804,7 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
||||
{canEdit && (
|
||||
<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"
|
||||
title="Block löschen"
|
||||
>
|
||||
|
||||
Reference in new issue
Block a user