Moradok Lanna
Dev notes

Jaan Editor (จ๋าร): a Lanna text editor in the browser

Jaan Editor lets someone write a document that mixes Thai, English and Lanna script, on a laptop or a phone, with no special software. This page is about the parts that were hard.

The name. จ๋าร (jaan) means to inscribe script, writing or patterns with a pointed iron stylus onto a palm leaf or stone (dictionary entry 2986). What you write with it is a Pabsa (ปั๊บสา, dictionary entry 5294), the book or manuscript made of mulberry (sa) paper, such as a treatise or scripture. The engine is Jaan; the saved, shareable document is a Pabsa.

The problem

Lanna script is not typed like Latin or Thai. The font encodes it with ordinary characters, marks combine and stack, and there is no standard keyboard layout. A writer also needs to switch between three things in one sentence: Thai, English, and Lanna. Meanwhile the site already has a dictionary of about 17,000 Lanna words, so the best way to write Lanna is often to pick the word, not to type its letters.

The model: a contenteditable with typed runs

The editor is a contenteditable element, because it gives native selection, IME, spell-check and accessibility for free. On top of it the document is a sequence of lines (paragraphs), and inside a line, Lanna text lives in spans of two kinds:

line  = [ text | word(ref, reading) | run(letters) ] ...
word  = atomic     -> deleted or copied as a whole
run   = editable   -> letters, may later be matched to a word

A reader can see at a glance which Lanna was verified by the dictionary and which was typed by hand.

Three input modes

ModeWhat it does
ไทย/EngNormal typing. Suggestions can still appear.
📖 พจนานุกรม (dictionary)Type a Thai reading or the Lanna key map and pick the word from a suggestion box. The inserted word is the dictionary's own spelling.
TilokDirect typing of Lanna letters with the key map. Produces a run.

Switching is a toolbar button, a shortcut (Alt+L toggles Thai/English and Tilok, Shift+Alt+L toggles the dictionary mode and back), and on a phone, switching to Tilok opens the on-screen Lanna keyboard.

The macOS inline-prediction bug, and "owning the keystroke"

The first versions decided what to do after the browser had changed the text. On macOS, inline prediction and autocorrect can rewrite a word as you press Space. A typed word followed by Space lost its last letters: the browser had already replaced the text before the editor looked at it.

The fix is a rule: for direct Tilok, the editor owns the keystroke. It handles keydown, cancels the browser's default action, and inserts the character itself. The browser's prediction never gets a chance to act.

on keydown(key) when mode == TILOK:
    if key is a printable letter or Space:
        preventDefault()
        insertIntoRun(map[key])      // we write the text
        return
    otherwise let the browser handle it

A hidden problem sat behind this one: when you leave Tilok mode, the caret is still inside the run, so the next Thai letter comes out in the Lanna font. Leaving a mode now steps the caret out of the run, splitting it if needed, and characters typed in other modes are always inserted outside it.

Composition-safe repair on mobile and IME

Phones and input methods use composition: the keyboard keeps a word "under construction" and may replace it several times. Fixing the DOM during composition breaks the IME and drops letters. So the editor cannot own every keystroke there. Instead:

  1. Never touch the text while a composition is active.
  2. When it ends, look at what the browser produced and repair it: move characters out of a run they should not be in, rejoin split runs, restore the caret.
  3. Do all of this as one undoable step.

The same repair pass cleans up after other quirks. For example, a clicked on-screen key at the end of a line used to become a stray styled span that the server-side cleaner then dropped.

Paste that keeps each language

Copying a mixed sentence and pasting it elsewhere should give the same sentence. The browser's plain clipboard loses the difference between a dictionary word and typed letters, so the editor writes a private clipboard flavour next to the plain text: markup that only Jaan produces, behind a marker.

copy:   clipboard.html  = marker + cleaned spans (words keep ref)
        clipboard.plain = the text as the browser gives it
paste:  if clipboard.html has the marker -> keep languages
        else if it came from a dictionary or board page -> Lanna becomes
             an unchecked run (the ref is unknown, so none is invented)
        else -> plain text

Pasted HTML is parsed in an inert document and reduced to text, line breaks and Jaan's own spans. Links, images, styles, scripts and every other attribute are dropped. Pasting inside a Lanna run splits the run first, so spans never nest. One paste is one undo step.

An on-screen keyboard that behaves like a tablet's

Most people have no Lanna keyboard, so the editor draws one: full-width staggered rows, the Lanna letter large in the centre of each key, the Thai or English label in the corner, a one-shot and lockable Shift, and combining marks drawn on a dotted circle so they are readable. Pressing a key sends the same events as the physical key, and the physical key lights up on the screen.

The hard part was layout. The keyboard is docked at the bottom of the screen like the iPad's, and the page gains bottom room so the text can scroll above it. After every action that moves the caret (typing, Enter, paste, picking a word, resize, rotating the phone) the editor scrolls just enough to keep the caret line above the keyboard. Because focusing a very tall editor jumps the page to its top, every focus call uses a no-scroll option and the caret is brought into view explicitly. The same pattern fixed a bug where picking a word near the bottom of a long text scrolled the page to the top.

A help panel inside the tool

A ❓ button opens a panel in the editor with tabs for getting started, shortcuts, the keyboard, the colours and the assistant tools. It is a bottom sheet on phones. The help text lives in one structure, so a release that changes behaviour updates the help in the same change. A one-time hint on the status bar points new users to it.

A headless typing test suite

Typing bugs do not show up in unit tests of string functions. They show up when a real browser receives real key events. So the editor has a headless-browser suite of about 86 cases that clicks, types and pastes in a real page, on a desktop and a 390-pixel-wide phone:

Every bug that reached a user gets a test before the fix ships. A failed case blocks the release.

What I'd say I learned

Back to Overview · Next: Live editing