From e1f1b8aef4c9cd45620f73aa9c85d8fe15f08d71 Mon Sep 17 00:00:00 2001 From: Federico Ruggi <1081051+ruggi@users.noreply.github.com> Date: Fri, 4 Oct 2024 09:46:38 +0200 Subject: [PATCH] Disable alignment buttons/groups based on element (#6461) **Problem:** Following up to https://github.com/concrete-utopia/utopia/pull/6459, the alignment buttons should be disabled following a comprehensive set of rules. **Fix:** This PR updates the logic to disable alignment buttons and groups following the following logic. - Grid children all have alignment buttons enabled - Flex children have alignment buttons enabled for the orientation opposite to their parent's flex direction - Absolute elements have alignment buttons enabled, if they are not storyboard children - Flow elements have all alignment buttons disabled Fixes #6460 --- .../alignment-buttons.spec.browser2.tsx | 290 ++++++++++++++++++ .../inspector/alignment-buttons.tsx | 11 +- .../inspector/controls/option-control.tsx | 3 +- .../inspector/use-disable-alignment.tsx | 74 +++++ 4 files changed, 373 insertions(+), 5 deletions(-) create mode 100644 editor/src/components/inspector/alignment-buttons.spec.browser2.tsx create mode 100644 editor/src/components/inspector/use-disable-alignment.tsx diff --git a/editor/src/components/inspector/alignment-buttons.spec.browser2.tsx b/editor/src/components/inspector/alignment-buttons.spec.browser2.tsx new file mode 100644 index 000000000000..1158a6aad77b --- /dev/null +++ b/editor/src/components/inspector/alignment-buttons.spec.browser2.tsx @@ -0,0 +1,290 @@ +import { MetadataUtils } from '../../core/model/element-metadata-utils' +import * as EP from '../../core/shared/element-path' +import { renderTestEditorWithCode } from '../canvas/ui-jsx.test-utils' +import { isAlignmentGroupDisabled } from './use-disable-alignment' + +describe('alignment buttons', () => { + describe('isAlignmentGroupDisabled', () => { + it('enables buttons for grid cells', async () => { + const renderResult = await renderTestEditorWithCode(projectCode, 'await-first-dom-report') + const metadata = renderResult.getEditorState().editor.jsxMetadata + + expect( + isAlignmentGroupDisabled( + MetadataUtils.findElementByElementPath(metadata, EP.fromString('sb/grid/grid-child')), + MetadataUtils.findElementByElementPath(metadata, EP.fromString('sb/grid')), + 'horizontal', + [], + ), + ).toBe(false) + }) + it('enables buttons for flex only in the opposite direction', async () => { + const renderResult = await renderTestEditorWithCode(projectCode, 'await-first-dom-report') + const metadata = renderResult.getEditorState().editor.jsxMetadata + + // flex-direction: row + { + expect( + isAlignmentGroupDisabled( + MetadataUtils.findElementByElementPath( + metadata, + EP.fromString('sb/flex-horiz/flex-horiz-child'), + ), + MetadataUtils.findElementByElementPath(metadata, EP.fromString('sb/flex-horiz')), + 'vertical', + [], + ), + ).toBe(false) + expect( + isAlignmentGroupDisabled( + MetadataUtils.findElementByElementPath( + metadata, + EP.fromString('sb/flex-horiz/flex-horiz-child'), + ), + MetadataUtils.findElementByElementPath(metadata, EP.fromString('sb/flex-horiz')), + 'horizontal', + [], + ), + ).toBe(true) + } + + // flex-direction: column + { + expect( + isAlignmentGroupDisabled( + MetadataUtils.findElementByElementPath( + metadata, + EP.fromString('sb/flex-vert/flex-vert-child'), + ), + MetadataUtils.findElementByElementPath(metadata, EP.fromString('sb/flex-vert')), + 'horizontal', + [], + ), + ).toBe(false) + expect( + isAlignmentGroupDisabled( + MetadataUtils.findElementByElementPath( + metadata, + EP.fromString('sb/flex-vert/flex-vert-child'), + ), + MetadataUtils.findElementByElementPath(metadata, EP.fromString('sb/flex-vert')), + 'vertical', + [], + ), + ).toBe(true) + } + }) + it('enables buttons for absolute only if not storyboard children', async () => { + const renderResult = await renderTestEditorWithCode(projectCode, 'await-first-dom-report') + const metadata = renderResult.getEditorState().editor.jsxMetadata + + expect( + isAlignmentGroupDisabled( + MetadataUtils.findElementByElementPath( + metadata, + EP.fromString('sb/flow/flow-child-absolute'), + ), + MetadataUtils.findElementByElementPath(metadata, EP.fromString('sb/flow')), + 'horizontal', + [], + ), + ).toBe(false) + + expect( + isAlignmentGroupDisabled( + MetadataUtils.findElementByElementPath(metadata, EP.fromString('sb/flow')), + MetadataUtils.findElementByElementPath(metadata, EP.fromString('sb')), + 'horizontal', + [], + ), + ).toBe(true) + + expect( + isAlignmentGroupDisabled( + MetadataUtils.findElementByElementPath(metadata, EP.fromString('sb/flow')), + MetadataUtils.findElementByElementPath(metadata, EP.fromString('sb')), + 'horizontal', + [EP.fromString('sb/flow'), EP.fromString('sb/grid')], + ), + ).toBe(false) + }) + it('disables buttons for flow', async () => { + const renderResult = await renderTestEditorWithCode(projectCode, 'await-first-dom-report') + const metadata = renderResult.getEditorState().editor.jsxMetadata + + expect( + isAlignmentGroupDisabled( + MetadataUtils.findElementByElementPath( + metadata, + EP.fromString('sb/flow/flow-child-relative'), + ), + MetadataUtils.findElementByElementPath(metadata, EP.fromString('sb/flow')), + 'horizontal', + [], + ), + ).toBe(true) + }) + }) +}) + +const projectCode = `import * as React from 'react' +import { Storyboard } from 'utopia-api' + +export var storyboard = ( + +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ +) +` diff --git a/editor/src/components/inspector/alignment-buttons.tsx b/editor/src/components/inspector/alignment-buttons.tsx index 3fa2e1841708..130820f8c003 100644 --- a/editor/src/components/inspector/alignment-buttons.tsx +++ b/editor/src/components/inspector/alignment-buttons.tsx @@ -26,6 +26,7 @@ import { Substores, useEditorState, useRefEditorState } from '../editor/store/st import { getControlStyles } from './common/control-styles' import { OptionChainControl } from './controls/option-chain-control' import { UIGridRow } from './widgets/ui-grid-row' +import { useDisableAlignment } from './use-disable-alignment' type ActiveAlignments = { [key in Alignment]: boolean } @@ -34,7 +35,6 @@ export const AlignmentButtons = React.memo(() => { const selectedViews = useRefEditorState((store) => store.editor.selectedViews) - const disableAlign = selectedViews.current.length === 0 const disableDistribute = selectedViews.current.length < 3 const activeAlignments = useActiveAlignments() @@ -171,6 +171,9 @@ export const AlignmentButtons = React.memo(() => { : null }, [activeAlignments]) + const disableJustify = useDisableAlignment(selectedViews.current, 'horizontal') + const disableAlign = useDisableAlignment(selectedViews.current, 'vertical') + return ( { value: 'left', icon: { category: 'inspector', type: 'justify-start' }, forceCallOnSubmitValue: true, - disabled: disableAlign, + disabled: disableJustify, }, { value: 'hcenter', icon: { category: 'inspector', type: 'justify-center' }, forceCallOnSubmitValue: true, - disabled: disableAlign, + disabled: disableJustify, }, { value: 'right', icon: { category: 'inspector', type: 'justify-end' }, forceCallOnSubmitValue: true, - disabled: disableAlign, + disabled: disableJustify, }, ]} /> diff --git a/editor/src/components/inspector/controls/option-control.tsx b/editor/src/components/inspector/controls/option-control.tsx index 328e71da2e21..f01c89636fce 100644 --- a/editor/src/components/inspector/controls/option-control.tsx +++ b/editor/src/components/inspector/controls/option-control.tsx @@ -118,7 +118,8 @@ export const OptionControl: React.FunctionComponent< }, }, '&:hover': { - opacity: props.controlStatus === 'disabled' ? undefined : 1, + opacity: + controlOptions.disabled || props.controlStatus === 'disabled' ? undefined : 1, }, '.control-option-icon-component': { opacity: 0.7, diff --git a/editor/src/components/inspector/use-disable-alignment.tsx b/editor/src/components/inspector/use-disable-alignment.tsx new file mode 100644 index 000000000000..839ced711303 --- /dev/null +++ b/editor/src/components/inspector/use-disable-alignment.tsx @@ -0,0 +1,74 @@ +import React from 'react' +import type { ElementPath } from 'utopia-shared/src/types' +import { MetadataUtils } from '../../core/model/element-metadata-utils' +import type { ElementInstanceMetadata } from '../../core/shared/element-template' +import { useEditorState, Substores } from '../editor/store/store-hook' +import * as EP from '../../core/shared/element-path' + +export function useDisableAlignment( + selectedViews: ElementPath[], + orientation: 'horizontal' | 'vertical', +) { + const selectedElements = useEditorState( + Substores.metadata, + (store) => + selectedViews.map((path) => { + return { + element: MetadataUtils.findElementByElementPath(store.editor.jsxMetadata, path), + parent: MetadataUtils.findElementByElementPath( + store.editor.jsxMetadata, + EP.parentPath(path), + ), + } + }), + 'useDisableAlignment selectedElements', + ) + + return React.useMemo(() => { + if (selectedViews.length === 0) { + return true + } + + return selectedElements.some(({ element, parent }) => { + return isAlignmentGroupDisabled(element, parent, orientation, selectedViews) + }) + }, [selectedViews, selectedElements, orientation]) +} + +export function isAlignmentGroupDisabled( + element: ElementInstanceMetadata | null, + parent: ElementInstanceMetadata | null, + orientation: 'horizontal' | 'vertical', + selection: ElementPath[], +): boolean { + if (element == null || parent == null) { + return true + } + + // grid cells have all alignments available + const isGridCell = MetadataUtils.isGridLayoutedContainer(parent) + if (isGridCell) { + return false + } + + // flex children have alignment enabled on the opposite orientation to their parent's flex direction + const isFlexChild = MetadataUtils.isFlexLayoutedContainer(parent) + if (isFlexChild) { + const flexDirection = MetadataUtils.getFlexDirection(parent) + return flexDirection === 'column' || flexDirection === 'column-reverse' + ? orientation === 'vertical' + : orientation === 'horizontal' + } + + // absolute elements have all alignments available, unless they are storyboard children or all + // selected elements are storyboard children + if ( + MetadataUtils.isPositionAbsolute(element) && + (!EP.isStoryboardChild(element.elementPath) || + (selection.length > 1 && selection.every((other) => EP.isStoryboardChild(other)))) + ) { + return false + } + + return true +}