UNPKG

lexical-vue

Version:

An extensible Vue 3 web text-editor based on Lexical.

333 lines (332 loc) 16.8 kB
import { computed, defineComponent, onUnmounted, ref, renderSlot, useSlots, watch, watchEffect } from "vue"; import { CAN_USE_DOM, mergeRegister } from "@lexical/utils"; import { $getSelection, $isRangeSelection, KEY_ARROW_DOWN_COMMAND, KEY_ARROW_UP_COMMAND, KEY_ENTER_COMMAND, KEY_ESCAPE_COMMAND, KEY_TAB_COMMAND, createCommand } from "lexical"; import { useLexicalComposer } from "../LexicalComposer.vine.js"; function _define_property(obj, key, value) { if (key in obj) Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); else obj[key] = value; return obj; } const PUNCTUATION = '\\.,\\+\\*\\?\\$\\@\\|#{}\\(\\)\\^\\-\\[\\]\\\\/!%\'"~=<>_:;'; class MenuOption { setRefElement(el) { this.ref = el; } constructor(key){ _define_property(this, "key", void 0); _define_property(this, "ref", void 0); this.key = key; this.ref = null; this.setRefElement = this.setRefElement.bind(this); } } function getScrollParent(element, includeHidden) { let style = getComputedStyle(element); const excludeStaticParent = 'absolute' === style.position; const overflowRegex = includeHidden ? /(auto|scroll|hidden)/ : /(auto|scroll)/; if ('fixed' === style.position) return document.body; for(let parent = element; parent = parent.parentElement;){ style = getComputedStyle(parent); if (!excludeStaticParent || 'static' !== style.position) { if (overflowRegex.test(style.overflow + style.overflowY + style.overflowX)) return parent; } } return document.body; } function isTriggerVisibleInNearestScrollContainer(targetElement, containerElement) { const tRect = targetElement.getBoundingClientRect(); const cRect = containerElement.getBoundingClientRect(); return tRect.top > cRect.top && tRect.top < cRect.bottom; } function useDynamicPositioning(resolution, targetElement, onReposition, onVisibilityChange) { const editor = useLexicalComposer(); watchEffect((onInvalidate)=>{ if (null != targetElement.value && null != resolution.value) { const rootElement = editor.getRootElement(); const rootScrollParent = null != rootElement ? getScrollParent(rootElement, false) : document.body; let ticking = false; let previousIsInView = isTriggerVisibleInNearestScrollContainer(targetElement.value, rootScrollParent); const handleScroll = function() { if (!ticking) { window.requestAnimationFrame(()=>{ onReposition(); ticking = false; }); ticking = true; } const isInView = isTriggerVisibleInNearestScrollContainer(targetElement.value, rootScrollParent); if (isInView !== previousIsInView) { previousIsInView = isInView; if (null != onVisibilityChange) onVisibilityChange(isInView); } }; const resizeObserver = new ResizeObserver(onReposition); window.addEventListener('resize', onReposition); document.addEventListener('scroll', handleScroll, { capture: true, passive: true }); resizeObserver.observe(targetElement.value); onInvalidate(()=>{ resizeObserver.unobserve(targetElement.value); window.removeEventListener('resize', onReposition); document.removeEventListener('scroll', handleScroll, true); }); } }); } function setContainerDivAttributes(containerDiv, className) { if (null != className) containerDiv.className = className; containerDiv.setAttribute('aria-label', 'Typeahead menu'); containerDiv.setAttribute('role', 'listbox'); containerDiv.style.display = 'block'; containerDiv.style.position = 'absolute'; } const SCROLL_TYPEAHEAD_OPTION_INTO_VIEW_COMMAND = createCommand('SCROLL_TYPEAHEAD_OPTION_INTO_VIEW_COMMAND'); function useMenuAnchorRef(resolution, setResolution, className) { let parent = arguments.length > 3 && void 0 !== arguments[3] ? arguments[3] : CAN_USE_DOM ? document.body : void 0, shouldIncludePageYOffset__EXPERIMENTAL = arguments.length > 4 && void 0 !== arguments[4] ? arguments[4] : true; const editor = useLexicalComposer(); const initialAnchorElement = CAN_USE_DOM ? document.createElement('div') : null; const anchorElementRef = ref(initialAnchorElement); const positionMenu = ()=>{ if (null === anchorElementRef.value || void 0 === parent) return; anchorElementRef.value.style.top = anchorElementRef.value.style.bottom; const rootElement = editor.getRootElement(); const containerDiv = anchorElementRef.value; const menuEle = containerDiv.firstElementChild; if (null !== rootElement && null !== resolution.value) { const { left, top, width, height } = resolution.value.getRect(); const anchorHeight = anchorElementRef.value.offsetHeight; containerDiv.style.top = "".concat(top + (shouldIncludePageYOffset__EXPERIMENTAL ? window.pageYOffset : 0) + anchorHeight + 3, "px"); containerDiv.style.left = "".concat(left + window.pageXOffset, "px"); containerDiv.style.height = "".concat(height, "px"); containerDiv.style.width = "".concat(width, "px"); if (null !== menuEle) { menuEle.style.top = "".concat(top); const menuRect = menuEle.getBoundingClientRect(); const menuHeight = menuRect.height; const menuWidth = menuRect.width; const rootElementRect = rootElement.getBoundingClientRect(); if (left + menuWidth > rootElementRect.right) containerDiv.style.left = "".concat(rootElementRect.right - menuWidth + window.pageXOffset, "px"); if ((top + menuHeight > window.innerHeight || top + menuHeight > rootElementRect.bottom) && top - rootElementRect.top > menuHeight + height) containerDiv.style.top = "".concat(top - menuHeight + (shouldIncludePageYOffset__EXPERIMENTAL ? window.pageYOffset : 0) - height, "px"); } if (!containerDiv.isConnected) { setContainerDivAttributes(containerDiv, className); parent.append(containerDiv); } containerDiv.setAttribute('id', 'typeahead-menu'); rootElement.setAttribute('aria-controls', 'typeahead-menu'); } }; watchEffect(()=>{ const rootElement = editor.getRootElement(); if (null !== resolution.value) { positionMenu(); return ()=>{ if (null !== rootElement) rootElement.removeAttribute('aria-controls'); const containerDiv = anchorElementRef.value; if (null !== containerDiv && containerDiv.isConnected) { containerDiv.remove(); containerDiv.removeAttribute('id'); } }; } }); const onVisibilityChange = (isInView)=>{ if (null !== resolution.value) { if (!isInView) setResolution(null); } }; useDynamicPositioning(resolution, anchorElementRef, positionMenu, onVisibilityChange); return anchorElementRef; } const LexicalMenu = (()=>{ const __vine = defineComponent({ name: 'LexicalMenu', props: { close: { required: true }, editor: { required: true }, anchorElementRef: { required: true }, resolution: { required: true }, options: { required: true }, shouldSplitNodeWithQuery: { type: Boolean }, commandPriority: {} }, emits: [ 'selectOption' ], setup (__props, param) { let { emit: __emit, expose: __expose } = param; const emit = __emit; __expose(); const props = __props; useSlots(); const selectedIndex = ref(null); const matchString = computed(()=>props.resolution.match && props.resolution.match.matchingString); function setHighlightedIndex(index) { selectedIndex.value = index; } function getFullMatchOffset(documentText, entryText, offset) { let triggerOffset = offset; for(let i = triggerOffset; i <= entryText.length; i++)if (documentText.substring(-i) === entryText.substring(0, i)) triggerOffset = i; return triggerOffset; } function $splitNodeContainingQuery(match) { const selection = $getSelection(); if (!$isRangeSelection(selection) || !selection.isCollapsed()) return null; const anchor = selection.anchor; if ('text' !== anchor.type) return null; const anchorNode = anchor.getNode(); if (!anchorNode.isSimpleText()) return null; const selectionOffset = anchor.offset; const textContent = anchorNode.getTextContent().slice(0, selectionOffset); const characterOffset = match.replaceableString.length; const queryOffset = getFullMatchOffset(textContent, match.matchingString, characterOffset); const startOffset = selectionOffset - queryOffset; if (startOffset < 0) return null; let newNode; if (0 === startOffset) [newNode] = anchorNode.splitText(selectionOffset); else [, newNode] = anchorNode.splitText(startOffset, selectionOffset); return newNode; } watch(matchString, ()=>{ setHighlightedIndex(0); }, { immediate: true }); function selectOptionAndCleanUp(selectedEntry) { props.editor.update(()=>{ const textNodeContainingQuery = null != props.resolution.match && props.shouldSplitNodeWithQuery ? $splitNodeContainingQuery(props.resolution.match) : null; emit('selectOption', { option: selectedEntry, textNodeContainingQuery, closeMenu: props.close, matchingString: props.resolution.match ? props.resolution.match.matchingString : '' }); }); } function updateSelectedIndex(index) { const rootElem = props.editor.getRootElement(); if (null !== rootElem) { rootElem.setAttribute('aria-activedescendant', "typeahead-item-".concat(index)); setHighlightedIndex(index); } } onUnmounted(()=>{ const rootElem = props.editor.getRootElement(); if (null !== rootElem) rootElem.removeAttribute('aria-activedescendant'); }); watchEffect(()=>{ if (null === props.options) setHighlightedIndex(null); else if (null === selectedIndex.value) updateSelectedIndex(0); }); function scrollIntoViewIfNeeded(target) { const typeaheadContainerNode = document.getElementById('typeahead-menu'); if (!typeaheadContainerNode) return; const typeaheadRect = typeaheadContainerNode.getBoundingClientRect(); if (typeaheadRect.top + typeaheadRect.height > window.innerHeight) typeaheadContainerNode.scrollIntoView({ block: 'center' }); if (typeaheadRect.top < 0) typeaheadContainerNode.scrollIntoView({ block: 'center' }); target.scrollIntoView({ block: 'nearest' }); } watchEffect((onInvalidate)=>{ if (!props.commandPriority) return; const fn = mergeRegister(props.editor.registerCommand(SCROLL_TYPEAHEAD_OPTION_INTO_VIEW_COMMAND, (param)=>{ let { option } = param; if (option.ref && null != option.ref) { scrollIntoViewIfNeeded(option.ref); return true; } return false; }, props.commandPriority)); onInvalidate(fn); }); watchEffect((onInvalidate)=>{ if (!props.commandPriority) return; const fn = mergeRegister(props.editor.registerCommand(KEY_ARROW_DOWN_COMMAND, (payload)=>{ const event = payload; if (null !== props.options && props.options.length && null !== selectedIndex.value) { const newSelectedIndex = selectedIndex.value !== props.options.length - 1 ? selectedIndex.value + 1 : 0; updateSelectedIndex(newSelectedIndex); const option = props.options[newSelectedIndex]; if (null != option.ref && option.ref) props.editor.dispatchCommand(SCROLL_TYPEAHEAD_OPTION_INTO_VIEW_COMMAND, { index: newSelectedIndex, option }); event.preventDefault(); event.stopImmediatePropagation(); } return true; }, props.commandPriority), props.editor.registerCommand(KEY_ARROW_UP_COMMAND, (payload)=>{ const event = payload; if (null !== props.options && props.options.length && null !== selectedIndex.value) { const newSelectedIndex = 0 !== selectedIndex.value ? selectedIndex.value - 1 : props.options.length - 1; updateSelectedIndex(newSelectedIndex); const option = props.options[newSelectedIndex]; if (null != option.ref && option.ref) scrollIntoViewIfNeeded(option.ref); event.preventDefault(); event.stopImmediatePropagation(); } return true; }, props.commandPriority), props.editor.registerCommand(KEY_ESCAPE_COMMAND, (payload)=>{ const event = payload; event.preventDefault(); event.stopImmediatePropagation(); close(); return true; }, props.commandPriority), props.editor.registerCommand(KEY_TAB_COMMAND, (payload)=>{ const event = payload; if (null === props.options || null === selectedIndex.value || null == props.options[selectedIndex.value]) return false; event.preventDefault(); event.stopImmediatePropagation(); selectOptionAndCleanUp(props.options[selectedIndex.value]); return true; }, props.commandPriority), props.editor.registerCommand(KEY_ENTER_COMMAND, (event)=>{ if (null === props.options || null === selectedIndex.value || null == props.options[selectedIndex.value]) return false; if (null !== event) { event.preventDefault(); event.stopImmediatePropagation(); } selectOptionAndCleanUp(props.options[selectedIndex.value]); return true; }, props.commandPriority)); onInvalidate(fn); }); return (_ctx, _cache)=>renderSlot(_ctx.$slots, "default", { itemProps: { options: _ctx.options, selectOptionAndCleanUp: selectOptionAndCleanUp, selectedIndex: selectedIndex.value, setHighlightedIndex: setHighlightedIndex }, anchorElementRef: _ctx.anchorElementRef, matchingString: _ctx.resolution.match ? _ctx.resolution.match.matchingString : '' }); } }); __vine.__vue_vine = true; return __vine; })(); export { LexicalMenu, MenuOption, PUNCTUATION, SCROLL_TYPEAHEAD_OPTION_INTO_VIEW_COMMAND, getScrollParent, useDynamicPositioning, useMenuAnchorRef };