Skip to content

useContextmenu

简介

提供右键菜单逻辑,支持多级菜单,ui需要自己实现,可参考使用示例。

使用示例

Overlay + 自定义UI

点我查看代码
vue
<script lang="ts" setup>
import type { ContextmenuList, ContextmenuOptions } from '@summeruse/ol'
import type { Feature } from 'ol'
import type { Positioning } from 'ol/Overlay'
import { createPointFeature, createPolygonFeature, createVectorLayer, getOSMLayer, OlMap, useContextmenu } from '@summeruse/ol'
import { Map as OLMap, Overlay } from 'ol'
import { h, ref, render, watch } from 'vue'

const olMap = new OLMap()
const { source, layer } = createVectorLayer()

const feature = createPointFeature([0, 0], {
  type: 'point',
  data: {
    name: `点${new Date().getTime()}`,
  },
})
source.addFeature(feature)

const feature2 = createPolygonFeature([
  [
    [1000000, 1000000],
    [1000000, 5000000],
    [5000000, 5000000],
    [5000000, 1000000],
  ],
], {
  type: 'polygon',
  data: {
    name: '多边形',
  },
})
source.addFeature(feature2)
olMap.addLayer(getOSMLayer())
olMap.addLayer(layer)
const items = ref<ContextmenuList>([
  {
    label: '添加点',
    key: 'add-point',
    visible: ({ feature }) => !feature,
    action: ({ coordinate }) => {
      const feature = createPointFeature(coordinate, {
        type: 'point',
        data: {
          name: `点${new Date().getTime()}`,
        },
      })
      source.addFeature(feature)
    },
  },
  {
    label: ({ feature }) => `删除${feature?.get('data')?.name || '点'}`,
    key: 'delete-point',
    visible: ({ feature }) => feature?.get('type') === 'point',
    action: ({ feature }) => {
      feature && source.removeFeature(feature as Feature)
    },
  },
  {
    label: '多边形区域',
    key: 'delete-polygon',
    visible: ({ feature }) => feature?.get('type') === 'polygon',
    children: [
      {
        label: '删除多边形',
        key: 'delete-polygon',
        visible: ({ feature }) => feature?.get('type') === 'polygon',
        action: ({ feature }) => {
          feature && source.removeFeature(feature as Feature)
        },
      },
    ],
  },
  {
    label: '清空点',
    divided: true,
    key: 'clear-point',
    action: () => {
      source.forEachFeature((feature) => {
        if (feature.get('type') === 'point') {
          source.removeFeature(feature)
        }
      })
    },
  },
])

const { visible, options, coordinate, position } = useContextmenu(olMap, items)

const container = document.createElement('div')
container.classList.add('contextmenu-container')
const content = document.createElement('div')
content.style.width = '200px'
content.style.height = '200px'
container.style.display = 'none'
container.appendChild(content)
const overlay = new Overlay({
  element: container,
  // stopEvent: true,
  positioning: 'top-left',
  position: [0, 0],
})
olMap.addOverlay(overlay)

function createContextmenu(container: HTMLElement, options: ContextmenuOptions) {
  options.forEach((option) => {
    if (option.divided) {
      const divider = document.createElement('div')
      divider.classList.add('contextmenu-divider')
      container.appendChild(divider)
    }
    const item = document.createElement('div')
    item.classList.add('contextmenu-item')
    if (option.disabled) {
      item.classList.add('contextmenu-item-disabled')
    }
    const label = option.label
    if (typeof label === 'function') {
      const node = h(label, option.props)
      render(node, item)
    }
    else {
      item.innerHTML = label
    }

    container.appendChild(item)
    if (option.children) {
      const subContainer = document.createElement('div')
      subContainer.classList.add('contextmenu-container')
      item.appendChild(subContainer)
      createContextmenu(subContainer, option.children)
    }
    else {
      item.onclick = option.action
    }
  })
}

