Skip to content

Instantly share code, notes, and snippets.

@richkuz
Created December 31, 2024 22:39
Show Gist options
  • Save richkuz/2f5d8b8204e291892862f4e500141d0c to your computer and use it in GitHub Desktop.
Save richkuz/2f5d8b8204e291892862f4e500141d0c to your computer and use it in GitHub Desktop.
OnlyOffice DocumentEditor React and Python Example using forcesave and JWT tokens

I was able to render an OnlyOffice document in React using the OnlyOffice local document server running in Docker with a JWT key and token.

This version also supports saving the document to my server automatically or on-demand.

Issues solved with this example include:

  1. Succesfully loading a document hosted on my local Python flask app when the JWT token setting is enabled while running OnlyOffice document server in docker on Mac OSX.

  2. Successfully allow my app to "Save" the document to my local server.

  3. Avoid CORS issues when sending a forcesave command to the command server by setting mode: 'no-cors'

In my case I hosted my document from a Python flask app running locally not in Docker.

Command to run the OnlyOffice server in Docker:

sudo docker run -it -p 5001:80 -e JWT_SECRET=my_jwt_secret onlyoffice/documentserver

Wrapper component for the OnlyOffice DocumentEditor:

import { DocumentEditor } from "@onlyoffice/document-editor-react";
import * as jose from "jose";
import { useEffect, useState, useMemo, useRef } from "react";

// Attribution for the token payload encoding:
// https://github.com/igorwang/agenthub/blob/6e1f8f043cb04410b170144f2d07ad4abe1a5c42/components/OfficeEditor/index.tsx#L4
async function encodePayload(payload) {
  try {
    // Generate a secret key for signing
    const secretKey = new TextEncoder().encode("my_jwt_secret");

    // Create a new JWT and sign it
    const jwt = await new jose.SignJWT(payload)
      .setProtectedHeader({ alg: "HS256" })
      .setIssuedAt()
      .setExpirationTime("2h") // Token expires in 2 hours
      .sign(secretKey);

    return jwt;
  } catch (error) {
    console.error("Error encoding payload:", error);
    throw error;
  }
}

function onLoadComponentError(errorCode, errorDescription) {
  switch (errorCode) {
    case -1:
      console.log("DocxViewer error -1: Unknown error loading component");
      break;

    case -2:
      console.log("DocxViewer error -2: Error loading DocsAPI from document server");
      break;

    case -3: // 
      console.log("DocxViewer error -3: DocsAPI is not defined");
      break;
    
    default:
      console.log("DocxViewer error " + errorCode);
      break;
  }
  console.log(errorDescription)
}

export default function DocxViewer({ docKey, url, title, callbackUrl }) {
  const [token, setToken] = useState("");

  const handleDocumentReady = () => {
    const docEditor = window.DocEditor.instances[`docxEditor-${docKey}`];
    console.log("Document loaded, DocEditor instance: ", docEditor);
  };

  function onDocumentStateChange(event) {
    console.log('Document updated');
    
    const forceSave = async () => {
      const payload = {
        "c": "forcesave",
        "key": docKey,
      }
      const tempToken = await encodePayload(payload);
      payload["token"] = tempToken;
      const payloadStr = JSON.stringify(payload);
      console.log("payload: " + payloadStr);

      await fetch('http://localhost:5001/command?c=forcesave', {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
        },
        mode: 'no-cors',  // because CORS
        body: payloadStr,
      });
    }
    forceSave();
  }
  
  const config = useMemo(() => ({
    document: {
      fileType: "docx",
      key: docKey,
      title: title,
      url: url,
      permissions: {
        edit: true,
      },
    },
    documentType: "word",
    height: "100%",
    editorConfig: {
        mode: "edit",
        customization: {
          forcesave: true, // Required so we can send the forcesave command to OnlyOffice doc server
        },
        callbackUrl: callbackUrl,
    },
  }), [docKey, url, title, callbackUrl]);

  useEffect(() => {
    const fetchToken = async () => {
      const token = await encodePayload(config);
      setToken(token);
    };
    fetchToken();
  }, [config]);

  if (!token) return <div>Loading...</div>;

  return (
    <div className="h-full flex flex-col">
      <div className="flex-1">
        <DocumentEditor
          id={`docxEditor-${docKey}`}
          documentServerUrl="http://localhost:5001"
          config={{ ...config, token }}
          onLoadComponentError={onLoadComponentError}
          events_onDocumentReady={handleDocumentReady}
          events_onDocumentStateChange={onDocumentStateChange}
        />
      </div>
    </div>
  );
}

