mirror of
https://github.com/PeWu/topola-viewer.git
synced 2025-12-23 18:50:04 +00:00
Sidebar improvements (Collapse/Expand, Mobile view) (#215)
* Extract sidebar to new component * Add sidebar toggle * Fix scrollbars sometimes appearing even at maximum zoom-out
This commit is contained in:
parent
0f0a75a5ec
commit
2a0c963789
90
src/app.tsx
90
src/app.tsx
@ -3,9 +3,14 @@ import queryString from 'query-string';
|
||||
import {useEffect, useState} from 'react';
|
||||
import {FormattedMessage, useIntl} from 'react-intl';
|
||||
import {Navigate, Route, Routes, useLocation, useNavigate} from 'react-router';
|
||||
import {Loader, Message, Portal, Tab} from 'semantic-ui-react';
|
||||
import {
|
||||
Loader,
|
||||
Message,
|
||||
Portal,
|
||||
SidebarPushable,
|
||||
SidebarPusher,
|
||||
} from 'semantic-ui-react';
|
||||
import {IndiInfo} from 'topola';
|
||||
import {Changelog} from './changelog';
|
||||
import {
|
||||
Chart,
|
||||
ChartType,
|
||||
@ -14,15 +19,6 @@ import {
|
||||
downloadSvg,
|
||||
printChart,
|
||||
} from './chart';
|
||||
import {
|
||||
argsToConfig,
|
||||
Config,
|
||||
ConfigPanel,
|
||||
configToArgs,
|
||||
DEFALUT_CONFIG,
|
||||
Ids,
|
||||
Sex,
|
||||
} from './config';
|
||||
import {DataSourceEnum, SourceSelection} from './datasource/data_source';
|
||||
import {EmbeddedDataSource, EmbeddedSourceSpec} from './datasource/embedded';
|
||||
import {
|
||||
@ -38,14 +34,21 @@ import {
|
||||
WikiTreeDataSource,
|
||||
WikiTreeSourceSpec,
|
||||
} from './datasource/wikitree';
|
||||
import {Details} from './details/details';
|
||||
import {DonatsoChart} from './donatso-chart';
|
||||
import {Intro} from './intro';
|
||||
import {TopBar} from './menu/top_bar';
|
||||
import {
|
||||
argsToConfig,
|
||||
Config,
|
||||
configToArgs,
|
||||
DEFALUT_CONFIG,
|
||||
Ids,
|
||||
Sex,
|
||||
} from './sidepanel/config/config';
|
||||
import {SidePanel} from './sidepanel/side-panel';
|
||||
import {analyticsEvent} from './util/analytics';
|
||||
import {getI18nMessage} from './util/error_i18n';
|
||||
import {idToIndiMap, TopolaData} from './util/gedcom_util';
|
||||
import {Media} from './util/media';
|
||||
|
||||
/**
|
||||
* Load GEDCOM URL from VITE_STATIC_URL environment variable.
|
||||
@ -241,7 +244,7 @@ export function App() {
|
||||
}
|
||||
}
|
||||
|
||||
function toggleDetails(config: Config, data: TopolaData | undefined) {
|
||||
function updateChartWithConfig(config: Config, data: TopolaData | undefined) {
|
||||
if (data === undefined) {
|
||||
return;
|
||||
}
|
||||
@ -254,6 +257,14 @@ export function App() {
|
||||
});
|
||||
}
|
||||
|
||||
function onToggleSidePanel() {
|
||||
const newShowSidePanel = !showSidePanel;
|
||||
setShowSidePanel(newShowSidePanel);
|
||||
updateUrl({
|
||||
sidePanel: newShowSidePanel ? 'true' : 'false',
|
||||
});
|
||||
}
|
||||
|
||||
/** Sets error message after data load failure. */
|
||||
function setErrorMessage(message: string) {
|
||||
setError(message);
|
||||
@ -362,7 +373,7 @@ export function App() {
|
||||
const data = await loadData(args.sourceSpec, args.selection);
|
||||
// Set state with data.
|
||||
setData(data);
|
||||
toggleDetails(args.config, data);
|
||||
updateChartWithConfig(args.config, data);
|
||||
setShowSidePanel(args.showSidePanel);
|
||||
setState(AppState.SHOWING_CHART);
|
||||
} catch (error: any) {
|
||||
@ -510,33 +521,6 @@ export function App() {
|
||||
case AppState.SHOWING_CHART:
|
||||
case AppState.LOADING_MORE:
|
||||
const updatedSelection = getSelection(data!.chartData, selection);
|
||||
const sidePanelTabs = [
|
||||
{
|
||||
menuItem: intl.formatMessage({
|
||||
id: 'tab.info',
|
||||
defaultMessage: 'Info',
|
||||
}),
|
||||
render: () => (
|
||||
<Details gedcom={data!.gedcom} indi={updatedSelection.id} />
|
||||
),
|
||||
},
|
||||
{
|
||||
menuItem: intl.formatMessage({
|
||||
id: 'tab.settings',
|
||||
defaultMessage: 'Settings',
|
||||
}),
|
||||
render: () => (
|
||||
<ConfigPanel
|
||||
config={config}
|
||||
onChange={(config) => {
|
||||
setConfig(config);
|
||||
toggleDetails(config, data);
|
||||
updateUrl(configToArgs(config));
|
||||
}}
|
||||
/>
|
||||
),
|
||||
},
|
||||
];
|
||||
return (
|
||||
<div id="content">
|
||||
<ErrorPopup
|
||||
@ -547,13 +531,21 @@ export function App() {
|
||||
{state === AppState.LOADING_MORE ? (
|
||||
<Loader active size="small" className="loading-more" />
|
||||
) : null}
|
||||
{renderChart(updatedSelection)}
|
||||
{showSidePanel ? (
|
||||
<Media greaterThanOrEqual="large" className="sidePanel">
|
||||
<Tab panes={sidePanelTabs} />
|
||||
</Media>
|
||||
) : null}
|
||||
<Changelog />
|
||||
<SidebarPushable>
|
||||
<SidePanel
|
||||
data={data!}
|
||||
selectedIndiId={updatedSelection.id}
|
||||
config={config}
|
||||
expanded={showSidePanel}
|
||||
onToggle={onToggleSidePanel}
|
||||
onConfigChange={(config) => {
|
||||
setConfig(config);
|
||||
updateChartWithConfig(config, data);
|
||||
updateUrl(configToArgs(config));
|
||||
}}
|
||||
/>
|
||||
<SidebarPusher>{renderChart(updatedSelection)}</SidebarPusher>
|
||||
</SidebarPushable>
|
||||
</div>
|
||||
);
|
||||
|
||||
|
||||
@ -14,6 +14,7 @@ import {useEffect, useRef} from 'react';
|
||||
import {IntlShape, useIntl} from 'react-intl';
|
||||
import {
|
||||
ChartHandle,
|
||||
ChartInfo,
|
||||
CircleRenderer,
|
||||
createChart,
|
||||
DetailedRenderer,
|
||||
@ -24,7 +25,7 @@ import {
|
||||
RelativesChart,
|
||||
ChartColors as TopolaChartColors,
|
||||
} from 'topola';
|
||||
import {ChartColors, Ids, Sex} from './config';
|
||||
import {ChartColors, Ids, Sex} from './sidepanel/config/config';
|
||||
import {Media} from './util/media';
|
||||
import {usePrevious} from './util/previous-hook';
|
||||
|
||||
@ -252,6 +253,40 @@ function getRendererType(chartType: ChartType) {
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns the element’s usable width and height by subtracting the assumed scrollbar size. */
|
||||
function getScrollbarAwareSize(
|
||||
element: Element,
|
||||
scrollbarSize = 20,
|
||||
): [number, number] {
|
||||
const htmlElement = element as HTMLElement;
|
||||
return [
|
||||
htmlElement.clientWidth - scrollbarSize,
|
||||
htmlElement.clientHeight - scrollbarSize,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates the allowed zoom scale range.
|
||||
* Sets the minimum scale so the chart cannot zoom out beyond full visibility,
|
||||
* and fixes the maximum scale at 2.
|
||||
*/
|
||||
function calculateScaleExtent(
|
||||
parent: Element,
|
||||
scale: number,
|
||||
chartInfo: ChartInfo,
|
||||
): [number, number] {
|
||||
const [availWidth, availHeight] = getScrollbarAwareSize(parent);
|
||||
|
||||
const zoomOutFactor = min([
|
||||
1,
|
||||
scale,
|
||||
availWidth / chartInfo.size[0],
|
||||
availHeight / chartInfo.size[1],
|
||||
])!;
|
||||
|
||||
return [max([0.1, zoomOutFactor])!, 2];
|
||||
}
|
||||
|
||||
export interface ChartProps {
|
||||
data: JsonGedcomData;
|
||||
selection: IndiInfo;
|
||||
@ -328,20 +363,18 @@ class ChartWrapper {
|
||||
});
|
||||
const svg = select('#chartSvg');
|
||||
const parent = select('#svgContainer').node() as Element;
|
||||
|
||||
const scale = zoomTransform(parent).k;
|
||||
const zoomOutFactor = min([
|
||||
1,
|
||||
const extent: [number, number] = calculateScaleExtent(
|
||||
parent,
|
||||
scale,
|
||||
parent.clientWidth / chartInfo.size[0],
|
||||
parent.clientHeight / chartInfo.size[1],
|
||||
])!;
|
||||
const extent: [number, number] = [max([0.1, zoomOutFactor])!, 2];
|
||||
chartInfo,
|
||||
);
|
||||
|
||||
this.zoomBehavior = zoom()
|
||||
.scaleExtent(extent)
|
||||
.translateExtent([[0, 0], chartInfo.size])
|
||||
.on('zoom', (event) => zoomed(chartInfo.size, event));
|
||||
|
||||
select(parent).on('scroll', scrolled).call(this.zoomBehavior);
|
||||
|
||||
const scrollTopTween = (scrollTop: number) => {
|
||||
|
||||
105
src/index.css
105
src/index.css
@ -14,9 +14,15 @@ body, html {
|
||||
|
||||
#content {
|
||||
flex: 1 1 auto;
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#introContent {
|
||||
flex: 1 1 auto;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
#svgContainer {
|
||||
@ -24,13 +30,6 @@ body, html {
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.sidePanel {
|
||||
flex: 0 0 320px;
|
||||
overflow: auto;
|
||||
border-left: solid #ccc 1px;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none;
|
||||
}
|
||||
@ -147,10 +146,12 @@ div.zoom {
|
||||
.details {
|
||||
padding: 15px 0px;
|
||||
border-bottom: 1px solid rgba(34,36,38,.15);
|
||||
overflow-y: auto;
|
||||
height: calc(100% - 43px);
|
||||
}
|
||||
|
||||
.details .ui.items .item .content {
|
||||
padding: 0 15px;
|
||||
padding: 0 15px 0 25px;
|
||||
}
|
||||
|
||||
.details .item-header {
|
||||
@ -166,8 +167,6 @@ div.zoom {
|
||||
}
|
||||
|
||||
.details .person-image {
|
||||
max-width: 289px;
|
||||
width: 289px;
|
||||
padding: 0 10px;
|
||||
}
|
||||
|
||||
@ -278,3 +277,85 @@ div.zoom {
|
||||
#dotatsoSvgContainer .link {
|
||||
stroke: black;
|
||||
}
|
||||
|
||||
#content .pusher {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#content #sidebar.wide ~ .pusher {
|
||||
width: calc(100% - 350px);
|
||||
transform: none;
|
||||
}
|
||||
|
||||
#content #sidebar.very.thin ~ .pusher {
|
||||
width: calc(100% - 60px);
|
||||
}
|
||||
|
||||
#sidebar {
|
||||
overflow: hidden;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
#sideTabs {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
#sideTabs > .ui.tabular.menu{
|
||||
padding-left: 60px;
|
||||
}
|
||||
|
||||
#sideToggle {
|
||||
position: absolute;
|
||||
background: inherit;
|
||||
top: 0;
|
||||
height: 42px;
|
||||
border-radius: 0;
|
||||
width: 60px;
|
||||
}
|
||||
|
||||
#sideToggle:hover {
|
||||
background-color: #ddd;
|
||||
}
|
||||
|
||||
.collapsed-details {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.collapsed-details .vertical-name {
|
||||
writing-mode: vertical-rl;
|
||||
transform: rotate(180deg);
|
||||
transform-origin: center;
|
||||
font-size: 1.2rem;
|
||||
font-weight: bold;
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
#sidebar.wide {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
#sidebar.very.thin {
|
||||
width: 35px;
|
||||
}
|
||||
|
||||
#content #sidebar.wide ~ .pusher {
|
||||
width: 0;
|
||||
}
|
||||
|
||||
#content #sidebar.very.thin ~ .pusher {
|
||||
width: calc(100% - 35px);
|
||||
}
|
||||
|
||||
#sidebar.very.thin #sideToggle {
|
||||
width: 35px;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@ -141,7 +141,7 @@ function Contents() {
|
||||
/** The intro page. */
|
||||
export function Intro() {
|
||||
return (
|
||||
<div id="content">
|
||||
<div id="introContent">
|
||||
<div className="backgroundImage" />
|
||||
<Card className="intro">
|
||||
<Card.Content as={Media} greaterThanOrEqual="large">
|
||||
|
||||
26
src/sidepanel/details/collapsed-details.tsx
Normal file
26
src/sidepanel/details/collapsed-details.tsx
Normal file
@ -0,0 +1,26 @@
|
||||
import {FormattedMessage} from 'react-intl';
|
||||
import {GedcomData} from '../../util/gedcom_util';
|
||||
|
||||
interface Props {
|
||||
gedcom: GedcomData;
|
||||
indi: string;
|
||||
}
|
||||
|
||||
export function CollapsedDetails(props: Props) {
|
||||
const entries = props.gedcom.indis[props.indi].tree;
|
||||
const nameEntry = entries.find((entry) => entry.tag === 'NAME');
|
||||
|
||||
const fullName = nameEntry?.data.replaceAll('/', '') ?? '';
|
||||
|
||||
return (
|
||||
<div className="collapsed-details">
|
||||
{fullName ? (
|
||||
<span className="vertical-name">{fullName}</span>
|
||||
) : (
|
||||
<span className="vertical-name">
|
||||
<FormattedMessage id="name.unknown_name" defaultMessage="N.N." />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -10,7 +10,7 @@ import {
|
||||
getImageFileEntry,
|
||||
getNonImageFileEntry,
|
||||
mapToSource,
|
||||
} from '../util/gedcom_util';
|
||||
} from '../../util/gedcom_util';
|
||||
import {AdditionalFiles} from './additional-files';
|
||||
import {ALL_SUPPORTED_EVENT_TYPES, Events} from './events';
|
||||
import {MultilineText} from './multiline-text';
|
||||
@ -9,7 +9,7 @@ import {
|
||||
Popup,
|
||||
Tab,
|
||||
} from 'semantic-ui-react';
|
||||
import {Source} from '../util/gedcom_util';
|
||||
import {Source} from '../../util/gedcom_util';
|
||||
import {AdditionalFiles, FileEntry} from './additional-files';
|
||||
import {MultilineText} from './multiline-text';
|
||||
import {Sources} from './sources';
|
||||
@ -5,8 +5,8 @@ import {FormattedMessage, IntlShape, useIntl} from 'react-intl';
|
||||
import {Link, useLocation} from 'react-router';
|
||||
import {Header, Item} from 'semantic-ui-react';
|
||||
import {DateOrRange, getDate} from 'topola';
|
||||
import {calcAge} from '../util/age_util';
|
||||
import {compareDates, formatDateOrRange} from '../util/date_util';
|
||||
import {calcAge} from '../../util/age_util';
|
||||
import {compareDates, formatDateOrRange} from '../../util/date_util';
|
||||
import {
|
||||
dereference,
|
||||
GedcomData,
|
||||
@ -20,7 +20,7 @@ import {
|
||||
resolveDate,
|
||||
resolveType,
|
||||
Source,
|
||||
} from '../util/gedcom_util';
|
||||
} from '../../util/gedcom_util';
|
||||
import {FileEntry} from './additional-files';
|
||||
import {EventExtras, Image} from './event-extras';
|
||||
import {TranslatedTag} from './translated-tag';
|
||||
@ -1,8 +1,8 @@
|
||||
import {useIntl} from 'react-intl';
|
||||
import Linkify from 'react-linkify';
|
||||
import {List} from 'semantic-ui-react';
|
||||
import {formatDateOrRange} from '../util/date_util';
|
||||
import {Source} from '../util/gedcom_util';
|
||||
import {formatDateOrRange} from '../../util/date_util';
|
||||
import {Source} from '../../util/gedcom_util';
|
||||
|
||||
interface Props {
|
||||
sources?: Source[];
|
||||
63
src/sidepanel/side-panel.tsx
Normal file
63
src/sidepanel/side-panel.tsx
Normal file
@ -0,0 +1,63 @@
|
||||
import {useIntl} from 'react-intl';
|
||||
import {Button, Icon, Sidebar, Tab} from 'semantic-ui-react';
|
||||
import {TopolaData} from '../util/gedcom_util';
|
||||
import {Config, ConfigPanel} from './config/config';
|
||||
import {CollapsedDetails} from './details/collapsed-details';
|
||||
import {Details} from './details/details';
|
||||
|
||||
interface SidePanelProps {
|
||||
data: TopolaData;
|
||||
selectedIndiId: string;
|
||||
config: Config;
|
||||
onConfigChange: (config: Config) => void;
|
||||
expanded: boolean;
|
||||
onToggle: () => void;
|
||||
}
|
||||
|
||||
export function SidePanel({
|
||||
data,
|
||||
selectedIndiId,
|
||||
config,
|
||||
onConfigChange,
|
||||
expanded,
|
||||
onToggle,
|
||||
}: SidePanelProps) {
|
||||
const intl = useIntl();
|
||||
|
||||
const tabs = [
|
||||
{
|
||||
menuItem: intl.formatMessage({
|
||||
id: 'tab.info',
|
||||
defaultMessage: 'Info',
|
||||
}),
|
||||
render: () => <Details gedcom={data.gedcom} indi={selectedIndiId} />,
|
||||
},
|
||||
{
|
||||
menuItem: intl.formatMessage({
|
||||
id: 'tab.settings',
|
||||
defaultMessage: 'Settings',
|
||||
}),
|
||||
render: () => <ConfigPanel config={config} onChange={onConfigChange} />,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Sidebar
|
||||
id="sidebar"
|
||||
animation="overlay"
|
||||
icon="labeled"
|
||||
width={expanded ? 'wide' : 'very thin'}
|
||||
direction="right"
|
||||
visible={true}
|
||||
>
|
||||
{expanded ? (
|
||||
<Tab id="sideTabs" panes={tabs} />
|
||||
) : (
|
||||
<CollapsedDetails gedcom={data.gedcom} indi={selectedIndiId} />
|
||||
)}
|
||||
<Button id="sideToggle" icon size="mini" onClick={() => onToggle()}>
|
||||
<Icon size="large" name={expanded ? 'arrow right' : 'arrow left'} />
|
||||
</Button>
|
||||
</Sidebar>
|
||||
);
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user