"use strict";
define([
"backbone",
"sortable",
"common/ValueUtilities",
"views/maps/LayerListView",
"views/maps/LayerItemView",
"views/maps/mapEditor/MapAssetItemView",
"views/maps/mapEditor/MapAssetEditorView",
"views/maps/mapEditor/MapAssetCategoryView",
], (
Backbone,
Sortable,
ValueUtilities,
LayerListView,
LayerItemView,
MapAssetItemView,
MapAssetEditorView,
MapAssetCategoryView,
) => {
const CLASS_NAMES = {
BASE: "assets-editor",
LIST: "assets-editor__list",
ADD: "assets-editor__add",
EMPTY: "assets-editor__empty",
DETAILS: "assets-editor__details",
LAYER_LIST: LayerListView.CLASS_NAMES.BASE,
ITEM: LayerItemView.CLASS_NAMES.BASE,
ITEM_LABEL: LayerItemView.CLASS_NAMES.LABEL,
ADD_ROW: "assets-editor__add-row",
ICON: LayerItemView.CLASS_NAMES.ICON_FONT,
ADD_ICON: "icon-plus",
MOVE_CONTROLS: "assets-editor__move-controls",
MOVE_STATUS: "assets-editor__move-status",
DROP_PLACEHOLDER: "assets-editor__drop-placeholder",
};
const DRAG_GROUP_PREFIX = "map-editor";
const ADD_LAYER_ATTRIBUTE = "data-add-layer";
const MOVE_ASSET_ATTRIBUTE = "data-move-asset";
const MOVE_ATTRIBUTES = {
DIRECTION: "data-move-direction",
CATEGORY_CONTROLS: "data-move-category-controls",
DESTINATION: "data-move-destination",
TO_CATEGORY: "data-move-to-category",
CLOSE: "data-close-move",
};
const MOVE_CONTROLS_ID_SUFFIX = "move-controls";
const MOVE_DESTINATION_ID_SUFFIX = "move-destination";
const ASSET_PANEL_ID_SUFFIX = "asset-panel";
const WMTS_PROVIDER = "WebMapTileServiceImageryProvider";
const MESSAGES = {
LAYERS: "Layers",
SETTINGS: "Layer settings",
SELECT_LAYER: "Select a layer to edit its settings.",
ADD_FIRST_LAYER:
"Use Add layer to add your first map layer, then configure its settings here.",
NO_LAYERS: "No layers configured.",
ADD_LAYER: "Add layer",
NEW_LAYER: "New layer",
UNTITLED_LAYER: "Untitled layer",
MOVE: "Move",
MOVE_UP: "Move up",
MOVE_DOWN: "Move down",
MOVE_TO_CATEGORY: "Move to category",
CLOSE: "Close",
};
/**
* @class MapAssetsEditorView
* @classdesc Owns layer lists, editor selection, and the active asset form.
* @classcategory Views/Maps/MapEditor
* @augments Backbone.View
* @screenshot views/maps/mapEditor/MapAssetsEditorView.png
* @since 2.41.0
*/
const MapAssetsEditorView = Backbone.View.extend(
/** @lends MapAssetsEditorView.prototype */ {
/** @inheritdoc */
className: CLASS_NAMES.BASE,
/** @inheritdoc */
events: {
[`click [${ADD_LAYER_ATTRIBUTE}]`]: "addAsset",
[`click [${MOVE_ATTRIBUTES.DIRECTION}], [${MOVE_ATTRIBUTES.TO_CATEGORY}]`]:
"moveFromControls",
/** Close the controls and focus the grip */
[`click [${MOVE_ATTRIBUTES.CLOSE}]`]() {
this.closeMoveControls(true);
},
[`keydown .${CLASS_NAMES.MOVE_CONTROLS}, [${MOVE_ASSET_ATTRIBUTE}]`]:
"handleMoveKeydown",
[`focusout .${CLASS_NAMES.MOVE_CONTROLS}, [${MOVE_ASSET_ATTRIBUTE}]`]:
"handleMoveFocusout",
},
/** Initialize editor selection, Move controls, and the child views */
initialize() {
this.selectedAsset = null;
this.assetEditorView = null;
this.assetItemViews = [];
this.layerLists = [];
this.moveContext = null;
},
/**
* Create the layers workspace and subscribe to its existing collections.
* @returns {MapAssetsEditorView} This view
*/
render() {
this.onClose();
this.el.innerHTML = `
<section class="${CLASS_NAMES.LIST}" aria-label="${MESSAGES.LAYERS}"></section>
<section id="${this.cid}-${ASSET_PANEL_ID_SUFFIX}" class="${CLASS_NAMES.DETAILS}" aria-label="${MESSAGES.SETTINGS}">
<p>${MESSAGES.SELECT_LAYER}</p>
</section>
`;
const container = this.el.querySelector(`.${CLASS_NAMES.LIST}`);
const categories = this.model.get("layerCategories");
this.model.getLayerGroups().forEach((layers, index) => {
const category = categories?.at(index);
let categoryView;
if (category) {
categoryView = new MapAssetCategoryView({
model: category,
}).render();
container.append(categoryView.el);
}
this.createLayerList(
layers,
categoryView?.contentEl || container,
categoryView,
);
this.listenTo(layers, "update reset", this.renderList);
});
container.insertAdjacentHTML(
"beforeend",
`<p class="${CLASS_NAMES.EMPTY}" tabindex="-1">${MESSAGES.NO_LAYERS}</p>
<div id="${this.cid}-${MOVE_CONTROLS_ID_SUFFIX}" class="${CLASS_NAMES.MOVE_CONTROLS}" role="group" hidden>
<button type="button" ${MOVE_ATTRIBUTES.DIRECTION}="-1">${MESSAGES.MOVE_UP}</button>
<button type="button" ${MOVE_ATTRIBUTES.DIRECTION}="1">${MESSAGES.MOVE_DOWN}</button>
<div ${MOVE_ATTRIBUTES.CATEGORY_CONTROLS}>
<label for="${this.cid}-${MOVE_DESTINATION_ID_SUFFIX}">${MESSAGES.MOVE_TO_CATEGORY}</label>
<select id="${this.cid}-${MOVE_DESTINATION_ID_SUFFIX}" ${MOVE_ATTRIBUTES.DESTINATION}></select>
<button type="button" ${MOVE_ATTRIBUTES.TO_CATEGORY}>${MESSAGES.MOVE}</button>
</div>
<button type="button" ${MOVE_ATTRIBUTES.CLOSE}>${MESSAGES.CLOSE}</button>
</div>
<p class="${CLASS_NAMES.MOVE_STATUS}" role="status"></p>`,
);
this.moveControls = container.querySelector(
`.${CLASS_NAMES.MOVE_CONTROLS}`,
);
this.moveStatus = container.querySelector(
`.${CLASS_NAMES.MOVE_STATUS}`,
);
this.renderList();
this.layerLists.forEach((entry) => {
Object.assign(entry, {
sortable: Sortable.create(entry.list, {
group: `${DRAG_GROUP_PREFIX}-${this.cid}`,
direction: "vertical",
handle: `[${MOVE_ASSET_ATTRIBUTE}]`,
ghostClass: CLASS_NAMES.DROP_PLACEHOLDER,
onStart: () => this.closeMoveControls(),
onEnd: (event) => {
const item = this.assetItemViews.find(
(row) => row.el === event.item,
);
const target = this.layerLists.find(
({ list }) => list === event.to,
);
// Sortable 1.10.2 can count its fallback ghost in the event index.
const index = Array.from(target.list.children).indexOf(
event.item,
);
const changed = this.moveAsset(
item.model,
target.layers,
index,
);
if (!changed) item.focusMove();
},
}),
});
});
return this;
},
/**
* Create a stable layer list and a separate Add control.
* @param {MapAssets} layers The collection shown in this list
* @param {HTMLElement} host The container for the list and Add control
* @param {MapAssetCategoryView} [categoryView] The category presentation
*/
createLayerList(layers, host, categoryView) {
const group = document.createElement("div");
const list = document.createElement("ul");
list.className = CLASS_NAMES.LAYER_LIST;
const addRow = document.createElement("div");
addRow.className = `${CLASS_NAMES.ITEM} ${CLASS_NAMES.ADD_ROW}`;
const add = document.createElement("button");
add.type = "button";
add.className = `${CLASS_NAMES.ITEM_LABEL} ${CLASS_NAMES.ADD}`;
add.setAttribute(ADD_LAYER_ATTRIBUTE, this.layerLists.length);
add.innerHTML = `<i class="${CLASS_NAMES.ICON} ${CLASS_NAMES.ADD_ICON}" aria-hidden="true"></i> ${MESSAGES.ADD_LAYER}`;
if (categoryView) {
const category = categoryView.model;
const updateName = () =>
add.setAttribute(
"aria-label",
`${MESSAGES.ADD_LAYER} to ${category.get("label")}`,
);
updateName();
this.listenTo(category, "change:label", updateName);
}
addRow.append(add);
group.append(list, addRow);
host.append(group);
this.layerLists.push({ layers, list, categoryView });
},
/**
* Assign a missing ID as the layer enters the editor, then create its row
* and listen for editing, movement, and removal requests.
* @param {MapAsset} asset The layer represented by the row
* @returns {MapAssetItemView} The rendered row
*/
createAssetItem(asset) {
if (!asset.get("layerId")) {
asset.set("layerId", ValueUtilities.makeShortId());
}
const item = new MapAssetItemView({
model: asset,
panelId: `${this.cid}-${ASSET_PANEL_ID_SUFFIX}`,
});
this.listenTo(item, "select:asset", (selected, event) => {
this.selectAsset(selected);
// Keyboard and assistive technology clicks have no pointer click count.
if (event?.originalEvent?.detail === 0)
this.assetEditorView?.focusLabel();
});
this.listenTo(item, "remove:asset", this.removeAsset);
this.listenTo(item, "move:asset", this.showMoveControls);
return item.render();
},
/**
* Sync rows while keeping their controls and the active form.
*/
renderList() {
const previousItems = this.assetItemViews;
const assets = this.model.getAllLayers();
previousItems.forEach((item) => {
if (!assets.includes(item.model)) {
if (this.moveContext?.asset === item.model)
this.closeMoveControls();
this.stopListening(item);
item.remove();
}
});
const orderedItems = [];
this.layerLists.forEach(({ layers, list }) => {
let next = list.firstElementChild;
layers.each((asset) => {
const item =
previousItems.find((row) => row.model === asset) ||
this.createAssetItem(asset);
// Leave ordered rows in place so their controls keep focus.
if (item.el !== next) list.insertBefore(item.el, next);
next = item.el.nextElementSibling;
item.setSelected(asset === this.selectedAsset);
orderedItems.push(item);
});
});
this.assetItemViews = orderedItems;
this.el.querySelector(`.${CLASS_NAMES.EMPTY}`).hidden =
orderedItems.length > 0;
if (this.selectedAsset && !assets.includes(this.selectedAsset)) {
this.selectAsset(null);
}
if (!this.selectedAsset) {
this.el.querySelector(`.${CLASS_NAMES.DETAILS} p`).textContent =
assets.length ? MESSAGES.SELECT_LAYER : MESSAGES.ADD_FIRST_LAYER;
}
},
/**
* Open or toggle Move controls for a layer without selecting it.
* @param {MapAsset} asset The layer to move
* @param {HTMLButtonElement} button The layer's grip
*/
showMoveControls(asset, button) {
if (this.moveContext?.button === button) {
this.closeMoveControls(true);
return;
}
this.closeMoveControls();
this.moveContext = { asset, button };
const source = this.layerLists.find(({ layers }) => layers.get(asset));
const index = source.layers.indexOf(asset);
const up = this.moveControls.querySelector(
`[${MOVE_ATTRIBUTES.DIRECTION}="-1"]`,
);
const down = this.moveControls.querySelector(
`[${MOVE_ATTRIBUTES.DIRECTION}="1"]`,
);
up.disabled = index === 0;
down.disabled = index === source.layers.length - 1;
const categoryControls = this.moveControls.querySelector(
`[${MOVE_ATTRIBUTES.CATEGORY_CONTROLS}]`,
);
const select = this.moveControls.querySelector(
`[${MOVE_ATTRIBUTES.DESTINATION}]`,
);
categoryControls.hidden = this.layerLists.length < 2;
select.replaceChildren();
if (!categoryControls.hidden) {
this.layerLists.forEach((entry) => {
if (entry !== source) {
select.add(
new Option(
entry.categoryView.model.get("label"),
entry.categoryView.model.cid,
),
);
}
});
}
this.moveControls.setAttribute(
"aria-label",
`${MESSAGES.MOVE} ${asset.get("label") || MESSAGES.UNTITLED_LAYER}`,
);
button.parentElement.append(this.moveControls);
button.setAttribute("aria-controls", this.moveControls.id);
button.setAttribute("aria-expanded", "true");
this.moveControls.hidden = false;
if (!up.disabled) up.focus();
else if (!down.disabled) down.focus();
else if (!categoryControls.hidden) select.focus();
else
this.moveControls.querySelector(`[${MOVE_ATTRIBUTES.CLOSE}]`).focus();
},
/**
* Hide Move controls and return them to the layers section.
* @param {boolean} [restoreFocus] Whether to focus the old grip
*/
closeMoveControls(restoreFocus = false) {
if (!this.moveContext) return;
const { button } = this.moveContext;
this.moveContext = null;
button.setAttribute("aria-expanded", "false");
this.moveControls.hidden = true;
this.el.querySelector(`.${CLASS_NAMES.LIST}`).append(this.moveControls);
if (restoreFocus) button.focus();
},
/**
* Move the active layer up, down, or to the chosen category.
* @param {Event} event The Move action's click
*/
moveFromControls(event) {
const { asset } = this.moveContext;
const source = this.layerLists.find(({ layers }) =>
layers.get(asset),
).layers;
let target = source;
let index;
if (event.currentTarget.hasAttribute(MOVE_ATTRIBUTES.DIRECTION)) {
index =
source.indexOf(asset) +
Number(event.currentTarget.getAttribute(MOVE_ATTRIBUTES.DIRECTION));
} else {
const cid = this.moveControls.querySelector(
`[${MOVE_ATTRIBUTES.DESTINATION}]`,
).value;
target = this.layerLists.find(
({ categoryView }) => categoryView.model.cid === cid,
).layers;
index = target.length;
}
this.moveAsset(asset, target, index);
},
/**
* Move a layer, show its destination, and announce its new position.
* @param {MapAsset} asset The layer to move
* @param {MapAssets} targetLayers The destination collection
* @param {number} index The layer's new index in that collection
* @returns {boolean} Whether the layer moved
*/
moveAsset(asset, targetLayers, index) {
if (!this.model.moveAsset(asset, targetLayers, index)) return false;
this.closeMoveControls();
const destination = this.layerLists.find(
({ layers }) => layers === targetLayers,
);
destination.categoryView?.setExpanded(true);
this.assetItemViews.find((item) => item.model === asset).focusMove();
const label = asset.get("label") || MESSAGES.UNTITLED_LAYER;
const category = destination.categoryView;
const location = category ? ` to ${category.model.get("label")}` : "";
this.moveStatus.textContent = `${label} moved${location}, position ${index + 1} of ${targetLayers.length}.`;
return true;
},
/**
* Close Move controls with Escape and focus the grip.
* @param {KeyboardEvent} event The key pressed in the controls or grip
*/
handleMoveKeydown(event) {
if (event.key === "Escape") {
event.preventDefault();
this.closeMoveControls(true);
}
},
/**
* Close Move controls when focus leaves them and their grip.
* @param {FocusEvent} event The focus change
*/
handleMoveFocusout(event) {
if (!this.moveContext) return;
if (
event.relatedTarget !== this.moveContext.button &&
!this.moveControls.contains(event.relatedTarget)
) {
this.closeMoveControls();
}
},
/**
* Select an asset for editing without changing runtime map selection.
* @param {MapAsset|null} asset The layer to edit, or null to clear selection
* @returns {void}
*/
selectAsset(asset) {
this.assetItemViews.forEach((item) => {
item.setSelected(item.model === asset);
});
this.layerLists
.find(({ layers }) => layers.get(asset))
?.categoryView?.setExpanded(true);
if (asset === this.selectedAsset) return;
this.assetEditorView?.remove();
this.assetEditorView = null;
this.selectedAsset = asset;
const panel = this.el.querySelector(`.${CLASS_NAMES.DETAILS}`);
panel.replaceChildren();
panel.scrollTop = 0;
if (asset) {
this.assetEditorView = new MapAssetEditorView({ model: asset });
panel.append(this.assetEditorView.render().el);
} else {
const message = document.createElement("p");
message.textContent = MESSAGES.SELECT_LAYER;
panel.append(message);
}
},
/**
* Add a WMTS layer to the chosen group and select its label for editing.
* @param {Event} event The add button click
* @returns {void}
*/
addAsset(event) {
const { layers } =
this.layerLists[
event.currentTarget.getAttribute(ADD_LAYER_ATTRIBUTE)
];
const asset = layers.addAsset(
{
label: MESSAGES.NEW_LAYER,
type: WMTS_PROVIDER,
cesiumOptions: { url: "" },
},
this.model,
);
this.selectAsset(asset);
this.assetEditorView.focusLabel();
},
/**
* Remove a layer and focus the next row, preceding row, or empty state.
* @param {MapAsset} asset The layer to remove
* @returns {void}
*/
removeAsset(asset) {
const index = this.assetItemViews.findIndex(
(item) => item.model === asset,
);
this.model.removeAsset(asset);
const item =
this.assetItemViews[Math.min(index, this.assetItemViews.length - 1)];
if (item) {
this.layerLists
.find(({ layers }) => layers.get(item.model))
.categoryView?.setExpanded(true);
item.focus();
} else this.el.querySelector(`.${CLASS_NAMES.EMPTY}`).focus();
},
/**
* Select the first invalid layer and delegate feedback to its form.
* @returns {void}
*/
showValidation() {
const invalidItem = this.assetItemViews.find(
(item) => item.model.validationError,
);
if (!invalidItem) return;
this.selectAsset(invalidItem.model);
invalidItem.focus();
this.assetEditorView.showValidation();
},
/**
* Close Move controls and release drag handlers, views, and subscriptions.
*/
onClose() {
this.closeMoveControls();
this.layerLists.forEach(({ sortable }) => sortable.destroy());
this.assetItemViews.forEach((item) => item.remove());
this.layerLists.forEach(({ categoryView }) => categoryView?.remove());
this.assetItemViews = [];
this.layerLists = [];
this.assetEditorView?.remove();
this.assetEditorView = null;
this.selectedAsset = null;
this.stopListening();
},
/** @inheritdoc */
remove() {
this.onClose();
return Backbone.View.prototype.remove.call(this);
},
},
);
/**
* CSS class names used by the assets workspace.
* @type {Object<string, string>}
*/
MapAssetsEditorView.CLASS_NAMES = CLASS_NAMES;
return MapAssetsEditorView;
});