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}
+
+
+
+
+ );
+};
+
+export default EditLinkDialog;
diff --git a/packages/app-mobile/components/NoteEditor/NoteEditor.tsx b/packages/app-mobile/components/NoteEditor/NoteEditor.tsx
index 16f8b519529..fd6487ac382 100644
--- a/packages/app-mobile/components/NoteEditor/NoteEditor.tsx
+++ b/packages/app-mobile/components/NoteEditor/NoteEditor.tsx
@@ -1,28 +1,26 @@
import Setting from '@joplin/lib/models/Setting';
import shim from '@joplin/lib/shim';
import { themeStyle } from '@joplin/lib/theme';
+import EditLinkDialog from './EditLinkDialog';
+import { defaultSearchState, SearchPanel } from './SearchPanel';
+
const React = require('react');
-const { forwardRef, useImperativeHandle, useEffect, useMemo, useState, useCallback, useRef } = require('react');
+const { forwardRef, useImperativeHandle } = require('react');
+const { useEffect, useMemo, useState, useCallback, useRef } = require('react');
const { WebView } = require('react-native-webview');
+const { View } = require('react-native');
const { editorFont } = require('../global-style');
-export interface ChangeEvent {
- value: string;
-}
-
-export interface UndoRedoDepthChangeEvent {
- undoDepth: number;
- redoDepth: number;
-}
-
-export interface Selection {
- start: number;
- end: number;
-}
+import SelectionFormatting from './SelectionFormatting';
+import {
+ EditorSettings,
+ EditorControl,
-export interface SelectionChangeEvent {
- selection: Selection;
-}
+ ChangeEvent, UndoRedoDepthChangeEvent, Selection, SelectionChangeEvent,
+ ListType,
+ SearchState,
+} from './types';
+import { _ } from '@joplin/lib/locale';
type ChangeEventHandler = (event: ChangeEvent)=> void;
type UndoRedoDepthChangeHandler = (event: UndoRedoDepthChangeEvent)=> void;
@@ -53,6 +51,18 @@ function useCss(themeId: number): string {
}
body {
+ margin: 0;
+ height: 100vh;
+ width: 100vh;
+ width: 100vw;
+ min-width: 100vw;
+ box-sizing: border-box;
+
+ padding-left: 1px;
+ padding-right: 1px;
+ padding-bottom: 1px;
+ padding-top: 10px;
+
font-size: 13pt;
}
`;
@@ -62,28 +72,27 @@ function useCss(themeId: number): string {
function useHtml(css: string): string {
const [html, setHtml] = useState('');
- useEffect(() => {
- setHtml(
- `
-
-
-
-
-
-
-
-
-
-
-
- `
- );
+ useMemo(() => {
+ setHtml(`
+
+
+
+
+
+ ${_('Note editor')}
+
+
+
+
+
+
+ `);
}, [css]);
return html;
@@ -105,6 +114,11 @@ function NoteEditor(props: Props, ref: any) {
cm.select(${props.initialSelection.start}, ${props.initialSelection.end});
` : '';
+ const editorSettings: EditorSettings = {
+ themeData: editorTheme(props.themeId),
+ katexEnabled: Setting.value('markdown.plugin.katex') as boolean,
+ };
+
const injectedJavaScript = `
function postMessage(name, data) {
window.ReactNativeWebView.postMessage(JSON.stringify({
@@ -117,51 +131,158 @@ function NoteEditor(props: Props, ref: any) {
postMessage('onLog', { value: msg });
}
- // This variable is not used within this script
- // but is called using "injectJavaScript" from
- // the wrapper component.
- window.cm = null;
+ // Globalize logMessage, postMessage
+ window.logMessage = logMessage;
+ window.postMessage = postMessage;
- try {
- ${shim.injectedJs('codeMirrorBundle')};
+ window.onerror = (message, source, lineno) => {
+ window.ReactNativeWebView.postMessage(
+ "error: " + message + " in file://" + source + ", line " + lineno
+ );
+ };
- const parentElement = document.getElementsByClassName('CodeMirror')[0];
- const theme = ${JSON.stringify(editorTheme(props.themeId))};
- const initialText = ${JSON.stringify(props.initialText)};
+ if (!window.cm) {
+ // This variable is not used within this script
+ // but is called using "injectJavaScript" from
+ // the wrapper component.
+ window.cm = null;
- cm = codeMirrorBundle.initCodeMirror(parentElement, initialText, theme);
- ${setInitialSelectionJS}
+ try {
+ ${shim.injectedJs('codeMirrorBundle')};
- // Fixes https://github.com/laurent22/joplin/issues/5949
- window.onresize = () => {
- cm.scrollSelectionIntoView();
- };
- } catch (e) {
- window.ReactNativeWebView.postMessage("error:" + e.message + ": " + JSON.stringify(e))
+ const parentElement = document.getElementsByClassName('CodeMirror')[0];
+ const initialText = ${JSON.stringify(props.initialText)};
+ const settings = ${JSON.stringify(editorSettings)};
+
+ cm = codeMirrorBundle.initCodeMirror(parentElement, initialText, settings);
+ ${setInitialSelectionJS}
+
+ window.onresize = () => {
+ cm.scrollSelectionIntoView();
+ };
+ } catch (e) {
+ window.ReactNativeWebView.postMessage("error:" + e.message + ": " + JSON.stringify(e))
+ }
}
true;
`;
const css = useCss(props.themeId);
const html = useHtml(css);
+ const [selectionState, setSelectionState] = useState(new SelectionFormatting());
+ const [searchState, setSearchState] = useState(defaultSearchState);
+ const [linkDialogVisible, setLinkDialogVisible] = useState(false);
+
+ // / Runs [js] in the context of the CodeMirror frame.
+ const injectJS = (js: string) => {
+ webviewRef.current.injectJavaScript(`
+ try {
+ ${js}
+ }
+ catch(e) {
+ logMessage('Error in injected JS:' + e, e);
+ throw e;
+ };
- useImperativeHandle(ref, () => {
- return {
- undo: function() {
- webviewRef.current.injectJavaScript('cm.undo(); true;');
+ true;`);
+ };
+
+
+ const editorControl: EditorControl = {
+ undo() {
+ injectJS('cm.undo();');
+ },
+ redo() {
+ injectJS('cm.redo();');
+ },
+ select(anchor: number, head: number) {
+ injectJS(
+ `cm.select(${JSON.stringify(anchor)}, ${JSON.stringify(head)});`
+ );
+ },
+ insertText(text: string) {
+ injectJS(`cm.insertText(${JSON.stringify(text)});`);
+ },
+
+ toggleBolded() {
+ injectJS('cm.toggleBolded();');
+ },
+ toggleItalicized() {
+ injectJS('cm.toggleItalicized();');
+ },
+ toggleList(listType: ListType) {
+ injectJS(`cm.toggleList(${JSON.stringify(listType)});`);
+ },
+ toggleCode() {
+ injectJS('cm.toggleCode();');
+ },
+ toggleMath() {
+ injectJS('cm.toggleMath();');
+ },
+ toggleHeaderLevel(level: number) {
+ injectJS(`cm.toggleHeaderLevel(${level});`);
+ },
+ increaseIndent() {
+ injectJS('cm.increaseIndent();');
+ },
+ decreaseIndent() {
+ injectJS('cm.decreaseIndent();');
+ },
+ updateLink(label: string, url: string) {
+ injectJS(`cm.updateLink(
+ ${JSON.stringify(label)},
+ ${JSON.stringify(url)}
+ );`);
+ },
+ scrollSelectionIntoView() {
+ injectJS('cm.scrollSelectionIntoView();');
+ },
+ showLinkDialog() {
+ setLinkDialogVisible(true);
+ },
+ hideLinkDialog() {
+ setLinkDialogVisible(false);
+ },
+ hideKeyboard() {
+ injectJS('document.activeElement?.blur();');
+ },
+ setSpellcheckEnabled(enabled: boolean) {
+ injectJS(`cm.setSpellcheckEnabled(${enabled ? 'true' : 'false'});`);
+ },
+ searchControl: {
+ findNext() {
+ injectJS('cm.searchControl.findNext();');
},
- redo: function() {
- webviewRef.current.injectJavaScript('cm.redo(); true;');
+ findPrevious() {
+ injectJS('cm.searchControl.findPrevious();');
},
- select: (anchor: number, head: number) => {
- webviewRef.current.injectJavaScript(
- `cm.select(${JSON.stringify(anchor)}, ${JSON.stringify(head)}); true;`
- );
+ replaceCurrent() {
+ injectJS('cm.searchControl.replaceCurrent();');
},
- insertText: (text: string) => {
- webviewRef.current.injectJavaScript(`cm.insertText(${JSON.stringify(text)}); true;`);
+ replaceAll() {
+ injectJS('cm.searchControl.replaceAll();');
},
- };
+ setSearchState(state: SearchState) {
+ injectJS(`cm.searchControl.setSearchState(${JSON.stringify(state)})`);
+ setSearchState(state);
+ },
+ showSearch() {
+ const newSearchState: SearchState = Object.assign({}, searchState);
+ newSearchState.dialogVisible = true;
+
+ setSearchState(newSearchState);
+ },
+ hideSearch() {
+ const newSearchState: SearchState = Object.assign({}, searchState);
+ newSearchState.dialogVisible = false;
+
+ setSearchState(newSearchState);
+ },
+ },
+ };
+
+ useImperativeHandle(ref, () => {
+ return editorControl;
});
useEffect(() => {
@@ -211,6 +332,26 @@ function NoteEditor(props: Props, ref: any) {
onSelectionChange: (event: SelectionChangeEvent) => {
props.onSelectionChange(event);
},
+
+ onSelectionFormattingChange(data: string) {
+ // We want a SelectionFormatting object, so are
+ // instantiating it from JSON.
+ const formatting = SelectionFormatting.fromJSON(data);
+ setSelectionState(formatting);
+ },
+
+ onRequestLinkEdit() {
+ editorControl.showLinkDialog();
+ },
+
+ onRequestShowSearch(data: SearchState) {
+ setSearchState(data);
+ editorControl.searchControl.showSearch();
+ },
+
+ onRequestHideSearch() {
+ editorControl.searchControl.hideSearch();
+ },
};
if (handlers[msg.name]) {
@@ -224,24 +365,53 @@ function NoteEditor(props: Props, ref: any) {
console.error('NoteEditor: webview error');
});
+
// - `setSupportMultipleWindows` must be `true` for security reasons:
// https://github.com/react-native-webview/react-native-webview/releases/tag/v11.0.0
// - `scrollEnabled` prevents iOS from scrolling the document (has no effect on Android)
// when the editor is focused.
- return ;
+ return (
+
+
+
+
+
+
+
+
+ );
}
export default forwardRef(NoteEditor);
diff --git a/packages/app-mobile/components/NoteEditor/SearchPanel.tsx b/packages/app-mobile/components/NoteEditor/SearchPanel.tsx
new file mode 100644
index 00000000000..99458af988a
--- /dev/null
+++ b/packages/app-mobile/components/NoteEditor/SearchPanel.tsx
@@ -0,0 +1,355 @@
+// Displays a find/replace dialog
+
+const React = require('react');
+const { StyleSheet } = require('react-native');
+const { TextInput, View, Text, TouchableOpacity } = require('react-native');
+const { useMemo, useState, useEffect } = require('react');
+const MaterialCommunityIcon = require('react-native-vector-icons/MaterialCommunityIcons').default;
+
+import { SearchControl, SearchState, EditorSettings } from './types';
+import { _ } from '@joplin/lib/locale';
+import { BackHandler } from 'react-native';
+import { Theme } from '@joplin/lib/themes/type';
+
+const buttonSize = 48;
+
+type OnChangeCallback = (text: string)=> void;
+type Callback = ()=> void;
+
+export const defaultSearchState: SearchState = {
+ useRegex: false,
+ caseSensitive: false,
+
+ searchText: '',
+ replaceText: '',
+ dialogVisible: false,
+};
+
+export interface SearchPanelProps {
+ searchControl: SearchControl;
+ searchState: SearchState;
+ editorSettings: EditorSettings;
+}
+
+interface ActionButtonProps {
+ styles: any;
+ iconName: string;
+ title: string;
+ onPress: Callback;
+}
+
+const ActionButton = (
+ props: ActionButtonProps
+) => {
+ return (
+
+
+
+ );
+};
+
+interface ToggleButtonProps {
+ styles: any;
+ iconName: string;
+ title: string;
+ active: boolean;
+ onToggle: Callback;
+}
+const ToggleButton = (props: ToggleButtonProps) => {
+ const active = props.active;
+
+ return (
+
+
+
+ );
+};
+
+
+const useStyles = (theme: Theme) => {
+ return useMemo(() => {
+ const buttonStyle = {
+ width: buttonSize,
+ height: buttonSize,
+ backgroundColor: theme.backgroundColor4,
+ alignItems: 'center',
+ justifyContent: 'center',
+ flexShrink: 1,
+ };
+ const buttonTextStyle = {
+ color: theme.color4,
+ fontSize: 30,
+ };
+
+ return StyleSheet.create({
+ button: buttonStyle,
+ toggleButton: {
+ ...buttonStyle,
+ },
+ toggleButtonActive: {
+ ...buttonStyle,
+ backgroundColor: theme.backgroundColor3,
+ },
+ input: {
+ flexGrow: 1,
+ height: buttonSize,
+ backgroundColor: theme.backgroundColor4,
+ color: theme.color4,
+ },
+ buttonText: buttonTextStyle,
+ activeButtonText: {
+ ...buttonTextStyle,
+ color: theme.color4,
+ },
+ text: {
+ color: theme.color,
+ },
+ labeledInput: {
+ flexGrow: 1,
+ flexDirection: 'row',
+ alignItems: 'center',
+ justifyContent: 'center',
+ marginLeft: 10,
+ },
+ });
+ }, [theme]);
+};
+
+export const SearchPanel = (props: SearchPanelProps) => {
+ const placeholderColor = props.editorSettings.themeData.color3;
+ const styles = useStyles(props.editorSettings.themeData);
+
+ const [showingAdvanced, setShowAdvanced] = useState(false);
+
+ const state = props.searchState;
+ const control = props.searchControl;
+
+ const updateSearchState = (changedData: any) => {
+ const newState = Object.assign({}, state, changedData);
+ control.setSearchState(newState);
+ };
+
+ // Creates a TextInut with the given parameters
+ const createInput = (
+ placeholder: string, value: string, onChange: OnChangeCallback, autoFocus: boolean
+ ) => {
+ return (
+
+ );
+ };
+
+ // Close the search dialog on back button press
+ useEffect(() => {
+ // Only register the listener if the dialog is visible
+ if (!state.dialogVisible) {
+ return () => {};
+ }
+
+ const backListener = BackHandler.addEventListener('hardwareBackPress', () => {
+ control.hideSearch();
+ return true;
+ });
+
+ return () => backListener.remove();
+ }, [state.dialogVisible]);
+
+
+
+ const closeButton = (
+
+ );
+
+ const showDetailsButton = (
+ setShowAdvanced(true)}
+ title={_('Show advanced')}
+ />
+ );
+
+ const hideDetailsButton = (
+ setShowAdvanced(false)}
+ title={_('Hide advanced')}
+ />
+ );
+
+ const searchTextInput = createInput(
+ _('Search for...'),
+ state.searchText,
+ (newText: string) => {
+ updateSearchState({
+ searchText: newText,
+ });
+ },
+
+ // Autofocus
+ true
+ );
+
+ const replaceTextInput = createInput(
+ _('Replace with...'),
+ state.replaceText,
+ (newText: string) => {
+ updateSearchState({
+ replaceText: newText,
+ });
+ },
+
+ // Don't autofocus
+ false
+ );
+
+ const labeledSearchInput = (
+
+ {_('Find: ')}
+ { searchTextInput }
+
+ );
+
+ const labeledReplaceInput = (
+
+ {_('Replace: ')}
+ { replaceTextInput }
+
+ );
+
+ const toNextButton = (
+
+ );
+
+ const toPrevButton = (
+
+ );
+
+ const replaceButton = (
+
+ );
+
+ const replaceAllButton = (
+
+ );
+
+ const regexpButton = (
+ {
+ updateSearchState({
+ useRegex: !state.useRegex,
+ });
+ }}
+ active={state.useRegex}
+ title={_('Regular expression')}
+ />
+ );
+
+ const caseSensitiveButton = (
+ {
+ updateSearchState({
+ caseSensitive: !state.caseSensitive,
+ });
+ }}
+ active={state.caseSensitive}
+ title={_('Case sensitive')}
+ />
+ );
+
+ const simpleLayout = (
+
+ { closeButton }
+ { searchTextInput }
+ { showDetailsButton }
+ { toPrevButton }
+ { toNextButton }
+
+ );
+
+ const advancedLayout = (
+
+
+ { closeButton }
+ { labeledSearchInput }
+ { hideDetailsButton }
+ { toPrevButton }
+ { toNextButton }
+
+
+ { regexpButton }
+ { caseSensitiveButton }
+ { labeledReplaceInput }
+ { replaceButton }
+ { replaceAllButton }
+
+
+ );
+
+ if (!state.dialogVisible) {
+ return null;
+ }
+
+ return showingAdvanced ? advancedLayout : simpleLayout;
+};
+
+export default SearchPanel;
diff --git a/packages/app-mobile/components/NoteEditor/SelectionFormatting.ts b/packages/app-mobile/components/NoteEditor/SelectionFormatting.ts
new file mode 100644
index 00000000000..a5540aef2f5
--- /dev/null
+++ b/packages/app-mobile/components/NoteEditor/SelectionFormatting.ts
@@ -0,0 +1,98 @@
+// Stores information about the current content of the user's selection
+
+export default class SelectionFormatting {
+ public bolded: boolean = false;
+ public italicized: boolean = false;
+ public inChecklist: boolean = false;
+ public inCode: boolean = false;
+ public inUnorderedList: boolean = false;
+ public inOrderedList: boolean = false;
+ public inMath: boolean = false;
+ public inLink: boolean = false;
+ public spellChecking: boolean = false;
+ public unspellCheckableRegion: boolean = false;
+
+ // Link data, both fields are null if not in a link.
+ public linkData: { linkText?: string; linkURL?: string } = {
+ linkText: null,
+ linkURL: null,
+ };
+
+ // If [headerLevel], [listLevel], etc. are zero, then the
+ // selection isn't in a header/list
+ public headerLevel: number = 0;
+ public listLevel: number = 0;
+
+ // Content of the selection
+ public selectedText: string = '';
+
+ // List of data properties (for serializing/deseralizing)
+ private static propNames: string[] = [
+ 'bolded', 'italicized', 'inChecklist', 'inCode',
+ 'inUnorderedList', 'inOrderedList', 'inMath',
+ 'inLink', 'linkData',
+
+ 'headerLevel', 'listLevel',
+
+ 'selectedText',
+
+ 'spellChecking',
+ 'unspellCheckableRegion',
+ ];
+
+ // Returns true iff [this] is equivalent to [other]
+ public eq(other: SelectionFormatting): boolean {
+ // Cast to Records to allow usage of the indexing ([])
+ // operator.
+ const selfAsRec = this as Record;
+ const otherAsRec = other as Record;
+
+ for (const prop of SelectionFormatting.propNames) {
+ if (selfAsRec[prop] !== otherAsRec[prop]) {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ public static fromJSON(json: string): SelectionFormatting {
+ const result = new SelectionFormatting();
+
+ // Casting result to a Record lets us use
+ // the indexing [] operator.
+ const resultRecord = result as Record;
+ const obj = JSON.parse(json) as Record;
+
+ for (const prop of SelectionFormatting.propNames) {
+ if (obj[prop] !== undefined) {
+ // Type checking!
+ if (typeof obj[prop] !== typeof resultRecord[prop]) {
+ throw new Error([
+ 'Deserialization Error:',
+ `${obj[prop]} and ${resultRecord[prop]}`,
+ 'have different types.',
+ ].join(' '));
+ }
+
+ resultRecord[prop] = obj[prop];
+ }
+ }
+
+ return result;
+ }
+
+ public toJSON(): string {
+ const resultObj: Record = {};
+
+ // Cast this to a dictionary. This allows us to use
+ // the indexing [] operator.
+ const selfAsRecord = this as Record;
+
+ for (const prop of SelectionFormatting.propNames) {
+ resultObj[prop] = selfAsRecord[prop];
+ }
+
+ return JSON.stringify(resultObj);
+ }
+}
diff --git a/packages/app-mobile/components/NoteEditor/types.ts b/packages/app-mobile/components/NoteEditor/types.ts
new file mode 100644
index 00000000000..e86ed545ed5
--- /dev/null
+++ b/packages/app-mobile/components/NoteEditor/types.ts
@@ -0,0 +1,61 @@
+// Types related to the NoteEditor
+
+import { CodeMirrorControl } from './CodeMirror/types';
+
+// Controls for the entire editor (including dialogs)
+export interface EditorControl extends CodeMirrorControl {
+ showLinkDialog(): void;
+ hideLinkDialog(): void;
+ hideKeyboard(): void;
+}
+
+export interface EditorSettings {
+ themeData: any;
+ katexEnabled: boolean;
+}
+
+export interface ChangeEvent {
+ // New editor content
+ value: string;
+}
+
+export interface UndoRedoDepthChangeEvent {
+ undoDepth: number;
+ redoDepth: number;
+}
+
+export interface Selection {
+ start: number;
+ end: number;
+}
+
+export interface SelectionChangeEvent {
+ selection: Selection;
+}
+
+export interface SearchControl {
+ findNext(): void;
+ findPrevious(): void;
+ replaceCurrent(): void;
+ replaceAll(): void;
+ setSearchState(state: SearchState): void;
+
+ showSearch(): void;
+ hideSearch(): void;
+}
+
+export interface SearchState {
+ useRegex: boolean;
+ caseSensitive: boolean;
+
+ searchText: string;
+ replaceText: string;
+ dialogVisible: boolean;
+}
+
+// Possible types of lists in the editor
+export enum ListType {
+ CheckList,
+ OrderedList,
+ UnorderedList,
+}
diff --git a/packages/app-mobile/components/screens/Note.tsx b/packages/app-mobile/components/screens/Note.tsx
index 47da9164cff..1ee7aed109e 100644
--- a/packages/app-mobile/components/screens/Note.tsx
+++ b/packages/app-mobile/components/screens/Note.tsx
@@ -5,7 +5,8 @@ import shim from '@joplin/lib/shim';
import UndoRedoService from '@joplin/lib/services/UndoRedoService';
import NoteBodyViewer from '../NoteBodyViewer/NoteBodyViewer';
import checkPermissions from '../../utils/checkPermissions';
-import NoteEditor, { ChangeEvent, UndoRedoDepthChangeEvent } from '../NoteEditor/NoteEditor';
+import NoteEditor from '../NoteEditor/NoteEditor';
+import { ChangeEvent, UndoRedoDepthChangeEvent } from '../NoteEditor/types';
const FileViewer = require('react-native-file-viewer').default;
const React = require('react');
diff --git a/packages/app-mobile/package.json b/packages/app-mobile/package.json
index a7d9f7b69ff..8b94b5eed82 100644
--- a/packages/app-mobile/package.json
+++ b/packages/app-mobile/package.json
@@ -98,6 +98,7 @@
"jest": "^28.1.1",
"jest-environment-jsdom": "^28.1.1",
"jetifier": "^1.6.5",
+ "jsdom": "^20.0.0",
"metro-react-native-babel-preset": "^0.66.2",
"nodemon": "^2.0.12",
"ts-jest": "^28.0.5",
diff --git a/yarn.lock b/yarn.lock
index 03bbea7251e..c97a4b8dd34 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -4058,6 +4058,7 @@ __metadata:
jetifier: ^1.6.5
joplin-rn-alarm-notification: ^1.0.5
jsc-android: 241213.1.0
+ jsdom: ^20.0.0
md5: ^2.2.1
metro-react-native-babel-preset: ^0.66.2
nodemon: ^2.0.12
@@ -12830,7 +12831,7 @@ __metadata:
languageName: node
linkType: hard
-"data-urls@npm:^3.0.1":
+"data-urls@npm:^3.0.1, data-urls@npm:^3.0.2":
version: 3.0.2
resolution: "data-urls@npm:3.0.2"
dependencies:
@@ -14290,6 +14291,13 @@ __metadata:
languageName: node
linkType: hard
+"entities@npm:^4.3.0":
+ version: 4.3.1
+ resolution: "entities@npm:4.3.1"
+ checksum: e8f6d2bac238494b2355e90551893882d2675142be7e7bdfcb15248ed0652a630678ba0e3a8dc750693e736cb6011f504c27dabeb4cd3330560092e88b105090
+ languageName: node
+ linkType: hard
+
"entities@npm:~2.0.0":
version: 2.0.3
resolution: "entities@npm:2.0.3"
@@ -18190,6 +18198,16 @@ __metadata:
languageName: node
linkType: hard
+"https-proxy-agent@npm:^5.0.1":
+ version: 5.0.1
+ resolution: "https-proxy-agent@npm:5.0.1"
+ dependencies:
+ agent-base: 6
+ debug: 4
+ checksum: 571fccdf38184f05943e12d37d6ce38197becdd69e58d03f43637f7fa1269cf303a7d228aa27e5b27bbd3af8f09fd938e1c91dcfefff2df7ba77c20ed8dfc765
+ languageName: node
+ linkType: hard
+
"human-signals@npm:^1.1.1":
version: 1.1.1
resolution: "human-signals@npm:1.1.1"
@@ -21603,6 +21621,46 @@ __metadata:
languageName: node
linkType: hard
+"jsdom@npm:^20.0.0":
+ version: 20.0.0
+ resolution: "jsdom@npm:20.0.0"
+ dependencies:
+ abab: ^2.0.6
+ acorn: ^8.7.1
+ acorn-globals: ^6.0.0
+ cssom: ^0.5.0
+ cssstyle: ^2.3.0
+ data-urls: ^3.0.2
+ decimal.js: ^10.3.1
+ domexception: ^4.0.0
+ escodegen: ^2.0.0
+ form-data: ^4.0.0
+ html-encoding-sniffer: ^3.0.0
+ http-proxy-agent: ^5.0.0
+ https-proxy-agent: ^5.0.1
+ is-potential-custom-element-name: ^1.0.1
+ nwsapi: ^2.2.0
+ parse5: ^7.0.0
+ saxes: ^6.0.0
+ symbol-tree: ^3.2.4
+ tough-cookie: ^4.0.0
+ w3c-hr-time: ^1.0.2
+ w3c-xmlserializer: ^3.0.0
+ webidl-conversions: ^7.0.0
+ whatwg-encoding: ^2.0.0
+ whatwg-mimetype: ^3.0.0
+ whatwg-url: ^11.0.0
+ ws: ^8.8.0
+ xml-name-validator: ^4.0.0
+ peerDependencies:
+ canvas: ^2.5.0
+ peerDependenciesMeta:
+ canvas:
+ optional: true
+ checksum: f69b40679d8cfaee2353615445aaff08b823c53dc7778ede6592d02ed12b3e9fb4e8db2b6d033551b67e08424a3adb2b79d231caa7dcda2d16019c20c705c11f
+ languageName: node
+ linkType: hard
+
"jsesc@npm:^1.3.0":
version: 1.3.0
resolution: "jsesc@npm:1.3.0"
@@ -26205,6 +26263,15 @@ __metadata:
languageName: node
linkType: hard
+"parse5@npm:^7.0.0":
+ version: 7.0.0
+ resolution: "parse5@npm:7.0.0"
+ dependencies:
+ entities: ^4.3.0
+ checksum: 7da5d61cc18eb36ffa71fc861e65cbfd1f23d96483a6631254e627be667dbc9c93ac0b0e6cb17a13a2e4033dab19bfb2f76f38e5936cfb57240ed49036a83fcc
+ languageName: node
+ linkType: hard
+
"parseurl@npm:^1.3.2, parseurl@npm:^1.3.3, parseurl@npm:~1.3.3":
version: 1.3.3
resolution: "parseurl@npm:1.3.3"
@@ -29427,6 +29494,15 @@ __metadata:
languageName: node
linkType: hard
+"saxes@npm:^6.0.0":
+ version: 6.0.0
+ resolution: "saxes@npm:6.0.0"
+ dependencies:
+ xmlchars: ^2.2.0
+ checksum: d3fa3e2aaf6c65ed52ee993aff1891fc47d5e47d515164b5449cbf5da2cbdc396137e55590472e64c5c436c14ae64a8a03c29b9e7389fc6f14035cf4e982ef3b
+ languageName: node
+ linkType: hard
+
"scheduler@npm:^0.15.0":
version: 0.15.0
resolution: "scheduler@npm:0.15.0"
@@ -34379,6 +34455,21 @@ __metadata:
languageName: node
linkType: hard
+"ws@npm:^8.8.0":
+ version: 8.8.1
+ resolution: "ws@npm:8.8.1"
+ peerDependencies:
+ bufferutil: ^4.0.1
+ utf-8-validate: ^5.0.2
+ peerDependenciesMeta:
+ bufferutil:
+ optional: true
+ utf-8-validate:
+ optional: true
+ checksum: 2152cf862cae0693f3775bc688a6afb2e989d19d626d215e70f5fcd8eb55b1c3b0d3a6a4052905ec320e2d7734e20aeedbf9744496d62f15a26ad79cf4cf7dae
+ languageName: node
+ linkType: hard
+
"xcode@npm:^2.0.0":
version: 2.1.0
resolution: "xcode@npm:2.1.0"