Skip to content
 
 

Latest commit

 

History

968 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

react-native-marcus

react-native-marcus

Marcus is a rich text markdown editor. It is a fork or Expensify/react-native-live-markdown that's a bit more a bit more ambitious.

Marcus takes a different approach to editing markdown: formatting is rendered live on every keystroke and syntax is highlighted but not removed. This way you get instant visual feedback and a good idea what rendered markdown will look like without losing the ability to easily edit the source.

Features

  • ⚛️ Drop-in replacement for <TextInput> component
  • ⌨️ Live synchronous formatting on every keystroke
  • ⚡ Fully native experience (selection, spellcheck, autocomplete)
  • 🔧 Customizable markdown flavor
  • 🎨 Customizable styles
  • 🌐 Universal support (Android, iOS, web)
  • ➗ Optional LaTeX math, typeset by MathJax and drawn identically on all three
  • ⌛ Optional typewriter animation, paced natively and never in the way of the text
  • 📋 Optional clipboard handlers: decide what a paste inserts and what a copy carries
  • ⌘ Optional key handlers: hardware key presses with their modifiers, and chords the input swallows
  • 📐 Optional caret reporting: where the caret is, for an overlay that follows the current line
  • 🏗️ Supports only the New Architecture

Differences from Expensify/react-native-live-markdown

  • Native side written mostly in Swift and Kotlin as opposed to Objective-C++ and Java
  • Uses established and maintained micromark parser
  • At least twice as fast; on small inputs and slow devices up to 8X faster
  • Fully CommonMark and GFM compliant

Installation

First, install the library from npm:

npm install react-native-marcus react-native-worklets

Or if using expo:

npx expo install react-native-marcus react-native-worklets

Important

Please follow the react-native-worklets Getting Started guide to avoid issues.

Then, install the iOS dependencies with CocoaPods:

npx pod-install

The library includes native code so you will need to re-build the native app.

Note

The library does not support Expo Go, you will need to setup Expo Dev Client (see here).

Usage

import { useState } from "react"
import { MarkdownTextInput } from "react-native-marcus"

export default function App() {
  const [text, setText] = useState("Hello, **world**!")

  return <MarkdownTextInput value={text} onChangeText={setText} />
}

Styling

MarkdownTextInput can be styled using style prop just like regular TextInput component.

It is also possible to customize the styling of the formatted contents of MarkdownTextInput component. The style object supports all color representations from React Native including PlatformColor and DynamicColorIOS according to the color reference.

Every styled entry takes the same set of text properties, picked from React Native's own TextStyle: color, fontFamily, fontSize, fontStyle, fontWeight, letterSpacing, lineHeight, textDecorationLine, textDecorationColor, textDecorationStyle, textShadowColor, textShadowOffset and textShadowRadius. Anything left out is inherited, so an entry naming only a colour keeps the face, the size and the weight of the prose around it.

Three entries are shapes rather than runs of text. orderedList and unorderedList take lengths and a nested marker style, which is what their drawn marker is set in; thematicBreak takes lengths only, a break having no prose in it at all.

Two of those are not honoured everywhere. textDecorationColor and textDecorationStyle are applied on iOS and the web; Android draws an underline and a strikethrough in the text's own colour as a solid line and offers no way to say otherwise. lineHeight is a property of a line rather than of a run on every platform, so a range that asks for one raises the whole line it sits on.

import type { MarkdownStyle } from "react-native-marcus"

const FONT_FAMILY_MONOSPACE = Platform.select({
  ios: "Courier",
  default: "monospace",
})

const FONT_FAMILY_EMOJI = Platform.select({
  ios: "System",
  android: "Noto Color Emoji",
  default: "System, Apple Color Emoji, Segoe UI Emoji, Noto Color Emoji",
})

