1
0
mirror of https://github.com/laurent22/joplin.git synced 2025-01-20 18:48:28 +02:00

Mobile: Add keyboard-activatable markdown commands (e.g. bold, italicize) (#6707)

This commit is contained in:
Henry Heino 2022-08-08 08:00:14 -07:00 committed by GitHub
parent bd5ce114a1
commit 03c3188a4a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
21 changed files with 3335 additions and 134 deletions

View File

@ -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

42
.gitignore vendored
View File

@ -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

View File

@ -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 { 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';
interface CodeMirrorResult {
editor: EditorView;
undo: Function;
redo: Function;
select(anchor: number, head: number): void;
scrollSelectionIntoView(): void;
insertText(text: string): void;
}
import { EditorState } from '@codemirror/state';
import { markdown } from '@codemirror/lang-markdown';
import { GFM as GitHubFlavoredMarkdownExtension } from '@lezer/markdown';
import { indentOnInput, indentUnit, syntaxTree } from '@codemirror/language';
import {
openSearchPanel, closeSearchPanel, SearchQuery, setSearchQuery, getSearchQuery,
highlightSelectionMatches, search, findNext, findPrevious, replaceAll, replaceNext,
} from '@codemirror/search';
function postMessage(name: string, data: any) {
(window as any).ReactNativeWebView.postMessage(JSON.stringify({
data,
name,
}));
}
import {
EditorView, drawSelection, highlightSpecialChars, ViewUpdate, Command,
} from '@codemirror/view';
import { undo, redo, history, undoDepth, redoDepth, indentWithTab } from '@codemirror/commands';
function logMessage(...msg: any[]) {
postMessage('onLog', { value: msg });
}
import { keymap, KeyBinding } from '@codemirror/view';
import { searchKeymap } from '@codemirror/search';
import { historyKeymap, defaultKeymap } from '@codemirror/commands';
export function initCodeMirror(parentElement: any, initialText: string, theme: any): CodeMirrorResult {
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, 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;
}

View File

@ -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;

View File

@ -0,0 +1,48 @@
<!--
Open this file in a web browser to more easily debug the CodeMirror editor.
Messages will show up in the console when posted.
-->
<!DOCTYPE html>
<html>
<head>
<meta name="viewport" content="width=device-width,initial-scale=1.0"/>
<meta charset="utf-8"/>
<title>CodeMirror test</title>
</head>
<body>
<div class="CodeMirror"></div>
<script>
// Override the default postMessage — codeMirrorBundle expects
// this to be present.
window.ReactNativeWebView = {
postMessage: message => {
console.log('postMessage:', message);
},
};
</script>
<script src="./CodeMirror.bundle.js"></script>
<script>
const parent = document.querySelector('.CodeMirror');
const initialText = 'Testing...';
const settings = {
katexEnabled: true,
themeData: {
fontSize: 1, // em
fontFamily: 'serif',
backgroundColor: 'black',
color: 'white',
backgroundColor2: '#330',
color2: '#ff0',
backgroundColor3: '#404',
color3: '#f0f',
backgroundColor4: '#555',
color4: '#0ff',
appearance: 'dark',
},
};
codeMirrorBundle.initCodeMirror(parent, initialText, settings);
</script>
</body>
</html>

View File

@ -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. ☑'
);
});
});

View File

@ -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.');
});
});

View File

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

View File

@ -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, RegExp> = {
[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;
};
};

View File

@ -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(' ');
});
});

View File

@ -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,
};
};

View File

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

View File

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

View File

@ -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 = (
<View style={styles.inputContainer} accessible>
<Text style={styles.text}>{_('Link Text')}</Text>
<TextInput
style={styles.input}
placeholder={_('Description of the link')}
placeholderTextColor={placeholderColor}
value={linkLabel}
returnKeyType="next"
autoFocus
onSubmitEditing={() => {
linkInputRef.current.focus();
}}
onChangeText={(text: string) => setLinkLabel(text)}
/>
</View>
);
const linkURLInput = (
<View style={styles.inputContainer} accessible>
<Text style={styles.text}>{_('URL')}</Text>
<TextInput
style={styles.input}
placeholder={_('URL')}
placeholderTextColor={placeholderColor}
value={linkURL}
ref={linkInputRef}
autoCorrect={false}
autoCapitalize="none"
keyboardType="url"
textContentType="URL"
returnKeyType="done"
onSubmitEditing={onSubmit}
onChangeText={(text: string) => setLinkURL(text)}
/>
</View>
);
return (
<Modal
animationType="slide"
transparent={true}
visible={props.visible}
onRequestClose={() => {
props.editorControl.hideLinkDialog();
}}>
<View style={styles.modalContent}>
<Text style={styles.header}>{_('Edit Link')}</Text>
<View>
{linkTextInput}
{linkURLInput}
</View>
<Button
style={styles.button}
onPress={onSubmit}
title={_('Done')}
/>
</View>
</Modal>
);
};
export default EditLinkDialog;

