1
0
mirror of https://github.com/laurent22/joplin.git synced 2025-11-06 09:19:22 +02:00

All: Use Lerna to manage monorepo

This commit is contained in:
Laurent Cozic
2020-11-05 16:58:23 +00:00
parent 122f20905c
commit cc07016b07
2839 changed files with 54217 additions and 16111 deletions

View File

@@ -0,0 +1,662 @@
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, useRootSize } from './utils';
import Toolbar from './Toolbar';
import styles_ from './styles';
import { RenderedBody, defaultRenderedBody } from './utils/types';
import NoteTextViewer from '../../../NoteTextViewer';
import Editor from './Editor';
import usePluginServiceRegistration from '../../utils/usePluginServiceRegistration';
import Setting from '@joplinapp/lib/models/Setting';
import { _ } from '@joplinapp/lib/locale';
import bridge from '../../../../services/bridge';
import markdownUtils from '@joplinapp/lib/markdownUtils';
import shim from '@joplinapp/lib/shim';
const Note = require('@joplinapp/lib/models/Note.js');
const { clipboard } = require('electron');
const shared = require('@joplinapp/lib/components/shared/note-screen-shared.js');
const Menu = bridge().Menu;
const MenuItem = bridge().MenuItem;
const { reg } = require('@joplinapp/lib/registry.js');
const dialogs = require('../../../dialogs');
const { themeStyle } = require('@joplinapp/lib/theme');
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 [renderedBodyContentKey, setRenderedBodyContentKey] = useState<string>(null);
const [webviewReady, setWebviewReady] = useState(false);
const previousContent = usePrevious(props.content);
const previousRenderedBody = usePrevious(renderedBody);
const previousSearchMarkers = usePrevious(props.searchMarkers);
const editorRef = useRef(null);
const rootRef = useRef(null);
const webviewRef = useRef(null);
const props_onChangeRef = useRef<Function>(null);
props_onChangeRef.current = props.onChange;
const rootSize = useRootSize({ rootRef });
usePluginServiceRegistration(ref);
const { resetScroll, editor_scroll, setEditorPercentScroll, setViewerPercentScroll } = useScrollHandler(editorRef, webviewRef, props.onScroll);
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 this 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;
}
let commandOutput = null;
if (!commandProcessed) {
const selectedText = () => {
if (!editorRef.current) return '';
const selections = editorRef.current.getSelections();
return selections.length ? selections[0] : '';
};
const commands: any = {
selectedText: () => {
return selectedText();
},
selectedHtml: () => {
return selectedText();
},
replaceSelection: (value:any) => {
return editorRef.current.replaceSelection(value);
},
textBold: () => wrapSelectionWithStrings('**', '**', _('strong text')),
textItalic: () => wrapSelectionWithStrings('*', '*', _('emphasised 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]) {
commandOutput = commands[cmd.name](cmd.value);
} else {
reg.logger().warn('CodeMirror: unsupported Joplin command: ', cmd);
}
}
return commandOutput;
},
};
}, [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 loadScript = async (script:any) => {
return new Promise((resolve) => {
let element:any = document.createElement('script');
if (script.src.indexOf('.css') >= 0) {
element = document.createElement('link');
element.rel = 'stylesheet';
element.href = script.src;
} else {
element.src = script.src;
if (script.attrs) {
for (const attr in script.attrs) {
element[attr] = script.attrs[attr];
}
}
}
element.id = script.id;
element.onload = () => {
resolve();
};
document.getElementsByTagName('head')[0].appendChild(element);
});
};
useEffect(() => {
let cancelled = false;
async function loadScripts() {
const scriptsToLoad:{src: string, id:string, loaded: boolean}[] = [
{
src: 'node_modules/codemirror/addon/dialog/dialog.css',
id: 'codemirrorDialogStyle',
loaded: false,
},
];
// The default codemirror theme is defined in codemirror.css
// and doesn't have an extra css file
if (styles.editor.codeMirrorTheme !== 'default') {
// Solarized light and solarized dark are loaded by the single
// solarized.css file
let theme = styles.editor.codeMirrorTheme;
if (theme.indexOf('solarized') >= 0) theme = 'solarized';
scriptsToLoad.push({
src: `node_modules/codemirror/theme/${theme}.css`,
id: `codemirrorTheme${theme}`,
loaded: false,
});
}
for (const s of scriptsToLoad) {
if (document.getElementById(s.id)) {
s.loaded = true;
continue;
}
await loadScript(s);
if (cancelled) return;
s.loaded = true;
}
}
loadScripts();
return () => {
cancelled = true;
};
}, [styles.editor.codeMirrorTheme]);
useEffect(() => {
const theme = themeStyle(props.themeId);
const element = document.createElement('style');
element.setAttribute('id', 'codemirrorStyle');
document.head.appendChild(element);
element.appendChild(document.createTextNode(`
/* These must be important to prevent the codemirror defaults from taking over*/
.CodeMirror {
font-family: monospace;
font-size: ${theme.editorFontSize}px;
height: 100% !important;
width: 100% !important;
color: inherit !important;
background-color: inherit !important;
position: absolute !important;
-webkit-box-shadow: none !important; // Some themes add a box shadow for some reason
}
.CodeMirror-lines {
/* This is used to enable the scroll-past end behaviour. The same height should */
/* be applied to the viewer. */
padding-bottom: 400px !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;
}
.cm-search-marker {
background: ${theme.searchMarkerBackgroundColor};
color: ${theme.searchMarkerColor} !important;
}
.cm-search-marker-selected {
background: ${theme.selectedColor2};
color: ${theme.color2} !important;
}
.cm-search-marker-scrollbar {
background: ${theme.searchMarkerBackgroundColor};
-moz-box-sizing: border-box;
box-sizing: border-box;
opacity: .5;
}
/* We need to use important to override theme specific values */
.cm-error {
color: inherit !important;
background-color: inherit !important;
border-bottom: 1px dotted #dc322f;
}
/* The default dark theme colors don't have enough contrast with the background */
.cm-s-nord span.cm-comment {
color: #9aa4b6 !important;
}
.cm-s-dracula span.cm-comment {
color: #a1abc9 !important;
}
.cm-s-monokai span.cm-comment {
color: #908b74 !important;
}
.cm-s-material-darker span.cm-comment {
color: #878787 !important;
}
.cm-s-solarized.cm-s-dark span.cm-comment {
color: #8ba1a7 !important;
}
`));
return () => {
document.head.removeChild(element);
};
}, [props.themeId]);
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;
// When a new note is loaded (contentKey is different), we want the note to be displayed
// right away. However once that's done, we put a small delay so that the view is not
// being constantly updated while the user changes the note.
const interval = renderedBodyContentKey !== props.contentKey ? 0 : 500;
const timeoutId = shim.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);
// Since we set `renderedBodyContentKey` here, it means this effect is going to
// be triggered again, but that's hard to avoid and the second call would be cheap
// anyway since the renderered markdown is cached by MdToHtml. We could use a ref
// to avoid this, but a second rendering might still happens anyway to render images,
// resources, or for other reasons. So it's best to focus on making any second call
// to this effect as cheap as possible with caching, etc.
setRenderedBodyContentKey(props.contentKey);
}, interval);
return () => {
cancelled = true;
shim.clearTimeout(timeoutId);
};
}, [props.content, props.contentKey, renderedBodyContentKey, 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) return;
// If there is a currently active search, it's important to re-search the text as the user
// types. However this is slow for performance so we ONLY want it to happen when there is
// a search
// Note that since the CodeMirror component also needs to handle the viewer pane, we need
// to check if the rendered body has changed too (it will be changed with a delay after
// props.content has been updated).
const textChanged = props.searchMarkers.keywords.length > 0 && (props.content !== previousContent || renderedBody !== previousRenderedBody);
if (props.searchMarkers !== previousSearchMarkers || textChanged) {
webviewRef.current.wrappedInstance.send('setMarkers', props.searchMarkers.keywords, props.searchMarkers.options);
if (editorRef.current) {
const matches = editorRef.current.setMarkers(props.searchMarkers.keywords, props.searchMarkers.options);
props.setLocalSearchResultCount(matches);
}
}
}, [props.searchMarkers, previousSearchMarkers, props.setLocalSearchResultCount, props.content, previousContent, renderedBody, previousRenderedBody, 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]);
useEffect(() => {
if (!editorRef.current) return;
// Anytime the user toggles the visible panes AND the editor is visible as a result
// we should focus the editor
// The intuition is that a panel toggle (with editor in view) is the equivalent of
// an editor interaction so users should expect the editor to be focused
if (props.visiblePanes.indexOf('editor') >= 0) {
editorRef.current.focus();
}
}, [props.visiblePanes]);
useEffect(() => {
if (!editorRef.current) return;
// Need to let codemirror know that it's container's size has changed so that it can
// re-compute anything it needs to. This ensures the cursor (and anything that is
// based on window size will be correct
// Codemirror will automatically refresh on window size changes but otherwise assumes
// that it's container size is stable, that is not true with Joplin, hence
// why we need to manually let codemirror know about resizes.
// Manually calling refresh here will cause a double refresh in some instances (when the
// window size is changed for example) but this is a fairly quick operation so it's worth
// it.
editorRef.current.refresh();
}, [rootSize, styles.editor, props.visiblePanes]);
function renderEditor() {
return (
<div style={cellEditorStyle}>
<Editor
value={props.content}
searchMarkers={props.searchMarkers}
ref={editorRef}
mode={props.contentMarkupLanguage === Note.MARKUP_LANGUAGE_HTML ? 'xml' : 'joplin-markdown'}
codeMirrorTheme={styles.editor.codeMirrorTheme}
style={styles.editor}
readOnly={props.visiblePanes.indexOf('editor') < 0}
autoMatchBraces={Setting.value('editor.autoMatchingBraces')}
keyMap={props.keyboardMode}
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
themeId={props.themeId}
// dispatch={props.dispatch}
// plugins={props.plugins}
/>
{props.noteToolbar}
</div>
<div style={styles.rowEditorViewer}>
{renderEditor()}
{renderViewer()}
</div>
</div>
);
}
export default forwardRef(CodeMirror);