const markdownStyle: MarkdownStyle = {
  syntax: {
    color: "gray",
  },
  link: {
    color: "blue",
    textDecorationLine: "underline",
  },
  // The marks that used to be hard-coded. Naming one replaces it rather than
  // fighting it: `strong: { fontWeight: "600" }` is a lighter bold, not a bold
  // with a 600 on top.
  strong: {
    fontWeight: "bold",
  },
  emphasis: {
    fontStyle: "italic",
  },
  strikethrough: {
    textDecorationLine: "line-through",
  },
  heading: {
    // Level N is this size scaled N-1 times, so one pair of values covers all six.
    fontSize: 25,
    scale: 0.85,
    fontWeight: "bold",
  },
  emoji: {
    fontSize: 20,
    fontFamily: FONT_FAMILY_EMOJI,
  },
  // The bar beside a quoted block, the indent it opens, and the text inside it.
  // `marginStart` and `paddingStart` rather than left and right: the gutter
  // opens on the line's leading edge, which is the right-hand side of a
  // right-to-left paragraph.
  blockquote: {
    borderColor: "gray",
    borderWidth: 6,
    // Rounds the bar's ends. A radius of half the width gives a capsule.
    borderRadius: 0,
    marginStart: 6,
    paddingStart: 6,
    fontStyle: "italic",
  },
  // The rule `MarkdownText` draws in place of a `---`, `***` or `___` line. The
  // bar is `borderWidth` thick, in `borderColor`, with `borderRadius` rounding
  // its ends -- the same three names a quote's bar takes. `padding` is the room
  // inside the rule's own box and `margin` the room around it, and since the box
  // holds nothing but the bar the two add up on each axis; `paddingHorizontal`
  // and the rest win over them where set. A `MarkdownTextInput` draws no rule at
  // all: there the `---` you typed is markup, and it is greyed like any other.
  thematicBreak: {
    borderColor: "gray",
    borderWidth: 1,
    borderRadius: 0,
    paddingVertical: 0,
    paddingHorizontal: 0,
    marginVertical: 8,
    marginHorizontal: 0,
  },
  // A list's indent, and the marker `MarkdownText` draws in the gutter it opens.
  // Both markers are sized from the base font -- whatever the wrapped `Text`
  // renders at. `marker` styles the marker itself, and what it leaves unset
  // comes from `syntax`. A `MarkdownTextInput` shows the marker you typed
  // instead, in the base font: there it is text being edited.
  orderedList: {
    marginStart: 6,
    paddingStart: 18,
    // The item's number, at this fraction of the base font size.
    markerScale: 0.8,
    // Room held open either side of the marker.
    markerPadding: 2,
    marker: { color: "gray" },
  },
  unorderedList: {
    marginStart: 6,
    paddingStart: 18,
    // The bullet's diameter, as a fraction of the base font size. A circle,
    // centred on the line rather than sat on the baseline.
    markerScale: 0.3,
    markerPadding: 2,
    marker: { color: "gray" },
  },
  code: {
    fontFamily: FONT_FAMILY_MONOSPACE,
    fontSize: 20,
    color: "black",
    backgroundColor: "lightgray",
    // An inline run is drawn as a box too, so it takes the same lengths. Its
    // padding and margin hold space open on either side of the run and grow into
    // the line's spacing above and below it.
    borderRadius: 4,
    borderWidth: 0,
    borderColor: "gray",
    padding: 2,
    margin: 2,
  },
  pre: {
    fontFamily: FONT_FAMILY_MONOSPACE,
    fontSize: 20,
    color: "black",
    backgroundColor: "lightgray",
    // A block is drawn as a box behind the whole of it: the padding is the space
    // inside the box, the margin the space around it. `padding` and `margin`
    // cover both axes; `paddingVertical` and the rest win over them where they
    // are set, which is what a block usually wants -- more room above and below
    // than beside it.
    borderRadius: 4,
    borderWidth: 1,
    borderColor: "gray",
    paddingHorizontal: 8,
    paddingVertical: 12,
    margin: 4,
  },
  // A name with an `@` in front of it, drawn as a pill: the same inline box a
  // run of code sits in, and so the same lengths.
  mention: {
    color: "blue",
    backgroundColor: "cyan",
    borderRadius: 5,
    padding: 2,
    margin: 0,
  },
}

The style object can be passed to multiple MarkdownTextInput components using markdownStyle prop:

<MarkdownTextInput
  value={text}
  onChangeText={setText}
  style={styles.input}
  markdownStyle={markdownStyle}
/>

Tip

We recommend to store the style object outside of a component body or memoize the style object with React.useMemo.

Markdown flavors support

react-native-marcus implements the CommonMark spec, and on top of it three extensions you can ask for: two from GFM (Github Flavored Markdown) — strikethrough (~~text~~) and bare autolinks (www.example.com, which CommonMark leaves as text) — and mentions, which no flavour of markdown has a notion of. Math is a fourth, and has a section to itself.

Emoji are recognised too, but they are not an extension: they are a property of the characters rather than a way of writing something, so nothing turns them on or off.

