import AsyncActionQueue from '@joplin/lib/AsyncActionQueue' ; import uuid from '@joplin/lib/uuid' ; import Setting from '@joplin/lib/models/Setting' ; import shim from '@joplin/lib/shim' ; import UndoRedoService from '@joplin/lib/services/UndoRedoService' ; import NoteBodyViewer from '../NoteBodyViewer/NoteBodyViewer' ; import checkPermissions from '../../utils/checkPermissions' ; import NoteEditor , { ChangeEvent , UndoRedoDepthChangeEvent } from '../NoteEditor/NoteEditor' ; const FileViewer = require ( 'react-native-file-viewer' ) . default ; const React = require ( 'react' ) ; const { Platform , Keyboard , View , TextInput , StyleSheet , Linking , Image , Share , PermissionsAndroid } = require ( 'react-native' ) ; const { connect } = require ( 'react-redux' ) ; const RNFS = require ( 'react-native-fs' ) ; import Note from '@joplin/lib/models/Note' ; import BaseItem from '@joplin/lib/models/BaseItem' ; import Resource from '@joplin/lib/models/Resource' ; import Folder from '@joplin/lib/models/Folder' ; const Clipboard = require ( '@react-native-community/clipboard' ) . default ; const md5 = require ( 'md5' ) ; const { BackButtonService } = require ( '../../services/back-button.js' ) ; import NavService from '@joplin/lib/services/NavService' ; import BaseModel from '@joplin/lib/BaseModel' ; const { ActionButton } = require ( '../action-button.js' ) ; const { fileExtension , safeFileExtension } = require ( '@joplin/lib/path-utils' ) ; const mimeUtils = require ( '@joplin/lib/mime-utils.js' ) . mime ; const { ScreenHeader } = require ( '../screen-header.js' ) ; const NoteTagsDialog = require ( './NoteTagsDialog' ) ; import time from '@joplin/lib/time' ; const { Checkbox } = require ( '../checkbox.js' ) ; const { _ } = require ( '@joplin/lib/locale' ) ; import { reg } from '@joplin/lib/registry' ; import ResourceFetcher from '@joplin/lib/services/ResourceFetcher' ; const { BaseScreenComponent } = require ( '../base-screen.js' ) ; const { themeStyle , editorFont } = require ( '../global-style.js' ) ; const { dialogs } = require ( '../../utils/dialogs.js' ) ; const DialogBox = require ( 'react-native-dialogbox' ) . default ; const DocumentPicker = require ( 'react-native-document-picker' ) . default ; const ImageResizer = require ( 'react-native-image-resizer' ) . default ; const shared = require ( '@joplin/lib/components/shared/note-screen-shared.js' ) ; const ImagePicker = require ( 'react-native-image-picker' ) . default ; import SelectDateTimeDialog from '../SelectDateTimeDialog' ; import ShareExtension from '../../utils/ShareExtension.js' ; import CameraView from '../CameraView' ; import { NoteEntity } from '@joplin/lib/services/database/types' ; import Logger from '@joplin/lib/Logger' ; const urlUtils = require ( '@joplin/lib/urlUtils' ) ; const emptyArray = [ ] ; const logger = Logger . create ( 'screens/Note' ) ; class NoteScreenComponent extends BaseScreenComponent { static navigationOptions ( ) { return { header : null } ; } constructor ( ) { super ( ) ; this . state = { note : Note . new ( ) , mode : 'view' , folder : null , lastSavedNote : null , isLoading : true , titleTextInputHeight : 20 , alarmDialogShown : false , heightBumpView : 0 , noteTagDialogShown : false , fromShare : false , showCamera : false , noteResources : { } , HACK_webviewLoadingState : 0 , undoRedoButtonState : { canUndo : false , canRedo : false , } , } ; this . saveActionQueues_ = { } ; this . doFocusUpdate_ = false ; this . enableMultilineTitle_ = Platform . OS !== 'ios' ; this . saveButtonHasBeenShown_ = false ; this . styles_ = { } ; this . editorRef = React . createRef ( ) ; const saveDialog = async ( ) => { if ( this . isModified ( ) ) { const buttonId = await dialogs . pop ( this , _ ( 'This note has been modified:' ) , [ { text : _ ( 'Save changes' ) , id : 'save' } , { text : _ ( 'Discard changes' ) , id : 'discard' } , { text : _ ( 'Cancel' ) , id : 'cancel' } ] ) ; if ( buttonId == 'cancel' ) return true ; if ( buttonId == 'save' ) await this . saveNoteButton_press ( ) ; } return false ; } ; this . navHandler = async ( ) => { return await saveDialog ( ) ; } ; this . backHandler = async ( ) => { if ( this . isModified ( ) ) { await this . saveNoteButton_press ( ) ; } const isProvisionalNote = this . props . provisionalNoteIds . includes ( this . props . noteId ) ; if ( isProvisionalNote ) { return false ; } if ( this . state . mode == 'edit' ) { Keyboard . dismiss ( ) ; this . setState ( { note : Object . assign ( { } , this . state . lastSavedNote ) , mode : 'view' , } ) ; await this . undoRedoService_ . reset ( ) ; return true ; } if ( this . state . fromShare ) { this . props . dispatch ( { type : 'NAV_GO' , routeName : 'Notes' , folderId : this . state . note . parent_id , } ) ; return true ; } return false ; } ; this . noteTagDialog_closeRequested = ( ) => { this . setState ( { noteTagDialogShown : false } ) ; } ; this . onJoplinLinkClick_ = async ( msg ) => { try { 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 %s' , itemId ) ) ; if ( item . type_ === BaseModel . TYPE_NOTE ) { this . props . dispatch ( { type : 'NAV_BACK' , } ) ; shim . setTimeout ( ( ) => { this . props . dispatch ( { type : 'NAV_GO' , routeName : 'Note' , noteId : item . id , noteHash : resourceUrlInfo . hash , } ) ; } , 5 ) ; } else if ( item . type_ === BaseModel . TYPE_RESOURCE ) { if ( ! ( await Resource . isReady ( item ) ) ) throw new Error ( _ ( 'This attachment is not downloaded or not decrypted yet.' ) ) ; const resourcePath = Resource . fullPath ( item ) ; logger . info ( `Opening resource: ${ resourcePath }` ) ; await FileViewer . open ( resourcePath ) ; } else { throw new Error ( _ ( 'The Joplin mobile app does not currently support this type of link: %s' , BaseModel . modelTypeToName ( item . type_ ) ) ) ; } } else { if ( msg . indexOf ( 'file://' ) === 0 ) { throw new Error ( _ ( 'Links with protocol "%s" are not supported' , 'file://' ) ) ; } else { Linking . openURL ( msg ) ; } } } catch ( error ) { dialogs . error ( this , error . message ) ; } } ; this . refreshResource = async ( resource , noteBody = null ) => { if ( noteBody === null && this . state . note && this . state . note . body ) noteBody = this . state . note . body ; if ( noteBody === null ) return ; const resourceIds = await Note . linkedResourceIds ( noteBody ) ; if ( resourceIds . indexOf ( resource . id ) >= 0 ) { shared . clearResourceCache ( ) ; const attachedResources = await shared . attachedResources ( noteBody ) ; this . setState ( { noteResources : attachedResources } ) ; } } ; this . takePhoto_onPress = this . takePhoto_onPress . bind ( this ) ; this . cameraView_onPhoto = this . cameraView_onPhoto . bind ( this ) ; this . cameraView_onCancel = this . cameraView_onCancel . bind ( this ) ; this . properties_onPress = this . properties_onPress . bind ( this ) ; this . showOnMap_onPress = this . showOnMap_onPress . bind ( this ) ; this . onMarkForDownload = this . onMarkForDownload . bind ( this ) ; this . sideMenuOptions = this . sideMenuOptions . bind ( this ) ; this . folderPickerOptions_valueChanged = this . folderPickerOptions_valueChanged . bind ( this ) ; this . saveNoteButton_press = this . saveNoteButton_press . bind ( this ) ; this . onAlarmDialogAccept = this . onAlarmDialogAccept . bind ( this ) ; this . onAlarmDialogReject = this . onAlarmDialogReject . bind ( this ) ; this . todoCheckbox_change = this . todoCheckbox_change . bind ( this ) ; this . titleTextInput_contentSizeChange = this . titleTextInput_contentSizeChange . bind ( this ) ; this . title_changeText = this . title_changeText . bind ( this ) ; this . undoRedoService_stackChange = this . undoRedoService_stackChange . bind ( this ) ; this . screenHeader_undoButtonPress = this . screenHeader_undoButtonPress . bind ( this ) ; this . screenHeader_redoButtonPress = this . screenHeader_redoButtonPress . bind ( this ) ; this . body_selectionChange = this . body_selectionChange . bind ( this ) ; this . onBodyViewerLoadEnd = this . onBodyViewerLoadEnd . bind ( this ) ; this . onBodyViewerCheckboxChange = this . onBodyViewerCheckboxChange . bind ( this ) ; this . onBodyChange = this . onBodyChange . bind ( this ) ; this . onUndoRedoDepthChange = this . onUndoRedoDepthChange . bind ( this ) ; } private useEditorBeta ( ) { return this . props . useEditorBeta ; } private onBodyChange ( event ) { shared . noteComponent_change ( this , 'body' , event . value ) ; this . scheduleSave ( ) ; } private onUndoRedoDepthChange ( event ) { if ( this . useEditorBeta ( ) ) { this . setState ( { undoRedoButtonState : { canUndo : ! ! event . undoDepth , canRedo : ! ! event . redoDepth , } } ) ; } } private undoRedoService_stackChange ( ) { if ( ! this . useEditorBeta ( ) ) { this . setState ( { undoRedoButtonState : { canUndo : this . undoRedoService_ . canUndo , canRedo : this . undoRedoService_ . canRedo , } } ) ; } } private async undoRedo ( type ) { const undoState = await this . undoRedoService_ [ type ] ( this . undoState ( ) ) ; if ( ! undoState ) return ; this . setState ( ( state ) => { const newNote = Object . assign ( { } , state . note ) ; newNote . body = undoState . body ; return { note : newNote , } ; } ) ; } screenHeader_undoButtonPress ( ) { if ( this . useEditorBeta ( ) ) { this . editorRef . current . undo ( ) ; } else { void this . undoRedo ( 'undo' ) ; } } screenHeader_redoButtonPress ( ) { if ( this . useEditorBeta ( ) ) { this . editorRef . current . redo ( ) ; } else { void this . undoRedo ( 'redo' ) ; } } undoState ( noteBody = null ) { return { body : noteBody === null ? this . state . note . body : noteBody , } ; } styles ( ) { const themeId = this . props . themeId ; const theme = themeStyle ( themeId ) ; const cacheKey = [ themeId , this . state . titleTextInputHeight , this . state . HACK_webviewLoadingState ] . join ( '_' ) ; if ( this . styles_ [ cacheKey ] ) return this . styles_ [ cacheKey ] ; this . styles_ = { } ; const styles = { screen : { flex : 1 , backgroundColor : theme . backgroundColor , } , bodyTextInput : { flex : 1 , paddingLeft : theme . marginLeft , paddingRight : theme . marginRight , textAlignVertical : 'top' , color : theme . color , backgroundColor : theme . backgroundColor , fontSize : theme . fontSize , fontFamily : editorFont ( this . props . editorFont ) , } , noteBodyViewer : { flex : 1 , paddingLeft : theme . marginLeft , paddingRight : theme . marginRight , } , checkbox : { color : theme . color , paddingRight : 10 , paddingLeft : theme . marginLeft , paddingTop : 10 , paddingBottom : 10 , } , markdownButtons : { borderColor : theme . dividerColor , color : theme . urlColor , } , } ; styles . noteBodyViewerPreview = { ... styles . noteBodyViewer , borderTopColor : theme . dividerColor , borderTopWidth : 1 , borderBottomColor : theme . dividerColor , borderBottomWidth : 1 , } ; styles . titleContainer = { flex : 0 , flexDirection : 'row' , paddingLeft : theme . marginLeft , paddingRight : theme . marginRight , borderBottomColor : theme . dividerColor , borderBottomWidth : 1 , } ; styles . titleContainerTodo = Object . assign ( { } , styles . titleContainer ) ; styles . titleContainerTodo . paddingLeft = 0 ; styles . titleTextInput = { flex : 1 , marginTop : 0 , paddingLeft : 0 , color : theme . color , backgroundColor : theme . backgroundColor , fontWeight : 'bold' , fontSize : theme . fontSize , paddingTop : 10 , paddingBottom : 10 , } ; if ( this . enableMultilineTitle_ ) styles . titleTextInput . height = this . state . titleTextInputHeight ; if ( this . state . HACK_webviewLoadingState === 1 ) styles . titleTextInput . marginTop = 1 ; this . styles_ [ cacheKey ] = StyleSheet . create ( styles ) ; return this . styles_ [ cacheKey ] ; } isModified ( ) { return shared . isModified ( this ) ; } async requestGeoLocationPermissions ( ) { if ( ! Setting . value ( 'trackLocation' ) ) return ; const response = await checkPermissions ( PermissionsAndroid . PERMISSIONS . ACCESS_FINE_LOCATION , { message : _ ( 'In order to associate a geo-location with the note, the app needs your permission to access your location.\n\nYou may turn off this option at any time in the Configuration screen.' ) , title : _ ( 'Permission needed' ) , } ) ; if ( response === PermissionsAndroid . RESULTS . NEVER_ASK_AGAIN ) { reg . logger ( ) . info ( 'Geo-location tracking has been automatically disabled' ) ; Setting . setValue ( 'trackLocation' , false ) ; } } async componentDidMount ( ) { BackButtonService . addHandler ( this . backHandler ) ; NavService . addHandler ( this . navHandler ) ; shared . clearResourceCache ( ) ; shared . installResourceHandling ( this . refreshResource ) ; await shared . initState ( this ) ; this . undoRedoService_ = new UndoRedoService ( ) ; this . undoRedoService_ . on ( 'stackChange' , this . undoRedoService_stackChange ) ; if ( this . state . note && this . state . note . body && Setting . value ( 'sync.resourceDownloadMode' ) === 'auto' ) { const resourceIds = await Note . linkedResourceIds ( this . state . note . body ) ; await ResourceFetcher . instance ( ) . markForDownload ( resourceIds ) ; } void this . requestGeoLocationPermissions ( ) ; } onMarkForDownload ( event ) { void ResourceFetcher . instance ( ) . markForDownload ( event . resourceId ) ; } componentDidUpdate ( prevProps ) { if ( this . doFocusUpdate_ ) { this . doFocusUpdate_ = false ; this . focusUpdate ( ) ; } if ( prevProps . showSideMenu !== this . props . showSideMenu && this . props . showSideMenu ) { this . props . dispatch ( { type : 'NOTE_SIDE_MENU_OPTIONS_SET' , options : this . sideMenuOptions ( ) , } ) ; } } componentWillUnmount ( ) { BackButtonService . removeHandler ( this . backHandler ) ; NavService . removeHandler ( this . navHandler ) ; shared . uninstallResourceHandling ( this . refreshResource ) ; if ( this . state . fromShare ) { ShareExtension . close ( ) ; } this . saveActionQueue ( this . state . note . id ) . processAllNow ( ) ; if ( this . undoRedoService_ ) this . undoRedoService_ . off ( 'stackChange' , this . undoRedoService_stackChange ) ; } title_changeText ( text ) { shared . noteComponent_change ( this , 'title' , text ) ; this . setState ( { newAndNoTitleChangeNoteId : null } ) ; this . scheduleSave ( ) ; } body_changeText ( text ) { if ( ! this . undoRedoService_ . canUndo ) { this . undoRedoService_ . push ( this . undoState ( ) ) ; } else { this . undoRedoService_ . schedulePush ( this . undoState ( ) ) ; } shared . noteComponent_change ( this , 'body' , text ) ; this . scheduleSave ( ) ; } body_selectionChange ( event ) { this . selection = event . nativeEvent . selection ; } makeSaveAction ( ) { return async ( ) => { return shared . saveNoteButton_press ( this ) ; } ; } saveActionQueue ( noteId ) { if ( ! this . saveActionQueues_ [ noteId ] ) { this . saveActionQueues_ [ noteId ] = new AsyncActionQueue ( 500 ) ; } return this . saveActionQueues_ [ noteId ] ; } scheduleSave ( ) { this . saveActionQueue ( this . state . note . id ) . push ( this . makeSaveAction ( ) ) ; } async saveNoteButton_press ( folderId = null ) { await shared . saveNoteButton_press ( this , folderId ) ; Keyboard . dismiss ( ) ; } async saveOneProperty ( name , value ) { await shared . saveOneProperty ( this , name , value ) ; } async deleteNote_onPress ( ) { const note = this . state . note ; if ( ! note . id ) return ; const ok = await dialogs . confirm ( this , _ ( 'Delete note?' ) ) ; if ( ! ok ) return ; const folderId = note . parent_id ; await Note . delete ( note . id ) ; this . props . dispatch ( { type : 'NAV_GO' , routeName : 'Notes' , folderId : folderId , } ) ; } async pickDocument ( ) { try { const result = await DocumentPicker . pick ( ) ; return result ; } catch ( error ) { if ( DocumentPicker . isCancel ( error ) ) { console . info ( 'pickDocument: user has cancelled' ) ; return null ; } else { throw error ; } } } async imageDimensions ( uri ) { return new Promise ( ( resolve , reject ) => { Image . getSize ( uri , ( width , height ) => { resolve ( { width : width , height : height } ) ; } , ( error ) => { reject ( error ) ; } ) ; } ) ; } showImagePicker ( options ) { return new Promise ( ( resolve ) => { ImagePicker . launchImageLibrary ( options , ( response ) => { resolve ( response ) ; } ) ; } ) ; } async resizeImage ( localFilePath , targetPath , mimeType ) { const maxSize = Resource . IMAGE_MAX_DIMENSION ; const dimensions = await this . imageDimensions ( localFilePath ) ; reg . logger ( ) . info ( 'Original dimensions ' , dimensions ) ; let mustResize = dimensions . width > maxSize || dimensions . height > maxSize ; if ( mustResize ) { const buttonId = await dialogs . pop ( this , _ ( 'You are about to attach a large image (%dx%d pixels). Would you like to resize it down to %d pixels before attaching it?' , dimensions . width , dimensions . height , maxSize ) , [ { text : _ ( 'Yes' ) , id : 'yes' } , { text : _ ( 'No' ) , id : 'no' } , { text : _ ( 'Cancel' ) , id : 'cancel' } , ] ) ; if ( buttonId === 'cancel' ) return false ; mustResize = buttonId === 'yes' ; } if ( mustResize ) { dimensions . width = maxSize ; dimensions . height = maxSize ; reg . logger ( ) . info ( 'New dimensions ' , dimensions ) ; const format = mimeType == 'image/png' ? 'PNG' : 'JPEG' ; reg . logger ( ) . info ( `Resizing image ${ localFilePath }` ) ; const resizedImage = await ImageResizer . createResizedImage ( localFilePath , dimensions . width , dimensions . height , format , 85 ) ; const resizedImagePath = resizedImage . uri ; reg . logger ( ) . info ( 'Resized image ' , resizedImagePath ) ; reg . logger ( ) . info ( `Moving ${ resizedImagePath } => ${ targetPath }` ) ; await RNFS . copyFile ( resizedImagePath , targetPath ) ; try { await RNFS . unlink ( resizedImagePath ) ; } catch ( error ) { reg . logger ( ) . warn ( 'Error when unlinking cached file: ' , error ) ; } } else { await RNFS . copyFile ( localFilePath , targetPath ) ; } return true ; } async attachFile ( pickerResponse , fileType ) { if ( ! pickerResponse ) { return ; } if ( pickerResponse . error ) { reg . logger ( ) . warn ( 'Got error from picker' , pickerResponse . error ) ; return ; } if ( pickerResponse . didCancel ) { reg . logger ( ) . info ( 'User cancelled picker' ) ; return ; } const localFilePath = Platform . select ( { android : pickerResponse . uri , ios : decodeURI ( pickerResponse . uri ) , } ) ; let mimeType = pickerResponse . type ; if ( ! mimeType ) { const ext = fileExtension ( localFilePath ) ; mimeType = mimeUtils . fromFileExtension ( ext ) ; } if ( ! mimeType && fileType === 'image' ) { reg . logger ( ) . info ( 'Missing file type and could not detect it - assuming image/jpg' ) ; mimeType = 'image/jpg' ; } reg . logger ( ) . info ( `Got file: ${ localFilePath }` ) ; reg . logger ( ) . info ( `Got type: ${ mimeType }` ) ; let resource = Resource . new ( ) ; resource . id = uuid . create ( ) ; resource . mime = mimeType ; resource . title = pickerResponse . name ? pickerResponse . name : '' ; resource . file_extension = safeFileExtension ( fileExtension ( pickerResponse . name ? pickerResponse . name : localFilePath ) ) ; if ( ! resource . mime ) resource . mime = 'application/octet-stream' ; const targetPath = Resource . fullPath ( resource ) ; try { if ( mimeType == 'image/jpeg' || mimeType == 'image/jpg' || mimeType == 'image/png' ) { const done = await this . resizeImage ( localFilePath , targetPath , mimeType ) ; if ( ! done ) return ; } else { if ( fileType === 'image' ) { dialogs . error ( this , _ ( 'Unsupported image type: %s' , mimeType ) ) ; return ; } else { await shim . fsDriver ( ) . copy ( localFilePath , targetPath ) ; const stat = await shim . fsDriver ( ) . stat ( targetPath ) ; if ( stat . size >= 10000000 ) { await shim . fsDriver ( ) . remove ( targetPath ) ; throw new Error ( 'Resources larger than 10 MB are not currently supported as they may crash the mobile applications. The issue is being investigated and will be fixed at a later time.' ) ; } } } } catch ( error ) { reg . logger ( ) . warn ( 'Could not attach file:' , error ) ; await dialogs . error ( this , error . message ) ; return ; } const itDoes = await shim . fsDriver ( ) . waitTillExists ( targetPath ) ; if ( ! itDoes ) throw new Error ( `Resource file was not created: ${ targetPath }` ) ; const fileStat = await shim . fsDriver ( ) . stat ( targetPath ) ; resource . size = fileStat . size ; resource = await Resource . save ( resource , { isNew : true } ) ; const resourceTag = Resource . markdownTag ( resource ) ; const newNote = Object . assign ( { } , this . state . note ) ; if ( this . state . mode == 'edit' && ! ! this . selection ) { const prefix = newNote . body . substring ( 0 , this . selection . start ) ; const suffix = newNote . body . substring ( this . selection . end ) ; newNote . body = `${ prefix }\n${ resourceTag }\n${ suffix }` ; } else { newNote . body += `\n${ resourceTag }` ; } this . setState ( { note : newNote } ) ; this . refreshResource ( resource , newNote . body ) ; this . scheduleSave ( ) ; } async attachPhoto_onPress ( ) { const response = await this . showImagePicker ( { mediaType : 'photo' , noData : true } ) ; await this . attachFile ( response , 'image' ) ; } takePhoto_onPress ( ) { this . setState ( { showCamera : true } ) ; } cameraView_onPhoto ( data ) { void this . attachFile ( { uri : data . uri , didCancel : false , error : null , type : 'image/jpg' , } , 'image' ) ; this . setState ( { showCamera : false } ) ; } cameraView_onCancel ( ) { this . setState ( { showCamera : false } ) ; } async attachFile_onPress ( ) { const response = await this . pickDocument ( ) ; await this . attachFile ( response , 'all' ) ; } toggleIsTodo_onPress ( ) { shared . toggleIsTodo_onPress ( this ) ; this . scheduleSave ( ) ; } tags_onPress ( ) { if ( ! this . state . note || ! this . state . note . id ) return ; this . setState ( { noteTagDialogShown : true } ) ; } async share_onPress ( ) { await Share . share ( { message : `${ this . state . note . title }\n\n${ this . state . note . body }` , title : this . state . note . title , } ) ; } properties_onPress ( ) { this . props . dispatch ( { type : 'SIDE_MENU_OPEN' } ) ; } setAlarm_onPress ( ) { this . setState ( { alarmDialogShown : true } ) ; } async onAlarmDialogAccept ( date ) { const newNote = Object . assign ( { } , this . state . note ) ; newNote . todo_due = date ? date . getTime ( ) : 0 ; await this . saveOneProperty ( 'todo_due' , date ? date . getTime ( ) : 0 ) ; this . setState ( { alarmDialogShown : false } ) ; } onAlarmDialogReject ( ) { this . setState ( { alarmDialogShown : false } ) ; } async showOnMap_onPress ( ) { if ( ! this . state . note . id ) return ; const note = await Note . load ( this . state . note . id ) ; try { const url = Note . geolocationUrl ( note ) ; Linking . openURL ( url ) ; } catch ( error ) { this . props . dispatch ( { type : 'SIDE_MENU_CLOSE' } ) ; await dialogs . error ( this , error . message ) ; } } async showSource_onPress ( ) { if ( ! this . state . note . id ) return ; const note = await Note . load ( this . state . note . id ) ; try { Linking . openURL ( note . source_url ) ; } catch ( error ) { await dialogs . error ( this , error . message ) ; } } copyMarkdownLink_onPress ( ) { const note = this . state . note ; Clipboard . setString ( Note . markdownTag ( note ) ) ; } sideMenuOptions ( ) { const note = this . state . note ; if ( ! note ) return [ ] ; const output = [ ] ; const createdDateString = time . formatMsToLocal ( note . user_created_time ) ; const updatedDateString = time . formatMsToLocal ( note . user_updated_time ) ; output . push ( { title : _ ( 'Created: %s' , createdDateString ) } ) ; output . push ( { title : _ ( 'Updated: %s' , updatedDateString ) } ) ; output . push ( { isDivider : true } ) ; output . push ( { title : _ ( 'View on map' ) , onPress : ( ) => { void this . showOnMap_onPress ( ) ; } , } ) ; if ( note . source_url ) { output . push ( { title : _ ( 'Go to source URL' ) , onPress : ( ) => { void this . showSource_onPress ( ) ; } , } ) ; } return output ; } menuOptions ( ) { const note = this . state . note ; const isTodo = note && ! ! note . is_todo ; const isSaved = note && note . id ; const cacheKey = md5 ( [ isTodo , isSaved ] . join ( '_' ) ) ; if ( ! this . menuOptionsCache_ ) this . menuOptionsCache_ = { } ; if ( this . menuOptionsCache_ [ cacheKey ] ) return this . menuOptionsCache_ [ cacheKey ] ; const output = [ ] ; let canAttachPicture = true ; if ( Platform . OS === 'android' && Platform . Version < 21 ) canAttachPicture = false ; if ( canAttachPicture ) { output . push ( { title : _ ( 'Attach...' ) , onPress : async ( ) => { if ( this . state . mode === 'edit' && this . useEditorBeta ( ) ) { alert ( 'Attaching files from the beta editor is not yet supported. You may do so from the viewer mode instead.' ) ; return ; } const buttons = [ ] ; buttons . push ( { text : _ ( 'Attach file' ) , id : 'attachFile' } ) ; if ( Platform . OS === 'ios' ) buttons . push ( { text : _ ( 'Attach photo' ) , id : 'attachPhoto' } ) ; buttons . push ( { text : _ ( 'Take photo' ) , id : 'takePhoto' } ) ; const buttonId = await dialogs . pop ( this , _ ( 'Choose an option' ) , buttons ) ; if ( buttonId === 'takePhoto' ) this . takePhoto_onPress ( ) ; if ( buttonId === 'attachFile' ) void this . attachFile_onPress ( ) ; if ( buttonId === 'attachPhoto' ) void this . attachPhoto_onPress ( ) ; } , } ) ; } if ( isTodo ) { output . push ( { title : _ ( 'Set alarm' ) , onPress : ( ) => { this . setState ( { alarmDialogShown : true } ) ; } , } ) ; } output . push ( { title : _ ( 'Share' ) , onPress : ( ) => { void this . share_onPress ( ) ; } , } ) ; if ( isSaved ) { output . push ( { title : _ ( 'Tags' ) , onPress : ( ) => { this . tags_onPress ( ) ; } , } ) ; } output . push ( { title : isTodo ? _ ( 'Convert to note' ) : _ ( 'Convert to todo' ) , onPress : ( ) => { this . toggleIsTodo_onPress ( ) ; } , } ) ; if ( isSaved ) { output . push ( { title : _ ( 'Copy Markdown link' ) , onPress : ( ) => { this . copyMarkdownLink_onPress ( ) ; } , } ) ; } output . push ( { title : _ ( 'Properties' ) , onPress : ( ) => { this . properties_onPress ( ) ; } , } ) ; output . push ( { title : _ ( 'Delete' ) , onPress : ( ) => { void this . deleteNote_onPress ( ) ; } , } ) ; this . menuOptionsCache_ = { } ; this . menuOptionsCache_ [ cacheKey ] = output ; return output ; } async todoCheckbox_change ( checked ) { await this . saveOneProperty ( 'todo_completed' , checked ? time . unixMs ( ) : 0 ) ; } titleTextInput_contentSizeChange ( event ) { if ( ! this . enableMultilineTitle_ ) return ; const height = event . nativeEvent . contentSize . height ; this . setState ( { titleTextInputHeight : height } ) ; } scheduleFocusUpdate ( ) { if ( this . focusUpdateIID_ ) shim . clearTimeout ( this . focusUpdateIID_ ) ; this . focusUpdateIID_ = shim . setTimeout ( ( ) => { this . focusUpdateIID_ = null ; this . focusUpdate ( ) ; } , 100 ) ; } focusUpdate ( ) { if ( this . focusUpdateIID_ ) shim . clearTimeout ( this . focusUpdateIID_ ) ; this . focusUpdateIID_ = null ; if ( ! this . state . note ) return ; 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 ( ) ; } } async folderPickerOptions_valueChanged ( itemValue ) { const note = this . state . note ; const isProvisionalNote = this . props . provisionalNoteIds . includes ( note . id ) ; if ( isProvisionalNote ) { await this . saveNoteButton_press ( itemValue ) ; } else { await Note . moveToFolder ( note . id , itemValue ) ; } note . parent_id = itemValue ; const folder = await Folder . load ( note . parent_id ) ; this . setState ( { lastSavedNote : Object . assign ( { } , note ) , note : note , folder : folder , } ) ; } folderPickerOptions ( ) { const options = { enabled : true , selectedFolderId : this . state . folder ? this . state . folder . id : null , onValueChange : this . folderPickerOptions_valueChanged , } ; if ( this . folderPickerOptions_ && options . selectedFolderId === this . folderPickerOptions_ . selectedFolderId ) return this . folderPickerOptions_ ; this . folderPickerOptions_ = options ; return this . folderPickerOptions_ ; } onBodyViewerLoadEnd ( ) { shim . setTimeout ( ( ) => { this . setState ( { HACK_webviewLoadingState : 1 } ) ; shim . setTimeout ( ( ) => { this . setState ( { HACK_webviewLoadingState : 0 } ) ; } , 50 ) ; } , 5 ) ; } onBodyViewerCheckboxChange ( newBody ) { void this . saveOneProperty ( 'body' , newBody ) ; } render ( ) { if ( this . state . isLoading ) { return ( < View style = { this . styles ( ) . screen } > < ScreenHeader /> < /View>); } const theme = themeStyle ( this . props . themeId ) ; const note = this . state . note ; const isTodo = ! ! Number ( note . is_todo ) ; if ( this . state . showCamera ) { return < CameraView themeId = { this . props . themeId } style = { { flex : 1 } } onPhoto = { this . cameraView_onPhoto } onCancel = { this . cameraView_onCancel } / > ; } const keywords = this . props . searchQuery && ! ! this . props . ftsEnabled ? this . props . highlightedWords : emptyArray ; let bodyComponent = null ; if ( this . state . mode == 'view' ) { bodyComponent = ! note || ! note . body . trim ( ) ? null : ( < NoteBodyViewer onJoplinLinkClick = { this . onJoplinLinkClick_ } style = { this . styles ( ) . noteBodyViewer } paddingBottom = { 150 } noteBody = { note . body } noteMarkupLanguage = { note . markup_language } noteResources = { this . state . noteResources } highlightedKeywords = { keywords } themeId = { this . props . themeId } noteHash = { this . props . noteHash } onCheckboxChange = { this . onBodyViewerCheckboxChange } onMarkForDownload = { this . onMarkForDownload } onLoadEnd = { this . onBodyViewerLoadEnd } / > ) ; } else { if ( ! this . useEditorBeta ( ) ) { bodyComponent = ( < TextInput autoCapitalize = "sentences" style = { this . styles ( ) . bodyTextInput } ref = "noteBodyTextField" multiline = { true } value = { note . body } onChangeText = { ( text ) => this . body_changeText ( text ) } onSelectionChange = { this . body_selectionChange } blurOnSubmit = { false } selectionColor = { theme . textSelectionColor } keyboardAppearance = { theme . keyboardAppearance } placeholder = { _ ( 'Add body' ) } placeholderTextColor = { theme . colorFaded } paddingBottom = { Platform . OS === 'ios' ? 40 : 0 } / > ) ; } else { bodyComponent = < NoteEditor ref = { this . editorRef } themeId = { this . props . themeId } initialText = { note . body } onChange = { this . onBodyChange } onUndoRedoDepthChange = { this . onUndoRedoDepthChange } style = { this . styles ( ) . bodyTextInput } / > ; } } const renderActionButton = ( ) => { const buttons = [ ] ; buttons . push ( { title : _ ( 'Edit' ) , icon : 'md-create' , onPress : ( ) => { this . setState ( { mode : 'edit' } ) ; this . doFocusUpdate_ = true ; } , } ) ; if ( this . state . mode == 'edit' ) return null ; return < ActionButton multiStates = { true } buttons = { buttons } buttonIndex = { 0 } / > ; } ; const actionButtonComp = renderActionButton ( ) ; const showSaveButton = false ; const saveButtonDisabled = true ; if ( showSaveButton ) this . saveButtonHasBeenShown_ = true ; const titleContainerStyle = isTodo ? this . styles ( ) . titleContainerTodo : this . styles ( ) . titleContainer ; const dueDate = Note . dueDateObject ( note ) ; const titleComp = ( < View style = { titleContainerStyle } > { isTodo && < Checkbox style = { this . styles ( ) . checkbox } checked = { ! ! Number ( note . todo_completed ) } onChange = { this . todoCheckbox_change } / > } < TextInput onContentSizeChange = { this . titleTextInput_contentSizeChange } multiline = { this . enableMultilineTitle_ } ref = "titleTextField" underlineColorAndroid = "#ffffff00" autoCapitalize = "sentences" style = { this . styles ( ) . titleTextInput } value = { note . title } onChangeText = { this . title_changeText } selectionColor = { theme . textSelectionColor } keyboardAppearance = { theme . keyboardAppearance } placeholder = { _ ( 'Add title' ) } placeholderTextColor = { theme . colorFaded } / > < /View>); const noteTagDialog = ! this . state . noteTagDialogShown ? null : < NoteTagsDialog onCloseRequested = { this . noteTagDialog_closeRequested } />; return ( < View style = { this . rootStyle ( this . props . themeId ) . root } > < ScreenHeader folderPickerOptions = { this . folderPickerOptions ( ) } menuOptions = { this . menuOptions ( ) } showSaveButton = { showSaveButton } saveButtonDisabled = { saveButtonDisabled } onSaveButtonPress = { this . saveNoteButton_press } showSideMenuButton = { false } showSearchButton = { false } showUndoButton = { this . state . undoRedoButtonState . canUndo || this . state . undoRedoButtonState . canRedo } showRedoButton = { this . state . undoRedoButtonState . canRedo } undoButtonDisabled = { ! this . state . undoRedoButtonState . canUndo && this . state . undoRedoButtonState . canRedo } onUndoButtonPress = { this . screenHeader_undoButtonPress } onRedoButtonPress = { this . screenHeader_redoButtonPress } / > { titleComp } { bodyComponent } { actionButtonComp } < SelectDateTimeDialog themeId = { this . props . themeId } shown = { this . state . alarmDialogShown } date = { dueDate } onAccept = { this . onAlarmDialogAccept } onReject = { this . onAlarmDialogReject } / > < DialogBox ref = { ( dialogbox ) => { this . dialogbox = dialogbox ; } } /> { noteTagDialog } < /View>); } } const NoteScreen = connect ( ( state ) => { return { noteId : state . selectedNoteIds . length ? state . selectedNoteIds [ 0 ] : null , noteHash : state . selectedNoteHash , folderId : state . selectedFolderId , itemType : state . selectedItemType , folders : state . folders , searchQuery : state . searchQuery , themeId : state . settings . theme , editorFont : [ state . settings [ 'style.editor.fontFamily' ] ] , ftsEnabled : state . settings [ 'db.ftsEnabled' ] , sharedData : state . sharedData , showSideMenu : state . showSideMenu , provisionalNoteIds : state . provisionalNoteIds , highlightedWords : state . highlightedWords , useEditorBeta : state . settings [ 'editor.beta' ] , } ; } ) ( NoteScreenComponent ) ; export default NoteScreen ;