UNPKG

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
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); }