diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index ff872fe..4184870 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -43,6 +43,16 @@ yarn ios # or: yarn android
- `rangy@1.3.2/` — vendored [Rangy](https://github.com/timdown/rangy) (do **not** edit or strip its copyright headers; see [THIRD-PARTY-NOTICES.md](./docs/THIRD-PARTY-NOTICES.md)).
- `android/`, `ios/` — the native Kotlin/Swift bridge.
- `example/` — a runnable Expo app used as the manual test bed.
+- `snack/` — the source of the published [Expo Snack](./snack/README.md), kept
+ here and copied into Snack by hand.
+- `react-native-libraries-entry.json` — our entry in
+ [React Native Directory](https://github.com/react-native-community/directory),
+ the listing most people browse before picking a library. The directory keeps
+ the real copy inside its own `react-native-libraries.json`; this file is the
+ local original, so a change here is not live until it is sent over as a pull
+ request to that repository. Update it when a platform flag changes, when the
+ example or demo links move, or when a new one (such as the Snack) is worth
+ listing under `examples`.
When adding a bridge message, keep the three sides in sync: `BridgingNames` (types.ts), the WebView handler (utils.ts), and the RN handler (SelectableTextView.tsx).
diff --git a/README.md b/README.md
index a1e5f28..bf0b7fe 100644
--- a/README.md
+++ b/README.md
@@ -11,6 +11,10 @@ highlighting via [Rangy](https://github.com/timdown/rangy). Serialize, sync, res
Web is not supported.
+> **[Try it in Expo Go →](https://snack.expo.dev/@majornutcracker/selectabletext)**
+> Select a passage, pick a color from the native menu, clear the highlights and
+> bring them back. No install, no build.
+
## Is this the right library?
| You need | This module |
diff --git a/docs/VERSION-UPDATE.md b/docs/VERSION-UPDATE.md
index 32fec5b..17a7937 100644
--- a/docs/VERSION-UPDATE.md
+++ b/docs/VERSION-UPDATE.md
@@ -39,9 +39,17 @@ regardless of whether the release is a patch, minor, or major. It is independent
The release workflow does not look at these, and a stale value here publishes a perfectly
good package. Refresh them when you remember.
-| File | What to change | Note |
-| --------------------------------------- | -------------------------------------- | --------------------------------------------------- |
-| `.github/ISSUE_TEMPLATE/bug_report.yml` | `placeholder:` under the version input | Shows a greyed-out example version in the bug form. |
+| File | What to change | Note |
+| --------------------------------------- | ------------------------------------------------------------------ | ------------------------------------------------------------------------------------------ |
+| `.github/ISSUE_TEMPLATE/bug_report.yml` | `placeholder:` under the version input | Shows a greyed-out example version in the bug form. |
+| `snack/package.json` | the pinned `@majornutcracker/react-native-selectable-text` version | The Snack demo installs exactly that version, so a stale pin shows the world an old build. |
+
+The Snack itself is a hand-kept copy of `snack/`, so bumping the file here is
+only half of it: open the published Snack and raise the pinned version in its
+dependency panel too. Until you do, the "try it" link everyone clicks — the
+README, the React Native Directory entry, answers that point at it — is running
+the previous release. See [`snack/README.md`](../snack/README.md) for the full
+procedure.
## Do NOT edit (auto-derived)
diff --git a/package.json b/package.json
index 30bc9e3..219211d 100644
--- a/package.json
+++ b/package.json
@@ -25,7 +25,7 @@
"scripts": {
"build": "expo-module build",
"clean": "expo-module clean",
- "lint": "expo-module lint",
+ "lint": "expo-module lint && eslint snack",
"test": "expo-module test",
"typecheck": "tsc --noEmit",
"format": "prettier --write .",
diff --git a/react-native-libraries-entry.json b/react-native-libraries-entry.json
new file mode 100644
index 0000000..9357eaa
--- /dev/null
+++ b/react-native-libraries-entry.json
@@ -0,0 +1,15 @@
+{
+ "githubUrl": "https://github.com/majornutcracker-dev/react-native-selectable-text",
+ "npmPkg": "@majornutcracker/react-native-selectable-text",
+ "examples": [
+ "https://snack.expo.dev/@majornutcracker/selectabletext",
+ "https://github.com/majornutcracker-dev/react-native-selectable-text/tree/main/example"
+ ],
+ "images": [
+ "https://github.com/majornutcracker-dev/react-native-selectable-text/raw/main/assets/ios.gif"
+ ],
+ "ios": true,
+ "android": true,
+ "expoGo": true,
+ "newArchitecture": true
+}
diff --git a/snack/App.js b/snack/App.js
new file mode 100644
index 0000000..a386c8a
--- /dev/null
+++ b/snack/App.js
@@ -0,0 +1,186 @@
+import { SelectableTextView } from "@majornutcracker/react-native-selectable-text";
+import { StatusBar } from "expo-status-bar";
+import { useRef, useState } from "react";
+import { StyleSheet, View } from "react-native";
+import {
+ SafeAreaProvider,
+ useSafeAreaInsets,
+} from "react-native-safe-area-context";
+
+import { Header } from "./components/Header";
+import { Toolbar } from "./components/Toolbar";
+import { highlighters } from "./highlighters";
+import { theme } from "./theme";
+
+const article = `
+
The reading room
+
+ Select any part of this text. The menu that pops up is the native selection
+ menu, and every item on it calls a method on this component.
+
+
+ Each highlight is serialized into a plain string you can store anywhere —
+ AsyncStorage, SQLite, your own API. Hand that string back through the
+ highlights prop and the highlights return exactly where the
+ reader left them: on another screen, another session, another device.
+
+
+ Tap a highlight and you get its id, its text and where it sits on screen,
+ so a popover can be anchored without measuring anything yourself.
+
+
+ Try it: highlight a few passages, press the bin to clear them, then press
+ undo to bring them back from the saved string.
+
+`;
+
+const css = `
+ body { background: ${theme.color.paper}; }
+ .content {
+ padding: 24px 22px 32px;
+ font-size: 18px;
+ line-height: 1.7;
+ color: ${theme.color.paperInk};
+ font-family: -apple-system, Roboto, "Segoe UI", sans-serif;
+ }
+ h1 { font-size: 25px; line-height: 1.2; margin: 0 0 12px; letter-spacing: -0.4px; }
+ .lede { font-size: 19px; color: #2C3444; }
+ p { margin: 0 0 16px; }
+ code {
+ background: #E7EAF2;
+ padding: 1px 5px;
+ border-radius: 5px;
+ font-size: 15px;
+ }
+`;
+
+function Demo() {
+ const insets = useSafeAreaInsets();
+ const ref = useRef(null);
+
+ const [current, setCurrent] = useState("amber");
+ const [highlights, setHighlights] = useState(undefined);
+ const [count, setCount] = useState(0);
+ const [depth, setDepth] = useState(0);
+ const [status, setStatus] = useState("Select some text to begin");
+
+ /**
+ * Every payload the view has reported, oldest first — an undo history rather
+ * than a single slot. A ref, not state: pushing must not re-render, and the
+ * handlers have to read the current stack, not the one their closure was
+ * created with.
+ *
+ * Popping matters for more than history. The view ignores a payload it just
+ * emitted, and React skips an effect when the prop value is unchanged, so
+ * restoring the *same* string twice does nothing either way. Each pop hands
+ * over a different, older payload, so the prop always changes and the view
+ * always acts.
+ */
+ const history = useRef([]);
+ /** The payload a restore just pushed back in, so it is not re-recorded. */
+ const restoring = useRef(null);
+
+ return (
+
+
+
+
+
+ {
+ const key = event.nativeEvent.key;
+ if (key === "remove") {
+ ref.current?.unhighlightSelection();
+ setStatus("Removed");
+ } else if (key === "underline") {
+ ref.current?.highlightSelection("underline");
+ setStatus("Underlined");
+ } else {
+ ref.current?.highlightSelection(current);
+ setStatus(`Highlighted in ${current}`);
+ }
+ },
+ }}
+ onHighlightsChange={(serialized, items) => {
+ setCount(items.length);
+
+ // The echo of a restore: it is already in the history, one step
+ // further back. Recording it again would undo the undo.
+ if (serialized === restoring.current) {
+ restoring.current = null;
+ return;
+ }
+ // Clearing reports an empty payload; there is nothing to go back
+ // to in it, and it would sit in the way of the real ones.
+ if (items.length === 0) return;
+
+ history.current.push(serialized);
+ setDepth(history.current.length);
+ }}
+ onHighlightPressed={(highlight) => {
+ setStatus(`Tapped: "${highlight.text.slice(0, 36)}"`);
+ }}
+ onError={(error) => {
+ // `details` carries what actually went wrong — the Rangy message
+ // behind a restore that refused, for instance. Worth showing:
+ // without it a failed restore looks like a button that does
+ // nothing.
+ setStatus(`${error.code}: ${error.details ?? error.message}`);
+ console.warn("[selectable-text]", error);
+ }}
+ />
+
+
+ {
+ setCurrent(name);
+ setStatus(`${name} selected — now highlight something`);
+ }}
+ onClear={() => {
+ ref.current?.clearHighlights();
+ setStatus("Cleared — undo brings them back");
+ }}
+ onRestore={() => {
+ const previous = history.current.pop();
+ setDepth(history.current.length);
+ if (previous === undefined) return;
+
+ restoring.current = previous;
+ setHighlights(previous);
+ setStatus(`Restored — ${history.current.length} step(s) left`);
+ }}
+ onToggle={() => ref.current?.toggleHighlightsVisibility()}
+ canRestore={depth > 0}
+ status={status}
+ bottomInset={insets.bottom}
+ />
+
+ );
+}
+
+export default function App() {
+ return (
+
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ screen: { flex: 1, backgroundColor: theme.color.bg },
+ reader: { flex: 1, backgroundColor: theme.color.paper },
+ webview: { flex: 1, backgroundColor: theme.color.paper },
+});
diff --git a/snack/README.md b/snack/README.md
new file mode 100644
index 0000000..3040cb7
--- /dev/null
+++ b/snack/README.md
@@ -0,0 +1,85 @@
+# Snack demo
+
+**Published at
+[snack.expo.dev/@majornutcracker/selectabletext](https://snack.expo.dev/@majornutcracker/selectabletext)**
+— that is the one to update, and the one linked from the README, the directory
+listing and anywhere else the demo is shared. It can also be embedded on a page
+with `data-snack-id="@majornutcracker/selectabletext"` plus
+`snack.expo.dev/embed.js`; GitHub strips scripts, so the README keeps the plain
+link.
+
+The source of the [Expo Snack](https://snack.expo.dev) that lets anyone try the
+module from a browser or from Expo Go, without cloning anything. It is the
+"try it" link in the README, in directory listings, and in answers we post.
+
+It is deliberately small: one screen, one document, four highlighters. The
+full tour — several documents, search, notes, animated exits, scroll
+restoration — lives in [`example/`](../example).
+
+## Why the source lives here
+
+Snack has no git integration we can rely on, so the editable copy is this
+folder and the Snack is a mirror of it. Editing here means the demo is
+reviewed, formatted and versioned like the rest of the repo, instead of only
+existing inside a web editor nobody else can see.
+
+## Files
+
+| File | What it holds |
+| ----------------------- | --------------------------------------------------------------------- |
+| `App.js` | The screen: reader, selection menu, and the save/clear/restore cycle. |
+| `highlighters.js` | The four highlighters and the three swatches the toolbar offers. |
+| `theme.js` | A trimmed copy of the example app's palette. |
+| `components/Header.js` | Icon, title and the highlight counter. |
+| `components/Toolbar.js` | Colour swatches and the hide / restore / clear actions. |
+| `assets/snack-icon.png` | The example app's icon at 512×512. |
+| `package.json` | The dependencies the Snack declares. |
+
+`expo`, `react` and `react-native` come from the Snack runtime, so they are not
+listed even though the module declares them as peer dependencies.
+
+## Updating the Snack
+
+The Snack is a copy of this folder, kept by hand. **Every change here — and
+every release — has to be carried over, or the demo shows an older library
+than the one people install.**
+
+1. Edit here and review the diff like any other change. `yarn lint` and
+ `yarn format` cover this folder.
+2. Open the Snack, keep **SDK 55** — the newest Snack offers — and paste each
+ changed file. Create the same folders (`components/`, `assets/`) so the
+ imports resolve, and upload `snack-icon.png` through the editor: an asset
+ cannot be typed in, and `Header.js` requires it on the first render.
+3. Check the dependency panel against `package.json`. The versions there are
+ the ones SDK 55 bundles, which is what Snack warns about when they differ.
+4. Run it on Android **and** iOS before saving. Expo Go is where the optional
+ native module is actually exercised.
+5. Save, and check the published link still opens the new version.
+
+### The `react-native-webview` mismatch
+
+Snack tops out at **SDK 55**, which bundles `react-native-webview@13.16.0`.
+The module's peer range is `^13.16.1`, so Snack reports an unmet peer
+dependency. It is a warning, not a wall: 13.16.0 has every API the module
+touches, and 13.16.1 only fixed an iOS crash inside the WebView itself
+([#3917](https://github.com/react-native-webview/react-native-webview/issues/3917)).
+
+Worth revisiting: a floor of `>=13.16.0` would cover every Expo Go from SDK 55
+on and drop the warning, at the cost of allowing a version with that crash. If
+the floor is ever relaxed, say so in the README's peer dependency line too.
+
+Automating this was investigated and dropped: Snack's GitHub import is broken,
+and `snack-sdk` in CI can save a Snack but is not documented to update an
+existing one in place, so the shared link could silently move. Not worth the
+moving parts for a demo that changes a few times a year.
+
+## What the demo is meant to prove
+
+- The selection menu is native and its items are yours — they call
+ `highlightSelection`, `unhighlightSelection` and friends on the ref.
+- A highlight is a string you can store. Clear them, then press undo: they come
+ back from the payload `onHighlightsChange` handed over.
+- Tapping a highlight reports its text, so anchoring your own popover needs no
+ measuring.
+- None of this needs a custom native build: the native module is optional, so
+ the package loads in Expo Go, where `react-native-webview` already ships.
diff --git a/snack/assets/snack-icon.png b/snack/assets/snack-icon.png
new file mode 100644
index 0000000..1bbb2ff
Binary files /dev/null and b/snack/assets/snack-icon.png differ
diff --git a/snack/components/Header.js b/snack/components/Header.js
new file mode 100644
index 0000000..60d3f45
--- /dev/null
+++ b/snack/components/Header.js
@@ -0,0 +1,59 @@
+import { Image, StyleSheet, Text, View } from "react-native";
+
+import { theme } from "../theme";
+
+export function Header({ count, topInset }) {
+ return (
+
+
+
+ Selectable Text
+
+ Select the text, pick an action from the menu
+
+
+
+ {count}
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ header: {
+ flexDirection: "row",
+ alignItems: "center",
+ gap: theme.space(3),
+ paddingHorizontal: theme.space(4),
+ paddingBottom: theme.space(3),
+ backgroundColor: theme.color.bgElevated,
+ borderBottomWidth: StyleSheet.hairlineWidth,
+ borderBottomColor: theme.color.border,
+ },
+ icon: { width: 34, height: 34, borderRadius: theme.radius.sm },
+ titles: { flex: 1 },
+ title: {
+ color: theme.color.text,
+ fontSize: 17,
+ fontWeight: "700",
+ letterSpacing: -0.2,
+ },
+ subtitle: { color: theme.color.textMuted, fontSize: 12, marginTop: 1 },
+ chip: {
+ minWidth: 30,
+ paddingHorizontal: theme.space(2),
+ paddingVertical: theme.space(1),
+ borderRadius: theme.radius.pill,
+ backgroundColor: theme.color.accent,
+ alignItems: "center",
+ },
+ chipText: {
+ color: theme.color.onAccent,
+ fontWeight: "700",
+ fontSize: 13,
+ },
+});
diff --git a/snack/components/Toolbar.js b/snack/components/Toolbar.js
new file mode 100644
index 0000000..6df0cdf
--- /dev/null
+++ b/snack/components/Toolbar.js
@@ -0,0 +1,120 @@
+import { Ionicons } from "@expo/vector-icons";
+import { Pressable, StyleSheet, Text, View } from "react-native";
+
+import { swatches } from "../highlighters";
+import { theme } from "../theme";
+
+export function Toolbar({
+ current,
+ onPick,
+ onClear,
+ onRestore,
+ onToggle,
+ canRestore,
+ status,
+ bottomInset,
+}) {
+ return (
+
+
+ {status}
+
+
+
+
+ {swatches.map((swatch) => {
+ const active = swatch.name === current;
+ return (
+ onPick(swatch.name)}
+ accessibilityRole="button"
+ accessibilityLabel={`Use the ${swatch.name} highlighter`}
+ style={[
+ styles.swatch,
+ { backgroundColor: swatch.color },
+ active && styles.swatchActive,
+ ]}
+ >
+ {active ? (
+
+ ) : null}
+
+ );
+ })}
+
+
+
+
+
+
+
+
+
+ );
+}
+
+function Action({ icon, label, onPress, disabled }) {
+ return (
+ [
+ styles.action,
+ pressed && styles.actionPressed,
+ disabled && styles.actionDisabled,
+ ]}
+ >
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ bar: {
+ gap: theme.space(3),
+ paddingHorizontal: theme.space(4),
+ paddingTop: theme.space(3),
+ backgroundColor: theme.color.bgElevated,
+ borderTopWidth: StyleSheet.hairlineWidth,
+ borderTopColor: theme.color.border,
+ },
+ status: { color: theme.color.textMuted, fontSize: 12.5 },
+ row: {
+ flexDirection: "row",
+ alignItems: "center",
+ justifyContent: "space-between",
+ gap: theme.space(3),
+ },
+ swatches: { flexDirection: "row", gap: theme.space(2) },
+ swatch: {
+ width: 34,
+ height: 34,
+ borderRadius: theme.radius.pill,
+ alignItems: "center",
+ justifyContent: "center",
+ borderWidth: 2,
+ borderColor: "transparent",
+ },
+ swatchActive: { borderColor: theme.color.text },
+ actions: { flexDirection: "row", gap: theme.space(2) },
+ action: {
+ width: 40,
+ height: 40,
+ borderRadius: theme.radius.md,
+ alignItems: "center",
+ justifyContent: "center",
+ backgroundColor: theme.color.bgCard,
+ borderWidth: StyleSheet.hairlineWidth,
+ borderColor: theme.color.border,
+ },
+ actionPressed: { backgroundColor: theme.color.border },
+ actionDisabled: { opacity: 0.4 },
+});
diff --git a/snack/highlighters.js b/snack/highlighters.js
new file mode 100644
index 0000000..2bd4ae8
--- /dev/null
+++ b/snack/highlighters.js
@@ -0,0 +1,49 @@
+import { theme } from "./theme";
+
+const { amber, coral, azure } = theme.highlight;
+
+/**
+ * One highlighter per colour, plus an underline that shows a different
+ * `type`. The entrance animation runs once — it is an arrival, not a loop.
+ */
+export const highlighters = [
+ {
+ name: "amber",
+ options: {
+ type: "background-color",
+ color: amber,
+ animation: {
+ keyframesCss: `
+ @keyframes markerIn {
+ from { background-color: transparent; }
+ to { background-color: ${amber}; }
+ }
+ `,
+ name: "markerIn",
+ duration: "420ms",
+ timingFunction: "cubic-bezier(.2,.8,.2,1)",
+ iterationCount: 1,
+ },
+ },
+ },
+ { name: "coral", options: { type: "background-color", color: coral } },
+ { name: "azure", options: { type: "background-color", color: azure } },
+ {
+ name: "underline",
+ options: {
+ type: "text-decoration-color",
+ color: theme.color.accent,
+ line: "underline",
+ style: "wavy",
+ thickness: 2,
+ offset: 4,
+ },
+ },
+];
+
+/** The three swatches the toolbar offers, in order. */
+export const swatches = [
+ { name: "amber", color: amber },
+ { name: "coral", color: coral },
+ { name: "azure", color: azure },
+];
diff --git a/snack/package.json b/snack/package.json
new file mode 100644
index 0000000..2f32fac
--- /dev/null
+++ b/snack/package.json
@@ -0,0 +1,9 @@
+{
+ "dependencies": {
+ "@majornutcracker/react-native-selectable-text": "1.1.0",
+ "@expo/vector-icons": "^15.0.2",
+ "expo-status-bar": "~55.0.6",
+ "react-native-safe-area-context": "~5.6.2",
+ "react-native-webview": "13.16.0"
+ }
+}
diff --git a/snack/theme.js b/snack/theme.js
new file mode 100644
index 0000000..dfbd8e3
--- /dev/null
+++ b/snack/theme.js
@@ -0,0 +1,26 @@
+/**
+ * A trimmed-down version of the example app's palette, so the Snack and the
+ * full example clearly belong to the same library.
+ */
+export const theme = {
+ color: {
+ bg: "#080A10",
+ bgElevated: "#0F131D",
+ bgCard: "#171C2A",
+ border: "#2B3448",
+ text: "#F2F5FA",
+ textMuted: "#9AA6BF",
+ accent: "#7C5CFF",
+ onAccent: "#0B0713",
+ paper: "#F7F8FB",
+ paperInk: "#141824",
+ },
+ /** The highlighter family, shared with `highlighters.js`. */
+ highlight: {
+ amber: "#FFC857",
+ coral: "#FF7A93",
+ azure: "#5BC8FF",
+ },
+ radius: { sm: 8, md: 14, lg: 20, pill: 999 },
+ space: (n) => n * 4,
+};