From c37634b70ea5e4830131540ecc02d287e6d00323 Mon Sep 17 00:00:00 2001 From: JoshuaPariona Date: Thu, 24 Sep 2026 01:39:30 -0500 Subject: [PATCH 01/10] feat: add the undo history to the webview runtime --- src/SelectableTextView.tsx | 82 ++++++++++++++++++++++++-------------- src/types.ts | 73 +++++++++++++++++++++++++++++---- src/utils.ts | 70 +++++++++++++++++++++++++++++--- 3 files changed, 180 insertions(+), 45 deletions(-) diff --git a/src/SelectableTextView.tsx b/src/SelectableTextView.tsx index baad643..e604ad2 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"; @@ -41,7 +43,7 @@ const SelectableTextView = React.forwardRef< >((props, ref) => { const { highlighters, - highlights, + initialHighlights, content, css, fonts, @@ -54,6 +56,7 @@ const SelectableTextView = React.forwardRef< onHighlightPressed, onHighlightsVisibilityStateChange, onCustomMessage, + onHistoryChange, webViewProps, } = props; const promises = React.useRef>({}); @@ -75,12 +78,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 +92,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 +122,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 +253,24 @@ const SelectableTextView = React.forwardRef< if (typeof message?.type === "string") { onCustomMessage?.({ type: message.type, data: message.data }); } + } else if (data.type === BridgingNames.events.onHistoryChange) { + const history = (data.value.history ?? []) as Highlights[]; + const historyIndex = Number(data.value.historyIndex); + const change = data.value.change as HistoryChange; + onHistoryChange?.({ + history, + historyIndex, + change, + }); + } else if (data.type === BridgingNames.promises.getHistory) { + const id = data.value.promiseId; + const history = data.value.history; + const historyIndex = data.value.historyIndex; + promises.current[id]?.resolve({ + history, + historyIndex, + }); + settlePromise(id); } } catch (error) { // A consumer callback threw or rejected. This handler is async, so @@ -301,26 +313,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 +449,30 @@ 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 getHistory = () => + _request(BridgingNames.promises.getHistory); + const evaluateJavaScript = ( script: string, options?: EvaluateJavaScriptOptions @@ -508,6 +524,10 @@ const SelectableTextView = React.forwardRef< getHighlightsVisibilityState, toggleHighlightsVisibility, evaluateJavaScript, + setHighlights, + undo, + redo, + getHistory, })); return ( diff --git a/src/types.ts b/src/types.ts index 488cd01..a79e271 100644 --- a/src/types.ts +++ b/src/types.ts @@ -297,6 +297,17 @@ export interface SelectableTextViewError extends Error { code: SelectableTextViewErrorCode; } +export type HistoryChange = "HISTORY" | "HISTORY_INDEX"; + +export type HistoryChangeEvent = HistoryState & { + change: HistoryChange; +}; + +export interface HistoryState { + history: Highlights[]; + historyIndex: number; +} + export type SelectableTextViewRef = { /** * A function that applies highlighting to the current selection with a highlighter name previously defined in the highlighters property; @@ -333,6 +344,38 @@ 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; + /** + * + */ + getHistory: () => Promise; /** * A promise that returns the selected text * @throws js Error @@ -453,16 +496,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 +601,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 +626,8 @@ export const BridgingNames = { focusHighlight: "focusHighlight", unfocusHighlight: "unfocusHighlight", unhighlightById: "unhighlightById", + redo: "redo", + undo: "undo", }, // out events: { @@ -584,6 +637,7 @@ export const BridgingNames = { onHighlightPressed: "onHighlightPressed", onHighlightsVisibilityStateChange: "onHighlightsVisibilityStateChange", onCustomMessage: "onCustomMessage", + onHistoryChange: "onHistoryChange", // dev log: "log", }, @@ -595,7 +649,10 @@ export const BridgingNames = { getHighlightsVisibilityState: "getHighlightsVisibilityState", toggleHighlightsVisibility: "toggleHighlightsVisibility", evaluateJavaScript: "evaluateJavaScript", + getHistory: "getHistory", }, }; export const VERSION = "1.1.0"; + +export const INITIAL_HIGHLIGHTS = "type:textContent"; diff --git a/src/utils.ts b/src/utils.ts index 00c508c..dda86a5 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, INITIAL_HIGHLIGHTS } 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,12 @@ 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 { sendOnError( "bridge_message_error", @@ -683,8 +691,47 @@ export const htmlContent = ({ })); } + // @sdk-internal + function pushHistory(highlights) { + __MNST__.state.history = [ + ...__MNST__.state.history.slice(0, __MNST__.state.historyIndex + 1), + highlights + ]; + __MNST__.state.historyIndex = __MNST__.state.history.length - 1; + sendOnHistoryChange("HISTORY", __MNST__.state.history, __MNST__.state.historyIndex); + } + + // @sdk-internal + function redo() { + if (__MNST__.state.historyIndex < __MNST__.state.history.length - 1) { + __MNST__.state.historyIndex = __MNST__.state.historyIndex + 1; + sendOnHistoryChange("HISTORY_INDEX", __MNST__.state.history, __MNST__.state.historyIndex); + 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", __MNST__.state.history, __MNST__.state.historyIndex); + const highlights = __MNST__.state.history[__MNST__.state.historyIndex]; + updateHighlights(highlights, true); + } + } + + // @native-event + function sendOnHistoryChange(change, history, historyIndex) { + postMessage(BridgingNames.events.onHistoryChange, { + history, + historyIndex, + change, + }); + } + // @native-event - function sendOnHighlightChange(highlights) { + 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 +744,8 @@ export const htmlContent = ({ highlights: highlights, items: items, }); + if (ignoreHistory) return + pushHistory(highlights); } // @native-event @@ -786,6 +835,15 @@ export const htmlContent = ({ postMessage(BridgingNames.events.onHighlightsVisibilityStateChange, visibilityState); } + // @native-promise-resolve + function sendGetHistory(promiseId) { + postMessage(BridgingNames.promises.getHistory, { + promiseId, + history: __MNST__.state.history, + historyIndex: __MNST__.state.historyIndex , + }); + } + // @native-promise-resolve function sendGetSelectedText(promiseId, success, text, error) { postMessage(BridgingNames.promises.getSelectedText, { @@ -889,7 +947,7 @@ export const htmlContent = ({ // <------------------------ Internal functions -------------------------------> // @sdk-internal-with-event - function updateHighlights(highlights) { + function updateHighlights(highlights, ignoreHistory) { // \`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. @@ -908,7 +966,7 @@ export const htmlContent = ({ // restored highlights play their entrance once, as they did before. markEntering(__MNST__.highlighter.highlights || []); applyHighlightVisibilityClass(false, true); - sendOnHighlightChange(__MNST__.highlighter.serialize()); + sendOnHighlightChange( __MNST__.highlighter.serialize(), ignoreHistory); } catch (e) { sendOnError( "invalid_highlight", @@ -1578,7 +1636,7 @@ 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 ?? INITIAL_HIGHLIGHTS)}, false); if (__MNST__.platform.isAndroid) { document.addEventListener("message", function (event) { From 8b0ed0a2d7fc5ee9790b34c5d225582d5f746116 Mon Sep 17 00:00:00 2001 From: JoshuaPariona Date: Thu, 24 Sep 2026 01:41:19 -0500 Subject: [PATCH 02/10] refactor: bound the history and slim its change event --- src/SelectableTextView.tsx | 11 ++++--- src/types.ts | 38 +++++++++++++++++++++--- src/utils.ts | 61 +++++++++++++++++++++++++++----------- 3 files changed, 82 insertions(+), 28 deletions(-) diff --git a/src/SelectableTextView.tsx b/src/SelectableTextView.tsx index e604ad2..99366c0 100644 --- a/src/SelectableTextView.tsx +++ b/src/SelectableTextView.tsx @@ -254,13 +254,12 @@ const SelectableTextView = React.forwardRef< onCustomMessage?.({ type: message.type, data: message.data }); } } else if (data.type === BridgingNames.events.onHistoryChange) { - const history = (data.value.history ?? []) as Highlights[]; - const historyIndex = Number(data.value.historyIndex); - const change = data.value.change as HistoryChange; onHistoryChange?.({ - history, - historyIndex, - change, + change: data.value.change as HistoryChange, + historyIndex: Number(data.value.historyIndex), + length: Number(data.value.length), + canUndo: Boolean(data.value.canUndo), + canRedo: Boolean(data.value.canRedo), }); } else if (data.type === BridgingNames.promises.getHistory) { const id = data.value.promiseId; diff --git a/src/types.ts b/src/types.ts index a79e271..5c3a8ae 100644 --- a/src/types.ts +++ b/src/types.ts @@ -297,12 +297,33 @@ 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"; -export type HistoryChangeEvent = HistoryState & { +/** + * Where the undo history stands. The entries themselves are left out on + * purpose: this fires on every change, and each one is a whole serialized + * payload — call {@link SelectableTextViewRef.getHistory} when they are needed. + */ +export type HistoryChangeEvent = { change: HistoryChange; + /** Which entry the content is sitting on, counting from 0. */ + 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; }; +/** The whole history, as {@link SelectableTextViewRef.getHistory} returns it. */ export interface HistoryState { history: Highlights[]; historyIndex: number; @@ -373,7 +394,11 @@ export type SelectableTextViewRef = { */ redo: () => 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; /** @@ -653,6 +678,11 @@ export const BridgingNames = { }, }; -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 INITIAL_HIGHLIGHTS = "type:textContent"; +export const VERSION = "1.1.0"; diff --git a/src/utils.ts b/src/utils.ts index dda86a5..da952fa 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -6,7 +6,7 @@ import { serializer, textRange, } from "./rangy@1.3.2"; -import { BridgingNames, INITIAL_HIGHLIGHTS } from "./types"; +import { BridgingNames, HISTORY_LIMIT } from "./types"; import type { Highlighter, AnimationOptions, @@ -623,7 +623,7 @@ export const htmlContent = ({ // @native-receiver function onMessage(type, value) { if (type === BridgingNames.functions.updateHighlights) { - updateHighlights(value,false); // highlights + updateHighlights(value, false); // highlights } else if (type === BridgingNames.functions.highlightSelection) { // { name, keepSelection, expectSelectionVersion } highlightSelection( @@ -654,11 +654,11 @@ export const htmlContent = ({ } else if (type === BridgingNames.promises.evaluateJavaScript) { evaluateJavaScript(value); // { promiseId, script } } else if (type === BridgingNames.promises.getHistory) { - sendGetHistory(value) + sendGetHistory(value); } else if (type === BridgingNames.functions.redo) { redo(); } else if (type === BridgingNames.functions.undo) { - undo() + undo(); } else { sendOnError( "bridge_message_error", @@ -693,19 +693,32 @@ export const htmlContent = ({ // @sdk-internal function pushHistory(highlights) { - __MNST__.state.history = [ - ...__MNST__.state.history.slice(0, __MNST__.state.historyIndex + 1), - highlights - ]; - __MNST__.state.historyIndex = __MNST__.state.history.length - 1; - sendOnHistoryChange("HISTORY", __MNST__.state.history, __MNST__.state.historyIndex); + // 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 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", __MNST__.state.history, __MNST__.state.historyIndex); + sendOnHistoryChange("HISTORY_INDEX"); const highlights = __MNST__.state.history[__MNST__.state.historyIndex]; updateHighlights(highlights, true); } @@ -715,18 +728,23 @@ export const htmlContent = ({ function undo() { if (__MNST__.state.historyIndex > 0) { __MNST__.state.historyIndex = __MNST__.state.historyIndex - 1; - sendOnHistoryChange("HISTORY_INDEX", __MNST__.state.history, __MNST__.state.historyIndex); + sendOnHistoryChange("HISTORY_INDEX"); const highlights = __MNST__.state.history[__MNST__.state.historyIndex]; updateHighlights(highlights, true); } } // @native-event - function sendOnHistoryChange(change, history, historyIndex) { + function sendOnHistoryChange(change) { + // Where the history stands, never the payloads themselves. This fires on + // every highlight, and each entry can be kilobytes; getHistory() is + // there for the rare caller that wants the entries. postMessage(BridgingNames.events.onHistoryChange, { - history, - historyIndex, change, + historyIndex: __MNST__.state.historyIndex, + length: __MNST__.state.history.length, + canUndo: __MNST__.state.historyIndex > 0, + canRedo: __MNST__.state.historyIndex < __MNST__.state.history.length - 1, }); } @@ -744,7 +762,7 @@ export const htmlContent = ({ highlights: highlights, items: items, }); - if (ignoreHistory) return + if (ignoreHistory) return; pushHistory(highlights); } @@ -840,7 +858,7 @@ export const htmlContent = ({ postMessage(BridgingNames.promises.getHistory, { promiseId, history: __MNST__.state.history, - historyIndex: __MNST__.state.historyIndex , + historyIndex: __MNST__.state.historyIndex, }); } @@ -1636,7 +1654,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 ?? INITIAL_HIGHLIGHTS)}, false); + 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) { From c36f7208ffe40bf11c97b0676a5bf74d6531fa5c Mon Sep 17 00:00:00 2001 From: JoshuaPariona Date: Thu, 24 Sep 2026 02:09:09 -0500 Subject: [PATCH 03/10] refactor: share one history shape between the event and getHistory --- src/SelectableTextView.tsx | 27 +++++++++++++++++---------- src/types.ts | 24 +++++++++++++----------- src/utils.ts | 30 +++++++++++++++++------------- 3 files changed, 47 insertions(+), 34 deletions(-) diff --git a/src/SelectableTextView.tsx b/src/SelectableTextView.tsx index 99366c0..0e2cb82 100644 --- a/src/SelectableTextView.tsx +++ b/src/SelectableTextView.tsx @@ -37,6 +37,21 @@ 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 @@ -255,20 +270,12 @@ const SelectableTextView = React.forwardRef< } } else if (data.type === BridgingNames.events.onHistoryChange) { onHistoryChange?.({ + ...readHistoryState(data.value), change: data.value.change as HistoryChange, - historyIndex: Number(data.value.historyIndex), - length: Number(data.value.length), - canUndo: Boolean(data.value.canUndo), - canRedo: Boolean(data.value.canRedo), }); } else if (data.type === BridgingNames.promises.getHistory) { const id = data.value.promiseId; - const history = data.value.history; - const historyIndex = data.value.historyIndex; - promises.current[id]?.resolve({ - history, - historyIndex, - }); + promises.current[id]?.resolve(readHistoryState(data.value)); settlePromise(id); } } catch (error) { diff --git a/src/types.ts b/src/types.ts index 5c3a8ae..fddac7b 100644 --- a/src/types.ts +++ b/src/types.ts @@ -304,13 +304,21 @@ export interface SelectableTextViewError extends Error { export type HistoryChange = "HISTORY" | "HISTORY_INDEX"; /** - * Where the undo history stands. The entries themselves are left out on - * purpose: this fires on every change, and each one is a whole serialized - * payload — call {@link SelectableTextViewRef.getHistory} when they are needed. + * 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 = { +export type HistoryChangeEvent = HistoryState & { change: HistoryChange; - /** Which entry the content is sitting on, counting from 0. */ +}; + +/** 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 @@ -321,12 +329,6 @@ export type HistoryChangeEvent = { canUndo: boolean; /** Whether {@link SelectableTextViewRef.redo} would do anything. */ canRedo: boolean; -}; - -/** The whole history, as {@link SelectableTextViewRef.getHistory} returns it. */ -export interface HistoryState { - history: Highlights[]; - historyIndex: number; } export type SelectableTextViewRef = { diff --git a/src/utils.ts b/src/utils.ts index da952fa..7598025 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -734,18 +734,24 @@ export const htmlContent = ({ } } - // @native-event - function sendOnHistoryChange(change) { - // Where the history stands, never the payloads themselves. This fires on - // every highlight, and each entry can be kilobytes; getHistory() is - // there for the rare caller that wants the entries. - postMessage(BridgingNames.events.onHistoryChange, { - change, + // @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 sendOnHistoryChange(change) { + const state = historyState(); + state.change = change; + postMessage(BridgingNames.events.onHistoryChange, state); } // @native-event @@ -855,11 +861,9 @@ export const htmlContent = ({ // @native-promise-resolve function sendGetHistory(promiseId) { - postMessage(BridgingNames.promises.getHistory, { - promiseId, - history: __MNST__.state.history, - historyIndex: __MNST__.state.historyIndex, - }); + const state = historyState(); + state.promiseId = promiseId; + postMessage(BridgingNames.promises.getHistory, state); } // @native-promise-resolve From 3431dd6525fce80da7cc792e426fe86aae7a5810 Mon Sep 17 00:00:00 2001 From: JoshuaPariona Date: Thu, 24 Sep 2026 02:16:10 -0500 Subject: [PATCH 04/10] fix: refuse an invalid payload without dropping the highlights --- src/utils.ts | 51 +++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 47 insertions(+), 4 deletions(-) diff --git a/src/utils.ts b/src/utils.ts index 7598025..010f153 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -970,12 +970,32 @@ export const htmlContent = ({ // @sdk-internal-with-event function updateHighlights(highlights, ignoreHistory) { - // \`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. + // \`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(); @@ -988,8 +1008,9 @@ export const htmlContent = ({ // restored highlights play their entrance once, as they did before. markEntering(__MNST__.highlighter.highlights || []); applyHighlightVisibilityClass(false, true); - sendOnHighlightChange( __MNST__.highlighter.serialize(), ignoreHistory); + sendOnHighlightChange(__MNST__.highlighter.serialize(), ignoreHistory); } catch (e) { + rollbackHighlights(previous); sendOnError( "invalid_highlight", "Failed to restore highlights", @@ -998,6 +1019,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 { From 24cedbce76794cf20fc09d47bb93f6f83d39bc45 Mon Sep 17 00:00:00 2001 From: JoshuaPariona Date: Thu, 24 Sep 2026 02:24:10 -0500 Subject: [PATCH 05/10] feat: add clearHistory --- src/SelectableTextView.tsx | 8 ++++++++ src/types.ts | 10 ++++++++++ src/utils.ts | 31 ++++++++++++++++++++++++++----- 3 files changed, 44 insertions(+), 5 deletions(-) diff --git a/src/SelectableTextView.tsx b/src/SelectableTextView.tsx index 0e2cb82..1eaffe7 100644 --- a/src/SelectableTextView.tsx +++ b/src/SelectableTextView.tsx @@ -476,6 +476,13 @@ const SelectableTextView = React.forwardRef< }); }; + const clearHistory = () => { + _postMessage({ + type: BridgingNames.functions.clearHistory, + value: null, + }); + }; + const getHistory = () => _request(BridgingNames.promises.getHistory); @@ -533,6 +540,7 @@ const SelectableTextView = React.forwardRef< setHighlights, undo, redo, + clearHistory, getHistory, })); diff --git a/src/types.ts b/src/types.ts index fddac7b..dd368b1 100644 --- a/src/types.ts +++ b/src/types.ts @@ -395,6 +395,15 @@ export type SelectableTextViewRef = { * 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 @@ -655,6 +664,7 @@ export const BridgingNames = { unhighlightById: "unhighlightById", redo: "redo", undo: "undo", + clearHistory: "clearHistory", }, // out events: { diff --git a/src/utils.ts b/src/utils.ts index 010f153..dc9c0c3 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -659,6 +659,8 @@ export const htmlContent = ({ redo(); } else if (type === BridgingNames.functions.undo) { undo(); + } else if (type === BridgingNames.functions.clearHistory) { + clearHistory(); } else { sendOnError( "bridge_message_error", @@ -707,6 +709,19 @@ export const htmlContent = ({ 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]; @@ -969,7 +984,7 @@ export const htmlContent = ({ // <------------------------ Internal functions -------------------------------> // @sdk-internal-with-event - function updateHighlights(highlights, ignoreHistory) { + 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. @@ -1004,11 +1019,17 @@ 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(), ignoreHistory); + sendOnHighlightChange(__MNST__.highlighter.serialize(), fromHistory); } catch (e) { rollbackHighlights(previous); sendOnError( From 1c40d573d4dfca83c9f992b0e49c88126a9447d5 Mon Sep 17 00:00:00 2001 From: JoshuaPariona Date: Thu, 24 Sep 2026 02:25:06 -0500 Subject: [PATCH 06/10] docs: exercise undo, redo and the history in the example --- example/src/components/ActionsFab.tsx | 73 ++++++++++++++++++++++++++- example/src/screens/Reader.tsx | 18 ++++++- 2 files changed, 87 insertions(+), 4 deletions(-) diff --git a/example/src/components/ActionsFab.tsx b/example/src/components/ActionsFab.tsx index 1b8fe16..ef73d78 100644 --- a/example/src/components/ActionsFab.tsx +++ b/example/src/components/ActionsFab.tsx @@ -24,6 +24,9 @@ type ActionsFabProps = { /** Reader accent, so the FAB picks up the current document's colour. */ accent?: string; onClearHighlights: () => void; + /** From `onHistoryChange`, so undo and redo dim when there is nowhere to go. */ + canUndo: boolean; + canRedo: boolean; }; type ActionFabIconName = @@ -31,7 +34,9 @@ type ActionFabIconName = | "highlights-data" | "selection" | "toggle-highlights" - | "clear-highlights"; + | "clear-highlights" + | "undo" + | "redo"; type ActionFabItem = { key: string; @@ -39,6 +44,8 @@ type ActionFabItem = { tint: string; icon: ActionFabIconName; onPress: () => void | Promise; + /** Shown, but dimmed and inert — the history says the step is not there. */ + disabled?: boolean; }; export function ActionsFab(props: ActionsFabProps) { @@ -142,6 +149,29 @@ export function ActionsFab(props: ActionsFabProps) { } }, }, + { + key: "undo", + label: "Undo", + tint: theme.color.accentSoft, + icon: "undo", + disabled: !props.canUndo, + onPress: () => props.selectableTextViewRef.current?.undo(), + }, + { + key: "redo", + label: "Redo", + tint: theme.color.accentSoft, + icon: "redo", + disabled: !props.canRedo, + onPress: () => props.selectableTextViewRef.current?.redo(), + }, + { + key: "clear-history", + label: "Clear History", + tint: theme.color.textMuted, + icon: "undo", + onPress: () => props.selectableTextViewRef.current?.clearHistory(), + }, { key: "clear-highlights", label: "Clear Highlights", @@ -168,6 +198,9 @@ export function ActionsFab(props: ActionsFabProps) { }; const runAction = (action: ActionFabItem) => { + if (action.disabled) { + return; + } setExpanded(false); void action.onPress(); }; @@ -229,6 +262,7 @@ export function ActionsFab(props: ActionsFabProps) { runAction(action)} /> void; + disabled?: boolean; }) { const pressScale = useRef(new Animated.Value(1)).current; @@ -295,12 +330,14 @@ function ActionFabButton(props: { return ( animatePress(0.85)} + disabled={props.disabled} + onPressIn={() => animatePress(props.disabled ? 1 : 0.85)} onPressOut={() => animatePress(1)} > @@ -404,6 +441,16 @@ function ActionFabIcon(props: { tint: string; icon: ActionFabIconName }) { ); } + if (props.icon === "undo" || props.icon === "redo") { + const flipped = props.icon === "redo"; + return ( + + + + + ); + } + if (props.icon === "clear-highlights") { return ( @@ -564,6 +611,28 @@ const styles = StyleSheet.create({ highlightsDataLineShort: { flex: 0.6, }, + actionFabButtonDisabled: { opacity: 0.35 }, + historyIcon: { width: 20, height: 18, justifyContent: "flex-end" }, + historyIconFlipped: { transform: [{ scaleX: -1 }] }, + historyIconArc: { + width: 18, + height: 11, + borderWidth: 2, + borderBottomWidth: 0, + borderTopLeftRadius: 9, + borderTopRightRadius: 9, + borderRightWidth: 0, + }, + historyIconHead: { + position: "absolute", + left: 0, + bottom: 0, + width: 8, + height: 8, + borderLeftWidth: 2, + borderBottomWidth: 2, + transform: [{ rotate: "45deg" }], + }, clearHighlightIcon: { width: 18, height: 14, diff --git a/example/src/screens/Reader.tsx b/example/src/screens/Reader.tsx index d21269b..e8553f7 100644 --- a/example/src/screens/Reader.tsx +++ b/example/src/screens/Reader.tsx @@ -46,6 +46,9 @@ export default function Reader(props: { documentId: string }) { const [currentHighlighterName, setCurrentHighlighterName] = useState(highlighters[0].name); + // Straight from `onHistoryChange`: the view owns the history, the UI only + // asks whether the steps exist. + const [history, setHistory] = useState({ canUndo: false, canRedo: false }); // Puts the reader back where it was left; `ready` once that has happened. const scroll = useScrollRestore(doc.id, viewRef); const search = useDocumentSearch(viewRef); @@ -193,7 +196,11 @@ export default function Reader(props: { documentId: string }) { content={doc.content} css={doc.css} fonts={doc.fonts} - highlights={states[doc.id]?.serialized ?? ""} + // Read once, when this document opens. Everything after that goes + // through the ref. + // `undefined`, not `""`: nothing stored means leave the content as it + // is, while an empty string is an instruction to clear it. + initialHighlights={states[doc.id]?.serialized || undefined} highlighterOptions={{ ignoredElements: [ "a", @@ -215,7 +222,12 @@ export default function Reader(props: { documentId: string }) { setSerialized(doc.id, serialized); setItems(doc.id, items); }} - onError={(error) => showToast(error.message)} + onHistoryChange={({ canUndo, canRedo }) => { + setHistory({ canUndo, canRedo }); + }} + // The code is what tells a refused payload from a refused selection, + // so it belongs in the toast while this is a test bed. + onError={(error) => showToast(`${error.code}: ${error.message}`)} onHighlightPressed={(highlight) => { showMenu(highlight); return HIGHLIGHT_FOCUS_CLASS; @@ -248,6 +260,8 @@ export default function Reader(props: { documentId: string }) { { dismiss(); reset(doc.id); From 1da6905e2a8e8d607f8eec4d1c5d5e6b2da4cc88 Mon Sep 17 00:00:00 2001 From: JoshuaPariona Date: Thu, 24 Sep 2026 02:25:28 -0500 Subject: [PATCH 07/10] docs: simplify the snack demo with undo and redo --- snack/App.js | 59 ++++++++++--------------------------- snack/components/Toolbar.js | 18 +++++++---- 2 files changed, 29 insertions(+), 48 deletions(-) diff --git a/snack/App.js b/snack/App.js index a386c8a..d3d455d 100644 --- a/snack/App.js +++ b/snack/App.js @@ -29,8 +29,9 @@ const article = ` 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. + 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({ + From c0b03869b2e9c74499081f4cd24bd7463ff801a8 Mon Sep 17 00:00:00 2001 From: JoshuaPariona Date: Thu, 24 Sep 2026 02:26:45 -0500 Subject: [PATCH 08/10] test: cover setHighlights, the history and getHistory --- src/__tests__/SelectableTextView.test.tsx | 181 +++++++++++++--------- 1 file changed, 111 insertions(+), 70 deletions(-) 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(() => { From fb9560e7ce21c59aa6541c1dcefb462010bbfc12 Mon Sep 17 00:00:00 2001 From: JoshuaPariona Date: Thu, 24 Sep 2026 02:27:41 -0500 Subject: [PATCH 09/10] docs: document the history, setHighlights and initialHighlights --- README.md | 15 ++++-- docs/REFERENCE.md | 123 ++++++++++++++++++++++++++++++++++++---------- 2 files changed, 108 insertions(+), 30 deletions(-) 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="

