UNPKG

@nuxt/ui

Version:

A UI Library for Modern Web Apps, powered by Vue & Tailwind CSS.

449 lines (448 loc) 16.2 kB
import { ref, h, computed, unref } from "vue"; import { defu } from "defu"; import { useFilter } from "reka-ui"; import { computePosition } from "@floating-ui/dom"; import { VueRenderer } from "@tiptap/vue-3"; import Suggestion from "@tiptap/suggestion"; import { PluginKey } from "@tiptap/pm/state"; import { buildFloatingUIMiddleware } from "../utils/editor.js"; import { get, isArrayOfArray } from "../utils/index.js"; export function useEditorMenu(options) { const filteredItems = ref([]); const selectedIndex = ref(0); const menuState = ref("closed"); let renderer = null; let element = null; let handleMouseDown = null; let commandFn = null; let keyDownHandler = null; let globalKeyHandler = null; let blurHandler = null; let triggerClientRect = null; let handleHover = null; let scrollHandler = null; const { contains } = useFilter({ sensitivity: "base" }); const cleanupMenu = () => { if (menuState.value === "closed") return; menuState.value = "closed"; if (globalKeyHandler) { document.removeEventListener("keydown", globalKeyHandler, true); globalKeyHandler = null; } if (blurHandler) { options.editor.view.dom.removeEventListener("blur", blurHandler); blurHandler = null; } if (scrollHandler) { window.removeEventListener("scroll", scrollHandler, true); scrollHandler = null; } if (element && handleMouseDown) { element.removeEventListener("mousedown", handleMouseDown); handleMouseDown = null; } if (renderer) { renderer.destroy(); renderer = null; } if (element) { element.remove(); element = null; } }; const filterFields = options.filterFields ?? ["label"]; const defaultFilter = (items2, query) => { if (!query) return items2; return items2.filter((item) => { return filterFields.some((field) => { const value = get(item, field); if (value === void 0 || value === null) return false; const stringValue = Array.isArray(value) ? value.join(" ") : String(value); return contains(stringValue, query) || contains(stringValue.replace(/[\s_-]/g, ""), query); }); }); }; const filter = options.filter || defaultFilter; const limit = options.limit ?? 42; const groups = computed(() => { const items2 = unref(options.items); return items2?.length ? isArrayOfArray(items2) ? items2 : [items2] : []; }); const items = computed(() => groups.value.flatMap((group) => group)); const filteredGroups = computed(() => { if (!filteredItems.value.length) return []; return groups.value.map((group) => group.filter((item) => filteredItems.value.includes(item))).filter((group) => group.length > 0); }); const selectableItems = computed(() => { return filteredItems.value.filter((item) => { return item.type !== "label" && item.type !== "separator"; }); }); const floatingUIOptions = defu(options.options, { strategy: "absolute", placement: "bottom-start", offset: 8, flip: {}, shift: { padding: 8 }, size: false, autoPlacement: false, hide: false, inline: false }); const middleware = buildFloatingUIMiddleware(floatingUIOptions); const updatePosition = (element2) => { if (!triggerClientRect) return; const rect = triggerClientRect(); if (!rect) return; const virtualElement = { getBoundingClientRect: () => rect }; computePosition(virtualElement, element2, { placement: floatingUIOptions.placement, strategy: floatingUIOptions.strategy, middleware }).then(({ x, y, strategy }) => { element2.style.width = "max-content"; element2.style.position = strategy; element2.style.top = "0"; element2.style.left = "0"; element2.style.transform = `translate(${Math.round(x)}px, ${Math.round(y)}px)`; }); }; const MenuComponent = { props: { groups: { type: Array, required: true }, selectedIndex: { type: Number, required: true }, onSelect: { type: Function, required: true }, onHover: { type: Function, required: true }, state: { type: String, required: true } }, setup(menuProps) { function handleClick(e, item, selectableIndex) { e.preventDefault(); menuProps.onSelect(item, selectableIndex); } function handleMouseEnter(selectableIndex) { menuProps.onHover(selectableIndex); } return () => { const groupsData = menuProps.groups; const selectableIndexMap = /* @__PURE__ */ new Map(); let selectableCounter = 0; for (const group of groupsData) { for (const item of group) { const itemData = item; if (itemData.type !== "label" && itemData.type !== "separator") { selectableIndexMap.set(item, selectableCounter++); } } } return h("div", { "class": options.ui.value.content(), "role": "listbox", "data-state": menuProps.state }, [ h("div", { class: options.ui.value.viewport(), role: "presentation" }, groupsData.map( (group, groupIndex) => h("div", { key: `group-${groupIndex}`, class: options.ui.value.group(), role: "group" }, group.map((item, itemInGroupIndex) => { const itemData = item; if (itemData.type === "label") { return h("div", { key: `label-${groupIndex}-${itemInGroupIndex}`, class: options.ui.value.label({ class: itemData.class }) }, options.renderItem(item, options.ui)); } if (itemData.type === "separator") { return h("div", { key: `separator-${groupIndex}-${itemInGroupIndex}`, class: options.ui.value.separator({ class: itemData.class }), role: "separator" }); } const selectableIndex = selectableIndexMap.get(item); const isHighlighted = selectableIndex === menuProps.selectedIndex; return h("div", { "key": `item-${selectableIndex}`, "class": options.ui.value.item({ class: itemData.class, active: false }), "role": "option", "aria-selected": isHighlighted, "data-highlighted": isHighlighted ? "" : void 0, "data-disabled": itemData.disabled ? "" : void 0, "onMousedown": (e) => handleClick(e, item, selectableIndex), "onMouseenter": () => handleMouseEnter(selectableIndex), "ref": (el) => { if (el && isHighlighted) { el.scrollIntoView({ block: "nearest", inline: "nearest" }); } } }, options.renderItem(item, options.ui)); })) )) ]); }; } }; const pluginKeyInstance = typeof options.pluginKey === "string" ? new PluginKey(options.pluginKey) : options.pluginKey; const plugin = Suggestion({ pluginKey: pluginKeyInstance, editor: options.editor, char: options.char, items: ({ query }) => { const filtered = filter(items.value, query); return filtered.slice(0, limit); }, command: ({ editor, range, props }) => { options.onSelect(editor, range, props); }, render: () => { keyDownHandler = (props) => { const { event } = props; if (!renderer || !selectableItems.value.length) { return false; } if (event.key === "Escape") { cleanupMenu(); return true; } if (event.key === "ArrowUp") { selectedIndex.value = (selectedIndex.value + selectableItems.value.length - 1) % selectableItems.value.length; renderer?.updateProps({ groups: filteredGroups.value, selectedIndex: selectedIndex.value, onSelect: commandFn, onHover: handleHover, state: menuState.value }); return true; } if (event.key === "ArrowDown") { selectedIndex.value = (selectedIndex.value + 1) % selectableItems.value.length; renderer?.updateProps({ groups: filteredGroups.value, selectedIndex: selectedIndex.value, onSelect: commandFn, onHover: handleHover, state: menuState.value }); return true; } if (event.key === "Enter" || event.key === "Tab") { const selectedItem = selectableItems.value[selectedIndex.value]; if (selectedItem && commandFn) { commandFn(selectedItem); } return true; } return false; }; const handlers = { onStart: (suggestionProps) => { filteredItems.value = suggestionProps.items; selectedIndex.value = 0; commandFn = (item) => suggestionProps.command(item); triggerClientRect = suggestionProps.clientRect; if (!filteredItems.value.length) { return; } menuState.value = "open"; globalKeyHandler = (e) => { if (keyDownHandler) { const handled = keyDownHandler({ event: e }); if (handled) { e.preventDefault(); e.stopPropagation(); } } }; document.addEventListener("keydown", globalKeyHandler, true); blurHandler = () => { setTimeout(() => { if (menuState.value === "open") { const tr = suggestionProps.editor.view.state.tr.setMeta(pluginKeyInstance, { exit: true }); suggestionProps.editor.view.dispatch(tr); } }, 0); }; suggestionProps.editor.view.dom.addEventListener("blur", blurHandler); scrollHandler = () => { if (element) { updatePosition(element); } }; window.addEventListener("scroll", scrollHandler, true); handleHover = (index) => { selectedIndex.value = index; if (renderer) { renderer.updateProps({ groups: filteredGroups.value, selectedIndex: index, onSelect: commandFn, onHover: handleHover, state: menuState.value }); } }; renderer = new VueRenderer(MenuComponent, { props: { groups: filteredGroups.value, selectedIndex: selectedIndex.value, onSelect: commandFn, onHover: handleHover, state: menuState.value }, editor: suggestionProps.editor }); element = document.createElement("div"); element.style.position = floatingUIOptions.strategy; element.style.zIndex = "50"; handleMouseDown = (e) => { e.preventDefault(); }; element.addEventListener("mousedown", handleMouseDown); const appendToElement = typeof options.appendTo === "function" ? options.appendTo() : options.appendTo; (appendToElement ?? suggestionProps.editor.view.dom.parentElement)?.appendChild(element); if (renderer.element) { element.appendChild(renderer.element); } updatePosition(element); }, onUpdate: (suggestionProps) => { filteredItems.value = suggestionProps.items; commandFn = (item) => suggestionProps.command(item); if (selectedIndex.value >= selectableItems.value.length) { selectedIndex.value = Math.max(0, selectableItems.value.length - 1); } if (!filteredItems.value.length) { cleanupMenu(); return; } if (!renderer) { menuState.value = "open"; if (!globalKeyHandler) { globalKeyHandler = (e) => { if (keyDownHandler) { const handled = keyDownHandler({ event: e }); if (handled) { e.preventDefault(); e.stopPropagation(); } } }; document.addEventListener("keydown", globalKeyHandler, true); } if (!blurHandler) { blurHandler = () => { setTimeout(() => { if (menuState.value === "open") { const tr = suggestionProps.editor.view.state.tr.setMeta(pluginKeyInstance, { exit: true }); suggestionProps.editor.view.dispatch(tr); } }, 0); }; suggestionProps.editor.view.dom.addEventListener("blur", blurHandler); } if (!scrollHandler) { scrollHandler = () => { if (element) { updatePosition(element); } }; window.addEventListener("scroll", scrollHandler, true); } handleHover = (index) => { selectedIndex.value = index; if (renderer) { renderer.updateProps({ groups: filteredGroups.value, selectedIndex: index, onSelect: commandFn, onHover: handleHover, state: menuState.value }); } }; renderer = new VueRenderer(MenuComponent, { props: { groups: filteredGroups.value, selectedIndex: selectedIndex.value, onSelect: commandFn, onHover: handleHover, state: menuState.value }, editor: suggestionProps.editor }); element = document.createElement("div"); element.style.position = floatingUIOptions.strategy; element.style.zIndex = "50"; handleMouseDown = (e) => { e.preventDefault(); }; element.addEventListener("mousedown", handleMouseDown); const appendToElement = typeof options.appendTo === "function" ? options.appendTo() : options.appendTo; (appendToElement ?? suggestionProps.editor.view.dom.parentElement)?.appendChild(element); if (renderer.element) { element.appendChild(renderer.element); } } else { renderer.updateProps({ groups: filteredGroups.value, selectedIndex: selectedIndex.value, onSelect: commandFn, onHover: (index) => { selectedIndex.value = index; }, state: menuState.value }); } if (element) { updatePosition(element); } }, onKeyDown: keyDownHandler, onExit: () => { cleanupMenu(); triggerClientRect = null; } }; return handlers; } }); const destroy = () => { menuState.value = "closed"; if (globalKeyHandler) { document.removeEventListener("keydown", globalKeyHandler, true); globalKeyHandler = null; } if (blurHandler) { options.editor.view.dom.removeEventListener("blur", blurHandler); blurHandler = null; } if (scrollHandler) { window.removeEventListener("scroll", scrollHandler, true); scrollHandler = null; } if (element && handleMouseDown) { element.removeEventListener("mousedown", handleMouseDown); handleMouseDown = null; } if (renderer) { renderer.destroy(); renderer = null; } if (element) { element.remove(); element = null; } }; return { plugin, destroy, filteredItems }; }