webtun 1.5.4 → 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/public/index.html CHANGED
@@ -208,10 +208,10 @@ svg { display: block; }
208
208
  #upload-progress.active #upload-progress-text { opacity: 1; }
209
209
  #upload-cancel-btn { position: absolute; right: 8px; top: -28px; height: 22px; padding: 0 8px; font-size: 11px; background: var(--bg2); border: 1px solid var(--border); border-radius: var(--radius); color: var(--fg2); cursor: pointer; display: none; z-index: 2; }
210
210
  #upload-progress.active #upload-cancel-btn { display: block; pointer-events: auto; }
211
- #main { display: flex; flex: 1; overflow: hidden; min-height: 0; position: relative; }
212
- #sidebar { position: relative; width: var(--sidebar-w); background: var(--bg2); border-right: 1px solid var(--border); display: flex; flex-direction: column; overflow: hidden; flex-shrink: 0; transition: var(--transition-normal); }
213
- #sidebar.dragging { transition: none; }
214
- #sidebar.hidden { width: 0 !important; }
211
+ #main { display: flex; flex: 1; overflow: hidden; min-height: 0; position: relative; isolation: isolate; }
212
+ #sidebar { position: relative; width: var(--sidebar-w); background: var(--bg2); border-right: 1px solid var(--border); display: flex; flex-direction: column; overflow: hidden; flex-shrink: 0; transition: width var(--transition-normal) ease, transform var(--transition-normal) ease; will-change: width, transform; backface-visibility: hidden; }
213
+ #sidebar.dragging { transition: none; will-change: width; }
214
+ #sidebar.hidden { width: 0 !important; overflow: hidden; }
215
215
  #content { flex: 1; display: flex; flex-direction: column; overflow: hidden; min-width: 0; min-height: 0; position: relative; }
216
216
  #tab-bar { display: flex; align-items: center; height: var(--tab-h); background: var(--bg2); border-bottom: 1px solid var(--border); flex-shrink: 0; gap: 2px; padding: 0 4px; }
217
217
  #tab-scroll { display: flex; align-items: center; gap: 2px; height: 100%; flex: 1; min-width: 0; overflow-x: auto; overflow-y: hidden; mask-image: linear-gradient(to right, black calc(100% - 20px), transparent); -webkit-mask-image: linear-gradient(to right, black calc(100% - 20px), transparent); scrollbar-width: none; }
@@ -267,17 +267,11 @@ svg { display: block; }
267
267
  }
268
268
  #editor-view { display: none; flex-direction: column; background: var(--bg); overflow: hidden; }
269
269
  #editor-view.open { display: flex; }
270
- #editor-view.fullscreen { position: fixed; inset: 0; z-index: var(--z-overlay); width: 100vw; height: 100vh; max-height: 100vh; background: var(--bg); border: none; }
270
+ #editor-view.fullscreen { position: absolute; inset: 0; z-index: 30; width: 100%; height: 100%; background: var(--bg); border: none; }
271
271
  #editor-view.fullscreen #editor-header { border-bottom: 1px solid var(--border); background: var(--bg2); }
272
272
  #editor-view.fullscreen #editor-area { flex: 1; min-height: 0; }
273
- #editor-view.fullscreen #editor-area .CodeMirror { height: 100vh; }
274
- #terminals.fullscreen { position: fixed; inset: 0; z-index: var(--z-overlay); width: 100vw; height: 100vh; max-height: 100vh; background: var(--bg); display: flex; flex-direction: column; }
275
- #terminals.fullscreen .term-wrapper { flex: 1; min-height: 0; }
276
- #terminals.fullscreen #search-bar { top: 8px; }
277
- #content.terminal-fullscreen { position: fixed; inset: 0; z-index: var(--z-overlay); background: var(--bg); display: flex; flex-direction: column; }
278
- #content.terminal-fullscreen #tab-bar { flex-shrink: 0; }
279
- #content.terminal-fullscreen #editor-split-area { flex: 1; min-height: 0; }
280
- #content.terminal-fullscreen #sidebar { display: none !important; }
273
+ #editor-view.fullscreen #editor-area .CodeMirror { height: 100%; }
274
+ #editor-split-area { position: relative; }
281
275
 
282
276
  /* Desktop: split layout */
283
277
  @media (min-width: 769px) {
@@ -350,8 +344,11 @@ svg { display: block; }
350
344
  background: var(--bg2); border-left: 1px solid var(--border);
351
345
  display: flex; flex-direction: column;
352
346
  transform: translateX(100%);
353
- transition: var(--transition-normal);
347
+ transition: transform var(--transition-normal) ease;
348
+ will-change: transform;
349
+ backface-visibility: hidden;
354
350
  z-index: var(--z-drawer);
351
+ contain: layout style;
355
352
  }
356
353
  #settings-panel.open {
357
354
  transform: translateX(0);
@@ -401,7 +398,7 @@ svg { display: block; }
401
398
  .tunnel-status, .tunnel-target { font-size: 9px; padding: 1px 5px; border-radius: 4px; flex-shrink: 0; display: inline-flex; align-items: center; gap: 2px; }
402
399
  .tunnel-btn { width: 28px; height: 28px; min-width: 28px; min-height: 28px; flex-shrink: 0; }
403
400
  .tunnel-btn.stop { color: var(--red); }
