Office tab: create, list and edit documents in place, saved in Seafile
build-container / build (push) Successful in 2m19s

- Choose where documents are saved (library + folder picker, like Files); remembered
- New document/spreadsheet/presentation asks for a name and shows the save location
- Documents in that location are listed in the tab; opening one edits it inside the tab
  with the navigation visible and a Back to Office button
- Fix double slash in editor URLs

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 5.5 committed 2026-10-20 14:19:52 -04:00
1 parent b5603d2488
commit 4456c03cd0
2 files changed
+121 -40

No files matched your search

+118 -40
View File
@@ -128,6 +128,7 @@ function route() {
try { editor?.destroyEditor(); } catch {} try { editor?.destroyEditor(); } catch {}
editor = null; editor = null;
document.body.classList.toggle('editing', name === 'edit'); document.body.classList.toggle('editing', name === 'edit');
document.body.classList.remove('inedit');
renderNav(name === 'edit' ? 'files' : name); renderNav(name === 'edit' ? 'files' : name);
routes[name](rest.map(decodeURIComponent)); routes[name](rest.map(decodeURIComponent));
$('#view').scrollTop = 0; $('#view').scrollTop = 0;
@@ -318,26 +319,96 @@ function viewMail() {
<span class="grow"><b>New message</b><small>from ${esc(m.name)}</small></span>${icon('chev', 'chev')}</a>`).join('')}</div></div>`; <span class="grow"><b>New message</b><small>from ${esc(m.name)}</small></span>${icon('chev', 'chev')}</a>`).join('')}</div></div>`;
} }
function viewOffice() { const officeHash = (repo, path) => '#/office/doc/' + encodeURIComponent(repo) + path.split('/').filter(Boolean).map(x => '/' + encodeURIComponent(x)).join('');
const docs = store.get('recents', []).filter(r => EDIT_EXT.has(ext(r.name)) && ext(r.name) !== 'txt').slice(0, 8);
$('#view').innerHTML = `<div class="wrap"><h1>Office</h1><p class="sub">Create a file in Seafile and edit it right here.</p> function viewOffice(args = []) {
if (args[0] === 'doc') return viewEdit(args.slice(1), { fromOffice: true });
$('#view').innerHTML = `<div class="wrap"><h1>Office</h1><p class="sub">Create, open and edit documents without leaving this tab. Everything is saved in Seafile.</p>
<div class="grid"> <div class="grid">
<button class="tile" data-x="docx" data-l="document"><span class="ic" style="background:#3d6bf0">${icon('doc')}</span><b>Document</b><small>Word (.docx)</small></button> <button class="tile" data-x="docx" data-l="document"><span class="ic" style="background:#3d6bf0">${icon('doc')}</span><b>Document</b><small>Word (.docx)</small></button>
<button class="tile" data-x="xlsx" data-l="spreadsheet"><span class="ic" style="background:#2f9e6e">${icon('doc')}</span><b>Spreadsheet</b><small>Excel (.xlsx)</small></button> <button class="tile" data-x="xlsx" data-l="spreadsheet"><span class="ic" style="background:#2f9e6e">${icon('doc')}</span><b>Spreadsheet</b><small>Excel (.xlsx)</small></button>
<button class="tile" data-x="pptx" data-l="presentation"><span class="ic" style="background:#e0742b">${icon('doc')}</span><b>Presentation</b><small>PowerPoint (.pptx)</small></button> <button class="tile" data-x="pptx" data-l="presentation"><span class="ic" style="background:#e0742b">${icon('doc')}</span><b>Presentation</b><small>PowerPoint (.pptx)</small></button>
</div> </div>
<p class="sub" id="olib" style="margin-top:12px"></p> <p class="sub" id="olib" style="margin-top:12px">${sf.token() ? 'Loading…' : 'Sign in to Files to create and open documents.'}</p>
${docs.length ? `<h2>Recent documents</h2><div class="list">${docs.map((r, i) => `<button class="row" data-r="${i}"><span class="ic" style="background:#e0742b">${icon('file')}</span><span class="grow"><b>${esc(r.name)}</b><small>${esc(r.path.slice(0, r.path.lastIndexOf('/')) || '/')}</small></span></button>`).join('')}</div>` : ''} <div id="odocs"></div>
<div class="bar"><a class="btn ghost" href="#/files">Browse all files</a></div> <div class="bar"><a class="btn ghost" href="#/files">Browse all files</a>${sf.token() ? '' : '<a class="btn" href="#/files">Sign in</a>'}</div></div>`;
<p class="sub">Editing server: <a href="${esc(cfg.onlyoffice)}" target="_blank" rel="noopener" style="color:var(--accent)">${esc(new URL(cfg.onlyoffice).host)}</a></p></div>`;
$('#view').querySelectorAll('.tile[data-x]').forEach(t => t.onclick = () => newOfficeDoc(t.dataset.x, t.dataset.l)); $('#view').querySelectorAll('.tile[data-x]').forEach(t => t.onclick = () => newOfficeDoc(t.dataset.x, t.dataset.l));
$('#view').querySelectorAll('button[data-r]').forEach(b => b.onclick = () => { const r = docs[b.dataset.r]; openFile(r.repo, r.path, r.name, r.path.slice(0, r.path.lastIndexOf('/')) || '/'); }); if (!sf.token()) return;
if (sf.token()) sf.req('/api2/repos/').then(repos => { (async () => {
const r = repos.find(x => x.id === store.get('office_repo')) || (repos.length === 1 ? repos[0] : null); try {
const el = $('#olib'); if (!el) return; const loc = await officeLocation(false), el = $('#olib'); if (!el) return;
el.innerHTML = r ? `New files go in <b>${esc(r.name)}</b> · <a href="#" id="chl" style="color:var(--accent)">change</a>` : 'You will be asked which library to use.'; if (!loc) {
const c = $('#chl'); if (c) c.onclick = e => { e.preventDefault(); store.del('office_repo'); pickLibrary().then(() => viewOffice()).catch(() => {}); }; el.textContent = 'Choose where your documents are saved in Seafile.';
}).catch(() => {}); $('#odocs').innerHTML = '<div class="bar"><button class="btn" id="chl2">Choose location</button></div>';
$('#chl2').onclick = () => chooseFolder('Save documents in…').then(l => { if (l) { store.set('office_loc', l); viewOffice(); } });
return;
}
el.innerHTML = `Saved in <b>${esc(locLabel(loc))}</b> · <a href="#" id="chl" style="color:var(--accent)">change</a>`;
$('#chl').onclick = e => { e.preventDefault(); chooseFolder('Save documents in…', loc).then(l => { if (l) { store.set('office_loc', l); viewOffice(); } }); };
const all = await sf.req(`/api2/repos/${encodeURIComponent(loc.repo)}/dir/?p=${encodeURIComponent(loc.dir)}&recursive=1`);
const docs = all.filter(f => f.type === 'file' && EDIT_EXT.has(ext(f.name)) && ext(f.name) !== 'txt').sort((a, b) => b.mtime - a.mtime).slice(0, 60);
if (!location.hash.startsWith('#/office') || !$('#odocs')) return;
$('#odocs').innerHTML = docs.length ? `<h2>Documents</h2><div class="list">${docs.map((f, i) => {
const color = { docx: '#3d6bf0', doc: '#3d6bf0', odt: '#3d6bf0', rtf: '#3d6bf0', xlsx: '#2f9e6e', xls: '#2f9e6e', ods: '#2f9e6e', csv: '#2f9e6e' }[ext(f.name)] || '#e0742b';
return `<button class="row" data-i="${i}"><span class="ic" style="background:${color}">${icon('file')}</span><span class="grow"><b>${esc(f.name)}</b><small>${esc(f.parent_dir || '/')} · ${fmtDate(f.mtime)}</small></span></button>`; }).join('')}</div>`
: '<p class="empty">No documents here yet. Create one above.</p>';
$('#odocs').querySelectorAll('button[data-i]').forEach(b => b.onclick = () => {
const f = docs[b.dataset.i], path = (f.parent_dir || '/').replace(/\/?$/, '/') + f.name;
addRecent(loc.repo, path, f.name);
if (cfg.editor) location.hash = officeHash(loc.repo, path); else window.open(`${cfg.seafile}/lib/${encodeURIComponent(loc.repo)}/file${encP(path)}`, '_blank', 'noopener');
});
} catch (e) { const el = $('#olib'); if (el) el.innerHTML = `<span class="err">${esc(e.message)}</span>`; }
})();
}
const locLabel = l => l.repoName + (l.dir === '/' ? '' : ' / ' + l.dir.split('/').filter(Boolean).join(' / '));
// The saved Office location, if it still exists (the library may have been deleted).
async function officeLocation(ask) {
const loc = store.get('office_loc');
if (loc) {
let repos;
try { repos = await sf.req('/api2/repos/'); } catch { return loc; } // a network hiccup must not forget the choice
const r = repos.find(x => x.id === loc.repo);
if (r) {
try { await sf.req(`/api2/repos/${encodeURIComponent(loc.repo)}/dir/?p=${encodeURIComponent(loc.dir)}`); return { ...loc, repoName: r.name }; }
catch (e) { if (!/not found|404|exist/i.test(e.message)) return { ...loc, repoName: r.name }; }
}
store.del('office_loc'); // library or folder is really gone
}
if (!ask) return null;
const l = await chooseFolder('Save documents in…'); if (l) store.set('office_loc', l); return l;
}
// Pick a library and folder in Seafile (same structure as the Files tab). Resolves null if cancelled.
function chooseFolder(title, start) {
return new Promise(resolve => {
let repo = start ? start.repo : null, repoName = start ? start.repoName : '', dir = start ? start.dir : '/', done = false;
const el = openSheet(title, '', '<div id="cf"></div>');
el.querySelector('.scrim').addEventListener('click', () => { if (!done) resolve(null); });
const finish = v => { done = true; closeSheet(); resolve(v); };
const draw = async () => {
const box = el.querySelector('#cf'); box.innerHTML = '<p class="empty">Loading…</p>';
try {
if (!repo) {
const repos = (await sf.req('/api2/repos/')).filter(r => !r.encrypted && r.permission !== 'r').sort((a, b) => a.name.localeCompare(b.name));
box.innerHTML = `<p class="sub">Choose a library</p><div class="list" style="background:var(--panel2);border:0;max-height:50dvh;overflow:auto">${repos.map(r => `<button class="row" data-r="${esc(r.id)}">${icon('files')}<span class="grow"><b>${esc(r.name)}</b></span>${icon('chev', 'chev')}</button>`).join('') || '<p class="empty">No libraries. Create one in Files.</p>'}</div>
<div class="bar"><button class="btn ghost" id="cx" style="flex:1;justify-content:center">Cancel</button></div>`;
box.querySelectorAll('[data-r]').forEach(b => b.onclick = () => { repo = b.dataset.r; repoName = repos.find(r => r.id === repo).name; dir = '/'; draw(); });
} else {
const subs = (await sf.req(`/api2/repos/${encodeURIComponent(repo)}/dir/?p=${encodeURIComponent(dir)}`)).filter(x => x.type === 'dir').sort((a, b) => a.name.localeCompare(b.name));
box.innerHTML = `<p class="sub" style="word-break:break-all">${esc(locLabel({ repoName, dir }))}</p><div class="list" style="background:var(--panel2);border:0;max-height:45dvh;overflow:auto">
<button class="row" data-up="1">${icon('back')}<span class="grow"><b>${dir === '/' ? 'All libraries' : 'Up'}</b></span></button>
${subs.map(x => `<button class="row" data-d="${esc(x.name)}">${icon('files')}<span class="grow"><b>${esc(x.name)}</b></span>${icon('chev', 'chev')}</button>`).join('')}</div>
<div class="bar"><button class="btn ghost" id="cx" style="flex:1;justify-content:center">Cancel</button><button class="btn" id="cs" style="flex:1;justify-content:center">Save here</button></div>`;
box.querySelectorAll('[data-d]').forEach(b => b.onclick = () => { dir = joinP(dir, b.dataset.d); draw(); });
box.querySelector('[data-up]').onclick = () => { if (dir === '/') repo = null; else dir = dir.slice(0, dir.lastIndexOf('/')) || '/'; draw(); };
box.querySelector('#cs').onclick = () => finish({ repo, repoName, dir });
}
box.querySelector('#cx').onclick = () => finish(null);
} catch (e) { box.innerHTML = `<p class="err">${esc(e.message)}</p>`; }
};
draw();
});
} }
// Blank Office files come from valid templates (an empty 0-byte .docx/.xlsx/.pptx often will not open in the editor). // Blank Office files come from valid templates (an empty 0-byte .docx/.xlsx/.pptx often will not open in the editor).
@@ -355,30 +426,35 @@ async function createFromTemplate(repo, dir, name, x) {
return ((await r.json())[0] || {}).name || name; // Seafile renames if the name is taken return ((await r.json())[0] || {}).name || name; // Seafile renames if the name is taken
} }
// Which library new Office files go in (remembered once chosen).
async function pickLibrary() {
const repos = (await sf.req('/api2/repos/')).filter(r => !r.encrypted && r.permission !== 'r');
if (!repos.length) throw new Error('No library to create files in. Make one in Files first.');
const saved = repos.find(r => r.id === store.get('office_repo'));
if (saved) return saved;
if (repos.length === 1) { store.set('office_repo', repos[0].id); return repos[0]; }
return new Promise((ok, no) => actionSheet('Choose a library', 'New files will be created here', repos.map(r => ({ label: r.name, icon: 'files', run: () => { store.set('office_repo', r.id); ok(r); } })))
|| 0);
}
async function newOfficeDoc(x, label) { async function newOfficeDoc(x, label) {
if (!sf.token()) { toast('Sign in to Files first'); location.hash = '#/files'; return; } if (!sf.token()) { toast('Sign in to Files first'); location.hash = '#/files'; return; }
let repo; let loc;
try { repo = await pickLibrary(); } catch (e) { return toast(e.message); } try { loc = await officeLocation(true); } catch (e) { return toast(e.message); }
textSheet('New ' + label, 'File name (in ' + repo.name + ')', 'Create', async n => { if (!loc) return;
const el = openSheet('New ' + label, '', `<form><label for="nn">File name</label><input type="text" id="nn" value="Untitled" autocomplete="off" autocapitalize="off" required>
<label>Saved in</label><button type="button" class="row" id="nl" style="background:var(--panel2);border-radius:12px;border:0"><span class="grow"><b id="nlt"></b></span><small style="color:var(--accent)">Change</small></button>
<div class="bar"><button class="btn ghost" type="button" id="sx" style="flex:1;justify-content:center">Cancel</button><button class="btn" style="flex:1;justify-content:center">Create</button></div><div class="err" id="ne"></div></form>`);
const showLoc = () => { el.querySelector('#nlt').textContent = locLabel(loc); };
showLoc();
el.querySelector('#sx').onclick = closeSheet;
el.querySelector('#nl').onclick = async () => {
// Sheets don't stack, so close this one, choose, then reopen with the new location.
const picked = await chooseFolder('Save in…', loc);
if (picked) { store.set('office_loc', picked); } newOfficeDoc(x, label);
};
setTimeout(() => { const t = el.querySelector('#nn'); t.focus(); t.select(); }, 50);
el.querySelector('form').onsubmit = async e => {
e.preventDefault();
const n = el.querySelector('#nn').value.trim(); if (!n) return;
const want = n.toLowerCase().endsWith('.' + x) ? n : n + '.' + x; const want = n.toLowerCase().endsWith('.' + x) ? n : n + '.' + x;
const btn = el.querySelector('form .btn:not(.ghost)'); btn.disabled = true;
try { try {
const name = await createFromTemplate(repo.id, '/', want, x), path = '/' + name; const name = await createFromTemplate(loc.repo, loc.dir, want, x), path = joinP(loc.dir, name);
addRecent(repo.id, path, name); addRecent(loc.repo, path, name); closeSheet();
if (cfg.editor) location.hash = '#/edit/' + encodeURIComponent(repo.id) + '/' + encodeURIComponent(name); if (cfg.editor) location.hash = officeHash(loc.repo, path);
else window.open(`${cfg.seafile}/lib/${encodeURIComponent(repo.id)}/file/${encodeURIComponent(name)}`, '_blank', 'noopener'); else window.open(`${cfg.seafile}/lib/${encodeURIComponent(loc.repo)}/file${encP(path)}`, '_blank', 'noopener');
} catch (e) { toast(e.message); } } catch (err) { btn.disabled = false; el.querySelector('#ne').textContent = err.message; }
}, 'Untitled'); };
} }
@@ -675,11 +751,13 @@ function viewSettings() {
} }
// ---- In-app editor (OnlyOffice Document Server, config signed server-side with the JWT secret) // ---- In-app editor (OnlyOffice Document Server, config signed server-side with the JWT secret)
async function viewEdit([repo, ...parts]) { async function viewEdit([repo, ...rawParts], opts = {}) {
const parts = rawParts.filter(Boolean);
const path = '/' + parts.join('/'), name = parts[parts.length - 1] || ''; const path = '/' + parts.join('/'), name = parts[parts.length - 1] || '';
const back = '#/files/' + encodeURIComponent(repo) + parts.slice(0, -1).map(s => '/' + encodeURIComponent(s)).join(''); const back = opts.fromOffice ? '#/office' : '#/files/' + encodeURIComponent(repo) + parts.slice(0, -1).map(s => '/' + encodeURIComponent(s)).join('');
document.body.classList.toggle('inedit', !!opts.fromOffice); // Office tab keeps the navigation visible around the editor
const v = $('#view'); const v = $('#view');
v.innerHTML = `<div class="edbar"><a class="btn ghost" href="${back}">${icon('back')}Close</a><b>${esc(name)}</b></div> v.innerHTML = `<div class="edbar"><a class="btn ghost" href="${back}">${icon('back')}${opts.fromOffice ? 'Office' : 'Close'}</a><b>${esc(name)}</b></div>
<div class="edwrap"><div id="oo"></div><p class="empty" id="oomsg">Opening…</p></div>`; <div class="edwrap"><div id="oo"></div><p class="empty" id="oomsg">Opening…</p></div>`;
if (!sf.token()) return (location.hash = '#/files'); if (!sf.token()) return (location.hash = '#/files');
const fail = m => { const e = $('#oomsg'); if (e) { e.className = 'err'; e.textContent = m; } }; const fail = m => { const e = $('#oomsg'); if (e) { e.className = 'err'; e.textContent = m; } };
@@ -692,7 +770,7 @@ async function viewEdit([repo, ...parts]) {
const s = document.createElement('script'); s.src = cfg.onlyoffice + '/web-apps/apps/api/documents/api.js'; const s = document.createElement('script'); s.src = cfg.onlyoffice + '/web-apps/apps/api/documents/api.js';
s.onload = ok; s.onerror = () => no(new Error('Could not load the OnlyOffice editor from ' + cfg.onlyoffice)); document.head.appendChild(s); s.onload = ok; s.onerror = () => no(new Error('Could not load the OnlyOffice editor from ' + cfg.onlyoffice)); document.head.appendChild(s);
}); });
if (!location.hash.startsWith('#/edit/')) return; if (!/^#\/(edit|office\/doc)\//.test(location.hash)) return;
$('#oomsg').remove(); $('#oomsg').remove();
conf.events = { onError: e => toast('Editor error: ' + (e.data?.errorDescription || e.data?.errorCode || '')) }; conf.events = { onError: e => toast('Editor error: ' + (e.data?.errorDescription || e.data?.errorCode || '')) };
editor = new DocsAPI.DocEditor('oo', conf); editor = new DocsAPI.DocEditor('oo', conf);
@@ -851,7 +929,7 @@ function addRecent(repo, path, name) {
// Default tap on a file: edit documents, preview media, otherwise show the actions. // Default tap on a file: edit documents, preview media, otherwise show the actions.
function openFile(repo, path, name, dir) { function openFile(repo, path, name, dir) {
if (cfg.editor && EDIT_EXT.has(ext(name)) && ext(name) !== 'txt') { addRecent(repo, path, name); location.hash = '#/edit/' + encodeURIComponent(repo) + path.split('/').map(s => '/' + encodeURIComponent(s)).join(''); } if (cfg.editor && EDIT_EXT.has(ext(name)) && ext(name) !== 'txt') { addRecent(repo, path, name); location.hash = '#/edit/' + encodeURIComponent(repo) + path.split('/').filter(Boolean).map(s => '/' + encodeURIComponent(s)).join(''); }
else if (previewKind(name)) previewFile(repo, path, name); else if (previewKind(name)) previewFile(repo, path, name);
else fileSheet(repo, path, name, dir); else fileSheet(repo, path, name, dir);
} }
@@ -919,7 +997,7 @@ function moveItem(repo, name, dir) {
function fileSheet(repo, path, name, dir) { function fileSheet(repo, path, name, dir) {
const enc = encodeURIComponent, acts = []; const enc = encodeURIComponent, acts = [];
if (previewKind(name)) acts.push({ label: 'Preview', icon: 'doc', run: () => previewFile(repo, path, name) }); if (previewKind(name)) acts.push({ label: 'Preview', icon: 'doc', run: () => previewFile(repo, path, name) });
if (cfg.editor && EDIT_EXT.has(ext(name))) acts.push({ label: 'Edit here', icon: 'edit', run: () => { addRecent(repo, path, name); location.hash = '#/edit/' + enc(repo) + path.split('/').map(s => '/' + enc(s)).join(''); } }); if (cfg.editor && EDIT_EXT.has(ext(name))) acts.push({ label: 'Edit here', icon: 'edit', run: () => { addRecent(repo, path, name); location.hash = '#/edit/' + enc(repo) + path.split('/').filter(Boolean).map(s => '/' + enc(s)).join(''); } });
if (OFFICE_EXT.has(ext(name))) acts.push({ label: 'Open in Seafile', icon: 'doc', run: () => window.open(`${cfg.seafile}/lib/${enc(repo)}/file${path.split('/').map(enc).join('/')}`, '_blank', 'noopener') }); if (OFFICE_EXT.has(ext(name))) acts.push({ label: 'Open in Seafile', icon: 'doc', run: () => window.open(`${cfg.seafile}/lib/${enc(repo)}/file${path.split('/').map(enc).join('/')}`, '_blank', 'noopener') });
acts.push({ label: 'Share link', icon: 'copy', run: () => shareLink(repo, path, false) }); acts.push({ label: 'Share link', icon: 'copy', run: () => shareLink(repo, path, false) });
acts.push({ label: 'Rename', icon: 'edit', run: () => renameItem(repo, path, name, dir, false) }); acts.push({ label: 'Rename', icon: 'edit', run: () => renameItem(repo, path, name, dir, false) });
+3
View File
@@ -191,3 +191,6 @@ input[type=search] { width: 100%; min-height: 44px; padding: 0 14px; border-radi
.row .art { object-fit: cover; flex: none; background: var(--panel2); } .row .art { object-fit: cover; flex: none; background: var(--panel2); }
.dot { width: 12px; height: 12px; border-radius: 50%; flex: none; } .dot { width: 12px; height: 12px; border-radius: 50%; flex: none; }
/* Editor inside the Office tab: keep the nav, drop the page padding so the editor fills the space */
body.inedit #view { padding: 0; display: flex; flex-direction: column; }