Skip to content

Commit 20f0f5c

Browse files
authored
Merge pull request #2411 from tf/batch-fixes
Fix batch editing regressions
2 parents a3d8f86 + 2ac6ae2 commit 20f0f5c

5 files changed

Lines changed: 68 additions & 35 deletions

File tree

entry_types/scrolled/package/src/editor/models/ScrolledEntry/Batch.js

Lines changed: 30 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -314,18 +314,28 @@ export function Batch(entry, section, {reviewSession} = {}) {
314314
},
315315

316316
success(response) {
317-
applyAdditions(response);
318-
applyPositions();
319-
applyDeletions();
317+
// Each step's intermediate state is observable in the iframe
318+
// (Backbone change events propagate as separate postMessages
319+
// and re-render React without batching), so the order
320+
// resolves these dependencies:
321+
//
322+
// - permaIds before thread updates: thread migrations
323+
// resolve `target.get('permaId')`.
324+
// - Thread updates before configuration changes: the
325+
// value-flip render in `useCachedValue` clears rangeRefs
326+
// and falls back to `thread.subjectRange`, which must
327+
// already be migrated.
328+
// - Positions before additions: Backbone's collection
329+
// comparator drops new elements into their sorted slot.
330+
// - Configuration changes before deletions: a delete-merge
331+
// survivor grows before its sibling disappears (no
332+
// brief collapse).
333+
applyPermaIds(response);
320334
applyThreadUpdates();
321-
// Apply configuration changes last so that the value flip
322-
// observed by `useCachedValue` happens after `reviewSession`
323-
// already holds the migrated thread ranges. `onReset` then
324-
// clears `useCommentRangeRefs`'s map and the post-render
325-
// effect rebuilds it from the new thread subject ranges
326-
// against the new editor content — no reliance on
327-
// `isValidRange` filtering out stale OLD ranges.
328335
applyConfigurationChanges();
336+
applyPositions();
337+
applyAdditions();
338+
applyDeletions();
329339

330340
section.contentElements.sort();
331341

@@ -384,20 +394,26 @@ export function Batch(entry, section, {reviewSession} = {}) {
384394
// Functionality to apply the recorded changes to the underlying
385395
// section once the request succeeded:
386396

387-
function applyAdditions(response) {
397+
function applyPermaIds(response) {
388398
contentElements.forEach((contentElement, index) => {
389399
if (contentElement.isNew()) {
390-
section.contentElements.add(contentElement);
391-
392400
contentElement.set({
393401
id: response[index].id,
394402
permaId: response[index].permaId
395403
});
396404
}
397-
else if (contentElement.section !== section) {
405+
});
406+
}
407+
408+
function applyAdditions() {
409+
contentElements.forEach(contentElement => {
410+
if (contentElement.section && contentElement.section !== section) {
398411
contentElement.section.contentElements.remove(contentElement);
399412
section.contentElements.add(contentElement);
400413
}
414+
else if (!section.contentElements.contains(contentElement)) {
415+
section.contentElements.add(contentElement);
416+
}
401417
});
402418
}
403419

entry_types/scrolled/package/src/frontend/inlineEditing/EditableText/BadgeColumn.js

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import React from 'react';
22

33
import {FloatingPortal} from '@floating-ui/react';
4-
import {useSlate} from 'slate-react';
4+
import {useSlate, ReactEditor} from 'slate-react';
55

66
import {Badge, useAnchoredFloating} from 'pageflow-scrolled/review';
77
import {useFloatingPortalRoot} from '../../FloatingPortalRootProvider';
@@ -11,11 +11,17 @@ import {highlightOverlapsSelection} from './highlightOverlapsSelection';
1111

1212
export function BadgeColumn({highlights, anchors}) {
1313
const editor = useSlate();
14+
// Treat `editor.selection` as a live cursor only while the editor
15+
// is focused. After the user clicks away, slate-react's throttled
16+
// `selectionchange` listener can sync a clamped DOM cursor back
17+
// into `editor.selection`, which would otherwise flip badges back
18+
// to overlap mode without any actual selection.
19+
const editorSelection = ReactEditor.isFocused(editor) ? editor.selection : null;
1420

1521
return highlights.map(highlight => (
1622
<PositionedBadge key={highlight.key}
1723
highlight={highlight}
18-
editorSelection={editor.selection}
24+
editorSelection={editorSelection}
1925
anchors={anchors} />
2026
));
2127
}

entry_types/scrolled/package/src/frontend/inlineEditing/EditableText/Selection.js

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,17 @@ export function Selection(props) {
7878
}
7979

8080
if (!isContentElementSelected && !boundsRef.current) {
81+
// Only treat the selection as user-initiated when the editor is
82+
// actually focused. After an external value replacement, the
83+
// browser may clamp its DOM cursor to the start of the
84+
// shrunken contenteditable; slate-react's throttled
85+
// `selectionchange` listener then re-syncs that cursor into
86+
// `editor.selection`. Without this guard, that synthetic
87+
// selection would re-select the content element here.
88+
if (!ReactEditor.isFocused(editor)) {
89+
return;
90+
}
91+
8192
select();
8293
}
8394

entry_types/scrolled/package/src/frontend/inlineEditing/EditableText/index.js

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -221,6 +221,19 @@ function resetSelectionIfOutsideNextValue(editor, nextValue) {
221221
if (editor.selection && (!hasTextAtPoint(nextEditor, editor.selection.anchor) ||
222222
!hasTextAtPoint(nextEditor, editor.selection.focus))) {
223223
Transforms.deselect(editor);
224+
// Also drop the browser's DOM cursor when it sits inside this
225+
// editor's contenteditable. Otherwise slate-react's throttled
226+
// `selectionchange` listener would later re-sync that stale
227+
// cursor — clamped to the start of the now-shrunken content —
228+
// back into `editor.selection`, and `Selection`'s auto-select
229+
// would treat it as a user click and re-select this content
230+
// element (overriding whatever the calling op selected, e.g. a
231+
// freshly inserted image).
232+
const domSelection = window.getSelection();
233+
const editorEl = ReactEditor.toDOMNode(editor, editor);
234+
if (domSelection && editorEl.contains(domSelection.anchorNode)) {
235+
domSelection.removeAllRanges();
236+
}
224237
}
225238
}
226239

entry_types/scrolled/package/src/frontend/inlineEditing/useCachedValue.js

Lines changed: 6 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -7,26 +7,13 @@ export function useCachedValue(value, {
77
const [cachedValue, setCachedValue] = useState(value || defaultValue);
88
const previousValue = useRef(value);
99

10-
// Adjust state during render: when the upstream value changes,
11-
// call `onReset` and flip `cachedValue` immediately so the first
12-
// committed render after the change already observes the new
13-
// value. React detects the in-render setState and re-runs the
14-
// component before commit, so no extra render lands on the
15-
// screen — and `onReset` side effects are visible to the same
16-
// render that observes the new `cachedValue`.
17-
if (previousValue.current !== value && value !== cachedValue) {
18-
onReset && onReset(value);
19-
setCachedValue(value);
20-
previousValue.current = value;
21-
}
10+
useEffect(() => {
11+
if (previousValue.current !== value && value !== cachedValue) {
12+
onReset && onReset(value);
13+
setCachedValue(value);
14+
}
15+
}, [onReset, value, cachedValue]);
2216

23-
// Track the upstream value across renders. The branch above only
24-
// assigns when an external change is observed; without this effect,
25-
// a render that sees `value` already in sync with `cachedValue` (a
26-
// local edit echoed back via `onDebouncedChange`) would leave
27-
// `previousValue.current` stale. The next local edit would then
28-
// misread that stale value as an external change and overwrite the
29-
// edit with `onReset`.
3017
useEffect(() => {
3118
previousValue.current = value;
3219
});

0 commit comments

Comments
 (0)