1
0
mirror of https://github.com/laurent22/joplin.git synced 2024-12-24 10:27:10 +02:00

Desktop: Add option to choose Code Mirror as code editor (#3284)

This commit is contained in:
Caleb John 2020-06-06 09:00:20 -06:00 committed by GitHub
parent a3153f1c9f
commit a8c8539e7a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
28 changed files with 1330 additions and 15 deletions

View File

@ -69,6 +69,16 @@ ElectronClient/gui/NoteEditor/NoteBody/AceEditor/Toolbar.js
ElectronClient/gui/NoteEditor/NoteBody/AceEditor/utils/index.js
ElectronClient/gui/NoteEditor/NoteBody/AceEditor/utils/types.js
ElectronClient/gui/NoteEditor/NoteBody/AceEditor/utils/useListIdent.js
ElectronClient/gui/NoteEditor/NoteBody/CodeMirror/CodeMirror.js
ElectronClient/gui/NoteEditor/NoteBody/CodeMirror/Editor.js
ElectronClient/gui/NoteEditor/NoteBody/CodeMirror/styles/index.js
ElectronClient/gui/NoteEditor/NoteBody/CodeMirror/Toolbar.js
ElectronClient/gui/NoteEditor/NoteBody/CodeMirror/utils/index.js
ElectronClient/gui/NoteEditor/NoteBody/CodeMirror/utils/types.js
ElectronClient/gui/NoteEditor/NoteBody/CodeMirror/utils/useCursorUtils.js
ElectronClient/gui/NoteEditor/NoteBody/CodeMirror/utils/useLineSorting.js
ElectronClient/gui/NoteEditor/NoteBody/CodeMirror/utils/useListIdent.js
ElectronClient/gui/NoteEditor/NoteBody/CodeMirror/utils/useScrollUtils.js
ElectronClient/gui/NoteEditor/NoteBody/TinyMCE/TinyMCE.js
ElectronClient/gui/NoteEditor/NoteBody/TinyMCE/utils/useScroll.js
ElectronClient/gui/NoteEditor/NoteEditor.js

10
.gitignore vendored
View File

@ -59,6 +59,16 @@ ElectronClient/gui/NoteEditor/NoteBody/AceEditor/Toolbar.js
ElectronClient/gui/NoteEditor/NoteBody/AceEditor/utils/index.js
ElectronClient/gui/NoteEditor/NoteBody/AceEditor/utils/types.js
ElectronClient/gui/NoteEditor/NoteBody/AceEditor/utils/useListIdent.js
ElectronClient/gui/NoteEditor/NoteBody/CodeMirror/CodeMirror.js
ElectronClient/gui/NoteEditor/NoteBody/CodeMirror/Editor.js
ElectronClient/gui/NoteEditor/NoteBody/CodeMirror/styles/index.js
ElectronClient/gui/NoteEditor/NoteBody/CodeMirror/Toolbar.js
ElectronClient/gui/NoteEditor/NoteBody/CodeMirror/utils/index.js
ElectronClient/gui/NoteEditor/NoteBody/CodeMirror/utils/types.js
ElectronClient/gui/NoteEditor/NoteBody/CodeMirror/utils/useCursorUtils.js
ElectronClient/gui/NoteEditor/NoteBody/CodeMirror/utils/useLineSorting.js
ElectronClient/gui/NoteEditor/NoteBody/CodeMirror/utils/useListIdent.js
ElectronClient/gui/NoteEditor/NoteBody/CodeMirror/utils/useScrollUtils.js
ElectronClient/gui/NoteEditor/NoteBody/TinyMCE/TinyMCE.js
ElectronClient/gui/NoteEditor/NoteBody/TinyMCE/utils/useScroll.js
ElectronClient/gui/NoteEditor/NoteEditor.js

View File

@ -1314,10 +1314,12 @@ class Application extends BaseApplication {
// The '*' and '!important' parts are necessary to make sure Russian text is displayed properly
// https://github.com/laurent22/joplin/issues/155
const css = `.ace_editor * { font-family: ${fontFamilies.join(', ')} !important; }`;
const css = `.CodeMirror * { font-family: ${fontFamilies.join(', ')} !important; }`;
const ace_css = `.ace_editor * { font-family: ${fontFamilies.join(', ')} !important; }`;
const styleTag = document.createElement('style');
styleTag.type = 'text/css';
styleTag.appendChild(document.createTextNode(css));
styleTag.appendChild(document.createTextNode(ace_css));
document.head.appendChild(styleTag);
}

View File

@ -866,7 +866,8 @@ class MainScreenComponent extends React.Component {
const noteContentPropertiesDialogOptions = this.state.noteContentPropertiesDialogOptions;
const shareNoteDialogOptions = this.state.shareNoteDialogOptions;
const bodyEditor = this.props.settingEditorCodeView ? 'AceEditor' : 'TinyMCE';
const codeEditor = Setting.value('editor.betaCodeMirror') ? 'CodeMirror' : 'AceEditor';
const bodyEditor = this.props.settingEditorCodeView ? codeEditor : 'TinyMCE';
return (
<div style={style}>

View File

@ -53,7 +53,7 @@ export default function styles(props: NoteBodyEditorProps) {
fontSize: `${theme.editorFontSize}px`,
color: theme.color,
backgroundColor: theme.backgroundColor,
editorTheme: theme.editorTheme, // Defined in theme.js
aceEditorTheme: theme.aceEditorTheme, // Defined in theme.js
},
};
});

View File

