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

View File

@@ -0,0 +1,582 @@
import * as React from 'react';
import { useState, useEffect, useCallback, useRef } from 'react';
import TinyMCE from './NoteBody/TinyMCE/TinyMCE';
import CodeMirror from './NoteBody/CodeMirror/CodeMirror';
import { connect } from 'react-redux';
import MultiNoteActions from '../MultiNoteActions';
import { htmlToMarkdown, formNoteToNote } from './utils';
import useSearchMarkers from './utils/useSearchMarkers';
import useNoteSearchBar from './utils/useNoteSearchBar';
import useMessageHandler from './utils/useMessageHandler';
import useWindowCommandHandler from './utils/useWindowCommandHandler';
import useDropHandler from './utils/useDropHandler';
import useMarkupToHtml from './utils/useMarkupToHtml';
import useFormNote, { OnLoadEvent } from './utils/useFormNote';
import useFolder from './utils/useFolder';
import styles_ from './styles';
import { NoteEditorProps, FormNote, ScrollOptions, ScrollOptionTypes, OnChangeEvent, NoteBodyEditorProps } from './utils/types';
import ResourceEditWatcher from '@joplinapp/lib/services/ResourceEditWatcher/index';
import CommandService from '@joplinapp/lib/services/CommandService';
import ToolbarButton from '../ToolbarButton/ToolbarButton';
import Button, { ButtonLevel } from '../Button/Button';
import eventManager from '@joplinapp/lib/eventManager';
import { AppState } from '../../app';
import ToolbarButtonUtils from '@joplinapp/lib/services/commands/ToolbarButtonUtils';
import { _ } from '@joplinapp/lib/locale';
import stateToWhenClauseContext from '@joplinapp/lib/services/commands/stateToWhenClauseContext';
import TagList from '../TagList';
import NoteTitleBar from './NoteTitle/NoteTitleBar';
import markupLanguageUtils from '@joplinapp/lib/markupLanguageUtils';
import usePrevious from '../hooks/usePrevious';
import Setting from '@joplinapp/lib/models/Setting';
const { themeStyle } = require('@joplinapp/lib/theme');
const { substrWithEllipsis } = require('@joplinapp/lib/string-utils');
const NoteSearchBar = require('../NoteSearchBar.min.js');
const { reg } = require('@joplinapp/lib/registry.js');
const Note = require('@joplinapp/lib/models/Note.js');
const bridge = require('electron').remote.require('./bridge').default;
const ExternalEditWatcher = require('@joplinapp/lib/services/ExternalEditWatcher');
const NoteRevisionViewer = require('../NoteRevisionViewer.min');
const commands = [
require('./commands/showRevisions'),
];
const toolbarButtonUtils = new ToolbarButtonUtils(CommandService.instance());
function NoteEditor(props: NoteEditorProps) {
const [showRevisions, setShowRevisions] = useState(false);
const [titleHasBeenManuallyChanged, setTitleHasBeenManuallyChanged] = useState(false);
const [scrollWhenReady, setScrollWhenReady] = useState<ScrollOptions>(null);
const editorRef = useRef<any>();
const titleInputRef = useRef<any>();
const isMountedRef = useRef(true);
const noteSearchBarRef = useRef(null);
const formNote_beforeLoad = useCallback(async (event:OnLoadEvent) => {
await saveNoteIfWillChange(event.formNote);
setShowRevisions(false);
}, []);
const formNote_afterLoad = useCallback(async () => {
setTitleHasBeenManuallyChanged(false);
}, []);
const { formNote, setFormNote, isNewNote, resourceInfos } = useFormNote({
syncStarted: props.syncStarted,
noteId: props.noteId,
isProvisional: props.isProvisional,
titleInputRef: titleInputRef,
editorRef: editorRef,
onBeforeLoad: formNote_beforeLoad,
onAfterLoad: formNote_afterLoad,
});
const formNoteRef = useRef<FormNote>();
formNoteRef.current = { ...formNote };
const formNoteFolder = useFolder({ folderId: formNote.parent_id });
const {
localSearch,
onChange: localSearch_change,
onNext: localSearch_next,
onPrevious: localSearch_previous,
onClose: localSearch_close,
setResultCount: setLocalSearchResultCount,
showLocalSearch,
setShowLocalSearch,
searchMarkers: localSearchMarkerOptions,
} = useNoteSearchBar();
// If the note has been modified in another editor, wait for it to be saved
// before loading it in this editor.
// const waitingToSaveNote = props.noteId && formNote.id !== props.noteId && props.editorNoteStatuses[props.noteId] === 'saving';
const styles = styles_(props);
function scheduleSaveNote(formNote: FormNote) {
if (!formNote.saveActionQueue) throw new Error('saveActionQueue is not set!!'); // Sanity check
// reg.logger().debug('Scheduling...', formNote);
const makeAction = (formNote: FormNote) => {
return async function() {
const note = await formNoteToNote(formNote);
reg.logger().debug('Saving note...', note);
const savedNote:any = await Note.save(note);
setFormNote((prev: FormNote) => {
return { ...prev, user_updated_time: savedNote.user_updated_time };
});
ExternalEditWatcher.instance().updateNoteFile(savedNote);
props.dispatch({
type: 'EDITOR_NOTE_STATUS_REMOVE',
id: formNote.id,
});
eventManager.emit('noteContentChange', { note: savedNote });
};
};
formNote.saveActionQueue.push(makeAction(formNote));
}
async function saveNoteIfWillChange(formNote: FormNote) {
if (!formNote.id || !formNote.bodyWillChangeId) return;
const body = await editorRef.current.content();
scheduleSaveNote({
...formNote,
body: body,
bodyWillChangeId: 0,
bodyChangeId: 0,
});
}
async function saveNoteAndWait(formNote: FormNote) {
saveNoteIfWillChange(formNote);
return formNote.saveActionQueue.waitForAllDone();
}
const markupToHtml = useMarkupToHtml({
themeId: props.themeId,
customCss: props.customCss,
plugins: props.plugins,
});
const allAssets = useCallback(async (markupLanguage: number): Promise<any[]> => {
const theme = themeStyle(props.themeId);
const markupToHtml = markupLanguageUtils.newMarkupToHtml({
resourceBaseUrl: `file://${Setting.value('resourceDir')}/`,
});
return markupToHtml.allAssets(markupLanguage, theme);
}, [props.themeId]);
const handleProvisionalFlag = useCallback(() => {
if (props.isProvisional) {
props.dispatch({
type: 'NOTE_PROVISIONAL_FLAG_CLEAR',
id: formNote.id,
});
}
}, [props.isProvisional, formNote.id]);
const previousNoteId = usePrevious(formNote.id);
useEffect(() => {
if (formNote.id === previousNoteId) return;
if (editorRef.current) {
editorRef.current.resetScroll();
}
setScrollWhenReady({
type: props.selectedNoteHash ? ScrollOptionTypes.Hash : ScrollOptionTypes.Percent,
value: props.selectedNoteHash ? props.selectedNoteHash : props.lastEditorScrollPercents[props.noteId] || 0,
});
ResourceEditWatcher.instance().stopWatchingAll();
}, [formNote.id, previousNoteId]);
const onFieldChange = useCallback((field: string, value: any, changeId = 0) => {
if (!isMountedRef.current) {
// When the component is unmounted, various actions can happen which can
// trigger onChange events, for example the textarea might be cleared.
// We need to ignore these events, otherwise the note is going to be saved
// with an invalid body.
reg.logger().debug('Skipping change event because the component is unmounted');
return;
}
handleProvisionalFlag();
const change = field === 'body' ? {
body: value,
} : {
title: value,
};
const newNote = {
...formNote,
...change,
bodyWillChangeId: 0,
bodyChangeId: 0,
hasChanged: true,
};
if (field === 'title') {
setTitleHasBeenManuallyChanged(true);
}
if (isNewNote && !titleHasBeenManuallyChanged && field === 'body') {
// TODO: Handle HTML/Markdown format
newNote.title = Note.defaultTitle(value);
}
if (changeId !== null && field === 'body' && formNote.bodyWillChangeId !== changeId) {
// Note was changed, but another note was loaded before save - skipping
// The previously loaded note, that was modified, will be saved via saveNoteIfWillChange()
} else {
setFormNote(newNote);
scheduleSaveNote(newNote);
}
}, [handleProvisionalFlag, formNote, isNewNote, titleHasBeenManuallyChanged]);
useWindowCommandHandler({ dispatch: props.dispatch, formNote, setShowLocalSearch, noteSearchBarRef, editorRef, titleInputRef, saveNoteAndWait });
const onDrop = useDropHandler({ editorRef });
const onBodyChange = useCallback((event: OnChangeEvent) => onFieldChange('body', event.content, event.changeId), [onFieldChange]);
const onTitleChange = useCallback((event: any) => onFieldChange('title', event.target.value), [onFieldChange]);
// const onTitleKeydown = useCallback((event:any) => {
// const keyCode = event.keyCode;
// if (keyCode === 9) {
// // TAB
// event.preventDefault();
// if (event.shiftKey) {
// CommandService.instance().execute('focusElement', 'noteList');
// } else {
// CommandService.instance().execute('focusElement', 'noteBody');
// }
// }
// }, [props.dispatch]);
const onBodyWillChange = useCallback((event: any) => {
handleProvisionalFlag();
setFormNote(prev => {
return {
...prev,
bodyWillChangeId: event.changeId,
hasChanged: true,
};
});
props.dispatch({
type: 'EDITOR_NOTE_STATUS_SET',
id: formNote.id,
status: 'saving',
});
}, [formNote, handleProvisionalFlag]);
const onMessage = useMessageHandler(scrollWhenReady, setScrollWhenReady, editorRef, setLocalSearchResultCount, props.dispatch, formNote);
const introductionPostLinkClick = useCallback(() => {
bridge().openExternal('https://www.patreon.com/posts/34246624');
}, []);
const externalEditWatcher_noteChange = useCallback((event) => {
if (event.id === formNote.id) {
const newFormNote = {
...formNote,
title: event.note.title,
body: event.note.body,
};
setFormNote(newFormNote);
}
}, [formNote]);
const onNotePropertyChange = useCallback((event) => {
setFormNote(formNote => {
if (formNote.id !== event.note.id) return formNote;
const newFormNote: FormNote = { ...formNote };
for (const key in event.note) {
if (key === 'id') continue;
(newFormNote as any)[key] = event.note[key];
}
return newFormNote;
});
}, []);
useEffect(() => {
eventManager.on('alarmChange', onNotePropertyChange);
ExternalEditWatcher.instance().on('noteChange', externalEditWatcher_noteChange);
return () => {
eventManager.off('alarmChange', onNotePropertyChange);
ExternalEditWatcher.instance().off('noteChange', externalEditWatcher_noteChange);
};
}, [externalEditWatcher_noteChange, onNotePropertyChange]);
useEffect(() => {
const dependencies = {
setShowRevisions,
};
CommandService.instance().componentRegisterCommands(dependencies, commands);
return () => {
CommandService.instance().componentUnregisterCommands(commands);
};
}, [setShowRevisions]);
const onScroll = useCallback((event: any) => {
props.dispatch({
type: 'EDITOR_SCROLL_PERCENT_SET',
noteId: formNote.id,
percent: event.percent,
});
}, [props.dispatch, formNote]);
function renderNoNotes(rootStyle:any) {
const emptyDivStyle = Object.assign(
{
backgroundColor: 'black',
opacity: 0.1,
},
rootStyle
);
return <div style={emptyDivStyle}></div>;
}
function renderTagButton() {
return <ToolbarButton
themeId={props.themeId}
toolbarButtonInfo={props.setTagsToolbarButtonInfo}
/>;
}
function renderTagBar() {
const theme = themeStyle(props.themeId);
const noteIds = [formNote.id];
const instructions = <span onClick={() => { CommandService.instance().execute('setTags', noteIds); }} style={{ ...theme.clickableTextStyle, whiteSpace: 'nowrap' }}>Click to add tags...</span>;
const tagList = props.selectedNoteTags.length ? <TagList items={props.selectedNoteTags} /> : null;
return (
<div style={{ paddingLeft: 8, display: 'flex', flexDirection: 'row', alignItems: 'center' }}>{tagList}{instructions}</div>
);
}
const searchMarkers = useSearchMarkers(showLocalSearch, localSearchMarkerOptions, props.searches, props.selectedSearchId, props.highlightedWords);
const editorProps:NoteBodyEditorProps = {
ref: editorRef,
contentKey: formNote.id,
style: styles.tinyMCE,
onChange: onBodyChange,
onWillChange: onBodyWillChange,
onMessage: onMessage,
content: formNote.body,
contentMarkupLanguage: formNote.markup_language,
contentOriginalCss: formNote.originalCss,
resourceInfos: resourceInfos,
htmlToMarkdown: htmlToMarkdown,
markupToHtml: markupToHtml,
allAssets: allAssets,
disabled: false,
themeId: props.themeId,
dispatch: props.dispatch,
noteToolbar: null,
onScroll: onScroll,
setLocalSearchResultCount: setLocalSearchResultCount,
searchMarkers: searchMarkers,
visiblePanes: props.noteVisiblePanes || ['editor', 'viewer'],
keyboardMode: Setting.value('editor.keyboardMode'),
locale: Setting.value('locale'),
onDrop: onDrop,
noteToolbarButtonInfos: props.toolbarButtonInfos,
plugins: props.plugins,
};
let editor = null;
if (props.bodyEditor === 'TinyMCE') {
editor = <TinyMCE {...editorProps}/>;
} else if (props.bodyEditor === 'CodeMirror') {
editor = <CodeMirror {...editorProps}/>;
} else {
throw new Error(`Invalid editor: ${props.bodyEditor}`);
}
const wysiwygBanner = props.bodyEditor !== 'TinyMCE' ? null : (
<div style={{ ...styles.warningBanner }}>
This is an experimental Rich Text editor for evaluation only. Please do not use with important notes as you may lose some data! See the <a style={styles.urlColor} onClick={introductionPostLinkClick} href="#">introduction post</a> for more information. To switch to the Markdown Editor please press the "Toggle editors" in the top right-hand corner.
</div>
);
const noteRevisionViewer_onBack = useCallback(() => {
setShowRevisions(false);
}, []);
if (showRevisions) {
const theme = themeStyle(props.themeId);
const revStyle:any = {
// ...props.style,
display: 'inline-flex',
padding: theme.margin,
verticalAlign: 'top',
boxSizing: 'border-box',
};
return (
<div style={revStyle}>
<NoteRevisionViewer customCss={props.customCss} noteId={formNote.id} onBack={noteRevisionViewer_onBack} />
</div>
);
}
if (props.selectedNoteIds.length > 1) {
return <MultiNoteActions
themeId={props.themeId}
selectedNoteIds={props.selectedNoteIds}
notes={props.notes}
dispatch={props.dispatch}
watchedNoteFiles={props.watchedNoteFiles}
plugins={props.plugins}
/>;
}
function renderSearchBar() {
if (!showLocalSearch) return false;
const theme = themeStyle(props.themeId);
return (
<NoteSearchBar
ref={noteSearchBarRef}
style={{
display: 'flex',
height: 35,
borderTop: `1px solid ${theme.dividerColor}`,
}}
query={localSearch.query}
searching={localSearch.searching}
resultCount={localSearch.resultCount}
selectedIndex={localSearch.selectedIndex}
onChange={localSearch_change}
onNext={localSearch_next}
onPrevious={localSearch_previous}
onClose={localSearch_close}
visiblePanes={props.noteVisiblePanes}
/>
);
}
function renderResourceWatchingNotification() {
if (!Object.keys(props.watchedResources).length) return null;
const resourceTitles = Object.keys(props.watchedResources).map(id => props.watchedResources[id].title);
return (
<div style={styles.resourceWatchBanner}>
<p style={styles.resourceWatchBannerLine}>{_('The following attachments are being watched for changes:')} <strong>{resourceTitles.join(', ')}</strong></p>
<p style={{ ...styles.resourceWatchBannerLine, marginBottom: 0 }}>{_('The attachments will no longer be watched when you switch to a different note.')}</p>
</div>
);
}
function renderSearchInfo() {
if (formNoteFolder && ['Search', 'Tag', 'SmartFilter'].includes(props.notesParentType)) {
return (
<div style={{ paddingTop: 10, paddingBottom: 10 }}>
<Button
iconName="icon-notebooks"
level={ButtonLevel.Primary}
title={_('In: %s', substrWithEllipsis(formNoteFolder.title, 0, 100))}
onClick={() => {
props.dispatch({
type: 'FOLDER_AND_NOTE_SELECT',
folderId: formNoteFolder.id,
noteId: formNote.id,
});
}}
/>
<div style={{ flex: 1 }}></div>
</div>
);
} else {
return null;
}
}
if (formNote.encryption_applied || !formNote.id || !props.noteId) {
return renderNoNotes(styles.root);
}
return (
<div style={styles.root} onDrop={onDrop}>
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
{renderResourceWatchingNotification()}
<NoteTitleBar
titleInputRef={titleInputRef}
themeId={props.themeId}
isProvisional={props.isProvisional}
noteIsTodo={formNote.is_todo}
noteTitle={formNote.title}
noteUserUpdatedTime={formNote.user_updated_time}
onTitleChange={onTitleChange}
/>
{renderSearchInfo()}
<div style={{ display: 'flex', flex: 1 }}>
{editor}
</div>
<div style={{ display: 'flex', flexDirection: 'row', alignItems: 'center' }}>
{renderSearchBar()}
</div>
<div style={{ display: 'flex', flexDirection: 'row', alignItems: 'center', height: 40 }}>
{renderTagButton()}
{renderTagBar()}
</div>
{wysiwygBanner}
</div>
</div>
);
}
export {
NoteEditor as NoteEditorComponent,
};
const mapStateToProps = (state: AppState) => {
const noteId = state.selectedNoteIds.length === 1 ? state.selectedNoteIds[0] : null;
const whenClauseContext = stateToWhenClauseContext(state);
return {
noteId: noteId,
notes: state.notes,
folders: state.folders,
selectedNoteIds: state.selectedNoteIds,
isProvisional: state.provisionalNoteIds.includes(noteId),
editorNoteStatuses: state.editorNoteStatuses,
syncStarted: state.syncStarted,
themeId: state.settings.theme,
watchedNoteFiles: state.watchedNoteFiles,
notesParentType: state.notesParentType,
selectedNoteTags: state.selectedNoteTags,
lastEditorScrollPercents: state.lastEditorScrollPercents,
selectedNoteHash: state.selectedNoteHash,
searches: state.searches,
selectedSearchId: state.selectedSearchId,
customCss: state.customCss,
noteVisiblePanes: state.noteVisiblePanes,
watchedResources: state.watchedResources,
highlightedWords: state.highlightedWords,
plugins: state.pluginService.plugins,
toolbarButtonInfos: toolbarButtonUtils.commandsToToolbarButtons([
'historyBackward',
'historyForward',
'toggleEditors',
'toggleExternalEditing',
], whenClauseContext),
setTagsToolbarButtonInfo: toolbarButtonUtils.commandsToToolbarButtons([
'setTags',
], whenClauseContext)[0],
};
};
export default connect(mapStateToProps)(NoteEditor);