Hello

Select some text and highlight it.

" css=".content { padding: 16px; font-size: 18px; }" highlighters={highlighters} + // What a previous session stored. Read once, when the view mounts. + initialHighlights={saved} onHighlightsChange={(highlights) => { // Persist this serialized string to restore highlights later. console.log(highlights); @@ -106,12 +108,17 @@ export default function Screen() { ); } -// Highlight the current selection from anywhere with the ref: +// From anywhere, with the ref: // ref.current?.highlightSelection("yellow-highlighter"); +// ref.current?.setHighlights(payload); // replace them; "" clears +// ref.current?.undo(); // and redo(), and clearHistory() ``` -Pass that serialized string back through the `highlights` prop to restore the -highlights on remount. +Store the string `onHighlightsChange` reports, hand it back through +`initialHighlights` on the next mount, and the highlights come back where the +reader left them. On a view that is already on screen, `setHighlights()` +replaces them and `undo()` / `redo()` walk a history the view keeps for you — +see the [API reference](./docs/REFERENCE.md#restoring-replacing-and-undoing). ## Documentation diff --git a/docs/REFERENCE.md b/docs/REFERENCE.md index c9dc690..b7be59f 100644 --- a/docs/REFERENCE.md +++ b/docs/REFERENCE.md @@ -15,22 +15,23 @@ This page is the full surface. For a quick start see the | `css` | Injected styles for layout and typography. Declared after the generated highlighter classes, so your rules win on equal specificity and can restyle or re-animate a highlight. | | `fonts` | WebView font setup via `googleFonts()`, `mergeFonts()`, or custom `preconnect`, `stylesheets`, and `@font-face` rules. Multiple families are supported in a single config. | | `highlighters` | Named highlight classes. A name must be a valid CSS class name — letters, digits, `-` and `_`, not starting with a digit — and invalid names are dropped with a console warning. | -| `highlights` | **State prop.** Serialized highlights to restore. `undefined` leaves the current highlights untouched; an empty string clears them. Obtain the value from `getHighlights()` or `onHighlightsChange`. A value this view just emitted is ignored, so it is safe to control. | +| `initialHighlights` | Serialized highlights painted when the view mounts — the string a previous session stored. Read once; changing it later does nothing (the view warns) and `setHighlights()` is how a mounted view is updated. | | `highlighterOptions` | `ignoredElements` — tags or selectors such as `a`, `sup`, `.ignored`. Ignored nodes skip the visible highlight but stay selectable and copyable. | | `options` | Viewport zoom: `userScalable`, `initialScale`, `maximumScale`. | | `webViewProps` | Pass-through to `react-native-webview`. `javaScriptEnabled`, `source`, and `onShouldStartLoadWithRequest` are owned by the component and cannot be overridden. `menuItems` and `onCustomMenuSelection` pass through untouched — see [Your own selection menu](#your-own-selection-menu). | ## Callbacks -| Callback | Fires when | -| -------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `onTextSelectionChange(text)` | The selection changes. Receives the selected text, or `""` when the selection is cleared. | -| `onHighlightsChange(highlights, items)` | The serialized highlight payload changes. Receives the payload to persist, plus the `HighlightData[]` it contains — no `getAllHighlightsData()` round-trip needed. | -| `onLink(url)` | A link is tapped. Without this prop, `http(s)` URLs open through `Linking`. | -| `onError(error)` | The WebView SDK reports an error: `{ code, message, details }`. Try highlighting over an existing highlight for `overlapping_highlight`, or with no selection for `empty_selection`. | -| `onHighlightPressed(highlight)` | A highlight is tapped. Receives `{ id, name, text, rect, rects }`. Return a `className` to style the pressed highlight (define it in `css`), or return nothing to leave it unstyled. | -| `onHighlightsVisibilityStateChange(visible)` | Highlight visibility changes. Receives `true` when visible, `false` when hidden. | -| `onCustomMessage(message)` | A script in the page calls `window.SelectableText.postMessage(type, data)`. Receives `{ type, data }`. See [Bridging custom actions](#bridging-custom-actions). | +| Callback | Fires when | +| -------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `onTextSelectionChange(text)` | The selection changes. Receives the selected text, or `""` when the selection is cleared. | +| `onHighlightsChange(highlights, items)` | The serialized highlight payload changes. Receives the payload to persist, plus the `HighlightData[]` it contains — no `getAllHighlightsData()` round-trip needed. | +| `onHistoryChange(state)` | The undo history moves. Receives `{ change, history, historyIndex, length, canUndo, canRedo }`, so undo and redo controls can enable themselves without tracking anything — see [Undo and redo](#undo-and-redo). | +| `onLink(url)` | A link is tapped. Without this prop, `http(s)` URLs open through `Linking`. | +| `onError(error)` | The WebView SDK reports an error: `{ code, message, details }`. Try highlighting over an existing highlight for `overlapping_highlight`, or with no selection for `empty_selection`. | +| `onHighlightPressed(highlight)` | A highlight is tapped. Receives `{ id, name, text, rect, rects }`. Return a `className` to style the pressed highlight (define it in `css`), or return nothing to leave it unstyled. | +| `onHighlightsVisibilityStateChange(visible)` | Highlight visibility changes. Receives `true` when visible, `false` when hidden. | +| `onCustomMessage(message)` | A script in the page calls `window.SelectableText.postMessage(type, data)`. Receives `{ type, data }`. See [Bridging custom actions](#bridging-custom-actions). | A callback that throws is caught and logged rather than crashing the bridge, so a bug in your handler will not take the component down with it. @@ -77,28 +78,94 @@ afterwards does not update them, so dismiss or re-anchor whatever you placed: `onTextSelectionChange` and a `webViewProps.onScroll` handler are the usual hooks for that. -### Controlling the highlights +### Restoring, replacing and undoing -`onHighlightsChange` hands you both halves of the state at once, so the usual -wiring is a plain controlled component: +Highlights travel as one serialized string. Persist what `onHighlightsChange` +reports, and hand it back on the next mount: ```tsx -const [highlights, setHighlights] = useState(""); -const [items, setItems] = useState([]); +const [saved, setSaved] = useState(undefined); + +useEffect(() => { + AsyncStorage.getItem("highlights").then((value) => setSaved(value ?? "")); +}, []); + +// Wait for storage: the prop is read once, when the view mounts. +if (saved === undefined) return ; { - setHighlights(serialized); - setItems(list); // ids, names and text — already resolved + initialHighlights={saved} + onHighlightsChange={(serialized, items) => { + AsyncStorage.setItem("highlights", serialized); + setItems(items); // ids, names and text — already resolved }} />; ``` -Passing the emitted value straight back does **not** replay the highlights: the -view remembers the last payload it reported and skips restoring an echo of it. -Restoring is reserved for a payload it did not produce — a value loaded from -storage, a different document, or `""` to clear. +To replace the highlights of a view that is already on screen, call the ref: + +```tsx +ref.current?.setHighlights(payload); // "" clears them +``` + +Those are the two halves on purpose. `initialHighlights` is the document the +reader opens with; `setHighlights` is every change after that. A single prop +doing both is what made restoring unpredictable: it had to guess which values +were genuine and which were the view's own report coming back around. + +#### Undo and redo + +The page keeps the history itself — every content change, in order — and reports +where it stands: + +```tsx +const [history, setHistory] = useState({ canUndo: false, canRedo: false }); + +; + +