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:
czifumasa 2025-09-09 10:41:45 +02:00 committed by GitHub
parent 0f0a75a5ec
commit 2a0c963789
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 272 additions and 77 deletions

View File

@ -3,9 +3,14 @@ import queryString from 'query-string';
import {useEffect, useState} from 'react'; import {useEffect, useState} from 'react';
import {FormattedMessage, useIntl} from 'react-intl'; import {FormattedMessage, useIntl} from 'react-intl';
import {Navigate, Route, Routes, useLocation, useNavigate} from 'react-router'; 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 {IndiInfo} from 'topola';
import {Changelog} from './changelog';
import { import {
Chart, Chart,
ChartType, ChartType,
@ -14,15 +19,6 @@ import {
downloadSvg, downloadSvg,
printChart, printChart,
} from './chart'; } from './chart';
import {
argsToConfig,
Config,
ConfigPanel,
configToArgs,
DEFALUT_CONFIG,
Ids,
Sex,
} from './config';
import {DataSourceEnum, SourceSelection} from './datasource/data_source'; import {DataSourceEnum, SourceSelection} from './datasource/data_source';
import {EmbeddedDataSource, EmbeddedSourceSpec} from './datasource/embedded'; import {EmbeddedDataSource, EmbeddedSourceSpec} from './datasource/embedded';
import { import {
@ -38,14 +34,21 @@ import {
WikiTreeDataSource, WikiTreeDataSource,
WikiTreeSourceSpec, WikiTreeSourceSpec,
} from './datasource/wikitree'; } from './datasource/wikitree';
import {Details} from './details/details';
import {DonatsoChart} from './donatso-chart'; import {DonatsoChart} from './donatso-chart';
import {Intro} from './intro'; import {Intro} from './intro';
import {TopBar} from './menu/top_bar'; 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 {analyticsEvent} from './util/analytics';
import {getI18nMessage} from './util/error_i18n'; import {getI18nMessage} from './util/error_i18n';
import {idToIndiMap, TopolaData} from './util/gedcom_util'; import {idToIndiMap, TopolaData} from './util/gedcom_util';
import {Media} from './util/media';
/** /**
* Load GEDCOM URL from VITE_STATIC_URL environment variable. * 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) { if (data === undefined) {
return; 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. */ /** Sets error message after data load failure. */
function setErrorMessage(message: string) { function setErrorMessage(message: string) {
setError(message); setError(message);
@ -362,7 +373,7 @@ export function App() {
const data = await loadData(args.sourceSpec, args.selection); const data = await loadData(args.sourceSpec, args.selection);
// Set state with data. // Set state with data.
setData(data); setData(data);
toggleDetails(args.config, data); updateChartWithConfig(args.config, data);
setShowSidePanel(args.showSidePanel); setShowSidePanel(args.showSidePanel);
setState(AppState.SHOWING_CHART); setState(AppState.SHOWING_CHART);
} catch (error: any) { } catch (error: any) {
@ -510,33 +521,6 @@ export function App() {
case AppState.SHOWING_CHART: case AppState.SHOWING_CHART:
case AppState.LOADING_MORE: case AppState.LOADING_MORE:
const updatedSelection = getSelection(data!.chartData, selection); 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 ( return (
<div id="content"> <div id="content">
<ErrorPopup <ErrorPopup
@ -547,13 +531,21 @@ export function App() {
{state === AppState.LOADING_MORE ? ( {state === AppState.LOADING_MORE ? (
<Loader active size="small" className="loading-more" /> <Loader active size="small" className="loading-more" />
) : null} ) : null}
{renderChart(updatedSelection)} <SidebarPushable>
{showSidePanel ? ( <SidePanel
<Media greaterThanOrEqual="large" className="sidePanel"> data={data!}
<Tab panes={sidePanelTabs} /> selectedIndiId={updatedSelection.id}
</Media> config={config}
) : null} expanded={showSidePanel}
<Changelog /> onToggle={onToggleSidePanel}
onConfigChange={(config) => {
setConfig(config);
updateChartWithConfig(config, data);
updateUrl(configToArgs(config));
}}
/>
<SidebarPusher>{renderChart(updatedSelection)}</SidebarPusher>
</SidebarPushable>
</div> </div>
); );

View File

@ -14,6 +14,7 @@ import {useEffect, useRef} from 'react';
import {IntlShape, useIntl} from 'react-intl'; import {IntlShape, useIntl} from 'react-intl';
import { import {
ChartHandle, ChartHandle,
ChartInfo,
CircleRenderer, CircleRenderer,
createChart, createChart,
DetailedRenderer, DetailedRenderer,
@ -24,7 +25,7 @@ import {
RelativesChart, RelativesChart,
ChartColors as TopolaChartColors, ChartColors as TopolaChartColors,
} from 'topola'; } from 'topola';
import {ChartColors, Ids, Sex} from './config'; import {ChartColors, Ids, Sex} from './sidepanel/config/config';
import {Media} from './util/media'; import {Media} from './util/media';
import {usePrevious} from './util/previous-hook'; import {usePrevious} from './util/previous-hook';
@ -252,6 +253,40 @@ function getRendererType(chartType: ChartType) {
} }
} }
/** Returns the elements 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 { export interface ChartProps {
data: JsonGedcomData; data: JsonGedcomData;
selection: IndiInfo; selection: IndiInfo;
@ -328,20 +363,18 @@ class ChartWrapper {
}); });
const svg = select('#chartSvg'); const svg = select('#chartSvg');
const parent = select('#svgContainer').node() as Element; const parent = select('#svgContainer').node() as Element;
const scale = zoomTransform(parent).k; const scale = zoomTransform(parent).k;
const zoomOutFactor = min([ const extent: [number, number] = calculateScaleExtent(
1, parent,
scale, scale,
parent.clientWidth / chartInfo.size[0], chartInfo,
parent.clientHeight / chartInfo.size[1], );
])!;
const extent: [number, number] = [max([0.1, zoomOutFactor])!, 2];
this.zoomBehavior = zoom() this.zoomBehavior = zoom()
.scaleExtent(extent) .scaleExtent(extent)
.translateExtent([[0, 0], chartInfo.size]) .translateExtent([[0, 0], chartInfo.size])
.on('zoom', (event) => zoomed(chartInfo.size, event)); .on('zoom', (event) => zoomed(chartInfo.size, event));
select(parent).on('scroll', scrolled).call(this.zoomBehavior); select(parent).on('scroll', scrolled).call(this.zoomBehavior);
const scrollTopTween = (scrollTop: number) => { const scrollTopTween = (scrollTop: number) => {

View File

@ -14,9 +14,15 @@ body, html {
#content { #content {
flex: 1 1 auto; flex: 1 1 auto;
display: flex;
overflow: hidden;
height: 100%; height: 100%;
overflow: hidden;
}
#introContent {
flex: 1 1 auto;
height: 100%;
overflow: hidden;
display: flex;
} }
#svgContainer { #svgContainer {
@ -24,13 +30,6 @@ body, html {
overflow: auto; overflow: auto;
} }
.sidePanel {
flex: 0 0 320px;
overflow: auto;
border-left: solid #ccc 1px;
overflow-x: hidden;
}
.hidden { .hidden {
display: none; display: none;
} }
@ -147,10 +146,12 @@ div.zoom {
.details { .details {
padding: 15px 0px; padding: 15px 0px;
border-bottom: 1px solid rgba(34,36,38,.15); border-bottom: 1px solid rgba(34,36,38,.15);
overflow-y: auto;
height: calc(100% - 43px);
} }
.details .ui.items .item .content { .details .ui.items .item .content {
padding: 0 15px; padding: 0 15px 0 25px;
} }
.details .item-header { .details .item-header {
@ -166,8 +167,6 @@ div.zoom {
} }
.details .person-image { .details .person-image {
max-width: 289px;
width: 289px;
padding: 0 10px; padding: 0 10px;
} }
@ -278,3 +277,85 @@ div.zoom {
#dotatsoSvgContainer .link { #dotatsoSvgContainer .link {
stroke: black; 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;
}
}

View File

@ -141,7 +141,7 @@ function Contents() {
/** The intro page. */ /** The intro page. */
export function Intro() { export function Intro() {
return ( return (
<div id="content"> <div id="introContent">
<div className="backgroundImage" /> <div className="backgroundImage" />
<Card className="intro"> <Card className="intro">
<Card.Content as={Media} greaterThanOrEqual="large"> <Card.Content as={Media} greaterThanOrEqual="large">

View 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>
);
}

View File

@ -10,7 +10,7 @@ import {
getImageFileEntry, getImageFileEntry,
getNonImageFileEntry, getNonImageFileEntry,
mapToSource, mapToSource,
} from '../util/gedcom_util'; } from '../../util/gedcom_util';
import {AdditionalFiles} from './additional-files'; import {AdditionalFiles} from './additional-files';
import {ALL_SUPPORTED_EVENT_TYPES, Events} from './events'; import {ALL_SUPPORTED_EVENT_TYPES, Events} from './events';
import {MultilineText} from './multiline-text'; import {MultilineText} from './multiline-text';

View File

@ -9,7 +9,7 @@ import {
Popup, Popup,
Tab, Tab,
} from 'semantic-ui-react'; } from 'semantic-ui-react';
import {Source} from '../util/gedcom_util'; import {Source} from '../../util/gedcom_util';
import {AdditionalFiles, FileEntry} from './additional-files'; import {AdditionalFiles, FileEntry} from './additional-files';
import {MultilineText} from './multiline-text'; import {MultilineText} from './multiline-text';
import {Sources} from './sources'; import {Sources} from './sources';

View File

@ -5,8 +5,8 @@ import {FormattedMessage, IntlShape, useIntl} from 'react-intl';
import {Link, useLocation} from 'react-router'; import {Link, useLocation} from 'react-router';
import {Header, Item} from 'semantic-ui-react'; import {Header, Item} from 'semantic-ui-react';
import {DateOrRange, getDate} from 'topola'; import {DateOrRange, getDate} from 'topola';
import {calcAge} from '../util/age_util'; import {calcAge} from '../../util/age_util';
import {compareDates, formatDateOrRange} from '../util/date_util'; import {compareDates, formatDateOrRange} from '../../util/date_util';
import { import {
dereference, dereference,
GedcomData, GedcomData,
@ -20,7 +20,7 @@ import {
resolveDate, resolveDate,
resolveType, resolveType,
Source, Source,
} from '../util/gedcom_util'; } from '../../util/gedcom_util';
import {FileEntry} from './additional-files'; import {FileEntry} from './additional-files';
import {EventExtras, Image} from './event-extras'; import {EventExtras, Image} from './event-extras';
import {TranslatedTag} from './translated-tag'; import {TranslatedTag} from './translated-tag';

View File

@ -1,8 +1,8 @@
import {useIntl} from 'react-intl'; import {useIntl} from 'react-intl';
import Linkify from 'react-linkify'; import Linkify from 'react-linkify';
import {List} from 'semantic-ui-react'; import {List} from 'semantic-ui-react';
import {formatDateOrRange} from '../util/date_util'; import {formatDateOrRange} from '../../util/date_util';
import {Source} from '../util/gedcom_util'; import {Source} from '../../util/gedcom_util';
interface Props { interface Props {
sources?: Source[]; sources?: Source[];

View 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>
);
}