watch([visible, coordinate], ([visible, coordinate]) => {
  if (visible && coordinate) {
    container.innerHTML = ''
    container.style.display = 'block'
    container.style.visibility = 'hidden'
    createContextmenu(container, options.value)

    const mapEl = olMap.getTargetElement() || olMap.getViewport()
    const mapRect = mapEl.getBoundingClientRect()
    const menuRect = container.getBoundingClientRect()
    let positioning: Positioning = 'top-left'
    const rightOverflow = position.value.x + menuRect.width > mapRect.right
    const bottomOverflow = position.value.y + menuRect.height > mapRect.bottom
    if (rightOverflow) {
      positioning = 'top-right'
    }
    if (bottomOverflow) {
      positioning = 'bottom-left'
    }
    if (rightOverflow && bottomOverflow) {
      positioning = 'bottom-right'
    }
    overlay.setPositioning(positioning)
    overlay.setPosition(coordinate)
    container.style.visibility = 'visible'
  }
  else {
    container.style.display = 'none'
  }
})
</script>

<template>
  <OlMap :ol-map class="w-100% h-400px" />
</template>

<style lang="scss">
  .contextmenu-container {
    background-color: #000;
    color: white;
    padding: 5px 0;
    border-radius: 5px;
    box-shadow: 0 0 10px rgba(0, 0, 0, 0.5);
    font-size: 14px;

    .contextmenu-divider {
      height: 1px;
      background-color: rgba(255, 255, 255, 0.5);
      margin: 2px 0;
    }

    .contextmenu-item {
      margin:0 5px;
      padding: 2px 10px;
      cursor: pointer;
      border-radius: 5px;
      position: relative;

      &:hover {
        background-color: rgba(255, 255, 255, 0.2);

        .contextmenu-container {
          display: flex;
          flex-direction: column;
          flex-wrap: nowrap;

          .contextmenu-item {
            width: max-content;
          }
        }
      }

      .contextmenu-container {
        display: none;
        position: absolute;
        right: 0;
        top: 0;
        transform: translate(100%, 0);
      }
    }
  }
</style>

结合NaiveUI

使用NaiveUI的 Dropdown 组件实现多级右键菜单。

点我查看代码
vue
<script lang="ts" setup>
import type { ContextmenuItem } from '@summeruse/ol'
import type { Feature } from 'ol'
import { createPointFeature, createPolygonFeature, createVectorLayer, getOSMLayer, OlMap } from '@summeruse/ol'
import { Map as OLMap } from 'ol'
import { ref } from 'vue'
import Contextmenu from './n-ol-contextmenu-demo.vue'

const olMap = new OLMap()
const { source, layer } = createVectorLayer()

const feature = createPointFeature([0, 0], {
  type: 'point',
  data: {
    name: `点${new Date().getTime()}`,
  },
})
source.addFeature(feature)

const feature2 = createPolygonFeature([
  [
    [1000000, 1000000],
    [1000000, 5000000],
    [5000000, 5000000],
    [5000000, 1000000],
  ],
], {
  type: 'polygon',
  data: {
    name: '多边形',
  },
})
source.addFeature(feature2)
olMap.addLayer(getOSMLayer())
olMap.addLayer(layer)
const items = ref<ContextmenuItem[]>([
  {
    label: () => '添加点',
    key: 'add-point',
    visible: ({ feature }) => !feature,
    action: ({ coordinate }) => {
      const feature = createPointFeature(coordinate, {
        type: 'point',
        data: {
          name: `点${new Date().getTime()}`,
        },
      })
      source.addFeature(feature)
    },
  },
  {
    label: ({ feature }) => `删除${feature?.get('data')?.name || '点'}`,
    key: 'delete-point',
    visible: ({ feature }) => feature?.get('type') === 'point',
    action: ({ feature }) => {
      feature && source.removeFeature(feature as Feature)
    },
  },
  {
    label: '多边形区域',
    key: 'delete-polygon',
    visible: ({ feature }) => feature?.get('type') === 'polygon',
    children: [
      {
        label: '删除1',
        key: 'delete-polygon1',
        action: ({ feature }) => {
          feature && source.removeFeature(feature as Feature)
        },
      },
      {
        label: '删除2',
        key: 'delete-polygon2',
        action: ({ feature }) => {
          feature && source.removeFeature(feature as Feature)
        },
      },
    ],
  },
  {
    divided: true,
    label: '清空点',
    key: 'clear-point',
    action: () => {
      source.forEachFeature((feature) => {
        if (feature.get('type') === 'point') {
          source.removeFeature(feature)
        }
      })
    },
  },
])
</script>