Usage from the React App.js:

// somewhere set:
const docKey = "docx-" + Math.random();

<DocxViewer
  docKey={docKey}
  title={`some_doc.docx`}
  url={`http://host.docker.internal:5000/path_to_docx_on_your_localhost_server`}
  callbackUrl={`http://host.docker.internal:5000/onlyoffice_save_callback`}
/>

Example Python flask app routes running on localhost:5000:

# REPLACEME - storing this in a global var, good enough for local dev apps, but not for multiple instances of the app
shared_instance_vars = {}

@app.route('/onlyoffice_save_callback', methods=['POST'])
def onlyoffice_save_callback():
    # Each onlyoffice editor has a unique doc key for the instance of the doc it's working on,
    # and the onlyoffice document server also maintains a download URL where you can
    # always fetch the latest saved version of the doc.
    # Persist the doc key and URL here in case later on the user wants to "save" the doc (i.e. copy it to server files/ dir)
    json_data = request.get_json()
    key = json_data.get('key')
    download_url = json_data.get("url")
    print(f"Download URL for {key}: {download_url}")

    shared_instance_vars[key] = {
        "download_url": download_url
    }
    return {'error':0}, 200

@app.route('/document/save', methods=['POST'])
def handle_docx_save():
    doc_key = request.args.get('doc_key', default=None)
    if doc_key is None:
        abort(400)
    download_url = shared_instance_vars[doc_key]["download_url"]
    output_path = safe_join(current_app.root_path, 'files', secure_filename(pipeline_id), 'foo.docx)
    print(f"Persisting {download_url} to {output_path}")
    # TODO ... save the file locally

    return {'error':0}, 200

And then somewhere in the React app you would invoke /document/save on the Python app to trigger the actual save.

In this setup, "save" really means "download the latest document from OnlyOffice via its own download URL and copy it someplace else."

Also if you ever need to interact with the OnlyOffice DocEditor JS object itself from the browser for debugging purposes, use:

const editor = DocEditor.instances[Object.keys(DocEditor.instances)[0]]
@richkuz
Copy link
Author

richkuz commented Jan 10, 2025

Note that there's a bug where OnlyOffice's editor will try to grab focus a few seconds after it loads, causing some apps to annoyingly scroll to the editor.

OnlyOffice's app.js runs this code:

   e.getController("DocumentHolder").getView().focus()

in its onEditComplete handler a few seconds after the editor loads, causing the iframe's editor_doc div to steal focus and scroll to it when it loads.

Because the OnlyOffice editor is in an iframe, we cannot intercept or change the focus event.

So instead we can install a 1-time scroll listener to watch for a large rapid shift in the scroll left, then scroll back.

e.g.:

  const getColumnGridDiv = () => {
    // Replace with the selector for your parent scrollable div container, or the document itself.
    return document.querySelector("#root > div > div.flex-1 > div");
  }
  const handleDocumentReady = () => {
    // window.DocEditor.instances[Object.keys(window.DocEditor.instances)[0]]
    // const docEditor = window.DocEditor.instances[`docxEditor-${docKey}`];
    let lastScrollLeft = getColumnGridDiv()?.scrollLeft || 0;
    const handleScroll = (e) => {
      const columnGridDiv = getColumnGridDiv();
      if (e.target === columnGridDiv) {
        const curScrollLeft = columnGridDiv.scrollLeft;
        if (Math.abs(lastScrollLeft - curScrollLeft) > 50) {
          // cancel the scroll
          columnGridDiv.scrollLeft = lastScrollLeft;
          columnGridDiv.removeEventListener('scroll', handleScroll);
        }
        else {
          lastScrollLeft = curScrollLeft;
        }
      }
    }
    getColumnGridDiv().addEventListener('scroll', handleScroll);
    setTimeout(() => {
      // No matter what, clear this event listener after 10s.
      // The docx editor steals focus well within 10 seconds.
      getColumnGridDiv()?.removeEventListener('scroll', handleScroll);
    }, 10000);
  };

...

<DocumentEditor
          id={`docxEditor-${docKey}`}
          documentServerUrl="http://localhost:5001"
          config={{ ...config, token }}
          onLoadComponentError={onLoadComponentError}
          events_onDocumentReady={handleDocumentReady}
          events_onDocumentStateChange={onDocumentStateChange}
        />

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment