From b6d4fd16c9b2998bbf51af89c237576cf77b0696 Mon Sep 17 00:00:00 2001 From: Devon Zuegel Date: Wed, 25 Mar 2020 03:50:45 -0700 Subject: [PATCH] Mobile: Add toolbar, list continuation and Markdown preview to editor (#2224) * The basic editor is working! No list continuation still though * List continuation is working! Now to delete when entering again and not typing on line + handle ordered lists * Supports checkboxes + attempted at setting font * Editor font works now; now need to fix the delete (look at past state) * Fix deletion problem * Add ordered list handler * Add comments * Extract insertListLine * End lists on enter for empty bullets * Add MarkdownView (renders badly though) * Save edited text from MarkdownEditor * Cleanup * Refactor react-native-markdown-editor/ * Rename react-native-markdown-editor/ => MarkdownEditor/ * Cleanup * Fix preview styles; still need to fix checkbox problem * Fix keyboard padding * Change name back to #body_changeText * Incorporate PR feedback from @laurent22 * wip: Move MarkdownEditor/ from ReactNativeClient/lib/ to ReactNativeClient/ * Move MarkdownEditor/ from ReactNativeClient/lib/ to ReactNativeClient/ * Remove log statement * Focus TextInput in MarkdownEditor from grandparent * Make eslint happy * Extract textInputRefName to shared variable * Remove accidental #setState * Cleanup * Cleanup * Run linter * Cleanup * Update button order * Improve styles for config descriptions * Allow descriptions to be added to BOOL type Setting configs * Add editorBeta Setting * Move FailSafe details to description text * Update descriptionText styles * Put the editor under the beta flag toggle * Incorporate PR feedback from @laurent22 * Refactor Markdown editor focusing * Cleanup * Reorder MarkdownEditor formats * Make applyListFormat behavior more intuitive * Add comment * Show MarkdownEditor with preview by default * Show preview by default, then hide on typing * Fix MarkdownEditor selection bug * Cleanup * Update Markdown button styles * Make Markdown button colors theme-conscious * Fix merge conflict resolution mistake * Fix broken import * Delete package-lock.json * Reset package-lock.json Co-authored-by: Laurent Cozic --- ReactNativeClient/MarkdownEditor/Formats.js | 23 +++ .../MarkdownEditor/MarkdownEditor.js | 162 ++++++++++++++++++ .../MarkdownEditor/applyListFormat.js | 43 +++++ .../MarkdownEditor/applyWebLinkFormat.js | 38 ++++ .../MarkdownEditor/applyWrapFormat.js | 27 +++ .../MarkdownEditor/applyWrapFormatNewLines.js | 55 ++++++ ReactNativeClient/MarkdownEditor/index.js | 13 ++ .../MarkdownEditor/renderButtons.js | 32 ++++ .../MarkdownEditor/static/visibility.png | Bin 0 -> 309 bytes ReactNativeClient/MarkdownEditor/utils.js | 9 + .../MarkdownEditor/webLinkValidator.js | 104 +++++++++++ .../lib/components/note-body-viewer.js | 2 + .../lib/components/screens/config.js | 27 +-- .../lib/components/screens/note.js | 98 +++++++++-- .../lib/joplin-renderer/MdToHtml.js | 4 +- .../lib/joplin-renderer/noteStyle.js | 3 +- ReactNativeClient/lib/models/Setting.js | 21 ++- 17 files changed, 636 insertions(+), 25 deletions(-) create mode 100644 ReactNativeClient/MarkdownEditor/Formats.js create mode 100644 ReactNativeClient/MarkdownEditor/MarkdownEditor.js create mode 100644 ReactNativeClient/MarkdownEditor/applyListFormat.js create mode 100644 ReactNativeClient/MarkdownEditor/applyWebLinkFormat.js create mode 100644 ReactNativeClient/MarkdownEditor/applyWrapFormat.js create mode 100644 ReactNativeClient/MarkdownEditor/applyWrapFormatNewLines.js create mode 100644 ReactNativeClient/MarkdownEditor/index.js create mode 100644 ReactNativeClient/MarkdownEditor/renderButtons.js create mode 100644 ReactNativeClient/MarkdownEditor/static/visibility.png create mode 100644 ReactNativeClient/MarkdownEditor/utils.js create mode 100644 ReactNativeClient/MarkdownEditor/webLinkValidator.js diff --git a/ReactNativeClient/MarkdownEditor/Formats.js b/ReactNativeClient/MarkdownEditor/Formats.js new file mode 100644 index 000000000..981fa6a2c --- /dev/null +++ b/ReactNativeClient/MarkdownEditor/Formats.js @@ -0,0 +1,23 @@ +import applyWrapFormat from './applyWrapFormat'; +import applyWrapFormatNewLines from './applyWrapFormatNewLines'; +import applyListFormat from './applyListFormat'; +import applyWebLinkFormat from './applyWebLinkFormat'; + +export default [ + { key: 'B', title: 'B', wrapper: '**', onPress: applyWrapFormat, style: { fontWeight: 'bold' } }, + { key: 'I', title: 'I', wrapper: '*', onPress: applyWrapFormat, style: { fontStyle: 'italic' } }, + { key: 'Link', title: 'Link', onPress: applyWebLinkFormat }, + { key: 'List', title: 'List', prefix: '-', onPress: applyListFormat }, + { + key: 'S', + title: 'S', + wrapper: '~~', + onPress: applyWrapFormat, + style: { textDecorationLine: 'line-through' }, + }, + { key: '', title: '', wrapper: '`', onPress: applyWrapFormat }, + { key: 'Pre', title: 'Pre', wrapper: '```', onPress: applyWrapFormatNewLines }, + { key: 'H1', title: 'H1', prefix: '#', onPress: applyListFormat }, + { key: 'H2', title: 'H2', prefix: '##', onPress: applyListFormat }, + { key: 'H3', title: 'H3', prefix: '###', onPress: applyListFormat }, +]; diff --git a/ReactNativeClient/MarkdownEditor/MarkdownEditor.js b/ReactNativeClient/MarkdownEditor/MarkdownEditor.js new file mode 100644 index 000000000..f6f05906b --- /dev/null +++ b/ReactNativeClient/MarkdownEditor/MarkdownEditor.js @@ -0,0 +1,162 @@ +/** + * Inspired by https://github.com/kunall17/MarkdownEditor + */ + +import React from 'react'; +import { + View, + StyleSheet, + TextInput, + Platform, + KeyboardAvoidingView, + TouchableOpacity, + Image, +} from 'react-native'; +import { renderFormatButtons } from './renderButtons'; +import { NoteBodyViewer } from 'lib/components/note-body-viewer.js'; + +const styles = StyleSheet.create({ + buttonContainer: { + flex: 0, + flexDirection: 'row', + }, + screen: { // Wrapper around the editor and the preview + flex: 1, + flexDirection: 'column', + alignItems: 'stretch', + }, +}); + +const MarkdownPreviewButton = (props) => + + + ; + +export default class MarkdownEditor extends React.Component { + constructor(props) { + super(props); + this.state = { + text: props.value, + selection: { start: 0, end: 0 }, + // Show preview by default + showPreview: props.showPreview ? props.showPreview : true, + }; + this.textAreaRef = React.createRef(); // For focusing the textarea + } + textInput: TextInput; + + changeText = (selection: {start: number, end: number}) => (input: string) => { + let result = input; + const cursor = selection.start; + const isOnNewline = '\n' === input.slice(cursor - 1, cursor); + const isDeletion = input.length < this.state.text.length; + if (isOnNewline && !isDeletion) { + const prevLines = input.slice(0, cursor - 1).split('\n'); + const prevLine = prevLines[prevLines.length - 1]; + + const insertListLine = (bullet) => ([ + prevLines.join('\n'), // Previous text + `\n${bullet} `, // Current line with new bullet point + input.slice(cursor, input.length), // Following text + ].join('')); + + const insertedEndListLine = [ + // Previous text (all but last bullet line, which we remove) + prevLines.slice(0, prevLines.length - 1).join('\n') , + '\n\n', // Two newlines to get out of the list + input.slice(cursor, input.length), // Following text + ].join(''); + + // Add new ordered list line item + if (prevLine.startsWith('- ') && !prevLine.startsWith('- [ ')) { + // If the bullet on the previous line isn't empty, add a new bullet. + if (prevLine.trim() !== '-') { + result = insertListLine('-'); + } else { + result = insertedEndListLine; + } + } + + // Add new checklist line item + if ((prevLine.startsWith('- [ ] ') || prevLine.startsWith('- [x] '))) { + // If the bullet on the previous line isn't empty, add a new bullet. + if (prevLine.trim() !== '- [ ]' && prevLine.trim() !== '- [x]') { + result = insertListLine('- [ ]'); + } else { + result = insertedEndListLine; + } + } + + // Add new ordered list item + if (/^\d+\./.test(prevLine)) { + // If the bullet on the previous line isn't empty, add a new bullet. + const digit = Number(prevLine.match(/^\d+/)[0]); + if (prevLine.trim() !== `${digit}.`) { + result = insertListLine(`${digit + 1}.`); + } else { + result = insertedEndListLine; + } + } + } + // Hide Markdown preview on text change + this.setState({ text: result, showPreview: false }); + this.props.saveText(result); + if (this.props.onMarkdownChange) this.props.onMarkdownChange(input); + }; + + onSelectionChange = event => { + this.setState({ selection: event.nativeEvent.selection }); + }; + + focus = () => this.textAreaRef.current.focus() + + convertMarkdown = () => this.setState({ showPreview: !this.state.showPreview }) + + render() { + const WrapperView = Platform.OS === 'ios' ? KeyboardAvoidingView : View; + const { Formats, markdownButton } = this.props; + const { text, selection, showPreview } = this.state; + return ( + + + {showPreview && } + + + {renderFormatButtons( + { + color: this.props.markdownButtonsColor, + getState: () => this.state, + setState: (state, callback) => { + // Hide Markdown preview on text change + this.setState({ showPreview: false }); + this.setState(state, callback); + }, + }, + Formats, + markdownButton, + )} + + + ); + } +} diff --git a/ReactNativeClient/MarkdownEditor/applyListFormat.js b/ReactNativeClient/MarkdownEditor/applyListFormat.js new file mode 100644 index 000000000..f51a56c8c --- /dev/null +++ b/ReactNativeClient/MarkdownEditor/applyListFormat.js @@ -0,0 +1,43 @@ +import { replaceBetween } from './utils'; + +export default ({ getState, item, setState }) => { + let { text } = getState(); + const { selection } = getState(); + text = text || ''; + let newText; + let newSelection; + + // Ignore multi-character selections. + // NOTE: I was on the fence about whether more appropriate behavior would be + // to add the list prefix (e.g. '-', '1.', '#', '##', '###') at the + // beginning of the line where the selection begins, but for now I think + // it's more natural to just ignore it in this case. If after using this + // editor for a while it turns out the other way is more natural, that's + // fine by me! + if (selection.start !== selection.end) { + return; + } + + const spaceForPrefix = item.prefix.length + 1; + const isNewLine = text.substring(selection.start - 1, selection.start) === '\n'; + if (isNewLine) { // We're at the start of a line + newText = replaceBetween(text, selection, `${item.prefix} `); + newSelection = { start: selection.start + spaceForPrefix, end: selection.start + spaceForPrefix }; + } else { // We're in the middle of a line + // NOTE: It may be more natural for the prefix (e.g. '-', '1.', '#', '##') + // to be prepended at the beginning of the line where the selection is, + // rather than creating a new line (which is the behavior implemented here). + // If the other way is more natural, that's fine by me! + newText = replaceBetween(text, selection, `\n${item.prefix} `); + newSelection = { + start: selection.start + spaceForPrefix + 1, + end: selection.start + spaceForPrefix + 1, + }; + } + + setState({ text: newText }, () => { + setTimeout(() => { + setState({ selection: newSelection }); + }, 300); + }); +}; diff --git a/ReactNativeClient/MarkdownEditor/applyWebLinkFormat.js b/ReactNativeClient/MarkdownEditor/applyWebLinkFormat.js new file mode 100644 index 000000000..ac174f912 --- /dev/null +++ b/ReactNativeClient/MarkdownEditor/applyWebLinkFormat.js @@ -0,0 +1,38 @@ +import { isStringWebLink, replaceBetween } from './utils'; + +export const writeUrlTextHere = 'https://example.com'; +export const writeTextHereString = 'Write some text here'; + +// eslint-disable-next-line no-unused-vars +export default ({ getState, item, setState }) => { + const { selection, text } = getState(); + let newText; + let newSelection; + const selectedText = text.substring(selection.start, selection.end); + if (selection.start !== selection.end) { + if (isStringWebLink(selectedText)) { + newText = replaceBetween(text, selection, `[${writeTextHereString}](${selectedText})`); + newSelection = { + start: selection.start + 1, + end: selection.start + 1 + writeTextHereString.length, + }; + } else { + newText = replaceBetween(text, selection, `[${selectedText}](${writeUrlTextHere})`); + newSelection = { + start: selection.end + 3, + end: selection.end + 3 + writeUrlTextHere.length, + }; + } + } else { + newText = replaceBetween(text, selection, `[${writeTextHereString}](${writeUrlTextHere})`); + newSelection = { + start: selection.start + 1, + end: selection.start + 1 + writeTextHereString.length, + }; + } + setState({ text: newText }, () => { + setTimeout(() => { + setState({ selection: newSelection }); + }, 25); + }); +}; diff --git a/ReactNativeClient/MarkdownEditor/applyWrapFormat.js b/ReactNativeClient/MarkdownEditor/applyWrapFormat.js new file mode 100644 index 000000000..ea73b7abc --- /dev/null +++ b/ReactNativeClient/MarkdownEditor/applyWrapFormat.js @@ -0,0 +1,27 @@ +import { replaceBetween } from './utils'; + +export default ({ getState, item, setState }) => { + const { text, selection } = getState(); + const newText = replaceBetween( + text, + selection, + item.wrapper.concat(text.substring(selection.start, selection.end), item.wrapper), + ); + let newPosition; + if (selection.start === selection.end) { + newPosition = selection.end + item.wrapper.length; + } else { + newPosition = selection.end + item.wrapper.length * 2; + } + const extra = { + selection: { + start: newPosition, + end: newPosition, + }, + }; + setState({ text: newText }, () => { + setTimeout(() => { + setState({ ...extra }); + }, 25); + }); +}; diff --git a/ReactNativeClient/MarkdownEditor/applyWrapFormatNewLines.js b/ReactNativeClient/MarkdownEditor/applyWrapFormatNewLines.js new file mode 100644 index 000000000..b6608247a --- /dev/null +++ b/ReactNativeClient/MarkdownEditor/applyWrapFormatNewLines.js @@ -0,0 +1,55 @@ +import { replaceBetween } from './utils'; + +export default ({ getState, item, setState }) => { + const { text, selection } = getState(); + let newText = replaceBetween( + text, + selection, + `\n${item.wrapper.concat( + '\n', + text.substring(selection.start, selection.end), + '\n', + item.wrapper, + '\n', + )}`, + ); + let newPosition; + if (selection.start === selection.end) { + newPosition = selection.end + item.wrapper.length + 2; // +2 For two new lines + newText = replaceBetween( + text, + selection, + `\n${item.wrapper.concat( + '\n', + text.substring(selection.start, selection.end), + '\n', + item.wrapper, + '\n', + )}`, + ); + } else { + newPosition = selection.end + item.wrapper.length * 2 + 3; // +3 For three new lines + newText = replaceBetween( + text, + selection, + `${item.wrapper.concat( + '\n', + text.substring(selection.start, selection.end), + '\n', + item.wrapper, + '\n', + )}`, + ); + } + const extra = { + selection: { + start: newPosition, + end: newPosition, + }, + }; + setState({ text: newText }, () => { + setTimeout(() => { + setState({ ...extra }); + }, 25); + }); +}; diff --git a/ReactNativeClient/MarkdownEditor/index.js b/ReactNativeClient/MarkdownEditor/index.js new file mode 100644 index 000000000..2687c75de --- /dev/null +++ b/ReactNativeClient/MarkdownEditor/index.js @@ -0,0 +1,13 @@ +import applyWrapFormat from './applyWrapFormat'; +import applyWrapFormatNewLines from './applyWrapFormatNewLines'; +import applyListFormat from './applyListFormat'; +import applyWebLinkFormat from './applyWebLinkFormat'; +import MarkdownEditor from './MarkdownEditor'; + +module.exports = { + MarkdownEditor, + applyWrapFormat, + applyWrapFormatNewLines, + applyListFormat, + applyWebLinkFormat, +}; diff --git a/ReactNativeClient/MarkdownEditor/renderButtons.js b/ReactNativeClient/MarkdownEditor/renderButtons.js new file mode 100644 index 000000000..333698d14 --- /dev/null +++ b/ReactNativeClient/MarkdownEditor/renderButtons.js @@ -0,0 +1,32 @@ +import React from 'react'; +import { FlatList, TouchableOpacity, Text } from 'react-native'; + +import Formats from './Formats'; + +const defaultStyles = { padding: 8, fontSize: 16 }; + +const defaultMarkdownButton = ({ item, getState, setState, color }) => { + return ( + item.onPress({ getState, setState, item })}> + + {item.title} + + + ); +}; + +export const renderFormatButtons = ({ getState, setState, color }, formats, markdownButton) => { + const list = ( + + markdownButton + ? markdownButton({ item, getState, setState }) + : defaultMarkdownButton({ item, getState, setState, color })} + horizontal + /> + ); + return list; +}; diff --git a/ReactNativeClient/MarkdownEditor/static/visibility.png b/ReactNativeClient/MarkdownEditor/static/visibility.png new file mode 100644 index 0000000000000000000000000000000000000000..58597e91b97dda5cef43b40691793da044e2a662 GIT binary patch literal 309 zcmV-50m}Y~P)4oRAdjLA!j=$GOA!1wD58Z(%>yB7{_)W_5fllEprBcZw1WnQAhZ=d4l}Sb zdUys07vOb`?)!e;*Hu#Jd+mMYD}4nzW%|BR1L9+G{ZQp&j7wfwL;U}!QM=PpRs)rbnK#@%Djz!t1Z#BiD_-5T{%v_ z|MEZvgh$wK1Ej+00000NkvXX Hu0mjfuP=*W literal 0 HcmV?d00001 diff --git a/ReactNativeClient/MarkdownEditor/utils.js b/ReactNativeClient/MarkdownEditor/utils.js new file mode 100644 index 000000000..d61e0f9de --- /dev/null +++ b/ReactNativeClient/MarkdownEditor/utils.js @@ -0,0 +1,9 @@ +import regexValidator from './webLinkValidator'; + +export const replaceBetween = (text: string, selection: Object, what: string) => + text.substring(0, selection.start) + what + text.substring(selection.end); + +export const isStringWebLink = (text: string): boolean => { + const pattern = regexValidator; + return pattern.test(text); +}; diff --git a/ReactNativeClient/MarkdownEditor/webLinkValidator.js b/ReactNativeClient/MarkdownEditor/webLinkValidator.js new file mode 100644 index 000000000..230e35cf9 --- /dev/null +++ b/ReactNativeClient/MarkdownEditor/webLinkValidator.js @@ -0,0 +1,104 @@ +// prettier-ignore +/* eslint-disable */ + +// +// Regular Expression for URL validation +// +// Author: Diego Perini +// Updated: 2010/12/05 +// License: MIT +// +// Copyright (c) 2010-2013 Diego Perini (http://www.iport.it) +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// +// the regular expression composed & commented +// could be easily tweaked for RFC compliance, +// it was expressly modified to fit & satisfy +// these test for an URL shortener: +// +// http://mathiasbynens.be/demo/url-regex +// +// Notes on possible differences from a standard/generic validation: +// +// - utf-8 char class take in consideration the full Unicode range +// - TLDs have been made mandatory so single names like "localhost" fails +// - protocols have been restricted to ftp, http and https only as requested +// +// Changes: +// +// - IP address dotted notation validation, range: 1.0.0.0 - 223.255.255.255 +// first and last IP address of each class is considered invalid +// (since they are broadcast/network addresses) +// +// - Added exclusion of private, reserved and/or local networks ranges +// +// - Made starting path slash optional (http://example.com?foo=bar) +// +// - Allow a dot (.) at the end of hostnames (http://example.com.) +// +// Compressed one-line versions: +// +// Javascript version +// +// /^(?:(?:https?|ftp):\/\/)(?:\S+(?::\S*)?@)?(?:(?!(?:10|127)(?:\.\d{1,3}){3})(?!(?:169\.254|192\.168)(?:\.\d{1,3}){2})(?!172\.(?:1[6-9]|2\d|3[0-1])(?:\.\d{1,3}){2})(?:[1-9]\d?|1\d\d|2[01]\d|22[0-3])(?:\.(?:1?\d{1,2}|2[0-4]\d|25[0-5])){2}(?:\.(?:[1-9]\d?|1\d\d|2[0-4]\d|25[0-4]))|(?:(?:[a-z\u00a1-\uffff0-9]-*)*[a-z\u00a1-\uffff0-9]+)(?:\.(?:[a-z\u00a1-\uffff0-9]-*)*[a-z\u00a1-\uffff0-9]+)*(?:\.(?:[a-z\u00a1-\uffff]{2,}))\.?)(?::\d{2,5})?(?:[/?#]\S*)?$/i +// +// PHP version +// +// _^(?:(?:https?|ftp)://)(?:\S+(?::\S*)?@)?(?:(?!(?:10|127)(?:\.\d{1,3}){3})(?!(?:169\.254|192\.168)(?:\.\d{1,3}){2})(?!172\.(?:1[6-9]|2\d|3[0-1])(?:\.\d{1,3}){2})(?:[1-9]\d?|1\d\d|2[01]\d|22[0-3])(?:\.(?:1?\d{1,2}|2[0-4]\d|25[0-5])){2}(?:\.(?:[1-9]\d?|1\d\d|2[0-4]\d|25[0-4]))|(?:(?:[a-z\x{00a1}-\x{ffff}0-9]-*)*[a-z\x{00a1}-\x{ffff}0-9]+)(?:\.(?:[a-z\x{00a1}-\x{ffff}0-9]-*)*[a-z\x{00a1}-\x{ffff}0-9]+)*(?:\.(?:[a-z\x{00a1}-\x{ffff}]{2,}))\.?)(?::\d{2,5})?(?:[/?#]\S*)?$_iuS +// +export default new RegExp( + "^" + + // protocol identifier + "(?:(?:https?|ftp)://)" + + // user:pass authentication + "(?:\\S+(?::\\S*)?@)?" + + "(?:" + + // IP address exclusion + // private & local networks + "(?!(?:10|127)(?:\\.\\d{1,3}){3})" + + "(?!(?:169\\.254|192\\.168)(?:\\.\\d{1,3}){2})" + + "(?!172\\.(?:1[6-9]|2\\d|3[0-1])(?:\\.\\d{1,3}){2})" + + // IP address dotted notation octets + // excludes loopback network 0.0.0.0 + // excludes reserved space >= 224.0.0.0 + // excludes network & broacast addresses + // (first & last IP address of each class) + "(?:[1-9]\\d?|1\\d\\d|2[01]\\d|22[0-3])" + + "(?:\\.(?:1?\\d{1,2}|2[0-4]\\d|25[0-5])){2}" + + "(?:\\.(?:[1-9]\\d?|1\\d\\d|2[0-4]\\d|25[0-4]))" + + "|" + + // host name + "(?:(?:[a-z\\u00a1-\\uffff0-9]-*)*[a-z\\u00a1-\\uffff0-9]+)" + + // domain name + "(?:\\.(?:[a-z\\u00a1-\\uffff0-9]-*)*[a-z\\u00a1-\\uffff0-9]+)*" + + // TLD identifier + "(?:\\.(?:[a-z\\u00a1-\\uffff]{2,}))" + + // TLD may end with dot + "\\.?" + + ")" + + // port number + "(?::\\d{2,5})?" + + // resource path + "(?:[/?#]\\S*)?" + + "$", "i" +); diff --git a/ReactNativeClient/lib/components/note-body-viewer.js b/ReactNativeClient/lib/components/note-body-viewer.js index afd051ac8..5e744272e 100644 --- a/ReactNativeClient/lib/components/note-body-viewer.js +++ b/ReactNativeClient/lib/components/note-body-viewer.js @@ -55,6 +55,8 @@ class NoteBodyViewer extends Component { this.forceUpdate(); }, 100); }, + paddingTop: '.8em', // Extra top padding on the rendered MD so it doesn't touch the border + paddingBottom: this.props.paddingBottom || '0', highlightedKeywords: this.props.highlightedKeywords, resources: this.props.noteResources, // await shared.attachedResources(bodyToRender), codeTheme: theme.codeThemeCss, diff --git a/ReactNativeClient/lib/components/screens/config.js b/ReactNativeClient/lib/components/screens/config.js index cc2206545..12f3cd103 100644 --- a/ReactNativeClient/lib/components/screens/config.js +++ b/ReactNativeClient/lib/components/screens/config.js @@ -190,8 +190,8 @@ class ConfigScreenComponent extends BaseScreenComponent { paddingRight: 5, }, descriptionText: { - color: theme.color, - fontSize: theme.fontSize, + color: theme.colorFaded, + fontSize: theme.fontSizeSmaller, flex: 1, }, sliderUnits: { @@ -200,8 +200,8 @@ class ConfigScreenComponent extends BaseScreenComponent { marginRight: 10, }, settingDescriptionText: { - color: theme.color, - fontSize: theme.fontSize, + color: theme.colorFaded, + fontSize: theme.fontSizeSmaller, flex: 1, paddingLeft: theme.marginLeft, paddingRight: theme.marginRight, @@ -341,6 +341,9 @@ class ConfigScreenComponent extends BaseScreenComponent { const md = Setting.settingMetadata(key); const settingDescription = md.description ? md.description() : ''; + const descriptionComp = !settingDescription ? null : {settingDescription}; + const containerStyle = !settingDescription ? this.styles().settingContainer : this.styles().settingContainerNoBottomBorder; + if (md.isEnum) { value = value.toString(); @@ -351,9 +354,6 @@ class ConfigScreenComponent extends BaseScreenComponent { items.push({ label: settingOptions[k], value: k.toString() }); } - const descriptionComp = !settingDescription ? null : {settingDescription}; - const containerStyle = !settingDescription ? this.styles().settingContainer : this.styles().settingContainerNoBottomBorder; - return ( @@ -386,11 +386,14 @@ class ConfigScreenComponent extends BaseScreenComponent { ); } else if (md.type == Setting.TYPE_BOOL) { return ( - - - {md.label()} - - updateSettingValue(key, value)} /> + + + + {md.label()} + + updateSettingValue(key, value)} /> + + {descriptionComp} ); } else if (md.type == Setting.TYPE_INT) { diff --git a/ReactNativeClient/lib/components/screens/note.js b/ReactNativeClient/lib/components/screens/note.js index 7e9c58330..16060c9b7 100644 --- a/ReactNativeClient/lib/components/screens/note.js +++ b/ReactNativeClient/lib/components/screens/note.js @@ -1,7 +1,8 @@ const React = require('react'); -const { Platform, Clipboard, Keyboard, View, TextInput, StyleSheet, Linking, Image, Share } = require('react-native'); +const { ScrollView, Platform, Clipboard, Keyboard, View, TextInput, StyleSheet, Linking, Image, Share } = require('react-native'); const { connect } = require('react-redux'); const { uuid } = require('lib/uuid.js'); +const { MarkdownEditor } = require('../../../MarkdownEditor/index.js'); const RNFS = require('react-native-fs'); const Note = require('lib/models/Note.js'); const BaseItem = require('lib/models/BaseItem.js'); @@ -71,6 +72,8 @@ class NoteScreenComponent extends BaseScreenComponent { HACK_webviewLoadingState: 0, }; + this.markdownEditorRef = React.createRef(); // For focusing the Markdown editor + this.doFocusUpdate_ = false; // iOS doesn't support multiline text fields properly so disable it @@ -207,6 +210,7 @@ class NoteScreenComponent extends BaseScreenComponent { if (this.styles_[cacheKey]) return this.styles_[cacheKey]; this.styles_ = {}; + // TODO: Clean up these style names and nesting const styles = { bodyTextInput: { flex: 1, @@ -222,8 +226,12 @@ class NoteScreenComponent extends BaseScreenComponent { flex: 1, paddingLeft: theme.marginLeft, paddingRight: theme.marginRight, - paddingTop: theme.marginTop, - paddingBottom: theme.marginBottom, + }, + noteBodyViewerPreview: { + borderTopColor: theme.dividerColor, + borderTopWidth: 1, + borderBottomColor: theme.dividerColor, + borderBottomWidth: 1, }, checkbox: { color: theme.color, @@ -232,6 +240,10 @@ class NoteScreenComponent extends BaseScreenComponent { paddingTop: 10, // Added for iOS (Not needed for Android??) paddingBottom: 10, // Added for iOS (Not needed for Android??) }, + markdownButtons: { + borderColor: theme.dividerColor, + color: theme.htmlLinkColor, + }, }; styles.titleContainer = { @@ -767,8 +779,14 @@ class NoteScreenComponent extends BaseScreenComponent { let fieldToFocus = this.state.note.is_todo ? 'title' : 'body'; if (this.state.mode === 'view') fieldToFocus = ''; - if (fieldToFocus === 'title' && this.refs.titleTextField) this.refs.titleTextField.focus(); - if (fieldToFocus === 'body' && this.refs.noteBodyTextField) this.refs.noteBodyTextField.focus(); + if (fieldToFocus === 'title' && this.refs.titleTextField) { + this.refs.titleTextField.focus(); + } + if (fieldToFocus === 'body' && this.markdownEditorRef.current) { + if (this.markdownEditorRef.current) { + this.markdownEditorRef.current.focus(); + } + } } async folderPickerOptions_valueChanged(itemValue) { @@ -822,7 +840,7 @@ class NoteScreenComponent extends BaseScreenComponent { } let bodyComponent = null; - if (this.state.mode == 'view') { + if (this.state.mode == 'view' && !Setting.value('editor.beta')) { const onCheckboxChange = newBody => { this.saveOneProperty('body', newBody); }; @@ -843,6 +861,9 @@ class NoteScreenComponent extends BaseScreenComponent { ref="noteBodyViewer" style={this.styles().noteBodyViewer} webViewStyle={theme} + // Extra bottom padding to make it possible to scroll past the + // action button (so that it doesn't overlap the text) + paddingBottom='3.8em' note={note} noteResources={this.state.noteResources} highlightedKeywords={keywords} @@ -865,9 +886,66 @@ class NoteScreenComponent extends BaseScreenComponent { } else { // autoFocus={fieldToFocus === 'body'} - // Note: blurOnSubmit is necessary to get multiline to work. - // See https://github.com/facebook/react-native/issues/12717#issuecomment-327001997 - bodyComponent = this.body_changeText(text)} blurOnSubmit={false} selectionColor={theme.textSelectionColor} placeholder={_('Add body')} placeholderTextColor={theme.colorFaded} />; + // Currently keyword highlighting is supported only when FTS is available. + let keywords = []; + if (this.props.searchQuery && !!this.props.ftsEnabled) { + const parsedQuery = SearchEngine.instance().parseQuery(this.props.searchQuery); + keywords = SearchEngine.instance().allParsedQueryTerms(parsedQuery); + } + + const onCheckboxChange = newBody => { + this.saveOneProperty('body', newBody); + }; + + bodyComponent = Setting.value('editor.beta') + // Note: blurOnSubmit is necessary to get multiline to work. + // See https://github.com/facebook/react-native/issues/12717#issuecomment-327001997 + ? this.body_changeText(text)} + blurOnSubmit={false} + selectionColor={theme.textSelectionColor} + placeholder={_('Add body')} + placeholderTextColor={theme.colorFaded} + noteBodyViewer={{ + onJoplinLinkClick: this.onJoplinLinkClick_, + ref: 'noteBodyViewer', + style: { + ...this.styles().noteBodyViewer, + ...this.styles().noteBodyViewerPreview, + }, + webViewStyle: theme, + note: note, + noteResources: this.state.noteResources, + highlightedKeywords: keywords, + theme: this.props.theme, + noteHash: this.props.noteHash, + onCheckboxChange: newBody => { + onCheckboxChange(newBody); + }, + onMarkForDownload: this.onMarkForDownload, + onLoadEnd: () => { + setTimeout(() => { + this.setState({ HACK_webviewLoadingState: 1 }); + setTimeout(() => { + this.setState({ HACK_webviewLoadingState: 0 }); + }, 50); + }, 5); + }, + }} + + /> + : ( + + this.body_changeText(text)} blurOnSubmit={false} selectionColor={theme.textSelectionColor} placeholder={_('Add body')} placeholderTextColor={theme.colorFaded} /> + + ); } const renderActionButton = () => { @@ -913,7 +991,7 @@ class NoteScreenComponent extends BaseScreenComponent { {titleComp} {bodyComponent} - {actionButtonComp} + {!Setting.value('editor.beta') && actionButtonComp} diff --git a/ReactNativeClient/lib/joplin-renderer/MdToHtml.js b/ReactNativeClient/lib/joplin-renderer/MdToHtml.js index c63c4f45c..c0e49f8d3 100644 --- a/ReactNativeClient/lib/joplin-renderer/MdToHtml.js +++ b/ReactNativeClient/lib/joplin-renderer/MdToHtml.js @@ -178,9 +178,11 @@ class MdToHtml { // files. Otherwise some of them might be in the cssStrings property. externalAssetsOnly: false, postMessageSyntax: 'postMessage', + paddingTop: '0', + paddingBottom: '0', highlightedKeywords: [], codeTheme: 'atom-one-light.css', - theme: Object.assign({}, defaultNoteStyle, theme), + theme: Object.assign({ paddingTop: '16px' }, defaultNoteStyle, theme), plugins: {}, }, options); diff --git a/ReactNativeClient/lib/joplin-renderer/noteStyle.js b/ReactNativeClient/lib/joplin-renderer/noteStyle.js index 6ee3408ca..ff4ba118b 100644 --- a/ReactNativeClient/lib/joplin-renderer/noteStyle.js +++ b/ReactNativeClient/lib/joplin-renderer/noteStyle.js @@ -18,7 +18,8 @@ module.exports = function(theme) { line-height: ${theme.htmlLineHeight}; background-color: ${theme.htmlBackgroundColor}; font-family: ${fontFamily}; - padding-bottom: ${theme.bodyPaddingBottom}; + padding-bottom: ${theme.paddingBottom}; + padding-top: ${theme.paddingTop}; } strong { color: ${theme.colorBright}; diff --git a/ReactNativeClient/lib/models/Setting.js b/ReactNativeClient/lib/models/Setting.js index 9f35a92c7..fe73d76d5 100644 --- a/ReactNativeClient/lib/models/Setting.js +++ b/ReactNativeClient/lib/models/Setting.js @@ -316,6 +316,17 @@ class Setting extends BaseModel { }, 'folders.sortOrder.reverse': { value: false, type: Setting.TYPE_BOOL, public: true, label: () => _('Reverse sort order'), appTypes: ['cli'] }, trackLocation: { value: true, type: Setting.TYPE_BOOL, section: 'note', public: true, label: () => _('Save geo-location with notes') }, + + 'editor.beta': { + value: false, + type: Setting.TYPE_BOOL, + section: 'note', + public: true, + appTypes: ['mobile'], + label: () => 'Opt-in to the editor beta', + description: () => 'This beta adds list continuation, Markdown preview, and Markdown shortcuts. If you find bugs, please report them in the Discourse forum.', + }, + newTodoFocus: { value: 'title', type: Setting.TYPE_STRING, @@ -552,7 +563,15 @@ class Setting extends BaseModel { label: () => _('Ignore TLS certificate errors'), }, - 'sync.wipeOutFailSafe': { value: true, type: Setting.TYPE_BOOL, advanced: true, public: true, section: 'sync', label: () => _('Fail-safe: Do not wipe out local data when sync target is empty (often the result of a misconfiguration or bug)') }, + 'sync.wipeOutFailSafe': { + value: true, + type: Setting.TYPE_BOOL, + advanced: true, + public: true, + section: 'sync', + label: () => _('Fail-safe'), + description: () => _('Fail-safe: Do not wipe out local data when sync target is empty (often the result of a misconfiguration or bug)'), + }, 'api.token': { value: null, type: Setting.TYPE_STRING, public: false }, 'api.port': { value: null, type: Setting.TYPE_INT, public: true, appTypes: ['cli'], description: () => _('Specify the port that should be used by the API server. If not set, a default will be used.') },