<template>
  <OlMap :ol-map class="w-100% h-400px">
    <Contextmenu :map="olMap" :items="items" />
  </OlMap>
</template>
vue
<script lang="ts" setup>
import type { ContextmenuItem, ContextmenuOption } from '@summeruse/ol'
import type { DropdownDividerOption, DropdownGroupOption, DropdownOption, DropdownRenderOption } from 'naive-ui'
import type { OLMap } from 'packages/ol/types'
import { useContextmenu } from '@summeruse/ol'
import { NDropdown } from 'naive-ui'
import { computed } from 'vue'

const props = defineProps<{
  map: OLMap
  items: ContextmenuItem[]
}>()

const { visible, options, position, hide } = useContextmenu(props.map, props.items)

  type DropdownOptions = Array<DropdownOption | DropdownGroupOption | DropdownDividerOption | DropdownRenderOption>

function formatOptions(option: ContextmenuOption[]): DropdownOptions {
  return option.reduce((prev, cur) => {
    if (cur.divided) {
      prev.push({
        type: 'divider',
      })
    }
    if (cur.children) {
      prev.push({
        label: cur.label,
        key: cur.key,
        children: formatOptions(cur.children),
        action: cur.action,
      })
    }
    else {
      prev.push({
        label: cur.label,
        key: cur.key,
        action: cur.action,
      })
    }
    return prev
  }, [] as DropdownOptions)
}

const dropdownOptions = computed(() => formatOptions(options.value))

function handleSelect(_: string, option: DropdownOption) {
  const action = option.action as () => void
  action()
}
</script>

<template>
  <NDropdown
    trigger="manual" placement="bottom-start" :show="visible" :x="position?.x" :y="position?.y"
    :options="dropdownOptions" @contextmenu.prevent @clickoutside="hide" @select="handleSelect"
  />
</template>

结合ElementPlus

使用ElementPlus的 Dropdown 组件实现单级右键菜单。

结合AntDesignVue

使用AntDesignVue的 Menu 组件实现二级右键菜单。


点我查看代码
vue
<script lang="ts" setup>
import type { ContextmenuItem } from '@summeruse/ol'
import type { Feature } from 'ol'
import { createPointFeature, createPolygonFeature, createVectorLayer, getOSMLayer, OlMap } from '@summeruse/ol'
import { Map as OLMap } from 'ol'
import { ref } from 'vue'
import Contextmenu from './a-ol-contextmenu-demo.vue'

const olMap = new OLMap()
const { source, layer } = createVectorLayer()

const feature = createPointFeature([0, 0], {
  type: 'point',
  data: {
    name: '点',
  },
})
source.addFeature(feature)

const feature2 = createPolygonFeature([
  [
    [1000000, 1000000],
    [1000000, 5000000],
    [5000000, 5000000],
    [5000000, 1000000],
  ],
], {
  type: 'polygon',
  data: {
    name: '多边形',
  },
})
source.addFeature(feature2)
olMap.addLayer(getOSMLayer())
olMap.addLayer(layer)
const items = ref<ContextmenuItem[]>([
  {
    label: '添加点',
    key: 'add-point',
    visible: ({ feature }) => !feature,
    action: ({ coordinate }) => {
      const feature = createPointFeature(coordinate, {
        type: 'point',
        data: {
          name: '点',
        },
      })
      source.addFeature(feature)
    },
  },
  {
    label: '删除点',
    key: 'delete-point',
    visible: ({ feature }) => feature?.get('type') === 'point',
    action: ({ feature }) => {
      feature && source.removeFeature(feature as Feature)
    },
  },
  {
    label: '多边形区域',
    key: 'delete-polygon',
    visible: ({ feature }) => feature?.get('type') === 'polygon',
  },
  {
    label: '清空点',
    divided: true,
    key: 'clear-point',
    action: () => {
      source.forEachFeature((feature) => {
        if (feature.get('type') === 'point') {
          source.removeFeature(feature)
        }
      })
    },
  },
])
</script>

<template>
  <OlMap :ol-map class="w-100% h-400px">
    <Contextmenu :map="olMap" :items="items" />
  </OlMap>
</template>
vue
<script lang="ts" setup>
import type { ContextmenuItem, ContextmenuOption } from '@summeruse/ol'
import type { OLMap } from 'packages/ol/types'
import { useContextmenu } from '@summeruse/ol'
import { Menu } from 'ant-design-vue'
import { computed } from 'vue'