View File

@@ -0,0 +1,98 @@
import * as React from 'react';
import { _ } from '@joplinapp/lib/locale';
import CommandService from '@joplinapp/lib/services/CommandService';
import { ChangeEvent, useCallback } from 'react';
import NoteToolbar from '../../NoteToolbar/NoteToolbar';
import { buildStyle } from '@joplinapp/lib/theme';
import time from '@joplinapp/lib/time';
interface Props {
themeId: number,
noteUserUpdatedTime: number,
noteTitle: string,
noteIsTodo: number,
isProvisional: boolean,
titleInputRef: any,
onTitleChange(event: ChangeEvent<HTMLInputElement>):void,
}
function styles_(props: Props) {
return buildStyle(['NoteEditorTitleBar'], props.themeId, (theme: any) => {
return {
root: {
display: 'flex', flexDirection: 'row', alignItems: 'center', height: theme.topRowHeight,
},
titleInput: {
flex: 1,
display: 'inline-block',
paddingTop: 5,
minHeight: 35,
boxSizing: 'border-box',
fontWeight: 'bold',
paddingBottom: 5,
paddingLeft: 0,
paddingRight: 8,
marginLeft: 5,
color: theme.textStyle.color,
fontSize: Math.round(theme.textStyle.fontSize * 1.5),
backgroundColor: theme.backgroundColor,
border: 'none',
},
titleDate: {
...theme.textStyle,
color: theme.colorFaded,
paddingLeft: 10,
paddingRight: 10,
},
toolbarStyle: {
marginBottom: 0,
},
};
});
}
export default function NoteTitleBar(props:Props) {
const styles = styles_(props);
const onTitleKeydown = useCallback((event:any) => {
const keyCode = event.keyCode;
if (keyCode === 9) { // TAB
event.preventDefault();
if (event.shiftKey) {
CommandService.instance().execute('focusElement', 'noteList');
} else {
CommandService.instance().execute('focusElement', 'noteBody');
}
}
}, []);
function renderTitleBarDate() {
return <span style={styles.titleDate}>{time.formatMsToLocal(props.noteUserUpdatedTime)}</span>;
}
function renderNoteToolbar() {
return <NoteToolbar
themeId={props.themeId}
style={styles.toolbarStyle}
/>;
}
return (
<div style={styles.root}>
<input
type="text"
ref={props.titleInputRef}
placeholder={props.isProvisional ? _('Creating new %s...', props.noteIsTodo ? _('to-do') : _('note')) : ''}
style={styles.titleInput}
onChange={props.onTitleChange}
onKeyDown={onTitleKeydown}
value={props.noteTitle}
/>
{renderTitleBarDate()}
{renderNoteToolbar()}
</div>
);
}

