lexical-vue
Version:
An extensible Vue 3 web text-editor based on Lexical.
333 lines (332 loc) • 16.8 kB
JavaScript
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 };