View File

@@ -0,0 +1,242 @@
import * as React from 'react';
import { useEffect, useImperativeHandle, useState, useRef, useCallback, forwardRef } from 'react';
import * as CodeMirror from '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/annotatescrollbar';
import 'codemirror/addon/search/matchesonscrollbar';
import 'codemirror/addon/search/searchcursor';
import useListIdent from './utils/useListIdent';
import useScrollUtils from './utils/useScrollUtils';
import useCursorUtils from './utils/useCursorUtils';
import useLineSorting from './utils/useLineSorting';
import useEditorSearch from './utils/useEditorSearch';
import useJoplinMode from './utils/useJoplinMode';
import useKeymap from './utils/useKeymap';
import 'codemirror/keymap/emacs';
import 'codemirror/keymap/vim';
import 'codemirror/keymap/sublime'; // Used for swapLineUp and swapLineDown
import 'codemirror/mode/meta';
// import eventManager from '@joplinapp/lib/eventManager';
const { reg } = require('@joplinapp/lib/registry.js');
// Based on http://pypl.github.io/PYPL.html
const topLanguages = [
'python',
'clike',
'javascript',
'jsx',
'php',
'r',
'swift',
'go',
'vb',
'vbscript',
'ruby',
'rust',
'dart',
'lua',
'groovy',
'perl',
'cobol',
'julia',
'haskell',
'pascal',
'css',
// Additional languages, not in the PYPL list
'xml', // For HTML too
'markdown',
'yaml',
'shell',
'dockerfile',
'diff',
'erlang',
'sql',
];
// Load Top Modes
for (let i = 0; i < topLanguages.length; i++) {
const mode = topLanguages[i];
if (CodeMirror.modeInfo.find((m: any) => m.mode === mode)) {
require(`codemirror/mode/${mode}/${mode}`);
} else {
reg.logger().error('Cannot find CodeMirror mode: ', mode);
}
}
export interface EditorProps {
value: string,
searchMarkers: any,
mode: string,
style: any,
codeMirrorTheme: any,
readOnly: boolean,
autoMatchBraces: boolean,
keyMap: string,
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);
useEditorSearch(CodeMirror);
useJoplinMode(CodeMirror);
useKeymap(CodeMirror);
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);
}
}, []);
useEffect(() => {
if (!editorParent.current) return () => {};
// const userOptions = eventManager.filterEmit('codeMirrorOptions', {});
const userOptions = {};
const cmOptions = Object.assign({}, {
value: props.value,
screenReaderLabel: props.value,
theme: props.codeMirrorTheme,
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,
indentWithTabs: true,
indentUnit: 4,
spellcheck: true,
allowDropFileTypes: [''], // disable codemirror drop handling
keyMap: props.keyMap ? props.keyMap : 'default',
}, userOptions);
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);
// It's possible for searchMarkers to be available before the editor
// In these cases we set the markers asap so the user can see them as
// soon as the editor is ready
if (props.searchMarkers) { cm.setMarkers(props.searchMarkers.keywords, props.searchMarkers.options); }
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);
}
}, [props.value]);
useEffect(() => {
if (editor) {
editor.setOption('theme', props.codeMirrorTheme);
}
}, [props.codeMirrorTheme]);
useEffect(() => {
if (editor) {
editor.setOption('mode', props.mode);
}
}, [props.mode]);
useEffect(() => {
if (editor) {
editor.setOption('readOnly', props.readOnly);
}
}, [props.readOnly]);
useEffect(() => {
if (editor) {
editor.setOption('autoCloseBrackets', props.autoMatchBraces);
}
}, [props.autoMatchBraces]);
useEffect(() => {
if (editor) {
editor.setOption('keyMap', props.keyMap ? props.keyMap : 'default');
}
}, [props.keyMap]);
return <div style={props.style} ref={editorParent} />;
}
export default forwardRef(Editor);

