Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).

Expand Down
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
14 changes: 11 additions & 3 deletions docs/VERSION-UPDATE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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 .",
Expand Down
15 changes: 15 additions & 0 deletions react-native-libraries-entry.json
Original file line number Diff line number Diff line change
@@ -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
}
186 changes: 186 additions & 0 deletions snack/App.js
Original file line number Diff line number Diff line change
@@ -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 = `
<h1>The reading room</h1>
<p class="lede">
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.
</p>
<p>
Each highlight is serialized into a plain string you can store anywhere —
AsyncStorage, SQLite, your own API. Hand that string back through the
<code>highlights</code> prop and the highlights return exactly where the
reader left them: on another screen, another session, another device.
</p>
<p>
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.
</p>
<p>
Try it: highlight a few passages, press the bin to clear them, then press
undo to bring them back from the saved string.
</p>
`;

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 (
<View style={styles.screen}>
<StatusBar style="light" />
<Header count={count} topInset={insets.top} />

<View style={styles.reader}>
<SelectableTextView
ref={ref}
content={article}
css={css}
highlighters={highlighters}
highlights={highlights}
webViewProps={{
style: styles.webview,
menuItems: [
{ key: "highlight", label: "Highlight" },
{ key: "underline", label: "Underline" },
{ key: "remove", label: "Remove" },
],
onCustomMenuSelection: (event) => {
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);
}}
/>
</View>

<Toolbar
current={current}
onPick={(name) => {
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}
/>
</View>
);
}

export default function App() {
return (
<SafeAreaProvider>
<Demo />
</SafeAreaProvider>
);
}

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 },
});
85 changes: 85 additions & 0 deletions snack/README.md
Original file line number Diff line number Diff line change
@@ -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.
Binary file added snack/assets/snack-icon.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Loading