diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml
index 912ddee..65df1d6 100644
--- a/.github/ISSUE_TEMPLATE/bug_report.yml
+++ b/.github/ISSUE_TEMPLATE/bug_report.yml
@@ -37,7 +37,7 @@ body:
id: package-version
attributes:
label: "@majornutcracker/react-native-selectable-text version"
- placeholder: 1.1.0
+ placeholder: 1.2.0
validations:
required: true
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 514f454..29828fd 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,49 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
+## [1.2.0] - 2026-09-24
+
+> **Read this before upgrading.** The `highlights` prop is gone, so code that
+> passes it no longer compiles. Rename it to `initialHighlights` for the value
+> the view mounts with, and call `setHighlights()` for every change after that.
+> The replacement is not a drop-in: `initialHighlights` is read **once**, when
+> the content is built, and changing it later does nothing.
+>
+> ```diff
+> -
+> +
+> + // later: ref.current?.setHighlights(payload)
+> ```
+
+### Added
+
+- `setHighlights(payload)` on the ref replaces every highlight in a mounted
+ view; `""` clears them.
+- An undo history, kept by the view itself: `undo()`, `redo()` and
+ `clearHistory()` on the ref, and `getHistory()` to read it.
+- `onHistoryChange`, which reports where the history stands — including
+ `canUndo` and `canRedo` — so undo and redo controls enable themselves without
+ tracking anything. The history records content changes only (highlighting,
+ unhighlighting, clearing and `setHighlights`) and holds the last 50 states.
+- `initialHighlights`, the serialized highlights painted when the view mounts.
+
+### Changed
+
+- A payload that is not a serialized highlights string is now refused through
+ `onError` with `invalid_highlight` **without touching the highlights already
+ on screen**. It used to remove them first and fail afterwards, so a bad
+ string cost the reader their highlights.
+- Stepping through the history no longer replays a highlight's entrance
+ animation: an undo restores something the reader has already seen.
+
+### Removed
+
+- The `highlights` prop, replaced as described above. It was both the mount
+ value and a controlled state prop, which meant the view had to guess which
+ values were genuine and which were its own report coming back — restoring
+ through it was unpredictable, and undo on top of it needed a stack, an echo
+ flag and string-identity reasoning in every consumer.
+
## [1.1.0] - 2026-09-23
### Changed
@@ -48,6 +91,7 @@ Initial release.
- Font helpers (`googleFonts()`, `mergeFonts()`), ignored elements, and viewport
zoom options.
-[unreleased]: https://github.com/majornutcracker-dev/react-native-selectable-text/compare/v1.1.0...HEAD
+[unreleased]: https://github.com/majornutcracker-dev/react-native-selectable-text/compare/v1.2.0...HEAD
+[1.2.0]: https://github.com/majornutcracker-dev/react-native-selectable-text/compare/v1.1.0...v1.2.0
[1.1.0]: https://github.com/majornutcracker-dev/react-native-selectable-text/compare/v1.0.0...v1.1.0
[1.0.0]: https://github.com/majornutcracker-dev/react-native-selectable-text/releases/tag/v1.0.0
diff --git a/README.md b/README.md
index bf0b7fe..9b60e27 100644
--- a/README.md
+++ b/README.md
@@ -86,7 +86,7 @@ const highlighters: Highlighter[] = [
},
];
-export default function Screen() {
+export default function Screen({ saved }: { saved?: string }) {
const ref = React.useRef(null);
return (
@@ -95,6 +95,8 @@ export default function Screen() {
content="
- Try it: highlight a few passages, press the bin to clear them, then press
- undo to bring them back from the saved string.
+ Try it: highlight a few passages, press the bin to clear them, then undo —
+ and redo. The view keeps the history, so the buttons below only have to ask
+ for it.
`;
@@ -59,27 +60,10 @@ function Demo() {
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 [history, setHistory] = useState({ canUndo: false, canRedo: false });
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 (
@@ -91,7 +75,6 @@ function Demo() {
content={article}
css={css}
highlighters={highlighters}
- highlights={highlights}
webViewProps={{
style: styles.webview,
menuItems: [
@@ -115,20 +98,10 @@ function Demo() {
}}
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);
+ // A real app stores this — AsyncStorage, SQLite, an API — and hands
+ // it back through `initialHighlights` the next time the screen opens.
}}
+ onHistoryChange={setHistory}
onHighlightPressed={(highlight) => {
setStatus(`Tapped: "${highlight.text.slice(0, 36)}"`);
}}
@@ -153,17 +126,17 @@ function Demo() {
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`);
+ onUndo={() => {
+ ref.current?.undo();
+ setStatus("Undone");
+ }}
+ onRedo={() => {
+ ref.current?.redo();
+ setStatus("Redone");
}}
onToggle={() => ref.current?.toggleHighlightsVisibility()}
- canRestore={depth > 0}
+ canUndo={history.canUndo}
+ canRedo={history.canRedo}
status={status}
bottomInset={insets.bottom}
/>
diff --git a/snack/components/Toolbar.js b/snack/components/Toolbar.js
index 6df0cdf..dcea78f 100644
--- a/snack/components/Toolbar.js
+++ b/snack/components/Toolbar.js
@@ -8,9 +8,11 @@ export function Toolbar({
current,
onPick,
onClear,
- onRestore,
+ onUndo,
+ onRedo,
onToggle,
- canRestore,
+ canUndo,
+ canRedo,
status,
bottomInset,
}) {
@@ -48,9 +50,15 @@ export function Toolbar({
+
diff --git a/snack/package.json b/snack/package.json
index 2f32fac..3f7bebf 100644
--- a/snack/package.json
+++ b/snack/package.json
@@ -1,6 +1,6 @@
{
"dependencies": {
- "@majornutcracker/react-native-selectable-text": "1.1.0",
+ "@majornutcracker/react-native-selectable-text": "1.2.0",
"@expo/vector-icons": "^15.0.2",
"expo-status-bar": "~55.0.6",
"react-native-safe-area-context": "~5.6.2",
diff --git a/src/SelectableTextView.tsx b/src/SelectableTextView.tsx
index baad643..1eaffe7 100644
--- a/src/SelectableTextView.tsx
+++ b/src/SelectableTextView.tsx
@@ -14,6 +14,8 @@ import {
type FocusHighlightOptions,
type UnhighlightOptions,
type EvaluateJavaScriptOptions,
+ type HistoryChange,
+ type HistoryState,
} from "./types";
import { generatePromiseId, htmlContent } from "./utils";
import { Linking, Platform } from "react-native";
@@ -35,13 +37,28 @@ export type SelectableTextViewProps = SelectableTextViewPropsBase & {
>;
};
+/**
+ * The page reports the history the same way in its event and in its answer to
+ * `getHistory`, so both are read here rather than in two places that could
+ * drift apart.
+ */
+function readHistoryState(value: any): HistoryState {
+ return {
+ history: (value?.history ?? []) as Highlights[],
+ historyIndex: Number(value?.historyIndex ?? 0),
+ length: Number(value?.length ?? 0),
+ canUndo: Boolean(value?.canUndo),
+ canRedo: Boolean(value?.canRedo),
+ };
+}
+
const SelectableTextView = React.forwardRef<
SelectableTextViewRef,
SelectableTextViewProps
>((props, ref) => {
const {
highlighters,
- highlights,
+ initialHighlights,
content,
css,
fonts,
@@ -54,6 +71,7 @@ const SelectableTextView = React.forwardRef<
onHighlightPressed,
onHighlightsVisibilityStateChange,
onCustomMessage,
+ onHistoryChange,
webViewProps,
} = props;
const promises = React.useRef>({});
@@ -75,12 +93,6 @@ const SelectableTextView = React.forwardRef<
const webviewRef = React.useRef(null);
- // The last payload this view reported through onHighlightsChange, seeded with
- // the value baked into the HTML so the very first echo is recognised too.
- const lastEmittedHighlights = React.useRef(
- highlights
- );
-
// Which selection the last getSelectedText() read, so an action decided on
// that text can refuse to land on a different one.
const lastSelectionVersion = React.useRef(undefined);
@@ -95,7 +107,7 @@ const SelectableTextView = React.forwardRef<
const finalSource = React.useRef({
html: htmlContent({
hl: highlighters,
- h: highlights,
+ h: initialHighlights,
c: content,
css: css,
f: fonts,
@@ -125,9 +137,6 @@ const SelectableTextView = React.forwardRef<
try {
if (data.type === BridgingNames.events.onHighlightsChange) {
const serialized = (data.value?.highlights ?? "") as Highlights;
- // Remembered so the effect below can tell a genuine restore request
- // from the consumer echoing back what this view just reported.
- lastEmittedHighlights.current = serialized;
onHighlightsChange?.(
serialized,
(data.value?.items ?? []) as HighlightData[]
@@ -259,6 +268,15 @@ const SelectableTextView = React.forwardRef<
if (typeof message?.type === "string") {
onCustomMessage?.({ type: message.type, data: message.data });
}
+ } else if (data.type === BridgingNames.events.onHistoryChange) {
+ onHistoryChange?.({
+ ...readHistoryState(data.value),
+ change: data.value.change as HistoryChange,
+ });
+ } else if (data.type === BridgingNames.promises.getHistory) {
+ const id = data.value.promiseId;
+ promises.current[id]?.resolve(readHistoryState(data.value));
+ settlePromise(id);
}
} catch (error) {
// A consumer callback threw or rejected. This handler is async, so
@@ -301,26 +319,6 @@ const SelectableTextView = React.forwardRef<
[onLink]
);
- const isFirstHighlightsEffect = React.useRef(true);
- React.useEffect(() => {
- // The initial value is already baked into the generated HTML; re-posting it
- // would replay the highlights and emit a redundant change event.
- if (isFirstHighlightsEffect.current) {
- isFirstHighlightsEffect.current = false;
- return;
- }
- // Echo of this view's own last change event: restoring it would wipe and
- // re-deserialize the highlights the content already has, dropping the focus
- // style and looping back through onHighlightsChange.
- if (highlights === lastEmittedHighlights.current) {
- return;
- }
- _postMessage({
- type: BridgingNames.functions.updateHighlights,
- value: highlights,
- });
- }, [highlights]);
-
React.useEffect(() => {
return () => {
for (const key in promises.current) {
@@ -457,6 +455,37 @@ const SelectableTextView = React.forwardRef<
const toggleHighlightsVisibility = () =>
_request(BridgingNames.promises.toggleHighlightsVisibility);
+ const setHighlights = (highlights: Highlights) => {
+ _postMessage({
+ type: BridgingNames.functions.updateHighlights,
+ value: highlights,
+ });
+ };
+
+ const undo = () => {
+ _postMessage({
+ type: BridgingNames.functions.undo,
+ value: null,
+ });
+ };
+
+ const redo = () => {
+ _postMessage({
+ type: BridgingNames.functions.redo,
+ value: null,
+ });
+ };
+
+ const clearHistory = () => {
+ _postMessage({
+ type: BridgingNames.functions.clearHistory,
+ value: null,
+ });
+ };
+
+ const getHistory = () =>
+ _request(BridgingNames.promises.getHistory);
+
const evaluateJavaScript = (
script: string,
options?: EvaluateJavaScriptOptions
@@ -508,6 +537,11 @@ const SelectableTextView = React.forwardRef<
getHighlightsVisibilityState,
toggleHighlightsVisibility,
evaluateJavaScript,
+ setHighlights,
+ undo,
+ redo,
+ clearHistory,
+ getHistory,
}));
return (
diff --git a/src/__tests__/SelectableTextView.test.tsx b/src/__tests__/SelectableTextView.test.tsx
index 91c286d..65ddf58 100644
--- a/src/__tests__/SelectableTextView.test.tsx
+++ b/src/__tests__/SelectableTextView.test.tsx
@@ -3,7 +3,7 @@ import { render, act } from "@testing-library/react-native";
import SelectableTextView from "../SelectableTextView";
import { BridgingNames } from "../types";
-import type { SelectableTextViewRef } from "../types";
+import type { HistoryState, SelectableTextViewRef } from "../types";
import type { SelectableTextViewProps } from "../SelectableTextView";
// Mock react-native-webview: expose the last props (so we can invoke onMessage)
@@ -383,29 +383,132 @@ describe("WebView readiness", () => {
});
});
-describe("highlights state prop", () => {
- it("does not re-post the initial value, which the HTML already carries", () => {
- renderComponent({ highlights: "serialized" });
+describe("initialHighlights", () => {
+ it("does not post the initial value, which the HTML already carries", () => {
+ renderComponent({ initialHighlights: "serialized" });
expect(mockPostMessage).not.toHaveBeenCalled();
});
- it("posts an empty string so the prop can be cleared", () => {
- const ref = React.createRef();
+ it("is read once: a later change is not posted", () => {
const view = render(
-
+
);
fireLoadEnd();
mockPostMessage.mockClear();
view.rerender(
-
+
);
+ expect(mockPostMessage).not.toHaveBeenCalled();
+ });
+});
+
+describe("the highlights history", () => {
+ it("posts a replacement through setHighlights", () => {
+ const ref = renderComponent();
+
+ act(() => ref.current?.setHighlights("payload"));
+
+ expect(lastPosted()).toEqual({
+ type: BridgingNames.functions.updateHighlights,
+ value: "payload",
+ });
+ });
+
+ it("clears with an empty string", () => {
+ const ref = renderComponent();
+
+ act(() => ref.current?.setHighlights(""));
+
expect(lastPosted()).toEqual({
type: BridgingNames.functions.updateHighlights,
value: "",
});
});
+
+ it.each([
+ ["undo", BridgingNames.functions.undo],
+ ["redo", BridgingNames.functions.redo],
+ ["clearHistory", BridgingNames.functions.clearHistory],
+ ])("asks the page to %s", (method, type) => {
+ const ref = renderComponent();
+
+ act(() => (ref.current as any)[method]());
+
+ expect(lastPosted()).toEqual({ type, value: null });
+ });
+
+ it("hands the reported history state to onHistoryChange", async () => {
+ const onHistoryChange = jest.fn();
+ renderComponent({ onHistoryChange });
+
+ await fireMessage(BridgingNames.events.onHistoryChange, {
+ change: "HISTORY",
+ history: ["type:textContent", "type:textContent|1$2$1$yellow$"],
+ historyIndex: 1,
+ length: 2,
+ canUndo: true,
+ canRedo: false,
+ });
+
+ expect(onHistoryChange).toHaveBeenCalledWith({
+ change: "HISTORY",
+ history: ["type:textContent", "type:textContent|1$2$1$yellow$"],
+ historyIndex: 1,
+ length: 2,
+ canUndo: true,
+ canRedo: false,
+ });
+ });
+
+ it("fills in what a malformed history message leaves out", async () => {
+ const onHistoryChange = jest.fn();
+ renderComponent({ onHistoryChange });
+
+ await fireMessage(BridgingNames.events.onHistoryChange, {
+ change: "HISTORY_INDEX",
+ });
+
+ expect(onHistoryChange).toHaveBeenCalledWith({
+ change: "HISTORY_INDEX",
+ history: [],
+ historyIndex: 0,
+ length: 0,
+ canUndo: false,
+ canRedo: false,
+ });
+ });
+
+ it("resolves getHistory with the entries the page reports", async () => {
+ const ref = renderComponent();
+ mockPostMessage.mockClear();
+
+ let pending!: Promise;
+ act(() => {
+ pending = ref.current!.getHistory();
+ });
+ const posted = lastPosted();
+ expect(posted.type).toBe(BridgingNames.promises.getHistory);
+
+ await fireMessage(BridgingNames.promises.getHistory, {
+ // The page answers with the id it was given.
+ promiseId: posted.value,
+ history: ["type:textContent"],
+ historyIndex: 0,
+ length: 1,
+ canUndo: false,
+ canRedo: true,
+ });
+
+ await expect(pending).resolves.toEqual({
+ history: ["type:textContent"],
+ historyIndex: 0,
+ length: 1,
+ canUndo: false,
+ canRedo: true,
+ });
+ });
});
describe("malformed bridge messages", () => {
@@ -579,68 +682,6 @@ describe("selection handling", () => {
});
});
-describe("highlights prop echo suppression", () => {
- function renderRerenderable(props: Partial = {}) {
- const view = render();
- fireLoadEnd();
- return (next: Partial) =>
- view.rerender(
-
- );
- }
-
- const updateCalls = () =>
- mockPostMessage.mock.calls
- .map((call) => JSON.parse(call[0]))
- .filter(
- (message) => message.type === BridgingNames.functions.updateHighlights
- );
-
- it("ignores a value the view itself just reported", async () => {
- const rerender = renderRerenderable({ highlights: "A" });
- await fireMessage(BridgingNames.events.onHighlightsChange, {
- highlights: "B",
- items: [],
- });
- mockPostMessage.mockClear();
-
- // What a controlled consumer does: store the emitted payload, pass it back.
- rerender({ highlights: "B" });
-
- expect(updateCalls()).toHaveLength(0);
- });
-
- it("still restores a payload the view did not emit", async () => {
- const rerender = renderRerenderable({ highlights: "A" });
- await fireMessage(BridgingNames.events.onHighlightsChange, {
- highlights: "B",
- items: [],
- });
- mockPostMessage.mockClear();
-
- rerender({ highlights: "C" });
-
- expect(updateCalls()).toEqual([
- { type: BridgingNames.functions.updateHighlights, value: "C" },
- ]);
- });
-
- it("still clears when reset to an empty string", async () => {
- const rerender = renderRerenderable({ highlights: "A" });
- await fireMessage(BridgingNames.events.onHighlightsChange, {
- highlights: "B",
- items: [],
- });
- mockPostMessage.mockClear();
-
- rerender({ highlights: "" });
-
- expect(updateCalls()).toEqual([
- { type: BridgingNames.functions.updateHighlights, value: "" },
- ]);
- });
-});
-
describe("bridging custom actions", () => {
beforeEach(() => jest.useFakeTimers());
afterEach(() => {
diff --git a/src/types.ts b/src/types.ts
index 488cd01..d0bdff8 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -297,6 +297,40 @@ export interface SelectableTextViewError extends Error {
code: SelectableTextViewErrorCode;
}
+/**
+ * What moved: a new entry was recorded (`HISTORY`), or the view stepped through
+ * the entries it already had (`HISTORY_INDEX`, from `undo()` or `redo()`).
+ */
+export type HistoryChange = "HISTORY" | "HISTORY_INDEX";
+
+/**
+ * Where the undo history stands, reported on every change. It carries the whole
+ * state — the entries included — so a consumer that mirrors the history does
+ * not have to ask for it; {@link SelectableTextViewRef.getHistory} is for
+ * reading it outside of a change.
+ *
+ * The entries are whole serialized payloads, which is why the history stops at
+ * {@link HISTORY_LIMIT}: it bounds both the memory and this message.
+ */
+export type HistoryChangeEvent = HistoryState & {
+ change: HistoryChange;
+};
+
+/** The whole history, as {@link SelectableTextViewRef.getHistory} returns it. */
+export interface HistoryState {
+ history: Highlights[];
+ historyIndex: number;
+ /**
+ * How many entries the history holds, the state the view mounted with
+ * included. It stops growing at {@link HISTORY_LIMIT}, dropping the oldest.
+ */
+ length: number;
+ /** Whether {@link SelectableTextViewRef.undo} would do anything. */
+ canUndo: boolean;
+ /** Whether {@link SelectableTextViewRef.redo} would do anything. */
+ canRedo: boolean;
+}
+
export type SelectableTextViewRef = {
/**
* A function that applies highlighting to the current selection with a highlighter name previously defined in the highlighters property;
@@ -333,6 +367,51 @@ export type SelectableTextViewRef = {
* A function that removes all the highlights
*/
clearHighlights: () => void;
+ /**
+ * Replaces every highlight in the content with the ones a serialized payload
+ * describes — the way to restore after mount, where `initialHighlights` no
+ * longer applies.
+ *
+ * An empty string clears them, which is what `clearHighlights()` does. A
+ * payload that does not belong to this content is reported through `onError`
+ * with `invalid_highlight` rather than throwing.
+ * @param highlights A payload from `getHighlights()` or `onHighlightsChange`.
+ * @throws js Error
+ */
+ setHighlights: (highlights: Highlights) => void;
+ /**
+ * Steps back to the previous set of highlights. Does nothing when there is
+ * nothing to go back to — {@link SelectableTextViewProps.onHistoryChange}
+ * reports when that is the case, so a button can disable itself.
+ *
+ * The history records content changes only: highlighting, unhighlighting,
+ * clearing and `setHighlights`. Showing, hiding and focusing are left out,
+ * since an undo that un-hid highlights would be a surprise.
+ *
+ */
+ undo: () => void;
+ /**
+ * Steps forward again after an {@link SelectableTextViewRef.undo}. Making a
+ * new change instead drops whatever was ahead, as editors do.
+ */
+ redo: () => void;
+ /**
+ * Forgets every recorded state and starts the history again from what the
+ * content holds right now, the way it stood when the view mounted: nothing
+ * to undo, nothing to redo, and the highlights left exactly as they are.
+ *
+ * For the moments after which going back makes no sense — the highlights
+ * were just saved, or a screen handed the view a different set to work on.
+ */
+ clearHistory: () => void;
+ /**
+ * Reads the undo history itself — every payload it holds and which one the
+ * content is on. {@link SelectableTextViewProps.onHistoryChange} already
+ * reports whether a step exists, so this is for the callers that want the
+ * entries: a history panel, a diff, a save of the whole session.
+ * @throws js Error
+ */
+ getHistory: () => Promise;
/**
* A promise that returns the selected text
* @throws js Error
@@ -453,16 +532,16 @@ export type SelectableTextViewPropsBase = {
*/
highlighters?: Highlighter[];
/**
- * --> State property
- * A serialized string that represents the current highlights in the content. This can be used to restore the highlights when the component is re-rendered, for example when the user navigates away from the screen and then comes back.
- * You can obtain this string from getHighlights method or onHighlightsChange event.
- * You can also use as a state, the content will be re-rendered with the highlights applied whenever this string changes.
+ * --> Final property
+ * The serialized highlights to paint on mount — the string a previous session
+ * stored, from `getHighlights()` or `onHighlightsChange`.
*
- * A value the view itself just emitted through `onHighlightsChange` is ignored, so
- * storing that value in state and passing it straight back is safe and will not
- * replay the highlights.
+ * It is read once, when the content is built. Changing it afterwards does
+ * nothing: use {@link SelectableTextViewRef.setHighlights} to replace the
+ * highlights of a mounted view, and remount the component to start from a
+ * different document.
*/
- highlights?: Highlights;
+ initialHighlights?: Highlights;
/**
* --> Final property
* A html string that will be rendered in the WebView.
@@ -558,6 +637,14 @@ export type SelectableTextViewPropsBase = {
* @param message The `type` and `data` the page sent.
*/
onCustomMessage?: (message: CustomMessage) => void;
+
+ /**
+ * Called when the undo history moves, so the controls that drive it can
+ * enable and disable themselves without keeping their own copy of it.
+ *
+ * Fires on every recorded change and on each `undo()` / `redo()`.
+ */
+ onHistoryChange?: (event: HistoryChangeEvent) => void;
};
export type Message = {
@@ -575,6 +662,9 @@ export const BridgingNames = {
focusHighlight: "focusHighlight",
unfocusHighlight: "unfocusHighlight",
unhighlightById: "unhighlightById",
+ redo: "redo",
+ undo: "undo",
+ clearHistory: "clearHistory",
},
// out
events: {
@@ -584,6 +674,7 @@ export const BridgingNames = {
onHighlightPressed: "onHighlightPressed",
onHighlightsVisibilityStateChange: "onHighlightsVisibilityStateChange",
onCustomMessage: "onCustomMessage",
+ onHistoryChange: "onHistoryChange",
// dev
log: "log",
},
@@ -595,7 +686,15 @@ export const BridgingNames = {
getHighlightsVisibilityState: "getHighlightsVisibilityState",
toggleHighlightsVisibility: "toggleHighlightsVisibility",
evaluateJavaScript: "evaluateJavaScript",
+ getHistory: "getHistory",
},
};
-export const VERSION = "1.1.0";
+/**
+ * How many states the undo history keeps. Every entry is a whole serialized
+ * payload, and a reader can produce a great many in one sitting, so the oldest
+ * are dropped rather than held forever.
+ */
+export const HISTORY_LIMIT = 50;
+
+export const VERSION = "1.2.0";
diff --git a/src/utils.ts b/src/utils.ts
index 00c508c..dc9c0c3 100644
--- a/src/utils.ts
+++ b/src/utils.ts
@@ -6,7 +6,7 @@ import {
serializer,
textRange,
} from "./rangy@1.3.2";
-import { BridgingNames } from "./types";
+import { BridgingNames, HISTORY_LIMIT } from "./types";
import type {
Highlighter,
AnimationOptions,
@@ -573,6 +573,8 @@ export const htmlContent = ({
window.__MNST__ = {
// state
state: {
+ history: [],
+ historyIndex: -1,
focusedElements: [],
visible: true,
// id -> timer, for removals waiting on an exit animation.
@@ -621,7 +623,7 @@ export const htmlContent = ({
// @native-receiver
function onMessage(type, value) {
if (type === BridgingNames.functions.updateHighlights) {
- updateHighlights(value); // highlights
+ updateHighlights(value, false); // highlights
} else if (type === BridgingNames.functions.highlightSelection) {
// { name, keepSelection, expectSelectionVersion }
highlightSelection(
@@ -651,6 +653,14 @@ export const htmlContent = ({
toggleHighlightsVisibility(value); // promiseId
} else if (type === BridgingNames.promises.evaluateJavaScript) {
evaluateJavaScript(value); // { promiseId, script }
+ } else if (type === BridgingNames.promises.getHistory) {
+ sendGetHistory(value);
+ } else if (type === BridgingNames.functions.redo) {
+ redo();
+ } else if (type === BridgingNames.functions.undo) {
+ undo();
+ } else if (type === BridgingNames.functions.clearHistory) {
+ clearHistory();
} else {
sendOnError(
"bridge_message_error",
@@ -683,8 +693,84 @@ export const htmlContent = ({
}));
}
+ // @sdk-internal
+ function pushHistory(highlights) {
+ // Anything the reader had stepped back from is dropped, the way typing
+ // after an undo does in an editor.
+ const kept = __MNST__.state.history.slice(0, __MNST__.state.historyIndex + 1);
+ kept.push(highlights);
+ // Bounded: every entry is a whole serialized payload, so a long reading
+ // session would otherwise grow one forever.
+ if (kept.length > ${HISTORY_LIMIT}) {
+ kept.shift();
+ }
+ __MNST__.state.history = kept;
+ __MNST__.state.historyIndex = kept.length - 1;
+ sendOnHistoryChange("HISTORY");
+ }
+
+ // @sdk-internal
+ function clearHistory() {
+ // Starts again from what is on screen, so undo has a floor to stop at —
+ // the same state a freshly mounted view starts with.
+ let current = "";
+ try {
+ current = __MNST__.highlighter.serialize();
+ } catch (e) {
+ current = "";
+ }
+ seedHistory(current);
+ }
+
+ // @sdk-internal
+ function seedHistory(highlights) {
+ __MNST__.state.history = [highlights];
+ __MNST__.state.historyIndex = 0;
+ sendOnHistoryChange("HISTORY");
+ }
+
+ // @sdk-internal
+ function redo() {
+ if (__MNST__.state.historyIndex < __MNST__.state.history.length - 1) {
+ __MNST__.state.historyIndex = __MNST__.state.historyIndex + 1;
+ sendOnHistoryChange("HISTORY_INDEX");
+ const highlights = __MNST__.state.history[__MNST__.state.historyIndex];
+ updateHighlights(highlights, true);
+ }
+ }
+
+ // @sdk-internal
+ function undo() {
+ if (__MNST__.state.historyIndex > 0) {
+ __MNST__.state.historyIndex = __MNST__.state.historyIndex - 1;
+ sendOnHistoryChange("HISTORY_INDEX");
+ const highlights = __MNST__.state.history[__MNST__.state.historyIndex];
+ updateHighlights(highlights, true);
+ }
+ }
+
+ // @sdk-internal
+ function historyState() {
+ // One shape for both the event and getHistory, so the two can never
+ // disagree about where the history stands.
+ return {
+ history: __MNST__.state.history,
+ historyIndex: __MNST__.state.historyIndex,
+ length: __MNST__.state.history.length,
+ canUndo: __MNST__.state.historyIndex > 0,
+ canRedo: __MNST__.state.historyIndex < __MNST__.state.history.length - 1,
+ };
+ }
+
// @native-event
- function sendOnHighlightChange(highlights) {
+ function sendOnHistoryChange(change) {
+ const state = historyState();
+ state.change = change;
+ postMessage(BridgingNames.events.onHistoryChange, state);
+ }
+
+ // @native-event
+ function sendOnHighlightChange(highlights, ignoreHistory) {
// The items are already in memory at this point, so they ride along and
// save the consumer a getAllHighlightsData() round-trip per change.
let items = [];
@@ -697,6 +783,8 @@ export const htmlContent = ({
highlights: highlights,
items: items,
});
+ if (ignoreHistory) return;
+ pushHistory(highlights);
}
// @native-event
@@ -786,6 +874,13 @@ export const htmlContent = ({
postMessage(BridgingNames.events.onHighlightsVisibilityStateChange, visibilityState);
}
+ // @native-promise-resolve
+ function sendGetHistory(promiseId) {
+ const state = historyState();
+ state.promiseId = promiseId;
+ postMessage(BridgingNames.promises.getHistory, state);
+ }
+
// @native-promise-resolve
function sendGetSelectedText(promiseId, success, text, error) {
postMessage(BridgingNames.promises.getSelectedText, {
@@ -889,13 +984,33 @@ export const htmlContent = ({
// <------------------------ Internal functions ------------------------------->
// @sdk-internal-with-event
- function updateHighlights(highlights) {
- // \`highlights\` is a state prop: null/undefined means "leave as is",
- // while an empty string means "clear everything". Treating "" as a
- // no-op would make the prop impossible to reset.
+ function updateHighlights(highlights, fromHistory) {
+ // \`null\`/\`undefined\` means "leave as is", while an empty string means
+ // "clear everything". Treating "" as a no-op would make the highlights
+ // impossible to reset.
if (highlights == null) {
return;
}
+ // Refused before anything is touched: a payload that is not one cannot
+ // cost the reader the highlights they already have.
+ // Rangy looks at the same first segment, and throws when it is missing.
+ const payloadType = String(highlights).split("|")[0];
+ if (highlights && !/^type:[A-Za-z0-9_]+$/.test(payloadType)) {
+ sendOnError(
+ "invalid_highlight",
+ "Failed to restore highlights",
+ "Not a serialized highlights payload: " + String(highlights).slice(0, 60)
+ );
+ return;
+ }
+ // What the content holds right now, to put back if the payload turns
+ // out to be unusable halfway through deserializing it.
+ let previous = null;
+ try {
+ previous = __MNST__.highlighter.serialize();
+ } catch (e) {
+ previous = null;
+ }
try {
clearHighlightFocusStyle();
flushPendingExitClasses();
@@ -904,12 +1019,19 @@ export const htmlContent = ({
__MNST__.highlighter.deserialize(highlights);
}
reconcileIgnoredElements();
- // Every highlight here is new — the old ones were just removed — so
- // restored highlights play their entrance once, as they did before.
- markEntering(__MNST__.highlighter.highlights || []);
+ if (!fromHistory) {
+ // Every highlight here is new — the old ones were just removed — so
+ // restored highlights play their entrance once, as they did before.
+ //
+ // Stepping through the history is the exception: an undo puts back
+ // a state the reader has already seen, and replaying the entrance
+ // would announce it as something that just happened.
+ markEntering(__MNST__.highlighter.highlights || []);
+ }
applyHighlightVisibilityClass(false, true);
- sendOnHighlightChange(__MNST__.highlighter.serialize());
+ sendOnHighlightChange(__MNST__.highlighter.serialize(), fromHistory);
} catch (e) {
+ rollbackHighlights(previous);
sendOnError(
"invalid_highlight",
"Failed to restore highlights",
@@ -918,6 +1040,28 @@ export const htmlContent = ({
}
}
+ // @sdk-internal
+ function rollbackHighlights(previous) {
+ // Deserializing can fail partway, leaving some of the payload applied,
+ // so the content is put back rather than left in between. No change
+ // event: as far as the consumer is concerned nothing happened, and the
+ // history must not record a state the reader never saw.
+ try {
+ __MNST__.highlighter.removeAllHighlights();
+ if (previous) {
+ __MNST__.highlighter.deserialize(previous);
+ }
+ reconcileIgnoredElements();
+ applyHighlightVisibilityClass(false, true);
+ } catch (e) {
+ sendOnError(
+ "invalid_highlight",
+ "Failed to restore the previous highlights",
+ e?.message ?? String(e)
+ );
+ }
+ }
+
// @sdk-internal-with-event
function highlightSelection(classApplierName, keepSelection, expectVersion) {
try {
@@ -1578,7 +1722,14 @@ export const htmlContent = ({
// null (not "") so that mounting without highlights stays a no-op —
// an empty string means "clear", which would emit a spurious change event.
- updateHighlights(${toScriptLiteral(highlights ?? null)});
+ updateHighlights(${toScriptLiteral(highlights ?? null)}, false);
+
+ // The state the reader opens with is the floor of the history, so undo
+ // stops here instead of stepping into a set that never existed. Mounting
+ // with highlights already recorded it, through the change it emitted.
+ if (__MNST__.state.history.length === 0) {
+ seedHistory(__MNST__.highlighter.serialize());
+ }
if (__MNST__.platform.isAndroid) {
document.addEventListener("message", function (event) {