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:
- Dictionary words are atomic, non-editable spans. They carry the dictionary reference and the reading, so the editor always knows which word it is. They are shown in one colour.
- Direct-typed runs are editable spans of letters typed straight on the keyboard. The dictionary has not checked them, so they are shown in another colour, and no reference or reading is ever invented for them.
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
| Mode | What it does |
|---|---|
| ไทย/Eng | Normal 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. |
| Tilok | Direct 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:
- Never touch the text while a composition is active.
- 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.
- 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:
- direct Tilok followed by Space, Latin after Tilok, Space in the middle of a word;
- composition followed by Space, leaving a mode and typing again;
- picking dictionary words at the end of a line and near the bottom of a long text;
- copy, cut, partial selection and paste of mixed content, hostile paste, undo after a paste;
- the on-screen keyboard (real mouse clicks, Shift, Backspace, Enter, the key lighting up, the phone sheet);
- the help panel, the assistant menu, and a check that no JavaScript or policy errors are logged.
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
- Own the input you can; repair the input you cannot. Desktop keyboards can be handled on
keydown. IME input has to be allowed to finish and then be repaired. - Keep verified and unverified text visibly different, and never let the editor claim a dictionary reference it does not have.
- A rich-text editor is a state machine on top of a browser that edits behind your back. Normalise often, and test with real events.
Back to Overview · Next: Live editing