Skip to content

Commit

Permalink
feat(text editor): more explicit checking of triggers and positions
Browse files Browse the repository at this point in the history
  • Loading branch information
john-traas committed Dec 11, 2024
1 parent 92fa95b commit ff4aaa8
Show file tree
Hide file tree
Showing 3 changed files with 155 additions and 86 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -7,56 +7,132 @@ import {
TriggerEventDetail,
} from 'src/components/text-editor/text-editor.types';
import { ContentTypeConverter } from '../../../utils/content-type-converter';
import { ResolvedPos } from 'prosemirror-model';
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 hasMoreThanSingleWhitespace = (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;
}

const nodeBefore = $from.nodeBefore;

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';
}

// Getting the position immediately before the current selection
const prevPos = $from.pos - 1;
// Default case for unsupported nodes
return null;
};

if (prevPos > 0) {
// allow trigger if the cursor is at the start of a new paragraph
if ($from.parentOffset === 0) {
return true;
export const findTriggerPosition = (
state: EditorState,
triggerCharacters: TriggerCharacter[] | TriggerCharacter,
): { trigger: TriggerCharacter; position: number } | null => {
if (!triggerCharacters) {
return null;
}

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,
};
}
}

const prevChar = state.doc.textBetween(prevPos, $from.pos);
position -= 1;

return prevChar === ' ' || prevChar === '\n';
// Stop if we reach the start of the block
const parentNodeStart = $from.start($from.depth);
if (position <= parentNodeStart) {
break;
}

// Don't return a trigger if there is more than one whitespace after the trigger
const textAfterPosition = state.doc.textBetween(position, $from.pos);
if (hasMoreThanSingleWhitespace(textAfterPosition)) {
return null;
}
}

return false;
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)),
)))
);
};

Expand All @@ -68,7 +144,7 @@ const getTriggerEventDetail = (
): TriggerEventDetail => {
return {
trigger: trigger,
textEditor: inserterFactory(view, contentConverter),
textEditor: inserterFactory(view, contentConverter, trigger),
value: value,
};
};
Expand Down Expand Up @@ -139,42 +215,34 @@ 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,
contentConverter,
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 handleTextInput = (
view: EditorView,
_from: number,
_to: number,
text: string,
): boolean => {
const { state } = view;

if (
event.inputType === 'insertText' &&
isTrigger(event.data, triggerCharacters) &&
shouldTrigger(state)
isTrigger(text, triggerCharacters) &&
shouldTrigger(state, triggerCharacters)
) {
activeTrigger = event.data;
activeTrigger = text as TriggerCharacter;
triggerText = '';
triggerPosition = state.selection.$from.pos - triggerText.length;

sendTriggerEvent(
'triggerStart',
view,
Expand All @@ -194,38 +262,36 @@ 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 (updatedText !== triggerText) {
triggerText = updatedText;
sendTriggerEvent(
'triggerChange',
pluginView,
contentConverter,
activeTrigger,
triggerText.slice(1),
if (trigger === activeTrigger) {
const updatedText = processTransactions(
triggerText,
transactions,
oldState,
);

if (updatedText !== triggerText) {
triggerText = updatedText;
sendTriggerEvent(
'triggerChange',
pluginView,
contentConverter,
activeTrigger,
triggerText.slice(1),
);
}
}
};

Expand All @@ -249,10 +315,7 @@ export const createTriggerPlugin = (
},
},
props: {
handleKeyDown: handleKeyDown,
handleDOMEvents: {
input: handleInput,
},
handleTextInput: handleTextInput,
},
appendTransaction: appendTransactions,
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,18 +3,25 @@ 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,
Expand All @@ -26,9 +33,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 {
Expand All @@ -40,11 +48,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 => {
Expand All @@ -62,11 +71,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);
};

Expand Down Expand Up @@ -103,7 +113,3 @@ const getCustomNode = (name: string, schema: Schema): NodeType => {

return customNode;
};

const getTriggerStartPosition = (view: EditorView): number => {
return view.state?.selection?.$from?.pos;
};
Original file line number Diff line number Diff line change
Expand Up @@ -351,11 +351,11 @@ export class ProsemirrorAdapter {
plugins: [
...exampleSetup({ schema: this.schema, menuBar: false }),
keymap(this.menuCommandFactory.buildKeymap()),
createLinkPlugin(this.handleNewLinkSelection),
createTriggerPlugin(
this.triggerCharacters,
this.contentConverter,
),
createLinkPlugin(this.handleNewLinkSelection),
createImageRemoverPlugin(),
createMenuStateTrackingPlugin(
editorMenuTypesArray,
Expand Down

0 comments on commit ff4aaa8

Please sign in to comment.