A mention is an @ followed by a name. The name starts with a letter and is made of letters, digits and marks, with -, _ and another @ joining two parts of it -- each needs a part on either side, so @user@example.com is a single mention while the @ in @user@ is left out of it. A . may appear anywhere, the last character included, so @bullet. is one mention; only the first of a run of dots belongs to the name, so @bullet.. is @bullet. followed by a full stop.

A mention cannot begin inside a word, or straight after another @: that is what keeps user@example.com an email address rather than an address with a mention buried in it. Anything else may sit against one -- **@someone**, _@someone_, [@user](/u) and (@user) all hold a mention, since the markup is gone by the time a display draws it and punctuation was never part of a name.

Every extension is off unless it is asked for, on MarkdownTextInput and MarkdownText alike:

<MarkdownTextInput value={text} onChangeText={setText} gfm mention />

Without them what is left is CommonMark: an @name is a word like any other and ~~text~~ keeps its tildes. That is the default because each extension changes what an ordinary character means, and a document written somewhere else — by someone who never heard of this library — is the worst possible place to guess at that. Emoji are a property of the characters rather than an extension, so they are always recognised.

Each combination is parsed by one parser that is built once and shared by every component asking for the same one, so a flavour costs nothing to choose.

Raw HTML

CommonMark lets a document carry HTML, and a browser is expected to render it. Nothing here can: two of the three platforms have no HTML in them at all. So it is treated as what it is — markup — and treated the same way everywhere. MarkdownTextInput greys it as syntax, a block of it down to the text between its tags, since the whole block is a thing to be rendered rather than read. MarkdownText removes it: the tags of a <em>b</em> c go and leave "a b c", and <div>…</div> on lines of its own goes entirely, along with the blank line it stood between.

Character references

&amp;, &#38; and &#x26; are three ways of writing &, and a display shows the character rather than the name. The full HTML5 table is understood — all 2231 of them — because it is already in the parser: it is how the parser decided the reference was one. A reference that stands for nothing is a replacement character, which is what CommonMark asks for, and one written where markup is raw — inside a code span, a code block or an autolink — is left exactly as typed.

The same resolution applies to what a link points at. A destination and a title are written in CommonMark's string content, where a backslash escapes the character behind it and &amp; means &, so [a](/b\_c?d=1&amp;e=2) points at /b_c?d=1&e=2 and not at the seventeen characters between the brackets. That resolved string is what onLinkPress and renderEmbed are given; the input still shows, and a copy still carries, exactly what was typed.

Math

LaTeX between double dollar signs — $$x^2$$ inline, and the same on lines of its own for a block — is recognised with the math prop.

<MarkdownTextInput value={text} onChangeText={setText} math />

Two dollars, never one. A single $ is a currency sign far more often than it is a delimiter: with the single form recognised, $5 and $6 is one maths span whose TeX is 5 and , and a price list quietly becomes a picture. Two dollars cost a keystroke and can never be an accident.

In an input that is all math does. The TeX stays exactly where you typed it and the dollars are greyed as the markup they are — the caret moves through those characters and backspace deletes them, so drawing a picture over them would make both unexplainable.

MarkdownText draws it instead, given two things: the maths backend, which is MathJax and is switched on in metro.config.js, and renderSVG, which puts the finished drawing on the screen.

import { SvgXml } from "react-native-svg"

;<MarkdownText
  math
  style={{ fontSize: 16, color: "#111" }}
  renderSVG={(xml, width, height) => <SvgXml xml={xml} width={width} height={height} />}
>
  {message}
</MarkdownText>

That is the whole of it. renderSVG is handed a complete SVG document and the box it belongs in, and there is deliberately nothing else in the signature: the glyph outlines are inline, the colour is already painted in, and the baseline has been dealt with by the time it is called. It exists only because an SVG renderer is a dependency and which one you use is not this library's business.

Three things follow from that:

  • The colour comes from the style you gave the MarkdownText. A view mounted in a Text inherits none of its text attributes on either platform, so the library reads color off the style and paints it into the SVG. A display with no color of its own draws maths in whatever your SVG renderer makes of currentColor, which is usually black.
  • The size comes from fontSize on that same style, because maths is the one kind of attachment that has to be sized in step with the prose around it. An inherited size is resolved by the platform long after this and cannot be read here.
  • Returning nothing leaves the drawing out. The span still costs its one character, so nothing shifts.

Without the backend, or without renderSVG, no maths is taken out of the text: $$x^2$$ is left on the screen exactly as it was written. That beats an empty box where an equation should be, and it is the one hint you will get that a line is missing from your Metro config.

Enabling MathJax

