navigation_plus-1.0.5/js/edit-mode-plugin.js
js/edit-mode-plugin.js
(($, Drupal, once, displace) => {
const modeManager = Drupal.NavigationPlus.ModeManager;
const toolManager = Drupal.NavigationPlus.ToolManager;
Drupal.behaviors.NavigationPlusEditMode = {
attach: (context, settings) => {
once('NavigationPlusEditMode', 'body', context).forEach(body => {
const isInitialPageEdit = Drupal.NavigationPlus.contentWasJustCreated();
const initialMode = typeof drupalSettings.navigationPlus?.initialMode === "undefined" ? 'none' : drupalSettings.navigationPlus.initialMode;
if (isInitialPageEdit && initialMode === 'none') {
return;
}
if (isInitialPageEdit) {
const mode = modeManager.getPlugin('edit');
Drupal.behaviors.NavigationPlusModes.EnableMode(mode);
} else if (Drupal.NavigationPlus.getCookieValue('navigationMode') === 'edit') {
// Enable active tool on page load.
modeManager.getPlugin('edit').activateTool();
window.setMode('edit');
window.setHotKey(true);
window.setFileDrag(true);
}
});
once('NavigationPlusEditMode', '#navigation-plus-edit .navigation-plus-button', context).forEach(toolButton => {
// Listen for tool activation clicks.
toolButton.addEventListener('click', (event) => {
const nextTool = event.currentTarget.dataset.tool;
modeManager.getPlugin('edit').changeTool(nextTool);
});
});
}
};
/**
* Navigation + Edit Mode plugin
*/
class EditModePlugin extends Drupal.NavigationPlus.ModePluginBase {
id = 'edit';
enable = () => {
this.ReloadPageElements().then((response, status) => {
this.activateTool();
window.setHotKey(true);
window.setFileDrag(true);
}).catch((error) => {
console.error('An error occurred while trying to load the editing UI:', error);
});
};
disable = (editModeDisabled = false) => {
window.setHotKey(false);
window.setFileDrag(false);
this.removeMouse();
const activeTool = localStorage.getItem('navigation_plus.tool.active');
toolManager.getPlugin(activeTool).disable(true);
return this.ReloadPageElements();
};
removeMouse = () => {
document.querySelectorAll('.navigation-plus-button.active').forEach(button => {
document.querySelector('html').classList.remove(button.dataset.tool);
button.classList.remove('active');
});
};
changeMouse = (pluginId) => {
this.removeMouse();
document.querySelector('[data-tool="' + pluginId + '"]').classList.add('active');
document.querySelector('html').classList.add(pluginId);
sessionStorage.setItem('mouseState', pluginId);
};
activateTool = (toolPluginId = null) => {
const isInitialPageEdit = Drupal.NavigationPlus.contentWasJustCreated();
if (!toolPluginId) {
const defaultTool = drupalSettings.navigationPlus.defaultTool ?? 'pointer';
if (isInitialPageEdit) {
// Use the configured tool for this content type when initially editing
// a page.
toolPluginId = defaultTool;
} else {
// Ensure the active tool is still valid for this entity type.
let activeToolId = localStorage.getItem('navigation_plus.tool.active');
try {
toolManager.getPlugin(activeToolId);
} catch (e) {
activeToolId = null;
}
toolPluginId = activeToolId ?? defaultTool;
}
}
this.changeMouse(toolPluginId);
const tool = toolManager.getPlugin(toolPluginId);
tool.enable().then(() => {
if (isInitialPageEdit) {
tool.initialEdit();
}
localStorage.setItem('navigation_plus.tool.active', toolPluginId);
document.cookie = 'activeTool=' + toolPluginId + '; path=/';
const ToolChangeEvent = new CustomEvent('NavigationPlus.EditModeToolChangeEvent', {
detail: {
active: toolPluginId,
},
bubbles: true,
cancelable: true
});
document.dispatchEvent(ToolChangeEvent);
});
};
changeTool = (nextTool = null) => {
const activeTool = localStorage.getItem('navigation_plus.tool.active') ?? 'pointer';
toolManager.getPlugin(activeTool).disable().then(() => {
modeManager.getPlugin('edit').activateTool(nextTool);
}).catch((error) => {
if (error) {
this.message(error.message, 'warning', {
duration: 3000,
scroll: false,
});
}
});
};
/**
* Reload page elements.
*
* When the edit cookie changes, AJAX reload the page so that the page
* elements will have the UI attributes applied to them.
*/
ReloadPageElements = () => {
return new Promise((resolve, reject) => {
const info = this.getMainEntityInfo();
if (!info) {
this.message('No main entity found.');
return;
}
let ajax = Drupal.NavigationPlus.ModePluginBase.ajax({
url: '/navigation-plus/load-editable-page/' + info.entityType + '/' + info.id + '/' + info.viewMode,
type: 'POST',
dataType: 'text',
progress: {
type: 'fullscreen',
message: Drupal.t('Loading Layout Builder...'),
},
error: error => {
console.error(error.responseText);
this.handleError(error, 'Unable to load the editing UI.');
},
success: (response, status) => {
Promise.resolve(
Drupal.Ajax.prototype.success.call(ajax, response, status),
).then(() => {
resolve();
});
},
});
ajax.execute();
});
};
getMainEntityWrapper = () => {
return document.querySelector('.navigation-plus-entity-wrapper[data-main-entity]');
};
getMainEntityInfo = () => {
const wrapper = this.getMainEntityWrapper();
if (!wrapper) {
return false;
}
const entityWrapperId = wrapper.dataset.navigationPlusEntityWrapper;
const [entityType, id, bundle] = entityWrapperId.split('::');
const viewMode = wrapper.dataset.navigationPlusViewMode;
return {entityType, id, bundle, viewMode, wrapper};
};
/**
* Get section storage information.
*
* @param element
* Probably a dropzone, field, or block element.
*
* @returns {{region: string, dropzoneType: string, precedingBlock: string, precedingSection: string, section: string}}
* An array of layout builder storage details for editing items on the
* page.
*/
getSectionStorageInfo = (element) => {
const storageType = drupalSettings['LB+']?.sectionStorageType;
const storageId = drupalSettings['LB+']?.sectionStorage;
if (!storageType) {
return null;
}
const sectionDelta = element.closest('[data-layout-builder-section-delta]')?.dataset.layoutBuilderSectionDelta;
const region = element.closest('[region]')?.getAttribute('region');
const block = element.closest('[data-layout-builder-block-uuid]');
const blockUuid = block?.dataset.layoutBuilderBlockUuid;
let nestedStoragePath = '';
const layoutBuilder = $(element).parents('.layout-builder, .lb-plus-layout-block');
for (let i = layoutBuilder.length - 2; i >= 0; i--) {
// Are we in a rendered layout block?
const layoutBlock = layoutBuilder[i].closest('[data-layout-builder-layout-block]');
if (!layoutBlock) {
// Are we editing a layout block?
const nestedLayoutBuilder = layoutBuilder[i].closest('[data-nested-storage-uuid]');
if (nestedLayoutBuilder) {
const parentSection = nestedLayoutBuilder.closest('[data-layout-builder-section-delta]');
if (nestedStoragePath) {
nestedStoragePath += '&';
}
nestedStoragePath += `${parentSection.dataset.layoutBuilderSectionDelta}&${nestedLayoutBuilder.dataset.nestedStorageUuid}`;
}
continue;
}
if (this.elementIsNested(element, layoutBlock)) {
const layoutBlockParentSection = layoutBlock.closest('[data-layout-builder-section-delta]');
if (nestedStoragePath) {
nestedStoragePath += '&';
}
nestedStoragePath += `${layoutBlockParentSection.dataset.layoutBuilderSectionDelta}&${layoutBlock.dataset.layoutBuilderBlockUuid}`;
}
}
return {
region,
storageId,
blockUuid,
storageType,
sectionDelta,
nestedStoragePath,
};
};
/**
* Element is nested.
*
* Is the given element a field or property on the Layout Block? Or is it
* nested in the section storage of the Layout Block?
*
* @param element
* The Editable Element.
* @param layoutBlock
* The layout Block.
* @returns {boolean}
* Whether the element is nested in the Layout Blocks section storage.
*/
elementIsNested = (element, layoutBlock) => {
// Okay we are in a Layout Block, but is the Element a field or property
// on the Layout Block? Or is it nested inside the Layout Block?
let elementIsNested = false;
// Get all sections, then filter out those contained in nested layout blocks.
const allSections = layoutBlock.querySelectorAll('[data-layout-builder-section-delta]');
const nestedSections = Array.from(layoutBlock.querySelectorAll('.lb-plus-layout-block')).flatMap(nested => Array.from(nested.querySelectorAll('[data-layout-builder-section-delta]')));
const layoutBlockChildSections = Array.from(allSections).filter(section =>
!nestedSections.includes(section)
);
if (layoutBlockChildSections.length > 0) {
elementIsNested = Array.from(layoutBlockChildSections).some(section =>
section.contains(element)
);
}
return elementIsNested;
};
/**
* Get dropzone information.
*
* @param dropzone
* The dropzone element.
*
* @returns {{region: string, dropzoneType: string, precedingBlock: string, precedingSection: string, section: string}}
* An array of layout builder storage details for placing items on the
* page.
*/
getDropzoneInfo = (dropzone) => {
const dropzoneWrapper = dropzone.closest('.drop-zone-wrapper');
const precedingBlock = dropzoneWrapper?.dataset.precedingBlockUuid;
const precedingSection = dropzoneWrapper?.dataset.precedingSectionId;
const region = dropzoneWrapper?.dataset.region;
const section = dropzoneWrapper?.dataset.sectionId;
const dropzoneType = dropzone.dataset.dropZoneType;
return {
region,
dropzoneType,
precedingBlock,
precedingSection,
section,
};
};
handleError = (error, message = 'Unknown operation') => {
document.querySelectorAll('.ajax-progress').forEach(progress => {
progress.remove();
});
this.message(message, 'error', 15000);
};
/**
* Show a message to the user.
*
* Uses Drupal's core message system for consistent styling and behavior.
*
* @param message
* The message text to display.
* @param type
* The message type: 'error', 'warning', 'status', or 'info'.
* @param options
* Optional configuration object:
* - duration: How long to show the message in milliseconds or -1 for
* forever.
* - allowDuplicates: Whether to allow duplicate messages
* - scroll: Whether to scroll to the message if not visible (default: true)
*/
message = (message, type = 'error', options = {}) => {
const {
duration = this.getMessageDuration(type),
allowDuplicates = false,
scroll = true,
} = options;
if (!allowDuplicates && this.isDuplicateMessage(message, type)) {
return;
}
// Clear the message wrapper.
// Remove the following after https://www.drupal.org/project/drupal/issues/3407067
const messageWrapper = document.querySelector('[data-drupal-messages]');
if (messageWrapper && messageWrapper.innerHTML !== '' && messageWrapper.firstElementChild === null) {
messageWrapper.innerHTML = '';
}
const drupalMessage = new Drupal.Message(messageWrapper);
const messageKey = `${type}:${message}`;
const messageId = drupalMessage.add(message, {
type: type,
id: messageKey.hashCode()
});
if (scroll) {
this.scrollToMessageIfNeeded(messageId);
}
// Auto-remove after duration
if (duration > 0) {
setTimeout(() => {
try {
drupalMessage.remove(messageId);
} catch(e) {
// Message may have already been removed
}
}, duration);
}
};
/**
* Get default duration for message type.
*/
getMessageDuration = (type) => {
const durations = {
'status': 6000,
'warning': 10000,
'error': 15000,
};
return durations[type] || 8000;
};
/**
* Check if message is a duplicate.
*/
isDuplicateMessage = (message, type) => {
const messageKey = `${type}:${message}`;
const id = messageKey.hashCode();
const duplicateMessage = document.querySelector(`[data-drupal-message-id="${id}"]`);
return !!duplicateMessage;
};
/**
* Scroll to message if it's not currently visible in the viewport.
*
* @param {string} messageId
* The message ID to scroll to.
*/
scrollToMessageIfNeeded = (messageId) => {
// Give the DOM a moment to update.
setTimeout(() => {
const messageElement = document.querySelector(`[data-drupal-message-id="${messageId}"]`);
if (!messageElement) {
return;
}
this.scrollToElement(messageElement);
}, 50);
};
scrollToElement = (element) => {
// Check if element is visible in viewport.
const rect = element.getBoundingClientRect();
const viewportHeight = window.innerHeight;
const viewportTop = 0;
const isVisible = rect.top < viewportHeight && rect.bottom > viewportTop;
if (!isVisible) {
element.style.scrollMarginTop = 'var(--drupal-displace-offset-top)';
element.scrollIntoView({
behavior: 'smooth',
});
}
}
/**
* Highlight.
*
* Adds a 3s fading background to an element to call attention to it.
*
* @param element
*/
highlight = (element) => {
element?.classList.add('call-attention-to');
setTimeout(() => {
element?.classList.remove('call-attention-to');
}, 3000);
};
}
modeManager.registerPlugin(new EditModePlugin());
})(jQuery, Drupal, once, Drupal.displace);
