diff --git a/packages/@steedos-widgets/amis-object/src/amis/AmisRecordDetailHeader.less b/packages/@steedos-widgets/amis-object/src/amis/AmisRecordDetailHeader.less index 1a1271071..1dad7d2fd 100644 --- a/packages/@steedos-widgets/amis-object/src/amis/AmisRecordDetailHeader.less +++ b/packages/@steedos-widgets/amis-object/src/amis/AmisRecordDetailHeader.less @@ -118,4 +118,81 @@ display: none; } } + + // Button overflow protection: prevent buttons from wrapping and breaking header height (65px constraint) + .steedos-header-buttons-col { + overflow: hidden; + min-width: 0; + } + + .steedos-header-buttons { + flex-wrap: nowrap !important; + align-items: center; + } + + // "More" dropdown for overflowed buttons + .steedos-header-overflow-more { + position: relative; + flex-shrink: 0; + margin-left: 4px; + + .steedos-header-overflow-more-btn { + display: flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + border: 1px solid #d9d9d9; + border-radius: 4px; + background: #fff; + cursor: pointer; + padding: 0; + + &:hover { + background: #f5f5f5; + } + + svg { + width: 16px; + height: 16px; + fill: #666; + } + } + + .steedos-header-overflow-menu { + position: absolute; + right: 0; + top: 100%; + z-index: 1050; + background: #fff; + border: 1px solid #e5e5e5; + border-radius: 4px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); + min-width: 120px; + max-height: 300px; + overflow-y: auto; + margin-top: 4px; + + .steedos-header-overflow-menu-item { + padding: 6px 12px; + cursor: pointer; + white-space: nowrap; + font-size: 13px; + color: #333; + line-height: 1.5; + + &:hover { + background: #f5f5f5; + } + + &:first-child { + border-radius: 4px 4px 0 0; + } + + &:last-child { + border-radius: 0 0 4px 4px; + } + } + } + } } \ No newline at end of file diff --git a/packages/@steedos-widgets/amis-object/src/amis/AmisRecordDetailHeader.tsx b/packages/@steedos-widgets/amis-object/src/amis/AmisRecordDetailHeader.tsx index acfe49056..a8de4ae2c 100644 --- a/packages/@steedos-widgets/amis-object/src/amis/AmisRecordDetailHeader.tsx +++ b/packages/@steedos-widgets/amis-object/src/amis/AmisRecordDetailHeader.tsx @@ -8,6 +8,223 @@ import './AmisRecordDetailHeader.less' import { getRecordDetailHeaderSchema , getUISchema} from '@steedos-widgets/amis-lib' +const OVERFLOW_HANDLER_ID = 'steedos_header_overflow_handler'; + +/** + * Traverse the schema JSON to add CSS class names to the button flex container, + * enabling targeted CSS styling and runtime overflow detection. + */ +function addButtonContainerClasses(schema: any) { + if (!schema || !schema.body) return; + const bodyArr = Array.isArray(schema.body) ? schema.body : [schema.body]; + for (const bodyItem of bodyArr) { + if (bodyItem.type === 'wrapper' && bodyItem.body) { + const gridItems = Array.isArray(bodyItem.body) ? bodyItem.body : [bodyItem.body]; + for (const gridItem of gridItems) { + if (gridItem.type === 'grid' && gridItem.columns) { + for (const col of gridItem.columns) { + if (col.body && col.body.type === 'flex') { + col.body.className = ((col.body.className || '') + ' steedos-header-buttons').trim(); + col.columnClassName = ((col.columnClassName || '') + ' steedos-header-buttons-col').trim(); + } + } + } + } + } + } +} + +/** + * Runtime overflow detection script for the amis `custom` component. + * Uses ResizeObserver + MutationObserver to detect when buttons overflow + * the container, and dynamically collapses them into a "more" dropdown. + * + * Parameters provided by amis custom onMount: (dom, value, onChange, props) + */ +const overflowOnMount = ` + var MUTATION_DEBOUNCE_MS = 150; + var INITIAL_CHECK_DELAY_MS = 300; + + var header = dom.closest('.steedos-object-record-detail-header'); + if (!header) return; + + var flexContainer = header.querySelector('.steedos-header-buttons'); + if (!flexContainer) return; + + // Create "more" dropdown container + var moreContainer = document.createElement('div'); + moreContainer.className = 'steedos-header-overflow-more'; + moreContainer.style.display = 'none'; + + var moreBtn = document.createElement('button'); + moreBtn.className = 'steedos-header-overflow-more-btn'; + moreBtn.innerHTML = ''; + + var moreMenu = document.createElement('div'); + moreMenu.className = 'steedos-header-overflow-menu'; + moreMenu.style.display = 'none'; + + moreContainer.appendChild(moreBtn); + moreContainer.appendChild(moreMenu); + flexContainer.appendChild(moreContainer); + + // Toggle menu on click + moreBtn.addEventListener('click', function(e) { + e.stopPropagation(); + moreMenu.style.display = moreMenu.style.display === 'none' ? 'block' : 'none'; + }); + + // Close menu on outside click + var closeHandler = function(e) { + if (!moreContainer.contains(e.target)) { + moreMenu.style.display = 'none'; + } + }; + document.addEventListener('click', closeHandler, true); + + var isUpdating = false; + var rafId = null; + var mutationTimer = null; + + var mutationObserverConfig = { + childList: true, + subtree: true, + attributes: true, + attributeFilter: ['style', 'class'] + }; + + function reconnectMutationObserver() { + // Reconnect on next frame so queued mutation records from our own + // DOM changes are discarded (disconnect clears the queue). + requestAnimationFrame(function() { + mutationObserver.observe(flexContainer, mutationObserverConfig); + }); + } + + function updateOverflow() { + if (isUpdating) return; + isUpdating = true; + // Disconnect MutationObserver during update to prevent infinite loop + // (our own DOM changes would otherwise re-trigger this function) + mutationObserver.disconnect(); + try { + var children = Array.from(flexContainer.children); + var buttonItems = children.filter(function(el) { return el !== moreContainer; }); + + // Reset all overflow-hidden buttons to measure true content width + buttonItems.forEach(function(el) { + if (el.getAttribute('data-overflow-hidden') === 'true') { + el.style.removeProperty('display'); + el.removeAttribute('data-overflow-hidden'); + } + }); + moreContainer.style.display = 'none'; + moreMenu.innerHTML = ''; + + // Force reflow to get accurate measurements + void flexContainer.offsetWidth; + + // Get buttons that are actually visible (not hidden by amis visibleOn) + var visibleButtons = buttonItems.filter(function(el) { + var cs = window.getComputedStyle(el); + return cs.display !== 'none' && cs.visibility !== 'hidden'; + }); + + // No overflow — nothing to do (also handles container not yet rendered) + if (flexContainer.clientWidth === 0 || flexContainer.scrollWidth <= flexContainer.clientWidth) return; + + // Show the "more" dropdown trigger + moreContainer.style.display = 'inline-flex'; + void flexContainer.offsetWidth; + + // Hide buttons from right to left until content fits + for (var i = visibleButtons.length - 1; i >= 0; i--) { + if (flexContainer.scrollWidth <= flexContainer.clientWidth) break; + + var btn = visibleButtons[i]; + btn.setAttribute('data-overflow-hidden', 'true'); + btn.style.display = 'none'; + + // Create a proxy menu item that clicks the original button + var menuItem = document.createElement('div'); + menuItem.className = 'steedos-header-overflow-menu-item'; + + // Extract label text from the amis-rendered button + var labelText = ''; + var innerBtn = btn.querySelector('button'); + if (innerBtn) { + var labelSpan = innerBtn.querySelector('.antd-Button-label'); + labelText = labelSpan ? labelSpan.textContent : innerBtn.textContent; + } + if (!labelText || !labelText.trim()) { + labelText = btn.textContent; + } + menuItem.textContent = (labelText || '').trim() || '...'; + + // Proxy click to the original hidden button + (function(originalBtn) { + menuItem.addEventListener('click', function(e) { + e.stopPropagation(); + moreMenu.style.display = 'none'; + var ct = originalBtn.querySelector('button') || originalBtn.querySelector('a') || originalBtn; + ct.click(); + }); + })(btn); + + moreMenu.insertBefore(menuItem, moreMenu.firstChild); + } + + // Hide dropdown if no items ended up in it + if (moreMenu.children.length === 0) { + moreContainer.style.display = 'none'; + } + } finally { + isUpdating = false; + reconnectMutationObserver(); + } + } + + function debouncedUpdate() { + if (rafId) cancelAnimationFrame(rafId); + rafId = requestAnimationFrame(updateOverflow); + } + + // Watch for container size changes (window resize, split view toggle, etc.) + var resizeObserver = new ResizeObserver(debouncedUpdate); + resizeObserver.observe(flexContainer); + + // Watch for DOM mutations (buttons shown/hidden by amis visibleOn, data loading, etc.) + var mutationObserver = new MutationObserver(function() { + clearTimeout(mutationTimer); + mutationTimer = setTimeout(debouncedUpdate, MUTATION_DEBOUNCE_MS); + }); + mutationObserver.observe(flexContainer, mutationObserverConfig); + + // Initial check after buttons are rendered (delay allows amis to finish rendering) + setTimeout(debouncedUpdate, INITIAL_CHECK_DELAY_MS); + + // Store cleanup function for onUnmount + window.__steedosOverflowCleanup = window.__steedosOverflowCleanup || {}; + window.__steedosOverflowCleanup[props.id] = function() { + resizeObserver.disconnect(); + mutationObserver.disconnect(); + document.removeEventListener('click', closeHandler, true); + if (rafId) cancelAnimationFrame(rafId); + clearTimeout(mutationTimer); + }; +`; + +/** + * Cleanup script for the amis `custom` component onUnmount. + * Parameters provided by amis custom onUnmount: (props) + */ +const overflowOnUnmount = ` + if (window.__steedosOverflowCleanup && window.__steedosOverflowCleanup[props.id]) { + window.__steedosOverflowCleanup[props.id](); + delete window.__steedosOverflowCleanup[props.id]; + } +`; + export const AmisRecordDetailHeader = async (props) => { // console.log(`AmisRecordDetailHeader=====>`, props) //sticky在最新版ios上存在bug,因此暂时去除手机版sticky @@ -18,6 +235,25 @@ export const AmisRecordDetailHeader = async (props) => { let objectApiName = props.objectApiName || "space_users"; const schema = (await getRecordDetailHeaderSchema(objectApiName, recordId, {showRecordTitle, formFactor: props.data.formFactor, showButtons, showBackButton, display: props.data.display, _inDrawer: props.data._inDrawer})).amisSchema; schema.className += " " + className; + + // Add CSS class names to the button flex container for overflow handling + addButtonContainerClasses(schema); + + // Inject runtime overflow handler (amis custom component with ResizeObserver) + if (showButtons !== false) { + if (!Array.isArray(schema.body)) { + schema.body = schema.body ? [schema.body] : []; + } + schema.body.push({ + type: 'custom', + inline: true, + id: OVERFLOW_HANDLER_ID, + html: '', + onMount: overflowOnMount, + onUnmount: overflowOnUnmount + }); + } + // console.log(`AmisRecordDetailHeader======>`, Object.assign({}, schema, {onEvent: onEvent})) let config = Object.assign({}, schema, {onEvent: onEvent})