IM: (Cropper) make image fill entire shape
also keep icon shape in sync with cropper shape closes MAT-827 flag=buttons_and_icons_root_account flag=buttons_and_icons_cropper test plan: -enable the ffs, find an RCE -create an icon, upload an image -crop to some non-square shape and submit >make sure the image fills the whole shape you chose and that the icon tray shape field updated to the shape that you chose -now change the shape in the icon tray >make sure the preview updates and re-crops the image, filling the entire shape once again >open the cropper and make sure that it defaults to the same shape that was chosen in the tray >try different combinations of shape / size / outline size and fill and make sure that all icons look correct -i.e., fill is behind the image, image always fills full shape, image shrinks to fit inside the outline, etc. -try making icons with single and multi color images too and make sure that nothing has broken with them Change-Id: I38966a7c226e13782f54f8360b3d932a3fb295fe Reviewed-on: https://gerrit.instructure.com/c/canvas-lms/+/300520 Tested-by: Service Cloud Jenkins <svc.cloudjenkins@instructure.com> Reviewed-by: Juan Chavez <juan.chavez@instructure.com> QA-Review: Juan Chavez <juan.chavez@instructure.com> Product-Review: David Lyons <lyons@instructure.com>
This commit is contained in:
parent
15b4348b6a
commit
e3cf19fe72
|
@ -87,7 +87,8 @@ export const ImageCropperModal = ({
|
|||
image,
|
||||
message,
|
||||
cropSettings,
|
||||
loading
|
||||
loading,
|
||||
trayDispatch
|
||||
}) => {
|
||||
const [settings, dispatch] = useReducer(cropperSettingsReducer, defaultState)
|
||||
useEffect(() => {
|
||||
|
@ -108,6 +109,7 @@ export const ImageCropperModal = ({
|
|||
onDismiss={onClose}
|
||||
onSubmit={e => {
|
||||
e.preventDefault()
|
||||
trayDispatch({shape: settings.shape})
|
||||
handleSubmit(onSubmit, settings).then(onClose).catch(onClose)
|
||||
}}
|
||||
shouldCloseOnDocumentClick={false}
|
||||
|
@ -136,7 +138,8 @@ ImageCropperModal.propTypes = {
|
|||
open: PropTypes.bool,
|
||||
onClose: PropTypes.func,
|
||||
onSubmit: PropTypes.func,
|
||||
loading: PropTypes.bool
|
||||
loading: PropTypes.bool,
|
||||
trayDispatch: PropTypes.func.isRequired
|
||||
}
|
||||
|
||||
ImageCropperModal.defaultProps = {
|
||||
|
|
|
@ -30,55 +30,57 @@ jest.mock('../imageCropUtils', () => ({
|
|||
}))
|
||||
|
||||
describe('ImageCropperModal', () => {
|
||||
let props
|
||||
|
||||
const renderComponent = (overrides = {}) => {
|
||||
return render(<ImageCropperModal {...props} {...overrides} />)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
props = {
|
||||
open: true,
|
||||
onSubmit: jest.fn(),
|
||||
image: 'data:image/png;base64,asdfasdfjksdf==',
|
||||
trayDispatch: jest.fn()
|
||||
}
|
||||
})
|
||||
|
||||
beforeAll(() => {
|
||||
global.fetch = jest.fn().mockResolvedValue({
|
||||
blob: () => Promise.resolve(new Blob(['somedata'], {type: 'image/svg+xml'}))
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
it('renders the message', () => {
|
||||
const onSubmit = jest.fn()
|
||||
render(
|
||||
<ImageCropperModal
|
||||
open
|
||||
onSubmit={onSubmit}
|
||||
image="data:image/png;base64,asdfasdfjksdf=="
|
||||
message="Banana"
|
||||
/>
|
||||
)
|
||||
renderComponent({message: 'Banana'})
|
||||
expect(screen.getByTestId('alert-message')).toBeInTheDocument()
|
||||
expect(screen.getByText(/banana/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("doesn't render the message", () => {
|
||||
const onSubmit = jest.fn()
|
||||
render(
|
||||
<ImageCropperModal open onSubmit={onSubmit} image="data:image/png;base64,asdfasdfjksdf==" />
|
||||
)
|
||||
renderComponent()
|
||||
expect(screen.queryByTestId('alert-message')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calls onSubmit function', async () => {
|
||||
const onSubmit = jest.fn()
|
||||
render(
|
||||
<ImageCropperModal open onSubmit={onSubmit} image="data:image/png;base64,asdfasdfjksdf==" />
|
||||
)
|
||||
renderComponent()
|
||||
const button = screen.getByRole('button', {name: /save/i})
|
||||
userEvent.click(button)
|
||||
await waitFor(() => {
|
||||
expect(onSubmit).toHaveBeenCalled()
|
||||
expect(props.onSubmit).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('call onSubmit function with correct args', async () => {
|
||||
const onSubmit = jest.fn()
|
||||
render(
|
||||
<ImageCropperModal open onSubmit={onSubmit} image="data:image/png;base64,asdfasdfjksdf==" />
|
||||
)
|
||||
renderComponent()
|
||||
const button = screen.getByRole('button', {name: /save/i})
|
||||
userEvent.click(button)
|
||||
await waitFor(() => {
|
||||
expect(onSubmit).toHaveBeenCalledWith(
|
||||
expect(props.onSubmit).toHaveBeenCalledWith(
|
||||
{
|
||||
image: 'data:image/png;base64,asdfasdfjksdf==',
|
||||
rotation: 0,
|
||||
|
@ -91,4 +93,14 @@ describe('ImageCropperModal', () => {
|
|||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('calls trayDispatch with the correct args', async () => {
|
||||
renderComponent()
|
||||
userEvent.click(screen.getByTestId('shape-select-dropdown'))
|
||||
userEvent.click(screen.getByText('Circle'))
|
||||
userEvent.click(screen.getByRole('button', {name: /save/i}))
|
||||
await waitFor(() => {
|
||||
expect(props.trayDispatch).toHaveBeenCalledWith({shape: 'circle'})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
@ -44,6 +44,7 @@ export const ShapeControls = ({shape, onChange}) => {
|
|||
value={shape}
|
||||
onChange={(event, {id}) => onChange(id)}
|
||||
renderLabel={null}
|
||||
data-testid="shape-select-dropdown"
|
||||
>
|
||||
{SHAPE_OPTIONS.map(option => (
|
||||
<SimpleSelect.Option key={option.id} id={option.id} value={option.id}>
|
||||
|
|
|
@ -91,7 +91,7 @@ function renderImageActionButtons({mode, collectionOpen}, dispatch, setFocus, re
|
|||
)
|
||||
}
|
||||
|
||||
export const ImageOptions = ({state, dispatch, rcsConfig}) => {
|
||||
export const ImageOptions = ({state, dispatch, rcsConfig, trayDispatch}) => {
|
||||
const [isImageActionFocused, setIsImageActionFocused] = useState(false)
|
||||
const imageActionRef = useCallback(
|
||||
el => {
|
||||
|
@ -135,6 +135,7 @@ export const ImageOptions = ({state, dispatch, rcsConfig}) => {
|
|||
cropSettings={state.cropperSettings}
|
||||
message={state.compressed ? getCompressionMessage() : null}
|
||||
loading={!image}
|
||||
trayDispatch={trayDispatch}
|
||||
/>
|
||||
)}
|
||||
</Flex.Item>
|
||||
|
@ -153,5 +154,6 @@ ImageOptions.propTypes = {
|
|||
compressed: PropTypes.bool.isRequired
|
||||
}).isRequired,
|
||||
dispatch: PropTypes.func.isRequired,
|
||||
rcsConfig: PropTypes.object.isRequired
|
||||
rcsConfig: PropTypes.object.isRequired,
|
||||
trayDispatch: PropTypes.func.isRequired
|
||||
}
|
||||
|
|
|
@ -72,6 +72,14 @@ export const ImageSection = ({settings, onChange, editing, editor, rcsConfig}) =
|
|||
|
||||
const isMetadataLoaded = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
dispatch({
|
||||
type: actions.SET_CROPPER_SETTINGS.type,
|
||||
payload: {...state.cropperSettings, shape: settings.shape}
|
||||
})
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [settings.shape])
|
||||
|
||||
useEffect(() => {
|
||||
const transform = transformForShape(settings.shape, settings.size)
|
||||
|
||||
|
@ -109,12 +117,16 @@ export const ImageSection = ({settings, onChange, editing, editor, rcsConfig}) =
|
|||
}, [onChange, settings.shape, settings.size])
|
||||
|
||||
useEffect(() => {
|
||||
if (editing && !!settings.encodedImage) {
|
||||
if (
|
||||
(editing && !!settings.encodedImage) ||
|
||||
(!!settings.encodedImage && settings.encodedImage !== state.image)
|
||||
) {
|
||||
dispatch({
|
||||
type: actions.SET_IMAGE.type,
|
||||
payload: settings.encodedImage
|
||||
})
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [editing, settings.encodedImage])
|
||||
|
||||
useEffect(() => {
|
||||
|
@ -199,7 +211,12 @@ export const ImageSection = ({settings, onChange, editing, editor, rcsConfig}) =
|
|||
<Text weight="bold">{formatMessage('Current Image')}</Text>
|
||||
</Flex.Item>
|
||||
<Flex.Item>
|
||||
<ImageOptions state={state} dispatch={dispatch} rcsConfig={rcsConfig} />
|
||||
<ImageOptions
|
||||
state={state}
|
||||
dispatch={dispatch}
|
||||
rcsConfig={rcsConfig}
|
||||
trayDispatch={onChange}
|
||||
/>
|
||||
</Flex.Item>
|
||||
</Flex>
|
||||
</Flex.Item>
|
||||
|
|
|
@ -40,7 +40,9 @@ describe('ImageOptions', () => {
|
|||
cropperOpen: false,
|
||||
loading: false
|
||||
},
|
||||
dispatch: dispatchFn
|
||||
dispatch: dispatchFn,
|
||||
rcsConfig: {},
|
||||
trayDispatch: () => {}
|
||||
}
|
||||
|
||||
beforeAll(() => {
|
||||
|
@ -129,27 +131,27 @@ describe('ImageOptions', () => {
|
|||
}
|
||||
|
||||
it('focuses Clear button when an image is selected', async () => {
|
||||
const {getByTestId, rerender} = render(<ImageOptions state={state} />)
|
||||
const {getByTestId, rerender} = subject({state})
|
||||
|
||||
const addImage = await getByTestId('add-image')
|
||||
act(() => addImage.focus())
|
||||
|
||||
state.image = 'data:image/png;base64,asdfasdfjksdf=='
|
||||
|
||||
rerender(<ImageOptions state={state} />)
|
||||
rerender(<ImageOptions {...defaultProps} state={state} />)
|
||||
|
||||
await waitFor(() => expect(getByTestId('clear-image')).toHaveFocus())
|
||||
})
|
||||
|
||||
it('focuses Add Image button when an image is cleared', async () => {
|
||||
state.image = 'data:image/png;base64,asdfasdfjksdf=='
|
||||
const {getByTestId, rerender} = render(<ImageOptions state={state} />)
|
||||
const {getByTestId, rerender} = subject({state})
|
||||
|
||||
const clearImage = getByTestId('clear-image')
|
||||
act(() => clearImage.focus())
|
||||
|
||||
state.image = null
|
||||
rerender(<ImageOptions state={state} />)
|
||||
rerender(<ImageOptions {...defaultProps} state={state} />)
|
||||
|
||||
await waitFor(() => expect(getByTestId('add-image')).toHaveFocus())
|
||||
})
|
||||
|
|
|
@ -212,20 +212,20 @@ describe('ImageSection', () => {
|
|||
})
|
||||
|
||||
describe('calls onChange passing metadata when state prop changes', () => {
|
||||
let getByTestId, getByText, getByTitle, getByRole, container
|
||||
let getByTestId, getByText, getByTitle, container
|
||||
|
||||
const lastPayloadOfActionType = (mockFn, type) =>
|
||||
mockFn.mock.calls.reverse().find(call => call[0].type === type)[0].payload
|
||||
|
||||
beforeEach(() => {
|
||||
const rendered = subject({
|
||||
rcsConfig: {features: {icon_maker_cropper: true}}
|
||||
rcsConfig: {features: {icon_maker_cropper: true}},
|
||||
settings: {size: Size.Small, shape: 'square'}
|
||||
})
|
||||
|
||||
getByTestId = rendered.getByTestId
|
||||
getByText = rendered.getByText
|
||||
getByTitle = rendered.getByTitle
|
||||
getByRole = rendered.getByRole
|
||||
container = rendered.container
|
||||
})
|
||||
|
||||
|
@ -291,12 +291,11 @@ describe('ImageSection', () => {
|
|||
await act(async () => {
|
||||
jest.runOnlyPendingTimers()
|
||||
})
|
||||
fireEvent.click(getByRole('button', {name: /crop image/i}))
|
||||
await act(async () => {
|
||||
jest.runOnlyPendingTimers()
|
||||
})
|
||||
// Zooms in just to change cropper settings
|
||||
fireEvent.click(getByTestId('zoom-in-button'))
|
||||
await waitFor(() =>
|
||||
expect(document.querySelector('[data-cid="Modal"] [type="submit"]')).toBeInTheDocument()
|
||||
)
|
||||
fireEvent.click(document.querySelector('[data-cid="Modal"] [type="submit"]'))
|
||||
await act(async () => {
|
||||
jest.runOnlyPendingTimers()
|
||||
|
|
|
@ -16,7 +16,7 @@
|
|||
* with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import React from 'react'
|
||||
import React, {useEffect} from 'react'
|
||||
|
||||
import {Flex} from '@instructure/ui-flex'
|
||||
import {SimpleSelect} from '@instructure/ui-simple-select'
|
||||
|
@ -25,43 +25,69 @@ import formatMessage from '../../../../../format-message'
|
|||
import {Shape} from '../../svg/shape'
|
||||
import {Size} from '../../svg/constants'
|
||||
|
||||
import {createCroppedImageSvg} from './ImageCropper/imageCropUtils'
|
||||
import {convertFileToBase64} from '../../svg/utils'
|
||||
|
||||
import {actions} from '../../reducers/svgSettings'
|
||||
|
||||
const SIZES = [Size.ExtraSmall, Size.Small, Size.Medium, Size.Large]
|
||||
|
||||
export const ShapeSection = ({settings, onChange}) => (
|
||||
<Flex as="section" direction="column" justifyItems="space-between" padding="small small 0">
|
||||
<Flex.Item padding="small">
|
||||
<SimpleSelect
|
||||
assistiveText={formatMessage('Use arrow keys to select a shape.')}
|
||||
id="icon-shape"
|
||||
onChange={(e, option) => onChange({shape: option.value})}
|
||||
renderLabel={formatMessage('Icon Shape')}
|
||||
value={settings.shape}
|
||||
>
|
||||
{Object.values(Shape).map(shape => (
|
||||
<SimpleSelect.Option id={`shape-${shape}`} key={`shape-${shape}`} value={shape}>
|
||||
{SHAPE_DESCRIPTION[shape] || ''}
|
||||
</SimpleSelect.Option>
|
||||
))}
|
||||
</SimpleSelect>
|
||||
</Flex.Item>
|
||||
export const ShapeSection = ({settings, onChange}) => {
|
||||
useEffect(() => {
|
||||
// if the user has an embedded image, we need to re-crop it so it fits the new shape
|
||||
if (settings.imageSettings?.cropperSettings) {
|
||||
createCroppedImageSvg({...settings.imageSettings.cropperSettings, shape: settings.shape})
|
||||
.then(generatedSvg =>
|
||||
convertFileToBase64(new Blob([generatedSvg.outerHTML], {type: 'image/svg+xml'}))
|
||||
)
|
||||
.then(base64Image => {
|
||||
onChange({
|
||||
type: actions.SET_ENCODED_IMAGE,
|
||||
payload: base64Image
|
||||
})
|
||||
})
|
||||
// eslint-disable-next-line no-console
|
||||
.catch(error => console.error(error))
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [settings.shape])
|
||||
|
||||
<Flex.Item padding="small">
|
||||
<SimpleSelect
|
||||
assistiveText={formatMessage('Use arrow keys to select a size.')}
|
||||
id="icon-size"
|
||||
onChange={(e, option) => onChange({size: option.value})}
|
||||
renderLabel={formatMessage('Icon Size')}
|
||||
value={settings.size}
|
||||
>
|
||||
{SIZES.map(size => (
|
||||
<SimpleSelect.Option id={`size-${size}`} key={`size-${size}`} value={size}>
|
||||
{SIZE_DESCRIPTION[size] || ''}
|
||||
</SimpleSelect.Option>
|
||||
))}
|
||||
</SimpleSelect>
|
||||
</Flex.Item>
|
||||
</Flex>
|
||||
)
|
||||
return (
|
||||
<Flex as="section" direction="column" justifyItems="space-between" padding="small small 0">
|
||||
<Flex.Item padding="small">
|
||||
<SimpleSelect
|
||||
assistiveText={formatMessage('Use arrow keys to select a shape.')}
|
||||
id="icon-shape"
|
||||
onChange={(e, option) => onChange({shape: option.value})}
|
||||
renderLabel={formatMessage('Icon Shape')}
|
||||
value={settings.shape}
|
||||
>
|
||||
{Object.values(Shape).map(shape => (
|
||||
<SimpleSelect.Option id={`shape-${shape}`} key={`shape-${shape}`} value={shape}>
|
||||
{SHAPE_DESCRIPTION[shape] || ''}
|
||||
</SimpleSelect.Option>
|
||||
))}
|
||||
</SimpleSelect>
|
||||
</Flex.Item>
|
||||
|
||||
<Flex.Item padding="small">
|
||||
<SimpleSelect
|
||||
assistiveText={formatMessage('Use arrow keys to select a size.')}
|
||||
id="icon-size"
|
||||
onChange={(e, option) => onChange({size: option.value})}
|
||||
renderLabel={formatMessage('Icon Size')}
|
||||
value={settings.size}
|
||||
>
|
||||
{SIZES.map(size => (
|
||||
<SimpleSelect.Option id={`size-${size}`} key={`size-${size}`} value={size}>
|
||||
{SIZE_DESCRIPTION[size] || ''}
|
||||
</SimpleSelect.Option>
|
||||
))}
|
||||
</SimpleSelect>
|
||||
</Flex.Item>
|
||||
</Flex>
|
||||
)
|
||||
}
|
||||
|
||||
const SHAPE_DESCRIPTION = {
|
||||
[Shape.Square]: formatMessage('Square'),
|
||||
|
|
|
@ -54,6 +54,19 @@ describe('<Preview />', () => {
|
|||
>
|
||||
<g
|
||||
fill="none"
|
||||
>
|
||||
<clippath
|
||||
id="clip-path-for-embed"
|
||||
>
|
||||
<path
|
||||
d="M109 8L210 214H8L109 8Z"
|
||||
/>
|
||||
</clippath>
|
||||
<path
|
||||
d="M109 8L210 214H8L109 8Z"
|
||||
/>
|
||||
</g>
|
||||
<g
|
||||
stroke="#0f0"
|
||||
stroke-width="4"
|
||||
>
|
||||
|
|
|
@ -17,10 +17,28 @@
|
|||
*/
|
||||
|
||||
import React from 'react'
|
||||
import {render, screen} from '@testing-library/react'
|
||||
import {render, screen, waitFor} from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import {DEFAULT_SETTINGS} from '../../../svg/constants'
|
||||
import {ShapeSection} from '../ShapeSection'
|
||||
import {createCroppedImageSvg} from '../ImageCropper/imageCropUtils'
|
||||
import {convertFileToBase64} from '../../../svg/utils'
|
||||
|
||||
jest.mock('../ImageCropper/imageCropUtils', () => {
|
||||
return {
|
||||
createCroppedImageSvg: jest
|
||||
.fn()
|
||||
.mockImplementation(() => Promise.resolve({outerHTML: '<svg />'}))
|
||||
}
|
||||
})
|
||||
|
||||
jest.mock('../../../svg/utils', () => {
|
||||
return {
|
||||
convertFileToBase64: jest
|
||||
.fn()
|
||||
.mockImplementation(() => Promise.resolve('data:image/svg+xml;base64,PHN2Zaaaaaaaaa'))
|
||||
}
|
||||
})
|
||||
|
||||
function selectOption(button, option) {
|
||||
userEvent.click(
|
||||
|
@ -49,4 +67,31 @@ describe('<ShapeSection />', () => {
|
|||
selectOption(/icon size/i, /extra small/i)
|
||||
expect(onChange).toHaveBeenCalledWith({size: 'x-small'})
|
||||
})
|
||||
|
||||
describe('when there is an embedded image', () => {
|
||||
const settings = {
|
||||
...DEFAULT_SETTINGS,
|
||||
shape: 'triangle',
|
||||
imageSettings: {
|
||||
cropperSettings: {}
|
||||
}
|
||||
}
|
||||
|
||||
it('recrops the embedded image', () => {
|
||||
render(<ShapeSection settings={settings} onChange={() => {}} />)
|
||||
expect(createCroppedImageSvg).toHaveBeenCalledWith({shape: 'triangle'})
|
||||
})
|
||||
|
||||
it('sets the encoded image to the newly cropped base64 encoded image', async () => {
|
||||
const onChange = jest.fn()
|
||||
render(<ShapeSection settings={settings} onChange={onChange} />)
|
||||
expect(convertFileToBase64).toHaveBeenCalled()
|
||||
await waitFor(() =>
|
||||
expect(onChange).toHaveBeenCalledWith({
|
||||
payload: 'data:image/svg+xml;base64,PHN2Zaaaaaaaaa',
|
||||
type: 'SetEncodedImage'
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
@ -211,6 +211,25 @@ describe('RCE "Icon Maker" Plugin > IconMakerTray', () => {
|
|||
>
|
||||
<g
|
||||
fill="#000000"
|
||||
>
|
||||
<clippath
|
||||
id="clip-path-for-embed"
|
||||
>
|
||||
<rect
|
||||
height="114"
|
||||
width="114"
|
||||
x="4"
|
||||
y="4"
|
||||
/>
|
||||
</clippath>
|
||||
<rect
|
||||
height="114"
|
||||
width="114"
|
||||
x="4"
|
||||
y="4"
|
||||
/>
|
||||
</g>
|
||||
<g
|
||||
stroke="#000000"
|
||||
stroke-width="0"
|
||||
>
|
||||
|
@ -246,7 +265,7 @@ describe('RCE "Icon Maker" Plugin > IconMakerTray', () => {
|
|||
})
|
||||
|
||||
it('with overwrite if "replace all" is checked', async () => {
|
||||
const {getByTestId} = render(<IconMakerTray {...defaults} editing />)
|
||||
const {getByTestId} = render(<IconMakerTray {...defaults} editing={true} />)
|
||||
|
||||
setIconColor('#000000')
|
||||
|
||||
|
@ -340,7 +359,7 @@ describe('RCE "Icon Maker" Plugin > IconMakerTray', () => {
|
|||
|
||||
describe('the "replace all instances" checkbox', () => {
|
||||
it('disables the name field when checked', async () => {
|
||||
const {getByTestId} = render(<IconMakerTray {...defaults} editing />)
|
||||
const {getByTestId} = render(<IconMakerTray {...defaults} editing={true} />)
|
||||
|
||||
act(() => getByTestId('cb-replace-all').click())
|
||||
|
||||
|
@ -348,7 +367,7 @@ describe('RCE "Icon Maker" Plugin > IconMakerTray', () => {
|
|||
})
|
||||
|
||||
it('does not disable the name field when not checked', async () => {
|
||||
const {getByTestId} = render(<IconMakerTray {...defaults} editing />)
|
||||
const {getByTestId} = render(<IconMakerTray {...defaults} editing={true} />)
|
||||
|
||||
await waitFor(() => expect(getByTestId('icon-name')).not.toBeDisabled())
|
||||
})
|
||||
|
@ -431,7 +450,7 @@ describe('RCE "Icon Maker" Plugin > IconMakerTray', () => {
|
|||
render(
|
||||
<IconMakerTray
|
||||
onClose={jest.fn()}
|
||||
editing
|
||||
editing={true}
|
||||
editor={ed}
|
||||
rcsConfig={{
|
||||
contextType: 'course',
|
||||
|
|
|
@ -16,9 +16,9 @@
|
|||
* with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {transformForShape} from '../image'
|
||||
import {transformForShape, buildImage} from '../image'
|
||||
import {Shape} from '../shape'
|
||||
import {Size} from '../constants'
|
||||
import {Size, DEFAULT_SETTINGS} from '../constants'
|
||||
|
||||
describe('transformShape()', () => {
|
||||
let shape, size
|
||||
|
@ -633,3 +633,70 @@ describe('transformShape()', () => {
|
|||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildImage', () => {
|
||||
describe('when there is no encoded image', () => {
|
||||
it('returns undefined', () => {
|
||||
expect(buildImage(DEFAULT_SETTINGS)).toBe(undefined)
|
||||
})
|
||||
})
|
||||
|
||||
describe('when there is an encoded image', () => {
|
||||
const baseSettings = {
|
||||
...DEFAULT_SETTINGS,
|
||||
width: 1,
|
||||
height: 2,
|
||||
x: 3,
|
||||
y: 4,
|
||||
transform: 'translate(5, 6)'
|
||||
}
|
||||
|
||||
describe('when the encoded image type is a stock image', () => {
|
||||
it('uses the icon settings to set the image attributes', () => {
|
||||
const settings = {
|
||||
...baseSettings,
|
||||
encodedImage: 'data:image/svg+xml;base64,PHN2Zaaaaaaaaa',
|
||||
encodedImageType: 'SingleColor'
|
||||
}
|
||||
expect(buildImage(settings)).toMatchInlineSnapshot(`
|
||||
<g
|
||||
clip-path="url(#clip-path-for-embed)"
|
||||
>
|
||||
<image
|
||||
height="2"
|
||||
href="data:image/svg+xml;base64,PHN2Zaaaaaaaaa"
|
||||
transform="translate(5, 6)"
|
||||
width="1"
|
||||
x="3"
|
||||
y="4"
|
||||
/>
|
||||
</g>
|
||||
`)
|
||||
})
|
||||
})
|
||||
|
||||
describe('when the encoded image type is not a stock image', () => {
|
||||
it('uses image attributes computed from the square size', () => {
|
||||
const settings = {
|
||||
...baseSettings,
|
||||
encodedImage: 'data:image/svg+xml;base64,PHN2Zbbbbbbbbb',
|
||||
encodedImageType: 'Course'
|
||||
}
|
||||
expect(buildImage(settings)).toMatchInlineSnapshot(`
|
||||
<g
|
||||
clip-path="url(#clip-path-for-embed)"
|
||||
>
|
||||
<image
|
||||
height="114"
|
||||
href="data:image/svg+xml;base64,PHN2Zbbbbbbbbb"
|
||||
transform="translate(-57, -57)"
|
||||
width="114"
|
||||
x="50%"
|
||||
y="50%"
|
||||
/>
|
||||
</g>
|
||||
`)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
@ -61,6 +61,23 @@ describe('buildSvg()', () => {
|
|||
>
|
||||
<g
|
||||
fill="#000"
|
||||
>
|
||||
<clippath
|
||||
id="clip-path-for-embed"
|
||||
>
|
||||
<circle
|
||||
cx="109"
|
||||
cy="109"
|
||||
r="105"
|
||||
/>
|
||||
</clippath>
|
||||
<circle
|
||||
cx="109"
|
||||
cy="109"
|
||||
r="105"
|
||||
/>
|
||||
</g>
|
||||
<g
|
||||
stroke="#fff"
|
||||
stroke-width="8"
|
||||
>
|
||||
|
@ -105,6 +122,23 @@ describe('buildSvg()', () => {
|
|||
>
|
||||
<g
|
||||
fill="none"
|
||||
>
|
||||
<clippath
|
||||
id="clip-path-for-embed"
|
||||
>
|
||||
<circle
|
||||
cx="109"
|
||||
cy="109"
|
||||
r="105"
|
||||
/>
|
||||
</clippath>
|
||||
<circle
|
||||
cx="109"
|
||||
cy="109"
|
||||
r="105"
|
||||
/>
|
||||
</g>
|
||||
<g
|
||||
stroke="#fff"
|
||||
stroke-width="8"
|
||||
>
|
||||
|
@ -150,6 +184,23 @@ describe('buildSvg()', () => {
|
|||
>
|
||||
<g
|
||||
fill="#000"
|
||||
>
|
||||
<clippath
|
||||
id="clip-path-for-embed"
|
||||
>
|
||||
<circle
|
||||
cx="109"
|
||||
cy="109"
|
||||
r="105"
|
||||
/>
|
||||
</clippath>
|
||||
<circle
|
||||
cx="109"
|
||||
cy="109"
|
||||
r="105"
|
||||
/>
|
||||
</g>
|
||||
<g
|
||||
stroke="#fff"
|
||||
stroke-width="8"
|
||||
>
|
||||
|
@ -252,91 +303,80 @@ describe('buildGroup()', () => {
|
|||
settings = {...DEFAULT_SETTINGS, color: '#f00', outlineColor: '#0f0', outlineSize: 'small'}
|
||||
})
|
||||
|
||||
it('builds the <g /> element when color is set', () => {
|
||||
expect(buildGroup(settings)).toMatchInlineSnapshot(`
|
||||
<g
|
||||
fill="#f00"
|
||||
stroke="#0f0"
|
||||
stroke-width="2"
|
||||
/>
|
||||
`)
|
||||
})
|
||||
|
||||
it('builds the <g /> element when color is not set', () => {
|
||||
settings = {...settings, color: null}
|
||||
expect(buildGroup(settings)).toMatchInlineSnapshot(`
|
||||
<g
|
||||
fill="none"
|
||||
stroke="#0f0"
|
||||
stroke-width="2"
|
||||
/>
|
||||
`)
|
||||
})
|
||||
|
||||
it('builds the <g /> element when color is not set and is preview mode', () => {
|
||||
settings = {...settings, color: null}
|
||||
options = {...options, isPreview: true}
|
||||
expect(buildGroup(settings, options)).toMatchInlineSnapshot(`
|
||||
<g
|
||||
fill="none"
|
||||
stroke="#0f0"
|
||||
stroke-width="2"
|
||||
/>
|
||||
`)
|
||||
})
|
||||
|
||||
it('builds the <g /> element when outlineColor is not set', () => {
|
||||
settings = {...settings, outlineColor: null}
|
||||
expect(buildGroup(settings)).toMatchInlineSnapshot(`
|
||||
<g
|
||||
fill="#f00"
|
||||
/>
|
||||
`)
|
||||
})
|
||||
|
||||
describe('when outlineSize is set', () => {
|
||||
it('builds the <g /> element when outlineSize is "none"', () => {
|
||||
settings = {...settings, outlineSize: 'none'}
|
||||
expect(buildGroup(settings)).toMatchInlineSnapshot(`
|
||||
describe('when fill is true', () => {
|
||||
it('builds the fill <g /> element with color when color is set', () => {
|
||||
expect(buildGroup(settings, {fill: true})).toMatchInlineSnapshot(`
|
||||
<g
|
||||
fill="#f00"
|
||||
stroke="#0f0"
|
||||
stroke-width="0"
|
||||
/>
|
||||
`)
|
||||
})
|
||||
|
||||
it('builds the <g /> element when outlineSize is "small"', () => {
|
||||
settings = {...settings, outlineSize: 'small'}
|
||||
it('builds the fill <g /> element with none when color is not set', () => {
|
||||
settings = {...settings, color: null}
|
||||
expect(buildGroup(settings, {fill: true})).toMatchInlineSnapshot(`
|
||||
<g
|
||||
fill="none"
|
||||
/>
|
||||
`)
|
||||
})
|
||||
})
|
||||
|
||||
describe('when fill is not true', () => {
|
||||
it('builds the border <g /> element', () => {
|
||||
expect(buildGroup(settings)).toMatchInlineSnapshot(`
|
||||
<g
|
||||
fill="#f00"
|
||||
stroke="#0f0"
|
||||
stroke-width="2"
|
||||
/>
|
||||
`)
|
||||
})
|
||||
|
||||
it('builds the <g /> element when outlineSize is "medium"', () => {
|
||||
settings = {...settings, outlineSize: 'medium'}
|
||||
expect(buildGroup(settings)).toMatchInlineSnapshot(`
|
||||
<g
|
||||
fill="#f00"
|
||||
stroke="#0f0"
|
||||
stroke-width="4"
|
||||
/>
|
||||
`)
|
||||
it('builds the <g /> element when outlineColor is not set', () => {
|
||||
settings = {...settings, outlineColor: null}
|
||||
expect(buildGroup(settings)).toMatchInlineSnapshot(`<g />`)
|
||||
})
|
||||
|
||||
it('builds the <g /> element when outlineSize is "large"', () => {
|
||||
settings = {...settings, outlineSize: 'large'}
|
||||
expect(buildGroup(settings)).toMatchInlineSnapshot(`
|
||||
<g
|
||||
fill="#f00"
|
||||
stroke="#0f0"
|
||||
stroke-width="8"
|
||||
/>
|
||||
`)
|
||||
describe('when outlineSize is set', () => {
|
||||
it('builds the border <g /> element when outlineSize is "none"', () => {
|
||||
settings = {...settings, outlineSize: 'none'}
|
||||
expect(buildGroup(settings)).toMatchInlineSnapshot(`
|
||||
<g
|
||||
stroke="#0f0"
|
||||
stroke-width="0"
|
||||
/>
|
||||
`)
|
||||
})
|
||||
|
||||
it('builds the border <g /> element when outlineSize is "small"', () => {
|
||||
settings = {...settings, outlineSize: 'small'}
|
||||
expect(buildGroup(settings)).toMatchInlineSnapshot(`
|
||||
<g
|
||||
stroke="#0f0"
|
||||
stroke-width="2"
|
||||
/>
|
||||
`)
|
||||
})
|
||||
|
||||
it('builds the border <g /> element when outlineSize is "medium"', () => {
|
||||
settings = {...settings, outlineSize: 'medium'}
|
||||
expect(buildGroup(settings)).toMatchInlineSnapshot(`
|
||||
<g
|
||||
stroke="#0f0"
|
||||
stroke-width="4"
|
||||
/>
|
||||
`)
|
||||
})
|
||||
|
||||
it('builds the border <g /> element when outlineSize is "large"', () => {
|
||||
settings = {...settings, outlineSize: 'large'}
|
||||
expect(buildGroup(settings)).toMatchInlineSnapshot(`
|
||||
<g
|
||||
stroke="#0f0"
|
||||
stroke-width="8"
|
||||
/>
|
||||
`)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
@ -110,3 +110,5 @@ export const MAX_CHAR_COUNT = {
|
|||
export const MAX_TOTAL_TEXT_CHARS = 32
|
||||
|
||||
export const TEXT_BACKGROUND_PADDING = 4
|
||||
|
||||
export const ICON_PADDING = 4
|
||||
|
|
|
@ -19,21 +19,50 @@
|
|||
import {createSvgElement} from './utils'
|
||||
import {CLIP_PATH_ID} from './clipPath'
|
||||
import {Shape} from './shape'
|
||||
import {Size} from './constants'
|
||||
import {Size, STROKE_WIDTH, BASE_SIZE, ICON_PADDING} from './constants'
|
||||
|
||||
const STOCK_IMAGE_TYPES = ['SingleColor', 'MultiColor']
|
||||
|
||||
const calculateImageHeight = ({size, outlineSize}) => {
|
||||
// Subtract the padding at the top and the bottom
|
||||
// to get the true height of the shape in the icon
|
||||
const iconHeightLessPadding = BASE_SIZE[size] - 2 * ICON_PADDING
|
||||
|
||||
// Shrink it by the size of the stroke width so the
|
||||
// border doesn't cover parts of the cropped image
|
||||
return iconHeightLessPadding - STROKE_WIDTH[outlineSize]
|
||||
}
|
||||
|
||||
export function buildImage(settings) {
|
||||
// Don't attempt to embed an image if none exist
|
||||
if (!settings.encodedImage) return
|
||||
|
||||
let imageAttributes
|
||||
if (STOCK_IMAGE_TYPES.includes(settings.encodedImageType)) {
|
||||
imageAttributes = {
|
||||
x: settings.x,
|
||||
y: settings.y,
|
||||
transform: settings.transform,
|
||||
width: settings.width,
|
||||
height: settings.height,
|
||||
href: settings.encodedImage
|
||||
}
|
||||
} else {
|
||||
// we need to embed the encoded image
|
||||
const squareHeight = calculateImageHeight(settings)
|
||||
const translation = translationFor(squareHeight)
|
||||
imageAttributes = {
|
||||
x: '50%',
|
||||
y: '50%',
|
||||
transform: `translate(${translation}, ${translation})`,
|
||||
width: squareHeight,
|
||||
height: squareHeight,
|
||||
href: settings.encodedImage
|
||||
}
|
||||
}
|
||||
|
||||
const group = createSvgElement('g', {'clip-path': `url(#${CLIP_PATH_ID})`})
|
||||
const image = createSvgElement('image', {
|
||||
x: settings.x,
|
||||
y: settings.y,
|
||||
transform: settings.transform,
|
||||
width: settings.width,
|
||||
height: settings.height,
|
||||
href: settings.encodedImage
|
||||
})
|
||||
const image = createSvgElement('image', imageAttributes)
|
||||
|
||||
group.appendChild(image)
|
||||
|
||||
|
|
|
@ -36,21 +36,33 @@ export function buildSvg(settings, options = {}) {
|
|||
mainContainer.appendChild(metadata)
|
||||
}
|
||||
|
||||
const g = buildGroup(settings) // The shape group. Sets the controls the fill color
|
||||
const fillGroup = buildGroup(settings, {fill: true}) // The shape with the fill color
|
||||
const borderGroup = buildGroup(settings) // The shape with the outline and image
|
||||
const clipPath = buildClipPath(settings) // A clip path used to crop the image
|
||||
const shape = buildShape(settings) // The actual path of the shape being built
|
||||
const image = buildImage(settings) // The embedded image. Cropped by clipPath
|
||||
|
||||
clipPath.appendChild(shape)
|
||||
g.appendChild(clipPath)
|
||||
g.appendChild(shape.cloneNode(true))
|
||||
|
||||
// Don't append an image if none has been selected
|
||||
// Also add image here so it sits beneath the outline,
|
||||
// which is added below to the borderGroup
|
||||
if (image) {
|
||||
g.appendChild(image)
|
||||
borderGroup.appendChild(image)
|
||||
}
|
||||
|
||||
shapeWrapper.appendChild(g)
|
||||
clipPath.appendChild(shape)
|
||||
|
||||
// These are required to make the group have the right shape
|
||||
fillGroup.appendChild(clipPath)
|
||||
fillGroup.appendChild(shape.cloneNode(true))
|
||||
|
||||
// These are required to make the group have the right shape
|
||||
borderGroup.appendChild(clipPath.cloneNode(true))
|
||||
borderGroup.appendChild(shape.cloneNode(true))
|
||||
|
||||
// Add fill group before the main group so the fill
|
||||
// sits behind the image and outline
|
||||
shapeWrapper.appendChild(fillGroup)
|
||||
shapeWrapper.appendChild(borderGroup)
|
||||
mainContainer.appendChild(shapeWrapper)
|
||||
|
||||
const textBackground = buildTextBackground(settings)
|
||||
|
@ -95,10 +107,11 @@ export function buildSvgContainer(settings, options) {
|
|||
return createSvgElement('svg', attributes)
|
||||
}
|
||||
|
||||
export function buildGroup({color, outlineColor, outlineSize}) {
|
||||
const fill = color || 'none'
|
||||
const g = createSvgElement('g', {fill})
|
||||
if (outlineColor) {
|
||||
export function buildGroup({color, outlineColor, outlineSize}, options = {}) {
|
||||
const g = createSvgElement('g')
|
||||
if (options.fill) {
|
||||
g.setAttribute('fill', color || 'none')
|
||||
} else if (outlineColor) {
|
||||
g.setAttribute('stroke', outlineColor)
|
||||
g.setAttribute('stroke-width', STROKE_WIDTH[outlineSize])
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue