Skip to content

Commit fde9b8e

Browse files
committed
Fix: correctly break update cycle.
1 parent ed662cf commit fde9b8e

File tree

1 file changed

+34
-22
lines changed

1 file changed

+34
-22
lines changed

client/src/CodeMirror-integration.mts

Lines changed: 34 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ import {
6262
} from "@codemirror/view";
6363
import {
6464
ChangeDesc,
65+
Compartment,
6566
EditorState,
6667
Extension,
6768
StateField,
@@ -101,14 +102,22 @@ import { show_toast } from "./show_toast.mjs";
101102
// -----------------------------------------------------------------------------
102103
let current_view: EditorView;
103104
let tinymce_singleton: Editor | undefined;
104-
// When true, don't update on the next call to `on_dirty`. See that function for
105-
// more info.
106-
let ignore_next_dirty = false;
105+
// When true, this is an IDE edit; when false, it's due to a user edit.
106+
//
107+
// There's an inherent cycle produced by edits; this variable breaks the cycle. There are two paths through the cycle: a user edit and an IDE edit. The cycle must be broken just before it loops in both cases; this variable therefore specifies where to break the cycle. The sequence is:
108+
//
109+
// 1. The user performs an edit **or** `DocBlockWidget.updateDom()` is called; if `is_ide_change` is false, this function returns; otherwise, it performs an edit.
110+
// 2. The `addEventListener("input")` for an indent or `TinyMCE.editor.on("Dirty")` callback is triggered, invoking `on_dirty()`.
111+
// 3. An IDE edit dispatches an `add/delete/updateDocBlock` **or** in `on_dirty()`, if `is_ide_change` is true, `on_dirty()` sets it to false then returns. Otherwise, `on_dirty()` dispatches `updateDocBlock`.
112+
// 5. This transaction invokes `docBlockField.update()`, which creates a `new DocBlockWidget()`. This loops back to step 1.
113+
let is_ide_change = false;
107114
// This indicates that a call to `on_dirty` is scheduled, but hasn't run yet.
108115
let on_dirty_scheduled = false;
109116
// True to ignore the next text selection change, since updates to the cursor or
110117
// scroll position from the Client trigged this change.
111118
let ignore_selection_change = false;
119+
// The compartment used to enable and disable the autosave extension.
120+
const autosaveCompartment = new Compartment();
112121

113122
// Options used when creating a `Decoration`.
114123
const decorationOptions = {
@@ -417,9 +426,9 @@ class DocBlockWidget extends WidgetType {
417426

418427
eq(other: DocBlockWidget) {
419428
return (
420-
other.indent == this.indent &&
421-
other.delimiter == this.delimiter &&
422-
other.contents == this.contents
429+
other.indent === this.indent &&
430+
other.delimiter === this.delimiter &&
431+
other.contents === this.contents
423432
);
424433
}
425434

@@ -448,11 +457,8 @@ class DocBlockWidget extends WidgetType {
448457
// "Update a DOM element created by a widget of the same type (but
449458
// different, non-eq content) to reflect this widget."
450459
updateDOM(dom: HTMLElement, view: EditorView): boolean {
451-
// See if this update was produced by a change in TinyMCE text, which
452-
// means the DOM is already updated.
453-
if ((dom as any).update_complete === true) {
454-
// Yes, so clear this update flag before returning.
455-
delete (dom as any).update_complete;
460+
// If this change was produced by a user edit, then the DOM was already updated. Stop here.
461+
if (!is_ide_change) {
456462
return true;
457463
}
458464
(dom.childNodes[0] as HTMLDivElement).innerHTML = this.indent;
@@ -462,9 +468,11 @@ class DocBlockWidget extends WidgetType {
462468
const [contents_div, is_tinymce] = get_contents(dom);
463469
window.MathJax.typesetClear(contents_div);
464470
if (is_tinymce) {
465-
ignore_next_dirty = true;
471+
// Save the cursor location before the update, then restore it afterwards.
472+
const bm = tinymce.activeEditor!.selection.getBookmark();
466473
tinymce_singleton!.setContent(this.contents);
467474
tinymce_singleton!.save();
475+
tinymce.activeEditor!.selection.moveToBookmark(bm);
468476
} else {
469477
contents_div.innerHTML = this.contents;
470478
}
@@ -568,14 +576,16 @@ const on_dirty = (
568576
// The div that's dirty. It must be a child of the doc block div.
569577
event_target: HTMLElement,
570578
) => {
571-
if (ignore_next_dirty) {
572-
ignore_next_dirty = false;
579+
// If this change was produced by an IDE edit, then the underlying state is updated. Stop here.
580+
if (is_ide_change) {
581+
is_ide_change = false;
573582
return;
574583
}
575584

576585
if (on_dirty_scheduled) {
577586
return;
578587
}
588+
on_dirty_scheduled = true;
579589

580590
// Only run this after typesetting is done.
581591
window.MathJax.whenReady(() => {
@@ -605,11 +615,6 @@ const on_dirty = (
605615
: contents_div.innerHTML;
606616
// Although this is async, nothing following this call depends on its completion.
607617
mathJaxTypeset(contents_div);
608-
// Sorta ugly hack: TinyMCE stores its data in the DOM. CodeMirror stores
609-
// state in external structs. We need to update the CodeMirror state, but
610-
// not overwrite the DOM with this "new" state, since the DOM is already
611-
// updated. So, signal that this "update" is already done.
612-
(target as any).update_complete = true;
613618
let effects: StateEffect<updateDocBlockType>[] = [
614619
updateDocBlock.of({
615620
from,
@@ -817,7 +822,7 @@ export const DocBlockPlugin = ViewPlugin.fromClass(
817822

818823
// Setting the content makes TinyMCE consider it dirty
819824
// -- ignore this "dirty" event.
820-
ignore_next_dirty = true;
825+
is_ide_change = true;
821826
tinymce_singleton!.setContent(contents_div.innerHTML);
822827
tinymce_singleton!.save();
823828
contents_div.remove();
@@ -1039,7 +1044,7 @@ export const CodeMirror_load = async (
10391044
parser,
10401045
basicSetup,
10411046
EditorView.lineWrapping,
1042-
autosaveExtension,
1047+
autosaveCompartment.of(autosaveExtension),
10431048
// Make tab an indent per the
10441049
// [docs](https://codemirror.net/examples/tab/). TODO:
10451050
// document a way to escape the tab key per the same docs.
@@ -1080,12 +1085,14 @@ export const CodeMirror_load = async (
10801085
return false;
10811086
}
10821087
// For some reason, calling this directly omits the most recent edit. Earlier versions of the code didn't have this problem. ???
1083-
setTimeout(() => on_dirty(target_or_false));
1088+
on_dirty(target_or_false);
10841089
});
10851090
},
10861091
})
10871092
)[0];
10881093
} else {
1094+
// Disable autosave when performing these updates.
1095+
current_view.dispatch({ effects: autosaveCompartment.reconfigure([]) });
10891096
// This contains a diff, instead of plain text. Apply the text diff.
10901097
//
10911098
// First, apply just the text edits. Use an annotation so that the doc
@@ -1121,7 +1128,12 @@ export const CodeMirror_load = async (
11211128
}
11221129
}
11231130
// Update the view with these changes to the state.
1131+
is_ide_change = true;
11241132
current_view.dispatch({ effects: stateEffects });
1133+
// Resume autosave.
1134+
current_view.dispatch({
1135+
effects: autosaveCompartment.reconfigure(autosaveExtension),
1136+
});
11251137
}
11261138
scroll_to_line(cursor_line, scroll_line);
11271139
};

0 commit comments

Comments
 (0)