2020-11-07 17:59:37 +02:00
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' ;
2021-07-13 20:13:13 +02:00
import UndoRedoService from '@joplin/lib/services/UndoRedoService' ;
2020-11-05 18:58:23 +02:00
import NoteBodyViewer from '../NoteBodyViewer/NoteBodyViewer' ;
import checkPermissions from '../../utils/checkPermissions' ;
2022-08-08 17:00:14 +02:00
import NoteEditor from '../NoteEditor/NoteEditor' ;
2023-11-30 01:12:34 +02:00
import { Size } from '@joplin/utils/types' ;
2020-10-16 17:26:19 +02:00
const FileViewer = require ( 'react-native-file-viewer' ) . default ;
2019-07-30 09:35:42 +02:00
const React = require ( 'react' ) ;
2023-12-19 20:23:05 +02:00
import { Keyboard , View , TextInput , StyleSheet , Linking , Image , Share , NativeSyntheticEvent } from 'react-native' ;
2023-09-11 21:45:32 +02:00
import { Platform , PermissionsAndroid } from 'react-native' ;
2018-03-09 22:59:12 +02:00
const { connect } = require ( 'react-redux' ) ;
2020-11-07 17:59:37 +02:00
// const { MarkdownEditor } = require('@joplin/lib/../MarkdownEditor/index.js');
2021-01-22 19:41:11 +02:00
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' ;
2020-10-16 17:26:19 +02:00
const Clipboard = require ( '@react-native-community/clipboard' ) . default ;
2019-07-12 19:32:08 +02:00
const md5 = require ( 'md5' ) ;
2020-11-05 18:58:23 +02:00
const { BackButtonService } = require ( '../../services/back-button.js' ) ;
2023-12-08 12:12:23 +02:00
import NavService , { OnNavigateCallback as OnNavigateCallback } from '@joplin/lib/services/NavService' ;
2024-03-02 16:25:27 +02:00
import BaseModel , { ModelType } from '@joplin/lib/BaseModel' ;
2023-01-08 14:22:41 +02:00
import ActionButton from '../ActionButton' ;
2020-11-07 17:59:37 +02:00
const { fileExtension , safeFileExtension } = require ( '@joplin/lib/path-utils' ) ;
const mimeUtils = require ( '@joplin/lib/mime-utils.js' ) . mime ;
2023-07-16 18:42:42 +02:00
import ScreenHeader , { MenuOptionType } from '../ScreenHeader' ;
2024-03-23 16:21:37 +02:00
import NoteTagsDialog from './NoteTagsDialog' ;
2021-01-22 19:41:11 +02:00
import time from '@joplin/lib/time' ;
2022-09-30 12:46:26 +02:00
const { Checkbox } = require ( '../checkbox.js' ) ;
2023-05-07 13:05:41 +02:00
import { _ , currentLocale } from '@joplin/lib/locale' ;
2021-01-29 20:45:11 +02:00
import { reg } from '@joplin/lib/registry' ;
2021-01-23 17:51:19 +02:00
import ResourceFetcher from '@joplin/lib/services/ResourceFetcher' ;
2023-12-08 12:12:23 +02:00
import { BaseScreenComponent } from '../base-screen' ;
2024-03-09 13:15:13 +02:00
import { themeStyle , editorFont } from '../global-style' ;
2020-11-05 18:58:23 +02:00
const { dialogs } = require ( '../../utils/dialogs.js' ) ;
2020-06-13 17:20:59 +02:00
const DialogBox = require ( 'react-native-dialogbox' ) . default ;
2023-08-18 10:34:31 +02:00
import ImageResizer from '@bam.tech/react-native-image-resizer' ;
2023-12-08 12:12:23 +02:00
import shared , { BaseNoteScreenComponent } from '@joplin/lib/components/shared/note-screen-shared' ;
2023-11-30 01:12:34 +02:00
import { Asset , ImagePickerResponse , launchImageLibrary } from 'react-native-image-picker' ;
2021-01-22 19:41:11 +02:00
import SelectDateTimeDialog from '../SelectDateTimeDialog' ;
import ShareExtension from '../../utils/ShareExtension.js' ;
import CameraView from '../CameraView' ;
2023-12-08 12:12:23 +02:00
import { FolderEntity , NoteEntity , ResourceEntity } from '@joplin/lib/services/database/types' ;
2023-07-27 17:05:56 +02:00
import Logger from '@joplin/utils/Logger' ;
2023-10-02 16:15:51 +02:00
import ImageEditor from '../NoteEditor/ImageEditor/ImageEditor' ;
import promptRestoreAutosave from '../NoteEditor/ImageEditor/promptRestoreAutosave' ;
import isEditableResource from '../NoteEditor/ImageEditor/isEditableResource' ;
2023-05-07 13:05:41 +02:00
import VoiceTypingDialog from '../voiceTyping/VoiceTypingDialog' ;
import { voskEnabled } from '../../services/voiceTyping/vosk' ;
2023-06-13 19:06:54 +02:00
import { isSupportedLanguage } from '../../services/voiceTyping/vosk.android' ;
2023-12-17 22:58:22 +02:00
import { ChangeEvent as EditorChangeEvent , SelectionRangeChangeEvent , UndoRedoDepthChangeEvent } from '@joplin/editor/events' ;
2023-10-31 00:17:49 +02:00
import { join } from 'path' ;
2023-12-08 12:12:23 +02:00
import { Dispatch } from 'redux' ;
import { RefObject } from 'react' ;
2023-12-17 22:58:22 +02:00
import { SelectionRange } from '../NoteEditor/types' ;
2024-03-02 16:25:27 +02:00
import { AppState } from '../../utils/types' ;
import restoreItems from '@joplin/lib/services/trash/restoreItems' ;
import { getDisplayParentTitle } from '@joplin/lib/services/trash' ;
2024-03-11 17:02:15 +02:00
import { PluginStates } from '@joplin/lib/services/plugins/reducer' ;
2024-03-14 21:04:32 +02:00
import pickDocument from '../../utils/pickDocument' ;
2024-03-20 13:02:10 +02:00
import debounce from '../../utils/debounce' ;
2020-11-07 17:59:37 +02:00
const urlUtils = require ( '@joplin/lib/urlUtils' ) ;
2017-05-12 22:23:54 +02:00
2020-11-12 21:13:28 +02:00
const emptyArray : any [ ] = [ ] ;
2020-10-16 17:26:19 +02:00
2021-11-29 12:37:06 +02:00
const logger = Logger . create ( 'screens/Note' ) ;
2023-12-08 12:12:23 +02:00
interface Props {
provisionalNoteIds : string [ ] ;
dispatch : Dispatch ;
noteId : string ;
useEditorBeta : boolean ;
2024-03-11 17:02:15 +02:00
plugins : PluginStates ;
2023-12-08 12:12:23 +02:00
themeId : number ;
editorFontSize : number ;
editorFont : number ; // e.g. Setting.FONT_MENLO
showSideMenu : boolean ;
searchQuery : string [ ] ;
ftsEnabled : boolean ;
highlightedWords : string [ ] ;
noteHash : string ;
toolbarEnabled : boolean ;
}
interface State {
2024-03-02 16:25:27 +02:00
note : NoteEntity ;
2023-12-08 12:12:23 +02:00
mode : 'view' | 'edit' ;
readOnly : boolean ;
folder : FolderEntity | null ;
lastSavedNote : any ;
isLoading : boolean ;
titleTextInputHeight : number ;
alarmDialogShown : boolean ;
heightBumpView : number ;
noteTagDialogShown : boolean ;
fromShare : boolean ;
showCamera : boolean ;
showImageEditor : boolean ;
imageEditorResource : ResourceEntity ;
imageEditorResourceFilepath : string ;
noteResources : Record < string , ResourceEntity > ;
newAndNoTitleChangeNoteId : boolean | null ;
HACK_webviewLoadingState : number ;
undoRedoButtonState : {
canUndo : boolean ;
canRedo : boolean ;
} ;
voiceTypingDialogShown : boolean ;
}
class NoteScreenComponent extends BaseScreenComponent < Props , State > implements BaseNoteScreenComponent {
2023-11-16 14:19:48 +02:00
// This isn't in this.state because we don't want changing scroll to trigger
// a re-render.
private lastBodyScroll : number | undefined = undefined ;
2023-05-03 13:19:43 +02:00
2023-12-08 12:12:23 +02:00
private saveActionQueues_ : any ;
private doFocusUpdate_ : boolean ;
private styles_ : any ;
private editorRef : any ;
private titleTextFieldRef : RefObject < TextInput > ;
private navHandler : OnNavigateCallback ;
private backHandler : ( ) = > Promise < boolean > ;
private undoRedoService_ : UndoRedoService ;
private noteTagDialog_closeRequested : any ;
private onJoplinLinkClick_ : any ;
private refreshResource : ( resource : any , noteBody? : string ) = > Promise < void > ;
2023-12-17 22:58:22 +02:00
private selection : SelectionRange ;
2023-12-08 12:12:23 +02:00
private menuOptionsCache_ : Record < string , any > ;
private focusUpdateIID_ : any ;
private folderPickerOptions_ : any ;
public dialogbox : any ;
2023-03-06 16:22:01 +02:00
public static navigationOptions ( ) : any {
2017-05-16 21:57:09 +02:00
return { header : null } ;
}
2017-05-12 22:23:54 +02:00
2023-12-08 12:12:23 +02:00
public constructor ( props : Props ) {
super ( props ) ;
2017-07-10 23:34:26 +02:00
this . state = {
note : Note.new ( ) ,
2018-03-09 22:59:12 +02:00
mode : 'view' ,
2023-12-08 12:12:23 +02:00
readOnly : false ,
2017-07-13 23:50:21 +02:00
folder : null ,
2017-07-15 01:12:32 +02:00
lastSavedNote : null ,
2017-07-24 23:52:30 +02:00
isLoading : true ,
2017-07-31 19:56:14 +02:00
titleTextInputHeight : 20 ,
2017-09-10 18:56:27 +02:00
alarmDialogShown : false ,
2019-07-30 09:35:42 +02:00
heightBumpView : 0 ,
2018-03-18 01:00:01 +02:00
noteTagDialogShown : false ,
2018-07-20 11:04:25 +02:00
fromShare : false ,
2018-10-13 11:32:44 +02:00
showCamera : false ,
2023-10-02 16:15:51 +02:00
showImageEditor : false ,
imageEditorResource : null ,
2019-03-08 19:14:17 +02:00
noteResources : { } ,
2023-12-08 12:12:23 +02:00
imageEditorResourceFilepath : null ,
newAndNoTitleChangeNoteId : null ,
2018-12-27 23:49:19 +02:00
// HACK: For reasons I can't explain, when the WebView is present, the TextInput initially does not display (It's just a white rectangle with
// no visible text). It will only appear when tapping it or doing certain action like selecting text on the webview. The bug started to
// appear one day and did not go away - reverting to an old RN version did not help, undoing all
// the commits till a working version did not help. The bug also does not happen in the simulator which makes it hard to fix.
// Eventually, a way that "worked" is to add a 1px margin on top of the text input just after the webview has loaded, then removing this
2019-07-30 09:35:42 +02:00
// margin. This forces RN to update the text input and to display it. Maybe that hack can be removed once RN is upgraded.
2018-12-27 23:49:19 +02:00
// See https://github.com/laurent22/joplin/issues/1057
HACK_webviewLoadingState : 0 ,
2017-07-16 23:17:22 +02:00
2020-06-13 17:20:18 +02:00
undoRedoButtonState : {
canUndo : false ,
canRedo : false ,
} ,
2023-05-07 13:05:41 +02:00
voiceTypingDialogShown : false ,
2020-06-13 17:20:18 +02:00
} ;
2020-05-20 18:46:01 +02:00
2020-06-10 01:30:32 +02:00
this . saveActionQueues_ = { } ;
2020-11-05 18:58:23 +02:00
// this.markdownEditorRef = React.createRef(); // For focusing the Markdown editor
2020-03-25 12:50:45 +02:00
2019-06-27 00:21:12 +02:00
this . doFocusUpdate_ = false ;
2017-08-01 20:53:50 +02:00
this . styles_ = { } ;
2021-07-13 20:13:13 +02:00
this . editorRef = React . createRef ( ) ;
2018-02-22 00:08:34 +02:00
const saveDialog = async ( ) = > {
2017-08-24 20:10:03 +02:00
if ( this . isModified ( ) ) {
2020-06-13 17:20:59 +02:00
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' } ] ) ;
2017-07-31 21:02:21 +02:00
2022-07-23 09:31:32 +02:00
if ( buttonId === 'cancel' ) return true ;
if ( buttonId === 'save' ) await this . saveNoteButton_press ( ) ;
2017-08-24 20:10:03 +02:00
}
2017-07-31 21:02:21 +02:00
2018-02-22 00:08:34 +02:00
return false ;
2019-07-30 09:35:42 +02:00
} ;
2018-02-22 00:08:34 +02:00
this . navHandler = async ( ) = > {
return await saveDialog ( ) ;
2019-07-30 09:35:42 +02:00
} ;
2018-02-22 00:08:34 +02:00
this . backHandler = async ( ) = > {
2020-03-24 00:34:13 +02:00
if ( this . isModified ( ) ) {
await this . saveNoteButton_press ( ) ;
}
2018-02-22 00:08:34 +02:00
2020-03-06 20:49:30 +02:00
const isProvisionalNote = this . props . provisionalNoteIds . includes ( this . props . noteId ) ;
if ( isProvisionalNote ) {
2017-08-24 20:10:03 +02:00
return false ;
}
2017-07-31 21:02:21 +02:00
2022-07-23 09:31:32 +02:00
if ( this . state . mode === 'edit' ) {
2019-07-30 09:35:42 +02:00
Keyboard . dismiss ( ) ;
2017-08-24 20:10:03 +02:00
this . setState ( {
2023-06-01 13:02:36 +02:00
note : { . . . this . state . lastSavedNote } ,
2018-03-09 22:59:12 +02:00
mode : 'view' ,
2017-08-24 20:10:03 +02:00
} ) ;
2020-06-13 17:20:18 +02:00
await this . undoRedoService_ . reset ( ) ;
2017-08-24 20:10:03 +02:00
return true ;
}
2020-06-04 19:40:44 +02:00
if ( this . state . fromShare ) {
2023-02-19 21:24:44 +02:00
// effectively the same as NAV_BACK but NAV_BACK causes undesired behaviour in this case:
// - share to Joplin from some other app
// - open Joplin and open any note
// - go back -- with NAV_BACK this causes the app to exit rather than just showing notes
this . props . dispatch ( {
type : 'NAV_GO' ,
routeName : 'Notes' ,
folderId : this.state.note.parent_id ,
} ) ;
2023-02-19 20:23:00 +02:00
ShareExtension . close ( ) ;
2020-06-04 19:40:44 +02:00
return true ;
}
2017-08-24 20:10:03 +02:00
return false ;
} ;
2018-03-18 01:00:01 +02:00
this . noteTagDialog_closeRequested = ( ) = > {
this . setState ( { noteTagDialogShown : false } ) ;
2019-07-30 09:35:42 +02:00
} ;
2018-05-02 16:13:20 +02:00
2020-11-12 21:13:28 +02:00
this . onJoplinLinkClick_ = async ( msg : string ) = > {
2018-05-02 16:13:20 +02:00
try {
2022-07-12 12:51:33 +02:00
const resourceUrlInfo = urlUtils . parseResourceUrl ( msg ) ;
if ( resourceUrlInfo ) {
2019-09-09 19:16:00 +02:00
const itemId = resourceUrlInfo . itemId ;
2018-05-02 16:13:20 +02:00
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 ( {
2024-03-14 21:04:32 +02:00
type : 'NAV_GO' ,
routeName : 'Note' ,
noteId : item.id ,
noteHash : resourceUrlInfo.hash ,
2018-05-02 16:13:20 +02:00
} ) ;
2018-06-30 20:45:21 +02:00
} else if ( item . type_ === BaseModel . TYPE_RESOURCE ) {
2018-11-13 02:45:08 +02:00
if ( ! ( await Resource . isReady ( item ) ) ) throw new Error ( _ ( 'This attachment is not downloaded or not decrypted yet.' ) ) ;
2021-11-29 12:37:06 +02:00
2023-10-02 16:15:51 +02:00
const resourcePath = Resource . fullPath ( item ) ;
2021-11-29 12:37:06 +02:00
logger . info ( ` Opening resource: ${ resourcePath } ` ) ;
2018-06-30 20:45:21 +02:00
await FileViewer . open ( resourcePath ) ;
2018-05-02 16:13:20 +02:00
} else {
throw new Error ( _ ( 'The Joplin mobile app does not currently support this type of link: %s' , BaseModel . modelTypeToName ( item . type_ ) ) ) ;
}
} else {
2019-06-14 09:11:15 +02:00
if ( msg . indexOf ( 'file://' ) === 0 ) {
throw new Error ( _ ( 'Links with protocol "%s" are not supported' , 'file://' ) ) ;
} else {
2023-11-30 01:12:34 +02:00
await Linking . openURL ( msg ) ;
2019-06-14 09:11:15 +02:00
}
2018-05-02 16:13:20 +02:00
}
} catch ( error ) {
2020-06-13 17:20:59 +02:00
dialogs . error ( this , error . message ) ;
2018-05-02 16:13:20 +02:00
}
2019-07-30 09:35:42 +02:00
} ;
2018-10-13 00:25:11 +02:00
2020-11-12 21:13:28 +02:00
this . refreshResource = async ( resource : any , noteBody : string = null ) = > {
2019-10-12 20:49:10 +02:00
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 ) {
2019-05-22 16:56:07 +02:00
shared . clearResourceCache ( ) ;
2019-10-12 20:49:10 +02:00
const attachedResources = await shared . attachedResources ( noteBody ) ;
2020-10-16 17:26:19 +02:00
this . setState ( { noteResources : attachedResources } ) ;
2018-10-13 00:25:11 +02:00
}
2019-07-30 09:35:42 +02:00
} ;
2018-10-13 11:32:44 +02:00
2018-12-07 02:07:10 +02:00
this . takePhoto_onPress = this . takePhoto_onPress . bind ( this ) ;
2018-10-13 11:32:44 +02:00
this . cameraView_onPhoto = this . cameraView_onPhoto . bind ( this ) ;
this . cameraView_onCancel = this . cameraView_onCancel . bind ( this ) ;
2019-07-11 19:23:29 +02:00
this . properties_onPress = this . properties_onPress . bind ( this ) ;
2020-03-13 21:58:17 +02:00
this . showOnMap_onPress = this . showOnMap_onPress . bind ( this ) ;
2019-05-22 16:56:07 +02:00
this . onMarkForDownload = this . onMarkForDownload . bind ( this ) ;
2019-07-11 19:23:29 +02:00
this . sideMenuOptions = this . sideMenuOptions . bind ( this ) ;
2019-07-12 19:32:08 +02:00
this . folderPickerOptions_valueChanged = this . folderPickerOptions_valueChanged . bind ( this ) ;
this . saveNoteButton_press = this . saveNoteButton_press . bind ( this ) ;
2019-07-12 20:36:12 +02:00
this . onAlarmDialogAccept = this . onAlarmDialogAccept . bind ( this ) ;
this . onAlarmDialogReject = this . onAlarmDialogReject . bind ( this ) ;
this . todoCheckbox_change = this . todoCheckbox_change . bind ( this ) ;
this . title_changeText = this . title_changeText . bind ( this ) ;
2020-06-13 17:20:18 +02:00
this . undoRedoService_stackChange = this . undoRedoService_stackChange . bind ( this ) ;
this . screenHeader_undoButtonPress = this . screenHeader_undoButtonPress . bind ( this ) ;
this . screenHeader_redoButtonPress = this . screenHeader_redoButtonPress . bind ( this ) ;
2020-10-16 17:26:19 +02:00
this . onBodyViewerLoadEnd = this . onBodyViewerLoadEnd . bind ( this ) ;
this . onBodyViewerCheckboxChange = this . onBodyViewerCheckboxChange . bind ( this ) ;
2021-07-13 20:13:13 +02:00
this . onUndoRedoDepthChange = this . onUndoRedoDepthChange . bind ( this ) ;
2023-05-07 13:05:41 +02:00
this . voiceTypingDialog_onText = this . voiceTypingDialog_onText . bind ( this ) ;
this . voiceTypingDialog_onDismiss = this . voiceTypingDialog_onDismiss . bind ( this ) ;
2021-07-13 20:13:13 +02:00
}
private useEditorBeta ( ) : boolean {
return this . props . useEditorBeta ;
}
private onUndoRedoDepthChange ( event : UndoRedoDepthChangeEvent ) {
if ( this . useEditorBeta ( ) ) {
this . setState ( { undoRedoButtonState : {
canUndo : ! ! event . undoDepth ,
canRedo : ! ! event . redoDepth ,
} } ) ;
}
2020-06-13 17:20:18 +02:00
}
2021-07-13 20:13:13 +02:00
private undoRedoService_stackChange() {
if ( ! this . useEditorBeta ( ) ) {
this . setState ( { undoRedoButtonState : {
canUndo : this.undoRedoService_.canUndo ,
canRedo : this.undoRedoService_.canRedo ,
} } ) ;
}
2020-06-13 17:20:18 +02:00
}
2023-12-08 12:12:23 +02:00
private async undoRedo ( type : 'undo' | 'redo' ) {
2020-06-13 17:20:18 +02:00
const undoState = await this . undoRedoService_ [ type ] ( this . undoState ( ) ) ;
if ( ! undoState ) return ;
2020-11-12 21:13:28 +02:00
this . setState ( ( state : any ) = > {
2023-06-01 13:02:36 +02:00
const newNote = { . . . state . note } ;
2020-06-13 17:20:18 +02:00
newNote . body = undoState . body ;
return {
note : newNote ,
} ;
} ) ;
}
2023-03-06 16:22:01 +02:00
private screenHeader_undoButtonPress() {
2021-07-13 20:13:13 +02:00
if ( this . useEditorBeta ( ) ) {
this . editorRef . current . undo ( ) ;
} else {
void this . undoRedo ( 'undo' ) ;
}
2020-06-13 17:20:18 +02:00
}
2023-03-06 16:22:01 +02:00
private screenHeader_redoButtonPress() {
2021-07-13 20:13:13 +02:00
if ( this . useEditorBeta ( ) ) {
this . editorRef . current . redo ( ) ;
} else {
void this . undoRedo ( 'redo' ) ;
}
}
2023-03-06 16:22:01 +02:00
public undoState ( noteBody : string = null ) {
2021-07-13 20:13:13 +02:00
return {
body : noteBody === null ? this . state.note.body : noteBody ,
} ;
2017-07-15 01:12:32 +02:00
}
2023-03-06 16:22:01 +02:00
public styles() {
2020-09-15 15:01:07 +02:00
const themeId = this . props . themeId ;
2017-08-01 20:53:50 +02:00
const theme = themeStyle ( themeId ) ;
2019-07-12 20:36:12 +02:00
const cacheKey = [ themeId , this . state . titleTextInputHeight , this . state . HACK_webviewLoadingState ] . join ( '_' ) ;
if ( this . styles_ [ cacheKey ] ) return this . styles_ [ cacheKey ] ;
2017-08-01 20:53:50 +02:00
this . styles_ = { } ;
2020-03-25 12:50:45 +02:00
// TODO: Clean up these style names and nesting
2020-11-12 21:13:28 +02:00
const styles : any = {
2020-06-20 12:14:01 +02:00
screen : {
flex : 1 ,
backgroundColor : theme.backgroundColor ,
} ,
2017-08-01 20:53:50 +02:00
bodyTextInput : {
flex : 1 ,
paddingLeft : theme.marginLeft ,
paddingRight : theme.marginRight ,
2020-04-21 00:31:21 +02:00
2020-04-08 02:22:18 +02:00
// Add extra space to allow scrolling past end of document, and also to fix this:
// https://github.com/laurent22/joplin/issues/1437
2020-04-21 00:31:21 +02:00
// 2020-04-20: removed bottom padding because it doesn't work properly in Android
// Instead of being inside the scrollable area, the padding is outside thus
// restricting the view.
// See https://github.com/laurent22/joplin/issues/3041#issuecomment-616267739
// paddingBottom: Math.round(dimensions.height / 4),
2018-03-09 22:59:12 +02:00
textAlignVertical : 'top' ,
2017-08-01 20:53:50 +02:00
color : theme.color ,
backgroundColor : theme.backgroundColor ,
2023-01-07 19:47:52 +02:00
fontSize : this.props.editorFontSize ,
2019-09-17 22:32:00 +02:00
fontFamily : editorFont ( this . props . editorFont ) ,
2017-08-01 20:53:50 +02:00
} ,
noteBodyViewer : {
flex : 1 ,
2020-03-25 12:50:45 +02:00
} ,
2019-07-12 20:36:12 +02:00
checkbox : {
color : theme.color ,
paddingRight : 10 ,
paddingLeft : theme.marginLeft ,
paddingTop : 10 , // Added for iOS (Not needed for Android??)
paddingBottom : 10 , // Added for iOS (Not needed for Android??)
} ,
2020-03-25 12:50:45 +02:00
markdownButtons : {
borderColor : theme.dividerColor ,
2020-06-10 23:08:59 +02:00
color : theme.urlColor ,
2020-03-25 12:50:45 +02:00
} ,
2017-08-01 20:53:50 +02:00
} ;
2020-10-16 17:26:19 +02:00
styles . noteBodyViewerPreview = {
. . . styles . noteBodyViewer ,
borderTopColor : theme.dividerColor ,
borderTopWidth : 1 ,
borderBottomColor : theme.dividerColor ,
borderBottomWidth : 1 ,
} ;
2017-08-01 20:53:50 +02:00
styles . titleContainer = {
flex : 0 ,
2018-03-09 22:59:12 +02:00
flexDirection : 'row' ,
2017-08-01 20:53:50 +02:00
paddingLeft : theme.marginLeft ,
paddingRight : theme.marginRight ,
borderBottomColor : theme.dividerColor ,
borderBottomWidth : 1 ,
} ;
2023-06-01 13:02:36 +02:00
styles . titleContainerTodo = { . . . styles . titleContainer } ;
2017-08-21 20:32:43 +02:00
styles . titleContainerTodo . paddingLeft = 0 ;
2017-08-01 20:53:50 +02:00
2019-07-12 20:36:12 +02:00
styles . titleTextInput = {
flex : 1 ,
marginTop : 0 ,
paddingLeft : 0 ,
color : theme.color ,
backgroundColor : theme.backgroundColor ,
fontWeight : 'bold' ,
fontSize : theme.fontSize ,
paddingTop : 10 , // Added for iOS (Not needed for Android??)
paddingBottom : 10 , // Added for iOS (Not needed for Android??)
} ;
if ( this . state . HACK_webviewLoadingState === 1 ) styles . titleTextInput . marginTop = 1 ;
this . styles_ [ cacheKey ] = StyleSheet . create ( styles ) ;
return this . styles_ [ cacheKey ] ;
2017-08-01 20:53:50 +02:00
}
2023-03-06 16:22:01 +02:00
public isModified() {
2017-11-05 02:49:23 +02:00
return shared . isModified ( this ) ;
2017-05-12 22:23:54 +02:00
}
2023-03-06 16:22:01 +02:00
public async requestGeoLocationPermissions() {
2020-10-16 17:26:19 +02:00
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 the user simply pressed "Deny", we don't automatically switch it off because they might accept
// once we show the rationale again on second try. If they press "Never again" however we switch it off.
// https://github.com/zoontek/react-native-permissions/issues/385#issuecomment-563132396
if ( response === PermissionsAndroid . RESULTS . NEVER_ASK_AGAIN ) {
reg . logger ( ) . info ( 'Geo-location tracking has been automatically disabled' ) ;
Setting . setValue ( 'trackLocation' , false ) ;
}
}
2023-03-06 16:22:01 +02:00
public async componentDidMount() {
2017-08-24 20:10:03 +02:00
BackButtonService . addHandler ( this . backHandler ) ;
2018-02-22 00:08:34 +02:00
NavService . addHandler ( this . navHandler ) ;
2017-07-15 01:12:32 +02:00
2019-05-22 16:56:07 +02:00
shared . clearResourceCache ( ) ;
shared . installResourceHandling ( this . refreshResource ) ;
2018-10-13 00:25:11 +02:00
2017-11-05 02:49:23 +02:00
await shared . initState ( this ) ;
2017-07-23 16:11:44 +02:00
2020-06-13 17:20:18 +02:00
this . undoRedoService_ = new UndoRedoService ( ) ;
this . undoRedoService_ . on ( 'stackChange' , this . undoRedoService_stackChange ) ;
2019-05-22 16:56:07 +02:00
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 ) ;
}
2020-10-16 17:26:19 +02:00
// Although it is async, we don't wait for the answer so that if permission
// has already been granted, it doesn't slow down opening the note. If it hasn't
// been granted, the popup will open anyway.
2020-11-25 16:40:25 +02:00
void this . requestGeoLocationPermissions ( ) ;
2017-07-13 23:50:21 +02:00
}
2023-03-06 16:22:01 +02:00
public onMarkForDownload ( event : any ) {
2021-01-22 19:41:11 +02:00
void ResourceFetcher . instance ( ) . markForDownload ( event . resourceId ) ;
2019-05-22 16:56:07 +02:00
}
2023-10-02 16:15:51 +02:00
public componentDidUpdate ( prevProps : any , prevState : any ) {
2019-06-27 00:21:12 +02:00
if ( this . doFocusUpdate_ ) {
this . doFocusUpdate_ = false ;
this . focusUpdate ( ) ;
}
2019-07-21 15:11:30 +02:00
if ( prevProps . showSideMenu !== this . props . showSideMenu && this . props . showSideMenu ) {
this . props . dispatch ( {
type : 'NOTE_SIDE_MENU_OPTIONS_SET' ,
options : this.sideMenuOptions ( ) ,
} ) ;
}
2023-10-02 16:15:51 +02:00
if ( prevState . isLoading !== this . state . isLoading && ! this . state . isLoading ) {
// If there's autosave data, prompt the user to restore it.
void promptRestoreAutosave ( ( drawingData : string ) = > {
void this . attachNewDrawing ( drawingData ) ;
} ) ;
}
// Disable opening/closing the side menu with touch gestures
// when the image editor is open.
if ( prevState . showImageEditor !== this . state . showImageEditor ) {
this . props . dispatch ( {
type : 'SET_SIDE_MENU_TOUCH_GESTURES_DISABLED' ,
disableSideMenuGestures : this.state.showImageEditor ,
} ) ;
}
2024-03-14 21:04:32 +02:00
2024-03-21 12:48:35 +02:00
if ( prevProps . noteId && this . props . noteId && prevProps . noteId !== this . props . noteId ) {
2024-03-14 21:04:32 +02:00
// Easier to just go back, then go to the note since
// the Note screen doesn't handle reloading a different note
const noteId = this . props . noteId ;
const noteHash = this . props . noteHash ;
this . props . dispatch ( {
type : 'NAV_GO' ,
routeName : 'Notes' ,
folderId : this.state.note.parent_id ,
} ) ;
shim . setTimeout ( ( ) = > {
this . props . dispatch ( {
type : 'NAV_GO' ,
routeName : 'Note' ,
noteId : noteId ,
noteHash : noteHash ,
} ) ;
} , 5 ) ;
}
2019-06-27 00:21:12 +02:00
}
2023-03-06 16:22:01 +02:00
public componentWillUnmount() {
2017-08-24 20:10:03 +02:00
BackButtonService . removeHandler ( this . backHandler ) ;
2018-02-22 00:08:34 +02:00
NavService . removeHandler ( this . navHandler ) ;
2018-07-20 11:04:25 +02:00
2019-05-22 16:56:07 +02:00
shared . uninstallResourceHandling ( this . refreshResource ) ;
2018-10-13 00:25:11 +02:00
2020-06-10 01:30:32 +02:00
this . saveActionQueue ( this . state . note . id ) . processAllNow ( ) ;
2020-06-13 17:20:18 +02:00
2020-10-08 12:49:39 +02:00
// It cannot theoretically be undefined, since componentDidMount should always be called before
// componentWillUnmount, but with React Native the impossible often becomes possible.
if ( this . undoRedoService_ ) this . undoRedoService_ . off ( 'stackChange' , this . undoRedoService_stackChange ) ;
2017-07-13 20:47:31 +02:00
}
2023-03-06 16:22:01 +02:00
private title_changeText ( text : string ) {
2018-03-09 22:59:12 +02:00
shared . noteComponent_change ( this , 'title' , text ) ;
2018-09-29 16:57:18 +02:00
this . setState ( { newAndNoTitleChangeNoteId : null } ) ;
2017-05-12 22:23:54 +02:00
}
2024-03-20 13:02:10 +02:00
private onPlainEditorTextChange = ( text : string ) = > {
2020-06-13 17:20:18 +02:00
if ( ! this . undoRedoService_ . canUndo ) {
this . undoRedoService_ . push ( this . undoState ( ) ) ;
} else {
this . undoRedoService_ . schedulePush ( this . undoState ( ) ) ;
}
2021-07-13 20:13:13 +02:00
2018-03-09 22:59:12 +02:00
shared . noteComponent_change ( this , 'body' , text ) ;
2024-03-20 13:02:10 +02:00
} ;
// Avoid saving immediately -- the NoteEditor's content isn't controlled by its props
// and updating this.state.note immediately causes slow rerenders.
//
// See https://github.com/laurent22/joplin/issues/10130
private onMarkdownEditorTextChange = debounce ( ( event : EditorChangeEvent ) = > {
shared . noteComponent_change ( this , 'body' , event . value ) ;
} , 100 ) ;
2019-06-27 00:26:26 +02:00
2024-02-26 12:16:23 +02:00
private onPlainEditorSelectionChange = ( event : NativeSyntheticEvent < any > ) = > {
2023-12-17 22:58:22 +02:00
this . selection = event . nativeEvent . selection ;
} ;
private onMarkdownEditorSelectionChange = ( event : SelectionRangeChangeEvent ) = > {
this . selection = { start : event.from , end : event.to } ;
} ;
2020-06-13 17:20:18 +02:00
2023-03-06 16:22:01 +02:00
public makeSaveAction() {
2020-06-10 01:30:32 +02:00
return async ( ) = > {
2023-02-18 17:31:59 +02:00
return shared . saveNoteButton_press ( this , null , null ) ;
2020-06-10 01:30:32 +02:00
} ;
}
2023-03-06 16:22:01 +02:00
public saveActionQueue ( noteId : string ) {
2020-06-10 01:30:32 +02:00
if ( ! this . saveActionQueues_ [ noteId ] ) {
this . saveActionQueues_ [ noteId ] = new AsyncActionQueue ( 500 ) ;
2019-06-27 00:26:26 +02:00
}
2020-06-10 01:30:32 +02:00
return this . saveActionQueues_ [ noteId ] ;
}
2019-06-27 00:26:26 +02:00
2023-03-06 16:22:01 +02:00
public scheduleSave() {
2020-06-10 01:30:32 +02:00
this . saveActionQueue ( this . state . note . id ) . push ( this . makeSaveAction ( ) ) ;
2017-07-22 20:16:16 +02:00
}
2023-03-06 16:22:01 +02:00
private async saveNoteButton_press ( folderId : string = null ) {
2023-02-18 17:31:59 +02:00
await shared . saveNoteButton_press ( this , folderId , null ) ;
2017-07-17 22:22:05 +02:00
2017-09-24 16:48:23 +02:00
Keyboard . dismiss ( ) ;
2017-05-12 22:23:54 +02:00
}
2023-03-06 16:22:01 +02:00
public async saveOneProperty ( name : string , value : any ) {
2017-11-05 02:49:23 +02:00
await shared . saveOneProperty ( this , name , value ) ;
2017-07-22 20:16:16 +02:00
}
2023-03-06 16:22:01 +02:00
private async deleteNote_onPress() {
2020-03-14 01:46:14 +02:00
const note = this . state . note ;
2017-07-15 01:12:32 +02:00
if ( ! note . id ) return ;
2020-03-14 01:46:14 +02:00
const folderId = note . parent_id ;
2017-07-15 01:12:32 +02:00
2024-03-09 12:33:05 +02:00
await Note . delete ( note . id , { toTrash : true , sourceDescription : 'Delete note button' } ) ;
2017-07-16 23:17:22 +02:00
2017-07-25 20:09:01 +02:00
this . props . dispatch ( {
2018-03-09 22:59:12 +02:00
type : 'NAV_GO' ,
routeName : 'Notes' ,
2017-07-25 20:09:01 +02:00
folderId : folderId ,
} ) ;
2017-06-04 17:01:52 +02:00
}
2022-09-11 17:58:36 +02:00
private async pickDocuments() {
2023-11-30 01:12:34 +02:00
const result = await pickDocument ( true ) ;
2022-10-13 23:02:06 +02:00
return result ;
2017-08-01 23:40:14 +02:00
}
2023-11-30 01:12:34 +02:00
public async imageDimensions ( uri : string ) : Promise < Size > {
2017-08-02 19:47:25 +02:00
return new Promise ( ( resolve , reject ) = > {
2019-07-30 09:35:42 +02:00
Image . getSize (
uri ,
2020-11-12 21:13:28 +02:00
( width : number , height : number ) = > {
2019-07-30 09:35:42 +02:00
resolve ( { width : width , height : height } ) ;
} ,
2020-11-12 21:13:28 +02:00
( error : any ) = > {
2019-07-30 09:35:42 +02:00
reject ( error ) ;
2023-08-22 12:58:53 +02:00
} ,
2019-07-30 09:35:42 +02:00
) ;
2017-08-02 19:47:25 +02:00
} ) ;
}
2023-03-06 16:22:01 +02:00
public async resizeImage ( localFilePath : string , targetPath : string , mimeType : string ) {
2017-11-19 17:18:07 +02:00
const maxSize = Resource . IMAGE_MAX_DIMENSION ;
2023-11-30 01:12:34 +02:00
const dimensions = await this . imageDimensions ( localFilePath ) ;
2018-03-09 22:59:12 +02:00
reg . logger ( ) . info ( 'Original dimensions ' , dimensions ) ;
2020-03-31 23:40:38 +02:00
2023-08-08 16:49:54 +02:00
const saveOriginalImage = async ( ) = > {
await shim . fsDriver ( ) . copy ( localFilePath , targetPath ) ;
return true ;
} ;
const saveResizedImage = async ( ) = > {
2017-11-19 17:18:07 +02:00
dimensions . width = maxSize ;
dimensions . height = maxSize ;
2020-03-31 23:40:38 +02:00
reg . logger ( ) . info ( 'New dimensions ' , dimensions ) ;
2017-12-19 22:14:40 +02:00
2022-07-23 09:31:32 +02:00
const format = mimeType === 'image/png' ? 'PNG' : 'JPEG' ;
2020-03-31 23:40:38 +02:00
reg . logger ( ) . info ( ` Resizing image ${ localFilePath } ` ) ;
2023-08-18 10:34:31 +02:00
const resizedImage = await ImageResizer . createResizedImage (
localFilePath ,
dimensions . width ,
dimensions . height ,
format ,
85 , // quality
undefined , // rotation
undefined , // outputPath
2023-08-22 12:58:53 +02:00
true , // keep metadata
2023-08-18 10:34:31 +02:00
) ;
2017-12-19 22:14:40 +02:00
2020-03-31 23:40:38 +02:00
const resizedImagePath = resizedImage . uri ;
reg . logger ( ) . info ( 'Resized image ' , resizedImagePath ) ;
reg . logger ( ) . info ( ` Moving ${ resizedImagePath } => ${ targetPath } ` ) ;
2017-12-19 22:14:40 +02:00
2022-10-13 23:02:06 +02:00
await shim . fsDriver ( ) . copy ( resizedImagePath , targetPath ) ;
2020-03-31 23:40:38 +02:00
try {
2022-10-13 23:02:06 +02:00
await shim . fsDriver ( ) . unlink ( resizedImagePath ) ;
2020-03-31 23:40:38 +02:00
} catch ( error ) {
reg . logger ( ) . warn ( 'Error when unlinking cached file: ' , error ) ;
}
2023-08-08 16:49:54 +02:00
return true ;
} ;
const canResize = dimensions . width > maxSize || dimensions . height > maxSize ;
if ( canResize ) {
const resizeLargeImages = Setting . value ( 'imageResizing' ) ;
if ( resizeLargeImages === 'alwaysAsk' ) {
const userAnswer = 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 ) } \ n \ n ${ _ ( '(You may disable this prompt in the options)' ) } ` , [
{ text : _ ( 'Yes' ) , id : 'yes' } ,
{ text : _ ( 'No' ) , id : 'no' } ,
{ text : _ ( 'Cancel' ) , id : 'cancel' } ,
] ) ;
if ( userAnswer === 'yes' ) return await saveResizedImage ( ) ;
if ( userAnswer === 'no' ) return await saveOriginalImage ( ) ;
if ( userAnswer === 'cancel' ) return false ;
} else if ( resizeLargeImages === 'alwaysResize' ) {
return await saveResizedImage ( ) ;
}
2017-11-19 17:18:07 +02:00
}
2020-03-31 23:40:38 +02:00
2023-08-08 16:49:54 +02:00
return await saveOriginalImage ( ) ;
2017-11-19 17:18:07 +02:00
}
2023-11-30 01:12:34 +02:00
public async attachFile ( pickerResponse : Asset , fileType : string ) : Promise < ResourceEntity | null > {
2017-11-19 17:18:07 +02:00
if ( ! pickerResponse ) {
2020-10-16 17:26:19 +02:00
// User has cancelled
2023-10-02 16:15:51 +02:00
return null ;
2017-11-01 19:39:56 +02:00
}
2017-08-01 23:40:14 +02:00
2019-07-21 15:39:52 +02:00
const localFilePath = Platform . select ( {
android : pickerResponse.uri ,
ios : decodeURI ( pickerResponse . uri ) ,
} ) ;
2019-07-30 09:35:42 +02:00
2017-11-20 00:08:58 +02:00
let mimeType = pickerResponse . type ;
if ( ! mimeType ) {
const ext = fileExtension ( localFilePath ) ;
mimeType = mimeUtils . fromFileExtension ( ext ) ;
}
2017-08-02 19:47:25 +02:00
2018-03-09 22:59:12 +02:00
if ( ! mimeType && fileType === 'image' ) {
2017-11-20 21:18:49 +02:00
// Assume JPEG if we couldn't determine the file type. It seems to happen with the image picker
// when the file path is something like content://media/external/images/media/123456
// If the image is not a JPEG, something will throw an error below, but there's a good chance
// it will work.
2018-03-09 22:59:12 +02:00
reg . logger ( ) . info ( 'Missing file type and could not detect it - assuming image/jpg' ) ;
mimeType = 'image/jpg' ;
2017-11-20 21:18:49 +02:00
}
2019-09-19 23:51:18 +02:00
reg . logger ( ) . info ( ` Got file: ${ localFilePath } ` ) ;
reg . logger ( ) . info ( ` Got type: ${ mimeType } ` ) ;
2017-08-01 23:40:14 +02:00
2023-10-02 16:15:51 +02:00
let resource : ResourceEntity = Resource . new ( ) ;
2017-08-01 23:40:14 +02:00
resource . id = uuid . create ( ) ;
2017-11-20 00:08:58 +02:00
resource . mime = mimeType ;
2023-11-30 01:12:34 +02:00
resource . title = pickerResponse . fileName ? pickerResponse . fileName : '' ;
resource . file_extension = safeFileExtension ( fileExtension ( pickerResponse . fileName ? pickerResponse.fileName : localFilePath ) ) ;
2017-12-02 01:15:49 +02:00
2018-03-09 22:59:12 +02:00
if ( ! resource . mime ) resource . mime = 'application/octet-stream' ;
2017-08-01 23:40:14 +02:00
2020-03-14 01:46:14 +02:00
const targetPath = Resource . fullPath ( resource ) ;
2017-08-02 19:47:25 +02:00
2017-11-20 00:08:58 +02:00
try {
2022-07-23 09:31:32 +02:00
if ( mimeType === 'image/jpeg' || mimeType === 'image/jpg' || mimeType === 'image/png' ) {
2020-06-04 19:40:44 +02:00
const done = await this . resizeImage ( localFilePath , targetPath , mimeType ) ;
2023-10-02 16:15:51 +02:00
if ( ! done ) return null ;
2017-11-19 17:18:07 +02:00
} else {
2023-10-02 16:15:51 +02:00
if ( fileType === 'image' && mimeType !== 'image/svg+xml' ) {
2020-06-13 17:20:59 +02:00
dialogs . error ( this , _ ( 'Unsupported image type: %s' , mimeType ) ) ;
2023-10-02 16:15:51 +02:00
return null ;
2017-11-20 00:08:58 +02:00
} else {
2018-10-07 19:55:49 +02:00
await shim . fsDriver ( ) . copy ( localFilePath , targetPath ) ;
2018-05-03 12:31:07 +02:00
const stat = await shim . fsDriver ( ) . stat ( targetPath ) ;
2022-10-13 23:02:06 +02:00
2022-09-14 13:21:21 +02:00
if ( stat . size >= 200 * 1024 * 1024 ) {
2018-05-03 12:31:07 +02:00
await shim . fsDriver ( ) . remove ( targetPath ) ;
2022-09-14 13:21:21 +02:00
throw new Error ( 'Resources larger than 200 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.' ) ;
2018-05-03 12:31:07 +02:00
}
2017-11-20 00:08:58 +02:00
}
2017-08-02 19:47:25 +02:00
}
2017-11-20 00:08:58 +02:00
} catch ( error ) {
2018-03-09 22:59:12 +02:00
reg . logger ( ) . warn ( 'Could not attach file:' , error ) ;
2020-06-13 17:20:59 +02:00
await dialogs . error ( this , error . message ) ;
2023-10-02 16:15:51 +02:00
return null ;
2017-08-02 19:47:25 +02:00
}
2017-08-01 23:40:14 +02:00
2019-05-12 02:15:52 +02:00
const itDoes = await shim . fsDriver ( ) . waitTillExists ( targetPath ) ;
2019-09-19 23:51:18 +02:00
if ( ! itDoes ) throw new Error ( ` Resource file was not created: ${ targetPath } ` ) ;
2019-05-12 02:15:52 +02:00
2019-05-11 18:55:40 +02:00
const fileStat = await shim . fsDriver ( ) . stat ( targetPath ) ;
resource . size = fileStat . size ;
2019-06-15 22:23:30 +02:00
resource = await Resource . save ( resource , { isNew : true } ) ;
2017-08-01 23:40:14 +02:00
2023-10-31 18:53:47 +02:00
const resourceTag = Resource . markupTag ( resource ) ;
2017-07-13 20:47:31 +02:00
2023-06-01 13:02:36 +02:00
const newNote = { . . . this . state . note } ;
2020-05-20 18:46:01 +02:00
2023-06-10 18:04:45 +02:00
if ( this . state . mode === 'edit' ) {
let newText = '' ;
if ( this . selection ) {
newText = ` \ n ${ resourceTag } \ n ` ;
const prefix = newNote . body . substring ( 0 , this . selection . start ) ;
const suffix = newNote . body . substring ( this . selection . end ) ;
newNote . body = ` ${ prefix } ${ newText } ${ suffix } ` ;
} else {
newText = ` \ n ${ resourceTag } ` ;
newNote . body = ` ${ newNote . body } \ n ${ newText } ` ;
}
2022-04-11 12:56:45 +02:00
if ( this . useEditorBeta ( ) ) {
// The beta editor needs to be explicitly informed of changes
// to the note's body
2023-11-27 21:14:04 +02:00
if ( this . editorRef . current ) {
this . editorRef . current . insertText ( newText ) ;
} else {
logger . error ( ` Tried to attach resource ${ resource . id } to the note when the editor is not visible! ` ) ;
}
2022-04-11 12:56:45 +02:00
}
2020-05-20 18:46:01 +02:00
} else {
newNote . body += ` \ n ${ resourceTag } ` ;
}
2017-08-02 19:47:25 +02:00
this . setState ( { note : newNote } ) ;
2019-06-15 22:23:30 +02:00
2023-12-08 12:12:23 +02:00
void this . refreshResource ( resource , newNote . body ) ;
2019-07-11 18:41:13 +02:00
this . scheduleSave ( ) ;
2023-10-02 16:15:51 +02:00
return resource ;
2017-07-13 20:47:31 +02:00
}
2022-09-09 16:06:03 +02:00
private async attachPhoto_onPress() {
2024-02-26 12:16:23 +02:00
// the selection Limit should be specified. I think 200 is enough?
2022-12-22 14:38:11 +02:00
const response : ImagePickerResponse = await launchImageLibrary ( { mediaType : 'photo' , includeBase64 : false , selectionLimit : 200 } ) ;
2022-09-11 17:58:36 +02:00
if ( response . errorCode ) {
reg . logger ( ) . warn ( 'Got error from picker' , response . errorCode ) ;
return ;
}
if ( response . didCancel ) {
reg . logger ( ) . info ( 'User cancelled picker' ) ;
return ;
}
for ( const asset of response . assets ) {
await this . attachFile ( asset , 'image' ) ;
}
2017-11-19 17:18:07 +02:00
}
2023-03-06 16:22:01 +02:00
private takePhoto_onPress() {
2018-10-13 11:32:44 +02:00
this . setState ( { showCamera : true } ) ;
2019-07-30 09:35:42 +02:00
}
2018-10-13 11:32:44 +02:00
2023-03-06 16:22:01 +02:00
private cameraView_onPhoto ( data : any ) {
2020-11-25 16:40:25 +02:00
void this . attachFile (
2019-07-30 09:35:42 +02:00
{
uri : data.uri ,
type : 'image/jpg' ,
} ,
2023-08-22 12:58:53 +02:00
'image' ,
2019-07-30 09:35:42 +02:00
) ;
2018-10-13 11:32:44 +02:00
this . setState ( { showCamera : false } ) ;
}
2023-03-06 16:22:01 +02:00
private cameraView_onCancel() {
2018-10-13 11:32:44 +02:00
this . setState ( { showCamera : false } ) ;
}
2023-10-02 16:15:51 +02:00
private async attachNewDrawing ( svgData : string ) {
const filePath = ` ${ Setting . value ( 'resourceDir' ) } /saved-drawing.joplin.svg ` ;
await shim . fsDriver ( ) . writeFile ( filePath , svgData , 'utf8' ) ;
logger . info ( 'Saved new drawing to' , filePath ) ;
return await this . attachFile ( {
uri : filePath ,
2023-11-30 01:12:34 +02:00
fileName : _ ( 'Drawing' ) ,
2023-10-02 16:15:51 +02:00
} , 'image' ) ;
}
private async updateDrawing ( svgData : string ) {
let resource : ResourceEntity | null = this . state . imageEditorResource ;
if ( ! resource ) {
2023-11-27 21:14:04 +02:00
resource = await this . attachNewDrawing ( svgData ) ;
2024-02-26 12:16:23 +02:00
// Set resource and file path to allow
2023-11-27 21:14:04 +02:00
// 1. subsequent saves to update the resource
// 2. the editor to load from the resource's filepath (can happen
// if the webview is reloaded).
this . setState ( {
imageEditorResourceFilepath : Resource.fullPath ( resource ) ,
imageEditorResource : resource ,
} ) ;
} else {
logger . info ( 'Saving drawing to resource' , resource . id ) ;
2023-10-02 16:15:51 +02:00
2023-11-27 21:14:04 +02:00
const tempFilePath = join ( Setting . value ( 'tempDir' ) , uuid . createNano ( ) ) ;
await shim . fsDriver ( ) . writeFile ( tempFilePath , svgData , 'utf8' ) ;
2023-10-31 00:17:49 +02:00
2023-11-27 21:14:04 +02:00
resource = await Resource . updateResourceBlobContent (
resource . id ,
tempFilePath ,
) ;
await shim . fsDriver ( ) . remove ( tempFilePath ) ;
2023-10-02 16:15:51 +02:00
2023-11-27 21:14:04 +02:00
await this . refreshResource ( resource ) ;
}
2023-10-02 16:15:51 +02:00
}
private onSaveDrawing = async ( svgData : string ) = > {
await this . updateDrawing ( svgData ) ;
} ;
private onCloseDrawing = ( ) = > {
this . setState ( { showImageEditor : false } ) ;
} ;
2023-11-27 21:14:04 +02:00
private drawPicture_onPress = async ( ) = > {
if ( this . state . mode === 'edit' ) {
// Create a new empty drawing and attach it now, before the image editor is opened.
// With the present structure of Note.tsx, the we can't use this.editorRef while
2024-02-26 12:16:23 +02:00
// the image editor is open, and thus can't attach drawings at the cursor location.
2023-11-27 21:14:04 +02:00
const resource = await this . attachNewDrawing ( '' ) ;
await this . editDrawing ( resource ) ;
} else {
logger . info ( 'Showing image editor...' ) ;
this . setState ( {
showImageEditor : true ,
imageEditorResourceFilepath : null ,
imageEditorResource : null ,
} ) ;
}
} ;
2023-10-02 16:15:51 +02:00
private async editDrawing ( item : BaseItem ) {
const filePath = Resource . fullPath ( item ) ;
this . setState ( {
showImageEditor : true ,
2023-11-12 17:06:16 +02:00
imageEditorResourceFilepath : filePath ,
2023-10-02 16:15:51 +02:00
imageEditorResource : item ,
} ) ;
}
private onEditResource = async ( message : string ) = > {
const messageData = /^edit:(.*)$/ . exec ( message ) ;
if ( ! messageData ) {
throw new Error ( 'onEditResource: Error: Invalid message' ) ;
}
const resourceId = messageData [ 1 ] ;
const resource = await BaseItem . loadItemById ( resourceId ) ;
await Resource . requireIsReady ( resource ) ;
if ( isEditableResource ( resource . mime ) ) {
await this . editDrawing ( resource ) ;
} else {
throw new Error ( _ ( 'Unable to edit resource of type %s' , resource . mime ) ) ;
}
} ;
2022-09-09 16:06:03 +02:00
private async attachFile_onPress() {
2022-09-11 17:58:36 +02:00
const response = await this . pickDocuments ( ) ;
for ( const asset of response ) {
await this . attachFile ( asset , 'all' ) ;
}
2017-11-19 17:18:07 +02:00
}
2023-03-06 16:22:01 +02:00
private toggleIsTodo_onPress() {
2017-11-05 02:49:23 +02:00
shared . toggleIsTodo_onPress ( this ) ;
2019-07-14 00:49:35 +02:00
this . scheduleSave ( ) ;
2017-07-17 22:22:05 +02:00
}
2023-03-06 16:22:01 +02:00
private tags_onPress() {
2018-03-16 22:17:52 +02:00
if ( ! this . state . note || ! this . state . note . id ) return ;
2018-03-18 01:00:01 +02:00
this . setState ( { noteTagDialogShown : true } ) ;
2018-03-16 22:17:52 +02:00
}
2023-03-06 16:22:01 +02:00
private async share_onPress() {
2018-05-10 21:39:41 +02:00
await Share . share ( {
2019-09-19 23:51:18 +02:00
message : ` ${ this . state . note . title } \ n \ n ${ this . state . note . body } ` ,
2018-05-10 21:39:41 +02:00
title : this.state.note.title ,
} ) ;
}
2023-03-06 16:22:01 +02:00
private properties_onPress() {
2019-07-11 19:23:29 +02:00
this . props . dispatch ( { type : 'SIDE_MENU_OPEN' } ) ;
}
2023-03-06 16:22:01 +02:00
public async onAlarmDialogAccept ( date : Date ) {
2023-06-10 18:02:58 +02:00
const response = await checkPermissions ( PermissionsAndroid . PERMISSIONS . POST_NOTIFICATIONS ) ;
2023-09-11 21:45:32 +02:00
// The POST_NOTIFICATIONS permission isn't supported on Android API < 33.
// (If unsupported, returns NEVER_ASK_AGAIN).
// On earlier releases, notifications should work without this permission.
if ( response === PermissionsAndroid . RESULTS . DENIED ) {
logger . warn ( 'POST_NOTIFICATIONS permission was not granted' ) ;
2023-06-10 18:02:58 +02:00
return ;
}
2023-06-01 13:02:36 +02:00
const newNote = { . . . this . state . note } ;
2017-09-10 18:56:27 +02:00
newNote . todo_due = date ? date . getTime ( ) : 0 ;
2018-03-09 22:59:12 +02:00
await this . saveOneProperty ( 'todo_due' , date ? date . getTime ( ) : 0 ) ;
2017-11-28 00:50:46 +02:00
this . setState ( { alarmDialogShown : false } ) ;
2017-09-10 18:56:27 +02:00
}
2023-03-06 16:22:01 +02:00
public onAlarmDialogReject() {
2017-09-10 18:56:27 +02:00
this . setState ( { alarmDialogShown : false } ) ;
}
2023-03-06 16:22:01 +02:00
private async showOnMap_onPress() {
2017-07-22 20:16:16 +02:00
if ( ! this . state . note . id ) return ;
2020-03-14 01:46:14 +02:00
const note = await Note . load ( this . state . note . id ) ;
2017-07-22 20:16:16 +02:00
try {
const url = Note . geolocationUrl ( note ) ;
2023-11-30 01:12:34 +02:00
await Linking . openURL ( url ) ;
2017-07-22 20:16:16 +02:00
} catch ( error ) {
2020-03-13 21:58:17 +02:00
this . props . dispatch ( { type : 'SIDE_MENU_CLOSE' } ) ;
2020-06-13 17:20:59 +02:00
await dialogs . error ( this , error . message ) ;
2017-07-22 20:16:16 +02:00
}
}
2023-03-06 16:22:01 +02:00
private async showSource_onPress() {
2018-10-12 20:30:00 +02:00
if ( ! this . state . note . id ) return ;
2020-03-14 01:46:14 +02:00
const note = await Note . load ( this . state . note . id ) ;
2018-10-12 20:30:00 +02:00
try {
2023-11-30 01:12:34 +02:00
await Linking . openURL ( note . source_url ) ;
2018-10-12 20:30:00 +02:00
} catch ( error ) {
2020-06-13 17:20:59 +02:00
await dialogs . error ( this , error . message ) ;
2018-10-12 20:30:00 +02:00
}
}
2023-03-06 16:22:01 +02:00
private copyMarkdownLink_onPress() {
2018-05-02 16:13:20 +02:00
const note = this . state . note ;
Clipboard . setString ( Note . markdownTag ( note ) ) ;
}
2023-03-06 16:22:01 +02:00
public sideMenuOptions() {
2019-07-11 19:23:29 +02:00
const note = this . state . note ;
if ( ! note ) return [ ] ;
const output = [ ] ;
2019-07-16 16:11:58 +02:00
const createdDateString = time . formatMsToLocal ( note . user_created_time ) ;
const updatedDateString = time . formatMsToLocal ( note . user_updated_time ) ;
2019-07-11 19:23:29 +02:00
output . push ( { title : _ ( 'Created: %s' , createdDateString ) } ) ;
output . push ( { title : _ ( 'Updated: %s' , updatedDateString ) } ) ;
output . push ( { isDivider : true } ) ;
2019-07-30 09:35:42 +02:00
output . push ( {
title : _ ( 'View on map' ) ,
onPress : ( ) = > {
2020-11-25 16:40:25 +02:00
void this . showOnMap_onPress ( ) ;
2019-07-30 09:35:42 +02:00
} ,
} ) ;
2020-03-14 01:57:34 +02:00
if ( note . source_url ) {
2019-07-30 09:35:42 +02:00
output . push ( {
title : _ ( 'Go to source URL' ) ,
onPress : ( ) = > {
2020-11-25 16:40:25 +02:00
void this . showSource_onPress ( ) ;
2019-07-30 09:35:42 +02:00
} ,
} ) ;
2020-03-14 01:57:34 +02:00
}
2019-07-11 19:23:29 +02:00
return output ;
}
2023-03-06 16:22:01 +02:00
public async showAttachMenu() {
2023-09-19 12:30:26 +02:00
// If the keyboard is editing a WebView, the standard Keyboard.dismiss()
// may not work. As such, we also need to call hideKeyboard on the editorRef
this . editorRef . current ? . hideKeyboard ( ) ;
2022-08-29 15:19:04 +02:00
const buttons = [ ] ;
// On iOS, it will show "local files", which means certain files saved from the browser
// and the iCloud files, but it doesn't include photos and images from the CameraRoll
//
2024-02-26 12:16:23 +02:00
// On Android, it will depend on the phone, but usually it will allow browsing all files and photos.
2022-08-29 15:19:04 +02:00
buttons . push ( { text : _ ( 'Attach file' ) , id : 'attachFile' } ) ;
// Disabled on Android because it doesn't work due to permission issues, but enabled on iOS
// because that's only way to browse photos from the camera roll.
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 ( ) ;
}
2023-05-07 18:53:19 +02:00
// private vosk_:Vosk;
// private async getVosk() {
// if (this.vosk_) return this.vosk_;
// this.vosk_ = new Vosk();
// await this.vosk_.loadModel('model-fr-fr');
// return this.vosk_;
// }
// private async voiceRecording_onPress() {
// logger.info('Vosk: Getting instance...');
// const vosk = await this.getVosk();
// this.voskResult_ = [];
// const eventHandlers: any[] = [];
// eventHandlers.push(vosk.onResult(e => {
// logger.info('Vosk: result', e.data);
// this.voskResult_.push(e.data);
// }));
// eventHandlers.push(vosk.onError(e => {
// logger.warn('Vosk: error', e.data);
// }));
// eventHandlers.push(vosk.onTimeout(e => {
// logger.warn('Vosk: timeout', e.data);
// }));
// eventHandlers.push(vosk.onFinalResult(e => {
// logger.info('Vosk: final result', e.data);
// }));
// logger.info('Vosk: Starting recording...');
// void vosk.start();
// const buttonId = await dialogs.pop(this, 'Voice recording in progress...', [
// { text: 'Stop recording', id: 'stop' },
// { text: _('Cancel'), id: 'cancel' },
// ]);
// logger.info('Vosk: Stopping recording...');
// vosk.stop();
// for (const eventHandler of eventHandlers) {
// eventHandler.remove();
// }
// logger.info('Vosk: Recording stopped:', this.voskResult_);
// if (buttonId === 'cancel') return;
// const newNote: NoteEntity = { ...this.state.note };
// newNote.body = `${newNote.body} ${this.voskResult_.join(' ')}`;
// this.setState({ note: newNote });
// this.scheduleSave();
// }
2023-03-06 16:22:01 +02:00
public menuOptions() {
2017-07-17 22:22:05 +02:00
const note = this . state . note ;
2017-09-10 18:57:06 +02:00
const isTodo = note && ! ! note . is_todo ;
2018-03-16 22:17:52 +02:00
const isSaved = note && note . id ;
2023-07-16 18:42:42 +02:00
const readOnly = this . state . readOnly ;
2024-03-02 16:25:27 +02:00
const isDeleted = ! ! this . state . note . deleted_time ;
2017-07-17 22:22:05 +02:00
2019-07-12 19:32:08 +02:00
const cacheKey = md5 ( [ isTodo , isSaved ] . join ( '_' ) ) ;
if ( ! this . menuOptionsCache_ ) this . menuOptionsCache_ = { } ;
if ( this . menuOptionsCache_ [ cacheKey ] ) return this . menuOptionsCache_ [ cacheKey ] ;
2023-07-16 18:42:42 +02:00
const output : MenuOptionType [ ] = [ ] ;
2017-09-10 18:57:06 +02:00
2024-02-26 12:16:23 +02:00
// The file attachment modules only work in Android >= 5 (Version 21)
2017-11-20 21:01:19 +02:00
// https://github.com/react-community/react-native-image-picker/issues/606
2020-10-16 17:26:19 +02:00
// As of 2020-10-13, support for attaching images from the gallery is removed
// as the package react-native-image-picker has permission issues. It's still
// possible to attach files, which has often a similar UI, with thumbnails for
// images so normally it should be enough.
2017-11-20 20:29:39 +02:00
let canAttachPicture = true ;
2018-03-09 22:59:12 +02:00
if ( Platform . OS === 'android' && Platform . Version < 21 ) canAttachPicture = false ;
2017-11-20 20:29:39 +02:00
if ( canAttachPicture ) {
2019-07-30 09:35:42 +02:00
output . push ( {
title : _ ( 'Attach...' ) ,
2022-08-29 15:19:04 +02:00
onPress : ( ) = > this . showAttachMenu ( ) ,
2023-07-16 18:42:42 +02:00
disabled : readOnly ,
2019-07-30 09:35:42 +02:00
} ) ;
2017-11-20 21:01:19 +02:00
}
2017-07-17 22:22:05 +02:00
2023-10-02 16:15:51 +02:00
output . push ( {
title : _ ( 'Draw picture' ) ,
onPress : ( ) = > this . drawPicture_onPress ( ) ,
disabled : readOnly ,
} ) ;
2017-11-28 00:50:46 +02:00
if ( isTodo ) {
2019-07-30 09:35:42 +02:00
output . push ( {
title : _ ( 'Set alarm' ) ,
onPress : ( ) = > {
this . setState ( { alarmDialogShown : true } ) ;
} ,
2023-07-16 18:42:42 +02:00
disabled : readOnly ,
2019-07-30 09:35:42 +02:00
} ) ;
2017-11-28 00:50:46 +02:00
}
2017-09-10 18:57:06 +02:00
2019-07-30 09:35:42 +02:00
output . push ( {
title : _ ( 'Share' ) ,
onPress : ( ) = > {
2020-11-25 16:40:25 +02:00
void this . share_onPress ( ) ;
2019-07-30 09:35:42 +02:00
} ,
2023-07-16 18:42:42 +02:00
disabled : readOnly ,
2019-07-30 09:35:42 +02:00
} ) ;
2023-05-03 13:19:43 +02:00
2023-05-07 13:05:41 +02:00
// Voice typing is enabled only for French language and on Android for now
2023-06-13 19:06:54 +02:00
if ( voskEnabled && shim . mobilePlatform ( ) === 'android' && isSupportedLanguage ( currentLocale ( ) ) ) {
2023-05-03 13:19:43 +02:00
output . push ( {
2023-05-07 13:05:41 +02:00
title : _ ( 'Voice typing...' ) ,
2023-05-03 13:19:43 +02:00
onPress : ( ) = > {
2023-05-07 18:53:19 +02:00
// this.voiceRecording_onPress();
2023-05-07 15:56:02 +02:00
this . setState ( { voiceTypingDialogShown : true } ) ;
2023-05-03 13:19:43 +02:00
} ,
2023-07-16 18:42:42 +02:00
disabled : readOnly ,
2023-05-03 13:19:43 +02:00
} ) ;
}
2024-03-02 16:25:27 +02:00
if ( isSaved && ! isDeleted ) {
2019-07-30 09:35:42 +02:00
output . push ( {
title : _ ( 'Tags' ) ,
onPress : ( ) = > {
this . tags_onPress ( ) ;
} ,
} ) ;
2020-03-14 01:57:34 +02:00
}
2024-03-02 16:25:27 +02:00
2019-07-30 09:35:42 +02:00
output . push ( {
title : isTodo ? _ ( 'Convert to note' ) : _ ( 'Convert to todo' ) ,
onPress : ( ) = > {
this . toggleIsTodo_onPress ( ) ;
} ,
2023-07-16 18:42:42 +02:00
disabled : readOnly ,
2019-07-30 09:35:42 +02:00
} ) ;
2024-03-02 16:25:27 +02:00
if ( isSaved && ! isDeleted ) {
2019-07-30 09:35:42 +02:00
output . push ( {
title : _ ( 'Copy Markdown link' ) ,
onPress : ( ) = > {
this . copyMarkdownLink_onPress ( ) ;
} ,
} ) ;
2020-03-14 01:57:34 +02:00
}
2024-03-02 16:25:27 +02:00
2019-07-30 09:35:42 +02:00
output . push ( {
title : _ ( 'Properties' ) ,
onPress : ( ) = > {
this . properties_onPress ( ) ;
} ,
} ) ;
2024-03-02 16:25:27 +02:00
if ( isDeleted ) {
output . push ( {
title : _ ( 'Restore' ) ,
onPress : async ( ) = > {
await restoreItems ( ModelType . Note , [ this . state . note . id ] ) ;
this . props . dispatch ( {
type : 'NAV_GO' ,
routeName : 'Notes' ,
} ) ;
} ,
} ) ;
}
2019-07-30 09:35:42 +02:00
output . push ( {
title : _ ( 'Delete' ) ,
onPress : ( ) = > {
2020-11-25 16:40:25 +02:00
void this . deleteNote_onPress ( ) ;
2019-07-30 09:35:42 +02:00
} ,
2023-07-16 18:42:42 +02:00
disabled : readOnly ,
2019-07-30 09:35:42 +02:00
} ) ;
2017-09-10 18:57:06 +02:00
2019-07-12 19:32:08 +02:00
this . menuOptionsCache_ = { } ;
this . menuOptionsCache_ [ cacheKey ] = output ;
2017-09-10 18:57:06 +02:00
return output ;
2017-06-04 17:01:52 +02:00
}
2023-03-06 16:22:01 +02:00
private async todoCheckbox_change ( checked : boolean ) {
2018-03-09 22:59:12 +02:00
await this . saveOneProperty ( 'todo_completed' , checked ? time . unixMs ( ) : 0 ) ;
2017-07-15 01:12:32 +02:00
}
2023-03-06 16:22:01 +02:00
public scheduleFocusUpdate() {
2020-10-09 19:35:46 +02:00
if ( this . focusUpdateIID_ ) shim . clearTimeout ( this . focusUpdateIID_ ) ;
2020-01-08 19:42:28 +02:00
2020-10-09 19:35:46 +02:00
this . focusUpdateIID_ = shim . setTimeout ( ( ) = > {
2020-01-08 19:42:28 +02:00
this . focusUpdateIID_ = null ;
this . focusUpdate ( ) ;
} , 100 ) ;
}
2023-03-06 16:22:01 +02:00
public focusUpdate() {
2020-10-09 19:35:46 +02:00
if ( this . focusUpdateIID_ ) shim . clearTimeout ( this . focusUpdateIID_ ) ;
2020-01-08 19:42:28 +02:00
this . focusUpdateIID_ = null ;
2019-06-27 00:21:12 +02:00
if ( ! this . state . note ) return ;
2019-07-30 09:35:42 +02:00
let fieldToFocus = this . state . note . is_todo ? 'title' : 'body' ;
2019-06-27 00:21:12 +02:00
if ( this . state . mode === 'view' ) fieldToFocus = '' ;
2023-12-24 16:54:24 +02:00
// Avoid writing `this.titleTextFieldRef.current` -- titleTextFieldRef may
// be undefined.
if ( fieldToFocus === 'title' && this . titleTextFieldRef ? . current ) {
2023-12-08 12:12:23 +02:00
this . titleTextFieldRef . current . focus ( ) ;
2020-03-25 12:50:45 +02:00
}
2020-11-05 18:58:23 +02:00
// if (fieldToFocus === 'body' && this.markdownEditorRef.current) {
// if (this.markdownEditorRef.current) {
// this.markdownEditorRef.current.focus();
// }
// }
2019-06-27 00:21:12 +02:00
}
2023-03-06 16:22:01 +02:00
private async folderPickerOptions_valueChanged ( itemValue : any ) {
2019-07-12 19:32:08 +02:00
const note = this . state . note ;
2020-06-08 09:40:52 +02:00
const isProvisionalNote = this . props . provisionalNoteIds . includes ( note . id ) ;
2019-07-12 19:32:08 +02:00
2020-06-08 09:40:52 +02:00
if ( isProvisionalNote ) {
2019-07-12 19:32:08 +02:00
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 ( {
2023-06-01 13:02:36 +02:00
lastSavedNote : { . . . note } ,
2019-07-12 19:32:08 +02:00
note : note ,
folder : folder ,
} ) ;
}
2023-03-06 16:22:01 +02:00
public folderPickerOptions() {
2019-07-12 19:32:08 +02:00
const options = {
2023-07-16 18:42:42 +02:00
enabled : ! this . state . readOnly ,
2019-07-12 19:32:08 +02:00
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_ ;
}
2023-03-06 16:22:01 +02:00
public onBodyViewerLoadEnd() {
2020-10-16 17:26:19 +02:00
shim . setTimeout ( ( ) = > {
this . setState ( { HACK_webviewLoadingState : 1 } ) ;
shim . setTimeout ( ( ) = > {
this . setState ( { HACK_webviewLoadingState : 0 } ) ;
} , 50 ) ;
} , 5 ) ;
}
2023-11-16 14:19:48 +02:00
private onBodyViewerScroll = ( scrollTop : number ) = > {
this . lastBodyScroll = scrollTop ;
} ;
2023-03-06 16:22:01 +02:00
public onBodyViewerCheckboxChange ( newBody : string ) {
2020-11-25 16:40:25 +02:00
void this . saveOneProperty ( 'body' , newBody ) ;
2020-10-16 17:26:19 +02:00
}
2023-05-07 13:05:41 +02:00
private voiceTypingDialog_onText ( text : string ) {
if ( this . state . mode === 'view' ) {
const newNote : NoteEntity = { . . . this . state . note } ;
newNote . body = ` ${ newNote . body } ${ text } ` ;
this . setState ( { note : newNote } ) ;
this . scheduleSave ( ) ;
} else {
if ( this . useEditorBeta ( ) ) {
2023-06-03 16:43:40 +02:00
// We add a space so that if the feature is used twice in a row,
// the sentences are not stuck to each others.
this . editorRef . current . insertText ( ` ${ text } ` ) ;
2023-05-07 13:05:41 +02:00
} else {
logger . warn ( 'Voice typing is not supported in plaintext editor' ) ;
}
}
}
private voiceTypingDialog_onDismiss() {
this . setState ( { voiceTypingDialogShown : false } ) ;
}
2023-03-06 16:22:01 +02:00
public render() {
2017-07-24 23:52:30 +02:00
if ( this . state . isLoading ) {
return (
< View style = { this . styles ( ) . screen } >
2019-07-30 09:35:42 +02:00
< ScreenHeader / >
2017-07-24 23:52:30 +02:00
< / View >
) ;
}
2020-09-15 15:01:07 +02:00
const theme = themeStyle ( this . props . themeId ) ;
2021-07-13 20:13:13 +02:00
const note : NoteEntity = this . state . note ;
2017-05-24 22:51:50 +02:00
const isTodo = ! ! Number ( note . is_todo ) ;
2018-10-13 11:32:44 +02:00
if ( this . state . showCamera ) {
2020-09-15 15:01:07 +02:00
return < CameraView themeId = { this . props . themeId } style = { { flex : 1 } } onPhoto = { this . cameraView_onPhoto } onCancel = { this . cameraView_onCancel } / > ;
2023-10-02 16:15:51 +02:00
} else if ( this . state . showImageEditor ) {
return < ImageEditor
2023-11-12 17:06:16 +02:00
resourceFilename = { this . state . imageEditorResourceFilepath }
2023-10-02 16:15:51 +02:00
themeId = { this . props . themeId }
onSave = { this . onSaveDrawing }
onExit = { this . onCloseDrawing }
/ > ;
2018-10-13 11:32:44 +02:00
}
2020-10-16 17:26:19 +02:00
// Currently keyword highlighting is supported only when FTS is available.
const keywords = this . props . searchQuery && ! ! this . props . ftsEnabled ? this . props.highlightedWords : emptyArray ;
2017-07-10 23:34:26 +02:00
let bodyComponent = null ;
2022-07-23 09:31:32 +02:00
if ( this . state . mode === 'view' ) {
2018-12-29 04:12:23 +02:00
// Note: as of 2018-12-29 it's important not to display the viewer if the note body is empty,
// to avoid the HACK_webviewLoadingState related bug.
2019-07-30 09:35:42 +02:00
bodyComponent =
! note || ! note . body . trim ( ) ? null : (
< NoteBodyViewer
onJoplinLinkClick = { this . onJoplinLinkClick_ }
style = { this . styles ( ) . noteBodyViewer }
2020-03-25 12:50:45 +02:00
// Extra bottom padding to make it possible to scroll past the
// action button (so that it doesn't overlap the text)
2020-10-16 17:26:19 +02:00
paddingBottom = { 150 }
noteBody = { note . body }
noteMarkupLanguage = { note . markup_language }
2019-07-30 09:35:42 +02:00
noteResources = { this . state . noteResources }
highlightedKeywords = { keywords }
2020-09-15 15:01:07 +02:00
themeId = { this . props . themeId }
2019-09-09 19:16:00 +02:00
noteHash = { this . props . noteHash }
2020-10-16 17:26:19 +02:00
onCheckboxChange = { this . onBodyViewerCheckboxChange }
2019-07-30 09:35:42 +02:00
onMarkForDownload = { this . onMarkForDownload }
2023-10-02 16:15:51 +02:00
onRequestEditResource = { this . onEditResource }
2020-10-16 17:26:19 +02:00
onLoadEnd = { this . onBodyViewerLoadEnd }
2023-11-16 14:19:48 +02:00
onScroll = { this . onBodyViewerScroll }
initialScroll = { this . lastBodyScroll }
2024-03-20 13:01:09 +02:00
pluginStates = { this . props . plugins }
2019-07-30 09:35:42 +02:00
/ >
) ;
2017-07-10 23:34:26 +02:00
} else {
2021-07-12 15:00:51 +02:00
// Note: In theory ScrollView can be used to provide smoother scrolling of the TextInput.
// However it causes memory or rendering issues on older Android devices, probably because
// the whole text input has to be in memory for the scrollview to work. So we keep it as
// a plain TextInput for now.
// See https://github.com/laurent22/joplin/issues/3041
// IMPORTANT: The TextInput selection is unreliable and cannot be used in a controlled component
2024-02-26 12:16:23 +02:00
// context. In other words, the selection should be considered read-only. For example, if the selection
2021-07-12 15:00:51 +02:00
// is saved to the state in onSelectionChange and the current text in onChangeText, then set
// back in `selection` and `value` props, it will mostly work. But when typing fast, sooner or
// later the real selection will be different from what is stored in the state, thus making
// the cursor jump around. Eg, when typing "abcdef", it will do this:
// abcd|
// abcde|
// abcde|f
2021-07-13 20:13:13 +02:00
if ( ! this . useEditorBeta ( ) ) {
bodyComponent = (
< TextInput
autoCapitalize = "sentences"
style = { this . styles ( ) . bodyTextInput }
ref = "noteBodyTextField"
multiline = { true }
value = { note . body }
2024-03-20 13:02:10 +02:00
onChangeText = { this . onPlainEditorTextChange }
2024-02-26 12:16:23 +02:00
onSelectionChange = { this . onPlainEditorSelectionChange }
2021-07-13 20:13:13 +02:00
blurOnSubmit = { false }
selectionColor = { theme . textSelectionColor }
keyboardAppearance = { theme . keyboardAppearance }
placeholder = { _ ( 'Add body' ) }
placeholderTextColor = { theme . colorFaded }
// need some extra padding for iOS so that the keyboard won't cover last line of the note
// see https://github.com/laurent22/joplin/issues/3607
2023-11-30 01:12:34 +02:00
// Property is gone as of RN 0.72?
// paddingBottom={ (Platform.OS === 'ios' ? 40 : 0) as any}
2021-07-13 20:13:13 +02:00
/ >
) ;
} else {
2022-08-29 15:19:04 +02:00
const editorStyle = this . styles ( ) . bodyTextInput ;
2021-07-13 20:13:13 +02:00
bodyComponent = < NoteEditor
ref = { this . editorRef }
2023-03-16 13:12:56 +02:00
toolbarEnabled = { this . props . toolbarEnabled }
2021-07-13 20:13:13 +02:00
themeId = { this . props . themeId }
initialText = { note . body }
2022-04-11 12:56:45 +02:00
initialSelection = { this . selection }
2024-03-20 13:02:10 +02:00
onChange = { this . onMarkdownEditorTextChange }
2023-12-17 22:58:22 +02:00
onSelectionChange = { this . onMarkdownEditorSelectionChange }
2021-07-13 20:13:13 +02:00
onUndoRedoDepthChange = { this . onUndoRedoDepthChange }
2022-08-29 15:19:04 +02:00
onAttach = { ( ) = > this . showAttachMenu ( ) }
2023-07-16 18:42:42 +02:00
readOnly = { this . state . readOnly }
2024-03-11 17:02:15 +02:00
plugins = { this . props . plugins }
2022-08-29 15:19:04 +02:00
style = { {
. . . editorStyle ,
2024-03-11 17:02:15 +02:00
// Allow the editor to set its own padding
2022-08-29 15:19:04 +02:00
paddingLeft : 0 ,
paddingRight : 0 ,
} }
2021-07-13 20:13:13 +02:00
/ > ;
}
2017-07-10 23:34:26 +02:00
}
2017-07-14 01:35:37 +02:00
const renderActionButton = ( ) = > {
2023-06-02 16:44:00 +02:00
if ( this . state . voiceTypingDialogShown ) return null ;
2024-03-02 16:25:27 +02:00
if ( ! this . state . note || ! ! this . state . note . deleted_time ) return null ;
2023-06-02 16:44:00 +02:00
2023-01-08 14:22:41 +02:00
const editButton = {
label : _ ( 'Edit' ) ,
2023-10-07 18:25:03 +02:00
icon : 'create' ,
2017-07-14 01:35:37 +02:00
onPress : ( ) = > {
2018-03-09 22:59:12 +02:00
this . setState ( { mode : 'edit' } ) ;
2019-06-27 00:21:12 +02:00
this . doFocusUpdate_ = true ;
2017-07-14 01:35:37 +02:00
} ,
2023-01-08 14:22:41 +02:00
} ;
2017-07-14 01:35:37 +02:00
2022-07-23 09:31:32 +02:00
if ( this . state . mode === 'edit' ) return null ;
2017-07-15 01:12:32 +02:00
2023-11-26 13:37:45 +02:00
return < ActionButton mainButton = { editButton } dispatch = { this . props . dispatch } / > ;
2019-07-30 09:35:42 +02:00
} ;
2017-07-14 01:35:37 +02:00
2020-06-10 01:30:32 +02:00
// Save button is not really needed anymore with the improved save logic
2022-07-23 09:31:32 +02:00
const showSaveButton = false ; // this.state.mode === 'edit' || this.isModified() || this.saveButtonHasBeenShown_;
2020-06-13 17:20:18 +02:00
const saveButtonDisabled = true ; // !this.isModified();
2017-07-16 12:17:40 +02:00
2017-08-01 20:53:50 +02:00
const titleContainerStyle = isTodo ? this . styles ( ) . titleContainerTodo : this.styles ( ) . titleContainer ;
2019-07-12 20:36:12 +02:00
const dueDate = Note . dueDateObject ( note ) ;
2017-09-10 18:56:27 +02:00
2017-07-31 19:56:14 +02:00
const titleComp = (
< View style = { titleContainerStyle } >
2019-07-30 09:35:42 +02:00
{ isTodo && < Checkbox style = { this . styles ( ) . checkbox } checked = { ! ! Number ( note . todo_completed ) } onChange = { this . todoCheckbox_change } / > }
2020-06-13 17:20:18 +02:00
< TextInput
2023-12-08 12:12:23 +02:00
ref = { this . titleTextFieldRef }
2020-06-13 17:20:18 +02:00
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 }
2023-07-16 18:42:42 +02:00
editable = { ! this . state . readOnly }
2020-06-13 17:20:18 +02:00
/ >
2017-07-31 19:56:14 +02:00
< / View >
) ;
2019-07-30 09:35:42 +02:00
const noteTagDialog = ! this . state . noteTagDialogShown ? null : < NoteTagsDialog onCloseRequested = { this . noteTagDialog_closeRequested } / > ;
2018-03-18 01:00:01 +02:00
2023-05-07 13:05:41 +02:00
const renderVoiceTypingDialog = ( ) = > {
if ( ! this . state . voiceTypingDialogShown ) return null ;
2023-06-13 19:06:54 +02:00
return < VoiceTypingDialog locale = { currentLocale ( ) } onText = { this . voiceTypingDialog_onText } onDismiss = { this . voiceTypingDialog_onDismiss } / > ;
2023-05-07 13:05:41 +02:00
} ;
2017-05-12 22:23:54 +02:00
return (
2020-09-15 15:01:07 +02:00
< View style = { this . rootStyle ( this . props . themeId ) . root } >
2020-06-13 17:20:18 +02:00
< ScreenHeader
folderPickerOptions = { this . folderPickerOptions ( ) }
menuOptions = { this . menuOptions ( ) }
showSaveButton = { showSaveButton }
saveButtonDisabled = { saveButtonDisabled }
onSaveButtonPress = { this . saveNoteButton_press }
showSideMenuButton = { false }
showSearchButton = { false }
2022-05-10 11:23:36 +02:00
showUndoButton = { ( this . state . undoRedoButtonState . canUndo || this . state . undoRedoButtonState . canRedo ) && this . state . mode === 'edit' }
showRedoButton = { this . state . undoRedoButtonState . canRedo && this . state . mode === 'edit' }
2020-06-13 17:20:18 +02:00
undoButtonDisabled = { ! this . state . undoRedoButtonState . canUndo && this . state . undoRedoButtonState . canRedo }
onUndoButtonPress = { this . screenHeader_undoButtonPress }
onRedoButtonPress = { this . screenHeader_redoButtonPress }
2024-03-02 16:25:27 +02:00
title = { getDisplayParentTitle ( this . state . note , this . state . folder ) }
2020-06-13 17:20:18 +02:00
/ >
2022-09-30 12:46:26 +02:00
{ titleComp }
{ bodyComponent }
2023-06-02 16:44:00 +02:00
{ renderActionButton ( ) }
{ renderVoiceTypingDialog ( ) }
2019-07-30 09:35:42 +02:00
2020-10-16 17:26:19 +02:00
< SelectDateTimeDialog themeId = { this . props . themeId } shown = { this . state . alarmDialogShown } date = { dueDate } onAccept = { this . onAlarmDialogAccept } onReject = { this . onAlarmDialogReject } / >
2020-06-13 17:20:59 +02:00
< DialogBox
2020-11-12 21:13:28 +02:00
ref = { ( dialogbox : any ) = > {
2020-06-13 17:20:59 +02:00
this . dialogbox = dialogbox ;
} }
/ >
2019-07-30 09:35:42 +02:00
{ noteTagDialog }
2018-02-05 20:32:59 +02:00
< / View >
2017-05-12 22:23:54 +02:00
) ;
}
}
2024-03-02 16:25:27 +02:00
const NoteScreen = connect ( ( state : AppState ) = > {
2019-07-30 09:35:42 +02:00
return {
noteId : state.selectedNoteIds.length ? state . selectedNoteIds [ 0 ] : null ,
2019-09-09 19:16:00 +02:00
noteHash : state.selectedNoteHash ,
2019-07-30 09:35:42 +02:00
folderId : state.selectedFolderId ,
itemType : state.selectedItemType ,
folders : state.folders ,
searchQuery : state.searchQuery ,
2020-09-15 15:01:07 +02:00
themeId : state.settings.theme ,
2019-09-17 22:32:00 +02:00
editorFont : [ state . settings [ 'style.editor.fontFamily' ] ] ,
2023-01-07 19:47:52 +02:00
editorFontSize : state.settings [ 'style.editor.fontSize' ] ,
2023-03-16 13:12:56 +02:00
toolbarEnabled : state.settings [ 'editor.mobile.toolbarEnabled' ] ,
2019-07-30 09:35:42 +02:00
ftsEnabled : state.settings [ 'db.ftsEnabled' ] ,
sharedData : state.sharedData ,
showSideMenu : state.showSideMenu ,
2020-03-04 03:13:10 +02:00
provisionalNoteIds : state.provisionalNoteIds ,
2020-09-06 14:07:00 +02:00
highlightedWords : state.highlightedWords ,
2024-03-11 17:02:15 +02:00
plugins : state.pluginService.plugins ,
2023-05-07 13:05:41 +02:00
// What we call "beta editor" in this component is actually the (now
// default) CodeMirror editor. That should be refactored to make it less
// confusing.
2023-01-03 21:48:33 +02:00
useEditorBeta : ! state . settings [ 'editor.usePlainText' ] ,
2019-07-30 09:35:42 +02:00
} ;
} ) ( NoteScreenComponent ) ;
2018-03-09 22:59:12 +02:00
2020-10-16 17:26:19 +02:00
export default NoteScreen ;