const props = defineProps<{
  map: OLMap
  items: Array<ContextmenuItem>
}>()

const { visible, options, position } = useContextmenu(props.map, props.items)

function formatOptions(option: ContextmenuOption[]) {
  let key = 0
  return option.reduce((prev, cur) => {
    if (cur.divided) {
      prev.push({
        type: 'divider',
      })
    }
    if (cur.children) {
      prev.push({
        label: cur.label,
        key: cur.key ?? `_${key++}`,
        title: '',
        children: formatOptions(cur.children),
        action: cur.action,
      })
    }
    else {
      prev.push({
        label: cur.label,
        key: cur.key ?? `_${key++}`,
        action: cur.action,
      })
    }
    return prev
  }, [] as any[])
}

const items = computed(() => formatOptions(options.value))

function handleClick(e: any) {
  e.item.action()
}
</script>

<template>
  <div
    v-show="visible" :style="{
      position: 'fixed',
      zIndex: 1000,
      left: `${position.x}px`,
      top: `${position.y}px`,
    }"
  >
    <Menu :items="items" style="margin: 0; padding: 0;border-radius: 8px;" @click="handleClick" @contextmenu.prevent />
  </div>
</template>

API

名称类型
useContextmenu(...args: UseContextmenuParams) => UseContextmenuReturn

UseContextmenuParams

名称类型默认值说明
args[0]MaybeRefOrGetter<OLMap | undefined>-地图实例
args[1]MaybeRefOrGetter<ContextmenuItem[]>-菜单项配置

UseContextmenuReturn

名称类型说明
visibleComputedRef<boolean>菜单是否可见
positionComputedRef<ContextmenuPosition>菜单当前位置
featureComputedRef<FeatureLike | undefined>触发菜单的要素
optionsComputedRef<ContextmenuOption[]>当前可显示的菜单项
hide() => void关闭菜单

ContextmenuItem

名称类型默认值说明
label((params: ContextmenuItemParams) => VNodeChild) | string-菜单项文本
visible((params: ContextmenuItemParams) => boolean) | booleantrue是否可见
disabled((params: ContextmenuItemParams) => boolean) | booleanfalse是否禁用
action(params: ContextmenuItemParams) => void-点击回调
childrenContextmenuItem[]-子项
dividedbooleanfalse是否显示分割线
icon((params: ContextmenuItemParams) => VNodeChild) | string-图标
ordernumber-排序
keystring-唯一键

ContextmenuOption

名称类型默认值说明
labelstring | (() => VNodeChild)-菜单项文本
visiblebooleantrue是否可见
disabledbooleanfalse是否禁用
action() => void-点击回调
childrenContextmenuOption[]-子项
dividedbooleanfalse是否显示分割线
iconstring | (() => VNodeChild)-图标
ordernumber-排序
keystring-唯一键

ContextmenuItemParams

名称类型说明
mapOLMap地图实例
positionContextmenuPosition菜单位置
coordinateCoordinate点击的坐标
featureFeatureLike点击的feature
layerLayerLike点击的图层

ContextmenuPosition

名称类型说明
xnumber菜单横坐标
ynumber菜单纵坐标

源代码

点我查看代码
ts
import type { Coordinate } from 'ol/coordinate'
import type { FeatureLike } from 'ol/Feature'
import type { MaybeRefOrGetter, VNodeChild } from 'vue'
import type { LayerLike, OLMap } from '../../types'
import { computed, onBeforeUnmount, ref, toValue, watch } from 'vue'

export interface ContextmenuPosition {
  x: number
  y: number
}

interface ContextmenuItemParams { map: OLMap, coordinate: Coordinate, position: ContextmenuPosition, feature?: FeatureLike, layer?: LayerLike }

export interface ContextmenuItemBase {
  label: ((params: ContextmenuItemParams) => VNodeChild) | string
  visible?: ((params: ContextmenuItemParams) => boolean) | boolean
  disabled?: ((params: ContextmenuItemParams) => boolean) | boolean
  action?: (params: ContextmenuItemParams) => void
  divided?: boolean
  icon?: ((params: ContextmenuItemParams) => VNodeChild) | string
  order?: number
  key?: string | number
  [key: string]: any
}

