mysticsymbolic.github.io/lib/page-with-shareable-state.tsx

155 wiersze
4.5 KiB
TypeScript

import React, { useCallback, useContext, useEffect, useState } from "react";
import { PageContext, PAGE_QUERY_ARG } from "./page";
import type { PageName } from "./pages";
export type ComponentWithShareableStateProps<T> = {
/** The default state to use when the component is first rendered. */
defaults: T;
/**
* Callback to trigger whenever the shareable state changes. Note
* that each call will add a new entry to the browser's navigation history.
* As such, every call to this function will mark a boundary where the
* user can press the "back" button to go back to the previous state.
*/
onChange: (value: T) => void;
};
export type PageWithShareableStateOptions<T> = {
/** The default shareable state. */
defaultValue: T;
/**
* Deserialize the given state, throwing an exception
* if it's invalid in any way.
*/
deserialize: (value: string) => T;
/** Serialize the given state to a string. */
serialize: (value: T) => string;
/** Component to render the page. */
component: React.ComponentType<ComponentWithShareableStateProps<T>>;
};
/** The query string argument that will store the serialized state. */
export const STATE_QUERY_ARG = "s";
export function createPageWithStateSearchParams(
page: PageName,
state: string
): URLSearchParams {
const search = new URLSearchParams();
search.set(PAGE_QUERY_ARG, page);
search.set(STATE_QUERY_ARG, state);
return search;
}
const DeserializationErrorModal: React.FC = () => {
const [show, setShow] = useState(true);
if (!show) return null;
// TODO: This isn't accessible at all; it ought to trap keyboard focus,
// disappear when the user presses ESC, and so on.
return (
<div className="page-error">
<div>
<p>
Sorry, an error occurred when trying to load the composition on this
page.
</p>
<p>
Either its data is corrupted, or displaying it is no longer supported.
</p>
<button onClick={() => setShow(false)}>OK</button>
</div>
</div>
);
};
/**
* Create a component that represents a page which exposes some
* aspect of its state in the current URL, so that it can be
* easily shared, recorded in the browser history, etc.
*/
export function createPageWithShareableState<T>({
defaultValue,
deserialize,
serialize,
component,
}: PageWithShareableStateOptions<T>): React.FC<{}> {
const Component = component;
const PageWithShareableState: React.FC<{}> = () => {
const { search, pushState, currPage } = useContext(PageContext);
/** The current serialized state, as reflected in the URL bar. */
const state = search.get(STATE_QUERY_ARG) || serialize(defaultValue);
/**
* What we think the latest serialized state is; used to determine whether
* the user navigated in their browser history.
*/
const [latestState, setLatestState] = useState(state);
/**
* The key to use when rendering our page component. This will
* be incremented whenever the user navigates their browser
* history, to ensure that our component resets itself to the
* default state we pass in.
*/
const [key, setKey] = useState(0);
/**
* Remembers whether we're in the middle of an update triggered by
* our own component.
*/
const [isInOnChange, setIsInOnChange] = useState(false);
/** The default state from the URL, which we'll pass into our component. */
let defaults: T = defaultValue;
let didDeserializeThrow = false;
try {
defaults = deserialize(state || "");
} catch (e) {
console.log(`Error deserializing state: ${e}`);
didDeserializeThrow = true;
}
const onChange = useCallback(
(value: T) => {
const newState = serialize(value);
if (state !== newState) {
const newSearch = createPageWithStateSearchParams(currPage, newState);
setIsInOnChange(true);
setLatestState(newState);
pushState("?" + newSearch.toString());
setIsInOnChange(false);
}
},
[state, currPage, pushState]
);
useEffect(() => {
if (!isInOnChange && latestState !== state) {
// The user navigated in their browser.
setLatestState(state);
setKey(key + 1);
}
}, [isInOnChange, state, latestState, key]);
return (
<>
{didDeserializeThrow && <DeserializationErrorModal />}
<Component key={key} defaults={defaults} onChange={onChange} />
</>
);
};
return PageWithShareableState;
}