mirror of
https://github.com/maputnik/editor.git
synced 2026-01-03 12:00:00 +00:00
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>
422 lines
13 KiB
TypeScript
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;
|