Skip to content

Commit

Permalink
Initial work on custom icon support in maps
Browse files Browse the repository at this point in the history
* Re-uses code from Canvas asset manager component to upload images
* Adds "Add Custom Icon" button to IconSelect component
* Adds CustomIcons array to VectorStyle descriptor (not yet persisted to saved object)
  • Loading branch information
nickpeihl committed Sep 27, 2021
1 parent e4e8001 commit 00d4404
Show file tree
Hide file tree
Showing 12 changed files with 442 additions and 30 deletions.
5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,7 @@
"archiver": "^5.2.0",
"axios": "^0.21.1",
"base64-js": "^1.3.1",
"bitmap-sdf": "^1.0.3",
"bluebird": "3.5.5",
"brace": "0.11.1",
"broadcast-channel": "^3.0.3",
Expand Down Expand Up @@ -278,8 +279,8 @@
"lodash": "^4.17.21",
"lru-cache": "^4.1.5",
"lz-string": "^1.4.4",
"maplibre-gl": "1.15.2",
"mapbox-gl-draw-rectangle-mode": "1.0.4",
"maplibre-gl": "1.15.2",
"markdown-it": "^10.0.0",
"md5": "^2.1.0",
"mdast-util-to-hast": "10.0.1",
Expand Down Expand Up @@ -639,8 +640,8 @@
"@types/yauzl": "^2.9.1",
"@types/zen-observable": "^0.8.0",
"@typescript-eslint/eslint-plugin": "^4.14.1",
"@typescript-eslint/typescript-estree": "^4.14.1",
"@typescript-eslint/parser": "^4.14.1",
"@typescript-eslint/typescript-estree": "^4.14.1",
"@yarnpkg/lockfile": "^1.1.0",
"abab": "^2.0.4",
"aggregate-error": "^3.1.0",
Expand Down
26 changes: 26 additions & 0 deletions x-pack/plugins/maps/common/dataurl.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/

import { fromByteArray } from 'base64-js';

export const imageTypes = ['image/svg+xml', 'image/jpeg', 'image/png', 'image/gif'];

export function encode(data: any | null, type = 'text/plain') {
// use FileReader if it's available, like in the browser
if (FileReader) {
return new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onloadend = () => resolve(reader.result as string);
reader.onerror = (err) => reject(err);
reader.readAsDataURL(data);
});
}

// otherwise fall back to fromByteArray
// note: Buffer doesn't seem to correctly base64 encode binary data
return Promise.resolve(`data:${type};base64,${fromByteArray(data)}`);
}
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,12 @@ export type SizeStylePropertyDescriptor =
options: SizeDynamicOptions;
};

export type CustomIcon = {
symbolId: string;
icon: string; //svg string
name: string; // user given name
};

