/*
ArozOS Office Suite - shared application framework
====================================================
Shared by Docs, Sheets and Slides. Provides:
- Document lifecycle: New / Open / Save / Save As in the app's own
Office format (.docx / .xlsx / .pptx), foreign formats (ODF, text),
the JSON envelope the editors hold, file version metadata
- Auto-save, crash-recovery drafts (localStorage), recent documents
- Menubar + statusbar chrome, dialogs, toasts, context menus
- Keyboard shortcut registry, undo/redo bindings
- Light/dark theme (shared across the suite), zoom controls, print
Requires (include before this file):
../../script/jquery.min.js
../../script/ao_module.js
../common/mode.js
../common/recents.js
../common/wasm.js
../common/platform.js
../common/office.css
Everything that reaches outside the browser tab - file dialogs, reading
and writing documents, session snapshots, AGI calls - goes through
OfficePlatform (common/platform.js), which is what
lets the same source run both as an ArozOS webapp and as the standalone
static-hosting build.
Usage: see Office/common/CONTRACT.md for the full API reference.
*/
/* ================= Undo stack ================= */
/*
Generic snapshot-based undo/redo helper.
var undo = new OfficeUndoStack({
limit: 100,
apply: function(state){ ...restore editor from state... }
});
undo.init(initialState);
undo.push(state); // record a new state (call after a change)
undo.pushDebounced(fn, ms); // coalesce rapid changes; fn() returns state
undo.undo(); undo.redo(); // calls apply() with the target state
undo.canUndo(); undo.canRedo();
undo.reset(initialState);
*/
function OfficeUndoStack(opts) {
opts = opts || {};
this.limit = opts.limit || 100;
this.applyFn = opts.apply || function () {};
this.stack = [];
this.pos = -1;
this._debTimer = null;
}
OfficeUndoStack.prototype.init = function (state) {
this.stack = [state];
this.pos = 0;
};
OfficeUndoStack.prototype.reset = OfficeUndoStack.prototype.init;
OfficeUndoStack.prototype.push = function (state) {
if (this.pos >= 0) {
var cur = this.stack[this.pos];
if (typeof cur === "string" && typeof state === "string" && cur === state) return;
try {
if (typeof cur === "object" && JSON.stringify(cur) === JSON.stringify(state)) return;
} catch (e) { /* non-serializable state, push anyway */ }
}
this.stack = this.stack.slice(0, this.pos + 1);
this.stack.push(state);
if (this.stack.length > this.limit) this.stack.shift();
this.pos = this.stack.length - 1;
};
OfficeUndoStack.prototype.pushDebounced = function (stateFn, ms) {
var that = this;
clearTimeout(this._debTimer);
this._debTimer = setTimeout(function () {
that.push(stateFn());
}, ms || 400);
};
OfficeUndoStack.prototype.flushDebounced = function (stateFn) {
if (this._debTimer) {
clearTimeout(this._debTimer);
this._debTimer = null;
this.push(stateFn());
}
};
OfficeUndoStack.prototype.canUndo = function () { return this.pos > 0; };
OfficeUndoStack.prototype.canRedo = function () { return this.pos < this.stack.length - 1; };
OfficeUndoStack.prototype.undo = function () {
if (!this.canUndo()) return false;
this.pos--;
this.applyFn(this.stack[this.pos]);
return true;
};
OfficeUndoStack.prototype.redo = function () {
if (!this.canRedo()) return false;
this.pos++;
this.applyFn(this.stack[this.pos]);
return true;
};
/* ================= OfficeApp ================= */
var OfficeApp = (function () {
var cfg = null;
var filepath = null; // virtual path of the open file, null = unsaved
var filename = null; // display name (with extension)
var dirty = false;
var meta = null; // envelope meta of the open document
var zoom = 100;
var shortcuts = {}; // normalized combo -> handler
var statusTimer = null;
var draftTimer = null;
var autosaveTimer = null;
var loadedFromImport = false;
// the file a read-only import came from (a .doc opened in Docs): the
// title shows it until the document is saved somewhere of its own,
// while filename holds the name Save suggests in the app's format
var sourceName = null;
// saving is asynchronous (the app prepares the document, then it
// travels): one write at a time, and an edit made while it is under way
// keeps the document dirty
var editSeq = 0; // bumped by every markDirty
var saveBusy = false;
var queuedSave = null; // {fp, fn, cb} asked for while a save was running
var ENVELOPE_TYPE = "arozos/office";
var GENERATOR = "ArozOS Office/1.0";
var DRAFT_MAX_AGE = 7 * 24 * 3600 * 1000; // prune drafts older than 7 days
var DRAFT_MAX_SIZE = 4 * 1024 * 1024; // skip drafts over 4 MB
var AUTOSAVE_INTERVAL = 25 * 1000;
var RECENT_MAX = 10;
/* ---------- small utils ---------- */
function escapeHtml(t) {
if (t === undefined || t === null) return "";
return String(t).replace(/&/g, "&").replace(//g, ">")
.replace(/"/g, """).replace(/'/g, "'");
}
function basename(p) {
if (!p) return "";
var s = String(p).replace(/\\/g, "/");
return s.substring(s.lastIndexOf("/") + 1);
}
function dirname(p) {
if (!p) return "";
var s = String(p).replace(/\\/g, "/");
return s.substring(0, s.lastIndexOf("/"));
}
function extOf(p) {
var b = basename(p);
var i = b.lastIndexOf(".");
return i < 0 ? "" : b.substring(i).toLowerCase();
}
function stripExt(p) {
var i = String(p).lastIndexOf(".");
return i < 0 ? String(p) : String(p).substring(0, i);
}
function now() { return new Date().getTime(); }
// "meeting-notes" -> "Meeting Notes": template file names become the
// suggested document name, so they should read like one
function titleCase(s) {
return String(s || "").replace(/[-_]+/g, " ").replace(/\s+/g, " ").trim()
.replace(/\b[a-z]/g, function (c) { return c.toUpperCase(); });
}
function lsKey(k) { return "office_" + (cfg ? cfg.appType : "generic") + "_" + k; }
function getSetting(k, def) {
try {
var v = localStorage.getItem(lsKey(k));
return v === null ? def : JSON.parse(v);
} catch (e) { return def; }
}
function setSetting(k, v) {
try { localStorage.setItem(lsKey(k), JSON.stringify(v)); } catch (e) { }
}
/* ---------- host bridge (see common/platform.js) ----------
These stay on OfficeApp because the whole suite already calls them
through it; the implementation - ArozOS virtual file system and AGI
gateway, or the visitor's own device - lives in OfficePlatform. */
function vfsLoad(path, cb, errcb) { OfficePlatform.readText(path, cb, errcb); }
function vfsSave(path, content, cb, errcb) { OfficePlatform.writeText(path, content, cb, errcb); }
function mediaUrl(vpath) { return OfficePlatform.mediaUrl(vpath); }
function blobToSrc(blob, filename, cb, errcb) {
OfficePlatform.blobToSrc(blob, filename, cb, errcb);
}
function agirunLarge(script, params, field, cb, errcb, timeout) {
OfficePlatform.agirunLarge(script, params, field, cb, errcb, timeout);
}
/* ---------- session snapshots ("Restore from previous session") ---------- */
function saveSession() {
if (!cfg) return;
var env;
try { env = buildEnvelopeNoBump(); } catch (e) { return; }
// annotate a COPY of meta - session bookkeeping must not leak into
// the real document file
var m = {};
Object.keys(env.meta || {}).forEach(function (k) { m[k] = env.meta[k]; });
m._sessionAt = now();
m._origin = filepath ? { fp: filepath, fn: filename } : null;
env.meta = m;
OfficePlatform.sessionSave(cfg.appType, JSON.stringify(env));
}
// drop the saved session snapshot so it stops prompting on next launch
function deleteSession() {
if (!cfg) return;
OfficePlatform.sessionDelete(cfg.appType);
}
function trySessionRestore() {
OfficePlatform.sessionLoad(cfg.appType, function (envelope) {
var env = envelope;
if (typeof env === "string") {
try { env = JSON.parse(env); } catch (e) { env = null; }
}
if (!env || env.type !== ENVELOPE_TYPE || env.app !== cfg.appType || !env.body) {
checkDraft();
return;
}
var m = env.meta || {};
var when = m._sessionAt ? new Date(m._sessionAt).toLocaleString() : "an earlier session";
var what = m._origin && m._origin.fn ? escapeHtml(m._origin.fn) : "an unsaved document";
var restore = function () {
var origin = m._origin;
delete m._sessionAt;
delete m._origin;
meta = m;
sourceName = null;
if (origin && origin.fp) {
filepath = origin.fp;
filename = origin.fn;
}
cfg.deserialize(env.body);
markDirty();
updateTitle();
setStatus("Previous session restored - remember to save");
};
dialog({
title: "Restore from previous session?",
body: "You were working on " + what + " (" + escapeHtml(when) + ").
" +
'Start fresh deletes the saved snapshot.',
dismissable: true,
buttons: [
// keeps the snapshot: dismiss now, may be offered next launch
//{ label: "Start fresh", action: function (close) { close(); checkDraft(); } },
// deletes the snapshot so it stops prompting
{
label: "Start fresh", danger: true,
action: function (close) { close(); deleteSession(); checkDraft(); }
},
{
label: "Restore", primary: true,
action: function (close) { close(); restore(); }
}
]
});
});
}
/* ---------- envelope ---------- */
function buildEnvelope() {
var m = meta || {};
m.title = stripExt(filename || cfg.defaultFileName);
m.createdAt = m.createdAt || now();
m.modifiedAt = now();
m.revision = (m.revision || 0) + 1;
m.generator = GENERATOR;
meta = m;
return {
type: ENVELOPE_TYPE,
app: cfg.appType,
version: 1,
meta: m,
body: cfg.serialize()
};
}
function parseEnvelope(text) {
var obj = JSON.parse(text);
if (!obj || obj.type !== ENVELOPE_TYPE || obj.app !== cfg.appType) {
throw new Error("Not a valid " + cfg.fileTypeName + " file");
}
return obj;
}
/* ---------- title / status ---------- */
function updateTitle() {
var name = openingName || (!filepath && sourceName) || filename || (cfg.defaultFileName + cfg.extension);
var t = name + (dirty ? " •" : "") + " - " + cfg.appName;
OfficePlatform.setWindowTitle(t);
var $dn = $(".of-docname");
$dn.html((dirty ? '• ' : "") + escapeHtml(name));
// the document's identity only ever changes alongside its title
updateForeignBanner();
}
/* ---------- start-up splash (common/splash.js, when the app has one) ----------
While it shows, status messages and busy messages are what it says;
it goes away when the document is on screen (documentLoaded), when
something needs the person (a dialog), or when something failed. */
function splashActive() {
return !!(window.OfficeSplash && OfficeSplash.active());
}
function splashDone() {
if (!splashActive()) return;
OfficeSplash.done();
showFromTop();
}
/* The document comes out from behind the splash at its beginning. The
window grows and the layout settles for a moment after that (a float
window resizing, pictures and fonts arriving), and any of it can
leave the view scrolled part-way down the first page - so the top is
asked for again until it has settled, unless the person has started
scrolling themselves. */
function showFromTop() {
var $ws = $(".of-workspace");
if (!$ws.length) return;
var touched = false;
var mark = function () { touched = true; };
$ws.one("wheel touchstart keydown mousedown", mark);
var top = function () {
if (touched) return;
$ws.scrollTop(0).scrollLeft(0);
};
top();
requestAnimationFrame(top);
[120, 350, 800].forEach(function (ms) { setTimeout(top, ms); });
setTimeout(function () { $ws.off("wheel touchstart keydown mousedown", mark); }, 900);
}
// the document an app was asked to open is on screen
function documentLoaded() {
splashDone();
}
// say msg on the splash and let it paint before fn does synchronous work
// (laying out a long document); without a splash fn just runs
function splashStep(msg, fn) {
if (!splashActive()) { fn(); return; }
OfficeSplash.status(msg);
// a frame to paint it in - or a moment, where frames do not come (a
// tab or window in the background)
var ran = false;
var go = function () { if (!ran) { ran = true; fn(); } };
requestAnimationFrame(function () { setTimeout(go, 0); });
setTimeout(go, 150);
}
function setStatus(msg, type, timeout) {
if (splashActive()) {
if (type === "error") splashDone();
else OfficeSplash.status(msg);
}
var $m = $(".of-status-msg");
$m.text(msg || "").removeClass("error");
if (type === "error") $m.addClass("error");
clearTimeout(statusTimer);
if (timeout !== 0) {
statusTimer = setTimeout(function () { $m.text(""); }, timeout || 5000);
}
}
function addStatusItem(id, html) {
var $it = $('').html(html);
$(".of-status-items").append($it);
return $it;
}
function updateStatusItem(id, html) {
$('.of-status-item[data-sid="' + id + '"]').html(html);
}
/* ---------- dirty / drafts ---------- */
function draftKey() {
return "officeDraft_" + cfg.appType + "_" + (filepath || "untitled");
}
function saveDraft() {
try {
var env = buildEnvelopeNoBump();
var s = JSON.stringify({ t: now(), fp: filepath, fn: filename, env: env });
if (s.length > DRAFT_MAX_SIZE) return;
localStorage.setItem(draftKey(), s);
} catch (e) { }
}
function buildEnvelopeNoBump() {
// snapshot without touching revision/modified metadata
return {
type: ENVELOPE_TYPE, app: cfg.appType, version: 1,
meta: meta || {}, body: cfg.serialize()
};
}
function clearDraft() {
try { localStorage.removeItem(draftKey()); } catch (e) { }
}
function pruneDrafts() {
try {
var kill = [];
for (var i = 0; i < localStorage.length; i++) {
var k = localStorage.key(i);
if (k && k.indexOf("officeDraft_") === 0) {
try {
var d = JSON.parse(localStorage.getItem(k));
if (!d.t || now() - d.t > DRAFT_MAX_AGE) kill.push(k);
} catch (e) { kill.push(k); }
}
}
kill.forEach(function (k) { localStorage.removeItem(k); });
} catch (e) { }
}
function checkDraft() {
var raw = null;
try { raw = localStorage.getItem(draftKey()); } catch (e) { }
if (!raw) return;
var d;
try { d = JSON.parse(raw); } catch (e) { clearDraft(); return; }
if (!d || !d.env || !d.env.body) { clearDraft(); return; }
var when = new Date(d.t).toLocaleString();
confirmDialog("Recover unsaved changes?",
"A recovered version of this document from " + escapeHtml(when) +
" was found. It may contain changes that were never saved.",
"Restore", "Discard",
function (restore) {
if (restore) {
meta = d.env.meta || meta;
cfg.deserialize(d.env.body);
markDirty();
setStatus("Recovered draft restored - remember to save");
} else {
clearDraft();
}
});
}
function markDirty() {
editSeq++;
if (!dirty) { dirty = true; updateTitle(); }
clearTimeout(draftTimer);
draftTimer = setTimeout(saveDraft, 2500);
if (cfg) refreshQat();
}
function markClean() {
dirty = false;
clearTimeout(draftTimer);
clearDraft();
updateTitle();
if (cfg) refreshQat();
}
/* ---------- recent documents ---------- */
/* A recent entry is only useful where the path survives the page: the
standalone build opens File objects the visitor picked, which are gone
on reload, so it keeps no list rather than offering dead entries. */
function getRecents() {
if (!OfficePlatform.tracksRecents()) return [];
return getSetting("recent", []);
}
function addRecent(fp, fn) {
if (!OfficePlatform.tracksRecents()) return;
var list = getRecents().filter(function (r) { return r.fp !== fp; });
list.unshift({ fp: fp, fn: fn, t: now() });
setSetting("recent", list.slice(0, RECENT_MAX));
}
/* ---------- document lifecycle ---------- */
/*
Foreign binary formats (.docx / .xlsx / .pptx / ODF) go through the Go
converters - server side in ArozOS, in the WebAssembly module in a
standalone build made with them. Reading the list through here rather
than off cfg keeps one decision in one place: with no converters they
are neither offered in the Open dialog's filter nor accepted by
openPath.
*/
function binaryImporters() {
if (!OfficePlatform.canConvert()) return {};
return cfg.binaryImporters || {};
}
/*
The legacy binary format of the app's kind (cfg.legacyExtensions:
.doc for Docs, .xls for Sheets, .ppt for Slides). Such a file opens
through the platform's documentLoad like the app's own format - the
Go converters turn it into OOXML first, server side in ArozOS, in the
WebAssembly module in a standalone build - but it is an import: the
document is not attached to the old file, and Save asks where to put
it in the app's own format. Like binaryImporters, it needs the
converters.
*/
function legacyExtensions() {
if (!OfficePlatform.canConvert()) return [];
return cfg.legacyExtensions || [];
}
function isLegacyExt(ext) { return legacyExtensions().indexOf(ext) >= 0; }
function loadLegacyEnvelope(env, fp, fn) {
if (!env || env.type !== ENVELOPE_TYPE || env.app !== cfg.appType) {
throw new Error("Not a valid " + cfg.fileTypeName + " file");
}
meta = { createdAt: now(), revision: 0 };
adoptImportedPath(fp, fn);
cfg.deserialize(env.body);
markClean();
// the old file stays in "recent": opening it again imports it again
addRecent(fp, fn);
setStatus(importedStatus(fn));
documentLoaded();
}
function loadNativeEnvelope(env, fp, fn, opts) {
if (!env || env.type !== ENVELOPE_TYPE || env.app !== cfg.appType) {
throw new Error("Not a valid " + cfg.fileTypeName + " file");
}
meta = env.meta || {};
loadedFromImport = false;
openingName = null;
sourceName = null;
/*
A template is a starting point, not a file the person is editing:
the content loads but the document stays unattached, so Ctrl+S
asks where to put it instead of writing back over the template.
The name is kept as a suggestion for that dialog.
*/
if (opts && opts.asTemplate) {
var pretty = titleCase(stripExt(fn));
filepath = null;
filename = pretty + cfg.extension;
meta = { createdAt: now(), revision: 0 };
cfg.deserialize(env.body);
markClean();
setStatus("New " + cfg.fileTypeName.toLowerCase() +
" from the " + pretty + " template");
documentLoaded();
return;
}
filepath = fp; filename = fn;
cfg.deserialize(env.body);
markClean();
addRecent(fp, fn);
setStatus("Opened " + fn);
documentLoaded();
checkDraft();
}
function loadNativeText(text, fp, fn, opts) {
loadNativeEnvelope(parseEnvelope(text), fp, fn, opts);
}
/*
A file opened from a foreign format stays attached to that file when
the app declared a writer for its extension (cfg.saveFormats): Save
then writes back in the original format, and only steps up to the
app's own format once the document outgrows it (see doSaveForeign).
With no writer the import is read-only and Save has to become Save As
- which is what every app did before saveFormats existed.
*/
function adoptImportedPath(fp, fn) {
loadedFromImport = true;
openingName = null;
sourceName = null;
if (saveFormatFor(extOf(fn))) {
filepath = fp;
filename = fn;
} else {
filepath = null; // read-only import: force Save As on save
filename = stripExt(fn) + cfg.extension;
sourceName = fn;
}
}
function importedStatus(fn) {
return filepath ? ("Opened " + fn) :
("Imported " + fn + " - use Save to store it as " + cfg.extension);
}
function loadImportText(text, fp, fn) {
var ext = extOf(fn);
var importer = (cfg.importers || {})[ext];
if (!importer) { setStatus("Unsupported file type " + ext, "error"); return; }
meta = { createdAt: now(), revision: 0 };
adoptImportedPath(fp, fn);
importer(text, fn);
dirty = false; updateTitle();
if (filepath) addRecent(filepath, filename);
setStatus(importedStatus(fn));
documentLoaded();
}
var openingName = null; // the file being opened, until it is in
/* The window is titled after the file from the moment it starts to
open - a large document can take a while, and "New Document" in the
title meanwhile says the wrong thing. A failed open goes back. */
function showOpening(fn, opts) {
openingName = (opts && opts.asTemplate) ? titleCase(stripExt(fn)) + cfg.extension : fn;
updateTitle();
}
function openFailed() {
openingName = null;
updateTitle();
}
function openPath(fp, fn, opts) {
fn = fn || basename(fp);
var ext = extOf(fn);
showOpening(fn, opts);
// a template (templates/*.json) is a plain envelope, not an Office
// file: it is read as text in both hosts and needs no conversion
if (opts && opts.asTemplate && ext === ".json") {
setStatus("Opening " + fn + "...", "info", 0);
vfsLoad(fp, function (text) {
splashStep("Preparing " + fn + "...", function () {
try {
loadNativeText(text, fp, fn, opts);
} catch (err) {
openFailed();
setStatus("Cannot open " + fn + ": " + err.message, "error");
}
});
}, function () {
openFailed();
setStatus("Failed to load " + fn, "error");
});
return;
}
// binary foreign formats (e.g. .odt) are handled by the app itself,
// usually through a server-side AGI conversion - no text fetch here
var bi = binaryImporters()[ext];
if (bi) {
meta = { createdAt: now(), revision: 0 };
adoptImportedPath(fp, fn);
dirty = false;
updateTitle();
if (filepath) addRecent(filepath, filename);
bi(fp, fn);
return;
}
if (isLegacyExt(ext)) {
setStatus("Converting " + fn + "...", "info", 0);
OfficePlatform.documentLoad(fp, function (envelope) {
splashStep("Preparing " + fn + "...", function () {
var env = envelope;
if (typeof env === "string") {
try { env = JSON.parse(env); } catch (e) { env = null; }
}
try {
loadLegacyEnvelope(env, fp, fn);
} catch (err) {
openFailed();
setStatus("Cannot open " + fn + ": " + err.message, "error");
}
});
}, function (msg) {
openFailed();
setStatus("Failed to open " + fn + ": " + (msg || "unknown error"), "error");
});
return;
}
setStatus("Opening " + fn + "...", "info", 0);
/* The app's own format: the platform turns the .docx / .xlsx / .pptx
into the envelope - the editor copy the suite embedded when it
saved the file, or an import of the OOXML when another program
wrote it (server side in ArozOS, WebAssembly in the web edition). */
if (ext === cfg.extension) {
OfficePlatform.documentLoad(fp, function (envelope) {
splashStep("Preparing " + fn + "...", function () {
var env = envelope;
if (typeof env === "string") {
try { env = JSON.parse(env); } catch (e) { env = null; }
}
try {
loadNativeEnvelope(env, fp, fn, opts);
} catch (err) {
openFailed();
setStatus("Cannot open " + fn + ": " + err.message, "error");
}
});
}, function (msg) {
openFailed();
setStatus("Failed to open " + fn + ": " + (msg || "unknown error"), "error");
});
return;
}
vfsLoad(fp, function (text) {
splashStep("Preparing " + fn + "...", function () {
try {
loadImportText(text, fp, fn);
} catch (err) {
openFailed();
setStatus("Cannot open " + fn + ": " + err.message, "error");
}
});
}, function () {
openFailed();
setStatus("Failed to load " + fn, "error");
});
}
/*
A host that keeps each document in a window of its own (the desktop
shell: OfficePlatform.prefersNewWindows) opens another document
beside this one once this window holds something - a file, or
unsaved work - the way a desktop app does. An empty untitled window
is simply reused. Elsewhere the document replaces this one.
*/
function holdsDocument() { return !!filepath || dirty; }
function openElsewhere(fp, fn) {
if (!OfficePlatform.prefersNewWindows() || !holdsDocument()) return false;
return OfficePlatform.openDocument(fp, fn, { appIcon: cfg.appIcon });
}
function newDocument() {
if (OfficePlatform.prefersNewWindows() && holdsDocument() &&
OfficePlatform.openNewWindow()) {
return;
}
var go = function () {
filepath = null;
filename = null;
sourceName = null;
meta = { createdAt: now(), revision: 0 };
loadedFromImport = false;
cfg.create();
markClean();
setStatus("New " + cfg.fileTypeName.toLowerCase() + " created");
};
if (dirty) {
confirmDialog("Discard unsaved changes?",
"The current document has unsaved changes that will be lost.",
"Discard", "Cancel", function (yes) { if (yes) go(); });
} else { go(); }
}
function openDialog() {
var go = function () {
var filter = [cfg.extension.substring(1)];
(Object.keys(cfg.importers || {})).forEach(function (e) {
filter.push(e.substring(1));
});
(Object.keys(binaryImporters())).forEach(function (e) {
filter.push(e.substring(1));
});
legacyExtensions().forEach(function (e) {
filter.push(e.substring(1));
});
OfficePlatform.pickOpen({ filter: filter, memoryKey: "document" }, function (files) {
if (openElsewhere(files[0].filepath, files[0].filename)) return;
openPath(files[0].filepath, files[0].filename);
});
};
// nothing is discarded when the document goes to a window of its own
if (dirty && !OfficePlatform.prefersNewWindows()) {
confirmDialog("Discard unsaved changes?",
"The current document has unsaved changes that will be lost.",
"Discard", "Cancel", function (yes) { if (yes) go(); });
} else { go(); }
}
/* ---------- foreign save formats (cfg.saveFormats) ---------- */
/*
An app may declare formats it can write besides its own (.docx / .xlsx / .pptx):
saveFormats: [{
ext: ".csv", label: "CSV (.csv)", icon: "file alternate outline",
oneWay: true, // a rendering (PDF):
// never becomes the file
// the document lives in
unsupported: fn -> null | [reason, ...],
save: fn(fp, fn, done, fail(msg))
}]
They drive both Save As (a format picker) and Save (a document opened
from one of these formats writes straight back to it). Apps that
declare none keep the native-format-only behaviour unchanged.
*/
/*
Two flags mark a writer that cannot exist everywhere:
needsConvert goes through the Office format converters (.xlsx,
.ods, ...) - present in ArozOS, and in a standalone
build shipped with the WebAssembly module
needsBackend needs a server outright (the real-text PDF renderer)
Filtering here rather than in each app keeps Save As, the
save-back-into-a-foreign-format path and autosave consistent.
*/
function saveFormats() {
var list = (cfg && cfg.saveFormats) || [];
return list.filter(function (f) {
if (f.needsBackend && !OfficePlatform.hasBackend()) return false;
if (f.needsConvert && !OfficePlatform.canConvert()) return false;
return true;
});
}
function findSaveFormat(ext, adoptableOnly) {
var list = saveFormats();
for (var i = 0; i < list.length; i++) {
if (list[i].ext !== ext) continue;
return (adoptableOnly && list[i].oneWay) ? null : list[i];
}
return null;
}
// a writer whose output the document can go on living in (excludes PDF)
function saveFormatFor(ext) { return findSaveFormat(ext, true); }
function formatLabel(fmt) { return (fmt && (fmt.label || fmt.ext)) || nativeLabel(); }
// "Word document (.docx)" - what the app's own format is called
function nativeLabel() {
return cfg.nativeLabel || (cfg.fileTypeName + " (" + cfg.extension + ")");
}
function formatReasons(fmt) {
if (!fmt || !fmt.unsupported) return null;
var r;
try { r = fmt.unsupported(); } catch (e) { return null; }
return (r && r.length) ? r : null;
}
/*
A foreign format holds less than the app's own format does, so it may
refuse a document outright instead of silently dropping content. The
reasons come from the app as plain strings - escaped here, never
trusted as markup.
*/
function formatBlockedDialog(fmt, reasons) {
var label = formatLabel(fmt);
var list = reasons.map(function (t) {
return "
This " + escapeHtml(cfg.fileTypeName.toLowerCase()) + " uses features that " + escapeHtml(label) + " cannot store:
" + "Save it as " + escapeHtml(cfg.extension) + " to keep them, or use File > Export for a lossy copy.
", buttons: [ { label: "Cancel" }, { label: "Save as " + cfg.extension + "...", primary: true, action: function (close) { close(); saveAsFormat(null); } } ] }); } function doSaveForeign(fp, fn, fmt, cb, silent, obs) { var reasons = formatReasons(fmt); if (reasons) { // autosave must never interrupt with a dialog: it skips the write // and lets the session snapshot be the safety net instead if (!silent) formatBlockedDialog(fmt, reasons); return false; } if (cfg.onBeforeSave) { try { cfg.onBeforeSave(); } catch (e) { } } setStatus("Saving...", "info", 0); if (obs) obs.progress("server"); var seq = editSeq; saveBusy = true; fmt.save(fp, fn, function () { setStatus("Saved " + fn); // a one-way rendering is a copy, not the document's own file - the // editor keeps its path and stays dirty if (!fmt.oneWay) { filepath = fp; filename = fn; loadedFromImport = false; savedClean(seq); addRecent(fp, fn); saveSession(); } finishSave(); if (cb) cb(); }, function (err) { setStatus("Save failed: " + err, "error"); finishSave(); if (obs) obs.fail(err); }); return true; } // the file on disk has what the document held when the save began: it is // clean only if nobody edited since function savedClean(seq) { if (seq === editSeq) markClean(); else updateTitle(); } function finishSave() { saveBusy = false; if (queuedSave) { var q = queuedSave; queuedSave = null; doSaveTo(q.fp, q.fn, q.cb, false, q.obs); } } /* obs, when given, follows the write: obs.progress(stage, percent) - "prepare", "compress", "upload", "server" - and obs.fail(message) when it does not happen (cb only ever reports success) */ function save(cb, obs) { if (!filepath) { saveAs(cb, obs); return; } if (!doSaveTo(filepath, filename, cb, false, obs) && obs) obs.fail(null); } function saveAs(cb, obs) { saveAsFormat(null, cb, obs); } /* `fmt` is a cfg.saveFormats entry, or null for the native format. The format fixes the extension, so the picker only supplies the name. */ function saveAsFormat(fmt, cb, obs) { var ext = fmt ? fmt.ext : cfg.extension; // ask before making the user pick a filename, not after var reasons = formatReasons(fmt); if (reasons) { formatBlockedDialog(fmt, reasons); return; } var defName = stripExt(filename || cfg.defaultFileName) + ext; OfficePlatform.pickSave({ defaultName: defName, ext: ext, memoryKey: "document", //A document that has never been saved has no folder of its own, so start //from wherever this app was last used rather than the hardcoded Desktop forceOverwrite: !filepath }, function (file) { if (!doSaveTo(file.filepath, file.filename, cb, false, obs) && obs) obs.fail(null); }); } /* Write the document into the app's own Office format at fp. Shared by Save / Save As and by the convert-to-native action, which writes a copy without moving the open document onto it - so this deliberately touches nothing but the file. Returns false when the envelope could not even be built. cfg.prepareNative(body) gets a private copy of the body to finish for the OOXML writer - render a chart to PNG, turn an SVG into something Word can hold - and may return a Promise. What it leaves in the copy is both what the .docx / .xlsx / .pptx shows and the editor copy embedded in it, so a preparation must only add, never take away. */ function writeNative(fp, done, fail, progress) { var env; try { env = buildEnvelope(); env.body = JSON.parse(JSON.stringify(env.body)); } catch (e) { fail(e.message); return false; } var prep; try { prep = cfg.prepareNative ? cfg.prepareNative(env.body) : null; } catch (e) { fail(e.message || "could not prepare the document"); return true; } if (progress) progress("prepare"); Promise.resolve(prep).then(function () { OfficePlatform.documentSave(fp, JSON.stringify(env), done, fail, progress); }, function (err) { fail((err && err.message) ? err.message : "could not prepare the document"); }); return true; } // returns false when the write was declined (format cannot hold the doc) function doSaveTo(fp, fn, cb, silent, obs) { if (saveBusy) { // autosave simply skips a tick; an explicit save runs next if (silent) return true; queuedSave = { fp: fp, fn: fn, cb: cb, obs: obs }; if (obs) obs.progress("wait"); setStatus("Saving...", "info", 0); return true; } var ext = extOf(fn); if (ext !== cfg.extension) { // one of the app's own foreign formats - including a document // opened from one and saved straight back with Ctrl+S var fmt = findSaveFormat(ext, false); if (fmt) { // a writer too expensive to run on a timer (rasterizing // charts, seeking videos for a poster frame, refetching every // image) stays out of autosave; the caller falls back to the // session snapshot, which is what that safety net is for if (silent && fmt.noAutosave) return false; return doSaveForeign(fp, fn, fmt, cb, silent, obs); } } if (cfg.onBeforeSave) { try { cfg.onBeforeSave(); } catch (e) { } } setStatus("Saving...", "info", 0); var seq = editSeq; saveBusy = true; var started = writeNative(fp, function () { filepath = fp; filename = fn; loadedFromImport = false; savedClean(seq); addRecent(fp, fn); setStatus("Saved " + fn); // the file now holds what the snapshot would: drop the snapshot // (one tiny request) rather than uploading the document a second // time, which doubled every save on a slow link and offered to // "restore" a document that was already saved deleteSession(); finishSave(); if (cb) cb(); }, function (err) { setStatus("Save failed: " + err, "error"); finishSave(); if (obs) obs.fail(err); }, obs ? obs.progress : null); if (!started) saveBusy = false; return started; } /* ---------- foreign-format banner ---------- */ /* A document opened from .docx / .pptx / .csv / ... goes on living in that file: Save rewrites it in its own format rather than quietly turning it into the app's own format (see doSaveTo). That is the right default - somebody who opened a .docx wants a .docx back - but it also means every ArozOS Office feature the foreign format cannot hold is being dropped on each save, which is worth saying out loud once rather than only at the moment content is lost. So the editor carries a banner under the toolbar for as long as the open file is not native, offering the one-click way out: convert to the app's own format. Native documents never see any of this. */ var bannerDismissed = null; // filepath the banner was dismissed for // the format the open document currently lives in, or null when that is // the app's own format (or nothing on disk yet) function foreignFormat() { if (!filepath || !filename) return null; var ext = extOf(filename); if (!ext || ext === cfg.extension) return null; return { ext: ext, fmt: findSaveFormat(ext, true) }; } function buildForeignBanner() { var $b = $(''); $b.append(''); $b.append(''); $b.append($('') .html('Convert to ' + escapeHtml(cfg.extension)) .attr("title", "Save a copy as a " + nativeLabel() + " and open it") .on("click", convertToNative)); $b.append($('') .on("click", function () { bannerDismissed = filepath; $(".of-fmtbanner").hide(); })); return $b; } function updateForeignBanner() { var $b = $(".of-fmtbanner"); if (!$b.length) return; var f = foreignFormat(); if (!f || bannerDismissed === filepath) { $b.hide(); return; } var what = f.fmt ? formatLabel(f.fmt) : (f.ext + " file"); $b.find(".of-fmtbanner-msg").html( "This is a " + escapeHtml(what) + ", not a " + escapeHtml(nativeLabel()) + ". " + "Anything " + escapeHtml(f.ext) + " cannot store is lost on save."); $b.css("display", ""); } /* Write the document out in the app's own format and open that copy in a window of its own. The editor stays on the original foreign file: the converted document is a new file, and which of the two to go on working in is the person's call, not ours. */ function convertToNative() { var defName = stripExt(filename || cfg.defaultFileName) + cfg.extension; OfficePlatform.pickSave({ defaultName: defName, ext: cfg.extension, memoryKey: "document" }, function (file) { if (cfg.onBeforeSave) { try { cfg.onBeforeSave(); } catch (e) { } } setStatus("Converting to " + cfg.extension + "...", "info", 0); writeNative(file.filepath, function () { setStatus("Converted to " + file.filename); addRecent(file.filepath, file.filename); // the question has been answered for this file either way bannerDismissed = filepath; updateForeignBanner(); var opened = OfficePlatform.openDocument(file.filepath, file.filename, { appIcon: cfg.appIcon }); toast(opened ? ("Converted - " + file.filename + " opened in a new window") : ("Converted - saved as " + file.filename)); }, function (err) { setStatus("Convert failed: " + err, "error"); toast("Convert failed: " + err, "error"); }); }); } /* ---------- autosave ---------- */ function autosaveEnabled() { return getSetting("autosave", true); } function autosaveTick() { // a host with no file system to write back to (the standalone build // saves by downloading) must never autosave: the snapshot below is // the whole safety net there if (dirty && filepath && autosaveEnabled() && OfficePlatform.autosavesToFile()) { // a document living in a foreign format is autosaved only while // that format can still hold it; when it cannot, doSaveTo's silent // mode declines the write rather than interrupting with a dialog, // and the session snapshot below protects the work instead if (!doSaveTo(filepath, filename, null, true)) saveSession(); } else if (dirty) { // unsaved (or autosave-off) documents still get a session // snapshot so "Restore from previous session" can recover them saveSession(); } } /* ---------- theme ---------- */ function isDark() { try { return localStorage.getItem("office_theme") === "dark"; } catch (e) { return false; } } function applyTheme() { var dark = isDark(); $("body").toggleClass("dark", dark); OfficePlatform.setWindowTheme(dark); if (cfg && cfg.onThemeChanged) { try { cfg.onThemeChanged(dark); } catch (e) { } } } function toggleTheme() { try { localStorage.setItem("office_theme", isDark() ? "light" : "dark"); } catch (e) { } applyTheme(); refreshMenuChecks(); } /* ---------- zoom ---------- */ var ZOOM_LEVELS = [50, 65, 75, 90, 100, 110, 125, 150, 175, 200]; function applyZoom(transient) { if (cfg.zoomTarget) { $(cfg.zoomTarget).css("zoom", zoom / 100); } $(".of-zoom-val").text(zoom + "%"); // a transient zoom (a phone fitting the page to its width) is not // the person's choice and must not become the next session's zoom if (!transient) setSetting("zoom", zoom); if (cfg.onZoomChanged) { try { cfg.onZoomChanged(zoom); } catch (e) { } } } function setZoom(z, opts) { zoom = Math.max(25, Math.min(400, Math.round(z))); applyZoom(!!(opts && opts.transient)); } /* Ctrl/Cmd + wheel zooms the document, not the whole ArozOS desktop. The browser's own page zoom owns that gesture, so the listener has to be non-passive to be allowed to preventDefault it. Deltas are accumulated rather than stepped per event: one mouse notch is ~100, but a trackpad pinch arrives as a stream of small deltas (Chrome reports those as wheel events with ctrlKey set) and stepping on each one would rocket through the zoom levels. */ var WHEEL_ZOOM_STEP = 40; var wheelAccum = 0; function onWheelZoom(e) { if (!e.ctrlKey && !e.metaKey) return; e.preventDefault(); // reversing direction should respond at once rather than first // having to work off the leftover from the other direction if (wheelAccum !== 0 && (wheelAccum < 0) !== (e.deltaY < 0)) wheelAccum = 0; wheelAccum += e.deltaY; // at most one level per event, so a single wheel notch (~100) is one // step while a stream of small pinch deltas still adds up to one if (wheelAccum <= -WHEEL_ZOOM_STEP) { wheelAccum = 0; zoomStep(1); } else if (wheelAccum >= WHEEL_ZOOM_STEP) { wheelAccum = 0; zoomStep(-1); } } function zoomStep(dir) { var i; if (dir > 0) { for (i = 0; i < ZOOM_LEVELS.length; i++) { if (ZOOM_LEVELS[i] > zoom) { setZoom(ZOOM_LEVELS[i]); return; } } setZoom(zoom + 25); } else { for (i = ZOOM_LEVELS.length - 1; i >= 0; i--) { if (ZOOM_LEVELS[i] < zoom) { setZoom(ZOOM_LEVELS[i]); return; } } setZoom(Math.max(25, zoom - 10)); } } /* ---------- keyboard shortcuts ---------- */ function normalizeCombo(c) { var parts = String(c).split("+").map(function (p) { return p.trim(); }); var mods = { ctrl: false, alt: false, shift: false }; var key = ""; parts.forEach(function (p) { var l = p.toLowerCase(); if (l === "ctrl" || l === "cmd" || l === "meta") mods.ctrl = true; else if (l === "alt") mods.alt = true; else if (l === "shift") mods.shift = true; else key = l; }); return (mods.ctrl ? "ctrl+" : "") + (mods.alt ? "alt+" : "") + (mods.shift ? "shift+" : "") + key; } function comboFromEvent(e) { var key = e.key; if (!key) return ""; if (key === " ") key = "space"; key = key.toLowerCase(); if (key === "escape") key = "esc"; return ((e.ctrlKey || e.metaKey) ? "ctrl+" : "") + (e.altKey ? "alt+" : "") + (e.shiftKey ? "shift+" : "") + key; } // Registers through the shared OfficeHotkeys registry when loaded // (hotkeys.js); the local map only remains as a standalone fallback. // opts (optional) pass straight through: description/group make the // binding show up in the Ctrl+/ help dialog, when() gates it. function registerShortcut(combo, handler, opts) { if (window.OfficeHotkeys) { return OfficeHotkeys.register(combo, function (e) { closeAllMenus(); return handler(e); }, $.extend({ allowInInput: true, inDialogs: true, group: "General" }, opts || {})); } shortcuts[normalizeCombo(combo)] = handler; } function handleKeydown(e) { var combo = comboFromEvent(e); var h = shortcuts[combo]; if (h) { e.preventDefault(); e.stopPropagation(); closeAllMenus(); h(e); return; } if (combo === "esc") closeAllMenus(); } /* ---------- menus (menubar drops, context menus, floating submenus) ---------- */ /* Submenus are NOT nested in their parent menu element - parents may scroll (overflow-y) which would clip them. Instead every submenu is a body-level ".of-float-menu" tagged with its depth; opening a menu at depth N closes every float menu at depth >= N. */ function closeFloatMenus(minDepth) { minDepth = minDepth || 0; $(".of-float-menu").each(function () { if (($(this).data("depth") || 0) >= minDepth) $(this).remove(); }); if (minDepth === 0) $(".of-menu-item.of-has-sub").removeClass("open"); } function closeAllMenus() { $(".of-menu").removeClass("open"); closeFloatMenus(0); if (window.OfficeRibbon) OfficeRibbon.closePopups(); } function positionFloatMenu($m, x, y) { var w = $m.outerWidth(), h = $m.outerHeight(); if (x + w > window.innerWidth) x = Math.max(4, window.innerWidth - w - 4); if (y + h > window.innerHeight) y = Math.max(4, window.innerHeight - h - 4); $m.css({ left: x + "px", top: y + "px" }); } function openFloatSubmenu($it, sub, depth) { closeFloatMenus(depth); $it.closest(".of-menu-drop, .of-float-menu").find(".of-has-sub").removeClass("open"); $it.addClass("open"); var items = (typeof sub === "function") ? sub() : sub; var $sm = $('').data("depth", depth); renderMenuItems($sm, items, depth); if (!items || items.length === 0) { $sm.append(''); } $("body").append($sm); var r = $it[0].getBoundingClientRect(); var w = $sm.outerWidth(); var x = r.right + 2; if (x + w > window.innerWidth) x = Math.max(4, r.left - w - 2); positionFloatMenu($sm, x, r.top - 4); } /* With foreign writers declared, "Save as" becomes a format picker whose first entry is the app's own format; without them it stays the plain Save As command it has always been. */ function saveAsMenuItem() { var list = saveFormats(); if (!list.length) { return { label: "Save as...", icon: "copy outline", key: "Ctrl+Shift+S", action: function () { saveAs(); } }; } var sub = [ { label: nativeLabel(), icon: "save", key: "Ctrl+Shift+S", action: function () { saveAsFormat(null); } }, { sep: true } ]; list.forEach(function (f) { // hidden: a writer that only exists to save a document back into // the file it came from (a second extension for the same format) if (f.hidden) return; sub.push({ label: formatLabel(f), icon: f.icon || "file outline", action: function () { saveAsFormat(f); } }); }); return { label: "Save as", icon: "copy outline", sub: sub }; } function renderMenuItems($drop, items, depth) { depth = depth || 0; $drop.empty(); (items || []).forEach(function (it) { if (!it) return; if (it.sep) { $drop.append(''); return; } var $it = $(''); if (it.checked !== undefined) { var on = (typeof it.checked === "function") ? it.checked() : it.checked; $it.append('' + (on ? "✓" : "") + ""); } else if (it.icon) { $it.append(''); } else { $it.append(''); } // it.html: a picture of the choice (a line sample, a swatch) the // app builds itself - trusted markup, never user content; the // label stays as the tooltip if (it.html) { $it.append($('').html(it.html)) .attr("title", it.label || ""); } else { $it.append('' + escapeHtml(it.label) + ""); } if (it.key) $it.append('' + escapeHtml(it.key) + ""); if (typeof it.enabled === "function" && !it.enabled()) $it.addClass("disabled"); if (it.sub) { $it.addClass("of-has-sub"); var open = function () { openFloatSubmenu($it, it.sub, depth + 1); }; $it.on("mouseenter", open); $it.on("click", function (ev) { ev.stopPropagation(); open(); }); } else { // hovering a plain item closes any deeper submenu $it.on("mouseenter", function () { closeFloatMenus(depth + 1); }); if (it.action) { $it.on("click", function (ev) { ev.stopPropagation(); closeAllMenus(); it.action(); }); } } $drop.append($it); }); } /* The title bar, Office style: app icon, the quick access buttons (Save, Undo, Redo, the AutoSave switch), then the File menu and the ribbon's tabs (common/ribbon.js) - or, for an app that declares no ribbon, its classic menus - and the document name on the right. It keeps the .of-menubar class: the menu code keys off it. */ function refreshQat() { $(".of-qat .of-rb-switch").each(function () { var s = $(this).data("sync"); if (s) s(); }); } function buildMenubar(menus) { var $bar = $(''); $bar.append('" + msgHtml + "
", dismissable: false, buttons: [ { label: noLabel || "Cancel", action: function (close) { close(); cb(false); } }, { label: yesLabel || "OK", primary: true, action: function (close) { close(); cb(true); } } ] }); } function promptDialog(title, label, defVal, cb) { var $b = $("'); $b.find("input").val(defVal === undefined ? "" : defVal); var d = dialog({ title: title, body: $b, buttons: [ { label: "Cancel", action: function (close) { close(); cb(null); } }, { label: "OK", primary: true, action: function (close, $body) { var v = $body.find(".of-prompt-input").val(); close(); cb(v); } } ] }); var $in = d.body.find("input"); $in.trigger("focus").trigger("select"); $in.on("keydown", function (e) { if (e.key === "Enter") { var v = $in.val(); d.close(); cb(v); } }); return d; } function toast(msg, type, ms) { if (type === "error") splashDone(); var $h = $(".of-toast-holder"); if (!$h.length) { $h = $(''); $("body").append($h); } var $t = $('').text(msg); if (type === "error") $t.addClass("error"); $h.append($t); requestAnimationFrame(function () { $t.addClass("show"); }); setTimeout(function () { $t.removeClass("show"); setTimeout(function () { $t.remove(); }, 250); }, ms || 2600); } function showContextMenu(x, y, items) { closeFloatMenus(0); var $cm = $('').data("depth", 0); renderMenuItems($cm, items, 0); $("body").append($cm); positionFloatMenu($cm, x, y); return { close: function () { closeFloatMenus(0); } }; } function showBusy(msg) { hideBusy(); // the splash already covers the app and says what is happening if (splashActive()) { OfficeSplash.status(msg || "Working..."); return; } var $o = $(''); $o.find(".of-busy-msg").text(msg || "Working..."); $("body").append($o); } function hideBusy() { $(".of-busy-overlay").remove(); } /* beginDrag(e, {move(ev), end(ev, cancelled), cursor}) Call from a pointerdown to own the rest of the gesture. A transparent full-window layer goes over everything and takes pointer capture, so every move and the release reach the drag however fast the pointer travels - even when the dragged element is re-rendered (which drops a capture set on the element itself) or the cursor crosses iframes, charts or other widgets. The layer is removed on release, cancel, or when the window loses focus. Returns a function that ends the drag. */ function beginDrag(e, opts) { opts = opts || {}; var ov = document.createElement("div"); ov.className = "of-drag-overlay"; if (opts.cursor) ov.style.cursor = opts.cursor; document.body.appendChild(ov); var done = false; try { ov.setPointerCapture(e.pointerId); } catch (err) { } function onMove(ev) { ev.preventDefault(); if (opts.move) opts.move(ev); } function finish(ev, cancelled) { if (done) return; done = true; ov.removeEventListener("pointermove", onMove); ov.removeEventListener("pointerup", onUp); ov.removeEventListener("pointercancel", onCancel); window.removeEventListener("blur", onCancel); if (ov.parentNode) ov.parentNode.removeChild(ov); if (opts.end) opts.end(ev || null, !!cancelled); } function onUp(ev) { finish(ev, false); } function onCancel(ev) { finish(ev && ev.type === "pointercancel" ? ev : null, true); } ov.addEventListener("pointermove", onMove); ov.addEventListener("pointerup", onUp); ov.addEventListener("pointercancel", onCancel); window.addEventListener("blur", onCancel); return function () { finish(null, true); }; } /* showProgress is showBusy's opposite number: a small panel in the corner of the editing area for work that takes a while but has no reason to hold the document hostage. Exporting is the case it was written for - the deck is snapshotted when the export starts, so carrying on editing while it runs changes nothing about the file that comes out. var p = OfficeApp.showProgress({ title: "Exporting PDF", anchor: "#slCanvasArea" }); p.set(done, total, "Exporting 3 of 6 pages"); p.close(); anchor is the element to sit in the top-right of; the panel is positioned from its box rather than placed inside it, so it does not have to care how that element is laid out. */ function showProgress(o) { o = o || {}; var $p = $(''); $p.find(".of-pp-title").text(o.title || "Working..."); $("body").append($p); function place() { var $a = o.anchor ? $(o.anchor) : $(); var el = $a.length ? $a[0] : null; var r = el ? el.getBoundingClientRect() : null; if (r && r.width > 0) { $p.css({ top: (r.top + 12) + "px", right: (window.innerWidth - r.right + 12) + "px" }); } else { $p.css({ top: "64px", right: "16px" }); } } place(); $(window).on("resize.ofprogress", place); var handle = { set: function (done, total, msg) { var pct = total > 0 ? Math.max(0, Math.min(100, done * 100 / total)) : 0; $p.find(".of-pp-fill").css("width", pct + "%"); if (msg !== undefined) $p.find(".of-pp-msg").text(msg); place(); }, message: function (msg) { $p.find(".of-pp-msg").text(msg); }, close: function () { $(window).off("resize.ofprogress", place); $p.remove(); } }; return handle; } /* ---------- statusbar ---------- */ function buildStatusbar() { var $sb = $(''); $sb.append(''); $sb.append(''); var $z = $(''); var $minus = $(''); var $plus = $(''); var $val = $('100%'); $minus.on("click", function () { zoomStep(-1); }); $plus.on("click", function () { zoomStep(1); }); $val.on("click", function () { setZoom(100); }); $z.append($minus).append($val).append($plus); $sb.append($z); return $sb; } /* ---------- init ---------- */ function init(config) { cfg = config; if (!cfg.appType || !cfg.serialize || !cfg.deserialize || !cfg.create) { throw new Error("OfficeApp.init: appType, serialize, deserialize and create are required"); } cfg.fileTypeName = cfg.fileTypeName || "Document"; cfg.defaultFileName = cfg.defaultFileName || "Untitled"; $("body").addClass("of-app"); // chrome if (cfg.ribbon && window.OfficeRibbon) OfficeRibbon.host(); var $menubar = buildMenubar(standardMenus()); $("body").prepend($menubar); $("body").append(buildStatusbar()); if (cfg.ribbon && window.OfficeRibbon && getSetting("ribbonMin", false)) { OfficeRibbon.setMinimized(true); } // the title bar is in the page now: fit its tabs and the panel if (cfg.ribbon && window.OfficeRibbon) OfficeRibbon.scheduleFit(); // the foreign-format banner sits under the whole toolbar strip, so // it reads as a note about the document rather than part of the // chrome; apps with no toolbar get it straight under the menubar var $banner = buildForeignBanner(); var $tb = $("body > .of-toolbar").last(); if ($tb.length) $tb.after($banner); else $menubar.after($banner); // shortcuts (standard) registerShortcut("Ctrl+S", function () { save(); }, { description: "Save" }); registerShortcut("Ctrl+Shift+S", function () { saveAs(); }, { description: "Save as..." }); registerShortcut("Ctrl+O", function () { openDialog(); }, { description: "Open..." }); registerShortcut("Ctrl+Alt+N", function () { newDocument(); }, { description: "New document" }); registerShortcut("Ctrl+P", function () { printDoc(); }, { description: "Print" }); registerShortcut("Ctrl+=", function () { zoomStep(1); }, { description: "Zoom in" }); registerShortcut("Ctrl++", function () { zoomStep(1); }); registerShortcut("Ctrl+-", function () { zoomStep(-1); }, { description: "Zoom out" }); registerShortcut("Ctrl+0", function () { setZoom(100); }, { description: "Reset zoom" }); // non-passive so the browser's own page zoom can be preventDefault-ed window.addEventListener("wheel", onWheelZoom, { passive: false }); if (cfg.onUndo) registerShortcut("Ctrl+Z", function () { cfg.onUndo(); }, { description: "Undo" }); if (cfg.onRedo) { registerShortcut("Ctrl+Y", function () { cfg.onRedo(); }, { description: "Redo" }); registerShortcut("Ctrl+Shift+Z", function () { cfg.onRedo(); }); } if (window.OfficeHotkeys) { registerShortcut("Ctrl+/", function () { OfficeHotkeys.showHelp(); }, { description: "Keyboard shortcuts help" }); // Escape falls through (return false) so app-level Escape // handlers still run after any open menu is closed OfficeHotkeys.register("Escape", function () { closeAllMenus(); return false; }, { id: "of.menuclose", allowInInput: true, inDialogs: true }); } else { window.addEventListener("keydown", handleKeydown, true); } // theme + zoom applyTheme(); zoom = getSetting("zoom", 100); applyZoom(); // housekeeping pruneDrafts(); // load input file (embedded / open-with) or start blank meta = { createdAt: now(), revision: 0 }; var inputs = OfficePlatform.loadInputFiles(cfg.extension); if (inputs && inputs.length > 0) { cfg.create(); openPath(inputs[0].filepath, inputs[0].filename, { asTemplate: !!inputs[0].asTemplate }); } else { cfg.create(); markClean(); splashDone(); trySessionRestore(); // falls back to checkDraft() itself } // autosave + unload guard autosaveTimer = setInterval(autosaveTick, AUTOSAVE_INTERVAL); window.addEventListener("beforeunload", function (e) { if (dirty) { saveDraft(); e.preventDefault(); e.returnValue = ""; } }); installFloatWindowCloseGuard(); installStandaloneDropOpen(); updateTitle(); // the web edition has no storage behind it - say so once, so "Save // downloads a copy" is not a surprise the first time (a desktop // shell saves to real files and needs no such warning) if (OfficePlatform.mode() === "standalone" && (!inputs || !inputs.length)) { setStatus("Web edition - open a " + cfg.extension + " from this device, and Save downloads it back", "info", 9000); } } /* Standalone build: dropping a document on the window opens it. This is the whole point of the web edition - somebody was sent a .docx and wants to look at it - so it is worth a drop target even though the apps already handle drops of their own. It listens in the capture phase but only claims the event when every dropped file is one this app can open; anything else (an image being dropped into the page, say) falls straight through to the app's own handler untouched. */ function installStandaloneDropOpen() { if (!OfficePlatform.isStandalone()) return; var openable = function (name) { var e = extOf(name); return e === cfg.extension || !!(cfg.importers || {})[e] || !!binaryImporters()[e] || isLegacyExt(e); }; var claims = function (e) { var dt = e.dataTransfer; if (!dt || !dt.files || dt.files.length !== 1) return false; return openable(dt.files[0].name); }; document.addEventListener("dragover", function (e) { if (!claims(e)) return; e.preventDefault(); e.stopPropagation(); e.dataTransfer.dropEffect = "copy"; }, true); document.addEventListener("drop", function (e) { if (!claims(e)) return; e.preventDefault(); e.stopPropagation(); var file = e.dataTransfer.files[0]; var go = function () { OfficePlatform.adoptDroppedFile(file, openPath); }; if (dirty) { confirmDialog("Discard unsaved changes?", "The current document has unsaved changes that will be lost.", "Discard", "Cancel", function (yes) { if (yes) go(); }); } else { go(); } }, true); } /* Ask before a window holding unsaved changes goes away. done() once it may close - nothing to lose, saved, or the changes thrown away; cancelled() when the person keeps it open. The ArozOS desktop asks through ao_module_close (below), a desktop shell through OfficeNative.requestClose. */ function requestClose(done, cancelled) { cancelled = cancelled || function () { }; if (!dirty) { done(); return; } // with AutoSave on, closing is one more autosave: the document goes // back into its file and the window closes - no question to answer. // Only when that cannot happen (never saved, a format that cannot // hold it, the write failed) does the window ask. if (canAutosaveOnClose()) { saveWithProgress(save, done, function (err) { if (err) toast("Could not save " + (filename || "the document") + ": " + err, "error"); askBeforeClose(done, cancelled); }); return; } askBeforeClose(done, cancelled); } function canAutosaveOnClose() { if (!autosaveEnabled() || !filepath || !OfficePlatform.autosavesToFile()) return false; var ext = extOf(filename); if (ext === cfg.extension) return true; // a foreign format: only when it can still hold the document var fmt = findSaveFormat(ext, false); return !!fmt && !formatReasons(fmt); } /* Run a save (save or saveAs) behind a progress panel: the window is about to close, so it holds the screen until the file is written - a save that uploads megabytes must not look like a frozen window. done() once the file is saved, failed(message) when it was not (a null message: declined or cancelled, nothing to report). */ function saveWithProgress(run, done, failed) { var ui = null, over = false; var stages = { wait: [2, "Waiting for the previous save..."], prepare: [8, "Preparing the document..."], compress: [16, "Compressing..."], server: [90, "Writing " + (filename || "the file") + "..."] }; var obs = { progress: function (stage, pct) { if (over) return; if (!ui) ui = showSaveProgress(filename || (cfg.defaultFileName + cfg.extension)); if (stage === "upload") { var p = Math.max(0, Math.min(100, pct || 0)); ui.set(18 + p * 0.7, "Uploading... " + Math.round(p) + "%", false); } else if (stages[stage]) { ui.set(stages[stage][0], stages[stage][1], stage === "server" || stage === "wait"); } }, fail: function (err) { if (over) return; over = true; if (ui) ui.close(); failed(err); } }; run(function () { if (over) return; over = true; if (!ui) { done(); return; } ui.set(100, "Saved", false); setTimeout(function () { ui.close(); done(); }, 300); }, obs); } function showSaveProgress(name) { var $o = $(''); $o.find(".of-saveprog-title").text("Saving " + name); $("body").append($o); return { set: function (pct, msg, busy) { $o.find(".of-saveprog-fill").css("width", pct + "%"); $o.find(".of-saveprog-bar").toggleClass("busy", !!busy); $o.find(".of-saveprog-msg").text(msg || ""); }, close: function () { $o.remove(); } }; } function askBeforeClose(done, cancelled) { dialog({ title: "Unsaved changes", body: "" + escapeHtml(filename || (cfg.defaultFileName + cfg.extension)) + " has unsaved changes. Close it anyway?", dismissable: true, onDismiss: cancelled, buttons: [ { label: "Cancel", action: function (close) { close(); cancelled(); } }, { label: "Close without saving", danger: true, action: function (close) { close(); markClean(); // never re-prompt while the window tears down done(); } }, { label: "Save & close", primary: true, // only a save that worked closes the window: a failed // or cancelled one leaves it open with the changes action: function (close) { close(); // a document never saved asks for a file first, and // the picker does not report a cancel: release the // close request now (a saved file still closes it) var asked = !filepath; if (asked) cancelled(); saveWithProgress(save, done, function (err) { if (err) toast("Could not save: " + err, "error"); if (!asked) cancelled(); }); } } ] }); } /* The desktop routes a floatWindow's X button through the iframe's ao_module_close() (see ao_module.js + desktop.html: it calls contentWindow.ao_module_close() when defined). beforeunload does NOT fire when the desktop just removes the iframe, so override that hook to confirm before discarding unsaved changes. */ function installFloatWindowCloseGuard() { if (typeof window.ao_module_close !== "function") return; var reallyClose = function () { markClean(); // never re-prompt while the window tears down // closed for good (saved, or changes thrown away): its working // copies can go now rather than wait for the nightly sweep OfficePlatform.releaseWorkdir(); if (typeof window.ao_module_closeHandler === "function") { ao_module_closeHandler(); } }; window.ao_module_close = function () { requestClose(reallyClose); }; } /* ---------- public API ---------- */ return { init: init, // lifecycle newDocument: newDocument, open: openDialog, openPath: openPath, save: save, saveAs: saveAs, print: printDoc, // ask before the window closes: done() = it may, cancelled() = kept requestClose: requestClose, markDirty: markDirty, isDirty: function () { return dirty; }, getFilePath: function () { return filepath; }, getFileName: function () { return filename; }, getMeta: function () { return meta; }, wasImported: function () { return loadedFromImport; }, // ui setStatus: setStatus, addStatusItem: addStatusItem, updateStatusItem: updateStatusItem, dialog: dialog, confirm: confirmDialog, prompt: promptDialog, toast: toast, showContextMenu: showContextMenu, showBusy: showBusy, hideBusy: hideBusy, documentLoaded: documentLoaded, splashStep: splashStep, showProgress: showProgress, beginDrag: beginDrag, closeAllMenus: closeAllMenus, updateMenus: updateMenus, // features registerShortcut: registerShortcut, print: printDoc, // the Edit commands, for an app's own ribbon (op: cut | copy | paste) clipboard: clipboardAction, showClipboardPane: showClipboardPane, autosaveEnabled: autosaveEnabled, setAutosave: function (on) { setSetting("autosave", !!on); refreshQat(); }, // the title bar's slot for an app's own buttons (right-hand side) titleBarSlot: function () { return $(".of-titleextras"); }, setZoom: setZoom, getZoom: function () { return zoom; }, zoomIn: function () { zoomStep(1); }, zoomOut: function () { zoomStep(-1); }, toggleTheme: toggleTheme, isDark: isDark, // storage getSetting: getSetting, setSetting: setSetting, getRecents: getRecents, // host (see common/platform.js) hasBackend: function () { return OfficePlatform.hasBackend(); }, isStandalone: function () { return OfficePlatform.isStandalone(); }, // vfs / media vfsLoad: vfsLoad, vfsSave: vfsSave, blobToSrc: blobToSrc, mediaUrl: mediaUrl, agirunLarge: agirunLarge, // utils escapeHtml: escapeHtml, basename: basename, dirname: dirname, extOf: extOf, stripExt: stripExt }; })();