View File

@@ -0,0 +1,94 @@
import { CommandDeclaration } from '@joplinapp/lib/services/CommandService';
import { _ } from '@joplinapp/lib/locale';
const declarations:CommandDeclaration[] = [
{
name: 'insertText',
},
{
name: 'scrollToHash',
},
{
name: 'textCopy',
label: () => _('Copy'),
role: 'copy',
},
{
name: 'textCut',
label: () => _('Cut'),
role: 'cut',
},
{
name: 'textPaste',
label: () => _('Paste'),
role: 'paste',
},
{
name: 'textSelectAll',
label: () => _('Select all'),
role: 'selectAll',
},
{
name: 'textBold',
label: () => _('Bold'),
iconName: 'icon-bold',
},
{
name: 'textItalic',
label: () => _('Italic'),
iconName: 'icon-italic',
},
{
name: 'textLink',
label: () => _('Hyperlink'),
iconName: 'icon-link',
},
{
name: 'textCode',
label: () => _('Code'),
iconName: 'icon-code',
},
{
name: 'attachFile',
label: () => _('Attach file'),
iconName: 'icon-attachment',
},
{
name: 'textNumberedList',
label: () => _('Numbered List'),
iconName: 'icon-numbered-list',
},
{
name: 'textBulletedList',
label: () => _('Bulleted List'),
iconName: 'icon-bulleted-list',
},
{
name: 'textCheckbox',
label: () => _('Checkbox'),
iconName: 'icon-to-do-list',
},
{
name: 'textHeading',
label: () => _('Heading'),
iconName: 'icon-heading',
},
{
name: 'textHorizontalRule',
label: () => _('Horizontal Rule'),
iconName: 'fas fa-ellipsis-h',
},
{
name: 'insertDateTime',
label: () => _('Insert Date Time'),
iconName: 'icon-add-date',
},
{
name: 'selectedText',
},
{
name: 'replaceSelection',
},
];
export default declarations;

View File

