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 +113 -0
- package/assets/app.js +555 -0
- package/dist/browser-entry.js +34 -0
- package/dist/cli.js +158 -0
- package/dist/orzmd.browser.js +326 -0
- package/dist/template.js +263 -0
- package/orz-mdhtml-skills/SKILL.md +90 -0
- package/package.json +41 -0
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
|
+
})();
|