Back to all projects

Senior Software Engineer · Frontend lead

TRW Translator Editor

The web translation editor every translator at TRW now works in. I took typing away from the browser, drew my own caret, and kept files with thousands of segments scrolling smoothly.

  • angular
  • typescript
  • signals
  • rxjs
  • dom
  • virtual-scroll
  • performance
TRW Translator Editor cover image

Today anyone can get a working text box out of an AI assistant in an afternoon. This project is about the part that comes after: getting an editor to the point where professionals type in it eight hours a day and stop noticing it’s there.

TRW is a translation company. I led the frontend of the web editor that replaced the software its translators had used for years. Today the whole company translates in it.

The difference translators noticed first was the waiting. With the old software, opening a large project could take five to ten minutes before anyone typed a word. In the new editor the first segments are on screen almost immediately, and the rest arrive as you scroll.

What a translator actually needs

A translation editor looks like a spreadsheet: source text on the left, your translation on the right, one sentence (“segment”) per row. Inside that grid, the demands are unforgiving:

  • Tags are untouchable. A segment such as Click <b>Save</b> to continue carries formatting codes from the original Word, InDesign or HTML file. You can move them, but you can never break, split or half-delete one, or the file won’t rebuild.
  • The keyboard is the interface. Professionals rarely touch the mouse. They confirm, jump to the next segment, insert a tag, pull a glossary term or copy the source, all through shortcuts and muscle memory built up in other tools.
  • Every language has its own typing rules. French puts a space before ? and turns oe into œ. German and Italian quotes don’t look like English ones. Arabic and Hebrew run right to left. Many translators still type accented characters with Alt + numpad codes.
  • Files are long. A manual or a website export can easily run to thousands of segments, and translators scroll through all of it.

Taking the keyboard back from the browser

The browser’s built-in editing (contenteditable) is designed for free-form text. Left alone, it will happily put the caret inside a tag, merge two formatting spans, paste invisible markup or “fix” text in ways that differ between Chrome, Firefox and Safari. Off-the-shelf editors such as ProseMirror or Slate wrap the same machinery and add a document model of their own, which would have pulled against ours.

So I went the other way: the browser is never allowed to edit the text. Every native input event is cancelled before it touches the page. The editor keeps its own model of each segment as a list of text runs and atomic tags, and the screen is simply a render of that model.

The row stays contenteditable, so focus, keyboard layouts and clipboard events still work, but every edit the browser would make is refused, and each key goes through code I control:

@Component({
  selector: 'trw-target-editor-virtual-row',
  host: {
    '[attr.contenteditable]': 'true', // keep focus, layouts, clipboard events
    onbeforeinput: 'return false',    // ...but the browser never edits the DOM
  },
})
export class TargetVirtualEditorRowComponent {
  // concatMap keeps keystrokes in order, even when one triggers async work
  keys$ = fromEvent<KeyboardEvent>(this.host, 'keydown').pipe(
    concatMap((event) =>
      isTextKey(event)
        ? defer(() => this.insertText(event))    // write into the model
        : defer(() => this.handleSpecial(event)), // arrows, shortcuts, tags
    ),
  );

  insertText(event: KeyboardEvent) {
    event.preventDefault();
    // « », „ “, ” ” … depending on the target language and the text before it
    const char = typographicVersion(event.key, this.language, this.textBeforeCaret());
    TextInputHandler.insertChar(this, char); // the model changes, the row re-renders
    applyFrenchDiphthongRule(this);          // "oe" → "œ" in French
    this.undoRedo.record(this);
  }
}

That decision sounds small. It meant rebuilding, by hand, everything a text field normally gives you for free:

  • A caret I draw myself. The blinking cursor is an element I position, because the native one can’t be trusted to stay out of tags.
  • Caret movement. Left and right are easy. Ctrl + arrow has to find word boundaries in any script, not just Latin. Up and down are the hard ones: the editor measures where every character actually landed on screen, groups them into visual lines, and finds the closest character above or below, across tags, line wraps and right-to-left text. The same logic carries the caret into the next or previous segment at the same horizontal position, so the grid feels like one continuous document.
  • Selection, clipboard, drag and drop, undo and redo, all working on the model rather than on HTML, so a paste can never smuggle in a broken tag.
  • The language rules above, applied as you type: typographic quotes chosen per target language, the French œ ligature, Alt + numpad codes decoded the way Windows does it.

Arrow up is a good example of a problem the browser normally hides. Where the caret goes depends on where the text happened to wrap, so the editor measures it rather than guessing:

static moveCaretOnArrowUp(row: TargetVirtualEditorRowComponent) {
  // 1. Where did every character actually land? (Range API, no DOM changes)
  const chars = collectCharRects(row.element);
  // 2. Same y (within a few px) means same visual line, wherever the wrap fell
  const lines = groupCharsIntoLines(chars);
  // 3. On the line above, pick the character closest to the caret's x
  const target = findCharAbove(lines, caretOffset(row));

  if (target === null) {
    // Already on the first line: continue into the segment above, same x
    return gotoPreviousSegment(row, caretX(row));
  }
  row.setCaret(target);
}

function findCharAbove(lines: CharRect[][], offset: number): number | null {
  const i = lines.findIndex((line) => line.some((c) => c.index === offset));
  if (i <= 0) return null;
  const { x } = lines[i].find((c) => c.index === offset)!;
  return lines[i - 1].reduce((best, c) =>
    Math.abs(c.x - x) < Math.abs(best.x - x) ? c : best,
  ).index;
}

Tags are measured as single characters, so the caret steps over them as one unit, and because positions come from the real layout, right-to-left text works without a separate code path.

The browser is never allowed to edit the text. Everything a text field gives you for free, I had to rebuild on purpose.

The payoff is control. Behaviour is identical in every browser, tags really are indestructible, and when a translator asks “can Ctrl + Shift + something work like it did in my old tool?”, the answer is almost always yes. Every keystroke already goes through code we own.

Thousands of rows, one smooth scroll

Each row in the grid holds two rich editors, tag chips, QA warnings, glossary highlights and spellcheck marks. Render a few thousand of those at once and the page grinds. That’s essentially what made the old tool take minutes to open: it tried to load and draw the whole document before letting anyone work.

Two techniques work together to fix that, and they’re the main reason translators switched:

  • Load only what’s needed (infinite scroll). The editor fetches the first page of segments and requests the next one as you get close to the end, so opening a project no longer depends on how big it is.
  • Only what’s on screen exists (virtual scroll). The rows you can see, plus a small buffer, are real DOM, and everything else is empty space. Rows vary in height because sentences vary in length, so the scroller measures each one as it appears instead of assuming a fixed size.

The rest keeps it smooth once it’s open:

  • Updates stay local. State lives in fine-grained signals, so confirming a segment re-renders that one row, not the table.
  • Cheap lookups instead of DOM walks. Right-clicking a misspelled word doesn’t scan the page. Word positions sit in a spatial index (an R-tree), so finding the word under the mouse takes one lookup however long the segment is.
  • Heavy work leaves the browser. Full-document QA runs on the server and streams progress back in real time, so the editor never freezes on a big file.

The spellcheck shows how those pieces fit together. The red squiggles sit in a separate layer on top of the text that ignores the mouse, so they never get in the way of the caret measurements above or steal a click. That means the browser can’t tell me which word was right-clicked, so a spatial index does it instead:

// Squiggle boxes go into an R-tree, indexed by their position on screen
const words = new RBush<WordBox>();
words.load(overlays.map(({ element, word, suggestions }) => {
  const r = element.getBoundingClientRect();
  return { minX: r.left, minY: r.top, maxX: r.right, maxY: r.bottom, word, suggestions };
}));

// Right-click: one point query instead of walking the DOM
fromEvent<MouseEvent>(row, 'contextmenu').subscribe(({ clientX: x, clientY: y }) => {
  const [hit] = words.search({ minX: x, minY: y, maxX: x, maxY: y });
  contextMenu.set(hit ? { word: hit.word, suggestions: hit.suggestions } : null);
});

Everything around the editor

The editor is the core, but translators live in the panels around it too: translation-memory matches with similarity scores, glossary autocomplete as you type, a QA checker for missing tags, numbers and punctuation, search and replace across the whole project, spellcheck with a personal dictionary, a file explorer, and a built-in chat for live support. Each one plugs into the same event bus and keyboard layer, which is why they all feel like one tool.

Why this still matters

AI tools have made the first 80% of an app almost free, and I use them every day. This project lived in the other 20%: browser behaviour you have to understand before you can override it, performance you have to measure rather than assume, and dozens of small typing details that only show up once real professionals put in real hours. That’s the work I enjoy most, and the part that still takes an engineer.

What I’d do next

  • Real-time co-editing of the same file, built on CRDTs
  • Offline mode with sync on reconnect, for translators on unreliable networks
  • A public plugin API for per-language typing rules