@ -0,0 +1,426 @@
import * as React from 'react';
import { useState, useEffect, useRef, forwardRef, useCallback, useImperativeHandle, useMemo } from 'react';
// eslint-disable-next-line no-unused-vars
import { EditorCommand, NoteBodyEditorProps } from '../../utils/types';
import { commandAttachFileToBody, handlePasteEvent } from '../../utils/resourceHandling';
import { ScrollOptions, ScrollOptionTypes } from '../../utils/types';
import { useScrollHandler, usePrevious, cursorPositionToTextOffset } from './utils';
import Toolbar from './Toolbar';
import styles_ from './styles';
import { RenderedBody, defaultRenderedBody } from './utils/types';
import Editor from './Editor';
// @ts-ignore
const { bridge } = require('electron').remote.require('./bridge');
// @ts-ignore
const Note = require('lib/models/Note.js');
const { clipboard } = require('electron');
const Setting = require('lib/models/Setting.js');
const NoteTextViewer = require('../../../NoteTextViewer.min');
const shared = require('lib/components/shared/note-screen-shared.js');
const Menu = bridge().Menu;
const MenuItem = bridge().MenuItem;
const markdownUtils = require('lib/markdownUtils');
const { _ } = require('lib/locale');
const { reg } = require('lib/registry.js');
const dialogs = require('../../../dialogs');
function markupRenderOptions(override: any = null) {
return { ...override };
}
function CodeMirror(props: NoteBodyEditorProps, ref: any) {
const styles = styles_(props);
const [renderedBody, setRenderedBody] = useState<RenderedBody>(defaultRenderedBody()); // Viewer content
const [webviewReady, setWebviewReady] = useState(false);
const previousRenderedBody = usePrevious(renderedBody);
const previousSearchMarkers = usePrevious(props.searchMarkers);
const previousContentKey = usePrevious(props.contentKey);
const editorRef = useRef(null);
const rootRef = useRef(null);
const webviewRef = useRef(null);
const props_onChangeRef = useRef<Function>(null);
props_onChangeRef.current = props.onChange;
const contentKeyHasChangedRef = useRef(false);
contentKeyHasChangedRef.current = previousContentKey !== props.contentKey;
const { resetScroll, editor_scroll, setEditorPercentScroll, setViewerPercentScroll } = useScrollHandler(editorRef, webviewRef, props.onScroll);
const cancelledKeys: {mac: string[], default: string[]} = { mac: [], default: [] };
// Remove Joplin reserved key bindings from the editor
const letters = ['F', 'T', 'P', 'Q', 'L', ',', 'G', 'K'];
for (let i = 0; i < letters.length; i++) {
const l = letters[i];
cancelledKeys.default.push(`Ctrl-${l}`);
cancelledKeys.mac.push(`Cmd-${l}`);
}
cancelledKeys.default.push('Alt-E');
cancelledKeys.mac.push('Alt-E');
const codeMirror_change = useCallback((newBody: string) => {
props_onChangeRef.current({ changeId: null, content: newBody });
}, []);
const wrapSelectionWithStrings = useCallback((string1: string, string2 = '', defaultText = '') => {
if (!editorRef.current) return;
if (editorRef.current.somethingSelected()) {
editorRef.current.wrapSelections(string1, string2);
} else {
editorRef.current.wrapSelections(string1 + defaultText, string2);
// Now select the default text so the user can replace it
const selections = editorRef.current.listSelections();
const newSelections = [];
for (let i = 0; i < selections.length; i++) {
const s = selections[i];
const anchor = { line: s.anchor.line, ch: s.anchor.ch + string1.length };
const head = { line: s.head.line, ch: s.head.ch - string2.length };
newSelections.push({ anchor: anchor, head: head });
}
editorRef.current.setSelections(newSelections);
}
editorRef.current.focus();
}, []);
const addListItem = useCallback((string1, defaultText = '') => {
if (editorRef.current) {
if (editorRef.current.somethingSelected()) {
editorRef.current.wrapSelectionsByLine(string1);
} else if (editorRef.current.getCursor('anchor').ch !== 0) {
editorRef.current.insertAtCursor(`\n${string1}`);
} else {
wrapSelectionWithStrings(string1, '', defaultText);
}
editorRef.current.focus();
}
}, [wrapSelectionWithStrings]);
useImperativeHandle(ref, () => {
return {
content: () => props.content,
resetScroll: () => {
resetScroll();
},
scrollTo: (options:ScrollOptions) => {
if (options.type === ScrollOptionTypes.Hash) {
if (!webviewRef.current) return;
webviewRef.current.wrappedInstance.send('scrollToHash', options.value as string);
} else if (options.type === ScrollOptionTypes.Percent) {
const p = options.value as number;
setEditorPercentScroll(p);
setViewerPercentScroll(p);
} else {
throw new Error(`Unsupported scroll options: ${options.type}`);
}
},
supportsCommand: (/* name:string*/) => {
// TODO: not implemented, currently only used for "search" command
// which is not directly supported by Ace Editor.
return false;
},
execCommand: async (cmd: EditorCommand) => {
if (!editorRef.current) return false;
reg.logger().debug('CodeMirror: execCommand', cmd);
let commandProcessed = true;
if (cmd.name === 'dropItems') {
if (cmd.value.type === 'notes') {
editorRef.current.insertAtCursor(cmd.value.markdownTags.join('\n'));
} else if (cmd.value.type === 'files') {
const pos = cursorPositionToTextOffset(editorRef.current.getCursor(), props.content);
const newBody = await commandAttachFileToBody(props.content, cmd.value.paths, { createFileURL: !!cmd.value.createFileURL, position: pos });
editorRef.current.updateBody(newBody);
} else {
reg.logger().warn('CodeMirror: unsupported drop item: ', cmd);
}
} else if (cmd.name === 'focus') {
editorRef.current.focus();
} else {
commandProcessed = false;
}
if (!commandProcessed) {
const commands: any = {
textBold: () => wrapSelectionWithStrings('**', '**', _('strong text')),
textItalic: () => wrapSelectionWithStrings('*', '*', _('emphasized text')),
textLink: async () => {
const url = await dialogs.prompt(_('Insert Hyperlink'));
if (url) wrapSelectionWithStrings('[', `](${url})`);
},
textCode: () => {
const selections = editorRef.current.getSelections();
// This bases the selection wrapping only around the first element
if (selections.length > 0) {
const string = selections[0];
// Look for newlines
const match = string.match(/\r?\n/);
if (match && match.length > 0) {
wrapSelectionWithStrings(`\`\`\`${match[0]}`, `${match[0]}\`\`\``);
} else {
wrapSelectionWithStrings('`', '`', '');
}
}
},
insertText: (value: any) => editorRef.current.insertAtCursor(value),
attachFile: async () => {
const cursor = editorRef.current.getCursor();
const pos = cursorPositionToTextOffset(cursor, props.content);
const newBody = await commandAttachFileToBody(props.content, null, { position: pos });
if (newBody) editorRef.current.updateBody(newBody);
},
textNumberedList: () => {
let bulletNumber = markdownUtils.olLineNumber(editorRef.current.getCurrentLine());
if (!bulletNumber) bulletNumber = markdownUtils.olLineNumber(editorRef.current.getPreviousLine());
if (!bulletNumber) bulletNumber = 0;
addListItem(`${bulletNumber + 1}. `, _('List item'));
},
textBulletedList: () => addListItem('- ', _('List item')),
textCheckbox: () => addListItem('- [ ] ', _('List item')),
textHeading: () => addListItem('## ', ''),
textHorizontalRule: () => addListItem('* * *'),
};
if (commands[cmd.name]) {
commands[cmd.name](cmd.value);
} else {
reg.logger().warn('CodeMirror: unsupported Joplin command: ', cmd);
return false;
}
}
return true;
},
};
}, [props.content, addListItem, wrapSelectionWithStrings, setEditorPercentScroll, setViewerPercentScroll, resetScroll, renderedBody]);
const onEditorPaste = useCallback(async (event: any = null) => {
const resourceMds = await handlePasteEvent(event);
if (!resourceMds.length) return;
if (editorRef.current) {
editorRef.current.replaceSelection(resourceMds.join('\n'));
}
}, []);
const editorCutText = useCallback(() => {
if (editorRef.current) {
const selections = editorRef.current.getSelections();
if (selections.length > 0) {
clipboard.writeText(selections[0]);
// Easy way to wipe out just the first selection
selections[0] = '';
editorRef.current.replaceSelections(selections);
}
}
}, []);
const editorCopyText = useCallback(() => {
if (editorRef.current) {
const selections = editorRef.current.getSelections();
if (selections.length > 0) {
clipboard.writeText(selections[0]);
}
}
}, []);
const editorPasteText = useCallback(() => {
if (editorRef.current) {
editorRef.current.replaceSelection(clipboard.readText());
}
}, []);
const onEditorContextMenu = useCallback(() => {
const menu = new Menu();
const hasSelectedText = editorRef.current && !!editorRef.current.getSelection() ;
const clipboardText = clipboard.readText();
menu.append(
new MenuItem({
label: _('Cut'),
enabled: hasSelectedText,
click: async () => {
editorCutText();
},
})
);
menu.append(
new MenuItem({
label: _('Copy'),
enabled: hasSelectedText,
click: async () => {
editorCopyText();
},
})
);
menu.append(
new MenuItem({
label: _('Paste'),
enabled: true,
click: async () => {
if (clipboardText) {
editorPasteText();
} else {
// To handle pasting images
onEditorPaste();
}
},
})
);
menu.popup(bridge().window());
}, [props.content, editorCutText, editorPasteText, editorCopyText, onEditorPaste]);
const webview_domReady = useCallback(() => {
setWebviewReady(true);
}, []);
const webview_ipcMessage = useCallback((event: any) => {
const msg = event.channel ? event.channel : '';
const args = event.args;
const arg0 = args && args.length >= 1 ? args[0] : null;
if (msg.indexOf('checkboxclick:') === 0) {
const newBody = shared.toggleCheckbox(msg, props.content);
if (editorRef.current) {
editorRef.current.updateBody(newBody);
}
} else if (msg === 'percentScroll') {
setEditorPercentScroll(arg0);
} else {
props.onMessage(event);
}
}, [props.onMessage, props.content, setEditorPercentScroll]);
useEffect(() => {
let cancelled = false;
const interval = contentKeyHasChangedRef.current ? 0 : 500;
const timeoutId = setTimeout(async () => {
let bodyToRender = props.content;
if (!bodyToRender.trim() && props.visiblePanes.indexOf('viewer') >= 0 && props.visiblePanes.indexOf('editor') < 0) {
// Fixes https://github.com/laurent22/joplin/issues/217
bodyToRender = `<i>${_('This note has no content. Click on "%s" to toggle the editor and edit the note.', _('Layout'))}</i>`;
}
const result = await props.markupToHtml(props.contentMarkupLanguage, bodyToRender, markupRenderOptions({ resourceInfos: props.resourceInfos }));
if (cancelled) return;
setRenderedBody(result);
}, interval);
return () => {
cancelled = true;
clearTimeout(timeoutId);
};
}, [props.content, props.contentMarkupLanguage, props.visiblePanes, props.resourceInfos, props.markupToHtml]);
useEffect(() => {
if (!webviewReady) return;
const options: any = {
pluginAssets: renderedBody.pluginAssets,
downloadResources: Setting.value('sync.resourceDownloadMode'),
};
webviewRef.current.wrappedInstance.send('setHtml', renderedBody.html, options);
}, [renderedBody, webviewReady]);
useEffect(() => {
if (props.searchMarkers !== previousSearchMarkers || renderedBody !== previousRenderedBody) {
webviewRef.current.wrappedInstance.send('setMarkers', props.searchMarkers.keywords, props.searchMarkers.options);
}
}, [props.searchMarkers, renderedBody]);
const cellEditorStyle = useMemo(() => {
const output = { ...styles.cellEditor };
if (!props.visiblePanes.includes('editor')) {
output.display = 'none'; // Seems to work fine since the refactoring
}
return output;
}, [styles.cellEditor, props.visiblePanes]);
const cellViewerStyle = useMemo(() => {
const output = { ...styles.cellViewer };
if (!props.visiblePanes.includes('viewer')) {
// Note: setting webview.display to "none" is currently not supported due
// to this bug: https://github.com/electron/electron/issues/8277
// So instead setting the width 0.
output.width = 1;
output.maxWidth = 1;
} else if (!props.visiblePanes.includes('editor')) {
output.borderLeftStyle = 'none';
}
return output;
}, [styles.cellViewer, props.visiblePanes]);
const editorReadOnly = props.visiblePanes.indexOf('editor') < 0;
function renderEditor() {
return (
<div style={cellEditorStyle}>
<Editor
value={props.content}
ref={editorRef}
mode={props.contentMarkupLanguage === Note.MARKUP_LANGUAGE_HTML ? 'xml' : 'gfm'}
theme={styles.editor.codeMirrorTheme}
style={styles.editor}
readOnly={props.visiblePanes.indexOf('editor') < 0}
autoMatchBraces={Setting.value('editor.autoMatchingBraces')}
keyMap={props.keyboardMode}
cancelledKeys={cancelledKeys}
onChange={codeMirror_change}
onScroll={editor_scroll}
onEditorContextMenu={onEditorContextMenu}
onEditorPaste={onEditorPaste}
/>
</div>
);
}
function renderViewer() {
return (
<div style={cellViewerStyle}>
<NoteTextViewer
ref={webviewRef}
viewerStyle={styles.viewer}
onIpcMessage={webview_ipcMessage}
onDomReady={webview_domReady}
/>
</div>
);
}
return (
<div style={styles.root} ref={rootRef}>
<div style={styles.rowToolbar}>
<Toolbar
theme={props.theme}
dispatch={props.dispatch}
disabled={editorReadOnly}
/>
{props.noteToolbar}
</div>
<div style={styles.rowEditorViewer}>
{renderEditor()}
{renderViewer()}
</div>
</div>
);
}
export default forwardRef(CodeMirror);

