orz-mdhtml 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,113 @@
1
+ # orz-mdhtml
2
+
3
+ Turn a Markdown file into a **single, self-contained `.md.html`** — a page that
4
+ reads like a normal themed website but is *quietly editable*. Powered by
5
+ [orz-markdown](https://www.npmjs.com/package/orz-markdown).
6
+
7
+ One file. Open it in a browser to read. Click the pencil to edit it. Download
8
+ your own copy to annotate. Nothing to install for the reader.
9
+
10
+ ## What a `.md.html` does
11
+
12
+ 1. **Reads like a webpage.** The rendered document fills an isolated `<iframe>`
13
+ (full theme isolation, true WYSIWYG). The only chrome is a small floating
14
+ toolbar in the corner — a reader barely notices it.
15
+ 2. **Edits in place.** Click the pencil for a minimal editor: a **CodeMirror**
16
+ source pane beside a **live preview** that updates *incrementally*
17
+ (only changed nodes repaint — scroll preserved, no flicker/reload).
18
+ 3. **Themes.** Switch among orz-markdown's built-in themes live; the chrome,
19
+ editor, and preview all follow the theme's light/dark scheme.
20
+ 4. **Reader comfort.** Adjust font size (A− / A+) for comfortable reading;
21
+ the choice is remembered.
22
+ 5. **Copy as Markdown.** Selecting and copying rendered content yields Markdown
23
+ source, not HTML (a table copies as a Markdown table, a TOC as its heading
24
+ links, etc.).
25
+ 6. **Keep your own copy.** **Export** downloads a local `.md.html`. On a local
26
+ file, **Save** writes back in place (Chromium); on a published page it
27
+ guides you to download a copy instead.
28
+
29
+ The Markdown source is embedded in the file (`<script type="text/markdown">`)
30
+ as the single source of truth; Save/Export re-serialize the whole document.
31
+
32
+ > "Self-contained" means *works as one file*, not *zero network*. The renderer
33
+ > is embedded, but themes and editor libraries (KaTeX, highlight.js, Mermaid,
34
+ > CodeMirror, Split.js, morphdom) load from CDN, so **viewing needs internet**.
35
+ > Editor libraries are lazy-loaded on first edit, so reading stays light.
36
+
37
+ ## Install / generate
38
+
39
+ Requires Node 18+. Until this package is published to npm, use it from a clone:
40
+
41
+ ```bash
42
+ git clone https://github.com/wangyu16/orz-mdhtml.git
43
+ cd orz-mdhtml
44
+ npm install
45
+ npm run bundle # build dist/orzmd.browser.js (the in-browser renderer)
46
+ npm run gen -- path/to/doc.md # → path/to/doc.md.html
47
+ open path/to/doc.md.html
48
+ ```
49
+
50
+ ### CLI options
51
+
52
+ ```
53
+ orz-mdhtml <input.md> [options]
54
+
55
+ -o, --out <file> output path (default: <input>.md.html)
56
+ --theme <name> default theme id (default: light-academic-1)
57
+ --inline embed the renderer bundle in the file (default; offline-capable renderer)
58
+ --cdn reference the renderer from jsDelivr (needs orz-mdhtml-browser published)
59
+ --title <text> document <title> (default: input filename)
60
+ ```
61
+
62
+ Themes: `light-academic-1/2`, `light-neat-1/2`, `light-playful-1/2`,
63
+ `beige-decent-1/2`, `dark-elegant-1/2`. (Readers can switch live in the editor.)
64
+
65
+ ## Browser support
66
+
67
+ | Feature | Support |
68
+ |---|---|
69
+ | Read, theme switch, font size, copy-as-markdown, export (download) | All modern browsers |
70
+ | Live editing (CodeMirror, incremental preview, Split.js) | All modern browsers |
71
+ | **Save in place** (File System Access API) | Chromium (Chrome/Edge); others fall back to Export/download |
72
+
73
+ ## For teaching
74
+
75
+ The orz-markdown family targets open-source publishing, especially teaching:
76
+
77
+ - **Teachers** author in Markdown, generate a `.md.html`, and serve it as a web
78
+ page (or hand out the file). They edit their local source in place.
79
+ - **Students** open the page, adjust the font, read, then **Download** their own
80
+ copy to add personal notes — no account, no tooling.
81
+
82
+ ## How it works
83
+
84
+ ```
85
+ src/browser-entry.ts exposes window.orzmd.render(); also stamps data-src-line
86
+ build/bundle.ts esbuild: orz-markdown + deps -> dist/orzmd.browser.js (IIFE)
87
+ assets/app.js in-file runtime: modes, live preview, themes, font, save, export, scroll-sync
88
+ src/template.ts builds the .md.html shell (chrome + iframe + embedded source)
89
+ src/cli.ts CLI: foo.md -> foo.md.html
90
+ orz-mdhtml-skills/ agent skill describing how to create & use .md.html
91
+ ```
92
+
93
+ The preview lives in an iframe so the document theme can't touch the editor
94
+ chrome, and the preview is exactly what gets exported. Incremental updates use
95
+ morphdom; editor↔preview scroll-sync maps CodeMirror lines to `data-src-line`
96
+ anchors (toggleable). Save is *self-reproducing*: it serializes the outer
97
+ document with the latest embedded source.
98
+
99
+ Requires `orz-markdown` ≥ 1.2.0 for copy-as-markdown; ≥ 1.2.1 for the
100
+ whole-table/blockquote copy fix.
101
+
102
+ ## Roadmap
103
+
104
+ - [x] Document-first UI: read / edit, iframe preview, incremental live updates
105
+ - [x] Theme picker, reader font size, export, scroll-sync toggle
106
+ - [x] copy-as-markdown via orz-markdown core
107
+ - [ ] Publish `orz-mdhtml` (the CLI) and `orz-mdhtml-browser` (CDN bundle) to npm
108
+ - [ ] Optional fully-offline build (inline themes + editor libs)
109
+ - [ ] Mermaid/KaTeX live re-render parity in the editor
110
+
111
+ ## License
112
+
113
+ MIT
package/assets/app.js ADDED
@@ -0,0 +1,555 @@
1
+ /* orz-mdhtml in-file runtime.
2
+ *
3
+ * A document that is quietly editable. By default it shows the rendered
4
+ * Markdown in an <iframe> (full theme isolation) with one small edit button.
5
+ * Editing reveals a CodeMirror editor beside a live, incrementally-updated
6
+ * preview. The embedded <script type="text/markdown"> is the single source of
7
+ * truth; Save re-serializes the outer document. Editor libraries are
8
+ * lazy-loaded on first edit so reading stays lightweight.
9
+ *
10
+ * Config arrives on window.__ORZ_MDHTML__ (themes, frame/editor CDN assets,
11
+ * the orz-markdown browser runtime).
12
+ */
13
+ (function () {
14
+ 'use strict';
15
+
16
+ var CFG = window.__ORZ_MDHTML__ || {};
17
+ var root = document.documentElement;
18
+ var frame = document.getElementById('orz-frame');
19
+ var textarea = document.getElementById('orz-textarea');
20
+ var themeSelect = document.getElementById('orz-theme');
21
+
22
+ var currentTheme = CFG.defaultTheme;
23
+ var cm = null;
24
+ var libsLoading = null;
25
+ var splitInstance = null;
26
+ var dirty = false;
27
+ var fileHandle = null;
28
+ var fontScale = 1;
29
+
30
+ // ---- source helpers ------------------------------------------------------
31
+ function unescapeSource(s) { return s.replace(/<\\\/(script)/gi, '</$1'); }
32
+ function escapeSource(s) { return s.replace(/<\/(script)/gi, '<\\/$1'); }
33
+ function embeddedSource() {
34
+ var el = document.getElementById('orz-src');
35
+ return el ? unescapeSource(el.textContent || '').replace(/^\n/, '').replace(/\n$/, '') : '';
36
+ }
37
+ function currentSource() {
38
+ if (cm) return cm.getValue();
39
+ if (textarea && textarea.value) return textarea.value;
40
+ return embeddedSource();
41
+ }
42
+
43
+ function themeById(id) {
44
+ for (var i = 0; i < CFG.themes.length; i++) if (CFG.themes[i].id === id) return CFG.themes[i];
45
+ return CFG.themes[0];
46
+ }
47
+
48
+ // ---- preview iframe ------------------------------------------------------
49
+ // Inline scripts written into the iframe must not contain a literal
50
+ // </script>; escape defensively (the orz runtime is JS, so <\/script is fine).
51
+ function guard(js) { return String(js).replace(/<\/(script)/gi, '<\\/$1'); }
52
+
53
+ function frameHtml(theme) {
54
+ var f = CFG.frame;
55
+ var hljsCss = theme.scheme === 'dark' ? f.hljsDarkCss : f.hljsLightCss;
56
+ return '<!DOCTYPE html><html lang="en"><head><meta charset="utf-8">'
57
+ + '<meta name="viewport" content="width=device-width, initial-scale=1">'
58
+ + '<link id="orz-theme-css" rel="stylesheet" href="' + theme.href + '">'
59
+ + '<link rel="stylesheet" href="' + f.katexCss + '">'
60
+ + '<link id="orz-hljs-css" rel="stylesheet" href="' + hljsCss + '">'
61
+ // bottom padding so the last content clears the floating reader tools
62
+ + '<style>html,body{margin:0}body{padding-bottom:84px}</style></head><body>'
63
+ + '<article class="markdown-body" id="orz-doc"></article>'
64
+ + '<script src="' + f.hljsJs + '"><\/script>'
65
+ + '<script src="' + f.mermaidJs + '"><\/script>'
66
+ + '<script>try{mermaid.initialize({startOnLoad:false})}catch(e){}<\/script>'
67
+ + '<script>' + guard(CFG.runtime) + '<\/script>'
68
+ + '<script>window.__orzEnhance=function(){'
69
+ + 'try{if(window.hljs){document.querySelectorAll("#orz-doc pre code:not(.hljs)").forEach(function(b){window.hljs.highlightElement(b)})}}catch(e){}'
70
+ + 'try{if(window.mermaid){window.mermaid.run({querySelector:"#orz-doc .mermaid:not([data-processed])"})}}catch(e){}'
71
+ + '};<\/script></body></html>';
72
+ }
73
+
74
+ function frameDoc() { return frame.contentDocument || (frame.contentWindow && frame.contentWindow.document); }
75
+
76
+ function buildFrame(theme) {
77
+ var doc = frameDoc();
78
+ doc.open(); doc.write(frameHtml(theme)); doc.close();
79
+ }
80
+
81
+ function applyThemeToFrame(theme) {
82
+ var doc = frameDoc(); if (!doc) return;
83
+ var link = doc.getElementById('orz-theme-css'); if (link) link.href = theme.href;
84
+ var hl = doc.getElementById('orz-hljs-css');
85
+ if (hl) hl.href = theme.scheme === 'dark' ? CFG.frame.hljsDarkCss : CFG.frame.hljsLightCss;
86
+ }
87
+
88
+ function renderHtml(src) {
89
+ return (window.orzmd && window.orzmd.render) ? window.orzmd.render(src) : '';
90
+ }
91
+
92
+ function enhance() {
93
+ var w = frame.contentWindow;
94
+ if (w && typeof w.__orzEnhance === 'function') { try { w.__orzEnhance(); } catch (e) {} }
95
+ }
96
+ // CDN libs inside the iframe load async — retry enhance a few times.
97
+ function enhanceSoon() { enhance(); setTimeout(enhance, 150); setTimeout(enhance, 600); setTimeout(enhance, 1500); }
98
+
99
+ function firstPaint() {
100
+ var doc = frameDoc();
101
+ var container = doc.getElementById('orz-doc');
102
+ if (container) container.innerHTML = renderHtml(currentSource());
103
+ enhanceSoon();
104
+ scheduleAnchors();
105
+ }
106
+
107
+ function patch(src) {
108
+ var doc = frameDoc(); if (!doc) return;
109
+ var container = doc.getElementById('orz-doc'); if (!container) return;
110
+ var next = doc.createElement('article');
111
+ next.className = 'markdown-body'; next.id = 'orz-doc';
112
+ next.innerHTML = renderHtml(src);
113
+ if (window.morphdom) {
114
+ window.morphdom(container, next, {
115
+ onBeforeElUpdated: function (fromEl, toEl) {
116
+ if (fromEl.isEqualNode && fromEl.isEqualNode(toEl)) return false;
117
+ // keep already-rendered mermaid when its source is unchanged
118
+ if (fromEl.classList && fromEl.classList.contains('mermaid') &&
119
+ fromEl.getAttribute('data-md') === toEl.getAttribute('data-md')) return false;
120
+ // keep highlighted code when the text is unchanged
121
+ if (fromEl.nodeName === 'CODE' && fromEl.classList && fromEl.classList.contains('hljs') &&
122
+ fromEl.textContent === toEl.textContent) return false;
123
+ return true;
124
+ },
125
+ });
126
+ } else {
127
+ container.innerHTML = next.innerHTML;
128
+ }
129
+ enhance();
130
+ scheduleAnchors();
131
+ }
132
+
133
+ var updTimer = null;
134
+ function scheduleUpdate() { if (updTimer) clearTimeout(updTimer); updTimer = setTimeout(function () { patch(currentSource()); }, 120); }
135
+
136
+ // ---- editor <-> preview scroll sync -------------------------------------
137
+ // Source-line mapped (not percentage): each preview block carries
138
+ // data-src-line (stamped by the renderer). We map between CodeMirror lines
139
+ // and preview offsets, interpolating between anchors.
140
+ var anchors = [];
141
+ var activePane = null; // 'editor' | 'preview' — whichever the user drives
142
+ var syncWired = false;
143
+ var syncEnabled = true; // user-toggleable; persisted in localStorage
144
+
145
+ function scroller() { var d = frameDoc(); return d ? (d.scrollingElement || d.documentElement) : null; }
146
+
147
+ function rebuildAnchors() {
148
+ var doc = frameDoc(); if (!doc) { anchors = []; return; }
149
+ var sc = scroller();
150
+ var st = sc ? sc.scrollTop : 0;
151
+ var els = doc.querySelectorAll('#orz-doc [data-src-line]');
152
+ var arr = [];
153
+ for (var i = 0; i < els.length; i++) {
154
+ var line = parseInt(els[i].getAttribute('data-src-line'), 10);
155
+ if (isNaN(line)) continue;
156
+ // document-space offset: rect is viewport-relative, add the scroll position
157
+ arr.push({ line: line, top: els[i].getBoundingClientRect().top + st });
158
+ }
159
+ arr.sort(function (a, b) { return a.top - b.top; });
160
+ anchors = arr;
161
+ }
162
+ function scheduleAnchors() {
163
+ requestAnimationFrame(rebuildAnchors);
164
+ setTimeout(rebuildAnchors, 350); // after async mermaid/image layout
165
+ }
166
+
167
+ function lineToTop(line) {
168
+ if (!anchors.length) return 0;
169
+ var prev = anchors[0], next = anchors[anchors.length - 1], found = false;
170
+ for (var i = 0; i < anchors.length; i++) {
171
+ if (anchors[i].line <= line) prev = anchors[i];
172
+ if (anchors[i].line > line) { next = anchors[i]; found = true; break; }
173
+ }
174
+ if (!found || next.line === prev.line) return prev.top;
175
+ var f = (line - prev.line) / (next.line - prev.line);
176
+ return prev.top + f * (next.top - prev.top);
177
+ }
178
+ function topToLine(top) {
179
+ if (!anchors.length) return 0;
180
+ var prev = anchors[0], next = anchors[anchors.length - 1], found = false;
181
+ for (var i = 0; i < anchors.length; i++) {
182
+ if (anchors[i].top <= top) prev = anchors[i];
183
+ if (anchors[i].top > top) { next = anchors[i]; found = true; break; }
184
+ }
185
+ if (!found || next.top === prev.top) return prev.line;
186
+ var f = (top - prev.top) / (next.top - prev.top);
187
+ return prev.line + f * (next.line - prev.line);
188
+ }
189
+
190
+ function syncPreviewFromEditor() {
191
+ if (!syncEnabled || !cm || root.getAttribute('data-mode') !== 'split') return;
192
+ var sc = scroller(); if (!sc) return;
193
+ rebuildAnchors(); // fresh offsets (fonts/images may have changed heights)
194
+ // first visible source line: the line at the top edge of the editor viewport
195
+ var wrapTop = cm.getWrapperElement().getBoundingClientRect().top;
196
+ var line = cm.lineAtHeight(wrapTop, 'window');
197
+ sc.scrollTop = lineToTop(line);
198
+ }
199
+ function syncEditorFromPreview() {
200
+ if (!syncEnabled || !cm || root.getAttribute('data-mode') !== 'split') return;
201
+ var sc = scroller(); if (!sc) return;
202
+ rebuildAnchors();
203
+ cm.scrollTo(null, cm.heightAtLine(Math.round(topToLine(sc.scrollTop)), 'local'));
204
+ }
205
+
206
+ function rafThrottle(fn) {
207
+ var queued = false;
208
+ return function () { if (queued) return; queued = true; requestAnimationFrame(function () { queued = false; fn(); }); };
209
+ }
210
+
211
+ function wireScrollSync() {
212
+ if (syncWired || !cm) return;
213
+ syncWired = true;
214
+ var onEd = rafThrottle(function () { if (activePane === 'editor') syncPreviewFromEditor(); });
215
+ var onPv = rafThrottle(function () { if (activePane === 'preview') syncEditorFromPreview(); });
216
+ cm.on('scroll', onEd);
217
+ cm.getWrapperElement().addEventListener('mouseenter', function () { activePane = 'editor'; });
218
+ cm.on('focus', function () { activePane = 'editor'; });
219
+ var fw = frame.contentWindow;
220
+ if (fw) fw.addEventListener('scroll', onPv, { passive: true });
221
+ frame.addEventListener('mouseenter', function () { activePane = 'preview'; });
222
+ }
223
+
224
+ // ---- lazy editor libs ----------------------------------------------------
225
+ function loadScript(src) {
226
+ return new Promise(function (res, rej) {
227
+ var s = document.createElement('script'); s.src = src; s.onload = res; s.onerror = rej;
228
+ document.head.appendChild(s);
229
+ });
230
+ }
231
+ function loadCss(href) {
232
+ if (!href) return;
233
+ var l = document.createElement('link'); l.rel = 'stylesheet'; l.href = href; document.head.appendChild(l);
234
+ }
235
+
236
+ function ensureLibs() {
237
+ if (libsLoading) return libsLoading;
238
+ var L = CFG.editorLibs;
239
+ loadCss(L.codemirrorCss); loadCss(L.codemirrorLightThemeCss); loadCss(L.codemirrorDarkThemeCss);
240
+ libsLoading = loadScript(L.morphdomJs)
241
+ .then(function () { return loadScript(L.splitJs); })
242
+ .then(function () { return loadScript(L.codemirrorJs); })
243
+ .then(function () { return loadScript(L.codemirrorMarkdownJs); })
244
+ .then(function () { return loadScript(L.codemirrorContinuelistJs); });
245
+ return libsLoading;
246
+ }
247
+
248
+ function cmTheme(scheme) { return scheme === 'dark' ? 'material-darker' : 'eclipse'; }
249
+
250
+ function initEditor() {
251
+ if (cm || !window.CodeMirror) return;
252
+ textarea.value = currentSource();
253
+ cm = window.CodeMirror.fromTextArea(textarea, {
254
+ mode: 'markdown', lineNumbers: true, lineWrapping: true,
255
+ tabSize: 2, indentUnit: 2, indentWithTabs: false, smartIndent: false,
256
+ theme: cmTheme(themeById(currentTheme).scheme),
257
+ extraKeys: {
258
+ 'Enter': 'newlineAndIndentContinueMarkdownList',
259
+ 'Tab': function (c) { c.execCommand('indentMore'); },
260
+ 'Shift-Tab': function (c) { c.execCommand('indentLess'); },
261
+ 'Cmd-S': function () { save(); },
262
+ 'Ctrl-S': function () { save(); },
263
+ },
264
+ });
265
+ cm.on('change', function () { markDirty(); scheduleUpdate(); });
266
+ wireScrollSync();
267
+ }
268
+
269
+ // ---- view modes ----------------------------------------------------------
270
+ function setMode(mode) {
271
+ root.setAttribute('data-mode', mode);
272
+ if (mode === 'split') enableSplit(); else disableSplit();
273
+ if (cm) setTimeout(function () { cm.refresh(); }, 30);
274
+ if (mode === 'split') scheduleAnchors();
275
+ }
276
+ function enableSplit() {
277
+ if (splitInstance || !window.Split) return;
278
+ splitInstance = window.Split(['#orz-editor', '#orz-frame'], {
279
+ sizes: [50, 50], minSize: 220, gutterSize: 8,
280
+ onDragStart: function () { frame.style.pointerEvents = 'none'; },
281
+ onDragEnd: function () { frame.style.pointerEvents = ''; if (cm) cm.refresh(); scheduleAnchors(); },
282
+ });
283
+ }
284
+ function disableSplit() {
285
+ if (!splitInstance) return;
286
+ splitInstance.destroy();
287
+ splitInstance = null;
288
+ document.getElementById('orz-editor').style.width = '';
289
+ frame.style.width = '';
290
+ document.getElementById('orz-editor').style.flex = '';
291
+ frame.style.flex = '';
292
+ }
293
+
294
+ function enterEdit() {
295
+ ensureLibs().then(function () {
296
+ initEditor();
297
+ setMode('split');
298
+ if (cm) cm.focus();
299
+ }).catch(function () {
300
+ // offline / lib load failed: plain-textarea fallback
301
+ setMode('editor');
302
+ textarea.focus();
303
+ toast('Editor libraries unavailable — basic editing');
304
+ });
305
+ }
306
+ function done() {
307
+ setMode('read');
308
+ if (dirty) toast('Unsaved changes — press ' + (isMac() ? '⌘' : 'Ctrl') + '+S to save');
309
+ }
310
+ function isMac() { return /Mac|iPhone|iPad/.test(navigator.platform || ''); }
311
+
312
+ function setSyncEnabled(on) {
313
+ syncEnabled = !!on;
314
+ var btn = document.getElementById('orz-sync');
315
+ if (btn) {
316
+ btn.setAttribute('aria-pressed', syncEnabled ? 'true' : 'false');
317
+ btn.title = syncEnabled ? 'Scroll sync on' : 'Scroll sync off';
318
+ }
319
+ try { localStorage.setItem('orz-mdhtml:scrollsync', syncEnabled ? '1' : '0'); } catch (e) {}
320
+ if (syncEnabled && activePane === 'editor') syncPreviewFromEditor();
321
+ }
322
+
323
+ // ---- theme ---------------------------------------------------------------
324
+ function setTheme(id) {
325
+ currentTheme = id;
326
+ var t = themeById(id);
327
+ root.setAttribute('data-chrome', t.scheme);
328
+ if (themeSelect) themeSelect.value = id;
329
+ applyThemeToFrame(t);
330
+ if (cm) cm.setOption('theme', cmTheme(t.scheme));
331
+ markDirty();
332
+ }
333
+
334
+ // ---- dirty ---------------------------------------------------------------
335
+ function markDirty() { if (!dirty) { dirty = true; root.setAttribute('data-dirty', '1'); } }
336
+ function clearDirty() { dirty = false; root.setAttribute('data-dirty', '0'); }
337
+
338
+ // ---- save (self-reproducing) --------------------------------------------
339
+ function serializeDoc(src) {
340
+ var clone = root.cloneNode(true);
341
+ var s = clone.querySelector('#orz-src');
342
+ if (s) s.textContent = '\n' + escapeSource(src) + '\n';
343
+ clone.setAttribute('data-mode', 'read');
344
+ clone.setAttribute('data-chrome', themeById(currentTheme).scheme);
345
+ clone.setAttribute('data-theme', currentTheme); // persist theme choice
346
+ clone.setAttribute('data-fontscale', String(fontScale));
347
+ clone.removeAttribute('data-dirty');
348
+ // strip live editor DOM (CodeMirror) — restore a clean textarea
349
+ var ed = clone.querySelector('#orz-editor');
350
+ if (ed) ed.innerHTML = '<textarea id="orz-textarea" spellcheck="false"></textarea>';
351
+ return '<!DOCTYPE html>\n' + clone.outerHTML + '\n';
352
+ }
353
+
354
+ // IndexedDB: persist the save handle so a reopened file can be saved with a
355
+ // one-click permission grant instead of the full picker. Best-effort — it
356
+ // no-ops gracefully where unavailable (e.g. file:// opaque origins).
357
+ function idbOpen() {
358
+ return new Promise(function (res, rej) {
359
+ var r = indexedDB.open('orz-mdhtml', 1);
360
+ r.onupgradeneeded = function () { r.result.createObjectStore('handles'); };
361
+ r.onsuccess = function () { res(r.result); };
362
+ r.onerror = function () { rej(r.error); };
363
+ });
364
+ }
365
+ function idbGet(key) {
366
+ return idbOpen().then(function (db) {
367
+ return new Promise(function (res, rej) {
368
+ var t = db.transaction('handles', 'readonly');
369
+ var g = t.objectStore('handles').get(key);
370
+ g.onsuccess = function () { res(g.result || null); };
371
+ g.onerror = function () { rej(g.error); };
372
+ });
373
+ }).catch(function () { return null; });
374
+ }
375
+ function idbPut(key, val) {
376
+ return idbOpen().then(function (db) {
377
+ return new Promise(function (res, rej) {
378
+ var t = db.transaction('handles', 'readwrite');
379
+ var p = t.objectStore('handles').put(val, key);
380
+ p.onsuccess = function () { res(); };
381
+ p.onerror = function () { rej(p.error); };
382
+ });
383
+ }).catch(function () {});
384
+ }
385
+
386
+ function pickAndStore() {
387
+ return window.showSaveFilePicker({
388
+ suggestedName: (CFG.filename || 'document') + '.md.html',
389
+ types: [{ description: 'Markdown HTML', accept: { 'text/html': ['.md.html', '.html'] } }],
390
+ }).then(function (h) { fileHandle = h; if (CFG.docId) idbPut(CFG.docId, h); return h; });
391
+ }
392
+
393
+ // Resolve a writable handle: in-memory → persisted (re-grant permission) → picker.
394
+ function acquireHandle() {
395
+ if (fileHandle) return Promise.resolve(fileHandle);
396
+ if (!CFG.docId) return pickAndStore();
397
+ return idbGet(CFG.docId).then(function (saved) {
398
+ if (!saved || !saved.queryPermission) return pickAndStore();
399
+ return saved.queryPermission({ mode: 'readwrite' }).then(function (p) {
400
+ if (p === 'granted') return saved;
401
+ // Chrome shows a prompt naming the file — a deliberate one-click gate.
402
+ return saved.requestPermission({ mode: 'readwrite' }).then(function (p2) {
403
+ return p2 === 'granted' ? saved : null;
404
+ });
405
+ }).then(function (h) {
406
+ if (h) { fileHandle = h; return h; }
407
+ return pickAndStore();
408
+ });
409
+ }).catch(function () { return pickAndStore(); });
410
+ }
411
+
412
+ function isServed() {
413
+ return location.protocol === 'http:' || location.protocol === 'https:';
414
+ }
415
+
416
+ function save() {
417
+ var src = currentSource();
418
+ var html = serializeDoc(src);
419
+ var s = document.getElementById('orz-src');
420
+ if (s) s.textContent = '\n' + escapeSource(src) + '\n';
421
+
422
+ // On a published (served) page with no prior file handle, the reader has no
423
+ // write access to the server. Only nag — once they've actually edited — that
424
+ // they should download a local copy. (Never shown on open or on a no-op save.)
425
+ if (isServed() && !fileHandle) { if (dirty) showServedNote(); return; }
426
+
427
+ if (window.showSaveFilePicker) {
428
+ acquireHandle()
429
+ .then(function (h) { return h.createWritable(); })
430
+ .then(function (w) { return Promise.resolve(w.write(html)).then(function () { return w.close(); }); })
431
+ .then(function () { clearDirty(); toast('Saved'); })
432
+ .catch(function (err) { if (err && err.name === 'AbortError') return; downloadFile(html); clearDirty(); toast('Saved a local copy'); });
433
+ } else {
434
+ downloadFile(html); clearDirty(); toast('Saved a local copy');
435
+ }
436
+ }
437
+
438
+ function downloadFile(text) {
439
+ var blob = new Blob([text], { type: 'text/html' });
440
+ var url = URL.createObjectURL(blob);
441
+ var a = document.createElement('a');
442
+ a.href = url; a.download = (CFG.filename || 'document') + '.md.html';
443
+ document.body.appendChild(a); a.click(); a.remove();
444
+ setTimeout(function () { URL.revokeObjectURL(url); }, 1000);
445
+ }
446
+
447
+ function exportCopy() { downloadFile(serializeDoc(currentSource())); toast('Downloaded a local copy'); }
448
+
449
+ function showServedNote() {
450
+ var n = document.getElementById('orz-served-note');
451
+ if (n) n.classList.add('show');
452
+ }
453
+
454
+ // ---- font size (reading comfort) ----------------------------------------
455
+ function applyFontScale() {
456
+ var doc = frameDoc();
457
+ if (doc && doc.body) doc.body.style.zoom = String(fontScale);
458
+ }
459
+ function bumpFont(delta) {
460
+ fontScale = Math.max(0.8, Math.min(1.8, Math.round((fontScale + delta) * 10) / 10));
461
+ applyFontScale();
462
+ try { localStorage.setItem('orz-mdhtml:fontscale', String(fontScale)); } catch (e) {}
463
+ }
464
+
465
+ // ---- version check -------------------------------------------------------
466
+ function checkVersion() {
467
+ if (!CFG.versionManifest || !CFG.rendererVersion) return;
468
+ try {
469
+ var cached = JSON.parse(localStorage.getItem('orz-mdhtml:vercheck') || 'null');
470
+ if (cached && (Date.now() - cached.t) < 86400000) {
471
+ if (cached.v && cached.v !== CFG.rendererVersion) showUpdate(cached.v);
472
+ return;
473
+ }
474
+ } catch (e) {}
475
+ fetch(CFG.versionManifest).then(function (r) { return r.json(); }).then(function (j) {
476
+ var latest = j && j.version;
477
+ try { localStorage.setItem('orz-mdhtml:vercheck', JSON.stringify({ t: Date.now(), v: latest })); } catch (e) {}
478
+ if (latest && latest !== CFG.rendererVersion) showUpdate(latest);
479
+ }).catch(function () {});
480
+ }
481
+ function showUpdate(latest) {
482
+ var bar = document.getElementById('orz-update'); if (!bar) return;
483
+ bar.querySelector('.upd-text').textContent = 'Renderer ' + latest + ' available (file uses ' + CFG.rendererVersion + ').';
484
+ bar.classList.add('show');
485
+ }
486
+
487
+ // ---- toast ---------------------------------------------------------------
488
+ var toastTimer = null;
489
+ function toast(msg) {
490
+ var t = document.getElementById('orz-toast'); if (!t) return;
491
+ t.textContent = msg; t.classList.add('show');
492
+ if (toastTimer) clearTimeout(toastTimer);
493
+ toastTimer = setTimeout(function () { t.classList.remove('show'); }, 1800);
494
+ }
495
+
496
+ // ---- wiring --------------------------------------------------------------
497
+ function wireUi() {
498
+ document.getElementById('orz-fab').addEventListener('click', enterEdit);
499
+ document.getElementById('orz-done').addEventListener('click', done);
500
+ document.getElementById('orz-save').addEventListener('click', save);
501
+ document.getElementById('orz-font-dec').addEventListener('click', function () { bumpFont(-0.1); });
502
+ document.getElementById('orz-font-inc').addEventListener('click', function () { bumpFont(0.1); });
503
+ document.getElementById('orz-export').addEventListener('click', exportCopy);
504
+ document.getElementById('orz-export-bar').addEventListener('click', exportCopy);
505
+ document.getElementById('orz-served-download').addEventListener('click', function () {
506
+ exportCopy();
507
+ document.getElementById('orz-served-note').classList.remove('show');
508
+ });
509
+ document.getElementById('orz-served-dismiss').addEventListener('click', function () {
510
+ document.getElementById('orz-served-note').classList.remove('show');
511
+ });
512
+ document.getElementById('orz-upd-dismiss').addEventListener('click', function () {
513
+ document.getElementById('orz-update').classList.remove('show');
514
+ });
515
+ if (themeSelect) themeSelect.addEventListener('change', function () { setTheme(this.value); });
516
+ var syncBtn = document.getElementById('orz-sync');
517
+ if (syncBtn) syncBtn.addEventListener('click', function () { setSyncEnabled(!syncEnabled); });
518
+ // plain-textarea fallback live updates (when CodeMirror isn't active)
519
+ textarea.addEventListener('input', function () { if (!cm) { markDirty(); scheduleUpdate(); } });
520
+ document.addEventListener('keydown', function (e) {
521
+ if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 's') { e.preventDefault(); save(); }
522
+ else if (e.key === 'Escape' && root.getAttribute('data-mode') !== 'read') { done(); }
523
+ });
524
+ // Warn before losing unsaved edits (close / reload / navigate away).
525
+ window.addEventListener('beforeunload', function (e) {
526
+ if (dirty) { e.preventDefault(); e.returnValue = ''; }
527
+ });
528
+ }
529
+
530
+ // ---- boot ----------------------------------------------------------------
531
+ function boot() {
532
+ currentTheme = root.getAttribute('data-theme') || CFG.defaultTheme;
533
+ var t = themeById(currentTheme);
534
+ root.setAttribute('data-chrome', t.scheme);
535
+ if (themeSelect) themeSelect.value = currentTheme;
536
+ // font scale: saved-in-file attribute wins, else last localStorage choice
537
+ var savedScale = parseFloat(root.getAttribute('data-fontscale') || '');
538
+ if (!isNaN(savedScale)) fontScale = savedScale;
539
+ else { try { var ls = parseFloat(localStorage.getItem('orz-mdhtml:fontscale') || ''); if (!isNaN(ls)) fontScale = ls; } catch (e) {} }
540
+ textarea.value = embeddedSource();
541
+ buildFrame(t);
542
+ firstPaint();
543
+ applyFontScale();
544
+ wireUi();
545
+ try { if (localStorage.getItem('orz-mdhtml:scrollsync') === '0') syncEnabled = false; } catch (e) {}
546
+ setSyncEnabled(syncEnabled);
547
+ checkVersion();
548
+ }
549
+
550
+ if (document.readyState === 'loading') {
551
+ document.addEventListener('DOMContentLoaded', boot);
552
+ } else {
553
+ boot();
554
+ }
555
+ })();