@@ -0,0 +1,17 @@
import { CommandRuntime, CommandDeclaration } from '@joplinapp/lib/services/CommandService';
import { _ } from '@joplinapp/lib/locale';
export const declaration:CommandDeclaration = {
name: 'focusElementNoteBody',
label: () => _('Note body'),
parentLabel: () => _('Focus'),
};
export const runtime = (comp:any):CommandRuntime => {
return {
execute: async () => {
comp.editorRef.current.execCommand({ name: 'focus' });
},
enabledCondition: 'oneNoteSelected',
};
};

View File

@@ -0,0 +1,18 @@
import { CommandRuntime, CommandDeclaration } from '@joplinapp/lib/services/CommandService';
import { _ } from '@joplinapp/lib/locale';
export const declaration:CommandDeclaration = {
name: 'focusElementNoteTitle',
label: () => _('Note title'),
parentLabel: () => _('Focus'),
};
export const runtime = (comp:any):CommandRuntime => {
return {
execute: async () => {
if (!comp.titleInputRef.current) return;
comp.titleInputRef.current.focus();
},
enabledCondition: 'oneNoteSelected',
};
};

View File

@@ -0,0 +1,21 @@
import { CommandRuntime, CommandDeclaration } from '@joplinapp/lib/services/CommandService';
import { _ } from '@joplinapp/lib/locale';
export const declaration:CommandDeclaration = {
name: 'showLocalSearch',
label: () => _('Search in current note'),
};
export const runtime = (comp:any):CommandRuntime => {
return {
execute: async () => {
if (comp.editorRef.current && comp.editorRef.current.supportsCommand('search')) {
comp.editorRef.current.execCommand({ name: 'search' });
} else {
comp.setShowLocalSearch(true);
if (comp.noteSearchBarRef.current) comp.noteSearchBarRef.current.wrappedInstance.focus();
}
},
enabledCondition: 'oneNoteSelected',
};
};

View File

@@ -0,0 +1,13 @@
import { CommandRuntime, CommandDeclaration } from '@joplinapp/lib/services/CommandService';
export const declaration:CommandDeclaration = {
name: 'showRevisions',
};
export const runtime = (comp:any):CommandRuntime => {
return {
execute: async () => {
comp.setShowRevisions(true);
},
};
};

View File

@@ -0,0 +1,67 @@
import { NoteEditorProps } from '../utils/types';
const { buildStyle } = require('@joplinapp/lib/theme');
export default function styles(props: NoteEditorProps) {
return buildStyle(['NoteEditor'], props.themeId, (theme: any) => {
return {
root: {
boxSizing: 'border-box',
paddingLeft: 0,// theme.mainPadding,
paddingTop: 0,
width: '100%',
height: '100%',
},
titleInput: {
flex: 1,
display: 'inline-block',
paddingTop: 5,
minHeight: 35,
boxSizing: 'border-box',
fontWeight: 'bold',
paddingBottom: 5,
paddingLeft: 0,
paddingRight: 8,
marginLeft: 5,
color: theme.textStyle.color,
fontSize: Math.round(theme.textStyle.fontSize * 1.5),
backgroundColor: theme.backgroundColor,
border: 'none',
},
warningBanner: {
background: theme.warningBackgroundColor,
fontFamily: theme.fontFamily,
padding: 10,
fontSize: theme.fontSize,
marginTop: 5,
marginBottom: 5,
},
tinyMCE: {
width: '100%',
height: '100%',
},
toolbar: {
marginTop: 4,
marginBottom: 0,
},
titleDate: {
...theme.textStyle,
color: theme.colorFaded,
paddingLeft: 10,
paddingRight: 10,
},
resourceWatchBanner: {
...theme.textStyle,
padding: 10,
marginLeft: 5,
marginBottom: 10,
color: theme.colorWarn,
backgroundColor: theme.warningBackgroundColor,
},
resourceWatchBannerLine: {
marginTop: 0,
marginBottom: 10,
},
};
});
}

View File

@@ -0,0 +1,150 @@
import ResourceEditWatcher from '@joplinapp/lib/services/ResourceEditWatcher/index';
import { _ } from '@joplinapp/lib/locale';
const bridge = require('electron').remote.require('./bridge').default;
const Menu = bridge().Menu;
const MenuItem = bridge().MenuItem;
const Resource = require('@joplinapp/lib/models/Resource.js');
const fs = require('fs-extra');
const { clipboard } = require('electron');
const { toSystemSlashes } = require('@joplinapp/lib/path-utils');
export enum ContextMenuItemType {
None = '',
Image = 'image',
Resource = 'resource',
Text = 'text',
Link = 'link',
}
export interface ContextMenuOptions {
itemType: ContextMenuItemType,
resourceId: string,
linkToCopy: string,
textToCopy: string,
htmlToCopy: string,
insertContent: Function,
isReadOnly?: boolean,
}
interface ContextMenuItem {
label: string,
onAction: Function,
isActive: Function,
}
interface ContextMenuItems {
[key:string]: ContextMenuItem;
}
async function resourceInfo(options:ContextMenuOptions):Promise<any> {
const resource = options.resourceId ? await Resource.load(options.resourceId) : null;
const resourcePath = resource ? Resource.fullPath(resource) : '';
return { resource, resourcePath };
}
function handleCopyToClipboard(options:ContextMenuOptions) {
if (options.textToCopy) {
clipboard.writeText(options.textToCopy);
} else if (options.htmlToCopy) {
clipboard.writeHTML(options.htmlToCopy);
}
}
export function menuItems():ContextMenuItems {
return {
open: {
label: _('Open...'),
onAction: async (options:ContextMenuOptions) => {
try {
await ResourceEditWatcher.instance().openAndWatch(options.resourceId);
} catch (error) {
console.error(error);
bridge().showErrorMessageBox(error.message);
}
},
isActive: (itemType:ContextMenuItemType) => itemType === ContextMenuItemType.Image || itemType === ContextMenuItemType.Resource,
},
saveAs: {
label: _('Save as...'),
onAction: async (options:ContextMenuOptions) => {
const { resourcePath, resource } = await resourceInfo(options);
const filePath = bridge().showSaveDialog({
defaultPath: resource.filename ? resource.filename : resource.title,
});
if (!filePath) return;
await fs.copy(resourcePath, filePath);
},
isActive: (itemType:ContextMenuItemType) => itemType === ContextMenuItemType.Image || itemType === ContextMenuItemType.Resource,
},
revealInFolder: {
label: _('Reveal file in folder'),
onAction: async (options:ContextMenuOptions) => {
const { resourcePath } = await resourceInfo(options);
bridge().showItemInFolder(resourcePath);
},
isActive: (itemType:ContextMenuItemType) => itemType === ContextMenuItemType.Image || itemType === ContextMenuItemType.Resource,
},
copyPathToClipboard: {
label: _('Copy path to clipboard'),
onAction: async (options:ContextMenuOptions) => {
const { resourcePath } = await resourceInfo(options);
clipboard.writeText(toSystemSlashes(resourcePath));
},
isActive: (itemType:ContextMenuItemType) => itemType === ContextMenuItemType.Image || itemType === ContextMenuItemType.Resource,
},
cut: {
label: _('Cut'),
onAction: async (options:ContextMenuOptions) => {
handleCopyToClipboard(options);
options.insertContent('');
},
isActive: (_itemType:ContextMenuItemType, options:ContextMenuOptions) => !options.isReadOnly && (!!options.textToCopy || !!options.htmlToCopy),
},
copy: {
label: _('Copy'),
onAction: async (options:ContextMenuOptions) => {
handleCopyToClipboard(options);
},
isActive: (_itemType:ContextMenuItemType, options:ContextMenuOptions) => !!options.textToCopy || !!options.htmlToCopy,
},
paste: {
label: _('Paste'),
onAction: async (options:ContextMenuOptions) => {
const content = clipboard.readHTML() ? clipboard.readHTML() : clipboard.readText();
options.insertContent(content);
},
isActive: (_itemType:ContextMenuItemType, options:ContextMenuOptions) => !options.isReadOnly && (!!clipboard.readText() || !!clipboard.readHTML()),
},
copyLinkUrl: {
label: _('Copy Link Address'),
onAction: async (options:ContextMenuOptions) => {
clipboard.writeText(options.linkToCopy !== null ? options.linkToCopy : options.textToCopy);
},
isActive: (itemType:ContextMenuItemType, options:ContextMenuOptions) => itemType === ContextMenuItemType.Link || !!options.linkToCopy,
},
};
}
export default async function contextMenu(options:ContextMenuOptions) {
const menu = new Menu();
const items = menuItems();
if (!('readyOnly' in options)) options.isReadOnly = true;
for (const itemKey in items) {
const item = items[itemKey];
if (!item.isActive(options.itemType, options)) continue;
menu.append(new MenuItem({
label: item.label,
click: () => {
item.onAction(options);
},
}));
}
return menu;
}

