PageSourceSearch

https://healthylifenetwork.com.au/assets/controllers/source-toggle_controller-vN8F6uH.js

js healthylifenetwork.com.au collected 2026-09-25 03:35:34 UTC 10,056 bytes, 260 lines download raw bytes

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, '&quot;') + '"';
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.