export type VectorStylePropertiesDescriptor = {
[VECTOR_STYLES.SYMBOLIZE_AS]: SymbolizeAsStylePropertyDescriptor;
[VECTOR_STYLES.FILL_COLOR]: ColorStylePropertyDescriptor;
Expand Down Expand Up @@ -239,6 +245,7 @@ export type StyleMetaDescriptor = {

export type VectorStyleDescriptor = StyleDescriptor & {
properties: VectorStylePropertiesDescriptor;
customIcons?: CustomIcon[];
isTimeAware: boolean;
__styleMeta?: StyleMetaDescriptor;
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@ import React, { Component } from 'react';
import { getMakiSymbolSvg, styleSvg, buildSrcUrl } from '../../symbol_utils';

interface Props {
symbolId: string;
symbolId?: string;
svg?: string;
fill?: string;
stroke?: string;
}
Expand Down Expand Up @@ -38,7 +39,7 @@ export class SymbolIcon extends Component<Props, State> {
async _loadSymbol() {
let imgDataUrl;
try {
const svg = getMakiSymbolSvg(this.props.symbolId);
const svg = this.props.svg ?? getMakiSymbolSvg(this.props.symbolId);
const styledSvg = await styleSvg(svg, this.props.fill, this.props.stroke);
imgDataUrl = buildSrcUrl(styledSvg);
} catch (error) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
import { i18n } from '@kbn/i18n';
import { getVectorStyleLabel, getDisabledByMessage } from './get_vector_style_label';
import { STYLE_TYPE, VECTOR_STYLES } from '../../../../../common/constants';
import { CustomIcon } from '../../../../../common/descriptor_types';
import { IStyleProperty } from '../properties/style_property';
import { StyleField } from '../style_fields_helper';

Expand All @@ -30,6 +31,7 @@ export interface Props<StaticOptions, DynamicOptions> {
fields: StyleField[];
onDynamicStyleChange: (propertyName: VECTOR_STYLES, options: DynamicOptions) => void;
onStaticStyleChange: (propertyName: VECTOR_STYLES, options: StaticOptions) => void;
onCustomIconsChange?: (customIcons: CustomIcon[]) => void;
styleProperty: IStyleProperty<StaticOptions | DynamicOptions>;
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,250 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/

import React, { PureComponent } from 'react';
import { get } from 'lodash';
import PropTypes from 'prop-types';
import {
EuiButton,
EuiButtonEmpty,
EuiFieldText,
EuiFilePicker,
EuiFlexGroup,
EuiFlexItem,
EuiFormRow,
EuiModal,
EuiModalBody,
EuiModalFooter,
EuiModalHeader,
EuiModalHeaderTitle,
EuiSpacer,
EuiText,
EuiTextArea,
EuiTitle,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';

import { encode } from '../../../../../../common/dataurl';

const MAX_NAME_LENGTH = 40;
const MAX_DESCRIPTION_LENGTH = 100;

const strings = {
getCancelButtonLabel: () =>
i18n.translate('xpack.maps.customIconModal.cancelButtonLabel', {
defaultMessage: 'Cancel',
}),
getCharactersRemainingDescription: (numberOfRemainingCharacter: number) =>
i18n.translate('xpack.maps.customIconModal.remainingCharactersDescription', {
defaultMessage: '{numberOfRemainingCharacter} characters remaining',
values: {
numberOfRemainingCharacter,
},
}),
getDescriptionInputLabel: () =>
i18n.translate('xpack.maps.customIconModal.descriptionInputLabel', {
defaultMessage: 'Description',
}),
getElementPreviewTitle: () =>
i18n.translate('xpack.maps.customIconModal.elementPreviewTitle', {
defaultMessage: 'Element preview',
}),
getImageFilePickerPlaceholder: () =>
i18n.translate('xpack.maps.customIconModal.imageFilePickerPlaceholder', {
defaultMessage: 'Select or drag and drop an image',
}),
getImageInputDescription: () =>
i18n.translate('xpack.maps.customIconModal.imageInputDescription', {
defaultMessage:
'Take a screenshot of your element and upload it here. This can also be done after saving.',
}),
getImageInputLabel: () =>
i18n.translate('xpack.maps.customIconModal.imageInputLabel', {
defaultMessage: 'Thumbnail image',
}),
getNameInputLabel: () =>
i18n.translate('xpack.maps.customIconModal.nameInputLabel', {
defaultMessage: 'Name',
}),
getSaveButtonLabel: () =>
i18n.translate('xpack.maps.customIconModal.saveButtonLabel', {
defaultMessage: 'Save',
}),
};
interface Props {
/**
* initial value of the name of the custom element
*/
name?: string;
/**
* initial value of the description of the custom element
*/
description?: string;
/**
* initial value of the preview image of the custom element as a base64 dataurl
*/
image?: string;
/**
* title of the modal
*/
title: string;
/**
* A click handler for the save button
*/
onSave: (name: string, description: string, image: string) => void;
/**
* A click handler for the cancel button
*/
onCancel: () => void;
}

interface State {
/**
* name of the custom element to be saved
*/
name?: string;
/**
* description of the custom element to be saved
*/
description?: string;
/**
* image of the custom element to be saved
*/
image?: string;
}

export class CustomIconModal extends PureComponent<Props, State> {
public static propTypes = {
name: PropTypes.string,
description: PropTypes.string,
image: PropTypes.string,
title: PropTypes.string.isRequired,
onSave: PropTypes.func.isRequired,
onCancel: PropTypes.func.isRequired,
};

public state = {
name: this.props.name || '',
description: this.props.description || '',
image: this.props.image || '',
};

private _handleChange = (type: 'name' | 'description' | 'image', img: string) => {
this.setState({ [type]: img });
};

private _handleUpload = (files: FileList | null) => {
if (files == null) return;
const file = files[0];
const [type, subtype] = get(file, 'type', '').split('/');
if (type === 'image') {
file.text().then((img: string) => this._handleChange('image', img));
}
};

public render() {
const { onSave, onCancel, title, ...rest } = this.props;
const { name, description, image } = this.state;

return (
<EuiModal
{...rest}
className={`mapsCustomIconModal`}
maxWidth={700}
onClose={onCancel}
initialFocus=".mapsCustomIconForm__name"
>
<EuiModalHeader>
<EuiModalHeaderTitle>
<h3>{title}</h3>
</EuiModalHeaderTitle>
</EuiModalHeader>
<EuiModalBody>
<EuiFlexGroup justifyContent="spaceBetween" alignItems="flexStart">
<EuiFlexItem className="mapsCustomIconForm" grow={2}>
<EuiFormRow
label={strings.getNameInputLabel()}
helpText={strings.getCharactersRemainingDescription(MAX_NAME_LENGTH - name.length)}
display="rowCompressed"
>
<EuiFieldText
value={name}
className="mapsCustomIconForm__name"
onChange={(e) =>
e.target.value.length <= MAX_NAME_LENGTH &&
this._handleChange('name', e.target.value)
}
required
data-test-subj="mapsCustomIconForm-name"
/>
</EuiFormRow>
<EuiFormRow
label={strings.getDescriptionInputLabel()}
helpText={strings.getCharactersRemainingDescription(
MAX_DESCRIPTION_LENGTH - description.length
)}
>
<EuiTextArea
value={description}
rows={2}
onChange={(e) =>
e.target.value.length <= MAX_DESCRIPTION_LENGTH &&
this._handleChange('description', e.target.value)
}
data-test-subj="mapsCustomIconForm-description"
/>
</EuiFormRow>
<EuiFormRow
className="mapsCustomIconForm__thumbnail"
label={strings.getImageInputLabel()}
display="rowCompressed"
>
<EuiFilePicker
initialPromptText={strings.getImageFilePickerPlaceholder()}
onChange={this._handleUpload}
className="mapsImageUpload"
accept="image/*"
/>
</EuiFormRow>
<EuiText className="mapsCustomIconForm__thumbnailHelp" size="xs">
<p>{strings.getImageInputDescription()}</p>
</EuiText>
</EuiFlexItem>
<EuiFlexItem
className="mapsElementCard__wrapper mapsCustomIconForm__preview"
grow={1}
>
<EuiTitle size="xxxs">
<h4>{strings.getElementPreviewTitle()}</h4>
</EuiTitle>
<EuiSpacer size="s" />
{/* TODO Render a preview in a MapComponent */}
</EuiFlexItem>
</EuiFlexGroup>
</EuiModalBody>
<EuiModalFooter>
<EuiFlexGroup justifyContent="flexEnd">
<EuiFlexItem grow={false}>
<EuiButtonEmpty onClick={onCancel}>{strings.getCancelButtonLabel()}</EuiButtonEmpty>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButton
fill
onClick={() => {
onSave(name, description, image);
}}
data-test-subj="mapsCustomIconForm-submit"
>
{strings.getSaveButtonLabel()}
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
</EuiModalFooter>
</EuiModal>
);
}
}
Loading

0 comments on commit 00d4404

Please sign in to comment.