View File

@@ -0,0 +1,31 @@
import { FormNote } from './types';
const HtmlToMd = require('@joplinapp/lib/HtmlToMd');
const Note = require('@joplinapp/lib/models/Note');
const { MarkupToHtml } = require('@joplinapp/renderer');
export async function htmlToMarkdown(markupLanguage: number, html: string, originalCss:string): Promise<string> {
let newBody = '';
if (markupLanguage === MarkupToHtml.MARKUP_LANGUAGE_MARKDOWN) {
const htmlToMd = new HtmlToMd();
newBody = htmlToMd.parse(html, { preserveImageTagsWithSize: true });
newBody = await Note.replaceResourceExternalToInternalLinks(newBody, { useAbsolutePaths: true });
} else {
newBody = await Note.replaceResourceExternalToInternalLinks(html, { useAbsolutePaths: true });
if (originalCss) newBody = `<style>${originalCss}</style>\n${newBody}`;
}
return newBody;
}
export async function formNoteToNote(formNote: FormNote): Promise<any> {
return {
id: formNote.id,
// Should also include parent_id so that the reducer can know in which folder the note should go when saving
// https://discourse.joplinapp.org/t/experimental-wysiwyg-editor-in-joplin/6915/57?u=laurent
parent_id: formNote.parent_id,
title: formNote.title,
body: formNote.body,
};
}

View File

@@ -0,0 +1,127 @@
import shim from '@joplinapp/lib/shim';
const Setting = require('@joplinapp/lib/models/Setting').default;
const Note = require('@joplinapp/lib/models/Note.js');
const BaseModel = require('@joplinapp/lib/BaseModel').default;
const Resource = require('@joplinapp/lib/models/Resource.js');
const bridge = require('electron').remote.require('./bridge').default;
const ResourceFetcher = require('@joplinapp/lib/services/ResourceFetcher.js');
const { reg } = require('@joplinapp/lib/registry.js');
const joplinRendererUtils = require('@joplinapp/renderer').utils;
const { clipboard } = require('electron');
const mimeUtils = require('@joplinapp/lib/mime-utils.js').mime;
const md5 = require('md5');
export async function handleResourceDownloadMode(noteBody: string) {
if (noteBody && Setting.value('sync.resourceDownloadMode') === 'auto') {
const resourceIds = await Note.linkedResourceIds(noteBody);
await ResourceFetcher.instance().markForDownload(resourceIds);
}
}
let resourceCache_: any = {};
export function clearResourceCache() {
resourceCache_ = {};
}
export async function attachedResources(noteBody: string): Promise<any> {
if (!noteBody) return {};
const resourceIds = await Note.linkedItemIdsByType(BaseModel.TYPE_RESOURCE, noteBody);
const output: any = {};
for (let i = 0; i < resourceIds.length; i++) {
const id = resourceIds[i];
if (resourceCache_[id]) {
output[id] = resourceCache_[id];
} else {
const resource = await Resource.load(id);
const localState = await Resource.localState(resource);
const o = {
item: resource,
localState: localState,
};
// eslint-disable-next-line require-atomic-updates
resourceCache_[id] = o;
output[id] = o;
}
}
return output;
}
export async function commandAttachFileToBody(body:string, filePaths:string[] = null, options:any = null) {
options = {
createFileURL: false,
position: 0,
...options,
};
if (!filePaths) {
filePaths = bridge().showOpenDialog({
properties: ['openFile', 'createDirectory', 'multiSelections'],
});
if (!filePaths || !filePaths.length) return null;
}
for (let i = 0; i < filePaths.length; i++) {
const filePath = filePaths[i];
try {
reg.logger().info(`Attaching ${filePath}`);
const newBody = await shim.attachFileToNoteBody(body, filePath, options.position, {
createFileURL: options.createFileURL,
resizeLargeImages: 'ask',
});
if (!newBody) {
reg.logger().info('File attachment was cancelled');
return null;
}
body = newBody;
reg.logger().info('File was attached.');
} catch (error) {
reg.logger().error(error);
bridge().showErrorMessageBox(error.message);
}
}
return body;
}
export function resourcesStatus(resourceInfos: any) {
let lowestIndex = joplinRendererUtils.resourceStatusIndex('ready');
for (const id in resourceInfos) {
const s = joplinRendererUtils.resourceStatus(Resource, resourceInfos[id]);
const idx = joplinRendererUtils.resourceStatusIndex(s);
if (idx < lowestIndex) lowestIndex = idx;
}
return joplinRendererUtils.resourceStatusName(lowestIndex);
}
export async function handlePasteEvent(event:any) {
const output = [];
const formats = clipboard.availableFormats();
for (let i = 0; i < formats.length; i++) {
const format = formats[i].toLowerCase();
const formatType = format.split('/')[0];
if (formatType === 'image') {
if (event) event.preventDefault();
const image = clipboard.readImage();
const fileExt = mimeUtils.toFileExtension(format);
const filePath = `${Setting.value('tempDir')}/${md5(Date.now())}.${fileExt}`;
await shim.writeImageToFile(image, format, filePath);
const md = await commandAttachFileToBody('', [filePath]);
await shim.fsDriver().remove(filePath);
if (md) output.push(md);
}
}
return output;
}

View File

