feat(office): mobileEdit am Telefon, PDF-Umweg entfällt

Ein Office-Dokument öffnete am Telefon als gewandeltes PDF, weil OnlyOffices
Oberfläche auf 400 Pixeln nichts vom Dokument übrig lässt. Die Begründung war
falsch: Die Ermittlungsliste bietet zu jeder Endung mehrere Aktionen an, und
OnlyOffice/Euro-Office führen neben `edit` auch `mobileEdit` – dieselbe
Adresse mit `?mobile=1`, dahinter eine für kleine Schirme gebaute Oberfläche.
`EditURL` kannte nur `edit` und `view`; die Handy-Fassung lag die ganze Zeit
in der Tabelle und wurde nie abgerufen.

Entschieden wird am Zeigegerät, nicht an der Fensterbreite – ein schmales
Fenster auf dem Schreibtisch ist immer noch eine Maus.

Mit dem Anlass fällt die Notlösung: preview.go (229 Zeilen), convert.go (271)
samt Test, files.preview_version (Migration 000026), die Sonderabfrage im GC,
die Route und dreizehn Fundstellen im Frontend. convert.go hatte keinen
anderen Aufrufer – die gesamte Umwandlung nach PDF existierte allein für
diese Ansicht. Wer ein PDF braucht, exportiert es im Editor. Die Dateien
unter previews/ sammelt der GC ein.

Zwei Fehler, die dabei sichtbar wurden:

fix(history): „Edit" schloss die Datei, statt den Editor zu öffnen. Beim
Wechsel räumt React erst die alte Ansicht ab; der Aufräumer von useBackClose
ruft history.back(), das verzögert wirkt und deshalb den Eintrag verbrauchte,
den der Editor gerade angelegt hatte. Ein Zähler für selbst ausgelöste
Schritte verschluckt genau diese popstate.

fix(filetypes): `.txt` ging gar nicht auf. Es stand in officeExtensions und
lief damit in den Office-Zweig statt in den eingebauten Text-Editor – am
Telefon in die PDF-Umwandlung, die daran scheiterte. `md`, `log` und `rst`
standen nie drin; das war die Asymmetrie.

Dazu Euro-Office als Compose-Profil (Abbild 6,99 GB, nachgemessen – die
Ankündigung nennt 5) und die Android-App als Idee.

Geprüft: go build/vet sauber, tsc sauber, 37 Vitest-Tests, alle 234
Prüfungen der sieben Selbsttest-Suiten grün.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
marcandClaude Opus 5 committed 2026-09-16 17:36:31 +02:00
1 parent 6042f6f2f6
commit 944b2c63e2
23 files changed
+284 -788

No files matched your search

