Files
Oxicloud/frontend/src/lib/components/WopiEditor.svelte
T

195 lines
4.5 KiB
Svelte
Raw Normal View History

2026-06-17 22:07:18 -06:00
<script lang="ts">
2026-06-17 22:30:56 -06:00
import { errorToast } from '$lib/utils/errors';
2026-06-17 22:07:18 -06:00
import { getEditorUrlWithFallback } from '$lib/api/endpoints/wopi';
import Icon from '$lib/icons/Icon.svelte';
import { t } from '$lib/i18n/index.svelte';
interface Props {
open: boolean;
fileId: string | null;
fileName: string;
action?: 'edit' | 'view';
onclose?: () => void;
}
let { open = $bindable(false), fileId, fileName, action = 'edit', onclose }: Props = $props();
let form = $state<HTMLFormElement | null>(null);
let editorUrl = $state('');
let token = $state('');
let tokenTtl = $state('');
let loading = $state(false);
function close() {
open = false;
editorUrl = '';
onclose?.();
}
function onKeydown(e: KeyboardEvent) {
if (open && e.key === 'Escape') close();
}
// The editor iframe posts status messages (Collabora / OnlyOffice WOPI
// protocol). We drop the spinner once it loads, and close the host modal
// when the editor's own close button fires UI_Close / Document close.
function onMessage(e: MessageEvent) {
if (!open) return;
let data: Record<string, unknown>;
try {
data = JSON.parse(typeof e.data === 'string' ? e.data : '') as Record<string, unknown>;
} catch {
return; // not a JSON message — ignore
}
const msgId = String(data.MessageId ?? data.messageId ?? '');
if (msgId === 'UI_Close' || msgId === 'close') {
close();
} else if (msgId === 'App_LoadingStatus') {
const values = data.Values as { Status?: string } | undefined;
const status = values?.Status;
if (status === 'Document_Loaded' || status === 'Frame_Ready') {
loading = false;
}
}
}
// When opened, fetch the editor URL + token, then submit the (hidden) form
// into the iframe — this is the WOPI host-page POST handshake.
$effect(() => {
if (!open || !fileId) return;
loading = true;
editorUrl = '';
getEditorUrlWithFallback(fileId, fileName, action)
.then((data) => {
editorUrl = data.editor_url;
token = data.access_token;
tokenTtl = String(data.access_token_ttl);
// Submit on the next microtask once the form has the values bound.
queueMicrotask(() => form?.submit());
})
.catch((e) => {
2026-06-17 22:30:56 -06:00
errorToast(e);
2026-06-17 22:07:18 -06:00
close();
})
.finally(() => (loading = false));
});
</script>
<svelte:window onkeydown={onKeydown} onmessage={onMessage} />
{#if open}
<div
class="wopi"
role="dialog"
data-testid="wopi-editor-dialog"
aria-modal="true"
aria-label={fileName}
>
2026-06-17 22:07:18 -06:00
<header class="wopi__bar">
<span class="wopi__title">{fileName}</span>
<button
class="wopi__close"
data-testid="wopi-editor-close-btn"
aria-label={t('common.close', 'Close')}
onclick={close}
>
2026-06-17 22:07:18 -06:00
<Icon name="times" />
</button>
</header>
<div class="wopi__frame-wrap">
{#if loading}
<p class="wopi__status">{t('common.loading', 'Loading…')}</p>
{/if}
{#if editorUrl}
<form
bind:this={form}
action={editorUrl}
method="post"
target="wopi_frame"
class="wopi__form"
>
<input
type="hidden"
name="access_token"
value={token}
data-testid="wopi-editor-access-token-input"
/>
<input
type="hidden"
name="access_token_ttl"
value={tokenTtl}
data-testid="wopi-editor-access-token-ttl-input"
/>
2026-06-17 22:07:18 -06:00
</form>
{/if}
<iframe
name="wopi_frame"
title={t('files.editor', 'Document editor')}
class="wopi__frame"
allow="clipboard-read; clipboard-write"
allowfullscreen
sandbox="allow-scripts allow-same-origin allow-forms allow-popups allow-top-navigation allow-popups-to-escape-sandbox"
></iframe>
</div>
</div>
{/if}
<style>
.wopi {
position: fixed;
inset: 0;
z-index: 1100;
display: flex;
flex-direction: column;
background: var(--color-bg-base, var(--color-bg-surface));
}
.wopi__bar {
display: flex;
align-items: center;
justify-content: space-between;
height: 40px;
padding: 0 1rem;
background: var(--color-bg-elevated, var(--color-bg-surface));
border-bottom: 1px solid var(--color-border);
color: var(--color-text-heading);
}
.wopi__title {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.wopi__close {
background: none;
border: none;
color: var(--color-text);
cursor: pointer;
font-size: 1.1rem;
}
.wopi__frame-wrap {
position: relative;
flex: 1;
}
.wopi__form {
display: none;
}
.wopi__frame {
width: 100%;
height: 100%;
border: none;
}
.wopi__status {
position: absolute;
inset: 0;
display: grid;
place-items: center;
color: var(--color-text-muted);
}
</style>