View File

@ -0,0 +1,182 @@
import * as React from 'react';
import { useEffect, useImperativeHandle, useState, useRef, useCallback, forwardRef } from 'react';
const CodeMirror = require('codemirror');
import 'codemirror/addon/comment/comment';
import 'codemirror/addon/dialog/dialog';
import 'codemirror/addon/edit/closebrackets';
import 'codemirror/addon/edit/continuelist';
import 'codemirror/addon/scroll/scrollpastend';
import useListIdent from './utils/useListIdent';
import useScrollUtils from './utils/useScrollUtils';
import useCursorUtils from './utils/useCursorUtils';
import useLineSorting from './utils/useLineSorting';
import 'codemirror/keymap/emacs';
import 'codemirror/keymap/vim';
import 'codemirror/mode/gfm/gfm';
import 'codemirror/mode/xml/xml';
// Modes for syntax highlighting inside of code blocks
import 'codemirror/mode/python/python';
import 'codemirror/mode/javascript/javascript';
import 'codemirror/mode/markdown/markdown';
import 'codemirror/mode/clike/clike';
import 'codemirror/mode/diff/diff';
import 'codemirror/mode/sql/sql';
export interface CancelledKeys {
mac: string[],
default: string[],
}
export interface EditorProps {
value: string,
mode: string,
style: any,
theme: any,
readOnly: boolean,
autoMatchBraces: boolean,
keyMap: string,
cancelledKeys: CancelledKeys,
onChange: any,
onScroll: any,
onEditorContextMenu: any,
onEditorPaste: any,
}
function Editor(props: EditorProps, ref: any) {
const [editor, setEditor] = useState(null);
const editorParent = useRef(null);
// Codemirror plugins add new commands to codemirror (or change it's behavior)
// This command adds the smartListIndent function which will be bound to tab
useListIdent(CodeMirror);
useScrollUtils(CodeMirror);
useCursorUtils(CodeMirror);
useLineSorting(CodeMirror);
useEffect(() => {
if (props.cancelledKeys) {
for (let i = 0; i < props.cancelledKeys.mac.length; i++) {
const k = props.cancelledKeys.mac[i];
CodeMirror.keyMap.macDefault[k] = null;
}
for (let i = 0; i < props.cancelledKeys.default.length; i++) {
const k = props.cancelledKeys.default[i];
CodeMirror.keyMap.default[k] = null;
}
}
}, [props.cancelledKeys]);
useImperativeHandle(ref, () => {
return editor;
});
const editor_change = useCallback((cm: any, change: any) => {
if (props.onChange && change.origin !== 'setValue') {
props.onChange(cm.getValue());
}
}, [props.onChange]);
// eslint-disable-next-line no-unused-vars, @typescript-eslint/no-unused-vars
const editor_scroll = useCallback((_cm: any) => {
props.onScroll();
}, [props.onScroll]);
// eslint-disable-next-line no-unused-vars, @typescript-eslint/no-unused-vars
const editor_mousedown = useCallback((_cm: any, event: any) => {
if (event && event.button === 2) {
props.onEditorContextMenu();
}
}, [props.onEditorContextMenu]);
// eslint-disable-next-line no-unused-vars, @typescript-eslint/no-unused-vars
const editor_paste = useCallback((_cm: any, _event: any) => {
props.onEditorPaste();
}, [props.onEditorPaste]);
// eslint-disable-next-line no-unused-vars, @typescript-eslint/no-unused-vars
const editor_drop = useCallback((cm: any, _event: any) => {
cm.focus();
}, []);
const editor_drag = useCallback((cm: any, event: any) => {
// This is the type for all drag and drops that are external to codemirror
// setting the cursor allows us to drop them in the right place
if (event.dataTransfer.effectAllowed === 'all') {
const coords = cm.coordsChar({ left: event.x, top: event.y });
cm.setCursor(coords);
}
}, []);
// const divRef = useCallback(node => {
useEffect(() => {
if (!editorParent.current) return () => {};
const cmOptions = {
value: props.value,
screenReaderLabel: props.value,
theme: props.theme,
mode: props.mode,
readOnly: props.readOnly,
autoCloseBrackets: props.autoMatchBraces,
inputStyle: 'textarea', // contenteditable loses cursor position on focus change, use textarea instead
lineWrapping: true,
lineNumbers: false,
scrollPastEnd: true,
indentWithTabs: true,
indentUnit: 4,
spellcheck: true,
allowDropFileTypes: [''], // disable codemirror drop handling
keyMap: props.keyMap ? props.keyMap : 'default',
extraKeys: { 'Enter': 'insertListElement',
'Ctrl-/': 'toggleComment',
'Ctrl-Alt-S': 'sortSelectedLines',
'Tab': 'smartListIndent',
'Shift-Tab': 'smartListUnindent' },
};
const cm = CodeMirror(editorParent.current, cmOptions);
setEditor(cm);
cm.on('change', editor_change);
cm.on('scroll', editor_scroll);
cm.on('mousedown', editor_mousedown);
cm.on('paste', editor_paste);
cm.on('drop', editor_drop);
cm.on('dragover', editor_drag);
return () => {
// Clean up codemirror
cm.off('change', editor_change);
cm.off('scroll', editor_scroll);
cm.off('mousedown', editor_mousedown);
cm.off('paste', editor_paste);
cm.off('drop', editor_drop);
cm.off('dragover', editor_drag);
editorParent.current.removeChild(cm.getWrapperElement());
setEditor(null);
};
}, []);
useEffect(() => {
if (editor) {
// Value can also be changed by the editor itself so we need this guard
// to prevent loops
if (props.value !== editor.getValue()) {
editor.setValue(props.value);
editor.clearHistory();
}
editor.setOption('screenReaderLabel', props.value);
editor.setOption('theme', props.theme);
editor.setOption('mode', props.mode);
editor.setOption('readOnly', props.readOnly);
editor.setOption('autoCloseBrackets', props.autoMatchBraces);
editor.setOption('keyMap', props.keyMap ? props.keyMap : 'default');
}
}, [props.value, props.theme, props.mode, props.readOnly, props.autoMatchBraces, props.keyMap]);
return <div style={props.style} ref={editorParent} />;
}
export default forwardRef(Editor);

