mirror of
https://github.com/maputnik/editor.git
synced 2025-12-25 07:30:00 +00:00
## Launch Checklist This PR adds back the error panel which was under the map for some reason. It also highlights problematic layers in the layers list (which already worked). It also highlights the field that has an error related to it. It fixes the error types throughout the code. Before: <img width="1141" height="665" alt="image" src="https://github.com/user-attachments/assets/c0593d6c-8f14-41b3-8a51-bc359446656d" /> After: <img width="1141" height="665" alt="image" src="https://github.com/user-attachments/assets/1ffeebb7-31ea-4ed5-97f4-fc5f907a6aea" /> - [x] Briefly describe the changes in this PR. - [x] Include before/after visuals or gifs if this PR includes visual changes. - [x] Write tests for all new functionality. - [x] Add an entry to `CHANGELOG.md` under the `## main` section. --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
421 lines
13 KiB
TypeScript
421 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(...args: unknown[]): unknown
|
|
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
|
|
layer={this.props.layer}
|
|
onChange={(layer) => {
|
|
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>
|
|
<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;
|