devexpress-richedit
Version:
DevExpress Rich Text Editor is an advanced word-processing tool designed for working with rich text documents.
327 lines (326 loc) • 16.7 kB
JavaScript
import { FixedInterval } from "@devexpress/utils/lib/intervals/fixed";
import { RichEditClientCommand } from "./commands/client-command";
import { LayoutPositionCreator } from "./layout-engine/layout-position-creator";
import { DocumentLayoutDetailsLevel } from "./layout/document-layout-details-level";
import { LayoutWordBounds } from "./word-bounds-engine/layout-word-bounds";
import { IntervalUtils } from "./utils/interval-utils";
import { NumberingType } from "./model/numbering-lists/numbering-list";
import { TablePositionIndexes } from "./model/tables/main-structures/table";
import { InlinePictureRun } from "./model/runs/inline-picture-run";
import { RichUtils } from "./model/rich-utils";
import { RunType } from "./model/runs/run-type";
import { SubDocumentPosition, TransformAction } from "./model/sub-document";
class TableInfo {
constructor(index, columnIndex, rowIndex) {
this.index = index;
this.columnIndex = columnIndex;
this.rowIndex = rowIndex;
}
}
export class ScreenReaderManager {
static { this._maxAnnouncedTextLength = 500; }
static { this._spaceSeparatorRegex = /^\p{Zs}$/u; }
constructor(control, liveRegion, resources) {
this._handlers = new Map([
[RichEditClientCommand.NextCharacter, (selection, lp) => this._getCurrentCharacterAnnouncement(selection, lp)],
[RichEditClientCommand.PreviousCharacter, (selection, lp) => this._getCurrentCharacterAnnouncement(selection, lp)],
[RichEditClientCommand.LineUp, (selection, lp) => this._getCurrentLineAnnouncement(selection, lp)],
[RichEditClientCommand.LineDown, (selection, lp) => this._getCurrentLineAnnouncement(selection, lp)],
[RichEditClientCommand.GoToNextWord, (selection, lp) => this._getCurrentWordAnnouncement(selection, lp)],
[RichEditClientCommand.GoToPrevWord, (selection, lp) => this._getCurrentWordAnnouncement(selection, lp)],
[RichEditClientCommand.GoToStartParagraph, (selection, lp) => this._getCurrentParagraphAnnouncement(selection, lp)],
[RichEditClientCommand.GoToEndParagraph, (selection, lp) => this._getCurrentParagraphAnnouncement(selection, lp)],
[RichEditClientCommand.NextPage, (selection, lp) => this._getCurrentLineAnnouncement(selection, lp)],
[RichEditClientCommand.PreviousPage, (selection, lp) => this._getCurrentLineAnnouncement(selection, lp)],
[RichEditClientCommand.GoToStartNextPage, (selection, lp) => this._getCurrentLineAnnouncement(selection, lp)],
[RichEditClientCommand.GoToStartPrevPage, (selection, lp) => this._getCurrentLineAnnouncement(selection, lp)],
]);
this._selectionIntervals = [];
this._commandId = null;
this._announceTimeoutId = null;
this._pageIndex = null;
this._tableInfo = null;
this._prevNavInHyperlink = false;
this._control = control;
this._liveRegion = liveRegion;
this._announcements = resources.announcements;
this._charNames = {
[RichUtils.specialCharacters.TabMark]: this._announcements.tab,
[RichUtils.specialCharacters.ParagraphMark]: this._announcements.carriageReturn,
[RichUtils.specialCharacters.LineBreak]: this._announcements.lineBreak,
[RichUtils.specialCharacters.PageBreak]: this._announcements.pageBreak,
[RichUtils.specialCharacters.SectionMark]: this._announcements.pageBreak,
};
}
_announce(...announcment) {
this._clearAnnouncement();
this._liveRegion.textContent = '';
this._announceTimeoutId = setTimeout(() => {
const textContent = announcment.join(' ');
this._liveRegion.textContent = textContent;
this._announceTimeoutId = null;
}, 50);
}
_clearAnnouncement() {
if (this._announceTimeoutId !== null) {
clearTimeout(this._announceTimeoutId);
this._announceTimeoutId = null;
}
}
beginExecute(commandId) {
this._commandId = commandId;
}
endExecute() {
this._commandId = null;
}
NotifySelectionChanged(selection) {
const currentIntervals = !selection.isCollapsed() ? [...selection.intervals].sort((x, y) => x.start - y.start) : [];
if (this._commandId != null) {
let announcments = [];
const handler = this._handlers.get(this._commandId);
if (handler) {
const pos = selection.intervals[0].start;
const subDocument = selection.activeSubDocument;
const lp = LayoutPositionCreator.createLightLayoutPosition(this._control.layout, subDocument, pos, selection.pageIndex, DocumentLayoutDetailsLevel.Box, selection.endOfLine, false);
const pagePrefix = this._getPageChangePrefix(lp.pageIndex);
if (pagePrefix)
announcments.push(pagePrefix);
const tableAnnouncements = this._processTable(selection.tableInfo);
if (tableAnnouncements.length > 0)
announcments.push(...tableAnnouncements);
const content = handler(selection, lp);
this._updateHyperlinkState(lp);
if (content)
announcments.push(content);
}
if ((!handler && currentIntervals.length > 0) || this._selectionIntervals.length > 0)
announcments.push(...this._processExtendSelectionCommand(currentIntervals));
if (announcments.length > 0)
this._announce(...announcments);
}
this._selectionIntervals = currentIntervals;
}
_processTable(tableInfo) {
const result = [];
if (!tableInfo.table) {
if (this._tableInfo) {
result.push(this._announcements.outOfTable);
this._tableInfo = null;
}
return result;
}
const extendedData = tableInfo.extendedData;
const tableCellGridInfos = tableInfo.gridInfoManager.tableCellGridInfos;
const cellGridInfo = tableInfo.gridInfoManager.gridInfosByTablePosition(new TablePositionIndexes(extendedData.firstRowInfo.rowIndex, extendedData.firstCellInfo.cellIndex));
const colIndex = cellGridInfo.getGridCellIndex();
const rowIndex = cellGridInfo.getStartRowIndex();
const currentTableInfo = new TableInfo(tableInfo.table.index, colIndex, rowIndex);
if (!this._tableInfo || this._tableInfo.index !== currentTableInfo.index) {
if (this._tableInfo) {
result.push(this._announcements.outOfTable);
this._tableInfo = null;
}
result.push(formatString(this._announcements.inTable, tableCellGridInfos.length, tableCellGridInfos[0].length));
}
if (!this._tableInfo || this._tableInfo.rowIndex !== currentTableInfo.rowIndex)
result.push(formatString(this._announcements.tableRow, rowIndex + 1));
if (!this._tableInfo || this._tableInfo.columnIndex !== currentTableInfo.columnIndex)
result.push(formatString(this._announcements.tableColumn, colIndex + 1));
this._tableInfo = currentTableInfo;
return result;
}
_processExtendSelectionCommand(intervals) {
let announcments = [];
if (this._commandId === RichEditClientCommand.SelectAll)
announcments.push(this._announcements.selectAll, "\n");
const subDocument = this._control.selection.activeSubDocument;
const selectedIntervals = IntervalUtils.subtractIntervals(intervals, this._selectionIntervals);
if (selectedIntervals.length > 0)
announcments.push(formatString(this._announcements.textSelected, this._getTextByIntervals(subDocument, selectedIntervals)));
const unselectedIntervals = IntervalUtils.subtractIntervals(this._selectionIntervals, intervals);
if (unselectedIntervals.length > 0)
announcments.push(formatString(this._announcements.textUnselected, this._getTextByIntervals(subDocument, unselectedIntervals)));
return announcments;
}
_getTextByIntervals(subDocument, intervals) {
const length = intervals.reduce((total, interval) => total + interval.length, 0);
if (length > ScreenReaderManager._maxAnnouncedTextLength)
return `${length} characters`;
return intervals.map(i => subDocument.getVisibleText(i, {
includeNumberedListMarks: true,
transform: this._tryResolveDocumentElements.bind(this)
})).join('');
}
_updateHyperlinkState(lp) {
this._prevNavInHyperlink = lp.box?.hyperlinkTip != null;
}
_getLinkTransitionPrefix(lp) {
const inHyperlink = lp.box?.hyperlinkTip != null;
const prevInHyperlink = this._prevNavInHyperlink;
if (inHyperlink && !prevInHyperlink)
return this._announcements.link;
if (!inHyperlink && prevInHyperlink)
return this._announcements.outOfLink;
return null;
}
_getCurrentCharacterAnnouncement(selection, lp) {
const pos = selection.intervals[0].start;
const subDocument = selection.activeSubDocument;
const info = subDocument.getRunAndIndexesByPosition(pos);
if (info.run instanceof InlinePictureRun)
return this._getInlinePictureText(info.run);
const isFieldCodeStartRun = info.run.getType() === RunType.FieldCodeStartRun;
const charInfo = isFieldCodeStartRun
? subDocument.getRunAndIndexesByPosition(lp.getLogPosition(DocumentLayoutDetailsLevel.Box))
: info;
const char = charInfo.getCurrentChar();
let content = this._getCharName(char);
return this._buildContextualAnnouncement(content, new SubDocumentPosition(subDocument, pos), lp);
}
_buildContextualAnnouncement(content, pos, lp) {
const paragraph = pos.subDocument.getParagraphByPosition(pos.position);
const linkPrefix = this._getLinkTransitionPrefix(lp);
if (linkPrefix)
content = `${linkPrefix} ${content}`;
const listContent = this._getListItemAnnouncement(paragraph, pos.position, lp, content);
return listContent ? listContent : content;
}
_getCurrentLineAnnouncement(selection, lp) {
const pos = selection.intervals[0].start;
const subDocument = selection.activeSubDocument;
const rowStart = lp.getLogPosition(DocumentLayoutDetailsLevel.Row);
const rowEnd = rowStart + lp.row.getLastBoxEndPositionInRow();
const intervalStart = rowStart;
const text = this._getVisibleText(subDocument, FixedInterval.fromPositions(intervalStart, rowEnd));
const listMarker = lp.row.numberingListBox
? this._resolveListMarker(subDocument.getParagraphByPosition(pos))
: null;
return listMarker ? `${listMarker} ${text}`.trim() : text;
}
_getCurrentWordAnnouncement(selection, lp) {
const pos = selection.intervals[0].start;
const subDocument = selection.activeSubDocument;
const wordEnd = LayoutWordBounds.getLayoutWordEndBound(this._control.layout, subDocument, selection, pos, false);
let text = subDocument.getVisibleText(FixedInterval.fromPositions(pos, wordEnd), {
transform: this._tryResolveDocumentElements.bind(this)
});
return this._buildContextualAnnouncement(text, new SubDocumentPosition(subDocument, pos), lp);
}
_getCurrentParagraphAnnouncement(selection, _lp) {
const pos = selection.intervals[0].start;
const subDocument = selection.activeSubDocument;
const paragraph = subDocument.getParagraphByPosition(pos);
const text = this._getVisibleText(subDocument, FixedInterval.fromPositions(paragraph.startLogPosition.value, paragraph.getEndPosition()), true);
return text;
}
_getHyperlinkField(fieldIndexes, _subDocument) {
return fieldIndexes
.map(index => _subDocument.fields[index])
.find(field => field.isHyperlinkField());
}
_onHyperlinkFieldStart(fieldIndexes, subDocument, pos) {
const field = this._getHyperlinkField(fieldIndexes, subDocument);
return field && pos === field.getResultStartPosition() ? ` ${this._announcements.link} ` : null;
}
_getVisibleText(subDocument, interval, includeNumberedListMarks = false) {
const result = subDocument.getVisibleText(interval, {
includeNumberedListMarks,
transform: (info, fieldIndexes) => {
const prefix = this._onHyperlinkFieldStart(fieldIndexes, subDocument, info.getAbsolutePosition());
const text = this._tryResolveDocumentElements(info);
if (prefix == null && text == null)
return null;
return {
text: (prefix ?? '') + (text ?? ''),
action: text != null ? TransformAction.Replace : TransformAction.InsertBefore
};
}
});
return result === RichUtils.specialCharacters.ParagraphMark
? this._announcements.blank
: result;
}
_tryResolveDocumentElements(info) {
return info.run.getType() === RunType.InlinePictureRun
? this._getInlinePictureText(info.run)
: this._tryResolveSpecialCharacters(info);
}
_tryResolveSpecialCharacters(info) {
const char = info.getCurrentChar();
if (char === RichUtils.specialCharacters.PageBreak || char === RichUtils.specialCharacters.SectionMark)
return ` ${this._announcements.pageBreak} `;
return null;
}
_getInlinePictureText(run) {
const nonVisualProps = run.info.nonVisualDrawingProperties;
return formatString(this._announcements.picture, nonVisualProps.description ?? nonVisualProps.name ?? '');
}
_getPageChangePrefix(pageIndex) {
if (this._pageIndex === null) {
this._pageIndex = pageIndex;
return '';
}
if (pageIndex !== this._pageIndex) {
this._pageIndex = pageIndex;
return formatString(this._announcements.page, pageIndex + 1);
}
return '';
}
_getCharName(char) {
if (char in this._charNames)
return this._charNames[char];
return ScreenReaderManager._spaceSeparatorRegex.test(char) ? this._announcements.space : char;
}
_resolveListMarker(paragraph) {
const levelIndex = paragraph.getListLevelIndex();
const levelType = paragraph.getNumberingList().getLevelType(levelIndex);
if (levelType === NumberingType.Bullet)
return this._announcements.bullet;
return paragraph.getNumberingListText();
}
_getListItemAnnouncement(paragraph, pos, lp, content) {
if (!paragraph.isInList() || pos !== paragraph.startLogPosition.value || !lp?.row?.numberingListBox)
return null;
const level = paragraph.getListLevelIndex() + 1;
const listMarker = this._resolveListMarker(paragraph);
return `${formatString(this._announcements.listItem, level, listMarker)} ${content}`;
}
dispose() {
this._clearAnnouncement();
this._liveRegion.remove();
}
}
const stringFormatterCache = {};
function createFormatter(template) {
const tokens = [];
let lastIndex = 0;
template.replace(/\{(\d+)\}/g, (match, p1, offset) => {
if (lastIndex < offset)
tokens.push(template.slice(lastIndex, offset));
tokens.push(Number(p1));
lastIndex = offset + match.length;
return match;
});
if (lastIndex < template.length)
tokens.push(template.slice(lastIndex));
return (args) => {
let result = '';
for (const token of tokens) {
if (typeof token === 'string')
result += token;
else {
const value = args[token];
result += value ?? '';
}
}
return result;
};
}
function formatString(template, ...args) {
let formatter = stringFormatterCache[template];
if (!formatter) {
formatter = createFormatter(template);
stringFormatterCache[template] = formatter;
}
return formatter(args);
}