View File

@@ -0,0 +1,63 @@
import * as React from 'react';
import CommandService from '@joplinapp/lib/services/CommandService';
import ToolbarBase from '../../../ToolbarBase';
import { utils as pluginUtils } from '@joplinapp/lib/services/plugins/reducer';
import { connect } from 'react-redux';
import { AppState } from '../../../../app';
import ToolbarButtonUtils, { ToolbarButtonInfo } from '@joplinapp/lib/services/commands/ToolbarButtonUtils';
import stateToWhenClauseContext from '@joplinapp/lib/services/commands/stateToWhenClauseContext';
const { buildStyle } = require('@joplinapp/lib/theme');
interface ToolbarProps {
themeId: number,
toolbarButtonInfos: ToolbarButtonInfo[],
}
function styles_(props:ToolbarProps) {
return buildStyle('CodeMirrorToolbar', props.themeId, () => {
return {
root: {
flex: 1,
marginBottom: 0,
},
};
});
}
const toolbarButtonUtils = new ToolbarButtonUtils(CommandService.instance());
function Toolbar(props:ToolbarProps) {
const styles = styles_(props);
return <ToolbarBase style={styles.root} items={props.toolbarButtonInfos} />;
}
const mapStateToProps = (state: AppState) => {
const whenClauseContext = stateToWhenClauseContext(state);
const commandNames = [
'historyBackward',
'historyForward',
'toggleExternalEditing',
'-',
'textBold',
'textItalic',
'-',
'textLink',
'textCode',
'attachFile',
'-',
'textBulletedList',
'textNumberedList',
'textCheckbox',
'textHeading',
'textHorizontalRule',
'insertDateTime',
'toggleEditors',
].concat(pluginUtils.commandNamesFromViews(state.pluginService.plugins, 'editorToolbar'));
return {
toolbarButtonInfos: toolbarButtonUtils.commandsToToolbarButtons(commandNames, whenClauseContext),
};
};
export default connect(mapStateToProps)(Toolbar);

View File

