Skip to content

Commit fef5d42

Browse files
committed
Fix: respond to input, not dirty event in document-only mode.
correctly save and restore cursor location replace tinymce_singleton
1 parent fc73e6e commit fef5d42

File tree

2 files changed

+133
-126
lines changed

2 files changed

+133
-126
lines changed

client/src/CodeChatEditor.mts

Lines changed: 104 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -255,17 +255,19 @@ const _open_lp = async (
255255
});
256256
tinymce.activeEditor!.focus();
257257
} else {
258-
// Save and restore cursor/scroll location after an update per the
259-
// [docs](https://www.tiny.cloud/docs/tinymce/6/apis/tinymce.dom.bookmarkmanager).
260-
// However, this doesn't seem to work for the cursor location.
261-
// Perhaps when TinyMCE normalizes the document, this gets lost?
262-
const bm = tinymce.activeEditor!.selection.getBookmark();
258+
// Save the cursor location before the update, then restore it
259+
// afterwards, if TinyMCE has focus.
260+
const sel = tinymce.activeEditor!.hasFocus()
261+
? saveSelection()
262+
: undefined;
263263
doc_content =
264264
"Plain" in source
265265
? source.Plain.doc
266266
: apply_diff_str(doc_content, source.Diff.doc);
267267
tinymce.activeEditor!.setContent(doc_content);
268-
tinymce.activeEditor!.selection.moveToBookmark(bm);
268+
if (sel !== undefined) {
269+
restoreSelection(sel);
270+
}
269271
}
270272
mathJaxTypeset(codechat_body);
271273
scroll_to_line(cursor_line, scroll_line);
@@ -336,6 +338,102 @@ const save_lp = (is_dirty: boolean) => {
336338
return update;
337339
};
338340

341+
export const saveSelection = () => {
342+
// Changing the text inside TinyMCE causes it to loose a selection tied to a
343+
// specific node. So, instead store the selection as an array of indices in
344+
// the childNodes array of each element: for example, a given selection is
345+
// element 10 of the root TinyMCE div's children (selecting an ol tag),
346+
// element 5 of the ol's children (selecting the last li tag), element 0 of
347+
// the li's children (a text node where the actual click landed; the offset
348+
// in this node is placed in `selection_offset`.)
349+
const sel = window.getSelection();
350+
const selection_path = [];
351+
const selection_offset = sel?.anchorOffset;
352+
if (sel?.anchorNode) {
353+
// Find a path from the selection back to the containing div.
354+
for (
355+
let current_node = sel.anchorNode, is_first = true;
356+
// Continue until we find the div which contains the doc block
357+
// contents: either it's not an element (such as a div), ...
358+
current_node.nodeType !== Node.ELEMENT_NODE ||
359+
// or it's not the doc block contents div.
360+
(!(current_node as Element).classList.contains(
361+
"CodeChat-doc-contents",
362+
) &&
363+
// Sometimes, the parent of a custom node (`wc-mermaid`) skips the TinyMCE div and returns the overall div. I don't know why.
364+
!(current_node as Element).classList.contains("CodeChat-doc"));
365+
current_node = current_node.parentNode!, is_first = false
366+
) {
367+
// Store the index of this node in its' parent list of child
368+
// nodes/children. Use `childNodes` on the first iteration, since
369+
// the selection is often in a text node, which isn't in the
370+
// `parents` list. However, using `childNodes` all the time causes
371+
// trouble when reversing the selection -- sometimes, the
372+
// `childNodes` change based on whether text nodes (such as a
373+
// newline) are included are not after tinyMCE parses the content.
374+
const p = current_node.parentNode;
375+
// In case we go off the rails, give up if there are no more parents.
376+
if (p === null) {
377+
return {
378+
selection_path: [],
379+
selection_offset: 0,
380+
};
381+
}
382+
selection_path.unshift(
383+
Array.prototype.indexOf.call(
384+
is_first ? p.childNodes : p.children,
385+
current_node,
386+
),
387+
);
388+
}
389+
}
390+
return { selection_path, selection_offset };
391+
};
392+
393+
// Restore the selection produced by `saveSelection` to the active TinyMCE
394+
// instance.
395+
export const restoreSelection = ({
396+
selection_path,
397+
selection_offset,
398+
}: {
399+
selection_path: number[];
400+
selection_offset?: number;
401+
}) => {
402+
// Copy the selection over to TinyMCE by indexing the selection path to find
403+
// the selected node.
404+
if (selection_path.length && typeof selection_offset === "number") {
405+
let selection_node = tinymce.activeEditor!.getContentAreaContainer();
406+
for (
407+
;
408+
selection_path.length &&
409+
// If something goes wrong, bail out instead of producing
410+
// exceptions.
411+
selection_node !== undefined;
412+
selection_node =
413+
// As before, use the more-consistent `children` except for the
414+
// last element, where we might be selecting a `text` node.
415+
(
416+
selection_path.length > 1
417+
? selection_node.children
418+
: selection_node.childNodes
419+
)[selection_path.shift()!]! as HTMLElement
420+
);
421+
// Exit on failure.
422+
if (selection_node === undefined) {
423+
return;
424+
}
425+
// Use that to set the selection.
426+
tinymce.activeEditor!.selection.setCursorLocation(
427+
selection_node,
428+
// In case of edits, avoid an offset past the end of the node.
429+
Math.min(
430+
selection_offset,
431+
selection_node.nodeValue?.length ?? Number.MAX_VALUE,
432+
),
433+
);
434+
}
435+
};
436+
339437
// Per
340438
// [MDN](https://developer.mozilla.org/en-US/docs/Web/API/Navigator/platform#examples),
341439
// here's the least bad way to choose between the control key and the command

