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:
jake.oeding 2022-09-06 18:02:09 -05:00 committed by Jake Oeding
parent 15b4348b6a
commit e3cf19fe72
16 changed files with 462 additions and 172 deletions

View File

@ -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 = {

View File

@ -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'})
})
})
})

View File

@ -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}>

View File

@ -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
}

View File

@ -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>

View File

@ -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())
})

View File

@ -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()

View File

@ -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'),

View File

@ -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"
>

View File

@ -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'
})
)
})
})
})

View File

@ -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',

View File

@ -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>
`)
})
})
})
})

View File

@ -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"
/>
`)
})
})
})
})

View File

@ -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

View File

@ -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)

View File

@ -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])
}