diff --git a/src/components/text-editor/prosemirror-adapter/plugins/punctuation-inputs/punctuation-input-handler.ts b/src/components/text-editor/prosemirror-adapter/plugins/punctuation-inputs/punctuation-input-handler.ts new file mode 100644 index 0000000000..f73f438ea5 --- /dev/null +++ b/src/components/text-editor/prosemirror-adapter/plugins/punctuation-inputs/punctuation-input-handler.ts @@ -0,0 +1,58 @@ +import { EditorView } from 'prosemirror-view'; +import { + PERIOD, + COMMA, + SEMICOLON, + EXCLAMATION, + QUESTION, +} from 'src/util/keycodes'; + +export const punctuationKeys = [PERIOD, COMMA, SEMICOLON, EXCLAMATION, QUESTION]; + +const shiftedPunctuationKeys = { + '!': EXCLAMATION, // Shift + 1 + '?': QUESTION, // Shift + / +}; + +/** + * This function handles the case where the user types any punctuation character. + * If there is a blank space before the punctuation character, it will replace the space with the punctuation character. + * + * @param view - The ProseMirror view instance. + * @param event - The event object associated with the key press. + * @returns {boolean} - Returns true if the replacement was made, otherwise false. + */ +export const handlePunctuationInput = ( + view: EditorView, + event: KeyboardEvent, +) => { + if (!punctuationKeys.includes(event.key)) { + return false; + } + + const { state, dispatch } = view; + const { selection, tr } = state; + const { $from } = selection; + const characterCountBack = 2; + + if ($from.pos < characterCountBack) { + return false; + } + + const prevPos = $from.pos - 1; + const prevChar = state.doc.textBetween(prevPos, $from.pos, 'text'); + + let key = event.key; + if (event.shiftKey && shiftedPunctuationKeys[event.key]) { + key = shiftedPunctuationKeys[event.key]; + } + + if (prevChar === ' ') { + tr.delete(prevPos, $from.pos); + dispatch(tr); + + return true; + } + + return false; +}; diff --git a/src/components/text-editor/prosemirror-adapter/plugins/trigger/create-html-inserter.ts b/src/components/text-editor/prosemirror-adapter/plugins/trigger/create-html-inserter.ts index e72579fdb3..65ed072144 100644 --- a/src/components/text-editor/prosemirror-adapter/plugins/trigger/create-html-inserter.ts +++ b/src/components/text-editor/prosemirror-adapter/plugins/trigger/create-html-inserter.ts @@ -1,11 +1,13 @@ import { Node, DOMParser, Fragment } from 'prosemirror-model'; import { EditorView } from 'prosemirror-view'; import { ContentTypeConverter } from '../../../utils/content-type-converter'; +import { TriggerCharacter } from 'src/interface'; +import { findTriggerPosition } from './factory'; export const createHtmlInserter = ( view: EditorView, contentConverter: ContentTypeConverter, - startPos: number, + triggerCharacter: TriggerCharacter, dispatchTransaction: ( view: EditorView, startPos: number, @@ -13,6 +15,10 @@ export const createHtmlInserter = ( ) => void, ): ((input: string) => Promise) => { const schema = view.state.schema; + const state = view.state; + + const foundTrigger = findTriggerPosition(state, triggerCharacter); + const position = foundTrigger?.position; return async (input: string): Promise => { const container = document.createElement('span'); @@ -20,6 +26,6 @@ export const createHtmlInserter = ( const fragment = DOMParser.fromSchema(schema).parse(container).content; - dispatchTransaction(view, startPos, fragment); + dispatchTransaction(view, position, fragment); }; }; diff --git a/src/components/text-editor/prosemirror-adapter/plugins/trigger/factory.ts b/src/components/text-editor/prosemirror-adapter/plugins/trigger/factory.ts index 13ae6fe023..65e78a1bff 100644 --- a/src/components/text-editor/prosemirror-adapter/plugins/trigger/factory.ts +++ b/src/components/text-editor/prosemirror-adapter/plugins/trigger/factory.ts @@ -7,56 +7,129 @@ import { TriggerEventDetail, } from 'src/components/text-editor/text-editor.types'; import { ContentTypeConverter } from '../../../utils/content-type-converter'; +import { ResolvedPos } from 'prosemirror-model'; +import { ESCAPE } from 'src/util/keycodes'; +import { handlePunctuationInput, punctuationKeys } from '../punctuation-inputs/punctuation-input-handler'; + +const TWO = 2; const isTrigger = ( - key: string, - validTriggers: TriggerCharacter[], -): key is TriggerCharacter => { - return key.length === 1 && validTriggers.includes(key as TriggerCharacter); + char: string, + validTriggers: TriggerCharacter[] | TriggerCharacter, +): char is TriggerCharacter => { + return ( + char.length === 1 && validTriggers.includes(char as TriggerCharacter) + ); }; -const shouldTrigger = (state: EditorState): boolean => { - const { $from } = state.selection; +const isWhitespace = (char: string): boolean => /\s/.test(char); - if ($from.pos === 1) { - return true; +// const hasSingleWhitespace = (text: string): boolean => { +// // Only one whitespace allowed between words within a trigger query +// return text.trim().split(/\s+/).length <= TWO; +// }; + +const isAtStartOfBlock = ($pos: ResolvedPos): boolean => { + return $pos.parentOffset === 0 || $pos.parentOffset === 1; +}; + +const getPreviousCharacter = ($from: ResolvedPos): string | null => { + if ($from.parentOffset === 0) { + return null; } - // Getting the position immediately before the current selection - const prevPos = $from.pos - 1; + const nodeBefore = $from.nodeBefore; - if (prevPos > 0) { - // allow trigger if the cursor is at the start of a new paragraph - if ($from.parentOffset === 0) { - return true; + if (!nodeBefore) { + return null; + } + + if (nodeBefore.isText) { + const text = nodeBefore.text; + if (text && text.length > 0) { + return text.charAt(text.length - 1); } + } else if (nodeBefore.type.name === 'hard_break') { + return '\n'; + } else if (nodeBefore.isInline) { + return '\uFFFC'; + } - const prevChar = state.doc.textBetween(prevPos, $from.pos); + // Default case for unsupported nodes + return null; +}; - return prevChar === ' ' || prevChar === '\n'; +export const findTriggerPosition = ( + state: EditorState, + triggerCharacters: TriggerCharacter[] | TriggerCharacter, +): { trigger: TriggerCharacter; position: number } | null => { + if (!triggerCharacters) { + return null; } - return false; + const { $from } = state.selection; + let position = $from.pos; + + while (position > 0) { + const currentChar = state.doc.textBetween(position - 1, position); + + if (isTrigger(currentChar, triggerCharacters)) { + const previousPosition = position - TWO; + let charBeforeTrigger: string | null = null; + if (previousPosition >= 0) { + charBeforeTrigger = state.doc.textBetween( + previousPosition, + previousPosition + 1, + ); + } + + if ( + (charBeforeTrigger && isWhitespace(charBeforeTrigger)) || + isAtStartOfBlock(state.doc.resolve(position - 1)) + ) { + return { + trigger: currentChar as TriggerCharacter, + position: position - 1, + }; + } + } + + position -= 1; + + // Stop if we reach the start of the block + const parentNodeStart = $from.start($from.depth); + if (position <= parentNodeStart) { + break; + } + } + + return null; }; -const stillHasTrigger = ( +const shouldTrigger = ( state: EditorState, - activeTrigger: string, - triggerPosition: number, - triggerLength: number, + triggerCharacters: TriggerCharacter[], ): boolean => { - const cursorPosition = state.selection.$from.pos; + const { $from } = state.selection; - if ( - cursorPosition < triggerPosition || - cursorPosition > triggerPosition + triggerLength + 1 - ) { + if (!state.selection.empty) { return false; } + if ($from.pos === 0 || isAtStartOfBlock($from)) { + return true; + } + + const prevChar = getPreviousCharacter($from); + return ( - state.doc.textBetween(triggerPosition, triggerPosition + 1) === - activeTrigger + prevChar === null || + isWhitespace(prevChar) || + (isTrigger(prevChar, triggerCharacters) && + (getPreviousCharacter(state.doc.resolve($from.pos - 1)) === null || + isWhitespace( + getPreviousCharacter(state.doc.resolve($from.pos - 1)), + ))) ); }; @@ -68,7 +141,7 @@ const getTriggerEventDetail = ( ): TriggerEventDetail => { return { trigger: trigger, - textEditor: inserterFactory(view, contentConverter), + textEditor: inserterFactory(view, contentConverter, trigger), value: value, }; }; @@ -139,10 +212,8 @@ export const createTriggerPlugin = ( let activeTrigger: TriggerCharacter | null = null; let triggerText = ''; let pluginView: EditorView | null = null; - let triggerPosition: number | null = null; const stopTrigger = () => { - triggerText = ''; sendTriggerEvent( 'triggerStop', pluginView, @@ -150,31 +221,21 @@ export const createTriggerPlugin = ( activeTrigger, triggerText, ); - triggerPosition = null; + triggerText = ''; activeTrigger = null; }; - const handleKeyDown = (_: EditorView, event: any) => { - if (event.key === 'Escape') { - stopTrigger(); - - return true; - } - - return false; - }; - const handleInput = (view: EditorView, event: any) => { const { state } = view; if ( event.inputType === 'insertText' && isTrigger(event.data, triggerCharacters) && - shouldTrigger(state) + shouldTrigger(state, triggerCharacters) ) { - activeTrigger = event.data; + activeTrigger = event.data as TriggerCharacter; + triggerText = ''; - triggerPosition = state.selection.$from.pos - triggerText.length; sendTriggerEvent( 'triggerStart', view, @@ -183,7 +244,21 @@ export const createTriggerPlugin = ( triggerText, ); - return false; + return true; + } + + return false; + }; + + const handleKeyDown = (view: EditorView, event: any) => { + if (event.key === ESCAPE) { + stopTrigger(); + + return true; + } + + if (punctuationKeys.includes(event.key) && !activeTrigger) { + handlePunctuationInput(view, event); } return false; @@ -194,36 +269,37 @@ export const createTriggerPlugin = ( oldState: EditorState, newState: EditorState, ): Transaction => { - if (!activeTrigger || !triggerPosition || !pluginView) { + if (!pluginView || !activeTrigger) { return; } - if ( - !stillHasTrigger( - newState, - activeTrigger, - triggerPosition, - triggerText.length, - ) - ) { + const foundTrigger = findTriggerPosition(newState, triggerCharacters); + const trigger: TriggerCharacter = foundTrigger?.trigger; + + if (!trigger) { + stopTrigger(); + return; } - const updatedText = processTransactions( - triggerText, - transactions, - oldState, - ); + if (trigger === activeTrigger) { - if (updatedText !== triggerText) { - triggerText = updatedText; - sendTriggerEvent( - 'triggerChange', - pluginView, - contentConverter, - activeTrigger, - triggerText.slice(1), + const updatedText = processTransactions( + triggerText, + transactions, + oldState, ); + + if (updatedText !== triggerText) { + triggerText = updatedText; + sendTriggerEvent( + 'triggerChange', + pluginView, + contentConverter, + activeTrigger, + triggerText, + ); + } } }; diff --git a/src/components/text-editor/prosemirror-adapter/plugins/trigger/inserter.ts b/src/components/text-editor/prosemirror-adapter/plugins/trigger/inserter.ts index be19795854..c768d28430 100644 --- a/src/components/text-editor/prosemirror-adapter/plugins/trigger/inserter.ts +++ b/src/components/text-editor/prosemirror-adapter/plugins/trigger/inserter.ts @@ -3,22 +3,27 @@ import { EditorView } from 'prosemirror-view'; import { TextEditor, TextEditorNode, + TriggerCharacter, } from 'src/components/text-editor/text-editor.types'; import { ContentTypeConverter } from '../../../utils/content-type-converter'; import { createHtmlInserter } from './create-html-inserter'; +import { findTriggerPosition } from './factory'; + +// const getTriggerStartPosition = (view: EditorView): number => { +// return view.state?.selection?.$from?.pos; +// }; export const inserterFactory = ( view: EditorView, contentConverter: ContentTypeConverter, + triggerCharacter: TriggerCharacter, ): TextEditor => { - const startPos = getTriggerStartPosition(view); - return { - insert: createNodeAndTextInserter(view, startPos), + insert: createNodeAndTextInserter(view, triggerCharacter), insertHtml: createHtmlInserter( view, contentConverter, - startPos, + triggerCharacter, dispatchTransaction, ), stopTrigger: () => stopTriggerTransaction(view), @@ -26,9 +31,10 @@ export const inserterFactory = ( }; const createNodeAndTextInserter = - (view: EditorView, startPos: number) => + (view: EditorView, triggerCharacter: TriggerCharacter) => (input: TextEditorNode | string): void => { const schema = view.state.schema; + const state = view.state; let node: Node; try { @@ -40,11 +46,12 @@ const createNodeAndTextInserter = return; } + const foundTrigger = findTriggerPosition(state, triggerCharacter); + const position = foundTrigger?.position; const spaceNode = schema.text(' '); - const fragment = schema.nodes.doc.create(null, [node, spaceNode]); - dispatchTransaction(view, startPos, fragment); + dispatchTransaction(view, position, fragment); }; const stopTriggerTransaction = (view: EditorView): void => { @@ -62,11 +69,12 @@ const dispatchTransaction = ( fragment: Fragment | Node, ): void => { const state = view.state; - const dispatch = view.dispatch; const fromPos = state.selection.$from.pos; - + const dispatch = view.dispatch; const transaction = state.tr.replaceWith(startPos, fromPos, fragment); + transaction.setMeta('stopTrigger', true); + dispatch(transaction); }; @@ -103,7 +111,3 @@ const getCustomNode = (name: string, schema: Schema): NodeType => { return customNode; }; - -const getTriggerStartPosition = (view: EditorView): number => { - return view.state?.selection?.$from?.pos; -}; diff --git a/src/util/keycodes.ts b/src/util/keycodes.ts index 01423fc1c8..b0112438ac 100644 --- a/src/util/keycodes.ts +++ b/src/util/keycodes.ts @@ -19,3 +19,14 @@ export const ARROW_UP = 'ArrowUp'; export const ARROW_DOWN = 'ArrowDown'; export const ARROW_LEFT = 'ArrowLeft'; export const ARROW_RIGHT = 'ArrowRight'; +export const PERIOD = '.'; +export const COMMA = ','; +export const SEMICOLON = ';'; +export const QUOTE = "'"; +export const DOUBLE_QUOTE = '"'; // Shift + ' +export const EXCLAMATION = '!'; +export const QUESTION = '?'; +export const HYPHEN = '-'; +export const UNDERSCORE = '_'; +export const LEFT_PARENTHESIS = '('; +export const RIGHT_PARENTHESIS = ')';