MediaWiki:Gadget-FDCodeEditor.js: Difference between revisions

From RECESSIM, A Reverse Engineering Community
Jump to navigation Jump to search
FDCodeEditor: debounce the synthetic preview keyup (upstream DOT preview stacks async renders)
FDCodeEditor: catch throws from attach() (then()s 2nd arg never saw them) + guard against double-attach
 
Line 57: Line 57:
var cm6 = require( 'ext.CodeMirror.v6.lib' );
var cm6 = require( 'ext.CodeMirror.v6.lib' );
var $textarea = $( textarea );
var $textarea = $( textarea );
var cm = new CodeMirror( textarea );
var previewTimer = null;
var previewTimer = null;
var cm;
// Never attach twice to the same textarea. CodeMirror's initialize() calls
// $.fn.textSelection( 'register', ... ), which THROWS "Another textSelection
// API was already registered" the second time - and because that throw
// happens inside a promise callback it is easy for it to disappear
// silently, leaving a plain textarea and no explanation.
if ( $textarea.data( 'fdCodeEditorAttached' ) ) {
return;
}
$textarea.data( 'fdCodeEditorAttached', true );
cm = new CodeMirror( textarea );


cm.initialize( [
cm.initialize( [
Line 112: Line 124:
attach( require, textarea );
attach( require, textarea );
} );
} );
}, function ( err ) {
} ).catch( function ( err ) {
// Never fail silently: a guard that bails without saying so is
// .catch(), NOT then()'s second argument: a rejection handler passed to
// indistinguishable from the gadget not being installed at all.
// then() does not see errors THROWN INSIDE its own success callback, so
mw.log.warn( '[FDCodeEditor] CodeMirror failed to load; the diagram ' +
// anything attach() threw used to vanish without trace - the editor just
// stayed a plain textarea with every module reporting "ready".
// Never fail silently.
mw.log.warn( '[FDCodeEditor] could not attach CodeMirror; the diagram ' +
'editor stays a plain textarea.', err );
'editor stays a plain textarea.', err );
} );
} );
} );
} );
}() );
}() );

Latest revision as of 18:44, 21 September 2026

/**
 * FDCodeEditor - a real code editor for the FlexDiagrams "edit diagram" screen.
 *
 * FlexDiagrams renders the Mermaid and DOT source as a bare <textarea>
 * (FD_SpecialEditDiagram.php:121,143), so there is no tab-to-indent, no line
 * numbers, no undo history and no find-in-editor. This gadget attaches MediaWiki's
 * CodeMirror 6 to that textarea via its documented, @stable
 * `new CodeMirror( textarea )` / `cm.initialize()` API.
 *
 * THE PART THAT IS NOT OBVIOUS - the sync bridge.
 *
 * CodeMirror hides the original textarea and copies content back to it ONLY on
 * `form.submit` (codemirror.js:429-441). FlexDiagrams has no form: it saves over
 * AJAX by reading `$( '.mermaidCode' ).val()`, drives its live preview from a
 * `keyup` handler, and arms its unsaved-changes warning from a `keypress` handler.
 * So without the updateListener below, the preview freezes, the leave-page warning
 * never arms, and SAVE WRITES THE PRE-EDIT TEXT - silent data loss. Keeping the
 * hidden textarea in sync instead means every existing FlexDiagrams code path keeps
 * working untouched, and nothing under extensions/FlexDiagrams needs patching -
 * which matters, because a FlexDiagrams upgrade silently reverts files there.
 *
 * `jquery.textSelection` is a REQUIRED dependency even though it looks unrelated:
 * ext.CodeMirror.v6 does not declare it, but codemirror.js:443 calls
 * `$( ... ).textSelection( 'register', ... )` unconditionally, so initialize()
 * throws without it. Upstream gets away with this because CodeMirror normally loads
 * alongside WikiEditor, which does declare it.
 *
 * Registered in [[MediaWiki:Gadgets-definition]]; styles in
 * [[MediaWiki:Gadget-FDCodeEditor.css]].
 */
