Skip to content

Commit

Permalink
Standalone Editor: Selection API step 2: Port selection API (#2229)
Browse files Browse the repository at this point in the history
* Standalone Editor: CreateStandaloneEditorCore

* Standalone Editor: Port LifecyclePlugin

* fix build

* fix test

* improve

* fix test

* Standalone Editor: Support keyboard input (init step)

* Standalone Editor: Port EntityPlugin

* improve

* Add test

* improve

* port selection api

* improve

* improve

* fix build

* fix build

* fix build

* improve

* Improve

* improve

* improve

* fix test

* improve

* add test

* remove unused code

* improve
  • Loading branch information
JiuqingSong authored Dec 6, 2023
1 parent 6e5f30c commit 9f4e4f0
Show file tree
Hide file tree
Showing 47 changed files with 2,703 additions and 1,138 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import type { Focus } from 'roosterjs-content-model-types';

/**
* @internal
* Focus to editor. If there is a cached selection range, use it as current selection
* @param core The StandaloneEditorCore object
*/
export const focus: Focus = core => {
if (!core.lifecycle.shadowEditFragment) {
const { api, selection } = core;

if (!api.hasFocus(core) && selection.selection?.type == 'range') {
api.setDOMSelection(core, selection.selection, true /*skipSelectionChangedEvent*/);
}

// fallback, in case editor still have no focus
if (!core.api.hasFocus(core)) {
core.contentDiv.focus();
}
}
};
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { SelectionRangeTypes } from 'roosterjs-editor-types';
import type {
DOMSelection,
GetDOMSelection,
Expand All @@ -9,33 +8,19 @@ import type {
* @internal
*/
export const getDOMSelection: GetDOMSelection = core => {
return core.cache.cachedSelection ?? getNewSelection(core);
return core.lifecycle.shadowEditFragment
? null
: core.selection.selection ?? getNewSelection(core);
};

function getNewSelection(core: StandaloneEditorCore): DOMSelection | null {
// TODO: Get rid of getSelectionRangeEx when we have standalone editor
const rangeEx = core.api.getSelectionRangeEx(core);
const selection = core.contentDiv.ownerDocument.defaultView?.getSelection();
const range = selection && selection.rangeCount > 0 ? selection.getRangeAt(0) : null;

if (rangeEx.type == SelectionRangeTypes.Normal && rangeEx.ranges[0]) {
return {
type: 'range',
range: rangeEx.ranges[0],
};
} else if (rangeEx.type == SelectionRangeTypes.TableSelection && rangeEx.coordinates) {
return {
type: 'table',
table: rangeEx.table,
firstColumn: rangeEx.coordinates.firstCell.x,
lastColumn: rangeEx.coordinates.lastCell.x,
firstRow: rangeEx.coordinates.firstCell.y,
lastRow: rangeEx.coordinates.lastCell.y,
};
} else if (rangeEx.type == SelectionRangeTypes.ImageSelection) {
return {
type: 'image',
image: rangeEx.image,
};
} else {
return null;
}
return range && core.contentDiv.contains(range.commonAncestorContainer)
? {
type: 'range',
range: range,
}
: null;
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { contains } from 'roosterjs-editor-dom';
import type { HasFocus } from 'roosterjs-content-model-types';

/**
Expand All @@ -9,7 +8,5 @@ import type { HasFocus } from 'roosterjs-content-model-types';
*/
export const hasFocus: HasFocus = core => {
const activeElement = core.contentDiv.ownerDocument.activeElement;
return !!(
activeElement && contains(core.contentDiv, activeElement, true /*treatSameNodeAsContain*/)
);
return !!(activeElement && core.contentDiv.contains(activeElement));
};
Original file line number Diff line number Diff line change
Expand Up @@ -29,12 +29,8 @@ export const setContentModel: SetContentModel = (core, model, option, onNodeCrea
if (!core.lifecycle.shadowEditFragment) {
core.cache.cachedSelection = selection || undefined;

if (selection) {
if (!option?.ignoreSelection) {
core.api.setDOMSelection(core, selection);
} else if (selection.type == 'range') {
core.selection.selectionRange = selection.range;
}
if (!option?.ignoreSelection && selection) {
core.api.setDOMSelection(core, selection);
}

core.cache.cachedModel = model;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,42 +1,218 @@
import { SelectionRangeTypes } from 'roosterjs-editor-types';
import type { SelectionRangeEx } from 'roosterjs-editor-types';
import type { SetDOMSelection } from 'roosterjs-content-model-types';
import { addRangeToSelection } from '../corePlugin/utils/addRangeToSelection';
import { isNodeOfType, toArray } from 'roosterjs-content-model-dom';
import { parseTableCells } from '../publicApi/domUtils/tableCellUtils';
import { PluginEventType } from 'roosterjs-editor-types';
import type {
ContentModelSelectionChangedEvent,
SetDOMSelection,
TableSelection,
} from 'roosterjs-content-model-types';

const IMAGE_ID = 'image';
const TABLE_ID = 'table';
const CONTENT_DIV_ID = 'contentDiv';
const DEFAULT_SELECTION_BORDER_COLOR = '#DB626C';
const TABLE_CSS_RULE = '{background-color: rgb(198,198,198) !important; caret-color: transparent}';
const MAX_RULE_SELECTOR_LENGTH = 9000;

/**
* @internal
*/
export const setDOMSelection: SetDOMSelection = (core, selection) => {
// TODO: Get rid of SelectionRangeEx in standalone editor
const rangeEx: SelectionRangeEx =
selection.type == 'range'
? {
type: SelectionRangeTypes.Normal,
ranges: [selection.range],
areAllCollapsed: selection.range.collapsed,
}
: selection.type == 'image'
? {
type: SelectionRangeTypes.ImageSelection,
ranges: [],
areAllCollapsed: false,
image: selection.image,
}
: {
type: SelectionRangeTypes.TableSelection,
ranges: [],
areAllCollapsed: false,
table: selection.table,
coordinates: {
firstCell: {
x: selection.firstColumn,
y: selection.firstRow,
},
lastCell: {
x: selection.lastColumn,
y: selection.lastRow,
},
},
};

core.api.select(core, rangeEx);
export const setDOMSelection: SetDOMSelection = (core, selection, skipSelectionChangedEvent) => {
// We are applying a new selection, so we don't need to apply cached selection in DOMEventPlugin.
// Set skipReselectOnFocus to skip this behavior
const skipReselectOnFocus = core.selection.skipReselectOnFocus;

const doc = core.contentDiv.ownerDocument;
const sheet = core.selection.selectionStyleNode?.sheet;

core.selection.skipReselectOnFocus = true;

try {
let selectionRules: string[] | undefined;
const rootSelector = '#' + addUniqueId(core.contentDiv, CONTENT_DIV_ID);

switch (selection?.type) {
case 'image':
const image = selection.image;

selectionRules = buildImageCSS(
rootSelector + ' #' + addUniqueId(image, IMAGE_ID),
core.selection.imageSelectionBorderColor
);
core.selection.selection = selection;

setRangeSelection(doc, image);
break;
case 'table':
const { table, firstColumn, firstRow } = selection;

selectionRules = buildTableCss(
rootSelector + ' #' + addUniqueId(table, TABLE_ID),
selection
);
core.selection.selection = selection;

setRangeSelection(doc, table.rows[firstRow]?.cells[firstColumn]);
break;
case 'range':
addRangeToSelection(doc, selection.range);

core.selection.selection = core.api.hasFocus(core) ? null : selection;
break;

default:
core.selection.selection = null;
break;
}

if (sheet) {
for (let i = sheet.cssRules.length - 1; i >= 0; i--) {
sheet.deleteRule(i);
}

if (selectionRules) {
for (let i = 0; i < selectionRules.length; i++) {
sheet.insertRule(selectionRules[i]);
}
}
}
} finally {
core.selection.skipReselectOnFocus = skipReselectOnFocus;
}

if (!skipSelectionChangedEvent) {
const eventData: ContentModelSelectionChangedEvent = {
eventType: PluginEventType.SelectionChanged,
newSelection: selection,
selectionRangeEx: null,
};

core.api.triggerEvent(core, eventData, true /*broadcast*/);
}
};

function buildImageCSS(rootSelector: string, borderColor?: string): string[] {
const color = borderColor || DEFAULT_SELECTION_BORDER_COLOR;

return [
`${rootSelector} {outline-style:auto!important;outline-color:${color}!important;caret-color:transparent;}`,
];
}

function buildTableCss(rootSelector: string, selection: TableSelection): string[] {
const { firstColumn, firstRow, lastColumn, lastRow } = selection;
const cells = parseTableCells(selection.table);
const isAllTableSelected =
firstRow == 0 &&
firstColumn == 0 &&
lastRow == cells.length - 1 &&
lastColumn == (cells[lastRow]?.length ?? 0) - 1;
const selectors = isAllTableSelected
? [rootSelector, `${rootSelector} *`]
: handleTableSelected(rootSelector, selection, cells);

const cssRules: string[] = [];
let currentRules: string = '';

for (let i = 0; i < selectors.length; i++) {
currentRules += (currentRules.length > 0 ? ',' : '') + selectors[i] || '';

if (
currentRules.length + (selectors[0]?.length || 0) > MAX_RULE_SELECTOR_LENGTH ||
i == selectors.length - 1
) {
cssRules.push(currentRules + ' ' + TABLE_CSS_RULE);
currentRules = '';
}
}

return cssRules;
}

function handleTableSelected(
rootSelector: string,
selection: TableSelection,
cells: (HTMLTableCellElement | null)[][]
) {
const { firstRow, firstColumn, lastRow, lastColumn, table } = selection;
const selectors: string[] = [];

// Get whether table has thead, tbody or tfoot, then Set the start and end of each of the table children,
// so we can build the selector according the element between the table and the row.
let cont = 0;
const indexes = toArray(table.childNodes)
.filter(
(node): node is HTMLTableSectionElement =>
['THEAD', 'TBODY', 'TFOOT'].indexOf(
isNodeOfType(node, 'ELEMENT_NODE') ? node.tagName : ''
) > -1
)
.map(node => {
const result = {
el: node.tagName,
start: cont,
end: node.childNodes.length + cont,
};

cont = result.end;
return result;
});

cells.forEach((row, rowIndex) => {
let tdCount = 0;

//Get current TBODY/THEAD/TFOOT
const midElement = indexes.filter(ind => ind.start <= rowIndex && ind.end > rowIndex)[0];
const middleElSelector = midElement ? '>' + midElement.el + '>' : '>';
const currentRow =
midElement && rowIndex + 1 >= midElement.start
? rowIndex + 1 - midElement.start
: rowIndex + 1;

for (let cellIndex = 0; cellIndex < row.length; cellIndex++) {
const cell = row[cellIndex];

if (cell) {
tdCount++;

if (
rowIndex >= firstRow &&
rowIndex <= lastRow &&
cellIndex >= firstColumn &&
cellIndex <= lastColumn
) {
const selector = `${rootSelector}${middleElSelector} tr:nth-child(${currentRow})>${cell.tagName}:nth-child(${tdCount})`;

selectors.push(selector, selector + ' *');
}
}
}
});

return selectors;
}

function setRangeSelection(doc: Document, element: HTMLElement | undefined) {
if (element) {
const range = doc.createRange();

range.selectNode(element);
range.collapse();

addRangeToSelection(doc, range);
}
}

function addUniqueId(element: HTMLElement, idPrefix: string): string {
idPrefix = element.id || idPrefix;

const doc = element.ownerDocument;
let i = 0;

while (!element.id || doc.querySelectorAll('#' + element.id).length > 1) {
element.id = idPrefix + '_' + i++;
}

return element.id;
}
Loading

0 comments on commit 9f4e4f0

Please sign in to comment.