Adding Photos to an Editor — Should've Taken Half a Day
Images vanishing on every round trip, contagious center alignment, an overlay that refused to draw. Everything one photo caused.
Adding Photos to an Editor — Should’ve Taken Half a Day
Fecit’s editor now supports photos. Pick one from the toolbar and it lands in the body; the moment you save, it’s registered as an attachment of that document. Delete it from the body and save, and the attachment cleans itself up.
That’s the whole feature description. It’s a common feature — every app has it. So I figured it would be quick.
Fecit has three editors: a web editor on desktop, and native editors on iOS and Android. All three read and write the same document format. The photo feature had to be built three times, and the problems arrived in threes too. Here are the three worth remembering.
Incident 1 — The photo that vanished on every round trip
On iOS, picking a photo registered the attachment, but nothing showed in the body. Upload succeeded. The insert command was sent. The screen showed nothing.
I made two fixes based on guesses. Neither worked. That’s when I stopped and planted logs — one at each JS boundary, one inside the native editor. Then a single line told the whole story:
updateValue: imgBefore=1 imgAfter=0 len=281
The parser received a document that contained an image (281 characters long) and parsed it into zero images. The insertion was fine. The problem was the value making a round trip through JS and coming back into the native parser.
The parser has a rule: “HTML tags might not start at the beginning of the document, so find the earliest tag and parse from there.” The candidate list has <p>, <li>, <table>, and so on — but on iOS, and only on iOS, <img> was missing from that list. So when a document started with an image, the parser latched onto the <p> that came after it as the “earliest tag” and silently discarded everything before it.
The Android parser had <img>. The JS parser had it. Only iOS was missing it. Maintain the same code in three copies and one of them will eventually drift — and the drifted copy fails in the quietest way possible. No error, no crash. The image just disappears.
Incident 2 — Contagious center alignment
Insert a photo, and the text on the line below it turned center-aligned. Delete the photo — still centered. Leave the screen and come back — back to normal.
The cause: image centering was implemented as a paragraph alignment attribute. iOS text editors inherit attributes for new characters from their surroundings, and the image paragraph’s “center” hitched a ride onto regular text. I added a guard that watches the cursor and resets alignment — then Korean IME composition bypassed the guard. I added a second guard that normalizes even mid-composition ranges — then the first syllable flashed centered before snapping back.
Somewhere around the third layer of guards, a thought landed: why not just contain the scope?
As long as alignment is expressed as a paragraph attribute, an inheritance path exists. So I changed the approach entirely. The image block’s cell now always spans the full width, and left/center/right placement is just where the bitmap gets drawn inside the cell. The paragraph itself is always left-aligned. Nothing to inherit, nothing to spread.
The answer wasn’t a better guard. It was a structure where alignment had nowhere to leak.
Incident 3 — invalidate() that drew nothing
Photos were too easy to delete, so I made backspace a two-step: the first press marks the image as “about to be deleted,” the second actually deletes it.
On Android, the mark never showed. I set a flag on the span object and called invalidate() — and a log revealed the span’s draw() was never being called at all.
A hardware-accelerated TextView caches its text rendering in a display list. You can invalidate the view, but if the framework believes neither the text nor its spans changed, it reuses the cache. Flipping a field inside your span object is not a change the framework can see.
The fix: call setSpan() again on the same range. That fires the SpanWatcher, TextView gets notified that the span changed, and the region actually redraws. Invalidating a view and invalidating a text layout are two different things.
What stayed with me
The rule I repeated most this week: when two guess-based fixes miss, stop and plant logs. In incidents 1 and 3, every guess before the log line was wrong, and after the log line the cause was one step away.
And now that I know one drifted list can make images silently vanish, the parser’s candidate list carries a comment: “keep all three copies in sync.” The next person to drift it might be me.
It was a feature for adding one photo.