View File

@ -0,0 +1,169 @@
import * as React from 'react';
const ToolbarBase = require('../../../Toolbar.min.js');
const { _ } = require('lib/locale');
const { buildStyle, themeStyle } = require('../../../../theme.js');
interface ToolbarProps {
theme: number,
dispatch: Function,
disabled: boolean,
}
function styles_(props:ToolbarProps) {
return buildStyle('AceEditorToolbar', props.theme, (/* theme:any*/) => {
const theme = themeStyle(props.theme);
return {
root: {
flex: 1,
marginBottom: 0,
borderTop: `1px solid ${theme.dividerColor}`,
},
};
});
}
export default function Toolbar(props:ToolbarProps) {
const styles = styles_(props);
function createToolbarItems() {
const toolbarItems = [];
toolbarItems.push({
tooltip: _('Bold'),
iconName: 'fa-bold',
onClick: () => {
props.dispatch({
type: 'WINDOW_COMMAND',
name: 'textBold',
});
},
});
toolbarItems.push({
tooltip: _('Italic'),
iconName: 'fa-italic',
onClick: () => {
props.dispatch({
type: 'WINDOW_COMMAND',
name: 'textItalic',
});
},
});
toolbarItems.push({
type: 'separator',
});
toolbarItems.push({
tooltip: _('Hyperlink'),
iconName: 'fa-link',
onClick: () => {
props.dispatch({
type: 'WINDOW_COMMAND',
name: 'textLink',
});
},
});
toolbarItems.push({
tooltip: _('Code'),
iconName: 'fa-code',
onClick: () => {
props.dispatch({
type: 'WINDOW_COMMAND',
name: 'textCode',
});
},
});
toolbarItems.push({
tooltip: _('Attach file'),
iconName: 'fa-paperclip',
onClick: () => {
props.dispatch({
type: 'WINDOW_COMMAND',
name: 'attachFile',
});
},
});
toolbarItems.push({
type: 'separator',
});
toolbarItems.push({
tooltip: _('Numbered List'),
iconName: 'fa-list-ol',
onClick: () => {
props.dispatch({
type: 'WINDOW_COMMAND',
name: 'textNumberedList',
});
},
});
toolbarItems.push({
tooltip: _('Bulleted List'),
iconName: 'fa-list-ul',
onClick: () => {
props.dispatch({
type: 'WINDOW_COMMAND',
name: 'textBulletedList',
});
},
});
toolbarItems.push({
tooltip: _('Checkbox'),
iconName: 'fa-check-square',
onClick: () => {
props.dispatch({
type: 'WINDOW_COMMAND',
name: 'textCheckbox',
});
},
});
toolbarItems.push({
tooltip: _('Heading'),
iconName: 'fa-heading',
onClick: () => {
props.dispatch({
type: 'WINDOW_COMMAND',
name: 'textHeading',
});
},
});
toolbarItems.push({
tooltip: _('Horizontal Rule'),
iconName: 'fa-ellipsis-h',
onClick: () => {
props.dispatch({
type: 'WINDOW_COMMAND',
name: 'textHorizontalRule',
});
},
});
toolbarItems.push({
tooltip: _('Insert Date Time'),
iconName: 'fa-calendar-plus',
onClick: () => {
props.dispatch({
type: 'WINDOW_COMMAND',
name: 'insertDateTime',
});
},
});
toolbarItems.push({
type: 'separator',
});
return toolbarItems;
}
return <ToolbarBase disabled={props.disabled} style={styles.root} items={createToolbarItems()} />;
}

