155 wiersze
4.5 KiB
TypeScript
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;
|
|
}
|