Save and Load

This guide extends upon the Basic Tutorial

Overview

Previously, we saw how we could serialize the entire state of Nodes in our editor into JSON. Of course, you probably will not want to store the JSON in your server or database, for obvious reasons. Instead, you should first employ a text compression technique of your choice to compress the serialized JSON Nodes.

In this guide, we'll be mainly modifying the previous tutorial's Topbar component. We'll add 2 new features

  • Copy the compressed output of the serialized Nodes to the user's clipboard
  • Load the editor state from a compressed output of serialized Nodes.

We'll be using 2 external libraries - lzutf8 (for compression) and copy-to-clipboard (you know)

yarn add lzutf8 copy-to-clipboard

Copy compressed output

We'll use lzutf8 to compress our serialised JSON Nodes, and additionally transform it into base64.

import React, { useState } from 'react';

import Box from '@mui/material/Box';
import MaterialButton from '@mui/material/Button';
import Grid from '@mui/material/Grid';
import Switch from '@mui/material/Switch';
import FormControlLabel from '@mui/material/FormControlLabel';
import Dialog from '@mui/material/Dialog';
import DialogTitle from '@mui/material/DialogTitle';
import DialogContent from '@mui/material/DialogContent';
import DialogContentText from '@mui/material/DialogContentText';
import DialogActions from '@mui/material/DialogActions';
import TextField from '@mui/material/TextField';
import Snackbar from '@mui/material/Snackbar';

import { useEditor } from '@webstencils/core';
import lz from 'lzutf8';
import copy from 'copy-to-clipboard';

export const Topbar = () => {
  const { actions, query, enabled } = useEditor((state) => ({
    enabled: state.options.enabled
  }));

  const [snackbarMessage, setSnackbarMessage] = useState();
  
  return (
    <Box px={1} py={1} mt={3} mb={1} bgcolor="#cbe8e7">
      <Grid container alignItems="center">
        <Grid item xs>
          <FormControlLabel
            className="enable-disable-toggle"
            control={<Switch checked={enabled} onChange={(_, value) => actions.setOptions(options => options.enabled = value)} />}
            label="Enable"
          />
        </Grid>
        <Grid item>
          <MaterialButton 
            className="copy-state-btn"
            size="small" 
            variant="outlined" 
            color="secondary"
            onClick={() => {
              const json = query.serialize();
              copy(lz.encodeBase64(lz.compress(json)));
              setSnackbarMessage("State copied to clipboard")
            }}
          >
              Copy current state
          </MaterialButton>
          <Snackbar
            autoHideDuration={1000}
            anchorOrigin={{ vertical: "bottom", horizontal: "center" }}
            open={!!snackbarMessage}
            onClose={() => setSnackbarMessage(null)}
            message={<span>{snackbarMessage}</span>}
          />
        </Grid>
      </Grid>
    </Box>
  )
};

When you click on the button now, it should copy the compressed base64 string to the clipboard.

Load state

Now let's implement the Load State button in our Topbar component. We will display a Dialog box when the button is clicked, and our users will be able to paste the compressed base64 string there.

Then, we will need to work in reverse to obtain the original JSON provided by our editor. Finally, we'll call the deserialize action which will result in the editor replacing all the current Nodes in the editor with the deserialized output.

import React, { useState } from 'react';

import Box from '@mui/material/Box';
import Grid from '@mui/material/Grid';
import MaterialButton from '@mui/material/Button';
import Switch from '@mui/material/Switch';
import FormControlLabel from '@mui/material/FormControlLabel';
import Dialog from '@mui/material/Dialog';
import DialogTitle from '@mui/material/DialogTitle';
import DialogContent from '@mui/material/DialogContent';
import DialogContentText from '@mui/material/DialogContentText';
import DialogActions from '@mui/material/DialogActions';
import TextField from '@mui/material/TextField';
import Snackbar from '@mui/material/Snackbar';

import { useEditor } from '@webstencils/core';
import lz from 'lzutf8';
import copy from 'copy-to-clipboard';

