diff --git a/packages/app-mobile/components/NoteEditor/MarkdownToolbar/buttons/useHeaderButtons.ts b/packages/app-mobile/components/NoteEditor/MarkdownToolbar/buttons/useHeaderButtons.ts index 87ba65007de..4999d1f2570 100644 --- a/packages/app-mobile/components/NoteEditor/MarkdownToolbar/buttons/useHeaderButtons.ts +++ b/packages/app-mobile/components/NoteEditor/MarkdownToolbar/buttons/useHeaderButtons.ts @@ -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); }, diff --git a/packages/app-mobile/components/screens/SearchScreen/index.tsx b/packages/app-mobile/components/screens/SearchScreen/index.tsx index 4e365585d7a..18f29b63eb1 100644 --- a/packages/app-mobile/components/screens/SearchScreen/index.tsx +++ b/packages/app-mobile/components/screens/SearchScreen/index.tsx @@ -96,6 +96,7 @@ const SearchScreenComponent: React.FC = props => { { diff --git a/packages/editor/CodeMirror/markdown/markdownCommands.ts b/packages/editor/CodeMirror/markdown/markdownCommands.ts index a230d50c32d..3685aebae4d 100644 --- a/packages/editor/CodeMirror/markdown/markdownCommands.ts +++ b/packages/editor/CodeMirror/markdown/markdownCommands.ts @@ -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); @@ -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; @@ -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); @@ -105,7 +119,11 @@ 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: '$$', @@ -113,6 +131,7 @@ export const toggleMath: Command = (view: EditorView): boolean => { start: blockStartRegex, end: blockEndRegex, }, + accessibleName: view.state.phrase('Block math'), }); const changes = toggleRegionFormatGlobally(view.state, inlineRegionSpec, blockRegionSpec); @@ -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; @@ -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 ++) { @@ -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, @@ -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); @@ -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); @@ -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); @@ -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); diff --git a/packages/editor/CodeMirror/utils/formatting/RegionSpec.ts b/packages/editor/CodeMirror/utils/formatting/RegionSpec.ts index 1064f8663f3..b65fce27a1a 100644 --- a/packages/editor/CodeMirror/utils/formatting/RegionSpec.ts +++ b/packages/editor/CodeMirror/utils/formatting/RegionSpec.ts @@ -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; } @@ -47,6 +50,7 @@ export namespace RegionSpec { // eslint-disable-line no-redeclare nodeName: config.nodeName, template: { start: templateStart, end: templateEnd }, matcher, + accessibleName: config.accessibleName, }; }; diff --git a/packages/editor/CodeMirror/utils/formatting/findInlineMatch.test.ts b/packages/editor/CodeMirror/utils/formatting/findInlineMatch.test.ts index 1e1f49de435..e5e23c375b6 100644 --- a/packages/editor/CodeMirror/utils/formatting/findInlineMatch.test.ts +++ b/packages/editor/CodeMirror/utils/formatting/findInlineMatch.test.ts @@ -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', () => { @@ -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]); @@ -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', () => { diff --git a/packages/editor/CodeMirror/utils/formatting/toggleInlineRegionSurrounded.ts b/packages/editor/CodeMirror/utils/formatting/toggleInlineRegionSurrounded.ts index 992b4bf91f5..9a7ced68427 100644 --- a/packages/editor/CodeMirror/utils/formatting/toggleInlineRegionSurrounded.ts +++ b/packages/editor/CodeMirror/utils/formatting/toggleInlineRegionSurrounded.ts @@ -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]. // @@ -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); @@ -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. @@ -35,6 +38,8 @@ const toggleInlineRegionSurrounded = ( to: sel.to, insert: content, }); + + announcement = state.phrase('Removed $ markup', spec.accessibleName); } else { changes.push({ from: sel.from, @@ -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), + ], }; }; diff --git a/packages/editor/CodeMirror/utils/formatting/toggleInlineSelectionFormat.ts b/packages/editor/CodeMirror/utils/formatting/toggleInlineSelectionFormat.ts index 95aeba72b73..7ce11ab7951 100644 --- a/packages/editor/CodeMirror/utils/formatting/toggleInlineSelectionFormat.ts +++ b/packages/editor/CodeMirror/utils/formatting/toggleInlineSelectionFormat.ts @@ -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; diff --git a/packages/editor/CodeMirror/utils/formatting/toggleRegionFormatGlobally.test.ts b/packages/editor/CodeMirror/utils/formatting/toggleRegionFormatGlobally.test.ts index 1054fa83998..f8bca8d7bd0 100644 --- a/packages/editor/CodeMirror/utils/formatting/toggleRegionFormatGlobally.test.ts +++ b/packages/editor/CodeMirror/utils/formatting/toggleRegionFormatGlobally.test.ts @@ -12,10 +12,12 @@ describe('toggleRegionFormatGlobally', () => { const inlineCodeRegionSpec = RegionSpec.of({ template: '`', nodeName: 'InlineCode', + accessibleName: 'Inline code', }); const blockCodeRegionSpec: RegionSpec = { template: { start: '``````', end: '``````' }, matcher: { start: codeFenceRegex, end: codeFenceRegex }, + accessibleName: 'Block code', }; it('should create an empty inline region around the cursor, if given an empty selection', () => { diff --git a/packages/editor/CodeMirror/utils/formatting/toggleRegionFormatGlobally.ts b/packages/editor/CodeMirror/utils/formatting/toggleRegionFormatGlobally.ts index a310be60888..97894bc1d24 100644 --- a/packages/editor/CodeMirror/utils/formatting/toggleRegionFormatGlobally.ts +++ b/packages/editor/CodeMirror/utils/formatting/toggleRegionFormatGlobally.ts @@ -3,6 +3,7 @@ import { RegionSpec } from './RegionSpec'; import findInlineMatch, { MatchSide } from './findInlineMatch'; import growSelectionToNode from '../growSelectionToNode'; import toggleInlineSelectionFormat from './toggleInlineSelectionFormat'; +import { EditorView } from '@codemirror/view'; const blockQuoteStartLen = '> '.length; const blockQuoteRegex = /^>\s/; @@ -98,6 +99,11 @@ const toggleRegionFormatGlobally = ( ], range: EditorSelection.cursor(inlineStart + blockStart.length), + effects: [ + EditorView.announce.of( + state.phrase('Converted $1 to $2', inlineSpec.accessibleName, blockSpec.accessibleName), + ), + ], }; } @@ -146,6 +152,7 @@ const toggleRegionFormatGlobally = ( // Otherwise, we're toggling the block version const startMatch = blockSpec.matcher.start.exec(fromLineText); const stopMatch = blockSpec.matcher.end.exec(toLineText); + let announcement; if (startMatch && stopMatch) { // Get start and stop indices for the starting and ending matches const [fromMatchFrom, fromMatchTo] = getMatchEndPoints(startMatch, fromLine, inBlockQuote); @@ -164,6 +171,8 @@ const toggleRegionFormatGlobally = ( to: toMatchTo, }); charsAdded -= toMatchTo - toMatchFrom; + + announcement = state.phrase('Removed $ markup', blockSpec.accessibleName); } else { let insertBefore, insertAfter; @@ -185,15 +194,27 @@ const toggleRegionFormatGlobally = ( insert: insertAfter, }); charsAdded += insertBefore.length + insertAfter.length; + + announcement = state.phrase('Added $ markup', blockSpec.accessibleName); + } + + const range = EditorSelection.range( + fromLine.from, toLine.to + charsAdded, + ); + + if (!range.empty) { + announcement += `\n${state.phrase('Selected changed content')}`; } return { changes, // Selection should now encompass all lines that were changed. - range: EditorSelection.range( - fromLine.from, toLine.to + charsAdded, - ), + range, + + effects: [ + EditorView.announce.of(announcement), + ], }; }); diff --git a/packages/editor/CodeMirror/utils/formatting/toggleSelectedLinesStartWith.ts b/packages/editor/CodeMirror/utils/formatting/toggleSelectedLinesStartWith.ts index 2637312d45c..2ebbfb5de6e 100644 --- a/packages/editor/CodeMirror/utils/formatting/toggleSelectedLinesStartWith.ts +++ b/packages/editor/CodeMirror/utils/formatting/toggleSelectedLinesStartWith.ts @@ -1,19 +1,25 @@ import { EditorSelection, EditorState, Line, SelectionRange, TransactionSpec } from '@codemirror/state'; import growSelectionToNode from '../growSelectionToNode'; +import { EditorView } from '@codemirror/view'; -// Toggles whether all lines in the user's selection start with [regex]. -const toggleSelectedLinesStartWith = ( - state: EditorState, - regex: RegExp, - template: string, - matchEmpty: boolean, +interface FormattingSpec { + regex: RegExp; + template: string; + matchEmpty: boolean; // Determines where this formatting can begin on a line. // Defaults to after a block quote marker - lineContentStartRegex = /^>\s/, + lineContentStartRegex?: RegExp; + // Syntax name associated with what [regex] matches (e.g. FencedCode) + nodeName?: string; + + accessibleName: string; +} - // Name associated with what [regex] matches (e.g. FencedCode) - nodeName?: string, +// Toggles whether all lines in the user's selection start with [regex]. +const toggleSelectedLinesStartWith = ( + state: EditorState, + { regex, template, matchEmpty, lineContentStartRegex = /^>\s/, nodeName, accessibleName }: FormattingSpec, ): TransactionSpec => { const getLineContentStart = (line: Line): number => { const blockQuoteMatch = line.text.match(lineContentStartRegex); @@ -38,7 +44,7 @@ const toggleSelectedLinesStartWith = ( const doc = state.doc; const fromLine = doc.lineAt(sel.from); const toLine = doc.lineAt(sel.to); - let hasProp = false; + let alreadyHasFormatting = false; let charsAdded = 0; let charsAddedBefore = 0; @@ -51,7 +57,7 @@ const toggleSelectedLinesStartWith = ( // If already matching [regex], if (text.search(regex) === 0) { - hasProp = true; + alreadyHasFormatting = true; } lines.push(line); @@ -68,24 +74,23 @@ const toggleSelectedLinesStartWith = ( continue; } - if (hasProp) { + if (alreadyHasFormatting) { const match = text.match(regex); - if (!match) { - continue; - } - changes.push({ - from: contentFrom, - to: contentFrom + match[0].length, - insert: '', - }); - - const deletedSize = match[0].length; - if (contentFrom <= sel.from) { - // Math.min: Handles the case where some deleted characters are before sel.from - // and others are after. - charsAddedBefore -= Math.min(sel.from - contentFrom, deletedSize); + if (match) { + changes.push({ + from: contentFrom, + to: contentFrom + match[0].length, + insert: '', + }); + + const deletedSize = match[0].length; + if (contentFrom <= sel.from) { + // Math.min: Handles the case where some deleted characters are before sel.from + // and others are after. + charsAddedBefore -= Math.min(sel.from - contentFrom, deletedSize); + } + charsAdded -= deletedSize; } - charsAdded -= deletedSize; } else { changes.push({ from: contentFrom, @@ -109,11 +114,22 @@ const toggleSelectedLinesStartWith = ( newSel = EditorSelection.range(fromLine.from, toLine.to + charsAdded); } + let announcement = ''; + if (charsAdded > 0) { + announcement = state.phrase('Added $ markup', accessibleName); + } else if (charsAdded < 0) { + announcement = state.phrase('Removed $ markup', accessibleName); + } + return { changes, // Selection should now encompass all lines that were changed. range: newSel, + + effects: announcement ? [ + EditorView.announce.of(announcement), + ] : [], }; }); diff --git a/packages/editor/CodeMirror/utils/formatting/types.ts b/packages/editor/CodeMirror/utils/formatting/types.ts index f330f84b469..a64ec847376 100644 --- a/packages/editor/CodeMirror/utils/formatting/types.ts +++ b/packages/editor/CodeMirror/utils/formatting/types.ts @@ -1,5 +1,5 @@ -import { ChangeSpec, SelectionRange } from '@codemirror/state'; +import { ChangeSpec, SelectionRange, StateEffect } from '@codemirror/state'; // Specifies the update of a single selection region and its contents -export type SelectionUpdate = { range: SelectionRange; changes?: ChangeSpec }; +export type SelectionUpdate = { range: SelectionRange; changes?: ChangeSpec; effects?: StateEffect[] };