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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions android/src/main/cpp/MarkdownParser.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,14 @@ namespace livemarkdown {
jni::alias_ref<jhybridobject> jThis,
jni::alias_ref<jni::JString> text,
const int parserId) {
const auto markdownWorklet = expensify::livemarkdown::findMarkdownWorklet(parserId);
if (markdownWorklet == nullptr) {
return jni::make_jstring("[]");
}

const auto markdownRuntime = expensify::livemarkdown::getMarkdownRuntime();
jsi::Runtime &rt = markdownRuntime->getJSIRuntime();

const auto markdownWorklet = expensify::livemarkdown::getMarkdownWorklet(parserId);

const auto input = jsi::String::createFromUtf8(rt, text->toStdString());
const auto output = markdownRuntime->runGuarded(markdownWorklet, input);

Expand Down
12 changes: 5 additions & 7 deletions apple/MarkdownParser.mm
Original file line number Diff line number Diff line change
Expand Up @@ -209,16 +209,14 @@ - (void)drainPendingWarmups
- (NSArray<MarkdownRange *> *)parseUncached:(nonnull NSString *)text
withParserId:(nonnull NSNumber *)parserId
{
const auto &markdownRuntime = expensify::livemarkdown::getMarkdownRuntime();
jsi::Runtime &rt = markdownRuntime->getJSIRuntime();

std::shared_ptr<SerializableWorklet> markdownWorklet;
try {
markdownWorklet = expensify::livemarkdown::getMarkdownWorklet([parserId intValue]);
} catch (const std::out_of_range &error) {
const auto markdownWorklet = expensify::livemarkdown::findMarkdownWorklet([parserId intValue]);
if (markdownWorklet == nullptr) {
return @[];
}

const auto &markdownRuntime = expensify::livemarkdown::getMarkdownRuntime();
jsi::Runtime &rt = markdownRuntime->getJSIRuntime();

const auto &input = jsi::String::createFromUtf8(rt, [text UTF8String]);

jsi::Value output;
Expand Down
10 changes: 4 additions & 6 deletions cpp/MarkdownGlobal.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -19,24 +19,22 @@ std::shared_ptr<WorkletRuntime> getMarkdownRuntime() {

std::unordered_map<int, std::shared_ptr<SerializableWorklet>> globalMarkdownShareableWorklets;
std::mutex globalMarkdownShareableWorkletsMutex;
int nextParserId = 1;

const int registerMarkdownWorklet(const std::shared_ptr<SerializableWorklet> &markdownWorklet) {
void registerMarkdownWorklet(const int parserId, const std::shared_ptr<SerializableWorklet> &markdownWorklet) {
assert(markdownWorklet != nullptr);
auto parserId = nextParserId++;
std::unique_lock<std::mutex> lock(globalMarkdownShareableWorkletsMutex);
globalMarkdownShareableWorklets[parserId] = markdownWorklet;
return parserId;
}

void unregisterMarkdownWorklet(const int parserId) {
std::unique_lock<std::mutex> lock(globalMarkdownShareableWorkletsMutex);
globalMarkdownShareableWorklets.erase(parserId);
}

std::shared_ptr<SerializableWorklet> getMarkdownWorklet(const int parserId) {
std::shared_ptr<SerializableWorklet> findMarkdownWorklet(const int parserId) {
std::unique_lock<std::mutex> lock(globalMarkdownShareableWorkletsMutex);
return globalMarkdownShareableWorklets.at(parserId);
const auto it = globalMarkdownShareableWorklets.find(parserId);
return it == globalMarkdownShareableWorklets.end() ? nullptr : it->second;
}

} // namespace livemarkdown
Expand Down
7 changes: 5 additions & 2 deletions cpp/MarkdownGlobal.h
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,14 @@ void setMarkdownRuntime(const std::shared_ptr<WorkletRuntime> &markdownWorkletRu

std::shared_ptr<WorkletRuntime> getMarkdownRuntime();

const int registerMarkdownWorklet(const std::shared_ptr<SerializableWorklet> &markdownWorklet);
// JS picks the id, one per mounted input, so the decorator view can carry it
// in the same commit that registers the worklet.
void registerMarkdownWorklet(const int parserId, const std::shared_ptr<SerializableWorklet> &markdownWorklet);

void unregisterMarkdownWorklet(const int parserId);

std::shared_ptr<SerializableWorklet> getMarkdownWorklet(const int parserId);
// Returns nullptr when nothing is registered under `parserId`.
std::shared_ptr<SerializableWorklet> findMarkdownWorklet(const int parserId);

} // namespace livemarkdown
} // namespace expensify
7 changes: 4 additions & 3 deletions cpp/RuntimeDecorator.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,11 @@ void injectJSIBindings(jsi::Runtime &rt) {
rt.global().setProperty(rt, "jsi_registerMarkdownWorklet", jsi::Function::createFromHostFunction(
rt,
jsi::PropNameID::forAscii(rt, "jsi_registerMarkdownWorklet"),
1,
2,
[](jsi::Runtime &rt, const jsi::Value &thisValue, const jsi::Value *args, size_t count) -> jsi::Value {
const auto parserId = registerMarkdownWorklet(extractSerializableOrThrow<SerializableWorklet>(rt, args[0]));
return jsi::Value(parserId);
const auto parserId = static_cast<int>(args[0].asNumber());
registerMarkdownWorklet(parserId, extractSerializableOrThrow<SerializableWorklet>(rt, args[1]));
return jsi::Value::undefined();
}));

rt.global().setProperty(rt, "jsi_unregisterMarkdownWorklet", jsi::Function::createFromHostFunction(
Expand Down
25 changes: 25 additions & 0 deletions example/src/AlwaysPaintedView.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import type {ViewProps} from 'react-native';
import {NativeComponentRegistry} from 'react-native';

// RN declares the view config types but does not export them, so they are read back off the registry signature.
type ViewConfigProvider = Parameters<typeof NativeComponentRegistry.get>[1];
type StyleAttributes = NonNullable<
NonNullable<ReturnType<ViewConfigProvider>['validAttributes']>['style']
>;

// React hides a host view under a hidden <Activity> by setting `display: none` on it.
// Pinning `display` to `contents` in the view config drops that write, so the subtree stays
// painted and native code keeps applying styles to an input whose JS side is hidden.
const pinnedDisplayStyleAttributes: StyleAttributes = {
display: {process: () => 'contents'},
};

const AlwaysPaintedView = NativeComponentRegistry.get<ViewProps>(
'AlwaysPaintedView',
() => ({
uiViewClassName: 'RCTView',
validAttributes: {style: pinnedDisplayStyleAttributes},
}),
);

export default AlwaysPaintedView;
100 changes: 83 additions & 17 deletions example/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,30 @@ import {
MarkdownTextInput,
parseExpensiMark,
} from '@expensify/react-native-live-markdown';
import type {MarkdownRange} from '@expensify/react-native-live-markdown';
import * as TEST_CONST from './testConstants';
import {PlatformInfo} from './PlatformInfo';
import {handleFormatSelection} from './formatSelectionUtils';
import AlwaysPaintedView from './AlwaysPaintedView';

// Passes an explicit max length: with react-native-worklets 0.10.2 the default parameter of `parseExpensiMark` is
// evaluated before the worklet closure is available and throws on the worklet runtime.
function parser(input: string) {
'worklet';

return parseExpensiMark(input, 4000);
}

function strikethroughParser(input: string): MarkdownRange[] {
'worklet';

return input.length === 0
? []
: [{type: 'strikethrough', start: 0, length: input.length}];
}

// Choosing a wrapper while the input is visible remounts it, so each hide and reveal cycle runs with a fixed wrapper.
type ActivityWrapper = 'none' | 'alwaysPainted';

export default function App() {
const [value, setValue] = React.useState(TEST_CONST.EXAMPLE_CONTENT);
Expand All @@ -16,6 +37,11 @@ export default function App() {
const [textFontSizeState, setTextFontSizeState] = React.useState(false);
const [emojiFontSizeState, setEmojiFontSizeState] = React.useState(false);
const [caretHidden, setCaretHidden] = React.useState(false);
const [activityWrapper, setActivityWrapper] =
React.useState<ActivityWrapper>('none');
const [activityHidden, setActivityHidden] = React.useState(false);
const [useStrikethroughParser, setUseStrikethroughParser] =
React.useState(false);
const [selection, setSelection] = React.useState({start: 0, end: 0});

const style = React.useMemo(() => {
Expand All @@ -36,27 +62,39 @@ export default function App() {

const ref = React.useRef<MarkdownTextInput>(null);

const input = (
<MarkdownTextInput
multiline={multiline}
formatSelection={handleFormatSelection}
autoCapitalize="none"
caretHidden={caretHidden}
value={value}
onChangeText={setValue}
style={[styles.input, style]}
ref={ref}
markdownStyle={markdownStyle}
parser={useStrikethroughParser ? strikethroughParser : parser}
placeholder="Type here..."
onSelectionChange={e => setSelection(e.nativeEvent.selection)}
selection={selection}
id={TEST_CONST.INPUT_ID}
maxLength={30000}
/>
);

return (
<ScrollView contentContainerStyle={styles.container} style={styles.content}>
<PlatformInfo />
<Text>{multiline ? 'multiline' : 'singleline'}</Text>
<MarkdownTextInput
multiline={multiline}
formatSelection={handleFormatSelection}
autoCapitalize="none"
caretHidden={caretHidden}
value={value}
onChangeText={setValue}
style={[styles.input, style]}
ref={ref}
markdownStyle={markdownStyle}
parser={parseExpensiMark}
placeholder="Type here..."
onSelectionChange={e => setSelection(e.nativeEvent.selection)}
selection={selection}
id={TEST_CONST.INPUT_ID}
maxLength={30000}
/>
<React.Activity mode={activityHidden ? 'hidden' : 'visible'}>
{activityWrapper === 'alwaysPainted' ? (
<AlwaysPaintedView style={styles.alwaysPainted}>
{input}
</AlwaysPaintedView>
) : (
input
)}
</React.Activity>
<Text style={styles.text}>{JSON.stringify(value)}</Text>
<Button
testID="focus"
Expand Down Expand Up @@ -122,6 +160,31 @@ export default function App() {
title="Toggle caret hidden"
onPress={() => setCaretHidden(prev => !prev)}
/>
<Button
title={
activityWrapper === 'none'
? 'Use AlwaysPaintedView'
: 'Use regular view'
}
disabled={activityHidden}
onPress={() =>
setActivityWrapper(prev =>
prev === 'none' ? 'alwaysPainted' : 'none',
)
}
/>
<Button
title={activityHidden ? 'Show Activity' : 'Hide Activity'}
onPress={() => setActivityHidden(prev => !prev)}
/>
<Button
title={
useStrikethroughParser
? 'Use ExpensiMark parser'
: 'Use strikethrough parser'
}
onPress={() => setUseStrikethroughParser(prev => !prev)}
/>
<Button
title="Toggle all"
onPress={() => {
Expand Down Expand Up @@ -154,6 +217,9 @@ const styles = StyleSheet.create({
content: {
marginTop: 60,
},
alwaysPainted: {
display: 'contents',
},
input: {
fontSize: 20,
width: 300,
Expand Down
11 changes: 11 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@
"@release-it/conventional-changelog": "^5.0.0",
"@types/jest": "^29.5.14",
"@types/react": "^19.2.0",
"@types/react-dom": "^19.2.7",
"@typescript-eslint/eslint-plugin": "^8.53.1",
"@typescript-eslint/parser": "^8.53.1",
"del-cli": "^5.0.0",
Expand Down
65 changes: 6 additions & 59 deletions src/MarkdownTextInput.tsx
Original file line number Diff line number Diff line change
@@ -1,66 +1,19 @@
import {StyleSheet, TextInput, processColor} from 'react-native';
import React from 'react';
import type {TextInputProps} from 'react-native';
import {createSerializable, createWorkletRuntime} from 'react-native-worklets';
import type {SerializableRef, WorkletFunction, WorkletRuntime} from 'react-native-worklets';
import MarkdownTextInputDecoratorViewNativeComponent from './MarkdownTextInputDecoratorViewNativeComponent';
import type {MarkdownStyle} from './MarkdownTextInputDecoratorViewNativeComponent';
import NativeLiveMarkdownModule from './NativeLiveMarkdownModule';
import {mergeMarkdownStyleWithDefault} from './styleUtils';
import type {PartialMarkdownStyle} from './styleUtils';
import type {InlineImagesInputProps, MarkdownRange} from './commonTypes';

declare global {
// eslint-disable-next-line no-var
var jsi_setMarkdownRuntime: (runtime: WorkletRuntime) => void;
// eslint-disable-next-line no-var
var jsi_registerMarkdownWorklet: (shareableWorklet: SerializableRef<WorkletFunction<[string], MarkdownRange[]>>) => number;
// eslint-disable-next-line no-var
var jsi_unregisterMarkdownWorklet: (parserId: number) => void;
}

let initialized = false;
let workletRuntime: WorkletRuntime | undefined;

function getWorkletRuntime(): WorkletRuntime {
if (workletRuntime === undefined) {
throw new Error(
"[react-native-live-markdown] Worklet runtime hasn't been created yet. Please avoid calling `getWorkletRuntime()` in top-level scope. Instead, call `getWorkletRuntime()` directly in `runOnRuntime` arguments list.",
);
}
return workletRuntime;
}

function initializeLiveMarkdownIfNeeded() {
if (initialized) {
return;
}
if (NativeLiveMarkdownModule) {
NativeLiveMarkdownModule.install();
}
if (!global.jsi_setMarkdownRuntime) {
throw new Error('[react-native-live-markdown] global.jsi_setMarkdownRuntime is not available');
}
workletRuntime = createWorkletRuntime({name: 'LiveMarkdownRuntime'});
global.jsi_setMarkdownRuntime(workletRuntime);
initialized = true;
}

function registerParser(parser: (input: string) => MarkdownRange[]): number {
initializeLiveMarkdownIfNeeded();
const serializableWorklet = createSerializable(parser as WorkletFunction<[string], MarkdownRange[]>);
const parserId = global.jsi_registerMarkdownWorklet(serializableWorklet);
return parserId;
}

function unregisterParser(parserId: number) {
global.jsi_unregisterMarkdownWorklet(parserId);
}
import type {InlineImagesInputProps} from './commonTypes';
import useParserId from './useParserId';
import {getWorkletRuntime} from './workletRuntime';
import type {ParserWorklet} from './workletRuntime';

interface MarkdownTextInputProps extends TextInputProps, InlineImagesInputProps {
markdownStyle?: PartialMarkdownStyle;
formatSelection?: (text: string, selectionStart: number, selectionEnd: number, formatCommand: string) => FormatSelectionResult;
parser: (value: string) => MarkdownRange[];
parser: ParserWorklet;
}

type FormatSelectionResult = {
Expand Down Expand Up @@ -104,13 +57,7 @@ const MarkdownTextInput = React.forwardRef<MarkdownTextInput, MarkdownTextInputP
throw new Error('[react-native-live-markdown] `parser` is not a worklet');
}

const parserId = React.useMemo(() => {
return registerParser(props.parser);
}, [props.parser]);

React.useEffect(() => {
return () => unregisterParser(parserId);
}, [parserId]);
const parserId = useParserId(props.parser);

return (
<MarkdownTextInputDecoratorViewNativeComponent
Expand Down
Loading
Loading