Files
editor/src/components/LayerEditor.tsx
Jere Suikkila 5b34a3791f fix: keep headers visible when scrolling left panes (#1485)
Keeps headers visible when scrolling left panes as described in the
[issue 951
](https://github.com/maplibre/maputnik/issues/951)

The fix was manually confirmed to be working, see video below.

## Before (taken from the issue)
<img width="713" height="231" alt="image"
src="https://github.com/user-attachments/assets/c1eadb0d-6dbf-4199-8732-68b07d626003"
/>


## After

https://github.com/user-attachments/assets/ab5e4a6f-c5f9-44fd-850d-8eac58c35c68

---------

Co-authored-by: Harel M <harel.mazor@gmail.com>
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2025-11-04 09:11:13 +02:00

422 lines
13 KiB
TypeScript

import React, {type JSX} from "react";
import { Wrapper, Button, Menu, MenuItem } from "react-aria-menubutton";
import {Accordion} from "react-accessible-accordion";
import {MdMoreVert} from "react-icons/md";
import { IconContext } from "react-icons";
import {type BackgroundLayerSpecification, type LayerSpecification, type SourceSpecification} from "maplibre-gl";
import {v8} from "@maplibre/maplibre-gl-style-spec";
import FieldJson from "./FieldJson";
import FilterEditor from "./FilterEditor";
import PropertyGroup from "./PropertyGroup";
import LayerEditorGroup from "./LayerEditorGroup";
import FieldType from "./FieldType";
import FieldId from "./FieldId";
import FieldMinZoom from "./FieldMinZoom";
import FieldMaxZoom from "./FieldMaxZoom";
import FieldComment from "./FieldComment";
import FieldSource from "./FieldSource";
import FieldSourceLayer from "./FieldSourceLayer";
import { changeType, changeProperty } from "../libs/layer";
import {formatLayerId} from "../libs/format";
import { type WithTranslation, withTranslation } from "react-i18next";
import { type TFunction } from "i18next";
import { NON_SOURCE_LAYERS } from "../libs/non-source-layers";
import { type MappedError, type MappedLayerErrors, type OnMoveLayerCallback } from "../libs/definitions";
type MaputnikLayoutGroup = {
id: string;
title: string;
type: string;
fields: string[];
};
function getLayoutForSymbolType(t: TFunction): MaputnikLayoutGroup[] {
const groups: MaputnikLayoutGroup[] = [];
groups.push({
title: t("General layout properties"),
id: "General_layout_properties",
type: "properties",
fields: Object.keys(v8["layout_symbol"]).filter(f => f.startsWith("symbol-"))
});
groups.push({
title: t("Text layout properties"),
id: "Text_layout_properties",
type: "properties",
fields: Object.keys(v8["layout_symbol"]).filter(f => f.startsWith("text-"))
});
groups.push({
title: t("Icon layout properties"),
id: "Icon_layout_properties",
type: "properties",
fields: Object.keys(v8["layout_symbol"]).filter(f => f.startsWith("icon-"))
});
groups.push({
title: t("Text paint properties"),
id: "Text_paint_properties",
type: "properties",
fields: Object.keys(v8["paint_symbol"]).filter(f => f.startsWith("text-"))
});
groups.push({
title: t("Icon paint properties"),
id: "Icon_paint_properties",
type: "properties",
fields: Object.keys(v8["paint_symbol"]).filter(f => f.startsWith("icon-"))
});
return groups;
}
function getLayoutForType(type: LayerSpecification["type"], t: TFunction): MaputnikLayoutGroup[] {
if (Object.keys(v8.layer.type.values).indexOf(type) < 0) {
return [];
}
if (type === "symbol") {
return getLayoutForSymbolType(t);
}
const groups: MaputnikLayoutGroup[] = [];
if (Object.keys(v8["paint_" + type]).length > 0) {
groups.push({
title: t("Paint properties"),
id: "Paint_properties",
type: "properties",
fields: Object.keys(v8["paint_" + type]),
});
}
if (Object.keys(v8["layout_" + type]).length > 0) {
groups.push({
title: t("Layout properties"),
id: "Layout_properties",
type: "properties",
fields: Object.keys(v8["layout_" + type])
});
}
return groups;
}
function layoutGroups(layerType: LayerSpecification["type"], t: TFunction): {id: string, title: string, type: string, fields?: string[]}[] {
const layerGroup = {
id: "layer",
title: t("Layer"),
type: "layer"
};
const filterGroup = {
id: "filter",
title: t("Filter"),
type: "filter"
};
const editorGroup = {
id: "jsoneditor",
title: t("JSON Editor"),
type: "jsoneditor"
};
return [layerGroup, filterGroup]
.concat(getLayoutForType(layerType, t))
.concat([editorGroup]);
}
type LayerEditorInternalProps = {
layer: LayerSpecification
sources: {[key: string]: SourceSpecification & {layers: string[]}}
vectorLayers: {[key: string]: any}
spec: any
onLayerChanged(index: number, layer: LayerSpecification): void
onLayerIdChange(...args: unknown[]): unknown
onMoveLayer: OnMoveLayerCallback
onLayerDestroy(...args: unknown[]): unknown
onLayerCopy(...args: unknown[]): unknown
onLayerVisibilityToggle(...args: unknown[]): unknown
isFirstLayer?: boolean
isLastLayer?: boolean
layerIndex: number
errors?: MappedError[]
} & WithTranslation;
type LayerEditorState = {
editorGroups: {[keys:string]: boolean}
};
/** Layer editor supporting multiple types of layers. */
class LayerEditorInternal extends React.Component<LayerEditorInternalProps, LayerEditorState> {
static defaultProps = {
onLayerChanged: () => {},
onLayerIdChange: () => {},
onLayerDestroyed: () => {},
};
constructor(props: LayerEditorInternalProps) {
super(props);
const editorGroups: {[keys:string]: boolean} = {};
for (const group of layoutGroups(this.props.layer.type, props.t)) {
editorGroups[group.title] = true;
}
this.state = { editorGroups };
}
static getDerivedStateFromProps(props: Readonly<LayerEditorInternalProps>, state: LayerEditorState) {
const additionalGroups = { ...state.editorGroups };
for (const group of getLayoutForType(props.layer.type, props.t)) {
if(!(group.title in additionalGroups)) {
additionalGroups[group.title] = true;
}
}
return {
editorGroups: additionalGroups
};
}
changeProperty(group: keyof LayerSpecification | null, property: string, newValue: any) {
this.props.onLayerChanged(
this.props.layerIndex,
changeProperty(this.props.layer, group, property, newValue)
);
}
onGroupToggle(groupTitle: string, active: boolean) {
const changedActiveGroups = {
...this.state.editorGroups,
[groupTitle]: active,
};
this.setState({
editorGroups: changedActiveGroups
});
}
renderGroupType(type: string, fields?: string[]): JSX.Element {
let comment = "";
if(this.props.layer.metadata) {
comment = (this.props.layer.metadata as any)["maputnik:comment"];
}
const {errors, layerIndex} = this.props;
const errorData: MappedLayerErrors = {};
errors!.forEach(error => {
if (
error.parsed &&
error.parsed.type === "layer" &&
error.parsed.data.index == layerIndex
) {
errorData[error.parsed.data.key] = {
message: error.parsed.data.message
};
}
});
let sourceLayerIds;
const layer = this.props.layer as Exclude<LayerSpecification, BackgroundLayerSpecification>;
if(Object.prototype.hasOwnProperty.call(this.props.sources, layer.source)) {
sourceLayerIds = this.props.sources[layer.source].layers;
}
switch(type) {
case "layer": return <div>
<FieldId
value={this.props.layer.id}
wdKey="layer-editor.layer-id"
error={errorData.id}
onChange={newId => this.props.onLayerIdChange(this.props.layerIndex, this.props.layer.id, newId)}
/>
<FieldType
disabled={true}
error={errorData.type}
value={this.props.layer.type}
onChange={newType => this.props.onLayerChanged(
this.props.layerIndex,
changeType(this.props.layer, newType)
)}
/>
{this.props.layer.type !== "background" && <FieldSource
error={errorData.source}
sourceIds={Object.keys(this.props.sources!)}
value={this.props.layer.source}
onChange={v => this.changeProperty(null, "source", v)}
/>
}
{!NON_SOURCE_LAYERS.includes(this.props.layer.type) &&
<FieldSourceLayer
error={errorData["source-layer"]}
sourceLayerIds={sourceLayerIds}
value={(this.props.layer as any)["source-layer"]}
onChange={v => this.changeProperty(null, "source-layer", v)}
/>
}
<FieldMinZoom
error={errorData.minzoom}
value={this.props.layer.minzoom}
onChange={v => this.changeProperty(null, "minzoom", v)}
/>
<FieldMaxZoom
error={errorData.maxzoom}
value={this.props.layer.maxzoom}
onChange={v => this.changeProperty(null, "maxzoom", v)}
/>
<FieldComment
error={errorData.comment}
value={comment}
onChange={v => this.changeProperty("metadata", "maputnik:comment", v == "" ? undefined : v)}
/>
</div>;
case "filter": return <div>
<div className="maputnik-filter-editor-wrapper">
<FilterEditor
errors={errorData}
filter={(this.props.layer as any).filter}
properties={this.props.vectorLayers[(this.props.layer as any)["source-layer"]]}
onChange={f => this.changeProperty(null, "filter", f)}
/>
</div>
</div>;
case "properties":
return <PropertyGroup
errors={errorData}
layer={this.props.layer}
groupFields={fields!}
spec={this.props.spec}
onChange={this.changeProperty.bind(this)}
/>;
case "jsoneditor":
return <FieldJson
lintType="layer"
value={this.props.layer}
onChange={(layer: LayerSpecification) => {
this.props.onLayerChanged(
this.props.layerIndex,
layer
);
}}
/>;
default: return <></>;
}
}
moveLayer(offset: number) {
this.props.onMoveLayer({
oldIndex: this.props.layerIndex,
newIndex: this.props.layerIndex+offset
});
}
render() {
const t = this.props.t;
const groupIds: string[] = [];
const layerType = this.props.layer.type;
const groups = layoutGroups(layerType, t).filter(group => {
return !(layerType === "background" && group.type === "source");
}).map(group => {
const groupId = group.id;
groupIds.push(groupId);
return <LayerEditorGroup
data-wd-key={group.title}
id={groupId}
key={groupId}
title={group.title}
isActive={this.state.editorGroups[group.title]}
onActiveToggle={this.onGroupToggle.bind(this, group.title)}
>
{this.renderGroupType(group.type, group.fields)}
</LayerEditorGroup>;
});
const layout = this.props.layer.layout || {};
const items: {[key: string]: {
text: string,
handler: () => void,
disabled?: boolean,
wdKey?: string
}} = {
delete: {
text: t("Delete"),
handler: () => this.props.onLayerDestroy(this.props.layerIndex),
wdKey: "menu-delete-layer"
},
duplicate: {
text: t("Duplicate"),
handler: () => this.props.onLayerCopy(this.props.layerIndex),
wdKey: "menu-duplicate-layer"
},
hide: {
text: (layout.visibility === "none") ? t("Show") : t("Hide"),
handler: () => this.props.onLayerVisibilityToggle(this.props.layerIndex),
wdKey: "menu-hide-layer"
},
moveLayerUp: {
text: t("Move layer up"),
disabled: this.props.isFirstLayer,
handler: () => this.moveLayer(-1),
wdKey: "menu-move-layer-up"
},
moveLayerDown: {
text: t("Move layer down"),
disabled: this.props.isLastLayer,
handler: () => this.moveLayer(+1),
wdKey: "menu-move-layer-down"
}
};
function handleSelection(id: string, event: React.SyntheticEvent) {
event.stopPropagation();
items[id].handler();
}
return <IconContext.Provider value={{size: "14px", color: "#8e8e8e"}}>
<section className="maputnik-layer-editor"
role="main"
aria-label={t("Layer editor")}
data-wd-key="layer-editor"
>
<header data-wd-key="layer-editor.header">
<div className="layer-header">
<h2 className="layer-header__title">
{t("Layer: {{layerId}}", { layerId: formatLayerId(this.props.layer.id) })}
</h2>
<div className="layer-header__info">
<Wrapper
className='more-menu'
onSelection={handleSelection}
closeOnSelection={false}
>
<Button
id="skip-target-layer-editor"
data-wd-key="skip-target-layer-editor"
className='more-menu__button'
title={"Layer options"}>
<MdMoreVert className="more-menu__button__svg" />
</Button>
<Menu>
<ul className="more-menu__menu">
{Object.keys(items).map((id) => {
const item = items[id];
return <li key={id}>
<MenuItem value={id} className='more-menu__menu__item' data-wd-key={item.wdKey}>
{item.text}
</MenuItem>
</li>;
})}
</ul>
</Menu>
</Wrapper>
</div>
</div>
</header>
<Accordion
allowMultipleExpanded={true}
allowZeroExpanded={true}
preExpanded={groupIds}
>
{groups}
</Accordion>
</section>
</IconContext.Provider>;
}
}
const LayerEditor = withTranslation()(LayerEditorInternal);
export default LayerEditor;