Added 'embedded mode' for using Topola Viewer inside other apps.

This commit is contained in:
Przemek Wiech
2019-04-14 14:05:15 +02:00
parent 177c721c2f
commit f0875ef339
3 changed files with 143 additions and 25 deletions

View File

@@ -21,6 +21,29 @@ export function ErrorMessage(props: {message: string}) {
);
}
/**
* Message types used in embedded mode.
* When the parent is ready to receive messages, it sends PARENT_READY.
* When the child (this app) is ready to receive messages, it sends READY.
* When the child receives PARENT_READY, it sends READY.
* When the parent receives READY, it sends data in a GEDCOM message.
*/
enum EmbeddedMessageType {
GEDCOM = 'gedcom',
READY = 'ready',
PARENT_READY = 'parent_ready',
}
/** Message sent to parent or received from parent in embedded mode. */
interface EmbeddedMessage {
message: EmbeddedMessageType;
}
interface GedcomMessage extends EmbeddedMessage {
message: EmbeddedMessageType.GEDCOM;
gedcom?: string;
}
interface State {
/** Loaded data. */
data?: TopolaData;
@@ -36,10 +59,12 @@ interface State {
url?: string;
/** Whether the side panel is shoen. */
showSidePanel?: boolean;
/** Whether the app is in embedded mode, i.e. embedded in an iframe. */
embedded: boolean;
}
export class App extends React.Component<RouteComponentProps, {}> {
state: State = {loading: false};
state: State = {loading: false, embedded: false};
chartRef: Chart | null = null;
private isNewData(
@@ -54,6 +79,62 @@ export class App extends React.Component<RouteComponentProps, {}> {
);
}
/** Sets the state with a new individual selection. */
private updateSelection(selection: IndiInfo) {
if (
!this.state.selection ||
this.state.selection.id !== selection.id ||
this.state.selection!.generation !== selection.generation
) {
this.setState(
Object.assign({}, this.state, {
selection,
}),
);
}
}
/** Sets error message after data load failure. */
private setError(error: string) {
this.setState(
Object.assign({}, this.state, {
error: error,
loading: false,
}),
);
}
private async onMessage(message: EmbeddedMessage) {
if (message.message === EmbeddedMessageType.PARENT_READY) {
// Parent didn't receive the first 'ready' message, so we need to send it again.
window.parent.postMessage({message: EmbeddedMessageType.READY}, '*');
} else if (message.message === EmbeddedMessageType.GEDCOM) {
const gedcom = (message as GedcomMessage).gedcom;
if (!gedcom) {
return;
}
try {
const data = await loadGedcom('', gedcom);
const software = getSoftware(data.gedcom.head);
analyticsEvent('embedded_file_loaded', {
event_label: software,
});
// Set state with data.
this.setState(
Object.assign({}, this.state, {
data,
selection: getSelection(data.chartData),
error: undefined,
loading: false,
}),
);
} catch (error) {
analyticsEvent('embedded_file_error');
this.setError(error.message);
}
}
}
componentDidMount() {
this.componentDidUpdate();
}
@@ -62,21 +143,39 @@ export class App extends React.Component<RouteComponentProps, {}> {
if (this.props.location.pathname !== '/view') {
return;
}
const gedcom = this.props.location.state && this.props.location.state.data;
const images =
this.props.location.state && this.props.location.state.images;
const search = queryString.parse(this.props.location.search);
const getParam = (name: string) => {
const value = search[name];
return typeof value === 'string' ? value : undefined;
};
const showSidePanel = getParam('sidePanel') !== 'false'; // True by default.
const embedded = getParam('embedded') === 'true'; // False by default.
if (embedded && !this.state.embedded) {
this.setState(
Object.assign({}, this.state, {embedded: true, showSidePanel}),
);
// Notify the parent window that we are ready.
window.parent.postMessage('ready', '*');
window.addEventListener('message', (data) => this.onMessage(data.data));
}
if (embedded) {
// If the app is embedded, do not run the normal loading code.
return;
}
const url = getParam('url');
const indi = getParam('indi');
const parsedGen = Number(getParam('gen'));
const generation = !isNaN(parsedGen) ? parsedGen : undefined;
const hash = getParam('file');
const handleCors = getParam('handleCors') !== 'false'; // True by default.
const showSidePanel = getParam('sidePanel') !== 'false'; // True by default.
const gedcom = this.props.location.state && this.props.location.state.data;
const images =
this.props.location.state && this.props.location.state.images;
if (!url && !hash) {
this.props.history.replace({pathname: '/'});
@@ -117,13 +216,7 @@ export class App extends React.Component<RouteComponentProps, {}> {
);
} catch (error) {
analyticsEvent(hash ? 'upload_file_error' : 'url_file_error');
// Set error state.
this.setState(
Object.assign({}, this.state, {
error: error.message,
loading: false,
}),
);
this.setError(error.message);
}
} else if (this.state.data && this.state.selection) {
// Update selection if it has changed in the URL.
@@ -132,16 +225,7 @@ export class App extends React.Component<RouteComponentProps, {}> {
indi,
generation,
);
if (
this.state.selection.id !== selection.id ||
this.state.selection.generation !== selection.generation
) {
this.setState(
Object.assign({}, this.state, {
selection,
}),
);
}
this.updateSelection(selection);
}
}
@@ -151,6 +235,11 @@ export class App extends React.Component<RouteComponentProps, {}> {
*/
private onSelection = (selection: IndiInfo) => {
analyticsEvent('selection_changed');
if (this.state.embedded) {
// In embedded mode the URL doesn't change.
this.updateSelection(selection);
return;
}
const location = this.props.location;
const search = queryString.parse(location.search);
search.indi = selection.id;
@@ -201,6 +290,7 @@ export class App extends React.Component<RouteComponentProps, {}> {
this.state.selection
)
}
embedded={this.state.embedded}
onSelection={this.onSelection}
onPrint={() => {
analyticsEvent('print');

View File

@@ -33,6 +33,7 @@ interface State {
interface Props {
showingChart: boolean;
gedcom?: GedcomData;
embedded: boolean;
onSelection: (indiInfo: IndiInfo) => void;
onPrint: () => void;
onDownloadPdf: () => void;
@@ -305,8 +306,8 @@ export class TopBar extends React.Component<
</>
) : null;
return (
<Menu attached="top" inverted color="blue" size="large">
const fileMenus = this.props.embedded ? null : (
<>
<Link to="/">
<Menu.Item>
<b>Topola Genealogy</b>
@@ -336,7 +337,25 @@ export class TopBar extends React.Component<
/>
</Menu.Item>
</label>
{chartMenus}
</>
);
const sourceLink = this.props.embedded ? (
<>
<Menu.Item
as="a"
href="https://pewu.github.com/topola-viewer"
position="right"
target="_blank"
>
<FormattedMessage
id="menu.powered_by"
defaultMessage="Powered by Topola"
/>
</Menu.Item>
</>
) : (
<>
<Menu.Item
as="a"
href="https://github.com/PeWu/topola-viewer"
@@ -348,6 +367,14 @@ export class TopBar extends React.Component<
defaultMessage="Source on GitHub"
/>
</Menu.Item>
</>
);
return (
<Menu attached="top" inverted color="blue" size="large">
{fileMenus}
{chartMenus}
{sourceLink}
{loadFromUrlModal}
</Menu>
);

View File

@@ -7,6 +7,7 @@
"menu.png_file": "Plik PNG",
"menu.svg_file": "Plik SVG",
"menu.github": "Źródła na GitHub",
"menu.powered_by": "Topola Genealogy",
"menu.search.placeholder": "Szukaj osoby",
"menu.search.no_results": "Brak wyników",
"intro.title": "Topola Genealogy",