404
- #conn-status { transition: background var(--transition-slow); }
401
+ #conn-status { transition: background var(--transition-slow); margin-left: 6px; }
405
402
  .reconnect-spinner { display: inline-flex; vertical-align: -1px; margin-right: 4px; }
406
403
  .reconnect-spinner svg { animation: reconnectSpin 1s linear infinite; }
407
404
  @keyframes reconnectSpin { to { transform: rotate(360deg); } }
@@ -429,7 +426,7 @@ svg { display: block; }
429
426
  #sidebar-header { flex-wrap: wrap; height: auto; }
430
427
  #file-breadcrumb { white-space: nowrap; overflow-x: auto; line-height: 1; scrollbar-width: none; font-size: 11px; color: var(--fg2); }
431
428
  #sidebar-header input { flex: 1 1 60px; min-width: 40px; height: 24px; font-size: 12px; border-radius: var(--radius-sm); }
432
- #file-list-wrap { flex: 1; overflow-y: auto; min-height: 0; touch-action: pan-y; -webkit-overflow-scrolling: touch; position: relative; overscroll-behavior: contain; content-visibility: auto; contain-intrinsic-size: 600px; }
429
+ #file-list-wrap { flex: 1; overflow-y: auto; min-height: 0; touch-action: pan-y; -webkit-overflow-scrolling: touch; position: relative; overscroll-behavior: contain; }
433
430
  #ptr-indicator { position: absolute; top: -40px; left: 0; right: 0; height: 36px; display: flex; align-items: center; justify-content: center; gap: 6px; font-size: 11px; color: var(--fg2); opacity: 0; pointer-events: none; }
434
431
  #ptr-indicator .ptr-arrow { display: inline-flex; }
435
432
  #ptr-indicator .ptr-arrow svg { transition: transform 0.2s ease; }
