Skip to content

Commit

Permalink
Editor: Announce changes made by Markup commands
Browse files Browse the repository at this point in the history
This commit announces changes (e.g. "added block code") made by most
Markdown commands. Currently:
- This is done on both mobile and desktop.
- The goal is to give screen reader users a brief description of how an
  action changed the document.
- It's possible that this change will be more annoying than useful (more
  content read by the screen reader?)

Related to laurent22#10795.
  • Loading branch information
personalizedrefrigerator committed Nov 22, 2024
1 parent f382ab3 commit a61c571
Show file tree
Hide file tree
Showing 12 changed files with 212 additions and 71 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,6 @@ const useHeaderButtons = ({ selectionState, editorControl, readOnly }: ButtonRow
description: _('Header %d', level),
active,

// We only call addHeaderButton 5 times and in the same order, so
// the linter error is safe to ignore.
// eslint-disable-next-line @seiyab/react-hooks/rules-of-hooks
onPress: () => {
editorControl.toggleHeaderLevel(level);
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ const SearchScreenComponent: React.FC<Props> = props => {
<TextInput
style={styles.searchTextInput}
autoFocus={props.visible}
accessibilityLabel={_('Search')}
underlineColorAndroid="#ffffff00"
onChangeText={setQuery}
value={query}
Expand Down
4 changes: 2 additions & 2 deletions packages/editor/CodeMirror/CodeMirrorControl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,8 +99,8 @@ export default class CodeMirrorControl extends CodeMirror5Emulation implements E
this.editor.dispatch(this.editor.state.replaceSelection(text), { userEvent });
}

public wrapSelections(start: string, end: string) {
const regionSpec = RegionSpec.of({ template: { start, end } });
public wrapSelections(start: string, end: string, accessibleName = 'region') {
const regionSpec = RegionSpec.of({ template: { start, end }, accessibleName });

this.editor.dispatch(
this.editor.state.changeByRange(range => {
Expand Down
150 changes: 119 additions & 31 deletions packages/editor/CodeMirror/markdown/markdownCommands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,11 @@ import toggleSelectedLinesStartWith from '../utils/formatting/toggleSelectedLine
const startingSpaceRegex = /^(\s*)/;

export const toggleBolded: Command = (view: EditorView): boolean => {
const spec = RegionSpec.of({ template: '**', nodeName: 'StrongEmphasis' });
const spec = RegionSpec.of({
template: '**',
nodeName: 'StrongEmphasis',
accessibleName: view.state.phrase('Bold'),
});
const changes = toggleInlineFormatGlobally(view.state, spec);

view.dispatch(changes);
Expand Down Expand Up @@ -78,8 +82,13 @@ export const toggleItalicized: Command = (view: EditorView): boolean => {

template: { start: '*', end: '*' },
matcher: { start: /[_*]/g, end: /[_*]/g },

accessibleName: view.state.phrase('Italic'),
});
view.dispatch(changes);

view.dispatch(
changes,
);
}

return true;
Expand All @@ -89,11 +98,16 @@ export const toggleItalicized: Command = (view: EditorView): boolean => {
// a block (fenced) code block.
export const toggleCode: Command = (view: EditorView): boolean => {
const codeFenceRegex = /^```\w*\s*$/;
const inlineRegionSpec = RegionSpec.of({ template: '`', nodeName: 'InlineCode' });
const inlineRegionSpec = RegionSpec.of({
template: '`',
nodeName: 'InlineCode',
accessibleName: view.state.phrase('Inline code'),
});
const blockRegionSpec: RegionSpec = {
nodeName: 'FencedCode',
template: { start: '```', end: '```' },
matcher: { start: codeFenceRegex, end: codeFenceRegex },
accessibleName: view.state.phrase('Block code'),
};

const changes = toggleRegionFormatGlobally(view.state, inlineRegionSpec, blockRegionSpec);
Expand All @@ -105,14 +119,19 @@ export const toggleCode: Command = (view: EditorView): boolean => {
export const toggleMath: Command = (view: EditorView): boolean => {
const blockStartRegex = /^\$\$/;
const blockEndRegex = /\$\$\s*$/;
const inlineRegionSpec = RegionSpec.of({ nodeName: 'InlineMath', template: '$' });
const inlineRegionSpec = RegionSpec.of({
nodeName: 'InlineMath',
template: '$',
accessibleName: view.state.phrase('Inline math'),
});
const blockRegionSpec = RegionSpec.of({
nodeName: 'BlockMath',
template: '$$',
matcher: {
start: blockStartRegex,
end: blockEndRegex,
},
accessibleName: view.state.phrase('Block math'),
});

const changes = toggleRegionFormatGlobally(view.state, inlineRegionSpec, blockRegionSpec);
Expand Down Expand Up @@ -161,6 +180,40 @@ export const toggleList = (listType: ListType): Command => {
return null;
};

const getAnnouncementForChange = (itemAddedCount: number, itemReplacedCount: number, itemRemovedCount: number) => {
if (itemAddedCount === 0 && itemRemovedCount === 0 && itemReplacedCount === 0) {
// No changes to announce
return '';
}

let listTypeDescription = '';
if (listType === ListType.CheckList) {
listTypeDescription = state.phrase('Checklist');
} else if (listType === ListType.OrderedList) {
listTypeDescription = state.phrase('Numbered list');
} else if (listType === ListType.UnorderedList) {
listTypeDescription = state.phrase('Bullet list');
} else {
const exhaustivenessCheck: never = listType;
throw new Error(`Unknown list type ${exhaustivenessCheck}`);
}

const announcement = [];
if (itemAddedCount) {
announcement.push(state.phrase('Added $1 $2 items', itemAddedCount, listTypeDescription));
}

if (itemReplacedCount) {
announcement.push(state.phrase('Replaced $1 items with $2 items', itemReplacedCount, listTypeDescription));
}

if (itemRemovedCount) {
announcement.push(state.phrase('Removed $1 $2 items', itemRemovedCount, listTypeDescription));
}

return announcement.join(' , ');
};

const changes: TransactionSpec = state.changeByRange((sel: SelectionRange) => {
const changes: ChangeSpec[] = [];
let containerType: ListType|null = null;
Expand Down Expand Up @@ -273,6 +326,11 @@ export const toggleList = (listType: ListType): Command => {
sel = EditorSelection.range(fromLine.from, toLine.to);
}

// Number of list items removed and replaced with non-list items
let numberOfItemsRemoved = 0;
let numberOfItemsReplaced = 0;
let numberOfItemsAdded = 0;

// Number of the item in the list (e.g. 2 for the 2nd item in the list)
let listItemCounter = 1;
for (let lineNum = fromLine.number; lineNum <= toLine.number; lineNum ++) {
Expand Down Expand Up @@ -327,6 +385,15 @@ export const toggleList = (listType: ListType): Command => {
replacementString = `${firstLineIndentation}- `;
}

// Store information required for accessibility announcements
if (replacementString.length === 0) {
numberOfItemsRemoved ++;
} else if (deleteTo > deleteFrom) {
numberOfItemsReplaced ++;
} else {
numberOfItemsAdded ++;
}

changes.push({
from: deleteFrom,
to: deleteTo,
Expand All @@ -348,9 +415,18 @@ export const toggleList = (listType: ListType): Command => {
);
}

const announcementText = getAnnouncementForChange(
numberOfItemsAdded, numberOfItemsReplaced, numberOfItemsRemoved,
);

return {
changes,
range: sel,
effects: [
announcementText ? (
EditorView.announce.of(announcementText)
) : [],
].flat(),
};
});
view.dispatch(changes);
Expand All @@ -371,29 +447,34 @@ export const toggleHeaderLevel = (level: number): Command => {
headerStr += '#';
}

const matchEmpty = true;
// Remove header formatting for any other level
let changes = toggleSelectedLinesStartWith(
view.state,
new RegExp(
// Check all numbers of #s lower than [level]
`${level - 1 >= 1 ? `(?:^[#]{1,${level - 1}}\\s)|` : ''
// Check all number of #s higher than [level]
}(?:^[#]{${level + 1},}\\s)`,
),
'',
matchEmpty,
{
regex: new RegExp(
// Check all numbers of #s lower than [level]
`${level - 1 >= 1 ? `(?:^[#]{1,${level - 1}}\\s)|` : ''
// Check all number of #s higher than [level]
}(?:^[#]{${level + 1},}\\s)`,
),
template: '',
matchEmpty: true,
accessibleName: view.state.phrase('Header'),
},
);
view.dispatch(changes);

// Set to the proper header level
changes = toggleSelectedLinesStartWith(
view.state,
// We want exactly [level] '#' characters.
new RegExp(`^[#]{${level}} `),
`${headerStr} `,
matchEmpty,
{
// We want exactly [level] '#' characters.
regex: new RegExp(`^[#]{${level}} `),
template: `${headerStr} `,
matchEmpty: true,
accessibleName: view.state.phrase('Header level $', level),
},
);
view.dispatch(changes);

Expand Down Expand Up @@ -428,17 +509,20 @@ export const insertHorizontalRule: Command = (view: EditorView) => {
// Prepends the given editor's indentUnit to all lines of the current selection
// and re-numbers modified ordered lists (if any).
export const increaseIndent: Command = (view: EditorView): boolean => {
const matchEmpty = true;
const matchNothing = /$ ^/;
const indentUnit = indentString(view.state, getIndentUnit(view.state));

const changes = toggleSelectedLinesStartWith(
view.state,
// Delete nothing
matchNothing,
// ...and thus always add indentUnit.
indentUnit,
matchEmpty,
{
// Delete nothing
regex: matchNothing,
// ...and thus always add indentUnit.
template: indentUnit,
matchEmpty: true,

accessibleName: view.state.phrase('Indent'),
},
);
view.dispatch(changes);

Expand Down Expand Up @@ -478,15 +562,19 @@ export const insertOrIncreaseIndent: Command = (view: EditorView): boolean => {
};

export const decreaseIndent: Command = (view: EditorView): boolean => {
const matchEmpty = true;
const changes = toggleSelectedLinesStartWith(
view.state,
// Assume indentation is either a tab or in units
// of n spaces.
new RegExp(`^(?:[\\t]|[ ]{1,${getIndentUnit(view.state)}})`),
// Don't add new text
'',
matchEmpty,
{
// Assume indentation is either a tab or in units
// of n spaces.
regex: new RegExp(`^(?:[\\t]|[ ]{1,${getIndentUnit(view.state)}})`),

// Don't add new text
template: '',
matchEmpty: true,

accessibleName: view.state.phrase('Indent'),
},
);

view.dispatch(changes);
Expand Down
4 changes: 4 additions & 0 deletions packages/editor/CodeMirror/utils/formatting/RegionSpec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,15 @@ export interface RegionSpec {

// How to identify the region
matcher: RegionMatchSpec;

accessibleName: string;
}

export namespace RegionSpec { // eslint-disable-line no-redeclare
interface RegionSpecConfig {
nodeName?: string;
template: string | { start: string; end: string };
accessibleName: string;
matcher?: RegionMatchSpec;
}

Expand All @@ -47,6 +50,7 @@ export namespace RegionSpec { // eslint-disable-line no-redeclare
nodeName: config.nodeName,
template: { start: templateStart, end: templateEnd },
matcher,
accessibleName: config.accessibleName,
};
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ describe('findInlineMatch', () => {

const boldSpec: RegionSpec = RegionSpec.of({
template: '**',
accessibleName: 'Bold',
});

it('matching a bolded region: should return the length of the match', () => {
Expand All @@ -33,6 +34,7 @@ describe('findInlineMatch', () => {
const spec: RegionSpec = {
template: { start: '*', end: '*' },
matcher: { start: /[*_]/g, end: /[*_]/g },
accessibleName: 'Italic',
};
const testString = 'This is a _test_';
const testDoc = DocumentText.of([testString]);
Expand All @@ -51,6 +53,7 @@ describe('findInlineMatch', () => {
start: /^\s*[-*]\s/g,
end: /$/g,
},
accessibleName: 'List',
};

it('matching a custom list: should not match a list if not within the selection', () => {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { Text as DocumentText, EditorSelection, SelectionRange } from '@codemirror/state';
import { EditorSelection, EditorState, SelectionRange } from '@codemirror/state';
import { RegionSpec } from './RegionSpec';
import findInlineMatch, { MatchSide } from './findInlineMatch';
import { SelectionUpdate } from './types';
import { EditorView } from '@codemirror/view';

// Toggles whether the given selection matches the inline region specified by [spec].
//
Expand All @@ -10,8 +11,9 @@ import { SelectionUpdate } from './types';
// If the selection is already surrounded by these characters, they are
// removed.
const toggleInlineRegionSurrounded = (
doc: DocumentText, sel: SelectionRange, spec: RegionSpec,
state: EditorState, sel: SelectionRange, spec: RegionSpec,
): SelectionUpdate => {
const doc = state.doc;
let content = doc.sliceString(sel.from, sel.to);
const startMatchLen = findInlineMatch(doc, spec, sel, MatchSide.Start);
const endMatchLen = findInlineMatch(doc, spec, sel, MatchSide.End);
Expand All @@ -22,6 +24,7 @@ const toggleInlineRegionSurrounded = (
const changes = [];
let finalSelStart = sel.from;
let finalSelEnd = sel.to;
let announcement;

if (startsWithBefore && endsWithAfter) {
// Remove the before and after.
Expand All @@ -35,6 +38,8 @@ const toggleInlineRegionSurrounded = (
to: sel.to,
insert: content,
});

announcement = state.phrase('Removed $ markup', spec.accessibleName);
} else {
changes.push({
from: sel.from,
Expand All @@ -55,11 +60,15 @@ const toggleInlineRegionSurrounded = (
finalSelStart = sel.from + spec.template.start.length;
finalSelEnd = finalSelStart;
}
announcement = state.phrase('Added $ markup', spec.accessibleName);
}

return {
changes,
range: EditorSelection.range(finalSelStart, finalSelEnd),
effects: [
EditorView.announce.of(announcement),
],
};
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ const toggleInlineSelectionFormat = (

// Grow the selection to encompass the entire node.
const newRange = growSelectionToNode(state, sel, spec.nodeName);
return toggleInlineRegionSurrounded(state.doc, newRange, spec);
return toggleInlineRegionSurrounded(state, newRange, spec);
};

export default toggleInlineSelectionFormat;
Loading

0 comments on commit a61c571

Please sign in to comment.