diff --git a/server/plugin/index.go b/server/plugin/index.go index 09d59cf6..438f7780 100644 --- a/server/plugin/index.go +++ b/server/plugin/index.go @@ -2,6 +2,7 @@ package plugin import ( . "github.com/mickael-kerjean/filestash/server/common" + _ "github.com/mickael-kerjean/filestash/server/plugin/plg_application_office" _ "github.com/mickael-kerjean/filestash/server/plugin/plg_authenticate_htpasswd" _ "github.com/mickael-kerjean/filestash/server/plugin/plg_authenticate_ldap" _ "github.com/mickael-kerjean/filestash/server/plugin/plg_authenticate_local" diff --git a/server/plugin/plg_application_office/Makefile b/server/plugin/plg_application_office/Makefile new file mode 100644 index 00000000..6d4ed237 --- /dev/null +++ b/server/plugin/plg_application_office/Makefile @@ -0,0 +1,18 @@ +all: + make build + make install + +install: + zip -r application_office.zip . -x "lib/vendor/*" + mv application_office.zip ../../../dist/data/state/plugins/ + +build: + make deps_zeta + +deps_zeta: + [ -d lib/lowa ] || mkdir lib/lowa + curl https://cdn.zetaoffice.net/zetaoffice_latest/soffice.js > lib/lowa/soffice.js + curl https://cdn.zetaoffice.net/zetaoffice_latest/soffice.wasm > lib/lowa/soffice.wasm.br + curl https://cdn.zetaoffice.net/zetaoffice_latest/soffice.data.js.metadata > lib/lowa/soffice.data.js.metadata + curl https://cdn.zetaoffice.net/zetaoffice_latest/soffice.data > lib/lowa/soffice.data.br + curl https://zetaoffice.net/demos/standalone/assets/vendor/zetajs/zeta.js > lib/lowa/zeta.js diff --git a/server/plugin/plg_application_office/loader_lowa.css b/server/plugin/plg_application_office/loader_lowa.css new file mode 100644 index 00000000..b57d4aee --- /dev/null +++ b/server/plugin/plg_application_office/loader_lowa.css @@ -0,0 +1,61 @@ +@import url("https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.7.2/css/fontawesome.min.css"); + +.component_word { + display: flex; + flex: 1 1 auto; +} +.component_word canvas { + border: none; + outline: none; + height: 100%; + width: 100%; +} +.component_menubar .action-item .texteditor svg.component_icon { + width: 16px; + margin: 1px 0 0 2px; +} +.component_menubar .action-item .texteditor.active svg.component_icon { + background: rgba(255, 255, 255, 0.2); + border-radius: 5px; +} + +.component_menubar .action-item .texteditor input[type="number"] { + width: 15px; + color: var(--color); + border: 2px solid #f2f2f2; + background: #f2f2f2; + border-radius: 2px; + padding: 0 3px 0 3px; + margin: 0 3px; + -moz-appearance: textfield; +} +.component_menubar .action-item .texteditor input[type="number"]::-webkit-outer-spin-button, +.component_menubar .action-item .texteditor input[type="number"]::-webkit-inner-spin-button { + -webkit-appearance: none; +} + +.component_menubar .action-item .texteditor .fontawesome { + font-family: "FontAwesome"; + color: var(--light); + background: inherit; + color: inherit; + border: none; +} +.component_menubar .action-item .texteditor select.fontawesome option { + background: var(--dark); +} + +.component_menubar .action-item .texteditor.picker { + display: flex; + align-items: center; +} +.component_menubar .action-item .texteditor.picker input[type="color"] { + padding: 0; + width: 0; + border: none; +} +.component_word + .component_loader { + position: absolute; + inset: 0; + margin-top: 150px; +} diff --git a/server/plugin/plg_application_office/loader_lowa.go b/server/plugin/plg_application_office/loader_lowa.go new file mode 100644 index 00000000..d7ffc92c --- /dev/null +++ b/server/plugin/plg_application_office/loader_lowa.go @@ -0,0 +1,18 @@ +package plg_application_office + +import ( + "net/http" + + . "github.com/mickael-kerjean/filestash/server/common" +) + +func init() { + Hooks.Register.Middleware(func(h HandlerFunc) HandlerFunc { + return func(app *App, res http.ResponseWriter, req *http.Request) { + head := res.Header() + head.Set("Cross-Origin-Opener-Policy", "same-origin") + head.Set("Cross-Origin-Embedder-Policy", "require-corp") + h(app, res, req) + } + }) +} diff --git a/server/plugin/plg_application_office/loader_lowa.js b/server/plugin/plg_application_office/loader_lowa.js new file mode 100644 index 00000000..c1b8a16d --- /dev/null +++ b/server/plugin/plg_application_office/loader_lowa.js @@ -0,0 +1,286 @@ +import { createElement, onDestroy } from "../../lib/skeleton/index.js"; +import rxjs, { effect } from "../../lib/rx.js"; +import ajax from "../../lib/ajax.js"; +import { qs } from "../../lib/dom.js"; +import { join } from "../../lib/path.js"; +import { loadJS, loadCSS } from "../../helpers/loader.js"; +import { buttonDownload } from "../../pages/viewerpage/component_menubar.js"; +import { $ICON } from "../../pages/viewerpage/common_fab.js"; +import { save } from "../../pages/viewerpage/model_files.js"; +import "../../components/fab.js"; + +import { $toolbar } from "./lib/dom.js"; + +await loadCSS(import.meta.url, "./loader_lowa.css"); + +let $canvas = null; +window.Module = { + uno_scripts: [join(import.meta.url, "./lib/lowa/zeta.js"), join(import.meta.url, "./loader_lowa.uno.js")], + locateFile: (path, prefix) => (prefix || join(import.meta.url, "./lib/lowa/")) + path, +}; + +export default async function(render, { mime, getDownloadUrl, getFilename, $menubar, acl$ }) { + const canWrite = (await acl$.toPromise()).indexOf("POST") >= 0; + const $page = createElement(` +
+ + +
+ `); + render($page); + + // feature1: init + const filename = getFilename(); + const $fab = qs($page, `[is="component-fab"]`); + const $qcanvas = qs($page, "canvas"); + if ($canvas) { + $qcanvas.remove(); + $page.appendChild($canvas); + } else { + $canvas = $qcanvas; + } + Object.assign($canvas.style, { + width: "100%", + height: "100%", + }); + + // feature2: toolbar init + if (canWrite) { + $menubar.add(buttonDownload(filename, getDownloadUrl())); + if (isWriter(mime)) { + $menubar.add($toolbar.bullet); + $menubar.add($toolbar.alignment); + $menubar.add($toolbar.title); + } + $menubar.add($toolbar.size); + $menubar.add($toolbar.strike); + $menubar.add($toolbar.underline); + $menubar.add($toolbar.italic); + $menubar.add($toolbar.bold); + $menubar.add($toolbar.color); + } + + // feature3: setup lowa + window.Module.canvas = $canvas; + await loadJS(import.meta.url, "./lib/lowa/soffice.js"); + let port = await Module.uno_main; + onDestroy(() => { + $canvas.style.visibility = "hidden"; + port.postMessage({ cmd: "destroy", mime }); + }); + + // feature4: display rule for save button + const action$ = new rxjs.Subject(); + if (canWrite) effect(rxjs.merge(rxjs.fromEvent($canvas, "keyup"), action$).pipe(rxjs.tap(() => { + $fab.classList.remove("hidden"); + $fab.render($ICON.SAVING); + $fab.onclick = () => { + $fab.render($ICON.LOADING); + $fab.disabled = true; + port.postMessage({ cmd: "save" }); + }; + }))); + + // feature5: load file + await effect(ajax({ url: getDownloadUrl(), responseType: "arraybuffer" }).pipe( + rxjs.mergeMap(async ({ response }) => { + try { FS.mkdir("/tmp/office/"); } catch {} + await FS.writeFile("/tmp/office/" + filename , new Uint8Array(response)); + await port.postMessage({ cmd: "load", filename, mime }); + onDestroy(() => FS.unlink("/tmp/office/" + filename)); + $canvas.focus(); + }), + )); + await new Promise((resolve) => { + port.onmessage = function(e) { + switch (e.data.cmd) { + case "loaded": + window.dispatchEvent(new Event("resize")); + setTimeout(() => { + resolve(); + $canvas.style.visibility = "visible"; + }, 250); + break; + case "save": + const bytes = FS.readFile("/tmp/office/" + filename); + effect(save(new Blob([bytes], {})).pipe(rxjs.tap(() => { + $fab.classList.add("hidden"); + $fab.render($ICON.SAVING); + $fab.disabled = false; + }))); + break; + case "setFormat": + switch(e.data.id) { + case "Bold": + e.data.state ? $toolbar.bold.classList.add("active") : $toolbar.bold.classList.remove("active"); + break; + case "Italic": + e.data.state ? $toolbar.italic.classList.add("active") : $toolbar.italic.classList.remove("active"); + break; + case "Underline": + e.data.state ? $toolbar.underline.classList.add("active") : $toolbar.underline.classList.remove("active"); + break; + case "Strikeout": + e.data.state ? $toolbar.strike.classList.add("active") : $toolbar.strike.classList.remove("active"); + break; + case "LeftPara": + if (e.data.state) qs($toolbar.alignment, "select").value = "left"; + break; + case "RightPara": + if (e.data.state) qs($toolbar.alignment, "select").value = "right"; + break; + case "CenterPara": + if (e.data.state) qs($toolbar.alignment, "select").value = "center"; + break; + case "JustifyPara": + if (e.data.state) qs($toolbar.alignment, "select").value = "justify"; + break; + case "DefaultBullet": + qs($toolbar.bullet, "select").value = e.data.state ? "ul" : "normal"; + break; + case "DefaultNumbering": + qs($toolbar.bullet, "select").value = e.data.state ? "ol" : "normal"; + break; + case "StyleApply": + let value = "normal"; + if (e.data.state === "Title") value = "title"; + else if (e.data.state === "Heading 1") value = "head1"; + else if (e.data.state === "Heading 2") value = "head2"; + else if (e.data.state === "Heading 3") value = "head3"; + qs($toolbar.title, "select").value = value; + break; + case "Color": + const hex = e.data.state && e.data.state > 0 ? "#" + e.data.state.toString(16).padStart(6, "0") : "#000000"; + $toolbar.color.children[0].style.fill = hex; + $toolbar.color.children[1].value = hex; + break; + case "FontHeight": + const fontSize = e.data.state; + qs($toolbar.size, "input").value = fontSize; + break; + default: + console.log("format", e); + throw new Error("Unknown format"); + } + $canvas.focus(); + break; + default: + console.log("message", e); + throw new Error("Unknown message"); + } + }; + }); + + // feature6: toolbar events + $toolbar.bold.onclick = () => { + $toolbar.bold.classList.toggle("active"); + action$.next(); + port.postMessage({ cmd: "toggleFormatting", id: "Bold" }); + }; + $toolbar.italic.onclick = () => { + $toolbar.italic.classList.toggle("active"); + action$.next(); + port.postMessage({ cmd: "toggleFormatting", id: "Italic" }); + }; + $toolbar.underline.onclick = () => { + $toolbar.underline.classList.toggle("active"); + action$.next(); + port.postMessage({ cmd: "toggleFormatting", id: "Underline" }); + }; + $toolbar.bullet.onchange = (e) => { + switch(e.target.value) { + case "normal": + port.postMessage({ cmd: "toggleFormatting", id: "RemoveBullets" }); + break; + case "ul": + port.postMessage({ cmd: "toggleFormatting", id: "DefaultBullet" }); + break; + case "ol": + port.postMessage({ cmd: "toggleFormatting", id: "DefaultNumbering" }); + break; + } + action$.next(); + }; + $toolbar.strike.onclick = () => { + $toolbar.strike.classList.toggle("active"); + action$.next(); + port.postMessage({ cmd: "toggleFormatting", id: "Strikeout" }); + }; + $toolbar.alignment.onchange = (e) => { + switch(e.target.value) { + case "left": + port.postMessage({ cmd: "toggleFormatting", id: "LeftPara" }); + break; + case "right": + port.postMessage({ cmd: "toggleFormatting", id: "RightPara" }); + break; + case "center": + port.postMessage({ cmd: "toggleFormatting", id: "CenterPara" }); + break; + case "justify": + port.postMessage({ cmd: "toggleFormatting", id: "JustifyPara" }); + break; + default: + throw new Error("Unknown tool alignment"); + } + action$.next(); + }; + $toolbar.title.onchange = (e) => { + switch(e.target.value) { + case "normal": + port.postMessage({ cmd: "toggleFormatting", id: "StyleApply?Style:string=Standard&FamilyName:string=ParagraphStyles" }); + break; + case "title": + port.postMessage({ cmd: "toggleFormatting", id: "StyleApply?Style:string=Title&FamilyName:string=ParagraphStyles" }); + break; + case "head1": + port.postMessage({ cmd: "toggleFormatting", id: "StyleApply?Style:string=Heading 1&FamilyName:string=ParagraphStyles" }); + break; + case "head2": + port.postMessage({ cmd: "toggleFormatting", id: "StyleApply?Style:string=Heading 2&FamilyName:string=ParagraphStyles" }); + break; + case "head3": + port.postMessage({ cmd: "toggleFormatting", id: "StyleApply?Style:string=Heading 3&FamilyName:string=ParagraphStyles" }); + break; + default: + throw new Error("Unknown text style"); + } + action$.next(); + }; + $toolbar.color.onclick = (e) => { + if (e.target.tagName === "INPUT") return; + const $svg = e.target.closest("svg") + const $input = $svg.nextElementSibling; + $input.onchange = (e) => { + $svg.style.fill = e.target.value; + const color = parseInt(e.target.value.slice(1), 16); + port.postMessage({ cmd: "toggleFormatting", id: `Color?Color:long=${color}` }) + }; + $input.click(); + action$.next(); + }; + effect(rxjs.fromEvent(qs($toolbar.size, "input"), "keyup").pipe( + rxjs.debounceTime(250), + rxjs.tap((e) => { + const fontSize = parseInt(e.target.value); + port.postMessage({ cmd: "toggleFormatting", id: `FontHeight?FontHeight.Height:float=${fontSize}` }); + action$.next(); + }), + )); + + // feature7: workaround known lowa bug + // - when pressing escape, lowa goes out of fullscreen and show some unwanted stuff + // - context menu functions like "replace" image which does crash everything with errors generated from soffice.js + // - ctrl + s is broken + effect(rxjs.fromEvent($page, "keydown", { capture: true }).pipe(rxjs.tap((e) => { + if (e.key === "Escape") e.stopPropagation(); + if (e.key === "s" && e.ctrlKey) e.stopPropagation(); + }))); + effect(rxjs.fromEvent($page, "mousedown", { capture: true }).pipe(rxjs.tap((e) => { + if (e.which === 3) e.stopPropagation(); + }))); +} + +function isWriter(mime) { + return ["application/word", "application/msword", "application/rtf", "application/vnd.oasis.opendocument.text"].indexOf(mime) >= 0; +} diff --git a/server/plugin/plg_application_office/loader_lowa.uno.js b/server/plugin/plg_application_office/loader_lowa.uno.js new file mode 100644 index 00000000..c9634c32 --- /dev/null +++ b/server/plugin/plg_application_office/loader_lowa.uno.js @@ -0,0 +1,123 @@ +// reference: +// - uno programming: https://www.youtube.com/watch?v=CzxLKG9CUvo +// - dispatch commands: https://wiki.documentfoundation.org/Development/DispatchCommands +Module.zetajs.then(function(zetajs) { + init({ + css: zetajs.uno.com.sun.star, + zetajs, + }); +}); + +function init({ zetajs, css }) { + const context = zetajs.getUnoComponentContext(); + const desktop = css.frame.Desktop.create(context); + let ctrl, xModel; + + // UI Element: remove toolbar in writer + const config = css.configuration.ReadWriteAccess.create(context, "en-US"); + ["Writer", "Calc", "Impress"].forEach((app) => { + const uielems = config.getByHierarchicalName(`/org.openoffice.Office.UI.${app}WindowState/UIElements/States`); + for (const i of uielems.getElementNames()) { + const uielem = uielems.getByName(i); + if (uielem.getByName("Visible")) uielem.setPropertyValue("Visible", false); + } + }); + + // Theme & Colors + const elmnts = config.getByHierarchicalName("/org.openoffice.Office.UI/ColorScheme/ColorSchemes"); + for (const i of elmnts.getElementNames()) { + const colorScheme = elmnts.getByName(i); + // console.log(colorScheme.getElementNames()); + colorScheme.getByName("AppBackground").setPropertyValue("Color", 16119285); // #f5f5f5 + colorScheme.getByName("WriterPageBreaks").setPropertyValue("Color", 16119285); // #f5f5f5 + colorScheme.getByName("WriterSectionBoundaries").setPropertyValue("Color", 16119285); // #f5f5f5 + colorScheme.getByName("Shadow").setPropertyValue("Color", 16119285); // #f5f5f5 + colorScheme.getByName("FontColor").setPropertyValue("Color", 2368548); // #242424 + colorScheme.getByName("WriterHeaderFooterMark").setPropertyValue("Color", 16777215); // #ffffff + } + config.commitChanges(); + + zetajs.mainPort.onmessage = function(e) { + switch (e.data.cmd) { + case "destroy": + toggleTools({ mime: e.data.mime, css, ctrl, context }); + xModel = null; + ctrl = null; + break; + case "load": + const { filename, mime } = e.data; + const in_path = `file:///tmp/office/${filename}`; + xModel = desktop.loadComponentFromURL(in_path, "_default", 0, []); + ctrl = xModel.getCurrentController(); + ctrl.getFrame().LayoutManager.hideElement("private:resource/menubar/menubar"); + ctrl.getFrame().LayoutManager.hideElement("private:resource/statusbar/statusbar"); + ctrl.getFrame().getContainerWindow().FullScreen = true; + toggleTools({ mime, css, ctrl, context }); + const commands = [ // ref: https://wiki.documentfoundation.org/Development/DispatchCommands + "Bold", "Italic", "Underline", "Strikeout", "LeftPara", "RightPara", "CenterPara", + "JustifyPara", "Color", "FontHeight", ...(isWriter(mime) ? ["StyleApply", "DefaultBullet", "DefaultNumbering"] : []), + ]; + for (const id of commands) { + const urlObj = transformUrl(".uno:" + id, { css, context }); + const listener = zetajs.unoObject([css.frame.XStatusListener], { + disposing: function(source) {}, + statusChanged: function(state) { + state = zetajs.fromAny(state.State); + if (id === "StyleApply") state = state && state.StyleName || null; + else if (id === "Color") state = typeof state === "number" ? state : null; + else if (id === "FontHeight") state = state && state.Height || null; + else if (typeof state !== "boolean") state = false; + + if (state === null) return; + zetajs.mainPort.postMessage({ cmd: "setFormat", id, state }); + }, + }); + queryDispatch(urlObj, { ctrl }).addStatusListener(listener, urlObj); + } + zetajs.mainPort.postMessage({ cmd: "loaded" }); + break; + case "save": + xModel.store(); + zetajs.mainPort.postMessage({ cmd: "save" }); + break; + case "toggleFormatting": + dispatch(".uno:" + e.data.id, { css, ctrl, context }); + break; + default: + throw Error("Unknown message command: " + e.data.cmd); + } + } +} + +function transformUrl(unoUrl, { css, context }) { + const ioparam = { + val: new css.util.URL({ + Complete: unoUrl + }), + }; + css.util.URLTransformer.create(context).parseStrict(ioparam); + return ioparam.val; +} + +function queryDispatch(urlObj, { ctrl }) { + return ctrl.queryDispatch(urlObj, "_self", 0); +} + +function dispatch(unoUrl, { css, ctrl, context }) { + const urlObj = transformUrl(unoUrl, { css, context }); + queryDispatch(urlObj, { ctrl }).dispatch(urlObj, []); +} + +function toggleTools({ css, ctrl, context, mime }) { + dispatch(".uno:Sidebar", { css, ctrl, context }); + if (isCalc(mime)) dispatch(".uno:InputLineVisible", { css, ctrl, context }); + if (isWriter(mime)) dispatch(".uno:Ruler", { css, ctrl, context }); +} + +function isWriter(mime) { + return ["application/word", "application/msword", "application/rtf", "application/vnd.oasis.opendocument.text"].indexOf(mime) >= 0; +} + +function isCalc(mime) { + return ["application/excel", "application/vnd.ms-excel", "application/vnd.oasis.opendocument.spreadsheet"].indexOf(mime) >= 0; +} diff --git a/server/plugin/plg_application_office/manifest.json b/server/plugin/plg_application_office/manifest.json new file mode 100644 index 00000000..f06fa5d3 --- /dev/null +++ b/server/plugin/plg_application_office/manifest.json @@ -0,0 +1,66 @@ +{ + "author": "Filestash Pty Ltd", + "version": "v0.0", + "modules": [ + { + "type": "xdg-open", + "mime": "application/word", + "entrypoint": "loader_lowa.js", + "application": "skeleton" + }, + { + "type": "xdg-open", + "mime": "application/msword", + "entrypoint": "loader_lowa.js", + "application": "skeleton" + }, + { + "type": "xdg-open", + "mime": "application/rtf", + "entrypoint": "loader_lowa.js", + "application": "skeleton" + }, + { + "type": "xdg-open", + "mime": "application/vnd.oasis.opendocument.text", + "entrypoint": "loader_lowa.js", + "application": "skeleton" + }, + { + "type": "xdg-open", + "mime": "application/excel", + "entrypoint": "loader_lowa.js", + "application": "skeleton" + }, + { + "type": "xdg-open", + "mime": "application/vnd.ms-excel", + "entrypoint": "loader_lowa.js", + "application": "skeleton" + }, + { + "type": "xdg-open", + "mime": "application/vnd.oasis.opendocument.spreadsheet", + "entrypoint": "loader_lowa.js", + "application": "skeleton" + }, + { + "type": "xdg-open", + "mime": "application/powerpoint", + "entrypoint": "loader_lowa.js", + "application": "skeleton" + }, + { + "type": "xdg-open", + "mime": "application/vnd.ms-powerpoint", + "entrypoint": "loader_lowa.js", + "application": "skeleton" + }, + { + "type": "xdg-open", + "mime": "application/vnd.oasis.opendocument.presentation", + "entrypoint": "loader_lowa.js", + "application": "skeleton" + } + ] +}