From 047437dd221064b40d516b12d02c49719d1fb476 Mon Sep 17 00:00:00 2001 From: matthew44-mappable <155086725+matthew44-mappable@users.noreply.github.com> Date: Tue, 14 May 2024 12:38:00 +0300 Subject: [PATCH] Add tooltip and popup markers (#5) * Added getting information about icons from Figma * Added getting links to icons * restructuring inside the tools folder * Fetching components only with export settings * load icon in local folder * uploading icons from Figma to a local folder * export only svg files * refactor * optimize svg and filter fallback icons * generate types for icons * generate icons list in md file * filter fill-opacity icons * default marker * add nanospinner in cli tool * path as common constants, nanospinner error in catch * show pin with icon * fetch colors from Figma * color props * optional props * custom color * add fallback icon * size props in marker * Downscale if no small icon * upgrade markers * download icons * fix * took chunk function from lodash * rename generated files * add tooltip marker * tooltip combined positions, tooltip offset * support dark theme * separated tooltip and balloon * add default popup * remove close callback from popup content * remove mock MMapPopupMarker * add overrides * popups examples * add unit-tests * add prefix in css * add open animations * remove __impl * add popup in default marker * fix popup in marker * Rename ballon to popup * Rename tooltip to text-popup * fix issues * remove text popup and default popup * fix --- example/marker-popup/common.ts | 5 + example/marker-popup/react/index.html | 36 ++++ example/marker-popup/react/index.tsx | 45 ++++ example/marker-popup/vanilla/index.html | 34 +++ example/marker-popup/vanilla/index.ts | 38 ++++ example/marker-popup/vue/index.html | 35 +++ example/marker-popup/vue/index.ts | 53 +++++ example/popups-on-map/close.svg | 1 + example/popups-on-map/common.css | 81 +++++++ example/popups-on-map/common.ts | 12 ++ example/popups-on-map/react/index.html | 37 ++++ example/popups-on-map/react/index.tsx | 78 +++++++ example/popups-on-map/vanilla/index.html | 35 +++ example/popups-on-map/vanilla/index.ts | 81 +++++++ example/popups-on-map/vue/index.html | 36 ++++ example/popups-on-map/vue/index.ts | 105 +++++++++ jest.config.js | 7 +- src/markers/MMapDefaultMarker/index.ts | 84 +++++++- src/markers/MMapDefaultMarker/react/index.tsx | 54 +++++ src/markers/MMapDefaultMarker/vue/index.ts | 56 ++++- .../MMapPopupMarker/MMapPopupMarker.test.ts | 175 +++++++++++++++ src/markers/MMapPopupMarker/index.css | 200 ++++++++++++++++++ src/markers/MMapPopupMarker/index.ts | 187 ++++++++++++++++ src/markers/MMapPopupMarker/react/index.tsx | 47 ++++ src/markers/MMapPopupMarker/tail.svg | 1 + src/markers/MMapPopupMarker/vue/index.ts | 69 ++++++ src/markers/index.ts | 10 +- tests/common.ts | 10 + tests/utils/svgTransform.js | 11 + 29 files changed, 1611 insertions(+), 12 deletions(-) create mode 100644 example/marker-popup/common.ts create mode 100644 example/marker-popup/react/index.html create mode 100644 example/marker-popup/react/index.tsx create mode 100644 example/marker-popup/vanilla/index.html create mode 100644 example/marker-popup/vanilla/index.ts create mode 100644 example/marker-popup/vue/index.html create mode 100644 example/marker-popup/vue/index.ts create mode 100644 example/popups-on-map/close.svg create mode 100644 example/popups-on-map/common.css create mode 100644 example/popups-on-map/common.ts create mode 100644 example/popups-on-map/react/index.html create mode 100644 example/popups-on-map/react/index.tsx create mode 100644 example/popups-on-map/vanilla/index.html create mode 100644 example/popups-on-map/vanilla/index.ts create mode 100644 example/popups-on-map/vue/index.html create mode 100644 example/popups-on-map/vue/index.ts create mode 100644 src/markers/MMapDefaultMarker/react/index.tsx create mode 100644 src/markers/MMapPopupMarker/MMapPopupMarker.test.ts create mode 100644 src/markers/MMapPopupMarker/index.css create mode 100644 src/markers/MMapPopupMarker/index.ts create mode 100644 src/markers/MMapPopupMarker/react/index.tsx create mode 100644 src/markers/MMapPopupMarker/tail.svg create mode 100644 src/markers/MMapPopupMarker/vue/index.ts create mode 100644 tests/common.ts create mode 100644 tests/utils/svgTransform.js diff --git a/example/marker-popup/common.ts b/example/marker-popup/common.ts new file mode 100644 index 0000000..835e6ef --- /dev/null +++ b/example/marker-popup/common.ts @@ -0,0 +1,5 @@ +import type {LngLat, MMapLocationRequest} from '@mappable-world/mappable-types'; + +export const CENTER: LngLat = [55.442795, 25.24107]; + +export const LOCATION: MMapLocationRequest = {center: CENTER, zoom: 9}; diff --git a/example/marker-popup/react/index.html b/example/marker-popup/react/index.html new file mode 100644 index 0000000..b9c642a --- /dev/null +++ b/example/marker-popup/react/index.html @@ -0,0 +1,36 @@ + + + + React example mappable-default-ui-theme + + + + + + + + + + + + + + +
+ + diff --git a/example/marker-popup/react/index.tsx b/example/marker-popup/react/index.tsx new file mode 100644 index 0000000..c7a006e --- /dev/null +++ b/example/marker-popup/react/index.tsx @@ -0,0 +1,45 @@ +import {MarkerSizeProps} from '../../src'; +import {CENTER, LOCATION} from '../common'; + +window.map = null; + +main(); +async function main() { + const [mappableReact] = await Promise.all([mappable.import('@mappable-world/mappable-reactify'), mappable.ready]); + const reactify = mappableReact.reactify.bindTo(React, ReactDOM); + + const {MMap, MMapDefaultSchemeLayer, MMapDefaultFeaturesLayer, MMapControls, MMapControlButton} = + reactify.module(mappable); + + const {useState, useCallback, useMemo} = React; + + const {MMapDefaultMarker} = reactify.module(await mappable.import('@mappable-world/mappable-default-ui-theme')); + + ReactDOM.render( + + + , + document.getElementById('app') + ); + + function App() { + const [location] = useState(LOCATION); + const [size, setSize] = useState('normal'); + const popup = useMemo(() => ({content: () => Marker popup}), []); + + return ( + (map = x)}> + + + + + + + setSize('normal'), [])} /> + setSize('small'), [])} /> + setSize('micro'), [])} /> + + + ); + } +} diff --git a/example/marker-popup/vanilla/index.html b/example/marker-popup/vanilla/index.html new file mode 100644 index 0000000..e4d869a --- /dev/null +++ b/example/marker-popup/vanilla/index.html @@ -0,0 +1,34 @@ + + + + Vanilla example mappable-default-ui-theme + + + + + + + + + + + + +
+ + diff --git a/example/marker-popup/vanilla/index.ts b/example/marker-popup/vanilla/index.ts new file mode 100644 index 0000000..7308633 --- /dev/null +++ b/example/marker-popup/vanilla/index.ts @@ -0,0 +1,38 @@ +import {CENTER, LOCATION} from '../common'; +window.map = null; + +main(); +async function main() { + await mappable.ready; + const {MMap, MMapDefaultSchemeLayer, MMapDefaultFeaturesLayer, MMapControls, MMapControlButton} = mappable; + + const {MMapDefaultMarker} = await mappable.import('@mappable-world/mappable-default-ui-theme'); + + map = new MMap(document.getElementById('app'), {location: LOCATION}); + + map.addChild(new MMapDefaultSchemeLayer({})); + map.addChild(new MMapDefaultFeaturesLayer({})); + + const marker = new MMapDefaultMarker({ + coordinates: CENTER, + iconName: 'fallback', + size: 'normal', + popup: { + content: () => { + const popup = document.createElement('span'); + popup.textContent = 'Marker popup'; + return popup; + } + } + }); + + map.addChild(marker); + + map.addChild( + new MMapControls({position: 'top left'}, [ + new MMapControlButton({text: 'Normal', onClick: () => marker.update({size: 'normal'})}), + new MMapControlButton({text: 'Small', onClick: () => marker.update({size: 'small'})}), + new MMapControlButton({text: 'Micro', onClick: () => marker.update({size: 'micro'})}) + ]) + ); +} diff --git a/example/marker-popup/vue/index.html b/example/marker-popup/vue/index.html new file mode 100644 index 0000000..9471aa8 --- /dev/null +++ b/example/marker-popup/vue/index.html @@ -0,0 +1,35 @@ + + + + Vue example mappable-default-ui-theme + + + + + + + + + + + + + +
+ + diff --git a/example/marker-popup/vue/index.ts b/example/marker-popup/vue/index.ts new file mode 100644 index 0000000..074ca99 --- /dev/null +++ b/example/marker-popup/vue/index.ts @@ -0,0 +1,53 @@ +import {MarkerSizeProps} from '../../src'; +import {CENTER, LOCATION} from '../common'; + +window.map = null; + +main(); +async function main() { + const [mappableVue] = await Promise.all([mappable.import('@mappable-world/mappable-vuefy'), mappable.ready]); + const vuefy = mappableVue.vuefy.bindTo(Vue); + + const {MMap, MMapDefaultSchemeLayer, MMapDefaultFeaturesLayer, MMapControls, MMapControlButton} = + vuefy.module(mappable); + + const {MMapDefaultMarker} = vuefy.module(await mappable.import('@mappable-world/mappable-default-ui-theme')); + + const app = Vue.createApp({ + components: { + MMap, + MMapDefaultSchemeLayer, + MMapDefaultFeaturesLayer, + MMapControls, + MMapControlButton, + MMapDefaultMarker + }, + setup() { + const size = Vue.ref('normal'); + const refMap = (ref: any) => { + window.map = ref?.entity; + }; + const setNormalSize = () => (size.value = 'normal'); + const setSmallSize = () => (size.value = 'small'); + const setMicroSize = () => (size.value = 'micro'); + + return {LOCATION, CENTER, size, refMap, setNormalSize, setSmallSize, setMicroSize}; + }, + template: ` + + + + + + + + + + + + ` + }); + app.mount('#app'); +} diff --git a/example/popups-on-map/close.svg b/example/popups-on-map/close.svg new file mode 100644 index 0000000..1ba55ba --- /dev/null +++ b/example/popups-on-map/close.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/example/popups-on-map/common.css b/example/popups-on-map/common.css new file mode 100644 index 0000000..820bf36 --- /dev/null +++ b/example/popups-on-map/common.css @@ -0,0 +1,81 @@ +.popup { + display: flex; + flex-direction: column; + row-gap: 8px; +} + +.header { + display: flex; + flex-direction: row; + justify-content: space-between; + gap: 8px; + padding-right: 20px; + + .header_title { + font-size: 16px; + font-weight: 400; + line-height: 22px; + color: #050d33; + } + .header_close { + position: absolute; + top: 0; + right: 0; + display: flex; + justify-content: center; + align-items: center; + width: 32px; + height: 32px; + border: none; + background: none; + color: #c8c9cc; + cursor: pointer; + background-image: url('./close.svg?inline'); + background-position: center; + background-repeat: no-repeat; + } +} + +.description { + font-size: 14px; + font-weight: 400; + line-height: 20px; + color: #7b7d85; +} + +.action { + width: max-content; + padding: 12px 16px; + border-radius: 8px; + border: none; + background-color: #eefd7d; + transition: background-color 0.1s ease-out; + color: #050d33; + text-align: center; + cursor: pointer; + white-space: normal; + + font-size: 14px; + font-style: normal; + font-weight: 500; + line-height: 16px; +} + +.action:hover { + background-color: #e5fd30; +} + +._dark { + .header_title { + color: #f2f5fa; + } + .header_close { + color: #46464d; + } + .description { + color: #7b7d85; + } + .action { + background-color: #d6fd63; + } +} diff --git a/example/popups-on-map/common.ts b/example/popups-on-map/common.ts new file mode 100644 index 0000000..b3b7c73 --- /dev/null +++ b/example/popups-on-map/common.ts @@ -0,0 +1,12 @@ +import type {MMapLocationRequest, LngLat} from '@mappable-world/mappable-types'; + +export const CENTER: LngLat = [55.442795, 25.24107]; +export const LOCATION: MMapLocationRequest = {center: CENTER, zoom: 14}; + +export const POPUP_TEXT = 'Default text popup'; +export const CUSTOM_POPUP_COORDS: LngLat = [CENTER[0] - 0.02, CENTER[1]]; +export const TEXT_POPUP_COORDS: LngLat = [CENTER[0] + 0.02, CENTER[1]]; + +export const TITLE = 'Default popup marker'; +export const DESCRIPTION = 'Description for default popup'; +export const ACTION = 'Make an action'; diff --git a/example/popups-on-map/react/index.html b/example/popups-on-map/react/index.html new file mode 100644 index 0000000..4183912 --- /dev/null +++ b/example/popups-on-map/react/index.html @@ -0,0 +1,37 @@ + + + + React example mappable-default-ui-theme + + + + + + + + + + + + + + + +
+ + diff --git a/example/popups-on-map/react/index.tsx b/example/popups-on-map/react/index.tsx new file mode 100644 index 0000000..a07ab88 --- /dev/null +++ b/example/popups-on-map/react/index.tsx @@ -0,0 +1,78 @@ +import type {MMapPopupPositionProps} from '../../src'; +import {ACTION, CUSTOM_POPUP_COORDS, DESCRIPTION, LOCATION, POPUP_TEXT, TEXT_POPUP_COORDS, TITLE} from '../common'; +window.map = null; + +main(); +async function main() { + const [mappableReact] = await Promise.all([mappable.import('@mappable-world/mappable-reactify'), mappable.ready]); + const reactify = mappableReact.reactify.bindTo(React, ReactDOM); + + const {MMap, MMapDefaultSchemeLayer, MMapDefaultFeaturesLayer, MMapControls, MMapControlButton} = + reactify.module(mappable); + + const {useState, useCallback} = React; + + const {MMapPopupMarker} = reactify.module(await mappable.import('@mappable-world/mappable-default-ui-theme')); + + ReactDOM.render( + + + , + document.getElementById('app') + ); + + function App() { + const [position, setPosition] = useState(undefined); + const [showCustom, setShowCustom] = useState(true); + + const positionLeft = useCallback(() => setPosition('left'), []); + const positionLeftTop = useCallback(() => setPosition('left top'), []); + const positionLeftBottom = useCallback(() => setPosition('left bottom'), []); + const positionBottom = useCallback(() => setPosition('bottom'), []); + const positionTop = useCallback(() => setPosition('top'), []); + const positionRightTop = useCallback(() => setPosition('right top'), []); + const positionRightBottom = useCallback(() => setPosition('right bottom'), []); + const positionRight = useCallback(() => setPosition('right'), []); + + const customPopupContent = useCallback( + () => ( + + + {TITLE} + + + {DESCRIPTION} + + + ), + [] + ); + + return ( + (map = x)}> + + + + + + + + + + + + + + + + ); + } +} diff --git a/example/popups-on-map/vanilla/index.html b/example/popups-on-map/vanilla/index.html new file mode 100644 index 0000000..730390e --- /dev/null +++ b/example/popups-on-map/vanilla/index.html @@ -0,0 +1,35 @@ + + + + Vanilla example mappable-default-ui-theme + + + + + + + + + + + + + +
+ + diff --git a/example/popups-on-map/vanilla/index.ts b/example/popups-on-map/vanilla/index.ts new file mode 100644 index 0000000..427c3ce --- /dev/null +++ b/example/popups-on-map/vanilla/index.ts @@ -0,0 +1,81 @@ +import type {MMapPopupPositionProps} from '../../src'; +import {ACTION, CUSTOM_POPUP_COORDS, DESCRIPTION, LOCATION, POPUP_TEXT, TEXT_POPUP_COORDS, TITLE} from '../common'; + +window.map = null; + +main(); +async function main() { + await mappable.ready; + const {MMap, MMapDefaultSchemeLayer, MMapDefaultFeaturesLayer, MMapControls, MMapControlButton} = mappable; + + const {MMapPopupMarker} = await mappable.import('@mappable-world/mappable-default-ui-theme'); + + map = new MMap(document.getElementById('app'), {location: LOCATION}); + + map.addChild(new MMapDefaultSchemeLayer({})); + map.addChild(new MMapDefaultFeaturesLayer({})); + + const updatePositions = (position: MMapPopupPositionProps) => { + textPopup.update({position}); + customPopup.update({position}); + }; + + map.addChild( + new MMapControls({position: 'top right'}, [ + new MMapControlButton({text: 'Left', onClick: () => updatePositions('left')}), + new MMapControlButton({text: 'Left Top', onClick: () => updatePositions('left top')}), + new MMapControlButton({text: 'Left Bottom', onClick: () => updatePositions('left bottom')}), + new MMapControlButton({text: 'Bottom', onClick: () => updatePositions('bottom')}), + new MMapControlButton({text: 'Top', onClick: () => updatePositions('top')}), + new MMapControlButton({text: 'Right Top', onClick: () => updatePositions('right top')}), + new MMapControlButton({text: 'Right Bottom', onClick: () => updatePositions('right bottom')}), + new MMapControlButton({text: 'Right', onClick: () => updatePositions('right')}) + ]) + ); + + const textPopup = new MMapPopupMarker({coordinates: TEXT_POPUP_COORDS, draggable: true, content: POPUP_TEXT}); + map.addChild(textPopup); + + const customPopup = new MMapPopupMarker({ + coordinates: CUSTOM_POPUP_COORDS, + draggable: true, + content: createDefaultPopup + }); + map.addChild(customPopup); + + function createDefaultPopup(): HTMLElement { + const popupRootElement = document.createElement('span'); + popupRootElement.classList.add('popup'); + + const popupHeaderElement = document.createElement('span'); + popupHeaderElement.classList.add('header'); + popupRootElement.appendChild(popupHeaderElement); + + const titleElement = document.createElement('span'); + titleElement.classList.add('header_title'); + titleElement.textContent = TITLE; + popupHeaderElement.appendChild(titleElement); + + const closeButton = document.createElement('button'); + closeButton.classList.add('header_close'); + closeButton.addEventListener('click', () => { + customPopup.update({show: false}); + }); + popupHeaderElement.appendChild(closeButton); + + const descriptionElement = document.createElement('span'); + descriptionElement.classList.add('description'); + descriptionElement.textContent = DESCRIPTION; + popupRootElement.appendChild(descriptionElement); + + const actionButton = document.createElement('button'); + actionButton.classList.add('action'); + actionButton.textContent = ACTION; + actionButton.addEventListener('click', () => { + alert('Click on action button!'); + }); + popupRootElement.appendChild(actionButton); + + return popupRootElement; + } +} diff --git a/example/popups-on-map/vue/index.html b/example/popups-on-map/vue/index.html new file mode 100644 index 0000000..85eeebd --- /dev/null +++ b/example/popups-on-map/vue/index.html @@ -0,0 +1,36 @@ + + + + Vue example mappable-default-ui-theme + + + + + + + + + + + + + + +
+ + diff --git a/example/popups-on-map/vue/index.ts b/example/popups-on-map/vue/index.ts new file mode 100644 index 0000000..8ae13bf --- /dev/null +++ b/example/popups-on-map/vue/index.ts @@ -0,0 +1,105 @@ +import type {MMapPopupPositionProps} from '../../src'; +import {ACTION, CUSTOM_POPUP_COORDS, DESCRIPTION, LOCATION, POPUP_TEXT, TEXT_POPUP_COORDS, TITLE} from '../common'; + +window.map = null; + +main(); +async function main() { + const [mappableVue] = await Promise.all([mappable.import('@mappable-world/mappable-vuefy'), mappable.ready]); + const vuefy = mappableVue.vuefy.bindTo(Vue); + + const {MMap, MMapDefaultSchemeLayer, MMapDefaultFeaturesLayer, MMapControls, MMapControlButton} = + vuefy.module(mappable); + + const {MMapPopupMarker} = vuefy.module(await mappable.import('@mappable-world/mappable-default-ui-theme')); + + const app = Vue.createApp({ + components: { + MMap, + MMapDefaultSchemeLayer, + MMapDefaultFeaturesLayer, + MMapControls, + MMapControlButton, + MMapPopupMarker + }, + setup() { + const refMap = (ref: any) => { + window.map = ref?.entity; + }; + const position = Vue.ref(undefined); + const showCustom = Vue.ref(true); + + const positionLeft = () => (position.value = 'left'); + const positionLeftTop = () => (position.value = 'left top'); + const positionLeftBottom = () => (position.value = 'left bottom'); + const positionBottom = () => (position.value = 'bottom'); + const positionTop = () => (position.value = 'top'); + const positionRightTop = () => (position.value = 'right top'); + const positionRightBottom = () => (position.value = 'right bottom'); + const positionRight = () => (position.value = 'right'); + + const customPopupAction = () => { + alert('Click on action button!'); + }; + + return { + ACTION, + CUSTOM_POPUP_COORDS, + DESCRIPTION, + LOCATION, + POPUP_TEXT, + TEXT_POPUP_COORDS, + TITLE, + position, + showCustom, + refMap, + positionLeft, + positionLeftTop, + positionLeftBottom, + positionBottom, + positionTop, + positionRightTop, + positionRightBottom, + positionRight, + customPopupAction + }; + }, + template: ` + + + + + + + + + + + + + + + + + + + + + + ` + }); + app.mount('#app'); +} diff --git a/jest.config.js b/jest.config.js index bbb284a..34b90c9 100644 --- a/jest.config.js +++ b/jest.config.js @@ -1,3 +1,6 @@ module.exports = { - ...require('@mappable-world/mappable-cli/jest.config') -} + ...require('@mappable-world/mappable-cli/jest.config'), + transform: { + '^.+\\.svg$': '/tests/utils/svgTransform.js' + } +}; diff --git a/src/markers/MMapDefaultMarker/index.ts b/src/markers/MMapDefaultMarker/index.ts index fcad78e..83237a6 100644 --- a/src/markers/MMapDefaultMarker/index.ts +++ b/src/markers/MMapDefaultMarker/index.ts @@ -1,6 +1,8 @@ import {MMapMarker, MMapMarkerProps} from '@mappable-world/mappable-types'; import {IconColor, IconName, iconColors, icons} from '../../icons'; -import {MMapDefaultMarkerVuefyOptions} from './vue'; +import {MMapPopupContentProps, MMapPopupMarker} from '../MMapPopupMarker'; +import {MMapDefaultMarkerReactifyOverride} from './react'; +import {MMapDefaultMarkerVuefyOptions, MMapDefaultMarkerVuefyOverride} from './vue'; import microPoiStrokeSVG from './backgrounds/micro-poi-stroke.svg'; import microPoiSVG from './backgrounds/micro-poi.svg'; @@ -30,9 +32,15 @@ const HINT_SUBTITLE_CLASS = 'mappable--hint-subtitle'; const HINT_STABLE = 'mappable--hint__stable'; const HINT_HOVERED = 'mappable--hint__hovered'; +const DISTANCE_BETWEEN_POPUP_AND_MARKER = 8; + export type ThemesColor = {day: string; night: string}; export type MarkerColorProps = IconColor | ThemesColor; export type MarkerSizeProps = 'normal' | 'small' | 'micro'; +export type MarkerPopupProps = { + /** The function of creating popup content */ + content: MMapPopupContentProps; +}; export type MMapDefaultMarkerProps = MMapMarkerProps & { iconName?: IconName; @@ -41,6 +49,7 @@ export type MMapDefaultMarkerProps = MMapMarkerProps & { title?: string; subtitle?: string; staticHint?: boolean; + popup?: MarkerPopupProps; }; const defaultProps = Object.freeze({color: 'darkgray', size: 'small', staticHint: true}); @@ -50,6 +59,8 @@ type BackgroundAndIcon = {background: HTMLElement; stroke: HTMLElement; icon: HT export class MMapDefaultMarker extends mappable.MMapComplexEntity { static defaultProps = defaultProps; + static [mappable.overrideKeyReactify] = MMapDefaultMarkerReactifyOverride; + static [mappable.overrideKeyVuefy] = MMapDefaultMarkerVuefyOverride; static [mappable.optionsKeyVuefy] = MMapDefaultMarkerVuefyOptions; private _marker: MMapMarker; @@ -64,6 +75,8 @@ export class MMapDefaultMarker extends mappable.MMapComplexEntity this._updateTheme(), { immediate: true }); } - protected _onUpdate(propsDiff: Partial): void { + protected _onUpdate(propsDiff: Partial, oldProps: MMapDefaultMarkerProps): void { const {title, subtitle} = this._props; if (propsDiff.color !== undefined) { this._color = this._getColor(); this._updateTheme(); } + + // popup props is changed + if (this._props.popup !== oldProps.popup) { + if (this._props.popup === undefined && oldProps.popup !== undefined) { + this.removeChild(this._popup); + this._popup = undefined; + } else if (this._props.popup !== undefined && oldProps.popup === undefined) { + this._popup = this._createPopupMarker(); + this.addChild(this._popup); + } else { + this._popup.update(this._props.popup); + } + } + if (propsDiff.size !== undefined) { this._updateMarkerSize(); this._updateSVG(); + if (this._popup) { + this._popup.update({offset: this._getPopupOffset()}); + } } this._titleHint.textContent = title ?? ''; @@ -148,7 +189,17 @@ export class MMapDefaultMarker extends mappable.MMapComplexEntity { + if (!this._popup) { + return; + } + this._popup.update({show: !this._popup.isOpen}); + this._props.onClick?.(event); + }; + private _updateTheme() { const themeCtx = this._consumeContext(mappable.ThemeContext); const theme = themeCtx.theme; @@ -222,6 +281,23 @@ export class MMapDefaultMarker extends mappable.MMapComplexEntity TReact.ReactElement); + }; + } + > +>; + +type MMapDefaultMarkerR = TReact.ForwardRefExoticComponent< + Prettify>> +>; + +export const MMapDefaultMarkerReactifyOverride: CustomReactify = ( + MMapDefaultMarkerI, + {reactify, React, ReactDOM} +) => { + const MMapDefaultMarkerReactified = reactify.entity(MMapDefaultMarkerI); + + const MMapDefaultMarker = React.forwardRef((props, ref) => { + const [popupElement] = React.useState(document.createElement('mappable')); + const [content, setContent] = React.useState(); + + const popupContent = React.useMemo(() => { + if (props.popup === undefined) { + return undefined; + } + + if (typeof props.popup.content === 'string') { + setContent(<>{props.popup.content}); + } else if (typeof props.popup.content === 'function') { + setContent(props.popup.content()); + } + + return {content: () => popupElement}; + }, [props.popup.content, popupElement]); + + return ( + <> + + {ReactDOM.createPortal(content, popupElement)} + + ); + }); + return MMapDefaultMarker; +}; diff --git a/src/markers/MMapDefaultMarker/vue/index.ts b/src/markers/MMapDefaultMarker/vue/index.ts index c72bcdf..9a7b547 100644 --- a/src/markers/MMapDefaultMarker/vue/index.ts +++ b/src/markers/MMapDefaultMarker/vue/index.ts @@ -1,7 +1,7 @@ -import {CustomVuefyOptions} from '@mappable-world/mappable-types/modules/vuefy'; -import type TVue from '@vue/runtime-core'; -import {MMapDefaultMarker, MarkerColorProps, MarkerSizeProps} from '../'; import {MMapFeatureProps, MMapMarkerEventHandler} from '@mappable-world/mappable-types'; +import {CustomVuefyFn, CustomVuefyOptions} from '@mappable-world/mappable-types/modules/vuefy'; +import type TVue from '@vue/runtime-core'; +import {MMapDefaultMarker, MMapDefaultMarkerProps, MarkerColorProps, MarkerPopupProps, MarkerSizeProps} from '../'; import {IconName} from '../../../icons'; export const MMapDefaultMarkerVuefyOptions: CustomVuefyOptions = { @@ -24,10 +24,56 @@ export const MMapDefaultMarkerVuefyOptions: CustomVuefyOptions, onFastClick: Function as TVue.PropType, iconName: {type: String as TVue.PropType}, - color: {type: Object as TVue.PropType, default: 'darkgray'}, + color: {type: [Object, String] as TVue.PropType, default: 'darkgray'}, size: {type: String as TVue.PropType, default: 'small'}, title: {type: String}, subtitle: {type: String}, - staticHint: {type: Boolean, default: true} + staticHint: {type: Boolean, default: true}, + popup: {type: Object as TVue.PropType} } }; + +type MMapDefaultMarkerSlots = { + popupContent: void; +}; + +export const MMapDefaultMarkerVuefyOverride: CustomVuefyFn = ( + MMapDefaultMarkerI, + props, + {vuefy, Vue} +) => { + const MMapDefaultMarkerV = vuefy.entity(MMapDefaultMarkerI); + const {popup, ...overridedProps} = props; + + return Vue.defineComponent({ + name: 'MMapDefaultMarker', + props: overridedProps, + slots: Object as TVue.SlotsType, + setup(props, {slots, expose}) { + const content: TVue.Ref = Vue.ref(null); + const popupHTMLElement = document.createElement('mappable'); + + const markerRef = Vue.ref<{entity: MMapDefaultMarker} | null>(null); + const markerEntity = Vue.computed(() => markerRef.value?.entity); + + const popup = Vue.computed(() => { + if (slots.popupContent === undefined) { + return undefined; + } + content.value = slots.popupContent(); + return {content: () => popupHTMLElement}; + }); + expose({entity: markerEntity}); + return () => + Vue.h( + MMapDefaultMarkerV, + { + ...props, + popup: popup.value, + ref: markerRef + }, + () => Vue.h(Vue.Teleport, {to: popupHTMLElement}, [content.value]) + ); + } + }); +}; diff --git a/src/markers/MMapPopupMarker/MMapPopupMarker.test.ts b/src/markers/MMapPopupMarker/MMapPopupMarker.test.ts new file mode 100644 index 0000000..3dce9e0 --- /dev/null +++ b/src/markers/MMapPopupMarker/MMapPopupMarker.test.ts @@ -0,0 +1,175 @@ +import {MMap} from '@mappable-world/mappable-types'; +import {createContainer, CENTER} from '../../../tests/common'; +import {MMapPopupMarker} from './'; + +describe('MMapPopupMarker', () => { + let map: MMap; + let container: HTMLElement; + + beforeEach(() => { + container = createContainer(); + document.body.append(container); + map = new mappable.MMap(container, {location: {center: CENTER, zoom: 0}}); + map.addChild(new mappable.MMapDefaultFeaturesLayer({})); + }); + + afterEach(() => { + map.destroy(); + }); + + it('add on map', () => { + const popup = new MMapPopupMarker({coordinates: CENTER, content: createPopupContent}); + map.addChild(popup); + + expect(document.querySelector('.mappable--popup-marker')).not.toBeNull(); + expect(document.querySelector('.mappable--popup-marker .test-popup')).not.toBeNull(); + }); + it('add on map with text', () => { + const popup = new MMapPopupMarker({coordinates: CENTER, content: 'test popup'}); + map.addChild(popup); + + expect(document.querySelector('.mappable--popup-marker')).not.toBeNull(); + expect(document.querySelector('.mappable--popup-marker .mappable--popup-marker_container').textContent).toBe( + 'test popup' + ); + }); + + it('changing show props', () => { + const popup = new MMapPopupMarker({show: true, coordinates: CENTER, content: createPopupContent}); + map.addChild(popup); + + const popupMarkerElement = document.querySelector('.mappable--popup-marker'); + expect(popupMarkerElement).not.toBeNull(); + + expect(popup.isOpen).toBe(true); + expect(document.querySelector('.mappable--popup-marker.mappable--popup-marker__hide')).toBeNull(); + + popup.update({show: false}); + expect(popup.isOpen).toBe(false); + expect(document.querySelector('.mappable--popup-marker.mappable--popup-marker__hide')).not.toBeNull(); + + popup.update({show: true}); + expect(popup.isOpen).toBe(true); + expect(document.querySelector('.mappable--popup-marker.mappable--popup-marker__hide')).toBeNull(); + }); + + it('offset props', () => { + const popup = new MMapPopupMarker({offset: 12, coordinates: CENTER, content: createPopupContent}); + map.addChild(popup); + + const popupMarkerElement = document.querySelector('.mappable--popup-marker'); + expect(popupMarkerElement.style.getPropertyValue('--mappable-default-offset')).toBe('12px'); + + popup.update({offset: 24}); + expect(popupMarkerElement.style.getPropertyValue('--mappable-default-offset')).toBe('24px'); + }); + + describe('callback for closing and opening', () => { + it('callback on open', (done) => { + const onOpen = () => { + expect(popup.isOpen).toBe(true); + done(); + }; + const popup = new MMapPopupMarker({ + show: true, + coordinates: CENTER, + content: createPopupContent, + onOpen + }); + map.addChild(popup); + }); + it('callback on close', (done) => { + const onClose = () => { + expect(popup.isOpen).toBe(false); + done(); + }; + const popup = new MMapPopupMarker({ + show: true, + coordinates: CENTER, + content: createPopupContent, + onClose + }); + map.addChild(popup); + popup.update({show: false}); + }); + }); + + describe('change popup position', () => { + it('initial default position', () => { + const popup = new MMapPopupMarker({coordinates: CENTER, content: createPopupContent}); + map.addChild(popup); + + expect( + document.querySelector('.mappable--popup-marker.mappable--popup-marker__position-top') + ).not.toBeNull(); + }); + it('initial position', () => { + const popup = new MMapPopupMarker({position: 'left', coordinates: CENTER, content: createPopupContent}); + map.addChild(popup); + + expect( + document.querySelector('.mappable--popup-marker.mappable--popup-marker__position-left') + ).not.toBeNull(); + }); + it('change position props', () => { + const popup = new MMapPopupMarker({coordinates: CENTER, content: createPopupContent}); + map.addChild(popup); + + popup.update({position: 'top'}); + expect( + document.querySelector('.mappable--popup-marker.mappable--popup-marker__position-top') + ).not.toBeNull(); + + popup.update({position: 'bottom'}); + expect( + document.querySelector('.mappable--popup-marker.mappable--popup-marker__position-bottom') + ).not.toBeNull(); + + popup.update({position: 'left'}); + expect( + document.querySelector('.mappable--popup-marker.mappable--popup-marker__position-left') + ).not.toBeNull(); + + popup.update({position: 'right'}); + expect( + document.querySelector('.mappable--popup-marker.mappable--popup-marker__position-right') + ).not.toBeNull(); + }); + it('change combined position props', () => { + const popup = new MMapPopupMarker({coordinates: CENTER, content: createPopupContent}); + map.addChild(popup); + + popup.update({position: 'top left'}); + expect( + document.querySelector( + '.mappable--popup-marker.mappable--popup-marker__position-top.mappable--popup-marker__position-left' + ) + ).not.toBeNull(); + popup.update({position: 'top right'}); + expect( + document.querySelector( + '.mappable--popup-marker.mappable--popup-marker__position-top.mappable--popup-marker__position-right' + ) + ).not.toBeNull(); + + popup.update({position: 'bottom left'}); + expect( + document.querySelector( + '.mappable--popup-marker.mappable--popup-marker__position-bottom.mappable--popup-marker__position-left' + ) + ).not.toBeNull(); + popup.update({position: 'bottom right'}); + expect( + document.querySelector( + '.mappable--popup-marker.mappable--popup-marker__position-bottom.mappable--popup-marker__position-right' + ) + ).not.toBeNull(); + }); + }); +}); + +const createPopupContent = () => { + const popup = document.createElement('div'); + popup.classList.add('test-popup'); + return popup; +}; diff --git a/src/markers/MMapPopupMarker/index.css b/src/markers/MMapPopupMarker/index.css new file mode 100644 index 0000000..a1658a3 --- /dev/null +++ b/src/markers/MMapPopupMarker/index.css @@ -0,0 +1,200 @@ +@keyframes mappable--popup-marker-show-top { + from { + transform: translateY(12px); + opacity: 0; + } + to { + transform: none; + opacity: 1; + } +} + +@keyframes mappable--popup-marker-show-bottom { + from { + transform: translateY(-12px); + opacity: 0; + } + to { + transform: none; + opacity: 1; + } +} + +@keyframes mappable--popup-marker-show-left { + from { + transform: translateX(12px); + opacity: 0; + } + to { + transform: none; + opacity: 1; + } +} + +@keyframes mappable--popup-marker-show-right { + from { + transform: translateX(-12px); + opacity: 0; + } + to { + transform: none; + opacity: 1; + } +} + +.mappable--popup-marker { + --mappable-default-tail-height: 12px; + --mappable-default-tail-width: 16px; + --mappable-default-border-radius: 12px; + + --mappable-default-tail-height-and-offset: calc( + var(--mappable-default-tail-height) + var(--mappable-default-offset) + ); + + --mappable-default-popup-tail-transform-top: translate(-50%, calc(-100% - var(--mappable-default-offset))) + rotate(180deg); + --mappable-default-popup-tail-transform-bottom: translate(-50%, var(--mappable-default-offset)); + + position: absolute; + + &.mappable--popup-marker__hide { + display: none; + } +} + +.mappable--popup-marker svg { + display: block; +} + +.mappable--popup-marker_container { + width: max-content; + max-width: 500px; + max-height: 600px; + display: block; + position: absolute; + padding: 8px 12px; + border-radius: var(--mappable-default-border-radius); + background-color: #fff; + color: #34374a; + font-size: 14px; + font-style: normal; + font-weight: 400; + line-height: 20px; + box-shadow: + 0px 4px 12px 0px #5f69831a, + 0px 4px 24px 0px #5f69830a; + overflow: hidden; + text-overflow: ellipsis; + + &.mappable--popup-marker__dark { + background-color: #272729; + color: #c8c9cc; + } +} + +.mappable--popup-marker_tail { + display: block; + position: absolute; + color: #fff; + + &.mappable--popup-marker__dark { + color: #272729; + } + + svg { + filter: drop-shadow(0px 4px 24px rgba(95, 105, 131, 0.04)) drop-shadow(0px 4px 12px rgba(95, 105, 131, 0.1)); + } +} + +/* positions */ +.mappable--popup-marker__position-top { + animation: mappable--popup-marker-show-top 200ms ease-out !important; + + .mappable--popup-marker_container { + transform: translate(-50%, calc(-100% - var(--mappable-default-tail-height-and-offset))); + } + .mappable--popup-marker_tail { + transform: var(--mappable-default-popup-tail-transform-top); + } + /* top left */ + &.mappable--popup-marker__position-left { + .mappable--popup-marker_container { + transform: translate( + calc(-100% + var(--mappable-default-border-radius) + var(--mappable-default-tail-width) / 2), + calc(-100% - var(--mappable-default-tail-height-and-offset)) + ); + } + .mappable--popup-marker_tail { + transform: var(--mappable-default-popup-tail-transform-top); + } + } + /* top right */ + &.mappable--popup-marker__position-right { + .mappable--popup-marker_container { + transform: translate( + calc(-1 * var(--mappable-default-border-radius) - var(--mappable-default-tail-width) / 2), + calc(-100% - var(--mappable-default-tail-height-and-offset)) + ); + } + .mappable--popup-marker_tail { + transform: var(--mappable-default-popup-tail-transform-top); + } + } +} + +.mappable--popup-marker__position-bottom { + animation: mappable--popup-marker-show-bottom 200ms ease-out !important; + + .mappable--popup-marker_container { + transform: translate(-50%, var(--mappable-default-tail-height-and-offset)); + } + .mappable--popup-marker_tail { + transform: var(--mappable-default-popup-tail-transform-bottom); + } + /* bottom left */ + &.mappable--popup-marker__position-left { + .mappable--popup-marker_container { + transform: translate( + calc(-100% + var(--mappable-default-border-radius) + var(--mappable-default-tail-width) / 2), + var(--mappable-default-tail-height-and-offset) + ); + } + .mappable--popup-marker_tail { + transform: var(--mappable-default-popup-tail-transform-bottom); + } + } + /* bottom right */ + &.mappable--popup-marker__position-right { + .mappable--popup-marker_container { + transform: translate( + calc(-1 * var(--mappable-default-border-radius) - var(--mappable-default-tail-width) / 2), + var(--mappable-default-tail-height-and-offset) + ); + } + .mappable--popup-marker_tail { + transform: var(--mappable-default-popup-tail-transform-bottom); + } + } +} + +.mappable--popup-marker__position-left { + animation: mappable--popup-marker-show-left 200ms ease-out; + + .mappable--popup-marker_container { + transform: translate(calc(-100% - var(--mappable-default-tail-height-and-offset)), -50%); + } + .mappable--popup-marker_tail { + transform: translate(calc(-100% - var(--mappable-default-offset)), -50%) rotate(90deg); + } +} + +.mappable--popup-marker__position-right { + animation: mappable--popup-marker-show-right 200ms ease-out; + + .mappable--popup-marker_container { + transform: translate(var(--mappable-default-tail-height-and-offset), -50%); + } + .mappable--popup-marker_tail { + transform: translate(var(--mappable-default-offset), -50%) rotate(-90deg); + } +} diff --git a/src/markers/MMapPopupMarker/index.ts b/src/markers/MMapPopupMarker/index.ts new file mode 100644 index 0000000..7fee34d --- /dev/null +++ b/src/markers/MMapPopupMarker/index.ts @@ -0,0 +1,187 @@ +import {MMapMarker, MMapMarkerProps} from '@mappable-world/mappable-types'; +import {MMapPopupMarkerReactifyOverride} from './react'; +import {MMapPopupMarkerVuefyOptions, MMapPopupMarkerVuefyOverride} from './vue'; + +import './index.css'; +import tailSVG from './tail.svg'; + +type VerticalPosition = 'top' | 'bottom'; +type HorizontalPosition = 'left' | 'right'; +export type MMapPopupPositionProps = + | VerticalPosition + | HorizontalPosition + | `${VerticalPosition} ${HorizontalPosition}` + | `${HorizontalPosition} ${VerticalPosition}`; + +export type MMapPopupContentProps = string | (() => HTMLElement); + +export type MMapPopupMarkerProps = MMapMarkerProps & { + /** The function of creating popup content */ + content: MMapPopupContentProps; + /** The position of the popup in relation to the point it is pointing to */ + position?: MMapPopupPositionProps; + /** The offset in pixels between the popup pointer and the point it is pointing to. */ + offset?: number; + /** Hide or show popup on map */ + show?: boolean; + /** Popup closing callback */ + onClose?: () => void; + /** Popup opening callback */ + onOpen?: () => void; +}; + +const defaultProps = Object.freeze({position: 'top', offset: 0, show: true}); +type DefaultProps = typeof defaultProps; + +/** + * `MMapPopupMarker` is a popup with customized content. + * @example + * ```js + * const popup = new MMapPopupMarker({ + * content: () => createPopupContentHTMLElement(), + * position: 'top', + * onOpen:() => console.log('open'), + * onClose:() => console.log('close'), + * // support MMapMarker props + * coordinates: POPUP_COORD, + * draggable: true, + * }); + * map.addChild(popup); + * ``` + */ +export class MMapPopupMarker extends mappable.MMapComplexEntity { + static defaultProps = defaultProps; + static [mappable.overrideKeyReactify] = MMapPopupMarkerReactifyOverride; + static [mappable.overrideKeyVuefy] = MMapPopupMarkerVuefyOverride; + static [mappable.optionsKeyVuefy] = MMapPopupMarkerVuefyOptions; + + public get isOpen() { + return this._props.show; + } + private _markerElement: HTMLElement; + private _popupContainer: HTMLElement; + private _popupTail: HTMLElement; + private _marker: MMapMarker; + + private _togglePopup(forceShowPopup?: boolean): void { + const openPopup = forceShowPopup ?? !this._props.show; + + this._markerElement.classList.toggle('mappable--popup-marker__hide', !openPopup); + + if (openPopup) { + this._props.onOpen?.(); + } else { + this._props.onClose?.(); + } + + this._props.show = openPopup; + } + + protected _onAttach(): void { + this._markerElement = document.createElement('mappable'); + this._markerElement.classList.add('mappable--popup-marker'); + + this._popupContainer = document.createElement('mappable'); + this._popupContainer.classList.add('mappable--popup-marker_container'); + + if (typeof this._props.content === 'string') { + this._popupContainer.textContent = this._props.content; + } else { + this._popupContainer.appendChild(this._props.content()); + } + + this._popupTail = document.createElement('mappable'); + this._popupTail.classList.add('mappable--popup-marker_tail'); + this._popupTail.innerHTML = tailSVG; + + this._togglePopup(this._props.show); + this._updatePosition(); + this._updateOffset(); + + this._markerElement.appendChild(this._popupContainer); + this._markerElement.appendChild(this._popupTail); + + this._marker = new mappable.MMapMarker(this._props, this._markerElement); + this.addChild(this._marker); + + this._watchContext(mappable.ThemeContext, () => this._updateTheme(), { + immediate: true + }); + } + + protected _onUpdate(propsDiff: Partial): void { + if (propsDiff.position !== undefined) { + this._updatePosition(); + } + if (propsDiff.offset !== undefined) { + this._updateOffset(); + } + + if (propsDiff.content !== undefined) { + this._popupContainer.innerHTML = ''; + + if (typeof this._props.content === 'string') { + this._popupContainer.textContent = this._props.content; + } else { + this._popupContainer.appendChild(this._props.content()); + } + } + + if (propsDiff.show !== undefined) { + this._togglePopup(propsDiff.show); + } + + this._marker.update(this._props); + } + + private _updateTheme() { + const themeCtx = this._consumeContext(mappable.ThemeContext); + const {theme} = themeCtx; + this._popupContainer.classList.toggle('mappable--popup-marker__dark', theme === 'dark'); + this._popupTail.classList.toggle('mappable--popup-marker__dark', theme === 'dark'); + } + + private _updateOffset(): void { + this._markerElement.style.setProperty('--mappable-default-offset', `${this._props.offset}px`); + } + + private _updatePosition(): void { + const {position} = this._props; + let verticalPosition: VerticalPosition; + let horizontalPosition: HorizontalPosition; + + const positionTypeHash: Record = { + top: 'vertical', + left: 'horizontal', + bottom: 'vertical', + right: 'horizontal' + }; + + if (position === 'top' || position === 'bottom') { + verticalPosition = position; + } else if (position === 'left' || position === 'right') { + horizontalPosition = position; + } else { + const [first, second] = position.split(' ') as (HorizontalPosition | VerticalPosition)[]; + if (positionTypeHash[first] === 'vertical' && positionTypeHash[second] === 'horizontal') { + verticalPosition = first as VerticalPosition; + horizontalPosition = second as HorizontalPosition; + } else if (positionTypeHash[first] === 'horizontal' && positionTypeHash[second] === 'vertical') { + verticalPosition = second as VerticalPosition; + horizontalPosition = first as HorizontalPosition; + } + } + + // check top position + this._markerElement.classList.toggle('mappable--popup-marker__position-top', verticalPosition === 'top'); + + // check bottom position + this._markerElement.classList.toggle('mappable--popup-marker__position-bottom', verticalPosition === 'bottom'); + + // check left position + this._markerElement.classList.toggle('mappable--popup-marker__position-left', horizontalPosition === 'left'); + + // check right position + this._markerElement.classList.toggle('mappable--popup-marker__position-right', horizontalPosition === 'right'); + } +} diff --git a/src/markers/MMapPopupMarker/react/index.tsx b/src/markers/MMapPopupMarker/react/index.tsx new file mode 100644 index 0000000..909781e --- /dev/null +++ b/src/markers/MMapPopupMarker/react/index.tsx @@ -0,0 +1,47 @@ +import {MMapEntity} from '@mappable-world/mappable-types'; +import {CustomReactify, OverrideProps, Prettify} from '@mappable-world/mappable-types/reactify/reactify'; +import type TReact from 'react'; +import {MMapPopupMarker as MMapPopupMarkerI, MMapPopupMarkerProps} from '../'; + +type MMapPopupMarkerReactifiedProps = Prettify< + OverrideProps< + MMapPopupMarkerProps, + { + /** The function of creating popup content */ + content: string | (() => TReact.ReactElement); + } + > +>; + +type MMapPopupMarkerR = TReact.ForwardRefExoticComponent< + Prettify>> +>; + +export const MMapPopupMarkerReactifyOverride: CustomReactify = ( + MMapPopupMarkerI, + {reactify, React, ReactDOM} +) => { + const MMapPopupMarkerReactified = reactify.entity(MMapPopupMarkerI); + + const MMapPopupMarker = React.forwardRef((props, ref) => { + const [popupElement] = React.useState(document.createElement('mappable')); + const [content, setContent] = React.useState(); + + const popup = React.useMemo(() => { + if (typeof props.content === 'string') { + setContent(<>{props.content}); + } else { + setContent(props.content()); + } + return () => popupElement; + }, [props.content, popupElement]); + + return ( + <> + + {ReactDOM.createPortal(content, popupElement)} + + ); + }); + return MMapPopupMarker; +}; diff --git a/src/markers/MMapPopupMarker/tail.svg b/src/markers/MMapPopupMarker/tail.svg new file mode 100644 index 0000000..0ac3f80 --- /dev/null +++ b/src/markers/MMapPopupMarker/tail.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/markers/MMapPopupMarker/vue/index.ts b/src/markers/MMapPopupMarker/vue/index.ts new file mode 100644 index 0000000..2d26804 --- /dev/null +++ b/src/markers/MMapPopupMarker/vue/index.ts @@ -0,0 +1,69 @@ +import {MMapFeatureProps, MMapMarkerEventHandler} from '@mappable-world/mappable-types'; +import {CustomVuefyFn, CustomVuefyOptions} from '@mappable-world/mappable-types/modules/vuefy'; +import type TVue from '@vue/runtime-core'; +import {MMapPopupContentProps, MMapPopupMarker, MMapPopupMarkerProps, MMapPopupPositionProps} from '../'; + +export const MMapPopupMarkerVuefyOptions: CustomVuefyOptions = { + props: { + coordinates: {type: Object, required: true}, + source: String, + zIndex: {type: Number, default: 0}, + properties: Object, + id: String, + disableRoundCoordinates: {type: Boolean, default: undefined}, + hideOutsideViewport: {type: [Object, Boolean], default: false}, + draggable: {type: Boolean, default: false}, + mapFollowsOnDrag: {type: [Boolean, Object]}, + onDragStart: Function as TVue.PropType, + onDragEnd: Function as TVue.PropType, + onDragMove: Function as TVue.PropType, + blockEvents: {type: Boolean, default: undefined}, + blockBehaviors: {type: Boolean, default: undefined}, + onDoubleClick: Function as TVue.PropType, + onClick: Function as TVue.PropType, + onFastClick: Function as TVue.PropType, + content: {type: [Function, String] as TVue.PropType, required: true}, + position: {type: String as TVue.PropType}, + offset: {type: Number, default: 0}, + show: {type: Boolean, default: true}, + onClose: {type: Function as TVue.PropType}, + onOpen: {type: Function as TVue.PropType} + } +}; + +type MMapPopupMarkerSlots = { + content: void; +}; + +export const MMapPopupMarkerVuefyOverride: CustomVuefyFn = (MMapPopupMarkerI, props, {vuefy, Vue}) => { + const MMapPopupMarkerV = vuefy.entity(MMapPopupMarkerI); + const {content, ...overridedProps} = props; + return Vue.defineComponent({ + name: 'MMapPopupMarker', + props: overridedProps, + slots: Object as TVue.SlotsType, + setup(props, {slots, expose}) { + const content: TVue.Ref = Vue.ref(null); + const popupHTMLElement = document.createElement('mappable'); + + const markerRef = Vue.ref<{entity: MMapPopupMarker} | null>(null); + const markerEntity = Vue.computed(() => markerRef.value?.entity); + + const popup = Vue.computed(() => { + content.value = slots.content?.(); + return () => popupHTMLElement; + }); + expose({entity: markerEntity}); + return () => + Vue.h( + MMapPopupMarkerV, + { + ...props, + content: popup.value, + ref: markerRef + }, + () => Vue.h(Vue.Teleport, {to: popupHTMLElement}, [content.value]) + ); + } + }); +}; diff --git a/src/markers/index.ts b/src/markers/index.ts index 8ad99ac..2d182cb 100644 --- a/src/markers/index.ts +++ b/src/markers/index.ts @@ -1 +1,9 @@ -export * from './MMapDefaultMarker'; +export { + MMapDefaultMarker, + MMapDefaultMarkerProps, + MarkerColorProps, + MarkerPopupProps, + MarkerSizeProps, + ThemesColor +} from './MMapDefaultMarker'; +export {MMapPopupMarker, MMapPopupMarkerProps, MMapPopupPositionProps, MMapPopupContentProps} from './MMapPopupMarker'; diff --git a/tests/common.ts b/tests/common.ts new file mode 100644 index 0000000..0502476 --- /dev/null +++ b/tests/common.ts @@ -0,0 +1,10 @@ +import {LngLat} from '@mappable-world/mappable-types'; + +export const CENTER: LngLat = [0, 0]; + +export const createContainer = () => { + const container = document.createElement('div'); + container.style.width = '640px'; + container.style.height = '480px'; + return container; +}; diff --git a/tests/utils/svgTransform.js b/tests/utils/svgTransform.js new file mode 100644 index 0000000..781ca5d --- /dev/null +++ b/tests/utils/svgTransform.js @@ -0,0 +1,11 @@ +module.exports = { + process() { + return { + code: `module.exports = {};` + }; + }, + getCacheKey() { + // The output is always the same. + return 'svgTransform'; + } +};