View File

@ -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;
}
import SelectionFormatting from './SelectionFormatting';
import {
EditorSettings,
EditorControl,
export interface UndoRedoDepthChangeEvent {
undoDepth: number;
redoDepth: number;
}
export interface Selection {
start: number;
end: number;
}
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(
`
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
<style>
.cm-editor {
height: 100%;
}
useMemo(() => {
setHtml(`
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
<title>${_('Note editor')}</title>
<style>
.cm-editor {
height: 100%;
}
${css}
</style>
</head>
<body style="margin:0; height:100vh; width:100vh; width:100vw; min-width:100vw; box-sizing: border-box; padding: 10px;">
<div class="CodeMirror" style="height:100%;" autocapitalize="on"></div>
</body>
</html>
`
);
${css}
</style>
</head>
<body>
<div class="CodeMirror" style="height:100%;" autocapitalize="on"></div>
</body>
</html>
`);
}, [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;
};
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();');
},
findPrevious() {
injectJS('cm.searchControl.findPrevious();');
},
replaceCurrent() {
injectJS('cm.searchControl.replaceCurrent();');
},
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 {
undo: function() {
webviewRef.current.injectJavaScript('cm.undo(); true;');
},
redo: function() {
webviewRef.current.injectJavaScript('cm.redo(); true;');
},
select: (anchor: number, head: number) => {
webviewRef.current.injectJavaScript(
`cm.select(${JSON.stringify(anchor)}, ${JSON.stringify(head)}); true;`
);
},
insertText: (text: string) => {
webviewRef.current.injectJavaScript(`cm.insertText(${JSON.stringify(text)}); true;`);
},
};
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 <WebView
style={props.style}
ref={webviewRef}
scrollEnabled={false}
useWebKit={true}
source={source}
setSupportMultipleWindows={true}
allowingReadAccessToURL={`file://${Setting.value('resourceDir')}`}
originWhitelist={['file://*', './*', 'http://*', 'https://*']}
allowFileAccess={true}
injectedJavaScript={injectedJavaScript}
onMessage={onMessage}
onError={onError}
/>;
return (
<View style={{
...props.style,
flexDirection: 'column',
}}>
<EditLinkDialog
visible={linkDialogVisible}
themeId={props.themeId}
editorControl={editorControl}
selectionState={selectionState}
/>
<View style={{
flexGrow: 1,
flexShrink: 0,
minHeight: '40%',
}}>
<WebView
style={{
backgroundColor: editorSettings.themeData.backgroundColor,
}}
ref={webviewRef}
scrollEnabled={false}
useWebKit={true}
source={source}
setSupportMultipleWindows={true}
hideKeyboardAccessoryView={true}
allowingReadAccessToURL={`file://${Setting.value('resourceDir')}`}
originWhitelist={['file://*', './*', 'http://*', 'https://*']}
allowFileAccess={true}
injectedJavaScript={injectedJavaScript}
onMessage={onMessage}
onError={onError}
/>
</View>
<SearchPanel
editorSettings={editorSettings}
searchControl={editorControl.searchControl}
searchState={searchState}
/>
</View>
);
}
export default forwardRef(NoteEditor);

View File

