DEV Community

Cover image for How I Built a 100% Client-Side Word Processor
Giovane Cardoso
Giovane Cardoso

Posted on

How I Built a 100% Client-Side Word Processor

After launching version 1.1 of betterwrite.io (and probably the last major one), deciding to write on a trajectory that lasts for almost 2 (two) years and which may last longer.

Warning from before that this project is not an example of software architecture, given its many technology changes, features that have been completely removed, forgotten or built without use; typos, dubious decisions and missed conversations.

Treat the word processor as a mere project for personal use, built by just one person and gone further than it should have. So, you won't be annoyed by the following review.

Better Write Cover

A Word Processor Focused on Creative Writing

The idea came up during the peak of the COVID-19 pandemic, where I divided my time between freelances without much desire and procrastination in the sheer boredom of social isolation.

Without further details, the idle time made me want to continue or start something personal that might or might not be useful for my work, but one factor was decisive for choosing a project that involved both programming and writing: the slowness of Google Documents.

The Cursed Canvas

As much as there are other considerably good options on the market, such as Microsoft Word, Overleaf, thousands of WYSIWYG editors that transform markdown into PDF and even notepad++, at the end of the day, being hostage to practicality and tools from Google Docs was a daily action.

But a problem was recurrent: When surpassing about 100 (one hundred) pages with large paragraphs and color and spacing customizations, the editor was considerably slow and required me to separate the project into other files and join them only at the end.

Consisting of a single canvas, all manipulation, and transformation depends on it, which means that even what is not being displayed is being considered valid in the context. Treating as a unit, not as a block, has its advantages, such as free customization and extensibility, but inserting a line break, page break or changing text positions shouldn't be costly.

These difficulties, added to the fact that practically all extensions (which considerably improve the editor as a whole) broke every release (like the beloved Dark Theme) made me decide: build my own or continue an existing project that was precisely what I wanted.

Obviously Google Docs is much more robust than Betterwrite, in addition to being generalist. Comparing them directly doesn't make much sense, as they have completely different focuses (and investments).

Fontaine Editor — A modern, multi-platform manuscript editor.

An initiative of this scale is a difficult idea to take seriously, especially when whoever is building the tool has already had several projects abandoned for several different reasons, but when finding Fontaine I ended up changing my opinion a little.

Fontaine consisted of a manuscript editor and provided control tools and an easy-to-use interface. However, the project was stopped, and its status did not allow me to use it due to the lack of document export.

Having it discontinued unfinished was a clear negative sign, but its simplicity in displaying relevant information, even though it was not of a high level of complexity, gave me enough courage to start a project with Vue 2, Vite, Typescript, PDFMake and TailwindCSS.

MVP

The above version, v0.1.2, was the first versioned (recovered for this, thanks git tag) and developed relatively quickly (rushing), consisting of a block editor where each line was a <textarea /> that barely worked properly, a cloud save with a .json via the SDK of Dropbox (one of features removed), shortcuts for each sidebar item (also removed), a list of chapters and the dreamed PDF generator.

Event System

Firstly, the entire logic of the application consists of issuing events anonymously and carrying out each job individually. Using mitt for this, all events of a given set (currently called plugin) are registered by a key and called even though they are not inside a setup().

Registered plugins have access to stores (pinia stores) and vue hooks, making it possible to decouple each functionality and facilitate maintenance.

The following is the flow of the PDF:

export const PDFPlugin = (): PluginTypes.Plugin =>
  createPlugin({ name: 'pdf' }, [PluginPDFBase, PluginPDFSet])

// ...
const PluginEntityInputInitial = (
  emitter: PluginTypes.PluginEmitter,
  content: PluginTypes.PluginContentOn
) => {
  emitter.on(
    'plugin-input-watch-initial',
    (item: PluginTypes.PluginLoggerDefault) => {
      if (!item.data) return

      const created = content[0]

      created && created(item)
    }
  )

  // ...
}