@@ -0,0 +1,158 @@
// eslint-disable-next-line no-unused-vars
import AsyncActionQueue from '@joplinapp/lib/AsyncActionQueue';
import { ToolbarButtonInfo } from '@joplinapp/lib/services/commands/ToolbarButtonUtils';
import { PluginStates } from '@joplinapp/lib/services/plugins/reducer';
export interface ToolbarButtonInfos {
[key:string]: ToolbarButtonInfo;
}
export interface NoteEditorProps {
// style: any;
noteId: string;
themeId: number;
dispatch: Function;
selectedNoteIds: string[];
notes: any[];
watchedNoteFiles: string[];
isProvisional: boolean;
editorNoteStatuses: any;
syncStarted: boolean;
bodyEditor: string;
folders: any[];
notesParentType: string;
selectedNoteTags: any[];
lastEditorScrollPercents: any;
selectedNoteHash: string;
searches: any[],
selectedSearchId: string,
customCss: string,
noteVisiblePanes: string[],
watchedResources: any,
highlightedWords: any[],
plugins: PluginStates,
toolbarButtonInfos: ToolbarButtonInfo[],
setTagsToolbarButtonInfo: ToolbarButtonInfo,
}
export interface NoteBodyEditorProps {
style: any;
ref: any,
themeId: number;
content: string,
contentKey: string,
contentMarkupLanguage: number,
contentOriginalCss: string,
onChange(event: OnChangeEvent): void;
onWillChange(event: any): void;
onMessage(event: any): void;
onScroll(event: any): void;
markupToHtml: Function;
htmlToMarkdown: Function;
allAssets: Function;
disabled: boolean;
dispatch: Function;
noteToolbar: any;
setLocalSearchResultCount(count: number): void,
searchMarkers: any,
visiblePanes: string[],
keyboardMode: string,
resourceInfos: ResourceInfos,
locale: string,
onDrop: Function,
noteToolbarButtonInfos: ToolbarButtonInfo[],
plugins: PluginStates,
}
export interface FormNote {
id: string,
title: string,
body: string,
parent_id: string,
is_todo: number,
bodyEditorContent?: any,
markup_language: number,
user_updated_time: number,
encryption_applied: number,
hasChanged: boolean,
// Getting the content from the editor can be a slow process because that content
// might need to be serialized first. For that reason, the wrapped editor (eg TinyMCE)
// first emits onWillChange when there is a change. That event does not include the
// editor content. After a few milliseconds (eg if the user stops typing for long
// enough), the editor emits onChange, and that event will include the editor content.
//
// Both onWillChange and onChange events include a changeId property which is used
// to link the two events together. It is used for example to detect if a new note
// was loaded before the current note was saved - in that case the changeId will be
// different. The two properties bodyWillChangeId and bodyChangeId are used to save
// this info with the currently loaded note.
//
// The willChange/onChange events also allow us to handle the case where the user
// types something then quickly switch a different note. In that case, bodyWillChangeId
// is set, thus we know we should save the note, even though we won't receive the
// onChange event.
bodyWillChangeId: number
bodyChangeId: number,
saveActionQueue: AsyncActionQueue,
// Note with markup_language = HTML have a block of CSS at the start, which is used
// to preserve the style from the original (web-clipped) page. When sending the note
// content to TinyMCE, we only send the actual HTML, without this CSS. The CSS is passed
// via a file in pluginAssets. This is because TinyMCE would not render the style otherwise.
// However, when we get back the HTML from TinyMCE, we need to reconstruct the original note.
// Since the CSS used by TinyMCE has been lost (since it's in a temp CSS file), we keep that
// original CSS here. It's used in formNoteToNote to rebuild the note body.
// We can keep it here because we know TinyMCE will not modify it anyway.
originalCss: string,
}
export function defaultFormNote():FormNote {
return {
id: '',
parent_id: '',
title: '',
body: '',
is_todo: 0,
markup_language: 1,
bodyWillChangeId: 0,
bodyChangeId: 0,
saveActionQueue: null,
originalCss: '',
hasChanged: false,
user_updated_time: 0,
encryption_applied: 0,
};
}
export interface ResourceInfo {
localState: any,
item: any,
}
export interface ResourceInfos {
[index:string]: ResourceInfo,
}
export enum ScrollOptionTypes {
None = 0,
Hash = 1,
Percent = 2,
}
export interface ScrollOptions {
type: ScrollOptionTypes,
value: any,
}
export interface OnChangeEvent {
changeId: number;
content: any;
}
export interface EditorCommand {
name: string;
value: any;
}

View File

@@ -0,0 +1,53 @@
import { useCallback } from 'react';
const Note = require('@joplinapp/lib/models/Note.js');
interface HookDependencies {
editorRef:any,
}
export default function useDropHandler(dependencies:HookDependencies) {
const { editorRef } = dependencies;
return useCallback(async (event:any) => {
const dt = event.dataTransfer;
const createFileURL = event.altKey;
if (dt.types.indexOf('text/x-jop-note-ids') >= 0) {
const noteIds = JSON.parse(dt.getData('text/x-jop-note-ids'));
const noteMarkdownTags = [];
for (let i = 0; i < noteIds.length; i++) {
const note = await Note.load(noteIds[i]);
noteMarkdownTags.push(Note.markdownTag(note));
}
editorRef.current.execCommand({
name: 'dropItems',
value: {
type: 'notes',
markdownTags: noteMarkdownTags,
},
});
return;
}
const files = dt.files;
if (files && files.length) {
const paths = [];
for (let i = 0; i < files.length; i++) {
const file = files[i];
if (!file.path) continue;
paths.push(file.path);
}
editorRef.current.execCommand({
name: 'dropItems',
value: {
type: 'files',
paths: paths,
createFileURL: createFileURL,
},
});
}
}, []);
}

View File

@@ -0,0 +1,29 @@
import { useState, useEffect } from 'react';
const Folder = require('@joplinapp/lib/models/Folder');
interface HookDependencies {
folderId: string,
}
export default function(dependencies:HookDependencies) {
const { folderId } = dependencies;
const [folder, setFolder] = useState(null);
useEffect(function() {
let cancelled = false;
async function loadFolder() {
const f = await Folder.load(folderId);
if (cancelled) return;
setFolder(f);
}
loadFolder();
return function() {
cancelled = true;
};
}, [folderId]);
return folder;
}

View File

@@ -0,0 +1,229 @@
import { useState, useEffect, useCallback } from 'react';
import { FormNote, defaultFormNote, ResourceInfos } from './types';
import { clearResourceCache, attachedResources } from './resourceHandling';
import AsyncActionQueue from '@joplinapp/lib/AsyncActionQueue';
import { handleResourceDownloadMode } from './resourceHandling';
const { MarkupToHtml } = require('@joplinapp/renderer');
const HtmlToHtml = require('@joplinapp/renderer/HtmlToHtml');
const usePrevious = require('../../hooks/usePrevious').default;
const Note = require('@joplinapp/lib/models/Note');
const Setting = require('@joplinapp/lib/models/Setting').default;
const { reg } = require('@joplinapp/lib/registry.js');
const ResourceFetcher = require('@joplinapp/lib/services/ResourceFetcher.js');
const DecryptionWorker = require('@joplinapp/lib/services/DecryptionWorker.js');
const ResourceEditWatcher = require('@joplinapp/lib/services/ResourceEditWatcher/index').default;
export interface OnLoadEvent {
formNote: FormNote,
}
interface HookDependencies {
syncStarted: boolean,
noteId: string,
isProvisional: boolean,
titleInputRef: any,
editorRef: any,
onBeforeLoad(event:OnLoadEvent):void,
onAfterLoad(event:OnLoadEvent):void,
}
function installResourceChangeHandler(onResourceChangeHandler: Function) {
ResourceFetcher.instance().on('downloadComplete', onResourceChangeHandler);
ResourceFetcher.instance().on('downloadStarted', onResourceChangeHandler);
DecryptionWorker.instance().on('resourceDecrypted', onResourceChangeHandler);
ResourceEditWatcher.instance().on('resourceChange', onResourceChangeHandler);
}
function uninstallResourceChangeHandler(onResourceChangeHandler: Function) {
ResourceFetcher.instance().off('downloadComplete', onResourceChangeHandler);
ResourceFetcher.instance().off('downloadStarted', onResourceChangeHandler);
DecryptionWorker.instance().off('resourceDecrypted', onResourceChangeHandler);
ResourceEditWatcher.instance().off('resourceChange', onResourceChangeHandler);
}
function resourceInfosChanged(a:ResourceInfos, b:ResourceInfos):boolean {
if (Object.keys(a).length !== Object.keys(b).length) return true;
for (const id in a) {
const r1 = a[id];
const r2 = b[id];
if (!r2) return true;
if (r1.item.updated_time !== r2.item.updated_time) return true;
if (r1.item.encryption_applied !== r2.item.encryption_applied) return true;
if (r1.item.is_shared !== r2.item.is_shared) return true;
if (r1.localState.fetch_status !== r2.localState.fetch_status) return true;
}
return false;
}
export default function useFormNote(dependencies:HookDependencies) {
const { syncStarted, noteId, isProvisional, titleInputRef, editorRef, onBeforeLoad, onAfterLoad } = dependencies;
const [formNote, setFormNote] = useState<FormNote>(defaultFormNote());
const [isNewNote, setIsNewNote] = useState(false);
const prevSyncStarted = usePrevious(syncStarted);
const previousNoteId = usePrevious(formNote.id);
const [resourceInfos, setResourceInfos] = useState<ResourceInfos>({});
async function initNoteState(n: any) {
let originalCss = '';
if (n.markup_language === MarkupToHtml.MARKUP_LANGUAGE_HTML) {
const htmlToHtml = new HtmlToHtml();
const splitted = htmlToHtml.splitHtml(n.body);
originalCss = splitted.css;
}
const newFormNote = {
id: n.id,
title: n.title,
body: n.body,
is_todo: n.is_todo,
parent_id: n.parent_id,
bodyWillChangeId: 0,
bodyChangeId: 0,
markup_language: n.markup_language,
saveActionQueue: new AsyncActionQueue(300),
originalCss: originalCss,
hasChanged: false,
user_updated_time: n.user_updated_time,
encryption_applied: n.encryption_applied,
};
// Note that for performance reason,the call to setResourceInfos should
// be first because it loads the resource infos in an async way. If we
// swap them, the formNote will be updated first and rendered, then the
// the resources will load, and the note will be re-rendered.
setResourceInfos(await attachedResources(n.body));
setFormNote(newFormNote);
await handleResourceDownloadMode(n.body);
return newFormNote;
}
useEffect(() => {
// Check that synchronisation has just finished - and
// if the note has never been changed, we reload it.
// If the note has already been changed, it's a conflict
// that's already been handled by the synchronizer.
if (!prevSyncStarted) return () => {};
if (syncStarted) return () => {};
if (formNote.hasChanged) return () => {};
reg.logger().debug('Sync has finished and note has never been changed - reloading it');
let cancelled = false;
const loadNote = async () => {
const n = await Note.load(noteId);
if (cancelled) return;
// Normally should not happened because if the note has been deleted via sync
// it would not have been loaded in the editor (due to note selection changing
// on delete)
if (!n) {
reg.logger().warn('Trying to reload note that has been deleted:', noteId);
return;
}
await initNoteState(n);
};
loadNote();
return () => {
cancelled = true;
};
}, [prevSyncStarted, syncStarted, formNote]);
useEffect(() => {
if (!noteId) return () => {};
if (formNote.id === noteId) return () => {};
let cancelled = false;
reg.logger().debug('Loading existing note', noteId);
function handleAutoFocus(noteIsTodo: boolean) {
if (!isProvisional) return;
const focusSettingName = noteIsTodo ? 'newTodoFocus' : 'newNoteFocus';
requestAnimationFrame(() => {
if (Setting.value(focusSettingName) === 'title') {
if (titleInputRef.current) titleInputRef.current.focus();
} else {
if (editorRef.current) editorRef.current.execCommand({ name: 'focus' });
}
});
}
async function loadNote() {
const n = await Note.load(noteId);
if (cancelled) return;
if (!n) throw new Error(`Cannot find note with ID: ${noteId}`);
reg.logger().debug('Loaded note:', n);
await onBeforeLoad({ formNote });
const newFormNote = await initNoteState(n);
setIsNewNote(isProvisional);
await onAfterLoad({ formNote: newFormNote });
handleAutoFocus(!!n.is_todo);
}
loadNote();
return () => {
cancelled = true;
};
}, [noteId, isProvisional, formNote]);
const onResourceChange = useCallback(async function(event:any = null) {
const resourceIds = await Note.linkedResourceIds(formNote.body);
if (!event || resourceIds.indexOf(event.id) >= 0) {
clearResourceCache();
setResourceInfos(await attachedResources(formNote.body));
}
}, [formNote.body]);
useEffect(() => {
installResourceChangeHandler(onResourceChange);
return () => {
uninstallResourceChangeHandler(onResourceChange);
};
}, [onResourceChange]);
useEffect(() => {
if (previousNoteId !== formNote.id) {
onResourceChange();
}
}, [previousNoteId, formNote.id, onResourceChange]);
useEffect(() => {
let cancelled = false;
async function runEffect() {
const r = await attachedResources(formNote.body);
if (cancelled) return;
setResourceInfos((previous:ResourceInfos) => {
return resourceInfosChanged(previous, r) ? r : previous;
});
}
runEffect();
return () => {
cancelled = true;
};
}, [formNote.body]);
return { isNewNote, formNote, setFormNote, resourceInfos };
}

