We Used a Textarea as a Code Editor. It Worked Great Until Line 3,000.
The textarea was a great choice. I'll fight anyone who says otherwise, and I'll lose, because the textarea has been winning arguments since 1997.
Our old editor was two elements stacked on top of each other: a <pre> showing colored text, and a <textarea> on top with transparent text and a visible caret. A scroll handler kept them aligned. That was the whole editor, and the browser did everything else:
- Selection. Drag, shift-click, triple-click. Free.
- Undo. It grouped your typing into sensible chunks. Free.
- Ctrl+F. Fast, localized, highlighted every match. Free.
- Right-click. Translated into every language your OS speaks. Free.
- IME for Korean, Chinese, and Japanese. The candidate strip appeared, you picked a character, it went in. Free.
- Cursor blinking. Twenty years of browser engineering in a single CSS property. Free.
We didn't realize how much we were leaning on that list. We thought we'd built an editor. We'd actually built a nice <pre> overlay and let the browser pretend it was one.
We wrote about 600 lines of JavaScript. It had a minimap, six languages of syntax highlighting, find and replace, and a dark theme. It worked beautifully for about 3,000 lines.
Then Line 3,000 Happened
The <pre> and the <textarea> have to agree on everything: font, size, weight, line height, letter spacing, tab size, padding, emoji width, combining characters, font fallbacks, anti-aliasing, and device pixel rounding. If one of them disagrees by a single pixel, the caret drifts.
On a 3,000-line file, a one-pixel error doesn't stay one pixel. Line-height rounding gives you 22.4px in one element and 23px in the other. Multiply that by 3,000 lines and your caret is roughly a full screen away from where you're typing.
Some of what we found:
- Tabs. Android Chrome sometimes renders a tab as 4 characters, sometimes as 8. Samsung Internet has its own opinions. You can approximate, but you can't agree.
-
Emoji and CJK. The
<pre>and the<textarea>fall back to different system fonts with different widths. The caret takes the hit. - Soft wrap. One element wraps a long line and the other doesn't. Now the gutter is wrong, the minimap is wrong, and everything below is wrong.
We spent a week on it. Fixing Chrome broke Firefox. Fixing Firefox broke Samsung. Fixing Samsung broke Chrome again. At some point we stopped debugging and started staring at the wall.
The CPU Joined In Too
Every keystroke retokenized the entire file, rebuilt around 5,000 <span>s, and replaced the whole <pre> DOM. The minimap and gutter each created one <div> per line. For a 3,000-line file, that's about 9,000 DOM nodes created and destroyed on every key.
We added requestAnimationFrame batching. It helped a little. The tokenizer, the DOM churn, and the 30ms hit stayed.
We were naive. We hadn't met the canvas yet.
The Canvas
So we drew everything ourselves.
Now there's one <canvas> doing all the drawing. Each line is a fillText() call. The caret is drawn at x = gutterWidth + column * charWidth. There's no second layer to disagree with, so the caret can't drift.
Only visible lines get redrawn, usually 30 to 50 per frame. Typing on line 3,400 doesn't retokenize lines 1 through 3,399. The minimap is a 72-pixel canvas that draws one rectangle per line in real token colors. You can see where the strings and comments are at a glance.
Then the features that were impossible in a textarea became practical:
- Multiple cursors. Type once, it appears N times.
- Sticky scroll. The function signature pins to the top like it does in VS Code.
- Folding. Collapse a 400-line function to one line.
-
Inline diagnostics. A red squiggle, a popup, and a Fix button. In a textarea, that's a two-week overlay project. On canvas, it's a
strokeStyleand a popup. - Command palette. Ctrl+K, type "format," Enter.
- Touch gestures. Pinch to zoom, long-press for a menu. The browser's touch handling on textareas isn't something you want to depend on.
The Bill
Canvas is not free, and I'd be lying if I said I didn't miss the textarea every day.
IME is a nightmare now. Some keyboards send compositionstart, then insertCompositionText, then compositionend. Some send a bare insertText. A few send only keydowns, and you have to reconstruct the composition yourself. The state machine for this is about 200 lines, and I'm still not sure it's correct.
Paste has four code paths. There's still a bug where Gboard on one specific Pixel model double-inserts pasted text. We've been told to "just not use Gboard." That's not helpful.
Ctrl+A, Ctrl+F, and Ctrl+Z are all ours now. Our Ctrl+F doesn't do regex lookahead. Our undo stores full buffer snapshots, which is fine for 3,000 lines and a disaster for 30,000. Our right-click menu is 15 items, in English, with no localization.
The numbers: the old editor was 600 lines in one file. The new one is about 2,900 lines in limn-bridge.js, plus roughly 65 files in /core and /ui. It took two months.
Was It Worth It?
Yes, for us, because we were going to hit this wall eventually. The textarea was going to break at 3,000 lines, and it did. We'd have spent another month chasing the same caret drift and CPU lag before making the same decision.
No, because CodeMirror exists. CodeMirror 6 has multiple cursors, folding, inline diagnostics, and a command palette. It handles IME, paste, and caret alignment, and it's had a team of engineers working on those problems for years. We wanted a specific look and behavior for a game engine, so we built our own. If you're building an editor and you're not us, use CodeMirror. Please.
Maybe, because we learned a lot. We learned that beforeinput is the future, keydown is the past, and neither is enough on its own. We learned how much of what we take for granted in a browser is a small miracle.
We learned that the textarea is a gift, and we don't deserve it.
Comparison Table
| Feature | Old (textarea) | New (canvas) |
|---|---|---|
| Syntax highlighting | Yes, <pre> behind textarea |
Yes, drawn on canvas |
| Minimap | Colored dots | Real token colors |
| Multiple cursors | No | Yes |
| Command palette | No | Yes |
| Sticky scroll | No | Yes |
| Folding | No | Yes |
| Inline diagnostics | No | Yes, with click-to-fix |
| IME | Free | Reimplemented, with bugs |
| Paste | Free | Four code paths |
| Undo | Free | Snapshot-based |
| Caret drift at 3,000 lines | Yes, badly | No |
| Code size | ~600 lines | ~2,900 lines + 65 files |
Try Both
The old textarea version is still in the repo as a reference. Open both, paste 3,000 lines into each, and watch what happens.
Then try building a canvas editor yourself and see how long it takes you to miss the textarea.
Draw Your Game Into Existence
That's the Limn Engine tagline. I mean it two ways.
Technically, you write a few lines, hit run, and something that didn't exist five seconds ago is on screen.
As a dare, whatever you're scared to build doesn't exist yet. Not the game, not the tools, not the arcade where people play it. Someone has to write the first line, and it might as well be you.
You don't need a gaming rig, a 40GB SDK, or a computer science degree with a minor in suffering. You need a browser tab and the nerve to type the first line.
Links
- Limn Studio: limn-engine-doc.vercel.app/editor
- The Arcade: limn-engine-doc.vercel.app/arcade
- Docs: limn-engine-doc.vercel.app
- GitHub: github.com/terracodes004/limn-engine-doc
If you've written your own editor, drop your worst IME bug in the comments. Mine: Korean keyboard, composing "가," autocomplete popup opens, I tap it, and the editor inserts "가" twice. I have not recovered.
Did the code work? Ask me tomorrow. I'll still be debugging the paste handler.
Draw your game into existence, even with 3000+ lines of code.
Top comments (0)