@@ -0,0 +1,60 @@
import { NoteBodyEditorProps } from '../../../utils/types';
const { buildStyle } = require('@joplinapp/lib/theme');
export default function styles(props: NoteBodyEditorProps) {
return buildStyle('CodeMirror', props.themeId, (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,104 @@
import { useEffect, useCallback, useRef, useState } from 'react';
import shim from '@joplinapp/lib/shim';
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) {
shim.clearTimeout(scrollTimeoutId_.current);
scrollTimeoutId_.current = null;
}
scrollTimeoutId_.current = shim.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 };
}
export function useRootSize(dependencies:any) {
const { rootRef } = dependencies;
const [rootSize, setRootSize] = useState({ width: 0, height: 0 });
useEffect(() => {
if (!rootRef.current) return;
const { width, height } = rootRef.current.getBoundingClientRect();
if (rootSize.width !== width || rootSize.height !== height) {
setRootSize({ width: width, height: height });
}
});
return rootSize;
}

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,157 @@
import { useEffect, useRef, useState } from 'react';
import shim from '@joplinapp/lib/shim';
export default function useEditorSearch(CodeMirror: any) {
const [markers, setMarkers] = useState([]);
const [overlay, setOverlay] = useState(null);
const [scrollbarMarks, setScrollbarMarks] = useState(null);
const [previousKeywordValue, setPreviousKeywordValue] = useState(null);
const [previousIndex, setPreviousIndex] = useState(null);
const [overlayTimeout, setOverlayTimeout] = useState(null);
const overlayTimeoutRef = useRef(null);
overlayTimeoutRef.current = overlayTimeout;
function clearMarkers() {
for (let i = 0; i < markers.length; i++) {
markers[i].clear();
}
setMarkers([]);
}
function clearOverlay(cm: any) {
if (overlay) cm.removeOverlay(overlay);
if (scrollbarMarks) scrollbarMarks.clear();
if (overlayTimeout) shim.clearTimeout(overlayTimeout);
setOverlay(null);
setScrollbarMarks(null);
setOverlayTimeout(null);
}
// Modified from codemirror/addons/search/search.js
function searchOverlay(query: RegExp) {
return { token: function(stream: any) {
query.lastIndex = stream.pos;
const match = query.exec(stream.string);
if (match && match.index == stream.pos) {
stream.pos += match[0].length || 1;
return 'search-marker';
} else if (match) {
stream.pos = match.index;
} else {
stream.skipToEnd();
}
return null;
} };
}
// Highlights the currently active found work
// It's possible to get tricky with this fucntions and just use findNext/findPrev
// but this is fast enough and works more naturally with the current search logic
function highlightSearch(cm: any, searchTerm: RegExp, index: number, scrollTo: boolean) {
const cursor = cm.getSearchCursor(searchTerm);
let match: any = null;
for (let j = 0; j < index + 1; j++) {
if (!cursor.findNext()) {
// If we run out of matches then just highlight the final match
break;
}
match = cursor.pos;
}
if (match) {
if (scrollTo) cm.scrollIntoView(match);
return cm.markText(match.from, match.to, { className: 'cm-search-marker-selected' });
}
return null;
}
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions#Escaping
function escapeRegExp(keyword: string) {
return keyword.replace(/[.*+\-?^${}()|[\]\\]/g, '\\$&'); // $& means the whole matched string
}
function getSearchTerm(keyword: any) {
const value = escapeRegExp(keyword.value);
return new RegExp(value, 'gi');
}
useEffect(() => {
return () => {
if (overlayTimeoutRef.current) shim.clearTimeout(overlayTimeoutRef.current);
overlayTimeoutRef.current = null;
};
}, []);
CodeMirror.defineExtension('setMarkers', function(keywords: any, options: any) {
if (!options) {
options = { selectedIndex: 0 };
}
clearMarkers();
// HIGHLIGHT KEYWORDS
// When doing a global search it's possible to have multiple keywords
// This means we need to highlight each one
const marks: any = [];
for (let i = 0; i < keywords.length; i++) {
const keyword = keywords[i];
if (keyword.value === '') continue;
const searchTerm = getSearchTerm(keyword);
// We only want to scroll the first keyword into view in the case of a multi keyword search
const scrollTo = i === 0 && (previousKeywordValue !== keyword.value || previousIndex !== options.selectedIndex);
const match = highlightSearch(this, searchTerm, options.selectedIndex, scrollTo);
if (match) marks.push(match);
}
setMarkers(marks);
setPreviousIndex(options.selectedIndex);
// SEARCHOVERLAY
// We only want to highlight all matches when there is only 1 search term
if (keywords.length !== 1 || keywords[0].value == '') {
clearOverlay(this);
const prev = keywords.length > 1 ? keywords[0].value : '';
setPreviousKeywordValue(prev);
return 0;
}
const searchTerm = getSearchTerm(keywords[0]);
// Determine the number of matches in the source, this is passed on
// to the NoteEditor component
const regexMatches = this.getValue().match(searchTerm);
const nMatches = regexMatches ? regexMatches.length : 0;
// Don't bother clearing and re-calculating the overlay if the search term
// hasn't changed
if (keywords[0].value === previousKeywordValue) return nMatches;
clearOverlay(this);
setPreviousKeywordValue(keywords[0].value);
// These operations are pretty slow, so we won't add use them until the user
// has finished typing, 500ms is probably enough time
const timeout = shim.setTimeout(() => {
const scrollMarks = this.showMatchesOnScrollbar(searchTerm, true, 'cm-search-marker-scrollbar');
const overlay = searchOverlay(searchTerm);
this.addOverlay(overlay);
setOverlay(overlay);
setScrollbarMarks(scrollMarks);
}, 500);
setOverlayTimeout(timeout);
overlayTimeoutRef.current = timeout;
return nMatches;
});
}

