Files
Oxicloud/static/js/features/files/wopiEditor.js
T
DioCrafts 81a93a489b feat: photo/video capture-date pipeline + premium UI/UX overhaul
Backend — Photos timeline now groups by real capture date instead of upload time. New MediaMetadataService (FileLifecycleHook) extracts EXIF DateTimeOriginal from images and container creation_time from videos (mov/mp4/mkv) via nom-exif, timezone-correct (OffsetTimeOriginal), persisting captured_at so the existing media_sort_date trigger takes over. Adds POST /admin/photos/metadata/reextract to backfill existing media. Falls back to upload date when no embedded date exists.

Frontend — premium grid cards: combined metadata line (relative date · size, owner avatar when shared), custom selection checkbox with a clear checked state, uniform full-width 4:3 thumbnail tiles independent of filename length, centered file-type icons, and a hit-test fix so checkbox/star/kebab clicks reach the controls (the decorative thumbnail no longer captures pointer events). Notification messages internationalised across all 16 locales. Broader polish: design tokens, a11y/focus-visible states, brand + PWA assets.

Chore — bump semver-compatible dependencies (cargo upgrade); add nom-exif 3.6.1.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 00:24:27 +02:00

271 lines
9.7 KiB
JavaScript

/**
* OxiCloud WOPI Editor Integration
*
* Opens document files in Collabora Online / OnlyOffice via WOPI protocol.
* Supports two modes: in-app modal (default) and new browser tab.
*/
import { loadFiles } from '../../app/filesView.js';
import { ui } from '../../app/ui.js';
import { i18n } from '../../core/i18n.js';
class WopiEditor {
constructor() {
this.editorModal = null;
this._escHandler = null;
this._messageHandler = null;
this._supportedExtensions = null;
}
/**
* Check if a file can be opened in a WOPI editor by extension.
* Fetches supported extensions from the server (cached after first call).
* @param {string} filename
*/
async canEdit(filename) {
const ext = filename.split('.').pop().toLowerCase();
const supported = await this._getSupportedExtensions();
return supported.includes(ext);
}
/**
* Open file in a modal overlay (default mode).
* @param {string} fileId
* @param {string} fileName
* @param {string} [action]
*/
async openInModal(fileId, fileName, action) {
action = action || 'edit';
// Error is thrown, caller can handle a classic view as fallback
var data = await this._getEditorUrlWithFallback(fileId, fileName, action);
this._showModal(data, fileName);
}
/**
* Open file in a new browser tab.
* @param {string} fileId
* @param {string} fileName
* @param {string} [action]
*/
async openInTab(fileId, fileName, action) {
action = action || 'edit';
try {
const data = await this._getEditorUrlWithFallback(fileId, fileName, action);
const hostUrl = `/wopi/edit/${encodeURIComponent(fileId)}?access_token=${encodeURIComponent(data.access_token)}`;
window.open(hostUrl, '_blank');
} catch (error) {
console.error('Failed to open WOPI editor in tab:', error);
ui.showNotification(i18n.t('notif.errorTitle'), i18n.t('notif.wopiOpenError'));
}
}
/**
* Fetch editor URL and WOPI token from the backend.
* @param {string} fileId
* @param {string} action
*/
async _getEditorUrl(fileId, action) {
const response = await fetch(`/api/wopi/editor-url?file_id=${encodeURIComponent(fileId)}&action=${encodeURIComponent(action)}`, {
credentials: 'same-origin'
});
if (!response.ok) {
const text = await response.text();
throw new Error(`Editor URL request failed: ${response.status} ${text}`);
}
return response.json();
}
/**
* Some WOPI file types, such as PDFs, are view-only.
* If an edit request returns 422, retry once in view mode.
* @param {string} fileId
* @param {string} fileName
* @param {string} action
*/
async _getEditorUrlWithFallback(fileId, fileName, action) {
try {
return await this._getEditorUrl(fileId, action);
} catch (error) {
if (!this._shouldRetryInViewMode(fileName, action, error)) {
throw error;
}
return this._getEditorUrl(fileId, 'view');
}
}
/**
* @param {string} fileName
* @param {string} action
* @param {any} error
*/
_shouldRetryInViewMode(fileName, action, error) {
if (action !== 'edit' || !error || !error.message) {
return false;
}
var ext = fileName.split('.').pop().toLowerCase();
return ext === 'pdf' && error.message.indexOf('422') !== -1;
}
/**
* Show the editor in a full-screen modal with iframe.
* @param {Record<string, any>} editorData
* @param {string} fileName
*/
_showModal(editorData, fileName) {
this.closeEditor();
const modal = document.createElement('div');
modal.id = 'wopi-editor-modal';
modal.style.cssText = 'position:fixed;top:0;left:0;width:100%;height:100%;z-index:10000;background:#fff;';
const header = document.createElement('div');
header.style.cssText =
'height:40px;background:#333;color:#fff;display:flex;align-items:center;justify-content:space-between;padding:0 16px;font-family:sans-serif;font-size:14px;';
const title = document.createElement('span');
title.textContent = fileName;
header.appendChild(title);
const closeBtn = document.createElement('button');
closeBtn.textContent = '\u2715';
closeBtn.style.cssText = 'background:none;border:none;color:#fff;cursor:pointer;font-size:18px;padding:4px 8px;';
closeBtn.onclick = this.closeEditor.bind(this);
header.appendChild(closeBtn);
const form = document.createElement('form');
form.id = 'wopi_form';
form.target = 'wopi_frame';
form.action = editorData.editor_url;
form.method = 'post';
form.style.display = 'none';
const tokenInput = document.createElement('input');
tokenInput.name = 'access_token';
tokenInput.value = editorData.access_token;
tokenInput.type = 'hidden';
form.appendChild(tokenInput);
const ttlInput = document.createElement('input');
ttlInput.name = 'access_token_ttl';
ttlInput.value = editorData.access_token_ttl;
ttlInput.type = 'hidden';
form.appendChild(ttlInput);
const frameHolder = document.createElement('div');
frameHolder.style.cssText = 'position:absolute;top:40px;left:0;right:0;bottom:0;';
// Loading spinner (removed once the editor signals ready)
const spinner = document.createElement('div');
spinner.id = 'wopi-loading-spinner';
spinner.style.cssText =
'position:absolute;top:0;left:0;right:0;bottom:0;display:flex;align-items:center;justify-content:center;background:#f5f5f5;z-index:1;';
spinner.innerHTML = '<i class="fas fa-spinner fa-spin empty-state-icon spinner"></i>';
frameHolder.appendChild(spinner);
const iframe = document.createElement('iframe');
iframe.name = 'wopi_frame';
iframe.title = 'Document Editor';
iframe.style.cssText = 'width:100%;height:100%;border:none;';
iframe.setAttribute('allowfullscreen', 'true');
// Fix 9: allow clipboard access for copy/paste inside the editor
iframe.setAttribute('allow', 'clipboard-read; clipboard-write');
iframe.setAttribute('sandbox', 'allow-scripts allow-same-origin allow-forms allow-popups allow-top-navigation allow-popups-to-escape-sandbox');
frameHolder.appendChild(iframe);
modal.appendChild(header);
modal.appendChild(form);
modal.appendChild(frameHolder);
document.body.appendChild(modal);
// ESC key handler
this._escHandler = (/** @type {KeyboardEvent} */ e) => {
if (e.key === 'Escape') this.closeEditor();
};
document.addEventListener('keydown', this._escHandler);
// Fix 7: Listen for postMessage from the editor iframe
this._messageHandler = (/** @type {MessageEvent} */ e) => {
var data;
try {
data = JSON.parse(e.data);
} catch (_) {
return; // Not a JSON message — ignore
}
var msgId = data.MessageId || data.messageId || '';
if (msgId === 'UI_Close' || msgId === 'close') {
this.closeEditor();
} else if (msgId === 'App_LoadingStatus') {
const status = data.Values?.Status;
if (status === 'Document_Loaded' || status === 'Frame_Ready') {
const sp = document.getElementById('wopi-loading-spinner');
if (sp) sp.remove();
}
}
};
window.addEventListener('message', this._messageHandler);
form.submit();
this.editorModal = modal;
}
/**
* Close the editor modal and refresh the file list.
*/
closeEditor() {
var modal = document.getElementById('wopi-editor-modal');
if (modal) modal.remove();
if (this._escHandler) {
document.removeEventListener('keydown', this._escHandler);
this._escHandler = null;
}
if (this._messageHandler) {
window.removeEventListener('message', this._messageHandler);
this._messageHandler = null;
}
this.editorModal = null;
// Refresh file list to pick up any saves
loadFiles();
}
/**
* Fetch supported extensions from the server (cached).
*/
async _getSupportedExtensions() {
if (this._supportedExtensions !== null) {
return this._supportedExtensions;
}
return this._fetchSupportedExtensions();
}
/**
* Fetch supported extensions from /wopi/supported-extensions.
* Falls back to a hardcoded list on failure.
*/
async _fetchSupportedExtensions() {
try {
const response = await fetch('/wopi/supported-extensions');
if (response.ok) {
const exts = await response.json();
if (Array.isArray(exts) && exts.length > 0) {
this._supportedExtensions = exts;
return exts;
}
}
} catch (_) {
// Ignore — fall through to hardcoded list
}
// Fallback hardcoded list
this._supportedExtensions = ['docx', 'doc', 'odt', 'rtf', 'txt', 'xlsx', 'xls', 'ods', 'csv', 'pptx', 'ppt', 'odp'];
return this._supportedExtensions;
}
}
// Global instance
export const wopiEditor = new WopiEditor();
// Prefetch supported extensions so canEdit() is fast on first use
wopiEditor._fetchSupportedExtensions();