@@ -622,6 +619,19 @@ kbd { display: inline-block; padding: 1px 6px; font-family: var(--font); font-si
622
619
  #editor-preview.active { display: block; }
623
620
  #editor-preview-iframe { width: 100%; height: 100%; border: none; display: none; background: var(--bg); border-radius: var(--radius); }
624
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
+
625
635
  /* ═══════════════════════════════════════════════════════
626
636
  CODEMIRROR
627
637
  ═══════════════════════════════════════════════════════ */
@@ -1061,9 +1071,6 @@ kbd { display: inline-block; padding: 1px 6px; font-family: var(--font); font-si
1061
1071
  <button class="icon-btn" id="tilesBtn" aria-label="Tiles Mode" title="Tiles Mode" onclick="toggleTiles()">
1062
1072
  <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/></svg>
1063
1073
  </button>
1064
- <button class="icon-btn" id="term-fullscreen-btn" aria-label="Terminal fullscreen" title="Terminal fullscreen (Shift+F11)" onclick="toggleTerminalFullscreen()">
1065
- <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="15 3 21 3 21 9"/><polyline points="9 21 3 21 3 15"/><line x1="21" y1="3" x2="14" y2="10"/><line x1="3" y1="21" x2="10" y2="14"/></svg>
1066
- </button>
1067
1074
  <button class="icon-btn" id="more-btn" aria-label="More options" title="More options" onclick="toggleMoreMenu()">
1068
1075
  <svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" stroke="none"><circle cx="12" cy="5" r="1.7"/><circle cx="12" cy="12" r="1.7"/><circle cx="12" cy="19" r="1.7"/></svg>
1069
1076
  </button>
@@ -1233,7 +1240,29 @@ kbd { display: inline-block; padding: 1px 6px; font-family: var(--font); font-si
1233
1240
  </div>
1234
1241
  <div id="editor-area">
1235
1242
  <textarea id="editor-textarea" spellcheck="false"></textarea>
1236
- <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>
1237
1266
  </div>
1238
1267
  </div>
1239
1268
  </div>
@@ -1850,6 +1879,7 @@ async function unlockApp() {
1850
1879
  setupSwipeGestures();
1851
1880
  setupFileListTouch();
1852
1881
  setupFileListKeyboard();
1882
+ setupFileListContextMenu();
1853
1883
  setupCtxMenuKeyboard();
1854
1884
  setupMoreMenuKeyboard();
1855
1885
  restoreSidebarWidth();
@@ -2897,21 +2927,10 @@ async function loadFiles(dir) {
2897
2927
  if (loadFilesAbortController) loadFilesAbortController.abort();
2898
2928
  loadFilesAbortController = new AbortController();
2899
2929
 
2900
- if (skipHistoryPush) {
2901
- // back/forward don't push and don't clear forward
2902
- } else {
2903
- if (currentPath && currentPath !== dir) {
2904
- navHistory.push(currentPath);
2905
- }
2906
- navForwardHistory = [];
2907
- }
2930
+ const prevPath = currentPath;
2931
+ const shouldPushHistory = !skipHistoryPush && currentPath && currentPath !== dir;
2908
2932
  skipHistoryPush = false;
2909
2933
 
2910
- currentPath = dir;
2911
- document.getElementById('path-input').value = dir;
2912
- renderBreadcrumb(dir);
2913
- saveCurrentPath();
2914
-
2915
2934
  const list = document.getElementById('file-list');
2916
2935
 
2917
2936
  setFileListLoading(true);
@@ -2924,19 +2943,52 @@ async function loadFiles(dir) {
2924
2943
  const errorDiv = document.createElement('div');
2925
2944
  errorDiv.style.cssText = 'padding:16px;text-align:center;';
2926
2945
  errorDiv.innerHTML = '<div style="color:var(--red);margin-bottom:8px">Failed to load directory</div>' +
2946
+ '<div style="color:var(--fg2);font-size:11px;margin-bottom:8px;word-break:break-all">' + escHtml(e.message || 'Unknown error') + '</div>' +
2927
2947
  '<button class="btn btn-primary" style="height:28px;padding:0 12px;font-size:11px">Retry</button>';
2928
2948
  list.innerHTML = '';
2929
2949
  list.appendChild(errorDiv);
2930
2950
  errorDiv.querySelector('button').addEventListener('click', () => loadFiles(dir));
2951
+ toast(e.message || 'Failed to load directory', 'error');
2931
2952
  return;
2932
2953
  }
2933
2954
 
2934
2955
  if (!data.files) {
2935
2956
  setFileListLoading(false);
2936
- if (dir !== homeDir) loadFiles(homeDir);
2957
+ const msg = data.error || 'Failed to load directory';
2958
+ // Revert optimistic path — don't save failed dir
2959
+ // Show error instead of silent bounce
2960
+ const errorDiv = document.createElement('div');
2961
+ errorDiv.style.cssText = 'padding:16px;text-align:center;';
2962
+ errorDiv.innerHTML = '<div style="color:var(--red);margin-bottom:8px">' + escHtml(msg) + '</div>' +
2963
+ '<button class="btn btn-primary" style="height:28px;padding:0 12px;font-size:11px">Retry</button>';
2964
+ list.innerHTML = '';
2965
+ list.appendChild(errorDiv);
2966
+ errorDiv.querySelector('button').addEventListener('click', () => loadFiles(dir));
2967
+ toast(msg, 'error');
2968
+ // Only bounce to home if dir != home and home is different and we haven't already shown home
2969
+ if (dir !== homeDir && prevPath !== homeDir) {
2970
+ // keep currentPath as prevPath, don't update breadcrumb to failed dir
2971
+ document.getElementById('path-input').value = prevPath;
2972
+ renderBreadcrumb(prevPath);
2973
+ } else {
2974
+ // we are already at home or prevPath is home — update UI to reflect attempted dir for debugging
2975
+ document.getElementById('path-input').value = dir;
2976
+ renderBreadcrumb(dir);
2977
+ }
2937
2978
  return;
2938
2979
  }
2939
2980
 
2981
+ // Success — now update history and UI state
2982
+ if (shouldPushHistory) {
2983
+ navHistory.push(prevPath);
2984
+ navForwardHistory = [];
2985
+ }
2986
+
2987
+ currentPath = data.path || dir;
2988
+ document.getElementById('path-input').value = currentPath;
2989
+ renderBreadcrumb(currentPath);
2990
+ saveCurrentPath();
2991
+
2940
2992
  currentParent = data.parent || '';
2941
2993
 
2942
2994
  // Map existing DOM children by dataset.path
@@ -3000,13 +3052,16 @@ async function loadFiles(dir) {
3000
3052
  sizeEl.remove();
3001
3053
  }
3002
3054
  existing.dataset.isDir = String(f.isDir);
3055
+ existing.dataset.path = key;
3056
+ const checkEl = existing.querySelector('.file-select-check');
3057
+ if (checkEl) checkEl.setAttribute('data-path', key);
3058
+ const ellEl = existing.querySelector('.file-ellipsis');
3059
+ if (ellEl) ellEl.setAttribute('aria-label', 'More actions for ' + f.name);
3060
+ const nameEl2 = existing.querySelector('.file-name');
3061
+ if (nameEl2) nameEl2.title = key;
3003
3062
  }
3004
3063
 
3005
- addItem(key, () => {
3006
- const item = makeFileItem(f.name, f.isDir, key, sizeStr, icon);
3007
- item.addEventListener('contextmenu', e => showCtxMenu(e, f));
3008
- return item;
3009
- });
3064
+ addItem(key, () => makeFileItem(f.name, f.isDir, key, sizeStr, icon));
3010
3065
  });
3011
3066
 
3012
3067
  // Remove any orphaned nodes (not in new data)
@@ -3048,25 +3103,27 @@ function makeFileItem(name, isDir, fullPath, size, icon) {
3048
3103
  <span class="file-ellipsis" role="button" tabindex="0" aria-label="More actions for ${escHtml(name)}" title="More actions"><svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" stroke="none"><circle cx="5" cy="12" r="1.6"/><circle cx="12" cy="12" r="1.6"/><circle cx="19" cy="12" r="1.6"/></svg></span>
3049
3104
  `;
3050
3105
  div.addEventListener('click', e => {
3106
+ const curPath = div.dataset.path;
3107
+ const curIsDir = div.dataset.isDir === 'true';
3108
+ const curName = (div.querySelector('.file-name')?.textContent || curPath.split(/[\\/]/).pop() || curPath);
3051
3109
  if (e.target.closest('.file-select-check')) {
3052
- toggleFileSelection(fullPath, div);
3110
+ toggleFileSelection(curPath, div);
3053
3111
  if (selectedFiles.length > 0) document.getElementById('select-actions').style.display = 'flex';
3054
3112
  return;
3055
3113
  }
3056
3114
  if (e.target.closest('.file-ellipsis')) {
3057
3115
  e.stopPropagation();
3058
3116
  const rect = div.querySelector('.file-ellipsis').getBoundingClientRect();
3059
- const m = fullPath.match(/\.([^.]+)$/);
3060
- showCtxMenu({ clientX: rect.left + rect.width / 2, clientY: rect.bottom + 4, preventDefault() {}, stopPropagation() {} }, { path: fullPath, name, isDir, ext: m ? '.' + m[1].toLowerCase() : '' });
3117
+ const m = curPath.match(/\.([^.]+)$/);
3118
+ showCtxMenu({ clientX: rect.left + rect.width / 2, clientY: rect.bottom + 4, preventDefault() {}, stopPropagation() {} }, { path: curPath, name: curName, isDir: curIsDir, ext: m ? '.' + m[1].toLowerCase() : '' });
3061
3119
  return;
3062
3120
  }
3063
3121
  if (selectMode) {
3064
- toggleFileSelection(fullPath, div);
3122
+ toggleFileSelection(curPath, div);
3065
3123
  return;
3066
3124
  }
3067
- if (isDir) loadFiles(fullPath);
3068
- else if (isImageFile(fullPath)) openImageViewer(fullPath);
3069
- else openFileEditor(fullPath);
3125
+ if (curIsDir) loadFiles(curPath);
3126
+ else openFileEditor(curPath);
3070
3127
  });
3071
3128
  const ell = div.querySelector('.file-ellipsis');
3072
3129
  if (ell) {
@@ -3074,9 +3131,12 @@ function makeFileItem(name, isDir, fullPath, size, icon) {
3074
3131
  if (e.key === 'Enter' || e.key === ' ') {
3075
3132
  e.preventDefault();
3076
3133
  e.stopPropagation();
3134
+ const curPath = div.dataset.path;
3135
+ const curIsDir = div.dataset.isDir === 'true';
3136
+ const curName = (div.querySelector('.file-name')?.textContent || curPath.split(/[\\/]/).pop() || curPath);
3077
3137
  const rect = ell.getBoundingClientRect();
3078
- const m = fullPath.match(/\.([^.]+)$/);
3079
- showCtxMenu({ clientX: rect.left + rect.width / 2, clientY: rect.bottom + 4, preventDefault() {}, stopPropagation() {} }, { path: fullPath, name, isDir, ext: m ? '.' + m[1].toLowerCase() : '' });
3138
+ const m = curPath.match(/\.([^.]+)$/);
3139
+ showCtxMenu({ clientX: rect.left + rect.width / 2, clientY: rect.bottom + 4, preventDefault() {}, stopPropagation() {} }, { path: curPath, name: curName, isDir: curIsDir, ext: m ? '.' + m[1].toLowerCase() : '' });
3080
3140
  }
3081
3141
  });
3082
3142
  }
@@ -3120,10 +3180,33 @@ function setupFileListKeyboard() {
3120
3180
  });
3121
3181
  }
3122
3182
 
3183
+ function setupFileListContextMenu() {
3184
+ const list = document.getElementById('file-list');
3185
+ if (!list || list.dataset._ctxSetup) return;
3186
+ list.dataset._ctxSetup = '1';
3187
+ list.addEventListener('contextmenu', e => {
3188
+ const item = e.target.closest('.file-item');
3189
+ if (!item || !item.dataset.path) return;
3190
+ const curPath = item.dataset.path;
3191
+ const curIsDir = item.dataset.isDir === 'true';
3192
+ const curName = (item.querySelector('.file-name')?.textContent || curPath.split(/[\\/]/).pop() || curPath);
3193
+ const m = curPath.match(/\.([^.]+)$/);
3194
+ const ext = m ? '.' + m[1].toLowerCase() : '';
3195
+ showCtxMenu(e, { path: curPath, name: curName, isDir: curIsDir, ext });
3196
+ });
3197
+ }
3198
+
3123
3199
  function refreshFiles() {
3124
3200
  if (currentPath) loadFiles(currentPath);
3125
3201
  }
3126
- function navigateTo(p) { loadFiles(p || homeDir); }
3202
+ function navigateTo(p) {
3203
+ if (!p || !String(p).trim()) return loadFiles(homeDir);
3204
+ let target = String(p).trim();
3205
+ // If relative path, resolve against currentPath
3206
+ const isAbsolute = target.startsWith('/') || /^[A-Za-z]:[\\/]/.test(target) || target.startsWith('\\\\');
3207
+ if (!isAbsolute) target = joinPath(currentPath || homeDir, target);
3208
+ loadFiles(target);
3209
+ }
3127
3210
  function navigateUp() {
3128
3211
  if (currentParent && currentParent !== currentPath) {
3129
3212
  loadFiles(currentParent);
@@ -3159,7 +3242,10 @@ function startFileWatcher() {
3159
3242
  if (fileWatchTimer) return;
3160
3243
  const watch = () => {
3161
3244
  const sb = document.getElementById('sidebar');
3245
+ const sp = document.getElementById('settings-panel');
3162
3246
  if (!sb || sb.classList.contains('hidden') || document.hidden) return;
3247
+ if (sp && sp.classList.contains('open')) return;
3248
+ if (document.querySelector('.overlay.open')) return;
3163
3249
  if (currentPath) loadFiles(currentPath);
3164
3250
  };
3165
3251
  const onVisibility = () => {
@@ -3183,8 +3269,10 @@ function getFileIcon(ext) {
3183
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 };
3184
3270
  const imgExts = { '.png':1,'.jpg':1,'.jpeg':1,'.gif':1,'.svg':1,'.webp':1,'.ico':1 };
3185
3271
  const archiveExts = { '.zip':1,'.tar':1,'.gz':1,'.bz2':1,'.xz':1,'.7z':1,'.rar':1 };
3272
+ const docExts = { '.pdf':1,'.epub':1 };
3186
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>';
3187
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>';
3188
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>';
3189
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>';
3190
3278
  }
@@ -3469,8 +3557,8 @@ function showCtxMenu(e, file) {
3469
3557
  ctxTarget = file;
3470
3558
  const menu = document.getElementById('ctx-menu');
3471
3559
  menu.style.display = 'block';
3472
- document.getElementById('ctx-open').style.display = file.isDir || isImageFile(file.path) ? '' : 'none';
3473
- 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' : '');
3474
3562
  document.getElementById('ctx-open-term').style.display = file.isDir ? '' : 'none';
3475
3563
  document.getElementById('ctx-paste').style.display = fsClipboard ? '' : 'none';
3476
3564
  document.getElementById('ctx-zip').style.display = '';
@@ -3501,7 +3589,12 @@ document.addEventListener('click', () => {
3501
3589
  if (termMenu) termMenu.style.display = 'none';
3502
3590
  });
3503
3591
 
3504
- 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
+ };
3505
3598
  document.getElementById('ctx-edit').onclick = () => ctxTarget && openFileEditor(ctxTarget.path);
3506
3599
  document.getElementById('ctx-download').onclick = () => {
3507
3600
  if (selectedFiles.length > 0) { downloadSelected(); return; }
@@ -3664,9 +3757,8 @@ async function confirmRename() {
3664
3757
  body: JSON.stringify({ oldPath: renamePath, newName })
3665
3758
  });
3666
3759
  setBtnBusy(document.getElementById('rename-ok-btn'), false);
3667
- if (r.success) { toast('Renamed', 'success'); refreshFiles(); }
3760
+ if (r.success) { toast('Renamed', 'success'); refreshFiles(); closeOverlay('rename-overlay'); }
3668
3761
  else showFieldError('rename-error', r.error || 'Rename failed');
3669
- closeOverlay('rename-overlay');
3670
3762
  }
3671
3763
 
3672
3764
  async function deleteFile(file) {
@@ -4101,8 +4193,15 @@ function getCMmode(fileName) {
4101
4193
  }
4102
4194
 
4103
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; }
4104
4200
  const r = await api(`/api/files/read?path=${encodeURIComponent(path)}`);
4105
- 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
+ }
4106
4205
  if (r.length > MAX_EDITOR_SIZE) {
4107
4206
  const ok = await confirmDialog({ title: 'Large file', message: `File is ${(r.length / 1024 / 1024).toFixed(1)}MB. Open anyway?`, okText: 'Open', danger: true });
4108
4207
  if (!ok) return;
@@ -4113,6 +4212,9 @@ async function openFileEditor(path) {
4113
4212
  const fileName = path.split(sep).pop() || path;
4114
4213
  const isMd = /\.md$/i.test(fileName);
4115
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 = '';
4116
4218
 
4117
4219
  const cm = initCodeMirror();
4118
4220
  cm.setValue(r.content);
@@ -4127,8 +4229,12 @@ async function openFileEditor(path) {
4127
4229
  const toggleBtn = document.getElementById('md-preview-toggle');
4128
4230
  const refreshBtn = document.getElementById('preview-refresh-btn');
4129
4231
  const previewIframe = document.getElementById('editor-preview-iframe');
4232
+ const mdContent = document.getElementById('md-preview-content');
4130
4233
  preview.classList.remove('active');
4131
4234
  if (previewIframe) { previewIframe.style.display = 'none'; previewIframe.srcdoc = ''; }
4235
+ if (mdContent) { mdContent.style.display = 'none'; mdContent.innerHTML = ''; }
4236
+ mdPreviewActive = false;
4237
+ clearPreviewLiveReload();
4132
4238
  if ((isMd && typeof marked !== 'undefined') || isHtml) {
4133
4239
  toggleBtn.style.display = '';
4134
4240
  toggleBtn.textContent = 'Preview';
@@ -4208,17 +4314,20 @@ async function saveFile() {
4208
4314
  }
4209
4315
 
4210
4316
  async function closeEditor() {
4211
- const currentContent = editor ? editor.getValue() : document.getElementById('editor-textarea').value;
4212
- if (currentContent !== editorOriginalContent) {
4213
- const ok = await confirmDialog({ title: 'Unsaved changes', message: 'You have unsaved changes. Close anyway?', okText: 'Discard', cancelText: 'Keep Editing', danger: true });
4214
- 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
+ }
4215
4324
  }
4216
4325
  clearPreviewLiveReload();
4326
+ cleanupDocViewers();
4217
4327
  const ev = document.getElementById('editor-view');
4218
4328
  ev.classList.remove('open');
4219
4329
  ev.classList.remove('fullscreen');
4220
4330
  document.removeEventListener('keydown', _fsEscHandler);
4221
- try { if (document.fullscreenElement) document.exitFullscreen(); } catch {}
4222
4331
  const fsBtn = document.getElementById('editor-fullscreen-toggle');
4223
4332
  if (fsBtn) { fsBtn.setAttribute('aria-label','Fullscreen'); fsBtn.title='Fullscreen (F11)'; fsBtn.innerHTML='<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="15 3 21 3 21 9"/><polyline points="9 21 3 21 3 15"/><line x1="21" y1="3" x2="14" y2="10"/><line x1="3" y1="21" x2="10" y2="14"/></svg>'; }
4224
4333
  document.getElementById('content').classList.remove('editor-open');
@@ -4227,6 +4336,8 @@ async function closeEditor() {
4227
4336
  document.getElementById('preview-refresh-btn').style.display = 'none';
4228
4337
  const previewIframe = document.getElementById('editor-preview-iframe');
4229
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 = ''; }
4230
4341
  const cm = document.querySelector('.CodeMirror');
4231
4342
  if (cm) cm.style.display = '';
4232
4343
  mdPreviewActive = false;
@@ -4239,11 +4350,445 @@ async function closeEditor() {
4239
4350
 
4240
4351
  const IMAGE_EXTS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.svg', '.webp', '.bmp', '.ico']);
4241
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
+
4242
4359
  function isImageFile(path) {
4243
4360
  const m = path.match(/\.([^.]+)$/);
4244
4361
  return m ? IMAGE_EXTS.has('.' + m[1].toLowerCase()) : false;
4245
4362
  }
4246
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
+
4247
4792
  let _ivImageUrl = null;
4248
4793
 
4249
4794
  function openImageViewer(path) {
@@ -4299,6 +4844,9 @@ function toggleHtmlPreview() {
4299
4844
  editor.focus();
4300
4845
  clearPreviewLiveReload();
4301
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');
4302
4850
  renderHtmlPreview();
4303
4851
  if (cmWrapper) cmWrapper.style.display = 'none';
4304
4852
  preview.classList.add('active');
@@ -4309,12 +4857,74 @@ function toggleHtmlPreview() {
4309
4857
  }
4310
4858
  }
4311
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
+ }
4312
4920
  function renderHtmlPreview() {
4313
4921
  const iframe = document.getElementById('editor-preview-iframe');
4922
+ const mdContent = document.getElementById('md-preview-content');
4314
4923
  const raw = editor ? editor.getValue() : '';
4315
4924
  if (!iframe) return;
4316
- // Clear previous live reload to prevent leak on re-entry (U83)
4317
- clearPreviewLiveReload();
4925
+ if (mdContent) mdContent.style.display = 'none';
4926
+ if (iframe) iframe.style.display = 'block';
4927
+ const baseDir = getHtmlBaseDir();
4318
4928
  const s = getComputedStyle(document.documentElement);
4319
4929
  const bg = s.getPropertyValue('--bg').trim();
4320
4930
  const fg = s.getPropertyValue('--fg').trim();
@@ -4323,11 +4933,31 @@ function renderHtmlPreview() {
4323
4933
  const border = s.getPropertyValue('--border').trim();
4324
4934
  const font = s.getPropertyValue('--font').trim();
4325
4935
  const isDark = !['#f9f9fb', '#ffffff', 'rgb(249, 249, 251)', 'rgb(255, 255, 255)'].includes(bg);
4326
- 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>
4327
4956
  <html>
4328
4957
  <head>
4329
4958
  <meta charset="UTF-8">
4330
4959
  <meta name="color-scheme" content="${isDark ? 'dark' : 'light'}">
4960
+ <base href="${escHtml(baseHrefFrag)}">
4331
4961
  <style>
4332
4962
  :root { color-scheme: ${isDark ? 'dark' : 'light'}; }
4333
4963
  * { box-sizing: border-box; }
@@ -4363,16 +4993,21 @@ function renderHtmlPreview() {
4363
4993
  input:focus, textarea:focus, select:focus { outline: 2px solid ${accent}; outline-offset: 1px; }
4364
4994
  </style>
4365
4995
  </head>
4366
- <body>${typeof DOMPurify !== 'undefined' ? DOMPurify.sanitize(raw, { USE_PROFILES: { html: true }, ADD_ATTR: ['target'] }) : raw}</body>
4996
+ <body>${fragment}</body>
4367
4997
  </html>`;
4998
+ }
4368
4999
  iframe.srcdoc = doc;
4369
5000
  iframe.style.display = 'block';
4370
5001
  }
4371
5002
 
4372
5003
  function renderMdPreview() {
4373
5004
  const preview = document.getElementById('editor-preview');
5005
+ const mdContent = document.getElementById('md-preview-content');
5006
+ const iframe = document.getElementById('editor-preview-iframe');
4374
5007
  const raw = editor ? editor.getValue() : '';
4375
- if (!preview) return;
5008
+ if (!preview || !mdContent) return;
5009
+ if (iframe) iframe.style.display = 'none';
5010
+ mdContent.style.display = 'block';
4376
5011
  try {
4377
5012
  const html = marked.parse(raw, { breaks: true, gfm: true, langPrefix: 'language-' });
4378
5013
  const sanitized = typeof DOMPurify !== 'undefined' ? DOMPurify.sanitize(html) : html;
@@ -4385,7 +5020,7 @@ function renderMdPreview() {
4385
5020
  const border = s.getPropertyValue('--border').trim();
4386
5021
  const fg2 = s.getPropertyValue('--fg2').trim();
4387
5022
  const font = s.getPropertyValue('--font').trim();
4388
- preview.innerHTML = `
5023
+ mdContent.innerHTML = `
4389
5024
  <style>
4390
5025
  .md-rendered { max-width: 800px; margin: 0 auto; }
4391
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; }
@@ -4411,7 +5046,7 @@ function renderMdPreview() {
4411
5046
  <div class="md-rendered">${sanitized}</div>`;
4412
5047
  } catch (e) {
4413
5048
  console.warn('Markdown preview error:', e);
4414
- preview.textContent = 'Error rendering markdown preview';
5049
+ mdContent.textContent = 'Error rendering markdown preview';
4415
5050
  }
4416
5051
  }
4417
5052
 
@@ -4452,6 +5087,8 @@ function toggleMdPreview() {
4452
5087
  const refreshBtn = document.getElementById('preview-refresh-btn');
4453
5088
  mdPreviewActive = !mdPreviewActive;
4454
5089
  if (mdPreviewActive) {
5090
+ document.getElementById('pdf-viewer').classList.remove('active');
5091
+ document.getElementById('epub-viewer').classList.remove('active');
4455
5092
  renderMdPreview();
4456
5093
  if (cmWrapper) cmWrapper.style.display = 'none';
4457
5094
  preview.classList.add('active');
@@ -4629,16 +5266,20 @@ function openSettings() {
4629
5266
  const isOpen = panel.classList.contains('open');
4630
5267
  // Don't inert #main because panel is inside #main — inert would disable panel itself. Inert content only (sidebar is sibling).
4631
5268
  const content = document.getElementById('content');
4632
- panel.classList.toggle('open');
4633
5269
  if (!isOpen) {
5270
+ panel.classList.add('open');
4634
5271
  panel.setAttribute('aria-modal', 'true');
4635
5272
  panel.setAttribute('role', 'dialog');
4636
- if (content) content.setAttribute('inert', '');
4637
- installFocusTrap(panel);
4638
- panel.querySelector('select, button, input')?.focus();
4639
- // Defer outside-click handler to next tick so opening click doesn't immediately close
5273
+ // Defer inert + focus to next frame so panel transform can composite on GPU without reflowing sidebar/file-list
5274
+ requestAnimationFrame(() => {
5275
+ if (content) content.setAttribute('inert', '');
5276
+ installFocusTrap(panel);
5277
+ const firstFocusable = panel.querySelector('select, button, input');
5278
+ if (firstFocusable) firstFocusable.focus({ preventScroll: true });
5279
+ });
4640
5280
  setTimeout(() => document.addEventListener('click', closeSettingsOnClickOutside, true), 50);
4641
5281
  } else {
5282
+ panel.classList.remove('open');
4642
5283
  panel.removeAttribute('aria-modal');
4643
5284
  if (content) content.removeAttribute('inert');
4644
5285
  removeFocusTrap();
@@ -5118,13 +5759,13 @@ function toggleEditorFullscreen() {
5118
5759
  }
5119
5760
  if (isFS) {
5120
5761
  document.addEventListener('keydown', _fsEscHandler);
5121
- try { view.requestFullscreen?.(); } catch {}
5122
5762
  } else {
5123
5763
  document.removeEventListener('keydown', _fsEscHandler);
5124
- try { if (document.fullscreenElement) document.exitFullscreen(); } catch {}
5125
5764
  requestAnimationFrame(() => { tabs.forEach(t=>{try{fitTerm(t);}catch(e){console.warn(e);}}); if(editor) editor.refresh(); });
5126
5765
  }
5127
5766
  if (editor) setTimeout(()=>editor.refresh(), 80);
5767
+ // Keep sidebar visible — editor fullscreen now only covers terminal area (absolute inside #editor-split-area)
5768
+ requestAnimationFrame(() => { tabs.forEach(t=>{try{fitTerm(t);}catch(e){console.warn(e);}}); });
5128
5769
  }
5129
5770
  function _fsEscHandler(e) {
5130
5771
  if (e.key === 'Escape' || e.key === 'F11') {
@@ -5133,49 +5774,8 @@ function _fsEscHandler(e) {
5133
5774
  }
5134
5775
  }
5135
5776
 
5136
- function toggleTerminalFullscreen() {
5137
- const content = document.getElementById('content');
5138
- const term = document.getElementById('terminals');
5139
- const sidebar = document.getElementById('sidebar');
5140
- if (!content || !term) return;
5141
- const isFS = content.classList.toggle('terminal-fullscreen');
5142
- term.classList.toggle('fullscreen', isFS);
5143
- const btn = document.getElementById('term-fullscreen-btn');
5144
- if (btn) {
5145
- btn.classList.toggle('active', isFS);
5146
- btn.setAttribute('aria-label', isFS ? 'Exit terminal fullscreen' : 'Terminal fullscreen');
5147
- btn.title = isFS ? 'Exit terminal fullscreen (Esc/Shift+F11)' : 'Terminal fullscreen (Shift+F11)';
5148
- btn.innerHTML = isFS
5149
- ? '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="4 14 10 14 10 4"/><polyline points="20 10 14 10 14 20"/><line x1="14" y1="10" x2="21" y2="3"/><line x1="10" y1="14" x2="3" y2="21"/></svg>'
5150
- : '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="15 3 21 3 21 9"/><polyline points="9 21 3 21 3 15"/><line x1="21" y1="3" x2="14" y2="10"/><line x1="3" y1="21" x2="10" y2="14"/></svg>';
5151
- }
5152
- if (isFS) {
5153
- document.addEventListener('keydown', _termFsEscHandler);
5154
- try { content.requestFullscreen?.(); } catch {}
5155
- } else {
5156
- document.removeEventListener('keydown', _termFsEscHandler);
5157
- try { if (document.fullscreenElement) document.exitFullscreen(); } catch {}
5158
- // Ensure sidebar is visible when exiting fullscreen
5159
- if (sidebar) {
5160
- sidebar.style.display = '';
5161
- sidebar.classList.remove('hidden');
5162
- if (window.innerWidth <= 768) {
5163
- sidebar.classList.add('mobile-open');
5164
- }
5165
- }
5166
- }
5167
- requestAnimationFrame(() => { tabs.forEach(t=>{try{fitTerm(t);}catch(e){console.warn(e);}}); const at=getActiveTab(); try{at?.term?.focus();}catch{} });
5168
- }
5169
- function _termFsEscHandler(e) {
5170
- const isTermFS = document.getElementById('content')?.classList.contains('terminal-fullscreen');
5171
- if (!isTermFS) return;
5172
- if (e.key === 'Escape' || (e.key === 'F11' && e.shiftKey)) { e.preventDefault(); toggleTerminalFullscreen(); }
5173
- else if (e.key === 'F11' && !e.shiftKey) {
5174
- // Let editor handle F11 when editor fullscreen, but prevent browser default
5175
- const ev = document.getElementById('editor-view');
5176
- if (!ev || !ev.classList.contains('fullscreen')) e.preventDefault();
5177
- }
5178
- }
5777
+ // Terminal fullscreen removed — per request, terminal fullscreen should not cover sidebar
5778
+ // toggleTerminalFullscreen and _termFsEscHandler intentionally removed
5179
5779
  const hasWakeLock = 'wakeLock' in navigator;
5180
5780
 
5181
5781
  async function requestWakeLock() {
@@ -5270,8 +5870,7 @@ function setupKeyboardShortcuts() {
5270
5870
  if (ctrl && e.shiftKey && e.key === 'ArrowRight') { e.preventDefault(); cycleTab(1); }
5271
5871
  if (ctrl && e.shiftKey && e.key === 'ArrowLeft') { e.preventDefault(); cycleTab(-1); }
5272
5872
  if (ctrl && e.shiftKey && e.key === 'r') { e.preventDefault(); refreshPreview(); }
5273
- if (e.key === 'F11' && e.shiftKey) { e.preventDefault(); toggleTerminalFullscreen(); }
5274
- else if (e.key === 'F11' && document.getElementById('editor-view').classList.contains('open')) { e.preventDefault(); toggleEditorFullscreen(); }
5873
+ if (e.key === 'F11' && document.getElementById('editor-view').classList.contains('open')) { e.preventDefault(); toggleEditorFullscreen(); }
5275
5874
  if (e.key === 'Escape') {
5276
5875
  const termMenu = document.getElementById('term-ctx-menu');
5277
5876
  if (termMenu && termMenu.style.display !== 'none') {
@@ -5661,8 +6260,23 @@ function setupDragDrop() {
5661
6260
  });
5662
6261
  document.addEventListener('dragleave', e => {
5663
6262
  if (!isFileDrag(e)) return;
5664
- dragCnt--;
5665
- if (dragCnt <= 0) { dragCnt = 0; overlay.classList.remove('active'); }
6263
+ // Only hide when leaving the viewport (relatedTarget null) or overlay itself
6264
+ // Prevents nested dragenter/leave from child elements desyncing counter
6265
+ if (!e.relatedTarget || e.relatedTarget === document.documentElement || e.target === overlay) {
6266
+ dragCnt = 0;
6267
+ overlay.classList.remove('active');
6268
+ } else {
6269
+ dragCnt--;
6270
+ if (dragCnt <= 0) { dragCnt = 0; overlay.classList.remove('active'); }
6271
+ }
6272
+ });
6273
+ document.addEventListener('dragend', () => {
6274
+ dragCnt = 0;
6275
+ overlay.classList.remove('active');
6276
+ });
6277
+ window.addEventListener('blur', () => {
6278
+ dragCnt = 0;
6279
+ overlay.classList.remove('active');
5666
6280
  });
5667
6281
  document.addEventListener('dragover', e => {
5668
6282
  if (!isFileDrag(e)) return;
@@ -6116,7 +6730,11 @@ function doFinderSearch() {
6116
6730
  fiDir.className = 'fi-dir';
6117
6731
  fiDir.textContent = r.dir;
6118
6732
  div.appendChild(fiDir);
6119
- 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
+ });
6120
6738
  div.addEventListener('mouseenter', () => { document.querySelectorAll('.finder-item.selected').forEach(el => el.classList.remove('selected')); div.classList.add('selected'); finderIdx = i; });
6121
6739
  results.appendChild(div);
6122
6740
  });