Skip to content

Commit 1d07a0d

Browse files
committed
Fix: handle rapid user edits.
1 parent 09f7928 commit 1d07a0d

File tree

1 file changed

+65
-51
lines changed

1 file changed

+65
-51
lines changed

client/src/CodeMirror-integration.mts

Lines changed: 65 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,6 @@ import {
6262
} from "@codemirror/view";
6363
import {
6464
ChangeDesc,
65-
Compartment,
6665
EditorState,
6766
Extension,
6867
StateField,
@@ -102,15 +101,8 @@ import { show_toast } from "./show_toast.mjs";
102101
// -----------------------------------------------------------------------------
103102
let current_view: EditorView;
104103
let tinymce_singleton: Editor | undefined;
105-
// True if the change was produce by a user edit, not an IDE update.
106-
let is_user_change = false;
107104
// This indicates that a call to `on_dirty` is scheduled, but hasn't run yet.
108105
let on_dirty_scheduled = false;
109-
// True to ignore the next text selection change, since updates to the cursor or
110-
// scroll position from the Client trigged this change.
111-
let ignore_selection_change = false;
112-
// The compartment used to enable and disable the autosave extension.
113-
const autosaveCompartment = new Compartment();
114106

115107
// Options used when creating a `Decoration`.
116108
const decorationOptions = {
@@ -124,8 +116,12 @@ declare global {
124116
}
125117
}
126118

119+
// When this is included in a transaction, don't update from/to of doc blocks.
127120
const docBlockFreezeAnnotation = Annotation.define<boolean>();
128121

122+
// When this is included in a transaction, don't send autosave scroll/cursor location updates.
123+
const noAutosaveAnnotation = Annotation.define<boolean>();
124+
129125
// Doc blocks in CodeMirror
130126
// -----------------------------------------------------------------------------
131127
//
@@ -193,6 +189,7 @@ export const docBlockField = StateField.define<DecorationSet>({
193189
effect.value.indent,
194190
effect.value.delimiter,
195191
effect.value.content,
192+
false,
196193
),
197194
...decorationOptions,
198195
}).range(effect.value.from, effect.value.to),
@@ -279,6 +276,8 @@ export const docBlockField = StateField.define<DecorationSet>({
279276
prev.spec.widget.contents,
280277
effect.value.contents,
281278
),
279+
// Assume this isn't a user change unless it's specified.
280+
effect.value.is_user_change ?? false,
282281
),
283282
...decorationOptions,
284283
}).range(from, to),
@@ -329,7 +328,12 @@ export const docBlockField = StateField.define<DecorationSet>({
329328
contents,
330329
]: CodeMirrorDocBlockTuple) =>
331330
Decoration.replace({
332-
widget: new DocBlockWidget(indent, delimiter, contents),
331+
widget: new DocBlockWidget(
332+
indent,
333+
delimiter,
334+
contents,
335+
false,
336+
),
333337
...decorationOptions,
334338
}).range(from, to),
335339
),
@@ -369,6 +373,8 @@ type updateDocBlockType = {
369373
indent?: string;
370374
delimiter?: string;
371375
contents: string | StringDiff[];
376+
// True if this update comes from a user change, as opposed to an update received from the IDE.
377+
is_user_change?: boolean;
372378
};
373379