View File

@@ -0,0 +1,63 @@
import { PluginStates } from '@joplinapp/lib/services/plugins/reducer';
import contentScriptsToRendererRules from '@joplinapp/lib/services/plugins/utils/contentScriptsToRendererRules';
import { useCallback, useMemo } from 'react';
import { ResourceInfos } from './types';
import markupLanguageUtils from '@joplinapp/lib/markupLanguageUtils';
import Setting from '@joplinapp/lib/models/Setting';
const { themeStyle } = require('@joplinapp/lib/theme');
const Note = require('@joplinapp/lib/models/Note');
interface HookDependencies {
themeId: number,
customCss: string,
plugins: PluginStates,
}
interface MarkupToHtmlOptions {
replaceResourceInternalToExternalLinks?: boolean,
resourceInfos?: ResourceInfos,
}
export default function useMarkupToHtml(deps:HookDependencies) {
const { themeId, customCss, plugins } = deps;
const markupToHtml = useMemo(() => {
return markupLanguageUtils.newMarkupToHtml({
resourceBaseUrl: `file://${Setting.value('resourceDir')}/`,
extraRendererRules: contentScriptsToRendererRules(plugins),
});
}, [plugins]);
return useCallback(async (markupLanguage: number, md: string, options: MarkupToHtmlOptions = null): Promise<any> => {
options = {
replaceResourceInternalToExternalLinks: false,
resourceInfos: {},
...options,
};
md = md || '';
const theme = themeStyle(themeId);
let resources = {};
if (options.replaceResourceInternalToExternalLinks) {
md = await Note.replaceResourceInternalToExternalLinks(md, { useAbsolutePaths: true });
} else {
resources = options.resourceInfos;
}
delete options.replaceResourceInternalToExternalLinks;
const result = await markupToHtml.render(markupLanguage, md, theme, Object.assign({}, {
codeTheme: theme.codeThemeCss,
userCss: customCss || '',
resources: resources,
postMessageSyntax: 'ipcProxySendToHost',
splitted: true,
externalAssetsOnly: true,
}, options));
return result;
}, [themeId, customCss, markupToHtml]);
}

View File

@@ -0,0 +1,97 @@
import { useCallback } from 'react';
import { FormNote } from './types';
import contextMenu from './contextMenu';
import ResourceEditWatcher from '@joplinapp/lib/services/ResourceEditWatcher/index';
import { _ } from '@joplinapp/lib/locale';
const BaseItem = require('@joplinapp/lib/models/BaseItem');
const BaseModel = require('@joplinapp/lib/BaseModel').default;
const Resource = require('@joplinapp/lib/models/Resource.js');
const bridge = require('electron').remote.require('./bridge').default;
const { urlDecode } = require('@joplinapp/lib/string-utils');
const urlUtils = require('@joplinapp/lib/urlUtils');
const ResourceFetcher = require('@joplinapp/lib/services/ResourceFetcher.js');
const { reg } = require('@joplinapp/lib/registry.js');
export default function useMessageHandler(scrollWhenReady:any, setScrollWhenReady:Function, editorRef:any, setLocalSearchResultCount:Function, dispatch:Function, formNote:FormNote) {
return useCallback(async (event: any) => {
const msg = event.channel ? event.channel : '';
const args = event.args;
const arg0 = args && args.length >= 1 ? args[0] : null;
// if (msg !== 'percentScroll') console.info(`Got ipc-message: ${msg}`, arg0);
if (msg.indexOf('error:') === 0) {
const s = msg.split(':');
s.splice(0, 1);
reg.logger().error(s.join(':'));
} else if (msg === 'noteRenderComplete') {
if (scrollWhenReady) {
const options = { ...scrollWhenReady };
setScrollWhenReady(null);
editorRef.current.scrollTo(options);
}
} else if (msg === 'setMarkerCount') {
setLocalSearchResultCount(arg0);
} else if (msg.indexOf('markForDownload:') === 0) {
const s = msg.split(':');
if (s.length < 2) throw new Error(`Invalid message: ${msg}`);
ResourceFetcher.instance().markForDownload(s[1]);
} else if (msg === 'contextMenu') {
const menu = await contextMenu({
itemType: arg0 && arg0.type,
resourceId: arg0.resourceId,
textToCopy: arg0.textToCopy,
linkToCopy: arg0.linkToCopy || null,
htmlToCopy: '',
insertContent: () => { console.warn('insertContent() not implemented'); },
});
menu.popup(bridge().window());
} else if (msg.indexOf('joplin://') === 0) {
const resourceUrlInfo = urlUtils.parseResourceUrl(msg);
const itemId = resourceUrlInfo.itemId;
const item = await BaseItem.loadItemById(itemId);
if (!item) throw new Error(`No item with ID ${itemId}`);
if (item.type_ === BaseModel.TYPE_RESOURCE) {
const localState = await Resource.localState(item);
if (localState.fetch_status !== Resource.FETCH_STATUS_DONE || !!item.encryption_blob_encrypted) {
if (localState.fetch_status === Resource.FETCH_STATUS_ERROR) {
bridge().showErrorMessageBox(`${_('There was an error downloading this attachment:')}\n\n${localState.fetch_error}`);
} else {
bridge().showErrorMessageBox(_('This attachment is not downloaded or not decrypted yet'));
}
return;
}
try {
await ResourceEditWatcher.instance().openAndWatch(item.id);
} catch (error) {
console.error(error);
bridge().showErrorMessageBox(error.message);
}
} else if (item.type_ === BaseModel.TYPE_NOTE) {
dispatch({
type: 'FOLDER_AND_NOTE_SELECT',
folderId: item.parent_id,
noteId: item.id,
hash: resourceUrlInfo.hash,
});
} else {
throw new Error(`Unsupported item type: ${item.type_}`);
}
} else if (urlUtils.urlProtocol(msg)) {
if (msg.indexOf('file://') === 0) {
// When using the file:// protocol, openExternal doesn't work (does nothing) with URL-encoded paths
require('electron').shell.openExternal(urlDecode(msg));
} else {
require('electron').shell.openExternal(msg);
}
} else if (msg.indexOf('#') === 0) {
// This is an internal anchor, which is handled by the WebView so skip this case
} else {
bridge().showErrorMessageBox(_('Unsupported link or message: %s', msg));
}
}, [dispatch, setLocalSearchResultCount, scrollWhenReady, formNote]);
}

