feature (plg_application_office): libreoffice lowa

This commit is contained in:
MickaelK 2025-07-18 20:29:17 +10:00
parent 083eac248f
commit 8a36ba943a
7 changed files with 573 additions and 0 deletions

View file

@ -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"

View file

@ -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

View file

@ -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;
}

View file

@ -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)
}
})
}

View file

@ -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(`
<div class="component_word">
<canvas id="qtcanvas" contenteditable="${canWrite}" style="visibility:hidden"></canvas>
<button is="component-fab" class="hidden"></button>
</div>
`);
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;
}

View file

@ -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;
}

View file

@ -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"
}
]
}