You've already forked joplin
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:
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
export interface RenderedBody {
|
||||
html: string;
|
||||
pluginAssets: any[];
|
||||
}
|
||||
|
||||
export function defaultRenderedBody(): RenderedBody {
|
||||
return {
|
||||
html: '',
|
||||
pluginAssets: [],
|
||||
};
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
@@ -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 };
|
||||
},
|
||||
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
}, []);
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
};
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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));
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
1133
packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/TinyMCE.tsx
Normal file
1133
packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/TinyMCE.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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,
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user