export const Topbar = () => {
  const { actions, query, enabled } = useEditor((state) => ({
    enabled: state.options.enabled
  }));

  const [dialogOpen, setDialogOpen] = useState(false);
  const [snackbarMessage, setSnackbarMessage] = useState();
  const [stateToLoad, setStateToLoad] = useState(null);

  return (
    <Box px={1} py={1} mt={3} mb={1} bgcolor="#cbe8e7">
      <Grid container alignItems="center">
        <Grid item xs>
          <FormControlLabel
            className="enable-disable-toggle"
            control={<Switch checked={enabled} onChange={(_, value) => actions.setOptions(options => options.enabled = value)} />}
            label="Enable"
          />
        </Grid>
        <Grid item>
          <MaterialButton 
            className="copy-state-btn"
            size="small" 
            variant="outlined" 
            color="secondary"
            onClick={() => {
              const json = query.serialize();
              copy(lz.encodeBase64(lz.compress(json)));
              setSnackbarMessage("State copied to clipboard")
            }}
          >
              Copy current state
          </MaterialButton>
          <MaterialButton 
            className="load-state-btn"
            size="small" 
            variant="outlined" 
            color="secondary"
            onClick={() => setDialogOpen(true)}
          >
              Load
          </MaterialButton>
          <Dialog
            open={dialogOpen}
            onClose={() => setDialogOpen(false)}
            fullWidth
            maxWidth="md"
          >
            <DialogTitle id="alert-dialog-title">Load state</DialogTitle>
            <DialogContent>
              <TextField 
                multiline 
                fullWidth
                placeholder='Paste the contents that was copied from the "Copy Current State" button'
                size="small"
                value={stateToLoad}
                onChange={e => setStateToLoad(e.target.value)}
              />
            </DialogContent>
            <DialogActions>
              <MaterialButton onClick={() => setDialogOpen(false)} color="primary">
                Cancel
              </MaterialButton>
              <MaterialButton 
                onClick={() => {
                  setDialogOpen(false);
                  const json = lz.decompress(lz.decodeBase64(stateToLoad));
                  actions.deserialize(json);
                  setSnackbarMessage("State loaded")
                }} 
                color="primary" 
                autoFocus
              >
                Load
              </MaterialButton>
            </DialogActions>
          </Dialog>
          <Snackbar
            autoHideDuration={1000}
            anchorOrigin={{ vertical: "bottom", horizontal: "center" }}
            open={!!snackbarMessage}
            onClose={() => setSnackbarMessage(null)}
            message={<span>{snackbarMessage}</span>}
          />
        </Grid>
      </Grid>
    </Box>
  )
};

Load JSON on page load

Of course, what if we wanted our editor to load a serialized output on page load? For this, we will need to take a step back and revisit the <Frame /> component which we encountered when we first set up WebStencils.

By default, it constructs the editor state based on what was initially rendered in its children. But, we could also specify the serialized JSON nodes to its json prop which would cause it to load the state from the JSON string instead.

import React, { useState, useEffect } from 'react';
import "../styles/main.css";

import Typography from '@mui/material/Typography';
import MaterialButton from '@mui/material/Button';
import Paper from '@mui/material/Paper';
import Grid from '@mui/material/Grid';

import { Toolbox } from '../components/Toolbox';
import { Container } from '../components/user/Container';
import { Button } from '../components/user/Button';
import { Card, CardBottom, CardTop } from '../components/user/Card';
import { Text } from '../components/user/Text';
import { SettingsPanel } from '../components/SettingsPanel';
import { Editor, Frame, Element } from "@webstencils/core";
import { Topbar } from '../components/Topbar';

export default function App() {
  const [enabled, setEnabled] = useState(true);
  const [json, setJson] = useState(null);

  // Load save state from server on page load
  useEffect(async () => {
    const stateToLoad = await fetch("your api to get the compressed data");
    const json = lz.decompress(lz.decodeBase64(stateToLoad));
    setJson(json);
  }, []);

  return (
    <div style={{margin: "0 auto", width: "800px"}}>
      <Typography style={{margin: "20px 0"}} variant="h5" align="center">Basic Page Editor</Typography>
        <Editor
          resolver={{Card, Button, Text, Container, CardTop, CardBottom}}
          enabled={enabled}
        > 
          <Topbar />
          <Grid container spacing={5} style={{paddingTop: "10px"}}>
            <Grid item xs>
              <Frame data={json}>
                <Element is={Container} padding={5} background="#eeeeee">
                  {/*...*/}
                </Element>
              </Frame>
            </Grid>
            <Grid item xs={4}>
              {/*...*/}
            </Grid>
          </Grid>
        </Editor>
    </div>
  );
}

All set!

Now, play with the editor and press the Copy Current State button when you are done. Refresh the page so the editor returns to its default state, then press the Load State button and paste the copied output - you should see the editor displaying the elements in the state from the time you copied.