Files
editor/src/components/LayerList.tsx
T
Nikita Maximilian Grimm 1301dc03e7 Fix layer group expansion state after deletion (#2146)
Deleting a layer changes the starting indices of later groups, so their
stored expansion state no longer matches. Preserve that state through
surviving layer IDs when rebuilding the group keys.

This keeps expanded groups open after deleting an earlier layer or the
first layer of a group. Separate groups with the same prefix retain
their own states; if deletion joins two groups, the merged group stays
expanded when either was expanded.

- Fixes #1781.

Adds five deletion regression tests, a small style fixture, and a
changelog entry.

Validation:

- All 33 layer-list E2E tests and 50 unit tests pass.
- Lint and the production build, including TypeScript checking, pass.
- Manually checked OSM Liberty: all 15 groups remain expanded after
deleting the background layer.
- The full E2E suite reports 160 passing tests and one failure in
`modals > open > upload` (`Could not get styleItem from localStorage`).
The same failure reproduces with the original production code restored.

---------

Co-authored-by: Harel M <harel.mazor@gmail.com>
2026-09-08 18:33:00 +00:00

396 lines
12 KiB
TypeScript

import React, {type JSX} from "react";
import classnames from "classnames";
import lodash from "lodash";
import {
DndContext,
PointerSensor,
useSensor,
useSensors,
closestCenter,
type DragEndEvent,
} from "@dnd-kit/core";
import {
SortableContext,
verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import { LayerListGroup } from "./LayerListGroup";
import { LayerListItem } from "./LayerListItem";
import { ModalAdd } from "./modals/ModalAdd";
import type {LayerSpecification, SourceSpecification} from "maplibre-gl";
import { generateUniqueId } from "../libs/document-uid";
import { findClosestCommonPrefix, layerPrefix } from "../libs/layer";
import { type WithTranslation, withTranslation } from "react-i18next";
import { type MappedError, type OnMoveLayerCallback } from "../libs/definitions";
type LayerListContainerProps = {
layers: LayerSpecification[]
selectedLayerIndex: number
onLayersChange(layers: LayerSpecification[]): unknown
onLayerSelect(index: number): void;
onLayerDestroy?(...args: unknown[]): unknown
onLayerCopy(...args: unknown[]): unknown
onLayerVisibilityToggle(...args: unknown[]): unknown
sources: Record<string, SourceSpecification & {layers: string[]}>;
errors: MappedError[]
};
type LayerListContainerInternalProps = LayerListContainerProps & WithTranslation;
type LayerListContainerState = {
collapsedGroups: {[ket: string]: boolean}
areAllGroupsExpanded: boolean
keys: {[key: string]: number}
isOpen: {[key: string]: boolean}
};
// List of collapsible layer editors
class LayerListContainerInternal extends React.Component<LayerListContainerInternalProps, LayerListContainerState> {
static defaultProps = {
onLayerSelect: () => {},
};
selectedItemRef: React.RefObject<any>;
scrollContainerRef: React.RefObject<HTMLElement | null>;
constructor(props: LayerListContainerInternalProps) {
super(props);
this.selectedItemRef = React.createRef();
this.scrollContainerRef = React.createRef();
this.state = {
collapsedGroups: {},
areAllGroupsExpanded: false,
keys: {
add: +generateUniqueId(),
},
isOpen: {
add: false,
}
};
}
toggleModal(modalName: string) {
this.setState({
keys: {
...this.state.keys,
[modalName]: +generateUniqueId(),
},
isOpen: {
...this.state.isOpen,
[modalName]: !this.state.isOpen[modalName]
}
});
}
toggleLayers = () => {
let idx = 0;
const newGroups: {[key:string]: boolean} = {};
this.groupedLayers().forEach(layers => {
const groupPrefix = layerPrefix(layers[0].id);
const lookupKey = [groupPrefix, idx].join("-");
if (layers.length > 1) {
newGroups[lookupKey] = this.state.areAllGroupsExpanded;
}
layers.forEach((_layer) => {
idx += 1;
});
});
this.setState({
collapsedGroups: newGroups,
areAllGroupsExpanded: !this.state.areAllGroupsExpanded
});
};
groupedLayers(layers = this.props.layers): (LayerSpecification & {key: string})[][] {
const groups = [];
const layerIdCount = new Map();
for (let i = 0; i < layers.length; i++) {
const origLayer = layers[i];
const previousLayer = layers[i-1];
layerIdCount.set(origLayer.id,
layerIdCount.has(origLayer.id) ? layerIdCount.get(origLayer.id) + 1 : 0
);
const layer = {
...origLayer,
key: `layers-list-${origLayer.id}-${layerIdCount.get(origLayer.id)}`,
};
if(previousLayer && layerPrefix(previousLayer.id) == layerPrefix(layer.id)) {
const lastGroup = groups[groups.length - 1];
lastGroup.push(layer);
} else {
groups.push([layer]);
}
}
return groups;
}
toggleLayerGroup(groupPrefix: string, idx: number) {
const lookupKey = [groupPrefix, idx].join("-");
const newGroups = { ...this.state.collapsedGroups };
if(lookupKey in this.state.collapsedGroups) {
newGroups[lookupKey] = !this.state.collapsedGroups[lookupKey];
} else {
newGroups[lookupKey] = false;
}
this.setState({
collapsedGroups: newGroups
});
}
isCollapsed(groupPrefix: string, idx: number) {
const collapsed = this.state.collapsedGroups[[groupPrefix, idx].join("-")];
return collapsed === undefined ? true : collapsed;
}
shouldComponentUpdate (nextProps: LayerListContainerProps, nextState: LayerListContainerState) {
// Always update on state change
if (this.state !== nextState) {
return true;
}
// This component tree only requires id and visibility from the layers
// objects
function getRequiredProps(layer: LayerSpecification) {
const out: {id: string, layout?: { visibility: any}} = {
id: layer.id,
};
if (layer.layout) {
out.layout = {
visibility: layer.layout.visibility
};
}
return out;
}
const layersEqual = lodash.isEqual(
nextProps.layers.map(getRequiredProps),
this.props.layers.map(getRequiredProps),
);
function withoutLayers(props: LayerListContainerProps) {
const out = {
...props
} as LayerListContainerProps & { layers?: any };
delete out["layers"];
return out;
}
// Compare the props without layers because we've already compared them
// efficiently above.
const propsEqual = lodash.isEqual(
withoutLayers(this.props),
withoutLayers(nextProps)
);
const propsChanged = !(layersEqual && propsEqual);
return propsChanged;
}
/**
* Preserves group state through surviving layer IDs when group indices change.
* If deletion joins groups, the result stays expanded when either group was.
*/
private preserveLayerGroupState(previousLayers: LayerSpecification[]) {
if (previousLayers === this.props.layers) return;
const collapsedByLayer = new Map<string, boolean>();
let idx = 0;
for (const layers of this.groupedLayers(previousLayers)) {
const collapsed = this.isCollapsed(layerPrefix(layers[0].id), idx);
for (const layer of layers) {
collapsedByLayer.set(layer.id, collapsed);
}
idx += layers.length;
}
const collapsedGroups: {[key: string]: boolean} = {};
idx = 0;
for (const layers of this.groupedLayers()) {
const lookupKey = [layerPrefix(layers[0].id), idx].join("-");
collapsedGroups[lookupKey] = layers.every(layer => collapsedByLayer.get(layer.id) !== false);
idx += layers.length;
}
if (!lodash.isEqual(collapsedGroups, this.state.collapsedGroups)) {
this.setState({ collapsedGroups });
}
}
componentDidUpdate (prevProps: LayerListContainerProps) {
this.preserveLayerGroupState(prevProps.layers);
if (prevProps.selectedLayerIndex !== this.props.selectedLayerIndex) {
const selectedItemNode = this.selectedItemRef.current;
if (selectedItemNode && selectedItemNode.node) {
const target = selectedItemNode.node;
const options = {
root: this.scrollContainerRef.current,
threshold: 1.0
};
const observer = new IntersectionObserver(entries => {
observer.unobserve(target);
if (entries.length > 0 && entries[0].intersectionRatio < 1) {
target.scrollIntoView();
}
}, options);
observer.observe(target);
}
}
}
render() {
const listItems: JSX.Element[] = [];
let idx = 0;
const layersByGroup = this.groupedLayers();
layersByGroup.forEach(layers => {
const groupPrefix = layerPrefix(layers[0].id);
if(layers.length > 1) {
const grp = <LayerListGroup
data-wd-key={[groupPrefix, idx].join("-")}
aria-controls={layers.map(l => l.key).join(" ")}
key={`group-${groupPrefix}-${idx}`}
title={groupPrefix}
isActive={!this.isCollapsed(groupPrefix, idx) || idx === this.props.selectedLayerIndex}
onActiveToggle={this.toggleLayerGroup.bind(this, groupPrefix, idx)}
/>;
listItems.push(grp);
}
layers.forEach((layer, idxInGroup) => {
const groupIdx = findClosestCommonPrefix(this.props.layers, idx);
const layerError = this.props.errors.find(error => {
return (
error.parsed &&
error.parsed.type === "layer" &&
error.parsed.data.index == idx
);
});
const additionalProps: {ref?: React.RefObject<any>} = {};
if (idx === this.props.selectedLayerIndex) {
additionalProps.ref = this.selectedItemRef;
}
const listItem = <LayerListItem
className={classnames({
"maputnik-layer-list-item-collapsed": layers.length > 1 && this.isCollapsed(groupPrefix, groupIdx) && idx !== this.props.selectedLayerIndex,
"maputnik-layer-list-item-group-last": idxInGroup == layers.length - 1 && layers.length > 1,
"maputnik-layer-list-item--error": !!layerError
})}
key={layer.key}
id={layer.key}
layerId={layer.id}
layerIndex={idx}
layerType={layer.type}
visibility={(layer.layout || {}).visibility}
isSelected={idx === this.props.selectedLayerIndex}
onLayerSelect={this.props.onLayerSelect}
onLayerDestroy={this.props.onLayerDestroy?.bind(this)}
onLayerCopy={this.props.onLayerCopy.bind(this)}
onLayerVisibilityToggle={this.props.onLayerVisibilityToggle.bind(this)}
{...additionalProps}
/>;
listItems.push(listItem);
idx += 1;
});
});
const t = this.props.t;
return <section
className="maputnik-layer-list"
data-wd-key="layer-list"
role="complementary"
aria-label={t("Layers list")}
ref={this.scrollContainerRef}
>
<ModalAdd
key={this.state.keys.add}
layers={this.props.layers}
sources={this.props.sources}
isOpen={this.state.isOpen.add}
onOpenToggle={this.toggleModal.bind(this, "add")}
onLayersChange={this.props.onLayersChange}
/>
<header className="maputnik-layer-list-header" data-wd-key="layer-list.header">
<span className="maputnik-layer-list-header-title">{t("Layers")}</span>
<span className="maputnik-space" />
<div className="maputnik-default-property">
<div className="maputnik-multibutton">
<button
id="skip-target-layer-list"
data-wd-key="skip-target-layer-list"
onClick={this.toggleLayers}
className="maputnik-button">
{this.state.areAllGroupsExpanded === true ?
t("Collapse")
:
t("Expand")
}
</button>
</div>
</div>
<div className="maputnik-default-property">
<div className="maputnik-multibutton">
<button
onClick={this.toggleModal.bind(this, "add")}
data-wd-key="layer-list:add-layer"
className="maputnik-button maputnik-button-selected">
{t("Add Layer")}
</button>
</div>
</div>
</header>
<div
role="navigation"
aria-label={t("Layers list")}
>
<ul className="maputnik-layer-list-container">
{listItems}
</ul>
</div>
</section>;
}
}
const LayerListContainer = withTranslation()(LayerListContainerInternal);
type LayerListProps = LayerListContainerProps & {
onMoveLayer: OnMoveLayerCallback
};
export const LayerList: React.FC<LayerListProps> = (props) => {
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 5 } }));
const handleDragEnd = (event: DragEndEvent) => {
const {active, over} = event;
if (!over) return;
const oldIndex = props.layers.findIndex(layer => layer.id === active.id);
const newIndex = props.layers.findIndex(layer => layer.id === over.id);
if (oldIndex !== -1 && newIndex !== -1 && oldIndex !== newIndex) {
props.onMoveLayer({oldIndex, newIndex});
}
};
const layerIds = props.layers.map(layer => layer.id);
return (
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<SortableContext items={layerIds} strategy={verticalListSortingStrategy}>
<LayerListContainer {...props} />
</SortableContext>
</DndContext>
);
};