diff --git a/client/src/CodeChatEditor.mts b/client/src/CodeChatEditor.mts index f9d2995c..8fe67936 100644 --- a/client/src/CodeChatEditor.mts +++ b/client/src/CodeChatEditor.mts @@ -107,6 +107,7 @@ declare global { cursor_position?: CursorPosition, scroll_line?: number, ) => Promise; + do_debug: () => void; send_update: (_only_if_dirty: boolean) => Promise; scroll_to_line: ( cursor_position?: CursorPosition, @@ -136,6 +137,9 @@ let current_metadata: { const webSocketComm = () => parent.window.CodeChatEditorFramework.webSocketComm; +// This set when a TinyMCE `input` event occurs, which usually produces a duplicate `Dirty` event which should be ignored. +let ignoreDirty = false; + // True if the document is dirty (needs saving). let is_dirty = false; @@ -197,6 +201,14 @@ const open_lp = async ( // incorrect results. This text is the unmodified content sent from the IDE. let doc_content = ""; +// For debugging, allow the extension or server to run this routine by sending +// the appropriate message. +const do_debug = () => { + if (DEBUG_ENABLED) { + tinymce.activeEditor?.save({ format: "raw" }); + } +}; + // This function is called on page load to "load" a file. Before this point, the // server has already lexed the source file into code and doc blocks; this // function transforms the code and doc blocks into HTML and updates the current @@ -298,21 +310,18 @@ const _open_lp = async ( // [handling editor events](https://www.tiny.cloud/docs/tinymce/6/events/#handling-editor-events), // this is how to create a TinyMCE event handler. setup: (editor: Editor) => { - editor.on( - "dirty", - ( - event: EditorEvent< - Events.EditorEventMap["dirty"] - >, - ) => { - // Sometimes, `tinymce.activeEditor` is null - // (perhaps when it's not focused). Use the - // `event` data instead. - event.target.setDirty(false); + editor.on("Dirty", () => { + if (!ignoreDirty) { is_dirty = true; - startAutoUpdateTimer(); - }, - ); + } + startAutoUpdateTimer(); + }); + + editor.on("input", () => { + ignoreDirty = true; + is_dirty = true; + }); + // Send updates on cursor movement. editor.on( "SelectionChange", @@ -430,6 +439,8 @@ const save_lp = async ( // Tiny MCE div. Update the `doc_contents` to stay in sync with // the Server. doc_content = tinymce.activeEditor!.save({ format: "raw" }); + // The `save()` flushes any duplicate `Dirty` events. After this, following `Dirty` events are genuine. + ignoreDirty = false; ( code_mirror_diffable as { Plain: CodeMirror; @@ -683,12 +694,12 @@ const scroll_to_line = ( } }; -/*eslint-disable-next-line @typescript-eslint/no-explicit-any */ -export const console_log = (...args: any) => { - if (DEBUG_ENABLED) { - console.log(...args); - } -}; +// If debug is enabled, show the line number of the caller, not the current line +// number, in the log output. +export const console_log = DEBUG_ENABLED + ? console.log.bind(console) + : /*eslint-disable-next-line @typescript-eslint/no-explicit-any */ + (..._args: any) => undefined; // A global error handler: this is called on any uncaught exception. export const on_error = (event: Event) => { @@ -720,6 +731,7 @@ on_dom_content_loaded(async () => { window.addEventListener("error", on_error); window.CodeChatEditor = { + do_debug, open_lp, send_update, scroll_to_line, diff --git a/client/src/CodeChatEditorFramework.mts b/client/src/CodeChatEditorFramework.mts index b25354e5..8eac4f3c 100644 --- a/client/src/CodeChatEditorFramework.mts +++ b/client/src/CodeChatEditorFramework.mts @@ -283,6 +283,12 @@ class WebSocketComm { } case "Result": { + // If the result has the magic ID, then call a debug + // routine. + if (id === 1e6 && DEBUG_ENABLED) { + root_iframe!.contentWindow?.CodeChatEditor?.do_debug(); + break; + } // Cancel the timer for this message and remove it from // `pending_messages`. const pending_message = this.pending_messages[id]; @@ -407,7 +413,7 @@ class WebSocketComm { Result: result === undefined ? { Ok: "Void" } : { Err: result }, }; console_log( - `CodeChat Client: sending result id = ${id}, message = ${format_struct(message)}`, + `CodeChat Editor Client: sending result id = ${id}, message = ${format_struct(message)}`, ); // We can't simply call `send_message` because that function expects a // result message back from the server. diff --git a/client/src/CodeMirror-integration.mts b/client/src/CodeMirror-integration.mts index ef7215e7..60f50e23 100644 --- a/client/src/CodeMirror-integration.mts +++ b/client/src/CodeMirror-integration.mts @@ -115,6 +115,8 @@ import { CursorPosition } from "./rust-types/CursorPosition"; let current_view: EditorView; // This indicates that a call to `on_dirty` is scheduled, but hasn't run yet. let on_dirty_scheduled = false; +// This set when an `input` event occurs, which usually produces a duplicate `Dirty` event which should be ignored. +let ignoreDirty = false; // Options used when creating a `Decoration`. const decorationOptions = { @@ -473,11 +475,13 @@ class DocBlockWidget extends WidgetType { wrap.innerHTML = // This doc block's indent. TODO: allow paste, but must only allow // pasting whitespace. - `
${sanitize_html(this.indent)}
` + - // The contents of this doc block. - `
` + + // The contents of this doc block. Make it focusable by assigning a + // tab stop, but not editable (until it's replaced by the TinyMCE + // editor). + `
` + this.contents + "
"; // TODO: this is an async call. However, CodeMirror doesn't provide @@ -548,8 +552,8 @@ class DocBlockWidget extends WidgetType { codechat_body.insertBefore(tinymce_div, null); // Make TinyMCE invisible, since it's placed below the body of the // page. - tinymce_div.classList.add(CODECHAT_DOC_HIDDEN); - tinymce.activeEditor?.resetContent(); + tinymce.get(0)!.dom.addClass(tinymce_div, CODECHAT_DOC_HIDDEN); + tinymce.get(0)!.resetContent(); } } } @@ -638,6 +642,7 @@ const on_dirty = ( if (on_dirty_scheduled) { return; } + set_is_dirty(); on_dirty_scheduled = true; // Only run this after typesetting is done. @@ -669,6 +674,8 @@ const on_dirty = ( const contents = is_tinymce ? tinymce.activeEditor!.save({ format: "raw" }) : contents_div.innerHTML; + // The `save()` flushes any duplicate `Dirty` events. After this, following `Dirty` events are genuine. + ignoreDirty = false; await mathJaxTypeset(contents_div); current_view.dispatch({ effects: [ @@ -833,9 +840,9 @@ export const DocBlockPlugin = ViewPlugin.fromClass( // If the contents aren't editable, then the div // won't receive a `focusin` message (it instead // goes to a CodeMirror layer). - old_contents_div.contentEditable = "true"; + old_contents_div.tabIndex = 0; old_contents_div.innerHTML = - tinymce.activeEditor!.save({ format: "raw" }); + tinymce.activeEditor!.save(); tinymce_div.parentNode!.insertBefore( old_contents_div, null, @@ -848,11 +855,21 @@ export const DocBlockPlugin = ViewPlugin.fromClass( // div it will replace. target.insertBefore(tinymce_div, null); - tinymce.activeEditor!.setContent( + // Calling `setContent()` instead produces spurious + // `Dirty` events, observed after receiving a + // re-translation. In addition, `resetContent()` clears + // the undo history, which is appropriate given that + // edits to the previous doc block no longer apply here. + // TODO: Eventually, we need a way to chain TinyMCE's + // undo history with CodeMirror's undo history. + tinymce.activeEditor!.resetContent( contents_div.innerHTML, ); contents_div.remove(); - tinymce_div.classList.remove(CODECHAT_DOC_HIDDEN); + tinymce.activeEditor!.dom.removeClass( + tinymce_div, + CODECHAT_DOC_HIDDEN, + ); // The new div is now a TinyMCE editor. Retypeset this. await mathJaxTypeset(tinymce_div); @@ -1092,24 +1109,86 @@ export const CodeMirror_load = async ( setup: (editor: Editor) => { // See the // [docs](https://www.tiny.cloud/docs/tinymce/latest/events/#editor-core-events). - // This is triggered on edits (just as the `input` event), but - // also when applying formatting changes, inserting images, etc. - // that the above callback misses. + // After much experimentation, using both an `input` event + // (which suppresses the redundant `Dirty` event which follows + // it) combined with a `Dirty` event (which catches GUI + // interactions, undo, etc. which doesn't produce an `input` + // event). Just using `Dirty` produces one failing case: insert + // a character (dirty event), delete the character (no dirty + // event), left arrow (delayed dirty event from backspace). + // + // Here's a demonstration of the bug and its fix: + // + // ``` + // + // + // + // + // + // TinyMCE Dirty Event Test + // + // + //

TinyMCE Dirty Event Test

+ // + // + // + // + // ``` editor.on( "Dirty", (event: EditorEvent) => { // Sometimes, `tinymce.activeEditor` is null (perhaps // when it's not focused). Use the `event` data instead. - event.target.setDirty(false); // Get the div TinyMCE stores edits in. - const target_or_false = event.target.bodyElement; - if (target_or_false == null) { + const target = event.target.bodyElement; + if (target == null) { return; } - setTimeout(() => on_dirty(target_or_false)); + if (!ignoreDirty) { + on_dirty(target); + } }, ); + editor.on("input", (event: InputEvent) => { + const target = event.target as HTMLElement; + if (target == null) { + return; + } + ignoreDirty = true; + on_dirty(target); + }); + // Send updates on cursor movement. editor.on( "SelectionChange", diff --git a/server/src/translation.rs b/server/src/translation.rs index 892d2a4a..c467b044 100644 --- a/server/src/translation.rs +++ b/server/src/translation.rs @@ -1355,9 +1355,6 @@ fn compare_html( // paragraph

`, which minifies to `

Previous // paragraph

`. Fix up this difference. let raw_html = raw_html.replace("