webtun 1.5.5 → 1.5.6
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/package.json +1 -1
- package/public/index.html +609 -18
- package/server.js +16 -3
package/package.json
CHANGED
package/public/index.html
CHANGED
|
@@ -619,6 +619,19 @@ kbd { display: inline-block; padding: 1px 6px; font-family: var(--font); font-si
|
|
|
619
619
|
#editor-preview.active { display: block; }
|
|
620
620
|
#editor-preview-iframe { width: 100%; height: 100%; border: none; display: none; background: var(--bg); border-radius: var(--radius); }
|
|
621
621
|
|
|
622
|
+
/* PDF / EPUB doc preview */
|
|
623
|
+
#pdf-viewer, #epub-viewer { display: none; flex-direction: column; width: 100%; height: 100%; overflow: hidden; background: var(--bg); }
|
|
624
|
+
#pdf-viewer.active, #epub-viewer.active { display: flex; }
|
|
625
|
+
.doc-toolbar { display: flex; align-items: center; gap: 6px; padding: 6px 10px; background: var(--bg2); border-bottom: 1px solid var(--border); flex-shrink: 0; font-size: 12px; }
|
|
626
|
+
.doc-toolbar .doc-title { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--fg); font-family: var(--font); font-size: 12px; }
|
|
627
|
+
.doc-toolbar .doc-page-info { color: var(--fg2); font-size: 11px; white-space: nowrap; }
|
|
628
|
+
.doc-toolbar .icon-btn { width: 26px; height: 26px; min-width: 26px; min-height: 26px; }
|
|
629
|
+
#pdf-canvas-wrap { flex: 1; overflow: auto; display: flex; flex-direction: column; align-items: center; gap: 12px; padding: 12px; background: var(--bg3); }
|
|
630
|
+
#pdf-canvas-wrap canvas { background: white; box-shadow: 0 2px 12px rgba(0,0,0,0.3); border-radius: 2px; max-width: 100%; }
|
|
631
|
+
#epub-wrap { flex: 1; overflow: auto; display: flex; flex-direction: column; background: white; -webkit-overflow-scrolling: touch; }
|
|
632
|
+
#epub-view { flex: 1; overflow: visible; position: relative; min-height: 100%; }
|
|
633
|
+
#epub-view iframe { width: 100%; height: 100%; border: none; }
|
|
634
|
+
|
|
622
635
|
/* ═══════════════════════════════════════════════════════
|
|
623
636
|
CODEMIRROR
|
|
624
637
|
═══════════════════════════════════════════════════════ */
|
|
@@ -1227,7 +1240,29 @@ kbd { display: inline-block; padding: 1px 6px; font-family: var(--font); font-si
|
|
|
1227
1240
|
</div>
|
|
1228
1241
|
<div id="editor-area">
|
|
1229
1242
|
<textarea id="editor-textarea" spellcheck="false"></textarea>
|
|
1230
|
-
<div id="editor-preview"><iframe id="editor-preview-iframe" sandbox="allow-scripts"></iframe></div>
|
|
1243
|
+
<div id="editor-preview"><div id="md-preview-content" style="display:none"></div><iframe id="editor-preview-iframe" sandbox="allow-same-origin allow-scripts"></iframe></div>
|
|
1244
|
+
<div id="pdf-viewer">
|
|
1245
|
+
<div class="doc-toolbar">
|
|
1246
|
+
<span class="doc-title" id="pdf-title"></span>
|
|
1247
|
+
<span class="doc-page-info" id="pdf-page-info"></span>
|
|
1248
|
+
<button class="icon-btn" title="Zoom out" onclick="pdfZoom(-0.2)"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="8" y1="11" x2="14" y2="11"/></svg></button>
|
|
1249
|
+
<button class="icon-btn" title="Zoom in" onclick="pdfZoom(0.2)"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="11" y1="8" x2="11" y2="14"/><line x1="8" y1="11" x2="14" y2="11"/></svg></button>
|
|
1250
|
+
<button class="icon-btn" title="Previous page" onclick="pdfNav(-1)"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="15 18 9 12 15 6"/></svg></button>
|
|
1251
|
+
<button class="icon-btn" title="Next page" onclick="pdfNav(1)"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="9 18 15 12 9 6"/></svg></button>
|
|
1252
|
+
<button class="icon-btn" title="Download" onclick="downloadFile(editorPath)"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg></button>
|
|
1253
|
+
</div>
|
|
1254
|
+
<div id="pdf-canvas-wrap"></div>
|
|
1255
|
+
</div>
|
|
1256
|
+
<div id="epub-viewer">
|
|
1257
|
+
<div class="doc-toolbar">
|
|
1258
|
+
<span class="doc-title" id="epub-title"></span>
|
|
1259
|
+
<span class="doc-page-info" id="epub-loc"></span>
|
|
1260
|
+
<button class="icon-btn" title="Previous" onclick="epubNav(-1)"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="15 18 9 12 15 6"/></svg></button>
|
|
1261
|
+
<button class="icon-btn" title="Next" onclick="epubNav(1)"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="9 18 15 12 9 6"/></svg></button>
|
|
1262
|
+
<button class="icon-btn" title="Download" onclick="downloadFile(editorPath)"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg></button>
|
|
1263
|
+
</div>
|
|
1264
|
+
<div id="epub-wrap"><div id="epub-view"></div></div>
|
|
1265
|
+
</div>
|
|
1231
1266
|
</div>
|
|
1232
1267
|
</div>
|
|
1233
1268
|
</div>
|
|
@@ -3088,7 +3123,6 @@ function makeFileItem(name, isDir, fullPath, size, icon) {
|
|
|
3088
3123
|
return;
|
|
3089
3124
|
}
|
|
3090
3125
|
if (curIsDir) loadFiles(curPath);
|
|
3091
|
-
else if (isImageFile(curPath)) openImageViewer(curPath);
|
|
3092
3126
|
else openFileEditor(curPath);
|
|
3093
3127
|
});
|
|
3094
3128
|
const ell = div.querySelector('.file-ellipsis');
|
|
@@ -3235,8 +3269,10 @@ function getFileIcon(ext) {
|
|
|
3235
3269
|
const codeExts = { '.js':1,'.ts':1,'.jsx':1,'.tsx':1,'.py':1,'.rs':1,'.go':1,'.java':1,'.c':1,'.cpp':1,'.html':1,'.css':1,'.sh':1,'.yml':1,'.yaml':1,'.toml':1,'.json':1,'.sql':1 };
|
|
3236
3270
|
const imgExts = { '.png':1,'.jpg':1,'.jpeg':1,'.gif':1,'.svg':1,'.webp':1,'.ico':1 };
|
|
3237
3271
|
const archiveExts = { '.zip':1,'.tar':1,'.gz':1,'.bz2':1,'.xz':1,'.7z':1,'.rar':1 };
|
|
3272
|
+
const docExts = { '.pdf':1,'.epub':1 };
|
|
3238
3273
|
if (codeExts[ext]) return '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg>';
|
|
3239
3274
|
if (imgExts[ext]) return '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg>';
|
|
3275
|
+
if (docExts[ext]) return '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/></svg>';
|
|
3240
3276
|
if (archiveExts[ext]) return '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 16V8a2 2 0 00-1-1.73l-7-4a2 2 0 00-2 0l-7 4A2 2 0 002 8v8a2 2 0 001 1.73l7 4a2 2 0 002 0l7-4A2 2 0 0021 16z"/><polyline points="3.27 6.96 12 12.01 20.73 6.96"/></svg>';
|
|
3241
3277
|
return '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>';
|
|
3242
3278
|
}
|
|
@@ -3521,8 +3557,8 @@ function showCtxMenu(e, file) {
|
|
|
3521
3557
|
ctxTarget = file;
|
|
3522
3558
|
const menu = document.getElementById('ctx-menu');
|
|
3523
3559
|
menu.style.display = 'block';
|
|
3524
|
-
document.getElementById('ctx-open').style.display = file.isDir || isImageFile(file.path) ? '' : 'none';
|
|
3525
|
-
document.getElementById('ctx-edit').style.display = file.isDir ? 'none' : (
|
|
3560
|
+
document.getElementById('ctx-open').style.display = file.isDir || isImageFile(file.path) || isDocFile(file.path) ? '' : 'none';
|
|
3561
|
+
document.getElementById('ctx-edit').style.display = file.isDir ? 'none' : (!canOpenInEditor(file.path) ? 'none' : '');
|
|
3526
3562
|
document.getElementById('ctx-open-term').style.display = file.isDir ? '' : 'none';
|
|
3527
3563
|
document.getElementById('ctx-paste').style.display = fsClipboard ? '' : 'none';
|
|
3528
3564
|
document.getElementById('ctx-zip').style.display = '';
|
|
@@ -3553,7 +3589,12 @@ document.addEventListener('click', () => {
|
|
|
3553
3589
|
if (termMenu) termMenu.style.display = 'none';
|
|
3554
3590
|
});
|
|
3555
3591
|
|
|
3556
|
-
document.getElementById('ctx-open').onclick = () => {
|
|
3592
|
+
document.getElementById('ctx-open').onclick = () => {
|
|
3593
|
+
if (!ctxTarget) return;
|
|
3594
|
+
if (ctxTarget.isDir) loadFiles(ctxTarget.path);
|
|
3595
|
+
else if (isImageFile(ctxTarget.path) || isDocFile(ctxTarget.path)) openFileEditor(ctxTarget.path);
|
|
3596
|
+
else loadFiles(ctxTarget.path);
|
|
3597
|
+
};
|
|
3557
3598
|
document.getElementById('ctx-edit').onclick = () => ctxTarget && openFileEditor(ctxTarget.path);
|
|
3558
3599
|
document.getElementById('ctx-download').onclick = () => {
|
|
3559
3600
|
if (selectedFiles.length > 0) { downloadSelected(); return; }
|
|
@@ -4152,8 +4193,15 @@ function getCMmode(fileName) {
|
|
|
4152
4193
|
}
|
|
4153
4194
|
|
|
4154
4195
|
async function openFileEditor(path) {
|
|
4196
|
+
if (isImageFile(path)) { openImageViewer(path); return; }
|
|
4197
|
+
if (isPdfFile(path)) { openPdfViewer(path); return; }
|
|
4198
|
+
if (isEpubFile(path)) { openEpubViewer(path); return; }
|
|
4199
|
+
if (!canOpenInEditor(path)) { toast('Preview not supported for this file type — use Download', 'warning'); return; }
|
|
4155
4200
|
const r = await api(`/api/files/read?path=${encodeURIComponent(path)}`);
|
|
4156
|
-
if (r.error) {
|
|
4201
|
+
if (r.error) {
|
|
4202
|
+
if (r.isBinary) { toast(r.error || 'Preview not supported for binary files — use Download', 'warning'); return; }
|
|
4203
|
+
toast(r.error, 'error'); return;
|
|
4204
|
+
}
|
|
4157
4205
|
if (r.length > MAX_EDITOR_SIZE) {
|
|
4158
4206
|
const ok = await confirmDialog({ title: 'Large file', message: `File is ${(r.length / 1024 / 1024).toFixed(1)}MB. Open anyway?`, okText: 'Open', danger: true });
|
|
4159
4207
|
if (!ok) return;
|
|
@@ -4164,6 +4212,9 @@ async function openFileEditor(path) {
|
|
|
4164
4212
|
const fileName = path.split(sep).pop() || path;
|
|
4165
4213
|
const isMd = /\.md$/i.test(fileName);
|
|
4166
4214
|
const isHtml = /\.html?$/i.test(fileName);
|
|
4215
|
+
// hide doc viewers when opening text file
|
|
4216
|
+
cleanupDocViewers();
|
|
4217
|
+
document.getElementById('editor-save-btn').style.display = '';
|
|
4167
4218
|
|
|
4168
4219
|
const cm = initCodeMirror();
|
|
4169
4220
|
cm.setValue(r.content);
|
|
@@ -4178,8 +4229,12 @@ async function openFileEditor(path) {
|
|
|
4178
4229
|
const toggleBtn = document.getElementById('md-preview-toggle');
|
|
4179
4230
|
const refreshBtn = document.getElementById('preview-refresh-btn');
|
|
4180
4231
|
const previewIframe = document.getElementById('editor-preview-iframe');
|
|
4232
|
+
const mdContent = document.getElementById('md-preview-content');
|
|
4181
4233
|
preview.classList.remove('active');
|
|
4182
4234
|
if (previewIframe) { previewIframe.style.display = 'none'; previewIframe.srcdoc = ''; }
|
|
4235
|
+
if (mdContent) { mdContent.style.display = 'none'; mdContent.innerHTML = ''; }
|
|
4236
|
+
mdPreviewActive = false;
|
|
4237
|
+
clearPreviewLiveReload();
|
|
4183
4238
|
if ((isMd && typeof marked !== 'undefined') || isHtml) {
|
|
4184
4239
|
toggleBtn.style.display = '';
|
|
4185
4240
|
toggleBtn.textContent = 'Preview';
|
|
@@ -4259,12 +4314,16 @@ async function saveFile() {
|
|
|
4259
4314
|
}
|
|
4260
4315
|
|
|
4261
4316
|
async function closeEditor() {
|
|
4262
|
-
const
|
|
4263
|
-
if (
|
|
4264
|
-
const
|
|
4265
|
-
if (
|
|
4317
|
+
const isDocPreview = !!(_pdfDoc || _epubBook);
|
|
4318
|
+
if (!isDocPreview) {
|
|
4319
|
+
const currentContent = editor ? editor.getValue() : document.getElementById('editor-textarea').value;
|
|
4320
|
+
if (currentContent !== editorOriginalContent) {
|
|
4321
|
+
const ok = await confirmDialog({ title: 'Unsaved changes', message: 'You have unsaved changes. Close anyway?', okText: 'Discard', cancelText: 'Keep Editing', danger: true });
|
|
4322
|
+
if (!ok) return;
|
|
4323
|
+
}
|
|
4266
4324
|
}
|
|
4267
4325
|
clearPreviewLiveReload();
|
|
4326
|
+
cleanupDocViewers();
|
|
4268
4327
|
const ev = document.getElementById('editor-view');
|
|
4269
4328
|
ev.classList.remove('open');
|
|
4270
4329
|
ev.classList.remove('fullscreen');
|
|
@@ -4277,6 +4336,8 @@ async function closeEditor() {
|
|
|
4277
4336
|
document.getElementById('preview-refresh-btn').style.display = 'none';
|
|
4278
4337
|
const previewIframe = document.getElementById('editor-preview-iframe');
|
|
4279
4338
|
if (previewIframe) { previewIframe.style.display = 'none'; previewIframe.srcdoc = ''; }
|
|
4339
|
+
const mdContent = document.getElementById('md-preview-content');
|
|
4340
|
+
if (mdContent) { mdContent.style.display = 'none'; mdContent.innerHTML = ''; }
|
|
4280
4341
|
const cm = document.querySelector('.CodeMirror');
|
|
4281
4342
|
if (cm) cm.style.display = '';
|
|
4282
4343
|
mdPreviewActive = false;
|
|
@@ -4289,11 +4350,445 @@ async function closeEditor() {
|
|
|
4289
4350
|
|
|
4290
4351
|
const IMAGE_EXTS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.svg', '.webp', '.bmp', '.ico']);
|
|
4291
4352
|
|
|
4353
|
+
// Binary extensions from https://github.com/sindresorhus/binary-extensions (v2.3.0)
|
|
4354
|
+
// Used to block editor preview for non-text files. Images are previewable via image viewer, so they are checked first.
|
|
4355
|
+
const BINARY_EXTS = new Set([
|
|
4356
|
+
'3dm','3ds','3g2','3gp','7z','a','aac','adp','afdesign','afphoto','afpub','ai','aif','aiff','alz','ape','apk','appimage','ar','arj','asf','au','avi','bak','baml','bh','bin','bk','bmp','btif','bz2','bzip2','cab','caf','cgm','class','cmx','cpio','cr2','cr3','cur','dat','dcm','deb','dex','djvu','dll','dmg','dng','doc','docm','docx','dot','dotm','dra','ds_store','dsk','dts','dtshd','dvb','dwg','dxf','ecelp4800','ecelp7470','ecelp9600','egg','eol','eot','epub','exe','f4v','fbs','fh','fla','flac','flatpak','fli','flv','fpx','fst','fvt','g3','gh','gif','graffle','gz','gzip','h261','h263','h264','icns','ico','ief','img','ipa','iso','jar','jpeg','jpg','jpgv','jpm','jxr','key','ktx','lha','lib','lvp','lz','lzh','lzma','lzo','m3u','m4a','m4v','mar','mdi','mht','mid','midi','mj2','mka','mkv','mmr','mng','mobi','mov','movie','mp3','mp4','mp4a','mpeg','mpg','mpga','mxu','nef','npx','numbers','nupkg','o','odp','ods','odt','oga','ogg','ogv','otf','ott','pages','pbm','pcx','pdb','pdf','pea','pgm','pic','png','pnm','pot','potm','potx','ppa','ppam','ppm','pps','ppsm','ppsx','ppt','pptm','pptx','psd','pya','pyc','pyo','pyv','qt','rar','ras','raw','resources','rgb','rip','rlc','rmf','rmvb','rpm','rtf','rz','s3m','s7z','scpt','sgi','shar','snap','sil','sketch','slk','smv','snk','so','stl','suo','sub','swf','tar','tbz','tbz2','tga','tgz','thmx','tif','tiff','tlz','ttc','ttf','txz','udf','uvh','uvi','uvm','uvp','uvs','uvu','viv','vob','war','wav','wax','wbmp','wdp','weba','webm','webp','whl','wim','wm','wma','wmv','wmx','woff','woff2','wrm','wvx','xbm','xif','xla','xlam','xls','xlsb','xlsm','xlsx','xlt','xltm','xltx','xm','xmind','xpi','xpm','xwd','xz','z','zip','zipx'
|
|
4357
|
+
]);
|
|
4358
|
+
|
|
4292
4359
|
function isImageFile(path) {
|
|
4293
4360
|
const m = path.match(/\.([^.]+)$/);
|
|
4294
4361
|
return m ? IMAGE_EXTS.has('.' + m[1].toLowerCase()) : false;
|
|
4295
4362
|
}
|
|
4296
4363
|
|
|
4364
|
+
function getFileExt(path) {
|
|
4365
|
+
const m = path.match(/\.([^.\\/]+)$/);
|
|
4366
|
+
return m ? '.' + m[1].toLowerCase() : '';
|
|
4367
|
+
}
|
|
4368
|
+
|
|
4369
|
+
function isBinaryFile(path) {
|
|
4370
|
+
const ext = getFileExt(path);
|
|
4371
|
+
return ext ? BINARY_EXTS.has(ext.slice(1).toLowerCase()) : false;
|
|
4372
|
+
}
|
|
4373
|
+
|
|
4374
|
+
function canOpenInEditor(path) {
|
|
4375
|
+
if (isImageFile(path)) return false;
|
|
4376
|
+
if (isPdfFile(path) || isEpubFile(path)) return false;
|
|
4377
|
+
if (isBinaryFile(path)) return false;
|
|
4378
|
+
return true;
|
|
4379
|
+
}
|
|
4380
|
+
|
|
4381
|
+
function isPdfFile(path) { return /\.pdf$/i.test(path); }
|
|
4382
|
+
function isEpubFile(path) { return /\.epub$/i.test(path); }
|
|
4383
|
+
function isDocFile(path) { return isPdfFile(path) || isEpubFile(path); }
|
|
4384
|
+
|
|
4385
|
+
// ── Doc preview dynamic loader ──
|
|
4386
|
+
function loadScript(src) {
|
|
4387
|
+
return new Promise((resolve, reject) => {
|
|
4388
|
+
if (document.querySelector(`script[src="${src}"]`)) return resolve();
|
|
4389
|
+
const s = document.createElement('script');
|
|
4390
|
+
s.src = src;
|
|
4391
|
+
s.async = true;
|
|
4392
|
+
s.onload = () => resolve();
|
|
4393
|
+
s.onerror = () => reject(new Error('Failed to load ' + src));
|
|
4394
|
+
document.head.appendChild(s);
|
|
4395
|
+
});
|
|
4396
|
+
}
|
|
4397
|
+
async function loadPdfJs() {
|
|
4398
|
+
if (window.pdfjsLib) return;
|
|
4399
|
+
// pdf.js v2 UMD global is pdfjsLib
|
|
4400
|
+
await loadScript('https://cdn.jsdelivr.net/npm/pdfjs-dist@2.16.105/build/pdf.min.js');
|
|
4401
|
+
const lib = window.pdfjsLib || window['pdfjs-dist/build/pdf'];
|
|
4402
|
+
if (!lib) throw new Error('PDF.js not found');
|
|
4403
|
+
window.pdfjsLib = lib;
|
|
4404
|
+
if (lib.GlobalWorkerOptions) {
|
|
4405
|
+
lib.GlobalWorkerOptions.workerSrc = 'https://cdn.jsdelivr.net/npm/pdfjs-dist@2.16.105/build/pdf.worker.min.js';
|
|
4406
|
+
}
|
|
4407
|
+
}
|
|
4408
|
+
async function loadEpubJs() {
|
|
4409
|
+
if (window.ePub) return;
|
|
4410
|
+
await loadScript('https://cdn.jsdelivr.net/npm/jszip@3.10.1/dist/jszip.min.js');
|
|
4411
|
+
await loadScript('https://cdn.jsdelivr.net/npm/epubjs@0.3.93/dist/epub.min.js');
|
|
4412
|
+
if (!window.ePub && !window.EPUBJS) throw new Error('EPUB.js not found');
|
|
4413
|
+
if (!window.ePub && window.EPUBJS) window.ePub = window.EPUBJS;
|
|
4414
|
+
}
|
|
4415
|
+
|
|
4416
|
+
let _pdfDoc = null;
|
|
4417
|
+
let _pdfPath = '';
|
|
4418
|
+
let _pdfScale = 1.4;
|
|
4419
|
+
let _pdfCurrentPage = 1;
|
|
4420
|
+
let _pdfPageOrder = null;
|
|
4421
|
+
|
|
4422
|
+
async function openPdfViewer(path) {
|
|
4423
|
+
// Show viewer shell immediately for perceived performance
|
|
4424
|
+
editorPath = path;
|
|
4425
|
+
editorOriginalContent = '';
|
|
4426
|
+
const fileName = path.split(/[\\/]/).pop() || path;
|
|
4427
|
+
document.getElementById('editor-filename').textContent = fileName + ' / ' + path;
|
|
4428
|
+
document.getElementById('editor-status').textContent = '';
|
|
4429
|
+
const cm = document.querySelector('.CodeMirror');
|
|
4430
|
+
if (cm) cm.style.display = 'none';
|
|
4431
|
+
document.getElementById('editor-preview').classList.remove('active');
|
|
4432
|
+
const iframe = document.getElementById('editor-preview-iframe');
|
|
4433
|
+
if (iframe) { iframe.style.display = 'none'; iframe.srcdoc = ''; }
|
|
4434
|
+
const mdContent = document.getElementById('md-preview-content');
|
|
4435
|
+
if (mdContent) { mdContent.style.display = 'none'; }
|
|
4436
|
+
mdPreviewActive = false;
|
|
4437
|
+
clearPreviewLiveReload();
|
|
4438
|
+
document.getElementById('epub-viewer').classList.remove('active');
|
|
4439
|
+
const pdfViewer = document.getElementById('pdf-viewer');
|
|
4440
|
+
pdfViewer.classList.add('active');
|
|
4441
|
+
document.getElementById('pdf-title').textContent = fileName;
|
|
4442
|
+
document.getElementById('pdf-page-info').textContent = 'Loading…';
|
|
4443
|
+
document.getElementById('pdf-canvas-wrap').innerHTML = '<div style="color:var(--fg2);padding:20px">Loading PDF…</div>';
|
|
4444
|
+
// open editor split immediately
|
|
4445
|
+
if (window.innerWidth > 768) {
|
|
4446
|
+
document.getElementById('content').classList.add('editor-open');
|
|
4447
|
+
const splitArea = document.getElementById('editor-split-area');
|
|
4448
|
+
try {
|
|
4449
|
+
const orient = safeStorage.getItem('wt-editor-split-orientation');
|
|
4450
|
+
const isHoriz = orient === 'horizontal';
|
|
4451
|
+
if (splitArea) splitArea.classList.toggle('horizontal', isHoriz);
|
|
4452
|
+
} catch {}
|
|
4453
|
+
}
|
|
4454
|
+
document.getElementById('editor-view').classList.add('open');
|
|
4455
|
+
document.getElementById('editor-save-btn').style.display = 'none';
|
|
4456
|
+
document.getElementById('md-preview-toggle').style.display = 'none';
|
|
4457
|
+
document.getElementById('preview-refresh-btn').style.display = 'none';
|
|
4458
|
+
requestAnimationFrame(() => { tabs.forEach(tab => { try { fitTerm(tab); } catch (e) {} }); });
|
|
4459
|
+
// Load PDF.js immediately without confirmation (cached after first load)
|
|
4460
|
+
if (!window.pdfjsLib) {
|
|
4461
|
+
const t = toast('Loading PDF viewer…', 'info');
|
|
4462
|
+
try { await loadPdfJs(); t.remove(); } catch (e) { t.remove(); toast('Failed to load PDF viewer: ' + e.message, 'error'); return; }
|
|
4463
|
+
}
|
|
4464
|
+
try {
|
|
4465
|
+
const resp = await fetch(`/api/files/image?path=${encodeURIComponent(path)}&_t=${Date.now()}`, { headers: { 'x-pin-token': authToken } });
|
|
4466
|
+
if (!resp.ok) throw new Error('Failed to fetch PDF');
|
|
4467
|
+
const buf = await resp.arrayBuffer();
|
|
4468
|
+
const lib = window.pdfjsLib;
|
|
4469
|
+
const loadingTask = lib.getDocument({ data: buf });
|
|
4470
|
+
_pdfDoc = await loadingTask.promise;
|
|
4471
|
+
_pdfPath = path;
|
|
4472
|
+
_pdfCurrentPage = 1;
|
|
4473
|
+
_pdfScale = 1.4;
|
|
4474
|
+
await renderPdfDoc();
|
|
4475
|
+
} catch (e) {
|
|
4476
|
+
console.warn('PDF load failed', e);
|
|
4477
|
+
document.getElementById('pdf-canvas-wrap').innerHTML = '<div style="color:var(--red);padding:20px">Failed to load PDF: ' + escHtml(e.message) + '</div>';
|
|
4478
|
+
document.getElementById('pdf-page-info').textContent = 'Error';
|
|
4479
|
+
toast('PDF load failed: ' + e.message, 'error');
|
|
4480
|
+
}
|
|
4481
|
+
}
|
|
4482
|
+
async function renderPdfDoc() {
|
|
4483
|
+
if (!_pdfDoc) return;
|
|
4484
|
+
const wrap = document.getElementById('pdf-canvas-wrap');
|
|
4485
|
+
wrap.innerHTML = '';
|
|
4486
|
+
const total = _pdfDoc.numPages;
|
|
4487
|
+
let pageLabels = null;
|
|
4488
|
+
try { pageLabels = await _pdfDoc.getPageLabels(); } catch {}
|
|
4489
|
+
const getLabel = (idx) => pageLabels && pageLabels[idx-1] ? pageLabels[idx-1] : String(idx);
|
|
4490
|
+
// Build ordered list of pages sorted by actual page number (label if numeric, else index) to ensure preview matches printed page numbers
|
|
4491
|
+
let pageOrder = Array.from({length: total}, (_, k) => k+1);
|
|
4492
|
+
if (pageLabels) {
|
|
4493
|
+
try {
|
|
4494
|
+
pageOrder.sort((a,b) => {
|
|
4495
|
+
const la = getLabel(a), lb = getLabel(b);
|
|
4496
|
+
const na = parseInt(la, 10), nb = parseInt(lb, 10);
|
|
4497
|
+
const aIsNum = !isNaN(na) && String(na) === la.trim();
|
|
4498
|
+
const bIsNum = !isNaN(nb) && String(nb) === lb.trim();
|
|
4499
|
+
if (aIsNum && bIsNum) return na - nb;
|
|
4500
|
+
if (aIsNum && !bIsNum) return -1;
|
|
4501
|
+
if (!aIsNum && bIsNum) return 1;
|
|
4502
|
+
return la.localeCompare(lb);
|
|
4503
|
+
});
|
|
4504
|
+
} catch {}
|
|
4505
|
+
}
|
|
4506
|
+
_pdfPageOrder = pageOrder;
|
|
4507
|
+
_pdfCurrentPage = pageOrder[0];
|
|
4508
|
+
document.getElementById('pdf-page-info').textContent = getLabel(pageOrder[0]) + ' / ' + getLabel(pageOrder[pageOrder.length-1]) + ' • ' + total + ' pages';
|
|
4509
|
+
// Render all pages vertically for scrollable preview in sorted order
|
|
4510
|
+
try {
|
|
4511
|
+
for (const i of pageOrder) {
|
|
4512
|
+
const page = await _pdfDoc.getPage(i);
|
|
4513
|
+
const viewport = page.getViewport({ scale: _pdfScale });
|
|
4514
|
+
const canvas = document.createElement('canvas');
|
|
4515
|
+
canvas.id = 'pdf-page-' + i;
|
|
4516
|
+
canvas.dataset.page = i;
|
|
4517
|
+
canvas.dataset.label = getLabel(i);
|
|
4518
|
+
const ctx = canvas.getContext('2d');
|
|
4519
|
+
canvas.width = viewport.width;
|
|
4520
|
+
canvas.height = viewport.height;
|
|
4521
|
+
canvas.style.width = viewport.width + 'px';
|
|
4522
|
+
canvas.style.height = viewport.height + 'px';
|
|
4523
|
+
// label
|
|
4524
|
+
const pageWrap = document.createElement('div');
|
|
4525
|
+
pageWrap.style.cssText = 'display:flex;flex-direction:column;align-items:center;gap:4px';
|
|
4526
|
+
pageWrap.appendChild(canvas);
|
|
4527
|
+
const label = document.createElement('div');
|
|
4528
|
+
label.style.cssText = 'color:var(--fg2);font-size:10px';
|
|
4529
|
+
label.textContent = 'Page ' + getLabel(i) + ' • ' + i + '/' + total;
|
|
4530
|
+
pageWrap.appendChild(label);
|
|
4531
|
+
wrap.appendChild(pageWrap);
|
|
4532
|
+
await page.render({ canvasContext: ctx, viewport }).promise;
|
|
4533
|
+
// allow UI to breathe for large docs
|
|
4534
|
+
if (i % 10 === 0) await new Promise(r => setTimeout(r, 0));
|
|
4535
|
+
}
|
|
4536
|
+
setupPdfScrollTracking();
|
|
4537
|
+
} catch (e) {
|
|
4538
|
+
wrap.innerHTML = '<div style="color:var(--red);padding:20px">Render failed: ' + escHtml(e.message) + '</div>';
|
|
4539
|
+
}
|
|
4540
|
+
}
|
|
4541
|
+
let _pdfObserver = null;
|
|
4542
|
+
function setupPdfScrollTracking() {
|
|
4543
|
+
const wrap = document.getElementById('pdf-canvas-wrap');
|
|
4544
|
+
if (!wrap || !_pdfDoc) return;
|
|
4545
|
+
if (_pdfObserver) try { _pdfObserver.disconnect(); } catch {}
|
|
4546
|
+
const canvases = wrap.querySelectorAll('canvas[id^="pdf-page-"]');
|
|
4547
|
+
if (!canvases.length) return;
|
|
4548
|
+
let ticking = false;
|
|
4549
|
+
const update = () => {
|
|
4550
|
+
ticking = false;
|
|
4551
|
+
let best = null;
|
|
4552
|
+
let bestTop = Infinity;
|
|
4553
|
+
const wrapRect = wrap.getBoundingClientRect();
|
|
4554
|
+
for (const c of canvases) {
|
|
4555
|
+
const r = c.getBoundingClientRect();
|
|
4556
|
+
// visible if within wrap viewport
|
|
4557
|
+
if (r.bottom > wrapRect.top && r.top < wrapRect.bottom) {
|
|
4558
|
+
const dist = Math.abs(r.top - wrapRect.top);
|
|
4559
|
+
if (dist < bestTop) { bestTop = dist; best = c; }
|
|
4560
|
+
}
|
|
4561
|
+
}
|
|
4562
|
+
if (best) {
|
|
4563
|
+
const pageNum = parseInt(best.dataset.page, 10);
|
|
4564
|
+
const label = best.dataset.label || String(pageNum);
|
|
4565
|
+
if (pageNum && pageNum !== _pdfCurrentPage) {
|
|
4566
|
+
_pdfCurrentPage = pageNum;
|
|
4567
|
+
let pageLabels2 = null;
|
|
4568
|
+
try { pageLabels2 = _pdfDoc.getPageLabels && _pdfDoc._pageLabels ? _pdfDoc._pageLabels : null; } catch {}
|
|
4569
|
+
// use stored label
|
|
4570
|
+
document.getElementById('pdf-page-info').textContent = label + ' • ' + pageNum + '/' + _pdfDoc.numPages + ' • scroll vertically';
|
|
4571
|
+
}
|
|
4572
|
+
}
|
|
4573
|
+
};
|
|
4574
|
+
wrap.addEventListener('scroll', () => {
|
|
4575
|
+
if (!ticking) { ticking = true; requestAnimationFrame(update); }
|
|
4576
|
+
}, { passive: true });
|
|
4577
|
+
// also use IntersectionObserver for more precise
|
|
4578
|
+
try {
|
|
4579
|
+
_pdfObserver = new IntersectionObserver((entries) => {
|
|
4580
|
+
let best = null; let bestRatio = 0;
|
|
4581
|
+
for (const e of entries) {
|
|
4582
|
+
if (e.isIntersecting && e.intersectionRatio > bestRatio) {
|
|
4583
|
+
bestRatio = e.intersectionRatio; best = e.target;
|
|
4584
|
+
}
|
|
4585
|
+
}
|
|
4586
|
+
if (best) {
|
|
4587
|
+
const pageNum = parseInt(best.dataset.page, 10);
|
|
4588
|
+
const label = best.dataset.label || String(pageNum);
|
|
4589
|
+
if (pageNum) {
|
|
4590
|
+
_pdfCurrentPage = pageNum;
|
|
4591
|
+
document.getElementById('pdf-page-info').textContent = label + ' • ' + pageNum + '/' + _pdfDoc.numPages + ' • scroll vertically';
|
|
4592
|
+
}
|
|
4593
|
+
}
|
|
4594
|
+
}, { root: wrap, threshold: [0.5, 0.75] });
|
|
4595
|
+
canvases.forEach(c => _pdfObserver.observe(c));
|
|
4596
|
+
} catch { _pdfObserver = null; }
|
|
4597
|
+
// initial update
|
|
4598
|
+
setTimeout(update, 100);
|
|
4599
|
+
}
|
|
4600
|
+
function pdfNav(dir) {
|
|
4601
|
+
if (!_pdfDoc) return;
|
|
4602
|
+
const wrap = document.getElementById('pdf-canvas-wrap');
|
|
4603
|
+
if (!wrap) return;
|
|
4604
|
+
// Scroll to next/prev page in sorted order
|
|
4605
|
+
const total = _pdfDoc.numPages;
|
|
4606
|
+
const order = _pdfPageOrder || Array.from({length: total}, (_,k)=>k+1);
|
|
4607
|
+
const idx = order.indexOf(_pdfCurrentPage);
|
|
4608
|
+
let nextIdx = idx + dir;
|
|
4609
|
+
if (nextIdx < 0) nextIdx = 0;
|
|
4610
|
+
if (nextIdx >= order.length) nextIdx = order.length - 1;
|
|
4611
|
+
const target = order[nextIdx];
|
|
4612
|
+
_pdfCurrentPage = target;
|
|
4613
|
+
const el = document.getElementById('pdf-page-' + target);
|
|
4614
|
+
const label = el ? el.dataset.label : String(target);
|
|
4615
|
+
document.getElementById('pdf-page-info').textContent = label + ' • ' + target + '/' + total + ' • scroll vertically';
|
|
4616
|
+
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
|
4617
|
+
else if (dir < 0) wrap.scrollBy({ top: -wrap.clientHeight * 0.9, behavior: 'smooth' });
|
|
4618
|
+
else wrap.scrollBy({ top: wrap.clientHeight * 0.9, behavior: 'smooth' });
|
|
4619
|
+
}
|
|
4620
|
+
function pdfZoom(delta) {
|
|
4621
|
+
if (!_pdfDoc) return;
|
|
4622
|
+
_pdfScale = Math.max(0.6, Math.min(3, _pdfScale + delta));
|
|
4623
|
+
// Re-render all pages at new scale (keeps scroll position near current page)
|
|
4624
|
+
const wrap = document.getElementById('pdf-canvas-wrap');
|
|
4625
|
+
const scrollTop = wrap ? wrap.scrollTop : 0;
|
|
4626
|
+
if (_pdfObserver) try { _pdfObserver.disconnect(); _pdfObserver = null; } catch {}
|
|
4627
|
+
renderPdfDoc().then(() => {
|
|
4628
|
+
if (wrap) {
|
|
4629
|
+
const el = document.getElementById('pdf-page-' + _pdfCurrentPage);
|
|
4630
|
+
if (el) el.scrollIntoView({ block: 'start' });
|
|
4631
|
+
else wrap.scrollTop = scrollTop;
|
|
4632
|
+
}
|
|
4633
|
+
});
|
|
4634
|
+
}
|
|
4635
|
+
|
|
4636
|
+
let _epubBook = null;
|
|
4637
|
+
let _epubRendition = null;
|
|
4638
|
+
let _epubPath = '';
|
|
4639
|
+
let _epubBlobUrl = null;
|
|
4640
|
+
|
|
4641
|
+
async function openEpubViewer(path) {
|
|
4642
|
+
editorPath = path;
|
|
4643
|
+
editorOriginalContent = '';
|
|
4644
|
+
const fileName = path.split(/[\\/]/).pop() || path;
|
|
4645
|
+
document.getElementById('editor-filename').textContent = fileName + ' / ' + path;
|
|
4646
|
+
document.getElementById('editor-status').textContent = '';
|
|
4647
|
+
const cm = document.querySelector('.CodeMirror');
|
|
4648
|
+
if (cm) cm.style.display = 'none';
|
|
4649
|
+
document.getElementById('editor-preview').classList.remove('active');
|
|
4650
|
+
const iframe = document.getElementById('editor-preview-iframe');
|
|
4651
|
+
if (iframe) { iframe.style.display = 'none'; iframe.srcdoc = ''; }
|
|
4652
|
+
const mdContent2 = document.getElementById('md-preview-content');
|
|
4653
|
+
if (mdContent2) { mdContent2.style.display = 'none'; }
|
|
4654
|
+
mdPreviewActive = false;
|
|
4655
|
+
clearPreviewLiveReload();
|
|
4656
|
+
document.getElementById('pdf-viewer').classList.remove('active');
|
|
4657
|
+
const epubViewer = document.getElementById('epub-viewer');
|
|
4658
|
+
epubViewer.classList.add('active');
|
|
4659
|
+
document.getElementById('epub-title').textContent = fileName;
|
|
4660
|
+
document.getElementById('epub-loc').textContent = 'Loading…';
|
|
4661
|
+
if (!window.ePub) {
|
|
4662
|
+
const t = toast('Loading EPUB viewer…', 'info');
|
|
4663
|
+
try { await loadEpubJs(); t.remove(); } catch (e) { t.remove(); toast('Failed to load EPUB viewer: ' + e.message, 'error'); return; }
|
|
4664
|
+
}
|
|
4665
|
+
if (window.innerWidth > 768) {
|
|
4666
|
+
document.getElementById('content').classList.add('editor-open');
|
|
4667
|
+
const splitArea = document.getElementById('editor-split-area');
|
|
4668
|
+
try {
|
|
4669
|
+
const orient = safeStorage.getItem('wt-editor-split-orientation');
|
|
4670
|
+
const isHoriz = orient === 'horizontal';
|
|
4671
|
+
if (splitArea) splitArea.classList.toggle('horizontal', isHoriz);
|
|
4672
|
+
} catch {}
|
|
4673
|
+
}
|
|
4674
|
+
document.getElementById('editor-view').classList.add('open');
|
|
4675
|
+
document.getElementById('editor-save-btn').style.display = 'none';
|
|
4676
|
+
document.getElementById('md-preview-toggle').style.display = 'none';
|
|
4677
|
+
document.getElementById('preview-refresh-btn').style.display = 'none';
|
|
4678
|
+
requestAnimationFrame(() => { tabs.forEach(tab => { try { fitTerm(tab); } catch (e) {} }); });
|
|
4679
|
+
// cleanup previous
|
|
4680
|
+
try { if (_epubRendition) { _epubRendition.destroy(); _epubRendition = null; } } catch {}
|
|
4681
|
+
try { if (_epubBook) { _epubBook.destroy(); _epubBook = null; } } catch {}
|
|
4682
|
+
if (_epubBlobUrl) { URL.revokeObjectURL(_epubBlobUrl); _epubBlobUrl = null; }
|
|
4683
|
+
document.getElementById('epub-view').innerHTML = '';
|
|
4684
|
+
// Show loading immediately
|
|
4685
|
+
document.getElementById('epub-loc').textContent = 'Loading EPUB…';
|
|
4686
|
+
try {
|
|
4687
|
+
const resp = await fetch(`/api/files/image?path=${encodeURIComponent(path)}&_t=${Date.now()}`, { headers: { 'x-pin-token': authToken } });
|
|
4688
|
+
if (!resp.ok) throw new Error('Failed to fetch EPUB (' + resp.status + ')');
|
|
4689
|
+
const arrayBuffer = await resp.arrayBuffer();
|
|
4690
|
+
if (!arrayBuffer || arrayBuffer.byteLength < 100) throw new Error('Empty or invalid EPUB file (size ' + (arrayBuffer ? arrayBuffer.byteLength : 0) + ')');
|
|
4691
|
+
const header = new Uint8Array(arrayBuffer.slice(0, 4));
|
|
4692
|
+
const isZip = header[0] === 0x50 && header[1] === 0x4B;
|
|
4693
|
+
console.log('EPUB fetch OK', path, 'bytes', arrayBuffer.byteLength, 'isZip', isZip, 'header', Array.from(header));
|
|
4694
|
+
if (!isZip) throw new Error('Not a valid EPUB (missing PK header, got ' + Array.from(header).join(',') + ')');
|
|
4695
|
+
_epubPath = path;
|
|
4696
|
+
const book = window.ePub(arrayBuffer, { openAs: 'binary', encoding: 'binary', store: false });
|
|
4697
|
+
_epubBook = book;
|
|
4698
|
+
// Listen for openFailed
|
|
4699
|
+
book.on('openFailed', (e) => { console.warn('EPUB openFailed', e); throw e; });
|
|
4700
|
+
// Ensure container is visible and has layout before renderTo
|
|
4701
|
+
await new Promise(r => requestAnimationFrame(r));
|
|
4702
|
+
// Try scrolled-doc first (vertical scroll), fallback to paginated if fails
|
|
4703
|
+
let rendition;
|
|
4704
|
+
try {
|
|
4705
|
+
rendition = book.renderTo('epub-view', { flow: 'scrolled-doc', width: '100%', height: '100%', allowScriptedContent: false, store: false });
|
|
4706
|
+
} catch (e) {
|
|
4707
|
+
console.warn('scrolled-doc failed, fallback to paginated', e);
|
|
4708
|
+
try { if (rendition) rendition.destroy(); } catch {}
|
|
4709
|
+
document.getElementById('epub-view').innerHTML = '';
|
|
4710
|
+
rendition = book.renderTo('epub-view', { flow: 'paginated', width: '100%', height: '100%', manager: 'default' });
|
|
4711
|
+
}
|
|
4712
|
+
_epubRendition = rendition;
|
|
4713
|
+
const s = getComputedStyle(document.documentElement);
|
|
4714
|
+
const bg = s.getPropertyValue('--bg').trim();
|
|
4715
|
+
const fg = s.getPropertyValue('--fg').trim();
|
|
4716
|
+
try {
|
|
4717
|
+
rendition.themes.default({ body: { background: bg + ' !important', color: fg + ' !important', 'font-size': '16px', 'line-height': '1.6', 'padding': '0 12px' } });
|
|
4718
|
+
} catch {}
|
|
4719
|
+
const displayTimeout = new Promise((_, reject) => setTimeout(() => reject(new Error('EPUB display timeout - try downloading the file')), 15000));
|
|
4720
|
+
await Promise.race([rendition.display(), displayTimeout]);
|
|
4721
|
+
document.getElementById('epub-loc').textContent = 'Scroll vertically • ' + (rendition.location ? 'ready' : '');
|
|
4722
|
+
// keyboard nav still works for scrolled-doc (prev/next section)
|
|
4723
|
+
rendition.on('keyup', (e) => {
|
|
4724
|
+
if (e.key === 'ArrowLeft') rendition.prev();
|
|
4725
|
+
if (e.key === 'ArrowRight') rendition.next();
|
|
4726
|
+
});
|
|
4727
|
+
// also allow continuous wheel scroll naturally; update location on relocate
|
|
4728
|
+
try {
|
|
4729
|
+
rendition.on('relocated', (loc) => {
|
|
4730
|
+
if (loc && loc.start && loc.start.percentage !== undefined) {
|
|
4731
|
+
const pct = Math.round(loc.start.percentage * 100);
|
|
4732
|
+
document.getElementById('epub-loc').textContent = pct + '% • scroll';
|
|
4733
|
+
}
|
|
4734
|
+
});
|
|
4735
|
+
} catch {}
|
|
4736
|
+
} catch (e) {
|
|
4737
|
+
console.warn('EPUB load failed', e);
|
|
4738
|
+
document.getElementById('epub-loc').textContent = 'Failed';
|
|
4739
|
+
document.getElementById('epub-view').innerHTML = '<div style="color:var(--red);padding:20px">Failed to load EPUB: ' + escHtml(e.message) + '</div>';
|
|
4740
|
+
toast('EPUB load failed: ' + e.message, 'error');
|
|
4741
|
+
}
|
|
4742
|
+
}
|
|
4743
|
+
function epubNav(dir) {
|
|
4744
|
+
if (!_epubRendition) return;
|
|
4745
|
+
if (dir < 0) _epubRendition.prev();
|
|
4746
|
+
else _epubRendition.next();
|
|
4747
|
+
}
|
|
4748
|
+
function cleanupDocViewers() {
|
|
4749
|
+
// PDF
|
|
4750
|
+
if (_pdfObserver) { try { _pdfObserver.disconnect(); } catch {} _pdfObserver = null; }
|
|
4751
|
+
if (_pdfDoc) { try { _pdfDoc.destroy(); } catch {} _pdfDoc = null; }
|
|
4752
|
+
_pdfPath = '';
|
|
4753
|
+
_pdfPageOrder = null;
|
|
4754
|
+
_pdfCurrentPage = 1;
|
|
4755
|
+
const pdfWrap = document.getElementById('pdf-canvas-wrap');
|
|
4756
|
+
if (pdfWrap) {
|
|
4757
|
+
// remove scroll listener added in setupPdfScrollTracking
|
|
4758
|
+
try { pdfWrap.onscroll = null; } catch {}
|
|
4759
|
+
pdfWrap.innerHTML = '';
|
|
4760
|
+
}
|
|
4761
|
+
const pdfViewer = document.getElementById('pdf-viewer');
|
|
4762
|
+
if (pdfViewer) pdfViewer.classList.remove('active');
|
|
4763
|
+
// EPUB
|
|
4764
|
+
try { if (_epubRendition) { _epubRendition.destroy(); _epubRendition = null; } } catch {}
|
|
4765
|
+
try { if (_epubBook) { _epubBook.destroy(); _epubBook = null; } } catch {}
|
|
4766
|
+
if (_epubBlobUrl) { URL.revokeObjectURL(_epubBlobUrl); _epubBlobUrl = null; }
|
|
4767
|
+
_epubPath = '';
|
|
4768
|
+
const epubViewer = document.getElementById('epub-viewer');
|
|
4769
|
+
if (epubViewer) epubViewer.classList.remove('active');
|
|
4770
|
+
const epubView = document.getElementById('epub-view');
|
|
4771
|
+
if (epubView) epubView.innerHTML = '';
|
|
4772
|
+
document.getElementById('editor-save-btn').style.display = '';
|
|
4773
|
+
}
|
|
4774
|
+
// Preload doc viewers in background for instant open (respect datasaver)
|
|
4775
|
+
function preloadDocViewers() {
|
|
4776
|
+
try {
|
|
4777
|
+
if (settings && settings.datasaver) return;
|
|
4778
|
+
if (!window.pdfjsLib) loadPdfJs().catch(()=>{});
|
|
4779
|
+
if (!window.ePub) loadEpubJs().catch(()=>{});
|
|
4780
|
+
} catch {}
|
|
4781
|
+
}
|
|
4782
|
+
setTimeout(() => {
|
|
4783
|
+
if (document.hidden) {
|
|
4784
|
+
const h = () => { if (!document.hidden) { document.removeEventListener('visibilitychange', h); preloadDocViewers(); } };
|
|
4785
|
+
document.addEventListener('visibilitychange', h);
|
|
4786
|
+
} else {
|
|
4787
|
+
if ('requestIdleCallback' in window) requestIdleCallback(preloadDocViewers, { timeout: 3000 });
|
|
4788
|
+
else setTimeout(preloadDocViewers, 1500);
|
|
4789
|
+
}
|
|
4790
|
+
}, 1500);
|
|
4791
|
+
|
|
4297
4792
|
let _ivImageUrl = null;
|
|
4298
4793
|
|
|
4299
4794
|
function openImageViewer(path) {
|
|
@@ -4349,6 +4844,9 @@ function toggleHtmlPreview() {
|
|
|
4349
4844
|
editor.focus();
|
|
4350
4845
|
clearPreviewLiveReload();
|
|
4351
4846
|
} else {
|
|
4847
|
+
// hide doc viewers when entering html preview
|
|
4848
|
+
document.getElementById('pdf-viewer').classList.remove('active');
|
|
4849
|
+
document.getElementById('epub-viewer').classList.remove('active');
|
|
4352
4850
|
renderHtmlPreview();
|
|
4353
4851
|
if (cmWrapper) cmWrapper.style.display = 'none';
|
|
4354
4852
|
preview.classList.add('active');
|
|
@@ -4359,12 +4857,74 @@ function toggleHtmlPreview() {
|
|
|
4359
4857
|
}
|
|
4360
4858
|
}
|
|
4361
4859
|
|
|
4860
|
+
function getHtmlBaseDir() {
|
|
4861
|
+
const p = editorPath || currentPath || '';
|
|
4862
|
+
if (!p) return '';
|
|
4863
|
+
const sep = p.includes('\\') ? '\\' : '/';
|
|
4864
|
+
const idx = p.lastIndexOf(sep);
|
|
4865
|
+
return idx > 0 ? p.slice(0, idx) : p;
|
|
4866
|
+
}
|
|
4867
|
+
function isAbsoluteUrlForHtml(url) {
|
|
4868
|
+
return /^(?:[a-z][a-z0-9+.-]*:|\/\/|data:|blob:|#)/i.test(url.trim()) || url.trim().startsWith('/');
|
|
4869
|
+
}
|
|
4870
|
+
function toPreviewApiUrl(rel, baseDir) {
|
|
4871
|
+
if (!rel || isAbsoluteUrlForHtml(rel)) return rel;
|
|
4872
|
+
if (/^[A-Za-z]:[\\/]/.test(rel)) return rel;
|
|
4873
|
+
// keep query/hash part
|
|
4874
|
+
let hash = '';
|
|
4875
|
+
let query = '';
|
|
4876
|
+
let pathPart = rel;
|
|
4877
|
+
const hIdx = pathPart.indexOf('#');
|
|
4878
|
+
if (hIdx !== -1) { hash = pathPart.slice(hIdx); pathPart = pathPart.slice(0, hIdx); }
|
|
4879
|
+
const qIdx = pathPart.indexOf('?');
|
|
4880
|
+
if (qIdx !== -1) { query = pathPart.slice(qIdx); pathPart = pathPart.slice(0, qIdx); }
|
|
4881
|
+
pathPart = pathPart.trim();
|
|
4882
|
+
if (!pathPart) return rel;
|
|
4883
|
+
const abs = joinPath(baseDir || '', pathPart);
|
|
4884
|
+
let api = `/api/files/image?path=${encodeURIComponent(abs)}`;
|
|
4885
|
+
if (authToken) api += `&token=${encodeURIComponent(authToken)}`;
|
|
4886
|
+
if (query) api += (query.startsWith('?') ? `&${query.slice(1)}` : query);
|
|
4887
|
+
if (hash) api += hash;
|
|
4888
|
+
return api;
|
|
4889
|
+
}
|
|
4890
|
+
function rewriteHtmlRelativeUrls(html, baseDir) {
|
|
4891
|
+
if (!baseDir) return html;
|
|
4892
|
+
// src/href/srcset/poster/data/action/cite/background
|
|
4893
|
+
html = html.replace(/\b(src|href|srcset|poster|data|cite|action|background)\s*=\s*(["'])([^"']+)\2/gi, (m, attr, q, val) => {
|
|
4894
|
+
if (attr.toLowerCase() === 'srcset') {
|
|
4895
|
+
const parts = val.split(',').map(p => {
|
|
4896
|
+
const seg = p.trim();
|
|
4897
|
+
if (!seg) return seg;
|
|
4898
|
+
const sp = seg.split(/\s+/);
|
|
4899
|
+
const url = sp[0];
|
|
4900
|
+
const desc = sp.slice(1).join(' ');
|
|
4901
|
+
const newUrl = toPreviewApiUrl(url, baseDir);
|
|
4902
|
+
return desc ? `${newUrl} ${desc}` : newUrl;
|
|
4903
|
+
});
|
|
4904
|
+
return `${attr}=${q}${parts.join(', ')}${q}`;
|
|
4905
|
+
}
|
|
4906
|
+
const newVal = toPreviewApiUrl(val, baseDir);
|
|
4907
|
+
if (newVal === val) return m;
|
|
4908
|
+
return `${attr}=${q}${newVal}${q}`;
|
|
4909
|
+
});
|
|
4910
|
+
// url(...) in style attributes and <style> tags
|
|
4911
|
+
html = html.replace(/url\(\s*(["']?)([^"'\)]+)\1\s*\)/gi, (m, q, url) => {
|
|
4912
|
+
const trimmed = url.trim();
|
|
4913
|
+
if (!trimmed || isAbsoluteUrlForHtml(trimmed) || trimmed.startsWith('/')) return m;
|
|
4914
|
+
if (/^[A-Za-z]:[\\/]/.test(trimmed)) return m;
|
|
4915
|
+
const newUrl = toPreviewApiUrl(trimmed, baseDir);
|
|
4916
|
+
return `url(${q}${newUrl}${q})`;
|
|
4917
|
+
});
|
|
4918
|
+
return html;
|
|
4919
|
+
}
|
|
4362
4920
|
function renderHtmlPreview() {
|
|
4363
4921
|
const iframe = document.getElementById('editor-preview-iframe');
|
|
4922
|
+
const mdContent = document.getElementById('md-preview-content');
|
|
4364
4923
|
const raw = editor ? editor.getValue() : '';
|
|
4365
4924
|
if (!iframe) return;
|
|
4366
|
-
|
|
4367
|
-
|
|
4925
|
+
if (mdContent) mdContent.style.display = 'none';
|
|
4926
|
+
if (iframe) iframe.style.display = 'block';
|
|
4927
|
+
const baseDir = getHtmlBaseDir();
|
|
4368
4928
|
const s = getComputedStyle(document.documentElement);
|
|
4369
4929
|
const bg = s.getPropertyValue('--bg').trim();
|
|
4370
4930
|
const fg = s.getPropertyValue('--fg').trim();
|
|
@@ -4373,11 +4933,31 @@ function renderHtmlPreview() {
|
|
|
4373
4933
|
const border = s.getPropertyValue('--border').trim();
|
|
4374
4934
|
const font = s.getPropertyValue('--font').trim();
|
|
4375
4935
|
const isDark = !['#f9f9fb', '#ffffff', 'rgb(249, 249, 251)', 'rgb(255, 255, 255)'].includes(bg);
|
|
4376
|
-
const
|
|
4936
|
+
const isFullDoc = /<!DOCTYPE|<html[\s>]/i.test(raw);
|
|
4937
|
+
let doc;
|
|
4938
|
+
if (isFullDoc) {
|
|
4939
|
+
let sanitized = typeof DOMPurify !== 'undefined' ? DOMPurify.sanitize(raw, { WHOLE_DOCUMENT: true, USE_PROFILES: { html: true }, ADD_TAGS: ['base','style'], ADD_ATTR: ['target'] }) : raw;
|
|
4940
|
+
sanitized = rewriteHtmlRelativeUrls(sanitized, baseDir);
|
|
4941
|
+
// Inject base tag if missing for any remaining relative URLs
|
|
4942
|
+
if (!/<base\b/i.test(sanitized)) {
|
|
4943
|
+
const baseHref = `/api/files/image?path=${encodeURIComponent(baseDir + '/')}` + (authToken ? `&token=${encodeURIComponent(authToken)}` : '');
|
|
4944
|
+
sanitized = sanitized.replace(/<head[^>]*>/i, m => m + `<base href="${escHtml(baseHref)}">`);
|
|
4945
|
+
}
|
|
4946
|
+
// Ensure color-scheme meta
|
|
4947
|
+
if (!/<meta[^>]*color-scheme/i.test(sanitized)) {
|
|
4948
|
+
sanitized = sanitized.replace(/<head[^>]*>/i, m => m + `<meta name="color-scheme" content="${isDark ? 'dark' : 'light'}">`);
|
|
4949
|
+
}
|
|
4950
|
+
doc = sanitized;
|
|
4951
|
+
} else {
|
|
4952
|
+
let fragment = typeof DOMPurify !== 'undefined' ? DOMPurify.sanitize(raw, { USE_PROFILES: { html: true }, ADD_ATTR: ['target'] }) : raw;
|
|
4953
|
+
fragment = rewriteHtmlRelativeUrls(fragment, baseDir);
|
|
4954
|
+
const baseHrefFrag = `/api/files/image?path=${encodeURIComponent(baseDir + '/')}` + (authToken ? `&token=${encodeURIComponent(authToken)}` : '');
|
|
4955
|
+
doc = `<!DOCTYPE html>
|
|
4377
4956
|
<html>
|
|
4378
4957
|
<head>
|
|
4379
4958
|
<meta charset="UTF-8">
|
|
4380
4959
|
<meta name="color-scheme" content="${isDark ? 'dark' : 'light'}">
|
|
4960
|
+
<base href="${escHtml(baseHrefFrag)}">
|
|
4381
4961
|
<style>
|
|
4382
4962
|
:root { color-scheme: ${isDark ? 'dark' : 'light'}; }
|
|
4383
4963
|
* { box-sizing: border-box; }
|
|
@@ -4413,16 +4993,21 @@ function renderHtmlPreview() {
|
|
|
4413
4993
|
input:focus, textarea:focus, select:focus { outline: 2px solid ${accent}; outline-offset: 1px; }
|
|
4414
4994
|
</style>
|
|
4415
4995
|
</head>
|
|
4416
|
-
<body>${
|
|
4996
|
+
<body>${fragment}</body>
|
|
4417
4997
|
</html>`;
|
|
4998
|
+
}
|
|
4418
4999
|
iframe.srcdoc = doc;
|
|
4419
5000
|
iframe.style.display = 'block';
|
|
4420
5001
|
}
|
|
4421
5002
|
|
|
4422
5003
|
function renderMdPreview() {
|
|
4423
5004
|
const preview = document.getElementById('editor-preview');
|
|
5005
|
+
const mdContent = document.getElementById('md-preview-content');
|
|
5006
|
+
const iframe = document.getElementById('editor-preview-iframe');
|
|
4424
5007
|
const raw = editor ? editor.getValue() : '';
|
|
4425
|
-
if (!preview) return;
|
|
5008
|
+
if (!preview || !mdContent) return;
|
|
5009
|
+
if (iframe) iframe.style.display = 'none';
|
|
5010
|
+
mdContent.style.display = 'block';
|
|
4426
5011
|
try {
|
|
4427
5012
|
const html = marked.parse(raw, { breaks: true, gfm: true, langPrefix: 'language-' });
|
|
4428
5013
|
const sanitized = typeof DOMPurify !== 'undefined' ? DOMPurify.sanitize(html) : html;
|
|
@@ -4435,7 +5020,7 @@ function renderMdPreview() {
|
|
|
4435
5020
|
const border = s.getPropertyValue('--border').trim();
|
|
4436
5021
|
const fg2 = s.getPropertyValue('--fg2').trim();
|
|
4437
5022
|
const font = s.getPropertyValue('--font').trim();
|
|
4438
|
-
|
|
5023
|
+
mdContent.innerHTML = `
|
|
4439
5024
|
<style>
|
|
4440
5025
|
.md-rendered { max-width: 800px; margin: 0 auto; }
|
|
4441
5026
|
.md-rendered h1, .md-rendered h2, .md-rendered h3, .md-rendered h4, .md-rendered h5, .md-rendered h6 { color: ${fg}; margin: 1.2em 0 0.5em; font-weight: 700; }
|
|
@@ -4461,7 +5046,7 @@ function renderMdPreview() {
|
|
|
4461
5046
|
<div class="md-rendered">${sanitized}</div>`;
|
|
4462
5047
|
} catch (e) {
|
|
4463
5048
|
console.warn('Markdown preview error:', e);
|
|
4464
|
-
|
|
5049
|
+
mdContent.textContent = 'Error rendering markdown preview';
|
|
4465
5050
|
}
|
|
4466
5051
|
}
|
|
4467
5052
|
|
|
@@ -4502,6 +5087,8 @@ function toggleMdPreview() {
|
|
|
4502
5087
|
const refreshBtn = document.getElementById('preview-refresh-btn');
|
|
4503
5088
|
mdPreviewActive = !mdPreviewActive;
|
|
4504
5089
|
if (mdPreviewActive) {
|
|
5090
|
+
document.getElementById('pdf-viewer').classList.remove('active');
|
|
5091
|
+
document.getElementById('epub-viewer').classList.remove('active');
|
|
4505
5092
|
renderMdPreview();
|
|
4506
5093
|
if (cmWrapper) cmWrapper.style.display = 'none';
|
|
4507
5094
|
preview.classList.add('active');
|
|
@@ -6143,7 +6730,11 @@ function doFinderSearch() {
|
|
|
6143
6730
|
fiDir.className = 'fi-dir';
|
|
6144
6731
|
fiDir.textContent = r.dir;
|
|
6145
6732
|
div.appendChild(fiDir);
|
|
6146
|
-
div.addEventListener('click', () => {
|
|
6733
|
+
div.addEventListener('click', () => {
|
|
6734
|
+
closeOverlay('finder-overlay');
|
|
6735
|
+
if (r.isDir) loadFiles(r.path);
|
|
6736
|
+
else openFileEditor(r.path);
|
|
6737
|
+
});
|
|
6147
6738
|
div.addEventListener('mouseenter', () => { document.querySelectorAll('.finder-item.selected').forEach(el => el.classList.remove('selected')); div.classList.add('selected'); finderIdx = i; });
|
|
6148
6739
|
results.appendChild(div);
|
|
6149
6740
|
});
|
package/server.js
CHANGED
|
@@ -77,13 +77,18 @@ const MIME_MAP = {
|
|
|
77
77
|
'.go':'text/x-go','.rs':'text/x-rust','.php':'text/x-php','.pl':'text/x-perl',
|
|
78
78
|
'.sql':'application/sql','.graphql':'application/graphql',
|
|
79
79
|
'.yaml':'text/yaml','.yml':'text/yaml','.toml':'application/toml','.ini':'text/plain',
|
|
80
|
-
'.env':'text/plain','.lock':'text/plain',
|
|
80
|
+
'.env':'text/plain','.lock':'text/plain','.epub':'application/epub+zip',
|
|
81
81
|
};
|
|
82
82
|
function mimeLookup(filePath) {
|
|
83
83
|
const ext = path.extname(filePath).toLowerCase();
|
|
84
84
|
return MIME_MAP[ext] || 'application/octet-stream';
|
|
85
85
|
}
|
|
86
86
|
|
|
87
|
+
// Binary extensions — preview not supported in editor (client has same list). Keep in sync.
|
|
88
|
+
const BINARY_EXTS = new Set([
|
|
89
|
+
'3dm','3ds','3g2','3gp','7z','a','aac','adp','afdesign','afphoto','afpub','ai','aif','aiff','alz','ape','apk','appimage','ar','arj','asf','au','avi','bak','baml','bh','bin','bk','bmp','btif','bz2','bzip2','cab','caf','cgm','class','cmx','cpio','cr2','cr3','cur','dat','dcm','deb','dex','djvu','dll','dmg','dng','doc','docm','docx','dot','dotm','dra','ds_store','dsk','dts','dtshd','dvb','dwg','dxf','ecelp4800','ecelp7470','ecelp9600','egg','eol','eot','epub','exe','f4v','fbs','fh','fla','flac','flatpak','fli','flv','fpx','fst','fvt','g3','gh','gif','graffle','gz','gzip','h261','h263','h264','icns','ico','ief','img','ipa','iso','jar','jpeg','jpg','jpgv','jpm','jxr','key','ktx','lha','lib','lvp','lz','lzh','lzma','lzo','m3u','m4a','m4v','mar','mdi','mht','mid','midi','mj2','mka','mkv','mmr','mng','mobi','mov','movie','mp3','mp4','mp4a','mpeg','mpg','mpga','mxu','nef','npx','numbers','nupkg','o','odp','ods','odt','oga','ogg','ogv','otf','ott','pages','pbm','pcx','pdb','pdf','pea','pgm','pic','png','pnm','pot','potm','potx','ppa','ppam','ppm','pps','ppsm','ppsx','ppt','pptm','pptx','psd','pya','pyc','pyo','pyv','qt','rar','ras','raw','resources','rgb','rip','rlc','rmf','rmvb','rpm','rtf','rz','s3m','s7z','scpt','sgi','shar','snap','sil','sketch','slk','smv','snk','so','stl','suo','sub','swf','tar','tbz','tbz2','tga','tgz','thmx','tif','tiff','tlz','ttc','ttf','txz','udf','uvh','uvi','uvm','uvp','uvs','uvu','viv','vob','war','wav','wax','wbmp','wdp','weba','webm','webp','whl','wim','wm','wma','wmv','wmx','woff','woff2','wrm','wvx','xbm','xif','xla','xlam','xls','xlsb','xlsm','xlsx','xlt','xltm','xltx','xm','xmind','xpi','xpm','xwd','xz','z','zip','zipx'
|
|
90
|
+
]);
|
|
91
|
+
|
|
87
92
|
// In-memory rate limiter factory
|
|
88
93
|
// NOTE: Behind cloudflared tunnel every remote IP appears as 127.0.0.1 (tunnel collapses to loopback).
|
|
89
94
|
// We use req.ip (Express respects app.set('trust proxy')) so limiter correctly respects trust proxy config.
|
|
@@ -169,7 +174,7 @@ app.use((req, res, next) => {
|
|
|
169
174
|
res.setHeader('X-Frame-Options', 'DENY');
|
|
170
175
|
res.setHeader('Referrer-Policy', 'no-referrer');
|
|
171
176
|
res.setHeader('Permissions-Policy', 'camera=(), microphone=(), geolocation=()');
|
|
172
|
-
res.setHeader('Content-Security-Policy', "default-src 'self'; connect-src 'self' https://*.trycloudflare.com wss:; script-src 'self' https://cdn.jsdelivr.net
|
|
177
|
+
res.setHeader('Content-Security-Policy', "default-src 'self'; connect-src 'self' https://*.trycloudflare.com wss: blob:; script-src 'self' https://cdn.jsdelivr.net blob:; style-src 'self' https://cdn.jsdelivr.net 'unsafe-inline'; font-src 'self' data: https://fonts.gstatic.com https://fonts.googleapis.com; img-src 'self' data: blob:; frame-src 'self' blob:; child-src 'self' blob:; worker-src 'self' blob: https://cdn.jsdelivr.net;");
|
|
173
178
|
next();
|
|
174
179
|
});
|
|
175
180
|
|
|
@@ -906,7 +911,15 @@ app.get('/api/files/read', checkPin, async (req, res) => {
|
|
|
906
911
|
const st = await fsPromises.stat(p);
|
|
907
912
|
if (st.isDirectory()) return res.status(400).json({ error: 'Cannot read a directory' });
|
|
908
913
|
if (st.size > 10 * 1024 * 1024) return res.status(413).json({ error: 'File too large (max 10MB) - use download' });
|
|
909
|
-
const
|
|
914
|
+
const ext = path.extname(p).toLowerCase().slice(1);
|
|
915
|
+
if (ext && BINARY_EXTS.has(ext)) {
|
|
916
|
+
return res.status(415).json({ error: 'Preview not supported for binary files - use download', isBinary: true });
|
|
917
|
+
}
|
|
918
|
+
const buf = await fsPromises.readFile(p);
|
|
919
|
+
if (buf.includes(0)) {
|
|
920
|
+
return res.status(415).json({ error: 'Preview not supported for binary files - use download', isBinary: true });
|
|
921
|
+
}
|
|
922
|
+
const content = buf.toString('utf8');
|
|
910
923
|
res.json({ content, length: st.size });
|
|
911
924
|
} catch (e) {
|
|
912
925
|
const status = e.status || 500;
|