Skip to main content

max / makenotwork

3.9 KB · 111 lines History Blame Raw
1 /**
2 * Binds the described markdown field's Write/Preview chrome.
3 *
4 * `crate::quasi::rich_field` emits the wrapper, the two mode buttons and an
5 * empty preview pane; makeover-webview's rules keep all of it hidden until
6 * something sets `data-ready`, so a reader with no script gets the plain
7 * textarea and nothing that looks live and answers nothing. This is what sets
8 * it.
9 *
10 * The pane is filled from /api/preview/markdown, which runs the same pipeline
11 * that publishes (docengine over ammonia, plus media-host restriction). That is
12 * why `innerHTML` is safe here and why the preview matches what saving does;
13 * see src/routes/api/preview.rs.
14 *
15 * Runs once per wrapper. Rebinds after an HTMX swap via `htmx:after:settle`,
16 * which fires on the swap target rather than the source (CONTRIBUTING.md,
17 * "HTMX partial re-initialization").
18 */
19 (function () {
20 'use strict';
21
22 var ENDPOINT = '/api/preview/markdown';
23
24 function setMode(wrapper, mode) {
25 wrapper.dataset.mode = mode;
26 wrapper.querySelectorAll('[data-editor-mode]').forEach(function (button) {
27 var chosen = button.dataset.editorMode === mode;
28 button.classList.toggle('chosen', chosen);
29 button.setAttribute('aria-pressed', String(chosen));
30 });
31 }
32
33 function renderPreview(area, pane, state) {
34 var source = area.value;
35 // Nothing changed since the last look, so the pane already holds the
36 // answer. Also covers flipping back and forth while reading.
37 if (state.rendered === source) {
38 return;
39 }
40 if (!source.trim()) {
41 state.rendered = source;
42 pane.textContent = 'Nothing to preview yet.';
43 return;
44 }
45
46 pane.textContent = 'Rendering...';
47 fetch(ENDPOINT, {
48 method: 'POST',
49 headers: { 'Content-Type': 'application/json', ...csrfHeaders() },
50 body: JSON.stringify({ markdown: source })
51 })
52 .then(function (res) {
53 if (!res.ok) {
54 throw new Error('preview failed');
55 }
56 return res.json();
57 })
58 .then(function (data) {
59 state.rendered = source;
60 pane.innerHTML = data.html;
61 })
62 .catch(function () {
63 // Deliberately not cached: the next flip should try again.
64 pane.textContent = 'Preview unavailable. The content is unchanged.';
65 });
66 }
67
68 function bind(wrapper) {
69 // `data-ready` is both the CSS gate and the bound marker, which is what
70 // keeps a second pass over the same document from wiring twice.
71 if (wrapper.hasAttribute('data-ready')) {
72 return;
73 }
74 var area = wrapper.querySelector('textarea[data-format="markdown"]');
75 var pane = wrapper.querySelector('[data-editor-preview]');
76 if (!area || !pane) {
77 return;
78 }
79
80 var state = { rendered: null };
81
82 wrapper.querySelectorAll('[data-editor-mode]').forEach(function (button) {
83 button.addEventListener('click', function () {
84 var mode = button.dataset.editorMode;
85 setMode(wrapper, mode);
86 if (mode === 'preview') {
87 renderPreview(area, pane, state);
88 }
89 });
90 });
91
92 setMode(wrapper, 'write');
93 wrapper.setAttribute('data-ready', '');
94 }
95
96 function scan(root) {
97 (root || document).querySelectorAll('[data-format="markdown"]').forEach(function (node) {
98 // The mark sits on the wrapper and on the control it holds. Only
99 // the wrapper has chrome to bind.
100 if (node.tagName !== 'TEXTAREA') {
101 bind(node);
102 }
103 });
104 }
105
106 scan(document);
107 document.body.addEventListener('htmx:after:settle', function (e) {
108 scan(e.target);
109 });
110 })();
111