View File

@ -0,0 +1,60 @@
import { NoteBodyEditorProps } from '../../../utils/types';
const { buildStyle } = require('../../../../../theme.js');
export default function styles(props: NoteBodyEditorProps) {
return buildStyle('AceEditor', props.theme, (theme: any) => {
return {
root: {
position: 'relative',
display: 'flex',
flexDirection: 'column',
...props.style,
},
rowToolbar: {
position: 'relative',
display: 'flex',
flexDirection: 'row',
},
rowEditorViewer: {
position: 'relative',
display: 'flex',
flexDirection: 'row',
flex: 1,
paddingTop: 10,
},
cellEditor: {
position: 'relative',
display: 'flex',
flex: 1,
},
cellViewer: {
position: 'relative',
display: 'flex',
flex: 1,
borderLeftWidth: 1,
borderLeftColor: theme.dividerColor,
borderLeftStyle: 'solid',
},
viewer: {
display: 'flex',
overflow: 'hidden',
verticalAlign: 'top',
boxSizing: 'border-box',
width: '100%',
},
editor: {
display: 'flex',
width: 'auto',
height: 'auto',
flex: 1,
overflowY: 'hidden',
paddingTop: 0,
lineHeight: `${theme.textAreaLineHeight}px`,
fontSize: `${theme.editorFontSize}px`,
color: theme.color,
backgroundColor: theme.backgroundColor,
codeMirrorTheme: theme.codeMirrorTheme, // Defined in theme.js
},
};
});
}

View File

