diff --git a/src/extension/_locales/de/messages.json b/src/extension/_locales/de/messages.json index c93e0f78..266a0c26 100644 --- a/src/extension/_locales/de/messages.json +++ b/src/extension/_locales/de/messages.json @@ -317,6 +317,9 @@ "dark_mode": { "message": "Dunkles Design" }, + "drag_to_reposition": { + "message": "Zum Neupositionieren ziehen" + }, "delete": { "message": "Löschen" }, diff --git a/src/extension/_locales/en/messages.json b/src/extension/_locales/en/messages.json index 72203c46..aceca29f 100644 --- a/src/extension/_locales/en/messages.json +++ b/src/extension/_locales/en/messages.json @@ -256,6 +256,9 @@ "dark_mode": { "message": "Dark theme" }, + "drag_to_reposition": { + "message": "Drag to reposition" + }, "delete": { "message": "Delete" }, diff --git a/src/extension/_locales/es/messages.json b/src/extension/_locales/es/messages.json index fdd38de5..dad39d1f 100644 --- a/src/extension/_locales/es/messages.json +++ b/src/extension/_locales/es/messages.json @@ -317,6 +317,9 @@ "dark_mode": { "message": "Tema oscuro" }, + "drag_to_reposition": { + "message": "Arrastrar para cambiar la posición" + }, "delete": { "message": "Eliminar" }, diff --git a/src/extension/_locales/fr/messages.json b/src/extension/_locales/fr/messages.json index 6d826d78..c403bb65 100644 --- a/src/extension/_locales/fr/messages.json +++ b/src/extension/_locales/fr/messages.json @@ -317,6 +317,9 @@ "dark_mode": { "message": "Thème sombre" }, + "drag_to_reposition": { + "message": "Faire glisser pour repositionner" + }, "delete": { "message": "Supprimer" }, diff --git a/src/extension/_locales/it/messages.json b/src/extension/_locales/it/messages.json index 3b1eec6d..cb456f42 100644 --- a/src/extension/_locales/it/messages.json +++ b/src/extension/_locales/it/messages.json @@ -317,6 +317,9 @@ "dark_mode": { "message": "Tema scuro" }, + "drag_to_reposition": { + "message": "Trascina per riposizionare" + }, "delete": { "message": "Elimina" }, diff --git a/src/extension/_locales/ja/messages.json b/src/extension/_locales/ja/messages.json index 828aee09..24b7e048 100644 --- a/src/extension/_locales/ja/messages.json +++ b/src/extension/_locales/ja/messages.json @@ -317,6 +317,9 @@ "dark_mode": { "message": "ダークテーマ" }, + "drag_to_reposition": { + "message": "ドラッグして位置を変更" + }, "delete": { "message": "削除" }, diff --git a/src/extension/_locales/ko/messages.json b/src/extension/_locales/ko/messages.json index 18fd7fe9..c797bfb7 100644 --- a/src/extension/_locales/ko/messages.json +++ b/src/extension/_locales/ko/messages.json @@ -317,6 +317,9 @@ "dark_mode": { "message": "어두운 테마" }, + "drag_to_reposition": { + "message": "재배치하려면 드래그" + }, "delete": { "message": "삭제" }, diff --git a/src/extension/_locales/pt_BR/messages.json b/src/extension/_locales/pt_BR/messages.json index 902b6069..43a8ab13 100644 --- a/src/extension/_locales/pt_BR/messages.json +++ b/src/extension/_locales/pt_BR/messages.json @@ -317,6 +317,9 @@ "dark_mode": { "message": "Tema escuro" }, + "drag_to_reposition": { + "message": "Arraste para reposicionar" + }, "delete": { "message": "Excluir" }, diff --git a/src/extension/_locales/zh_CN/messages.json b/src/extension/_locales/zh_CN/messages.json index 520faa79..d69c81bc 100644 --- a/src/extension/_locales/zh_CN/messages.json +++ b/src/extension/_locales/zh_CN/messages.json @@ -317,6 +317,9 @@ "dark_mode": { "message": "深色主题" }, + "drag_to_reposition": { + "message": "拖动以重新定位" + }, "delete": { "message": "删除" }, diff --git a/src/extension/_locales/zh_TW/messages.json b/src/extension/_locales/zh_TW/messages.json index c2812d9c..1ed4028f 100644 --- a/src/extension/_locales/zh_TW/messages.json +++ b/src/extension/_locales/zh_TW/messages.json @@ -317,6 +317,9 @@ "dark_mode": { "message": "深色主題" }, + "drag_to_reposition": { + "message": "拖曳以重新定位" + }, "delete": { "message": "刪除" }, diff --git a/src/extension/app/aem-sidekick.css.js b/src/extension/app/aem-sidekick.css.js index 56fc9c03..441bc6bc 100644 --- a/src/extension/app/aem-sidekick.css.js +++ b/src/extension/app/aem-sidekick.css.js @@ -66,8 +66,8 @@ export const style = css` @media (max-width: 800px) { plugin-action-bar { min-width: unset; - width: 100%; - max-width: unset; + width: 100vw; + max-width: 100vw; bottom: 0; } } diff --git a/src/extension/app/components/action-bar/action-bar.js b/src/extension/app/components/action-bar/action-bar.js index 09a436ea..27dd44fe 100644 --- a/src/extension/app/components/action-bar/action-bar.js +++ b/src/extension/app/components/action-bar/action-bar.js @@ -19,6 +19,7 @@ export class ActionBar extends LitElement { .action-bar { display: flex; border-radius: var(--spectrum2-sidekick-border-radius); + clip-path: border-box; color: var(--spectrum2-sidekick-color)); background-color: var(--spectrum2-sidekick-background); border: 1px solid var(--spectrum2-sidekick-border-color); @@ -59,6 +60,7 @@ export class ActionBar extends LitElement { .action-bar { border-radius: 0; } + } `; render() { diff --git a/src/extension/app/components/plugin/env-switcher/env-switcher.js b/src/extension/app/components/plugin/env-switcher/env-switcher.js index 6905424c..094ad006 100644 --- a/src/extension/app/components/plugin/env-switcher/env-switcher.js +++ b/src/extension/app/components/plugin/env-switcher/env-switcher.js @@ -376,6 +376,6 @@ export class EnvironmentSwitcher extends ConnectedElement { } render() { - return html``; + return html``; } } diff --git a/src/extension/app/components/plugin/plugin-action-bar.css.js b/src/extension/app/components/plugin/plugin-action-bar.css.js index a89c852d..98787599 100644 --- a/src/extension/app/components/plugin/plugin-action-bar.css.js +++ b/src/extension/app/components/plugin/plugin-action-bar.css.js @@ -15,6 +15,11 @@ import { css } from 'lit'; export const style = css` + :host([dragging="true"]) { + user-select: none; + opacity: 0.95; + } + action-bar > div.action-group { display: flex; padding: 12px; @@ -38,7 +43,9 @@ export const style = css` flex-shrink: 0; } - + action-bar > div.plugin-menu-container.hidden { + display: none; + } action-bar > div.badge-plugins-container { position: absolute; @@ -55,16 +62,48 @@ export const style = css` } action-bar .logo { - padding: 12px; - width: 32px; + width: 42px; height: 32px; + padding: 12px 12px 12px 0; + display: flex; + align-items: center; + justify-content: center; + gap: 0; + } + + action-bar .logo > svg { + width: 24px; + } + + action-bar .logo .drag-handle { display: flex; align-items: center; justify-content: center; + height: 100%; + cursor: grab; + flex-shrink: 0; + user-select: none; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + padding: 0 8px; + } + + action-bar .logo .drag-handle .drag-bar { + width: 2px; + height: 16px; + background-color: var(--spectrum2-sidekick-color); + opacity: 0.2; + border-radius: 2px; + transition: opacity 0.2s; + } + + action-bar .logo .drag-handle:hover .drag-bar { + opacity: 0.5; } - action-bar .logo > svg{ - width: 56px; + action-bar .logo .drag-handle:active { + cursor: grabbing; } #plugin-menu { @@ -126,8 +165,6 @@ export const style = css` width: 37px; flex-shrink: 0; cursor: pointer; - border-top-right-radius: var(--spectrum2-sidekick-border-radius); - border-bottom-right-radius: var(--spectrum2-sidekick-border-radius); background-color: var(--spectrum2-sidekick-background-close); backdrop-filter: var(--sidekick-backdrop-filter); } @@ -161,16 +198,15 @@ export const style = css` color: var(--spectrum-global-color-gray-600); } - @media (max-width: 500px) { - #properties { - display: none; + @media (max-width: 800px) { + action-bar .logo { + width: 32px; + min-width: 32px; + padding-left: 12px; } - } - @media (max-width: 800px) { - action-bar .close-button { - border-top-right-radius: 0; - border-bottom-right-radius: 0; + action-bar .logo .drag-handle { + display: none; } } `; diff --git a/src/extension/app/components/plugin/plugin-action-bar.js b/src/extension/app/components/plugin/plugin-action-bar.js index 946e6034..dc871461 100644 --- a/src/extension/app/components/plugin/plugin-action-bar.js +++ b/src/extension/app/components/plugin/plugin-action-bar.js @@ -13,7 +13,9 @@ /* eslint-disable max-len */ import { html } from 'lit'; -import { customElement, queryAll, queryAsync } from 'lit/decorators.js'; +import { + customElement, queryAll, queryAsync, state, +} from 'lit/decorators.js'; import { ifDefined } from 'lit/directives/if-defined.js'; import { reaction } from 'mobx'; import { @@ -36,19 +38,16 @@ import { getConfig } from '../../../config.js'; */ /** - * The gap between plugins in the plugin group - */ -const PLUGIN_GROUP_GAP = 8; - -/** - * The maximum width of the action bar + * The maximum width of the action bar (from CSS)) + * @type {number} */ const ACTION_BAR_MAX_WIDTH = 800; /** - * The threshold for overflow + * The gap between plugins in the plugin group (from CSS) + * @type {number} */ -const OVERFLOW_THRESHOLD = -10; +const PLUGIN_GAP = 8; @customElement('plugin-action-bar') export class PluginActionBar extends ConnectedElement { @@ -65,10 +64,10 @@ export class PluginActionBar extends ConnectedElement { visiblePlugins = []; /** - * The plugins visible in the action bar. + * All pinned plugins (to be distributed to the action bar/menu) * @type {Plugin[]} */ - barPlugins = []; + pinnedPlugins = []; /** * The plugins folded into the action menu. @@ -77,10 +76,10 @@ export class PluginActionBar extends ConnectedElement { menuPlugins = []; /** - * The plugins temporarily folded into the action menu. + * The badge plugins. * @type {Plugin[]} */ - transientPlugins = []; + badgePlugins = []; /** * The current width of the action bar. @@ -88,12 +87,86 @@ export class PluginActionBar extends ConnectedElement { */ actionBarWidth = 0; + /** + * The last window width we calculated for + * @type {number} + */ + lastWindowWidth = 0; + + /** + * Last plugin count (to detect plugin visibility changes) + * @type {number} + */ + lastPluginCount = 0; + + /** + * Whether plugin distribution is currently running + * @type {boolean} + */ + isDistributing = false; + + /** + * Timeout ID for post-resize plugin distribution check + * @type {number|null} + */ + resizeDistributingTimeout = null; + /** * The configured projects * @type {Array} */ projects = []; + /** + * Whether the action bar is currently being dragged + * @type {boolean} + */ + isDragging = false; + + /** + * The initial X coordinate when drag starts + * @type {number} + */ + dragStartX = 0; + + /** + * The initial Y coordinate when drag starts + * @type {number} + */ + dragStartY = 0; + + /** + * The initial left position (translateX) when drag starts + * @type {number} + */ + initialLeft = 0; + + /** + * The initial bottom position when drag starts + * @type {number} + */ + initialBottom = 0; + + /** + * Throttle timer for window resize handler + * @type {number|null} + */ + resizeThrottle = null; + + /** + * The plugins visible in the action bar. + * @type {Plugin[]} + */ + @state() + accessor barPlugins = []; + + /** + * The plugins temporarily folded into the action menu. + * @type {Plugin[]} + */ + @state() + accessor transientPlugins = []; + @queryAsync('action-bar') accessor actionBar; @@ -112,28 +185,8 @@ export class PluginActionBar extends ConnectedElement { @queryAsync('.close-button') accessor closeButton; - /** - * Set up the bar and menu plugins in this environment and updates the component. - */ - setupPlugins() { - this.transientPlugins = []; - - this.visiblePlugins = [ - ...Object.values(this.appStore.corePlugins), - ...Object.values(this.appStore.customPlugins), - ].filter((plugin) => plugin.isVisible()); - - this.barPlugins = this.visiblePlugins - .filter((plugin) => plugin.isPinned() && !plugin.isBadge()); - - this.menuPlugins = this.visiblePlugins - .filter((plugin) => !plugin.isPinned() && !plugin.isBadge()); - - this.badgePlugins = this.visiblePlugins - .filter((plugin) => plugin.isBadge()); - - this.requestUpdate(); - } + @queryAsync('.drag-handle') + accessor dragHandle; async connectedCallback() { super.connectedCallback(); @@ -191,13 +244,237 @@ export class PluginActionBar extends ConnectedElement { } }); + window.addEventListener('resize', this.onWindowResize); + this.requestUpdate(); } disconnectedCallback() { super.disconnectedCallback(); this.removeEventListener('click', this.onClick); + window.removeEventListener('resize', this.onWindowResize); EventBus.instance.removeEventListener(EVENTS.CLOSE_POPOVER); + + // Clear any pending throttled resize + if (this.resizeThrottle) { + window.clearTimeout(this.resizeThrottle); + this.resizeThrottle = null; + } + + // Clear any pending post-resize check + if (this.resizeDistributingTimeout) { + window.clearTimeout(this.resizeDistributingTimeout); + this.resizeDistributingTimeout = null; + } + } + + /** + * Set up the bar and menu plugins in this environment and updates the component. + */ + setupPlugins() { + this.visiblePlugins = [ + ...Object.values(this.appStore.corePlugins), + ...Object.values(this.appStore.customPlugins), + ].filter((plugin) => plugin.isVisible()); + + // Store all pinned plugins (before distribution) + this.pinnedPlugins = this.visiblePlugins + .filter((plugin) => plugin.isPinned() && !plugin.isBadge()); + + // Non-pinned plugins go directly to menu (not subject to distribution) + this.menuPlugins = this.visiblePlugins + .filter((plugin) => !plugin.isPinned() && !plugin.isBadge()); + + this.badgePlugins = this.visiblePlugins + .filter((plugin) => plugin.isBadge()); + + this.requestUpdate(); + } + + /** + * Handle window resize + */ + onWindowResize = () => { + // Throttle resize handling to avoid performance issues + if (this.resizeThrottle) { + return; + } + + this.resizeThrottle = window.setTimeout(() => { + if (this.hasAttribute('style')) { + if (window.innerWidth <= ACTION_BAR_MAX_WIDTH) { + // Below 800px, remove custom positioning + this.removeAttribute('style'); + } else { + // Restrict custom positioning to viewport + this.constrainToViewport(); + } + } + this.resizeThrottle = null; + }, 150); + + // Trigger plugin redistribution with resize flag + this.distributePlugins(true); + }; + + /** + * Prevent selection during drag + * @param {Event} e The event + */ + preventSelection = (e) => { + if (this.isDragging) { + e.preventDefault(); + e.stopPropagation(); + } + }; + + /** + * Handle mouse leaving viewport during drag + */ + onMouseLeave = () => { + if (this.isDragging) { + this.onDragEnd(); + } + }; + + /** + * Handle drag move + * @param {MouseEvent} e The mouse event + */ + onDragMove = (e) => { + if (!this.isDragging) return; + + e.preventDefault(); + + // Calculate delta + const deltaX = e.clientX - this.dragStartX; + const deltaY = this.dragStartY - e.clientY; // Inverted because bottom increases upward + + // Calculate new position + const newLeft = this.initialLeft + deltaX; + const newBottom = this.initialBottom + deltaY; + + // Apply new position + this.style.left = '50%'; + this.style.transform = `translate(${newLeft}px, 0px)`; + this.style.bottom = `${newBottom}px`; + }; + + /** + * Handle drag end + */ + onDragEnd = (e) => { + if (!this.isDragging) return; + + if (e) { + e.preventDefault(); + } + + this.isDragging = false; + + // Reset drag start positions + this.dragStartX = 0; + this.dragStartY = 0; + + // Remove event listeners + window.removeEventListener('mousemove', this.onDragMove, false); + window.removeEventListener('mouseup', this.onDragEnd, false); + window.removeEventListener('selectstart', this.preventSelection, true); + window.removeEventListener('mouseleave', this.onMouseLeave, false); + + // Remove dragging attribute + this.removeAttribute('dragging'); + + // Constrain to viewport + this.constrainToViewport(); + }; + + /** + * Handle drag start + * @param {MouseEvent} e The mouse event + */ + onDragStart = (e) => { + e.preventDefault(); + e.stopPropagation(); + + this.isDragging = true; + + this.appStore.sampleRUM('click', { source: 'sidekick', target: 'sidekick-dragged' }); + + // Store initial mouse position + this.dragStartX = e.clientX; + this.dragStartY = e.clientY; + + // Get current position + const computedStyle = window.getComputedStyle(this); + const { transform, bottom } = computedStyle; + + // Parse current position from transform and bottom + if (transform && transform !== 'none') { + const matrix = new DOMMatrixReadOnly(transform); + this.initialLeft = matrix.m41; // translateX value + } else { + this.initialLeft = 0; + } + + this.initialBottom = parseInt(bottom, 10); + + // Add move and up listeners + window.addEventListener('mousemove', this.onDragMove, false); + window.addEventListener('mouseup', this.onDragEnd, false); + window.addEventListener('selectstart', this.preventSelection, true); + window.addEventListener('mouseleave', this.onMouseLeave, false); + + // Set dragging attribute for CSS styling + this.setAttribute('dragging', 'true'); + }; + + /** + * Constrain the element to viewport bounds + */ + constrainToViewport() { + const rect = this.getBoundingClientRect(); + const viewportWidth = window.innerWidth; + const viewportHeight = window.innerHeight; + + // Get current values + const computedStyle = window.getComputedStyle(this); + const { transform, bottom } = computedStyle; + + let translateX = 0; + if (transform && transform !== 'none') { + const matrix = new DOMMatrixReadOnly(transform); + translateX = matrix.m41; + } + + const currentBottom = parseInt(this.style.bottom || bottom, 10) || 0; + + // Check horizontal bounds + let newTranslateX = translateX; + let newLeft = '50%'; // Default for too far left + if (rect.left < 0) { + // Too far left + newTranslateX = translateX - rect.left; + } else if (rect.right > viewportWidth) { + // Too far right + newTranslateX = translateX - (rect.right - viewportWidth); + newLeft = '49%'; // Account for scrollbar + } + + // Check vertical bounds + let newBottom = currentBottom; + if (rect.top < 0) { + // Too far up - decrease bottom to move element down + newBottom = Math.max(0, currentBottom + rect.top); + } else if (rect.bottom > viewportHeight) { + // Too far down - increase bottom to move element up + newBottom = currentBottom + (rect.bottom - viewportHeight); + } + + // Apply constrained position + this.style.left = newLeft; + this.style.transform = `translate(${newTranslateX}px, 0px)`; + this.style.bottom = `${newBottom}px`; } /** @@ -212,92 +489,116 @@ export class PluginActionBar extends ConnectedElement { return width + padding * 2; }; - async checkOverflow() { + /** + * Distribute plugins between bar and menu based on available space. + * @param {boolean} resizing - Whether this is called during a resize event + * @param {boolean} checkAfterResize - Whether this is a post-resize verification check + */ + async distributePlugins(resizing = false, checkAfterResize = false) { + // Prevent parallel execution + if (this.isDistributing) { + return; + } + + // Wait for DOM elements to be ready if (this.actionGroups.length < 3) { - // wait for all action groups to be rendered return; } - const [logoContainer, closeButton] = await Promise.all([this.logoContainer, this.closeButton]); - - // Action bar styles - const barWidth = parseInt(window.getComputedStyle(this).width, 10); - const logoWidth = this.getTotalWidth(logoContainer); - const closeButtonWidth = parseInt(window.getComputedStyle(closeButton).width, 10); - - const [pluginGroup, pluginMenu, systemGroup] = this.actionGroups; - - // Left plugin container styles - const pluginGroupStyles = window.getComputedStyle(pluginGroup); - const pluginGroupPadding = parseInt(pluginGroupStyles.padding, 10); - const pluginGroupWidth = parseInt(pluginGroupStyles.width, 10); - const pluginGroupWidthIncludingPadding = pluginGroupWidth + (pluginGroupPadding * 2); - - const pluginMenuWidth = this.getTotalWidth(pluginMenu); - const systemWidth = this.getTotalWidth(systemGroup); - - // Combined width of system plugins, plugin menu, and close button - const rightWidth = pluginMenuWidth + systemWidth + closeButtonWidth; - - // Combined width of left logo and plugin group - const leftWidth = pluginGroupWidthIncludingPadding + logoWidth; - - // Total free space in the bar, minus 3px to account for the dividers - const totalFreeSpace = barWidth - leftWidth - rightWidth - 3; - - // If there's not enough space for the plugins in the bar, move the last plugin to the menu - // We check against -10 to avoid endless loop caused after a plugin is added to back to the bar - if (totalFreeSpace <= OVERFLOW_THRESHOLD && this.barPlugins.length > 1) { - this.transientPlugins.unshift(this.barPlugins.pop()); - this.requestUpdate(); - // If we don't need to remove a plugin and if the action bar is less than 800px wide, - // the logic must adjust to check if there's space for the next plugin - } else if (window.innerWidth < ACTION_BAR_MAX_WIDTH) { - const nextPlugin = this.transientPlugins[0]; - if (nextPlugin) { - const children = Array.from(this.actionGroups[0].children); - - let childrenWidth = children.reduce((acc, child) => acc + child.clientWidth, 0); - // Account for the gap between the plugins - childrenWidth += (children.length - 1) * PLUGIN_GROUP_GAP; - - // Calculate the hypothetical new width of the plugins group if we added the next plugin - const nextPluginWidth = nextPlugin.getEstimatedWidth(); - const newWidth = nextPluginWidth + childrenWidth + PLUGIN_GROUP_GAP; - - // If the new width is less than the plugin group width, add the plugin to the bar - if (newWidth < pluginGroupWidth) { - this.barPlugins.push(this.transientPlugins.shift()); - this.requestUpdate(); + // Only recalculate if window width or plugin count changed + const currentWindowWidth = window.innerWidth; + const currentPluginCount = this.pinnedPlugins.length; + if (currentWindowWidth === this.lastWindowWidth + && currentPluginCount === this.lastPluginCount) { + return; + } + + this.isDistributing = true; + + try { + // Schedule a post-resize check if we're resizing but not already in a check + if (resizing && !checkAfterResize) { + // Clear any pending post-resize check + if (this.resizeDistributingTimeout) { + window.clearTimeout(this.resizeDistributingTimeout); } + // Schedule a new check after UI has settled + this.resizeDistributingTimeout = window.setTimeout(() => { + this.resizeDistributingTimeout = null; + this.distributePlugins(true, true); + }, 150); } - } else { - // If the action bar is wider than 800px, and we don't need to remove a plugin, check if there's space for the next plugin - const extraSpace = ACTION_BAR_MAX_WIDTH - barWidth; - if (this.transientPlugins.length > 0) { - const nextPlugin = this.transientPlugins[0]; - if (nextPlugin) { - const nextPluginWidth = nextPlugin.getEstimatedWidth(); - if (nextPluginWidth < extraSpace) { - this.barPlugins.push(this.transientPlugins.shift()); - this.requestUpdate(); - } + + this.lastWindowWidth = currentWindowWidth; + this.lastPluginCount = currentPluginCount; + + // Determine maximum bar width based on window size + const maxBarWidth = currentWindowWidth > ACTION_BAR_MAX_WIDTH + ? ACTION_BAR_MAX_WIDTH + : currentWindowWidth; + + // Get fixed width elements + const [logoContainer, closeButton] = await Promise.all([ + this.logoContainer, + this.closeButton, + ]); + + // Get actual rendered widths + const logoWidth = parseInt(window.getComputedStyle(logoContainer).width, 10); + const closeButtonWidth = parseInt(window.getComputedStyle(closeButton).width, 10); + + // Get system plugins width and plugin menu button width + const [pluginGroup, pluginMenu, systemGroup] = this.actionGroups; + const pluginGroupPadding = parseInt(window.getComputedStyle(pluginGroup).padding, 10) * 2; + const pluginMenuWidth = this.getTotalWidth(pluginMenu); + const systemWidth = this.getTotalWidth(systemGroup); + + // Calculate fixed widths (always reserve space for plugin menu even when hidden) + const fixedWidth = logoWidth + pluginGroupPadding + 3 + systemWidth + pluginMenuWidth + closeButtonWidth; + + // Calculate available space for plugins with safety margin + // Safety margin accounts for estimation errors (estimated vs actual rendered widths) + const safetyMargin = 16; + const availableWidth = maxBarWidth - fixedWidth - safetyMargin; + + // Distribute plugins + const fittingPlugins = []; + const overflowPlugins = []; + let accumulatedWidth = 0; + let hasOverflowed = false; // Track if we've started overflowing + + // Iterate through plugins in original order to preserve sequence + for (const plugin of this.pinnedPlugins) { + const pluginWidth = plugin.getEstimatedWidth(); + const gapWidth = fittingPlugins.length > 0 ? PLUGIN_GAP : 0; + const requiredWidth = accumulatedWidth + pluginWidth + gapWidth; + + // Always keep env-switcher in the bar, regardless of space + const isEnvSwitcher = plugin.id === 'env-switcher'; + const fitsInBar = requiredWidth <= availableWidth; + + // Add to bar if: it's env-switcher OR (it fits AND nothing has overflowed yet) + if (isEnvSwitcher || (fitsInBar && !hasOverflowed)) { + fittingPlugins.push(plugin); + accumulatedWidth = requiredWidth; + } else { + // Plugin overflows - add to menu and mark that we've started overflowing + overflowPlugins.push(plugin); + hasOverflowed = true; } } - } - this.actionBarWidth = barWidth; - } - - firstUpdated() { - window.addEventListener('resize', () => { - this.checkOverflow(); - }); + // Update reactive arrays with new distribution + this.barPlugins = [...fittingPlugins]; + this.transientPlugins = [...overflowPlugins]; + } finally { + this.isDistributing = false; + } } async updated() { await this.updateComplete; - this.checkOverflow(); + await this.distributePlugins(); } // istanbul ignore next 7 @@ -342,6 +643,9 @@ export class PluginActionBar extends ConnectedElement { return html` @@ -387,8 +691,10 @@ export class PluginActionBar extends ConnectedElement { return html`
`; } + const isHidden = this.menuPlugins.length === 0 && this.transientPlugins.length === 0; + return html` -
+
${this.transientPlugins.length > 0 || this.menuPlugins.length > 0 ? html` !store.isAdmin(), }, appStore); diff --git a/src/extension/app/store/app.js b/src/extension/app/store/app.js index 2577a856..4eb6c1ab 100644 --- a/src/extension/app/store/app.js +++ b/src/extension/app/store/app.js @@ -355,7 +355,9 @@ export class AppStore { let processedUrl; if (url) { const target = new URL(url, `https://${innerHost}/`); - target.searchParams.set('theme', this.theme); + if (isPalette || isPopover) { + target.searchParams.set('theme', this.theme); + } if (passConfig) { target.searchParams.append('ref', this.siteStore.ref); target.searchParams.append('repo', this.siteStore.repo); diff --git a/test/app/components/bulk/bulk-info.test.js b/test/app/components/bulk/bulk-info.test.js index 64f31164..29a59235 100644 --- a/test/app/components/bulk/bulk-info.test.js +++ b/test/app/components/bulk/bulk-info.test.js @@ -104,7 +104,7 @@ describe('Test Bulk Info (sharepoint)', () => { path: '/foo/non-latin', file: 'non-latin.docx', type: 'docx', - }, 'list', true)); + }, 'list')); const bulkInfo = recursiveQuery(sidekick, 'bulk-info'); await sidekickTest.toggleAdminItems(['non-latin']); diff --git a/test/app/components/plugin/plugin-action-bar.test.js b/test/app/components/plugin/plugin-action-bar.test.js index 6add2801..b5d766a9 100644 --- a/test/app/components/plugin/plugin-action-bar.test.js +++ b/test/app/components/plugin/plugin-action-bar.test.js @@ -766,6 +766,58 @@ describe('Plugin action bar', () => { customPluginId, ]); }).timeout(100000); + + it('clears existing resize distributing timeout on subsequent resize', async () => { + sidekickTest + .mockFetchStatusSuccess() + .mockFetchSidekickConfigSuccess() + .mockHelixEnvironment(HelixMockEnvironments.PREVIEW); + + sidekick = sidekickTest.createSidekick(); + await sidekickTest.awaitEnvSwitcher(); + + const actionBar = recursiveQuery(sidekick, 'plugin-action-bar'); + expect(actionBar).to.exist; + + // Set up a mock timeout to simulate a previous resize + const mockTimeout = window.setTimeout(() => {}, 200); + actionBar.resizeDistributingTimeout = mockTimeout; + expect(actionBar.resizeDistributingTimeout).to.equal(mockTimeout); + + // Spy on clearTimeout to verify line 523 is executed + const clearTimeoutSpy = sidekickTest.sandbox.spy(window, 'clearTimeout'); + + // Change window width to trigger recalculation + const originalWidth = window.innerWidth; + Object.defineProperty(window, 'innerWidth', { + writable: true, + configurable: true, + value: 700, + }); + actionBar.lastWindowWidth = 600; + + // Call distributePlugins with resizing=true + // This should execute line 523 which clears the existing timeout + await actionBar.distributePlugins(true, false); + + // Verify clearTimeout was called with the mock timeout (line 523) + expect(clearTimeoutSpy.calledWith(mockTimeout)).to.be.true; + + // Verify a new timeout was set (different from the mock) + expect(actionBar.resizeDistributingTimeout).to.not.be.null; + expect(actionBar.resizeDistributingTimeout).to.not.equal(mockTimeout); + + // Cleanup + if (actionBar.resizeDistributingTimeout) { + clearTimeout(actionBar.resizeDistributingTimeout); + actionBar.resizeDistributingTimeout = null; + } + Object.defineProperty(window, 'innerWidth', { + writable: true, + configurable: true, + value: originalWidth, + }); + }); }); describe('login states', () => { @@ -1266,9 +1318,11 @@ describe('Plugin action bar', () => { const actionBar = recursiveQuery(sidekick, 'plugin-action-bar'); expect(actionBar).to.exist; - // Find the actual plugin instance in barPlugins or menuPlugins - const plugin = [...actionBar.barPlugins, ...actionBar.menuPlugins] - .find((p) => p.getId() === 'test-popover'); + // Wait for plugins to be distributed + await waitUntil(() => actionBar.visiblePlugins && actionBar.visiblePlugins.length > 0); + + // Find the actual plugin instance in visiblePlugins (matches implementation) + const plugin = actionBar.visiblePlugins.find((p) => p.getId() === 'test-popover'); expect(plugin).to.exist; expect(plugin.isPopover()).to.be.true; @@ -1306,9 +1360,11 @@ describe('Plugin action bar', () => { const actionBar = recursiveQuery(sidekick, 'plugin-action-bar'); expect(actionBar).to.exist; - // Find the actual plugin instance in barPlugins or menuPlugins - const plugin = [...actionBar.barPlugins, ...actionBar.menuPlugins] - .find((p) => p.getId() === 'test-popover'); + // Wait for plugins to be distributed + await waitUntil(() => actionBar.visiblePlugins && actionBar.visiblePlugins.length > 0); + + // Find the actual plugin instance in visiblePlugins (matches implementation) + const plugin = actionBar.visiblePlugins.find((p) => p.getId() === 'test-popover'); expect(plugin).to.exist; // Mock the plugin's closePopover method @@ -1345,9 +1401,11 @@ describe('Plugin action bar', () => { const actionBar = recursiveQuery(sidekick, 'plugin-action-bar'); expect(actionBar).to.exist; - // Find the actual plugin instance in barPlugins or menuPlugins - const plugin = [...actionBar.barPlugins, ...actionBar.menuPlugins] - .find((p) => p.getId() === 'test-popover'); + // Wait for plugins to be distributed + await waitUntil(() => actionBar.visiblePlugins && actionBar.visiblePlugins.length > 0); + + // Find the actual plugin instance in visiblePlugins (matches implementation) + const plugin = actionBar.visiblePlugins.find((p) => p.getId() === 'test-popover'); expect(plugin).to.exist; // Mock the plugin's closePopover method @@ -1362,4 +1420,436 @@ describe('Plugin action bar', () => { expect(closePopoverStub.called).to.be.false; }); }); + + describe('test repositioning', () => { + let actionBar; + + beforeEach(async () => { + sidekickTest + .mockFetchEditorStatusSuccess() + .mockFetchSidekickConfigSuccess() + .createSidekick(); + + // Wait for action bar to be created + await waitUntil(() => recursiveQuery(sidekickTest.sidekick, 'plugin-action-bar'), 'Action bar not found'); + + actionBar = recursiveQuery(sidekickTest.sidekick, 'plugin-action-bar'); + expect(actionBar).to.exist; + }); + + it('starts dragging', () => { + const mouseEvent = new MouseEvent('mousedown', { + bubbles: true, + cancelable: true, + clientX: 100, + clientY: 200, + }); + + actionBar.onDragStart(mouseEvent); + + // Check drag state + expect(actionBar.isDragging).to.be.true; + expect(actionBar.dragStartX).to.equal(100); + expect(actionBar.dragStartY).to.equal(200); + expect(actionBar.getAttribute('dragging')).to.equal('true'); + + // Clean up + actionBar.onDragEnd(); + }); + + it('moves element during drag', () => { + // Start drag + actionBar.onDragStart(new MouseEvent('mousedown', { + bubbles: true, + cancelable: true, + clientX: 100, + clientY: 200, + })); + + // Simulate mousemove + const mouseMoveEvent = new MouseEvent('mousemove', { + bubbles: true, + cancelable: true, + clientX: 150, + clientY: 250, + }); + actionBar.onDragMove(mouseMoveEvent); + + // Check that position changed + expect(actionBar.style.transform).to.include('translate'); + expect(actionBar.style.left).to.equal('50%'); + + // Clean up + actionBar.onDragEnd(); + }); + + it('ends dragging', () => { + // Start drag + actionBar.onDragStart(new MouseEvent('mousedown', { + bubbles: true, + cancelable: true, + clientX: 100, + clientY: 200, + })); + + expect(actionBar.isDragging).to.be.true; + + // End drag + actionBar.onDragEnd(new MouseEvent('mouseup', { + bubbles: true, + cancelable: true, + })); + + // Check drag state + expect(actionBar.isDragging).to.be.false; + expect(actionBar.dragStartX).to.equal(0); + expect(actionBar.dragStartY).to.equal(0); + expect(actionBar.getAttribute('dragging')).to.be.null; + }); + + it('ends dragging when mouse leaves viewport', () => { + // Start drag + actionBar.onDragStart(new MouseEvent('mousedown', { + bubbles: true, + cancelable: true, + clientX: 100, + clientY: 200, + })); + + expect(actionBar.isDragging).to.be.true; + + // Simulate mouse leaving viewport + actionBar.onMouseLeave(); + + // Check drag state + expect(actionBar.isDragging).to.be.false; + expect(actionBar.getAttribute('dragging')).to.be.null; + }); + + it('prevents text selection during drag', () => { + // Start drag + actionBar.onDragStart(new MouseEvent('mousedown', { + bubbles: true, + cancelable: true, + clientX: 100, + clientY: 200, + })); + + // Try to select text + const selectEvent = new Event('selectstart', { + bubbles: true, + cancelable: true, + }); + const preventDefaultSpy = sidekickTest.sandbox.spy(selectEvent, 'preventDefault'); + + actionBar.preventSelection(selectEvent); + + // Check that selection was prevented + expect(preventDefaultSpy.calledOnce).to.be.true; + + // Clean up + actionBar.onDragEnd(); + }); + + it('constrains element to viewport bounds - left edge', async () => { + // Set position beyond left edge + actionBar.style.left = '50%'; + actionBar.style.transform = 'translate(-1000px, 0px)'; + actionBar.style.bottom = '20px'; + + actionBar.constrainToViewport(); + + // Check that position was constrained + // Should be constrained to not go off-screen + const rect = actionBar.getBoundingClientRect(); + expect(rect.left).to.be.at.least(0); + }); + + it('constrains element to viewport bounds - right edge', async () => { + // Set position beyond right edge + actionBar.style.left = '50%'; + actionBar.style.transform = 'translate(1000px, 0px)'; + actionBar.style.bottom = '20px'; + + actionBar.constrainToViewport(); + + // Check that position was constrained + const rect = actionBar.getBoundingClientRect(); + expect(rect.right).to.be.at.most(window.innerWidth); + }); + + it('constrains element to viewport bounds - top edge', async () => { + // Set position beyond top edge + actionBar.style.left = '50%'; + actionBar.style.transform = 'translate(0px, 0px)'; + actionBar.style.bottom = '2000px'; + + actionBar.constrainToViewport(); + + // Check that position was constrained + const rect = actionBar.getBoundingClientRect(); + expect(rect.top).to.be.at.least(0); + }); + + it('constrains element to viewport bounds - bottom edge', async () => { + // Set position beyond bottom edge + actionBar.style.left = '50%'; + actionBar.style.transform = 'translate(0px, 0px)'; + actionBar.style.bottom = '-1000px'; + + actionBar.constrainToViewport(); + + // Check that position was constrained + const bottom = parseInt(actionBar.style.bottom, 10); + expect(bottom).to.be.at.least(0); + }); + + it('does not move if not dragging', async () => { + // Simulate mousemove without starting drag + const initialTransform = actionBar.style.transform; + + window.dispatchEvent(new MouseEvent('mousemove', { + bubbles: true, + cancelable: true, + clientX: 150, + clientY: 250, + })); + + // Position should not change + expect(actionBar.style.transform).to.equal(initialTransform); + }); + + it('does not end drag if not dragging', async () => { + expect(actionBar.isDragging).to.be.false; + + // Try to end drag + actionBar.onDragEnd(); + + // Should be a no-op + expect(actionBar.isDragging).to.be.false; + }); + + it('clears resize throttle on disconnect', async () => { + // Trigger a resize to set the throttle + actionBar.onWindowResize(); + + // Verify throttle was set + expect(actionBar.resizeThrottle).to.not.be.null; + + // Disconnect the component + actionBar.disconnectedCallback(); + + // Verify throttle was cleared + expect(actionBar.resizeThrottle).to.be.null; + + // Verify the timeout was actually cleared by checking it doesn't fire + const requestUpdateSpy = sidekickTest.sandbox.spy(actionBar, 'requestUpdate'); + + // Wait longer than the throttle delay (150ms) + await aTimeout(200); + + // requestUpdate should not have been called because the timeout was cleared + expect(requestUpdateSpy.called).to.be.false; + }); + + it('clears resize distributing timeout on disconnect', async () => { + // Manually set the timeout to simulate a resize event + actionBar.resizeDistributingTimeout = window.setTimeout(() => { + actionBar.distributePlugins(true, true); + }, 200); + + // Verify timeout was set + expect(actionBar.resizeDistributingTimeout).to.not.be.null; + + // Spy on distributePlugins to verify it's not called after disconnect + const distributePluginsSpy = sidekickTest.sandbox.spy(actionBar, 'distributePlugins'); + + // Disconnect the component + actionBar.disconnectedCallback(); + + // Verify timeout was cleared + expect(actionBar.resizeDistributingTimeout).to.be.null; + + // Wait longer than the distributing delay (200ms) + await aTimeout(250); + + // distributePlugins should not have been called because the timeout was cleared + expect(distributePluginsSpy.called).to.be.false; + }); + + it('throttles resize events', () => { + // First resize should set throttle + actionBar.onWindowResize(); + expect(actionBar.resizeThrottle).to.not.be.null; + + const firstThrottleId = actionBar.resizeThrottle; + + // Second immediate resize should return early and keep same throttle + actionBar.onWindowResize(); + expect(actionBar.resizeThrottle).to.equal(firstThrottleId); + }); + + it('removes custom position on resize below 800px after throttle', async () => { + // Set custom position + actionBar.style.left = '50%'; + actionBar.style.transform = 'translate(100px, 0px)'; + actionBar.style.bottom = '50px'; + + expect(actionBar.hasAttribute('style')).to.be.true; + + // Mock window.innerWidth to be below 800px + const originalInnerWidth = window.innerWidth; + Object.defineProperty(window, 'innerWidth', { + writable: true, + configurable: true, + value: 600, + }); + + // Trigger resize + actionBar.onWindowResize(); + + // Wait for throttled callback to execute + await aTimeout(200); + + // Style should be removed + expect(actionBar.hasAttribute('style')).to.be.false; + + // Restore original innerWidth + Object.defineProperty(window, 'innerWidth', { + writable: true, + configurable: true, + value: originalInnerWidth, + }); + }); + + it('constrains position on resize above 800px after throttle', async () => { + // Set position beyond viewport + actionBar.style.left = '50%'; + actionBar.style.transform = 'translate(-2000px, 0px)'; + actionBar.style.bottom = '50px'; + + // Mock window.innerWidth to be above 800px + const originalInnerWidth = window.innerWidth; + Object.defineProperty(window, 'innerWidth', { + writable: true, + configurable: true, + value: 1200, + }); + + // Spy on constrainToViewport + const constrainSpy = sidekickTest.sandbox.spy(actionBar, 'constrainToViewport'); + + // Trigger resize + actionBar.onWindowResize(); + + // Wait for throttled callback to execute + await aTimeout(200); + + // constrainToViewport should have been called + expect(constrainSpy.calledOnce).to.be.true; + + // Restore original innerWidth + Object.defineProperty(window, 'innerWidth', { + writable: true, + configurable: true, + value: originalInnerWidth, + }); + }); + + it('does not move when not dragging', () => { + expect(actionBar.isDragging).to.be.false; + + const mouseMoveEvent = new MouseEvent('mousemove', { + bubbles: true, + cancelable: true, + clientX: 150, + clientY: 250, + }); + + // Should return early and not throw + actionBar.onDragMove(mouseMoveEvent); + + // No error means early return worked + expect(actionBar.isDragging).to.be.false; + }); + + it('handles drag start without existing transform', () => { + // Ensure no transform is set + actionBar.style.transform = 'none'; + + const mouseEvent = new MouseEvent('mousedown', { + bubbles: true, + cancelable: true, + clientX: 100, + clientY: 200, + }); + + actionBar.onDragStart(mouseEvent); + + // initialLeft should be 0 when no transform exists + expect(actionBar.initialLeft).to.equal(0); + expect(actionBar.isDragging).to.be.true; + + // Clean up + actionBar.onDragEnd(); + }); + + it('does not prevent selection when not dragging', () => { + expect(actionBar.isDragging).to.be.false; + + const selectEvent = new Event('selectstart', { + bubbles: true, + cancelable: true, + }); + const preventDefaultSpy = sidekickTest.sandbox.spy(selectEvent, 'preventDefault'); + const stopPropagationSpy = sidekickTest.sandbox.spy(selectEvent, 'stopPropagation'); + + // Call preventSelection when not dragging + actionBar.preventSelection(selectEvent); + + // Selection should NOT be prevented + expect(preventDefaultSpy.called).to.be.false; + expect(stopPropagationSpy.called).to.be.false; + }); + + it('does not end drag on mouse leave when not dragging', () => { + expect(actionBar.isDragging).to.be.false; + + const onDragEndSpy = sidekickTest.sandbox.spy(actionBar, 'onDragEnd'); + + // Call onMouseLeave when not dragging + actionBar.onMouseLeave(); + + // onDragEnd should NOT be called + expect(onDragEndSpy.called).to.be.false; + }); + + it('constrains using computed bottom when style.bottom is not set', () => { + // Set position without setting style.bottom (only transform) + actionBar.style.left = '50%'; + actionBar.style.transform = 'translate(-2000px, 0px)'; + // Don't set actionBar.style.bottom - it should use computed style + + // Call constrainToViewport + actionBar.constrainToViewport(); + + // Should successfully constrain without error + const rect = actionBar.getBoundingClientRect(); + expect(rect.left).to.be.at.least(0); + }); + + it('constrains using 0 when bottom value is invalid', () => { + // Set invalid bottom value that parseInt cannot parse + actionBar.style.left = '50%'; + actionBar.style.transform = 'translate(-2000px, 0px)'; + actionBar.style.bottom = 'auto'; + + // Call constrainToViewport - should use 0 as fallback + actionBar.constrainToViewport(); + + // Should successfully constrain without error + const rect = actionBar.getBoundingClientRect(); + expect(rect.left).to.be.at.least(0); + }); + }); }); diff --git a/test/app/plugins/bulk/bulk-copy-urls.test.js b/test/app/plugins/bulk/bulk-copy-urls.test.js index bcc3324b..161303f1 100644 --- a/test/app/plugins/bulk/bulk-copy-urls.test.js +++ b/test/app/plugins/bulk/bulk-copy-urls.test.js @@ -105,8 +105,7 @@ describe('Bulk preview plugin', () => { }); it('bulk copy prod urls calls bulkStore.copyUrls() with prod host', async () => { - // add prod host - appStore.loadContext(sidekickTest.sidekick, { + await appStore.loadContext(sidekickTest.sidekick, { ...defaultSidekickConfig, host: 'www.example.com', }); diff --git a/test/app/store/app.test.js b/test/app/store/app.test.js index 30a5c47e..7ee9ab77 100644 --- a/test/app/store/app.test.js +++ b/test/app/store/app.test.js @@ -111,6 +111,53 @@ describe('Test App Store', () => { expect(appStore.siteStore.project).to.eq('AEM Boilerplate'); }); + it('loadContext - adds theme parameter to palette and popover plugin URLs', async () => { + sidekickTest + .mockFetchSidekickConfigSuccess(false, false, { + plugins: [ + { + id: 'palette-plugin', + title: 'Palette Plugin', + url: 'https://example.com/palette.html', + isPalette: true, + }, + { + id: 'popover-plugin', + title: 'Popover Plugin', + url: 'https://example.com/popover.html', + isPopover: true, + }, + { + id: 'regular-plugin', + title: 'Regular Plugin', + url: 'https://example.com/regular.html', + }, + ], + }); + + await appStore.loadContext(sidekickElement, defaultSidekickConfig); + + // Wait for plugins to be loaded + await waitUntil(() => Object.keys(appStore.customPlugins).length > 0); + + // Check palette plugin has theme parameter + const palettePlugin = appStore.customPlugins['palette-plugin']; + expect(palettePlugin).to.exist; + expect(palettePlugin.config.url).to.include('theme='); + expect(palettePlugin.config.url).to.include(`theme=${appStore.theme}`); + + // Check popover plugin has theme parameter + const popoverPlugin = appStore.customPlugins['popover-plugin']; + expect(popoverPlugin).to.exist; + expect(popoverPlugin.config.url).to.include('theme='); + expect(popoverPlugin.config.url).to.include(`theme=${appStore.theme}`); + + // Check regular plugin does NOT have theme parameter + const regularPlugin = appStore.customPlugins['regular-plugin']; + expect(regularPlugin).to.exist; + expect(regularPlugin.config.url).to.not.include('theme='); + }); + it('loadContext - loads german dictionary', async () => { sidekickTest .mockFetchSidekickConfigSuccess(true, true)