Skip to content

Commit

Permalink
Content Model: Support Ctrl+Delete/Backspace (Step 4 of 4): Finally s…
Browse files Browse the repository at this point in the history
…upport Ctrl+Delete/Backspace (microsoft#1827)

* Content Model: Do not hard code default format

* Content Model: Support Ctrl+Backspace (1/2)

* Content Model Ctrl+Delete step 2

* Step 3: Support Ctrl+Delete/Backspace

* Step 4

* split change

* add comment

* fix build

* improve

* fix test

* fix comment

---------

Co-authored-by: Julia Roldi <[email protected]>
  • Loading branch information
JiuqingSong and juliaroldi authored May 26, 2023
1 parent 1be0d27 commit 3431c34
Show file tree
Hide file tree
Showing 17 changed files with 851 additions and 40 deletions.
Binary file added assets/design-charts/BackwardDeleteWord.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added assets/design-charts/ForwardDeleteWord.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { createText } from '../../modelApi/creators/createText';
import { DomToModelContext } from '../../publicTypes/context/DomToModelContext';
import { ElementProcessor } from '../../publicTypes/context/ElementProcessor';
import { getRegularSelectionOffsets } from '../utils/getRegularSelectionOffsets';
import { hasSpacesOnly } from '../../domUtils/hasSpacesOnly';
import { hasSpacesOnly } from '../../domUtils/stringUtil';

/**
* @internal
Expand Down
10 changes: 0 additions & 10 deletions packages/roosterjs-content-model/lib/domUtils/hasSpacesOnly.ts

This file was deleted.

35 changes: 35 additions & 0 deletions packages/roosterjs-content-model/lib/domUtils/stringUtil.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
// A regex to match text that only has space and CR
// We use real space char " " (\u0020) here but not "\s" since "\s" will also match "&nbsp;" (\u00A0) which is something we need to keep
const SPACE_TEXT_REGEX = /^[\r\n\t ]*$/;

const SPACES_REGEX = /[\u2000\u2009\u200a\u200b\u202f\u205f\u3000\s\t\r\n]/gm;
const PUNCTUATIONS = '.,?!:"()[]\\/';

/**
* @internal
*/
export function isPunctuation(char: string) {
return PUNCTUATIONS.indexOf(char) >= 0;
}

/**
* @internal
*/
export function isSpace(char: string) {
const code = char?.charCodeAt(0) ?? 0;
return code == 160 || code == 32 || SPACES_REGEX.test(char);
}

/**
* @internal
*/
export function hasSpacesOnly(txt: string): boolean {
return SPACE_TEXT_REGEX.test(txt);
}

/**
* @internal
*/
export function normalizeText(txt: string, isForward: boolean): string {
return txt.replace(isForward ? /^\u0020+/ : /\u0020+$/, '\u00A0');
}
Original file line number Diff line number Diff line change
Expand Up @@ -76,3 +76,20 @@ export function handleKeyboardEventResult(
return true;
}
}

/**
* @internal
*/
export function shouldDeleteWord(rawEvent: KeyboardEvent, isMac: boolean) {
return (
(isMac && rawEvent.altKey && !rawEvent.metaKey) ||
(!isMac && rawEvent.ctrlKey && !rawEvent.altKey)
);
}