View File

@@ -0,0 +1,122 @@
import 'codemirror/addon/mode/multiplex';
import 'codemirror/mode/stex/stex';
import Setting from '@joplinapp/lib/models/Setting';
// Joplin markdown is a the same as markdown mode, but it has configured defaults
// and support for katex math blocks
export default function useJoplinMode(CodeMirror: any) {
CodeMirror.defineMode('joplin-markdown', (config: any) => {
const markdownConfig = {
name: 'markdown',
taskLists: true,
strikethrough: true,
emoji: Setting.value('markdown.plugin.emoji'),
tokenTypeOverrides: {
linkText: 'link-text',
},
};
const markdownMode = CodeMirror.getMode(config, markdownConfig);
const stex = CodeMirror.getMode(config, { name: 'stex', inMathMode: true });
const inlineKatexOpenRE = /(?<!\S)\$(?=[^\s$].*?[^\\\s$]\$(?!\S))/;
const inlineKatexCloseRE = /(?<![\\\s$])\$(?!\S)/;
const blockKatexOpenRE = /(?<!\S)\$\$/;
const blockKatexCloseRE = /(?<![\\\s])\$\$/;
// Find token will search for a valid katex start or end token
// If found then it will return the index, otherwise -1
function findToken(stream: any, token: RegExp) {
const match = token.exec(stream.string.slice(stream.pos));
return match ? match.index + stream.pos : -1;
}
return {
startState: function(): { outer: any, openCharacter: string, inner: any } {
return {
outer: CodeMirror.startState(markdownMode),
openCharacter: '',
inner: CodeMirror.startState(stex),
};
},
copyState: function(state: any) {
return {
outer: CodeMirror.copyState(markdownMode, state.outer),
openCharacter: state.openCharacter,
inner: CodeMirror.copyState(stex, state.inner),
};
},
token: function(stream: any, state: any) {
let currentMode = markdownMode;
let currentState = state.outer;
let tokenLabel = 'katex-marker-open';
let nextTokenPos = stream.string.length;
let closing = false;
if (state.openCharacter) {
currentMode = stex;
currentState = state.inner;
tokenLabel = 'katex-marker-close';
closing = true;
const blockPos = findToken(stream, blockKatexCloseRE);
const inlinePos = findToken(stream, inlineKatexCloseRE);
if (state.openCharacter === '$$' && blockPos !== -1) nextTokenPos = blockPos;
if (state.openCharacter === '$' && inlinePos !== -1) nextTokenPos = inlinePos;
} else if (!currentState.code) {
const blockPos = findToken(stream, blockKatexOpenRE);
const inlinePos = findToken(stream, inlineKatexOpenRE);
if (blockPos !== -1) nextTokenPos = blockPos;
if (inlinePos !== -1 && inlinePos < nextTokenPos) nextTokenPos = inlinePos;
if (blockPos === stream.pos) state.openCharacter = '$$';
if (inlinePos === stream.pos) state.openCharacter = '$';
}
if (nextTokenPos === stream.pos) {
stream.match(state.openCharacter);
if (closing) state.openCharacter = '';
return tokenLabel;
}
// If we found a token in this stream but haven;t reached it yet, then we will
// pass all the characters leading up to our token to markdown mode
const oldString = stream.string;
stream.string = oldString.slice(0, nextTokenPos);
const token = currentMode.token(stream, currentState);
stream.string = oldString;
return token;
},
indent: function(state: any, textAfter: string, line: any) {
const mode = state.openCharacter ? stex : markdownMode;
if (!mode.indent) return CodeMirror.Pass;
return mode.indent(state.openCharacter ? state.inner : state.outer, textAfter, line);
},
blankLine: function(state: any) {
const mode = state.openCharacter ? stex : markdownMode;
if (mode.blankLine) {
mode.blankLine(state.openCharacter ? state.inner : state.outer);
}
},
electricChars: markdownMode.electricChars,
innerMode: function(state: any) {
return state.openCharacter ? { state: state.inner, mode: stex } : { state: state.outer, mode: markdownMode };
},
};
});
}

View File