// in pdf monorepo
On.externals().PluginPDFGenerate(emitter, [
  (options: PDFDocOptions) => {
    // ...

    hooks.storage.normalize().then(() => {
      create(options)
    })
  },
  () => {},
])
Enter fullscreen mode Exit fullscreen mode
// App.vue
<template>
  <router-view></router-view>
  <PWAPrompt />
</template>

<script setup lang="ts">
  import { useStart } from '@/use/start'
  import { ThemePlugin } from 'better-write-plugin-theme'
  import { ImporterPlugin } from 'better-write-plugin-importer'
  import { PDFPlugin } from 'better-write-plugin-exporter-pdf'
  import { DocxPlugin } from 'better-write-plugin-exporter-docx'
  import { TxtPlugin } from 'better-write-plugin-exporter-txt'
  import { HtmlPlugin } from 'better-write-plugin-exporter-html'
  import { AnnotationsPlugin } from 'better-write-plugin-annotations'
  import { EpubPlugin } from 'better-write-plugin-exporter-epub'

  useStart([
    ThemePlugin(),
    ImporterPlugin(),
    PDFPlugin(),
    DocxPlugin(),
    EpubPlugin(),
    TxtPlugin(),
    HtmlPlugin(),
    AnnotationsPlugin(),
  ]).init()
</script>
Enter fullscreen mode Exit fullscreen mode

As you can see, this system enabled the migration to mono-repositories, using turborepo and lerna to adjust the flow and enable the decentralization of internal packages.

The PDF Generator

Speaking of the generator, it implied 10 (ten) mb of .tff in the bundle and had an unbearable bug when attaching external sources (and which continued for a long time), which consisted of not allowing the use for being a readonly method and that made its customization usable only in a development environment. However, it was (and still is, as it has not been completely rewritten) useful to me.

At the time I didn't know about skipTsLibCheck.

pdfeasy was built to replace pdfmake, but since pdfmake received updates that tweaked the most of the problems ended up being maintained.

The other generators implemented follow a flow very similar to that of the PDF.

Model-Entity

As much as it has undergone severe changes in the new versions, the concept is the same: a list of entities, and each entity consists of blocks of different types such as paragraphs, list, image, line/page break, among others.

<template>
  //...
  <EditorEntityDefault
    v-for="(element, index) in CONTEXT.entities"
    :id="`entity-${index}`"
    :key="index"
    :entity="element"
  />
  // ...
</template>
Enter fullscreen mode Exit fullscreen mode

Its big difference to block-style editors is that each event is emitted individually, and each event entry is triggered only when needed.

For example, text blocks do not use Vue's v-model, explained in this file, where the value of each el.innerHTML (new blocks use contenteditable in place of <input />) is computed only when a newline is added or another chapter is loaded.

await storage.normalize()

// ...
const setData = (val: string) => {
  input.value.innerHTML = val
}

const save = (target: number, raw: string) => {
  if (raw === null) return

  CONTEXT.entities[target].raw = raw
}
Enter fullscreen mode Exit fullscreen mode

It may seem like a silly approach, but this allows massive bodies of entities being rendered in the same chapter to have fluid typing in text entries, which is a common problem in WYSIWYG editors.

The display sidebar is only changed when a block needs to be updated, which is a limitation of this approach, but something to overlook.

The list uses the key-index of the list itself for each entity to infer which block is at the front or back in O(1), such as adding an extra empty line after a page/line break or for deleting multiple items.

The find/replace text uses a different list view as it has a different view proposition:

<template>
  <EditorBaseRenderFinder v-if="ABSOLUTE.shortcuts.finder" />
  <EditorBaseRenderSwitcher v-else-if="ABSOLUTE.shortcuts.switcher" />
  <EditorBaseRenderDefault v-else />
</template>

<script setup lang="ts">
  import { useAbsoluteStore } from '@/store/absolute'

  const ABSOLUTE = useAbsoluteStore()
</script>
Enter fullscreen mode Exit fullscreen mode