/**
* @internal
*/
export function shouldDeleteAllSegmentsBefore(rawEvent: KeyboardEvent) {
return rawEvent.metaKey && !rawEvent.altKey;
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { ContentModelSegment } from '../../publicTypes/segment/ContentModelSegment';
import { ContentModelText } from '../../publicTypes/segment/ContentModelText';
import { hasSpacesOnly } from '../../domUtils/hasSpacesOnly';
import { hasSpacesOnly } from '../../domUtils/stringUtil';

const SPACE = '\u0020';
const NONE_BREAK_SPACE = '\u00A0';
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { DeleteResult, DeleteSelectionStep } from '../utils/DeleteSelectionStep';
import { deleteSegment } from '../utils/deleteSegment';

/**
* @internal
*/
export const deleteAllSegmentBefore: DeleteSelectionStep = (context, onDeleteEntity) => {
const { paragraph, marker } = context.insertPoint;
const index = paragraph.segments.indexOf(marker);

for (let i = index - 1; i >= 0; i--) {
const segment = paragraph.segments[i];

segment.isSelected = true;

if (deleteSegment(paragraph, segment, onDeleteEntity)) {
context.deleteResult = DeleteResult.Range;
}
}
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
import { ContentModelParagraph } from '../../../publicTypes/block/ContentModelParagraph';
import { isPunctuation, isSpace, normalizeText } from '../../../domUtils/stringUtil';
import { isWhiteSpacePreserved } from '../../common/isWhiteSpacePreserved';
import {
DeleteResult,
DeleteSelectionContext,
DeleteSelectionStep,
} from '../utils/DeleteSelectionStep';

const enum DeleteWordState {
Start,
Punctuation,
Text,
NonText,
Space,
End,
}

interface CharInfo {
text: boolean;
space: boolean;
punctuation: boolean;
}

function getDeleteWordSelection(direction: 'forward' | 'backward'): DeleteSelectionStep {
return context => {
const { marker, paragraph } = context.insertPoint;
const startIndex = paragraph.segments.indexOf(marker);
const deleteNext = direction == 'forward';

let iterator = iterateSegments(paragraph, startIndex, deleteNext, context);
let curr = iterator.next();

for (let state = DeleteWordState.Start; state != DeleteWordState.End && !curr.done; ) {
const { punctuation, space, text } = curr.value;

// This is a state machine of how to delete a whole word together with space and punctuations.
// For a full state machine chart, see
// Forward delete: https://github.com/microsoft/roosterjs/blob/master/assets/design-charts/ForwardDeleteWord.png
// Backward delete: https://github.com/microsoft/roosterjs/blob/master/assets/design-charts/BackwardDeleteWord.png
switch (state) {
case DeleteWordState.Start:
state = space
? DeleteWordState.Space
: punctuation
? DeleteWordState.Punctuation
: DeleteWordState.Text;
curr = iterator.next(true /*delete*/);
break;

case DeleteWordState.Punctuation:
if (deleteNext && space) {
state = DeleteWordState.NonText;
curr = iterator.next(true /*delete*/);
} else if (punctuation) {
curr = iterator.next(true /*delete*/);
} else {
state = DeleteWordState.End;
}
break;

case DeleteWordState.Text:
if (deleteNext && space) {
state = DeleteWordState.NonText;
curr = iterator.next(true /*delete*/);
} else if (text) {
curr = iterator.next(true /*delete*/);
} else {
state = DeleteWordState.End;
}
break;

case DeleteWordState.NonText:
if (punctuation || !space) {
state = DeleteWordState.End;
} else {
curr = iterator.next(true /*delete*/);
}
break;

case DeleteWordState.Space:
if (space) {
curr = iterator.next(true /*delete*/);
} else if (punctuation) {
state = deleteNext ? DeleteWordState.NonText : DeleteWordState.Punctuation;
curr = iterator.next(true /*delete*/);
} else {
state = deleteNext ? DeleteWordState.End : DeleteWordState.Text;
}
break;
}
}
};
}

function* iterateSegments(
paragraph: ContentModelParagraph,
markerIndex: number,
forward: boolean,
context: DeleteSelectionContext
): Generator<CharInfo, null, boolean> {
const step = forward ? 1 : -1;
const segments = paragraph.segments;
const preserveWhiteSpace = isWhiteSpacePreserved(paragraph);

for (let i = markerIndex + step; i >= 0 && i < segments.length; i += step) {
const segment = segments[i];

switch (segment.segmentType) {
case 'Text':
for (
let j = forward ? 0 : segment.text.length - 1;
j >= 0 && j < segment.text.length;
j += step
) {
const c = segment.text[j];
const punctuation = isPunctuation(c);
const space = isSpace(c);
const text = !punctuation && !space;

if (yield { punctuation, space, text }) {
let newText = segment.text;

newText = newText.substring(0, j) + newText.substring(j + 1);

if (!preserveWhiteSpace) {
newText = normalizeText(newText, forward);
}

context.deleteResult = DeleteResult.Range;

if (newText) {
segment.text = newText;

if (step > 0) {
j -= step;
}
} else {
segments.splice(i, 1);

if (step > 0) {
i -= step;
}

break;
}
}
}
break;

case 'Image':
if (
yield { punctuation: true, space: false, text: false } // Treat image as punctuation since they have the same behavior.
) {
segments.splice(i, 1);

if (step > 0) {
i -= step;
}

context.deleteResult = DeleteResult.Range;
}
break;

case 'SelectionMarker':
break;

default:
return null;
}
}

return null;
}

/**
* @internal
*/
export const forwardDeleteWordSelection = getDeleteWordSelection('forward');

/**
* @internal
*/
export const backwardDeleteWordSelection = getDeleteWordSelection('backward');
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { createNormalizeSegmentContext, normalizeSegment } from '../../common/no
import { deleteSingleChar } from './deleteSingleChar';
import { EntityOperation } from 'roosterjs-editor-types';
import { isWhiteSpacePreserved } from '../../common/isWhiteSpacePreserved';
import { normalizeText } from '../../../domUtils/stringUtil';
import { OnDeleteEntity } from './DeleteSelectionStep';

/**
Expand Down Expand Up @@ -55,7 +56,7 @@ export function deleteSegment(
text = deleteSingleChar(text, isForward); // isForward ? text.substring(1) : text.substring(0, text.length - 1);

if (!preserveWhiteSpace) {
text = text.replace(isForward ? /^\u0020+/ : /\u0020+$/, '\u00A0');
text = normalizeText(text, isForward);
}

if (text == '') {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { ContentModelParagraph } from '../../publicTypes/block/ContentModelParag
import { ContentModelSegment } from '../../publicTypes/segment/ContentModelSegment';
import { ContentModelText } from '../../publicTypes/segment/ContentModelText';
import { createText } from '../creators/createText';
import { isPunctuation, isSpace } from '../../domUtils/stringUtil';
import { iterateSelections } from '../../modelApi/selection/iterateSelections';

/**
Expand Down Expand Up @@ -93,22 +94,23 @@ https://unicode.org/Public/UNIDATA/Scripts.txt
\u205f​ = MEDIUM MATHEMATICAL SPACE
\u3000 = IDEOGRAPHIC SPACE
*/
const SPACES_REGEX = /[\u2000\u2009\u200a\u200b\u202f\u205f\u3000\s\t\r\n]/gm;
const PUNCTUATION_REGEX = /[.,?!:"()\[\]\\/]/gu;

export function findDelimiter(segment: ContentModelText, moveRightward: boolean): number {
function findDelimiter(segment: ContentModelText, moveRightward: boolean): number {
const word = segment.text;
let offset = -1;
if (moveRightward) {
for (let i = 0; i < word.length; i++) {
if (isWordDelimiter(word[i])) {
const char = word[i];

if (isPunctuation(char) || isSpace(char)) {
offset = i;
break;
}
}
} else {
for (let i = word.length - 1; i >= 0; i--) {
if (isWordDelimiter(word[i])) {
const char = word[i];

if (isPunctuation(char) || isSpace(char)) {
offset = i + 1;
break;
}
Expand Down Expand Up @@ -142,16 +144,3 @@ function splitTextSegment(
textSegment.text = text.substring(found, text.length);
segments.splice(index, 0, newSegment);
}

function isWordDelimiter(char: string) {
return PUNCTUATION_REGEX.test(char) || isSpace(char);
}

function isSpace(char: string) {
return (
char &&
(char.toString() == String.fromCharCode(160) /* &nbsp | \u00A0*/ ||
char.toString() == String.fromCharCode(32) /* RegularSpace | \u0020*/ ||
SPACES_REGEX.test(char))
);
}
Loading

0 comments on commit 3431c34

Please sign in to comment.