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 {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>
);

View File

@ -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 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 {
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) => {

View File

@ -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;
}
}

View File

@ -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">

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,
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';

View File

@ -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';

View File

@ -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';

View File

@ -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[];

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