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 9, 2024
1 parent b823e7d commit 99a6615
Showing 3 changed files with 151 additions and 77 deletions.
Original file line number Diff line number Diff line change
@@ -1,25 +1,31 @@
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,
fragment: Fragment | Node,
) => void,
): ((input: string) => Promise<void>) => {
const schema = view.state.schema;
const state = view.state;

const foundTrigger = findTriggerPosition(state, triggerCharacter);
const position = foundTrigger?.position;

return async (input: string): Promise<void> => {
const container = document.createElement('span');
container.innerHTML = await contentConverter.parseAsHTML(input, schema);

const fragment = DOMParser.fromSchema(schema).parse(container).content;

dispatchTransaction(view, startPos, fragment);
dispatchTransaction(view, position, fragment);
};
};
Original file line number Diff line number Diff line change
@@ -7,56 +7,128 @@ 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';

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;
const charBeforeTrigger =
previousPosition >= 0

Check failure on line 78 in src/components/text-editor/prosemirror-adapter/plugins/trigger/factory.ts

GitHub Actions / Lint

Unexpected newline between test and consequent of ternary expression
? state.doc.textBetween(

Check failure on line 79 in src/components/text-editor/prosemirror-adapter/plugins/trigger/factory.ts

GitHub Actions / Lint

Unexpected newline between consequent and alternate of ternary expression
previousPosition,
previousPosition + 1,
)
: null;

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 +140,7 @@ const getTriggerEventDetail = (
): TriggerEventDetail => {
return {
trigger: trigger,
textEditor: inserterFactory(view, contentConverter),
textEditor: inserterFactory(view, contentConverter, trigger),
value: value,
};
};
@@ -139,23 +211,21 @@ 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') {
if (event.key === ESCAPE) {
stopTrigger();

return true;
@@ -170,11 +240,11 @@ export const createTriggerPlugin = (
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 +253,7 @@ export const createTriggerPlugin = (
triggerText,
);

return false;
return true;
}

return false;
@@ -194,36 +264,30 @@ export const createTriggerPlugin = (
oldState: EditorState,
newState: EditorState,
): Transaction => {
if (!activeTrigger || !triggerPosition || !pluginView) {
return;
}

if (
!stillHasTrigger(
newState,
activeTrigger,
triggerPosition,
triggerText.length,
)
) {
if (!pluginView) {
return;
}

const updatedText = processTransactions(
triggerText,
transactions,
oldState,
);
const foundTrigger = findTriggerPosition(newState, triggerCharacters);
const trigger: TriggerCharacter = foundTrigger?.trigger;

if (updatedText !== triggerText) {
triggerText = updatedText;
sendTriggerEvent(
'triggerChange',
pluginView,
contentConverter,
activeTrigger,
triggerText.slice(1),
if (activeTrigger && trigger === activeTrigger) {
const updatedText = processTransactions(
triggerText,
transactions,
oldState,
);

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

Original file line number Diff line number Diff line change
@@ -3,32 +3,38 @@ 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),
};
};

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

0 comments on commit 99a6615

Please sign in to comment.