@@ -0,0 +1,107 @@
import { useEffect } from 'react';
import CommandService from '@joplinapp/lib/services/CommandService';
import shim from '@joplinapp/lib/shim';
export default function useKeymap(CodeMirror: any) {
function save() {
CommandService.instance().execute('synchronize');
}
function setupEmacs() {
CodeMirror.keyMap.emacs['Tab'] = 'smartListIndent';
CodeMirror.keyMap.emacs['Enter'] = 'insertListElement';
CodeMirror.keyMap.emacs['Shift-Tab'] = 'smartListUnindent';
}
function setupVim() {
CodeMirror.Vim.defineAction('swapLineDown', CodeMirror.commands.swapLineDown);
CodeMirror.Vim.mapCommand('<A-j>', 'action', 'swapLineDown', {}, { context: 'normal', isEdit: true });
CodeMirror.Vim.defineAction('swapLineUp', CodeMirror.commands.swapLineUp);
CodeMirror.Vim.mapCommand('<A-k>', 'action', 'swapLineUp', {}, { context: 'normal', isEdit: true });
CodeMirror.Vim.defineAction('insertListElement', CodeMirror.commands.vimInsertListElement);
CodeMirror.Vim.mapCommand('o', 'action', 'insertListElement', { after: true }, { context: 'normal', isEdit: true, interlaceInsertRepeat: true });
}
useEffect(() => {
// This enables the special modes (emacs and vim) to initiate sync by the save action
CodeMirror.commands.save = save;
CodeMirror.keyMap.basic = {
'Left': 'goCharLeft',
'Right': 'goCharRight',
'Up': 'goLineUp',
'Down': 'goLineDown',
'End': 'goLineRight',
'Home': 'goLineLeftSmart',
'PageUp': 'goPageUp',
'PageDown': 'goPageDown',
'Delete': 'delCharAfter',
'Backspace': 'delCharBefore',
'Shift-Backspace': 'delCharBefore',
'Tab': 'smartListIndent',
'Shift-Tab': 'smartListUnindent',
'Enter': 'insertListElement',
'Insert': 'toggleOverwrite',
'Esc': 'singleSelection',
};
if (shim.isMac()) {
CodeMirror.keyMap.default = {
// MacOS
'Cmd-A': 'selectAll',
'Cmd-D': 'deleteLine',
'Cmd-Z': 'undo',
'Shift-Cmd-Z': 'redo',
'Cmd-Y': 'redo',
'Cmd-Home': 'goDocStart',
'Cmd-Up': 'goDocStart',
'Cmd-End': 'goDocEnd',
'Cmd-Down': 'goDocEnd',
'Cmd-Left': 'goLineLeft',
'Cmd-Right': 'goLineRight',
'Alt-Left': 'goGroupLeft',
'Alt-Right': 'goGroupRight',
'Alt-Backspace': 'delGroupBefore',
'Alt-Delete': 'delGroupAfter',
'Cmd-[': 'indentLess',
'Cmd-]': 'indentMore',
'Cmd-/': 'toggleComment',
'Cmd-Opt-S': 'sortSelectedLines',
'Opt-Up': 'swapLineUp',
'Opt-Down': 'swapLineDown',
'fallthrough': 'basic',
};
} else {
CodeMirror.keyMap.default = {
// Windows/linux
'Ctrl-A': 'selectAll',
'Ctrl-D': 'deleteLine',
'Ctrl-Z': 'undo',
'Shift-Ctrl-Z': 'redo',
'Ctrl-Y': 'redo',
'Ctrl-Home': 'goDocStart',
'Ctrl-End': 'goDocEnd',
'Ctrl-Up': 'goLineUp',
'Ctrl-Down': 'goLineDown',
'Ctrl-Left': 'goGroupLeft',
'Ctrl-Right': 'goGroupRight',
'Alt-Left': 'goLineStart',
'Alt-Right': 'goLineEnd',
'Ctrl-Backspace': 'delGroupBefore',
'Ctrl-Delete': 'delGroupAfter',
'Ctrl-[': 'indentLess',
'Ctrl-]': 'indentMore',
'Ctrl-/': 'toggleComment',
'Ctrl-Alt-S': 'sortSelectedLines',
'Alt-Up': 'swapLineUp',
'Alt-Down': 'swapLineDown',
'fallthrough': 'basic',
};
}
setupEmacs();
setupVim();
}, []);
}

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,183 @@
const { isListItem, isEmptyListItem, extractListToken, olLineNumber } = require('@joplinapp/lib/markdownUtils').default;
// 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 character coordinates of the start and end of a list token
function getListSpan(listTokens: any, line: string) {
let start = listTokens[0].start;
const token = extractListToken(line);
if (listTokens.length > 1 && listTokens[0].string.match(/^\s/)) {
start = listTokens[1].start;
}
return { start: start, end: start + token.length };
}
CodeMirror.commands.smartListIndent = function(cm: any) {
if (cm.getOption('disableInput')) return CodeMirror.Pass;
const ranges = cm.listSelections();
cm.operation(() => {
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)) {
cm.execCommand('defaultTab');
// This will apply to all selections so it makes sense to stop processing here
// this is an edge case for users because there is no clear intended behavior
// if the use multicursor with a mix of selected and not selected
break;
} else if (!isListItem(line) || !isEmptyListItem(line)) {
cm.replaceRange('\t', anchor, head);
} else {
if (olLineNumber(line)) {
const tokens = cm.getLineTokens(anchor.line);
const { start, end } = getListSpan(tokens, line);
// 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();
cm.operation(() => {
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)) {
cm.execCommand('indentLess');
// This will apply to all selections so it makes sense to stop processing here
// this is an edge case for users because there is no clear intended behavior
// if the use multicursor with a mix of selected and not selected
break;
} else if (!isListItem(line) || !isEmptyListItem(line)) {
cm.indentLine(anchor.line, 'subtract');
} else {
const newToken = newListToken(cm, anchor.line);
const tokens = cm.getLineTokens(anchor.line);
const { start, end } = getListSpan(tokens, line);
cm.replaceRange(newToken, { line: anchor.line, ch: start }, { line: anchor.line, ch: end });
cm.indentLine(anchor.line, 'subtract');
}
}
});
};
// This is a special case of insertList element because it happens when
// vim is in normal mode and input is disabled and the cursor is not
// necessarily at the end of line (but it should pretend it is
CodeMirror.commands.vimInsertListElement = function(cm: any) {
cm.setOption('disableInput', false);
const ranges = cm.listSelections();
if (ranges.length === 0) return;
const { anchor } = ranges[0];
// Need to move the cursor to end of line as this is the vim behavior
const line = cm.getLine(anchor.line);
cm.setCursor({ line: anchor.line, ch: line.length });
cm.execCommand('insertListElement');
CodeMirror.Vim.handleKey(cm, 'i', 'macro');
};
CodeMirror.commands.insertListElement = function(cm: any) {
if (cm.getOption('disableInput')) return CodeMirror.Pass;
const ranges = cm.listSelections();
if (ranges.length === 0) return;
const { anchor } = ranges[0];
// Only perform the extra smart code if there is a single cursor
// otherwise fallback on the default codemirror behavior
if (ranges.length === 1) {
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);
}
return;
}
}
// Disable automatic indent for html/xml outside of codeblocks
const state = cm.getTokenAt(anchor).state;
const mode = cm.getModeAt(anchor);
// html/xml inside of a codeblock is fair game for auto-indent
// for states who's mode is xml, having the localState property means they are within a code block
if (mode.name !== 'xml' || !!state.outer.localState) {
cm.execCommand('newlineAndIndentContinueMarkdownList');
} else {
cm.replaceSelection('\n');
}
};
}

