Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Experiment] Add freeform image cropper component #63335

Draft
wants to merge 34 commits into
base: trunk
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
4a68ed3
Add freeform image cropper component
kevin940726 Jul 10, 2024
bc1d455
Try rotating 90deg
kevin940726 Jul 16, 2024
100c3a2
Fix rotation
kevin940726 Jul 17, 2024
25ecc7e
Add flip
kevin940726 Jul 17, 2024
03a96e2
Add comments for math
kevin940726 Jul 17, 2024
937d9d4
Set basic max-width and max-height for resizing
kevin940726 Jul 18, 2024
554a34a
Refactor story
kevin940726 Jul 18, 2024
7f3b252
Refactor state
kevin940726 Jul 18, 2024
eb19720
Add some comments
kevin940726 Jul 18, 2024
b09ccf7
Better move algo
kevin940726 Jul 27, 2024
6fe95a4
Smooth animation
kevin940726 Jul 29, 2024
0ad22cf
Fix aspect ratio and generated image
kevin940726 Jul 29, 2024
c08674e
Fix flip (scale)
kevin940726 Jul 29, 2024
ed2e076
Fix pinching animation
kevin940726 Jul 29, 2024
f53ed0c
Rewrite algo
kevin940726 Jul 29, 2024
2593a1a
motionize resizable-box
kevin940726 Jul 29, 2024
4f4de0a
Fix resizing and scaling from cursor position
kevin940726 Jul 31, 2024
2360802
Fix rotation from the cropper center
kevin940726 Jul 31, 2024
b66130c
Fix resizing container
kevin940726 Jul 31, 2024
17c0e0b
Rename rotate action
kevin940726 Aug 6, 2024
f0cbb6a
Fix animation in iframe
kevin940726 Aug 7, 2024
a0f2526
Integrate into the image block behind an experimental flag
kevin940726 Aug 8, 2024
335f726
Add aspect ratio support and remove xy state for cropper
kevin940726 Aug 8, 2024
0591c23
Convert to doc comments for vscode tooltips
ajlende Aug 1, 2024
b1b3579
Add doc comments for actions
ajlende Aug 1, 2024
e107bac
Add back missing lock and unlock aspect ration actions
ajlende Aug 12, 2024
6d49c58
Add return type for better type checking
ajlende Aug 12, 2024
c4987be
Remove MOVE_WINDOW action
ajlende Aug 12, 2024
2cdee02
Refactor cropper state
ajlende Aug 12, 2024
2e63e39
Refactor absScale
ajlende Aug 12, 2024
f83a59f
Simplify flip
ajlende Aug 12, 2024
dc21251
Add aspect ratio dropdown
kevin940726 Aug 9, 2024
cedef1c
Default to inline the image
kevin940726 Aug 16, 2024
3b1affe
Use ID as a namespace to increase specificity
kevin940726 Aug 16, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions lib/experimental/editor-settings.php
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,9 @@ function gutenberg_enable_experiments() {
if ( $gutenberg_experiments && array_key_exists( 'gutenberg-editor-write-mode', $gutenberg_experiments ) ) {
wp_add_inline_script( 'wp-block-editor', 'window.__experimentalEditorWriteMode = true', 'before' );
}
if ( $gutenberg_experiments && array_key_exists( 'gutenberg-image-cropper', $gutenberg_experiments ) ) {
wp_add_inline_script( 'wp-block-editor', 'window.__experimentalImageCropper = true', 'before' );
}
}

add_action( 'admin_init', 'gutenberg_enable_experiments' );
Expand Down
12 changes: 12 additions & 0 deletions lib/experiments-page.php
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,18 @@ function gutenberg_initialize_experiments_settings() {
)
);

add_settings_field(
'gutenberg-image-cropper',
__( 'Redesigned image cropper', 'gutenberg' ),
'gutenberg_display_experiment_field',
'gutenberg-experiments',
'gutenberg_experiments_section',
array(
'label' => __( 'Enable a redesigned version of the image cropper in the block editor.', 'gutenberg' ),
'id' => 'gutenberg-image-cropper',
)
);

