I've written simple plugin so i can push text from lexical to server:
const SubmitPlugin = ({ onSubmit }) => {
const [editor] = useLexicalComposerContext();
const onEnter = useCallback(
(event) => {
const { ctrlKey, metaKey } = event;
if (ctrlKey || metaKey) {
event.preventDefault();
onSubmit(
dompyrify.sanitize($generateHtmlFromNodes(editor), {
ALLOWED_ATTR: ['style'],
}),
);
editor.dispatchCommand(CLEAR_EDITOR_COMMAND, undefined);
}
return true;
},
[editor],
);
useEffect(() => {
return mergeRegister(
editor.registerCommand(KEY_ENTER_COMMAND, onEnter, COMMAND_PRIORITY_HIGH),
);
}, [editor, onEnter]);
return null;
};
When i press Ctrl or CMD + Enter it calls onSubmit function. Everything works fine except for editor.dispatchCommand(CLEAR_EDITOR_COMMAND, undefined); part. It doesn't do anything. All i need is to clear editor
It turns out that CLEAR_EDITOR_COMMAND is separated into plugin:
import { ClearEditorPlugin } from '#lexical/react/LexicalClearEditorPlugin';
and
return (
<LexicalComposer initialConfig={initialConfig}>
<RichTextPlugin
contentEditable={<ContentEditable />}
placeholder={<div className="placeholder">Reply...</div>}
/>
<ClearEditorPlugin />
<SubmitPlugin onSubmit={onSubmit} />
</LexicalComposer>
);
Now it works just fine
Related
Requirement
I have a requirement to get the editor state in JSON format as well as the text content of the editor. In addition, I want to receive these values in the debounced way.
I wanted to get these values (as debounced) because I wanted to send them to my server.
Dependencies
"react": "^18.2.0",
"lexical": "^0.3.8",
"#lexical/react": "^0.3.8",
You don't need to touch any of Lexical's internals for this; a custom hook that reads and "stashes" the editor state into a ref and sets up a debounced callback (via use-debounce here, but you can use whatever implementation you like) is enough.
getEditorState is in charge of converting the editor state into whichever format you want to send over the wire. It's always called within editorState.read().
function useDebouncedLexicalOnChange<T>(
getEditorState: (editorState: EditorState) => T,
callback: (value: T) => void,
delay: number
) {
const lastPayloadRef = React.useRef<T | null>(null);
const callbackRef = React.useRef<(arg: T) => void | null>(callback);
React.useEffect(() => {
callbackRef.current = callback;
}, [callback]);
const callCallbackWithLastPayload = React.useCallback(() => {
if (lastPayloadRef.current) {
callbackRef.current?.(lastPayloadRef.current);
}
}, []);
const call = useDebouncedCallback(callCallbackWithLastPayload, delay);
const onChange = React.useCallback(
(editorState) => {
editorState.read(() => {
lastPayloadRef.current = getEditorState(editorState);
call();
});
},
[call, getEditorState]
);
return onChange;
}
// ...
const getEditorState = (editorState: EditorState) => ({
text: $getRoot().getTextContent(false),
stateJson: JSON.stringify(editorState)
});
function App() {
const debouncedOnChange = React.useCallback((value) => {
console.log(new Date(), value);
// TODO: send to server
}, []);
const onChange = useDebouncedLexicalOnChange(
getEditorState,
debouncedOnChange,
1000
);
// ...
<OnChangePlugin onChange={onChange} />
}
Code
File: onChangeDebouce.tsx
import {$getRoot} from "lexical";
import { useLexicalComposerContext } from "#lexical/react/LexicalComposerContext";
import React from "react";
const CAN_USE_DOM = typeof window !== 'undefined' && typeof window.document !== 'undefined' && typeof window.document.createElement !== 'undefined';
const useLayoutEffectImpl = CAN_USE_DOM ? React.useLayoutEffect : React.useEffect;
var useLayoutEffect = useLayoutEffectImpl;
type onChangeFunction = (editorStateJson: string, editorText: string) => void;
export const OnChangeDebounce: React.FC<{
ignoreInitialChange?: boolean;
ignoreSelectionChange?: boolean;
onChange: onChangeFunction;
wait?: number
}> = ({ ignoreInitialChange= true, ignoreSelectionChange = false, onChange, wait= 167 }) => {
const [editor] = useLexicalComposerContext();
let timerId: NodeJS.Timeout | null = null;
useLayoutEffect(() => {
return editor.registerUpdateListener(({
editorState,
dirtyElements,
dirtyLeaves,
prevEditorState
}) => {
if (ignoreSelectionChange && dirtyElements.size === 0 && dirtyLeaves.size === 0) {
return;
}
if (ignoreInitialChange && prevEditorState.isEmpty()) {
return;
}
if(timerId === null) {
timerId = setTimeout(() => {
editorState.read(() => {
const root = $getRoot();
onChange(JSON.stringify(editorState), root.getTextContent());
})
}, wait);
} else {
clearTimeout(timerId);
timerId = setTimeout(() => {
editorState.read(() => {
const root = $getRoot();
onChange(JSON.stringify(editorState), root.getTextContent());
});
}, wait);
}
});
}, [editor, ignoreInitialChange, ignoreSelectionChange, onChange]);
return null;
}
This is the code for the plugin and it is inspired (or copied) from OnChangePlugin of lexical
Since, lexical is in early development the implementation of OnChangePlugin might change. And in fact, there is one more parameter added as of version 0.3.8. You can check the latest code at github.
The only thing I have added is calling onChange function in timer logic.
ie.
if(timerId === null) {
timerId = setTimeout(() => {
editorState.read(() => {
const root = $getRoot();
onChange(JSON.stringify(editorState), root.getTextContent());
})
}, wait);
} else {
clearTimeout(timerId);
timerId = setTimeout(() => {
editorState.read(() => {
const root = $getRoot();
onChange(JSON.stringify(editorState), root.getTextContent());
});
}, wait);
}
If you are new to lexical, then you have to use declare this plugin as a child of lexical composer, something like this.
File: RichEditor.tsx
<LexicalComposer initialConfig={getRichTextConfig(namespace)}>
<div className="editor-shell lg:m-2" ref={scrollRef}>
<div className="editor-container">
{/* your other plugins */}
<RichTextPlugin
contentEditable={<ContentEditable
className={"ContentEditable__root"} />
}
placeholder={<Placeholder text={placeHolderText} />}
/>
<OnChangeDebounce onChange={onChange} />
</div>
</div>
</LexicalComposer>
In this code, as you can see I have passed the onChange function as a prop and you can also pass wait in milliseconds like this.
<OnChangeDebounce onChange={onChange} wait={1000}/>
Now the last bit is the implementation of onChange function, which is pretty straightforward
const onChange = (editorStateJson:string, editorText:string) => {
console.log("editorStateJson:", editorStateJson);
console.log("editorText:", editorText);
// send data to a server or to your data store (eg. redux)
};
Finally
Thanks to Meta and the lexical team for open sourcing this library. And lastly, the code I have provided works for me, I am no expert, feel free to comment to suggest an improvement.
Problem
I'm trying to play some audio files in some specific situations.
e.g)
When users access to login page, the audio plays 'Please enter your phone number'
when an error message alert comes up, audio file is played such as 'your phone number has been already registered'
So far, the audio files are played successfully when you access some pages, but I got the reference error in the image after I added two lines of code below in the root component (app.tsx)
import {kioskAudio} from '../src/common/utils/kioskAudio';
const {playAudio, stopAudio} = kioskAudio();
What I've tried to resolve this issue
First try:
I imported 'kioskAudio' method into KioskAlertError component directly. But I got the same reference error.
Second try:
So I thought, 'Then should I import the 'kioskAudio' method to the root component(app.tsx) and deliver the props(playAudio, stopAudio) to the component like this :
<KioskAlertError playAudio={playAudio} stopAudio={stopAudio} />
But I still got the reference error. How can I resolve this issue?
Source Code
app.tsx
import KioskAlert, {IKioskAlertProps} from './component/KioskAlert';
import KioskAlertError from './component/KioskAlertError';
import {kioskAudio} from '../src/common/utils/kioskAudio';
export default function CustomApp({Component, pageProps}) {
const router = useRouter();
const [shouldStartRender, setShouldStartRender] = useState(false);
const [kioskAlertInfo, setKioskAlertInfo] = useState({isShow: false, onOK: null} as IKioskAlertProps);
const {playAudio, stopAudio} = kioskAudio();
useEffect(() => {
setShouldStartRender(true);
}, [router]);
return (
<>
<KioskAlertContext.Provider
value={{
kioskAlertState: kioskAlertInfo,
openKioskAlert: openKioskAlert,
closeKioskAlert: closeKioskAlert,
}}
>
<SWRConfig
value={{
refreshInterval: 0,
revalidateOnReconnect: true,
revalidateOnFocus: true,
onErrorRetry: (error, key, config, revalidate, {retryCount}) => {
if (error.response?.status === 401) {
localStorage.removeItem('accessToken');
return;
}
if (retryCount >= 5) return;
setTimeout(() => {
revalidate({retryCount});
}, 5000);
},
}}
>
{shouldStartRender ? (
<DomRouter>
<DomRoutes>
<DomRoute path="/home/home.html" element={<Home />} />
<DomRoute path="/home/clause.html" element={<Clause />} />
<DomRoute path="/home/loginPhone.html" element={<LoginPhone />} />
<DomRoute path="/home/loginPin.html" element={<LoginPin />} />
<DomRoute path="/home/signUp-phone.html" element={<SignUpPhone />} />
<DomRoute path="/home/signUp-authCode.html" element={<SignUpAuthCode />} />
<DomRoute path="/home/signUp-pin.html" element={<SignUpPin />} />
<DomRoute path="/home/CheckUserByPin.html" element={<CheckUserByPin />} />
</DomRoutes>
</DomRouter>
) : null}
<KioskAlertError playAudio={playAudio} stopAudio={stopAudio} />
<KioskAlert {...kioskAlertInfo} />
</SWRConfig>
</KioskAlertContext.Provider>
</>
);
}
KioskAudio.ts
export const kioskAudio = () => {
const audio = new Audio();
const playAudio = (folder: string, file: string) => {
stopAudio();
audio.setAttribute('src', `/sounds/${folder}/${file}.mp3`);
audio.play();
};
const stopAudio = () => {
audio.pause();
audio.currentTime = 0;
};
return {
playAudio,
stopAudio,
};
};
KioskAlertError.tsx
const KioskAlertError: React.FC<IKioskAlertErrorProps> = ({playAudio, stopAudio}) => {
const [isShow, setIsShow] = useState(false);
const [content, setContent] = useState('');
useEffect(() => {
document.addEventListener('error', (data: CustomEvent) => {
const message = JSON.parse(data.detail);
const errorMessage = message.message;
setContent(getErrorMessage(message.message));
setIsShow(true);
switch (errorMessage) {
case 'Already Registered':
console.log('Already joined');
playAudio('alert', '2');
break;
case 'Can't find your numbers':
console.log('userNotFound');
playAudio('alert', '1');
break;
}
});
return () => {
document.removeEventListener('error', null);
};
}, []);
const getErrorMessage = (messageCode) => {
return messageCode;
};
return isShow ? (
<Alert
content={content}
okText={'OK'}
onOK={() => setIsShow(false)}
wrapperStyle={defaultWrapperStyle}
alertStyle={defaultAlertStyle}
upperSectionStyle={defaultUpperSectionStyle}
lowerSectionStyle={defaultLowerSectionStyle}
titleStyle={defaultTitleStyle}
contentStyle={defaultContentStyle}
cancelStyle={defaultButtonStyle}
okStyle={defaultButtonStyle}
/>
) : null;
};
export default KioskAlertError;
As you have used the audio variable inside your audio functions, the reference to the variable in function closures get lost between component re-renders. So you need to convert the kisokAudio util into a custom hook which holds the ref between renders & then use useKioskAudio instead of the simple function.
useKioskAudio.ts
import { useRef } from "react";
export const useKioskAudio = () => {
const audio = useRef(new Audio());
const playAudio = (folder: string, file: string) => {
stopAudio();
audio.current.setAttribute('src', `/sounds/${folder}/${file}.mp3`);
audio.current.play();
};
const stopAudio = () => {
audio.current.pause();
audio.current.currentTime = 0;
};
return {
playAudio,
stopAudio,
};
};
and then use it like
const { playAudio, stopAudio } = useKioskAudio();
in your app.tsx component.
I'm using react-native-vector-icons, and I want to change the icon when I press the button.
const {favorites, toggleFavorite} = useFavorites();
const isFavorite = favorites.includes(value);
return (
...
<Button
icon={
<Icon
name={isFavorite ? 'favorite' : 'favorite-border'}
size={24}
color={theme.colors.text.contrast}
/>
}
onPress={() => toggleFavorite(value)}
type="clear"
/>
export const useFavorites = (): {
favorites: string[];
toggleFavorite: (lineNumber: string) => Promise<void>;
} => {
const [favorites, setFavorites] = useState<string[]>([]);
const readFavorites = async () => {
try {
const value = await AsyncStorage.getItem(FAVORITES_KEY);
setFavorites(value ? value.split(',') : []);
} catch (e) {
console.log(e);
}
};
const toggleFavorite = async (lineNumber: string) => {
let newFavorites = favorites;
if (favorites.includes(lineNumber)) {
newFavorites = newFavorites.filter(favorite => favorite !== lineNumber);
} else {
newFavorites.push(lineNumber);
}
try {
await AsyncStorage.setItem(FAVORITES_KEY, newFavorites.join(','));
setFavorites(newFavorites);
} catch (e) {
console.log(e);
}
};
useEffect(() => {
readFavorites();
}, []);
return {
favorites,
toggleFavorite,
};
};
When I press the button, the value of isFavorite is toggled correctly. Also it works when isFavorite is true initially. It doesn't work the other way around. What am I missing here?
EDIT: Added useFavorites for more context
Your problem is you add item wrong way. You cannot mutate state directly. Just update like this:
if (favorites.includes(lineNumber)) {
newFavorites = newFavorites.filter(favorite => favorite !== lineNumber);
} else {
newFavorites = [...favorites, lineNumber]
}
I'm using react-hook-form library with a multi-step-form
I tried getValues() in useEffect to update a state while changing tab ( without submit ) and it returned {}
useEffect(() => {
return () => {
const values = getValues();
setCount(values.count);
};
}, []);
It worked in next js dev, but returns {} in production
codesandbox Link : https://codesandbox.io/s/quirky-colden-tc5ft?file=/src/App.js
Details:
The form requirement is to switch between tabs and change different parameters
and finally display results in a results tab. user can toggle between any tab and check back result tab anytime.
Implementation Example :
I used context provider and custom hook to wrap setting data state.
const SomeContext = createContext();
const useSome = () => {
return useContext(SomeContext);
};
const SomeProvider = ({ children }) => {
const [count, setCount] = useState(0);
const values = {
setCount,
count
};
return <SomeContext.Provider value={values}>{children}</SomeContext.Provider>;
};
Wrote form component like this ( each tab is a form ) and wrote the logic to update state upon componentWillUnmount.
as i found it working in next dev, i deployed it
const FormComponent = () => {
const { count, setCount } = useSome();
const { register, getValues } = useForm({
defaultValues: { count }
});
useEffect(() => {
return () => {
const values = getValues(); // returns {} in production
setCount(values.count);
};
}, []);
return (
<form>
<input type="number" name={count} ref={register} />
</form>
);
};
const DisplayComponent = () => {
const { count } = useSome();
return <div>{count}</div>;
};
Finally a tab switching component & tab switch logic within ( simplified below )
const App = () => {
const [edit, setEdit] = useState(true);
return (
<SomeProvider>
<div
onClick={() => {
setEdit(!edit);
}}
>
Click to {edit ? "Display" : "Edit"}
</div>
{edit ? <FormComponent /> : <DisplayComponent />}
</SomeProvider>
);
}
What the below code does is to get data from API, and then render it on the page. searchChange function takes a value from the input tag, and setValue for query state. My api endpoint takes argument to filter the API such as http://127.0.0.1:8000/api/deals/?q=${query}.
I'm very confused how I can update the DealList component with the API updated with query state whenever typing something in the input tag. I'm thinking of that I need to something in searchChange function, but not sure what to do there.
index.js
const useFetch = (url, query, defaultResponse) => {
const [result, setResult] = useState(defaultResponse);
const getDataFromAPI = async url => {
try {
const data = await axios.get(url);
setResult({
isLoading: false,
data
});
} catch (e) {}
};
useEffect(() => {
if (query.length > 0) {
getDataFromAPI(`${url}?q=${query}`);
} else {
getDataFromAPI(url);
}
}, []);
return result;
};
const Index = ({ data }) => {
const query = useInput("");
const apiEndpoint = "http://127.0.0.1:8000/api/deals/";
const dealFetchResponse = useFetch(apiEndpoint, query, {
isLoading: true,
data: null
});
const searchChange = e => {
query.onChange(e);
query.setValue(e.target.value);
};
return (
<Layout>
<Head title="Home" />
<Navigation />
<Container>
<Headline>
<h1>The best lease deal finder</h1>
<h4>See all the lease deals here</h4>
</Headline>
<InputContainer>
<input value={query.value} onChange={searchChange} />
</InputContainer>
{!dealFetchResponse.data || dealFetchResponse.isLoading ? (
<Spinner />
) : (
<DealList dealList={dealFetchResponse.data.data.results} />
)}
</Container>
</Layout>
);
};
export default Index;
The biggest challenge in something like this is detecting when a user has stopped typing.. If someone is searching for 'Milk' - when do you actually fire off the API request? How do you know they aren't searching for 'Milk Duds'? (This is hypothetical, and to demonstrate the 'hard' part in search bars/APIs due to their async nature)..
This is typically solved by debouncing, which has been proven to work, but is not very solid.
In this example, you can search Github repos...but even in this example, there are unnecessary requests being sent - this is simply to be used as a demonstration. This example will need some fine tuning..
const GithubSearcher = () => {
const [repos, setRepos] = React.useState();
const getGithubRepo = q => {
fetch("https://api.github.com/search/repositories?q=" + q)
.then(res => {
return res.json();
})
.then(json => {
let formattedJson = json.items.map(itm => {
return itm.name;
})
setRepos(formattedJson);
});
}
const handleOnChange = event => {
let qry = event.target.value;
if(qry) {
setTimeout(() => {
getGithubRepo(qry);
}, 500);
} else {
setRepos("");
}
};
return (
<div>
<p>Search Github</p>
<input onChange={event => handleOnChange(event)} type="text" />
<pre>
{repos ? "Repo Names:" + JSON.stringify(repos, null, 2) : ""}
</pre>
</div>
);
};
ReactDOM.render(<GithubSearcher />, document.body);
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/16.8.6/umd/react.production.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/16.8.6/umd/react-dom.production.min.js"></script>