+19 -14
View File
@@ -591,8 +591,21 @@ export interface OfficeSession {
* damit das Kontextmenue den Punkt gar nicht erst anbietet, wo er nicht ginge –
* entschieden wird serverseitig.
*/
/**
* Endungen, die im Office-Server aufgehen sollen.
*
* **`txt` steht hier bewusst nicht mehr drin** (seit 16.09.2026). Es stand es
* einmal, weil der Office-Server eine Textdatei durchaus öffnen kann – nur
* hat Aether einen eingebauten Text-Editor, der sofort da ist, keinen Server
* braucht und direkt speichert. Mit `txt` in dieser Liste lief eine
* 12-Byte-Notiz stattdessen in den Office-Zweig und ging am Telefon gar nicht
* auf. `md`, `log` und `rst` standen nie drin – das war die Asymmetrie.
*
* `csv` bleibt: Eine Tabelle ist im Tabellenprogramm besser aufgehoben als in
* einem Textfeld.
*/
const officeExtensions = new Set([
'odt', 'docx', 'doc', 'rtf', 'txt',
'odt', 'docx', 'doc', 'rtf',
'ods', 'xlsx', 'xls', 'csv',
'odp', 'pptx', 'ppt',
'odg',
@@ -604,8 +617,11 @@ export function isOfficeFile(name: string): boolean {
}
export const officeApi = {
session: (fileId: string) =>
request<OfficeSession>('GET', `/office/session/${fileId}`),
/** `mobile` wählt die Handy-Fassung des Editors, wo der Office-Server eine
* anbietet (OnlyOffice/Euro-Office: `mobileEdit`). Collabora kennt sie
* nicht und bleibt unverändert. */
session: (fileId: string, mobile = false) =>
request<OfficeSession>('GET', `/office/session/${fileId}${mobile ? '?mobile=1' : ''}`),
}
export const ocrApi = {
@@ -651,17 +667,6 @@ export const filesApi = {
* Gemessen: dreimal dieselbe Datei hochgeladen ergibt dreimal dieselbe ID
* mit den Fassungen 1, 2, 3.
*/
/**
* Ein Office-Dokument als PDF – zum Ansehen, ohne den Editor.
*
* Die Fassung steht in der Adresse: Nach einer Bearbeitung ändert sie sich,
* und der Browser holt das PDF von selbst neu, statt ein Dokument von
* gestern aus seinem Zwischenspeicher zu zeigen. Derselbe Kniff wie beim
* Vorschaubild.
*/
officePreview: (id: string, version?: number) =>
`/api/v1/files/${id}/preview.pdf${version ? `?v=${version}` : ''}`,
thumbnail: (id: string, version?: number | string) =>
`/api/v1/files/${id}/thumbnail${version ? `?v=${encodeURIComponent(version)}` : ''}`,
+3 -33
View File
@@ -19,17 +19,6 @@ interface Props {
onClose: () => void
/** Nach dem Speichern: Liste neu holen, damit Größe und Datum stimmen. */
onSaved?: () => void
/**
* Ein Office-Dokument als PDF zeigen statt im Editor.
*
* Am Telefon der Normalfall: Der Editor ist dort eine vollständige
* Textverarbeitung, und vom Dokument bleibt zwischen seinen Leisten ein
* Rechteck übrig. Zum Nachsehen – und das ist unterwegs fast immer der
* Anlass – ist ein PDF im eigenen Betrachter das bessere Werkzeug.
*/
asOfficePdf?: boolean
/** „Trotzdem bearbeiten" – nur gesetzt, wenn es einen Editor gibt. */
onEdit?: () => void
/**
* Suchwort, mit dem die Datei gefunden wurde. Der PDF-Betrachter springt
* damit zur Fundstelle, statt auf Seite 1 zu beginnen – der Grund, aus dem
@@ -101,12 +90,10 @@ export function canPreview(file: FileItem): boolean {
return kindOf(file.mime_type, file.name) !== 'none'
}
export default function FilePreview({ file, onPrev, onNext, onClose, onSaved, searchTerm, asOfficePdf, onEdit }: Props) {
// Ein Office-Dokument ist für den Browser sonst „nichts anzeigbares" – als
// gewandeltes PDF ist es eins.
const kind = asOfficePdf ? 'pdf' : kindOf(file.mime_type, file.name)
export default function FilePreview({ file, onPrev, onNext, onClose, onSaved, searchTerm }: Props) {
const kind = kindOf(file.mime_type, file.name)
const narrow = useNarrow()
const src = asOfficePdf ? filesApi.officePreview(file.id, file.version) : filesApi.inline(file.id)
const src = filesApi.inline(file.id)
const [text, setText] = useState<string | null>(null)
const [tooBig, setTooBig] = useState(false)
const [editing, setEditing] = useState(false)
@@ -218,23 +205,6 @@ export default function FilePreview({ file, onPrev, onNext, onClose, onSaved, se
{file.name}
</span>
{/* Der Weg in den Editor bleibt offen – eine Ansicht ohne Ausweg wäre
eine Bevormundung. Er steht bewusst *neben* dem Namen und nicht
als Hauptknopf: Am Telefon ist Nachsehen der Anlass, Bearbeiten
die Ausnahme. */}
{asOfficePdf && onEdit && (
<button
onClick={onEdit}
style={{
padding: '6px 12px', borderRadius: 8, fontSize: 12, flexShrink: 0,
border: '1px solid var(--color-border)', background: 'transparent',
color: 'var(--color-text)', cursor: 'pointer', whiteSpace: 'nowrap',
}}
>
Edit
</button>
)}
{kind === 'text' && !tooBig && text !== null && (
editing ? (
<>
+6 -2
View File
@@ -1,6 +1,7 @@
import { useEffect, useRef, useState } from 'react'
import { officeApi, type FileItem, type OfficeSession } from '../api/client'
import { useBackClose } from '../hooks/useBackClose'
import { useCoarsePointer } from './FileViews'
/**
* Ein Dokument im Office-Server bearbeiten.
@@ -21,10 +22,13 @@ export default function OfficeEditor({ file, onClose }: {
const [session, setSession] = useState<OfficeSession | null>(null)
const [error, setError] = useState('')
const formRef = useRef<HTMLFormElement>(null)
// Am Zeigegerät, nicht an der Fensterbreite: Ein schmales Fenster auf dem
// Schreibtisch ist immer noch eine Maus und eine Tastatur.
const coarse = useCoarsePointer()
useEffect(() => {
let alive = true
officeApi.session(file.id)
officeApi.session(file.id, coarse)
.then(s => {
if (!alive) return
if (!s.enabled) { setError('Office editing is switched off on this server.'); return }
@@ -35,7 +39,7 @@ export default function OfficeEditor({ file, onClose }: {
setError(err instanceof Error ? err.message : 'Could not open the editor')
})
return () => { alive = false }
}, [file.id])
}, [file.id, coarse])
// Das Formular abschicken, sobald es steht. Ein useEffect statt eines
// autoSubmit-Attributs: HTML kennt keines, und ein Klick des Nutzers wäre
+24
View File
@@ -26,6 +26,23 @@ import { useEffect, useRef } from 'react'
let stack: number[] = []
let seq = 0
/**
* Wie viele `popstate` noch von **uns selbst** stammen und nicht vom Nutzer.
*
* Der Aufräumer unten ruft `history.back()`, wenn eine Ansicht über einen
* Knopf geschlossen wurde – ihr Eintrag muss ja weg. Nur wirkt `back()` nicht
* sofort, sondern erst im nächsten Durchlauf. Öffnet in derselben Runde eine
* *andere* Ansicht (Vorschau → „Edit" → Editor), liegt deren frischer Eintrag
* dann schon obenauf – und das verspätete `back()` verbraucht ihn. Der Editor
* bekam ein `popstate`, hielt es für die Zurück-Geste und schloss sich sofort
* wieder: Aus „Edit" wurde „Datei zu".
*
* Deshalb zählen wir die selbst ausgelösten Schritte mit und verschlucken
* genau so viele `popstate`. Die Zurück-Geste des Nutzers erkennt man daran,
* dass keiner offen ist.
*/
let eigeneSchritte = 0
/** Wir merken uns unseren Eintrag im State des Browsers, um ihn beim
* Zurückkommen wiederzuerkennen. */
const stateKey = 'aether:overlay'
@@ -63,6 +80,12 @@ export function useBackClose({ active, onClose }: Options) {
}
function onPop() {
// Von uns selbst ausgelöst? Dann ist nichts zu schließen – der Schritt
// gehört zu einer Ansicht, die bereits weg ist.
if (eigeneSchritte > 0) {
eigeneSchritte--
return
}
// Nur der oberste reagiert. Ein tiefer liegender Eintrag hat seinen
// eigenen Zurück-Schritt noch vor sich.
if (stack.at(-1) !== id) return
@@ -81,6 +104,7 @@ export function useBackClose({ active, onClose }: Options) {
// Zurück-Geste danach einen Schritt ins Leere.
if (stack.at(-1) === id) {
stack = stack.filter(x => x !== id)
eigeneSchritte++
window.history.back()
} else {
stack = stack.filter(x => x !== id)
+3 -30
View File
@@ -6,7 +6,7 @@ import {
} from '../api/client'
import Breadcrumb from '../components/Breadcrumb'
import PageHeader from '../components/PageHeader'
import GridView, { ListView, useCoarsePointer } from '../components/FileViews'
import GridView, { ListView } from '../components/FileViews'
import FilePreview, { canPreview, kindOf } from '../components/FilePreview'
import ContextMenu, { type MenuItem } from '../components/ContextMenu'
import UploadQueue from '../components/UploadQueue'
@@ -98,14 +98,9 @@ export default function FileBrowser({
const [viewMode, setViewMode] = useState<ViewMode>(() => loadPref('aether:view', 'grid'))
const [sort, setSort] = useState<Sort>(() => loadPref('aether:sort', 'name-asc'))
const [preview, setPreview] = useState<FileItem | null>(null)
/** Office-Dokument als PDF – am Telefon der übliche Weg hinein. */
const [officePreview, setOfficePreview] = useState<FileItem | null>(null)
/** Datei, die gerade im Office-Server bearbeitet wird. */
const [editing, setEditing] = useState<FileItem | null>(null)
const { officeEnabled, officeProvider } = useAppSettings()
/** Finger oder Maus? Entscheidet, ob ein Office-Dokument als PDF oder im
* Editor aufgeht. */
const touchDevice = useCoarsePointer()
const { officeEnabled } = useAppSettings()
/** Etiketten je Datei-ID – kommen mit der Ordnerliste in einer Abfrage mit,
* nicht einzeln je Kachel. */
@@ -354,20 +349,7 @@ export default function FileBrowser({
*/
function openFile(file: FileItem) {
if (officeEnabled && isOfficeFile(file.name)) {
// **Nur bei OnlyOffice** öffnet ein Dokument am Telefon als PDF.
//
// Der Unterschied ist gemessen, nicht vermutet: Collabora bringt eine
// eigene Handy-Ansicht mit, die taugt – dort ist der Editor der
// richtige Weg. OnlyOffice baut auf dem Telefon dieselbe Oberfläche
// auf wie am PC; zwischen Menüband, Formatleisten und
// Eigenschaftenbereich bleibt vom Dokument ein Rechteck übrig, und
// genau das war der Anlass für die PDF-Ansicht.
//
// Die Entscheidung fällt am **Zeigegerät**, nicht an der Fensterbreite:
// Ein schmales Fenster auf dem Schreibtisch ist immer noch eine Maus
// und eine Tastatur, und dort will man bearbeiten.
if (touchDevice && officeProvider === 'onlyoffice') setOfficePreview(file)
else setEditing(file)
setEditing(file)
return
}
if (canPreview(file)) setPreview(file)
@@ -643,15 +625,6 @@ export default function FileBrowser({
/>
)}
{officePreview && (
<FilePreview
file={officePreview}
asOfficePdf
onEdit={() => { const f = officePreview; setOfficePreview(null); setEditing(f) }}
onClose={() => setOfficePreview(null)}
/>
)}
{preview && (
<FilePreview
file={preview}