lexical-vue
Version:
An extensible Vue 3 web text-editor based on Lexical.
191 lines (190 loc) • 9.3 kB
JavaScript
import { createBlock, createCommentVNode, defineComponent, guardReactiveProps, nextTick, normalizeProps, openBlock, ref, renderSlot, unref, useSlots, watchEffect, withCtx } from "vue";
import { $getSelection, $isRangeSelection, $isTextNode, COMMAND_PRIORITY_LOW, createCommand } from "lexical";
import { useLexicalComposer } from "./LexicalComposer.vine.js";
import { LexicalMenu, MenuOption, useDynamicPositioning, useMenuAnchorRef } from "./shared/LexicalMenu.vine.js";
const PUNCTUATION = '\\.,\\+\\*\\?\\$\\@\\|#{}\\(\\)\\^\\-\\[\\]\\\\/!%\'"~=<>_:;';
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;
}
const SCROLL_TYPEAHEAD_OPTION_INTO_VIEW_COMMAND = createCommand('SCROLL_TYPEAHEAD_OPTION_INTO_VIEW_COMMAND');
function useBasicTypeaheadTriggerMatch(trigger, param) {
let { minLength = 1, maxLength = 75, punctuation = PUNCTUATION, allowWhitespace = false } = param;
return (text)=>{
const validCharsSuffix = allowWhitespace ? '' : '\\s';
const validChars = "[^".concat(trigger).concat(punctuation).concat(validCharsSuffix, "]");
const TypeaheadTriggerRegex = new RegExp("(^|\\s|\\()(" + "[".concat(trigger, "]") + "((?:".concat(validChars, "){0,").concat(maxLength, "})") + ")$");
const match = TypeaheadTriggerRegex.exec(text);
if (null !== match) {
const maybeLeadingWhitespace = match[1];
const matchingString = match[3];
if (matchingString.length >= minLength) return {
leadOffset: match.index + maybeLeadingWhitespace.length,
matchingString,
replaceableString: match[2]
};
}
return null;
};
}
const TypeaheadMenuPlugin = (()=>{
const __vine = defineComponent({
name: 'TypeaheadMenuPlugin',
props: {
options: {
required: true
},
triggerFn: {
required: true
},
anchorClassName: {},
commandPriority: {},
parent: {},
preselectFirstItem: {
type: Boolean
},
ignoreEntityBoundary: {
type: Boolean
}
},
emits: [
'close',
'open',
'queryChange',
'selectOption'
],
setup (__props, param) {
let { emit: __emit, expose: __expose } = param;
const emit = __emit;
__expose();
const props = __props;
useSlots();
const editor = useLexicalComposer();
const resolution = ref(null);
function setResolution(payload) {
resolution.value = payload;
}
const anchorElementRef = useMenuAnchorRef(resolution, setResolution, props.anchorClassName, props.parent);
function closeTypeahead() {
setResolution(null);
if (null !== resolution.value) emit('close');
}
function openTypeahead(res) {
setResolution(res);
if (null === resolution.value) emit('open', res);
}
function getTextUpToAnchor(selection) {
const anchor = selection.anchor;
if ('text' !== anchor.type) return null;
const anchorNode = anchor.getNode();
if (!anchorNode.isSimpleText()) return null;
const anchorOffset = anchor.offset;
return anchorNode.getTextContent().slice(0, anchorOffset);
}
function tryToPositionRange(leadOffset, range, editorWindow) {
const domSelection = editorWindow.getSelection();
if (null === domSelection || !domSelection.isCollapsed) return false;
const anchorNode = domSelection.anchorNode;
const startOffset = leadOffset;
const endOffset = domSelection.anchorOffset;
if (null == anchorNode || null == endOffset) return false;
try {
range.setStart(anchorNode, startOffset);
range.setEnd(anchorNode, endOffset);
} catch (e) {
return false;
}
return true;
}
function getQueryTextForSearch(editor) {
let text = null;
editor.getEditorState().read(()=>{
const selection = $getSelection();
if (!$isRangeSelection(selection)) return;
text = getTextUpToAnchor(selection);
});
return text;
}
function isSelectionOnEntityBoundary(editor, offset) {
if (0 !== offset) return false;
return editor.getEditorState().read(()=>{
const selection = $getSelection();
if ($isRangeSelection(selection)) {
const anchor = selection.anchor;
const anchorNode = anchor.getNode();
const prevSibling = anchorNode.getPreviousSibling();
return $isTextNode(prevSibling) && prevSibling.isTextEntity();
}
return false;
});
}
watchEffect((onInvalidate)=>{
const updateListener = ()=>{
editor.getEditorState().read(()=>{
if (!editor.isEditable()) return void closeTypeahead();
const editorWindow = editor._window || window;
const range = editorWindow.document.createRange();
const selection = $getSelection();
const text = getQueryTextForSearch(editor);
if (!$isRangeSelection(selection) || !selection.isCollapsed() || null === text || null === range) return void closeTypeahead();
const match = props.triggerFn(text, editor);
emit('queryChange', match ? match.matchingString : null);
if (null !== match && (props.ignoreEntityBoundary || !isSelectionOnEntityBoundary(editor, match.leadOffset))) {
const isRangePositioned = tryToPositionRange(match.leadOffset, range, editorWindow);
if (null !== isRangePositioned) return void nextTick(()=>openTypeahead({
getRect: ()=>range.getBoundingClientRect(),
match
}));
}
closeTypeahead();
});
};
const removeUpdateListener = editor.registerUpdateListener(updateListener);
onInvalidate(removeUpdateListener);
});
watchEffect((onInvalidate)=>{
const unregister = editor.registerEditableListener((isEditable)=>{
if (!isEditable) closeTypeahead();
});
onInvalidate(unregister);
});
return (_ctx, _cache)=>{
var _ctx_commandPriority;
return null !== resolution.value && null !== unref(editor) && null !== unref(anchorElementRef) ? (openBlock(), createBlock(unref(LexicalMenu), {
key: 0,
"anchor-element-ref": unref(anchorElementRef),
editor: unref(editor),
resolution: resolution.value,
options: _ctx.options,
"should-split-node-with-query": true,
"command-priority": null != (_ctx_commandPriority = _ctx.commandPriority) ? _ctx_commandPriority : unref(COMMAND_PRIORITY_LOW),
close: closeTypeahead,
onSelectOption: _cache[0] || (_cache[0] = ($event)=>emit('selectOption', $event))
}, {
default: withCtx((slotProps)=>[
renderSlot(_ctx.$slots, "default", normalizeProps(guardReactiveProps(slotProps)))
]),
_: 3
}, 8, [
"anchor-element-ref",
"editor",
"resolution",
"options",
"command-priority"
])) : createCommentVNode("", true);
};
}
});
__vine.__vue_vine = true;
return __vine;
})();
export { MenuOption, PUNCTUATION, SCROLL_TYPEAHEAD_OPTION_INTO_VIEW_COMMAND, TypeaheadMenuPlugin, getScrollParent, useBasicTypeaheadTriggerMatch, useDynamicPositioning };