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
名称 | 类型 | 说明 |
---|---|---|
visible | ComputedRef<boolean> | 菜单是否可见 |
position | ComputedRef<ContextmenuPosition> | 菜单当前位置 |
feature | ComputedRef<FeatureLike | undefined> | 触发菜单的要素 |
options | ComputedRef<ContextmenuOption[]> | 当前可显示的菜单项 |
hide | () => void | 关闭菜单 |
ContextmenuItem
名称 | 类型 | 默认值 | 说明 |
---|---|---|---|
label | ((params: ContextmenuItemParams) => VNodeChild) | string | - | 菜单项文本 |
visible | ((params: ContextmenuItemParams) => boolean) | boolean | true | 是否可见 |
disabled | ((params: ContextmenuItemParams) => boolean) | boolean | false | 是否禁用 |
action | (params: ContextmenuItemParams) => void | - | 点击回调 |
children | ContextmenuItem[] | - | 子项 |
divided | boolean | false | 是否显示分割线 |
icon | ((params: ContextmenuItemParams) => VNodeChild) | string | - | 图标 |
order | number | - | 排序 |
key | string | - | 唯一键 |
ContextmenuOption
名称 | 类型 | 默认值 | 说明 |
---|---|---|---|
label | string | (() => VNodeChild) | - | 菜单项文本 |
visible | boolean | true | 是否可见 |
disabled | boolean | false | 是否禁用 |
action | () => void | - | 点击回调 |
children | ContextmenuOption[] | - | 子项 |
divided | boolean | false | 是否显示分割线 |
icon | string | (() => VNodeChild) | - | 图标 |
order | number | - | 排序 |
key | string | - | 唯一键 |
ContextmenuItemParams
名称 | 类型 | 说明 |
---|---|---|
map | OLMap | 地图实例 |
position | ContextmenuPosition | 菜单位置 |
coordinate | Coordinate | 点击的坐标 |
feature | FeatureLike | 点击的feature |
layer | LayerLike | 点击的图层 |
ContextmenuPosition
名称 | 类型 | 说明 |
---|---|---|
x | number | 菜单横坐标 |
y | number | 菜单纵坐标 |
源代码
点我查看代码
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