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, +};