export interface ContextmenuItem extends ContextmenuItemBase {
  children?: Array<ContextmenuItem>
}

export type ContextmenuList = ContextmenuItem[]

export interface ContextmenuOptionBase {
  label: string | (() => VNodeChild)
  visible?: boolean
  disabled?: boolean
  action: () => void
  divided?: boolean
  icon?: string | (() => VNodeChild)
  order?: number
  key?: string | number
  [key: string]: any
}

export interface ContextmenuOption extends ContextmenuOptionBase {
  children?: Array<ContextmenuOption>
}

export type ContextmenuOptions = ContextmenuOption[]

export function useContextmenu(mapRef: MaybeRefOrGetter<OLMap | undefined>, items: MaybeRefOrGetter<ContextmenuList>) {
  const visible = ref(false)
  const position = ref<ContextmenuPosition>({ x: 0, y: 0 })
  const feature = ref<FeatureLike | undefined>()
  const options = ref<ContextmenuOptions>([])
  const coordinate = ref<Coordinate>()

  let currentMap: OLMap | undefined

  /** 递归过滤可见菜单并排序 */
  function filterAndSortMenu(
    menus: ContextmenuList,
    params: ContextmenuItemParams,
  ): ContextmenuOptions {
    const options: ContextmenuOptions = []
    menus
      .filter((item) => {
        const visible = item.visible
        if (typeof visible === 'function') {
          return visible(params)
        }
        return visible ?? true
      })
      .filter(item => !item.children || item.children.length > 0)
      .sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
      .forEach((item) => {
        const icon = item.icon
        const label = item.label
        options.push({
          ...item,
          children: item.children ? filterAndSortMenu(item.children, params) : undefined,
          visible: true,
          action: () => {
            item.action?.(params)
            hide()
          },
          icon: typeof icon === 'function' ? () => icon(params) : icon,
          label: typeof label === 'function' ? () => label(params) : label,
          disabled: typeof item.disabled === 'function' ? item.disabled(params) : item.disabled ?? false,
        })
      })

    return options
  }

  function show(evt: MouseEvent) {
    evt.preventDefault()
    if (!currentMap)
      return

    const _coordinate = currentMap.getEventCoordinate(evt)
    coordinate.value = _coordinate
    const pixel = currentMap.getEventPixel(evt)
    let foundFeature: FeatureLike | undefined
    let foundLayer: LayerLike | undefined
    currentMap.forEachFeatureAtPixel(pixel, (feature, layer) => {
      foundFeature = feature
      foundLayer = layer
      return true
    })

    feature.value = foundFeature
    const [x, y] = [evt.clientX, evt.clientY]
    position.value = { x, y }
    options.value = filterAndSortMenu(toValue(items), {
      map: currentMap,
      position: { ...position.value },
      coordinate: _coordinate,
      feature: foundFeature,
      layer: foundLayer,
    })
    visible.value = true
  }

  function hide() {
    visible.value = false
  }

  /** 绑定 / 解绑事件 */
  function bindMapEvents(map?: OLMap) {
    if (!map)
      return
    const el = map.getViewport()
    el.addEventListener('contextmenu', show)
    el.addEventListener('click', hide)
  }

  function unbindMapEvents(map?: OLMap) {
    if (!map)
      return
    const el = map.getViewport()
    el.removeEventListener('contextmenu', show)
    el.removeEventListener('click', hide)
  }

  /** 监听 mapRef 变化 */
  watch(
    () => toValue(mapRef),
    (newMap, oldMap) => {
      if (oldMap !== newMap) {
        unbindMapEvents(oldMap)
        bindMapEvents(newMap)
        currentMap = newMap
      }
    },
    { immediate: true },
  )

  onBeforeUnmount(() => {
    unbindMapEvents(currentMap)
  })

  return {
    visible: computed(() => visible.value),
    position: computed(() => position.value),
    feature: computed(() => feature.value),
    options: computed(() => options.value),
    coordinate: computed(() => coordinate.value),
    hide,
  }
}

export type UseContextmenuReturn = ReturnType<typeof useContextmenu>
export type UseContextmenuParams = Parameters<typeof useContextmenu>
export type UseContextmenuFn = (...args: UseContextmenuParams) => UseContextmenuReturn

Released under the ISC License.