@ -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 (
<TouchableOpacity
style={props.styles.button}
onPress={props.onPress}
accessibilityLabel={props.title}
accessibilityRole='button'
>
<MaterialCommunityIcon name={props.iconName} style={props.styles.buttonText}/>
</TouchableOpacity>
);
};
interface ToggleButtonProps {
styles: any;
iconName: string;
title: string;
active: boolean;
onToggle: Callback;
}
const ToggleButton = (props: ToggleButtonProps) => {
const active = props.active;
return (
<TouchableOpacity
style={{
...props.styles.toggleButton,
...(active ? props.styles.toggleButtonActive : {}),
}}
onPress={props.onToggle}
accessibilityState={{
checked: props.active,
}}
accessibilityLabel={props.title}
accessibilityRole='switch'
>
<MaterialCommunityIcon name={props.iconName} style={
active ? props.styles.activeButtonText : props.styles.buttonText
}/>
</TouchableOpacity>
);
};
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 (
<TextInput
style={styles.input}
autoFocus={autoFocus}
onChangeText={onChange}
value={value}
placeholder={placeholder}
placeholderTextColor={placeholderColor}
returnKeyType='search'
blurOnSubmit={false}
onSubmitEditing={control.findNext}
/>
);
};
// 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 = (
<ActionButton
styles={styles}
iconName="close"
onPress={control.hideSearch}
title={_('Close search')}
/>
);
const showDetailsButton = (
<ActionButton
styles={styles}
iconName="menu-down"
onPress={() => setShowAdvanced(true)}
title={_('Show advanced')}
/>
);
const hideDetailsButton = (
<ActionButton
styles={styles}
iconName="menu-up"
onPress={() => 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 = (
<View style={styles.labeledInput} accessible>
<Text style={styles.text}>{_('Find: ')}</Text>
{ searchTextInput }
</View>
);
const labeledReplaceInput = (
<View style={styles.labeledInput} accessible>
<Text style={styles.text}>{_('Replace: ')}</Text>
{ replaceTextInput }
</View>
);
const toNextButton = (
<ActionButton
styles={styles}
iconName="menu-right"
onPress={control.findNext}
title={_('Next match')}
/>
);
const toPrevButton = (
<ActionButton
styles={styles}
iconName="menu-left"
onPress={control.findPrevious}
title={_('Previous match')}
/>
);
const replaceButton = (
<ActionButton
styles={styles}
iconName="swap-horizontal"
onPress={control.replaceCurrent}
title={_('Replace')}
/>
);
const replaceAllButton = (
<ActionButton
styles={styles}
iconName="reply-all"
onPress={control.replaceAll}
title={_('Replace all')}
/>
);
const regexpButton = (
<ToggleButton
styles={styles}
iconName="regex"
onToggle={() => {
updateSearchState({
useRegex: !state.useRegex,
});
}}
active={state.useRegex}
title={_('Regular expression')}
/>
);
const caseSensitiveButton = (
<ToggleButton
styles={styles}
iconName="format-letter-case"
onToggle={() => {
updateSearchState({
caseSensitive: !state.caseSensitive,
});
}}
active={state.caseSensitive}
title={_('Case sensitive')}
/>
);
const simpleLayout = (
<View style={{ flexDirection: 'row' }}>
{ closeButton }
{ searchTextInput }
{ showDetailsButton }
{ toPrevButton }
{ toNextButton }
</View>
);
const advancedLayout = (
<View style={{ flexDirection: 'column', alignItems: 'center' }}>
<View style={{ flexDirection: 'row' }}>
{ closeButton }
{ labeledSearchInput }
{ hideDetailsButton }
{ toPrevButton }
{ toNextButton }
</View>
<View style={{ flexDirection: 'row' }}>
{ regexpButton }
{ caseSensitiveButton }
{ labeledReplaceInput }
{ replaceButton }
{ replaceAllButton }
</View>
</View>
);
if (!state.dialogVisible) {
return null;
}
return showingAdvanced ? advancedLayout : simpleLayout;
};
export default SearchPanel;

View File

@ -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<string, any>;
const otherAsRec = other as Record<string, any>;
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<string, any> lets us use
// the indexing [] operator.
const resultRecord = result as Record<string, any>;
const obj = JSON.parse(json) as Record<string, any>;
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<string, any> = {};
// Cast this to a dictionary. This allows us to use
// the indexing [] operator.
const selfAsRecord = this as Record<string, any>;
for (const prop of SelectionFormatting.propNames) {
resultObj[prop] = selfAsRecord[prop];
}
return JSON.stringify(resultObj);
}
}

View File

@ -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,
}

View File

@ -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');

View File

@ -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",

View File

@ -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"