/* 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 "
  • " + escapeHtml(t) + "
  • "; }).join(""); setStatus("Cannot save in " + label + " format", "error"); dialog({ title: "Cannot save as " + label, body: "

    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('
    (empty)
    '); } $("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(''); // Save, Undo and Redo live in the File and Edit menus (and on // their shortcuts); the title bar keeps only the AutoSave switch var $qat = $('
    '); if (window.OfficeRibbon) { $qat.append(OfficeRibbon.toggle({ label: "AutoSave", cls: "of-autosave", title: OfficePlatform.autosavesToFile() ? "AutoSave: write changes to the file as you work" : "AutoSave: keep a recovery copy as you work", get: autosaveEnabled, set: function (on) { setSetting("autosave", !!on); } })); } $bar.append($qat); $bar.append('
    '); var $menus = $('
    '); menus.forEach(function (m) { var $m = $('
    ' + escapeHtml(m.title) + "
    "); var $drop = $('
    '); $m.append($drop); // contextual menus (e.g. Table) only show when their condition // holds; the app re-evaluates via OfficeApp.updateMenus() if (m.when) { $m.data("when", m.when); $m.hide(); } $m.on("click", function (ev) { if ($(ev.target).closest(".of-menu-drop").length) return; var wasOpen = $m.hasClass("open"); closeAllMenus(); if (!wasOpen) { renderMenuItems($drop, (typeof m.items === "function") ? m.items() : m.items, 0); $m.addClass("open"); } }); $m.on("mouseenter", function () { if ($(".of-menu.open").length && !$m.hasClass("open")) { closeAllMenus(); renderMenuItems($drop, (typeof m.items === "function") ? m.items() : m.items, 0); $m.addClass("open"); } }); $menus.append($m); }); $bar.append($menus); if (cfg.ribbon && window.OfficeRibbon) OfficeRibbon.attachStrip($menus); $bar.append('
    '); // room for an app's own title-bar buttons (Slides' Slideshow) $bar.append('
    '); if (cfg.ribbon && window.OfficeRibbon) { var $min = $(''); $min.on("click", function () { OfficeRibbon.setMinimized(!OfficeRibbon.isMinimized()); }); $bar.append($min); } // one global closer for menubar drops, context menus and submenus $(document).on("mousedown.ofmenu", function (ev) { // the ribbon's popups (a folded group, a gallery) close themselves if (!$(ev.target).closest(".of-menubar, .of-context-menu, .of-rb-popup, .of-rb-gpop").length) closeAllMenus(); }); return $bar; } function refreshMenuChecks() { // menus re-render on open; nothing to do live } // show/hide conditional menubar menus (menu defs carrying when: fn), // and re-read the ribbon's contextual tabs and control states function updateMenus() { $(".of-menubar .of-menu").each(function () { var w = $(this).data("when"); if (!w) return; var on = false; try { on = !!w(); } catch (e) { } if (!on && $(this).hasClass("open")) closeAllMenus(); $(this).toggle(on); }); if (cfg && cfg.ribbon && window.OfficeRibbon) OfficeRibbon.refresh(); if (cfg) refreshQat(); } function standardMenus() { var fileItems = function () { var items = [ { label: "New", icon: "file outline", key: "Ctrl+Alt+N", action: newDocument }, { label: "Open...", icon: "folder open", key: "Ctrl+O", action: openDialog }, !OfficePlatform.tracksRecents() ? null : { label: "Open recent", icon: "history", sub: function () { return getRecents().map(function (r) { return { label: r.fn, action: function () { if (openElsewhere(r.fp, r.fn)) return; if (dirty) { confirmDialog("Discard unsaved changes?", "The current document has unsaved changes.", "Discard", "Cancel", function (yes) { if (yes) openPath(r.fp, r.fn); }); } else { openPath(r.fp, r.fn); } } }; }); } }, { sep: true }, { label: "Save", icon: "save", key: "Ctrl+S", action: function () { save(); } }, saveAsMenuItem(), { label: "Auto-save", checked: autosaveEnabled, action: function () { setSetting("autosave", !autosaveEnabled()); } } ]; if (cfg.fileMenuExtras && cfg.fileMenuExtras.length) { items.push({ sep: true }); items = items.concat(cfg.fileMenuExtras); } items.push({ sep: true }); items.push({ label: "Print / PDF...", icon: "print", key: "Ctrl+P", action: printDoc }); return items; }; var editItems = function () { var items = [ { label: "Undo", icon: "undo", key: "Ctrl+Z", enabled: function () { return !cfg.canUndo || cfg.canUndo(); }, action: function () { if (cfg.onUndo) cfg.onUndo(); } }, { label: "Redo", icon: "redo", key: "Ctrl+Y", enabled: function () { return !cfg.canRedo || cfg.canRedo(); }, action: function () { if (cfg.onRedo) cfg.onRedo(); } }, { sep: true }, { label: "Cut", icon: "cut", key: "Ctrl+X", action: function () { clipboardAction("cut"); } }, { label: "Copy", icon: "copy", key: "Ctrl+C", action: function () { clipboardAction("copy"); } }, { label: "Paste", icon: "paste", key: "Ctrl+V", action: function () { clipboardAction("paste"); } }, { label: "Clipboard...", icon: "clipboard outline", action: showClipboardPane } ]; if (cfg.editMenuExtras && cfg.editMenuExtras.length) { items.push({ sep: true }); items = items.concat(cfg.editMenuExtras); } return items; }; var viewItems = function () { var items = [ { label: "Zoom in", icon: "zoom-in", key: "Ctrl+=", action: function () { zoomStep(1); } }, { label: "Zoom out", icon: "zoom-out", key: "Ctrl+-", action: function () { zoomStep(-1); } }, { label: "Reset zoom", icon: "expand", key: "Ctrl+0", action: function () { setZoom(100); } }, { sep: true }, { label: "Dark theme", checked: isDark, action: toggleTheme } ]; if (cfg.viewMenuExtras && cfg.viewMenuExtras.length) { items.push({ sep: true }); items = items.concat(cfg.viewMenuExtras); } return items; }; // a ribbon app keeps File and Edit as menus, Google Docs style, and // carries the rest (its own menus, View) as ribbon tabs var menus = [{ title: "File", items: fileItems }, { title: "Edit", items: editItems }]; if (cfg.ribbon) return menus; (cfg.menus || []).forEach(function (m) { menus.push(m); }); menus.push({ title: "View", items: viewItems }); return menus; } /* ---------- clipboard ---------- */ function clipboardAction(op) { var hook = { cut: cfg.onCut, copy: cfg.onCopy, paste: cfg.onPaste }[op]; if (hook) { hook(); return; } if (op === "paste") { if (navigator.clipboard && navigator.clipboard.readText) { navigator.clipboard.readText().then(function (t) { if (cfg.onPasteText) cfg.onPasteText(t); else document.execCommand("insertText", false, t); }).catch(function () { setStatus("Paste blocked by the browser - use Ctrl+V instead", "error"); }); } else { setStatus("Paste is not available - use Ctrl+V instead", "error"); } } else { document.execCommand(op); } } /* ---------- the Clipboard pane (Home > Clipboard launcher) ---------- Everything copied or cut in Office in this tab (OfficeClipboard keeps it), newest first: click an item to paste it, as Office's Clipboard task pane does. An item is put back on the system clipboard and pasted by the app's own Paste, so every app pastes it the way it pastes anything else. */ var APP_NAMES = { docs: "Docs", sheets: "Sheets", slides: "Slides" }; function clipItemView(it) { var parsed = it.html ? OfficeClipboard.parse(it.html) : null; // another app's object marker is never shown as raw JSON var text = OfficeClipboard.isMarker(it.text) || /^\s*\{\s*"app"\s*:/.test(it.text) ? (parsed ? parsed.text : "") : (it.text || (parsed ? parsed.text : "")); text = String(text || "").replace(/\s+/g, " ").trim(); var $prev = $(''); if (parsed && parsed.images.length && !text) { $prev.append($('').attr("src", parsed.images[0].src)); } else if (parsed && parsed.tables.length && !it.text) { $prev.text(text.slice(0, 160) || "Table"); } else { $prev.text(text.length > 160 ? text.slice(0, 160) + "..." : (text || "(empty)")); } return { $prev: $prev, text: text }; } function pasteClipItem(it) { OfficeClipboard.writeAsync({ text: it.text, html: it.html, noHistory: true }).then(function () { clipboardAction("paste"); }, function () { setStatus("The browser blocked the clipboard - copy it again and press Ctrl+V", "error"); }); } function showClipboardPane() { closeAllMenus(); if (!window.OfficeClipboard) return; var $list = $('
    '); var d = null; var render = function () { var items = OfficeClipboard.history(); $list.empty(); if (!items.length) { $list.append('
    The clipboard is empty.
    Copy or cut something in Docs, Sheets or Slides to collect it here.
    '); return; } items.forEach(function (it, i) { var v = clipItemView(it); var $row = $('
    ').attr("title", "Click to paste"); $row.append($('').text(APP_NAMES[it.app] || "Office")); $row.append(v.$prev); var $del = $(''); $del.on("click", function (e) { e.stopPropagation(); OfficeClipboard.removeFromHistory(i); render(); }); $row.append($del); $row.on("click", function () { d.close(); pasteClipItem(it); }); $row.on("keydown", function (e) { if (e.key === "Enter") { d.close(); pasteClipItem(it); } }); $list.append($row); }); }; var $body = $('
    ') .append('
    Click an item to paste it.
    ') .append($list); d = dialog({ title: "Clipboard", body: $body, buttons: [ { label: "Clear All", action: function () { OfficeClipboard.clearHistory(); render(); } }, { label: "Paste All", action: function (close) { // oldest first, the order they were copied in var items = OfficeClipboard.history().reverse(); if (!items.length) return; var texts = [], htmls = []; items.forEach(function (it) { var v = clipItemView(it); texts.push(v.text); htmls.push(it.html || escapeHtml(it.text).replace(/\n/g, "
    ")); }); close(); pasteClipItem({ text: texts.join("\n"), html: htmls.join("
    ") }); } }, { label: "Close", primary: true } ] }); render(); } /* ---------- print ---------- */ function printDoc() { closeAllMenus(); if (cfg.onBeforePrint) { try { cfg.onBeforePrint(); } catch (e) { } } setTimeout(function () { // the print layout stays up until the dialog is done with it - // at once in a browser, later behind a native print panel OfficePlatform.print(function () { if (cfg.onAfterPrint) { try { cfg.onAfterPrint(); } catch (e) { } } }); }, 60); } /* ---------- dialogs / toasts / context menu ---------- */ function dialog(opt) { splashDone(); // a question for the person: nothing may cover it var $ov = $('
    '); var $dl = $('
    '); if (opt.wide) $dl.addClass("wide"); if (opt.title) $dl.append('
    ' + escapeHtml(opt.title) + "
    "); var $body = $('
    '); if (opt.body instanceof $) $body.append(opt.body); else $body.html(opt.body || ""); $dl.append($body); var close = function () { $ov.remove(); $(document).off("keydown.ofdialog"); }; if (opt.buttons && opt.buttons.length) { var $act = $('
    '); opt.buttons.forEach(function (b) { var $b = $('').text(b.label); if (b.primary) $b.addClass("primary"); if (b.danger) $b.addClass("danger"); $b.on("click", function () { if (b.action) b.action(close, $body); else close(); }); $act.append($b); }); $dl.append($act); } $ov.append($dl); // dismissed without a button: opt.onDismiss hears about it var dismiss = function () { close(); if (opt.onDismiss) opt.onDismiss(); }; $ov.on("mousedown", function (e) { if (e.target === $ov[0] && opt.dismissable !== false) dismiss(); }); $(document).on("keydown.ofdialog", function (e) { if (e.key === "Escape" && opt.dismissable !== false) dismiss(); }); $("body").append($ov); return { close: close, body: $body }; } function confirmDialog(title, msgHtml, yesLabel, noLabel, cb) { dialog({ title: title, body: "

    " + 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 }; })();