MathJax is the only JavaScript typesetter that renders TeX to a standalone SVG, which is what lets Android, iOS and web draw identical glyphs from one implementation. It is also 11 MB, and most applications will never show an equation — so nothing in this library imports it, and turning it on is two lines:

npm install @mathjax/src speech-rule-engine react-native-svg
// metro.config.js
const { getDefaultConfig } = require("expo/metro-config")
const { withMath } = require("react-native-marcus/metro")

module.exports = withMath(getDefaultConfig(__dirname))

withMath does two things. It points the library's maths backend — a stub that draws nothing — at the real one, which is what keeps MathJax out of every bundle that has not asked for it. And it teaches Metro the subpath imports MathJax addresses its own modules with: the imports field in its package.json, which Metro does not implement, so without this every internal import fails to resolve. The mapping is read out of MathJax's own manifest rather than written down here. Any resolveRequest already on your config is kept and chained to.

The whole font is bundled — all forty glyph ranges, about 3.5 MB gzipped — because the alternative is an expression that silently fails to draw. Everything else about running MathJax under Hermes and Metro is handled in src/math/mathjax.ts, which is worth reading if you are doing something similar: the navigator.appVersion it assumes exists, the synchronous font loader it needs instead of fetch, the inline line-breaking that has to be turned off, and the ex units that must not reach a browser.

speech-rule-engine is MathJax's own accessibility engine and is what says an equation out loud — see Accessibility and copying. It comes with MathJax as a dependency of its own, and is named on the install line so that it resolves from this package too under a strict node_modules layout. Leave it out and everything still draws; equations are just read as their TeX.

withMath takes the languages you want equations read out in, and bundles only those:

module.exports = withMath(getDefaultConfig(__dirname), { locales: ["en", "fr"] })

The engine ships af, ca, da, de, en, es, fr, hi, it, ko, nb, nn and sv, at around 300 KB of rules each. ["en"] is the default, and everything not named is resolved away to nothing — an application that reads its maths in English has no business carrying the four megabytes for the twelve languages it does not. Name the ones you already translate the rest of your interface into.

Accessibility and copying

A drawing is one character in the text the platform lays out — U+FFFC, the object replacement character — which is what a view nested in a Text costs on both platforms. That one character is what makes the layout work, and it is also all that a screen reader and the clipboard would otherwise get.

Reading. MarkdownText gives the paragraph an accessibilityLabel in which every placeholder is written back out as what it stands for: an image's alt, and, for maths, the equation said out loud.

$$\frac{a}{b}$$          →  "StartFraction a Over b EndFraction"
$$\alpha\beta \in \mathbb{R}$$  →  "alpha beta element of double struck upper R"

That is speech-rule-engine, the engine MathJax itself uses for the same job on the web, so an equation in your app is announced in the phrasing a screen reader user of maths already knows. An expression it cannot say falls back to its TeX, and so does every expression when the maths backend is not installed.

In whichever language your reader speaks. mathAccessibilityLocale says which; it is usually the same value the rest of your interface is translated into, since what a document is written in and what its reader speaks are two different questions.

<MarkdownText math renderSVG={renderSVG} mathAccessibilityLocale="fr">
  {message}
</MarkdownText>
$$\frac{a}{b}$$   en →  "StartFraction a Over b EndFraction"
                  fr →  "début fraction a sur b fin fraction"
                  de →  "Anfang Bruch a durch b Ende Bruch"

Only the languages named in withMath(config, { locales }) are in the bundle; asking for one that is not reads it in English and warns in the console saying which line would fix it. Nothing else about the drawing changes — the picture is the same in every language, and the prose around it is read in the language it was written in.

An empty alt is left empty, since that is how a decorative image is declared, and your own accessibilityLabel always wins. On the web each drawing is a role="math" (or role="img") element with an aria-label, so it is read in place rather than as part of one long string.

MarkdownTextInput keeps the TeX, and should: there the TeX is what is on the screen, what the caret moves through and what backspace deletes, so reading anything else out would describe a document that is not there.

Copying. Copying a selection puts the maths back on the clipboard as the markdown it was written as — $$x^2$$, not a hole where an equation was — so a message copied out of a chat pastes back into one, on all three platforms.

how
Web the copy event is intercepted and the fragment the browser was about to serialise is walked
Android the selection toolbar's copy is answered by the decorator, which rebuilds the selected range from the source
iOS copy: on the paragraph is replaced with one that substitutes the placeholders before writing to the pasteboard

