Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
81 commits
Select commit Hold shift + click to select a range
00f09f3
add repositionTouchSelection handler
Sep 12, 2025
4f5abe8
fix
Sep 12, 2025
e4cb103
define constant
Sep 12, 2025
db8fade
fix
Sep 12, 2025
d8ad64f
fix
Sep 12, 2025
71a1cde
Merge branch 'master' into u/nguyenvi/touch-selection
vinguyen12 Sep 12, 2025
9b98984
revert previous PR change
Sep 12, 2025
19e940c
add Touch Plugin
Sep 12, 2025
381dc9f
Merge branch 'u/nguyenvi/touch-selection' of https://github.com/micro…
Sep 12, 2025
3a962e6
add repositionTouchSelection in Touch Plugin
Sep 12, 2025
2043205
small fix
Sep 12, 2025
fc6463b
add touch plugin to demo page
Sep 12, 2025
a344f83
add comment
Sep 12, 2025
3ff602b
fix
Sep 15, 2025
bfb579e
export pointer event
Sep 15, 2025
aec3ef5
add touchplugin test
Sep 15, 2025
7799ead
Merge pull request #3151 from microsoft/u/nguyenvi/touch-selection
vinguyen12 Sep 16, 2025
47e64d6
reset pointerEvent to null after trigger action and settimeout to wai…
Sep 16, 2025
059454f
Remove tslint from recommended extension list (#3153)
JiuqingSong Sep 16, 2025
0893eab
default to end of the word if user tapped in the middle
Sep 16, 2025
5b1e9f4
add pointer event double click
Sep 16, 2025
4feed5e
handle selecting first space if selection is an open space with no wo…
Sep 16, 2025
d132ce7
remove console.log
Sep 16, 2025
db9743c
clearTimeout when dispose plugin
Sep 16, 2025
68460d1
address Copilot comment
Sep 16, 2025
6a64360
fix
Sep 16, 2025
d187575
Merge branch 'master' into u/nguyenvi/touch-double-tap
vinguyen12 Sep 16, 2025
08c0095
Merge pull request #3156 from microsoft/u/nguyenvi/touch-double-tap
vinguyen12 Sep 16, 2025
7760ed5
Address comment
isnine Sep 17, 2025
e509065
Lint code
isnine Sep 17, 2025
819e0d7
handle the entire flow of touch selection, not rely on browser
Sep 17, 2025
390309c
remove test
Sep 17, 2025
ada00b1
move timeout and prevent default to within touch plugin, use doublecl…
Sep 17, 2025
541359c
address Copilot comments
Sep 17, 2025
8bc9520
add comment
Sep 17, 2025
26daf4d
fix comment
Sep 17, 2025
ff0c04f
create const for regex
Sep 17, 2025
abafaec
resuse getNodePositionFromEvent
Sep 17, 2025
c14818c
remove unused change
Sep 17, 2025
af5251f
fix issue with selection in middle of word
Sep 17, 2025
3f56a19
fix
Sep 17, 2025
8948dcf
address comments
Sep 17, 2025
d219f23
Add comments
isnine Sep 18, 2025
e9e9654
Remove auto-capitalizatio for first character
isnine Sep 18, 2025
f1e0830
Remove Boolean() wrap
isnine Sep 18, 2025
c8ad563
Merge branch 'master' into master
JiuqingSong Sep 18, 2025
bacd67d
Merge pull request #3154 from isnine/master
haven2world Sep 18, 2025
5abd07a
Merge branch 'master' into u/nguyenvi/touch-reimplementation
vinguyen12 Sep 18, 2025
9319ed6
[Image Edit] Image with borders (#3155)
juliaroldi Sep 18, 2025
a15da10
Merge branch 'master' into u/nguyenvi/touch-reimplementation
vinguyen12 Sep 18, 2025
f31217b
fix
Sep 18, 2025
e4ceabb
Merge branch 'u/nguyenvi/touch-reimplementation' of https://github.co…
Sep 18, 2025
f2f3e47
remove word matching regex but using !space regex and !punctuation re…
Sep 18, 2025
bd21095
Merge pull request #3157 from microsoft/u/nguyenvi/touch-reimplementa…
vinguyen12 Sep 18, 2025
96a8413
fix selection range collapsed issue when pressing touch on a word
Sep 19, 2025
f8fd630
use sourceCapabilities.firesTouchEvents to recognize touch event
Sep 19, 2025
6df3ac5
fix comment
Sep 19, 2025
d511de2
Merge branch 'master' into u/nguyenvi/fix-touch-context-menu
vinguyen12 Sep 19, 2025
8c49efb
Merge branch 'u/nguyenvi/fix-touch-context-menu' of https://github.co…
Sep 19, 2025
3d13cef
revert previous change, use PointerEvent to detect touch
Sep 19, 2025
af38067
fix
Sep 19, 2025
84cac40
fix
Sep 19, 2025
6019772
Merge pull request #3159 from microsoft/u/nguyenvi/fix-touch-context-…
vinguyen12 Sep 23, 2025
0412f9a
horizontal line (#3161)
juliaroldi Sep 26, 2025
d94989c
Add role format handler (#3165)
angelusmcnally Oct 1, 2025
5496b37
only take control of touch in some specific scenario
Oct 2, 2025
f5b6b47
fix
Oct 2, 2025
bf9f3d1
move repositiationTouchSelection to Touch plugin
Oct 2, 2025
2ce5311
Support CloneIndependentRoot feature (#3170)
JiuqingSong Oct 2, 2025
04fb2b0
Improve table selection (#3167)
JiuqingSong Oct 2, 2025
a7d4f67
address comment
Oct 2, 2025
ece9e71
Merge branch 'master' into u/nguyenvi/fix-touch-1
vinguyen12 Oct 2, 2025
a082ae2
add return true
Oct 3, 2025
a64f122
directly update selection with content model
Oct 3, 2025
91116e8
Merge branch 'u/nguyenvi/fix-touch-1' of https://github.com/microsoft…
Oct 3, 2025
ca7aa4f
Merge branch 'master' into u/nguyenvi/version-bump-100325
Oct 3, 2025
cee2807
fix version
Oct 3, 2025
bb2a6de
fix 'segmentAndParagraphs' is never reassigned
Oct 3, 2025
3b08d7d
address comment
vinguyen12 Oct 3, 2025
ed3cfba
Merge pull request #3169 from microsoft/u/nguyenvi/fix-touch-1
vinguyen12 Oct 3, 2025
cd16286
Merge branch 'master' into u/nguyenvi/version-bump-100325
vinguyen12 Oct 3, 2025
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
Expand Up @@ -66,6 +66,7 @@ const initialState: OptionState = {
'PersistCache',
'HandleEnterKey',
'CustomCopyCut',
'CloneIndependentRoot',
]),
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export class ExperimentalFeatures extends React.Component<DefaultFormatProps, {}
{this.renderFeature('PersistCache')}
{this.renderFeature('HandleEnterKey')}
{this.renderFeature('CustomCopyCut')}
{this.renderFeature('CloneIndependentRoot')}
</>
);
}
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 @@ -559,6 +559,10 @@ class SelectionPlugin implements PluginWithState<SelectionPluginState> {

range.setStart(posStart.node, posStart.offset);
range.setEnd(posEnd.node, posEnd.offset);

if (range.toString() === '') {
range.collapse(true /* toStart */);
}
} else {
// Get deepest editable position in the cell
const { node, offset } = normalizePos(cell, nodeOffset);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,15 @@ import type {
DOMHelper,
} from 'roosterjs-content-model-types';

/**
* @internal
*/
export interface DOMHelperImplOption {
cloneIndependentRoot?: boolean;
}

class DOMHelperImpl implements DOMHelper {
constructor(private contentDiv: HTMLElement) {}
constructor(private contentDiv: HTMLElement, private options: DOMHelperImplOption) {}

queryElements(selector: string): HTMLElement[] {
return toArray(this.contentDiv.querySelectorAll(selector)) as HTMLElement[];
Expand Down Expand Up @@ -90,7 +97,14 @@ class DOMHelperImpl implements DOMHelper {
* Get a deep cloned root element
*/
getClonedRoot(): HTMLElement {
return this.contentDiv.cloneNode(true /*deep*/) as HTMLElement;
if (this.options.cloneIndependentRoot) {
const doc = this.contentDiv.ownerDocument.implementation.createHTMLDocument();
const clone = doc.importNode(this.contentDiv, true /*deep*/);

return clone;
} else {
return this.contentDiv.cloneNode(true /*deep*/) as HTMLElement;
}
}

/**
Expand Down Expand Up @@ -139,6 +153,9 @@ class DOMHelperImpl implements DOMHelper {
/**
* @internal Create new instance of DOMHelper
*/
export function createDOMHelper(contentDiv: HTMLElement): DOMHelper {
return new DOMHelperImpl(contentDiv);
export function createDOMHelper(
contentDiv: HTMLElement,
options: DOMHelperImplOption = {}
): DOMHelper {
return new DOMHelperImpl(contentDiv, options);
}
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,9 @@ export function createEditorCore(contentDiv: HTMLDivElement, options: EditorOpti
? options.trustedHTMLHandler
: createTrustedHTMLHandler(domCreator),
domCreator: domCreator,
domHelper: createDOMHelper(contentDiv),
domHelper: createDOMHelper(contentDiv, {
cloneIndependentRoot: options.experimentalFeatures?.includes('CloneIndependentRoot'),
}),
...getPluginState(corePlugins),
disposeErrorHandler: options.disposeErrorHandler,
onFixUpModel: options.onFixUpModel,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -365,6 +365,31 @@ describe('DOMHelperImpl', () => {
expect(result).toBe(mockedClone);
expect(cloneSpy).toHaveBeenCalledWith(true);
});

it('getClonedRoot, with CloneIndependentRoot on', () => {
const mockedClone = 'CLONE' as any;
const cloneSpy = jasmine.createSpy('cloneSpy').and.returnValue(mockedClone);
const importNodeSpy = jasmine.createSpy('importNodeSpy').and.returnValue(mockedClone);
const mockedDiv: HTMLElement = {
cloneNode: cloneSpy,
ownerDocument: {
implementation: {
createHTMLDocument: () => ({
importNode: importNodeSpy,
}),
},
},
} as any;
const domHelper = createDOMHelper(mockedDiv, {
cloneIndependentRoot: true,
});

const result = domHelper.getClonedRoot();

expect(result).toBe(mockedClone);
expect(cloneSpy).not.toHaveBeenCalled();
expect(importNodeSpy).toHaveBeenCalledWith(mockedDiv, true);
});
});

describe('getContainerFormat', () => {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import type { RoleFormat } from 'roosterjs-content-model-types';
import type { FormatHandler } from '../FormatHandler';

/**
* @internal
*/
export const roleFormatHandler: FormatHandler<RoleFormat> = {
parse: (format, element) => {
const role = element.getAttribute('role');

if (role) {
format.role = role;
}
},
apply: (format, element) => {
if (format.role) {
element.setAttribute('role', format.role);
}
},
};
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import { listLevelThreadFormatHandler } from './list/listLevelThreadFormatHandle
import { listStyleFormatHandler } from './list/listStyleFormatHandler';
import { marginFormatHandler } from './block/marginFormatHandler';
import { paddingFormatHandler } from './block/paddingFormatHandler';
import { roleFormatHandler } from './common/roleFormatHandler';
import { sizeFormatHandler } from './common/sizeFormatHandler';
import { strikeFormatHandler } from './segment/strikeFormatHandler';
import { superOrSubScriptFormatHandler } from './segment/superOrSubScriptFormatHandler';
Expand Down Expand Up @@ -79,6 +80,7 @@ const defaultFormatHandlerMap: FormatHandlers = {
listStyle: listStyleFormatHandler,
margin: marginFormatHandler,
padding: paddingFormatHandler,
role: roleFormatHandler,
size: sizeFormatHandler,
strike: strikeFormatHandler,
superOrSubScript: superOrSubScriptFormatHandler,
Expand Down Expand Up @@ -179,6 +181,7 @@ export const defaultFormatKeysPerCategory: {
'tableLayout',
'textColor',
'direction',
'role',
],
tableBorder: ['borderBox', 'tableSpacing'],
tableCellBorder: ['borderBox'],
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { createDomToModelContext } from '../../../lib/domToModel/context/createDomToModelContext';
import { createModelToDomContext } from '../../../lib/modelToDom/context/createModelToDomContext';
import { DisplayFormat, DomToModelContext, ModelToDomContext } from 'roosterjs-content-model-types';
import { displayFormatHandler } from '../../../lib/formatHandlers/block/displayFormatHandler';
import { DisplayFormat, DomToModelContext, ModelToDomContext } from 'roosterjs-content-model-types';

describe('displayFormatHandler.parse', () => {
let div: HTMLElement;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
import { DomToModelContext, ModelToDomContext, RoleFormat } from 'roosterjs-content-model-types';
import { roleFormatHandler } from '../../../lib/formatHandlers/common/roleFormatHandler';
import { createDomToModelContext } from '../../../lib/domToModel/context/createDomToModelContext';
import { createModelToDomContext } from '../../../lib/modelToDom/context/createModelToDomContext';

describe('roleFormatHandler.parse', () => {
let div: HTMLElement;
let format: RoleFormat;
let context: DomToModelContext;

beforeEach(() => {
div = document.createElement('div');
format = {};
context = createDomToModelContext();
});

it('No role', () => {
roleFormatHandler.parse(format, div, context, {});
expect(format).toEqual({});
});

it('has role', () => {
div.setAttribute('role', 'button');
roleFormatHandler.parse(format, div, context, {});
expect(format).toEqual({
role: 'button',
});
});

it('has role with different value', () => {
div.setAttribute('role', 'tabpanel');
roleFormatHandler.parse(format, div, context, {});
expect(format).toEqual({
role: 'tabpanel',
});
});

it('has empty role', () => {
div.setAttribute('role', '');
roleFormatHandler.parse(format, div, context, {});
expect(format).toEqual({});
});

it('table with role="table"', () => {
const table = document.createElement('table');
table.setAttribute('role', 'table');
roleFormatHandler.parse(format, table, context, {});
expect(format).toEqual({
role: 'table',
});
});

it('table with role="grid"', () => {
const table = document.createElement('table');
table.setAttribute('role', 'grid');
roleFormatHandler.parse(format, table, context, {});
expect(format).toEqual({
role: 'grid',
});
});

it('table with role="presentation"', () => {
const table = document.createElement('table');
table.setAttribute('role', 'presentation');
roleFormatHandler.parse(format, table, context, {});
expect(format).toEqual({
role: 'presentation',
});
});

it('table with role="treegrid"', () => {
const table = document.createElement('table');
table.setAttribute('role', 'treegrid');
roleFormatHandler.parse(format, table, context, {});
expect(format).toEqual({
role: 'treegrid',
});
});
});

describe('roleFormatHandler.apply', () => {
let div: HTMLElement;
let format: RoleFormat;
let context: ModelToDomContext;

beforeEach(() => {
div = document.createElement('div');
format = {};
context = createModelToDomContext();
});

it('No role', () => {
roleFormatHandler.apply(format, div, context);
expect(div.outerHTML).toBe('<div></div>');
});

it('Has role', () => {
format.role = 'button';
roleFormatHandler.apply(format, div, context);
expect(div.outerHTML).toBe('<div role="button"></div>');
});

it('Has role with different value', () => {
format.role = 'tabpanel';
roleFormatHandler.apply(format, div, context);
expect(div.outerHTML).toBe('<div role="tabpanel"></div>');
});

it('Role applied to different element types', () => {
format.role = 'navigation';
const nav = document.createElement('nav');
roleFormatHandler.apply(format, nav, context);
expect(nav.outerHTML).toBe('<nav role="navigation"></nav>');
});

it('Apply role="table" to table element', () => {
format.role = 'table';
const table = document.createElement('table');
roleFormatHandler.apply(format, table, context);
expect(table.outerHTML).toBe('<table role="table"></table>');
});

it('Apply role="grid" to table element', () => {
format.role = 'grid';
const table = document.createElement('table');
roleFormatHandler.apply(format, table, context);
expect(table.outerHTML).toBe('<table role="grid"></table>');
});

it('Apply role="presentation" to table element', () => {
format.role = 'presentation';
const table = document.createElement('table');
roleFormatHandler.apply(format, table, context);
expect(table.outerHTML).toBe('<table role="presentation"></table>');
});

it('Apply role="treegrid" to table element', () => {
format.role = 'treegrid';
const table = document.createElement('table');
roleFormatHandler.apply(format, table, context);
expect(table.outerHTML).toBe('<table role="treegrid"></table>');
});

it('Apply role to table cell elements', () => {
format.role = 'gridcell';
const td = document.createElement('td');
roleFormatHandler.apply(format, td, context);
expect(td.outerHTML).toBe('<td role="gridcell"></td>');
});

it('Apply role to table header elements', () => {
format.role = 'columnheader';
const th = document.createElement('th');
roleFormatHandler.apply(format, th, context);
expect(th.outerHTML).toBe('<th role="columnheader"></th>');
});

it('Apply role to table row elements', () => {
format.role = 'row';
const tr = document.createElement('tr');
roleFormatHandler.apply(format, tr, context);
expect(tr.outerHTML).toBe('<tr role="row"></tr>');
});
});
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type {
ContentModelDividerFormat,
ContentModelListItem,
FormatContentModelContext,
ReadonlyContentModelDocument,
ShallowMutableContentModelParagraph,
Expand All @@ -8,6 +9,7 @@ import {
addBlock,
createContentModelDocument,
createDivider,
getOperationalBlocks,
mergeModel,
} from 'roosterjs-content-model-dom';

Expand Down Expand Up @@ -128,6 +130,20 @@ export const checkAndInsertHorizontalLine = (
paragraph: ShallowMutableContentModelParagraph,
context: FormatContentModelContext
) => {
// Do not create horizontal lines inside a list
const blocks = getOperationalBlocks<ContentModelListItem>(
model,
['ListItem'],
['TableCell', 'FormatContainer']
);
if (
blocks[0] &&
blocks[0].block.blockType == 'BlockGroup' &&
blocks[0].block.blockGroupType == 'ListItem'
) {
return false;
}

const allText = paragraph.segments.reduce(
(acc, segment) => (segment.segmentType === 'Text' ? acc + segment.text : acc),
''
Expand Down
Loading
Loading