@ -0,0 +1,84 @@
import { useEffect, useCallback, useRef } from 'react';
export function cursorPositionToTextOffset(cursorPos: any, body: string) {
if (!body) return 0;
const noteLines = body.split('\n');
let pos = 0;
for (let i = 0; i < noteLines.length; i++) {
if (i > 0) pos++; // Need to add the newline that's been removed in the split() call above
if (i === cursorPos.line) {
pos += cursorPos.ch;
break;
} else {
pos += noteLines[i].length;
}
}
return pos;
}
export function usePrevious(value: any): any {
const ref = useRef();
useEffect(() => {
ref.current = value;
});
return ref.current;
}
export function useScrollHandler(editorRef: any, webviewRef: any, onScroll: Function) {
const ignoreNextEditorScrollEvent_ = useRef(false);
const scrollTimeoutId_ = useRef<any>(null);
const scheduleOnScroll = useCallback((event: any) => {
if (scrollTimeoutId_.current) {
clearTimeout(scrollTimeoutId_.current);
scrollTimeoutId_.current = null;
}
scrollTimeoutId_.current = setTimeout(() => {
scrollTimeoutId_.current = null;
onScroll(event);
}, 10);
}, [onScroll]);
const setEditorPercentScroll = useCallback((p: number) => {
ignoreNextEditorScrollEvent_.current = true;
if (editorRef.current) {
editorRef.current.setScrollPercent(p);
scheduleOnScroll({ percent: p });
}
}, [scheduleOnScroll]);
const setViewerPercentScroll = useCallback((p: number) => {
if (webviewRef.current) {
webviewRef.current.wrappedInstance.send('setPercentScroll', p);
scheduleOnScroll({ percent: p });
}
}, [scheduleOnScroll]);
const editor_scroll = useCallback(() => {
if (ignoreNextEditorScrollEvent_.current) {
ignoreNextEditorScrollEvent_.current = false;
return;
}
if (editorRef.current) {
const percent = editorRef.current.getScrollPercent();
setViewerPercentScroll(percent);
}
}, [setViewerPercentScroll]);
const resetScroll = useCallback(() => {
if (editorRef.current) {
editorRef.current.setScrollPercent(0);
}
}, []);
return { resetScroll, setEditorPercentScroll, setViewerPercentScroll, editor_scroll };
}

View File

@ -0,0 +1,11 @@
export interface RenderedBody {
html: string;
pluginAssets: any[];
}
export function defaultRenderedBody(): RenderedBody {
return {
html: '',
pluginAssets: [],
};
}

View File

@ -0,0 +1,104 @@
// Helper functions that use the cursor
export default function useCursorUtils(CodeMirror: any) {
CodeMirror.defineExtension('insertAtCursor', function(text: string) {
// This is also the method to get all cursors
const ranges = this.listSelections();
// Batches the insert operations, if this wasn't done the inserts
// could potentially overwrite one another
this.operation(() => {
for (let i = 0; i < ranges.length; i++) {
// anchor is where the selection starts, and head is where it ends
// this changes based on how the uses makes a selection
const { anchor, head } = ranges[i];
// We want the selection that comes first in the document
let range = anchor;
if (head.line < anchor.line || (head.line === anchor.line && head.ch < anchor.ch)) {
range = head;
}
this.replaceRange(text, range);
}
});
});
CodeMirror.defineExtension('getCurrentLine', function() {
const curs = this.getCursor('anchor');
return this.getLine(curs.line);
});
CodeMirror.defineExtension('getPreviousLine', function() {
const curs = this.getCursor('anchor');
if (curs.line > 0) { return this.getLine(curs.line - 1); }
return '';
});
// this updates the body in a way that registers with the undo/redo
CodeMirror.defineExtension('updateBody', function(newBody: string) {
const start = { line: this.firstLine(), ch: 0 };
const last = this.getLine(this.lastLine());
const end = { line: this.lastLine(), ch: last ? last.length : 0 };
this.replaceRange(newBody, start, end);
});
CodeMirror.defineExtension('wrapSelections', function(string1: string, string2: string) {
const selectedStrings = this.getSelections();
// Batches the insert operations, if this wasn't done the inserts
// could potentially overwrite one another
this.operation(() => {
for (let i = 0; i < selectedStrings.length; i++) {
const selected = selectedStrings[i];
// Remove white space on either side of selection
const start = selected.search(/[^\s]/);
const end = selected.search(/[^\s](?=[\s]*$)/);
const core = selected.substr(start, end - start + 1);
// If selection can be toggled do that
if (core.startsWith(string1) && core.endsWith(string2)) {
const inside = core.substr(string1.length, core.length - string1.length - string2.length);
selectedStrings[i] = selected.substr(0, start) + inside + selected.substr(end + 1);
} else {
selectedStrings[i] = selected.substr(0, start) + string1 + core + string2 + selected.substr(end + 1);
}
}
this.replaceSelections(selectedStrings, 'around');
});
});
CodeMirror.defineExtension('wrapSelectionsByLine', function(string1: string) {
const selectedStrings = this.getSelections();
// Batches the insert operations, if this wasn't done the inserts
// could potentially overwrite one another
this.operation(() => {
for (let i = 0; i < selectedStrings.length; i++) {
const selected = selectedStrings[i];
const lines = selected.split(/\r?\n/);
// Save the newline character to restore it later
const newLines = selected.match(/\r?\n/);
for (let j = 0; j < lines.length; j++) {
const line = lines[j];
// Only add the list token if it's not already there
// if it is, remove it
if (!line.startsWith(string1)) {
lines[j] = string1 + line;
} else {
lines[j] = line.substr(string1.length, line.length - string1.length);
}
}
const newLine = newLines !== null ? newLines[0] : '\n';
selectedStrings[i] = lines.join(newLine);
}
this.replaceSelections(selectedStrings, 'around');
});
});
}

View File

@ -0,0 +1,29 @@
// Duplicates AceEditors line sorting function
// https://discourse.joplinapp.org/t/sort-lines/8874/2
export default function useLineSorting(CodeMirror: any) {
CodeMirror.commands.sortSelectedLines = function(cm: any) {
const ranges = cm.listSelections();
// Batches the insert operations, if this wasn't done the inserts
// could potentially overwrite one another
cm.operation(() => {
for (let i = 0; i < ranges.length; i++) {
// anchor is where the selection starts, and head is where it ends
// this changes based on how the uses makes a selection
const { anchor, head } = ranges[i];
const start = Math.min(anchor.line, head.line);
const end = Math.max(anchor.line, head.line);
const lines = [];
for (let j = start; j <= end; j++) {
lines.push(cm.getLine(j));
}
const text = lines.sort().join('\n');
// Get the end of the last line
const ch = lines[lines.length - 1].length;
cm.replaceRange(text, { line: start, ch: 0 }, { line: end, ch: ch });
}
});
};
}

View File