The rest of a copy is what a copy of styled text has always been: plain text. **bold** comes off the screen as "bold". List markers are laid out rather than removed, so they come with it; a heading's # and a quote's > are drawn rather than written, so they do not. An image copies as nothing, the way an <img> does on the web.

On Android this only ever applies to a Text you have marked selectable, and only to paragraphs that have a drawing in them; everything else copies exactly as it did before.

To decide what a copy puts on the clipboard yourself -- or what a paste puts into an input -- see The clipboard.

The clipboard

Two handlers let you decide what a paste puts in and what a copy takes out. Both are optional, and without them the input pastes and copies exactly as it did before.

onPaste is handed everything on the clipboard and returns the markdown to insert.

<MarkdownTextInput
  value={value}
  onChangeText={setValue}
  onPaste={async (targets) => {
    const image = targets.find((target) => target.mime.startsWith("image/"))

    if (!image) {
      return null // paste as it would have
    }

    if (await image.size() > 10_000_000) {
      return "" // swallow it
    }

    const url = await upload(await image.bytes())
    return `![](${url})`
  }}
/>

Return null to let the paste happen as it would have, an empty string to swallow it, and anything else to insert that at the caret — replacing the selection, as any paste does. The handler is always asynchronous and the text always lands after it has answered, so read what you need and answer promptly: what is on the screen is not frozen while you think.

onCopy is handed the markdown the selection was written as, and returns the representations to put on the clipboard.

<MarkdownTextInput
  onCopy={(text, { reason }) => [
    { mime: "text/plain", string: text },
    { mime: "text/html", string: toHTML(text) },
  ]}
/>

It is synchronous, deliberately: on the web there is no way to fill a clipboard once the copy event has been handled, and the text it describes is already in hand. Return null for the copy that would have happened. reason tells a "copy" from a "cut" or a "drag".

The text is always markdown, on both components. In an input that is what is on the screen anyway. In a MarkdownText it is not: the syntax has been taken out, so dragging across bold selects bold and the platform knows nothing about the asterisks. The handler is given the source instead — the selection mapped back onto the document it was rendered from, with every drawing written back out as its $$…$$ and the inline markers a partial selection cut through put back around it:

shown       bold and plain          ← what the reader selects
handed      **bold** and plain      ← what onCopy is given

shown       ol                      ← half of the bold run
handed      **ol**

Emphasis, strong, strikethrough and inline code are reconstructed, being the ones written as a pair of markers around their content. A block's marker is not: a heading's # and a quote's > say what a whole line is, and half a line quoted is not a quote. Without a handler the clipboard still gets what it always did — plain text, with the maths put back.

What a target is

The clipboard is two levels deep, and the API says so:

interface ClipboardTarget {
  mime: string        // best effort; "" for a platform type nothing portable names
  item: number        // which of the clipboard's items this represents
  uri?: string        // valid only until your promise settles
  size(): Promise<number>
  bytes(): Promise<Uint8Array>
  string(): string | undefined
}

Two targets sharing an item are one thing said twice — the same picture as PNG and JPEG, the same text as HTML and plain — so pick whichever you understand best. Two targets with different items are two different things, and both deserve handling. Copy two photos and you get two items; copy one photo out of an editor and you often get one item in three encodings. A flat list could not tell those apart.

Targets arrive ordered by item and, within an item, richest first, and mime is unique within an item.

size() and bytes() are asynchronous because finding out is I/O on every platform — a content provider on Android, a promised representation on iOS. Ask before reading anything that might be large: what fits in memory is your call, not this library's. string() needs no waiting, since the platforms hold text decoded already.

bytes() is how you read a target, including one that has a uri. Android streams the content:// through its provider — converting to the media type the target names, where the provider offers that conversion — and the web reads the file. The uri is there for the few things that can take one, not as the way in: an Android content:// resolves only through a ContentResolver, in this application, under a permission the paste granted, so it means nothing to fetch, to an upload library, or to another app.

And it is a loan, not a keepsake. Android grants that permission for the paste and no longer; the web URL is revoked when your handler is done. Anything worth keeping must be read before the promise settles.

What each platform can do

paste copy
Web the paste and drop events the copy, cut and dragstart events
Android OnReceiveContentListener: the menu, Ctrl+V, a drop, and an input method's image the selection toolbar's copy and cut, and Ctrl+C / Ctrl+X from a keyboard

| iOS | paste: on the backed text input | copy: and cut:, and copy: on a MarkdownText paragraph |

