Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Handle punctuation inputs on custom inputs #3358

Draft
wants to merge 3 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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];

Check failure on line 10 in src/components/text-editor/prosemirror-adapter/plugins/punctuation-inputs/punctuation-input-handler.ts

View workflow job for this annotation

GitHub Actions / Lint

Replace `PERIOD,·COMMA,·SEMICOLON,·EXCLAMATION,·QUESTION` with `⏎····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;

Check failure on line 45 in src/components/text-editor/prosemirror-adapter/plugins/punctuation-inputs/punctuation-input-handler.ts

View workflow job for this annotation

GitHub Actions / Lint

Remove this useless assignment to variable "key"
if (event.shiftKey && shiftedPunctuationKeys[event.key]) {
key = shiftedPunctuationKeys[event.key];

Check failure on line 47 in src/components/text-editor/prosemirror-adapter/plugins/punctuation-inputs/punctuation-input-handler.ts

View workflow job for this annotation

GitHub Actions / Lint

Remove this useless assignment to variable "key"

Check failure on line 47 in src/components/text-editor/prosemirror-adapter/plugins/punctuation-inputs/punctuation-input-handler.ts

View workflow job for this annotation

GitHub Actions / Lint

'key' is assigned a value but never used. Allowed unused vars must match /^h$/u
}

if (prevChar === ' ') {
tr.delete(prevPos, $from.pos);
dispatch(tr);

return true;
}

return false;
};
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
Expand Up @@ -7,56 +7,129 @@
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';

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

View workflow job for this annotation

GitHub Actions / Lint

Replace `·handlePunctuationInput,·punctuationKeys·` with `⏎····handlePunctuationInput,⏎····punctuationKeys,⏎`

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

Expand All @@ -68,7 +141,7 @@
): TriggerEventDetail => {
return {
trigger: trigger,
textEditor: inserterFactory(view, contentConverter),
textEditor: inserterFactory(view, contentConverter, trigger),
value: value,
};
};
Expand Down Expand Up @@ -139,42 +212,30 @@
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 { 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,
Expand All @@ -183,7 +244,21 @@
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;
Expand All @@ -194,36 +269,37 @@
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) {

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

View workflow job for this annotation

GitHub Actions / Lint

Delete `⏎`

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

Expand Down
Loading
Loading