@ -0,0 +1,133 @@
const { isListItem, isEmptyListItem, extractListToken, olLineNumber } = require('lib/markdownUtils');
// Markdown list indentation.
// If the current line starts with `markup.list` token,
// hitting `Tab` key indents the line instead of inserting tab at cursor.
// hitting enter will insert a new list element, and unindent/delete an empty element
export default function useListIdent(CodeMirror: any) {
function isSelection(anchor: any, head: any) {
return anchor.line !== head.line || anchor.ch !== head.ch;
}
function getIndentLevel(cm: any, line: number) {
const tokens = cm.getLineTokens(line);
let indentLevel = 0;
if (tokens.length > 0 && tokens[0].string.match(/\s/)) {
indentLevel = tokens[0].string.length;
}
return indentLevel;
}
function newListToken(cm: any, line: number) {
const currentToken = extractListToken(cm.getLine(line));
const indentLevel = getIndentLevel(cm, line);
while (--line > 0) {
const currentLine = cm.getLine(line);
if (!isListItem(currentLine)) return currentToken;
const indent = getIndentLevel(cm, line);
if (indent < indentLevel - 1) return currentToken;
if (indent === indentLevel - 1) {
if (olLineNumber(currentLine)) {
return `${olLineNumber(currentLine) + 1}. `;
}
const token = extractListToken(currentLine);
if (token.match(/x/)) {
return '- [ ] ';
}
return token;
}
}
return currentToken;
}
// Gets the first non-whitespace token locationof a list
function getListSpan(listTokens: any) {
let start = listTokens[0].start;
const end = listTokens[listTokens.length - 1].end;
if (listTokens.length > 1 && listTokens[0].string.match(/\s/)) {
start = listTokens[1].start;
}
return { start: start, end: end };
}
CodeMirror.commands.smartListIndent = function(cm: any) {
if (cm.getOption('disableInput')) return CodeMirror.Pass;
const ranges = cm.listSelections();
for (let i = 0; i < ranges.length; i++) {
const { anchor, head } = ranges[i];
const line = cm.getLine(anchor.line);
// This is an actual selection and we should indent
if (isSelection(anchor, head) || !isListItem(line)) {
cm.execCommand('defaultTab');
} else {
if (olLineNumber(line)) {
const tokens = cm.getLineTokens(anchor.line);
const { start, end } = getListSpan(tokens);
// Resets numbered list to 1.
cm.replaceRange('1. ', { line: anchor.line, ch: start }, { line: anchor.line, ch: end });
}
cm.indentLine(anchor.line, 'add');
}
}
};
CodeMirror.commands.smartListUnindent = function(cm: any) {
if (cm.getOption('disableInput')) return CodeMirror.Pass;
const ranges = cm.listSelections();
for (let i = 0; i < ranges.length; i++) {
const { anchor, head } = ranges[i];
const line = cm.getLine(anchor.line);
// This is an actual selection and we should unindent
if (isSelection(anchor, head) || !isListItem(line)) {
cm.execCommand('indentLess');
} else {
const newToken = newListToken(cm, anchor.line);
const tokens = cm.getLineTokens(anchor.line);
const { start, end } = getListSpan(tokens);
cm.replaceRange(newToken, { line: anchor.line, ch: start }, { line: anchor.line, ch: end });
cm.indentLine(anchor.line, 'subtract');
}
}
};
CodeMirror.commands.insertListElement = function(cm: any) {
if (cm.getOption('disableInput')) return CodeMirror.Pass;
const ranges = cm.listSelections();
for (let i = 0; i < ranges.length; i++) {
const { anchor } = ranges[i];
const line = cm.getLine(anchor.line);
if (isEmptyListItem(line)) {
const tokens = cm.getLineTokens(anchor.line);
// A empty list item with an indent will have whitespace as the first token
if (tokens.length > 1 && tokens[0].string.match(/\s/)) {
cm.execCommand('smartListUnindent');
} else {
cm.replaceRange('', { line: anchor.line, ch: 0 }, anchor);
}
} else {
cm.execCommand('newlineAndIndentContinueMarkdownList');
}
}
};
}

View File

@ -0,0 +1,19 @@
// Helper functions to sync up scrolling
export default function useScrollUtils(CodeMirror: any) {
function getScrollHeight(cm: any) {
const info = cm.getScrollInfo();
const overdraw = cm.state.scrollPastEndPadding ? cm.state.scrollPastEndPadding : '0px';
return info.height - info.clientHeight - parseInt(overdraw);
}
CodeMirror.defineExtension('getScrollPercent', function() {
const info = this.getScrollInfo();
return info.top / getScrollHeight(this);
});
CodeMirror.defineExtension('setScrollPercent', function(p: number) {
this.scrollTo(null, p * getScrollHeight(this));
});
}

View File

@ -3,6 +3,7 @@ import { useState, useEffect, useCallback, useRef } from 'react';
// eslint-disable-next-line no-unused-vars
import TinyMCE from './NoteBody/TinyMCE/TinyMCE';
import AceEditor from './NoteBody/AceEditor/AceEditor';
import CodeMirror from './NoteBody/CodeMirror/CodeMirror';
import { connect } from 'react-redux';
import MultiNoteActions from '../MultiNoteActions';
import NoteToolbar from '../NoteToolbar/NoteToolbar';
@ -448,6 +449,8 @@ function NoteEditor(props: NoteEditorProps) {
editor = <TinyMCE {...editorProps}/>;
} else if (props.bodyEditor === 'AceEditor') {
editor = <AceEditor {...editorProps}/>;
} else if (props.bodyEditor === 'CodeMirror') {
editor = <CodeMirror {...editorProps}/>;
} else {
throw new Error(`Invalid editor: ${props.bodyEditor}`);
}

View File

