office.js 95 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091209220932094209520962097209820992100210121022103210421052106210721082109211021112112211321142115211621172118211921202121212221232124212521262127212821292130213121322133213421352136213721382139214021412142214321442145214621472148214921502151215221532154215521562157215821592160216121622163216421652166216721682169217021712172217321742175217621772178217921802181218221832184218521862187218821892190219121922193219421952196219721982199
  1. /*
  2. ArozOS Office Suite - shared application framework
  3. ====================================================
  4. Shared by Docs, Sheets and Slides. Provides:
  5. - Document lifecycle: New / Open / Save / Save As in the app's own
  6. Office format (.docx / .xlsx / .pptx), foreign formats (ODF, text),
  7. the JSON envelope the editors hold, file version metadata
  8. - Auto-save, crash-recovery drafts (localStorage), recent documents
  9. - Menubar + statusbar chrome, dialogs, toasts, context menus
  10. - Keyboard shortcut registry, undo/redo bindings
  11. - Light/dark theme (shared across the suite), zoom controls, print
  12. Requires (include before this file):
  13. ../../script/jquery.min.js
  14. ../../script/ao_module.js
  15. ../common/mode.js
  16. ../common/recents.js
  17. ../common/wasm.js
  18. ../common/platform.js
  19. ../common/office.css
  20. Everything that reaches outside the browser tab - file dialogs, reading
  21. and writing documents, session snapshots, AGI calls - goes through
  22. OfficePlatform (common/platform.js), which is what
  23. lets the same source run both as an ArozOS webapp and as the standalone
  24. static-hosting build.
  25. Usage: see Office/common/CONTRACT.md for the full API reference.
  26. */
  27. /* ================= Undo stack ================= */
  28. /*
  29. Generic snapshot-based undo/redo helper.
  30. var undo = new OfficeUndoStack({
  31. limit: 100,
  32. apply: function(state){ ...restore editor from state... }
  33. });
  34. undo.init(initialState);
  35. undo.push(state); // record a new state (call after a change)
  36. undo.pushDebounced(fn, ms); // coalesce rapid changes; fn() returns state
  37. undo.undo(); undo.redo(); // calls apply() with the target state
  38. undo.canUndo(); undo.canRedo();
  39. undo.reset(initialState);
  40. */
  41. function OfficeUndoStack(opts) {
  42. opts = opts || {};
  43. this.limit = opts.limit || 100;
  44. this.applyFn = opts.apply || function () {};
  45. this.stack = [];
  46. this.pos = -1;
  47. this._debTimer = null;
  48. }
  49. OfficeUndoStack.prototype.init = function (state) {
  50. this.stack = [state];
  51. this.pos = 0;
  52. };
  53. OfficeUndoStack.prototype.reset = OfficeUndoStack.prototype.init;
  54. OfficeUndoStack.prototype.push = function (state) {
  55. if (this.pos >= 0) {
  56. var cur = this.stack[this.pos];
  57. if (typeof cur === "string" && typeof state === "string" && cur === state) return;
  58. try {
  59. if (typeof cur === "object" && JSON.stringify(cur) === JSON.stringify(state)) return;
  60. } catch (e) { /* non-serializable state, push anyway */ }
  61. }
  62. this.stack = this.stack.slice(0, this.pos + 1);
  63. this.stack.push(state);
  64. if (this.stack.length > this.limit) this.stack.shift();
  65. this.pos = this.stack.length - 1;
  66. };
  67. OfficeUndoStack.prototype.pushDebounced = function (stateFn, ms) {
  68. var that = this;
  69. clearTimeout(this._debTimer);
  70. this._debTimer = setTimeout(function () {
  71. that.push(stateFn());
  72. }, ms || 400);
  73. };
  74. OfficeUndoStack.prototype.flushDebounced = function (stateFn) {
  75. if (this._debTimer) {
  76. clearTimeout(this._debTimer);
  77. this._debTimer = null;
  78. this.push(stateFn());
  79. }
  80. };
  81. OfficeUndoStack.prototype.canUndo = function () { return this.pos > 0; };
  82. OfficeUndoStack.prototype.canRedo = function () { return this.pos < this.stack.length - 1; };
  83. OfficeUndoStack.prototype.undo = function () {
  84. if (!this.canUndo()) return false;
  85. this.pos--;
  86. this.applyFn(this.stack[this.pos]);
  87. return true;
  88. };
  89. OfficeUndoStack.prototype.redo = function () {
  90. if (!this.canRedo()) return false;
  91. this.pos++;
  92. this.applyFn(this.stack[this.pos]);
  93. return true;
  94. };
  95. /* ================= OfficeApp ================= */
  96. var OfficeApp = (function () {
  97. var cfg = null;
  98. var filepath = null; // virtual path of the open file, null = unsaved
  99. var filename = null; // display name (with extension)
  100. var dirty = false;
  101. var meta = null; // envelope meta of the open document
  102. var zoom = 100;
  103. var shortcuts = {}; // normalized combo -> handler
  104. var statusTimer = null;
  105. var draftTimer = null;
  106. var autosaveTimer = null;
  107. var loadedFromImport = false;
  108. // the file a read-only import came from (a .doc opened in Docs): the
  109. // title shows it until the document is saved somewhere of its own,
  110. // while filename holds the name Save suggests in the app's format
  111. var sourceName = null;
  112. // saving is asynchronous (the app prepares the document, then it
  113. // travels): one write at a time, and an edit made while it is under way
  114. // keeps the document dirty
  115. var editSeq = 0; // bumped by every markDirty
  116. var saveBusy = false;
  117. var queuedSave = null; // {fp, fn, cb} asked for while a save was running
  118. var ENVELOPE_TYPE = "arozos/office";
  119. var GENERATOR = "ArozOS Office/1.0";
  120. var DRAFT_MAX_AGE = 7 * 24 * 3600 * 1000; // prune drafts older than 7 days
  121. var DRAFT_MAX_SIZE = 4 * 1024 * 1024; // skip drafts over 4 MB
  122. var AUTOSAVE_INTERVAL = 25 * 1000;
  123. var RECENT_MAX = 10;
  124. /* ---------- small utils ---------- */
  125. function escapeHtml(t) {
  126. if (t === undefined || t === null) return "";
  127. return String(t).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;")
  128. .replace(/"/g, "&quot;").replace(/'/g, "&#39;");
  129. }
  130. function basename(p) {
  131. if (!p) return "";
  132. var s = String(p).replace(/\\/g, "/");
  133. return s.substring(s.lastIndexOf("/") + 1);
  134. }
  135. function dirname(p) {
  136. if (!p) return "";
  137. var s = String(p).replace(/\\/g, "/");
  138. return s.substring(0, s.lastIndexOf("/"));
  139. }
  140. function extOf(p) {
  141. var b = basename(p);
  142. var i = b.lastIndexOf(".");
  143. return i < 0 ? "" : b.substring(i).toLowerCase();
  144. }
  145. function stripExt(p) {
  146. var i = String(p).lastIndexOf(".");
  147. return i < 0 ? String(p) : String(p).substring(0, i);
  148. }
  149. function now() { return new Date().getTime(); }
  150. // "meeting-notes" -> "Meeting Notes": template file names become the
  151. // suggested document name, so they should read like one
  152. function titleCase(s) {
  153. return String(s || "").replace(/[-_]+/g, " ").replace(/\s+/g, " ").trim()
  154. .replace(/\b[a-z]/g, function (c) { return c.toUpperCase(); });
  155. }
  156. function lsKey(k) { return "office_" + (cfg ? cfg.appType : "generic") + "_" + k; }
  157. function getSetting(k, def) {
  158. try {
  159. var v = localStorage.getItem(lsKey(k));
  160. return v === null ? def : JSON.parse(v);
  161. } catch (e) { return def; }
  162. }
  163. function setSetting(k, v) {
  164. try { localStorage.setItem(lsKey(k), JSON.stringify(v)); } catch (e) { }
  165. }
  166. /* ---------- host bridge (see common/platform.js) ----------
  167. These stay on OfficeApp because the whole suite already calls them
  168. through it; the implementation - ArozOS virtual file system and AGI
  169. gateway, or the visitor's own device - lives in OfficePlatform. */
  170. function vfsLoad(path, cb, errcb) { OfficePlatform.readText(path, cb, errcb); }
  171. function vfsSave(path, content, cb, errcb) { OfficePlatform.writeText(path, content, cb, errcb); }
  172. function mediaUrl(vpath) { return OfficePlatform.mediaUrl(vpath); }
  173. function blobToSrc(blob, filename, cb, errcb) {
  174. OfficePlatform.blobToSrc(blob, filename, cb, errcb);
  175. }
  176. function agirunLarge(script, params, field, cb, errcb, timeout) {
  177. OfficePlatform.agirunLarge(script, params, field, cb, errcb, timeout);
  178. }
  179. /* ---------- session snapshots ("Restore from previous session") ---------- */
  180. function saveSession() {
  181. if (!cfg) return;
  182. var env;
  183. try { env = buildEnvelopeNoBump(); } catch (e) { return; }
  184. // annotate a COPY of meta - session bookkeeping must not leak into
  185. // the real document file
  186. var m = {};
  187. Object.keys(env.meta || {}).forEach(function (k) { m[k] = env.meta[k]; });
  188. m._sessionAt = now();
  189. m._origin = filepath ? { fp: filepath, fn: filename } : null;
  190. env.meta = m;
  191. OfficePlatform.sessionSave(cfg.appType, JSON.stringify(env));
  192. }
  193. // drop the saved session snapshot so it stops prompting on next launch
  194. function deleteSession() {
  195. if (!cfg) return;
  196. OfficePlatform.sessionDelete(cfg.appType);
  197. }
  198. function trySessionRestore() {
  199. OfficePlatform.sessionLoad(cfg.appType, function (envelope) {
  200. var env = envelope;
  201. if (typeof env === "string") {
  202. try { env = JSON.parse(env); } catch (e) { env = null; }
  203. }
  204. if (!env || env.type !== ENVELOPE_TYPE || env.app !== cfg.appType || !env.body) {
  205. checkDraft();
  206. return;
  207. }
  208. var m = env.meta || {};
  209. var when = m._sessionAt ? new Date(m._sessionAt).toLocaleString() : "an earlier session";
  210. var what = m._origin && m._origin.fn ? escapeHtml(m._origin.fn) : "an unsaved document";
  211. var restore = function () {
  212. var origin = m._origin;
  213. delete m._sessionAt;
  214. delete m._origin;
  215. meta = m;
  216. sourceName = null;
  217. if (origin && origin.fp) {
  218. filepath = origin.fp;
  219. filename = origin.fn;
  220. }
  221. cfg.deserialize(env.body);
  222. markDirty();
  223. updateTitle();
  224. setStatus("Previous session restored - remember to save");
  225. };
  226. dialog({
  227. title: "Restore from previous session?",
  228. body: "You were working on <b>" + what + "</b> (" + escapeHtml(when) + ").<br><br>" +
  229. '<span class="of-dim">Start fresh deletes the saved snapshot.</span>',
  230. dismissable: true,
  231. buttons: [
  232. // keeps the snapshot: dismiss now, may be offered next launch
  233. //{ label: "Start fresh", action: function (close) { close(); checkDraft(); } },
  234. // deletes the snapshot so it stops prompting
  235. {
  236. label: "Start fresh", danger: true,
  237. action: function (close) { close(); deleteSession(); checkDraft(); }
  238. },
  239. {
  240. label: "Restore", primary: true,
  241. action: function (close) { close(); restore(); }
  242. }
  243. ]
  244. });
  245. });
  246. }
  247. /* ---------- envelope ---------- */
  248. function buildEnvelope() {
  249. var m = meta || {};
  250. m.title = stripExt(filename || cfg.defaultFileName);
  251. m.createdAt = m.createdAt || now();
  252. m.modifiedAt = now();
  253. m.revision = (m.revision || 0) + 1;
  254. m.generator = GENERATOR;
  255. meta = m;
  256. return {
  257. type: ENVELOPE_TYPE,
  258. app: cfg.appType,
  259. version: 1,
  260. meta: m,
  261. body: cfg.serialize()
  262. };
  263. }
  264. function parseEnvelope(text) {
  265. var obj = JSON.parse(text);
  266. if (!obj || obj.type !== ENVELOPE_TYPE || obj.app !== cfg.appType) {
  267. throw new Error("Not a valid " + cfg.fileTypeName + " file");
  268. }
  269. return obj;
  270. }
  271. /* ---------- title / status ---------- */
  272. function updateTitle() {
  273. var name = openingName || (!filepath && sourceName) || filename || (cfg.defaultFileName + cfg.extension);
  274. var t = name + (dirty ? " •" : "") + " - " + cfg.appName;
  275. OfficePlatform.setWindowTitle(t);
  276. var $dn = $(".of-docname");
  277. $dn.html((dirty ? '<span class="of-dirty-dot">• </span>' : "") + escapeHtml(name));
  278. // the document's identity only ever changes alongside its title
  279. updateForeignBanner();
  280. }
  281. /* ---------- start-up splash (common/splash.js, when the app has one) ----------
  282. While it shows, status messages and busy messages are what it says;
  283. it goes away when the document is on screen (documentLoaded), when
  284. something needs the person (a dialog), or when something failed. */
  285. function splashActive() {
  286. return !!(window.OfficeSplash && OfficeSplash.active());
  287. }
  288. function splashDone() {
  289. if (!splashActive()) return;
  290. OfficeSplash.done();
  291. showFromTop();
  292. }
  293. /* The document comes out from behind the splash at its beginning. The
  294. window grows and the layout settles for a moment after that (a float
  295. window resizing, pictures and fonts arriving), and any of it can
  296. leave the view scrolled part-way down the first page - so the top is
  297. asked for again until it has settled, unless the person has started
  298. scrolling themselves. */
  299. function showFromTop() {
  300. var $ws = $(".of-workspace");
  301. if (!$ws.length) return;
  302. var touched = false;
  303. var mark = function () { touched = true; };
  304. $ws.one("wheel touchstart keydown mousedown", mark);
  305. var top = function () {
  306. if (touched) return;
  307. $ws.scrollTop(0).scrollLeft(0);
  308. };
  309. top();
  310. requestAnimationFrame(top);
  311. [120, 350, 800].forEach(function (ms) { setTimeout(top, ms); });
  312. setTimeout(function () { $ws.off("wheel touchstart keydown mousedown", mark); }, 900);
  313. }
  314. // the document an app was asked to open is on screen
  315. function documentLoaded() {
  316. splashDone();
  317. }
  318. // say msg on the splash and let it paint before fn does synchronous work
  319. // (laying out a long document); without a splash fn just runs
  320. function splashStep(msg, fn) {
  321. if (!splashActive()) { fn(); return; }
  322. OfficeSplash.status(msg);
  323. // a frame to paint it in - or a moment, where frames do not come (a
  324. // tab or window in the background)
  325. var ran = false;
  326. var go = function () { if (!ran) { ran = true; fn(); } };
  327. requestAnimationFrame(function () { setTimeout(go, 0); });
  328. setTimeout(go, 150);
  329. }
  330. function setStatus(msg, type, timeout) {
  331. if (splashActive()) {
  332. if (type === "error") splashDone();
  333. else OfficeSplash.status(msg);
  334. }
  335. var $m = $(".of-status-msg");
  336. $m.text(msg || "").removeClass("error");
  337. if (type === "error") $m.addClass("error");
  338. clearTimeout(statusTimer);
  339. if (timeout !== 0) {
  340. statusTimer = setTimeout(function () { $m.text(""); }, timeout || 5000);
  341. }
  342. }
  343. function addStatusItem(id, html) {
  344. var $it = $('<span class="of-status-item" data-sid="' + escapeHtml(id) + '"></span>').html(html);
  345. $(".of-status-items").append($it);
  346. return $it;
  347. }
  348. function updateStatusItem(id, html) {
  349. $('.of-status-item[data-sid="' + id + '"]').html(html);
  350. }
  351. /* ---------- dirty / drafts ---------- */
  352. function draftKey() {
  353. return "officeDraft_" + cfg.appType + "_" + (filepath || "untitled");
  354. }
  355. function saveDraft() {
  356. try {
  357. var env = buildEnvelopeNoBump();
  358. var s = JSON.stringify({ t: now(), fp: filepath, fn: filename, env: env });
  359. if (s.length > DRAFT_MAX_SIZE) return;
  360. localStorage.setItem(draftKey(), s);
  361. } catch (e) { }
  362. }
  363. function buildEnvelopeNoBump() {
  364. // snapshot without touching revision/modified metadata
  365. return {
  366. type: ENVELOPE_TYPE, app: cfg.appType, version: 1,
  367. meta: meta || {}, body: cfg.serialize()
  368. };
  369. }
  370. function clearDraft() {
  371. try { localStorage.removeItem(draftKey()); } catch (e) { }
  372. }
  373. function pruneDrafts() {
  374. try {
  375. var kill = [];
  376. for (var i = 0; i < localStorage.length; i++) {
  377. var k = localStorage.key(i);
  378. if (k && k.indexOf("officeDraft_") === 0) {
  379. try {
  380. var d = JSON.parse(localStorage.getItem(k));
  381. if (!d.t || now() - d.t > DRAFT_MAX_AGE) kill.push(k);
  382. } catch (e) { kill.push(k); }
  383. }
  384. }
  385. kill.forEach(function (k) { localStorage.removeItem(k); });
  386. } catch (e) { }
  387. }
  388. function checkDraft() {
  389. var raw = null;
  390. try { raw = localStorage.getItem(draftKey()); } catch (e) { }
  391. if (!raw) return;
  392. var d;
  393. try { d = JSON.parse(raw); } catch (e) { clearDraft(); return; }
  394. if (!d || !d.env || !d.env.body) { clearDraft(); return; }
  395. var when = new Date(d.t).toLocaleString();
  396. confirmDialog("Recover unsaved changes?",
  397. "A recovered version of this document from <b>" + escapeHtml(when) +
  398. "</b> was found. It may contain changes that were never saved.",
  399. "Restore", "Discard",
  400. function (restore) {
  401. if (restore) {
  402. meta = d.env.meta || meta;
  403. cfg.deserialize(d.env.body);
  404. markDirty();
  405. setStatus("Recovered draft restored - remember to save");
  406. } else {
  407. clearDraft();
  408. }
  409. });
  410. }
  411. function markDirty() {
  412. editSeq++;
  413. if (!dirty) { dirty = true; updateTitle(); }
  414. clearTimeout(draftTimer);
  415. draftTimer = setTimeout(saveDraft, 2500);
  416. if (cfg) refreshQat();
  417. }
  418. function markClean() {
  419. dirty = false;
  420. clearTimeout(draftTimer);
  421. clearDraft();
  422. updateTitle();
  423. if (cfg) refreshQat();
  424. }
  425. /* ---------- recent documents ---------- */
  426. /* A recent entry is only useful where the path survives the page: the
  427. standalone build opens File objects the visitor picked, which are gone
  428. on reload, so it keeps no list rather than offering dead entries. */
  429. function getRecents() {
  430. if (!OfficePlatform.tracksRecents()) return [];
  431. return getSetting("recent", []);
  432. }
  433. function addRecent(fp, fn) {
  434. if (!OfficePlatform.tracksRecents()) return;
  435. var list = getRecents().filter(function (r) { return r.fp !== fp; });
  436. list.unshift({ fp: fp, fn: fn, t: now() });
  437. setSetting("recent", list.slice(0, RECENT_MAX));
  438. }
  439. /* ---------- document lifecycle ---------- */
  440. /*
  441. Foreign binary formats (.docx / .xlsx / .pptx / ODF) go through the Go
  442. converters - server side in ArozOS, in the WebAssembly module in a
  443. standalone build made with them. Reading the list through here rather
  444. than off cfg keeps one decision in one place: with no converters they
  445. are neither offered in the Open dialog's filter nor accepted by
  446. openPath.
  447. */
  448. function binaryImporters() {
  449. if (!OfficePlatform.canConvert()) return {};
  450. return cfg.binaryImporters || {};
  451. }
  452. /*
  453. The legacy binary format of the app's kind (cfg.legacyExtensions:
  454. .doc for Docs, .xls for Sheets, .ppt for Slides). Such a file opens
  455. through the platform's documentLoad like the app's own format - the
  456. Go converters turn it into OOXML first, server side in ArozOS, in the
  457. WebAssembly module in a standalone build - but it is an import: the
  458. document is not attached to the old file, and Save asks where to put
  459. it in the app's own format. Like binaryImporters, it needs the
  460. converters.
  461. */
  462. function legacyExtensions() {
  463. if (!OfficePlatform.canConvert()) return [];
  464. return cfg.legacyExtensions || [];
  465. }
  466. function isLegacyExt(ext) { return legacyExtensions().indexOf(ext) >= 0; }
  467. function loadLegacyEnvelope(env, fp, fn) {
  468. if (!env || env.type !== ENVELOPE_TYPE || env.app !== cfg.appType) {
  469. throw new Error("Not a valid " + cfg.fileTypeName + " file");
  470. }
  471. meta = { createdAt: now(), revision: 0 };
  472. adoptImportedPath(fp, fn);
  473. cfg.deserialize(env.body);
  474. markClean();
  475. // the old file stays in "recent": opening it again imports it again
  476. addRecent(fp, fn);
  477. setStatus(importedStatus(fn));
  478. documentLoaded();
  479. }
  480. function loadNativeEnvelope(env, fp, fn, opts) {
  481. if (!env || env.type !== ENVELOPE_TYPE || env.app !== cfg.appType) {
  482. throw new Error("Not a valid " + cfg.fileTypeName + " file");
  483. }
  484. meta = env.meta || {};
  485. loadedFromImport = false;
  486. openingName = null;
  487. sourceName = null;
  488. /*
  489. A template is a starting point, not a file the person is editing:
  490. the content loads but the document stays unattached, so Ctrl+S
  491. asks where to put it instead of writing back over the template.
  492. The name is kept as a suggestion for that dialog.
  493. */
  494. if (opts && opts.asTemplate) {
  495. var pretty = titleCase(stripExt(fn));
  496. filepath = null;
  497. filename = pretty + cfg.extension;
  498. meta = { createdAt: now(), revision: 0 };
  499. cfg.deserialize(env.body);
  500. markClean();
  501. setStatus("New " + cfg.fileTypeName.toLowerCase() +
  502. " from the " + pretty + " template");
  503. documentLoaded();
  504. return;
  505. }
  506. filepath = fp; filename = fn;
  507. cfg.deserialize(env.body);
  508. markClean();
  509. addRecent(fp, fn);
  510. setStatus("Opened " + fn);
  511. documentLoaded();
  512. checkDraft();
  513. }
  514. function loadNativeText(text, fp, fn, opts) {
  515. loadNativeEnvelope(parseEnvelope(text), fp, fn, opts);
  516. }
  517. /*
  518. A file opened from a foreign format stays attached to that file when
  519. the app declared a writer for its extension (cfg.saveFormats): Save
  520. then writes back in the original format, and only steps up to the
  521. app's own format once the document outgrows it (see doSaveForeign).
  522. With no writer the import is read-only and Save has to become Save As
  523. - which is what every app did before saveFormats existed.
  524. */
  525. function adoptImportedPath(fp, fn) {
  526. loadedFromImport = true;
  527. openingName = null;
  528. sourceName = null;
  529. if (saveFormatFor(extOf(fn))) {
  530. filepath = fp;
  531. filename = fn;
  532. } else {
  533. filepath = null; // read-only import: force Save As on save
  534. filename = stripExt(fn) + cfg.extension;
  535. sourceName = fn;
  536. }
  537. }
  538. function importedStatus(fn) {
  539. return filepath ? ("Opened " + fn) :
  540. ("Imported " + fn + " - use Save to store it as " + cfg.extension);
  541. }
  542. function loadImportText(text, fp, fn) {
  543. var ext = extOf(fn);
  544. var importer = (cfg.importers || {})[ext];
  545. if (!importer) { setStatus("Unsupported file type " + ext, "error"); return; }
  546. meta = { createdAt: now(), revision: 0 };
  547. adoptImportedPath(fp, fn);
  548. importer(text, fn);
  549. dirty = false; updateTitle();
  550. if (filepath) addRecent(filepath, filename);
  551. setStatus(importedStatus(fn));
  552. documentLoaded();
  553. }
  554. var openingName = null; // the file being opened, until it is in
  555. /* The window is titled after the file from the moment it starts to
  556. open - a large document can take a while, and "New Document" in the
  557. title meanwhile says the wrong thing. A failed open goes back. */
  558. function showOpening(fn, opts) {
  559. openingName = (opts && opts.asTemplate) ? titleCase(stripExt(fn)) + cfg.extension : fn;
  560. updateTitle();
  561. }
  562. function openFailed() {
  563. openingName = null;
  564. updateTitle();
  565. }
  566. function openPath(fp, fn, opts) {
  567. fn = fn || basename(fp);
  568. var ext = extOf(fn);
  569. showOpening(fn, opts);
  570. // a template (templates/*.json) is a plain envelope, not an Office
  571. // file: it is read as text in both hosts and needs no conversion
  572. if (opts && opts.asTemplate && ext === ".json") {
  573. setStatus("Opening " + fn + "...", "info", 0);
  574. vfsLoad(fp, function (text) {
  575. splashStep("Preparing " + fn + "...", function () {
  576. try {
  577. loadNativeText(text, fp, fn, opts);
  578. } catch (err) {
  579. openFailed();
  580. setStatus("Cannot open " + fn + ": " + err.message, "error");
  581. }
  582. });
  583. }, function () {
  584. openFailed();
  585. setStatus("Failed to load " + fn, "error");
  586. });
  587. return;
  588. }
  589. // binary foreign formats (e.g. .odt) are handled by the app itself,
  590. // usually through a server-side AGI conversion - no text fetch here
  591. var bi = binaryImporters()[ext];
  592. if (bi) {
  593. meta = { createdAt: now(), revision: 0 };
  594. adoptImportedPath(fp, fn);
  595. dirty = false;
  596. updateTitle();
  597. if (filepath) addRecent(filepath, filename);
  598. bi(fp, fn);
  599. return;
  600. }
  601. if (isLegacyExt(ext)) {
  602. setStatus("Converting " + fn + "...", "info", 0);
  603. OfficePlatform.documentLoad(fp, function (envelope) {
  604. splashStep("Preparing " + fn + "...", function () {
  605. var env = envelope;
  606. if (typeof env === "string") {
  607. try { env = JSON.parse(env); } catch (e) { env = null; }
  608. }
  609. try {
  610. loadLegacyEnvelope(env, fp, fn);
  611. } catch (err) {
  612. openFailed();
  613. setStatus("Cannot open " + fn + ": " + err.message, "error");
  614. }
  615. });
  616. }, function (msg) {
  617. openFailed();
  618. setStatus("Failed to open " + fn + ": " + (msg || "unknown error"), "error");
  619. });
  620. return;
  621. }
  622. setStatus("Opening " + fn + "...", "info", 0);
  623. /* The app's own format: the platform turns the .docx / .xlsx / .pptx
  624. into the envelope - the editor copy the suite embedded when it
  625. saved the file, or an import of the OOXML when another program
  626. wrote it (server side in ArozOS, WebAssembly in the web edition). */
  627. if (ext === cfg.extension) {
  628. OfficePlatform.documentLoad(fp, function (envelope) {
  629. splashStep("Preparing " + fn + "...", function () {
  630. var env = envelope;
  631. if (typeof env === "string") {
  632. try { env = JSON.parse(env); } catch (e) { env = null; }
  633. }
  634. try {
  635. loadNativeEnvelope(env, fp, fn, opts);
  636. } catch (err) {
  637. openFailed();
  638. setStatus("Cannot open " + fn + ": " + err.message, "error");
  639. }
  640. });
  641. }, function (msg) {
  642. openFailed();
  643. setStatus("Failed to open " + fn + ": " + (msg || "unknown error"), "error");
  644. });
  645. return;
  646. }
  647. vfsLoad(fp, function (text) {
  648. splashStep("Preparing " + fn + "...", function () {
  649. try {
  650. loadImportText(text, fp, fn);
  651. } catch (err) {
  652. openFailed();
  653. setStatus("Cannot open " + fn + ": " + err.message, "error");
  654. }
  655. });
  656. }, function () {
  657. openFailed();
  658. setStatus("Failed to load " + fn, "error");
  659. });
  660. }
  661. /*
  662. A host that keeps each document in a window of its own (the desktop
  663. shell: OfficePlatform.prefersNewWindows) opens another document
  664. beside this one once this window holds something - a file, or
  665. unsaved work - the way a desktop app does. An empty untitled window
  666. is simply reused. Elsewhere the document replaces this one.
  667. */
  668. function holdsDocument() { return !!filepath || dirty; }
  669. function openElsewhere(fp, fn) {
  670. if (!OfficePlatform.prefersNewWindows() || !holdsDocument()) return false;
  671. return OfficePlatform.openDocument(fp, fn, { appIcon: cfg.appIcon });
  672. }
  673. function newDocument() {
  674. if (OfficePlatform.prefersNewWindows() && holdsDocument() &&
  675. OfficePlatform.openNewWindow()) {
  676. return;
  677. }
  678. var go = function () {
  679. filepath = null;
  680. filename = null;
  681. sourceName = null;
  682. meta = { createdAt: now(), revision: 0 };
  683. loadedFromImport = false;
  684. cfg.create();
  685. markClean();
  686. setStatus("New " + cfg.fileTypeName.toLowerCase() + " created");
  687. };
  688. if (dirty) {
  689. confirmDialog("Discard unsaved changes?",
  690. "The current document has unsaved changes that will be lost.",
  691. "Discard", "Cancel", function (yes) { if (yes) go(); });
  692. } else { go(); }
  693. }
  694. function openDialog() {
  695. var go = function () {
  696. var filter = [cfg.extension.substring(1)];
  697. (Object.keys(cfg.importers || {})).forEach(function (e) {
  698. filter.push(e.substring(1));
  699. });
  700. (Object.keys(binaryImporters())).forEach(function (e) {
  701. filter.push(e.substring(1));
  702. });
  703. legacyExtensions().forEach(function (e) {
  704. filter.push(e.substring(1));
  705. });
  706. OfficePlatform.pickOpen({ filter: filter, memoryKey: "document" }, function (files) {
  707. if (openElsewhere(files[0].filepath, files[0].filename)) return;
  708. openPath(files[0].filepath, files[0].filename);
  709. });
  710. };
  711. // nothing is discarded when the document goes to a window of its own
  712. if (dirty && !OfficePlatform.prefersNewWindows()) {
  713. confirmDialog("Discard unsaved changes?",
  714. "The current document has unsaved changes that will be lost.",
  715. "Discard", "Cancel", function (yes) { if (yes) go(); });
  716. } else { go(); }
  717. }
  718. /* ---------- foreign save formats (cfg.saveFormats) ---------- */
  719. /*
  720. An app may declare formats it can write besides its own (.docx / .xlsx / .pptx):
  721. saveFormats: [{
  722. ext: ".csv", label: "CSV (.csv)", icon: "file alternate outline",
  723. oneWay: true, // a rendering (PDF):
  724. // never becomes the file
  725. // the document lives in
  726. unsupported: fn -> null | [reason, ...],
  727. save: fn(fp, fn, done, fail(msg))
  728. }]
  729. They drive both Save As (a format picker) and Save (a document opened
  730. from one of these formats writes straight back to it). Apps that
  731. declare none keep the native-format-only behaviour unchanged.
  732. */
  733. /*
  734. Two flags mark a writer that cannot exist everywhere:
  735. needsConvert goes through the Office format converters (.xlsx,
  736. .ods, ...) - present in ArozOS, and in a standalone
  737. build shipped with the WebAssembly module
  738. needsBackend needs a server outright (the real-text PDF renderer)
  739. Filtering here rather than in each app keeps Save As, the
  740. save-back-into-a-foreign-format path and autosave consistent.
  741. */
  742. function saveFormats() {
  743. var list = (cfg && cfg.saveFormats) || [];
  744. return list.filter(function (f) {
  745. if (f.needsBackend && !OfficePlatform.hasBackend()) return false;
  746. if (f.needsConvert && !OfficePlatform.canConvert()) return false;
  747. return true;
  748. });
  749. }
  750. function findSaveFormat(ext, adoptableOnly) {
  751. var list = saveFormats();
  752. for (var i = 0; i < list.length; i++) {
  753. if (list[i].ext !== ext) continue;
  754. return (adoptableOnly && list[i].oneWay) ? null : list[i];
  755. }
  756. return null;
  757. }
  758. // a writer whose output the document can go on living in (excludes PDF)
  759. function saveFormatFor(ext) { return findSaveFormat(ext, true); }
  760. function formatLabel(fmt) { return (fmt && (fmt.label || fmt.ext)) || nativeLabel(); }
  761. // "Word document (.docx)" - what the app's own format is called
  762. function nativeLabel() {
  763. return cfg.nativeLabel || (cfg.fileTypeName + " (" + cfg.extension + ")");
  764. }
  765. function formatReasons(fmt) {
  766. if (!fmt || !fmt.unsupported) return null;
  767. var r;
  768. try { r = fmt.unsupported(); } catch (e) { return null; }
  769. return (r && r.length) ? r : null;
  770. }
  771. /*
  772. A foreign format holds less than the app's own format does, so it may
  773. refuse a document outright instead of silently dropping content. The
  774. reasons come from the app as plain strings - escaped here, never
  775. trusted as markup.
  776. */
  777. function formatBlockedDialog(fmt, reasons) {
  778. var label = formatLabel(fmt);
  779. var list = reasons.map(function (t) {
  780. return "<li>" + escapeHtml(t) + "</li>";
  781. }).join("");
  782. setStatus("Cannot save in " + label + " format", "error");
  783. dialog({
  784. title: "Cannot save as " + label,
  785. body: "<p>This " + escapeHtml(cfg.fileTypeName.toLowerCase()) +
  786. " uses features that " + escapeHtml(label) + " cannot store:</p>" +
  787. "<ul>" + list + "</ul>" +
  788. "<p>Save it as " + escapeHtml(cfg.extension) +
  789. " to keep them, or use File &gt; Export for a lossy copy.</p>",
  790. buttons: [
  791. { label: "Cancel" },
  792. {
  793. label: "Save as " + cfg.extension + "...", primary: true,
  794. action: function (close) { close(); saveAsFormat(null); }
  795. }
  796. ]
  797. });
  798. }
  799. function doSaveForeign(fp, fn, fmt, cb, silent, obs) {
  800. var reasons = formatReasons(fmt);
  801. if (reasons) {
  802. // autosave must never interrupt with a dialog: it skips the write
  803. // and lets the session snapshot be the safety net instead
  804. if (!silent) formatBlockedDialog(fmt, reasons);
  805. return false;
  806. }
  807. if (cfg.onBeforeSave) { try { cfg.onBeforeSave(); } catch (e) { } }
  808. setStatus("Saving...", "info", 0);
  809. if (obs) obs.progress("server");
  810. var seq = editSeq;
  811. saveBusy = true;
  812. fmt.save(fp, fn, function () {
  813. setStatus("Saved " + fn);
  814. // a one-way rendering is a copy, not the document's own file - the
  815. // editor keeps its path and stays dirty
  816. if (!fmt.oneWay) {
  817. filepath = fp; filename = fn;
  818. loadedFromImport = false;
  819. savedClean(seq);
  820. addRecent(fp, fn);
  821. saveSession();
  822. }
  823. finishSave();
  824. if (cb) cb();
  825. }, function (err) {
  826. setStatus("Save failed: " + err, "error");
  827. finishSave();
  828. if (obs) obs.fail(err);
  829. });
  830. return true;
  831. }
  832. // the file on disk has what the document held when the save began: it is
  833. // clean only if nobody edited since
  834. function savedClean(seq) {
  835. if (seq === editSeq) markClean();
  836. else updateTitle();
  837. }
  838. function finishSave() {
  839. saveBusy = false;
  840. if (queuedSave) {
  841. var q = queuedSave;
  842. queuedSave = null;
  843. doSaveTo(q.fp, q.fn, q.cb, false, q.obs);
  844. }
  845. }
  846. /* obs, when given, follows the write: obs.progress(stage, percent) -
  847. "prepare", "compress", "upload", "server" - and obs.fail(message) when
  848. it does not happen (cb only ever reports success) */
  849. function save(cb, obs) {
  850. if (!filepath) { saveAs(cb, obs); return; }
  851. if (!doSaveTo(filepath, filename, cb, false, obs) && obs) obs.fail(null);
  852. }
  853. function saveAs(cb, obs) { saveAsFormat(null, cb, obs); }
  854. /*
  855. `fmt` is a cfg.saveFormats entry, or null for the native format. The
  856. format fixes the extension, so the picker only supplies the name.
  857. */
  858. function saveAsFormat(fmt, cb, obs) {
  859. var ext = fmt ? fmt.ext : cfg.extension;
  860. // ask before making the user pick a filename, not after
  861. var reasons = formatReasons(fmt);
  862. if (reasons) { formatBlockedDialog(fmt, reasons); return; }
  863. var defName = stripExt(filename || cfg.defaultFileName) + ext;
  864. OfficePlatform.pickSave({
  865. defaultName: defName,
  866. ext: ext,
  867. memoryKey: "document",
  868. //A document that has never been saved has no folder of its own, so start
  869. //from wherever this app was last used rather than the hardcoded Desktop
  870. forceOverwrite: !filepath
  871. }, function (file) {
  872. if (!doSaveTo(file.filepath, file.filename, cb, false, obs) && obs) obs.fail(null);
  873. });
  874. }
  875. /*
  876. Write the document into the app's own Office format at fp. Shared by
  877. Save / Save As and by the convert-to-native action, which writes a
  878. copy without moving the open document onto it - so this deliberately
  879. touches nothing but the file. Returns false when the envelope could
  880. not even be built.
  881. cfg.prepareNative(body) gets a private copy of the body to finish for
  882. the OOXML writer - render a chart to PNG, turn an SVG into something
  883. Word can hold - and may return a Promise. What it leaves in the copy
  884. is both what the .docx / .xlsx / .pptx shows and the editor copy
  885. embedded in it, so a preparation must only add, never take away.
  886. */
  887. function writeNative(fp, done, fail, progress) {
  888. var env;
  889. try {
  890. env = buildEnvelope();
  891. env.body = JSON.parse(JSON.stringify(env.body));
  892. } catch (e) { fail(e.message); return false; }
  893. var prep;
  894. try { prep = cfg.prepareNative ? cfg.prepareNative(env.body) : null; }
  895. catch (e) { fail(e.message || "could not prepare the document"); return true; }
  896. if (progress) progress("prepare");
  897. Promise.resolve(prep).then(function () {
  898. OfficePlatform.documentSave(fp, JSON.stringify(env), done, fail, progress);
  899. }, function (err) {
  900. fail((err && err.message) ? err.message : "could not prepare the document");
  901. });
  902. return true;
  903. }
  904. // returns false when the write was declined (format cannot hold the doc)
  905. function doSaveTo(fp, fn, cb, silent, obs) {
  906. if (saveBusy) {
  907. // autosave simply skips a tick; an explicit save runs next
  908. if (silent) return true;
  909. queuedSave = { fp: fp, fn: fn, cb: cb, obs: obs };
  910. if (obs) obs.progress("wait");
  911. setStatus("Saving...", "info", 0);
  912. return true;
  913. }
  914. var ext = extOf(fn);
  915. if (ext !== cfg.extension) {
  916. // one of the app's own foreign formats - including a document
  917. // opened from one and saved straight back with Ctrl+S
  918. var fmt = findSaveFormat(ext, false);
  919. if (fmt) {
  920. // a writer too expensive to run on a timer (rasterizing
  921. // charts, seeking videos for a poster frame, refetching every
  922. // image) stays out of autosave; the caller falls back to the
  923. // session snapshot, which is what that safety net is for
  924. if (silent && fmt.noAutosave) return false;
  925. return doSaveForeign(fp, fn, fmt, cb, silent, obs);
  926. }
  927. }
  928. if (cfg.onBeforeSave) { try { cfg.onBeforeSave(); } catch (e) { } }
  929. setStatus("Saving...", "info", 0);
  930. var seq = editSeq;
  931. saveBusy = true;
  932. var started = writeNative(fp, function () {
  933. filepath = fp; filename = fn;
  934. loadedFromImport = false;
  935. savedClean(seq);
  936. addRecent(fp, fn);
  937. setStatus("Saved " + fn);
  938. // the file now holds what the snapshot would: drop the snapshot
  939. // (one tiny request) rather than uploading the document a second
  940. // time, which doubled every save on a slow link and offered to
  941. // "restore" a document that was already saved
  942. deleteSession();
  943. finishSave();
  944. if (cb) cb();
  945. }, function (err) {
  946. setStatus("Save failed: " + err, "error");
  947. finishSave();
  948. if (obs) obs.fail(err);
  949. }, obs ? obs.progress : null);
  950. if (!started) saveBusy = false;
  951. return started;
  952. }
  953. /* ---------- foreign-format banner ---------- */
  954. /*
  955. A document opened from .docx / .pptx / .csv / ... goes on living in
  956. that file: Save rewrites it in its own format rather than quietly
  957. turning it into the app's own format (see doSaveTo). That is the right
  958. default - somebody who opened a .docx wants a .docx back - but it
  959. also means every ArozOS Office feature the foreign format cannot hold
  960. is being dropped on each save, which is worth saying out loud once
  961. rather than only at the moment content is lost.
  962. So the editor carries a banner under the toolbar for as long as the
  963. open file is not native, offering the one-click way out: convert to
  964. the app's own format. Native documents never see any of this.
  965. */
  966. var bannerDismissed = null; // filepath the banner was dismissed for
  967. // the format the open document currently lives in, or null when that is
  968. // the app's own format (or nothing on disk yet)
  969. function foreignFormat() {
  970. if (!filepath || !filename) return null;
  971. var ext = extOf(filename);
  972. if (!ext || ext === cfg.extension) return null;
  973. return { ext: ext, fmt: findSaveFormat(ext, true) };
  974. }
  975. function buildForeignBanner() {
  976. var $b = $('<div class="of-fmtbanner of-noprint" style="display:none;"></div>');
  977. $b.append('<i class="exclamation triangle icon"></i>');
  978. $b.append('<span class="of-fmtbanner-msg"></span>');
  979. $b.append($('<button type="button" class="of-fmtbanner-btn"></button>')
  980. .html('<i class="exchange icon"></i>Convert to ' + escapeHtml(cfg.extension))
  981. .attr("title", "Save a copy as a " + nativeLabel() + " and open it")
  982. .on("click", convertToNative));
  983. $b.append($('<button type="button" class="of-fmtbanner-x" title="Dismiss">×</button>')
  984. .on("click", function () {
  985. bannerDismissed = filepath;
  986. $(".of-fmtbanner").hide();
  987. }));
  988. return $b;
  989. }
  990. function updateForeignBanner() {
  991. var $b = $(".of-fmtbanner");
  992. if (!$b.length) return;
  993. var f = foreignFormat();
  994. if (!f || bannerDismissed === filepath) { $b.hide(); return; }
  995. var what = f.fmt ? formatLabel(f.fmt) : (f.ext + " file");
  996. $b.find(".of-fmtbanner-msg").html(
  997. "This is a <b>" + escapeHtml(what) + "</b>, not a " +
  998. escapeHtml(nativeLabel()) + ". " +
  999. "Anything " + escapeHtml(f.ext) + " cannot store is lost on save.");
  1000. $b.css("display", "");
  1001. }
  1002. /*
  1003. Write the document out in the app's own format and open that copy in a
  1004. window of its own. The editor stays on the original foreign file: the
  1005. converted document is a new file, and which of the two to go on
  1006. working in is the person's call, not ours.
  1007. */
  1008. function convertToNative() {
  1009. var defName = stripExt(filename || cfg.defaultFileName) + cfg.extension;
  1010. OfficePlatform.pickSave({
  1011. defaultName: defName,
  1012. ext: cfg.extension,
  1013. memoryKey: "document"
  1014. }, function (file) {
  1015. if (cfg.onBeforeSave) { try { cfg.onBeforeSave(); } catch (e) { } }
  1016. setStatus("Converting to " + cfg.extension + "...", "info", 0);
  1017. writeNative(file.filepath, function () {
  1018. setStatus("Converted to " + file.filename);
  1019. addRecent(file.filepath, file.filename);
  1020. // the question has been answered for this file either way
  1021. bannerDismissed = filepath;
  1022. updateForeignBanner();
  1023. var opened = OfficePlatform.openDocument(file.filepath, file.filename,
  1024. { appIcon: cfg.appIcon });
  1025. toast(opened ? ("Converted - " + file.filename + " opened in a new window")
  1026. : ("Converted - saved as " + file.filename));
  1027. }, function (err) {
  1028. setStatus("Convert failed: " + err, "error");
  1029. toast("Convert failed: " + err, "error");
  1030. });
  1031. });
  1032. }
  1033. /* ---------- autosave ---------- */
  1034. function autosaveEnabled() { return getSetting("autosave", true); }
  1035. function autosaveTick() {
  1036. // a host with no file system to write back to (the standalone build
  1037. // saves by downloading) must never autosave: the snapshot below is
  1038. // the whole safety net there
  1039. if (dirty && filepath && autosaveEnabled() && OfficePlatform.autosavesToFile()) {
  1040. // a document living in a foreign format is autosaved only while
  1041. // that format can still hold it; when it cannot, doSaveTo's silent
  1042. // mode declines the write rather than interrupting with a dialog,
  1043. // and the session snapshot below protects the work instead
  1044. if (!doSaveTo(filepath, filename, null, true)) saveSession();
  1045. } else if (dirty) {
  1046. // unsaved (or autosave-off) documents still get a session
  1047. // snapshot so "Restore from previous session" can recover them
  1048. saveSession();
  1049. }
  1050. }
  1051. /* ---------- theme ---------- */
  1052. function isDark() {
  1053. try { return localStorage.getItem("office_theme") === "dark"; } catch (e) { return false; }
  1054. }
  1055. function applyTheme() {
  1056. var dark = isDark();
  1057. $("body").toggleClass("dark", dark);
  1058. OfficePlatform.setWindowTheme(dark);
  1059. if (cfg && cfg.onThemeChanged) { try { cfg.onThemeChanged(dark); } catch (e) { } }
  1060. }
  1061. function toggleTheme() {
  1062. try { localStorage.setItem("office_theme", isDark() ? "light" : "dark"); } catch (e) { }
  1063. applyTheme();
  1064. refreshMenuChecks();
  1065. }
  1066. /* ---------- zoom ---------- */
  1067. var ZOOM_LEVELS = [50, 65, 75, 90, 100, 110, 125, 150, 175, 200];
  1068. function applyZoom(transient) {
  1069. if (cfg.zoomTarget) {
  1070. $(cfg.zoomTarget).css("zoom", zoom / 100);
  1071. }
  1072. $(".of-zoom-val").text(zoom + "%");
  1073. // a transient zoom (a phone fitting the page to its width) is not
  1074. // the person's choice and must not become the next session's zoom
  1075. if (!transient) setSetting("zoom", zoom);
  1076. if (cfg.onZoomChanged) { try { cfg.onZoomChanged(zoom); } catch (e) { } }
  1077. }
  1078. function setZoom(z, opts) {
  1079. zoom = Math.max(25, Math.min(400, Math.round(z)));
  1080. applyZoom(!!(opts && opts.transient));
  1081. }
  1082. /*
  1083. Ctrl/Cmd + wheel zooms the document, not the whole ArozOS desktop.
  1084. The browser's own page zoom owns that gesture, so the listener has to
  1085. be non-passive to be allowed to preventDefault it.
  1086. Deltas are accumulated rather than stepped per event: one mouse notch
  1087. is ~100, but a trackpad pinch arrives as a stream of small deltas
  1088. (Chrome reports those as wheel events with ctrlKey set) and stepping
  1089. on each one would rocket through the zoom levels.
  1090. */
  1091. var WHEEL_ZOOM_STEP = 40;
  1092. var wheelAccum = 0;
  1093. function onWheelZoom(e) {
  1094. if (!e.ctrlKey && !e.metaKey) return;
  1095. e.preventDefault();
  1096. // reversing direction should respond at once rather than first
  1097. // having to work off the leftover from the other direction
  1098. if (wheelAccum !== 0 && (wheelAccum < 0) !== (e.deltaY < 0)) wheelAccum = 0;
  1099. wheelAccum += e.deltaY;
  1100. // at most one level per event, so a single wheel notch (~100) is one
  1101. // step while a stream of small pinch deltas still adds up to one
  1102. if (wheelAccum <= -WHEEL_ZOOM_STEP) { wheelAccum = 0; zoomStep(1); }
  1103. else if (wheelAccum >= WHEEL_ZOOM_STEP) { wheelAccum = 0; zoomStep(-1); }
  1104. }
  1105. function zoomStep(dir) {
  1106. var i;
  1107. if (dir > 0) {
  1108. for (i = 0; i < ZOOM_LEVELS.length; i++) {
  1109. if (ZOOM_LEVELS[i] > zoom) { setZoom(ZOOM_LEVELS[i]); return; }
  1110. }
  1111. setZoom(zoom + 25);
  1112. } else {
  1113. for (i = ZOOM_LEVELS.length - 1; i >= 0; i--) {
  1114. if (ZOOM_LEVELS[i] < zoom) { setZoom(ZOOM_LEVELS[i]); return; }
  1115. }
  1116. setZoom(Math.max(25, zoom - 10));
  1117. }
  1118. }
  1119. /* ---------- keyboard shortcuts ---------- */
  1120. function normalizeCombo(c) {
  1121. var parts = String(c).split("+").map(function (p) { return p.trim(); });
  1122. var mods = { ctrl: false, alt: false, shift: false };
  1123. var key = "";
  1124. parts.forEach(function (p) {
  1125. var l = p.toLowerCase();
  1126. if (l === "ctrl" || l === "cmd" || l === "meta") mods.ctrl = true;
  1127. else if (l === "alt") mods.alt = true;
  1128. else if (l === "shift") mods.shift = true;
  1129. else key = l;
  1130. });
  1131. return (mods.ctrl ? "ctrl+" : "") + (mods.alt ? "alt+" : "") + (mods.shift ? "shift+" : "") + key;
  1132. }
  1133. function comboFromEvent(e) {
  1134. var key = e.key;
  1135. if (!key) return "";
  1136. if (key === " ") key = "space";
  1137. key = key.toLowerCase();
  1138. if (key === "escape") key = "esc";
  1139. return ((e.ctrlKey || e.metaKey) ? "ctrl+" : "") + (e.altKey ? "alt+" : "") +
  1140. (e.shiftKey ? "shift+" : "") + key;
  1141. }
  1142. // Registers through the shared OfficeHotkeys registry when loaded
  1143. // (hotkeys.js); the local map only remains as a standalone fallback.
  1144. // opts (optional) pass straight through: description/group make the
  1145. // binding show up in the Ctrl+/ help dialog, when() gates it.
  1146. function registerShortcut(combo, handler, opts) {
  1147. if (window.OfficeHotkeys) {
  1148. return OfficeHotkeys.register(combo, function (e) {
  1149. closeAllMenus();
  1150. return handler(e);
  1151. }, $.extend({ allowInInput: true, inDialogs: true, group: "General" }, opts || {}));
  1152. }
  1153. shortcuts[normalizeCombo(combo)] = handler;
  1154. }
  1155. function handleKeydown(e) {
  1156. var combo = comboFromEvent(e);
  1157. var h = shortcuts[combo];
  1158. if (h) {
  1159. e.preventDefault();
  1160. e.stopPropagation();
  1161. closeAllMenus();
  1162. h(e);
  1163. return;
  1164. }
  1165. if (combo === "esc") closeAllMenus();
  1166. }
  1167. /* ---------- menus (menubar drops, context menus, floating submenus) ---------- */
  1168. /*
  1169. Submenus are NOT nested in their parent menu element - parents may
  1170. scroll (overflow-y) which would clip them. Instead every submenu is
  1171. a body-level ".of-float-menu" tagged with its depth; opening a menu
  1172. at depth N closes every float menu at depth >= N.
  1173. */
  1174. function closeFloatMenus(minDepth) {
  1175. minDepth = minDepth || 0;
  1176. $(".of-float-menu").each(function () {
  1177. if (($(this).data("depth") || 0) >= minDepth) $(this).remove();
  1178. });
  1179. if (minDepth === 0) $(".of-menu-item.of-has-sub").removeClass("open");
  1180. }
  1181. function closeAllMenus() {
  1182. $(".of-menu").removeClass("open");
  1183. closeFloatMenus(0);
  1184. if (window.OfficeRibbon) OfficeRibbon.closePopups();
  1185. }
  1186. function positionFloatMenu($m, x, y) {
  1187. var w = $m.outerWidth(), h = $m.outerHeight();
  1188. if (x + w > window.innerWidth) x = Math.max(4, window.innerWidth - w - 4);
  1189. if (y + h > window.innerHeight) y = Math.max(4, window.innerHeight - h - 4);
  1190. $m.css({ left: x + "px", top: y + "px" });
  1191. }
  1192. function openFloatSubmenu($it, sub, depth) {
  1193. closeFloatMenus(depth);
  1194. $it.closest(".of-menu-drop, .of-float-menu").find(".of-has-sub").removeClass("open");
  1195. $it.addClass("open");
  1196. var items = (typeof sub === "function") ? sub() : sub;
  1197. var $sm = $('<div class="of-context-menu of-float-menu"></div>').data("depth", depth);
  1198. renderMenuItems($sm, items, depth);
  1199. if (!items || items.length === 0) {
  1200. $sm.append('<div class="of-menu-item disabled"><span style="width:16px;"></span><span class="of-mi-label">(empty)</span></div>');
  1201. }
  1202. $("body").append($sm);
  1203. var r = $it[0].getBoundingClientRect();
  1204. var w = $sm.outerWidth();
  1205. var x = r.right + 2;
  1206. if (x + w > window.innerWidth) x = Math.max(4, r.left - w - 2);
  1207. positionFloatMenu($sm, x, r.top - 4);
  1208. }
  1209. /*
  1210. With foreign writers declared, "Save as" becomes a format picker whose
  1211. first entry is the app's own format; without them it stays the plain
  1212. Save As command it has always been.
  1213. */
  1214. function saveAsMenuItem() {
  1215. var list = saveFormats();
  1216. if (!list.length) {
  1217. return {
  1218. label: "Save as...", icon: "copy outline", key: "Ctrl+Shift+S",
  1219. action: function () { saveAs(); }
  1220. };
  1221. }
  1222. var sub = [
  1223. {
  1224. label: nativeLabel(),
  1225. icon: "save", key: "Ctrl+Shift+S",
  1226. action: function () { saveAsFormat(null); }
  1227. },
  1228. { sep: true }
  1229. ];
  1230. list.forEach(function (f) {
  1231. // hidden: a writer that only exists to save a document back into
  1232. // the file it came from (a second extension for the same format)
  1233. if (f.hidden) return;
  1234. sub.push({
  1235. label: formatLabel(f), icon: f.icon || "file outline",
  1236. action: function () { saveAsFormat(f); }
  1237. });
  1238. });
  1239. return { label: "Save as", icon: "copy outline", sub: sub };
  1240. }
  1241. function renderMenuItems($drop, items, depth) {
  1242. depth = depth || 0;
  1243. $drop.empty();
  1244. (items || []).forEach(function (it) {
  1245. if (!it) return;
  1246. if (it.sep) { $drop.append('<div class="of-menu-sep"></div>'); return; }
  1247. var $it = $('<div class="of-menu-item"></div>');
  1248. if (it.checked !== undefined) {
  1249. var on = (typeof it.checked === "function") ? it.checked() : it.checked;
  1250. $it.append('<span class="of-mi-check">' + (on ? "✓" : "") + "</span>");
  1251. } else if (it.icon) {
  1252. $it.append('<i class="' + escapeHtml(it.icon) + ' icon"></i>');
  1253. } else {
  1254. $it.append('<span style="width:16px;"></span>');
  1255. }
  1256. // it.html: a picture of the choice (a line sample, a swatch) the
  1257. // app builds itself - trusted markup, never user content; the
  1258. // label stays as the tooltip
  1259. if (it.html) {
  1260. $it.append($('<span class="of-mi-label of-mi-html"></span>').html(it.html))
  1261. .attr("title", it.label || "");
  1262. } else {
  1263. $it.append('<span class="of-mi-label">' + escapeHtml(it.label) + "</span>");
  1264. }
  1265. if (it.key) $it.append('<span class="of-mi-key">' + escapeHtml(it.key) + "</span>");
  1266. if (typeof it.enabled === "function" && !it.enabled()) $it.addClass("disabled");
  1267. if (it.sub) {
  1268. $it.addClass("of-has-sub");
  1269. var open = function () { openFloatSubmenu($it, it.sub, depth + 1); };
  1270. $it.on("mouseenter", open);
  1271. $it.on("click", function (ev) { ev.stopPropagation(); open(); });
  1272. } else {
  1273. // hovering a plain item closes any deeper submenu
  1274. $it.on("mouseenter", function () { closeFloatMenus(depth + 1); });
  1275. if (it.action) {
  1276. $it.on("click", function (ev) {
  1277. ev.stopPropagation();
  1278. closeAllMenus();
  1279. it.action();
  1280. });
  1281. }
  1282. }
  1283. $drop.append($it);
  1284. });
  1285. }
  1286. /*
  1287. The title bar, Office style: app icon, the quick access buttons
  1288. (Save, Undo, Redo, the AutoSave switch), then the File menu and the
  1289. ribbon's tabs (common/ribbon.js) - or, for an app that declares no
  1290. ribbon, its classic menus - and the document name on the right.
  1291. It keeps the .of-menubar class: the menu code keys off it.
  1292. */
  1293. function refreshQat() {
  1294. $(".of-qat .of-rb-switch").each(function () {
  1295. var s = $(this).data("sync");
  1296. if (s) s();
  1297. });
  1298. }
  1299. function buildMenubar(menus) {
  1300. var $bar = $('<div class="of-menubar of-titlebar of-noprint"></div>');
  1301. $bar.append('<img class="of-appicon" src="' + escapeHtml(cfg.appIcon || "../img/docs.svg") + '" alt="">');
  1302. // Save, Undo and Redo live in the File and Edit menus (and on
  1303. // their shortcuts); the title bar keeps only the AutoSave switch
  1304. var $qat = $('<div class="of-qat"></div>');
  1305. if (window.OfficeRibbon) {
  1306. $qat.append(OfficeRibbon.toggle({
  1307. label: "AutoSave", cls: "of-autosave",
  1308. title: OfficePlatform.autosavesToFile() ?
  1309. "AutoSave: write changes to the file as you work" :
  1310. "AutoSave: keep a recovery copy as you work",
  1311. get: autosaveEnabled,
  1312. set: function (on) { setSetting("autosave", !!on); }
  1313. }));
  1314. }
  1315. $bar.append($qat);
  1316. $bar.append('<div class="of-tsep of-qatsep"></div>');
  1317. var $menus = $('<div class="of-tabstrip"></div>');
  1318. menus.forEach(function (m) {
  1319. var $m = $('<div class="of-menu" tabindex="-1">' + escapeHtml(m.title) + "</div>");
  1320. var $drop = $('<div class="of-menu-drop"></div>');
  1321. $m.append($drop);
  1322. // contextual menus (e.g. Table) only show when their condition
  1323. // holds; the app re-evaluates via OfficeApp.updateMenus()
  1324. if (m.when) {
  1325. $m.data("when", m.when);
  1326. $m.hide();
  1327. }
  1328. $m.on("click", function (ev) {
  1329. if ($(ev.target).closest(".of-menu-drop").length) return;
  1330. var wasOpen = $m.hasClass("open");
  1331. closeAllMenus();
  1332. if (!wasOpen) {
  1333. renderMenuItems($drop, (typeof m.items === "function") ? m.items() : m.items, 0);
  1334. $m.addClass("open");
  1335. }
  1336. });
  1337. $m.on("mouseenter", function () {
  1338. if ($(".of-menu.open").length && !$m.hasClass("open")) {
  1339. closeAllMenus();
  1340. renderMenuItems($drop, (typeof m.items === "function") ? m.items() : m.items, 0);
  1341. $m.addClass("open");
  1342. }
  1343. });
  1344. $menus.append($m);
  1345. });
  1346. $bar.append($menus);
  1347. if (cfg.ribbon && window.OfficeRibbon) OfficeRibbon.attachStrip($menus);
  1348. $bar.append('<div class="of-docname"></div>');
  1349. // room for an app's own title-bar buttons (Slides' Slideshow)
  1350. $bar.append('<div class="of-titleextras"></div>');
  1351. if (cfg.ribbon && window.OfficeRibbon) {
  1352. var $min = $('<button type="button" class="of-qbtn of-rb-mintoggle" title="Collapse the ribbon"><i class="chevron up icon"></i></button>');
  1353. $min.on("click", function () { OfficeRibbon.setMinimized(!OfficeRibbon.isMinimized()); });
  1354. $bar.append($min);
  1355. }
  1356. // one global closer for menubar drops, context menus and submenus
  1357. $(document).on("mousedown.ofmenu", function (ev) {
  1358. // the ribbon's popups (a folded group, a gallery) close themselves
  1359. if (!$(ev.target).closest(".of-menubar, .of-context-menu, .of-rb-popup, .of-rb-gpop").length) closeAllMenus();
  1360. });
  1361. return $bar;
  1362. }
  1363. function refreshMenuChecks() {
  1364. // menus re-render on open; nothing to do live
  1365. }
  1366. // show/hide conditional menubar menus (menu defs carrying when: fn),
  1367. // and re-read the ribbon's contextual tabs and control states
  1368. function updateMenus() {
  1369. $(".of-menubar .of-menu").each(function () {
  1370. var w = $(this).data("when");
  1371. if (!w) return;
  1372. var on = false;
  1373. try { on = !!w(); } catch (e) { }
  1374. if (!on && $(this).hasClass("open")) closeAllMenus();
  1375. $(this).toggle(on);
  1376. });
  1377. if (cfg && cfg.ribbon && window.OfficeRibbon) OfficeRibbon.refresh();
  1378. if (cfg) refreshQat();
  1379. }
  1380. function standardMenus() {
  1381. var fileItems = function () {
  1382. var items = [
  1383. { label: "New", icon: "file outline", key: "Ctrl+Alt+N", action: newDocument },
  1384. { label: "Open...", icon: "folder open", key: "Ctrl+O", action: openDialog },
  1385. !OfficePlatform.tracksRecents() ? null : {
  1386. label: "Open recent", icon: "history", sub: function () {
  1387. return getRecents().map(function (r) {
  1388. return {
  1389. label: r.fn, action: function () {
  1390. if (openElsewhere(r.fp, r.fn)) return;
  1391. if (dirty) {
  1392. confirmDialog("Discard unsaved changes?",
  1393. "The current document has unsaved changes.",
  1394. "Discard", "Cancel",
  1395. function (yes) { if (yes) openPath(r.fp, r.fn); });
  1396. } else { openPath(r.fp, r.fn); }
  1397. }
  1398. };
  1399. });
  1400. }
  1401. },
  1402. { sep: true },
  1403. { label: "Save", icon: "save", key: "Ctrl+S", action: function () { save(); } },
  1404. saveAsMenuItem(),
  1405. { label: "Auto-save", checked: autosaveEnabled, action: function () { setSetting("autosave", !autosaveEnabled()); } }
  1406. ];
  1407. if (cfg.fileMenuExtras && cfg.fileMenuExtras.length) {
  1408. items.push({ sep: true });
  1409. items = items.concat(cfg.fileMenuExtras);
  1410. }
  1411. items.push({ sep: true });
  1412. items.push({ label: "Print / PDF...", icon: "print", key: "Ctrl+P", action: printDoc });
  1413. return items;
  1414. };
  1415. var editItems = function () {
  1416. var items = [
  1417. {
  1418. label: "Undo", icon: "undo", key: "Ctrl+Z",
  1419. enabled: function () { return !cfg.canUndo || cfg.canUndo(); },
  1420. action: function () { if (cfg.onUndo) cfg.onUndo(); }
  1421. },
  1422. {
  1423. label: "Redo", icon: "redo", key: "Ctrl+Y",
  1424. enabled: function () { return !cfg.canRedo || cfg.canRedo(); },
  1425. action: function () { if (cfg.onRedo) cfg.onRedo(); }
  1426. },
  1427. { sep: true },
  1428. { label: "Cut", icon: "cut", key: "Ctrl+X", action: function () { clipboardAction("cut"); } },
  1429. { label: "Copy", icon: "copy", key: "Ctrl+C", action: function () { clipboardAction("copy"); } },
  1430. { label: "Paste", icon: "paste", key: "Ctrl+V", action: function () { clipboardAction("paste"); } },
  1431. { label: "Clipboard...", icon: "clipboard outline", action: showClipboardPane }
  1432. ];
  1433. if (cfg.editMenuExtras && cfg.editMenuExtras.length) {
  1434. items.push({ sep: true });
  1435. items = items.concat(cfg.editMenuExtras);
  1436. }
  1437. return items;
  1438. };
  1439. var viewItems = function () {
  1440. var items = [
  1441. { label: "Zoom in", icon: "zoom-in", key: "Ctrl+=", action: function () { zoomStep(1); } },
  1442. { label: "Zoom out", icon: "zoom-out", key: "Ctrl+-", action: function () { zoomStep(-1); } },
  1443. { label: "Reset zoom", icon: "expand", key: "Ctrl+0", action: function () { setZoom(100); } },
  1444. { sep: true },
  1445. { label: "Dark theme", checked: isDark, action: toggleTheme }
  1446. ];
  1447. if (cfg.viewMenuExtras && cfg.viewMenuExtras.length) {
  1448. items.push({ sep: true });
  1449. items = items.concat(cfg.viewMenuExtras);
  1450. }
  1451. return items;
  1452. };
  1453. // a ribbon app keeps File and Edit as menus, Google Docs style, and
  1454. // carries the rest (its own menus, View) as ribbon tabs
  1455. var menus = [{ title: "File", items: fileItems }, { title: "Edit", items: editItems }];
  1456. if (cfg.ribbon) return menus;
  1457. (cfg.menus || []).forEach(function (m) { menus.push(m); });
  1458. menus.push({ title: "View", items: viewItems });
  1459. return menus;
  1460. }
  1461. /* ---------- clipboard ---------- */
  1462. function clipboardAction(op) {
  1463. var hook = { cut: cfg.onCut, copy: cfg.onCopy, paste: cfg.onPaste }[op];
  1464. if (hook) { hook(); return; }
  1465. if (op === "paste") {
  1466. if (navigator.clipboard && navigator.clipboard.readText) {
  1467. navigator.clipboard.readText().then(function (t) {
  1468. if (cfg.onPasteText) cfg.onPasteText(t);
  1469. else document.execCommand("insertText", false, t);
  1470. }).catch(function () {
  1471. setStatus("Paste blocked by the browser - use Ctrl+V instead", "error");
  1472. });
  1473. } else {
  1474. setStatus("Paste is not available - use Ctrl+V instead", "error");
  1475. }
  1476. } else {
  1477. document.execCommand(op);
  1478. }
  1479. }
  1480. /* ---------- the Clipboard pane (Home > Clipboard launcher) ----------
  1481. Everything copied or cut in Office in this tab (OfficeClipboard
  1482. keeps it), newest first: click an item to paste it, as Office's
  1483. Clipboard task pane does. An item is put back on the system
  1484. clipboard and pasted by the app's own Paste, so every app pastes it
  1485. the way it pastes anything else. */
  1486. var APP_NAMES = { docs: "Docs", sheets: "Sheets", slides: "Slides" };
  1487. function clipItemView(it) {
  1488. var parsed = it.html ? OfficeClipboard.parse(it.html) : null;
  1489. // another app's object marker is never shown as raw JSON
  1490. var text = OfficeClipboard.isMarker(it.text) || /^\s*\{\s*"app"\s*:/.test(it.text) ?
  1491. (parsed ? parsed.text : "") : (it.text || (parsed ? parsed.text : ""));
  1492. text = String(text || "").replace(/\s+/g, " ").trim();
  1493. var $prev = $('<span class="of-clip-prev"></span>');
  1494. if (parsed && parsed.images.length && !text) {
  1495. $prev.append($('<img alt="">').attr("src", parsed.images[0].src));
  1496. } else if (parsed && parsed.tables.length && !it.text) {
  1497. $prev.text(text.slice(0, 160) || "Table");
  1498. } else {
  1499. $prev.text(text.length > 160 ? text.slice(0, 160) + "..." : (text || "(empty)"));
  1500. }
  1501. return { $prev: $prev, text: text };
  1502. }
  1503. function pasteClipItem(it) {
  1504. OfficeClipboard.writeAsync({ text: it.text, html: it.html, noHistory: true }).then(function () {
  1505. clipboardAction("paste");
  1506. }, function () {
  1507. setStatus("The browser blocked the clipboard - copy it again and press Ctrl+V", "error");
  1508. });
  1509. }
  1510. function showClipboardPane() {
  1511. closeAllMenus();
  1512. if (!window.OfficeClipboard) return;
  1513. var $list = $('<div class="of-clip-list"></div>');
  1514. var d = null;
  1515. var render = function () {
  1516. var items = OfficeClipboard.history();
  1517. $list.empty();
  1518. if (!items.length) {
  1519. $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>');
  1520. return;
  1521. }
  1522. items.forEach(function (it, i) {
  1523. var v = clipItemView(it);
  1524. var $row = $('<div class="of-clip-item" tabindex="0"></div>').attr("title", "Click to paste");
  1525. $row.append($('<span class="of-clip-app"></span>').text(APP_NAMES[it.app] || "Office"));
  1526. $row.append(v.$prev);
  1527. var $del = $('<button type="button" class="of-clip-del" title="Delete"><i class="close icon"></i></button>');
  1528. $del.on("click", function (e) {
  1529. e.stopPropagation();
  1530. OfficeClipboard.removeFromHistory(i);
  1531. render();
  1532. });
  1533. $row.append($del);
  1534. $row.on("click", function () { d.close(); pasteClipItem(it); });
  1535. $row.on("keydown", function (e) { if (e.key === "Enter") { d.close(); pasteClipItem(it); } });
  1536. $list.append($row);
  1537. });
  1538. };
  1539. var $body = $('<div class="of-clip-pane"></div>')
  1540. .append('<div class="of-clip-hint">Click an item to paste it.</div>')
  1541. .append($list);
  1542. d = dialog({
  1543. title: "Clipboard",
  1544. body: $body,
  1545. buttons: [
  1546. {
  1547. label: "Clear All", action: function () {
  1548. OfficeClipboard.clearHistory();
  1549. render();
  1550. }
  1551. },
  1552. {
  1553. label: "Paste All", action: function (close) {
  1554. // oldest first, the order they were copied in
  1555. var items = OfficeClipboard.history().reverse();
  1556. if (!items.length) return;
  1557. var texts = [], htmls = [];
  1558. items.forEach(function (it) {
  1559. var v = clipItemView(it);
  1560. texts.push(v.text);
  1561. htmls.push(it.html || escapeHtml(it.text).replace(/\n/g, "<br>"));
  1562. });
  1563. close();
  1564. pasteClipItem({ text: texts.join("\n"), html: htmls.join("<br>") });
  1565. }
  1566. },
  1567. { label: "Close", primary: true }
  1568. ]
  1569. });
  1570. render();
  1571. }
  1572. /* ---------- print ---------- */
  1573. function printDoc() {
  1574. closeAllMenus();
  1575. if (cfg.onBeforePrint) { try { cfg.onBeforePrint(); } catch (e) { } }
  1576. setTimeout(function () {
  1577. // the print layout stays up until the dialog is done with it -
  1578. // at once in a browser, later behind a native print panel
  1579. OfficePlatform.print(function () {
  1580. if (cfg.onAfterPrint) { try { cfg.onAfterPrint(); } catch (e) { } }
  1581. });
  1582. }, 60);
  1583. }
  1584. /* ---------- dialogs / toasts / context menu ---------- */
  1585. function dialog(opt) {
  1586. splashDone(); // a question for the person: nothing may cover it
  1587. var $ov = $('<div class="of-dialog-overlay"></div>');
  1588. var $dl = $('<div class="of-dialog"></div>');
  1589. if (opt.wide) $dl.addClass("wide");
  1590. if (opt.title) $dl.append('<div class="of-dialog-title">' + escapeHtml(opt.title) + "</div>");
  1591. var $body = $('<div class="of-dialog-body"></div>');
  1592. if (opt.body instanceof $) $body.append(opt.body);
  1593. else $body.html(opt.body || "");
  1594. $dl.append($body);
  1595. var close = function () { $ov.remove(); $(document).off("keydown.ofdialog"); };
  1596. if (opt.buttons && opt.buttons.length) {
  1597. var $act = $('<div class="of-dialog-actions"></div>');
  1598. opt.buttons.forEach(function (b) {
  1599. var $b = $('<button class="of-btn"></button>').text(b.label);
  1600. if (b.primary) $b.addClass("primary");
  1601. if (b.danger) $b.addClass("danger");
  1602. $b.on("click", function () {
  1603. if (b.action) b.action(close, $body);
  1604. else close();
  1605. });
  1606. $act.append($b);
  1607. });
  1608. $dl.append($act);
  1609. }
  1610. $ov.append($dl);
  1611. // dismissed without a button: opt.onDismiss hears about it
  1612. var dismiss = function () {
  1613. close();
  1614. if (opt.onDismiss) opt.onDismiss();
  1615. };
  1616. $ov.on("mousedown", function (e) { if (e.target === $ov[0] && opt.dismissable !== false) dismiss(); });
  1617. $(document).on("keydown.ofdialog", function (e) {
  1618. if (e.key === "Escape" && opt.dismissable !== false) dismiss();
  1619. });
  1620. $("body").append($ov);
  1621. return { close: close, body: $body };
  1622. }
  1623. function confirmDialog(title, msgHtml, yesLabel, noLabel, cb) {
  1624. dialog({
  1625. title: title,
  1626. body: "<p>" + msgHtml + "</p>",
  1627. dismissable: false,
  1628. buttons: [
  1629. { label: noLabel || "Cancel", action: function (close) { close(); cb(false); } },
  1630. { label: yesLabel || "OK", primary: true, action: function (close) { close(); cb(true); } }
  1631. ]
  1632. });
  1633. }
  1634. function promptDialog(title, label, defVal, cb) {
  1635. var $b = $("<div><label>" + escapeHtml(label) + '</label><input type="text" class="of-prompt-input"></div>');
  1636. $b.find("input").val(defVal === undefined ? "" : defVal);
  1637. var d = dialog({
  1638. title: title, body: $b,
  1639. buttons: [
  1640. { label: "Cancel", action: function (close) { close(); cb(null); } },
  1641. {
  1642. label: "OK", primary: true, action: function (close, $body) {
  1643. var v = $body.find(".of-prompt-input").val();
  1644. close(); cb(v);
  1645. }
  1646. }
  1647. ]
  1648. });
  1649. var $in = d.body.find("input");
  1650. $in.trigger("focus").trigger("select");
  1651. $in.on("keydown", function (e) {
  1652. if (e.key === "Enter") { var v = $in.val(); d.close(); cb(v); }
  1653. });
  1654. return d;
  1655. }
  1656. function toast(msg, type, ms) {
  1657. if (type === "error") splashDone();
  1658. var $h = $(".of-toast-holder");
  1659. if (!$h.length) { $h = $('<div class="of-toast-holder"></div>'); $("body").append($h); }
  1660. var $t = $('<div class="of-toast"></div>').text(msg);
  1661. if (type === "error") $t.addClass("error");
  1662. $h.append($t);
  1663. requestAnimationFrame(function () { $t.addClass("show"); });
  1664. setTimeout(function () {
  1665. $t.removeClass("show");
  1666. setTimeout(function () { $t.remove(); }, 250);
  1667. }, ms || 2600);
  1668. }
  1669. function showContextMenu(x, y, items) {
  1670. closeFloatMenus(0);
  1671. var $cm = $('<div class="of-context-menu of-float-menu"></div>').data("depth", 0);
  1672. renderMenuItems($cm, items, 0);
  1673. $("body").append($cm);
  1674. positionFloatMenu($cm, x, y);
  1675. return { close: function () { closeFloatMenus(0); } };
  1676. }
  1677. function showBusy(msg) {
  1678. hideBusy();
  1679. // the splash already covers the app and says what is happening
  1680. if (splashActive()) {
  1681. OfficeSplash.status(msg || "Working...");
  1682. return;
  1683. }
  1684. var $o = $('<div class="of-busy-overlay"><div class="of-spinner"></div><div class="of-busy-msg"></div></div>');
  1685. $o.find(".of-busy-msg").text(msg || "Working...");
  1686. $("body").append($o);
  1687. }
  1688. function hideBusy() { $(".of-busy-overlay").remove(); }
  1689. /*
  1690. beginDrag(e, {move(ev), end(ev, cancelled), cursor})
  1691. Call from a pointerdown to own the rest of the gesture. A transparent
  1692. full-window layer goes over everything and takes pointer capture, so
  1693. every move and the release reach the drag however fast the pointer
  1694. travels - even when the dragged element is re-rendered (which drops
  1695. a capture set on the element itself) or the cursor crosses iframes,
  1696. charts or other widgets. The layer is removed on release, cancel, or
  1697. when the window loses focus. Returns a function that ends the drag.
  1698. */
  1699. function beginDrag(e, opts) {
  1700. opts = opts || {};
  1701. var ov = document.createElement("div");
  1702. ov.className = "of-drag-overlay";
  1703. if (opts.cursor) ov.style.cursor = opts.cursor;
  1704. document.body.appendChild(ov);
  1705. var done = false;
  1706. try { ov.setPointerCapture(e.pointerId); } catch (err) { }
  1707. function onMove(ev) {
  1708. ev.preventDefault();
  1709. if (opts.move) opts.move(ev);
  1710. }
  1711. function finish(ev, cancelled) {
  1712. if (done) return;
  1713. done = true;
  1714. ov.removeEventListener("pointermove", onMove);
  1715. ov.removeEventListener("pointerup", onUp);
  1716. ov.removeEventListener("pointercancel", onCancel);
  1717. window.removeEventListener("blur", onCancel);
  1718. if (ov.parentNode) ov.parentNode.removeChild(ov);
  1719. if (opts.end) opts.end(ev || null, !!cancelled);
  1720. }
  1721. function onUp(ev) { finish(ev, false); }
  1722. function onCancel(ev) { finish(ev && ev.type === "pointercancel" ? ev : null, true); }
  1723. ov.addEventListener("pointermove", onMove);
  1724. ov.addEventListener("pointerup", onUp);
  1725. ov.addEventListener("pointercancel", onCancel);
  1726. window.addEventListener("blur", onCancel);
  1727. return function () { finish(null, true); };
  1728. }
  1729. /* showProgress is showBusy's opposite number: a small panel in the
  1730. corner of the editing area for work that takes a while but has no
  1731. reason to hold the document hostage. Exporting is the case it was
  1732. written for - the deck is snapshotted when the export starts, so
  1733. carrying on editing while it runs changes nothing about the file
  1734. that comes out.
  1735. var p = OfficeApp.showProgress({ title: "Exporting PDF",
  1736. anchor: "#slCanvasArea" });
  1737. p.set(done, total, "Exporting 3 of 6 pages");
  1738. p.close();
  1739. anchor is the element to sit in the top-right of; the panel is
  1740. positioned from its box rather than placed inside it, so it does not
  1741. have to care how that element is laid out. */
  1742. function showProgress(o) {
  1743. o = o || {};
  1744. var $p = $('<div class="of-progress-panel of-noprint">' +
  1745. '<div class="of-pp-title"></div>' +
  1746. '<div class="of-pp-msg"></div>' +
  1747. '<div class="of-pp-bar"><div class="of-pp-fill"></div></div>' +
  1748. '</div>');
  1749. $p.find(".of-pp-title").text(o.title || "Working...");
  1750. $("body").append($p);
  1751. function place() {
  1752. var $a = o.anchor ? $(o.anchor) : $();
  1753. var el = $a.length ? $a[0] : null;
  1754. var r = el ? el.getBoundingClientRect() : null;
  1755. if (r && r.width > 0) {
  1756. $p.css({ top: (r.top + 12) + "px", right: (window.innerWidth - r.right + 12) + "px" });
  1757. } else {
  1758. $p.css({ top: "64px", right: "16px" });
  1759. }
  1760. }
  1761. place();
  1762. $(window).on("resize.ofprogress", place);
  1763. var handle = {
  1764. set: function (done, total, msg) {
  1765. var pct = total > 0 ? Math.max(0, Math.min(100, done * 100 / total)) : 0;
  1766. $p.find(".of-pp-fill").css("width", pct + "%");
  1767. if (msg !== undefined) $p.find(".of-pp-msg").text(msg);
  1768. place();
  1769. },
  1770. message: function (msg) { $p.find(".of-pp-msg").text(msg); },
  1771. close: function () {
  1772. $(window).off("resize.ofprogress", place);
  1773. $p.remove();
  1774. }
  1775. };
  1776. return handle;
  1777. }
  1778. /* ---------- statusbar ---------- */
  1779. function buildStatusbar() {
  1780. var $sb = $('<div class="of-statusbar of-noprint"></div>');
  1781. $sb.append('<span class="of-status-msg"></span>');
  1782. $sb.append('<span class="of-status-items"></span>');
  1783. var $z = $('<span class="of-status-zoom"></span>');
  1784. var $minus = $('<button type="button" title="Zoom out">−</button>');
  1785. var $plus = $('<button type="button" title="Zoom in">+</button>');
  1786. var $val = $('<span class="of-zoom-val" title="Reset zoom">100%</span>');
  1787. $minus.on("click", function () { zoomStep(-1); });
  1788. $plus.on("click", function () { zoomStep(1); });
  1789. $val.on("click", function () { setZoom(100); });
  1790. $z.append($minus).append($val).append($plus);
  1791. $sb.append($z);
  1792. return $sb;
  1793. }
  1794. /* ---------- init ---------- */
  1795. function init(config) {
  1796. cfg = config;
  1797. if (!cfg.appType || !cfg.serialize || !cfg.deserialize || !cfg.create) {
  1798. throw new Error("OfficeApp.init: appType, serialize, deserialize and create are required");
  1799. }
  1800. cfg.fileTypeName = cfg.fileTypeName || "Document";
  1801. cfg.defaultFileName = cfg.defaultFileName || "Untitled";
  1802. $("body").addClass("of-app");
  1803. // chrome
  1804. if (cfg.ribbon && window.OfficeRibbon) OfficeRibbon.host();
  1805. var $menubar = buildMenubar(standardMenus());
  1806. $("body").prepend($menubar);
  1807. $("body").append(buildStatusbar());
  1808. if (cfg.ribbon && window.OfficeRibbon && getSetting("ribbonMin", false)) {
  1809. OfficeRibbon.setMinimized(true);
  1810. }
  1811. // the title bar is in the page now: fit its tabs and the panel
  1812. if (cfg.ribbon && window.OfficeRibbon) OfficeRibbon.scheduleFit();
  1813. // the foreign-format banner sits under the whole toolbar strip, so
  1814. // it reads as a note about the document rather than part of the
  1815. // chrome; apps with no toolbar get it straight under the menubar
  1816. var $banner = buildForeignBanner();
  1817. var $tb = $("body > .of-toolbar").last();
  1818. if ($tb.length) $tb.after($banner); else $menubar.after($banner);
  1819. // shortcuts (standard)
  1820. registerShortcut("Ctrl+S", function () { save(); }, { description: "Save" });
  1821. registerShortcut("Ctrl+Shift+S", function () { saveAs(); }, { description: "Save as..." });
  1822. registerShortcut("Ctrl+O", function () { openDialog(); }, { description: "Open..." });
  1823. registerShortcut("Ctrl+Alt+N", function () { newDocument(); }, { description: "New document" });
  1824. registerShortcut("Ctrl+P", function () { printDoc(); }, { description: "Print" });
  1825. registerShortcut("Ctrl+=", function () { zoomStep(1); }, { description: "Zoom in" });
  1826. registerShortcut("Ctrl++", function () { zoomStep(1); });
  1827. registerShortcut("Ctrl+-", function () { zoomStep(-1); }, { description: "Zoom out" });
  1828. registerShortcut("Ctrl+0", function () { setZoom(100); }, { description: "Reset zoom" });
  1829. // non-passive so the browser's own page zoom can be preventDefault-ed
  1830. window.addEventListener("wheel", onWheelZoom, { passive: false });
  1831. if (cfg.onUndo) registerShortcut("Ctrl+Z", function () { cfg.onUndo(); }, { description: "Undo" });
  1832. if (cfg.onRedo) {
  1833. registerShortcut("Ctrl+Y", function () { cfg.onRedo(); }, { description: "Redo" });
  1834. registerShortcut("Ctrl+Shift+Z", function () { cfg.onRedo(); });
  1835. }
  1836. if (window.OfficeHotkeys) {
  1837. registerShortcut("Ctrl+/", function () { OfficeHotkeys.showHelp(); },
  1838. { description: "Keyboard shortcuts help" });
  1839. // Escape falls through (return false) so app-level Escape
  1840. // handlers still run after any open menu is closed
  1841. OfficeHotkeys.register("Escape", function () {
  1842. closeAllMenus();
  1843. return false;
  1844. }, { id: "of.menuclose", allowInInput: true, inDialogs: true });
  1845. } else {
  1846. window.addEventListener("keydown", handleKeydown, true);
  1847. }
  1848. // theme + zoom
  1849. applyTheme();
  1850. zoom = getSetting("zoom", 100);
  1851. applyZoom();
  1852. // housekeeping
  1853. pruneDrafts();
  1854. // load input file (embedded / open-with) or start blank
  1855. meta = { createdAt: now(), revision: 0 };
  1856. var inputs = OfficePlatform.loadInputFiles(cfg.extension);
  1857. if (inputs && inputs.length > 0) {
  1858. cfg.create();
  1859. openPath(inputs[0].filepath, inputs[0].filename,
  1860. { asTemplate: !!inputs[0].asTemplate });
  1861. } else {
  1862. cfg.create();
  1863. markClean();
  1864. splashDone();
  1865. trySessionRestore(); // falls back to checkDraft() itself
  1866. }
  1867. // autosave + unload guard
  1868. autosaveTimer = setInterval(autosaveTick, AUTOSAVE_INTERVAL);
  1869. window.addEventListener("beforeunload", function (e) {
  1870. if (dirty) {
  1871. saveDraft();
  1872. e.preventDefault();
  1873. e.returnValue = "";
  1874. }
  1875. });
  1876. installFloatWindowCloseGuard();
  1877. installStandaloneDropOpen();
  1878. updateTitle();
  1879. // the web edition has no storage behind it - say so once, so "Save
  1880. // downloads a copy" is not a surprise the first time (a desktop
  1881. // shell saves to real files and needs no such warning)
  1882. if (OfficePlatform.mode() === "standalone" && (!inputs || !inputs.length)) {
  1883. setStatus("Web edition - open a " + cfg.extension +
  1884. " from this device, and Save downloads it back", "info", 9000);
  1885. }
  1886. }
  1887. /*
  1888. Standalone build: dropping a document on the window opens it.
  1889. This is the whole point of the web edition - somebody was sent a
  1890. .docx and wants to look at it - so it is worth a drop target even
  1891. though the apps already handle drops of their own.
  1892. It listens in the capture phase but only claims the event when every
  1893. dropped file is one this app can open; anything else (an image being
  1894. dropped into the page, say) falls straight through to the app's own
  1895. handler untouched.
  1896. */
  1897. function installStandaloneDropOpen() {
  1898. if (!OfficePlatform.isStandalone()) return;
  1899. var openable = function (name) {
  1900. var e = extOf(name);
  1901. return e === cfg.extension || !!(cfg.importers || {})[e] || !!binaryImporters()[e] ||
  1902. isLegacyExt(e);
  1903. };
  1904. var claims = function (e) {
  1905. var dt = e.dataTransfer;
  1906. if (!dt || !dt.files || dt.files.length !== 1) return false;
  1907. return openable(dt.files[0].name);
  1908. };
  1909. document.addEventListener("dragover", function (e) {
  1910. if (!claims(e)) return;
  1911. e.preventDefault();
  1912. e.stopPropagation();
  1913. e.dataTransfer.dropEffect = "copy";
  1914. }, true);
  1915. document.addEventListener("drop", function (e) {
  1916. if (!claims(e)) return;
  1917. e.preventDefault();
  1918. e.stopPropagation();
  1919. var file = e.dataTransfer.files[0];
  1920. var go = function () { OfficePlatform.adoptDroppedFile(file, openPath); };
  1921. if (dirty) {
  1922. confirmDialog("Discard unsaved changes?",
  1923. "The current document has unsaved changes that will be lost.",
  1924. "Discard", "Cancel", function (yes) { if (yes) go(); });
  1925. } else { go(); }
  1926. }, true);
  1927. }
  1928. /*
  1929. Ask before a window holding unsaved changes goes away. done() once
  1930. it may close - nothing to lose, saved, or the changes thrown away;
  1931. cancelled() when the person keeps it open. The ArozOS desktop asks
  1932. through ao_module_close (below), a desktop shell through
  1933. OfficeNative.requestClose.
  1934. */
  1935. function requestClose(done, cancelled) {
  1936. cancelled = cancelled || function () { };
  1937. if (!dirty) { done(); return; }
  1938. // with AutoSave on, closing is one more autosave: the document goes
  1939. // back into its file and the window closes - no question to answer.
  1940. // Only when that cannot happen (never saved, a format that cannot
  1941. // hold it, the write failed) does the window ask.
  1942. if (canAutosaveOnClose()) {
  1943. saveWithProgress(save, done, function (err) {
  1944. if (err) toast("Could not save " + (filename || "the document") + ": " + err, "error");
  1945. askBeforeClose(done, cancelled);
  1946. });
  1947. return;
  1948. }
  1949. askBeforeClose(done, cancelled);
  1950. }
  1951. function canAutosaveOnClose() {
  1952. if (!autosaveEnabled() || !filepath || !OfficePlatform.autosavesToFile()) return false;
  1953. var ext = extOf(filename);
  1954. if (ext === cfg.extension) return true;
  1955. // a foreign format: only when it can still hold the document
  1956. var fmt = findSaveFormat(ext, false);
  1957. return !!fmt && !formatReasons(fmt);
  1958. }
  1959. /*
  1960. Run a save (save or saveAs) behind a progress panel: the window is
  1961. about to close, so it holds the screen until the file is written -
  1962. a save that uploads megabytes must not look like a frozen window.
  1963. done() once the file is saved, failed(message) when it was not (a
  1964. null message: declined or cancelled, nothing to report).
  1965. */
  1966. function saveWithProgress(run, done, failed) {
  1967. var ui = null, over = false;
  1968. var stages = {
  1969. wait: [2, "Waiting for the previous save..."],
  1970. prepare: [8, "Preparing the document..."],
  1971. compress: [16, "Compressing..."],
  1972. server: [90, "Writing " + (filename || "the file") + "..."]
  1973. };
  1974. var obs = {
  1975. progress: function (stage, pct) {
  1976. if (over) return;
  1977. if (!ui) ui = showSaveProgress(filename || (cfg.defaultFileName + cfg.extension));
  1978. if (stage === "upload") {
  1979. var p = Math.max(0, Math.min(100, pct || 0));
  1980. ui.set(18 + p * 0.7, "Uploading... " + Math.round(p) + "%", false);
  1981. } else if (stages[stage]) {
  1982. ui.set(stages[stage][0], stages[stage][1], stage === "server" || stage === "wait");
  1983. }
  1984. },
  1985. fail: function (err) {
  1986. if (over) return;
  1987. over = true;
  1988. if (ui) ui.close();
  1989. failed(err);
  1990. }
  1991. };
  1992. run(function () {
  1993. if (over) return;
  1994. over = true;
  1995. if (!ui) { done(); return; }
  1996. ui.set(100, "Saved", false);
  1997. setTimeout(function () { ui.close(); done(); }, 300);
  1998. }, obs);
  1999. }
  2000. function showSaveProgress(name) {
  2001. var $o = $('<div class="of-saveprog-overlay of-noprint" role="dialog" aria-live="polite">' +
  2002. '<div class="of-saveprog"><div class="of-saveprog-title"></div>' +
  2003. '<div class="of-saveprog-bar"><div class="of-saveprog-fill"></div></div>' +
  2004. '<div class="of-saveprog-msg"></div></div></div>');
  2005. $o.find(".of-saveprog-title").text("Saving " + name);
  2006. $("body").append($o);
  2007. return {
  2008. set: function (pct, msg, busy) {
  2009. $o.find(".of-saveprog-fill").css("width", pct + "%");
  2010. $o.find(".of-saveprog-bar").toggleClass("busy", !!busy);
  2011. $o.find(".of-saveprog-msg").text(msg || "");
  2012. },
  2013. close: function () { $o.remove(); }
  2014. };
  2015. }
  2016. function askBeforeClose(done, cancelled) {
  2017. dialog({
  2018. title: "Unsaved changes",
  2019. body: "<b>" + escapeHtml(filename || (cfg.defaultFileName + cfg.extension)) +
  2020. "</b> has unsaved changes. Close it anyway?",
  2021. dismissable: true,
  2022. onDismiss: cancelled,
  2023. buttons: [
  2024. { label: "Cancel", action: function (close) { close(); cancelled(); } },
  2025. {
  2026. label: "Close without saving", danger: true,
  2027. action: function (close) {
  2028. close();
  2029. markClean(); // never re-prompt while the window tears down
  2030. done();
  2031. }
  2032. },
  2033. {
  2034. label: "Save & close", primary: true,
  2035. // only a save that worked closes the window: a failed
  2036. // or cancelled one leaves it open with the changes
  2037. action: function (close) {
  2038. close();
  2039. // a document never saved asks for a file first, and
  2040. // the picker does not report a cancel: release the
  2041. // close request now (a saved file still closes it)
  2042. var asked = !filepath;
  2043. if (asked) cancelled();
  2044. saveWithProgress(save, done, function (err) {
  2045. if (err) toast("Could not save: " + err, "error");
  2046. if (!asked) cancelled();
  2047. });
  2048. }
  2049. }
  2050. ]
  2051. });
  2052. }
  2053. /* The desktop routes a floatWindow's X button through the iframe's
  2054. ao_module_close() (see ao_module.js + desktop.html: it calls
  2055. contentWindow.ao_module_close() when defined). beforeunload does NOT
  2056. fire when the desktop just removes the iframe, so override that hook
  2057. to confirm before discarding unsaved changes. */
  2058. function installFloatWindowCloseGuard() {
  2059. if (typeof window.ao_module_close !== "function") return;
  2060. var reallyClose = function () {
  2061. markClean(); // never re-prompt while the window tears down
  2062. // closed for good (saved, or changes thrown away): its working
  2063. // copies can go now rather than wait for the nightly sweep
  2064. OfficePlatform.releaseWorkdir();
  2065. if (typeof window.ao_module_closeHandler === "function") {
  2066. ao_module_closeHandler();
  2067. }
  2068. };
  2069. window.ao_module_close = function () { requestClose(reallyClose); };
  2070. }
  2071. /* ---------- public API ---------- */
  2072. return {
  2073. init: init,
  2074. // lifecycle
  2075. newDocument: newDocument,
  2076. open: openDialog,
  2077. openPath: openPath,
  2078. save: save,
  2079. saveAs: saveAs,
  2080. print: printDoc,
  2081. // ask before the window closes: done() = it may, cancelled() = kept
  2082. requestClose: requestClose,
  2083. markDirty: markDirty,
  2084. isDirty: function () { return dirty; },
  2085. getFilePath: function () { return filepath; },
  2086. getFileName: function () { return filename; },
  2087. getMeta: function () { return meta; },
  2088. wasImported: function () { return loadedFromImport; },
  2089. // ui
  2090. setStatus: setStatus,
  2091. addStatusItem: addStatusItem,
  2092. updateStatusItem: updateStatusItem,
  2093. dialog: dialog,
  2094. confirm: confirmDialog,
  2095. prompt: promptDialog,
  2096. toast: toast,
  2097. showContextMenu: showContextMenu,
  2098. showBusy: showBusy,
  2099. hideBusy: hideBusy,
  2100. documentLoaded: documentLoaded,
  2101. splashStep: splashStep,
  2102. showProgress: showProgress,
  2103. beginDrag: beginDrag,
  2104. closeAllMenus: closeAllMenus,
  2105. updateMenus: updateMenus,
  2106. // features
  2107. registerShortcut: registerShortcut,
  2108. print: printDoc,
  2109. // the Edit commands, for an app's own ribbon (op: cut | copy | paste)
  2110. clipboard: clipboardAction,
  2111. showClipboardPane: showClipboardPane,
  2112. autosaveEnabled: autosaveEnabled,
  2113. setAutosave: function (on) { setSetting("autosave", !!on); refreshQat(); },
  2114. // the title bar's slot for an app's own buttons (right-hand side)
  2115. titleBarSlot: function () { return $(".of-titleextras"); },
  2116. setZoom: setZoom,
  2117. getZoom: function () { return zoom; },
  2118. zoomIn: function () { zoomStep(1); },
  2119. zoomOut: function () { zoomStep(-1); },
  2120. toggleTheme: toggleTheme,
  2121. isDark: isDark,
  2122. // storage
  2123. getSetting: getSetting,
  2124. setSetting: setSetting,
  2125. getRecents: getRecents,
  2126. // host (see common/platform.js)
  2127. hasBackend: function () { return OfficePlatform.hasBackend(); },
  2128. isStandalone: function () { return OfficePlatform.isStandalone(); },
  2129. // vfs / media
  2130. vfsLoad: vfsLoad,
  2131. vfsSave: vfsSave,
  2132. blobToSrc: blobToSrc,
  2133. mediaUrl: mediaUrl,
  2134. agirunLarge: agirunLarge,
  2135. // utils
  2136. escapeHtml: escapeHtml,
  2137. basename: basename,
  2138. dirname: dirname,
  2139. extOf: extOf,
  2140. stripExt: stripExt
  2141. };
  2142. })();