View File

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

View File

@@ -0,0 +1,50 @@
// Kept only for reference
import * as React from 'react';
import { useEffect, useCallback, useRef, forwardRef, useImperativeHandle } from 'react';
export interface OnChangeEvent {
changeId: number,
content: any,
}
interface PlainEditorProps {
style: any,
onChange(event: OnChangeEvent): void,
onWillChange(event:any): void,
markupToHtml: Function,
disabled: boolean,
}
const PlainEditor = (props:PlainEditorProps, ref:any) => {
const editorRef = useRef<any>();
useImperativeHandle(ref, () => {
return {
content: () => '',
};
}, []);
useEffect(() => {
if (!editorRef.current) return;
editorRef.current.value = props.defaultEditorState.value;
}, [props.defaultEditorState]);
const onChange = useCallback((event:any) => {
props.onChange({ changeId: null, content: event.target.value });
}, [props.onWillChange, props.onChange]);
return (
<div style={props.style}>
<textarea
ref={editorRef}
style={{ width: '100%', height: '100%' }}
defaultValue={props.defaultEditorState.value}
onChange={onChange}
/>;
</div>
);
};
export default forwardRef(PlainEditor);

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,5 @@
tinymce.IconManager.add('Joplin', {
icons: {
'paperclip': '<svg xmlns:xlink="http://www.w3.org/1999/xlink" width="24" height="24"><defs><path d="M17.5 21.8c-1 0-2.1-.4-2.9-1.2L5 10.9a4.9 4.9 0 01-1.4-3.3c0-2.7 2.1-4.8 4.8-4.8 1.2 0 2.4.5 3.3 1.4l7.6 7.6.1.2c0 .3-.7 1-1 1l-.2-.1-7.6-7.6c-.6-.6-1.4-1-2.2-1A3.2 3.2 0 006 9.8l9.6 9.8c.5.4 1.2.7 1.8.7 1 0 1.9-.7 1.9-1.8 0-.7-.3-1.3-.8-1.8l-7.2-7.2c-.2-.2-.5-.3-.8-.3-.4 0-.8.3-.8.8 0 .3.1.5.3.7l5.1 5.1.1.3c0 .3-.7 1-1 1l-.2-.1L9 11.8c-.5-.5-.8-1.2-.8-1.9 0-1.4 1-2.4 2.4-2.4.7 0 1.4.3 1.9.8l7.2 7.2c.8.8 1.3 1.8 1.3 2.9 0 2-1.5 3.4-3.5 3.4z" id="a"/></defs><use xlink:href="#a"/><use xlink:href="#a" fill-opacity="0" stroke="#000" stroke-opacity="0"/></svg>',
},
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,61 @@
import { NoteBodyEditorProps } from '../../../utils/types';
const { buildStyle } = require('@joplinapp/lib/theme');
export default function styles(props:NoteBodyEditorProps) {
return buildStyle(['TinyMCE', props.style.width, props.style.height], props.themeId, (theme:any) => {
const extraToolbarContainer = {
backgroundColor: theme.backgroundColor3,
display: 'flex',
flexDirection: 'row',
position: 'absolute',
height: theme.toolbarHeight,
zIndex: 2,
top: 0,
padding: theme.toolbarPadding,
};
return {
disabledOverlay: {
zIndex: 10,
position: 'absolute',
backgroundColor: 'white',
opacity: 0.7,
height: '100%',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
padding: 20,
paddingTop: 50,
textAlign: 'center',
width: '100%',
},
rootStyle: {
position: 'relative',
width: props.style.width,
height: props.style.height,
},
leftExtraToolbarContainer: {
...extraToolbarContainer,
width: 80,
left: 0,
},
rightExtraToolbarContainer: {
...extraToolbarContainer,
alignItems: 'center',
justifyContent: 'flex-end',
width: 70,
right: 0,
paddingRight: theme.mainPadding,
},
extraToolbarButton: {
display: 'flex',
border: 'none',
background: 'none',
},
extraToolbarButtonIcon: {
fontSize: theme.toolbarIconSize,
color: theme.color3,
},
};
});
}

View File

@@ -0,0 +1,51 @@
module.exports = [
"ar",
"bg_BG",
"ca",
"cs",
"cy",
"da",
"de",
"el",
"eo",
"es_ES",
"es_MX",
"es",
"eu",
"fa_IR",
"fa",
"fi",
"fr_FR",
"gl",
"he_IL",
"hr",
"hu_HU",
"id",
"it_IT",
"it",
"ja",
"kk",
"ko_KR",
"lt",
"nb_NO",
"nl",
"pl",
"pt_BR",
"pt_PT",
"ro_RO",
"ro",
"ru",
"sk",
"sl_SI",
"sl",
"sv_SE",
"ta_IN",
"ta",
"th_TH",
"tr_TR",
"tr",
"uk",
"vi",
"zh_CN",
"zh_TW"
]

View File

@@ -0,0 +1,91 @@
import SpellCheckerService from '@joplinapp/lib/services/spellChecker/SpellCheckerService';
import bridge from '../../../../../services/bridge';
import { menuItems, ContextMenuOptions, ContextMenuItemType } from '../../../utils/contextMenu';
const Resource = require('@joplinapp/lib/models/Resource');
// x and y are the absolute coordinates, as returned by the context-menu event
// handler on the webContent. This function will return null if the point is
// not within the TinyMCE editor.
function contextMenuElement(editor:any, x:number, y:number) {
const iframes = document.getElementsByClassName('tox-edit-area__iframe');
if (!iframes.length) return null;
const iframeRect = iframes[0].getBoundingClientRect();
if (iframeRect.x < x && iframeRect.y < y && iframeRect.right > x && iframeRect.bottom > y) {
const relativeX = x - iframeRect.x;
const relativeY = y - iframeRect.y;
return editor.getDoc().elementFromPoint(relativeX, relativeY);
}
return null;
}
interface ContextMenuActionOptions {
current: ContextMenuOptions,
}
const contextMenuActionOptions:ContextMenuActionOptions = { current: null };
export default function(editor:any) {
const contextMenuItems = menuItems();
bridge().window().webContents.on('context-menu', (_event:any, params:any) => {
const element = contextMenuElement(editor, params.x, params.y);
if (!element) return;
const Menu = bridge().Menu;
const MenuItem = bridge().MenuItem;
let itemType:ContextMenuItemType = ContextMenuItemType.None;
let resourceId = '';
let linkToCopy = null;
if (element.nodeName === 'IMG') {
itemType = ContextMenuItemType.Image;
resourceId = Resource.pathToId(element.src);
} else if (element.nodeName === 'A') {
resourceId = Resource.pathToId(element.href);
itemType = resourceId ? ContextMenuItemType.Resource : ContextMenuItemType.Link;
linkToCopy = element.getAttribute('href') || '';
} else {
itemType = ContextMenuItemType.Text;
}
contextMenuActionOptions.current = {
itemType,
resourceId,
linkToCopy,
textToCopy: null,
htmlToCopy: editor.selection ? editor.selection.getContent() : '',
insertContent: (content:string) => {
editor.insertContent(content);
},
isReadOnly: false,
};
const menu = new Menu();
for (const itemName in contextMenuItems) {
const item = contextMenuItems[itemName];
if (!item.isActive(itemType, contextMenuActionOptions.current)) continue;
menu.append(new MenuItem({
label: item.label,
click: () => {
item.onAction(contextMenuActionOptions.current);
},
}));
}
const spellCheckerMenuItems = SpellCheckerService.instance().contextMenuItems(params.misspelledWord, params.dictionarySuggestions);
for (const item of spellCheckerMenuItems) {
menu.append(item);
}
menu.popup();
});
}

View File

@@ -0,0 +1,71 @@
import { useEffect, useCallback, useRef } from 'react';
import shim from '@joplinapp/lib/shim';
interface HookDependencies {
editor:any,
onScroll: Function,
}
export default function useScroll(dependencies:HookDependencies) {
const { editor, onScroll } = dependencies;
const scrollTimeoutId_ = useRef(null);
const maxScrollTop = useCallback(() => {
if (!editor) return 0;
const doc = editor.getDoc();
const win = editor.getWin();
if (!doc || !win) return 0;
const firstChild = doc.firstElementChild;
if (!firstChild) return 0;
const winHeight = win.innerHeight;
const contentHeight = firstChild.scrollHeight;
return contentHeight < winHeight ? 0 : contentHeight - winHeight;
}, [editor]);
const scrollTop = useCallback(() => {
if (!editor) return 0;
const win = editor.getWin();
if (!win) return 0;
return win.scrollY;
}, [editor]);
const scrollPercent = useCallback(() => {
const m = maxScrollTop();
const t = scrollTop();
return m <= 0 ? 0 : t / m;
}, [maxScrollTop, scrollTop]);
const scrollToPercent = useCallback((percent:number) => {
if (!editor) return;
editor.getWin().scrollTo(0, maxScrollTop() * percent);
}, [editor, maxScrollTop]);
const scheduleOnScroll = useCallback((event: any) => {
if (scrollTimeoutId_.current) {
shim.clearTimeout(scrollTimeoutId_.current);
scrollTimeoutId_.current = null;
}
scrollTimeoutId_.current = shim.setTimeout(() => {
scrollTimeoutId_.current = null;
onScroll(event);
}, 10);
}, [onScroll]);
const onEditorScroll = useCallback(() => {
scheduleOnScroll({ percent: scrollPercent() });
}, [scheduleOnScroll, scrollPercent]);
useEffect(() => {
if (!editor) return () => {};
editor.getDoc().addEventListener('scroll', onEditorScroll);
return () => {
editor.getDoc().removeEventListener('scroll', onEditorScroll);
};
}, [editor, onEditorScroll]);
return { scrollToPercent };
}