( function () {
	'use strict';

	// Both diagram editors use identical markup, so one gadget covers both.
	var SELECTOR = 'textarea.mermaidCode, textarea.dotCode';

	// Mermaid's own documentation, and every diagram on this wiki, indents by two.
	var INDENT = '  ';

	// How long to sit on the synthetic `keyup` that drives the live preview.
	//
	// This is not cosmetic. FlexDiagrams' DOT preview has an upstream race:
	// ext.flexdiagrams.dot.js clears the pane with a synchronous $( '.dot' ).empty()
	// but renders through an ASYNCHRONOUS Viz.instance().then( ... appendChild ),
	// and unlike the Mermaid path it has no debounce of its own. So N keyups in
	// flight produce N stacked copies of the diagram after the last empty(). That
	// is reproducible on a plain textarea with this gadget disabled - it is not
	// ours - but we synthesise these events, so we should not make it worse.
	//
	// The textarea value is still updated SYNCHRONOUSLY below; only the preview
	// nudge is delayed. Hitting save mid-debounce therefore still saves the
	// current text.
	var PREVIEW_DEBOUNCE_MS = 250;

	function attach( require, textarea ) {
		var CodeMirror = require( 'ext.CodeMirror.v6' );
		var cm6 = require( 'ext.CodeMirror.v6.lib' );
		var $textarea = $( textarea );
		var previewTimer = null;
		var cm;

		// Never attach twice to the same textarea. CodeMirror's initialize() calls
		// $.fn.textSelection( 'register', ... ), which THROWS "Another textSelection
		// API was already registered" the second time - and because that throw
		// happens inside a promise callback it is easy for it to disappear
		// silently, leaving a plain textarea and no explanation.
		if ( $textarea.data( 'fdCodeEditorAttached' ) ) {
			return;
		}
		$textarea.data( 'fdCodeEditorAttached', true );

		cm = new CodeMirror( textarea );

		cm.initialize( [
			// Line numbers, undo/redo, bracket matching, find-and-replace,
			// multiple cursors, line wrapping.
			cm.defaultExtensions,

			// Tab-to-indent is deliberately absent from defaultExtensions, because
			// binding Tab traps keyboard navigation. Press Escape first, then Tab,
			// to move focus out of the editor.
			cm6.keymap.of( [ cm6.indentWithTab ] ),
			cm6.indentUnit.of( INDENT ),
			cm6.highlightActiveLine(),

			// The sync bridge - see the header comment. Without this, saving
			// silently writes the pre-edit text.
			cm6.EditorView.updateListener.of( function ( update ) {
				if ( !update.docChanged ) {
					return;
				}

				// Synchronous, every time: this is what save and the
				// unsaved-changes warning read.
				$textarea
					.val( update.state.doc.toString() )
					.trigger( 'keypress' );  // FD_editWarning unsaved-changes flag

				// Debounced: drives the live preview. See PREVIEW_DEBOUNCE_MS.
				clearTimeout( previewTimer );
				previewTimer = setTimeout( function () {
					$textarea.trigger( 'keyup' );
				}, PREVIEW_DEBOUNCE_MS );
			} )
		] );
	}

	// Driven from DOM-ready rather than mw.hook( 'wikipage.content' ), which has
	// been seen not firing on diagram pages.
	$( function () {
		var textareas = $( SELECTOR ).toArray();

		if ( !textareas.length ) {
			// Any page that is not the "edit diagram" UI.
			return;
		}

		mw.loader.using( [
			'ext.CodeMirror.v6',
			'ext.CodeMirror.v6.lib',
			'jquery.textSelection'
		] ).then( function ( require ) {
			textareas.forEach( function ( textarea ) {
				attach( require, textarea );
			} );
		} ).catch( function ( err ) {
			// .catch(), NOT then()'s second argument: a rejection handler passed to
			// then() does not see errors THROWN INSIDE its own success callback, so
			// anything attach() threw used to vanish without trace - the editor just
			// stayed a plain textarea with every module reporting "ready".
			// Never fail silently.
			mw.log.warn( '[FDCodeEditor] could not attach CodeMirror; the diagram ' +
				'editor stays a plain textarea.', err );
		} );
	} );
}() );