374380
// Define an update.
@@ -411,6 +417,7 @@ class DocBlockWidget extends WidgetType {
411417
readonly indent: string,
412418
readonly delimiter: string,
413419
readonly contents: string,
420+
readonly is_user_change: boolean,
414421
) {
415422
// TODO: I don't understand why I don't need to store the provided
416423
// parameters in the object: `this.indent = indent;`, etc.
@@ -449,10 +456,10 @@ class DocBlockWidget extends WidgetType {
449456
// [docs](https://codemirror.net/docs/ref/#view.WidgetType.updateDOM),
450457
// "Update a DOM element created by a widget of the same type (but
451458
// different, non-eq content) to reflect this widget."
452-
updateDOM(dom: HTMLElement, view: EditorView): boolean {
459+
updateDOM(dom: HTMLElement, _view: EditorView): boolean {
453460
// If this change was produced by a user edit, then the DOM was already updated. Stop here.
454-
if (is_user_change) {
455-
is_user_change = false;
461+
if (this.is_user_change) {
462+
console.log("user change -- skipping DOM update.");
456463
return true;
457464
}
458465
(dom.childNodes[0] as HTMLDivElement).innerHTML = this.indent;
@@ -462,10 +469,14 @@ class DocBlockWidget extends WidgetType {
462469
const [contents_div, is_tinymce] = get_contents(dom);
463470
window.MathJax.typesetClear(contents_div);
464471
if (is_tinymce) {
465-
// Save the cursor location before the update, then restore it afterwards.
466-
const sel = saveSelection();
472+
// Save the cursor location before the update, then restore it afterwards, if TinyMCE has focus.
473+
const sel = tinymce_singleton!.hasFocus()
474+
? saveSelection()
475+
: undefined;
467476
tinymce_singleton!.setContent(this.contents);
468-
restoreSelection(sel);
477+
if (sel !== undefined) {
478+
restoreSelection(sel);
479+
}
469480
} else {
470481
contents_div.innerHTML = this.contents;
471482
}
@@ -552,7 +563,7 @@ const saveSelection = () => {
552563
return { selection_path, selection_offset };
553564
};
554565

555-
// Restore the selection produced by `saveSelection`.
566+
// Restore the selection produced by `saveSelection` to the active TinyMCE instance.
556567
const restoreSelection = ({
557568
selection_path,
558569
selection_offset,
@@ -651,24 +662,22 @@ const element_is_in_doc_block = (
651662
// untypeset, then the dirty ignored.
652663
// 3. When MathJax typesets math on a TinyMCE focus out event, the dirty flag
653664
// gets set. This should be ignored. However, typesetting is an async
654-
// operation, so we assume it's OK to await the typeset completion, then
655-
// clear the `ignore_next_dirty flag`. This will lead to nasty bugs at some
656-
// point.
665+
// operation, so we assume it's OK to await the typeset completion.
666+
// This will lead to nasty bugs at some point.
657667
// 4. When an HTML doc block is assigned to the TinyMCE instance for editing,
658668
// the dirty flag is set. This must be ignored.
659669
const on_dirty = (
660670
// The div that's dirty. It must be a child of the doc block div.
661671
event_target: HTMLElement,
662672
) => {
663-
is_user_change = true;
664-
665673
if (on_dirty_scheduled) {
666674
return;
667675
}
668676
on_dirty_scheduled = true;
669677

670678
// Only run this after typesetting is done.
671-
window.MathJax.whenReady(() => {
679+
window.MathJax.whenReady(async () => {
680+
console.log("Starting update for user change.");
672681
on_dirty_scheduled = false;
673682
// Find the doc block parent div.
674683
const target = (event_target as HTMLDivElement).closest(
@@ -694,18 +703,17 @@ const on_dirty = (
694703
const contents = is_tinymce
695704
? tinymce_singleton!.save()
696705
: contents_div.innerHTML;
697-
// Although this is async, nothing following this call depends on its completion.
698-
mathJaxTypeset(contents_div);
699-
let effects: StateEffect<updateDocBlockType>[] = [
700-
updateDocBlock.of({
701-
from,
702-
indent,
703-
delimiter,
704-
contents,
705-
}),
706-
];
707-
708-
current_view.dispatch({ effects });
706+
await mathJaxTypeset(contents_div);
707+
current_view.dispatch({
708+
effects: [
709+
updateDocBlock.of({
710+
from,
711+
indent,
712+
delimiter,
713+
contents,
714+
}),
715+
],
716+
});
709717
});
710718
};
711719

@@ -819,7 +827,7 @@ export const DocBlockPlugin = ViewPlugin.fromClass(
819827
// Wait until the focus event completes; this causes the
820828
// cursor position (the selection) to be set in the
821829
// contenteditable div. Then, save that location.
822-
setTimeout(() => {
830+
setTimeout(async () => {
823831
// Untypeset math in the old doc block and the current doc block before moving its contents around.
824832
const tinymce_div =
825833
document.getElementById("TinyMCE-inst")!;
@@ -847,7 +855,7 @@ export const DocBlockPlugin = ViewPlugin.fromClass(
847855
null,
848856
);
849857
// The previous content edited by TinyMCE is now a div. Retypeset this after the transition.
850-
mathJaxTypeset(old_contents_div);
858+
await mathJaxTypeset(old_contents_div);
851859
// Move TinyMCE to the new location, then remove the old
852860
// div it will replace.
853861
target.insertBefore(tinymce_div, null);
@@ -857,7 +865,7 @@ export const DocBlockPlugin = ViewPlugin.fromClass(
857865
tinymce_singleton!.setContent(contents_div.innerHTML);
858866
contents_div.remove();
859867
// The new div is now a TinyMCE editor. Retypeset this.
860-
mathJaxTypeset(tinymce_div);
868+
await mathJaxTypeset(tinymce_div);
861869

862870
// This process causes TinyMCE to lose focus. Restore
863871
// that. However, this causes TinyMCE to lose the
@@ -895,6 +903,15 @@ const autosaveExtension = EditorView.updateListener.of(
895903
// [ViewUpdate](https://codemirror.net/docs/ref/#view.ViewUpdate) which
896904
// describes a change being made to the document.
897905
(v: ViewUpdate) => {
906+
// Ignore any transaction group marked with a `noAutosaveAnnotation`.
907+
if (
908+
v.transactions.some(
909+
(tr) => tr.annotation(noAutosaveAnnotation) === true,
910+
)
911+
) {
912+
return true;
913+
}
914+
898915
// The
899916
// [docChanged](https://codemirror.net/docs/ref/#view.ViewUpdate.docChanged)
900917
// flag is the relevant part of this change description. However, this
@@ -919,10 +936,6 @@ const autosaveExtension = EditorView.updateListener.of(
919936
set_is_dirty();
920937
startAutosaveTimer();
921938
} else if (v.selectionSet) {
922-
if (ignore_selection_change) {
923-
ignore_selection_change = false;
924-
return;
925-
}
926939
// Send an update if only the selection changed.
927940
startAutosaveTimer();
928941
}
@@ -1047,7 +1060,7 @@ export const CodeMirror_load = async (
10471060
parser,
10481061
basicSetup,
10491062
EditorView.lineWrapping,
1050-
autosaveCompartment.of(autosaveExtension),
1063+
autosaveExtension,
10511064
// Make tab an indent per the
10521065
// [docs](https://codemirror.net/examples/tab/). TODO:
10531066
// document a way to escape the tab key per the same docs.
@@ -1093,16 +1106,17 @@ export const CodeMirror_load = async (
10931106
})
10941107
)[0];
10951108
} else {
1096-
// Disable autosave when performing these updates.
1097-
current_view.dispatch({ effects: autosaveCompartment.reconfigure([]) });
10981109
// This contains a diff, instead of plain text. Apply the text diff.
10991110
//
11001111
// First, apply just the text edits. Use an annotation so that the doc
11011112
// blocks aren't changed; without this, the diff won't work (since
11021113
// from/to values of doc blocks are changed by unfrozen text edits).
11031114
current_view.dispatch({
11041115
changes: codechat_for_web.source.Diff.doc,
1105-
annotations: docBlockFreezeAnnotation.of(true),
1116+
annotations: [
1117+
docBlockFreezeAnnotation.of(true),
1118+
noAutosaveAnnotation.of(true),
1119+
],
11061120
});
11071121
// Now, apply the diff in a separate transaction. Applying them in the
11081122
// same transaction causes the text edits to modify from/to values in
@@ -1130,10 +1144,9 @@ export const CodeMirror_load = async (
11301144
}
11311145
}
11321146
// Update the view with these changes to the state.
1133-
current_view.dispatch({ effects: stateEffects });
1134-
// Resume autosave.
11351147
current_view.dispatch({
1136-
effects: autosaveCompartment.reconfigure(autosaveExtension),
1148+
effects: stateEffects,
1149+
annotations: noAutosaveAnnotation.of(true),
11371150
});
11381151
}
11391152
scroll_to_line(cursor_line, scroll_line);
@@ -1145,8 +1158,10 @@ export const scroll_to_line = (cursor_line?: number, scroll_line?: number) => {
11451158
return;
11461159
}
11471160

1148-
// Create a transaction to set the cursor and scroll position.
1149-
const dispatch_data: TransactionSpec = {};
1161+
// Create a transaction to set the cursor and scroll position. Avoid an autosave that sends updated cursor/scroll positions produced by this transaction.
1162+
const dispatch_data: TransactionSpec = {
1163+
annotations: noAutosaveAnnotation.of(true),
1164+
};
11501165
if (cursor_line !== undefined) {
11511166
// Translate the line numbers to a position.
11521167
const cursor_pos = current_view?.state.doc.line(cursor_line).from;
@@ -1169,7 +1184,6 @@ export const scroll_to_line = (cursor_line?: number, scroll_line?: number) => {
11691184
}
11701185

11711186
// Run it.
1172-
ignore_selection_change = true;
11731187
current_view?.dispatch(dispatch_data);
11741188
};
11751189

0 commit comments

Comments
 (0)