Building Phrase

How we built a native block editor for 10,000 blocks

A document that stays whole, with layout and native views focused on the passage you're editing.

A beaver carries a branch toward a low timber dam in a sunlit woodland stream.

Only a fraction of a 10,000-block note fits in a window. The rest still needs to remain available to selection, editing, and navigation.

Replacing text across two paragraphs shows why those requirements belong together. The document changes its structure. CoreText lays out the affected text. The editor updates visible blocks, selection, and the geometry requested by the input method. Content below the edit may move, while the passage being edited needs to stay in view.

We built Phrase's block editor around that complete operation. Its architecture separates the document from derived layout and native presentation, then keeps them coordinated through each edit. The document can grow while we concentrate expensive work on the passage in front of us.

We are optimizing the editor across iOS and macOS. Here, we use our macOS implementation and its 10,000-block tests to explain the architecture.

The document outlives the views

A block's content and its place in the note do not depend on whether it has a view on screen. The document owns text, structure, and editing state. Native views are a presentation of the part we need to show.

That gives us room to recycle views as the viewport moves. A paragraph can leave the screen without losing its identity or becoming unavailable to selection. An edit can span several blocks even when only part of the range is visible.

We keep three responsibilities explicit:

The document model owns content and edits, derived layout supplies text and rich-content geometry, and native presentation uses that geometry for visible views, selection, and input.

The document is authoritative. Layout is derived from it. Native presentation uses the settled result for both drawing and editing.

The document model decides what an operation means. The layout layer decides where its result belongs. In the macOS implementation, the native editor view connects that result to AppKit and the system's text input services. A view disappearing during scrolling must not change the meaning of the document.

One edit, one coherent result

Replacing a text range across blocks is a document operation. We preserve the unselected text at the edges, update the affected structure, and leave the selection at the result. Undo belongs to that same action.

This becomes especially important when an input method is composing text. A visible candidate may still change before it is accepted. We keep provisional composition distinct from committed document edits, while letting it participate in the layout the writer sees.

On macOS, NSTextInputClient is the platform interface for that conversation. It needs more than a finished string: selection, marked text, and accurate geometry all matter. Our editor connects those requests to the same document and layout used by the native surface.

On iOS, that connection goes through UITextInput. The platform interfaces differ; the shared document and editing foundations keep the meaning of the operation consistent.

The architectural boundary is the writing action. Splitting a paragraph, replacing a range, or accepting a composition has to produce a result that the document, selection, and input system agree on.

Geometry is shared across the edit

We use CoreText for shaped text and line layout. The editor uses that geometry to draw the words, place the caret, and construct selection highlights.

A block layout engine composes those text measurements with the geometry supplied by rich content. A formula, a diagram, and a paragraph need different measurement rules, but all contribute to the document's layout.

We reuse geometry only while its inputs remain valid. Content and style changes can alter the text. Width changes can alter line breaks. A display-scale change can alter pixel alignment. A diagram's measured bounds belong to the source that produced them.

We track which inputs a result depends on, so an edit can invalidate the affected geometry while leaving unrelated results reusable. The visible text and caret then follow the same updated layout.

Keep measurement close to the change

Measuring text and placing a block are different kinds of work. If a paragraph gains a line, the blocks below it move by the height difference. Their text does not necessarily need measuring again.

Our incremental layout path uses that distinction. A same-height edit can leave later frames in place. A height-changing edit reuses valid measurements while updating the affected positions. Local structural changes, such as a split or join, get a corresponding layout update around the changed part of the sequence.

The amount of positional work can still depend on how much content follows the edit. What we avoid is turning that work into repeated typesetting of unchanged text. We measure these costs separately so a faster character insertion cannot conceal a slow structural edit.

Give the visible passage priority

A long document has much more content than a window can display. We keep native views around the visible area and resolve the geometry needed to draw and edit that area accurately.

An architectural animation: the visible range changes, incoming text gets exact geometry, and the native views follow. The timing illustrates the sequence.

Eligible distant text can use estimated heights while exact measurement is deferred. We keep that distinction explicit. An estimate is useful for planning the rest of the document; the text we mount for drawing and editing needs exact layout.

Layout can itself change what is visible. A corrected document height may adjust the scroll position and bring another block into view. We settle that geometry before mounting the resulting views. A direct request for a distant caret or input-method location also resolves the target before using its geometry.

This keeps the optimization behind the editing surface. The writer interacts with typeset text, even while the rest of the document is being refined.

Let deferred work yield to interaction

In our macOS implementation, refinement runs in small batches on the main thread, with time returned to the run loop between turns. We defer it while interactions or animations may still be moving the page.

The reading anchor follows a passage, so changes in earlier block heights do not make a fixed scroll distance point at unrelated text. We restore that anchor after refinement, while giving deliberate user scrolling precedence.

The same principle guides future background work: a derived result needs stable inputs and a validity check before it can affect the editor. Moving work to another thread only helps if an obsolete result cannot overwrite newer editing state. Native view updates and synchronous input requests still need a coherent foreground state.

The next scaling boundary

Our design keeps positional indexing separate from text measurement. That leaves a path to hierarchical height summaries for larger documents: locate a region and update aggregate heights without revisiting every intervening frame.

We are designing that next stage around the existing editing and layout contracts. An index can reduce positional work; text still needs shaping when its content or width changes. We will judge the change on whole editing sequences, memory use, and reading stability, alongside lookup cost.

The current 10,000-block measurements come from the macOS implementation described above. The next index needs to earn its place against that baseline.

Verify the system through complete edits

Our layout tests compare optimized paths with reference results and check frame positions, heights, and document size. Other checks follow the interactions across those paths: opening a long document, scrolling into an estimated region, editing, resizing, refining, and preserving the reading anchor.

The questions are concrete. Does a selection still refer to the intended text? Do mounted blocks have exact geometry? Does the caret follow the settled layout? Does background refinement respect a scroll the writer just started?

Our 10,000-block macOS test results report the measured editor work and the method. The Swift and native input overview explains the platform interfaces. Together, they describe the architecture at two levels: what an edit requires, and how much work the measured implementation spends carrying it out.

The system lets us reuse the document's unchanged work while keeping the current passage exact. That is the relationship we care about as notes grow: document size should not dictate the cost of every small revision.

You can use the native block editor today in Phrase for iPhone. We are continuing to optimize the iOS editor while developing the Mac app.

More from the series

All articles