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:
parent
bd5ce114a1
commit
03c3188a4a
@ -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
42
.gitignore
vendored
@ -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
|
||||
|
@ -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;
|
||||
}
|
||||
|
||||
|
@ -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;
|
48
packages/app-mobile/components/NoteEditor/CodeMirror/demo.html
vendored
Normal file
48
packages/app-mobile/components/NoteEditor/CodeMirror/demo.html
vendored
Normal 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>
|
@ -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. ☑'
|
||||
);
|
||||
});
|
||||
});
|
@ -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.');
|
||||
});
|
||||
});
|
||||
|
@ -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);
|
||||
});
|
||||
});
|
@ -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;
|
||||
};
|
||||
};
|
@ -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(' ');
|
||||
});
|
||||
});
|
@ -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,
|
||||
};
|
||||
};
|
@ -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;
|
||||
}
|
@ -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 });
|
||||
}
|
||||
|
156
packages/app-mobile/components/NoteEditor/EditLinkDialog.tsx
Normal file
156
packages/app-mobile/components/NoteEditor/EditLinkDialog.tsx
Normal 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;
|
@ -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);
|
||||
|
355
packages/app-mobile/components/NoteEditor/SearchPanel.tsx
Normal file
355
packages/app-mobile/components/NoteEditor/SearchPanel.tsx
Normal 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;
|
@ -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);
|
||||
}
|
||||
}
|
61
packages/app-mobile/components/NoteEditor/types.ts
Normal file
61
packages/app-mobile/components/NoteEditor/types.ts
Normal 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,
|
||||
}
|
@ -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');
|
||||
|
@ -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",
|
||||
|
93
yarn.lock
93
yarn.lock
@ -4058,6 +4058,7 @@ __metadata:
|
||||
jetifier: ^1.6.5
|
||||
joplin-rn-alarm-notification: ^1.0.5
|
||||
jsc-android: 241213.1.0
|
||||
jsdom: ^20.0.0
|
||||
md5: ^2.2.1
|
||||
metro-react-native-babel-preset: ^0.66.2
|
||||
nodemon: ^2.0.12
|
||||
@ -12830,7 +12831,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"data-urls@npm:^3.0.1":
|
||||
"data-urls@npm:^3.0.1, data-urls@npm:^3.0.2":
|
||||
version: 3.0.2
|
||||
resolution: "data-urls@npm:3.0.2"
|
||||
dependencies:
|
||||
@ -14290,6 +14291,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"entities@npm:^4.3.0":
|
||||
version: 4.3.1
|
||||
resolution: "entities@npm:4.3.1"
|
||||
checksum: e8f6d2bac238494b2355e90551893882d2675142be7e7bdfcb15248ed0652a630678ba0e3a8dc750693e736cb6011f504c27dabeb4cd3330560092e88b105090
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"entities@npm:~2.0.0":
|
||||
version: 2.0.3
|
||||
resolution: "entities@npm:2.0.3"
|
||||
@ -18190,6 +18198,16 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"https-proxy-agent@npm:^5.0.1":
|
||||
version: 5.0.1
|
||||
resolution: "https-proxy-agent@npm:5.0.1"
|
||||
dependencies:
|
||||
agent-base: 6
|
||||
debug: 4
|
||||
checksum: 571fccdf38184f05943e12d37d6ce38197becdd69e58d03f43637f7fa1269cf303a7d228aa27e5b27bbd3af8f09fd938e1c91dcfefff2df7ba77c20ed8dfc765
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"human-signals@npm:^1.1.1":
|
||||
version: 1.1.1
|
||||
resolution: "human-signals@npm:1.1.1"
|
||||
@ -21603,6 +21621,46 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"jsdom@npm:^20.0.0":
|
||||
version: 20.0.0
|
||||
resolution: "jsdom@npm:20.0.0"
|
||||
dependencies:
|
||||
abab: ^2.0.6
|
||||
acorn: ^8.7.1
|
||||
acorn-globals: ^6.0.0
|
||||
cssom: ^0.5.0
|
||||
cssstyle: ^2.3.0
|
||||
data-urls: ^3.0.2
|
||||
decimal.js: ^10.3.1
|
||||
domexception: ^4.0.0
|
||||
escodegen: ^2.0.0
|
||||
form-data: ^4.0.0
|
||||
html-encoding-sniffer: ^3.0.0
|
||||
http-proxy-agent: ^5.0.0
|
||||
https-proxy-agent: ^5.0.1
|
||||
is-potential-custom-element-name: ^1.0.1
|
||||
nwsapi: ^2.2.0
|
||||
parse5: ^7.0.0
|
||||
saxes: ^6.0.0
|
||||
symbol-tree: ^3.2.4
|
||||
tough-cookie: ^4.0.0
|
||||
w3c-hr-time: ^1.0.2
|
||||
w3c-xmlserializer: ^3.0.0
|
||||
webidl-conversions: ^7.0.0
|
||||
whatwg-encoding: ^2.0.0
|
||||
whatwg-mimetype: ^3.0.0
|
||||
whatwg-url: ^11.0.0
|
||||
ws: ^8.8.0
|
||||
xml-name-validator: ^4.0.0
|
||||
peerDependencies:
|
||||
canvas: ^2.5.0
|
||||
peerDependenciesMeta:
|
||||
canvas:
|
||||
optional: true
|
||||
checksum: f69b40679d8cfaee2353615445aaff08b823c53dc7778ede6592d02ed12b3e9fb4e8db2b6d033551b67e08424a3adb2b79d231caa7dcda2d16019c20c705c11f
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"jsesc@npm:^1.3.0":
|
||||
version: 1.3.0
|
||||
resolution: "jsesc@npm:1.3.0"
|
||||
@ -26205,6 +26263,15 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"parse5@npm:^7.0.0":
|
||||
version: 7.0.0
|
||||
resolution: "parse5@npm:7.0.0"
|
||||
dependencies:
|
||||
entities: ^4.3.0
|
||||
checksum: 7da5d61cc18eb36ffa71fc861e65cbfd1f23d96483a6631254e627be667dbc9c93ac0b0e6cb17a13a2e4033dab19bfb2f76f38e5936cfb57240ed49036a83fcc
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"parseurl@npm:^1.3.2, parseurl@npm:^1.3.3, parseurl@npm:~1.3.3":
|
||||
version: 1.3.3
|
||||
resolution: "parseurl@npm:1.3.3"
|
||||
@ -29427,6 +29494,15 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"saxes@npm:^6.0.0":
|
||||
version: 6.0.0
|
||||
resolution: "saxes@npm:6.0.0"
|
||||
dependencies:
|
||||
xmlchars: ^2.2.0
|
||||
checksum: d3fa3e2aaf6c65ed52ee993aff1891fc47d5e47d515164b5449cbf5da2cbdc396137e55590472e64c5c436c14ae64a8a03c29b9e7389fc6f14035cf4e982ef3b
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"scheduler@npm:^0.15.0":
|
||||
version: 0.15.0
|
||||
resolution: "scheduler@npm:0.15.0"
|
||||
@ -34379,6 +34455,21 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"ws@npm:^8.8.0":
|
||||
version: 8.8.1
|
||||
resolution: "ws@npm:8.8.1"
|
||||
peerDependencies:
|
||||
bufferutil: ^4.0.1
|
||||
utf-8-validate: ^5.0.2
|
||||
peerDependenciesMeta:
|
||||
bufferutil:
|
||||
optional: true
|
||||
utf-8-validate:
|
||||
optional: true
|
||||
checksum: 2152cf862cae0693f3775bc688a6afb2e989d19d626d215e70f5fcd8eb55b1c3b0d3a6a4052905ec320e2d7734e20aeedbf9744496d62f15a26ad79cf4cf7dae
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"xcode@npm:^2.0.0":
|
||||
version: 2.1.0
|
||||
resolution: "xcode@npm:2.1.0"
|
||||
|
Loading…
x
Reference in New Issue
Block a user