diff --git a/.circleci/config.yml b/.circleci/config.yml index 27bf9933..6450a6e6 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -41,6 +41,7 @@ templates: - run: mkdir -p /tmp/artifacts/logs - run: npm run build + - run: npm run profiling-build - run: npm run lint - run: npm run lint-styles - run: DOCKER_HOST=localhost npm test @@ -49,11 +50,6 @@ templates: path: /tmp/artifacts destination: /artifacts jobs: - build-linux-node-v8: - docker: - - image: node:8 - working_directory: ~/repo-linux-node-v8 - steps: *build-steps build-linux-node-v10: docker: - image: node:10 @@ -65,13 +61,10 @@ jobs: - image: node:12 working_directory: ~/repo-linux-node-v12 steps: *build-steps - build-osx-node-v8: - macos: - xcode: "9.0" - dependencies: - override: - - brew install node@8 - working_directory: ~/repo-osx-node-v8 + build-linux-node-v13: + docker: + - image: node:13 + working_directory: ~/repo-linux-node-v13 steps: *build-steps build-osx-node-v10: macos: @@ -89,14 +82,22 @@ jobs: - brew install node@12 working_directory: ~/repo-osx-node-v12 steps: *build-steps + build-osx-node-v13: + macos: + xcode: "9.0" + dependencies: + override: + - brew install node@13 + working_directory: ~/repo-osx-node-v13 + steps: *build-steps workflows: version: 2 build: jobs: - - build-linux-node-v8 - build-linux-node-v10 - build-linux-node-v12 - - build-osx-node-v8 + - build-linux-node-v13 - build-osx-node-v10 - build-osx-node-v12 + - build-osx-node-v13 diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 00000000..c71688f0 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1 @@ +custom: "https://maputnik.github.io/donate" diff --git a/appveyor.yml b/appveyor.yml index c5a86e52..5c6a57dc 100644 --- a/appveyor.yml +++ b/appveyor.yml @@ -1,9 +1,9 @@ -image: Visual Studio 2015 +image: Visual Studio 2019 environment: matrix: - - nodejs_version: "8" - nodejs_version: "10" - nodejs_version: "12" + - nodejs_version: "13" platform: - x86 - x64 @@ -17,7 +17,7 @@ install: Update-NodeJsInstallation (Get-NodeJsLatestBuild $env:nodejs_version) $env:platform } - md public - - npm --vs2015 install --global windows-build-tools + - npm install --global windows-build-tools - npm install build_script: - npm run build diff --git a/config/webpack.profiling.config.js b/config/webpack.profiling.config.js new file mode 100644 index 00000000..84c4da23 --- /dev/null +++ b/config/webpack.profiling.config.js @@ -0,0 +1,20 @@ +const webpackProdConfig = require('./webpack.production.config'); +const artifacts = require("../test/artifacts"); + +const OUTPATH = artifacts.pathSync("/profiling"); + +module.exports = { + ...webpackProdConfig, + output: { + ...webpackProdConfig.output, + path: OUTPATH, + }, + resolve: { + ...webpackProdConfig.resolve, + alias: { + ...webpackProdConfig.resolve.alias, + 'react-dom$': 'react-dom/profiling', + 'scheduler/tracing': 'scheduler/tracing-profiling', + } + } +}; diff --git a/package-lock.json b/package-lock.json index ecc57ecc..223a485b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -964,9 +964,9 @@ "integrity": "sha512-RaCYfnxULUUUxNwcUimV9C/o2295ktTyLEUzD/+VWkqXqvaVfFcZ5slytGzb2Sd/Jj4MlbxD0DCZbfa6CzcmMw==" }, "@mapbox/mapbox-gl-style-spec": { - "version": "13.9.0", - "resolved": "https://registry.npmjs.org/@mapbox/mapbox-gl-style-spec/-/mapbox-gl-style-spec-13.9.0.tgz", - "integrity": "sha512-w7wqxZ9pIyqyk30cj3ujmhaldnGhg9aNTmQX7nUE6aMuhhen0mMrVhTNgET11/LIMkr/yZE1BOdQ8Fbyb/2/FA==", + "version": "13.9.1", + "resolved": "https://registry.npmjs.org/@mapbox/mapbox-gl-style-spec/-/mapbox-gl-style-spec-13.9.1.tgz", + "integrity": "sha512-7sOXtrliGz3LAErjJc0q1MtYGcmgwwE1G/PzoTrhvSQTcexSVz+v88QKZ4lAzvhF36ItxzI/UdFilsssAw6hYQ==", "requires": { "@mapbox/jsonlint-lines-primitives": "~2.0.2", "@mapbox/unitbezier": "^0.0.0", @@ -7095,9 +7095,9 @@ } }, "mapbox-gl": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/mapbox-gl/-/mapbox-gl-1.4.1.tgz", - "integrity": "sha512-4Jkf1JjsBFKlZA3BHHghgIogbbOuodjVjsdOR/j4AtfLx0G4jXrPcGvwSEVcwyQ27kVECBCn6EyRb6eUNUQujw==", + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/mapbox-gl/-/mapbox-gl-1.5.0.tgz", + "integrity": "sha512-seTQUttE7XaL93on+zfLv06HmROsIdTh3riEPrBdbylSirLmBRnofG+iV873ZbJQElf+d2USyHpWAJm37RehEQ==", "requires": { "@mapbox/geojson-rewind": "^0.4.0", "@mapbox/geojson-types": "^1.0.2", @@ -7115,7 +7115,7 @@ "grid-index": "^1.1.0", "minimist": "0.0.8", "murmurhash-js": "^1.0.0", - "pbf": "^3.0.5", + "pbf": "^3.2.1", "potpack": "^1.0.1", "quickselect": "^2.0.0", "rw": "^1.3.3", @@ -9070,12 +9070,9 @@ } }, "react-collapse": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/react-collapse/-/react-collapse-4.0.3.tgz", - "integrity": "sha512-OO4NhtEqFtz+1ma31J1B7+ezdRnzHCZiTGSSd/Pxoks9hxrZYhzFEddeYt05A/1477xTtdrwo7xEa2FLJyWGCQ==", - "requires": { - "prop-types": "^15.5.8" - } + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/react-collapse/-/react-collapse-5.0.1.tgz", + "integrity": "sha512-cN2tkxBWizhPQ2JHfe0aUSJtmMthKA17NZkTElpiQ2snQAAi1hssXZ2fv88rAPNNvG5ss4t0PbOZT0TIl9Lk3Q==" }, "react-color": { "version": "2.17.3", diff --git a/package.json b/package.json index 4a9f54fa..a8f02d0c 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,7 @@ "scripts": { "stats": "webpack --config config/webpack.production.config.js --profile --json > stats.json", "build": "webpack --config config/webpack.production.config.js --progress --profile --colors", + "profiling-build": "webpack --config config/webpack.profiling.config.js --progress --profile --colors", "test": "cross-env NODE_ENV=test wdio config/wdio.conf.js", "test-watch": "cross-env NODE_ENV=test wdio config/wdio.conf.js --watch", "start": "webpack-dev-server --progress --profile --colors --config config/webpack.config.js", @@ -22,7 +23,7 @@ "dependencies": { "@babel/runtime": "^7.6.3", "@mapbox/mapbox-gl-rtl-text": "^0.2.3", - "@mapbox/mapbox-gl-style-spec": "^13.9.0", + "@mapbox/mapbox-gl-style-spec": "^13.9.1", "classnames": "^2.2.6", "codemirror": "^5.49.0", "color": "^3.1.2", @@ -36,7 +37,7 @@ "lodash.get": "^4.4.2", "lodash.isequal": "^4.5.0", "lodash.throttle": "^4.1.1", - "mapbox-gl": "^1.4.1", + "mapbox-gl": "^1.5.0", "mapbox-gl-inspect": "^1.3.1", "maputnik-design": "github:maputnik/design", "ol": "^6.0.1", @@ -47,7 +48,7 @@ "react-aria-modal": "^4.0.0", "react-autobind": "^1.0.6", "react-autocomplete": "^1.8.1", - "react-collapse": "^4.0.3", + "react-collapse": "^5.0.1", "react-color": "^2.17.3", "react-dom": "^16.10.2", "react-file-reader-input": "^2.0.0", diff --git a/src/codemirror-maputnik.css b/src/codemirror-maputnik.css index 83b653b2..b43c6c0f 100644 --- a/src/codemirror-maputnik.css +++ b/src/codemirror-maputnik.css @@ -17,7 +17,7 @@ } .cm-s-maputnik .CodeMirror-cursor { - border-left: solid thin #8e8e8e !important; + border-left: solid thin #f0f0f0 !important; } .cm-s-maputnik.CodeMirror-focused div.CodeMirror-selected { @@ -47,5 +47,11 @@ } .cm-s-maputnik .CodeMirror-matchingbracket { - text-decoration: underline; color: white !important; + background-color: #f0f0f0; + color: #565659 !important; +} + +.cm-s-maputnik .CodeMirror-nonmatchingbracket { + background-color: #bb0000; + color: white !important; } diff --git a/src/components/App.jsx b/src/components/App.jsx index e816eaa0..47f57b16 100644 --- a/src/components/App.jsx +++ b/src/components/App.jsx @@ -212,6 +212,13 @@ export default class App extends React.Component { vectorLayers: {}, mapState: "map", spec: latest, + mapView: { + zoom: 0, + center: { + lng: 0, + lat: 0, + }, + }, isOpen: { settings: false, sources: false, @@ -531,11 +538,22 @@ export default class App extends React.Component { return metadata['maputnik:renderer'] || 'mbgljs'; } + onMapChange = (mapView) => { + this.setState({ + mapView, + }); + } + mapRenderer() { const metadata = this.state.mapStyle.metadata || {}; const mapProps = { - mapStyle: style.replaceAccessTokens(this.state.mapStyle, {allowFallback: true}), + mapStyle: this.state.mapStyle, + replaceAccessTokens: (mapStyle) => { + return style.replaceAccessTokens(mapStyle, { + allowFallback: true + }); + }, onDataChange: (e) => { this.layerWatcher.analyzeMap(e.map) this.fetchSources(); @@ -550,11 +568,13 @@ export default class App extends React.Component { if(renderer === 'ol') { mapElement = } else { mapElement = this.shortcutEl = el} diff --git a/src/components/fields/FunctionSpecField.jsx b/src/components/fields/FunctionSpecField.jsx index 36d79309..e2542c5a 100644 --- a/src/components/fields/FunctionSpecField.jsx +++ b/src/components/fields/FunctionSpecField.jsx @@ -14,6 +14,25 @@ function isDataField(value) { return typeof value === 'object' && value.stops && typeof value.property !== 'undefined' } +/** + * If we don't have a default value just make one up + */ +function findDefaultFromSpec (spec) { + if (spec.hasOwnProperty('default')) { + return spec.default; + } + + const defaults = { + 'color': '#000000', + 'string': '', + 'boolean': false, + 'number': 0, + 'array': [], + } + + return defaults[spec.type] || ''; +} + /** Supports displaying spec field for zoom function objects * https://www.mapbox.com/mapbox-gl-style-spec/#types-function-zoom-property */ @@ -82,8 +101,8 @@ export default class FunctionSpecProperty extends React.Component { makeZoomFunction = () => { const zoomFunc = { stops: [ - [6, this.props.value], - [10, this.props.value] + [6, this.props.value || findDefaultFromSpec(this.props.fieldSpec)], + [10, this.props.value || findDefaultFromSpec(this.props.fieldSpec)] ] } this.props.onChange(this.props.fieldName, zoomFunc) @@ -96,8 +115,8 @@ export default class FunctionSpecProperty extends React.Component { property: "", type: functionType, stops: [ - [{zoom: 6, value: stopValue}, this.props.value || stopValue], - [{zoom: 10, value: stopValue}, this.props.value || stopValue] + [{zoom: 6, value: stopValue}, this.props.value || findDefaultFromSpec(this.props.fieldSpec)], + [{zoom: 10, value: stopValue}, this.props.value || findDefaultFromSpec(this.props.fieldSpec)] ] } this.props.onChange(this.props.fieldName, dataFunc) diff --git a/src/components/fields/PropertyGroup.jsx b/src/components/fields/PropertyGroup.jsx index efb567bb..35577244 100644 --- a/src/components/fields/PropertyGroup.jsx +++ b/src/components/fields/PropertyGroup.jsx @@ -59,7 +59,7 @@ export default class PropertyGroup extends React.Component { onChange={this.onPropertyChange} key={fieldName} fieldName={fieldName} - value={fieldValue === undefined ? fieldSpec.default : fieldValue} + value={fieldValue} fieldSpec={fieldSpec} /> }) diff --git a/src/components/fields/SpecField.jsx b/src/components/fields/SpecField.jsx index 1f2aac6d..92c7fc3d 100644 --- a/src/components/fields/SpecField.jsx +++ b/src/components/fields/SpecField.jsx @@ -11,7 +11,7 @@ import ArrayInput from '../inputs/ArrayInput' import DynamicArrayInput from '../inputs/DynamicArrayInput' import FontInput from '../inputs/FontInput' import IconInput from '../inputs/IconInput' -import EnumInput from '../inputs/SelectInput' +import EnumInput from '../inputs/EnumInput' import capitalize from 'lodash.capitalize' const iconProperties = ['background-pattern', 'fill-pattern', 'line-pattern', 'fill-extrusion-pattern', 'icon-image'] @@ -75,6 +75,7 @@ export default class SpecField extends React.Component { {...commonProps} options={options} /> + case 'resolvedImage': case 'formatted': case 'string': if(iconProperties.indexOf(this.props.fieldName) >= 0) { diff --git a/src/components/fields/_DataProperty.jsx b/src/components/fields/_DataProperty.jsx index 4116031f..acfa2f6e 100644 --- a/src/components/fields/_DataProperty.jsx +++ b/src/components/fields/_DataProperty.jsx @@ -8,11 +8,32 @@ import StringInput from '../inputs/StringInput' import SelectInput from '../inputs/SelectInput' import DocLabel from './DocLabel' import InputBlock from '../inputs/InputBlock' +import docUid from '../../libs/document-uid' +import sortNumerically from '../../libs/sort-numerically' import labelFromFieldName from './_labelFromFieldName' import DeleteStopButton from './_DeleteStopButton' + +function setStopRefs(props, state) { + // This is initialsed below only if required to improved performance. + let newRefs; + + if(props.value && props.value.stops) { + props.value.stops.forEach((val, idx) => { + if(!state.refs.hasOwnProperty(idx)) { + if(!newRefs) { + newRefs = {...state}; + } + newRefs[idx] = docUid("stop-"); + } + }) + } + + return newRefs; +} + export default class DataProperty extends React.Component { static propTypes = { onChange: PropTypes.func, @@ -29,6 +50,30 @@ export default class DataProperty extends React.Component { ]), } + state = { + refs: {} + } + + componentDidMount() { + const newRefs = setStopRefs(this.props, this.state); + + if(newRefs) { + this.setState({ + refs: newRefs + }) + } + } + + static getDerivedStateFromProps(props, state) { + const newRefs = setStopRefs(props, state); + if(newRefs) { + return { + refs: newRefs + }; + } + return null; + } + getFieldFunctionType(fieldSpec) { if (fieldSpec.expression.interpolated) { return "exponential" @@ -48,14 +93,42 @@ export default class DataProperty extends React.Component { } } + // Order the stops altering the refs to reflect their new position. + orderStopsByZoom(stops) { + const mappedWithRef = stops + .map((stop, idx) => { + return { + ref: this.state.refs[idx], + data: stop + } + }) + // Sort by zoom + .sort((a, b) => sortNumerically(a.data[0].zoom, b.data[0].zoom)); + + // Fetch the new position of the stops + const newRefs = {}; + mappedWithRef + .forEach((stop, idx) =>{ + newRefs[idx] = stop.ref; + }) + + this.setState({ + refs: newRefs + }); + + return mappedWithRef.map((item) => item.data); + } changeStop(changeIdx, stopData, value) { const stops = this.props.value.stops.slice(0) const changedStop = stopData.zoom === undefined ? stopData.value : stopData stops[changeIdx] = [changedStop, value] + + const orderedStops = this.orderStopsByZoom(stops); + const changedValue = { ...this.props.value, - stops: stops, + stops: orderedStops, } this.props.onChange(this.props.fieldName, changedValue) } @@ -77,6 +150,7 @@ export default class DataProperty extends React.Component { const dataFields = this.props.value.stops.map((stop, idx) => { const zoomLevel = typeof stop[0] === 'object' ? stop[0].zoom : undefined; + const key = this.state.refs[idx]; const dataLevel = typeof stop[0] === 'object' ? stop[0].value : stop[0]; const value = stop[1] const deleteStopBtn = @@ -107,7 +181,7 @@ export default class DataProperty extends React.Component { } - return + return {zoomInput}
{dataInput} diff --git a/src/components/inputs/ArrayInput.jsx b/src/components/inputs/ArrayInput.jsx index 69a01d52..bb406d81 100644 --- a/src/components/inputs/ArrayInput.jsx +++ b/src/components/inputs/ArrayInput.jsx @@ -12,29 +12,89 @@ class ArrayInput extends React.Component { onChange: PropTypes.func, } - changeValue(idx, newValue) { - console.log(idx, newValue) - const values = this.values.slice(0) - values[idx] = newValue - this.props.onChange(values) + static defaultProps = { + value: [], + default: [], } - get values() { - return this.props.value || this.props.default || [] + constructor (props) { + super(props); + this.state = { + value: this.props.value.slice(0), + // This is so we can compare changes in getDerivedStateFromProps + initialPropsValue: this.props.value.slice(0), + }; + } + + static getDerivedStateFromProps(props, state) { + const value = []; + const initialPropsValue = state.initialPropsValue.slice(0); + + Array(props.length).fill(null).map((_, i) => { + if (props.value[i] === state.initialPropsValue[i]) { + value[i] = state.value[i]; + } + else { + value[i] = state.value[i]; + initialPropsValue[i] = state.value[i]; + } + }) + + return { + value, + initialPropsValue, + }; + } + + isComplete (value) { + return Array(this.props.length).fill(null).every((_, i) => { + const val = value[i] + return !(val === undefined || val === ""); + }); + } + + changeValue(idx, newValue) { + const value = this.state.value.slice(0); + value[idx] = newValue; + + this.setState({ + value, + }, () => { + if (this.isComplete(value)) { + this.props.onChange(value); + } + else { + // Unset until complete + this.props.onChange(undefined); + } + }); } render() { - const inputs = this.values.map((v, i) => { + const {value} = this.state; + + const containsValues = ( + value.length > 0 && + !value.every(val => { + return (val === "" || val === undefined) + }) + ); + + const inputs = Array(this.props.length).fill(null).map((_, i) => { if(this.props.type === 'number') { return } else { return } diff --git a/src/components/inputs/EnumInput.jsx b/src/components/inputs/EnumInput.jsx index 0c6039cc..9472a2ff 100644 --- a/src/components/inputs/EnumInput.jsx +++ b/src/components/inputs/EnumInput.jsx @@ -29,17 +29,17 @@ class EnumInput extends React.Component { if(options.length <= 3 && optionsLabelLength(options) <= 20) { return } else { return } } } -export default StringInput +export default EnumInput diff --git a/src/components/inputs/NumberInput.jsx b/src/components/inputs/NumberInput.jsx index 0de1fa48..2be980b7 100644 --- a/src/components/inputs/NumberInput.jsx +++ b/src/components/inputs/NumberInput.jsx @@ -1,6 +1,8 @@ import React from 'react' import PropTypes from 'prop-types' +let IDX = 0; + class NumberInput extends React.Component { static propTypes = { value: PropTypes.number, @@ -8,36 +10,52 @@ class NumberInput extends React.Component { min: PropTypes.number, max: PropTypes.number, onChange: PropTypes.func, + allowRange: PropTypes.bool, + rangeStep: PropTypes.number, + wdKey: PropTypes.string, + required: PropTypes.bool, + } + + static defaultProps = { + rangeStep: 1 } constructor(props) { super(props) this.state = { + uuid: IDX++, editing: false, value: props.value, + dirtyValue: props.value, } } static getDerivedStateFromProps(props, state) { if (!state.editing) { return { - value: props.value + value: props.value, + dirtyValue: props.value, }; } - return {}; + return null; } changeValue(newValue) { - this.setState({editing: true}); const value = (newValue === "" || newValue === undefined) ? undefined : parseFloat(newValue); - const hasChanged = this.state.value !== value + const hasChanged = this.props.value !== value; if(this.isValid(value) && hasChanged) { this.props.onChange(value) + this.setState({ + dirtyValue: newValue, + }); } - this.setState({ value: newValue }) + + this.setState({ + value: newValue, + }) } isValid(v) { @@ -65,7 +83,7 @@ class NumberInput extends React.Component { this.setState({editing: false}); // Reset explicitly to default value if value has been cleared if(this.state.value === "") { - return this.changeValue(this.props.default) + return; } // If set value is invalid fall back to the last valid value from props or at last resort the default value @@ -73,20 +91,117 @@ class NumberInput extends React.Component { if(this.isValid(this.props.value)) { this.changeValue(this.props.value) } else { - this.changeValue(this.props.default) + this.changeValue(undefined); } } } + onChangeRange = (e) => { + let value = parseFloat(e.target.value, 10); + const step = this.props.rangeStep; + let dirtyValue = value; + + if(step) { + // Can't do this with the range step attribute else we won't be able to set a high precision value via the text input. + const snap = value % step; + + // Round up/down to step + if (this._keyboardEvent) { + // If it's keyboard event we might get a low positive/negative value, + // for example we might go from 13 to 13.23, however because we know + // that came from a keyboard event we always want to increase by a + // single step value. + if (value < this.state.dirtyValue) { + value = this.state.value - step; + } + else { + value = this.state.value + step + } + dirtyValue = value; + } + else { + if (snap < step/2) { + value = value - snap; + } + else { + value = value + (step - snap); + }; + } + } + + this._keyboardEvent = false; + + // Clamp between min/max + value = Math.max(this.props.min, Math.min(this.props.max, value)); + + this.setState({value, dirtyValue}); + this.props.onChange(value); + } + render() { - return this.changeValue(e.target.value)} - onBlur={this.resetValue} - /> + if( + this.props.hasOwnProperty("min") && this.props.hasOwnProperty("max") && + this.props.min !== undefined && this.props.max !== undefined && + this.props.allowRange + ) { + const dirtyValue = this.state.dirtyValue === undefined ? this.props.default : this.state.dirtyValue + const value = this.state.value === undefined ? "" : this.state.value; + + return
+ { + this._keyboardEvent = true; + }} + onPointerDown={() => { + this.setState({editing: true}); + }} + onPointerUp={() => { + // Safari doesn't get onBlur event + this.setState({editing: false}); + }} + onBlur={() => { + this.setState({editing: false}); + }} + /> + { + if (!this.state.editing) { + this.changeValue(e.target.value); + } + }} + onBlur={this.resetValue} + /> +
+ } + else { + const value = this.state.value === undefined ? "" : this.state.value; + + return this.changeValue(e.target.value)} + onBlur={this.resetValue} + required={this.props.required} + /> + } } } diff --git a/src/components/inputs/StringInput.jsx b/src/components/inputs/StringInput.jsx index 875a4548..87bdd31c 100644 --- a/src/components/inputs/StringInput.jsx +++ b/src/components/inputs/StringInput.jsx @@ -8,7 +8,13 @@ class StringInput extends React.Component { style: PropTypes.object, default: PropTypes.string, onChange: PropTypes.func, + onInput: PropTypes.func, multi: PropTypes.bool, + required: PropTypes.bool, + } + + static defaultProps = { + onInput: () => {}, } constructor(props) { @@ -50,20 +56,23 @@ class StringInput extends React.Component { spellCheck: !(tag === "input"), className: classes.join(" "), style: this.props.style, - value: this.state.value, + value: this.state.value === undefined ? "" : this.state.value, placeholder: this.props.default, onChange: e => { this.setState({ editing: true, value: e.target.value - }) + }, () => { + this.props.onInput(this.state.value); + }); }, onBlur: () => { if(this.state.value!==this.props.value) { this.setState({editing: false}); this.props.onChange(this.state.value); } - } + }, + required: this.props.required, }); } } diff --git a/src/components/inputs/UrlInput.jsx b/src/components/inputs/UrlInput.jsx new file mode 100644 index 00000000..335d3ff0 --- /dev/null +++ b/src/components/inputs/UrlInput.jsx @@ -0,0 +1,77 @@ +import React from 'react' +import PropTypes from 'prop-types' +import StringInput from './StringInput' +import SmallError from '../util/SmallError' + + +function validate (url) { + let error; + const getProtocol = (url) => { + try { + const urlObj = new URL(url); + return urlObj.protocol; + } + catch (err) { + return undefined; + } + }; + const protocol = getProtocol(url); + if ( + protocol && + protocol === "http:" && + window.location.protocol === "https:" + ) { + error = ( + + CORS policy won't allow fetching resources served over http from https, use a https:// domain + + ); + } + + return error; +} + +class UrlInput extends React.Component { + static propTypes = { + "data-wd-key": PropTypes.string, + value: PropTypes.string, + style: PropTypes.object, + default: PropTypes.string, + onChange: PropTypes.func, + onInput: PropTypes.func, + multi: PropTypes.bool, + required: PropTypes.bool, + } + + static defaultProps = { + onInput: () => {}, + } + + constructor (props) { + super(props); + this.state = { + error: validate(props.value) + }; + } + + onInput = (url) => { + this.setState({ + error: validate(url) + }); + this.props.onInput(url); + } + + render () { + return ( +
+ + {this.state.error} +
+ ); + } +} + +export default UrlInput diff --git a/src/components/layers/JSONEditor.jsx b/src/components/layers/JSONEditor.jsx index 310ed137..e0a99ec9 100644 --- a/src/components/layers/JSONEditor.jsx +++ b/src/components/layers/JSONEditor.jsx @@ -7,6 +7,7 @@ import CodeMirror from 'codemirror'; import 'codemirror/mode/javascript/javascript' import 'codemirror/addon/lint/lint' +import 'codemirror/addon/edit/matchbrackets' import 'codemirror/lib/codemirror.css' import 'codemirror/addon/lint/lint.css' import '../../codemirror-maputnik.css' @@ -19,6 +20,7 @@ import '../../vendor/codemirror/addon/lint/json-lint' class JSONEditor extends React.Component { static propTypes = { layer: PropTypes.object.isRequired, + maxHeight: PropTypes.number, onChange: PropTypes.func, } @@ -46,6 +48,7 @@ class JSONEditor extends React.Component { viewportMargin: Infinity, lineNumbers: true, lint: true, + matchBrackets: true, gutters: ["CodeMirror-lint-markers"], scrollbarStyle: "null", }); @@ -104,20 +107,15 @@ class JSONEditor extends React.Component { } render() { - const codeMirrorOptions = { - mode: {name: "javascript", json: true}, - tabSize: 2, - theme: 'maputnik', - viewportMargin: Infinity, - lineNumbers: true, - lint: true, - gutters: ["CodeMirror-lint-markers"], - scrollbarStyle: "null", + const style = {}; + if (this.props.maxHeight) { + style.maxHeight = this.props.maxHeight; } return
this._el = el} + style={style} /> } } diff --git a/src/components/layers/LayerEditor.jsx b/src/components/layers/LayerEditor.jsx index 39c862d9..ed161163 100644 --- a/src/components/layers/LayerEditor.jsx +++ b/src/components/layers/LayerEditor.jsx @@ -141,7 +141,7 @@ export default class LayerEditor extends React.Component { onChange={v => this.changeProperty(null, 'source', v)} /> } - {['background', 'raster', 'hillshade', 'heatmap'].indexOf(this.state.type) < 0 && + {['background', 'raster', 'hillshade', 'heatmap'].indexOf(this.props.layer.type) < 0 && {}, onDataChange: () => {}, onLayerSelect: () => {}, + onChange: () => {}, mapboxAccessToken: tokens.mapbox, options: {}, } @@ -87,21 +89,12 @@ export default class MapboxGlMap extends React.Component { const metadata = props.mapStyle.metadata || {} MapboxGl.accessToken = metadata['maputnik:mapbox_access_token'] || tokens.mapbox - if(!props.inspectModeEnabled) { - //Mapbox GL now does diffing natively so we don't need to calculate - //the necessary operations ourselves! - this.state.map.setStyle(props.mapStyle, { diff: true}) - } - } - - shouldComponentUpdate(nextProps, nextState) { - let should = false; - try { - should = JSON.stringify(this.props) !== JSON.stringify(nextProps) || JSON.stringify(this.state) !== JSON.stringify(nextState); - } catch(e) { - // no biggie, carry on - } - return should; + //Mapbox GL now does diffing natively so we don't need to calculate + //the necessary operations ourselves! + this.state.map.setStyle( + this.props.replaceAccessTokens(props.mapStyle), + {diff: true} + ) } componentDidUpdate(prevProps) { @@ -112,6 +105,9 @@ export default class MapboxGlMap extends React.Component { this.updateMapFromProps(this.props); if(this.props.inspectModeEnabled !== prevProps.inspectModeEnabled) { + // HACK: Fix for , while we wait for a proper fix. + // eslint-disable-next-line + this.state.inspect._popupBlocked = false; this.state.inspect.toggleInspector() } if(this.props.inspectModeEnabled) { @@ -133,16 +129,24 @@ export default class MapboxGlMap extends React.Component { container: this.container, style: this.props.mapStyle, hash: true, + maxZoom: 24 } const map = new MapboxGl.Map(mapOpts); + const mapViewChange = () => { + const center = map.getCenter(); + const zoom = map.getZoom(); + this.props.onChange({center, zoom}); + } + mapViewChange(); + map.showTileBoundaries = mapOpts.showTileBoundaries; map.showCollisionBoxes = mapOpts.showCollisionBoxes; map.showOverdrawInspector = mapOpts.showOverdrawInspector; - const zoom = new ZoomControl; - map.addControl(zoom, 'top-right'); + const zoomControl = new ZoomControl; + map.addControl(zoomControl, 'top-right'); const nav = new MapboxGl.NavigationControl({visualizePitch:true}); map.addControl(nav, 'top-right'); @@ -190,11 +194,18 @@ export default class MapboxGlMap extends React.Component { }) }) + map.on("error", e => { + console.log("ERROR", e); + }) + map.on("zoom", e => { this.setState({ zoom: map.getZoom() }); - }) + }); + + map.on("dragend", mapViewChange); + map.on("zoomend", mapViewChange); } render() { diff --git a/src/components/map/OpenLayersMap.jsx b/src/components/map/OpenLayersMap.jsx index b1ee83a7..446096a5 100644 --- a/src/components/map/OpenLayersMap.jsx +++ b/src/components/map/OpenLayersMap.jsx @@ -32,6 +32,8 @@ export default class OpenLayersMap extends React.Component { style: PropTypes.object, onLayerSelect: PropTypes.func.isRequired, debugToolbox: PropTypes.bool.isRequired, + replaceAccessTokens: PropTypes.func.isRequired, + onChange: PropTypes.func.isRequired, } static defaultProps = { @@ -61,7 +63,9 @@ export default class OpenLayersMap extends React.Component { componentDidUpdate(prevProps) { if (this.props.mapStyle !== prevProps.mapStyle) { - this.updateStyle(this.props.mapStyle); + this.updateStyle( + this.props.replaceAccessTokens(this.props.mapStyle) + ); } } @@ -93,6 +97,22 @@ export default class OpenLayersMap extends React.Component { }) }) + const onMoveEnd = () => { + const zoom = map.getView().getZoom(); + const center = toLonLat(map.getView().getCenter()); + + this.props.onChange({ + zoom, + center: { + lng: center[0], + lat: center[1], + }, + }); + } + + onMoveEnd(); + map.on('moveend', onMoveEnd); + map.on('postrender', (evt) => { const center = toLonLat(map.getView().getCenter()); this.setState({ @@ -108,7 +128,9 @@ export default class OpenLayersMap extends React.Component { this.map = map; - this.updateStyle(this.props.mapStyle); + this.updateStyle( + this.props.replaceAccessTokens(this.props.mapStyle) + ); } closeOverlay = (e) => { diff --git a/src/components/modals/DebugModal.js b/src/components/modals/DebugModal.js index 91bf47c7..181983f1 100644 --- a/src/components/modals/DebugModal.js +++ b/src/components/modals/DebugModal.js @@ -13,9 +13,16 @@ class DebugModal extends React.Component { onOpenToggle: PropTypes.func.isRequired, mapboxGlDebugOptions: PropTypes.object, openlayersDebugOptions: PropTypes.object, + mapView: PropTypes.object, } render() { + const {mapView} = this.props; + + const osmZoom = Math.round(mapView.zoom)+1; + const osmLon = Number.parseFloat(mapView.center.lng).toFixed(5); + const osmLat = Number.parseFloat(mapView.center.lat).toFixed(5); + return
+

Options

{this.props.renderer === 'mbgljs' &&
    {Object.entries(this.props.mapboxGlDebugOptions).map(([key, val]) => { @@ -46,6 +54,18 @@ class DebugModal extends React.Component {
}
+
+

Links

+

+ + Open in OSM + — Opens the current view on openstreetmap.org +

+
} } diff --git a/src/components/modals/OpenModal.jsx b/src/components/modals/OpenModal.jsx index d2c64773..67f289da 100644 --- a/src/components/modals/OpenModal.jsx +++ b/src/components/modals/OpenModal.jsx @@ -4,6 +4,7 @@ import LoadingModal from './LoadingModal' import Modal from './Modal' import Button from '../Button' import FileReaderInput from 'react-file-reader-input' +import UrlInput from '../inputs/UrlInput' import {MdFileUpload} from 'react-icons/md' import {MdAddCircleOutline} from 'react-icons/md' @@ -122,9 +123,8 @@ class OpenModal extends React.Component { }) } - onOpenUrl = () => { - const url = this.styleUrlElement.value; - this.onStyleSelect(url); + onOpenUrl = (url) => { + this.onStyleSelect(this.state.styleUrl); } onUpload = (_, files) => { @@ -160,9 +160,9 @@ class OpenModal extends React.Component { this.props.onOpenToggle(); } - onChangeUrl = () => { + onChangeUrl = (url) => { this.setState({ - styleUrl: this.styleUrlElement.value + styleUrl: url, }); } @@ -209,14 +209,13 @@ class OpenModal extends React.Component {

Load from a URL. Note that the URL must have CORS enabled.

- this.styleUrlElement = input} className="maputnik-input" - placeholder="Enter URL..." + default="Enter URL..." value={this.state.styleUrl} - onChange={this.onChangeUrl} + onInput={this.onChangeUrl} />
diff --git a/src/components/sources/SourceTypeEditor.jsx b/src/components/sources/SourceTypeEditor.jsx index 7a9c3237..8ed46728 100644 --- a/src/components/sources/SourceTypeEditor.jsx +++ b/src/components/sources/SourceTypeEditor.jsx @@ -3,8 +3,10 @@ import PropTypes from 'prop-types' import {latest} from '@mapbox/mapbox-gl-style-spec' import InputBlock from '../inputs/InputBlock' import StringInput from '../inputs/StringInput' +import UrlInput from '../inputs/UrlInput' import NumberInput from '../inputs/NumberInput' import SelectInput from '../inputs/SelectInput' +import JSONEditor from '../layers/JSONEditor' class TileJSONSourceEditor extends React.Component { @@ -17,7 +19,7 @@ class TileJSONSourceEditor extends React.Component { render() { return
- this.props.onChange({ ...this.props.source, @@ -51,7 +53,7 @@ class TileURLSourceEditor extends React.Component { const tiles = this.props.source.tiles || [] return tiles.map((tileUrl, tileIndex) => { return - @@ -86,15 +88,15 @@ class TileURLSourceEditor extends React.Component { } } -class GeoJSONSourceEditor extends React.Component { +class GeoJSONSourceUrlEditor extends React.Component { static propTypes = { source: PropTypes.object.isRequired, onChange: PropTypes.func.isRequired, } render() { - return - + this.props.onChange({ ...this.props.source, @@ -105,6 +107,28 @@ class GeoJSONSourceEditor extends React.Component { } } +class GeoJSONSourceJSONEditor extends React.Component { + static propTypes = { + source: PropTypes.object.isRequired, + onChange: PropTypes.func.isRequired, + } + + render() { + return + { + this.props.onChange({ + ...this.props.source, + data, + }) + }} + /> + + } +} + class SourceTypeEditor extends React.Component { static propTypes = { mode: PropTypes.string.isRequired, @@ -118,7 +142,8 @@ class SourceTypeEditor extends React.Component { onChange: this.props.onChange, } switch(this.props.mode) { - case 'geojson': return + case 'geojson_url': return + case 'geojson_json': return case 'tilejson_vector': return case 'tilexyz_vector': return case 'tilejson_raster': return diff --git a/src/components/util/SmallError.jsx b/src/components/util/SmallError.jsx new file mode 100644 index 00000000..03d9c784 --- /dev/null +++ b/src/components/util/SmallError.jsx @@ -0,0 +1,20 @@ +import React from 'react' +import PropTypes from 'prop-types' +import './SmallError.scss'; + + +class SmallError extends React.Component { + static propTypes = { + children: PropTypes.node, + } + + render () { + return ( +
+ Error: {this.props.children} +
+ ); + } +} + +export default SmallError diff --git a/src/components/util/SmallError.scss b/src/components/util/SmallError.scss new file mode 100644 index 00000000..1111282d --- /dev/null +++ b/src/components/util/SmallError.scss @@ -0,0 +1,7 @@ +@import '../../styles/vars'; + +.SmallError { + color: #E57373; + font-size: $font-size-6; + margin-top: $margin-2 +} diff --git a/src/config/styles.json b/src/config/styles.json index 155b9273..ff0e4072 100644 --- a/src/config/styles.json +++ b/src/config/styles.json @@ -1,62 +1,62 @@ [ - { - "id": "klokantech-basic", - "title": "Klokantech Basic", - "url": "https://cdn.jsdelivr.net/gh/openmaptiles/klokantech-basic-gl-style@e142f83/style.json", - "thumbnail": "https://maputnik.github.io/thumbnails/klokantech-basic.png" - }, - { - "id": "dark-matter", - "title": "Dark Matter", - "url": "https://cdn.jsdelivr.net/gh/openmaptiles/dark-matter-gl-style@1dcc1d3/style.json", - "thumbnail": "https://maputnik.github.io/thumbnails/dark-matter.png" - }, - { - "id": "positron", - "title": "Positron", - "url": "https://cdn.jsdelivr.net/gh/openmaptiles/positron-gl-style@2877814/style.json", - "thumbnail": "https://maputnik.github.io/thumbnails/positron.png" - }, - { - "id": "osm-bright", - "title": "OSM Bright", - "url": "https://cdn.jsdelivr.net/gh/openmaptiles/osm-bright-gl-style@500e26e/style.json", - "thumbnail": "https://maputnik.github.io/thumbnails/osm-bright.png" - }, - { - "id": "toner-gl-style", - "title": "Toner", - "url": "https://cdn.jsdelivr.net/gh/openmaptiles/toner-gl-style@bb49571/style.json", - "thumbnail": "https://maputnik.github.io/thumbnails/toner.png" - }, { "id": "osm-liberty", "title": "OSM Liberty", "url": "https://maputnik.github.io/osm-liberty/style.json", "thumbnail": "https://maputnik.github.io/thumbnails/osm-liberty.png" }, + { + "id": "klokantech-basic", + "title": "Klokantech Basic", + "url": "https://cdn.jsdelivr.net/gh/openmaptiles/klokantech-basic-gl-style@v1.9/style.json", + "thumbnail": "https://maputnik.github.io/thumbnails/klokantech-basic.png" + }, + { + "id": "dark-matter", + "title": "Dark Matter", + "url": "https://cdn.jsdelivr.net/gh/openmaptiles/dark-matter-gl-style@v1.8/style.json", + "thumbnail": "https://maputnik.github.io/thumbnails/dark-matter.png" + }, + { + "id": "positron", + "title": "Positron", + "url": "https://cdn.jsdelivr.net/gh/openmaptiles/positron-gl-style@v1.8/style.json", + "thumbnail": "https://maputnik.github.io/thumbnails/positron.png" + }, + { + "id": "osm-bright", + "title": "OSM Bright", + "url": "https://cdn.jsdelivr.net/gh/openmaptiles/osm-bright-gl-style@v1.9/style.json", + "thumbnail": "https://maputnik.github.io/thumbnails/osm-bright.png" + }, + { + "id": "toner-gl-style", + "title": "Toner", + "url": "https://cdn.jsdelivr.net/gh/openmaptiles/toner-gl-style@dcb6e64/style.json", + "thumbnail": "https://maputnik.github.io/thumbnails/toner.png" + }, { "id": "os-zoomstack-outdoor", "title": "Zoomstack Outdoor", - "url": "https://s3-eu-west-1.amazonaws.com/tiles.os.uk/styles/open-zoomstack-outdoor/style.json", + "url": "https://s3-eu-west-1.amazonaws.com/tiles.os.uk/v2/styles/open-zoomstack-outdoor/style.json", "thumbnail": "https://maputnik.github.io/thumbnails/os-zoomstack-outdoor.png" }, { "id": "os-zoomstack-road", "title": "Zoomstack Road", - "url": "https://s3-eu-west-1.amazonaws.com/tiles.os.uk/styles/open-zoomstack-road/style.json", + "url": "https://s3-eu-west-1.amazonaws.com/tiles.os.uk/v2/styles/open-zoomstack-road/style.json", "thumbnail": "https://maputnik.github.io/thumbnails/os-zoomstack-road.png" }, { "id": "os-zoomstack-light", "title": "Zoomstack Light", - "url": "https://s3-eu-west-1.amazonaws.com/tiles.os.uk/styles/open-zoomstack-light/style.json", + "url": "https://s3-eu-west-1.amazonaws.com/tiles.os.uk/v2/styles/open-zoomstack-light/style.json", "thumbnail": "https://maputnik.github.io/thumbnails/os-zoomstack-light.png" }, { "id": "os-zoomstack-night", "title": "Zoomstack Night", - "url": "https://s3-eu-west-1.amazonaws.com/tiles.os.uk/styles/open-zoomstack-night/style.json", + "url": "https://s3-eu-west-1.amazonaws.com/tiles.os.uk/v2/styles/open-zoomstack-night/style.json", "thumbnail": "https://maputnik.github.io/thumbnails/os-zoomstack-night.png" }, { diff --git a/src/config/tilesets.json b/src/config/tilesets.json index a586c103..1cd02868 100644 --- a/src/config/tilesets.json +++ b/src/config/tilesets.json @@ -16,7 +16,7 @@ }, "open_zoomstack": { "type": "vector", - "url": "https://s3-eu-west-1.amazonaws.com/tiles.os.uk/data/vector/open-zoomstack/config.json", + "url": "https://s3-eu-west-1.amazonaws.com/tiles.os.uk/v2/data/vector/open-zoomstack/config.json", "title": "OS Open Zoomstack" } } diff --git a/src/styles/_codemirror.scss b/src/styles/_codemirror.scss index 85d7aa84..14cb415a 100644 --- a/src/styles/_codemirror.scss +++ b/src/styles/_codemirror.scss @@ -1,3 +1,9 @@ +.CodeMirror-lint-tooltip { + z-index: 2000 !important; +} + .codemirror-container { max-width: 100%; + position: relative; + overflow: auto; } diff --git a/src/styles/_input.scss b/src/styles/_input.scss index 90e84ed3..6c196a2b 100644 --- a/src/styles/_input.scss +++ b/src/styles/_input.scss @@ -11,6 +11,11 @@ border: none; background-color: $color-gray; color: lighten($color-lowgray, 12); + + &:invalid { + border: solid 1px #B71C1C; + border-radius: 2px; + } } .maputnik-string { @@ -22,6 +27,16 @@ } } +.maputnik-number-container { + display: flex; +} + +.maputnik-number-range { + width: calc(100% - 4.5em); + margin-right: 0.5em; + flex-shrink: 0; +} + .maputnik-number { @extend .maputnik-input; } @@ -173,3 +188,8 @@ margin-bottom: $margin-3; } } + +.maputnik-input-block-content { + position: relative; + overflow: hidden; +} diff --git a/src/styles/_modal.scss b/src/styles/_modal.scss index d4f11f19..f15cf79e 100644 --- a/src/styles/_modal.scss +++ b/src/styles/_modal.scss @@ -180,10 +180,26 @@ border-width: 2px; border-style: solid; padding: $margin-2; + + .maputnik-input-block-label { + width: 30%; + } + + .maputnik-input-block-content { + width: 70%; + } } .maputnik-add-source { @extend .clearfix; + + .maputnik-input-block-label { + width: 30%; + } + + .maputnik-input-block-content { + width: 70%; + } } .maputnik-add-source-button { @@ -264,3 +280,7 @@ color: $color-green; margin-top: 16px; } + +.modal-settings { + width: 400px; +} diff --git a/src/styles/_react-collapse.scss b/src/styles/_react-collapse.scss index 3ee0cae7..ce8db33b 100644 --- a/src/styles/_react-collapse.scss +++ b/src/styles/_react-collapse.scss @@ -7,3 +7,7 @@ flex: 1; } } + +.ReactCollapse--collapse { + transition: height 180ms; +} diff --git a/src/styles/_vars.scss b/src/styles/_vars.scss new file mode 100644 index 00000000..7cb3d178 --- /dev/null +++ b/src/styles/_vars.scss @@ -0,0 +1,23 @@ +$color-black: #191b20; +$color-gray: #222429; +$color-midgray: #303237; +$color-lowgray: #a4a4a4; +$color-white: #f0f0f0; +$color-red: #cf4a4a; +$color-green: #53b972; +$margin-1: 3px; +$margin-2: 5px; +$margin-3: 10px; +$margin-4: 30px; +$margin-5: 40px; +$font-size-1: 24px; +$font-size-2: 20px; +$font-size-3: 18px; +$font-size-4: 16px; +$font-size-5: 14px; +$font-size-6: 12px; +$font-family: Roboto, sans-serif; + +$toolbar-height: 40px; +$toolbar-offset: 0; + diff --git a/src/styles/_zoomproperty.scss b/src/styles/_zoomproperty.scss index 0c5adb0a..2b9ce14b 100644 --- a/src/styles/_zoomproperty.scss +++ b/src/styles/_zoomproperty.scss @@ -45,17 +45,12 @@ } .maputnik-delete-stop { + display: inline-block; + padding-bottom: 0; + padding-top: 0; + vertical-align: middle; + @extend .maputnik-icon-button; - - vertical-align: top; - - .maputnik-doc-wrapper { - width: auto; - } - - .maputnik-doc-target { - cursor: pointer; - } } .maputnik-add-stop { diff --git a/src/styles/index.scss b/src/styles/index.scss index bfef57f0..759b73cf 100644 --- a/src/styles/index.scss +++ b/src/styles/index.scss @@ -1,26 +1,4 @@ -$color-black: #191b20; -$color-gray: #222429; -$color-midgray: #303237; -$color-lowgray: #a4a4a4; -$color-white: #f0f0f0; -$color-red: #cf4a4a; -$color-green: #53b972; -$margin-1: 3px; -$margin-2: 5px; -$margin-3: 10px; -$margin-4: 30px; -$margin-5: 40px; -$font-size-1: 24px; -$font-size-2: 20px; -$font-size-3: 18px; -$font-size-4: 16px; -$font-size-5: 14px; -$font-size-6: 12px; -$font-family: Roboto, sans-serif; - -$toolbar-height: 40px; -$toolbar-offset: 0; - +@import 'vars'; @import 'mixins'; @import 'reset'; @import 'base'; @@ -37,8 +15,8 @@ $toolbar-offset: 0; @import 'zoomproperty'; @import 'popup'; @import 'map'; -@import 'react-collapse'; @import 'codemirror'; +@import 'react-collapse'; /** * Hacks for webdriverio isVisibleWithinViewport diff --git a/test/functional/layers/index.js b/test/functional/layers/index.js index 7665bc1c..1186e41c 100644 --- a/test/functional/layers/index.js +++ b/test/functional/layers/index.js @@ -200,7 +200,7 @@ describe("layers", function() { const elem = $(wd.$("layer-list-item:background:"+bgId)); elem.click(); - browser.setValueSafe(wd.$("min-zoom", "input"), 1) + browser.setValueSafe(wd.$("min-zoom", 'input[type="text"]'), 1) const elem2 = $(wd.$("layer-editor.layer-id", "input")); elem2.click(); @@ -232,7 +232,7 @@ describe("layers", function() { const elem = $(wd.$("layer-list-item:background:"+bgId)); elem.click(); - browser.setValueSafe(wd.$("max-zoom", "input"), 1) + browser.setValueSafe(wd.$("max-zoom", 'input[type="text"]'), 1) const elem2 = $(wd.$("layer-editor.layer-id", "input")); elem2.click();