| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091209220932094209520962097209820992100210121022103210421052106210721082109211021112112211321142115211621172118211921202121212221232124212521262127212821292130213121322133213421352136213721382139214021412142214321442145214621472148214921502151215221532154215521562157215821592160216121622163216421652166216721682169217021712172217321742175217621772178217921802181218221832184218521862187218821892190219121922193219421952196219721982199 |
- /*
- 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, """).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 <b>" + what + "</b> (" + escapeHtml(when) + ").<br><br>" +
- '<span class="of-dim">Start fresh deletes the saved snapshot.</span>',
- 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 ? '<span class="of-dirty-dot">• </span>' : "") + 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 = $('<span class="of-status-item" data-sid="' + escapeHtml(id) + '"></span>').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 <b>" + escapeHtml(when) +
- "</b> 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 "<li>" + escapeHtml(t) + "</li>";
- }).join("");
- setStatus("Cannot save in " + label + " format", "error");
- dialog({
- title: "Cannot save as " + label,
- body: "<p>This " + escapeHtml(cfg.fileTypeName.toLowerCase()) +
- " uses features that " + escapeHtml(label) + " cannot store:</p>" +
- "<ul>" + list + "</ul>" +
- "<p>Save it as " + escapeHtml(cfg.extension) +
- " to keep them, or use File > Export for a lossy copy.</p>",
- 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 = $('<div class="of-fmtbanner of-noprint" style="display:none;"></div>');
- $b.append('<i class="exclamation triangle icon"></i>');
- $b.append('<span class="of-fmtbanner-msg"></span>');
- $b.append($('<button type="button" class="of-fmtbanner-btn"></button>')
- .html('<i class="exchange icon"></i>Convert to ' + escapeHtml(cfg.extension))
- .attr("title", "Save a copy as a " + nativeLabel() + " and open it")
- .on("click", convertToNative));
- $b.append($('<button type="button" class="of-fmtbanner-x" title="Dismiss">×</button>')
- .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 <b>" + escapeHtml(what) + "</b>, 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 = $('<div class="of-context-menu of-float-menu"></div>').data("depth", depth);
- renderMenuItems($sm, items, depth);
- if (!items || items.length === 0) {
- $sm.append('<div class="of-menu-item disabled"><span style="width:16px;"></span><span class="of-mi-label">(empty)</span></div>');
- }
- $("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('<div class="of-menu-sep"></div>'); return; }
- var $it = $('<div class="of-menu-item"></div>');
- if (it.checked !== undefined) {
- var on = (typeof it.checked === "function") ? it.checked() : it.checked;
- $it.append('<span class="of-mi-check">' + (on ? "✓" : "") + "</span>");
- } else if (it.icon) {
- $it.append('<i class="' + escapeHtml(it.icon) + ' icon"></i>');
- } else {
- $it.append('<span style="width:16px;"></span>');
- }
- // 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($('<span class="of-mi-label of-mi-html"></span>').html(it.html))
- .attr("title", it.label || "");
- } else {
- $it.append('<span class="of-mi-label">' + escapeHtml(it.label) + "</span>");
- }
- if (it.key) $it.append('<span class="of-mi-key">' + escapeHtml(it.key) + "</span>");
- 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 = $('<div class="of-menubar of-titlebar of-noprint"></div>');
- $bar.append('<img class="of-appicon" src="' + escapeHtml(cfg.appIcon || "../img/docs.svg") + '" alt="">');
- // 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 = $('<div class="of-qat"></div>');
- 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('<div class="of-tsep of-qatsep"></div>');
- var $menus = $('<div class="of-tabstrip"></div>');
- menus.forEach(function (m) {
- var $m = $('<div class="of-menu" tabindex="-1">' + escapeHtml(m.title) + "</div>");
- var $drop = $('<div class="of-menu-drop"></div>');
- $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('<div class="of-docname"></div>');
- // room for an app's own title-bar buttons (Slides' Slideshow)
- $bar.append('<div class="of-titleextras"></div>');
- if (cfg.ribbon && window.OfficeRibbon) {
- var $min = $('<button type="button" class="of-qbtn of-rb-mintoggle" title="Collapse the ribbon"><i class="chevron up icon"></i></button>');
- $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 = $('<span class="of-clip-prev"></span>');
- if (parsed && parsed.images.length && !text) {
- $prev.append($('<img alt="">').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 = $('<div class="of-clip-list"></div>');
- var d = null;
- var render = function () {
- var items = OfficeClipboard.history();
- $list.empty();
- if (!items.length) {
- $list.append('<div class="of-clip-empty">The clipboard is empty.<br>Copy or cut something in Docs, Sheets or Slides to collect it here.</div>');
- return;
- }
- items.forEach(function (it, i) {
- var v = clipItemView(it);
- var $row = $('<div class="of-clip-item" tabindex="0"></div>').attr("title", "Click to paste");
- $row.append($('<span class="of-clip-app"></span>').text(APP_NAMES[it.app] || "Office"));
- $row.append(v.$prev);
- var $del = $('<button type="button" class="of-clip-del" title="Delete"><i class="close icon"></i></button>');
- $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 = $('<div class="of-clip-pane"></div>')
- .append('<div class="of-clip-hint">Click an item to paste it.</div>')
- .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, "<br>"));
- });
- close();
- pasteClipItem({ text: texts.join("\n"), html: htmls.join("<br>") });
- }
- },
- { 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 = $('<div class="of-dialog-overlay"></div>');
- var $dl = $('<div class="of-dialog"></div>');
- if (opt.wide) $dl.addClass("wide");
- if (opt.title) $dl.append('<div class="of-dialog-title">' + escapeHtml(opt.title) + "</div>");
- var $body = $('<div class="of-dialog-body"></div>');
- 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 = $('<div class="of-dialog-actions"></div>');
- opt.buttons.forEach(function (b) {
- var $b = $('<button class="of-btn"></button>').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: "<p>" + msgHtml + "</p>",
- 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 = $("<div><label>" + escapeHtml(label) + '</label><input type="text" class="of-prompt-input"></div>');
- $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 = $('<div class="of-toast-holder"></div>'); $("body").append($h); }
- var $t = $('<div class="of-toast"></div>').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 = $('<div class="of-context-menu of-float-menu"></div>').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 = $('<div class="of-busy-overlay"><div class="of-spinner"></div><div class="of-busy-msg"></div></div>');
- $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 = $('<div class="of-progress-panel of-noprint">' +
- '<div class="of-pp-title"></div>' +
- '<div class="of-pp-msg"></div>' +
- '<div class="of-pp-bar"><div class="of-pp-fill"></div></div>' +
- '</div>');
- $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 = $('<div class="of-statusbar of-noprint"></div>');
- $sb.append('<span class="of-status-msg"></span>');
- $sb.append('<span class="of-status-items"></span>');
- var $z = $('<span class="of-status-zoom"></span>');
- var $minus = $('<button type="button" title="Zoom out">−</button>');
- var $plus = $('<button type="button" title="Zoom in">+</button>');
- var $val = $('<span class="of-zoom-val" title="Reset zoom">100%</span>');
- $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 = $('<div class="of-saveprog-overlay of-noprint" role="dialog" aria-live="polite">' +
- '<div class="of-saveprog"><div class="of-saveprog-title"></div>' +
- '<div class="of-saveprog-bar"><div class="of-saveprog-fill"></div></div>' +
- '<div class="of-saveprog-msg"></div></div></div>');
- $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: "<b>" + escapeHtml(filename || (cfg.defaultFileName + cfg.extension)) +
- "</b> 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
- };
- })();
|