Three limits worth knowing before you design around them:

  • Android cannot carry bytes on its clipboard. It holds text, HTML, a URI or an intent — a picture goes on it as a content:// URI that some provider streams. A write carrying bytes is reported in the log and dropped there; expose the content through a ContentProvider and name its uri instead.
  • The web writes few media types. Browsers reliably take text/plain, text/html and image/png; anything else needs the web prefix that marks a custom format, which only other web pages can read.
  • On Android the copy chord is Ctrl, never ⌘. A Mac keyboard's command key arrives as meta, and Android's system answers meta+C by opening the calendar — no application ever sees it. In an emulator on a Mac, press Ctrl+C. (macOS's Keyboard settings can swap the two modifiers if you would rather keep the muscle memory.)
  • A MarkdownText's copy on iOS is the whole paragraph. React Native's Text has no partial selection there and copy: is sent for the lot, so a handler is given the whole document rather than a part of it.

Keys

onKeyDown and onKeyUp report what a hardware keyboard pressed, shaped like the DOM's KeyboardEvent:

<MarkdownTextInput
  value={value}
  onChangeText={setValue}
  onKeyDown={(event) => {
    if (event.key === "b" && (event.metaKey || event.ctrlKey)) {
      wrapSelection("**")
    }
  }}
/>
interface MarkdownKeyEvent {
  key: string       // "b", "B", "å", " ", "Enter", "ArrowLeft", "F7", "Shift"
  altKey: boolean
  ctrlKey: boolean
  metaKey: boolean  // ⌘ on a Mac, the Windows key elsewhere
  shiftKey: boolean
}

This is not React Native's onKeyPress, which is built from the text an edit inserted: a chord that inserts nothing -- which is every shortcut worth having -- never reaches it, and it has no modifiers to report. These two are built from the key event itself. onKeyPress still works, and is still the right thing for "was that a backspace?".

key follows the DOM. A key that produces a character reports the character the layout produced with shift and alt applied, so a, A and å are three different keys; control and command are reported beside the character rather than folded into it, so ⌘B and Ctrl+B both say "b". A key that produces no character reports its name: Enter, Backspace, Tab, Escape, Delete, the four Arrow*, Home, End, PageUp, PageDown, Insert, CapsLock, F1-F12, and the modifiers themselves as Shift, Control, Alt and Meta. Anything this library has no name for is "Unidentified".

A held key repeats, as it does on the web: each repeat is another onKeyDown, and the onKeyUp at the end says it was let go.

A modifier carries itself. Pressing Shift on its own is a key press like any other, and the flag describes the state the event left behind -- shiftKey is true on its way down and false on its way back up. That is the DOM's rule; Android reports the state before the key applied, and is normalised to agree.

Taking a key away from the platform

captureKeys names the chords the input should swallow:

<MarkdownTextInput
  value={value}
  onChangeText={setValue}
  onKeyDown={(event) => {
    if (event.key === "b" && (event.metaKey || event.ctrlKey)) {
      wrapSelection("**")
    }
  }}
  captureKeys={["Meta+b", "Ctrl+b", "Tab"]}
/>

A captured key is still reported to onKeyDown and onKeyUp. What it no longer does is anything else: nothing is typed, no caret moves, the input's own handling of it does not run, and neither does the platform's -- ⌘B is yours, not whatever iOS would have done with it. The key's release is captured with it, even where the modifier was let go first.

Write a chord as its modifiers and its key joined by +, in any order and any case. Meta, Cmd and Command all name the same key, as do Ctrl and Control, Alt and Option. The key is spelled the way key reports it -- "b", "1", "+", "Escape", "ArrowLeft", "F7". Shift is part of the chord rather than of the key, so "Meta+b" and "Meta+B" are the same chord and neither of them is ⌘⇧B; that one is "Meta+Shift+B". An entry nobody can read is dropped with a warning rather than thrown over.

Handlers are not required. A chord may be captured with none attached at all, which is how a shortcut is taken away from the platform and given to a menu somewhere else in the application.

What to know before designing around this

  • A hardware keyboard only. Tapping an on-screen key is not a key press on either native platform -- iOS turns it straight into text, and Android's input method commits it over an InputConnection. Neither raises the event these are built from. Use onChangeText for what was typed.
  • Handlers report; captureKeys decides. The handler is told what was pressed after the fact, so it cannot swallow a key: an event that has crossed from native to JavaScript has already happened. What is swallowed is decided before the platform acts, from the list, natively. On the web the handler is given the DOM event itself, before the input does anything with it, so preventDefault() works there too -- along with code, repeat and the rest of KeyboardEvent.
  • Keep the handler off the input's own state. These run in the middle of the edit the keystroke is making. Setting state that re-renders a controlled MarkdownTextInput from inside one puts a stale value back into an input that has already moved on, and a character can end up in twice. Keep whatever the handler records away from the input's own props -- the keys screen in the example app shows the shape of it.
where the keys come from how one is captured
Web keydown and keyup on the input, passed through untouched preventDefault(), and the input's own handling skipped
Android the pre-IME pass over the focused view, before an input method can take the key the pass is answered, so nothing downstream sees the key
iOS pressesBegan: and pressesEnded: on the backed text input the press is not passed on to UIKit

The caret

onCaretMove says where the caret is, in the input's own coordinates, whenever it moves:

const [caret, setCaret] = useState(null)

<View style={{ position: "relative" }}>
  <MarkdownTextInput value={value} onChangeText={setValue} onCaretMove={setCaret} multiline />
  {caret ? (
    <Autocomplete style={{ position: "absolute", left: caret.x, top: caret.y + caret.height }} />
  ) : null}
</View>
interface MarkdownCaretEvent {
  x: number       // from the input's left edge
  y: number       // from the input's top edge
  width: number   // how wide the caret is drawn; iOS only, 0 elsewhere
  height: number  // the height of the line, so y + height is the bottom of it
}

The origin is the input's top-left corner -- its border box, before its own padding. The decorator that measures this has no layout of its own to measure against: it wraps the input with display: contents, and the input is the only view in the pair with a frame. Put the overlay in a container the input is also in, as above, and the numbers go straight into left and top with nothing added to them.

height is the line's, not the glyphs', which is what makes y + height the bottom of the current line -- the place something drawn under it goes. A caret on an indented line, in a quote or a list, is reported where it is drawn, so an overlay follows the indent without knowing anything about it.

It is the bottom of the line to within a point or two rather than to the pixel. Android measures the line box and the web measures the rectangle a collapsed range occupies, and both are exactly the line; iOS reports the height UIKit draws the caret at, which is slightly taller than the line-to-line advance. Position against it, do not tile with it.

The numbers include the input's scrolling. They say where the caret is on the screen rather than where it is in the document, so a caret scrolled out of sight reports a y outside the input's height -- often a negative one. That is the answer rather than a fault: it is what tells an overlay to hide instead of following the caret off the edge.

A selection reports its end -- the later of the two, in the text's own order -- because a selection has no caret. Not the end that moved, which is only a different answer for a selection made backwards: UITextRange is a start and an end and says nothing about which of them UIKit just dragged, so the end that moves is not a number iOS can give. All three report the same one instead.

Only while the input has focus, and blurring fires nothing. There is no caret to report, and an overlay that has just taken the focus for itself wants the last position it was given to stand rather than to be withdrawn from under it. onBlur is the other half of that. Focus coming back reports the caret again wherever it lands, even if that is exactly where it was.

Only when the rectangle changed. The caret is measured far more often than it moves -- on every draw, on both native platforms -- and an unchanged rectangle is dropped before it crosses the bridge. What you are called for is a movement.

What to know before designing around this

  • width is iOS's alone. UIKit reports a caret rectangle and it has a width; Android has no public caret geometry, and the DOM's answer for a collapsed range is a rectangle of no width. Both report 0. Treat 0 as "no width to work with" rather than as a measurement -- centre on x if you must centre on something.
  • Watching costs something. Both native platforms look at the caret again every time the input draws, and the web measures it on a frame after every selection change, scroll and resize. It is all behind the handler: attach none and nothing is installed, measured or observed. Do not attach one to keep a value you never read.
  • The handler runs after the edit, not during it, so unlike onKeyDown it is safe to set state from directly -- including state a controlled MarkdownTextInput renders from. The caret screen in the example app is written that way.
  • Round trips, not instants. The rectangle is measured natively and delivered to JavaScript, which takes a frame. An overlay following the caret is a frame behind it; that is invisible when typing and visible if you animate the overlay's position, so do not.
where the rectangle comes from what says it may have moved
Web a collapsed Range at the selection's end, differenced against the input's own box selectionchange, the input's scroll, a ResizeObserver, every render
Android Layout.getPrimaryHorizontal and the line's top and bottom, less the scroll a pre-draw listener: one hook for the selection, the text, the scroll and the layout
iOS caretRect(for:) on the backed view, converted into the view around it the backed input's delegate for the selection, and layoutSubviews for the scroll and the size

Animation

animation types the text on rather than simply showing it, at the rate you give it in characters a second. Anything falsy -- which is the default -- turns it off and costs nothing.

<MarkdownTextInput animation={40} value={value} onChangeText={setValue} multiline />

Only what the person at the keyboard types is animated. Pasting, dictation, autocorrect, an input method's candidates, undo, and anything the application writes into value all appear at once, because none of them is somebody typing.

Only the text on the screen waits. value, onChangeText, the selection, the clipboard and the screen reader are all looking at the finished document while the animation is still catching up, so no application logic ever sees a half-typed word. What has not been shown yet is in the document and takes up no room in it: nothing is reserved for a character that has not arrived, and the line grows as the text does.

Deleting is not animated. A character cannot be taken away a piece at a time once the document no longer has it, so a deletion happens the moment it is made -- backspace, forward delete, a selection, a word at a time -- and whatever is still waiting elsewhere simply moves out of its way and carries on.

On the web, the browser draws the caret from the text it is standing in, and text of no width gives it no height: the caret is not drawn while there are characters still to come. At a normal rate that is the few milliseconds between one keystroke and the next; at a slow one it lasts as long as the animation does.

The rate is nominal rather than a maximum: whatever is waiting has to be shown within a second, so typing faster than the animation gives the characters back speeds it up rather than falling behind.

A rate above typing speed shows nothing, and that is arithmetic rather than a limitation. Only text that arrives faster than the rate ever waits: at 16 a second -- about twice as fast as a quick typist -- each character is given back in 62ms, long before the next keystroke lands, so the queue never holds more than the one character and the effect is invisible. Choose the rate you want the text read at rather than one that keeps up with the keyboard: 4 to 8 reads as a typewriter, and 16 and above is a keyboard with the drawing a frame or two behind it.

It is off wherever the system asks for less motion -- Reduce Motion on iOS, animations turned off in developer options or accessibility settings on Android, prefers-reduced-motion on the web.

onAnimationFrame is called once a frame at most, whenever the text on the screen changed, with or without animation. It takes no arguments and is for keeping something else in step: a scroll position pinned to the end of the text, a caret drawn alongside, a companion view that has to grow with it.

API reference

MarkdownTextInput inherits all props of React Native's TextInput component as well as introduces the following properties:

Prop Type Default Note
markdownStyle MarkdownStyle undefined Adds custom styling to Markdown text. The provided value is merged with default style object. See Styling for more information.
gfm boolean false Recognises GFM's strikethrough and bare autolinks. See Markdown flavors support.
mention boolean false Recognises @name mentions. See Markdown flavors support.
math boolean false Recognises $$x^2$$ LaTeX. See Math.
animation number 0 Types what you type on at this many characters a second. Falsy turns it off. See Animation.
onAnimationFrame () => void undefined Called once a frame at most, whenever the text on the screen changed. See Animation.
onPaste (targets, context) => Promise<string | null> undefined Turns what is on the clipboard into the markdown to insert. See The clipboard.
onCopy (text, context) => write[] | null undefined Describes the copied selection for the clipboard. Synchronous. See The clipboard.
onKeyDown (event) => void undefined A key on a hardware keyboard going down. See Keys.
onKeyUp (event) => void undefined The same, for a key coming back up. See Keys.
captureKeys string[] [] Chords the input swallows rather than acts on, as ["Meta+b", "Escape"]. See Keys.
onCaretMove (event) => void undefined Where the caret is, whenever it moves. See The caret.

MarkdownText inherits all props of React Native's Text component, takes markdownStyle and the three flavour props above, and adds:

Prop Type Default Note
renderEmbed (uri, alt, title, inline) => element? undefined What to put where an image was. Without it, images are left as markup.
renderSVG (xml, width, height) => element? undefined What to draw an SVG with. Without it, maths is left as it was written. See Math.
mathAccessibilityLocale string "en" The language a drawn equation is read out in. See Accessibility and copying.
onLinkPress (uri, label, title) => void undefined Called when a link is pressed. The URL is not opened for you.
onCopy (text, context) => write[] | null undefined Describes the copied selection for the clipboard. See The clipboard.

Compatibility

react-native-marcus supports only latest React Native minor releases with the New Architecture enabled.

React Native compatibility

0.81 0.82 0.83 0.84 0.85 0.86
0.0.1+

react-native-worklets compatibility

0.6.x 0.7.x 0.8.x 0.9.x 0.10.2+
0.0.1+

License

Apache-2.0

About

Drop-in replacement for React Native's TextInput component with Markdown formatting.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Sponsor this project

Packages

Contributors

Languages