Skip to content
2 changes: 2 additions & 0 deletions demo/scripts/controlsV2/mainPane/MainPane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ import {
ShortcutPlugin,
TableEditPlugin,
WatermarkPlugin,
TouchPlugin,
} from 'roosterjs-content-model-plugins';
import DOMPurify = require('dompurify');

Expand Down Expand Up @@ -559,6 +560,7 @@ export class MainPane extends React.Component<{}, MainPaneState> {
new HiddenPropertyPlugin({
undeletableLinkChecker: undeletableLinkChecker,
}),
pluginList.touch && new TouchPlugin(),
].filter(x => !!x);
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ const initialState: OptionState = {
hyperlink: true,
customReplace: true,
hiddenProperty: true,
touch: true,
},
defaultFormat: {
fontFamily: 'Calibri',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export interface BuildInPluginList {
imageEditPlugin: boolean;
customReplace: boolean;
hiddenProperty: boolean;
touch: boolean;
}

export interface OptionState {
Expand Down
1 change: 1 addition & 0 deletions demo/scripts/controlsV2/sidePane/editorOptions/Plugins.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -328,6 +328,7 @@ export class Plugins extends PluginsBase<keyof BuildInPluginList> {
</>
)}
{this.renderPluginItem('hiddenProperty', 'Hidden Property')}
{this.renderPluginItem('touch', 'Touch')}
</tbody>
</table>
);
Expand Down
1 change: 1 addition & 0 deletions packages/roosterjs-content-model-api/lib/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,3 +62,4 @@ export { matchLink } from './modelApi/link/matchLink';
export { promoteLink } from './modelApi/link/promoteLink';
export { getListAnnounceData } from './modelApi/list/getListAnnounceData';
export { queryContentModelBlocks } from './modelApi/common/queryContentModelBlocks';
export { adjustWordSelection } from './modelApi/selection/adjustWordSelection';
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,10 @@ import type {
} from 'roosterjs-content-model-types';

