1import { Controller } from '@hotwired/stimulus'; 2 3/** 4 * Source toggle for the chapter editor. 5 * 6 * Swaps the ProseMirror rich-text editor for a CodeMirror HTML source 7 * editor and back. The chapter content is stored as ProseMirror JSON; 8 * "source" mode exposes the rendered HTML (prosemirror getHTML()) for 9 * direct editing and round-trips it back through prosemirror setHTML() 10 * so the existing Save path (editor#save â prosemirror.getContent()) 11 * keeps working unchanged. 12 * 13 * Sync strategy: CodeMirror â ProseMirror happens on CodeMirror blur 14 * and on toggle-back-to-rich. Clicking "Save Chapter" blurs CodeMirror 15 * first, so getContent() is always current when a save fires. No 16 * per-keystroke reparsing. 17 * 18 * Targets: 19 * - rich: the <div data-controller="prosemirror"> element 20 * - code: empty container CodeMirror mounts into 21 * - button: the toggle button (gets btn-active in source mode) 22 * - label: span whose text flips Source â Rich Text 23 */ 24export default class extends Controller { 25 static targets = ['rich', 'code', 'button', 'label']; 26 27 connect() { 28 this.mode = 'rich'; 29 this.cmView = null; 30 31 // Switching chapters while in source mode must NOT push the stale 32 // source HTML onto the freshly-loaded chapter. The hidden 33 // #chapter-select fires 'change' when the rail binder switches 34 // chapters; revert to rich without syncing when that happens. 35 this._chapterSelect = document.getElementById('chapter-select'); 36 if (this._chapterSelect) { 37 this._onChapterChange = () => this._forceRich(); 38 this._chapterSelect.addEventListener('change', this._onChapterChange); 39 } 40 41 // Listen for the toolbar's "Source" MenuItem dispatching this event. 42 // When fired from the ProseMirror menubar (which lives inside richTarget), 43 // the event bubbles up to this controller's element. 44 this._onToolbarToggle = () => this.toggle(); 45 this.element.addEventListener('prosemirror:toggle-source', this._onToolbarToggle); 46 47 // In rich mode on connect, ensure the external button row is hidden 48 // (it is only needed as a "back to Rich Text" button in source mode). 49 this._setExternalButtonVisible(false); 50 } 51 52 disconnect() { 53 if (this._chapterSelect && this._onChapterChange) { 54 this._chapterSelect.removeEventListener('change', this._onChapterChange); 55 } 56 if (this._onToolbarToggle) { 57 this.element.removeEventListener('prosemirror:toggle-source', this._onToolbarToggle); 58 } 59 this._destroyCm(); 60 } 61 62 /** 63 * Show or hide the external button row (the "Rich Text" back-button that 64 * lives outside the ProseMirror menubar in the template). In rich mode 65 * the menubar's "Source" item is used instead; in source mode the menubar 66 * is hidden so the external button must be visible to allow switching back. 67 */ 68 _setExternalButtonVisible(visible) { 69 if (this.hasButtonTarget) { 70 this.buttonTarget.closest('.flex')?.classList.toggle('hidden', !visible); 71 } 72 } 73 74 get pmController() { 75 if (!this.hasRichTarget) return null; 76 return this.application.getControllerForElementAndIdentifier(this.richTarget, 'prosemirror'); 77 } 78 79 async toggle() { 80 if (this.mode === 'rich') { 81 await this._toSource(); 82 } else { 83 this._toRich(); 84 } 85 } 86 87 async _toSource() { 88 const pm = this.pmController; 89 if (!pm) { 90 return; 91 } 92 93 // Source-toggle collision mitigation: close any open Edit-HTML popover 94 // before entering source mode. The popover holds a view-editable lock and 95 // captures stale from/to positions; leaving it open while switching modes 96 // would mean (a) the frozen editor is never unfrozen, and (b) Apply would 97 // fire against HTML that is no longer the active editing surface. 98 if (pm.closeHtmlEditPopover) { 99 pm.closeHtmlEditPopover(); 100 } 101 102 const html = this._prettyPrintHtml(pm.getHTML()); 103 const ok = await this._ensureCm(html); 104 if (!ok) { 105 return; // import failed;
105 stay in rich mode 106 } 107 108 this.richTarget.classList.add('hidden'); 109 this.codeTarget.classList.remove('hidden'); 110 this.mode = 'source'; 111 this._setButtonState(true); 112 // Show the external "Rich Text" back-button so the user can return 113 this._setExternalButtonVisible(true); 114 this.cmView.focus(); 115 } 116 117 _toRich() { 118 this._syncToPm(); 119 this._showRich(); 120 } 121 122 /** Revert to rich WITHOUT syncing â used on chapter switch. */ 123 _forceRich() { 124 if (this.mode !== 'source') { 125 return; 126 } 127 this._showRich(); 128 } 129 130 _showRich() { 131 if (this.hasCodeTarget) this.codeTarget.classList.add('hidden'); 132 if (this.hasRichTarget) this.richTarget.classList.remove('hidden'); 133 this.mode = 'rich'; 134 this._setButtonState(false); 135 // Hide the external button â the toolbar "Source" item is visible again 136 this._setExternalButtonVisible(false); 137 } 138 139 _setButtonState(active) { 140 if (this.hasLabelTarget) { 141 this.labelTarget.textContent = active ? 'Rich Text' : 'Source'; 142 } 143 if (this.hasButtonTarget) { 144 this.buttonTarget.classList.toggle('btn-active', active); 145 } 146 } 147 148 /** 149 * Pretty-print HTML for the source view: indent block-level structure so 150 * it's human-readable, WITHOUT changing what renders. Blocks containing only 151 * inline content stay on one line (innerHTML preserved byte-for-byte); blocks 152 * containing other blocks are expanded with indentation. The added whitespace 153 * lives only BETWEEN block tags, which ProseMirror collapses on the way back 154 * (setHTML), so display is unaffected. <pre> is preserved verbatim. 155 */ 156 _prettyPrintHtml(html) { 157 const BLOCK = new Set(['DIV','P','UL','OL','LI','H1','H2','H3','H4','H5','H6','BLOCKQUOTE','TABLE','THEAD','TBODY','TFOOT','TR','TD','TH','SECTION','ARTICLE','FIGURE','FIGCAPTION','HR','PRE','DL','DT','DD','ASIDE','MAIN','HEADER','FOOTER','NAV']); 158 let host; 159 try { 160 host = new DOMParser().parseFromString('<body>' + (html || '') + '</body>', 'text/html').body; 161 } catch (e) { 162 return html; 163 } 164 const openTag = (el) => { 165 let s = '<' + el.tagName.toLowerCase(); 166 for (const a of el.attributes) s += ' ' + a.name + '="' + String(a.value).replace(/"/g, '"') + '"'; 167 return s + '>'; 168 }; 169 const hasBlockChild = (el) => Array.from(el.children).some(c => BLOCK.has(c.tagName)); 170 const out = []; 171 const walk = (parent, depth) => { 172 const pad = ' '.repeat(depth); 173 parent.childNodes.forEach((node) => { 174 if (node.nodeType === 3) { // text 175 if (node.textContent.trim() === '') return; // drop whitespace-only between blocks 176 out.push(pad + node.textContent.trim()); 177 return; 178 } 179 if (node.nodeType !== 1) return; // skip comments etc. 180 const el = node; 181 const tag = el.tagName; 182 if (tag === 'PRE') { out.push(pad + el.outerHTML); return; } // verbatim 183 if (BLOCK.has(tag) && hasBlockChild(el)) { 184 out.push(pad + openTag(el)); 185 walk(el, depth + 1); 186 out.push(pad + '</' + tag.toLowerCase() + '>'); 187 } else { 188 out.push(pad + el.outerHTML); // inline-only block / inline element: one line 189 } 190 }); 191 }; 192 walk(host, 0); 193 const result = out.join('\n'); 194 return result.trim() === '' ? (html || '') : result; 195 } 196 197 /** Push the current CodeMirror HTML back into ProseMirror. */ 198 _syncToPm() { 199 const pm = this.pmController; 200 if (pm && this.cmView) { 201 pm.setHTML(this.cmView.state.doc.toString()); 202 } 203 } 204 205 async _ensureCm(html) { 206 if (this.cmView) { 207 this.cmView.dispatch({ 208 changes: { from: 0, to: this.cmView.state.doc.length, insert: html || '' }, 209 }); 210 return true; 211 } 212 213 try { 214 const [ 215 { EditorView, basicSetup },
216 { EditorState }, 217 { html: htmlLang }, 218 { oneDark }, 219 ] = await Promise.all([ 220 import('codemirror'), 221 import('@codemirror/state'), 222 import('@codemirror/lang-html'), 223 import('@codemirror/theme-one-dark'), 224 ]); 225 226 this.cmView = new EditorView({ 227 state: EditorState.create({ 228 doc: html || '', 229 extensions: [ 230 basicSetup, 231 htmlLang(), 232 oneDark, 233 EditorView.lineWrapping, 234 // Sync to ProseMirror when focus leaves the source editor 235 // (clicking Save blurs CodeMirror before the save fires). 236 EditorView.domEventHandlers({ 237 blur: () => { 238 if (this.mode === 'source') { 239 this._syncToPm(); 240 } 241 }, 242 }), 243 ], 244 }), 245 parent: this.codeTarget, 246 }); 247 return true; 248 } catch (e) { 249 console.warn('[source-toggle] CodeMirror failed to load:', e); 250 return false; 251 } 252 } 253 254 _destroyCm() { 255 if (this.cmView) { 256 this.cmView.destroy(); 257 this.cmView = null; 258 } 259 } 260}
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.