Other features follow a similar philosophy, such as the annotations markdown editor that uses debounce time to save changes and automatic saving that only occurs in case of context destruction:

// annotations monorepo
const fn = hooks.vueuse.core.useDebounceFn(doc => {
  setFile(file.id, doc.toJSON())
}, 500)
Enter fullscreen mode Exit fullscreen mode
// editor.ts
const inForcedSave = async () => {
  if (EDITOR.configuration.autosave) {
    await storage.normalize()
    await local.onSaveProject(false)
  }

  if (EDITOR.configuration.cloudAutosave && online.value) {
    await cloud.saveProject()
  }
}

useEventListener('beforeunload', async () => {
  await inForcedSave()
})

onBeforeRouteLeave(async () => {
  await inForcedSave()
})
Enter fullscreen mode Exit fullscreen mode

100% Client-Side

Betterwrite has a simple concept as an application: only static files, that is, without the need for SSR. This is only possible due to the polyfills that allow the import and generation of documents using the API of the browsers themselves. By Vite, node-stdlib-browser was used to accomplish this task:

import inject from '@rollup/plugin-inject'
import { Plugin } from 'vite'

export const viteStdlib = (): Plugin => {
  return {...inject({
    global: [
      require.resolve(
        'node-stdlib-browser/helpers/esbuild/shim'
      ),
      'global'
    ],
    process: [
      require.resolve(
        'node-stdlib-browser/helpers/esbuild/shim'
      ),
      'process'
    ],
    Buffer: [
      require.resolve(
        'node-stdlib-browser/helpers/esbuild/shim'
      ),
      'Buffer'
    ]
  }),
  enforce: 'post'
}}
Enter fullscreen mode Exit fullscreen mode

As the word processor uses most polyfills, it turns out to be worth injecting them all at once.

Another important point is the migration of the SDK from Dropbox to Supabase, which made it possible to authenticate users and save the project in the cloud safely on the client-side.

Even the word processor, being static, is hosted for free. Thank you Vercel!

If you want more information about 100% Client-Side, see the application's technical documentation.

The Progress

When I decided to go ahead with this project, I made some drastic changes that (feel) paid off.

To validate the editor and his idea, some book would have to serve as a guinea pig, and that is why Restos da Geada - Entre Plano was chosen. Self-publish, being my first book released in my name that was completed, consequently gave me the freedom to do whatever I wanted with the editor (like losing paragraphs due to editor error without fearing deadlines). It was started in Google Docs, but was migrated to Betterwrite, having its project initialized in v0.0.1.

A war survivor, he was responsible for compatibility between versions, the need for authentication, all the features focused on creativity and the ability to place images on break lines.

A curious fact is that support for importing in other extensions only existed in v0.10, so a copy-paste of raw HTML between blocks exclusively for this task was implemented, feature also removed .

Another curious fact is that your project was lost in a test in production, but it was recovered in an old locally saved project (.bw) and its values ​​were copied from .json by hand.

Electron

Before adopting PWA, Electron was implemented almost completely (with support to other operating systems and automatic updates), but due to some limitations of Vue (and the Vite plugin) it was decided to "throw out" the entire implementation and make room for a hybrid application.

The PWA went hand in hand by allowing caching in service-worker certain requests (gstatic) and required files (a .ttf font for pdfmake and a typeface font used by *threejs * on the landing).

WindiCSS

The project was migrated from TailwindCSS to WindiCSS due to the delayed release of TailwindCSS 3.0, with HeadlessUI being a big part of material design. The hot-reload saved me hours in development mode.

A migration to a proprietary Atomic CSS lib almost occurred, but the process was paused.

Bundle Size

The size of the final application was never of great concern, but when migrating to PWA, the loading time was drastically off the curve (negatively).

It is currently acceptable for your proposal, but it can be improved.

The actual cache can reach up to 20 (twenty) mb depending on usage.

1.1

Its major version has been released, but 1.1 is the true stable version, fixing chronic application problems and bringing with it the editor's true initial objective: the focus on creativity.

Top comments (0)