@ -34,7 +34,8 @@ const aritimStyle = {
htmlCodeBorderColor: '#141a21', // Single line code border, and tables
htmlCodeColor: '#005b47', // Single line code text
editorTheme: 'chaos',
aceEditorTheme: 'chaos',
codeMirrorTheme: 'monokai',
codeThemeCss: 'atom-one-dark-reasonable.css',
highlightedColor: '#d3dae3',

View File

@ -33,7 +33,8 @@ const darkStyle = {
htmlCodeBackgroundColor: 'rgb(47, 48, 49)',
htmlCodeBorderColor: 'rgb(70, 70, 70)',
editorTheme: 'twilight',
aceEditorTheme: 'twilight',
codeMirrorTheme: 'material-darker',
codeThemeCss: 'atom-one-dark-reasonable.css',
highlightedColor: '#0066C7',

View File

@ -33,7 +33,8 @@ const draculaStyle = {
htmlCodeBorderColor: '#f8f8f2',
htmlCodeColor: '#50fa7b',
editorTheme: 'dracula',
aceEditorTheme: 'dracula',
codeMirrorTheme: 'dracula',
codeThemeCss: 'atom-one-dark-reasonable.css',
};

View File

@ -32,7 +32,8 @@ const lightStyle = {
htmlCodeBorderColor: 'rgb(220, 220, 220)',
htmlCodeColor: 'rgb(0,0,0)',
editorTheme: 'chrome',
aceEditorTheme: 'chrome',
codeMirrorTheme: 'default',
codeThemeCss: 'atom-one-light.css',
};

View File

@ -79,7 +79,8 @@ const nordStyle = {
htmlCodeBorderColor: nord[2],
htmlCodeColor: nord[13],
editorTheme: 'terminal',
aceEditorTheme: 'terminal',
codeMirrorTheme: 'nord',
codeThemeCss: 'atom-one-dark-reasonable.css',
};

View File

@ -33,7 +33,8 @@ const solarizedDarkStyle = {
htmlCodeBorderColor: '#696969',
htmlCodeColor: '#fdf6e3',
editorTheme: 'twilight',
aceEditorTheme: 'twilight',
codeMirrorTheme: 'solarized dark',
codeThemeCss: 'atom-one-dark-reasonable.css',
};

View File

@ -31,7 +31,8 @@ const solarizedLightStyle = {
htmlCodeBorderColor: '#eee8d5',
htmlCodeColor: '#002b36',
editorTheme: 'tomorrow',
aceEditorTheme: 'tomorrow',
codeMirrorTheme: 'solarized light',
codeThemeCss: 'atom-one-light.css',
};

View File

@ -12,6 +12,12 @@
<link rel="stylesheet" href="node_modules/@fortawesome/fontawesome-free/css/all.min.css">
<link rel="stylesheet" href="node_modules/react-datetime/css/react-datetime.css">
<link rel="stylesheet" href="node_modules/smalltalk/css/smalltalk.css">
<link rel="stylesheet" href="node_modules/codemirror/lib/codemirror.css">
<link rel="stylesheet" href="node_modules/codemirror/theme/dracula.css">
<link rel="stylesheet" href="node_modules/codemirror/theme/monokai.css">
<link rel="stylesheet" href="node_modules/codemirror/theme/solarized.css">
<link rel="stylesheet" href="node_modules/codemirror/theme/nord.css">
<link rel="stylesheet" href="node_modules/codemirror/theme/material-darker.css">
<style>
.smalltalk {
@ -40,4 +46,4 @@
}
</style>
</body>
</html>
</html>

View File

@ -102,6 +102,7 @@
"base64-stream": "^1.0.0",
"chokidar": "^3.0.0",
"clean-html": "^1.5.0",
"codemirror": "^5.54.0",
"color": "^3.1.2",
"compare-versions": "^3.2.1",
"countable": "^3.0.1",

View File

@ -169,4 +169,34 @@ a {
@keyframes rotate {
from {transform: rotate(0deg);}
to {transform: rotate(360deg);}
}
}
/* These must be important to prevent the codemirror defaults from taking over*/
.CodeMirror {
font-family: monospace;
height: 100% !important;
width: 100% !important;
color: inherit !important;
background-color: inherit !important;
position: absolute !important;
}
.cm-header-1 {
font-size: 1.5em;
}
.cm-header-2 {
font-size: 1.3em;
}
.cm-header-3 {
font-size: 1.1em;
}
.cm-header-4, .cm-header-5, .cm-header-6 {
font-size: 1em;
}
.cm-header-1, .cm-header-2, .cm-header-3, .cm-header-4, .cm-header-5, .cm-header-6 {
line-height: 1.5em;
}

View File

@ -4,6 +4,10 @@ const MarkdownIt = require('markdown-it');
const { setupLinkify } = require('lib/joplin-renderer');
const removeMarkdown = require('remove-markdown');
// Taken from codemirror/addon/edit/continuelist.js
const listRegex = /^(\s*)([*+-] \[[x ]\]\s|[*+-]\s|(\d+)([.)]))(\s*)/;
const emptyListRegex = /^(\s*)([*+-] \[[x ]\]|[*+-]|(\d+)[.)])(\s*)$/;
const markdownUtils = {
// Not really escaping because that's not supported by marked.js
escapeLinkText(text) {
@ -61,9 +65,25 @@ const markdownUtils = {
return output;
},
// The match results has 5 items
// Full match array is
// [Full match, whitespace, list token, ol line number, whitespace following token]
olLineNumber(line) {
const match = line.match(/^(\d+)\.(\s.*|)$/);
return match ? Number(match[1]) : 0;
const match = line.match(listRegex);
return match ? Number(match[3]) : 0;
},
extractListToken(line) {
const match = line.match(listRegex);
return match ? match[2] : '';
},
isListItem(line) {
return listRegex.test(line);
},
isEmptyListItem(line) {
return emptyListRegex.test(line);
},
createMarkdownTable(headers, rows) {

View File

@ -347,6 +347,14 @@ class Setting extends BaseModel {
appTypes: ['desktop'],
label: () => _('Auto-pair braces, parenthesis, quotations, etc.'),
},
'editor.betaCodeMirror': {
value: false,
type: Setting.TYPE_BOOL,
public: true,
section: 'note',
appTypes: ['desktop'],
label: () => _('Use CodeMirror as the code editor (WARNING: BETA).'),
},
'notes.sortOrder.reverse': { value: true, type: Setting.TYPE_BOOL, section: 'note', public: true, label: () => _('Reverse sort order'), appTypes: ['cli'] },
'folders.sortOrder.field': {
value: 'title',
@ -499,7 +507,7 @@ class Setting extends BaseModel {
section: 'appearance',
label: () => _('Editor font family'),
description: () =>
_('This must be *monospace* font or it will not work properly. If the font ' +
_('This should be a *monospace* font or some elements will render incorrectly. If the font ' +
'is incorrect or empty, it will default to a generic monospace font.'),
},
'style.sidebar.width': { value: 150, minimum: 80, maximum: 400, type: Setting.TYPE_INT, public: false, appTypes: ['desktop'] },