/**
* @internal
* Return specific word segment where the selection marker is located
* @param model The model document
* @param marker The selection marker
* @returns An array of segments that form the word where the selection marker is located
*/
export function adjustWordSelection(
model: ReadonlyContentModelDocument,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ class DOMEventPlugin implements PluginWithState<DOMEventPluginState> {
private editor: IEditor | null = null;
private disposer: (() => void) | null = null;
private state: DOMEventPluginState;
private pointerEvent: PointerEvent | null = null;

/**
* Construct a new instance of DOMEventPlugin
Expand Down Expand Up @@ -83,6 +84,9 @@ class DOMEventPlugin implements PluginWithState<DOMEventPluginState> {
// 4. Drag and Drop event
dragstart: { beforeDispatch: this.onDragStart },
drop: { beforeDispatch: this.onDrop },

// 5. Pointer event
pointerdown: { beforeDispatch: (event: PointerEvent) => this.onPointerDown(event) },
};

this.disposer = this.editor.attachDomEvent(<Record<string, DOMEventRecord>>eventHandlers);
Expand All @@ -107,6 +111,7 @@ class DOMEventPlugin implements PluginWithState<DOMEventPluginState> {
this.disposer?.();
this.disposer = null;
this.editor = null;
this.pointerEvent = null;
}

/**
Expand Down Expand Up @@ -197,6 +202,10 @@ class DOMEventPlugin implements PluginWithState<DOMEventPluginState> {
this.editor.triggerEvent('mouseDown', {
rawEvent: event,
});

if (event.defaultPrevented) {
this.pointerEvent = null;
}
}
};

Expand All @@ -209,6 +218,12 @@ class DOMEventPlugin implements PluginWithState<DOMEventPluginState> {
this.state.mouseDownX == rawEvent.pageX &&
this.state.mouseDownY == rawEvent.pageY,
});

if (this.pointerEvent) {
this.editor.triggerEvent('pointerUp', {
rawEvent: this.pointerEvent,
});
}
}
};

Expand All @@ -229,6 +244,12 @@ class DOMEventPlugin implements PluginWithState<DOMEventPluginState> {
this.editor.getDocument().removeEventListener('mouseup', this.onMouseUp, true);
}
}

private onPointerDown = (e: PointerEvent) => {
if (e.pointerType === 'touch' || e.pointerType === 'pen') {
this.pointerEvent = e;
}
};
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,6 @@ import type {
ParsedTable,
TableSelectionInfo,
TableCellCoordinate,
DOMEventRecord,
} from 'roosterjs-content-model-types';

const MouseLeftButton = 0;
Expand Down Expand Up @@ -49,7 +48,6 @@ class SelectionPlugin implements PluginWithState<SelectionPluginState> {
private isSafari = false;
private isMac = false;
private scrollTopCache: number = 0;
private pointerEvent: PointerEvent | null = null;

constructor(options: EditorOptions) {
this.state = {
Expand Down Expand Up @@ -101,15 +99,18 @@ class SelectionPlugin implements PluginWithState<SelectionPluginState> {
this.isSafari = !!env.isSafari;
this.isMac = !!env.isMac;
document.addEventListener('selectionchange', this.onSelectionChange);
const eventHandlers: Partial<
{ [P in keyof HTMLElementEventMap]: DOMEventRecord<HTMLElementEventMap[P]> }
> = {
focus: { beforeDispatch: this.onFocus },
drop: { beforeDispatch: this.onDrop },
blur: this.isSafari ? undefined : { beforeDispatch: this.onBlur },
pointerdown: { beforeDispatch: (event: PointerEvent) => this.onPointerDown(event) },
};
this.disposer = this.editor.attachDomEvent(<Record<string, DOMEventRecord>>eventHandlers);
if (this.isSafari) {
this.disposer = this.editor.attachDomEvent({
focus: { beforeDispatch: this.onFocus },
drop: { beforeDispatch: this.onDrop },
});
} else {
this.disposer = this.editor.attachDomEvent({
focus: { beforeDispatch: this.onFocus },
blur: { beforeDispatch: this.onBlur },
drop: { beforeDispatch: this.onDrop },
});
}
}

dispose() {
Expand Down Expand Up @@ -245,10 +246,6 @@ class SelectionPlugin implements PluginWithState<SelectionPluginState> {
},
});
}

if (rawEvent.defaultPrevented) {
this.pointerEvent = null;
}
}

private onMouseMove = (event: Event) => {
Expand Down Expand Up @@ -313,27 +310,12 @@ class SelectionPlugin implements PluginWithState<SelectionPluginState> {

private onMouseUp() {
this.detachMouseEvent();
if (this.pointerEvent && this.pointerEvent.pointerType === 'touch') {
requestAnimationFrame(() => {
if (this.editor && this.pointerEvent) {
// Handle touch selection here since the cursor position is updated
// Work-in-progress
}
this.pointerEvent = null;
});
}
}

private onDrop = () => {
this.detachMouseEvent();
};

private onPointerDown = (e: PointerEvent) => {
if (e.pointerType === 'touch' || e.pointerType === 'pen') {
this.pointerEvent = e;
}
};

private onKeyDown(editor: IEditor, rawEvent: KeyboardEvent) {
const key = rawEvent.key;
const selection = editor.getDOMSelection();
Expand Down
1 change: 1 addition & 0 deletions packages/roosterjs-content-model-plugins/lib/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,3 +42,4 @@ export { ImageEditPlugin } from './imageEdit/ImageEditPlugin';
export { ImageEditOptions } from './imageEdit/types/ImageEditOptions';
export { HiddenPropertyPlugin } from './hiddenProperty/HiddenPropertyPlugin';
export { HiddenPropertyOptions } from './hiddenProperty/HiddenPropertyOptions';
export { TouchPlugin } from './touch/TouchPlugin';
51 changes: 51 additions & 0 deletions packages/roosterjs-content-model-plugins/lib/touch/TouchPlugin.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import type { EditorPlugin, IEditor, PluginEvent } from 'roosterjs-content-model-types';
import { repositionTouchSelection } from './repositionTouchSelection';

/**
* Touch plugin to manage touch behaviors
*/
export class TouchPlugin implements EditorPlugin {
private editor: IEditor | null = null;

/**
* Create an instance of Touch plugin
*/
constructor() {}

/**
* Get a friendly name of this plugin
*/
getName() {
return 'Touch';
}

/**
* Initialize this plugin. This should only be called from Editor
* @param editor Editor instance
*/
initialize(editor: IEditor) {
this.editor = editor;
}

/**
* Dispose this plugin
*/
dispose() {
this.editor = null;
}

/**
* Handle events triggered from editor
* @param event PluginEvent object
*/
onPluginEvent(event: PluginEvent) {
if (!this.editor) {
return;
}
switch (event.eventType) {
case 'pointerUp':
repositionTouchSelection(this.editor);
break;
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import type { IEditor, ContentModelText } from 'roosterjs-content-model-types';
import { getSelectedSegmentsAndParagraphs } from 'roosterjs-content-model-dom';
import { adjustWordSelection } from 'roosterjs-content-model-api';

const MAX_TOUCH_MOVE_DISTANCE = 6; // the max number of offsets for the touch selection to move

/**
* @internal
* Touch selection, if tap within 6 characters of the beginning/end. Find the nearest edge of the word and move cursor there.
*/
export function repositionTouchSelection(editor: IEditor) {
editor.formatContentModel(
(model, _context) => {
let segmentAndParagraphs = getSelectedSegmentsAndParagraphs(
model,
false /*includingFormatHolder*/,
true /*includingEntity*/,
true /*mutate*/
);

const isCollapsedSelection =
segmentAndParagraphs.length >= 1 &&
segmentAndParagraphs.every(x => x[0].segmentType == 'SelectionMarker');

// 1. adjust selection to a word if selection is collapsed
if (isCollapsedSelection) {
const para = segmentAndParagraphs[0][1];
const path = segmentAndParagraphs[0][2];

segmentAndParagraphs = adjustWordSelection(
model,
segmentAndParagraphs[0][0]
).map(x => [x, para, path]);

// 2. Collect all text segments in selection
const segments: ContentModelText[] = [];
segmentAndParagraphs.forEach(item => {
if (item[0].segmentType == 'Text') {
segments.push(item[0]);
}
});

// If there are 3 text segment in the Word, selection is in middle of the word
// before selection marker + after selection marker
if (segments.length === 2) {
// 3. Calculate the offset to move cursor to the nearest edge of the word if within 6 characters
const leftCursorWordLength = segments[0].text.length;
const rightCursorWordLength = segments[1].text.length;
let movingOffset: number =
leftCursorWordLength > rightCursorWordLength
? rightCursorWordLength
: -leftCursorWordLength;
movingOffset =
Math.abs(movingOffset) > MAX_TOUCH_MOVE_DISTANCE ? 0 : movingOffset;

// 4. Move cursor to the calculated offset
const selection = editor.getDOMSelection();
if (selection?.type == 'range' && movingOffset !== 0) {
const selectedRange = selection.range;
const newRange = editor.getDocument().createRange();
newRange.setStart(
selectedRange.startContainer,
selectedRange.startOffset + movingOffset
);
newRange.setEnd(
selectedRange.endContainer,
selectedRange.endOffset + movingOffset
);
editor.setDOMSelection({
type: 'range',
range: newRange,
isReverted: false,
});
}
}
}
return false;
},
{
apiName: 'TouchSelection',
}
);
}
Loading
Loading