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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "webtun",
3
- "version": "1.5.5",
3
+ "version": "1.5.6",
4
4
  "description": "Self-hosted web terminal with Cloudflare Tunnel, file explorer, and PWA support",
5
5
  "author": {
6
6
  "name": "Gaurang Patel",
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' : (isImageFile(file.path) ? '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 = () => { if (!ctxTarget) return; if (isImageFile(ctxTarget.path)) openImageViewer(ctxTarget.path); else loadFiles(ctxTarget.path); };
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) { toast(r.error, 'error'); return; }
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 currentContent = editor ? editor.getValue() : document.getElementById('editor-textarea').value;
4263
- if (currentContent !== editorOriginalContent) {
4264
- const ok = await confirmDialog({ title: 'Unsaved changes', message: 'You have unsaved changes. Close anyway?', okText: 'Discard', cancelText: 'Keep Editing', danger: true });
4265
- if (!ok) return;
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
- // Clear previous live reload to prevent leak on re-entry (U83)
4367
- clearPreviewLiveReload();
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 doc = `<!DOCTYPE html>
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>${typeof DOMPurify !== 'undefined' ? DOMPurify.sanitize(raw, { USE_PROFILES: { html: true }, ADD_ATTR: ['target'] }) : raw}</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
- preview.innerHTML = `
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
- preview.textContent = 'Error rendering markdown preview';
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', () => { closeOverlay('finder-overlay'); if (r.isDir) loadFiles(r.path); else openFileEditor(r.path); });
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; 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:;");
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 content = await fsPromises.readFile(p, 'utf8');
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;