View File

@@ -0,0 +1,92 @@
import { useState, useCallback } from 'react';
import { SearchMarkers } from './useSearchMarkers';
interface LocalSearch {
query: string,
selectedIndex: number,
resultCount: number,
searching: boolean,
timestamp: number,
}
function defaultLocalSearch():LocalSearch {
return {
query: '',
selectedIndex: 0,
resultCount: 0,
searching: false,
timestamp: 0,
};
}
export default function useNoteSearchBar() {
const [showLocalSearch, setShowLocalSearch] = useState(false);
const [localSearch, setLocalSearch] = useState<LocalSearch>(defaultLocalSearch());
const onChange = useCallback((query:string) => {
setLocalSearch((prev:LocalSearch) => {
return {
query: query,
selectedIndex: 0,
timestamp: Date.now(),
resultCount: prev.resultCount,
searching: true,
};
});
}, []);
const noteSearchBarNextPrevious = useCallback((inc:number) => {
setLocalSearch((prev:LocalSearch) => {
const ls = Object.assign({}, prev);
ls.selectedIndex += inc;
ls.timestamp = Date.now();
if (ls.selectedIndex < 0) ls.selectedIndex = ls.resultCount - 1;
if (ls.selectedIndex >= ls.resultCount) ls.selectedIndex = 0;
return ls;
});
}, []);
const onNext = useCallback(() => {
noteSearchBarNextPrevious(+1);
}, [noteSearchBarNextPrevious]);
const onPrevious = useCallback(() => {
noteSearchBarNextPrevious(-1);
}, [noteSearchBarNextPrevious]);
const onClose = useCallback(() => {
setShowLocalSearch(false);
setLocalSearch(defaultLocalSearch());
}, []);
const setResultCount = useCallback((count:number) => {
setLocalSearch((prev:LocalSearch) => {
if (prev.resultCount === count && !prev.searching) return prev;
return {
...prev,
resultCount: count,
searching: false,
};
});
}, []);
const searchMarkers = useCallback(():SearchMarkers => {
return {
options: {
selectedIndex: localSearch.selectedIndex,
separateWordSearch: false,
searchTimestamp: localSearch.timestamp,
},
keywords: [
{
type: 'text',
value: localSearch.query,
accuracy: 'partially',
},
],
};
}, [localSearch]);
return { localSearch, onChange, onNext, onPrevious, onClose, setResultCount, showLocalSearch, setShowLocalSearch, searchMarkers };
}

View File

@@ -0,0 +1,12 @@
import { useEffect } from 'react';
import PlatformImplementation from '../../../services/plugins/PlatformImplementation';
export default function usePluginServiceRegistration(ref:any) {
useEffect(() => {
PlatformImplementation.instance().registerComponent('textEditor', ref);
return () => {
PlatformImplementation.instance().unregisterComponent('textEditor');
};
}, []);
}

View File

@@ -0,0 +1,35 @@
import { useMemo } from 'react';
interface SearchMarkersOptions {
searchTimestamp: number,
selectedIndex: number,
separateWordSearch: boolean,
}
export interface SearchMarkers {
keywords: any[],
options: SearchMarkersOptions,
}
function defaultSearchMarkers():SearchMarkers {
return {
keywords: [],
options: {
searchTimestamp: 0,
selectedIndex: 0,
separateWordSearch: false,
},
};
}
export default function useSearchMarkers(showLocalSearch:boolean, localSearchMarkerOptions:Function, searches:any[], selectedSearchId:string, highlightedWords: any[] = []) {
return useMemo(():SearchMarkers => {
if (showLocalSearch) return localSearchMarkerOptions();
const output = defaultSearchMarkers();
output.keywords = highlightedWords;
return output;
}, [highlightedWords, showLocalSearch, localSearchMarkerOptions, searches, selectedSearchId]);
}

View File

@@ -0,0 +1,82 @@
import { useEffect } from 'react';
import { FormNote, ScrollOptionTypes } from './types';
import editorCommandDeclarations from '../commands/editorCommandDeclarations';
import CommandService, { CommandDeclaration, CommandRuntime, CommandContext } from '@joplinapp/lib/services/CommandService';
const time = require('@joplinapp/lib/time').default;
const { reg } = require('@joplinapp/lib/registry.js');
const commandsWithDependencies = [
require('../commands/showLocalSearch'),
require('../commands/focusElementNoteTitle'),
require('../commands/focusElementNoteBody'),
];
interface HookDependencies {
formNote:FormNote,
setShowLocalSearch:Function,
dispatch:Function,
noteSearchBarRef:any,
editorRef:any,
titleInputRef:any,
saveNoteAndWait: Function,
}
function editorCommandRuntime(declaration:CommandDeclaration, editorRef:any):CommandRuntime {
return {
execute: async (_context:CommandContext, ...args:any[]) => {
if (!editorRef.current.execCommand) {
reg.logger().warn('Received command, but editor cannot execute commands', declaration.name);
return;
}
if (declaration.name === 'insertDateTime') {
return editorRef.current.execCommand({
name: 'insertText',
value: time.formatMsToLocal(new Date().getTime()),
});
} else if (declaration.name === 'scrollToHash') {
return editorRef.current.scrollTo({
type: ScrollOptionTypes.Hash,
value: args[0],
});
} else {
return editorRef.current.execCommand({
name: declaration.name,
value: args[0],
});
}
},
enabledCondition: '!modalDialogVisible && markdownEditorPaneVisible && oneNoteSelected && noteIsMarkdown',
};
}
export default function useWindowCommandHandler(dependencies:HookDependencies) {
const { setShowLocalSearch, noteSearchBarRef, editorRef, titleInputRef } = dependencies;
useEffect(() => {
for (const declaration of editorCommandDeclarations) {
CommandService.instance().registerRuntime(declaration.name, editorCommandRuntime(declaration, editorRef));
}
const dependencies = {
editorRef,
setShowLocalSearch,
noteSearchBarRef,
titleInputRef,
};
for (const command of commandsWithDependencies) {
CommandService.instance().registerRuntime(command.declaration.name, command.runtime(dependencies));
}
return () => {
for (const declaration of editorCommandDeclarations) {
CommandService.instance().unregisterRuntime(declaration.name);
}
for (const command of commandsWithDependencies) {
CommandService.instance().unregisterRuntime(command.declaration.name);
}
};
}, [editorRef, setShowLocalSearch, noteSearchBarRef, titleInputRef]);
}