@oar-sh/oar 0.9.7 → 0.9.9
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/CHANGELOG.md +344 -0
- package/README.md +89 -40
- package/bin/oar.js +1023 -135
- package/package.json +4 -5
- package/server/README.md +20 -1
- package/server/claude-worker/claude-sdk-adapter.mjs +0 -11
- package/server/claude-worker/claude-session-process.mjs +196 -28
- package/server/claude-worker/sdk-message-normalizer.mjs +28 -0
- package/server/db-schema.mjs +8 -0
- package/server/migrations/0008-message-pins.mjs +85 -0
- package/server/public/app/activity-replay-state.mjs +68 -5
- package/server/public/app/api-client.js +36 -2
- package/server/public/app/attachments-view.js +508 -125
- package/server/public/app/back-guard.mjs +58 -0
- package/server/public/app/bootstrap.js +170 -59
- package/server/public/app/context-usage-view.mjs +127 -8
- package/server/public/app/conversation-view.js +172 -32
- package/server/public/app/external-link-policy.mjs +4 -1
- package/server/public/app/file-version.mjs +58 -0
- package/server/public/app/journal-view.js +16 -2
- package/server/public/app/pinned-messages-view.mjs +253 -0
- package/server/public/app/pwa-install.js +6 -1
- package/server/public/app/relay-dot-state.mjs +8 -1
- package/server/public/app/router.js +8 -2
- package/server/public/app/scrollbar-hover.mjs +78 -0
- package/server/public/app/settings-modal.js +4 -1
- package/server/public/app/socket-handlers.js +8 -1
- package/server/public/app/store.js +32 -0
- package/server/public/app/transcript-separators.mjs +48 -11
- package/server/public/app/viewer-chat-gallery.mjs +59 -0
- package/server/public/app/viewer-controls-state.mjs +59 -0
- package/server/public/app/viewer-gallery.mjs +46 -0
- package/server/public/app/viewer-gestures.mjs +105 -0
- package/server/public/app/viewer-history.mjs +43 -0
- package/server/public/app/viewer-tap.mjs +112 -0
- package/server/public/app-icon-192.png +0 -0
- package/server/public/app-icon-512.png +0 -0
- package/server/public/app-icon-light-192.png +0 -0
- package/server/public/app-icon-light-512.png +0 -0
- package/server/public/app-icon-light-maskable-512.png +0 -0
- package/server/public/app-icon-light.svg +4 -0
- package/server/public/app-icon-maskable-512.png +0 -0
- package/server/public/app-icon.svg +3 -3
- package/server/public/favicon.ico +0 -0
- package/server/public/index.html +487 -67
- package/server/public/manifest.webmanifest +9 -3
- package/server/public/sw.js +6 -1
- package/server/repositories/message-pin-statements.mjs +39 -0
- package/server/repositories/message-repository.mjs +5 -0
- package/server/routes/messages-routes.mjs +36 -62
- package/server/routes/sessions-routes.mjs +107 -26
- package/server/server-runtime.mjs +39 -8
- package/server/services/claude-context-usage.mjs +51 -3
- package/server/services/cloudflared-tunnel-service.mjs +139 -41
- package/server/services/file-serving.mjs +155 -0
- package/server/services/message-pin-list.mjs +52 -0
- package/server/services/oar-cli-helpers.mjs +205 -4
- package/server/services/oar-cli-menu.mjs +240 -0
- package/server/services/oar-cli-wsl.mjs +129 -0
- package/server/services/pwa-manifest-theme.mjs +30 -0
- package/server/services/safe-served-content.mjs +61 -11
- package/server/services/session-history-refresh-service.mjs +24 -0
- package/server/services/session-worker-registry-service.mjs +18 -1
- package/server/services/update-install-service.mjs +9 -3
|
@@ -49,9 +49,21 @@ import {
|
|
|
49
49
|
copyReferenceTokenToClipboard,
|
|
50
50
|
eventClosest,
|
|
51
51
|
} from './router.js';
|
|
52
|
+
import { buildDownloadLink } from './file-version.mjs';
|
|
52
53
|
import { markdownHeadingId, resolveFilePreviewLink } from './file-preview-navigation.mjs';
|
|
53
54
|
import { openExternalNavigation } from './external-link-policy.mjs';
|
|
54
55
|
import { attachCodeCopyButtons } from './code-copy.mjs';
|
|
56
|
+
import { createControlsState } from './viewer-controls-state.mjs';
|
|
57
|
+
import { createTapRecognizer } from './viewer-tap.mjs';
|
|
58
|
+
import { createSwipeRecognizer } from './viewer-gestures.mjs';
|
|
59
|
+
import { createViewerHistory } from './viewer-history.mjs';
|
|
60
|
+
import {
|
|
61
|
+
galleryAt,
|
|
62
|
+
galleryFromFolder,
|
|
63
|
+
galleryNeighborIndex,
|
|
64
|
+
galleryNeighbors,
|
|
65
|
+
galleryPositionLabel,
|
|
66
|
+
} from './viewer-gallery.mjs';
|
|
55
67
|
import {
|
|
56
68
|
deepestExistingAncestor,
|
|
57
69
|
planRepoRehydration,
|
|
@@ -76,6 +88,15 @@ function isVideoMimeType(mimeType) {
|
|
|
76
88
|
return String(mimeType || '').toLowerCase().startsWith('video/');
|
|
77
89
|
}
|
|
78
90
|
|
|
91
|
+
function isAudioMimeType(mimeType) {
|
|
92
|
+
return String(mimeType || '').toLowerCase().startsWith('audio/');
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Kinds whose bytes arrive as text in the preview payload and read in a
|
|
96
|
+
// column; the others are shown edge to edge on black, or as a card.
|
|
97
|
+
const TEXT_PREVIEW_KINDS = new Set(['markdown', 'code', 'text']);
|
|
98
|
+
const DARK_PREVIEW_KINDS = new Set(['image', 'video', 'audio']);
|
|
99
|
+
|
|
79
100
|
function normalizeVideoPreviewOptions(options = {}) {
|
|
80
101
|
const startSeconds = Math.max(0, Number(options?.startSeconds ?? options?.startAtSeconds ?? 0) || 0);
|
|
81
102
|
const preload = String(options?.preload || 'metadata').toLowerCase();
|
|
@@ -195,17 +216,18 @@ export function renderAttachmentMarkup(attachments, { messageId = '' } = {}) {
|
|
|
195
216
|
: ''}
|
|
196
217
|
</div>`
|
|
197
218
|
: '';
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
const
|
|
219
|
+
const isAudio = isAudioMimeType(att?.type);
|
|
220
|
+
if ((isImage || isVideo || isAudio) && rawUrl) {
|
|
221
|
+
// Opened by the conversation's click handler (see
|
|
222
|
+
// viewer-chat-gallery.mjs), which also finds the other media around it.
|
|
223
|
+
const mediaKind = isVideo ? 'video' : (isAudio ? 'audio' : 'image');
|
|
224
|
+
const chip = isVideo ? '🎞️' : '🎵';
|
|
203
225
|
return `
|
|
204
|
-
<div class="msg-attachment msg-attachment-${
|
|
205
|
-
${
|
|
206
|
-
? `<
|
|
207
|
-
: `<
|
|
208
|
-
<div class="msg-attachment-meta"><a href="#"
|
|
226
|
+
<div class="msg-attachment msg-attachment-${mediaKind}" data-media-name="${name}" data-media-url="${escHtml(rawUrl)}" data-media-type="${escHtml(att?.type || 'image/jpeg')}">
|
|
227
|
+
${isImage
|
|
228
|
+
? `<img src="${escHtml(rawUrl)}" alt="${name}" loading="eager" decoding="async">`
|
|
229
|
+
: `<div class="msg-attachment-video-chip">${chip}</div>`}
|
|
230
|
+
<div class="msg-attachment-meta"><a href="#" data-media-open>${name}</a> · ${type}${sizeText} · <a href="#" data-media-open>open</a>${continuityActions}</div>
|
|
209
231
|
</div>`;
|
|
210
232
|
}
|
|
211
233
|
if (rawUrl) {
|
|
@@ -610,8 +632,10 @@ function updateFilePreviewUiState() {
|
|
|
610
632
|
const payload = filePreviewState.payload;
|
|
611
633
|
const isMarkdown = payload?.kind === 'markdown';
|
|
612
634
|
const isUpload = filePreviewState.source === 'upload';
|
|
613
|
-
|
|
614
|
-
|
|
635
|
+
// Preview/Raw only mean something for text; media has no raw form to show.
|
|
636
|
+
const hasTextModes = !isUpload && TEXT_PREVIEW_KINDS.has(payload?.kind);
|
|
637
|
+
previewBtn.style.display = hasTextModes ? '' : 'none';
|
|
638
|
+
rawBtn.style.display = hasTextModes ? '' : 'none';
|
|
615
639
|
previewBtn.classList.toggle('active', filePreviewState.mode === 'preview');
|
|
616
640
|
rawBtn.classList.toggle('active', filePreviewState.mode === 'raw');
|
|
617
641
|
htmlBtn.style.display = (!isUpload && isMarkdown && filePreviewState.mode === 'preview') ? 'inline-block' : 'none';
|
|
@@ -625,6 +649,14 @@ function updateFilePreviewUiState() {
|
|
|
625
649
|
if (annotateBtn) annotateBtn.hidden = !filePreviewSupportsAnnotation();
|
|
626
650
|
const copyBtn = document.getElementById('file-preview-copy-btn');
|
|
627
651
|
if (copyBtn) copyBtn.hidden = payload?.kind !== 'image';
|
|
652
|
+
const gallery = filePreviewState.gallery;
|
|
653
|
+
const bottomChrome = document.getElementById('file-preview-chrome-bottom');
|
|
654
|
+
if (bottomChrome) {
|
|
655
|
+
bottomChrome.hidden = !gallery;
|
|
656
|
+
document.getElementById('file-preview-position').textContent = galleryPositionLabel(gallery);
|
|
657
|
+
document.getElementById('file-preview-prev').disabled = galleryNeighborIndex(gallery, -1) === -1;
|
|
658
|
+
document.getElementById('file-preview-next').disabled = galleryNeighborIndex(gallery, 1) === -1;
|
|
659
|
+
}
|
|
628
660
|
}
|
|
629
661
|
|
|
630
662
|
/**
|
|
@@ -681,6 +713,7 @@ function snapshotFilePreviewState() {
|
|
|
681
713
|
error: filePreviewState.error,
|
|
682
714
|
payload: filePreviewState.payload,
|
|
683
715
|
viewerOptions: filePreviewState.viewerOptions,
|
|
716
|
+
gallery: filePreviewState.gallery,
|
|
684
717
|
scrollTop: Number(bodyEl?.scrollTop || 0),
|
|
685
718
|
};
|
|
686
719
|
}
|
|
@@ -728,25 +761,92 @@ function teardownImageZoom() {
|
|
|
728
761
|
if (bodyEl) bodyEl.classList.remove('image-zoom-mode');
|
|
729
762
|
}
|
|
730
763
|
|
|
731
|
-
function
|
|
732
|
-
const
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
const onCanPlay = videoPreview.onCanPlay;
|
|
736
|
-
if (video) {
|
|
737
|
-
if (onLoadedMetadata) video.removeEventListener('loadedmetadata', onLoadedMetadata);
|
|
738
|
-
if (onError) video.removeEventListener('error', onError);
|
|
739
|
-
if (onCanPlay) video.removeEventListener('canplay', onCanPlay);
|
|
764
|
+
function teardownMediaPreview() {
|
|
765
|
+
const media = mediaPreview.el;
|
|
766
|
+
if (media) {
|
|
767
|
+
for (const [eventName, handler] of mediaPreview.listeners) media.removeEventListener(eventName, handler);
|
|
740
768
|
try {
|
|
741
|
-
|
|
769
|
+
media.pause();
|
|
742
770
|
} catch {}
|
|
743
771
|
}
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
videoPreview.onCanPlay = null;
|
|
772
|
+
mediaPreview.el = null;
|
|
773
|
+
mediaPreview.listeners = [];
|
|
774
|
+
viewerControls.setPlaying(false);
|
|
748
775
|
const bodyEl = document.getElementById('file-preview-body');
|
|
749
|
-
if (bodyEl) bodyEl.classList.remove('video-preview-mode');
|
|
776
|
+
if (bodyEl) bodyEl.classList.remove('video-preview-mode', 'audio-preview-mode', 'card-preview-mode');
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
function listenMediaPreview(media, eventName, handler, options) {
|
|
780
|
+
media.addEventListener(eventName, handler, options);
|
|
781
|
+
mediaPreview.listeners.push([eventName, handler]);
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
// The hovering controls fade while a file plays; the player's own events
|
|
785
|
+
// drive that, so a pause from the native controls brings them back.
|
|
786
|
+
function followMediaPlayback(media) {
|
|
787
|
+
listenMediaPreview(media, 'play', () => viewerControls.setPlaying(true));
|
|
788
|
+
listenMediaPreview(media, 'pause', () => viewerControls.setPlaying(false));
|
|
789
|
+
listenMediaPreview(media, 'ended', () => viewerControls.setPlaying(false));
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
const viewerControls = createControlsState({
|
|
793
|
+
onChange: (visible) => {
|
|
794
|
+
document.getElementById('file-preview-modal')?.classList.toggle('controls-hidden', !visible);
|
|
795
|
+
},
|
|
796
|
+
});
|
|
797
|
+
const viewerTap = createTapRecognizer({
|
|
798
|
+
onTap: () => viewerControls.toggle(),
|
|
799
|
+
hasSelection: () => String(window.getSelection?.()?.toString() || '').length > 0,
|
|
800
|
+
});
|
|
801
|
+
|
|
802
|
+
/** The chrome's height goes into a CSS variable so text starts below it. */
|
|
803
|
+
function syncViewerChromeHeight() {
|
|
804
|
+
const modal = document.getElementById('file-preview-modal');
|
|
805
|
+
const chromeTop = document.getElementById('file-preview-chrome-top');
|
|
806
|
+
if (!modal || !chromeTop || !modal.classList.contains('visible')) return;
|
|
807
|
+
modal.style.setProperty('--file-viewer-chrome-top', `${Math.ceil(chromeTop.getBoundingClientRect().height)}px`);
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
function showFilePreviewModal() {
|
|
811
|
+
const modal = document.getElementById('file-preview-modal');
|
|
812
|
+
const wasVisible = modal.classList.contains('visible');
|
|
813
|
+
modal.classList.add('visible');
|
|
814
|
+
modal.setAttribute('aria-hidden', 'false');
|
|
815
|
+
if (!wasVisible) {
|
|
816
|
+
viewerControls.reset();
|
|
817
|
+
viewerTap.cancel();
|
|
818
|
+
viewerHistory.opened();
|
|
819
|
+
// The composer keeps the keyboard otherwise: keys would type into it
|
|
820
|
+
// under the viewer instead of moving through the gallery.
|
|
821
|
+
const active = document.activeElement;
|
|
822
|
+
if (active && active !== document.body && !modal.contains(active)) active.blur?.();
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
const viewerHistory = createViewerHistory({
|
|
827
|
+
history: window.history,
|
|
828
|
+
onBack: () => closeFilePreview(),
|
|
829
|
+
});
|
|
830
|
+
window.addEventListener('popstate', () => viewerHistory.onPopState());
|
|
831
|
+
|
|
832
|
+
function setViewerMediaMode(kind) {
|
|
833
|
+
document.getElementById('file-preview-modal')?.classList.toggle('media-mode', DARK_PREVIEW_KINDS.has(kind));
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
function fileCardMarkup({ icon, name, meta, actions }) {
|
|
837
|
+
return `
|
|
838
|
+
<div class="file-preview-card">
|
|
839
|
+
<div class="file-preview-card-icon">${icon}</div>
|
|
840
|
+
<div class="file-preview-card-name">${escHtml(name)}</div>
|
|
841
|
+
${meta ? `<div class="file-preview-card-meta">${escHtml(meta)}</div>` : ''}
|
|
842
|
+
<div class="file-preview-card-actions">${actions}</div>
|
|
843
|
+
</div>`;
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
function downloadAnchorMarkup(download, plainName) {
|
|
847
|
+
const href = String(download?.href || '');
|
|
848
|
+
if (!href) return '';
|
|
849
|
+
return `<a class="file-preview-btn" href="${escHtml(href)}" download="${escHtml(download.downloadName || plainName)}">Download</a>`;
|
|
750
850
|
}
|
|
751
851
|
|
|
752
852
|
function setupImageZoom(container) {
|
|
@@ -798,11 +898,9 @@ let imgZoom = {
|
|
|
798
898
|
baseW: 0,
|
|
799
899
|
baseH: 0,
|
|
800
900
|
};
|
|
801
|
-
let
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
onError: null,
|
|
805
|
-
onCanPlay: null,
|
|
901
|
+
let mediaPreview = {
|
|
902
|
+
el: null,
|
|
903
|
+
listeners: [],
|
|
806
904
|
};
|
|
807
905
|
const IMG_ZOOM_MIN_FLOOR = 0.05;
|
|
808
906
|
const IMG_ZOOM_MAX = 8;
|
|
@@ -812,6 +910,11 @@ function _isAtImgZoomMin() {
|
|
|
812
910
|
return Math.abs(imgZoom.scale - imgZoom.minScale) <= IMG_ZOOM_EPS;
|
|
813
911
|
}
|
|
814
912
|
|
|
913
|
+
/** True unless an image is zoomed in, when a sideways drag pans it instead. */
|
|
914
|
+
function imageZoomAtRest() {
|
|
915
|
+
return !imgZoom.container || _isAtImgZoomMin();
|
|
916
|
+
}
|
|
917
|
+
|
|
815
918
|
/**
|
|
816
919
|
* The image's laid-out size at scale 1, measured once with the zoom styles
|
|
817
920
|
* removed. Deriving it from the live rect instead would be circular now that
|
|
@@ -1084,10 +1187,19 @@ function _imgZoomOnResize() {
|
|
|
1084
1187
|
_recomputeImgZoomMinScale({ resetToMin: _isAtImgZoomMin() });
|
|
1085
1188
|
}
|
|
1086
1189
|
|
|
1190
|
+
function uploadPreviewKind(mimeType) {
|
|
1191
|
+
const type = String(mimeType || '').toLowerCase();
|
|
1192
|
+
if (type.startsWith('image/')) return 'image';
|
|
1193
|
+
if (isVideoMimeType(type)) return 'video';
|
|
1194
|
+
if (isAudioMimeType(type)) return 'audio';
|
|
1195
|
+
if (type === 'application/pdf') return 'pdf';
|
|
1196
|
+
return 'binary';
|
|
1197
|
+
}
|
|
1198
|
+
|
|
1087
1199
|
export function openUploadedAttachmentViewer(name, contentUrl, mimeType, options = {}) {
|
|
1088
|
-
const isImage = String(mimeType || '').startsWith('image/');
|
|
1089
|
-
const isVideo = isVideoMimeType(mimeType);
|
|
1090
1200
|
const viewerOptions = normalizeVideoPreviewOptions(options);
|
|
1201
|
+
filePreviewLoadSeq += 1;
|
|
1202
|
+
filePreviewHistory.length = 0;
|
|
1091
1203
|
setFilePreviewState({
|
|
1092
1204
|
path: String(name || 'attachment'),
|
|
1093
1205
|
source: 'upload',
|
|
@@ -1096,18 +1208,34 @@ export function openUploadedAttachmentViewer(name, contentUrl, mimeType, options
|
|
|
1096
1208
|
loading: false,
|
|
1097
1209
|
error: '',
|
|
1098
1210
|
viewerOptions,
|
|
1211
|
+
gallery: options?.gallery || null,
|
|
1099
1212
|
payload: {
|
|
1100
|
-
kind:
|
|
1213
|
+
kind: uploadPreviewKind(mimeType),
|
|
1101
1214
|
name: String(name || 'attachment'),
|
|
1102
1215
|
rawUrl: String(contentUrl || ''),
|
|
1103
1216
|
size: 0,
|
|
1104
1217
|
contentType: String(mimeType || '').toLowerCase(),
|
|
1105
1218
|
},
|
|
1106
1219
|
});
|
|
1107
|
-
|
|
1108
|
-
modal.classList.add('visible');
|
|
1109
|
-
modal.setAttribute('aria-hidden', 'false');
|
|
1220
|
+
showFilePreviewModal();
|
|
1110
1221
|
renderFilePreview();
|
|
1222
|
+
prefetchGalleryNeighbors();
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1225
|
+
/**
|
|
1226
|
+
* Open one item of a gallery: an uploaded attachment by its address, a
|
|
1227
|
+
* drive or workspace file by its path. The gallery rides along so the
|
|
1228
|
+
* viewer can move on from there.
|
|
1229
|
+
*/
|
|
1230
|
+
export function openGalleryItem(item, gallery) {
|
|
1231
|
+
if (!item) return Promise.resolve(false);
|
|
1232
|
+
const source = item.source || gallery?.source || 'workspace';
|
|
1233
|
+
if (source === 'upload') {
|
|
1234
|
+
openUploadedAttachmentViewer(item.name, item.href, item.mime, { gallery });
|
|
1235
|
+
return Promise.resolve(true);
|
|
1236
|
+
}
|
|
1237
|
+
const open = source === 'workspace' ? openWorkspaceFilePreview : openDriveFilePreview;
|
|
1238
|
+
return open(item.path, { gallery, viaGallery: true }).then((opened) => Boolean(opened));
|
|
1111
1239
|
}
|
|
1112
1240
|
|
|
1113
1241
|
export function setFilePreviewMode(mode) {
|
|
@@ -1123,25 +1251,38 @@ export function toggleFilePreviewHtml() {
|
|
|
1123
1251
|
renderFilePreview();
|
|
1124
1252
|
}
|
|
1125
1253
|
|
|
1254
|
+
/** The address the browser fetches a previewed file's bytes from. */
|
|
1255
|
+
function previewRawHref(source, path, payload) {
|
|
1256
|
+
if (source === 'drives' || source === 'session') return driveFileHrefFromPath(path, payload?.version);
|
|
1257
|
+
if (source === 'upload') return String(payload?.rawUrl || '');
|
|
1258
|
+
return `${BASE}/api/files/${String(path || '').split('/').map((s) => encodeURIComponent(s)).join('/')}${currentWorkspaceScopeSuffix()}`;
|
|
1259
|
+
}
|
|
1260
|
+
|
|
1126
1261
|
export function renderFilePreview() {
|
|
1127
1262
|
teardownImageZoom();
|
|
1128
|
-
|
|
1263
|
+
teardownMediaPreview();
|
|
1264
|
+
viewerTap.cancel();
|
|
1265
|
+
setViewerMediaMode(filePreviewState.payload?.kind);
|
|
1266
|
+
requestAnimationFrame(syncViewerChromeHeight);
|
|
1129
1267
|
const titleEl = document.getElementById('file-preview-title');
|
|
1130
1268
|
const metaEl = document.getElementById('file-preview-meta');
|
|
1131
1269
|
const bodyEl = document.getElementById('file-preview-body');
|
|
1132
1270
|
const rawLink = document.getElementById('file-preview-open-raw');
|
|
1133
1271
|
const payload = filePreviewState.payload;
|
|
1134
|
-
const rawHref = filePreviewState.source
|
|
1135
|
-
|| filePreviewState.source === 'session'
|
|
1136
|
-
? driveFileHrefFromPath(filePreviewState.path)
|
|
1137
|
-
: filePreviewState.source === 'upload'
|
|
1138
|
-
? String(payload?.rawUrl || '')
|
|
1139
|
-
: `${BASE}/api/files/${filePreviewState.path.split('/').map((s) => encodeURIComponent(s)).join('/')}${currentWorkspaceScopeSuffix()}`;
|
|
1140
|
-
rawLink.href = rawHref || '#';
|
|
1272
|
+
const rawHref = previewRawHref(filePreviewState.source, filePreviewState.path, payload);
|
|
1141
1273
|
const fallbackName = String(filePreviewState.path || '').split('/').filter(Boolean).pop() || 'download';
|
|
1142
|
-
|
|
1274
|
+
const plainName = String(payload?.name || fallbackName);
|
|
1275
|
+
// An upload is addressed by its content and never changes; a file on disk
|
|
1276
|
+
// does, so its link and the name it is saved under carry its version.
|
|
1277
|
+
const download = filePreviewState.source === 'upload'
|
|
1278
|
+
? { href: rawHref, downloadName: plainName }
|
|
1279
|
+
: buildDownloadLink({ href: rawHref, name: plainName, version: payload?.version, mtimeMs: payload?.mtimeMs });
|
|
1280
|
+
rawLink.href = download.href || '#';
|
|
1281
|
+
rawLink.setAttribute('download', download.downloadName || plainName);
|
|
1143
1282
|
rawLink.textContent = 'Download';
|
|
1144
|
-
rawLink.setAttribute('title',
|
|
1283
|
+
rawLink.setAttribute('title', download.downloadName && download.downloadName !== plainName
|
|
1284
|
+
? `Download as ${download.downloadName}`
|
|
1285
|
+
: 'Download file');
|
|
1145
1286
|
|
|
1146
1287
|
const titlePath = filePreviewState.path || '';
|
|
1147
1288
|
titleEl.textContent = titlePath || 'File preview';
|
|
@@ -1178,8 +1319,56 @@ export function renderFilePreview() {
|
|
|
1178
1319
|
const truncatedLabel = payload.truncated ? ` · truncated to ${formatBytes(FILE_PREVIEW_MAX_BYTES)}` : '';
|
|
1179
1320
|
metaEl.textContent = `${kindLabel}${langLabel} · ${formatBytes(payload.size || 0)}${truncatedLabel}`;
|
|
1180
1321
|
|
|
1322
|
+
const cardMeta = payload.size > 0 ? formatBytes(payload.size) : '';
|
|
1323
|
+
|
|
1181
1324
|
if (payload.kind === 'binary') {
|
|
1182
|
-
bodyEl.innerHTML =
|
|
1325
|
+
bodyEl.innerHTML = fileCardMarkup({
|
|
1326
|
+
icon: '📦',
|
|
1327
|
+
name: plainName,
|
|
1328
|
+
meta: cardMeta ? `${cardMeta} · no inline preview` : 'no inline preview',
|
|
1329
|
+
actions: downloadAnchorMarkup(download, plainName),
|
|
1330
|
+
});
|
|
1331
|
+
bodyEl.classList.add('card-preview-mode');
|
|
1332
|
+
return;
|
|
1333
|
+
}
|
|
1334
|
+
|
|
1335
|
+
if (payload.kind === 'pdf') {
|
|
1336
|
+
// The browser's own PDF viewer takes it in a new tab; the plain address
|
|
1337
|
+
// (no `as=` name) keeps the answer inline there.
|
|
1338
|
+
const inlineHref = String(rawHref || payload.rawUrl || '');
|
|
1339
|
+
bodyEl.innerHTML = fileCardMarkup({
|
|
1340
|
+
icon: '📄',
|
|
1341
|
+
name: plainName,
|
|
1342
|
+
meta: cardMeta ? `PDF · ${cardMeta}` : 'PDF',
|
|
1343
|
+
actions: `${inlineHref ? `<button type="button" class="file-preview-btn" data-viewer-open-tab="${escHtml(inlineHref)}">Open in new tab</button>` : ''}${downloadAnchorMarkup(download, plainName)}`,
|
|
1344
|
+
});
|
|
1345
|
+
bodyEl.classList.add('card-preview-mode');
|
|
1346
|
+
return;
|
|
1347
|
+
}
|
|
1348
|
+
|
|
1349
|
+
if (payload.kind === 'audio') {
|
|
1350
|
+
const audioHref = String(rawHref || payload.rawUrl || '');
|
|
1351
|
+
bodyEl.innerHTML = `
|
|
1352
|
+
<div class="file-preview-audio-shell">
|
|
1353
|
+
${fileCardMarkup({
|
|
1354
|
+
icon: '🎵',
|
|
1355
|
+
name: plainName,
|
|
1356
|
+
meta: cardMeta,
|
|
1357
|
+
actions: audioHref ? `<audio class="file-preview-audio" controls preload="metadata" src="${escHtml(audioHref)}"></audio>` : '<span class="file-preview-note">Audio preview unavailable.</span>',
|
|
1358
|
+
})}
|
|
1359
|
+
</div>`;
|
|
1360
|
+
bodyEl.classList.add('audio-preview-mode');
|
|
1361
|
+
const audio = bodyEl.querySelector('audio');
|
|
1362
|
+
if (audio) {
|
|
1363
|
+
mediaPreview.el = audio;
|
|
1364
|
+
listenMediaPreview(audio, 'error', () => {
|
|
1365
|
+
const note = document.createElement('div');
|
|
1366
|
+
note.className = 'file-preview-note';
|
|
1367
|
+
note.innerHTML = 'Audio preview unavailable. Use <b>Download</b> to save the file.';
|
|
1368
|
+
audio.replaceWith(note);
|
|
1369
|
+
}, { once: true });
|
|
1370
|
+
followMediaPlayback(audio);
|
|
1371
|
+
}
|
|
1183
1372
|
return;
|
|
1184
1373
|
}
|
|
1185
1374
|
|
|
@@ -1197,13 +1386,6 @@ export function renderFilePreview() {
|
|
|
1197
1386
|
return;
|
|
1198
1387
|
}
|
|
1199
1388
|
|
|
1200
|
-
if (filePreviewState.mode === 'raw') {
|
|
1201
|
-
bodyEl.innerHTML = payload.kind === 'video'
|
|
1202
|
-
? '<div class="file-preview-note">Video files are binary. Use <b>Download</b> to save the file.</div>'
|
|
1203
|
-
: `<div class="file-preview-code"><pre><code>${escHtml(String(payload.content || ''))}</code></pre></div>`;
|
|
1204
|
-
return;
|
|
1205
|
-
}
|
|
1206
|
-
|
|
1207
1389
|
if (payload.kind === 'video') {
|
|
1208
1390
|
const videoHref = String(rawHref || payload.rawUrl || '');
|
|
1209
1391
|
const viewerOptions = filePreviewState.viewerOptions || {};
|
|
@@ -1214,35 +1396,30 @@ export function renderFilePreview() {
|
|
|
1214
1396
|
bodyEl.innerHTML = `
|
|
1215
1397
|
<div class="file-preview-video-shell" data-start-seconds="${escHtml(String(startSeconds))}" data-preload="${escHtml(preload)}">
|
|
1216
1398
|
<video class="file-preview-video" controls playsinline preload="${escHtml(preload)}" src="${escHtml(videoHref)}"></video>
|
|
1217
|
-
<div class="file-preview-note">${startSeconds > 0 ? `Will start at ${startSeconds.toFixed(2)}s.` : 'Video preview ready.'} ${preload === 'auto' ? 'Preloading enabled.' : 'Metadata preload enabled.'}</div>
|
|
1218
1399
|
</div>`;
|
|
1219
1400
|
bodyEl.classList.add('video-preview-mode');
|
|
1220
1401
|
const shell = bodyEl.querySelector('.file-preview-video-shell');
|
|
1221
1402
|
const video = shell?.querySelector('video');
|
|
1222
1403
|
if (video) {
|
|
1223
|
-
|
|
1224
|
-
|
|
1404
|
+
mediaPreview.el = video;
|
|
1405
|
+
const tryPlay = () => {
|
|
1406
|
+
const playPromise = video.play?.();
|
|
1407
|
+
if (playPromise && typeof playPromise.catch === 'function') {
|
|
1408
|
+
playPromise.catch(() => {});
|
|
1409
|
+
}
|
|
1410
|
+
};
|
|
1411
|
+
listenMediaPreview(video, 'loadedmetadata', () => {
|
|
1225
1412
|
if (startSeconds > 0 && Number.isFinite(video.duration) && video.duration > startSeconds) {
|
|
1226
1413
|
try {
|
|
1227
1414
|
video.currentTime = startSeconds;
|
|
1228
1415
|
} catch {}
|
|
1229
1416
|
}
|
|
1230
|
-
if (autoplay)
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
};
|
|
1237
|
-
videoPreview.onCanPlay = () => {
|
|
1238
|
-
if (autoplay && video.paused) {
|
|
1239
|
-
const playPromise = video.play?.();
|
|
1240
|
-
if (playPromise && typeof playPromise.catch === 'function') {
|
|
1241
|
-
playPromise.catch(() => {});
|
|
1242
|
-
}
|
|
1243
|
-
}
|
|
1244
|
-
};
|
|
1245
|
-
videoPreview.onError = () => {
|
|
1417
|
+
if (autoplay) tryPlay();
|
|
1418
|
+
}, { once: true });
|
|
1419
|
+
listenMediaPreview(video, 'canplay', () => {
|
|
1420
|
+
if (autoplay && video.paused) tryPlay();
|
|
1421
|
+
});
|
|
1422
|
+
listenMediaPreview(video, 'error', () => {
|
|
1246
1423
|
let errorNote = bodyEl.querySelector('.file-preview-video-error');
|
|
1247
1424
|
if (!errorNote) {
|
|
1248
1425
|
errorNote = document.createElement('div');
|
|
@@ -1250,19 +1427,22 @@ export function renderFilePreview() {
|
|
|
1250
1427
|
bodyEl.appendChild(errorNote);
|
|
1251
1428
|
}
|
|
1252
1429
|
errorNote.innerHTML = 'Video preview unavailable. Use <b>Download</b> to save the file.';
|
|
1253
|
-
};
|
|
1254
|
-
video
|
|
1255
|
-
video.addEventListener('canplay', videoPreview.onCanPlay);
|
|
1256
|
-
video.addEventListener('error', videoPreview.onError, { once: true });
|
|
1430
|
+
}, { once: true });
|
|
1431
|
+
followMediaPlayback(video);
|
|
1257
1432
|
}
|
|
1258
1433
|
} else {
|
|
1259
1434
|
bodyEl.innerHTML = '<div class="file-preview-note">Video preview unavailable.</div>';
|
|
1260
1435
|
}
|
|
1261
1436
|
return;
|
|
1262
1437
|
}
|
|
1263
|
-
|
|
1438
|
+
|
|
1264
1439
|
const rawText = String(payload.content || '');
|
|
1265
1440
|
|
|
1441
|
+
if (filePreviewState.mode === 'raw') {
|
|
1442
|
+
bodyEl.innerHTML = `<div class="file-preview-code"><pre><code>${escHtml(rawText)}</code></pre></div>`;
|
|
1443
|
+
return;
|
|
1444
|
+
}
|
|
1445
|
+
|
|
1266
1446
|
if (payload.kind === 'markdown') {
|
|
1267
1447
|
const html = renderMarkdownPreview(rawText, filePreviewState.allowHtml);
|
|
1268
1448
|
bodyEl.innerHTML = `<article class="file-preview-markdown">${html}</article>`;
|
|
@@ -1285,92 +1465,173 @@ export function renderFilePreview() {
|
|
|
1285
1465
|
attachCodeCopyButtons(bodyEl);
|
|
1286
1466
|
}
|
|
1287
1467
|
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1468
|
+
// Two files asked for in a row (a quick swipe, say) must not race: only the
|
|
1469
|
+
// answer to the latest request is shown.
|
|
1470
|
+
let filePreviewLoadSeq = 0;
|
|
1471
|
+
|
|
1472
|
+
async function openFilePreviewFromSource(source, normalized, options, fetchPayload) {
|
|
1291
1473
|
const viewerOptions = { ...normalizeVideoPreviewOptions(options), fragment: String(options?.fragment || '') };
|
|
1292
1474
|
if (!options?.fromViewerLink) filePreviewHistory.length = 0;
|
|
1475
|
+
const seq = ++filePreviewLoadSeq;
|
|
1293
1476
|
setFilePreviewState({
|
|
1294
1477
|
path: normalized,
|
|
1295
|
-
source
|
|
1478
|
+
source,
|
|
1296
1479
|
mode: 'preview',
|
|
1297
1480
|
allowHtml: true,
|
|
1298
1481
|
loading: true,
|
|
1299
1482
|
error: '',
|
|
1300
1483
|
payload: null,
|
|
1301
1484
|
viewerOptions,
|
|
1485
|
+
gallery: options?.gallery || null,
|
|
1302
1486
|
});
|
|
1303
|
-
|
|
1304
|
-
modal.classList.add('visible');
|
|
1305
|
-
modal.setAttribute('aria-hidden', 'false');
|
|
1487
|
+
showFilePreviewModal();
|
|
1306
1488
|
renderFilePreview();
|
|
1307
1489
|
|
|
1308
|
-
|
|
1490
|
+
// A step through the gallery takes the neighbour fetched ahead of time.
|
|
1491
|
+
const cached = options?.viaGallery ? galleryPayloadCache.get(galleryCacheKey(source, normalized)) : null;
|
|
1492
|
+
const payload = await (cached || fetchPayload());
|
|
1493
|
+
if (seq !== filePreviewLoadSeq) return false;
|
|
1309
1494
|
if (!payload || payload.error) {
|
|
1310
1495
|
filePreviewState.loading = false;
|
|
1311
|
-
filePreviewState.error = payload?.error ||
|
|
1496
|
+
filePreviewState.error = payload?.error || `Failed to load ${source === 'workspace' ? '' : 'drive '}file preview`;
|
|
1312
1497
|
renderFilePreview();
|
|
1313
1498
|
return false;
|
|
1314
1499
|
}
|
|
1500
|
+
rememberGalleryPayload(source, normalized, payload);
|
|
1315
1501
|
filePreviewState.loading = false;
|
|
1316
1502
|
filePreviewState.payload = payload;
|
|
1317
1503
|
filePreviewState.path = String(payload.path || normalized);
|
|
1504
|
+
filePreviewState.source = source;
|
|
1318
1505
|
filePreviewState.viewerOptions = viewerOptions;
|
|
1319
1506
|
renderFilePreview();
|
|
1507
|
+
prefetchGalleryNeighbors();
|
|
1320
1508
|
return true;
|
|
1321
1509
|
}
|
|
1322
1510
|
|
|
1511
|
+
export async function openWorkspaceFilePreview(rawPath, options = {}) {
|
|
1512
|
+
const normalized = normalizeWorkspaceMentionPath(rawPath);
|
|
1513
|
+
if (!normalized) return;
|
|
1514
|
+
return openFilePreviewFromSource('workspace', normalized, options,
|
|
1515
|
+
() => loadWorkspaceFilePreview(normalized, currentConversationId()));
|
|
1516
|
+
}
|
|
1517
|
+
|
|
1323
1518
|
export async function openDriveFilePreview(rawPath, options = {}) {
|
|
1324
1519
|
const normalized = normalizeDriveBrowserPath(rawPath);
|
|
1325
1520
|
if (!normalized) return;
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
setFilePreviewState({
|
|
1329
|
-
path: normalized,
|
|
1330
|
-
source: 'drives',
|
|
1331
|
-
mode: 'preview',
|
|
1332
|
-
allowHtml: true,
|
|
1333
|
-
loading: true,
|
|
1334
|
-
error: '',
|
|
1335
|
-
payload: null,
|
|
1336
|
-
viewerOptions,
|
|
1337
|
-
});
|
|
1338
|
-
const modal = document.getElementById('file-preview-modal');
|
|
1339
|
-
modal.classList.add('visible');
|
|
1340
|
-
modal.setAttribute('aria-hidden', 'false');
|
|
1341
|
-
renderFilePreview();
|
|
1521
|
+
return openFilePreviewFromSource('drives', normalized, options, () => loadDriveFilePreview(normalized));
|
|
1522
|
+
}
|
|
1342
1523
|
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
}
|
|
1350
|
-
filePreviewState.loading = false;
|
|
1351
|
-
filePreviewState.payload = payload;
|
|
1352
|
-
filePreviewState.path = String(payload.path || normalized);
|
|
1353
|
-
filePreviewState.source = 'drives';
|
|
1354
|
-
filePreviewState.viewerOptions = viewerOptions;
|
|
1355
|
-
renderFilePreview();
|
|
1356
|
-
return true;
|
|
1524
|
+
/** The files of the browser's current folder, as a gallery open at `openedPath`. */
|
|
1525
|
+
function repoFolderGallery(openedPath) {
|
|
1526
|
+
const node = repoBrowserState.nodeMap.get(String(repoBrowserState.currentPath || '')) || null;
|
|
1527
|
+
if (!node || node.type !== 'dir' || !Array.isArray(node.children)) return null;
|
|
1528
|
+
return galleryFromFolder(node.children, openedPath, {
|
|
1529
|
+
source: repoBrowserState.activeRoot === 'workspace' ? 'workspace' : 'drives',
|
|
1530
|
+
});
|
|
1357
1531
|
}
|
|
1358
1532
|
|
|
1359
1533
|
export async function openWorkspaceFilePreviewFromRepo(rawPath, options = {}) {
|
|
1534
|
+
const gallery = repoFolderGallery(rawPath);
|
|
1535
|
+
const openOptions = gallery ? { ...options, gallery } : options;
|
|
1360
1536
|
if (repoBrowserState.activeRoot !== 'workspace') {
|
|
1361
|
-
await openDriveFilePreview(rawPath,
|
|
1537
|
+
await openDriveFilePreview(rawPath, openOptions);
|
|
1362
1538
|
return;
|
|
1363
1539
|
}
|
|
1364
|
-
await openWorkspaceFilePreview(rawPath,
|
|
1540
|
+
await openWorkspaceFilePreview(rawPath, openOptions);
|
|
1541
|
+
}
|
|
1542
|
+
|
|
1543
|
+
/** Show the file `delta` steps along the gallery; false at either end. */
|
|
1544
|
+
export async function stepFilePreviewGallery(delta) {
|
|
1545
|
+
const gallery = filePreviewState.gallery;
|
|
1546
|
+
const index = galleryNeighborIndex(gallery, delta);
|
|
1547
|
+
if (index === -1) return false;
|
|
1548
|
+
const next = galleryAt(gallery, index);
|
|
1549
|
+
return openGalleryItem(next.items[index], next);
|
|
1550
|
+
}
|
|
1551
|
+
|
|
1552
|
+
// Preview payloads of the open file's neighbours, fetched ahead so a swipe
|
|
1553
|
+
// lands on the file and not on "Fetching…"; an image's bytes are asked for
|
|
1554
|
+
// too, under the very address the viewer will use. Entries are promises.
|
|
1555
|
+
const galleryPayloadCache = new Map();
|
|
1556
|
+
const GALLERY_PAYLOAD_CACHE_MAX = 12;
|
|
1557
|
+
|
|
1558
|
+
function galleryCacheKey(source, path) {
|
|
1559
|
+
return `${source}:${path}`;
|
|
1560
|
+
}
|
|
1561
|
+
|
|
1562
|
+
function rememberGalleryPayload(source, path, payload) {
|
|
1563
|
+
const key = galleryCacheKey(source, path);
|
|
1564
|
+
galleryPayloadCache.delete(key);
|
|
1565
|
+
galleryPayloadCache.set(key, Promise.resolve(payload));
|
|
1566
|
+
while (galleryPayloadCache.size > GALLERY_PAYLOAD_CACHE_MAX) {
|
|
1567
|
+
galleryPayloadCache.delete(galleryPayloadCache.keys().next().value);
|
|
1568
|
+
}
|
|
1569
|
+
}
|
|
1570
|
+
|
|
1571
|
+
function prefetchGalleryNeighbors() {
|
|
1572
|
+
const gallery = filePreviewState.gallery;
|
|
1573
|
+
if (!gallery) return;
|
|
1574
|
+
for (const item of galleryNeighbors(gallery)) {
|
|
1575
|
+
const source = item.source || gallery.source;
|
|
1576
|
+
if (source === 'upload') {
|
|
1577
|
+
// An attachment has no preview to fetch; its picture can be primed.
|
|
1578
|
+
if (String(item.mime || '').startsWith('image/') && item.href) {
|
|
1579
|
+
const image = new Image();
|
|
1580
|
+
image.src = item.href;
|
|
1581
|
+
}
|
|
1582
|
+
continue;
|
|
1583
|
+
}
|
|
1584
|
+
const normalized = source === 'workspace'
|
|
1585
|
+
? normalizeWorkspaceMentionPath(item.path)
|
|
1586
|
+
: normalizeDriveBrowserPath(item.path);
|
|
1587
|
+
if (!normalized) continue;
|
|
1588
|
+
const key = galleryCacheKey(source, normalized);
|
|
1589
|
+
if (galleryPayloadCache.has(key)) continue;
|
|
1590
|
+
const request = (source === 'workspace'
|
|
1591
|
+
? loadWorkspaceFilePreview(normalized, currentConversationId())
|
|
1592
|
+
: loadDriveFilePreview(normalized)
|
|
1593
|
+
).then((payload) => {
|
|
1594
|
+
if (!payload || payload.error) {
|
|
1595
|
+
galleryPayloadCache.delete(key);
|
|
1596
|
+
return null;
|
|
1597
|
+
}
|
|
1598
|
+
if (payload.kind === 'image') {
|
|
1599
|
+
const image = new Image();
|
|
1600
|
+
image.src = previewRawHref(source, String(payload.path || normalized), payload);
|
|
1601
|
+
}
|
|
1602
|
+
return payload;
|
|
1603
|
+
}).catch(() => {
|
|
1604
|
+
galleryPayloadCache.delete(key);
|
|
1605
|
+
return null;
|
|
1606
|
+
});
|
|
1607
|
+
rememberGalleryPayload(source, normalized, request);
|
|
1608
|
+
}
|
|
1365
1609
|
}
|
|
1366
1610
|
|
|
1367
1611
|
export function closeFilePreview() {
|
|
1368
1612
|
teardownImageZoom();
|
|
1369
|
-
|
|
1613
|
+
teardownMediaPreview();
|
|
1614
|
+
viewerTap.cancel();
|
|
1615
|
+
viewerControls.reset();
|
|
1370
1616
|
const modal = document.getElementById('file-preview-modal');
|
|
1371
|
-
modal.classList.
|
|
1617
|
+
const wasVisible = modal.classList.contains('visible');
|
|
1618
|
+
modal.classList.remove('visible', 'controls-hidden', 'media-mode');
|
|
1372
1619
|
modal.setAttribute('aria-hidden', 'true');
|
|
1373
1620
|
filePreviewHistory.length = 0;
|
|
1621
|
+
filePreviewState.gallery = null;
|
|
1622
|
+
galleryPayloadCache.clear();
|
|
1623
|
+
viewerSwipe.cancel();
|
|
1624
|
+
clearViewerDragStyles();
|
|
1625
|
+
if (wasVisible) viewerHistory.closed();
|
|
1626
|
+
}
|
|
1627
|
+
|
|
1628
|
+
function clearViewerDragStyles() {
|
|
1629
|
+
const bodyEl = document.getElementById('file-preview-body');
|
|
1630
|
+
const modal = document.getElementById('file-preview-modal');
|
|
1631
|
+
bodyEl.style.transform = '';
|
|
1632
|
+
bodyEl.style.transition = '';
|
|
1633
|
+
modal.style.opacity = '';
|
|
1634
|
+
modal.style.transition = '';
|
|
1374
1635
|
}
|
|
1375
1636
|
|
|
1376
1637
|
export function goBackFilePreview() {
|
|
@@ -2175,11 +2436,133 @@ document.addEventListener('click', (event) => {
|
|
|
2175
2436
|
void openWorkspaceFilePreview(anchor.dataset.workspacePath || '');
|
|
2176
2437
|
});
|
|
2177
2438
|
|
|
2178
|
-
|
|
2179
|
-
|
|
2439
|
+
// A tap on the content shows or hides the floating controls; the recognizer
|
|
2440
|
+
// leaves drags, pinches, double taps and the content's own buttons alone.
|
|
2441
|
+
// While something plays, a mouse moving over the viewer brings the controls
|
|
2442
|
+
// back as players do; over a still image a click is the only switch, or a
|
|
2443
|
+
// nudge of the mouse would undo every click that hid them.
|
|
2444
|
+
for (const eventName of ['pointerdown', 'pointermove', 'pointerup', 'pointercancel']) {
|
|
2445
|
+
document.getElementById('file-preview-body').addEventListener(eventName, (event) => {
|
|
2446
|
+
viewerTap.handle(event);
|
|
2447
|
+
viewerSwipe.handle(event);
|
|
2448
|
+
if (eventName === 'pointermove' && event.pointerType === 'mouse' && viewerControls.playing) {
|
|
2449
|
+
viewerControls.interaction();
|
|
2450
|
+
}
|
|
2451
|
+
});
|
|
2452
|
+
}
|
|
2453
|
+
for (const id of ['file-preview-chrome-top', 'file-preview-chrome-bottom']) {
|
|
2454
|
+
document.getElementById(id).addEventListener('pointerdown', () => viewerControls.interaction());
|
|
2455
|
+
}
|
|
2456
|
+
window.addEventListener('resize', syncViewerChromeHeight);
|
|
2457
|
+
|
|
2458
|
+
// Moving through the gallery: the buttons below, the arrow keys, and a
|
|
2459
|
+
// sideways swipe of a finger (a mouse drag selects text and stays a drag).
|
|
2460
|
+
document.getElementById('file-preview-prev').addEventListener('click', () => { void stepFilePreviewGallery(-1); });
|
|
2461
|
+
document.getElementById('file-preview-next').addEventListener('click', () => { void stepFilePreviewGallery(1); });
|
|
2462
|
+
|
|
2463
|
+
function isEditableTarget(target) {
|
|
2464
|
+
return Boolean(target?.closest?.('input, textarea, select, [contenteditable]'));
|
|
2465
|
+
}
|
|
2466
|
+
|
|
2467
|
+
document.addEventListener('keydown', (event) => {
|
|
2468
|
+
if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') return;
|
|
2469
|
+
const modal = document.getElementById('file-preview-modal');
|
|
2470
|
+
if (!modal.classList.contains('visible')) return;
|
|
2471
|
+
if (document.getElementById('image-annotate-modal')?.classList.contains('visible')) return;
|
|
2472
|
+
if ((isEditableTarget(event.target) && modal.contains(event.target)) || !filePreviewState.gallery) return;
|
|
2473
|
+
event.preventDefault();
|
|
2474
|
+
void stepFilePreviewGallery(event.key === 'ArrowRight' ? 1 : -1);
|
|
2475
|
+
});
|
|
2476
|
+
|
|
2477
|
+
/** Something between `target` and the viewer body scrolls sideways itself. */
|
|
2478
|
+
function insideHorizontalScroller(target) {
|
|
2479
|
+
const bodyEl = document.getElementById('file-preview-body');
|
|
2480
|
+
for (let el = target instanceof Element ? target : null; el && el !== bodyEl; el = el.parentElement) {
|
|
2481
|
+
if (el.scrollWidth > el.clientWidth + 1) {
|
|
2482
|
+
const overflowX = getComputedStyle(el).overflowX;
|
|
2483
|
+
if (overflowX === 'auto' || overflowX === 'scroll') return true;
|
|
2484
|
+
}
|
|
2485
|
+
}
|
|
2486
|
+
return false;
|
|
2487
|
+
}
|
|
2488
|
+
|
|
2489
|
+
// A swipe down dismisses what does not scroll (a picture, a player, a card):
|
|
2490
|
+
// the content follows the finger and the viewer fades, then goes if the
|
|
2491
|
+
// finger went far or fast enough. Text scrolls under the finger instead and
|
|
2492
|
+
// is closed with ×, Escape or the back gesture.
|
|
2493
|
+
const DISMISS_FADE_PX = 480;
|
|
2494
|
+
const DISMISS_ANIMATION_MS = 200;
|
|
2495
|
+
let viewerDismissing = false;
|
|
2496
|
+
|
|
2497
|
+
function viewerDismissableBySwipe() {
|
|
2498
|
+
return !viewerDismissing && !TEXT_PREVIEW_KINDS.has(filePreviewState.payload?.kind) && imageZoomAtRest();
|
|
2499
|
+
}
|
|
2500
|
+
|
|
2501
|
+
function dismissFilePreviewWithSlide() {
|
|
2502
|
+
const bodyEl = document.getElementById('file-preview-body');
|
|
2503
|
+
const modal = document.getElementById('file-preview-modal');
|
|
2504
|
+
viewerDismissing = true;
|
|
2505
|
+
bodyEl.style.transition = `transform ${DISMISS_ANIMATION_MS}ms ease-in`;
|
|
2506
|
+
modal.style.transition = `opacity ${DISMISS_ANIMATION_MS}ms ease-in`;
|
|
2507
|
+
bodyEl.style.transform = 'translateY(100vh)';
|
|
2508
|
+
modal.style.opacity = '0';
|
|
2509
|
+
setTimeout(() => {
|
|
2510
|
+
viewerDismissing = false;
|
|
2511
|
+
closeFilePreview();
|
|
2512
|
+
}, DISMISS_ANIMATION_MS);
|
|
2513
|
+
}
|
|
2514
|
+
|
|
2515
|
+
const viewerSwipe = createSwipeRecognizer({
|
|
2516
|
+
canStart: (event) => event.pointerType !== 'mouse'
|
|
2517
|
+
&& !viewerDismissing
|
|
2518
|
+
&& !event.target?.closest?.('video, audio, input, textarea, select, [contenteditable]')
|
|
2519
|
+
&& !insideHorizontalScroller(event.target),
|
|
2520
|
+
horizontalEnabled: () => Boolean(filePreviewState.gallery) && imageZoomAtRest(),
|
|
2521
|
+
verticalEnabled: viewerDismissableBySwipe,
|
|
2522
|
+
onStart: () => {
|
|
2523
|
+
const bodyEl = document.getElementById('file-preview-body');
|
|
2524
|
+
bodyEl.style.transition = 'none';
|
|
2525
|
+
document.getElementById('file-preview-modal').style.transition = 'none';
|
|
2526
|
+
},
|
|
2527
|
+
onMove: ({ axis, dx, dy }) => {
|
|
2528
|
+
const bodyEl = document.getElementById('file-preview-body');
|
|
2529
|
+
if (axis === 'y') {
|
|
2530
|
+
// Upwards there is nothing: the content barely gives.
|
|
2531
|
+
const shift = dy < 0 ? dy * 0.2 : dy;
|
|
2532
|
+
bodyEl.style.transform = `translateY(${shift}px)`;
|
|
2533
|
+
document.getElementById('file-preview-modal').style.opacity = String(Math.max(0.3, 1 - Math.max(0, dy) / DISMISS_FADE_PX));
|
|
2534
|
+
return;
|
|
2535
|
+
}
|
|
2536
|
+
// Past either end the content gives a little, then holds.
|
|
2537
|
+
const toward = galleryNeighborIndex(filePreviewState.gallery, dx < 0 ? 1 : -1);
|
|
2538
|
+
const shift = toward === -1 ? dx * 0.25 : dx;
|
|
2539
|
+
bodyEl.style.transform = `translateX(${shift}px)`;
|
|
2540
|
+
},
|
|
2541
|
+
onEnd: ({ axis, swipe }) => {
|
|
2542
|
+
if (axis === 'y' && swipe === 'down') {
|
|
2543
|
+
dismissFilePreviewWithSlide();
|
|
2544
|
+
return;
|
|
2545
|
+
}
|
|
2546
|
+
const bodyEl = document.getElementById('file-preview-body');
|
|
2547
|
+
const modal = document.getElementById('file-preview-modal');
|
|
2548
|
+
bodyEl.style.transition = 'transform 180ms ease';
|
|
2549
|
+
modal.style.transition = 'opacity 180ms ease';
|
|
2550
|
+
bodyEl.style.transform = '';
|
|
2551
|
+
modal.style.opacity = '';
|
|
2552
|
+
if (swipe === 'left') void stepFilePreviewGallery(1);
|
|
2553
|
+
else if (swipe === 'right') void stepFilePreviewGallery(-1);
|
|
2554
|
+
},
|
|
2180
2555
|
});
|
|
2181
2556
|
|
|
2182
2557
|
document.getElementById('file-preview-body').addEventListener('click', (event) => {
|
|
2558
|
+
const openTab = eventClosest(event, '[data-viewer-open-tab]');
|
|
2559
|
+
if (openTab) {
|
|
2560
|
+
event.preventDefault();
|
|
2561
|
+
openExternalNavigation(openTab.getAttribute('data-viewer-open-tab') || '', (url) => {
|
|
2562
|
+
window.dispatchEvent(new CustomEvent('copilot:external-link-fallback', { detail: { url } }));
|
|
2563
|
+
});
|
|
2564
|
+
return;
|
|
2565
|
+
}
|
|
2183
2566
|
const anchor = eventClosest(event, '.file-preview-markdown a[href]');
|
|
2184
2567
|
if (!anchor) return;
|
|
2185
2568
|
const target = resolveFilePreviewLink(anchor.getAttribute('href'), filePreviewState.path);
|