diff --git a/packages/super-editor/src/editors/v1/components/toolbar/ButtonGroup.vue b/packages/super-editor/src/editors/v1/components/toolbar/ButtonGroup.vue index 1ea11f143c..2a7f980c18 100644 --- a/packages/super-editor/src/editors/v1/components/toolbar/ButtonGroup.vue +++ b/packages/super-editor/src/editors/v1/components/toolbar/ButtonGroup.vue @@ -39,30 +39,37 @@ const props = defineProps({ type: Boolean, default: false, }, + compactSideGroups: { + type: Boolean, + default: false, + }, }); const currentItem = ref(null); const { isHighContrastMode } = useHighContrastMode(); // Matches media query from SuperDoc.vue const isMobile = window.matchMedia('(max-width: 768px)').matches; -const styleMap = { - left: { - minWidth: '120px', - justifyContent: 'flex-start', - }, - right: { - minWidth: '120px', - justifyContent: 'flex-end', - }, - default: { + +const getPositionStyle = computed(() => { + if (props.position === 'left') { + return { + minWidth: props.compactSideGroups ? 'auto' : '120px', + justifyContent: 'flex-start', + }; + } + + if (props.position === 'right') { + return { + minWidth: props.compactSideGroups ? 'auto' : '120px', + justifyContent: 'flex-end', + }; + } + + return { // Only grow if not on a mobile device flexGrow: isMobile ? 0 : 1, justifyContent: 'center', - }, -}; - -const getPositionStyle = computed(() => { - return styleMap[props.position] || styleMap.default; + }; }); const isButton = (item) => item.type === 'button'; diff --git a/packages/super-editor/src/editors/v1/components/toolbar/Toolbar.vue b/packages/super-editor/src/editors/v1/components/toolbar/Toolbar.vue index d2a03efca6..fb2b3c7e00 100644 --- a/packages/super-editor/src/editors/v1/components/toolbar/Toolbar.vue +++ b/packages/super-editor/src/editors/v1/components/toolbar/Toolbar.vue @@ -2,6 +2,7 @@ import { ref, getCurrentInstance, onMounted, onDeactivated, nextTick, computed } from 'vue'; import { throttle } from './helpers.js'; import ButtonGroup from './ButtonGroup.vue'; +import { RESPONSIVE_BREAKPOINTS } from './constants.js'; /** * The default font-family to use for toolbar UI surfaces when no custom font is configured. @@ -14,6 +15,8 @@ const { proxy } = getCurrentInstance(); const emit = defineEmits(['command', 'toggle', 'select']); let toolbarKey = ref(1); +const compactSideGroups = ref(false); +let containerResizeObserver = null; /** * Computed property that determines the font-family to use for toolbar UI surfaces. @@ -40,14 +43,27 @@ const getFilteredItems = (position) => { return proxy.$toolbar.getToolbarItemByGroup(position).filter((item) => !excludeButtonsList.includes(item.name.value)); }; +const updateCompactSideGroups = () => { + compactSideGroups.value = proxy.$toolbar.getAvailableWidth() <= RESPONSIVE_BREAKPOINTS.lg; +}; + onMounted(() => { window.addEventListener('resize', onResizeThrottled); window.addEventListener('keydown', onKeyDown); + if (typeof ResizeObserver !== 'undefined' && proxy.$toolbar.toolbarContainer) { + containerResizeObserver = new ResizeObserver(() => { + onResizeThrottled(); + }); + containerResizeObserver.observe(proxy.$toolbar.toolbarContainer); + } + updateCompactSideGroups(); }); onDeactivated(() => { window.removeEventListener('resize', onResizeThrottled); window.removeEventListener('keydown', onKeyDown); + containerResizeObserver?.disconnect(); + containerResizeObserver = null; }); const onKeyDown = async (e) => { @@ -66,6 +82,7 @@ const onKeyDown = async (e) => { const onWindowResized = async () => { await proxy.$toolbar.onToolbarResize(); + updateCompactSideGroups(); toolbarKey.value += 1; }; const onResizeThrottled = throttle(onWindowResized, 300); @@ -107,6 +124,7 @@ const handleToolbarMousedown = (e) => { tabindex="0" v-if="showLeftSide" :toolbar-items="getFilteredItems('left')" + :compact-side-groups="compactSideGroups" :ui-font-family="uiFontFamily" position="left" @command="handleCommand" @@ -117,6 +135,7 @@ const handleToolbarMousedown = (e) => { tabindex="0" :toolbar-items="getFilteredItems('center')" :overflow-items="proxy.$toolbar.overflowItems" + :compact-side-groups="compactSideGroups" :ui-font-family="uiFontFamily" position="center" @command="handleCommand" @@ -126,6 +145,7 @@ const handleToolbarMousedown = (e) => { tabindex="0" v-if="showRightSide" :toolbar-items="getFilteredItems('right')" + :compact-side-groups="compactSideGroups" :ui-font-family="uiFontFamily" position="right" @command="handleCommand" @@ -148,12 +168,6 @@ const handleToolbarMousedown = (e) => { z-index: var(--sd-ui-toolbar-z-index, 10); } -@media (max-width: 1280px) { - .superdoc-toolbar-group-side { - min-width: auto !important; - } -} - @media (max-width: 768px) { .superdoc-toolbar { padding: 4px 10px; diff --git a/packages/super-editor/src/editors/v1/components/toolbar/ToolbarButton.vue b/packages/super-editor/src/editors/v1/components/toolbar/ToolbarButton.vue index 6747576dcb..62720a3858 100644 --- a/packages/super-editor/src/editors/v1/components/toolbar/ToolbarButton.vue +++ b/packages/super-editor/src/editors/v1/components/toolbar/ToolbarButton.vue @@ -291,21 +291,19 @@ const caretIcon = computed(() => { height: 10px; } -@media (max-width: 1280px) { - .toolbar-item--doc-mode .button-label { - display: none; - } +.toolbar-item--doc-mode-compact .button-label { + display: none; +} - .toolbar-item--doc-mode .toolbar-icon { - margin-right: 5px; - } +.toolbar-item--doc-mode-compact .toolbar-icon { + margin-right: 5px; +} - .toolbar-item--linked-styles { - width: auto !important; - } +.toolbar-item--linked-styles-compact { + width: auto !important; +} - .toolbar-item--linked-styles .button-label { - display: none; - } +.toolbar-item--linked-styles-compact .button-label { + display: none; } diff --git a/packages/super-editor/src/editors/v1/components/toolbar/constants.js b/packages/super-editor/src/editors/v1/components/toolbar/constants.js index 4217900c4e..ab5f4d14f1 100644 --- a/packages/super-editor/src/editors/v1/components/toolbar/constants.js +++ b/packages/super-editor/src/editors/v1/components/toolbar/constants.js @@ -54,6 +54,13 @@ export const TOOLBAR_FONT_SIZES = [ { label: '96', key: '96pt', props: { 'data-item': 'btn-fontSize-option' } }, ]; +export const RESPONSIVE_BREAKPOINTS = { + sm: 768, + md: 1024, + lg: 1280, + xl: 1410, +}; + export const HEADLESS_ITEM_MAP = { undo: 'undo', redo: 'redo', diff --git a/packages/super-editor/src/editors/v1/components/toolbar/defaultItems.js b/packages/super-editor/src/editors/v1/components/toolbar/defaultItems.js index a16e4b00dc..7cad86927e 100644 --- a/packages/super-editor/src/editors/v1/components/toolbar/defaultItems.js +++ b/packages/super-editor/src/editors/v1/components/toolbar/defaultItems.js @@ -14,7 +14,7 @@ import { scrollToElement } from './scroll-helpers.js'; import checkIconSvg from '@superdoc/common/icons/check.svg?raw'; import SearchInput from './SearchInput.vue'; -import { TOOLBAR_FONTS, TOOLBAR_FONT_SIZES } from './constants.js'; +import { RESPONSIVE_BREAKPOINTS, TOOLBAR_FONTS, TOOLBAR_FONT_SIZES } from './constants.js'; import { getQuickFormatList } from '@extensions/linked-styles/index.js'; const closeDropdown = (dropdown) => { @@ -995,18 +995,34 @@ export const makeDefaultItems = ({ }), }); - // Responsive toolbar calculations - const breakpoints = { - sm: 768, - md: 1024, - lg: 1280, - xl: 1410, - }; + // Responsive toolbar calculations. + // `availableWidth` comes from SuperToolbar and represents either: + // - container width when `responsiveToContainer: true` + // - viewport/document width when `responsiveToContainer: false` + + // Extra headroom to prevent toolbar jitter at the XL edge; + // with `<=` this effectively shifts overflow by 21px. + const XL_OVERFLOW_SAFETY_BUFFER = 20; const stickyItemsWidth = 120; const toolbarPadding = 32; const itemsToHideXL = ['linkedStyles', 'clearFormatting', 'copyFormat', 'ruler']; const itemsToHideSM = ['zoom', 'fontFamily', 'fontSize', 'redo']; + const shouldUseLgCompactStyles = availableWidth <= RESPONSIVE_BREAKPOINTS.lg; + + if (shouldUseLgCompactStyles) { + documentMode.attributes.value = { + ...documentMode.attributes.value, + className: `${documentMode.attributes.value.className} toolbar-item--doc-mode-compact`, + }; + } + + if (shouldUseLgCompactStyles) { + linkedStyles.attributes.value = { + ...linkedStyles.attributes.value, + className: `${linkedStyles.attributes.value.className} toolbar-item--linked-styles-compact`, + }; + } let toolbarItems = [ undo, @@ -1053,7 +1069,7 @@ export const makeDefaultItems = ({ } // Hide separators on small screens - if (availableWidth <= breakpoints.md && hideButtons) { + if (availableWidth <= RESPONSIVE_BREAKPOINTS.md && hideButtons) { toolbarItems = toolbarItems.filter((item) => item.type !== 'separator'); } @@ -1088,7 +1104,11 @@ export const makeDefaultItems = ({ toolbarItems.forEach((item) => { const itemWidth = controlSizes.get(item.name.value) || controlSizes.get('default'); - if (availableWidth < breakpoints.xl && itemsToHideXL.includes(item.name.value) && hideButtons) { + if ( + availableWidth <= RESPONSIVE_BREAKPOINTS.xl + XL_OVERFLOW_SAFETY_BUFFER && + itemsToHideXL.includes(item.name.value) && + hideButtons + ) { overflowItems.push(item); if (item.name.value === 'linkedStyles') { const linkedStylesIdx = toolbarItems.findIndex((item) => item.name.value === 'linkedStyles'); @@ -1097,7 +1117,7 @@ export const makeDefaultItems = ({ return; } - if (availableWidth < breakpoints.sm && itemsToHideSM.includes(item.name.value) && hideButtons) { + if (availableWidth < RESPONSIVE_BREAKPOINTS.sm && itemsToHideSM.includes(item.name.value) && hideButtons) { overflowItems.push(item); return; } diff --git a/packages/super-editor/src/editors/v1/components/toolbar/super-toolbar.js b/packages/super-editor/src/editors/v1/components/toolbar/super-toolbar.js index c290c31ad7..97d9341ea6 100644 --- a/packages/super-editor/src/editors/v1/components/toolbar/super-toolbar.js +++ b/packages/super-editor/src/editors/v1/components/toolbar/super-toolbar.js @@ -386,6 +386,16 @@ export class SuperToolbar extends EventEmitter { return this.toolbarItems.find((item) => item.name.value === name); } + /** + * Get the width used for responsive toolbar decisions. + * @returns {number} Available width in pixels + */ + getAvailableWidth() { + const documentWidth = document.documentElement.clientWidth; // take into account the scrollbar + const containerWidth = this.toolbarContainer?.offsetWidth ?? 0; + return this.config.responsiveToContainer ? containerWidth : documentWidth; + } + /** * Create toolbar items based on configuration * @private @@ -397,9 +407,7 @@ export class SuperToolbar extends EventEmitter { * @returns {void} */ #makeToolbarItems({ superToolbar, icons, texts, fonts, hideButtons, isDev = false } = {}) { - const documentWidth = document.documentElement.clientWidth; // take into account the scrollbar - const containerWidth = this.toolbarContainer?.offsetWidth ?? 0; - const availableWidth = this.config.responsiveToContainer ? containerWidth : documentWidth; + const availableWidth = this.getAvailableWidth(); const { defaultItems, overflowItems } = makeDefaultItems({ superToolbar, diff --git a/packages/superdoc/src/dev/components/SuperdocDev.vue b/packages/superdoc/src/dev/components/SuperdocDev.vue index 448e4ba0b0..afd1e36b4f 100644 --- a/packages/superdoc/src/dev/components/SuperdocDev.vue +++ b/packages/superdoc/src/dev/components/SuperdocDev.vue @@ -730,7 +730,7 @@ const init = async () => { // }, // fonts: null, // hideButtons: false, - // responsiveToContainer: true, + responsiveToContainer: true, excludeItems: [], // ['italic', 'bold'], // texts: {}, }, diff --git a/tests/behavior/tests/toolbar/overflow-dropdown-label.spec.ts b/tests/behavior/tests/toolbar/overflow-dropdown-label.spec.ts index 4a608f7515..3d594e1590 100644 --- a/tests/behavior/tests/toolbar/overflow-dropdown-label.spec.ts +++ b/tests/behavior/tests/toolbar/overflow-dropdown-label.spec.ts @@ -29,6 +29,11 @@ test('font family applies and label updates when selected from overflow menu', a // Select Georgia from font family dropdown await superdoc.page.locator('[data-item="btn-fontFamily"]').click(); await superdoc.waitForStable(); + // Wait for the dropdown options to appear + await superdoc.page + .locator('[data-item="btn-fontFamily-option"]') + .first() + .waitFor({ state: 'visible', timeout: 5000 }); await superdoc.page.locator('[data-item="btn-fontFamily-option"]').filter({ hasText: 'Georgia' }).click(); await superdoc.waitForStable();