diff --git a/.eslintignore b/.eslintignore index 6f5e677389f..e80ed0b1dd3 100644 --- a/.eslintignore +++ b/.eslintignore @@ -857,24 +857,66 @@ packages/app-mobile/components/NoteBodyViewer/hooks/useSource.js.map packages/app-mobile/components/NoteEditor/CodeMirror/CodeMirror.d.ts packages/app-mobile/components/NoteEditor/CodeMirror/CodeMirror.js packages/app-mobile/components/NoteEditor/CodeMirror/CodeMirror.js.map +packages/app-mobile/components/NoteEditor/CodeMirror/createEditor.d.ts +packages/app-mobile/components/NoteEditor/CodeMirror/createEditor.js +packages/app-mobile/components/NoteEditor/CodeMirror/createEditor.js.map packages/app-mobile/components/NoteEditor/CodeMirror/decoratorExtension.d.ts packages/app-mobile/components/NoteEditor/CodeMirror/decoratorExtension.js packages/app-mobile/components/NoteEditor/CodeMirror/decoratorExtension.js.map +packages/app-mobile/components/NoteEditor/CodeMirror/markdownCommands.bulletedVsChecklist.test.d.ts +packages/app-mobile/components/NoteEditor/CodeMirror/markdownCommands.bulletedVsChecklist.test.js +packages/app-mobile/components/NoteEditor/CodeMirror/markdownCommands.bulletedVsChecklist.test.js.map +packages/app-mobile/components/NoteEditor/CodeMirror/markdownCommands.d.ts +packages/app-mobile/components/NoteEditor/CodeMirror/markdownCommands.js +packages/app-mobile/components/NoteEditor/CodeMirror/markdownCommands.js.map +packages/app-mobile/components/NoteEditor/CodeMirror/markdownCommands.test.d.ts +packages/app-mobile/components/NoteEditor/CodeMirror/markdownCommands.test.js +packages/app-mobile/components/NoteEditor/CodeMirror/markdownCommands.test.js.map +packages/app-mobile/components/NoteEditor/CodeMirror/markdownCommands.toggleTwice.test.d.ts +packages/app-mobile/components/NoteEditor/CodeMirror/markdownCommands.toggleTwice.test.js +packages/app-mobile/components/NoteEditor/CodeMirror/markdownCommands.toggleTwice.test.js.map +packages/app-mobile/components/NoteEditor/CodeMirror/markdownCommands.togglingLists.test.d.ts +packages/app-mobile/components/NoteEditor/CodeMirror/markdownCommands.togglingLists.test.js +packages/app-mobile/components/NoteEditor/CodeMirror/markdownCommands.togglingLists.test.js.map packages/app-mobile/components/NoteEditor/CodeMirror/markdownMathParser.d.ts packages/app-mobile/components/NoteEditor/CodeMirror/markdownMathParser.js packages/app-mobile/components/NoteEditor/CodeMirror/markdownMathParser.js.map packages/app-mobile/components/NoteEditor/CodeMirror/markdownMathParser.test.d.ts packages/app-mobile/components/NoteEditor/CodeMirror/markdownMathParser.test.js packages/app-mobile/components/NoteEditor/CodeMirror/markdownMathParser.test.js.map +packages/app-mobile/components/NoteEditor/CodeMirror/markdownReformatter.d.ts +packages/app-mobile/components/NoteEditor/CodeMirror/markdownReformatter.js +packages/app-mobile/components/NoteEditor/CodeMirror/markdownReformatter.js.map +packages/app-mobile/components/NoteEditor/CodeMirror/markdownReformatter.test.d.ts +packages/app-mobile/components/NoteEditor/CodeMirror/markdownReformatter.test.js +packages/app-mobile/components/NoteEditor/CodeMirror/markdownReformatter.test.js.map packages/app-mobile/components/NoteEditor/CodeMirror/syntaxHighlightingLanguages.d.ts packages/app-mobile/components/NoteEditor/CodeMirror/syntaxHighlightingLanguages.js packages/app-mobile/components/NoteEditor/CodeMirror/syntaxHighlightingLanguages.js.map packages/app-mobile/components/NoteEditor/CodeMirror/theme.d.ts packages/app-mobile/components/NoteEditor/CodeMirror/theme.js packages/app-mobile/components/NoteEditor/CodeMirror/theme.js.map +packages/app-mobile/components/NoteEditor/CodeMirror/types.d.ts +packages/app-mobile/components/NoteEditor/CodeMirror/types.js +packages/app-mobile/components/NoteEditor/CodeMirror/types.js.map +packages/app-mobile/components/NoteEditor/CodeMirror/webviewLogger.d.ts +packages/app-mobile/components/NoteEditor/CodeMirror/webviewLogger.js +packages/app-mobile/components/NoteEditor/CodeMirror/webviewLogger.js.map +packages/app-mobile/components/NoteEditor/EditLinkDialog.d.ts +packages/app-mobile/components/NoteEditor/EditLinkDialog.js +packages/app-mobile/components/NoteEditor/EditLinkDialog.js.map packages/app-mobile/components/NoteEditor/NoteEditor.d.ts packages/app-mobile/components/NoteEditor/NoteEditor.js packages/app-mobile/components/NoteEditor/NoteEditor.js.map +packages/app-mobile/components/NoteEditor/SearchPanel.d.ts +packages/app-mobile/components/NoteEditor/SearchPanel.js +packages/app-mobile/components/NoteEditor/SearchPanel.js.map +packages/app-mobile/components/NoteEditor/SelectionFormatting.d.ts +packages/app-mobile/components/NoteEditor/SelectionFormatting.js +packages/app-mobile/components/NoteEditor/SelectionFormatting.js.map +packages/app-mobile/components/NoteEditor/types.d.ts +packages/app-mobile/components/NoteEditor/types.js +packages/app-mobile/components/NoteEditor/types.js.map packages/app-mobile/components/SelectDateTimeDialog.d.ts packages/app-mobile/components/SelectDateTimeDialog.js packages/app-mobile/components/SelectDateTimeDialog.js.map diff --git a/.gitignore b/.gitignore index 88a24a4f4cc..39b00874304 100644 --- a/.gitignore +++ b/.gitignore @@ -846,24 +846,66 @@ packages/app-mobile/components/NoteBodyViewer/hooks/useSource.js.map packages/app-mobile/components/NoteEditor/CodeMirror/CodeMirror.d.ts packages/app-mobile/components/NoteEditor/CodeMirror/CodeMirror.js packages/app-mobile/components/NoteEditor/CodeMirror/CodeMirror.js.map +packages/app-mobile/components/NoteEditor/CodeMirror/createEditor.d.ts +packages/app-mobile/components/NoteEditor/CodeMirror/createEditor.js +packages/app-mobile/components/NoteEditor/CodeMirror/createEditor.js.map packages/app-mobile/components/NoteEditor/CodeMirror/decoratorExtension.d.ts packages/app-mobile/components/NoteEditor/CodeMirror/decoratorExtension.js packages/app-mobile/components/NoteEditor/CodeMirror/decoratorExtension.js.map +packages/app-mobile/components/NoteEditor/CodeMirror/markdownCommands.bulletedVsChecklist.test.d.ts +packages/app-mobile/components/NoteEditor/CodeMirror/markdownCommands.bulletedVsChecklist.test.js +packages/app-mobile/components/NoteEditor/CodeMirror/markdownCommands.bulletedVsChecklist.test.js.map +packages/app-mobile/components/NoteEditor/CodeMirror/markdownCommands.d.ts +packages/app-mobile/components/NoteEditor/CodeMirror/markdownCommands.js +packages/app-mobile/components/NoteEditor/CodeMirror/markdownCommands.js.map +packages/app-mobile/components/NoteEditor/CodeMirror/markdownCommands.test.d.ts +packages/app-mobile/components/NoteEditor/CodeMirror/markdownCommands.test.js +packages/app-mobile/components/NoteEditor/CodeMirror/markdownCommands.test.js.map +packages/app-mobile/components/NoteEditor/CodeMirror/markdownCommands.toggleTwice.test.d.ts +packages/app-mobile/components/NoteEditor/CodeMirror/markdownCommands.toggleTwice.test.js +packages/app-mobile/components/NoteEditor/CodeMirror/markdownCommands.toggleTwice.test.js.map +packages/app-mobile/components/NoteEditor/CodeMirror/markdownCommands.togglingLists.test.d.ts +packages/app-mobile/components/NoteEditor/CodeMirror/markdownCommands.togglingLists.test.js +packages/app-mobile/components/NoteEditor/CodeMirror/markdownCommands.togglingLists.test.js.map packages/app-mobile/components/NoteEditor/CodeMirror/markdownMathParser.d.ts packages/app-mobile/components/NoteEditor/CodeMirror/markdownMathParser.js packages/app-mobile/components/NoteEditor/CodeMirror/markdownMathParser.js.map packages/app-mobile/components/NoteEditor/CodeMirror/markdownMathParser.test.d.ts packages/app-mobile/components/NoteEditor/CodeMirror/markdownMathParser.test.js packages/app-mobile/components/NoteEditor/CodeMirror/markdownMathParser.test.js.map +packages/app-mobile/components/NoteEditor/CodeMirror/markdownReformatter.d.ts +packages/app-mobile/components/NoteEditor/CodeMirror/markdownReformatter.js +packages/app-mobile/components/NoteEditor/CodeMirror/markdownReformatter.js.map +packages/app-mobile/components/NoteEditor/CodeMirror/markdownReformatter.test.d.ts +packages/app-mobile/components/NoteEditor/CodeMirror/markdownReformatter.test.js +packages/app-mobile/components/NoteEditor/CodeMirror/markdownReformatter.test.js.map packages/app-mobile/components/NoteEditor/CodeMirror/syntaxHighlightingLanguages.d.ts packages/app-mobile/components/NoteEditor/CodeMirror/syntaxHighlightingLanguages.js packages/app-mobile/components/NoteEditor/CodeMirror/syntaxHighlightingLanguages.js.map packages/app-mobile/components/NoteEditor/CodeMirror/theme.d.ts packages/app-mobile/components/NoteEditor/CodeMirror/theme.js packages/app-mobile/components/NoteEditor/CodeMirror/theme.js.map +packages/app-mobile/components/NoteEditor/CodeMirror/types.d.ts +packages/app-mobile/components/NoteEditor/CodeMirror/types.js +packages/app-mobile/components/NoteEditor/CodeMirror/types.js.map +packages/app-mobile/components/NoteEditor/CodeMirror/webviewLogger.d.ts +packages/app-mobile/components/NoteEditor/CodeMirror/webviewLogger.js +packages/app-mobile/components/NoteEditor/CodeMirror/webviewLogger.js.map +packages/app-mobile/components/NoteEditor/EditLinkDialog.d.ts +packages/app-mobile/components/NoteEditor/EditLinkDialog.js +packages/app-mobile/components/NoteEditor/EditLinkDialog.js.map packages/app-mobile/components/NoteEditor/NoteEditor.d.ts packages/app-mobile/components/NoteEditor/NoteEditor.js packages/app-mobile/components/NoteEditor/NoteEditor.js.map +packages/app-mobile/components/NoteEditor/SearchPanel.d.ts +packages/app-mobile/components/NoteEditor/SearchPanel.js +packages/app-mobile/components/NoteEditor/SearchPanel.js.map +packages/app-mobile/components/NoteEditor/SelectionFormatting.d.ts +packages/app-mobile/components/NoteEditor/SelectionFormatting.js +packages/app-mobile/components/NoteEditor/SelectionFormatting.js.map +packages/app-mobile/components/NoteEditor/types.d.ts +packages/app-mobile/components/NoteEditor/types.js +packages/app-mobile/components/NoteEditor/types.js.map packages/app-mobile/components/SelectDateTimeDialog.d.ts packages/app-mobile/components/SelectDateTimeDialog.js packages/app-mobile/components/SelectDateTimeDialog.js.map diff --git a/packages/app-mobile/components/NoteEditor/CodeMirror/CodeMirror.ts b/packages/app-mobile/components/NoteEditor/CodeMirror/CodeMirror.ts index 1c4712dd1b7..d766f2d9134 100644 --- a/packages/app-mobile/components/NoteEditor/CodeMirror/CodeMirror.ts +++ b/packages/app-mobile/components/NoteEditor/CodeMirror/CodeMirror.ts @@ -9,48 +9,52 @@ // wrapper to access CodeMirror functionalities. Anything else should be done // from NoteEditor.tsx. +import { MarkdownMathExtension } from './markdownMathParser'; import createTheme from './theme'; import decoratorExtension from './decoratorExtension'; +import syntaxHighlightingLanguages from './syntaxHighlightingLanguages'; + import { EditorState } from '@codemirror/state'; import { markdown } from '@codemirror/lang-markdown'; -import { highlightSelectionMatches, search } from '@codemirror/search'; -import { EditorView, drawSelection, highlightSpecialChars, ViewUpdate } from '@codemirror/view'; -import { undo, redo, history, undoDepth, redoDepth } from '@codemirror/commands'; - -import { keymap } from '@codemirror/view'; -import { indentOnInput } from '@codemirror/language'; -import { searchKeymap } from '@codemirror/search'; -import { historyKeymap, defaultKeymap } from '@codemirror/commands'; -import { MarkdownMathExtension } from './markdownMathParser'; import { GFM as GitHubFlavoredMarkdownExtension } from '@lezer/markdown'; -import syntaxHighlightingLanguages from './syntaxHighlightingLanguages'; +import { indentOnInput, indentUnit, syntaxTree } from '@codemirror/language'; +import { + openSearchPanel, closeSearchPanel, SearchQuery, setSearchQuery, getSearchQuery, + highlightSelectionMatches, search, findNext, findPrevious, replaceAll, replaceNext, +} from '@codemirror/search'; -interface CodeMirrorResult { - editor: EditorView; - undo: Function; - redo: Function; - select(anchor: number, head: number): void; - scrollSelectionIntoView(): void; - insertText(text: string): void; -} +import { + EditorView, drawSelection, highlightSpecialChars, ViewUpdate, Command, +} from '@codemirror/view'; +import { undo, redo, history, undoDepth, redoDepth, indentWithTab } from '@codemirror/commands'; -function postMessage(name: string, data: any) { - (window as any).ReactNativeWebView.postMessage(JSON.stringify({ - data, - name, - })); -} +import { keymap, KeyBinding } from '@codemirror/view'; +import { searchKeymap } from '@codemirror/search'; +import { historyKeymap, defaultKeymap } from '@codemirror/commands'; -function logMessage(...msg: any[]) { - postMessage('onLog', { value: msg }); -} +import { CodeMirrorControl } from './types'; +import { EditorSettings, ListType, SearchState } from '../types'; +import { ChangeEvent, SelectionChangeEvent, Selection } from '../types'; +import SelectionFormatting from '../SelectionFormatting'; +import { logMessage, postMessage } from './webviewLogger'; +import { + decreaseIndent, increaseIndent, + toggleBolded, toggleCode, + toggleHeaderLevel, toggleItalicized, + toggleList, toggleMath, updateLink, +} from './markdownCommands'; -export function initCodeMirror(parentElement: any, initialText: string, theme: any): CodeMirrorResult { +export function initCodeMirror( + parentElement: any, initialText: string, settings: EditorSettings +): CodeMirrorControl { logMessage('Initializing CodeMirror...'); + const theme = settings.themeData; + + let searchVisible = false; let schedulePostUndoRedoDepthChangeId_: any = 0; - function schedulePostUndoRedoDepthChange(editor: EditorView, doItNow: boolean = false) { + const schedulePostUndoRedoDepthChange = (editor: EditorView, doItNow: boolean = false) => { if (schedulePostUndoRedoDepthChangeId_) { if (doItNow) { clearTimeout(schedulePostUndoRedoDepthChangeId_); @@ -66,7 +70,193 @@ export function initCodeMirror(parentElement: any, initialText: string, theme: a redoDepth: redoDepth(editor.state), }); }, doItNow ? 0 : 1000); - } + }; + + const notifyDocChanged = (viewUpdate: ViewUpdate) => { + if (viewUpdate.docChanged) { + const event: ChangeEvent = { + value: editor.state.doc.toString(), + }; + + postMessage('onChange', event); + schedulePostUndoRedoDepthChange(editor); + } + }; + + const notifyLinkEditRequest = () => { + postMessage('onRequestLinkEdit', null); + }; + + const showSearchDialog = () => { + const query = getSearchQuery(editor.state); + const searchState: SearchState = { + searchText: query.search, + replaceText: query.replace, + useRegex: query.regexp, + caseSensitive: query.caseSensitive, + dialogVisible: true, + }; + + postMessage('onRequestShowSearch', searchState); + searchVisible = true; + }; + + const hideSearchDialog = () => { + postMessage('onRequestHideSearch', null); + searchVisible = false; + }; + + const notifySelectionChange = (viewUpdate: ViewUpdate) => { + if (!viewUpdate.state.selection.eq(viewUpdate.startState.selection)) { + const mainRange = viewUpdate.state.selection.main; + const selection: Selection = { + start: mainRange.from, + end: mainRange.to, + }; + const event: SelectionChangeEvent = { + selection, + }; + postMessage('onSelectionChange', event); + } + }; + + const notifySelectionFormattingChange = (viewUpdate?: ViewUpdate) => { + // If we can't determine the previous formatting, post the update regardless + if (!viewUpdate) { + const formatting = computeSelectionFormatting(editor.state); + postMessage('onSelectionFormattingChange', formatting.toJSON()); + } else if (viewUpdate.docChanged || !viewUpdate.state.selection.eq(viewUpdate.startState.selection)) { + // Only post the update if something changed + const oldFormatting = computeSelectionFormatting(viewUpdate.startState); + const newFormatting = computeSelectionFormatting(viewUpdate.state); + + if (!oldFormatting.eq(newFormatting)) { + postMessage('onSelectionFormattingChange', newFormatting.toJSON()); + } + } + }; + + const computeSelectionFormatting = (state: EditorState): SelectionFormatting => { + const range = state.selection.main; + const formatting: SelectionFormatting = new SelectionFormatting(); + formatting.selectedText = state.doc.sliceString(range.from, range.to); + formatting.spellChecking = editor.contentDOM.spellcheck; + + const parseLinkData = (nodeText: string) => { + const linkMatch = nodeText.match(/\[([^\]]*)\]\(([^)]*)\)/); + + if (linkMatch) { + return { + linkText: linkMatch[1], + linkURL: linkMatch[2], + }; + } + + return null; + }; + + // Find nodes that overlap/are within the selected region + syntaxTree(state).iterate({ + from: range.from, to: range.to, + enter: node => { + // Checklists don't have a specific containing node. As such, + // we're in a checklist if we've selected a 'Task' node. + if (node.name === 'Task') { + formatting.inChecklist = true; + } + + // Only handle notes that contain the entire range. + if (node.from > range.from || node.to < range.to) { + return; + } + // Lazily compute the node's text + const nodeText = () => state.doc.sliceString(node.from, node.to); + + switch (node.name) { + case 'StrongEmphasis': + formatting.bolded = true; + break; + case 'Emphasis': + formatting.italicized = true; + break; + case 'ListItem': + formatting.listLevel += 1; + break; + case 'BulletList': + formatting.inUnorderedList = true; + break; + case 'OrderedList': + formatting.inOrderedList = true; + break; + case 'TaskList': + formatting.inChecklist = true; + break; + case 'InlineCode': + case 'FencedCode': + formatting.inCode = true; + formatting.unspellCheckableRegion = true; + break; + case 'InlineMath': + case 'BlockMath': + formatting.inMath = true; + formatting.unspellCheckableRegion = true; + break; + case 'ATXHeading1': + formatting.headerLevel = 1; + break; + case 'ATXHeading2': + formatting.headerLevel = 2; + break; + case 'ATXHeading3': + formatting.headerLevel = 3; + break; + case 'ATXHeading4': + formatting.headerLevel = 4; + break; + case 'ATXHeading5': + formatting.headerLevel = 5; + break; + case 'URL': + formatting.inLink = true; + formatting.linkData.linkURL = nodeText(); + formatting.unspellCheckableRegion = true; + break; + case 'Link': + formatting.inLink = true; + formatting.linkData = parseLinkData(nodeText()); + break; + } + }, + }); + + // The markdown parser marks checklists as unordered lists. Ensure + // that they aren't marked as such. + if (formatting.inChecklist) { + if (!formatting.inUnorderedList) { + // Even if the selection contains a Task, because an unordered list node + // must contain a valid Task node, we're only in a checklist if we're also in + // an unordered list. + formatting.inChecklist = false; + } else { + formatting.inUnorderedList = false; + } + } + + if (formatting.unspellCheckableRegion) { + formatting.spellChecking = false; + } + + return formatting; + }; + + // Returns a keyboard command that returns true (so accepts the keybind) + const keyCommand = (key: string, run: Command): KeyBinding => { + return { + key, + run, + preventDefault: true, + }; + }; const editor = new EditorView({ state: EditorState.create({ @@ -75,37 +265,73 @@ export function initCodeMirror(parentElement: any, initialText: string, theme: a extensions: [ markdown({ extensions: [ - MarkdownMathExtension, GitHubFlavoredMarkdownExtension, + + // Don't highlight KaTeX if the user disabled it + settings.katexEnabled ? MarkdownMathExtension : [], ], codeLanguages: syntaxHighlightingLanguages, }), ...createTheme(theme), history(), - search(), + search({ + createPanel(_: EditorView) { + return { + // The actual search dialog is implemented with react native, + // use a dummy element. + dom: document.createElement('div'), + mount() { + showSearchDialog(); + }, + destroy() { + hideSearchDialog(); + }, + }; + }, + }), drawSelection(), highlightSpecialChars(), highlightSelectionMatches(), indentOnInput(), + // By default, indent with four spaces + indentUnit.of(' '), + EditorState.tabSize.of(4), + + // Apply styles to entire lines (block-display decorations) decoratorExtension, + EditorView.lineWrapping, EditorView.contentAttributes.of({ autocapitalize: 'sentence' }), EditorView.updateListener.of((viewUpdate: ViewUpdate) => { - if (viewUpdate.docChanged) { - postMessage('onChange', { value: editor.state.doc.toString() }); - schedulePostUndoRedoDepthChange(editor); - } - - if (!viewUpdate.state.selection.eq(viewUpdate.startState.selection)) { - const mainRange = viewUpdate.state.selection.main; - const selStart = mainRange.from; - const selEnd = mainRange.to; - postMessage('onSelectionChange', { selection: { start: selStart, end: selEnd } }); - } + notifyDocChanged(viewUpdate); + notifySelectionChange(viewUpdate); + notifySelectionFormattingChange(viewUpdate); }), keymap.of([ - ...defaultKeymap, ...historyKeymap, ...searchKeymap, + // Custom mod-f binding: Toggle the external dialog implementation + // (don't show/hide the Panel dialog). + keyCommand('Mod-f', (_: EditorView) => { + if (searchVisible) { + hideSearchDialog(); + } else { + showSearchDialog(); + } + return true; + }), + // Markdown formatting keyboard shortcuts + keyCommand('Mod-b', toggleBolded), + keyCommand('Mod-i', toggleItalicized), + keyCommand('Mod-$', toggleMath), + keyCommand('Mod-`', toggleCode), + keyCommand('Mod-[', decreaseIndent), + keyCommand('Mod-]', increaseIndent), + keyCommand('Mod-k', (_: EditorView) => { + notifyLinkEditRequest(); + return true; + }), + + ...defaultKeymap, ...historyKeymap, indentWithTab, ...searchKeymap, ]), ], doc: initialText, @@ -113,7 +339,19 @@ export function initCodeMirror(parentElement: any, initialText: string, theme: a parent: parentElement, }); - return { + const updateSearchQuery = (newState: SearchState) => { + const query = new SearchQuery({ + search: newState.searchText, + caseSensitive: newState.caseSensitive, + regexp: newState.useRegex, + replace: newState.replaceText, + }); + editor.dispatch({ + effects: setSearchQuery.of(query), + }); + }; + + const editorControls = { editor, undo: () => { undo(editor); @@ -137,5 +375,54 @@ export function initCodeMirror(parentElement: any, initialText: string, theme: a insertText: (text: string) => { editor.dispatch(editor.state.replaceSelection(text)); }, + toggleFindDialog: () => { + const opened = openSearchPanel(editor); + if (!opened) { + closeSearchPanel(editor); + } + }, + setSpellcheckEnabled: (enabled: boolean) => { + editor.contentDOM.spellcheck = enabled; + notifySelectionFormattingChange(); + }, + + // Formatting + toggleBolded: () => { toggleBolded(editor); }, + toggleItalicized: () => { toggleItalicized(editor); }, + toggleCode: () => { toggleCode(editor); }, + toggleMath: () => { toggleMath(editor); }, + increaseIndent: () => { increaseIndent(editor); }, + decreaseIndent: () => { decreaseIndent(editor); }, + toggleList: (kind: ListType) => { toggleList(kind)(editor); }, + toggleHeaderLevel: (level: number) => { toggleHeaderLevel(level)(editor); }, + updateLink: (label: string, url: string) => { updateLink(label, url)(editor); }, + + // Search + searchControl: { + findNext: () => { + findNext(editor); + }, + findPrevious: () => { + findPrevious(editor); + }, + replaceCurrent: () => { + replaceNext(editor); + }, + replaceAll: () => { + replaceAll(editor); + }, + setSearchState: (state: SearchState) => { + updateSearchQuery(state); + }, + showSearch: () => { + showSearchDialog(); + }, + hideSearch: () => { + hideSearchDialog(); + }, + }, }; + + return editorControls; } + diff --git a/packages/app-mobile/components/NoteEditor/CodeMirror/createEditor.ts b/packages/app-mobile/components/NoteEditor/CodeMirror/createEditor.ts new file mode 100644 index 00000000000..cf7a821008c --- /dev/null +++ b/packages/app-mobile/components/NoteEditor/CodeMirror/createEditor.ts @@ -0,0 +1,23 @@ +import { markdown } from '@codemirror/lang-markdown'; +import { GFM as GithubFlavoredMarkdownExt } from '@lezer/markdown'; +import { indentUnit } from '@codemirror/language'; +import { SelectionRange, EditorSelection, EditorState } from '@codemirror/state'; +import { EditorView } from '@codemirror/view'; +import { MarkdownMathExtension } from './markdownMathParser'; + +// Creates and returns a minimal editor with markdown extensions +const createEditor = (initialText: string, initialSelection: SelectionRange): EditorView => { + return new EditorView({ + doc: initialText, + selection: EditorSelection.create([initialSelection]), + extensions: [ + markdown({ + extensions: [MarkdownMathExtension, GithubFlavoredMarkdownExt], + }), + indentUnit.of('\t'), + EditorState.tabSize.of(4), + ], + }); +}; + +export default createEditor; diff --git a/packages/app-mobile/components/NoteEditor/CodeMirror/demo.html b/packages/app-mobile/components/NoteEditor/CodeMirror/demo.html new file mode 100644 index 00000000000..bee0ddb9b3e --- /dev/null +++ b/packages/app-mobile/components/NoteEditor/CodeMirror/demo.html @@ -0,0 +1,48 @@ + + + + + + + CodeMirror test + + +
+ + + + + \ No newline at end of file diff --git a/packages/app-mobile/components/NoteEditor/CodeMirror/markdownCommands.bulletedVsChecklist.test.ts b/packages/app-mobile/components/NoteEditor/CodeMirror/markdownCommands.bulletedVsChecklist.test.ts new file mode 100644 index 00000000000..1976b5d369d --- /dev/null +++ b/packages/app-mobile/components/NoteEditor/CodeMirror/markdownCommands.bulletedVsChecklist.test.ts @@ -0,0 +1,47 @@ +/** + * @jest-environment jsdom + */ +import { EditorSelection } from '@codemirror/state'; +import { ListType } from '../types'; +import createEditor from './createEditor'; +import { toggleList } from './markdownCommands'; + +describe('markdownCommands.bulletedVsChecklist', () => { + const bulletedListPart = '- Test\n- This is a test.\n- 3\n- 4\n- 5'; + const checklistPart = '- [ ] This is a checklist\n- [ ] with multiple items.\n- [ ] ā˜‘'; + const initialDocText = `${bulletedListPart}\n\n${checklistPart}`; + + it('should remove a checklist following a bulleted list without modifying the bulleted list', () => { + const editor = createEditor( + initialDocText, EditorSelection.cursor(bulletedListPart.length + 5) + ); + + toggleList(ListType.CheckList)(editor); + expect(editor.state.doc.toString()).toBe( + `${bulletedListPart}\n\nThis is a checklist\nwith multiple items.\nā˜‘` + ); + }); + + it('should remove an unordered list following a checklist without modifying the checklist', () => { + const editor = createEditor( + initialDocText, EditorSelection.cursor(bulletedListPart.length - 5) + ); + + toggleList(ListType.UnorderedList)(editor); + expect(editor.state.doc.toString()).toBe( + `Test\nThis is a test.\n3\n4\n5\n\n${checklistPart}` + ); + }); + + it('should replace a selection of unordered and task lists with a correctly-numbered list', () => { + const editor = createEditor( + initialDocText, EditorSelection.range(0, initialDocText.length) + ); + + toggleList(ListType.OrderedList)(editor); + expect(editor.state.doc.toString()).toBe( + '1. Test\n2. This is a test.\n3. 3\n4. 4\n5. 5' + + '\n\n6. This is a checklist\n7. with multiple items.\n8. ā˜‘' + ); + }); +}); diff --git a/packages/app-mobile/components/NoteEditor/CodeMirror/markdownCommands.test.ts b/packages/app-mobile/components/NoteEditor/CodeMirror/markdownCommands.test.ts new file mode 100644 index 00000000000..003b78dd214 --- /dev/null +++ b/packages/app-mobile/components/NoteEditor/CodeMirror/markdownCommands.test.ts @@ -0,0 +1,248 @@ +/** + * @jest-environment jsdom + */ + +import { EditorSelection, EditorState, SelectionRange } from '@codemirror/state'; +import { EditorView } from '@codemirror/view'; +import { + toggleBolded, toggleCode, toggleHeaderLevel, toggleItalicized, toggleMath, updateLink, +} from './markdownCommands'; +import { GFM as GithubFlavoredMarkdownExt } from '@lezer/markdown'; +import { markdown } from '@codemirror/lang-markdown'; +import { MarkdownMathExtension } from './markdownMathParser'; +import { indentUnit } from '@codemirror/language'; + +// Creates and returns a minimal editor with markdown extensions +const createEditor = (initialText: string, initialSelection: SelectionRange): EditorView => { + return new EditorView({ + doc: initialText, + selection: EditorSelection.create([initialSelection]), + extensions: [ + markdown({ + extensions: [MarkdownMathExtension, GithubFlavoredMarkdownExt], + }), + indentUnit.of('\t'), + EditorState.tabSize.of(4), + ], + }); +}; + +describe('markdownCommands', () => { + it('should bold/italicize everything selected', () => { + const initialDocText = 'Testing...'; + const editor = createEditor( + initialDocText, EditorSelection.range(0, initialDocText.length) + ); + + toggleBolded(editor); + + let mainSel = editor.state.selection.main; + const boldedText = '**Testing...**'; + expect(editor.state.doc.toString()).toBe(boldedText); + expect(mainSel.from).toBe(0); + expect(mainSel.to).toBe(boldedText.length); + + toggleBolded(editor); + mainSel = editor.state.selection.main; + expect(editor.state.doc.toString()).toBe(initialDocText); + expect(mainSel.from).toBe(0); + expect(mainSel.to).toBe(initialDocText.length); + + toggleItalicized(editor); + expect(editor.state.doc.toString()).toBe('*Testing...*'); + + toggleItalicized(editor); + expect(editor.state.doc.toString()).toBe('Testing...'); + }); + + it('toggling math should both create and navigate out of math regions', () => { + const initialDocText = 'Testing... '; + const editor = createEditor(initialDocText, EditorSelection.cursor(initialDocText.length)); + + toggleMath(editor); + expect(editor.state.doc.toString()).toBe('Testing... $$'); + expect(editor.state.selection.main.empty).toBe(true); + + editor.dispatch(editor.state.replaceSelection('3 + 3 \\neq 5')); + expect(editor.state.doc.toString()).toBe('Testing... $3 + 3 \\neq 5$'); + + toggleMath(editor); + editor.dispatch(editor.state.replaceSelection('...')); + expect(editor.state.doc.toString()).toBe('Testing... $3 + 3 \\neq 5$...'); + }); + + it('toggling inline code should both create and navigate out of an inline code region', () => { + const initialDocText = 'Testing...\n\n'; + const editor = createEditor(initialDocText, EditorSelection.cursor(initialDocText.length)); + + toggleCode(editor); + editor.dispatch(editor.state.replaceSelection('f(x) = ...')); + toggleCode(editor); + + editor.dispatch(editor.state.replaceSelection(' is a function.')); + expect(editor.state.doc.toString()).toBe('Testing...\n\n`f(x) = ...` is a function.'); + }); + + it('should set headers to the proper levels (when toggling)', () => { + const initialDocText = 'Testing...\nThis is a test.'; + const editor = createEditor(initialDocText, EditorSelection.cursor(3)); + + toggleHeaderLevel(1)(editor); + + let mainSel = editor.state.selection.main; + expect(editor.state.doc.toString()).toBe('# Testing...\nThis is a test.'); + expect(mainSel.empty).toBe(true); + expect(mainSel.from).toBe('# Testing...'.length); + + toggleHeaderLevel(2)(editor); + + mainSel = editor.state.selection.main; + expect(editor.state.doc.toString()).toBe('## Testing...\nThis is a test.'); + expect(mainSel.empty).toBe(true); + expect(mainSel.from).toBe('## Testing...'.length); + + toggleHeaderLevel(2)(editor); + + mainSel = editor.state.selection.main; + expect(editor.state.doc.toString()).toEqual(initialDocText); + expect(mainSel.empty).toBe(true); + expect(mainSel.from).toBe('Testing...'.length); + }); + + it('headers should toggle properly within block quotes', () => { + const initialDocText = 'Testing...\n\n> This is a test.\n> ...a test'; + const editor = createEditor( + initialDocText, + EditorSelection.cursor('Testing...\n\n> This'.length) + ); + + toggleHeaderLevel(1)(editor); + + const mainSel = editor.state.selection.main; + expect(editor.state.doc.toString()).toBe( + 'Testing...\n\n> # This is a test.\n> ...a test' + ); + expect(mainSel.empty).toBe(true); + expect(mainSel.from).toBe('Testing...\n\n> # This is a test.'.length); + + toggleHeaderLevel(3)(editor); + + expect(editor.state.doc.toString()).toBe( + 'Testing...\n\n> ### This is a test.\n> ...a test' + ); + }); + + it('block math should properly toggle within block quotes', () => { + const initialDocText = 'Testing...\n\n> This is a test.\n> y = mx + b\n> ...a test'; + const editor = createEditor( + initialDocText, + EditorSelection.range( + 'Testing...\n\n> This'.length, + 'Testing...\n\n> This is a test.\n> y = mx + b'.length + ) + ); + + toggleMath(editor); + + // Toggling math should surround the content in '$$'s + let mainSel = editor.state.selection.main; + expect(editor.state.doc.toString()).toEqual( + 'Testing...\n\n> $$\n> This is a test.\n> y = mx + b\n> $$\n> ...a test' + ); + expect(mainSel.from).toBe('Testing...\n\n'.length); + expect(mainSel.to).toBe('Testing...\n\n> $$\n> This is a test.\n> y = mx + b\n> $$'.length); + + // Change to a cursor --- test cursor expansion + editor.dispatch({ + selection: EditorSelection.cursor('Testing...\n\n> $$\n> This is'.length), + }); + + // Toggling math again should remove the '$$'s + toggleMath(editor); + mainSel = editor.state.selection.main; + expect(editor.state.doc.toString()).toEqual(initialDocText); + expect(mainSel.from).toBe('Testing...\n\n'.length); + expect(mainSel.to).toBe('Testing...\n\n> This is a test.\n> y = mx + b'.length); + }); + + it('updateLink should replace link titles and isolate URLs if no title is given', () => { + const initialDocText = '[foo](http://example.com/)'; + const editor = createEditor(initialDocText, EditorSelection.cursor('[f'.length)); + + updateLink('bar', 'https://example.com/')(editor); + expect(editor.state.doc.toString()).toBe( + '[bar](https://example.com/)' + ); + + updateLink('', 'https://example.com/')(editor); + expect(editor.state.doc.toString()).toBe( + 'https://example.com/' + ); + }); + + it('toggling math twice, starting on a line with content, should a math block', () => { + const initialDocText = 'Testing... '; + const editor = createEditor(initialDocText, EditorSelection.cursor(initialDocText.length)); + + toggleMath(editor); + toggleMath(editor); + editor.dispatch(editor.state.replaceSelection('f(x) = ...')); + expect(editor.state.doc.toString()).toBe('Testing... \n$$\nf(x) = ...\n$$'); + }); + + it('toggling math twice on an empty line should create an empty math block', () => { + const initialDocText = 'Testing...\n\n'; + const editor = createEditor(initialDocText, EditorSelection.cursor(initialDocText.length)); + + toggleMath(editor); + toggleMath(editor); + editor.dispatch(editor.state.replaceSelection('f(x) = ...')); + expect(editor.state.doc.toString()).toBe('Testing...\n\n$$\nf(x) = ...\n$$'); + }); + + it('toggling code twice on an empty line should create an empty code block', () => { + const initialDocText = 'Testing...\n\n'; + const editor = createEditor(initialDocText, EditorSelection.cursor(initialDocText.length)); + + // Toggling code twice should create a block code region + toggleCode(editor); + toggleCode(editor); + editor.dispatch(editor.state.replaceSelection('f(x) = ...')); + expect(editor.state.doc.toString()).toBe('Testing...\n\n```\nf(x) = ...\n```'); + + toggleCode(editor); + expect(editor.state.doc.toString()).toBe('Testing...\n\nf(x) = ...\n'); + }); + + it('toggling math twice inside a block quote should produce an empty math block', () => { + const initialDocText = '> Testing...> \n> '; + const editor = createEditor(initialDocText, EditorSelection.cursor(initialDocText.length)); + + toggleMath(editor); + toggleMath(editor); + editor.dispatch(editor.state.replaceSelection('f(x) = ...')); + expect(editor.state.doc.toString()).toBe( + '> Testing...> \n> \n> $$\n> f(x) = ...\n> $$' + ); + + // If we toggle math again, everything from the start of the line with the first + // $$ to the end of the document should be selected. + toggleMath(editor); + const sel = editor.state.selection.main; + expect(sel.from).toBe('> Testing...> \n> \n'.length); + expect(sel.to).toBe(editor.state.doc.length); + }); + + it('toggling inline code should both create and navigate out of an inline code region', () => { + const initialDocText = 'Testing...\n\n'; + const editor = createEditor(initialDocText, EditorSelection.cursor(initialDocText.length)); + + toggleCode(editor); + editor.dispatch(editor.state.replaceSelection('f(x) = ...')); + toggleCode(editor); + + editor.dispatch(editor.state.replaceSelection(' is a function.')); + expect(editor.state.doc.toString()).toBe('Testing...\n\n`f(x) = ...` is a function.'); + }); +}); + diff --git a/packages/app-mobile/components/NoteEditor/CodeMirror/markdownCommands.toggleList.test.ts b/packages/app-mobile/components/NoteEditor/CodeMirror/markdownCommands.toggleList.test.ts new file mode 100644 index 00000000000..361baa387ea --- /dev/null +++ b/packages/app-mobile/components/NoteEditor/CodeMirror/markdownCommands.toggleList.test.ts @@ -0,0 +1,189 @@ +/** + * @jest-environment jsdom + */ + +import { EditorSelection, EditorState } from '@codemirror/state'; +import { + increaseIndent, toggleList, +} from './markdownCommands'; +import { ListType } from '../types'; +import createEditor from './createEditor'; + +describe('markdownCommands.toggleList', () => { + it('should remove the same type of list', () => { + const initialDocText = '- testing\n- this is a test'; + + const editor = createEditor( + initialDocText, + EditorSelection.cursor(5) + ); + + toggleList(ListType.UnorderedList)(editor); + expect(editor.state.doc.toString()).toBe( + 'testing\nthis is a test' + ); + }); + + it('should insert a numbered list with correct numbering', () => { + const initialDocText = 'Testing...\nThis is a test\nof list toggling...'; + const editor = createEditor( + initialDocText, + EditorSelection.cursor('Testing...\nThis is a'.length) + ); + + toggleList(ListType.OrderedList)(editor); + expect(editor.state.doc.toString()).toBe( + 'Testing...\n1. This is a test\nof list toggling...' + ); + + editor.setState(EditorState.create({ + doc: initialDocText, + selection: EditorSelection.range(4, initialDocText.length), + })); + + toggleList(ListType.OrderedList)(editor); + expect(editor.state.doc.toString()).toBe( + '1. Testing...\n2. This is a test\n3. of list toggling...' + ); + }); + + const numberedListText = '- 1\n- 2\n- 3\n- 4\n- 5\n- 6\n- 7'; + + it('should correctly replace an unordered list with a numbered list', () => { + const editor = createEditor( + numberedListText, + EditorSelection.cursor(numberedListText.length) + ); + + toggleList(ListType.OrderedList)(editor); + expect(editor.state.doc.toString()).toBe( + '1. 1\n2. 2\n3. 3\n4. 4\n5. 5\n6. 6\n7. 7' + ); + }); + + + it('should correctly replace an unordered list with a checklist', () => { + const editor = createEditor( + numberedListText, + EditorSelection.cursor(numberedListText.length) + ); + + toggleList(ListType.CheckList)(editor); + expect(editor.state.doc.toString()).toBe( + '- [ ] 1\n- [ ] 2\n- [ ] 3\n- [ ] 4\n- [ ] 5\n- [ ] 6\n- [ ] 7' + ); + }); + + it('should properly toggle a sublist of a bulleted list', () => { + const preSubListText = '# List test\n * This\n * is\n'; + const initialDocText = `${preSubListText}\t* a\n\t* test\n * of list toggling`; + + const editor = createEditor( + initialDocText, + EditorSelection.cursor(preSubListText.length + '\t* a'.length) + ); + + // Indentation should be preserved when changing list types + toggleList(ListType.OrderedList)(editor); + expect(editor.state.doc.toString()).toBe( + '# List test\n * This\n * is\n\t1. a\n\t2. test\n * of list toggling' + ); + + // The changed region should be selected + expect(editor.state.selection.main.from).toBe(preSubListText.length); + expect(editor.state.selection.main.to).toBe( + `${preSubListText}\t1. a\n\t2. test`.length + ); + + // Indentation should not be preserved when removing lists + toggleList(ListType.OrderedList)(editor); + expect(editor.state.selection.main.from).toBe(preSubListText.length); + expect(editor.state.doc.toString()).toBe( + '# List test\n * This\n * is\na\ntest\n * of list toggling' + ); + + + // Put the cursor in the middle of the list + editor.dispatch({ selection: EditorSelection.cursor(preSubListText.length) }); + + // Sublists should be changed + toggleList(ListType.CheckList)(editor); + const expectedChecklistPart = + '# List test\n - [ ] This\n - [ ] is\n - [ ] a\n - [ ] test\n - [ ] of list toggling'; + expect(editor.state.doc.toString()).toBe( + expectedChecklistPart + ); + + editor.dispatch({ selection: EditorSelection.cursor(editor.state.doc.length) }); + editor.dispatch(editor.state.replaceSelection('\n\n\n')); + + // toggleList should also create a new list if the cursor is on an empty line. + toggleList(ListType.OrderedList)(editor); + editor.dispatch(editor.state.replaceSelection('Test.\n2. Test2\n3. Test3')); + + expect(editor.state.doc.toString()).toBe( + `${expectedChecklistPart}\n\n\n1. Test.\n2. Test2\n3. Test3` + ); + + toggleList(ListType.CheckList)(editor); + expect(editor.state.doc.toString()).toBe( + `${expectedChecklistPart}\n\n\n- [ ] Test.\n- [ ] Test2\n- [ ] Test3` + ); + + // The entire checklist should have been selected (and thus will now be indented) + increaseIndent(editor); + expect(editor.state.doc.toString()).toBe( + `${expectedChecklistPart}\n\n\n\t- [ ] Test.\n\t- [ ] Test2\n\t- [ ] Test3` + ); + }); + + it('should toggle a numbered list without changing its sublists', () => { + const initialDocText = '1. Foo\n2. Bar\n3. Baz\n\t- Test\n\t- of\n\t- sublists\n4. Foo'; + + const editor = createEditor( + initialDocText, + EditorSelection.cursor(0) + ); + + toggleList(ListType.CheckList)(editor); + expect(editor.state.doc.toString()).toBe( + '- [ ] Foo\n- [ ] Bar\n- [ ] Baz\n\t- Test\n\t- of\n\t- sublists\n- [ ] Foo' + ); + }); + + it('should toggle a sublist without changing the parent list', () => { + const initialDocText = '1. This\n2. is\n3. '; + + const editor = createEditor( + initialDocText, + EditorSelection.cursor(initialDocText.length) + ); + + increaseIndent(editor); + expect(editor.state.selection.main.empty).toBe(true); + + toggleList(ListType.CheckList)(editor); + expect(editor.state.doc.toString()).toBe( + '1. This\n2. is\n\t- [ ] ' + ); + + editor.dispatch(editor.state.replaceSelection('a test.')); + expect(editor.state.doc.toString()).toBe( + '1. This\n2. is\n\t- [ ] a test.' + ); + }); + + it('should toggle lists properly within block quotes', () => { + const preSubListText = '> # List test\n> * This\n> * is\n'; + const initialDocText = `${preSubListText}> \t* a\n> \t* test\n> * of list toggling`; + const editor = createEditor( + initialDocText, EditorSelection.cursor(preSubListText.length + 3) + ); + + toggleList(ListType.OrderedList)(editor); + expect(editor.state.doc.toString()).toBe( + '> # List test\n> * This\n> * is\n> \t1. a\n> \t2. test\n> * of list toggling' + ); + expect(editor.state.selection.main.from).toBe(preSubListText.length); + }); +}); diff --git a/packages/app-mobile/components/NoteEditor/CodeMirror/markdownCommands.ts b/packages/app-mobile/components/NoteEditor/CodeMirror/markdownCommands.ts new file mode 100644 index 00000000000..da974e37a4c --- /dev/null +++ b/packages/app-mobile/components/NoteEditor/CodeMirror/markdownCommands.ts @@ -0,0 +1,440 @@ +// CodeMirror 6 commands that modify markdown formatting (e.g. toggleBold). + +import { EditorView, Command } from '@codemirror/view'; + +import { ListType } from '../types'; +import { + SelectionRange, EditorSelection, ChangeSpec, Line, TransactionSpec, +} from '@codemirror/state'; +import { getIndentUnit, indentString, syntaxTree } from '@codemirror/language'; +import { + RegionSpec, growSelectionToNode, renumberList, + toggleInlineFormatGlobally, toggleRegionFormatGlobally, toggleSelectedLinesStartWith, + isIndentationEquivalent, stripBlockquote, tabsToSpaces, +} from './markdownReformatter'; + +const startingSpaceRegex = /^(\s*)/; + +export const toggleBolded: Command = (view: EditorView): boolean => { + const spec = RegionSpec.of({ template: '**', nodeName: 'StrongEmphasis' }); + const changes = toggleInlineFormatGlobally(view.state, spec); + + view.dispatch(changes); + return true; +}; + +export const toggleItalicized: Command = (view: EditorView): boolean => { + const changes = toggleInlineFormatGlobally(view.state, { + nodeName: 'Emphasis', + + template: { start: '*', end: '*' }, + matcher: { start: /[_*]/g, end: /[_*]/g }, + }); + view.dispatch(changes); + + return true; +}; + +// If the selected region is an empty inline code block, it will be converted to +// a block (fenced) code block. +export const toggleCode: Command = (view: EditorView): boolean => { + const codeFenceRegex = /^```\w*\s*$/; + const inlineRegionSpec = RegionSpec.of({ template: '`', nodeName: 'InlineCode' }); + const blockRegionSpec: RegionSpec = { + nodeName: 'FencedCode', + template: { start: '```', end: '```' }, + matcher: { start: codeFenceRegex, end: codeFenceRegex }, + }; + + const changes = toggleRegionFormatGlobally(view.state, inlineRegionSpec, blockRegionSpec); + view.dispatch(changes); + + return true; +}; + +export const toggleMath: Command = (view: EditorView): boolean => { + const blockStartRegex = /^\$\$/; + const blockEndRegex = /\$\$\s*$/; + const inlineRegionSpec = RegionSpec.of({ nodeName: 'InlineMath', template: '$' }); + const blockRegionSpec = RegionSpec.of({ + nodeName: 'BlockMath', + template: '$$', + matcher: { + start: blockStartRegex, + end: blockEndRegex, + }, + }); + + const changes = toggleRegionFormatGlobally(view.state, inlineRegionSpec, blockRegionSpec); + view.dispatch(changes); + + return true; +}; + +export const toggleList = (listType: ListType): Command => { + return (view: EditorView): boolean => { + let state = view.state; + let doc = state.doc; + + const orderedListTag = 'OrderedList'; + const unorderedListTag = 'BulletList'; + + // RegExps for different list types. The regular expressions MUST + // be mutually exclusive. + // `(?!\[[ xX]+\]\s?)` means "not followed by [x] or [ ]". + const bulletedRegex = /^\s*([-*])(?!\s\[[ xX]+\])\s?/; + const checklistRegex = /^\s*[-*]\s\[[ xX]+\]\s?/; + const numberedRegex = /^\s*\d+\.\s?/; + + const listRegexes: Record = { + [ListType.OrderedList]: numberedRegex, + [ListType.CheckList]: checklistRegex, + [ListType.UnorderedList]: bulletedRegex, + }; + + const getContainerType = (line: Line): ListType|null => { + const lineContent = stripBlockquote(line); + + // Determine the container's type. + const checklistMatch = lineContent.match(checklistRegex); + const bulletListMatch = lineContent.match(bulletedRegex); + const orderedListMatch = lineContent.match(numberedRegex); + + if (checklistMatch) { + return ListType.CheckList; + } else if (bulletListMatch) { + return ListType.UnorderedList; + } else if (orderedListMatch) { + return ListType.OrderedList; + } + + return null; + }; + + const changes: TransactionSpec = state.changeByRange((sel: SelectionRange) => { + const changes: ChangeSpec[] = []; + let containerType: ListType|null = null; + + // Total number of characters added (deleted if negative) + let charsAdded = 0; + + const originalSel = sel; + let fromLine: Line; + let toLine: Line; + let firstLineIndentation: string; + let firstLineInBlockQuote: boolean; + let fromLineContent: string; + const computeSelectionProps = () => { + fromLine = doc.lineAt(sel.from); + toLine = doc.lineAt(sel.to); + fromLineContent = stripBlockquote(fromLine); + firstLineIndentation = fromLineContent.match(startingSpaceRegex)[0]; + firstLineInBlockQuote = (fromLineContent !== fromLine.text); + + containerType = getContainerType(fromLine); + }; + computeSelectionProps(); + + const origFirstLineIndentation = firstLineIndentation; + const origContainerType = containerType; + + // Grow [sel] to the smallest containing list + if (sel.empty) { + sel = growSelectionToNode(state, sel, [orderedListTag, unorderedListTag]); + computeSelectionProps(); + } + + // Reset the selection if it seems likely the user didn't want the selection + // to be expanded + const isIndentationDiff = + !isIndentationEquivalent(state, firstLineIndentation, origFirstLineIndentation); + if (isIndentationDiff) { + const expandedRegionIndentation = firstLineIndentation; + sel = originalSel; + computeSelectionProps(); + + // Use the indentation level of the expanded region if it's greater. + // This makes sense in the case where unindented text is being converted to + // the same type of list as its container. For example, + // 1. Foobar + // unindented text + // that should be made a part of the above list. + // + // becoming + // + // 1. Foobar + // 2. unindented text + // 3. that should be made a part of the above list. + const wasGreaterIndentation = ( + tabsToSpaces(state, expandedRegionIndentation).length + > tabsToSpaces(state, firstLineIndentation).length + ); + if (wasGreaterIndentation) { + firstLineIndentation = expandedRegionIndentation; + } + } else if ( + (origContainerType !== containerType && (origContainerType ?? null) !== null) + || containerType !== getContainerType(toLine) + ) { + // If the container type changed, this could be an artifact of checklists/bulleted + // lists sharing the same node type. + // Find the closest range of the same type of list to the original selection + let newFromLineNo = doc.lineAt(originalSel.from).number; + let newToLineNo = doc.lineAt(originalSel.to).number; + let lastFromLineNo; + let lastToLineNo; + + while (newFromLineNo !== lastFromLineNo || newToLineNo !== lastToLineNo) { + lastFromLineNo = newFromLineNo; + lastToLineNo = newToLineNo; + + if (lastFromLineNo - 1 >= 1) { + const testFromLine = doc.line(lastFromLineNo - 1); + if (getContainerType(testFromLine) === origContainerType) { + newFromLineNo --; + } + } + + if (lastToLineNo + 1 <= doc.lines) { + const testToLine = doc.line(lastToLineNo + 1); + if (getContainerType(testToLine) === origContainerType) { + newToLineNo ++; + } + } + } + + sel = EditorSelection.range( + doc.line(newFromLineNo).from, + doc.line(newToLineNo).to + ); + computeSelectionProps(); + } + + // Determine whether the expanded selection should be empty + if (originalSel.empty && fromLine.number === toLine.number) { + sel = EditorSelection.cursor(toLine.to); + } + + // Select entire lines (if not just a cursor) + if (!sel.empty) { + sel = EditorSelection.range(fromLine.from, toLine.to); + } + + // 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 ++) { + const line = doc.line(lineNum); + const lineContent = stripBlockquote(line); + const lineContentFrom = line.to - lineContent.length; + const inBlockQuote = (lineContent !== line.text); + const indentation = lineContent.match(startingSpaceRegex)[0]; + + const wrongIndentaton = !isIndentationEquivalent(state, indentation, firstLineIndentation); + + // If not the right list level, + if (inBlockQuote !== firstLineInBlockQuote || wrongIndentaton) { + // We'll be starting a new list + listItemCounter = 1; + continue; + } + + // Don't add list numbers to otherwise empty lines (unless it's the first line) + if (lineNum !== fromLine.number && line.text.trim().length === 0) { + // Do not reset the counter -- the markdown renderer doesn't! + continue; + } + + const deleteFrom = lineContentFrom; + let deleteTo = deleteFrom + indentation.length; + + // If we need to remove an existing list, + const currentContainer = getContainerType(line); + if (currentContainer !== null) { + const containerRegex = listRegexes[currentContainer]; + const containerMatch = lineContent.match(containerRegex); + if (!containerMatch) { + throw new Error( + 'Assertion failed: container regex does not match line content.' + ); + } + + deleteTo = lineContentFrom + containerMatch[0].length; + } + + let replacementString; + + if (listType === containerType) { + // Delete the existing list if it's the same type as the current + replacementString = ''; + } else if (listType === ListType.OrderedList) { + replacementString = `${firstLineIndentation}${listItemCounter}. `; + } else if (listType === ListType.CheckList) { + replacementString = `${firstLineIndentation}- [ ] `; + } else { + replacementString = `${firstLineIndentation}- `; + } + + changes.push({ + from: deleteFrom, + to: deleteTo, + insert: replacementString, + }); + charsAdded -= deleteTo - deleteFrom; + charsAdded += replacementString.length; + listItemCounter++; + } + + // Don't change cursors to selections + if (sel.empty) { + // Position the cursor at the end of the last line modified + sel = EditorSelection.cursor(toLine.to + charsAdded); + } else { + sel = EditorSelection.range( + sel.from, + sel.to + charsAdded + ); + } + + return { + changes, + range: sel, + }; + }); + view.dispatch(changes); + state = view.state; + doc = state.doc; + + // Renumber the list + view.dispatch(state.changeByRange((sel: SelectionRange) => { + return renumberList(state, sel); + })); + + return true; + }; +}; + +export const toggleHeaderLevel = (level: number): Command => { + return (view: EditorView): boolean => { + let headerStr = ''; + for (let i = 0; i < level; i++) { + 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 + ); + view.dispatch(changes); + + // Set to the proper header level + changes = toggleSelectedLinesStartWith( + view.state, + // We want exactly [level] '#' characters. + new RegExp(`^[#]{${level}} `), + `${headerStr} `, + matchEmpty + ); + view.dispatch(changes); + + return true; + }; +}; + +// 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 + ); + view.dispatch(changes); + + // Fix any lists + view.dispatch(view.state.changeByRange((sel: SelectionRange) => { + return renumberList(view.state, sel); + })); + + return true; +}; + +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 + ); + + view.dispatch(changes); + + // Fix any lists + view.dispatch(view.state.changeByRange((sel: SelectionRange) => { + return renumberList(view.state, sel); + })); + + return true; +}; + +export const updateLink = (label: string, url: string): Command => { + // Empty label? Just include the URL. + const linkText = label === '' ? url : `[${label}](${url})`; + + return (editor: EditorView): boolean => { + const transaction = editor.state.changeByRange((sel: SelectionRange) => { + const changes = []; + + // Search for a link that overlaps [sel] + let linkFrom: number | null = null; + let linkTo: number | null = null; + syntaxTree(editor.state).iterate({ + from: sel.from, to: sel.to, + enter: node => { + const haveFoundLink = (linkFrom !== null && linkTo !== null); + + if (node.name === 'Link' || (node.name === 'URL' && !haveFoundLink)) { + linkFrom = node.from; + linkTo = node.to; + } + }, + }); + + linkFrom ??= sel.from; + linkTo ??= sel.to; + + changes.push({ + from: linkFrom, to: linkTo, + insert: linkText, + }); + + return { + changes, + range: EditorSelection.range(linkFrom, linkFrom + linkText.length), + }; + }); + + editor.dispatch(transaction); + return true; + }; +}; diff --git a/packages/app-mobile/components/NoteEditor/CodeMirror/markdownReformatter.test.ts b/packages/app-mobile/components/NoteEditor/CodeMirror/markdownReformatter.test.ts new file mode 100644 index 00000000000..7aedba8e422 --- /dev/null +++ b/packages/app-mobile/components/NoteEditor/CodeMirror/markdownReformatter.test.ts @@ -0,0 +1,142 @@ +import { + findInlineMatch, MatchSide, RegionSpec, tabsToSpaces, toggleRegionFormatGlobally, +} from './markdownReformatter'; +import { Text as DocumentText, EditorSelection, EditorState } from '@codemirror/state'; +import { indentUnit } from '@codemirror/language'; + +describe('markdownReformatter', () => { + const boldSpec: RegionSpec = RegionSpec.of({ + template: '**', + }); + + it('matching a bolded region: should return the length of the match', () => { + const doc = DocumentText.of(['**test**']); + const sel = EditorSelection.range(0, 5); + + // matchStart returns the length of the match + expect(findInlineMatch(doc, boldSpec, sel, MatchSide.Start)).toBe(2); + }); + + it('matching a bolded region: should match the end of a region, if next to the cursor', () => { + const doc = DocumentText.of(['**...** test.']); + const sel = EditorSelection.range(5, 5); + expect(findInlineMatch(doc, boldSpec, sel, MatchSide.End)).toBe(2); + }); + + it('matching a bolded region: should return -1 if no match is found', () => { + const doc = DocumentText.of(['**...** test.']); + const sel = EditorSelection.range(3, 3); + expect(findInlineMatch(doc, boldSpec, sel, MatchSide.Start)).toBe(-1); + }); + + it('should match a custom specification of italicized regions', () => { + const spec: RegionSpec = { + template: { start: '*', end: '*' }, + matcher: { start: /[*_]/g, end: /[*_]/g }, + }; + const testString = 'This is a _test_'; + const testDoc = DocumentText.of([testString]); + const fullSel = EditorSelection.range('This is a '.length, testString.length); + + // should match the start of the region + expect(findInlineMatch(testDoc, spec, fullSel, MatchSide.Start)).toBe(1); + + // should match the end of the region + expect(findInlineMatch(testDoc, spec, fullSel, MatchSide.End)).toBe(1); + }); + + const listSpec: RegionSpec = { + template: { start: ' - ', end: '' }, + matcher: { + start: /^\s*[-*]\s/g, + end: /$/g, + }, + }; + + it('matching a custom list: should not match a list if not within the selection', () => { + const doc = DocumentText.of(['- Test...']); + const sel = EditorSelection.range(1, 6); + + // Beginning of list not selected: no match + expect(findInlineMatch(doc, listSpec, sel, MatchSide.Start)).toBe(-1); + }); + + it('matching a custom list: should match start of selected, unindented list', () => { + const doc = DocumentText.of(['- Test...']); + const sel = EditorSelection.range(0, 6); + + expect(findInlineMatch(doc, listSpec, sel, MatchSide.Start)).toBe(2); + }); + + it('matching a custom list: should match start of indented list', () => { + const doc = DocumentText.of([' - Test...']); + const sel = EditorSelection.range(0, 6); + + expect(findInlineMatch(doc, listSpec, sel, MatchSide.Start)).toBe(5); + }); + + it('matching a custom list: should match the end of an item in an indented list', () => { + const doc = DocumentText.of([' - Test...']); + const sel = EditorSelection.range(0, 6); + + // Zero-length, but found, selection + expect(findInlineMatch(doc, listSpec, sel, MatchSide.End)).toBe(0); + }); + + const multiLineTestText = `Internal text manipulation + This is a test... + of block and inline region toggling.`; + const codeFenceRegex = /^``````\w*\s*$/; + const inlineCodeRegionSpec = RegionSpec.of({ + template: '`', + nodeName: 'InlineCode', + }); + const blockCodeRegionSpec: RegionSpec = { + template: { start: '``````', end: '``````' }, + matcher: { start: codeFenceRegex, end: codeFenceRegex }, + }; + + it('should create an empty inline region around the cursor, if given an empty selection', () => { + const initialState: EditorState = EditorState.create({ + doc: multiLineTestText, + selection: EditorSelection.cursor(0), + }); + + const changes = toggleRegionFormatGlobally( + initialState, inlineCodeRegionSpec, blockCodeRegionSpec + ); + + const newState = initialState.update(changes).state; + expect(newState.doc.toString()).toEqual(`\`\`${multiLineTestText}`); + }); + + it('should wrap multiple selected lines in block formatting', () => { + const initialState: EditorState = EditorState.create({ + doc: multiLineTestText, + selection: EditorSelection.range(0, multiLineTestText.length), + }); + + const changes = toggleRegionFormatGlobally( + initialState, inlineCodeRegionSpec, blockCodeRegionSpec + ); + + const newState = initialState.update(changes).state; + const editorText = newState.doc.toString(); + expect(editorText).toBe(`\`\`\`\`\`\`\n${multiLineTestText}\n\`\`\`\`\`\``); + expect(newState.selection.main.from).toBe(0); + expect(newState.selection.main.to).toBe(editorText.length); + }); + + it('should convert tabs to spaces based on indentUnit', () => { + const state: EditorState = EditorState.create({ + doc: multiLineTestText, + selection: EditorSelection.cursor(0), + extensions: [ + indentUnit.of(' '), + ], + }); + expect(tabsToSpaces(state, '\t')).toBe(' '); + expect(tabsToSpaces(state, '\t ')).toBe(' '); + expect(tabsToSpaces(state, ' \t ')).toBe(' '); + }); +}); diff --git a/packages/app-mobile/components/NoteEditor/CodeMirror/markdownReformatter.ts b/packages/app-mobile/components/NoteEditor/CodeMirror/markdownReformatter.ts new file mode 100644 index 00000000000..3067816371e --- /dev/null +++ b/packages/app-mobile/components/NoteEditor/CodeMirror/markdownReformatter.ts @@ -0,0 +1,712 @@ +import { + Text as DocumentText, EditorSelection, SelectionRange, ChangeSpec, EditorState, Line, TransactionSpec, +} from '@codemirror/state'; +import { getIndentUnit, syntaxTree } from '@codemirror/language'; +import { SyntaxNodeRef } from '@lezer/common'; + +// pregQuote escapes text for usage in regular expressions +const { pregQuote } = require('@joplin/lib/string-utils-common'); + +// Length of the symbol that starts a block quote +const blockQuoteStartLen = '> '.length; +const blockQuoteRegex = /^>\s/; + +// Specifies the update of a single selection region and its contents +type SelectionUpdate = { range: SelectionRange; changes?: ChangeSpec }; + +// Specifies how a to find the start/stop of a type of formatting +interface RegionMatchSpec { + start: RegExp; + end: RegExp; +} + +// Describes a region's formatting +export interface RegionSpec { + // The name of the node corresponding to the region in the syntax tree + nodeName?: string; + + // Text to be inserted before and after the region when toggling. + template: { start: string; end: string }; + + // How to identify the region + matcher: RegionMatchSpec; +} + +export namespace RegionSpec { // eslint-disable-line no-redeclare + interface RegionSpecConfig { + nodeName?: string; + template: string | { start: string; end: string }; + matcher?: RegionMatchSpec; + } + + // Creates a new RegionSpec, given a simplified set of options. + // If [config.template] is a string, it is used as both the starting and ending + // templates. + // Similarly, if [config.matcher] is not given, a matcher is created based on + // [config.template]. + export const of = (config: RegionSpecConfig): RegionSpec => { + let templateStart: string, templateEnd: string; + if (typeof config.template === 'string') { + templateStart = config.template; + templateEnd = config.template; + } else { + templateStart = config.template.start; + templateEnd = config.template.end; + } + + const matcher: RegionMatchSpec = + config.matcher ?? matcherFromTemplate(templateStart, templateEnd); + + return { + nodeName: config.nodeName, + template: { start: templateStart, end: templateEnd }, + matcher, + }; + }; + + const matcherFromTemplate = (start: string, end: string): RegionMatchSpec => { + // See https://stackoverflow.com/a/30851002 + const escapedStart = pregQuote(start); + const escapedEnd = pregQuote(end); + + return { + start: new RegExp(escapedStart, 'g'), + end: new RegExp(escapedEnd, 'g'), + }; + }; +} + +export enum MatchSide { + Start, + End, +} + +// Returns the length of a match for this in the given selection, +// -1 if no match is found. +export const findInlineMatch = ( + doc: DocumentText, spec: RegionSpec, sel: SelectionRange, side: MatchSide +): number => { + const [regex, template] = (() => { + if (side === MatchSide.Start) { + return [spec.matcher.start, spec.template.start]; + } else { + return [spec.matcher.end, spec.template.end]; + } + })(); + const [startIndex, endIndex] = (() => { + if (!sel.empty) { + return [sel.from, sel.to]; + } + + const bufferSize = template.length; + if (side === MatchSide.Start) { + return [sel.from - bufferSize, sel.to]; + } else { + return [sel.from, sel.to + bufferSize]; + } + })(); + const searchText = doc.sliceString(startIndex, endIndex); + + // Returns true if [idx] is in the right place (the match is at + // the end of the string or the beginning based on startIndex/endIndex). + const indexSatisfies = (idx: number, len: number): boolean => { + idx += startIndex; + if (side === MatchSide.Start) { + return idx === startIndex; + } else { + return idx + len === endIndex; + } + }; + + // Enforce 'g' flag. + if (!regex.global) { + throw new Error('Regular expressions used by RegionSpec must have the global flag!'); + } + + // Search from the beginning. + regex.lastIndex = 0; + + let foundMatch = null; + let match; + while ((match = regex.exec(searchText)) !== null) { + if (indexSatisfies(match.index, match[0].length)) { + foundMatch = match; + break; + } + } + + if (foundMatch) { + const matchLength = foundMatch[0].length; + const matchIndex = foundMatch.index; + + // If the match isn't in the right place, + if (indexSatisfies(matchIndex, matchLength)) { + return matchLength; + } + } + + return -1; +}; + +export const stripBlockquote = (line: Line): string => { + const match = line.text.match(blockQuoteRegex); + + if (match) { + return line.text.substring(match[0].length); + } + + return line.text; +}; + +export const tabsToSpaces = (state: EditorState, text: string): string => { + const chunks = text.split('\t'); + const spaceLen = getIndentUnit(state); + let result = chunks[0]; + + for (let i = 1; i < chunks.length; i++) { + for (let j = result.length % spaceLen; j < spaceLen; j++) { + result += ' '; + } + + result += chunks[i]; + } + return result; +}; + +// Returns true iff [a] (an indentation string) is roughly equivalent to [b]. +export const isIndentationEquivalent = (state: EditorState, a: string, b: string): boolean => { + // Consider sublists to be the same as their parent list if they have the same + // label plus or minus 1 space. + return Math.abs(tabsToSpaces(state, a).length - tabsToSpaces(state, b).length) <= 1; +}; + +// Expands and returns a copy of [sel] to the smallest container node with name in [nodeNames]. +export const growSelectionToNode = ( + state: EditorState, sel: SelectionRange, nodeNames: string|string[]|null +): SelectionRange => { + if (!nodeNames) { + return sel; + } + + const isAcceptableNode = (name: string): boolean => { + if (typeof nodeNames === 'string') { + return name === nodeNames; + } + + for (const otherName of nodeNames) { + if (otherName === name) { + return true; + } + } + + return false; + }; + + let newFrom = null; + let newTo = null; + let smallestLen = Infinity; + + // Find the smallest range. + syntaxTree(state).iterate({ + from: sel.from, to: sel.to, + enter: node => { + if (isAcceptableNode(node.name)) { + if (node.to - node.from < smallestLen) { + newFrom = node.from; + newTo = node.to; + smallestLen = newTo - newFrom; + } + } + }, + }); + + // If it's in such a node, + if (newFrom !== null && newTo !== null) { + return EditorSelection.range(newFrom, newTo); + } else { + return sel; + } +}; + +// Toggles whether the given selection matches the inline region specified by [spec]. +// +// For example, something similar to toggleSurrounded('**', '**') would surround +// every selection range with asterisks (including the caret). +// If the selection is already surrounded by these characters, they are +// removed. +const toggleInlineRegionSurrounded = ( + doc: DocumentText, sel: SelectionRange, spec: RegionSpec +): SelectionUpdate => { + let content = doc.sliceString(sel.from, sel.to); + const startMatchLen = findInlineMatch(doc, spec, sel, MatchSide.Start); + const endMatchLen = findInlineMatch(doc, spec, sel, MatchSide.End); + + const startsWithBefore = startMatchLen >= 0; + const endsWithAfter = endMatchLen >= 0; + + const changes = []; + let finalSelStart = sel.from; + let finalSelEnd = sel.to; + + if (startsWithBefore && endsWithAfter) { + // Remove the before and after. + content = content.substring(startMatchLen); + content = content.substring(0, content.length - endMatchLen); + + finalSelEnd -= startMatchLen + endMatchLen; + + changes.push({ + from: sel.from, + to: sel.to, + insert: content, + }); + } else { + changes.push({ + from: sel.from, + insert: spec.template.start, + }); + + changes.push({ + from: sel.to, + insert: spec.template.start, + }); + + // If not a caret, + if (!sel.empty) { + // Select the surrounding chars. + finalSelEnd += spec.template.start.length + spec.template.end.length; + } else { + // Position the caret within the added content. + finalSelStart = sel.from + spec.template.start.length; + finalSelEnd = finalSelStart; + } + } + + return { + changes, + range: EditorSelection.range(finalSelStart, finalSelEnd), + }; +}; + +// Returns updated selections: For all selections in the given `EditorState`, toggles +// whether each is contained in an inline region of type [spec]. +export const toggleInlineSelectionFormat = ( + state: EditorState, spec: RegionSpec, sel: SelectionRange +): SelectionUpdate => { + const endMatchLen = findInlineMatch(state.doc, spec, sel, MatchSide.End); + + // If at the end of the region, move the + // caret to the end. + // E.g. + // **foobar|** + // **foobar**| + if (sel.empty && endMatchLen > -1) { + const newCursorPos = sel.from + endMatchLen; + + return { + range: EditorSelection.cursor(newCursorPos), + }; + } + + // Grow the selection to encompass the entire node. + const newRange = growSelectionToNode(state, sel, spec.nodeName); + return toggleInlineRegionSurrounded(state.doc, newRange, spec); +}; + +// Like toggleInlineSelectionFormat, but for all selections in [state]. +export const toggleInlineFormatGlobally = ( + state: EditorState, spec: RegionSpec +): TransactionSpec => { + const changes = state.changeByRange((sel: SelectionRange) => { + return toggleInlineSelectionFormat(state, spec, sel); + }); + return changes; +}; + +// Toggle formatting in a region, applying block formatting +export const toggleRegionFormatGlobally = ( + state: EditorState, + + inlineSpec: RegionSpec, + blockSpec: RegionSpec +): TransactionSpec => { + const doc = state.doc; + const preserveBlockQuotes = true; + + const getMatchEndPoints = ( + match: RegExpMatchArray, line: Line, inBlockQuote: boolean + ): [startIdx: number, stopIdx: number] => { + const startIdx = line.from + match.index; + let stopIdx; + + // Don't treat '> ' as part of the line's content if we're in a blockquote. + let contentLength = line.text.length; + if (inBlockQuote && preserveBlockQuotes) { + contentLength -= blockQuoteStartLen; + } + + // If it matches the entire line, remove the newline character. + if (match[0].length === contentLength) { + stopIdx = line.to + 1; + } else { + stopIdx = startIdx + match[0].length; + + // Take into account the extra '> ' characters, if necessary + if (inBlockQuote && preserveBlockQuotes) { + stopIdx += blockQuoteStartLen; + } + } + + stopIdx = Math.min(stopIdx, doc.length); + return [startIdx, stopIdx]; + }; + + // Returns a change spec that converts an inline region to a block region + // only if the user's cursor is in an empty inline region. + // For example, + // $|$ -> $$\n|\n$$ where | represents the cursor. + const handleInlineToBlockConversion = (sel: SelectionRange) => { + if (!sel.empty) { + return null; + } + + const startMatchLen = findInlineMatch(doc, inlineSpec, sel, MatchSide.Start); + const stopMatchLen = findInlineMatch(doc, inlineSpec, sel, MatchSide.End); + + if (startMatchLen >= 0 && stopMatchLen >= 0) { + const fromLine = doc.lineAt(sel.from); + const inBlockQuote = fromLine.text.match(blockQuoteRegex); + + let lineStartStr = '\n'; + if (inBlockQuote && preserveBlockQuotes) { + lineStartStr = '\n> '; + } + + + const inlineStart = sel.from - startMatchLen; + const inlineStop = sel.from + stopMatchLen; + + // Determine the text that starts the new block (e.g. \n$$\n for + // a math block). + let blockStart = `${blockSpec.template.start}${lineStartStr}`; + if (fromLine.from !== inlineStart) { + // Add a line before to put the start of the block + // on its own line. + blockStart = lineStartStr + blockStart; + } + + return { + changes: [ + { + from: inlineStart, + to: inlineStop, + insert: `${blockStart}${lineStartStr}${blockSpec.template.end}`, + }, + ], + + range: EditorSelection.cursor(inlineStart + blockStart.length), + }; + } + + return null; + }; + + const changes = state.changeByRange((sel: SelectionRange) => { + const blockConversion = handleInlineToBlockConversion(sel); + if (blockConversion) { + return blockConversion; + } + + // If we're in the block version, grow the selection to cover the entire region. + sel = growSelectionToNode(state, sel, blockSpec.nodeName); + + const fromLine = doc.lineAt(sel.from); + const toLine = doc.lineAt(sel.to); + let fromLineText = fromLine.text; + let toLineText = toLine.text; + + let charsAdded = 0; + const changes = []; + + // Single line: Inline toggle. + if (fromLine.number === toLine.number) { + return toggleInlineSelectionFormat(state, inlineSpec, sel); + } + + // Are all lines in a block quote? + let inBlockQuote = true; + for (let i = fromLine.number; i <= toLine.number; i++) { + const line = doc.line(i); + + if (!line.text.match(blockQuoteRegex)) { + inBlockQuote = false; + break; + } + } + + // Ignore block quote characters if in a block quote. + if (inBlockQuote && preserveBlockQuotes) { + fromLineText = fromLineText.substring(blockQuoteStartLen); + toLineText = toLineText.substring(blockQuoteStartLen); + } + + // Otherwise, we're toggling the block version + const startMatch = blockSpec.matcher.start.exec(fromLineText); + const stopMatch = blockSpec.matcher.end.exec(toLineText); + if (startMatch && stopMatch) { + // Get start and stop indicies for the starting and ending matches + const [fromMatchFrom, fromMatchTo] = getMatchEndPoints(startMatch, fromLine, inBlockQuote); + const [toMatchFrom, toMatchTo] = getMatchEndPoints(stopMatch, toLine, inBlockQuote); + + // Delete content of the first line + changes.push({ + from: fromMatchFrom, + to: fromMatchTo, + }); + charsAdded -= fromMatchTo - fromMatchFrom; + + // Delete content of the last line + changes.push({ + from: toMatchFrom, + to: toMatchTo, + }); + charsAdded -= toMatchTo - toMatchFrom; + } else { + let insertBefore, insertAfter; + + if (inBlockQuote && preserveBlockQuotes) { + insertBefore = `> ${blockSpec.template.start}\n`; + insertAfter = `\n> ${blockSpec.template.end}`; + } else { + insertBefore = `${blockSpec.template.start}\n`; + insertAfter = `\n${blockSpec.template.end}`; + } + + changes.push({ + from: fromLine.from, + insert: insertBefore, + }); + + changes.push({ + from: toLine.to, + insert: insertAfter, + }); + charsAdded += insertBefore.length + insertAfter.length; + } + + return { + changes, + + // Selection should now encompass all lines that were changed. + range: EditorSelection.range( + fromLine.from, toLine.to + charsAdded + ), + }; + }); + + return changes; +}; + +// Toggles whether all lines in the user's selection start with [regex]. +export const toggleSelectedLinesStartWith = ( + state: EditorState, + regex: RegExp, + template: string, + matchEmpty: boolean, + + // Name associated with what [regex] matches (e.g. FencedCode) + nodeName?: string +): TransactionSpec => { + const ignoreBlockQuotes = true; + const getLineContentStart = (line: Line): number => { + if (!ignoreBlockQuotes) { + return line.from; + } + + const blockQuoteMatch = line.text.match(blockQuoteRegex); + if (blockQuoteMatch) { + return line.from + blockQuoteMatch[0].length; + } + + return line.from; + }; + + const getLineContent = (line: Line): string => { + const contentStart = getLineContentStart(line); + return line.text.substring(contentStart - line.from); + }; + + const changes = state.changeByRange((sel: SelectionRange) => { + // Attempt to select all lines in the region + if (nodeName && sel.empty) { + sel = growSelectionToNode(state, sel, nodeName); + } + + const doc = state.doc; + const fromLine = doc.lineAt(sel.from); + const toLine = doc.lineAt(sel.to); + let hasProp = false; + let charsAdded = 0; + + const changes = []; + const lines = []; + + for (let i = fromLine.number; i <= toLine.number; i++) { + const line = doc.line(i); + const text = getLineContent(line); + + // If already matching [regex], + if (text.search(regex) === 0) { + hasProp = true; + } + + lines.push(line); + } + + for (const line of lines) { + const text = getLineContent(line); + const contentFrom = getLineContentStart(line); + + // Only process if the line is non-empty. + if (!matchEmpty && text.trim().length === 0 + // Treat the first line differently + && fromLine.number < line.number) { + continue; + } + + if (hasProp) { + const match = text.match(regex); + if (!match) { + continue; + } + changes.push({ + from: contentFrom, + to: contentFrom + match[0].length, + insert: '', + }); + + charsAdded -= match[0].length; + } else { + changes.push({ + from: contentFrom, + insert: template, + }); + + charsAdded += template.length; + } + } + + // If the selection is empty and a single line was changed, don't grow it. + // (user might be adding a list/header, in which case, selecting the just + // added text isn't helpful) + let newSel; + if (sel.empty && fromLine.number === toLine.number) { + const regionEnd = toLine.to + charsAdded; + newSel = EditorSelection.cursor(regionEnd); + } else { + newSel = EditorSelection.range(fromLine.from, toLine.to + charsAdded); + } + + return { + changes, + + // Selection should now encompass all lines that were changed. + range: newSel, + }; + }); + + return changes; +}; + +// Ensures that ordered lists within [sel] are numbered in ascending order. +export const renumberList = (state: EditorState, sel: SelectionRange): SelectionUpdate => { + const doc = state.doc; + + const listItemRegex = /^(\s*)(\d+)\.\s?/; + const changes: ChangeSpec[] = []; + const fromLine = doc.lineAt(sel.from); + const toLine = doc.lineAt(sel.to); + let charsAdded = 0; + + // Re-numbers ordered lists and sublists with numbers on each line in [linesToHandle] + const handleLines = (linesToHandle: Line[]) => { + let currentGroupIndentation = ''; + let nextListNumber = 1; + const listNumberStack: number[] = []; + let prevLineNumber; + + for (const line of linesToHandle) { + // Don't re-handle lines. + if (line.number === prevLineNumber) { + continue; + } + prevLineNumber = line.number; + + const filteredText = stripBlockquote(line); + const match = filteredText.match(listItemRegex); + const indentation = match[1]; + + const indentationLen = tabsToSpaces(state, indentation).length; + const targetIndentLen = tabsToSpaces(state, currentGroupIndentation).length; + if (targetIndentLen < indentationLen) { + listNumberStack.push(nextListNumber); + nextListNumber = 1; + } else if (targetIndentLen > indentationLen) { + nextListNumber = listNumberStack.pop() ?? parseInt(match[2], 10); + } + + if (targetIndentLen !== indentationLen) { + currentGroupIndentation = indentation; + } + + const from = line.to - filteredText.length; + const to = from + match[0].length; + const inserted = `${indentation}${nextListNumber}. `; + nextListNumber++; + + changes.push({ + from, + to, + insert: inserted, + }); + charsAdded -= to - from; + charsAdded += inserted.length; + } + }; + + const linesToHandle: Line[] = []; + syntaxTree(state).iterate({ + from: sel.from, + to: sel.to, + enter: (nodeRef: SyntaxNodeRef) => { + if (nodeRef.name === 'ListItem') { + for (const node of nodeRef.node.parent.getChildren('ListItem')) { + const line = doc.lineAt(node.from); + const filteredText = stripBlockquote(line); + const match = filteredText.match(listItemRegex); + if (match) { + linesToHandle.push(line); + } + } + } + }, + }); + + linesToHandle.sort((a, b) => a.number - b.number); + handleLines(linesToHandle); + + // Re-position the selection in a way that makes sense + if (sel.empty) { + sel = EditorSelection.cursor(toLine.to + charsAdded); + } else { + sel = EditorSelection.range( + fromLine.from, + toLine.to + charsAdded + ); + } + + return { + range: sel, + changes, + }; +}; diff --git a/packages/app-mobile/components/NoteEditor/CodeMirror/types.ts b/packages/app-mobile/components/NoteEditor/CodeMirror/types.ts new file mode 100644 index 00000000000..cfcc1974dde --- /dev/null +++ b/packages/app-mobile/components/NoteEditor/CodeMirror/types.ts @@ -0,0 +1,29 @@ +import { ListType, SearchControl } from '../types'; + +// Controls for the CodeMirror portion of the editor +export interface CodeMirrorControl { + undo(): void; + redo(): void; + select(anchor: number, head: number): void; + insertText(text: string): void; + + setSpellcheckEnabled(enabled: boolean): void; + + // Toggle whether we're in a type of region. + toggleBolded(): void; + toggleItalicized(): void; + toggleList(kind: ListType): void; + toggleCode(): void; + toggleMath(): void; + toggleHeaderLevel(level: number): void; + + // Create a new link or update the currently selected link with + // the given [label] and [url]. + updateLink(label: string, url: string): void; + + increaseIndent(): void; + decreaseIndent(): void; + scrollSelectionIntoView(): void; + + searchControl: SearchControl; +} diff --git a/packages/app-mobile/components/NoteEditor/CodeMirror/webviewLogger.ts b/packages/app-mobile/components/NoteEditor/CodeMirror/webviewLogger.ts new file mode 100644 index 00000000000..124eefdae17 --- /dev/null +++ b/packages/app-mobile/components/NoteEditor/CodeMirror/webviewLogger.ts @@ -0,0 +1,19 @@ +// Handle logging strings when running in a WebView. + +// Because this will be running both in a WebView and in nodeJS, we need to use +// globalThis in place of window. We need to tell ESLint that we're doing this: +/* global globalThis*/ + +export function postMessage(name: string, data: any) { + // Only call postMessage if we're running in a WebView (this code may be called + // in integration tests). + (globalThis as any).ReactNativeWebView?.postMessage(JSON.stringify({ + data, + name, + })); +} + +export function logMessage(...msg: any[]) { + postMessage('onLog', { value: msg }); +} + diff --git a/packages/app-mobile/components/NoteEditor/EditLinkDialog.tsx b/packages/app-mobile/components/NoteEditor/EditLinkDialog.tsx new file mode 100644 index 00000000000..cb388fc2cdc --- /dev/null +++ b/packages/app-mobile/components/NoteEditor/EditLinkDialog.tsx @@ -0,0 +1,156 @@ +// Dialog allowing the user to update/create links + +const React = require('react'); +const { useState, useEffect, useMemo, useRef } = require('react'); +const { StyleSheet } = require('react-native'); +const { View, Modal, Text, TextInput, Button } = require('react-native'); + +import { themeStyle } from '@joplin/lib/theme'; +import { _ } from '@joplin/lib/locale'; +import { EditorControl } from './types'; +import SelectionFormatting from './SelectionFormatting'; +import { useCallback } from 'react'; + +interface LinkDialogProps { + editorControl: EditorControl; + selectionState: SelectionFormatting; + visible: boolean; + themeId: number; +} + +const EditLinkDialog = (props: LinkDialogProps) => { + // The content of the link selected in the editor (if any) + const editorLinkData = props.selectionState.linkData; + const [linkLabel, setLinkLabel] = useState(''); + const [linkURL, setLinkURL] = useState(''); + + const linkInputRef = useRef(); + + // Reset the label and URL when shown/hidden + useEffect(() => { + setLinkLabel(editorLinkData.linkText ?? props.selectionState.selectedText); + setLinkURL(editorLinkData.linkURL ?? ''); + }, [ + props.visible, editorLinkData.linkText, props.selectionState.selectedText, + editorLinkData.linkURL, + ]); + + const [styles, placeholderColor] = useMemo(() => { + const theme = themeStyle(props.themeId); + + const styleSheet = StyleSheet.create({ + modalContent: { + margin: 15, + padding: 30, + backgroundColor: theme.backgroundColor, + + elevation: 5, + shadowOffset: { + width: 1, + height: 1, + }, + shadowOpacity: 0.4, + shadowRadius: 1, + }, + button: { + color: theme.color2, + backgroundColor: theme.backgroundColor2, + }, + text: { + color: theme.color, + }, + header: { + color: theme.color, + fontSize: 22, + }, + input: { + color: theme.color, + backgroundColor: theme.backgroundColor, + + minHeight: 48, + borderBottomColor: theme.backgroundColor3, + borderBottomWidth: 1, + }, + inputContainer: { + flexDirection: 'column', + paddingBottom: 10, + }, + }); + const placeholderColor = theme.colorFaded; + return [styleSheet, placeholderColor]; + }, [props.themeId]); + + const onSubmit = useCallback(() => { + props.editorControl.updateLink(linkLabel, linkURL); + props.editorControl.hideLinkDialog(); + }, [props.editorControl, linkLabel, linkURL]); + + // See https://www.hingehealth.com/engineering-blog/accessible-react-native-textinput/ + // for more about creating accessible RN inputs. + const linkTextInput = ( + + {_('Link Text')} + { + linkInputRef.current.focus(); + }} + onChangeText={(text: string) => setLinkLabel(text)} + /> + + ); + + const linkURLInput = ( + + {_('URL')} + setLinkURL(text)} + /> + + ); + + return ( + { + props.editorControl.hideLinkDialog(); + }}> + + {_('Edit Link')} + + {linkTextInput} + {linkURLInput} + +