mirror of
https://github.com/PeWu/topola-viewer.git
synced 2026-02-18 02:55:48 +00:00
Added search box
This commit is contained in:
parent
120e4effbb
commit
9eecc7a0fd
@ -6,10 +6,13 @@
|
||||
"array.prototype.flatmap": "^1.2.1",
|
||||
"canvas-toBlob": "^1.0.0",
|
||||
"d3": "^5.7.0",
|
||||
"debounce": "^1.2.0",
|
||||
"detect-browser": "^4.1.0",
|
||||
"file-saver": "^2.0.1",
|
||||
"history": "^4.7.2",
|
||||
"javascript-natural-sort": "^0.7.1",
|
||||
"jspdf": "^1.5.3",
|
||||
"lunr": "^2.3.6",
|
||||
"md5": "^2.2.1",
|
||||
"parse-gedcom": "^1.0.5",
|
||||
"query-string": "^5.1.1",
|
||||
@ -25,13 +28,15 @@
|
||||
"devDependencies": {
|
||||
"@types/array.prototype.flatmap": "^1.2.0",
|
||||
"@types/d3": "^5.5.0",
|
||||
"@types/debounce": "^1.2.0",
|
||||
"@types/file-saver": "^2.0.0",
|
||||
"@types/history": "^4.7.2",
|
||||
"@types/jest": "*",
|
||||
"@types/jspdf": "^1.2.2",
|
||||
"@types/lunr": "^2.3.2",
|
||||
"@types/md5": "^2.1.33",
|
||||
"@types/node": "*",
|
||||
"@types/query-string": "^6.2.0",
|
||||
"@types/query-string": "6.2.0",
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"@types/react-intl": "^2.3.15",
|
||||
|
||||
@ -190,6 +190,7 @@ export class App extends React.Component<RouteComponentProps, {}> {
|
||||
render={(props: RouteComponentProps) => (
|
||||
<TopBar
|
||||
{...props}
|
||||
gedcom={this.state.data && this.state.data.gedcom}
|
||||
showingChart={
|
||||
!!(
|
||||
this.props.history.location.pathname === '/view' &&
|
||||
@ -197,6 +198,7 @@ export class App extends React.Component<RouteComponentProps, {}> {
|
||||
this.state.selection
|
||||
)
|
||||
}
|
||||
onSelection={this.onSelection}
|
||||
onPrint={() => {
|
||||
analyticsEvent('print');
|
||||
this.chartRef && this.chartRef.print();
|
||||
|
||||
92
src/date_util.ts
Normal file
92
src/date_util.ts
Normal file
@ -0,0 +1,92 @@
|
||||
import {Date as TopolaDate, DateRange, getDate} from 'topola';
|
||||
import {InjectedIntl} from 'react-intl';
|
||||
|
||||
const DATE_QUALIFIERS = new Map([
|
||||
['abt', 'about'],
|
||||
['cal', 'calculated'],
|
||||
['est', 'estimated'],
|
||||
]);
|
||||
|
||||
function formatDate(date: TopolaDate, intl: InjectedIntl) {
|
||||
const hasDay = date.day !== undefined;
|
||||
const hasMonth = date.month !== undefined;
|
||||
const hasYear = date.year !== undefined;
|
||||
if (!hasDay && !hasMonth && !hasYear) {
|
||||
return date.text || '';
|
||||
}
|
||||
const dateObject = new Date(
|
||||
hasYear ? date.year! : 0,
|
||||
hasMonth ? date.month! - 1 : 0,
|
||||
hasDay ? date.day! : 1,
|
||||
);
|
||||
|
||||
const qualifier = date.qualifier && date.qualifier.toLowerCase();
|
||||
const translatedQualifier =
|
||||
qualifier &&
|
||||
intl.formatMessage({
|
||||
id: `date.${qualifier}`,
|
||||
defaultMessage: DATE_QUALIFIERS.get(qualifier) || qualifier,
|
||||
});
|
||||
|
||||
const formatOptions = {
|
||||
day: hasDay ? 'numeric' : undefined,
|
||||
month: hasMonth ? 'long' : undefined,
|
||||
year: hasYear ? 'numeric' : undefined,
|
||||
};
|
||||
const translatedDate = new Intl.DateTimeFormat(
|
||||
intl.locale,
|
||||
formatOptions,
|
||||
).format(dateObject);
|
||||
|
||||
return [translatedQualifier, translatedDate].join(' ');
|
||||
}
|
||||
|
||||
function formatDateRage(dateRange: DateRange, intl: InjectedIntl) {
|
||||
const fromDate = dateRange.from;
|
||||
const toDate = dateRange.to;
|
||||
const translatedFromDate = fromDate && formatDate(fromDate, intl);
|
||||
const translatedToDate = toDate && formatDate(toDate, intl);
|
||||
if (translatedFromDate && translatedToDate) {
|
||||
return intl.formatMessage(
|
||||
{
|
||||
id: 'date.between',
|
||||
defaultMessage: 'between {from} and {to}',
|
||||
},
|
||||
{from: translatedFromDate, to: translatedToDate},
|
||||
);
|
||||
}
|
||||
if (translatedFromDate) {
|
||||
return intl.formatMessage(
|
||||
{
|
||||
id: 'date.after',
|
||||
defaultMessage: 'after {from}',
|
||||
},
|
||||
{from: translatedFromDate},
|
||||
);
|
||||
}
|
||||
if (translatedToDate) {
|
||||
return intl.formatMessage(
|
||||
{
|
||||
id: 'date.before',
|
||||
defaultMessage: 'before {to}',
|
||||
},
|
||||
{to: translatedToDate},
|
||||
);
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
/** Formats a date given in GEDCOM format. */
|
||||
export function translateDate(gedcomDate: string, intl: InjectedIntl) {
|
||||
const dateOrRange = getDate(gedcomDate);
|
||||
if (!dateOrRange) {
|
||||
return '';
|
||||
}
|
||||
if (dateOrRange.date) {
|
||||
return formatDate(dateOrRange.date, intl);
|
||||
}
|
||||
if (dateOrRange.dateRange) {
|
||||
return formatDateRage(dateOrRange.dateRange, intl);
|
||||
}
|
||||
return '';
|
||||
}
|
||||
@ -1,11 +1,11 @@
|
||||
import * as React from 'react';
|
||||
import flatMap from 'array.prototype.flatmap';
|
||||
import Linkify from 'react-linkify';
|
||||
import {Date as TopolaDate, DateRange, getDate} from 'topola';
|
||||
import {FormattedMessage, InjectedIntl} from 'react-intl';
|
||||
import {GedcomData} from './gedcom_util';
|
||||
import {GedcomEntry} from 'parse-gedcom';
|
||||
import {intlShape} from 'react-intl';
|
||||
import {translateDate} from './date_util';
|
||||
|
||||
interface Props {
|
||||
gedcom: GedcomData;
|
||||
@ -35,95 +35,6 @@ function translateTag(tag: string) {
|
||||
);
|
||||
}
|
||||
|
||||
const DATE_QUALIFIERS = new Map([
|
||||
['abt', 'about'],
|
||||
['cal', 'calculated'],
|
||||
['est', 'estimated'],
|
||||
]);
|
||||
|
||||
function formatDate(date: TopolaDate, intl: InjectedIntl) {
|
||||
const hasDay = date.day !== undefined;
|
||||
const hasMonth = date.month !== undefined;
|
||||
const hasYear = date.year !== undefined;
|
||||
if (!hasDay && !hasMonth && !hasYear) {
|
||||
return date.text || '';
|
||||
}
|
||||
const dateObject = new Date(
|
||||
hasYear ? date.year! : 0,
|
||||
hasMonth ? date.month! - 1 : 0,
|
||||
hasDay ? date.day! : 1,
|
||||
);
|
||||
|
||||
const qualifier = date.qualifier && date.qualifier.toLowerCase();
|
||||
const translatedQualifier =
|
||||
qualifier &&
|
||||
intl.formatMessage({
|
||||
id: `date.${qualifier}`,
|
||||
defaultMessage: DATE_QUALIFIERS.get(qualifier) || qualifier,
|
||||
});
|
||||
|
||||
const formatOptions = {
|
||||
day: hasDay ? 'numeric' : undefined,
|
||||
month: hasMonth ? 'long' : undefined,
|
||||
year: hasYear ? 'numeric' : undefined,
|
||||
};
|
||||
const translatedDate = new Intl.DateTimeFormat(
|
||||
intl.locale,
|
||||
formatOptions,
|
||||
).format(dateObject);
|
||||
|
||||
return [translatedQualifier, translatedDate].join(' ');
|
||||
}
|
||||
|
||||
function formatDateRage(dateRange: DateRange, intl: InjectedIntl) {
|
||||
const fromDate = dateRange.from;
|
||||
const toDate = dateRange.to;
|
||||
const translatedFromDate = fromDate && formatDate(fromDate, intl);
|
||||
const translatedToDate = toDate && formatDate(toDate, intl);
|
||||
if (translatedFromDate && translatedToDate) {
|
||||
return intl.formatMessage(
|
||||
{
|
||||
id: 'date.between',
|
||||
defaultMessage: 'between {from} and {to}',
|
||||
},
|
||||
{from: translatedFromDate, to: translatedToDate},
|
||||
);
|
||||
}
|
||||
if (translatedFromDate) {
|
||||
return intl.formatMessage(
|
||||
{
|
||||
id: 'date.after',
|
||||
defaultMessage: 'after {from}',
|
||||
},
|
||||
{from: translatedFromDate},
|
||||
);
|
||||
}
|
||||
if (translatedToDate) {
|
||||
return intl.formatMessage(
|
||||
{
|
||||
id: 'date.before',
|
||||
defaultMessage: 'before {to}',
|
||||
},
|
||||
{to: translatedToDate},
|
||||
);
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function translateDate(gedcomDate: string, intl: InjectedIntl) {
|
||||
const dateOrRange = getDate(gedcomDate);
|
||||
if (!dateOrRange) {
|
||||
return '';
|
||||
}
|
||||
if (dateOrRange.date) {
|
||||
return formatDate(dateOrRange.date, intl);
|
||||
}
|
||||
if (dateOrRange.dateRange) {
|
||||
return formatDateRage(dateOrRange.dateRange, intl);
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function joinLines(lines: (JSX.Element | string)[]) {
|
||||
return (
|
||||
<>
|
||||
|
||||
4
src/javascript-natural-sort.d.ts
vendored
Normal file
4
src/javascript-natural-sort.d.ts
vendored
Normal file
@ -0,0 +1,4 @@
|
||||
// Data type definitions for the javascript-natural-sort library.
|
||||
declare module 'javascript-natural-sort' {
|
||||
export default function naturalSort(a: string, b: string): number;
|
||||
}
|
||||
71
src/search_index.ts
Normal file
71
src/search_index.ts
Normal file
@ -0,0 +1,71 @@
|
||||
import naturalSort from 'javascript-natural-sort';
|
||||
import lunr from 'lunr';
|
||||
import {GedcomData} from './gedcom_util';
|
||||
import {GedcomEntry} from 'parse-gedcom';
|
||||
|
||||
const MAX_RESULTS = 8;
|
||||
|
||||
export interface SearchResult {
|
||||
id: string;
|
||||
indi: GedcomEntry;
|
||||
}
|
||||
|
||||
export interface SearchIndex {
|
||||
search(input: string): SearchResult[];
|
||||
}
|
||||
|
||||
/** Removes accents from letters, e.g. ó->o, ę->e. */
|
||||
function normalize(input: string) {
|
||||
return input
|
||||
.toLocaleLowerCase()
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.replace(/\u0142/g, 'l'); // Special case: ł is not affected by NFD.
|
||||
}
|
||||
|
||||
/** Comparator to sort by score first, then by id. */
|
||||
function compare(a: lunr.Index.Result, b: lunr.Index.Result) {
|
||||
if (a.score !== b.score) {
|
||||
return a.score - b.score;
|
||||
}
|
||||
return naturalSort(a.ref, b.ref);
|
||||
}
|
||||
|
||||
class LunrSearchIndex implements SearchIndex {
|
||||
private index: lunr.Index;
|
||||
|
||||
public constructor(private gedcom: GedcomData) {
|
||||
this.index = lunr(function() {
|
||||
this.ref('id');
|
||||
this.field('id');
|
||||
this.field('name');
|
||||
this.field('normalizedName');
|
||||
|
||||
for (let id in gedcom.indis) {
|
||||
const name = gedcom.indis[id].tree
|
||||
.filter((entry) => entry.tag === 'NAME')
|
||||
.map((entry) => entry.data)
|
||||
.join(' ');
|
||||
this.add({id, name, normalizedName: normalize(name)});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public search(input: string) {
|
||||
const query = input
|
||||
.split(' ')
|
||||
.filter((s) => !!s)
|
||||
.map((s) => `+${s}*`)
|
||||
.join(' ');
|
||||
const results = this.index.search(query);
|
||||
return results
|
||||
.sort(compare)
|
||||
.slice(0, MAX_RESULTS)
|
||||
.map((result) => ({id: result.ref, indi: this.gedcom.indis[result.ref]}));
|
||||
}
|
||||
}
|
||||
|
||||
/** Builds a search index from data. */
|
||||
export function buildSearchIndex(gedcom: GedcomData): SearchIndex {
|
||||
return new LunrSearchIndex(gedcom);
|
||||
}
|
||||
48
src/search_util.tsx
Normal file
48
src/search_util.tsx
Normal file
@ -0,0 +1,48 @@
|
||||
import * as React from 'react';
|
||||
import {GedcomEntry} from 'parse-gedcom';
|
||||
import {InjectedIntl} from 'react-intl';
|
||||
import {SearchResult} from './search_index';
|
||||
import {translateDate} from './date_util';
|
||||
|
||||
function getNameLine(result: SearchResult) {
|
||||
const nameTag = result.indi.tree.find((entry) => entry.tag === 'NAME');
|
||||
const name =
|
||||
nameTag &&
|
||||
nameTag.data
|
||||
.split('/')
|
||||
.filter((s) => !!s)
|
||||
.join(' ');
|
||||
if (result.id.length > 8) {
|
||||
return name;
|
||||
}
|
||||
return (
|
||||
<>
|
||||
{name} <i>({result.id})</i>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function getDate(indi: GedcomEntry, tag: string, intl: InjectedIntl) {
|
||||
const eventEntry = indi.tree.find((entry) => entry.tag === tag);
|
||||
const dateEntry =
|
||||
eventEntry && eventEntry.tree.find((entry) => entry.tag === 'DATE');
|
||||
return (dateEntry && translateDate(dateEntry.data, intl)) || '';
|
||||
}
|
||||
|
||||
function getDescriptionLine(indi: GedcomEntry, intl: InjectedIntl) {
|
||||
const birthDate = getDate(indi, 'BIRT', intl);
|
||||
const deathDate = getDate(indi, 'DEAT', intl);
|
||||
if (!deathDate) {
|
||||
return birthDate;
|
||||
}
|
||||
return `${birthDate} – ${deathDate}`;
|
||||
}
|
||||
|
||||
/** Produces an object that is displayed in the Semantic UI Search results. */
|
||||
export function displaySearchResult(result: SearchResult, intl: InjectedIntl) {
|
||||
return {
|
||||
id: result.id,
|
||||
title: getNameLine(result),
|
||||
description: getDescriptionLine(result.indi, intl),
|
||||
};
|
||||
}
|
||||
@ -1,8 +1,13 @@
|
||||
import * as queryString from 'query-string';
|
||||
import * as React from 'react';
|
||||
import debounce from 'debounce';
|
||||
import md5 from 'md5';
|
||||
import {analyticsEvent} from './analytics';
|
||||
import {FormattedMessage} from 'react-intl';
|
||||
import {buildSearchIndex, SearchIndex, SearchResult} from './search_index';
|
||||
import {displaySearchResult} from './search_util';
|
||||
import {FormattedMessage, intlShape} from 'react-intl';
|
||||
import {GedcomData} from './gedcom_util';
|
||||
import {IndiInfo} from 'topola';
|
||||
import {Link} from 'react-router-dom';
|
||||
import {RouteComponentProps} from 'react-router-dom';
|
||||
import {
|
||||
@ -14,16 +19,21 @@ import {
|
||||
Input,
|
||||
Form,
|
||||
Dropdown,
|
||||
Search,
|
||||
SearchProps,
|
||||
} from 'semantic-ui-react';
|
||||
|
||||
/** Menus and dialogs state. */
|
||||
interface State {
|
||||
loadUrlDialogOpen: boolean;
|
||||
url?: string;
|
||||
searchResults: SearchResult[];
|
||||
}
|
||||
|
||||
interface Props {
|
||||
showingChart: boolean;
|
||||
gedcom?: GedcomData;
|
||||
onSelection: (indiInfo: IndiInfo) => void;
|
||||
onPrint: () => void;
|
||||
onDownloadPdf: () => void;
|
||||
onDownloadPng: () => void;
|
||||
@ -49,8 +59,13 @@ export class TopBar extends React.Component<
|
||||
RouteComponentProps & Props,
|
||||
State
|
||||
> {
|
||||
state: State = {loadUrlDialogOpen: false};
|
||||
state: State = {
|
||||
loadUrlDialogOpen: false,
|
||||
searchResults: [],
|
||||
};
|
||||
inputRef?: Input;
|
||||
searchRef?: {setValue(value: string): void};
|
||||
searchIndex?: SearchIndex;
|
||||
|
||||
/** Handles the "Upload file" button. */
|
||||
async handleUpload(event: React.SyntheticEvent<HTMLInputElement>) {
|
||||
@ -135,6 +150,44 @@ export class TopBar extends React.Component<
|
||||
);
|
||||
}
|
||||
|
||||
/** On search input change. */
|
||||
handleSearch(input: string | undefined) {
|
||||
if (!input) {
|
||||
return;
|
||||
}
|
||||
const results = this.searchIndex!.search(input).map((result) =>
|
||||
displaySearchResult(result, this.context.intl),
|
||||
);
|
||||
this.setState(Object.assign({}, this.state, {searchResults: results}));
|
||||
}
|
||||
|
||||
/** On search result selected. */
|
||||
handleResultSelect(id: string) {
|
||||
this.props.onSelection({id, generation: 0});
|
||||
this.searchRef!.setValue('');
|
||||
}
|
||||
|
||||
initializeSearchIndex() {
|
||||
if (this.props.gedcom) {
|
||||
this.searchIndex = buildSearchIndex(this.props.gedcom);
|
||||
}
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.initializeSearchIndex();
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps: Props) {
|
||||
if (prevProps.gedcom !== this.props.gedcom) {
|
||||
this.initializeSearchIndex();
|
||||
}
|
||||
}
|
||||
|
||||
/** Make intl appear in this.context. */
|
||||
static contextTypes = {
|
||||
intl: intlShape,
|
||||
};
|
||||
|
||||
render() {
|
||||
const loadFromUrlModal = (
|
||||
<Modal
|
||||
@ -216,6 +269,29 @@ export class TopBar extends React.Component<
|
||||
</Dropdown.Item>
|
||||
</Dropdown.Menu>
|
||||
</Dropdown>
|
||||
<Search
|
||||
onSearchChange={debounce(
|
||||
(_: React.MouseEvent<HTMLElement>, data: SearchProps) =>
|
||||
this.handleSearch(data.value),
|
||||
200,
|
||||
)}
|
||||
onResultSelect={(_, data) => this.handleResultSelect(data.result.id)}
|
||||
results={this.state.searchResults}
|
||||
noResultsMessage={this.context.intl.formatMessage({
|
||||
id: 'menu.search.no_results',
|
||||
defaultMessage: 'No results found',
|
||||
})}
|
||||
placeholder={this.context.intl.formatMessage({
|
||||
id: 'menu.search.placeholder',
|
||||
defaultMessage: 'Search for people',
|
||||
})}
|
||||
selectFirstResult={true}
|
||||
ref={(ref) =>
|
||||
(this.searchRef = (ref as unknown) as {
|
||||
setValue(value: string): void;
|
||||
})
|
||||
}
|
||||
/>
|
||||
</>
|
||||
) : null;
|
||||
|
||||
|
||||
@ -7,6 +7,8 @@
|
||||
"menu.png_file": "Plik PNG",
|
||||
"menu.svg_file": "Plik SVG",
|
||||
"menu.github": "Źródła na GitHub",
|
||||
"menu.search.placeholder": "Szukaj osoby",
|
||||
"menu.search.no_results": "Brak wyników",
|
||||
"intro.title": "Topola Genealogy",
|
||||
"intro.description": "Topola Genealogy pozwala przeglądać drzewo genealogiczne w interaktywny sposób.",
|
||||
"intro.instructions": "Kliknij OTWÓRZ URL lub OTWÓRZ PLIK, aby załadować plik GEDCOM. Większość programów genealogicznych posiada funkcję eksportu do pliku GEDCOM.",
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user