-
Notifications
You must be signed in to change notification settings - Fork 161
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Standalone Editor: Selection API step 2: Port selection API (#2229)
* 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
1 parent
6e5f30c
commit 9f4e4f0
Showing
47 changed files
with
2,703 additions
and
1,138 deletions.
There are no files selected for viewing
21 changes: 21 additions & 0 deletions
21
packages-content-model/roosterjs-content-model-core/lib/coreApi/focus.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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(); | ||
} | ||
} | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
250 changes: 213 additions & 37 deletions
250
packages-content-model/roosterjs-content-model-core/lib/coreApi/setDOMSelection.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
} |
Oops, something went wrong.