register_setting(
'gutenberg-experiments',
'gutenberg-experiments'
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
/**
* WordPress dependencies
*/
import { check, aspectRatio as aspectRatioIcon } from '@wordpress/icons';
import { DropdownMenu, MenuGroup, MenuItem } from '@wordpress/components';
import { __ } from '@wordpress/i18n';

/**
* Internal dependencies
*/
import { useSettings } from '../../use-settings';
import { useImageCropper } from './context';

function AspectRatioGroup( { aspectRatios, label, onClick, value } ) {
return (
<MenuGroup label={ label }>
{ aspectRatios.map( ( { name, slug, ratio } ) => (
<MenuItem
key={ slug }
onClick={ () => {
onClick( ratio );
} }
role="menuitemradio"
isSelected={ ratio.toFixed( 4 ) === value.toFixed( 4 ) }
icon={ ratio === value ? check : undefined }
>
{ name }
</MenuItem>
) ) }
</MenuGroup>
);
}

export function ratioToNumber( str ) {
// TODO: support two-value aspect ratio?
// https://css-tricks.com/almanac/properties/a/aspect-ratio/#aa-it-can-take-two-values
const [ a, b, ...rest ] = str.split( '/' ).map( Number );
if (
a <= 0 ||
b <= 0 ||
Number.isNaN( a ) ||
Number.isNaN( b ) ||
rest.length
) {
return NaN;
}
return b ? a / b : a;
}

function presetRatioAsNumber( { ratio, ...rest } ) {
return {
ratio: ratioToNumber( ratio ),
...rest,
};
}

export default function AspectRatioDropdown( { toggleProps } ) {
const {
state: { image, cropper, isAspectRatioLocked },
dispatch,
} = useImageCropper();
const defaultAspect = image.width / image.height;
const aspectRatio = cropper.width / cropper.height;

const [ defaultRatios, themeRatios, showDefaultRatios ] = useSettings(
'dimensions.aspectRatios.default',
'dimensions.aspectRatios.theme',
'dimensions.defaultAspectRatios'
);

return (
<DropdownMenu
icon={ aspectRatioIcon }
label={ __( 'Aspect Ratio' ) }
popoverProps={ { placement: 'bottom-start' } }
toggleProps={ toggleProps }
>
{ ( { onClose } ) => (
<>
<AspectRatioGroup
onClick={ ( newAspect ) => {
if ( newAspect === 0 ) {
dispatch( { type: 'UNLOCK_ASPECT_RATIO' } );
} else {
dispatch( {
type: 'LOCK_ASPECT_RATIO',
aspectRatio: newAspect,
} );
}
onClose();
} }
value={ isAspectRatioLocked ? aspectRatio : 0 }
aspectRatios={ [
// All ratios should be mirrored in AspectRatioTool in @wordpress/block-editor.
{
slug: 'free',
name: __( 'Free' ),
ratio: 0,
},
{
slug: 'original',
name: __( 'Original' ),
ratio: defaultAspect,
},
...( showDefaultRatios
? defaultRatios
.map( presetRatioAsNumber )
.filter( ( { ratio } ) => ratio === 1 )
: [] ),
] }
/>
{ themeRatios?.length > 0 && (
<AspectRatioGroup
label={ __( 'Theme' ) }
onClick={ ( newAspect ) => {
dispatch( {
type: 'LOCK_ASPECT_RATIO',
aspectRatio: newAspect,
} );
onClose();
} }
value={ aspectRatio }
aspectRatios={ themeRatios }
/>
) }
{ showDefaultRatios && (
<AspectRatioGroup
label={ __( 'Landscape' ) }
onClick={ ( newAspect ) => {
dispatch( {
type: 'LOCK_ASPECT_RATIO',
aspectRatio: newAspect,
} );
onClose();
} }
value={ aspectRatio }
aspectRatios={ defaultRatios
.map( presetRatioAsNumber )
.filter( ( { ratio } ) => ratio > 1 ) }
/>
) }
{ showDefaultRatios && (
<AspectRatioGroup
label={ __( 'Portrait' ) }
onClick={ ( newAspect ) => {
dispatch( {
type: 'LOCK_ASPECT_RATIO',
aspectRatio: newAspect,
} );
onClose();
} }
value={ aspectRatio }
aspectRatios={ defaultRatios
.map( presetRatioAsNumber )
.filter( ( { ratio } ) => ratio < 1 ) }
/>
) }
</>
) }
</DropdownMenu>
);
}
14 changes: 14 additions & 0 deletions packages/block-editor/src/components/image-editor/v2/context.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
/**
* WordPress dependencies
*/
import { privateApis as componentsPrivateApis } from '@wordpress/components';
import { useContext } from '@wordpress/element';

/**
* Internal dependencies
*/
import { unlock } from '../../../lock-unlock';

const { ImageCropperContext } = unlock( componentsPrivateApis );

export const useImageCropper = () => useContext( ImageCropperContext );
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
/**
* WordPress dependencies
*/
import { ToolbarButton } from '@wordpress/components';
import { __ } from '@wordpress/i18n';

/**
* Internal dependencies
*/
import { useImageCropper } from './context';

export default function FormControls( { onCrop, onCancel } ) {
const { state, getImageBlob, dispatch } = useImageCropper();

async function apply() {
const blob = await getImageBlob( state );
onCrop?.( blob, state );
}

function cancel() {
dispatch( { type: 'RESET' } );
onCancel?.();
}

return (
<>
<ToolbarButton onClick={ apply }>{ __( 'Apply' ) }</ToolbarButton>
<ToolbarButton onClick={ cancel }>{ __( 'Cancel' ) }</ToolbarButton>
</>
);
}
47 changes: 47 additions & 0 deletions packages/block-editor/src/components/image-editor/v2/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
/**
* WordPress dependencies
*/
import {
ToolbarGroup,
ToolbarItem,
privateApis as componentsPrivateApis,
} from '@wordpress/components';

/**
* Internal dependencies
*/
import AspectRatioDropdown from './aspect-ratio-dropdown';
import BlockControls from '../../block-controls';
import RotationButton from './rotation-button';
import FormControls from './form-controls';
import { unlock } from '../../../lock-unlock';

const { ImageCropper } = unlock( componentsPrivateApis );

export default function ImageEditor( {
src,
width,
height,
onCrop,
onCancel,
} ) {
return (
<ImageCropper.Provider src={ src } width={ width } height={ height }>
<ImageCropper />

<BlockControls>
<ToolbarGroup>
<ToolbarItem>
{ ( toggleProps ) => (
<AspectRatioDropdown toggleProps={ toggleProps } />
) }
</ToolbarItem>
<RotationButton />
</ToolbarGroup>
<ToolbarGroup>
<FormControls onCrop={ onCrop } onCancel={ onCancel } />
</ToolbarGroup>
</BlockControls>
</ImageCropper.Provider>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
/**
* WordPress dependencies
*/

import { ToolbarButton } from '@wordpress/components';
import { __ } from '@wordpress/i18n';
import { rotateRight as rotateRightIcon } from '@wordpress/icons';

/**
* Internal dependencies
*/
import { useImageCropper } from './context';

export default function RotationButton() {
const { dispatch } = useImageCropper();

return (
<ToolbarButton
icon={ rotateRightIcon }
label={ __( 'Rotate' ) }
onClick={ () => {
dispatch( { type: 'ROTATE_90_DEG' } );
} }
/>
);
}
2 changes: 2 additions & 0 deletions packages/block-editor/src/private-apis.js
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ import useBlockDisplayTitle from './components/block-title/use-block-display-tit
import TabbedSidebar from './components/tabbed-sidebar';
import CommentIconSlotFill from './components/collab/block-comment-icon-slot';
import CommentIconToolbarSlotFill from './components/collab/block-comment-icon-toolbar-slot';
import ImageEditor from './components/image-editor/v2';
/**
* Private @wordpress/block-editor APIs.
*/
Expand Down Expand Up @@ -97,4 +98,5 @@ lock( privateApis, {
sectionRootClientIdKey,
CommentIconSlotFill,
CommentIconToolbarSlotFill,
ImageEditor,
} );
58 changes: 42 additions & 16 deletions packages/block-library/src/image/image.js
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,11 @@ import { useToolsPanelDropdownMenuProps } from '../utils/hooks';
import { MIN_SIZE, ALLOWED_MEDIA_TYPES } from './constants';
import { evalAspectRatio } from './utils';

const { DimensionsTool, ResolutionTool } = unlock( blockEditorPrivateApis );
const {
DimensionsTool,
ResolutionTool,
ImageEditor: ImageEditorV2,
} = unlock( blockEditorPrivateApis );

const scaleOptions = [
{
Expand Down Expand Up @@ -950,21 +954,43 @@ export default function Image( {
if ( canEditImage && isEditingImage ) {
img = (
<ImageWrapper href={ href }>
<ImageEditor
id={ id }
url={ url }
width={ numericWidth }
height={ numericHeight }
naturalHeight={ naturalHeight }
naturalWidth={ naturalWidth }
onSaveImage={ ( imageAttributes ) =>
setAttributes( imageAttributes )
}
onFinishEditing={ () => {
setIsEditingImage( false );
} }
borderProps={ isRounded ? undefined : borderProps }
/>
{ window.__experimentalImageCropper ? (
<ImageEditorV2
src={ url }
width={ numericWidth }
height={ numericHeight }
onCrop={ ( imageBlob ) => {
getSettings().mediaUpload( {
filesList: [ imageBlob ],
onFileChange: ( [ media ] ) => {
onSelectImage( media );
},
allowedTypes: ALLOWED_MEDIA_TYPES,
onError: onUploadError,
} );
setIsEditingImage( false );
} }
onCancel={ () => {
setIsEditingImage( false );
} }
/>
) : (
<ImageEditor
id={ id }
url={ url }
width={ numericWidth }
height={ numericHeight }
naturalHeight={ naturalHeight }
naturalWidth={ naturalWidth }
onSaveImage={ ( imageAttributes ) =>
setAttributes( imageAttributes )
}
onFinishEditing={ () => {
setIsEditingImage( false );
} }
borderProps={ isRounded ? undefined : borderProps }
/>
) }
</ImageWrapper>
);
} else if ( ! isResizable || parentLayoutType === 'grid' ) {
Expand Down
Loading
Loading