client/src/CodeMirror-integration.mts

Lines changed: 29 additions & 120 deletions
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,12 @@ import { yaml } from "@codemirror/lang-yaml";
8585
import { Editor, init, tinymce } from "./tinymce-config.mjs";
8686

8787
// ### Local
88-
import { set_is_dirty, startAutosaveTimer } from "./CodeChatEditor.mjs";
88+
import {
89+
set_is_dirty,
90+
startAutosaveTimer,
91+
saveSelection,
92+
restoreSelection,
93+
} from "./CodeChatEditor.mjs";
8994
import {
9095
CodeChatForWeb,
9196
CodeMirror,
@@ -100,7 +105,6 @@ import { show_toast } from "./show_toast.mjs";
100105
// Globals
101106
// -----------------------------------------------------------------------------
102107
let current_view: EditorView;
103-
let tinymce_singleton: Editor | undefined;
104108
// This indicates that a call to `on_dirty` is scheduled, but hasn't run yet.
105109
let on_dirty_scheduled = false;
106110

@@ -461,10 +465,10 @@ class DocBlockWidget extends WidgetType {
461465
if (is_tinymce) {
462466
// Save the cursor location before the update, then restore it
463467
// afterwards, if TinyMCE has focus.
464-
const sel = tinymce_singleton!.hasFocus()
468+
const sel = tinymce.activeEditor!.hasFocus()
465469
? saveSelection()
466470
: undefined;
467-
tinymce_singleton!.setContent(this.contents);
471+
tinymce.activeEditor!.setContent(this.contents);
468472
if (sel !== undefined) {
469473
restoreSelection(sel);
470474
}
@@ -504,102 +508,6 @@ class DocBlockWidget extends WidgetType {
504508
}
505509
}
506510

507-
const saveSelection = () => {
508-
// Changing the text inside TinyMCE causes it to loose a selection tied to a
509-
// specific node. So, instead store the selection as an array of indices in
510-
// the childNodes array of each element: for example, a given selection is
511-
// element 10 of the root TinyMCE div's children (selecting an ol tag),
512-
// element 5 of the ol's children (selecting the last li tag), element 0 of
513-
// the li's children (a text node where the actual click landed; the offset
514-
// in this node is placed in `selection_offset`.)
515-
const sel = window.getSelection();
516-
const selection_path = [];
517-
const selection_offset = sel?.anchorOffset;
518-
if (sel?.anchorNode) {
519-
// Find a path from the selection back to the containing div.
520-
for (
521-
let current_node = sel.anchorNode, is_first = true;
522-
// Continue until we find the div which contains the doc block
523-
// contents: either it's not an element (such as a div), ...
524-
current_node.nodeType !== Node.ELEMENT_NODE ||
525-
// or it's not the doc block contents div.
526-
(!(current_node as Element).classList.contains(
527-
"CodeChat-doc-contents",
528-
) &&
529-
// Sometimes, the parent of a custom node (`wc-mermaid`) skips the TinyMCE div and returns the overall div. I don't know why.
530-
!(current_node as Element).classList.contains("CodeChat-doc"));
531-
current_node = current_node.parentNode!, is_first = false
532-
) {
533-
// Store the index of this node in its' parent list of child
534-
// nodes/children. Use `childNodes` on the first iteration, since
535-
// the selection is often in a text node, which isn't in the
536-
// `parents` list. However, using `childNodes` all the time causes
537-
// trouble when reversing the selection -- sometimes, the
538-
// `childNodes` change based on whether text nodes (such as a
539-
// newline) are included are not after tinyMCE parses the content.
540-
const p = current_node.parentNode;
541-
// In case we go off the rails, give up if there are no more parents.
542-
if (p === null) {
543-
return {
544-
selection_path: [],
545-
selection_offset: 0,
546-
};
547-
}
548-
selection_path.unshift(
549-
Array.prototype.indexOf.call(
550-
is_first ? p.childNodes : p.children,
551-
current_node,
552-
),
553-
);
554-
}
555-
}
556-
return { selection_path, selection_offset };
557-
};
558-
559-
// Restore the selection produced by `saveSelection` to the active TinyMCE
560-
// instance.
561-
const restoreSelection = ({
562-
selection_path,
563-
selection_offset,
564-
}: {
565-
selection_path: number[];
566-
selection_offset?: number;
567-
}) => {
568-
// Copy the selection over to TinyMCE by indexing the selection path to find
569-
// the selected node.
570-
if (selection_path.length && typeof selection_offset === "number") {
571-
let selection_node = tinymce_singleton!.getContentAreaContainer();
572-
for (
573-
;
574-
selection_path.length &&
575-
// If something goes wrong, bail out instead of producing
576-
// exceptions.
577-
selection_node !== undefined;
578-
selection_node =
579-
// As before, use the more-consistent `children` except for the
580-
// last element, where we might be selecting a `text` node.
581-
(
582-
selection_path.length > 1
583-
? selection_node.children
584-
: selection_node.childNodes
585-
)[selection_path.shift()!]! as HTMLElement
586-
);
587-
// Exit on failure.
588-
if (selection_node === undefined) {
589-
return;
590-
}
591-
// Use that to set the selection.
592-
tinymce_singleton!.selection.setCursorLocation(
593-
selection_node,
594-
// In case of edits, avoid an offset past the end of the node.
595-
Math.min(
596-
selection_offset,
597-
selection_node.nodeValue?.length ?? Number.MAX_VALUE,
598-
),
599-
);
600-
}
601-
};
602-
603511
// Typeset the provided node; taken from the
604512
// [MathJax docs](https://docs.mathjax.org/en/latest/web/typeset.html#handling-asynchronous-typesetting).
605513
export const mathJaxTypeset = async (
@@ -700,7 +608,7 @@ const on_dirty = (
700608
// the actual div. But I don't know how.
701609
mathJaxUnTypeset(contents_div);
702610
const contents = is_tinymce
703-
? tinymce_singleton!.save()
611+
? tinymce.activeEditor!.save()
704612
: contents_div.innerHTML;
705613
await mathJaxTypeset(contents_div);
706614
current_view.dispatch({
@@ -847,7 +755,7 @@ export const DocBlockPlugin = ViewPlugin.fromClass(
847755
old_contents_div.className = "CodeChat-doc-contents";
848756
old_contents_div.contentEditable = "true";
849757
old_contents_div.replaceChildren(
850-
...tinymce_singleton!.getContentAreaContainer()
758+
...tinymce.activeEditor!.getContentAreaContainer()
851759
.childNodes,
852760
);
853761
tinymce_div.parentNode!.insertBefore(
@@ -863,15 +771,17 @@ export const DocBlockPlugin = ViewPlugin.fromClass(
863771

864772
// Setting the content makes TinyMCE consider it dirty
865773
// -- ignore this "dirty" event.
866-
tinymce_singleton!.setContent(contents_div.innerHTML);
774+
tinymce.activeEditor!.setContent(
775+
contents_div.innerHTML,
776+
);
867777
contents_div.remove();
868778
// The new div is now a TinyMCE editor. Retypeset this.
869779
await mathJaxTypeset(tinymce_div);
870780

871781
// This process causes TinyMCE to lose focus. Restore
872782
// that. However, this causes TinyMCE to lose the
873783
// selection, which the next bit of code then restores.
874-
tinymce_singleton!.focus(false);
784+
tinymce.activeEditor!.focus(false);
875785

876786
// Copy the selection over to TinyMCE by indexing the
877787
// selection path to find the selected node.
@@ -1091,22 +1001,21 @@ export const CodeMirror_load = async (
10911001
state,
10921002
scrollTo: scrollSnapshot,
10931003
});
1094-
tinymce_singleton = (
1095-
await init({
1096-
selector: "#TinyMCE-inst",
1097-
setup: (editor: Editor) => {
1098-
// See the
1099-
// [docs](https://www.tiny.cloud/docs/tinymce/latest/events/#editor-core-events).
1100-
editor.on("input", (event: Event) => {
1101-
const target_or_false = event.target as HTMLElement;
1102-
if (target_or_false == null) {
1103-
return;
1104-
}
1105-
setTimeout(() => on_dirty(target_or_false));
1106-
});
1107-
},
1108-
})
1109-
)[0];
1004+
1005+
await init({
1006+
selector: "#TinyMCE-inst",
1007+
setup: (editor: Editor) => {
1008+
// See the
1009+
// [docs](https://www.tiny.cloud/docs/tinymce/latest/events/#editor-core-events).
1010+
editor.on("input", (event: Event) => {
1011+
const target_or_false = event.target as HTMLElement;
1012+
if (target_or_false == null) {
1013+
return;
1014+
}
1015+
setTimeout(() => on_dirty(target_or_false));
1016+
});
1017+
},
1018+
});
11101019
} else {
11111020
// This contains a diff, instead of plain text. Apply the text diff.
11121021
//

0 commit comments

Comments
 (0)