You've already forked joplin
mirror of
https://github.com/laurent22/joplin.git
synced 2025-11-06 09:19:22 +02:00
All: Use Lerna to manage monorepo
This commit is contained in:
@@ -0,0 +1,662 @@
|
||||
import * as React from 'react';
|
||||
import { useState, useEffect, useRef, forwardRef, useCallback, useImperativeHandle, useMemo } from 'react';
|
||||
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
import { EditorCommand, NoteBodyEditorProps } from '../../utils/types';
|
||||
import { commandAttachFileToBody, handlePasteEvent } from '../../utils/resourceHandling';
|
||||
import { ScrollOptions, ScrollOptionTypes } from '../../utils/types';
|
||||
import { useScrollHandler, usePrevious, cursorPositionToTextOffset, useRootSize } from './utils';
|
||||
import Toolbar from './Toolbar';
|
||||
import styles_ from './styles';
|
||||
import { RenderedBody, defaultRenderedBody } from './utils/types';
|
||||
import NoteTextViewer from '../../../NoteTextViewer';
|
||||
import Editor from './Editor';
|
||||
import usePluginServiceRegistration from '../../utils/usePluginServiceRegistration';
|
||||
import Setting from '@joplinapp/lib/models/Setting';
|
||||
import { _ } from '@joplinapp/lib/locale';
|
||||
import bridge from '../../../../services/bridge';
|
||||
import markdownUtils from '@joplinapp/lib/markdownUtils';
|
||||
import shim from '@joplinapp/lib/shim';
|
||||
|
||||
const Note = require('@joplinapp/lib/models/Note.js');
|
||||
const { clipboard } = require('electron');
|
||||
const shared = require('@joplinapp/lib/components/shared/note-screen-shared.js');
|
||||
const Menu = bridge().Menu;
|
||||
const MenuItem = bridge().MenuItem;
|
||||
const { reg } = require('@joplinapp/lib/registry.js');
|
||||
const dialogs = require('../../../dialogs');
|
||||
const { themeStyle } = require('@joplinapp/lib/theme');
|
||||
|
||||
function markupRenderOptions(override: any = null) {
|
||||
return { ...override };
|
||||
}
|
||||
|
||||
function CodeMirror(props: NoteBodyEditorProps, ref: any) {
|
||||
const styles = styles_(props);
|
||||
|
||||
const [renderedBody, setRenderedBody] = useState<RenderedBody>(defaultRenderedBody()); // Viewer content
|
||||
const [renderedBodyContentKey, setRenderedBodyContentKey] = useState<string>(null);
|
||||
|
||||
const [webviewReady, setWebviewReady] = useState(false);
|
||||
|
||||
const previousContent = usePrevious(props.content);
|
||||
const previousRenderedBody = usePrevious(renderedBody);
|
||||
const previousSearchMarkers = usePrevious(props.searchMarkers);
|
||||
|
||||
const editorRef = useRef(null);
|
||||
const rootRef = useRef(null);
|
||||
const webviewRef = useRef(null);
|
||||
const props_onChangeRef = useRef<Function>(null);
|
||||
props_onChangeRef.current = props.onChange;
|
||||
|
||||
const rootSize = useRootSize({ rootRef });
|
||||
|
||||
usePluginServiceRegistration(ref);
|
||||
|
||||
const { resetScroll, editor_scroll, setEditorPercentScroll, setViewerPercentScroll } = useScrollHandler(editorRef, webviewRef, props.onScroll);
|
||||
|
||||
const codeMirror_change = useCallback((newBody: string) => {
|
||||
props_onChangeRef.current({ changeId: null, content: newBody });
|
||||
}, []);
|
||||
|
||||
const wrapSelectionWithStrings = useCallback((string1: string, string2 = '', defaultText = '') => {
|
||||
if (!editorRef.current) return;
|
||||
|
||||
if (editorRef.current.somethingSelected()) {
|
||||
editorRef.current.wrapSelections(string1, string2);
|
||||
} else {
|
||||
editorRef.current.wrapSelections(string1 + defaultText, string2);
|
||||
|
||||
// Now select the default text so the user can replace it
|
||||
const selections = editorRef.current.listSelections();
|
||||
const newSelections = [];
|
||||
for (let i = 0; i < selections.length; i++) {
|
||||
const s = selections[i];
|
||||
const anchor = { line: s.anchor.line, ch: s.anchor.ch + string1.length };
|
||||
const head = { line: s.head.line, ch: s.head.ch - string2.length };
|
||||
newSelections.push({ anchor: anchor, head: head });
|
||||
}
|
||||
editorRef.current.setSelections(newSelections);
|
||||
}
|
||||
editorRef.current.focus();
|
||||
}, []);
|
||||
|
||||
const addListItem = useCallback((string1, defaultText = '') => {
|
||||
if (editorRef.current) {
|
||||
if (editorRef.current.somethingSelected()) {
|
||||
editorRef.current.wrapSelectionsByLine(string1);
|
||||
} else if (editorRef.current.getCursor('anchor').ch !== 0) {
|
||||
editorRef.current.insertAtCursor(`\n${string1}`);
|
||||
} else {
|
||||
wrapSelectionWithStrings(string1, '', defaultText);
|
||||
}
|
||||
editorRef.current.focus();
|
||||
}
|
||||
}, [wrapSelectionWithStrings]);
|
||||
|
||||
useImperativeHandle(ref, () => {
|
||||
return {
|
||||
content: () => props.content,
|
||||
resetScroll: () => {
|
||||
resetScroll();
|
||||
},
|
||||
scrollTo: (options:ScrollOptions) => {
|
||||
if (options.type === ScrollOptionTypes.Hash) {
|
||||
if (!webviewRef.current) return;
|
||||
webviewRef.current.wrappedInstance.send('scrollToHash', options.value as string);
|
||||
} else if (options.type === ScrollOptionTypes.Percent) {
|
||||
const p = options.value as number;
|
||||
setEditorPercentScroll(p);
|
||||
setViewerPercentScroll(p);
|
||||
} else {
|
||||
throw new Error(`Unsupported scroll options: ${options.type}`);
|
||||
}
|
||||
},
|
||||
supportsCommand: (/* name:string*/) => {
|
||||
// TODO: not implemented, currently only used for "search" command
|
||||
// which is not directly supported by this Editor.
|
||||
return false;
|
||||
},
|
||||
execCommand: async (cmd: EditorCommand) => {
|
||||
if (!editorRef.current) return false;
|
||||
|
||||
reg.logger().debug('CodeMirror: execCommand', cmd);
|
||||
|
||||
let commandProcessed = true;
|
||||
|
||||
if (cmd.name === 'dropItems') {
|
||||
if (cmd.value.type === 'notes') {
|
||||
editorRef.current.insertAtCursor(cmd.value.markdownTags.join('\n'));
|
||||
} else if (cmd.value.type === 'files') {
|
||||
const pos = cursorPositionToTextOffset(editorRef.current.getCursor(), props.content);
|
||||
const newBody = await commandAttachFileToBody(props.content, cmd.value.paths, { createFileURL: !!cmd.value.createFileURL, position: pos });
|
||||
editorRef.current.updateBody(newBody);
|
||||
} else {
|
||||
reg.logger().warn('CodeMirror: unsupported drop item: ', cmd);
|
||||
}
|
||||
} else if (cmd.name === 'focus') {
|
||||
editorRef.current.focus();
|
||||
} else {
|
||||
commandProcessed = false;
|
||||
}
|
||||
|
||||
let commandOutput = null;
|
||||
|
||||
if (!commandProcessed) {
|
||||
const selectedText = () => {
|
||||
if (!editorRef.current) return '';
|
||||
const selections = editorRef.current.getSelections();
|
||||
return selections.length ? selections[0] : '';
|
||||
};
|
||||
|
||||
const commands: any = {
|
||||
selectedText: () => {
|
||||
return selectedText();
|
||||
},
|
||||
selectedHtml: () => {
|
||||
return selectedText();
|
||||
},
|
||||
replaceSelection: (value:any) => {
|
||||
return editorRef.current.replaceSelection(value);
|
||||
},
|
||||
textBold: () => wrapSelectionWithStrings('**', '**', _('strong text')),
|
||||
textItalic: () => wrapSelectionWithStrings('*', '*', _('emphasised text')),
|
||||
textLink: async () => {
|
||||
const url = await dialogs.prompt(_('Insert Hyperlink'));
|
||||
if (url) wrapSelectionWithStrings('[', `](${url})`);
|
||||
},
|
||||
textCode: () => {
|
||||
const selections = editorRef.current.getSelections();
|
||||
|
||||
// This bases the selection wrapping only around the first element
|
||||
if (selections.length > 0) {
|
||||
const string = selections[0];
|
||||
|
||||
// Look for newlines
|
||||
const match = string.match(/\r?\n/);
|
||||
|
||||
if (match && match.length > 0) {
|
||||
wrapSelectionWithStrings(`\`\`\`${match[0]}`, `${match[0]}\`\`\``);
|
||||
} else {
|
||||
wrapSelectionWithStrings('`', '`', '');
|
||||
}
|
||||
}
|
||||
},
|
||||
insertText: (value: any) => editorRef.current.insertAtCursor(value),
|
||||
attachFile: async () => {
|
||||
const cursor = editorRef.current.getCursor();
|
||||
const pos = cursorPositionToTextOffset(cursor, props.content);
|
||||
|
||||
const newBody = await commandAttachFileToBody(props.content, null, { position: pos });
|
||||
if (newBody) editorRef.current.updateBody(newBody);
|
||||
},
|
||||
textNumberedList: () => {
|
||||
let bulletNumber = markdownUtils.olLineNumber(editorRef.current.getCurrentLine());
|
||||
if (!bulletNumber) bulletNumber = markdownUtils.olLineNumber(editorRef.current.getPreviousLine());
|
||||
if (!bulletNumber) bulletNumber = 0;
|
||||
addListItem(`${bulletNumber + 1}. `, _('List item'));
|
||||
},
|
||||
textBulletedList: () => addListItem('- ', _('List item')),
|
||||
textCheckbox: () => addListItem('- [ ] ', _('List item')),
|
||||
textHeading: () => addListItem('## ', ''),
|
||||
textHorizontalRule: () => addListItem('* * *'),
|
||||
};
|
||||
|
||||
if (commands[cmd.name]) {
|
||||
commandOutput = commands[cmd.name](cmd.value);
|
||||
} else {
|
||||
reg.logger().warn('CodeMirror: unsupported Joplin command: ', cmd);
|
||||
}
|
||||
}
|
||||
|
||||
return commandOutput;
|
||||
},
|
||||
};
|
||||
}, [props.content, addListItem, wrapSelectionWithStrings, setEditorPercentScroll, setViewerPercentScroll, resetScroll, renderedBody]);
|
||||
|
||||
const onEditorPaste = useCallback(async (event: any = null) => {
|
||||
const resourceMds = await handlePasteEvent(event);
|
||||
if (!resourceMds.length) return;
|
||||
if (editorRef.current) {
|
||||
editorRef.current.replaceSelection(resourceMds.join('\n'));
|
||||
}
|
||||
}, []);
|
||||
|
||||
const editorCutText = useCallback(() => {
|
||||
if (editorRef.current) {
|
||||
const selections = editorRef.current.getSelections();
|
||||
if (selections.length > 0) {
|
||||
clipboard.writeText(selections[0]);
|
||||
// Easy way to wipe out just the first selection
|
||||
selections[0] = '';
|
||||
editorRef.current.replaceSelections(selections);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
const editorCopyText = useCallback(() => {
|
||||
if (editorRef.current) {
|
||||
const selections = editorRef.current.getSelections();
|
||||
if (selections.length > 0) {
|
||||
clipboard.writeText(selections[0]);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
const editorPasteText = useCallback(() => {
|
||||
if (editorRef.current) {
|
||||
editorRef.current.replaceSelection(clipboard.readText());
|
||||
}
|
||||
}, []);
|
||||
|
||||
const onEditorContextMenu = useCallback(() => {
|
||||
const menu = new Menu();
|
||||
|
||||
const hasSelectedText = editorRef.current && !!editorRef.current.getSelection() ;
|
||||
const clipboardText = clipboard.readText();
|
||||
|
||||
menu.append(
|
||||
new MenuItem({
|
||||
label: _('Cut'),
|
||||
enabled: hasSelectedText,
|
||||
click: async () => {
|
||||
editorCutText();
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
menu.append(
|
||||
new MenuItem({
|
||||
label: _('Copy'),
|
||||
enabled: hasSelectedText,
|
||||
click: async () => {
|
||||
editorCopyText();
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
menu.append(
|
||||
new MenuItem({
|
||||
label: _('Paste'),
|
||||
enabled: true,
|
||||
click: async () => {
|
||||
if (clipboardText) {
|
||||
editorPasteText();
|
||||
} else {
|
||||
// To handle pasting images
|
||||
onEditorPaste();
|
||||
}
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
menu.popup(bridge().window());
|
||||
}, [props.content, editorCutText, editorPasteText, editorCopyText, onEditorPaste]);
|
||||
|
||||
const loadScript = async (script:any) => {
|
||||
return new Promise((resolve) => {
|
||||
let element:any = document.createElement('script');
|
||||
if (script.src.indexOf('.css') >= 0) {
|
||||
element = document.createElement('link');
|
||||
element.rel = 'stylesheet';
|
||||
element.href = script.src;
|
||||
} else {
|
||||
element.src = script.src;
|
||||
|
||||
if (script.attrs) {
|
||||
for (const attr in script.attrs) {
|
||||
element[attr] = script.attrs[attr];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
element.id = script.id;
|
||||
|
||||
element.onload = () => {
|
||||
resolve();
|
||||
};
|
||||
|
||||
document.getElementsByTagName('head')[0].appendChild(element);
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
async function loadScripts() {
|
||||
const scriptsToLoad:{src: string, id:string, loaded: boolean}[] = [
|
||||
{
|
||||
src: 'node_modules/codemirror/addon/dialog/dialog.css',
|
||||
id: 'codemirrorDialogStyle',
|
||||
loaded: false,
|
||||
},
|
||||
];
|
||||
|
||||
// The default codemirror theme is defined in codemirror.css
|
||||
// and doesn't have an extra css file
|
||||
if (styles.editor.codeMirrorTheme !== 'default') {
|
||||
// Solarized light and solarized dark are loaded by the single
|
||||
// solarized.css file
|
||||
let theme = styles.editor.codeMirrorTheme;
|
||||
if (theme.indexOf('solarized') >= 0) theme = 'solarized';
|
||||
|
||||
scriptsToLoad.push({
|
||||
src: `node_modules/codemirror/theme/${theme}.css`,
|
||||
id: `codemirrorTheme${theme}`,
|
||||
loaded: false,
|
||||
});
|
||||
}
|
||||
|
||||
for (const s of scriptsToLoad) {
|
||||
if (document.getElementById(s.id)) {
|
||||
s.loaded = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
await loadScript(s);
|
||||
if (cancelled) return;
|
||||
|
||||
s.loaded = true;
|
||||
}
|
||||
}
|
||||
|
||||
loadScripts();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [styles.editor.codeMirrorTheme]);
|
||||
|
||||
useEffect(() => {
|
||||
const theme = themeStyle(props.themeId);
|
||||
|
||||
const element = document.createElement('style');
|
||||
element.setAttribute('id', 'codemirrorStyle');
|
||||
document.head.appendChild(element);
|
||||
element.appendChild(document.createTextNode(`
|
||||
/* These must be important to prevent the codemirror defaults from taking over*/
|
||||
.CodeMirror {
|
||||
font-family: monospace;
|
||||
font-size: ${theme.editorFontSize}px;
|
||||
height: 100% !important;
|
||||
width: 100% !important;
|
||||
color: inherit !important;
|
||||
background-color: inherit !important;
|
||||
position: absolute !important;
|
||||
-webkit-box-shadow: none !important; // Some themes add a box shadow for some reason
|
||||
}
|
||||
|
||||
.CodeMirror-lines {
|
||||
/* This is used to enable the scroll-past end behaviour. The same height should */
|
||||
/* be applied to the viewer. */
|
||||
padding-bottom: 400px !important;
|
||||
}
|
||||
|
||||
.cm-header-1 {
|
||||
font-size: 1.5em;
|
||||
}
|
||||
|
||||
.cm-header-2 {
|
||||
font-size: 1.3em;
|
||||
}
|
||||
|
||||
.cm-header-3 {
|
||||
font-size: 1.1em;
|
||||
}
|
||||
|
||||
.cm-header-4, .cm-header-5, .cm-header-6 {
|
||||
font-size: 1em;
|
||||
}
|
||||
|
||||
.cm-header-1, .cm-header-2, .cm-header-3, .cm-header-4, .cm-header-5, .cm-header-6 {
|
||||
line-height: 1.5em;
|
||||
}
|
||||
|
||||
.cm-search-marker {
|
||||
background: ${theme.searchMarkerBackgroundColor};
|
||||
color: ${theme.searchMarkerColor} !important;
|
||||
}
|
||||
|
||||
.cm-search-marker-selected {
|
||||
background: ${theme.selectedColor2};
|
||||
color: ${theme.color2} !important;
|
||||
}
|
||||
|
||||
.cm-search-marker-scrollbar {
|
||||
background: ${theme.searchMarkerBackgroundColor};
|
||||
-moz-box-sizing: border-box;
|
||||
box-sizing: border-box;
|
||||
opacity: .5;
|
||||
}
|
||||
|
||||
/* We need to use important to override theme specific values */
|
||||
.cm-error {
|
||||
color: inherit !important;
|
||||
background-color: inherit !important;
|
||||
border-bottom: 1px dotted #dc322f;
|
||||
}
|
||||
|
||||
/* The default dark theme colors don't have enough contrast with the background */
|
||||
.cm-s-nord span.cm-comment {
|
||||
color: #9aa4b6 !important;
|
||||
}
|
||||
|
||||
.cm-s-dracula span.cm-comment {
|
||||
color: #a1abc9 !important;
|
||||
}
|
||||
|
||||
.cm-s-monokai span.cm-comment {
|
||||
color: #908b74 !important;
|
||||
}
|
||||
|
||||
.cm-s-material-darker span.cm-comment {
|
||||
color: #878787 !important;
|
||||
}
|
||||
|
||||
.cm-s-solarized.cm-s-dark span.cm-comment {
|
||||
color: #8ba1a7 !important;
|
||||
}
|
||||
`));
|
||||
|
||||
return () => {
|
||||
document.head.removeChild(element);
|
||||
};
|
||||
}, [props.themeId]);
|
||||
|
||||
const webview_domReady = useCallback(() => {
|
||||
setWebviewReady(true);
|
||||
}, []);
|
||||
|
||||
const webview_ipcMessage = useCallback((event: any) => {
|
||||
const msg = event.channel ? event.channel : '';
|
||||
const args = event.args;
|
||||
const arg0 = args && args.length >= 1 ? args[0] : null;
|
||||
|
||||
if (msg.indexOf('checkboxclick:') === 0) {
|
||||
const newBody = shared.toggleCheckbox(msg, props.content);
|
||||
if (editorRef.current) {
|
||||
editorRef.current.updateBody(newBody);
|
||||
}
|
||||
} else if (msg === 'percentScroll') {
|
||||
setEditorPercentScroll(arg0);
|
||||
} else {
|
||||
props.onMessage(event);
|
||||
}
|
||||
}, [props.onMessage, props.content, setEditorPercentScroll]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
// When a new note is loaded (contentKey is different), we want the note to be displayed
|
||||
// right away. However once that's done, we put a small delay so that the view is not
|
||||
// being constantly updated while the user changes the note.
|
||||
const interval = renderedBodyContentKey !== props.contentKey ? 0 : 500;
|
||||
|
||||
const timeoutId = shim.setTimeout(async () => {
|
||||
let bodyToRender = props.content;
|
||||
|
||||
if (!bodyToRender.trim() && props.visiblePanes.indexOf('viewer') >= 0 && props.visiblePanes.indexOf('editor') < 0) {
|
||||
// Fixes https://github.com/laurent22/joplin/issues/217
|
||||
bodyToRender = `<i>${_('This note has no content. Click on "%s" to toggle the editor and edit the note.', _('Layout'))}</i>`;
|
||||
}
|
||||
|
||||
const result = await props.markupToHtml(props.contentMarkupLanguage, bodyToRender, markupRenderOptions({ resourceInfos: props.resourceInfos }));
|
||||
|
||||
if (cancelled) return;
|
||||
|
||||
setRenderedBody(result);
|
||||
|
||||
// Since we set `renderedBodyContentKey` here, it means this effect is going to
|
||||
// be triggered again, but that's hard to avoid and the second call would be cheap
|
||||
// anyway since the renderered markdown is cached by MdToHtml. We could use a ref
|
||||
// to avoid this, but a second rendering might still happens anyway to render images,
|
||||
// resources, or for other reasons. So it's best to focus on making any second call
|
||||
// to this effect as cheap as possible with caching, etc.
|
||||
setRenderedBodyContentKey(props.contentKey);
|
||||
}, interval);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
shim.clearTimeout(timeoutId);
|
||||
};
|
||||
}, [props.content, props.contentKey, renderedBodyContentKey, props.contentMarkupLanguage, props.visiblePanes, props.resourceInfos, props.markupToHtml]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!webviewReady) return;
|
||||
|
||||
const options: any = {
|
||||
pluginAssets: renderedBody.pluginAssets,
|
||||
downloadResources: Setting.value('sync.resourceDownloadMode'),
|
||||
};
|
||||
webviewRef.current.wrappedInstance.send('setHtml', renderedBody.html, options);
|
||||
}, [renderedBody, webviewReady]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!props.searchMarkers) return;
|
||||
|
||||
// If there is a currently active search, it's important to re-search the text as the user
|
||||
// types. However this is slow for performance so we ONLY want it to happen when there is
|
||||
// a search
|
||||
|
||||
// Note that since the CodeMirror component also needs to handle the viewer pane, we need
|
||||
// to check if the rendered body has changed too (it will be changed with a delay after
|
||||
// props.content has been updated).
|
||||
const textChanged = props.searchMarkers.keywords.length > 0 && (props.content !== previousContent || renderedBody !== previousRenderedBody);
|
||||
|
||||
if (props.searchMarkers !== previousSearchMarkers || textChanged) {
|
||||
webviewRef.current.wrappedInstance.send('setMarkers', props.searchMarkers.keywords, props.searchMarkers.options);
|
||||
|
||||
if (editorRef.current) {
|
||||
const matches = editorRef.current.setMarkers(props.searchMarkers.keywords, props.searchMarkers.options);
|
||||
|
||||
props.setLocalSearchResultCount(matches);
|
||||
}
|
||||
}
|
||||
}, [props.searchMarkers, previousSearchMarkers, props.setLocalSearchResultCount, props.content, previousContent, renderedBody, previousRenderedBody, renderedBody]);
|
||||
|
||||
const cellEditorStyle = useMemo(() => {
|
||||
const output = { ...styles.cellEditor };
|
||||
if (!props.visiblePanes.includes('editor')) {
|
||||
output.display = 'none'; // Seems to work fine since the refactoring
|
||||
}
|
||||
|
||||
return output;
|
||||
}, [styles.cellEditor, props.visiblePanes]);
|
||||
|
||||
const cellViewerStyle = useMemo(() => {
|
||||
const output = { ...styles.cellViewer };
|
||||
if (!props.visiblePanes.includes('viewer')) {
|
||||
// Note: setting webview.display to "none" is currently not supported due
|
||||
// to this bug: https://github.com/electron/electron/issues/8277
|
||||
// So instead setting the width 0.
|
||||
output.width = 1;
|
||||
output.maxWidth = 1;
|
||||
} else if (!props.visiblePanes.includes('editor')) {
|
||||
output.borderLeftStyle = 'none';
|
||||
}
|
||||
return output;
|
||||
}, [styles.cellViewer, props.visiblePanes]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!editorRef.current) return;
|
||||
|
||||
// Anytime the user toggles the visible panes AND the editor is visible as a result
|
||||
// we should focus the editor
|
||||
// The intuition is that a panel toggle (with editor in view) is the equivalent of
|
||||
// an editor interaction so users should expect the editor to be focused
|
||||
if (props.visiblePanes.indexOf('editor') >= 0) {
|
||||
editorRef.current.focus();
|
||||
}
|
||||
}, [props.visiblePanes]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!editorRef.current) return;
|
||||
|
||||
// Need to let codemirror know that it's container's size has changed so that it can
|
||||
// re-compute anything it needs to. This ensures the cursor (and anything that is
|
||||
// based on window size will be correct
|
||||
// Codemirror will automatically refresh on window size changes but otherwise assumes
|
||||
// that it's container size is stable, that is not true with Joplin, hence
|
||||
// why we need to manually let codemirror know about resizes.
|
||||
// Manually calling refresh here will cause a double refresh in some instances (when the
|
||||
// window size is changed for example) but this is a fairly quick operation so it's worth
|
||||
// it.
|
||||
editorRef.current.refresh();
|
||||
}, [rootSize, styles.editor, props.visiblePanes]);
|
||||
|
||||
function renderEditor() {
|
||||
|
||||
return (
|
||||
<div style={cellEditorStyle}>
|
||||
<Editor
|
||||
value={props.content}
|
||||
searchMarkers={props.searchMarkers}
|
||||
ref={editorRef}
|
||||
mode={props.contentMarkupLanguage === Note.MARKUP_LANGUAGE_HTML ? 'xml' : 'joplin-markdown'}
|
||||
codeMirrorTheme={styles.editor.codeMirrorTheme}
|
||||
style={styles.editor}
|
||||
readOnly={props.visiblePanes.indexOf('editor') < 0}
|
||||
autoMatchBraces={Setting.value('editor.autoMatchingBraces')}
|
||||
keyMap={props.keyboardMode}
|
||||
onChange={codeMirror_change}
|
||||
onScroll={editor_scroll}
|
||||
onEditorContextMenu={onEditorContextMenu}
|
||||
onEditorPaste={onEditorPaste}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function renderViewer() {
|
||||
return (
|
||||
<div style={cellViewerStyle}>
|
||||
<NoteTextViewer
|
||||
ref={webviewRef}
|
||||
viewerStyle={styles.viewer}
|
||||
onIpcMessage={webview_ipcMessage}
|
||||
onDomReady={webview_domReady}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={styles.root} ref={rootRef}>
|
||||
<div style={styles.rowToolbar}>
|
||||
<Toolbar
|
||||
themeId={props.themeId}
|
||||
// dispatch={props.dispatch}
|
||||
// plugins={props.plugins}
|
||||
/>
|
||||
{props.noteToolbar}
|
||||
</div>
|
||||
<div style={styles.rowEditorViewer}>
|
||||
{renderEditor()}
|
||||
{renderViewer()}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default forwardRef(CodeMirror);
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
import * as React from 'react';
|
||||
import { useEffect, useImperativeHandle, useState, useRef, useCallback, forwardRef } from 'react';
|
||||
|
||||
import * as CodeMirror from 'codemirror';
|
||||
|
||||
import 'codemirror/addon/comment/comment';
|
||||
import 'codemirror/addon/dialog/dialog';
|
||||
import 'codemirror/addon/edit/closebrackets';
|
||||
import 'codemirror/addon/edit/continuelist';
|
||||
import 'codemirror/addon/scroll/annotatescrollbar';
|
||||
import 'codemirror/addon/search/matchesonscrollbar';
|
||||
import 'codemirror/addon/search/searchcursor';
|
||||
|
||||
import useListIdent from './utils/useListIdent';
|
||||
import useScrollUtils from './utils/useScrollUtils';
|
||||
import useCursorUtils from './utils/useCursorUtils';
|
||||
import useLineSorting from './utils/useLineSorting';
|
||||
import useEditorSearch from './utils/useEditorSearch';
|
||||
import useJoplinMode from './utils/useJoplinMode';
|
||||
import useKeymap from './utils/useKeymap';
|
||||
|
||||
import 'codemirror/keymap/emacs';
|
||||
import 'codemirror/keymap/vim';
|
||||
import 'codemirror/keymap/sublime'; // Used for swapLineUp and swapLineDown
|
||||
|
||||
import 'codemirror/mode/meta';
|
||||
|
||||
// import eventManager from '@joplinapp/lib/eventManager';
|
||||
|
||||
const { reg } = require('@joplinapp/lib/registry.js');
|
||||
|
||||
// Based on http://pypl.github.io/PYPL.html
|
||||
const topLanguages = [
|
||||
'python',
|
||||
'clike',
|
||||
'javascript',
|
||||
'jsx',
|
||||
'php',
|
||||
'r',
|
||||
'swift',
|
||||
'go',
|
||||
'vb',
|
||||
'vbscript',
|
||||
'ruby',
|
||||
'rust',
|
||||
'dart',
|
||||
'lua',
|
||||
'groovy',
|
||||
'perl',
|
||||
'cobol',
|
||||
'julia',
|
||||
'haskell',
|
||||
'pascal',
|
||||
'css',
|
||||
|
||||
// Additional languages, not in the PYPL list
|
||||
'xml', // For HTML too
|
||||
'markdown',
|
||||
'yaml',
|
||||
'shell',
|
||||
'dockerfile',
|
||||
'diff',
|
||||
'erlang',
|
||||
'sql',
|
||||
];
|
||||
// Load Top Modes
|
||||
for (let i = 0; i < topLanguages.length; i++) {
|
||||
const mode = topLanguages[i];
|
||||
|
||||
if (CodeMirror.modeInfo.find((m: any) => m.mode === mode)) {
|
||||
require(`codemirror/mode/${mode}/${mode}`);
|
||||
} else {
|
||||
reg.logger().error('Cannot find CodeMirror mode: ', mode);
|
||||
}
|
||||
}
|
||||
|
||||
export interface EditorProps {
|
||||
value: string,
|
||||
searchMarkers: any,
|
||||
mode: string,
|
||||
style: any,
|
||||
codeMirrorTheme: any,
|
||||
readOnly: boolean,
|
||||
autoMatchBraces: boolean,
|
||||
keyMap: string,
|
||||
onChange: any,
|
||||
onScroll: any,
|
||||
onEditorContextMenu: any,
|
||||
onEditorPaste: any,
|
||||
}
|
||||
|
||||
function Editor(props: EditorProps, ref: any) {
|
||||
const [editor, setEditor] = useState(null);
|
||||
const editorParent = useRef(null);
|
||||
|
||||
// Codemirror plugins add new commands to codemirror (or change it's behavior)
|
||||
// This command adds the smartListIndent function which will be bound to tab
|
||||
useListIdent(CodeMirror);
|
||||
useScrollUtils(CodeMirror);
|
||||
useCursorUtils(CodeMirror);
|
||||
useLineSorting(CodeMirror);
|
||||
useEditorSearch(CodeMirror);
|
||||
useJoplinMode(CodeMirror);
|
||||
useKeymap(CodeMirror);
|
||||
|
||||
useImperativeHandle(ref, () => {
|
||||
return editor;
|
||||
});
|
||||
|
||||
const editor_change = useCallback((cm: any, change: any) => {
|
||||
if (props.onChange && change.origin !== 'setValue') {
|
||||
props.onChange(cm.getValue());
|
||||
}
|
||||
}, [props.onChange]);
|
||||
|
||||
// eslint-disable-next-line no-unused-vars, @typescript-eslint/no-unused-vars
|
||||
const editor_scroll = useCallback((_cm: any) => {
|
||||
props.onScroll();
|
||||
}, [props.onScroll]);
|
||||
|
||||
// eslint-disable-next-line no-unused-vars, @typescript-eslint/no-unused-vars
|
||||
const editor_mousedown = useCallback((_cm: any, event: any) => {
|
||||
if (event && event.button === 2) {
|
||||
props.onEditorContextMenu();
|
||||
}
|
||||
}, [props.onEditorContextMenu]);
|
||||
|
||||
// eslint-disable-next-line no-unused-vars, @typescript-eslint/no-unused-vars
|
||||
const editor_paste = useCallback((_cm: any, _event: any) => {
|
||||
props.onEditorPaste();
|
||||
}, [props.onEditorPaste]);
|
||||
|
||||
// eslint-disable-next-line no-unused-vars, @typescript-eslint/no-unused-vars
|
||||
const editor_drop = useCallback((cm: any, _event: any) => {
|
||||
cm.focus();
|
||||
}, []);
|
||||
|
||||
const editor_drag = useCallback((cm: any, event: any) => {
|
||||
// This is the type for all drag and drops that are external to codemirror
|
||||
// setting the cursor allows us to drop them in the right place
|
||||
if (event.dataTransfer.effectAllowed === 'all') {
|
||||
const coords = cm.coordsChar({ left: event.x, top: event.y });
|
||||
cm.setCursor(coords);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!editorParent.current) return () => {};
|
||||
|
||||
// const userOptions = eventManager.filterEmit('codeMirrorOptions', {});
|
||||
const userOptions = {};
|
||||
|
||||
const cmOptions = Object.assign({}, {
|
||||
value: props.value,
|
||||
screenReaderLabel: props.value,
|
||||
theme: props.codeMirrorTheme,
|
||||
mode: props.mode,
|
||||
readOnly: props.readOnly,
|
||||
autoCloseBrackets: props.autoMatchBraces,
|
||||
inputStyle: 'textarea', // contenteditable loses cursor position on focus change, use textarea instead
|
||||
lineWrapping: true,
|
||||
lineNumbers: false,
|
||||
indentWithTabs: true,
|
||||
indentUnit: 4,
|
||||
spellcheck: true,
|
||||
allowDropFileTypes: [''], // disable codemirror drop handling
|
||||
keyMap: props.keyMap ? props.keyMap : 'default',
|
||||
}, userOptions);
|
||||
|
||||
const cm = CodeMirror(editorParent.current, cmOptions);
|
||||
setEditor(cm);
|
||||
cm.on('change', editor_change);
|
||||
cm.on('scroll', editor_scroll);
|
||||
cm.on('mousedown', editor_mousedown);
|
||||
cm.on('paste', editor_paste);
|
||||
cm.on('drop', editor_drop);
|
||||
cm.on('dragover', editor_drag);
|
||||
|
||||
// It's possible for searchMarkers to be available before the editor
|
||||
// In these cases we set the markers asap so the user can see them as
|
||||
// soon as the editor is ready
|
||||
if (props.searchMarkers) { cm.setMarkers(props.searchMarkers.keywords, props.searchMarkers.options); }
|
||||
|
||||
return () => {
|
||||
// Clean up codemirror
|
||||
cm.off('change', editor_change);
|
||||
cm.off('scroll', editor_scroll);
|
||||
cm.off('mousedown', editor_mousedown);
|
||||
cm.off('paste', editor_paste);
|
||||
cm.off('drop', editor_drop);
|
||||
cm.off('dragover', editor_drag);
|
||||
editorParent.current.removeChild(cm.getWrapperElement());
|
||||
setEditor(null);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (editor) {
|
||||
// Value can also be changed by the editor itself so we need this guard
|
||||
// to prevent loops
|
||||
if (props.value !== editor.getValue()) {
|
||||
editor.setValue(props.value);
|
||||
editor.clearHistory();
|
||||
}
|
||||
editor.setOption('screenReaderLabel', props.value);
|
||||
}
|
||||
}, [props.value]);
|
||||
|
||||
useEffect(() => {
|
||||
if (editor) {
|
||||
editor.setOption('theme', props.codeMirrorTheme);
|
||||
}
|
||||
}, [props.codeMirrorTheme]);
|
||||
|
||||
useEffect(() => {
|
||||
if (editor) {
|
||||
editor.setOption('mode', props.mode);
|
||||
}
|
||||
}, [props.mode]);
|
||||
|
||||
useEffect(() => {
|
||||
if (editor) {
|
||||
editor.setOption('readOnly', props.readOnly);
|
||||
}
|
||||
}, [props.readOnly]);
|
||||
|
||||
useEffect(() => {
|
||||
if (editor) {
|
||||
editor.setOption('autoCloseBrackets', props.autoMatchBraces);
|
||||
}
|
||||
}, [props.autoMatchBraces]);
|
||||
|
||||
useEffect(() => {
|
||||
if (editor) {
|
||||
editor.setOption('keyMap', props.keyMap ? props.keyMap : 'default');
|
||||
}
|
||||
}, [props.keyMap]);
|
||||
|
||||
return <div style={props.style} ref={editorParent} />;
|
||||
}
|
||||
|
||||
export default forwardRef(Editor);
|
||||
@@ -0,0 +1,63 @@
|
||||
import * as React from 'react';
|
||||
import CommandService from '@joplinapp/lib/services/CommandService';
|
||||
import ToolbarBase from '../../../ToolbarBase';
|
||||
import { utils as pluginUtils } from '@joplinapp/lib/services/plugins/reducer';
|
||||
import { connect } from 'react-redux';
|
||||
import { AppState } from '../../../../app';
|
||||
import ToolbarButtonUtils, { ToolbarButtonInfo } from '@joplinapp/lib/services/commands/ToolbarButtonUtils';
|
||||
import stateToWhenClauseContext from '@joplinapp/lib/services/commands/stateToWhenClauseContext';
|
||||
const { buildStyle } = require('@joplinapp/lib/theme');
|
||||
|
||||
interface ToolbarProps {
|
||||
themeId: number,
|
||||
toolbarButtonInfos: ToolbarButtonInfo[],
|
||||
}
|
||||
|
||||
function styles_(props:ToolbarProps) {
|
||||
return buildStyle('CodeMirrorToolbar', props.themeId, () => {
|
||||
return {
|
||||
root: {
|
||||
flex: 1,
|
||||
marginBottom: 0,
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
const toolbarButtonUtils = new ToolbarButtonUtils(CommandService.instance());
|
||||
|
||||
function Toolbar(props:ToolbarProps) {
|
||||
const styles = styles_(props);
|
||||
return <ToolbarBase style={styles.root} items={props.toolbarButtonInfos} />;
|
||||
}
|
||||
|
||||
const mapStateToProps = (state: AppState) => {
|
||||
const whenClauseContext = stateToWhenClauseContext(state);
|
||||
|
||||
const commandNames = [
|
||||
'historyBackward',
|
||||
'historyForward',
|
||||
'toggleExternalEditing',
|
||||
'-',
|
||||
'textBold',
|
||||
'textItalic',
|
||||
'-',
|
||||
'textLink',
|
||||
'textCode',
|
||||
'attachFile',
|
||||
'-',
|
||||
'textBulletedList',
|
||||
'textNumberedList',
|
||||
'textCheckbox',
|
||||
'textHeading',
|
||||
'textHorizontalRule',
|
||||
'insertDateTime',
|
||||
'toggleEditors',
|
||||
].concat(pluginUtils.commandNamesFromViews(state.pluginService.plugins, 'editorToolbar'));
|
||||
|
||||
return {
|
||||
toolbarButtonInfos: toolbarButtonUtils.commandsToToolbarButtons(commandNames, whenClauseContext),
|
||||
};
|
||||
};
|
||||
|
||||
export default connect(mapStateToProps)(Toolbar);
|
||||
@@ -0,0 +1,60 @@
|
||||
import { NoteBodyEditorProps } from '../../../utils/types';
|
||||
const { buildStyle } = require('@joplinapp/lib/theme');
|
||||
|
||||
export default function styles(props: NoteBodyEditorProps) {
|
||||
return buildStyle('CodeMirror', props.themeId, (theme: any) => {
|
||||
return {
|
||||
root: {
|
||||
position: 'relative',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
...props.style,
|
||||
},
|
||||
rowToolbar: {
|
||||
position: 'relative',
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
},
|
||||
rowEditorViewer: {
|
||||
position: 'relative',
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
flex: 1,
|
||||
paddingTop: 10,
|
||||
},
|
||||
cellEditor: {
|
||||
position: 'relative',
|
||||
display: 'flex',
|
||||
flex: 1,
|
||||
},
|
||||
cellViewer: {
|
||||
position: 'relative',
|
||||
display: 'flex',
|
||||
flex: 1,
|
||||
borderLeftWidth: 1,
|
||||
borderLeftColor: theme.dividerColor,
|
||||
borderLeftStyle: 'solid',
|
||||
},
|
||||
viewer: {
|
||||
display: 'flex',
|
||||
overflow: 'hidden',
|
||||
verticalAlign: 'top',
|
||||
boxSizing: 'border-box',
|
||||
width: '100%',
|
||||
},
|
||||
editor: {
|
||||
display: 'flex',
|
||||
width: 'auto',
|
||||
height: 'auto',
|
||||
flex: 1,
|
||||
overflowY: 'hidden',
|
||||
paddingTop: 0,
|
||||
lineHeight: `${theme.textAreaLineHeight}px`,
|
||||
fontSize: `${theme.editorFontSize}px`,
|
||||
color: theme.color,
|
||||
backgroundColor: theme.backgroundColor,
|
||||
codeMirrorTheme: theme.codeMirrorTheme, // Defined in theme.js
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import { useEffect, useCallback, useRef, useState } from 'react';
|
||||
import shim from '@joplinapp/lib/shim';
|
||||
|
||||
export function cursorPositionToTextOffset(cursorPos: any, body: string) {
|
||||
if (!body) return 0;
|
||||
|
||||
const noteLines = body.split('\n');
|
||||
|
||||
let pos = 0;
|
||||
for (let i = 0; i < noteLines.length; i++) {
|
||||
if (i > 0) pos++; // Need to add the newline that's been removed in the split() call above
|
||||
|
||||
if (i === cursorPos.line) {
|
||||
pos += cursorPos.ch;
|
||||
break;
|
||||
} else {
|
||||
pos += noteLines[i].length;
|
||||
}
|
||||
}
|
||||
|
||||
return pos;
|
||||
}
|
||||
|
||||
export function usePrevious(value: any): any {
|
||||
const ref = useRef();
|
||||
useEffect(() => {
|
||||
ref.current = value;
|
||||
});
|
||||
return ref.current;
|
||||
}
|
||||
|
||||
export function useScrollHandler(editorRef: any, webviewRef: any, onScroll: Function) {
|
||||
const ignoreNextEditorScrollEvent_ = useRef(false);
|
||||
const scrollTimeoutId_ = useRef<any>(null);
|
||||
|
||||
const scheduleOnScroll = useCallback((event: any) => {
|
||||
if (scrollTimeoutId_.current) {
|
||||
shim.clearTimeout(scrollTimeoutId_.current);
|
||||
scrollTimeoutId_.current = null;
|
||||
}
|
||||
|
||||
scrollTimeoutId_.current = shim.setTimeout(() => {
|
||||
scrollTimeoutId_.current = null;
|
||||
onScroll(event);
|
||||
}, 10);
|
||||
}, [onScroll]);
|
||||
|
||||
const setEditorPercentScroll = useCallback((p: number) => {
|
||||
ignoreNextEditorScrollEvent_.current = true;
|
||||
|
||||
if (editorRef.current) {
|
||||
editorRef.current.setScrollPercent(p);
|
||||
|
||||
scheduleOnScroll({ percent: p });
|
||||
}
|
||||
}, [scheduleOnScroll]);
|
||||
|
||||
const setViewerPercentScroll = useCallback((p: number) => {
|
||||
if (webviewRef.current) {
|
||||
webviewRef.current.wrappedInstance.send('setPercentScroll', p);
|
||||
scheduleOnScroll({ percent: p });
|
||||
}
|
||||
}, [scheduleOnScroll]);
|
||||
|
||||
const editor_scroll = useCallback(() => {
|
||||
if (ignoreNextEditorScrollEvent_.current) {
|
||||
ignoreNextEditorScrollEvent_.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (editorRef.current) {
|
||||
const percent = editorRef.current.getScrollPercent();
|
||||
|
||||
setViewerPercentScroll(percent);
|
||||
}
|
||||
}, [setViewerPercentScroll]);
|
||||
|
||||
const resetScroll = useCallback(() => {
|
||||
if (editorRef.current) {
|
||||
editorRef.current.setScrollPercent(0);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return { resetScroll, setEditorPercentScroll, setViewerPercentScroll, editor_scroll };
|
||||
}
|
||||
|
||||
|
||||
export function useRootSize(dependencies:any) {
|
||||
const { rootRef } = dependencies;
|
||||
|
||||
const [rootSize, setRootSize] = useState({ width: 0, height: 0 });
|
||||
|
||||
useEffect(() => {
|
||||
if (!rootRef.current) return;
|
||||
|
||||
const { width, height } = rootRef.current.getBoundingClientRect();
|
||||
|
||||
if (rootSize.width !== width || rootSize.height !== height) {
|
||||
setRootSize({ width: width, height: height });
|
||||
}
|
||||
});
|
||||
|
||||
return rootSize;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
export interface RenderedBody {
|
||||
html: string;
|
||||
pluginAssets: any[];
|
||||
}
|
||||
|
||||
export function defaultRenderedBody(): RenderedBody {
|
||||
return {
|
||||
html: '',
|
||||
pluginAssets: [],
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
// Helper functions that use the cursor
|
||||
export default function useCursorUtils(CodeMirror: any) {
|
||||
|
||||
CodeMirror.defineExtension('insertAtCursor', function(text: string) {
|
||||
// This is also the method to get all cursors
|
||||
const ranges = this.listSelections();
|
||||
// Batches the insert operations, if this wasn't done the inserts
|
||||
// could potentially overwrite one another
|
||||
this.operation(() => {
|
||||
for (let i = 0; i < ranges.length; i++) {
|
||||
// anchor is where the selection starts, and head is where it ends
|
||||
// this changes based on how the uses makes a selection
|
||||
const { anchor, head } = ranges[i];
|
||||
// We want the selection that comes first in the document
|
||||
let range = anchor;
|
||||
if (head.line < anchor.line || (head.line === anchor.line && head.ch < anchor.ch)) {
|
||||
range = head;
|
||||
}
|
||||
this.replaceRange(text, range);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
CodeMirror.defineExtension('getCurrentLine', function() {
|
||||
const curs = this.getCursor('anchor');
|
||||
|
||||
return this.getLine(curs.line);
|
||||
});
|
||||
|
||||
CodeMirror.defineExtension('getPreviousLine', function() {
|
||||
const curs = this.getCursor('anchor');
|
||||
|
||||
if (curs.line > 0) { return this.getLine(curs.line - 1); }
|
||||
return '';
|
||||
});
|
||||
|
||||
// this updates the body in a way that registers with the undo/redo
|
||||
CodeMirror.defineExtension('updateBody', function(newBody: string) {
|
||||
const start = { line: this.firstLine(), ch: 0 };
|
||||
const last = this.getLine(this.lastLine());
|
||||
const end = { line: this.lastLine(), ch: last ? last.length : 0 };
|
||||
|
||||
this.replaceRange(newBody, start, end);
|
||||
});
|
||||
|
||||
CodeMirror.defineExtension('wrapSelections', function(string1: string, string2: string) {
|
||||
const selectedStrings = this.getSelections();
|
||||
|
||||
// Batches the insert operations, if this wasn't done the inserts
|
||||
// could potentially overwrite one another
|
||||
this.operation(() => {
|
||||
for (let i = 0; i < selectedStrings.length; i++) {
|
||||
const selected = selectedStrings[i];
|
||||
|
||||
// Remove white space on either side of selection
|
||||
const start = selected.search(/[^\s]/);
|
||||
const end = selected.search(/[^\s](?=[\s]*$)/);
|
||||
const core = selected.substr(start, end - start + 1);
|
||||
|
||||
// If selection can be toggled do that
|
||||
if (core.startsWith(string1) && core.endsWith(string2)) {
|
||||
const inside = core.substr(string1.length, core.length - string1.length - string2.length);
|
||||
selectedStrings[i] = selected.substr(0, start) + inside + selected.substr(end + 1);
|
||||
} else {
|
||||
selectedStrings[i] = selected.substr(0, start) + string1 + core + string2 + selected.substr(end + 1);
|
||||
}
|
||||
}
|
||||
this.replaceSelections(selectedStrings, 'around');
|
||||
});
|
||||
});
|
||||
|
||||
CodeMirror.defineExtension('wrapSelectionsByLine', function(string1: string) {
|
||||
const selectedStrings = this.getSelections();
|
||||
|
||||
// Batches the insert operations, if this wasn't done the inserts
|
||||
// could potentially overwrite one another
|
||||
this.operation(() => {
|
||||
for (let i = 0; i < selectedStrings.length; i++) {
|
||||
const selected = selectedStrings[i];
|
||||
|
||||
const lines = selected.split(/\r?\n/);
|
||||
// Save the newline character to restore it later
|
||||
const newLines = selected.match(/\r?\n/);
|
||||
|
||||
for (let j = 0; j < lines.length; j++) {
|
||||
const line = lines[j];
|
||||
// Only add the list token if it's not already there
|
||||
// if it is, remove it
|
||||
if (!line.startsWith(string1)) {
|
||||
lines[j] = string1 + line;
|
||||
} else {
|
||||
lines[j] = line.substr(string1.length, line.length - string1.length);
|
||||
}
|
||||
}
|
||||
|
||||
const newLine = newLines !== null ? newLines[0] : '\n';
|
||||
selectedStrings[i] = lines.join(newLine);
|
||||
}
|
||||
this.replaceSelections(selectedStrings, 'around');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import shim from '@joplinapp/lib/shim';
|
||||
|
||||
export default function useEditorSearch(CodeMirror: any) {
|
||||
|
||||
const [markers, setMarkers] = useState([]);
|
||||
const [overlay, setOverlay] = useState(null);
|
||||
const [scrollbarMarks, setScrollbarMarks] = useState(null);
|
||||
const [previousKeywordValue, setPreviousKeywordValue] = useState(null);
|
||||
const [previousIndex, setPreviousIndex] = useState(null);
|
||||
const [overlayTimeout, setOverlayTimeout] = useState(null);
|
||||
const overlayTimeoutRef = useRef(null);
|
||||
overlayTimeoutRef.current = overlayTimeout;
|
||||
|
||||
function clearMarkers() {
|
||||
for (let i = 0; i < markers.length; i++) {
|
||||
markers[i].clear();
|
||||
}
|
||||
|
||||
setMarkers([]);
|
||||
}
|
||||
|
||||
function clearOverlay(cm: any) {
|
||||
if (overlay) cm.removeOverlay(overlay);
|
||||
if (scrollbarMarks) scrollbarMarks.clear();
|
||||
|
||||
if (overlayTimeout) shim.clearTimeout(overlayTimeout);
|
||||
|
||||
setOverlay(null);
|
||||
setScrollbarMarks(null);
|
||||
setOverlayTimeout(null);
|
||||
}
|
||||
|
||||
// Modified from codemirror/addons/search/search.js
|
||||
function searchOverlay(query: RegExp) {
|
||||
return { token: function(stream: any) {
|
||||
query.lastIndex = stream.pos;
|
||||
const match = query.exec(stream.string);
|
||||
if (match && match.index == stream.pos) {
|
||||
stream.pos += match[0].length || 1;
|
||||
return 'search-marker';
|
||||
} else if (match) {
|
||||
stream.pos = match.index;
|
||||
} else {
|
||||
stream.skipToEnd();
|
||||
}
|
||||
return null;
|
||||
} };
|
||||
}
|
||||
|
||||
// Highlights the currently active found work
|
||||
// It's possible to get tricky with this fucntions and just use findNext/findPrev
|
||||
// but this is fast enough and works more naturally with the current search logic
|
||||
function highlightSearch(cm: any, searchTerm: RegExp, index: number, scrollTo: boolean) {
|
||||
const cursor = cm.getSearchCursor(searchTerm);
|
||||
|
||||
let match: any = null;
|
||||
for (let j = 0; j < index + 1; j++) {
|
||||
if (!cursor.findNext()) {
|
||||
// If we run out of matches then just highlight the final match
|
||||
break;
|
||||
}
|
||||
match = cursor.pos;
|
||||
}
|
||||
|
||||
if (match) {
|
||||
if (scrollTo) cm.scrollIntoView(match);
|
||||
return cm.markText(match.from, match.to, { className: 'cm-search-marker-selected' });
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions#Escaping
|
||||
function escapeRegExp(keyword: string) {
|
||||
return keyword.replace(/[.*+\-?^${}()|[\]\\]/g, '\\$&'); // $& means the whole matched string
|
||||
}
|
||||
|
||||
function getSearchTerm(keyword: any) {
|
||||
const value = escapeRegExp(keyword.value);
|
||||
return new RegExp(value, 'gi');
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (overlayTimeoutRef.current) shim.clearTimeout(overlayTimeoutRef.current);
|
||||
overlayTimeoutRef.current = null;
|
||||
};
|
||||
}, []);
|
||||
|
||||
CodeMirror.defineExtension('setMarkers', function(keywords: any, options: any) {
|
||||
if (!options) {
|
||||
options = { selectedIndex: 0 };
|
||||
}
|
||||
|
||||
clearMarkers();
|
||||
|
||||
// HIGHLIGHT KEYWORDS
|
||||
// When doing a global search it's possible to have multiple keywords
|
||||
// This means we need to highlight each one
|
||||
const marks: any = [];
|
||||
for (let i = 0; i < keywords.length; i++) {
|
||||
const keyword = keywords[i];
|
||||
|
||||
if (keyword.value === '') continue;
|
||||
|
||||
const searchTerm = getSearchTerm(keyword);
|
||||
|
||||
// We only want to scroll the first keyword into view in the case of a multi keyword search
|
||||
const scrollTo = i === 0 && (previousKeywordValue !== keyword.value || previousIndex !== options.selectedIndex);
|
||||
|
||||
const match = highlightSearch(this, searchTerm, options.selectedIndex, scrollTo);
|
||||
if (match) marks.push(match);
|
||||
}
|
||||
|
||||
setMarkers(marks);
|
||||
setPreviousIndex(options.selectedIndex);
|
||||
|
||||
// SEARCHOVERLAY
|
||||
// We only want to highlight all matches when there is only 1 search term
|
||||
if (keywords.length !== 1 || keywords[0].value == '') {
|
||||
clearOverlay(this);
|
||||
const prev = keywords.length > 1 ? keywords[0].value : '';
|
||||
setPreviousKeywordValue(prev);
|
||||
return 0;
|
||||
}
|
||||
|
||||
const searchTerm = getSearchTerm(keywords[0]);
|
||||
|
||||
// Determine the number of matches in the source, this is passed on
|
||||
// to the NoteEditor component
|
||||
const regexMatches = this.getValue().match(searchTerm);
|
||||
const nMatches = regexMatches ? regexMatches.length : 0;
|
||||
|
||||
// Don't bother clearing and re-calculating the overlay if the search term
|
||||
// hasn't changed
|
||||
if (keywords[0].value === previousKeywordValue) return nMatches;
|
||||
|
||||
clearOverlay(this);
|
||||
setPreviousKeywordValue(keywords[0].value);
|
||||
|
||||
// These operations are pretty slow, so we won't add use them until the user
|
||||
// has finished typing, 500ms is probably enough time
|
||||
const timeout = shim.setTimeout(() => {
|
||||
const scrollMarks = this.showMatchesOnScrollbar(searchTerm, true, 'cm-search-marker-scrollbar');
|
||||
const overlay = searchOverlay(searchTerm);
|
||||
this.addOverlay(overlay);
|
||||
setOverlay(overlay);
|
||||
setScrollbarMarks(scrollMarks);
|
||||
}, 500);
|
||||
|
||||
setOverlayTimeout(timeout);
|
||||
overlayTimeoutRef.current = timeout;
|
||||
|
||||
return nMatches;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import 'codemirror/addon/mode/multiplex';
|
||||
import 'codemirror/mode/stex/stex';
|
||||
import Setting from '@joplinapp/lib/models/Setting';
|
||||
|
||||
// Joplin markdown is a the same as markdown mode, but it has configured defaults
|
||||
// and support for katex math blocks
|
||||
export default function useJoplinMode(CodeMirror: any) {
|
||||
|
||||
CodeMirror.defineMode('joplin-markdown', (config: any) => {
|
||||
const markdownConfig = {
|
||||
name: 'markdown',
|
||||
taskLists: true,
|
||||
strikethrough: true,
|
||||
emoji: Setting.value('markdown.plugin.emoji'),
|
||||
tokenTypeOverrides: {
|
||||
linkText: 'link-text',
|
||||
},
|
||||
};
|
||||
|
||||
const markdownMode = CodeMirror.getMode(config, markdownConfig);
|
||||
const stex = CodeMirror.getMode(config, { name: 'stex', inMathMode: true });
|
||||
|
||||
const inlineKatexOpenRE = /(?<!\S)\$(?=[^\s$].*?[^\\\s$]\$(?!\S))/;
|
||||
const inlineKatexCloseRE = /(?<![\\\s$])\$(?!\S)/;
|
||||
const blockKatexOpenRE = /(?<!\S)\$\$/;
|
||||
const blockKatexCloseRE = /(?<![\\\s])\$\$/;
|
||||
|
||||
// Find token will search for a valid katex start or end token
|
||||
// If found then it will return the index, otherwise -1
|
||||
function findToken(stream: any, token: RegExp) {
|
||||
const match = token.exec(stream.string.slice(stream.pos));
|
||||
|
||||
return match ? match.index + stream.pos : -1;
|
||||
}
|
||||
|
||||
return {
|
||||
startState: function(): { outer: any, openCharacter: string, inner: any } {
|
||||
return {
|
||||
outer: CodeMirror.startState(markdownMode),
|
||||
openCharacter: '',
|
||||
inner: CodeMirror.startState(stex),
|
||||
};
|
||||
},
|
||||
|
||||
copyState: function(state: any) {
|
||||
return {
|
||||
outer: CodeMirror.copyState(markdownMode, state.outer),
|
||||
openCharacter: state.openCharacter,
|
||||
inner: CodeMirror.copyState(stex, state.inner),
|
||||
};
|
||||
},
|
||||
|
||||
token: function(stream: any, state: any) {
|
||||
let currentMode = markdownMode;
|
||||
let currentState = state.outer;
|
||||
let tokenLabel = 'katex-marker-open';
|
||||
let nextTokenPos = stream.string.length;
|
||||
let closing = false;
|
||||
|
||||
if (state.openCharacter) {
|
||||
currentMode = stex;
|
||||
currentState = state.inner;
|
||||
tokenLabel = 'katex-marker-close';
|
||||
closing = true;
|
||||
|
||||
const blockPos = findToken(stream, blockKatexCloseRE);
|
||||
const inlinePos = findToken(stream, inlineKatexCloseRE);
|
||||
|
||||
if (state.openCharacter === '$$' && blockPos !== -1) nextTokenPos = blockPos;
|
||||
if (state.openCharacter === '$' && inlinePos !== -1) nextTokenPos = inlinePos;
|
||||
} else if (!currentState.code) {
|
||||
const blockPos = findToken(stream, blockKatexOpenRE);
|
||||
const inlinePos = findToken(stream, inlineKatexOpenRE);
|
||||
|
||||
if (blockPos !== -1) nextTokenPos = blockPos;
|
||||
if (inlinePos !== -1 && inlinePos < nextTokenPos) nextTokenPos = inlinePos;
|
||||
|
||||
if (blockPos === stream.pos) state.openCharacter = '$$';
|
||||
if (inlinePos === stream.pos) state.openCharacter = '$';
|
||||
}
|
||||
|
||||
if (nextTokenPos === stream.pos) {
|
||||
stream.match(state.openCharacter);
|
||||
|
||||
if (closing) state.openCharacter = '';
|
||||
|
||||
return tokenLabel;
|
||||
}
|
||||
|
||||
// If we found a token in this stream but haven;t reached it yet, then we will
|
||||
// pass all the characters leading up to our token to markdown mode
|
||||
const oldString = stream.string;
|
||||
|
||||
stream.string = oldString.slice(0, nextTokenPos);
|
||||
const token = currentMode.token(stream, currentState);
|
||||
stream.string = oldString;
|
||||
|
||||
return token;
|
||||
},
|
||||
|
||||
indent: function(state: any, textAfter: string, line: any) {
|
||||
const mode = state.openCharacter ? stex : markdownMode;
|
||||
if (!mode.indent) return CodeMirror.Pass;
|
||||
return mode.indent(state.openCharacter ? state.inner : state.outer, textAfter, line);
|
||||
},
|
||||
|
||||
blankLine: function(state: any) {
|
||||
const mode = state.openCharacter ? stex : markdownMode;
|
||||
if (mode.blankLine) {
|
||||
mode.blankLine(state.openCharacter ? state.inner : state.outer);
|
||||
}
|
||||
},
|
||||
|
||||
electricChars: markdownMode.electricChars,
|
||||
|
||||
innerMode: function(state: any) {
|
||||
return state.openCharacter ? { state: state.inner, mode: stex } : { state: state.outer, mode: markdownMode };
|
||||
},
|
||||
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import { useEffect } from 'react';
|
||||
import CommandService from '@joplinapp/lib/services/CommandService';
|
||||
import shim from '@joplinapp/lib/shim';
|
||||
|
||||
export default function useKeymap(CodeMirror: any) {
|
||||
|
||||
function save() {
|
||||
CommandService.instance().execute('synchronize');
|
||||
}
|
||||
|
||||
function setupEmacs() {
|
||||
CodeMirror.keyMap.emacs['Tab'] = 'smartListIndent';
|
||||
CodeMirror.keyMap.emacs['Enter'] = 'insertListElement';
|
||||
CodeMirror.keyMap.emacs['Shift-Tab'] = 'smartListUnindent';
|
||||
}
|
||||
|
||||
function setupVim() {
|
||||
CodeMirror.Vim.defineAction('swapLineDown', CodeMirror.commands.swapLineDown);
|
||||
CodeMirror.Vim.mapCommand('<A-j>', 'action', 'swapLineDown', {}, { context: 'normal', isEdit: true });
|
||||
CodeMirror.Vim.defineAction('swapLineUp', CodeMirror.commands.swapLineUp);
|
||||
CodeMirror.Vim.mapCommand('<A-k>', 'action', 'swapLineUp', {}, { context: 'normal', isEdit: true });
|
||||
CodeMirror.Vim.defineAction('insertListElement', CodeMirror.commands.vimInsertListElement);
|
||||
CodeMirror.Vim.mapCommand('o', 'action', 'insertListElement', { after: true }, { context: 'normal', isEdit: true, interlaceInsertRepeat: true });
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
// This enables the special modes (emacs and vim) to initiate sync by the save action
|
||||
CodeMirror.commands.save = save;
|
||||
|
||||
CodeMirror.keyMap.basic = {
|
||||
'Left': 'goCharLeft',
|
||||
'Right': 'goCharRight',
|
||||
'Up': 'goLineUp',
|
||||
'Down': 'goLineDown',
|
||||
'End': 'goLineRight',
|
||||
'Home': 'goLineLeftSmart',
|
||||
'PageUp': 'goPageUp',
|
||||
'PageDown': 'goPageDown',
|
||||
'Delete': 'delCharAfter',
|
||||
'Backspace': 'delCharBefore',
|
||||
'Shift-Backspace': 'delCharBefore',
|
||||
'Tab': 'smartListIndent',
|
||||
'Shift-Tab': 'smartListUnindent',
|
||||
'Enter': 'insertListElement',
|
||||
'Insert': 'toggleOverwrite',
|
||||
'Esc': 'singleSelection',
|
||||
};
|
||||
|
||||
if (shim.isMac()) {
|
||||
CodeMirror.keyMap.default = {
|
||||
// MacOS
|
||||
'Cmd-A': 'selectAll',
|
||||
'Cmd-D': 'deleteLine',
|
||||
'Cmd-Z': 'undo',
|
||||
'Shift-Cmd-Z': 'redo',
|
||||
'Cmd-Y': 'redo',
|
||||
'Cmd-Home': 'goDocStart',
|
||||
'Cmd-Up': 'goDocStart',
|
||||
'Cmd-End': 'goDocEnd',
|
||||
'Cmd-Down': 'goDocEnd',
|
||||
'Cmd-Left': 'goLineLeft',
|
||||
'Cmd-Right': 'goLineRight',
|
||||
'Alt-Left': 'goGroupLeft',
|
||||
'Alt-Right': 'goGroupRight',
|
||||
'Alt-Backspace': 'delGroupBefore',
|
||||
'Alt-Delete': 'delGroupAfter',
|
||||
'Cmd-[': 'indentLess',
|
||||
'Cmd-]': 'indentMore',
|
||||
'Cmd-/': 'toggleComment',
|
||||
'Cmd-Opt-S': 'sortSelectedLines',
|
||||
'Opt-Up': 'swapLineUp',
|
||||
'Opt-Down': 'swapLineDown',
|
||||
|
||||
'fallthrough': 'basic',
|
||||
};
|
||||
} else {
|
||||
CodeMirror.keyMap.default = {
|
||||
// Windows/linux
|
||||
'Ctrl-A': 'selectAll',
|
||||
'Ctrl-D': 'deleteLine',
|
||||
'Ctrl-Z': 'undo',
|
||||
'Shift-Ctrl-Z': 'redo',
|
||||
'Ctrl-Y': 'redo',
|
||||
'Ctrl-Home': 'goDocStart',
|
||||
'Ctrl-End': 'goDocEnd',
|
||||
'Ctrl-Up': 'goLineUp',
|
||||
'Ctrl-Down': 'goLineDown',
|
||||
'Ctrl-Left': 'goGroupLeft',
|
||||
'Ctrl-Right': 'goGroupRight',
|
||||
'Alt-Left': 'goLineStart',
|
||||
'Alt-Right': 'goLineEnd',
|
||||
'Ctrl-Backspace': 'delGroupBefore',
|
||||
'Ctrl-Delete': 'delGroupAfter',
|
||||
'Ctrl-[': 'indentLess',
|
||||
'Ctrl-]': 'indentMore',
|
||||
'Ctrl-/': 'toggleComment',
|
||||
'Ctrl-Alt-S': 'sortSelectedLines',
|
||||
'Alt-Up': 'swapLineUp',
|
||||
'Alt-Down': 'swapLineDown',
|
||||
|
||||
'fallthrough': 'basic',
|
||||
};
|
||||
}
|
||||
setupEmacs();
|
||||
setupVim();
|
||||
}, []);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
// Duplicates AceEditors line sorting function
|
||||
// https://discourse.joplinapp.org/t/sort-lines/8874/2
|
||||
export default function useLineSorting(CodeMirror: any) {
|
||||
CodeMirror.commands.sortSelectedLines = function(cm: any) {
|
||||
const ranges = cm.listSelections();
|
||||
// Batches the insert operations, if this wasn't done the inserts
|
||||
// could potentially overwrite one another
|
||||
cm.operation(() => {
|
||||
for (let i = 0; i < ranges.length; i++) {
|
||||
// anchor is where the selection starts, and head is where it ends
|
||||
// this changes based on how the uses makes a selection
|
||||
const { anchor, head } = ranges[i];
|
||||
const start = Math.min(anchor.line, head.line);
|
||||
const end = Math.max(anchor.line, head.line);
|
||||
|
||||
const lines = [];
|
||||
for (let j = start; j <= end; j++) {
|
||||
lines.push(cm.getLine(j));
|
||||
}
|
||||
|
||||
const text = lines.sort().join('\n');
|
||||
// Get the end of the last line
|
||||
const ch = lines[lines.length - 1].length;
|
||||
|
||||
cm.replaceRange(text, { line: start, ch: 0 }, { line: end, ch: ch });
|
||||
}
|
||||
});
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
const { isListItem, isEmptyListItem, extractListToken, olLineNumber } = require('@joplinapp/lib/markdownUtils').default;
|
||||
|
||||
// Markdown list indentation.
|
||||
// If the current line starts with `markup.list` token,
|
||||
// hitting `Tab` key indents the line instead of inserting tab at cursor.
|
||||
// hitting enter will insert a new list element, and unindent/delete an empty element
|
||||
export default function useListIdent(CodeMirror: any) {
|
||||
|
||||
function isSelection(anchor: any, head: any) {
|
||||
return anchor.line !== head.line || anchor.ch !== head.ch;
|
||||
}
|
||||
|
||||
function getIndentLevel(cm: any, line: number) {
|
||||
const tokens = cm.getLineTokens(line);
|
||||
let indentLevel = 0;
|
||||
if (tokens.length > 0 && tokens[0].string.match(/^\s/)) {
|
||||
indentLevel = tokens[0].string.length;
|
||||
}
|
||||
|
||||
return indentLevel;
|
||||
}
|
||||
|
||||
function newListToken(cm: any, line: number) {
|
||||
const currentToken = extractListToken(cm.getLine(line));
|
||||
const indentLevel = getIndentLevel(cm, line);
|
||||
|
||||
while (--line > 0) {
|
||||
const currentLine = cm.getLine(line);
|
||||
if (!isListItem(currentLine)) return currentToken;
|
||||
|
||||
const indent = getIndentLevel(cm, line);
|
||||
|
||||
if (indent < indentLevel - 1) return currentToken;
|
||||
|
||||
if (indent === indentLevel - 1) {
|
||||
if (olLineNumber(currentLine)) {
|
||||
return `${olLineNumber(currentLine) + 1}. `;
|
||||
}
|
||||
const token = extractListToken(currentLine);
|
||||
if (token.match(/x/)) {
|
||||
return '- [ ] ';
|
||||
}
|
||||
return token;
|
||||
}
|
||||
}
|
||||
|
||||
return currentToken;
|
||||
}
|
||||
|
||||
// Gets the character coordinates of the start and end of a list token
|
||||
function getListSpan(listTokens: any, line: string) {
|
||||
let start = listTokens[0].start;
|
||||
const token = extractListToken(line);
|
||||
|
||||
if (listTokens.length > 1 && listTokens[0].string.match(/^\s/)) {
|
||||
start = listTokens[1].start;
|
||||
}
|
||||
|
||||
return { start: start, end: start + token.length };
|
||||
}
|
||||
|
||||
CodeMirror.commands.smartListIndent = function(cm: any) {
|
||||
if (cm.getOption('disableInput')) return CodeMirror.Pass;
|
||||
|
||||
const ranges = cm.listSelections();
|
||||
|
||||
cm.operation(() => {
|
||||
for (let i = 0; i < ranges.length; i++) {
|
||||
const { anchor, head } = ranges[i];
|
||||
|
||||
const line = cm.getLine(anchor.line);
|
||||
|
||||
// This is an actual selection and we should indent
|
||||
if (isSelection(anchor, head)) {
|
||||
cm.execCommand('defaultTab');
|
||||
// This will apply to all selections so it makes sense to stop processing here
|
||||
// this is an edge case for users because there is no clear intended behavior
|
||||
// if the use multicursor with a mix of selected and not selected
|
||||
break;
|
||||
} else if (!isListItem(line) || !isEmptyListItem(line)) {
|
||||
cm.replaceRange('\t', anchor, head);
|
||||
} else {
|
||||
if (olLineNumber(line)) {
|
||||
const tokens = cm.getLineTokens(anchor.line);
|
||||
const { start, end } = getListSpan(tokens, line);
|
||||
// Resets numbered list to 1.
|
||||
cm.replaceRange('1. ', { line: anchor.line, ch: start }, { line: anchor.line, ch: end });
|
||||
}
|
||||
|
||||
cm.indentLine(anchor.line, 'add');
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
CodeMirror.commands.smartListUnindent = function(cm: any) {
|
||||
if (cm.getOption('disableInput')) return CodeMirror.Pass;
|
||||
|
||||
const ranges = cm.listSelections();
|
||||
|
||||
cm.operation(() => {
|
||||
for (let i = 0; i < ranges.length; i++) {
|
||||
const { anchor, head } = ranges[i];
|
||||
|
||||
const line = cm.getLine(anchor.line);
|
||||
|
||||
// This is an actual selection and we should unindent
|
||||
if (isSelection(anchor, head)) {
|
||||
cm.execCommand('indentLess');
|
||||
// This will apply to all selections so it makes sense to stop processing here
|
||||
// this is an edge case for users because there is no clear intended behavior
|
||||
// if the use multicursor with a mix of selected and not selected
|
||||
break;
|
||||
} else if (!isListItem(line) || !isEmptyListItem(line)) {
|
||||
cm.indentLine(anchor.line, 'subtract');
|
||||
} else {
|
||||
const newToken = newListToken(cm, anchor.line);
|
||||
const tokens = cm.getLineTokens(anchor.line);
|
||||
const { start, end } = getListSpan(tokens, line);
|
||||
|
||||
cm.replaceRange(newToken, { line: anchor.line, ch: start }, { line: anchor.line, ch: end });
|
||||
|
||||
cm.indentLine(anchor.line, 'subtract');
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// This is a special case of insertList element because it happens when
|
||||
// vim is in normal mode and input is disabled and the cursor is not
|
||||
// necessarily at the end of line (but it should pretend it is
|
||||
CodeMirror.commands.vimInsertListElement = function(cm: any) {
|
||||
cm.setOption('disableInput', false);
|
||||
|
||||
const ranges = cm.listSelections();
|
||||
if (ranges.length === 0) return;
|
||||
const { anchor } = ranges[0];
|
||||
|
||||
// Need to move the cursor to end of line as this is the vim behavior
|
||||
const line = cm.getLine(anchor.line);
|
||||
cm.setCursor({ line: anchor.line, ch: line.length });
|
||||
|
||||
cm.execCommand('insertListElement');
|
||||
CodeMirror.Vim.handleKey(cm, 'i', 'macro');
|
||||
};
|
||||
|
||||
CodeMirror.commands.insertListElement = function(cm: any) {
|
||||
if (cm.getOption('disableInput')) return CodeMirror.Pass;
|
||||
|
||||
const ranges = cm.listSelections();
|
||||
if (ranges.length === 0) return;
|
||||
const { anchor } = ranges[0];
|
||||
|
||||
// Only perform the extra smart code if there is a single cursor
|
||||
// otherwise fallback on the default codemirror behavior
|
||||
if (ranges.length === 1) {
|
||||
const line = cm.getLine(anchor.line);
|
||||
|
||||
if (isEmptyListItem(line)) {
|
||||
const tokens = cm.getLineTokens(anchor.line);
|
||||
// A empty list item with an indent will have whitespace as the first token
|
||||
if (tokens.length > 1 && tokens[0].string.match(/^\s/)) {
|
||||
cm.execCommand('smartListUnindent');
|
||||
} else {
|
||||
cm.replaceRange('', { line: anchor.line, ch: 0 }, anchor);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Disable automatic indent for html/xml outside of codeblocks
|
||||
const state = cm.getTokenAt(anchor).state;
|
||||
const mode = cm.getModeAt(anchor);
|
||||
|
||||
// html/xml inside of a codeblock is fair game for auto-indent
|
||||
// for states who's mode is xml, having the localState property means they are within a code block
|
||||
if (mode.name !== 'xml' || !!state.outer.localState) {
|
||||
cm.execCommand('newlineAndIndentContinueMarkdownList');
|
||||
} else {
|
||||
cm.replaceSelection('\n');
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
// Helper functions to sync up scrolling
|
||||
export default function useScrollUtils(CodeMirror: any) {
|
||||
CodeMirror.defineExtension('getScrollPercent', function() {
|
||||
const info = this.getScrollInfo();
|
||||
return info.top / (info.height - info.clientHeight);
|
||||
});
|
||||
|
||||
CodeMirror.defineExtension('setScrollPercent', function(p: number) {
|
||||
const info = this.getScrollInfo();
|
||||
this.scrollTo(null, p * (info.height - info.clientHeight));
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
// Kept only for reference
|
||||
|
||||
import * as React from 'react';
|
||||
import { useEffect, useCallback, useRef, forwardRef, useImperativeHandle } from 'react';
|
||||
|
||||
export interface OnChangeEvent {
|
||||
changeId: number,
|
||||
content: any,
|
||||
}
|
||||
|
||||
interface PlainEditorProps {
|
||||
style: any,
|
||||
onChange(event: OnChangeEvent): void,
|
||||
onWillChange(event:any): void,
|
||||
markupToHtml: Function,
|
||||
disabled: boolean,
|
||||
}
|
||||
|
||||
const PlainEditor = (props:PlainEditorProps, ref:any) => {
|
||||
const editorRef = useRef<any>();
|
||||
|
||||
useImperativeHandle(ref, () => {
|
||||
return {
|
||||
content: () => '',
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!editorRef.current) return;
|
||||
editorRef.current.value = props.defaultEditorState.value;
|
||||
}, [props.defaultEditorState]);
|
||||
|
||||
const onChange = useCallback((event:any) => {
|
||||
props.onChange({ changeId: null, content: event.target.value });
|
||||
}, [props.onWillChange, props.onChange]);
|
||||
|
||||
return (
|
||||
<div style={props.style}>
|
||||
<textarea
|
||||
ref={editorRef}
|
||||
style={{ width: '100%', height: '100%' }}
|
||||
defaultValue={props.defaultEditorState.value}
|
||||
onChange={onChange}
|
||||
/>;
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default forwardRef(PlainEditor);
|
||||
|
||||
1133
packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/TinyMCE.tsx
Normal file
1133
packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/TinyMCE.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,5 @@
|
||||
tinymce.IconManager.add('Joplin', {
|
||||
icons: {
|
||||
'paperclip': '<svg xmlns:xlink="http://www.w3.org/1999/xlink" width="24" height="24"><defs><path d="M17.5 21.8c-1 0-2.1-.4-2.9-1.2L5 10.9a4.9 4.9 0 01-1.4-3.3c0-2.7 2.1-4.8 4.8-4.8 1.2 0 2.4.5 3.3 1.4l7.6 7.6.1.2c0 .3-.7 1-1 1l-.2-.1-7.6-7.6c-.6-.6-1.4-1-2.2-1A3.2 3.2 0 006 9.8l9.6 9.8c.5.4 1.2.7 1.8.7 1 0 1.9-.7 1.9-1.8 0-.7-.3-1.3-.8-1.8l-7.2-7.2c-.2-.2-.5-.3-.8-.3-.4 0-.8.3-.8.8 0 .3.1.5.3.7l5.1 5.1.1.3c0 .3-.7 1-1 1l-.2-.1L9 11.8c-.5-.5-.8-1.2-.8-1.9 0-1.4 1-2.4 2.4-2.4.7 0 1.4.3 1.9.8l7.2 7.2c.8.8 1.3 1.8 1.3 2.9 0 2-1.5 3.4-3.5 3.4z" id="a"/></defs><use xlink:href="#a"/><use xlink:href="#a" fill-opacity="0" stroke="#000" stroke-opacity="0"/></svg>',
|
||||
},
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,61 @@
|
||||
import { NoteBodyEditorProps } from '../../../utils/types';
|
||||
const { buildStyle } = require('@joplinapp/lib/theme');
|
||||
|
||||
export default function styles(props:NoteBodyEditorProps) {
|
||||
return buildStyle(['TinyMCE', props.style.width, props.style.height], props.themeId, (theme:any) => {
|
||||
const extraToolbarContainer = {
|
||||
backgroundColor: theme.backgroundColor3,
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
position: 'absolute',
|
||||
height: theme.toolbarHeight,
|
||||
zIndex: 2,
|
||||
top: 0,
|
||||
padding: theme.toolbarPadding,
|
||||
};
|
||||
|
||||
return {
|
||||
disabledOverlay: {
|
||||
zIndex: 10,
|
||||
position: 'absolute',
|
||||
backgroundColor: 'white',
|
||||
opacity: 0.7,
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
padding: 20,
|
||||
paddingTop: 50,
|
||||
textAlign: 'center',
|
||||
width: '100%',
|
||||
},
|
||||
rootStyle: {
|
||||
position: 'relative',
|
||||
width: props.style.width,
|
||||
height: props.style.height,
|
||||
},
|
||||
leftExtraToolbarContainer: {
|
||||
...extraToolbarContainer,
|
||||
width: 80,
|
||||
left: 0,
|
||||
},
|
||||
rightExtraToolbarContainer: {
|
||||
...extraToolbarContainer,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'flex-end',
|
||||
width: 70,
|
||||
right: 0,
|
||||
paddingRight: theme.mainPadding,
|
||||
},
|
||||
extraToolbarButton: {
|
||||
display: 'flex',
|
||||
border: 'none',
|
||||
background: 'none',
|
||||
},
|
||||
extraToolbarButtonIcon: {
|
||||
fontSize: theme.toolbarIconSize,
|
||||
color: theme.color3,
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
module.exports = [
|
||||
"ar",
|
||||
"bg_BG",
|
||||
"ca",
|
||||
"cs",
|
||||
"cy",
|
||||
"da",
|
||||
"de",
|
||||
"el",
|
||||
"eo",
|
||||
"es_ES",
|
||||
"es_MX",
|
||||
"es",
|
||||
"eu",
|
||||
"fa_IR",
|
||||
"fa",
|
||||
"fi",
|
||||
"fr_FR",
|
||||
"gl",
|
||||
"he_IL",
|
||||
"hr",
|
||||
"hu_HU",
|
||||
"id",
|
||||
"it_IT",
|
||||
"it",
|
||||
"ja",
|
||||
"kk",
|
||||
"ko_KR",
|
||||
"lt",
|
||||
"nb_NO",
|
||||
"nl",
|
||||
"pl",
|
||||
"pt_BR",
|
||||
"pt_PT",
|
||||
"ro_RO",
|
||||
"ro",
|
||||
"ru",
|
||||
"sk",
|
||||
"sl_SI",
|
||||
"sl",
|
||||
"sv_SE",
|
||||
"ta_IN",
|
||||
"ta",
|
||||
"th_TH",
|
||||
"tr_TR",
|
||||
"tr",
|
||||
"uk",
|
||||
"vi",
|
||||
"zh_CN",
|
||||
"zh_TW"
|
||||
]
|
||||
@@ -0,0 +1,91 @@
|
||||
import SpellCheckerService from '@joplinapp/lib/services/spellChecker/SpellCheckerService';
|
||||
import bridge from '../../../../../services/bridge';
|
||||
import { menuItems, ContextMenuOptions, ContextMenuItemType } from '../../../utils/contextMenu';
|
||||
const Resource = require('@joplinapp/lib/models/Resource');
|
||||
|
||||
// x and y are the absolute coordinates, as returned by the context-menu event
|
||||
// handler on the webContent. This function will return null if the point is
|
||||
// not within the TinyMCE editor.
|
||||
function contextMenuElement(editor:any, x:number, y:number) {
|
||||
const iframes = document.getElementsByClassName('tox-edit-area__iframe');
|
||||
if (!iframes.length) return null;
|
||||
|
||||
const iframeRect = iframes[0].getBoundingClientRect();
|
||||
|
||||
if (iframeRect.x < x && iframeRect.y < y && iframeRect.right > x && iframeRect.bottom > y) {
|
||||
const relativeX = x - iframeRect.x;
|
||||
const relativeY = y - iframeRect.y;
|
||||
|
||||
return editor.getDoc().elementFromPoint(relativeX, relativeY);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
interface ContextMenuActionOptions {
|
||||
current: ContextMenuOptions,
|
||||
}
|
||||
|
||||
const contextMenuActionOptions:ContextMenuActionOptions = { current: null };
|
||||
|
||||
export default function(editor:any) {
|
||||
const contextMenuItems = menuItems();
|
||||
|
||||
bridge().window().webContents.on('context-menu', (_event:any, params:any) => {
|
||||
const element = contextMenuElement(editor, params.x, params.y);
|
||||
if (!element) return;
|
||||
|
||||
const Menu = bridge().Menu;
|
||||
const MenuItem = bridge().MenuItem;
|
||||
|
||||
let itemType:ContextMenuItemType = ContextMenuItemType.None;
|
||||
let resourceId = '';
|
||||
let linkToCopy = null;
|
||||
|
||||
if (element.nodeName === 'IMG') {
|
||||
itemType = ContextMenuItemType.Image;
|
||||
resourceId = Resource.pathToId(element.src);
|
||||
} else if (element.nodeName === 'A') {
|
||||
resourceId = Resource.pathToId(element.href);
|
||||
itemType = resourceId ? ContextMenuItemType.Resource : ContextMenuItemType.Link;
|
||||
linkToCopy = element.getAttribute('href') || '';
|
||||
} else {
|
||||
itemType = ContextMenuItemType.Text;
|
||||
}
|
||||
|
||||
contextMenuActionOptions.current = {
|
||||
itemType,
|
||||
resourceId,
|
||||
linkToCopy,
|
||||
textToCopy: null,
|
||||
htmlToCopy: editor.selection ? editor.selection.getContent() : '',
|
||||
insertContent: (content:string) => {
|
||||
editor.insertContent(content);
|
||||
},
|
||||
isReadOnly: false,
|
||||
};
|
||||
|
||||
const menu = new Menu();
|
||||
|
||||
for (const itemName in contextMenuItems) {
|
||||
const item = contextMenuItems[itemName];
|
||||
|
||||
if (!item.isActive(itemType, contextMenuActionOptions.current)) continue;
|
||||
|
||||
menu.append(new MenuItem({
|
||||
label: item.label,
|
||||
click: () => {
|
||||
item.onAction(contextMenuActionOptions.current);
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
const spellCheckerMenuItems = SpellCheckerService.instance().contextMenuItems(params.misspelledWord, params.dictionarySuggestions);
|
||||
|
||||
for (const item of spellCheckerMenuItems) {
|
||||
menu.append(item);
|
||||
}
|
||||
|
||||
menu.popup();
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { useEffect, useCallback, useRef } from 'react';
|
||||
import shim from '@joplinapp/lib/shim';
|
||||
|
||||
interface HookDependencies {
|
||||
editor:any,
|
||||
onScroll: Function,
|
||||
}
|
||||
|
||||
export default function useScroll(dependencies:HookDependencies) {
|
||||
const { editor, onScroll } = dependencies;
|
||||
const scrollTimeoutId_ = useRef(null);
|
||||
|
||||
const maxScrollTop = useCallback(() => {
|
||||
if (!editor) return 0;
|
||||
|
||||
const doc = editor.getDoc();
|
||||
const win = editor.getWin();
|
||||
if (!doc || !win) return 0;
|
||||
const firstChild = doc.firstElementChild;
|
||||
if (!firstChild) return 0;
|
||||
|
||||
const winHeight = win.innerHeight;
|
||||
const contentHeight = firstChild.scrollHeight;
|
||||
return contentHeight < winHeight ? 0 : contentHeight - winHeight;
|
||||
}, [editor]);
|
||||
|
||||
const scrollTop = useCallback(() => {
|
||||
if (!editor) return 0;
|
||||
const win = editor.getWin();
|
||||
if (!win) return 0;
|
||||
return win.scrollY;
|
||||
}, [editor]);
|
||||
|
||||
const scrollPercent = useCallback(() => {
|
||||
const m = maxScrollTop();
|
||||
const t = scrollTop();
|
||||
return m <= 0 ? 0 : t / m;
|
||||
}, [maxScrollTop, scrollTop]);
|
||||
|
||||
const scrollToPercent = useCallback((percent:number) => {
|
||||
if (!editor) return;
|
||||
editor.getWin().scrollTo(0, maxScrollTop() * percent);
|
||||
}, [editor, maxScrollTop]);
|
||||
|
||||
const scheduleOnScroll = useCallback((event: any) => {
|
||||
if (scrollTimeoutId_.current) {
|
||||
shim.clearTimeout(scrollTimeoutId_.current);
|
||||
scrollTimeoutId_.current = null;
|
||||
}
|
||||
|
||||
scrollTimeoutId_.current = shim.setTimeout(() => {
|
||||
scrollTimeoutId_.current = null;
|
||||
onScroll(event);
|
||||
}, 10);
|
||||
}, [onScroll]);
|
||||
|
||||
const onEditorScroll = useCallback(() => {
|
||||
scheduleOnScroll({ percent: scrollPercent() });
|
||||
}, [scheduleOnScroll, scrollPercent]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!editor) return () => {};
|
||||
|
||||
editor.getDoc().addEventListener('scroll', onEditorScroll);
|
||||
return () => {
|
||||
editor.getDoc().removeEventListener('scroll', onEditorScroll);
|
||||
};
|
||||
}, [editor, onEditorScroll]);
|
||||
|
||||
return { scrollToPercent };
|
||||
}
|
||||
582
packages/app-desktop/gui/NoteEditor/NoteEditor.tsx
Normal file
582
packages/app-desktop/gui/NoteEditor/NoteEditor.tsx
Normal 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);
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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',
|
||||
};
|
||||
};
|
||||
@@ -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',
|
||||
};
|
||||
};
|
||||
@@ -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',
|
||||
};
|
||||
};
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
};
|
||||
67
packages/app-desktop/gui/NoteEditor/styles/index.ts
Normal file
67
packages/app-desktop/gui/NoteEditor/styles/index.ts
Normal 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,
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
150
packages/app-desktop/gui/NoteEditor/utils/contextMenu.ts
Normal file
150
packages/app-desktop/gui/NoteEditor/utils/contextMenu.ts
Normal 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;
|
||||
}
|
||||
31
packages/app-desktop/gui/NoteEditor/utils/index.ts
Normal file
31
packages/app-desktop/gui/NoteEditor/utils/index.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
127
packages/app-desktop/gui/NoteEditor/utils/resourceHandling.ts
Normal file
127
packages/app-desktop/gui/NoteEditor/utils/resourceHandling.ts
Normal 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;
|
||||
}
|
||||
158
packages/app-desktop/gui/NoteEditor/utils/types.ts
Normal file
158
packages/app-desktop/gui/NoteEditor/utils/types.ts
Normal 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;
|
||||
}
|
||||
53
packages/app-desktop/gui/NoteEditor/utils/useDropHandler.ts
Normal file
53
packages/app-desktop/gui/NoteEditor/utils/useDropHandler.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
}
|
||||
29
packages/app-desktop/gui/NoteEditor/utils/useFolder.ts
Normal file
29
packages/app-desktop/gui/NoteEditor/utils/useFolder.ts
Normal 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;
|
||||
}
|
||||
229
packages/app-desktop/gui/NoteEditor/utils/useFormNote.ts
Normal file
229
packages/app-desktop/gui/NoteEditor/utils/useFormNote.ts
Normal 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 };
|
||||
}
|
||||
63
packages/app-desktop/gui/NoteEditor/utils/useMarkupToHtml.ts
Normal file
63
packages/app-desktop/gui/NoteEditor/utils/useMarkupToHtml.ts
Normal 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]);
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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');
|
||||
};
|
||||
}, []);
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
Reference in New Issue
Block a user